diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index fe3aa47fa..996bc2d4e 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -21,6 +21,7 @@ ### Fixed - Fixed expired OAuth handling so provider-level paths no longer attempt direct token refresh calls for expired credentials and instead rely on `AuthStorage` for rotation +- Fixed `google-gemini-cli` / `google-antigravity` aborting heavy reasoning runs with "Provider stream timed out while waiting for the first event" before the upstream had a chance to emit its first SSE frame. Cloud Code Assist routinely takes >100s on Gemini 3.x Pro at high thinking levels; the lazy-stream wrapper now floors the first-event watchdog at 5 minutes for these two providers when neither `StreamOptions.streamFirstEventTimeoutMs` nor `PI_STREAM_FIRST_EVENT_TIMEOUT_MS` pins a value. Other providers keep the 100s default. Internally, `getStreamIdleTimeoutMs` and `getStreamFirstEventTimeoutMs` now accept an optional per-provider `fallbackMs` so other slow-first-token providers can opt into the same widening without leaking through to the global default. - 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 diff --git a/packages/ai/src/providers/register-builtins.ts b/packages/ai/src/providers/register-builtins.ts index ab463f7d6..cb47fe4c7 100644 --- a/packages/ai/src/providers/register-builtins.ts +++ b/packages/ai/src/providers/register-builtins.ts @@ -166,19 +166,47 @@ function hasFinalResult( return typeof (source as { result?: unknown }).result === "function"; } +/** + * Per-provider default overrides for the lazy stream watchdogs. These widen the + * floor used when neither caller option nor env var pins a value. The env vars + * (`PI_STREAM_FIRST_EVENT_TIMEOUT_MS`, `PI_STREAM_IDLE_TIMEOUT_MS`) still take + * precedence; `StreamOptions.streamFirstEventTimeoutMs` / `streamIdleTimeoutMs` + * still trump everything. + */ +interface LazyStreamLimits { + defaultFirstEventTimeoutMs?: number; + defaultIdleTimeoutMs?: number; +} + +/** + * Cloud Code Assist (google-gemini-cli / google-antigravity) routinely takes + * longer than the global 100s default to emit its first SSE event when serving + * the heavier Gemini 3.x Pro tiers at high thinking levels. Bump the first-event + * floor to five minutes so duke et al. stop seeing spurious "stream timed out + * while waiting for the first event" aborts on legitimate cold reasoning starts. + * The steady-state idle watchdog stays on the global default since the upstream + * emits thinking tokens frequently once it gets going. + */ +const GOOGLE_GEMINI_CLI_LAZY_STREAM_LIMITS: LazyStreamLimits = { + defaultFirstEventTimeoutMs: 300_000, +}; + function forwardStream( target: EventStreamImpl, source: AsyncIterable, model: Model, options: OptionsForApi, abortTracker: AbortSourceTracker, + limits?: LazyStreamLimits, ): void { (async () => { try { - const idleTimeoutMs = options.streamIdleTimeoutMs ?? getStreamIdleTimeoutMs(); + const idleTimeoutMs = options.streamIdleTimeoutMs ?? getStreamIdleTimeoutMs(limits?.defaultIdleTimeoutMs); const watchedSource = iterateWithIdleTimeout(source, { idleTimeoutMs, - firstItemTimeoutMs: options.streamFirstEventTimeoutMs ?? getStreamFirstEventTimeoutMs(idleTimeoutMs), + firstItemTimeoutMs: + options.streamFirstEventTimeoutMs ?? + getStreamFirstEventTimeoutMs(idleTimeoutMs, limits?.defaultFirstEventTimeoutMs), errorMessage: LAZY_STREAM_IDLE_TIMEOUT_ERROR, firstItemErrorMessage: LAZY_STREAM_FIRST_EVENT_TIMEOUT_ERROR, onIdle: () => abortTracker.abortLocally(new Error(LAZY_STREAM_IDLE_TIMEOUT_ERROR)), @@ -241,6 +269,7 @@ function createLazyLoadErrorMessage( function createLazyStream( loadModule: () => Promise>, + limits?: LazyStreamLimits, ): (model: Model, context: Context, options: OptionsForApi) => EventStreamImpl { return (model, context, options) => { const outer = new EventStreamImpl(); @@ -251,7 +280,7 @@ function createLazyStream( const abortTracker = createAbortSourceTracker(streamOptions.signal); const providerOptions = { ...streamOptions, signal: abortTracker.requestSignal } as OptionsForApi; const inner = module.stream(model, context, providerOptions); - forwardStream(outer, inner, model, streamOptions, abortTracker); + forwardStream(outer, inner, model, streamOptions, abortTracker, limits); }) .catch(error => { const message = createLazyLoadErrorMessage(model, error); @@ -369,7 +398,10 @@ function loadBedrockProviderModule(): Promise env var > per-provider fallback > base default. + */ + +const ENV_KEYS = [ + "PI_STREAM_IDLE_TIMEOUT_MS", + "PI_OPENAI_STREAM_IDLE_TIMEOUT_MS", + "PI_STREAM_FIRST_EVENT_TIMEOUT_MS", +] as const; + +const originalEnv: Partial> = {}; + +beforeEach(() => { + for (const key of ENV_KEYS) { + originalEnv[key] = Bun.env[key]; + delete Bun.env[key]; + } +}); + +afterEach(() => { + for (const key of ENV_KEYS) { + const prior = originalEnv[key]; + if (prior === undefined) { + delete Bun.env[key]; + } else { + Bun.env[key] = prior; + } + } +}); + +describe("getStreamIdleTimeoutMs(fallbackMs)", () => { + it("returns the per-provider fallback when env vars are unset", () => { + expect(getStreamIdleTimeoutMs(300_000)).toBe(300_000); + }); + + it("lets PI_STREAM_IDLE_TIMEOUT_MS override the per-provider fallback", () => { + Bun.env.PI_STREAM_IDLE_TIMEOUT_MS = "42"; + expect(getStreamIdleTimeoutMs(300_000)).toBe(42); + }); + + it("treats PI_STREAM_IDLE_TIMEOUT_MS=0 as a watchdog disable", () => { + Bun.env.PI_STREAM_IDLE_TIMEOUT_MS = "0"; + expect(getStreamIdleTimeoutMs(300_000)).toBeUndefined(); + }); +}); + +describe("getStreamFirstEventTimeoutMs(idleTimeoutMs, fallbackMs)", () => { + it("returns the per-provider fallback when env unset and idle timeout is undefined", () => { + expect(getStreamFirstEventTimeoutMs(undefined, 300_000)).toBe(300_000); + }); + + it("floors the first-event timeout at the per-provider fallback even when idle is shorter", () => { + expect(getStreamFirstEventTimeoutMs(50_000, 300_000)).toBe(300_000); + }); + + it("never undershoots the steady-state idle timeout", () => { + expect(getStreamFirstEventTimeoutMs(500_000, 300_000)).toBe(500_000); + }); + + it("lets PI_STREAM_FIRST_EVENT_TIMEOUT_MS override the per-provider fallback", () => { + Bun.env.PI_STREAM_FIRST_EVENT_TIMEOUT_MS = "42"; + expect(getStreamFirstEventTimeoutMs(undefined, 300_000)).toBe(42); + }); + + it("treats PI_STREAM_FIRST_EVENT_TIMEOUT_MS=0 as a watchdog disable", () => { + Bun.env.PI_STREAM_FIRST_EVENT_TIMEOUT_MS = "0"; + expect(getStreamFirstEventTimeoutMs(undefined, 300_000)).toBeUndefined(); + }); + + it("falls back to the 100s global default when no fallback or env is provided", () => { + expect(getStreamFirstEventTimeoutMs()).toBe(100_000); + }); +});