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
5 changes: 3 additions & 2 deletions apps/mobile/src/features/usage/UsageDailyChart.ios.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand All @@ -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],
})),
);

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 @@ -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[];
Expand All @@ -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);

Expand All @@ -28,10 +29,12 @@ export function UsageDailyChart({ days, daily, metric, height }: UsageDailyChart
>
{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
24 changes: 22 additions & 2 deletions apps/mobile/src/features/usage/UsageRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -606,6 +606,11 @@ function ChartCard(props: {
))}
</View>
</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 @@ -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}
</Text>
</View>
);
Expand Down Expand Up @@ -783,8 +791,14 @@ function TotalsSection(props: {
<MetricCell
columns={props.columns}
label="Output"
value={formatTokens(merged.outputTokens)}
detail={`incl. ${formatTokens(merged.reasoningTokens)} reasoning`}
value={formatTokens(merged.outputTokens - merged.reasoningTokens)}
detail="Excludes recorded thinking"
/>
<MetricCell
columns={props.columns}
label="Thinking"
value={formatTokens(merged.reasoningTokens)}
detail="Only separately recorded tokens"
/>
<MetricCell
columns={props.columns}
Expand Down Expand Up @@ -953,6 +967,12 @@ function ModelsSection(props: {
? "Unpriced"
: formatUsd(model.costUsd)}
</Text>
{metric === "tokens" && model.tokens.reasoningTokens > 0 ? (
<Text className="text-xs text-foreground-muted">
{formatTokens(model.tokens.outputTokens - model.tokens.reasoningTokens)} output ·{" "}
{formatTokens(model.tokens.reasoningTokens)} thinking
</Text>
) : null}
</View>
))}
</View>
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 @@ -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;
}

Expand All @@ -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,
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 { useResolveClassNames } from "uniwind";
import { flattenThemeColor, themeColorWithAlpha } from "../../lib/mobileTheme";
import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";

export const PROVIDER_ORDER: readonly UsageProviderKind[] = [
Expand Down Expand Up @@ -42,6 +44,17 @@ export function useProviderColors(): Record<UsageProviderKind, string> {
};
}

export function useProviderThinkingColors(): Record<UsageProviderKind, string> {
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";
Expand Down
20 changes: 20 additions & 0 deletions apps/server/src/provider/Drivers/claudeUsage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ function claudeLine(overrides: {
contentType: string;
model?: string;
outputTokens?: number;
thinkingTokens?: number;
speed?: string;
}): string {
return JSON.stringify({
Expand All @@ -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 }),
},
},
Expand All @@ -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", () => {
Expand Down
18 changes: 10 additions & 8 deletions apps/server/src/provider/Drivers/claudeUsage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "<synthetic>";

/**
* 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 {
Expand Down Expand Up @@ -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<string, unknown>)["thinking_tokens"])
: 0;

const executor: UsageRecord = {
provider: "claude",
Expand All @@ -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",
Expand Down
Loading
Loading