fix(ai): drop stale thinking blocks on Bedrock Claude replays

Send thinking.block_binding with prefix_mismatch_behavior=drop_block and
the thinking-binding-controls-2026-08-01 beta for Bedrock Claude Opus 4.7+,
Sonnet 5+, and Fable 5, matching the Anthropic provider. Opus 4.6 and
Sonnet 4.6 reject the field, and GovCloud is skipped.

closes #10324
This commit is contained in:
Armin Ronacher 2026-10-02 22:38:43 +02:00
parent 9fba660cf1
commit 69f0be6f0a
3 changed files with 80 additions and 16 deletions

View file

@ -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

View file

@ -119,6 +119,8 @@ type Block = (TextContent | ThinkingContent | ToolCall) & {
const EMPTY_TEXT_PLACEHOLDER = "<empty>";
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<string, any> = 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<ThinkingLevel, number> = {

View file

@ -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"> = {