From 6850e9300276b22239ee4ee4b17dbb51db5c635f Mon Sep 17 00:00:00 2001 From: Markus Hartung Date: Wed, 2 Sep 2026 15:28:38 -0300 Subject: [PATCH] feat(executors): make chatgpt-session stream-open timeout tunable Add OMNIROUTE_CHATGPT_SESSION_STREAM_OPEN_TIMEOUT_MS to override the 30s CHATGPT_SESSION_STREAM_OPEN_TIMEOUT_MS default, mirroring resolveDirectHeadersTimeoutMs's validation. Resolved per request in the executor so a changed env var takes effect without a restart. --- .env.example | 9 ++++ docs/reference/ENVIRONMENT.md | 1 + open-sse/executors/chatgpt-session.ts | 5 +- open-sse/executors/chatgpt-session/bridge.ts | 18 +++++++ tests/unit/chatgpt-session-bridge.test.ts | 49 ++++++++++++++++++++ 5 files changed, 81 insertions(+), 1 deletion(-) diff --git a/.env.example b/.env.example index cc6830b46f..64b03b9602 100644 --- a/.env.example +++ b/.env.example @@ -2922,6 +2922,15 @@ QUOTA_STORE_DRIVER=sqlite # CODEX_CHATGPT_WEB_BUN=/absolute/path/to/bun # CODEX_WEB_GPT_BUN=/absolute/path/to/bun +# ───────────────────────────────────────────────────────────────────────────── +# ChatGPT Session provider — stream-open gate +# Used by: open-sse/executors/chatgpt-session/bridge.ts +# How long the stream-open gate waits for the first committing event before +# opening the stream anyway. Falls back to the 30s default when unset, empty, +# unparseable, non-finite, or not positive. +# ───────────────────────────────────────────────────────────────────────────── +# OMNIROUTE_CHATGPT_SESSION_STREAM_OPEN_TIMEOUT_MS=30000 + # ───────────────────────────────────────────────────────────────────────────── # Browser-login VNC sessions (optional — src/lib/vncSession/manifest.ts) # Containerized Chromium+VNC used for interactive browser-login credential diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index bae4db9c87..9cc3d835ad 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -749,6 +749,7 @@ REQUEST_TIMEOUT_MS (global override) | `OMNIROUTE_CODEX_APPSERVER_AUTO_APPROVE` | `false` | Auto-approve the app-server's own approval prompts (command/file/permission execution on the host). Off by default — prompts are auto-denied; harness tool calls are unaffected (they travel the separate `item/tool/call` passthrough). Accepts `true`/`1`/`yes`. Per-connection override: `providerSpecificData.codexAppServerAutoApprove`. | | `FETCH_HEADERS_TIMEOUT_MS` | = `FETCH_TIMEOUT_MS` | Time to receive response headers. | | `OMNIROUTE_DIRECT_HEADERS_TIMEOUT_MS` | `30000` (30s) | Maximum response-start wait (ms) for each direct no-proxy attempt. A timeout retries once on a fresh socket; set `0` to disable the bound and retain the previous behavior. | +| `OMNIROUTE_CHATGPT_SESSION_STREAM_OPEN_TIMEOUT_MS` | `30000` (30s) | How long the `chatgpt-session` executor's stream-open gate waits for the first committing event before opening the stream anyway (`open-sse/executors/chatgpt-session/bridge.ts`). Falls back to the 30s default when unset, empty, unparseable, non-finite, or not positive. | | `FETCH_BODY_TIMEOUT_MS` | = `FETCH_TIMEOUT_MS` | Time to receive the full response body. | | `FETCH_CONNECT_TIMEOUT_MS` | `30000` | TCP connection establishment timeout. | | `FETCH_KEEPALIVE_TIMEOUT_MS` | `4000` | Keep-alive socket idle timeout. | diff --git a/open-sse/executors/chatgpt-session.ts b/open-sse/executors/chatgpt-session.ts index 09a140bce1..bad8d46500 100644 --- a/open-sse/executors/chatgpt-session.ts +++ b/open-sse/executors/chatgpt-session.ts @@ -24,6 +24,7 @@ import { connectionRuntimePaths } from "./chatgpt-web-codex/storageState.ts"; import { buildChatGptSessionCompletion, openChatGptSessionStream, + resolveChatGptSessionStreamOpenTimeoutMs, type ChatGptSessionResponseMeta, } from "./chatgpt-session/bridge.ts"; import { classifyChatGptSessionError } from "./chatgpt-session/errors.ts"; @@ -311,7 +312,9 @@ export class ChatGptSessionExecutor extends BaseExecutor { } void run(); - const opened = await openChatGptSessionStream(events, meta); + const opened = await openChatGptSessionStream(events, meta, { + streamOpenTimeoutMs: resolveChatGptSessionStreamOpenTimeoutMs(), + }); if (opened.kind === "error") { // Every field of the verdict comes from the bridge's classification of the real adapter // event — status, code and fallbackHint alike. Re-classifying the sanitized message here diff --git a/open-sse/executors/chatgpt-session/bridge.ts b/open-sse/executors/chatgpt-session/bridge.ts index 83670e86dd..349d1523bb 100644 --- a/open-sse/executors/chatgpt-session/bridge.ts +++ b/open-sse/executors/chatgpt-session/bridge.ts @@ -64,6 +64,24 @@ const COMMENTARY_PHASE: CodexMessagePhase = "commentary"; */ export const CHATGPT_SESSION_STREAM_OPEN_TIMEOUT_MS = 30_000; +/** + * Reads `OMNIROUTE_CHATGPT_SESSION_STREAM_OPEN_TIMEOUT_MS` and falls back to + * {@link CHATGPT_SESSION_STREAM_OPEN_TIMEOUT_MS} whenever the variable is unset, empty, + * unparseable, non-finite, or not positive — mirrors `resolveDirectHeadersTimeoutMs` + * (`open-sse/utils/directResponseStartTimeout.ts`). Callers resolve this per request so a + * changed environment variable takes effect without a restart. + */ +export function resolveChatGptSessionStreamOpenTimeoutMs( + env: Record = process.env +): number { + const raw = env.OMNIROUTE_CHATGPT_SESSION_STREAM_OPEN_TIMEOUT_MS; + if (raw == null || raw.trim() === "") return CHATGPT_SESSION_STREAM_OPEN_TIMEOUT_MS; + const parsed = Number(raw); + return Number.isFinite(parsed) && parsed > 0 + ? Math.floor(parsed) + : CHATGPT_SESSION_STREAM_OPEN_TIMEOUT_MS; +} + /** Race marker for the gate deadline — distinguishable from any `IteratorResult`. */ const GATE_TIMED_OUT = Symbol("chatgpt-session-gate-timeout"); diff --git a/tests/unit/chatgpt-session-bridge.test.ts b/tests/unit/chatgpt-session-bridge.test.ts index 60506fde95..fc7158959b 100644 --- a/tests/unit/chatgpt-session-bridge.test.ts +++ b/tests/unit/chatgpt-session-bridge.test.ts @@ -5,6 +5,7 @@ import { CHATGPT_SESSION_STREAM_OPEN_TIMEOUT_MS, buildChatGptSessionCompletion, openChatGptSessionStream, + resolveChatGptSessionStreamOpenTimeoutMs, } from "../../open-sse/executors/chatgpt-session/bridge.ts"; import type { AdapterEvent } from "../../open-sse/vendor/codex-chatgpt-web/types.ts"; @@ -438,3 +439,51 @@ test("no event is lost to the deadline race", async () => { assert.match(text, /"content":"one"/); assert.match(text, /"content":"two"/); }); + +const STREAM_OPEN_TIMEOUT_ENV_VAR = "OMNIROUTE_CHATGPT_SESSION_STREAM_OPEN_TIMEOUT_MS"; + +function withStreamOpenTimeoutEnv(raw: string | undefined, run: () => void): void { + const saved = process.env[STREAM_OPEN_TIMEOUT_ENV_VAR]; + if (raw === undefined) delete process.env[STREAM_OPEN_TIMEOUT_ENV_VAR]; + else process.env[STREAM_OPEN_TIMEOUT_ENV_VAR] = raw; + try { + run(); + } finally { + if (saved === undefined) delete process.env[STREAM_OPEN_TIMEOUT_ENV_VAR]; + else process.env[STREAM_OPEN_TIMEOUT_ENV_VAR] = saved; + } +} + +test("resolveChatGptSessionStreamOpenTimeoutMs falls back to the default when the env var is unset", () => { + withStreamOpenTimeoutEnv(undefined, () => { + assert.equal( + resolveChatGptSessionStreamOpenTimeoutMs(), + CHATGPT_SESSION_STREAM_OPEN_TIMEOUT_MS + ); + }); +}); + +test("resolveChatGptSessionStreamOpenTimeoutMs honors a valid positive integer", () => { + withStreamOpenTimeoutEnv("45000", () => { + assert.equal(resolveChatGptSessionStreamOpenTimeoutMs(), 45_000); + }); +}); + +const INVALID_STREAM_OPEN_TIMEOUT_INPUTS: Array<[label: string, raw: string]> = [ + ["an empty string", ""], + ["non-numeric text", "not-a-number"], + ["zero", "0"], + ["a negative number", "-10"], + ["a non-finite value", "Infinity"], +]; + +for (const [label, raw] of INVALID_STREAM_OPEN_TIMEOUT_INPUTS) { + test(`resolveChatGptSessionStreamOpenTimeoutMs falls back to the default for ${label}`, () => { + withStreamOpenTimeoutEnv(raw, () => { + assert.equal( + resolveChatGptSessionStreamOpenTimeoutMs(), + CHATGPT_SESSION_STREAM_OPEN_TIMEOUT_MS + ); + }); + }); +}