Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions apps/mobile/src/features/usage/UsageDailyChart.ios.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { useMemo } from "react";
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[];
Expand All @@ -16,24 +16,25 @@ export interface UsageDailyChartProps {

/**
* Native Swift Charts daily bars. Points sharing an x value stack, so emitting
* one point per provider per day yields per-provider bands whose stack height
* is the day's total; changes animate natively.
* provider points with optional thinking points preserves each day's total
* while distinguishing recorded thinking; changes animate natively.
*
* Axes are hidden: 30-90 categorical day labels cannot fit on a phone, so the
* screen renders its own edge labels under the chart instead.
*/
export function UsageDailyChart({ days, daily, metric, height }: UsageDailyChartProps) {
const colors = useProviderColors();
const thinkingColors = useProviderThinkingColors();

const data = useMemo((): ChartDataPoint[] => {
return 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],
})),
);
}, [days, daily, metric, colors]);
}, [days, daily, metric, colors, thinkingColors]);

return (
<Host style={{ height, width: "100%" }}>
Expand Down
9 changes: 6 additions & 3 deletions apps/mobile/src/features/usage/UsageDailyChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,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[];
Expand All @@ -19,6 +19,7 @@ export interface UsageDailyChartProps {
*/
export function UsageDailyChart({ days, daily, metric, height }: UsageDailyChartProps) {
const colors = useProviderColors();
const thinkingColors = useProviderThinkingColors();
const chartDays = useMemo(() => buildChartDays(days, daily, metric), [days, daily, metric]);
const max = chartDays.reduce((peak, day) => Math.max(peak, day.total), 0);

Expand All @@ -30,10 +31,12 @@ export function UsageDailyChart({ days, daily, metric, height }: UsageDailyChart
<View key={day.day} className="h-full flex-1 flex-col-reverse overflow-hidden rounded-sm">
{day.values.map((entry) => (
<View
key={entry.provider}
key={`${entry.provider}:${entry.thinking}`}
style={{
height: max === 0 ? 0 : (entry.value / max) * height,
backgroundColor: colors[entry.provider],
backgroundColor: entry.thinking
? thinkingColors[entry.provider]
: colors[entry.provider],
}}
/>
))}
Expand Down
17 changes: 15 additions & 2 deletions apps/mobile/src/features/usage/UsageRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -613,6 +613,11 @@ function ChartCard(props: {
: formatDayShort(props.untilDay)}
</Text>
</View>
{metric === "tokens" ? (
<Text className="text-xs text-foreground-muted">
Lighter segments show recorded thinking tokens. Unreported thinking stays in output.
</Text>
) : null}
</View>
);
}
Expand Down Expand Up @@ -698,6 +703,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}
</Text>
</View>
);
Expand Down Expand Up @@ -744,8 +752,13 @@ function TotalsSection(props: { readonly merged: MergedUsage; readonly isPast24H
/>
<MetricCell
label="Output"
value={formatTokens(merged.outputTokens)}
detail={`incl. ${formatTokens(merged.reasoningTokens)} reasoning`}
value={formatTokens(merged.outputTokens - merged.reasoningTokens)}
detail="Excludes recorded thinking"
/>
<MetricCell
label="Thinking"
value={formatTokens(merged.reasoningTokens)}
detail="Only separately recorded tokens"
/>
<MetricCell
label="Unpriced"
Expand Down
16 changes: 13 additions & 3 deletions apps/mobile/src/features/usage/usageChartData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,11 @@ 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 value: number }[];
readonly values: readonly {
readonly provider: UsageProviderKind;
readonly thinking: boolean;
readonly value: number;
}[];
readonly total: number;
}

Expand All @@ -27,10 +31,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,
Expand Down
13 changes: 13 additions & 0 deletions apps/mobile/src/features/usage/usageProviders.ts
Original file line number Diff line number Diff line change
@@ -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";

/**
Expand Down Expand Up @@ -39,6 +41,17 @@ export function useProviderColors(): Record<UsageProviderKind, string> {
};
}

/** Flatten the lighter thinking bands against the chart card for native charts. */
export function useProviderThinkingColors(): Record<UsageProviderKind, string> {
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
Expand Down
12 changes: 12 additions & 0 deletions apps/server/src/provider/Drivers/claudeUsage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ function claudeLine(overrides: {
contentType: string;
model?: string;
outputTokens?: number;
thinkingTokens?: number;
speed?: string;
}): string {
return JSON.stringify({
Expand All @@ -25,6 +26,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 }),
},
},
Expand All @@ -49,6 +53,14 @@ describe("parseClaudeLine", () => {
expect(record?.speed).toBe("standard");
});

it("records thinking as part of output", () => {
const line = (thinkingTokens: number) =>
parseClaudeLine(claudeLine({ messageId: "msg_1", contentType: "text", thinkingTokens }));

expect(line(100)?.totals).toMatchObject({ outputTokens: 286, reasoningTokens: 100 });
expect(line(1000)?.totals.reasoningTokens).toBe(286);
});

it("marks fast-mode requests", () => {
const line = (speed: string) =>
parseClaudeLine(claudeLine({ messageId: "msg_1", contentType: "text", speed }));
Expand Down
18 changes: 12 additions & 6 deletions apps/server/src/provider/Drivers/claudeUsage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,9 @@ import {
* Parses one line of a Claude Code transcript.
*
* 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.
* those records repeats the parent message's cumulative `usage`. Summing them
* overcounts by roughly 2.4x on a real workload, so the caller reconciles
* repeats by `dedupeKey`, keeping the fullest snapshot.
*/
export function parseClaudeLine(line: string): UsageRecord | null {
let parsed: unknown;
Expand Down Expand Up @@ -65,6 +65,12 @@ function parseClaudeRecord(parsed: unknown): UsageRecord | null {
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<string, unknown>)["thinking_tokens"])
: 0;

return {
provider: "claude",
Expand All @@ -75,9 +81,9 @@ function parseClaudeRecord(parsed: unknown): UsageRecord | null {
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",
Expand Down
2 changes: 1 addition & 1 deletion apps/server/src/usage/UsageService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1152,7 +1152,7 @@ describe("UsageService", () => {

yield* Effect.gen(function* () {
const { stateDir } = yield* ServerConfig.ServerConfig;
const cachePath = NodePath.join(stateDir, "usage-scan-cache-v5.json");
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);
Expand Down
9 changes: 6 additions & 3 deletions apps/server/src/usage/UsageService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ import {
import {
decodeScanCache,
dedupeWithinFile,
LEGACY_SCAN_CACHE_FILE_NAME,
LEGACY_SCAN_CACHE_FILE_NAMES,
makeScanCacheWriter,
pruneScanCache,
SCAN_CACHE_FILE_NAME,
Expand Down Expand Up @@ -241,7 +241,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 legacyScanCachePath = path.join(config.stateDir, LEGACY_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),
Expand Down Expand Up @@ -429,7 +431,8 @@ export const make = Effect.gen(function* () {
Effect.catchCause(() => Effect.succeed(null)),
);
let document = yield* readDocument(scanCachePath);
if (document === null) {
for (const legacyScanCachePath of legacyScanCachePaths) {
if (document !== null) break;
document = yield* readDocument(legacyScanCachePath);
// Write the migrated cache to its own file on the next scan.
cacheDirty = document !== null;
Expand Down
11 changes: 8 additions & 3 deletions apps/server/src/usage/usageScanCache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -263,15 +263,20 @@ describe("pruneScanCache", () => {
});

describe("dedupeWithinFile", () => {
it("keeps the first record per dedupe key", () => {
it("keeps the first Claude record's attribution with its fullest usage snapshot", () => {
const kept = dedupeWithinFile([
record({ totals: { ...record().totals, outputTokens: 1 } }),
record({ totals: { ...record().totals, outputTokens: 999 } }),
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]?.totals.outputTokens).toBe(1);
expect(kept[0]?.timestampMs).toBe(1_786_000_000_000);
expect(kept[0]?.totals).toMatchObject({ outputTokens: 999, reasoningTokens: 400 });
});

it("keeps every record that has no dedupe key", () => {
Expand Down
41 changes: 32 additions & 9 deletions apps/server/src/usage/usageScanCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,17 +31,21 @@ import { GUARD_LENGTH, type TranscriptParsePosition } from "./usageTranscriptRea
// v4: records carry Claude fast mode, which v3 rows never captured.
// v5: Codex records carry their service tier. v4 rows store speed the same
// way, so v4 entries still load; see `decodeScanCache` for v4 stateful entries.
const USAGE_SCAN_CACHE_VERSION = 5 as const;
// v6: Claude records keep their fullest usage snapshot and recorded thinking.
const USAGE_SCAN_CACHE_VERSION = 6 as const;
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.
* A v5 server reads the legacy (v4) file once, when its own file is missing.
* When its own file is missing, a server reads the newest older file once.
*/
export const SCAN_CACHE_FILE_NAME = "usage-scan-cache-v5.json";
export const LEGACY_SCAN_CACHE_FILE_NAME = "usage-scan-cache.json";
export const SCAN_CACHE_FILE_NAME = "usage-scan-cache-v6.json";
export const LEGACY_SCAN_CACHE_FILE_NAMES = [
"usage-scan-cache-v5.json",
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
"usage-scan-cache.json",
] as const;

/** Serialised as the index into this list. */
const SPEEDS: readonly UsageSpeed[] = ["standard", "fast", "ultrafast"];
Expand Down Expand Up @@ -328,11 +332,12 @@ export function decodeScanCache(
) {
continue;
}
// v4 records of stateful formats (Codex) predate service tiers, so they
// all priced as standard. Keep them, because the rollout may be gone, but
// make a live rollout re-parse whole: no file has size -1, and a zero
// position cannot resume.
const legacy = format.state !== undefined && version < USAGE_SCAN_CACHE_VERSION;
// v4 records of stateful formats (Codex) predate service tiers, and
// pre-v6 Claude records miss later usage snapshots. Keep them, because the
// transcript may be gone, but make a live one re-parse whole: no file has
// size -1, and a zero position cannot resume.
const legacy =
(format.state !== undefined && version < 5) || (entry.p === "claude" && version < 6);
// A corrupt state disqualifies the entry: resuming with it would attach
// appended usage to the wrong model or replay fork-copied history.
if (!legacy && !isValidState.get(entry.p)?.(entry.cs)) continue;
Expand Down Expand Up @@ -404,12 +409,30 @@ export function dedupeWithinFile(
seen: Set<string> = new Set(),
): readonly UsageRecord[] {
const kept: UsageRecord[] = [];
const indexes = new Map<string, number>();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium usage/usageScanCache.ts:412

A fuller Claude snapshot in a valid final JSON line without a newline is discarded, leaving output, thinking, and cost undercounted in the cached result on warm hits. UsageService calls dedupeWithinFile separately for complete-line and tail records with the same seen set, but this function’s replacement index is local to each call, so the tail record is skipped once its key is already in seen. Reconcile tail snapshots with the records kept from the complete-line batch while preserving tail replay semantics.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/usage/usageScanCache.ts around line 412:

A fuller Claude snapshot in a valid final JSON line without a newline is discarded, leaving output, thinking, and cost undercounted in the cached result on warm hits. `UsageService` calls `dedupeWithinFile` separately for complete-line and tail records with the same `seen` set, but this function’s replacement index is local to each call, so the tail record is skipped once its key is already in `seen`. Reconcile tail snapshots with the records kept from the complete-line batch while preserving tail replay semantics.

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;
Comment on lines +435 to +437

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium usage/usageScanCache.ts:435

When two Claude records share a dedupe key and have equal outputTokens, this keeps the first record even if the later record reports more thinkingTokens; for example, 100 output/0 thinking followed by 100/40 returns zero thinking. The condition compares only outputTokens, so include thinkingTokens when selecting the fuller snapshot.

Suggested change
return next.provider === "claude" && next.totals.outputTokens > kept.totals.outputTokens
? { ...kept, totals: next.totals, reportedCostUsd: next.reportedCostUsd }
: kept;
return next.provider === "claude" &&
(next.totals.outputTokens > kept.totals.outputTokens ||
next.totals.thinkingTokens > kept.totals.thinkingTokens)
? { ...kept, totals: next.totals, reportedCostUsd: next.reportedCostUsd }
: kept;
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/usage/usageScanCache.ts around lines 435-437:

When two Claude records share a dedupe key and have equal `outputTokens`, this keeps the first record even if the later record reports more `thinkingTokens`; for example, 100 output/0 thinking followed by 100/40 returns zero thinking. The condition compares only `outputTokens`, so include `thinkingTokens` when selecting the fuller snapshot.

}
Loading
Loading