diff --git a/packages/host-daemon-contract/src/protocol.ts b/packages/host-daemon-contract/src/protocol.ts index 7e7793a875..c398b5d911 100644 --- a/packages/host-daemon-contract/src/protocol.ts +++ b/packages/host-daemon-contract/src/protocol.ts @@ -331,9 +331,13 @@ // replaces the owning turn retains it. The wire shape is unchanged, but the // server-to-daemon payload semantics differ. // +// Version 171 normalizes Claude model context-window reports against the +// capacity implied by each model id. The usage event meaning changed across +// the daemon boundary, so enrolled daemons must update with the server. +// // 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 = 170 as const; +export const HOST_DAEMON_PROTOCOL_VERSION = 171 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 123b9bff45..2b3852f9c7 100644 --- a/packages/host-daemon-contract/test/contract.test.ts +++ b/packages/host-daemon-contract/test/contract.test.ts @@ -1046,7 +1046,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(170); + expect(HOST_DAEMON_PROTOCOL_VERSION).toBe(171); 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 bd78bdf8e8..8c04864a41 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" @@ -6,4 +6,4 @@ {"ts":1787279795704,"run":1787279794200,"seq":10.272727272727273,"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.363636363636363,"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.454545454545455,"dir":"bridge→runtime","line":"{\"jsonrpc\":\"2.0\",\"method\":\"provider/recovery\",\"params\":{\"threadId\":\"thr_9e3ukzazz3\",\"kind\":\"authRequired\",\"message\":\"Not logged in · Please run /login\",\"retryable\":false}}"} -{"ts":1787279795707,"run":1787279794200,"seq":10.545454545454545,"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":1787279795707,"run":1787279794200,"seq":10.545454545454545,"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 a0c9b8643f..a400b8b3bd 100644 --- a/plugins/provider-claude-code/src/bridge/__tests__/bridge.test.ts +++ b/plugins/provider-claude-code/src/bridge/__tests__/bridge.test.ts @@ -4611,13 +4611,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(() => { @@ -4631,22 +4625,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", @@ -4663,6 +4667,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(); @@ -4682,6 +4691,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 88b635e352..4752902f63 100644 --- a/plugins/provider-claude-code/src/bridge/bridge.ts +++ b/plugins/provider-claude-code/src/bridge/bridge.ts @@ -969,13 +969,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 1ef8fb4970..d8284fcc22 100644 --- a/plugins/provider-claude-code/src/delta-translation.ts +++ b/plugins/provider-claude-code/src/delta-translation.ts @@ -1495,10 +1495,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; }