From d798082c6127f1e05864084223ff2a679b21d364 Mon Sep 17 00:00:00 2001 From: Sergiy Dybskiy Date: Wed, 11 Mar 2026 09:50:32 -0400 Subject: [PATCH] fix(provider): resolve bedrock compaction failures for Claude models Three fixes for Bedrock (and Anthropic direct) Claude model compaction: 1. Extend empty content filtering to all Claude-hosting providers. The filter previously only applied to @ai-sdk/anthropic but Bedrock and Vertex host the same models with the same validation rules. 2. Strip providerMetadata from reasoning parts during compaction. Thinking block signatures from old assistant messages caused the API to reject compaction requests with 'thinking blocks cannot be modified'. 3. Auto-detect 1M context window when user configures the context-1m beta flag (via anthropicBeta option on Bedrock or anthropic-beta header on Anthropic direct). Without this, models.dev reports 200k and compaction triggers too early for 1M users. Closes #16486 --- packages/opencode/src/provider/provider.ts | 15 ++ packages/opencode/src/provider/transform.ts | 15 +- packages/opencode/src/session/compaction.ts | 2 +- packages/opencode/src/session/message-v2.ts | 19 ++- .../opencode/test/provider/provider.test.ts | 108 ++++++++++++++ .../opencode/test/provider/transform.test.ts | 64 ++++++++- .../opencode/test/session/message-v2.test.ts | 135 ++++++++++++++++++ 7 files changed, 345 insertions(+), 13 deletions(-) diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index a02a017e77c..f3d965654f1 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -961,6 +961,21 @@ export namespace Provider { (v) => omit(v, ["disabled"]), ) } + + // Auto-detect 1M context window for Claude models when the user + // configures the context-1m beta flag. Without this, models.dev reports + // 200k and compaction triggers too early. + // Bedrock uses providerOptions: anthropicBeta (array of strings). + // Anthropic direct uses HTTP headers: anthropic-beta (comma-separated string). + if (model.limit.context <= 200_000) { + const has1mBeta = + (Array.isArray(model.options?.anthropicBeta) && + model.options.anthropicBeta.some((b: string) => /^context-1m-/.test(b))) || + /context-1m-/.test(model.headers?.["anthropic-beta"] ?? "") + if (has1mBeta) { + model.limit = { ...model.limit, context: 1_000_000 } + } + } } if (Object.keys(provider.models).length === 0) { diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 7ed89cb2599..c5c699b8ec4 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -20,6 +20,14 @@ function mimeToModality(mime: string): Modality | undefined { export namespace ProviderTransform { export const OUTPUT_TOKEN_MAX = Flag.OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX || 32_000 + // Returns true if the model is a Claude model on any provider (Anthropic, Bedrock, Vertex). + // Claude uniformly rejects empty content blocks regardless of host provider. + function isClaude(model: Provider.Model): boolean { + return ( + model.api.npm === "@ai-sdk/anthropic" || model.api.id.includes("claude") || model.api.id.includes("anthropic") + ) + } + // Maps npm package to the key the AI SDK expects for providerOptions function sdkKey(npm: string): string | undefined { switch (npm) { @@ -49,9 +57,10 @@ export namespace ProviderTransform { model: Provider.Model, options: Record, ): ModelMessage[] { - // Anthropic rejects messages with empty content - filter out empty string messages - // and remove empty text/reasoning parts from array content - if (model.api.npm === "@ai-sdk/anthropic") { + // Claude rejects messages with empty content - filter out empty string messages + // and remove empty text/reasoning parts from array content. + // This applies to all providers hosting Claude models (Anthropic, Bedrock, Vertex). + if (isClaude(model)) { msgs = msgs .map((msg) => { if (typeof msg.content === "string") { diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index 5660ca97319..2b2fd899e01 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -183,7 +183,7 @@ When constructing the summary, try to stick to this template: tools: {}, system: [], messages: [ - ...MessageV2.toModelMessages(input.messages, model), + ...MessageV2.toModelMessages(input.messages, model, { stripMetadata: true }), { role: "user", content: [ diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 63159ecc50c..894a514ac7f 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -442,7 +442,11 @@ export namespace MessageV2 { }) export type WithParts = z.infer - export function toModelMessages(input: WithParts[], model: Provider.Model): ModelMessage[] { + export function toModelMessages( + input: WithParts[], + model: Provider.Model, + options?: { stripMetadata?: boolean }, + ): ModelMessage[] { const result: UIMessage[] = [] const toolNames = new Set() // Track media from tool results that need to be injected as user messages @@ -540,7 +544,8 @@ export namespace MessageV2 { } if (msg.info.role === "assistant") { - const differentModel = `${model.providerID}/${model.id}` !== `${msg.info.providerID}/${msg.info.modelID}` + const skipMetadata = + options?.stripMetadata || `${model.providerID}/${model.id}` !== `${msg.info.providerID}/${msg.info.modelID}` const media: Array<{ mime: string; url: string }> = [] if ( @@ -562,7 +567,7 @@ export namespace MessageV2 { assistantMessage.parts.push({ type: "text", text: part.text, - ...(differentModel ? {} : { providerMetadata: part.metadata }), + ...(skipMetadata ? {} : { providerMetadata: part.metadata }), }) if (part.type === "step-start") assistantMessage.parts.push({ @@ -599,7 +604,7 @@ export namespace MessageV2 { toolCallId: part.callID, input: part.state.input, output, - ...(differentModel ? {} : { callProviderMetadata: part.metadata }), + ...(skipMetadata ? {} : { callProviderMetadata: part.metadata }), }) } if (part.state.status === "error") @@ -609,7 +614,7 @@ export namespace MessageV2 { toolCallId: part.callID, input: part.state.input, errorText: part.state.error, - ...(differentModel ? {} : { callProviderMetadata: part.metadata }), + ...(skipMetadata ? {} : { callProviderMetadata: part.metadata }), }) // Handle pending/running tool calls to prevent dangling tool_use blocks // Anthropic/Claude APIs require every tool_use to have a corresponding tool_result @@ -620,14 +625,14 @@ export namespace MessageV2 { toolCallId: part.callID, input: part.state.input, errorText: "[Tool execution was interrupted]", - ...(differentModel ? {} : { callProviderMetadata: part.metadata }), + ...(skipMetadata ? {} : { callProviderMetadata: part.metadata }), }) } if (part.type === "reasoning") { assistantMessage.parts.push({ type: "reasoning", text: part.text, - ...(differentModel ? {} : { providerMetadata: part.metadata }), + ...(skipMetadata ? {} : { providerMetadata: part.metadata }), }) } } diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index 98cd49c02fd..7602a6e455f 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -2127,3 +2127,111 @@ test("custom model with variants enabled and disabled", async () => { }, }) }) + +test("bedrock model with anthropicBeta context-1m option gets 1M context limit", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + provider: { + "amazon-bedrock": { + models: { + "us.anthropic.claude-opus-4-6-v1": { + options: { + anthropicBeta: ["context-1m-2025-08-07"], + }, + }, + }, + }, + }, + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + init: async () => { + Env.set("AWS_ACCESS_KEY_ID", "test") + Env.set("AWS_SECRET_ACCESS_KEY", "test") + Env.set("AWS_REGION", "us-east-1") + }, + fn: async () => { + const providers = await Provider.list() + const model = providers["amazon-bedrock"]?.models["us.anthropic.claude-opus-4-6-v1"] + if (!model) return // skip if models.dev doesn't include this model in test env + expect(model.limit.context).toBe(1_000_000) + }, + }) +}) + +test("bedrock model without anthropicBeta keeps default context limit", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + provider: { + "amazon-bedrock": { + models: { + "us.anthropic.claude-opus-4-6-v1": {}, + }, + }, + }, + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + init: async () => { + Env.set("AWS_ACCESS_KEY_ID", "test") + Env.set("AWS_SECRET_ACCESS_KEY", "test") + Env.set("AWS_REGION", "us-east-1") + }, + fn: async () => { + const providers = await Provider.list() + const model = providers["amazon-bedrock"]?.models["us.anthropic.claude-opus-4-6-v1"] + if (!model) return + expect(model.limit.context).toBeLessThanOrEqual(200_000) + }, + }) +}) + +test("anthropic model with context-1m header gets 1M context limit", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + provider: { + anthropic: { + models: { + "claude-opus-4-6": { + headers: { + "anthropic-beta": "context-1m-2025-08-07", + }, + }, + }, + }, + }, + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + init: async () => { + Env.set("ANTHROPIC_API_KEY", "test-key") + }, + fn: async () => { + const providers = await Provider.list() + const model = providers["anthropic"]?.models["claude-opus-4-6"] + if (!model) return + expect(model.limit.context).toBe(1_000_000) + }, + }) +}) diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index 02bb5278fc7..64b3b7e3678 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -649,7 +649,7 @@ describe("ProviderTransform.message - empty image handling", () => { }) }) -describe("ProviderTransform.message - anthropic empty content filtering", () => { +describe("ProviderTransform.message - claude empty content filtering", () => { const anthropicModel = { id: "anthropic/claude-3-5-sonnet", providerID: "anthropic", @@ -797,7 +797,7 @@ describe("ProviderTransform.message - anthropic empty content filtering", () => expect(result[0].content[1]).toEqual({ type: "text", text: "Result" }) }) - test("does not filter for non-anthropic providers", () => { + test("does not filter for non-claude providers", () => { const openaiModel = { ...anthropicModel, providerID: "openai", @@ -822,6 +822,66 @@ describe("ProviderTransform.message - anthropic empty content filtering", () => expect(result[0].content).toBe("") expect(result[1].content).toHaveLength(1) }) + + test("filters empty content for bedrock claude models", () => { + const bedrockModel = { + ...anthropicModel, + providerID: "amazon-bedrock", + api: { + id: "us.anthropic.claude-opus-4-6-v1", + url: "https://bedrock-runtime.us-east-1.amazonaws.com", + npm: "@ai-sdk/amazon-bedrock", + }, + } + + const msgs = [ + { role: "assistant", content: "" }, + { + role: "assistant", + content: [ + { type: "text", text: "" }, + { type: "text", text: "Hello" }, + ], + }, + { + role: "assistant", + content: [ + { type: "reasoning", text: "" }, + { type: "text", text: "Answer" }, + ], + }, + ] as any[] + + const result = ProviderTransform.message(msgs, bedrockModel, {}) + + expect(result).toHaveLength(2) + expect(result[0].content).toHaveLength(1) + expect(result[0].content[0]).toEqual({ type: "text", text: "Hello" }) + expect(result[1].content).toHaveLength(1) + expect(result[1].content[0]).toEqual({ type: "text", text: "Answer" }) + }) + + test("filters empty content for vertex claude models", () => { + const vertexModel = { + ...anthropicModel, + providerID: "google-vertex", + api: { + id: "claude-opus-4-6@20260205", + url: "https://us-east5-aiplatform.googleapis.com", + npm: "@ai-sdk/google-vertex", + }, + } + + const msgs = [ + { role: "assistant", content: "" }, + { role: "user", content: "World" }, + ] as any[] + + const result = ProviderTransform.message(msgs, vertexModel, {}) + + expect(result).toHaveLength(1) + expect(result[0].content).toBe("World") + }) }) describe("ProviderTransform.message - strip openai metadata when store=false", () => { diff --git a/packages/opencode/test/session/message-v2.test.ts b/packages/opencode/test/session/message-v2.test.ts index c043754bdb4..38433976622 100644 --- a/packages/opencode/test/session/message-v2.test.ts +++ b/packages/opencode/test/session/message-v2.test.ts @@ -784,6 +784,141 @@ describe("session.message-v2.toModelMessage", () => { }, ]) }) + + test("strips provider metadata when stripMetadata option is set", () => { + const userID = "m-user" + const assistantID = "m-assistant" + + const input: MessageV2.WithParts[] = [ + { + info: userInfo(userID), + parts: [ + { + ...basePart(userID, "u1"), + type: "text", + text: "hello", + }, + ] as MessageV2.Part[], + }, + { + info: assistantInfo(assistantID, userID), + parts: [ + { + ...basePart(assistantID, "a1"), + type: "text", + text: "response", + metadata: { bedrock: { thinking: "sig" } }, + }, + { + ...basePart(assistantID, "a2"), + type: "reasoning", + text: "thinking about it", + time: { start: 0 }, + metadata: { bedrock: { thinking_signature: "abc" } }, + }, + { + ...basePart(assistantID, "a3"), + type: "tool", + callID: "call-1", + tool: "bash", + state: { + status: "completed", + input: { cmd: "ls" }, + output: "ok", + title: "Bash", + metadata: {}, + time: { start: 0, end: 1 }, + }, + metadata: { bedrock: { tool_meta: "xyz" } }, + }, + ] as MessageV2.Part[], + }, + ] + + // Same model, but stripMetadata=true should still omit metadata + const result = MessageV2.toModelMessages(input, model, { stripMetadata: true }) + + expect(result).toStrictEqual([ + { + role: "user", + content: [{ type: "text", text: "hello" }], + }, + { + role: "assistant", + content: [ + { type: "text", text: "response" }, + { type: "reasoning", text: "thinking about it", providerOptions: undefined }, + { + type: "tool-call", + toolCallId: "call-1", + toolName: "bash", + input: { cmd: "ls" }, + providerExecuted: undefined, + }, + ], + }, + { + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: "call-1", + toolName: "bash", + output: { type: "text", value: "ok" }, + }, + ], + }, + ]) + }) + + test("preserves provider metadata without stripMetadata option", () => { + const userID = "m-user" + const assistantID = "m-assistant" + + const input: MessageV2.WithParts[] = [ + { + info: userInfo(userID), + parts: [ + { + ...basePart(userID, "u1"), + type: "text", + text: "hello", + }, + ] as MessageV2.Part[], + }, + { + info: assistantInfo(assistantID, userID), + parts: [ + { + ...basePart(assistantID, "a1"), + type: "reasoning", + text: "thinking", + time: { start: 0 }, + metadata: { bedrock: { sig: "keep" } }, + }, + ] as MessageV2.Part[], + }, + ] + + const result = MessageV2.toModelMessages(input, model) + + expect(result).toStrictEqual([ + { + role: "user", + content: [{ type: "text", text: "hello" }], + }, + { + role: "assistant", + content: [ + { + type: "reasoning", + text: "thinking", + providerOptions: { bedrock: { sig: "keep" } }, + }, + ], + }, + ]) + }) }) describe("session.message-v2.fromError", () => {