From 160961a447038ccfe8b4043fa81763a4d780ff76 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 4 Oct 2026 04:25:50 +0900 Subject: [PATCH 1/3] fix(combo): recognize bounded nested HTTP refusal carriers --- src/combos/failover.ts | 10 ++++- structure/providers-and-adapters.md | 1 + ...uter-combo-failover-classification.test.ts | 25 +++++++++++ .../server-combo-plan-model-refusal.test.ts | 45 +++++++++++++++++++ 4 files changed, 79 insertions(+), 2 deletions(-) diff --git a/src/combos/failover.ts b/src/combos/failover.ts index 51e441986fc..0be746748b1 100644 --- a/src/combos/failover.ts +++ b/src/combos/failover.ts @@ -703,8 +703,14 @@ export function codexAccountModelRefusal(status: number, message: string): Codex if (CODEX_ACCOUNT_MODEL_REFUSAL.test(text)) return "refusal"; try { const payload: unknown = JSON.parse(text); - return hasConflictingCodexModelRefusalEnvelopes(status, payload) - ? "ambiguous" : codexAccountModelRefusalPayload(status, payload); + if (hasConflictingCodexModelRefusalEnvelopes(status, payload)) return "ambiguous"; + // A present root carrier stays authoritative, even when malformed or nonmatching. + if (payload && typeof payload === "object" && !Array.isArray(payload) + && !Object.hasOwn(payload, "detail") && !Object.hasOwn(payload, "error") + && Object.hasOwn(payload, "response")) { + return codexAccountModelRefusalPayload(status, (payload as Record).response); + } + return codexAccountModelRefusalPayload(status, payload); } catch { return "other"; } } diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index 4edc04a85ae..a4bd1903b7d 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -570,4 +570,5 @@ Known Claude Sonnet and Opus 5.5 base and low/medium/high IDs share a determinis `src/server/responses/core-options.ts` carries the value to the combo decision. An inspected non-match remains authoritative. No raw body is retained in that field, serialized into client errors, or logged. Presence of both `detail` and `error` across the original root and its own non-array `response` record is ambiguous, regardless of values; null counts and deeper quoted objects do not. This is checked before selecting an error carrier. +For complete bounded HTTP errors, the text wrapper selects root `detail`/`error` when present; otherwise it selects the own `response` record for the same exact `detail` or `error.message` match. It never searches another carrier after a nonmatch or descends further. Policy-fallback and direct callers share this wrapper; SSE carrier selection is unchanged. Committed output, cancellation, hard policy refusals and resend limits remain stronger than model fallback. diff --git a/tests/routing/router-combo-failover-classification.test.ts b/tests/routing/router-combo-failover-classification.test.ts index 9d105cbc8b9..0ea79943cac 100644 --- a/tests/routing/router-combo-failover-classification.test.ts +++ b/tests/routing/router-combo-failover-classification.test.ts @@ -172,6 +172,31 @@ describe("combo failure hop/stop verdicts", () => { response: { error: null } }))).toBe("hop"); }); + test("nested HTTP refusal selects one carrier without borrowing body metadata", () => { + const refusal = "The 'gpt-6-astra' model is not supported when using Codex with a ChatGPT account."; + for (const response of [{ detail: refusal }, { error: { message: refusal } }]) { + const body = JSON.stringify({ response }); + expect(comboFailureDecision(400, body)).toBe("hop"); + expect(comboFailureDecision(400, `Provider error 400: ${body}`)).toBe("hop"); + expect(comboFailureDecision(400, body, { codexModelRefusal: "other" })).toBe("stop"); + expect(comboFailureDecision(400, body, { code: "origin_rejected" })).toBe("stop"); + expect(comboFailureDecision(400, body, { code: "upstream_no_response" })).toBe("stop"); + expect(comboFailureDecision(400, body, { code: "cyber_policy" })).toBe("stop"); + expect(comboFailureDecision(499, body)).toBe("stop"); + expect(comboFailureDecision(422, body)).toBe("stop"); + expect(comboFailureDecision(400, body.slice(0, -1))).toBe("stop"); + expect(comboFailureDecision(400, JSON.stringify({ response, padding: "x".repeat(16_384) }))).toBe("stop"); + } + for (const body of [ + { detail: null, response: { detail: refusal } }, + { detail: "bad input", response: { detail: refusal } }, + { error: null, response: { error: { message: refusal } } }, + { error: { message: "bad input" }, response: { error: { message: refusal } } }, + { response: { error: { message: "bad input" } }, codexModelRefusal: "refusal" }, + { response: { error: { message: "bad input" }, codexModelRefusal: "refusal" } }, + ]) expect(comboFailureDecision(400, JSON.stringify(body))).toBe("stop"); + }); + test("402 and 425 hop instead of ending the chain", () => { expect(comboFailureDecision(402, "payment required")).toBe("hop"); expect(comboFailureDecision(425, "too early")).toBe("hop"); diff --git a/tests/server/server-combo-plan-model-refusal.test.ts b/tests/server/server-combo-plan-model-refusal.test.ts index 4f09e6e967d..133f927f2df 100644 --- a/tests/server/server-combo-plan-model-refusal.test.ts +++ b/tests/server/server-combo-plan-model-refusal.test.ts @@ -203,3 +203,48 @@ test("outer error presence blocks nested detail despite a model code", async () expect(await response.text()).not.toContain("fallback succeeded"); expect(sends).toEqual([firstModel]); }); + +for (const carrier of ["detail", "error"] as const) { + test.each([0, 800])(`nested-only HTTP ${carrier} refusal hops (padding=%s)`, async padding => { + const nested = carrier === "detail" ? { detail: refusal } : { error: { message: refusal } }; + const { response, sends } = await run({ response: nested, padding: "x".repeat(padding) }); + expect(response.status).toBe(200); + await response.text(); + expect(sends).toEqual([firstModel, secondModel]); + }); +} + +test("HTTP root carrier stays authoritative over a same-kind nested refusal", async () => { + for (const error of [null, { message: "bad input" }]) { + const { response, sends } = await run({ error, response: { error: { message: refusal } } }); + expect(response.status).toBe(400); + await response.text(); + expect(sends).toEqual([firstModel]); + } +}); + +test("HTTP nested carrier does not recursively search quotes or accept non-record responses", async () => { + for (const nested of [null, [], [{ detail: refusal }], { quoted: { detail: refusal } }, + { response: { error: { message: refusal } } }]) { + const { response, sends } = await run({ response: nested }); + expect(response.status).toBe(400); + await response.text(); + expect(sends).toEqual([firstModel]); + } +}); + +for (const detailLevel of ["root", "response"] as const) { + for (const errorLevel of ["root", "response"] as const) { + test(`HTTP mixed ${detailLevel} detail / ${errorLevel} error stops before model-code hop`, async () => { + const root: Record = { padding: "x".repeat(800) }; + const nested: Record = {}; + (detailLevel === "root" ? root : nested).detail = null; + (errorLevel === "root" ? root : nested).error = { message: refusal, code: "unsupported_model" }; + root.response = nested; + const { response, sends } = await run(root); + expect(response.status).toBe(400); + await response.text(); + expect(sends).toEqual([firstModel]); + }); + } +} From 7796f5d841eb198ebbbe9db580e76d1978d3907e Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 4 Oct 2026 04:40:03 +0900 Subject: [PATCH 2/3] fix(combo): preserve hard stops across bounded error carriers --- src/combos/failover.ts | 59 ++++++++++++++----- .../responses/combo-stream-preflight.ts | 2 +- src/server/responses/core-combo-failure.ts | 4 +- structure/providers-and-adapters.md | 2 +- ...uter-combo-failover-classification.test.ts | 3 + tests/routing/routing-policy-fallback.test.ts | 56 ++++++++++++++++++ .../server-combo-plan-model-refusal.test.ts | 49 ++++++++++++++- 7 files changed, 156 insertions(+), 19 deletions(-) diff --git a/src/combos/failover.ts b/src/combos/failover.ts index 0be746748b1..6bdb9533c63 100644 --- a/src/combos/failover.ts +++ b/src/combos/failover.ts @@ -695,23 +695,52 @@ export function codexAccountModelRefusalPayload(status: number, payload: unknown return field !== undefined && CODEX_ACCOUNT_MODEL_REFUSAL.test(field) ? "refusal" : "other"; } -/** A bounded fallback signal, never credential or account-entitlement evidence. */ -export function codexAccountModelRefusal(status: number, message: string): CodexAccountModelRefusal { - if (status !== 400 || message.length > 16_384) return "other"; +/** One complete, bounded HTTP envelope; accept only a single exact status prefix. */ +function parseCodexRefusalEnvelope(status: number, message: string): { text: string; payload: unknown } | undefined { + if (status !== 400 || message.length > 16_384) return undefined; let text = message.trim(); if (text.startsWith("Provider error 400: ")) text = text.slice("Provider error 400: ".length); - if (CODEX_ACCOUNT_MODEL_REFUSAL.test(text)) return "refusal"; - try { - const payload: unknown = JSON.parse(text); - if (hasConflictingCodexModelRefusalEnvelopes(status, payload)) return "ambiguous"; - // A present root carrier stays authoritative, even when malformed or nonmatching. - if (payload && typeof payload === "object" && !Array.isArray(payload) - && !Object.hasOwn(payload, "detail") && !Object.hasOwn(payload, "error") - && Object.hasOwn(payload, "response")) { - return codexAccountModelRefusalPayload(status, (payload as Record).response); + let payload: unknown; + try { payload = JSON.parse(text); } catch { /* Bare refusal text is also a supported carrier. */ } + return { text, payload }; +} + +/** Only allowlisted hard stops cross the same HTTP boundary as positive refusal evidence. */ +export function codexAccountModelRefusalHardStopCode(status: number, message: string): string | undefined { + const payload = parseCodexRefusalEnvelope(status, message)?.payload; + if (!payload || typeof payload !== "object" || Array.isArray(payload)) return undefined; + const root = payload as Record; + const response = Object.hasOwn(root, "response") ? root.response : undefined; + const records = response && typeof response === "object" && !Array.isArray(response) + ? [root, response as Record] : [root]; + for (const record of records) { + const error = record.error && typeof record.error === "object" && !Array.isArray(record.error) + ? record.error as Record : undefined; + for (const code of [error?.code, error?.type, record.code]) { + if (typeof code !== "string") continue; + if (isNonReplayableUpstreamCode(code) || isCyberPolicyCode(code)) return code; + if (normalizedFailureCode(code) === "origin_rejected") return "origin_rejected"; } - return codexAccountModelRefusalPayload(status, payload); - } catch { return "other"; } + } + return undefined; +} + +/** A bounded fallback signal, never credential or account-entitlement evidence. */ +export function codexAccountModelRefusal( + status: number, message: string, options?: { allowNestedResponse?: boolean }, +): CodexAccountModelRefusal { + const envelope = parseCodexRefusalEnvelope(status, message); + if (!envelope) return "other"; + if (CODEX_ACCOUNT_MODEL_REFUSAL.test(envelope.text)) return "refusal"; + const payload = envelope.payload; + if (hasConflictingCodexModelRefusalEnvelopes(status, payload)) return "ambiguous"; + // A present root carrier stays authoritative, even when malformed or nonmatching. + if (options?.allowNestedResponse !== false && payload && typeof payload === "object" && !Array.isArray(payload) + && !Object.hasOwn(payload, "detail") && !Object.hasOwn(payload, "error") + && Object.hasOwn(payload, "response")) { + return codexAccountModelRefusalPayload(status, (payload as Record).response); + } + return codexAccountModelRefusalPayload(status, payload); } export function comboFailureDecision( @@ -732,6 +761,8 @@ export function comboFailureDecision( // Cyber policy is a hard non-retryable refusal — honor structured code even when // classificationText was truncated before the JSON code field. if (isCyberPolicyCode(options?.code)) return "stop"; + // HTTP consumers retain hard codes before truncation; SSE metadata owns its selected carrier. + if (options?.codexModelRefusal === undefined && codexAccountModelRefusalHardStopCode(status, message)) return "stop"; const modelRefusal = status === 400 ? options?.codexModelRefusal ?? codexAccountModelRefusal(status, message) : "other"; // Competing envelopes cannot grant a hop through an earlier structured-code rule either. diff --git a/src/server/responses/combo-stream-preflight.ts b/src/server/responses/combo-stream-preflight.ts index 1758e4e2044..390097b0762 100644 --- a/src/server/responses/combo-stream-preflight.ts +++ b/src/server/responses/combo-stream-preflight.ts @@ -84,7 +84,7 @@ function terminalModelRefusal(status: number, event: Record): C } } if (event.type === "error" && !Object.hasOwn(event, "error") && typeof event.message === "string") { - return codexAccountModelRefusal(status, event.message); + return codexAccountModelRefusal(status, event.message, { allowNestedResponse: false }); } return outer; } diff --git a/src/server/responses/core-combo-failure.ts b/src/server/responses/core-combo-failure.ts index 40b9a2cb367..dd338d69488 100644 --- a/src/server/responses/core-combo-failure.ts +++ b/src/server/responses/core-combo-failure.ts @@ -1,4 +1,4 @@ -import { codexAccountModelRefusal, type CodexAccountModelRefusal } from "../../combos/failover"; +import { codexAccountModelRefusal, codexAccountModelRefusalHardStopCode, type CodexAccountModelRefusal } from "../../combos/failover"; import { parseRetryAfterMs } from "../../combos"; import type { ConsumedComboFailure, HandleResponsesOptions } from "./core-options"; import type { OcxUsage } from "../../types"; @@ -66,7 +66,7 @@ export async function consumeComboFailure( const normalized = normalizeUpstreamErrorText(body.text, fallback); if (utf8Trusted || isCyberPolicyCode(normalized.code) || isCyberPolicyMessage(normalized.safeText)) { classificationText = normalized.safeText; - upstreamCode = normalized.code; + upstreamCode = codexAccountModelRefusalHardStopCode(response.status, body.text) ?? normalized.code; upstreamMessage = normalized.message; upstreamType = normalized.type; } diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index a4bd1903b7d..a3c6e394531 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -570,5 +570,5 @@ Known Claude Sonnet and Opus 5.5 base and low/medium/high IDs share a determinis `src/server/responses/core-options.ts` carries the value to the combo decision. An inspected non-match remains authoritative. No raw body is retained in that field, serialized into client errors, or logged. Presence of both `detail` and `error` across the original root and its own non-array `response` record is ambiguous, regardless of values; null counts and deeper quoted objects do not. This is checked before selecting an error carrier. -For complete bounded HTTP errors, the text wrapper selects root `detail`/`error` when present; otherwise it selects the own `response` record for the same exact `detail` or `error.message` match. It never searches another carrier after a nonmatch or descends further. Policy-fallback and direct callers share this wrapper; SSE carrier selection is unchanged. +For complete bounded HTTP errors, the text wrapper selects root `detail`/`error` when present; otherwise it selects the own `response` record for the same exact `detail` or `error.message` match. It never searches another carrier after a nonmatch or descends further. Policy-fallback and direct callers share this wrapper; SSE message parsing disables nested HTTP selection. The same bounded HTTP parser retains allowlisted hard codes from root and response before display truncation. Policy/direct classification inspects full text only without authoritative carrier metadata; SSE never borrows hard codes from quoted message JSON. Committed output, cancellation, hard policy refusals and resend limits remain stronger than model fallback. diff --git a/tests/routing/router-combo-failover-classification.test.ts b/tests/routing/router-combo-failover-classification.test.ts index 0ea79943cac..06dc9976d26 100644 --- a/tests/routing/router-combo-failover-classification.test.ts +++ b/tests/routing/router-combo-failover-classification.test.ts @@ -187,6 +187,9 @@ describe("combo failure hop/stop verdicts", () => { expect(comboFailureDecision(400, body.slice(0, -1))).toBe("stop"); expect(comboFailureDecision(400, JSON.stringify({ response, padding: "x".repeat(16_384) }))).toBe("stop"); } + expect(comboFailureDecision(400, JSON.stringify({ response: { error: { + code: "upstream_no_response", message: refusal, + } } }), { code: "unsupported_model", codexModelRefusal: "other" })).toBe("hop"); for (const body of [ { detail: null, response: { detail: refusal } }, { detail: "bad input", response: { detail: refusal } }, diff --git a/tests/routing/routing-policy-fallback.test.ts b/tests/routing/routing-policy-fallback.test.ts index 2e12daa62b6..0efc0522c28 100644 --- a/tests/routing/routing-policy-fallback.test.ts +++ b/tests/routing/routing-policy-fallback.test.ts @@ -61,6 +61,62 @@ function seedAttempt(logCtx: RequestLogContext, provider: string, model: string) } describe("policy candidate fallback", () => { + test.each([undefined, "upstream_no_response", "origin_rejected", "cyber_policy"])( + "nested plan refusal preserves policy hard-stop code %s", async code => { + let calls = 0; + const refusal = "The 'gpt-6-astra' model is not supported when using Codex with a ChatGPT account."; + const response = await handleResponsesWithPolicyFallback(request(), fixtureConfig(), {} as RequestLogContext, {}, { + runCore: async (req, _config, context, options) => { + calls++; + options.onRequestBodyParsed?.(await req.json()); + context.routeDecision = policyTrace(); + return calls === 1 ? Response.json({ response: { error: { code, message: refusal } } }, { status: 400 }) + : Response.json({ status: "completed" }); + }, + }); + expect(calls).toBe(code ? 1 : 2); + expect(response.status).toBe(code ? 400 : 200); + }, + ); + + for (const hardCode of ["upstream_no_response", "origin_rejected", "origin-rejected", " ORIGIN-REJECTED ", "cyber_policy"]) { + test.each(["root", "nested"])(`policy preserves ${hardCode} at %s beside a competing code`, async location => { + let calls = 0; + const refusal = "The 'gpt-6-astra' model is not supported when using Codex with a ChatGPT account."; + const response = await handleResponsesWithPolicyFallback(request(), fixtureConfig(), {} as RequestLogContext, {}, { + runCore: async (req, _config, context, options) => { + calls++; + options.onRequestBodyParsed?.(await req.json()); + context.routeDecision = policyTrace(); + return calls === 1 ? Response.json({ + code: location === "root" ? hardCode : "invalid_request_error", + response: { error: { code: location === "root" ? "unsupported_model" : hardCode, message: refusal } }, + }, { status: 400 }) : Response.json({ status: "completed" }); + }, + }); + expect(calls).toBe(1); + expect(response.status).toBe(400); + }); + } + + test.each([undefined, "upstream_no_response", "origin_rejected", "cyber_policy"])( + "prefixed nested HTTP refusal retains policy code %s", async code => { + let calls = 0; + const message = "The 'gpt-6-astra' model is not supported when using Codex with a ChatGPT account."; + const response = await handleResponsesWithPolicyFallback(request(), fixtureConfig(), {} as RequestLogContext, {}, { + runCore: async (req, _config, context, options) => { + calls++; + options.onRequestBodyParsed?.(await req.json()); + context.routeDecision = policyTrace(); + return calls === 1 ? new Response(`Provider error 400: ${JSON.stringify({ response: { error: { code, message } } })}`, + { status: 400 }) : Response.json({ status: "completed" }); + }, + }); + expect(calls).toBe(code ? 1 : 2); + expect(response.status).toBe(code ? 400 : 200); + }, + ); + test("a marked context overflow never tries another policy route", async () => { const failure = Response.json({ error: { type: "invalid_request_error", code: "context_length_exceeded", message: "Context window exceeded", diff --git a/tests/server/server-combo-plan-model-refusal.test.ts b/tests/server/server-combo-plan-model-refusal.test.ts index 133f927f2df..8c645a590da 100644 --- a/tests/server/server-combo-plan-model-refusal.test.ts +++ b/tests/server/server-combo-plan-model-refusal.test.ts @@ -85,7 +85,8 @@ async function run(body: unknown, streamEvent?: Record, committ sends.push(payload.model); expect(new Headers(init?.headers).get("authorization")).toBe(streamEvent ? "Bearer fixture-key" : `Bearer ${caller}`); if (payload.model === firstModel) { - if (!streamEvent) return Response.json(body, { status: 400 }); + if (!streamEvent) return typeof body === "string" ? new Response(body, { status: 400 }) + : Response.json(body, { status: 400 }); const events = [ ...(committed ? [{ type: "response.output_text.delta", delta: "already visible" }] : []), streamEvent, @@ -248,3 +249,49 @@ for (const detailLevel of ["root", "response"] as const) { }); } } + +test.each(["detail", "error"] as const)("bare SSE message cannot borrow a nested HTTP %s carrier", async carrier => { + const nested = carrier === "detail" ? { detail: refusal } : { error: { message: refusal } }; + const { response, sends } = await run(undefined, { + type: "error", status: 400, code: "invalid_request_error", message: JSON.stringify({ response: nested }), + }); + expect(await response.text()).not.toContain("fallback succeeded"); + expect(sends).toEqual([firstModel]); +}); + +for (const prefix of ["", "Provider error 400: "]) { + test.each(["upstream_no_response", "origin-rejected", "cyber_policy"])( + `HTTP ${prefix || "bare JSON"} nested hard stop %s never replays`, async code => { + const { response, sends } = await run(prefix + JSON.stringify({ padding: "x".repeat(800), response: { error: { code, message: refusal } } })); + expect(response.status).toBe(400); + await response.text(); + expect(sends).toEqual([firstModel]); + }, + ); + test(`HTTP ${prefix || "bare JSON"} root hard stop cannot be hidden by nested model code`, async () => { + const { response, sends } = await run(prefix + JSON.stringify({ padding: "x".repeat(800), code: " ORIGIN-REJECTED ", + response: { error: { code: "unsupported_model", message: refusal } } })); + expect(response.status).toBe(400); + await response.text(); + expect(sends).toEqual([firstModel]); + }); +} + +test.each(["Provider error 400: ", "Provider error 401: ", "Provider error 400: Provider error 400: "])( + "HTTP refusal permits only a single matching prefix (%s)", async prefix => { + const { response, sends } = await run(prefix + JSON.stringify({ response: { error: { message: refusal } } })); + const valid = prefix === "Provider error 400: "; + expect(response.status).toBe(valid ? 200 : 400); + await response.text(); + expect(sends).toEqual(valid ? [firstModel, secondModel] : [firstModel]); + }, +); + +test("SSE structured model code does not borrow a hard stop from quoted HTTP JSON", async () => { + const { response, sends } = await run(undefined, { + type: "error", status: 400, error: { code: "unsupported_model", + message: JSON.stringify({ response: { error: { code: "upstream_no_response", message: refusal } } }) }, + }); + expect(await response.text()).toContain("fallback succeeded"); + expect(sends).toEqual([firstModel, secondModel]); +}); From fa040a03d0a666480815c5b77aa483af5e072164 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 4 Oct 2026 05:09:17 +0900 Subject: [PATCH 3/3] fix(combo): keep diagnostic types out of hard-stop code evidence --- src/combos/failover.ts | 3 +- structure/providers-and-adapters.md | 2 +- ...uter-combo-failover-classification.test.ts | 30 +++++++++++- tests/routing/routing-policy-fallback.test.ts | 22 +++++++++ .../server-combo-plan-model-refusal.test.ts | 49 +++++++++++++++++++ 5 files changed, 103 insertions(+), 3 deletions(-) diff --git a/src/combos/failover.ts b/src/combos/failover.ts index 6bdb9533c63..9f0a86fc1c3 100644 --- a/src/combos/failover.ts +++ b/src/combos/failover.ts @@ -716,7 +716,8 @@ export function codexAccountModelRefusalHardStopCode(status: number, message: st for (const record of records) { const error = record.error && typeof record.error === "object" && !Array.isArray(record.error) ? record.error as Record : undefined; - for (const code of [error?.code, error?.type, record.code]) { + // Diagnostic type is never code evidence, even when a code is absent or malformed. + for (const code of [error?.code, record.code]) { if (typeof code !== "string") continue; if (isNonReplayableUpstreamCode(code) || isCyberPolicyCode(code)) return code; if (normalizedFailureCode(code) === "origin_rejected") return "origin_rejected"; diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index a3c6e394531..4f5bd1259ce 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -570,5 +570,5 @@ Known Claude Sonnet and Opus 5.5 base and low/medium/high IDs share a determinis `src/server/responses/core-options.ts` carries the value to the combo decision. An inspected non-match remains authoritative. No raw body is retained in that field, serialized into client errors, or logged. Presence of both `detail` and `error` across the original root and its own non-array `response` record is ambiguous, regardless of values; null counts and deeper quoted objects do not. This is checked before selecting an error carrier. -For complete bounded HTTP errors, the text wrapper selects root `detail`/`error` when present; otherwise it selects the own `response` record for the same exact `detail` or `error.message` match. It never searches another carrier after a nonmatch or descends further. Policy-fallback and direct callers share this wrapper; SSE message parsing disables nested HTTP selection. The same bounded HTTP parser retains allowlisted hard codes from root and response before display truncation. Policy/direct classification inspects full text only without authoritative carrier metadata; SSE never borrows hard codes from quoted message JSON. +For complete bounded HTTP errors, the text wrapper selects root `detail`/`error` when present; otherwise it selects the own `response` record for the same exact `detail` or `error.message` match. It never searches another carrier after a nonmatch or descends further. Policy-fallback and direct callers share this wrapper; SSE message parsing disables nested HTTP selection. The same bounded HTTP parser retains allowlisted dedicated `code` fields from root and response before display truncation; diagnostic `type` is never promoted, including when code is absent or malformed. Policy/direct classification inspects full text only without authoritative carrier metadata; SSE never borrows hard codes from quoted message JSON. Committed output, cancellation, hard policy refusals and resend limits remain stronger than model fallback. diff --git a/tests/routing/router-combo-failover-classification.test.ts b/tests/routing/router-combo-failover-classification.test.ts index 06dc9976d26..dde82d304d5 100644 --- a/tests/routing/router-combo-failover-classification.test.ts +++ b/tests/routing/router-combo-failover-classification.test.ts @@ -8,7 +8,7 @@ import { pickComboTarget, targetKey, } from "../../src/combos"; -import { comboFailureCooldownScope, comboFailureDecision } from "../../src/combos/failover"; +import { codexAccountModelRefusalHardStopCode, comboFailureCooldownScope, comboFailureDecision } from "../../src/combos/failover"; import { adapterFailureFromMessage, inferHttpStatusFromAdapterMessage } from "../../src/lib/errors"; import type { OcxConfig } from "../../src/types"; @@ -600,3 +600,31 @@ describe("response_format capability refusal, second gateway", () => { } }); }); + +describe("HTTP hard-code evidence excludes diagnostic types", () => { + test("code/type matrix inspects dedicated code fields only at either level", () => { + const codes: Array<[unknown, string | undefined]> = [ + ["cyber_policy", "cyber_policy"], ["upstream_no_response", "upstream_no_response"], + ["origin-rejected", "origin_rejected"], ["unsupported_model", undefined], ["unknown_code", undefined], + ["", undefined], [" ", undefined], [undefined, undefined], [null, undefined], [17, undefined], [{}, undefined], + ]; + for (const nested of [false, true]) { + for (const [code, expected] of codes) { + for (const type of ["cyber_policy", "upstream_no_response", "upstream_reset_replay_refused", "origin_rejected", "invalid_request_error", null, undefined]) { + const record = { error: { code, type, message: "fixture failure" } }; + const body = JSON.stringify(nested ? { response: record } : record); + expect(codexAccountModelRefusalHardStopCode(400, body)).toBe(expected); + } + } + } + }); + + test("a genuine code in another record still wins over a nonhard code and diagnostic type", () => { + const diagnostic = { error: { code: "unsupported_model", type: "cyber_policy", message: "fixture failure" } }; + const hard = { code: "upstream_no_response" }; + for (const body of [{ ...diagnostic, response: hard }, { ...hard, response: diagnostic }]) { + expect(codexAccountModelRefusalHardStopCode(400, JSON.stringify(body))).toBe("upstream_no_response"); + expect(comboFailureDecision(400, JSON.stringify(body))).toBe("stop"); + } + }); +}); diff --git a/tests/routing/routing-policy-fallback.test.ts b/tests/routing/routing-policy-fallback.test.ts index 0efc0522c28..a7a1370da3f 100644 --- a/tests/routing/routing-policy-fallback.test.ts +++ b/tests/routing/routing-policy-fallback.test.ts @@ -61,6 +61,28 @@ function seedAttempt(logCtx: RequestLogContext, provider: string, model: string) } describe("policy candidate fallback", () => { + for (const nested of [false, true]) { + test.each(["cyber_policy", "upstream_no_response", "upstream_reset_replay_refused"])( + `policy preserves explicit model code over diagnostic %s (nested=${nested})`, async type => { + let calls = 0; + const response = await handleResponsesWithPolicyFallback(request(), fixtureConfig(), {} as RequestLogContext, {}, { + runCore: async (req, _config, context, options) => { + calls++; + options.onRequestBodyParsed?.(await req.json()); + context.routeDecision = policyTrace(); + if (calls !== 1) return Response.json({ status: "completed" }); + const record = { error: { code: "unsupported_model", type, + message: nested ? "The 'gpt-6-astra' model is not supported when using Codex with a ChatGPT account." + : "fixture failure" } }; + return Response.json(nested ? { response: record } : record, { status: 400 }); + }, + }); + expect(calls).toBe(2); + expect(response.status).toBe(200); + }, + ); + } + test.each([undefined, "upstream_no_response", "origin_rejected", "cyber_policy"])( "nested plan refusal preserves policy hard-stop code %s", async code => { let calls = 0; diff --git a/tests/server/server-combo-plan-model-refusal.test.ts b/tests/server/server-combo-plan-model-refusal.test.ts index 8c645a590da..16262ebb74f 100644 --- a/tests/server/server-combo-plan-model-refusal.test.ts +++ b/tests/server/server-combo-plan-model-refusal.test.ts @@ -2,6 +2,8 @@ import { afterEach, beforeEach, expect, test } from "bun:test"; import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { consumeComboFailure } from "../../src/server/responses/core-combo-failure"; +import { isNonReplayableResponse } from "../../src/lib/upstream-retry"; import { handleResponses } from "../../src/server/responses"; import { captureCallerDirectAuth } from "../../src/providers/caller-authorization"; import { clearComboSelectionState, clearComboTargetCooldowns } from "../../src/combos"; @@ -295,3 +297,50 @@ test("SSE structured model code does not borrow a hard stop from quoted HTTP JSO expect(await response.text()).toContain("fallback succeeded"); expect(sends).toEqual([firstModel, secondModel]); }); + +for (const nested of [false, true]) { + for (const type of ["cyber_policy", "upstream_no_response", "upstream_closed_before_response", "upstream_reset_replay_refused"]) { + test.each(["fixture failure", refusal])(`HTTP code overrides diagnostic ${type} (nested=${nested}, message=%s)`, async message => { + const record = { error: { code: "unsupported_model", type, message } }; + const { response, sends } = await run(nested ? { response: record } : record); + expect(response.status).toBe(200); + await response.text(); + expect(sends).toEqual([firstModel, secondModel]); + }); + } +} + +test("diagnostic type cannot manufacture consumed/serialized codes, retry headers or replay markers", async () => { + for (const nested of [false, true]) { + for (const code of ["unsupported_model", "unknown_code", "", " ", undefined, null, 17, {}]) { + for (const type of ["cyber_policy", "upstream_no_response", "upstream_reset_replay_refused", "origin_rejected"]) { + const record = { error: { code, type, message: "fixture failure" } }; + const consumed = await consumeComboFailure(Response.json(nested ? { response: record } : record, { status: 400 })); + expect(consumed.upstreamCode).toBe(typeof code === "string" && code.trim() ? code.trim() : undefined); + expect(consumed.upstreamType).toBe(type); + expect(consumed.nonReplayable).toBeUndefined(); + expect(isNonReplayableResponse(consumed.response)).toBe(false); + expect(consumed.response.status).toBe(400); + expect(consumed.response.headers.get("retry-after")).toBeNull(); + expect(consumed.response.headers.get("x-should-retry")).toBeNull(); + const wire = await consumed.response.json(); + expect(wire.error.code).toBe("invalid_request_error"); + expect(wire.error.type).toBe("invalid_request_error"); + } + } + } +}); + +test("genuine hard codes retain formatter identity and non-replayable behavior with benign diagnostic types", async () => { + for (const nested of [false, true]) { + for (const code of ["cyber_policy", "upstream_no_response", "upstream_reset_replay_refused"]) { + const record = { error: { code, type: "invalid_request_error", message: "fixture failure" } }; + const consumed = await consumeComboFailure(Response.json(nested ? { response: record } : record, { status: 400 })); + expect(consumed.upstreamCode).toBe(code); + expect((await consumed.response.json()).error.code).toBe(code); + expect(consumed.response.status).toBe(code === "upstream_reset_replay_refused" ? 429 : 400); + expect(isNonReplayableResponse(consumed.response)).toBe(code !== "cyber_policy"); + expect(consumed.response.headers.get("x-should-retry")).toBe(code === "upstream_reset_replay_refused" ? "false" : null); + } + } +});