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", () => {