fix(dashboard): show custom provider given-name instead of internal id across dashboard pages (#4603) (#4960)

Integrated into release/v3.8.36 (fixes #4603)
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-06-24 14:03:21 -03:00
committed by GitHub
parent d575691d90
commit 3d37242993
13 changed files with 195 additions and 57 deletions

View File

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

View File

@@ -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}
</div>
<div className="mt-1 text-xs text-text-muted">
{target.provider} · traffic {formatShare(target.trafficShare)}
{resolveProviderName(target.provider, nodeMap)} · traffic{" "}
{formatShare(target.trafficShare)}
</div>
</div>
<Badge variant={getRiskVariant(target.quota.risk)} size="sm">
@@ -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}
</div>
<div className="mt-1 text-xs text-text-muted">
{target.provider} · score {target.score.toFixed(3)}
{resolveProviderName(target.provider, nodeMap)} · score{" "}
{target.score.toFixed(3)}
</div>
</div>
<Badge variant={target.rank === 1 ? "success" : "default"} size="sm">
@@ -488,6 +493,7 @@ function ComboHealthCard({
scoringInspector?: ComboScoringInspectorCombo;
}) {
const t = useTranslations("analytics");
const nodeMap = useProviderNodeMap();
const sortedDistribution = useMemo(
() =>
@@ -563,7 +569,9 @@ function ComboHealthCard({
>
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<div className="text-sm font-medium text-text-main">{provider.provider}</div>
<div className="text-sm font-medium text-text-main">
{resolveProviderName(provider.provider, nodeMap)}
</div>
<div className="mt-1 text-xs text-text-muted">
Remaining quota {formatPercent(provider.remainingPct, 1)}
</div>

View File

@@ -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<string | null>(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]) => (
<ProviderBar
key={prov}
provider={prov}
provider={resolveProviderName(prov, nodeMap)}
count={data.count}
total={stats.totalRequests}
tokensSaved={data.tokensSaved}

View File

@@ -2,6 +2,7 @@
import { useTranslations } from "next-intl";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useProviderNodeMap, resolveProviderName } from "@/lib/display/useProviderNodeMap";
import {
CartesianGrid,
Legend,
@@ -91,6 +92,7 @@ function getLatestPoints(points: ProviderUtilizationPoint[]) {
export default function ProviderUtilizationTab() {
const t = useTranslations("analytics");
const nodeMap = useProviderNodeMap();
const [range, setRange] = useState<UtilizationTimeRange>("24h");
const [aggregateBy, setAggregateBy] = useState<"provider" | "connection">("provider");
const [data, setData] = useState<ProviderUtilizationResponse | null>(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() {
<ProviderIcon providerId={point.provider} size={22} />
</div>
<div>
<p className="text-sm font-semibold text-text-main">{point.provider}</p>
<p className="text-sm font-semibold text-text-main">
{resolveProviderName(point.provider, nodeMap)}
</p>
<p className="text-xs text-text-muted">
{t("providerUtilizationLatestSnapshot")}
</p>

View File

@@ -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 <div className="text-sm text-text-muted">No related target evidence persisted yet.</div>;
}
@@ -267,7 +269,7 @@ function TargetTimeline({ targets }: { targets: ExplainTarget[] }) {
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="truncate text-sm font-medium text-text-main">
{target.provider || "unknown"} / {target.model || "unknown"}
{resolveProviderName(target.provider, nodeMap)} / {target.model || "unknown"}
</span>
{target.outcome === "selected" ? (
<Badge variant="primary" size="sm">
@@ -308,6 +310,7 @@ function replayAlignmentVariant(alignment: NonNullable<DecisionReplay["recompute
}
function WhyThisTargetCard({ replay }: { replay: DecisionReplay | undefined }) {
const nodeMap = useProviderNodeMap();
if (!replay) return null;
const recompute = replay.recompute;
const candidates = recompute?.candidates ?? [];
@@ -324,7 +327,8 @@ function WhyThisTargetCard({ replay }: { replay: DecisionReplay | undefined }) {
<div className="min-w-0">
<div className="text-sm font-semibold text-text-main">Exact runtime log</div>
<div className="mt-1 truncate text-xs text-text-muted">
{replay.runtime.provider || "unknown"} / {replay.runtime.model || "unknown"}
{resolveProviderName(replay.runtime.provider, nodeMap)} /{" "}
{replay.runtime.model || "unknown"}
</div>
<div className="mt-1 text-xs text-text-muted">
{formatDate(replay.runtime.timestamp)} · {replay.runtime.comboStepId || "no step"}
@@ -400,7 +404,7 @@ function WhyThisTargetCard({ replay }: { replay: DecisionReplay | undefined }) {
<div className="flex flex-wrap items-center gap-2 text-sm font-medium text-text-main">
<span>#{candidate.rank}</span>
<span className="truncate">
{candidate.provider} / {candidate.model}
{resolveProviderName(candidate.provider, nodeMap)} / {candidate.model}
</span>
{candidate.isRuntimeSelected ? (
<Badge variant="primary" size="sm">
@@ -447,6 +451,7 @@ export default function RouteExplainabilityTab({
initialRequestId?: string;
}) {
const t = useTranslations("analytics") as AnalyticsTranslator;
const nodeMap = useProviderNodeMap();
const [logs, setLogs] = useState<CallLogOption[]>([]);
const [selectedId, setSelectedId] = useState(initialRequestId);
const [explanation, setExplanation] = useState<RouteExplainabilityResponse | null>(null);
@@ -564,7 +569,7 @@ export default function RouteExplainabilityTab({
{logs.map((log) => (
<option key={log.id} value={log.id}>
{formatDate(log.timestamp)} · HTTP {log.status} ·{" "}
{log.comboName || log.provider || "direct"} ·{" "}
{log.comboName || resolveProviderName(log.provider, nodeMap) || "direct"} ·{" "}
{log.model || log.requestedModel || log.id}
</option>
))}
@@ -662,7 +667,7 @@ export default function RouteExplainabilityTab({
<Card title="Selected target" icon="my_location">
<div className="grid gap-3 text-sm">
{[
["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"],

View File

@@ -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<CacheStats | null>(null);
const [loading, setLoading] = useState(true);
@@ -600,7 +602,9 @@ export default function CachePage() {
className="border-b border-border/15 last:border-b-0"
>
<td className="px-4 py-3">
<div className="font-medium text-text-main">{provider}</div>
<div className="font-medium text-text-main">
{resolveProviderName(provider, nodeMap)}
</div>
<div className="mt-1 text-xs text-text-muted">
{totalRequests.toLocaleString()} {t("requests").toLowerCase()}
</div>

View File

@@ -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));

View File

@@ -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<AutopilotReport | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@@ -241,7 +243,7 @@ export default function ProviderHealthAutopilotCard() {
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<div>
<h3 className="font-semibold text-text-main">
{getProviderDisplayName(provider.provider)}
{resolveProviderName(provider.provider, nodeMap)}
</h3>
<p className="text-xs text-text-muted">
score {(provider.score * 100).toFixed(0)}% · active{" "}

View File

@@ -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 (
<div
key={provider}
@@ -821,7 +826,10 @@ export default function HealthPage() {
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-2">
{healthy.map(([provider]) => {
const providerInfo = AI_PROVIDERS[provider];
const displayName = getProviderDisplayName(provider, providerInfo);
const displayName = getProviderDisplayName(
provider,
nodeMap.get(provider) ?? providerInfo
);
return (
<div
key={provider}
@@ -857,25 +865,9 @@ export default function HealthPage() {
const connectionId = parts[1] || "";
const model = parts.slice(2).join(":") || null;
// Resolve friendly name
let displayName;
// Resolve friendly name — prefer user-given name from provider node map
let providerInfo = AI_PROVIDERS[providerId];
if (providerId.startsWith("openai-compatible-")) {
const customName = providerId.replace("openai-compatible-", "");
displayName = tp("openaiCompatibleName");
providerInfo = { color: "#10A37F", textIcon: "OC" };
if (customName.length > 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 };
};

View File

@@ -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 (
<Fragment key={p.provider}>
<tr
className="border-b border-border/50 hover:bg-surface/50 transition-colors cursor-pointer"
onClick={() =>
setExpandedProvider(isExpanded ? null : models.length > 0 ? p.provider : null)
setExpandedProvider(
isExpanded ? null : models.length > 0 ? p.provider : null
)
}
>
<td className="py-2.5 px-3 font-medium text-text-main">
{p.provider}
{resolveProviderName(p.provider, nodeMap)}
</td>
<td className="py-2.5 px-3 text-right tabular-nums text-text-main">
{formatNumber(p.totalRequests)}
@@ -363,7 +374,9 @@ export default function ProviderStatsPage() {
<th className="text-right py-1.5 px-3 font-medium">Requests</th>
<th className="text-right py-1.5 px-3 font-medium">Success</th>
<th className="text-right py-1.5 px-3 font-medium">Rate</th>
<th className="text-right py-1.5 px-3 font-medium">Avg Latency</th>
<th className="text-right py-1.5 px-3 font-medium">
Avg Latency
</th>
<th className="px-3 w-8" />
</tr>
</thead>
@@ -444,7 +457,10 @@ export default function ProviderStatsPage() {
</thead>
<tbody>
{Object.entries(data.comboMetrics).map(([name, m]: [string, any]) => (
<tr key={name} className="border-b border-border/50 hover:bg-surface/50 transition-colors">
<tr
key={name}
className="border-b border-border/50 hover:bg-surface/50 transition-colors"
>
<td className="py-2 px-3 font-medium text-text-main">{name}</td>
<td className="py-2 px-3 text-right tabular-nums text-text-main">
{formatNumber(m.requestCount ?? m.totalRequests ?? 0)}
@@ -456,7 +472,9 @@ export default function ProviderStatsPage() {
{formatLatency(m.avgLatency ?? m.avgTotalMs ?? null)}
</td>
<td className="py-2 px-3 text-right tabular-nums">
{typeof m.successRate === "number" ? `${(m.successRate * 100).toFixed(1)}%` : "—"}
{typeof m.successRate === "number"
? `${(m.successRate * 100).toFixed(1)}%`
: "—"}
</td>
</tr>
))}
@@ -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 (
<div
key={key}
className="rounded-lg border border-border/40 bg-surface/30 p-3"
>
<div key={key} className="rounded-lg border border-border/40 bg-surface/30 p-3">
<p className="text-xs text-text-muted uppercase tracking-wide">
{key.replace(/([A-Z])/g, " $1").replace(/_/g, " ")}
</p>

View File

@@ -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<HealthPayload | null>(null);
const [connections, setConnections] = useState<Connection[]>([]);
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}`}
>
<div className="flex items-center gap-1.5 text-[11px] font-semibold text-text-main">
<ProviderIcon providerId={b.provider} size={14} />
<span className="truncate flex-1">{b.provider}</span>
<span className="truncate flex-1">
{resolveProviderName(b.provider, nodeMap)}
</span>
<span style={{ color: tone.dot }} className="text-[10px] font-bold">
{tone.label}
</span>
@@ -721,7 +725,7 @@ export default function RuntimePageClient() {
<ProviderIcon providerId={c.provider} size={18} />
<div className="min-w-0">
<div className="text-[12px] text-text-main truncate font-medium">
{c.provider}/{label}
{resolveProviderName(c.provider, nodeMap)}/{label}
</div>
{c.lastErrorType && (
<div className="text-[10px] text-text-muted truncate">
@@ -1174,7 +1178,7 @@ function QuotaGroup({
<div className="min-w-0">
<div className="text-[11px] font-medium text-text-main truncate">
{m.accountId ?? "—"}
{m.provider ? ` / ${m.provider}` : ""}
{m.provider ? ` / ${resolveProviderName(m.provider, nodeMap)}` : ""}
</div>
<div className="text-[10px] text-text-muted">{m.window ?? ""}</div>
</div>

View File

@@ -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<id, {name?, prefix?}>
*
* 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<string, ProviderNodeEntry> | 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<string, ProviderNodeEntry> {
const [nodeMap, setNodeMap] = useState<Map<string, ProviderNodeEntry>>(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<string, ProviderNodeEntry>();
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;
}

View File

@@ -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", {