openvibers/openvibe.exchange Commits

Owner console: monthly provider credit plans (#242)

OP OpenVibers committed 15 h ago · b257111f34 · 5 files, +138 −1

Changes

ddiff --git a/server/http/console.js b/server/http/console.js
iindex 51b6388..1cb1303 100644
---- a/server/http/console.js
++++ b/server/http/console.js
@@@ -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 {
ddiff --git a/server/views/console-ai.js b/server/views/console-ai.js
iindex 3bd5ac7..7610d9e 100644
---- a/server/views/console-ai.js
++++ b/server/views/console-ai.js
@@@ -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 };
ddiff --git a/server/views/console.js b/server/views/console.js
iindex 989e1ff..0c60a5e 100644
---- a/server/views/console.js
++++ b/server/views/console.js
@@@ -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('');
ddiff --git a/test/console-ai.test.js b/test/console-ai.test.js
iindex 5f223cd..1d6ecd4 100644
---- a/test/console-ai.test.js
++++ b/test/console-ai.test.js
@@@ -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(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' } },
ddiff --git a/test/console-merge.test.js b/test/console-merge.test.js
iindex 13d162f..b279cd3 100644
---- a/test/console-merge.test.js
++++ b/test/console-merge.test.js
@@@ -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',
});