From 82d619a877e512b9b5b02bd1df1146cbadf5135e Mon Sep 17 00:00:00 2001 From: Chunhao Zhang <1226577187zch@gmail.com> Date: Tue, 3 Nov 2026 17:07:32 +0800 Subject: [PATCH 1/2] fix(server): Claude context meter shows the window for models outside the catalog Custom and gateway Claude models had no context window: the adapter only read the bundled catalog, so the meter showed used tokens with no limit or percentage. Claude Code already reports the window it compacts against in each result's `modelUsage[model].contextWindow`. The adapter now records that reported window per model and uses it when the catalog has no entry, for live usage, compaction updates, and `getModelContextWindow`. When a result first reveals a window, the turn's last usage is re-measured so the meter fills in before the turn settles. Catalog models are unchanged. Claude Opus 5.5 (MiniMax Code) --- .../Adapters/ClaudeAdapterV2.test.ts | 66 ++++++++++++++++++- .../Adapters/ClaudeAdapterV2.ts | 32 +++++---- .../src/provider/ClaudeTurnTokenUsage.ts | 13 ++++ 3 files changed, 98 insertions(+), 13 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 45d21b5944e24..d0f9caeb7edc4 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -738,7 +738,7 @@ describe("ClaudeAdapterV2 context usage", () => { output_tokens: 1, cache_creation: { ephemeral_1h_input_tokens: 0, ephemeral_5m_input_tokens: 300 }, }, - CLAUDE_TEST_MODEL_SELECTION, + null, "2026-08-29T00:03:00.000Z", "", DateTime.makeUnsafe("2026-08-29T00:00:00.000Z"), @@ -2990,6 +2990,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { readonly cache_creation_input_tokens: number; readonly cache_read_input_tokens: number; }; + readonly modelUsage?: SDKResultMessage["modelUsage"]; }) => claudeSdkFrame({ type: "result", @@ -3007,7 +3008,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { cache_creation_input_tokens: 0, cache_read_input_tokens: 0, }, - modelUsage: {}, + modelUsage: input.modelUsage ?? {}, permission_denials: [], uuid: input.uuid, session_id: WAKE_NATIVE_SESSION, @@ -7117,6 +7118,67 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); + it.effect("measures a model outside the catalog against the window Claude reports", () => + Effect.gen(function* () { + const harness = yield* makeWakeHarness; + const modelSelection = { + instanceId: ProviderInstanceId.make(ClaudeAdapterV2.CLAUDE_PROVIDER), + model: "gateway-model", + } satisfies ModelSelection; + assert.strictEqual(harness.runtime.getModelContextWindow?.(modelSelection), undefined); + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("attempt-reported-context-window"), + text: "Continue.", + attachments: [], + modelSelection, + }), + ); + const usage = { ...zeroUsage, input_tokens: 9_667, output_tokens: 2 }; + const frames = [ + usageStreamFrame({ type: "message_start", message: { id: "msg_gateway", usage } }), + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000680", + result: "OK", + modelUsage: { + "gateway-model": { + inputTokens: 9_667, + outputTokens: 2, + cacheReadInputTokens: 0, + cacheCreationInputTokens: 0, + webSearchRequests: 0, + costUSD: 0, + contextWindow: 1_048_576, + maxOutputTokens: 32_000, + }, + }, + }), + ]; + for (const frame of frames) yield* Queue.offer(harness.sdkMessages, frame); + const terminal = yield* Queue.take(harness.terminalReceipts); + assert.equal(terminal.status, "completed"); + const windows = harness.events.flatMap((event) => + event.type === "provider_turn.updated" && event.providerTurn.tokenUsage + ? [event.providerTurn.tokenUsage.maxTokens] + : [], + ); + assert.deepEqual(windows, [null, 1_048_576]); + assert.strictEqual(harness.runtime.getModelContextWindow?.(modelSelection), 1_048_576); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), + ), + ), + ); + it.effect("merges cumulative stream usage into the message_start counts", () => Effect.gen(function* () { const harness = yield* makeWakeHarnessWithOptions({ diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 674f55c524d75..26b490e39f9a5 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -25,6 +25,7 @@ import { addClaudeLedgerUsage, claudeLedgerDelta, claudeModelLedger, + claudeReportedContextWindows, normalizeClaudeTurnTokenUsage, type ClaudeLedgerUsage, type ClaudeModelLedger, @@ -221,12 +222,6 @@ import { export const CLAUDE_PROVIDER = ProviderDriverKind.make("claudeAgent"); const CLAUDE_AGENT_SDK_QUERY_PROTOCOL = "claude-agent-sdk.query" as const; -function claudeContextWindow(modelSelection: ModelSelection): number | null { - return ( - resolveClaudeCatalogContextWindowTokens(BUNDLED_CLAUDE_MODEL_CATALOG, modelSelection) ?? null - ); -} - interface ClaudeCacheCreation { readonly ephemeral_1h_input_tokens: number; readonly ephemeral_5m_input_tokens: number; @@ -273,7 +268,7 @@ export function claudeProviderTurnTokenUsage( readonly cache_creation?: ClaudeCacheCreation | null; readonly output_tokens: number; }, - modelSelection: ModelSelection, + maxTokens: number | null, updatedAt: string, autoCompactWindow = "", requestedAt?: DateTime.Utc, @@ -281,7 +276,6 @@ export function claudeProviderTurnTokenUsage( const inputTokens = claudeMessageInputTokens(usage); const outputTokens = usage.output_tokens; const promptCacheTtlMs = claudePromptCacheTtlMs(usage.cache_creation); - const maxTokens = claudeContextWindow(modelSelection); // The cache lifetime starts with the request, not when its response ends. const promptCacheExpiresAt = promptCacheTtlMs === undefined || requestedAt === undefined @@ -4620,6 +4614,12 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( BUNDLED_CLAUDE_MODEL_CATALOG, adapterOptions.settings.customModels, ); + // Results report the window Claude Code compacts against; outside the catalog it is the only source. + const reportedContextWindows = new Map(); + const claudeContextWindow = (modelSelection: ModelSelection): number | null => + resolveClaudeCatalogContextWindowTokens(BUNDLED_CLAUDE_MODEL_CATALOG, modelSelection) ?? + reportedContextWindows.get(compileClaudeModelSelection(modelSelection).apiModelId) ?? + null; const continuationRequests = adapterOptions.continuationRequests ?? { offer: () => Effect.void, }; @@ -4852,6 +4852,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( const queryContext = yield* Ref.make(null); // Wake buffers replay result frames, so ledger growth is measured once on arrival. const resultLedgerUsage = new WeakMap(); + const resultsReportingNewContextWindow = new WeakSet(); // A worktree handoff changes the project settings even within one session. let bypassCache: { cwd: string | null; availability: ClaudeBypassAvailability } | null = null; @@ -5147,7 +5148,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( if (requestStarted && context.streamUsage !== null) context.streamUsage.requestedAt = now; const tokenUsage = claudeProviderTurnTokenUsage( usage, - context.input.modelSelection, + claudeContextWindow(context.input.modelSelection), DateTime.formatIso(now), adapterOptions.settings.autoCompactWindow, context.streamUsage?.requestedAt, @@ -9584,6 +9585,9 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( } if (message.type === "result") { + if (resultsReportingNewContextWindow.has(message) && context.streamUsage?.usage) { + yield* emitContextUsage(context, context.streamUsage.usage); + } const completedAt = yield* DateTime.now; const interrupted = (yield* Ref.get(interruptedTurns)).has(context.providerTurnId); const wasSteered = (yield* Ref.get(steeredTurns)).has(context.providerTurnId); @@ -10646,6 +10650,13 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( growth === undefined && context.modelLedger !== undefined ? undefined : ledger; + for (const [model, window] of claudeReportedContextWindows( + message.modelUsage, + )) { + if (reportedContextWindows.get(model) === window) continue; + reportedContextWindows.set(model, window); + resultsReportingNewContextWindow.add(message); + } } return handleSdkMessage({ query: querySession, message }); }), @@ -11348,8 +11359,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( driver: CLAUDE_PROVIDER, providerSessionId: input.providerSessionId, providerSession: session, - getModelContextWindow: (selection) => - resolveClaudeCatalogContextWindowTokens(BUNDLED_CLAUDE_MODEL_CATALOG, selection), + getModelContextWindow: (selection) => claudeContextWindow(selection) ?? undefined, events: Stream.fromEffectRepeat(Queue.take(events)), backgroundTasksEndWithProcess: true, hasPendingBackgroundWork: Effect.gen(function* () { diff --git a/apps/server/src/provider/ClaudeTurnTokenUsage.ts b/apps/server/src/provider/ClaudeTurnTokenUsage.ts index 2fa6a11bac1e5..118716c55c26d 100644 --- a/apps/server/src/provider/ClaudeTurnTokenUsage.ts +++ b/apps/server/src/provider/ClaudeTurnTokenUsage.ts @@ -77,6 +77,19 @@ export function claudeModelLedger(modelUsage: unknown): ClaudeModelLedger | unde return ledger; } +/** The window each model in the SDK's `modelUsage` is measured against, as Claude Code resolved it. */ +export function claudeReportedContextWindows(modelUsage: unknown): ReadonlyMap { + const windows = new Map(); + if (typeof modelUsage !== "object" || modelUsage === null) return windows; + for (const [model, entry] of Object.entries(modelUsage as Record)) { + const window = finiteNonNegativeInteger( + (entry as Record | null)?.contextWindow, + ); + if (window !== undefined && window > 0) windows.set(model, window); + } + return windows; +} + /** Ledger growth since `previous`. Unknown without a baseline or when a counter goes backwards. */ export function claudeLedgerDelta( previous: ClaudeModelLedger | undefined, From f508bc0b037d94653c987ef7466483b756fca3d9 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Tue, 3 Nov 2026 10:21:11 +0000 Subject: [PATCH 2/2] fix(server): refresh reported context limits after compaction --- .../Adapters/ClaudeAdapterV2.test.ts | 23 ++++++ .../Adapters/ClaudeAdapterV2.ts | 74 ++++++++----------- 2 files changed, 54 insertions(+), 43 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index d0f9caeb7edc4..2568a3e6d197a 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -7420,6 +7420,10 @@ describe("ClaudeAdapterV2 background wake turns", () => { providerThread: harness.providerThread, now, attemptId: RunAttemptId.make("claude-native-compact-attempt"), + modelSelection: { + instanceId: ProviderInstanceId.make(ClaudeAdapterV2.CLAUDE_PROVIDER), + model: "gateway-model", + }, text: " /COMPACT ", attachments: [], }), @@ -7439,6 +7443,18 @@ describe("ClaudeAdapterV2 background wake turns", () => { makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000202", result: "Compacted conversation.", + modelUsage: { + "gateway-model": { + inputTokens: 1, + outputTokens: 1, + cacheReadInputTokens: 0, + cacheCreationInputTokens: 0, + webSearchRequests: 0, + costUSD: 0, + contextWindow: 200_000, + maxOutputTokens: 32_000, + }, + }, }), ); yield* Queue.take(harness.terminalReceipts); @@ -7459,6 +7475,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { event.providerTurn.tokenUsage?.usedTokens === 400, ); assert.isDefined(watermark); + const windows = harness.events.flatMap((event) => + event.type === "provider_turn.updated" && event.providerTurn.tokenUsage + ? [event.providerTurn.tokenUsage.maxTokens] + : [], + ); + assert.deepEqual(windows, [null, 200_000]); const completed = harness.events.findLast( (event) => event.type === "provider_turn.updated", ); @@ -7472,6 +7494,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { outputTokens: 1, cachedInputTokens: 0, cacheCreationTokens: 0, + otherUsage: { inputTokens: 0, outputTokens: 0 }, }); } }).pipe( diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 26b490e39f9a5..fd0a530549cb8 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -4116,6 +4116,7 @@ interface ActiveClaudeTurnContext { requestedAt?: DateTime.Utc; usage: ClaudeMessageUsage | null; } | null; + compactionTokens: number | undefined; readonly toolCalls: Map; // A successful status can precede the boundary that supplies its token counts. compaction: Extract | null; @@ -5139,21 +5140,36 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( // Usage without input is a placeholder, not a context measurement. const emitContextUsage = Effect.fnUntraced(function* ( context: ActiveClaudeTurnContext, - usage: ClaudeMessageUsage, + usage: ClaudeMessageUsage | number, requestStarted = false, ) { const now = yield* DateTime.now; // Only message_start marks the request start; an assistant snapshot // arrives after generation, so it leaves the cache expiry unknown. if (requestStarted && context.streamUsage !== null) context.streamUsage.requestedAt = now; - const tokenUsage = claudeProviderTurnTokenUsage( - usage, - claudeContextWindow(context.input.modelSelection), - DateTime.formatIso(now), - adapterOptions.settings.autoCompactWindow, - context.streamUsage?.requestedAt, - ); - if (tokenUsage.inputTokens <= 0) return; + const maxTokens = claudeContextWindow(context.input.modelSelection); + const tokenUsage = + typeof usage === "number" + ? { + usedTokens: usage, + maxTokens, + ...claudeAutoCompactThreshold( + adapterOptions.settings.autoCompactWindow, + maxTokens, + ), + updatedAt: DateTime.formatIso(now), + } + : claudeProviderTurnTokenUsage( + usage, + maxTokens, + DateTime.formatIso(now), + adapterOptions.settings.autoCompactWindow, + context.streamUsage?.requestedAt, + ); + if (typeof usage !== "number") { + if (claudeMessageInputTokens(usage) <= 0) return; + context.compactionTokens = undefined; + } yield* emitProviderEvent({ type: "provider_turn.updated", driver: CLAUDE_PROVIDER, @@ -8828,38 +8844,8 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( typeof postTokens === "number" && Number.isFinite(postTokens) && postTokens > 0 ? Math.round(postTokens) : undefined; - if (afterTokenCount !== undefined) { - const maxTokens = claudeContextWindow(context.input.modelSelection); - yield* emitProviderEvent({ - type: "provider_turn.updated", - driver: CLAUDE_PROVIDER, - threadId: context.input.threadId, - providerTurn: { - id: context.providerTurnId, - providerThreadId: context.input.providerThread.id, - nodeId: context.input.rootNodeId, - runAttemptId: context.input.attemptId, - nativeTurnRef: { - driver: CLAUDE_PROVIDER, - nativeId: context.nativeTurnId, - strength: "strong", - }, - ordinal: context.providerTurnOrdinal, - status: "running", - startedAt: context.startedAt, - completedAt: null, - tokenUsage: { - usedTokens: afterTokenCount, - maxTokens, - ...claudeAutoCompactThreshold( - adapterOptions.settings.autoCompactWindow, - maxTokens, - ), - updatedAt: DateTime.formatIso(now), - }, - }, - }); - } + context.compactionTokens = afterTokenCount; + if (afterTokenCount !== undefined) yield* emitContextUsage(context, afterTokenCount); yield* emitProviderEvent({ type: "turn_item.updated", driver: CLAUDE_PROVIDER, @@ -9585,8 +9571,9 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( } if (message.type === "result") { - if (resultsReportingNewContextWindow.has(message) && context.streamUsage?.usage) { - yield* emitContextUsage(context, context.streamUsage.usage); + const contextUsage = context.streamUsage?.usage ?? context.compactionTokens; + if (resultsReportingNewContextWindow.has(message) && contextUsage !== undefined) { + yield* emitContextUsage(context, contextUsage); } const completedAt = yield* DateTime.now; const interrupted = (yield* Ref.get(interruptedTurns)).has(context.providerTurnId); @@ -10795,6 +10782,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( blocks: new Map(), }, streamUsage: null, + compactionTokens: undefined, toolCalls: new Map(), compaction: null, ignoredTaskIds: new Set(),