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}`);
}
});