openvibers/openvibe.exchange Commits

The owner console's Providers, Models and Price book, designed (#162)

OP OpenVibers committed 4 days ago · 45c8ab948d · 6 files, +327 −20

The owner asked for providers configurable from an admin panel; these sections were JSON dumps with forms.
- Providers: each provider as a card (its maker's mark, host, models, order, markup; On/Off, Key stored/No key,
  health, 18+ and resale level as words) with Turn on/off at hand and Settings and key in a fold (status, resale,
  order, markup %, uncensored 18+, a password field for the key, reset). "Ready to add": every preset not yet added,
  cheapest confirmed price first with its own price page and check date, added with just the key.
- Models: by provider, each with its accepted price and the owner's price with its reason; set or clear it in a fold.
- Price book: each pending change as old → new with the percent and the provider's page; Accept sends "approve"
  (the word AI takes; anything else was a reject), or keep the current price; applied changes below.
- A missing price is never $0 (null was read as 0); a real 0 reads Free. The raw JSON stays in a counted fold.
- Buttons are the kit's ovk-btn (ovk-button was never a kit class) across the console and the credentials page.

Changes

ddiff --git a/public/css/console-ai.css b/public/css/console-ai.css
nnew file mode 100644
iindex 0000000..30adc2e
---- /dev/null
++++ b/public/css/console-ai.css
@@@ -0,0 +1,77 @@
++/* The owner console's Providers, Models and Price book (views/console-ai.js). The kit's tokens, phone first. */
++.exc{display:grid;gap:22px;min-width:0}
++.exc>*{min-width:0}
++.exc-head{display:grid;gap:10px}
++.exc-head h2{margin:0;font-size:clamp(1.4rem,3vw,1.9rem)}
++.exc-head>p{margin:0;max-width:70ch;color:var(--ovk-muted);line-height:1.55}
++.exc-stats{display:flex;flex-wrap:wrap;gap:8px;margin:4px 0 0}
++.exc-stats div{display:flex;flex-direction:column-reverse;min-width:110px;padding:10px 14px;border:1px solid var(--ovk-line);border-radius:12px;background:var(--ovk-panel)}
++.exc-stats dt{color:var(--ovk-muted);font-size:.8rem}
++.exc-stats dd{margin:0;font-size:1.35rem;font-weight:800;font-variant-numeric:tabular-nums}
++.exc-sec{display:grid;gap:12px}
++.exc-sec>h3{display:flex;align-items:center;gap:8px;margin:0;font-size:1.05rem}
++.exc-n{display:inline-grid;place-items:center;min-width:1.7em;height:1.7em;padding:0 .4em;border-radius:999px;background:var(--ovk-panel-2,var(--ovk-panel));color:var(--ovk-muted);font-size:.78rem;font-weight:700;font-variant-numeric:tabular-nums}
++.exc-list{list-style:none;margin:0;padding:0;display:grid;gap:10px}
++.exc-grid{list-style:none;margin:0;padding:0;display:grid;grid-template-columns:repeat(auto-fill,minmax(min(100%,300px),1fr));gap:10px}
++.exc-note{margin:0;color:var(--ovk-muted);font-size:.88rem;line-height:1.45}
++.exc-quiet{color:var(--ovk-muted)}
++
++.exc-prov,.exc-preset,.exc-model,.exc-change{display:grid;gap:10px;min-width:0;padding:14px;border:1px solid var(--ovk-line);border-radius:16px;background:var(--ovk-panel)}
++.exc-prov.is-off{opacity:.78}
++.exc-prov-head,.exc-model-head{display:flex;align-items:center;gap:12px;min-width:0}
++.exc-prov-name{display:grid;gap:2px;min-width:0;flex:1}
++.exc-prov-name b,.exc-model-head b{overflow-wrap:anywhere}
++.exc-prov-name small,.exc-model-head small{color:var(--ovk-muted);font-size:.8rem;overflow-wrap:anywhere}
++.exc-model-head{flex-wrap:wrap}
++.exc-model-head small{flex:1 1 12rem}
++.exc-quick{display:flex;align-items:center;gap:6px;margin:0 0 0 auto;flex:none}
++.exc-quick select{min-height:40px;border:1px solid var(--ovk-line);border-radius:10px;background:var(--ovk-input,transparent);color:inherit;padding:0 8px}
++
++.exc-tags{display:flex;flex-wrap:wrap;gap:6px;margin:0}
++.exc-tag{display:inline-flex;align-items:center;min-height:24px;padding:0 9px;border:1px solid var(--ovk-line);border-radius:999px;font-size:.76rem;font-weight:650;color:var(--ovk-muted)}
++.exc-tag.is-ok{color:color-mix(in srgb,var(--ovk-ok) 55%,var(--ovk-text));border-color:color-mix(in srgb,var(--ovk-ok) 40%,var(--ovk-line))}
++.exc-tag.is-warn{color:color-mix(in srgb,var(--ovk-warn) 55%,var(--ovk-text));border-color:color-mix(in srgb,var(--ovk-warn) 40%,var(--ovk-line))}
++.exc-tag.is-bad{color:color-mix(in srgb,var(--ovk-bad) 55%,var(--ovk-text));border-color:color-mix(in srgb,var(--ovk-bad) 40%,var(--ovk-line))}
++.exc-tag.is-adult{color:color-mix(in srgb,#f472b6 55%,var(--ovk-text));border-color:color-mix(in srgb,#f472b6 40%,var(--ovk-line))}
++.exc-price{margin:0;font-size:.9rem;color:var(--ovk-muted)}
++.exc-price b{color:var(--ovk-text);font-variant-numeric:tabular-nums}
++.exc-price a{color:inherit}
++
++.exc-fold{border-top:1px solid var(--ovk-line);padding-top:8px}
++.exc-fold>summary{cursor:pointer;min-height:36px;display:flex;align-items:center;font-weight:650;font-size:.9rem;color:var(--ovk-accent)}
++.exc-fold-body{display:grid;gap:14px;padding-top:8px}
++.exc-raw{margin-top:18px}
++.exc-raw pre{max-height:420px;overflow:auto;font-size:.78rem}
++
++.exc-form{display:grid;grid-template-columns:repeat(auto-fill,minmax(min(100%,180px),1fr));gap:10px 12px;align-items:end;margin:0}
++.exc-form label{display:grid;gap:5px;min-width:0;font-size:.82rem;color:var(--ovk-muted)}
++.exc-form label>span{font-weight:650;color:var(--ovk-text)}
++.exc-form small{color:var(--ovk-muted);font-size:.76rem}
++.exc-form input:not([type=checkbox]),.exc-form select{box-sizing:border-box;width:100%;min-width:0;min-height:44px;padding:0 12px;border:1px solid var(--ovk-line);border-radius:10px;background:var(--ovk-input,transparent);color:var(--ovk-text);font:inherit}
++.exc-form--key,.exc-form--key label{grid-column:1/-1}
++.exc-form .exc-check{display:flex;align-items:center;gap:8px;grid-column:1/-1;min-height:44px}
++.exc-form .exc-check input{width:20px;height:20px}
++.exc-form .exc-wide,.exc-actions{grid-column:1/-1}
++.exc-actions{display:flex;flex-wrap:wrap;gap:8px;margin:0}
++.exc-inline{display:flex;flex-wrap:wrap;align-items:center;gap:10px;margin:0}
++
++.exc-model-prices{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,220px),1fr));gap:8px}
++.exc-model-prices>span{display:grid;gap:2px;padding:10px 12px;border-radius:12px;background:var(--ovk-panel-2,color-mix(in srgb,var(--ovk-text) 4%,transparent));font-size:.9rem}
++.exc-model-prices em{font-style:normal;font-size:.72rem;font-weight:800;letter-spacing:.06em;text-transform:uppercase;color:var(--ovk-muted)}
++.exc-model-prices b{font-variant-numeric:tabular-nums}
++.exc-model-prices small{color:var(--ovk-muted);font-size:.76rem}
++.exc-model-prices .is-override{box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--ovk-accent) 45%,transparent)}
++
++.exc-delta{display:flex;flex-wrap:wrap;align-items:baseline;gap:8px;margin:0;font-variant-numeric:tabular-nums}
++.exc-delta>span:first-child{color:var(--ovk-muted);text-decoration:line-through}
++.exc-delta b{font-size:1.15rem}
++.exc-delta em{font-style:normal;font-weight:700;font-size:.85rem}
++.exc-delta.is-up em{color:color-mix(in srgb,var(--ovk-bad) 60%,var(--ovk-text))}
++.exc-delta.is-down em{color:color-mix(in srgb,var(--ovk-ok) 60%,var(--ovk-text))}
++.exc-decide .ovk-btn{min-height:44px}
++
++@media (max-width:600px){
++ .exc-prov-head{flex-wrap:wrap}
++ .exc-quick{margin-left:0;width:100%}
++ .exc-quick .ovk-btn{flex:1}
++}
ddiff --git a/server/http/console.js b/server/http/console.js
iindex 547284e..d8a4db0 100644
---- a/server/http/console.js
++++ b/server/http/console.js
@@@ -19,8 +19,12 @@ function createConsoleRoutes({ config, ai }) {
const r = express.Router();
r.use(express.urlencoded({ extended: false, limit: '32kb' }));
const isOwner = (req) => Boolean(req.viewer && req.viewer.kind === 'user' && req.viewer.subject && req.viewer.role === 'owner');
++ // Providers, Models and the Price book are designed sections (views/console-ai.js): their stylesheet and the
++ // makers' marks come with them.
++ const DESIGNED_HEAD = raw(`<link rel="stylesheet" href="/css/console-ai.css"><style>${require('../views/console-ai').CSS}</style>`);
const page = (req, res, section, data, error = null, status = 200) => send(res, status, {
viewer: req.viewer, config, path: req.originalUrl, index: false, title: 'AI operator console',
++ head: ['providers', 'models', 'pricebook'].includes(section) ? DESIGNED_HEAD : undefined,
body: raw(consolePage({ section, data, error })),
});
r.use((req, res, next) => isOwner(req) ? next() : res.sendStatus(404));
@@@ -199,8 +203,10 @@ function createConsoleRoutes({ config, ai }) {
const cost = {};
for (const [form, field] of [['input_per_million', 'in_per_mtok'], ['cached_per_million', 'cached_per_mtok'], ['output_per_million', 'out_per_mtok']])
if (Object.prototype.hasOwnProperty.call(req.body, form)) cost[field] = number(req.body[form]);
++ // "Use the accepted price" clears the owner's price (AI deletes it on null).
++ const override = req.body.clear === '1' ? null : { reason: String(req.body.reason || ''), cost };
await ai.adminRequest(`/api/v1/models/${encodeURIComponent(req.params.provider)}/${encodeURIComponent(req.params.model)}`, {
-- method: 'PATCH', subject: req.viewer.subject, body: { owner_override: { reason: String(req.body.reason || ''), cost } },
++ method: 'PATCH', subject: req.viewer.subject, body: { owner_override: override },
});
return res.redirect(303, '/console/models');
} catch (err) { return page(req, res, 'models', {}, err.message || 'The model price override could not be saved.', 502); }
ddiff --git a/server/views/console-ai.js b/server/views/console-ai.js
nnew file mode 100644
iindex 0000000..d46410c
---- /dev/null
++++ b/server/views/console-ai.js
@@@ -0,0 +1,181 @@
++'use strict';
++/**
++ * The owner console's AI supply sections, designed: Providers (who the Exchange routes to, in what order, at what
++ * markup, with which keys), Models (each model's accepted price and the owner's override) and the Price book (what
++ * providers' own pages say now, waiting for a yes). The data comes from AI's admin read models
++ * (/api/v1/admin/console/:section); every form posts to the console routes in server/http/console.js.
++ *
++ * Keys are never shown: a provider says only whether one is stored. A number with no source says so in words.
++ */
++const { esc } = require('../render/html');
++const marks = require('openvibe-shared/ai-marks');
++
++const ACCESS = Object.freeze({
++ OWN_DEPLOYMENT: ['Our own deployment', 'Runs on machines OpenVibe controls.'],
++ AUTHORIZED_WHOLESALE: ['Resale agreed', 'An agreement allows selling it on.'],
++ UNVERIFIED: ['Not verified', 'No resale agreement on file: people can use it with their own key only.'],
++ RESTRICTED: ['Restricted', 'Its terms forbid resale.'],
++});
++const access = (v) => ACCESS[v] || [v ? String(v) : 'Not set', ''];
++// A missing price is null, never $0 (the missing-is-not-zero rule); a real 0 reads "Free".
++const price = (v) => (v == null || v === '' || !Number.isFinite(Number(v)) ? null : Number(v));
++const money = (v) => { const n = price(v); return n == null ? null : n === 0 ? 'Free' : `$${n >= 0.1 ? n.toFixed(2) : String(Number(n.toPrecision(2)))}`; };
++const host = (u) => { try { return new URL(String(u)).host; } catch { return ''; } };
++const day = (iso) => { const d = new Date(iso); return Number.isNaN(d.getTime()) ? '' : d.toISOString().slice(0, 10); };
++const mark = (name, size = 36) => marks.mark(name, { size, label: false });
++const tag = (text, tone = '') => `<span class="exc-tag${tone ? ` is-${tone}` : ''}">${esc(text)}</span>`;
++const btn = (label, kind = 'quiet', type = 'submit') => `<button class="ovk-btn ovk-btn--${kind}" type="${type}">${esc(label)}</button>`;
++const priceTriple = (c) => {
++ if (!c) return '<span class="exc-quiet">No price yet</span>';
++ const inp = money(c.in_per_mtok), out = money(c.out_per_mtok), cached = money(c.cached_per_mtok);
++ if (!inp && !out) return '<span class="exc-quiet">No price yet</span>';
++ return `<b>${esc(out || '–')}</b> out · ${esc(inp || '–')} in${cached ? ` · ${esc(cached)} cached` : ''}<small>per 1M tokens</small>`;
++};
++const healthWord = (h) => {
++ const s = h && (h.state || h.status);
++ if (!s) return null;
++ if (/^(up|ok|healthy|closed)$/i.test(s)) return ['Healthy', 'ok'];
++ if (/degrad|half/i.test(s)) return ['Degraded', 'warn'];
++ if (/down|open|fail/i.test(s)) return ['Failing', 'bad'];
++ return [String(s), ''];
++};
++
++// ── Providers ──
++function providerCard(p, models) {
++ const key = encodeURIComponent(p.key || '');
++ const on = p.status !== 'disabled';
++ const [accessLabel, accessNote] = access(p.provider_access || (p.metadata && p.metadata.provider_access));
++ const health = healthWord(p.health);
++ const mine = models.filter((m) => m.provider === p.key);
++ const tags = [
++ tag(on ? 'On' : 'Off', on ? 'ok' : ''),
++ tag(p.key_present ? 'Key stored' : 'No key', p.key_present ? '' : 'warn'),
++ health ? tag(health[0], health[1]) : '',
++ p.uncensored ? tag('18+', 'adult') : '',
++ tag(accessLabel),
++ ].join('');
++ const settings = `<form class="exc-form" method="post" action="/console/providers/${key}/settings">
++ <label><span>Status</span><select name="status"><option value="active"${on ? ' selected' : ''}>On</option><option value="disabled"${on ? '' : ' selected'}>Off</option></select></label>
++ <label><span>Resale</span><select name="provider_access">${Object.entries(ACCESS).map(([v, [l]]) => `<option value="${v}"${(p.provider_access || '') === v ? ' selected' : ''}>${esc(l)}</option>`).join('')}</select></label>
++ <label><span>Order</span><input name="preference_order" type="number" min="0" max="1000" step="1" value="${esc(p.preference_order ?? p.priority ?? 50)}" inputmode="numeric"><small>Lower is tried first.</small></label>
++ <label><span>Markup %</span><input name="markup_percent" type="number" min="0" max="500" step="0.5" value="${esc(p.markup_percent || 0)}" inputmode="decimal"><small>On top of the accepted price.</small></label>
++ <label class="exc-check"><input type="checkbox" name="uncensored" value="1"${p.uncensored ? ' checked' : ''}><span>Uncensored models (18+ only)</span></label>
++ <p class="exc-actions">${btn('Save settings', 'primary')}</p>
++</form>`;
++ const keyForm = `<form class="exc-form exc-form--key" method="post" action="/console/providers/${key}/key">
++ <label><span>${p.key_present ? 'Replace the key' : 'Add the key'}</span><input name="api_key" type="password" autocomplete="off" minlength="8" maxlength="400" required placeholder="Paste the provider's API key"><small>Stored sealed and bound to ${esc(host(p.base_url) || 'this provider')}; never shown again.</small></label>
++ <p class="exc-actions">${btn(p.key_present ? 'Replace key' : 'Save key')}</p>
++</form>`;
++ return `<li class="exc-prov${on ? '' : ' is-off'}" id="p-${esc(p.key)}">
++ <div class="exc-prov-head">${mark(p.key || p.display_name)}<div class="exc-prov-name"><b>${esc(p.display_name || p.key)}</b><small>${esc(host(p.base_url))}${mine.length ? ` · ${mine.length} ${mine.length === 1 ? 'model' : 'models'}` : ''} · order ${esc(p.preference_order ?? p.priority ?? '–')}${Number(p.markup_percent) ? ` · +${esc(p.markup_percent)}%` : ''}</small></div>
++ <form method="post" action="/console/providers/${key}/status" class="exc-quick"><input type="hidden" name="status" value="${on ? 'disabled' : 'active'}">${btn(on ? 'Turn off' : 'Turn on')}</form></div>
++ <p class="exc-tags">${tags}</p>
++ ${accessNote ? `<p class="exc-note">${esc(accessNote)}</p>` : ''}
++ <details class="exc-fold"><summary>Settings and key</summary><div class="exc-fold-body">${settings}${keyForm}
++ <form method="post" action="/console/providers/${key}/reset" class="exc-inline"><p class="exc-note">Reset clears its failure count and opens it to traffic again.</p>${btn('Reset health')}</form></div></details>
++</li>`;
++}
++
++function cheapestOf(p) {
++ const priced = (Array.isArray(p.models) ? p.models : []).filter((m) => m.cost && price(m.cost.out_per_mtok) != null);
++ return priced.sort((a, b) => price(a.cost.out_per_mtok) - price(b.cost.out_per_mtok))[0] || null;
++}
++function presetCard(p) {
++ const models = Array.isArray(p.models) ? p.models : [];
++ const cheapest = cheapestOf(p);
++ const [accessLabel] = access(p.provider_access);
++ const first = p.default_model || (models[0] && models[0].model_key) || '';
++ return `<li class="exc-preset">
++ <div class="exc-prov-head">${mark(p.key || p.display_name, 32)}<div class="exc-prov-name"><b>${esc(p.display_name || p.key)}</b><small>${esc(host(p.base_url))} · ${models.length} ${models.length === 1 ? 'model' : 'models'}</small></div></div>
++ <p class="exc-tags">${p.uncensored ? tag('18+', 'adult') : ''}${tag(accessLabel)}</p>
++ ${cheapest ? `<p class="exc-price">${price(cheapest.cost.out_per_mtok) === 0 ? 'Has a <b>free</b> model' : `From <b>${esc(money(cheapest.cost.out_per_mtok))}</b> out per 1M`} · ${esc(cheapest.display_name || cheapest.model_key)}${cheapest.source ? ` · <a href="${esc(cheapest.source)}" rel="noopener noreferrer" target="_blank">their price page</a>` : ''}${cheapest.checked ? ` · checked ${esc(day(cheapest.checked))}` : ''}</p>` : '<p class="exc-note">No price confirmed on their own page yet.</p>'}
++ <details class="exc-fold"><summary>Add with your key</summary><div class="exc-fold-body"><form class="exc-form" method="post" action="/console/providers">
++ <input type="hidden" name="preset" value="custom"><input type="hidden" name="key" value="${esc(p.key)}"><input type="hidden" name="display_name" value="${esc(p.display_name || p.key)}"><input type="hidden" name="base_url" value="${esc(p.base_url || '')}"><input type="hidden" name="default_model" value="${esc(first)}">
++ <label><span>API key</span><input name="api_key" type="password" autocomplete="off" minlength="8" maxlength="400" required placeholder="Paste the key from ${esc(host(p.base_url) || 'the provider')}"><small>Sealed and bound to ${esc(host(p.base_url) || 'its address')}. Its models arrive with the prices above; it starts on.</small></label>
++ <p class="exc-actions">${btn('Add provider', 'primary')}</p></form></div></details>
++</li>`;
++}
++
++function providersSection(data = {}) {
++ const providers = Array.isArray(data.providers) ? data.providers : [];
++ const models = Array.isArray(data.models) ? data.models : [];
++ const added = new Set(providers.map((p) => p.key));
++ const cost = (p) => { const c = cheapestOf(p); return c ? price(c.cost.out_per_mtok) : Infinity; };
++ const presets = (Array.isArray(data.presets) ? data.presets : []).filter((p) => !added.has(p.key)).sort((a, b) => cost(a) - cost(b));
++ const ordered = [...providers].sort((a, b) => (a.status === 'disabled') - (b.status === 'disabled') || (Number(a.preference_order ?? a.priority) - Number(b.preference_order ?? b.priority)));
++ const on = providers.filter((p) => p.status !== 'disabled').length;
++ const keyed = providers.filter((p) => p.key_present).length;
++ const stats = [[on, 'on'], [keyed, keyed === 1 ? 'with a key' : 'with keys'], [presets.length, 'ready to add']].filter(([n]) => n);
++ const custom = `<details class="exc-fold"><summary>Another OpenAI-compatible provider</summary><div class="exc-fold-body"><form class="exc-form" method="post" action="/console/providers">
++ <label><span>Kind</span><select name="preset"><option value="custom">OpenAI-compatible</option><option value="anthropic">Anthropic</option><option value="openai">OpenAI</option></select></label>
++ <label><span>Name</span><input name="key" required pattern="[a-z0-9][a-z0-9._-]{1,40}" placeholder="lowercase, e.g. myprovider"></label>
++ <label><span>Shown as</span><input name="display_name" placeholder="My Provider"></label>
++ <label><span>Base URL</span><input name="base_url" type="url" placeholder="https://api.example.com/v1"></label>
++ <label><span>Default model</span><input name="default_model" placeholder="model id"></label>
++ <label><span>API key</span><input name="api_key" type="password" autocomplete="off" minlength="8" maxlength="400" required></label>
++ <label class="exc-check"><input type="checkbox" name="vision" value="1"><span>Takes images</span></label>
++ <p class="exc-actions">${btn('Add provider', 'primary')}</p></form></div></details>`;
++ return `<div class="exc">
++<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>
++${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>`;
++}
++
++// ── Models ──
++function modelsSection(data = {}) {
++ const models = Array.isArray(data.models) ? data.models : [];
++ const byProvider = new Map();
++ for (const m of models) { if (!byProvider.has(m.provider)) byProvider.set(m.provider, []); byProvider.get(m.provider).push(m); }
++ const row = (m) => {
++ const id = m.model || m.model_key || m.id;
++ const path = `/console/models/${encodeURIComponent(m.provider)}/${encodeURIComponent(id)}`;
++ const o = m.owner_override;
++ const accepted = m.accepted_price || m.cost;
++ return `<li class="exc-model">
++ <div class="exc-model-head"><b>${esc(m.display_name || id)}</b><small>${esc(id)}</small>
++ <form method="post" action="${path}/status" class="exc-quick"><select name="status" aria-label="Status of ${esc(id)}">${['active', 'disabled'].map((s) => `<option value="${s}"${(m.status || 'active') === s ? ' selected' : ''}>${s === 'active' ? 'On' : 'Off'}</option>`).join('')}</select>${btn('Set')}</form></div>
++ <div class="exc-model-prices"><span><em>Accepted</em>${priceTriple(accepted)}</span>${o ? `<span class="is-override"><em>Your price</em>${priceTriple(o.cost)}<small>${esc(o.reason || '')}</small></span>` : ''}</div>
++ <details class="exc-fold"><summary>${o ? 'Change your price' : 'Set your own price'}</summary><div class="exc-fold-body"><form class="exc-form exc-form--price" method="post" action="${path}/override">
++ <label><span>In / 1M</span><input name="input_per_million" type="number" min="0" step="0.0001" inputmode="decimal" value="${esc(o && o.cost && o.cost.in_per_mtok != null ? o.cost.in_per_mtok : '')}"></label>
++ <label><span>Cached / 1M</span><input name="cached_per_million" type="number" min="0" step="0.0001" inputmode="decimal" value="${esc(o && o.cost && o.cost.cached_per_mtok != null ? o.cost.cached_per_mtok : '')}"></label>
++ <label><span>Out / 1M</span><input name="output_per_million" type="number" min="0" step="0.0001" inputmode="decimal" value="${esc(o && o.cost && o.cost.out_per_mtok != null ? o.cost.out_per_mtok : '')}"></label>
++ <label class="exc-wide"><span>Why</span><input name="reason" required maxlength="500" placeholder="e.g. their promo price until Dec 1" value="${esc(o && o.reason || '')}"></label>
++ <p class="exc-actions">${btn('Save price', 'primary')}</p></form>${o ? `<form method="post" action="${path}/override" class="exc-inline"><input type="hidden" name="clear" value="1">${btn('Use the accepted price')}</form>` : ''}</div></details>
++</li>`;
++ };
++ return `<div class="exc">
++<header class="exc-head"><h2>Models</h2><p>Every model by provider. The accepted price is what the price book confirmed on the provider's own page; your price, with its reason, replaces it until you clear it. The provider's markup applies on top.</p></header>
++${models.length ? [...byProvider.entries()].map(([prov, list]) => `<section class="exc-sec"><h3>${mark(prov, 24)} ${esc(prov)} <span class="exc-n">${list.length}</span></h3><ul class="exc-list">${list.map(row).join('')}</ul></section>`).join('') : '<p class="exc-note">No models yet: add a provider and its models arrive.</p>'}
++</div>`;
++}
++
++// ── Price book ──
++function pricebookSection(data = {}) {
++ const pending = Array.isArray(data.pending) ? data.pending : [];
++ const recent = Array.isArray(data.recent) ? data.recent : [];
++ const rep = data.report || null;
++ // One change is one price of one model (price_changes: provider_key, model_key, field, old_price, new_price,
++ // change_pct, source, checked_at).
++ const FIELD = { in_per_mtok: 'input', cached_per_mtok: 'cached input', out_per_mtok: 'output' };
++ const change = (c, open) => {
++ const pct = c.change_pct == null || !Number.isFinite(Number(c.change_pct)) ? null : Number(c.change_pct) * (Math.abs(Number(c.change_pct)) <= 5 ? 100 : 1);
++ const dir = c.old_price == null ? 'new' : Number(c.new_price) > Number(c.old_price) ? 'up' : 'down';
++ return `<li class="exc-change">
++ <div class="exc-model-head">${mark(c.provider_key, 24)}<b>${esc(c.model_key || '')}</b><small>${esc(c.provider_key || '')} · ${esc(FIELD[c.field] || c.field || '')} price per 1M${c.checked_at ? ` · read ${esc(day(c.checked_at))}` : ''}${c.source ? ` · <a href="${esc(c.source)}" rel="noopener noreferrer" target="_blank">their page</a>` : ''}</small></div>
++ <p class="exc-delta is-${dir}"><span>${esc(money(c.old_price) || 'No price')}</span><span aria-hidden="true">→</span><b>${esc(money(c.new_price) || '–')}</b>${pct != null ? `<em>${pct > 0 ? '+' : ''}${esc(pct.toFixed(Math.abs(pct) < 10 ? 1 : 0))}%</em>` : ''}</p>
++ ${open ? `<form method="post" action="/console/pricebook/changes/${encodeURIComponent(c.id)}" class="exc-inline exc-decide"><button class="ovk-btn ovk-btn--primary" name="decision" value="approve">Accept</button><button class="ovk-btn ovk-btn--quiet" name="decision" value="reject">Keep the current price</button></form>` : `<p class="exc-note">${esc(c.status === 'applied' ? 'Applied' : c.status || '')}${c.decided_at ? ` ${esc(day(c.decided_at))}` : ''}</p>`}
++</li>`;
++ };
++ return `<div class="exc">
++<header class="exc-head"><h2>Price book</h2><p>OpenVibe reads every provider's own price page on a schedule. A change waits here for your yes; nothing a provider publishes reaches people's bills unseen.</p>
++<form method="post" action="/console/pricebook/run" class="exc-inline">${btn('Read their pages now')}${rep && rep.finished_at ? `<span class="exc-note">Last read ${esc(day(rep.finished_at))}${Array.isArray(rep.sources) ? ` · ${rep.sources.length} pages` : ''}</span>` : ''}</form></header>
++<section class="exc-sec"><h3>Waiting for you <span class="exc-n">${pending.length}</span></h3>${pending.length ? `<ul class="exc-list">${pending.map((c) => change(c, true)).join('')}</ul>` : '<p class="exc-note">Nothing waiting: every price matches its provider\'s page.</p>'}</section>
++${recent.length ? `<section class="exc-sec"><h3>Applied lately <span class="exc-n">${recent.length}</span></h3><ul class="exc-list">${recent.slice(0, 20).map((c) => change(c, false)).join('')}</ul></section>` : ''}
++</div>`;
++}
++
++const SECTIONS = { providers: providersSection, models: modelsSection, pricebook: pricebookSection };
++module.exports = { providersSection, modelsSection, pricebookSection, SECTIONS, CSS: marks.CSS };
ddiff --git a/server/views/console.js b/server/views/console.js
iindex b43c7f8..1627292 100644
---- a/server/views/console.js
++++ b/server/views/console.js
@@@ -1,6 +1,7 @@
'use strict';
const { esc } = require('../render/html');
++const designed = require('./console-ai');
const SECTIONS = [
['Overview', '/console'], ['Defaults', '/console/defaults'], ['GPU supply (dry run)', '/console/supply'], ['Providers', '/console/providers'], ['Models', '/console/models'],
@@@ -21,33 +22,33 @@ function consolePage({ section = 'overview', data = {}, error = null }) {
const nav = SECTIONS.map(([label, href]) => `<a class="ex-console-tab${active === href ? ' is-active' : ''}" href="${href}">${label}</a>`).join('');
const json = esc(JSON.stringify(safe(data), null, 2));
let actions = '';
-- if (section === 'supply') actions = `<p>Dry run only. No GPU is rented or stopped. Missing measurements are shown as missing.</p><form method="post" action="/console/supply" class="ovk-form"><label>Model<input name="model" required></label><label>Runtime<select name="runtime"><option value="vllm">vLLM</option><option value="sglang">SGLang</option></select></label>${[['input_per_million','Input USD / million'],['output_per_million','Output USD / million'],['hourly_ceiling_usd','Hourly spend ceiling USD'],['monthly_ceiling_usd','Monthly spend ceiling USD'],['required_vram_mb','Required VRAM MB'],['idle_minutes','Stop after idle minutes']].map(([key,label])=>`<label>${label}<input name="${key}" type="number" min="0" step="any" required></label>`).join('')}<button class="ovk-button" type="submit">Save prices and ceilings</button></form>`;
++ if (section === 'supply') actions = `<p>Dry run only. No GPU is rented or stopped. Missing measurements are shown as missing.</p><form method="post" action="/console/supply" class="ovk-form"><label>Model<input name="model" required></label><label>Runtime<select name="runtime"><option value="vllm">vLLM</option><option value="sglang">SGLang</option></select></label>${[['input_per_million','Input USD / million'],['output_per_million','Output USD / million'],['hourly_ceiling_usd','Hourly spend ceiling USD'],['monthly_ceiling_usd','Monthly spend ceiling USD'],['required_vram_mb','Required VRAM MB'],['idle_minutes','Stop after idle minutes']].map(([key,label])=>`<label>${label}<input name="${key}" type="number" min="0" step="any" required></label>`).join('')}<button class="ovk-btn ovk-btn--primary" type="submit">Save prices and ceilings</button></form>`;
if (section === 'providers' && Array.isArray(data.providers)) actions = data.providers.map((p) => {
const key = encodeURIComponent(p.key || '');
const next = p.status === 'disabled' ? 'active' : 'disabled';
-- return `<form class="ex-console-action" method="post" action="/console/providers/${key}/status"><input type="hidden" name="status" value="${next}"><button class="ovk-button ovk-button-quiet" type="submit">${next === 'active' ? 'Enable' : 'Disable'} ${esc(p.key || 'provider')}</button></form><form class="ex-console-action" method="post" action="/console/providers/${key}/reset"><button class="ovk-button ovk-button-quiet" type="submit">Reset circuit</button></form><form class="ex-console-action" method="post" action="/console/providers/${key}/key"><label>Replace ${esc(p.key || 'provider')} key<input type="password" name="api_key" autocomplete="new-password" minlength="8" maxlength="400" required></label><button class="ovk-button ovk-button-quiet" type="submit">Save key</button></form>`;
++ return `<form class="ex-console-action" method="post" action="/console/providers/${key}/status"><input type="hidden" name="status" value="${next}"><button class="ovk-btn ovk-btn--quiet" type="submit">${next === 'active' ? 'Enable' : 'Disable'} ${esc(p.key || 'provider')}</button></form><form class="ex-console-action" method="post" action="/console/providers/${key}/reset"><button class="ovk-btn ovk-btn--quiet" type="submit">Reset circuit</button></form><form class="ex-console-action" method="post" action="/console/providers/${key}/key"><label>Replace ${esc(p.key || 'provider')} key<input type="password" name="api_key" autocomplete="new-password" minlength="8" maxlength="400" required></label><button class="ovk-btn ovk-btn--quiet" type="submit">Save key</button></form>`;
}).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-button" 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-button" 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-button ovk-button-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-button ovk-button-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-button ovk-button-quiet" type="submit">Remove session</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-button ovk-button-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-button ovk-button-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-button ovk-button-quiet" type="submit">Save status</button></form>`).join('');
-- if (section === 'quotas') actions = `<form class="ovk-form ex-console-quota" method="post" action="/console/quotas"><label>Scope type<input name="scope_type" required value="service"></label><label>Scope ID<input name="scope_id" required></label><label>Window<select name="window"><option>day</option><option>month</option></select></label><label>Max requests<input name="max_requests" type="number" min="0"></label><label>Max tokens<input name="max_tokens" type="number" min="0"></label><label>Max cost USD<input name="max_cost_usd" type="number" min="0" step="0.01"></label><button class="ovk-button" type="submit">Save quota</button></form>`;
-- if (section === 'cache') actions = `<form class="ex-console-action" method="post" action="/console/cache/purge"><label>Workflow (optional)<input name="workflow"></label><button class="ovk-button ovk-button-quiet" type="submit">Purge cache</button></form>`;
-- if (section === 'gpus' && data.state && Array.isArray(data.state.models)) actions = data.state.models.map((m) => `<form class="ex-console-action" method="post" action="/console/gpus/${encodeURIComponent(m.key)}/launch"><button class="ovk-button ovk-button-quiet" type="submit">Request ${esc(m.display_name || m.key)}</button></form><form class="ex-console-action" method="post" action="/console/gpus/${encodeURIComponent(m.key)}/stop"><button class="ovk-button ovk-button-quiet" type="submit">Stop ${esc(m.display_name || m.key)}</button></form>`).join('');
-- if (section === 'pricebook' && Array.isArray(data.pending)) actions = `<form class="ex-console-action" method="post" action="/console/pricebook/run"><button class="ovk-button ovk-button-quiet" type="submit">Read provider listings now</button></form>` + data.pending.map((c) => `<form class="ex-console-action" method="post" action="/console/pricebook/changes/${encodeURIComponent(c.id)}"><button class="ovk-button ovk-button-quiet" name="decision" value="approve">Approve ${esc(c.provider_key || c.model_key)}</button><button class="ovk-button ovk-button-quiet" name="decision" value="reject">Reject</button></form>`).join('');
-- if (section === 'exchange' && data.credits && Array.isArray(data.credits.review)) actions = data.credits.review.map((c) => `<form class="ex-console-action" method="post" action="/console/exchange/credits/${encodeURIComponent(c.id)}/resolve"><button class="ovk-button ovk-button-quiet" type="submit">Mark ${esc(c.id)} resolved</button></form>`).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 === '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('');
++ if (section === 'quotas') actions = `<form class="ovk-form ex-console-quota" method="post" action="/console/quotas"><label>Scope type<input name="scope_type" required value="service"></label><label>Scope ID<input name="scope_id" required></label><label>Window<select name="window"><option>day</option><option>month</option></select></label><label>Max requests<input name="max_requests" type="number" min="0"></label><label>Max tokens<input name="max_tokens" type="number" min="0"></label><label>Max cost USD<input name="max_cost_usd" type="number" min="0" step="0.01"></label><button class="ovk-btn ovk-btn--primary" type="submit">Save quota</button></form>`;
++ if (section === 'cache') actions = `<form class="ex-console-action" method="post" action="/console/cache/purge"><label>Workflow (optional)<input name="workflow"></label><button class="ovk-btn ovk-btn--quiet" type="submit">Purge cache</button></form>`;
++ if (section === 'gpus' && data.state && Array.isArray(data.state.models)) actions = data.state.models.map((m) => `<form class="ex-console-action" method="post" action="/console/gpus/${encodeURIComponent(m.key)}/launch"><button class="ovk-btn ovk-btn--quiet" type="submit">Request ${esc(m.display_name || m.key)}</button></form><form class="ex-console-action" method="post" action="/console/gpus/${encodeURIComponent(m.key)}/stop"><button class="ovk-btn ovk-btn--quiet" type="submit">Stop ${esc(m.display_name || m.key)}</button></form>`).join('');
++ if (section === 'pricebook' && Array.isArray(data.pending)) actions = `<form class="ex-console-action" method="post" action="/console/pricebook/run"><button class="ovk-btn ovk-btn--quiet" type="submit">Read provider listings now</button></form>` + data.pending.map((c) => `<form class="ex-console-action" method="post" action="/console/pricebook/changes/${encodeURIComponent(c.id)}"><button class="ovk-btn ovk-btn--quiet" name="decision" value="approve">Approve ${esc(c.provider_key || c.model_key)}</button><button class="ovk-btn ovk-btn--quiet" name="decision" value="reject">Reject</button></form>`).join('');
++ if (section === 'exchange' && data.credits && Array.isArray(data.credits.review)) actions = data.credits.review.map((c) => `<form class="ex-console-action" method="post" action="/console/exchange/credits/${encodeURIComponent(c.id)}/resolve"><button class="ovk-btn ovk-btn--quiet" type="submit">Mark ${esc(c.id)} resolved</button></form>`).join('');
if (section === 'runs') {
const rows = Array.isArray(data.rows) ? data.rows : [];
-- actions = rows.filter((run) => ['queued', 'running'].includes(run.status)).map((run) => `<form class="ex-console-action" method="post" action="/console/runs/${encodeURIComponent(run.id)}/cancel"><button class="ovk-button ovk-button-quiet" type="submit">Cancel ${esc(run.id)}</button></form>`).join('');
-- if (data.run && ['queued', 'running'].includes(data.run.status)) actions += `<form class="ex-console-action" method="post" action="/console/runs/${encodeURIComponent(data.run.id)}/cancel"><button class="ovk-button ovk-button-quiet" type="submit">Cancel run</button></form>`;
++ actions = rows.filter((run) => ['queued', 'running'].includes(run.status)).map((run) => `<form class="ex-console-action" method="post" action="/console/runs/${encodeURIComponent(run.id)}/cancel"><button class="ovk-btn ovk-btn--quiet" type="submit">Cancel ${esc(run.id)}</button></form>`).join('');
++ if (data.run && ['queued', 'running'].includes(data.run.status)) actions += `<form class="ex-console-action" method="post" action="/console/runs/${encodeURIComponent(data.run.id)}/cancel"><button class="ovk-btn ovk-btn--quiet" type="submit">Cancel run</button></form>`;
}
return `<div class="ex-wrap ex-page ex-console">
<header class="ex-console-head"><p class="ex-eyebrow">OpenVibe.Exchange · owner</p><h1>AI operator console</h1><p class="ex-prose">Manage the AI router through its JSON admin API. Provider secrets are never shown here.</p></header>
<nav class="ex-console-nav" aria-label="AI console">${nav}</nav>
${error ? `<p class="ex-console-error" role="alert">${esc(error)}</p>` : ''}
--<section class="ex-console-panel"><h2>${esc(section[0].toUpperCase() + section.slice(1))}</h2>${actions}<pre><code>${json}</code></pre></section>
++${designed.SECTIONS[section] && !error ? `<section class="ex-console-panel ex-console-panel--designed">${designed.SECTIONS[section](data)}<details class="exc-fold exc-raw"><summary>The data as JSON (${esc(String(json.length))} characters)</summary><pre><code>${json}</code></pre></details></section>` : `<section class="ex-console-panel"><h2>${esc(section[0].toUpperCase() + section.slice(1))}</h2>${actions}<pre><code>${json}</code></pre></section>`}
</div>`;
}
ddiff --git a/server/views/credentials.js b/server/views/credentials.js
iindex 6f385ad..bf75115 100644
---- a/server/views/credentials.js
++++ b/server/views/credentials.js
@@@ -8,8 +8,8 @@ function credentialsPage({ credentials = [], error = null, balance = null }) {
const cards = credentials.length ? credentials.map((c) => `<article class="ex-credential-card">
<div><strong>${esc(c.label || c.provider || c.kind)}</strong><span class="ex-credential-kind">${esc(c.kind === 'session_cookie' ? 'Browser session cookie' : 'API key')}</span></div>
<dl><dt>Provider</dt><dd>${esc(c.provider || '—')}</dd><dt>Status</dt><dd>${esc(c.status || (c.expired ? 'expired' : 'saved'))}</dd><dt>Added</dt><dd>${esc(c.created || '—')}</dd>${c.hint ? `<dt>Key hint</dt><dd>••••${esc(c.hint)}</dd>` : ''}${c.last_used ? `<dt>Last used</dt><dd>${esc(c.last_used)}</dd>` : ''}</dl>
--${c.kind === 'session_cookie' || ['openrouter', 'deepseek'].includes(c.provider) ? `<form method="post" action="/privacy/credentials/${encodeURIComponent(c.id)}/balance"><button class="ovk-button ovk-button-quiet" type="submit">Check balance</button></form>` : ''}
--<form method="post" action="/privacy/credentials/${encodeURIComponent(c.id)}/remove"><button class="ovk-button ovk-button-quiet" type="submit">Remove</button></form>
++${c.kind === 'session_cookie' || ['openrouter', 'deepseek'].includes(c.provider) ? `<form method="post" action="/privacy/credentials/${encodeURIComponent(c.id)}/balance"><button class="ovk-btn ovk-btn--quiet" type="submit">Check balance</button></form>` : ''}
++<form method="post" action="/privacy/credentials/${encodeURIComponent(c.id)}/remove"><button class="ovk-btn ovk-btn--quiet" type="submit">Remove</button></form>
</article>`).join('') : '<p class="ex-prose">No provider credentials are saved to your AI account yet.</p>';
return `<div class="ex-wrap ex-page ex-credentials">
<header class="ex-credentials-head"><p class="ex-eyebrow">Your account</p><h1>Your keys and accounts</h1><p class="ex-prose">Provider keys and browser session cookies are stored encrypted by OpenVibe.AI. This page shows metadata and never displays a saved value.</p></header>
@@@ -21,13 +21,13 @@ ${balance ? `<section class="ex-credential-balance" role="status"><h2>Balance fr
<label>API key<input name="key" type="password" autocomplete="new-password" required minlength="8" maxlength="400"></label>
<label>Label (optional)<input name="label" maxlength="80" autocomplete="off"></label>
<label>Custom base URL (custom provider only)<input name="base_url" type="url" placeholder="https://api.example.com/v1"></label>
--<button class="ovk-button" type="submit">Save and check key</button></form></section>
++<button class="ovk-btn ovk-btn--primary" type="submit">Save and check key</button></form></section>
<section class="ex-credential-section"><h2>Add a browser session cookie</h2><p class="ex-prose">A session cookie can grant access to your provider account until it expires or you remove it. Use a dedicated browser session and remove it here when you no longer need it. OpenVibe.AI stores it encrypted and checks only the provider balance endpoints it supports.</p>
<form class="ovk-form ex-credential-form" method="post" action="/privacy/credentials"><input type="hidden" name="kind" value="session_cookie">
<label>Provider<select name="provider"><option value="anthropic">Anthropic</option><option value="openai">OpenAI</option></select></label>
<label>Cookie value<input name="cookie" type="password" autocomplete="new-password" required minlength="16" maxlength="4096"></label>
<label>Anthropic organization ID (required for Anthropic)<input name="org_id" autocomplete="off" placeholder="UUID from provider console"></label>
--<button class="ovk-button ovk-button-quiet" type="submit">Save session cookie</button></form></section>
++<button class="ovk-btn ovk-btn--quiet" type="submit">Save session cookie</button></form></section>
</div>`;
}
ddiff --git a/test/console-ai.test.js b/test/console-ai.test.js
nnew file mode 100644
iindex 0000000..3b0187c
---- /dev/null
++++ b/test/console-ai.test.js
@@@ -0,0 +1,42 @@
++'use strict';
++// The owner console's designed AI sections (views/console-ai.js): providers, models and the price book.
++const assert = require('assert');
++const { providersSection, modelsSection, pricebookSection } = require('../server/views/console-ai');
++
++const secret = 'sk-live-never-on-a-page';
++const providers = [
++ { key: 'openai', display_name: 'OpenAI', base_url: 'https://api.openai.com/v1', status: 'active', key_present: true, preference_order: 10, markup_percent: 0, provider_access: 'AUTHORIZED_WHOLESALE', health: { state: 'up' }, api_key: secret },
++ { key: 'venice', display_name: 'Venice AI', base_url: 'https://api.venice.ai/api/v1', status: 'disabled', key_present: false, preference_order: 60, uncensored: true, provider_access: 'UNVERIFIED' },
++];
++const presets = [
++ { key: 'pricey', display_name: 'Pricey', base_url: 'https://api.pricey.example/v1', provider_access: 'UNVERIFIED', models: [{ model_key: 'big', cost: { in_per_mtok: 3, out_per_mtok: 15 } }] },
++ { key: 'unpriced', display_name: 'Unpriced', base_url: 'https://api.unpriced.example/v1', provider_access: 'UNVERIFIED', uncensored: true, models: [{ model_key: 'x', cost: { in_per_mtok: null, out_per_mtok: null } }] },
++ { key: 'cheap', display_name: 'Cheap', base_url: 'https://api.cheap.example/v1', provider_access: 'UNVERIFIED', models: [{ model_key: 'small', cost: { in_per_mtok: 0.02, out_per_mtok: 0.08 }, source: 'https://cheap.example/pricing', checked: '2026-11-09' }] },
++ { key: 'openai', display_name: 'OpenAI', base_url: 'https://api.openai.com/v1', models: [] },
++];
++
++const html = providersSection({ providers, models: [], presets });
++assert.ok(!html.includes(secret), 'a key value never reaches the page');
++assert.ok(html.includes('Key stored') && html.includes('No key'), 'a key is only present or not');
++assert.ok(html.includes('18+'), 'the uncensored tier is marked 18+');
++assert.ok(html.includes('type="password"'), 'keys are typed into password fields');
++const order = ['Cheap', 'Pricey', 'Unpriced'].map((n) => html.indexOf(`<b>${n}</b>`));
++assert.ok(order.every((i) => i > 0) && order[0] < order[1] && order[1] < order[2], 'presets: cheapest first, unpriced last');
++assert.ok(!html.includes('<b>OpenAI</b><small>api.openai.com · 0'), 'an added provider is not offered again as a preset');
++assert.ok(html.includes('No price confirmed on their own page yet.'), 'a missing price says so');
++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');
++
++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' } },
++] });
++assert.ok(models.includes('Your price') && models.includes('promo'), 'the owner price shows with its reason');
++assert.ok(models.includes('name="clear" value="1"'), 'the owner price can be cleared');
++assert.ok(models.includes('name="reason" required'), 'a price needs a reason');
++
++const book = pricebookSection({ pending: [{ id: 'pc_1', provider_key: 'deepseek', model_key: 'v4-flash', field: 'out_per_mtok', old_price: 1.2, new_price: 0.9, change_pct: -0.25, source: 'https://example/pricing', checked_at: '2026-11-09' }], recent: [] });
++assert.ok(book.includes('value="approve"'), 'Accept sends approve, the word AI takes');
++assert.ok(book.includes('$1.20') && book.includes('$0.90') && book.includes('-25%'), 'old, new and the change');
++assert.ok(pricebookSection({ pending: [], recent: [] }).includes('Nothing waiting'), 'an empty price book says so');
++console.log('console-ai: providers, models and the price book passed');