Skip to content
Closed
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
14 changes: 14 additions & 0 deletions packages/ai/src/protocols/anthropic-messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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 =
Expand Down
78 changes: 78 additions & 0 deletions packages/ai/test/provider/anthropic-messages.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) =>
Expand Down Expand Up @@ -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", () =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down
Loading