diff --git a/src/combos/failover.ts b/src/combos/failover.ts index ae0c044be2..4cb9cdc55a 100644 --- a/src/combos/failover.ts +++ b/src/combos/failover.ts @@ -307,6 +307,19 @@ function isModelLifecycleGone( ); } +function isProviderTargetContextOverflow( + status: number, + message: string, + code?: string | null, +): boolean { + if (status !== 400) return false; + const normalizedCode = normalizedFailureCode(code); + const text = message.toLowerCase(); + if (text.includes("invalid_request_prompt_too_long")) return true; + return normalizedCode === "5059" + && /\bprompt\s+\d+\s*>\s*\d+\s+maximum context length\b/i.test(message); +} + export function comboFailureDecision( status: number, message: string, @@ -324,6 +337,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 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. + if (isProviderTargetContextOverflow(status, message, options?.code)) return "hop"; // A local input-admission refusal (#1524) says "this candidate cannot fit the request", // not "the request is impossible": the next candidate may have a larger context window. // diff --git a/tests/combos.test.ts b/tests/combos.test.ts index 511bdae1a2..51ae4fa1a9 100644 --- a/tests/combos.test.ts +++ b/tests/combos.test.ts @@ -500,6 +500,14 @@ describe("combo failure policy and advancement", () => { // 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"); + const providerHardCap = JSON.stringify({ error: { + message: "Prompt 346030 > 262144 maximum context length", + type: "invalid_request_prompt_too_long", + code: "5059", + raw_status_code: 400, + }}); + expect(comboFailureDecision(400, providerHardCap, { code: "5059" })).toBe("hop"); + expect(comboFailureDecision(400, "ordinary invalid request", { code: "5059" })).toBe("stop"); expect(comboFailureDecision(413, "request too large")).toBe("stop"); }); diff --git a/tests/server-combo-failover-e2e.test.ts b/tests/server-combo-failover-e2e.test.ts index 39053f39fb..1c3f69b778 100644 --- a/tests/server-combo-failover-e2e.test.ts +++ b/tests/server-combo-failover-e2e.test.ts @@ -1547,6 +1547,27 @@ describe("server combo failover 030 activation matrix", () => { expect(await exhausted.text()).not.toContain("sk-a-should-redact"); }); + test("provider-specific prompt-too-long 400 hops to a larger-context combo target", async () => { + let backupHits = 0; + const capped = serve(() => Response.json({ error: { + message: "Prompt 346030 > 262144 maximum context length", + type: "invalid_request_prompt_too_long", + code: "5059", + raw_status_code: 400, + } }, { status: 400 })); + const backup = serve(() => { + backupHits += 1; + return chatSuccess("larger context backup", "m2"); + }); + const response = await post(comboConfig({ + a: provider("openai-chat", baseUrl(capped), "key-a"), + b: provider("openai-chat", baseUrl(backup), "key-b"), + })); + expect(response.status).toBe(200); + expect(backupHits).toBe(1); + expect(await response.text()).toContain("larger context backup"); + }); + test("429 Retry-After 120 keeps A cooling at 60 seconds and restores it at 120", async () => { const t0 = Date.parse("2026-07-18T00:00:00.000Z"); let now = t0;