openvibers/openvibe.network Commits

Platform AI: who answers, and what it costs (#781)

OP OpenVibers committed 3 days ago · be03641a0b · 6 files, +555 −1

* Platform AI: who answers, and what it costs

The owner's inference settings on the Platform AI page: one row per kind of
work that says its choice as a sentence and opens to change it (who answers
first, a price ceiling, providers never to use, the ways of paying less),
the uncensored kind's two locks shown and never switchable, a spending
limit per provider with what was spent, and what it cost per provider and
model for a day or a week.

* Owner inference: a glyph the site draws for the 18+ row; the admin script list names the new file

Changes

ddiff --git a/public/admin.html b/public/admin.html
iindex 01ed6b0d..7c8c2d01 100644
---- a/public/admin.html
++++ b/public/admin.html
@@@ -686,6 +686,7 @@
<script src="/js/admin/payments.js" defer></script>
<script src="/js/admin/glossary.js" defer></script>
<script src="/js/admin/ai.js" defer></script>
++<script src="/js/admin/inference.js" defer></script>
<script src="/js/admin/setup.js" defer></script>
<script src="/js/admin/content.js" defer></script>
<script src="/js/admin/analytics.js" defer></script>
ddiff --git a/public/css/admin.css b/public/css/admin.css
iindex d656ffb4..f9e12a50 100644
---- a/public/css/admin.css
++++ b/public/css/admin.css
@@@ -424,3 +424,128 @@ body:has(.admin-clarity.active) .ov-sel-opt { min-height:44px; }
.setup-more-actions .btn{flex:1 1 auto;justify-content:center}
.setup-row{padding:12px}
}
++
++/* ── Platform AI: who answers, and what it costs (js/admin/inference.js, owner only; 2026-11-10) ─────────────────
++ Calm cards, one per kind of work; the choice reads as a sentence before the controls. A tone is a tint and a soft
++ glow, never a coloured bar (the kit's rule, Shared 4.196.0). */
++.oin-lead{max-width:760px;font-size:12.5px;line-height:1.5;margin:-2px 0 10px}
++.oin-note{font-size:11.5px;margin-top:6px}
++.oin-bad{color:var(--danger-text,var(--danger))}
++.oin-label{display:block;font-size:11px;font-weight:700;letter-spacing:.02em;color:var(--text-muted);margin-bottom:5px}
++.oin-strip{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px;margin-bottom:6px}
++.oin-tile{display:grid;gap:3px;padding:13px 15px;border-radius:12px;background:var(--bg-card);border:1px solid var(--border)}
++.oin-tile-k{font-size:11px;font-weight:700;color:var(--text-muted)}
++.oin-tile b{font-size:22px;font-weight:800;letter-spacing:-.01em;font-variant-numeric:tabular-nums;line-height:1.15}
++.oin-tile-sub{font-size:11.5px;color:var(--text-secondary);line-height:1.4}
++.oin-tile.is-good{border-color:color-mix(in srgb,var(--success) 32%,var(--border));background:linear-gradient(135deg,color-mix(in srgb,var(--success) 11%,var(--bg-card)),var(--bg-card) 70%)}
++.oin-tile.is-good b{color:var(--success-text,var(--success))}
++.oin-kinds{display:grid;grid-template-columns:repeat(auto-fill,minmax(min(100%,420px),1fr));gap:8px 12px;align-items:start}
++.oin-kind{border-radius:14px;background:var(--bg-card);border:1px solid var(--border);transition:border-color .18s ease,box-shadow .18s ease}
++.oin-kind[open]{grid-column:1 / -1}
++.oin-kind-body{display:grid;gap:12px;padding:0 16px 12px}
++.oin-what{font-size:12px;line-height:1.45;margin-top:-2px}
++.oin-kind.is-dirty{border-color:color-mix(in srgb,var(--accent) 45%,var(--border));box-shadow:0 10px 28px -20px var(--accent)}
++.oin-kind-head{display:flex;gap:11px;align-items:center;padding:12px 14px;min-height:44px;cursor:pointer;list-style:none;border-radius:14px}
++.oin-kind-head::-webkit-details-marker{display:none}
++.oin-kind-head:hover{background:color-mix(in srgb,var(--text) 3%,transparent)}
++.oin-kind-head:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
++.oin-kind-t{flex:1;min-width:0;display:grid;gap:2px}
++.oin-kind-t b{font-size:13.5px;font-weight:800}
++.oin-chev{flex:none;font-size:12px;color:var(--text-muted);transition:transform .2s ease}
++.oin-kind[open] .oin-chev{transform:rotate(180deg)}
++.oin-kind-ic{flex:none;display:grid;place-items:center;width:34px;height:34px;border-radius:10px;background:color-mix(in srgb,var(--accent) 14%,transparent);color:var(--accent-light);font-size:14px}
++.oin-kind[data-kind="uncensored"] .oin-kind-ic{background:color-mix(in srgb,#8b5cf6 16%,transparent);color:#a78bfa}
++.oin-now{font-size:12px;line-height:1.4;color:var(--text-secondary)}
++.oin-kind.is-dirty .oin-now{color:var(--accent-light)}
++.oin-locks{display:grid;gap:6px}
++.oin-lock{display:flex;gap:9px;align-items:flex-start;padding:9px 11px;border-radius:10px;font-size:12px;line-height:1.45;color:var(--text-secondary);background:linear-gradient(135deg,color-mix(in srgb,#8b5cf6 12%,transparent),color-mix(in srgb,#8b5cf6 4%,transparent) 65%);box-shadow:inset 0 0 0 1px color-mix(in srgb,#8b5cf6 22%,transparent)}
++.oin-lock i{margin-top:2px;color:#a78bfa}
++.oin-lock b{color:var(--text)}
++.oin-row{min-width:0}
++.oin-two{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:12px 16px;align-items:start}
++.oin-seg{display:inline-flex;flex-wrap:wrap;gap:2px;padding:3px;border-radius:11px;background:var(--bg-tertiary);border:1px solid var(--border);max-width:100%}
++.oin-seg-opt{position:relative;cursor:pointer}
++.oin-seg-opt input{position:absolute;opacity:0;inset:0;margin:0;cursor:pointer}
++.oin-seg-opt span{display:inline-flex;align-items:center;min-height:32px;padding:0 13px;border-radius:8px;font-size:12.5px;font-weight:650;color:var(--text-secondary);transition:background .15s ease,color .15s ease}
++.oin-seg-opt:hover span{color:var(--text)}
++.oin-seg-opt input:checked+span{background:var(--bg-card);color:var(--text);box-shadow:0 1px 2px rgba(0,0,0,.25),inset 0 0 0 1px color-mix(in srgb,var(--accent) 35%,transparent)}
++.oin-seg-opt input:focus-visible+span{outline:2px solid var(--accent);outline-offset:1px}
++.oin-seg-sm{margin-bottom:10px}
++.oin-order{margin-top:10px}
++.oin-order-list{list-style:none;margin:0 0 8px;padding:0;display:grid;gap:5px}
++.oin-order-list li{display:flex;align-items:center;gap:6px;padding:5px 6px 5px 8px;border-radius:9px;background:var(--bg-tertiary);border:1px solid var(--border);min-width:0}
++.oin-order-n{flex:none;display:grid;place-items:center;width:22px;height:22px;border-radius:50%;font-size:11px;font-weight:800;background:color-mix(in srgb,var(--accent) 18%,transparent);color:var(--accent-light)}
++.oin-order-name{flex:1;min-width:0;font-size:12.5px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
++.oin-order-name code{font-family:ui-monospace,Menlo,Consolas,monospace;font-size:11px;font-weight:500;color:var(--text-muted)}
++.oin-icon{flex:none;display:grid;place-items:center;width:32px;height:32px;border:0;border-radius:8px;background:transparent;color:var(--text-muted);cursor:pointer}
++.oin-icon:hover:not(:disabled){background:var(--bg-card);color:var(--text)}
++.oin-icon.is-x:hover:not(:disabled){color:var(--danger-text,var(--danger))}
++.oin-icon:disabled{opacity:.35;cursor:default}
++.oin-add select,.oin-deny-add,.oin-money input,.oin-way-num input,.oin-limit-set input{min-height:36px;padding:7px 9px;background:var(--bg-input);border:1px solid var(--border);border-radius:8px;color:var(--text);font:inherit;font-size:12.5px}
++.oin-add select{width:100%}
++.oin-deny-add{min-width:150px;flex:1 1 150px;max-width:260px}
++.oin-money{position:relative;display:block}
++.oin-money .pai-prefix,.oin-limit-set .pai-prefix{position:absolute;left:10px;bottom:10px;color:var(--text-muted);font-size:12.5px;pointer-events:none}
++.oin-money input{width:100%;padding-left:22px}
++.oin-ways{border-top:1px solid var(--border);padding-top:4px}
++.oin-ways>summary{cursor:pointer;min-height:36px;display:flex;align-items:center;font-size:12.5px;font-weight:700;color:var(--text-secondary)}
++.oin-ways>summary:hover{color:var(--text)}
++.oin-ways-list{display:grid;gap:4px;padding-bottom:4px}
++.oin-way{display:flex;gap:10px;align-items:flex-start;padding:7px 4px;border-radius:9px;cursor:pointer}
++.oin-way:hover{background:color-mix(in srgb,var(--text) 3%,transparent)}
++.oin-way .pai-switch{min-height:22px;padding:0}
++.oin-way-text{display:grid;gap:1px;min-width:0;font-size:12.5px;line-height:1.4}
++.oin-way-text b{font-weight:650}
++.oin-way-text .muted{font-size:11.5px}
++.oin-way-num{align-items:center;justify-content:space-between;cursor:default}
++.oin-way-num input{width:120px;flex:none}
++.oin-soon{display:inline-block;margin-left:4px;padding:0 7px;border-radius:999px;font-size:10px;font-weight:700;color:var(--text-muted);background:var(--bg-tertiary);border:1px solid var(--border);white-space:nowrap}
++.oin-kind:not(.is-dirty) .oin-kind-foot{display:none}
++.oin-kind-foot [hidden]{display:none}
++.oin-kind-foot{display:flex;align-items:center;justify-content:flex-end;gap:8px;border-top:1px solid var(--border);padding-top:10px}
++.oin-dirty-note{margin-right:auto;font-size:11.5px;font-weight:600;color:var(--accent-light)}
++.oin-limits{display:grid;gap:8px}
++.oin-limit{display:grid;grid-template-columns:minmax(150px,1.2fr) minmax(110px,1fr) minmax(110px,1fr) auto;gap:10px 16px;align-items:center;padding:11px 14px;border-radius:11px;background:var(--bg-card);border:1px solid var(--border)}
++.oin-limit.is-off{opacity:.72}
++.oin-limit-name{display:grid;gap:2px;min-width:0}
++.oin-limit-name b{font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
++.oin-limit-name .muted{font-size:11.5px}
++.oin-limit-use{display:grid;gap:3px;font-size:12.5px;font-variant-numeric:tabular-nums}
++.oin-limit-use .oin-label{margin:0}
++.oin-meter{display:block;height:4px;border-radius:3px;background:var(--bg-tertiary);overflow:hidden}
++.oin-meter span{display:block;height:100%;border-radius:3px;background:var(--accent)}
++.oin-meter.is-near span{background:var(--warning)}
++.oin-meter.is-over span{background:var(--danger)}
++.oin-limit-set{display:flex;align-items:flex-end;gap:8px}
++.oin-limit-set label{position:relative;display:block}
++.oin-limit-set label .oin-label{margin-bottom:3px}
++.oin-limit-set input{width:96px;padding-left:22px}
++.oin-costs{display:grid;gap:6px}
++.oin-cost{border-radius:11px;background:var(--bg-card);border:1px solid var(--border)}
++.oin-cost>summary{display:grid;grid-template-columns:minmax(120px,200px) minmax(0,1fr) auto;gap:12px;align-items:center;min-height:44px;padding:6px 14px;cursor:pointer;list-style:none}
++.oin-cost>summary::-webkit-details-marker{display:none}
++.oin-cost-name{font-size:13px;font-weight:700;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
++.oin-cost-bar{height:6px;border-radius:4px;background:var(--bg-tertiary);overflow:hidden}
++.oin-cost-bar span{display:block;height:100%;border-radius:4px;background:linear-gradient(90deg,color-mix(in srgb,var(--accent) 70%,transparent),var(--accent))}
++.oin-cost-n{font-size:12.5px;font-variant-numeric:tabular-nums;white-space:nowrap;text-align:right}
++.oin-models{margin:0 14px 12px;width:calc(100% - 28px)}
++.oin-models td{font-variant-numeric:tabular-nums}
++@media(max-width:760px){
++ .oin-strip{grid-template-columns:repeat(2,minmax(0,1fr))}
++ .oin-tile.is-good{grid-column:1 / -1}
++ .oin-tile b{font-size:19px}
++ .oin-two{grid-template-columns:minmax(0,1fr)}
++ .oin-limit{grid-template-columns:minmax(0,1fr) minmax(0,1fr)}
++ .oin-limit-name,.oin-limit-set{grid-column:1 / -1}
++ .oin-limit-set label{flex:1}
++ .oin-limit-set input{width:100%}
++ .oin-cost>summary{grid-template-columns:minmax(0,1fr) auto}
++ .oin-cost-bar{grid-column:1 / -1;grid-row:2}
++}
++@media(max-width:640px){
++ #platform-ai .oin-seg-opt span,#platform-ai .oin-icon{min-height:44px;min-width:44px;justify-content:center}
++ #platform-ai .oin-seg{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));width:100%}
++ #platform-ai .oin-seg-opt span{width:100%}
++ .oin-kind-body{padding:0 12px 12px}
++ #platform-ai .oin-way{min-height:44px}
++}
ddiff --git a/public/js/admin/ai.js b/public/js/admin/ai.js
iindex 5e30d736..a2b0d6bf 100644
---- a/public/js/admin/ai.js
++++ b/public/js/admin/ai.js
@@@ -766,6 +766,7 @@ function paiRender(root) {
root.innerHTML = paiShell(
'<nav class="pai-index" aria-label="Platform AI sections">'
+ '<a href="#pai-providers">AI providers' + count(PAI.providers.length) + '</a>'
++ + '<a href="#pai-inference">Who answers, and what it costs</a>'
+ '<a href="#pai-exchange">What the Exchange offers' + count(exchange.length) + '</a>'
+ '<a href="#pai-money">Money' + count(money.length) + '</a>'
+ '<a href="#pai-services">Other services\' AI settings' + count(rest.length) + '</a>'
@@@ -776,6 +777,7 @@ function paiRender(root) {
+ '<p class="pai-words muted">Words used here: ' + [paiWhat('resale', 'reselling'), paiWhat('byok', 'the person\'s own key'), paiWhat('health', 'paused'), paiWhat('credits', 'Credits')].filter(Boolean).join(' · ') + '</p></div>'
+ '<div class="pai-toolbar"><button type="button" class="btn btn-primary" id="pai-add-provider"><i class="fa-solid fa-plus" aria-hidden="true"></i> Add provider</button></div>'
+ '<div id="pai-providers-list"></div></section>'
++ + '<section id="pai-inference" class="pai-section"><div class="pai-section-head"><h3>Who answers, and what it costs</h3><p class="muted">For each kind of work: which provider answers first, the most it may cost, the providers never to use and the ways of paying less; then a spending limit for each provider and what it all cost. Changes apply to the next call.</p></div><div id="oin-body"><p class="muted">Loading…</p></div></section>'
+ '<section id="pai-exchange" class="pai-section"><div class="pai-section-head"><h3>What the Exchange offers</h3><p class="muted">Which models people can pick on openvibe.exchange, the free tier, and the spending limits that protect your balance.</p></div><div id="pai-cards-exchange" data-pai-cards></div></section>'
+ '<section id="pai-money" class="pai-section"><div class="pai-section-head"><h3>Money</h3><p class="muted">Credits are what people spend on AI; these switches decide whether people can buy them, top up and pay sellers. Each switch says what it turns on before you change it. ' + paiWhat('credits') + '</p></div><div id="pai-cards-money" data-pai-cards></div></section>'
+ '<section id="pai-services" class="pai-section"><div class="pai-section-head"><h3>Other services\' AI settings</h3><p class="muted">AI settings that live in other parts of OpenVibe (Codes, Actor, Bench…), one card per service. Their other keys (publishing, sign-in apps, data sources) are on the <a href="/admin/setup">Setup</a> tab.</p></div><div id="pai-cards-services" data-pai-cards></div></section>'
@@@ -785,6 +787,7 @@ function paiRender(root) {
paiRenderProviders();
paiRenderCards();
paiRenderVault();
++ if (typeof oinLoad === 'function') oinLoad();
const add = document.getElementById('pai-add-provider');
if (add) add.addEventListener('click', () => paiProviderDialog(null));
const list = document.getElementById('pai-providers-list');
ddiff --git a/public/js/admin/inference.js b/public/js/admin/inference.js
nnew file mode 100644
iindex 00000000..394d6919
---- /dev/null
++++ b/public/js/admin/inference.js
@@@ -0,0 +1,325 @@
++/* ═══════════════════════════════════════════════════════════════
++ OpenVibe.Network — admin panel, Platform AI: "Who answers, and what it costs" (owner only).
++
++ The owner, 2026-11-10: "implement stuff like [Parasail] or anything else you can find for cheapest inference, and
++ also other methods, and make it all configurable in admin panel by owner." OpenVibe.AI keeps the configuration
++ (server/owner-config.js); Network's proxy is /api/admin/ai/config/owner-inference (owner only, audited, no key ever
++ in it). This file draws one section of the Platform AI page (admin/ai.js calls oinLoad() after it renders):
++ - the spend strip: the last day and the last week, and what batches saved;
++ - one card per kind of work: who answers first (automatic, cheapest, fastest or the owner's own order), the most
++ a million output tokens may cost, providers never to use, and the ways of paying less;
++ - the uncensored kind carries its two locks, shown and never switchable;
++ - spending limits per provider (a day, a month), with what was spent;
++ - what it cost, per provider and model, for the last day or week.
++ Classic script, global scope: it uses admin/core.js (api, esc, toast) and admin/ai.js (paiApi, paiFriendly).
++ ═══════════════════════════════════════════════════════════════ */
++const OIN_KINDS = [
++ { id: 'chat', name: 'Chat and agents', icon: 'fa-comments', what: 'Vibe, Actor, Codes and every conversation on the sites.' },
++ { id: 'uncensored', name: 'Uncensored (adults only)', icon: 'fa-user-lock', what: 'The 18+ tier: abliterated and uncensored models.' },
++ { id: 'embed', name: 'Search and memory', icon: 'fa-magnifying-glass', what: 'Embeddings: what search, memory and recommendations read.' },
++ { id: 'image', name: 'Pictures', icon: 'fa-image', what: 'Generated pictures, thumbnails and goal art.' },
++ { id: 'transcribe', name: 'Speech to text', icon: 'fa-microphone-lines', what: 'Transcripts of streams, recordings and voice messages.' },
++ { id: 'batch', name: 'Work that can wait', icon: 'fa-hourglass-half', what: 'Summaries, backfills and anything not in front of a person.' },
++];
++const OIN_STRATEGIES = [
++ ['auto', 'Automatic', 'OpenVibe.AI weighs price, speed and health'],
++ ['cheapest', 'Cheapest', 'The lowest price that passes the floors below'],
++ ['fastest', 'Fastest', 'The quickest answer, at any price under the ceiling'],
++ ['fixed', 'My order', 'Your list, top first; the next one when it fails'],
++];
++// The ways of paying less. `acts`: whether the switch changes calls today (OpenVibe.AI #445: the output limit and the
++// small model first do; the other three are recorded and their savings measured, but do not yet rewire the calls).
++const OIN_ECONOMY = [
++ ['small_first', 'Try a small model first', 'A large one answers only when the small one cannot.', true],
++ ['batches', 'Send it to a batch when it can wait', 'About half price; the answer comes within a day.', false],
++ ['prompt_cache', 'Keep repeated prompts cached', 'The same opening is paid once, then at a fraction.', false],
++ ['answer_cache', 'Reuse an identical answer', 'Only for the same person asking the same thing.', false],
++];
++const OIN = { data: null, window: '24h', busy: false, drafts: {} };
++
++const oinMoney = (v) => {
++ const n = Number(v) || 0;
++ if (n === 0) return '$0';
++ if (n < 0.01) return '$' + n.toFixed(4).replace(/0+$/, '');
++ if (n < 100) return '$' + n.toFixed(2);
++ return '$' + Math.round(n).toLocaleString('en-US');
++};
++const oinCount = (v) => {
++ const n = Number(v) || 0;
++ if (n >= 1e9) return (n / 1e9).toFixed(1).replace(/\.0$/, '') + 'B';
++ if (n >= 1e6) return (n / 1e6).toFixed(1).replace(/\.0$/, '') + 'M';
++ if (n >= 1e4) return (n / 1e3).toFixed(1).replace(/\.0$/, '') + 'k';
++ return n.toLocaleString('en-US');
++};
++const oinProvider = (key) => ((OIN.data && OIN.data.providers) || []).find((p) => p.key === key);
++const oinProviderName = (key) => { const p = oinProvider(key); return (p && p.display_name) || key; };
++
++async function oinLoad() {
++ const box = document.getElementById('oin-body');
++ if (!box) return;
++ try {
++ const r = await paiApi('/owner-inference');
++ OIN.data = r.config || {};
++ OIN.drafts = {};
++ oinRender();
++ } catch (err) {
++ box.innerHTML = '<p class="pai-error">' + esc(paiFriendly('ai', err)) + '</p>';
++ }
++}
++
++function oinRender() {
++ const box = document.getElementById('oin-body');
++ if (!box || !OIN.data) return;
++ box.innerHTML = oinStrip()
++ + '<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">What it cost</h4>' + oinCosts();
++ if (!box.dataset.wired) {
++ box.dataset.wired = '1';
++ box.addEventListener('click', oinClick);
++ box.addEventListener('change', oinChange);
++ box.addEventListener('input', oinInput);
++ }
++}
++
++// ── The strip: the last day, the last week, what was saved ──────────────────────────────────────────────────────
++function oinTotals(c) {
++ const t = { cost: 0, calls: 0, failures: 0, tokens: 0 };
++ for (const p of (c && c.by_provider) || []) { t.cost += Number(p.cost) || 0; t.calls += Number(p.calls) || 0; t.failures += Number(p.failures) || 0; t.tokens += (Number(p.tokens_in) || 0) + (Number(p.tokens_out) || 0); }
++ return t;
++}
++function oinStrip() {
++ const day = oinTotals(OIN.data.costs_24h), week = oinTotals(OIN.data.costs_7d);
++ const s = (OIN.data.kinds && OIN.data.kinds.chat && OIN.data.kinds.chat.savings) || {};
++ const batch = s.batches || {}, cache = s.prompt_cache || {}, answers = s.answer_cache || {};
++ const tile = (label, big, sub, tone = '') => '<div class="oin-tile' + (tone ? ' is-' + tone : '') + '"><span class="oin-tile-k">' + label + '</span><b>' + big + '</b><span class="oin-tile-sub">' + sub + '</span></div>';
++ const failed = (t) => t.failures ? ' · <span class="oin-bad">' + oinCount(t.failures) + ' failed</span>' : '';
++ const saved = batch.saved_usd != null ? oinMoney(batch.saved_usd) : '—';
++ return '<div class="oin-strip">'
++ + tile('Last 24 hours', oinMoney(day.cost), oinCount(day.calls) + ' calls · ' + oinCount(day.tokens) + ' tokens' + failed(day))
++ + tile('Last 7 days', oinMoney(week.cost), oinCount(week.calls) + ' calls · ' + oinCount(week.tokens) + ' tokens' + failed(week))
++ + tile('Saved this week', saved, (batch.runs ? oinCount(batch.runs) + ' batch runs' : 'no batch runs yet') + ' · ' + oinCount(cache.cached_tokens) + ' cached tokens · ' + oinCount(answers.cached_runs) + ' reused answers', batch.saved_usd > 0 ? 'good' : '')
++ + '</div>';
++}
++
++// ── One card per kind of work ────────────────────────────────────────────────────────────────────────────────────
++function oinState(kind) {
++ if (OIN.drafts[kind]) return OIN.drafts[kind];
++ const r = ((OIN.data.routing || []).find((x) => x.capability === kind)) || {};
++ const e = ((OIN.data.economy || []).find((x) => x.capability === kind)) || {};
++ return {
++ strategy: r.enabled === false || !r.strategy ? 'auto' : r.strategy,
++ ceiling: r.ceiling == null ? '' : String(r.ceiling),
++ denylist: Array.isArray(r.denylist) ? r.denylist.slice() : [],
++ choices: Array.isArray(r.choices) ? r.choices.map((c) => ({ provider: c.provider, model: c.model })) : [],
++ small_first: !!e.small_first, batches: !!e.batches, prompt_cache: !!e.prompt_cache, answer_cache: !!e.answer_cache,
++ output_limit: e.output_limit == null ? '' : String(e.output_limit),
++ };
++}
++function oinDraft(kind) { if (!OIN.drafts[kind]) OIN.drafts[kind] = oinState(kind); return OIN.drafts[kind]; }
++
++/** The card's state as one sentence, so the owner reads the choice before the controls. */
++function oinSentence(kind, st) {
++ const how = { auto: 'OpenVibe.AI chooses', cheapest: 'The cheapest answers first', fastest: 'The fastest answers first', fixed: 'Your order' }[st.strategy] || 'OpenVibe.AI chooses';
++ const bits = [how + (st.strategy === 'fixed' && st.choices.length ? ' (' + st.choices.length + ')' : '')];
++ if (st.ceiling !== '') bits.push('at most ' + oinMoney(st.ceiling) + ' per million output tokens');
++ if (st.denylist.length) bits.push(st.denylist.length + ' never used');
++ const on = OIN_ECONOMY.filter(([k]) => st[k]).length + (st.output_limit !== '' ? 1 : 0);
++ if (on) bits.push(on + ' way' + (on === 1 ? '' : 's') + ' of paying less');
++ return bits.join(' · ');
++}
++
++/** The models this kind may pick from, for "My order": registered models of providers that are on (for uncensored,
++ * uncensored models only), cheapest output first. */
++function oinModels(kind) {
++ const out = [];
++ for (const m of (OIN.data.models || [])) {
++ const p = oinProvider(m.provider_key || m.provider);
++ if (!p || p.status !== 'active') continue;
++ if (kind === 'uncensored' ? m.content_tier !== 'uncensored' : m.content_tier === 'uncensored') continue;
++ // The model's own type (OpenVibe.AI models.type: chat | vision | embedding | stt | image) decides the kind.
++ const type = String(m.type || 'chat');
++ const fits = kind === 'embed' ? type === 'embedding' : kind === 'transcribe' ? type === 'stt' : kind === 'image' ? /image/.test(type) : type === 'chat' || type === 'vision';
++ if (!fits) continue;
++ out.push({ provider: p.key, model: m.model_key || m.model, label: (p.display_name || p.key) + ' · ' + (m.display_name || m.model_key || m.model), out: Number(m.cost_out_per_mtok) });
++ }
++ return out.sort((a, b) => (Number.isFinite(a.out) ? a.out : 1e9) - (Number.isFinite(b.out) ? b.out : 1e9));
++}
++
++function oinKindCard(k) {
++ const st = oinState(k.id);
++ const dirty = !!OIN.drafts[k.id];
++ const radios = OIN_STRATEGIES.map(([id, label, hint]) => '<label class="oin-seg-opt" title="' + esc(hint) + '"><input type="radio" name="oin-strategy-' + k.id + '" value="' + id + '" data-oin="strategy" data-ov-native' + (st.strategy === id ? ' checked' : '') + '><span>' + label + '</span></label>').join('');
++ const hint = (OIN_STRATEGIES.find(([id]) => id === st.strategy) || [])[2] || '';
++ const models = st.strategy === 'fixed' ? oinModels(k.id) : [];
++ const order = st.strategy !== 'fixed' ? ''
++ : '<div class="oin-order">'
++ + (st.choices.length ? '<ol class="oin-order-list">' + st.choices.map((c, i) => '<li><span class="oin-order-n">' + (i + 1) + '</span><span class="oin-order-name">' + esc(oinProviderName(c.provider)) + ' <code>' + esc(c.model || 'its default') + '</code></span>'
++ + '<button type="button" class="oin-icon" data-oin-act="up" data-i="' + i + '" aria-label="Move up"' + (i === 0 ? ' disabled' : '') + '><i class="fa-solid fa-arrow-up" aria-hidden="true"></i></button>'
++ + '<button type="button" class="oin-icon" data-oin-act="down" data-i="' + i + '" aria-label="Move down"' + (i === st.choices.length - 1 ? ' disabled' : '') + '><i class="fa-solid fa-arrow-down" aria-hidden="true"></i></button>'
++ + '<button type="button" class="oin-icon is-x" data-oin-act="drop" data-i="' + i + '" aria-label="Remove"><i class="fa-solid fa-xmark" aria-hidden="true"></i></button></li>').join('') + '</ol>'
++ : '<p class="muted oin-note">Nothing in your order yet: until you add one, OpenVibe.AI chooses.</p>')
++ + (models.length ? '<div class="oin-add"><select data-oin="pick" aria-label="Add a model to the order"><option value="">Add a model…</option>' + models.filter((m) => !st.choices.some((c) => c.provider === m.provider && c.model === m.model)).map((m) => '<option value="' + esc(JSON.stringify([m.provider, m.model])) + '">' + esc(m.label) + (Number.isFinite(m.out) ? ' · ' + oinMoney(m.out) + ' out' : '') + '</option>').join('') + '</select></div>'
++ : '<p class="muted oin-note">No provider that is on offers this kind yet.</p>')
++ + '</div>';
++ const deny = '<div class="pai-chips">' + st.denylist.map((key) => '<span class="pai-chip"><span>' + esc(oinProviderName(key)) + '</span><button type="button" data-oin-act="undeny" data-key="' + esc(key) + '" aria-label="Allow ' + esc(oinProviderName(key)) + ' again">×</button></span>').join('')
++ + '<select class="oin-deny-add" data-oin="deny" aria-label="Never use a provider"><option value="">' + (st.denylist.length ? 'Add another…' : 'None. Add one…') + '</option>' + ((OIN.data.providers || []).filter((p) => !st.denylist.includes(p.key)).map((p) => '<option value="' + esc(p.key) + '">' + esc(p.display_name || p.key) + '</option>').join('')) + '</select></div>';
++ const economy = OIN_ECONOMY.map(([key, label, desc, acts]) => '<label class="oin-way"><span class="pai-switch"><input type="checkbox" role="switch" data-oin="' + key + '" data-ov-native' + (st[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 + (acts ? '' : ' <span class="oin-soon">Recorded; not changing calls yet</span>') + '</span></span></label>').join('')
++ + '<label class="oin-way oin-way-num"><span class="oin-way-text"><b>Longest answer</b><span class="muted">Tokens; blank is the model\'s own limit.</span></span><input type="number" min="1" step="1" inputmode="numeric" data-oin="output_limit" value="' + esc(st.output_limit) + '" placeholder="No limit" aria-label="Longest answer in tokens"></label>';
++ const locks = k.id !== 'uncensored' ? '' : '<div class="oin-locks">'
++ + '<div class="oin-lock"><i class="fa-solid fa-lock" aria-hidden="true"></i><span><b>Adults only.</b> The 18+ check runs before every call. Always on.</span></div>'
++ + '<div class="oin-lock"><i class="fa-solid fa-lock" aria-hidden="true"></i><span><b>Nothing sexual involving a minor.</b> Blocked in front of every provider and model. Always on.</span></div></div>';
++ // A row that reads as a sentence; it opens to change it (the six kinds fit one screen, nothing is hidden: the
++ // choice is in the row itself).
++ return '<details class="oin-kind' + (dirty ? ' is-dirty' : '') + '" data-kind="' + k.id + '"' + (dirty ? ' open' : '') + '>'
++ + '<summary class="oin-kind-head"><span class="oin-kind-ic"><i class="fa-solid ' + k.icon + '" aria-hidden="true"></i></span><span class="oin-kind-t"><b>' + esc(k.name) + '</b><span class="oin-now">' + esc(oinSentence(k.id, st)) + '</span></span><i class="fa-solid fa-chevron-down oin-chev" aria-hidden="true"></i></summary>'
++ + '<div class="oin-kind-body"><p class="muted oin-what">' + esc(k.what) + '</p>' + locks
++ + '<div class="oin-row"><span class="oin-label" id="oin-who-' + k.id + '">Who answers first</span><div class="oin-seg" role="radiogroup" aria-labelledby="oin-who-' + k.id + '">' + radios + '</div><p class="muted oin-note">' + esc(hint) + '</p>' + order + '</div>'
++ + '<div class="oin-row oin-two"><label class="oin-money"><span class="oin-label">Price ceiling, per million tokens out</span><span class="pai-prefix">$</span><input type="number" min="0" step="0.01" inputmode="decimal" data-oin="ceiling" value="' + esc(st.ceiling) + '" placeholder="No ceiling"></label>'
++ + '<div><span class="oin-label">Never use</span>' + deny + '</div></div>'
++ + '<details class="oin-ways"' + (OIN_ECONOMY.some(([key]) => st[key]) || st.output_limit !== '' ? ' open' : '') + '><summary>Ways of paying less</summary><div class="oin-ways-list">' + economy + '</div></details>'
++ + '<footer class="oin-kind-foot"><span class="oin-dirty-note" aria-live="polite">' + (dirty ? 'Not saved yet' : '') + '</span>'
++ + '<button type="button" class="btn btn-sm pai-quiet" data-oin-act="reset"' + (dirty ? '' : ' hidden') + '>Undo</button>'
++ + '<button type="button" class="btn btn-sm btn-primary" data-oin-act="save"' + (dirty ? '' : ' disabled') + '>Save</button></footer>'
++ + '</div></details>';
++}
++
++// ── Spending limits ──────────────────────────────────────────────────────────────────────────────────────────────
++function oinLimitRow(p) {
++ const cap = p.spend_cap || {}, spend = p.spend || {};
++ const on = p.status === 'active';
++ const key = p.key_state === 'missing' ? '<span class="oin-bad">No key</span>' : p.key_state === 'not_required' ? 'No key needed' : 'Key set';
++ const used = (spent, limit) => oinMoney(spent) + (limit != null && limit !== '' ? ' <span class="muted">of ' + oinMoney(limit) + '</span>' : '');
++ const pct = (spent, limit) => (limit ? Math.min(100, Math.round((Number(spent) || 0) / Number(limit) * 100)) : 0);
++ const meter = (spent, limit) => (limit ? '<span class="oin-meter' + (pct(spent, limit) >= 100 ? ' is-over' : pct(spent, limit) >= 80 ? ' is-near' : '') + '" aria-hidden="true"><span style="width:' + pct(spent, limit) + '%"></span></span>' : '');
++ return '<div class="oin-limit' + (on ? '' : ' is-off') + '" data-provider="' + esc(p.key) + '">'
++ + '<div class="oin-limit-name"><b>' + esc(p.display_name || p.key) + '</b><span class="muted">' + (on ? 'On' : 'Off') + ' · ' + key + (p.uncensored ? ' · 18+' : '') + '</span></div>'
++ + '<div class="oin-limit-use"><span class="oin-label">Today</span><span>' + used(spend.spent_today, cap.daily_usd) + '</span>' + meter(spend.spent_today, cap.daily_usd) + '</div>'
++ + '<div class="oin-limit-use"><span class="oin-label">This month</span><span>' + used(spend.spent_month, cap.monthly_usd) + '</span>' + meter(spend.spent_month, cap.monthly_usd) + '</div>'
++ + '<div class="oin-limit-set"><label><span class="oin-label">A day</span><span class="pai-prefix">$</span><input type="number" min="0" step="1" inputmode="decimal" data-oin-cap="daily_usd" value="' + esc(cap.daily_usd == null ? '' : cap.daily_usd) + '" placeholder="None"></label>'
++ + '<label><span class="oin-label">A month</span><span class="pai-prefix">$</span><input type="number" min="0" step="1" inputmode="decimal" data-oin-cap="monthly_usd" value="' + esc(cap.monthly_usd == null ? '' : cap.monthly_usd) + '" placeholder="None"></label>'
++ + '<button type="button" class="btn btn-sm pai-quiet" data-oin-act="cap">Save</button></div>'
++ + '</div>';
++}
++function oinLimits() {
++ const all = (OIN.data.providers || []).slice();
++ const used = (p) => (p.spend && (Number(p.spend.spent_month) || 0)) || 0;
++ const first = all.filter((p) => p.status === 'active' || p.spend_cap).sort((a, b) => used(b) - used(a));
++ const rest = all.filter((p) => !first.includes(p));
++ if (!all.length) return '<p class="muted">No providers yet.</p>';
++ return '<div class="oin-limits">' + first.map(oinLimitRow).join('') + '</div>'
++ + (rest.length ? '<details class="pai-more"><summary>Providers that are off (' + rest.length + ')</summary><div class="oin-limits">' + rest.map(oinLimitRow).join('') + '</div></details>' : '');
++}
++
++// ── What it cost ─────────────────────────────────────────────────────────────────────────────────────────────────
++function oinCosts() {
++ const c = OIN.window === '7d' ? OIN.data.costs_7d : OIN.data.costs_24h;
++ const rows = ((c && c.by_provider) || []).slice().sort((a, b) => (Number(b.cost) || 0) - (Number(a.cost) || 0));
++ const seg = '<div class="oin-seg oin-seg-sm" role="radiogroup" aria-label="Which days">' + [['24h', 'Last 24 hours'], ['7d', 'Last 7 days']].map(([id, label]) => '<label class="oin-seg-opt"><input type="radio" name="oin-window" value="' + id + '" data-oin="window" data-ov-native' + (OIN.window === id ? ' checked' : '') + '><span>' + label + '</span></label>').join('') + '</div>';
++ if (!rows.length) return seg + '<p class="muted oin-note">No calls in this window.</p>';
++ const max = Math.max(...rows.map((r) => Number(r.cost) || 0), 0.000001);
++ const row = (r) => '<details class="oin-cost"><summary><span class="oin-cost-name">' + esc(oinProviderName(r.provider)) + '</span>'
++ + '<span class="oin-cost-bar" aria-hidden="true"><span style="width:' + Math.max(2, Math.round((Number(r.cost) || 0) / max * 100)) + '%"></span></span>'
++ + '<span class="oin-cost-n"><b>' + oinMoney(r.cost) + '</b> <span class="muted">' + oinCount(r.calls) + ' calls' + (r.failures ? ' · <span class="oin-bad">' + oinCount(r.failures) + ' failed</span>' : '') + '</span></span></summary>'
++ + '<table class="pai-table oin-models"><thead><tr><th>Model</th><th>Calls</th><th>Tokens in</th><th>Tokens out</th><th>Cost</th></tr></thead><tbody>'
++ + (r.by_model || []).map((m) => '<tr><td><code>' + esc(m.model) + '</code></td><td>' + oinCount(m.calls) + (m.failures ? ' <span class="oin-bad">(' + oinCount(m.failures) + ' failed)</span>' : '') + '</td><td>' + oinCount(m.tokens_in) + '</td><td>' + oinCount(m.tokens_out) + '</td><td>' + oinMoney(m.cost) + '</td></tr>').join('')
++ + '</tbody></table></details>';
++ return seg + '<div class="oin-costs">' + rows.map(row).join('') + '</div>';
++}
++
++// ── Changes ──────────────────────────────────────────────────────────────────────────────────────────────────────
++function oinRepaint(kind) {
++ const old = document.querySelector('#oin-body .oin-kind[data-kind="' + kind + '"]');
++ const k = OIN_KINDS.find((x) => x.id === kind);
++ if (!old || !k) return;
++ const open = old.querySelector('.oin-ways') && old.querySelector('.oin-ways').open;
++ const t = document.createElement('template');
++ t.innerHTML = oinKindCard(k);
++ const fresh = t.content.firstElementChild;
++ fresh.open = old.open || fresh.open;
++ if (open && fresh.querySelector('.oin-ways')) fresh.querySelector('.oin-ways').open = true;
++ old.replaceWith(fresh);
++}
++function oinMarkDirty(card) {
++ if (!card) return;
++ card.classList.add('is-dirty');
++ const save = card.querySelector('[data-oin-act="save"]'), undo = card.querySelector('[data-oin-act="reset"]'), note = card.querySelector('.oin-dirty-note');
++ if (save) save.disabled = false;
++ if (undo) undo.hidden = false;
++ if (note) note.textContent = 'Not saved yet';
++ const now = card.querySelector('.oin-now');
++ if (now) now.textContent = oinSentence(card.dataset.kind, oinDraft(card.dataset.kind));
++}
++function oinInput(ev) {
++ const el = ev.target.closest('[data-oin="ceiling"], [data-oin="output_limit"]');
++ const card = el && el.closest('.oin-kind');
++ if (!card) return;
++ oinDraft(card.dataset.kind)[el.dataset.oin] = el.value.trim();
++ oinMarkDirty(card);
++}
++function oinChange(ev) {
++ const el = ev.target;
++ if (el.dataset.oin === 'window') { OIN.window = el.value; oinRender(); return; }
++ const card = el.closest('.oin-kind');
++ if (!card) return;
++ const kind = card.dataset.kind, d = oinDraft(kind);
++ if (el.dataset.oin === 'strategy') { d.strategy = el.value; oinRepaint(kind); return; }
++ if (el.dataset.oin === 'pick' && el.value) { try { const [provider, model] = JSON.parse(el.value); d.choices.push({ provider, model }); } catch { /* not a choice */ } oinRepaint(kind); return; }
++ if (el.dataset.oin === 'deny' && el.value) { if (!d.denylist.includes(el.value)) d.denylist.push(el.value); oinRepaint(kind); return; }
++ if (OIN_ECONOMY.some(([key]) => key === el.dataset.oin)) { d[el.dataset.oin] = el.checked; oinMarkDirty(card); }
++}
++async function oinClick(ev) {
++ const btn = ev.target.closest('[data-oin-act]');
++ if (!btn || btn.disabled) return;
++ const act = btn.dataset.oinAct;
++ if (act === 'cap') return oinSaveCap(btn.closest('.oin-limit'), btn);
++ const card = btn.closest('.oin-kind');
++ if (!card) return;
++ const kind = card.dataset.kind, d = oinDraft(kind), i = Number(btn.dataset.i);
++ if (act === 'up' && i > 0) { [d.choices[i - 1], d.choices[i]] = [d.choices[i], d.choices[i - 1]]; return oinRepaint(kind); }
++ if (act === 'down' && i < d.choices.length - 1) { [d.choices[i + 1], d.choices[i]] = [d.choices[i], d.choices[i + 1]]; return oinRepaint(kind); }
++ if (act === 'drop') { d.choices.splice(i, 1); return oinRepaint(kind); }
++ if (act === 'undeny') { d.denylist = d.denylist.filter((k) => k !== btn.dataset.key); return oinRepaint(kind); }
++ if (act === 'reset') { delete OIN.drafts[kind]; return oinRepaint(kind); }
++ if (act === 'save') return oinSaveKind(kind, btn);
++}
++function oinNumber(text, { integer = false } = {}) {
++ if (text === '' || text == null) return { value: null };
++ const n = Number(text);
++ if (!Number.isFinite(n) || n < 0 || (integer && (!Number.isInteger(n) || n < 1))) return { error: integer ? 'a whole number above 0' : 'a number of dollars, 0 or more' };
++ return { value: n };
++}
++async function oinSaveKind(kind, btn) {
++ const d = oinDraft(kind);
++ const ceiling = oinNumber(d.ceiling), limit = oinNumber(d.output_limit, { integer: true });
++ if (ceiling.error) return toast('Price ceiling: ' + ceiling.error, 'error');
++ if (limit.error) return toast('Longest answer: ' + limit.error, 'error');
++ if (d.strategy === 'fixed' && !d.choices.length) return toast('Add at least one model to your order, or choose Automatic.', 'error');
++ btn.disabled = true;
++ try {
++ await paiApi('/owner-inference/routing', { method: 'POST', body: { capability: kind, strategy: d.strategy, enabled: d.strategy !== 'auto' || ceiling.value != null || d.denylist.length > 0, ceiling_usd_per_mtok_out: ceiling.value, denylist: d.denylist, choices: d.strategy === 'fixed' ? d.choices : [] } });
++ await paiApi('/owner-inference/economy', { method: 'POST', body: { capability: kind, small_first: d.small_first, batches: d.batches, prompt_cache: d.prompt_cache, answer_cache: d.answer_cache, output_limit: limit.value } });
++ toast((OIN_KINDS.find((k) => k.id === kind) || {}).name + ': saved', 'success');
++ await oinLoad();
++ } catch (err) {
++ 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());
++ const month = oinNumber(row.querySelector('[data-oin-cap="monthly_usd"]').value.trim());
++ if (day.error || month.error) return toast('Spending limits are ' + (day.error || month.error), 'error');
++ btn.disabled = true;
++ try {
++ await paiApi('/owner-inference/spend-caps', { method: 'POST', body: { provider_key: row.dataset.provider, daily_usd: day.value, monthly_usd: month.value } });
++ toast(oinProviderName(row.dataset.provider) + ': limits saved', 'success');
++ await oinLoad();
++ } catch (err) {
++ btn.disabled = false;
++ toast(paiFriendly('ai', err), 'error');
++ }
++}
ddiff --git a/test/admin-modules.test.js b/test/admin-modules.test.js
iindex 93f23e85..ae71d68e 100644
---- a/test/admin-modules.test.js
++++ b/test/admin-modules.test.js
@@@ -12,7 +12,7 @@ for (const [, body] of html.matchAll(/<script\b[^>]*>([\s\S]*?)<\/script>/g)) as
const files = fs.readdirSync(path.join(pub, 'js', 'admin')).filter((f) => f.endsWith('.js')).sort();
// The module map, in load order (public/js/admin/…).
--const modules = ['core.js', 'dashboard.js', 'users.js', 'moderation.js', 'settings.js', 'payments.js', 'glossary.js', 'ai.js', 'setup.js', 'content.js', 'analytics.js', 'deploy.js', 'boot.js'];
++const modules = ['core.js', 'dashboard.js', 'users.js', 'moderation.js', 'settings.js', 'payments.js', 'glossary.js', 'ai.js', 'inference.js', 'setup.js', 'content.js', 'analytics.js', 'deploy.js', 'boot.js'];
assert.deepStrictEqual(files, [...modules].sort(), 'each module file exists and there is no other');
const tags = [...html.matchAll(/<script\b([^>]*)\bsrc="\/js\/admin\/([^"]+)"([^>]*)><\/script>/g)];
const loaded = tags.map((m) => m[2]);
ddiff --git a/test/admin-owner-inference.test.js b/test/admin-owner-inference.test.js
nnew file mode 100644
iindex 00000000..ed27cb57
---- /dev/null
++++ b/test/admin-owner-inference.test.js
@@@ -0,0 +1,100 @@
++'use strict';
++// "Who answers, and what it costs" on the Platform AI page (public/js/admin/inference.js; the owner, 2026-11-10:
++// cheapest inference "configurable in admin panel by owner"). Pure checks in a vm, as admin-ai-clarity does:
++// - the six kinds of work, each a row whose summary says its choice as a sentence;
++// - the uncensored kind shows its two locks and offers no switch for them;
++// - "My order" offers only models of the right type from providers that are on, uncensored ones only for that kind;
++// - Save posts routing and economy for that kind, with the order, the ceiling, the never-use list and the limits;
++// - a spending limit posts dollars for that provider; a bad number is refused in words, nothing is sent;
++// - nothing secret-looking is ever drawn.
++// node test/admin-owner-inference.test.js
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const vm = require('vm');
++
++const src = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'admin', 'inference.js'), 'utf8');
++const posts = [];
++const toasts = [];
++const ctx = {
++ console,
++ esc: (s) => String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;'),
++ 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 },
++};
++vm.createContext(ctx);
++vm.runInContext(src + '\n;globalThis.__oin = { OIN, OIN_KINDS, oinKindCard, oinSentence, oinModels, oinLimits, oinStrip, oinCosts, oinSaveKind, oinSaveCap, oinDraft, oinState };', ctx);
++const O = ctx.__oin;
++
++const P = (key, status, extra = {}) => ({ key, display_name: key.toUpperCase(), status, capabilities: ['chat'], key_state: 'configured', ...extra });
++ctx.__data = {
++ providers: [P('deepinfra', 'active', { spend_cap: { daily_usd: 5, monthly_usd: 100 }, spend: { spent_today: 4.31, spent_month: 61.2 } }), P('venice', 'active', { uncensored: true }), P('parasail', 'disabled')],
++ models: [
++ { provider_key: 'deepinfra', model_key: 'gpt-oss-120b', type: 'chat', cost_out_per_mtok: 0.19, content_tier: 'standard' },
++ { provider_key: 'deepinfra', model_key: 'bge-m3', type: 'embedding', cost_out_per_mtok: 0, content_tier: 'standard' },
++ { provider_key: 'venice', model_key: 'glm-heretic', type: 'chat', cost_out_per_mtok: 0.4, content_tier: 'uncensored' },
++ { provider_key: 'parasail', model_key: 'kimi-k2.6', type: 'chat', cost_out_per_mtok: 3, content_tier: 'standard' },
++ ],
++ routing: [{ capability: 'chat', strategy: 'cheapest', ceiling: 2, denylist: ['venice'], choices: [], enabled: true }],
++ economy: [{ capability: 'chat', small_first: true, output_limit: 4000 }],
++ 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 } } } },
++};
++O.OIN.data = ctx.__data;
++
++(async () => {
++ // Six kinds, each a row that says its choice.
++ assert.strictEqual(O.OIN_KINDS.map((k) => k.id).join(' '), 'chat uncensored embed image transcribe batch');
++ const chat = O.oinKindCard(O.OIN_KINDS[0]);
++ assert.match(chat, /^<details class="oin-kind" data-kind="chat">/);
++ assert.match(chat, /<span class="oin-now">The cheapest answers first · at most \$2\.00 per million output tokens · 1 never used · 2 ways of paying less<\/span>/);
++ assert.strictEqual(O.oinSentence('embed', O.oinState('embed')), 'OpenVibe.AI chooses', 'nothing set reads as the default');
++
++ // The uncensored kind: both locks, and no control for either.
++ const unc = O.oinKindCard(O.OIN_KINDS[1]);
++ assert.match(unc, /Adults only\.<\/b> The 18\+ check runs before every call\. Always on\./);
++ assert.match(unc, /Nothing sexual involving a minor\.<\/b> Blocked in front of every provider and model\. Always on\./);
++ assert.ok(!/data-oin="(adult|minor|floor|gate)/i.test(unc), 'no switch for the locks');
++
++ // My order: the right type, providers that are on, uncensored only for its own kind.
++ assert.strictEqual(O.oinModels('chat').map((m) => m.model).join(' '), 'gpt-oss-120b');
++ assert.strictEqual(O.oinModels('uncensored').map((m) => m.model).join(' '), 'glm-heretic');
++ assert.strictEqual(O.oinModels('embed').map((m) => m.model).join(' '), 'bge-m3');
++
++ // Save: routing then economy, for that kind.
++ const d = O.oinDraft('chat');
++ d.strategy = 'fixed'; d.choices.push({ provider: 'deepinfra', model: 'gpt-oss-120b' }); d.ceiling = '1.5'; d.output_limit = '2000'; d.batches = true;
++ await O.oinSaveKind('chat', { disabled: false });
++ assert.deepStrictEqual(JSON.parse(JSON.stringify(posts)), [
++ ['/owner-inference/routing', { capability: 'chat', strategy: 'fixed', enabled: true, ceiling_usd_per_mtok_out: 1.5, denylist: ['venice'], choices: [{ provider: 'deepinfra', model: 'gpt-oss-120b' }] }],
++ ['/owner-inference/economy', { capability: 'chat', small_first: true, batches: true, prompt_cache: false, answer_cache: false, output_limit: 2000 }],
++ ]);
++ posts.length = 0;
++ // An empty order is refused in words; nothing is sent.
++ const e = O.oinDraft('embed'); e.strategy = 'fixed';
++ await O.oinSaveKind('embed', { disabled: false });
++ assert.strictEqual(posts.length, 0);
++ assert.match(toasts[toasts.length - 1][0], /Add at least one model to your order/);
++ const bad = O.oinDraft('image'); bad.output_limit = '-4';
++ await O.oinSaveKind('image', { disabled: false });
++ assert.strictEqual(posts.length, 0);
++ assert.match(toasts[toasts.length - 1][0], /^Longest answer: a whole number above 0/);
++
++ // A spending limit: dollars for that provider; blank is no limit.
++ const row = { dataset: { provider: 'deepinfra' }, querySelector: (s) => ({ value: s.includes('daily') ? '7' : '' }) };
++ await O.oinSaveCap(row, { disabled: false });
++ assert.deepStrictEqual(JSON.parse(JSON.stringify(posts)), [['/owner-inference/spend-caps', { provider_key: 'deepinfra', daily_usd: 7, monthly_usd: null }]]);
++
++ // The limits, the strip and the costs; nothing secret-looking anywhere.
++ const limits = O.oinLimits();
++ assert.match(limits, /\$4\.31 <span class="muted">of \$5\.00<\/span>/);
++ 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();
++ 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); });