From d33e62af9c79ebe8cdabef12ceabca1dc6795114 Mon Sep 17 00:00:00 2001 From: killmonger2317-coder Date: Sat, 15 Aug 2026 12:52:46 -0400 Subject: [PATCH] fix(sse): let :free OpenRouter models bypass connection-wide credits_exhausted lock (#10445) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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> --- src/sse/services/auth.ts | 28 +++- stryker.conf.json | 1 + ...outer-free-model-credits-exhausted.test.ts | 127 ++++++++++++++++++ 3 files changed, 155 insertions(+), 1 deletion(-) create mode 100644 tests/unit/openrouter-free-model-credits-exhausted.test.ts diff --git a/src/sse/services/auth.ts b/src/sse/services/auth.ts index 5a012cb5f5..f3ec47870a 100644 --- a/src/sse/services/auth.ts +++ b/src/sse/services/auth.ts @@ -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; } diff --git a/stryker.conf.json b/stryker.conf.json index a9a06bdcac..0714bd72dc 100644 --- a/stryker.conf.json +++ b/stryker.conf.json @@ -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", diff --git a/tests/unit/openrouter-free-model-credits-exhausted.test.ts b/tests/unit/openrouter-free-model-credits-exhausted.test.ts new file mode 100644 index 0000000000..601553b658 --- /dev/null +++ b/tests/unit/openrouter-free-model-credits-exhausted.test.ts @@ -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" + ); +});