Quellcode durchsuchen

feat(providers): expose vendor endpoint pools in settings UI (#719)

* feat(providers): add endpoint status mapping

* feat(providers): add endpoint pool hover

* feat(providers): show vendor endpoints in list rows

* feat(providers): extract vendor endpoint CRUD table

* chore(i18n): add provider endpoint UI strings

* fix(providers): integrate endpoint pool into provider form
Ding vor 2 Monaten
Ursprung
Commit
137fdd707f
30 geänderte Dateien mit 2841 neuen und 582 gelöschten Zeilen
  1. 1 1
      biome.json
  2. 3 0
      messages/en/settings/providers/form/errors.json
  3. 4 0
      messages/en/settings/providers/form/sections.json
  4. 12 1
      messages/en/settings/providers/strings.json
  5. 3 0
      messages/ja/settings/providers/form/errors.json
  6. 4 0
      messages/ja/settings/providers/form/sections.json
  7. 12 1
      messages/ja/settings/providers/strings.json
  8. 3 0
      messages/ru/settings/providers/form/errors.json
  9. 4 0
      messages/ru/settings/providers/form/sections.json
  10. 12 1
      messages/ru/settings/providers/strings.json
  11. 3 0
      messages/zh-CN/settings/providers/form/errors.json
  12. 4 0
      messages/zh-CN/settings/providers/form/sections.json
  13. 12 1
      messages/zh-CN/settings/providers/strings.json
  14. 3 0
      messages/zh-TW/settings/providers/form/errors.json
  15. 4 0
      messages/zh-TW/settings/providers/form/sections.json
  16. 12 1
      messages/zh-TW/settings/providers/strings.json
  17. 107 0
      src/app/[locale]/settings/providers/_components/endpoint-status.ts
  18. 136 9
      src/app/[locale]/settings/providers/_components/forms/provider-form/index.tsx
  19. 53 28
      src/app/[locale]/settings/providers/_components/forms/provider-form/sections/basic-info-section.tsx
  20. 154 0
      src/app/[locale]/settings/providers/_components/provider-endpoint-hover.tsx
  21. 717 0
      src/app/[locale]/settings/providers/_components/provider-endpoints-table.tsx
  22. 24 3
      src/app/[locale]/settings/providers/_components/provider-rich-list-item.tsx
  23. 9 519
      src/app/[locale]/settings/providers/_components/provider-vendor-view.tsx
  24. 26 4
      tests/unit/i18n/zh-tw-providers-strings-quality.test.ts
  25. 101 0
      tests/unit/settings/providers/endpoint-status.test.ts
  26. 244 0
      tests/unit/settings/providers/provider-endpoint-hover.test.tsx
  27. 567 0
      tests/unit/settings/providers/provider-endpoints-table.test.tsx
  28. 313 0
      tests/unit/settings/providers/provider-form-endpoint-pool.test.tsx
  29. 55 13
      tests/unit/settings/providers/provider-form-total-limit-ui.test.tsx
  30. 239 0
      tests/unit/settings/providers/provider-rich-list-item-endpoints.test.tsx

+ 1 - 1
biome.json

@@ -1,5 +1,5 @@
 {
-  "$schema": "https://biomejs.dev/schemas/2.3.11/schema.json",
+  "$schema": "https://biomejs.dev/schemas/2.3.10/schema.json",
   "vcs": {
     "enabled": true,
     "clientKind": "git",

+ 3 - 0
messages/en/settings/providers/form/errors.json

@@ -2,7 +2,10 @@
   "addFailed": "Failed to add provider",
   "deleteFailed": "Failed to delete provider",
   "groupTagTooLong": "Provider group tags are too long (max {max} chars total)",
+  "keyRequired": "Please enter an API key",
+  "nameRequired": "Please enter a provider name",
   "invalidUrl": "Please enter a valid API address",
+  "urlRequired": "Please enter an API address",
   "invalidWebsiteUrl": "Please enter a valid provider website URL",
   "updateFailed": "Failed to update provider"
 }

+ 4 - 0
messages/en/settings/providers/form/sections.json

@@ -8,6 +8,10 @@
       "title": "API Endpoint",
       "desc": "Configure the base URL for API requests"
     },
+    "endpointPool": {
+      "title": "Endpoint Pool",
+      "desc": "Manage vendor endpoints for this provider type"
+    },
     "auth": {
       "title": "Authentication",
       "desc": "Provide your API key for authentication"

+ 12 - 1
messages/en/settings/providers/strings.json

@@ -88,6 +88,7 @@
   "endpointUrlPlaceholder": "https://api.example.com/v1",
   "endpointLabelOptional": "Label (optional)",
   "endpointLabelPlaceholder": "Production endpoint",
+  "sortOrder": "Sort Order",
   "editEndpoint": "Edit Endpoint",
   "editVendor": "Edit Vendor",
   "vendorName": "Vendor Name",
@@ -102,5 +103,15 @@
   "vendorUpdateSuccess": "Vendor updated successfully",
   "vendorUpdateFailed": "Failed to update vendor",
   "vendorDeleteSuccess": "Vendor deleted successfully",
-  "vendorDeleteFailed": "Failed to delete vendor"
+  "vendorDeleteFailed": "Failed to delete vendor",
+  "endpointStatus": {
+    "viewDetails": "View Details ({count})",
+    "activeEndpoints": "Active Endpoints",
+    "noEndpoints": "No Endpoints",
+    "healthy": "Healthy",
+    "unhealthy": "Unhealthy",
+    "unknown": "Unknown",
+    "circuitOpen": "Circuit Open",
+    "circuitHalfOpen": "Circuit Half-Open"
+  }
 }

+ 3 - 0
messages/ja/settings/providers/form/errors.json

@@ -2,7 +2,10 @@
   "addFailed": "プロバイダーの追加に失敗しました",
   "deleteFailed": "プロバイダーの削除に失敗しました",
   "groupTagTooLong": "プロバイダーグループが長すぎます(合計{max}文字まで)",
+  "keyRequired": "API キーを入力してください",
+  "nameRequired": "プロバイダー名を入力してください",
   "invalidUrl": "有効な API アドレスを入力してください",
+  "urlRequired": "プロバイダー URL を入力してください",
   "invalidWebsiteUrl": "有効な公式サイト URL を入力してください",
   "updateFailed": "プロバイダーの更新に失敗しました"
 }

+ 4 - 0
messages/ja/settings/providers/form/sections.json

@@ -8,6 +8,10 @@
       "title": "API エンドポイント",
       "desc": "API リクエストのベース URL を設定"
     },
+    "endpointPool": {
+      "title": "エンドポイントプール",
+      "desc": "このプロバイダー種別のエンドポイントを管理"
+    },
     "auth": {
       "title": "認証",
       "desc": "認証用の API キーを入力"

+ 12 - 1
messages/ja/settings/providers/strings.json

@@ -88,6 +88,7 @@
   "endpointUrlPlaceholder": "https://api.example.com/v1",
   "endpointLabelOptional": "ラベル (任意)",
   "endpointLabelPlaceholder": "本番環境",
+  "sortOrder": "並び順",
   "editEndpoint": "エンドポイントを編集",
   "editVendor": "ベンダーを編集",
   "vendorName": "ベンダー名",
@@ -102,5 +103,15 @@
   "vendorUpdateSuccess": "ベンダーを更新しました",
   "vendorUpdateFailed": "ベンダーの更新に失敗しました",
   "vendorDeleteSuccess": "ベンダーを削除しました",
-  "vendorDeleteFailed": "ベンダーの削除に失敗しました"
+  "vendorDeleteFailed": "ベンダーの削除に失敗しました",
+  "endpointStatus": {
+    "viewDetails": "詳細を見る({count})",
+    "activeEndpoints": "有効なエンドポイント",
+    "noEndpoints": "エンドポイントなし",
+    "healthy": "正常",
+    "unhealthy": "異常",
+    "unknown": "不明",
+    "circuitOpen": "サーキットオープン",
+    "circuitHalfOpen": "サーキット半開"
+  }
 }

+ 3 - 0
messages/ru/settings/providers/form/errors.json

@@ -2,7 +2,10 @@
   "addFailed": "Не удалось добавить провайдера",
   "deleteFailed": "Не удалось удалить провайдера",
   "groupTagTooLong": "Список групп провайдера слишком длинный (макс. {max} символов всего)",
+  "keyRequired": "Введите API-ключ",
+  "nameRequired": "Введите имя провайдера",
   "invalidUrl": "Введите корректный адрес API",
+  "urlRequired": "Введите URL провайдера",
   "invalidWebsiteUrl": "Введите корректный адрес сайта провайдера",
   "updateFailed": "Не удалось обновить провайдера"
 }

+ 4 - 0
messages/ru/settings/providers/form/sections.json

@@ -8,6 +8,10 @@
       "title": "API Endpoint",
       "desc": "Настройте базовый URL для API запросов"
     },
+    "endpointPool": {
+      "title": "Пул эндпоинтов",
+      "desc": "Управляйте эндпоинтами для этого типа провайдера"
+    },
     "auth": {
       "title": "Аутентификация",
       "desc": "Укажите API ключ для аутентификации"

+ 12 - 1
messages/ru/settings/providers/strings.json

@@ -88,6 +88,7 @@
   "endpointUrlPlaceholder": "https://api.example.com/v1",
   "endpointLabelOptional": "Метка (необязательно)",
   "endpointLabelPlaceholder": "Продакшн",
+  "sortOrder": "Порядок",
   "editEndpoint": "Редактировать эндпоинт",
   "editVendor": "Редактировать вендора",
   "vendorName": "Название вендора",
@@ -102,5 +103,15 @@
   "vendorUpdateSuccess": "Вендор обновлён",
   "vendorUpdateFailed": "Не удалось обновить вендора",
   "vendorDeleteSuccess": "Вендор удалён",
-  "vendorDeleteFailed": "Не удалось удалить вендора"
+  "vendorDeleteFailed": "Не удалось удалить вендора",
+  "endpointStatus": {
+    "viewDetails": "Подробнее ({count})",
+    "activeEndpoints": "Активные эндпоинты",
+    "noEndpoints": "Нет эндпоинтов",
+    "healthy": "Доступен",
+    "unhealthy": "Недоступен",
+    "unknown": "Неизвестно",
+    "circuitOpen": "Circuit открыт",
+    "circuitHalfOpen": "Circuit полуоткрыт"
+  }
 }

+ 3 - 0
messages/zh-CN/settings/providers/form/errors.json

@@ -2,6 +2,9 @@
   "invalidUrl": "请输入有效的 API 地址",
   "invalidWebsiteUrl": "请输入有效的供应商官网地址",
   "groupTagTooLong": "分组标签总长度不能超过 {max} 个字符",
+  "nameRequired": "请输入供应商名称",
+  "urlRequired": "请先填写供应商 URL",
+  "keyRequired": "请输入 API 密钥",
   "addFailed": "添加服务商失败",
   "updateFailed": "更新服务商失败",
   "deleteFailed": "删除服务商失败"

+ 4 - 0
messages/zh-CN/settings/providers/form/sections.json

@@ -8,6 +8,10 @@
       "title": "API 端点",
       "desc": "配置 API 请求的基础 URL"
     },
+    "endpointPool": {
+      "title": "端点池",
+      "desc": "管理该供应商类型的端点池"
+    },
     "auth": {
       "title": "身份认证",
       "desc": "提供用于认证的 API 密钥"

+ 12 - 1
messages/zh-CN/settings/providers/strings.json

@@ -88,6 +88,7 @@
   "endpointUrlPlaceholder": "https://api.example.com/v1",
   "endpointLabelOptional": "标签(可选)",
   "endpointLabelPlaceholder": "生产环境",
+  "sortOrder": "排序",
   "editEndpoint": "编辑端点",
   "editVendor": "编辑服务商",
   "vendorName": "服务商名称",
@@ -102,5 +103,15 @@
   "vendorUpdateSuccess": "服务商更新成功",
   "vendorUpdateFailed": "更新服务商失败",
   "vendorDeleteSuccess": "服务商删除成功",
-  "vendorDeleteFailed": "删除服务商失败"
+  "vendorDeleteFailed": "删除服务商失败",
+  "endpointStatus": {
+    "viewDetails": "查看详情({count})",
+    "activeEndpoints": "启用端点",
+    "noEndpoints": "无端点",
+    "healthy": "健康",
+    "unhealthy": "故障",
+    "unknown": "未知",
+    "circuitOpen": "熔断开启",
+    "circuitHalfOpen": "熔断半开"
+  }
 }

+ 3 - 0
messages/zh-TW/settings/providers/form/errors.json

@@ -2,7 +2,10 @@
   "addFailed": "新增供應商失敗",
   "deleteFailed": "刪除供應商失敗",
   "groupTagTooLong": "分組標籤總長度不能超過 {max} 個字元",
+  "keyRequired": "請輸入 API 金鑰",
+  "nameRequired": "請輸入供應商名稱",
   "invalidUrl": "請輸入有效的 API 位址",
+  "urlRequired": "請先填寫供應商 URL",
   "invalidWebsiteUrl": "請輸入有效的供應商官網",
   "updateFailed": "更新供應商失敗"
 }

+ 4 - 0
messages/zh-TW/settings/providers/form/sections.json

@@ -8,6 +8,10 @@
       "title": "API 端點",
       "desc": "設定 API 請求的基礎 URL"
     },
+    "endpointPool": {
+      "title": "端點池",
+      "desc": "管理此供應商類型的端點池"
+    },
     "auth": {
       "title": "身份驗證",
       "desc": "提供用於驗證的 API 金鑰"

+ 12 - 1
messages/zh-TW/settings/providers/strings.json

@@ -88,6 +88,7 @@
   "endpointUrlPlaceholder": "https://api.example.com/v1",
   "endpointLabelOptional": "標籤(選填)",
   "endpointLabelPlaceholder": "生產環境",
+  "sortOrder": "排序",
   "editEndpoint": "編輯端點",
   "editVendor": "編輯供應商",
   "vendorName": "供應商名稱",
@@ -102,5 +103,15 @@
   "vendorUpdateSuccess": "供應商已更新",
   "vendorUpdateFailed": "更新供應商失敗",
   "vendorDeleteSuccess": "供應商已刪除",
-  "vendorDeleteFailed": "刪除供應商失敗"
+  "vendorDeleteFailed": "刪除供應商失敗",
+  "endpointStatus": {
+    "viewDetails": "檢視詳情({count})",
+    "activeEndpoints": "啟用端點",
+    "noEndpoints": "無端點",
+    "healthy": "健康",
+    "unhealthy": "故障",
+    "unknown": "未知",
+    "circuitOpen": "熔斷開啟",
+    "circuitHalfOpen": "熔斷半開"
+  }
 }

+ 107 - 0
src/app/[locale]/settings/providers/_components/endpoint-status.ts

@@ -0,0 +1,107 @@
+import {
+  AlertTriangle,
+  Ban,
+  CheckCircle2,
+  HelpCircle,
+  type LucideIcon,
+  XCircle,
+} from "lucide-react";
+import type { ProviderEndpoint } from "@/types/provider";
+
+export type EndpointCircuitState = "closed" | "open" | "half-open";
+
+export type EndpointStatusSeverity = "success" | "error" | "warning" | "neutral";
+
+export type EndpointStatusToken =
+  | "healthy"
+  | "unhealthy"
+  | "unknown"
+  | "circuit-open"
+  | "circuit-half-open";
+
+export interface EndpointStatusModel {
+  status: EndpointStatusToken;
+  labelKey: string;
+  severity: EndpointStatusSeverity;
+  icon: LucideIcon;
+  color: string;
+  bgColor: string;
+  borderColor: string;
+}
+
+/**
+ * Determines the UI status model for an endpoint based on its probe snapshot and circuit state.
+ *
+ * Logic:
+ * 1. Circuit Open -> 'circuit-open' (Error)
+ * 2. Circuit Half-Open -> 'circuit-half-open' (Warning)
+ * 3. Circuit Closed (or missing):
+ *    - lastProbeOk === true -> 'healthy' (Success)
+ *    - lastProbeOk === false -> 'unhealthy' (Error)
+ *    - lastProbeOk === null -> 'unknown' (Neutral)
+ */
+export function getEndpointStatusModel(
+  endpoint: Pick<ProviderEndpoint, "lastProbeOk">,
+  circuitState?: EndpointCircuitState | null
+): EndpointStatusModel {
+  // 1. Circuit Breaker Priority
+  if (circuitState === "open") {
+    return {
+      status: "circuit-open",
+      labelKey: "settings.providers.endpointStatus.circuitOpen",
+      severity: "error",
+      icon: Ban,
+      color: "text-rose-500",
+      bgColor: "bg-rose-500/10",
+      borderColor: "border-rose-500/30",
+    };
+  }
+
+  if (circuitState === "half-open") {
+    return {
+      status: "circuit-half-open",
+      labelKey: "settings.providers.endpointStatus.circuitHalfOpen",
+      severity: "warning",
+      icon: AlertTriangle,
+      color: "text-amber-500",
+      bgColor: "bg-amber-500/10",
+      borderColor: "border-amber-500/30",
+    };
+  }
+
+  // 2. Probe Status Fallback (Circuit Closed)
+  if (endpoint.lastProbeOk === true) {
+    return {
+      status: "healthy",
+      labelKey: "settings.providers.endpointStatus.healthy",
+      severity: "success",
+      icon: CheckCircle2,
+      color: "text-emerald-500",
+      bgColor: "bg-emerald-500/10",
+      borderColor: "border-emerald-500/30",
+    };
+  }
+
+  if (endpoint.lastProbeOk === false) {
+    return {
+      status: "unhealthy",
+      labelKey: "settings.providers.endpointStatus.unhealthy",
+      severity: "error",
+      icon: XCircle,
+      color: "text-rose-500",
+      bgColor: "bg-rose-500/10",
+      borderColor: "border-rose-500/30",
+    };
+  }
+
+  // 3. Unknown
+  return {
+    status: "unknown",
+    labelKey: "settings.providers.endpointStatus.unknown",
+    severity: "neutral",
+    icon: HelpCircle,
+    color: "text-slate-400",
+    bgColor: "bg-slate-400/10",
+    borderColor: "border-slate-400/30",
+  };
+}

