mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-16 10:30:01 +08:00
fix(provider): scope thinking binding to Claude 5.1+ (#46848)
This commit is contained in:
@@ -684,35 +684,39 @@ function anthropicOmitsThinking(apiId: string) {
|
|||||||
return anthropicUsesModernAdaptiveThinking(apiId)
|
return anthropicUsesModernAdaptiveThinking(apiId)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Opus 5, Sonnet 5, Fable 5.x, and Mythos 5.x think without a `thinking` parameter.
|
// Default to binding controls for Claude 5.1+ as enforcement expands to later models.
|
||||||
function anthropicThinksByDefault(apiId: string) {
|
// Mythos 5.1 explicitly does not run the conversation-prefix check.
|
||||||
const version = /claude-(?:[a-z]+-)?(\d+)(?:[.-](\d{1,2}))?(?:[.@-]|$)/i.exec(apiId)
|
// 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
|
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
|
// 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
|
// 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),
|
// 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.
|
// 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.
|
// The patched AI SDK adds the thinking-binding-controls beta whenever it is set.
|
||||||
const ANTHROPIC_BLOCK_BINDING = { prefixMismatchBehavior: "drop_block" }
|
const ANTHROPIC_BLOCK_BINDING = { prefixMismatchBehavior: "drop_block" }
|
||||||
|
|
||||||
function anthropicBlockBinding(model: Provider.Model, options: { [x: string]: any }) {
|
function anthropicBlockBinding(model: Provider.Model, options: { [x: string]: any }) {
|
||||||
if (!model.api.id.toLowerCase().includes("claude")) return options
|
if (!anthropicBindsThinking(model.api.id)) return options
|
||||||
const byDefault = anthropicThinksByDefault(model.api.id)
|
|
||||||
switch (model.api.npm) {
|
switch (model.api.npm) {
|
||||||
case "@ai-sdk/anthropic":
|
case "@ai-sdk/anthropic":
|
||||||
case "@ai-sdk/google-vertex/anthropic": {
|
case "@ai-sdk/google-vertex/anthropic": {
|
||||||
const thinking = options.thinking ?? (byDefault ? { type: "adaptive" } : undefined)
|
const thinking = options.thinking ?? { type: "adaptive" }
|
||||||
if (!thinking || (thinking.type !== "adaptive" && thinking.type !== "enabled")) return options
|
if (thinking.type !== "adaptive" && thinking.type !== "enabled") return options
|
||||||
return { ...options, thinking: { ...thinking, blockBinding: ANTHROPIC_BLOCK_BINDING } }
|
return { ...options, thinking: { ...thinking, blockBinding: ANTHROPIC_BLOCK_BINDING } }
|
||||||
}
|
}
|
||||||
case "@ai-sdk/amazon-bedrock": {
|
case "@ai-sdk/amazon-bedrock": {
|
||||||
const reasoningConfig = options.reasoningConfig ?? (byDefault ? { type: "adaptive" } : undefined)
|
const reasoningConfig = options.reasoningConfig ?? { type: "adaptive" }
|
||||||
if (!reasoningConfig || (reasoningConfig.type !== "adaptive" && reasoningConfig.type !== "enabled"))
|
if (reasoningConfig.type !== "adaptive" && reasoningConfig.type !== "enabled") return options
|
||||||
return options
|
|
||||||
return { ...options, reasoningConfig: { ...reasoningConfig, blockBinding: ANTHROPIC_BLOCK_BINDING } }
|
return { ...options, reasoningConfig: { ...reasoningConfig, blockBinding: ANTHROPIC_BLOCK_BINDING } }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -851,39 +851,179 @@ describe("ProviderTransform.providerOptions", () => {
|
|||||||
const binding = { prefixMismatchBehavior: "drop_block" }
|
const binding = { prefixMismatchBehavior: "drop_block" }
|
||||||
const claude = (npm: string, id: string) =>
|
const claude = (npm: string, id: string) =>
|
||||||
createModel({ providerID: "custom", api: { id, url: "https://example.com", npm } })
|
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", () => {
|
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({
|
expect(ProviderTransform.providerOptions(model, { thinking: { type: "adaptive" }, effort: "high" })).toEqual({
|
||||||
anthropic: { thinking: { type: "adaptive", blockBinding: binding }, effort: "high" },
|
anthropic: { thinking: { type: "adaptive", blockBinding: binding }, effort: "high" },
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test("adds blockBinding to explicit enabled thinking", () => {
|
test("leaves explicit enabled thinking on older models alone", () => {
|
||||||
const model = claude("@ai-sdk/anthropic", "claude-sonnet-4-5")
|
const model = claude("@ai-sdk/anthropic", "claude-haiku-4-5")
|
||||||
expect(ProviderTransform.providerOptions(model, { thinking: { type: "enabled", budgetTokens: 4000 } })).toEqual({
|
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", () => {
|
sdks.forEach((sdk) => {
|
||||||
for (const id of ["claude-fable-5-1", "claude-mythos-5-1", "claude-opus-5", "claude-sonnet-5"]) {
|
describe(sdk.npm, () => {
|
||||||
const model = claude("@ai-sdk/anthropic", id)
|
test.each([
|
||||||
expect(ProviderTransform.providerOptions(model, {})).toEqual({
|
"claude-fable-5-1",
|
||||||
anthropic: { thinking: { type: "adaptive", blockBinding: binding } },
|
"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", () => {
|
test.each([
|
||||||
for (const id of ["claude-opus-4-7", "claude-opus-4-5", "claude-sonnet-4-6", "claude-haiku-4-5"]) {
|
"claude-haiku-4-5",
|
||||||
const model = claude("@ai-sdk/anthropic", id)
|
"claude-opus-4-8",
|
||||||
expect(ProviderTransform.providerOptions(model, {})).toEqual({ anthropic: {} })
|
"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", () => {
|
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({
|
expect(ProviderTransform.providerOptions(model, { thinking: { type: "disabled" } })).toEqual({
|
||||||
anthropic: { thinking: { type: "disabled" } },
|
anthropic: { thinking: { type: "disabled" } },
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user