From 80828fc88a3fbbf9a9e44ccdf568b08c5dfe496f Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Wed, 16 Sep 2026 13:35:49 -0300 Subject: [PATCH] fix(sse): say when an API key's allowlist is what hid every connection (#13879) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #13832. A user reported that `nvidia` and `openrouter` — added after the initial setup — always failed chat with `No active credentials for provider: X`, while on the same instance and the same minute `/api/providers/{id}/test` returned valid and `/sync-models` pulled 82 models. Reproducing the resolution chain on the tip shows no defect in it: a connection created exactly as `POST /api/providers` creates one resolves for every model tried, and creation order is irrelevant — the query is `provider = ? AND is_active = 1`, there is no boot-time registry and no migration that backfills only older rows. The three-line AUTH log the reporter pasted is reachable from exactly one place: the pool arriving EMPTY at the key-policy filter. Every post-query skip produces a different message ("all N accounts unavailable"). So the connections exist and are active; the calling key's `allowed_connections` / quota scope removed them — the shape you get from a key minted before those providers existed, which is also why the older providers on that key keep working. The real defect is that nothing ever said so. `/test` and `/sync-models` address a connection by id and never consult the key's scope, so they cannot contradict it, and the one log line that hinted at the filter became `debug` in #11937. `getProviderCredentials` now counts the connections it had before applying the key policy and, when that filter is what emptied the pool, returns `{ blockedByKeyPolicy, blockedCount }` instead of a bare null. `handleNoCredentials` turns it into a 403 naming the allowlist and the fix, alongside the existing allRateLimited/allExpired branches. 403, not 401: the credential is valid, this principal just may not use it. Test is red-first in tests/unit/chat-helpers.test.ts (it asserts the status, the count and that the message names the gate). This does not close the report on its own — it makes the next occurrence self-explanatory. The reporter still needs to confirm their key's allowed_connections/allowed_quotas. --- .../13832-credential-allowlist-diagnostic.md | 1 + src/sse/handlers/chatHelpers.ts | 17 +++++++++++ src/sse/services/auth.ts | 17 +++++++++++ tests/unit/chat-helpers.test.ts | 29 +++++++++++++++++++ 4 files changed, 64 insertions(+) create mode 100644 changelog.d/fixes/13832-credential-allowlist-diagnostic.md diff --git a/changelog.d/fixes/13832-credential-allowlist-diagnostic.md b/changelog.d/fixes/13832-credential-allowlist-diagnostic.md new file mode 100644 index 0000000000..619f323fb3 --- /dev/null +++ b/changelog.d/fixes/13832-credential-allowlist-diagnostic.md @@ -0,0 +1 @@ +- **fix(sse):** when a gateway API key's `allowed_connections` / quota scope hides every connection of a provider, chat now answers `403` naming that scope instead of the generic `No active credentials for provider: X` — which was indistinguishable from "never configured" even though `/test` and `/sync-models` kept working on the same connection ([#13832](https://github.com/diegosouzapw/OmniRoute/issues/13832)) diff --git a/src/sse/handlers/chatHelpers.ts b/src/sse/handlers/chatHelpers.ts index 8a18d01c1d..b47c0037ac 100644 --- a/src/sse/handlers/chatHelpers.ts +++ b/src/sse/handlers/chatHelpers.ts @@ -825,6 +825,23 @@ export function handleNoCredentials( status === "credits_exhausted" ? HTTP_STATUS.PAYMENT_REQUIRED : HTTP_STATUS.UNAUTHORIZED; return errorResponse(httpStatus, message); } + if (credentials?.blockedByKeyPolicy) { + // #13832: the provider HAS active connections — they were filtered out by the + // gateway API key's connection allowlist (`allowed_connections`) or its quota + // scope, so the pool arrived empty and the generic "No active credentials" + // below was indistinguishable from "this provider was never configured". That + // cost the reporter a full investigation: their key passed `/test` and synced + // 82 models (both address the connection by id and never consult the key's + // scope), while chat kept failing. The classic shape is a key minted before + // the provider existed, which is why older providers keep working on it. + // 403, not 401: the credential is fine, this principal is not allowed to use it. + const count = credentials.blockedCount || 1; + const message = + `[${provider}] ${count} connection(s) exist but are excluded by this API key's ` + + `connection allowlist / quota scope — add them to the key in the dashboard, or use a key without that scope`; + log.warn("AUTH", message); + return errorResponse(HTTP_STATUS.FORBIDDEN, message); + } if (!excludeConnectionId) { // Ported from upstream decolua/9router#336 (Ibrahim Ryan): surface as 404 // NOT_FOUND instead of 400 BAD_REQUEST so combo routing can fall through to diff --git a/src/sse/services/auth.ts b/src/sse/services/auth.ts index 42554e6d30..2658f8d1fb 100644 --- a/src/sse/services/auth.ts +++ b/src/sse/services/auth.ts @@ -1389,9 +1389,16 @@ export async function getProviderCredentials( let allConnections = (allConnectionsResults.filter(Array.isArray).flat() as unknown[]) .map(toProviderConnection) .filter((conn) => conn.id.length > 0); + // #13832: remember how many connections the provider really has BEFORE the + // key-policy filter, so an empty pool can say which gate emptied it. Without + // this the caller only ever saw "No active credentials for provider: X", + // identical to "never configured" — while /test and /sync-models kept working, + // because they address a connection by id and never consult the key's scope. + const connectionsBeforeKeyPolicy = allConnections.length; if (allowedConnections && allowedConnections.length > 0) { allConnections = allConnections.filter((conn) => allowedConnections.includes(conn.id)); } + const blockedByKeyPolicyCount = connectionsBeforeKeyPolicy - allConnections.length; if (forcedConnectionId) { allConnections = allConnections.filter((conn) => conn.id === forcedConnectionId); } @@ -1461,6 +1468,16 @@ export async function getProviderCredentials( return geminiEnvCredentials; } invalidateManagedLease(options, "CONNECTION_INELIGIBLE"); + if (blockedByKeyPolicyCount > 0) { + // #13832: the pool is empty only because the calling key's allowlist / + // quota scope removed every connection. Say so instead of returning the + // bare null that becomes "No active credentials for provider: X". + log.warn( + "AUTH", + `${provider} | ${blockedByKeyPolicyCount} connection(s) hidden by the API key's allowed_connections/quota scope` + ); + return { blockedByKeyPolicy: true, blockedCount: blockedByKeyPolicyCount }; + } log.debug("AUTH", `No credentials for ${provider}`); return null; } diff --git a/tests/unit/chat-helpers.test.ts b/tests/unit/chat-helpers.test.ts index 21ac50fa27..24a2b7ed03 100644 --- a/tests/unit/chat-helpers.test.ts +++ b/tests/unit/chat-helpers.test.ts @@ -778,3 +778,32 @@ test("resolveModelOrError returns model_not_found error for unrecognised bare mo assert.match(json.error.message, /Unable to determine provider/i); assert.match(json.error.message, /completely-unknown-model-xyz/i); }); + +test("handleNoCredentials names the API key's connection allowlist as the reason (#13832)", async () => { + // #13832: connections for the provider exist and are active, but the gateway API + // key's allowed_connections / quota scope filtered every one of them out, so the + // pool arrived empty. The old generic "No active credentials for provider: nvidia" + // is indistinguishable from "never configured" — the reporter had a key that + // passed /test and synced 82 models, and no message ever mentioned the allowlist. + const blocked = handleNoCredentials( + { blockedByKeyPolicy: true, blockedCount: 2 }, + null, + "nvidia", + "nvidia/nemotron", + null, + null, + undefined, + /* isCombo */ false + ); + + assert.equal(blocked.status, 403); + const blockedJson = (await blocked.json()) as { error?: { message?: string } }; + const message = blockedJson.error?.message ?? ""; + assert.match(message, /nvidia/); + assert.match(message, /2 connection\(s\)/); + assert.match( + message, + /allowlist|quota scope/i, + "the operator must be told WHICH gate hid the connections" + ); +});