openvibers/openvibe.exchange Commits

Open an approved offer and show the GPU supply route (#213)

OP OpenVibers committed 2 days ago · 60b0db101b · 6 files, +158 −8

* Open an approved offer and show the GPU supply route

The owner console's Models section now reads the route AI's registry answers:
live, or the gates that stop it (provider off, no key, model off, no approved
price, resale not allowed). One action posts to AI's `open` route, which
approves the model's waiting prices and switches the provider and model on; a
missing key or price disables the button with that reason in the owner's words.

The GPU supply section leaves the raw-JSON page: it shows the fixed input and
output prices, the approved hourly and monthly ceilings, the selected host and
whether renting is cheaper, and whether a registered GPU host serves the fixed
route. Saving a plan is the existing form; enabling live supply needs the typed
confirmation and AI still enforces every owner gate. The nav label loses "dry
run" because a live plan is now possible.

Tests: console-ai (route line, one-action form, disabled reasons, supply plan
and gate), console-merge (open and supply writes, the typed confirmation).

* Drop the stale dry-run supply form: the designed section is the page

Changes

ddiff --git a/public/css/console-ai.css b/public/css/console-ai.css
iindex bbaf706..8302ffe 100644
---- a/public/css/console-ai.css
++++ b/public/css/console-ai.css
@@@ -101,3 +101,7 @@
.exc-pb-prov>.exc-prov-head .exc-decide{flex-basis:100%;margin-left:0}
.exc-pb-all .exc-decide,.exc-pb-all .exc-decide .ovk-btn{flex:1}
}
++
++/* An offer's route: whether the catalogue and router list it, and the gate that stops it. */
++.exc-route{display:flex;flex-wrap:wrap;align-items:baseline;gap:4px 10px;margin:0}
++.exc-route small{color:var(--ovk-muted);font-size:.82rem}
ddiff --git a/server/http/console.js b/server/http/console.js
iindex d02190a..513ab5f 100644
---- a/server/http/console.js
++++ b/server/http/console.js
@@@ -25,7 +25,7 @@ function createConsoleRoutes({ config, ai }) {
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,
++ head: ['providers', 'models', 'pricebook', 'supply'].includes(section) ? DESIGNED_HEAD : undefined,
body: raw(consolePage({ section, data, error })),
});
r.use((req, res, next) => isOwner(req) ? next() : res.sendStatus(req.viewer?.kind === 'user' ? 403 : 404));
@@@ -139,10 +139,14 @@ function createConsoleRoutes({ config, ai }) {
});
r.post('/console/supply', async (req,res) => {
if (!sameOrigin(req)) return res.sendStatus(403);
-- const body = { model: req.body.model, runtime: req.body.runtime };
++ const enabled = req.body.enabled === '1';
++ if (enabled && String(req.body.confirm || '').trim().toLowerCase() !== 'open') {
++ return page(req, res, 'supply', {}, 'Type open in the confirmation field to turn on live supply. Nothing was changed.', 422);
++ }
++ const body = { model: req.body.model, runtime: req.body.runtime, enabled };
for (const k of ['input_per_million','output_per_million','hourly_ceiling_usd','monthly_ceiling_usd','required_vram_mb','idle_minutes']) body[k] = Number(req.body[k]);
try { await ai.adminRequest('/api/v1/admin/console/supply', { method:'POST', subject:req.viewer.subject, body }); return res.redirect(303,'/console/supply'); }
-- catch { return page(req,res,'supply',{},'Supply settings could not be saved.',502); }
++ catch (err) { return page(req,res,'supply',{},err.message || 'Supply settings could not be saved.',err.status === 409 || err.status === 422 ? err.status : 502); }
});
r.post('/console/providers', async (req, res) => {
if (!sameOrigin(req)) return res.sendStatus(403);
@@@ -231,6 +235,17 @@ function createConsoleRoutes({ config, ai }) {
return res.redirect(303, '/console/models');
} catch (err) { return page(req, res, 'models', {}, err.message || 'The change could not be saved.', 502); }
});
++ // The owner's one action that opens an offer: approve its waiting prices and switch the provider and model on
++ // (AI's server/exchange/offers.js). A missing key or price comes back as the plain sentence AI answered.
++ r.post('/console/models/:provider/:model/open', async (req, res) => {
++ if (!sameOrigin(req)) return res.sendStatus(403);
++ try {
++ await ai.adminRequest(`/api/v1/admin/console/models/${encodeURIComponent(req.params.provider)}/${encodeURIComponent(req.params.model)}/open`, {
++ method: 'POST', subject: req.viewer.subject, body: {},
++ });
++ return res.redirect(303, '/console/models');
++ } catch (err) { return page(req, res, 'models', {}, err.message || 'The offer could not be opened.', err.status === 409 ? 409 : 502); }
++ });
r.post('/console/models/:provider/:model/settings', async (req, res) => {
if (!sameOrigin(req)) return res.sendStatus(403);
try {
ddiff --git a/server/views/console-ai.js b/server/views/console-ai.js
iindex befa5b2..1e83a78 100644
---- a/server/views/console-ai.js
++++ b/server/views/console-ai.js
@@@ -39,6 +39,13 @@ const healthWord = (h) => {
if (/down|open|fail/i.test(s)) return ['Failing', 'bad'];
return [String(s), ''];
};
++// The route reasons the AI registry answers (server/exchange/offers.js), in the owner's words.
++const ROUTE_WORDS = Object.freeze({
++ no_provider: 'no provider', provider_off: 'provider off', no_key: 'no key', access_restricted: 'resale not allowed',
++ model_off: 'model off', model_gone: 'gone from the provider list', kind_not_offered: 'kind not sold',
++ price_unknown: 'no approved price',
++});
++const routeWords = (route) => (route && Array.isArray(route.reasons) ? route.reasons.map((r) => ROUTE_WORDS[r] || r).join(' · ') : '');
// ── Providers ──
function providerCard(p, models) {
@@@ -150,10 +157,28 @@ function modelsSection(data = {}) {
const path = `/console/models/${encodeURIComponent(m.provider)}/${encodeURIComponent(id)}`;
const o = m.owner_override;
const accepted = m.accepted_price || m.cost;
++ const route = m.route || null;
++ const live = Boolean(route && route.live);
++ const reasons = route && Array.isArray(route.reasons) ? route.reasons : [];
++ const keyFirst = Boolean(route && !route.key_present);
++ const priceFirst = reasons.includes('price_unknown') && !(route && route.pending_prices);
++ const missing = Boolean(route && route.missing_since);
++ const restricted = reasons.includes('access_restricted');
++ const canOpen = Boolean(route && !keyFirst && !priceFirst && !missing && !restricted);
++ const showOpen = Boolean(route && (!live || (route.pending_prices || 0) > 0));
++ const submit = live ? 'Approve waiting prices' : 'Approve prices and open route';
++ const open = showOpen ? `<form method="post" action="${path}/open" class="exc-inline">${canOpen
++ ? `<button class="ovk-btn ${live ? 'ovk-btn--quiet' : 'ovk-btn--primary'}" type="submit">${esc(submit)}</button>`
++ : `<button class="ovk-btn ovk-btn--quiet" type="submit" disabled>${keyFirst ? 'Add the provider key first' : priceFirst ? 'Approve a price first' : 'Resolve the gate first'}</button>`}</form>` : '';
++ const routeLine = route ? `<p class="exc-route">${live ? tag('Live route', 'ok') : tag('Not routed', 'warn')}<small>${live
++ ? 'The catalogue and the router list this offer.'
++ : esc(routeWords(route) || 'not listed')}${route.pending_prices ? ` · ${esc(route.pending_prices)} waiting price${route.pending_prices === 1 ? '' : 's'}` : ''}</small></p>` : '';
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>
++ ${routeLine}
++ ${open}
${m.metadata?.missing_since ? `<p>Gone from the provider list since ${esc(m.metadata.missing_since)}.</p>` : ''}
${accepted?.source ? `<p>Price source: <a href="${esc(accepted.source)}" rel="noopener noreferrer">${esc(accepted.source)}</a></p>` : ''}
<form method="post" action="${path}/settings">
@@@ -253,5 +278,53 @@ ${recent.length ? `<section class="exc-sec"><h3>Applied lately <span class="exc-
</div>`;
}
--const SECTIONS = { providers: providersSection, models: modelsSection, pricebook: pricebookSection, sync: syncSection };
--module.exports = { providersSection, modelsSection, pricebookSection, syncSection, SECTIONS, CSS: marks.CSS };
++// ── GPU supply ──
++function supplySection(data = {}) {
++ const reports = Array.isArray(data.reports) ? data.reports : [];
++ const settings = Array.isArray(data.settings) ? data.settings : [];
++ const gate = data.gate || { ok: false, reason: 'GPU supply is not available on this host.' };
++ const ACTION = {
++ would_rent: 'Would rent the selected host', use_reseller: 'Buying these tokens is cheaper',
++ would_stop: 'No demand right now', needs_measurement: 'Needs a measured throughput first',
++ ceiling_required: 'The host hour is over the ceilings',
++ };
++ const form = (plan = {}) => `<form method="post" action="/console/supply" class="exc-form">
++ <label><span>Model</span><input name="model" required maxlength="200" value="${esc(plan.model || '')}" placeholder="e.g. deepseek-r1-32b-abliterated"></label>
++ <label><span>Runtime</span><select name="runtime">${['vllm', 'sglang'].map((r) => `<option value="${r}"${(plan.runtime || 'vllm') === r ? ' selected' : ''}>${esc(r)}</option>`).join('')}</select></label>
++ <label><span>Input USD / million</span><input name="input_per_million" type="number" min="0" step="any" required value="${esc(plan.input_per_million ?? '')}"></label>
++ <label><span>Output USD / million</span><input name="output_per_million" type="number" min="0" step="any" required value="${esc(plan.output_per_million ?? '')}"></label>
++ <label><span>Hourly spend ceiling USD</span><input name="hourly_ceiling_usd" type="number" min="0" step="any" required value="${esc(plan.hourly_ceiling_usd ?? '')}"></label>
++ <label><span>Monthly spend ceiling USD</span><input name="monthly_ceiling_usd" type="number" min="0" step="any" required value="${esc(plan.monthly_ceiling_usd ?? '')}"></label>
++ <label><span>Required VRAM MB</span><input name="required_vram_mb" type="number" min="1" step="1" required value="${esc(plan.required_vram_mb ?? '24000')}"></label>
++ <label><span>Stop after idle minutes</span><input name="idle_minutes" type="number" min="1" step="1" required value="${esc(plan.idle_minutes ?? '10')}"></label>
++ <label><span>Live supply</span><select name="enabled"><option value="0"${plan.enabled ? '' : ' selected'}>Off, quote only</option><option value="1"${plan.enabled ? ' selected' : ''}>On, rent when it is cheaper</option></select></label>
++ <label class="exc-wide"><span>Type open to confirm a live plan</span><input name="confirm" maxlength="8" placeholder="open"></label>
++ <p class="exc-actions">${btn('Save plan', 'primary')}</p>
++</form>`;
++ const planCard = (report, plan) => {
++ const route = report.route || { registered: false };
++ const selected = report.selected;
++ const routeLine = route.registered
++ ? `${route.live ? tag('Fixed route live', 'ok') : tag('Route registered, not live', 'warn')}<small>${esc(route.provider_key || '')} · ${route.fixed ? 'your fixed price' : 'the computed price'} · ${route.price && route.price.in != null ? `$${esc(route.price.in)} in / $${esc(route.price.out)} out per 1M` : 'no price'}${route.live ? '' : ` · ${esc(route.provider_status || '')}/${esc(route.model_status || '')}`}</small>`
++ : `<span class="exc-quiet">${esc(route.reason || 'No host has registered this model yet.')}</span>`;
++ const openRoute = route.registered && !route.live ? `<form method="post" action="/console/models/${encodeURIComponent(route.provider_key || '')}/${encodeURIComponent(plan.model || '')}/open" class="exc-inline">${btn('Open the fixed route', 'quiet')}</form>` : '';
++ return `<li class="exc-pb-prov">
++ <div class="exc-prov-head">${mark(plan.model || 'gpu', 32)}<span class="exc-prov-name"><b>${esc(plan.model || 'GPU plan')}</b><small>${esc(plan.runtime || '')} · ${esc(money(plan.input_per_million) || '–')} in / ${esc(money(plan.output_per_million) || '–')} out per 1M · ceiling ${esc(money(plan.hourly_ceiling_usd) || '–')}/h and ${esc(money(plan.monthly_ceiling_usd) || '–')}/month</small></span>${report.active ? tag('Live supply', 'ok') : report.enabled ? tag('Enabled, blocked', 'warn') : tag('Quote only')}</div>
++ <p class="exc-tags">${report.enabled ? tag('Enabled') : ''}${selected ? tag(String(selected.gpu || '')) : ''}</p>
++ <p class="exc-note">${esc(ACTION[report.action] || report.action || '')}${selected ? ` · ${esc(selected.provider || '')} ${esc(selected.gpu || '')} ${esc(selected.region || '')} at ${esc(money(selected.hourly_usd))}/h${report.rental_cost_usd != null ? ` · rent ${esc(money(report.rental_cost_usd))}/day` : ''}${report.reseller_cost_usd != null ? ` · buying ${esc(money(report.reseller_cost_usd))}/day` : ' · no resale price'}` : ''}</p>
++ <p class="exc-route">${routeLine}</p>
++ ${openRoute}
++ ${form({ ...plan, enabled: Boolean(plan.enabled) })}
++</li>`;
++ };
++ const plans = reports.map((report, i) => ({ report, plan: settings[i] || { ...report, ...(report.price ? { input_per_million: report.price.in, output_per_million: report.price.out } : {}) } }));
++ return `<div class="exc">
++<header class="exc-head"><h2>GPU supply</h2><p>What a rented GPU must earn per million tokens, and the host hour you will pay for it. The fixed prices are the route's price; a live plan asks for the cheapest host under your ceilings only when that is cheaper than buying the same tokens.</p>
++<p class="exc-note">${gate.ok ? 'Owner gate open: a marketplace key, a monthly cap and a bounded cost allowance are set.' : `Gate closed: ${esc(gate.reason || 'the GPU provisioner is not set up')}`}</p></header>
++<section class="exc-sec"><h3>Plans <span class="exc-n">${plans.length}</span></h3>${plans.length ? `<ul class="exc-list">${plans.map(({ report, plan }) => planCard(report, plan)).join('')}</ul>` : '<p class="exc-note">No plan yet. Save fixed prices and ceilings below to price a host; the route opens when a host registers it.</p>'}
++<details class="exc-fold"><summary>Save another plan</summary><div class="exc-fold-body">${form({})}</div></details></section>
++</div>`;
++}
++
++const SECTIONS = { providers: providersSection, models: modelsSection, pricebook: pricebookSection, supply: supplySection, sync: syncSection };
++module.exports = { providersSection, modelsSection, pricebookSection, supplySection, syncSection, SECTIONS, CSS: marks.CSS };
ddiff --git a/server/views/console.js b/server/views/console.js
iindex cacff5d..8fa765d 100644
---- a/server/views/console.js
++++ b/server/views/console.js
@@@ -4,7 +4,7 @@ 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'],
++ ['Overview', '/console'], ['Defaults', '/console/defaults'], ['GPU supply', '/console/supply'], ['Providers', '/console/providers'], ['Models', '/console/models'],
['Routes', '/console/routes'], ['Templates', '/console/templates'], ['Workflows', '/console/workflows'],
['Runs', '/console/runs'], ['Quotas', '/console/quotas'], ['Usage', '/console/usage'],
['Cache', '/console/cache'], ['GPUs', '/console/gpus'], ['Price book', '/console/pricebook'],
@@@ -23,7 +23,6 @@ function consolePage({ section = 'overview', data = {}, error = null }) {
const json = esc(JSON.stringify(safe(data), null, 2));
let actions = '';
if (section === 'orders' && Array.isArray(data.orders)) actions = data.orders.map(order => `<form class="ovk-form" method="post" action="/console/orders/${encodeURIComponent(order.capability)}"><h2>${esc(order.capability)}</h2><label>Use this order<select name="enabled"><option value="0"${order.enabled ? '' : ' selected'}>Off</option><option value="1"${order.enabled ? ' selected' : ''}>On</option></select></label><label>Provider and model choices<textarea name="choices" rows="6" required>${esc(JSON.stringify(order.choices || [], null, 2))}</textarea></label><p>First healthy choice answers. An empty enabled array allows no provider. Version ${esc(order.version || 'none')}.</p><button class="ovk-btn ovk-btn--primary" type="submit">Save order</button></form>`).join('');
-- 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 || '');
ddiff --git a/test/console-ai.test.js b/test/console-ai.test.js
iindex 98a678f..b4bb2b5 100644
---- a/test/console-ai.test.js
++++ b/test/console-ai.test.js
@@@ -1,7 +1,7 @@
'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, syncSection } = require('../server/views/console-ai');
++const { providersSection, modelsSection, pricebookSection, supplySection, syncSection } = require('../server/views/console-ai');
const secret = 'sk-live-never-on-a-page';
const providers = [
@@@ -47,6 +47,36 @@ assert.ok(models.includes('name="clear" value="1"'), 'the owner price can be cle
assert.ok(models.includes('name="reason" required'), 'a price needs a reason');
assert.ok(models.includes('name="display_name"') && models.includes('name="content_tier"'), 'model names and content tiers are editable');
++// The offer's route: waiting prices are approved and the offer switched on in one owner action, and a
++// missing key is said before the button rather than after a failed write.
++const routed = modelsSection({ models: [
++ { provider: 'refuseless', model: 'abliterated-model', accepted_price: { in_per_mtok: 1, out_per_mtok: 3 }, status: 'disabled',
++ route: { live: false, reasons: ['provider_off', 'model_off'], key_present: true, pending_prices: 2 } },
++ { provider: 'keyless', model: 'abliterated-model', accepted_price: { in_per_mtok: 1, out_per_mtok: 3 }, status: 'disabled',
++ route: { live: false, reasons: ['provider_off', 'no_key', 'model_off'], key_present: false, pending_prices: 0 } },
++ { provider: 'openai', model: 'gpt-5-mini', accepted_price: { in_per_mtok: 0.25, out_per_mtok: 2 },
++ route: { live: true, reasons: [], key_present: true, pending_prices: 0 } },
++] });
++assert.ok(routed.includes('Not routed') && routed.includes('provider off · model off'), 'the closed gates are named');
++assert.ok(routed.includes('action="/console/models/refuseless/abliterated-model/open"'), 'the owner can open the offer');
++assert.ok(routed.includes('Approve prices and open route') && routed.includes('2 waiting prices'), 'waiting prices show beside the action');
++assert.ok(routed.includes('Add the provider key first'), 'a missing key disables the action with its reason');
++assert.ok(routed.includes('Live route') && !routed.includes('action="/console/models/openai/gpt-5-mini/open"'), 'a live route needs no open action');
++
++// GPU supply: fixed per-token prices, the approved ceilings, the selected host and the fixed route it serves.
++const supply = supplySection({ gate: { ok: true }, live: true, settings: [{ model: 'deepseek-r1-32b-abliterated', runtime: 'vllm',
++ input_per_million: 1, output_per_million: 2, hourly_ceiling_usd: 0.5, monthly_ceiling_usd: 300, required_vram_mb: 24000, idle_minutes: 10, enabled: true }],
++ reports: [{ model: 'deepseek-r1-32b-abliterated', enabled: true, active: true, action: 'would_rent', price: { in: 1, out: 2 },
++ selected: { provider: 'vast', gpu: 'RTX 4090', region: 'US', hourly_usd: 0.5 }, rental_cost_usd: 12, reseller_cost_usd: null,
++ route: { registered: true, provider_key: 'gpu.deepseek-r1-32b-abliterated', provider_status: 'active', model_status: 'active', fixed: true, price: { in: 1, out: 2 }, live: true } }] });
++assert.ok(supply.includes('Fixed route live') && supply.includes('gpu.deepseek-r1-32b-abliterated'), 'the fixed route is named');
++assert.ok(supply.includes('RTX 4090') && supply.includes('$0.50') && supply.includes('Would rent the selected host'), 'the selected host and the decision');
++assert.ok(supply.includes('name="enabled"') && supply.includes('value="1" selected'), 'the owner switch renders on the saved plan');
++assert.ok(supply.includes('Type open to confirm a live plan'), 'live supply needs the typed confirmation');
++const closed = supplySection({ gate: { ok: false, reason: 'Add a Vast.ai or RunPod API key first; nothing was changed.' }, settings: [], reports: [] });
++assert.ok(closed.includes('Gate closed: Add a Vast.ai or RunPod API key first'), 'the owner gate is said in words');
++assert.ok(!/\$0(?!\.)/.test(supply), 'a missing price is never $0');
++
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');
ddiff --git a/test/console-merge.test.js b/test/console-merge.test.js
iindex 746b1c3..22f1e3c 100644
---- a/test/console-merge.test.js
++++ b/test/console-merge.test.js
@@@ -125,6 +125,35 @@ app.use(createConsoleRoutes({
});
assert.strictEqual(providerSettings.status, 303);
assert.ok(writes.some((write) => write.path === '/api/v1/providers/groq' && write.body.markup_percent === 12.5 && write.body.uncensored === true));
++ const modelOpen = await fetch(`${base}/console/models/groq/openai%2Fgpt-oss-120b/open`, {
++ method: 'POST', headers: { 'x-test-role': 'owner', Origin: 'https://openvibe.exchange' }, redirect: 'manual',
++ });
++ assert.strictEqual(modelOpen.status, 303);
++ assert.strictEqual(writes.at(-1).path, '/api/v1/admin/console/models/groq/openai%2Fgpt-oss-120b/open');
++ assert.strictEqual(writes.at(-1).method, 'POST');
++ const supplyConfirm = await fetch(`${base}/console/supply`, {
++ method: 'POST', headers: { 'x-test-role': 'owner', Origin: 'https://openvibe.exchange', 'content-type': 'application/x-www-form-urlencoded' },
++ body: new URLSearchParams({ model: 'm', runtime: 'vllm', input_per_million: '1', output_per_million: '2', hourly_ceiling_usd: '1',
++ monthly_ceiling_usd: '100', required_vram_mb: '24000', idle_minutes: '10', enabled: '1' }),
++ });
++ assert.strictEqual(supplyConfirm.status, 422, 'live supply needs the typed confirmation');
++ const beforeSupply = writes.length;
++ const supplyOn = await fetch(`${base}/console/supply`, {
++ method: 'POST', headers: { 'x-test-role': 'owner', Origin: 'https://openvibe.exchange', 'content-type': 'application/x-www-form-urlencoded' },
++ body: new URLSearchParams({ model: 'm', runtime: 'vllm', input_per_million: '1', output_per_million: '2', hourly_ceiling_usd: '1',
++ monthly_ceiling_usd: '100', required_vram_mb: '24000', idle_minutes: '10', enabled: '1', confirm: 'open' }), redirect: 'manual',
++ });
++ assert.strictEqual(supplyOn.status, 303);
++ assert.strictEqual(writes.length, beforeSupply + 1);
++ assert.strictEqual(writes.at(-1).path, '/api/v1/admin/console/supply');
++ assert.strictEqual(writes.at(-1).body.enabled, true);
++ const supplyOff = await fetch(`${base}/console/supply`, {
++ method: 'POST', headers: { 'x-test-role': 'owner', Origin: 'https://openvibe.exchange', 'content-type': 'application/x-www-form-urlencoded' },
++ body: new URLSearchParams({ model: 'm', runtime: 'vllm', input_per_million: '1', output_per_million: '2', hourly_ceiling_usd: '1',
++ monthly_ceiling_usd: '100', required_vram_mb: '24000', idle_minutes: '10', enabled: '0' }), redirect: 'manual',
++ });
++ assert.strictEqual(supplyOff.status, 303);
++ assert.strictEqual(writes.at(-1).body.enabled, false);
const modelOverride = await fetch(`${base}/console/models/groq/openai%2Fgpt-oss-120b/override`, {
method: 'POST', headers: { 'x-test-role': 'owner', Origin: 'https://openvibe.exchange', 'content-type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ input_per_million: '0.2', cached_per_million: '', output_per_million: '0.7', reason: 'Owner approved adjustment' }), redirect: 'manual',