Browse Source

fix: isolate provider endpoint pools and sticky sessions (#919)

* fix: isolate provider endpoint pools and sticky sessions

* fix: address PR #919 review comments

- Extract duplicate terminateStickySessionsForProviders into
  SessionManager static method (DRY)
- Fix sticky session invalidation to check actual value changes
  via preimageFields instead of raw payload keys (logic bug)
- Add missing allowed_clients/blocked_clients to preimage mapping
- Handle Redis pipeline errors in terminateProviderSessionsBatch
  instead of silently dropping them
- Remove stale hasEnabledProviderReferenceForVendorTypeUrlMock
  from tests

* chore: format code (fix-provider-endpoint-isolation-and-sticky-session-672ed8c)

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Ding 3 weeks ago
parent
commit
d85cc5d91f

+ 1 - 0
messages/en/errors.json

@@ -49,6 +49,7 @@
   "RESOURCE_BUSY": "Resource is currently in use",
   "INVALID_STATE": "Operation not allowed in current state",
   "CONFLICT": "Operation conflict",
+  "ENDPOINT_REFERENCED_BY_ENABLED_PROVIDERS": "This endpoint is still referenced by {count} enabled providers: {providers}",
 
   "RATE_LIMIT_RPM_EXCEEDED": "Rate limit exceeded: {current} requests per minute (limit: {limit}). Resets at {resetTime}",
   "RATE_LIMIT_5H_EXCEEDED": "5-hour cost limit exceeded: ${current} USD (limit: ${limit} USD). Resets at {resetTime}",

+ 1 - 0
messages/ja/errors.json

@@ -58,6 +58,7 @@
   "RESOURCE_BUSY": "リソースは現在使用中です",
   "INVALID_STATE": "現在の状態では操作が許可されていません",
   "CONFLICT": "操作の競合",
+  "ENDPOINT_REFERENCED_BY_ENABLED_PROVIDERS": "このエンドポイントは {count} 件の有効なプロバイダーから参照されています: {providers}",
 
   "USER_NOT_FOUND": "ユーザーが見つかりません",
   "USER_CANNOT_MODIFY_SENSITIVE_FIELDS": "一般ユーザーはクォータ制限とプロバイダーグループを変更できません",

+ 1 - 0
messages/ru/errors.json

@@ -58,6 +58,7 @@
   "RESOURCE_BUSY": "Ресурс в настоящее время используется",
   "INVALID_STATE": "Операция не разрешена в текущем состоянии",
   "CONFLICT": "Конфликт операции",
+  "ENDPOINT_REFERENCED_BY_ENABLED_PROVIDERS": "Этот endpoint все еще используется {count} активными провайдерами: {providers}",
 
   "USER_NOT_FOUND": "Пользователь не найден",
   "USER_CANNOT_MODIFY_SENSITIVE_FIELDS": "Обычные пользователи не могут изменять лимиты квоты и группы провайдеров",

+ 1 - 0
messages/zh-CN/errors.json

@@ -58,6 +58,7 @@
   "RESOURCE_BUSY": "资源正在使用中",
   "INVALID_STATE": "当前状态不允许此操作",
   "CONFLICT": "操作冲突",
+  "ENDPOINT_REFERENCED_BY_ENABLED_PROVIDERS": "该端点仍被 {count} 个启用中的供应商引用:{providers}",
 
   "USER_NOT_FOUND": "用户不存在",
   "USER_CANNOT_MODIFY_SENSITIVE_FIELDS": "普通用户不能修改账户限额和供应商分组",

+ 1 - 0
messages/zh-TW/errors.json

@@ -58,6 +58,7 @@
   "RESOURCE_BUSY": "資源正在使用中",
   "INVALID_STATE": "當前狀態不允許此操作",
   "CONFLICT": "操作衝突",
+  "ENDPOINT_REFERENCED_BY_ENABLED_PROVIDERS": "此端點仍被 {count} 個啟用中的供應商引用:{providers}",
 
   "USER_NOT_FOUND": "使用者不存在",
   "USER_CANNOT_MODIFY_SENSITIVE_FIELDS": "普通使用者不能修改帳戶額度和供應商分組",

+ 57 - 7
src/actions/provider-endpoints.ts

@@ -9,8 +9,12 @@ import {
   resetEndpointCircuit as resetEndpointCircuitState,
 } from "@/lib/endpoint-circuit-breaker";
 import { logger } from "@/lib/logger";
-import { PROVIDER_ENDPOINT_CONFLICT_CODE } from "@/lib/provider-endpoint-error-codes";
+import {
+  ENDPOINT_REFERENCED_BY_ENABLED_PROVIDERS_CODE,
+  PROVIDER_ENDPOINT_CONFLICT_CODE,
+} from "@/lib/provider-endpoint-error-codes";
 import { probeProviderEndpointAndRecordByEndpoint } from "@/lib/provider-endpoints/probe";
+import { SessionManager } from "@/lib/session-manager";
 import { ERROR_CODES } from "@/lib/utils/error-messages";
 import { extractZodErrorCode, formatZodError } from "@/lib/utils/zod-i18n";
 import {
@@ -35,8 +39,9 @@ import {
 } from "@/repository";
 import {
   findDashboardProviderEndpointsByVendorAndType,
+  findEnabledProviderIdsByVendorAndType,
+  findEnabledProviderReferencesForVendorTypeUrl,
   findEnabledProviderVendorTypePairs,
-  hasEnabledProviderReferenceForVendorTypeUrl,
 } from "@/repository/provider-endpoints";
 import {
   findProviderEndpointProbeLogsBatch,
@@ -219,6 +224,25 @@ function isForeignKeyViolationError(error: unknown): boolean {
   );
 }
 
+function formatProviderReferenceSummary(
+  references: Array<{ id: number; name: string }>,
+  maxDisplayCount: number = 3
+): string {
+  const uniqueNames = Array.from(
+    new Set(references.map((reference) => reference.name.trim()).filter(Boolean))
+  );
+  if (uniqueNames.length === 0) {
+    return "";
+  }
+
+  if (uniqueNames.length <= maxDisplayCount) {
+    return uniqueNames.join(", ");
+  }
+
+  const displayed = uniqueNames.slice(0, maxDisplayCount).join(", ");
+  return `${displayed} +${uniqueNames.length - maxDisplayCount}`;
+}
+
 export async function getProviderVendors(): Promise<ProviderVendor[]> {
   try {
     const session = await getAdminSession();
@@ -496,6 +520,21 @@ export async function editProviderEndpoint(
       }
     }
 
+    const shouldTerminateStickySessions =
+      parsed.data.url !== undefined ||
+      parsed.data.sortOrder !== undefined ||
+      parsed.data.isEnabled !== undefined;
+    if (shouldTerminateStickySessions) {
+      const affectedProviderIds = await findEnabledProviderIdsByVendorAndType(
+        endpoint.vendorId,
+        endpoint.providerType
+      );
+      await SessionManager.terminateStickySessionsForProviders(
+        affectedProviderIds,
+        "editProviderEndpoint"
+      );
+    }
+
     try {
       await publishProviderCacheInvalidation();
     } catch (error) {
@@ -551,18 +590,20 @@ export async function removeProviderEndpoint(input: unknown): Promise<ActionResu
       };
     }
 
-    // 若该端点仍被启用 provider 引用,则不允许删除:否则会导致运行时 endpoint pool 变空/回填复活,
-    // 产生“删了但还在/仍被探测”的困惑(#781)。
-    const referencedByEnabledProvider = await hasEnabledProviderReferenceForVendorTypeUrl({
+    const references = await findEnabledProviderReferencesForVendorTypeUrl({
       vendorId: endpoint.vendorId,
       providerType: endpoint.providerType,
       url: endpoint.url,
     });
-    if (referencedByEnabledProvider) {
+    if (references.length > 0) {
       return {
         ok: false,
         error: "该端点仍被启用的供应商引用,请先修改或禁用相关供应商的 URL 后再删除",
-        errorCode: ERROR_CODES.CONFLICT,
+        errorCode: ENDPOINT_REFERENCED_BY_ENABLED_PROVIDERS_CODE,
+        errorParams: {
+          count: references.length,
+          providers: formatProviderReferenceSummary(references),
+        },
       };
     }
 
@@ -585,6 +626,15 @@ export async function removeProviderEndpoint(input: unknown): Promise<ActionResu
       });
     }
 
+    const affectedProviderIds = await findEnabledProviderIdsByVendorAndType(
+      endpoint.vendorId,
+      endpoint.providerType
+    );
+    await SessionManager.terminateStickySessionsForProviders(
+      affectedProviderIds,
+      "removeProviderEndpoint"
+    );
+
     // Auto cleanup: if the vendor has no active providers/endpoints, delete it as well.
     try {
       await tryDeleteProviderVendorIfEmpty(endpoint.vendorId);

+ 31 - 0
src/actions/providers.ts

@@ -43,6 +43,7 @@ import {
   saveProviderCircuitConfig,
 } from "@/lib/redis/circuit-breaker-config";
 import { RedisKVStore } from "@/lib/redis/redis-kv-store";
+import { SessionManager } from "@/lib/session-manager";
 import { maskKey } from "@/lib/utils/validation";
 import { extractZodErrorCode, formatZodError } from "@/lib/utils/zod-i18n";
 import { validateProviderUrlForConnectivity } from "@/lib/validation/provider-url";
@@ -183,6 +184,28 @@ async function broadcastProviderCacheInvalidation(context: {
   }
 }
 
+const STICKY_SESSION_INVALIDATING_PROVIDER_KEYS = new Set<string>([
+  "url",
+  "websiteUrl",
+  "providerType",
+  "groupTag",
+  "isEnabled",
+  "allowedModels",
+  "allowedClients",
+  "blockedClients",
+  "modelRedirects",
+  "activeTimeStart",
+  "activeTimeEnd",
+]);
+
+function shouldInvalidateStickySessionsOnProviderEdit(
+  changedProviderFields: Record<string, unknown>
+): boolean {
+  return Object.keys(changedProviderFields).some((key) =>
+    STICKY_SESSION_INVALIDATING_PROVIDER_KEYS.has(key)
+  );
+}
+
 // 获取服务商数据
 export async function getProviders(): Promise<ProviderDisplay[]> {
   try {
@@ -776,6 +799,10 @@ export async function editProvider(
       return { ok: false, error: "供应商不存在" };
     }
 
+    if (shouldInvalidateStickySessionsOnProviderEdit(preimageFields)) {
+      await SessionManager.terminateStickySessionsForProviders([providerId], "editProvider");
+    }
+
     // 同步熔断器配置到 Redis(如果配置有变化)
     const hasCircuitConfigChange =
       validated.circuit_breaker_failure_threshold !== undefined ||
@@ -848,6 +875,8 @@ export async function removeProvider(
     const provider = await findProviderById(providerId);
     await deleteProvider(providerId);
 
+    await SessionManager.terminateStickySessionsForProviders([providerId], "removeProvider");
+
     const undoToken = createProviderPatchUndoToken();
     const operationId = createProviderPatchOperationId();
 
@@ -1280,6 +1309,8 @@ const SINGLE_EDIT_PREIMAGE_FIELD_TO_PROVIDER_KEY: Record<string, keyof Provider>
   active_time_end: "activeTimeEnd",
   model_redirects: "modelRedirects",
   allowed_models: "allowedModels",
+  allowed_clients: "allowedClients",
+  blocked_clients: "blockedClients",
   limit_5h_usd: "limit5hUsd",
   limit_daily_usd: "limitDailyUsd",
   daily_reset_mode: "dailyResetMode",

+ 2 - 4
src/app/[locale]/dashboard/logs/_components/provider-chain-popover.test.tsx

@@ -427,10 +427,8 @@ describe("provider-chain-popover hedge/abort reason handling", () => {
     // hedge_triggered is informational, not an actual request
     // so the request count should be 2 (winner + loser), not 3
     const document = parseHtml(html);
-    const countBadge = Array.from(document.querySelectorAll('[data-slot="badge"]')).find((node) =>
-      (node.textContent ?? "").includes("times")
-    );
-    expect(countBadge?.textContent).toContain("2");
+    const requestRows = document.querySelectorAll("#root .relative.flex.gap-2");
+    expect(requestRows).toHaveLength(2);
   });
 
   test("hedge_winner is treated as successful provider", () => {

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

@@ -4,7 +4,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
 import { useTranslations } from "next-intl";
 import { useCallback, useEffect, useMemo, useRef, useState, useTransition } from "react";
 import { toast } from "sonner";
-import { getProviderEndpoints, getProviderVendors } from "@/actions/provider-endpoints";
+import { getProviderEndpoints } from "@/actions/provider-endpoints";
 import {
   addProvider,
   editProvider,
@@ -27,12 +27,7 @@ import {
 import { Button } from "@/components/ui/button";
 import { PROVIDER_BATCH_PATCH_ERROR_CODES } from "@/lib/provider-batch-patch-error-codes";
 import { isValidUrl } from "@/lib/utils/validation";
-import type {
-  ProviderDisplay,
-  ProviderEndpoint,
-  ProviderType,
-  ProviderVendor,
-} from "@/types/provider";
+import type { ProviderDisplay, ProviderEndpoint, ProviderType } from "@/types/provider";
 import { FormTabNav, NAV_ORDER, PARENT_MAP, TAB_ORDER } from "./components/form-tab-nav";
 import { ProviderFormProvider, useProviderForm } from "./provider-form-context";
 import type { NavTargetId, SubTabId, TabId } from "./provider-form-types";
@@ -43,29 +38,6 @@ import { OptionsSection } from "./sections/options-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;
@@ -101,29 +73,10 @@ function ProviderFormContent({
   const isEdit = mode === "edit";
 
   const queryClient = useQueryClient();
-  const { data: vendors = [] } = useQuery<ProviderVendor[]>({
-    queryKey: ["provider-vendors"],
-    queryFn: getProviderVendors,
-    staleTime: 60_000,
-    refetchOnWindowFocus: false,
-  });
-
-  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]);
+    return isEdit ? (provider?.providerVendorId ?? null) : null;
+  }, [isEdit, provider?.providerVendorId]);
 
   const endpointPoolQueryKey = useMemo(() => {
     if (resolvedEndpointPoolVendorId == null) return null;
@@ -162,22 +115,6 @@ function ProviderFormContent({
     !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) {

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

@@ -234,6 +234,7 @@ function EndpointRow({
   circuitState: EndpointCircuitState | null;
 }) {
   const t = useTranslations("settings.providers");
+  const tErrors = useTranslations("errors");
   const tStatus = useTranslations("settings.providers.endpointStatus");
   const tCommon = useTranslations("settings.common");
   const queryClient = useQueryClient();
@@ -274,7 +275,9 @@ function EndpointRow({
   const deleteMutation = useMutation({
     mutationFn: async () => {
       const res = await removeProviderEndpoint({ endpointId: endpoint.id });
-      if (!res.ok) throw new Error(res.error);
+      if (!res.ok) {
+        throw Object.assign(new Error(res.error), { actionResult: res });
+      }
       return res.data;
     },
     onSuccess: () => {
@@ -282,8 +285,21 @@ function EndpointRow({
       queryClient.invalidateQueries({ queryKey: ["provider-vendors"] });
       toast.success(t("endpointDeleteSuccess"));
     },
-    onError: () => {
-      toast.error(t("endpointDeleteFailed"));
+    onError: (
+      error: Error & {
+        actionResult?: {
+          error?: string;
+          errorCode?: string;
+          errorParams?: Record<string, string | number>;
+        };
+      }
+    ) => {
+      const actionResult = error.actionResult;
+      toast.error(
+        actionResult?.errorCode
+          ? getErrorMessage(tErrors, actionResult.errorCode, actionResult.errorParams)
+          : (actionResult?.error ?? t("endpointDeleteFailed"))
+      );
     },
   });
 

+ 2 - 0
src/lib/provider-endpoint-error-codes.ts

@@ -1,3 +1,5 @@
 export const PROVIDER_ENDPOINT_CONFLICT_CODE = "PROVIDER_ENDPOINT_CONFLICT";
 export const PROVIDER_ENDPOINT_WRITE_READ_INCONSISTENCY_CODE =
   "PROVIDER_ENDPOINT_WRITE_READ_INCONSISTENCY";
+export const ENDPOINT_REFERENCED_BY_ENABLED_PROVIDERS_CODE =
+  "ENDPOINT_REFERENCED_BY_ENABLED_PROVIDERS";

+ 85 - 0
src/lib/session-manager.ts

@@ -2033,6 +2033,91 @@ export class SessionManager {
     }
   }
 
+  static async terminateProviderSessionsBatch(providerIds: number[]): Promise<number> {
+    const uniqueProviderIds = Array.from(
+      new Set(providerIds.filter((providerId) => Number.isInteger(providerId) && providerId > 0))
+    );
+    if (uniqueProviderIds.length === 0) {
+      return 0;
+    }
+
+    const redis = getRedisClient();
+    if (!redis || redis.status !== "ready") {
+      logger.warn("SessionManager: Redis not ready, cannot terminate provider sessions");
+      return 0;
+    }
+
+    try {
+      const pipeline = redis.pipeline();
+      for (const providerId of uniqueProviderIds) {
+        pipeline.zrange(`provider:${providerId}:active_sessions`, 0, -1);
+      }
+
+      const results = await pipeline.exec();
+      if (!results) {
+        return 0;
+      }
+
+      const sessionIds = new Set<string>();
+      for (const [err, result] of results) {
+        if (err) {
+          logger.warn("SessionManager: Pipeline command error in terminateProviderSessionsBatch", {
+            error: err,
+          });
+          continue;
+        }
+        if (!Array.isArray(result)) {
+          continue;
+        }
+
+        for (const sessionId of result) {
+          if (typeof sessionId === "string" && sessionId.trim()) {
+            sessionIds.add(sessionId);
+          }
+        }
+      }
+
+      if (sessionIds.size === 0) {
+        return 0;
+      }
+
+      const terminatedCount = await SessionManager.terminateSessionsBatch([...sessionIds]);
+      logger.info("SessionManager: Terminated provider sessions batch", {
+        providerIds: uniqueProviderIds,
+        sessionCount: sessionIds.size,
+        terminatedCount,
+      });
+      return terminatedCount;
+    } catch (error) {
+      logger.error("SessionManager: Failed to terminate provider sessions batch", {
+        error,
+        providerIds: uniqueProviderIds,
+      });
+      return 0;
+    }
+  }
+
+  static async terminateStickySessionsForProviders(
+    providerIds: number[],
+    context: string
+  ): Promise<void> {
+    const uniqueProviderIds = Array.from(
+      new Set(providerIds.filter((providerId) => Number.isInteger(providerId) && providerId > 0))
+    );
+    if (uniqueProviderIds.length === 0) {
+      return;
+    }
+
+    try {
+      await SessionManager.terminateProviderSessionsBatch(uniqueProviderIds);
+    } catch (error) {
+      logger.warn(`${context}:terminate_provider_sessions_failed`, {
+        providerIds: uniqueProviderIds,
+        error: error instanceof Error ? error.message : String(error),
+      });
+    }
+  }
+
   /**
    * 批量终止 Session
    *

+ 72 - 5
src/repository/provider-endpoints.ts

@@ -75,7 +75,7 @@ function toNullableDate(value: unknown): Date | null {
   return toDate(value);
 }
 
-function normalizeWebsiteDomainFromUrl(rawUrl: string): string | null {
+function normalizeWebsiteDomainKeyFromUrl(rawUrl: string): string | null {
   const trimmed = rawUrl.trim();
   if (!trimmed) return null;
 
@@ -89,9 +89,16 @@ function normalizeWebsiteDomainFromUrl(rawUrl: string): string | null {
       const parsed = new URL(candidate);
       const hostname = parsed.hostname?.toLowerCase();
       if (!hostname) continue;
-      return hostname.startsWith("www.") ? hostname.slice(4) : hostname;
+
+      const normalizedHostname = hostname.startsWith("www.") ? hostname.slice(4) : hostname;
+
+      if (!parsed.port) {
+        return normalizedHostname;
+      }
+
+      return `${normalizedHostname}:${parsed.port}`;
     } catch (error) {
-      logger.debug("[ProviderVendor] Failed to parse URL", {
+      logger.debug("[ProviderVendor] Failed to parse website URL for vendor key", {
         candidateLength: candidate.length,
         error: error instanceof Error ? error.message : String(error),
       });
@@ -150,7 +157,9 @@ function normalizeHostWithPort(rawUrl: string): string | null {
  * Compute vendor clustering key based on URLs.
  *
  * Rules:
- * - If websiteUrl is non-empty: key = normalized hostname (strip www, lowercase), ignore port
+ * - If websiteUrl is non-empty:
+ *   - hostname only when no explicit port
+ *   - host:port when an explicit port is present
  * - If websiteUrl is empty: key = host:port
  *   - IPv6 format: [ipv6]:port
  *   - Missing port: use protocol default (http=80, https=443)
@@ -164,7 +173,7 @@ export async function computeVendorKey(input: {
 
   // Case 1: websiteUrl is non-empty - use hostname only (existing behavior)
   if (websiteUrl?.trim()) {
-    return normalizeWebsiteDomainFromUrl(websiteUrl);
+    return normalizeWebsiteDomainKeyFromUrl(websiteUrl);
   }
 
   // Case 2: websiteUrl is empty - use host:port as key
@@ -739,6 +748,64 @@ export async function hasEnabledProviderReferenceForVendorTypeUrl(input: {
   return Boolean(row);
 }
 
+export async function findEnabledProviderReferencesForVendorTypeUrl(input: {
+  vendorId: number;
+  providerType: ProviderType;
+  url: string;
+  excludeProviderId?: number;
+}): Promise<Array<{ id: number; name: string }>> {
+  const trimmedUrl = input.url.trim();
+  if (!trimmedUrl) {
+    return [];
+  }
+
+  const whereClauses = [
+    eq(providers.providerVendorId, input.vendorId),
+    eq(providers.providerType, input.providerType),
+    eq(providers.url, trimmedUrl),
+    eq(providers.isEnabled, true),
+    isNull(providers.deletedAt),
+  ];
+
+  if (input.excludeProviderId != null) {
+    whereClauses.push(ne(providers.id, input.excludeProviderId));
+  }
+
+  const rows = await db
+    .select({
+      id: providers.id,
+      name: providers.name,
+    })
+    .from(providers)
+    .where(and(...whereClauses))
+    .orderBy(asc(providers.id));
+
+  return rows.map((row) => ({
+    id: row.id,
+    name: row.name,
+  }));
+}
+
+export async function findEnabledProviderIdsByVendorAndType(
+  vendorId: number,
+  providerType: ProviderType
+): Promise<number[]> {
+  const rows = await db
+    .select({ id: providers.id })
+    .from(providers)
+    .where(
+      and(
+        eq(providers.providerVendorId, vendorId),
+        eq(providers.providerType, providerType),
+        eq(providers.isEnabled, true),
+        isNull(providers.deletedAt)
+      )
+    )
+    .orderBy(asc(providers.id));
+
+  return rows.map((row) => row.id);
+}
+
 /**
  * Dashboard/Endpoint Health 用:仅在存在启用 provider 的前提下返回该 vendor/type 的端点池。
  *

+ 100 - 4
tests/unit/actions/provider-endpoints.test.ts

@@ -12,8 +12,10 @@ const tryDeleteProviderVendorIfEmptyMock = vi.fn();
 const updateProviderEndpointMock = vi.fn();
 const findProviderEndpointProbeLogsBatchMock = vi.fn();
 const findVendorTypeEndpointStatsBatchMock = vi.fn();
-const hasEnabledProviderReferenceForVendorTypeUrlMock = vi.fn();
+const findEnabledProviderReferencesForVendorTypeUrlMock = vi.fn();
+const findEnabledProviderIdsByVendorAndTypeMock = vi.fn();
 const findDashboardProviderEndpointsByVendorAndTypeMock = vi.fn();
+const terminateProviderSessionsBatchMock = vi.fn();
 
 vi.mock("@/lib/auth", () => ({
   getSession: getSessionMock,
@@ -56,6 +58,13 @@ vi.mock("@/lib/provider-endpoints/probe", () => ({
   probeProviderEndpointAndRecordByEndpoint: vi.fn(async () => null),
 }));
 
+vi.mock("@/lib/session-manager", () => ({
+  SessionManager: {
+    terminateProviderSessionsBatch: terminateProviderSessionsBatchMock,
+    terminateStickySessionsForProviders: terminateProviderSessionsBatchMock,
+  },
+}));
+
 vi.mock("@/repository/provider-endpoints-batch", () => ({
   findProviderEndpointProbeLogsBatch: findProviderEndpointProbeLogsBatchMock,
   findVendorTypeEndpointStatsBatch: findVendorTypeEndpointStatsBatchMock,
@@ -64,7 +73,8 @@ vi.mock("@/repository/provider-endpoints-batch", () => ({
 vi.mock("@/repository/provider-endpoints", () => ({
   findDashboardProviderEndpointsByVendorAndType: findDashboardProviderEndpointsByVendorAndTypeMock,
   findEnabledProviderVendorTypePairs: vi.fn(async () => []),
-  hasEnabledProviderReferenceForVendorTypeUrl: hasEnabledProviderReferenceForVendorTypeUrlMock,
+  findEnabledProviderReferencesForVendorTypeUrl: findEnabledProviderReferencesForVendorTypeUrlMock,
+  findEnabledProviderIdsByVendorAndType: findEnabledProviderIdsByVendorAndTypeMock,
 }));
 
 vi.mock("@/repository", () => ({
@@ -84,8 +94,10 @@ vi.mock("@/repository", () => ({
 describe("provider-endpoints actions", () => {
   beforeEach(() => {
     vi.clearAllMocks();
-    hasEnabledProviderReferenceForVendorTypeUrlMock.mockResolvedValue(false);
+    findEnabledProviderReferencesForVendorTypeUrlMock.mockResolvedValue([]);
+    findEnabledProviderIdsByVendorAndTypeMock.mockResolvedValue([]);
     findDashboardProviderEndpointsByVendorAndTypeMock.mockResolvedValue([]);
+    terminateProviderSessionsBatchMock.mockResolvedValue(0);
   });
 
   it("editProviderVendor: requires admin", async () => {
@@ -344,7 +356,7 @@ describe("provider-endpoints actions", () => {
     });
     softDeleteProviderEndpointMock.mockResolvedValue(true);
     tryDeleteProviderVendorIfEmptyMock.mockResolvedValue(true);
-    hasEnabledProviderReferenceForVendorTypeUrlMock.mockResolvedValue(false);
+    findEnabledProviderIdsByVendorAndTypeMock.mockResolvedValue([11, 12]);
 
     const { removeProviderEndpoint } = await import("@/actions/provider-endpoints");
     const res = await removeProviderEndpoint({ endpointId: 99 });
@@ -354,6 +366,90 @@ describe("provider-endpoints actions", () => {
     const { resetEndpointCircuit } = await import("@/lib/endpoint-circuit-breaker");
     expect(resetEndpointCircuit).toHaveBeenCalledWith(99);
     expect(tryDeleteProviderVendorIfEmptyMock).toHaveBeenCalledWith(123);
+    expect(terminateProviderSessionsBatchMock).toHaveBeenCalledWith(
+      [11, 12],
+      "removeProviderEndpoint"
+    );
+  });
+
+  it("removeProviderEndpoint: returns detailed conflict when endpoint is still referenced", async () => {
+    getSessionMock.mockResolvedValue({ user: { role: "admin" } });
+
+    findProviderEndpointByIdMock.mockResolvedValue({
+      id: 99,
+      vendorId: 123,
+      providerType: "claude",
+      url: "https://api.example.com",
+      label: null,
+      sortOrder: 0,
+      isEnabled: true,
+      lastProbedAt: null,
+      lastProbeOk: null,
+      lastProbeStatusCode: null,
+      lastProbeLatencyMs: null,
+      lastProbeErrorType: null,
+      lastProbeErrorMessage: null,
+      createdAt: new Date(),
+      updatedAt: new Date(),
+      deletedAt: null,
+    });
+    findEnabledProviderReferencesForVendorTypeUrlMock.mockResolvedValue([
+      { id: 1, name: "CPA Primary" },
+      { id: 2, name: "CPA Backup" },
+      { id: 3, name: "CPA Canary" },
+      { id: 4, name: "CPA Extra" },
+    ]);
+
+    const { removeProviderEndpoint } = await import("@/actions/provider-endpoints");
+    const res = await removeProviderEndpoint({ endpointId: 99 });
+
+    expect(res.ok).toBe(false);
+    expect(res.errorCode).toBe("ENDPOINT_REFERENCED_BY_ENABLED_PROVIDERS");
+    expect(res.errorParams).toEqual({
+      count: 4,
+      providers: "CPA Primary, CPA Backup, CPA Canary +1",
+    });
+    expect(softDeleteProviderEndpointMock).not.toHaveBeenCalled();
+    expect(terminateProviderSessionsBatchMock).not.toHaveBeenCalled();
+  });
+
+  it("editProviderEndpoint: route-affecting changes terminate affected provider sessions", async () => {
+    getSessionMock.mockResolvedValue({ user: { role: "admin" } });
+
+    const endpoint = {
+      id: 42,
+      vendorId: 123,
+      providerType: "claude" as const,
+      url: "https://next.example.com/v1/messages",
+      label: "primary",
+      sortOrder: 7,
+      isEnabled: true,
+      lastProbedAt: null,
+      lastProbeOk: null,
+      lastProbeStatusCode: null,
+      lastProbeLatencyMs: null,
+      lastProbeErrorType: null,
+      lastProbeErrorMessage: null,
+      createdAt: new Date("2026-01-01T00:00:00.000Z"),
+      updatedAt: new Date("2026-01-01T00:00:00.000Z"),
+      deletedAt: null,
+    };
+
+    findProviderEndpointByIdMock.mockResolvedValue(endpoint);
+    updateProviderEndpointMock.mockResolvedValue({
+      ...endpoint,
+      isEnabled: false,
+    });
+    findEnabledProviderIdsByVendorAndTypeMock.mockResolvedValue([7, 8]);
+
+    const { editProviderEndpoint } = await import("@/actions/provider-endpoints");
+    const res = await editProviderEndpoint({
+      endpointId: 42,
+      isEnabled: false,
+    });
+
+    expect(res.ok).toBe(true);
+    expect(terminateProviderSessionsBatchMock).toHaveBeenCalledWith([7, 8], "editProviderEndpoint");
   });
 
   it("probeProviderEndpoint: calls probeProviderEndpointAndRecordByEndpoint and returns result", async () => {

+ 33 - 0
tests/unit/actions/providers.test.ts

@@ -15,6 +15,7 @@ const saveProviderCircuitConfigMock = vi.fn();
 const deleteProviderCircuitConfigMock = vi.fn();
 const clearConfigCacheMock = vi.fn();
 const clearProviderStateMock = vi.fn();
+const terminateProviderSessionsBatchMock = vi.fn();
 
 const revalidatePathMock = vi.fn();
 
@@ -50,6 +51,13 @@ vi.mock("@/lib/circuit-breaker", () => ({
   resetCircuit: vi.fn(),
 }));
 
+vi.mock("@/lib/session-manager", () => ({
+  SessionManager: {
+    terminateProviderSessionsBatch: terminateProviderSessionsBatchMock,
+    terminateStickySessionsForProviders: terminateProviderSessionsBatchMock,
+  },
+}));
+
 vi.mock("@/lib/logger", () => ({
   logger: {
     trace: vi.fn(),
@@ -167,6 +175,7 @@ describe("Provider Actions - Async Optimization", () => {
     saveProviderCircuitConfigMock.mockResolvedValue(undefined);
     deleteProviderCircuitConfigMock.mockResolvedValue(undefined);
     clearProviderStateMock.mockResolvedValue(undefined);
+    terminateProviderSessionsBatchMock.mockResolvedValue(0);
     updateProviderPrioritiesBatchMock.mockResolvedValue(0);
   });
 
@@ -502,6 +511,7 @@ describe("Provider Actions - Async Optimization", () => {
 
       expect(result.ok).toBe(true);
       expect(revalidatePathMock).not.toHaveBeenCalled();
+      expect(terminateProviderSessionsBatchMock).not.toHaveBeenCalled();
     });
 
     it("editProvider endpoint sync: should forward url/provider_type edits to repository", async () => {
@@ -522,6 +532,7 @@ describe("Provider Actions - Async Optimization", () => {
         })
       );
       expect(publishProviderCacheInvalidationMock).toHaveBeenCalledTimes(1);
+      expect(terminateProviderSessionsBatchMock).toHaveBeenCalledWith([1], "editProvider");
     });
 
     it("editProvider endpoint sync: should generate favicon_url when website_url is updated", async () => {
@@ -543,6 +554,7 @@ describe("Provider Actions - Async Optimization", () => {
           favicon_url: "https://www.google.com/s2/favicons?domain=vendor.example.com&sz=32",
         })
       );
+      expect(terminateProviderSessionsBatchMock).toHaveBeenCalledWith([1], "editProvider");
     });
 
     it("editProvider endpoint sync: should clear favicon_url when website_url is cleared", async () => {
@@ -561,6 +573,26 @@ describe("Provider Actions - Async Optimization", () => {
           favicon_url: null,
         })
       );
+      expect(terminateProviderSessionsBatchMock).toHaveBeenCalledWith([1], "editProvider");
+    });
+
+    it("editProvider: group or allowlist changes should also terminate sticky sessions", async () => {
+      const { editProvider } = await import("@/actions/providers");
+
+      const result = await editProvider(1, {
+        group_tag: "gpt-load",
+        allowed_models: ["gpt-4.1"],
+      });
+
+      expect(result.ok).toBe(true);
+      expect(updateProviderMock).toHaveBeenCalledWith(
+        1,
+        expect.objectContaining({
+          group_tag: "gpt-load",
+          allowed_models: ["gpt-4.1"],
+        })
+      );
+      expect(terminateProviderSessionsBatchMock).toHaveBeenCalledWith([1], "editProvider");
     });
   });
 
@@ -571,6 +603,7 @@ describe("Provider Actions - Async Optimization", () => {
 
       expect(result.ok).toBe(true);
       expect(revalidatePathMock).not.toHaveBeenCalled();
+      expect(terminateProviderSessionsBatchMock).toHaveBeenCalledWith([1], "removeProvider");
     });
   });
 });

+ 55 - 0
tests/unit/lib/session-manager-terminate-provider-sessions.test.ts

@@ -0,0 +1,55 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+let redisClientRef: any;
+let pipelineRef: any;
+
+vi.mock("server-only", () => ({}));
+
+vi.mock("@/lib/logger", () => ({
+  logger: {
+    warn: vi.fn(),
+    info: vi.fn(),
+    error: vi.fn(),
+    debug: vi.fn(),
+    trace: vi.fn(),
+  },
+}));
+
+vi.mock("@/lib/redis", () => ({
+  getRedisClient: () => redisClientRef,
+}));
+
+describe("SessionManager.terminateProviderSessionsBatch", () => {
+  beforeEach(() => {
+    vi.resetAllMocks();
+    vi.resetModules();
+
+    pipelineRef = {
+      zrange: vi.fn(() => pipelineRef),
+      exec: vi.fn(async () => [
+        [null, ["sess-a", "sess-b"]],
+        [null, ["sess-b", "sess-c"]],
+      ]),
+    };
+
+    redisClientRef = {
+      status: "ready",
+      pipeline: vi.fn(() => pipelineRef),
+    };
+  });
+
+  it("should collect unique session ids from provider active-session zsets and terminate them in batch", async () => {
+    const { SessionManager } = await import("@/lib/session-manager");
+    const terminateSessionsBatchSpy = vi
+      .spyOn(SessionManager, "terminateSessionsBatch")
+      .mockResolvedValue(3);
+
+    const result = await SessionManager.terminateProviderSessionsBatch([42, 43, 42, 0]);
+
+    expect(result).toBe(3);
+    expect(pipelineRef.zrange).toHaveBeenCalledTimes(2);
+    expect(pipelineRef.zrange).toHaveBeenCalledWith("provider:42:active_sessions", 0, -1);
+    expect(pipelineRef.zrange).toHaveBeenCalledWith("provider:43:active_sessions", 0, -1);
+    expect(terminateSessionsBatchSpy).toHaveBeenCalledWith(["sess-a", "sess-b", "sess-c"]);
+  });
+});

+ 26 - 2
tests/unit/repository/provider-endpoints-vendor-key.test.ts

@@ -3,15 +3,24 @@ import { computeVendorKey } from "@/repository/provider-endpoints";
 
 describe("computeVendorKey", () => {
   describe("with websiteUrl (priority over providerUrl)", () => {
-    test("returns hostname only, ignoring port", async () => {
+    test("returns hostname only when websiteUrl has no explicit port", async () => {
       expect(
         await computeVendorKey({
           providerUrl: "https://api.example.com:8080/v1/messages",
-          websiteUrl: "https://example.com:3000",
+          websiteUrl: "https://example.com",
         })
       ).toBe("example.com");
     });
 
+    test("preserves explicit websiteUrl port for local/self-hosted vendors", async () => {
+      expect(
+        await computeVendorKey({
+          providerUrl: "https://api.example.com:8080/v1/messages",
+          websiteUrl: "https://example.com:3000",
+        })
+      ).toBe("example.com:3000");
+    });
+
     test("strips www prefix", async () => {
       expect(
         await computeVendorKey({
@@ -38,6 +47,21 @@ describe("computeVendorKey", () => {
         })
       ).toBe("example.com");
     });
+
+    test("keeps distinct ports as distinct vendor keys when websiteUrl contains explicit ports", async () => {
+      const key1 = await computeVendorKey({
+        providerUrl: "http://192.168.1.1:8080/v1/messages",
+        websiteUrl: "http://192.168.1.1:111",
+      });
+      const key2 = await computeVendorKey({
+        providerUrl: "http://192.168.1.1:9090/v1/messages",
+        websiteUrl: "http://192.168.1.1:222",
+      });
+
+      expect(key1).toBe("192.168.1.1:111");
+      expect(key2).toBe("192.168.1.1:222");
+      expect(key1).not.toBe(key2);
+    });
   });
 
   describe("without websiteUrl (fallback to providerUrl with host:port)", () => {

+ 36 - 2
tests/unit/repository/statistics-quota-costs-all-time.test.ts

@@ -41,11 +41,28 @@ describe("sumUserQuotaCosts & sumKeyQuotaCostsById - all-time query support", ()
 
     vi.doMock("drizzle-orm", async () => {
       const actual = await vi.importActual<typeof import("drizzle-orm")>("drizzle-orm");
+      const sqlMock = Object.assign(
+        (strings: TemplateStringsArray, ...values: unknown[]) => ({
+          queryChunks: [...strings, ...values],
+        }),
+        {
+          raw: vi.fn(),
+          join: vi.fn(),
+          identifier: vi.fn(),
+        }
+      );
       return {
         ...actual,
+        relations: vi.fn(() => ({})),
+        eq: vi.fn((...args: unknown[]) => ({ kind: "eq", args })),
+        gte: vi.fn((...args: unknown[]) => ({ kind: "gte", args })),
+        inArray: vi.fn((...args: unknown[]) => ({ kind: "inArray", args })),
+        isNull: vi.fn((...args: unknown[]) => ({ kind: "isNull", args })),
+        lt: vi.fn((...args: unknown[]) => ({ kind: "lt", args })),
+        sql: sqlMock,
         and: (...args: unknown[]) => {
           capturedAndArgs = args;
-          return (actual as any).and(...args);
+          return { kind: "and", args };
         },
       };
     });
@@ -88,11 +105,28 @@ describe("sumUserQuotaCosts & sumKeyQuotaCostsById - all-time query support", ()
 
     vi.doMock("drizzle-orm", async () => {
       const actual = await vi.importActual<typeof import("drizzle-orm")>("drizzle-orm");
+      const sqlMock = Object.assign(
+        (strings: TemplateStringsArray, ...values: unknown[]) => ({
+          queryChunks: [...strings, ...values],
+        }),
+        {
+          raw: vi.fn(),
+          join: vi.fn(),
+          identifier: vi.fn(),
+        }
+      );
       return {
         ...actual,
+        relations: vi.fn(() => ({})),
+        eq: vi.fn((...args: unknown[]) => ({ kind: "eq", args })),
+        gte: vi.fn((...args: unknown[]) => ({ kind: "gte", args })),
+        inArray: vi.fn((...args: unknown[]) => ({ kind: "inArray", args })),
+        isNull: vi.fn((...args: unknown[]) => ({ kind: "isNull", args })),
+        lt: vi.fn((...args: unknown[]) => ({ kind: "lt", args })),
+        sql: sqlMock,
         and: (...args: unknown[]) => {
           capturedAndArgs = args;
-          return (actual as any).and(...args);
+          return { kind: "and", args };
         },
       };
     });

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

@@ -102,6 +102,7 @@ function loadMessages() {
 }
 
 let queryClient: QueryClient;
+const confirmMock = vi.fn(() => true);
 
 function renderWithProviders(node: ReactNode) {
   const container = document.createElement("div");
@@ -143,6 +144,8 @@ describe("ProviderEndpointsTable", () => {
       },
     });
     vi.clearAllMocks();
+    confirmMock.mockReturnValue(true);
+    vi.stubGlobal("confirm", confirmMock);
     while (document.body.firstChild) {
       document.body.removeChild(document.body.firstChild);
     }
@@ -270,6 +273,56 @@ describe("ProviderEndpointsTable", () => {
     unmount();
   });
 
+  test("delete failure should show translated reference detail instead of generic error", async () => {
+    providerEndpointsActionMocks.removeProviderEndpoint.mockResolvedValueOnce({
+      ok: false,
+      errorCode: "ENDPOINT_REFERENCED_BY_ENABLED_PROVIDERS",
+      errorParams: {
+        count: 2,
+        providers: "CPA Primary, CPA Backup",
+      },
+    } as any);
+
+    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 buttons = endpointRow?.querySelectorAll("button");
+    const moreButton = buttons?.[buttons.length - 1] as HTMLButtonElement | undefined;
+    expect(moreButton).toBeDefined();
+
+    act(() => {
+      moreButton?.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true }));
+      moreButton?.click();
+    });
+
+    await flushTicks(2);
+
+    const deleteItem = Array.from(
+      document.querySelectorAll("[data-slot='dropdown-menu-item']")
+    ).find((item) => item.textContent?.includes("Delete")) as HTMLElement | undefined;
+
+    expect(deleteItem).toBeDefined();
+
+    act(() => {
+      deleteItem?.click();
+    });
+
+    await flushTicks(4);
+
+    expect(confirmMock).toHaveBeenCalledTimes(1);
+    expect(sonnerMocks.toast.error).toHaveBeenCalledWith(
+      "This endpoint is still referenced by 2 enabled providers: CPA Primary, CPA Backup"
+    );
+
+    unmount();
+  });
+
   test("shows empty state when no endpoints", async () => {
     providerEndpointsActionMocks.getProviderEndpointsByVendor.mockResolvedValueOnce([]);
 

+ 156 - 5
tests/unit/settings/providers/provider-form-endpoint-pool.test.tsx

@@ -11,7 +11,11 @@ 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";
+import type {
+  ProviderDisplay,
+  ProviderEndpoint,
+  ProviderVendor,
+} from "../../../../src/types/provider";
 
 const sonnerMocks = vi.hoisted(() => ({
   toast: {
@@ -111,6 +115,68 @@ function setNativeValue(element: HTMLInputElement, value: string) {
   element.value = value;
 }
 
+function makeCloneProvider(overrides: Partial<ProviderDisplay> = {}): ProviderDisplay {
+  return {
+    id: 88,
+    name: "CPA Provider",
+    url: "https://old.example.com/v1/messages",
+    maskedKey: "sk-****1234",
+    isEnabled: true,
+    weight: 1,
+    priority: 0,
+    groupPriorities: null,
+    costMultiplier: 1,
+    groupTag: null,
+    providerType: "claude",
+    providerVendorId: 1,
+    preserveClientIp: false,
+    modelRedirects: null,
+    allowedModels: null,
+    allowedClients: [],
+    blockedClients: [],
+    mcpPassthroughType: "none",
+    mcpPassthroughUrl: null,
+    limit5hUsd: null,
+    limitDailyUsd: null,
+    dailyResetMode: "fixed",
+    dailyResetTime: "00:00",
+    limitWeeklyUsd: null,
+    limitMonthlyUsd: null,
+    limitTotalUsd: null,
+    limitConcurrentSessions: 0,
+    maxRetryAttempts: null,
+    circuitBreakerFailureThreshold: 5,
+    circuitBreakerOpenDuration: 1800000,
+    circuitBreakerHalfOpenSuccessThreshold: 2,
+    proxyUrl: null,
+    proxyFallbackToDirect: false,
+    firstByteTimeoutStreamingMs: 30000,
+    streamingIdleTimeoutMs: 60000,
+    requestTimeoutNonStreamingMs: 120000,
+    websiteUrl: "https://example.com",
+    faviconUrl: null,
+    cacheTtlPreference: null,
+    swapCacheTtlBilling: false,
+    context1mPreference: null,
+    codexReasoningEffortPreference: null,
+    codexReasoningSummaryPreference: null,
+    codexTextVerbosityPreference: null,
+    codexParallelToolCallsPreference: null,
+    codexServiceTierPreference: null,
+    anthropicMaxTokensPreference: null,
+    anthropicThinkingBudgetPreference: null,
+    anthropicAdaptiveThinking: null,
+    geminiGoogleSearchPreference: null,
+    tpm: null,
+    rpm: null,
+    rpd: null,
+    cc: null,
+    createdAt: "2026-01-01",
+    updatedAt: "2026-01-01",
+    ...overrides,
+  };
+}
+
 describe("ProviderForm: endpoint pool integration", () => {
   beforeEach(() => {
     queryClient = new QueryClient({
@@ -144,7 +210,7 @@ describe("ProviderForm: endpoint pool integration", () => {
     unmount();
   });
 
-  test("When vendor resolves and endpoints exist, should show endpoint pool and hide URL input", async () => {
+  test("Create mode should keep manual URL input even when websiteUrl matches an existing vendor", async () => {
     providerEndpointsActionMocks.getProviderVendors.mockResolvedValueOnce([
       {
         id: 1,
@@ -195,9 +261,94 @@ describe("ProviderForm: endpoint pool integration", () => {
 
     await flushTicks(6);
 
-    expect(document.getElementById("url")).toBeNull();
-    expect(document.body.textContent || "").toContain("Endpoints");
-    expect(document.body.textContent || "").toContain("Add Endpoint");
+    expect(providerEndpointsActionMocks.getProviderEndpoints).not.toHaveBeenCalled();
+    expect(document.getElementById("url")).toBeTruthy();
+    expect(document.body.textContent || "").not.toContain("Add Endpoint");
+
+    unmount();
+  });
+
+  test("Clone mode should submit the explicit URL instead of inheriting an existing vendor endpoint pool", 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 cloneProvider={makeCloneProvider()} />
+    );
+
+    await flushTicks(2);
+
+    const nameInput = document.getElementById("name") as HTMLInputElement | null;
+    const urlInput = document.getElementById("url") as HTMLInputElement | null;
+    const keyInput = document.getElementById("key") as HTMLInputElement | null;
+
+    expect(nameInput).toBeTruthy();
+    expect(urlInput).toBeTruthy();
+    expect(keyInput).toBeTruthy();
+
+    await act(async () => {
+      if (!nameInput || !urlInput || !keyInput) return;
+      setNativeValue(nameInput, "CPA Provider_Copy");
+      nameInput.dispatchEvent(new Event("input", { bubbles: true }));
+      nameInput.dispatchEvent(new Event("change", { bubbles: true }));
+
+      setNativeValue(urlInput, "https://manual.example.com/v1/messages");
+      urlInput.dispatchEvent(new Event("input", { bubbles: true }));
+      urlInput.dispatchEvent(new Event("change", { bubbles: true }));
+
+      setNativeValue(keyInput, "new-key");
+      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(providerEndpointsActionMocks.getProviderEndpoints).not.toHaveBeenCalled();
+    expect(providersActionMocks.addProvider).toHaveBeenCalledWith(
+      expect.objectContaining({
+        url: "https://manual.example.com/v1/messages",
+        website_url: "https://example.com",
+      })
+    );
 
     unmount();
   });