From 7fde21aefec8d9c95e0f9421ff0ad1a8b0387544 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Mon, 7 Sep 2026 23:08:36 -0500 Subject: [PATCH 1/3] fix(ai): default newer Claude models to drop invalid thinking --- .../ai/src/protocols/anthropic-messages.ts | 64 ++++++++++++++----- packages/ai/src/schema/options.ts | 2 + .../anthropic-thinking-binding.test.ts | 34 ++++++++++ packages/core/src/model-resolver.ts | 14 +++- .../core/test/model-thinking-binding.test.ts | 37 +++++++++++ 5 files changed, 133 insertions(+), 18 deletions(-) create mode 100644 packages/ai/test/provider/anthropic-thinking-binding.test.ts create mode 100644 packages/core/test/model-thinking-binding.test.ts diff --git a/packages/ai/src/protocols/anthropic-messages.ts b/packages/ai/src/protocols/anthropic-messages.ts index 40cd6c62979c..be5cd4a81b2b 100644 --- a/packages/ai/src/protocols/anthropic-messages.ts +++ b/packages/ai/src/protocols/anthropic-messages.ts @@ -50,15 +50,24 @@ const SSE_EVENTS = new Set([ ]) export const framing = Framing.sseEvents(SSE_EVENTS) +export type ThinkingBlockBinding = { + readonly prefix_mismatch_behavior?: "error" | "drop_block" | (string & {}) +} + export type ThinkingInput = | { readonly type: "adaptive" readonly display?: "summarized" | "omitted" + readonly block_binding?: ThinkingBlockBinding } | { readonly type: "disabled" } - | ({ readonly type: "enabled"; readonly display?: "summarized" | "omitted" } & ( + | ({ + readonly type: "enabled" + readonly display?: "summarized" | "omitted" + readonly block_binding?: ThinkingBlockBinding + } & ( | { readonly budgetTokens: number; readonly budget_tokens?: number } | { readonly budgetTokens?: number; readonly budget_tokens: number } )) @@ -301,15 +310,21 @@ const AnthropicToolChoice = Schema.Union([ }), ]) +const AnthropicThinkingBlockBinding = Schema.Struct({ + prefix_mismatch_behavior: Schema.optional(Schema.String), +}) + const AnthropicThinking = Schema.Union([ Schema.Struct({ type: Schema.tag("enabled"), budget_tokens: Schema.Number, display: Schema.optional(Schema.Literals(["summarized", "omitted"])), + block_binding: Schema.optional(AnthropicThinkingBlockBinding), }), Schema.Struct({ type: Schema.tag("adaptive"), display: Schema.optional(Schema.Literals(["summarized", "omitted"])), + block_binding: Schema.optional(AnthropicThinkingBlockBinding), }), Schema.Struct({ type: Schema.tag("disabled"), @@ -1026,7 +1041,7 @@ const resolveOptions = Effect.fn("AnthropicMessages.resolveOptions")(function* ( ...(outputConfigFormat === undefined ? {} : { format: outputConfigFormat }), } return { - thinking: yield* resolveThinking(input?.thinking), + thinking: yield* resolveThinking(input?.thinking, request), effort: outputConfigEffort, output_config, service_tier, @@ -1037,15 +1052,31 @@ const resolveOptions = Effect.fn("AnthropicMessages.resolveOptions")(function* ( } }) -const resolveThinking = Effect.fn("AnthropicMessages.resolveThinking")(function* (input: unknown) { - if (!ProviderShared.isRecord(input)) return undefined +const supportsThinkingBlockBinding = (request: LLMRequest) => { + if (request.model.compatibility?.supportsThinkingBlockBinding !== undefined) + return request.model.compatibility.supportsThinkingBlockBinding + // Accept namespaced IDs, dotted versions, and dated snapshots without treating a date as a minor version. + const version = /(?:^|[./])claude-[a-z]+-(\d+)(?:[.-](\d{1,2}))?(?:$|[-:])/i.exec(request.model.id) + return version !== null && (Number(version[1]) > 5 || (Number(version[1]) === 5 && Number(version[2] ?? 0) >= 1)) +} + +const resolveThinking = Effect.fn("AnthropicMessages.resolveThinking")(function* (input: unknown, request: LLMRequest) { + const supported = supportsThinkingBlockBinding(request) + if (!ProviderShared.isRecord(input)) + return supported + ? { type: "adaptive" as const, block_binding: { prefix_mismatch_behavior: "drop_block" } } + : undefined + if (input.type === "disabled") return { type: "disabled" as const } + if (input.type !== "adaptive" && input.type !== "enabled") return undefined + const binding = yield* ProviderShared.validateWith( + Schema.decodeUnknownEffect(Schema.UndefinedOr(AnthropicThinkingBlockBinding)), + )(input.block_binding) + const block_binding = supported ? { prefix_mismatch_behavior: "drop_block", ...binding } : binding const display = input.display === "summarized" || input.display === "omitted" ? (input.display as "summarized" | "omitted") : undefined - if (input.type === "adaptive") return { type: "adaptive" as const, ...(display === undefined ? {} : { display }) } - if (input.type === "disabled") return { type: "disabled" as const } - if (input.type !== "enabled") return undefined + if (input.type === "adaptive") return { type: "adaptive" as const, display, block_binding } const budget = typeof input.budgetTokens === "number" ? input.budgetTokens @@ -1054,7 +1085,7 @@ const resolveThinking = Effect.fn("AnthropicMessages.resolveThinking")(function* : undefined if (budget === undefined) return yield* ProviderShared.invalidRequest("Anthropic thinking provider option requires budgetTokens") - return { type: "enabled" as const, budget_tokens: budget, ...(display === undefined ? {} : { display }) } + return { type: "enabled" as const, budget_tokens: budget, display, block_binding } }) const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (request: LLMRequest) { @@ -1636,16 +1667,18 @@ export const protocol = Protocol.make({ }, }) -export const transport = >() => { +export const transport = < + Body extends Pick, +>() => { const http = HttpTransport.httpJson({ framing }) return { ...http, prepare: (input: Parameters[0]) => { - if ( - !input.body.context_management?.edits.length && - !input.body.messages.some((message) => message.content.some((block) => block.type === "compaction")) - ) - return http.prepare(input) + const compaction = + input.body.context_management?.edits.length || + input.body.messages.some((message) => message.content.some((block) => block.type === "compaction")) + const binding = input.body.thinking?.type !== "disabled" && input.body.thinking?.block_binding !== undefined + if (!compaction && !binding) return http.prepare(input) const headers = Headers.fromInput(input.request.http?.headers) const betas = new Set( (headers["anthropic-beta"] ?? "") @@ -1653,7 +1686,8 @@ export const transport = item.trim()) .filter(Boolean), ) - betas.add("compact-2026-01-12") + if (compaction) betas.add("compact-2026-01-12") + if (binding) betas.add("thinking-binding-controls-2026-08-01") return http.prepare({ ...input, request: LLMRequest.update(input.request, { diff --git a/packages/ai/src/schema/options.ts b/packages/ai/src/schema/options.ts index a1dffada699d..27a16fbe3c83 100644 --- a/packages/ai/src/schema/options.ts +++ b/packages/ai/src/schema/options.ts @@ -163,6 +163,8 @@ export class LanguageModelCompatibility extends Schema.Class + Effect.gen(function* () { + const request = LLM.request({ + model: AnthropicMessages.route.model({ id }), + prompt: "Hello", + http: { headers: { "anthropic-beta": "existing-beta" } }, + }) + const compiled = yield* compileRequest(request) + const prepared = yield* AnthropicMessages.route.prepareTransport(compiled.body, request) + expect(compiled.body.thinking).toEqual( + enabled ? { type: "adaptive", block_binding: { prefix_mismatch_behavior: "drop_block" } } : undefined, + ) + expect(prepared.request.headers["anthropic-beta"]).toBe( + enabled ? "existing-beta,thinking-binding-controls-2026-08-01" : "existing-beta", + ) + }), + ) +} diff --git a/packages/core/src/model-resolver.ts b/packages/core/src/model-resolver.ts index c8e9582c8b09..8edb1149b60e 100644 --- a/packages/core/src/model-resolver.ts +++ b/packages/core/src/model-resolver.ts @@ -195,11 +195,19 @@ const resolveCatalogModel = Effect.fn("ModelResolver.resolveCatalogModel")(funct return yield* Effect.try({ try: () => { const runtime = module.model(resolved.modelID ?? resolved.id, settings) + // Keep catalog dates here; the AI protocol also recognizes versioned Claude IDs. + const thinkingBinding = + runtime.route.protocol === "anthropic-messages" && + /(?:^|[./])claude-/i.test(runtime.id) && + resolved.time.released >= Date.UTC(2026, 8, 1) + ? { supportsThinkingBlockBinding: true } + : undefined return LanguageModel.update(runtime, { provider: resolved.canonical ?? resolved.providerID, - compatibility: resolved.compatibility - ? Object.assign({}, runtime.compatibility, resolved.compatibility) - : runtime.compatibility, + compatibility: + resolved.compatibility || thinkingBinding + ? Object.assign({}, thinkingBinding, runtime.compatibility, resolved.compatibility) + : runtime.compatibility, }) }, catch: () => unsupported(resolved), diff --git a/packages/core/test/model-thinking-binding.test.ts b/packages/core/test/model-thinking-binding.test.ts new file mode 100644 index 000000000000..fd87bcda9072 --- /dev/null +++ b/packages/core/test/model-thinking-binding.test.ts @@ -0,0 +1,37 @@ +import { describe, expect } from "bun:test" +import { LLM } from "@opencode/ai" +import { compileRequest } from "@opencode/ai/route/client" +import { Model } from "@opencode/schema/model" +import { Provider } from "@opencode/core/provider" +import { ModelResolver } from "@opencode/core/model-resolver" +import { Effect } from "effect" +import { it } from "./lib/effect" + +describe("Claude thinking-binding release policy", () => { + for (const fixture of [ + { id: "claude-new-family-1", released: "2026-09-01", enabled: true }, + { id: "claude-opus-5", released: "2026-08-31", enabled: false }, + { id: "claude-fable-5-1", released: "1970-01-01", enabled: true }, + { id: "kimi-new-1", released: "2027-01-01", enabled: false }, + ]) { + it.effect(`${fixture.id} released ${fixture.released}`, () => + Effect.gen(function* () { + const selected = yield* ModelResolver.fromCatalogModel( + Model.Info.make({ + ...Model.Info.default(Provider.ID.anthropic, Model.ID.make(fixture.id)), + modelID: Model.ID.make(fixture.id), + package: Provider.aisdk("@ai-sdk/anthropic"), + time: { released: Date.parse(fixture.released) }, + settings: { apiKey: "test", thinking: { type: "adaptive", display: "summarized" } }, + }), + ) + const prepared = yield* compileRequest(LLM.request({ model: selected, prompt: "Hello" })) + expect(prepared.body.thinking).toEqual({ + type: "adaptive", + display: "summarized", + ...(fixture.enabled ? { block_binding: { prefix_mismatch_behavior: "drop_block" } } : {}), + }) + }), + ) + } +}) From 850fbc49c2efdf29510eea1dd3adf10edc90ffa7 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Mon, 7 Sep 2026 23:15:41 -0500 Subject: [PATCH 2/3] refactor(ai): clarify thinking defaults and beta headers --- .../ai/src/protocols/anthropic-messages.ts | 74 ++++++++++++------- .../anthropic-thinking-binding.test.ts | 21 ++++++ 2 files changed, 68 insertions(+), 27 deletions(-) diff --git a/packages/ai/src/protocols/anthropic-messages.ts b/packages/ai/src/protocols/anthropic-messages.ts index be5cd4a81b2b..c8574402a004 100644 --- a/packages/ai/src/protocols/anthropic-messages.ts +++ b/packages/ai/src/protocols/anthropic-messages.ts @@ -330,6 +330,7 @@ const AnthropicThinking = Schema.Union([ type: Schema.tag("disabled"), }), ]) +type AnthropicThinking = typeof AnthropicThinking.Type // SDK OutputConfig:2684 {effort?: "low"|"medium"|"high"|"xhigh"|"max"|null, format?: JSONOutputFormat:2399} const AnthropicJsonOutputFormat = Schema.Struct({ @@ -1040,8 +1041,9 @@ const resolveOptions = Effect.fn("AnthropicMessages.resolveOptions")(function* ( ...(outputConfigEffort === undefined ? {} : { effort: outputConfigEffort }), ...(outputConfigFormat === undefined ? {} : { format: outputConfigFormat }), } + const thinking = yield* resolveThinking(input?.thinking) return { - thinking: yield* resolveThinking(input?.thinking, request), + thinking: applyThinkingBindingDefault(request.model, thinking), effort: outputConfigEffort, output_config, service_tier, @@ -1052,26 +1054,36 @@ const resolveOptions = Effect.fn("AnthropicMessages.resolveOptions")(function* ( } }) -const supportsThinkingBlockBinding = (request: LLMRequest) => { - if (request.model.compatibility?.supportsThinkingBlockBinding !== undefined) - return request.model.compatibility.supportsThinkingBlockBinding +const supportsThinkingBlockBinding = (model: LLMRequest["model"]) => { + const override = model.compatibility?.supportsThinkingBlockBinding + if (override !== undefined) return override // Accept namespaced IDs, dotted versions, and dated snapshots without treating a date as a minor version. - const version = /(?:^|[./])claude-[a-z]+-(\d+)(?:[.-](\d{1,2}))?(?:$|[-:])/i.exec(request.model.id) - return version !== null && (Number(version[1]) > 5 || (Number(version[1]) === 5 && Number(version[2] ?? 0) >= 1)) + const version = /(?:^|[./])claude-[a-z]+-(?\d+)(?:[.-](?\d{1,2}))?(?:$|[-:])/i.exec(model.id)?.groups + if (!version) return false + const major = Number(version.major) + const minor = Number(version.minor ?? 0) + return major > 5 || (major === 5 && minor >= 1) } -const resolveThinking = Effect.fn("AnthropicMessages.resolveThinking")(function* (input: unknown, request: LLMRequest) { - const supported = supportsThinkingBlockBinding(request) - if (!ProviderShared.isRecord(input)) - return supported - ? { type: "adaptive" as const, block_binding: { prefix_mismatch_behavior: "drop_block" } } - : undefined +const applyThinkingBindingDefault = (model: LLMRequest["model"], thinking: AnthropicThinking | undefined) => { + if (thinking?.type === "disabled") return thinking + if (!supportsThinkingBlockBinding(model)) return thinking + return { + ...(thinking ?? { type: "adaptive" as const }), + block_binding: { + prefix_mismatch_behavior: "drop_block", + ...thinking?.block_binding, + }, + } +} + +const resolveThinking = Effect.fn("AnthropicMessages.resolveThinking")(function* (input: unknown) { + if (!ProviderShared.isRecord(input)) return undefined if (input.type === "disabled") return { type: "disabled" as const } if (input.type !== "adaptive" && input.type !== "enabled") return undefined - const binding = yield* ProviderShared.validateWith( + const block_binding = yield* ProviderShared.validateWith( Schema.decodeUnknownEffect(Schema.UndefinedOr(AnthropicThinkingBlockBinding)), )(input.block_binding) - const block_binding = supported ? { prefix_mismatch_behavior: "drop_block", ...binding } : binding const display = input.display === "summarized" || input.display === "omitted" ? (input.display as "summarized" | "omitted") @@ -1674,20 +1686,14 @@ export const transport = < return { ...http, prepare: (input: Parameters[0]) => { - const compaction = - input.body.context_management?.edits.length || - input.body.messages.some((message) => message.content.some((block) => block.type === "compaction")) - const binding = input.body.thinking?.type !== "disabled" && input.body.thinking?.block_binding !== undefined - if (!compaction && !binding) return http.prepare(input) + const requiredBetas = requiredBetaHeaders(input.body) + if (requiredBetas.length === 0) return http.prepare(input) const headers = Headers.fromInput(input.request.http?.headers) - const betas = new Set( - (headers["anthropic-beta"] ?? "") - .split(",") - .map((item) => item.trim()) - .filter(Boolean), - ) - if (compaction) betas.add("compact-2026-01-12") - if (binding) betas.add("thinking-binding-controls-2026-08-01") + const existingBetas = (headers["anthropic-beta"] ?? "") + .split(",") + .map((item) => item.trim()) + .filter(Boolean) + const betas = new Set([...existingBetas, ...requiredBetas]) return http.prepare({ ...input, request: LLMRequest.update(input.request, { @@ -1701,6 +1707,20 @@ export const transport = < } } +function requiredBetaHeaders(body: Pick) { + const betas: string[] = [] + const requestsCompaction = (body.context_management?.edits.length ?? 0) > 0 + const replaysCompaction = body.messages.some((message) => + message.content.some((block) => block.type === "compaction"), + ) + if (requestsCompaction || replaysCompaction) betas.push("compact-2026-01-12") + + const thinking = body.thinking + if (thinking && thinking.type !== "disabled" && thinking.block_binding) + betas.push("thinking-binding-controls-2026-08-01") + return betas +} + export const route = Route.make({ id: ADAPTER, provider: "anthropic", diff --git a/packages/ai/test/provider/anthropic-thinking-binding.test.ts b/packages/ai/test/provider/anthropic-thinking-binding.test.ts index d080e2b8969f..034d88e40393 100644 --- a/packages/ai/test/provider/anthropic-thinking-binding.test.ts +++ b/packages/ai/test/provider/anthropic-thinking-binding.test.ts @@ -32,3 +32,24 @@ for (const [id, enabled] of [ }), ) } + +it.effect("preserves explicit thinking settings and combines required beta headers", () => + Effect.gen(function* () { + for (const thinking of [ + { type: "disabled" }, + { type: "adaptive", block_binding: { prefix_mismatch_behavior: "error" } }, + ] as const) { + const request = LLM.request({ + model: AnthropicMessages.route.model({ id: "claude-fable-5-1" }), + prompt: "Hello", + providerOptions: { thinking, contextManagement: { edits: [{ type: "compact_20260112" }] } }, + }) + const compiled = yield* compileRequest(request) + const prepared = yield* AnthropicMessages.route.prepareTransport(compiled.body, request) + expect(compiled.body.thinking).toEqual(thinking) + expect(prepared.request.headers["anthropic-beta"]).toBe( + thinking.type === "disabled" ? "compact-2026-01-12" : "compact-2026-01-12,thinking-binding-controls-2026-08-01", + ) + } + }), +) From 01745e9dc436d8e35dfc69a63547307800964bfa Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Mon, 7 Sep 2026 23:32:01 -0500 Subject: [PATCH 3/3] refactor(ai): keep thinking-binding defaults in the adapter --- .../ai/src/protocols/anthropic-messages.ts | 4 +- .../anthropic-thinking-binding.test.ts | 5 +++ packages/core/src/model-resolver.ts | 14 ++----- .../core/test/model-thinking-binding.test.ts | 37 ------------------- 4 files changed, 10 insertions(+), 50 deletions(-) delete mode 100644 packages/core/test/model-thinking-binding.test.ts diff --git a/packages/ai/src/protocols/anthropic-messages.ts b/packages/ai/src/protocols/anthropic-messages.ts index c8574402a004..1c4c32da3b80 100644 --- a/packages/ai/src/protocols/anthropic-messages.ts +++ b/packages/ai/src/protocols/anthropic-messages.ts @@ -1057,8 +1057,8 @@ const resolveOptions = Effect.fn("AnthropicMessages.resolveOptions")(function* ( const supportsThinkingBlockBinding = (model: LLMRequest["model"]) => { const override = model.compatibility?.supportsThinkingBlockBinding if (override !== undefined) return override - // Accept namespaced IDs, dotted versions, and dated snapshots without treating a date as a minor version. - const version = /(?:^|[./])claude-[a-z]+-(?\d+)(?:[.-](?\d{1,2}))?(?:$|[-:])/i.exec(model.id)?.groups + // Accept gateway namespaces and Vertex suffixes without treating a snapshot date as a minor version. + const version = /(?:^|[./])claude-[a-z]+-(?\d+)(?:[.-](?\d{1,2}))?(?:$|[-:@])/i.exec(model.id)?.groups if (!version) return false const major = Number(version.major) const minor = Number(version.minor ?? 0) diff --git a/packages/ai/test/provider/anthropic-thinking-binding.test.ts b/packages/ai/test/provider/anthropic-thinking-binding.test.ts index 034d88e40393..1258089f6ded 100644 --- a/packages/ai/test/provider/anthropic-thinking-binding.test.ts +++ b/packages/ai/test/provider/anthropic-thinking-binding.test.ts @@ -8,10 +8,15 @@ import { it } from "../lib/effect.js" for (const [id, enabled] of [ ["claude-fable-5-1", true], ["claude-mythos-5-1", true], + ["claude-fable-5.1", true], ["anthropic/claude-fable-5.1", true], + ["claude-fable-5-1@default", true], + ["claude-fable-5-1@20260901", true], ["claude-sonnet-6", true], ["claude-opus-5-20260901", false], ["claude-opus-4-8", false], + ["anthropic/claude-opus-4.8", false], + ["claude-fable-5@default", false], ["kimi-k2.5", false], ] as const) { it.effect(`thinking-binding defaults for ${id}`, () => diff --git a/packages/core/src/model-resolver.ts b/packages/core/src/model-resolver.ts index 8edb1149b60e..c8e9582c8b09 100644 --- a/packages/core/src/model-resolver.ts +++ b/packages/core/src/model-resolver.ts @@ -195,19 +195,11 @@ const resolveCatalogModel = Effect.fn("ModelResolver.resolveCatalogModel")(funct return yield* Effect.try({ try: () => { const runtime = module.model(resolved.modelID ?? resolved.id, settings) - // Keep catalog dates here; the AI protocol also recognizes versioned Claude IDs. - const thinkingBinding = - runtime.route.protocol === "anthropic-messages" && - /(?:^|[./])claude-/i.test(runtime.id) && - resolved.time.released >= Date.UTC(2026, 8, 1) - ? { supportsThinkingBlockBinding: true } - : undefined return LanguageModel.update(runtime, { provider: resolved.canonical ?? resolved.providerID, - compatibility: - resolved.compatibility || thinkingBinding - ? Object.assign({}, thinkingBinding, runtime.compatibility, resolved.compatibility) - : runtime.compatibility, + compatibility: resolved.compatibility + ? Object.assign({}, runtime.compatibility, resolved.compatibility) + : runtime.compatibility, }) }, catch: () => unsupported(resolved), diff --git a/packages/core/test/model-thinking-binding.test.ts b/packages/core/test/model-thinking-binding.test.ts deleted file mode 100644 index fd87bcda9072..000000000000 --- a/packages/core/test/model-thinking-binding.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { describe, expect } from "bun:test" -import { LLM } from "@opencode/ai" -import { compileRequest } from "@opencode/ai/route/client" -import { Model } from "@opencode/schema/model" -import { Provider } from "@opencode/core/provider" -import { ModelResolver } from "@opencode/core/model-resolver" -import { Effect } from "effect" -import { it } from "./lib/effect" - -describe("Claude thinking-binding release policy", () => { - for (const fixture of [ - { id: "claude-new-family-1", released: "2026-09-01", enabled: true }, - { id: "claude-opus-5", released: "2026-08-31", enabled: false }, - { id: "claude-fable-5-1", released: "1970-01-01", enabled: true }, - { id: "kimi-new-1", released: "2027-01-01", enabled: false }, - ]) { - it.effect(`${fixture.id} released ${fixture.released}`, () => - Effect.gen(function* () { - const selected = yield* ModelResolver.fromCatalogModel( - Model.Info.make({ - ...Model.Info.default(Provider.ID.anthropic, Model.ID.make(fixture.id)), - modelID: Model.ID.make(fixture.id), - package: Provider.aisdk("@ai-sdk/anthropic"), - time: { released: Date.parse(fixture.released) }, - settings: { apiKey: "test", thinking: { type: "adaptive", display: "summarized" } }, - }), - ) - const prepared = yield* compileRequest(LLM.request({ model: selected, prompt: "Hello" })) - expect(prepared.body.thinking).toEqual({ - type: "adaptive", - display: "summarized", - ...(fixture.enabled ? { block_binding: { prefix_mismatch_behavior: "drop_block" } } : {}), - }) - }), - ) - } -})