| 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 | 2234 | readonly apiErrorStatus?: number; |
| 2235 | 2235 | // null omits the field, as the CLI does on a zero-turn result. |
| 2236 | 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 | 2244 | claudeSdkFrame({ |
| 2239 | 2245 | type: "result", |
| ... | ... | @@ -2245,7 +2251,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 2245 | 2251 | result: input.result, |
| 2246 | 2252 | stop_reason: "end_turn", |
| 2247 | 2253 | total_cost_usd: 0, |
| 2248 | usage: { | |
| 2254 | usage: input.usage ?? { | |
| 2249 | 2255 | input_tokens: 1, |
| 2250 | 2256 | output_tokens: 1, |
| 2251 | 2257 | cache_creation_input_tokens: 0, |
| ... | ... | @@ -2993,6 +2999,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 2993 | 2999 | uuid: "00000000-0000-4000-8000-000000000901", |
| 2994 | 3000 | result: "", |
| 2995 | 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 | 3010 | if (steered) { |
| ... | ... | @@ -3002,6 +3014,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 3002 | 3014 | makeResultFrame({ |
| 3003 | 3015 | uuid: "00000000-0000-4000-8000-000000000902", |
| 3004 | 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 | 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 | 3057 | assert.lengthOf(harness.terminalEvents(), 1); |
| 3021 | 3058 | }).pipe( |
apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts+6| ... | ... | @@ -2918,6 +2918,9 @@ interface ActiveClaudeTurnContext { |
| 2918 | 2918 | readonly rejectedRateLimitTypes: Set<string>; |
| 2919 | 2919 | readonly rateLimitResetTimes: Map<string, string | null>; |
| 2920 | 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 | 2924 | readonly subagentsByTaskId: Map<string, ActiveClaudeSubagent>; |
| 2922 | 2925 | readonly subagentsByToolUseId: Map<string, ActiveClaudeSubagent>; |
| 2923 | 2926 | readonly subagentNodesByTaskId: Map<string, OrchestrationV2ExecutionNode["id"]>; |
| ... | ... | @@ -5922,6 +5925,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( |
| 5922 | 5925 | input.context.subagentsByTaskId.size > 0 || |
| 5923 | 5926 | input.context.subagentsByToolUseId.size > 0, |
| 5924 | 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 | 7664 | const interrupted = (yield* Ref.get(interruptedTurns)).has(context.providerTurnId); |
| 7661 | 7665 | const wasSteered = (yield* Ref.get(steeredTurns)).has(context.providerTurnId); |
| 7662 | 7666 | if (!interrupted && wasSteered && isClaudeActiveSteeringAbortResult(message)) { |
| 7667 | context.steeredSegmentUsage.push(message.usage); | |
| 7663 | 7668 | return; |
| 7664 | 7669 | } |
| 7665 | 7670 | yield* Ref.update(steeredTurns, (current) => { |
| ... | ... | @@ -8568,6 +8573,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( |
| 8568 | 8573 | rejectedRateLimitTypes: new Set(), |
| 8569 | 8574 | rateLimitResetTimes: new Map(), |
| 8570 | 8575 | latestAssistantRateLimited: false, |
| 8576 | steeredSegmentUsage: [], | |
| 8571 | 8577 | subagentsByTaskId: new Map(), |
| 8572 | 8578 | subagentsByToolUseId: new Map(), |
| 8573 | 8579 | subagentNodesByTaskId: new Map(), |
apps/server/src/provider/ClaudeTurnTokenUsage.ts+59-1| ... | ... | @@ -7,12 +7,70 @@ function finiteNonNegativeInteger(value: unknown): number | undefined { |
| 7 | 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 | 66 | export function normalizeClaudeTurnTokenUsage( |
| 11 | 67 | result: { readonly subtype: SDKResultMessage["subtype"]; readonly usage?: unknown } | undefined, |
| 12 | 68 | hasSubagents: boolean, |
| 13 | 69 | terminalStatus: ProviderRuntimeTurnStatus, |
| 70 | earlierSegmentUsage: ReadonlyArray<unknown> = [], | |
| 14 | 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 | 74 | if (!usage) { |
| 17 | 75 | return { |
| 18 | 76 | usageStatus: "unavailable", |