Skip to content

Commit 92ef3c7

Browse files
authored
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!
1 parent ce66d31 commit 92ef3c7

3 files changed

Lines changed: 57 additions & 6 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
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

open-sse/services/accountFallback.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,11 @@ import {
2121
honorsRuleLockScope,
2222
} from "../config/providerErrorRules.ts";
2323
import * as rot from "./rotationConfig.ts";
24-
import { getPassthroughProviders, getProviderCategory, isLocalProvider } from "../config/providerRegistry.ts";
24+
import {
25+
getPassthroughProviders,
26+
getProviderCategory,
27+
isLocalProvider,
28+
} from "../config/providerRegistry.ts";
2529
import {
2630
DEFAULT_RESILIENCE_SETTINGS,
2731
resolveResilienceSettings,
@@ -37,7 +41,12 @@ import {
3741
type FailureKind,
3842
} from "../../src/shared/utils/classify429";
3943
import { recordProviderSuccess as resetCooldownFailureCount } from "./providerCooldownTracker.ts";
40-
import { resolveProviderId, isLocalProvider as isLocalProviderId, isSelfHostedChatProvider } from "../../src/shared/constants/providers";
44+
import {
45+
getProviderById,
46+
resolveProviderId,
47+
isLocalProvider as isLocalProviderId,
48+
isSelfHostedChatProvider,
49+
} from "../../src/shared/constants/providers";
4150
import { resolveUseUpstream429BreakerHints } from "../../src/shared/utils/providerHints";
4251
import { getCodexModelScope } from "../config/codexQuotaScopes.ts";
4352
import { getQuotaScopedModelForProvider } from "./antigravityQuotaFamily.ts";
@@ -797,6 +806,12 @@ export function hasPerModelQuota(
797806
if (canonicalId === "gemini" || canonicalId === "github") return true;
798807
if (canonicalId === "antigravity" || canonicalId === "agy") return true;
799808
if (getPassthroughProviders().has(canonicalId)) return true;
809+
// #11071: getPassthroughProviders() reads the open-sse REGISTRY. A provider can declare
810+
// passthroughModels:true in the SHARED registry (src/shared/constants/providers/) and be
811+
// absent from that set — 40 of them are, and they are neither local nor self-hosted, so the
812+
// branch below never reaches them either. Without this lookup a missing-model 404 on one of
813+
// those cools the whole connection instead of locking out the single model.
814+
if (getProviderById(canonicalId)?.passthroughModels === true) return true;
800815
if (isCompatibleProvider(canonicalId)) return true;
801816
if (isLocalProviderId(canonicalId) || isSelfHostedChatProvider(canonicalId)) return true;
802817
return false;

tests/unit/account-fallback-service.test.ts

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -453,6 +453,25 @@ test("hasPerModelQuota returns true for GitHub Copilot provider (#1624)", () =>
453453
assert.equal(hasPerModelQuota("github", "gpt-5-mini"), true);
454454
});
455455

456+
test("hasPerModelQuota honors shared-registry passthrough providers (#11071)", () => {
457+
// These declare passthroughModels:true in src/shared/constants/providers/, but are absent
458+
// from the open-sse REGISTRY passthrough set and are neither local nor self-hosted — so the
459+
// isLocalProvider/isSelfHostedChatProvider branch (#11078) never reaches them. Without the
460+
// shared-registry lookup a missing model on one of these cools the WHOLE connection.
461+
assert.equal(hasPerModelQuota("novita"), true);
462+
assert.equal(hasPerModelQuota("uncloseai"), true);
463+
assert.equal(hasPerModelQuota("orcarouter"), true);
464+
465+
// Already covered by the local/self-hosted branch — asserted so this port cannot regress it.
466+
assert.equal(hasPerModelQuota("ollama-local"), true);
467+
assert.equal(hasPerModelQuota("lm-studio"), true);
468+
assert.equal(hasPerModelQuota("vllm"), true);
469+
470+
// Neither declared in the shared registry nor local: a failure here is still connection-wide.
471+
assert.equal(hasPerModelQuota("openai"), false);
472+
assert.equal(hasPerModelQuota("anthropic"), false);
473+
});
474+
456475
test("Codex Spark 429s are scoped away from normal Codex models", () => {
457476
const connectionId = `codex-${Date.now()}`;
458477
clearModelLock("codex", connectionId, "gpt-5.3-codex-spark");
@@ -1659,7 +1678,11 @@ test("#10460: model-unsupported 400 handles various phrasings", async () => {
16591678
// Verify connection stays healthy after each iteration
16601679
const conn = await providersDb.getProviderConnectionById(id);
16611680
assert.ok(!conn.rateLimitedUntil, `"${errorText}" must not rate-limit connection`);
1662-
assert.notStrictEqual(conn.testStatus, "unavailable", `"${errorText}" must not mark unavailable`);
1681+
assert.notStrictEqual(
1682+
conn.testStatus,
1683+
"unavailable",
1684+
`"${errorText}" must not mark unavailable`
1685+
);
16631686
}
16641687
});
16651688

@@ -1692,7 +1715,11 @@ test("#10460: non-400 status with model-unsupported text does NOT trigger guard"
16921715
"test-model"
16931716
);
16941717

1695-
assert.strictEqual(result.shouldFallback, true, "non-400 must not be short-circuited by model guard");
1718+
assert.strictEqual(
1719+
result.shouldFallback,
1720+
true,
1721+
"non-400 must not be short-circuited by model guard"
1722+
);
16961723
// The key assertion: guard returns shouldFallback:false. If we get here with
16971724
// shouldFallback:true, the guard did NOT fire (correct behavior).
16981725
});
@@ -1723,7 +1750,11 @@ test("#10460: auth-credential 400 text does NOT match model-unsupported guard",
17231750

17241751
// This text does NOT match MODEL_ACCESS_DENIED_PATTERNS (verified by regex test)
17251752
// so it falls through to checkFallbackError which returns shouldFallback:false for generic 400
1726-
assert.strictEqual(result.shouldFallback, false, "auth-credential 400 must not be caught by model guard");
1753+
assert.strictEqual(
1754+
result.shouldFallback,
1755+
false,
1756+
"auth-credential 400 must not be caught by model guard"
1757+
);
17271758
// The generic 400 path returns cooldownMs:0 — same as the guard, but the
17281759
// connection was NOT touched (no rateLimitedUntil set). This distinguishes
17291760
// 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
17371768

17381769
// Guard path: model-unsupported 400 → shouldFallback:false, cooldownMs:0, no DB change
17391770
const guardResult = await auth.markAccountUnavailable(
1740-
connId, 400, "The requested model is not supported", "github", "test-model"
1771+
connId,
1772+
400,
1773+
"The requested model is not supported",
1774+
"github",
1775+
"test-model"
17411776
);
17421777
assert.strictEqual(guardResult.shouldFallback, false);
17431778
assert.strictEqual(guardResult.cooldownMs, 0);

0 commit comments

Comments
 (0)