Просмотр исходного кода

feat(ui): add model vendor icons to usage tables and price filters (#827)

* feat(ui): add model vendor icons to usage tables and price filters

Display upstream AI vendor icons (Claude, OpenAI, DeepSeek, etc.) next
to model names in usage records, my-usage table, and expand price table
filter buttons from 3 hardcoded to 20 data-driven vendors.

* fix(ui): widen model column and shrink cost column in usage logs tables

Regular table: model column 180px -> 220px.
Virtualized table: model flex 1.0 -> 1.3, cost flex 0.7 -> 0.6.

* feat(ui): add click-to-copy on model names in usage logs and my-usage tables

* chore: format code (feat-provider-icons-7fa5a90)

* fix: strictly sort MODEL_VENDOR_RULES by prefix length descending

Address bugbot review comments from gemini-code-assist and greptile:
baichuan (8 chars) was misplaced after 7-char prefixes. Reorder all
entries with length-group comments and alphabetical sort within groups.

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Ding 1 месяц назад
Родитель
Сommit
865fb0a19d

+ 18 - 1
messages/en/settings/prices.json

@@ -11,7 +11,24 @@
     "local": "Local",
     "anthropic": "Anthropic",
     "openai": "OpenAI",
-    "vertex": "Vertex"
+    "vertex": "Vertex",
+    "deepseek": "DeepSeek",
+    "mistral": "Mistral",
+    "meta": "Meta",
+    "cohere": "Cohere",
+    "xai": "xAI",
+    "groq": "Groq",
+    "bedrock": "Bedrock",
+    "azure": "Azure",
+    "together": "Together",
+    "nvidia": "NVIDIA",
+    "zhipuai": "Zhipu",
+    "volcengine": "Volcengine",
+    "minimax": "MiniMax",
+    "qwen": "Qwen",
+    "fireworks": "Fireworks",
+    "ollama": "Ollama",
+    "openrouter": "OpenRouter"
   },
   "badges": {
     "local": "Local"

+ 18 - 1
messages/ja/settings/prices.json

@@ -11,7 +11,24 @@
     "local": "ローカル",
     "anthropic": "Anthropic",
     "openai": "OpenAI",
-    "vertex": "Vertex"
+    "vertex": "Vertex",
+    "deepseek": "DeepSeek",
+    "mistral": "Mistral",
+    "meta": "Meta",
+    "cohere": "Cohere",
+    "xai": "xAI",
+    "groq": "Groq",
+    "bedrock": "Bedrock",
+    "azure": "Azure",
+    "together": "Together",
+    "nvidia": "NVIDIA",
+    "zhipuai": "Zhipu",
+    "volcengine": "Volcengine",
+    "minimax": "MiniMax",
+    "qwen": "Qwen",
+    "fireworks": "Fireworks",
+    "ollama": "Ollama",
+    "openrouter": "OpenRouter"
   },
   "badges": {
     "local": "ローカル"

+ 18 - 1
messages/ru/settings/prices.json

@@ -11,7 +11,24 @@
     "local": "Локальные",
     "anthropic": "Anthropic",
     "openai": "OpenAI",
-    "vertex": "Vertex"
+    "vertex": "Vertex",
+    "deepseek": "DeepSeek",
+    "mistral": "Mistral",
+    "meta": "Meta",
+    "cohere": "Cohere",
+    "xai": "xAI",
+    "groq": "Groq",
+    "bedrock": "Bedrock",
+    "azure": "Azure",
+    "together": "Together",
+    "nvidia": "NVIDIA",
+    "zhipuai": "Zhipu",
+    "volcengine": "Volcengine",
+    "minimax": "MiniMax",
+    "qwen": "Qwen",
+    "fireworks": "Fireworks",
+    "ollama": "Ollama",
+    "openrouter": "OpenRouter"
   },
   "badges": {
     "local": "Локальная"

+ 18 - 1
messages/zh-CN/settings/prices.json

@@ -11,7 +11,24 @@
     "local": "本地",
     "anthropic": "Anthropic",
     "openai": "OpenAI",
-    "vertex": "Vertex"
+    "vertex": "Vertex",
+    "deepseek": "DeepSeek",
+    "mistral": "Mistral",
+    "meta": "Meta",
+    "cohere": "Cohere",
+    "xai": "xAI",
+    "groq": "Groq",
+    "bedrock": "Bedrock",
+    "azure": "Azure",
+    "together": "Together",
+    "nvidia": "NVIDIA",
+    "zhipuai": "智谱",
+    "volcengine": "火山引擎",
+    "minimax": "MiniMax",
+    "qwen": "Qwen",
+    "fireworks": "Fireworks",
+    "ollama": "Ollama",
+    "openrouter": "OpenRouter"
   },
   "badges": {
     "local": "本地"

+ 18 - 1
messages/zh-TW/settings/prices.json

@@ -11,7 +11,24 @@
     "local": "本機",
     "anthropic": "Anthropic",
     "openai": "OpenAI",
-    "vertex": "Vertex"
+    "vertex": "Vertex",
+    "deepseek": "DeepSeek",
+    "mistral": "Mistral",
+    "meta": "Meta",
+    "cohere": "Cohere",
+    "xai": "xAI",
+    "groq": "Groq",
+    "bedrock": "Bedrock",
+    "azure": "Azure",
+    "together": "Together",
+    "nvidia": "NVIDIA",
+    "zhipuai": "智譜",
+    "volcengine": "火山引擎",
+    "minimax": "MiniMax",
+    "qwen": "Qwen",
+    "fireworks": "Fireworks",
+    "ollama": "Ollama",
+    "openrouter": "OpenRouter"
   },
   "badges": {
     "local": "本機"

+ 30 - 2
src/app/[locale]/dashboard/logs/_components/model-display-with-redirect.tsx

@@ -1,7 +1,12 @@
 "use client";
 
 import { ArrowRight } from "lucide-react";
+import { useTranslations } from "next-intl";
+import { useCallback } from "react";
+import { toast } from "sonner";
+import { ModelVendorIcon } from "@/components/customs/model-vendor-icon";
 import { Badge } from "@/components/ui/badge";
+import { copyTextToClipboard } from "@/lib/utils/clipboard";
 import type { BillingModelSource } from "@/types/system-config";
 
 interface ModelDisplayWithRedirectProps {
@@ -17,20 +22,43 @@ export function ModelDisplayWithRedirect({
   billingModelSource,
   onRedirectClick,
 }: ModelDisplayWithRedirectProps) {
+  const t = useTranslations("common");
+
   // 判断是否发生重定向
   const isRedirected = originalModel && currentModel && originalModel !== currentModel;
 
   // 根据计费模型来源配置决定显示哪个模型
   const billingModel = billingModelSource === "original" ? originalModel : currentModel;
 
+  const handleCopyModel = useCallback(
+    (e: React.MouseEvent) => {
+      e.stopPropagation();
+      if (!billingModel) return;
+      void copyTextToClipboard(billingModel).then((ok) => {
+        if (ok) toast.success(t("copySuccess"));
+      });
+    },
+    [billingModel, t]
+  );
+
   if (!isRedirected) {
-    return <span className="truncate">{billingModel || "-"}</span>;
+    return (
+      <div className="flex items-center gap-1.5 min-w-0">
+        {billingModel ? <ModelVendorIcon modelId={billingModel} /> : null}
+        <span className="truncate cursor-pointer hover:underline" onClick={handleCopyModel}>
+          {billingModel || "-"}
+        </span>
+      </div>
+    );
   }
 
   // 计费模型 + 重定向标记(只显示图标)
   return (
     <div className="flex items-center gap-1.5 min-w-0">
-      <span className="truncate">{billingModel}</span>
+      {billingModel ? <ModelVendorIcon modelId={billingModel} /> : null}
+      <span className="truncate cursor-pointer hover:underline" onClick={handleCopyModel}>
+        {billingModel}
+      </span>
       <Badge
         variant="outline"
         className="cursor-pointer text-xs border-blue-300 text-blue-700 dark:border-blue-700 dark:text-blue-300 px-1 shrink-0"

+ 1 - 1
src/app/[locale]/dashboard/logs/_components/usage-logs-table.tsx

@@ -251,7 +251,7 @@ export function UsageLogsTable({
                         </div>
                       )}
                     </TableCell>
-                    <TableCell className="font-mono text-xs w-[180px] max-w-[180px]">
+                    <TableCell className="font-mono text-xs w-[220px] max-w-[220px]">
                       <TooltipProvider>
                         <Tooltip>
                           <TooltipTrigger asChild>

+ 4 - 4
src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.tsx

@@ -248,7 +248,7 @@ export function VirtualizedLogsTable({
                 </div>
               )}
               <div
-                className="flex-[1] min-w-[80px] px-1.5 truncate"
+                className="flex-[1.3] min-w-[100px] px-1.5 truncate"
                 title={t("logs.columns.model")}
               >
                 {t("logs.columns.model")}
@@ -271,7 +271,7 @@ export function VirtualizedLogsTable({
               )}
               {hideCostColumn ? null : (
                 <div
-                  className="flex-[0.7] min-w-[60px] text-right px-1.5 truncate"
+                  className="flex-[0.6] min-w-[50px] text-right px-1.5 truncate"
                   title={t("logs.columns.cost")}
                 >
                   {t("logs.columns.cost")}
@@ -502,7 +502,7 @@ export function VirtualizedLogsTable({
                     )}
 
                     {/* Model */}
-                    <div className="flex-[1] min-w-[80px] font-mono text-xs px-1.5">
+                    <div className="flex-[1.3] min-w-[100px] font-mono text-xs px-1.5">
                       <TooltipProvider>
                         <Tooltip>
                           <TooltipTrigger asChild>
@@ -622,7 +622,7 @@ export function VirtualizedLogsTable({
 
                     {/* Cost */}
                     {hideCostColumn ? null : (
-                      <div className="flex-[0.7] min-w-[60px] text-right font-mono text-xs px-1.5">
+                      <div className="flex-[0.6] min-w-[50px] text-right font-mono text-xs px-1.5">
                         {isNonBilling ? (
                           "-"
                         ) : log.costUsd != null ? (

+ 27 - 1
src/app/[locale]/my-usage/_components/usage-logs-table.tsx

@@ -2,7 +2,10 @@
 
 import { formatInTimeZone } from "date-fns-tz";
 import { useTimeZone, useTranslations } from "next-intl";
+import { useCallback } from "react";
+import { toast } from "sonner";
 import type { MyUsageLogEntry } from "@/actions/my-usage";
+import { ModelVendorIcon } from "@/components/customs/model-vendor-icon";
 import { Badge } from "@/components/ui/badge";
 import { Skeleton } from "@/components/ui/skeleton";
 import {
@@ -15,6 +18,7 @@ import {
 } from "@/components/ui/table";
 import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
 import { CURRENCY_CONFIG, type CurrencyCode } from "@/lib/utils";
+import { copyTextToClipboard } from "@/lib/utils/clipboard";
 
 interface UsageLogsTableProps {
   logs: MyUsageLogEntry[];
@@ -38,6 +42,7 @@ export function UsageLogsTable({
   loadingLabel,
 }: UsageLogsTableProps) {
   const t = useTranslations("myUsage.logs");
+  const tCommon = useTranslations("common");
   const timeZone = useTimeZone() ?? "UTC";
   const totalPages = Math.max(1, Math.ceil(total / pageSize));
 
@@ -46,6 +51,15 @@ export function UsageLogsTable({
     return value.toLocaleString();
   };
 
+  const handleCopyModel = useCallback(
+    (modelId: string) => {
+      void copyTextToClipboard(modelId).then((ok) => {
+        if (ok) toast.success(tCommon("copySuccess"));
+      });
+    },
+    [tCommon]
+  );
+
   return (
     <div className="space-y-3">
       <div className="overflow-x-auto rounded-md border">
@@ -87,7 +101,19 @@ export function UsageLogsTable({
                       : "-"}
                   </TableCell>
                   <TableCell className="space-y-1">
-                    <div className="text-sm">{log.model ?? t("unknownModel")}</div>
+                    <div className="flex items-center gap-1.5 text-sm">
+                      {log.model ? <ModelVendorIcon modelId={log.model} /> : null}
+                      {log.model ? (
+                        <span
+                          className="cursor-pointer hover:underline truncate"
+                          onClick={() => handleCopyModel(log.model!)}
+                        >
+                          {log.model}
+                        </span>
+                      ) : (
+                        <span>{t("unknownModel")}</span>
+                      )}
+                    </div>
                     {log.modelRedirect ? (
                       <div className="text-xs text-muted-foreground">{log.modelRedirect}</div>
                     ) : null}

+ 18 - 48
src/app/[locale]/settings/prices/_components/price-list.tsx

@@ -1,6 +1,5 @@
 "use client";
 
-import { Claude, Gemini, OpenAI } from "@lobehub/icons";
 import { formatInTimeZone } from "date-fns-tz";
 import {
   Braces,
@@ -41,6 +40,7 @@ import {
 } from "@/components/ui/select";
 import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
 import { useDebounce } from "@/lib/hooks/use-debounce";
+import { PRICE_FILTER_VENDORS } from "@/lib/model-vendor-icons";
 import { copyToClipboard } from "@/lib/utils/clipboard";
 import type { ModelPrice, ModelPriceSource } from "@/types/model-price";
 import { DeleteModelDialog } from "./delete-model-dialog";
@@ -357,53 +357,23 @@ export function PriceList({
           {t("filters.local")}
         </Button>
 
-        <Button
-          type="button"
-          variant={litellmProviderFilter === "anthropic" ? "default" : "outline"}
-          size="sm"
-          onClick={() =>
-            applyFilters({
-              source: "",
-              litellmProvider: litellmProviderFilter === "anthropic" ? "" : "anthropic",
-            })
-          }
-        >
-          <Claude.Color className="h-4 w-4 mr-2" />
-          {t("filters.anthropic")}
-        </Button>
-
-        <Button
-          type="button"
-          variant={litellmProviderFilter === "openai" ? "default" : "outline"}
-          size="sm"
-          onClick={() =>
-            applyFilters({
-              source: "",
-              litellmProvider: litellmProviderFilter === "openai" ? "" : "openai",
-            })
-          }
-        >
-          <OpenAI className="h-4 w-4 mr-2" />
-          {t("filters.openai")}
-        </Button>
-
-        <Button
-          type="button"
-          variant={litellmProviderFilter === "vertex_ai-language-models" ? "default" : "outline"}
-          size="sm"
-          onClick={() =>
-            applyFilters({
-              source: "",
-              litellmProvider:
-                litellmProviderFilter === "vertex_ai-language-models"
-                  ? ""
-                  : "vertex_ai-language-models",
-            })
-          }
-        >
-          <Gemini.Color className="h-4 w-4 mr-2" />
-          {t("filters.vertex")}
-        </Button>
+        {PRICE_FILTER_VENDORS.map(({ i18nKey, litellmProvider, icon: Icon }) => (
+          <Button
+            key={litellmProvider}
+            type="button"
+            variant={litellmProviderFilter === litellmProvider ? "default" : "outline"}
+            size="sm"
+            onClick={() =>
+              applyFilters({
+                source: "",
+                litellmProvider: litellmProviderFilter === litellmProvider ? "" : litellmProvider,
+              })
+            }
+          >
+            <Icon className="h-4 w-4 mr-2" />
+            {t(`filters.${i18nKey}`)}
+          </Button>
+        ))}
       </div>
 
       {/* 搜索和页面大小控制 */}

+ 18 - 0
src/components/customs/model-vendor-icon.tsx

@@ -0,0 +1,18 @@
+"use client";
+
+import { getModelVendor } from "@/lib/model-vendor-icons";
+
+interface ModelVendorIconProps {
+  modelId: string;
+  className?: string;
+}
+
+export function ModelVendorIcon({
+  modelId,
+  className = "h-3.5 w-3.5 shrink-0",
+}: ModelVendorIconProps) {
+  const vendor = getModelVendor(modelId);
+  if (!vendor) return null;
+  const Icon = vendor.icon;
+  return <Icon className={className} />;
+}

+ 142 - 0
src/lib/model-vendor-icons.test.ts

@@ -0,0 +1,142 @@
+import { describe, expect, it } from "vitest";
+import { getModelVendor, PRICE_FILTER_VENDORS } from "./model-vendor-icons";
+
+describe("getModelVendor", () => {
+  const cases: Array<{ modelId: string; expectedKey: string | null }> = [
+    // Anthropic
+    { modelId: "claude-sonnet-4-5-20250929", expectedKey: "anthropic" },
+    { modelId: "claude-3-opus-20240229", expectedKey: "anthropic" },
+    // OpenAI - gpt prefix
+    { modelId: "gpt-4o-mini", expectedKey: "openai" },
+    { modelId: "gpt-5.2-codex", expectedKey: "openai" },
+    // OpenAI - chatgpt prefix
+    { modelId: "chatgpt-4o-latest", expectedKey: "openai" },
+    // OpenAI - o1/o3/o4 prefix
+    { modelId: "o1-preview", expectedKey: "openai" },
+    { modelId: "o3-mini", expectedKey: "openai" },
+    { modelId: "o4-mini", expectedKey: "openai" },
+    // Gemini
+    { modelId: "gemini-2.5-pro", expectedKey: "vertex" },
+    // DeepSeek
+    { modelId: "deepseek-chat", expectedKey: "deepseek" },
+    { modelId: "deepseek-reasoner", expectedKey: "deepseek" },
+    // Mistral family
+    { modelId: "mistral-large-latest", expectedKey: "mistral" },
+    { modelId: "mixtral-8x7b-instruct", expectedKey: "mistral" },
+    { modelId: "codestral-latest", expectedKey: "mistral" },
+    { modelId: "pixtral-large", expectedKey: "mistral" },
+    // Meta
+    { modelId: "llama-3.1-70b", expectedKey: "meta" },
+    // Qwen
+    { modelId: "qwen-turbo-latest", expectedKey: "qwen" },
+    // Cohere
+    { modelId: "command-r-plus", expectedKey: "cohere" },
+    // Grok (xAI)
+    { modelId: "grok-2", expectedKey: "xai" },
+    // Perplexity
+    { modelId: "pplx-70b-online", expectedKey: "perplexity" },
+    { modelId: "sonar-pro", expectedKey: "perplexity" },
+    // Doubao / Volcengine
+    { modelId: "doubao-pro-32k", expectedKey: "volcengine" },
+    { modelId: "seed-1.6-thinking", expectedKey: "volcengine" },
+    // Zhipu
+    { modelId: "chatglm-4", expectedKey: "zhipuai" },
+    { modelId: "glm-4-plus", expectedKey: "zhipuai" },
+    // Minimax
+    { modelId: "minimax-pro", expectedKey: "minimax" },
+    { modelId: "abab-6.5", expectedKey: "minimax" },
+    // Kimi
+    { modelId: "kimi-k1.5", expectedKey: "kimi" },
+    // Moonshot
+    { modelId: "moonshot-v1-8k", expectedKey: "moonshot" },
+    // Yi
+    { modelId: "yi-lightning", expectedKey: "yi" },
+    // Stepfun
+    { modelId: "step-2-16k", expectedKey: "stepfun" },
+    // Baichuan
+    { modelId: "baichuan-4", expectedKey: "baichuan" },
+    // SenseNova
+    { modelId: "sensenova-5.5", expectedKey: "sensenova" },
+    // Spark
+    { modelId: "spark-4.0-ultra", expectedKey: "spark" },
+    // Hunyuan
+    { modelId: "hunyuan-pro", expectedKey: "hunyuan" },
+    // Wenxin / Ernie
+    { modelId: "wenxin-4", expectedKey: "wenxin" },
+    { modelId: "ernie-4.0-8k", expectedKey: "wenxin" },
+    // Gemma
+    { modelId: "gemma-2-27b", expectedKey: "gemma" },
+    // Nvidia
+    { modelId: "nvidia-nemotron-4-340b", expectedKey: "nvidia" },
+    // InternLM
+    { modelId: "internlm2-20b", expectedKey: "internlm" },
+  ];
+
+  it.each(cases)("matches '$modelId' -> $expectedKey", ({ modelId, expectedKey }) => {
+    const result = getModelVendor(modelId);
+    if (expectedKey === null) {
+      expect(result).toBeNull();
+    } else {
+      expect(result).not.toBeNull();
+      expect(result!.i18nKey).toBe(expectedKey);
+    }
+  });
+
+  it("is case-insensitive", () => {
+    expect(getModelVendor("Claude-Sonnet-4-5")?.i18nKey).toBe("anthropic");
+    expect(getModelVendor("GPT-4o")?.i18nKey).toBe("openai");
+    expect(getModelVendor("DEEPSEEK-CHAT")?.i18nKey).toBe("deepseek");
+  });
+
+  it("returns null for unknown models", () => {
+    expect(getModelVendor("unknown-model")).toBeNull();
+    expect(getModelVendor("custom-model-v2")).toBeNull();
+    expect(getModelVendor("some-random-thing")).toBeNull();
+  });
+
+  it("returns null for empty string", () => {
+    expect(getModelVendor("")).toBeNull();
+  });
+
+  it("resolves chatglm before glm (longest prefix wins)", () => {
+    const chatglm = getModelVendor("chatglm-4");
+    const glm = getModelVendor("glm-4-plus");
+    expect(chatglm?.prefix).toBe("chatglm");
+    expect(glm?.prefix).toBe("glm");
+    // Both map to zhipuai
+    expect(chatglm?.i18nKey).toBe("zhipuai");
+    expect(glm?.i18nKey).toBe("zhipuai");
+  });
+
+  it("resolves grok vs gpt correctly", () => {
+    expect(getModelVendor("grok-2")?.i18nKey).toBe("xai");
+    expect(getModelVendor("gpt-4o")?.i18nKey).toBe("openai");
+  });
+
+  it("exact prefix match works", () => {
+    // Model ID equals exactly the prefix
+    expect(getModelVendor("gpt")?.i18nKey).toBe("openai");
+    expect(getModelVendor("o1")?.i18nKey).toBe("openai");
+    expect(getModelVendor("yi")?.i18nKey).toBe("yi");
+  });
+});
+
+describe("PRICE_FILTER_VENDORS", () => {
+  it("has unique litellmProvider values", () => {
+    const providers = PRICE_FILTER_VENDORS.map((v) => v.litellmProvider);
+    expect(new Set(providers).size).toBe(providers.length);
+  });
+
+  it("has unique i18nKey values", () => {
+    const keys = PRICE_FILTER_VENDORS.map((v) => v.i18nKey);
+    expect(new Set(keys).size).toBe(keys.length);
+  });
+
+  it("includes core vendors", () => {
+    const keys = PRICE_FILTER_VENDORS.map((v) => v.i18nKey);
+    expect(keys).toContain("anthropic");
+    expect(keys).toContain("openai");
+    expect(keys).toContain("vertex");
+    expect(keys).toContain("deepseek");
+  });
+});

+ 209 - 0
src/lib/model-vendor-icons.tsx

@@ -0,0 +1,209 @@
+import {
+  Azure,
+  Baichuan,
+  Bedrock,
+  ChatGLM,
+  Claude,
+  Cohere,
+  DeepSeek,
+  Doubao,
+  Fireworks,
+  Gemini,
+  Gemma,
+  Grok,
+  Groq,
+  Hunyuan,
+  InternLM,
+  Kimi,
+  Meta,
+  Minimax,
+  Mistral,
+  Moonshot,
+  Nvidia,
+  Ollama,
+  OpenAI,
+  OpenRouter,
+  Perplexity,
+  Qwen,
+  SenseNova,
+  Spark,
+  Stepfun,
+  Together,
+  Wenxin,
+  Yi,
+  Zhipu,
+} from "@lobehub/icons";
+
+export interface ModelVendorEntry {
+  prefix: string;
+  icon: React.ComponentType<{ className?: string }>;
+  hasColor: boolean;
+  i18nKey: string;
+  litellmProvider?: string;
+}
+
+// Strictly sorted by prefix length descending to ensure longest-match-first.
+// Within same length, sorted alphabetically.
+const MODEL_VENDOR_RULES: ModelVendorEntry[] = [
+  // 9 chars
+  {
+    prefix: "codestral",
+    icon: Mistral.Color,
+    hasColor: true,
+    i18nKey: "mistral",
+    litellmProvider: "mistral",
+  },
+  { prefix: "sensenova", icon: SenseNova.Color, hasColor: true, i18nKey: "sensenova" },
+  // 8 chars
+  { prefix: "baichuan", icon: Baichuan.Color, hasColor: true, i18nKey: "baichuan" },
+  {
+    prefix: "deepseek",
+    icon: DeepSeek.Color,
+    hasColor: true,
+    i18nKey: "deepseek",
+    litellmProvider: "deepseek",
+  },
+  { prefix: "internlm", icon: InternLM.Color, hasColor: true, i18nKey: "internlm" },
+  { prefix: "moonshot", icon: Moonshot, hasColor: false, i18nKey: "moonshot" },
+  // 7 chars
+  {
+    prefix: "chatglm",
+    icon: ChatGLM.Color,
+    hasColor: true,
+    i18nKey: "zhipuai",
+    litellmProvider: "zhipuai",
+  },
+  {
+    prefix: "chatgpt",
+    icon: OpenAI,
+    hasColor: false,
+    i18nKey: "openai",
+    litellmProvider: "openai",
+  },
+  {
+    prefix: "command",
+    icon: Cohere.Color,
+    hasColor: true,
+    i18nKey: "cohere",
+    litellmProvider: "cohere_chat",
+  },
+  { prefix: "hunyuan", icon: Hunyuan.Color, hasColor: true, i18nKey: "hunyuan" },
+  { prefix: "minimax", icon: Minimax.Color, hasColor: true, i18nKey: "minimax" },
+  {
+    prefix: "mistral",
+    icon: Mistral.Color,
+    hasColor: true,
+    i18nKey: "mistral",
+    litellmProvider: "mistral",
+  },
+  {
+    prefix: "mixtral",
+    icon: Mistral.Color,
+    hasColor: true,
+    i18nKey: "mistral",
+    litellmProvider: "mistral",
+  },
+  {
+    prefix: "pixtral",
+    icon: Mistral.Color,
+    hasColor: true,
+    i18nKey: "mistral",
+    litellmProvider: "mistral",
+  },
+  // 6 chars
+  {
+    prefix: "claude",
+    icon: Claude.Color,
+    hasColor: true,
+    i18nKey: "anthropic",
+    litellmProvider: "anthropic",
+  },
+  {
+    prefix: "doubao",
+    icon: Doubao.Color,
+    hasColor: true,
+    i18nKey: "volcengine",
+    litellmProvider: "volcengine",
+  },
+  {
+    prefix: "gemini",
+    icon: Gemini.Color,
+    hasColor: true,
+    i18nKey: "vertex",
+    litellmProvider: "vertex_ai-language-models",
+  },
+  { prefix: "nvidia", icon: Nvidia.Color, hasColor: true, i18nKey: "nvidia" },
+  { prefix: "wenxin", icon: Wenxin.Color, hasColor: true, i18nKey: "wenxin" },
+  // 5 chars
+  { prefix: "ernie", icon: Wenxin.Color, hasColor: true, i18nKey: "wenxin" },
+  { prefix: "gemma", icon: Gemma.Color, hasColor: true, i18nKey: "gemma" },
+  { prefix: "llama", icon: Meta.Color, hasColor: true, i18nKey: "meta" },
+  { prefix: "sonar", icon: Perplexity.Color, hasColor: true, i18nKey: "perplexity" },
+  { prefix: "spark", icon: Spark.Color, hasColor: true, i18nKey: "spark" },
+  // 4 chars
+  { prefix: "abab", icon: Minimax.Color, hasColor: true, i18nKey: "minimax" },
+  { prefix: "grok", icon: Grok, hasColor: false, i18nKey: "xai", litellmProvider: "xai" },
+  { prefix: "kimi", icon: Kimi.Color, hasColor: true, i18nKey: "kimi" },
+  { prefix: "pplx", icon: Perplexity.Color, hasColor: true, i18nKey: "perplexity" },
+  { prefix: "qwen", icon: Qwen.Color, hasColor: true, i18nKey: "qwen" },
+  {
+    prefix: "seed",
+    icon: Doubao.Color,
+    hasColor: true,
+    i18nKey: "volcengine",
+    litellmProvider: "volcengine",
+  },
+  { prefix: "step", icon: Stepfun.Color, hasColor: true, i18nKey: "stepfun" },
+  // 3 chars
+  {
+    prefix: "glm",
+    icon: ChatGLM.Color,
+    hasColor: true,
+    i18nKey: "zhipuai",
+    litellmProvider: "zhipuai",
+  },
+  { prefix: "gpt", icon: OpenAI, hasColor: false, i18nKey: "openai", litellmProvider: "openai" },
+  // 2 chars
+  { prefix: "o1", icon: OpenAI, hasColor: false, i18nKey: "openai", litellmProvider: "openai" },
+  { prefix: "o3", icon: OpenAI, hasColor: false, i18nKey: "openai", litellmProvider: "openai" },
+  { prefix: "o4", icon: OpenAI, hasColor: false, i18nKey: "openai", litellmProvider: "openai" },
+  { prefix: "yi", icon: Yi.Color, hasColor: true, i18nKey: "yi" },
+];
+
+export function getModelVendor(modelId: string): ModelVendorEntry | null {
+  if (!modelId) return null;
+  const lower = modelId.toLowerCase();
+  for (const rule of MODEL_VENDOR_RULES) {
+    if (lower.startsWith(rule.prefix)) {
+      return rule;
+    }
+  }
+  return null;
+}
+
+export const PRICE_FILTER_VENDORS: Array<{
+  i18nKey: string;
+  litellmProvider: string;
+  icon: React.ComponentType<{ className?: string }>;
+}> = [
+  { i18nKey: "anthropic", litellmProvider: "anthropic", icon: Claude.Color },
+  { i18nKey: "openai", litellmProvider: "openai", icon: OpenAI },
+  { i18nKey: "vertex", litellmProvider: "vertex_ai-language-models", icon: Gemini.Color },
+  { i18nKey: "deepseek", litellmProvider: "deepseek", icon: DeepSeek.Color },
+  { i18nKey: "mistral", litellmProvider: "mistral", icon: Mistral.Color },
+  { i18nKey: "meta", litellmProvider: "meta", icon: Meta.Color },
+  { i18nKey: "cohere", litellmProvider: "cohere_chat", icon: Cohere.Color },
+  { i18nKey: "xai", litellmProvider: "xai", icon: Grok },
+  { i18nKey: "groq", litellmProvider: "groq", icon: Groq },
+  { i18nKey: "bedrock", litellmProvider: "bedrock", icon: Bedrock.Color },
+  { i18nKey: "azure", litellmProvider: "azure", icon: Azure.Color },
+  { i18nKey: "together", litellmProvider: "together_ai", icon: Together.Color },
+  { i18nKey: "nvidia", litellmProvider: "nvidia_nim", icon: Nvidia.Color },
+  { i18nKey: "zhipuai", litellmProvider: "zhipuai", icon: Zhipu.Color },
+  { i18nKey: "volcengine", litellmProvider: "volcengine", icon: Doubao.Color },
+  { i18nKey: "minimax", litellmProvider: "minimax", icon: Minimax.Color },
+  { i18nKey: "qwen", litellmProvider: "qwen", icon: Qwen.Color },
+  { i18nKey: "fireworks", litellmProvider: "fireworks_ai", icon: Fireworks.Color },
+  { i18nKey: "ollama", litellmProvider: "ollama", icon: Ollama },
+  { i18nKey: "openrouter", litellmProvider: "openrouter", icon: OpenRouter },
+];