mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-16 10:30:01 +08:00
fix(ai): guard Sonnet 5.5 Bedrock Converse requests
This commit is contained in:
@@ -6,6 +6,7 @@ import {
|
||||
AIError,
|
||||
LLMEvent,
|
||||
Usage,
|
||||
mergeJsonRecords,
|
||||
type CacheHint,
|
||||
type FinishReason,
|
||||
type FinishReasonDetails,
|
||||
@@ -23,7 +24,7 @@ import { JsonObject, optionalArray, ProviderShared } from "./shared.js"
|
||||
import { BedrockAuth } from "./utils/bedrock-auth.js"
|
||||
import { BedrockCache } from "./utils/bedrock-cache.js"
|
||||
import { BedrockMedia } from "./utils/bedrock-media.js"
|
||||
import { supportsThinkingBlockBinding, THINKING_BINDING_BETA } from "./utils/claude-model.js"
|
||||
import { claudeVersion, supportsThinkingBlockBinding, THINKING_BINDING_BETA } from "./utils/claude-model.js"
|
||||
import { Lifecycle } from "./utils/lifecycle.js"
|
||||
import { MistralToolID } from "./utils/mistral-tool-id.js"
|
||||
import { ToolStream } from "./utils/tool-stream.js"
|
||||
@@ -428,6 +429,10 @@ const lowerSystem = (breakpoints: BedrockCache.Breakpoints, system: ReadonlyArra
|
||||
// Nova 2 rejects `maxTokens` at high reasoning effort, where its output can exceed the field's maximum. Other models
|
||||
// that take `reasoningConfig`, such as Grok on Bedrock, accept it.
|
||||
const isNova2 = (model: LanguageModel) => /\bamazon\.nova-2-/.test(model.id)
|
||||
const isSonnet55 = (model: LanguageModel) => {
|
||||
const version = claudeVersion(model.id)
|
||||
return version?.family === "sonnet" && version.major === 5 && version.minor === 5
|
||||
}
|
||||
const isHighReasoningEffort = Schema.is(
|
||||
Schema.Struct({
|
||||
additionalModelRequestFields: Schema.Struct({
|
||||
@@ -437,23 +442,31 @@ const isHighReasoningEffort = Schema.is(
|
||||
)
|
||||
|
||||
const Options = Schema.Struct({
|
||||
thinking: Schema.optional(Schema.Struct({ type: Schema.Literal("enabled"), budgetTokens: Schema.Number })),
|
||||
thinking: Schema.optional(
|
||||
Schema.Union([
|
||||
Schema.Struct({ type: Schema.Literal("enabled"), budgetTokens: Schema.Number }),
|
||||
Schema.Struct({ type: Schema.Literal("between_tools") }),
|
||||
]),
|
||||
),
|
||||
})
|
||||
export type OptionsInput = typeof Options.Type
|
||||
const decodeOptions = ProviderShared.validateWith(Schema.decodeUnknownEffect(Options))
|
||||
// Claude on Bedrock requires the thinking budget below `maxTokens`, with a minimum of 1,024.
|
||||
const MIN_THINKING_BUDGET = 1_024
|
||||
|
||||
const isThinkingDisabled = Schema.is(
|
||||
const isThinkingWithoutBinding = Schema.is(
|
||||
Schema.Struct({
|
||||
additionalModelRequestFields: Schema.Struct({ thinking: Schema.Struct({ type: Schema.Literal("disabled") }) }),
|
||||
additionalModelRequestFields: Schema.Struct({
|
||||
thinking: Schema.Struct({ type: Schema.Literals(["disabled", "between_tools"]) }),
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
// Claude 5.1+ binds each thinking signature to the prefix above it. Ask Bedrock to drop the affected blocks instead of
|
||||
// failing when that prefix changes. `http.body` overlays this field by field, so callers can still override it.
|
||||
// failing when that prefix changes. `http.body` overlays this field by field; between_tools cannot carry binding.
|
||||
const applyThinkingBindingDefault = (request: LLMRequest, thinking: Readonly<Record<string, unknown>> | undefined) => {
|
||||
if (isThinkingDisabled(request.http?.body)) return thinking
|
||||
if (thinking?.type === "between_tools") return thinking
|
||||
if (isThinkingWithoutBinding(request.http?.body)) return thinking
|
||||
if (!supportsThinkingBlockBinding(request.model)) return thinking
|
||||
return {
|
||||
...(thinking ?? { type: "adaptive" as const }),
|
||||
@@ -466,12 +479,16 @@ const fromRequest = Effect.fn("BedrockConverse.fromRequest")(function* (request:
|
||||
const flattened = ProviderShared.flattenToolRequest(request)
|
||||
const generation = request.generation
|
||||
const options = yield* decodeOptions(request.providerOptions ?? {})
|
||||
if (options.thinking?.type === "between_tools" && !isSonnet55(request.model))
|
||||
return yield* ProviderShared.invalidRequest("Bedrock Converse between_tools thinking requires Claude Sonnet 5.5")
|
||||
const maxTokens =
|
||||
isNova2(request.model) && isHighReasoningEffort(request.http?.body) ? undefined : generation?.maxTokens
|
||||
const thinking = applyThinkingBindingDefault(
|
||||
request,
|
||||
options.thinking === undefined
|
||||
? undefined
|
||||
: options.thinking.type === "between_tools"
|
||||
? { type: "between_tools" }
|
||||
: {
|
||||
type: "enabled",
|
||||
budget_tokens: ProviderShared.fitThinkingBudget(
|
||||
@@ -515,7 +532,7 @@ const fromRequest = Effect.fn("BedrockConverse.fromRequest")(function* (request:
|
||||
stopSequences: generation?.stop,
|
||||
}
|
||||
})()
|
||||
return {
|
||||
const body = {
|
||||
modelId: request.model.id,
|
||||
messages,
|
||||
system,
|
||||
@@ -533,6 +550,69 @@ const fromRequest = Effect.fn("BedrockConverse.fromRequest")(function* (request:
|
||||
...(thinking?.block_binding === undefined ? {} : { anthropic_beta: [THINKING_BINDING_BETA] }),
|
||||
},
|
||||
}
|
||||
if (isSonnet55(request.model)) yield* validateSonnet55Body(request, body)
|
||||
return body
|
||||
})
|
||||
|
||||
const validateSonnet55Body = Effect.fn("BedrockConverse.validateSonnet55Body")(function* (
|
||||
request: LLMRequest,
|
||||
body: Record<string, unknown>,
|
||||
) {
|
||||
// The HTTP body overlay is merged after protocol lowering; check the request that will actually be sent.
|
||||
const effective = mergeJsonRecords(body, request.http?.body)
|
||||
const config = effective?.toolConfig
|
||||
const choice = ProviderShared.isRecord(config) ? config.toolChoice : undefined
|
||||
if (ProviderShared.isRecord(choice) && ("any" in choice || "tool" in choice))
|
||||
return yield* ProviderShared.invalidRequest(
|
||||
"Bedrock Converse Claude Sonnet 5.5 does not support forced tool choice; use auto instead",
|
||||
)
|
||||
const additional = effective?.additionalModelRequestFields
|
||||
const thinking = ProviderShared.isRecord(additional) ? additional.thinking : undefined
|
||||
if (ProviderShared.isRecord(thinking) && thinking.type === "enabled")
|
||||
return yield* ProviderShared.invalidRequest(
|
||||
"Bedrock Converse Claude Sonnet 5.5 does not support manual thinking budgets; use adaptive thinking instead",
|
||||
)
|
||||
if (ProviderShared.isRecord(thinking) && thinking.type === "disabled")
|
||||
return yield* ProviderShared.invalidRequest(
|
||||
"Bedrock Converse Claude Sonnet 5.5 does not support disabled thinking; use between_tools instead",
|
||||
)
|
||||
if (
|
||||
ProviderShared.isRecord(thinking) &&
|
||||
thinking.type === "between_tools" &&
|
||||
("budget_tokens" in thinking || "block_binding" in thinking || "display" in thinking)
|
||||
)
|
||||
return yield* ProviderShared.invalidRequest(
|
||||
"Bedrock Converse Claude Sonnet 5.5 between_tools does not accept a budget, block binding, or display",
|
||||
)
|
||||
const output = ProviderShared.isRecord(additional) ? additional.output_config : undefined
|
||||
if (
|
||||
ProviderShared.isRecord(thinking) &&
|
||||
thinking.type === "between_tools" &&
|
||||
ProviderShared.isRecord(output) &&
|
||||
(output.effort === "xhigh" || output.effort === "max")
|
||||
)
|
||||
return yield* ProviderShared.invalidRequest(
|
||||
"Bedrock Converse Claude Sonnet 5.5 between_tools does not support xhigh or max effort; use adaptive thinking",
|
||||
)
|
||||
const inference = effective?.inferenceConfig
|
||||
if (ProviderShared.isRecord(inference) && inference.temperature !== undefined && inference.temperature !== 1)
|
||||
return yield* ProviderShared.invalidRequest(
|
||||
"Bedrock Converse Claude Sonnet 5.5 does not support non-default temperature; omit temperature instead",
|
||||
)
|
||||
// AWS's Claude parameter reference documents 0.999 for top_p, while Anthropic's API uses 1.
|
||||
if (
|
||||
ProviderShared.isRecord(inference) &&
|
||||
inference.topP !== undefined &&
|
||||
inference.topP !== 1 &&
|
||||
inference.topP !== 0.999
|
||||
)
|
||||
return yield* ProviderShared.invalidRequest(
|
||||
"Bedrock Converse Claude Sonnet 5.5 does not support non-default topP; omit topP instead",
|
||||
)
|
||||
if (ProviderShared.isRecord(additional) && additional.top_k !== undefined && additional.top_k !== 0)
|
||||
return yield* ProviderShared.invalidRequest(
|
||||
"Bedrock Converse Claude Sonnet 5.5 does not support non-default top_k; omit topK instead",
|
||||
)
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
import { EventStreamCodec } from "@smithy/eventstream-codec"
|
||||
import { fromUtf8, toUtf8 } from "@smithy/util-utf8"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { HttpClientRequest } from "effect/unstable/http"
|
||||
import { LLM, LLMRequest, ToolChoice, ToolDefinition } from "../../src/index.js"
|
||||
import { AmazonBedrock } from "../../src/providers.js"
|
||||
import { LLMClient } from "../../src/route.js"
|
||||
import { compileRequest } from "../../src/route/client.js"
|
||||
import { it } from "../lib/effect.js"
|
||||
import { dynamicResponse } from "../lib/http.js"
|
||||
|
||||
const bedrock = (id: string, settings: Parameters<typeof AmazonBedrock.model>[1] = {}) =>
|
||||
AmazonBedrock.model(id, { baseURL: "https://bedrock-runtime.test", apiKey: "test-bearer", ...settings })
|
||||
|
||||
const request = LLM.request({
|
||||
model: bedrock("global.anthropic.claude-sonnet-5-5"),
|
||||
prompt: "What is the weather?",
|
||||
cache: "none",
|
||||
tools: [ToolDefinition.make({ name: "lookup", description: "Weather lookup", inputSchema: { type: "object" } })],
|
||||
})
|
||||
|
||||
describe("Bedrock Converse Claude Sonnet 5.5", () => {
|
||||
it.effect("rejects required tool choice rather than silently allowing a different tool policy", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* compileRequest(
|
||||
LLMRequest.update(request, { toolChoice: ToolChoice.make({ type: "required" }) }),
|
||||
).pipe(Effect.flip)
|
||||
|
||||
expect(error.reason._tag).toBe("InvalidRequest")
|
||||
expect(error.message).toContain("tool choice")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects named tool choice supplied by a body overlay", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* compileRequest(
|
||||
LLMRequest.update(request, { http: { body: { toolConfig: { toolChoice: { tool: { name: "lookup" } } } } } }),
|
||||
).pipe(Effect.flip)
|
||||
|
||||
expect(error.reason._tag).toBe("InvalidRequest")
|
||||
expect(error.message).toContain("tool choice")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects manual thinking budgets instead of silently dropping the budget", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* compileRequest(
|
||||
LLMRequest.update(request, {
|
||||
model: bedrock("global.anthropic.claude-sonnet-5-5", {
|
||||
thinking: { type: "enabled", budgetTokens: 4_000 },
|
||||
}),
|
||||
}),
|
||||
).pipe(Effect.flip)
|
||||
|
||||
expect(error.reason._tag).toBe("InvalidRequest")
|
||||
expect(error.message).toContain("manual thinking")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects explicitly disabled thinking with an actionable alternative", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* compileRequest(
|
||||
LLMRequest.update(request, {
|
||||
http: { body: { additionalModelRequestFields: { thinking: { type: "disabled" } } } },
|
||||
}),
|
||||
).pipe(Effect.flip)
|
||||
|
||||
expect(error.reason._tag).toBe("InvalidRequest")
|
||||
expect(error.message).toContain("between_tools")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects non-default temperature without discarding the caller's sampling preference", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* compileRequest(LLMRequest.update(request, { generation: { temperature: 0 } })).pipe(
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error.reason._tag).toBe("InvalidRequest")
|
||||
expect(error.message).toContain("temperature")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects non-default topP configured through the Bedrock provider", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* compileRequest(
|
||||
LLMRequest.update(request, { model: bedrock("global.anthropic.claude-sonnet-5-5", { topP: 0.8 }) }),
|
||||
).pipe(Effect.flip)
|
||||
|
||||
expect(error.reason._tag).toBe("InvalidRequest")
|
||||
expect(error.message).toContain("topP")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects non-default topK in additional model request fields", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* compileRequest(LLMRequest.update(request, { generation: { topK: 40 } })).pipe(Effect.flip)
|
||||
|
||||
expect(error.reason._tag).toBe("InvalidRequest")
|
||||
expect(error.message).toContain("top_k")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not add adaptive thinking binding to an explicit between_tools request", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLMRequest.update(request, {
|
||||
http: { body: { additionalModelRequestFields: { thinking: { type: "between_tools" } } } },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.additionalModelRequestFields).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("accepts between_tools as a typed Bedrock model setting", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLMRequest.update(request, {
|
||||
model: bedrock("global.anthropic.claude-sonnet-5-5", { thinking: { type: "between_tools" } }),
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.additionalModelRequestFields).toEqual({ thinking: { type: "between_tools" } })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects a between_tools overlay that retains a manual budget", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* compileRequest(
|
||||
LLMRequest.update(request, {
|
||||
model: bedrock("global.anthropic.claude-sonnet-5-5", {
|
||||
thinking: { type: "enabled", budgetTokens: 4_000 },
|
||||
}),
|
||||
http: { body: { additionalModelRequestFields: { thinking: { type: "between_tools" } } } },
|
||||
}),
|
||||
).pipe(Effect.flip)
|
||||
|
||||
expect(error.reason._tag).toBe("InvalidRequest")
|
||||
expect(error.message).toContain("between_tools")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects between_tools at unsupported max effort", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* compileRequest(
|
||||
LLMRequest.update(request, {
|
||||
model: bedrock("global.anthropic.claude-sonnet-5-5", { thinking: { type: "between_tools" } }),
|
||||
http: { body: { additionalModelRequestFields: { output_config: { effort: "max" } } } },
|
||||
}),
|
||||
).pipe(Effect.flip)
|
||||
|
||||
expect(error.reason._tag).toBe("InvalidRequest")
|
||||
expect(error.message).toContain("max")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("sends between_tools without block binding and preserves auto tool choice on the wire", () =>
|
||||
Effect.gen(function* () {
|
||||
const bodies: Array<Record<string, unknown>> = []
|
||||
const response = new EventStreamCodec(toUtf8, fromUtf8).encode({
|
||||
headers: {
|
||||
":message-type": { type: "string", value: "event" },
|
||||
":event-type": { type: "string", value: "messageStop" },
|
||||
":content-type": { type: "string", value: "application/json" },
|
||||
},
|
||||
body: new TextEncoder().encode('{"stopReason":"end_turn"}'),
|
||||
})
|
||||
yield* LLMClient.generate(
|
||||
LLMRequest.update(request, {
|
||||
toolChoice: ToolChoice.make({ type: "auto" }),
|
||||
http: { body: { additionalModelRequestFields: { thinking: { type: "between_tools" } } } },
|
||||
}),
|
||||
).pipe(
|
||||
Effect.provide(
|
||||
dynamicResponse((input) =>
|
||||
Effect.gen(function* () {
|
||||
const sent = yield* HttpClientRequest.toWeb(input.request)
|
||||
bodies.push(JSON.parse(yield* Effect.promise(() => sent.text())))
|
||||
return input.respond(response, { headers: { "content-type": "application/vnd.amazon.eventstream" } })
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(bodies[0]?.additionalModelRequestFields).toEqual({ thinking: { type: "between_tools" } })
|
||||
expect(bodies[0]?.toolConfig).toMatchObject({ toolChoice: { auto: {} } })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps Sonnet 5.5 defaults and supported sampling defaults", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLMRequest.update(request, {
|
||||
toolChoice: ToolChoice.make({ type: "none" }),
|
||||
generation: { temperature: 1, topP: 0.999, topK: 0 },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.toolConfig?.toolChoice).toBeUndefined()
|
||||
expect(prepared.body.inferenceConfig).toMatchObject({ temperature: 1, topP: 0.999 })
|
||||
expect(prepared.body.additionalModelRequestFields).toMatchObject({ top_k: 0, thinking: { type: "adaptive" } })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps earlier Claude and other Bedrock models' request behavior", () =>
|
||||
Effect.gen(function* () {
|
||||
for (const id of ["us.anthropic.claude-sonnet-4-6", "us.amazon.nova-2-lite-v1:0"]) {
|
||||
const prepared = yield* compileRequest(
|
||||
LLMRequest.update(request, {
|
||||
model: bedrock(id),
|
||||
toolChoice: ToolChoice.make({ type: "required" }),
|
||||
generation: { temperature: 0 },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.toolConfig?.toolChoice).toEqual({ any: {} })
|
||||
expect(prepared.body.inferenceConfig).toEqual({ temperature: 0 })
|
||||
}
|
||||
}),
|
||||
)
|
||||
})
|
||||
Reference in New Issue
Block a user