openvibers/openvibe.exchange Commits

Show every GPU provider's policy and state in the owner console (#221)

OP OpenVibers committed 1 days ago · 53eeeef86c · 3 files, +117 −2

* Show every GPU provider's policy and state in the owner console

* Merge main into the GPU console branch

Changes

ddiff --git a/server/http/console.js b/server/http/console.js
iindex 4794344..51b6388 100644
---- a/server/http/console.js
++++ b/server/http/console.js
@@@ -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,
ddiff --git a/server/views/console-ai.js b/server/views/console-ai.js
iindex a185759..09efc36 100644
---- a/server/views/console-ai.js
++++ b/server/views/console-ai.js
@@@ -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 };
ddiff --git a/test/console-merge.test.js b/test/console-merge.test.js
iindex da446e9..13d162f 100644
---- a/test/console-merge.test.js
++++ b/test/console-merge.test.js
@@@ -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');