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..56847c13b2220 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: { + + {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 0826adf7587a5..68d141accc0fa 100644 --- a/apps/mobile/src/features/usage/usageChartData.ts +++ b/apps/mobile/src/features/usage/usageChartData.ts @@ -7,7 +7,11 @@ export type UsageChartMetric = "cost" | "tokens"; export interface UsageChartDay { readonly day: string; - readonly values: readonly { readonly provider: UsageProviderKind; readonly value: number }[]; + readonly values: readonly { + readonly provider: UsageProviderKind; + readonly thinking: boolean; + readonly value: number; + }[]; readonly total: number; } @@ -19,10 +23,16 @@ export function buildChartDays( const byDay = new Map(daily.map((totals) => [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..5275911b665fb 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 { useResolveClassNames } from "uniwind"; +import { flattenThemeColor, themeColorWithAlpha } from "../../lib/mobileTheme"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; export const PROVIDER_ORDER: readonly UsageProviderKind[] = [ @@ -42,6 +44,17 @@ export function useProviderColors(): Record { }; } +export function useProviderThinkingColors(): Record { + const colors = useProviderColors(); + 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); + } + return thinking; +} + 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..6e01203384d91 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 }), }, }, @@ -46,6 +50,22 @@ describe("parseClaudeLine", () => { }); expect(record?.dedupeKey).toBe("msg_1:"); 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", () => { 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..fc88a0d33a068 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"; @@ -80,6 +80,7 @@ function claudeLine( outputTokens: number, model = "claude-fable-5", sessionId = "session-1", + thinkingTokens?: number, ): string { return `${JSON.stringify({ type: "assistant", @@ -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`; } @@ -2614,12 +2621,113 @@ 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); + + // 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) + .trimEnd() + .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, + "\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":', ); @@ -2628,6 +2736,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, @@ -2635,9 +2744,25 @@ 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(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, @@ -2768,7 +2893,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 +3020,184 @@ 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 first = yield* UsageService.make; + yield* first.readSummary(WINDOW); + }).pipe(Effect.scoped); + + const legacy = yield* Effect.promise(async () => { + const document = decodeUnknownJsonString(await NodeFSP.readFile(cachePath, "utf8")) as { + files: Record< + string, + { + 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]!; + 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 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); + // 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; + }; + 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, + ); + 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", () => 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..87adacbd3cea5 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, @@ -172,6 +173,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), @@ -385,20 +389,55 @@ export const make = Effect.gen(function* () { Effect.flatMap((raw) => decodeScanCacheFile(raw)), Effect.catchCause(() => Effect.succeed(null)), ); - let document = yield* readDocument(scanCachePath); - for (const fileName of LEGACY_SCAN_CACHE_FILE_NAMES) { - if (document !== null) break; - document = yield* readDocument(path.join(config.stateDir, fileName)); - 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 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 records = reconcile(selected.records, older.records); + 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) { + 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; + } + } + } } }), ); @@ -464,10 +503,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 +520,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 +553,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 +779,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..b47d2f74b75a8 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,24 @@ 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 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).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/server/src/usage/usageScanCache.ts b/apps/server/src/usage/usageScanCache.ts index c9032d64c05bd..d6d4043b5e449 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. + * Import older files too: a downgraded server may have retained additional history. */ -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,54 @@ export function pruneScanCache(cache: ScanCache, retentionCutoffMs: number): num return removed; } -/** Mutates seen; share it across incremental line/tail batches to deduplicate the whole file. */ -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) { if (record.dedupeKey !== null) { - if (seen.has(record.dedupeKey)) continue; - seen.add(record.dedupeKey); + const index = indexes.get(record.dedupeKey); + if (index !== undefined) { + kept[index] = fullerClaudeSnapshot(kept[index]!, record); + continue; + } + 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 { + 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/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} { 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 }], ]), @@ -30,18 +30,33 @@ describe("buildPeriodColumns", () => { ], ]); + 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..96499525283ce 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,38 @@ 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) => { + 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 +263,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 +510,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..0b30eea8eb01d 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,22 @@ const model = ( ...overrides, }); +describe("token types", () => { + 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(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", () => { 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..578fad82f0e25 100644 --- a/apps/web/src/components/usage/usageProviders.ts +++ b/apps/web/src/components/usage/usageProviders.ts @@ -49,6 +49,10 @@ export const PROVIDER_PRESENTATION = { }, } satisfies Record; +export function providerThinkingColor(provider: UsageProviderKind): string { + return `color-mix(in oklab, ${PROVIDER_PRESENTATION[provider].color} 55%, var(--background))`; +} + 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..bda8e2b89c67a 100644 --- a/packages/shared/src/usageMerge.test.ts +++ b/packages/shared/src/usageMerge.test.ts @@ -113,6 +113,34 @@ describe("mergeUsage", () => { expect(merged.duplicateSources).toHaveLength(1); }); + 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( + [ + 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: 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: 70 }); + expect(merged.duplicateSources).toHaveLength(1); + }); + 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,