Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/fixes/14940-responses-stall-window-bound.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- **fix(opencode):** with the opt-in Responses stall guard on (`OPENCODE_RESPONSES_STALL_ROTATION`), a `RESPONSES_FIRST_BYTE_TIMEOUT_MS` configured above `STREAM_READINESS_TIMEOUT_MS` is now capped at the readiness bound, so a silent streamed Responses reply rotates once then fails fast instead of waiting out the larger window; windows at or below the bound, readiness disabled (`0`), and the flag off are unchanged. ([#14940](https://github.com/diegosouzapw/OmniRoute/pull/14940)) — thanks @maxmad64bis
6 changes: 3 additions & 3 deletions open-sse/executors/opencode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ export { isPremiumOpencodeModel };
import {
guardResponsesStall,
isResponsesFirstByteTimeout,
resolveResponsesStallWindowMs,
setupStallGuard,
} from "./opencodeResponsesStall.ts";
import { discardResponseBody } from "./opencodeResponseBody.ts";
import { headersWaitDispatch, headersWaitState } from "./opencodeHeadersWait.ts";
Expand Down Expand Up @@ -563,8 +563,8 @@ export class OpencodeExecutor extends BaseExecutor {
const skippedCooldown = new Map<string, number>();

const hasProxies = accounts.some((a) => a.proxy !== null);
// Opt-in Responses first-byte stall guard (#13484); a no-op when the window is 0.
const stallWindowMs = resolveResponsesStallWindowMs(input.stream, this._requestFormat);
// Opt-in Responses first-byte stall guard; 0 = no-op.
const stallWindowMs = setupStallGuard(input.stream, this._requestFormat, log, cid).windowMs;
const guardStall = <T>(r: T) => guardResponsesStall(r, stallWindowMs, input.signal);
const headersWait = headersWaitState(
input,
Expand Down
61 changes: 57 additions & 4 deletions open-sse/executors/opencodeResponsesStall.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,25 +7,78 @@
* stalled, not thinking. Chat Completions streams are left alone: gateways may
* legitimately hold them until the answer is ready.
*
* Worst case on the stall arm: at most 2 first-byte waits per request (one
* rotation), each bounded by the window; the guarded final direct call can add
* one more wait without a rotation, so at most 3 waits mixed, all window-bounded.
*
* Gated by OPENCODE_RESPONSES_STALL_ROTATION (default off). With the flag off
* the window is 0 and every guard call hands back the very same result object,
* so the stream readiness timeout stays the only bound, as before.
*/

import { isOpencodeResponsesStallRotationEnabled } from "@/shared/utils/featureFlags";
import { getResponsesFirstByteTimeoutMs } from "@/shared/utils/runtimeTimeouts";
import {
DEFAULT_STREAM_READINESS_TIMEOUT_MS,
getResponsesFirstByteTimeoutMs,
getUpstreamTimeoutConfig,
} from "@/shared/utils/runtimeTimeouts";
import { guardResponsesStreamFirstByte } from "../utils/firstByteWatchdog.ts";

export { isResponsesFirstByteTimeout } from "../utils/firstByteWatchdog.ts";

/** First-byte window (ms) for this request, or 0 when the guard does not apply. */
export type StallGuardSetup = {
windowMs: number;
capped: boolean;
};

/**
* Reads the readiness bound, resolves the window, and reports whether the
* configured value was capped — so the call site stays a one-line wiring hunk
* on the frozen executor file. `readBound` / `readConfigured` default to the
* live getters; tests inject fakes. The cap notice is logged here (never in
* the pure `resolve` below) through the optional `log` sink, in generic
* English with no internal IDs beyond the caller's own prefix.
*/
export function setupStallGuard(
stream: boolean | undefined,
requestFormat: string | null,
log?: { warn?: (tag: string, message: string) => void } | null,
cid = "",
readBound: () => number = () => getUpstreamTimeoutConfig().streamReadinessTimeoutMs,
readConfigured: () => number = getResponsesFirstByteTimeoutMs
): StallGuardSetup {
const capMs = readBound();
const windowMs = resolveResponsesStallWindowMs(stream, requestFormat, capMs);
const capped = capMs > 0 && windowMs > 0 && readConfigured() > capMs;
if (capped) log?.warn?.("OPENCODE", `${cid}stalled stream first-byte wait capped`);
return { windowMs, capped };
}

/**
* First-byte window (ms) for this request, or 0 when the guard does not apply.
*
* The configured window is capped by the stream readiness bound (`capMs`).
* Equal to the bound is enough — no need to stay strictly below it: the guard
* consumes the first byte BEFORE the executor returns, and the readiness check
* in chatCore only starts on the response the executor hands back (which then
* replays that byte at once), so the two waits never race. A guard firing at
* exactly the bound costs the same wall time the readiness check would have,
* but buys the rotation. A non-positive bound means readiness is disabled
* (`STREAM_READINESS_TIMEOUT_MS=0`): there is no ceiling, and the configured
* window is used as is, since the guard is then the only first-byte bound left.
*/
export function resolveResponsesStallWindowMs(
stream: boolean | undefined,
requestFormat: string | null
requestFormat: string | null,
capMs?: number
): number {
if (!stream || requestFormat !== "openai-responses") return 0;
if (!isOpencodeResponsesStallRotationEnabled()) return 0;
return getResponsesFirstByteTimeoutMs();
const configured = getResponsesFirstByteTimeoutMs();
if (configured === 0) return 0;
const cap = capMs ?? DEFAULT_STREAM_READINESS_TIMEOUT_MS;
if (!(cap > 0)) return configured;
return configured > cap ? cap : configured;
}

/**
Expand Down
Loading
Loading