Open History to see the last commit
1import { describe, expect, it } from "vite-plus/test";
2import {
3 claudeLedgerDelta,
4 claudeModelLedger,
5 normalizeClaudeTurnTokenUsage,
6} from "./ClaudeTurnTokenUsage.ts";
7import {
8 accumulateCodexTurnTokenUsage,
9 completeCodexTurnTokenUsage,
10 getCodexTurnAccumulator,
11 makeCodexTurnTokenUsageState,
12} from "./CodexTurnTokenUsage.ts";
13
14const breakdown = (input: number, cached: number, output: number) => ({
15 totalTokens: input + output,
16 inputTokens: input,
17 cachedInputTokens: cached,
18 outputTokens: output,
19 reasoningOutputTokens: 0,
20});
21const update = (total: ReturnType<typeof breakdown>, last = total) => ({
22 total,
23 last,
24 modelContextWindow: 200_000,
25});
26
27describe("billable native turn usage", () => {
28 it("adds each Codex response once and excludes the prior resumed history", () => {
29 const state = makeCodexTurnTokenUsageState();
30 state.activeTurnId = "turn";
31 const first = update(breakdown(1010, 505, 102), breakdown(10, 5, 2));
32 accumulateCodexTurnTokenUsage(state, "turn", first);
33 accumulateCodexTurnTokenUsage(state, "turn", first);
34 accumulateCodexTurnTokenUsage(
35 state,
36 "turn",
37 update(breakdown(1030, 515, 108), breakdown(20, 10, 6)),
38 );
39 expect(completeCodexTurnTokenUsage(state, "turn", true)).toEqual({
40 usageStatus: "complete",
41 usageScope: "main_agent",
42 hasSubagents: false,
43 inputTokens: 30,
44 cachedInputTokens: 15,
45 outputTokens: 8,
46 reasoningTokens: 0,
47 });
48 });
49
50 it("keeps previous responses when compaction resets Codex cumulative counters", () => {
51 const state = makeCodexTurnTokenUsageState();
52 state.activeTurnId = "turn";
53 accumulateCodexTurnTokenUsage(state, "turn", update(breakdown(100, 40, 20)));
54 accumulateCodexTurnTokenUsage(state, "turn", update(breakdown(20, 5, 3)));
55 getCodexTurnAccumulator(state, "turn").hasSubagents = true;
56 expect(completeCodexTurnTokenUsage(state, "turn", false)).toMatchObject({
57 usageStatus: "partial",
58 hasSubagents: true,
59 inputTokens: 120,
60 cachedInputTokens: 45,
61 outputTokens: 23,
62 });
63 });
64
65 it("does not bill late Codex updates against the next active turn", () => {
66 const state = makeCodexTurnTokenUsageState();
67 state.activeTurnId = "next";
68 accumulateCodexTurnTokenUsage(state, "previous", update(breakdown(100, 10, 20)));
69 accumulateCodexTurnTokenUsage(
70 state,
71 "next",
72 update(breakdown(104, 12, 21), breakdown(4, 2, 1)),
73 );
74 expect(completeCodexTurnTokenUsage(state, "next", true)).toMatchObject({
75 inputTokens: 4,
76 outputTokens: 1,
77 });
78 expect(completeCodexTurnTokenUsage(state, "missing", false).usageStatus).toBe("unavailable");
79 });
80
81 it("counts Claude cached input inside total input and bounds thinking output", () => {
82 expect(
83 normalizeClaudeTurnTokenUsage(
84 {
85 subtype: "success",
86 usage: {
87 input_tokens: 100,
88 cache_read_input_tokens: 40,
89 cache_creation_input_tokens: 10,
90 output_tokens: 20,
91 output_tokens_details: { thinking_tokens: 25 },
92 },
93 },
94 true,
95 "completed",
96 ),
97 ).toEqual({
98 usageStatus: "complete",
99 usageScope: "main_agent",
100 hasSubagents: true,
101 inputTokens: 150,
102 cachedInputTokens: 40,
103 cacheCreationTokens: 10,
104 outputTokens: 20,
105 reasoningTokens: 20,
106 });
107 });
108
109 it("does not turn a zeroed Claude crash result into a measured zero-cost turn", () => {
110 expect(
111 normalizeClaudeTurnTokenUsage(
112 {
113 subtype: "error_during_execution",
114 usage: {
115 input_tokens: 0,
116 output_tokens: 0,
117 },
118 },
119 false,
120 "failed",
121 ).usageStatus,
122 ).toBe("unavailable");
123 expect(
124 normalizeClaudeTurnTokenUsage(
125 {
126 subtype: "success",
127 usage: {
128 input_tokens: 4,
129 output_tokens: 2,
130 },
131 },
132 false,
133 "interrupted",
134 ),
135 ).toMatchObject({ usageStatus: "partial", inputTokens: 4, outputTokens: 2 });
136 });
137
138 it.each(["failed", "interrupted", "completed"] as const)(
139 "retains measured Claude steering usage as partial after %s without final usage",
140 (status) => {
141 expect(
142 normalizeClaudeTurnTokenUsage(
143 status === "completed" ? { subtype: "success" } : undefined,
144 true,
145 status,
146 [
147 { input_tokens: 100, cache_read_input_tokens: 40, output_tokens: 20 },
148 { input_tokens: 5, cache_read_input_tokens: 2, output_tokens: 3 },
149 ],
150 ),
151 ).toEqual({
152 usageStatus: "partial",
153 usageScope: "main_agent",
154 hasSubagents: true,
155 inputTokens: 147,
156 cachedInputTokens: 42,
157 outputTokens: 23,
158 });
159 },
160 );
161
162 it.each([undefined, null, {}])(
163 "keeps unavailable Claude usage unavailable without measured segments (%s)",
164 (usage) => {
165 expect(
166 normalizeClaudeTurnTokenUsage({ subtype: "success", usage }, false, "completed")
167 .usageStatus,
168 ).toBe("unavailable");
169 },
170 );
171
172 it("retains measured thinking tokens without a final result", () => {
173 expect(
174 normalizeClaudeTurnTokenUsage(undefined, false, "failed", [
175 { input_tokens: 4, output_tokens: 3, output_tokens_details: { thinking_tokens: 2 } },
176 ]),
177 ).toMatchObject({
178 usageStatus: "partial",
179 inputTokens: 4,
180 outputTokens: 3,
181 reasoningTokens: 2,
182 });
183 });
184
185 it.each([undefined, null])(
186 "retains earlier cache counts when a steering segment reports %s",
187 (cache) => {
188 expect(
189 normalizeClaudeTurnTokenUsage(undefined, false, "failed", [
190 { input_tokens: 100, cache_read_input_tokens: 40, output_tokens: 20 },
191 { input_tokens: 5, cache_read_input_tokens: cache, output_tokens: 3 },
192 ]),
193 ).toMatchObject({
194 usageStatus: "partial",
195 inputTokens: 145,
196 cachedInputTokens: 40,
197 outputTokens: 23,
198 });
199 },
200 );
201
202 it.each(["cache_read_input_tokens", "cache_creation_input_tokens"])(
203 "keeps Claude usage partial when the final %s is invalid",
204 (field) => {
205 expect(
206 normalizeClaudeTurnTokenUsage(
207 { subtype: "success", usage: { input_tokens: 5, output_tokens: 3, [field]: -1 } },
208 false,
209 "completed",
210 [{ input_tokens: 100, output_tokens: 20, [field]: 40 }],
211 ),
212 ).toMatchObject({ usageStatus: "partial", inputTokens: 145, outputTokens: 23 });
213 },
214 );
215
216 it("measures Claude ledger growth only from a known, nondecreasing baseline", () => {
217 const ledger = (output: number) =>
218 claudeModelLedger({ "claude-sonnet-4-6": { inputTokens: 100, outputTokens: output } });
219 expect(claudeLedgerDelta(ledger(10), ledger(25))).toEqual({ inputTokens: 0, outputTokens: 15 });
220 expect(claudeLedgerDelta(ledger(25), ledger(10))).toBeUndefined();
221 expect(claudeLedgerDelta(undefined, ledger(10))).toBeUndefined();
222 expect(claudeModelLedger({})).toBeUndefined();
223 });
224
225 it("adds Claude steering segments to the final measured result once", () => {
226 expect(
227 normalizeClaudeTurnTokenUsage(
228 { subtype: "success", usage: { input_tokens: 5, output_tokens: 3 } },
229 false,
230 "completed",
231 [{ input_tokens: 4, output_tokens: 2 }],
232 ),
233 ).toMatchObject({ usageStatus: "complete", inputTokens: 9, outputTokens: 5 });
234 });
235});