mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-16 10:30:01 +08:00
fix(ai): guard unsupported Claude Opus 5.5 request options
This commit is contained in:
committed by
opencode-agent[bot]
parent
917d904f18
commit
04f3ef7bea
@@ -997,6 +997,11 @@ const claudeVersion = (id: string) => {
|
||||
return { family: match.family, major: Number(match.major), minor: Number(match.minor ?? 0) }
|
||||
}
|
||||
|
||||
const isClaudeOpus55 = (model: LLMRequest["model"]) => {
|
||||
const version = claudeVersion(model.id)
|
||||
return version?.family === "opus" && version.major === 5 && version.minor === 5
|
||||
}
|
||||
|
||||
const supportsThinkingBlockBinding = (model: LLMRequest["model"]) => {
|
||||
const override = model.compatibility?.supportsThinkingBlockBinding
|
||||
if (override !== undefined) return override
|
||||
@@ -1037,6 +1042,13 @@ const fitThinking = (thinking: AnthropicThinking | undefined, maxTokens: number)
|
||||
|
||||
const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (request: LLMRequest) {
|
||||
const options = yield* decodeOptions(request.providerOptions ?? {})
|
||||
const opus55 = isClaudeOpus55(request.model)
|
||||
// Neither a manual thinking budget nor forced tool use has an equivalent on Opus 5.5.
|
||||
// Reject explicitly requested behavior rather than silently changing its meaning.
|
||||
if (opus55 && options.thinking && options.thinking.type !== "adaptive")
|
||||
return yield* invalid(
|
||||
"Claude Opus 5.5 only supports adaptive thinking; omit thinking or use effort to control its depth",
|
||||
)
|
||||
const management = options.contextManagement
|
||||
const outputConfig = options.output_config ?? options.outputConfig
|
||||
const format = outputConfig?.format ?? undefined
|
||||
@@ -1049,6 +1061,8 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
|
||||
const flattened = ProviderShared.flattenToolRequest(updates.request)
|
||||
const tools = flattened.tools.length === 0 ? undefined : flattened.tools.map((tool) => lowerTool(breakpoints, tool))
|
||||
// Anthropic rejects tool_choice when tools are absent; "none" is only meaningful with tools present.
|
||||
if (tools && opus55 && (request.toolChoice?.type === "required" || request.toolChoice?.type === "tool"))
|
||||
return yield* invalid("Claude Opus 5.5 does not support forced tool use; use toolChoice auto or none")
|
||||
const toolChoice = tools === undefined || !request.toolChoice ? undefined : yield* lowerToolChoice(request.toolChoice)
|
||||
const systemParts = request.system.filter((part) => part.text.length > 0)
|
||||
const system =
|
||||
|
||||
@@ -170,6 +170,41 @@ describe("Anthropic Messages route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects unsupported thinking modes for Claude Opus 5.5 before sending", () =>
|
||||
Effect.gen(function* () {
|
||||
for (const thinking of [{ type: "disabled" as const }, { type: "enabled" as const, budgetTokens: 2_048 }]) {
|
||||
const error = yield* compileRequest(
|
||||
LLMRequest.update(request, {
|
||||
model: AnthropicMessages.route.model({ id: "claude-opus-5-5" }),
|
||||
providerOptions: { thinking },
|
||||
}),
|
||||
).pipe(Effect.flip)
|
||||
|
||||
expect(error.reason._tag).toBe("InvalidRequest")
|
||||
expect(error.message).toContain("Claude Opus 5.5")
|
||||
expect(error.message).toContain("effort")
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps adaptive thinking and effort on Claude Opus 5.5", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLMRequest.update(request, {
|
||||
model: AnthropicMessages.route.model({ id: "claude-opus-5-5" }),
|
||||
providerOptions: { thinking: { type: "adaptive", display: "summarized" }, effort: "low" },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.thinking).toEqual({
|
||||
type: "adaptive",
|
||||
display: "summarized",
|
||||
block_binding: { prefix_mismatch_behavior: "drop_block" },
|
||||
})
|
||||
expect(prepared.body.output_config).toEqual({ effort: "low" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("fits the thinking budget to half the output limit", () =>
|
||||
Effect.gen(function* () {
|
||||
const thinking = (maxTokens: number) =>
|
||||
@@ -730,6 +765,49 @@ describe("Anthropic Messages route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects forced tool use for Claude Opus 5.5 before sending", () =>
|
||||
Effect.gen(function* () {
|
||||
for (const toolChoice of ["required" as const, { type: "tool" as const, name: "lookup" }]) {
|
||||
const error = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: AnthropicMessages.route.model({ id: "anthropic.claude-opus-5-5@default" }),
|
||||
prompt: "Look it up",
|
||||
tools: [{ name: "lookup", description: "Look things up", inputSchema: { type: "object" } }],
|
||||
toolChoice,
|
||||
}),
|
||||
).pipe(Effect.flip)
|
||||
|
||||
expect(error.reason._tag).toBe("InvalidRequest")
|
||||
expect(error.message).toContain("Claude Opus 5.5")
|
||||
expect(error.message).toContain("auto")
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves supported tool choices on Opus 5.5 and forced choices on Opus 5", () =>
|
||||
Effect.gen(function* () {
|
||||
const choices = yield* Effect.forEach(
|
||||
[
|
||||
{ id: "claude-opus-5-5", toolChoice: "auto" as const },
|
||||
{ id: "claude-opus-5-5", toolChoice: "none" as const },
|
||||
{ id: "claude-opus-5", toolChoice: "required" as const },
|
||||
{ id: "claude-opus-5", toolChoice: { type: "tool" as const, name: "lookup" } },
|
||||
],
|
||||
({ id, toolChoice }) =>
|
||||
compileRequest(
|
||||
LLM.request({
|
||||
model: AnthropicMessages.route.model({ id }),
|
||||
prompt: "Look it up",
|
||||
tools: [{ name: "lookup", description: "Look things up", inputSchema: { type: "object" } }],
|
||||
toolChoice,
|
||||
}),
|
||||
).pipe(Effect.map((prepared) => prepared.body.tool_choice)),
|
||||
)
|
||||
|
||||
expect(choices).toEqual([{ type: "auto" }, { type: "none" }, { type: "any" }, { type: "tool", name: "lookup" }])
|
||||
}),
|
||||
)
|
||||
|
||||
// Regression: read tool results must stay structured so base64 media data is
|
||||
// not JSON-stringified into `tool_result.content`.
|
||||
it.effect("lowers media tool-result content as structured blocks", () =>
|
||||
|
||||
@@ -12,6 +12,7 @@ for (const [id, enabled] of [
|
||||
["anthropic/claude-fable-5.1", true],
|
||||
["claude-fable-5-1@default", true],
|
||||
["claude-fable-5-1@20260901", true],
|
||||
["claude-opus-5-5", true],
|
||||
["claude-sonnet-6", true],
|
||||
["claude-opus-5-20260901", false],
|
||||
["claude-opus-4-8", false],
|
||||
|
||||
Reference in New Issue
Block a user