From ee9deba239bcae96cd87b3eafb1576f565282887 Mon Sep 17 00:00:00 2001 From: roboomp Date: Mon, 25 May 2026 19:08:00 +0000 Subject: [PATCH] fix(ai): defaulted bedrock claude thinking.display to summarized MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Anthropic silently switched adaptive-thinking's display default to "omitted" starting with Claude Opus 4.7, so the Bedrock provider stopped surfacing any reasoning content — observed in the wild as silent streams and timeouts on long reasoning runs. Mirrors the existing direct-Anthropic behavior in this repo: - For adaptive-thinking models that accept the field (Opus 4.7+), emit { type: "adaptive", display: "summarized" } by default. - For older adaptive-thinking models (Opus 4.6, Sonnet 4.6+) that reject the field, keep emitting just { type: "adaptive" }. - For budget-based Claude models, always set display. - Expose BedrockOptions.thinkingDisplay ("summarized" | "omitted") so callers can opt out, and wire hideThinkingSummary into the bedrock-converse-stream case in stream.ts. Fixes #1373 --- packages/ai/CHANGELOG.md | 4 + packages/ai/src/providers/amazon-bedrock.ts | 46 +++++++- packages/ai/src/stream.ts | 1 + packages/ai/test/issue-1373-repro.test.ts | 123 ++++++++++++++++++++ 4 files changed, 172 insertions(+), 2 deletions(-) create mode 100644 packages/ai/test/issue-1373-repro.test.ts diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 47565475a..fd86a7790 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed Claude Opus 4.7 on Amazon Bedrock streaming no reasoning output (and appearing to hang on long reasoning runs) because Anthropic silently switched the adaptive-thinking display default to `"omitted"`. The Bedrock provider now sends `thinking.display = "summarized"` by default on Opus 4.7+ adaptive models and on budget-based Claude models, mirroring the existing direct-Anthropic behavior. `BedrockOptions.thinkingDisplay` (`"summarized" | "omitted"`) is exposed for callers that want to opt out, and `hideThinkingSummary` now wires through to the Bedrock case ([#1373](https://github.com/can1357/oh-my-pi/issues/1373)). + ## [15.3.2] - 2026-05-25 ### Added diff --git a/packages/ai/src/providers/amazon-bedrock.ts b/packages/ai/src/providers/amazon-bedrock.ts index c6a618a92..bf74b62c8 100644 --- a/packages/ai/src/providers/amazon-bedrock.ts +++ b/packages/ai/src/providers/amazon-bedrock.ts @@ -37,6 +37,8 @@ import { decodeEventStream } from "./aws-eventstream"; import { signRequest } from "./aws-sigv4"; import { transformMessages } from "./transform-messages"; +export type BedrockThinkingDisplay = "summarized" | "omitted"; + export interface BedrockOptions extends StreamOptions { region?: string; profile?: string; @@ -47,6 +49,19 @@ export interface BedrockOptions extends StreamOptions { thinkingBudgets?: ThinkingBudgets; /* Only supported by Claude 4.x models, see https://docs.aws.amazon.com/bedrock/latest/userguide/claude-messages-extended-thinking.html#claude-messages-extended-thinking-tool-use-interleaved */ interleavedThinking?: boolean; + /** + * Controls how Claude returns thinking content in Bedrock responses. + * - `"summarized"`: thinking blocks include human-readable summaries (default here). + * - `"omitted"`: thinking content is suppressed; the encrypted signature still + * travels back for multi-turn continuity. + * + * Starting with Claude Opus 4.7 the Anthropic API default is `"omitted"`, which + * leaves callers waiting on a silent stream during long reasoning runs (issue + * #1373). We default to `"summarized"` so adaptive-thinking models that accept + * the field keep producing visible thinking deltas. Older adaptive-thinking + * models (Opus 4.6, Sonnet 4.6+) reject the field, so we omit it for them. + */ + thinkingDisplay?: BedrockThinkingDisplay; } type Block = (TextContent | ThinkingContent | ToolCall) & { index?: number; partialJson?: string }; @@ -753,8 +768,16 @@ function buildAdditionalModelRequestFields( const mode = model.thinking?.mode; if (mode === "anthropic-adaptive") { const effort = mapEffortToAnthropicAdaptiveEffort(model, reasoning); + // Starting with Claude Opus 4.7, Anthropic switched the adaptive-thinking + // default to "omitted", which silently suppresses streamed reasoning and + // can read as a stalled stream during long reasoning runs (issue #1373). + // Opt back into "summarized" by default on models that accept the field. + const adaptive: { type: "adaptive"; display?: BedrockThinkingDisplay } = { type: "adaptive" }; + if (supportsAdaptiveThinkingDisplay(model.id)) { + adaptive.display = options.thinkingDisplay ?? "summarized"; + } return { - thinking: { type: "adaptive" }, + thinking: adaptive, output_config: { effort }, }; } @@ -770,7 +793,11 @@ function buildAdditionalModelRequestFields( const budget = options.thinkingBudgets?.[level] ?? defaultBudgets[level]; const result: Record = { - thinking: { type: "enabled", budget_tokens: budget }, + thinking: { + type: "enabled", + budget_tokens: budget, + display: options.thinkingDisplay ?? "summarized", + }, }; if (options.interleavedThinking) { @@ -780,6 +807,21 @@ function buildAdditionalModelRequestFields( return result; } +/** + * Adaptive thinking `display` is supported starting with Claude Opus 4.7. + * Older adaptive-thinking models (Opus 4.6, Sonnet 4.6+) reject the field. + * Bedrock model ids are prefixed with region/inference-profile slugs (e.g. + * `eu.anthropic.claude-opus-4-7-...`); the regex matches the `claude-opus-X-Y` + * fragment regardless of prefix. + */ +function supportsAdaptiveThinkingDisplay(modelId: string): boolean { + const match = /claude-opus-(\d+)-(\d+)/.exec(modelId); + if (!match) return false; + const major = Number(match[1]); + const minor = Number(match[2]); + return major > 4 || (major === 4 && minor >= 7); +} + /** * Bedrock's wire format expects the image as `{ source: { bytes: }, format }`. * The caller already passes base64-encoded data, so no decode/re-encode round-trip is needed. diff --git a/packages/ai/src/stream.ts b/packages/ai/src/stream.ts index 9deee0a0e..6a5e77ac7 100644 --- a/packages/ai/src/stream.ts +++ b/packages/ai/src/stream.ts @@ -657,6 +657,7 @@ function mapOptionsForApi( reasoning: options?.reasoning, thinkingBudgets: options?.thinkingBudgets, toolChoice: mapAnthropicToolChoice(options?.toolChoice), + thinkingDisplay: options?.hideThinkingSummary ? "omitted" : undefined, }; // Adaptive mode sends effort directly, no budget_tokens — skip budget inflation. if (model.thinking?.mode === "anthropic-adaptive") { diff --git a/packages/ai/test/issue-1373-repro.test.ts b/packages/ai/test/issue-1373-repro.test.ts new file mode 100644 index 000000000..986acbf3c --- /dev/null +++ b/packages/ai/test/issue-1373-repro.test.ts @@ -0,0 +1,123 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { Effort } from "../src/model-thinking"; +import { streamBedrock } from "../src/providers/amazon-bedrock"; +import type { Context, Model } from "../src/types"; + +const originalSkipAuth = process.env.AWS_BEDROCK_SKIP_AUTH; + +beforeAll(() => { + process.env.AWS_BEDROCK_SKIP_AUTH = "1"; +}); + +afterAll(() => { + if (originalSkipAuth === undefined) delete process.env.AWS_BEDROCK_SKIP_AUTH; + else process.env.AWS_BEDROCK_SKIP_AUTH = originalSkipAuth; +}); + +function adaptiveModel(id: string): Model<"bedrock-converse-stream"> { + return { + id, + name: id, + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + reasoning: true, + input: ["text", "image"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 1_000_000, + maxTokens: 128_000, + thinking: { mode: "anthropic-adaptive", minLevel: Effort.Minimal, maxLevel: Effort.XHigh }, + }; +} + +function budgetModel(id: string): Model<"bedrock-converse-stream"> { + return { + id, + name: id, + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + reasoning: true, + input: ["text", "image"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 200_000, + maxTokens: 64_000, + thinking: { mode: "budget", minLevel: Effort.Minimal, maxLevel: Effort.High }, + }; +} + +const baseContext: Context = { + systemPrompt: ["You are concise."], + messages: [{ role: "user", content: "ping", timestamp: Date.now() }], +}; + +function abortedSignal(): AbortSignal { + const controller = new AbortController(); + controller.abort(); + return controller.signal; +} + +interface ThinkingPayload { + additionalModelRequestFields?: { + thinking?: { type?: string; display?: string; budget_tokens?: number }; + }; +} + +function captureBedrockPayload( + model: Model<"bedrock-converse-stream">, + options: Parameters[2] = {}, +): Promise { + const { promise, resolve } = Promise.withResolvers(); + void streamBedrock(model, baseContext, { + signal: abortedSignal(), + ...options, + onPayload: payload => { + resolve(payload as ThinkingPayload); + return undefined; + }, + }); + return promise; +} + +describe("issue #1373: Bedrock Claude thinkingDisplay", () => { + it("defaults adaptive thinking to display=summarized on Opus 4.7+", async () => { + const payload = await captureBedrockPayload(adaptiveModel("anthropic.claude-opus-4-7"), { + reasoning: Effort.High, + }); + expect(payload.additionalModelRequestFields?.thinking).toMatchObject({ + type: "adaptive", + display: "summarized", + }); + }); + + it("respects explicit thinkingDisplay='omitted' on Opus 4.7+", async () => { + const payload = await captureBedrockPayload(adaptiveModel("eu.anthropic.claude-opus-4-7"), { + reasoning: Effort.High, + thinkingDisplay: "omitted", + }); + expect(payload.additionalModelRequestFields?.thinking).toMatchObject({ + type: "adaptive", + display: "omitted", + }); + }); + + it("omits display on adaptive Opus 4.6 (older models reject the field)", async () => { + const payload = await captureBedrockPayload(adaptiveModel("global.anthropic.claude-opus-4-6-v1"), { + reasoning: Effort.High, + }); + const thinking = payload.additionalModelRequestFields?.thinking; + expect(thinking?.type).toBe("adaptive"); + expect(thinking?.display).toBeUndefined(); + }); + + it("sends display=summarized by default on budget-based thinking models", async () => { + const payload = await captureBedrockPayload(budgetModel("us.anthropic.claude-haiku-4-5-20251001-v1:0"), { + reasoning: Effort.High, + }); + expect(payload.additionalModelRequestFields?.thinking).toMatchObject({ + type: "enabled", + display: "summarized", + }); + expect(typeof payload.additionalModelRequestFields?.thinking?.budget_tokens).toBe("number"); + }); +});