From 5e2ff3556660c6f5defd6f1ff47cc2057734d95b Mon Sep 17 00:00:00 2001 From: Dizzle <112548150+maxmad64bis@users.noreply.github.com> Date: Wed, 23 Sep 2026 11:26:41 +0200 Subject: [PATCH] fix(sse): share applied proxy capture across duplicated fetch modules --- .../14620-applied-proxy-shared-context.md | 1 + config/quality/file-size-baseline.json | 3 +- open-sse/utils/proxyFetch.ts | 17 ++-- .../unit/proxy-fetch-applied-context.test.ts | 86 +++++++++++++++++++ 4 files changed, 101 insertions(+), 6 deletions(-) create mode 100644 changelog.d/fixes/14620-applied-proxy-shared-context.md create mode 100644 tests/unit/proxy-fetch-applied-context.test.ts diff --git a/changelog.d/fixes/14620-applied-proxy-shared-context.md b/changelog.d/fixes/14620-applied-proxy-shared-context.md new file mode 100644 index 00000000000..a6200e26776 --- /dev/null +++ b/changelog.d/fixes/14620-applied-proxy-shared-context.md @@ -0,0 +1 @@ +- **fix(sse):** the applied-proxy capture context is shared across duplicated `proxyFetch` module instances, so the proxy journal can tell a received provider status apart from a local failure no matter which server copy serves the dispatch. ([#14620](https://github.com/diegosouzapw/OmniRoute/pull/14620)) — thanks @maxmad64bis diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index c93bfb88d5d..a1f8a50fab6 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -1,4 +1,5 @@ { + "_rebaseline_2026_09_23_applied_proxy_shared_context": "Own growth: open-sse/utils/proxyFetch.ts 1287->1294 (+7 = the shared-store key + store type + lazy getter, plus four one-line mechanical swaps at the existing call sites). Irreducible plumbing at the single store-identity chokepoint: every duplicated module copy must resolve the same AsyncLocalStorage instance so the fetch patch installed by one copy reads the capture sink opened by another; the store itself lives in the same file, no new module needed for 7 lines. Covered by tests/unit/proxy-fetch-applied-context.test.ts (3/3: single-copy trap, two-copy capture, sequential isolation) plus the neighbor suites proxyfetch-upstream-status-capture (9/9), proxy-fetch (9/9) and proxy-logs-upstream-status (4/4).", "_rebaseline_2026_09_22_14069_model_not_in_catalog": "PR #14069 (@RaviTharuma) own growth: a live-catalog miss is now recorded as the model_not_in_catalog skip reason instead of collapsing into the generic availability bucket, so an unknown alias stops being reported as \"no credentials available\" (#14068). Measured on the tree reconciled with release/v3.8.51 @ea3c1226: src/sse/handlers/chat.ts 2559->2560 (+1 = the single modelInfo.errorType === \"model_not_found\" early return inside the existing isModelAvailable callback) and open-sse/services/combo/roundRobinCombo.ts 1261->1263 (+2 = the strict `available !== true` pre-check plus the sticky-target expression, which Prettier printWidth 100 reflows over three lines once it becomes `(await isModelAvailable(...)) === true`). Both files were already frozen exactly at their measured size with zero headroom (chat.ts was tightened to 2559 by #14223 on 2026-09-20), so the growth cannot be absorbed. These are call-site lines threading the new model_not_in_catalog skip reason through the two availability chokepoints and nothing else; the reason itself lives outside the frozen files, all under cap: the ModelAvailabilityResult union and modelAvailabilitySkipReason in open-sse/services/combo/types.ts, the COMBO_SKIP_REASONS entry in decisionTrace.ts and the threading in executeTargetGates.ts. Irreducible. Covered by tests/unit/combo/combo-skipped-targets-summary.test.ts. Structural shrink of both god-files stays tracked in #3501.", "_rebaseline_2026_09_22_11725_cpa_auth_index": "PR for #11725 own growth: open-sse/handlers/chatCore.ts 6400->6402 (+2). Prettier printWidth 100 keeps the failure-usage cpaAuthIndex property and the readCpaAuthIndex import on their own lines; the streaming and non-streaming call sites stay on the existing endpoint line. The parser, stamp, and label join live in open-sse/handlers/chatCore/cpaTraceAuthIndex.ts (under cap). Covered by tests/unit/cpa-trace-auth-index.test.ts, tests/unit/cpa-auth-index-usage.test.ts, and tests/unit/db/migration-185-cpa-auth-index.test.ts.", "_rebaseline_2026_09_20_14223_rotation_attribution_growth": "PR #14223 own growth: rotation attribution diagnostics (masked serving-account id + request correlation id on proxy log rows, per-account rotation state, skip lines). Re-measured on the tree reconciled with release/v3.8.51 @59de50e4 (which brought #14149 -- the MuseSpark block and the per-request format/session context moved out of opencode.ts -- #14226 and #14464): open-sse/executors/opencode.ts 1251->1318 gate count (re-measured again after #14353 moved the member list off the instance) (snapshotEntries + logSkippedCooldownAccounts + attribution wiring at the nine existing rotation exits incl. the two park-and-replay returns; logic kept at the rotation seam), src/sse/handlers/chat.ts 2547->2559 (attribution sink type + flag read + two forwarded fields at the existing log call-site), src/sse/handlers/chatHelpers.ts 1253->1257 (+4, two additive optional params forwarded to the journal row), open-sse/utils/proxyFetch.ts 1268->1287 (+19, AppliedProxySink rotationAccount field + noteRotationAccount helper). Irreducible spec wiring at existing chokepoints, additive and flag-off inert. Covered by 6 new test files (17 tests).", @@ -495,7 +496,7 @@ "open-sse/services/combo/executeTargetAttempt.ts": 1273, "open-sse/translator/response/openai-responses.ts": 1518, "open-sse/utils/cursorAgentProtobuf.ts": 1588, - "open-sse/utils/proxyFetch.ts": 1287, + "open-sse/utils/proxyFetch.ts": 1294, "open-sse/utils/stream.ts": 3239, "open-sse/vendor/codex-chatgpt-web/adapters/chatgpt-web/browser-worker.ts": 4398, "open-sse/vendor/codex-chatgpt-web/bridge.ts": 1335, diff --git a/open-sse/utils/proxyFetch.ts b/open-sse/utils/proxyFetch.ts index 8b455e15e88..eaab13edbe2 100644 --- a/open-sse/utils/proxyFetch.ts +++ b/open-sse/utils/proxyFetch.ts @@ -195,7 +195,14 @@ export type AppliedProxySink = { /** Masked serving-account id (N112) — set by the rotation executor at dispatch. */ rotationAccount?: string | null; }; -const appliedProxyContext = new AsyncLocalStorage(); +const APPLIED_PROXY_CONTEXT_KEY = Symbol.for("omniroute.proxyFetch.applied-context"); +type AppliedProxyStore = typeof globalThis & { + [APPLIED_PROXY_CONTEXT_KEY]?: AsyncLocalStorage; +}; +function getAppliedProxyContext(): AsyncLocalStorage { + return ((globalThis as AppliedProxyStore)[APPLIED_PROXY_CONTEXT_KEY] ??= + new AsyncLocalStorage()); +} /** * Run `fn` with an applied-proxy capture sink in context. Any @@ -205,7 +212,7 @@ const appliedProxyContext = new AsyncLocalStorage(); * resolves. Pure plumbing — no behavioral change to the request itself. */ export function runWithAppliedProxyCapture(sink: AppliedProxySink, fn: () => T): T { - return appliedProxyContext.run(sink, fn); + return getAppliedProxyContext().run(sink, fn); } /** @@ -215,7 +222,7 @@ export function runWithAppliedProxyCapture(sink: AppliedProxySink, fn: () => */ export function noteRotationAccount(masked: string): void { try { - const sink = appliedProxyContext.getStore(); + const sink = getAppliedProxyContext().getStore(); if (sink) sink.rotationAccount = masked; } catch { /* attribution is best-effort; never break the request path */ @@ -684,7 +691,7 @@ export async function runWithProxyContext( // otherwise leave proxyInfo reading "direct"). Innermost runWithProxyContext // wins, which is exactly the per-account proxy the executor selected. if (effectiveProxyConfig) { - const sink = appliedProxyContext.getStore(); + const sink = getAppliedProxyContext().getStore(); if (sink) sink.proxy = effectiveProxyConfig; } @@ -1192,7 +1199,7 @@ async function patchedFetchUnrecorded( throw lastProxyError; } -const getAppliedProxySink = () => appliedProxyContext.getStore(); +const getAppliedProxySink = () => getAppliedProxyContext().getStore(); const patchedFetch = withUpstreamStatusCapture(patchedFetchUnrecorded, getAppliedProxySink); /** diff --git a/tests/unit/proxy-fetch-applied-context.test.ts b/tests/unit/proxy-fetch-applied-context.test.ts new file mode 100644 index 00000000000..8abbcb4318e --- /dev/null +++ b/tests/unit/proxy-fetch-applied-context.test.ts @@ -0,0 +1,86 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import http from "node:http"; +import type { AddressInfo } from "node:net"; + +// Two module copies share the applied-proxy capture: a fetch installed by copy A +// must see the capture sink opened by copy B. This mirrors production, where the +// module is bundled once per server chunk, so a provider dispatch served by any +// copy records its received status on whichever copy opened the capture. +// Single-copy coverage alone cannot catch this: it passes before and after. +// Note: serverless runtimes skip the global patch, so there is no capture there +// by design; this test only covers the patched path. + +const MODULE_PATH = "../../open-sse/utils/proxyFetch.ts"; +const { runWithCapture } = await import("../../open-sse/utils/providerRequestLogging.ts"); + +const capture = { capture: () => {}, body: (fallback: unknown) => fallback }; +let server: http.Server; +let baseUrl = ""; + +test.before(async () => { + server = http.createServer((req, res) => { + const params = new URL(req.url ?? "/", "http://local").searchParams; + res.writeHead(Number(params.get("code") ?? 200), { "content-type": "application/json" }); + res.end("{}"); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", () => resolve())); + baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; +}); + +test.after(async () => { + await new Promise((resolve) => server.close(() => resolve())); +}); + +type Sink = { proxy: unknown; upstreamStatus?: number }; + +async function dispatch(fn: () => Promise) { + await runWithCapture(capture, async () => { + const res = await fn(); + await res.text(); + }); +} + +test("a single copy records the received status", async () => { + const solo = await import(`${MODULE_PATH}?graph=solo`); + const sink: Sink = { proxy: null }; + await solo.runWithAppliedProxyCapture(sink, () => + dispatch(() => fetch(`${baseUrl}/v1?code=429`)) + ); + assert.equal(sink.upstreamStatus, 429); +}); + +test("a fetch from copy A records the status on a sink opened by copy B", async () => { + // Distinct specifiers → two genuinely separate module records. + const copyA = await import(`${MODULE_PATH}?graph=a`); + const copyB = await import(`${MODULE_PATH}?graph=b`); + const sink: Sink = { proxy: null }; + await copyB.runWithAppliedProxyCapture(sink, () => + dispatch(() => copyA.proxyFetch(`${baseUrl}/v1?code=429`)) + ); + assert.equal(sink.upstreamStatus, 429); +}); + +test("sequential captures keep their own status, a failed fetch leaves none", async () => { + const copyA = await import(`${MODULE_PATH}?graph=c`); + const copyB = await import(`${MODULE_PATH}?graph=d`); + const first: Sink = { proxy: null }; + await copyB.runWithAppliedProxyCapture(first, () => + dispatch(() => copyA.proxyFetch(`${baseUrl}/v1?code=429`)) + ); + assert.equal(first.upstreamStatus, 429); + + const second: Sink = { proxy: null }; + await copyB.runWithAppliedProxyCapture(second, () => + dispatch(() => copyA.proxyFetch(`${baseUrl}/v1?code=200`)) + ); + assert.equal(second.upstreamStatus, 200); + + const failed: Sink = { proxy: null }; + await copyB + .runWithAppliedProxyCapture(failed, () => + dispatch(() => copyA.proxyFetch("http://127.0.0.1:1/v1")) + ) + .catch(() => {}); + assert.equal(failed.upstreamStatus, undefined); +});