From d18781cca9b9792d24da469c33211361063bf21a Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Mon, 17 Aug 2026 01:04:11 +0900 Subject: [PATCH] fix(routing): let fallback skip a candidate the request cannot fit An oversized request was never silently sent -- `checkInputAdmission` already refuses it before any upstream I/O. The defect is what happens next: that local 413 was classified `request_too_large`, which `comboFailureDecision` treats as `stop`, so the FIRST candidate whose context window was too small ended the fallback chain. A larger-window candidate sitting behind it was never tried. The local preflight refusal now carries its own code, `input_admission_refused`, and that code is hop-eligible. The distinction is the whole point: - `input_admission_refused` is OUR verdict about THIS candidate, so another candidate may well fit. - upstream `context_length_exceeded`, and a generic 413 with no structured code, still stop -- retrying those elsewhere is guesswork. The code is read from the structured field, the classified error and the body text, because the generic classifier maps 413 to its own code and would otherwise swallow the signal on the text path. Driven red with the branch disabled. Nothing here truncates history or drops images to force a fit; an incompatible candidate is skipped, not squeezed. Refs #1524. --- src/combos/failover.ts | 10 ++++++++++ src/server/responses/core.ts | 7 ++++++- tests/combos.test.ts | 8 ++++++++ 3 files changed, 24 insertions(+), 1 deletion(-) diff --git a/src/combos/failover.ts b/src/combos/failover.ts index 95a5bbc839..3944eb2c6f 100644 --- a/src/combos/failover.ts +++ b/src/combos/failover.ts @@ -124,6 +124,16 @@ export function comboFailureDecision( if (["origin_rejected", "context_length_exceeded", "invalid_request_error"].includes(error.code ?? "")) { return "stop"; } + // A local input-admission refusal (#1524) says "this candidate cannot fit the request", + // not "the request is impossible". Hopping is exactly right: the next candidate may have a + // larger context window. Read it from the structured code OR the body text, because the + // generic classifier maps 413 to its own code and would otherwise swallow this signal. + // Upstream `context_length_exceeded` above still stops. + if (options?.code === "input_admission_refused" + || error.code === "input_admission_refused" + || message.includes("input_admission_refused")) { + return "hop"; + } if ([401, 403, 404, 408, 429].includes(status) || status >= 500) return "hop"; if ([ "permission_denied", diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index ca6a0e29da..45e74780b5 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -1969,9 +1969,14 @@ async function handleResponsesInner( if (parsed._compactionRequest !== true) { const inputAdmission = checkInputAdmission(parsed, route.provider, route.providerName, parsed.modelId); if (!inputAdmission.admitted) { + // #1524: this is a LOCAL preflight refusal, not an upstream verdict. A policy or combo + // fallback must be able to skip this candidate and try one whose context window fits, + // instead of treating the first incompatible candidate as the end of the chain. The + // distinct code is what lets the fallback layer tell the two apart -- an upstream + // `context_length_exceeded` still stops, because retrying it elsewhere is guesswork. return formatErrorResponse( 413, - "request_too_large", + "input_admission_refused", `Estimated input (~${inputAdmission.estimatedTokens} tokens) is far past the context window ` + `of ${parsed.modelId} (${inputAdmission.ceiling} tokens). Start a new session or choose a ` + `model with a larger context window.`, diff --git a/tests/combos.test.ts b/tests/combos.test.ts index b20f65efc7..899118e961 100644 --- a/tests/combos.test.ts +++ b/tests/combos.test.ts @@ -357,6 +357,14 @@ describe("combo failure policy and advancement", () => { expect(comboFailureDecision(409, "conflict")).toBe("stop"); expect(comboFailureDecision(499, "client cancelled")).toBe("stop"); expect(comboFailureDecision(422, "invalid_api_key")).toBe("hop"); + // #1524: a LOCAL input-admission refusal means "this candidate cannot fit the request", + // not "the request is impossible". The next candidate may have a larger context window, + // so the chain must continue instead of ending at the first incompatible target. + expect(comboFailureDecision(413, '{"code":"input_admission_refused"}')).toBe("hop"); + // 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"); + expect(comboFailureDecision(413, "request too large")).toBe("stop"); }); test("failure clears the active sticky target without adding a success", () => {