diff --git a/docs-site/src/content/docs/guides/codex-app-models.md b/docs-site/src/content/docs/guides/codex-app-models.md index c84f8f6407..053fba1669 100644 --- a/docs-site/src/content/docs/guides/codex-app-models.md +++ b/docs-site/src/content/docs/guides/codex-app-models.md @@ -105,6 +105,11 @@ them by ignoring `visibility`. See [Codex Desktop native-allowlist compatibility for the command, disable-key semantics, and safety constraints. ## Integration path + +`ocx init`, `ocx start`, and `ocx sync` wire the shared Codex config and catalog into the proxy; see +[Codex Integration](/guides/codex-integration/) for config injection, catalog sync, shims, WebSocket +fallback, and restore mechanics. + ## Native quota fallback limitation When the Codex app exhausts its native five-hour quota it can switch to a reserve @@ -137,11 +142,6 @@ mode is active; if the client rewrites or refuses it before the request leaves, setting changes that. Treat the explicit-selection route as worth trying rather than a confirmed workaround. - -`ocx init`, `ocx start`, and `ocx sync` wire the shared Codex config and catalog into the proxy; see -[Codex Integration](/guides/codex-integration/) for config injection, catalog sync, shims, WebSocket -fallback, and restore mechanics. - ## Why routed models show up Codex's model picker expects Codex-shaped catalog entries. opencodex builds routed entries by cloning diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 96bb1f8ce5..1f56ed979a 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -6144,7 +6144,15 @@ async function handleResponsesInner( { abortSignal: upstream.signal, label: safeHostLabel(builtContinuationRequest.url), - ...(continuationTransientPolicy ? { attempts: continuationTransientPolicy.attempts } : {}), + // Same request-scoped budget as the initial send and the 429/rotation refetches: + // a terminal-guard continuation is another leg of ONE request, so handing it a + // fresh `attempts` would let one request exceed the configured total-send ceiling. + ...(continuationTransientPolicy + ? { + attempts: remainingTransientSendBudget(continuationTransientPolicy.attempts), + onSendsConsumed: noteTransientSends, + } + : {}), }, ); } finally { diff --git a/tests/transient-budget-scope-source.test.ts b/tests/transient-budget-scope-source.test.ts new file mode 100644 index 0000000000..437973ba32 --- /dev/null +++ b/tests/transient-budget-scope-source.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +const source = (relative: string): string => + readFileSync(join(import.meta.dir, "..", "src", ...relative.split("/")), "utf8"); + +/** + * `transientRetryOn5xx.attempts` is ONE request-wide total-send budget, not a per-leg + * allowance. A Responses request can reach upstream on several legs — the initial send, a + * 429/account-rotation refetch, and the terminal-guard continuation — and each leg calls + * `fetchWithTransientRetry` separately. The budget only holds if every leg draws from the + * shared request-scoped counter. + * + * The continuation leg shipped on the raw policy value instead, so a request that reached it + * received a fresh full `attempts` allowance: with `attempts: 3` an initial send that had + * already spent its budget could still emit three more upstream sends. Runtime coverage in + * `tests/upstream-transient-retry.test.ts` proves the helper reports and honors a remainder; + * it cannot prove that every call site asks for one, because a site that forgets simply + * passes a larger number. This asserts the wiring at the source, which is the only place the + * omission is visible. + */ +describe("transient send budget stays request-scoped", () => { + test("every transient-retry call site draws from the shared counter", () => { + const core = source("server/responses/core.ts"); + + // One owner per request, declared before any leg can send. + expect(core.match(/let transientSendsUsed = 0;/g)).toHaveLength(1); + expect(core.match(/const remainingTransientSendBudget = \(budget: number\): number =>/g)).toHaveLength(1); + + // Initial send, 429/rotation refetch, and terminal-guard continuation: three legs, three + // reports into the same counter. + expect(core.match(/onSendsConsumed: noteTransientSends/g)).toHaveLength(3); + + // The refetch and continuation legs must ask for the REMAINDER. Only the initial send may + // pass a policy value directly, because nothing has been spent yet. + expect(core.match(/attempts: remainingTransientSendBudget\(/g)).toHaveLength(2); + expect(core).toContain("attempts: remainingTransientSendBudget(refetchTransientPolicy.attempts)"); + expect(core).toContain("attempts: remainingTransientSendBudget(continuationTransientPolicy.attempts)"); + + // The regressed shape: a leg handing itself a fresh full budget. + expect(core).not.toContain("attempts: continuationTransientPolicy.attempts }"); + expect(core).not.toContain("attempts: refetchTransientPolicy.attempts }"); + }); + + test("the helper still exposes the seam those call sites depend on", () => { + const retry = source("lib/upstream-retry.ts"); + expect(retry).toContain("onSendsConsumed?: (sends: number) => void;"); + // Reported in `finally` so every exit path — return, throw, abort — feeds the counter. + expect(retry).toMatch(/} finally \{\n\s*opts\.onSendsConsumed\?\.\(sent\);/); + }); +});