Merge main into release/v3.8.8

Back-merge to resolve PR #2930 (release/v3.8.8 -> main) conflicts. Release is a
superset of main's features, so all ~44 content conflicts resolved to the
release ("ours") version; generated .source/* dropped.

Reconciliation:
- auth.ts: port #3058 (getProviderSearchPool expands custom provider_nodes
  prefixes to internal connection ids) — release lacked this main fix.
- quota-plan-registry.test.ts: align knownProviders() 6 -> 10 (pre-existing
  stale assertion vs the registry).
This commit is contained in:
diegosouzapw
2026-06-02 17:46:48 -03:00
6 changed files with 117 additions and 11 deletions

View File

@@ -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(

View File

@@ -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")
}
>
<span
className={`material-symbols-outlined text-[16px] ${refreshingAll ? "animate-spin" : ""}`}
@@ -187,7 +191,10 @@ export default function ProviderQuotaWidget({ autoRefreshInterval = 0 }: Provide
? tr("refreshing", "Refreshing")
: autoRefreshIntervalMs > 0
? `${tr("autoRefreshing", "Auto-refreshing")} ${formatAutoRefreshCountdown(
Math.max(0, autoRefreshIntervalMs - (autoRefreshClock - lastRefreshAllAtRef.current))
Math.max(
0,
autoRefreshIntervalMs - (autoRefreshClock - lastRefreshAllAtRef.current)
)
)}`
: tr("refreshAll", "Refresh All")}
</span>
@@ -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"
>
<div className="flex items-center gap-2">
<ProviderIcon provider={provider} size={18} />
<ProviderIcon providerId={provider} size={18} />
<span className="font-medium text-sm truncate">
{provider.charAt(0).toUpperCase() + provider.slice(1)}
</span>

View File

@@ -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<string[]> {
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-<uuid>.
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 }))
);

View File

@@ -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<string\[]>/,
"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"
);
});

View File

@@ -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("<ProviderIcon providerId={provider} size={18} />"),
"ProviderQuotaWidget must pass provider through ProviderIcon's providerId prop"
);
assert.equal(
providerQuotaWidgetSrc.includes("<ProviderIcon provider={provider}"),
false,
"ProviderQuotaWidget must not pass an unsupported provider prop to ProviderIcon"
);
});

View File

@@ -64,13 +64,24 @@ test("getKnownPlan('') returns null", () => {
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}`);
}
});