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
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -1835,6 +1835,8 @@ CURSOR_USER_AGENT="Cursor/3.4"
# OPENCODE_PARK_AND_RESUME=false # #13924 feature flag (Settings → Feature Flags wins): park the request with a heartbeat after repeated transient 429s, then replay one capped leg of up to 3 accounts
#OPENCODE_POOL_STRAIN_MARKER_PATH=/tmp/opencode-pool-strain.json # #13924: pool-strain marker path (JSON {since, reason, ttl_s}); fresh marker parks without recounting
# RESPONSES_FIRST_BYTE_TIMEOUT_MS=15000 # #13484: OpenCode Responses first-byte window, only used when the OPENCODE_RESPONSES_STALL_ROTATION flag is on (0 disables)
# OPENCODE_RESPONSES_HEADERS_WAIT_MS=30000 # opt-in bound on waiting for upstream response headers on streamed Responses calls before moving to the next account (0 = off, suggested 30000)
# OPENCODE_RESPONSES_HEADERS_WAIT_MAX_ROTATIONS=2 # rotation budget for the headers-wait bound above
# FLUSH_EMPTY_RETRY_ENABLED=false # #14213 feature flag (Settings → Feature Flags wins): retry empty translated streaming turns through the normal credential path (up to STREAM_RECOVERY.EMPTY_TURN_RETRY_MAX retries)

