From e2bb6db02dbaa9fa1ac35ca464f8a8296bc17500 Mon Sep 17 00:00:00 2001 From: Fahad Iftikhar Date: Fri, 14 Aug 2026 09:59:51 +0500 Subject: [PATCH] =?UTF-8?q?fix(usage):=20count=20cached=20tokens=20in=20ev?= =?UTF-8?q?ery=20total=20=E2=80=94=20Codebase/Today/Yesterday=20were=20~99?= =?UTF-8?q?%=20short?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CLI's per-message tokens.input EXCLUDES cache reads; the sums used for the rows and charts (input + output + reasoning) dropped cache.read entirely. DeepSeek V4 sessions routinely carry ~700K cached prompt tokens per message, so the displayed totals massively undercounted. HistoryRow now carries a computed tokensTotal (input + output + reasoning + cache.read), verified against the DB's authoritative tokens.total, and every counting site (sumDailyUsage / buildUsageSeries / codebaseUsage) uses it. The extension's own tracked entries already included cached tokens in promptTokens, so the two sources are now consistent. Requests (1 per assistant message) and costs (CLI's own per-message field; billable = prompt - cached) were already correct. New regression test with a cache-heavy row (277 tests). --- CHANGELOG.md | 2 ++ src/goUsageTracker.ts | 41 +++++++++++++++++++--------- src/test/goUsageTracker.test.ts | 48 ++++++++++++++++++++++++++++++--- 3 files changed, 74 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a051bd..c348363 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,8 @@ All notable changes to the **OpenCode Go BYOK Provider** extension are documente ### Fixed +- **`[Usage]` Token counting now includes cached tokens — Codebase/Today/Yesterday totals were massively undercounted.** The CLI's per-message `tokens.input` EXCLUDES cache reads, and the sum used for the charts and rows (`input + output + reasoning`) dropped `cache.read` entirely. DeepSeek V4 sessions routinely carry ~700K cached prompt tokens per message, so the displayed totals were ~99% short. Every counting site (`sumDailyUsage`, `buildUsageSeries`, `codebaseUsage`) now uses the full total `input + output + reasoning + cache.read`, verified to match the DB's authoritative `tokens.total`. Requests and costs were already correct (1 per assistant message; cost from the CLI's own per-message field, extension-side billing via billable = prompt − cached). + - **`[Usage]` SQLite reads no longer depend on the `sqlite3` binary.** The zero-usage mystery was the Android SDK's `sqlite3` (`~/Android/Sdk/platform-tools/sqlite3`) being on the PATH only when VS Code launches from a shell that exports it — desktop-launched windows silently lost all CLI history (Today/Yesterday/Codebase = 0 while the fetched quota kept working). The CLI history is now read through Node's built-in `node:sqlite` first (zero external dependencies, retried twice on busy WAL states), falling back to the `sqlite3` binary resolved from PATH **plus** known locations (system, Homebrew, Android SDK). Failures are logged with the exact error to the "OpenCode Go Usage" output channel. - **`[Usage]` Panel polish + chat-completion charts.** Hovering anywhere on a chart (not just on points) highlights the nearest day with a guide line + dots and a cursor-follow tooltip; on the Models tab the tooltip lists every model's spend for that day. Two new tabs — **Suggested** and **Approved** — chart inline chat completions with whole-number axes and honest tooltips (the day's suggestion/approval counts, not chat token totals); both series share the exact same day buckets, so hovers never resolve to undefined. Acceptances are detected with a bounded heuristic: VS Code's stable API exposes no inline-completion acceptance event, so committing a ghost text is recognized by the document insert starting exactly at the suggested position with a matching multi-character text (30s window, cleared on first match — see `matchesAcceptance`). The default chart window is **Lifetime**, switchable live via the **Window** button (Week → 14 days → Month → Lifetime). The panel brand shows only the profile name, the legend swatches are square and text-aligned, and axes keep round equal tick steps. diff --git a/src/goUsageTracker.ts b/src/goUsageTracker.ts index d66df4c..b342b59 100644 --- a/src/goUsageTracker.ts +++ b/src/goUsageTracker.ts @@ -330,6 +330,14 @@ export interface HistoryRow { cwd?: string; /** Model that produced the message (OpenCode CLI data). */ modelId?: string; + /** + * Total tokens for the message: input + output + reasoning + cache.read. + * The CLI's `tokens.input` EXCLUDES cached tokens — the authoritative + * `tokens.total` matches input + output + reasoning + cache.read — so this + * sum is what any "tokens used" display must count (parity with the + * extension's own promptTokens, which include cached tokens). + */ + tokensTotal: number; } /** Non-negative finite integer (tokens can legitimately be 0). */ @@ -358,7 +366,7 @@ export function sumDailyUsage( if (row.createdMs < dayStartMs) continue; cost += row.cost; requests += 1; - tokens += row.tokensInput + row.tokensOutput + row.tokensReasoning; + tokens += row.tokensTotal; } } @@ -468,7 +476,7 @@ export function buildUsageSeries( if (source !== "extension") { for (const row of rows) { - add(row.modelId, row.createdMs, row.cost, row.tokensInput + row.tokensOutput + row.tokensReasoning); + add(row.modelId, row.createdMs, row.cost, row.tokensTotal); } } if (source !== "cli") { @@ -539,16 +547,23 @@ function normalizeHistoryRows(rows: unknown): HistoryRow[] { typeof candidate.createdMs === "number" && candidate.createdMs > 0 && typeof candidate.cost === "number" && candidate.cost >= 0 ); }) - .map((row) => ({ - createdMs: row.createdMs, - cost: row.cost, - tokensInput: positiveNumberish(row.tokensInput), - tokensOutput: positiveNumberish(row.tokensOutput), - tokensReasoning: positiveNumberish(row.tokensReasoning), - tokensCacheRead: positiveNumberish(row.tokensCacheRead), - cwd: typeof row.cwd === "string" && row.cwd.trim() ? row.cwd : undefined, - modelId: typeof row.modelId === "string" && row.modelId.trim() ? row.modelId : undefined, - })); + .map((row) => { + const tokensInput = positiveNumberish(row.tokensInput); + const tokensOutput = positiveNumberish(row.tokensOutput); + const tokensReasoning = positiveNumberish(row.tokensReasoning); + const tokensCacheRead = positiveNumberish(row.tokensCacheRead); + return { + createdMs: row.createdMs, + cost: row.cost, + tokensInput, + tokensOutput, + tokensReasoning, + tokensCacheRead, + tokensTotal: tokensInput + tokensOutput + tokensReasoning + tokensCacheRead, + cwd: typeof row.cwd === "string" && row.cwd.trim() ? row.cwd : undefined, + modelId: typeof row.modelId === "string" && row.modelId.trim() ? row.modelId : undefined, + }; + }); } /** @@ -892,7 +907,7 @@ export class GoUsageTracker { if (!isCwdInWorkspace(row.cwd, folders)) continue; cost += row.cost; requests += 1; - tokens += row.tokensInput + row.tokensOutput + row.tokensReasoning; + tokens += row.tokensTotal; } return { cost, requests, tokens }; } diff --git a/src/test/goUsageTracker.test.ts b/src/test/goUsageTracker.test.ts index 287d606..84a07b9 100644 --- a/src/test/goUsageTracker.test.ts +++ b/src/test/goUsageTracker.test.ts @@ -636,8 +636,26 @@ describe("sumDailyUsage", () => { const now = new Date(); const dayMs = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()); const rows: HistoryRow[] = [ - { createdMs: dayMs + 1000, cost: 0.1, tokensInput: 100, tokensOutput: 50, tokensReasoning: 20, tokensCacheRead: 10, cwd: "/repo" }, - { createdMs: dayMs - 60_000, cost: 0.2, tokensInput: 200, tokensOutput: 100, tokensReasoning: 0, tokensCacheRead: 0, cwd: "/repo" }, + { + createdMs: dayMs + 1000, + cost: 0.1, + tokensInput: 100, + tokensOutput: 50, + tokensReasoning: 20, + tokensCacheRead: 10, + cwd: "/repo", + tokensTotal: 180, + }, + { + createdMs: dayMs - 60_000, + cost: 0.2, + tokensInput: 200, + tokensOutput: 100, + tokensReasoning: 0, + tokensCacheRead: 0, + cwd: "/repo", + tokensTotal: 300, + }, ]; const entries: UsageLogEntry[] = [ { @@ -654,14 +672,14 @@ describe("sumDailyUsage", () => { it("merges CLI rows and extension entries in auto mode", () => { const total = sumDailyUsage(rows, entries, dayMs, "auto"); assert.equal(total.requests, 2); - assert.equal(total.tokens, 210); + assert.equal(total.tokens, 220, "row total includes cache.read (180) plus the entry (40)"); assert.ok(Math.abs(total.cost - 0.15) < 1e-9, `expected ~0.15, got ${String(total.cost)}`); }); it("excludes rows before the day window", () => { const total = sumDailyUsage(rows, [], dayMs, "cli"); assert.equal(total.requests, 1, "only the row inside the window counts"); - assert.equal(total.tokens, 170, "input + output + reasoning"); + assert.equal(total.tokens, 180, "input + output + reasoning + cache.read"); }); it("cli source ignores extension entries", () => { @@ -747,6 +765,7 @@ describe("buildUsageSeries", () => { tokensOutput: 50, tokensReasoning: 0, tokensCacheRead: 0, + tokensTotal: 150, cwd: "/repo", modelId: "qwen3.6-plus", }, @@ -757,6 +776,7 @@ describe("buildUsageSeries", () => { tokensOutput: 100, tokensReasoning: 0, tokensCacheRead: 0, + tokensTotal: 300, cwd: "/repo", modelId: "deepseek-v4-flash", }, @@ -767,6 +787,7 @@ describe("buildUsageSeries", () => { tokensOutput: 150, tokensReasoning: 0, tokensCacheRead: 0, + tokensTotal: 450, cwd: "/repo", modelId: "qwen3.6-plus", }, @@ -777,6 +798,7 @@ describe("buildUsageSeries", () => { tokensOutput: 200, tokensReasoning: 0, tokensCacheRead: 0, + tokensTotal: 600, cwd: "/repo", modelId: "qwen3.6-plus", }, @@ -825,6 +847,24 @@ describe("buildUsageSeries", () => { assert.ok(!series.byModel.some((p) => p.model === "glm-5")); }); + it("counts cached tokens in daily totals (tokens.input excludes cache)", () => { + const cached: HistoryRow[] = [ + { + createdMs: dayMs, + cost: 0.1, + tokensInput: 152, + tokensOutput: 209, + tokensReasoning: 0, + tokensCacheRead: 699_392, + tokensTotal: 699_753, + cwd: "/repo", + modelId: "deepseek-v4-flash", + }, + ]; + const series = buildUsageSeries(cached, [], 1, dayMs, "cli"); + assert.equal(series.days[0].tokens, 699_753, "cache.read must be part of the token total"); + }); + it("lifetime windows (days=0) span from the earliest usage day", () => { const series = buildUsageSeries(rows, entries, 0, dayMs, "auto"); // earliest row = dayMs - DAY → 2 buckets: yesterday + today