Open History to see the last commit
1import type { SDKResultMessage } from "@anthropic-ai/claude-agent-sdk";
2import type { RuntimeTurnState, TurnTokenUsage } from "@t3tools/contracts";
3
4function finiteNonNegativeInteger(value: unknown): number | undefined {
5 return typeof value === "number" && Number.isFinite(value) && value >= 0
6 ? Math.round(value)
7 : undefined;
8}
9
10const BILLED_USAGE_FIELDS = [
11 "input_tokens",
12 "cache_read_input_tokens",
13 "cache_creation_input_tokens",
14 "output_tokens",
15] as const;
16
17function addKnownCount(finalCount: unknown, earlierCounts: ReadonlyArray<unknown>): unknown {
18 const counts = [finalCount, ...earlierCounts]
19 .map(finiteNonNegativeInteger)
20 .filter((count) => count !== undefined);
21 return counts.length === 0 ? finalCount : counts.reduce((sum, count) => sum + count, 0);
22}
23
24// Steering aborts the current segment; each SDK result reports only that segment's usage.
25function withEarlierSegmentUsage(
26 usage: Record<string, unknown>,
27 earlierSegmentUsage: ReadonlyArray<unknown>,
28): Record<string, unknown> {
29 if (earlierSegmentUsage.length === 0) return usage;
30 const segments = earlierSegmentUsage.map((segment) => (segment ?? {}) as Record<string, unknown>);
31 const total = { ...usage };
32 for (const field of BILLED_USAGE_FIELDS) {
33 total[field] = addKnownCount(
34 usage[field],
35 segments.map((segment) => segment[field]),
36 );
37 }
38 const outputDetails = usage.output_tokens_details as Record<string, unknown> | undefined;
39 total.output_tokens_details = {
40 ...outputDetails,
41 thinking_tokens: addKnownCount(
42 outputDetails?.thinking_tokens,
43 segments.map(
44 (segment) =>
45 (segment.output_tokens_details as Record<string, unknown> | undefined)?.thinking_tokens,
46 ),
47 ),
48 };
49 return total;
50}
51
52/** Per-model totals from the SDK's `modelUsage`, which grows for the life of one CLI process. */
53export type ClaudeModelLedger = ReadonlyMap<
54 string,
55 { readonly input: number; readonly output: number }
56>;
57
58export interface ClaudeLedgerUsage {
59 readonly inputTokens: number;
60 readonly outputTokens: number;
61}
62
63/** Unknown when absent or empty: a process that did model work never reports an empty ledger. */
64export function claudeModelLedger(modelUsage: unknown): ClaudeModelLedger | undefined {
65 if (typeof modelUsage !== "object" || modelUsage === null) return undefined;
66 if (Object.keys(modelUsage).length === 0) return undefined;
67 const ledger = new Map<string, { readonly input: number; readonly output: number }>();
68 for (const [model, entry] of Object.entries(modelUsage as Record<string, unknown>)) {
69 const fields = (entry ?? {}) as Record<string, unknown>;
70 const input = finiteNonNegativeInteger(fields.inputTokens);
71 const output = finiteNonNegativeInteger(fields.outputTokens);
72 if (input === undefined || output === undefined) return undefined;
73 const cacheRead = finiteNonNegativeInteger(fields.cacheReadInputTokens) ?? 0;
74 const cacheWrite = finiteNonNegativeInteger(fields.cacheCreationInputTokens) ?? 0;
75 ledger.set(model, { input: input + cacheRead + cacheWrite, output });
76 }
77 return ledger;
78}
79
80/** The window each model in the SDK's `modelUsage` is measured against, as Claude Code resolved it. */
81export function claudeReportedContextWindows(modelUsage: unknown): ReadonlyMap<string, number> {
82 const windows = new Map<string, number>();
83 if (typeof modelUsage !== "object" || modelUsage === null) return windows;
84 for (const [model, entry] of Object.entries(modelUsage as Record<string, unknown>)) {
85 const window = finiteNonNegativeInteger(
86 (entry as Record<string, unknown> | null)?.contextWindow,
87 );
88 if (window !== undefined && window > 0) windows.set(model, window);
89 }
90 return windows;
91}
92
93/** Ledger growth since `previous`. Unknown without a baseline or when a counter goes backwards. */
94export function claudeLedgerDelta(
95 previous: ClaudeModelLedger | undefined,
96 current: ClaudeModelLedger | undefined,
97): ClaudeLedgerUsage | undefined {
98 if (previous === undefined || current === undefined) return undefined;
99 let inputTokens = 0;
100 let outputTokens = 0;
101 for (const [model, before] of previous) {
102 const after = current.get(model);
103 if (after === undefined || after.input < before.input || after.output < before.output) {
104 return undefined;
105 }
106 }
107 for (const [model, after] of current) {
108 const before = previous.get(model);
109 inputTokens += after.input - (before?.input ?? 0);
110 outputTokens += after.output - (before?.output ?? 0);
111 }
112 return { inputTokens, outputTokens };
113}
114
115export function addClaudeLedgerUsage(
116 left: ClaudeLedgerUsage | undefined,
117 right: ClaudeLedgerUsage | undefined,
118): ClaudeLedgerUsage | undefined {
119 return left === undefined || right === undefined
120 ? undefined
121 : {
122 inputTokens: left.inputTokens + right.inputTokens,
123 outputTokens: left.outputTokens + right.outputTokens,
124 };
125}
126
127export interface ClaudeTurnObservations {
128 // Models on the turn's root assistant messages.
129 readonly rootModels?: ReadonlySet<string>;
130 // Ledger growth across every result this turn consumed, including steered segments.
131 readonly ledger?: ClaudeLedgerUsage | undefined;
132}
133
134export function normalizeClaudeTurnTokenUsage(
135 result:
136 | {
137 readonly subtype: SDKResultMessage["subtype"];
138 readonly usage?: unknown;
139 readonly local_command?: unknown;
140 }
141 | undefined,
142 hasSubagents: boolean,
143 terminalStatus: RuntimeTurnState,
144 earlierSegmentUsage: ReadonlyArray<unknown> = [],
145 observed: ClaudeTurnObservations = {},
146): TurnTokenUsage {
147 const observedFields = {
148 ...(observed.rootModels !== undefined && observed.rootModels.size > 0
149 ? { models: [...observed.rootModels] }
150 : {}),
151 ...(typeof result?.local_command === "string" && result.local_command.length > 0
152 ? { localCommand: true }
153 : {}),
154 };
155 const finalUsage = result?.usage as Record<string, unknown> | undefined;
156 const usage = withEarlierSegmentUsage(finalUsage ?? {}, earlierSegmentUsage);
157 const uncachedInputTokens = finiteNonNegativeInteger(usage.input_tokens);
158 const cachedInputTokens = finiteNonNegativeInteger(usage.cache_read_input_tokens);
159 const cacheCreationTokens = finiteNonNegativeInteger(usage.cache_creation_input_tokens);
160 const rawOutputTokens = finiteNonNegativeInteger(usage.output_tokens);
161 const outputDetails = usage.output_tokens_details as Record<string, unknown> | undefined;
162 const thinkingTokens = finiteNonNegativeInteger(outputDetails?.thinking_tokens);
163 const cachedInputContribution = usage.cache_read_input_tokens == null ? 0 : cachedInputTokens;
164 const cacheCreationContribution =
165 usage.cache_creation_input_tokens == null ? 0 : cacheCreationTokens;
166 const inputTokens =
167 uncachedInputTokens !== undefined &&
168 cachedInputContribution !== undefined &&
169 cacheCreationContribution !== undefined
170 ? uncachedInputTokens + cachedInputContribution + cacheCreationContribution
171 : undefined;
172 const hasKnownUsage =
173 uncachedInputTokens !== undefined ||
174 cachedInputTokens !== undefined ||
175 cacheCreationTokens !== undefined ||
176 rawOutputTokens !== undefined;
177 const hasPositiveUsage =
178 (uncachedInputTokens ?? 0) +
179 (cachedInputTokens ?? 0) +
180 (cacheCreationTokens ?? 0) +
181 (rawOutputTokens ?? 0) >
182 0;
183
184 if (!hasKnownUsage || (result?.subtype !== "success" && !hasPositiveUsage)) {
185 return {
186 usageStatus: "unavailable",
187 usageScope: "main_agent",
188 hasSubagents,
189 ...observedFields,
190 };
191 }
192
193 // The result omits subagent and compaction work that the process-wide ledger records.
194 const otherUsage =
195 observed.ledger !== undefined &&
196 inputTokens !== undefined &&
197 rawOutputTokens !== undefined &&
198 observed.ledger.inputTokens >= inputTokens &&
199 observed.ledger.outputTokens >= rawOutputTokens
200 ? {
201 inputTokens: observed.ledger.inputTokens - inputTokens,
202 outputTokens: observed.ledger.outputTokens - rawOutputTokens,
203 }
204 : undefined;
205 const commonUsage = {
206 ...observedFields,
207 ...(otherUsage === undefined ? {} : { otherUsage }),
208 usageScope: "main_agent",
209 ...(cachedInputTokens !== undefined ? { cachedInputTokens } : {}),
210 ...(cacheCreationTokens !== undefined ? { cacheCreationTokens } : {}),
211 ...(thinkingTokens !== undefined && rawOutputTokens !== undefined
212 ? { reasoningTokens: Math.min(rawOutputTokens, thinkingTokens) }
213 : {}),
214 hasSubagents,
215 } as const;
216 if (
217 terminalStatus === "completed" &&
218 result?.subtype === "success" &&
219 finiteNonNegativeInteger(finalUsage?.input_tokens) !== undefined &&
220 finiteNonNegativeInteger(finalUsage?.output_tokens) !== undefined &&
221 inputTokens !== undefined &&
222 rawOutputTokens !== undefined &&
223 BILLED_USAGE_FIELDS.every(
224 (field) =>
225 finalUsage?.[field] == null || finiteNonNegativeInteger(finalUsage[field]) !== undefined,
226 )
227 ) {
228 return {
229 ...commonUsage,
230 usageStatus: "complete",
231 inputTokens,
232 outputTokens: rawOutputTokens,
233 };
234 }
235 return {
236 ...commonUsage,
237 usageStatus: "partial",
238 ...(inputTokens !== undefined ? { inputTokens } : {}),
239 ...(rawOutputTokens !== undefined ? { outputTokens: rawOutputTokens } : {}),
240 };
241}