diff --git a/src/combos/failover.ts b/src/combos/failover.ts index e60fe83deef..bd2bbaddda5 100644 --- a/src/combos/failover.ts +++ b/src/combos/failover.ts @@ -401,9 +401,12 @@ export function comboFailureCooldownScope( ): ComboFailureCooldownScope { const code = normalizedFailureCode(options?.code); // Request-shape refusals first: an oversized request must not cool a healthy target. + // Some native transports surface a definite zero-output model overflow as a generic + // upstream_server_error plus precise context-window prose, so honor the bounded classifier too. if ( status === 413 || REQUEST_SHAPE_FAILURE_CODES.has(code) + || isDefiniteContextOverflow(message) || isRequestLocalFreePromptCap(status, message, options?.code) || isProviderTargetContextOverflow(status, message, options?.code) || isRequestLocalTargetIncompatibility(status, message, options?.code) @@ -451,6 +454,44 @@ function isProviderTargetContextOverflow( && /\bprompt\s+\d+\s*>\s*\d+\s+maximum context length\b/i.test(message); } +/** + * Confirm context overflow from the provider MESSAGE, not merely from a JSON code token. + * Upstreams control both fields and can emit contradictory envelopes; a stray + * `context_length_exceeded` code beside an unrelated refusal must not authorize replay. + * Bounded wrapper unwrapping covers OpenCodex's own `Provider error N: {...}` envelope. + */ +function isDefiniteContextOverflow(message: string): boolean { + if (message.length > 16_384) return false; + let text = message.trim(); + for (let depth = 0; depth < 4; depth += 1) { + const providerPrefix = /^Provider error \d{3}:\s*/.exec(text); + if (providerPrefix) text = text.slice(providerPrefix[0].length).trim(); + let payload: unknown; + try { payload = JSON.parse(text); } catch { + const normalized = text.toLowerCase(); + return normalized === "context_length_exceeded" + || normalized.includes("exceeds the context window") + || normalized.includes("context window exceeded") + || normalized.includes("context length exceeded") + || normalized.includes("maximum context length") + || normalized.includes("maximum context window") + || normalized.includes("too many tokens"); + } + if (!payload || typeof payload !== "object" || Array.isArray(payload)) return false; + const record = payload as Record; + const response = record.response && typeof record.response === "object" && !Array.isArray(record.response) + ? record.response as Record + : undefined; + const candidates = [record.error, response?.error, response?.last_error, record.last_error, record]; + const source = candidates.find((candidate): candidate is Record => + !!candidate && typeof candidate === "object" && !Array.isArray(candidate) + && typeof (candidate as Record).message === "string"); + if (!source || typeof source.message !== "string") return false; + text = source.message.trim(); + } + return false; +} + export function comboFailureDecision( status: number, message: string, @@ -472,6 +513,10 @@ export function comboFailureDecision( if (isModelLifecycleGone(status, message, options?.code)) return "hop"; const error = classifyError(status, "upstream_error", message); if (isCyberPolicyCode(error.code)) return "stop"; + // A definite context-window refusal is target-local inside a heterogeneous combo: this + // model cannot fit the turn, but a later model may have a larger window. Non-replayable + // post-send transport ambiguity was already rejected above, before this classification. + if (isDefiniteContextOverflow(message)) return "hop"; // A provider can expose its own target hard cap with a non-semantic vendor code // (for example 5059 + invalid_request_prompt_too_long). That is evidence that this // target is too small, not that every later combo target is incapable of serving it. @@ -510,7 +555,7 @@ export function comboFailureDecision( // per-request cap, not provider-wide evidence), so keep its hop verdict explicit here. if (failureCode === "free_rate_limited") return "hop"; if (isRequestLocalTargetIncompatibility(status, message, options?.code)) return "hop"; - if (["origin_rejected", "context_length_exceeded", "invalid_request_error"].includes(error.code ?? "")) { + if (["origin_rejected", "invalid_request_error"].includes(error.code ?? "")) { return "stop"; } // 402 (payment required) and 425 (too early) are provider-state signals, not verdicts about diff --git a/tests/codex-integration/combos.test.ts b/tests/codex-integration/combos.test.ts index 0bc1230e0f9..5047aa2262f 100644 --- a/tests/codex-integration/combos.test.ts +++ b/tests/codex-integration/combos.test.ts @@ -855,7 +855,7 @@ describe("combo failure policy and advancement", () => { for (const status of [401, 403, 404, 408, 429, 500, 503]) { expect(comboFailureDecision(status, "provider failure")).toBe("hop"); } - expect(comboFailureDecision(400, "context_length_exceeded")).toBe("stop"); + expect(comboFailureDecision(400, "context_length_exceeded")).toBe("hop"); expect(comboFailureDecision(403, '{"code":"origin_rejected"}')).toBe("stop"); expect(comboFailureDecision(413, "request too large")).toBe("stop"); expect(comboFailureDecision(409, "conflict")).toBe("stop"); @@ -874,9 +874,9 @@ describe("combo failure policy and advancement", () => { // verdict by echoing the token, so that shape must NOT hop. expect(comboFailureDecision(413, 'refused', { code: 'input_admission_refused' })).toBe('hop'); expect(comboFailureDecision(400, 'upstream mentions input_admission_refused in prose')).toBe('stop'); - // An UPSTREAM context verdict still stops: retrying that elsewhere is guesswork, and a - // generic 413 with no structured code keeps its existing conservative handling. - expect(comboFailureDecision(400, "context_length_exceeded")).toBe("stop"); + // An UPSTREAM context verdict is target-local in a heterogeneous combo: another model may + // have a larger window. A generic 413 with no structured context signal stays conservative. + expect(comboFailureDecision(400, "context_length_exceeded")).toBe("hop"); const providerHardCap = JSON.stringify({ error: { message: "Prompt 346030 > 262144 maximum context length", type: "invalid_request_prompt_too_long", diff --git a/tests/helpers/combo-context-overflow-cases.ts b/tests/helpers/combo-context-overflow-cases.ts new file mode 100644 index 00000000000..f7df60bba26 --- /dev/null +++ b/tests/helpers/combo-context-overflow-cases.ts @@ -0,0 +1,76 @@ +import { expect, test } from "bun:test"; +import type { OcxConfig, OcxProviderConfig } from "../../src/types"; + +interface ComboHarness { + serve(handler: () => Response | Promise): Server; + baseUrl(server: Server): string; + chatSuccess(text: string, model?: string): Response; + provider(adapter: string, url: string, apiKey: string, extra?: Partial): OcxProviderConfig; + comboConfig(providers: OcxConfig["providers"]): OcxConfig; + post(config: OcxConfig, raw?: Record): Promise; + chatStream(text: string): Response; + collectSse(response: Response): Promise; +} + +/** Register under the caller's isolated homes, mock state and server cleanup hooks. */ +export function registerComboContextOverflowCases({ + serve, baseUrl, chatSuccess, provider, comboConfig, post, chatStream, collectSse, +}: ComboHarness): void { + test("context overflow advances while exhausted retryable targets return the sanitized last status", async () => { + let stopBackupHits = 0; + const context = serve(() => Response.json({ error: { code: "context_length_exceeded", message: "too many tokens" } }, { status: 400 })); + const unused = serve(() => { + stopBackupHits += 1; + return chatSuccess("larger context fallback"); + }); + const stopConfig = comboConfig({ + a: provider("openai-chat", baseUrl(context), "key-a"), + b: provider("openai-chat", baseUrl(unused), "key-b"), + }); + const stopped = await post(stopConfig); + expect(stopped.status).toBe(200); + expect(stopBackupHits).toBe(1); + expect(await stopped.text()).toContain("larger context fallback"); + + const order: string[] = []; + const first = serve(() => { + order.push("a"); + return new Response("secret sk-a-should-redact", { status: 503 }); + }); + const last = serve(() => { + order.push("b"); + return Response.json({ error: { message: "missing model" } }, { status: 404 }); + }); + const exhausted = await post(comboConfig({ + a: provider("openai-chat", baseUrl(first), "key-a"), + b: provider("openai-chat", baseUrl(last), "key-b"), + })); + expect(exhausted.status).toBe(404); + expect(order).toEqual(["a", "b"]); + expect(await exhausted.text()).not.toContain("sk-a-should-redact"); + }); + + test("zero-output context overflow 502 hops to a healthy combo target", async () => { + let backupHits = 0; + const capped = serve(() => new Response([ + "event: response.created", + 'data: {"type":"response.created","response":{"id":"resp_context","status":"in_progress"}}', + "", + "event: response.failed", + 'data: {"type":"response.failed","response":{"id":"resp_context","status":"failed","error":{"type":"server_error","code":"upstream_server_error","message":"Your input exceeds the context window of this model. Please adjust your input and try again."}}}', + "", + "", + ].join("\n"), { headers: { "content-type": "text/event-stream" } })); + const backup = serve(() => { + backupHits += 1; + return chatStream("larger context backup"); + }); + const response = await post(comboConfig({ + a: provider("openai-responses", baseUrl(capped), "key-a"), + b: provider("openai-chat", baseUrl(backup), "key-b"), + }), { stream: true }); + expect(response.status).toBe(200); + expect(backupHits).toBe(1); + expect(JSON.stringify(await collectSse(response))).toContain("larger context backup"); + }); +} diff --git a/tests/routing/router-combo-failover-classification.test.ts b/tests/routing/router-combo-failover-classification.test.ts index cb5d765459c..ee1ad345a5a 100644 --- a/tests/routing/router-combo-failover-classification.test.ts +++ b/tests/routing/router-combo-failover-classification.test.ts @@ -62,6 +62,9 @@ describe("combo failure cooldown scope", () => { } // Hyphenated spellings normalize to the same codes. expect(comboFailureCooldownScope(400, "refused", { code: "input-admission-refused" })).toBe("none"); + expect(comboFailureCooldownScope(502, + "Your input exceeds the context window of this model. Please adjust your input and try again.", + { code: "upstream_server_error" })).toBe("none"); // A provider's own per-target hard cap (vendor code 5059) is equally request-shaped. expect(comboFailureCooldownScope( 400, @@ -248,14 +251,21 @@ describe("request-local optional control incompatibility", () => { }); test("hard refusal and non-replayable codes take precedence over a compatible message", () => { const message = JSON.stringify({ error: unsupportedUser }); - for (const code of ["origin_rejected", "context_length_exceeded", "upstream_no_response", "upstream_closed_before_response"]) { + for (const code of ["origin_rejected", "upstream_no_response", "upstream_closed_before_response"]) { expect(comboFailureDecision(400, message, { code })).toBe("stop"); } + expect(comboFailureDecision(400, "context length exceeded", { code: "context_length_exceeded" })).toBe("hop"); expect(comboFailureDecision(499, message)).toBe("stop"); expect(comboFailureDecision(413, message)).toBe("stop"); }); }); +test("zero-output upstream context overflow is target-local and may hop", () => { + const message = "Your input exceeds the context window of this model. Please adjust your input and try again."; + const body = JSON.stringify({ error: { type: "server_error", code: "upstream_server_error", message } }); + expect(comboFailureDecision(502, body, { code: "upstream_server_error" })).toBe("hop"); +}); + describe("bounded optional-control error envelopes", () => { const wrapped = (message: string, code = "invalid_request_error") => JSON.stringify({ error: { type: "invalid_request_error", code, message: `Provider error 400: ${message}` }, diff --git a/tests/routing/routing-policy-fallback.test.ts b/tests/routing/routing-policy-fallback.test.ts index 1883c1277d8..069ebec3c99 100644 --- a/tests/routing/routing-policy-fallback.test.ts +++ b/tests/routing/routing-policy-fallback.test.ts @@ -142,9 +142,7 @@ describe("policy candidate fallback", () => { expect(response.status).toBe(400); expect(seenModels).toEqual(["policy/daily"]); }); - test("an upstream context_length_exceeded still stops the chain (#1524)", async () => { - // The mirror-image contract. An upstream verdict is about the REQUEST, so retrying it - // elsewhere is guesswork -- and hopping would burn every candidate on a doomed request. + test("an upstream context_length_exceeded advances to the next policy candidate", async () => { const trace = policyTrace(); const logCtx = { requestedModel: "policy/daily", routeDecision: trace, attempts: [] } as unknown as RequestLogContext; const seenModels: string[] = []; @@ -153,16 +151,17 @@ describe("policy candidate fallback", () => { seenModels.push(String(body.model)); ctx.routeDecision = trace; seedAttempt(ctx, "provider", String(body.model)); - return Response.json( + if (seenModels.length === 1) return Response.json( { error: { message: "context length exceeded", type: "invalid_request_error", code: "context_length_exceeded" } }, { status: 400 }, ); + return Response.json({ id: "resp", object: "response", status: "completed", output: [] }); }; const response = await handleResponsesWithPolicyFallback(request(), {} as OcxConfig, logCtx, {}, { runCore }); - expect(response.status).toBe(400); - expect(seenModels).toEqual(["policy/daily"]); + expect(response.status).toBe(200); + expect(seenModels).toEqual(["policy/daily", "provider-b/model-b"]); }); test("retries the next policy candidate and keeps distinct physical attempts", async () => { const trace = policyTrace(); diff --git a/tests/server/server-combo-failover-e2e.test.ts b/tests/server/server-combo-failover-e2e.test.ts index 041ed847c04..50ce932f163 100644 --- a/tests/server/server-combo-failover-e2e.test.ts +++ b/tests/server/server-combo-failover-e2e.test.ts @@ -1,3 +1,4 @@ +import { registerComboContextOverflowCases } from "../helpers/combo-context-overflow-cases"; import { sessionLaneIdFromRequest } from "../../src/server/request-log-conversation"; import { afterEach, beforeEach, describe, expect, mock, setDefaultTimeout, test } from "bun:test"; import { logsFromApiBody } from "../helpers/logs-api"; @@ -2085,38 +2086,7 @@ describe("server combo failover 030 activation matrix", () => { expect(primaryHits.every(hit => hit.webTool === valid)).toBe(true); }); - test("context 400 stops while exhausted retryable targets return the sanitized last status", async () => { - let stopBackupHits = 0; - const context = serve(() => Response.json({ error: { code: "context_length_exceeded", message: "too many tokens" } }, { status: 400 })); - const unused = serve(() => { - stopBackupHits += 1; - return chatSuccess("must not run"); - }); - const stopConfig = comboConfig({ - a: provider("openai-chat", baseUrl(context), "key-a"), - b: provider("openai-chat", baseUrl(unused), "key-b"), - }); - const stopped = await post(stopConfig); - expect(stopped.status).toBe(400); - expect(stopBackupHits).toBe(0); - - const order: string[] = []; - const first = serve(() => { - order.push("a"); - return new Response("secret sk-a-should-redact", { status: 503 }); - }); - const last = serve(() => { - order.push("b"); - return Response.json({ error: { message: "missing model" } }, { status: 404 }); - }); - const exhausted = await post(comboConfig({ - a: provider("openai-chat", baseUrl(first), "key-a"), - b: provider("openai-chat", baseUrl(last), "key-b"), - })); - expect(exhausted.status).toBe(404); - expect(order).toEqual(["a", "b"]); - expect(await exhausted.text()).not.toContain("sk-a-should-redact"); - }); + registerComboContextOverflowCases({ serve, baseUrl, chatSuccess, provider, comboConfig, post, chatStream, collectSse }); test("provider-specific prompt-too-long 400 hops to a larger-context combo target", async () => { let backupHits = 0;