openvibers/openvibe.network Commits
Platform AI: who answers, and what it costs (#781)
Changes
@@@ -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>@@@ -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}++}@@@ -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');nnew file mode 100644@@@ -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');++ }++}@@@ -12,7 +12,7 @@ for (const [, body] of html.matchAll(/<script\b[^>]*>([\s\S]*?)<\/script>/g)) asconst 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]);nnew file mode 100644@@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, '''),++ 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); });
@@ -686,6 +686,7 @@
@@ -686,6 +686,7 @@
<script src="/js/admin/payments.js" defer></script>
<script src="/js/admin/payments.js" defer></script>
<script src="/js/admin/glossary.js" defer></script>
<script src="/js/admin/glossary.js" defer></script>
<script src="/js/admin/ai.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/setup.js" defer></script>
<script src="/js/admin/content.js" defer></script>
<script src="/js/admin/content.js" defer></script>
<script src="/js/admin/analytics.js" defer></script>
<script src="/js/admin/analytics.js" defer></script>
@@ -424,3 +424,128 @@ body:has(.admin-clarity.active) .ov-sel-opt { min-height:44px; }
@@ -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-more-actions .btn{flex:1 1 auto;justify-content:center}
.setup-row{padding:12px}
.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}
+}
@@ -766,6 +766,7 @@ function paiRender(root) {
@@ -766,6 +766,7 @@ function paiRender(root) {
root.innerHTML = paiShell(
root.innerHTML = paiShell(
'<nav class="pai-index" aria-label="Platform AI sections">'
'<nav class="pai-index" aria-label="Platform AI sections">'
+ '<a href="#pai-providers">AI providers' + count(PAI.providers.length) + '</a>'
+ '<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-exchange">What the Exchange offers' + count(exchange.length) + '</a>'
+ '<a href="#pai-money">Money' + count(money.length) + '</a>'
+ '<a href="#pai-money">Money' + count(money.length) + '</a>'
+ '<a href="#pai-services">Other services\' AI settings' + count(rest.length) + '</a>'
+ '<a href="#pai-services">Other services\' AI settings' + count(rest.length) + '</a>'
@@ -776,6 +777,7 @@ function paiRender(root) {
@@ -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>'
+ '<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 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>'
+ '<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-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-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>'
+ '<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) {
@@ -785,6 +787,7 @@ function paiRender(root) {
paiRenderProviders();
paiRenderProviders();
paiRenderCards();
paiRenderCards();
paiRenderVault();
paiRenderVault();
+ if (typeof oinLoad === 'function') oinLoad();
const add = document.getElementById('pai-add-provider');
const add = document.getElementById('pai-add-provider');
if (add) add.addEventListener('click', () => paiProviderDialog(null));
if (add) add.addEventListener('click', () => paiProviderDialog(null));
const list = document.getElementById('pai-providers-list');
const list = document.getElementById('pai-providers-list');
new file mode 100644
new file mode 100644
@@ -0,0 +1,325 @@
@@ -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');
+ }
+}
@@ -12,7 +12,7 @@ for (const [, body] of html.matchAll(/<script\b[^>]*>([\s\S]*?)<\/script>/g)) as
@@ -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();
const files = fs.readdirSync(path.join(pub, 'js', 'admin')).filter((f) => f.endsWith('.js')).sort();
// The module map, in load order (public/js/admin/…).
// 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');
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 tags = [...html.matchAll(/<script\b([^>]*)\bsrc="\/js\/admin\/([^"]+)"([^>]*)><\/script>/g)];
const loaded = tags.map((m) => m[2]);
const loaded = tags.map((m) => m[2]);
new file mode 100644
new file mode 100644
@@ -0,0 +1,100 @@
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, '''),
+ 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); });