openvibers/openvibe.exchange Commits
The owner console's Providers, Models and Price book, designed (#162)
Changes
nnew file mode 100644@@@ -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}++}@@@ -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); }nnew file mode 100644@@@ -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 };@@@ -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>`;}@@@ -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>`;}nnew file mode 100644@@@ -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');
new file mode 100644
new file mode 100644
@@ -0,0 +1,77 @@
@@ -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}
+}
@@ -19,8 +19,12 @@ function createConsoleRoutes({ config, ai }) {
@@ -19,8 +19,12 @@ function createConsoleRoutes({ config, ai }) {
const r = express.Router();
const r = express.Router();
r.use(express.urlencoded({ extended: false, limit: '32kb' }));
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');
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, {
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',
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 })),
body: raw(consolePage({ section, data, error })),
});
});
r.use((req, res, next) => isOwner(req) ? next() : res.sendStatus(404));
r.use((req, res, next) => isOwner(req) ? next() : res.sendStatus(404));
@@ -199,8 +203,10 @@ function createConsoleRoutes({ config, ai }) {
@@ -199,8 +203,10 @@ function createConsoleRoutes({ config, ai }) {
const cost = {};
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']])
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]);
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)}`, {
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');
return res.redirect(303, '/console/models');
} catch (err) { return page(req, res, 'models', {}, err.message || 'The model price override could not be saved.', 502); }
} catch (err) { return page(req, res, 'models', {}, err.message || 'The model price override could not be saved.', 502); }
new file mode 100644
new file mode 100644
@@ -0,0 +1,181 @@
@@ -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 };
@@ -1,6 +1,7 @@
@@ -1,6 +1,7 @@
'use strict';
'use strict';
const { esc } = require('../render/html');
const { esc } = require('../render/html');
+const designed = require('./console-ai');
const SECTIONS = [
const SECTIONS = [
['Overview', '/console'], ['Defaults', '/console/defaults'], ['GPU supply (dry run)', '/console/supply'], ['Providers', '/console/providers'], ['Models', '/console/models'],
['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 }) {
@@ -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 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));
const json = esc(JSON.stringify(safe(data), null, 2));
let actions = '';
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) => {
if (section === 'providers' && Array.isArray(data.providers)) actions = data.providers.map((p) => {
const key = encodeURIComponent(p.key || '');
const key = encodeURIComponent(p.key || '');
const next = p.status === 'disabled' ? 'active' : 'disabled';
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('');
}).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') {
if (section === 'runs') {
const rows = Array.isArray(data.rows) ? data.rows : [];
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">
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>
<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>
<nav class="ex-console-nav" aria-label="AI console">${nav}</nav>
${error ? `<p class="ex-console-error" role="alert">${esc(error)}</p>` : ''}
${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>`;
</div>`;
}
}
@@ -8,8 +8,8 @@ function credentialsPage({ credentials = [], error = null, balance = null }) {
@@ -8,8 +8,8 @@ function credentialsPage({ credentials = [], error = null, balance = null }) {
const cards = credentials.length ? credentials.map((c) => `<article class="ex-credential-card">
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>
<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>
<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>';
</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">
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>
<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
@@ -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>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>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>
<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>
<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">
<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>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>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>
<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>`;
</div>`;
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,42 @@
@@ -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');