fix(sse): say when an API key's allowlist is what hid every connection (#13879)

#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.
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-09-16 13:35:49 -03:00
committed by GitHub
parent ba274b616a
commit 80828fc88a
4 changed files with 64 additions and 0 deletions

View File

@@ -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))

View File

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

View File

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

View File

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