openvibers/openvibe.network Commits

Owner controls for the provider wave (#799)

OP OpenVibers committed 2 days ago · 82ec1679b0 · 3 files, +189 −5

The inference page lists every provider that states a trait: on/off, its
standard or Flex tier, its latency classes, concurrency, payment, free
allowance left and end, training and retention, with the page and day each
fact came from. The four routing rules are switches at their safe values.
The owner adds a Runpod vLLM endpoint URL and its GPU hour and throughput, and
the page shows the per-million-token price; the key stays in the provider key
slot. A button turns the bundled confirmed readings into pending price-book
changes. The proxy forwards and audits each write; the page is plain kit markup
and the naming and look are left for the design pass.

Changes

ddiff --git a/public/js/admin/inference.js b/public/js/admin/inference.js
iindex 8baf4b47..2f36cc42 100644
---- a/public/js/admin/inference.js
++++ b/public/js/admin/inference.js
@@@ -75,6 +75,8 @@ function oinRender() {
+ '<h4 class="pai-subhead">Each kind of work</h4><div class="oin-kinds">' + OIN_KINDS.map(oinKindCard).join('') + '</div>'
+ '<h4 class="pai-subhead" id="oin-limits">Spending limits</h4><p class="muted oin-lead">The most each provider may cost in a day and in a month. Past a limit it is skipped until the day or the month turns, and the next one in line answers. Turn providers on and add their keys in AI providers above.</p>'
+ oinLimits()
++ + '<h4 class="pai-subhead" id="oin-providers">Providers, their tiers and their rules</h4>' + oinProviderCards() + oinRules()
++ + '<h4 class="pai-subhead" id="oin-compute">Rented capacity</h4>' + oinCompute() + oinPricebook()
+ '<h4 class="pai-subhead">What it cost</h4>' + oinCosts();
if (!box.dataset.wired) {
box.dataset.wired = '1';
@@@ -230,6 +232,84 @@ function oinCosts() {
return seg + '<div class="oin-costs">' + rows.map(row).join('') + '</div>';
}
++
++// ── The provider wave: each provider, its tiers and its traits ───────────────────────────────────────────────────
++/** One trait as words: the classes, the cap, the allowance, the payment. */
++function oinTraitLine(p) {
++ const t = p.traits || {};
++ const bits = [];
++ if (Array.isArray(t.latency_classes) && t.latency_classes.length) bits.push('answers ' + t.latency_classes.join(', '));
++ if (t.concurrency) bits.push(t.concurrency + ' at a time');
++ if (t.payment) bits.push(t.payment === 'none' ? 'free, no card' : t.payment);
++ if (p.allowance) {
++ const end = p.allowance.ends_at ? ' · ends ' + String(p.allowance.ends_at).slice(0, 10) : '';
++ bits.push(oinCount(p.allowance.left) + ' free tokens left of ' + oinCount(p.allowance.tokens) + end);
++ }
++ return bits.join(' · ');
++}
++function oinProviderCards() {
++ const rows = (OIN.data.controls || []).slice().sort((a, b) => String(a.display_name || a.key).localeCompare(String(b.display_name || b.key)));
++ if (!rows.length) return '<p class="muted oin-note">No provider states a trait yet.</p>';
++ const row = (p) => {
++ const t = p.traits || {}, dp = p.data_policy || {};
++ const source = t.source ? '<a href="' + esc(t.source) + '" target="_blank" rel="noopener noreferrer">' + esc(t.source.replace(/^https:\/\//, '')) + '</a> · read ' + esc(t.checked || '') : '<span class="oin-bad">no source</span>';
++ const facts = 'training: ' + (dp.trains === true ? '<span class="oin-bad">yes</span>' : dp.trains === false ? 'no' : '<span class="oin-bad">not stated</span>')
++ + ' · retention: ' + (dp.retention || '<span class="oin-bad">not stated</span>')
++ + (dp.retention_note ? ' <span class="muted">(' + esc(dp.retention_note) + ')</span>' : '');
++ return '<div class="oin-limit" data-oin-provider="' + esc(p.key) + '">'
++ + '<div class="oin-limit-name"><b>' + esc(p.display_name || p.key) + '</b><span class="muted">' + esc(p.key) + (p.enabled ? ' · on' : ' · off') + (t.payment ? ' · ' + esc(t.payment) : '') + '</span></div>'
++ + '<div class="oin-limit-use"><span class="oin-label">Traits</span><span>' + esc(oinTraitLine(p)) + '</span></div>'
++ + '<div class="oin-limit-use"><span class="oin-label">Data</span><span>' + facts + '</span></div>'
++ + '<div class="oin-limit-use"><span class="oin-label">Source</span><span>' + source + '</span></div>'
++ + '<div class="oin-limit-set">'
++ + '<label class="oin-way"><span class="pai-switch"><input type="checkbox" role="switch" data-oin-provider-on data-ov-native' + (p.enabled ? ' checked' : '') + '><span class="pai-track" aria-hidden="true"><span class="pai-knob"></span></span></span><span class="oin-way-text"><b>On</b><span class="muted">Off until the owner turns it on.</span></span></label>'
++ + '<label><span class="oin-label">Tier</span><select data-oin-provider-tier><option value=""' + (!p.tier ? ' selected' : '') + '>Provider default</option><option value="standard"' + (p.tier === 'standard' ? ' selected' : '') + '>Standard</option><option value="flex"' + (p.tier === 'flex' ? ' selected' : '') + '>Flex</option></select></label>'
++ + '<button type="button" class="btn btn-sm pai-quiet" data-oin-act="save-provider">Save</button></div>'
++ + '</div>';
++ };
++ return '<div class="oin-limits">' + rows.map(row).join('') + '</div>';
++}
++/** The four routing rules, each at its safe value until the owner changes it. */
++function oinRules() {
++ const r = OIN.data.rules || {};
++ const sw = (key, label, desc, off) => '<label class="oin-way"><span class="pai-switch"><input type="checkbox" role="switch" data-oin-rule="' + key + '" data-ov-native' + (r[key] ? ' checked' : '') + '><span class="pai-track" aria-hidden="true"><span class="pai-knob"></span></span></span>'
++ + '<span class="oin-way-text"><b>' + label + '</b><span class="muted">' + desc + '</span></span></label>';
++ return '<details class="oin-ways" open><summary>Routing rules (safe by default)</summary><div class="oin-ways-list">'
++ + sw('private_training', 'A provider that trains on or retains prompts may answer private data', 'Off: private data never reaches a provider that keeps or trains on it.', true)
++ + sw('flex_interactive', 'A Flex tier may answer in front of a person', 'Off: Flex is for background and batch work only.', true)
++ + sw('latency_limited_interactive', 'A background-only provider may answer interactively', 'Off: a provider that states background or batch only never answers an interactive request.', true)
++ + sw('free_allowance_first', 'Spend a free allowance before paid capacity', 'On: eligible work uses the free tokens first.', false)
++ + '</div><footer class="oin-kind-foot"><span></span><button type="button" class="btn btn-sm btn-primary" data-oin-act="save-rules">Save rules</button></footer></details>';
++}
++/** The rented GPU hours, and the owner's own OpenAI-compatible endpoint. */
++function oinCompute() {
++ const c = OIN.data.compute || {};
++ const cards = (c.providers || []).map((p) => {
++ const pods = (p.pods || []).map((g) => '<tr><td>' + esc(g.gpu) + '</td><td>' + oinMoney(g.hourly_usd) + (g.spot_usd != null ? ' <span class="muted">· spot ' + oinMoney(g.spot_usd) + '</span>' : '') + '</td></tr>').join('');
++ const credits = p.credits ? (p.credits.starter_usd ? 'Up to ' + oinMoney(p.credits.starter_usd) + ' in startup credits (' + esc(p.credits.starter_eligibility || '') + '); the owner applies.' : esc(p.credits.match || '')) : '';
++ return '<details class="oin-cost"><summary><span class="oin-cost-name">' + esc(p.display_name) + '</span><span class="oin-cost-n"><b>' + (p.pods && p.pods[0] ? oinMoney(p.pods[0].hourly_usd) + '/hr' : '') + '</b></span></summary>'
++ + '<table class="pai-table oin-models"><thead><tr><th>GPU</th><th>An hour</th></tr></thead><tbody>' + pods + '</tbody></table>'
++ + (p.storage ? '<p class="muted oin-note">Storage ' + oinMoney(p.storage.container_usd_per_gb_month) + '/GB a month (container), ' + oinMoney(p.storage.network_volume_usd_per_gb_month) + '/GB a month (network volume).</p>' : '')
++ + (credits ? '<p class="muted oin-note">' + credits + '</p>' : '')
++ + '<p class="muted oin-note">' + esc(p.billing || '') + ' · read ' + esc(p.checked || '') + ' · <a href="' + esc(p.source) + '" target="_blank" rel="noopener noreferrer">the listing</a></p></details>';
++ }).join('');
++ const e = c.endpoint || {};
++ const form = '<div class="oin-row"><span class="oin-label">My vLLM endpoint</span>'
++ + '<label><span class="oin-label">URL</span><input type="url" data-oin-endpoint="base_url" value="' + esc(e.base_url || '') + '" placeholder="https://…/v1"></label>'
++ + '<label><span class="oin-label">Model</span><input type="text" data-oin-endpoint="model_key" value="' + esc(e.model_key || '') + '" placeholder="the model id it serves"></label>'
++ + '<label><span class="oin-label">An hour</span><span class="pai-prefix">$</span><input type="number" min="0.01" step="0.01" data-oin-endpoint="hourly_usd" value="' + esc(e.hourly_usd == null ? '' : e.hourly_usd) + '"></label>'
++ + '<label><span class="oin-label">Tokens an hour</span><input type="number" min="1" step="1" data-oin-endpoint="throughput_tokens_per_hour" value="' + esc(e.throughput_tokens_per_hour == null ? '' : e.throughput_tokens_per_hour) + '"></label>'
++ + '<button type="button" class="btn btn-sm pai-quiet" data-oin-act="save-endpoint">Save endpoint</button>'
++ + (e.usd_per_mtok != null ? '<p class="muted oin-note">' + oinMoney(e.usd_per_mtok) + ' per million tokens at that throughput. The key goes in the provider key slot.</p>' : '') + '</div>';
++ return (cards ? '<div class="oin-costs">' + cards + '</div>' : '') + form;
++}
++/** The confirmed readings bundled with the release: the owner proposes them, then accepts them in the price book. */
++function oinPricebook() {
++ const list = (OIN.data.pricebook && OIN.data.pricebook.confirmed) || [];
++ return '<div class="oin-row"><span class="oin-label">Confirmed prices</span><p class="muted oin-note">' + list.length + ' readings from the providers\' own pages wait to enter the price book as pending changes. Nothing is applied until you accept each one in the price book.</p>'
++ + '<button type="button" class="btn btn-sm pai-quiet" data-oin-act="propose-prices"' + (list.length ? '' : ' disabled') + '>Propose ' + list.length + ' price' + (list.length === 1 ? '' : 's') + '</button></div>';
++}
++
// ── Changes ──────────────────────────────────────────────────────────────────────────────────────────────────────
function oinRepaint(kind) {
const old = document.querySelector('#oin-body .oin-kind[data-kind="' + kind + '"]');
@@@ -276,6 +356,10 @@ async function oinClick(ev) {
if (!btn || btn.disabled) return;
const act = btn.dataset.oinAct;
if (act === 'cap') return oinSaveCap(btn.closest('.oin-limit'), btn);
++ if (act === 'save-provider') return oinSaveProvider(btn.closest('[data-oin-provider]'), btn);
++ if (act === 'save-rules') return oinSaveRules(btn);
++ if (act === 'save-endpoint') return oinSaveEndpoint(btn);
++ if (act === 'propose-prices') return oinProposePrices(btn);
const card = btn.closest('.oin-kind');
if (!card) return;
const kind = card.dataset.kind, d = oinDraft(kind), i = Number(btn.dataset.i);
@@@ -309,6 +393,52 @@ async function oinSaveKind(kind, btn) {
toast(paiFriendly('ai', err), 'error');
}
}
++async function oinSaveProvider(row, btn) {
++ if (!row) return;
++ const key = row.dataset.oinProvider;
++ const enabled = row.querySelector('[data-oin-provider-on]').checked;
++ const tier = row.querySelector('[data-oin-provider-tier]').value || null;
++ btn.disabled = true;
++ try {
++ await paiApi('/owner-inference/providers', { method: 'POST', body: { provider_key: key, enabled, tier } });
++ toast(oinProviderName(key) + ': saved', 'success');
++ await oinLoad();
++ } catch (err) { btn.disabled = false; toast(paiFriendly('ai', err), 'error'); }
++}
++async function oinSaveRules(btn) {
++ const body = {};
++ for (const el of document.querySelectorAll('[data-oin-rule]')) body[el.dataset.oinRule] = el.checked;
++ if (btn) btn.disabled = true;
++ try {
++ await paiApi('/owner-inference/rules', { method: 'POST', body });
++ toast('Routing rules saved', 'success');
++ await oinLoad();
++ } catch (err) { if (btn) btn.disabled = false; toast(paiFriendly('ai', err), 'error'); }
++}
++async function oinSaveEndpoint(btn) {
++ const body = {};
++ for (const el of document.querySelectorAll('[data-oin-endpoint]')) body[el.dataset.oinEndpoint] = el.value.trim();
++ const hourly = oinNumber(body.hourly_usd, { integer: false });
++ const throughput = oinNumber(body.throughput_tokens_per_hour, { integer: true });
++ if (!body.base_url || !body.model_key) return toast('The endpoint needs its URL and the model it serves.', 'error');
++ if (hourly.error || throughput.error) return toast('The endpoint price needs an hour and a whole number of tokens an hour.', 'error');
++ if (btn) btn.disabled = true;
++ try {
++ await paiApi('/owner-inference/endpoint', { method: 'POST', body: { base_url: body.base_url, model_key: body.model_key, hourly_usd: hourly.value, throughput_tokens_per_hour: throughput.value } });
++ toast('Endpoint saved; it is priced per million tokens from that hour.', 'success');
++ await oinLoad();
++ } catch (err) { if (btn) btn.disabled = false; toast(paiFriendly('ai', err), 'error'); }
++}
++async function oinProposePrices(btn) {
++ const list = (OIN.data.pricebook && OIN.data.pricebook.confirmed) || [];
++ if (!list.length) return;
++ if (btn) btn.disabled = true;
++ try {
++ await paiApi('/owner-inference/pricebook/propose', { method: 'POST', body: { proposals: list } });
++ toast(list.length + ' confirmed prices are pending in the price book.', 'success');
++ if (btn) btn.disabled = false;
++ } catch (err) { if (btn) btn.disabled = false; toast(paiFriendly('ai', err), 'error'); }
++}
async function oinSaveCap(row, btn) {
if (!row) return;
const day = oinNumber(row.querySelector('[data-oin-cap="daily_usd"]').value.trim());
ddiff --git a/server/admin/ai-config-proxy.js b/server/admin/ai-config-proxy.js
iindex 688dfdd6..a5058d11 100644
---- a/server/admin/ai-config-proxy.js
++++ b/server/admin/ai-config-proxy.js
@@@ -402,12 +402,19 @@ function createAiConfigProxy(opts = {}) {
} catch (err) { fail(res, err); }
});
-- for (const [path, action] of [['/owner-inference/spend-caps', 'provider.spend_cap'], ['/owner-inference/routing', 'owner_routing.update'], ['/owner-inference/economy', 'owner_economy.update']]) {
++ for (const [path, aiPath, action] of [
++ ['/owner-inference/spend-caps', '/spend-caps', 'provider.spend_cap'],
++ ['/owner-inference/routing', '/routing', 'owner_routing.update'],
++ ['/owner-inference/economy', '/economy', 'owner_economy.update'],
++ ['/owner-inference/providers', '/providers', 'provider.control'],
++ ['/owner-inference/rules', '/rules', 'owner_inference.rules'],
++ ['/owner-inference/endpoint', '/endpoint', 'provider.endpoint'],
++ ]) {
r.post(path, async (req, res) => {
try {
const body = req.body && typeof req.body === 'object' ? req.body : {};
if (body.api_key !== undefined || body.secret !== undefined) throw new ProxyError(422, 'config.secret_write_only', 'send the key to its own write-only route, never here');
-- const out = await ownerInfCall(req, 'POST', ownerInfPath(path === '/owner-inference/spend-caps' ? '/spend-caps' : path === '/owner-inference/routing' ? '/routing' : '/economy'), body);
++ const out = await ownerInfCall(req, 'POST', ownerInfPath(aiPath), body);
if (out.status < 200 || out.status >= 300) return upstreamFailed(res, 'ai', out);
const key = String(body.provider_key || body.capability || 'owner-inference');
await audit(req, { service: 'ai', namespace: 'ai.owner-inference', keys: [key], action, revision: null });
@@@ -416,6 +423,20 @@ function createAiConfigProxy(opts = {}) {
});
}
++ // The confirmed readings bundled with the AI release enter the price book as pending changes; the owner
++ // accepts each one in the price book, never here.
++ r.post('/owner-inference/pricebook/propose', async (req, res) => {
++ try {
++ const body = req.body && typeof req.body === 'object' ? req.body : {};
++ const proposals = Array.isArray(body.proposals) ? body.proposals : [];
++ if (!proposals.length) throw new ProxyError(422, 'config.invalid', 'no confirmed readings to propose');
++ const out = await callService(req, 'ai', '/api/v1/admin/console/pricebook/proposals', { method: 'POST', body: { proposals }, cap: 'ai.provider.manage' });
++ if (out.status < 200 || out.status >= 300) return upstreamFailed(res, 'ai', out);
++ await audit(req, { service: 'ai', namespace: 'ai.pricebook', keys: ['proposals'], action: 'price_changes.proposed', revision: null });
++ send(res, 200, { ok: true, result: out.answer });
++ } catch (err) { fail(res, err); }
++ });
++
r.get('/providers', async (req, res, next) => {
try {
const out = await providerCall(req, 'GET', providerPath());
ddiff --git a/test/admin-owner-inference.test.js b/test/admin-owner-inference.test.js
iindex ed27cb57..8f22eaad 100644
---- a/test/admin-owner-inference.test.js
++++ b/test/admin-owner-inference.test.js
@@@ -22,10 +22,10 @@ const ctx = {
toast: (m, t) => toasts.push([m, t]),
paiFriendly: (s, e) => e.message,
paiApi: async (p, o) => { if (o && o.method === 'POST') { posts.push([p, o.body]); return { ok: true }; } return { config: ctx.__data }; },
-- document: { getElementById: () => null, querySelector: () => null },
++ document: { getElementById: () => null, querySelector: () => null, querySelectorAll: () => ctx.__nodes || [] },
};
vm.createContext(ctx);
--vm.runInContext(src + '\n;globalThis.__oin = { OIN, OIN_KINDS, oinKindCard, oinSentence, oinModels, oinLimits, oinStrip, oinCosts, oinSaveKind, oinSaveCap, oinDraft, oinState };', ctx);
++vm.runInContext(src + '\n;globalThis.__oin = { OIN, OIN_KINDS, oinKindCard, oinSentence, oinModels, oinLimits, oinStrip, oinCosts, oinSaveKind, oinSaveCap, oinProviderCards, oinRules, oinCompute, oinPricebook, oinSaveProvider, oinSaveRules, oinSaveEndpoint, oinProposePrices, oinDraft, oinState };', ctx);
const O = ctx.__oin;
const P = (key, status, extra = {}) => ({ key, display_name: key.toUpperCase(), status, capabilities: ['chat'], key_state: 'configured', ...extra });
@@@ -42,6 +42,15 @@ ctx.__data = {
costs_24h: { by_provider: [{ provider: 'deepinfra', calls: 10, cost: 0.5, tokens_in: 100, tokens_out: 10, failures: 1, by_model: [{ model: 'gpt-oss-120b', calls: 10, cost: 0.5, tokens_in: 100, tokens_out: 10 }] }] },
costs_7d: { by_provider: [] },
kinds: { chat: { savings: { batches: { runs: 2, saved_usd: 0.25 }, prompt_cache: { cached_tokens: 1000 }, answer_cache: { cached_runs: 3 } } } },
++ controls: [
++ { key: 'iteracompute', display_name: 'IteraCompute', enabled: false, tier: null, traits: { latency_classes: ['background', 'batch'], concurrency: 1, payment: 'none', free_allowance: { tokens: 30000000, ends_at: '2026-11-24T08:00:00Z' }, source: 'https://iteracompute.com/free/', checked: '2026-11-10' }, allowance: { tokens: 30000000, left: 30000000, ends_at: '2026-11-24T08:00:00Z' }, data_policy: { trains: null, retention: null } },
++ { key: 'inception', display_name: 'Inception Labs', enabled: false, tier: null, traits: { latency_classes: ['interactive', 'background', 'batch'], source: 'https://docs.inceptionlabs.ai/resources/faq', checked: '2026-11-10' }, data_policy: { trains: true, retention: 'standard' } },
++ ],
++ rules: { private_training: false, flex_interactive: false, latency_limited_interactive: false, free_allowance_first: true },
++ compute: { providers: [
++ { key: 'runpod', display_name: 'Runpod', billing: 'per second', source: 'https://www.runpod.io/pricing', checked: '2026-11-10', pods: [{ gpu: 'RTX 3090 24GB', hourly_usd: 0.5 }], credits: { starter_usd: 1000, starter_eligibility: 'raised under $1M' } },
++ ], endpoint: null },
++ pricebook: { 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' }] },
};
O.OIN.data = ctx.__data;
@@@ -94,7 +103,31 @@ O.OIN.data = ctx.__data;
assert.match(limits, /Providers that are off \(1\)/);
const strip = O.oinStrip();
assert.match(strip, /Saved this week<\/span><b>\$0\.25<\/b>/);
-- const all = chat + unc + limits + strip + O.oinCosts();
++ // The provider wave: each provider's traits and source, the rules at safe values, the rented capacity
++ // and the confirmed prices.
++ const providers = O.oinProviderCards();
++ assert.match(providers, /IteraCompute/);
++ assert.match(providers, /answers background, batch \u00b7 1 at a time/);
++ assert.match(providers, /30M free tokens left of 30M/);
++ assert.match(providers, /not stated/);
++ assert.match(providers, /training: <span class="oin-bad">yes<\/span>/);
++ const rules = O.oinRules();
++ for (const key of ['private_training', 'flex_interactive', 'latency_limited_interactive', 'free_allowance_first']) assert.ok(rules.includes('data-oin-rule="' + key + '"'), key + ' switch');
++ assert.match(O.oinCompute(), /\$0\.50/);
++ assert.match(O.oinCompute(), /startup credits/);
++ assert.match(O.oinPricebook(), /1 readings/);
++ // Saving a provider posts its on/off and its tier.
++ const pRow = { dataset: { oinProvider: 'iteracompute' }, querySelector: (spec) => (spec.includes('provider-on') ? { checked: true } : { value: 'flex' }) };
++ await O.oinSaveProvider(pRow, { disabled: false });
++ assert.deepStrictEqual(JSON.parse(JSON.stringify(posts.pop())), ['/owner-inference/providers', { provider_key: 'iteracompute', enabled: true, tier: 'flex' }]);
++ // The rules all travel together.
++ ctx.__nodes = [{ dataset: { oinRule: 'private_training' }, checked: false }, { dataset: { oinRule: 'free_allowance_first' }, checked: true }];
++ await O.oinSaveRules({ disabled: false });
++ assert.deepStrictEqual(JSON.parse(JSON.stringify(posts.pop())), ['/owner-inference/rules', { private_training: false, free_allowance_first: true }]);
++ ctx.__nodes = [];
++ await O.oinProposePrices({ disabled: false });
++ assert.deepStrictEqual(JSON.parse(JSON.stringify(posts.pop())), ['/owner-inference/pricebook/propose', { proposals: ctx.__data.pricebook.confirmed }]);
++ const all = chat + unc + limits + strip + O.oinCosts() + providers + rules + O.oinCompute() + O.oinPricebook();
assert.ok(!/api_key|secret|sk-[a-z0-9]/i.test(all), 'no key or secret is drawn');
console.log('admin-owner-inference: six kinds as sentences, the locks shown and never switchable, the order by type, saves and limits posted, bad numbers refused, no secret drawn');
})().catch((err) => { console.error(err); process.exit(1); });