openvibers/openvibe.network Commits
Owner controls for the provider wave (#799)
Changes
@@@ -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());@@@ -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());@@@ -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); });
@@ -75,6 +75,8 @@ function oinRender() {
@@ -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">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>'
+ '<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()
+ 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();
+ '<h4 class="pai-subhead">What it cost</h4>' + oinCosts();
if (!box.dataset.wired) {
if (!box.dataset.wired) {
box.dataset.wired = '1';
box.dataset.wired = '1';
@@ -230,6 +232,84 @@ function oinCosts() {
@@ -230,6 +232,84 @@ function oinCosts() {
return seg + '<div class="oin-costs">' + rows.map(row).join('') + '</div>';
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 ──────────────────────────────────────────────────────────────────────────────────────────────────────
// ── Changes ──────────────────────────────────────────────────────────────────────────────────────────────────────
function oinRepaint(kind) {
function oinRepaint(kind) {
const old = document.querySelector('#oin-body .oin-kind[data-kind="' + kind + '"]');
const old = document.querySelector('#oin-body .oin-kind[data-kind="' + kind + '"]');
@@ -276,6 +356,10 @@ async function oinClick(ev) {
@@ -276,6 +356,10 @@ async function oinClick(ev) {
if (!btn || btn.disabled) return;
if (!btn || btn.disabled) return;
const act = btn.dataset.oinAct;
const act = btn.dataset.oinAct;
if (act === 'cap') return oinSaveCap(btn.closest('.oin-limit'), btn);
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');
const card = btn.closest('.oin-kind');
if (!card) return;
if (!card) return;
const kind = card.dataset.kind, d = oinDraft(kind), i = Number(btn.dataset.i);
const kind = card.dataset.kind, d = oinDraft(kind), i = Number(btn.dataset.i);
@@ -309,6 +393,52 @@ async function oinSaveKind(kind, btn) {
@@ -309,6 +393,52 @@ async function oinSaveKind(kind, btn) {
toast(paiFriendly('ai', err), 'error');
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) {
async function oinSaveCap(row, btn) {
if (!row) return;
if (!row) return;
const day = oinNumber(row.querySelector('[data-oin-cap="daily_usd"]').value.trim());
const day = oinNumber(row.querySelector('[data-oin-cap="daily_usd"]').value.trim());
@@ -402,12 +402,19 @@ function createAiConfigProxy(opts = {}) {
@@ -402,12 +402,19 @@ function createAiConfigProxy(opts = {}) {
} catch (err) { fail(res, err); }
} 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) => {
r.post(path, async (req, res) => {
try {
try {
const body = req.body && typeof req.body === 'object' ? req.body : {};
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');
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);
if (out.status < 200 || out.status >= 300) return upstreamFailed(res, 'ai', out);
const key = String(body.provider_key || body.capability || 'owner-inference');
const key = String(body.provider_key || body.capability || 'owner-inference');
await audit(req, { service: 'ai', namespace: 'ai.owner-inference', keys: [key], action, revision: null });
await audit(req, { service: 'ai', namespace: 'ai.owner-inference', keys: [key], action, revision: null });
@@ -416,6 +423,20 @@ function createAiConfigProxy(opts = {}) {
@@ -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) => {
r.get('/providers', async (req, res, next) => {
try {
try {
const out = await providerCall(req, 'GET', providerPath());
const out = await providerCall(req, 'GET', providerPath());
@@ -22,10 +22,10 @@ const ctx = {
@@ -22,10 +22,10 @@ const ctx = {
toast: (m, t) => toasts.push([m, t]),
toast: (m, t) => toasts.push([m, t]),
paiFriendly: (s, e) => e.message,
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 }; },
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.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 O = ctx.__oin;
const P = (key, status, extra = {}) => ({ key, display_name: key.toUpperCase(), status, capabilities: ['chat'], key_state: 'configured', ...extra });
const P = (key, status, extra = {}) => ({ key, display_name: key.toUpperCase(), status, capabilities: ['chat'], key_state: 'configured', ...extra });
@@ -42,6 +42,15 @@ ctx.__data = {
@@ -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_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: [] },
costs_7d: { by_provider: [] },
kinds: { chat: { savings: { batches: { runs: 2, saved_usd: 0.25 }, prompt_cache: { cached_tokens: 1000 }, answer_cache: { cached_runs: 3 } } } },
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;
O.OIN.data = ctx.__data;
@@ -94,7 +103,31 @@ O.OIN.data = ctx.__data;
@@ -94,7 +103,31 @@ O.OIN.data = ctx.__data;
assert.match(limits, /Providers that are off \(1\)/);
assert.match(limits, /Providers that are off \(1\)/);
const strip = O.oinStrip();
const strip = O.oinStrip();
assert.match(strip, /Saved this week<\/span><b>\$0\.25<\/b>/);
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');
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');
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); });
})().catch((err) => { console.error(err); process.exit(1); });