From 09d8397f14cf7344abeef38671cb809441b1c339 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 3 Oct 2026 23:50:58 +0000 Subject: [PATCH 1/7] feat(usage): separate recorded thinking tokens Rebased onto main. This commit also carries the changes from "fix(usage): retain history from coexisting legacy caches", "fix(usage): reconcile cumulative Claude token updates", "fix(usage): preserve reported cost across thinking updates", "fix(usage): preserve recovered cache counts and final costs" and "test(usage): use current service layer for Claude reconciliation". Main has since rewritten the code they touched, so they no longer apply on their own. --- .../features/usage/UsageDailyChart.ios.tsx | 5 +- .../src/features/usage/UsageDailyChart.tsx | 9 +- .../src/features/usage/UsageRouteScreen.tsx | 18 +- .../src/features/usage/usageChartData.ts | 17 +- .../src/features/usage/usageProviders.ts | 18 ++ .../src/provider/Drivers/claudeUsage.test.ts | 21 ++ .../src/provider/Drivers/claudeUsage.ts | 18 +- apps/server/src/usage/UsageService.test.ts | 187 +++++++++++++++++- apps/server/src/usage/UsageService.ts | 44 ++--- apps/server/src/usage/usageScanCache.test.ts | 34 +++- apps/server/src/usage/usageScanCache.ts | 39 +++- apps/web/src/components/usage/UsagePage.tsx | 16 +- .../usage/UsageProviderChart.test.ts | 31 ++- .../components/usage/UsageProviderChart.tsx | 112 +++++++---- .../components/usage/usageBreakdown.test.ts | 23 ++- .../src/components/usage/usageBreakdown.ts | 12 +- .../src/components/usage/usageProviders.ts | 6 + docs/user/usage.md | 7 +- packages/shared/src/usageMerge.test.ts | 27 +++ packages/shared/src/usageMerge.ts | 50 ++++- 20 files changed, 560 insertions(+), 134 deletions(-) diff --git a/apps/mobile/src/features/usage/UsageDailyChart.ios.tsx b/apps/mobile/src/features/usage/UsageDailyChart.ios.tsx index 9f0ebc61df91a..6cec26c38fd6e 100644 --- a/apps/mobile/src/features/usage/UsageDailyChart.ios.tsx +++ b/apps/mobile/src/features/usage/UsageDailyChart.ios.tsx @@ -4,7 +4,7 @@ import { frame } from "@expo/ui/swift-ui/modifiers"; import type { DailyTotals } from "@t3tools/shared/usageMerge"; import { buildChartDays, type UsageChartMetric } from "./usageChartData"; -import { useProviderColors } from "./usageProviders"; +import { useProviderColors, useProviderThinkingColors } from "./usageProviders"; export interface UsageDailyChartProps { readonly days: readonly string[]; @@ -15,12 +15,13 @@ export interface UsageDailyChartProps { export function UsageDailyChart({ days, daily, metric, height }: UsageDailyChartProps) { const colors = useProviderColors(); + const thinkingColors = useProviderThinkingColors(); const data: ChartDataPoint[] = buildChartDays(days, daily, metric).flatMap((day) => day.values.map((entry) => ({ x: day.day, y: entry.value, - color: colors[entry.provider], + color: entry.thinking ? thinkingColors[entry.provider] : colors[entry.provider], })), ); diff --git a/apps/mobile/src/features/usage/UsageDailyChart.tsx b/apps/mobile/src/features/usage/UsageDailyChart.tsx index 5d64cf9dbc7e7..8fdb4e34e4a8f 100644 --- a/apps/mobile/src/features/usage/UsageDailyChart.tsx +++ b/apps/mobile/src/features/usage/UsageDailyChart.tsx @@ -3,7 +3,7 @@ import { View } from "react-native"; import type { DailyTotals } from "@t3tools/shared/usageMerge"; import { buildChartDays, type UsageChartMetric } from "./usageChartData"; -import { useProviderColors } from "./usageProviders"; +import { useProviderColors, useProviderThinkingColors } from "./usageProviders"; export interface UsageDailyChartProps { readonly days: readonly string[]; @@ -14,6 +14,7 @@ export interface UsageDailyChartProps { export function UsageDailyChart({ days, daily, metric, height }: UsageDailyChartProps) { const colors = useProviderColors(); + const thinkingColors = useProviderThinkingColors(); const chartDays = buildChartDays(days, daily, metric); const max = chartDays.reduce((peak, day) => Math.max(peak, day.total), 0); @@ -28,10 +29,12 @@ export function UsageDailyChart({ days, daily, metric, height }: UsageDailyChart > {day.values.map((entry) => ( ))} diff --git a/apps/mobile/src/features/usage/UsageRouteScreen.tsx b/apps/mobile/src/features/usage/UsageRouteScreen.tsx index 032ab7e23fb02..4c8680722c461 100644 --- a/apps/mobile/src/features/usage/UsageRouteScreen.tsx +++ b/apps/mobile/src/features/usage/UsageRouteScreen.tsx @@ -606,6 +606,11 @@ function ChartCard(props: { ))} + {metric === "tokens" ? ( + + Lighter segments show recorded thinking tokens. Unreported thinking stays in output. + + ) : null} ); } @@ -728,6 +733,9 @@ function ProviderSection(props: { {metric === "cost" ? `${formatPercent(share)} of cost · ${formatTokens(provider.totalTokens)} tokens` : `${formatPercent(share)} of tokens · ${formatUsd(provider.costUsd)}`} + {metric === "tokens" && (provider.reasoningTokens ?? 0) > 0 + ? ` · ${formatTokens(provider.reasoningTokens ?? 0)} thinking` + : null} ); @@ -783,8 +791,14 @@ function TotalsSection(props: { + [totals.day, totals])); return days.map((day) => { const totals = byDay.get(day); - const values = PROVIDER_ORDER.map((provider) => { + const values = PROVIDER_ORDER.flatMap((provider) => { const entry = totals?.byProvider.get(provider); const value = entry === undefined ? 0 : metric === "cost" ? entry.costUsd : entry.totalTokens; - return { provider, value }; + const thinking = metric === "tokens" ? (entry?.reasoningTokens ?? 0) : 0; + return thinking > 0 + ? [ + { provider, thinking: false, value: value - thinking }, + { provider, thinking: true, value: thinking }, + ] + : [{ provider, thinking: false, value }]; }); return { day, diff --git a/apps/mobile/src/features/usage/usageProviders.ts b/apps/mobile/src/features/usage/usageProviders.ts index ce9c778a77762..bc61ac9de35b5 100644 --- a/apps/mobile/src/features/usage/usageProviders.ts +++ b/apps/mobile/src/features/usage/usageProviders.ts @@ -1,4 +1,6 @@ import type { UsageProviderKind } from "@t3tools/contracts"; +import { flattenThemeColor, themeColorWithAlpha } from "../../lib/mobileTheme"; +import { useUniwindTheme } from "../../lib/useUniwindTheme"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; export const PROVIDER_ORDER: readonly UsageProviderKind[] = [ @@ -42,6 +44,22 @@ export function useProviderColors(): Record { }; } +/** Flatten the lighter thinking bands against the chart card for native charts. */ +export function useProviderThinkingColors(): Record { + const colors = useProviderColors(); + const surface = useUniwindTheme()["--color-grouped-card"]; + const thinking = { ...colors }; + for (const provider of PROVIDER_ORDER) { + thinking[provider] = flattenThemeColor(themeColorWithAlpha(colors[provider], 0.45), surface); + } + return thinking; +} + +/** + * Neutral steps for cost and token mixes, so they never borrow a provider's + * color. Matches the web steps: oklab mixes of the codex ink into the + * background, above the 15 ΔE separation floor for adjacent segments. + */ export function useUsageMixColors() { const { themeAppearance: scheme } = useAppearancePreferences(); const dark = scheme === "dark"; diff --git a/apps/server/src/provider/Drivers/claudeUsage.test.ts b/apps/server/src/provider/Drivers/claudeUsage.test.ts index 6f457f4f4cf2f..dacd7a223168a 100644 --- a/apps/server/src/provider/Drivers/claudeUsage.test.ts +++ b/apps/server/src/provider/Drivers/claudeUsage.test.ts @@ -7,6 +7,7 @@ function claudeLine(overrides: { contentType: string; model?: string; outputTokens?: number; + thinkingTokens?: number; speed?: string; }): string { return JSON.stringify({ @@ -24,6 +25,9 @@ function claudeLine(overrides: { cache_creation_input_tokens: 66818, cache_read_input_tokens: 1000, output_tokens: overrides.outputTokens ?? 286, + ...(overrides.thinkingTokens === undefined + ? {} + : { output_tokens_details: { thinking_tokens: overrides.thinkingTokens } }), ...(overrides.speed === undefined ? {} : { speed: overrides.speed }), }, }, @@ -48,6 +52,23 @@ describe("parseClaudeLine", () => { expect(record?.speed).toBe("standard"); }); + const withThinking = claudeLine({ + messageId: "msg_1", + contentType: "text", + thinkingTokens: 100, + }); + const [thinking] = parseClaudeLine(withThinking); + expect(thinking?.totals).toEqual({ ...record?.totals, reasoningTokens: 100 }); + expect(claudeUsageFormat.parseProjected(JSON.parse(withThinking), undefined)).toEqual([ + thinking, + ]); + expect( + parseClaudeLine( + claudeLine({ messageId: "msg_1", contentType: "text", thinkingTokens: 1000 }), + )[0]?.totals.reasoningTokens, + ).toBe(286); + }); + it("gives every content block of one message the same dedupe key", () => { // Content-block records repeat full parent usage; summing them overcounts. const [text] = parseClaudeLine(claudeLine({ messageId: "msg_2", contentType: "text" })); diff --git a/apps/server/src/provider/Drivers/claudeUsage.ts b/apps/server/src/provider/Drivers/claudeUsage.ts index aca7696d31df5..4681ba4981d7c 100644 --- a/apps/server/src/provider/Drivers/claudeUsage.ts +++ b/apps/server/src/provider/Drivers/claudeUsage.ts @@ -15,11 +15,7 @@ import * as HostProcess from "@t3tools/shared/HostProcess"; // Claude Code uses this model for local placeholders that never reached the API. export const CLAUDE_SYNTHETIC_MODEL = ""; -/** - * T3 Code writes one record per assistant *content block*, and every one of those records repeats - * the same complete `usage` object for the parent message. Summing them overcounts by roughly - * 2.4x on a real workload, so the caller must drop repeats by `dedupeKey` and keep the first. - */ +/** Claude content blocks repeat cumulative message usage; callers keep the fullest snapshot. */ export function parseClaudeLine(line: string): readonly UsageRecord[] { let parsed: unknown; try { @@ -56,6 +52,12 @@ function parseClaudeRecord(parsed: unknown): readonly UsageRecord[] { messageId === null && requestId === null ? null : `${messageId ?? ""}:${requestId ?? ""}`; const cost = record["costUSD"]; + const outputTokens = tokenCount(usageRecord["output_tokens"]); + const outputDetails = usageRecord["output_tokens_details"]; + const thinkingTokens = + typeof outputDetails === "object" && outputDetails !== null + ? tokenCount((outputDetails as Record)["thinking_tokens"]) + : 0; const executor: UsageRecord = { provider: "claude", @@ -66,9 +68,9 @@ function parseClaudeRecord(parsed: unknown): readonly UsageRecord[] { uncachedInputTokens: tokenCount(usageRecord["input_tokens"]), cachedInputTokens: tokenCount(usageRecord["cache_read_input_tokens"]), cacheCreationTokens: tokenCount(usageRecord["cache_creation_input_tokens"]), - outputTokens: tokenCount(usageRecord["output_tokens"]), - // Anthropic folds thinking tokens into output and does not break them out. - reasoningTokens: 0, + outputTokens, + // Thinking is already included in output. + reasoningTokens: Math.min(outputTokens, thinkingTokens), }, reportedCostUsd: typeof cost === "number" && Number.isFinite(cost) ? cost : null, speed: usageRecord["speed"] === "fast" ? "fast" : "standard", diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts index 31d7fbbda0a13..9e945cd9a3574 100644 --- a/apps/server/src/usage/UsageService.test.ts +++ b/apps/server/src/usage/UsageService.test.ts @@ -7,7 +7,7 @@ import * as NodePath from "node:path"; import * as NodeSqlite from "node:sqlite"; import * as NodeZlib from "node:zlib"; -import { assert, describe, it } from "@effect/vitest"; +import { assert, describe, expect, it } from "@effect/vitest"; import { vi } from "vite-plus/test"; import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; @@ -19,7 +19,6 @@ import { ProviderInstanceId, UsageDay, type UsageSource, - type UsageSummary, type UsageSummaryInput, } from "@t3tools/contracts"; import * as Duration from "effect/Duration"; @@ -80,6 +79,7 @@ function claudeLine( outputTokens: number, model = "claude-fable-5", sessionId = "session-1", + thinkingTokens?: number, ): string { return `${JSON.stringify({ type: "assistant", @@ -2614,12 +2614,82 @@ describe("UsageService", () => { }).pipe(Effect.scoped), ); + it.live("reconciles a growing transcript without committing its replaceable tail", () => + Effect.gen(function* () { + const { transcript, settings, home } = yield* setup; + const committed = claudeLine(1, 100); + const tail = claudeLine(1, 160, "claude-fable-5", "session-1", 40).trimEnd(); + yield* Effect.promise(() => NodeFSP.writeFile(transcript, tail)); + + const check = (summary: UsageSummary, output: number, thinking: number, records: number) => { + expect.soft(totalOutputTokens(summary)).toBe(output); + expect + .soft(summary.buckets.reduce((sum, bucket) => sum + bucket.totals.reasoningTokens, 0)) + .toBe(thinking); + expect.soft(summary.buckets.reduce((sum, bucket) => sum + bucket.records, 0)).toBe(records); + expect + .soft(summary.buckets.reduce((sum, bucket) => sum + bucket.costUsd, 0)) + .toBeCloseTo(records * 10 * 1e-5 + output * 5e-5, 12); + }; + yield* Effect.gen(function* () { + const { stateDir } = yield* ServerConfig.ServerConfig; + const cachePath = NodePath.join(stateDir, SCAN_CACHE_FILE_NAME); + const legacyPath = NodePath.join(stateDir, "usage-scan-cache-v5.json"); + yield* Effect.gen(function* () { + const service = yield* UsageService.make; + check(yield* service.readSummary(WINDOW), 160, 40, 1); + check(yield* service.readSummary(WINDOW), 160, 40, 1); + }).pipe(Effect.scoped); + + yield* Effect.gen(function* () { + const service = yield* UsageService.make; + check(yield* service.readSummary(WINDOW), 160, 40, 1); + + const service = yield* UsageService.make.pipe( + Effect.provide(layerService({ prefix: "usage-service-grow-test", home, settings })), + ); + + yield* Effect.promise(() => + NodeFSP.appendFile( + transcript, + "\n" + claudeLine(1, 180, "claude-fable-5", "session-1", 60).trimEnd(), + ), + ); + check(yield* service.readSummary(WINDOW), 380, 60, 2); + }).pipe(Effect.scoped); + + yield* Effect.gen(function* () { + const service = yield* UsageService.make; + check(yield* service.readSummary(WINDOW), 380, 60, 2); + yield* Effect.promise(() => + NodeFSP.appendFile( + transcript, + "\n" + claudeLine(1, 180, "claude-fable-5", "session-1", 80), + ), + ); + check(yield* service.readSummary(WINDOW), 380, 80, 2); + }).pipe(Effect.scoped); + }).pipe( + Effect.provide( + layerService({ + prefix: "usage-service-grow-test", + home, + settings, + ratesDocument: { + "claude-fable-5": { input_cost_per_token: 1e-5, output_cost_per_token: 5e-5 }, + }, + }), + ), + ); + }).pipe(Effect.scoped), + ); + it.live( "keeps large-record totals and costs exact through append, dedupe, restart and cleanup", () => Effect.gen(function* () { const { transcript, settings, home } = yield* setup; - const large = claudeLine(1, 9900).replace( + const large = claudeLine(1, 9900, "claude-fable-5", "session-1", 3900).replace( '"message":', '"padding":' + encodeUnknownJsonString("x".repeat(9 * 1024 * 1024)) + ',"message":', ); @@ -2635,7 +2705,14 @@ describe("UsageService", () => { ); const warm = yield* service.readSummary(WINDOW); assert.deepStrictEqual(warm.buckets, first.buckets); - yield* Effect.promise(() => NodeFSP.appendFile(transcript, large + claudeLine(2, 100))); + yield* Effect.promise(() => + NodeFSP.appendFile( + transcript, + large + + claudeLine(1, 10000, "claude-fable-5", "session-1", 4000) + + claudeLine(2, 100, "claude-fable-5", "session-1", 30), + ), + ); const appended = yield* service.readSummary(WINDOW); assert.strictEqual(totalOutputTokens(appended), 10000); assert.strictEqual( @@ -2768,7 +2845,7 @@ describe("UsageService", () => { 60 + 20 * (version === 4 ? 1 : 2), ); yield* upgraded.awaitPersisted; - assert.strictEqual(SCAN_CACHE_FILE_NAME, "usage-scan-cache-v11.json"); + assert.strictEqual(SCAN_CACHE_FILE_NAME, "usage-scan-cache-v12.json"); assert.strictEqual( yield* Effect.promise(() => NodeFSP.readFile(legacyPath, "utf8")), legacy, @@ -2895,6 +2972,106 @@ describe("UsageService", () => { }).pipe(Effect.scoped), ); + it.live("upgrades live usage and retains deleted history from current, v5 and v4 caches", () => + Effect.gen(function* () { + const { home, settings, transcript } = yield* setup; + const sessions = NodePath.join(home, "codex", "sessions"); + const rollout = (sessionId: string, outputTokens: number) => + [ + { type: "session_meta", payload: { id: sessionId } }, + { type: "turn_context", payload: { model: "gpt-6-astra" } }, + { + type: "event_msg", + payload: { + type: "thread_settings_applied", + thread_settings: { service_tier: "ultrafast" }, + }, + { + type: "event_msg", + timestamp: "2026-08-01T10:00:00Z", + payload: { + type: "token_count", + info: { last_token_usage: { input_tokens: 0, output_tokens: outputTokens } }, + }, + }, + }, + ] + .map((line) => encodeUnknownJsonString(line)) + .join("\n") + "\n"; + const live = NodePath.join(sessions, "live.jsonl"); + const deleted = NodePath.join(sessions, "deleted.jsonl"); + const legacyOnly = NodePath.join(sessions, "legacy-only.jsonl"); + const newerOnly = NodePath.join(sessions, "newer-only.jsonl"); + yield* Effect.promise(async () => { + await NodeFSP.mkdir(sessions, { recursive: true }); + await NodeFSP.writeFile(live, rollout("live", 10)); + await NodeFSP.writeFile(deleted, rollout("deleted", 20)); + await NodeFSP.writeFile(legacyOnly, rollout("legacy-only", 30)); + await NodeFSP.writeFile(newerOnly, rollout("newer-only", 40)); + await NodeFSP.writeFile( + transcript, + claudeLine(1, 100) + claudeLine(1, 160, "claude-fable-5", "session-1", 40).trimEnd(), + ); + }); + + yield* Effect.gen(function* () { + const { stateDir } = yield* ServerConfig.ServerConfig; + const cachePath = NodePath.join(stateDir, SCAN_CACHE_FILE_NAME); + const legacyPath = NodePath.join(stateDir, "usage-scan-cache.json"); + const v5Path = NodePath.join(stateDir, "usage-scan-cache-v5.json"); + yield* Effect.gen(function* () { + const { stateDir } = yield* ServerConfig.ServerConfig; + const cachePath = NodePath.join(stateDir, "usage-scan-cache-v6.json"); + const legacyPath = NodePath.join(stateDir, "usage-scan-cache.json"); + const first = yield* UsageService.make; + yield* first.readSummary(WINDOW); + yield* first.awaitPersisted; + + const legacy = yield* Effect.promise(async () => { + const document = decodeUnknownJsonString(await NodeFSP.readFile(cachePath, "utf8")) as { + files: Record; + }; + for (const file of Object.values(document.files)) { + file.r = file.r.map((row) => [...row.slice(0, 10), 0]); + delete file.cs.speed; + } + const text = encodeUnknownJsonString({ ...document, version: 4 }); + await NodeFSP.writeFile(legacyPath, text); + await NodeFSP.rm(cachePath); + await NodeFSP.rm(deleted); + return text; + }); + + const summary = yield* (yield* UsageService.make).readSummary(WINDOW); + assert.strictEqual(totalOutputTokens(summary), 30); + assert.strictEqual( + summary.buckets.reduce((sum, bucket) => sum + bucket.costUsd, 0), + 80, + ); + assert.strictEqual( + yield* Effect.promise(() => NodeFSP.readFile(legacyPath, "utf8")), + legacy, + ); + }).pipe( + Effect.provide( + layerService({ + prefix: "usage-service-v4-upgrade-test", + home, + settings, + ratesDocument: { + "gpt-6-astra": { + input_cost_per_token: 0, + output_cost_per_token: 1, + input_cost_per_token_ultrafast: 0, + output_cost_per_token_ultrafast: 6, + }, + }, + }), + ), + ); + }).pipe(Effect.scoped), + ); + it.live("preserves saved tokens, costs and sessions after transcript cleanup and restart", () => Effect.gen(function* () { const { transcript, settings, home } = yield* setup; diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index 8876f0af131f1..734102496b8cd 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -172,6 +172,9 @@ export const make = Effect.gen(function* () { const ratesCachePath = path.join(config.stateDir, "usage-model-rates.json"); const scanCachePath = path.join(config.stateDir, SCAN_CACHE_FILE_NAME); + const legacyScanCachePaths = LEGACY_SCAN_CACHE_FILE_NAMES.map((fileName) => + path.join(config.stateDir, fileName), + ); const writeCacheFile = (filePath: string, contents: string) => writeFileStringAtomically({ filePath, contents }).pipe( Effect.provideService(FileSystem.FileSystem, fileSystem), @@ -386,9 +389,9 @@ export const make = Effect.gen(function* () { Effect.catchCause(() => Effect.succeed(null)), ); let document = yield* readDocument(scanCachePath); - for (const fileName of LEGACY_SCAN_CACHE_FILE_NAMES) { + for (const legacyScanCachePath of legacyScanCachePaths) { if (document !== null) break; - document = yield* readDocument(path.join(config.stateDir, fileName)); + document = yield* readDocument(legacyScanCachePath); cacheDirty = document !== null; } if (document === null) return; @@ -464,10 +467,7 @@ export const make = Effect.gen(function* () { cached.provider === provider ) { return { - records: - cached.tailRecords.length === 0 - ? cached.records - : [...cached.records, ...cached.tailRecords], + records: cachedFileRecords(cached), malformedRecords: cached.malformedRecords, }; } @@ -484,7 +484,7 @@ export const make = Effect.gen(function* () { // (size, mtime) would silently drop the file's usage until it changes. if (parsed === null) return { - records: cached?.provider === provider ? [...cached.records, ...cached.tailRecords] : [], + records: cached?.provider === provider ? cachedFileRecords(cached) : [], malformedRecords: cached?.provider === provider ? cached.malformedRecords : 0, failed: true, }; @@ -517,25 +517,19 @@ export const make = Effect.gen(function* () { } } const base = parsed.resumed && cached !== undefined ? cached.records : []; - const seen = new Set(); - const records = dedupeWithinFile([...base, ...parsed.records], seen); - const tailRecords = dedupeWithinFile(parsed.tailRecords, seen); - + const entry = { + size, + mtimeMs, + provider, + records: dedupeWithinFile([...base, ...parsed.records]), + tailRecords: dedupeWithinFile(parsed.tailRecords), + malformedRecords: parsed.malformedRecords, + position: parsed.position, + }; return { - records: tailRecords.length === 0 ? records : [...records, ...tailRecords], + records: cachedFileRecords(entry), malformedRecords: parsed.malformedRecords, - update: { - entry: { - size, - mtimeMs, - provider, - records, - tailRecords, - malformedRecords: parsed.malformedRecords, - position: parsed.position, - }, - replaces: cached, - }, + update: { entry, replaces: cached }, }; }); @@ -749,7 +743,7 @@ export const make = Effect.gen(function* () { continue; retainedFiles.push({ path: filePath, - records: [...entry.records, ...entry.tailRecords], + records: cachedFileRecords(entry), malformedRecords: entry.malformedRecords, }); } diff --git a/apps/server/src/usage/usageScanCache.test.ts b/apps/server/src/usage/usageScanCache.test.ts index fa659c4a443b3..9c82976d07e92 100644 --- a/apps/server/src/usage/usageScanCache.test.ts +++ b/apps/server/src/usage/usageScanCache.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "@effect/vitest"; import { decodeScanCache, + dedupeWithinFile, makeScanCacheWriter, type CachedFile, type ScanCache, @@ -218,15 +219,12 @@ describe("scan cache round trip", () => { }; const restored = decodeScanCache(JSON.parse(JSON.stringify(legacy)), TEST_FORMAT_MAP); - expect(restored.get("/gone.jsonl")).toEqual( - version >= 10 - ? original.get("/gone.jsonl") - : { - ...original.get("/gone.jsonl"), - size: -1, - position: { resumeOffset: 0, guardLength: 0, guardHash: 0, state: null }, - }, - ); + // Every listed version predates v12's fuller Claude snapshots. + expect(restored.get("/gone.jsonl")).toEqual({ + ...original.get("/gone.jsonl"), + size: -1, + position: { resumeOffset: 0, guardLength: 0, guardHash: 0, state: null }, + }); expect(restored.get("/gone-codex.jsonl")).toEqual({ ...original.get("/gone-codex.jsonl"), size: -1, @@ -325,3 +323,21 @@ describe("scan cache round trip", () => { expect(restored.has("/a.jsonl")).toBe(false); }); }); + +describe("dedupeWithinFile", () => { + it("keeps the first Claude record's attribution with its fullest usage snapshot", () => { + const kept = dedupeWithinFile([ + record({ totals: { ...record().totals, outputTokens: 1 } }), + record({ + timestampMs: 1_786_000_000_500, + totals: { ...record().totals, outputTokens: 999, reasoningTokens: 400 }, + }), + record({ totals: { ...record().totals, outputTokens: 3 } }), + record({ dedupeKey: "msg_2:" }), + ]); + + expect(kept).toHaveLength(2); + expect(kept[0]?.timestampMs).toBe(1_786_000_000_000); + expect(kept[0]?.totals).toMatchObject({ outputTokens: 999, reasoningTokens: 400 }); + }); +}); diff --git a/apps/server/src/usage/usageScanCache.ts b/apps/server/src/usage/usageScanCache.ts index c9032d64c05bd..cd699e4e4baac 100644 --- a/apps/server/src/usage/usageScanCache.ts +++ b/apps/server/src/usage/usageScanCache.ts @@ -9,16 +9,18 @@ import * as Schema from "effect/Schema"; import { CLAUDE_SYNTHETIC_MODEL } from "../provider/Drivers/claudeUsage.ts"; import { GUARD_LENGTH, type TranscriptParsePosition } from "./usageTranscriptReader.ts"; -const USAGE_SCAN_CACHE_VERSION = 11 as const; +const USAGE_SCAN_CACHE_VERSION = 12 as const; const SPEED_COMPATIBLE_SINCE_VERSION = 4; /** - * An older server sharing that directory cannot read a newer cache and would - * replace it, dropping saved usage for deleted transcripts. Separate files keep - * both. + * Each cache version writes its own file in the state directory. An older + * server sharing that directory cannot read a newer cache and would replace + * it, dropping saved usage for deleted transcripts. Separate files keep both. + * When its own file is missing, a server reads the newest older file once. */ -export const SCAN_CACHE_FILE_NAME = "usage-scan-cache-v11.json"; +export const SCAN_CACHE_FILE_NAME = "usage-scan-cache-v12.json"; export const LEGACY_SCAN_CACHE_FILE_NAMES = [ + "usage-scan-cache-v11.json", "usage-scan-cache-v10.json", "usage-scan-cache-v9.json", "usage-scan-cache-v8.json", @@ -293,9 +295,9 @@ export function decodeScanCache( } // Keep history for deleted rollouts, but re-parse incompatible reducer state. // v9 and v11 repair skipped Codex requests; clean v8+ entries stay served while unchanged. - // v10 adds Claude advisor calls, which no older Claude entry holds. + // v10 adds Claude advisor calls and v12 fuller Claude snapshots with thinking. const legacy = - (entry.p === "claude" && version < 10) || + (entry.p === "claude" && version < 12) || (format.state !== undefined && (version < 6 || (entry.p === "codex" && (version < 8 || (version < 11 && malformedRecords > 0))))); @@ -357,18 +359,39 @@ export function pruneScanCache(cache: ScanCache, retentionCutoffMs: number): num return removed; } -/** Mutates seen; share it across incremental line/tail batches to deduplicate the whole file. */ +/** + * Callers stitching an incremental parse together pass one `seen` set across the line and tail + * record batches so the whole file stays deduplicated as a unit; the set is mutated in place. + */ export function dedupeWithinFile( records: readonly UsageRecord[], seen: Set = new Set(), ): readonly UsageRecord[] { const kept: UsageRecord[] = []; + const indexes = new Map(); for (const record of records) { if (record.dedupeKey !== null) { + const index = indexes.get(record.dedupeKey); + if (index !== undefined) { + kept[index] = fullerClaudeSnapshot(kept[index]!, record); + continue; + } if (seen.has(record.dedupeKey)) continue; seen.add(record.dedupeKey); + indexes.set(record.dedupeKey, kept.length); } kept.push(record); } return kept; } + +/** + * Claude Code repeats a message's usage on every content block, and later + * blocks can carry fuller output and thinking counts. Keep the first block's + * attribution with the fullest snapshot; never sum the repeats. + */ +function fullerClaudeSnapshot(kept: UsageRecord, next: UsageRecord): UsageRecord { + return next.provider === "claude" && next.totals.outputTokens > kept.totals.outputTokens + ? { ...kept, totals: next.totals, reportedCostUsd: next.reportedCostUsd } + : kept; +} diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index 2e7a4bd014395..312a08cde5aec 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -838,7 +838,12 @@ function UsageOverview({

Totals

-
+
0 ? "md:grid-cols-6" : "md:grid-cols-5", + )} + > + {merged.reasoningTokens > 0 ? ( + + ) : null} { ], ]); + it("shows thinking within token totals without changing the cost series", () => { + const tokens = buildPeriodColumns(days, byDay, "tokens"); + expect(tokens.map((column) => column.total)).toEqual([420, 0, 50]); + expect(tokens[0]?.bands.find((band) => band.provider === "claude")).toEqual({ + provider: "claude", + value: 200, + thinking: 40, + }); + expect(tokens[0]?.bands.find((band) => band.provider === "codex")?.thinking).toBe(0); + expect(tokens[1]?.bands.every((band) => band.thinking === 0)).toBe(true); + const cost = buildPeriodColumns(days, byDay, "cost"); + expect(cost.map((column) => column.total)).toEqual([42, 0, 5]); + expect(cost[0]?.bands.every((band) => band.thinking === 0)).toBe(true); + }); + it("keeps band values absolute rather than cumulative", () => { const [first] = buildPeriodColumns(days, byDay, "cost"); expect(first?.bands).toEqual([ - { provider: "codex", value: 10 }, - { provider: "claude", value: 20 }, - { provider: "grok", value: 0 }, - { provider: "cursor", value: 0 }, - { provider: "opencode", value: 0 }, - { provider: "antigravity", value: 0 }, - { provider: "pi", value: 5 }, - { provider: "muse", value: 7 }, + { provider: "codex", value: 10, thinking: 0 }, + { provider: "claude", value: 20, thinking: 0 }, + { provider: "grok", value: 0, thinking: 0 }, + { provider: "cursor", value: 0, thinking: 0 }, + { provider: "opencode", value: 0, thinking: 0 }, + { provider: "antigravity", value: 0, thinking: 0 }, + { provider: "pi", value: 5, thinking: 0 }, + { provider: "muse", value: 7, thinking: 0 }, ]); }); }); diff --git a/apps/web/src/components/usage/UsageProviderChart.tsx b/apps/web/src/components/usage/UsageProviderChart.tsx index 1745be4df897b..4b9060b96549a 100644 --- a/apps/web/src/components/usage/UsageProviderChart.tsx +++ b/apps/web/src/components/usage/UsageProviderChart.tsx @@ -1,6 +1,6 @@ import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon"; import type { UsageProviderKind } from "@t3tools/contracts"; -import { useLayoutEffect, useRef, useState } from "react"; +import { Fragment, useLayoutEffect, useRef, useState } from "react"; import type { DailyTotals, HourlyTotals } from "@t3tools/shared/usageMerge"; import { @@ -11,7 +11,7 @@ import { formatUsd, } from "@t3tools/shared/usageFormat"; import { cn } from "~/lib/utils"; -import { PROVIDER_ORDER, PROVIDER_PRESENTATION } from "./usageProviders"; +import { PROVIDER_ORDER, PROVIDER_PRESENTATION, providerThinkingColor } from "./usageProviders"; import { observeResize } from "~/lib/observeResize"; const VIEW_WIDTH = 960; @@ -40,6 +40,7 @@ export interface DayColumn { readonly bands: readonly { readonly provider: UsageProviderKind; readonly value: number; + readonly thinking: number; }[]; readonly total: number; } @@ -69,6 +70,7 @@ export function buildPeriodColumns( const bands = PROVIDER_ORDER.map((provider) => ({ provider, value: valueFor(entry, provider, metric), + thinking: metric === "tokens" ? (entry?.byProvider.get(provider)?.reasoningTokens ?? 0) : 0, })); return { bands, total: bands.reduce((sum, band) => sum + band.value, 0) }; }); @@ -199,25 +201,39 @@ function buildChart( const columns = buildPeriodColumns(periods, byPeriod, metric); const scale = chartScale(columns, loadingProviders); const stepX = periods.length < 2 ? 0 : VIEW_WIDTH / (periods.length - 1); - const paths = providers.map((provider) => { + // Each provider's total, plus a dashed series for its recorded thinking. + const paths = providers.flatMap((provider) => { const slot = PROVIDER_ORDER.indexOf(provider); - const line = curvePath( - smoothCurve( - columns.map((column, periodIndex) => ({ - x: periodIndex * stepX, - y: valueToY(column.bands[slot]?.value ?? 0, scale.max), - })), - ), - ); - return { - provider, - loading: loadingProviders.has(provider), - total: columns.reduce((sum, column) => sum + (column.bands[slot]?.value ?? 0), 0), - line, - area: areaPath(line), - }; + return (["value", "thinking"] as const).flatMap((series) => { + const values = columns.map((column) => column.bands[slot]?.[series] ?? 0); + const total = values.reduce((sum, value) => sum + value, 0); + if (series === "thinking" && total === 0) return []; + const line = curvePath( + smoothCurve( + values.map((value, periodIndex) => ({ + x: periodIndex * stepX, + y: valueToY(value, scale.max), + })), + ), + ); + return [ + { + key: `${provider}:${series}`, + color: + series === "thinking" + ? providerThinkingColor(provider) + : PROVIDER_PRESENTATION[provider].color, + thinking: series === "thinking", + loading: loadingProviders.has(provider), + total, + line, + area: areaPath(line), + }, + ]; + }); }); + // Paint the heavier series first so the lighter one is not buried. return { columns, scale, stepX, paths: paths.toSorted((a, b) => b.total - a.total) }; } @@ -248,26 +264,35 @@ function UsageChartTooltip({ >
{label}
{providers.map((provider) => { - const { label: providerLabel, driverKind } = PROVIDER_PRESENTATION[provider]; + const { label, driverKind } = PROVIDER_PRESENTATION[provider]; + const band = column?.bands.find((band) => band.provider === provider); return ( -
- - - {providerLabel} - - - {format(column?.bands.find((band) => band.provider === provider)?.value ?? 0)} - -
+ +
+ + + {label} + + + {format(band?.value ?? 0)} + +
+ {(band?.thinking ?? 0) > 0 ? ( +
+ Thinking + {formatTokens(band?.thinking ?? 0)} +
+ ) : null} +
); })}
@@ -486,22 +511,23 @@ export function UsageProviderChart({ ); })} - {paths.map(({ provider, loading, area }) => ( + {paths.map(({ key, color, loading, area }) => ( ))} - {paths.map(({ provider, loading, line }) => ( + {paths.map(({ key, color, thinking, loading, line }) => ( diff --git a/apps/web/src/components/usage/usageBreakdown.test.ts b/apps/web/src/components/usage/usageBreakdown.test.ts index 14fe70017fcbc..b3b846ef9f7f8 100644 --- a/apps/web/src/components/usage/usageBreakdown.test.ts +++ b/apps/web/src/components/usage/usageBreakdown.test.ts @@ -1,7 +1,7 @@ import type { ModelTotals } from "@t3tools/shared/usageMerge"; import { describe, expect, it } from "vite-plus/test"; -import { cacheHitRate, costPerMillionTokens } from "./usageBreakdown"; +import { cacheHitRate, costPerMillionTokens, tokenTypeSegments } from "./usageBreakdown"; const model = ( name: string, @@ -28,6 +28,27 @@ const model = ( ...overrides, }); +describe("token types", () => { + it.each([0, 40, 100])( + "splits %i thinking tokens out of output without changing the total", + (reasoningTokens) => { + const segments = tokenTypeSegments({ + uncachedInputTokens: 200, + cachedInputTokens: 300, + cacheCreationTokens: 50, + outputTokens: 100, + reasoningTokens, + }); + + expect(segments.find((segment) => segment.label === "Output")?.value).toBe( + 100 - reasoningTokens, + ); + expect(segments.find((segment) => segment.label === "Thinking")?.value).toBe(reasoningTokens); + expect(segments.reduce((sum, segment) => sum + segment.value, 0)).toBe(650); + }, + ); +}); + describe("model rates", () => { it("counts cache writes as misses and leaves unpriced tokens out of $/1M", () => { const mixed = model("mixed", 4_000_000, 6, { diff --git a/apps/web/src/components/usage/usageBreakdown.ts b/apps/web/src/components/usage/usageBreakdown.ts index 7fecab508c223..b7c51ce4407ea 100644 --- a/apps/web/src/components/usage/usageBreakdown.ts +++ b/apps/web/src/components/usage/usageBreakdown.ts @@ -42,6 +42,7 @@ const TYPE_COLORS = { cacheRead: ink(30), cacheWrite: ink(72), output: ink(100), + thinking: ink(82), other: ink(44), }; @@ -55,14 +56,17 @@ export function costTypeSegments(cost: CategoryCost): readonly ShareSegment[] { ]; } -export function tokenTypeSegments( - tokens: Omit, -): readonly ShareSegment[] { +export function tokenTypeSegments(tokens: UsageTokenTotals): readonly ShareSegment[] { return [ { label: "Input", value: tokens.uncachedInputTokens, color: TYPE_COLORS.input }, { label: "Cache read", value: tokens.cachedInputTokens, color: TYPE_COLORS.cacheRead }, { label: "Cache write", value: tokens.cacheCreationTokens, color: TYPE_COLORS.cacheWrite }, - { label: "Output", value: tokens.outputTokens, color: TYPE_COLORS.output }, + { + label: "Output", + value: tokens.outputTokens - tokens.reasoningTokens, + color: TYPE_COLORS.output, + }, + { label: "Thinking", value: tokens.reasoningTokens, color: TYPE_COLORS.thinking }, ]; } diff --git a/apps/web/src/components/usage/usageProviders.ts b/apps/web/src/components/usage/usageProviders.ts index ba86171e87a4c..b598514ca90f8 100644 --- a/apps/web/src/components/usage/usageProviders.ts +++ b/apps/web/src/components/usage/usageProviders.ts @@ -49,6 +49,12 @@ export const PROVIDER_PRESENTATION = { }, } satisfies Record; +/** A softer shade that stays distinct even for the theme-aware neutral providers. */ +export function providerThinkingColor(provider: UsageProviderKind): string { + return `color-mix(in oklab, ${PROVIDER_PRESENTATION[provider].color} 55%, var(--background))`; +} + +/** Stable provider reading order across charts, summaries, tables, and hover rows. */ export const PROVIDER_ORDER = Object.keys(PROVIDER_PRESENTATION) as UsageProviderKind[]; export function providersWithUsage( diff --git a/docs/user/usage.md b/docs/user/usage.md index d25bdba81f00a..8207292fa393b 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -15,7 +15,12 @@ cost, split by token type and by speed. These estimates are not your subscriptio split, such as a provider-reported cost for a model without public rates, shows as **Other**. Select a model under **Breakdown** to see its trend, cache hit rate, and cost per million tokens. -Totals depend on the history available on each server. Grok turns without a saved completed-turn +**Tokens** shows recorded **Thinking** separately, in a lighter shade of each provider's color. +Thinking is part of output, so this does not change totals or cost. Thinking a provider does not +report separately stays in **Output**. + +Totals depend on the history available on each server. Recovering fuller Claude Code usage from +saved history can raise past totals and estimated costs. Grok turns without a saved completed-turn record are missing from the totals. Text generation (thread titles, commit messages, PR text, branch names and review guides) is not in the totals for Codex or Claude Code 2.0.63 and newer, because those runs save no session history. diff --git a/packages/shared/src/usageMerge.test.ts b/packages/shared/src/usageMerge.test.ts index 828b652077f62..bda7a42693797 100644 --- a/packages/shared/src/usageMerge.test.ts +++ b/packages/shared/src/usageMerge.test.ts @@ -113,6 +113,33 @@ describe("mergeUsage", () => { expect(merged.duplicateSources).toHaveLength(1); }); + it("rolls up reasoning tokens, clamped to each bucket's output", () => { + const merged = mergeUsage( + [ + environment( + "env-a", + summary( + [ + bucket({ + hourStart: "2026-08-07T09:00:00.000Z", + totals: { ...bucket().totals, reasoningTokens: 100 }, + }), + bucket({ hourStart: "2026-08-07T09:00:00.000Z" }), + ], + [{ provider: "claude", hostId: "mac", homePath: "/a", distinctSessions: 0 }], + ), + ), + ], + USAGE_CONTRACT_VERSION, + ); + const totals = { costUsd: 20, totalTokens: 2320, reasoningTokens: 50 }; + expect(merged).toMatchObject({ ...totals, outputTokens: 100 }); + expect(merged.providers[0]).toMatchObject(totals); + expect(merged.daily[0]?.byProvider.get("claude")).toEqual(totals); + expect(merged.hourly[0]?.byProvider.get("claude")).toEqual(totals); + expect(merged.models[0]?.tokens).toMatchObject({ outputTokens: 100, reasoningTokens: 50 }); + }); + it("counts a shared transcript directory once", () => { const shared = { provider: "claude" as const, hostId: "mac", homePath: "/home/theo/.claude" }; const merged = mergeUsage( diff --git a/packages/shared/src/usageMerge.ts b/packages/shared/src/usageMerge.ts index 6c3e9e84ae21d..d17f4253bd94e 100644 --- a/packages/shared/src/usageMerge.ts +++ b/packages/shared/src/usageMerge.ts @@ -19,6 +19,8 @@ export interface ProviderTotals { readonly provider: UsageProviderKind; readonly costUsd: number; readonly totalTokens: number; + /** A subset of output tokens, already included in totalTokens. */ + readonly reasoningTokens?: number; readonly records: number; readonly sessions: number; readonly costShare: number; @@ -47,7 +49,10 @@ export interface DailyTotals { readonly day: string; readonly costUsd: number; readonly totalTokens: number; - readonly byProvider: ReadonlyMap; + readonly byProvider: ReadonlyMap< + UsageProviderKind, + { costUsd: number; totalTokens: number; readonly reasoningTokens?: number } + >; } export interface HourlyTotals { @@ -55,7 +60,10 @@ export interface HourlyTotals { readonly hourStart: string; readonly costUsd: number; readonly totalTokens: number; - readonly byProvider: ReadonlyMap; + readonly byProvider: ReadonlyMap< + UsageProviderKind, + { costUsd: number; totalTokens: number; readonly reasoningTokens?: number } + >; } export interface CostQuality { @@ -347,7 +355,13 @@ export function mergeUsage( const providerAccumulator = new Map< UsageProviderKind, - { costUsd: number; totalTokens: number; records: number; sessions: number } + { + costUsd: number; + totalTokens: number; + reasoningTokens: number; + records: number; + sessions: number; + } >(); const modelAccumulator = new Map< string, @@ -361,12 +375,13 @@ export function mergeUsage( unpricedTokens: number; } >(); + type ProviderTokenTotals = { costUsd: number; totalTokens: number; reasoningTokens: number }; const dailyAccumulator = new Map< string, { costUsd: number; totalTokens: number; - byProvider: Map; + byProvider: Map; } >(); const hourlyAccumulator = new Map< @@ -376,7 +391,7 @@ export function mergeUsage( hourStart: string; costUsd: number; totalTokens: number; - byProvider: Map; + byProvider: Map; } >(); const contributingEnvironments: EnvironmentId[] = []; @@ -396,6 +411,7 @@ export function mergeUsage( const provider = providerAccumulator.get(providerKind) ?? { costUsd: 0, totalTokens: 0, + reasoningTokens: 0, records: 0, sessions: 0, }; @@ -405,6 +421,10 @@ export function mergeUsage( for (const bucket of buckets) { const tokens = bucketTokens(bucket); + const bucketReasoningTokens = Math.min( + bucket.totals.reasoningTokens, + bucket.totals.outputTokens, + ); costUsd += bucket.costUsd; cacheSavingsUsd += bucket.cacheSavingsUsd; @@ -412,7 +432,7 @@ export function mergeUsage( cachedInputTokens += bucket.totals.cachedInputTokens; cacheCreationTokens += bucket.totals.cacheCreationTokens; outputTokens += bucket.totals.outputTokens; - reasoningTokens += bucket.totals.reasoningTokens; + reasoningTokens += bucketReasoningTokens; records += bucket.records; unpricedRecords += bucket.unpricedRecords; if (bucket.costSource === "providerReported") providerReportedRecords += bucket.records; @@ -429,11 +449,13 @@ export function mergeUsage( const provider = providerAccumulator.get(bucket.provider) ?? { costUsd: 0, totalTokens: 0, + reasoningTokens: 0, records: 0, sessions: 0, }; provider.costUsd += bucket.costUsd; provider.totalTokens += tokens; + provider.reasoningTokens += bucketReasoningTokens; provider.records += bucket.records; providerAccumulator.set(bucket.provider, provider); @@ -460,7 +482,7 @@ export function mergeUsage( cachedInputTokens: model.tokens.cachedInputTokens + bucket.totals.cachedInputTokens, cacheCreationTokens: model.tokens.cacheCreationTokens + bucket.totals.cacheCreationTokens, outputTokens: model.tokens.outputTokens + bucket.totals.outputTokens, - reasoningTokens: model.tokens.reasoningTokens + bucket.totals.reasoningTokens, + reasoningTokens: model.tokens.reasoningTokens + bucketReasoningTokens, }; model.records += bucket.records; model.unpricedRecords += bucket.unpricedRecords; @@ -472,13 +494,18 @@ export function mergeUsage( const day = dailyAccumulator.get(bucket.day) ?? { costUsd: 0, totalTokens: 0, - byProvider: new Map(), + byProvider: new Map(), }; day.costUsd += bucket.costUsd; day.totalTokens += tokens; - const dayProvider = day.byProvider.get(bucket.provider) ?? { costUsd: 0, totalTokens: 0 }; + const dayProvider = day.byProvider.get(bucket.provider) ?? { + costUsd: 0, + totalTokens: 0, + reasoningTokens: 0, + }; dayProvider.costUsd += bucket.costUsd; dayProvider.totalTokens += tokens; + dayProvider.reasoningTokens += bucketReasoningTokens; day.byProvider.set(bucket.provider, dayProvider); dailyAccumulator.set(bucket.day, day); @@ -488,16 +515,18 @@ export function mergeUsage( hourStart: bucket.hourStart, costUsd: 0, totalTokens: 0, - byProvider: new Map(), + byProvider: new Map(), }; hour.costUsd += bucket.costUsd; hour.totalTokens += tokens; const hourProvider = hour.byProvider.get(bucket.provider) ?? { costUsd: 0, totalTokens: 0, + reasoningTokens: 0, }; hourProvider.costUsd += bucket.costUsd; hourProvider.totalTokens += tokens; + hourProvider.reasoningTokens += bucketReasoningTokens; hour.byProvider.set(bucket.provider, hourProvider); hourlyAccumulator.set(bucket.hourStart, hour); } @@ -511,6 +540,7 @@ export function mergeUsage( provider, costUsd: totals.costUsd, totalTokens: totals.totalTokens, + reasoningTokens: totals.reasoningTokens, records: totals.records, sessions: totals.sessions, costShare: costUsd === 0 ? 0 : totals.costUsd / costUsd, From 24bd638de45ef6b11182f66cad70174f0f1e5a3a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 19:40:39 +0000 Subject: [PATCH 2/7] test(usage): cover thinking snapshots and retained history --- .../src/provider/Drivers/claudeUsage.test.ts | 1 - apps/server/src/usage/UsageService.test.ts | 186 ++++++++++++------ apps/server/src/usage/usageScanCache.test.ts | 27 +-- .../usage/UsageProviderChart.test.ts | 2 +- .../components/usage/usageBreakdown.test.ts | 29 ++- packages/shared/src/usageMerge.test.ts | 35 ++-- 6 files changed, 176 insertions(+), 104 deletions(-) diff --git a/apps/server/src/provider/Drivers/claudeUsage.test.ts b/apps/server/src/provider/Drivers/claudeUsage.test.ts index dacd7a223168a..6e01203384d91 100644 --- a/apps/server/src/provider/Drivers/claudeUsage.test.ts +++ b/apps/server/src/provider/Drivers/claudeUsage.test.ts @@ -50,7 +50,6 @@ describe("parseClaudeLine", () => { }); expect(record?.dedupeKey).toBe("msg_1:"); expect(record?.speed).toBe("standard"); - }); const withThinking = claudeLine({ messageId: "msg_1", diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts index 9e945cd9a3574..3a5c4f09236df 100644 --- a/apps/server/src/usage/UsageService.test.ts +++ b/apps/server/src/usage/UsageService.test.ts @@ -19,6 +19,7 @@ import { ProviderInstanceId, UsageDay, type UsageSource, + type UsageSummary, type UsageSummaryInput, } from "@t3tools/contracts"; import * as Duration from "effect/Duration"; @@ -89,7 +90,13 @@ function claudeLine( message: { id: `msg_${id}`, model, - usage: { input_tokens: 10, output_tokens: outputTokens }, + usage: { + input_tokens: 10, + output_tokens: outputTokens, + ...(thinkingTokens === undefined + ? {} + : { output_tokens_details: { thinking_tokens: thinkingTokens } }), + }, }, })}\n`; } @@ -2645,9 +2652,12 @@ describe("UsageService", () => { const service = yield* UsageService.make; check(yield* service.readSummary(WINDOW), 160, 40, 1); - const service = yield* UsageService.make.pipe( - Effect.provide(layerService({ prefix: "usage-service-grow-test", home, settings })), - ); + // Preserve the committed prefix and grow the file, so this resumes from the tail. + const replacement = claudeLine(2, 200) + .trimEnd() + .replace('"message":', '"padding":"' + "x".repeat(256) + '","message":'); + yield* Effect.promise(() => NodeFSP.writeFile(transcript, committed + replacement)); + check(yield* service.readSummary(WINDOW), 300, 0, 2); yield* Effect.promise(() => NodeFSP.appendFile( @@ -2698,6 +2708,7 @@ describe("UsageService", () => { const service = yield* UsageService.make; const first = yield* service.readSummary(WINDOW); assert.strictEqual(totalOutputTokens(first), 9900); + assert.strictEqual(first.buckets[0]?.totals.reasoningTokens, 3900); assert.closeTo( first.buckets.reduce((sum, bucket) => sum + bucket.costUsd, 0), 0.4951, @@ -2714,7 +2725,16 @@ describe("UsageService", () => { ), ); const appended = yield* service.readSummary(WINDOW); - assert.strictEqual(totalOutputTokens(appended), 10000); + assert.strictEqual(totalOutputTokens(appended), 10100); + assert.strictEqual( + appended.buckets.reduce((sum, bucket) => sum + bucket.totals.reasoningTokens, 0), + 4030, + ); + assert.closeTo( + appended.buckets.reduce((sum, bucket) => sum + bucket.costUsd, 0), + 0.5052, + 1e-12, + ); assert.strictEqual( appended.buckets.reduce((sum, bucket) => sum + bucket.totals.uncachedInputTokens, 0), 20, @@ -2986,13 +3006,13 @@ describe("UsageService", () => { type: "thread_settings_applied", thread_settings: { service_tier: "ultrafast" }, }, - { - type: "event_msg", - timestamp: "2026-08-01T10:00:00Z", - payload: { - type: "token_count", - info: { last_token_usage: { input_tokens: 0, output_tokens: outputTokens } }, - }, + }, + { + type: "event_msg", + timestamp: "2026-08-01T10:00:00Z", + payload: { + type: "token_count", + info: { last_token_usage: { input_tokens: 0, output_tokens: outputTokens } }, }, }, ] @@ -3020,56 +3040,110 @@ describe("UsageService", () => { const legacyPath = NodePath.join(stateDir, "usage-scan-cache.json"); const v5Path = NodePath.join(stateDir, "usage-scan-cache-v5.json"); yield* Effect.gen(function* () { - const { stateDir } = yield* ServerConfig.ServerConfig; - const cachePath = NodePath.join(stateDir, "usage-scan-cache-v6.json"); - const legacyPath = NodePath.join(stateDir, "usage-scan-cache.json"); const first = yield* UsageService.make; yield* first.readSummary(WINDOW); - yield* first.awaitPersisted; + }).pipe(Effect.scoped); - const legacy = yield* Effect.promise(async () => { - const document = decodeUnknownJsonString(await NodeFSP.readFile(cachePath, "utf8")) as { - files: Record; - }; - for (const file of Object.values(document.files)) { - file.r = file.r.map((row) => [...row.slice(0, 10), 0]); - delete file.cs.speed; + const legacy = yield* Effect.promise(async () => { + const document = decodeUnknownJsonString(await NodeFSP.readFile(cachePath, "utf8")) as { + files: Record< + string, + { m: number; p: string; r: unknown[][]; cs: { speed?: unknown } | null } + >; + }; + for (const file of Object.values(document.files)) { + file.r = file.r.map((row) => [...row.slice(0, 10), 0]); + if (file.cs !== null) delete file.cs.speed; + if (file.p === "claude") { + file.r[0]![6] = 100; + file.r[0]![7] = 0; } - const text = encodeUnknownJsonString({ ...document, version: 4 }); - await NodeFSP.writeFile(legacyPath, text); - await NodeFSP.rm(cachePath); - await NodeFSP.rm(deleted); - return text; - }); + } + const newerEntry = document.files[newerOnly]!; + delete document.files[newerOnly]; + const v4 = encodeUnknownJsonString({ ...document, version: 4 }); + document.files[newerOnly] = newerEntry; + await NodeFSP.writeFile(legacyPath, v4); + delete document.files[legacyOnly]; + document.files[deleted]!.m -= 1000; + document.files[deleted]!.r[0]![6] = 10; + delete document.files[live]; + for (const file of Object.values(document.files)) { + if (file.cs !== null) file.cs.speed = "standard"; + } + const v5 = encodeUnknownJsonString({ ...document, version: 5 }); + await NodeFSP.writeFile(v5Path, v5); + await NodeFSP.rm(cachePath); + await NodeFSP.rm(deleted); + await NodeFSP.rm(legacyOnly); + await NodeFSP.rm(newerOnly); + return { v4, v5 }; + }); - const summary = yield* (yield* UsageService.make).readSummary(WINDOW); - assert.strictEqual(totalOutputTokens(summary), 30); - assert.strictEqual( - summary.buckets.reduce((sum, bucket) => sum + bucket.costUsd, 0), - 80, - ); - assert.strictEqual( - yield* Effect.promise(() => NodeFSP.readFile(legacyPath, "utf8")), - legacy, - ); - }).pipe( - Effect.provide( - layerService({ - prefix: "usage-service-v4-upgrade-test", - home, - settings, - ratesDocument: { - "gpt-6-astra": { - input_cost_per_token: 0, - output_cost_per_token: 1, - input_cost_per_token_ultrafast: 0, - output_cost_per_token_ultrafast: 6, - }, - }, - }), - ), + const check = (summary: UsageSummary) => { + expect.soft(totalOutputTokens(summary)).toBe(260); + expect + .soft(summary.buckets.reduce((sum, bucket) => sum + bucket.totals.reasoningTokens, 0)) + .toBe(40); + expect.soft(summary.buckets.reduce((sum, bucket) => sum + bucket.costUsd, 0)).toBe(310); + }; + yield* Effect.gen(function* () { + check(yield* (yield* UsageService.make).readSummary(WINDOW)); + }).pipe(Effect.scoped); + + // A downgrade can add history after the current cache has already been written. + yield* Effect.promise(async () => { + const v5 = decodeUnknownJsonString(legacy.v5) as { files: Record }; + delete v5.files[newerOnly]; + legacy.v5 = encodeUnknownJsonString(v5); + await NodeFSP.writeFile(v5Path, legacy.v5); + const document = decodeUnknownJsonString(await NodeFSP.readFile(cachePath, "utf8")) as { + files: Record; + }; + delete document.files[legacyOnly]; + document.files[deleted]!.m -= 1000; + document.files[deleted]!.r[0]![6] = 10; + await NodeFSP.writeFile(cachePath, encodeUnknownJsonString(document)); + }); + yield* Effect.gen(function* () { + check(yield* (yield* UsageService.make).readSummary(WINDOW)); + }).pipe(Effect.scoped); + assert.strictEqual( + yield* Effect.promise(() => NodeFSP.readFile(legacyPath, "utf8")), + legacy.v4, ); - }).pipe(Effect.scoped), + assert.strictEqual( + yield* Effect.promise(() => NodeFSP.readFile(v5Path, "utf8")), + legacy.v5, + ); + + // Imported deleted history must survive even when only the new cache remains. + yield* Effect.promise(async () => { + await NodeFSP.rm(legacyPath); + await NodeFSP.rm(v5Path); + }); + yield* Effect.gen(function* () { + check(yield* (yield* UsageService.make).readSummary(WINDOW)); + }).pipe(Effect.scoped); + }).pipe( + Effect.provide( + layerService({ + prefix: "usage-service-v4-upgrade-test", + home, + settings, + ratesDocument: { + "claude-fable-5": { input_cost_per_token: 0, output_cost_per_token: 1 }, + "gpt-6-astra": { + input_cost_per_token: 0, + output_cost_per_token: 1, + input_cost_per_token_ultrafast: 0, + output_cost_per_token_ultrafast: 6, + }, + }, + }), + ), + ); + }).pipe(Effect.scoped), ); it.live("preserves saved tokens, costs and sessions after transcript cleanup and restart", () => diff --git a/apps/server/src/usage/usageScanCache.test.ts b/apps/server/src/usage/usageScanCache.test.ts index 9c82976d07e92..b47d2f74b75a8 100644 --- a/apps/server/src/usage/usageScanCache.test.ts +++ b/apps/server/src/usage/usageScanCache.test.ts @@ -326,18 +326,21 @@ describe("scan cache round trip", () => { describe("dedupeWithinFile", () => { it("keeps the first Claude record's attribution with its fullest usage snapshot", () => { - const kept = dedupeWithinFile([ - record({ totals: { ...record().totals, outputTokens: 1 } }), - record({ - timestampMs: 1_786_000_000_500, - totals: { ...record().totals, outputTokens: 999, reasoningTokens: 400 }, - }), - record({ totals: { ...record().totals, outputTokens: 3 } }), - record({ dedupeKey: "msg_2:" }), - ]); + const first = record({ totals: { ...record().totals, outputTokens: 1 } }); + const output = record({ + timestampMs: first.timestampMs + 500, + totals: { ...first.totals, outputTokens: 100 }, + reportedCostUsd: 0.25, + }); + const thinking = record({ + ...output, + totals: { ...output.totals, reasoningTokens: 40 }, + }); + const other = record({ dedupeKey: "msg_2:" }); + const kept = dedupeWithinFile([first, output, thinking, first, thinking, other]); - expect(kept).toHaveLength(2); - expect(kept[0]?.timestampMs).toBe(1_786_000_000_000); - expect(kept[0]?.totals).toMatchObject({ outputTokens: 999, reasoningTokens: 400 }); + expect(kept).toEqual([{ ...first, totals: thinking.totals, reportedCostUsd: 0.25 }, other]); + expect(first.totals.outputTokens).toBe(1); + expect(output.totals.reasoningTokens).toBe(0); }); }); diff --git a/apps/web/src/components/usage/UsageProviderChart.test.ts b/apps/web/src/components/usage/UsageProviderChart.test.ts index 8636b99eeebba..0de41d58d8208 100644 --- a/apps/web/src/components/usage/UsageProviderChart.test.ts +++ b/apps/web/src/components/usage/UsageProviderChart.test.ts @@ -13,7 +13,7 @@ describe("buildPeriodColumns", () => { totalTokens: 420, byProvider: new Map([ ["codex" as const, { costUsd: 10, totalTokens: 100 }], - ["claude" as const, { costUsd: 20, totalTokens: 200 }], + ["claude" as const, { costUsd: 20, totalTokens: 200, reasoningTokens: 40 }], ["pi" as const, { costUsd: 5, totalTokens: 50 }], ["muse" as const, { costUsd: 7, totalTokens: 70 }], ]), diff --git a/apps/web/src/components/usage/usageBreakdown.test.ts b/apps/web/src/components/usage/usageBreakdown.test.ts index b3b846ef9f7f8..0b30eea8eb01d 100644 --- a/apps/web/src/components/usage/usageBreakdown.test.ts +++ b/apps/web/src/components/usage/usageBreakdown.test.ts @@ -29,24 +29,19 @@ const model = ( }); describe("token types", () => { - it.each([0, 40, 100])( - "splits %i thinking tokens out of output without changing the total", - (reasoningTokens) => { - const segments = tokenTypeSegments({ - uncachedInputTokens: 200, - cachedInputTokens: 300, - cacheCreationTokens: 50, - outputTokens: 100, - reasoningTokens, - }); + it("separates recorded thinking from output without counting it twice", () => { + const segments = tokenTypeSegments({ + uncachedInputTokens: 200, + cachedInputTokens: 300, + cacheCreationTokens: 50, + outputTokens: 100, + reasoningTokens: 40, + }); - expect(segments.find((segment) => segment.label === "Output")?.value).toBe( - 100 - reasoningTokens, - ); - expect(segments.find((segment) => segment.label === "Thinking")?.value).toBe(reasoningTokens); - expect(segments.reduce((sum, segment) => sum + segment.value, 0)).toBe(650); - }, - ); + expect(segments.find((segment) => segment.label === "Output")?.value).toBe(60); + expect(segments.find((segment) => segment.label === "Thinking")?.value).toBe(40); + expect(segments.reduce((sum, segment) => sum + segment.value, 0)).toBe(650); + }); }); describe("model rates", () => { diff --git a/packages/shared/src/usageMerge.test.ts b/packages/shared/src/usageMerge.test.ts index bda7a42693797..bda8e2b89c67a 100644 --- a/packages/shared/src/usageMerge.test.ts +++ b/packages/shared/src/usageMerge.test.ts @@ -113,31 +113,32 @@ describe("mergeUsage", () => { expect(merged.duplicateSources).toHaveLength(1); }); - it("rolls up reasoning tokens, clamped to each bucket's output", () => { - const merged = mergeUsage( + it("rolls thinking into every view once, including when environments share history", () => { + const source = { provider: "claude" as const, hostId: "mac", homePath: "/a" }; + const usage = summary( [ - environment( - "env-a", - summary( - [ - bucket({ - hourStart: "2026-08-07T09:00:00.000Z", - totals: { ...bucket().totals, reasoningTokens: 100 }, - }), - bucket({ hourStart: "2026-08-07T09:00:00.000Z" }), - ], - [{ provider: "claude", hostId: "mac", homePath: "/a", distinctSessions: 0 }], - ), - ), + bucket({ + hourStart: "2026-08-07T09:00:00.000Z", + totals: { ...bucket().totals, reasoningTokens: 20 }, + }), + bucket({ + hourStart: "2026-08-07T09:00:00.000Z", + totals: { ...bucket().totals, reasoningTokens: 100 }, + }), ], + [source], + ); + const merged = mergeUsage( + [environment("env-a", usage), environment("env-b", usage)], USAGE_CONTRACT_VERSION, ); - const totals = { costUsd: 20, totalTokens: 2320, reasoningTokens: 50 }; + const totals = { costUsd: 20, totalTokens: 2320, reasoningTokens: 70 }; expect(merged).toMatchObject({ ...totals, outputTokens: 100 }); expect(merged.providers[0]).toMatchObject(totals); expect(merged.daily[0]?.byProvider.get("claude")).toEqual(totals); expect(merged.hourly[0]?.byProvider.get("claude")).toEqual(totals); - expect(merged.models[0]?.tokens).toMatchObject({ outputTokens: 100, reasoningTokens: 50 }); + expect(merged.models[0]?.tokens).toMatchObject({ outputTokens: 100, reasoningTokens: 70 }); + expect(merged.duplicateSources).toHaveLength(1); }); it("counts a shared transcript directory once", () => { From 17fb5e8a2062561f5cc1018e27869891f2c6ffc7 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 19:46:44 +0000 Subject: [PATCH 3/7] fix(usage): reconcile thinking snapshots and retained history --- .../src/features/usage/UsageRouteScreen.tsx | 6 +++ .../src/features/usage/usageChartData.ts | 1 - .../src/features/usage/usageProviders.ts | 11 ++-- apps/server/src/usage/UsageService.test.ts | 16 ++++++ apps/server/src/usage/UsageService.ts | 51 ++++++++++++++----- apps/server/src/usage/usageScanCache.ts | 43 +++++++++++----- .../components/usage/UsageProviderChart.tsx | 1 - .../src/components/usage/usageProviders.ts | 2 - 8 files changed, 91 insertions(+), 40 deletions(-) diff --git a/apps/mobile/src/features/usage/UsageRouteScreen.tsx b/apps/mobile/src/features/usage/UsageRouteScreen.tsx index 4c8680722c461..56847c13b2220 100644 --- a/apps/mobile/src/features/usage/UsageRouteScreen.tsx +++ b/apps/mobile/src/features/usage/UsageRouteScreen.tsx @@ -967,6 +967,12 @@ function ModelsSection(props: { ? "Unpriced" : formatUsd(model.costUsd)} + {metric === "tokens" && model.tokens.reasoningTokens > 0 ? ( + + {formatTokens(model.tokens.outputTokens - model.tokens.reasoningTokens)} output ·{" "} + {formatTokens(model.tokens.reasoningTokens)} thinking + + ) : null} ))} diff --git a/apps/mobile/src/features/usage/usageChartData.ts b/apps/mobile/src/features/usage/usageChartData.ts index 0145ed7d8b2cd..68d141accc0fa 100644 --- a/apps/mobile/src/features/usage/usageChartData.ts +++ b/apps/mobile/src/features/usage/usageChartData.ts @@ -7,7 +7,6 @@ export type UsageChartMetric = "cost" | "tokens"; export interface UsageChartDay { readonly day: string; - /** In {@link PROVIDER_ORDER}, i.e. bottom of the stack first. */ readonly values: readonly { readonly provider: UsageProviderKind; readonly thinking: boolean; diff --git a/apps/mobile/src/features/usage/usageProviders.ts b/apps/mobile/src/features/usage/usageProviders.ts index bc61ac9de35b5..5275911b665fb 100644 --- a/apps/mobile/src/features/usage/usageProviders.ts +++ b/apps/mobile/src/features/usage/usageProviders.ts @@ -1,6 +1,6 @@ import type { UsageProviderKind } from "@t3tools/contracts"; +import { useResolveClassNames } from "uniwind"; import { flattenThemeColor, themeColorWithAlpha } from "../../lib/mobileTheme"; -import { useUniwindTheme } from "../../lib/useUniwindTheme"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; export const PROVIDER_ORDER: readonly UsageProviderKind[] = [ @@ -44,10 +44,10 @@ export function useProviderColors(): Record { }; } -/** Flatten the lighter thinking bands against the chart card for native charts. */ export function useProviderThinkingColors(): Record { const colors = useProviderColors(); - const surface = useUniwindTheme()["--color-grouped-card"]; + const { backgroundColor: surface } = useResolveClassNames("bg-grouped-card"); + if (typeof surface !== "string") return colors; const thinking = { ...colors }; for (const provider of PROVIDER_ORDER) { thinking[provider] = flattenThemeColor(themeColorWithAlpha(colors[provider], 0.45), surface); @@ -55,11 +55,6 @@ export function useProviderThinkingColors(): Record { return thinking; } -/** - * Neutral steps for cost and token mixes, so they never borrow a provider's - * color. Matches the web steps: oklab mixes of the codex ink into the - * background, above the 15 ΔE separation floor for adjacent segments. - */ export function useUsageMixColors() { const { themeAppearance: scheme } = useAppearancePreferences(); const dark = scheme === "dark"; diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts index 3a5c4f09236df..b915a2a0bb9e7 100644 --- a/apps/server/src/usage/UsageService.test.ts +++ b/apps/server/src/usage/UsageService.test.ts @@ -3108,6 +3108,22 @@ describe("UsageService", () => { yield* Effect.gen(function* () { check(yield* (yield* UsageService.make).readSummary(WINDOW)); }).pipe(Effect.scoped); + // Newer legacy metadata must not erase a fuller snapshot already saved in v6. + yield* Effect.promise(async () => { + const current = decodeUnknownJsonString(await NodeFSP.readFile(cachePath, "utf8")) as { + files: Record; + }; + const v5 = decodeUnknownJsonString(legacy.v5) as { + files: Record; + }; + v5.files[transcript]!.m = current.files[transcript]!.m + 1000; + legacy.v5 = encodeUnknownJsonString(v5); + await NodeFSP.writeFile(v5Path, legacy.v5); + await NodeFSP.rm(transcript); + }); + yield* Effect.gen(function* () { + check(yield* (yield* UsageService.make).readSummary(WINDOW)); + }).pipe(Effect.scoped); assert.strictEqual( yield* Effect.promise(() => NodeFSP.readFile(legacyPath, "utf8")), legacy.v4, diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index 734102496b8cd..d11144463809c 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -47,6 +47,7 @@ import { readTranscriptRecords, } from "./usageTranscriptReader.ts"; import { + cachedFileRecords, decodeScanCache, dedupeWithinFile, LEGACY_SCAN_CACHE_FILE_NAMES, @@ -388,20 +389,42 @@ export const make = Effect.gen(function* () { Effect.flatMap((raw) => decodeScanCacheFile(raw)), Effect.catchCause(() => Effect.succeed(null)), ); - let document = yield* readDocument(scanCachePath); - for (const legacyScanCachePath of legacyScanCachePaths) { - if (document !== null) break; - document = yield* readDocument(legacyScanCachePath); - cacheDirty = document !== null; - } - if (document === null) return; - for (const [path, entry] of decodeScanCache(document, transcriptFormats)) { - fileCache.set(path, entry); - } - const sources = decodeCachedSources(document); - if (Option.isSome(sources)) { - for (const [key, source] of Object.entries(sources.value.sources)) - sourceCache.set(key, source); + // Current format wins ties, but every supported cache can hold deleted history. + for (const filePath of [scanCachePath, ...legacyScanCachePaths]) { + const document = yield* readDocument(filePath); + if (document === null) continue; + for (const [path, entry] of decodeScanCache(document, transcriptFormats)) { + const previous = fileCache.get(path); + let selected = + previous === undefined || entry.mtimeMs > previous.mtimeMs ? entry : previous; + if (previous?.provider === "claude" && entry.provider === "claude") { + const older = selected === entry ? previous : entry; + const snapshots = new Map( + dedupeWithinFile([...selected.records, ...older.records]) + .filter((record) => record.dedupeKey !== null) + .map((record) => [record.dedupeKey, record]), + ); + const records = selected.records.map((record) => + record.dedupeKey === null ? record : (snapshots.get(record.dedupeKey) ?? record), + ); + if (records.some((record, index) => record !== selected.records[index])) { + selected = { ...selected, records }; + } + } + if (selected !== previous) { + fileCache.set(path, selected); + if (filePath !== scanCachePath) cacheDirty = true; + } + } + const sources = decodeCachedSources(document); + if (Option.isSome(sources)) { + for (const [key, source] of Object.entries(sources.value.sources)) { + if (!sourceCache.has(key)) { + sourceCache.set(key, source); + if (filePath !== scanCachePath) cacheDirty = true; + } + } + } } }), ); diff --git a/apps/server/src/usage/usageScanCache.ts b/apps/server/src/usage/usageScanCache.ts index cd699e4e4baac..d6d4043b5e449 100644 --- a/apps/server/src/usage/usageScanCache.ts +++ b/apps/server/src/usage/usageScanCache.ts @@ -16,7 +16,7 @@ const SPEED_COMPATIBLE_SINCE_VERSION = 4; * Each cache version writes its own file in the state directory. An older * server sharing that directory cannot read a newer cache and would replace * it, dropping saved usage for deleted transcripts. Separate files keep both. - * When its own file is missing, a server reads the newest older file once. + * Import older files too: a downgraded server may have retained additional history. */ export const SCAN_CACHE_FILE_NAME = "usage-scan-cache-v12.json"; export const LEGACY_SCAN_CACHE_FILE_NAMES = [ @@ -359,14 +359,7 @@ export function pruneScanCache(cache: ScanCache, retentionCutoffMs: number): num return removed; } -/** - * Callers stitching an incremental parse together pass one `seen` set across the line and tail - * record batches so the whole file stays deduplicated as a unit; the set is mutated in place. - */ -export function dedupeWithinFile( - records: readonly UsageRecord[], - seen: Set = new Set(), -): readonly UsageRecord[] { +export function dedupeWithinFile(records: readonly UsageRecord[]): readonly UsageRecord[] { const kept: UsageRecord[] = []; const indexes = new Map(); for (const record of records) { @@ -376,8 +369,6 @@ export function dedupeWithinFile( kept[index] = fullerClaudeSnapshot(kept[index]!, record); continue; } - if (seen.has(record.dedupeKey)) continue; - seen.add(record.dedupeKey); indexes.set(record.dedupeKey, kept.length); } kept.push(record); @@ -391,7 +382,31 @@ export function dedupeWithinFile( * attribution with the fullest snapshot; never sum the repeats. */ function fullerClaudeSnapshot(kept: UsageRecord, next: UsageRecord): UsageRecord { - return next.provider === "claude" && next.totals.outputTokens > kept.totals.outputTokens - ? { ...kept, totals: next.totals, reportedCostUsd: next.reportedCostUsd } - : kept; + if (kept.provider !== "claude" || next.provider !== "claude") return kept; + const snapshot = + next.totals.outputTokens > kept.totals.outputTokens || + (next.totals.outputTokens === kept.totals.outputTokens && + next.totals.reasoningTokens > kept.totals.reasoningTokens) + ? next + : kept; + const reasoningTokens = Math.min( + snapshot.totals.outputTokens, + Math.max(kept.totals.reasoningTokens, next.totals.reasoningTokens), + ); + if (snapshot === kept && reasoningTokens === kept.totals.reasoningTokens) return kept; + return { + ...kept, + totals: { ...snapshot.totals, reasoningTokens }, + reportedCostUsd: + snapshot.totals.outputTokens > kept.totals.outputTokens + ? snapshot.reportedCostUsd + : (snapshot.reportedCostUsd ?? kept.reportedCostUsd), + }; +} + +/** Reconcile the displayed usage without promoting a replaceable tail into committed records. */ +export function cachedFileRecords(entry: CachedFile): readonly UsageRecord[] { + return entry.tailRecords.length === 0 + ? entry.records + : dedupeWithinFile([...entry.records, ...entry.tailRecords]); } diff --git a/apps/web/src/components/usage/UsageProviderChart.tsx b/apps/web/src/components/usage/UsageProviderChart.tsx index 4b9060b96549a..96499525283ce 100644 --- a/apps/web/src/components/usage/UsageProviderChart.tsx +++ b/apps/web/src/components/usage/UsageProviderChart.tsx @@ -201,7 +201,6 @@ function buildChart( const columns = buildPeriodColumns(periods, byPeriod, metric); const scale = chartScale(columns, loadingProviders); const stepX = periods.length < 2 ? 0 : VIEW_WIDTH / (periods.length - 1); - // Each provider's total, plus a dashed series for its recorded thinking. const paths = providers.flatMap((provider) => { const slot = PROVIDER_ORDER.indexOf(provider); return (["value", "thinking"] as const).flatMap((series) => { diff --git a/apps/web/src/components/usage/usageProviders.ts b/apps/web/src/components/usage/usageProviders.ts index b598514ca90f8..578fad82f0e25 100644 --- a/apps/web/src/components/usage/usageProviders.ts +++ b/apps/web/src/components/usage/usageProviders.ts @@ -49,12 +49,10 @@ export const PROVIDER_PRESENTATION = { }, } satisfies Record; -/** A softer shade that stays distinct even for the theme-aware neutral providers. */ export function providerThinkingColor(provider: UsageProviderKind): string { return `color-mix(in oklab, ${PROVIDER_PRESENTATION[provider].color} 55%, var(--background))`; } -/** Stable provider reading order across charts, summaries, tables, and hover rows. */ export const PROVIDER_ORDER = Object.keys(PROVIDER_PRESENTATION) as UsageProviderKind[]; export function providersWithUsage( From 5c527d4c5c51ea92498df7bbcd92095abce918d5 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 19:56:25 +0000 Subject: [PATCH 4/7] test(usage): cover thinking retention across tail cache migration --- apps/server/src/usage/UsageService.test.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts index b915a2a0bb9e7..833fd0211b31c 100644 --- a/apps/server/src/usage/UsageService.test.ts +++ b/apps/server/src/usage/UsageService.test.ts @@ -3048,15 +3048,23 @@ describe("UsageService", () => { const document = decodeUnknownJsonString(await NodeFSP.readFile(cachePath, "utf8")) as { files: Record< string, - { m: number; p: string; r: unknown[][]; cs: { speed?: unknown } | null } + { + m: number; + p: string; + r: unknown[][]; + t: unknown[][]; + cs: { speed?: unknown } | null; + } >; }; for (const file of Object.values(document.files)) { file.r = file.r.map((row) => [...row.slice(0, 10), 0]); + file.t = file.t.map((row) => [...row.slice(0, 10), 0]); if (file.cs !== null) delete file.cs.speed; if (file.p === "claude") { file.r[0]![6] = 100; file.r[0]![7] = 0; + file.t[0]![7] = 0; } } const newerEntry = document.files[newerOnly]!; @@ -3108,7 +3116,7 @@ describe("UsageService", () => { yield* Effect.gen(function* () { check(yield* (yield* UsageService.make).readSummary(WINDOW)); }).pipe(Effect.scoped); - // Newer legacy metadata must not erase a fuller snapshot already saved in v6. + // A downgrade's newer tail must retain thinking saved only in the v6 tail. yield* Effect.promise(async () => { const current = decodeUnknownJsonString(await NodeFSP.readFile(cachePath, "utf8")) as { files: Record; From de9e88d786f6372f4acea14e85c1acb9b3613841 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 20:00:06 +0000 Subject: [PATCH 5/7] fix(usage): retain thinking across tail cache migration --- apps/server/src/usage/UsageService.ts | 38 ++++++++++++++++++++------- 1 file changed, 28 insertions(+), 10 deletions(-) diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index d11144463809c..a3b37c13ccaa4 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -399,16 +399,34 @@ export const make = Effect.gen(function* () { previous === undefined || entry.mtimeMs > previous.mtimeMs ? entry : previous; if (previous?.provider === "claude" && entry.provider === "claude") { const older = selected === entry ? previous : entry; - const snapshots = new Map( - dedupeWithinFile([...selected.records, ...older.records]) - .filter((record) => record.dedupeKey !== null) - .map((record) => [record.dedupeKey, record]), - ); - const records = selected.records.map((record) => - record.dedupeKey === null ? record : (snapshots.get(record.dedupeKey) ?? record), - ); - if (records.some((record, index) => record !== selected.records[index])) { - selected = { ...selected, records }; + const reconcile = ( + records: readonly UsageRecord[], + candidates: readonly UsageRecord[], + ) => { + const snapshots = new Map( + dedupeWithinFile([...records, ...candidates]) + .filter((record) => record.dedupeKey !== null) + .map((record) => [record.dedupeKey, record]), + ); + return records.map((record) => + record.dedupeKey === null ? record : (snapshots.get(record.dedupeKey) ?? record), + ); + }; + const tailKeys = new Set(selected.tailRecords.map((record) => record.dedupeKey)); + // A matching tail must stay replaceable, even when its key also has a committed row. + const records = reconcile(selected.records, [ + ...older.records, + ...older.tailRecords.filter((record) => !tailKeys.has(record.dedupeKey)), + ]); + const tailRecords = reconcile(selected.tailRecords, [ + ...older.records, + ...older.tailRecords, + ]); + if ( + records.some((record, index) => record !== selected.records[index]) || + tailRecords.some((record, index) => record !== selected.tailRecords[index]) + ) { + selected = { ...selected, records, tailRecords }; } } if (selected !== previous) { From d38ce653969c1d17024cf3b060b8f1751de0b56c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 20:24:57 +0000 Subject: [PATCH 6/7] test(usage): cover stale legacy tails after replacement --- apps/server/src/usage/UsageService.test.ts | 28 ++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts index 833fd0211b31c..fc88a0d33a068 100644 --- a/apps/server/src/usage/UsageService.test.ts +++ b/apps/server/src/usage/UsageService.test.ts @@ -2648,9 +2648,23 @@ describe("UsageService", () => { check(yield* service.readSummary(WINDOW), 160, 40, 1); }).pipe(Effect.scoped); + // A v5 scan of a tail-only file could retain A without a duplicate committed row. + const legacy = yield* Effect.promise(async () => { + const document = decodeUnknownJsonString(await NodeFSP.readFile(cachePath, "utf8")) as { + files: Record; + }; + const file = document.files[transcript]!; + assert.strictEqual(file.r.length, 0); + assert.strictEqual(file.t.length, 1); + file.t[0]![7] = 0; + return encodeUnknownJsonString({ ...document, version: 5 }); + }); + yield* Effect.promise(() => NodeFSP.writeFile(transcript, committed + tail)); + yield* Effect.gen(function* () { const service = yield* UsageService.make; check(yield* service.readSummary(WINDOW), 160, 40, 1); + check(yield* service.readSummary(WINDOW), 160, 40, 1); // Preserve the committed prefix and grow the file, so this resumes from the tail. const replacement = claudeLine(2, 200) @@ -2658,7 +2672,21 @@ describe("UsageService", () => { .replace('"message":', '"padding":"' + "x".repeat(256) + '","message":'); yield* Effect.promise(() => NodeFSP.writeFile(transcript, committed + replacement)); check(yield* service.readSummary(WINDOW), 300, 0, 2); + }).pipe(Effect.scoped); + // Import the obsolete A tail only after the current cache has persisted tail B. + yield* Effect.promise(() => NodeFSP.writeFile(legacyPath, legacy)); + yield* Effect.gen(function* () { + const service = yield* UsageService.make; + check(yield* service.readSummary(WINDOW), 300, 0, 2); + check(yield* service.readSummary(WINDOW), 300, 0, 2); + }).pipe(Effect.scoped); + yield* Effect.promise(() => NodeFSP.rm(legacyPath)); + + yield* Effect.gen(function* () { + const service = yield* UsageService.make; + check(yield* service.readSummary(WINDOW), 300, 0, 2); + check(yield* service.readSummary(WINDOW), 300, 0, 2); yield* Effect.promise(() => NodeFSP.appendFile( transcript, From cac47a85c00477fd0d5a1455910e4594a5695117 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 20:26:54 +0000 Subject: [PATCH 7/7] fix(usage): keep stale cache tails out of committed history --- apps/server/src/usage/UsageService.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index a3b37c13ccaa4..87adacbd3cea5 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -412,12 +412,7 @@ export const make = Effect.gen(function* () { record.dedupeKey === null ? record : (snapshots.get(record.dedupeKey) ?? record), ); }; - const tailKeys = new Set(selected.tailRecords.map((record) => record.dedupeKey)); - // A matching tail must stay replaceable, even when its key also has a committed row. - const records = reconcile(selected.records, [ - ...older.records, - ...older.tailRecords.filter((record) => !tailKeys.has(record.dedupeKey)), - ]); + const records = reconcile(selected.records, older.records); const tailRecords = reconcile(selected.tailRecords, [ ...older.records, ...older.tailRecords,