diff --git a/packages/ai/src/protocols/anthropic-messages.ts b/packages/ai/src/protocols/anthropic-messages.ts index 33805d8e2831..cbaca5c1b684 100644 --- a/packages/ai/src/protocols/anthropic-messages.ts +++ b/packages/ai/src/protocols/anthropic-messages.ts @@ -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 = diff --git a/packages/ai/test/provider/anthropic-messages.test.ts b/packages/ai/test/provider/anthropic-messages.test.ts index 173a8c7b8f98..085d1b13af66 100644 --- a/packages/ai/test/provider/anthropic-messages.test.ts +++ b/packages/ai/test/provider/anthropic-messages.test.ts @@ -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", () => diff --git a/packages/ai/test/provider/anthropic-thinking-binding.test.ts b/packages/ai/test/provider/anthropic-thinking-binding.test.ts index 3df8e873b774..f7826062e0e7 100644 --- a/packages/ai/test/provider/anthropic-thinking-binding.test.ts +++ b/packages/ai/test/provider/anthropic-thinking-binding.test.ts @@ -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],