openvibers/openvibe.exchange Commits
Owner console: monthly provider credit plans (#242)
Changes
@@@ -312,6 +312,29 @@ function createConsoleRoutes({ config, ai }) {return res.redirect(303, '/console/providers');} catch (err) { return page(req, res, 'providers', {}, err.message || 'The credit record could not be saved.', 502); }});++ r.post('/console/provider-credit-plans', async (req, res) => {++ if (!sameOrigin(req)) return res.sendStatus(403);++ try {++ await ai.adminRequest('/api/v1/admin/console/provider-credit-plans', { method: 'POST', subject: req.viewer.subject, body: {++ provider: String(req.body.provider || ''),++ label: String(req.body.label || ''),++ monthly_usd: Number(req.body.monthly_usd),++ renews_day: Number(req.body.renews_day),++ starts_on: String(req.body.starts_on || ''),++ ends_on: req.body.ends_on ? String(req.body.ends_on) : null,++ prorate_first: req.body.prorate_first === '1',++ notes: req.body.notes ? String(req.body.notes) : null,++ } });++ return res.redirect(303, '/console/providers');++ } catch (err) { return page(req, res, 'providers', {}, err.message || 'The plan could not be saved.', 502); }++ });++ r.post('/console/provider-credit-plans/:id', async (req, res) => {++ if (!sameOrigin(req)) return res.sendStatus(403);++ try {++ await ai.adminRequest(`/api/v1/admin/console/provider-credit-plans/${encodeURIComponent(req.params.id)}`, { method: 'POST', subject: req.viewer.subject, body: { active: req.body.active === '1' } });++ return res.redirect(303, '/console/providers');++ } catch (err) { return page(req, res, 'providers', {}, err.message || 'The plan could not be changed.', 502); }++ });r.post('/console/provider-credit-preference', async (req, res) => {if (!sameOrigin(req)) return res.sendStatus(403);try {@@@ -119,6 +119,48 @@ function presetCard(p) {</li>`;}++// Monthly credits that come with a plan. The plan is the owner's: a monthly amount, the day it renews, and the++// cycle it is in. The cycle's remaining credit comes from AI's estimate, never from a local sum.++function creditPlansPanel(data = {}) {++ const estimates = Array.isArray(data.credit_estimates) ? data.credit_estimates : [];++ const providers = Array.isArray(data.providers) ? data.providers : [];++ const keys = [...new Set([++ ...providers.map((p) => p.key), ...estimates.map((e) => e.provider), 'anthropic', 'openai',++ ].filter(Boolean))];++ const usd = (v) => { const n = price(v); return n == null ? null : `$${n.toFixed(2)}`; };++ const plans = estimates.flatMap((e) => (Array.isArray(e.plans) ? e.plans : []).map((plan) => ({++ ...plan, provider: e.provider, cycles: Array.isArray(e.estimate && e.estimate.plans) ? e.estimate.plans : [],++ })));++ const row = (plan) => {++ const cycle = plan.cycles.find((c) => String(c.applies_to || '').startsWith(`plan:${plan.id}:`)) || null;++ const remaining = cycle ? usd(cycle.remaining_usd) : null;++ const left = cycle++ ? `${remaining ? `${remaining} left` : 'Remaining credit not reported'} this cycle${cycle.expires_at ? ` · ends ${day(cycle.expires_at)}` : ''}`++ : 'No credit for this cycle yet.';++ return `<li class="ex-console-action">++ <p><b>${esc(plan.label)}</b> · ${esc(plan.provider)} · ${esc(usd(plan.monthly_usd) || '–')} a month · renews on day ${esc(plan.renews_day)}</p>++ <p>${esc(left)}</p>++ <form method="post" action="/console/provider-credit-plans/${encodeURIComponent(plan.id)}">++ <label class="exc-check"><input type="checkbox" name="active" value="1" role="switch"${plan.active ? ' checked' : ''}><span>Plan on</span></label>++ <button class="ovk-btn ovk-btn--quiet" type="submit">Save</button>++ </form>++</li>`;++ };++ return `<form class="ovk-form ex-console-quota" method="post" action="/console/provider-credit-plans">++ <h3>Monthly credits from a plan</h3>++ <label>Provider<select name="provider" required>${keys.map((k) => `<option value="${esc(k)}">${esc(k)}</option>`).join('')}</select></label>++ <label>Label<input name="label" required maxlength="120" placeholder="e.g. ChatGPT Pro API credits"></label>++ <label>Amount per month (USD)<input name="monthly_usd" type="number" min="0" step="0.01" required></label>++ <label>Renews on day (1-31)<input name="renews_day" type="number" min="1" max="31" step="1" required></label>++ <label>Starts on<input name="starts_on" type="date" required></label>++ <label>Ends on (optional)<input name="ends_on" type="date"></label>++ <label class="exc-check"><input name="prorate_first" type="checkbox" value="1" checked><span>Prorate the first month</span></label>++ <label>Notes<input name="notes" maxlength="500"></label>++ <button class="ovk-btn ovk-btn--primary" type="submit">Save plan</button>++</form>++${plans.length ? `<ul class="exc-list">${plans.map(row).join('')}</ul>` : '<p class="exc-note">No monthly plan recorded yet.</p>'}`;++}++function providersSection(data = {}) {const providers = Array.isArray(data.providers) ? data.providers : [];const models = Array.isArray(data.models) ? data.models : [];@@@ -142,6 +184,7 @@ function providersSection(data = {}) {<header class="exc-head"><h2>Providers</h2><p>Who the Exchange routes to, in what order and at what markup. A key is stored sealed and never shown; "Not verified" providers serve people's own keys until a resale agreement is on file.</p>${stats.length ? `<dl class="exc-stats">${stats.map(([n, l]) => `<div><dt>${esc(l)}</dt><dd>${esc(n)}</dd></div>`).join('')}</dl>` : ''}</header><section class="exc-sec"><h3>On the Exchange <span class="exc-n">${providers.length}</span></h3>${providers.length ? `<ul class="exc-list">${ordered.map((p) => providerCard(p, models)).join('')}</ul>` : '<p class="exc-note">No provider yet. Add one below.</p>'}</section>++<section class="exc-sec" id="credit-plans">${creditPlansPanel(data)}</section>${presets.length ? `<section class="exc-sec"><h3>Ready to add <span class="exc-n">${presets.length}</span></h3><p class="exc-note">Cheapest first, from each provider's own price page. Add one with its key and its models arrive with these prices.</p><ul class="exc-grid">${presets.map(presetCard).join('')}</ul></section>` : ''}<section class="exc-sec">${custom}</section></div>`;@@@ -473,4 +516,4 @@ ${recent.length ? `<table class="ovk-table"><thead><tr><th scope="col">When</th>}const SECTIONS = { providers: providersSection, models: modelsSection, pricebook: pricebookSection, supply: supplySection, sync: syncSection, orientation: orientationSection };--module.exports = { providersSection, modelsSection, pricebookSection, supplySection, gpuPolicySection, syncSection, orientationSection, SECTIONS, CSS: marks.CSS };++module.exports = { providersSection, modelsSection, pricebookSection, supplySection, gpuPolicySection, syncSection, orientationSection, creditPlansPanel, SECTIONS, CSS: marks.CSS };@@@ -31,6 +31,7 @@ function consolePage({ section = 'overview', data = {}, error = null }) {}).join('');if (section === 'providers') actions += `<form class="ovk-form ex-console-action" method="post" action="/console/providers"><h3>Add provider</h3><label>Preset<select name="preset"><option value="custom">OpenAI-compatible</option><option value="anthropic">Anthropic</option><option value="openai">OpenAI</option></select></label><label>Provider ID<input name="key" required minlength="2" maxlength="41" pattern="[a-z0-9][a-z0-9._-]+"></label><label>Display name<input name="display_name" maxlength="80"></label><label>Base URL<input name="base_url" type="url" required placeholder="https://api.example.com/v1"></label><label>Default model<input name="default_model" maxlength="256"></label><label>Provider API key<input name="api_key" type="password" autocomplete="new-password" required minlength="8" maxlength="400"></label><label><input name="vision" type="checkbox" value="1"> Supports vision</label><button class="ovk-btn ovk-btn--primary" type="submit">Add provider</button></form><form class="ovk-form ex-console-quota" method="post" action="/console/provider-credits"><h3>Record prepaid credit</h3><label>Provider<select name="provider"><option>anthropic</option><option>openai</option></select></label><label>Kind<select name="kind"><option>promotional</option><option>purchased</option></select></label><label>Amount USD<input name="amount_usd" type="number" min="0" step="0.01" required></label><label>As of<input name="as_of" type="date" required></label><label>Remaining USD<input name="remaining_as_of_usd" type="number" min="0" step="0.01"></label><label>Applies to<input name="applies_to" maxlength="200"></label><button class="ovk-btn ovk-btn--primary" type="submit">Save credit</button></form><form class="ovk-form ex-console-action" method="post" action="/console/provider-credit-preference"><h3>Promotional credit preference</h3><label>Provider<select name="provider"><option>anthropic</option><option>openai</option></select></label><label>Use promotional credit first<select name="enabled"><option value="1">Yes</option><option value="0">No</option></select></label><label>Minimum remaining USD<input name="floor_usd" type="number" min="0" step="0.01" value="5"></label><button class="ovk-btn ovk-btn--quiet" type="submit">Save preference</button></form><form class="ovk-form ex-console-action" method="post" action="/console/provider-session-credentials"><h3>Provider account session</h3><label>Provider<select name="provider"><option>anthropic</option><option>openai</option></select></label><label>Session cookie<input name="cookie" type="password" autocomplete="new-password" required minlength="16" maxlength="4096"></label><label>Label<input name="label" maxlength="80"></label><label>Anthropic organization ID<input name="org_id" placeholder="UUID"></label><button class="ovk-btn ovk-btn--quiet" type="submit">Save session</button></form>`;if (section === 'providers' && Array.isArray(data.session_credentials)) actions += data.session_credentials.map((c) => `<form class="ex-console-action" method="post" action="/console/provider-session-credentials/${encodeURIComponent(c.id)}/remove"><span>${esc(c.provider)} · ${esc(c.label || c.id)} · ${c.expired ? 'expired' : 'saved'}</span><button class="ovk-btn ovk-btn--quiet" type="submit">Remove session</button></form>`).join('');++ if (section === 'providers') actions += designed.creditPlansPanel(data);if (section === 'models' && Array.isArray(data.models)) actions = data.models.map((m) => `<form class="ex-console-action" method="post" action="/console/models/${encodeURIComponent(m.provider)}/${encodeURIComponent(m.model || m.id)}/status"><label>Status for ${esc(m.provider)}/${esc(m.model || m.id)} <select name="status"><option value="active">Active</option><option value="disabled">Disabled</option></select></label><button class="ovk-btn ovk-btn--quiet" type="submit">Save status</button></form>`).join('');if (section === 'workflows' && Array.isArray(data.workflows)) actions = data.workflows.flatMap((w) => (w.versions || []).map((v) => `<form class="ex-console-action" method="post" action="/console/workflows/${encodeURIComponent(w.key)}/versions/${encodeURIComponent(v.version)}/status"><label>Status for ${esc(w.key)} v${esc(v.version)} <select name="status"><option value="active">Active</option><option value="disabled">Disabled</option></select></label><button class="ovk-btn ovk-btn--quiet" type="submit">Save status</button></form>`)).join('');if (section === 'workflows' && data.workflow && Array.isArray(data.versions)) actions = data.versions.map((v) => `<form class="ex-console-action" method="post" action="/console/workflows/${encodeURIComponent(data.workflow.key)}/versions/${encodeURIComponent(v.version)}/status"><label>Status for ${esc(data.workflow.key)} v${esc(v.version)} <select name="status"><option value="active">Active</option><option value="disabled">Disabled</option></select></label><button class="ovk-btn ovk-btn--quiet" type="submit">Save status</button></form>`).join('');@@@ -38,6 +38,19 @@ assert.ok(html.includes('No price confirmed on their own page yet.'), 'a missingassert.ok(!/\$0(?!\.)/.test(html), 'a missing price is never $0');assert.ok(html.includes('From <b>$0.08</b>') && html.includes('href="https://cheap.example/pricing"'), 'the cheapest price with its own page');assert.ok(!html.includes('ovk-button'), 'kit buttons only');++assert.ok(html.includes('Monthly credits from a plan') && html.includes('action="/console/provider-credit-plans"'), 'the plan form sits in the providers section');++assert.ok(html.includes('<option value="anthropic">') && html.includes('<option value="openai">'), 'the plan picker always offers Anthropic and OpenAI');++assert.ok(html.includes('name="prorate_first" type="checkbox" value="1" checked'), 'the first month is prorated by default');++const planned = providersSection({ providers, models: [], presets: [], credit_estimates: [++ { provider: 'openai', plans: [{ id: 7, label: 'ChatGPT Pro API credits', monthly_usd: 100, renews_day: 1, starts_on: '2026-11-12', ends_on: null, active: true, notes: null }],++ estimate: { plans: [{ applies_to: 'plan:7:2026-11-01', amount_usd: 100, used_usd: 25, remaining_usd: 75, expires_at: '2026-12-01T00:00:00.000Z' }] } },++ { provider: 'altare', plans: [{ id: 8, label: 'Altare free monthly credits', monthly_usd: 100, renews_day: 1, starts_on: '2026-12-01', ends_on: null, active: false, notes: null }],++ estimate: { plans: [] } },++] });++assert.ok(planned.includes('ChatGPT Pro API credits') && planned.includes('$100.00 a month') && planned.includes('renews on day 1'), 'a plan lists its label, amount and renewal day');++assert.ok(planned.includes('$75.00 left this cycle') && planned.includes('ends 2026-12-01'), 'the cycle remaining comes from the estimate');++assert.ok(planned.includes('action="/console/provider-credit-plans/7"') && planned.includes('role="switch"'), 'a plan has its own on/off switch');++assert.ok(planned.includes('No credit for this cycle yet.'), 'a plan with no cycle yet says so');const models = modelsSection({ models: [{ provider: 'openai', model: 'gpt-5-mini', accepted_price: { in_per_mtok: 0.25, out_per_mtok: 2 }, owner_override: { cost: { in_per_mtok: 0.2, out_per_mtok: 1.6 }, reason: 'promo' } },@@@ -27,6 +27,16 @@ app.use(createConsoleRoutes({],};if (path === '/api/v1/admin/console/gpus') return { state: { enabled: true, reason: '', dry_run: true, cap_nano: '50000000000', spent_nano: '1000000000', committed_nano: '2000000000', by_class: { interactive: '1000000000' }, instances: [] } };++ if (path === '/api/v1/admin/console/provider-credit-plans' && options.body && options.body.label === 'fail') throw Object.assign(new Error('provider credit plans are not configured'), { status: 503 });++ if (path === '/api/v1/admin/console/providers') return {++ providers: [{ key: 'openai', status: 'active', api_key: fakeSecret }],++ credit_estimates: [++ { provider: 'openai', plans: [{ id: 7, label: 'ChatGPT Pro API credits', monthly_usd: 100, renews_day: 1, starts_on: '2026-11-12', ends_on: null, active: true, notes: 'API credits' }],++ estimate: { plans: [{ applies_to: 'plan:7:2026-11-01', amount_usd: 100, used_usd: 25, remaining_usd: 75, expires_at: '2026-12-01T00:00:00.000Z' }] } },++ { provider: 'altare', plans: [{ id: 8, label: 'Altare free monthly credits', monthly_usd: 100, renews_day: 1, starts_on: '2026-12-01', ends_on: null, active: false, notes: null }],++ estimate: { plans: [] } },++ ],++ };return { providers: [{ key: 'openai', status: 'active', api_key: fakeSecret }] };} },}));@@@ -46,6 +56,13 @@ app.use(createConsoleRoutes({assert.strictEqual(response.status, 200, html);assert.ok(html.includes('AI operator console'));assert.ok(!html.includes(fakeSecret), 'admin JSON values that resemble secrets never reach page HTML');++ assert.ok(html.includes('Monthly credits from a plan') && html.includes('action="/console/provider-credit-plans"'), 'the plan form is in the providers section');++ assert.ok(html.includes('name="monthly_usd"') && html.includes('name="renews_day"') && html.includes('name="starts_on"') && html.includes('name="ends_on"'), 'a plan records its amount and its cycle');++ assert.ok(html.includes('name="prorate_first" type="checkbox" value="1" checked'), 'the first month is prorated by default');++ assert.ok(html.includes('ChatGPT Pro API credits') && html.includes('$100.00 a month') && html.includes('renews on day 1'), 'a recorded plan shows its label, amount and renewal day');++ assert.ok(html.includes('$75.00 left this cycle') && html.includes('ends 2026-12-01'), 'the cycle remaining comes from the estimate');++ assert.ok(html.includes('action="/console/provider-credit-plans/7"') && html.includes('role="switch"'), 'a plan has its own on/off switch');++ assert.ok(html.includes('No credit for this cycle yet.'), 'a plan with no cycle yet says so');assert.deepStrictEqual(calls, [READS.providers]);for (const path of consolePaths) {const page = await fetch(`${base}${path}`, { headers: { 'x-test-role': 'owner' } });@@@ -141,6 +158,46 @@ app.use(createConsoleRoutes({});assert.strictEqual(providerSettings.status, 303);assert.ok(writes.some((write) => write.path === '/api/v1/providers/groq' && write.body.markup_percent === 12.5 && write.body.uncensored === true));++++ for (const role of ['user', 'admin']) {++ const count = writes.length;++ for (const path of ['/console/provider-credit-plans', '/console/provider-credit-plans/7']) {++ const denied = await fetch(`${base}${path}`, { method: 'POST', headers: { 'x-test-role': role, Origin: 'https://openvibe.exchange' } });++ assert.strictEqual(denied.status, 403, `${role} cannot record a provider credit plan`);++ }++ assert.strictEqual(writes.length, count, 'non-owners cannot reach the AI service');++ }++ const planCsrf = await fetch(`${base}/console/provider-credit-plans`, { method: 'POST', headers: { 'x-test-role': 'owner', Origin: 'https://evil.example' } });++ assert.strictEqual(planCsrf.status, 403);++ const planWrite = await fetch(`${base}/console/provider-credit-plans`, {++ method: 'POST', headers: { 'x-test-role': 'owner', Origin: 'https://openvibe.exchange', 'content-type': 'application/x-www-form-urlencoded' },++ body: new URLSearchParams({ provider: 'openai', label: 'ChatGPT Pro API credits', monthly_usd: '100', renews_day: '1', starts_on: '2026-11-12', ends_on: '', prorate_first: '1', notes: 'API credits' }),++ redirect: 'manual',++ });++ assert.strictEqual(planWrite.status, 303);++ assert.strictEqual(planWrite.headers.get('location'), '/console/providers');++ assert.deepStrictEqual(writes.at(-1), { path: '/api/v1/admin/console/provider-credit-plans', method: 'POST', subject: 'usr_01JAB2C3D4E5F6G7H8J9K0MNPX', body: {++ provider: 'openai', label: 'ChatGPT Pro API credits', monthly_usd: 100, renews_day: 1, starts_on: '2026-11-12', ends_on: null, prorate_first: true, notes: 'API credits',++ } });++ const planOn = await fetch(`${base}/console/provider-credit-plans/7`, {++ method: 'POST', headers: { 'x-test-role': 'owner', Origin: 'https://openvibe.exchange', 'content-type': 'application/x-www-form-urlencoded' },++ body: new URLSearchParams({ active: '1' }), redirect: 'manual',++ });++ assert.strictEqual(planOn.status, 303);++ assert.deepStrictEqual(writes.at(-1), { path: '/api/v1/admin/console/provider-credit-plans/7', method: 'POST', subject: 'usr_01JAB2C3D4E5F6G7H8J9K0MNPX', body: { active: true } });++ const planOff = await fetch(`${base}/console/provider-credit-plans/7`, {++ method: 'POST', headers: { 'x-test-role': 'owner', Origin: 'https://openvibe.exchange', 'content-type': 'application/x-www-form-urlencoded' },++ body: new URLSearchParams({}), redirect: 'manual',++ });++ assert.strictEqual(planOff.status, 303);++ assert.deepStrictEqual(writes.at(-1).body, { active: false }, 'an unchecked switch turns the plan off');++ const planError = await fetch(`${base}/console/provider-credit-plans`, {++ method: 'POST', headers: { 'x-test-role': 'owner', Origin: 'https://openvibe.exchange', 'content-type': 'application/x-www-form-urlencoded' },++ body: new URLSearchParams({ provider: 'openai', label: 'fail', monthly_usd: '100', renews_day: '1', starts_on: '2026-11-12' }),++ });++ const planErrorHtml = await planError.text();++ assert.strictEqual(planError.status, 502);++ assert.ok(planErrorHtml.includes('provider credit plans are not configured'), 'the AI error is shown on the page');const loadConfirmed = await fetch(`${base}/console/pricebook/confirmed`, {method: 'POST', headers: { 'x-test-role': 'owner', Origin: 'https://openvibe.exchange' }, redirect: 'manual',});
@@ -312,6 +312,29 @@ function createConsoleRoutes({ config, ai }) {
@@ -312,6 +312,29 @@ function createConsoleRoutes({ config, ai }) {
return res.redirect(303, '/console/providers');
return res.redirect(303, '/console/providers');
} catch (err) { return page(req, res, 'providers', {}, err.message || 'The credit record could not be saved.', 502); }
} catch (err) { return page(req, res, 'providers', {}, err.message || 'The credit record could not be saved.', 502); }
});
});
+ r.post('/console/provider-credit-plans', async (req, res) => {
+ if (!sameOrigin(req)) return res.sendStatus(403);
+ try {
+ await ai.adminRequest('/api/v1/admin/console/provider-credit-plans', { method: 'POST', subject: req.viewer.subject, body: {
+ provider: String(req.body.provider || ''),
+ label: String(req.body.label || ''),
+ monthly_usd: Number(req.body.monthly_usd),
+ renews_day: Number(req.body.renews_day),
+ starts_on: String(req.body.starts_on || ''),
+ ends_on: req.body.ends_on ? String(req.body.ends_on) : null,
+ prorate_first: req.body.prorate_first === '1',
+ notes: req.body.notes ? String(req.body.notes) : null,
+ } });
+ return res.redirect(303, '/console/providers');
+ } catch (err) { return page(req, res, 'providers', {}, err.message || 'The plan could not be saved.', 502); }
+ });
+ r.post('/console/provider-credit-plans/:id', async (req, res) => {
+ if (!sameOrigin(req)) return res.sendStatus(403);
+ try {
+ await ai.adminRequest(`/api/v1/admin/console/provider-credit-plans/${encodeURIComponent(req.params.id)}`, { method: 'POST', subject: req.viewer.subject, body: { active: req.body.active === '1' } });
+ return res.redirect(303, '/console/providers');
+ } catch (err) { return page(req, res, 'providers', {}, err.message || 'The plan could not be changed.', 502); }
+ });
r.post('/console/provider-credit-preference', async (req, res) => {
r.post('/console/provider-credit-preference', async (req, res) => {
if (!sameOrigin(req)) return res.sendStatus(403);
if (!sameOrigin(req)) return res.sendStatus(403);
try {
try {
@@ -119,6 +119,48 @@ function presetCard(p) {
@@ -119,6 +119,48 @@ function presetCard(p) {
</li>`;
</li>`;
}
}
+// Monthly credits that come with a plan. The plan is the owner's: a monthly amount, the day it renews, and the
+// cycle it is in. The cycle's remaining credit comes from AI's estimate, never from a local sum.
+function creditPlansPanel(data = {}) {
+ const estimates = Array.isArray(data.credit_estimates) ? data.credit_estimates : [];
+ const providers = Array.isArray(data.providers) ? data.providers : [];
+ const keys = [...new Set([
+ ...providers.map((p) => p.key), ...estimates.map((e) => e.provider), 'anthropic', 'openai',
+ ].filter(Boolean))];
+ const usd = (v) => { const n = price(v); return n == null ? null : `$${n.toFixed(2)}`; };
+ const plans = estimates.flatMap((e) => (Array.isArray(e.plans) ? e.plans : []).map((plan) => ({
+ ...plan, provider: e.provider, cycles: Array.isArray(e.estimate && e.estimate.plans) ? e.estimate.plans : [],
+ })));
+ const row = (plan) => {
+ const cycle = plan.cycles.find((c) => String(c.applies_to || '').startsWith(`plan:${plan.id}:`)) || null;
+ const remaining = cycle ? usd(cycle.remaining_usd) : null;
+ const left = cycle
+ ? `${remaining ? `${remaining} left` : 'Remaining credit not reported'} this cycle${cycle.expires_at ? ` · ends ${day(cycle.expires_at)}` : ''}`
+ : 'No credit for this cycle yet.';
+ return `<li class="ex-console-action">
+ <p><b>${esc(plan.label)}</b> · ${esc(plan.provider)} · ${esc(usd(plan.monthly_usd) || '–')} a month · renews on day ${esc(plan.renews_day)}</p>
+ <p>${esc(left)}</p>
+ <form method="post" action="/console/provider-credit-plans/${encodeURIComponent(plan.id)}">
+ <label class="exc-check"><input type="checkbox" name="active" value="1" role="switch"${plan.active ? ' checked' : ''}><span>Plan on</span></label>
+ <button class="ovk-btn ovk-btn--quiet" type="submit">Save</button>
+ </form>
+</li>`;
+ };
+ return `<form class="ovk-form ex-console-quota" method="post" action="/console/provider-credit-plans">
+ <h3>Monthly credits from a plan</h3>
+ <label>Provider<select name="provider" required>${keys.map((k) => `<option value="${esc(k)}">${esc(k)}</option>`).join('')}</select></label>
+ <label>Label<input name="label" required maxlength="120" placeholder="e.g. ChatGPT Pro API credits"></label>
+ <label>Amount per month (USD)<input name="monthly_usd" type="number" min="0" step="0.01" required></label>
+ <label>Renews on day (1-31)<input name="renews_day" type="number" min="1" max="31" step="1" required></label>
+ <label>Starts on<input name="starts_on" type="date" required></label>
+ <label>Ends on (optional)<input name="ends_on" type="date"></label>
+ <label class="exc-check"><input name="prorate_first" type="checkbox" value="1" checked><span>Prorate the first month</span></label>
+ <label>Notes<input name="notes" maxlength="500"></label>
+ <button class="ovk-btn ovk-btn--primary" type="submit">Save plan</button>
+</form>
+${plans.length ? `<ul class="exc-list">${plans.map(row).join('')}</ul>` : '<p class="exc-note">No monthly plan recorded yet.</p>'}`;
+}
+
function providersSection(data = {}) {
function providersSection(data = {}) {
const providers = Array.isArray(data.providers) ? data.providers : [];
const providers = Array.isArray(data.providers) ? data.providers : [];
const models = Array.isArray(data.models) ? data.models : [];
const models = Array.isArray(data.models) ? data.models : [];
@@ -142,6 +184,7 @@ function providersSection(data = {}) {
@@ -142,6 +184,7 @@ function providersSection(data = {}) {
<header class="exc-head"><h2>Providers</h2><p>Who the Exchange routes to, in what order and at what markup. A key is stored sealed and never shown; "Not verified" providers serve people's own keys until a resale agreement is on file.</p>
<header class="exc-head"><h2>Providers</h2><p>Who the Exchange routes to, in what order and at what markup. A key is stored sealed and never shown; "Not verified" providers serve people's own keys until a resale agreement is on file.</p>
${stats.length ? `<dl class="exc-stats">${stats.map(([n, l]) => `<div><dt>${esc(l)}</dt><dd>${esc(n)}</dd></div>`).join('')}</dl>` : ''}</header>
${stats.length ? `<dl class="exc-stats">${stats.map(([n, l]) => `<div><dt>${esc(l)}</dt><dd>${esc(n)}</dd></div>`).join('')}</dl>` : ''}</header>
<section class="exc-sec"><h3>On the Exchange <span class="exc-n">${providers.length}</span></h3>${providers.length ? `<ul class="exc-list">${ordered.map((p) => providerCard(p, models)).join('')}</ul>` : '<p class="exc-note">No provider yet. Add one below.</p>'}</section>
<section class="exc-sec"><h3>On the Exchange <span class="exc-n">${providers.length}</span></h3>${providers.length ? `<ul class="exc-list">${ordered.map((p) => providerCard(p, models)).join('')}</ul>` : '<p class="exc-note">No provider yet. Add one below.</p>'}</section>
+<section class="exc-sec" id="credit-plans">${creditPlansPanel(data)}</section>
${presets.length ? `<section class="exc-sec"><h3>Ready to add <span class="exc-n">${presets.length}</span></h3><p class="exc-note">Cheapest first, from each provider's own price page. Add one with its key and its models arrive with these prices.</p><ul class="exc-grid">${presets.map(presetCard).join('')}</ul></section>` : ''}
${presets.length ? `<section class="exc-sec"><h3>Ready to add <span class="exc-n">${presets.length}</span></h3><p class="exc-note">Cheapest first, from each provider's own price page. Add one with its key and its models arrive with these prices.</p><ul class="exc-grid">${presets.map(presetCard).join('')}</ul></section>` : ''}
<section class="exc-sec">${custom}</section>
<section class="exc-sec">${custom}</section>
</div>`;
</div>`;
@@ -473,4 +516,4 @@ ${recent.length ? `<table class="ovk-table"><thead><tr><th scope="col">When</th>
@@ -473,4 +516,4 @@ ${recent.length ? `<table class="ovk-table"><thead><tr><th scope="col">When</th>
}
}
const SECTIONS = { providers: providersSection, models: modelsSection, pricebook: pricebookSection, supply: supplySection, sync: syncSection, orientation: orientationSection };
const SECTIONS = { providers: providersSection, models: modelsSection, pricebook: pricebookSection, supply: supplySection, sync: syncSection, orientation: orientationSection };
-module.exports = { providersSection, modelsSection, pricebookSection, supplySection, gpuPolicySection, syncSection, orientationSection, SECTIONS, CSS: marks.CSS };
+module.exports = { providersSection, modelsSection, pricebookSection, supplySection, gpuPolicySection, syncSection, orientationSection, creditPlansPanel, SECTIONS, CSS: marks.CSS };
@@ -31,6 +31,7 @@ function consolePage({ section = 'overview', data = {}, error = null }) {
@@ -31,6 +31,7 @@ function consolePage({ section = 'overview', data = {}, error = null }) {
}).join('');
}).join('');
if (section === 'providers') actions += `<form class="ovk-form ex-console-action" method="post" action="/console/providers"><h3>Add provider</h3><label>Preset<select name="preset"><option value="custom">OpenAI-compatible</option><option value="anthropic">Anthropic</option><option value="openai">OpenAI</option></select></label><label>Provider ID<input name="key" required minlength="2" maxlength="41" pattern="[a-z0-9][a-z0-9._-]+"></label><label>Display name<input name="display_name" maxlength="80"></label><label>Base URL<input name="base_url" type="url" required placeholder="https://api.example.com/v1"></label><label>Default model<input name="default_model" maxlength="256"></label><label>Provider API key<input name="api_key" type="password" autocomplete="new-password" required minlength="8" maxlength="400"></label><label><input name="vision" type="checkbox" value="1"> Supports vision</label><button class="ovk-btn ovk-btn--primary" type="submit">Add provider</button></form><form class="ovk-form ex-console-quota" method="post" action="/console/provider-credits"><h3>Record prepaid credit</h3><label>Provider<select name="provider"><option>anthropic</option><option>openai</option></select></label><label>Kind<select name="kind"><option>promotional</option><option>purchased</option></select></label><label>Amount USD<input name="amount_usd" type="number" min="0" step="0.01" required></label><label>As of<input name="as_of" type="date" required></label><label>Remaining USD<input name="remaining_as_of_usd" type="number" min="0" step="0.01"></label><label>Applies to<input name="applies_to" maxlength="200"></label><button class="ovk-btn ovk-btn--primary" type="submit">Save credit</button></form><form class="ovk-form ex-console-action" method="post" action="/console/provider-credit-preference"><h3>Promotional credit preference</h3><label>Provider<select name="provider"><option>anthropic</option><option>openai</option></select></label><label>Use promotional credit first<select name="enabled"><option value="1">Yes</option><option value="0">No</option></select></label><label>Minimum remaining USD<input name="floor_usd" type="number" min="0" step="0.01" value="5"></label><button class="ovk-btn ovk-btn--quiet" type="submit">Save preference</button></form><form class="ovk-form ex-console-action" method="post" action="/console/provider-session-credentials"><h3>Provider account session</h3><label>Provider<select name="provider"><option>anthropic</option><option>openai</option></select></label><label>Session cookie<input name="cookie" type="password" autocomplete="new-password" required minlength="16" maxlength="4096"></label><label>Label<input name="label" maxlength="80"></label><label>Anthropic organization ID<input name="org_id" placeholder="UUID"></label><button class="ovk-btn ovk-btn--quiet" type="submit">Save session</button></form>`;
if (section === 'providers') actions += `<form class="ovk-form ex-console-action" method="post" action="/console/providers"><h3>Add provider</h3><label>Preset<select name="preset"><option value="custom">OpenAI-compatible</option><option value="anthropic">Anthropic</option><option value="openai">OpenAI</option></select></label><label>Provider ID<input name="key" required minlength="2" maxlength="41" pattern="[a-z0-9][a-z0-9._-]+"></label><label>Display name<input name="display_name" maxlength="80"></label><label>Base URL<input name="base_url" type="url" required placeholder="https://api.example.com/v1"></label><label>Default model<input name="default_model" maxlength="256"></label><label>Provider API key<input name="api_key" type="password" autocomplete="new-password" required minlength="8" maxlength="400"></label><label><input name="vision" type="checkbox" value="1"> Supports vision</label><button class="ovk-btn ovk-btn--primary" type="submit">Add provider</button></form><form class="ovk-form ex-console-quota" method="post" action="/console/provider-credits"><h3>Record prepaid credit</h3><label>Provider<select name="provider"><option>anthropic</option><option>openai</option></select></label><label>Kind<select name="kind"><option>promotional</option><option>purchased</option></select></label><label>Amount USD<input name="amount_usd" type="number" min="0" step="0.01" required></label><label>As of<input name="as_of" type="date" required></label><label>Remaining USD<input name="remaining_as_of_usd" type="number" min="0" step="0.01"></label><label>Applies to<input name="applies_to" maxlength="200"></label><button class="ovk-btn ovk-btn--primary" type="submit">Save credit</button></form><form class="ovk-form ex-console-action" method="post" action="/console/provider-credit-preference"><h3>Promotional credit preference</h3><label>Provider<select name="provider"><option>anthropic</option><option>openai</option></select></label><label>Use promotional credit first<select name="enabled"><option value="1">Yes</option><option value="0">No</option></select></label><label>Minimum remaining USD<input name="floor_usd" type="number" min="0" step="0.01" value="5"></label><button class="ovk-btn ovk-btn--quiet" type="submit">Save preference</button></form><form class="ovk-form ex-console-action" method="post" action="/console/provider-session-credentials"><h3>Provider account session</h3><label>Provider<select name="provider"><option>anthropic</option><option>openai</option></select></label><label>Session cookie<input name="cookie" type="password" autocomplete="new-password" required minlength="16" maxlength="4096"></label><label>Label<input name="label" maxlength="80"></label><label>Anthropic organization ID<input name="org_id" placeholder="UUID"></label><button class="ovk-btn ovk-btn--quiet" type="submit">Save session</button></form>`;
if (section === 'providers' && Array.isArray(data.session_credentials)) actions += data.session_credentials.map((c) => `<form class="ex-console-action" method="post" action="/console/provider-session-credentials/${encodeURIComponent(c.id)}/remove"><span>${esc(c.provider)} · ${esc(c.label || c.id)} · ${c.expired ? 'expired' : 'saved'}</span><button class="ovk-btn ovk-btn--quiet" type="submit">Remove session</button></form>`).join('');
if (section === 'providers' && Array.isArray(data.session_credentials)) actions += data.session_credentials.map((c) => `<form class="ex-console-action" method="post" action="/console/provider-session-credentials/${encodeURIComponent(c.id)}/remove"><span>${esc(c.provider)} · ${esc(c.label || c.id)} · ${c.expired ? 'expired' : 'saved'}</span><button class="ovk-btn ovk-btn--quiet" type="submit">Remove session</button></form>`).join('');
+ if (section === 'providers') actions += designed.creditPlansPanel(data);
if (section === 'models' && Array.isArray(data.models)) actions = data.models.map((m) => `<form class="ex-console-action" method="post" action="/console/models/${encodeURIComponent(m.provider)}/${encodeURIComponent(m.model || m.id)}/status"><label>Status for ${esc(m.provider)}/${esc(m.model || m.id)} <select name="status"><option value="active">Active</option><option value="disabled">Disabled</option></select></label><button class="ovk-btn ovk-btn--quiet" type="submit">Save status</button></form>`).join('');
if (section === 'models' && Array.isArray(data.models)) actions = data.models.map((m) => `<form class="ex-console-action" method="post" action="/console/models/${encodeURIComponent(m.provider)}/${encodeURIComponent(m.model || m.id)}/status"><label>Status for ${esc(m.provider)}/${esc(m.model || m.id)} <select name="status"><option value="active">Active</option><option value="disabled">Disabled</option></select></label><button class="ovk-btn ovk-btn--quiet" type="submit">Save status</button></form>`).join('');
if (section === 'workflows' && Array.isArray(data.workflows)) actions = data.workflows.flatMap((w) => (w.versions || []).map((v) => `<form class="ex-console-action" method="post" action="/console/workflows/${encodeURIComponent(w.key)}/versions/${encodeURIComponent(v.version)}/status"><label>Status for ${esc(w.key)} v${esc(v.version)} <select name="status"><option value="active">Active</option><option value="disabled">Disabled</option></select></label><button class="ovk-btn ovk-btn--quiet" type="submit">Save status</button></form>`)).join('');
if (section === 'workflows' && Array.isArray(data.workflows)) actions = data.workflows.flatMap((w) => (w.versions || []).map((v) => `<form class="ex-console-action" method="post" action="/console/workflows/${encodeURIComponent(w.key)}/versions/${encodeURIComponent(v.version)}/status"><label>Status for ${esc(w.key)} v${esc(v.version)} <select name="status"><option value="active">Active</option><option value="disabled">Disabled</option></select></label><button class="ovk-btn ovk-btn--quiet" type="submit">Save status</button></form>`)).join('');
if (section === 'workflows' && data.workflow && Array.isArray(data.versions)) actions = data.versions.map((v) => `<form class="ex-console-action" method="post" action="/console/workflows/${encodeURIComponent(data.workflow.key)}/versions/${encodeURIComponent(v.version)}/status"><label>Status for ${esc(data.workflow.key)} v${esc(v.version)} <select name="status"><option value="active">Active</option><option value="disabled">Disabled</option></select></label><button class="ovk-btn ovk-btn--quiet" type="submit">Save status</button></form>`).join('');
if (section === 'workflows' && data.workflow && Array.isArray(data.versions)) actions = data.versions.map((v) => `<form class="ex-console-action" method="post" action="/console/workflows/${encodeURIComponent(data.workflow.key)}/versions/${encodeURIComponent(v.version)}/status"><label>Status for ${esc(data.workflow.key)} v${esc(v.version)} <select name="status"><option value="active">Active</option><option value="disabled">Disabled</option></select></label><button class="ovk-btn ovk-btn--quiet" type="submit">Save status</button></form>`).join('');
@@ -38,6 +38,19 @@ assert.ok(html.includes('No price confirmed on their own page yet.'), 'a missing
@@ -38,6 +38,19 @@ assert.ok(html.includes('No price confirmed on their own page yet.'), 'a missing
assert.ok(!/\$0(?!\.)/.test(html), 'a missing price is never $0');
assert.ok(!/\$0(?!\.)/.test(html), 'a missing price is never $0');
assert.ok(html.includes('From <b>$0.08</b>') && html.includes('href="https://cheap.example/pricing"'), 'the cheapest price with its own page');
assert.ok(html.includes('From <b>$0.08</b>') && html.includes('href="https://cheap.example/pricing"'), 'the cheapest price with its own page');
assert.ok(!html.includes('ovk-button'), 'kit buttons only');
assert.ok(!html.includes('ovk-button'), 'kit buttons only');
+assert.ok(html.includes('Monthly credits from a plan') && html.includes('action="/console/provider-credit-plans"'), 'the plan form sits in the providers section');
+assert.ok(html.includes('<option value="anthropic">') && html.includes('<option value="openai">'), 'the plan picker always offers Anthropic and OpenAI');
+assert.ok(html.includes('name="prorate_first" type="checkbox" value="1" checked'), 'the first month is prorated by default');
+const planned = providersSection({ providers, models: [], presets: [], credit_estimates: [
+ { provider: 'openai', plans: [{ id: 7, label: 'ChatGPT Pro API credits', monthly_usd: 100, renews_day: 1, starts_on: '2026-11-12', ends_on: null, active: true, notes: null }],
+ estimate: { plans: [{ applies_to: 'plan:7:2026-11-01', amount_usd: 100, used_usd: 25, remaining_usd: 75, expires_at: '2026-12-01T00:00:00.000Z' }] } },
+ { provider: 'altare', plans: [{ id: 8, label: 'Altare free monthly credits', monthly_usd: 100, renews_day: 1, starts_on: '2026-12-01', ends_on: null, active: false, notes: null }],
+ estimate: { plans: [] } },
+] });
+assert.ok(planned.includes('ChatGPT Pro API credits') && planned.includes('$100.00 a month') && planned.includes('renews on day 1'), 'a plan lists its label, amount and renewal day');
+assert.ok(planned.includes('$75.00 left this cycle') && planned.includes('ends 2026-12-01'), 'the cycle remaining comes from the estimate');
+assert.ok(planned.includes('action="/console/provider-credit-plans/7"') && planned.includes('role="switch"'), 'a plan has its own on/off switch');
+assert.ok(planned.includes('No credit for this cycle yet.'), 'a plan with no cycle yet says so');
const models = modelsSection({ models: [
const models = modelsSection({ models: [
{ provider: 'openai', model: 'gpt-5-mini', accepted_price: { in_per_mtok: 0.25, out_per_mtok: 2 }, owner_override: { cost: { in_per_mtok: 0.2, out_per_mtok: 1.6 }, reason: 'promo' } },
{ provider: 'openai', model: 'gpt-5-mini', accepted_price: { in_per_mtok: 0.25, out_per_mtok: 2 }, owner_override: { cost: { in_per_mtok: 0.2, out_per_mtok: 1.6 }, reason: 'promo' } },
@@ -27,6 +27,16 @@ app.use(createConsoleRoutes({
@@ -27,6 +27,16 @@ app.use(createConsoleRoutes({
],
],
};
};
if (path === '/api/v1/admin/console/gpus') return { state: { enabled: true, reason: '', dry_run: true, cap_nano: '50000000000', spent_nano: '1000000000', committed_nano: '2000000000', by_class: { interactive: '1000000000' }, instances: [] } };
if (path === '/api/v1/admin/console/gpus') return { state: { enabled: true, reason: '', dry_run: true, cap_nano: '50000000000', spent_nano: '1000000000', committed_nano: '2000000000', by_class: { interactive: '1000000000' }, instances: [] } };
+ if (path === '/api/v1/admin/console/provider-credit-plans' && options.body && options.body.label === 'fail') throw Object.assign(new Error('provider credit plans are not configured'), { status: 503 });
+ if (path === '/api/v1/admin/console/providers') return {
+ providers: [{ key: 'openai', status: 'active', api_key: fakeSecret }],
+ credit_estimates: [
+ { provider: 'openai', plans: [{ id: 7, label: 'ChatGPT Pro API credits', monthly_usd: 100, renews_day: 1, starts_on: '2026-11-12', ends_on: null, active: true, notes: 'API credits' }],
+ estimate: { plans: [{ applies_to: 'plan:7:2026-11-01', amount_usd: 100, used_usd: 25, remaining_usd: 75, expires_at: '2026-12-01T00:00:00.000Z' }] } },
+ { provider: 'altare', plans: [{ id: 8, label: 'Altare free monthly credits', monthly_usd: 100, renews_day: 1, starts_on: '2026-12-01', ends_on: null, active: false, notes: null }],
+ estimate: { plans: [] } },
+ ],
+ };
return { providers: [{ key: 'openai', status: 'active', api_key: fakeSecret }] };
return { providers: [{ key: 'openai', status: 'active', api_key: fakeSecret }] };
} },
} },
}));
}));
@@ -46,6 +56,13 @@ app.use(createConsoleRoutes({
@@ -46,6 +56,13 @@ app.use(createConsoleRoutes({
assert.strictEqual(response.status, 200, html);
assert.strictEqual(response.status, 200, html);
assert.ok(html.includes('AI operator console'));
assert.ok(html.includes('AI operator console'));
assert.ok(!html.includes(fakeSecret), 'admin JSON values that resemble secrets never reach page HTML');
assert.ok(!html.includes(fakeSecret), 'admin JSON values that resemble secrets never reach page HTML');
+ assert.ok(html.includes('Monthly credits from a plan') && html.includes('action="/console/provider-credit-plans"'), 'the plan form is in the providers section');
+ assert.ok(html.includes('name="monthly_usd"') && html.includes('name="renews_day"') && html.includes('name="starts_on"') && html.includes('name="ends_on"'), 'a plan records its amount and its cycle');
+ assert.ok(html.includes('name="prorate_first" type="checkbox" value="1" checked'), 'the first month is prorated by default');
+ assert.ok(html.includes('ChatGPT Pro API credits') && html.includes('$100.00 a month') && html.includes('renews on day 1'), 'a recorded plan shows its label, amount and renewal day');
+ assert.ok(html.includes('$75.00 left this cycle') && html.includes('ends 2026-12-01'), 'the cycle remaining comes from the estimate');
+ assert.ok(html.includes('action="/console/provider-credit-plans/7"') && html.includes('role="switch"'), 'a plan has its own on/off switch');
+ assert.ok(html.includes('No credit for this cycle yet.'), 'a plan with no cycle yet says so');
assert.deepStrictEqual(calls, [READS.providers]);
assert.deepStrictEqual(calls, [READS.providers]);
for (const path of consolePaths) {
for (const path of consolePaths) {
const page = await fetch(`${base}${path}`, { headers: { 'x-test-role': 'owner' } });
const page = await fetch(`${base}${path}`, { headers: { 'x-test-role': 'owner' } });
@@ -141,6 +158,46 @@ app.use(createConsoleRoutes({
@@ -141,6 +158,46 @@ app.use(createConsoleRoutes({
});
});
assert.strictEqual(providerSettings.status, 303);
assert.strictEqual(providerSettings.status, 303);
assert.ok(writes.some((write) => write.path === '/api/v1/providers/groq' && write.body.markup_percent === 12.5 && write.body.uncensored === true));
assert.ok(writes.some((write) => write.path === '/api/v1/providers/groq' && write.body.markup_percent === 12.5 && write.body.uncensored === true));
+
+ for (const role of ['user', 'admin']) {
+ const count = writes.length;
+ for (const path of ['/console/provider-credit-plans', '/console/provider-credit-plans/7']) {
+ const denied = await fetch(`${base}${path}`, { method: 'POST', headers: { 'x-test-role': role, Origin: 'https://openvibe.exchange' } });
+ assert.strictEqual(denied.status, 403, `${role} cannot record a provider credit plan`);
+ }
+ assert.strictEqual(writes.length, count, 'non-owners cannot reach the AI service');
+ }
+ const planCsrf = await fetch(`${base}/console/provider-credit-plans`, { method: 'POST', headers: { 'x-test-role': 'owner', Origin: 'https://evil.example' } });
+ assert.strictEqual(planCsrf.status, 403);
+ const planWrite = await fetch(`${base}/console/provider-credit-plans`, {
+ method: 'POST', headers: { 'x-test-role': 'owner', Origin: 'https://openvibe.exchange', 'content-type': 'application/x-www-form-urlencoded' },
+ body: new URLSearchParams({ provider: 'openai', label: 'ChatGPT Pro API credits', monthly_usd: '100', renews_day: '1', starts_on: '2026-11-12', ends_on: '', prorate_first: '1', notes: 'API credits' }),
+ redirect: 'manual',
+ });
+ assert.strictEqual(planWrite.status, 303);
+ assert.strictEqual(planWrite.headers.get('location'), '/console/providers');
+ assert.deepStrictEqual(writes.at(-1), { path: '/api/v1/admin/console/provider-credit-plans', method: 'POST', subject: 'usr_01JAB2C3D4E5F6G7H8J9K0MNPX', body: {
+ provider: 'openai', label: 'ChatGPT Pro API credits', monthly_usd: 100, renews_day: 1, starts_on: '2026-11-12', ends_on: null, prorate_first: true, notes: 'API credits',
+ } });
+ const planOn = await fetch(`${base}/console/provider-credit-plans/7`, {
+ method: 'POST', headers: { 'x-test-role': 'owner', Origin: 'https://openvibe.exchange', 'content-type': 'application/x-www-form-urlencoded' },
+ body: new URLSearchParams({ active: '1' }), redirect: 'manual',
+ });
+ assert.strictEqual(planOn.status, 303);
+ assert.deepStrictEqual(writes.at(-1), { path: '/api/v1/admin/console/provider-credit-plans/7', method: 'POST', subject: 'usr_01JAB2C3D4E5F6G7H8J9K0MNPX', body: { active: true } });
+ const planOff = await fetch(`${base}/console/provider-credit-plans/7`, {
+ method: 'POST', headers: { 'x-test-role': 'owner', Origin: 'https://openvibe.exchange', 'content-type': 'application/x-www-form-urlencoded' },
+ body: new URLSearchParams({}), redirect: 'manual',
+ });
+ assert.strictEqual(planOff.status, 303);
+ assert.deepStrictEqual(writes.at(-1).body, { active: false }, 'an unchecked switch turns the plan off');
+ const planError = await fetch(`${base}/console/provider-credit-plans`, {
+ method: 'POST', headers: { 'x-test-role': 'owner', Origin: 'https://openvibe.exchange', 'content-type': 'application/x-www-form-urlencoded' },
+ body: new URLSearchParams({ provider: 'openai', label: 'fail', monthly_usd: '100', renews_day: '1', starts_on: '2026-11-12' }),
+ });
+ const planErrorHtml = await planError.text();
+ assert.strictEqual(planError.status, 502);
+ assert.ok(planErrorHtml.includes('provider credit plans are not configured'), 'the AI error is shown on the page');
const loadConfirmed = await fetch(`${base}/console/pricebook/confirmed`, {
const loadConfirmed = await fetch(`${base}/console/pricebook/confirmed`, {
method: 'POST', headers: { 'x-test-role': 'owner', Origin: 'https://openvibe.exchange' }, redirect: 'manual',
method: 'POST', headers: { 'x-test-role': 'owner', Origin: 'https://openvibe.exchange' }, redirect: 'manual',
});
});