fix(ai,catalog): widen Bedrock stream-stall watchdog via model compat

The lazy provider wrapper ignored model.compat.streamIdleTimeoutMs, so
Bedrock reasoning models sat on the generic 300s idle watchdog despite
ConverseStream sending no ping keepalives; long quiet thinking runs died
with "Provider stream stalled while waiting for the next event" during
plan writing and todo execution (issue #4758's Bedrock variant, worst on
Fable 5 where the display default flipped to omitted).

- catalog: BedrockCompat gains streamIdleTimeoutMs; reasoning models get
  a 600s floor, adaptive-thinking Claude (Opus 4.7+, Sonnet/Opus 5,
  Fable/Mythos 5) 900s to match direct Anthropic's ping-extended
  tolerance; explicit compat overrides still win (0 disables).
- ai: forwardStream resolves options -> env -> model.compat -> default,
  and lazy terminal errors carry the structural errorId classification
  so session auto-retry classifies stalls without text matching.
This commit is contained in:
Voon Foo
2026-08-07 14:18:46 +08:00
parent 3a8591a8af
commit 2f24d4457e
7 changed files with 185 additions and 7 deletions
+10 -2
View File
@@ -240,12 +240,19 @@ function forwardStream<TApi extends Api>(
(async () => {
try {
const providerHandlesStreamTimeouts = limits?.providerHandlesStreamTimeouts === true;
// Per-model catalog compat can widen the fallback watchdog for hosts
// with no keepalive events (e.g. Bedrock reasoning models that go
// quiet for minutes mid-thinking, issue #4758). Caller options and
// env overrides still take precedence over the compat fallback.
const compatIdleTimeoutMs = (model.compat as { streamIdleTimeoutMs?: number } | undefined)
?.streamIdleTimeoutMs;
const idleTimeoutFallbackMs = compatIdleTimeoutMs ?? limits?.defaultIdleTimeoutMs;
const idleTimeoutMs = providerHandlesStreamTimeouts
? undefined
: (options.streamIdleTimeoutMs ??
(limits?.openAIIdleEnvFloorsFirstEvent
? getOpenAIStreamIdleTimeoutMs(limits.defaultIdleTimeoutMs)
: getStreamIdleTimeoutMs(limits?.defaultIdleTimeoutMs)));
? getOpenAIStreamIdleTimeoutMs(idleTimeoutFallbackMs)
: getStreamIdleTimeoutMs(idleTimeoutFallbackMs)));
const firstItemTimeoutMs = providerHandlesStreamTimeouts
? 0
: (options.streamFirstEventTimeoutMs ??
@@ -311,6 +318,7 @@ function createLazyLoadErrorMessage<TApi extends Api>(
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason,
errorId: stopReason === "error" ? AIError.classify(error, model.api) || undefined : undefined,
errorMessage:
stopReason === "aborted" ? "Request was aborted" : error instanceof Error ? error.message : String(error),
timestamp: Date.now(),
+57 -2
View File
@@ -1,21 +1,25 @@
import { describe, expect, it } from "bun:test";
import * as AIError from "@oh-my-pi/pi-ai/error";
import { setBedrockProviderModule, streamBedrock } from "@oh-my-pi/pi-ai/providers/register-builtins";
import type { AssistantMessage, Context, Model } from "@oh-my-pi/pi-ai/types";
import type { AssistantMessageEventStream } from "@oh-my-pi/pi-ai/utils/event-stream";
import { buildModel } from "@oh-my-pi/pi-catalog/build";
function createModel(): Model<"bedrock-converse-stream"> {
function createModel(
overrides: { reasoning?: boolean; compat?: { streamIdleTimeoutMs?: number } } = {},
): Model<"bedrock-converse-stream"> {
return buildModel({
id: "mock-bedrock",
name: "Mock Bedrock",
api: "bedrock-converse-stream",
provider: "amazon-bedrock",
baseUrl: "https://example.invalid",
reasoning: false,
reasoning: overrides.reasoning ?? false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 8192,
maxTokens: 2048,
compat: overrides.compat,
});
}
@@ -129,6 +133,57 @@ describe("register-builtins lazy streams", () => {
expect(providerSignal?.aborted).toBe(true);
expect(result.stopReason).toBe("error");
expect(result.errorMessage).toBe("Provider stream stalled while waiting for the next event");
// The watchdog's StreamTimeoutError classification must survive onto the
// message so session-level auto-retry can classify it structurally.
expect(AIError.is(result.errorId, AIError.Flag.Transient)).toBe(true);
expect(AIError.is(result.errorId, AIError.Flag.Timeout)).toBe(true);
expect(AIError.retriable(result.errorId)).toBe(true);
});
it("honors model.compat.streamIdleTimeoutMs as the lazy watchdog fallback", async () => {
const partialMessage = createAssistantMessage("stop");
let providerSignal: AbortSignal | undefined;
const source = {
async *[Symbol.asyncIterator]() {
yield { type: "start", partial: partialMessage } as const;
yield { type: "text_delta", contentIndex: 0, delta: "hello", partial: partialMessage } as const;
const { promise, reject } = Promise.withResolvers<never>();
if (providerSignal?.aborted) {
reject(new Error("Request was aborted"));
}
providerSignal?.addEventListener("abort", () => reject(new Error("Request was aborted")), {
once: true,
});
await promise;
},
} as unknown as AssistantMessageEventStream;
setBedrockProviderModule({
streamBedrock: (_model, _context, options) => {
providerSignal = options.signal;
return source;
},
});
// No per-call option: the catalog compat override must reach the lazy
// watchdog (a stalled Bedrock stream previously waited the generic 300s
// default because model.compat was ignored on this path).
const model = createModel({ reasoning: true, compat: { streamIdleTimeoutMs: 20 } });
expect(model.compat.streamIdleTimeoutMs).toBe(20);
const stream = streamBedrock(model, baseContext, {});
// Real-clock race guard (matching this file's other lazy-stream tests):
// the lazy watchdog runs on the platform clock, so fake timers cannot
// drive it; the 20ms compat deadline settles the result long before the
// bound, which exists only to fail fast instead of hanging the test.
const result = await Promise.race([stream.result(), Bun.sleep(2_000).then(() => "timeout" as const)]);
expect(result).not.toBe("timeout");
if (result === "timeout") {
throw new Error("Timed out waiting for compat-driven stream stall result");
}
expect(providerSignal?.aborted).toBe(true);
expect(result.stopReason).toBe("error");
expect(result.errorMessage).toBe("Provider stream stalled while waiting for the next event");
});
it("preserves caller aborts while forwarding lazy provider streams", async () => {
+28 -1
View File
@@ -1,3 +1,4 @@
import { supportsAdaptiveThinkingDisplay } from "../identity/family";
import type { ModelSpec, ResolvedBedrockCompat } from "../types";
import { applyCompatOverrides } from "./apply";
@@ -118,9 +119,35 @@ function detectedBedrockCompat(modelId: string): ResolvedBedrockCompat {
return NO_EXPLICIT_CHECKPOINTS;
}
/** Resolve Bedrock Converse prompt-cache capabilities once per model. */
/**
* Bedrock ConverseStream sends no ping/keepalive events, so a reasoning model
* that goes quiet mid-thinking (summarized-display gaps, `omitted` thinking,
* or the wedged long tool-call generation of issue #4900) reads as a dead
* stream to the generic 300s idle watchdog and dies with "Provider stream
* stalled while waiting for the next event" (issue #4758's Bedrock variant).
* Widen the floor to 600s for reasoning models, mirroring the GLM coding-plan
* floor; explicit `spec.compat.streamIdleTimeoutMs` overrides still win.
*/
const BEDROCK_REASONING_STREAM_IDLE_TIMEOUT_MS = 600_000;
/**
* Adaptive-thinking Claude (Opus 4.7+, Sonnet/Opus 5, Fable/Mythos 5) reasons
* for much longer stretches, and starting with Opus 4.7 / Fable 5 the
* Anthropic-side display default is `omitted` (issue #1373), so quiet gaps run
* longest on exactly this family — Fable 5 being the worst offender in the
* field. Direct Anthropic keeps these streams alive with ping keepalives and
* tolerates up to 3x the 300s idle budget of real-event silence (#4900);
* pingless Bedrock needs the same 900s tolerance in the raw idle floor.
*/
const BEDROCK_ADAPTIVE_THINKING_STREAM_IDLE_TIMEOUT_MS = 900_000;
/** Resolve Bedrock Converse prompt-cache and stream-watchdog compat once per model. */
export function buildBedrockCompat(spec: ModelSpec<"bedrock-converse-stream">): ResolvedBedrockCompat {
const compat = { ...detectedBedrockCompat(spec.id) };
compat.streamIdleTimeoutMs = spec.reasoning
? supportsAdaptiveThinkingDisplay(spec.id)
? BEDROCK_ADAPTIVE_THINKING_STREAM_IDLE_TIMEOUT_MS
: BEDROCK_REASONING_STREAM_IDLE_TIMEOUT_MS
: undefined;
applyCompatOverrides(compat, spec.compat);
return compat;
}
+13
View File
@@ -518,6 +518,12 @@ export interface BedrockCompat {
* Capability metadata only; zero means no explicit checkpoints.
*/
promptCacheMaximumCheckpoints?: number;
/**
* Stream-watchdog idle-timeout fallback in ms; 0 disables the idle watchdog.
* Undefined defers to `PI_STREAM_IDLE_TIMEOUT_MS`, then the legacy
* `PI_OPENAI_STREAM_IDLE_TIMEOUT_MS` alias, then the 300s default.
*/
streamIdleTimeoutMs?: number;
}
/** Fully-resolved Bedrock Converse prompt-cache capabilities, materialized once by `buildModel`. */
@@ -526,6 +532,13 @@ export interface ResolvedBedrockCompat {
supportsLongPromptCacheRetention: boolean;
promptCacheMinimumTokens: number;
promptCacheMaximumCheckpoints: number;
/**
* Stream-watchdog idle-timeout fallback in ms for hosts with no keepalive
* events; 0 disables the idle watchdog. Undefined defers to
* `PI_STREAM_IDLE_TIMEOUT_MS`, then the legacy
* `PI_OPENAI_STREAM_IDLE_TIMEOUT_MS` alias, then the 300s default.
*/
streamIdleTimeoutMs?: number;
}
/**
@@ -142,6 +142,8 @@ describe("Amazon Bedrock Claude Opus 5", () => {
supportsLongPromptCacheRetention: true,
promptCacheMinimumTokens: 512,
promptCacheMaximumCheckpoints: 4,
// reasoning:true adaptive-thinking family → 900s keepalive-free idle floor.
streamIdleTimeoutMs: 900_000,
});
}
});
@@ -1,5 +1,6 @@
import { describe, expect, test } from "bun:test";
import { buildModel } from "@oh-my-pi/pi-catalog/build";
import { supportsAdaptiveThinkingDisplay } from "@oh-my-pi/pi-catalog/identity";
import { getBundledModel } from "@oh-my-pi/pi-catalog/models";
import type { ModelSpec } from "@oh-my-pi/pi-catalog/types";
@@ -90,6 +91,9 @@ describe("Bedrock prompt-cache compat", () => {
supportsLongPromptCacheRetention: supportsLongRetention,
promptCacheMinimumTokens: minimumTokens,
promptCacheMaximumCheckpoints: minimumTokens === 0 ? 0 : 4,
// bedrockSpec is reasoning:true → keepalive-free idle floor applies
// (900s for the adaptive-thinking family, 600s otherwise).
streamIdleTimeoutMs: supportsAdaptiveThinkingDisplay(id) ? 900_000 : 600_000,
});
}
});
@@ -109,7 +113,11 @@ describe("Bedrock prompt-cache compat", () => {
"us.amazon.nova-premier-v1:0",
"global.amazon.nova-2-lite-v1:0",
] as const) {
expect(getBundledModel<"bedrock-converse-stream">("amazon-bedrock", id)?.compat).toEqual(expected);
const model = getBundledModel<"bedrock-converse-stream">("amazon-bedrock", id);
expect(model?.compat).toEqual({
...expected,
streamIdleTimeoutMs: model?.reasoning ? 600_000 : undefined,
});
}
// AWS documents in-region model IDs plus geo/global inference-profile IDs.
@@ -125,7 +133,7 @@ describe("Bedrock prompt-cache compat", () => {
"jp.amazon.nova-2-lite-v1:0",
"global.amazon.nova-2-lite-v1:0",
] as const) {
expect(buildModel(bedrockSpec({ id })).compat).toEqual(expected);
expect(buildModel(bedrockSpec({ id })).compat).toEqual({ ...expected, streamIdleTimeoutMs: 600_000 });
}
});
@@ -0,0 +1,65 @@
import { describe, expect, test } from "bun:test";
import { buildModel } from "@oh-my-pi/pi-catalog/build";
import type { ModelSpec } from "@oh-my-pi/pi-catalog/types";
function bedrockSpec(
overrides: Partial<ModelSpec<"bedrock-converse-stream">> = {},
): ModelSpec<"bedrock-converse-stream"> {
return {
id: "global.anthropic.claude-fable-5",
name: "Claude Fable 5",
api: "bedrock-converse-stream",
provider: "amazon-bedrock",
baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com",
reasoning: true,
input: ["text"],
cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
contextWindow: 1_000_000,
maxTokens: 128_000,
...overrides,
};
}
// Bedrock ConverseStream sends no ping keepalives, so reasoning models that go
// quiet mid-thinking previously fell back to the generic 300s idle watchdog and
// died with "Provider stream stalled while waiting for the next event" during
// long plan-writing/reasoning phases (issue #4758's Bedrock variant, worst on
// the adaptive-thinking Fable/Opus 4.7+ family).
describe("Bedrock stream idle-timeout compat", () => {
test("widens the idle timeout to 900s for adaptive-thinking Claude", () => {
for (const id of [
"global.anthropic.claude-fable-5",
"global.anthropic.claude-fable-5-20260120-v1:0",
"us.anthropic.claude-opus-4-8",
"us.anthropic.claude-sonnet-5",
"us.anthropic.claude-opus-5",
]) {
expect(buildModel(bedrockSpec({ id })).compat.streamIdleTimeoutMs).toBe(900_000);
}
});
test("widens the idle timeout to 600s for other reasoning models", () => {
for (const id of [
"anthropic.claude-opus-4-6-v1",
"anthropic.claude-3-7-sonnet-20250219-v1:0",
"us.amazon.nova-premier-v1:0",
]) {
expect(buildModel(bedrockSpec({ id })).compat.streamIdleTimeoutMs).toBe(600_000);
}
});
test("leaves non-reasoning models on the generic default", () => {
expect(
buildModel(bedrockSpec({ id: "anthropic.claude-3-5-haiku-20241022-v1:0", reasoning: false })).compat
.streamIdleTimeoutMs,
).toBeUndefined();
});
test("explicit compat overrides win over the reasoning floors", () => {
expect(buildModel(bedrockSpec({ compat: { streamIdleTimeoutMs: 120_000 } })).compat.streamIdleTimeoutMs).toBe(
120_000,
);
// 0 disables the idle watchdog entirely.
expect(buildModel(bedrockSpec({ compat: { streamIdleTimeoutMs: 0 } })).compat.streamIdleTimeoutMs).toBe(0);
});
});