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