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:
killmonger2317-coder
2026-08-15 12:52:46 -04:00
committed by GitHub
parent f466ea91c9
commit d33e62af9c
3 changed files with 155 additions and 1 deletions

View File

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

View File

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

View 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"
);
});