From d9447218af19c22b388ae073c927995b855927a6 Mon Sep 17 00:00:00 2001 From: Fsocietyhhh <1211904451@qq.com> Date: Thu, 30 Apr 2026 03:00:54 -0700 Subject: [PATCH] fix(stats): savings calc no longer goes negative when user spends on media MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "Saved vs Opus" hero on the panel could display a negative dollar amount as soon as a user spent meaningfully on ImageGen or VideoGen (e.g. \"$-8.79 — You spent $20.4896 instead of $11.70\"). ## Root cause `getStatsSummary()` in src/stats/tracker.ts compared two values that live in different accounting universes: - `opusCost` = (totalInputTokens + totalOutputTokens) * Opus token rate → only counts chat tokens - `stats.totalCostUsd` = every recorded costUsd (chat + image + video + music) `recordUsage` for media generation logs costUsd > 0 with both inputTokens and outputTokens at 0 (per_image / per_second / per_track billing has no token concept). So: ``` saved = opusCost - totalCostUsd = (chat-tokens-at-Opus-rates) - (chat + media) = (Opus-vs-chosen-chat-delta) - media_cost ``` Once `media_cost` exceeded the chat delta, `saved` flipped negative even though the user genuinely saved money on every chat call — the math just doesn't have room for media. ## Fix Walk `byModel` once and split spend into: - **chatOnlyCost** — rows that ever accumulated tokens - **mediaCost** — rows that didn't Then build the comparison so media appears identically on both sides of "you spent X instead of Y", which makes the displayed totals match the user's real wallet activity: ``` opusCost (display baseline) = opusChatCost + mediaCost totalCostUsd (display actual) = chatOnlyCost + mediaCost (unchanged) saved = max(0, opusChatCost - chatOnlyCost) ``` The `Math.max(0, ...)` clamp handles the edge case where the user deliberately picked a more expensive chat model than Opus (e.g. Sonnet 4.6 with extended thinking on every request) — show zero saved, never negative. Also exposes `chatOnlyCost` and `mediaCost` on the returned summary so future panel improvements can show the breakdown explicitly. The panel hero is unchanged in shape — same "spent X instead of Y" line, just with consistent numbers behind it. ## Worked example Before: ``` totalInputTokens = 50k, totalOutputTokens = 30k chat spend = $2.00 (cheap mix) image spend = $11.30 totalCostUsd = $13.30, opusCost = $11.70 saved = $11.70 - $13.30 = -$1.60 ← bug ``` After: ``` chatOnlyCost = $2.00, mediaCost = $11.30 opusChatCost = $11.70 opusCost (display) = $11.70 + $11.30 = $23.00 totalCostUsd (display) = $13.30 (unchanged) saved = max(0, $11.70 - $2.00) = $9.70 ``` Hero now reads "You spent $13.30 instead of $23.00, saved $9.70" — every number reflects reality. ## Out of scope - The `Insights` panel (`src/stats/insights.ts`) already had a `Math.max(0, ...)` clamp on its own savings calc, so it was never visibly negative — but its math has the same chat/media conflation. Could be aligned in a follow-up. --- src/panel/html.ts | 11 +++++++++-- src/stats/tracker.ts | 38 +++++++++++++++++++++++++++++++++----- 2 files changed, 42 insertions(+), 7 deletions(-) diff --git a/src/panel/html.ts b/src/panel/html.ts index 85059d12..3a09831a 100644 --- a/src/panel/html.ts +++ b/src/panel/html.ts @@ -684,8 +684,15 @@ async function loadOverview() { document.getElementById('period-info').textContent = stats.period || ''; if (stats.opusCost > 0) { - const saved = stats.saved || (stats.opusCost - stats.totalCostUsd); - const pct = stats.savedPct || ((1 - stats.totalCostUsd / stats.opusCost) * 100); + // tracker.ts now returns saved already clamped to >= 0 and opusCost + // already inclusive of media (so comparing to totalCostUsd is + // apples-to-apples). Older summaries — or the rare path where saved + // is undefined — get the same Math.max clamp here so the panel + // never shows a negative dollar amount. + const saved = Math.max(0, stats.saved != null ? stats.saved : (stats.opusCost - stats.totalCostUsd)); + const pct = stats.savedPct != null + ? Math.max(0, stats.savedPct) + : (stats.opusCost > 0 ? Math.max(0, (saved / stats.opusCost) * 100) : 0); document.getElementById('savings-hero').style.display = 'flex'; document.getElementById('savings-amount').textContent = usdBig(saved); document.getElementById('savings-pct').textContent = pct.toFixed(0) + '%'; diff --git a/src/stats/tracker.ts b/src/stats/tracker.ts index 145e8687..ff48f155 100644 --- a/src/stats/tracker.ts +++ b/src/stats/tracker.ts @@ -257,6 +257,10 @@ export function recordUsage( export function getStatsSummary(): { stats: Stats; opusCost: number; + /** All chat / token-billed model spend (excludes image / video / music). */ + chatOnlyCost: number; + /** Per-image / per-second / per-track media generation spend. */ + mediaCost: number; saved: number; savedPct: number; avgCostPerRequest: number; @@ -264,12 +268,36 @@ export function getStatsSummary(): { } { const stats = loadStats(); - // Calculate what it would cost with the Opus-tier baseline - const opusCost = + // Hypothetical "if you'd used Opus for everything" baseline. Opus is a + // chat model — it can't replace ImageGen / VideoGen / Music (per_image, + // per_second, per_track billing), so for those rows the Opus-equivalent + // cost IS just the actual cost (no alternative). For chat rows, the + // baseline is the same tokens repriced at Opus rates. + // + // Walk byModel: rows with zero tokens are media (recordUsage stores + // image/video calls with inputTokens=0 outputTokens=0). Those count + // towards both sides equally; chat rows count at actual price on the + // "actual" side and at Opus rates on the "baseline" side. Keeping them + // on both sides means the displayed totals match the user's real + // spend rather than an unfamiliar chat-only subset. + let chatOnlyCost = 0; + let mediaCost = 0; + for (const m of Object.values(stats.byModel)) { + if ((m.inputTokens + m.outputTokens) > 0) chatOnlyCost += m.costUsd; + else mediaCost += m.costUsd; + } + const opusChatCost = (stats.totalInputTokens / 1_000_000) * OPUS_PRICING.input + (stats.totalOutputTokens / 1_000_000) * OPUS_PRICING.output; - - const saved = opusCost - stats.totalCostUsd; + // Display-side baseline: include media on both sides so "you spent X + // instead of Y" shows real, comparable totals. + const opusCost = opusChatCost + mediaCost; + + // Saved is the chat-side delta only — media nets to zero. Clamp to 0 + // so a session where the user paid more than Opus-equivalent for chat + // (e.g. Sonnet 4.6 with extended thinking enabled) doesn't show a + // negative "savings" number; we just say zero saved. + const saved = Math.max(0, opusChatCost - chatOnlyCost); const savedPct = opusCost > 0 ? (saved / opusCost) * 100 : 0; const avgCostPerRequest = stats.totalRequests > 0 ? stats.totalCostUsd / stats.totalRequests : 0; @@ -285,5 +313,5 @@ export function getStatsSummary(): { else period = `${days} days`; } - return { stats, opusCost, saved, savedPct, avgCostPerRequest, period }; + return { stats, opusCost, chatOnlyCost, mediaCost, saved, savedPct, avgCostPerRequest, period }; }