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
56 changes: 47 additions & 9 deletions src/combos/failover.ts
Original file line number Diff line number Diff line change
Expand Up @@ -695,17 +695,53 @@ 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);
return hasConflictingCodexModelRefusalEnvelopes(status, payload)
? "ambiguous" : codexAccountModelRefusalPayload(status, payload);
} catch { return "other"; }
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<string, unknown>;
const response = Object.hasOwn(root, "response") ? root.response : undefined;
const records = response && typeof response === "object" && !Array.isArray(response)
? [root, response as Record<string, unknown>] : [root];
for (const record of records) {
const error = record.error && typeof record.error === "object" && !Array.isArray(record.error)
? record.error as Record<string, unknown> : undefined;
// 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";
}
}
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<string, unknown>).response);
}
return codexAccountModelRefusalPayload(status, payload);
}

export function comboFailureDecision(
Expand All @@ -726,6 +762,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";
Comment thread
lidge-jun marked this conversation as resolved.
const modelRefusal = status === 400
? options?.codexModelRefusal ?? codexAccountModelRefusal(status, message) : "other";
// Competing envelopes cannot grant a hop through an earlier structured-code rule either.
Expand Down
2 changes: 1 addition & 1 deletion src/server/responses/combo-stream-preflight.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ function terminalModelRefusal(status: number, event: Record<string, unknown>): 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;
}
Expand Down
4 changes: 2 additions & 2 deletions src/server/responses/core-combo-failure.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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;
}
Expand Down
1 change: 1 addition & 0 deletions structure/providers-and-adapters.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 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.
58 changes: 57 additions & 1 deletion tests/routing/router-combo-failover-classification.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -172,6 +172,34 @@ 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");
}
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 } },
{ 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");
Expand Down Expand Up @@ -572,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");
}
});
});
78 changes: 78 additions & 0 deletions tests/routing/routing-policy-fallback.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,84 @@ 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;
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",
Expand Down
Loading
Loading