| author | |
| committer | |
| log | 8d11fe1261f0dfd74f58a1b1ae8ad7e400905fcb |
| tree | 720ff59e093d944a257c3af736bd12a6e5a3bf90 |
| parent | f372c0061f7678da94853110da06e821a3af7c83 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | } |
| 9 | 9 | ||
| 10 | const 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. | ||
| 19 | function 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. | ||
| 32 | function 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 | */ | ||
| 10 | export function normalizeClaudeTurnTokenUsage( | 66 | export 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", |