+ 136 - 9
src/app/[locale]/settings/providers/_components/forms/provider-form/index.tsx

@@ -1,8 +1,10 @@
 "use client";
 
+import { useQuery, useQueryClient } from "@tanstack/react-query";
 import { useTranslations } from "next-intl";
-import { useCallback, useEffect, useRef, useState, useTransition } from "react";
+import { useCallback, useEffect, useMemo, useRef, useState, useTransition } from "react";
 import { toast } from "sonner";
+import { getProviderEndpoints, getProviderVendors } from "@/actions/provider-endpoints";
 import { addProvider, editProvider, removeProvider } from "@/actions/providers";
 import { getDistinctProviderGroupsAction } from "@/actions/request-filters";
 import {
@@ -18,7 +20,12 @@ import {
 } from "@/components/ui/alert-dialog";
 import { Button } from "@/components/ui/button";
 import { isValidUrl } from "@/lib/utils/validation";
-import type { ProviderDisplay, ProviderType } from "@/types/provider";
+import type {
+  ProviderDisplay,
+  ProviderEndpoint,
+  ProviderType,
+  ProviderVendor,
+} from "@/types/provider";
 import { FormTabNav } from "./components/form-tab-nav";
 import { ProviderFormProvider, useProviderForm } from "./provider-form-context";
 import type { TabId } from "./provider-form-types";
@@ -28,6 +35,29 @@ import { NetworkSection } from "./sections/network-section";
 import { RoutingSection } from "./sections/routing-section";
 import { TestingSection } from "./sections/testing-section";
 
+function normalizeWebsiteDomainFromUrl(rawUrl: string): string | null {
+  const trimmed = rawUrl.trim();
+  if (!trimmed) return null;
+
+  const candidates = [trimmed];
+  if (!/^[a-zA-Z][a-zA-Z\d+.-]*:\/\//.test(trimmed)) {
+    candidates.push(`https://${trimmed}`);
+  }
+
+  for (const candidate of candidates) {
+    try {
+      const parsed = new URL(candidate);
+      const hostname = parsed.hostname?.toLowerCase();
+      if (!hostname) continue;
+      return hostname.startsWith("www.") ? hostname.slice(4) : hostname;
+    } catch {
+      // ignore
+    }
+  }
+
+  return null;
+}
+
 export interface ProviderFormProps {
   mode: "create" | "edit";
   onSuccess?: () => void;
@@ -61,6 +91,80 @@ function ProviderFormContent({
   const [isPending, startTransition] = useTransition();
   const isEdit = mode === "edit";
 
+  const queryClient = useQueryClient();
+  const { data: vendors = [] } = useQuery<ProviderVendor[]>({
+    queryKey: ["provider-vendors"],
+    queryFn: getProviderVendors,
+  });
+
+  const websiteDomain = useMemo(
+    () => normalizeWebsiteDomainFromUrl(state.basic.websiteUrl),
+    [state.basic.websiteUrl]
+  );
+
+  const resolvedEndpointPoolVendorId = useMemo(() => {
+    // Edit mode: vendor id already attached to provider record
+    if (isEdit) {
+      return provider?.providerVendorId ?? null;
+    }
+
+    // Create/clone: resolve vendor from websiteUrl hostname
+    if (!websiteDomain) return null;
+    const vendor = vendors.find((v) => v.websiteDomain === websiteDomain);
+    return vendor?.id ?? null;
+  }, [isEdit, provider?.providerVendorId, vendors, websiteDomain]);
+
+  const endpointPoolQueryKey = useMemo(() => {
+    if (resolvedEndpointPoolVendorId == null) return null;
+    return [
+      "provider-endpoints",
+      resolvedEndpointPoolVendorId,
+      state.routing.providerType,
+      "provider-form",
+    ] as const;
+  }, [resolvedEndpointPoolVendorId, state.routing.providerType]);
+
+  const { data: endpointPoolEndpoints = [] } = useQuery<ProviderEndpoint[]>({
+    enabled: !hideUrl && endpointPoolQueryKey != null,
+    queryKey: endpointPoolQueryKey ?? ["provider-endpoints", "unresolved", "provider-form"],
+    queryFn: async () => {
+      if (resolvedEndpointPoolVendorId == null) return [];
+      return await getProviderEndpoints({
+        vendorId: resolvedEndpointPoolVendorId,
+        providerType: state.routing.providerType,
+      });
+    },
+  });
+
+  const enabledEndpointPoolEndpoints = useMemo(
+    () => endpointPoolEndpoints.filter((e) => e.isEnabled && !e.deletedAt),
+    [endpointPoolEndpoints]
+  );
+
+  const endpointPoolHasEnabledEndpoints = enabledEndpointPoolEndpoints.length > 0;
+  const endpointPoolPreferredUrl =
+    (enabledEndpointPoolEndpoints[0] ?? endpointPoolEndpoints[0])?.url ?? null;
+
+  const endpointPoolHideLegacyUrlInput =
+    !hideUrl && resolvedEndpointPoolVendorId != null && endpointPoolHasEnabledEndpoints;
+
+  // Keep state.basic.url usable across other sections when legacy URL input is hidden.
+  useEffect(() => {
+    if (isEdit) return;
+    if (hideUrl) return;
+    if (!endpointPoolHideLegacyUrlInput) return;
+    if (!endpointPoolPreferredUrl) return;
+    if (state.basic.url.trim()) return;
+    dispatch({ type: "SET_URL", payload: endpointPoolPreferredUrl });
+  }, [
+    isEdit,
+    hideUrl,
+    endpointPoolHideLegacyUrlInput,
+    endpointPoolPreferredUrl,
+    state.basic.url,
+    dispatch,
+  ]);
+
   // Update URL when resolved URL changes
   useEffect(() => {
     if (resolvedUrl && !state.basic.url && !isEdit) {
@@ -142,12 +246,15 @@ function ProviderFormContent({
     if (!state.basic.name.trim()) {
       return t("errors.nameRequired");
     }
-    if (!hideUrl && !state.basic.url.trim()) {
+
+    const needsLegacyUrl = !hideUrl && !endpointPoolHideLegacyUrlInput;
+    if (needsLegacyUrl && !state.basic.url.trim()) {
       return t("errors.urlRequired");
     }
-    if (!hideUrl && !isValidUrl(state.basic.url)) {
+    if (needsLegacyUrl && !isValidUrl(state.basic.url)) {
       return t("errors.invalidUrl");
     }
+
     if (!isEdit && !state.basic.key.trim()) {
       return t("errors.keyRequired");
     }
@@ -187,9 +294,13 @@ function ProviderFormContent({
         const trimmedKey = state.basic.key.trim();
 
         // Base form data without key (for type safety)
+        const effectiveProviderUrl = endpointPoolHideLegacyUrlInput
+          ? (endpointPoolPreferredUrl ?? state.basic.url).trim()
+          : state.basic.url.trim();
+
         const baseFormData = {
           name: state.basic.name.trim(),
-          url: state.basic.url.trim(),
+          url: effectiveProviderUrl,
           website_url: state.basic.websiteUrl?.trim() || null,
           provider_type: state.routing.providerType,
           preserve_client_ip: state.routing.preserveClientIp,
@@ -249,16 +360,20 @@ function ProviderFormContent({
           const createFormData = { ...baseFormData, key: trimmedKey };
           const res = await addProvider(createFormData);
           if (!res.ok) {
-            toast.error(res.error || t("errors.createFailed"));
+            toast.error(res.error || t("errors.addFailed"));
             return;
           }
+
+          queryClient.invalidateQueries({ queryKey: ["provider-vendors"] });
+          queryClient.invalidateQueries({ queryKey: ["provider-endpoints"] });
+
           toast.success(t("success.created"));
           dispatch({ type: "RESET_FORM" });
         }
         onSuccess?.();
       } catch (e) {
         console.error("Form submission error:", e);
-        toast.error(isEdit ? t("errors.updateFailed") : t("errors.createFailed"));
+        toast.error(isEdit ? t("errors.updateFailed") : t("errors.addFailed"));
       }
     });
   };
@@ -304,7 +419,8 @@ function ProviderFormContent({
     const status: Partial<Record<TabId, "default" | "warning" | "configured">> = {};
 
     // Basic - warning if required fields missing
-    if (!state.basic.name.trim() || (!hideUrl && !state.basic.url.trim())) {
+    const needsLegacyUrl = !hideUrl && !endpointPoolHideLegacyUrlInput;
+    if (!state.basic.name.trim() || (needsLegacyUrl && !state.basic.url.trim())) {
       status.basic = "warning";
     }
 
@@ -366,7 +482,18 @@ function ProviderFormContent({
                 sectionRefs.current.basic = el;
               }}
             >
-              <BasicInfoSection autoUrlPending={autoUrlPending} />
+              <BasicInfoSection
+                autoUrlPending={autoUrlPending}
+                endpointPool={
+                  !hideUrl && resolvedEndpointPoolVendorId != null
+                    ? {
+                        vendorId: resolvedEndpointPoolVendorId,
+                        providerType: state.routing.providerType,
+                        hideLegacyUrlInput: endpointPoolHideLegacyUrlInput,
+                      }
+                    : null
+                }
+              />
             </div>
 
             {/* Routing Section */}

+ 53 - 28
src/app/[locale]/settings/providers/_components/forms/provider-form/sections/basic-info-section.tsx

@@ -4,7 +4,9 @@ import { motion } from "framer-motion";
 import { ExternalLink, Eye, EyeOff, Globe, Key, Link2, User } from "lucide-react";
 import { useTranslations } from "next-intl";
 import { useEffect, useRef, useState } from "react";
+import { ProviderEndpointsSection } from "@/app/[locale]/settings/providers/_components/provider-endpoints-table";
 import { Input } from "@/components/ui/input";
+import type { ProviderType } from "@/types/provider";
 import { UrlPreview } from "../../url-preview";
 import { QuickPasteDialog } from "../components/quick-paste-dialog";
 import { SectionCard, SmartInputWrapper } from "../components/section-card";
@@ -12,9 +14,14 @@ import { useProviderForm } from "../provider-form-context";
 
 interface BasicInfoSectionProps {
   autoUrlPending?: boolean;
+  endpointPool?: {
+    vendorId: number;
+    providerType: ProviderType;
+    hideLegacyUrlInput: boolean;
+  } | null;
 }
 
-export function BasicInfoSection({ autoUrlPending }: BasicInfoSectionProps) {
+export function BasicInfoSection({ autoUrlPending, endpointPool }: BasicInfoSectionProps) {
   const t = useTranslations("settings.providers.form");
   const tProviders = useTranslations("settings.providers");
   const { state, dispatch, mode, provider, hideUrl, hideWebsiteUrl } = useProviderForm();
@@ -64,8 +71,50 @@ export function BasicInfoSection({ autoUrlPending }: BasicInfoSectionProps) {
         </div>
       </SectionCard>
 
+      {/* Website URL */}
+      {!hideWebsiteUrl && (
+        <SectionCard
+          title={t("websiteUrl.label")}
+          description={t("websiteUrl.desc")}
+          icon={ExternalLink}
+        >
+          <SmartInputWrapper label={t("websiteUrl.label")}>
+            <div className="relative">
+              <Input
+                id={isEdit ? "edit-website-url" : "website-url"}
+                type="url"
+                value={state.basic.websiteUrl}
+                onChange={(e) => dispatch({ type: "SET_WEBSITE_URL", payload: e.target.value })}
+                placeholder={t("websiteUrl.placeholder")}
+                disabled={state.ui.isPending}
+                className="pr-10"
+              />
+              <ExternalLink className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" />
+            </div>
+          </SmartInputWrapper>
+        </SectionCard>
+      )}
+
+      {/* Endpoint Pool */}
+      {!hideUrl && endpointPool?.vendorId ? (
+        <SectionCard
+          title={t("sections.basic.endpointPool.title")}
+          description={t("sections.basic.endpointPool.desc")}
+          icon={Globe}
+        >
+          <div className="-mx-5 -mb-5">
+            <ProviderEndpointsSection
+              vendorId={endpointPool.vendorId}
+              providerType={endpointPool.providerType}
+              hideTypeColumn={true}
+              queryKeySuffix="provider-form"
+            />
+          </div>
+        </SectionCard>
+      ) : null}
+
       {/* API Endpoint */}
-      {!hideUrl ? (
+      {!hideUrl && !endpointPool?.hideLegacyUrlInput ? (
         <SectionCard
           title={t("sections.basic.endpoint.title")}
           description={t("sections.basic.endpoint.desc")}
@@ -98,7 +147,7 @@ export function BasicInfoSection({ autoUrlPending }: BasicInfoSectionProps) {
             )}
           </div>
         </SectionCard>
-      ) : (
+      ) : hideUrl ? (
         <>
           {/* No endpoints warning */}
           {!isEdit && !autoUrlPending && !state.basic.url.trim() && (
@@ -116,7 +165,7 @@ export function BasicInfoSection({ autoUrlPending }: BasicInfoSectionProps) {
             </div>
           )}
         </>
-      )}
+      ) : null}
 
       {/* Authentication */}
       <SectionCard
@@ -153,30 +202,6 @@ export function BasicInfoSection({ autoUrlPending }: BasicInfoSectionProps) {
           </SmartInputWrapper>
         </div>
       </SectionCard>
-
-      {/* Website URL */}
-      {!hideWebsiteUrl && (
-        <SectionCard
-          title={t("websiteUrl.label")}
-          description={t("websiteUrl.desc")}
-          icon={ExternalLink}
-        >
-          <SmartInputWrapper label={t("websiteUrl.label")}>
-            <div className="relative">
-              <Input
-                id={isEdit ? "edit-website-url" : "website-url"}
-                type="url"
-                value={state.basic.websiteUrl}
-                onChange={(e) => dispatch({ type: "SET_WEBSITE_URL", payload: e.target.value })}
-                placeholder={t("websiteUrl.placeholder")}
-                disabled={state.ui.isPending}
-                className="pr-10"
-              />
-              <ExternalLink className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" />
-            </div>
-          </SmartInputWrapper>
-        </SectionCard>
-      )}
     </motion.div>
   );
 }

+ 154 - 0
src/app/[locale]/settings/providers/_components/provider-endpoint-hover.tsx

@@ -0,0 +1,154 @@
+"use client";
+
+import { useQuery } from "@tanstack/react-query";
+import { Server } from "lucide-react";
+import { useTranslations } from "next-intl";
+import { useMemo, useState } from "react";
+import { getEndpointCircuitInfo, getProviderEndpointsByVendor } from "@/actions/provider-endpoints";
+import { Badge } from "@/components/ui/badge";
+import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
+import { cn } from "@/lib/utils";
+import type { ProviderEndpoint, ProviderType } from "@/types/provider";
+import { getEndpointStatusModel } from "./endpoint-status";
+
+interface ProviderEndpointHoverProps {
+  vendorId: number;
+  providerType: ProviderType;
+}
+
+export function ProviderEndpointHover({ vendorId, providerType }: ProviderEndpointHoverProps) {
+  const t = useTranslations("settings.providers");
+  const [isOpen, setIsOpen] = useState(false);
+
+  const { data: allEndpoints = [] } = useQuery({
+    queryKey: ["provider-endpoints", vendorId],
+    queryFn: async () => getProviderEndpointsByVendor({ vendorId }),
+    staleTime: 1000 * 30,
+  });
+
+  const endpoints = useMemo(() => {
+    return allEndpoints
+      .filter(
+        (ep) => ep.providerType === providerType && ep.isEnabled === true && ep.deletedAt === null
+      )
+      .sort((a, b) => {
+        const getStatusScore = (ok: boolean | null) => {
+          if (ok === true) return 0;
+          if (ok === null) return 1;
+          return 2;
+        };
+        const scoreA = getStatusScore(a.lastProbeOk);
+        const scoreB = getStatusScore(b.lastProbeOk);
+        if (scoreA !== scoreB) return scoreA - scoreB;
+
+        const sortA = a.sortOrder ?? 0;
+        const sortB = b.sortOrder ?? 0;
+        if (sortA !== sortB) return sortA - sortB;
+
+        const latA = a.lastProbeLatencyMs ?? Number.MAX_SAFE_INTEGER;
+        const latB = b.lastProbeLatencyMs ?? Number.MAX_SAFE_INTEGER;
+        if (latA !== latB) return latA - latB;
+
+        return a.id - b.id;
+      });
+  }, [allEndpoints, providerType]);
+
+  const count = endpoints.length;
+
+  return (
+    <TooltipProvider>
+      <Tooltip open={isOpen} onOpenChange={setIsOpen} delayDuration={200}>
+        <TooltipTrigger asChild>
+          <div
+            className="flex items-center gap-1.5 cursor-help opacity-80 hover:opacity-100 transition-opacity focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 rounded px-1"
+            tabIndex={0}
+            role="button"
+            aria-label={t("endpointStatus.viewDetails", { count })}
+            data-testid="endpoint-hover-trigger"
+          >
+            <Server className="h-3.5 w-3.5 text-muted-foreground" />
+            <span className="text-xs font-medium text-muted-foreground tabular-nums">{count}</span>
+          </div>
+        </TooltipTrigger>
+        <TooltipContent
+          side="right"
+          className="p-0 border shadow-lg rounded-lg overflow-hidden min-w-[280px] max-w-[320px] bg-popover text-popover-foreground"
+        >
+          <div className="bg-muted/40 px-3 py-2 border-b">
+            <h4 className="text-xs font-semibold text-foreground">
+              {t("endpointStatus.activeEndpoints")} ({count})
+            </h4>
+          </div>
+          <div className="max-h-[300px] overflow-y-auto py-1">
+            {count === 0 ? (
+              <div className="px-3 py-4 text-center text-xs text-muted-foreground">
+                {t("endpointStatus.noEndpoints")}
+              </div>
+            ) : (
+              <div className="flex flex-col gap-0.5">
+                {endpoints.map((endpoint) => (
+                  <EndpointRow key={endpoint.id} endpoint={endpoint} isOpen={isOpen} />
+                ))}
+              </div>
+            )}
+          </div>
+        </TooltipContent>
+      </Tooltip>
+    </TooltipProvider>
+  );
+}
+
+function EndpointRow({ endpoint, isOpen }: { endpoint: ProviderEndpoint; isOpen: boolean }) {
+  const t = useTranslations("settings.providers");
+
+  const { data: circuitResult } = useQuery({
+    queryKey: ["endpoint-circuit", endpoint.id],
+    queryFn: async () => getEndpointCircuitInfo({ endpointId: endpoint.id }),
+    enabled: isOpen,
+    staleTime: 1000 * 10,
+  });
+
+  const circuitState =
+    circuitResult?.ok && circuitResult.data ? circuitResult.data.health.circuitState : undefined;
+
+  const statusModel = getEndpointStatusModel(endpoint, circuitState);
+  const Icon = statusModel.icon;
+
+  return (
+    <div className="px-3 py-2 hover:bg-muted/50 transition-colors flex items-start gap-3 group">
+      <div className="mt-0.5 shrink-0">
+        <Icon className={cn("h-3.5 w-3.5", statusModel.color)} />
+      </div>
+      <div className="flex-1 min-w-0 space-y-1">
+        <div className="flex items-center justify-between gap-2">
+          <span className="text-xs font-medium truncate text-foreground/90">{endpoint.url}</span>
+          {endpoint.lastProbeLatencyMs != null && (
+            <span className="text-[10px] text-muted-foreground tabular-nums shrink-0">
+              {endpoint.lastProbeLatencyMs}ms
+            </span>
+          )}
+        </div>
+
+        <div className="flex items-center gap-2 text-[10px] text-muted-foreground">
+          <span className={cn("font-medium", statusModel.color)}>
+            {t(statusModel.labelKey.replace("settings.providers.", ""))}
+          </span>
+
+          {(circuitState === "open" || circuitState === "half-open") && (
+            <Badge
+              variant="outline"
+              className={cn(
+                "h-4 px-1 text-[9px] uppercase tracking-wider border-current opacity-80",
+                statusModel.color
+              )}
+            >
+              {circuitState === "open"
+                ? t("endpointStatus.circuitOpen")
+                : t("endpointStatus.circuitHalfOpen")}
+            </Badge>
+          )}
+        </div>
+      </div>
+    </div>
+  );
+}

+ 717 - 0
src/app/[locale]/settings/providers/_components/provider-endpoints-table.tsx

@@ -0,0 +1,717 @@
+"use client";
+
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { formatDistanceToNow } from "date-fns";
+import { Edit2, Loader2, MoreHorizontal, Play, Plus, Trash2 } from "lucide-react";
+import { useTranslations } from "next-intl";
+import { useEffect, useMemo, useState } from "react";
+import { toast } from "sonner";
+import {
+  addProviderEndpoint,
+  editProviderEndpoint,
+  getProviderEndpoints,
+  getProviderEndpointsByVendor,
+  probeProviderEndpoint,
+  removeProviderEndpoint,
+} from "@/actions/provider-endpoints";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import {
+  Dialog,
+  DialogContent,
+  DialogDescription,
+  DialogFooter,
+  DialogHeader,
+  DialogTitle,
+  DialogTrigger,
+} from "@/components/ui/dialog";
+import {
+  DropdownMenu,
+  DropdownMenuContent,
+  DropdownMenuItem,
+  DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import {
+  Select,
+  SelectContent,
+  SelectItem,
+  SelectTrigger,
+  SelectValue,
+} from "@/components/ui/select";
+import { Switch } from "@/components/ui/switch";
+import {
+  Table,
+  TableBody,
+  TableCell,
+  TableHead,
+  TableHeader,
+  TableRow,
+} from "@/components/ui/table";
+import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
+import {
+  getAllProviderTypes,
+  getProviderTypeConfig,
+  getProviderTypeTranslationKey,
+} from "@/lib/provider-type-utils";
+import { getErrorMessage } from "@/lib/utils/error-messages";
+import type { ProviderEndpoint, ProviderType } from "@/types/provider";
+import { EndpointLatencySparkline } from "./endpoint-latency-sparkline";
+import { UrlPreview } from "./forms/url-preview";
+
+// ============================================================================
+// Types
+// ============================================================================
+
+export interface ProviderEndpointsTableProps {
+  /** Vendor ID to fetch endpoints for */
+  vendorId: number;
+  /** Optional: filter endpoints by providerType. If undefined, shows all types. */
+  providerType?: ProviderType;
+  /** If true, hides add/edit/delete actions (view-only mode) */
+  readOnly?: boolean;
+  /** If true, hides the type column (useful when filtering by single type) */
+  hideTypeColumn?: boolean;
+  /** Custom query key suffix for cache isolation */
+  queryKeySuffix?: string;
+}
+
+// ============================================================================
+// Main Component
+// ============================================================================
+
+/**
+ * Reusable endpoint CRUD table component.
+ * Supports filtering by providerType and read-only mode for ProviderForm reuse.
+ */
+export function ProviderEndpointsTable({
+  vendorId,
+  providerType,
+  readOnly = false,
+  hideTypeColumn = false,
+  queryKeySuffix,
+}: ProviderEndpointsTableProps) {
+  const t = useTranslations("settings.providers");
+  const tTypes = useTranslations("settings.providers.types");
+
+  // Build query key based on whether we filter by type
+  const queryKey = providerType
+    ? ["provider-endpoints", vendorId, providerType, queryKeySuffix].filter(
+        (value) => value != null
+      )
+    : ["provider-endpoints", vendorId, queryKeySuffix].filter((value) => value != null);
+
+  const { data: rawEndpoints = [], isLoading } = useQuery({
+    queryKey,
+    queryFn: async () => {
+      if (providerType) {
+        return await getProviderEndpoints({ vendorId, providerType });
+      }
+      return await getProviderEndpointsByVendor({ vendorId });
+    },
+  });
+
+  // Sort endpoints by type order (from getAllProviderTypes) then by sortOrder
+  const endpoints = useMemo(() => {
+    const typeOrder = getAllProviderTypes();
+    const typeIndexMap = new Map(typeOrder.map((t, i) => [t, i]));
+
+    return [...rawEndpoints].sort((a, b) => {
+      const aTypeIndex = typeIndexMap.get(a.providerType) ?? 999;
+      const bTypeIndex = typeIndexMap.get(b.providerType) ?? 999;
+      if (aTypeIndex !== bTypeIndex) {
+        return aTypeIndex - bTypeIndex;
+      }
+      return (a.sortOrder ?? 0) - (b.sortOrder ?? 0);
+    });
+  }, [rawEndpoints]);
+
+  if (isLoading) {
+    return <div className="text-center py-4 text-sm text-muted-foreground">{t("keyLoading")}</div>;
+  }
+
+  if (endpoints.length === 0) {
+    return (
+      <div className="text-center py-8 border rounded-md border-dashed">
+        <p className="text-sm text-muted-foreground">{t("noEndpoints")}</p>
+        <p className="text-xs text-muted-foreground mt-1">{t("noEndpointsDesc")}</p>
+      </div>
+    );
+  }
+
+  return (
+    <div className="border rounded-md">
+      <Table>
+        <TableHeader>
+          <TableRow>
+            {!hideTypeColumn && <TableHead className="w-[60px]">{t("columnType")}</TableHead>}
+            <TableHead>{t("columnUrl")}</TableHead>
+            <TableHead>{t("status")}</TableHead>
+            <TableHead className="w-[220px]">{t("latency")}</TableHead>
+            {!readOnly && <TableHead className="text-right">{t("columnActions")}</TableHead>}
+          </TableRow>
+        </TableHeader>
+        <TableBody>
+          {endpoints.map((endpoint) => (
+            <EndpointRow
+              key={endpoint.id}
+              endpoint={endpoint}
+              tTypes={tTypes}
+              readOnly={readOnly}
+              hideTypeColumn={hideTypeColumn}
+            />
+          ))}
+        </TableBody>
+      </Table>
+    </div>
+  );
+}
+
+// ============================================================================
+// EndpointRow
+// ============================================================================
+
+function EndpointRow({
+  endpoint,
+  tTypes,
+  readOnly,
+  hideTypeColumn,
+}: {
+  endpoint: ProviderEndpoint;
+  tTypes: ReturnType<typeof useTranslations>;
+  readOnly: boolean;
+  hideTypeColumn: boolean;
+}) {
+  const t = useTranslations("settings.providers");
+  const tCommon = useTranslations("settings.common");
+  const queryClient = useQueryClient();
+  const [isProbing, setIsProbing] = useState(false);
+  const [isToggling, setIsToggling] = useState(false);
+
+  const typeConfig = getProviderTypeConfig(endpoint.providerType);
+  const TypeIcon = typeConfig.icon;
+  const typeKey = getProviderTypeTranslationKey(endpoint.providerType);
+  const typeLabel = tTypes(`${typeKey}.label`);
+
+  const probeMutation = useMutation({
+    mutationFn: async () => {
+      const res = await probeProviderEndpoint({ endpointId: endpoint.id });
+      if (!res.ok) throw new Error(res.error);
+      return res.data;
+    },
+    onMutate: () => setIsProbing(true),
+    onSettled: () => setIsProbing(false),
+    onSuccess: (data) => {
+      queryClient.invalidateQueries({ queryKey: ["provider-endpoints"] });
+      if (data?.result.ok) {
+        toast.success(t("probeSuccess"));
+      } else {
+        toast.error(
+          data?.result.errorMessage
+            ? `${t("probeFailed")}: ${data.result.errorMessage}`
+            : t("probeFailed")
+        );
+      }
+    },
+    onError: () => {
+      toast.error(t("probeFailed"));
+    },
+  });
+
+  const deleteMutation = useMutation({
+    mutationFn: async () => {
+      const res = await removeProviderEndpoint({ endpointId: endpoint.id });
+      if (!res.ok) throw new Error(res.error);
+      return res.data;
+    },
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ["provider-endpoints"] });
+      queryClient.invalidateQueries({ queryKey: ["provider-vendors"] });
+      toast.success(t("endpointDeleteSuccess"));
+    },
+    onError: () => {
+      toast.error(t("endpointDeleteFailed"));
+    },
+  });
+
+  const toggleMutation = useMutation({
+    mutationFn: async (nextEnabled: boolean) => {
+      const res = await editProviderEndpoint({
+        endpointId: endpoint.id,
+        isEnabled: nextEnabled,
+      });
+      if (!res.ok) throw new Error(res.error);
+      return res.data;
+    },
+    onMutate: () => setIsToggling(true),
+    onSettled: () => setIsToggling(false),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ["provider-endpoints"] });
+      toast.success(t("endpointUpdateSuccess"));
+    },
+    onError: () => {
+      toast.error(t("endpointUpdateFailed"));
+    },
+  });
+
+  return (
+    <TableRow>
+      {!hideTypeColumn && (
+        <TableCell>
+          <TooltipProvider>
+            <Tooltip delayDuration={200}>
+              <TooltipTrigger asChild>
+                <span
+                  className={`inline-flex h-6 w-6 items-center justify-center rounded ${typeConfig.bgColor}`}
+                >
+                  <TypeIcon className={`h-4 w-4 ${typeConfig.iconColor}`} />
+                </span>
+              </TooltipTrigger>
+              <TooltipContent>{typeLabel}</TooltipContent>
+            </Tooltip>
+          </TooltipProvider>
+        </TableCell>
+      )}
+      <TableCell className="font-mono text-xs max-w-[200px] truncate" title={endpoint.url}>
+        {endpoint.url}
+      </TableCell>
+      <TableCell>
+        <div className="flex items-center gap-2">
+          {endpoint.isEnabled ? (
+            <Badge
+              variant="secondary"
+              className="text-green-600 bg-green-500/10 hover:bg-green-500/20"
+            >
+              {t("enabledStatus")}
+            </Badge>
+          ) : (
+            <Badge variant="outline">{t("disabledStatus")}</Badge>
+          )}
+          {!readOnly && (
+            <Switch
+              checked={endpoint.isEnabled}
+              onCheckedChange={(checked) => toggleMutation.mutate(checked)}
+              disabled={isToggling}
+              aria-label={t("enabledStatus")}
+            />
+          )}
+        </div>
+      </TableCell>
+      <TableCell>
+        <div className="flex items-center gap-3">
+          <EndpointLatencySparkline endpointId={endpoint.id} limit={12} />
+          {endpoint.lastProbedAt ? (
+            <span className="text-muted-foreground text-[10px] whitespace-nowrap">
+              {formatDistanceToNow(new Date(endpoint.lastProbedAt), { addSuffix: true })}
+            </span>
+          ) : (
+            <span className="text-muted-foreground text-[10px]">-</span>
+          )}
+        </div>
+      </TableCell>
+      {!readOnly && (
+        <TableCell className="text-right">
+          <div className="flex justify-end gap-2">
+            <Button
+              variant="ghost"
+              size="icon"
+              className="h-8 w-8"
+              onClick={() => probeMutation.mutate()}
+              disabled={isProbing}
+            >
+              {isProbing ? (
+                <Loader2 className="h-4 w-4 animate-spin" />
+              ) : (
+                <Play className="h-4 w-4" />
+              )}
+            </Button>
+
+            <EditEndpointDialog endpoint={endpoint} />
+
+            <DropdownMenu>
+              <DropdownMenuTrigger asChild>
+                <Button variant="ghost" size="icon" className="h-8 w-8">
+                  <MoreHorizontal className="h-4 w-4" />
+                </Button>
+              </DropdownMenuTrigger>
+              <DropdownMenuContent align="end">
+                <DropdownMenuItem
+                  className="text-destructive focus:text-destructive"
+                  onClick={() => {
+                    if (confirm(t("confirmDeleteEndpoint"))) {
+                      deleteMutation.mutate();
+                    }
+                  }}
+                >
+                  <Trash2 className="mr-2 h-4 w-4" />
+                  {tCommon("delete")}
+                </DropdownMenuItem>
+              </DropdownMenuContent>
+            </DropdownMenu>
+          </div>
+        </TableCell>
+      )}
+    </TableRow>
+  );
+}
+
+// ============================================================================
+// AddEndpointButton
+// ============================================================================
+
+export interface AddEndpointButtonProps {
+  vendorId: number;
+  /** If provided, locks the type selector to this value */
+  providerType?: ProviderType;
+  /** Custom query key suffix for cache invalidation */
+  queryKeySuffix?: string;
+}
+
+export function AddEndpointButton({
+  vendorId,
+  providerType: fixedProviderType,
+  queryKeySuffix,
+}: AddEndpointButtonProps) {
+  const t = useTranslations("settings.providers");
+  const tErrors = useTranslations("errors");
+  const tTypes = useTranslations("settings.providers.types");
+  const tCommon = useTranslations("settings.common");
+  const [open, setOpen] = useState(false);
+  const queryClient = useQueryClient();
+  const [isSubmitting, setIsSubmitting] = useState(false);
+  const [url, setUrl] = useState("");
+  const [label, setLabel] = useState("");
+  const [sortOrder, setSortOrder] = useState(0);
+  const [isEnabled, setIsEnabled] = useState(true);
+  const [providerType, setProviderType] = useState<ProviderType>(fixedProviderType ?? "claude");
+
+  const selectableTypes: ProviderType[] = getAllProviderTypes().filter(
+    (type) => !["claude-auth", "gemini-cli"].includes(type)
+  );
+
+  useEffect(() => {
+    if (!open) {
+      setUrl("");
+      setLabel("");
+      setSortOrder(0);
+      setIsEnabled(true);
+      setProviderType(fixedProviderType ?? "claude");
+    }
+  }, [open, fixedProviderType]);
+
+  const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
+    e.preventDefault();
+    setIsSubmitting(true);
+    const formData = new FormData(e.currentTarget);
+    const endpointUrl = formData.get("url") as string;
+    const endpointLabel = formData.get("label") as string;
+    const endpointSortOrder = Number.parseInt(formData.get("sortOrder") as string, 10) || 0;
+
+    try {
+      const res = await addProviderEndpoint({
+        vendorId,
+        providerType,
+        url: endpointUrl,
+        label: endpointLabel.trim() || null,
+        sortOrder: endpointSortOrder,
+        isEnabled,
+      });
+
+      if (res.ok) {
+        toast.success(t("endpointAddSuccess"));
+        setOpen(false);
+        // Invalidate both specific and general queries
+        queryClient.invalidateQueries({ queryKey: ["provider-endpoints", vendorId] });
+        if (fixedProviderType) {
+          queryClient.invalidateQueries({
+            queryKey: ["provider-endpoints", vendorId, fixedProviderType, queryKeySuffix].filter(
+              (value) => value != null
+            ),
+          });
+        }
+      } else {
+        toast.error(
+          res.errorCode
+            ? getErrorMessage(tErrors, res.errorCode, res.errorParams)
+            : t("endpointAddFailed")
+        );
+      }
+    } catch (_err) {
+      toast.error(t("endpointAddFailed"));
+    } finally {
+      setIsSubmitting(false);
+    }
+  };
+
+  const showTypeSelector = !fixedProviderType;
+
+  return (
+    <Dialog open={open} onOpenChange={setOpen}>
+      <DialogTrigger asChild>
+        <Button size="sm" className="h-7 gap-1">
+          <Plus className="h-3.5 w-3.5" />
+          {t("addEndpoint")}
+        </Button>
+      </DialogTrigger>
+      <DialogContent className="sm:max-w-md">
+        <DialogHeader>
+          <DialogTitle>{t("addEndpoint")}</DialogTitle>
+          <DialogDescription>{t("addEndpointDescGeneric")}</DialogDescription>
+        </DialogHeader>
+        <form onSubmit={handleSubmit} className="space-y-4">
+          {showTypeSelector && (
+            <div className="space-y-2">
+              <Label htmlFor="providerType">{t("columnType")}</Label>
+              <Select
+                value={providerType}
+                onValueChange={(value) => setProviderType(value as ProviderType)}
+              >
+                <SelectTrigger id="providerType">
+                  <SelectValue />
+                </SelectTrigger>
+                <SelectContent>
+                  {selectableTypes.map((type) => {
+                    const typeConfig = getProviderTypeConfig(type);
+                    const TypeIcon = typeConfig.icon;
+                    const typeKey = getProviderTypeTranslationKey(type);
+                    const label = tTypes(`${typeKey}.label`);
+                    return (
+                      <SelectItem key={type} value={type}>
+                        <div className="flex items-center gap-2">
+                          <span
+                            className={`inline-flex h-5 w-5 items-center justify-center rounded ${typeConfig.bgColor}`}
+                          >
+                            <TypeIcon className={`h-3.5 w-3.5 ${typeConfig.iconColor}`} />
+                          </span>
+                          {label}
+                        </div>
+                      </SelectItem>
+                    );
+                  })}
+                </SelectContent>
+              </Select>
+            </div>
+          )}
+
+          <div className="space-y-2">
+            <Label htmlFor="url">{t("endpointUrlLabel")}</Label>
+            <Input
+              id="url"
+              name="url"
+              placeholder={t("endpointUrlPlaceholder")}
+              required
+              onChange={(e) => setUrl(e.target.value)}
+            />
+          </div>
+
+          <div className="space-y-2">
+            <Label htmlFor="label">{t("endpointLabelOptional")}</Label>
+            <Input
+              id="label"
+              name="label"
+              placeholder={t("endpointLabelPlaceholder")}
+              value={label}
+              onChange={(e) => setLabel(e.target.value)}
+            />
+          </div>
+
+          <div className="grid grid-cols-2 gap-4">
+            <div className="space-y-2">
+              <Label htmlFor="sortOrder">{t("sortOrder")}</Label>
+              <Input
+                id="sortOrder"
+                name="sortOrder"
+                type="number"
+                min={0}
+                value={sortOrder}
+                onChange={(e) => setSortOrder(Number.parseInt(e.target.value, 10) || 0)}
+              />
+            </div>
+            <div className="space-y-2">
+              <Label>{t("enabledStatus")}</Label>
+              <div className="flex items-center h-9">
+                <Switch id="isEnabled" checked={isEnabled} onCheckedChange={setIsEnabled} />
+              </div>
+            </div>
+          </div>
+
+          <UrlPreview baseUrl={url} providerType={providerType} />
+
+          <DialogFooter>
+            <Button type="button" variant="outline" onClick={() => setOpen(false)}>
+              {tCommon("cancel")}
+            </Button>
+            <Button type="submit" disabled={isSubmitting}>
+              {isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
+              {tCommon("create")}
+            </Button>
+          </DialogFooter>
+        </form>
+      </DialogContent>
+    </Dialog>
+  );
+}
+
+// ============================================================================
+// EditEndpointDialog
+// ============================================================================
+
+function EditEndpointDialog({ endpoint }: { endpoint: ProviderEndpoint }) {
+  const t = useTranslations("settings.providers");
+  const tErrors = useTranslations("errors");
+  const tCommon = useTranslations("settings.common");
+  const [open, setOpen] = useState(false);
+  const queryClient = useQueryClient();
+  const [isSubmitting, setIsSubmitting] = useState(false);
+  const [isEnabled, setIsEnabled] = useState(endpoint.isEnabled);
+
+  useEffect(() => {
+    if (open) {
+      setIsEnabled(endpoint.isEnabled);
+    }
+  }, [open, endpoint.isEnabled]);
+
+  const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
+    e.preventDefault();
+    setIsSubmitting(true);
+    const formData = new FormData(e.currentTarget);
+    const url = formData.get("url") as string;
+    const label = formData.get("label") as string;
+    const sortOrder = Number.parseInt(formData.get("sortOrder") as string, 10) || 0;
+
+    try {
+      const res = await editProviderEndpoint({
+        endpointId: endpoint.id,
+        url,
+        label: label.trim() || null,
+        sortOrder,
+        isEnabled,
+      });
+
+      if (res.ok) {
+        toast.success(t("endpointUpdateSuccess"));
+        setOpen(false);
+        queryClient.invalidateQueries({ queryKey: ["provider-endpoints"] });
+      } else {
+        toast.error(
+          res.errorCode
+            ? getErrorMessage(tErrors, res.errorCode, res.errorParams)
+            : t("endpointUpdateFailed")
+        );
+      }
+    } catch (_err) {
+      toast.error(t("endpointUpdateFailed"));
+    } finally {
+      setIsSubmitting(false);
+    }
+  };
+
+  return (
+    <Dialog open={open} onOpenChange={setOpen}>
+      <DialogTrigger asChild>
+        <Button variant="ghost" size="icon" className="h-8 w-8">
+          <Edit2 className="h-4 w-4" />
+        </Button>
+      </DialogTrigger>
+      <DialogContent className="sm:max-w-md">
+        <DialogHeader>
+          <DialogTitle>{t("editEndpoint")}</DialogTitle>
+        </DialogHeader>
+        <form onSubmit={handleSubmit} className="space-y-4">
+          <div className="space-y-2">
+            <Label htmlFor="url">{t("endpointUrlLabel")}</Label>
+            <Input id="url" name="url" defaultValue={endpoint.url} required />
+          </div>
+          <div className="space-y-2">
+            <Label htmlFor="label">{t("endpointLabelOptional")}</Label>
+            <Input
+              id="label"
+              name="label"
+              placeholder={t("endpointLabelPlaceholder")}
+              defaultValue={endpoint.label ?? ""}
+            />
+          </div>
+          <div className="grid grid-cols-2 gap-4">
+            <div className="space-y-2">
+              <Label htmlFor="sortOrder">{t("sortOrder")}</Label>
+              <Input
+                id="sortOrder"
+                name="sortOrder"
+                type="number"
+                min={0}
+                defaultValue={endpoint.sortOrder ?? 0}
+              />
+            </div>
+            <div className="space-y-2">
+              <Label>{t("enabledStatus")}</Label>
+              <div className="flex items-center h-9">
+                <Switch id="isEnabled" checked={isEnabled} onCheckedChange={setIsEnabled} />
+              </div>
+            </div>
+          </div>
+          <DialogFooter>
+            <Button type="button" variant="outline" onClick={() => setOpen(false)}>
+              {tCommon("cancel")}
+            </Button>
+            <Button type="submit" disabled={isSubmitting}>
+              {isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
+              {tCommon("save")}
+            </Button>
+          </DialogFooter>
+        </form>
+      </DialogContent>
+    </Dialog>
+  );
+}
+
+// ============================================================================
+// ProviderEndpointsSection (convenience wrapper)
+// ============================================================================
+
+export interface ProviderEndpointsSectionProps {
+  vendorId: number;
+  providerType?: ProviderType;
+  readOnly?: boolean;
+  hideTypeColumn?: boolean;
+  queryKeySuffix?: string;
+}
+
+/**
+ * Section wrapper that includes header with Add button and the table.
+ * Use this for full section rendering (like in VendorCard).
+ */
+export function ProviderEndpointsSection({
+  vendorId,
+  providerType,
+  readOnly = false,
+  hideTypeColumn = false,
+  queryKeySuffix,
+}: ProviderEndpointsSectionProps) {
+  const t = useTranslations("settings.providers");
+
+  return (
+    <div>
+      <div className="px-6 py-3 bg-muted/10 border-b font-medium text-sm text-muted-foreground flex items-center justify-between">
+        <span>{t("endpoints")}</span>
+        {!readOnly && (
+          <AddEndpointButton
+            vendorId={vendorId}
+            providerType={providerType}
+            queryKeySuffix={queryKeySuffix}
+          />
+        )}
+      </div>
+
+      <div className="p-6">
+        <ProviderEndpointsTable
+          vendorId={vendorId}
+          providerType={providerType}
+          readOnly={readOnly}
+          hideTypeColumn={hideTypeColumn}
+          queryKeySuffix={queryKeySuffix}
+        />
+      </div>
+    </div>
+  );
+}

+ 24 - 3
src/app/[locale]/settings/providers/_components/provider-rich-list-item.tsx

@@ -1,5 +1,5 @@
 "use client";
-import { useQueryClient } from "@tanstack/react-query";
+import { useQuery, useQueryClient } from "@tanstack/react-query";
 import {
   AlertTriangle,
   CheckCircle,
@@ -15,6 +15,7 @@ import { useRouter } from "next/navigation";
 import { useTranslations } from "next-intl";
 import { useEffect, useState, useTransition } from "react";
 import { toast } from "sonner";
+import { getProviderVendors } from "@/actions/provider-endpoints";
 import {
   editProvider,
   getUnmaskedProviderKey,
@@ -55,6 +56,7 @@ import type { ProviderDisplay, ProviderStatistics } from "@/types/provider";
 import type { User } from "@/types/user";
 import { ProviderForm } from "./forms/provider-form";
 import { InlineEditPopover } from "./inline-edit-popover";
+import { ProviderEndpointHover } from "./provider-endpoint-hover";
 
 interface ProviderRichListItemProps {
   provider: ProviderDisplay;
@@ -95,6 +97,12 @@ export function ProviderRichListItem({
 }: ProviderRichListItemProps) {
   const router = useRouter();
   const queryClient = useQueryClient();
+  const { data: vendors = [] } = useQuery({
+    queryKey: ["provider-vendors"],
+    queryFn: async () => await getProviderVendors(),
+    staleTime: 60000,
+  });
+
   const [openEdit, setOpenEdit] = useState(false);
   const [openClone, setOpenClone] = useState(false);
   const [showKeyDialog, setShowKeyDialog] = useState(false);
@@ -147,6 +155,10 @@ export function ProviderRichListItem({
   const typeLabel = tTypes(`${typeKey}.label`);
   const typeDescription = tTypes(`${typeKey}.description`);
 
+  const vendor = provider.providerVendorId
+    ? vendors.find((v) => v.id === provider.providerVendorId)
+    : undefined;
+
   useEffect(() => {
     setClipboardAvailable(isClipboardSupported());
   }, []);
@@ -445,8 +457,17 @@ export function ProviderRichListItem({
           </div>
 
           <div className="flex items-center gap-3 mt-1 text-sm text-muted-foreground flex-wrap">
-            {/* URL */}
-            <span className="truncate max-w-[300px]">{provider.url}</span>
+            {/* Vendor & Endpoints OR Legacy URL */}
+            {vendor ? (
+              <div className="flex items-center gap-2">
+                <span className="truncate max-w-[300px] font-medium text-foreground/80">
+                  {vendor.displayName || vendor.websiteDomain}
+                </span>
+                <ProviderEndpointHover vendorId={vendor.id} providerType={provider.providerType} />
+              </div>
+            ) : (
+              <span className="truncate max-w-[300px]">{provider.url}</span>
+            )}
 
             {/* 官网链接 */}
             {provider.websiteUrl && (

+ 9 - 519
src/app/[locale]/settings/providers/_components/provider-vendor-view.tsx

@@ -1,29 +1,11 @@
 "use client";
 
-import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
-import { formatDistanceToNow } from "date-fns";
-import {
-  Edit2,
-  ExternalLink,
-  InfoIcon,
-  Loader2,
-  MoreHorizontal,
-  Play,
-  Plus,
-  Trash2,
-} from "lucide-react";
+import { useQuery, useQueryClient } from "@tanstack/react-query";
+import { ExternalLink, InfoIcon, Loader2, Trash2 } from "lucide-react";
 import { useTranslations } from "next-intl";
-import { useEffect, useMemo, useState } from "react";
+import { useMemo, useState } from "react";
 import { toast } from "sonner";
-import {
-  addProviderEndpoint,
-  editProviderEndpoint,
-  getProviderEndpointsByVendor,
-  getProviderVendors,
-  probeProviderEndpoint,
-  removeProviderEndpoint,
-  removeProviderVendor,
-} from "@/actions/provider-endpoints";
+import { getProviderVendors, removeProviderVendor } from "@/actions/provider-endpoints";
 import {
   AlertDialog,
   AlertDialogAction,
@@ -36,59 +18,14 @@ import {
   AlertDialogTrigger,
 } from "@/components/ui/alert-dialog";
 import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
-import { Badge } from "@/components/ui/badge";
 import { Button } from "@/components/ui/button";
 import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
-import {
-  Dialog,
-  DialogContent,
-  DialogDescription,
-  DialogFooter,
-  DialogHeader,
-  DialogTitle,
-  DialogTrigger,
-} from "@/components/ui/dialog";
-import {
-  DropdownMenu,
-  DropdownMenuContent,
-  DropdownMenuItem,
-  DropdownMenuTrigger,
-} from "@/components/ui/dropdown-menu";
-import { Input } from "@/components/ui/input";
-import { Label } from "@/components/ui/label";
-import {
-  Select,
-  SelectContent,
-  SelectItem,
-  SelectTrigger,
-  SelectValue,
-} from "@/components/ui/select";
-import { Switch } from "@/components/ui/switch";
-import {
-  Table,
-  TableBody,
-  TableCell,
-  TableHead,
-  TableHeader,
-  TableRow,
-} from "@/components/ui/table";
 import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
-import {
-  getAllProviderTypes,
-  getProviderTypeConfig,
-  getProviderTypeTranslationKey,
-} from "@/lib/provider-type-utils";
 import type { CurrencyCode } from "@/lib/utils/currency";
 import { getErrorMessage } from "@/lib/utils/error-messages";
-import type {
-  ProviderDisplay,
-  ProviderEndpoint,
-  ProviderType,
-  ProviderVendor,
-} from "@/types/provider";
+import type { ProviderDisplay, ProviderVendor } from "@/types/provider";
 import type { User } from "@/types/user";
-import { EndpointLatencySparkline } from "./endpoint-latency-sparkline";
-import { UrlPreview } from "./forms/url-preview";
+import { ProviderEndpointsSection } from "./provider-endpoints-table";
 import { VendorKeysCompactList } from "./vendor-keys-compact-list";
 
 interface ProviderVendorViewProps {
@@ -270,461 +207,14 @@ function VendorCard({
           currencyCode={currencyCode}
         />
 
-        {enableMultiProviderTypes && vendorId > 0 && <VendorEndpointsSection vendorId={vendorId} />}
+        {enableMultiProviderTypes && vendorId > 0 && (
+          <ProviderEndpointsSection vendorId={vendorId} />
+        )}
       </CardContent>
     </Card>
   );
 }
 
-function VendorEndpointsSection({ vendorId }: { vendorId: number }) {
-  const t = useTranslations("settings.providers");
-
-  return (
-    <div>
-      <div className="px-6 py-3 bg-muted/10 border-b font-medium text-sm text-muted-foreground flex items-center justify-between">
-        <span>{t("endpoints")}</span>
-        <AddEndpointButton vendorId={vendorId} />
-      </div>
-
-      <div className="p-6">
-        <EndpointsTable vendorId={vendorId} />
-      </div>
-    </div>
-  );
-}
-
-function EndpointsTable({ vendorId }: { vendorId: number }) {
-  const t = useTranslations("settings.providers");
-  const tTypes = useTranslations("settings.providers.types");
-
-  const { data: rawEndpoints = [], isLoading } = useQuery({
-    queryKey: ["provider-endpoints", vendorId],
-    queryFn: async () => {
-      const endpoints = await getProviderEndpointsByVendor({ vendorId });
-      return endpoints;
-    },
-  });
-
-  // Sort endpoints by type order (from getAllProviderTypes) then by sortOrder
-  const endpoints = useMemo(() => {
-    const typeOrder = getAllProviderTypes();
-    const typeIndexMap = new Map(typeOrder.map((t, i) => [t, i]));
-
-    return [...rawEndpoints].sort((a, b) => {
-      const aTypeIndex = typeIndexMap.get(a.providerType) ?? 999;
-      const bTypeIndex = typeIndexMap.get(b.providerType) ?? 999;
-      if (aTypeIndex !== bTypeIndex) {
-        return aTypeIndex - bTypeIndex;
-      }
-      return (a.sortOrder ?? 0) - (b.sortOrder ?? 0);
-    });
-  }, [rawEndpoints]);
-
-  if (isLoading) {
-    return <div className="text-center py-4 text-sm text-muted-foreground">{t("keyLoading")}</div>;
-  }
-
-  if (endpoints.length === 0) {
-    return (
-      <div className="text-center py-8 border rounded-md border-dashed">
-        <p className="text-sm text-muted-foreground">{t("noEndpoints")}</p>
-        <p className="text-xs text-muted-foreground mt-1">{t("noEndpointsDesc")}</p>
-      </div>
-    );
-  }
-
-  return (
-    <div className="border rounded-md">
-      <Table>
-        <TableHeader>
-          <TableRow>
-            <TableHead className="w-[60px]">{t("columnType")}</TableHead>
-            <TableHead>{t("columnUrl")}</TableHead>
-            <TableHead>{t("status")}</TableHead>
-            <TableHead className="w-[220px]">{t("latency")}</TableHead>
-            <TableHead className="text-right">{t("columnActions")}</TableHead>
-          </TableRow>
-        </TableHeader>
-        <TableBody>
-          {endpoints.map((endpoint) => (
-            <EndpointRow key={endpoint.id} endpoint={endpoint} tTypes={tTypes} />
-          ))}
-        </TableBody>
-      </Table>
-    </div>
-  );
-}
-
-function EndpointRow({
-  endpoint,
-  tTypes,
-}: {
-  endpoint: ProviderEndpoint;
-  tTypes: ReturnType<typeof useTranslations>;
-}) {
-  const t = useTranslations("settings.providers");
-  const tCommon = useTranslations("settings.common");
-  const queryClient = useQueryClient();
-  const [isProbing, setIsProbing] = useState(false);
-  const [isToggling, setIsToggling] = useState(false);
-
-  const typeConfig = getProviderTypeConfig(endpoint.providerType);
-  const TypeIcon = typeConfig.icon;
-  const typeKey = getProviderTypeTranslationKey(endpoint.providerType);
-  const typeLabel = tTypes(`${typeKey}.label`);
-
-  const probeMutation = useMutation({
-    mutationFn: async () => {
-      const res = await probeProviderEndpoint({ endpointId: endpoint.id });
-      if (!res.ok) throw new Error(res.error);
-      return res.data;
-    },
-    onMutate: () => setIsProbing(true),
-    onSettled: () => setIsProbing(false),
-    onSuccess: (data) => {
-      queryClient.invalidateQueries({ queryKey: ["provider-endpoints"] });
-      if (data?.result.ok) {
-        toast.success(t("probeSuccess"));
-      } else {
-        toast.error(
-          data?.result.errorMessage
-            ? `${t("probeFailed")}: ${data.result.errorMessage}`
-            : t("probeFailed")
-        );
-      }
-    },
-    onError: () => {
-      toast.error(t("probeFailed"));
-    },
-  });
-
-  const deleteMutation = useMutation({
-    mutationFn: async () => {
-      const res = await removeProviderEndpoint({ endpointId: endpoint.id });
-      if (!res.ok) throw new Error(res.error);
-      return res.data;
-    },
-    onSuccess: () => {
-      queryClient.invalidateQueries({ queryKey: ["provider-endpoints"] });
-      queryClient.invalidateQueries({ queryKey: ["provider-vendors"] });
-      toast.success(t("endpointDeleteSuccess"));
-    },
-    onError: () => {
-      toast.error(t("endpointDeleteFailed"));
-    },
-  });
-
-  const toggleMutation = useMutation({
-    mutationFn: async (nextEnabled: boolean) => {
-      const res = await editProviderEndpoint({
-        endpointId: endpoint.id,
-        isEnabled: nextEnabled,
-      });
-      if (!res.ok) throw new Error(res.error);
-      return res.data;
-    },
-    onMutate: () => setIsToggling(true),
-    onSettled: () => setIsToggling(false),
-    onSuccess: () => {
-      queryClient.invalidateQueries({ queryKey: ["provider-endpoints"] });
-      toast.success(t("endpointUpdateSuccess"));
-    },
-    onError: () => {
-      toast.error(t("endpointUpdateFailed"));
-    },
-  });
-
-  return (
-    <TableRow>
-      <TableCell>
-        <TooltipProvider>
-          <Tooltip delayDuration={200}>
-            <TooltipTrigger asChild>
-              <span
-                className={`inline-flex h-6 w-6 items-center justify-center rounded ${typeConfig.bgColor}`}
-              >
-                <TypeIcon className={`h-4 w-4 ${typeConfig.iconColor}`} />
-              </span>
-            </TooltipTrigger>
-            <TooltipContent>{typeLabel}</TooltipContent>
-          </Tooltip>
-        </TooltipProvider>
-      </TableCell>
-      <TableCell className="font-mono text-xs max-w-[200px] truncate" title={endpoint.url}>
-        {endpoint.url}
-      </TableCell>
-      <TableCell>
-        <div className="flex items-center gap-2">
-          {endpoint.isEnabled ? (
-            <Badge
-              variant="secondary"
-              className="text-green-600 bg-green-500/10 hover:bg-green-500/20"
-            >
-              {t("enabledStatus")}
-            </Badge>
-          ) : (
-            <Badge variant="outline">{t("disabledStatus")}</Badge>
-          )}
-          <Switch
-            checked={endpoint.isEnabled}
-            onCheckedChange={(checked) => toggleMutation.mutate(checked)}
-            disabled={isToggling}
-            aria-label={t("enabledStatus")}
-          />
-        </div>
-      </TableCell>
-      <TableCell>
-        <div className="flex items-center gap-3">
-          <EndpointLatencySparkline endpointId={endpoint.id} limit={12} />
-          {endpoint.lastProbedAt ? (
-            <span className="text-muted-foreground text-[10px] whitespace-nowrap">
-              {formatDistanceToNow(new Date(endpoint.lastProbedAt), { addSuffix: true })}
-            </span>
-          ) : (
-            <span className="text-muted-foreground text-[10px]">-</span>
-          )}
-        </div>
-      </TableCell>
-      <TableCell className="text-right">
-        <div className="flex justify-end gap-2">
-          <Button
-            variant="ghost"
-            size="icon"
-            className="h-8 w-8"
-            onClick={() => probeMutation.mutate()}
-            disabled={isProbing}
-          >
-            {isProbing ? (
-              <Loader2 className="h-4 w-4 animate-spin" />
-            ) : (
-              <Play className="h-4 w-4" />
-            )}
-          </Button>
-
-          <EditEndpointDialog endpoint={endpoint} />
-
-          <DropdownMenu>
-            <DropdownMenuTrigger asChild>
-              <Button variant="ghost" size="icon" className="h-8 w-8">
-                <MoreHorizontal className="h-4 w-4" />
-              </Button>
-            </DropdownMenuTrigger>
-            <DropdownMenuContent align="end">
-              <DropdownMenuItem
-                className="text-destructive focus:text-destructive"
-                onClick={() => {
-                  if (confirm(t("confirmDeleteEndpoint"))) {
-                    deleteMutation.mutate();
-                  }
-                }}
-              >
-                <Trash2 className="mr-2 h-4 w-4" />
-                {tCommon("delete")}
-              </DropdownMenuItem>
-            </DropdownMenuContent>
-          </DropdownMenu>
-        </div>
-      </TableCell>
-    </TableRow>
-  );
-}
-
-function AddEndpointButton({ vendorId }: { vendorId: number }) {
-  const t = useTranslations("settings.providers");
-  const tTypes = useTranslations("settings.providers.types");
-  const tCommon = useTranslations("settings.common");
-  const [open, setOpen] = useState(false);
-  const queryClient = useQueryClient();
-  const [isSubmitting, setIsSubmitting] = useState(false);
-  const [url, setUrl] = useState("");
-  const [providerType, setProviderType] = useState<ProviderType>("claude");
-
-  // Get provider types for the selector (exclude claude-auth and gemini-cli which are internal)
-  const selectableTypes: ProviderType[] = getAllProviderTypes().filter(
-    (type) => !["claude-auth", "gemini-cli"].includes(type)
-  );
-
-  useEffect(() => {
-    if (!open) {
-      setUrl("");
-      setProviderType("claude");
-    }
-  }, [open]);
-
-  const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
-    e.preventDefault();
-    setIsSubmitting(true);
-    const formData = new FormData(e.currentTarget);
-    const endpointUrl = formData.get("url") as string;
-
-    try {
-      const res = await addProviderEndpoint({
-        vendorId,
-        providerType,
-        url: endpointUrl,
-        label: null,
-        sortOrder: 0,
-        isEnabled: true,
-      });
-
-      if (res.ok) {
-        toast.success(t("endpointAddSuccess"));
-        setOpen(false);
-        queryClient.invalidateQueries({ queryKey: ["provider-endpoints", vendorId] });
-      } else {
-        toast.error(res.error || t("endpointAddFailed"));
-      }
-    } catch (_err) {
-      toast.error(t("endpointAddFailed"));
-    } finally {
-      setIsSubmitting(false);
-    }
-  };
-
-  return (
-    <Dialog open={open} onOpenChange={setOpen}>
-      <DialogTrigger asChild>
-        <Button size="sm" className="h-7 gap-1">
-          <Plus className="h-3.5 w-3.5" />
-          {t("addEndpoint")}
-        </Button>
-      </DialogTrigger>
-      <DialogContent className="sm:max-w-md">
-        <DialogHeader>
-          <DialogTitle>{t("addEndpoint")}</DialogTitle>
-          <DialogDescription>{t("addEndpointDescGeneric")}</DialogDescription>
-        </DialogHeader>
-        <form onSubmit={handleSubmit} className="space-y-4">
-          <div className="space-y-2">
-            <Label htmlFor="providerType">{t("columnType")}</Label>
-            <Select
-              value={providerType}
-              onValueChange={(value) => setProviderType(value as ProviderType)}
-            >
-              <SelectTrigger id="providerType">
-                <SelectValue />
-              </SelectTrigger>
-              <SelectContent>
-                {selectableTypes.map((type) => {
-                  const typeConfig = getProviderTypeConfig(type);
-                  const TypeIcon = typeConfig.icon;
-                  const typeKey = getProviderTypeTranslationKey(type);
-                  const label = tTypes(`${typeKey}.label`);
-                  return (
-                    <SelectItem key={type} value={type}>
-                      <div className="flex items-center gap-2">
-                        <span
-                          className={`inline-flex h-5 w-5 items-center justify-center rounded ${typeConfig.bgColor}`}
-                        >
-                          <TypeIcon className={`h-3.5 w-3.5 ${typeConfig.iconColor}`} />
-                        </span>
-                        {label}
-                      </div>
-                    </SelectItem>
-                  );
-                })}
-              </SelectContent>
-            </Select>
-          </div>
-
-          <div className="space-y-2">
-            <Label htmlFor="url">{t("endpointUrlLabel")}</Label>
-            <Input
-              id="url"
-              name="url"
-              placeholder={t("endpointUrlPlaceholder")}
-              required
-              onChange={(e) => setUrl(e.target.value)}
-            />
-          </div>
-
-          <UrlPreview baseUrl={url} providerType={providerType} />
-
-          <DialogFooter>
-            <Button type="button" variant="outline" onClick={() => setOpen(false)}>
-              {tCommon("cancel")}
-            </Button>
-            <Button type="submit" disabled={isSubmitting}>
-              {isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
-              {tCommon("create")}
-            </Button>
-          </DialogFooter>
-        </form>
-      </DialogContent>
-    </Dialog>
-  );
-}
-
-function EditEndpointDialog({ endpoint }: { endpoint: ProviderEndpoint }) {
-  const t = useTranslations("settings.providers");
-  const tCommon = useTranslations("settings.common");
-  const [open, setOpen] = useState(false);
-  const queryClient = useQueryClient();
-  const [isSubmitting, setIsSubmitting] = useState(false);
-
-  const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
-    e.preventDefault();
-    setIsSubmitting(true);
-    const formData = new FormData(e.currentTarget);
-    const url = formData.get("url") as string;
-    const isEnabled = formData.get("isEnabled") === "on";
-
-    try {
-      const res = await editProviderEndpoint({
-        endpointId: endpoint.id,
-        url,
-        isEnabled,
-      });
-
-      if (res.ok) {
-        toast.success(t("endpointUpdateSuccess"));
-        setOpen(false);
-        queryClient.invalidateQueries({ queryKey: ["provider-endpoints"] });
-      } else {
-        toast.error(res.error || t("endpointUpdateFailed"));
-      }
-    } catch (_err) {
-      toast.error(t("endpointUpdateFailed"));
-    } finally {
-      setIsSubmitting(false);
-    }
-  };
-
-  return (
-    <Dialog open={open} onOpenChange={setOpen}>
-      <DialogTrigger asChild>
-        <Button variant="ghost" size="icon" className="h-8 w-8">
-          <Edit2 className="h-4 w-4" />
-        </Button>
-      </DialogTrigger>
-      <DialogContent className="sm:max-w-md">
-        <DialogHeader>
-          <DialogTitle>{t("editEndpoint")}</DialogTitle>
-        </DialogHeader>
-        <form onSubmit={handleSubmit} className="space-y-4">
-          <div className="space-y-2">
-            <Label htmlFor="url">{t("endpointUrlLabel")}</Label>
-            <Input id="url" name="url" defaultValue={endpoint.url} required />
-          </div>
-          <div className="flex items-center space-x-2">
-            <Switch id="isEnabled" name="isEnabled" defaultChecked={endpoint.isEnabled} />
-            <Label htmlFor="isEnabled">{t("enabledStatus")}</Label>
-          </div>
-          <DialogFooter>
-            <Button type="button" variant="outline" onClick={() => setOpen(false)}>
-              {tCommon("cancel")}
-            </Button>
-            <Button type="submit" disabled={isSubmitting}>
-              {isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
-              {tCommon("save")}
-            </Button>
-          </DialogFooter>
-        </form>
-      </DialogContent>
-    </Dialog>
-  );
-}
-
 function DeleteVendorDialog({ vendor, vendorId }: { vendor?: ProviderVendor; vendorId: number }) {
   const t = useTranslations("settings.providers");
   const tCommon = useTranslations("settings.common");

+ 26 - 4
tests/unit/i18n/zh-tw-providers-strings-quality.test.ts

@@ -5,14 +5,36 @@ import { describe, expect, test } from "vitest";
 const readJson = (relPath: string) => {
   const filePath = path.join(process.cwd(), relPath);
   const text = fs.readFileSync(filePath, "utf8");
-  return JSON.parse(text) as Record<string, string>;
+  return JSON.parse(text) as unknown;
 };
 
+type JsonValue = string | number | boolean | null | { [key: string]: JsonValue } | JsonValue[];
+
+function visitStrings(value: JsonValue, visit: (text: string) => void): void {
+  if (typeof value === "string") {
+    visit(value);
+    return;
+  }
+
+  if (Array.isArray(value)) {
+    for (const item of value) {
+      visitStrings(item, visit);
+    }
+    return;
+  }
+
+  if (value && typeof value === "object") {
+    for (const item of Object.values(value)) {
+      visitStrings(item, visit);
+    }
+  }
+}
+
 describe("messages/zh-TW/settings/providers/strings.json", () => {
   test("does not contain placeholder markers, emoji, or halfwidth parentheses", () => {
-    const zhTW = readJson("messages/zh-TW/settings/providers/strings.json");
+    const zhTW = readJson("messages/zh-TW/settings/providers/strings.json") as JsonValue;
 
-    for (const value of Object.values(zhTW)) {
+    visitStrings(zhTW, (value) => {
       expect(value).not.toContain("(繁)");
       expect(value).not.toContain("[JA]");
       expect(value).not.toContain("(TW)");
@@ -24,6 +46,6 @@ describe("messages/zh-TW/settings/providers/strings.json", () => {
       expect(value).not.toContain(")");
 
       expect(value).not.toMatch(/[1-4]\uFE0F\u20E3/);
-    }
+    });
   });
 });

+ 101 - 0
tests/unit/settings/providers/endpoint-status.test.ts

@@ -0,0 +1,101 @@
+import { describe, expect, it } from "vitest";
+import {
+  type EndpointCircuitState,
+  getEndpointStatusModel,
+} from "@/app/[locale]/settings/providers/_components/endpoint-status";
+import { AlertTriangle, Ban, CheckCircle2, HelpCircle, XCircle } from "lucide-react";
+
+describe("getEndpointStatusModel", () => {
+  const createEndpoint = (lastProbeOk: boolean | null) => ({ lastProbeOk });
+
+  describe("Circuit Breaker Priority", () => {
+    it("should return circuit-open status when circuit is open, regardless of probe", () => {
+      const endpoint = createEndpoint(true); // Probe is OK
+      const result = getEndpointStatusModel(endpoint, "open");
+
+      expect(result).toEqual({
+        status: "circuit-open",
+        labelKey: "settings.providers.endpointStatus.circuitOpen",
+        severity: "error",
+        icon: Ban,
+        color: "text-rose-500",
+        bgColor: "bg-rose-500/10",
+        borderColor: "border-rose-500/30",
+      });
+    });
+
+    it("should return circuit-half-open status when circuit is half-open", () => {
+      const endpoint = createEndpoint(false); // Probe is bad
+      const result = getEndpointStatusModel(endpoint, "half-open");
+
+      expect(result).toEqual({
+        status: "circuit-half-open",
+        labelKey: "settings.providers.endpointStatus.circuitHalfOpen",
+        severity: "warning",
+        icon: AlertTriangle,
+        color: "text-amber-500",
+        bgColor: "bg-amber-500/10",
+        borderColor: "border-amber-500/30",
+      });
+    });
+  });
+
+  describe("Probe Status Fallback (Circuit Closed or Missing)", () => {
+    it.each([
+      { circuit: "closed" as EndpointCircuitState },
+      { circuit: null },
+      { circuit: undefined },
+    ])("should return healthy when probe is ok and circuit is $circuit", ({ circuit }) => {
+      const endpoint = createEndpoint(true);
+      const result = getEndpointStatusModel(endpoint, circuit);
+
+      expect(result).toEqual({
+        status: "healthy",
+        labelKey: "settings.providers.endpointStatus.healthy",
+        severity: "success",
+        icon: CheckCircle2,
+        color: "text-emerald-500",
+        bgColor: "bg-emerald-500/10",
+        borderColor: "border-emerald-500/30",
+      });
+    });
+
+    it.each([
+      { circuit: "closed" as EndpointCircuitState },
+      { circuit: null },
+      { circuit: undefined },
+    ])("should return unhealthy when probe is failed and circuit is $circuit", ({ circuit }) => {
+      const endpoint = createEndpoint(false);
+      const result = getEndpointStatusModel(endpoint, circuit);
+
+      expect(result).toEqual({
+        status: "unhealthy",
+        labelKey: "settings.providers.endpointStatus.unhealthy",
+        severity: "error",
+        icon: XCircle,
+        color: "text-rose-500",
+        bgColor: "bg-rose-500/10",
+        borderColor: "border-rose-500/30",
+      });
+    });
+
+    it.each([
+      { circuit: "closed" as EndpointCircuitState },
+      { circuit: null },
+      { circuit: undefined },
+    ])("should return unknown when probe is null and circuit is $circuit", ({ circuit }) => {
+      const endpoint = createEndpoint(null);
+      const result = getEndpointStatusModel(endpoint, circuit);
+
+      expect(result).toEqual({
+        status: "unknown",
+        labelKey: "settings.providers.endpointStatus.unknown",
+        severity: "neutral",
+        icon: HelpCircle,
+        color: "text-slate-400",
+        bgColor: "bg-slate-400/10",
+        borderColor: "border-slate-400/30",
+      });
+    });
+  });
+});

+ 244 - 0
tests/unit/settings/providers/provider-endpoint-hover.test.tsx

@@ -0,0 +1,244 @@
+/**
+ * @vitest-environment happy-dom
+ */
+
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import type { ReactNode } from "react";
+import { act } from "react";
+import { createRoot } from "react-dom/client";
+import { NextIntlClientProvider } from "next-intl";
+import { beforeEach, describe, expect, test, vi } from "vitest";
+import { ProviderEndpointHover } from "@/app/[locale]/settings/providers/_components/provider-endpoint-hover";
+import type { ProviderEndpoint } from "@/types/provider";
+import enMessages from "../../../../messages/en";
+
+vi.mock("@/components/ui/tooltip", () => ({
+  TooltipProvider: ({ children }: { children: ReactNode }) => <>{children}</>,
+  Tooltip: ({ children, open }: { children: ReactNode; open: boolean }) => (
+    <div data-testid="tooltip" data-state={open ? "open" : "closed"}>
+      {children}
+    </div>
+  ),
+  TooltipTrigger: ({ children }: { children: ReactNode }) => (
+    <div data-testid="tooltip-trigger">{children}</div>
+  ),
+  TooltipContent: ({ children }: { children: ReactNode }) => (
+    <div data-testid="tooltip-content">{children}</div>
+  ),
+}));
+
+const providerEndpointsActionMocks = vi.hoisted(() => ({
+  getProviderEndpointsByVendor: vi.fn(),
+  getEndpointCircuitInfo: vi.fn(),
+}));
+
+vi.mock("@/actions/provider-endpoints", () => providerEndpointsActionMocks);
+
+function loadMessages() {
+  const endpointStatus = {
+    viewDetails: "View Details",
+    activeEndpoints: "Active Endpoints",
+    noEndpoints: "No Endpoints",
+    healthy: "Healthy",
+    unhealthy: "Unhealthy",
+    unknown: "Unknown",
+    circuitOpen: "Circuit Open",
+    circuitHalfOpen: "Circuit Half-Open",
+  };
+
+  return {
+    settings: {
+      ...enMessages.settings,
+      providers: {
+        ...(enMessages.settings.providers || {}),
+        endpointStatus,
+      },
+    },
+  };
+}
+
+let queryClient: QueryClient;
+
+function renderWithProviders(node: ReactNode) {
+  const container = document.createElement("div");
+  document.body.appendChild(container);
+  const root = createRoot(container);
+
+  act(() => {
+    root.render(
+      <QueryClientProvider client={queryClient}>
+        <NextIntlClientProvider locale="en" messages={loadMessages()} timeZone="UTC">
+          {node}
+        </NextIntlClientProvider>
+      </QueryClientProvider>
+    );
+  });
+
+  return {
+    container,
+    unmount: () => {
+      act(() => root.unmount());
+      container.remove();
+    },
+  };
+}
+
+async function flushTicks(times = 3) {
+  for (let i = 0; i < times; i++) {
+    await act(async () => {
+      await new Promise((r) => setTimeout(r, 0));
+    });
+  }
+}
+
+describe("ProviderEndpointHover", () => {
+  beforeEach(() => {
+    queryClient = new QueryClient({
+      defaultOptions: {
+        queries: { retry: false },
+      },
+    });
+    vi.clearAllMocks();
+    while (document.body.firstChild) {
+      document.body.removeChild(document.body.firstChild);
+    }
+  });
+
+  const mockEndpoints: ProviderEndpoint[] = [
+    {
+      id: 1,
+      vendorId: 1,
+      providerType: "claude",
+      url: "https://api.anthropic.com/v1",
+      label: "Healthy Endpoint",
+      sortOrder: 10,
+      isEnabled: true,
+      deletedAt: null,
+      lastProbeOk: true,
+      lastProbeLatencyMs: 100,
+      createdAt: "2024-01-01",
+      updatedAt: "2024-01-01",
+      lastProbedAt: null,
+      lastOk: null,
+      lastLatencyMs: null,
+    },
+    {
+      id: 2,
+      vendorId: 1,
+      providerType: "claude",
+      url: "https://api.anthropic.com/v2",
+      label: "Unhealthy Endpoint",
+      sortOrder: 20,
+      isEnabled: true,
+      deletedAt: null,
+      lastProbeOk: false,
+      lastProbeLatencyMs: null,
+      createdAt: "2024-01-01",
+      updatedAt: "2024-01-01",
+      lastProbedAt: null,
+      lastOk: null,
+      lastLatencyMs: null,
+    },
+    {
+      id: 3,
+      vendorId: 1,
+      providerType: "claude",
+      url: "https://api.anthropic.com/v3",
+      label: "Unknown Endpoint",
+      sortOrder: 5,
+      isEnabled: true,
+      deletedAt: null,
+      lastProbeOk: null,
+      lastProbeLatencyMs: null,
+      createdAt: "2024-01-01",
+      updatedAt: "2024-01-01",
+      lastProbedAt: null,
+      lastOk: null,
+      lastLatencyMs: null,
+    },
+    {
+      id: 4,
+      vendorId: 1,
+      providerType: "openai-compatible",
+      url: "https://api.openai.com",
+      label: "Wrong Type",
+      sortOrder: 0,
+      isEnabled: true,
+      deletedAt: null,
+      lastProbeOk: true,
+      lastProbeLatencyMs: 50,
+      createdAt: "2024-01-01",
+      updatedAt: "2024-01-01",
+      lastProbedAt: null,
+      lastOk: null,
+      lastLatencyMs: null,
+    },
+    {
+      id: 5,
+      vendorId: 1,
+      providerType: "claude",
+      url: "https://api.anthropic.com/v4",
+      label: "Disabled Endpoint",
+      sortOrder: 0,
+      isEnabled: false,
+      deletedAt: null,
+      lastProbeOk: true,
+      lastProbeLatencyMs: 50,
+      createdAt: "2024-01-01",
+      updatedAt: "2024-01-01",
+      lastProbedAt: null,
+      lastOk: null,
+      lastLatencyMs: null,
+    },
+  ];
+
+  test("renders trigger with correct count and filters correctly", async () => {
+    providerEndpointsActionMocks.getProviderEndpointsByVendor.mockResolvedValue(mockEndpoints);
+
+    const { unmount, container } = renderWithProviders(
+      <ProviderEndpointHover vendorId={1} providerType="claude" />
+    );
+
+    await flushTicks();
+
+    const triggerText = container.textContent;
+    expect(triggerText).toContain("3");
+
+    unmount();
+  });
+
+  test("sorts endpoints correctly: Healthy > Unknown > Unhealthy", async () => {
+    providerEndpointsActionMocks.getProviderEndpointsByVendor.mockResolvedValue(mockEndpoints);
+
+    const { unmount } = renderWithProviders(
+      <ProviderEndpointHover vendorId={1} providerType="claude" />
+    );
+
+    await flushTicks();
+
+    const tooltipContent = document.querySelector("[data-testid='tooltip-content']");
+    expect(tooltipContent).not.toBeNull();
+
+    const labels = Array.from(
+      document.querySelectorAll("[data-testid='tooltip-content'] span.truncate")
+    ).map((el) => el.textContent);
+
+    expect(labels).toEqual(["Healthy Endpoint", "Unknown Endpoint", "Unhealthy Endpoint"]);
+
+    unmount();
+  });
+
+  test("does not fetch circuit info initially (when closed)", async () => {
+    providerEndpointsActionMocks.getProviderEndpointsByVendor.mockResolvedValue(mockEndpoints);
+
+    const { unmount } = renderWithProviders(
+      <ProviderEndpointHover vendorId={1} providerType="claude" />
+    );
+
+    await flushTicks();
+
+    expect(providerEndpointsActionMocks.getEndpointCircuitInfo).not.toHaveBeenCalled();
+
+    unmount();
+  });
+});

+ 567 - 0
tests/unit/settings/providers/provider-endpoints-table.test.tsx

@@ -0,0 +1,567 @@
+/**
+ * @vitest-environment happy-dom
+ */
+
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import type { ReactNode } from "react";
+import { act } from "react";
+import { createRoot } from "react-dom/client";
+import { NextIntlClientProvider } from "next-intl";
+import { beforeEach, describe, expect, test, vi } from "vitest";
+import {
+  ProviderEndpointsTable,
+  AddEndpointButton,
+  ProviderEndpointsSection,
+} from "@/app/[locale]/settings/providers/_components/provider-endpoints-table";
+import enMessages from "../../../../messages/en";
+
+vi.mock("next/navigation", () => ({
+  useRouter: () => ({ refresh: vi.fn() }),
+}));
+
+const sonnerMocks = vi.hoisted(() => ({
+  toast: {
+    success: vi.fn(),
+    error: vi.fn(),
+  },
+}));
+vi.mock("sonner", () => sonnerMocks);
+
+vi.mock("@/components/ui/tooltip", () => ({
+  Tooltip: ({ children }: { children: ReactNode }) => <>{children}</>,
+  TooltipContent: ({ children }: { children: ReactNode }) => <span>{children}</span>,
+  TooltipProvider: ({ children }: { children: ReactNode }) => <>{children}</>,
+  TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children}</>,
+}));
+
+const providerEndpointsActionMocks = vi.hoisted(() => ({
+  addProviderEndpoint: vi.fn(async () => ({ ok: true, data: { endpoint: {} } })),
+  editProviderEndpoint: vi.fn(async () => ({ ok: true, data: { endpoint: {} } })),
+  getProviderEndpointProbeLogs: vi.fn(async () => ({ ok: true, data: { logs: [] } })),
+  getProviderEndpoints: vi.fn(async () => [
+    {
+      id: 1,
+      vendorId: 1,
+      providerType: "claude",
+      url: "https://api.claude.example.com/v1",
+      label: null as string | null,
+      sortOrder: 0,
+      isEnabled: true,
+      lastProbedAt: null,
+      lastProbeOk: null,
+      lastProbeLatencyMs: null,
+      createdAt: "2026-01-01",
+      updatedAt: "2026-01-01",
+    },
+  ]),
+  getProviderEndpointsByVendor: vi.fn(async () => [
+    {
+      id: 1,
+      vendorId: 1,
+      providerType: "claude",
+      url: "https://api.claude.example.com/v1",
+      label: null as string | null,
+      sortOrder: 0,
+      isEnabled: true,
+      lastProbedAt: null,
+      lastProbeOk: null,
+      lastProbeLatencyMs: null,
+      createdAt: "2026-01-01",
+      updatedAt: "2026-01-01",
+    },
+    {
+      id: 2,
+      vendorId: 1,
+      providerType: "openai-compatible",
+      url: "https://api.openai.example.com/v1",
+      label: null as string | null,
+      sortOrder: 0,
+      isEnabled: false,
+      lastProbedAt: "2026-01-01T12:00:00Z",
+      lastProbeOk: true,
+      lastProbeLatencyMs: 150,
+      createdAt: "2026-01-01",
+      updatedAt: "2026-01-01",
+    },
+  ]),
+  getProviderVendors: vi.fn(async () => []),
+  probeProviderEndpoint: vi.fn(async () => ({ ok: true, data: { result: { ok: true } } })),
+  removeProviderEndpoint: vi.fn(async () => ({ ok: true })),
+  removeProviderVendor: vi.fn(async () => ({ ok: true })),
+}));
+vi.mock("@/actions/provider-endpoints", () => providerEndpointsActionMocks);
+
+function loadMessages() {
+  return {
+    common: enMessages.common,
+    errors: enMessages.errors,
+    ui: enMessages.ui,
+    forms: enMessages.forms,
+    settings: enMessages.settings,
+  };
+}
+
+let queryClient: QueryClient;
+
+function renderWithProviders(node: ReactNode) {
+  const container = document.createElement("div");
+  document.body.appendChild(container);
+  const root = createRoot(container);
+
+  act(() => {
+    root.render(
+      <QueryClientProvider client={queryClient}>
+        <NextIntlClientProvider locale="en" messages={loadMessages()} timeZone="UTC">
+          {node}
+        </NextIntlClientProvider>
+      </QueryClientProvider>
+    );
+  });
+
+  return {
+    unmount: () => {
+      act(() => root.unmount());
+      container.remove();
+    },
+  };
+}
+
+async function flushTicks(times = 3) {
+  for (let i = 0; i < times; i++) {
+    await act(async () => {
+      await new Promise((r) => setTimeout(r, 0));
+    });
+  }
+}
+
+describe("ProviderEndpointsTable", () => {
+  beforeEach(() => {
+    queryClient = new QueryClient({
+      defaultOptions: {
+        queries: { retry: false },
+        mutations: { retry: false },
+      },
+    });
+    vi.clearAllMocks();
+    while (document.body.firstChild) {
+      document.body.removeChild(document.body.firstChild);
+    }
+  });
+
+  test("renders endpoints from getProviderEndpointsByVendor when no providerType filter", async () => {
+    const { unmount } = renderWithProviders(<ProviderEndpointsTable vendorId={1} />);
+
+    await flushTicks(6);
+
+    expect(providerEndpointsActionMocks.getProviderEndpointsByVendor).toHaveBeenCalledWith({
+      vendorId: 1,
+    });
+    expect(document.body.textContent || "").toContain("https://api.claude.example.com/v1");
+    expect(document.body.textContent || "").toContain("https://api.openai.example.com/v1");
+
+    unmount();
+  });
+
+  test("renders endpoints from getProviderEndpoints when providerType filter is set", async () => {
+    const { unmount } = renderWithProviders(
+      <ProviderEndpointsTable vendorId={1} providerType="claude" />
+    );
+
+    await flushTicks(6);
+
+    expect(providerEndpointsActionMocks.getProviderEndpoints).toHaveBeenCalledWith({
+      vendorId: 1,
+      providerType: "claude",
+    });
+
+    unmount();
+  });
+
+  test("hides type column when hideTypeColumn is true", async () => {
+    const { unmount } = renderWithProviders(
+      <ProviderEndpointsTable vendorId={1} hideTypeColumn={true} />
+    );
+
+    await flushTicks(6);
+
+    const headers = Array.from(document.querySelectorAll("th")).map((th) => th.textContent);
+    expect(headers).not.toContain("Type");
+
+    unmount();
+  });
+
+  test("shows type column by default", async () => {
+    const { unmount } = renderWithProviders(<ProviderEndpointsTable vendorId={1} />);
+
+    await flushTicks(6);
+
+    expect(document.body.textContent || "").toContain("Type");
+
+    unmount();
+  });
+
+  test("hides actions column in readOnly mode", async () => {
+    const { unmount } = renderWithProviders(
+      <ProviderEndpointsTable vendorId={1} readOnly={true} />
+    );
+
+    await flushTicks(6);
+
+    const headers = Array.from(document.querySelectorAll("th")).map((th) => th.textContent);
+    expect(headers).not.toContain("Actions");
+
+    const switchElements = document.querySelectorAll("[data-slot='switch']");
+    expect(switchElements.length).toBe(0);
+
+    unmount();
+  });
+
+  test("shows actions column by default", async () => {
+    const { unmount } = renderWithProviders(<ProviderEndpointsTable vendorId={1} />);
+
+    await flushTicks(6);
+
+    expect(document.body.textContent || "").toContain("Actions");
+
+    unmount();
+  });
+
+  test("toggle switch calls editProviderEndpoint", async () => {
+    const { unmount } = renderWithProviders(<ProviderEndpointsTable vendorId={1} />);
+
+    await flushTicks(6);
+
+    const endpointRow = Array.from(document.querySelectorAll("tr")).find((row) =>
+      row.textContent?.includes("https://api.claude.example.com/v1")
+    );
+    expect(endpointRow).toBeDefined();
+
+    const switchEl = endpointRow?.querySelector<HTMLElement>("[data-slot='switch']");
+    expect(switchEl).not.toBeNull();
+    switchEl?.click();
+
+    await flushTicks(2);
+
+    expect(providerEndpointsActionMocks.editProviderEndpoint).toHaveBeenCalledWith(
+      expect.objectContaining({ endpointId: 1, isEnabled: false })
+    );
+
+    unmount();
+  });
+
+  test("probe button calls probeProviderEndpoint", async () => {
+    const { unmount } = renderWithProviders(<ProviderEndpointsTable vendorId={1} />);
+
+    await flushTicks(6);
+
+    const probeButtons = document.querySelectorAll("button");
+    const probeButton = Array.from(probeButtons).find((btn) =>
+      btn.querySelector("svg.lucide-play")
+    );
+    expect(probeButton).toBeDefined();
+
+    probeButton?.click();
+    await flushTicks(2);
+
+    expect(providerEndpointsActionMocks.probeProviderEndpoint).toHaveBeenCalledWith({
+      endpointId: 1,
+    });
+
+    unmount();
+  });
+
+  test("shows empty state when no endpoints", async () => {
+    providerEndpointsActionMocks.getProviderEndpointsByVendor.mockResolvedValueOnce([]);
+
+    const { unmount } = renderWithProviders(<ProviderEndpointsTable vendorId={1} />);
+
+    await flushTicks(6);
+
+    expect(document.body.textContent || "").toContain("No endpoints");
+
+    unmount();
+  });
+
+  test("displays enabled/disabled badge correctly", async () => {
+    const { unmount } = renderWithProviders(<ProviderEndpointsTable vendorId={1} />);
+
+    await flushTicks(6);
+
+    expect(document.body.textContent || "").toContain("enabled");
+    expect(document.body.textContent || "").toContain("disabled");
+
+    unmount();
+  });
+
+  test("edit dialog submits with label, sortOrder, and isEnabled", async () => {
+    providerEndpointsActionMocks.getProviderEndpointsByVendor.mockResolvedValueOnce([
+      {
+        id: 10,
+        vendorId: 1,
+        providerType: "claude",
+        url: "https://original.example.com/v1",
+        label: "Original Label",
+        sortOrder: 3,
+        isEnabled: true,
+        lastProbedAt: null,
+        lastProbeOk: null,
+        lastProbeLatencyMs: null,
+        createdAt: "2026-01-01",
+        updatedAt: "2026-01-01",
+      },
+    ]);
+
+    const { unmount } = renderWithProviders(<ProviderEndpointsTable vendorId={1} />);
+
+    await flushTicks(6);
+
+    const editButtons = document.querySelectorAll("button");
+    const editButton = Array.from(editButtons).find((btn) => btn.querySelector("svg.lucide-pen"));
+    expect(editButton).toBeDefined();
+
+    act(() => {
+      editButton?.click();
+    });
+
+    await flushTicks(4);
+
+    const urlInput = document.querySelector<HTMLInputElement>('input[name="url"]');
+    const labelInput = document.querySelector<HTMLInputElement>('input[name="label"]');
+    const sortOrderInput = document.querySelector<HTMLInputElement>('input[name="sortOrder"]');
+
+    expect(urlInput?.value).toBe("https://original.example.com/v1");
+    expect(labelInput?.value).toBe("Original Label");
+    expect(sortOrderInput?.value).toBe("3");
+
+    act(() => {
+      if (urlInput) {
+        urlInput.value = "https://updated.example.com/v1";
+        urlInput.dispatchEvent(new Event("input", { bubbles: true }));
+      }
+      if (labelInput) {
+        labelInput.value = "Updated Label";
+        labelInput.dispatchEvent(new Event("input", { bubbles: true }));
+      }
+      if (sortOrderInput) {
+        sortOrderInput.value = "10";
+        sortOrderInput.dispatchEvent(new Event("input", { bubbles: true }));
+      }
+    });
+
+    const form = document.querySelector("form");
+    act(() => {
+      form?.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
+    });
+
+    await flushTicks(4);
+
+    expect(providerEndpointsActionMocks.editProviderEndpoint).toHaveBeenCalledWith(
+      expect.objectContaining({
+        endpointId: 10,
+        url: "https://updated.example.com/v1",
+        label: "Updated Label",
+        sortOrder: 10,
+        isEnabled: true,
+      })
+    );
+
+    unmount();
+  });
+});
+
+describe("AddEndpointButton", () => {
+  beforeEach(() => {
+    queryClient = new QueryClient({
+      defaultOptions: {
+        queries: { retry: false },
+        mutations: { retry: false },
+      },
+    });
+    vi.clearAllMocks();
+    while (document.body.firstChild) {
+      document.body.removeChild(document.body.firstChild);
+    }
+  });
+
+  test("renders add button", async () => {
+    const { unmount } = renderWithProviders(<AddEndpointButton vendorId={1} />);
+
+    await flushTicks(2);
+
+    expect(document.body.textContent || "").toContain("Add Endpoint");
+
+    unmount();
+  });
+
+  test("opens dialog on click", async () => {
+    const { unmount } = renderWithProviders(<AddEndpointButton vendorId={1} />);
+
+    await flushTicks(2);
+
+    const addButton = document.querySelector("button");
+    addButton?.click();
+
+    await flushTicks(2);
+
+    expect(document.body.textContent || "").toContain("URL");
+
+    unmount();
+  });
+
+  test("shows type selector when no fixed providerType", async () => {
+    const { unmount } = renderWithProviders(<AddEndpointButton vendorId={1} />);
+
+    await flushTicks(2);
+
+    const addButton = document.querySelector("button");
+    addButton?.click();
+
+    await flushTicks(2);
+
+    expect(document.body.textContent || "").toContain("Type");
+
+    unmount();
+  });
+
+  test("hides type selector when providerType is fixed", async () => {
+    const { unmount } = renderWithProviders(
+      <AddEndpointButton vendorId={1} providerType="claude" />
+    );
+
+    await flushTicks(2);
+
+    const addButton = document.querySelector("button");
+    addButton?.click();
+
+    await flushTicks(2);
+
+    const labels = Array.from(document.querySelectorAll("label")).map((l) => l.textContent);
+    const hasTypeLabel = labels.some((l) => l === "Type");
+    expect(hasTypeLabel).toBe(false);
+
+    unmount();
+  });
+
+  test("submits with label, sortOrder, and isEnabled fields", async () => {
+    const { unmount } = renderWithProviders(<AddEndpointButton vendorId={1} />);
+
+    await flushTicks(2);
+
+    const addButton = document.querySelector("button");
+    act(() => {
+      addButton?.click();
+    });
+
+    await flushTicks(2);
+
+    const urlInput = document.querySelector<HTMLInputElement>('input[name="url"]');
+    const labelInput = document.querySelector<HTMLInputElement>('input[name="label"]');
+    const sortOrderInput = document.querySelector<HTMLInputElement>('input[name="sortOrder"]');
+
+    act(() => {
+      if (urlInput) {
+        urlInput.value = "https://test.example.com/v1";
+        urlInput.dispatchEvent(new Event("input", { bubbles: true }));
+      }
+      if (labelInput) {
+        labelInput.value = "Test Label";
+        labelInput.dispatchEvent(new Event("input", { bubbles: true }));
+      }
+      if (sortOrderInput) {
+        sortOrderInput.value = "5";
+        sortOrderInput.dispatchEvent(new Event("input", { bubbles: true }));
+      }
+    });
+
+    const form = document.querySelector("form");
+    act(() => {
+      form?.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
+    });
+
+    await flushTicks(4);
+
+    expect(providerEndpointsActionMocks.addProviderEndpoint).toHaveBeenCalledWith(
+      expect.objectContaining({
+        vendorId: 1,
+        url: "https://test.example.com/v1",
+        label: "Test Label",
+        sortOrder: 5,
+        isEnabled: true,
+      })
+    );
+
+    unmount();
+  });
+});
+
+describe("ProviderEndpointsSection", () => {
+  beforeEach(() => {
+    queryClient = new QueryClient({
+      defaultOptions: {
+        queries: { retry: false },
+        mutations: { retry: false },
+      },
+    });
+    vi.clearAllMocks();
+    while (document.body.firstChild) {
+      document.body.removeChild(document.body.firstChild);
+    }
+  });
+
+  test("renders section header with endpoints label", async () => {
+    const { unmount } = renderWithProviders(<ProviderEndpointsSection vendorId={1} />);
+
+    await flushTicks(6);
+
+    expect(document.body.textContent || "").toContain("Endpoints");
+
+    unmount();
+  });
+
+  test("renders add button in section header", async () => {
+    const { unmount } = renderWithProviders(<ProviderEndpointsSection vendorId={1} />);
+
+    await flushTicks(6);
+
+    expect(document.body.textContent || "").toContain("Add Endpoint");
+
+    unmount();
+  });
+
+  test("hides add button in readOnly mode", async () => {
+    const { unmount } = renderWithProviders(
+      <ProviderEndpointsSection vendorId={1} readOnly={true} />
+    );
+
+    await flushTicks(6);
+
+    expect(document.body.textContent || "").not.toContain("Add Endpoint");
+
+    unmount();
+  });
+
+  test("renders table with endpoints", async () => {
+    const { unmount } = renderWithProviders(<ProviderEndpointsSection vendorId={1} />);
+
+    await flushTicks(6);
+
+    expect(document.body.textContent || "").toContain("https://api.claude.example.com/v1");
+
+    unmount();
+  });
+
+  test("passes providerType filter to table", async () => {
+    const { unmount } = renderWithProviders(
+      <ProviderEndpointsSection vendorId={1} providerType="claude" />
+    );
+
+    await flushTicks(6);
+
+    expect(providerEndpointsActionMocks.getProviderEndpoints).toHaveBeenCalledWith({
+      vendorId: 1,
+      providerType: "claude",
+    });
+
+    unmount();
+  });
+});

+ 313 - 0
tests/unit/settings/providers/provider-form-endpoint-pool.test.tsx

@@ -0,0 +1,313 @@
+/**
+ * @vitest-environment happy-dom
+ */
+
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import type { ReactNode } from "react";
+import { act } from "react";
+import { createRoot } from "react-dom/client";
+import { NextIntlClientProvider } from "next-intl";
+import { beforeEach, describe, expect, test, vi } from "vitest";
+import { ProviderForm } from "../../../../src/app/[locale]/settings/providers/_components/forms/provider-form";
+import { Dialog } from "../../../../src/components/ui/dialog";
+import enMessages from "../../../../messages/en";
+import type { ProviderEndpoint, ProviderVendor } from "../../../../src/types/provider";
+
+const sonnerMocks = vi.hoisted(() => ({
+  toast: {
+    success: vi.fn(),
+    error: vi.fn(),
+  },
+}));
+vi.mock("sonner", () => sonnerMocks);
+
+const providersActionMocks = vi.hoisted(() => ({
+  addProvider: vi.fn(async () => ({ ok: true })),
+  editProvider: vi.fn(async () => ({ ok: true })),
+  removeProvider: vi.fn(async () => ({ ok: true })),
+  getUnmaskedProviderKey: vi.fn(async () => ({ ok: true, data: { key: "test-key" } })),
+  getProviderTestPresets: vi.fn(async () => ({ ok: true, data: [] })),
+  getModelSuggestionsByProviderGroup: vi.fn(async () => []),
+  fetchUpstreamModels: vi.fn(async () => ({ ok: true, data: { models: [] } })),
+}));
+vi.mock("@/actions/providers", () => providersActionMocks);
+
+const requestFiltersActionMocks = vi.hoisted(() => ({
+  getDistinctProviderGroupsAction: vi.fn(async () => ({ ok: true, data: [] })),
+}));
+vi.mock("@/actions/request-filters", () => requestFiltersActionMocks);
+
+const modelPricesActionMocks = vi.hoisted(() => ({
+  getAvailableModelsByProviderType: vi.fn(async () => []),
+}));
+vi.mock("@/actions/model-prices", () => modelPricesActionMocks);
+
+const providerEndpointsActionMocks = vi.hoisted(() => ({
+  getProviderVendors: vi.fn(async (): Promise<ProviderVendor[]> => []),
+  getProviderEndpoints: vi.fn(async (): Promise<ProviderEndpoint[]> => []),
+  getProviderEndpointsByVendor: vi.fn(async (): Promise<ProviderEndpoint[]> => []),
+  addProviderEndpoint: vi.fn(async () => ({ ok: true, data: { endpoint: {} } })),
+  editProviderEndpoint: vi.fn(async () => ({ ok: true, data: { endpoint: {} } })),
+  probeProviderEndpoint: vi.fn(async () => ({
+    ok: true,
+    data: { endpoint: {}, result: { ok: true } },
+  })),
+  removeProviderEndpoint: vi.fn(async () => ({ ok: true })),
+}));
+vi.mock("@/actions/provider-endpoints", () => providerEndpointsActionMocks);
+
+function loadMessages() {
+  return {
+    common: enMessages.common,
+    errors: enMessages.errors,
+    ui: enMessages.ui,
+    forms: enMessages.forms,
+    settings: enMessages.settings,
+  };
+}
+
+let queryClient: QueryClient;
+
+function renderWithProviders(node: ReactNode) {
+  const container = document.createElement("div");
+  document.body.appendChild(container);
+  const root = createRoot(container);
+
+  act(() => {
+    root.render(
+      <QueryClientProvider client={queryClient}>
+        <NextIntlClientProvider locale="en" messages={loadMessages()} timeZone="UTC">
+          <Dialog open onOpenChange={() => {}}>
+            {node}
+          </Dialog>
+        </NextIntlClientProvider>
+      </QueryClientProvider>
+    );
+  });
+
+  return {
+    unmount: () => {
+      act(() => root.unmount());
+      container.remove();
+    },
+  };
+}
+
+async function flushTicks(times = 3) {
+  for (let i = 0; i < times; i++) {
+    await act(async () => {
+      await new Promise((r) => setTimeout(r, 0));
+    });
+  }
+}
+
+function setNativeValue(element: HTMLInputElement, value: string) {
+  const prototype = Object.getPrototypeOf(element) as unknown as { value?: unknown };
+  const descriptor = Object.getOwnPropertyDescriptor(prototype, "value");
+  if (descriptor?.set) {
+    descriptor.set.call(element, value);
+    return;
+  }
+  element.value = value;
+}
+
+describe("ProviderForm: endpoint pool integration", () => {
+  beforeEach(() => {
+    queryClient = new QueryClient({
+      defaultOptions: {
+        queries: { retry: false },
+        mutations: { retry: false },
+      },
+    });
+    vi.clearAllMocks();
+    while (document.body.firstChild) {
+      document.body.removeChild(document.body.firstChild);
+    }
+  });
+
+  test("Website URL input should render before provider URL input", async () => {
+    const { unmount } = renderWithProviders(
+      <ProviderForm mode="create" enableMultiProviderTypes />
+    );
+
+    await flushTicks(2);
+
+    const websiteUrlInput = document.getElementById("website-url") as HTMLInputElement | null;
+    const urlInput = document.getElementById("url") as HTMLInputElement | null;
+
+    expect(websiteUrlInput).toBeTruthy();
+    expect(urlInput).toBeTruthy();
+
+    const relative = websiteUrlInput?.compareDocumentPosition(urlInput as Node) ?? 0;
+    expect((relative & Node.DOCUMENT_POSITION_FOLLOWING) !== 0).toBe(true);
+
+    unmount();
+  });
+
+  test("When vendor resolves and endpoints exist, should show endpoint pool and hide URL input", async () => {
+    providerEndpointsActionMocks.getProviderVendors.mockResolvedValueOnce([
+      {
+        id: 1,
+        websiteDomain: "example.com",
+        displayName: "Example",
+        websiteUrl: "https://example.com",
+        faviconUrl: null,
+        createdAt: new Date("2026-01-01"),
+        updatedAt: new Date("2026-01-01"),
+      },
+    ]);
+    providerEndpointsActionMocks.getProviderEndpoints.mockResolvedValueOnce([
+      {
+        id: 10,
+        vendorId: 1,
+        providerType: "claude",
+        url: "https://api.example.com/v1",
+        label: null,
+        sortOrder: 0,
+        isEnabled: true,
+        lastProbedAt: null,
+        lastProbeOk: null,
+        lastProbeStatusCode: null,
+        lastProbeLatencyMs: null,
+        lastProbeErrorType: null,
+        lastProbeErrorMessage: null,
+        createdAt: new Date("2026-01-01T00:00:00Z"),
+        updatedAt: new Date("2026-01-01T00:00:00Z"),
+        deletedAt: null,
+      },
+    ]);
+
+    const { unmount } = renderWithProviders(
+      <ProviderForm mode="create" enableMultiProviderTypes />
+    );
+
+    await flushTicks(2);
+
+    const websiteUrlInput = document.getElementById("website-url") as HTMLInputElement | null;
+    expect(websiteUrlInput).toBeTruthy();
+
+    await act(async () => {
+      if (!websiteUrlInput) return;
+      setNativeValue(websiteUrlInput, "https://example.com");
+      websiteUrlInput.dispatchEvent(new Event("input", { bubbles: true }));
+      websiteUrlInput.dispatchEvent(new Event("change", { bubbles: true }));
+    });
+
+    await flushTicks(6);
+
+    expect(document.getElementById("url")).toBeNull();
+    expect(document.body.textContent || "").toContain("Endpoints");
+    expect(document.body.textContent || "").toContain("Add Endpoint");
+
+    unmount();
+  });
+
+  test("When vendor cannot be resolved, should show URL input and block submit without valid URL", async () => {
+    const { unmount } = renderWithProviders(
+      <ProviderForm mode="create" enableMultiProviderTypes />
+    );
+
+    await flushTicks(2);
+
+    const nameInput = document.getElementById("name") as HTMLInputElement | null;
+    const keyInput = document.getElementById("key") as HTMLInputElement | null;
+    const urlInput = document.getElementById("url") as HTMLInputElement | null;
+    expect(nameInput).toBeTruthy();
+    expect(keyInput).toBeTruthy();
+    expect(urlInput).toBeTruthy();
+
+    await act(async () => {
+      if (!nameInput || !keyInput) return;
+      setNativeValue(nameInput, "p1");
+      nameInput.dispatchEvent(new Event("input", { bubbles: true }));
+      nameInput.dispatchEvent(new Event("change", { bubbles: true }));
+
+      setNativeValue(keyInput, "k");
+      keyInput.dispatchEvent(new Event("input", { bubbles: true }));
+      keyInput.dispatchEvent(new Event("change", { bubbles: true }));
+    });
+
+    const form = document.body.querySelector("form") as HTMLFormElement | null;
+    expect(form).toBeTruthy();
+
+    await act(async () => {
+      form?.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
+    });
+
+    await flushTicks(3);
+
+    expect(providersActionMocks.addProvider).toHaveBeenCalledTimes(0);
+    expect(sonnerMocks.toast.error).toHaveBeenCalled();
+
+    unmount();
+  });
+
+  test("When vendor cannot be resolved but URL provided, should call addProvider", async () => {
+    providerEndpointsActionMocks.getProviderVendors
+      .mockResolvedValueOnce([])
+      .mockResolvedValueOnce([
+        {
+          id: 99,
+          websiteDomain: "example.com",
+          displayName: "Example",
+          websiteUrl: "https://example.com",
+          faviconUrl: null,
+          createdAt: new Date("2026-01-01"),
+          updatedAt: new Date("2026-01-01"),
+        },
+      ]);
+
+    const { unmount } = renderWithProviders(
+      <ProviderForm mode="create" enableMultiProviderTypes />
+    );
+
+    await flushTicks(2);
+
+    const nameInput = document.getElementById("name") as HTMLInputElement | null;
+    const websiteUrlInput = document.getElementById("website-url") as HTMLInputElement | null;
+    const urlInput = document.getElementById("url") as HTMLInputElement | null;
+    const keyInput = document.getElementById("key") as HTMLInputElement | null;
+    expect(nameInput).toBeTruthy();
+    expect(websiteUrlInput).toBeTruthy();
+    expect(urlInput).toBeTruthy();
+    expect(keyInput).toBeTruthy();
+
+    await act(async () => {
+      if (!nameInput || !websiteUrlInput || !urlInput || !keyInput) return;
+      setNativeValue(nameInput, "p2");
+      nameInput.dispatchEvent(new Event("input", { bubbles: true }));
+      nameInput.dispatchEvent(new Event("change", { bubbles: true }));
+
+      setNativeValue(websiteUrlInput, "https://example.com");
+      websiteUrlInput.dispatchEvent(new Event("input", { bubbles: true }));
+      websiteUrlInput.dispatchEvent(new Event("change", { bubbles: true }));
+
+      setNativeValue(urlInput, "https://api.example.com/v1");
+      urlInput.dispatchEvent(new Event("input", { bubbles: true }));
+      urlInput.dispatchEvent(new Event("change", { bubbles: true }));
+
+      setNativeValue(keyInput, "k");
+      keyInput.dispatchEvent(new Event("input", { bubbles: true }));
+      keyInput.dispatchEvent(new Event("change", { bubbles: true }));
+    });
+
+    const form = document.body.querySelector("form") as HTMLFormElement | null;
+    expect(form).toBeTruthy();
+
+    await act(async () => {
+      form?.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
+    });
+
+    for (let i = 0; i < 8; i++) {
+      if (providersActionMocks.addProvider.mock.calls.length > 0) break;
+      await flushTicks(1);
+    }
+
+    expect(providersActionMocks.addProvider).toHaveBeenCalledTimes(1);
+
+    await flushTicks(3);
+    expect(providerEndpointsActionMocks.addProviderEndpoint).toHaveBeenCalledTimes(0);
+
+    unmount();
+  });
+});

+ 55 - 13
tests/unit/settings/providers/provider-form-total-limit-ui.test.tsx

@@ -2,14 +2,25 @@
  * @vitest-environment happy-dom
  */
 
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
 import type { ReactNode } from "react";
 import { act } from "react";
 import { createRoot } from "react-dom/client";
 import { NextIntlClientProvider } from "next-intl";
 import { beforeEach, describe, expect, test, vi } from "vitest";
-import { Dialog } from "@/components/ui/dialog";
-import { ProviderForm } from "@/app/[locale]/settings/providers/_components/forms/provider-form";
+import { ProviderForm } from "../../../../src/app/[locale]/settings/providers/_components/forms/provider-form";
+import { Dialog } from "../../../../src/components/ui/dialog";
 import enMessages from "../../../../messages/en";
+import type { ProviderDisplay } from "../../../../src/types/provider";
+
+let queryClient: QueryClient;
+
+function hasOwn(obj: object, prop: PropertyKey): boolean {
+  return (Object as unknown as { hasOwn: (obj: object, prop: PropertyKey) => boolean }).hasOwn(
+    obj,
+    prop
+  );
+}
 
 const sonnerMocks = vi.hoisted(() => ({
   toast: {
@@ -20,9 +31,9 @@ const sonnerMocks = vi.hoisted(() => ({
 vi.mock("sonner", () => sonnerMocks);
 
 const providersActionMocks = vi.hoisted(() => ({
-  addProvider: vi.fn(async () => ({ ok: true })),
-  editProvider: vi.fn(async () => ({ ok: true })),
-  removeProvider: vi.fn(async () => ({ ok: true })),
+  addProvider: vi.fn(async (_data: unknown) => ({ ok: true })),
+  editProvider: vi.fn(async (_providerId: number, _data: unknown) => ({ ok: true })),
+  removeProvider: vi.fn(async (_providerId: number) => ({ ok: true })),
   getUnmaskedProviderKey: vi.fn(async () => ({ ok: true, data: { key: "test-key" } })),
   getProviderTestPresets: vi.fn(async () => ({ ok: true, data: [] })),
   getModelSuggestionsByProviderGroup: vi.fn(async () => []),
@@ -40,6 +51,13 @@ const modelPricesActionMocks = vi.hoisted(() => ({
 }));
 vi.mock("@/actions/model-prices", () => modelPricesActionMocks);
 
+const providerEndpointsActionMocks = vi.hoisted(() => ({
+  getProviderVendors: vi.fn(async () => []),
+  getProviderEndpoints: vi.fn(async () => []),
+  addProviderEndpoint: vi.fn(async () => ({ ok: true, data: { endpoint: {} } })),
+}));
+vi.mock("@/actions/provider-endpoints", () => providerEndpointsActionMocks);
+
 function loadMessages() {
   return {
     common: enMessages.common,
@@ -56,7 +74,7 @@ function render(node: ReactNode) {
   const root = createRoot(container);
 
   act(() => {
-    root.render(node);
+    root.render(<QueryClientProvider client={queryClient}>{node}</QueryClientProvider>);
   });
 
   return {
@@ -79,6 +97,12 @@ function setNativeValue(element: HTMLInputElement, value: string) {
 
 describe("ProviderForm: 编辑时应支持提交总消费上限(limit_total_usd)", () => {
   beforeEach(() => {
+    queryClient = new QueryClient({
+      defaultOptions: {
+        queries: { retry: false },
+        mutations: { retry: false },
+      },
+    });
     vi.clearAllMocks();
 
     // happy-dom 在部分运行时可能不会提供完整的 Storage 实现,这里做最小 mock,避免组件读写报错
@@ -86,7 +110,7 @@ describe("ProviderForm: 编辑时应支持提交总消费上限(limit_total_usd)
     const storage = (() => {
       let store: Record<string, string> = {};
       return {
-        getItem: (key: string) => (Object.hasOwn(store, key) ? store[key] : null),
+        getItem: (key: string) => (hasOwn(store, key) ? store[key] : null),
         setItem: (key: string, value: string) => {
           store[key] = String(value);
         },
@@ -114,7 +138,7 @@ describe("ProviderForm: 编辑时应支持提交总消费上限(limit_total_usd)
   test("填写总消费上限后提交应调用 editProvider 且 payload 携带 limit_total_usd", async () => {
     const messages = loadMessages();
 
-    const provider = {
+    const provider: ProviderDisplay = {
       id: 1,
       name: "p",
       url: "https://example.com",
@@ -125,6 +149,7 @@ describe("ProviderForm: 编辑时应支持提交总消费上限(limit_total_usd)
       costMultiplier: 1,
       groupTag: null,
       providerType: "claude",
+      providerVendorId: null,
       preserveClientIp: false,
       modelRedirects: null,
       allowedModels: null,
@@ -136,6 +161,7 @@ describe("ProviderForm: 编辑时应支持提交总消费上限(limit_total_usd)
       dailyResetTime: "00:00",
       limitWeeklyUsd: null,
       limitMonthlyUsd: null,
+      limitTotalUsd: null,
       limitConcurrentSessions: 0,
       maxRetryAttempts: null,
       circuitBreakerFailureThreshold: 5,
@@ -150,13 +176,19 @@ describe("ProviderForm: 编辑时应支持提交总消费上限(limit_total_usd)
       faviconUrl: null,
       cacheTtlPreference: null,
       context1mPreference: null,
+      codexReasoningEffortPreference: null,
+      codexReasoningSummaryPreference: null,
+      codexTextVerbosityPreference: null,
+      codexParallelToolCallsPreference: null,
+      anthropicMaxTokensPreference: null,
+      anthropicThinkingBudgetPreference: null,
       tpm: null,
       rpm: null,
       rpd: null,
       cc: null,
       createdAt: "2026-01-04",
       updatedAt: "2026-01-04",
-    } as any;
+    };
 
     const { unmount } = render(
       <NextIntlClientProvider locale="en" messages={messages} timeZone="UTC">
@@ -197,8 +229,10 @@ describe("ProviderForm: 编辑时应支持提交总消费上限(limit_total_usd)
     }
 
     expect(providersActionMocks.editProvider).toHaveBeenCalledTimes(1);
-    const [, payload] = providersActionMocks.editProvider.mock.calls[0] as [number, any];
-    expect(Object.hasOwn(payload, "limit_total_usd")).toBe(true);
+    const payload = providersActionMocks.editProvider.mock.calls[0]?.[1] as {
+      limit_total_usd?: unknown;
+    };
+    expect(hasOwn(payload, "limit_total_usd")).toBe(true);
     expect(payload.limit_total_usd).toBe(10.5);
 
     unmount();
@@ -207,12 +241,18 @@ describe("ProviderForm: 编辑时应支持提交总消费上限(limit_total_usd)
 
 describe("ProviderForm: 新增成功后应重置总消费上限输入", () => {
   beforeEach(() => {
+    queryClient = new QueryClient({
+      defaultOptions: {
+        queries: { retry: false },
+        mutations: { retry: false },
+      },
+    });
     vi.clearAllMocks();
 
     const storage = (() => {
       let store: Record<string, string> = {};
       return {
-        getItem: (key: string) => (Object.hasOwn(store, key) ? store[key] : null),
+        getItem: (key: string) => (hasOwn(store, key) ? store[key] : null),
         setItem: (key: string, value: string) => {
           store[key] = String(value);
         },
@@ -299,7 +339,9 @@ describe("ProviderForm: 新增成功后应重置总消费上限输入", () => {
     }
 
     expect(providersActionMocks.addProvider).toHaveBeenCalledTimes(1);
-    const [payload] = providersActionMocks.addProvider.mock.calls[0] as [any];
+    const payload = providersActionMocks.addProvider.mock.calls[0]?.[0] as {
+      limit_total_usd?: unknown;
+    };
     expect(payload.limit_total_usd).toBe(10.5);
 
     // 等待一次调度,让 React 处理新增成功后的 state 重置

+ 239 - 0
tests/unit/settings/providers/provider-rich-list-item-endpoints.test.tsx

@@ -0,0 +1,239 @@
+/**
+ * @vitest-environment happy-dom
+ */
+
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { NextIntlClientProvider } from "next-intl";
+import { type ReactNode, act } from "react";
+import { createRoot } from "react-dom/client";
+import { beforeEach, describe, expect, test, vi } from "vitest";
+import { ProviderRichListItem } from "@/app/[locale]/settings/providers/_components/provider-rich-list-item";
+import type { ProviderDisplay } from "@/types/provider";
+import type { User } from "@/types/user";
+import enMessages from "../../../../messages/en";
+
+// Mock dependencies
+vi.mock("next/navigation", () => ({
+  useRouter: () => ({ refresh: vi.fn() }),
+}));
+
+vi.mock("sonner", () => ({
+  toast: {
+    success: vi.fn(),
+    error: vi.fn(),
+  },
+}));
+
+// Mock actions
+const providerEndpointsActionMocks = vi.hoisted(() => ({
+  getProviderVendors: vi.fn(async () => [
+    {
+      id: 101,
+      displayName: "Anthropic",
+      websiteDomain: "anthropic.com",
+      websiteUrl: "https://anthropic.com",
+      faviconUrl: null,
+      createdAt: "2026-01-01",
+      updatedAt: "2026-01-01",
+    },
+  ]),
+  getProviderEndpointsByVendor: vi.fn(async () => []),
+}));
+vi.mock("@/actions/provider-endpoints", () => providerEndpointsActionMocks);
+
+const providersActionMocks = vi.hoisted(() => ({
+  editProvider: vi.fn(async () => ({ ok: true })),
+  removeProvider: vi.fn(async () => ({ ok: true })),
+  getUnmaskedProviderKey: vi.fn(async () => ({ ok: true, data: { key: "sk-test" } })),
+  resetProviderCircuit: vi.fn(async () => ({ ok: true })),
+  resetProviderTotalUsage: vi.fn(async () => ({ ok: true })),
+}));
+vi.mock("@/actions/providers", () => providersActionMocks);
+
+// Mock tooltip to simplify testing
+vi.mock("@/components/ui/tooltip", () => ({
+  Tooltip: ({ children }: { children: ReactNode }) => <>{children}</>,
+  TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children}</>,
+  TooltipContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
+  TooltipProvider: ({ children }: { children: ReactNode }) => <>{children}</>,
+}));
+
+// Mock ProviderEndpointHover to avoid complex children rendering if needed,
+// but we want to check if it's rendered.
+// Actually, let's NOT mock it fully, or mock it to render a simple test id.
+vi.mock("@/app/[locale]/settings/providers/_components/provider-endpoint-hover", () => ({
+  ProviderEndpointHover: ({ vendorId }: { vendorId: number }) => (
+    <div data-testid="mock-endpoint-hover">Endpoints for Vendor {vendorId}</div>
+  ),
+}));
+
+const ADMIN_USER: User = {
+  id: 1,
+  name: "admin",
+  description: "",
+  role: "admin",
+  rpm: null,
+  dailyQuota: null,
+  providerGroup: null,
+  tags: [],
+  createdAt: new Date("2026-01-01"),
+  updatedAt: new Date("2026-01-01"),
+  dailyResetMode: "fixed",
+  dailyResetTime: "00:00",
+  isEnabled: true,
+};
+
+function makeProviderDisplay(overrides: Partial<ProviderDisplay> = {}): ProviderDisplay {
+  return {
+    id: 1,
+    name: "Claude 3.5 Sonnet",
+    url: "https://api.anthropic.com",
+    maskedKey: "sk-***",
+    isEnabled: true,
+    weight: 1,
+    priority: 1,
+    costMultiplier: 1,
+    groupTag: null,
+    providerType: "claude",
+    providerVendorId: null, // Default to null for legacy check
+    preserveClientIp: false,
+    modelRedirects: null,
+    allowedModels: null,
+    mcpPassthroughType: "none",
+    mcpPassthroughUrl: null,
+    limit5hUsd: null,
+    limitDailyUsd: null,
+    dailyResetMode: "fixed",
+    dailyResetTime: "00:00",
+    limitWeeklyUsd: null,
+    limitMonthlyUsd: null,
+    limitTotalUsd: null,
+    limitConcurrentSessions: 1,
+    maxRetryAttempts: null,
+    circuitBreakerFailureThreshold: 1,
+    circuitBreakerOpenDuration: 60,
+    circuitBreakerHalfOpenSuccessThreshold: 1,
+    proxyUrl: null,
+    proxyFallbackToDirect: false,
+    firstByteTimeoutStreamingMs: 0,
+    streamingIdleTimeoutMs: 0,
+    requestTimeoutNonStreamingMs: 0,
+    websiteUrl: null,
+    faviconUrl: null,
+    cacheTtlPreference: null,
+    context1mPreference: null,
+    codexReasoningEffortPreference: null,
+    codexReasoningSummaryPreference: null,
+    codexTextVerbosityPreference: null,
+    codexParallelToolCallsPreference: null,
+    anthropicMaxTokensPreference: null,
+    anthropicThinkingBudgetPreference: null,
+    tpm: null,
+    rpm: null,
+    rpd: null,
+    cc: null,
+    createdAt: "2026-01-01",
+    updatedAt: "2026-01-01",
+    ...overrides,
+  };
+}
+
+let queryClient: QueryClient;
+
+function renderWithProviders(node: ReactNode) {
+  const container = document.createElement("div");
+  document.body.appendChild(container);
+  const root = createRoot(container);
+
+  act(() => {
+    root.render(
+      <QueryClientProvider client={queryClient}>
+        <NextIntlClientProvider locale="en" messages={enMessages} timeZone="UTC">
+          {node}
+        </NextIntlClientProvider>
+      </QueryClientProvider>
+    );
+  });
+
+  return {
+    unmount: () => {
+      act(() => root.unmount());
+      container.remove();
+    },
+    container,
+  };
+}
+
+async function flushTicks(times = 3) {
+  for (let i = 0; i < times; i++) {
+    await act(async () => {
+      await new Promise((r) => setTimeout(r, 0));
+    });
+  }
+}
+
+describe("ProviderRichListItem Endpoint Display", () => {
+  beforeEach(() => {
+    queryClient = new QueryClient({
+      defaultOptions: {
+        queries: { retry: false },
+      },
+    });
+    vi.clearAllMocks();
+    while (document.body.firstChild) {
+      document.body.removeChild(document.body.firstChild);
+    }
+  });
+
+  test("renders legacy URL when providerVendorId is null", async () => {
+    const provider = makeProviderDisplay({
+      providerVendorId: null,
+      url: "https://api.legacy.com",
+    });
+
+    const { unmount } = renderWithProviders(
+      <ProviderRichListItem
+        provider={provider}
+        currentUser={ADMIN_USER}
+        enableMultiProviderTypes={true}
+      />
+    );
+
+    await flushTicks(5);
+
+    expect(document.body.textContent).toContain("https://api.legacy.com");
+    expect(document.body.textContent).not.toContain("Anthropic");
+    expect(document.querySelector('[data-testid="mock-endpoint-hover"]')).toBeNull();
+
+    unmount();
+  });
+
+  test("renders vendor name and endpoint hover when providerVendorId exists", async () => {
+    const provider = makeProviderDisplay({
+      providerVendorId: 101,
+      url: "https://api.anthropic.com",
+    });
+
+    const { unmount } = renderWithProviders(
+      <ProviderRichListItem
+        provider={provider}
+        currentUser={ADMIN_USER}
+        enableMultiProviderTypes={true}
+      />
+    );
+
+    await flushTicks(5); // Wait for query to resolve
+
+    // Should show vendor name (mocked as "Anthropic")
+    expect(document.body.textContent).toContain("Anthropic");
+
+    // Should NOT show the raw URL in the main label position (though it might be in tooltip, but here we check main text replacement)
+    // The implementation replaces the URL span with the vendor/hover block
+
+    // Should render the mock endpoint hover
+    expect(document.querySelector('[data-testid="mock-endpoint-hover"]')).not.toBeNull();
+    expect(document.body.textContent).toContain("Endpoints for Vendor 101");
+
+    unmount();
+  });
+});