authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-10-14 01:39:52-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-14 01:39:52-07:00
log8d11fe1261f0dfd74f58a1b1ae8ad7e400905fcb
tree720ff59e093d944a257c3af736bd12a6e5a3bf90
parentf372c0061f7678da94853110da06e821a3af7c83
signature Signed by PGP key B5690EEEBB952194

fix(server): steered Claude turns no longer undercount token usage (#19314)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

3 files changed, 103 insertions(+), 2 deletions(-)

apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts+38-1
...@@ -2234,6 +2234,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -2234,6 +2234,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {
2234 readonly apiErrorStatus?: number;2234 readonly apiErrorStatus?: number;
2235 // null omits the field, as the CLI does on a zero-turn result.2235 // null omits the field, as the CLI does on a zero-turn result.
2236 readonly terminalReason?: SDKResultMessage["terminal_reason"] | null;2236 readonly terminalReason?: SDKResultMessage["terminal_reason"] | null;
2237 readonly usage?: {
2238 readonly input_tokens: number;
2239 readonly output_tokens: number;
2240 readonly cache_creation_input_tokens: number;
2241 readonly cache_read_input_tokens: number;
2242 };
2237 }) =>2243 }) =>
2238 claudeSdkFrame({2244 claudeSdkFrame({
2239 type: "result",2245 type: "result",
...@@ -2245,7 +2251,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -2245,7 +2251,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {
2245 result: input.result,2251 result: input.result,
2246 stop_reason: "end_turn",2252 stop_reason: "end_turn",
2247 total_cost_usd: 0,2253 total_cost_usd: 0,
2248 usage: {2254 usage: input.usage ?? {
2249 input_tokens: 1,2255 input_tokens: 1,
2250 output_tokens: 1,2256 output_tokens: 1,
2251 cache_creation_input_tokens: 0,2257 cache_creation_input_tokens: 0,
...@@ -2993,6 +2999,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -2993,6 +2999,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {
2993 uuid: "00000000-0000-4000-8000-000000000901",2999 uuid: "00000000-0000-4000-8000-000000000901",
2994 result: "",3000 result: "",
2995 terminalReason,3001 terminalReason,
3002 usage: {
3003 input_tokens: 600,
3004 output_tokens: 40,
3005 cache_creation_input_tokens: 500,
3006 cache_read_input_tokens: 2000,
3007 },
2996 }),3008 }),
2997 );3009 );
2998 if (steered) {3010 if (steered) {
...@@ -3002,6 +3014,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -3002,6 +3014,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {
3002 makeResultFrame({3014 makeResultFrame({
3003 uuid: "00000000-0000-4000-8000-000000000902",3015 uuid: "00000000-0000-4000-8000-000000000902",
3004 result: "Audit finished after the steer.",3016 result: "Audit finished after the steer.",
3017 usage: {
3018 input_tokens: 3,
3019 output_tokens: 6,
3020 cache_creation_input_tokens: 2347,
3021 cache_read_input_tokens: 9455,
3022 },
3005 }),3023 }),
3006 );3024 );
3007 }3025 }
...@@ -3016,6 +3034,25 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -3016,6 +3034,25 @@ describe("ClaudeAdapterV2 background wake turns", () => {
3016 event.turnItem.text === WAKE_ASSISTANT_TEXT,3034 event.turnItem.text === WAKE_ASSISTANT_TEXT,
3017 ),3035 ),
3018 );3036 );
3037 // Each result's usage covers only its own segment, so the steered
3038 // turn bills both segments.
3039 const completed = harness.events.findLast(
3040 (event) => event.type === "provider_turn.updated",
3041 );
3042 assert.deepEqual(
3043 completed?.type === "provider_turn.updated"
3044 ? completed.providerTurn.turnTokenUsage
3045 : undefined,
3046 {
3047 usageScope: "main_agent",
3048 usageStatus: "complete",
3049 hasSubagents: false,
3050 inputTokens: 603 + 2847 + 11455,
3051 outputTokens: 46,
3052 cachedInputTokens: 11455,
3053 cacheCreationTokens: 2847,
3054 },
3055 );
3019 }3056 }
3020 assert.lengthOf(harness.terminalEvents(), 1);3057 assert.lengthOf(harness.terminalEvents(), 1);
3021 }).pipe(3058 }).pipe(
apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts+6
...@@ -2918,6 +2918,9 @@ interface ActiveClaudeTurnContext {...@@ -2918,6 +2918,9 @@ interface ActiveClaudeTurnContext {
2918 readonly rejectedRateLimitTypes: Set<string>;2918 readonly rejectedRateLimitTypes: Set<string>;
2919 readonly rateLimitResetTimes: Map<string, string | null>;2919 readonly rateLimitResetTimes: Map<string, string | null>;
2920 latestAssistantRateLimited: boolean;2920 latestAssistantRateLimited: boolean;
2921 // Usage of the results that steers ended, oldest first. The final result's
2922 // usage covers only its own segment, so the turn bills these as well.
2923 readonly steeredSegmentUsage: Array<SDKResultMessage["usage"]>;
2921 readonly subagentsByTaskId: Map<string, ActiveClaudeSubagent>;2924 readonly subagentsByTaskId: Map<string, ActiveClaudeSubagent>;
2922 readonly subagentsByToolUseId: Map<string, ActiveClaudeSubagent>;2925 readonly subagentsByToolUseId: Map<string, ActiveClaudeSubagent>;
2923 readonly subagentNodesByTaskId: Map<string, OrchestrationV2ExecutionNode["id"]>;2926 readonly subagentNodesByTaskId: Map<string, OrchestrationV2ExecutionNode["id"]>;
...@@ -5922,6 +5925,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (...@@ -5922,6 +5925,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
5922 input.context.subagentsByTaskId.size > 0 ||5925 input.context.subagentsByTaskId.size > 0 ||
5923 input.context.subagentsByToolUseId.size > 0,5926 input.context.subagentsByToolUseId.size > 0,
5924 input.status,5927 input.status,
5928 input.context.steeredSegmentUsage,
5925 ),5929 ),
5926 },5930 },
5927 }),5931 }),
...@@ -7660,6 +7664,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (...@@ -7660,6 +7664,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
7660 const interrupted = (yield* Ref.get(interruptedTurns)).has(context.providerTurnId);7664 const interrupted = (yield* Ref.get(interruptedTurns)).has(context.providerTurnId);
7661 const wasSteered = (yield* Ref.get(steeredTurns)).has(context.providerTurnId);7665 const wasSteered = (yield* Ref.get(steeredTurns)).has(context.providerTurnId);
7662 if (!interrupted && wasSteered && isClaudeActiveSteeringAbortResult(message)) {7666 if (!interrupted && wasSteered && isClaudeActiveSteeringAbortResult(message)) {
7667 context.steeredSegmentUsage.push(message.usage);
7663 return;7668 return;
7664 }7669 }
7665 yield* Ref.update(steeredTurns, (current) => {7670 yield* Ref.update(steeredTurns, (current) => {
...@@ -8568,6 +8573,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (...@@ -8568,6 +8573,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
8568 rejectedRateLimitTypes: new Set(),8573 rejectedRateLimitTypes: new Set(),
8569 rateLimitResetTimes: new Map(),8574 rateLimitResetTimes: new Map(),
8570 latestAssistantRateLimited: false,8575 latestAssistantRateLimited: false,
8576 steeredSegmentUsage: [],
8571 subagentsByTaskId: new Map(),8577 subagentsByTaskId: new Map(),
8572 subagentsByToolUseId: new Map(),8578 subagentsByToolUseId: new Map(),
8573 subagentNodesByTaskId: new Map(),8579 subagentNodesByTaskId: new Map(),
apps/server/src/provider/ClaudeTurnTokenUsage.ts+59-1
...@@ -7,12 +7,70 @@ function finiteNonNegativeInteger(value: unknown): number | undefined {...@@ -7,12 +7,70 @@ function finiteNonNegativeInteger(value: unknown): number | undefined {
7 : undefined;7 : undefined;
8}8}
99
10const BILLED_USAGE_FIELDS = [
11 "input_tokens",
12 "cache_read_input_tokens",
13 "cache_creation_input_tokens",
14 "output_tokens",
15] as const;
16
17// Adds earlier segments' counts to a count the final result reports. A count
18// the final result lacks stays missing, so status rules see the same fields.
19function addKnownCount(finalCount: unknown, earlierCounts: ReadonlyArray<unknown>): unknown {
20 const known = finiteNonNegativeInteger(finalCount);
21 if (known === undefined) return finalCount;
22 return earlierCounts.reduce<number>(
23 (sum, count) => sum + (finiteNonNegativeInteger(count) ?? 0),
24 known,
25 );
26}
27
28// A steer ends the running segment with an aborted result, and the SDK scopes
29// each result's usage to its own segment. Adds the billed counts of those
30// earlier segments onto the final result's usage. Other usage fields describe
31// the final result only and are left as they are.
32function withEarlierSegmentUsage(
33 usage: Record<string, unknown>,
34 earlierSegmentUsage: ReadonlyArray<unknown>,
35): Record<string, unknown> {
36 if (earlierSegmentUsage.length === 0) return usage;
37 const segments = earlierSegmentUsage.map((segment) => (segment ?? {}) as Record<string, unknown>);
38 const total = { ...usage };
39 for (const field of BILLED_USAGE_FIELDS) {
40 total[field] = addKnownCount(
41 usage[field],
42 segments.map((segment) => segment[field]),
43 );
44 }
45 const outputDetails = usage.output_tokens_details as Record<string, unknown> | undefined;
46 if (outputDetails != null) {
47 total.output_tokens_details = {
48 ...outputDetails,
49 thinking_tokens: addKnownCount(
50 outputDetails.thinking_tokens,
51 segments.map(
52 (segment) =>
53 (segment.output_tokens_details as Record<string, unknown> | undefined)?.thinking_tokens,
54 ),
55 ),
56 };
57 }
58 return total;
59}
60
61/**
62 * Normalizes a Claude turn's billed usage from its final SDK result.
63 * `earlierSegmentUsage` holds the usage of results a steer ended before the
64 * final one, oldest first.
65 */
10export function normalizeClaudeTurnTokenUsage(66export function normalizeClaudeTurnTokenUsage(
11 result: { readonly subtype: SDKResultMessage["subtype"]; readonly usage?: unknown } | undefined,67 result: { readonly subtype: SDKResultMessage["subtype"]; readonly usage?: unknown } | undefined,
12 hasSubagents: boolean,68 hasSubagents: boolean,
13 terminalStatus: ProviderRuntimeTurnStatus,69 terminalStatus: ProviderRuntimeTurnStatus,
70 earlierSegmentUsage: ReadonlyArray<unknown> = [],
14): TurnTokenUsage {71): TurnTokenUsage {
15 const usage = result?.usage as Record<string, unknown> | undefined;72 const finalUsage = result?.usage as Record<string, unknown> | undefined;
73 const usage = finalUsage && withEarlierSegmentUsage(finalUsage, earlierSegmentUsage);
16 if (!usage) {74 if (!usage) {
17 return {75 return {
18 usageStatus: "unavailable",76 usageStatus: "unavailable",