Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
1 change: 1 addition & 0 deletions changelog.d/fixes/15889-credential-scope-diagnostics.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Report whether connection restrictions come from an API key, quota scope, combo pin, or routing allowlist, and log distinct reasons when synthetic no-auth fallback is unavailable without changing credential selection or access rules.
40 changes: 13 additions & 27 deletions src/sse/handlers/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,6 @@ import { isVerifiedNativeCodexRequest } from "@omniroute/open-sse/config/codexId
import { resolveCompressionSettings } from "@omniroute/open-sse/handlers/chatCore/compressionSettings.ts";
import type { CompressionExclusions } from "@omniroute/open-sse/services/compression/exclusions.ts";
import { resolveComboConfig } from "@omniroute/open-sse/services/comboConfig.ts";
import { comboPinAllowlist } from "@/lib/combos/steps.ts";
import { injectHandoffIntoBody } from "@omniroute/open-sse/services/contextHandoff.ts";
import { runWithTransientBackendRetry } from "@omniroute/open-sse/services/transientBackendRetry.ts";
import {
Expand Down Expand Up @@ -221,6 +220,7 @@ import {
validateExclusiveLeaseKeyConfiguration,
type ManagedLeaseDispatchContext,
} from "../services/leaseContext";
import { resolveConnectionRestrictions } from "../services/connectionRestrictions.ts";

registerCodexQuotaFetcher();
registerQuotaTrackersBatch();
Expand Down Expand Up @@ -328,25 +328,6 @@ async function getCombosCachedForChat(): Promise<ComboLike[]> {
return combosCachePromise;
}

function normalizeAllowedConnectionIds(value: unknown): string[] | null {
if (!Array.isArray(value)) return null;
const ids = value.filter(
(entry): entry is string => typeof entry === "string" && entry.trim().length > 0
);
return ids.length > 0 ? ids : null;
}

function intersectAllowedConnectionIds(primary: unknown, secondary: unknown): string[] | null {
const first = normalizeAllowedConnectionIds(primary);
const second = normalizeAllowedConnectionIds(secondary);

if (first && second) {
return first.filter((id) => second.includes(id));
}

return first || second || null;
}

/** Shape of the videoBridgeLog param threaded to executeChatWithBreaker -> handleChatCore (#12150 P1b). */
type VideoBridgeLog = { observed: boolean; redaction: VideoBridgeLogRedactionEntry[] };

Expand Down Expand Up @@ -1182,10 +1163,8 @@ async function handleChatImplementation(
const resolvedModel = modelInfo.model || modelString;
const githubGate = await ghComboGate(comboPreselectedCredentials, provider, resolvedModel);
if (githubGate !== null) return githubGate;
let allowedConnections = intersectAllowedConnectionIds(
apiKeyInfo?.allowedConnections ?? null,
comboPinAllowlist(true, target?.connectionId ?? null, target?.allowedConnectionIds ?? null)
);
const restrictions = resolveConnectionRestrictions(apiKeyInfo, target, true);
let allowedConnections = restrictions.allowedConnections;

// A4: quota-exclusive keys must only use the pool's connection(s).
if (apiKeyInfo?.allowedQuotas && apiKeyInfo.allowedQuotas.length > 0) {
Expand All @@ -1206,6 +1185,7 @@ async function handleChatImplementation(
allowedConnections,
resolvedModel,
{
connectionRestrictionSources: restrictions.sources,
sessionKey: sessionAffinityKey,
...(target?.allowRateLimitedConnection ? { allowRateLimitedConnections: true } : {}),
...(target?.connectionId ? { forcedConnectionId: target.connectionId } : {}),
Expand Down Expand Up @@ -1685,10 +1665,15 @@ async function handleSingleModelChat(
? runtimeOptions.forcedConnectionId.trim()
: "";
const hasForcedConnection = forcedConnectionId.length > 0;
let effectiveAllowedConnections = intersectAllowedConnectionIds(
apiKeyInfo?.allowedConnections ?? null,
comboPinAllowlist(isCombo, forcedConnectionId || null, runtimeOptions.allowedConnectionIds)
const restrictions = resolveConnectionRestrictions(
apiKeyInfo,
{
connectionId: forcedConnectionId,
allowedConnectionIds: runtimeOptions.allowedConnectionIds,
},
isCombo
);
let effectiveAllowedConnections = restrictions.allowedConnections;

// A4: quota-exclusive keys must only use the pool's connection(s).
if (apiKeyInfo?.allowedQuotas && apiKeyInfo.allowedQuotas.length > 0) {
Expand Down Expand Up @@ -1817,6 +1802,7 @@ async function handleSingleModelChat(
effectiveAllowedConnections,
model,
{
connectionRestrictionSources: restrictions.sources,
sessionKey: occupancySessionKey,
reserveOAuthSession: true,
excludeConnectionIds: Array.from(excludedConnectionIds),
Expand Down
15 changes: 4 additions & 11 deletions src/sse/handlers/chatHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ import { logProxyJournal } from "./proxyJournal";
import type { AttemptJournalEntry } from "./proxyJournal";
import { logTranslationEvent } from "../../lib/translatorEvents";
import { getRuntimeProviderProfile } from "@omniroute/open-sse/services/accountFallback.ts";
import { describeConnectionRestriction } from "../services/credentialSelectionDiagnostics.ts";

// #14960: circuit-open 503 that also names the breaker's classified failure kind.
function breakerOpenResponse(provider: string, breaker: CircuitBreaker, retryAfterSec: number) {
Expand Down Expand Up @@ -842,19 +843,11 @@ export function handleNoCredentials(
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.
// #15889: the effective restriction is not necessarily an API-key policy.
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`;
`[${provider}] ${count} connection(s) exist but are excluded by ` +
`${describeConnectionRestriction(credentials.connectionRestrictionSources)} — review the connection routing constraints in the dashboard`;
log.warn("AUTH", message);
return errorResponse(HTTP_STATUS.FORBIDDEN, message);
}
Expand Down
86 changes: 43 additions & 43 deletions src/sse/services/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,13 @@ import {
getOAuthSessionAvailability,
reserveOAuthSession,
} from "@omniroute/open-sse/services/oauthSessionOccupancy.ts";
import {
logCredentialPoolState,
formatConnectionPrefixesForLog,
buildConnectionRestrictionFailure,
createNoAuthRefusalLogger,
type ConnectionRestrictionSource,
} from "./credentialSelectionDiagnostics.ts";

type JsonRecord = Record<string, unknown>;
interface RecoverableConnectionState {
Expand All @@ -220,6 +227,8 @@ interface RecoverableConnectionState {
lastErrorSource?: string | null;
}
export interface CredentialSelectionOptions {
/** Provenance of the effective allowlist; does not alter authorization. */
connectionRestrictionSources?: readonly ConnectionRestrictionSource[];
allowSuppressedConnections?: boolean;
allowRateLimitedConnections?: boolean;
bypassQuotaPolicy?: boolean;
Expand Down Expand Up @@ -787,30 +796,32 @@ async function maybeSyntheticNoAuthFallback(
excludedConnectionIds: Set<string>,
allowedConnections: string[] | null = null,
pauseAsCooldown = false,
requestedModelForPause?: string | null
requestedModelForPause?: string | null,
restrictionSources?: readonly ConnectionRestrictionSource[]
) {
if (!providerCanUseSyntheticNoAuthFallback(providerId)) return null;
// #9057: a restricted key must NOT reach free providers (OpenCode Free, etc.) through the
// synthetic "noauth" connection unless its allowedConnections names it.
if (!allowlistPermitsSyntheticNoAuth(allowedConnections)) return null;
if (excludedConnectionIds.has(SYNTHETIC_NOAUTH_CONNECTION_ID)) return null;
const refuse = createNoAuthRefusalLogger(providerId, allowedConnections, restrictionSources);
// Restricted callers must explicitly admit the synthetic connection.
if (!allowlistPermitsSyntheticNoAuth(allowedConnections)) return refuse("allowlist");
if (excludedConnectionIds.has(SYNTHETIC_NOAUTH_CONNECTION_ID)) return refuse("excluded");
if (pauseAsCooldown) {
const paused = pauseCooldownIfPaused(
providerId,
SYNTHETIC_NOAUTH_CONNECTION_ID,
requestedModelForPause
);
if (paused) return paused;
if (paused) {
refuse("paused");
return paused;
}
} else if (isOpencodeFreeTierSkipped(providerId, Date.now(), requestedModelForPause)) {
log.info("AUTH", `${providerId} | no-auth fallback skipped (OpenCode free-tier pause)`); // #14313
return null;
return refuse("paused");
}
if (
isAnonymousFallbackOnlyProvider(providerId) &&
(await isAnonymousFallbackDisabledBySettings(providerId))
) {
log.info("AUTH", `${providerId} | anonymous no-auth fallback disabled by settings`);
return null;
return refuse("disabled");
}
// #4954: hydrate per-account proxy/rotation config off the connection row so
// no-auth executors (opencode, mimocode) actually honor configured proxies.
Expand All @@ -837,13 +848,6 @@ function normalizeExcludedConnectionIds(

return normalized;
}
function formatConnectionPrefixesForLog(ids: Iterable<string>, max = 6): string {
const prefixes = Array.from(ids)
.filter((id) => typeof id === "string" && id.length > 0)
.slice(0, max)
.map((id) => `${id.slice(0, 8)}...`);
return prefixes.length > 0 ? prefixes.join(",") : "none";
}
function buildPeakHourProtectionRateLimitedResult(
provider: string,
blockedByPeakHour: Array<{
Expand Down Expand Up @@ -1255,9 +1259,10 @@ export async function getProviderCredentials(
return await maybeSyntheticNoAuthFallback(
resolvedId,
excludedForNoAuth,
null,
allowedConnections,
true,
requestedModel
requestedModel,
options.connectionRestrictionSources
);
}
}
Expand Down Expand Up @@ -1402,20 +1407,14 @@ export async function getProviderCredentials(
}
}
const activeConnectionsCount = connections.length;
const rawConnectionsCount = connectionsRaw.length;
const blockedByForcedConnection = forcedConnectionId
? rawConnectionsCount - connections.length
: 0;
const blockedByAllowedConnections =
allowedConnections && allowedConnections.length > 0
? Math.max(0, rawConnectionsCount - connections.length - blockedByForcedConnection)
: 0;
const forcedIdForLog = forcedConnectionId ? `${forcedConnectionId.slice(0, 8)}...` : "none";

log.debug(
"AUTH",
`${provider} | active=${activeConnectionsCount}, excluded=${excludedConnectionIds.size} (${formatConnectionPrefixesForLog(excludedConnectionIds)}), forcedId=${forcedIdForLog}, blocked_forced=${blockedByForcedConnection}, blocked_allowed=${blockedByAllowedConnections}`
);
logCredentialPoolState(provider, {
active: activeConnectionsCount,
raw: connectionsRaw.length,
forcedConnectionId,
excludedConnectionIds,
allowedConnections,
sources: options.connectionRestrictionSources,
});
if (provider === "antigravity" && (forcedConnectionId || allowedConnections?.length)) {
const reasons: string[] = [];
if (forcedConnectionId) reasons.push(`forcedConnectionId kept ${connections.length}`);
Expand Down Expand Up @@ -1494,7 +1493,8 @@ export async function getProviderCredentials(
excludedConnectionIds,
allowedConnections,
false,
requestedModel
requestedModel,
options.connectionRestrictionSources
);
if (syntheticFallback) return syntheticFallback;
return buildAllExpiredCredentials(terminalConnections);
Expand All @@ -1505,7 +1505,8 @@ export async function getProviderCredentials(
excludedConnectionIds,
allowedConnections,
false,
requestedModel
requestedModel,
options.connectionRestrictionSources
);
if (syntheticFallback) return syntheticFallback;
const jinaEnvCredentials = buildJinaEnvCredentials(resolvedId, {
Expand All @@ -1528,14 +1529,12 @@ export async function getProviderCredentials(
}
invalidateManagedLease(options, "CONNECTION_INELIGIBLE");
if (blockedByKeyPolicyCount > 0 && keyPolicyDeniesTarget) {
// #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 buildConnectionRestrictionFailure(
provider,
blockedByKeyPolicyCount,
allowedConnections,
options.connectionRestrictionSources
);
return { blockedByKeyPolicy: true, blockedCount: blockedByKeyPolicyCount };
}
log.debug("AUTH", `No credentials for ${provider}`);
return null;
Expand Down Expand Up @@ -1770,7 +1769,8 @@ export async function getProviderCredentials(
excludedConnectionIds,
allowedConnections,
false,
requestedModel
requestedModel,
options.connectionRestrictionSources
);
if (syntheticFallback) return syntheticFallback;

Expand Down
44 changes: 44 additions & 0 deletions src/sse/services/connectionRestrictions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { comboPinAllowlist } from "@/lib/combos/steps.ts";
import { getConnectionRestrictionSources } from "./credentialSelectionDiagnostics.ts";

function normalizeAllowedConnectionIds(value: unknown): string[] | null {
if (!Array.isArray(value)) return null;
const ids = value.filter(
(entry): entry is string => typeof entry === "string" && entry.trim().length > 0
);
return ids.length > 0 ? ids : null;
}

function intersectAllowedConnectionIds(primary: unknown, secondary: unknown): string[] | null {
const first = normalizeAllowedConnectionIds(primary);
const second = normalizeAllowedConnectionIds(secondary);

if (first && second) {
return first.filter((id) => second.includes(id));
}

return first || second || null;
}

/** Pair the existing allowlist intersection with its diagnostic provenance. */
export function resolveConnectionRestrictions(
key: { allowedConnections?: unknown; allowedQuotas?: unknown } | null | undefined,
target:
{ connectionId?: string | null; allowedConnectionIds?: string[] | null } | null | undefined,
isCombo: boolean
) {
const routingAllowlist = comboPinAllowlist(
isCombo,
target?.connectionId,
target?.allowedConnectionIds
);
return {
allowedConnections: intersectAllowedConnectionIds(key?.allowedConnections, routingAllowlist),
sources: getConnectionRestrictionSources({
keyAllowlist: key?.allowedConnections,
keyQuotas: key?.allowedQuotas,
routingAllowlist,
isCombo,
}),
};
}
Loading
Loading