From 6f9a6d12e6379ef70ac681de43c67d164a28f25d Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Fri, 9 Oct 2026 13:31:05 -0300 Subject: [PATCH] fix(routing): share provider alias pools with combo discovery (#15927) Batch-merged with the owner's 2026-10-09 wave (open-sse/services/AGENTS.md one-line edit explicitly approved by the owner in-session). Reconciled on the current release tip: the only conflict was the stryker.conf.json test list against the already-landed #15922/#15939 (union of entries). Focused: 16/16 tests (provider-alias-combo-pool-12844 + combo-custom-provider-resolution); typecheck:core and check:open-sse-typecheck clean. Same change also validated earlier in the combined 57-PR tree. --- .../fixes/12844-combo-provider-alias-pools.md | 1 + open-sse/services/AGENTS.md | 1 + open-sse/services/combo.ts | 6 +- open-sse/services/combo/autoStrategy.ts | 6 +- open-sse/services/combo/dispatchPrelude.ts | 4 +- .../services/combo/promptCacheAffinity.ts | 4 +- open-sse/services/combo/providerWildcard.ts | 7 +- open-sse/services/combo/quotaStrategies.ts | 12 +- open-sse/services/combo/sessionStickiness.ts | 6 +- open-sse/services/providerConnectionPool.ts | 133 +++++++++++++ src/sse/services/auth.ts | 108 +--------- stryker.conf.json | 1 + .../combo-custom-provider-resolution.test.ts | 2 +- .../provider-alias-combo-pool-12844.test.ts | 185 ++++++++++++++++++ 14 files changed, 348 insertions(+), 128 deletions(-) create mode 100644 changelog.d/fixes/12844-combo-provider-alias-pools.md create mode 100644 open-sse/services/providerConnectionPool.ts create mode 100644 tests/unit/provider-alias-combo-pool-12844.test.ts diff --git a/changelog.d/fixes/12844-combo-provider-alias-pools.md b/changelog.d/fixes/12844-combo-provider-alias-pools.md new file mode 100644 index 0000000000..051b5999b1 --- /dev/null +++ b/changelog.d/fixes/12844-combo-provider-alias-pools.md @@ -0,0 +1 @@ +- Reuse direct-auth provider alias pools in combo/auto discovery so Antigravity CLI and OpenCode accounts remain visible to scoring, quota, tags and affinity while preserving account restrictions. (#12844) diff --git a/open-sse/services/AGENTS.md b/open-sse/services/AGENTS.md index 863b5f31f5..a410b1a3dc 100644 --- a/open-sse/services/AGENTS.md +++ b/open-sse/services/AGENTS.md @@ -22,6 +22,7 @@ Live count: `ls open-sse/services/*.ts | wc -l` (currently 134). More including ### Account & Token Management +- **`providerConnectionPool.ts`** — Shared alias/custom-node connection pools for direct authentication and combo discovery; preserves node ownership and leaves caller ACL/pin filtering in place. - **`tokenRefresh.ts`** — OAuth token expiration detection and refresh. - **`accountFallback.ts`** — Account switching on quota/rate-limit. Also houses model lockout. - **`sessionManager.ts`** — Request session state across retries. diff --git a/open-sse/services/combo.ts b/open-sse/services/combo.ts index 3019a78a2e..e84413d7e5 100644 --- a/open-sse/services/combo.ts +++ b/open-sse/services/combo.ts @@ -43,7 +43,7 @@ import { getCachedProviderConnectionById } from "../../src/lib/db/readCache.ts"; import { expandPromptCacheAffinityTargetsFromConnections } from "./combo/promptCacheAffinity.ts"; -import { getCachedProviderConnections } from "../../src/lib/db/readCache"; +import { getCachedProviderPoolConnections } from "./providerConnectionPool.ts"; import { resolveResilienceSettings, type ResilienceSettings, @@ -355,7 +355,7 @@ export async function buildAutoCandidates( await Promise.all( uniqueProviders.map(async (provider) => { try { - const connections = (await getCachedProviderConnections({ + const connections = (await getCachedProviderPoolConnections({ provider, isActive: true, })) as Array>; @@ -909,7 +909,7 @@ async function handleComboChatInner({ let pinnedLockWaitMs = 0; if (activeNativeTurnPin) { - const activeConnections = (await getCachedProviderConnections({ + const activeConnections = (await getCachedProviderPoolConnections({ provider: resolveProviderId(activeNativeTurnPin.provider), isActive: true, })) as Array>; diff --git a/open-sse/services/combo/autoStrategy.ts b/open-sse/services/combo/autoStrategy.ts index f60aaf457e..f49a8849fc 100644 --- a/open-sse/services/combo/autoStrategy.ts +++ b/open-sse/services/combo/autoStrategy.ts @@ -43,7 +43,7 @@ import { type ScoringWeights, } from "../autoCombo/scoring.ts"; import type { RoutingHint } from "../manifestAdapter"; -import { getCachedProviderConnections } from "../../../src/lib/db/readCache"; +import { getCachedProviderPoolConnections } from "../providerConnectionPool.ts"; import { getSyncedAvailableModels, getCustomModels, @@ -266,7 +266,7 @@ export async function applyRequestTagRouting( await Promise.all( providerIds.map(async (providerId) => { try { - const connections = await getCachedProviderConnections({ + const connections = await getCachedProviderPoolConnections({ provider: providerId, isActive: true, }); @@ -483,7 +483,7 @@ export async function expandAutoComboCandidatePool( if (Array.isArray(explicitModels) && explicitModels.length > 0) return eligibleTargets; try { - const allConnections = await getCachedProviderConnections({ isActive: true }); + const allConnections = await getCachedProviderPoolConnections({ isActive: true }); const providerIds = [ ...new Set( (allConnections as Array<{ provider?: unknown }>) diff --git a/open-sse/services/combo/dispatchPrelude.ts b/open-sse/services/combo/dispatchPrelude.ts index 4b28e6872d..08972ea2fc 100644 --- a/open-sse/services/combo/dispatchPrelude.ts +++ b/open-sse/services/combo/dispatchPrelude.ts @@ -12,7 +12,7 @@ * * Extracted from combo.ts as a pure move (#3501). No behaviour change. */ -import { getCachedProviderConnections } from "../../../src/lib/db/readCache"; +import { getCachedProviderPoolConnections } from "../providerConnectionPool.ts"; import { getCircuitBreaker } from "../../../src/shared/utils/circuitBreaker"; import { fisherYatesShuffle, getNextFromDeck } from "../../../src/shared/utils/shuffleDeck"; import { handleFusionChat, type FusionTuning } from "../fusion.ts"; @@ -177,7 +177,7 @@ async function isPinnedModelDurablyUnhealthy(pinnedModel: string): Promise { try { - const connections = (await getCachedProviderConnections({ + const connections = (await getCachedProviderPoolConnections({ provider, isActive: true, })) as Array>; diff --git a/open-sse/services/combo/providerWildcard.ts b/open-sse/services/combo/providerWildcard.ts index 55763815c8..0bddb94232 100644 --- a/open-sse/services/combo/providerWildcard.ts +++ b/open-sse/services/combo/providerWildcard.ts @@ -53,6 +53,7 @@ import { isAlibabaFreeTierVisionComboName, } from "../dashscopeTextModels.ts"; import type { ComboLike } from "./types.ts"; +import { isRecord } from "./comboData.ts"; /** Sentinel pattern used for "all models of a provider". */ const PROVIDER_WILDCARD_SENTINEL = "*"; @@ -160,9 +161,9 @@ async function filterAlibabaFreeDrainedModelIds( return modelIds; } try { - const { getProviderConnections } = await import("../../../src/lib/db/providers.ts"); - const connections = await getProviderConnections({ provider: providerId }); - const connection = connections.find((entry) => entry.id === connectionId); + const { getCachedProviderPoolConnections } = await import("../providerConnectionPool.ts"); + const connections = await getCachedProviderPoolConnections({ provider: providerId }); + const connection = connections.find((entry) => isRecord(entry) && entry.id === connectionId); if (!connection) return modelIds; if (isAlibabaFreeTierVisionComboName(comboName)) { diff --git a/open-sse/services/combo/quotaStrategies.ts b/open-sse/services/combo/quotaStrategies.ts index 5be84020b7..d48d6b0d84 100644 --- a/open-sse/services/combo/quotaStrategies.ts +++ b/open-sse/services/combo/quotaStrategies.ts @@ -10,7 +10,7 @@ * State cohesion: `resetAwareQuotaCache` and * `MAX_RESET_AWARE_CACHE` MUST remain single instances defined once here, * alongside their only readers/writers (`fetchResetAwareQuotaWithCache`). - * Connection lists go through `getCachedProviderConnections` (5s TTL, + * Connection lists go through `getCachedProviderPoolConnections` (5s TTL, * invalidated on connection writes). Do not add a second connection cache. * * Cross-module state: the tie-band round-robin in orderTargetsByResetAwareQuota @@ -32,7 +32,7 @@ import { import { PRE_SCREEN_CONCURRENCY } from "../comboConfig.ts"; import { getQuotaFetcher } from "../quotaPreflight.ts"; import { getCircuitBreaker } from "../../../src/shared/utils/circuitBreaker"; -import { getCachedProviderConnections } from "../../../src/lib/db/readCache"; +import { getCachedProviderPoolConnections } from "../providerConnectionPool.ts"; import { MAX_RR_COUNTERS, rrCounters } from "./rrState.ts"; import type { ResolvedComboTarget, IsModelAvailable } from "./types.ts"; import { @@ -91,7 +91,10 @@ async function getQuotaAwareConnectionsForTarget( provider, (async () => { try { - const connections = await getCachedProviderConnections({ provider, isActive: true }); + const connections = await getCachedProviderPoolConnections({ + provider, + isActive: true, + }); let activeConnections = Array.isArray(connections) ? (connections as Array>).filter( (connection) => @@ -222,10 +225,11 @@ export async function expandTargetsByQuotaAwareConnections( continue; } + const poolIds = new Set(connections.map((connection) => connection.id)); for (const connectionId of connectionIds) { const provider = getResetAwareProvider(target); const connection = connectionById.get(connectionId); - if (provider && getQuotaFetcher(provider) && connection?.provider !== provider) continue; + if (provider && getQuotaFetcher(provider) && !poolIds.has(connectionId)) continue; if ( connection && typeof connection.rateLimitedUntil === "string" && diff --git a/open-sse/services/combo/sessionStickiness.ts b/open-sse/services/combo/sessionStickiness.ts index 6929dff9b6..68269c0376 100644 --- a/open-sse/services/combo/sessionStickiness.ts +++ b/open-sse/services/combo/sessionStickiness.ts @@ -144,11 +144,11 @@ async function resolveConnectionHealth( if (_connectionFetcherOverride) return _connectionFetcherOverride(connectionId, provider); try { - const mod = await import("../../../src/lib/db/readCache"); - const getCachedProviderConnections = mod.getCachedProviderConnections as ( + const mod = await import("../providerConnectionPool.ts"); + const getCachedProviderPoolConnections = mod.getCachedProviderPoolConnections as ( filter: Record ) => Promise; - const connections = (await getCachedProviderConnections({ + const connections = (await getCachedProviderPoolConnections({ provider, isActive: true, })) as Array; diff --git a/open-sse/services/providerConnectionPool.ts b/open-sse/services/providerConnectionPool.ts new file mode 100644 index 0000000000..963095a70d --- /dev/null +++ b/open-sse/services/providerConnectionPool.ts @@ -0,0 +1,133 @@ +import { nodeTypeFromId } from "@/lib/db/providerNodeSelect"; +import { getCachedProviderConnections, getCachedProviderNodes } from "@/lib/db/readCache"; +import { isCommonChatGptWebRetiredProviderId } from "@/shared/constants/chatgptWebRetirement"; +import { getProviderById, getProviderAlias, resolveProviderId } from "@/shared/constants/providers"; + +function asRecord(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; +} + +const PROVIDER_SEARCH_PAIRS: string[][] = [ + ["nvidia", "nvidia_nim"], + ["kimi-coding", "kimi-coding-apikey"], + // The model layer canonicalizes `agy/` to `antigravity`, but the Antigravity + // CLI card stores its connection under `agy`. Same account, either id serves. + ["antigravity", "agy"], + // OpenCode connection card stores under `opencode`, but model alias resolves to `opencode-zen`. + ["opencode", "opencode-zen"], + // One Jina token works on api.jina.ai, r.jina.ai, and s.jina.ai. + // Requested id stays first so embed/rerank do not silently pick a + // Reader-only row when both cards are filled. jina-search has no + // dashboard card — it must still see jina-ai / jina-reader keys + // before falling through to JINA_AI_API_KEY. + ["jina-ai", "jina-reader", "jina-search"], +]; +/** Resolve provider aliases (e.g., nvidia -> nvidia_nim) for DB lookup. */ +export async function getProviderSearchPool(provider: string): Promise { + const canonicalProvider = resolveProviderId(provider); + const canonicalAlias = getProviderAlias(canonicalProvider); + if (isCommonChatGptWebRetiredProviderId(provider)) return []; + const group = PROVIDER_SEARCH_PAIRS.find((aliases) => aliases.includes(provider)); + if (group) return [provider, ...group.filter((id) => id !== provider)]; + + const searchPool = new Set([provider, canonicalProvider, canonicalAlias].filter(Boolean)); + + // Built-in providers already resolve through static ids/aliases. Only + // compatible/custom providers need provider_nodes expansion back to the + // generated internal connection ids. (#3058) + if (getProviderById(canonicalProvider)) { + return Array.from(searchPool); + } + + // Custom provider nodes are referenced by user-facing prefixes in combos + // (for example "78code/gpt-5.4"), but live credentials are stored under + // internal provider ids like openai-compatible-responses-. + try { + const providerNodes = await getCachedProviderNodes(); + const compatibleNodes = Array.isArray(providerNodes) ? providerNodes : []; + const nodeTypes = new Map(); + for (const node of compatibleNodes) { + const nodeRecord = asRecord(node); + const nodeId = typeof nodeRecord.id === "string" ? nodeRecord.id.trim() : ""; + if (!nodeId) continue; + const derivedType = nodeTypeFromId(nodeId); + nodeTypes.set(derivedType, (nodeTypes.get(derivedType) || 0) + 1); + } + + for (const node of compatibleNodes) { + const nodeRecord = asRecord(node); + const nodePrefix = typeof nodeRecord.prefix === "string" ? nodeRecord.prefix.trim() : ""; + const nodeId = typeof nodeRecord.id === "string" ? nodeRecord.id.trim() : ""; + if (!nodeId) continue; + if ( + nodePrefix && + (nodePrefix === provider || + nodePrefix === canonicalProvider || + nodePrefix === canonicalAlias) + ) { + searchPool.add(nodeId); + } + + // #10085: bridge the concrete uuid node id (what the chat path resolves, + // "-") to the GENERIC derived type id (what + // resolveProviderNodeForConnection also accepts for connection creation, + // #4421) -- and back. A connection created via the bare generic type + // (e.g. "openai-compatible-chat") must still be found when the chat path + // looks up the concrete node id, and vice versa. + // + // #10434: both bridging directions MUST require the derived type to be + // unambiguous (exactly one provider node of that type) before falling + // back to a generic-type match -- an explicit ownership check, not just + // a string-format coincidence. This mirrors the exact rule already + // enforced by selectProviderNodeForConnection() for connection CREATION + // (src/lib/db/providerNodeSelect.ts, #4421): "only when exactly one such + // node exists, so an ambiguous type never silently picks the wrong + // node". Without this guard on the generic->concrete direction, a bare + // generic-type lookup would pool in EVERY node sharing that derived + // type, including a connection scoped (via its own providerSpecificData + // baseUrl/headers) to one specific node -- leaking that node's + // credentials/upstream URL into a lookup for a different, unrelated + // node of the same generic type. + const derivedType = nodeTypeFromId(nodeId); + if (derivedType && derivedType !== nodeId) { + const typeIsUnambiguous = nodeTypes.get(derivedType) === 1; + if (typeIsUnambiguous) { + if (nodeId === provider || nodeId === canonicalProvider || nodeId === canonicalAlias) { + searchPool.add(derivedType); + } + if ( + derivedType === provider || + derivedType === canonicalProvider || + derivedType === canonicalAlias + ) { + searchPool.add(nodeId); + } + } + } + } + } catch { + // Best-effort alias expansion only. + } + + return Array.from(searchPool); +} + +/** Resolve the same account pool as direct auth, keeping the existing DB cache. */ +export async function getCachedProviderPoolConnections( + filter?: Record +): Promise { + if (typeof filter?.provider !== "string") return getCachedProviderConnections(filter); + const providers = await getProviderSearchPool(filter.provider); + const results = await Promise.all( + providers.map((provider) => getCachedProviderConnections({ ...filter, provider })) + ); + const seen = new Set(); + return results.flat().filter((connection) => { + const id = asRecord(connection).id; + if (typeof id !== "string" || !id || seen.has(id)) return false; + seen.add(id); + return true; + }); +} diff --git a/src/sse/services/auth.ts b/src/sse/services/auth.ts index 9fc36d552c..5db1c1965b 100644 --- a/src/sse/services/auth.ts +++ b/src/sse/services/auth.ts @@ -2,7 +2,7 @@ import type { CredentialSelectionOptions } from "./credentialSelectionOptions"; export type { CredentialSelectionOptions } from "./credentialSelectionOptions"; import { qoderSupportsCallerTools } from "@omniroute/open-sse/services/qoderCapabilities"; import { randomUUID } from "crypto"; -import { nodeTypeFromId } from "@/lib/db/providerNodeSelect"; +import { getProviderSearchPool } from "@omniroute/open-sse/services/providerConnectionPool.ts"; import { hydrateConnectionProviderSpecificData } from "./compatibleNodeBaseUrl.ts"; // #13452 import { extractGoogApiKeyHeader } from "./googApiKeyAuth.ts"; import { describeUpstreamFailure } from "@/shared/utils/upstreamError"; @@ -39,7 +39,6 @@ import { } from "@/lib/providers/peakHourProtection"; import { buildJinaEnvCredentials } from "@/lib/providers/jina"; import { buildGeminiEnvCredentials } from "@/lib/providers/gemini"; -import { isCommonChatGptWebRetiredProviderId } from "@/shared/constants/chatgptWebRetirement"; import { toNumber } from "@/shared/utils/numeric"; import { timingSafeCompare } from "@/shared/utils/timingSafeCompare"; import { isMicrosoftDesignerWebRetiredProviderId } from "@/shared/constants/designerWebRetirement"; @@ -144,7 +143,6 @@ import { } from "@omniroute/open-sse/services/codexAccount/index.ts"; import { getProviderById, - getProviderAlias, resolveProviderId, NOAUTH_PROVIDERS, WEB_COOKIE_PROVIDERS, @@ -925,110 +923,6 @@ export { fisherYatesShuffle, getNextFromDeckSync as getNextFromDeck }; // backwards compat with existing imports (e.g. googApiKeyAuth.ts). export { readHeaderValue, type AuthRequestHeaders } from "./headerReader.ts"; export { extractSessionAffinityKey } from "./sessionAffinityPin"; -const PROVIDER_SEARCH_PAIRS: string[][] = [ - ["nvidia", "nvidia_nim"], - ["kimi-coding", "kimi-coding-apikey"], - // The model layer canonicalizes `agy/` to `antigravity`, but the Antigravity - // CLI card stores its connection under `agy`. Same account, either id serves. - ["antigravity", "agy"], - // OpenCode connection card stores under `opencode`, but model alias resolves to `opencode-zen`. - ["opencode", "opencode-zen"], - // One Jina token works on api.jina.ai, r.jina.ai, and s.jina.ai. - // Requested id stays first so embed/rerank do not silently pick a - // Reader-only row when both cards are filled. jina-search has no - // dashboard card — it must still see jina-ai / jina-reader keys - // before falling through to JINA_AI_API_KEY. - ["jina-ai", "jina-reader", "jina-search"], -]; -/** Resolve provider aliases (e.g., nvidia -> nvidia_nim) for DB lookup. */ -async function getProviderSearchPool(provider: string): Promise { - const canonicalProvider = resolveProviderId(provider); - const canonicalAlias = getProviderAlias(canonicalProvider); - if (isCommonChatGptWebRetiredProviderId(provider)) return []; - const group = PROVIDER_SEARCH_PAIRS.find((aliases) => aliases.includes(provider)); - if (group) return [provider, ...group.filter((id) => id !== provider)]; - - const searchPool = new Set([provider, canonicalProvider, canonicalAlias].filter(Boolean)); - - // Built-in providers already resolve through static ids/aliases. Only - // compatible/custom providers need provider_nodes expansion back to the - // generated internal connection ids. (#3058) - if (getProviderById(canonicalProvider)) { - return Array.from(searchPool); - } - - // Custom provider nodes are referenced by user-facing prefixes in combos - // (for example "78code/gpt-5.4"), but live credentials are stored under - // internal provider ids like openai-compatible-responses-. - try { - const providerNodes = await getCachedProviderNodes(); - const compatibleNodes = Array.isArray(providerNodes) ? providerNodes : []; - const nodeTypes = new Map(); - for (const node of compatibleNodes) { - const nodeRecord = asRecord(node); - const nodeId = typeof nodeRecord.id === "string" ? nodeRecord.id.trim() : ""; - if (!nodeId) continue; - const derivedType = nodeTypeFromId(nodeId); - nodeTypes.set(derivedType, (nodeTypes.get(derivedType) || 0) + 1); - } - - for (const node of compatibleNodes) { - const nodeRecord = asRecord(node); - const nodePrefix = typeof nodeRecord.prefix === "string" ? nodeRecord.prefix.trim() : ""; - const nodeId = typeof nodeRecord.id === "string" ? nodeRecord.id.trim() : ""; - if (!nodeId) continue; - if ( - nodePrefix && - (nodePrefix === provider || - nodePrefix === canonicalProvider || - nodePrefix === canonicalAlias) - ) { - searchPool.add(nodeId); - } - - // #10085: bridge the concrete uuid node id (what the chat path resolves, - // "-") to the GENERIC derived type id (what - // resolveProviderNodeForConnection also accepts for connection creation, - // #4421) -- and back. A connection created via the bare generic type - // (e.g. "openai-compatible-chat") must still be found when the chat path - // looks up the concrete node id, and vice versa. - // - // #10434: both bridging directions MUST require the derived type to be - // unambiguous (exactly one provider node of that type) before falling - // back to a generic-type match -- an explicit ownership check, not just - // a string-format coincidence. This mirrors the exact rule already - // enforced by selectProviderNodeForConnection() for connection CREATION - // (src/lib/db/providerNodeSelect.ts, #4421): "only when exactly one such - // node exists, so an ambiguous type never silently picks the wrong - // node". Without this guard on the generic->concrete direction, a bare - // generic-type lookup would pool in EVERY node sharing that derived - // type, including a connection scoped (via its own providerSpecificData - // baseUrl/headers) to one specific node -- leaking that node's - // credentials/upstream URL into a lookup for a different, unrelated - // node of the same generic type. - const derivedType = nodeTypeFromId(nodeId); - if (derivedType && derivedType !== nodeId) { - const typeIsUnambiguous = nodeTypes.get(derivedType) === 1; - if (typeIsUnambiguous) { - if (nodeId === provider || nodeId === canonicalProvider || nodeId === canonicalAlias) { - searchPool.add(derivedType); - } - if ( - derivedType === provider || - derivedType === canonicalProvider || - derivedType === canonicalAlias - ) { - searchPool.add(nodeId); - } - } - } - } - } catch { - // Best-effort alias expansion only. - } - - return Array.from(searchPool); -} function invalidateManagedLease( options: CredentialSelectionOptions, diff --git a/stryker.conf.json b/stryker.conf.json index bf7e259e0a..b2af9902eb 100644 --- a/stryker.conf.json +++ b/stryker.conf.json @@ -604,6 +604,7 @@ "tests/unit/github-copilot-model-not-supported-learning-15634.test.ts", "tests/unit/alibaba-free-quota-drain-preserves-model-catalog-cache.test.ts", "tests/unit/upstreamErrorEnvelope.test.ts", + "tests/unit/provider-alias-combo-pool-12844.test.ts", "tests/unit/model-capacity-failure-scope-15788.test.ts", "tests/unit/provider-node-local-dispatch-13452.test.ts" ], diff --git a/tests/unit/combo-custom-provider-resolution.test.ts b/tests/unit/combo-custom-provider-resolution.test.ts index cf2108be9b..dae3ee9f70 100644 --- a/tests/unit/combo-custom-provider-resolution.test.ts +++ b/tests/unit/combo-custom-provider-resolution.test.ts @@ -160,7 +160,7 @@ test("#2778 matching logic: node with prefix=flymux and id=UUID-id still matches test("custom provider auth lookup search pool maps alias prefixes to internal provider ids", async () => { const authSrc = fs.readFileSync( - path.resolve(__dirname, "../../src/sse/services/auth.ts"), + path.resolve(__dirname, "../../open-sse/services/providerConnectionPool.ts"), "utf8" ); diff --git a/tests/unit/provider-alias-combo-pool-12844.test.ts b/tests/unit/provider-alias-combo-pool-12844.test.ts new file mode 100644 index 0000000000..20fb53b515 --- /dev/null +++ b/tests/unit/provider-alias-combo-pool-12844.test.ts @@ -0,0 +1,185 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import type { ResolvedComboTarget } from "../../open-sse/services/combo/types.ts"; + +const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "alias-combo-12844-")); +process.env.DATA_DIR = dataDir; +const { resetDbInstance } = await import("../../src/lib/db/core.ts"); +const { createProviderConnection, updateProviderConnection } = + await import("../../src/lib/db/providers.ts"); +const { getProviderCredentials } = await import("../../src/sse/services/auth.ts"); +const { buildAutoCandidates, handleComboChat } = await import("../../open-sse/services/combo.ts"); +const { expandPromptCacheAffinityTargets } = + await import("../../open-sse/services/combo/promptCacheAffinity.ts"); +const { expandTargetsByQuotaAwareConnections } = + await import("../../open-sse/services/combo/quotaStrategies.ts"); +const { applyRequestTagRouting } = await import("../../open-sse/services/combo/autoStrategy.ts"); +const { registerQuotaFetcher } = await import("../../open-sse/services/quotaPreflight.ts"); +const noOpLog = { info() {}, debug() {}, warn() {}, error() {} }; + +// Quota is deterministic and never contacts an upstream in these routing tests. +for (const provider of ["antigravity", "agy", "opencode", "opencode-zen", "gemini"]) + registerQuotaFetcher(provider, async () => ({ used: 0, total: 100, percentUsed: 0 })); + +test.after(() => { + resetDbInstance(); + fs.rmSync(dataDir, { recursive: true, force: true }); +}); + +async function seed(provider: string, name: string) { + return createProviderConnection({ + provider, + name, + authType: "oauth", + accessToken: "fixture-token", + isActive: true, + testStatus: "active", + providerSpecificData: { projectId: "fixture-project", tags: ["fixture"] }, + }); +} +function target(provider: string, allowedConnectionIds?: string[]): ResolvedComboTarget { + const modelStr = `${provider}/gemini-3-pro`; + return { + kind: "model", + provider, + providerId: provider, + modelStr, + stepId: modelStr, + executionKey: modelStr, + connectionId: null, + weight: 1, + label: null, + allowedConnectionIds, + }; +} + +for (const [stored, requested] of [ + ["agy", "antigravity"], + ["antigravity", "agy"], + ["opencode", "opencode-zen"], + ["opencode-zen", "opencode"], +]) { + test(`#12844 ${stored} account is visible to ${requested} direct, auto and affinity routing`, async () => { + const connection = await seed(stored, `${stored}-only`); + const step = target(requested, [connection.id]); + const direct = await getProviderCredentials(requested, null, [connection.id]); + assert.equal(direct?.connectionId, connection.id, "direct routing control already works"); + const candidates = await buildAutoCandidates([step], `alias-${stored}`); + assert.deepEqual( + candidates.map((entry) => entry.connectionId), + [connection.id] + ); + const affinity = await expandPromptCacheAffinityTargets([step]); + assert.deepEqual( + affinity.map((entry) => entry.connectionId), + [connection.id] + ); + }); +} + +test("#12844 quota expansion intersects aliases with step/API-key restrictions and rejects foreign pins", async () => { + const allowed = await seed("agy", "allowed"); + const denied = await seed("antigravity", "denied"); + const foreign = await seed("gemini", "foreign"); + const result = await expandTargetsByQuotaAwareConnections( + [target("antigravity", [allowed.id, denied.id])], + "alias-quota", + noOpLog, + [allowed.id] + ); + assert.deepEqual( + result.expandedTargets.map((entry) => entry.connectionId), + [allowed.id] + ); + const wrongPin = { ...target("antigravity"), connectionId: foreign.id }; + const foreignResult = await expandTargetsByQuotaAwareConnections( + [wrongPin, { ...target("gemini"), connectionId: foreign.id }], + "alias-foreign-pin", + noOpLog + ); + assert.deepEqual( + foreignResult.expandedTargets.map((entry) => entry.provider), + ["gemini"] + ); + const blocked = await getProviderCredentials("antigravity", null, [foreign.id]); + assert.equal(blocked?.blockedByKeyPolicy, true); +}); + +test("#12844 alias pool deduplicates accounts and tag routing preserves the step allowlist", async () => { + const first = await seed("agy", "first"); + const second = await seed("antigravity", "second"); + const step = target("antigravity", [first.id, second.id]); + const candidates = await buildAutoCandidates([step], "alias-dedup"); + assert.deepEqual( + candidates.map((entry) => entry.connectionId).sort(), + [first.id, second.id].sort() + ); + const tagged = await applyRequestTagRouting( + [target("antigravity", [first.id])], + { metadata: { tags: ["fixture"] } }, + noOpLog + ); + assert.deepEqual(tagged[0]?.allowedConnectionIds, [first.id]); +}); + +test("#12844 round-robin dispatch selects an aliased account within API-key and step restrictions", async () => { + const allowed = await seed("agy", "rr-allowed"); + await seed("antigravity", "rr-denied"); + const seen: string[] = []; + for (let i = 0; i < 3; i++) { + const response = await handleComboChat({ + body: { model: "alias-rr", messages: [{ role: "user", content: "hello" }] }, + combo: { + name: "alias-rr", + strategy: "round-robin", + models: [ + { kind: "model", model: "antigravity/gemini-3-pro", allowedConnectionIds: [allowed.id] }, + ], + }, + apiKeyAllowedConnections: [allowed.id], + log: noOpLog, + handleSingleModel: async (_body, _model, resolved) => { + const credentials = await getProviderCredentials( + "antigravity", + null, + resolved.allowedConnectionIds, + "gemini-3-pro", + { forcedConnectionId: resolved.connectionId } + ); + assert.equal(credentials?.connectionId, allowed.id); + seen.push(credentials.connectionId); + return Response.json({ choices: [{ message: { content: "fixture" } }] }); + }, + }); + assert.equal(response.status, 200); + } + assert.deepEqual(seen, [allowed.id, allowed.id, allowed.id]); +}); + +test("#12844 sticky health sees a banned alias account and drops its pin", async () => { + const sticky = await import("../../open-sse/services/combo/sessionStickiness.ts"); + const healthy = await seed("antigravity", "sticky-healthy"); + const banned = await seed("agy", "sticky-banned"); + await updateProviderConnection(banned.id, { testStatus: "banned" }); + const targets = [healthy, banned].map((connection) => ({ + ...target("antigravity"), + connectionId: connection.id, + })); + const messages = [{ role: "user", content: "alias sticky fixture" }]; + sticky.__setStickinessHeadroomFetcherForTests(async () => ({ util5h: 0, util7d: 0 })); + try { + const initial = await sticky.applySessionStickiness(targets, messages, "alias-health"); + assert.ok(initial.messageHash); + sticky.recordStickyBinding(initial.messageHash, banned.id, "alias-health"); + const result = await sticky.applySessionStickiness(targets, messages, "alias-health"); + assert.equal(result.stuck, false); + assert.equal(result.targets[0].connectionId, healthy.id); + assert.equal(sticky.peekStickyConnectionId(initial.messageHash), null); + } finally { + sticky.clearAllStickyBindings(); + sticky.__setStickinessHeadroomFetcherForTests(null); + } +});