fix(ai): bind Bedrock Claude thinking blocks by default (#51959)

This commit is contained in:
Aiden Cline
2026-09-28 23:38:20 -05:00
committed by GitHub
parent 36ef6cc216
commit 34a8938dc7
7 changed files with 353 additions and 20 deletions
@@ -29,6 +29,7 @@ import { JsonObject, knownString, optionalArray, optionalNull, ProviderShared }
import { classifyProviderFailure } from "../provider-error.js"
import { effortUpdate, resolveEffortUpdates } from "../effort-updates.js"
import * as Cache from "./utils/cache.js"
import { claudeVersion, supportsThinkingBlockBinding, THINKING_BINDING_BETA } from "./utils/claude-model.js"
import { Lifecycle } from "./utils/lifecycle.js"
import { ToolStream } from "./utils/tool-stream.js"
@@ -988,22 +989,6 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
return messages
})
// Accept gateway namespaces and Vertex suffixes without treating a snapshot date as a minor version.
const claudeVersion = (id: string) => {
const match = /(?:^|[./])claude-(?<family>[a-z]+)-(?<major>\d+)(?:[.-](?<minor>\d{1,2}))?(?:$|[-:@])/.exec(
id.toLowerCase(),
)?.groups
if (!match) return undefined
return { family: match.family, major: Number(match.major), minor: Number(match.minor ?? 0) }
}
const supportsThinkingBlockBinding = (model: LLMRequest["model"]) => {
const override = model.compatibility?.supportsThinkingBlockBinding
if (override !== undefined) return override
const version = claudeVersion(model.id)
return version !== undefined && (version.major > 5 || (version.major === 5 && version.minor >= 1))
}
const supportsEffortUpdates = (model: LLMRequest["model"]) => {
const override = model.compatibility?.supportsEffortUpdates
if (override !== undefined) return override
@@ -1652,8 +1637,7 @@ 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-controls-2026-08-01")
if (thinking && thinking.type !== "disabled" && thinking.block_binding) betas.push(THINKING_BINDING_BETA)
return betas
}
+24 -2
View File
@@ -23,6 +23,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 { Lifecycle } from "./utils/lifecycle.js"
import { MistralToolID } from "./utils/mistral-tool-id.js"
import { ToolStream } from "./utils/tool-stream.js"
@@ -443,6 +444,23 @@ const decodeOptions = ProviderShared.validateWith(Schema.decodeUnknownEffect(Opt
// 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(
Schema.Struct({
additionalModelRequestFields: Schema.Struct({ thinking: Schema.Struct({ type: Schema.Literal("disabled") }) }),
}),
)
// 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.
const applyThinkingBindingDefault = (request: LLMRequest, thinking: Readonly<Record<string, unknown>> | undefined) => {
if (isThinkingDisabled(request.http?.body)) return thinking
if (!supportsThinkingBlockBinding(request.model)) return thinking
return {
...(thinking ?? { type: "adaptive" as const }),
block_binding: { prefix_mismatch_behavior: "drop_block" },
}
}
const fromRequest = Effect.fn("BedrockConverse.fromRequest")(function* (request: LLMRequest) {
const toolChoice = request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined
const flattened = ProviderShared.flattenToolRequest(request)
@@ -450,7 +468,8 @@ const fromRequest = Effect.fn("BedrockConverse.fromRequest")(function* (request:
const options = yield* decodeOptions(request.providerOptions ?? {})
const maxTokens =
isNova2(request.model) && isHighReasoningEffort(request.http?.body) ? undefined : generation?.maxTokens
const thinking =
const thinking = applyThinkingBindingDefault(
request,
options.thinking === undefined
? undefined
: {
@@ -460,7 +479,8 @@ const fromRequest = Effect.fn("BedrockConverse.fromRequest")(function* (request:
maxTokens,
MIN_THINKING_BUDGET,
),
}
},
)
// Bedrock-Claude shares Anthropic's 4-breakpoint cap. Spend the budget in
// tools → system → messages order to favour the highest-impact prefixes.
const breakpoints = BedrockCache.breakpoints(request.model.id)
@@ -509,6 +529,8 @@ const fromRequest = Effect.fn("BedrockConverse.fromRequest")(function* (request:
: {
...(generation?.topK === undefined ? {} : { top_k: generation.topK }),
...(thinking === undefined ? {} : { thinking }),
// Converse takes Anthropic betas in the body, and Bedrock rejects `block_binding` without this one.
...(thinking?.block_binding === undefined ? {} : { anthropic_beta: [THINKING_BINDING_BETA] }),
},
}
})
@@ -0,0 +1,19 @@
import type { LLMRequest } from "../../schema/index.js"
export const THINKING_BINDING_BETA = "thinking-binding-controls-2026-08-01"
// Accept gateway namespaces and Vertex suffixes without treating a snapshot date as a minor version.
export const claudeVersion = (id: string) => {
const match = /(?:^|[./])claude-(?<family>[a-z]+)-(?<major>\d+)(?:[.-](?<minor>\d{1,2}))?(?:$|[-:@])/.exec(
id.toLowerCase(),
)?.groups
if (!match) return undefined
return { family: match.family, major: Number(match.major), minor: Number(match.minor ?? 0) }
}
export const supportsThinkingBlockBinding = (model: LLMRequest["model"]) => {
const override = model.compatibility?.supportsThinkingBlockBinding
if (override !== undefined) return override
const version = claudeVersion(model.id)
return version !== undefined && (version.major > 5 || (version.major === 5 && version.minor >= 1))
}
@@ -0,0 +1,34 @@
{
"version": 1,
"metadata": {
"tags": [
"prefix:bedrock-converse-thinking-binding",
"provider:amazon-bedrock",
"protocol:bedrock-converse",
"reasoning"
],
"name": "bedrock-converse-thinking-binding/accepts-the-default-binding-with-no-thinking-configured",
"recordedAt": "2026-09-28T23:09:14.527Z"
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://bedrock-runtime.us-east-1.amazonaws.com/model/global.anthropic.claude-opus-5-5/converse-stream",
"headers": {
"content-type": "application/json"
},
"body": "{\"modelId\":\"global.anthropic.claude-opus-5-5\",\"messages\":[{\"role\":\"user\",\"content\":[{\"text\":\"Say hello.\"}]}],\"system\":[{\"text\":\"Reply with the single word 'Hello'.\"}],\"inferenceConfig\":{\"maxTokens\":1024},\"additionalModelRequestFields\":{\"thinking\":{\"type\":\"adaptive\",\"block_binding\":{\"prefix_mismatch_behavior\":\"drop_block\"}},\"anthropic_beta\":[\"thinking-binding-controls-2026-08-01\"]}}"
},
"response": {
"status": 200,
"headers": {
"content-type": "application/vnd.amazon.eventstream"
},
"body": "AAAAoQAAAFKtAFmXCzpldmVudC10eXBlBwAMbWVzc2FnZVN0YXJ0DTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6QUJDREVGR0hJSiIsInJvbGUiOiJhc3Npc3RhbnQifUMJiXoAAAC8AAAAV0Ua/isLOmV2ZW50LXR5cGUHABFjb250ZW50QmxvY2tEZWx0YQ06Y29udGVudC10eXBlBwAQYXBwbGljYXRpb24vanNvbg06bWVzc2FnZS10eXBlBwAFZXZlbnR7ImNvbnRlbnRCbG9ja0luZGV4IjowLCJkZWx0YSI6eyJ0ZXh0IjoiSGVsbG8ifSwicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6QUJDRCJ91Ic6iwAAALQAAABWAm2FfAs6ZXZlbnQtdHlwZQcAEGNvbnRlbnRCbG9ja1N0b3ANOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJjb250ZW50QmxvY2tJbmRleCI6MCwicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6QUJDREVGR0hJSktMTU5PUFFSU1RVViJ9qr7BagAAAIYAAABRR+j7OQs6ZXZlbnQtdHlwZQcAC21lc3NhZ2VTdG9wDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsicCI6ImFiY2RlIiwic3RvcFJlYXNvbiI6ImVuZF90dXJuIn0pJ2GhAAABCAAAAE4PaiuaCzpldmVudC10eXBlBwAIbWV0YWRhdGENOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJtZXRyaWNzIjp7ImxhdGVuY3lNcyI6MTI0OX0sInAiOiJhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ekFCQ0RFRkdISUpLTE1OT1BRUlNUVVZXWFkiLCJ1c2FnZSI6eyJpbnB1dFRva2VucyI6MjgsIm91dHB1dFRva2VucyI6NSwic2VydmVyVG9vbFVzYWdlIjp7fSwidG90YWxUb2tlbnMiOjMzfX2s2SiQ",
"bodyEncoding": "base64"
}
}
]
}
@@ -0,0 +1,103 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { LLM, LLMRequest, Message } from "../../src/index.js"
import { LLMClient } from "../../src/route.js"
import { AmazonBedrock } from "../../src/providers.js"
import { recordedTests } from "../recorded-test.js"
const RECORDING_REGION = process.env.BEDROCK_RECORDING_REGION ?? "us-east-1"
// Claude Opus 5.5 enforces the conversation-prefix check on Bedrock, so a changed system prompt is a real
// mismatch. Override with BEDROCK_BINDING_MODEL_ID for another model that enforces it.
const modelID = process.env.BEDROCK_BINDING_MODEL_ID ?? "global.anthropic.claude-opus-5-5"
// Mirrors the "high" variant, so the recording covers the real overlay and default merge.
const model = (blockBinding?: { readonly prefix_mismatch_behavior: string }) =>
AmazonBedrock.model(modelID, {
apiKey: process.env.AWS_BEARER_TOKEN_BEDROCK ?? "fixture",
region: RECORDING_REGION,
body: {
additionalModelRequestFields: {
thinking: { type: "adaptive", display: "summarized", ...(blockBinding ? { block_binding: blockBinding } : {}) },
output_config: { effort: "high" },
},
},
})
const question = "How many positive integers below 5000 have exactly 12 positive divisors? Reply with the number only."
const first = LLM.request({
id: "recorded_bedrock_thinking_binding_first",
model: model(),
system: "You are a concise assistant.",
prompt: question,
cache: "none",
generation: { maxTokens: 12_000 },
})
const recorded = recordedTests({
prefix: "bedrock-converse-thinking-binding",
provider: "amazon-bedrock",
protocol: "bedrock-converse",
requires: ["AWS_BEARER_TOKEN_BEDROCK"],
})
describe("Bedrock Converse thinking binding recorded", () => {
recorded.effect.with("accepts the default binding with no thinking configured", { tags: ["reasoning"] }, () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
LLM.request({
id: "recorded_bedrock_thinking_binding_default",
model: AmazonBedrock.model(modelID, {
apiKey: process.env.AWS_BEARER_TOKEN_BEDROCK ?? "fixture",
region: RECORDING_REGION,
}),
system: "Reply with the single word 'Hello'.",
prompt: "Say hello.",
cache: "none",
generation: { maxTokens: 1_024 },
}),
)
expect(response.finishReason?.normalized).toBe("stop")
expect(response.text).toMatch(/hello/i)
}),
)
recorded.effect.with(
"keeps a session working after the system prompt changes",
{ tags: ["reasoning"] },
() =>
Effect.gen(function* () {
const turn = yield* LLMClient.generate(first)
// The signed thinking block is what Bedrock binds to the first system prompt.
expect(turn.message.content.some((part) => part.type === "reasoning")).toBe(true)
// Replay the assistant turn under a different system prompt, as a prompt rebuild or compaction would.
const next = LLMRequest.update(first, {
id: "recorded_bedrock_thinking_binding_next",
system: [{ type: "text", text: "You are a concise assistant. Today is Monday." }],
messages: [
...first.messages,
Message.assistant(turn.message.content),
Message.user("Now double it. Reply with the number only."),
],
})
const bound = yield* LLMClient.generate(next)
expect(bound.finishReason?.normalized).toBe("stop")
expect(bound.text).toMatch(/\d/)
// The same replay is rejected when the caller asks for the default `error` behavior, proving the check is
// enforced and that the default `drop_block` is what let the request above through.
const rejected = yield* LLMClient.generate(
LLMRequest.update(next, {
id: "recorded_bedrock_thinking_binding_error",
model: model({ prefix_mismatch_behavior: "error" }),
}),
).pipe(Effect.flip)
expect(rejected.message).toContain("bound to a different conversation")
}),
60_000,
)
})
@@ -0,0 +1,100 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { GenerationOptions, LanguageModel, LLM } from "../../src/index.js"
import { compileRequest } from "../../src/route/client.js"
import { AmazonBedrock } from "../../src/providers.js"
import { it } from "../lib/effect.js"
const binding = { prefix_mismatch_behavior: "drop_block" }
const beta = ["thinking-binding-controls-2026-08-01"]
const bedrock = (id: string, settings: Parameters<typeof AmazonBedrock.model>[1] = {}) =>
AmazonBedrock.model(id, { baseURL: "https://bedrock-runtime.test", apiKey: "test-bearer", ...settings })
const fields = (model: LanguageModel, generation?: GenerationOptions) =>
compileRequest(
LLM.request({ id: "req_1", model, system: "You are concise.", prompt: "Say hello.", cache: "none", generation }),
).pipe(Effect.map((prepared) => prepared.body.additionalModelRequestFields))
describe("Bedrock Converse thinking block binding", () => {
for (const id of [
"global.anthropic.claude-fable-5-1",
"us.anthropic.claude-fable-5-1-v1:0",
"anthropic.claude-fable-5.1",
"anthropic.claude-mythos-5-1",
"us.anthropic.claude-opus-5-5",
"global.anthropic.claude-sonnet-5-5",
"anthropic.claude-opus-6",
]) {
it.effect(`defaults adaptive thinking to drop_block with the beta for ${id}`, () =>
Effect.gen(function* () {
expect(yield* fields(bedrock(id))).toEqual({
thinking: { type: "adaptive", block_binding: binding },
anthropic_beta: beta,
})
}),
)
}
for (const id of [
"global.anthropic.claude-opus-5",
"us.anthropic.claude-sonnet-5",
"global.anthropic.claude-fable-5",
"anthropic.claude-fable-5-v1:0",
"global.anthropic.claude-opus-4-8",
"us.anthropic.claude-opus-4-5-20251101-v1:0",
"us.anthropic.claude-haiku-4-5-20251001-v1:0",
"anthropic.claude-3-5-sonnet-20241022-v2:0",
"us.amazon.nova-2-lite-v1:0",
]) {
it.effect(`leaves ${id} untouched`, () =>
Effect.gen(function* () {
expect(yield* fields(bedrock(id))).toBeUndefined()
}),
)
}
it.effect("binds a manual thinking budget and keeps top_k", () =>
Effect.gen(function* () {
expect(
yield* fields(
bedrock("global.anthropic.claude-fable-5-1", { thinking: { type: "enabled", budgetTokens: 4_000 } }),
GenerationOptions.make({ maxTokens: 64_000, topK: 40 }),
),
).toEqual({
top_k: 40,
thinking: { type: "enabled", budget_tokens: 4_000, block_binding: binding },
anthropic_beta: beta,
})
}),
)
it.effect("does not bind disabled thinking", () =>
Effect.gen(function* () {
expect(
yield* fields(
bedrock("global.anthropic.claude-fable-5-1", {
body: { additionalModelRequestFields: { thinking: { type: "disabled" } } },
}),
),
).toBeUndefined()
}),
)
it.effect("honors the compatibility override in both directions", () =>
Effect.gen(function* () {
const arn = "arn:aws:bedrock:us-east-1:123456789012:application-inference-profile/abc123"
expect(yield* fields(bedrock(arn))).toBeUndefined()
expect(
yield* fields(LanguageModel.update(bedrock(arn), { compatibility: { supportsThinkingBlockBinding: true } })),
).toEqual({ thinking: { type: "adaptive", block_binding: binding }, anthropic_beta: beta })
expect(
yield* fields(
LanguageModel.update(bedrock("global.anthropic.claude-fable-5-1"), {
compatibility: { supportsThinkingBlockBinding: false },
}),
),
).toBeUndefined()
}),
)
})