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/app/(dashboard)/home/ProviderQuotaWidget.tsx b/src/app/(dashboard)/home/ProviderQuotaWidget.tsx index eb267fafdb..41cd067db2 100644 --- a/src/app/(dashboard)/home/ProviderQuotaWidget.tsx +++ b/src/app/(dashboard)/home/ProviderQuotaWidget.tsx @@ -175,7 +175,11 @@ export default function ProviderQuotaWidget({ autoRefreshInterval = 0 }: Provide onClick={refreshAll} disabled={refreshingAll || loading} className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-border bg-bg-subtle text-xs font-medium text-text-main disabled:opacity-50 disabled:cursor-not-allowed hover:bg-surface transition-colors" - title={autoRefreshIntervalMs > 0 ? tr("autoRefreshing", "Auto-refreshing") : tr("refreshAll", "Refresh All")} + title={ + autoRefreshIntervalMs > 0 + ? tr("autoRefreshing", "Auto-refreshing") + : tr("refreshAll", "Refresh All") + } > 0 ? `${tr("autoRefreshing", "Auto-refreshing")} ${formatAutoRefreshCountdown( - Math.max(0, autoRefreshIntervalMs - (autoRefreshClock - lastRefreshAllAtRef.current)) + Math.max( + 0, + autoRefreshIntervalMs - (autoRefreshClock - lastRefreshAllAtRef.current) + ) )}` : tr("refreshAll", "Refresh All")} @@ -224,7 +231,7 @@ export default function ProviderQuotaWidget({ autoRefreshInterval = 0 }: Provide className="rounded-lg border border-border bg-surface/40 p-3 flex flex-col gap-2" >
- + {provider.charAt(0).toUpperCase() + provider.slice(1)} diff --git a/src/sse/services/auth.ts b/src/sse/services/auth.ts index 350e9f2b4d..0652c277eb 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, @@ -799,7 +801,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); @@ -810,7 +812,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. (#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 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); } /** @@ -885,7 +914,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" + ); +}); diff --git a/tests/unit/provider-quota-widget-icon-prop.test.ts b/tests/unit/provider-quota-widget-icon-prop.test.ts new file mode 100644 index 0000000000..be3e4af6b5 --- /dev/null +++ b/tests/unit/provider-quota-widget-icon-prop.test.ts @@ -0,0 +1,22 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", ".."); +const PROVIDER_QUOTA_WIDGET_PATH = join(ROOT, "src/app/(dashboard)/home/ProviderQuotaWidget.tsx"); + +const providerQuotaWidgetSrc = readFileSync(PROVIDER_QUOTA_WIDGET_PATH, "utf8"); + +test("ProviderQuotaWidget passes provider IDs using ProviderIcon's providerId prop", () => { + assert.ok( + providerQuotaWidgetSrc.includes(""), + "ProviderQuotaWidget must pass provider through ProviderIcon's providerId prop" + ); + assert.equal( + providerQuotaWidgetSrc.includes(" { assert.equal(getKnownPlan(""), null); }); -test("knownProviders() returns exactly 6 entries", () => { - assert.equal(knownProviders().length, 6); +test("knownProviders() returns exactly 10 entries", () => { + assert.equal(knownProviders().length, 10); }); -test("knownProviders() includes codex/glm/minimax/bailian/kimi/alibaba", () => { +test("knownProviders() includes the full registry set", () => { const list = knownProviders() as readonly string[]; - for (const p of ["codex", "glm", "minimax", "bailian", "kimi", "alibaba"]) { + for (const p of [ + "codex", + "claude", + "glm", + "minimax", + "deepseek", + "bailian", + "kimi", + "kimi-coding", + "xiaomi-mimo", + "alibaba", + ]) { assert.ok(list.includes(p), `missing ${p}`); } });