fix(ai): defaulted bedrock claude thinking.display to summarized

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
This commit is contained in:
roboomp
2026-05-25 19:08:00 +00:00
parent d201442a16
commit ee9deba239
4 changed files with 172 additions and 2 deletions
+4
View File
@@ -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
+44 -2
View File
@@ -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<string, unknown> = {
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: <base64-string> }, format }`.
* The caller already passes base64-encoded data, so no decode/re-encode round-trip is needed.
+1
View File
@@ -657,6 +657,7 @@ function mapOptionsForApi<TApi extends Api>(
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") {
+123
View File
@@ -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<typeof streamBedrock>[2] = {},
): Promise<ThinkingPayload> {
const { promise, resolve } = Promise.withResolvers<ThinkingPayload>();
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");
});
});