fix(resilience): honor shared-registry passthrough providers (#11071) (#11165)

Validated on a worktree over the current tip: account-fallback-service 91/91 plus the five sibling lockout suites 24/24. The measurement in the body (40 of 111 passthroughModels providers uncovered on this branch) is the clincher — one lookup via getProviderById().passthroughModels beside the existing checks, closing the #11071 remainder for shared-registry gateways (port of #11075 which had only landed on main). Thank you @yourspraveen!
This commit is contained in:
Praveen K Palaniswamy
2026-08-22 19:43:15 -04:00
committed by GitHub
parent ce66d319b1
commit 92ef3c71ea
3 changed files with 57 additions and 6 deletions

View File

@@ -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

View File

@@ -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;

View File

@@ -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);