Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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
19 changes: 17 additions & 2 deletions open-sse/services/accountFallback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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";
Expand Down Expand Up @@ -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;
Expand Down
43 changes: 39 additions & 4 deletions tests/unit/account-fallback-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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`
);
}
});

Expand Down Expand Up @@ -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).
});
Expand Down Expand Up @@ -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.
Expand All @@ -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);
Expand Down
Loading