# ── API Bridge (/v1 proxy server) ──
Expand Down
1 change: 1 addition & 0 deletions changelog.d/features/14558-opencode-headers-wait.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- **feat(opencode):** bound the Responses headers wait with an opt-in rotation budget ([#14558](https://github.com/diegosouzapw/OmniRoute/pull/14558)) — thanks @maxmad64bis
3 changes: 2 additions & 1 deletion config/quality/file-size-baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -377,6 +377,7 @@
"_rebaseline_2026_07_27_3850_relax_filesize_cap": "OWNER-APPROVED TEMPORARY relax for v3.8.50-3.8.54 PREPARE phase (docs/ROADMAP.md). cap 800->900 (+100), testCap 800->900 (+100). Targets: decompose-existing-frozen unchanged (frozen still only-shrink); this only relaxes the cap for NEW files in the decompose/extract-while-PREPARE phase (.51='executor registry in-place' and .52='combo.ts decomposition' create new leaf modules above 800). RE-TIGHTENING MANDATORY in v3.8.51: cap target 850 = 850 once decomposition wave stabilizes. SUPERSEDED by _rebaseline_2026_07_27_3850_relax_filesize_cap_v2_20pct (v1 +20% buffer) — retained for audit. Tracked via same roadmap issue.",
"_rebaseline_2026_07_27_v3849_train1h": "Merge-train 1H (31 PRs) — owner-approved 2026-07-27. Two distinct causes, kept separate on purpose: (1) GENUINE irreducible growth at existing chokepoints — providerLimits/auth (#8632 Kimi quota-reset recovery), rateLimitManager (#8616 idle wedged limiters), models-catalog-route.test (#8610 OpenCode Go effort aliases); (2) COLLISION with #8585, which banked shrinks measured on the pre-train release tip while 30 sibling PRs in the SAME train grew those files again — chat/accountFallback (#8628), chatCore (#8613), videoGeneration (#8581), imageGeneration. The zero-headroom frozen entries cannot absorb either. Ceilings re-pinned to the post-merge tip; #8612 (also in this train) automates shrink-banking so this self-inflicted drift stops recurring. Detail: src/lib/usage/providerLimits.ts 1006->1013 (#8632); src/sse/services/auth.ts 2492->2508 (#8632); open-sse/services/rateLimitManager.ts 1014->1060 (#8616); src/sse/handlers/chat.ts 1842->1845 (#8628); open-sse/handlers/chatCore.ts 4939->4955 (#8613); open-sse/handlers/imageGeneration.ts 3100->3101 ((sem PR — teto do #8585)); open-sse/handlers/videoGeneration.ts 1038->1063 (#8581); open-sse/services/accountFallback.ts 1965->1966 (#8628); tests/unit/models-catalog-route.test.ts 1608->1636 (#8610)",
"frozen": {
"_rebaseline_2026_09_24_14558_reconcile": "PR #14558 own growth, re-measured after merging release/v3.8.51 on 2026-09-24 (merge-batch; the PR's original entry conflicted with the tip's baseline): open-sse/executors/opencode.ts 1318->1338. Only files this PR touches are adjusted; tip-level drift is handled in the wave's follow-up rebaseline.",
"_rebaseline_2026_09_23_14116_codex_quota_header_leak": "PR #14465 (fix #14116, Codex quota-header leak to a foreign combo/pool account) own growth, re-measured after merging release/v3.8.51 on 2026-09-23 (merge-batch): src/sse/handlers/chat.ts 2560->2561 (+1: one forcedConnectionId field threaded into the dispatchChatWithAffinityEviction call args) and src/sse/handlers/chatHelpers.ts 1257->1258 (+1: one passthrough field into handleChatCore). Irreducible call-site plumbing; the predicate/strip logic lives in the non-frozen open-sse/handlers/chatCore/responseHeaders.ts. Covered by tests/unit/codex-quota-header-leak-14116.test.ts. Structural shrink tracked in #3501.",
"src/sse/handlers/chatHelpers.ts": 1258,
"_rebaseline_2026_09_17_13720_merge_release_v3851": "Merge de release/v3.8.51 na #13720 (2026-09-17). src/sse/handlers/chatHelpers.ts 1246 -> 1253, decomposto: 1246 -> 1250 e crescimento INHERITED do tip (base-red ja presente em origin/release/v3.8.51 no commit 9688032451fc, arquivo com 1250 linhas contra cap 1246 — nao e desta PR e nao foi introduzido por este merge); 1250 -> 1253 sao as MESMAS +3 linhas da propria #13720 ja auditadas e aprovadas pelo dono na entrada _rebaseline_2026_09_16_13720_suffix_effort_propagation abaixo (threading de resolvedThinkingEffort). Nenhum outro teto foi tocado por este merge; tests/unit/chatcore-translation-paths.test.ts (3449 > 3447) permanece vermelho de proposito — e base-red herdado e a PR nao toca o arquivo.",
Expand Down Expand Up @@ -541,7 +542,7 @@
"open-sse/executors/deepseek-web.ts": 1224,
"open-sse/executors/default.ts": 1205,
"open-sse/services/rateLimitManager.ts": 1329,
"open-sse/executors/opencode.ts": 1318
"open-sse/executors/opencode.ts": 1338
},
"_rebaseline_2026_09_15_roundrobin_dashboard_events": "Fix #13089 (Combo Studio Live dashboard shows an empty backlog for round-robin combos): open-sse/services/combo/roundRobinCombo.ts 1205->1213. Round-robin is the only combo strategy that bypasses handleComboChat/executeTargetAttempt.ts, the path that publishes the combo.target.attempt/succeeded/failed EventBus events the Live dashboard listens for — so round-robin completions never showed up. The new call-site wiring (createRRDashboardEvents(...) instantiated once per target, one-line .attempt()/.succeeded()/.failed() calls at the 6 existing dispatch/outcome points) is the emitter logic actually extracted into a new module, open-sse/services/combo/rrDashboardEvents.ts — this is the minimum irreducible footprint for wiring 6 required call sites into 6 fixed control-flow points of the frozen file. Covered by tests/unit/issue-13089-roundrobin-live-ws-events.test.ts (2 tests: success + failure paths).",
"_rebaseline_base_2026_08_10_proxyfetch": "Base-red fix (green-prs sweep, issue #9985): open-sse/utils/proxyFetch.ts 1207 > cap 1000 — new proxied-TLS fetch helper introduced by the Fal reference-image work. Owner-authorized quick rebaseline to green; structural slim tracked for v3.9.0.",
Expand Down
4 changes: 4 additions & 0 deletions docs/reference/ENVIRONMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -760,6 +760,8 @@ REQUEST_TIMEOUT_MS (global override)
│ ├─→ TLS_CLIENT_TIMEOUT_MS (inherits from FETCH_TIMEOUT_MS)
│ │ └── TLS_FIRST_BYTE_WATCHDOG_MS (independent, default: 10000)
│ ├── RESPONSES_FIRST_BYTE_TIMEOUT_MS (independent, default: 15000)
│ ├── OPENCODE_RESPONSES_HEADERS_WAIT_MS (independent, default: 0 = off, suggested: 30000)
│ ├── OPENCODE_RESPONSES_HEADERS_WAIT_MAX_ROTATIONS (independent, default: 2)
│ ├── FETCH_CONNECT_TIMEOUT_MS (independent, default: 30000)
│ └── FETCH_KEEPALIVE_TIMEOUT_MS (independent, default: 4000)
├─→ STREAM_IDLE_TIMEOUT_MS (inherits from REQUEST_TIMEOUT_MS, default: 600000)
Expand Down Expand Up @@ -802,6 +804,8 @@ REQUEST_TIMEOUT_MS (global override)
| `TLS_CLIENT_TIMEOUT_MS` | = `FETCH_TIMEOUT_MS` | TLS fingerprint proxy (wreq-js) timeout. |
| `TLS_FIRST_BYTE_WATCHDOG_MS` | `10000` | Bounds time-to-first-byte on the wreq-js TLS-fingerprint transport's body specifically; `TLS_CLIENT_TIMEOUT_MS` alone cannot catch a stalled body since it resolves as soon as headers arrive (#12656). A timeout cancels the wreq reader and falls back to the direct/proxy dispatcher; `0` disables the watchdog. |
| `RESPONSES_FIRST_BYTE_TIMEOUT_MS` | `15000` | OpenCode executor only, and only while the `OPENCODE_RESPONSES_STALL_ROTATION` feature flag is on (default off): bounds the wait for the first body byte of a streamed Responses reply after its headers (#13484). A Responses stream opens with `response.created`, so silence past this window is a stall: the account is cooled down and the request rotates to the next account once; a second stall fails fast. `0` disables the guard even with the flag on. |
| `OPENCODE_RESPONSES_HEADERS_WAIT_MS` | `0` (= off) | OpenCode executor only: bounds the wait for upstream response headers on a streamed Responses call when another account is still available — a queued request stops waiting and moves on instead of holding the full headers window. Only streamed Responses calls are affected (the Responses stream opens with `response.created`, so silence there is a queue, not generation); chat completions and non-streamed calls are untouched, and the window only shortens the effective fetch-start ceiling, never extends it. Suggested value when enabling: `30000`. `0` disables. |
| `OPENCODE_RESPONSES_HEADERS_WAIT_MAX_ROTATIONS` | `2` | OpenCode executor only: how many times per request the headers-wait bound above may move to the next account. The last remaining account always keeps the full headers window. |
| `OPENCODE_PARK_AND_RESUME` | `false` | OpenCode executor only: park the request with a heartbeat after repeated transient 429s (or a fresh pool-strain marker), then replay one capped leg of up to 3 sequential accounts instead of fanning out the whole fleet (#13924). Off by default: every 429 rotates to the next account exactly as before. |
| `OPENCODE_POOL_STRAIN_MARKER_PATH` | _(unset)_ | OpenCode executor only: override path of the pool-strain marker read before parking (`{since, reason, ttl_s}`, default `/tmp/opencode-pool-strain.json`, #13924). A fresh marker parks without recounting; absent or stale falls back to the burst counter. |
| `API_BRIDGE_PROXY_TIMEOUT_MS` | `30000` | Proxy hop timeout for `/v1` bridge requests. |
Expand Down
53 changes: 45 additions & 8 deletions open-sse/executors/opencode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ import {
resolveResponsesStallWindowMs,
} from "./opencodeResponsesStall.ts";
import { discardResponseBody } from "./opencodeResponseBody.ts";
import { headersWaitDispatch, headersWaitState } from "./opencodeHeadersWait.ts";
import {
isRetriableUpstreamFailure,
releaseResponseBody,
Expand Down Expand Up @@ -532,6 +533,12 @@ export class OpencodeExecutor extends BaseExecutor {
// Opt-in Responses first-byte stall guard (#13484); a no-op when the window is 0.
const stallWindowMs = resolveResponsesStallWindowMs(input.stream, this._requestFormat);
const guardStall = <T>(r: T) => guardResponsesStall(r, stallWindowMs, input.signal);
const headersWait = headersWaitState(
input,
this._requestFormat,
this.getTimeoutMs(),
this.config?.fetchStartTimeoutCapMs
);
// Fast path: no multi-account proxy wiring configured → original behavior,
// plus exactly ONE bounded retry when the upstream answers a 400 empty
// rejection (same predicate and logging as the rotation loop). Everything
Expand Down Expand Up @@ -622,8 +629,7 @@ export class OpencodeExecutor extends BaseExecutor {
// (received refusal or refused TCP probe) are skipped. Off = plain rotation.
const skipRecentlyFailed = isProxySkipRecentlyFailedEnabled();
let directTried = false;
// Stalls before the first Responses byte: one rotation, then fail fast.
const stallCounter = { attempts: 0 };
const stallCounter = { attempts: 0 }; // first-byte stalls: one rotation, then fail fast
// A response an opt-in branch rotated away from. It stays lastResult (and
// intact) until a newer attempt replaces it, then its body is cancelled.
let abandonedResponse: Response | null = null;
Expand Down Expand Up @@ -746,13 +752,44 @@ export class OpencodeExecutor extends BaseExecutor {
account = paced.account;
let result: HttpExecuteResult;
try {
// super.execute() dispatches the HTTP path (never the web/scraping arm).
result = (await guardStall(
await runWithProxyContext(account.proxy, () =>
super.execute({ ...input, skipUpstreamRetry: true })
)
)) as HttpExecuteResult;
const { outcome, waitMs } = await headersWaitDispatch(
// opt-in bound on the guarded dispatch (stall guard inside the race)
headersWait,
account,
accounts,
isProxiedCandidate,
(attemptSignal) =>
(async () =>
guardStall(
await runWithProxyContext(account.proxy, () =>
super.execute({
...input,
skipUpstreamRetry: true,
signal: attemptSignal ?? input.signal,
})
)
) as Promise<HttpExecuteResult>)(),
input.signal
);
if (outcome.kind !== "ok") {
if (outcome.kind === "aborted")
egressPacing.throwPacedError(egressRelease, outcome.reason);
egressPacing.settleStalledDispatch(egressRelease, account, {
tried: geoTriedProxyKeys,
stalled: headersWait.spent,
cooldown: markCooldown,
markDirect: () => (directTried = true),
}); // same settle as the stall arm
log?.warn?.(
"OPENCODE",
`${cid}no response headers within ${waitMs}ms on account ${masked}, rotating to next…`
);
continue;
}
result = outcome.result;
} catch (err) {
if (headersWait.policy.windowMs > 0 && input.signal?.aborted)
egressPacing.throwPacedError(egressRelease, err); // client abort never rotates, slot released
const reason = err instanceof Error ? err.message : String(err);
// Stall guard: headers arrived, so the egress works — never a shared-egress
// outage; proxied and proxy-less accounts rotate alike. A client abort never rotates.
Expand Down
Loading
Loading