diff --git a/changelog.d/fixes/11165-shared-registry-passthrough-model-lockout.md b/changelog.d/fixes/11165-shared-registry-passthrough-model-lockout.md new file mode 100644 index 0000000000..eabe67cb09 --- /dev/null +++ b/changelog.d/fixes/11165-shared-registry-passthrough-model-lockout.md @@ -0,0 +1 @@ +- **fix(resilience):** a missing-model `404` on a provider that declares `passthroughModels: true` in the shared registry (novita, uncloseai, orcarouter and 37 others) now locks out only that model instead of cooling the entire connection — `hasPerModelQuota()` previously read only the open-sse registry and the local/self-hosted families ([#11165](https://github.com/diegosouzapw/OmniRoute/pull/11165)) — thanks @yourspraveen diff --git a/open-sse/services/accountFallback.ts b/open-sse/services/accountFallback.ts index 9e1f5f2ee3..63407a38fc 100644 --- a/open-sse/services/accountFallback.ts +++ b/open-sse/services/accountFallback.ts @@ -21,7 +21,11 @@ import { honorsRuleLockScope, } from "../config/providerErrorRules.ts"; import * as rot from "./rotationConfig.ts"; -import { getPassthroughProviders, getProviderCategory, isLocalProvider } from "../config/providerRegistry.ts"; +import { + getPassthroughProviders, + getProviderCategory, + isLocalProvider, +} from "../config/providerRegistry.ts"; import { DEFAULT_RESILIENCE_SETTINGS, resolveResilienceSettings, @@ -37,7 +41,12 @@ import { type FailureKind, } from "../../src/shared/utils/classify429"; import { recordProviderSuccess as resetCooldownFailureCount } from "./providerCooldownTracker.ts"; -import { resolveProviderId, isLocalProvider as isLocalProviderId, isSelfHostedChatProvider } from "../../src/shared/constants/providers"; +import { + getProviderById, + resolveProviderId, + isLocalProvider as isLocalProviderId, + isSelfHostedChatProvider, +} from "../../src/shared/constants/providers"; import { resolveUseUpstream429BreakerHints } from "../../src/shared/utils/providerHints"; import { getCodexModelScope } from "../config/codexQuotaScopes.ts"; import { getQuotaScopedModelForProvider } from "./antigravityQuotaFamily.ts"; @@ -797,6 +806,12 @@ export function hasPerModelQuota( if (canonicalId === "gemini" || canonicalId === "github") return true; if (canonicalId === "antigravity" || canonicalId === "agy") return true; if (getPassthroughProviders().has(canonicalId)) return true; + // #11071: getPassthroughProviders() reads the open-sse REGISTRY. A provider can declare + // passthroughModels:true in the SHARED registry (src/shared/constants/providers/) and be + // absent from that set — 40 of them are, and they are neither local nor self-hosted, so the + // branch below never reaches them either. Without this lookup a missing-model 404 on one of + // those cools the whole connection instead of locking out the single model. + if (getProviderById(canonicalId)?.passthroughModels === true) return true; if (isCompatibleProvider(canonicalId)) return true; if (isLocalProviderId(canonicalId) || isSelfHostedChatProvider(canonicalId)) return true; return false; diff --git a/tests/unit/account-fallback-service.test.ts b/tests/unit/account-fallback-service.test.ts index 3711e6217c..266179ec7a 100644 --- a/tests/unit/account-fallback-service.test.ts +++ b/tests/unit/account-fallback-service.test.ts @@ -453,6 +453,25 @@ test("hasPerModelQuota returns true for GitHub Copilot provider (#1624)", () => assert.equal(hasPerModelQuota("github", "gpt-5-mini"), true); }); +test("hasPerModelQuota honors shared-registry passthrough providers (#11071)", () => { + // These declare passthroughModels:true in src/shared/constants/providers/, but are absent + // from the open-sse REGISTRY passthrough set and are neither local nor self-hosted — so the + // isLocalProvider/isSelfHostedChatProvider branch (#11078) never reaches them. Without the + // shared-registry lookup a missing model on one of these cools the WHOLE connection. + assert.equal(hasPerModelQuota("novita"), true); + assert.equal(hasPerModelQuota("uncloseai"), true); + assert.equal(hasPerModelQuota("orcarouter"), true); + + // Already covered by the local/self-hosted branch — asserted so this port cannot regress it. + assert.equal(hasPerModelQuota("ollama-local"), true); + assert.equal(hasPerModelQuota("lm-studio"), true); + assert.equal(hasPerModelQuota("vllm"), true); + + // Neither declared in the shared registry nor local: a failure here is still connection-wide. + assert.equal(hasPerModelQuota("openai"), false); + assert.equal(hasPerModelQuota("anthropic"), false); +}); + test("Codex Spark 429s are scoped away from normal Codex models", () => { const connectionId = `codex-${Date.now()}`; clearModelLock("codex", connectionId, "gpt-5.3-codex-spark"); @@ -1659,7 +1678,11 @@ test("#10460: model-unsupported 400 handles various phrasings", async () => { // Verify connection stays healthy after each iteration const conn = await providersDb.getProviderConnectionById(id); assert.ok(!conn.rateLimitedUntil, `"${errorText}" must not rate-limit connection`); - assert.notStrictEqual(conn.testStatus, "unavailable", `"${errorText}" must not mark unavailable`); + assert.notStrictEqual( + conn.testStatus, + "unavailable", + `"${errorText}" must not mark unavailable` + ); } }); @@ -1692,7 +1715,11 @@ test("#10460: non-400 status with model-unsupported text does NOT trigger guard" "test-model" ); - assert.strictEqual(result.shouldFallback, true, "non-400 must not be short-circuited by model guard"); + assert.strictEqual( + result.shouldFallback, + true, + "non-400 must not be short-circuited by model guard" + ); // The key assertion: guard returns shouldFallback:false. If we get here with // shouldFallback:true, the guard did NOT fire (correct behavior). }); @@ -1723,7 +1750,11 @@ test("#10460: auth-credential 400 text does NOT match model-unsupported guard", // This text does NOT match MODEL_ACCESS_DENIED_PATTERNS (verified by regex test) // so it falls through to checkFallbackError which returns shouldFallback:false for generic 400 - assert.strictEqual(result.shouldFallback, false, "auth-credential 400 must not be caught by model guard"); + assert.strictEqual( + result.shouldFallback, + false, + "auth-credential 400 must not be caught by model guard" + ); // The generic 400 path returns cooldownMs:0 — same as the guard, but the // connection was NOT touched (no rateLimitedUntil set). This distinguishes // it from the normal fallback path which would set a cooldown. @@ -1737,7 +1768,11 @@ test("#10460: guard early return does not touch DB (distinguishes from normal pa // Guard path: model-unsupported 400 → shouldFallback:false, cooldownMs:0, no DB change const guardResult = await auth.markAccountUnavailable( - connId, 400, "The requested model is not supported", "github", "test-model" + connId, + 400, + "The requested model is not supported", + "github", + "test-model" ); assert.strictEqual(guardResult.shouldFallback, false); assert.strictEqual(guardResult.cooldownMs, 0);