فهرست منبع

feat(probe): implement dynamic endpoint probe intervals (#669)

* feat(probe): implement dynamic endpoint probe intervals

- Change probe scheduling from global interval to per-endpoint dynamic intervals:
  - Base interval: 60s (configurable via ENDPOINT_PROBE_INTERVAL_MS)
  - Single-endpoint vendor: 10min (reduces unnecessary probing)
  - Timeout override: 10s (faster recovery for timeout errors)
- Filter probes to only "due" endpoints based on lastProbedAt + effectiveInterval
- Remove type tabs from VendorEndpointsSection, show all endpoints in single list
- Display type icon with tooltip for each endpoint row
- Sort endpoints by type order (from getAllProviderTypes) then sortOrder
- Add type selector in AddEndpointButton dialog
- Update i18n strings for all 5 languages
- Add comprehensive unit tests for dynamic interval rules

Co-Authored-By: Claude Opus 4.5 <[email protected]>

* refactor: derive selectableTypes from getAllProviderTypes

Address code review feedback: use getAllProviderTypes().filter() instead
of hardcoded array to ensure automatic sync when new provider types are added.

Co-Authored-By: Claude Opus 4.5 <[email protected]>

---------

Co-authored-by: Claude Opus 4.5 <[email protected]>
Ding 2 هفته پیش
والد
کامیت
3414457d30

+ 10 - 3
.env.example

@@ -122,9 +122,16 @@ PROBE_INTERVAL_MS=30000
 PROBE_TIMEOUT_MS=5000
 
 # Provider Endpoint Probing (always enabled)
-# 功能说明:每 10 秒探测所有启用端点的速度与连通性,并刷新端点选择排序。
-# 注意:没有 ENABLE 开关,默认启用;可通过下列参数调优。
-ENDPOINT_PROBE_INTERVAL_MS=10000
+# Probes all enabled endpoints based on dynamic intervals and refreshes endpoint selection ranking.
+# Note: No ENABLE switch, enabled by default; tune via parameters below.
+#
+# Dynamic Interval Rules (in priority order):
+# 1. Timeout Override (10s): If endpoint's lastProbeErrorType === "timeout" and not recovered (lastProbeOk !== true)
+# 2. Single-Vendor (10min): If vendor has only 1 enabled endpoint
+# 3. Base Interval (default): All other endpoints
+#
+# ENDPOINT_PROBE_INTERVAL_MS controls the base interval. Single-vendor and timeout intervals are fixed.
+ENDPOINT_PROBE_INTERVAL_MS=60000
 ENDPOINT_PROBE_TIMEOUT_MS=5000
 ENDPOINT_PROBE_CONCURRENCY=10
 ENDPOINT_PROBE_CYCLE_JITTER_MS=1000

+ 2 - 0
messages/en/settings/providers/strings.json

@@ -82,6 +82,8 @@
   "probeOk": "OK",
   "probeError": "Error",
   "addEndpointDesc": "Add a new {providerType} endpoint for this vendor.",
+  "addEndpointDescGeneric": "Add a new API endpoint for this vendor.",
+  "columnType": "Type",
   "endpointUrlLabel": "URL",
   "endpointUrlPlaceholder": "https://api.example.com/v1",
   "endpointLabelOptional": "Label (optional)",

+ 2 - 0
messages/ja/settings/providers/strings.json

@@ -82,6 +82,8 @@
   "probeOk": "OK",
   "probeError": "エラー",
   "addEndpointDesc": "このベンダーに {providerType} エンドポイントを追加します。",
+  "addEndpointDescGeneric": "このベンダーに新しい API エンドポイントを追加します。",
+  "columnType": "種類",
   "endpointUrlLabel": "URL",
   "endpointUrlPlaceholder": "https://api.example.com/v1",
   "endpointLabelOptional": "ラベル (任意)",

+ 2 - 0
messages/ru/settings/providers/strings.json

@@ -82,6 +82,8 @@
   "probeOk": "OK",
   "probeError": "Ошибка",
   "addEndpointDesc": "Добавьте новый эндпоинт {providerType} для этого вендора.",
+  "addEndpointDescGeneric": "Добавьте новый API эндпоинт для этого вендора.",
+  "columnType": "Тип",
   "endpointUrlLabel": "URL",
   "endpointUrlPlaceholder": "https://api.example.com/v1",
   "endpointLabelOptional": "Метка (необязательно)",

+ 2 - 0
messages/zh-CN/settings/providers/strings.json

@@ -82,6 +82,8 @@
   "probeOk": "正常",
   "probeError": "异常",
   "addEndpointDesc": "为该服务商添加一个新的 {providerType} 端点。",
+  "addEndpointDescGeneric": "为该服务商添加一个新的 API 端点。",
+  "columnType": "类型",
   "endpointUrlLabel": "URL",
   "endpointUrlPlaceholder": "https://api.example.com/v1",
   "endpointLabelOptional": "标签(可选)",

+ 2 - 0
messages/zh-TW/settings/providers/strings.json

@@ -82,6 +82,8 @@
   "probeOk": "正常",
   "probeError": "異常",
   "addEndpointDesc": "為此供應商新增一個 {providerType} 端點。",
+  "addEndpointDescGeneric": "為此供應商新增一個新的 API 端點。",
+  "columnType": "類型",
   "endpointUrlLabel": "URL",
   "endpointUrlPlaceholder": "https://api.example.com/v1",
   "endpointLabelOptional": "標籤(選填)",

+ 25 - 0
src/actions/provider-endpoints.ts

@@ -21,6 +21,7 @@ import {
   deleteProviderVendor,
   findProviderEndpointById,
   findProviderEndpointProbeLogs,
+  findProviderEndpointsByVendor,
   findProviderEndpointsByVendorAndType,
   findProviderVendorById,
   findProviderVendors,
@@ -185,6 +186,30 @@ export async function getProviderEndpoints(input: {
   }
 }
 
+export async function getProviderEndpointsByVendor(input: {
+  vendorId: number;
+}): Promise<ProviderEndpoint[]> {
+  try {
+    const session = await getAdminSession();
+    if (!session) {
+      return [];
+    }
+
+    const parsed = z.object({ vendorId: VendorIdSchema }).safeParse(input);
+    if (!parsed.success) {
+      logger.debug("getProviderEndpointsByVendor:invalid_input", {
+        error: parsed.error,
+      });
+      return [];
+    }
+
+    return await findProviderEndpointsByVendor(parsed.data.vendorId);
+  } catch (error) {
+    logger.error("getProviderEndpointsByVendor:error", error);
+    return [];
+  }
+}
+
 export async function addProviderEndpoint(
   input: unknown
 ): Promise<ActionResult<{ endpoint: ProviderEndpoint }>> {

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

@@ -3,7 +3,6 @@
 import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
 import { formatDistanceToNow } from "date-fns";
 import {
-  Activity,
   Edit2,
   ExternalLink,
   InfoIcon,
@@ -19,13 +18,11 @@ import { toast } from "sonner";
 import {
   addProviderEndpoint,
   editProviderEndpoint,
-  getProviderEndpoints,
+  getProviderEndpointsByVendor,
   getProviderVendors,
-  getVendorTypeCircuitInfo,
   probeProviderEndpoint,
   removeProviderEndpoint,
   removeProviderVendor,
-  resetVendorTypeCircuit,
 } from "@/actions/provider-endpoints";
 import {
   AlertDialog,
@@ -59,6 +56,13 @@ import {
 } 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,
@@ -69,7 +73,11 @@ import {
   TableRow,
 } from "@/components/ui/table";
 import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
-import { getProviderTypeConfig, getProviderTypeTranslationKey } from "@/lib/provider-type-utils";
+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 {
@@ -270,140 +278,47 @@ function VendorCard({
 
 function VendorEndpointsSection({ vendorId }: { vendorId: number }) {
   const t = useTranslations("settings.providers");
-  const tTypes = useTranslations("settings.providers.types");
-  const [activeType, setActiveType] = useState<ProviderType>("claude");
-
-  const providerTypes: ProviderType[] = ["claude", "codex", "gemini", "openai-compatible"];
 
   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">
-        <div className="flex flex-col space-y-4">
-          <div className="flex items-center justify-between">
-            <div className="flex items-center space-x-2 bg-muted p-1 rounded-md">
-              {providerTypes.map((type) => {
-                const typeConfig = getProviderTypeConfig(type);
-                const TypeIcon = typeConfig.icon;
-                const typeKey = getProviderTypeTranslationKey(type);
-                const label = tTypes(`${typeKey}.label`);
-                return (
-                  <Button
-                    key={type}
-                    variant={activeType === type ? "default" : "ghost"}
-                    size="sm"
-                    onClick={() => setActiveType(type)}
-                    className="h-7 text-xs capitalize"
-                  >
-                    <span
-                      className={`mr-1.5 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}
-                  </Button>
-                );
-              })}
-            </div>
-
-            <AddEndpointButton vendorId={vendorId} providerType={activeType} />
-          </div>
-
-          <VendorTypeCircuitControl vendorId={vendorId} providerType={activeType} />
-
-          <EndpointsTable vendorId={vendorId} providerType={activeType} />
-        </div>
+        <EndpointsTable vendorId={vendorId} />
       </div>
     </div>
   );
 }
 
-function VendorTypeCircuitControl({
-  vendorId,
-  providerType,
-}: {
-  vendorId: number;
-  providerType: ProviderType;
-}) {
+function EndpointsTable({ vendorId }: { vendorId: number }) {
   const t = useTranslations("settings.providers");
-  const queryClient = useQueryClient();
+  const tTypes = useTranslations("settings.providers.types");
 
-  const { data: circuitInfo, isLoading } = useQuery({
-    queryKey: ["vendor-circuit", vendorId, providerType],
+  const { data: rawEndpoints = [], isLoading } = useQuery({
+    queryKey: ["provider-endpoints", vendorId],
     queryFn: async () => {
-      const res = await getVendorTypeCircuitInfo({ vendorId, providerType });
-      if (!res.ok) throw new Error(res.error);
-      return res.data;
-    },
-  });
-
-  const resetMutation = useMutation({
-    mutationFn: async () => {
-      const res = await resetVendorTypeCircuit({ vendorId, providerType });
-      if (!res.ok) throw new Error(res.error);
-      return res.data;
-    },
-    onSuccess: () => {
-      queryClient.invalidateQueries({ queryKey: ["vendor-circuit", vendorId, providerType] });
-      toast.success(t("vendorTypeCircuitUpdated"));
-    },
-    onError: () => {
-      toast.error(t("toggleFailed"));
+      const endpoints = await getProviderEndpointsByVendor({ vendorId });
+      return endpoints;
     },
   });
 
-  if (isLoading || !circuitInfo) return null;
-
-  return (
-    <div className="flex items-center justify-between bg-muted/20 p-3 rounded-md border">
-      <div className="flex items-center gap-2">
-        <Activity
-          className={`h-4 w-4 ${circuitInfo.circuitState === "open" ? "text-destructive" : "text-green-500"}`}
-        />
-        <span className="text-sm font-medium">{t("vendorTypeCircuit")}</span>
-        {circuitInfo.circuitState === "open" && (
-          <Badge variant="destructive" className="ml-2 text-xs">
-            {t("circuitBroken")}
-          </Badge>
-        )}
-      </div>
-
-      {circuitInfo.circuitState === "open" ? (
-        <Button
-          type="button"
-          variant="outline"
-          size="sm"
-          className="h-7 text-xs"
-          onClick={() => resetMutation.mutate()}
-          disabled={resetMutation.isPending}
-        >
-          {resetMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
-          {t("manualCircuitClose")}
-        </Button>
-      ) : null}
-    </div>
-  );
-}
-
-function EndpointsTable({
-  vendorId,
-  providerType,
-}: {
-  vendorId: number;
-  providerType: ProviderType;
-}) {
-  const t = useTranslations("settings.providers");
+  // 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]));
 
-  const { data: endpoints = [], isLoading } = useQuery({
-    queryKey: ["provider-endpoints", vendorId, providerType],
-    queryFn: async () => {
-      const endpoints = await getProviderEndpoints({ vendorId, providerType });
-      return endpoints;
-    },
-  });
+    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>;
@@ -423,6 +338,7 @@ function EndpointsTable({
       <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>
@@ -431,7 +347,7 @@ function EndpointsTable({
         </TableHeader>
         <TableBody>
           {endpoints.map((endpoint) => (
-            <EndpointRow key={endpoint.id} endpoint={endpoint} />
+            <EndpointRow key={endpoint.id} endpoint={endpoint} tTypes={tTypes} />
           ))}
         </TableBody>
       </Table>
@@ -439,13 +355,24 @@ function EndpointsTable({
   );
 }
 
-function EndpointRow({ endpoint }: { endpoint: ProviderEndpoint }) {
+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 });
@@ -509,6 +436,20 @@ function EndpointRow({ endpoint }: { endpoint: ProviderEndpoint }) {
 
   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>
@@ -588,22 +529,26 @@ function EndpointRow({ endpoint }: { endpoint: ProviderEndpoint }) {
   );
 }
 
-function AddEndpointButton({
-  vendorId,
-  providerType,
-}: {
-  vendorId: number;
-  providerType: ProviderType;
-}) {
+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("");
+    if (!open) {
+      setUrl("");
+      setProviderType("claude");
+    }
   }, [open]);
 
   const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
@@ -625,7 +570,7 @@ function AddEndpointButton({
       if (res.ok) {
         toast.success(t("endpointAddSuccess"));
         setOpen(false);
-        queryClient.invalidateQueries({ queryKey: ["provider-endpoints", vendorId, providerType] });
+        queryClient.invalidateQueries({ queryKey: ["provider-endpoints", vendorId] });
       } else {
         toast.error(res.error || t("endpointAddFailed"));
       }
@@ -647,9 +592,41 @@ function AddEndpointButton({
       <DialogContent className="sm:max-w-md">
         <DialogHeader>
           <DialogTitle>{t("addEndpoint")}</DialogTitle>
-          <DialogDescription>{t("addEndpointDesc", { providerType })}</DialogDescription>
+          <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

+ 102 - 11
src/lib/provider-endpoints/probe-scheduler.ts

@@ -6,7 +6,10 @@ import {
   renewLeaderLock,
 } from "@/lib/provider-endpoints/leader-lock";
 import { probeProviderEndpointAndRecordByEndpoint } from "@/lib/provider-endpoints/probe";
-import { findEnabledProviderEndpointsForProbing } from "@/repository";
+import {
+  findEnabledProviderEndpointsForProbing,
+  type ProviderEndpointProbeTarget,
+} from "@/repository";
 
 const LOCK_KEY = "locks:endpoint-probe-scheduler";
 
@@ -15,10 +18,17 @@ function parseIntWithDefault(value: string | undefined, fallback: number): numbe
   return Number.isFinite(n) ? n : fallback;
 }
 
-const INTERVAL_MS = Math.max(
+// Base interval (default 60s)
+const BASE_INTERVAL_MS = Math.max(
   1,
-  parseIntWithDefault(process.env.ENDPOINT_PROBE_INTERVAL_MS, 10_000)
+  parseIntWithDefault(process.env.ENDPOINT_PROBE_INTERVAL_MS, 60_000)
 );
+// Single-vendor interval (10 minutes)
+const SINGLE_VENDOR_INTERVAL_MS = 600_000;
+// Timeout override interval (10 seconds)
+const TIMEOUT_OVERRIDE_INTERVAL_MS = 10_000;
+// Scheduler tick interval - use shortest possible interval to support timeout override
+const TICK_INTERVAL_MS = Math.min(BASE_INTERVAL_MS, TIMEOUT_OVERRIDE_INTERVAL_MS);
 const TIMEOUT_MS = Math.max(1, parseIntWithDefault(process.env.ENDPOINT_PROBE_TIMEOUT_MS, 5_000));
 const CONCURRENCY = Math.max(1, parseIntWithDefault(process.env.ENDPOINT_PROBE_CONCURRENCY, 10));
 const CYCLE_JITTER_MS = Math.max(
@@ -54,6 +64,67 @@ function shuffleInPlace<T>(arr: T[]): void {
   }
 }
 
+/**
+ * Count enabled endpoints per vendor
+ */
+function countEndpointsByVendor(endpoints: ProviderEndpointProbeTarget[]): Map<number, number> {
+  const counts = new Map<number, number>();
+  for (const ep of endpoints) {
+    counts.set(ep.vendorId, (counts.get(ep.vendorId) ?? 0) + 1);
+  }
+  return counts;
+}
+
+/**
+ * Calculate effective interval for an endpoint based on:
+ * 1. Timeout override (10s) - if lastProbeErrorType === "timeout" and lastProbeOk !== true
+ * 2. Single-vendor interval (10min) - if vendor has only 1 enabled endpoint
+ * 3. Base interval (60s) - default
+ *
+ * Priority: timeout override > single-vendor > base
+ */
+function getEffectiveIntervalMs(
+  endpoint: ProviderEndpointProbeTarget,
+  vendorEndpointCounts: Map<number, number>
+): number {
+  // Timeout override takes highest priority
+  const hasTimeoutError =
+    endpoint.lastProbeErrorType === "timeout" && endpoint.lastProbeOk !== true;
+  if (hasTimeoutError) {
+    return TIMEOUT_OVERRIDE_INTERVAL_MS;
+  }
+
+  // Single-vendor interval
+  const vendorCount = vendorEndpointCounts.get(endpoint.vendorId) ?? 0;
+  if (vendorCount === 1) {
+    return SINGLE_VENDOR_INTERVAL_MS;
+  }
+
+  // Default base interval
+  return BASE_INTERVAL_MS;
+}
+
+/**
+ * Filter endpoints that are due for probing based on their effective interval
+ */
+function filterDueEndpoints(
+  endpoints: ProviderEndpointProbeTarget[],
+  vendorEndpointCounts: Map<number, number>,
+  now: Date
+): ProviderEndpointProbeTarget[] {
+  const nowMs = now.getTime();
+  return endpoints.filter((ep) => {
+    // Never probed - always due
+    if (ep.lastProbedAt === null) {
+      return true;
+    }
+
+    const effectiveInterval = getEffectiveIntervalMs(ep, vendorEndpointCounts);
+    const dueAt = ep.lastProbedAt.getTime() + effectiveInterval;
+    return nowMs >= dueAt;
+  });
+}
+
 async function ensureLeaderLock(): Promise<boolean> {
   const current = schedulerState.__CCH_ENDPOINT_PROBE_SCHEDULER_LOCK__;
   if (current) {
@@ -155,7 +226,17 @@ async function runProbeCycle(): Promise<void> {
       return;
     }
 
-    const endpoints = await findEnabledProviderEndpointsForProbing();
+    const allEndpoints = await findEnabledProviderEndpointsForProbing();
+    if (allEndpoints.length === 0) {
+      return;
+    }
+
+    // Calculate vendor endpoint counts for interval decisions
+    const vendorEndpointCounts = countEndpointsByVendor(allEndpoints);
+
+    // Filter to only endpoints that are due for probing
+    const now = new Date();
+    const endpoints = filterDueEndpoints(allEndpoints, vendorEndpointCounts, now);
     if (endpoints.length === 0) {
       return;
     }
@@ -163,10 +244,11 @@ async function runProbeCycle(): Promise<void> {
     const concurrency = Math.max(1, Math.min(CONCURRENCY, endpoints.length));
     const minBatches = Math.ceil(endpoints.length / concurrency);
     const expectedFloorMs = minBatches * Math.max(0, TIMEOUT_MS);
-    if (expectedFloorMs > INTERVAL_MS) {
+    if (expectedFloorMs > TICK_INTERVAL_MS) {
       logger.warn("[EndpointProbeScheduler] Probe capacity may be insufficient", {
-        endpointsCount: endpoints.length,
-        intervalMs: INTERVAL_MS,
+        dueEndpointsCount: endpoints.length,
+        totalEndpointsCount: allEndpoints.length,
+        tickIntervalMs: TICK_INTERVAL_MS,
         timeoutMs: TIMEOUT_MS,
         concurrency,
         expectedFloorMs,
@@ -222,10 +304,13 @@ export function startEndpointProbeScheduler(): void {
 
   schedulerState.__CCH_ENDPOINT_PROBE_SCHEDULER_INTERVAL_ID__ = setInterval(() => {
     void runProbeCycle();
-  }, INTERVAL_MS);
+  }, TICK_INTERVAL_MS);
 
   logger.info("[EndpointProbeScheduler] Started", {
-    intervalMs: INTERVAL_MS,
+    baseIntervalMs: BASE_INTERVAL_MS,
+    singleVendorIntervalMs: SINGLE_VENDOR_INTERVAL_MS,
+    timeoutOverrideIntervalMs: TIMEOUT_OVERRIDE_INTERVAL_MS,
+    tickIntervalMs: TICK_INTERVAL_MS,
     timeoutMs: TIMEOUT_MS,
     concurrency: CONCURRENCY,
     jitterMs: CYCLE_JITTER_MS,
@@ -254,7 +339,10 @@ export function stopEndpointProbeScheduler(): void {
 export function getEndpointProbeSchedulerStatus(): {
   started: boolean;
   running: boolean;
-  intervalMs: number;
+  baseIntervalMs: number;
+  singleVendorIntervalMs: number;
+  timeoutOverrideIntervalMs: number;
+  tickIntervalMs: number;
   timeoutMs: number;
   concurrency: number;
   jitterMs: number;
@@ -263,7 +351,10 @@ export function getEndpointProbeSchedulerStatus(): {
   return {
     started: schedulerState.__CCH_ENDPOINT_PROBE_SCHEDULER_STARTED__ === true,
     running: schedulerState.__CCH_ENDPOINT_PROBE_SCHEDULER_RUNNING__ === true,
-    intervalMs: INTERVAL_MS,
+    baseIntervalMs: BASE_INTERVAL_MS,
+    singleVendorIntervalMs: SINGLE_VENDOR_INTERVAL_MS,
+    timeoutOverrideIntervalMs: TIMEOUT_OVERRIDE_INTERVAL_MS,
+    tickIntervalMs: TICK_INTERVAL_MS,
     timeoutMs: TIMEOUT_MS,
     concurrency: CONCURRENCY,
     jitterMs: CYCLE_JITTER_MS,

+ 2 - 1
src/repository/index.ts

@@ -44,7 +44,7 @@ export {
   getDistinctProviderGroups,
   updateProvider,
 } from "./provider";
-
+export type { ProviderEndpointProbeTarget } from "./provider-endpoints";
 export {
   createProviderEndpoint,
   deleteProviderEndpointProbeLogsBeforeDateBatch,
@@ -52,6 +52,7 @@ export {
   findEnabledProviderEndpointsForProbing,
   findProviderEndpointById,
   findProviderEndpointProbeLogs,
+  findProviderEndpointsByVendor,
   findProviderEndpointsByVendorAndType,
   findProviderVendorById,
   findProviderVendors,

+ 32 - 1
src/repository/provider-endpoints.ts

@@ -106,7 +106,7 @@ function toProviderEndpointProbeLog(row: any): ProviderEndpointProbeLog {
 
 export type ProviderEndpointProbeTarget = Pick<
   ProviderEndpoint,
-  "id" | "url" | "lastProbedAt" | "lastProbeOk"
+  "id" | "url" | "vendorId" | "lastProbedAt" | "lastProbeOk" | "lastProbeErrorType"
 >;
 
 export async function findEnabledProviderEndpointsForProbing(): Promise<
@@ -116,8 +116,10 @@ export async function findEnabledProviderEndpointsForProbing(): Promise<
     .select({
       id: providerEndpoints.id,
       url: providerEndpoints.url,
+      vendorId: providerEndpoints.vendorId,
       lastProbedAt: providerEndpoints.lastProbedAt,
       lastProbeOk: providerEndpoints.lastProbeOk,
+      lastProbeErrorType: providerEndpoints.lastProbeErrorType,
     })
     .from(providerEndpoints)
     .where(and(eq(providerEndpoints.isEnabled, true), isNull(providerEndpoints.deletedAt)))
@@ -126,8 +128,10 @@ export async function findEnabledProviderEndpointsForProbing(): Promise<
   return rows.map((row) => ({
     id: row.id,
     url: row.url,
+    vendorId: row.vendorId,
     lastProbedAt: toNullableDate(row.lastProbedAt),
     lastProbeOk: row.lastProbeOk ?? null,
+    lastProbeErrorType: row.lastProbeErrorType ?? null,
   }));
 }
 
@@ -563,6 +567,33 @@ export async function findProviderEndpointsByVendorAndType(
   return rows.map(toProviderEndpoint);
 }
 
+export async function findProviderEndpointsByVendor(vendorId: number): Promise<ProviderEndpoint[]> {
+  const rows = await db
+    .select({
+      id: providerEndpoints.id,
+      vendorId: providerEndpoints.vendorId,
+      providerType: providerEndpoints.providerType,
+      url: providerEndpoints.url,
+      label: providerEndpoints.label,
+      sortOrder: providerEndpoints.sortOrder,
+      isEnabled: providerEndpoints.isEnabled,
+      lastProbedAt: providerEndpoints.lastProbedAt,
+      lastProbeOk: providerEndpoints.lastProbeOk,
+      lastProbeStatusCode: providerEndpoints.lastProbeStatusCode,
+      lastProbeLatencyMs: providerEndpoints.lastProbeLatencyMs,
+      lastProbeErrorType: providerEndpoints.lastProbeErrorType,
+      lastProbeErrorMessage: providerEndpoints.lastProbeErrorMessage,
+      createdAt: providerEndpoints.createdAt,
+      updatedAt: providerEndpoints.updatedAt,
+      deletedAt: providerEndpoints.deletedAt,
+    })
+    .from(providerEndpoints)
+    .where(and(eq(providerEndpoints.vendorId, vendorId), isNull(providerEndpoints.deletedAt)))
+    .orderBy(asc(providerEndpoints.sortOrder), asc(providerEndpoints.id));
+
+  return rows.map(toProviderEndpoint);
+}
+
 export async function createProviderEndpoint(payload: {
   vendorId: number;
   providerType: ProviderType;

+ 278 - 3
tests/unit/lib/provider-endpoints/probe-scheduler.test.ts

@@ -1,8 +1,10 @@
 type ProbeTarget = {
   id: number;
   url: string;
+  vendorId: number;
   lastProbedAt: Date | null;
   lastProbeOk: boolean | null;
+  lastProbeErrorType: string | null;
 };
 
 type ProbeResult = {
@@ -14,12 +16,14 @@ type ProbeResult = {
   errorMessage: string | null;
 };
 
-function makeEndpoint(id: number): ProbeTarget {
+function makeEndpoint(id: number, overrides: Partial<ProbeTarget> = {}): ProbeTarget {
   return {
     id,
     url: `https://example.com/${id}`,
-    lastProbedAt: null,
-    lastProbeOk: null,
+    vendorId: overrides.vendorId ?? 1,
+    lastProbedAt: overrides.lastProbedAt ?? null,
+    lastProbeOk: overrides.lastProbeOk ?? null,
+    lastProbeErrorType: overrides.lastProbeErrorType ?? null,
   };
 }
 
@@ -168,4 +172,275 @@ describe("provider-endpoints: probe scheduler", () => {
 
     stopEndpointProbeScheduler();
   });
+
+  describe("dynamic interval calculation", () => {
+    test("default interval is 60s - endpoints probed 60s ago should be probed", async () => {
+      vi.useFakeTimers();
+      vi.setSystemTime(new Date("2024-01-01T12:01:00Z"));
+
+      vi.resetModules();
+      vi.stubEnv("ENDPOINT_PROBE_INTERVAL_MS", "60000");
+      vi.stubEnv("ENDPOINT_PROBE_CYCLE_JITTER_MS", "0");
+
+      acquireLeaderLockMock = vi.fn(async () => ({
+        key: "locks:endpoint-probe-scheduler",
+        lockId: "test",
+        lockType: "memory" as const,
+      }));
+      renewLeaderLockMock = vi.fn(async () => true);
+      releaseLeaderLockMock = vi.fn(async () => {});
+
+      // Two endpoints from SAME vendor (multi-endpoint vendor uses base 60s interval)
+      // Both probed 61s ago - should be due
+      const endpoint = makeEndpoint(1, {
+        vendorId: 1,
+        lastProbedAt: new Date("2024-01-01T11:59:59Z"), // 61s ago
+      });
+      const endpoint2 = makeEndpoint(2, {
+        vendorId: 1, // Same vendor
+        lastProbedAt: new Date("2024-01-01T11:59:59Z"), // 61s ago
+      });
+
+      findEnabledEndpointsMock = vi.fn(async () => [endpoint, endpoint2]);
+      probeByEndpointMock = vi.fn(async () => makeOkResult());
+
+      const { startEndpointProbeScheduler, stopEndpointProbeScheduler } = await import(
+        "@/lib/provider-endpoints/probe-scheduler"
+      );
+
+      startEndpointProbeScheduler();
+      await flushMicrotasks();
+
+      // Both endpoints should be probed since they're due (61s > 60s interval)
+      expect(probeByEndpointMock).toHaveBeenCalledTimes(2);
+
+      stopEndpointProbeScheduler();
+    });
+
+    test("single-endpoint vendor uses 10min interval", async () => {
+      vi.useFakeTimers();
+      vi.setSystemTime(new Date("2024-01-01T12:05:00Z"));
+
+      vi.resetModules();
+      vi.stubEnv("ENDPOINT_PROBE_INTERVAL_MS", "60000");
+      vi.stubEnv("ENDPOINT_PROBE_CYCLE_JITTER_MS", "0");
+
+      acquireLeaderLockMock = vi.fn(async () => ({
+        key: "locks:endpoint-probe-scheduler",
+        lockId: "test",
+        lockType: "memory" as const,
+      }));
+      renewLeaderLockMock = vi.fn(async () => true);
+      releaseLeaderLockMock = vi.fn(async () => {});
+
+      // Vendor 1: single endpoint probed 5min ago (should NOT be due - 10min interval)
+      // Vendor 2: two endpoints, one probed 30s ago (should NOT be due - 60s interval but recently probed)
+      const singleVendorEndpoint = makeEndpoint(1, {
+        vendorId: 1,
+        lastProbedAt: new Date("2024-01-01T12:00:00Z"), // 5min ago
+      });
+      const multiVendorEndpoint1 = makeEndpoint(2, {
+        vendorId: 2,
+        lastProbedAt: new Date("2024-01-01T12:04:30Z"), // 30s ago - NOT due
+      });
+      const multiVendorEndpoint2 = makeEndpoint(3, {
+        vendorId: 2,
+        lastProbedAt: new Date("2024-01-01T12:00:00Z"), // 5min ago - should be due
+      });
+
+      findEnabledEndpointsMock = vi.fn(async () => [
+        singleVendorEndpoint,
+        multiVendorEndpoint1,
+        multiVendorEndpoint2,
+      ]);
+      probeByEndpointMock = vi.fn(async () => makeOkResult());
+
+      const { startEndpointProbeScheduler, stopEndpointProbeScheduler } = await import(
+        "@/lib/provider-endpoints/probe-scheduler"
+      );
+
+      startEndpointProbeScheduler();
+      await flushMicrotasks();
+
+      // Only multiVendorEndpoint2 should be probed (5min > 60s, multi-endpoint vendor)
+      // singleVendorEndpoint not due (5min < 10min)
+      // multiVendorEndpoint1 not due (30s < 60s)
+      expect(probeByEndpointMock).toHaveBeenCalledTimes(1);
+      expect(probeByEndpointMock.mock.calls[0][0].endpoint.id).toBe(3);
+
+      stopEndpointProbeScheduler();
+    });
+
+    test("timeout endpoint uses 10s override interval", async () => {
+      vi.useFakeTimers();
+      vi.setSystemTime(new Date("2024-01-01T12:00:15Z"));
+
+      vi.resetModules();
+      vi.stubEnv("ENDPOINT_PROBE_INTERVAL_MS", "60000");
+      vi.stubEnv("ENDPOINT_PROBE_CYCLE_JITTER_MS", "0");
+
+      acquireLeaderLockMock = vi.fn(async () => ({
+        key: "locks:endpoint-probe-scheduler",
+        lockId: "test",
+        lockType: "memory" as const,
+      }));
+      renewLeaderLockMock = vi.fn(async () => true);
+      releaseLeaderLockMock = vi.fn(async () => {});
+
+      // Endpoint with timeout error 15s ago - should be due (10s override)
+      const timeoutEndpoint = makeEndpoint(1, {
+        vendorId: 1,
+        lastProbedAt: new Date("2024-01-01T12:00:00Z"),
+        lastProbeOk: false,
+        lastProbeErrorType: "timeout",
+      });
+      // Normal endpoint from same vendor probed 15s ago - not due (60s interval)
+      const normalEndpoint = makeEndpoint(2, {
+        vendorId: 1,
+        lastProbedAt: new Date("2024-01-01T12:00:00Z"),
+        lastProbeOk: true,
+      });
+
+      findEnabledEndpointsMock = vi.fn(async () => [timeoutEndpoint, normalEndpoint]);
+      probeByEndpointMock = vi.fn(async () => makeOkResult());
+
+      const { startEndpointProbeScheduler, stopEndpointProbeScheduler } = await import(
+        "@/lib/provider-endpoints/probe-scheduler"
+      );
+
+      startEndpointProbeScheduler();
+      await flushMicrotasks();
+
+      // Only timeout endpoint should be probed
+      expect(probeByEndpointMock).toHaveBeenCalledTimes(1);
+      expect(probeByEndpointMock.mock.calls[0][0].endpoint.id).toBe(1);
+
+      stopEndpointProbeScheduler();
+    });
+
+    test("timeout override takes priority over 10min single-vendor interval", async () => {
+      vi.useFakeTimers();
+      vi.setSystemTime(new Date("2024-01-01T12:00:15Z"));
+
+      vi.resetModules();
+      vi.stubEnv("ENDPOINT_PROBE_INTERVAL_MS", "60000");
+      vi.stubEnv("ENDPOINT_PROBE_CYCLE_JITTER_MS", "0");
+
+      acquireLeaderLockMock = vi.fn(async () => ({
+        key: "locks:endpoint-probe-scheduler",
+        lockId: "test",
+        lockType: "memory" as const,
+      }));
+      renewLeaderLockMock = vi.fn(async () => true);
+      releaseLeaderLockMock = vi.fn(async () => {});
+
+      // Single-endpoint vendor with timeout error 15s ago
+      // Without timeout, would use 10min interval and not be due
+      // With timeout, uses 10s override and IS due
+      const timeoutSingleVendor = makeEndpoint(1, {
+        vendorId: 1, // only endpoint for this vendor
+        lastProbedAt: new Date("2024-01-01T12:00:00Z"),
+        lastProbeOk: false,
+        lastProbeErrorType: "timeout",
+      });
+
+      findEnabledEndpointsMock = vi.fn(async () => [timeoutSingleVendor]);
+      probeByEndpointMock = vi.fn(async () => makeOkResult());
+
+      const { startEndpointProbeScheduler, stopEndpointProbeScheduler } = await import(
+        "@/lib/provider-endpoints/probe-scheduler"
+      );
+
+      startEndpointProbeScheduler();
+      await flushMicrotasks();
+
+      // Timeout override should take priority
+      expect(probeByEndpointMock).toHaveBeenCalledTimes(1);
+
+      stopEndpointProbeScheduler();
+    });
+
+    test("recovered endpoint (lastProbeOk=true) reverts to normal interval", async () => {
+      vi.useFakeTimers();
+      vi.setSystemTime(new Date("2024-01-01T12:00:15Z"));
+
+      vi.resetModules();
+      vi.stubEnv("ENDPOINT_PROBE_INTERVAL_MS", "60000");
+      vi.stubEnv("ENDPOINT_PROBE_CYCLE_JITTER_MS", "0");
+
+      acquireLeaderLockMock = vi.fn(async () => ({
+        key: "locks:endpoint-probe-scheduler",
+        lockId: "test",
+        lockType: "memory" as const,
+      }));
+      renewLeaderLockMock = vi.fn(async () => true);
+      releaseLeaderLockMock = vi.fn(async () => {});
+
+      // Had timeout before but now recovered (lastProbeOk=true) - uses normal interval
+      const recoveredEndpoint = makeEndpoint(1, {
+        vendorId: 1,
+        lastProbedAt: new Date("2024-01-01T12:00:00Z"), // 15s ago
+        lastProbeOk: true, // recovered!
+        lastProbeErrorType: "timeout", // had timeout before
+      });
+      // Multi-vendor so 60s base interval applies
+      const otherEndpoint = makeEndpoint(2, {
+        vendorId: 1,
+        lastProbedAt: new Date("2024-01-01T12:00:00Z"),
+        lastProbeOk: true,
+      });
+
+      findEnabledEndpointsMock = vi.fn(async () => [recoveredEndpoint, otherEndpoint]);
+      probeByEndpointMock = vi.fn(async () => makeOkResult());
+
+      const { startEndpointProbeScheduler, stopEndpointProbeScheduler } = await import(
+        "@/lib/provider-endpoints/probe-scheduler"
+      );
+
+      startEndpointProbeScheduler();
+      await flushMicrotasks();
+
+      // Neither should be probed - 15s < 60s and lastProbeOk=true means no timeout override
+      expect(probeByEndpointMock).toHaveBeenCalledTimes(0);
+
+      stopEndpointProbeScheduler();
+    });
+
+    test("null lastProbedAt is always due for probing", async () => {
+      vi.useFakeTimers();
+      vi.setSystemTime(new Date("2024-01-01T12:00:00Z"));
+
+      vi.resetModules();
+      vi.stubEnv("ENDPOINT_PROBE_INTERVAL_MS", "60000");
+      vi.stubEnv("ENDPOINT_PROBE_CYCLE_JITTER_MS", "0");
+
+      acquireLeaderLockMock = vi.fn(async () => ({
+        key: "locks:endpoint-probe-scheduler",
+        lockId: "test",
+        lockType: "memory" as const,
+      }));
+      renewLeaderLockMock = vi.fn(async () => true);
+      releaseLeaderLockMock = vi.fn(async () => {});
+
+      // Never probed endpoint should always be due
+      const neverProbed = makeEndpoint(1, {
+        vendorId: 1,
+        lastProbedAt: null,
+      });
+
+      findEnabledEndpointsMock = vi.fn(async () => [neverProbed]);
+      probeByEndpointMock = vi.fn(async () => makeOkResult());
+
+      const { startEndpointProbeScheduler, stopEndpointProbeScheduler } = await import(
+        "@/lib/provider-endpoints/probe-scheduler"
+      );
+
+      startEndpointProbeScheduler();
+      await flushMicrotasks();
+
+      expect(probeByEndpointMock).toHaveBeenCalledTimes(1);
+
+      stopEndpointProbeScheduler();
+    });
+  });
 });

+ 18 - 56
tests/unit/settings/providers/provider-vendor-view-circuit-ui.test.tsx

@@ -29,7 +29,7 @@ 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 () => [
+  getProviderEndpointsByVendor: vi.fn(async () => [
     {
       id: 1,
       vendorId: 1,
@@ -56,21 +56,9 @@ const providerEndpointsActionMocks = vi.hoisted(() => ({
       updatedAt: "2026-01-01",
     },
   ]),
-  getVendorTypeCircuitInfo: vi.fn(async () => ({
-    ok: true,
-    data: {
-      vendorId: 1,
-      providerType: "claude",
-      circuitState: "open",
-      circuitOpenUntil: null,
-      lastFailureTime: null,
-      manualOpen: false,
-    },
-  })),
   probeProviderEndpoint: vi.fn(async () => ({ ok: true, data: { result: { ok: true } } })),
   removeProviderEndpoint: vi.fn(async () => ({ ok: true })),
   removeProviderVendor: vi.fn(async () => ({ ok: true })),
-  resetVendorTypeCircuit: vi.fn(async () => ({ ok: true })),
 }));
 vi.mock("@/actions/provider-endpoints", () => providerEndpointsActionMocks);
 
@@ -196,7 +184,7 @@ async function flushTicks(times = 3) {
   }
 }
 
-describe("ProviderVendorView: VendorTypeCircuitControl 仅在熔断时展示关闭按钮", () => {
+describe("ProviderVendorView: Endpoints table renders with type icons", () => {
   beforeEach(() => {
     queryClient = new QueryClient({
       defaultOptions: {
@@ -205,22 +193,12 @@ describe("ProviderVendorView: VendorTypeCircuitControl 仅在熔断时展示关
       },
     });
     vi.clearAllMocks();
-    document.body.innerHTML = "";
+    while (document.body.firstChild) {
+      document.body.removeChild(document.body.firstChild);
+    }
   });
 
-  test("circuitState=open 时显示 Close Circuit,且不显示 Manually Open Circuit", async () => {
-    providerEndpointsActionMocks.getVendorTypeCircuitInfo.mockResolvedValueOnce({
-      ok: true,
-      data: {
-        vendorId: 1,
-        providerType: "claude",
-        circuitState: "open",
-        circuitOpenUntil: null,
-        lastFailureTime: null,
-        manualOpen: false,
-      },
-    });
-
+  test("renders endpoint URL and latency header", async () => {
     const { unmount } = renderWithProviders(
       <ProviderVendorView
         providers={[makeProviderDisplay()]}
@@ -235,31 +213,17 @@ describe("ProviderVendorView: VendorTypeCircuitControl 仅在熔断时展示关
 
     await flushTicks(6);
 
-    expect(document.body.textContent || "").toContain("Close Circuit");
-    expect(document.body.textContent || "").not.toContain("Manually Open Circuit");
-    // Check that provider type tabs are rendered
-    expect(document.body.textContent || "").toContain("Gemini");
-    expect(document.body.textContent || "").toContain("Claude");
+    // Check that endpoint URL is rendered
+    expect(document.body.textContent || "").toContain("https://api.example.com/v1");
 
+    // Check that latency header is present
     const latencyHeader = document.querySelector('th[class*="w-[220px]"]');
     expect(latencyHeader?.textContent || "").toContain("Latency");
 
     unmount();
   });
 
-  test("circuitState=closed 时不显示 Close Circuit,也不显示 Manually Open Circuit", async () => {
-    providerEndpointsActionMocks.getVendorTypeCircuitInfo.mockResolvedValueOnce({
-      ok: true,
-      data: {
-        vendorId: 1,
-        providerType: "claude",
-        circuitState: "closed",
-        circuitOpenUntil: null,
-        lastFailureTime: null,
-        manualOpen: false,
-      },
-    });
-
+  test("renders type column header", async () => {
     const { unmount } = renderWithProviders(
       <ProviderVendorView
         providers={[makeProviderDisplay()]}
@@ -274,14 +238,8 @@ describe("ProviderVendorView: VendorTypeCircuitControl 仅在熔断时展示关
 
     await flushTicks(6);
 
-    expect(document.body.textContent || "").not.toContain("Close Circuit");
-    expect(document.body.textContent || "").not.toContain("Manually Open Circuit");
-    // Check that provider type tabs are rendered
-    expect(document.body.textContent || "").toContain("Gemini");
-    expect(document.body.textContent || "").toContain("Claude");
-
-    const latencyHeader = document.querySelector('th[class*="w-[220px]"]');
-    expect(latencyHeader?.textContent || "").toContain("Latency");
+    // Check that type column header is present
+    expect(document.body.textContent || "").toContain("Type");
 
     unmount();
   });
@@ -296,7 +254,9 @@ describe("ProviderVendorView vendor list", () => {
       },
     });
     vi.clearAllMocks();
-    document.body.innerHTML = "";
+    while (document.body.firstChild) {
+      document.body.removeChild(document.body.firstChild);
+    }
   });
 
   test("vendors with zero providers are hidden", async () => {
@@ -341,7 +301,9 @@ describe("ProviderVendorView endpoints table", () => {
       },
     });
     vi.clearAllMocks();
-    document.body.innerHTML = "";
+    while (document.body.firstChild) {
+      document.body.removeChild(document.body.firstChild);
+    }
   });
 
   test("renders endpoints and toggles enabled status", async () => {