openvibers/openvibe.exchange Commits
Show every GPU provider's policy and state in the owner console (#221)
Changes
@@@ -182,6 +182,12 @@ function createConsoleRoutes({ config, ai }) {if (!endpoint) return res.sendStatus(404);try {const data = await ai.adminRequest(endpoint + (req.originalUrl.includes('?') ? req.originalUrl.slice(req.originalUrl.indexOf('?')) : ''));++ if (section === 'supply') {++ // The GPU supply section also shows the owner's provider policy and what the provisioner++ // is doing. A read that fails leaves the rest of the page usable.++ try { data.policy = await ai.adminRequest('/api/v1/admin/console/gpu-policy'); } catch { data.policy = null; }++ try { data.gpus = await ai.adminRequest('/api/v1/admin/console/gpus'); } catch { data.gpus = null; }++ }return page(req, res, section, data);} catch (err) {return page(req, res, section, {}, err.message || 'AI admin API did not answer.', err.status === 404 ? 404 : 502);@@@ -216,6 +222,11 @@ function createConsoleRoutes({ config, ai }) {try { await ai.adminRequest('/api/v1/admin/console/supply', { method:'POST', subject:req.viewer.subject, body }); return res.redirect(303,'/console/supply'); }catch (err) { return page(req,res,'supply',{},err.message || 'Supply settings could not be saved.',err.status === 409 || err.status === 422 ? err.status : 502); }});++ const nanoOfUsd = (value) => {++ if (value == null || value === '') return null;++ const n = Number(value);++ return Number.isFinite(n) && n >= 0 ? String(BigInt(Math.round(n * 1e9))) : null;++ };r.post('/console/gpu-policy/:scope', async (req, res) => {if (!sameOrigin(req)) return res.sendStatus(403);const scope = String(req.params.scope || '');@@@ -223,9 +234,16 @@ function createConsoleRoutes({ config, ai }) {const body = {};for (const key of ['enabled', 'interruptible_allowed', 'quote_only']) if (Object.hasOwn(input, key)) body[key] = input[key] === '1';for (const key of ['order', 'max_replicas', 'monthly_cap_share_percent']) if (Object.hasOwn(input, key)) body[key] = Number(input[key]);++ for (const [usd, nano] of [['monthly_cap_usd', 'monthly_cap_nano'], ['interactive_cap_usd', 'interactive_cap_nano'], ['batch_cap_usd', 'batch_cap_nano'], ['max_hourly_usd', 'max_hourly_nano']]) {++ if (Object.hasOwn(input, usd)) body[nano] = nanoOfUsd(input[usd]);++ }for (const key of ['monthly_cap_nano', 'interactive_cap_nano', 'batch_cap_nano', 'max_hourly_nano']) if (Object.hasOwn(input, key)) body[key] = input[key] || null;if (Object.hasOwn(input, 'classes')) body.classes = String(input.classes).split(',').map(x => x.trim()).filter(Boolean);if (Object.hasOwn(input, 'regions')) body.regions = String(input.regions).split(',').map(x => x.trim()).filter(Boolean);++ if (Object.hasOwn(input, 'options')) {++ try { body.options = input.options ? JSON.parse(String(input.options)) : {}; }++ catch { return page(req, res, 'supply', {}, 'Provider options are not valid JSON.', 422); }++ }try {await ai.adminRequest(`/api/v1/admin/console/gpu-policy/${encodeURIComponent(scope)}`, {method: 'POST', subject: req.viewer.subject, role: req.viewer.role, body,@@@ -285,6 +285,70 @@ ${recent.length ? `<section class="exc-sec"><h3>Applied lately <span class="exc-</div>`;}++// The owner's GPU policy: every provider's switch, key (only the last four), order, classes, ceilings++// and share, plus the class caps and what each provider is doing right now. Data first; the forms are++// plain ovk markup so the main session can design the look without this code changing.++const nanoUsd = (n) => (n == null || n === '' ? null : `$${(Number(n) / 1e9).toFixed(2)}`);++const providerState = (gpuState, key) => {++ const instances = (gpuState && Array.isArray(gpuState.instances) ? gpuState.instances : []).filter((i) => i.marketplace === key);++ const live = instances.find((i) => ['starting', 'running', 'stopping'].includes(i.status));++ return live ? `${live.status} · ${live.gpu_class || live.job_class || 'interactive'} · ${live.gpu_name || ''} · ${nanoUsd(live.hourly_nano)}/h` : (gpuState && gpuState.marketplaces && gpuState.marketplaces[key] ? 'key set, idle' : 'no key');++};++function gpuPolicySection(data = {}) {++ const policy = data.policy && typeof data.policy === 'object' ? data.policy : null;++ const gpuState = data.gpus && data.gpus.state ? data.gpus.state : (data.gpus || null);++ const global = (policy && policy.global) || {};++ const providers = policy && Array.isArray(policy.providers) ? policy.providers : [];++ if (!policy && !gpuState) return '';++ const cap = gpuState ? { spent: nanoUsd(gpuState.spent_nano), committed: nanoUsd(gpuState.committed_nano), cap: nanoUsd(gpuState.cap_nano) } : null;++ const globalForm = `<form method="post" action="/console/gpu-policy/global" class="ovk-form">++ <label><span>Monthly cap (USD)</span><input name="monthly_cap_usd" type="number" min="0" step="0.01" value="${esc(global.monthly_cap_nano == null ? '' : Number(global.monthly_cap_nano) / 1e9)}"><small>${cap ? `${esc(cap.spent)} spent · ${esc(cap.committed)} committed of ${esc(cap.cap || 'no cap')}` : ''}</small></label>++ <label><span>Interactive cap (USD)</span><input name="interactive_cap_usd" type="number" min="0" step="0.01" value="${esc(global.interactive_cap_nano == null ? '' : Number(global.interactive_cap_nano) / 1e9)}"></label>++ <label><span>Batch cap (USD)</span><input name="batch_cap_usd" type="number" min="0" step="0.01" value="${esc(global.batch_cap_nano == null ? '' : Number(global.batch_cap_nano) / 1e9)}"></label>++ <label><span>Quote only</span><select name="quote_only"><option value="1"${global.quote_only ? ' selected' : ''}>Quotes only, rent nothing</option><option value="0"${global.quote_only ? '' : ' selected'}>Rent when the plan allows it</option></select></label>++ <p><button class="ovk-btn ovk-btn--primary" type="submit">Save caps</button></p>++</form>`;++ const providerCard = (p) => {++ const key = encodeURIComponent(p.key || '');++ const keyState = p.key_state || {};++ const live = providerState(gpuState, p.key);++ const classes = Array.isArray(p.classes) ? p.classes : [];++ const options = p.options && typeof p.options === 'object' ? p.options : {};++ return `<article class="ovk-card">++ <header><h3>${esc(p.key || 'provider')}</h3><p>${p.enabled ? '<span class="ovk-badge">Enabled</span>' : '<span class="ovk-badge">Off</span>'} ${p.configured ? '' : '<span class="ovk-badge">Not saved yet</span>'} ${keyState.present ? `<span class="ovk-badge">Key ends ${esc(keyState.last_four || '????')}</span>` : '<span class="ovk-badge">No key</span>'}</p><p>${esc(live)}</p></header>++ <form method="post" action="/console/gpu-policy/${key}" class="ovk-form">++ <label><span>Enabled</span><select name="enabled"><option value="1"${p.enabled ? ' selected' : ''}>On</option><option value="0"${p.enabled ? '' : ' selected'}>Off</option></select></label>++ <label><span>Order</span><input name="order" type="number" min="0" max="1000" step="1" value="${esc(p.order == null ? 50 : p.order)}"><small>Lower is tried first when two offers cost the same.</small></label>++ <fieldset><legend>Job classes</legend><label><input type="checkbox" name="classes" value="interactive"${classes.includes('interactive') ? ' checked' : ''}> Interactive</label><label><input type="checkbox" name="classes" value="batch"${classes.includes('batch') ? ' checked' : ''}> Batch</label></fieldset>++ <label><span>Interruptible allowed</span><select name="interruptible_allowed"><option value="1"${p.interruptible_allowed ? ' selected' : ''}>Yes, batch jobs with a checkpoint only</option><option value="0"${p.interruptible_allowed ? '' : ' selected'}>No</option></select></label>++ <label><span>Max $/GPU-hour</span><input name="max_hourly_usd" type="number" min="0" step="0.01" value="${esc(p.max_hourly_nano == null ? '' : Number(p.max_hourly_nano) / 1e9)}"></label>++ <label><span>Max replicas</span><input name="max_replicas" type="number" min="1" max="100" step="1" value="${esc(p.max_replicas == null ? 1 : p.max_replicas)}"></label>++ <label><span>Regions</span><input name="regions" maxlength="500" value="${esc(Array.isArray(p.regions) ? p.regions.join(', ') : '')}" placeholder="US, EU"><small>A comma-separated list; empty means any.</small></label>++ <label><span>Share of the monthly cap (%)</span><input name="monthly_cap_share_percent" type="number" min="0" max="100" step="1" value="${esc(p.monthly_cap_share_percent == null ? 100 : p.monthly_cap_share_percent)}"></label>++ <label><span>Provider options (JSON)</span><textarea name="options" rows="3">${esc(Object.keys(options).length ? JSON.stringify(options) : '')}</textarea><small>Organization, project, template, endpoint, monthly_nano: only what the provider needs.</small></label>++ <p><button class="ovk-btn ovk-btn--primary" type="submit">Save ${esc(p.key || 'provider')}</button></p>++ </form>++ <form method="post" action="/console/gpu-policy/${key}/key" class="ovk-form">++ <label><span>API key (write-only)</span><input name="api_key" type="password" autocomplete="new-password" minlength="8" maxlength="400"></label>++ <p><button class="ovk-btn ovk-btn--quiet" type="submit">Save key</button></p>++ </form>++</article>`;++ };++ const routing = `<table class="ovk-table"><caption>Class routing and caps</caption><thead><tr><th>Class</th><th>Providers allowed</th><th>Cap</th><th>Committed</th></tr></thead><tbody>++ ${['interactive', 'batch'].map((c) => `<tr><td>${c}</td><td>${esc(providers.filter((p) => Array.isArray(p.classes) && p.classes.includes(c)).map((p) => p.key).join(', ') || 'none')}</td><td>${esc(nanoUsd(global[c + '_cap_nano']) || 'the monthly cap')}</td><td>${esc(nanoUsd(gpuState && gpuState.by_class && gpuState.by_class[c]) || '$0.00')}</td></tr>`).join('')}++ </tbody></table>`;++ const instances = (gpuState && Array.isArray(gpuState.instances) ? gpuState.instances : []).filter((i) => ['starting', 'running', 'stopping'].includes(i.status));++ const instanceTable = instances.length ? `<table class="ovk-table"><caption>Live rentals</caption><thead><tr><th>Model</th><th>Provider</th><th>Class</th><th>GPU</th><th>Hourly</th><th>Status</th></tr></thead><tbody>${instances.map((i) => `<tr><td>${esc(i.model_key)}</td><td>${esc(i.marketplace)}</td><td>${esc(i.job_class || 'interactive')}${i.interruptible ? ' · interruptible' : ''}</td><td>${esc(i.gpu_name || '')}</td><td>${esc(nanoUsd(i.hourly_nano))}</td><td>${esc(i.status)}</td></tr>`).join('')}</tbody></table>` : '';++ return `<section class="ovk-section"><header><h2>GPU providers and job classes</h2><p>The owner's policy for every GPU provider: switch, key, order, classes, ceilings and share, and the monthly and per-class caps. Keys are write-only and only their last four are shown. Quote-only is the safe default.</p></header>++ ${gpuState ? `<p>${gpuState.enabled ? 'The provisioner is on.' : `The provisioner is off: ${esc(gpuState.reason || 'no key or no cap')}.`} ${gpuState.dry_run ? 'Quote-only is on.' : ''}</p>` : ''}++ ${globalForm}++ ${routing}++ ${providers.length ? `<div class="ovk-grid">${providers.map(providerCard).join('')}</div>` : '<p>No provider policy is available on this host.</p>'}++ ${instanceTable}++</section>`;++}++++// ── GPU supply ──function supplySection(data = {}) {const reports = Array.isArray(data.reports) ? data.reports : [];@@@ -330,6 +394,7 @@ function supplySection(data = {}) {<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>++${gpuPolicySection(data)}</div>`;}@@@ -373,4 +438,4 @@ ${recent.length ? `<table class="ovk-table"><thead><tr><th scope="col">When</th>}const SECTIONS = { providers: providersSection, models: modelsSection, pricebook: pricebookSection, supply: supplySection, sync: syncSection, orientation: orientationSection };--module.exports = { providersSection, modelsSection, pricebookSection, supplySection, syncSection, orientationSection, SECTIONS, CSS: marks.CSS };++module.exports = { providersSection, modelsSection, pricebookSection, supplySection, gpuPolicySection, syncSection, orientationSection, SECTIONS, CSS: marks.CSS };@@@ -16,7 +16,19 @@ app.use((req, _res, next) => {});app.use(createConsoleRoutes({config: { baseUrl: 'https://openvibe.exchange' },-- ai: { async adminRequest(path, options = {}) { calls.push(path); if (options.method) writes.push({ path, ...options }); if (path === '/api/v1/admin/console/pricebook') return { confirmed: [{ provider_key: 'sable', model_key: 'deepseek-v4.1-flash-1017-uncensored', in_per_mtok: 0.15, out_per_mtok: 0.6, source: 'https://sableapi.com/models', checked_at: '2026-11-10' }] }; return { providers: [{ key: 'openai', status: 'active', api_key: fakeSecret }] }; } },++ ai: { async adminRequest(path, options = {}) {++ calls.push(path); if (options.method) writes.push({ path, ...options });++ if (path === '/api/v1/admin/console/pricebook') return { confirmed: [{ provider_key: 'sable', model_key: 'deepseek-v4.1-flash-1017-uncensored', in_per_mtok: 0.15, out_per_mtok: 0.6, source: 'https://sableapi.com/models', checked_at: '2026-11-10' }] };++ if (path === '/api/v1/admin/console/gpu-policy') return {++ global: { quote_only: true, monthly_cap_nano: '50000000000', interactive_cap_nano: '20000000000', batch_cap_nano: '30000000000' },++ providers: [++ { key: 'vast', enabled: true, configured: true, order: 3, classes: ['interactive', 'batch'], interruptible_allowed: true, max_hourly_nano: '500000000', max_replicas: 2, regions: ['US'], monthly_cap_share_percent: 40, key_state: { present: true, last_four: '4321' } },++ { key: 'salad', enabled: false, configured: false, order: 50, classes: ['batch'], interruptible_allowed: false, max_replicas: 3, regions: [], monthly_cap_share_percent: 100, key_state: { present: false } },++ ],++ };++ if (path === '/api/v1/admin/console/gpus') return { state: { enabled: true, reason: '', dry_run: true, cap_nano: '50000000000', spent_nano: '1000000000', committed_nano: '2000000000', by_class: { interactive: '1000000000' }, instances: [] } };++ return { providers: [{ key: 'openai', status: 'active', api_key: fakeSecret }] };++ } },}));(async () => {@@@ -185,6 +197,26 @@ app.use(createConsoleRoutes({});assert.strictEqual(modelOverride.status, 303);assert.ok(writes.some((write) => write.path === '/api/v1/models/groq/openai%2Fgpt-oss-120b' && write.body.owner_override.reason === 'Owner approved adjustment'));++ // The GPU supply page carries the provider policy and the class caps as data with forms.++ const supplyPage = await fetch(`${base}/console/supply`, { headers: { 'x-test-role': 'owner' } });++ const supplyHtml = await supplyPage.text();++ assert.strictEqual(supplyPage.status, 200, supplyHtml);++ assert.ok(supplyHtml.includes('GPU providers and job classes'), 'the policy section is on the GPU supply page');++ assert.ok(supplyHtml.includes('/console/gpu-policy/vast'), 'each provider has its form');++ assert.ok(supplyHtml.includes('Key ends 4321'), 'the key shows its last four only');++ assert.ok(supplyHtml.includes('Batch cap (USD)'), 'the owner sets the class caps');++ assert.ok(supplyHtml.includes('Class routing and caps'), 'the class routing is data on the page');++ assert.ok(!supplyHtml.includes(fakeSecret), 'no provider key reaches the page');++ // The USD forms become the exact nano values AI stores; options are JSON.++ const policyUsd = await fetch(`${base}/console/gpu-policy/vast`, {++ method: 'POST', headers: { 'x-test-role': 'owner', Origin: 'https://openvibe.exchange', 'content-type': 'application/x-www-form-urlencoded' },++ body: new URLSearchParams({ enabled: '1', classes: ['batch', 'interactive'], max_hourly_usd: '0.50', max_replicas: '2',++ monthly_cap_share_percent: '40', regions: 'US, EU', options: '{"organization":"org","project":"proj"}' }), redirect: 'manual',++ });++ assert.strictEqual(policyUsd.status, 303);++ assert.deepStrictEqual(writes.at(-1).body, { enabled: true, max_replicas: 2, monthly_cap_share_percent: 40,++ max_hourly_nano: '500000000', classes: ['batch', 'interactive'], regions: ['US', 'EU'],++ options: { organization: 'org', project: 'proj' } });} finally { await new Promise((resolve) => server.close(resolve)); }process.stdout.write('console-merge: owner gate and secret redaction passed\n');
@@ -182,6 +182,12 @@ function createConsoleRoutes({ config, ai }) {
@@ -182,6 +182,12 @@ function createConsoleRoutes({ config, ai }) {
if (!endpoint) return res.sendStatus(404);
if (!endpoint) return res.sendStatus(404);
try {
try {
const data = await ai.adminRequest(endpoint + (req.originalUrl.includes('?') ? req.originalUrl.slice(req.originalUrl.indexOf('?')) : ''));
const data = await ai.adminRequest(endpoint + (req.originalUrl.includes('?') ? req.originalUrl.slice(req.originalUrl.indexOf('?')) : ''));
+ if (section === 'supply') {
+ // The GPU supply section also shows the owner's provider policy and what the provisioner
+ // is doing. A read that fails leaves the rest of the page usable.
+ try { data.policy = await ai.adminRequest('/api/v1/admin/console/gpu-policy'); } catch { data.policy = null; }
+ try { data.gpus = await ai.adminRequest('/api/v1/admin/console/gpus'); } catch { data.gpus = null; }
+ }
return page(req, res, section, data);
return page(req, res, section, data);
} catch (err) {
} catch (err) {
return page(req, res, section, {}, err.message || 'AI admin API did not answer.', err.status === 404 ? 404 : 502);
return page(req, res, section, {}, err.message || 'AI admin API did not answer.', err.status === 404 ? 404 : 502);
@@ -216,6 +222,11 @@ function createConsoleRoutes({ config, ai }) {
@@ -216,6 +222,11 @@ function createConsoleRoutes({ config, ai }) {
try { await ai.adminRequest('/api/v1/admin/console/supply', { method:'POST', subject:req.viewer.subject, body }); return res.redirect(303,'/console/supply'); }
try { await ai.adminRequest('/api/v1/admin/console/supply', { method:'POST', subject:req.viewer.subject, body }); return res.redirect(303,'/console/supply'); }
catch (err) { return page(req,res,'supply',{},err.message || 'Supply settings could not be saved.',err.status === 409 || err.status === 422 ? err.status : 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); }
});
});
+ const nanoOfUsd = (value) => {
+ if (value == null || value === '') return null;
+ const n = Number(value);
+ return Number.isFinite(n) && n >= 0 ? String(BigInt(Math.round(n * 1e9))) : null;
+ };
r.post('/console/gpu-policy/:scope', async (req, res) => {
r.post('/console/gpu-policy/:scope', async (req, res) => {
if (!sameOrigin(req)) return res.sendStatus(403);
if (!sameOrigin(req)) return res.sendStatus(403);
const scope = String(req.params.scope || '');
const scope = String(req.params.scope || '');
@@ -223,9 +234,16 @@ function createConsoleRoutes({ config, ai }) {
@@ -223,9 +234,16 @@ function createConsoleRoutes({ config, ai }) {
const body = {};
const body = {};
for (const key of ['enabled', 'interruptible_allowed', 'quote_only']) if (Object.hasOwn(input, key)) body[key] = input[key] === '1';
for (const key of ['enabled', 'interruptible_allowed', 'quote_only']) if (Object.hasOwn(input, key)) body[key] = input[key] === '1';
for (const key of ['order', 'max_replicas', 'monthly_cap_share_percent']) if (Object.hasOwn(input, key)) body[key] = Number(input[key]);
for (const key of ['order', 'max_replicas', 'monthly_cap_share_percent']) if (Object.hasOwn(input, key)) body[key] = Number(input[key]);
+ for (const [usd, nano] of [['monthly_cap_usd', 'monthly_cap_nano'], ['interactive_cap_usd', 'interactive_cap_nano'], ['batch_cap_usd', 'batch_cap_nano'], ['max_hourly_usd', 'max_hourly_nano']]) {
+ if (Object.hasOwn(input, usd)) body[nano] = nanoOfUsd(input[usd]);
+ }
for (const key of ['monthly_cap_nano', 'interactive_cap_nano', 'batch_cap_nano', 'max_hourly_nano']) if (Object.hasOwn(input, key)) body[key] = input[key] || null;
for (const key of ['monthly_cap_nano', 'interactive_cap_nano', 'batch_cap_nano', 'max_hourly_nano']) if (Object.hasOwn(input, key)) body[key] = input[key] || null;
if (Object.hasOwn(input, 'classes')) body.classes = String(input.classes).split(',').map(x => x.trim()).filter(Boolean);
if (Object.hasOwn(input, 'classes')) body.classes = String(input.classes).split(',').map(x => x.trim()).filter(Boolean);
if (Object.hasOwn(input, 'regions')) body.regions = String(input.regions).split(',').map(x => x.trim()).filter(Boolean);
if (Object.hasOwn(input, 'regions')) body.regions = String(input.regions).split(',').map(x => x.trim()).filter(Boolean);
+ if (Object.hasOwn(input, 'options')) {
+ try { body.options = input.options ? JSON.parse(String(input.options)) : {}; }
+ catch { return page(req, res, 'supply', {}, 'Provider options are not valid JSON.', 422); }
+ }
try {
try {
await ai.adminRequest(`/api/v1/admin/console/gpu-policy/${encodeURIComponent(scope)}`, {
await ai.adminRequest(`/api/v1/admin/console/gpu-policy/${encodeURIComponent(scope)}`, {
method: 'POST', subject: req.viewer.subject, role: req.viewer.role, body,
method: 'POST', subject: req.viewer.subject, role: req.viewer.role, body,
@@ -285,6 +285,70 @@ ${recent.length ? `<section class="exc-sec"><h3>Applied lately <span class="exc-
@@ -285,6 +285,70 @@ ${recent.length ? `<section class="exc-sec"><h3>Applied lately <span class="exc-
</div>`;
</div>`;
}
}
+// The owner's GPU policy: every provider's switch, key (only the last four), order, classes, ceilings
+// and share, plus the class caps and what each provider is doing right now. Data first; the forms are
+// plain ovk markup so the main session can design the look without this code changing.
+const nanoUsd = (n) => (n == null || n === '' ? null : `$${(Number(n) / 1e9).toFixed(2)}`);
+const providerState = (gpuState, key) => {
+ const instances = (gpuState && Array.isArray(gpuState.instances) ? gpuState.instances : []).filter((i) => i.marketplace === key);
+ const live = instances.find((i) => ['starting', 'running', 'stopping'].includes(i.status));
+ return live ? `${live.status} · ${live.gpu_class || live.job_class || 'interactive'} · ${live.gpu_name || ''} · ${nanoUsd(live.hourly_nano)}/h` : (gpuState && gpuState.marketplaces && gpuState.marketplaces[key] ? 'key set, idle' : 'no key');
+};
+function gpuPolicySection(data = {}) {
+ const policy = data.policy && typeof data.policy === 'object' ? data.policy : null;
+ const gpuState = data.gpus && data.gpus.state ? data.gpus.state : (data.gpus || null);
+ const global = (policy && policy.global) || {};
+ const providers = policy && Array.isArray(policy.providers) ? policy.providers : [];
+ if (!policy && !gpuState) return '';
+ const cap = gpuState ? { spent: nanoUsd(gpuState.spent_nano), committed: nanoUsd(gpuState.committed_nano), cap: nanoUsd(gpuState.cap_nano) } : null;
+ const globalForm = `<form method="post" action="/console/gpu-policy/global" class="ovk-form">
+ <label><span>Monthly cap (USD)</span><input name="monthly_cap_usd" type="number" min="0" step="0.01" value="${esc(global.monthly_cap_nano == null ? '' : Number(global.monthly_cap_nano) / 1e9)}"><small>${cap ? `${esc(cap.spent)} spent · ${esc(cap.committed)} committed of ${esc(cap.cap || 'no cap')}` : ''}</small></label>
+ <label><span>Interactive cap (USD)</span><input name="interactive_cap_usd" type="number" min="0" step="0.01" value="${esc(global.interactive_cap_nano == null ? '' : Number(global.interactive_cap_nano) / 1e9)}"></label>
+ <label><span>Batch cap (USD)</span><input name="batch_cap_usd" type="number" min="0" step="0.01" value="${esc(global.batch_cap_nano == null ? '' : Number(global.batch_cap_nano) / 1e9)}"></label>
+ <label><span>Quote only</span><select name="quote_only"><option value="1"${global.quote_only ? ' selected' : ''}>Quotes only, rent nothing</option><option value="0"${global.quote_only ? '' : ' selected'}>Rent when the plan allows it</option></select></label>
+ <p><button class="ovk-btn ovk-btn--primary" type="submit">Save caps</button></p>
+</form>`;
+ const providerCard = (p) => {
+ const key = encodeURIComponent(p.key || '');
+ const keyState = p.key_state || {};
+ const live = providerState(gpuState, p.key);
+ const classes = Array.isArray(p.classes) ? p.classes : [];
+ const options = p.options && typeof p.options === 'object' ? p.options : {};
+ return `<article class="ovk-card">
+ <header><h3>${esc(p.key || 'provider')}</h3><p>${p.enabled ? '<span class="ovk-badge">Enabled</span>' : '<span class="ovk-badge">Off</span>'} ${p.configured ? '' : '<span class="ovk-badge">Not saved yet</span>'} ${keyState.present ? `<span class="ovk-badge">Key ends ${esc(keyState.last_four || '????')}</span>` : '<span class="ovk-badge">No key</span>'}</p><p>${esc(live)}</p></header>
+ <form method="post" action="/console/gpu-policy/${key}" class="ovk-form">
+ <label><span>Enabled</span><select name="enabled"><option value="1"${p.enabled ? ' selected' : ''}>On</option><option value="0"${p.enabled ? '' : ' selected'}>Off</option></select></label>
+ <label><span>Order</span><input name="order" type="number" min="0" max="1000" step="1" value="${esc(p.order == null ? 50 : p.order)}"><small>Lower is tried first when two offers cost the same.</small></label>
+ <fieldset><legend>Job classes</legend><label><input type="checkbox" name="classes" value="interactive"${classes.includes('interactive') ? ' checked' : ''}> Interactive</label><label><input type="checkbox" name="classes" value="batch"${classes.includes('batch') ? ' checked' : ''}> Batch</label></fieldset>
+ <label><span>Interruptible allowed</span><select name="interruptible_allowed"><option value="1"${p.interruptible_allowed ? ' selected' : ''}>Yes, batch jobs with a checkpoint only</option><option value="0"${p.interruptible_allowed ? '' : ' selected'}>No</option></select></label>
+ <label><span>Max $/GPU-hour</span><input name="max_hourly_usd" type="number" min="0" step="0.01" value="${esc(p.max_hourly_nano == null ? '' : Number(p.max_hourly_nano) / 1e9)}"></label>
+ <label><span>Max replicas</span><input name="max_replicas" type="number" min="1" max="100" step="1" value="${esc(p.max_replicas == null ? 1 : p.max_replicas)}"></label>
+ <label><span>Regions</span><input name="regions" maxlength="500" value="${esc(Array.isArray(p.regions) ? p.regions.join(', ') : '')}" placeholder="US, EU"><small>A comma-separated list; empty means any.</small></label>
+ <label><span>Share of the monthly cap (%)</span><input name="monthly_cap_share_percent" type="number" min="0" max="100" step="1" value="${esc(p.monthly_cap_share_percent == null ? 100 : p.monthly_cap_share_percent)}"></label>
+ <label><span>Provider options (JSON)</span><textarea name="options" rows="3">${esc(Object.keys(options).length ? JSON.stringify(options) : '')}</textarea><small>Organization, project, template, endpoint, monthly_nano: only what the provider needs.</small></label>
+ <p><button class="ovk-btn ovk-btn--primary" type="submit">Save ${esc(p.key || 'provider')}</button></p>
+ </form>
+ <form method="post" action="/console/gpu-policy/${key}/key" class="ovk-form">
+ <label><span>API key (write-only)</span><input name="api_key" type="password" autocomplete="new-password" minlength="8" maxlength="400"></label>
+ <p><button class="ovk-btn ovk-btn--quiet" type="submit">Save key</button></p>
+ </form>
+</article>`;
+ };
+ const routing = `<table class="ovk-table"><caption>Class routing and caps</caption><thead><tr><th>Class</th><th>Providers allowed</th><th>Cap</th><th>Committed</th></tr></thead><tbody>
+ ${['interactive', 'batch'].map((c) => `<tr><td>${c}</td><td>${esc(providers.filter((p) => Array.isArray(p.classes) && p.classes.includes(c)).map((p) => p.key).join(', ') || 'none')}</td><td>${esc(nanoUsd(global[c + '_cap_nano']) || 'the monthly cap')}</td><td>${esc(nanoUsd(gpuState && gpuState.by_class && gpuState.by_class[c]) || '$0.00')}</td></tr>`).join('')}
+ </tbody></table>`;
+ const instances = (gpuState && Array.isArray(gpuState.instances) ? gpuState.instances : []).filter((i) => ['starting', 'running', 'stopping'].includes(i.status));
+ const instanceTable = instances.length ? `<table class="ovk-table"><caption>Live rentals</caption><thead><tr><th>Model</th><th>Provider</th><th>Class</th><th>GPU</th><th>Hourly</th><th>Status</th></tr></thead><tbody>${instances.map((i) => `<tr><td>${esc(i.model_key)}</td><td>${esc(i.marketplace)}</td><td>${esc(i.job_class || 'interactive')}${i.interruptible ? ' · interruptible' : ''}</td><td>${esc(i.gpu_name || '')}</td><td>${esc(nanoUsd(i.hourly_nano))}</td><td>${esc(i.status)}</td></tr>`).join('')}</tbody></table>` : '';
+ return `<section class="ovk-section"><header><h2>GPU providers and job classes</h2><p>The owner's policy for every GPU provider: switch, key, order, classes, ceilings and share, and the monthly and per-class caps. Keys are write-only and only their last four are shown. Quote-only is the safe default.</p></header>
+ ${gpuState ? `<p>${gpuState.enabled ? 'The provisioner is on.' : `The provisioner is off: ${esc(gpuState.reason || 'no key or no cap')}.`} ${gpuState.dry_run ? 'Quote-only is on.' : ''}</p>` : ''}
+ ${globalForm}
+ ${routing}
+ ${providers.length ? `<div class="ovk-grid">${providers.map(providerCard).join('')}</div>` : '<p>No provider policy is available on this host.</p>'}
+ ${instanceTable}
+</section>`;
+}
+
+
// ── GPU supply ──
// ── GPU supply ──
function supplySection(data = {}) {
function supplySection(data = {}) {
const reports = Array.isArray(data.reports) ? data.reports : [];
const reports = Array.isArray(data.reports) ? data.reports : [];
@@ -330,6 +394,7 @@ function supplySection(data = {}) {
@@ -330,6 +394,7 @@ function supplySection(data = {}) {
<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>
<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>'}
<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>
<details class="exc-fold"><summary>Save another plan</summary><div class="exc-fold-body">${form({})}</div></details></section>
+${gpuPolicySection(data)}
</div>`;
</div>`;
}
}
@@ -373,4 +438,4 @@ ${recent.length ? `<table class="ovk-table"><thead><tr><th scope="col">When</th>
@@ -373,4 +438,4 @@ ${recent.length ? `<table class="ovk-table"><thead><tr><th scope="col">When</th>
}
}
const SECTIONS = { providers: providersSection, models: modelsSection, pricebook: pricebookSection, supply: supplySection, sync: syncSection, orientation: orientationSection };
const SECTIONS = { providers: providersSection, models: modelsSection, pricebook: pricebookSection, supply: supplySection, sync: syncSection, orientation: orientationSection };
-module.exports = { providersSection, modelsSection, pricebookSection, supplySection, syncSection, orientationSection, SECTIONS, CSS: marks.CSS };
+module.exports = { providersSection, modelsSection, pricebookSection, supplySection, gpuPolicySection, syncSection, orientationSection, SECTIONS, CSS: marks.CSS };
@@ -16,7 +16,19 @@ app.use((req, _res, next) => {
@@ -16,7 +16,19 @@ app.use((req, _res, next) => {
});
});
app.use(createConsoleRoutes({
app.use(createConsoleRoutes({
config: { baseUrl: 'https://openvibe.exchange' },
config: { baseUrl: 'https://openvibe.exchange' },
- ai: { async adminRequest(path, options = {}) { calls.push(path); if (options.method) writes.push({ path, ...options }); if (path === '/api/v1/admin/console/pricebook') return { confirmed: [{ provider_key: 'sable', model_key: 'deepseek-v4.1-flash-1017-uncensored', in_per_mtok: 0.15, out_per_mtok: 0.6, source: 'https://sableapi.com/models', checked_at: '2026-11-10' }] }; return { providers: [{ key: 'openai', status: 'active', api_key: fakeSecret }] }; } },
+ ai: { async adminRequest(path, options = {}) {
+ calls.push(path); if (options.method) writes.push({ path, ...options });
+ if (path === '/api/v1/admin/console/pricebook') return { confirmed: [{ provider_key: 'sable', model_key: 'deepseek-v4.1-flash-1017-uncensored', in_per_mtok: 0.15, out_per_mtok: 0.6, source: 'https://sableapi.com/models', checked_at: '2026-11-10' }] };
+ if (path === '/api/v1/admin/console/gpu-policy') return {
+ global: { quote_only: true, monthly_cap_nano: '50000000000', interactive_cap_nano: '20000000000', batch_cap_nano: '30000000000' },
+ providers: [
+ { key: 'vast', enabled: true, configured: true, order: 3, classes: ['interactive', 'batch'], interruptible_allowed: true, max_hourly_nano: '500000000', max_replicas: 2, regions: ['US'], monthly_cap_share_percent: 40, key_state: { present: true, last_four: '4321' } },
+ { key: 'salad', enabled: false, configured: false, order: 50, classes: ['batch'], interruptible_allowed: false, max_replicas: 3, regions: [], monthly_cap_share_percent: 100, key_state: { present: false } },
+ ],
+ };
+ if (path === '/api/v1/admin/console/gpus') return { state: { enabled: true, reason: '', dry_run: true, cap_nano: '50000000000', spent_nano: '1000000000', committed_nano: '2000000000', by_class: { interactive: '1000000000' }, instances: [] } };
+ return { providers: [{ key: 'openai', status: 'active', api_key: fakeSecret }] };
+ } },
}));
}));
(async () => {
(async () => {
@@ -185,6 +197,26 @@ app.use(createConsoleRoutes({
@@ -185,6 +197,26 @@ app.use(createConsoleRoutes({
});
});
assert.strictEqual(modelOverride.status, 303);
assert.strictEqual(modelOverride.status, 303);
assert.ok(writes.some((write) => write.path === '/api/v1/models/groq/openai%2Fgpt-oss-120b' && write.body.owner_override.reason === 'Owner approved adjustment'));
assert.ok(writes.some((write) => write.path === '/api/v1/models/groq/openai%2Fgpt-oss-120b' && write.body.owner_override.reason === 'Owner approved adjustment'));
+ // The GPU supply page carries the provider policy and the class caps as data with forms.
+ const supplyPage = await fetch(`${base}/console/supply`, { headers: { 'x-test-role': 'owner' } });
+ const supplyHtml = await supplyPage.text();
+ assert.strictEqual(supplyPage.status, 200, supplyHtml);
+ assert.ok(supplyHtml.includes('GPU providers and job classes'), 'the policy section is on the GPU supply page');
+ assert.ok(supplyHtml.includes('/console/gpu-policy/vast'), 'each provider has its form');
+ assert.ok(supplyHtml.includes('Key ends 4321'), 'the key shows its last four only');
+ assert.ok(supplyHtml.includes('Batch cap (USD)'), 'the owner sets the class caps');
+ assert.ok(supplyHtml.includes('Class routing and caps'), 'the class routing is data on the page');
+ assert.ok(!supplyHtml.includes(fakeSecret), 'no provider key reaches the page');
+ // The USD forms become the exact nano values AI stores; options are JSON.
+ const policyUsd = await fetch(`${base}/console/gpu-policy/vast`, {
+ method: 'POST', headers: { 'x-test-role': 'owner', Origin: 'https://openvibe.exchange', 'content-type': 'application/x-www-form-urlencoded' },
+ body: new URLSearchParams({ enabled: '1', classes: ['batch', 'interactive'], max_hourly_usd: '0.50', max_replicas: '2',
+ monthly_cap_share_percent: '40', regions: 'US, EU', options: '{"organization":"org","project":"proj"}' }), redirect: 'manual',
+ });
+ assert.strictEqual(policyUsd.status, 303);
+ assert.deepStrictEqual(writes.at(-1).body, { enabled: true, max_replicas: 2, monthly_cap_share_percent: 40,
+ max_hourly_nano: '500000000', classes: ['batch', 'interactive'], regions: ['US', 'EU'],
+ options: { organization: 'org', project: 'proj' } });
} finally { await new Promise((resolve) => server.close(resolve)); }
} finally { await new Promise((resolve) => server.close(resolve)); }
process.stdout.write('console-merge: owner gate and secret redaction passed\n');
process.stdout.write('console-merge: owner gate and secret redaction passed\n');