fix(coding-agent): forwarded stream timeout settings

Forwarded persisted provider stream timeout settings into model requests so slow local LLM streams can widen or disable first-event and idle watchdogs without environment variables.

Fixes #3878
This commit is contained in:
roboomp
2026-06-30 07:07:41 +00:00
parent 38250ce88b
commit 2c8b723083
5 changed files with 76 additions and 7 deletions
+2 -2
View File
@@ -207,8 +207,8 @@ OAuth host chain: `KIMI_CODE_OAUTH_HOST` → `KIMI_OAUTH_HOST` → `https://auth
| `PI_CODEX_WEBSOCKET_IDLE_TIMEOUT_MS` | Positive integer override (default 300000) |
| `PI_CODEX_WEBSOCKET_RETRY_BUDGET` | Non-negative integer override (default 5) |
| `PI_CODEX_WEBSOCKET_RETRY_DELAY_MS` | Positive integer base backoff override (default 500) |
| `PI_OPENAI_STREAM_FIRST_EVENT_TIMEOUT_MS` | Positive integer OpenAI first-event timeout override |
| `PI_OPENAI_STREAM_IDLE_TIMEOUT_MS` | Positive integer OpenAI stream idle timeout override |
| `PI_OPENAI_STREAM_FIRST_EVENT_TIMEOUT_MS` | Positive integer OpenAI first-event timeout override; `0` disables. `omp config set providers.streamFirstEventTimeoutSeconds <seconds>` provides the persisted config equivalent |
| `PI_OPENAI_STREAM_IDLE_TIMEOUT_MS` | Positive integer OpenAI stream idle timeout override; `0` disables. `omp config set providers.streamIdleTimeoutSeconds <seconds>` provides the persisted config equivalent |
### Cursor provider debug
+1
View File
@@ -15,6 +15,7 @@
### Fixed
- Fixed slow local LLM streams by forwarding persisted stream timeout settings (`providers.streamFirstEventTimeoutSeconds`, `providers.streamIdleTimeoutSeconds`) into model requests, so users can widen or disable watchdogs without environment variables. ([#3878](https://github.com/can1357/oh-my-pi/issues/3878))
- Improved reliability of DuckDuckGo web searches by updating browser request headers and parameters
- Fixed an issue where CJK (Chinese, Japanese, Korean) history could become unrenderable during repeated context compactions.
- Fixed a memory exhaustion bug in the TUI when using `/resume` on large previous sessions.
@@ -144,7 +144,7 @@ export const TAB_GROUPS: Record<SettingTab, readonly string[]> = {
"Developer",
],
tasks: ["Modes", "Subagents", "Isolation", "Commands & Skills"],
providers: ["Services", "Fireworks", "Tiny Model", "Protocol", "Privacy"],
providers: ["Services", "Fireworks", "Tiny Model", "Protocol", "Timeouts", "Privacy"],
};
/** Status line segment identifiers */
@@ -4554,6 +4554,44 @@ export const SETTINGS_SCHEMA = {
},
},
"providers.streamFirstEventTimeoutSeconds": {
type: "number",
default: -1,
ui: {
tab: "providers",
group: "Timeouts",
label: "Stream First Event Timeout",
description:
"Seconds to wait for the first model stream event; -1 uses provider/env defaults, 0 disables the watchdog",
options: [
{ value: "-1", label: "Auto", description: "Use provider defaults and PI_* timeout env vars" },
{ value: "0", label: "Off", description: "Disable first-event timeout" },
{ value: "300", label: "5 minutes" },
{ value: "600", label: "10 minutes" },
{ value: "1800", label: "30 minutes" },
],
},
},
"providers.streamIdleTimeoutSeconds": {
type: "number",
default: -1,
ui: {
tab: "providers",
group: "Timeouts",
label: "Stream Idle Timeout",
description:
"Seconds a model stream may stay silent between events; -1 uses provider/env defaults, 0 disables the watchdog",
options: [
{ value: "-1", label: "Auto", description: "Use provider defaults and PI_* timeout env vars" },
{ value: "0", label: "Off", description: "Disable idle timeout" },
{ value: "300", label: "5 minutes" },
{ value: "600", label: "10 minutes" },
{ value: "1800", label: "30 minutes" },
],
},
},
"providers.openrouterVariant": {
type: "enum",
values: ["default", "nitro", "floor", "online", "exacto"] as const,
@@ -2,8 +2,8 @@
* Settings-aware stream wrapper shared by the main agent (sdk.ts) and the
* advisor agent (AgentSession.#buildAdvisorRuntime).
*
* Reads OpenRouter / Antigravity routing variants, Responses-family text
* verbosity, per-provider in-flight caps, and the loop guard out of `Settings`
* verbosity, stream watchdog budgets, per-provider in-flight caps, and the loop
* guard out of `Settings`
* per request, layering them onto whatever options the caller passed. Before
* this helper existed, advisor turns called bare `streamSimple` while the main
* turn went through an inline closure that read these settings — so an advisor on
@@ -14,6 +14,12 @@ import type { StreamFn } from "@oh-my-pi/pi-agent-core";
import { type SimpleStreamOptions, streamSimple } from "@oh-my-pi/pi-ai";
import { type Settings, validateProviderMaxInFlightRequests } from "../config/settings";
function timeoutSecondsToMs(value: number): number | undefined {
if (!Number.isFinite(value) || value < 0) return undefined;
if (value === 0) return 0;
return Math.max(1, Math.trunc(value * 1000));
}
/**
* Build a {@link StreamFn} that reads provider routing/guard settings from
* `settings` per call and forwards to `base` (defaults to `streamSimple`).
@@ -30,11 +36,15 @@ export function createSettingsAwareStreamFn(settings: Settings, base: StreamFn =
model.api === "openai-codex-responses" || model.api === "openai-responses"
? settings.get("textVerbosity")
: undefined;
const streamFirstEventTimeoutMs = timeoutSecondsToMs(settings.get("providers.streamFirstEventTimeoutSeconds"));
const streamIdleTimeoutMs = timeoutSecondsToMs(settings.get("providers.streamIdleTimeoutSeconds"));
const merged: SimpleStreamOptions = {
...streamOptions,
openrouterVariant: streamOptions?.openrouterVariant ?? openrouterVariant,
antigravityEndpointMode: streamOptions?.antigravityEndpointMode ?? antigravityEndpointMode,
textVerbosity: streamOptions?.textVerbosity ?? textVerbosity,
streamFirstEventTimeoutMs: streamOptions?.streamFirstEventTimeoutMs ?? streamFirstEventTimeoutMs,
streamIdleTimeoutMs: streamOptions?.streamIdleTimeoutMs ?? streamIdleTimeoutMs,
maxInFlightRequests: validateProviderMaxInFlightRequests(
streamOptions?.maxInFlightRequests ?? settings.get("providers.maxInFlightRequests"),
),
@@ -1,8 +1,8 @@
/**
* Contract: `createSettingsAwareStreamFn` layers session provider settings
* (`providers.openrouterVariant`, `providers.antigravityEndpoint`,
* `providers.maxInFlightRequests`, `model.loopGuard.*`, `textVerbosity` for
* Responses-family requests) onto every call while letting caller-supplied
* `providers.stream*TimeoutSeconds`, `providers.maxInFlightRequests`,
* `model.loopGuard.*`, `textVerbosity` for Responses-family requests)
* options win — the same wiring the main agent and the advisor agent share so
* OpenRouter sticky-routing / response caching behaves the same on advisor turns
* (can1357/oh-my-pi#3639).
@@ -65,6 +65,26 @@ describe("createSettingsAwareStreamFn", () => {
expect(calls[2]?.options?.textVerbosity).toBe("medium");
});
it("forwards configured stream watchdog budgets while preserving caller overrides", () => {
const settings = Settings.isolated({
"providers.streamFirstEventTimeoutSeconds": 600,
"providers.streamIdleTimeoutSeconds": 300,
});
const { fn: base, calls } = captureBase();
const wrapped = createSettingsAwareStreamFn(settings, base);
wrapped(stubModel, stubContext, undefined);
wrapped(stubModel, stubContext, {
streamFirstEventTimeoutMs: 15_000,
streamIdleTimeoutMs: 10_000,
});
expect(calls[0]?.options?.streamFirstEventTimeoutMs).toBe(600_000);
expect(calls[0]?.options?.streamIdleTimeoutMs).toBe(300_000);
expect(calls[1]?.options?.streamFirstEventTimeoutMs).toBe(15_000);
expect(calls[1]?.options?.streamIdleTimeoutMs).toBe(10_000);
});
it("treats the default openrouterVariant as absent so the base call carries no variant", () => {
const settings = Settings.isolated({ "providers.openrouterVariant": "default" });
const { fn: base, calls } = captureBase();