authorgravatar for ashvin.nihalani@gmail.comAshvin Nihalani <ashvin.nihalani@gmail.com> 2026-04-12 01:03:36-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-04-12 01:03:36-07:00
log7a008461fc48defe1481822021d9c8c757c24b2a
treebff4e757e02708c543e19fa5c94a95c22bff3fac
parentf59ee36bf17ec9c8e0808c0e5aada497ae80831b
signature Signed by PGP key B5690EEEBB952194

fix: Align token usage metrics for both Claude and Codex (#1943)

Co-authored-by: Julius Marminge <julius0216@outlook.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

2 files changed, 191 insertions(+), 48 deletions(-)

apps/server/src/provider/Layers/ClaudeAdapter.test.ts+144
...@@ -1408,6 +1408,150 @@ describe("ClaudeAdapterLive", () => {...@@ -1408,6 +1408,150 @@ describe("ClaudeAdapterLive", () => {
1408 );1408 );
1409 });1409 });
14101410
1411 it.effect("clamps oversized Claude usage to the reported context window", () => {
1412 const harness = makeHarness();
1413 return Effect.gen(function* () {
1414 const adapter = yield* ClaudeAdapter;
1415
1416 const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 7).pipe(
1417 Stream.runCollect,
1418 Effect.forkChild,
1419 );
1420
1421 yield* adapter.startSession({
1422 threadId: THREAD_ID,
1423 provider: "claudeAgent",
1424 runtimeMode: "full-access",
1425 });
1426
1427 yield* adapter.sendTurn({
1428 threadId: THREAD_ID,
1429 input: "hello",
1430 attachments: [],
1431 });
1432
1433 harness.query.emit({
1434 type: "result",
1435 subtype: "success",
1436 is_error: false,
1437 duration_ms: 1234,
1438 duration_api_ms: 1200,
1439 num_turns: 1,
1440 result: "done",
1441 stop_reason: "end_turn",
1442 session_id: "sdk-session-result-usage-clamped",
1443 usage: {
1444 total_tokens: 535000,
1445 },
1446 modelUsage: {
1447 "claude-opus-4-6": {
1448 contextWindow: 200000,
1449 maxOutputTokens: 64000,
1450 },
1451 },
1452 } as unknown as SDKMessage);
1453 harness.query.finish();
1454
1455 const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber));
1456 const usageEvent = runtimeEvents.find((event) => event.type === "thread.token-usage.updated");
1457 assert.equal(usageEvent?.type, "thread.token-usage.updated");
1458 if (usageEvent?.type === "thread.token-usage.updated") {
1459 assert.deepEqual(usageEvent.payload, {
1460 usage: {
1461 usedTokens: 200000,
1462 lastUsedTokens: 200000,
1463 totalProcessedTokens: 535000,
1464 maxTokens: 200000,
1465 },
1466 });
1467 }
1468 }).pipe(
1469 Effect.provideService(Random.Random, makeDeterministicRandomService()),
1470 Effect.provide(harness.layer),
1471 );
1472 });
1473
1474 it.effect(
1475 "preserves oversized Claude result totals after task progress snapshots are recorded",
1476 () => {
1477 const harness = makeHarness();
1478 return Effect.gen(function* () {
1479 const adapter = yield* ClaudeAdapter;
1480
1481 const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 9).pipe(
1482 Stream.runCollect,
1483 Effect.forkChild,
1484 );
1485
1486 yield* adapter.startSession({
1487 threadId: THREAD_ID,
1488 provider: "claudeAgent",
1489 runtimeMode: "full-access",
1490 });
1491
1492 yield* adapter.sendTurn({
1493 threadId: THREAD_ID,
1494 input: "hello",
1495 attachments: [],
1496 });
1497
1498 harness.query.emit({
1499 type: "system",
1500 subtype: "task_progress",
1501 task_id: "task-usage-clamped",
1502 description: "Thinking through the patch",
1503 usage: {
1504 total_tokens: 190000,
1505 },
1506 session_id: "sdk-session-task-usage-clamped",
1507 uuid: "task-usage-progress-clamped",
1508 } as unknown as SDKMessage);
1509
1510 harness.query.emit({
1511 type: "result",
1512 subtype: "success",
1513 is_error: false,
1514 duration_ms: 1234,
1515 duration_api_ms: 1200,
1516 num_turns: 1,
1517 result: "done",
1518 stop_reason: "end_turn",
1519 session_id: "sdk-session-result-usage-clamped-after-progress",
1520 usage: {
1521 total_tokens: 535000,
1522 },
1523 modelUsage: {
1524 "claude-opus-4-6": {
1525 contextWindow: 200000,
1526 maxOutputTokens: 64000,
1527 },
1528 },
1529 } as unknown as SDKMessage);
1530 harness.query.finish();
1531
1532 const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber));
1533 const usageEvents = runtimeEvents.filter(
1534 (event) => event.type === "thread.token-usage.updated",
1535 );
1536 const finalUsageEvent = usageEvents.at(-1);
1537 assert.equal(finalUsageEvent?.type, "thread.token-usage.updated");
1538 if (finalUsageEvent?.type === "thread.token-usage.updated") {
1539 assert.deepEqual(finalUsageEvent.payload, {
1540 usage: {
1541 usedTokens: 190000,
1542 lastUsedTokens: 190000,
1543 totalProcessedTokens: 535000,
1544 maxTokens: 200000,
1545 },
1546 });
1547 }
1548 }).pipe(
1549 Effect.provideService(Random.Random, makeDeterministicRandomService()),
1550 Effect.provide(harness.layer),
1551 );
1552 },
1553 );
1554
1411 it.effect(1555 it.effect(
1412 "emits completion only after turn result when assistant frames arrive before deltas",1556 "emits completion only after turn result when assistant frames arrive before deltas",
1413 () => {1557 () => {
apps/server/src/provider/Layers/ClaudeAdapter.ts+47-48
...@@ -17,6 +17,8 @@ import {...@@ -17,6 +17,8 @@ import {
17 type SDKResultMessage,17 type SDKResultMessage,
18 type SettingSource,18 type SettingSource,
19 type SDKUserMessage,19 type SDKUserMessage,
20 ModelUsage,
21 NonNullableUsage,
20} from "@anthropic-ai/claude-agent-sdk";22} from "@anthropic-ai/claude-agent-sdk";
21import {23import {
22 ApprovalRequestId,24 ApprovalRequestId,
...@@ -272,24 +274,14 @@ function asRuntimeItemId(value: string): RuntimeItemId {...@@ -272,24 +274,14 @@ function asRuntimeItemId(value: string): RuntimeItemId {
272 return RuntimeItemId.make(value);274 return RuntimeItemId.make(value);
273}275}
274276
275function maxClaudeContextWindowFromModelUsage(modelUsage: unknown): number | undefined {277function maxClaudeContextWindowFromModelUsage(
276 if (!modelUsage || typeof modelUsage !== "object") {278 modelUsage: Record<string, ModelUsage> | undefined,
277 return undefined;279): number | undefined {
278 }280 if (!modelUsage) return undefined;
279281
280 let maxContextWindow: number | undefined;282 let maxContextWindow: number | undefined;
281 for (const value of Object.values(modelUsage as Record<string, unknown>)) {283 for (const value of Object.values(modelUsage)) {
282 if (!value || typeof value !== "object") {284 const contextWindow = value.contextWindow;
283 continue;
284 }
285 const contextWindow = (value as { contextWindow?: unknown }).contextWindow;
286 if (
287 typeof contextWindow !== "number" ||
288 !Number.isFinite(contextWindow) ||
289 contextWindow <= 0
290 ) {
291 continue;
292 }
293 maxContextWindow = Math.max(maxContextWindow ?? 0, contextWindow);285 maxContextWindow = Math.max(maxContextWindow ?? 0, contextWindow);
294 }286 }
295287
...@@ -297,53 +289,58 @@ function maxClaudeContextWindowFromModelUsage(modelUsage: unknown): number | und...@@ -297,53 +289,58 @@ function maxClaudeContextWindowFromModelUsage(modelUsage: unknown): number | und
297}289}
298290
299function normalizeClaudeTokenUsage(291function normalizeClaudeTokenUsage(
300 usage: unknown,292 value: NonNullableUsage | undefined,
301 contextWindow?: number,293 contextWindow?: number,
302): ThreadTokenUsageSnapshot | undefined {294): ThreadTokenUsageSnapshot | undefined {
303 if (!usage || typeof usage !== "object") {295 if (!value || typeof value !== "object") {
304 return undefined;296 return undefined;
305 }297 }
306298
307 const record = usage as Record<string, unknown>;299 const usage = value as Record<string, unknown>;
308 const directUsedTokens =
309 typeof record.total_tokens === "number" && Number.isFinite(record.total_tokens)
310 ? record.total_tokens
311 : undefined;
312 const inputTokens =300 const inputTokens =
313 (typeof record.input_tokens === "number" && Number.isFinite(record.input_tokens)301 (typeof usage.input_tokens === "number" && Number.isFinite(usage.input_tokens)
314 ? record.input_tokens302 ? usage.input_tokens
315 : 0) +303 : 0) +
316 (typeof record.cache_creation_input_tokens === "number" &&304 (typeof usage.cache_creation_input_tokens === "number" &&
317 Number.isFinite(record.cache_creation_input_tokens)305 Number.isFinite(usage.cache_creation_input_tokens)
318 ? record.cache_creation_input_tokens306 ? usage.cache_creation_input_tokens
319 : 0) +307 : 0) +
320 (typeof record.cache_read_input_tokens === "number" &&308 (typeof usage.cache_read_input_tokens === "number" &&
321 Number.isFinite(record.cache_read_input_tokens)309 Number.isFinite(usage.cache_read_input_tokens)
322 ? record.cache_read_input_tokens310 ? usage.cache_read_input_tokens
323 : 0);311 : 0);
324 const outputTokens =312 const outputTokens =
325 typeof record.output_tokens === "number" && Number.isFinite(record.output_tokens)313 typeof usage.output_tokens === "number" && Number.isFinite(usage.output_tokens)
326 ? record.output_tokens314 ? usage.output_tokens
327 : 0;315 : 0;
328 const derivedUsedTokens = inputTokens + outputTokens;316 const derivedTotalProcessedTokens = inputTokens + outputTokens;
329 const usedTokens = directUsedTokens ?? (derivedUsedTokens > 0 ? derivedUsedTokens : undefined);317 const totalProcessedTokens =
330 if (usedTokens === undefined || usedTokens <= 0) {318 (typeof usage.total_tokens === "number" && Number.isFinite(usage.total_tokens)
319 ? usage.total_tokens
320 : undefined) ?? (derivedTotalProcessedTokens > 0 ? derivedTotalProcessedTokens : undefined);
321 if (totalProcessedTokens === undefined || totalProcessedTokens <= 0) {
331 return undefined;322 return undefined;
332 }323 }
333324
325 const maxTokens =
326 typeof contextWindow === "number" && Number.isFinite(contextWindow) && contextWindow > 0
327 ? contextWindow
328 : undefined;
329 const usedTokens =
330 maxTokens !== undefined ? Math.min(totalProcessedTokens, maxTokens) : totalProcessedTokens;
331
334 return {332 return {
335 usedTokens,333 usedTokens,
336 lastUsedTokens: usedTokens,334 lastUsedTokens: usedTokens,
335 ...(totalProcessedTokens > usedTokens ? { totalProcessedTokens } : {}),
337 ...(inputTokens > 0 ? { inputTokens } : {}),336 ...(inputTokens > 0 ? { inputTokens } : {}),
338 ...(outputTokens > 0 ? { outputTokens } : {}),337 ...(outputTokens > 0 ? { outputTokens } : {}),
339 ...(typeof contextWindow === "number" && Number.isFinite(contextWindow) && contextWindow > 0338 ...(maxTokens !== undefined ? { maxTokens } : {}),
340 ? { maxTokens: contextWindow }339 ...(typeof usage.tool_uses === "number" && Number.isFinite(usage.tool_uses)
341 : {}),340 ? { toolUses: usage.tool_uses }
342 ...(typeof record.tool_uses === "number" && Number.isFinite(record.tool_uses)
343 ? { toolUses: record.tool_uses }
344 : {}),341 : {}),
345 ...(typeof record.duration_ms === "number" && Number.isFinite(record.duration_ms)342 ...(typeof usage.duration_ms === "number" && Number.isFinite(usage.duration_ms)
346 ? { durationMs: record.duration_ms }343 ? { durationMs: usage.duration_ms }
347 : {}),344 : {}),
348 };345 };
349}346}
...@@ -1328,8 +1325,6 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (...@@ -1328,8 +1325,6 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
1328 errorMessage?: string,1325 errorMessage?: string,
1329 result?: SDKResultMessage,1326 result?: SDKResultMessage,
1330 ) {1327 ) {
1331 const resultUsage =
1332 result?.usage && typeof result.usage === "object" ? { ...result.usage } : undefined;
1333 const resultContextWindow = maxClaudeContextWindowFromModelUsage(result?.modelUsage);1328 const resultContextWindow = maxClaudeContextWindowFromModelUsage(result?.modelUsage);
1334 if (resultContextWindow !== undefined) {1329 if (resultContextWindow !== undefined) {
1335 context.lastKnownContextWindow = resultContextWindow;1330 context.lastKnownContextWindow = resultContextWindow;
...@@ -1341,9 +1336,11 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (...@@ -1341,9 +1336,11 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
1341 // Instead, use the last known context-window-accurate usage from task_progress1336 // Instead, use the last known context-window-accurate usage from task_progress
1342 // events and treat the accumulated total as totalProcessedTokens.1337 // events and treat the accumulated total as totalProcessedTokens.
1343 const accumulatedSnapshot = normalizeClaudeTokenUsage(1338 const accumulatedSnapshot = normalizeClaudeTokenUsage(
1344 resultUsage,1339 result?.usage,
1345 resultContextWindow ?? context.lastKnownContextWindow,1340 resultContextWindow ?? context.lastKnownContextWindow,
1346 );1341 );
1342 const accumulatedTotalProcessedTokens =
1343 accumulatedSnapshot?.totalProcessedTokens ?? accumulatedSnapshot?.usedTokens;
1347 const lastGoodUsage = context.lastKnownTokenUsage;1344 const lastGoodUsage = context.lastKnownTokenUsage;
1348 const maxTokens = resultContextWindow ?? context.lastKnownContextWindow;1345 const maxTokens = resultContextWindow ?? context.lastKnownContextWindow;
1349 const usageSnapshot: ThreadTokenUsageSnapshot | undefined = lastGoodUsage1346 const usageSnapshot: ThreadTokenUsageSnapshot | undefined = lastGoodUsage
...@@ -1352,8 +1349,10 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (...@@ -1352,8 +1349,10 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
1352 ...(typeof maxTokens === "number" && Number.isFinite(maxTokens) && maxTokens > 01349 ...(typeof maxTokens === "number" && Number.isFinite(maxTokens) && maxTokens > 0
1353 ? { maxTokens }1350 ? { maxTokens }
1354 : {}),1351 : {}),
1355 ...(accumulatedSnapshot && accumulatedSnapshot.usedTokens > lastGoodUsage.usedTokens1352 ...(typeof accumulatedTotalProcessedTokens === "number" &&
1356 ? { totalProcessedTokens: accumulatedSnapshot.usedTokens }1353 Number.isFinite(accumulatedTotalProcessedTokens) &&
1354 accumulatedTotalProcessedTokens > lastGoodUsage.usedTokens
1355 ? { totalProcessedTokens: accumulatedTotalProcessedTokens }
1357 : {}),1356 : {}),
1358 }1357 }
1359 : accumulatedSnapshot;1358 : accumulatedSnapshot;