Compare commits

...
Author SHA1 Message Date
nexxeln 8a7a5526a1 fix(ai): support Sonnet 5.5 Messages request shapes 2026-09-29 08:39:07 +00:00
2 changed files with 193 additions and 5 deletions

No files matched your search

@@ -286,7 +286,13 @@ const AnthropicThinkingEnabled = Schema.Struct({
})
const AnthropicThinkingAdaptive = Schema.Struct({ type: Schema.tag("adaptive"), ...AnthropicThinkingFields })
const AnthropicThinkingDisabled = Schema.Struct({ type: Schema.tag("disabled") })
const AnthropicThinking = Schema.Union([AnthropicThinkingEnabled, AnthropicThinkingAdaptive, AnthropicThinkingDisabled])
const AnthropicThinkingBetweenTools = Schema.Struct({ type: Schema.tag("between_tools") })
const AnthropicThinking = Schema.Union([
AnthropicThinkingEnabled,
AnthropicThinkingAdaptive,
AnthropicThinkingDisabled,
AnthropicThinkingBetweenTools,
])
type AnthropicThinking = typeof AnthropicThinking.Type
// SDK OutputConfig:2684 {effort?: "low"|"medium"|"high"|"xhigh"|"max"|null, format?: JSONOutputFormat:2399}
@@ -332,7 +338,20 @@ const ThinkingEnabledInput = Schema.Union([
encode: SchemaGetter.passthrough({ strict: false }),
}),
)
const Thinking = Schema.Union([ThinkingEnabledInput, AnthropicThinkingAdaptive, AnthropicThinkingDisabled])
// Retain unsupported extra fields through decoding so we can reject them instead of silently stripping them.
const ThinkingBetweenToolsInput = Schema.Struct({
type: Schema.tag("between_tools"),
display: Schema.optional(Schema.String),
block_binding: Schema.optional(AnthropicThinkingBlockBinding),
budget_tokens: Schema.optional(Schema.Number),
budgetTokens: Schema.optional(Schema.Number),
})
const Thinking = Schema.Union([
ThinkingEnabledInput,
AnthropicThinkingAdaptive,
AnthropicThinkingDisabled,
ThinkingBetweenToolsInput,
])
const OutputConfigInput = Schema.Struct({
effort: optionalNull(Schema.String),
@@ -1000,7 +1019,7 @@ const supportsEffortUpdates = (model: LLMRequest["model"]) => {
}
const applyThinkingBindingDefault = (model: LLMRequest["model"], thinking: AnthropicThinking | undefined) => {
if (thinking?.type === "disabled") return thinking
if (thinking?.type === "disabled" || thinking?.type === "between_tools") return thinking
if (!supportsThinkingBlockBinding(model)) return thinking
return {
...(thinking ?? { type: "adaptive" as const }),
@@ -1027,12 +1046,51 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
const format = outputConfig?.format ?? undefined
const updates = resolveEffortUpdates(request, options.effort ?? outputConfig?.effort ?? undefined)
const generation = request.generation
const version = claudeVersion(request.model.id)
const sonnet55 = version?.family === "sonnet" && version.major === 5 && version.minor === 5
if (options.thinking?.type === "between_tools") {
if (!sonnet55) return yield* invalid("Anthropic Messages between_tools thinking requires Claude Sonnet 5.5")
if (options.thinking.display !== undefined) return yield* invalid("between_tools does not support display")
if (options.thinking.block_binding !== undefined)
return yield* invalid("between_tools does not support block_binding")
if (options.thinking.budget_tokens !== undefined || options.thinking.budgetTokens !== undefined)
return yield* invalid("between_tools does not support thinking budgets")
if (updates.effort === "xhigh" || updates.effort === "max")
return yield* invalid("Claude Sonnet 5.5 between_tools requires effort high or below")
if (
updates.request.messages.some((message) => {
const update = effortUpdate(message)
return update !== undefined && (update.effort ?? DEFAULT_EFFORT) !== (updates.effort ?? DEFAULT_EFFORT)
})
)
return yield* invalid("Claude Sonnet 5.5 between_tools cannot change effort mid-conversation")
}
if (sonnet55) {
if (options.thinking?.type === "disabled")
return yield* invalid(
"Claude Sonnet 5.5 does not support disabled thinking; use between_tools at high effort or below",
)
if (options.thinking?.type === "enabled")
return yield* invalid("Claude Sonnet 5.5 does not support thinking budgets; use adaptive thinking and effort")
if (generation?.temperature !== undefined && generation.temperature !== 1)
return yield* invalid("Claude Sonnet 5.5 does not support non-default sampling parameters")
if (generation?.topP !== undefined && generation.topP < 0.99)
return yield* invalid("Claude Sonnet 5.5 does not support non-default sampling parameters")
if (generation?.topK !== undefined)
return yield* invalid("Claude Sonnet 5.5 does not support top_k sampling; omit topK")
}
// Allocate the 4-breakpoint budget in invalidation order: tools → system →
// messages. Tools live highest in the cache hierarchy, so when callers
// over-mark we keep their tool hints and shed the message-tail ones first.
const breakpoints = Cache.newBreakpoints(ANTHROPIC_BREAKPOINT_CAP)
const flattened = ProviderShared.flattenToolRequest(updates.request)
const tools = flattened.tools.length === 0 ? undefined : flattened.tools.map((tool) => lowerTool(breakpoints, tool))
if (
sonnet55 &&
tools !== undefined &&
(request.toolChoice?.type === "required" || request.toolChoice?.type === "tool")
)
return yield* invalid("Claude Sonnet 5.5 does not support forced tool choice; use auto or none")
// Anthropic rejects tool_choice when tools are absent; "none" is only meaningful with tools present.
const toolChoice = tools === undefined || !request.toolChoice ? undefined : yield* lowerToolChoice(request.toolChoice)
const systemParts = request.system.filter((part) => part.text.length > 0)
@@ -1065,7 +1123,10 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
top_p: generation?.topP,
top_k: generation?.topK,
stop_sequences: generation?.stop,
thinking: applyThinkingBindingDefault(request.model, fitThinking(options.thinking, maxTokens)),
thinking: applyThinkingBindingDefault(
request.model,
options.thinking?.type === "between_tools" ? { type: "between_tools" } : fitThinking(options.thinking, maxTokens),
),
output_config,
// top-level passthrough per SDK MessageCreateParamsBase:4638,4643,4649,4654,4670
cache_control: options.cache_control ?? options.cacheControl,
@@ -1637,7 +1698,8 @@ function requiredBetaHeaders(body: Pick<AnthropicMessagesBody, "messages" | "con
betas.push("mid-conversation-output-config-2026-07-01")
const thinking = body.thinking
if (thinking && thinking.type !== "disabled" && thinking.block_binding) betas.push(THINKING_BINDING_BETA)
if (thinking && (thinking.type === "adaptive" || thinking.type === "enabled") && thinking.block_binding)
betas.push(THINKING_BINDING_BETA)
return betas
}
@@ -9,6 +9,7 @@ import {
Message,
ToolCallPart,
ToolDefinition,
ToolChoice,
Usage,
Media,
} from "../../src/index.js"
@@ -199,6 +200,131 @@ describe("Anthropic Messages route", () => {
}),
)
it.effect("sends Sonnet 5.5 between-tools thinking without binding or a binding beta", () =>
Effect.gen(function* () {
const sonnet = AnthropicMessages.route.model({ id: "claude-sonnet-5-5" })
const input = LLM.request({
model: sonnet,
prompt: "Hello",
providerOptions: { thinking: { type: "between_tools" }, effort: "medium" },
})
const compiled = yield* compileRequest(input)
const prepared = yield* AnthropicMessages.route.prepareTransport(compiled.body, input)
expect(compiled.body.thinking).toEqual({ type: "between_tools" })
expect(compiled.body.output_config).toEqual({ effort: "medium" })
expect(prepared.request.headers["anthropic-beta"]).not.toContain("thinking-binding-controls")
expect(
(yield* compileRequest(LLMRequest.update(input, { providerOptions: { effort: "high" } }))).body.thinking,
).toEqual({
type: "adaptive",
block_binding: { prefix_mismatch_behavior: "drop_block" },
})
}),
)
it.effect("rejects Sonnet 5.5 thinking settings that cannot retain caller intent", () =>
Effect.gen(function* () {
const sonnet = AnthropicMessages.route.model({ id: "anthropic/claude-sonnet-5-5" })
const invalid = [
{ thinking: { type: "disabled" } },
{ thinking: { type: "enabled", budgetTokens: 2048 } },
{ thinking: { type: "between_tools", block_binding: { prefix_mismatch_behavior: "drop_block" } } },
{ thinking: { type: "between_tools", display: "summarized" } },
{ thinking: { type: "between_tools", budget_tokens: 2048 } },
{ thinking: { type: "between_tools" }, effort: "xhigh" },
{ thinking: { type: "between_tools" }, output_config: { effort: "max" } },
]
const errors = yield* Effect.forEach(invalid, (providerOptions) =>
compileRequest(LLM.request({ model: sonnet, prompt: "Hello", providerOptions })).pipe(Effect.flip),
)
expect(errors.map((error) => error.reason._tag)).toEqual(invalid.map(() => "InvalidRequest"))
expect(errors[0]?.message).toContain("between_tools")
expect(errors[1]?.message).toContain("adaptive")
expect(errors[2]?.message).toContain("block_binding")
expect(errors[3]?.message).toContain("display")
expect(errors[4]?.message).toContain("budgets")
expect(errors[5]?.message).toContain("effort")
const older = yield* compileRequest(
LLM.request({
model: AnthropicMessages.route.model({ id: "claude-sonnet-5" }),
prompt: "Hello",
providerOptions: { thinking: { type: "between_tools" } },
}),
).pipe(Effect.flip)
expect(older.reason._tag).toBe("InvalidRequest")
}),
)
it.effect("rejects forced tools and non-default sampling only on Sonnet 5.5", () =>
Effect.gen(function* () {
const sonnet = AnthropicMessages.route.model({ id: "claude-sonnet-5-5" })
const tools = [{ name: "lookup", description: "Look up", inputSchema: { type: "object", properties: {} } }]
for (const toolChoice of ["required", ToolChoice.named("lookup")] as const) {
const error = yield* compileRequest(LLM.request({ model: sonnet, prompt: "Hello", tools, toolChoice })).pipe(
Effect.flip,
)
expect(error.reason._tag).toBe("InvalidRequest")
expect(error.message).toContain("tool choice")
}
for (const generation of [{ temperature: 0.5 }, { topP: 0.98 }, { topK: 10 }]) {
const error = yield* compileRequest(LLM.request({ model: sonnet, prompt: "Hello", generation })).pipe(
Effect.flip,
)
expect(error.reason._tag).toBe("InvalidRequest")
expect(error.message).toContain("sampling")
}
const accepted = yield* compileRequest(
LLM.request({
model: sonnet,
prompt: "Hello",
tools,
toolChoice: "auto",
generation: { temperature: 1, topP: 0.99 },
}),
)
expect(accepted.body.tool_choice).toEqual({ type: "auto" })
expect(accepted.body.temperature).toBe(1)
expect(accepted.body.top_p).toBe(0.99)
expect(
(yield* compileRequest(LLM.request({ model: sonnet, prompt: "Hello", tools, toolChoice: "none" }))).body
.tool_choice,
).toEqual({ type: "none" })
expect(
(yield* compileRequest(
LLM.request({
model: AnthropicMessages.route.model({ id: "claude-sonnet-5" }),
prompt: "Hello",
tools,
toolChoice: "required",
}),
)).body.tool_choice,
).toEqual({ type: "any" })
}),
)
it.effect("does not send per-message effort changes with Sonnet 5.5 between-tools thinking", () =>
Effect.gen(function* () {
const sonnet = AnthropicMessages.route.model({
id: "claude-sonnet-5-5",
compatibility: { supportsEffortUpdates: true },
})
const error = yield* compileRequest(
LLM.request({
model: sonnet,
messages: [
Message.user("Before"),
Message.effort({ effort: "low", previous: "medium" }),
Message.user("After"),
],
providerOptions: { thinking: { type: "between_tools" }, effort: "low" },
}),
).pipe(Effect.flip)
expect(error.reason._tag).toBe("InvalidRequest")
expect(error.message).toContain("mid-conversation")
}),
)
it.effect("lowers passthrough provider options and accepts either key spelling", () =>
Effect.gen(function* () {
const snake = yield* compileRequest(