mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-10-19 05:20:05 +00:00
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.
This commit is contained in:
parent
c49f75a181
commit
6f9a6d12e6
14 changed files with 348 additions and 128 deletions
1
changelog.d/fixes/12844-combo-provider-alias-pools.md
Normal file
1
changelog.d/fixes/12844-combo-provider-alias-pools.md
Normal file
|
|
@ -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)
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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<Record<string, unknown>>;
|
||||
|
|
@ -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<Record<string, unknown>>;
|
||||
|
|
|
|||
|
|
@ -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 }>)
|
||||
|
|
|
|||
|
|
@ -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<boole
|
|||
const provider = parseModel(pinnedModel).provider;
|
||||
if (!provider) return false;
|
||||
const circuitState = getCircuitBreaker(provider)?.getStatus?.()?.state;
|
||||
const connections = (await getCachedProviderConnections({
|
||||
const connections = (await getCachedProviderPoolConnections({
|
||||
provider,
|
||||
isActive: true,
|
||||
})) as Array<{
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import {
|
|||
analyzePrefix,
|
||||
generatePromptCacheKey,
|
||||
} from "../../../src/lib/promptCache/prefixAnalyzer.ts";
|
||||
import { getCachedProviderConnections } from "../../../src/lib/db/readCache";
|
||||
import { getCachedProviderPoolConnections } from "../providerConnectionPool.ts";
|
||||
import { parseModel } from "../model.ts";
|
||||
import type { ResolvedComboTarget } from "./types.ts";
|
||||
import { getOAuthSessionAvailability } from "../oauthSessionOccupancy.ts";
|
||||
|
|
@ -199,7 +199,7 @@ export async function expandPromptCacheAffinityTargets(
|
|||
await Promise.all(
|
||||
providers.map(async (provider) => {
|
||||
try {
|
||||
const connections = (await getCachedProviderConnections({
|
||||
const connections = (await getCachedProviderPoolConnections({
|
||||
provider,
|
||||
isActive: true,
|
||||
})) as Array<Record<string, unknown>>;
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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<Record<string, unknown>>).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" &&
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>
|
||||
) => Promise<StickyConnectionHealth[]>;
|
||||
const connections = (await getCachedProviderConnections({
|
||||
const connections = (await getCachedProviderPoolConnections({
|
||||
provider,
|
||||
isActive: true,
|
||||
})) as Array<StickyConnectionHealth & { id?: string }>;
|
||||
|
|
|
|||
133
open-sse/services/providerConnectionPool.ts
Normal file
133
open-sse/services/providerConnectionPool.ts
Normal file
|
|
@ -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<string, unknown> {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: {};
|
||||
}
|
||||
|
||||
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<string[]> {
|
||||
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-<uuid>.
|
||||
try {
|
||||
const providerNodes = await getCachedProviderNodes();
|
||||
const compatibleNodes = Array.isArray(providerNodes) ? providerNodes : [];
|
||||
const nodeTypes = new Map<string, number>();
|
||||
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,
|
||||
// "<generic-type>-<uuid>") 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<string, unknown>
|
||||
): Promise<unknown[]> {
|
||||
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<string>();
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
|
@ -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<string[]> {
|
||||
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-<uuid>.
|
||||
try {
|
||||
const providerNodes = await getCachedProviderNodes();
|
||||
const compatibleNodes = Array.isArray(providerNodes) ? providerNodes : [];
|
||||
const nodeTypes = new Map<string, number>();
|
||||
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,
|
||||
// "<generic-type>-<uuid>") 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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
);
|
||||
|
||||
|
|
|
|||
185
tests/unit/provider-alias-combo-pool-12844.test.ts
Normal file
185
tests/unit/provider-alias-combo-pool-12844.test.ts
Normal file
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue