diff --git a/.env.example b/.env.example index 9cae3068e..14193bd05 100644 --- a/.env.example +++ b/.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 diff --git a/messages/en/settings/providers/strings.json b/messages/en/settings/providers/strings.json index 52f1dc929..c64eddf12 100644 --- a/messages/en/settings/providers/strings.json +++ b/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)", diff --git a/messages/ja/settings/providers/strings.json b/messages/ja/settings/providers/strings.json index 48b3615e1..b291a5517 100644 --- a/messages/ja/settings/providers/strings.json +++ b/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": "ラベル (任意)", diff --git a/messages/ru/settings/providers/strings.json b/messages/ru/settings/providers/strings.json index 866fbb074..78c41d67f 100644 --- a/messages/ru/settings/providers/strings.json +++ b/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": "Метка (необязательно)", diff --git a/messages/zh-CN/settings/providers/strings.json b/messages/zh-CN/settings/providers/strings.json index 1634f5af6..3cebf3338 100644 --- a/messages/zh-CN/settings/providers/strings.json +++ b/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": "标签(可选)", diff --git a/messages/zh-TW/settings/providers/strings.json b/messages/zh-TW/settings/providers/strings.json index cf08088e1..616f812e5 100644 --- a/messages/zh-TW/settings/providers/strings.json +++ b/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": "標籤(選填)", diff --git a/src/actions/provider-endpoints.ts b/src/actions/provider-endpoints.ts index be3e8bcda..47c6e7e70 100644 --- a/src/actions/provider-endpoints.ts +++ b/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 { + 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> { diff --git a/src/app/[locale]/settings/providers/_components/provider-vendor-view.tsx b/src/app/[locale]/settings/providers/_components/provider-vendor-view.tsx index 2ffe022df..b134bbc08 100644 --- a/src/app/[locale]/settings/providers/_components/provider-vendor-view.tsx +++ b/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("claude"); - - const providerTypes: ProviderType[] = ["claude", "codex", "gemini", "openai-compatible"]; return (
{t("endpoints")} +
-
-
-
- {providerTypes.map((type) => { - const typeConfig = getProviderTypeConfig(type); - const TypeIcon = typeConfig.icon; - const typeKey = getProviderTypeTranslationKey(type); - const label = tTypes(`${typeKey}.label`); - return ( - - ); - })} -
- - -
- - - - -
+
); } -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 ( -
-
- - {t("vendorTypeCircuit")} - {circuitInfo.circuitState === "open" && ( - - {t("circuitBroken")} - - )} -
- - {circuitInfo.circuitState === "open" ? ( - - ) : null} -
- ); -} - -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
{t("keyLoading")}
; @@ -423,6 +338,7 @@ function EndpointsTable({ + {t("columnType")} {t("columnUrl")} {t("status")} {t("latency")} @@ -431,7 +347,7 @@ function EndpointsTable({ {endpoints.map((endpoint) => ( - + ))}
@@ -439,13 +355,24 @@ function EndpointsTable({ ); } -function EndpointRow({ endpoint }: { endpoint: ProviderEndpoint }) { +function EndpointRow({ + endpoint, + tTypes, +}: { + endpoint: ProviderEndpoint; + tTypes: ReturnType; +}) { 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 ( + + + + + + + + + {typeLabel} + + + {endpoint.url} @@ -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("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) => { @@ -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({ {t("addEndpoint")} - {t("addEndpointDesc", { providerType })} + {t("addEndpointDescGeneric")}
+
+ + +
+
(arr: T[]): void { } } +/** + * Count enabled endpoints per vendor + */ +function countEndpointsByVendor(endpoints: ProviderEndpointProbeTarget[]): Map { + const counts = new Map(); + 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 { + // 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, + 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 { const current = schedulerState.__CCH_ENDPOINT_PROBE_SCHEDULER_LOCK__; if (current) { @@ -155,7 +226,17 @@ async function runProbeCycle(): Promise { 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 { 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, diff --git a/src/repository/index.ts b/src/repository/index.ts index b0946ba8d..17258f18b 100644 --- a/src/repository/index.ts +++ b/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, diff --git a/src/repository/provider-endpoints.ts b/src/repository/provider-endpoints.ts index 9b786baad..fe6144003 100644 --- a/src/repository/provider-endpoints.ts +++ b/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 { + 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; diff --git a/tests/unit/lib/provider-endpoints/probe-scheduler.test.ts b/tests/unit/lib/provider-endpoints/probe-scheduler.test.ts index da793d846..d1ba2de29 100644 --- a/tests/unit/lib/provider-endpoints/probe-scheduler.test.ts +++ b/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 { 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(); + }); + }); }); diff --git a/tests/unit/settings/providers/provider-vendor-view-circuit-ui.test.tsx b/tests/unit/settings/providers/provider-vendor-view-circuit-ui.test.tsx index de7b2b396..289db7f62 100644 --- a/tests/unit/settings/providers/provider-vendor-view-circuit-ui.test.tsx +++ b/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( { - 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( { }, }); 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 () => {