mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-16 20:22:21 +03:00
fix(sse): let :free OpenRouter models bypass connection-wide credits_exhausted lock (#10445)
* fix(sse): let :free OpenRouter models bypass connection-wide credits_exhausted lock A 402 from one paid OpenRouter model correctly locks the whole connection as credits_exhausted for an hour (intentional, per #6842), but that lock was also blocking every :free model on the same connection even though OpenRouter bills free models separately from account credits. Reconstructed clean against release/v3.8.50 by the maintainer: the author's original branch predated a large auth.ts import refactor; the same delta was re-applied onto the current tip and the TDD test still passes. TDD: tests/unit/openrouter-free-model-credits-exhausted.test.ts reproduces the bug (fails before the fix, passes after) and covers the three guard cases above. Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * test(mutation): register openrouter-free-model-credits-exhausted in stryker tap.testFiles The new unit test covers src/sse/services/auth.ts, which is one of the 31 stryker-mutated modules — per check-mutation-test-coverage every covering test must be listed in tap.testFiles or its mutant kills stop counting. Registered the file so the blocking mutation-test-coverage gate passes. Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --------- Co-authored-by: killmonger2317-coder <282069920+killmonger2317-coder@users.noreply.github.com> Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
f466ea91c9
commit
d33e62af9c
@@ -81,6 +81,7 @@ import {
|
||||
WEB_COOKIE_PROVIDERS,
|
||||
} from "@/shared/constants/providers";
|
||||
import { isModelExcludedByConnection } from "@/domain/connectionModelRules";
|
||||
import { isFreeModel } from "@/shared/utils/freeModels";
|
||||
import {
|
||||
applySessionAffinityPin,
|
||||
formatSessionKeyForLog,
|
||||
@@ -340,6 +341,31 @@ function isTerminalConnectionStatus(connection: ProviderConnectionView): boolean
|
||||
return status === "credits_exhausted" || status === "banned" || status === "expired";
|
||||
}
|
||||
|
||||
// OpenRouter's paid balance and its `:free`-suffixed models are billed
|
||||
// separately — a 402 from a paid model call correctly locks the whole
|
||||
// connection as credits_exhausted (see openrouter-quota-6842.test.ts), but
|
||||
// that lock must not also block :free model requests on the same
|
||||
// connection, or combo failover to the user's configured free models never
|
||||
// fires. Scoped to provider === "openrouter" + status === credits_exhausted
|
||||
// only; every other terminal status (banned, expired) and every other
|
||||
// provider keep the unconditional exclusion.
|
||||
function isTerminalConnectionStatusForModel(
|
||||
connection: ProviderConnectionView,
|
||||
provider: string,
|
||||
requestedModel: string | null
|
||||
): boolean {
|
||||
if (!isTerminalConnectionStatus(connection)) return false;
|
||||
if (
|
||||
provider === "openrouter" &&
|
||||
normalizeStatus(connection.testStatus) === "credits_exhausted" &&
|
||||
requestedModel &&
|
||||
isFreeModel("openrouter", { id: requestedModel })
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// #8200: cookie-auth providers (perplexity-web, grok-web, ...) use a rotating browser
|
||||
// session, not a static API key — a 401 means "session needs a refresh", not "dead".
|
||||
function isRecoverableCookieAuth401(
|
||||
@@ -1239,7 +1265,7 @@ export async function getProviderCredentials(
|
||||
connectionFilterStatus.set(c.id, "rateLimited");
|
||||
return false;
|
||||
}
|
||||
if (isTerminalConnectionStatus(c)) {
|
||||
if (isTerminalConnectionStatusForModel(c, provider, requestedModel)) {
|
||||
connectionFilterStatus.set(c.id, "terminalStatus");
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -273,6 +273,7 @@
|
||||
"tests/unit/observability-payloads.test.ts",
|
||||
"tests/unit/ollama-cloud-weekly-quota-cooldown-3709.test.ts",
|
||||
"tests/unit/openapi-security-tiers.test.ts",
|
||||
"tests/unit/openrouter-free-model-credits-exhausted.test.ts",
|
||||
"tests/unit/openrouter-passthrough-models.test.ts",
|
||||
"tests/unit/openrouter-quota-6842.test.ts",
|
||||
"tests/unit/persist-429-cooldown-account-fallback.test.ts",
|
||||
|
||||
127
tests/unit/openrouter-free-model-credits-exhausted.test.ts
Normal file
127
tests/unit/openrouter-free-model-credits-exhausted.test.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* A 402 from a PAID OpenRouter model locks the whole connection as
|
||||
* "credits_exhausted" (openrouter-quota-6842.test.ts confirms this is
|
||||
* intentional connection-scoped behavior for OpenRouter's shared account
|
||||
* balance). But OpenRouter's `:free` models are not gated by that same
|
||||
* balance, so once one paid-model call trips the lock, every `:free` model
|
||||
* combo target on that same connection is also skipped for the full 1h
|
||||
* cooldown — even though the free models never touched the exhausted
|
||||
* credits. This defeats combo failover to free models, which is the whole
|
||||
* point of configuring them.
|
||||
*
|
||||
* getProviderCredentials must keep serving `:free` model requests from a
|
||||
* connection whose ONLY problem is credits_exhausted, while still refusing
|
||||
* paid-model requests (and still refusing free-model requests on a
|
||||
* connection that's terminal for another reason, e.g. banned).
|
||||
*/
|
||||
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-openrouter-free-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const providersDb = await import("../../src/lib/db/providers.ts");
|
||||
const auth = await import("../../src/sse/services/auth.ts");
|
||||
|
||||
async function resetStorage() {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
test.after(() => {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("getProviderCredentials still serves a :free OpenRouter model after the connection is credits_exhausted", async () => {
|
||||
await resetStorage();
|
||||
|
||||
const conn = await providersDb.createProviderConnection({
|
||||
provider: "openrouter",
|
||||
authType: "apikey",
|
||||
apiKey: "sk-or-exhausted",
|
||||
isActive: true,
|
||||
testStatus: "credits_exhausted",
|
||||
});
|
||||
|
||||
const selected = await auth.getProviderCredentials(
|
||||
"openrouter",
|
||||
null,
|
||||
null,
|
||||
"meta-llama/llama-3.1-8b-instruct:free"
|
||||
);
|
||||
|
||||
assert.ok(selected, "a credits_exhausted OpenRouter connection must still serve :free models");
|
||||
assert.equal(selected.connectionId, conn.id);
|
||||
});
|
||||
|
||||
test("getProviderCredentials still refuses a PAID OpenRouter model on a credits_exhausted connection", async () => {
|
||||
await resetStorage();
|
||||
|
||||
await providersDb.createProviderConnection({
|
||||
provider: "openrouter",
|
||||
authType: "apikey",
|
||||
apiKey: "sk-or-exhausted-paid",
|
||||
isActive: true,
|
||||
testStatus: "credits_exhausted",
|
||||
});
|
||||
|
||||
const selected = await auth.getProviderCredentials(
|
||||
"openrouter",
|
||||
null,
|
||||
null,
|
||||
"anthropic/claude-opus-4.5"
|
||||
);
|
||||
|
||||
assert.equal(selected, null, "paid-model requests must still be blocked on the exhausted connection");
|
||||
});
|
||||
|
||||
test("getProviderCredentials still refuses a :free OpenRouter model on a banned connection", async () => {
|
||||
await resetStorage();
|
||||
|
||||
await providersDb.createProviderConnection({
|
||||
provider: "openrouter",
|
||||
authType: "apikey",
|
||||
apiKey: "sk-or-banned",
|
||||
isActive: true,
|
||||
testStatus: "banned",
|
||||
});
|
||||
|
||||
const selected = await auth.getProviderCredentials(
|
||||
"openrouter",
|
||||
null,
|
||||
null,
|
||||
"meta-llama/llama-3.1-8b-instruct:free"
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
selected,
|
||||
null,
|
||||
"the free-model exemption only applies to credits_exhausted, not other terminal statuses"
|
||||
);
|
||||
});
|
||||
|
||||
test("getProviderCredentials still refuses a :free model on a credits_exhausted connection for a NON-openrouter provider", async () => {
|
||||
await resetStorage();
|
||||
|
||||
await providersDb.createProviderConnection({
|
||||
provider: "openai",
|
||||
authType: "apikey",
|
||||
apiKey: "sk-oai-exhausted",
|
||||
isActive: true,
|
||||
testStatus: "credits_exhausted",
|
||||
});
|
||||
|
||||
const selected = await auth.getProviderCredentials("openai", null, null, "some-model:free");
|
||||
|
||||
assert.equal(
|
||||
selected,
|
||||
null,
|
||||
"the exemption is OpenRouter-specific, since only OpenRouter uses the :free naming convention with a shared balance"
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user