From 3d372429930724bc50cdf6131326ed458f50db02 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Wed, 24 Jun 2026 14:03:21 -0300 Subject: [PATCH] fix(dashboard): show custom provider given-name instead of internal id across dashboard pages (#4603) (#4960) Integrated into release/v3.8.36 (fixes #4603) --- CHANGELOG.md | 4 ++ .../dashboard/analytics/ComboHealthTab.tsx | 14 +++- .../analytics/CompressionAnalyticsTab.tsx | 4 +- .../analytics/ProviderUtilizationTab.tsx | 8 ++- .../analytics/RouteExplainabilityTab.tsx | 15 ++-- src/app/(dashboard)/dashboard/cache/page.tsx | 6 +- .../dashboard/costs/CostOverviewTab.tsx | 5 +- .../health/ProviderHealthAutopilotCard.tsx | 4 +- src/app/(dashboard)/dashboard/health/page.tsx | 32 ++++----- .../dashboard/provider-stats/page.tsx | 53 +++++++++------ .../dashboard/runtime/RuntimePageClient.tsx | 12 ++-- src/lib/display/useProviderNodeMap.ts | 68 +++++++++++++++++++ tests/unit/display-and-error-utils.test.ts | 27 ++++++++ 13 files changed, 195 insertions(+), 57 deletions(-) create mode 100644 src/lib/display/useProviderNodeMap.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b248707f8..6e5fc041c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,10 @@ _In development — bullets added per PR; finalized at release._ - **feat(providers):** update volcengine-ark model list, adding DeepSeek-V4-Flash and DeepSeek-V4-Pro. (thanks @kenlin8827) +### 🔧 Bug Fixes + +- **fix(dashboard):** show custom provider given-name instead of internal id across dashboard pages — cache, combo health, compression analytics, cost overview, health/autopilot, provider stats, route explainability, provider utilization, runtime. Adds shared `resolveProviderName` resolver and `useProviderNodeMap` hook. (#4603) + --- ## [3.8.35] — 2026-06-23 diff --git a/src/app/(dashboard)/dashboard/analytics/ComboHealthTab.tsx b/src/app/(dashboard)/dashboard/analytics/ComboHealthTab.tsx index dda2143082..62684d51fd 100644 --- a/src/app/(dashboard)/dashboard/analytics/ComboHealthTab.tsx +++ b/src/app/(dashboard)/dashboard/analytics/ComboHealthTab.tsx @@ -3,6 +3,7 @@ import Link from "next/link"; import { useCallback, useEffect, useMemo, useState } from "react"; import { useTranslations } from "next-intl"; +import { useProviderNodeMap, resolveProviderName } from "@/lib/display/useProviderNodeMap"; import Card from "@/shared/components/Card"; import Badge from "@/shared/components/Badge"; import { Skeleton, Spinner } from "@/shared/components/Loading"; @@ -156,6 +157,7 @@ function DistributionBar({ label, value, meta }: { label: string; value: number; } function ComboForecastPanel({ forecast }: { forecast: ComboForecastMetrics }) { + const nodeMap = useProviderNodeMap(); const topTargets = useMemo( () => [...forecast.targets] @@ -227,7 +229,8 @@ function ComboForecastPanel({ forecast }: { forecast: ComboForecastMetrics }) { {target.label || target.model}
- {target.provider} · traffic {formatShare(target.trafficShare)} + {resolveProviderName(target.provider, nodeMap)} · traffic{" "} + {formatShare(target.trafficShare)}
@@ -379,6 +382,7 @@ function ComboAutopilotPanel({ report }: { report: ComboAutopilotReport }) { } function ComboScoringInspectorPanel({ inspector }: { inspector: ComboScoringInspectorCombo }) { + const nodeMap = useProviderNodeMap(); const topTargets = inspector.targets.slice(0, 3); return ( @@ -431,7 +435,8 @@ function ComboScoringInspectorPanel({ inspector }: { inspector: ComboScoringInsp #{target.rank} {target.label || target.model}
- {target.provider} · score {target.score.toFixed(3)} + {resolveProviderName(target.provider, nodeMap)} · score{" "} + {target.score.toFixed(3)}
@@ -488,6 +493,7 @@ function ComboHealthCard({ scoringInspector?: ComboScoringInspectorCombo; }) { const t = useTranslations("analytics"); + const nodeMap = useProviderNodeMap(); const sortedDistribution = useMemo( () => @@ -563,7 +569,9 @@ function ComboHealthCard({ >
-
{provider.provider}
+
+ {resolveProviderName(provider.provider, nodeMap)} +
Remaining quota {formatPercent(provider.remainingPct, 1)}
diff --git a/src/app/(dashboard)/dashboard/analytics/CompressionAnalyticsTab.tsx b/src/app/(dashboard)/dashboard/analytics/CompressionAnalyticsTab.tsx index d633b38010..fcc47b3862 100644 --- a/src/app/(dashboard)/dashboard/analytics/CompressionAnalyticsTab.tsx +++ b/src/app/(dashboard)/dashboard/analytics/CompressionAnalyticsTab.tsx @@ -9,6 +9,7 @@ import { useEffect, useState } from "react"; import { useTranslations } from "next-intl"; +import { useProviderNodeMap, resolveProviderName } from "@/lib/display/useProviderNodeMap"; interface CompressionAnalyticsSummary { totalRequests: number; @@ -122,6 +123,7 @@ export default function CompressionAnalyticsTab() { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [since, setSince] = useState<"24h" | "7d" | "30d" | "all">("24h"); + const nodeMap = useProviderNodeMap(); useEffect(() => { fetch(`/api/analytics/compression?since=${since}`) @@ -303,7 +305,7 @@ export default function CompressionAnalyticsTab() { {providers.map(([prov, data]) => ( ("24h"); const [aggregateBy, setAggregateBy] = useState<"provider" | "connection">("provider"); const [data, setData] = useState(null); @@ -350,7 +352,7 @@ export default function ProviderUtilizationTab() { key={provider} type="monotone" dataKey={provider} - name={provider} + name={resolveProviderName(provider, nodeMap)} stroke={providerColors.get(provider) ?? "var(--color-primary)"} strokeWidth={2.5} dot={false} @@ -374,7 +376,9 @@ export default function ProviderUtilizationTab() {
-

{point.provider}

+

+ {resolveProviderName(point.provider, nodeMap)} +

{t("providerUtilizationLatestSnapshot")}

diff --git a/src/app/(dashboard)/dashboard/analytics/RouteExplainabilityTab.tsx b/src/app/(dashboard)/dashboard/analytics/RouteExplainabilityTab.tsx index be2257f368..d5ea1b397b 100644 --- a/src/app/(dashboard)/dashboard/analytics/RouteExplainabilityTab.tsx +++ b/src/app/(dashboard)/dashboard/analytics/RouteExplainabilityTab.tsx @@ -6,6 +6,7 @@ import Badge from "@/shared/components/Badge"; import Card from "@/shared/components/Card"; import { Skeleton } from "@/shared/components/Loading"; import { cn } from "@/shared/utils/cn"; +import { useProviderNodeMap, resolveProviderName } from "@/lib/display/useProviderNodeMap"; type CallLogOption = { id: string; @@ -247,6 +248,7 @@ function FactorCard({ factor }: { factor: ExplanationFactor }) { } function TargetTimeline({ targets }: { targets: ExplainTarget[] }) { + const nodeMap = useProviderNodeMap(); if (targets.length === 0) { return
No related target evidence persisted yet.
; } @@ -267,7 +269,7 @@ function TargetTimeline({ targets }: { targets: ExplainTarget[] }) {
- {target.provider || "unknown"} / {target.model || "unknown"} + {resolveProviderName(target.provider, nodeMap)} / {target.model || "unknown"} {target.outcome === "selected" ? ( @@ -308,6 +310,7 @@ function replayAlignmentVariant(alignment: NonNullable
Exact runtime log
- {replay.runtime.provider || "unknown"} / {replay.runtime.model || "unknown"} + {resolveProviderName(replay.runtime.provider, nodeMap)} /{" "} + {replay.runtime.model || "unknown"}
{formatDate(replay.runtime.timestamp)} · {replay.runtime.comboStepId || "no step"} @@ -400,7 +404,7 @@ function WhyThisTargetCard({ replay }: { replay: DecisionReplay | undefined }) {
#{candidate.rank} - {candidate.provider} / {candidate.model} + {resolveProviderName(candidate.provider, nodeMap)} / {candidate.model} {candidate.isRuntimeSelected ? ( @@ -447,6 +451,7 @@ export default function RouteExplainabilityTab({ initialRequestId?: string; }) { const t = useTranslations("analytics") as AnalyticsTranslator; + const nodeMap = useProviderNodeMap(); const [logs, setLogs] = useState([]); const [selectedId, setSelectedId] = useState(initialRequestId); const [explanation, setExplanation] = useState(null); @@ -564,7 +569,7 @@ export default function RouteExplainabilityTab({ {logs.map((log) => ( ))} @@ -662,7 +667,7 @@ export default function RouteExplainabilityTab({
{[ - ["Provider", explanation.selectedTarget.provider || "n/a"], + ["Provider", resolveProviderName(explanation.selectedTarget.provider, nodeMap)], ["Model", explanation.selectedTarget.model || "n/a"], ["Account", explanation.selectedTarget.account || "n/a"], ["Connection", explanation.selectedTarget.connectionId || "n/a"], diff --git a/src/app/(dashboard)/dashboard/cache/page.tsx b/src/app/(dashboard)/dashboard/cache/page.tsx index 26cf749841..11ad9c89ba 100644 --- a/src/app/(dashboard)/dashboard/cache/page.tsx +++ b/src/app/(dashboard)/dashboard/cache/page.tsx @@ -4,6 +4,7 @@ import { useState, useEffect, useCallback, type ReactNode } from "react"; import { Card, Button, EmptyState } from "@/shared/components"; import { useNotificationStore } from "@/store/notificationStore"; import { useTranslations } from "next-intl"; +import { useProviderNodeMap, resolveProviderName } from "@/lib/display/useProviderNodeMap"; import CacheEntriesTab from "./components/CacheEntriesTab"; import ReasoningCacheTab from "./components/ReasoningCacheTab"; @@ -351,6 +352,7 @@ export default function CachePage() { const tSettings = useTranslations("settings"); const tRoot = useTranslations(); const notify = useNotificationStore(); + const nodeMap = useProviderNodeMap(); const [stats, setStats] = useState(null); const [loading, setLoading] = useState(true); @@ -600,7 +602,9 @@ export default function CachePage() { className="border-b border-border/15 last:border-b-0" > -
{provider}
+
+ {resolveProviderName(provider, nodeMap)} +
{totalRequests.toLocaleString()} {t("requests").toLowerCase()}
diff --git a/src/app/(dashboard)/dashboard/costs/CostOverviewTab.tsx b/src/app/(dashboard)/dashboard/costs/CostOverviewTab.tsx index 829fb5060f..3915e68a36 100644 --- a/src/app/(dashboard)/dashboard/costs/CostOverviewTab.tsx +++ b/src/app/(dashboard)/dashboard/costs/CostOverviewTab.tsx @@ -3,6 +3,7 @@ import { useEffect, useMemo, useState } from "react"; import { useSearchParams } from "next/navigation"; import { useLocale, useTranslations } from "next-intl"; +import { useProviderNodeMap, resolveProviderName } from "@/lib/display/useProviderNodeMap"; import { Card, EmptyState, SegmentedControl, CardSkeleton } from "@/shared/components"; import { getServiceTierDisplayLabel, @@ -282,6 +283,7 @@ function downloadFile(content: string, filename: string, mimeType: string) { export default function CostOverviewTab() { const t = useTranslations("costs"); const locale = useLocale(); + const nodeMap = useProviderNodeMap(); const searchParams = useSearchParams(); const apiKeyIdsParam = searchParams.get("apiKeyIds"); const selectedApiKeyIds = useMemo(() => parseApiKeyIds(apiKeyIdsParam), [apiKeyIdsParam]); @@ -377,7 +379,8 @@ export default function CostOverviewTab() { const providersByCost = [...(analytics?.byProvider || [])] .filter((provider) => (hasCostData ? provider.cost > 0 : provider.requests > 0)) - .sort((left, right) => (hasCostData ? right.cost - left.cost : right.requests - left.requests)); + .sort((left, right) => (hasCostData ? right.cost - left.cost : right.requests - left.requests)) + .map((row) => ({ ...row, provider: resolveProviderName(row.provider, nodeMap) })); const modelsByCost = [...(analytics?.byModel || [])] .filter((model) => (hasCostData ? model.cost > 0 : model.requests > 0)) .sort((left, right) => (hasCostData ? right.cost - left.cost : right.requests - left.requests)); diff --git a/src/app/(dashboard)/dashboard/health/ProviderHealthAutopilotCard.tsx b/src/app/(dashboard)/dashboard/health/ProviderHealthAutopilotCard.tsx index 339b12c7d9..15d264b015 100644 --- a/src/app/(dashboard)/dashboard/health/ProviderHealthAutopilotCard.tsx +++ b/src/app/(dashboard)/dashboard/health/ProviderHealthAutopilotCard.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { Card } from "@/shared/components"; import { getProviderDisplayName } from "@/lib/display/names"; +import { useProviderNodeMap, resolveProviderName } from "@/lib/display/useProviderNodeMap"; type AutopilotAction = { type: string; @@ -99,6 +100,7 @@ function formatConnectionEvidence(issue: AutopilotIssue): string | null { } export default function ProviderHealthAutopilotCard() { + const nodeMap = useProviderNodeMap(); const [report, setReport] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -241,7 +243,7 @@ export default function ProviderHealthAutopilotCard() {

- {getProviderDisplayName(provider.provider)} + {resolveProviderName(provider.provider, nodeMap)}

score {(provider.score * 100).toFixed(0)}% · active{" "} diff --git a/src/app/(dashboard)/dashboard/health/page.tsx b/src/app/(dashboard)/dashboard/health/page.tsx index 12605e5da9..30e2eeb5e0 100644 --- a/src/app/(dashboard)/dashboard/health/page.tsx +++ b/src/app/(dashboard)/dashboard/health/page.tsx @@ -16,6 +16,7 @@ import { useState, useEffect, useCallback } from "react"; import { Card } from "@/shared/components"; import { AI_PROVIDERS } from "@/shared/constants/providers"; import { getProviderDisplayName } from "@/lib/display/names"; +import { useProviderNodeMap, resolveProviderName } from "@/lib/display/useProviderNodeMap"; import { compareTr } from "@/shared/utils/turkishText"; import { useTranslations } from "next-intl"; import TelemetryCard from "./TelemetryCard"; @@ -59,6 +60,7 @@ export default function HealthPage() { const t = useTranslations("health"); const tc = useTranslations("common"); const tp = useTranslations("providers"); + const nodeMap = useProviderNodeMap(); const [data, setData] = useState(null); const [dbHealth, setDbHealth] = useState(null); const [dbHealthError, setDbHealthError] = useState(null); @@ -763,7 +765,10 @@ export default function HealthPage() { {unhealthy.map(([provider, cb]: [string, any]) => { const style = CB_STYLES[cb.state] || CB_STYLES.OPEN; const providerInfo = AI_PROVIDERS[provider]; - const displayName = getProviderDisplayName(provider, providerInfo); + const displayName = getProviderDisplayName( + provider, + nodeMap.get(provider) ?? providerInfo + ); return (

{healthy.map(([provider]) => { const providerInfo = AI_PROVIDERS[provider]; - const displayName = getProviderDisplayName(provider, providerInfo); + const displayName = getProviderDisplayName( + provider, + nodeMap.get(provider) ?? providerInfo + ); return (
12) displayName += ` (${customName.slice(0, 8)}…)`; - else if (customName) displayName += ` (${customName})`; - } else if (providerId.startsWith("anthropic-compatible-")) { - const customName = providerId.replace("anthropic-compatible-", ""); - displayName = tp("anthropicCompatibleName"); - providerInfo = { color: "#D97757", textIcon: "AC" }; - if (customName.length > 12) displayName += ` (${customName.slice(0, 8)}…)`; - else if (customName) displayName += ` (${customName})`; - } else { - displayName = getProviderDisplayName(providerId, providerInfo); - } + const displayName = resolveProviderName(providerId, nodeMap); return { providerId, displayName, providerInfo, connectionId, model }; }; diff --git a/src/app/(dashboard)/dashboard/provider-stats/page.tsx b/src/app/(dashboard)/dashboard/provider-stats/page.tsx index 9be2cb0a5a..da4e218a19 100644 --- a/src/app/(dashboard)/dashboard/provider-stats/page.tsx +++ b/src/app/(dashboard)/dashboard/provider-stats/page.tsx @@ -9,6 +9,7 @@ import { useState, useEffect, useCallback, Fragment } from "react"; import { Card } from "@/shared/components"; +import { useProviderNodeMap, resolveProviderName } from "@/lib/display/useProviderNodeMap"; interface ProviderStat { provider: string; @@ -33,7 +34,14 @@ interface ToolLatencyStat { measurementCount: number; } -type SortKey = "totalRequests" | "successfulRequests" | "avgLatencyMs" | "totalTokensIn" | "totalTokensOut" | "avgTtftAfterToolMs" | "avgGapAfterToolMs"; +type SortKey = + | "totalRequests" + | "successfulRequests" + | "avgLatencyMs" + | "totalTokensIn" + | "totalTokensOut" + | "avgTtftAfterToolMs" + | "avgGapAfterToolMs"; type SortDir = "asc" | "desc"; function formatNumber(n: number | null): string { @@ -55,6 +63,7 @@ function successRate(successful: number, total: number): string { } export default function ProviderStatsPage() { + const nodeMap = useProviderNodeMap(); const [data, setData] = useState<{ providers: ProviderStat[]; models: ModelStat[]; @@ -99,12 +108,11 @@ export default function ProviderStatsPage() { // Compute summary stats const totalRequests = data?.providers.reduce((s, p) => s + (p.totalRequests || 0), 0) ?? 0; const totalSuccessful = data?.providers.reduce((s, p) => s + (p.successfulRequests || 0), 0) ?? 0; - const avgLatency = - data?.providers.length - ? Math.round( - data.providers.reduce((s, p) => s + (p.avgLatencyMs || 0), 0) / data.providers.length - ) - : 0; + const avgLatency = data?.providers.length + ? Math.round( + data.providers.reduce((s, p) => s + (p.avgLatencyMs || 0), 0) / data.providers.length + ) + : 0; const activeProviders = data?.providers.length ?? 0; // Sorted providers @@ -112,8 +120,8 @@ export default function ProviderStatsPage() { if (sortKey === "avgTtftAfterToolMs" || sortKey === "avgGapAfterToolMs") { const aLatency = data?.toolLatency?.[a.provider] ?? null; const bLatency = data?.toolLatency?.[b.provider] ?? null; - const va = aLatency ? (aLatency[sortKey] as number) ?? 0 : 0; - const vb = bLatency ? (bLatency[sortKey] as number) ?? 0 : 0; + const va = aLatency ? ((aLatency[sortKey] as number) ?? 0) : 0; + const vb = bLatency ? ((bLatency[sortKey] as number) ?? 0) : 0; return sortDir === "desc" ? vb - va : va - vb; } const va = (a[sortKey] as number) ?? 0; @@ -294,17 +302,20 @@ export default function ProviderStatsPage() { {sortedProviders.map((p) => { const isExpanded = expandedProvider === p.provider; const models = modelsByProvider.get(p.provider) ?? []; - const rate = p.totalRequests > 0 ? (p.successfulRequests / p.totalRequests) * 100 : 0; + const rate = + p.totalRequests > 0 ? (p.successfulRequests / p.totalRequests) * 100 : 0; return ( - setExpandedProvider(isExpanded ? null : models.length > 0 ? p.provider : null) + setExpandedProvider( + isExpanded ? null : models.length > 0 ? p.provider : null + ) } > - {p.provider} + {resolveProviderName(p.provider, nodeMap)} {formatNumber(p.totalRequests)} @@ -363,7 +374,9 @@ export default function ProviderStatsPage() { Requests Success Rate - Avg Latency + + Avg Latency + @@ -444,7 +457,10 @@ export default function ProviderStatsPage() { {Object.entries(data.comboMetrics).map(([name, m]: [string, any]) => ( - + {name} {formatNumber(m.requestCount ?? m.totalRequests ?? 0)} @@ -456,7 +472,9 @@ export default function ProviderStatsPage() { {formatLatency(m.avgLatency ?? m.avgTotalMs ?? null)} - {typeof m.successRate === "number" ? `${(m.successRate * 100).toFixed(1)}%` : "—"} + {typeof m.successRate === "number" + ? `${(m.successRate * 100).toFixed(1)}%` + : "—"} ))} @@ -478,10 +496,7 @@ export default function ProviderStatsPage() { {Object.entries(data.telemetry).map(([key, val]: [string, any]) => { if (val == null || typeof val === "object") return null; return ( -
+

{key.replace(/([A-Z])/g, " $1").replace(/_/g, " ")}

diff --git a/src/app/(dashboard)/dashboard/runtime/RuntimePageClient.tsx b/src/app/(dashboard)/dashboard/runtime/RuntimePageClient.tsx index beed568a11..300d93e2e3 100644 --- a/src/app/(dashboard)/dashboard/runtime/RuntimePageClient.tsx +++ b/src/app/(dashboard)/dashboard/runtime/RuntimePageClient.tsx @@ -6,6 +6,7 @@ import Link from "next/link"; import Card from "@/shared/components/Card"; import ProviderIcon from "@/shared/components/ProviderIcon"; import ModelCooldownsCard from "./components/ModelCooldownsCard"; +import { useProviderNodeMap, resolveProviderName } from "@/lib/display/useProviderNodeMap"; type KnownBreakerState = "CLOSED" | "OPEN" | "HALF_OPEN" | "DEGRADED"; type BreakerState = KnownBreakerState | (string & {}); @@ -399,6 +400,7 @@ function diffSnapshots( export default function RuntimePageClient() { const t = useTranslations("runtime"); + const nodeMap = useProviderNodeMap(); const [health, setHealth] = useState(null); const [connections, setConnections] = useState([]); const [loading, setLoading] = useState(true); @@ -673,11 +675,13 @@ export default function RuntimePageClient() { key={b.provider} className="rounded-md border px-2.5 py-2 flex flex-col gap-0.5" style={{ borderColor: tone.ring, background: tone.bg }} - title={`${b.provider} · ${state || "UNKNOWN"} · failures ${b.failureCount}`} + title={`${resolveProviderName(b.provider, nodeMap)} · ${state || "UNKNOWN"} · failures ${b.failureCount}`} >
- {b.provider} + + {resolveProviderName(b.provider, nodeMap)} + {tone.label} @@ -721,7 +725,7 @@ export default function RuntimePageClient() {
- {c.provider}/{label} + {resolveProviderName(c.provider, nodeMap)}/{label}
{c.lastErrorType && (
@@ -1174,7 +1178,7 @@ function QuotaGroup({
{m.accountId ?? "—"} - {m.provider ? ` / ${m.provider}` : ""} + {m.provider ? ` / ${resolveProviderName(m.provider, nodeMap)}` : ""}
{m.window ?? ""}
diff --git a/src/lib/display/useProviderNodeMap.ts b/src/lib/display/useProviderNodeMap.ts new file mode 100644 index 0000000000..df152509f4 --- /dev/null +++ b/src/lib/display/useProviderNodeMap.ts @@ -0,0 +1,68 @@ +/** + * Shared resolver for custom provider display names. + * + * Provides: + * - `resolveProviderName(id, nodeMap)` — pure function for tests and SSR + * - `useProviderNodeMap()` — React hook that fetches /api/provider-nodes once + * and returns a Map + * + * Usage in components: + * const nodeMap = useProviderNodeMap(); + * const label = resolveProviderName(providerId, nodeMap); + * + * @module lib/display/useProviderNodeMap + */ + +"use client"; + +import { useEffect, useState } from "react"; +import { getProviderDisplayName, type ProviderNodeLike } from "./names"; + +export type ProviderNodeEntry = { name?: string | null; prefix?: string | null }; + +/** + * Pure resolver — usable in tests without React. + * + * Delegates to the canonical `getProviderDisplayName` helper, supplying the + * node entry from the map as the second argument so custom names take priority. + */ +export function resolveProviderName( + id: string | null | undefined, + nodeMap: Map | null | undefined +): string { + const node = id ? (nodeMap?.get(id) ?? null) : null; + return getProviderDisplayName(id, node as ProviderNodeLike | null); +} + +/** + * Fetches /api/provider-nodes once per mount and returns a stable Map. + * Returns an empty Map while loading or on error — callers degrade gracefully + * because `resolveProviderName` falls back to the de-UUIDed id. + */ +export function useProviderNodeMap(): Map { + const [nodeMap, setNodeMap] = useState>(new Map()); + + useEffect(() => { + let cancelled = false; + fetch("/api/provider-nodes") + .then((r) => (r.ok ? r.json() : null)) + .then((data) => { + if (cancelled || !data?.nodes) return; + const map = new Map(); + for (const node of data.nodes) { + if (typeof node.id === "string") { + map.set(node.id, { name: node.name ?? null, prefix: node.prefix ?? null }); + } + } + setNodeMap(map); + }) + .catch(() => { + // Silently degrade — custom providers will show de-UUIDed fallback label + }); + return () => { + cancelled = true; + }; + }, []); + + return nodeMap; +} diff --git a/tests/unit/display-and-error-utils.test.ts b/tests/unit/display-and-error-utils.test.ts index a2ad1ed3c4..b97ec91bbc 100644 --- a/tests/unit/display-and-error-utils.test.ts +++ b/tests/unit/display-and-error-utils.test.ts @@ -6,6 +6,7 @@ const { createErrorResponse, createErrorResponseFromUnknown } = await import("../../src/lib/api/errorResponse.ts"); const { getAccountDisplayName, getProviderDisplayName } = await import("../../src/lib/display/names.ts"); +const { resolveProviderName } = await import("../../src/lib/display/useProviderNodeMap.ts"); test("toJsonErrorPayload: preserves upstream error objects that already have error payloads", () => { const payload = { @@ -201,6 +202,32 @@ test("getAccountDisplayName: respects priority order and fallback", () => { assert.equal(getAccountDisplayName(null), "Unknown Account"); }); +test("resolveProviderName: returns user-given name from nodeMap for custom provider", () => { + const nodeMap = new Map([ + ["openai-compatible-chat-abc123", { name: "My LLM", prefix: "my-llm" }], + ]); + assert.equal( + resolveProviderName("openai-compatible-chat-abc123", nodeMap), + "My LLM", + "should return the user-given name when present in the map" + ); + // Falls back to prefix when name is blank + const mapNoName = new Map([ + ["openai-compatible-chat-abc123", { name: null, prefix: "my-prefix" }], + ]); + assert.equal(resolveProviderName("openai-compatible-chat-abc123", mapNoName), "my-prefix"); + // Falls back to de-UUIDed label when no node entry exists + assert.equal( + resolveProviderName("openai-compatible-chat-02669115-2545-4896-b003-cb4dac09d441", new Map()), + "Compatible (openai)" + ); + // Falls back gracefully for unknown ids not in the map + assert.equal(resolveProviderName("plain-provider", nodeMap), "plain-provider"); + // Handles null/undefined id + assert.equal(resolveProviderName(null, nodeMap), "Unknown Provider"); + assert.equal(resolveProviderName(undefined, null), "Unknown Provider"); +}); + test("getProviderDisplayName: prefers node metadata and simplifies compatible IDs", () => { assert.equal( getProviderDisplayName("openai-compatible-chat-02669115-2545-4896-b003-cb4dac09d441", {