Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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))
17 changes: 17 additions & 0 deletions src/sse/handlers/chatHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 17 additions & 0 deletions src/sse/services/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down Expand Up @@ -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;
}
Expand Down
29 changes: 29 additions & 0 deletions tests/unit/chat-helpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
);
});
Loading