diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 1244963e846..56099effe15 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -684,35 +684,39 @@ function anthropicOmitsThinking(apiId: string) { return anthropicUsesModernAdaptiveThinking(apiId) } -// Opus 5, Sonnet 5, Fable 5.x, and Mythos 5.x think without a `thinking` parameter. -function anthropicThinksByDefault(apiId: string) { - const version = /claude-(?:[a-z]+-)?(\d+)(?:[.-](\d{1,2}))?(?:[.@-]|$)/i.exec(apiId) +// Default to binding controls for Claude 5.1+ as enforcement expands to later models. +// Mythos 5.1 explicitly does not run the conversation-prefix check. +// https://platform.claude.com/docs/en/build-with-claude/thinking#preserved-in-conversation +function anthropicBindsThinking(apiId: string) { + // Capture either family/version order, without reading release dates as minor versions. + const version = /claude-(?:([a-z]+)-)?(\d+)(?:[.-](\d{1,2}))?(?:-([a-z]+))?(?:[.@-]|$)/i.exec(apiId) if (!version) return false - return Number(version[1]) >= 5 + const major = Number(version[2]) + const minor = Number(version[3] ?? 0) + if (major === 5 && minor === 1 && (version[1] ?? version[4])?.toLowerCase() === "mythos") return false + return major > 5 || (major === 5 && minor >= 1) } // Fable 5.1 binds each thinking signature to the system prompt, tool list, and // messages above it, and rejects the request when any of that changes. opencode // re-renders parts of that prefix between turns (system prompt, tools, compaction), // so ask the API to drop the affected blocks instead of failing the request. -// Models that do not run the check accept the field, so it is safe on every Claude. +// Older model deployments may reject this field, even with thinking enabled. // The patched AI SDK adds the thinking-binding-controls beta whenever it is set. const ANTHROPIC_BLOCK_BINDING = { prefixMismatchBehavior: "drop_block" } function anthropicBlockBinding(model: Provider.Model, options: { [x: string]: any }) { - if (!model.api.id.toLowerCase().includes("claude")) return options - const byDefault = anthropicThinksByDefault(model.api.id) + if (!anthropicBindsThinking(model.api.id)) return options switch (model.api.npm) { case "@ai-sdk/anthropic": case "@ai-sdk/google-vertex/anthropic": { - const thinking = options.thinking ?? (byDefault ? { type: "adaptive" } : undefined) - if (!thinking || (thinking.type !== "adaptive" && thinking.type !== "enabled")) return options + const thinking = options.thinking ?? { type: "adaptive" } + if (thinking.type !== "adaptive" && thinking.type !== "enabled") return options return { ...options, thinking: { ...thinking, blockBinding: ANTHROPIC_BLOCK_BINDING } } } case "@ai-sdk/amazon-bedrock": { - const reasoningConfig = options.reasoningConfig ?? (byDefault ? { type: "adaptive" } : undefined) - if (!reasoningConfig || (reasoningConfig.type !== "adaptive" && reasoningConfig.type !== "enabled")) - return options + const reasoningConfig = options.reasoningConfig ?? { type: "adaptive" } + if (reasoningConfig.type !== "adaptive" && reasoningConfig.type !== "enabled") return options return { ...options, reasoningConfig: { ...reasoningConfig, blockBinding: ANTHROPIC_BLOCK_BINDING } } } } diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index 4d346d8871a..bfe4a0aa9b6 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -851,39 +851,179 @@ describe("ProviderTransform.providerOptions", () => { const binding = { prefixMismatchBehavior: "drop_block" } const claude = (npm: string, id: string) => createModel({ providerID: "custom", api: { id, url: "https://example.com", npm } }) + const sdks = [ + { npm: "@ai-sdk/anthropic", key: "anthropic", option: "thinking" }, + { npm: "@ai-sdk/google-vertex/anthropic", key: "anthropic", option: "thinking" }, + { npm: "@ai-sdk/amazon-bedrock", key: "bedrock", option: "reasoningConfig" }, + ] test("adds blockBinding to explicit adaptive thinking on @ai-sdk/anthropic", () => { - const model = claude("@ai-sdk/anthropic", "claude-opus-4-7") + const model = claude("@ai-sdk/anthropic", "claude-fable-5-1") expect(ProviderTransform.providerOptions(model, { thinking: { type: "adaptive" }, effort: "high" })).toEqual({ anthropic: { thinking: { type: "adaptive", blockBinding: binding }, effort: "high" }, }) }) - test("adds blockBinding to explicit enabled thinking", () => { - const model = claude("@ai-sdk/anthropic", "claude-sonnet-4-5") + test("leaves explicit enabled thinking on older models alone", () => { + const model = claude("@ai-sdk/anthropic", "claude-haiku-4-5") expect(ProviderTransform.providerOptions(model, { thinking: { type: "enabled", budgetTokens: 4000 } })).toEqual({ - anthropic: { thinking: { type: "enabled", budgetTokens: 4000, blockBinding: binding } }, + anthropic: { thinking: { type: "enabled", budgetTokens: 4000 } }, }) }) - test("injects adaptive thinking for models that think by default when no variant is set", () => { - for (const id of ["claude-fable-5-1", "claude-mythos-5-1", "claude-opus-5", "claude-sonnet-5"]) { - const model = claude("@ai-sdk/anthropic", id) - expect(ProviderTransform.providerOptions(model, {})).toEqual({ - anthropic: { thinking: { type: "adaptive", blockBinding: binding } }, + sdks.forEach((sdk) => { + describe(sdk.npm, () => { + test.each([ + "claude-fable-5-1", + "claude-fable-5.1", + "claude-5.1-fable", + "global.anthropic.claude-fable-5-1", + "us.anthropic.claude-fable-5-1-v1:0", + "claude-fable-5-1@default", + "CLAUDE-FABLE-5-1", + "claude-opus-5-1", + "claude-sonnet-5-2", + "claude-mythos-5-2", + "claude-mythos-5-10", + "claude-opus-6", + "claude-6-opus", + "claude-mythos-6-20270901", + ])("adds binding for %s", (id) => { + const model = claude(sdk.npm, id) + expect(ProviderTransform.providerOptions(model, {})).toEqual({ + [sdk.key]: { [sdk.option]: { type: "adaptive", blockBinding: binding } }, + }) + expect( + ProviderTransform.providerOptions(model, { [sdk.option]: { type: "adaptive", display: "summarized" } }), + ).toEqual({ + [sdk.key]: { [sdk.option]: { type: "adaptive", display: "summarized", blockBinding: binding } }, + }) + expect( + ProviderTransform.providerOptions(model, { [sdk.option]: { type: "enabled", budgetTokens: 4000 } }), + ).toEqual({ + [sdk.key]: { [sdk.option]: { type: "enabled", budgetTokens: 4000, blockBinding: binding } }, + }) + expect(ProviderTransform.providerOptions(model, { [sdk.option]: { type: "disabled" } })).toEqual({ + [sdk.key]: { [sdk.option]: { type: "disabled" } }, + }) }) - } - }) - test("does not inject thinking for models that are off by default", () => { - for (const id of ["claude-opus-4-7", "claude-opus-4-5", "claude-sonnet-4-6", "claude-haiku-4-5"]) { - const model = claude("@ai-sdk/anthropic", id) - expect(ProviderTransform.providerOptions(model, {})).toEqual({ anthropic: {} }) - } + test.each([ + "claude-haiku-4-5", + "claude-opus-4-8", + "claude-sonnet-4-6", + "claude-opus-5", + "claude-sonnet-5", + "claude-fable-5", + "claude-opus-5-0", + "claude-opus-5-20260724", + "global.anthropic.claude-opus-5", + "us.anthropic.claude-opus-5", + "claude-sonnet-5@default", + "claude-mythos-5-1", + "claude-mythos-5.1", + "claude-5.1-mythos", + "global.anthropic.claude-mythos-5-1-v1:0", + "claude-mythos-5-1@default", + "CLAUDE-MYTHOS-5-1", + "claude-future", + ])("leaves thinking unchanged for %s", (id) => { + const model = claude(sdk.npm, id) + expect(ProviderTransform.providerOptions(model, {})).toEqual({ [sdk.key]: {} }) + expect( + ProviderTransform.providerOptions(model, { [sdk.option]: { type: "adaptive", display: "summarized" } }), + ).toEqual({ [sdk.key]: { [sdk.option]: { type: "adaptive", display: "summarized" } } }) + expect( + ProviderTransform.providerOptions(model, { [sdk.option]: { type: "enabled", budgetTokens: 4000 } }), + ).toEqual({ [sdk.key]: { [sdk.option]: { type: "enabled", budgetTokens: 4000 } } }) + }) + + test.each([ + ["claude-opus-5", "default"], + ["claude-opus-5", "high"], + ["claude-sonnet-5", "default"], + ["claude-sonnet-5", "high"], + ["claude-mythos-5-1", "default"], + ["claude-mythos-5-1", "high"], + ["claude-haiku-4-5", "title"], + ])("omits binding from the %s %s request body and betas", async (id, mode) => { + const requests: Request[] = [] + const capture = Object.assign( + async (...args: Parameters) => { + requests.push(new Request(...args)) + return Response.json( + sdk.key === "bedrock" + ? { + output: { message: { role: "assistant", content: [{ text: "ok" }] } }, + stopReason: "end_turn", + usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 }, + } + : { + type: "message", + id: "msg_1", + model: "test-model", + role: "assistant", + content: [{ type: "text", text: "ok" }], + stop_reason: "end_turn", + usage: { input_tokens: 1, output_tokens: 1 }, + }, + ) + }, + { preconnect: () => undefined }, + ) + const provider = + sdk.key === "bedrock" + ? createAmazonBedrock({ apiKey: "test-key", region: "ap-southeast-1", fetch: capture }) + : sdk.npm === "@ai-sdk/google-vertex/anthropic" + ? createVertexAnthropic({ + project: "test-project", + location: "global", + generateAuthToken: async () => "test-token", + fetch: capture, + }) + : createAnthropic({ apiKey: "test-key", fetch: capture }) + const model = claude( + sdk.npm, + sdk.key === "bedrock" + ? `global.anthropic.${id}` + : sdk.npm === "@ai-sdk/google-vertex/anthropic" + ? `${id}@default` + : id, + ) + const variants = ProviderTransform.variants(model) + const options = + mode === "title" + ? ProviderTransform.smallOptions({ ...model, variants }) + : mode === "high" + ? variants.high + : {} + await generateText({ + model: provider(model.api.id), + prompt: "hi", + maxOutputTokens: 32000, + providerOptions: ProviderTransform.providerOptions(model, options), + }) + expect(requests).toHaveLength(1) + const body = await requests[0].json() + const fields = sdk.key === "bedrock" ? (body.additionalModelRequestFields ?? {}) : body + expect(fields.thinking).toEqual( + mode === "title" + ? { type: "enabled", budget_tokens: 16000 } + : mode === "high" + ? { type: "adaptive", display: "summarized" } + : undefined, + ) + expect(fields.output_config).toEqual(mode === "high" ? { effort: "high" } : undefined) + expect(fields.anthropic_beta ?? []).not.toContain("thinking-binding-controls-2026-08-01") + expect(requests[0].headers.get("anthropic-beta")?.split(",") ?? []).not.toContain( + "thinking-binding-controls-2026-08-01", + ) + }) + }) }) test("leaves disabled thinking alone", () => { - const model = claude("@ai-sdk/anthropic", "claude-sonnet-5") + const model = claude("@ai-sdk/anthropic", "claude-fable-5-1") expect(ProviderTransform.providerOptions(model, { thinking: { type: "disabled" } })).toEqual({ anthropic: { thinking: { type: "disabled" } }, })