fix(combo): advance on model-scoped 400s wrapped as invalid/Bad Request (#8252)

#2101 still hard-stopped model-not-supported failures when upstream
wrapped them as invalid_request_error / Bad Request. Keep models in the
combo and try the next target (#8251, residual of #5249).

- export MODEL_ACCESS_DENIED_PATTERNS + broaden does-not-support shapes
- add isModelScoped400() and exempt it from the body-specific stop guard
- regression tests for wrapper forms; body-specific stop still intact

Co-authored-by: Ravi Tharuma <RaviTharuma@users.noreply.github.com>
This commit is contained in:
Ravi Tharuma
2026-07-23 14:50:41 +02:00
committed by GitHub
parent 610ff8527a
commit 1110f9ca5f
4 changed files with 182 additions and 5 deletions

View File

@@ -241,10 +241,18 @@ const MODEL_ACCESS_AMBIGUOUS_TYPES = new Set([
// Model access patterns — the account does not have access to the requested model
// but a different account (e.g. PRO vs free tier) may support it.
const MODEL_ACCESS_DENIED_PATTERNS = [
// Exported so combo.ts #2101 can exempt model-scoped 400s from the body-specific
// stop guard (#5249): "model not supported" must advance to the next combo target
// even when the message also contains wrapper words like "invalid" / "bad request".
export const MODEL_ACCESS_DENIED_PATTERNS = [
/\binvalid model\b/i,
/\bmodel.*not.*(?:available|found|supported|accessible)\b/i,
/\bmodel.*(?:does not exist|doesn't exist)\b/i,
// "does not support" / "unsupported model" — GitHub Copilot / OpenAI-compatible
// often phrase model rejection this way without the "is not supported" word order.
/\bmodel\b[\s\S]{0,80}?\b(?:does\s+not\s+support|doesn't\s+support|unsupported)\b/i,
/\b(?:does\s+not\s+support|doesn't\s+support|unsupported)\b[\s\S]{0,80}?\bmodel\b/i,
/\bunsupported\s+model\b/i,
/\baccess.*denied.*model\b/i,
/\bmodel.*access.*denied\b/i,
/\bplease select a different model\b/i,

View File

@@ -15,6 +15,7 @@ import {
getRuntimeProviderProfile,
hasPerModelQuota,
isModelLocked,
MODEL_ACCESS_DENIED_PATTERNS,
recordModelLockoutFailure,
recordProviderFailure,
selectLockoutCooldownMs,
@@ -697,6 +698,28 @@ export function isParamValidation400(errorText) {
/\bis illegal.*range\b/i.test(errorText)
);
}
/**
* #5249 / #2101: model-scoped 400s must NEVER stop the combo.
* Upstream often wraps "model X is not supported" in `invalid_request_error` /
* "Bad Request" envelopes. Those wrapper words match the body-specific stop
* substrings, so without this exemption the combo hard-stops on the first
* unavailable model instead of trying the next target. Keep the models in the
* combo — if one rejects, advance.
* @param {string} errorText
*/
export function isModelScoped400(errorText) {
const text = String(errorText || "");
if (!text) return false;
if (MODEL_ACCESS_DENIED_PATTERNS.some((p) => p.test(text))) return true;
// Extra model-rejection shapes that providers emit outside the shared list
// (Responses API, Copilot, gateway wrappers).
return (
/\bmodel\b[\s\S]{0,80}?\b(?:not\s+supported|unsupported|unknown|unavailable)\b/i.test(text) ||
/\b(?:not\s+supported|unsupported|unknown)\b[\s\S]{0,80}?\bmodel\b/i.test(text) ||
/\bunsupported_api_for_model\b/i.test(text) ||
/\bdoes\s+not\s+support\s+(?:the\s+)?responses\s+api\b/i.test(text)
);
}
/** @param {object} options */
export async function handleComboChat({
@@ -2302,16 +2325,19 @@ export async function handleComboChat({
// #2101: Prevent infinite fallback loops with 400 Bad Request errors that are genuinely
// body-specific (malformed JSON, bad format, missing required fields).
// Context overflow and parameter validation errors are NOT body-specific:
// These should NOT stop the combo:
// - Context overflow: different models have different context windows
// - Max_tokens / param errors: different models have different output limits
// - Model access denied: different providers serve different model sets
// These should fall through so the next combo target can try.
// - Model access denied / "not supported": different providers serve different
// model sets — keep the model in the combo and try the next target (#5249).
// Wrapper words like "invalid" / "bad request" still stop only when the text is
// NOT model-scoped (e.g. "invalid message format").
if (
result.status === 400 &&
fallbackResult.shouldFallback &&
!isContextOverflow400(errorText) &&
!isParamValidation400(errorText) &&
!isModelScoped400(errorText) &&
(errorText.toLowerCase().includes("context") ||
errorText.toLowerCase().includes("prompt") ||
errorText.toLowerCase().includes("token") ||

View File

@@ -0,0 +1,133 @@
/**
* Combo must ADVANCE on model-scoped 400s even when the error text also
* contains #2101 stop substrings ("invalid", "bad request").
*
* User intent: keep models in the combo whether or not every provider supports
* them. If github rejects `claude-fable-5` with "not supported", try the next
* target (claude / antigravity / …) instead of hard-stopping the combo.
*
* Regression shapes covered:
* - "requested model is not supported" (baseline #5249)
* - "invalid_request_error: model X is not supported" (wrapper + model)
* - "Bad Request: The model is not supported" (status text wrapper)
* - "model X does not support Responses API" (API capability rejection)
*
* Genuinely body-specific 400s ("invalid message format") must still stop —
* covered by combo-body-specific-400-stop-4279.test.ts.
*/
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-combo-model-400-"));
process.env.DATA_DIR = TEST_DATA_DIR;
process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "combo-model-400-test-secret";
const { handleComboChat, isModelScoped400 } = await import("../../open-sse/services/combo.ts");
const noop = () => {};
const log = { info: noop, warn: noop, debug: noop, error: noop };
function makeCombo(models: string[]) {
return {
name: "test-combo-model-400",
strategy: "priority",
models: models.map((m) => ({ model: m })),
};
}
function okResponse(modelStr: string) {
return Response.json({
id: "ok",
object: "chat.completion",
choices: [{ message: { role: "assistant", content: `ok from ${modelStr}` } }],
});
}
test("isModelScoped400 recognizes model-not-supported shapes (incl. invalid/Bad Request wrappers)", () => {
assert.equal(isModelScoped400("requested model is not supported"), true);
assert.equal(isModelScoped400("model claude-fable-5 is not supported"), true);
assert.equal(isModelScoped400("invalid_request_error: model is not supported"), true);
assert.equal(isModelScoped400("Bad Request: The model is not supported"), true);
assert.equal(isModelScoped400("model claude-fable-5 does not support Responses API."), true);
assert.equal(isModelScoped400("unsupported_api_for_model"), true);
assert.equal(isModelScoped400("The model `x` does not exist or you do not have access to it."), true);
// Genuinely body-specific — must NOT be treated as model-scoped
assert.equal(isModelScoped400("Invalid message format: the request body is malformed."), false);
assert.equal(isModelScoped400("malformed JSON in request body"), false);
assert.equal(isModelScoped400("Invalid field: foo is not a recognized field"), false);
});
async function assertAdvancesOn(errorMessage: string, label: string) {
const modelsCalled: string[] = [];
const response = await handleComboChat({
body: { model: "test", messages: [{ role: "user", content: "hi" }] },
combo: makeCombo(["github/claude-fable-5", "claude/claude-fable-5"]),
handleSingleModel: async (_body: unknown, modelStr: string) => {
modelsCalled.push(modelStr);
if (modelStr === "github/claude-fable-5") {
return Response.json({ error: { message: errorMessage, type: "invalid_request_error" } }, { status: 400 });
}
return okResponse(modelStr);
},
log,
settings: {},
allCombos: [],
});
assert.equal(
response.status,
200,
`${label}: combo should advance to second model and return 200 (got ${response.status})`
);
assert.deepEqual(
modelsCalled,
["github/claude-fable-5", "claude/claude-fable-5"],
`${label}: must try both targets; tried: ${modelsCalled.join(", ")}`
);
}
test("combo advances when first target returns plain 'model is not supported'", async () => {
await assertAdvancesOn("requested model is not supported", "plain");
});
test("combo advances when first target wraps model rejection in invalid_request_error", async () => {
await assertAdvancesOn(
"invalid_request_error: model claude-fable-5 is not supported",
"invalid_request_error wrapper"
);
});
test("combo advances when first target returns Bad Request + model not supported", async () => {
await assertAdvancesOn("Bad Request: The model is not supported", "Bad Request wrapper");
});
test("combo advances when first target rejects model for Responses API", async () => {
await assertAdvancesOn(
"model claude-fable-5 does not support Responses API.",
"Responses API capability"
);
});
test("combo still STOPS on genuinely body-specific invalid message format", async () => {
const modelsCalled: string[] = [];
const response = await handleComboChat({
body: { model: "test", messages: [{ role: "user", content: "hi" }] },
combo: makeCombo(["a/model-1", "b/model-2", "c/model-3"]),
handleSingleModel: async (_body: unknown, modelStr: string) => {
modelsCalled.push(modelStr);
return Response.json(
{ detail: "Invalid message format: the request body is malformed." },
{ status: 400 }
);
},
log,
settings: {},
allCombos: [],
});
assert.equal(modelsCalled.length, 1, `body-specific 400 must stop at target 1; tried: ${modelsCalled.join(", ")}`);
assert.equal(response.status, 400, "body-specific 400 must surface to the client");
});

View File

@@ -11,7 +11,7 @@ import assert from "node:assert/strict";
// 400 and rate-limit as 429 (instead of rewriting everything to 502).
const { checkFallbackError } = await import("../../open-sse/services/accountFallback.ts");
const { isContextOverflow400, isParamValidation400 } = await import(
const { isContextOverflow400, isParamValidation400, isModelScoped400 } = await import(
"../../open-sse/services/combo.ts"
);
const { normalizeUpstreamFailure } = await import(
@@ -38,6 +38,16 @@ test("#4519 combo guard: a genuinely body-specific 400 is NOT classified as over
const malformed = "Invalid JSON: unexpected token at position 12";
assert.equal(isContextOverflow400(malformed), false);
assert.equal(isParamValidation400(malformed), false);
assert.equal(isModelScoped400(malformed), false);
});
test("#5249 combo guard: model-not-supported is model-scoped (must advance, not stop)", () => {
assert.equal(isModelScoped400("requested model is not supported"), true);
assert.equal(isModelScoped400("invalid_request_error: model claude-fable-5 is not supported"), true);
assert.equal(isModelScoped400("Bad Request: The model is not supported"), true);
// still not overflow/param — those are separate advance reasons
assert.equal(isContextOverflow400("model is not supported"), false);
assert.equal(isParamValidation400("model is not supported"), false);
});
test("#4519 normalizeUpstreamFailure preserves context_length_exceeded as 400", () => {