diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index d6f4c0fa3..a0a2a02fd 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -17,6 +17,7 @@ - Fixed Cloudflare AI Gateway Claude models failing with a 404 by using dashed model IDs (`claude-opus-5-5` instead of `claude-opus-5.5`), which Anthropic requires - Fixed Sign in with ChatGPT continuing when its callback port is taken by another login, which made the browser show "OAuth state mismatch"; it now fails with a port-in-use error ([#10265](https://github.com/earendil-works/pi/issues/10265)) - Fixed Amazon Bedrock OpenAI models costing requests above 272k input tokens at the short-context rate; Bedrock models now include the pricing tiers listed on models.dev ([#10326](https://github.com/earendil-works/pi/issues/10326)) +- Fixed Amazon Bedrock Claude requests failing with "Invalid `signature` in `thinking` block" after the system prompt or tools changed; Claude Opus 4.7+, Sonnet 5+, and Fable 5 now drop stale thinking blocks like the Anthropic provider ([#10324](https://github.com/earendil-works/pi/issues/10324)) ## [1.0.0] - 2026-10-01 diff --git a/packages/ai/src/api/bedrock-converse-stream.ts b/packages/ai/src/api/bedrock-converse-stream.ts index 74f32f5ee..76aaef65e 100644 --- a/packages/ai/src/api/bedrock-converse-stream.ts +++ b/packages/ai/src/api/bedrock-converse-stream.ts @@ -119,6 +119,8 @@ type Block = (TextContent | ThinkingContent | ToolCall) & { const EMPTY_TEXT_PLACEHOLDER = ""; +const THINKING_BINDING_CONTROLS_BETA = "thinking-binding-controls-2026-08-01"; + /** Matches the placeholder the Anthropic API path uses for redacted thinking. */ const REDACTED_THINKING_PLACEHOLDER = "[Reasoning redacted]"; @@ -787,6 +789,22 @@ function supportsNativeXhighEffort(model: Model<"bedrock-converse-stream">): boo ); } +/** + * Check if the model accepts `thinking.block_binding`. Opus 4.6 and Sonnet 4.6 reject it with + * "thinking.adaptive.block_binding: Extra inputs are not permitted". + */ +function supportsThinkingBlockBinding(model: Model<"bedrock-converse-stream">): boolean { + const candidates = getModelMatchCandidates(model.id, model.name); + return candidates.some( + (s) => + s.includes("opus-4-7") || + s.includes("opus-4-8") || + s.includes("opus-5") || + s.includes("sonnet-5") || + s.includes("fable-5"), + ); +} + function mapThinkingLevelToEffort( model: Model<"bedrock-converse-stream">, level: SimpleStreamOptions["reasoning"], @@ -1244,11 +1262,21 @@ function buildAdditionalModelRequestFields( if (isAnthropicClaudeModel(model)) { // GovCloud Bedrock currently rejects the Claude thinking.display field. // Omit it there until the GovCloud Converse schema catches up. - const display = isGovCloudBedrockTarget(model, options) ? undefined : (options.thinkingDisplay ?? "summarized"); + const isGovCloud = isGovCloudBedrockTarget(model, options); + const display = isGovCloud ? undefined : (options.thinkingDisplay ?? "summarized"); + // Replayed signed thinking blocks are bound to the system prompt and tools they were + // created with. Bedrock 400s on replay after either changes unless stale blocks are + // dropped, matching the Anthropic provider. Skipped on GovCloud like display. + const useBlockBinding = !isGovCloud && supportsThinkingBlockBinding(model); const result: Record = supportsAdaptiveThinking(model.id, model.name) ? { - thinking: { type: "adaptive", ...(display !== undefined ? { display } : {}) }, + thinking: { + type: "adaptive", + ...(display !== undefined ? { display } : {}), + ...(useBlockBinding ? { block_binding: { prefix_mismatch_behavior: "drop_block" } } : {}), + }, output_config: { effort: mapThinkingLevelToEffort(model, options.reasoning) }, + ...(useBlockBinding ? { anthropic_beta: [THINKING_BINDING_CONTROLS_BETA] } : {}), } : (() => { const defaultBudgets: Record = { diff --git a/packages/ai/test/bedrock-thinking-payload.test.ts b/packages/ai/test/bedrock-thinking-payload.test.ts index 657c701e0..122fafc84 100644 --- a/packages/ai/test/bedrock-thinking-payload.test.ts +++ b/packages/ai/test/bedrock-thinking-payload.test.ts @@ -6,12 +6,24 @@ import { hasBedrockCredentials } from "./bedrock-utils.ts"; interface BedrockThinkingPayload { additionalModelRequestFields?: { - thinking?: { type: string; budget_tokens?: number; display?: string }; + thinking?: { + type: string; + budget_tokens?: number; + display?: string; + block_binding?: { prefix_mismatch_behavior: string }; + }; output_config?: { effort?: string }; anthropic_beta?: string[]; }; } +const THINKING_BINDING_CONTROLS_BETA = "thinking-binding-controls-2026-08-01"; +const ADAPTIVE_WITH_BINDING = { + type: "adaptive", + display: "summarized", + block_binding: { prefix_mismatch_behavior: "drop_block" }, +}; + class PayloadCaptured extends Error { constructor() { super("payload captured"); @@ -63,9 +75,9 @@ describe("Bedrock thinking payload", () => { const payload = await capturePayload(model); - expect(payload.additionalModelRequestFields?.thinking).toEqual({ type: "adaptive", display: "summarized" }); + expect(payload.additionalModelRequestFields?.thinking).toEqual(ADAPTIVE_WITH_BINDING); expect(payload.additionalModelRequestFields?.output_config).toEqual({ effort: "high" }); - expect(payload.additionalModelRequestFields?.anthropic_beta).toBeUndefined(); + expect(payload.additionalModelRequestFields?.anthropic_beta).toEqual([THINKING_BINDING_CONTROLS_BETA]); }); it("maps xhigh reasoning to effort=xhigh for Claude Opus 4.8", async () => { @@ -78,9 +90,9 @@ describe("Bedrock thinking payload", () => { const payload = await capturePayload(model, { reasoning: "xhigh" }); - expect(payload.additionalModelRequestFields?.thinking).toEqual({ type: "adaptive", display: "summarized" }); + expect(payload.additionalModelRequestFields?.thinking).toEqual(ADAPTIVE_WITH_BINDING); expect(payload.additionalModelRequestFields?.output_config).toEqual({ effort: "xhigh" }); - expect(payload.additionalModelRequestFields?.anthropic_beta).toBeUndefined(); + expect(payload.additionalModelRequestFields?.anthropic_beta).toEqual([THINKING_BINDING_CONTROLS_BETA]); }); it("uses adaptive thinking for Claude Fable 5 when reasoning is enabled", async () => { @@ -88,9 +100,9 @@ describe("Bedrock thinking payload", () => { const payload = await capturePayload(model); - expect(payload.additionalModelRequestFields?.thinking).toEqual({ type: "adaptive", display: "summarized" }); + expect(payload.additionalModelRequestFields?.thinking).toEqual(ADAPTIVE_WITH_BINDING); expect(payload.additionalModelRequestFields?.output_config).toEqual({ effort: "high" }); - expect(payload.additionalModelRequestFields?.anthropic_beta).toBeUndefined(); + expect(payload.additionalModelRequestFields?.anthropic_beta).toEqual([THINKING_BINDING_CONTROLS_BETA]); }); it("uses adaptive thinking for Claude Sonnet 5 when reasoning is enabled", async () => { @@ -98,9 +110,9 @@ describe("Bedrock thinking payload", () => { const payload = await capturePayload(model); - expect(payload.additionalModelRequestFields?.thinking).toEqual({ type: "adaptive", display: "summarized" }); + expect(payload.additionalModelRequestFields?.thinking).toEqual(ADAPTIVE_WITH_BINDING); expect(payload.additionalModelRequestFields?.output_config).toEqual({ effort: "high" }); - expect(payload.additionalModelRequestFields?.anthropic_beta).toBeUndefined(); + expect(payload.additionalModelRequestFields?.anthropic_beta).toEqual([THINKING_BINDING_CONTROLS_BETA]); }); it("uses adaptive thinking for Claude Opus 5 when reasoning is enabled", async () => { @@ -108,9 +120,9 @@ describe("Bedrock thinking payload", () => { const payload = await capturePayload(model); - expect(payload.additionalModelRequestFields?.thinking).toEqual({ type: "adaptive", display: "summarized" }); + expect(payload.additionalModelRequestFields?.thinking).toEqual(ADAPTIVE_WITH_BINDING); expect(payload.additionalModelRequestFields?.output_config).toEqual({ effort: "high" }); - expect(payload.additionalModelRequestFields?.anthropic_beta).toBeUndefined(); + expect(payload.additionalModelRequestFields?.anthropic_beta).toEqual([THINKING_BINDING_CONTROLS_BETA]); }); it("maps xhigh reasoning to effort=xhigh for Claude Opus 5", async () => { @@ -118,9 +130,9 @@ describe("Bedrock thinking payload", () => { const payload = await capturePayload(model, { reasoning: "xhigh" }); - expect(payload.additionalModelRequestFields?.thinking).toEqual({ type: "adaptive", display: "summarized" }); + expect(payload.additionalModelRequestFields?.thinking).toEqual(ADAPTIVE_WITH_BINDING); expect(payload.additionalModelRequestFields?.output_config).toEqual({ effort: "xhigh" }); - expect(payload.additionalModelRequestFields?.anthropic_beta).toBeUndefined(); + expect(payload.additionalModelRequestFields?.anthropic_beta).toEqual([THINKING_BINDING_CONTROLS_BETA]); }); it("maps xhigh reasoning to effort=xhigh for Claude Fable 5", async () => { @@ -128,10 +140,33 @@ describe("Bedrock thinking payload", () => { const payload = await capturePayload(model, { reasoning: "xhigh" }); - expect(payload.additionalModelRequestFields?.thinking).toEqual({ type: "adaptive", display: "summarized" }); + expect(payload.additionalModelRequestFields?.thinking).toEqual(ADAPTIVE_WITH_BINDING); expect(payload.additionalModelRequestFields?.output_config).toEqual({ effort: "xhigh" }); }); + // https://github.com/earendil-works/pi/issues/10324 + it("sends block_binding and the binding beta for Claude Opus 5.5", async () => { + const model = getModel("amazon-bedrock", "global.anthropic.claude-opus-5-5"); + + const payload = await capturePayload(model); + + expect(payload.additionalModelRequestFields?.thinking).toEqual(ADAPTIVE_WITH_BINDING); + expect(payload.additionalModelRequestFields?.anthropic_beta).toEqual([THINKING_BINDING_CONTROLS_BETA]); + }); + + // Bedrock rejects block_binding on 4.6 models: "Extra inputs are not permitted" (#10324) + it.each(["global.anthropic.claude-opus-4-6-v1", "global.anthropic.claude-sonnet-4-6"] as const)( + "omits block_binding for %s", + async (modelId) => { + const model = getModel("amazon-bedrock", modelId); + + const payload = await capturePayload(model); + + expect(payload.additionalModelRequestFields?.thinking).toEqual({ type: "adaptive", display: "summarized" }); + expect(payload.additionalModelRequestFields?.anthropic_beta).toBeUndefined(); + }, + ); + it("omits display for GovCloud model ids on non-adaptive Claude thinking", async () => { const baseModel = getModel("amazon-bedrock", "us.anthropic.claude-sonnet-4-5-20250929-v1:0"); const model: Model<"bedrock-converse-stream"> = {