fix(ai): guard unsupported Claude Opus 5.5 request options

This commit is contained in:
nexxeln
2026-09-25 08:41:42 +00:00
committed by opencode-agent[bot]
parent 917d904f18
commit 04f3ef7bea
3 changed files with 93 additions and 0 deletions
@@ -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],