From dec12e6eee54276d0ea90f261c52554786d4224c Mon Sep 17 00:00:00 2001 From: Scott Sunarto Date: Sat, 22 Aug 2026 15:49:19 -0700 Subject: [PATCH] fix(claude-code): honor model context capacity floor refactor(claude-code): simplify context capacity normalization test(claude-code): cover custom endpoint context reporting --- packages/host-daemon-contract/src/protocol.ts | 2 +- .../test/contract.test.ts | 2 +- .../bridge\342\206\222runtime.current.ndjson" | 2 +- .../src/bridge/__tests__/bridge.test.ts | 46 ++++++++++++++----- .../provider-claude-code/src/bridge/bridge.ts | 9 ++-- .../src/delta-translation.ts | 8 ++-- .../src/delta-translation.usage.test.ts | 2 +- .../src/sdk-extraction.ts | 17 +++++-- 8 files changed, 59 insertions(+), 29 deletions(-) diff --git a/packages/host-daemon-contract/src/protocol.ts b/packages/host-daemon-contract/src/protocol.ts index 2137d61e17..4f7e515653 100644 --- a/packages/host-daemon-contract/src/protocol.ts +++ b/packages/host-daemon-contract/src/protocol.ts @@ -172,7 +172,7 @@ // // The version mismatch is what triggers the enrolled daemon's automatic update // instead of an `invalid-message` reconnect loop. -export const HOST_DAEMON_PROTOCOL_VERSION = 153 as const; +export const HOST_DAEMON_PROTOCOL_VERSION = 154 as const; /** * Absolute ceiling for any executable artifact delivered to a host daemon — diff --git a/packages/host-daemon-contract/test/contract.test.ts b/packages/host-daemon-contract/test/contract.test.ts index c5461e15bb..6f8ca5a430 100644 --- a/packages/host-daemon-contract/test/contract.test.ts +++ b/packages/host-daemon-contract/test/contract.test.ts @@ -1133,7 +1133,7 @@ describe("host-daemon command schemas", () => { // mixed version. Version 113 carried the Devin Desktop open target rename // and remains part of the protocol lineage. it("uses the current host-daemon protocol version", () => { - expect(HOST_DAEMON_PROTOCOL_VERSION).toBe(153); + expect(HOST_DAEMON_PROTOCOL_VERSION).toBe(154); expect(HOST_ARTIFACT_MAX_BYTES).toBe(256 * 1024 * 1024); }); diff --git "a/packages/provider-bridge-protocol/recordings/claude-code/auth-failure/bridge\342\206\222runtime.current.ndjson" "b/packages/provider-bridge-protocol/recordings/claude-code/auth-failure/bridge\342\206\222runtime.current.ndjson" index 3466dd9e6d..88fcfd6608 100644 --- "a/packages/provider-bridge-protocol/recordings/claude-code/auth-failure/bridge\342\206\222runtime.current.ndjson" +++ "b/packages/provider-bridge-protocol/recordings/claude-code/auth-failure/bridge\342\206\222runtime.current.ndjson" @@ -5,4 +5,4 @@ {"ts":1787279795703,"run":1787279794200,"seq":10.2,"dir":"bridge→runtime","line":"{\"jsonrpc\":\"2.0\",\"id\":8,\"result\":{\"threadId\":\"thr_9e3ukzazz3\"}}"} {"ts":1787279795704,"run":1787279794200,"seq":10.3,"dir":"bridge→runtime","line":"{\"jsonrpc\":\"2.0\",\"method\":\"thread/identity\",\"params\":{\"threadId\":\"thr_9e3ukzazz3\",\"providerThreadId\":\"54386450-a88e-4d9e-b4e1-8893a0c47239\",\"sessionRestorable\":true}}"} {"ts":1787279795705,"run":1787279794200,"seq":10.4,"dir":"bridge→runtime","line":"{\"jsonrpc\":\"2.0\",\"method\":\"thread/delta\",\"params\":{\"threadId\":\"thr_9e3ukzazz3\",\"deltas\":[{\"kind\":\"turn.open\"},{\"kind\":\"item.textClose\",\"key\":{\"channel\":\"assistant\"},\"channel\":\"agentMessage\",\"text\":\"Not logged in · Please run /login\"}]}}"} -{"ts":1787279795706,"run":1787279794200,"seq":10.5,"dir":"bridge→runtime","line":"{\"jsonrpc\":\"2.0\",\"method\":\"thread/delta\",\"params\":{\"threadId\":\"thr_9e3ukzazz3\",\"deltas\":[{\"kind\":\"turn.open\"},{\"kind\":\"contextWindow\",\"used\":0,\"size\":null,\"estimated\":true,\"attach\":\"open\"},{\"kind\":\"usage\",\"total\":{\"totalTokens\":0,\"inputTokens\":0,\"cachedInputTokens\":0,\"outputTokens\":0,\"reasoningOutputTokens\":0},\"last\":{\"totalTokens\":0,\"inputTokens\":0,\"cachedInputTokens\":0,\"outputTokens\":0,\"reasoningOutputTokens\":0},\"modelContextWindow\":null},{\"kind\":\"provider.error\",\"message\":\"Provider error\",\"detail\":\"Not logged in · Please run /login\"},{\"kind\":\"turn.boundary\",\"status\":\"failed\",\"providerCheckpointId\":\"4a270486-b600-43dd-a6bd-07b72524f931\"}]}}"} +{"ts":1787279795706,"run":1787279794200,"seq":10.5,"dir":"bridge→runtime","line":"{\"jsonrpc\":\"2.0\",\"method\":\"thread/delta\",\"params\":{\"threadId\":\"thr_9e3ukzazz3\",\"deltas\":[{\"kind\":\"turn.open\"},{\"kind\":\"contextWindow\",\"used\":0,\"size\":1000000,\"estimated\":true,\"attach\":\"open\"},{\"kind\":\"usage\",\"total\":{\"totalTokens\":0,\"inputTokens\":0,\"cachedInputTokens\":0,\"outputTokens\":0,\"reasoningOutputTokens\":0},\"last\":{\"totalTokens\":0,\"inputTokens\":0,\"cachedInputTokens\":0,\"outputTokens\":0,\"reasoningOutputTokens\":0},\"modelContextWindow\":null},{\"kind\":\"provider.error\",\"message\":\"Provider error\",\"detail\":\"Not logged in · Please run /login\"},{\"kind\":\"turn.boundary\",\"status\":\"failed\",\"providerCheckpointId\":\"4a270486-b600-43dd-a6bd-07b72524f931\"}]}}"} diff --git a/plugins/provider-claude-code/src/bridge/__tests__/bridge.test.ts b/plugins/provider-claude-code/src/bridge/__tests__/bridge.test.ts index c7316a23bf..93b62b455c 100644 --- a/plugins/provider-claude-code/src/bridge/__tests__/bridge.test.ts +++ b/plugins/provider-claude-code/src/bridge/__tests__/bridge.test.ts @@ -4529,13 +4529,7 @@ describe("canonical model context-window hint", () => { permissionEscalation: null, }; - // Claude reports `modelUsage.contextWindow` on some results and omits it on - // others. The legacy adapter seeded a model-derived fallback on every - // command plan that carried a model; the canonical bridge has no command - // plan, so it seeds the translator at session construction instead. Without - // that seeding, capacity read as unknown for every result Claude sent - // without the field — notably the 1M `[1m]` aliases. - it("seeds the model-derived context window for canonical sessions", async () => { + it("uses Fable's Claude Code capacity through a custom API endpoint", async () => { const bridge = createBridgeJsonRpcTestHarness(handleLine); const queries: ControlledClaudeQuery[] = []; queryMock.mockImplementation(() => { @@ -4549,22 +4543,32 @@ describe("canonical model context-window hint", () => { threadId: "thread-context-hint", cwd: "/tmp/worktree", instructionMode: "append", - options: { ...canonicalOptions, model: "claude-opus-4-7[1m]" }, + options: { + ...canonicalOptions, + model: "claude-fable-5", + envVars: { + ANTHROPIC_BASE_URL: "http://127.0.0.1:8317", + }, + }, }); await bridge.waitForResponse(1); + expect(getLatestQueryOptions().env?.ANTHROPIC_BASE_URL).toBe( + "http://127.0.0.1:8317", + ); + bridge.sendRequest(2, "turn/start", { threadId: "thread-context-hint", providerThreadId: "thread-context-hint", clientRequestId: "creq_23456789ab", input: [{ type: "text", text: "hello", mentions: [] }], - options: { ...canonicalOptions, model: "claude-opus-4-7[1m]" }, + options: { ...canonicalOptions, model: "claude-fable-5" }, }); await readNextPrompt(getLatestQueryCall()); await bridge.waitForResponse(2); - // A result with token usage but no `modelUsage`: the only capacity - // source left is the seeded hint. + // The SDK reports its legacy 200k BYOK fallback through a custom endpoint, + // although current Fable providers support 1M. queries[0]?.emit({ type: "result", subtype: "success", @@ -4581,6 +4585,11 @@ describe("canonical model context-window hint", () => { cache_creation_input_tokens: 30, cache_read_input_tokens: 40, }, + modelUsage: { + "claude-fable-5": { + contextWindow: 200_000, + }, + }, session_id: "session-1", } as unknown as SDKMessage); await bridge.flushWork(); @@ -4600,6 +4609,21 @@ describe("canonical model context-window hint", () => { expect(contextWindowEvents.at(-1)?.contextWindowUsage).toMatchObject({ modelContextWindow: 1_000_000, }); + + const tokenUsageEvents = assembleCapturedThreadEvents( + bridge.messages, + "claude-code", + ).filter( + ( + event, + ): event is Extract< + ThreadEvent, + { type: "thread/tokenUsage/updated" } + > => event.type === "thread/tokenUsage/updated", + ); + expect(tokenUsageEvents.at(-1)?.tokenUsage).toMatchObject({ + modelContextWindow: 1_000_000, + }); } finally { queries[0]?.finish(); bridge.restore(); diff --git a/plugins/provider-claude-code/src/bridge/bridge.ts b/plugins/provider-claude-code/src/bridge/bridge.ts index 37e65b6fd5..f5ef1c1286 100644 --- a/plugins/provider-claude-code/src/bridge/bridge.ts +++ b/plugins/provider-claude-code/src/bridge/bridge.ts @@ -823,13 +823,10 @@ function withTrackedPermissionEscalation( } /** - * Seed the translator's context-window fallback from the selected model. + * Seed the translator's context-window hint from the selected model. * - * Claude reports `modelUsage.contextWindow` on some results and omits it on - * others; when it is missing the translator falls back to the capacity implied - * by the model id (notably the 1M `[1m]` aliases). The bridge seeds the hint - * here, on every session construction and every turn that carries a model — - * without it, capacity reads as unknown whenever Claude omits the field. + * Claude can omit `modelUsage.contextWindow`. Seed the selected model as its + * fallback; reported model entries normalize their own known capacity. */ function seedModelContextWindowHint( threadSession: ThreadSession, diff --git a/plugins/provider-claude-code/src/delta-translation.ts b/plugins/provider-claude-code/src/delta-translation.ts index ad10cd4688..2799897c56 100644 --- a/plugins/provider-claude-code/src/delta-translation.ts +++ b/plugins/provider-claude-code/src/delta-translation.ts @@ -1483,10 +1483,10 @@ export function createClaudeDeltaTranslator() { } /** - * Seed the context-window fallback from the selected model. Claude reports - * `modelUsage.contextWindow` on some results and omits it on others; when - * missing, capacity falls back to what the model id implies (notably the 1M - * `[1m]` aliases). Called at session construction and live model changes. + * Seed the context-window hint from the selected model. The model's known + * Claude Code capacity is a floor for `modelUsage.contextWindow`, which can + * report the generic 200k window through custom Anthropic endpoints. Called + * at session construction and live model changes. */ function setClaudeModelContextWindowHint( threadId: string, diff --git a/plugins/provider-claude-code/src/delta-translation.usage.test.ts b/plugins/provider-claude-code/src/delta-translation.usage.test.ts index ef2da38988..0ebdaa8c34 100644 --- a/plugins/provider-claude-code/src/delta-translation.usage.test.ts +++ b/plugins/provider-claude-code/src/delta-translation.usage.test.ts @@ -375,7 +375,7 @@ describe("claude usage and fixture translation (delta path)", () => { ); }); - // The four cases below pin the translator's use of sdk-extraction.ts's + // The cases below pin the translator's use of sdk-extraction.ts's // resolveClaudeModelContextWindowHint. The canonical bridge calls // setClaudeModelContextWindowHint from session construction and from the // live model change; that the bridge really calls it is pinned separately diff --git a/plugins/provider-claude-code/src/sdk-extraction.ts b/plugins/provider-claude-code/src/sdk-extraction.ts index fd61d72878..f694e73b99 100644 --- a/plugins/provider-claude-code/src/sdk-extraction.ts +++ b/plugins/provider-claude-code/src/sdk-extraction.ts @@ -294,7 +294,8 @@ export function extractClaudeContextWindowUsage( args.message.modelUsage, ); const modelContextWindow = parsedModelUsage.success - ? extractModelContextWindow(parsedModelUsage.data) + ? (extractModelContextWindow(parsedModelUsage.data) ?? + args.fallbackModelContextWindow) : args.fallbackModelContextWindow; const usedTokens = args.latestRequestContextTokens ?? null; @@ -356,9 +357,17 @@ function extractModelContextWindow( if (!modelUsage) return null; let largestContextWindow: number | null = null; - for (const usage of Object.values(modelUsage)) { - const contextWindow = toPositiveNumber(usage.contextWindow); - if (contextWindow === undefined) continue; + for (const [model, usage] of Object.entries(modelUsage)) { + const reportedContextWindow = toPositiveNumber(usage.contextWindow); + if (reportedContextWindow === undefined) continue; + + // The SDK's 200k custom-endpoint value is a legacy BYOK fallback. Current + // Fable providers, including Bedrock, support 1M. + const modelContextWindow = resolveClaudeModelContextWindowHint(model); + const contextWindow = + modelContextWindow === null + ? reportedContextWindow + : Math.max(reportedContextWindow, modelContextWindow); if (largestContextWindow === null || contextWindow > largestContextWindow) { largestContextWindow = contextWindow; }