diff --git a/src/app/(dashboard)/dashboard/media-providers/combo/[id]/page.js b/src/app/(dashboard)/dashboard/media-providers/combo/[id]/page.js
index 45c1546025..1de397e1fa 100644
--- a/src/app/(dashboard)/dashboard/media-providers/combo/[id]/page.js
+++ b/src/app/(dashboard)/dashboard/media-providers/combo/[id]/page.js
@@ -6,6 +6,7 @@ import Link from "next/link";
import { Card, Button, Input, Toggle, ModelSelectModal } from "@/shared/components";
import ProviderIcon from "@/shared/components/ProviderIcon";
import { AI_PROVIDERS, MEDIA_PROVIDER_KINDS } from "@/shared/constants/providers";
+import { resolveProviderIconInfo } from "@/shared/helpers/providerIconInfo";
// Parse "providerId/model" or just "providerId" → { providerId, model }
function parseModelEntry(entry) {
@@ -304,8 +305,8 @@ export default function ComboDetailPage() {
alt={p?.name || providerId}
size={24}
className="object-contain rounded shrink-0"
- fallbackText={p?.textIcon || providerId.slice(0, 2).toUpperCase()}
- fallbackColor={p?.color}
+ fallbackText={resolveProviderIconInfo(providerId).fallbackText}
+ fallbackColor={resolveProviderIconInfo(providerId).fallbackColor}
/>
{p?.name || providerId}
diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.js b/src/app/(dashboard)/dashboard/providers/[id]/page.js
index fd9defc5ed..b910b55013 100644
--- a/src/app/(dashboard)/dashboard/providers/[id]/page.js
+++ b/src/app/(dashboard)/dashboard/providers/[id]/page.js
@@ -10,6 +10,7 @@ import { getModelsByProviderId } from "@/shared/constants/models";
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
import { translate } from "@/i18n/runtime";
import { fetchSuggestedModels } from "@/shared/utils/providerModelsFetcher";
+import { resolveProviderIconInfo } from "@/shared/helpers/providerIconInfo";
import ModelRow from "./ModelRow";
import PassthroughModelsSection from "./PassthroughModelsSection";
import CompatibleModelsSection from "./CompatibleModelsSection";
@@ -116,15 +117,18 @@ export default function ProviderDetailPage() {
};
const providerInfo = providerNode
- ? {
- id: providerNode.id,
- name: providerNode.name || (providerNode.type === "anthropic-compatible" ? "Anthropic Compatible" : "OpenAI Compatible"),
- color: providerNode.type === "anthropic-compatible" ? "#D97757" : "#10A37F",
- textIcon: providerNode.type === "anthropic-compatible" ? "AC" : "OC",
- apiType: providerNode.apiType,
- baseUrl: providerNode.baseUrl,
- type: providerNode.type,
- }
+ ? (() => {
+ const iconInfo = resolveProviderIconInfo(providerNode.id, providerNode.name)
+ return {
+ id: providerNode.id,
+ name: providerNode.name || (providerNode.type === "anthropic-compatible" ? "Anthropic Compatible" : "OpenAI Compatible"),
+ textIcon: iconInfo.fallbackText,
+ color: iconInfo.fallbackColor,
+ apiType: providerNode.apiType,
+ baseUrl: providerNode.baseUrl,
+ type: providerNode.type,
+ }
+ })()
: (OAUTH_PROVIDERS[providerId] || APIKEY_PROVIDERS[providerId] || FREE_PROVIDERS[providerId] || FREE_TIER_PROVIDERS[providerId] || WEB_COOKIE_PROVIDERS[providerId]);
const authModes = providerInfo?.authModes || [];
const isOAuth = !!OAUTH_PROVIDERS[providerId] || !!FREE_PROVIDERS[providerId] || authModes.includes("oauth");
@@ -1107,7 +1111,7 @@ export default function ProviderDetailPage() {
>
{headerImgError ? (
- {providerInfo.textIcon || providerInfo.id.slice(0, 2).toUpperCase()}
+ {providerInfo.textIcon}
) : (
node.type === "openai-compatible")
- .map((node) => ({
- id: node.id,
- name: node.name || "OpenAI Compatible",
- color: "#10A37F",
- textIcon: "OC",
- apiType: node.apiType,
- }))
+ .map((node) => {
+ const info = resolveProviderIconInfo(node.id, node.name);
+ return {
+ id: node.id,
+ name: node.name || "OpenAI Compatible",
+ color: info.fallbackColor,
+ textIcon: info.fallbackText,
+ apiType: node.apiType,
+ };
+ })
.filter((p) => matchSearch(p.name));
const anthropicCompatibleProviders = providerNodes
.filter((node) => node.type === "anthropic-compatible")
- .map((node) => ({
- id: node.id,
- name: node.name || "Anthropic Compatible",
- color: "#D97757",
- textIcon: "AC",
- }))
+ .map((node) => {
+ const info = resolveProviderIconInfo(node.id, node.name);
+ return {
+ id: node.id,
+ name: node.name || "Anthropic Compatible",
+ color: info.fallbackColor,
+ textIcon: info.fallbackText,
+ };
+ })
.filter((p) => matchSearch(p.name));
const oauthEntries = Object.entries(OAUTH_PROVIDERS).filter(
@@ -628,9 +635,7 @@ function ProviderCard({ providerId, provider, stats, authType, onToggle }) {
alt={provider.name}
size={30}
className="object-contain rounded-lg max-w-[32px] max-h-[32px]"
- fallbackText={
- provider.textIcon || provider.id.slice(0, 2).toUpperCase()
- }
+ fallbackText={provider.textIcon}
fallbackColor={provider.color}
/>
@@ -756,9 +761,7 @@ function ApiKeyProviderCard({
alt={provider.name}
size={30}
className="object-contain rounded-lg max-w-[30px] max-h-[30px]"
- fallbackText={
- provider.textIcon || provider.id.slice(0, 2).toUpperCase()
- }
+ fallbackText={provider.textIcon}
fallbackColor={provider.color}
/>
diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderTopology.js b/src/app/(dashboard)/dashboard/usage/components/ProviderTopology.js
index 3827a0f854..84337ab921 100644
--- a/src/app/(dashboard)/dashboard/usage/components/ProviderTopology.js
+++ b/src/app/(dashboard)/dashboard/usage/components/ProviderTopology.js
@@ -10,6 +10,7 @@ import {
} from "@xyflow/react";
import "@xyflow/react/dist/style.css";
import { AI_PROVIDERS } from "@/shared/constants/providers";
+import { resolveProviderIconInfo } from "@/shared/helpers/providerIconInfo";
// Force-stop FE animation if a provider stays active longer than this
const FE_ACTIVE_TIMEOUT_MS = 60000;
@@ -144,15 +145,16 @@ function buildLayout(providers, activeSet, lastSet, errorSet) {
providers.forEach((p, i) => {
const config = getProviderConfig(p.provider);
+ const iconInfo = resolveProviderIconInfo(p.provider, p.nodeName || p.name);
const active = activeSet.has(p.provider?.toLowerCase());
const last = !active && lastSet.has(p.provider?.toLowerCase());
const error = !active && errorSet.has(p.provider?.toLowerCase());
const nodeId = `provider-${p.provider}`;
const data = {
label: (config.name !== p.provider ? config.name : null) || p.nodeName || p.name || p.provider,
- color: config.color || "#6b7280",
+ color: iconInfo.fallbackColor,
imageUrl: getProviderImageUrl(p.provider),
- textIcon: config.textIcon || (p.provider || "?").slice(0, 2).toUpperCase(),
+ textIcon: iconInfo.fallbackText,
active,
};
diff --git a/src/shared/components/ModelSelectModal.js b/src/shared/components/ModelSelectModal.js
index 0e965f8d98..883da14b7a 100644
--- a/src/shared/components/ModelSelectModal.js
+++ b/src/shared/components/ModelSelectModal.js
@@ -6,6 +6,7 @@ import Modal from "./Modal";
import ProviderIcon from "./ProviderIcon";
import { getModelsByProviderId } from "@/shared/constants/models";
import { OAUTH_PROVIDERS, APIKEY_PROVIDERS, FREE_PROVIDERS, FREE_TIER_PROVIDERS, AI_PROVIDERS, isOpenAICompatibleProvider, isAnthropicCompatibleProvider, getProviderAlias } from "@/shared/constants/providers";
+import { resolveProviderIconInfo } from "@/shared/helpers/providerIconInfo";
// Provider order: OAuth first, then Free Tier, then API Key (matches dashboard/providers)
const PROVIDER_ORDER = [
@@ -447,8 +448,8 @@ export default function ModelSelectModal({
src={`/providers/${providerId}.png`}
alt={group.name}
size={14}
- fallbackText={(group.name || providerId).slice(0, 2).toUpperCase()}
- fallbackColor={group.color}
+ fallbackText={resolveProviderIconInfo(providerId, group.name).fallbackText}
+ fallbackColor={resolveProviderIconInfo(providerId, group.name).fallbackColor}
/>
{group.name}
diff --git a/src/shared/helpers/providerIconInfo.js b/src/shared/helpers/providerIconInfo.js
new file mode 100644
index 0000000000..c59fdd8553
--- /dev/null
+++ b/src/shared/helpers/providerIconInfo.js
@@ -0,0 +1,38 @@
+import { getProviderByAlias, isOpenAICompatibleProvider, isAnthropicCompatibleProvider } from "@/shared/constants/providers"
+
+function getInitials(name, fallbackId) {
+ if (name && name.trim() !== "") {
+ return name.slice(0, 2).toUpperCase()
+ }
+ return fallbackId.slice(0, 2).toUpperCase()
+}
+
+export function resolveProviderIconInfo(providerId, displayName) {
+ const entry = getProviderByAlias(providerId)
+
+ if (entry) {
+ return {
+ fallbackText: entry.textIcon || entry.alias.slice(0, 2).toUpperCase(),
+ fallbackColor: entry.color,
+ }
+ }
+
+ if (isOpenAICompatibleProvider(providerId)) {
+ return {
+ fallbackText: getInitials(displayName, providerId),
+ fallbackColor: "#10A37F",
+ }
+ }
+
+ if (isAnthropicCompatibleProvider(providerId)) {
+ return {
+ fallbackText: getInitials(displayName, providerId),
+ fallbackColor: "#D97757",
+ }
+ }
+
+ return {
+ fallbackText: getInitials(displayName, providerId),
+ fallbackColor: "#6b7280",
+ }
+}
diff --git a/tests/unit/provider-icon-info.test.js b/tests/unit/provider-icon-info.test.js
new file mode 100644
index 0000000000..86f5dbf525
--- /dev/null
+++ b/tests/unit/provider-icon-info.test.js
@@ -0,0 +1,78 @@
+import { describe, it, expect } from "vitest"
+
+import { resolveProviderIconInfo } from "../../src/shared/helpers/providerIconInfo.js"
+
+describe("resolveProviderIconInfo", () => {
+ it("returns icon info for built-in provider kr (Kiro)", () => {
+ const result = resolveProviderIconInfo("kr")
+
+ expect(result).toEqual({
+ fallbackText: "KR",
+ fallbackColor: "#FF6B35",
+ })
+ })
+
+ it("returns icon info for built-in provider glm", () => {
+ const result = resolveProviderIconInfo("glm")
+
+ expect(result).toEqual({
+ fallbackText: "GL",
+ fallbackColor: "#2563EB",
+ })
+ })
+
+ it("returns OpenAI-compatible chat provider icon with displayName uppercase slice", () => {
+ const result = resolveProviderIconInfo("openai-compatible-chat-abc123", "Sumopod")
+
+ expect(result).toEqual({
+ fallbackText: "SU",
+ fallbackColor: "#10A37F",
+ })
+ })
+
+ it("returns OpenAI-compatible responses provider icon with displayName uppercase slice", () => {
+ const result = resolveProviderIconInfo("openai-compatible-responses-xyz", "MyServer")
+
+ expect(result).toEqual({
+ fallbackText: "MY",
+ fallbackColor: "#10A37F",
+ })
+ })
+
+ it("returns Anthropic-compatible provider icon with displayName uppercase slice", () => {
+ const result = resolveProviderIconInfo("anthropic-compatible-xyz", "ClaudeProxy")
+
+ expect(result).toEqual({
+ fallbackText: "CL",
+ fallbackColor: "#D97757",
+ })
+ })
+
+ it("falls back to provider ID slice when displayName is empty", () => {
+ const result = resolveProviderIconInfo("openai-compatible-chat-abc", "")
+
+ expect(result).toEqual({
+ fallbackText: "OP",
+ fallbackColor: "#10A37F",
+ })
+ })
+
+ it("does not throw when displayName is null, returns non-empty fallbackText", () => {
+ expect(() => resolveProviderIconInfo("anthropic-compatible-xyz", null)).not.toThrow()
+
+ const result = resolveProviderIconInfo("anthropic-compatible-xyz", null)
+
+ expect(result.fallbackText).toBeDefined()
+ expect(result.fallbackText.length).toBeGreaterThan(0)
+ expect(result.fallbackColor).toBeDefined()
+ })
+
+ it("returns generic icon for unknown provider", () => {
+ const result = resolveProviderIconInfo("unknown-xyz")
+
+ expect(result).toEqual({
+ fallbackText: "UN",
+ fallbackColor: "#6b7280",
+ })
+ })
+})