@@ -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,