diff --git a/src/app/(dashboard)/dashboard/combos/page.tsx b/src/app/(dashboard)/dashboard/combos/page.tsx index e2af3ab665..27af619bd1 100644 --- a/src/app/(dashboard)/dashboard/combos/page.tsx +++ b/src/app/(dashboard)/dashboard/combos/page.tsx @@ -2512,7 +2512,19 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, combo }; const handleAddModel = (model) => { - const nextEntry = { model: model.value, weight: 0 }; + const qualifiedModel = typeof model?.value === "string" ? model.value : ""; + const parsedModel = parseQualifiedModel(qualifiedModel); + const resolvedProviderId = + resolveComboBuilderProviderId(model?.providerId, builderProviders) || + resolveComboBuilderProviderId(parsedModel?.providerId, builderProviders) || + (typeof model?.providerId === "string" && model.providerId.trim()) || + parsedModel?.providerId || + null; + const nextEntry = { + model: qualifiedModel, + ...(resolvedProviderId ? { providerId: resolvedProviderId } : {}), + weight: 0, + }; if (hasExactModelStepDuplicate(models, nextEntry)) { setBuilderError( getI18nOrFallback( diff --git a/src/sse/services/auth.ts b/src/sse/services/auth.ts index 65a69f194f..fd645bc84c 100644 --- a/src/sse/services/auth.ts +++ b/src/sse/services/auth.ts @@ -1,6 +1,7 @@ import { randomUUID, createHash } from "crypto"; import { getProviderConnections, + getProviderNodes, validateApiKey, updateProviderConnection, getSettings, @@ -44,6 +45,7 @@ import { import { looksLikeQuotaExhausted } from "@/shared/utils/classify429"; import { getCodexModelScope } from "@omniroute/open-sse/executors/codex.ts"; import { + getProviderById, getProviderAlias, resolveProviderId, NOAUTH_PROVIDERS, @@ -791,7 +793,7 @@ export { fisherYatesShuffle, getNextFromDeckSync as getNextFromDeck }; /** * Resolve provider aliases (e.g., nvidia -> nvidia_nim) for DB lookup */ -function getProviderSearchPool(provider: string): string[] { +async function getProviderSearchPool(provider: string): Promise { const canonicalProvider = resolveProviderId(provider); const canonicalAlias = getProviderAlias(canonicalProvider); @@ -802,7 +804,34 @@ function getProviderSearchPool(provider: string): string[] { return ["nvidia_nim", "nvidia"]; } - return Array.from(new Set([provider, canonicalProvider, canonicalAlias].filter(Boolean))); + 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. + 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 getProviderNodes(); + for (const node of Array.isArray(providerNodes) ? providerNodes : []) { + const nodeRecord = asRecord(node); + const nodePrefix = typeof nodeRecord.prefix === "string" ? nodeRecord.prefix.trim() : ""; + const nodeId = typeof nodeRecord.id === "string" ? nodeRecord.id.trim() : ""; + if (!nodePrefix || !nodeId) continue; + if (nodePrefix === provider || nodePrefix === canonicalProvider || nodePrefix === canonicalAlias) { + searchPool.add(nodeId); + } + } + } catch { + // Best-effort alias expansion only. + } + + return Array.from(searchPool); } /** @@ -864,7 +893,7 @@ export async function getProviderCredentials( ); // Fix #922: Check for aliases (nvidia/nvidia_nim) to ensure credentials are found - const providersToSearch = getProviderSearchPool(provider); + const providersToSearch = await getProviderSearchPool(provider); const connectionResults = await Promise.all( providersToSearch.map((p) => getProviderConnections({ provider: p, isActive: true })) ); diff --git a/tests/unit/combo-custom-provider-resolution.test.ts b/tests/unit/combo-custom-provider-resolution.test.ts index fcd5933c9f..ae8a30bad0 100644 --- a/tests/unit/combo-custom-provider-resolution.test.ts +++ b/tests/unit/combo-custom-provider-resolution.test.ts @@ -149,3 +149,28 @@ test("#2778 matching logic: node with prefix=flymux and id=UUID-id still matches assert.ok(matchByPrefixOrId !== undefined, "Alias-based match must still work after the fix"); assert.strictEqual(matchByPrefixOrId?.id, FAKE_UUID_NODE_ID); }); + +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"), "utf8"); + + assert.match( + authSrc, + /async function getProviderSearchPool\(provider: string\): Promise/, + "getProviderSearchPool should be async so it can expand custom provider aliases via provider_nodes" + ); + assert.match( + authSrc, + /getProviderNodes\(/, + "auth lookup should read provider_nodes to map custom prefixes like 78code/micu back to internal provider ids" + ); + assert.match( + authSrc, + /nodePrefix === provider \|\| nodePrefix === canonicalProvider \|\| nodePrefix === canonicalAlias/, + "auth lookup should match provider node prefixes against the requested alias/canonical provider values" + ); + assert.match( + authSrc, + /searchPool\.add\(nodeId\)/, + "auth lookup should add the matched custom provider node id into the credential search pool" + ); +});