mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-16 20:02:45 +03:00
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:
committed by
GitHub
parent
ba274b616a
commit
80828fc88a
@@ -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))
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user