// Billing / pricing surface for newOS4 (2026-10-28) // // Why: the terminal could show a model's name but never what it costs. Prices and the anonymous // message allowance are public data, so they need no login โ the account BALANCE does, and newOS4 // has no session layer, so that is reported honestly as "not signed in" rather than guessed. // // Commands: cost [on|off], usage class BillingModule { constructor() { this.commands = {}; this.terminal = null; this._prices = null; this._pricesAt = 0; this._installed = false; } getHelpInfo() { return { 'cost': 'cost [on|off] โ AI prices, today\'s free-message allowance and this session\'s spend', 'usage': 'usage โ same as cost', }; } registerCommands() { this.commands['cost'] = (args) => this.run(args); this.commands['usage'] = (args) => this.run(args); } init(terminal) { this.terminal = terminal; this._hookUsage(); } // --- data ----------------------------------------------------------------- async _loadPrices() { if (this._prices && Date.now() - this._pricesAt < 300000) return this._prices; try { const r = await fetch('/api/billing/prices'); if (!r.ok) return null; this._prices = await r.json(); this._pricesAt = Date.now(); return this._prices; } catch (e) { return null; } } async _loadDemo() { try { const r = await fetch('/api/billing/demo-usage'); if (!r.ok) return null; return await r.json(); } catch (e) { return null; } } async _loadBalance() { // Only answers when a session exists; newOS4 currently has none, so this is expected to 401. try { const r = await fetch('/api/billing/subscription', { credentials: 'include' }); if (!r.ok) return null; const d = await r.json(); return d && d.balance_usd != null ? d : null; } catch (e) { return null; } } // --- session spend, measured where the tokens actually are ---------------- _hookUsage() { const om = this.terminal && this.terminal.ollamaManager; if (!om || om.__billingHooked) return; const orig = om._logUsage ? om._logUsage.bind(om) : null; if (!orig) return; om._billingSession = { msgs: 0, prompt: 0, completion: 0, cost: 0, byModel: {} }; om._logUsage = (provider, model, latency, promptTokens, completionTokens) => { try { this._accumulate(model, promptTokens, completionTokens); } catch (e) { /* never break a reply */ } return orig(provider, model, latency, promptTokens, completionTokens); }; om.__billingHooked = true; } _rate(model) { const cat = (this._prices && this._prices.models) || []; const bare = String(model || '').replace(/:cloud$|-cloud$/, ''); return cat.find(m => m.model === model) || cat.find(m => m.model === bare) || null; } _accumulate(rawModel, promptTokens, completionTokens) { const om = this.terminal.ollamaManager; const s = om._billingSession; if (!s) return; const model = rawModel || 'unknown'; const pt = Number(promptTokens) || 0; const ct = Number(completionTokens) || 0; if (!pt && !ct) return; // metadata calls carry no tokens const row = this._rate(model); const r = row ? row.retail_per_million : null; const cost = r ? (pt * r.input + ct * r.output) / 1e6 : 0; s.msgs += 1; s.prompt += pt; s.completion += ct; s.cost += cost; const m = s.byModel[model] || (s.byModel[model] = { msgs: 0, tokens: 0, cost: 0 }); m.msgs += 1; m.tokens += pt + ct; m.cost += cost; if (this._costLineOn() && ct > 0 && cost > 0) { this.terminal.print( `๐ต $${cost.toFixed(4)} ยท ${model} ยท ${(pt / 1000).toFixed(1)}k in / ${(ct / 1000).toFixed(1)}k out (retail est.)`, 'info'); } } _costLineOn() { try { return localStorage.getItem('webx11_cost_line') === '1'; } catch (e) { return false; } } // --- the command ---------------------------------------------------------- async run(args) { const p = this.terminal.print.bind(this.terminal); if (args && args[0] === 'on') { try { localStorage.setItem('webx11_cost_line', '1'); } catch (e) {} p('๐ต cost line on โ each reply will show its retail estimate. `cost off` to stop.', 'success'); return; } if (args && args[0] === 'off') { try { localStorage.setItem('webx11_cost_line', '0'); } catch (e) {} p('๐ต cost line off.', 'info'); return; } const [prices, demo, bal] = await Promise.all([this._loadPrices(), this._loadDemo(), this._loadBalance()]); const s = (this.terminal.ollamaManager && this.terminal.ollamaManager._billingSession) || null; if (demo) { const cap = demo.exempt ? 'unlimited (operator exemption)' : `${demo.used} / ${demo.limit} messages today โ ${demo.remaining} left, resets ${String(demo.resets_at).slice(0, 10)}`; p(`๐ Anonymous allowance: ${cap}`, 'info'); // distinct from the monthly money allowance below } else { p('๐ Free allowance: unavailable (billing API did not answer)', 'warning'); } p(`๐ฐ Balance: ${bal && bal.balance_usd != null ? '$' + Number(bal.balance_usd).toFixed(2) : 'not signed in โ newOS4 sends no session token, so a balance cannot be shown here yet'}`, 'info'); if (bal && bal.plan_allowance) { const pa = bal.plan_allowance; p(`๐ Included usage (${pa.plan}): $${Number(pa.used_usd).toFixed(2)} of $${Number(pa.limit_usd).toFixed(2)} ${pa.period === 'day' ? 'today' : 'this month'} โ $${Number(pa.remaining_usd).toFixed(2)} left, then your balance pays`, 'info'); } if (s && s.msgs) { p(`๐งฎ This session: ${s.msgs} call(s), ${(s.prompt / 1000).toFixed(1)}k in / ${(s.completion / 1000).toFixed(1)}k out โ $${s.cost.toFixed(4)} retail`, 'info'); Object.entries(s.byModel) .sort((a, b) => b[1].cost - a[1].cost) .slice(0, 5) .forEach(([m, v]) => p(` ยท ${m}: ${v.msgs} call(s), ${(v.tokens / 1000).toFixed(1)}k tok, $${v.cost.toFixed(4)}`, '')); } else { p('๐งฎ This session: nothing yet', ''); } if (prices) { const rows = prices.models .filter(m => m.retail_per_million.input > 0) .sort((a, b) => a.retail_per_million.output - b.retail_per_million.output); const body = rows.map(m => { const r = m.retail_per_million; return `
| model | input | cached | output |
|---|