Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 16 additions & 12 deletions packages/opencode/src/provider/transform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } }
}
}
Expand Down
174 changes: 157 additions & 17 deletions packages/opencode/test/provider/transform.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof fetch>) => {
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" } },
})
Expand Down
Loading