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:
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user