From 79fec5d6ccf0a7a79e54da6220ea7d1a917d8869 Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 10 Sep 2026 07:26:20 +0900 Subject: [PATCH 1/2] fix(responses): let a combo shadow-call target enter the failover loop A shadowCallIntercept whose replacement names a combo ran exactly one attempt and never entered the failover loop, so a 429 or 5xx from the first target returned to the caller instead of hopping to the next one. Two cooperating causes. The combo gate reads the UN-rewritten body: comboIdFromRawBody sees only body.model, which is still the bare helper slug (gpt-5.6-luna) at that point, so handleComboResponses never runs and neither does advanceComboAfterFailure. The rewrite happened later, after parse, where resolveRoute("combo/shadow") goes through routeModel -> tryPickComboModel and collapses the combo to ONE target while still tagging routeKind "combo". That collapsed pick is the reported "combo route, one attempt". There is a second path through the same site. shouldInterceptShadowCall is isShadowSourceModel && !shadowCallTargetsIntersect, so when the collapsed first pick happens to be openai/gpt-5.6-luna the intersect check is true, the intercept is skipped outright, and the request leaves as a plain native route with no marker. Swapping the two blocks does not close that path. Rewrite the selector before comboIdFromRawBody reads it instead, and identify the combo with resolveComboId - a pure config lookup that performs no routing and therefore cannot collapse the table. A combo selector is routing policy, not the identity of its first pick. The existing combo gate takes it from there and handleComboResponses runs its ordinary loop. shouldInterceptShadowCall is left alone: it still suppresses direct same-provider replacements (#2706). The late intercept site does not re-enter handleComboResponses, which would double-run expandPreviousResponseInput and onRequestBodyRead. The marker records the operator-configured prefix through sanitizeLogMetadataString exactly as the late site does, so no caller-controlled string is persisted. Closes #4129. --- src/server/responses/core.ts | 24 ++++ .../responses-shadow-intercept.test.ts | 124 ++++++++++++++++++ 2 files changed, 148 insertions(+) diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index e41c2e6239..c1ce136ca4 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -3303,6 +3303,30 @@ async function handleResponsesInner( } } } + // A shadow-call replacement that names a COMBO is routing policy, not the identity of any + // one pick. The late intercept site below resolves it through routeModel/tryPickComboModel, + // which collapses the table to a single target while still tagging `routeKind: "combo"`, so + // the combo gate on the next line never fires, handleComboResponses never runs, and 429/5xx + // hops — which only exist inside that loop — are unreachable (#4129). Rewrite the selector + // here instead, before comboIdFromRawBody reads `model`, and identify the combo by CONFIG + // LOOKUP so the check can never observe a one-candidate collapse. + if (!options.comboAttempt && body && typeof body === "object" && !Array.isArray(body)) { + const shadowIntercept = config.shadowCallIntercept; + const rawShadowModel = (body as { model?: unknown }).model; + if (shadowIntercept?.enabled && shadowIntercept.model && typeof rawShadowModel === "string" + && isShadowSourceModel(rawShadowModel, shadowIntercept.sourceModels)) { + const shadowComboId = resolveComboId(config, shadowIntercept.model); + if (shadowComboId && Object.hasOwn(config.combos ?? {}, shadowComboId)) { + (body as Record).model = shadowIntercept.model; + // Same rule as the late intercept site: record the operator-configured prefix that + // matched, never the caller's raw model string. Matching is by prefix, so the raw + // value is caller-controlled and reaches usage.jsonl and /api/logs. + logCtx.shadowCallRewrittenFrom = sanitizeLogMetadataString( + shadowSourceModelPrefix(rawShadowModel, shadowIntercept.sourceModels), + ); + } + } + } const comboId = !options.comboAttempt ? comboIdFromRawBody(body, config) : null; if (comboId && Object.hasOwn(config.combos ?? {}, comboId)) { options.onRequestBodyRead?.(); diff --git a/tests/responses/responses-shadow-intercept.test.ts b/tests/responses/responses-shadow-intercept.test.ts index 3f7e12eb38..c904d7d31d 100644 --- a/tests/responses/responses-shadow-intercept.test.ts +++ b/tests/responses/responses-shadow-intercept.test.ts @@ -250,6 +250,130 @@ describe("shadow call intercept request path (issue #311)", () => { }); }); +/** + * A shadow-call replacement naming a COMBO used to run exactly one attempt and never enter + * the failover loop (#4129). Two cooperating causes: the combo gate reads the UN-rewritten + * body, where the model is still the bare helper slug, and the late intercept resolved the + * replacement through routeModel/tryPickComboModel, which collapses the combo table to a + * single target while still tagging routeKind "combo" — so the reported "combo route, one + * attempt" was a collapsed native pick, and 429/5xx hops (which only exist inside + * handleComboResponses) were unreachable. + */ +function comboInterceptConfig(targets: Array<{ provider: string; model: string }>): OcxConfig { + return { + port: 0, + defaultProvider: "xai", + providers: { + xai: { + adapter: "openai-chat", + baseUrl: "https://api.x.ai/v1", + authMode: "key", + apiKey: "test-xai-key", + models: ["grok-4.5"], + }, + // Named for the shadow source's own provider on purpose: it is what makes the second + // case a real reproduction. shadowCallTargetsIntersect compares provider+model, and + // the source identity falls back to the OpenAI Codex provider id, so a collapsed first + // pick of openai/gpt-5.6-luna used to suppress the intercept outright. + openai: { + adapter: "openai-chat", + baseUrl: "https://helper.example/v1", + authMode: "key", + apiKey: "test-openai-key", + models: ["gpt-5.6-luna"], + }, + }, + combos: { + shadow: { strategy: "failover", targets }, + }, + shadowCallIntercept: { enabled: true, model: "combo/shadow" }, + } as unknown as OcxConfig; +} + +function chatOk(text: string): Response { + return Response.json({ + choices: [{ index: 0, message: { role: "assistant", content: text }, finish_reason: "stop" }], + usage: { prompt_tokens: 1, completion_tokens: 1 }, + }); +} + +describe("a combo shadow-call target enters the failover loop (#4129)", () => { + test("a helper call rewritten to a combo hops past a 429 to the second target", async () => { + const urls: string[] = []; + const logCtx: RequestLogContext = { model: "", provider: "" }; + globalThis.fetch = (async (url: unknown) => { + urls.push(String(url)); + return urls.length === 1 + ? Response.json({ error: { message: "rate limited" } }, { status: 429 }) + : chatOk("ok"); + }) as typeof fetch; + + const config = comboInterceptConfig([ + { provider: "xai", model: "grok-4.5" }, + { provider: "openai", model: "gpt-5.6-luna" }, + ]); + const response = await post(config, "gpt-5.6-luna", "turn", logCtx); + + expect(response.ok).toBe(true); + // The whole point: two upstream attempts, in configured order. + expect(urls).toHaveLength(2); + expect(urls[0]).toContain("api.x.ai"); + expect(urls[1]).toContain("helper.example"); + expect(logCtx.provider).toBe("combo"); + expect(logCtx.comboId).toBe("shadow"); + expect(logCtx.routeDecision?.routeKind).toBe("combo"); + expect(logCtx.shadowCallRewrittenFrom).toBe("gpt-5.6-luna"); + const attempts = (logCtx.attempts ?? []) as Array<{ provider?: string; model?: string }>; + expect(attempts).toHaveLength(2); + expect(attempts.map(a => `${a.provider}/${a.model}`)) + .toEqual(["xai/grok-4.5", "openai/gpt-5.6-luna"]); + }); + + test("a combo whose first target intersects the source still routes as a combo", async () => { + const urls: string[] = []; + const logCtx: RequestLogContext = { model: "", provider: "" }; + globalThis.fetch = (async (url: unknown) => { + urls.push(String(url)); + return chatOk("ok"); + }) as typeof fetch; + + const config = comboInterceptConfig([ + { provider: "openai", model: "gpt-5.6-luna" }, + { provider: "xai", model: "grok-4.5" }, + ]); + const response = await post(config, "gpt-5.6-luna", "turn", logCtx); + + expect(response.ok).toBe(true); + // A healthy first target still costs exactly one upstream call. + expect(urls).toHaveLength(1); + expect(urls[0]).toContain("helper.example"); + expect(logCtx.provider).toBe("combo"); + expect(logCtx.comboId).toBe("shadow"); + expect(logCtx.routeDecision?.routeKind).toBe("combo"); + // Red before the fix: shouldInterceptShadowCall saw the collapsed pick as a self-target, + // skipped the rewrite, and the request left as a plain native route with no marker. + expect(logCtx.shadowCallRewrittenFrom).toBe("gpt-5.6-luna"); + }); + + test("a non-combo replacement still takes the ordinary late intercept", async () => { + const urls: string[] = []; + const logCtx: RequestLogContext = { model: "", provider: "" }; + globalThis.fetch = (async (url: unknown) => { + urls.push(String(url)); + return chatOk("ok"); + }) as typeof fetch; + + const config = comboInterceptConfig([{ provider: "xai", model: "grok-4.5" }]); + config.shadowCallIntercept = { enabled: true, model: "xai/grok-4.5" }; + const response = await post(config, "gpt-5.6-luna", "turn", logCtx); + + expect(response.ok).toBe(true); + expect(urls).toHaveLength(1); + expect(logCtx.comboId).toBeUndefined(); + expect(logCtx.shadowCallRewrittenFrom).toBe("gpt-5.6-luna"); + }); +}); + /** * The GUI badge/tooltip used to hard-code "5.4-mini", so it kept naming a model * Codex no longer sends. The management API is the single source of truth for From 421aea87afc9e9fd356149d3944d107d40bb8b13 Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 10 Sep 2026 07:38:36 +0900 Subject: [PATCH 2/2] test(responses): keep the #4129 combo fixture off the fixed-endpoint openai provider The first CI run proved the fix itself: the combo loop was entered and hopped ("[combo] shadow: xai/grok-4.5 failed with 429", then the second target). Both new cases still failed, because the fixture used a provider literally named "openai", whose endpoint is pinned to https://chatgpt.com/backend-api/codex. The configured helper.example baseUrl was ignored with a warning and the second target answered 401 instead of the mocked 200. Move both cases onto ordinary key providers. The self-target case now uses the same shape as the existing #2706 no-op test: a custom sourceModels prefix whose resolved provider is also the combo's first target, so shadowCallTargetsIntersect is genuinely true for the collapsed one-candidate pick. That is the condition that used to suppress the intercept, and it is now reproduced without depending on a reserved provider id. --- .../responses-shadow-intercept.test.ts | 46 ++++++++++--------- 1 file changed, 25 insertions(+), 21 deletions(-) diff --git a/tests/responses/responses-shadow-intercept.test.ts b/tests/responses/responses-shadow-intercept.test.ts index c904d7d31d..feafd404df 100644 --- a/tests/responses/responses-shadow-intercept.test.ts +++ b/tests/responses/responses-shadow-intercept.test.ts @@ -259,7 +259,10 @@ describe("shadow call intercept request path (issue #311)", () => { * attempt" was a collapsed native pick, and 429/5xx hops (which only exist inside * handleComboResponses) were unreachable. */ -function comboInterceptConfig(targets: Array<{ provider: string; model: string }>): OcxConfig { +function comboInterceptConfig( + targets: Array<{ provider: string; model: string }>, + shadowCallIntercept: Record = { enabled: true, model: "combo/shadow" }, +): OcxConfig { return { port: 0, defaultProvider: "xai", @@ -269,24 +272,18 @@ function comboInterceptConfig(targets: Array<{ provider: string; model: string } baseUrl: "https://api.x.ai/v1", authMode: "key", apiKey: "test-xai-key", - models: ["grok-4.5"], }, - // Named for the shadow source's own provider on purpose: it is what makes the second - // case a real reproduction. shadowCallTargetsIntersect compares provider+model, and - // the source identity falls back to the OpenAI Codex provider id, so a collapsed first - // pick of openai/gpt-5.6-luna used to suppress the intercept outright. - openai: { + alt: { adapter: "openai-chat", - baseUrl: "https://helper.example/v1", + baseUrl: "https://alt.example/v1", authMode: "key", - apiKey: "test-openai-key", - models: ["gpt-5.6-luna"], + apiKey: "test-alt-key", }, }, combos: { shadow: { strategy: "failover", targets }, }, - shadowCallIntercept: { enabled: true, model: "combo/shadow" }, + shadowCallIntercept, } as unknown as OcxConfig; } @@ -310,7 +307,7 @@ describe("a combo shadow-call target enters the failover loop (#4129)", () => { const config = comboInterceptConfig([ { provider: "xai", model: "grok-4.5" }, - { provider: "openai", model: "gpt-5.6-luna" }, + { provider: "alt", model: "grok-4.5" }, ]); const response = await post(config, "gpt-5.6-luna", "turn", logCtx); @@ -318,7 +315,7 @@ describe("a combo shadow-call target enters the failover loop (#4129)", () => { // The whole point: two upstream attempts, in configured order. expect(urls).toHaveLength(2); expect(urls[0]).toContain("api.x.ai"); - expect(urls[1]).toContain("helper.example"); + expect(urls[1]).toContain("alt.example"); expect(logCtx.provider).toBe("combo"); expect(logCtx.comboId).toBe("shadow"); expect(logCtx.routeDecision?.routeKind).toBe("combo"); @@ -326,7 +323,7 @@ describe("a combo shadow-call target enters the failover loop (#4129)", () => { const attempts = (logCtx.attempts ?? []) as Array<{ provider?: string; model?: string }>; expect(attempts).toHaveLength(2); expect(attempts.map(a => `${a.provider}/${a.model}`)) - .toEqual(["xai/grok-4.5", "openai/gpt-5.6-luna"]); + .toEqual(["xai/grok-4.5", "alt/grok-4.5"]); }); test("a combo whose first target intersects the source still routes as a combo", async () => { @@ -337,22 +334,29 @@ describe("a combo shadow-call target enters the failover loop (#4129)", () => { return chatOk("ok"); }) as typeof fetch; - const config = comboInterceptConfig([ - { provider: "openai", model: "gpt-5.6-luna" }, - { provider: "xai", model: "grok-4.5" }, - ]); - const response = await post(config, "gpt-5.6-luna", "turn", logCtx); + // The #2706 self-target shape: the source model routes to xai, and the combo's FIRST + // target is that same provider+model. shadowCallTargetsIntersect is therefore true for + // the collapsed one-candidate pick, which is what used to suppress the intercept + // outright and leave the request on a plain native route. + const config = comboInterceptConfig( + [ + { provider: "xai", model: "custom-helper" }, + { provider: "alt", model: "grok-4.5" }, + ], + { enabled: true, model: "combo/shadow", sourceModels: ["custom-helper"] }, + ); + const response = await post(config, "custom-helper", "turn", logCtx); expect(response.ok).toBe(true); // A healthy first target still costs exactly one upstream call. expect(urls).toHaveLength(1); - expect(urls[0]).toContain("helper.example"); + expect(urls[0]).toContain("api.x.ai"); expect(logCtx.provider).toBe("combo"); expect(logCtx.comboId).toBe("shadow"); expect(logCtx.routeDecision?.routeKind).toBe("combo"); // Red before the fix: shouldInterceptShadowCall saw the collapsed pick as a self-target, // skipped the rewrite, and the request left as a plain native route with no marker. - expect(logCtx.shadowCallRewrittenFrom).toBe("gpt-5.6-luna"); + expect(logCtx.shadowCallRewrittenFrom).toBe("custom-helper"); }); test("a non-combo replacement still takes the ordinary late intercept", async () => {