Browse Source

feat(providers,security): add batch preview step, patch draft builder, and auth hardening

Security:
- Add constant-time string comparison utility to prevent timing attacks
- Harden login route with improved token validation
- Add rate limiting to proxy auth guard
- Tighten CORS origin validation

Provider batch edit:
- Add build-patch-draft module for generating per-provider patches
- Add provider-batch-preview-step with field-level diff display
- Extend provider-form-context with batch mode support
- Refactor form sections (basic-info, network, routing, testing) for batch compatibility
- Expand provider-patch-contract with apply/undo engine types
- Add repository helpers for bulk provider operations
- Update i18n messages across all 5 languages

Tests:
- Add constant-time compare and proxy auth rate limit security tests
- Add build-patch-draft, preview step, form context, and undo toast unit tests
- Update existing batch dialog and patch contract tests
ding113 1 week ago
parent
commit
2aaba44854
32 changed files with 5376 additions and 837 deletions
  1. 25 3
      messages/en/settings/providers/batchEdit.json
  2. 25 3
      messages/ja/settings/providers/batchEdit.json
  3. 25 3
      messages/ru/settings/providers/batchEdit.json
  4. 25 3
      messages/zh-CN/settings/providers/batchEdit.json
  5. 25 3
      messages/zh-TW/settings/providers/batchEdit.json
  6. 174 4
      src/actions/providers.ts
  7. 296 0
      src/app/[locale]/settings/providers/_components/batch-edit/build-patch-draft.ts
  8. 326 419
      src/app/[locale]/settings/providers/_components/batch-edit/provider-batch-dialog.tsx
  9. 153 0
      src/app/[locale]/settings/providers/_components/batch-edit/provider-batch-preview-step.tsx
  10. 171 16
      src/app/[locale]/settings/providers/_components/forms/provider-form/provider-form-context.tsx
  11. 11 2
      src/app/[locale]/settings/providers/_components/forms/provider-form/provider-form-types.ts
  12. 86 3
      src/app/[locale]/settings/providers/_components/forms/provider-form/sections/basic-info-section.tsx
  13. 19 14
      src/app/[locale]/settings/providers/_components/forms/provider-form/sections/network-section.tsx
  14. 100 81
      src/app/[locale]/settings/providers/_components/forms/provider-form/sections/routing-section.tsx
  15. 39 36
      src/app/[locale]/settings/providers/_components/forms/provider-form/sections/testing-section.tsx
  16. 28 6
      src/app/api/auth/login/route.ts
  17. 13 7
      src/app/v1/_lib/cors.ts
  18. 59 0
      src/app/v1/_lib/proxy/auth-guard.ts
  19. 6 3
      src/lib/auth.ts
  20. 574 1
      src/lib/provider-patch-contract.ts
  21. 27 0
      src/lib/security/constant-time-compare.ts
  22. 131 0
      src/repository/provider.ts
  23. 145 1
      src/types/provider.ts
  24. 43 0
      tests/security/constant-time-compare.test.ts
  25. 160 0
      tests/security/proxy-auth-rate-limit.test.ts
  26. 15 1
      tests/security/security-headers-integration.test.ts
  27. 730 0
      tests/unit/actions/providers-patch-contract.test.ts
  28. 647 0
      tests/unit/settings/providers/build-patch-draft.test.ts
  29. 214 228
      tests/unit/settings/providers/provider-batch-dialog-step1.test.tsx
  30. 296 0
      tests/unit/settings/providers/provider-batch-preview-step.test.tsx
  31. 190 0
      tests/unit/settings/providers/provider-form-batch-context.test.ts
  32. 598 0
      tests/unit/settings/providers/provider-undo-toast.test.tsx

+ 25 - 3
messages/en/settings/providers/batchEdit.json

@@ -46,8 +46,7 @@
     "modelRedirects": "Model Redirects",
     "allowedModels": "Allowed Models",
     "thinkingBudget": "Thinking Budget",
-    "adaptiveThinking": "Adaptive Thinking",
-    "comingSoon": "Coming soon"
+    "adaptiveThinking": "Adaptive Thinking"
   },
   "affectedProviders": {
     "title": "Affected Providers",
@@ -60,10 +59,33 @@
     "goBack": "Go Back",
     "processing": "Processing..."
   },
+  "preview": {
+    "title": "Preview Changes",
+    "description": "Review changes before applying to {count} providers",
+    "providerHeader": "{name}",
+    "fieldChanged": "{field}: {before} -> {after}",
+    "fieldSkipped": "{field}: Skipped ({reason})",
+    "excludeProvider": "Exclude",
+    "summary": "{providerCount} providers, {fieldCount} changes, {skipCount} skipped",
+    "noChanges": "No changes to apply",
+    "apply": "Apply Changes",
+    "back": "Back to Edit",
+    "loading": "Generating preview..."
+  },
+  "batchNotes": {
+    "codexOnly": "Codex only",
+    "claudeOnly": "Claude only",
+    "geminiOnly": "Gemini only"
+  },
   "toast": {
     "updated": "Updated {count} providers",
     "deleted": "Deleted {count} providers",
     "circuitReset": "Reset {count} circuit breakers",
-    "failed": "Operation failed: {error}"
+    "failed": "Operation failed: {error}",
+    "undo": "Undo",
+    "undoSuccess": "Reverted {count} providers",
+    "undoFailed": "Undo failed: {error}",
+    "undoExpired": "Undo window expired",
+    "previewFailed": "Preview failed: {error}"
   }
 }

+ 25 - 3
messages/ja/settings/providers/batchEdit.json

@@ -46,8 +46,7 @@
     "modelRedirects": "モデルリダイレクト",
     "allowedModels": "許可モデル",
     "thinkingBudget": "思考バジェット",
-    "adaptiveThinking": "アダプティブ思考",
-    "comingSoon": "近日公開"
+    "adaptiveThinking": "アダプティブ思考"
   },
   "affectedProviders": {
     "title": "影響を受けるプロバイダー",
@@ -60,10 +59,33 @@
     "goBack": "戻る",
     "processing": "処理中..."
   },
+  "preview": {
+    "title": "変更のプレビュー",
+    "description": "{count} 件のプロバイダーに適用する前に変更内容を確認してください",
+    "providerHeader": "{name}",
+    "fieldChanged": "{field}: {before} -> {after}",
+    "fieldSkipped": "{field}: スキップ ({reason})",
+    "excludeProvider": "除外",
+    "summary": "{providerCount} 件のプロバイダー, {fieldCount} 件の変更, {skipCount} 件スキップ",
+    "noChanges": "適用する変更はありません",
+    "apply": "変更を適用",
+    "back": "編集に戻る",
+    "loading": "プレビューを生成中..."
+  },
+  "batchNotes": {
+    "codexOnly": "Codex のみ",
+    "claudeOnly": "Claude のみ",
+    "geminiOnly": "Gemini のみ"
+  },
   "toast": {
     "updated": "{count} 件のプロバイダーを更新しました",
     "deleted": "{count} 件のプロバイダーを削除しました",
     "circuitReset": "{count} 件のサーキットブレーカーをリセットしました",
-    "failed": "操作に失敗しました: {error}"
+    "failed": "操作に失敗しました: {error}",
+    "undo": "元に戻す",
+    "undoSuccess": "{count} 件のプロバイダーを復元しました",
+    "undoFailed": "元に戻す操作に失敗しました: {error}",
+    "undoExpired": "元に戻す期限が切れました",
+    "previewFailed": "プレビューに失敗しました: {error}"
   }
 }

+ 25 - 3
messages/ru/settings/providers/batchEdit.json

@@ -46,8 +46,7 @@
     "modelRedirects": "Перенаправление моделей",
     "allowedModels": "Разрешённые модели",
     "thinkingBudget": "Бюджет мышления",
-    "adaptiveThinking": "Адаптивное мышление",
-    "comingSoon": "Скоро"
+    "adaptiveThinking": "Адаптивное мышление"
   },
   "affectedProviders": {
     "title": "Затронутые поставщики",
@@ -60,10 +59,33 @@
     "goBack": "Назад",
     "processing": "Обработка..."
   },
+  "preview": {
+    "title": "Предпросмотр изменений",
+    "description": "Проверьте изменения перед применением к {count} поставщикам",
+    "providerHeader": "{name}",
+    "fieldChanged": "{field}: {before} -> {after}",
+    "fieldSkipped": "{field}: Пропущено ({reason})",
+    "excludeProvider": "Исключить",
+    "summary": "{providerCount} поставщиков, {fieldCount} изменений, {skipCount} пропущено",
+    "noChanges": "Нет изменений для применения",
+    "apply": "Применить изменения",
+    "back": "Вернуться к редактированию",
+    "loading": "Генерация предпросмотра..."
+  },
+  "batchNotes": {
+    "codexOnly": "Только Codex",
+    "claudeOnly": "Только Claude",
+    "geminiOnly": "Только Gemini"
+  },
   "toast": {
     "updated": "Обновлено поставщиков: {count}",
     "deleted": "Удалено поставщиков: {count}",
     "circuitReset": "Сброшено прерывателей: {count}",
-    "failed": "Операция не удалась: {error}"
+    "failed": "Операция не удалась: {error}",
+    "undo": "Отменить",
+    "undoSuccess": "Восстановлено поставщиков: {count}",
+    "undoFailed": "Отмена не удалась: {error}",
+    "undoExpired": "Время отмены истекло",
+    "previewFailed": "Предпросмотр не удался: {error}"
   }
 }

+ 25 - 3
messages/zh-CN/settings/providers/batchEdit.json

@@ -46,8 +46,7 @@
     "modelRedirects": "模型重定向",
     "allowedModels": "允许的模型",
     "thinkingBudget": "思维预算",
-    "adaptiveThinking": "自适应思维",
-    "comingSoon": "即将推出"
+    "adaptiveThinking": "自适应思维"
   },
   "affectedProviders": {
     "title": "受影响的供应商",
@@ -60,10 +59,33 @@
     "goBack": "返回",
     "processing": "处理中..."
   },
+  "preview": {
+    "title": "预览变更",
+    "description": "将变更应用到 {count} 个供应商前请先确认",
+    "providerHeader": "{name}",
+    "fieldChanged": "{field}: {before} -> {after}",
+    "fieldSkipped": "{field}: 已跳过 ({reason})",
+    "excludeProvider": "排除",
+    "summary": "{providerCount} 个供应商, {fieldCount} 项变更, {skipCount} 项跳过",
+    "noChanges": "没有可应用的变更",
+    "apply": "应用变更",
+    "back": "返回编辑",
+    "loading": "正在生成预览..."
+  },
+  "batchNotes": {
+    "codexOnly": "仅 Codex",
+    "claudeOnly": "仅 Claude",
+    "geminiOnly": "仅 Gemini"
+  },
   "toast": {
     "updated": "已更新 {count} 个供应商",
     "deleted": "已删除 {count} 个供应商",
     "circuitReset": "已重置 {count} 个熔断器",
-    "failed": "操作失败: {error}"
+    "failed": "操作失败: {error}",
+    "undo": "撤销",
+    "undoSuccess": "已还原 {count} 个供应商",
+    "undoFailed": "撤销失败: {error}",
+    "undoExpired": "撤销窗口已过期",
+    "previewFailed": "预览失败: {error}"
   }
 }

+ 25 - 3
messages/zh-TW/settings/providers/batchEdit.json

@@ -46,8 +46,7 @@
     "modelRedirects": "模型重新導向",
     "allowedModels": "允許的模型",
     "thinkingBudget": "思維預算",
-    "adaptiveThinking": "自適應思維",
-    "comingSoon": "即將推出"
+    "adaptiveThinking": "自適應思維"
   },
   "affectedProviders": {
     "title": "受影響的供應商",
@@ -60,10 +59,33 @@
     "goBack": "返回",
     "processing": "處理中..."
   },
+  "preview": {
+    "title": "預覽變更",
+    "description": "將變更應用到 {count} 個供應商前請先確認",
+    "providerHeader": "{name}",
+    "fieldChanged": "{field}: {before} -> {after}",
+    "fieldSkipped": "{field}: 已跳過 ({reason})",
+    "excludeProvider": "排除",
+    "summary": "{providerCount} 個供應商, {fieldCount} 項變更, {skipCount} 項跳過",
+    "noChanges": "沒有可應用的變更",
+    "apply": "應用變更",
+    "back": "返回編輯",
+    "loading": "正在產生預覽..."
+  },
+  "batchNotes": {
+    "codexOnly": "僅 Codex",
+    "claudeOnly": "僅 Claude",
+    "geminiOnly": "僅 Gemini"
+  },
   "toast": {
     "updated": "已更新 {count} 個供應商",
     "deleted": "已刪除 {count} 個供應商",
     "circuitReset": "已重置 {count} 個熔斷器",
-    "failed": "操作失敗: {error}"
+    "failed": "操作失敗: {error}",
+    "undo": "復原",
+    "undoSuccess": "已還原 {count} 個供應商",
+    "undoFailed": "復原失敗: {error}",
+    "undoExpired": "復原時限已過期",
+    "previewFailed": "預覽失敗: {error}"
   }
 }

+ 174 - 4
src/actions/providers.ts

@@ -1243,6 +1243,102 @@ function mapApplyUpdatesToRepositoryFormat(
   if (applyUpdates.anthropic_adaptive_thinking !== undefined) {
     result.anthropicAdaptiveThinking = applyUpdates.anthropic_adaptive_thinking;
   }
+  if (applyUpdates.preserve_client_ip !== undefined) {
+    result.preserveClientIp = applyUpdates.preserve_client_ip;
+  }
+  if (applyUpdates.group_priorities !== undefined) {
+    result.groupPriorities = applyUpdates.group_priorities;
+  }
+  if (applyUpdates.cache_ttl_preference !== undefined) {
+    result.cacheTtlPreference = applyUpdates.cache_ttl_preference;
+  }
+  if (applyUpdates.swap_cache_ttl_billing !== undefined) {
+    result.swapCacheTtlBilling = applyUpdates.swap_cache_ttl_billing;
+  }
+  if (applyUpdates.context_1m_preference !== undefined) {
+    result.context1mPreference = applyUpdates.context_1m_preference;
+  }
+  if (applyUpdates.codex_reasoning_effort_preference !== undefined) {
+    result.codexReasoningEffortPreference = applyUpdates.codex_reasoning_effort_preference;
+  }
+  if (applyUpdates.codex_reasoning_summary_preference !== undefined) {
+    result.codexReasoningSummaryPreference = applyUpdates.codex_reasoning_summary_preference;
+  }
+  if (applyUpdates.codex_text_verbosity_preference !== undefined) {
+    result.codexTextVerbosityPreference = applyUpdates.codex_text_verbosity_preference;
+  }
+  if (applyUpdates.codex_parallel_tool_calls_preference !== undefined) {
+    result.codexParallelToolCallsPreference = applyUpdates.codex_parallel_tool_calls_preference;
+  }
+  if (applyUpdates.anthropic_max_tokens_preference !== undefined) {
+    result.anthropicMaxTokensPreference = applyUpdates.anthropic_max_tokens_preference;
+  }
+  if (applyUpdates.gemini_google_search_preference !== undefined) {
+    result.geminiGoogleSearchPreference = applyUpdates.gemini_google_search_preference;
+  }
+  if (applyUpdates.limit_5h_usd !== undefined) {
+    result.limit5hUsd =
+      applyUpdates.limit_5h_usd != null ? applyUpdates.limit_5h_usd.toString() : null;
+  }
+  if (applyUpdates.limit_daily_usd !== undefined) {
+    result.limitDailyUsd =
+      applyUpdates.limit_daily_usd != null ? applyUpdates.limit_daily_usd.toString() : null;
+  }
+  if (applyUpdates.daily_reset_mode !== undefined) {
+    result.dailyResetMode = applyUpdates.daily_reset_mode;
+  }
+  if (applyUpdates.daily_reset_time !== undefined) {
+    result.dailyResetTime = applyUpdates.daily_reset_time;
+  }
+  if (applyUpdates.limit_weekly_usd !== undefined) {
+    result.limitWeeklyUsd =
+      applyUpdates.limit_weekly_usd != null ? applyUpdates.limit_weekly_usd.toString() : null;
+  }
+  if (applyUpdates.limit_monthly_usd !== undefined) {
+    result.limitMonthlyUsd =
+      applyUpdates.limit_monthly_usd != null ? applyUpdates.limit_monthly_usd.toString() : null;
+  }
+  if (applyUpdates.limit_total_usd !== undefined) {
+    result.limitTotalUsd =
+      applyUpdates.limit_total_usd != null ? applyUpdates.limit_total_usd.toString() : null;
+  }
+  if (applyUpdates.limit_concurrent_sessions !== undefined) {
+    result.limitConcurrentSessions = applyUpdates.limit_concurrent_sessions;
+  }
+  if (applyUpdates.circuit_breaker_failure_threshold !== undefined) {
+    result.circuitBreakerFailureThreshold = applyUpdates.circuit_breaker_failure_threshold;
+  }
+  if (applyUpdates.circuit_breaker_open_duration !== undefined) {
+    result.circuitBreakerOpenDuration = applyUpdates.circuit_breaker_open_duration;
+  }
+  if (applyUpdates.circuit_breaker_half_open_success_threshold !== undefined) {
+    result.circuitBreakerHalfOpenSuccessThreshold =
+      applyUpdates.circuit_breaker_half_open_success_threshold;
+  }
+  if (applyUpdates.max_retry_attempts !== undefined) {
+    result.maxRetryAttempts = applyUpdates.max_retry_attempts;
+  }
+  if (applyUpdates.proxy_url !== undefined) {
+    result.proxyUrl = applyUpdates.proxy_url;
+  }
+  if (applyUpdates.proxy_fallback_to_direct !== undefined) {
+    result.proxyFallbackToDirect = applyUpdates.proxy_fallback_to_direct;
+  }
+  if (applyUpdates.first_byte_timeout_streaming_ms !== undefined) {
+    result.firstByteTimeoutStreamingMs = applyUpdates.first_byte_timeout_streaming_ms;
+  }
+  if (applyUpdates.streaming_idle_timeout_ms !== undefined) {
+    result.streamingIdleTimeoutMs = applyUpdates.streaming_idle_timeout_ms;
+  }
+  if (applyUpdates.request_timeout_non_streaming_ms !== undefined) {
+    result.requestTimeoutNonStreamingMs = applyUpdates.request_timeout_non_streaming_ms;
+  }
+  if (applyUpdates.mcp_passthrough_type !== undefined) {
+    result.mcpPassthroughType = applyUpdates.mcp_passthrough_type;
+  }
+  if (applyUpdates.mcp_passthrough_url !== undefined) {
+    result.mcpPassthroughUrl = applyUpdates.mcp_passthrough_url;
+  }
   return result;
 }
 
@@ -1256,21 +1352,81 @@ const PATCH_FIELD_TO_PROVIDER_KEY: Record<ProviderBatchPatchField, keyof Provide
   allowed_models: "allowedModels",
   anthropic_thinking_budget_preference: "anthropicThinkingBudgetPreference",
   anthropic_adaptive_thinking: "anthropicAdaptiveThinking",
+  preserve_client_ip: "preserveClientIp",
+  group_priorities: "groupPriorities",
+  cache_ttl_preference: "cacheTtlPreference",
+  swap_cache_ttl_billing: "swapCacheTtlBilling",
+  context_1m_preference: "context1mPreference",
+  codex_reasoning_effort_preference: "codexReasoningEffortPreference",
+  codex_reasoning_summary_preference: "codexReasoningSummaryPreference",
+  codex_text_verbosity_preference: "codexTextVerbosityPreference",
+  codex_parallel_tool_calls_preference: "codexParallelToolCallsPreference",
+  anthropic_max_tokens_preference: "anthropicMaxTokensPreference",
+  gemini_google_search_preference: "geminiGoogleSearchPreference",
+  limit_5h_usd: "limit5hUsd",
+  limit_daily_usd: "limitDailyUsd",
+  daily_reset_mode: "dailyResetMode",
+  daily_reset_time: "dailyResetTime",
+  limit_weekly_usd: "limitWeeklyUsd",
+  limit_monthly_usd: "limitMonthlyUsd",
+  limit_total_usd: "limitTotalUsd",
+  limit_concurrent_sessions: "limitConcurrentSessions",
+  circuit_breaker_failure_threshold: "circuitBreakerFailureThreshold",
+  circuit_breaker_open_duration: "circuitBreakerOpenDuration",
+  circuit_breaker_half_open_success_threshold: "circuitBreakerHalfOpenSuccessThreshold",
+  max_retry_attempts: "maxRetryAttempts",
+  proxy_url: "proxyUrl",
+  proxy_fallback_to_direct: "proxyFallbackToDirect",
+  first_byte_timeout_streaming_ms: "firstByteTimeoutStreamingMs",
+  streaming_idle_timeout_ms: "streamingIdleTimeoutMs",
+  request_timeout_non_streaming_ms: "requestTimeoutNonStreamingMs",
+  mcp_passthrough_type: "mcpPassthroughType",
+  mcp_passthrough_url: "mcpPassthroughUrl",
 };
 
 const PATCH_FIELD_CLEAR_VALUE: Partial<Record<ProviderBatchPatchField, unknown>> = {
   anthropic_thinking_budget_preference: "inherit",
+  cache_ttl_preference: "inherit",
+  context_1m_preference: "inherit",
+  codex_reasoning_effort_preference: "inherit",
+  codex_reasoning_summary_preference: "inherit",
+  codex_text_verbosity_preference: "inherit",
+  codex_parallel_tool_calls_preference: "inherit",
+  anthropic_max_tokens_preference: "inherit",
+  gemini_google_search_preference: "inherit",
+  mcp_passthrough_type: "none",
 };
 
-const ANTHROPIC_ONLY_FIELDS: ReadonlySet<ProviderBatchPatchField> = new Set([
+const CLAUDE_ONLY_FIELDS: ReadonlySet<ProviderBatchPatchField> = new Set([
   "anthropic_thinking_budget_preference",
   "anthropic_adaptive_thinking",
+  "anthropic_max_tokens_preference",
+  "context_1m_preference",
+]);
+
+const CODEX_ONLY_FIELDS: ReadonlySet<ProviderBatchPatchField> = new Set([
+  "codex_reasoning_effort_preference",
+  "codex_reasoning_summary_preference",
+  "codex_text_verbosity_preference",
+  "codex_parallel_tool_calls_preference",
+]);
+
+const GEMINI_ONLY_FIELDS: ReadonlySet<ProviderBatchPatchField> = new Set([
+  "gemini_google_search_preference",
 ]);
 
 function isClaudeProviderType(providerType: ProviderType): boolean {
   return providerType === "claude" || providerType === "claude-auth";
 }
 
+function isCodexProviderType(providerType: ProviderType): boolean {
+  return providerType === "codex";
+}
+
+function isGeminiProviderType(providerType: ProviderType): boolean {
+  return providerType === "gemini" || providerType === "gemini-cli";
+}
+
 function computePreviewAfterValue(
   field: ProviderBatchPatchField,
   operation: ProviderPatchOperation<unknown>
@@ -1305,8 +1461,22 @@ function generatePreviewRows(
       const before = provider[providerKey];
       const after = computePreviewAfterValue(field, operation);
 
-      const isAnthropicOnly = ANTHROPIC_ONLY_FIELDS.has(field);
-      const isCompatible = !isAnthropicOnly || isClaudeProviderType(provider.providerType);
+      const isClaudeOnly = CLAUDE_ONLY_FIELDS.has(field);
+      const isCodexOnly = CODEX_ONLY_FIELDS.has(field);
+      const isGeminiOnly = GEMINI_ONLY_FIELDS.has(field);
+
+      let isCompatible = true;
+      let skipReason = "";
+      if (isClaudeOnly && !isClaudeProviderType(provider.providerType)) {
+        isCompatible = false;
+        skipReason = `Field "${field}" is only applicable to claude/claude-auth providers`;
+      } else if (isCodexOnly && !isCodexProviderType(provider.providerType)) {
+        isCompatible = false;
+        skipReason = `Field "${field}" is only applicable to codex providers`;
+      } else if (isGeminiOnly && !isGeminiProviderType(provider.providerType)) {
+        isCompatible = false;
+        skipReason = `Field "${field}" is only applicable to gemini/gemini-cli providers`;
+      }
 
       if (isCompatible) {
         rows.push({
@@ -1325,7 +1495,7 @@ function generatePreviewRows(
           status: "skipped",
           before,
           after,
-          skipReason: `Field "${field}" is only applicable to claude/claude-auth providers`,
+          skipReason,
         });
       }
     }

+ 296 - 0
src/app/[locale]/settings/providers/_components/batch-edit/build-patch-draft.ts

@@ -0,0 +1,296 @@
+import type { ProviderBatchPatchDraft } from "@/types/provider";
+import type { ProviderFormState } from "../forms/provider-form/provider-form-types";
+
+/**
+ * Builds a ProviderBatchPatchDraft from the current form state,
+ * including only fields that the user has actually modified (dirty fields).
+ *
+ * Unit conversions:
+ * - circuitBreaker.openDurationMinutes (minutes) -> circuit_breaker_open_duration (ms)
+ * - network.*Seconds (seconds) -> *_ms (ms)
+ */
+export function buildPatchDraftFromFormState(
+  state: ProviderFormState,
+  dirtyFields: Set<string>
+): ProviderBatchPatchDraft {
+  const draft: ProviderBatchPatchDraft = {};
+
+  // Batch-specific: isEnabled
+  if (dirtyFields.has("batch.isEnabled")) {
+    if (state.batch.isEnabled !== "no_change") {
+      draft.is_enabled = { set: state.batch.isEnabled === "true" };
+    }
+  }
+
+  // Routing fields
+  if (dirtyFields.has("routing.priority")) {
+    draft.priority = { set: state.routing.priority };
+  }
+  if (dirtyFields.has("routing.weight")) {
+    draft.weight = { set: state.routing.weight };
+  }
+  if (dirtyFields.has("routing.costMultiplier")) {
+    draft.cost_multiplier = { set: state.routing.costMultiplier };
+  }
+  if (dirtyFields.has("routing.groupTag")) {
+    const joined = state.routing.groupTag.join(", ");
+    if (joined === "") {
+      draft.group_tag = { clear: true };
+    } else {
+      draft.group_tag = { set: joined };
+    }
+  }
+  if (dirtyFields.has("routing.preserveClientIp")) {
+    draft.preserve_client_ip = { set: state.routing.preserveClientIp };
+  }
+  if (dirtyFields.has("routing.modelRedirects")) {
+    const entries = Object.keys(state.routing.modelRedirects);
+    if (entries.length === 0) {
+      draft.model_redirects = { clear: true };
+    } else {
+      draft.model_redirects = { set: state.routing.modelRedirects };
+    }
+  }
+  if (dirtyFields.has("routing.allowedModels")) {
+    if (state.routing.allowedModels.length === 0) {
+      draft.allowed_models = { clear: true };
+    } else {
+      draft.allowed_models = { set: state.routing.allowedModels };
+    }
+  }
+  if (dirtyFields.has("routing.groupPriorities")) {
+    const entries = Object.keys(state.routing.groupPriorities);
+    if (entries.length === 0) {
+      draft.group_priorities = { clear: true };
+    } else {
+      draft.group_priorities = { set: state.routing.groupPriorities };
+    }
+  }
+  if (dirtyFields.has("routing.cacheTtlPreference")) {
+    if (state.routing.cacheTtlPreference === "inherit") {
+      draft.cache_ttl_preference = { clear: true };
+    } else {
+      draft.cache_ttl_preference = { set: state.routing.cacheTtlPreference };
+    }
+  }
+  if (dirtyFields.has("routing.swapCacheTtlBilling")) {
+    draft.swap_cache_ttl_billing = { set: state.routing.swapCacheTtlBilling };
+  }
+  if (dirtyFields.has("routing.context1mPreference")) {
+    if (state.routing.context1mPreference === "inherit") {
+      draft.context_1m_preference = { clear: true };
+    } else {
+      draft.context_1m_preference = { set: state.routing.context1mPreference };
+    }
+  }
+
+  // Codex preferences
+  if (dirtyFields.has("routing.codexReasoningEffortPreference")) {
+    if (state.routing.codexReasoningEffortPreference === "inherit") {
+      draft.codex_reasoning_effort_preference = { clear: true };
+    } else {
+      draft.codex_reasoning_effort_preference = {
+        set: state.routing.codexReasoningEffortPreference,
+      };
+    }
+  }
+  if (dirtyFields.has("routing.codexReasoningSummaryPreference")) {
+    if (state.routing.codexReasoningSummaryPreference === "inherit") {
+      draft.codex_reasoning_summary_preference = { clear: true };
+    } else {
+      draft.codex_reasoning_summary_preference = {
+        set: state.routing.codexReasoningSummaryPreference,
+      };
+    }
+  }
+  if (dirtyFields.has("routing.codexTextVerbosityPreference")) {
+    if (state.routing.codexTextVerbosityPreference === "inherit") {
+      draft.codex_text_verbosity_preference = { clear: true };
+    } else {
+      draft.codex_text_verbosity_preference = { set: state.routing.codexTextVerbosityPreference };
+    }
+  }
+  if (dirtyFields.has("routing.codexParallelToolCallsPreference")) {
+    if (state.routing.codexParallelToolCallsPreference === "inherit") {
+      draft.codex_parallel_tool_calls_preference = { clear: true };
+    } else {
+      draft.codex_parallel_tool_calls_preference = {
+        set: state.routing.codexParallelToolCallsPreference,
+      };
+    }
+  }
+
+  // Anthropic preferences
+  if (dirtyFields.has("routing.anthropicMaxTokensPreference")) {
+    if (state.routing.anthropicMaxTokensPreference === "inherit") {
+      draft.anthropic_max_tokens_preference = { clear: true };
+    } else {
+      draft.anthropic_max_tokens_preference = { set: state.routing.anthropicMaxTokensPreference };
+    }
+  }
+  if (dirtyFields.has("routing.anthropicThinkingBudgetPreference")) {
+    if (state.routing.anthropicThinkingBudgetPreference === "inherit") {
+      draft.anthropic_thinking_budget_preference = { clear: true };
+    } else {
+      draft.anthropic_thinking_budget_preference = {
+        set: state.routing.anthropicThinkingBudgetPreference,
+      };
+    }
+  }
+  if (dirtyFields.has("routing.anthropicAdaptiveThinking")) {
+    if (state.routing.anthropicAdaptiveThinking === null) {
+      draft.anthropic_adaptive_thinking = { clear: true };
+    } else {
+      draft.anthropic_adaptive_thinking = { set: state.routing.anthropicAdaptiveThinking };
+    }
+  }
+
+  // Gemini preferences
+  if (dirtyFields.has("routing.geminiGoogleSearchPreference")) {
+    if (state.routing.geminiGoogleSearchPreference === "inherit") {
+      draft.gemini_google_search_preference = { clear: true };
+    } else {
+      draft.gemini_google_search_preference = { set: state.routing.geminiGoogleSearchPreference };
+    }
+  }
+
+  // Rate limit fields
+  if (dirtyFields.has("rateLimit.limit5hUsd")) {
+    if (state.rateLimit.limit5hUsd === null) {
+      draft.limit_5h_usd = { clear: true };
+    } else {
+      draft.limit_5h_usd = { set: state.rateLimit.limit5hUsd };
+    }
+  }
+  if (dirtyFields.has("rateLimit.limitDailyUsd")) {
+    if (state.rateLimit.limitDailyUsd === null) {
+      draft.limit_daily_usd = { clear: true };
+    } else {
+      draft.limit_daily_usd = { set: state.rateLimit.limitDailyUsd };
+    }
+  }
+  if (dirtyFields.has("rateLimit.dailyResetMode")) {
+    draft.daily_reset_mode = { set: state.rateLimit.dailyResetMode };
+  }
+  if (dirtyFields.has("rateLimit.dailyResetTime")) {
+    draft.daily_reset_time = { set: state.rateLimit.dailyResetTime };
+  }
+  if (dirtyFields.has("rateLimit.limitWeeklyUsd")) {
+    if (state.rateLimit.limitWeeklyUsd === null) {
+      draft.limit_weekly_usd = { clear: true };
+    } else {
+      draft.limit_weekly_usd = { set: state.rateLimit.limitWeeklyUsd };
+    }
+  }
+  if (dirtyFields.has("rateLimit.limitMonthlyUsd")) {
+    if (state.rateLimit.limitMonthlyUsd === null) {
+      draft.limit_monthly_usd = { clear: true };
+    } else {
+      draft.limit_monthly_usd = { set: state.rateLimit.limitMonthlyUsd };
+    }
+  }
+  if (dirtyFields.has("rateLimit.limitTotalUsd")) {
+    if (state.rateLimit.limitTotalUsd === null) {
+      draft.limit_total_usd = { clear: true };
+    } else {
+      draft.limit_total_usd = { set: state.rateLimit.limitTotalUsd };
+    }
+  }
+  if (dirtyFields.has("rateLimit.limitConcurrentSessions")) {
+    if (state.rateLimit.limitConcurrentSessions === null) {
+      draft.limit_concurrent_sessions = { set: 0 };
+    } else {
+      draft.limit_concurrent_sessions = { set: state.rateLimit.limitConcurrentSessions };
+    }
+  }
+
+  // Circuit breaker fields (minutes -> ms conversion for open duration)
+  if (dirtyFields.has("circuitBreaker.failureThreshold")) {
+    if (state.circuitBreaker.failureThreshold === undefined) {
+      draft.circuit_breaker_failure_threshold = { set: 0 };
+    } else {
+      draft.circuit_breaker_failure_threshold = { set: state.circuitBreaker.failureThreshold };
+    }
+  }
+  if (dirtyFields.has("circuitBreaker.openDurationMinutes")) {
+    if (state.circuitBreaker.openDurationMinutes === undefined) {
+      draft.circuit_breaker_open_duration = { set: 0 };
+    } else {
+      // Convert minutes to milliseconds
+      draft.circuit_breaker_open_duration = {
+        set: state.circuitBreaker.openDurationMinutes * 60000,
+      };
+    }
+  }
+  if (dirtyFields.has("circuitBreaker.halfOpenSuccessThreshold")) {
+    if (state.circuitBreaker.halfOpenSuccessThreshold === undefined) {
+      draft.circuit_breaker_half_open_success_threshold = { set: 0 };
+    } else {
+      draft.circuit_breaker_half_open_success_threshold = {
+        set: state.circuitBreaker.halfOpenSuccessThreshold,
+      };
+    }
+  }
+  if (dirtyFields.has("circuitBreaker.maxRetryAttempts")) {
+    if (state.circuitBreaker.maxRetryAttempts === null) {
+      draft.max_retry_attempts = { clear: true };
+    } else {
+      draft.max_retry_attempts = { set: state.circuitBreaker.maxRetryAttempts };
+    }
+  }
+
+  // Network fields (seconds -> ms conversion)
+  if (dirtyFields.has("network.proxyUrl")) {
+    if (state.network.proxyUrl === "") {
+      draft.proxy_url = { clear: true };
+    } else {
+      draft.proxy_url = { set: state.network.proxyUrl };
+    }
+  }
+  if (dirtyFields.has("network.proxyFallbackToDirect")) {
+    draft.proxy_fallback_to_direct = { set: state.network.proxyFallbackToDirect };
+  }
+  if (dirtyFields.has("network.firstByteTimeoutStreamingSeconds")) {
+    if (state.network.firstByteTimeoutStreamingSeconds === undefined) {
+      draft.first_byte_timeout_streaming_ms = { set: 0 };
+    } else {
+      draft.first_byte_timeout_streaming_ms = {
+        set: state.network.firstByteTimeoutStreamingSeconds * 1000,
+      };
+    }
+  }
+  if (dirtyFields.has("network.streamingIdleTimeoutSeconds")) {
+    if (state.network.streamingIdleTimeoutSeconds === undefined) {
+      draft.streaming_idle_timeout_ms = { set: 0 };
+    } else {
+      draft.streaming_idle_timeout_ms = { set: state.network.streamingIdleTimeoutSeconds * 1000 };
+    }
+  }
+  if (dirtyFields.has("network.requestTimeoutNonStreamingSeconds")) {
+    if (state.network.requestTimeoutNonStreamingSeconds === undefined) {
+      draft.request_timeout_non_streaming_ms = { set: 0 };
+    } else {
+      draft.request_timeout_non_streaming_ms = {
+        set: state.network.requestTimeoutNonStreamingSeconds * 1000,
+      };
+    }
+  }
+
+  // MCP fields
+  if (dirtyFields.has("mcp.mcpPassthroughType")) {
+    if (state.mcp.mcpPassthroughType === "none") {
+      draft.mcp_passthrough_type = { set: "none" };
+    } else {
+      draft.mcp_passthrough_type = { set: state.mcp.mcpPassthroughType };
+    }
+  }
+  if (dirtyFields.has("mcp.mcpPassthroughUrl")) {
+    if (state.mcp.mcpPassthroughUrl === "") {
+      draft.mcp_passthrough_url = { clear: true };
+    } else {
+      draft.mcp_passthrough_url = { set: state.mcp.mcpPassthroughUrl };
+    }
+  }
+
+  return draft;
+}

+ 326 - 419
src/app/[locale]/settings/providers/_components/batch-edit/provider-batch-dialog.tsx

@@ -6,10 +6,12 @@ import { useTranslations } from "next-intl";
 import { useCallback, useMemo, useState } from "react";
 import { toast } from "sonner";
 import {
-  type BatchUpdateProvidersParams,
+  applyProviderBatchPatch,
   batchDeleteProviders,
   batchResetProviderCircuits,
-  batchUpdateProviders,
+  type PreviewProviderBatchPatchResult,
+  previewProviderBatchPatch,
+  undoProviderPatch,
 } from "@/actions/providers";
 import {
   AlertDialog,
@@ -30,20 +32,20 @@ import {
   DialogHeader,
   DialogTitle,
 } from "@/components/ui/dialog";
-import { Input } from "@/components/ui/input";
-import { Label } from "@/components/ui/label";
+import type { ProviderDisplay } from "@/types/provider";
+import { FormTabNav } from "../forms/provider-form/components/form-tab-nav";
 import {
-  Select,
-  SelectContent,
-  SelectItem,
-  SelectTrigger,
-  SelectValue,
-} from "@/components/ui/select";
-import { Separator } from "@/components/ui/separator";
-import type { AnthropicAdaptiveThinkingConfig, ProviderDisplay } from "@/types/provider";
-import { AdaptiveThinkingEditor } from "../adaptive-thinking-editor";
-import { ThinkingBudgetEditor } from "../thinking-budget-editor";
+  ProviderFormProvider,
+  useProviderForm,
+} from "../forms/provider-form/provider-form-context";
+import { BasicInfoSection } from "../forms/provider-form/sections/basic-info-section";
+import { LimitsSection } from "../forms/provider-form/sections/limits-section";
+import { NetworkSection } from "../forms/provider-form/sections/network-section";
+import { RoutingSection } from "../forms/provider-form/sections/routing-section";
+import { TestingSection } from "../forms/provider-form/sections/testing-section";
+import { buildPatchDraftFromFormState } from "./build-patch-draft";
 import type { BatchActionMode } from "./provider-batch-actions";
+import { ProviderBatchPreviewStep } from "./provider-batch-preview-step";
 
 // ---------------------------------------------------------------------------
 // Props
@@ -58,36 +60,6 @@ export interface ProviderBatchDialogProps {
   onSuccess?: () => void;
 }
 
-// ---------------------------------------------------------------------------
-// State
-// ---------------------------------------------------------------------------
-
-interface BatchEditFieldState {
-  isEnabled: "no_change" | "true" | "false";
-  priority: string;
-  weight: string;
-  costMultiplier: string;
-  groupTag: string;
-  thinkingBudget: string;
-  adaptiveThinkingEnabled: "no_change" | "true" | "false";
-  adaptiveThinkingConfig: AnthropicAdaptiveThinkingConfig;
-}
-
-const INITIAL_EDIT_STATE: BatchEditFieldState = {
-  isEnabled: "no_change",
-  priority: "",
-  weight: "",
-  costMultiplier: "",
-  groupTag: "",
-  thinkingBudget: "",
-  adaptiveThinkingEnabled: "no_change",
-  adaptiveThinkingConfig: {
-    effort: "medium",
-    modelMatchMode: "all",
-    models: [],
-  },
-};
-
 // ---------------------------------------------------------------------------
 // Component
 // ---------------------------------------------------------------------------
@@ -100,150 +72,180 @@ export function ProviderBatchDialog({
   providers,
   onSuccess,
 }: ProviderBatchDialogProps) {
-  const t = useTranslations("settings.providers.batchEdit");
-  const queryClient = useQueryClient();
+  // For edit mode: delegate to form-based dialog
+  if (mode === "edit") {
+    return (
+      <BatchEditDialog
+        open={open}
+        onOpenChange={onOpenChange}
+        selectedProviderIds={selectedProviderIds}
+        providers={providers}
+        onSuccess={onSuccess}
+      />
+    );
+  }
 
-  const [editState, setEditState] = useState<BatchEditFieldState>(INITIAL_EDIT_STATE);
-  const [confirmOpen, setConfirmOpen] = useState(false);
-  const [isSubmitting, setIsSubmitting] = useState(false);
+  // For delete/resetCircuit: use AlertDialog
+  return (
+    <BatchConfirmDialog
+      open={open}
+      mode={mode}
+      onOpenChange={onOpenChange}
+      selectedProviderIds={selectedProviderIds}
+      providers={providers}
+      onSuccess={onSuccess}
+    />
+  );
+}
+
+// ---------------------------------------------------------------------------
+// BatchEditDialog: Uses ProviderFormProvider mode="batch"
+// ---------------------------------------------------------------------------
 
+function BatchEditDialog({
+  open,
+  onOpenChange,
+  selectedProviderIds,
+  providers,
+  onSuccess,
+}: Omit<ProviderBatchDialogProps, "mode">) {
   const selectedCount = selectedProviderIds.size;
 
-  // Affected providers: filter by selectedProviderIds
   const affectedProviders = useMemo(() => {
     return providers.filter((p) => selectedProviderIds.has(p.id));
   }, [providers, selectedProviderIds]);
 
-  // Check if any field has been changed from its default
-  const hasChanges = useMemo(() => {
-    if (mode !== "edit") return true;
-    return (
-      editState.isEnabled !== "no_change" ||
-      editState.priority !== "" ||
-      editState.weight !== "" ||
-      editState.costMultiplier !== "" ||
-      editState.groupTag !== "" ||
-      editState.thinkingBudget !== "" ||
-      editState.adaptiveThinkingEnabled !== "no_change"
-    );
-  }, [mode, editState]);
+  return (
+    <Dialog open={open} onOpenChange={onOpenChange}>
+      <DialogContent className="max-w-4xl max-h-[85vh] overflow-hidden flex flex-col">
+        <ProviderFormProvider
+          mode="batch"
+          enableMultiProviderTypes={false}
+          groupSuggestions={[]}
+          batchProviders={affectedProviders}
+        >
+          <BatchEditDialogContent
+            selectedProviderIds={selectedProviderIds}
+            selectedCount={selectedCount}
+            onOpenChange={onOpenChange}
+            onSuccess={onSuccess}
+          />
+        </ProviderFormProvider>
+      </DialogContent>
+    </Dialog>
+  );
+}
 
-  const resetState = useCallback(() => {
-    setEditState(INITIAL_EDIT_STATE);
-    setConfirmOpen(false);
-    setIsSubmitting(false);
-  }, []);
+// Inner component that can use useProviderForm()
+type DialogStep = "edit" | "preview";
+
+function BatchEditDialogContent({
+  selectedProviderIds,
+  selectedCount,
+  onOpenChange,
+  onSuccess,
+}: {
+  selectedProviderIds: Set<number>;
+  selectedCount: number;
+  onOpenChange: (open: boolean) => void;
+  onSuccess?: () => void;
+}) {
+  const t = useTranslations("settings.providers.batchEdit");
+  const queryClient = useQueryClient();
+  const { state, dispatch, dirtyFields } = useProviderForm();
 
-  const handleOpenChange = useCallback(
-    (newOpen: boolean) => {
-      if (!newOpen) {
-        resetState();
+  const [step, setStep] = useState<DialogStep>("edit");
+  const [isSubmitting, setIsSubmitting] = useState(false);
+  const [isLoadingPreview, setIsLoadingPreview] = useState(false);
+  const [previewResult, setPreviewResult] = useState<PreviewProviderBatchPatchResult | null>(null);
+  const [excludedProviderIds, setExcludedProviderIds] = useState<Set<number>>(new Set());
+
+  const hasChanges = dirtyFields.size > 0;
+
+  const handleExcludeToggle = useCallback((providerId: number) => {
+    setExcludedProviderIds((prev) => {
+      const next = new Set(prev);
+      if (next.has(providerId)) {
+        next.delete(providerId);
+      } else {
+        next.add(providerId);
       }
-      onOpenChange(newOpen);
-    },
-    [onOpenChange, resetState]
-  );
+      return next;
+    });
+  }, []);
 
-  const handleNext = useCallback(() => {
+  const handleNext = useCallback(async () => {
     if (!hasChanges) return;
-    setConfirmOpen(true);
-  }, [hasChanges]);
 
-  const handleConfirm = useCallback(async () => {
-    if (isSubmitting) return;
-    setIsSubmitting(true);
+    setIsLoadingPreview(true);
+    setStep("preview");
 
     try {
       const providerIds = Array.from(selectedProviderIds);
+      const patch = buildPatchDraftFromFormState(state, dirtyFields);
+      const result = await previewProviderBatchPatch({ providerIds, patch });
+
+      if (result.ok) {
+        setPreviewResult(result.data);
+      } else {
+        toast.error(t("toast.previewFailed", { error: result.error }));
+        setStep("edit");
+      }
+    } catch (error) {
+      const message = error instanceof Error ? error.message : "Unknown error";
+      toast.error(t("toast.previewFailed", { error: message }));
+      setStep("edit");
+    } finally {
+      setIsLoadingPreview(false);
+    }
+  }, [hasChanges, selectedProviderIds, state, dirtyFields, t]);
 
-      if (mode === "edit") {
-        const updates: BatchUpdateProvidersParams["updates"] = {};
-
-        // isEnabled
-        if (editState.isEnabled !== "no_change") {
-          updates.is_enabled = editState.isEnabled === "true";
-        }
-
-        // priority
-        if (editState.priority.trim()) {
-          const val = Number.parseInt(editState.priority, 10);
-          if (!Number.isNaN(val) && val >= 0) {
-            updates.priority = val;
-          }
-        }
-
-        // weight
-        if (editState.weight.trim()) {
-          const val = Number.parseInt(editState.weight, 10);
-          if (!Number.isNaN(val) && val >= 0) {
-            updates.weight = val;
-          }
-        }
-
-        // costMultiplier
-        if (editState.costMultiplier.trim()) {
-          const val = Number.parseFloat(editState.costMultiplier);
-          if (!Number.isNaN(val) && val >= 0) {
-            updates.cost_multiplier = val;
-          }
-        }
-
-        // groupTag
-        if (editState.groupTag !== "") {
-          if (editState.groupTag === "__clear__") {
-            updates.group_tag = null;
-          } else {
-            updates.group_tag = editState.groupTag.trim() || null;
-          }
-        }
-
-        // thinkingBudget
-        if (editState.thinkingBudget !== "") {
-          if (editState.thinkingBudget === "inherit") {
-            updates.anthropic_thinking_budget_preference = null;
-          } else {
-            updates.anthropic_thinking_budget_preference = editState.thinkingBudget;
-          }
-        }
+  const handleBackToEdit = useCallback(() => {
+    setStep("edit");
+    setPreviewResult(null);
+    setExcludedProviderIds(new Set());
+  }, []);
 
-        // adaptiveThinking
-        if (editState.adaptiveThinkingEnabled === "true") {
-          updates.anthropic_adaptive_thinking = editState.adaptiveThinkingConfig;
-        } else if (editState.adaptiveThinkingEnabled === "false") {
-          updates.anthropic_adaptive_thinking = null;
-        }
+  const handleApply = useCallback(async () => {
+    if (isSubmitting || !previewResult) return;
+    setIsSubmitting(true);
 
-        const result = await batchUpdateProviders({ providerIds, updates });
-        if (result.ok) {
-          toast.success(t("toast.updated", { count: result.data?.updatedCount ?? 0 }));
-        } else {
-          toast.error(t("toast.failed", { error: result.error }));
-          setIsSubmitting(false);
-          return;
-        }
-      } else if (mode === "delete") {
-        const result = await batchDeleteProviders({ providerIds });
-        if (result.ok) {
-          toast.success(t("toast.deleted", { count: result.data?.deletedCount ?? 0 }));
-        } else {
-          toast.error(t("toast.failed", { error: result.error }));
-          setIsSubmitting(false);
-          return;
-        }
-      } else if (mode === "resetCircuit") {
-        const result = await batchResetProviderCircuits({ providerIds });
-        if (result.ok) {
-          toast.success(t("toast.circuitReset", { count: result.data?.resetCount ?? 0 }));
-        } else {
-          toast.error(t("toast.failed", { error: result.error }));
-          setIsSubmitting(false);
-          return;
-        }
+    try {
+      const providerIds = Array.from(selectedProviderIds);
+      const patch = buildPatchDraftFromFormState(state, dirtyFields);
+      const result = await applyProviderBatchPatch({
+        previewToken: previewResult.previewToken,
+        previewRevision: previewResult.previewRevision,
+        providerIds,
+        patch,
+        excludeProviderIds: Array.from(excludedProviderIds),
+      });
+
+      if (result.ok) {
+        await queryClient.invalidateQueries({ queryKey: ["providers"] });
+        onOpenChange(false);
+        onSuccess?.();
+
+        const undoToken = result.data.undoToken;
+        const operationId = result.data.operationId;
+        toast.success(t("toast.updated", { count: result.data.updatedCount }), {
+          duration: 10000,
+          action: {
+            label: t("toast.undo"),
+            onClick: async () => {
+              const undoResult = await undoProviderPatch({ undoToken, operationId });
+              if (undoResult.ok) {
+                toast.success(t("toast.undoSuccess", { count: undoResult.data.revertedCount }));
+                queryClient.invalidateQueries({ queryKey: ["providers"] });
+              } else {
+                toast.error(t("toast.undoFailed", { error: undoResult.error }));
+              }
+            },
+          },
+        });
+      } else {
+        toast.error(t("toast.failed", { error: result.error }));
       }
-
-      await queryClient.invalidateQueries({ queryKey: ["providers"] });
-      handleOpenChange(false);
-      onSuccess?.();
     } catch (error) {
       const message = error instanceof Error ? error.message : "Unknown error";
       toast.error(t("toast.failed", { error: message }));
@@ -252,19 +254,116 @@ export function ProviderBatchDialog({
     }
   }, [
     isSubmitting,
+    previewResult,
     selectedProviderIds,
-    mode,
-    editState,
+    state,
+    dirtyFields,
+    excludedProviderIds,
     queryClient,
-    handleOpenChange,
+    onOpenChange,
     onSuccess,
     t,
   ]);
 
+  return (
+    <>
+      <DialogHeader>
+        <DialogTitle>{step === "preview" ? t("preview.title") : t("dialog.editTitle")}</DialogTitle>
+        <DialogDescription>
+          {step === "preview"
+            ? t("preview.description", { count: selectedCount })
+            : t("dialog.editDesc", { count: selectedCount })}
+        </DialogDescription>
+      </DialogHeader>
+
+      {step === "edit" && (
+        <div className="flex-1 overflow-hidden flex flex-col gap-4">
+          <FormTabNav
+            activeTab={state.ui.activeTab}
+            onTabChange={(tab) => dispatch({ type: "SET_ACTIVE_TAB", payload: tab })}
+          />
+          <div className="flex-1 overflow-y-auto pr-1">
+            {state.ui.activeTab === "basic" && <BasicInfoSection />}
+            {state.ui.activeTab === "routing" && <RoutingSection />}
+            {state.ui.activeTab === "limits" && <LimitsSection />}
+            {state.ui.activeTab === "network" && <NetworkSection />}
+            {state.ui.activeTab === "testing" && <TestingSection />}
+          </div>
+        </div>
+      )}
+
+      {step === "preview" && (
+        <div className="flex-1 overflow-y-auto py-4">
+          <ProviderBatchPreviewStep
+            rows={previewResult?.rows ?? []}
+            summary={previewResult?.summary ?? { providerCount: 0, fieldCount: 0, skipCount: 0 }}
+            excludedProviderIds={excludedProviderIds}
+            onExcludeToggle={handleExcludeToggle}
+            isLoading={isLoadingPreview}
+          />
+        </div>
+      )}
+
+      <DialogFooter>
+        {step === "preview" ? (
+          <>
+            <Button variant="outline" onClick={handleBackToEdit}>
+              {t("preview.back")}
+            </Button>
+            <Button
+              onClick={handleApply}
+              disabled={
+                isSubmitting ||
+                isLoadingPreview ||
+                !previewResult ||
+                previewResult.summary.fieldCount === 0
+              }
+            >
+              {isSubmitting ? (
+                <>
+                  <Loader2 className="mr-2 h-4 w-4 animate-spin" />
+                  {t("confirm.processing")}
+                </>
+              ) : (
+                t("preview.apply")
+              )}
+            </Button>
+          </>
+        ) : (
+          <>
+            <Button variant="outline" onClick={() => onOpenChange(false)}>
+              {t("confirm.cancel")}
+            </Button>
+            <Button onClick={handleNext} disabled={!hasChanges}>
+              {t("dialog.next")}
+            </Button>
+          </>
+        )}
+      </DialogFooter>
+    </>
+  );
+}
+
+// ---------------------------------------------------------------------------
+// BatchConfirmDialog: Delete / Reset Circuit (unchanged)
+// ---------------------------------------------------------------------------
+
+function BatchConfirmDialog({
+  open,
+  mode,
+  onOpenChange,
+  selectedProviderIds,
+  providers: _providers,
+  onSuccess,
+}: ProviderBatchDialogProps) {
+  const t = useTranslations("settings.providers.batchEdit");
+  const queryClient = useQueryClient();
+  const [isSubmitting, setIsSubmitting] = useState(false);
+
+  const selectedCount = selectedProviderIds.size;
+
   const dialogTitle = useMemo(() => {
     switch (mode) {
-      case "edit":
-        return t("dialog.editTitle");
       case "delete":
         return t("dialog.deleteTitle");
       case "resetCircuit":
@@ -276,8 +375,6 @@ export function ProviderBatchDialog({
 
   const dialogDescription = useMemo(() => {
     switch (mode) {
-      case "edit":
-        return t("dialog.editDesc", { count: selectedCount });
       case "delete":
         return t("dialog.deleteDesc", { count: selectedCount });
       case "resetCircuit":
@@ -287,255 +384,65 @@ export function ProviderBatchDialog({
     }
   }, [mode, selectedCount, t]);
 
-  return (
-    <>
-      <Dialog open={open && !confirmOpen} onOpenChange={handleOpenChange}>
-        <DialogContent className="sm:max-w-lg">
-          <DialogHeader>
-            <DialogTitle>{dialogTitle}</DialogTitle>
-            <DialogDescription>{dialogDescription}</DialogDescription>
-          </DialogHeader>
-
-          {mode === "edit" && (
-            <div className="space-y-6 py-4 max-h-[60vh] overflow-y-auto">
-              {/* Affected Provider Summary */}
-              <AffectedProviderSummary providers={affectedProviders} />
-
-              {/* Section 1: Basic Settings */}
-              <SectionBlock title={t("sections.basic")} dataSection="basic">
-                {/* isEnabled - three-state select */}
-                <div className="flex items-center justify-between gap-4" data-field="isEnabled">
-                  <Label className="text-sm whitespace-nowrap">{t("fields.isEnabled.label")}</Label>
-                  <Select
-                    value={editState.isEnabled}
-                    onValueChange={(v) =>
-                      setEditState((s) => ({
-                        ...s,
-                        isEnabled: v as "no_change" | "true" | "false",
-                      }))
-                    }
-                  >
-                    <SelectTrigger className="w-40">
-                      <SelectValue />
-                    </SelectTrigger>
-                    <SelectContent>
-                      <SelectItem value="no_change">{t("fields.isEnabled.noChange")}</SelectItem>
-                      <SelectItem value="true">{t("fields.isEnabled.enable")}</SelectItem>
-                      <SelectItem value="false">{t("fields.isEnabled.disable")}</SelectItem>
-                    </SelectContent>
-                  </Select>
-                </div>
-
-                {/* priority */}
-                <div className="flex items-center justify-between gap-4" data-field="priority">
-                  <Label className="text-sm whitespace-nowrap">{t("fields.priority")}</Label>
-                  <Input
-                    type="number"
-                    min="0"
-                    step="1"
-                    value={editState.priority}
-                    onChange={(e) => setEditState((s) => ({ ...s, priority: e.target.value }))}
-                    placeholder="0"
-                    className="w-24"
-                  />
-                </div>
-
-                {/* weight */}
-                <div className="flex items-center justify-between gap-4" data-field="weight">
-                  <Label className="text-sm whitespace-nowrap">{t("fields.weight")}</Label>
-                  <Input
-                    type="number"
-                    min="0"
-                    step="1"
-                    value={editState.weight}
-                    onChange={(e) => setEditState((s) => ({ ...s, weight: e.target.value }))}
-                    placeholder="1"
-                    className="w-24"
-                  />
-                </div>
-
-                {/* costMultiplier */}
-                <div
-                  className="flex items-center justify-between gap-4"
-                  data-field="costMultiplier"
-                >
-                  <Label className="text-sm whitespace-nowrap">{t("fields.costMultiplier")}</Label>
-                  <Input
-                    type="number"
-                    min="0"
-                    step="0.0001"
-                    value={editState.costMultiplier}
-                    onChange={(e) =>
-                      setEditState((s) => ({ ...s, costMultiplier: e.target.value }))
-                    }
-                    placeholder="1.0"
-                    className="w-24"
-                  />
-                </div>
-              </SectionBlock>
-
-              <Separator />
-
-              {/* Section 2: Group & Routing */}
-              <SectionBlock title={t("sections.routing")} dataSection="routing">
-                {/* groupTag */}
-                <div className="flex items-center justify-between gap-4" data-field="groupTag">
-                  <Label className="text-sm whitespace-nowrap">{t("fields.groupTag.label")}</Label>
-                  <div className="flex gap-2">
-                    <Input
-                      type="text"
-                      value={editState.groupTag}
-                      onChange={(e) => setEditState((s) => ({ ...s, groupTag: e.target.value }))}
-                      placeholder="tag1, tag2"
-                      className="w-40"
-                    />
-                    <Button
-                      size="sm"
-                      variant="outline"
-                      onClick={() => setEditState((s) => ({ ...s, groupTag: "__clear__" }))}
-                    >
-                      {t("fields.groupTag.clear")}
-                    </Button>
-                  </div>
-                </div>
-
-                {/* modelRedirects - coming soon */}
-                <div className="flex items-center justify-between gap-4">
-                  <Label className="text-sm whitespace-nowrap text-muted-foreground">
-                    {t("fields.modelRedirects")}
-                  </Label>
-                  <span className="text-sm text-muted-foreground">{t("fields.comingSoon")}</span>
-                </div>
-
-                {/* allowedModels - coming soon */}
-                <div className="flex items-center justify-between gap-4">
-                  <Label className="text-sm whitespace-nowrap text-muted-foreground">
-                    {t("fields.allowedModels")}
-                  </Label>
-                  <span className="text-sm text-muted-foreground">{t("fields.comingSoon")}</span>
-                </div>
-              </SectionBlock>
-
-              <Separator />
-
-              {/* Section 3: Anthropic Settings */}
-              <SectionBlock title={t("sections.anthropic")} dataSection="anthropic">
-                {/* ThinkingBudgetEditor */}
-                <div className="space-y-2" data-field="thinkingBudget">
-                  <Label className="text-sm">{t("fields.thinkingBudget")}</Label>
-                  <ThinkingBudgetEditor
-                    value={editState.thinkingBudget || "inherit"}
-                    onChange={(v) => setEditState((s) => ({ ...s, thinkingBudget: v }))}
-                  />
-                </div>
-
-                {/* AdaptiveThinkingEditor */}
-                <div className="space-y-2" data-field="adaptiveThinking">
-                  <Label className="text-sm">{t("fields.adaptiveThinking")}</Label>
-                  <AdaptiveThinkingEditor
-                    enabled={editState.adaptiveThinkingEnabled === "true"}
-                    config={editState.adaptiveThinkingConfig}
-                    onEnabledChange={(val) =>
-                      setEditState((s) => ({
-                        ...s,
-                        adaptiveThinkingEnabled: val ? "true" : "false",
-                      }))
-                    }
-                    onConfigChange={(config) =>
-                      setEditState((s) => ({ ...s, adaptiveThinkingConfig: config }))
-                    }
-                  />
-                </div>
-              </SectionBlock>
-            </div>
-          )}
-
-          {(mode === "delete" || mode === "resetCircuit") && (
-            <div className="py-4 text-sm text-muted-foreground">{dialogDescription}</div>
-          )}
-
-          <DialogFooter>
-            <Button variant="outline" onClick={() => handleOpenChange(false)}>
-              {t("confirm.cancel")}
-            </Button>
-            <Button onClick={handleNext} disabled={!hasChanges}>
-              {t("dialog.next")}
-            </Button>
-          </DialogFooter>
-        </DialogContent>
-      </Dialog>
-
-      <AlertDialog open={confirmOpen} onOpenChange={setConfirmOpen}>
-        <AlertDialogContent>
-          <AlertDialogHeader>
-            <AlertDialogTitle>{t("confirm.title")}</AlertDialogTitle>
-            <AlertDialogDescription>{dialogDescription}</AlertDialogDescription>
-          </AlertDialogHeader>
-          <AlertDialogFooter>
-            <AlertDialogCancel disabled={isSubmitting}>{t("confirm.goBack")}</AlertDialogCancel>
-            <AlertDialogAction onClick={handleConfirm} disabled={isSubmitting}>
-              {isSubmitting ? (
-                <>
-                  <Loader2 className="mr-2 h-4 w-4 animate-spin" />
-                  {t("confirm.processing")}
-                </>
-              ) : (
-                t("confirm.confirm")
-              )}
-            </AlertDialogAction>
-          </AlertDialogFooter>
-        </AlertDialogContent>
-      </AlertDialog>
-    </>
-  );
-}
-
-// ---------------------------------------------------------------------------
-// Sub-components
-// ---------------------------------------------------------------------------
-
-const MAX_DISPLAYED_PROVIDERS = 5;
-
-function AffectedProviderSummary({ providers }: { providers: ProviderDisplay[] }) {
-  const t = useTranslations("settings.providers.batchEdit");
+  const handleConfirm = useCallback(async () => {
+    if (isSubmitting) return;
+    setIsSubmitting(true);
 
-  if (providers.length === 0) return null;
+    try {
+      const providerIds = Array.from(selectedProviderIds);
 
-  const displayed = providers.slice(0, MAX_DISPLAYED_PROVIDERS);
-  const remaining = providers.length - displayed.length;
+      if (mode === "delete") {
+        const result = await batchDeleteProviders({ providerIds });
+        if (result.ok) {
+          toast.success(t("toast.deleted", { count: result.data?.deletedCount ?? 0 }));
+        } else {
+          toast.error(t("toast.failed", { error: result.error }));
+          setIsSubmitting(false);
+          return;
+        }
+      } else if (mode === "resetCircuit") {
+        const result = await batchResetProviderCircuits({ providerIds });
+        if (result.ok) {
+          toast.success(t("toast.circuitReset", { count: result.data?.resetCount ?? 0 }));
+        } else {
+          toast.error(t("toast.failed", { error: result.error }));
+          setIsSubmitting(false);
+          return;
+        }
+      }
 
-  return (
-    <div className="rounded-md border bg-muted/50 p-3 text-sm" data-testid="affected-summary">
-      <p className="font-medium">
-        {t("affectedProviders.title")} ({providers.length})
-      </p>
-      <div className="mt-1 space-y-0.5 text-muted-foreground">
-        {displayed.map((p) => (
-          <p key={p.id}>
-            {p.name} ({p.maskedKey})
-          </p>
-        ))}
-        {remaining > 0 && (
-          <p className="text-xs">{t("affectedProviders.more", { count: remaining })}</p>
-        )}
-      </div>
-    </div>
-  );
-}
+      await queryClient.invalidateQueries({ queryKey: ["providers"] });
+      onOpenChange(false);
+      onSuccess?.();
+    } catch (error) {
+      const message = error instanceof Error ? error.message : "Unknown error";
+      toast.error(t("toast.failed", { error: message }));
+    } finally {
+      setIsSubmitting(false);
+    }
+  }, [isSubmitting, selectedProviderIds, mode, queryClient, onOpenChange, onSuccess, t]);
 
-function SectionBlock({
-  title,
-  dataSection,
-  children,
-}: {
-  title: string;
-  dataSection: string;
-  children: React.ReactNode;
-}) {
   return (
-    <div className="space-y-3" data-section={dataSection}>
-      <h4 className="text-sm font-medium">{title}</h4>
-      <div className="space-y-3 pl-1">{children}</div>
-    </div>
+    <AlertDialog open={open} onOpenChange={onOpenChange}>
+      <AlertDialogContent>
+        <AlertDialogHeader>
+          <AlertDialogTitle>{dialogTitle}</AlertDialogTitle>
+          <AlertDialogDescription>{dialogDescription}</AlertDialogDescription>
+        </AlertDialogHeader>
+        <AlertDialogFooter>
+          <AlertDialogCancel disabled={isSubmitting}>{t("confirm.goBack")}</AlertDialogCancel>
+          <AlertDialogAction onClick={handleConfirm} disabled={isSubmitting}>
+            {isSubmitting ? (
+              <>
+                <Loader2 className="mr-2 h-4 w-4 animate-spin" />
+                {t("confirm.processing")}
+              </>
+            ) : (
+              t("confirm.confirm")
+            )}
+          </AlertDialogAction>
+        </AlertDialogFooter>
+      </AlertDialogContent>
+    </AlertDialog>
   );
 }

+ 153 - 0
src/app/[locale]/settings/providers/_components/batch-edit/provider-batch-preview-step.tsx

@@ -0,0 +1,153 @@
+"use client";
+
+import { Loader2 } from "lucide-react";
+import { useTranslations } from "next-intl";
+import { useMemo } from "react";
+import type { ProviderBatchPreviewRow } from "@/actions/providers";
+import { Checkbox } from "@/components/ui/checkbox";
+
+// ---------------------------------------------------------------------------
+// Props
+// ---------------------------------------------------------------------------
+
+export interface ProviderBatchPreviewStepProps {
+  rows: ProviderBatchPreviewRow[];
+  summary: { providerCount: number; fieldCount: number; skipCount: number };
+  excludedProviderIds: Set<number>;
+  onExcludeToggle: (providerId: number) => void;
+  isLoading?: boolean;
+}
+
+// ---------------------------------------------------------------------------
+// Types
+// ---------------------------------------------------------------------------
+
+interface ProviderGroup {
+  providerId: number;
+  providerName: string;
+  rows: ProviderBatchPreviewRow[];
+}
+
+// ---------------------------------------------------------------------------
+// Component
+// ---------------------------------------------------------------------------
+
+export function ProviderBatchPreviewStep({
+  rows,
+  summary,
+  excludedProviderIds,
+  onExcludeToggle,
+  isLoading,
+}: ProviderBatchPreviewStepProps) {
+  const t = useTranslations("settings.providers.batchEdit");
+
+  const grouped = useMemo(() => {
+    const map = new Map<number, ProviderGroup>();
+    for (const row of rows) {
+      let group = map.get(row.providerId);
+      if (!group) {
+        group = { providerId: row.providerId, providerName: row.providerName, rows: [] };
+        map.set(row.providerId, group);
+      }
+      group.rows.push(row);
+    }
+    return Array.from(map.values());
+  }, [rows]);
+
+  if (isLoading) {
+    return (
+      <div
+        className="flex items-center justify-center gap-2 py-8 text-sm text-muted-foreground"
+        data-testid="preview-loading"
+      >
+        <Loader2 className="h-4 w-4 animate-spin" />
+        <span>{t("preview.loading")}</span>
+      </div>
+    );
+  }
+
+  if (rows.length === 0) {
+    return (
+      <div className="py-8 text-center text-sm text-muted-foreground" data-testid="preview-empty">
+        {t("preview.noChanges")}
+      </div>
+    );
+  }
+
+  return (
+    <div className="space-y-4" data-testid="preview-step">
+      {/* Summary */}
+      <p className="text-sm text-muted-foreground" data-testid="preview-summary">
+        {t("preview.summary", {
+          providerCount: summary.providerCount,
+          fieldCount: summary.fieldCount,
+          skipCount: summary.skipCount,
+        })}
+      </p>
+
+      {/* Provider groups */}
+      <div className="max-h-[50vh] space-y-3 overflow-y-auto">
+        {grouped.map((group) => {
+          const excluded = excludedProviderIds.has(group.providerId);
+          return (
+            <div
+              key={group.providerId}
+              className="rounded-md border p-3 text-sm"
+              data-testid={`preview-provider-${group.providerId}`}
+            >
+              {/* Provider header with exclusion checkbox */}
+              <div className="flex items-center gap-2">
+                <Checkbox
+                  checked={!excluded}
+                  onCheckedChange={() => onExcludeToggle(group.providerId)}
+                  aria-label={t("preview.excludeProvider")}
+                  data-testid={`exclude-checkbox-${group.providerId}`}
+                />
+                <span className="font-medium">
+                  {t("preview.providerHeader", { name: group.providerName })}
+                </span>
+              </div>
+
+              {/* Field rows */}
+              <div className="mt-2 space-y-1 pl-6">
+                {group.rows.map((row) => (
+                  <div
+                    key={`${row.providerId}-${row.field}`}
+                    className={
+                      row.status === "skipped" ? "text-muted-foreground" : "text-foreground"
+                    }
+                    data-testid={`preview-row-${row.providerId}-${row.field}`}
+                    data-status={row.status}
+                  >
+                    {row.status === "changed"
+                      ? t("preview.fieldChanged", {
+                          field: row.field,
+                          before: formatValue(row.before),
+                          after: formatValue(row.after),
+                        })
+                      : t("preview.fieldSkipped", {
+                          field: row.field,
+                          reason: row.skipReason ?? "",
+                        })}
+                  </div>
+                ))}
+              </div>
+            </div>
+          );
+        })}
+      </div>
+    </div>
+  );
+}
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+function formatValue(value: unknown): string {
+  if (value === null || value === undefined) return "null";
+  if (typeof value === "boolean") return String(value);
+  if (typeof value === "number") return String(value);
+  if (typeof value === "string") return value;
+  return JSON.stringify(value);
+}

+ 171 - 16
src/app/[locale]/settings/providers/_components/forms/provider-form/provider-form-context.tsx

@@ -1,6 +1,15 @@
 "use client";
 
-import { createContext, type ReactNode, useContext, useReducer } from "react";
+import {
+  createContext,
+  type Dispatch,
+  type ReactNode,
+  useCallback,
+  useContext,
+  useMemo,
+  useReducer,
+  useRef,
+} from "react";
 import type { ProviderDisplay, ProviderType } from "@/types/provider";
 import type {
   FormMode,
@@ -9,6 +18,52 @@ import type {
   ProviderFormState,
 } from "./provider-form-types";
 
+// Maps action types to dirty field paths for batch mode tracking
+const ACTION_TO_FIELD_PATH: Partial<Record<ProviderFormAction["type"], string>> = {
+  SET_BATCH_IS_ENABLED: "batch.isEnabled",
+  SET_PRIORITY: "routing.priority",
+  SET_WEIGHT: "routing.weight",
+  SET_COST_MULTIPLIER: "routing.costMultiplier",
+  SET_GROUP_TAG: "routing.groupTag",
+  SET_PRESERVE_CLIENT_IP: "routing.preserveClientIp",
+  SET_MODEL_REDIRECTS: "routing.modelRedirects",
+  SET_ALLOWED_MODELS: "routing.allowedModels",
+  SET_GROUP_PRIORITIES: "routing.groupPriorities",
+  SET_CACHE_TTL_PREFERENCE: "routing.cacheTtlPreference",
+  SET_SWAP_CACHE_TTL_BILLING: "routing.swapCacheTtlBilling",
+  SET_CONTEXT_1M_PREFERENCE: "routing.context1mPreference",
+  SET_CODEX_REASONING_EFFORT: "routing.codexReasoningEffortPreference",
+  SET_CODEX_REASONING_SUMMARY: "routing.codexReasoningSummaryPreference",
+  SET_CODEX_TEXT_VERBOSITY: "routing.codexTextVerbosityPreference",
+  SET_CODEX_PARALLEL_TOOL_CALLS: "routing.codexParallelToolCallsPreference",
+  SET_ANTHROPIC_MAX_TOKENS: "routing.anthropicMaxTokensPreference",
+  SET_ANTHROPIC_THINKING_BUDGET: "routing.anthropicThinkingBudgetPreference",
+  SET_ADAPTIVE_THINKING_ENABLED: "routing.anthropicAdaptiveThinking",
+  SET_ADAPTIVE_THINKING_EFFORT: "routing.anthropicAdaptiveThinking",
+  SET_ADAPTIVE_THINKING_MODEL_MATCH_MODE: "routing.anthropicAdaptiveThinking",
+  SET_ADAPTIVE_THINKING_MODELS: "routing.anthropicAdaptiveThinking",
+  SET_GEMINI_GOOGLE_SEARCH: "routing.geminiGoogleSearchPreference",
+  SET_LIMIT_5H_USD: "rateLimit.limit5hUsd",
+  SET_LIMIT_DAILY_USD: "rateLimit.limitDailyUsd",
+  SET_DAILY_RESET_MODE: "rateLimit.dailyResetMode",
+  SET_DAILY_RESET_TIME: "rateLimit.dailyResetTime",
+  SET_LIMIT_WEEKLY_USD: "rateLimit.limitWeeklyUsd",
+  SET_LIMIT_MONTHLY_USD: "rateLimit.limitMonthlyUsd",
+  SET_LIMIT_TOTAL_USD: "rateLimit.limitTotalUsd",
+  SET_LIMIT_CONCURRENT_SESSIONS: "rateLimit.limitConcurrentSessions",
+  SET_FAILURE_THRESHOLD: "circuitBreaker.failureThreshold",
+  SET_OPEN_DURATION_MINUTES: "circuitBreaker.openDurationMinutes",
+  SET_HALF_OPEN_SUCCESS_THRESHOLD: "circuitBreaker.halfOpenSuccessThreshold",
+  SET_MAX_RETRY_ATTEMPTS: "circuitBreaker.maxRetryAttempts",
+  SET_PROXY_URL: "network.proxyUrl",
+  SET_PROXY_FALLBACK_TO_DIRECT: "network.proxyFallbackToDirect",
+  SET_FIRST_BYTE_TIMEOUT_STREAMING: "network.firstByteTimeoutStreamingSeconds",
+  SET_STREAMING_IDLE_TIMEOUT: "network.streamingIdleTimeoutSeconds",
+  SET_REQUEST_TIMEOUT_NON_STREAMING: "network.requestTimeoutNonStreamingSeconds",
+  SET_MCP_PASSTHROUGH_TYPE: "mcp.mcpPassthroughType",
+  SET_MCP_PASSTHROUGH_URL: "mcp.mcpPassthroughUrl",
+};
+
 // Initial state factory
 export function createInitialState(
   mode: FormMode,
@@ -22,9 +77,72 @@ export function createInitialState(
   }
 ): ProviderFormState {
   const isEdit = mode === "edit";
+  const isBatch = mode === "batch";
   const raw = isEdit ? provider : cloneProvider;
   const sourceProvider = raw ? structuredClone(raw) : undefined;
 
+  // Batch mode: all fields start at neutral defaults (no provider source)
+  if (isBatch) {
+    return {
+      basic: { name: "", url: "", key: "", websiteUrl: "" },
+      routing: {
+        providerType: "claude",
+        groupTag: [],
+        preserveClientIp: false,
+        modelRedirects: {},
+        allowedModels: [],
+        priority: 0,
+        groupPriorities: {},
+        weight: 1,
+        costMultiplier: 1.0,
+        cacheTtlPreference: "inherit",
+        swapCacheTtlBilling: false,
+        context1mPreference: "inherit",
+        codexReasoningEffortPreference: "inherit",
+        codexReasoningSummaryPreference: "inherit",
+        codexTextVerbosityPreference: "inherit",
+        codexParallelToolCallsPreference: "inherit",
+        anthropicMaxTokensPreference: "inherit",
+        anthropicThinkingBudgetPreference: "inherit",
+        anthropicAdaptiveThinking: null,
+        geminiGoogleSearchPreference: "inherit",
+      },
+      rateLimit: {
+        limit5hUsd: null,
+        limitDailyUsd: null,
+        dailyResetMode: "fixed",
+        dailyResetTime: "00:00",
+        limitWeeklyUsd: null,
+        limitMonthlyUsd: null,
+        limitTotalUsd: null,
+        limitConcurrentSessions: null,
+      },
+      circuitBreaker: {
+        failureThreshold: undefined,
+        openDurationMinutes: undefined,
+        halfOpenSuccessThreshold: undefined,
+        maxRetryAttempts: null,
+      },
+      network: {
+        proxyUrl: "",
+        proxyFallbackToDirect: false,
+        firstByteTimeoutStreamingSeconds: undefined,
+        streamingIdleTimeoutSeconds: undefined,
+        requestTimeoutNonStreamingSeconds: undefined,
+      },
+      mcp: {
+        mcpPassthroughType: "none",
+        mcpPassthroughUrl: "",
+      },
+      batch: { isEnabled: "no_change" },
+      ui: {
+        activeTab: "basic",
+        isPending: false,
+        showFailureThresholdConfirm: false,
+      },
+    };
+  }
+
   return {
     basic: {
       name: isEdit
@@ -105,6 +223,7 @@ export function createInitialState(
       mcpPassthroughType: sourceProvider?.mcpPassthroughType ?? "none",
       mcpPassthroughUrl: sourceProvider?.mcpPassthroughUrl ?? "",
     },
+    batch: { isEnabled: "no_change" },
     ui: {
       activeTab: "basic",
       isPending: false,
@@ -317,6 +436,10 @@ export function providerFormReducer(
     case "SET_MCP_PASSTHROUGH_URL":
       return { ...state, mcp: { ...state.mcp, mcpPassthroughUrl: action.payload } };
 
+    // Batch
+    case "SET_BATCH_IS_ENABLED":
+      return { ...state, batch: { ...state.batch, isEnabled: action.payload } };
+
     // UI
     case "SET_ACTIVE_TAB":
       return { ...state, ui: { ...state.ui, activeTab: action.payload } };
@@ -357,6 +480,7 @@ export function ProviderFormProvider({
   hideWebsiteUrl = false,
   preset,
   groupSuggestions,
+  batchProviders,
 }: {
   children: ReactNode;
   mode: FormMode;
@@ -372,27 +496,58 @@ export function ProviderFormProvider({
     providerType?: ProviderType;
   };
   groupSuggestions: string[];
+  batchProviders?: ProviderDisplay[];
 }) {
-  const [state, dispatch] = useReducer(
+  const [state, rawDispatch] = useReducer(
     providerFormReducer,
     createInitialState(mode, provider, cloneProvider, preset)
   );
 
+  const dirtyFieldsRef = useRef(new Set<string>());
+  const isBatch = mode === "batch";
+
+  // Wrap dispatch for batch mode to auto-track dirty fields
+  const dispatch: Dispatch<ProviderFormAction> = useCallback(
+    (action: ProviderFormAction) => {
+      if (isBatch) {
+        const fieldPath = ACTION_TO_FIELD_PATH[action.type];
+        if (fieldPath) {
+          dirtyFieldsRef.current.add(fieldPath);
+        }
+      }
+      rawDispatch(action);
+    },
+    [isBatch]
+  );
+
+  const contextValue = useMemo<ProviderFormContextValue>(
+    () => ({
+      state,
+      dispatch,
+      mode,
+      provider,
+      enableMultiProviderTypes,
+      hideUrl,
+      hideWebsiteUrl,
+      groupSuggestions,
+      batchProviders,
+      dirtyFields: dirtyFieldsRef.current,
+    }),
+    [
+      state,
+      dispatch,
+      mode,
+      provider,
+      enableMultiProviderTypes,
+      hideUrl,
+      hideWebsiteUrl,
+      groupSuggestions,
+      batchProviders,
+    ]
+  );
+
   return (
-    <ProviderFormContext.Provider
-      value={{
-        state,
-        dispatch,
-        mode,
-        provider,
-        enableMultiProviderTypes,
-        hideUrl,
-        hideWebsiteUrl,
-        groupSuggestions,
-      }}
-    >
-      {children}
-    </ProviderFormContext.Provider>
+    <ProviderFormContext.Provider value={contextValue}>{children}</ProviderFormContext.Provider>
   );
 }
 

+ 11 - 2
src/app/[locale]/settings/providers/_components/forms/provider-form/provider-form-types.ts

@@ -16,7 +16,7 @@ import type {
 } from "@/types/provider";
 
 // Form mode
-export type FormMode = "create" | "edit";
+export type FormMode = "create" | "edit" | "batch";
 
 // Tab identifiers
 export type TabId = "basic" | "routing" | "limits" | "network" | "testing";
@@ -93,6 +93,10 @@ export interface McpState {
   mcpPassthroughUrl: string;
 }
 
+export interface BatchState {
+  isEnabled: "no_change" | "true" | "false";
+}
+
 export interface UIState {
   activeTab: TabId;
   isPending: boolean;
@@ -107,6 +111,7 @@ export interface ProviderFormState {
   circuitBreaker: CircuitBreakerState;
   network: NetworkState;
   mcp: McpState;
+  batch: BatchState;
   ui: UIState;
 }
 
@@ -173,7 +178,9 @@ export type ProviderFormAction =
   | { type: "SET_SHOW_FAILURE_THRESHOLD_CONFIRM"; payload: boolean }
   // Bulk actions
   | { type: "RESET_FORM" }
-  | { type: "LOAD_PROVIDER"; payload: ProviderDisplay };
+  | { type: "LOAD_PROVIDER"; payload: ProviderDisplay }
+  // Batch actions
+  | { type: "SET_BATCH_IS_ENABLED"; payload: "no_change" | "true" | "false" };
 
 // Form props
 export interface ProviderFormProps {
@@ -204,4 +211,6 @@ export interface ProviderFormContextValue {
   hideUrl: boolean;
   hideWebsiteUrl: boolean;
   groupSuggestions: string[];
+  batchProviders?: ProviderDisplay[];
+  dirtyFields: Set<string>;
 }

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

@@ -7,6 +7,13 @@ import { useEffect, useMemo, useRef, useState } from "react";
 import { ProviderEndpointsSection } from "@/app/[locale]/settings/providers/_components/provider-endpoints-table";
 import { InlineWarning } from "@/components/ui/inline-warning";
 import { Input } from "@/components/ui/input";
+import {
+  Select,
+  SelectContent,
+  SelectItem,
+  SelectTrigger,
+  SelectValue,
+} from "@/components/ui/select";
 import { detectApiKeyWarnings } from "@/lib/utils/validation/api-key-warnings";
 import type { ProviderType } from "@/types/provider";
 import { UrlPreview } from "../../url-preview";
@@ -14,6 +21,8 @@ import { QuickPasteDialog } from "../components/quick-paste-dialog";
 import { SectionCard, SmartInputWrapper } from "../components/section-card";
 import { useProviderForm } from "../provider-form-context";
 
+const MAX_DISPLAYED_PROVIDERS = 5;
+
 interface BasicInfoSectionProps {
   autoUrlPending?: boolean;
   endpointPool?: {
@@ -25,21 +34,95 @@ interface BasicInfoSectionProps {
 
 export function BasicInfoSection({ autoUrlPending, endpointPool }: BasicInfoSectionProps) {
   const t = useTranslations("settings.providers.form");
+  const tBatch = useTranslations("settings.providers.batchEdit");
   const tProviders = useTranslations("settings.providers");
-  const { state, dispatch, mode, provider, hideUrl, hideWebsiteUrl } = useProviderForm();
+  const { state, dispatch, mode, provider, hideUrl, hideWebsiteUrl, batchProviders } =
+    useProviderForm();
   const isEdit = mode === "edit";
+  const isBatch = mode === "batch";
   const nameInputRef = useRef<HTMLInputElement>(null);
   const [showKey, setShowKey] = useState(false);
 
   const apiKeyWarnings = useMemo(() => detectApiKeyWarnings(state.basic.key), [state.basic.key]);
 
-  // Auto-focus name input
+  // Auto-focus name input (skip in batch mode)
   useEffect(() => {
+    if (isBatch) return;
     const timer = setTimeout(() => {
       nameInputRef.current?.focus();
     }, 100);
     return () => clearTimeout(timer);
-  }, []);
+  }, [isBatch]);
+
+  // Batch mode: only isEnabled tri-state + provider summary
+  if (isBatch) {
+    const providers = batchProviders ?? [];
+    const displayed = providers.slice(0, MAX_DISPLAYED_PROVIDERS);
+    const remaining = providers.length - displayed.length;
+
+    return (
+      <motion.div
+        initial={{ opacity: 0, x: 20 }}
+        animate={{ opacity: 1, x: 0 }}
+        exit={{ opacity: 0, x: -20 }}
+        transition={{ duration: 0.2 }}
+        className="space-y-6"
+      >
+        <SectionCard
+          title={t("sections.basic.identity.title")}
+          description={tBatch("dialog.editDesc", { count: providers.length })}
+          icon={User}
+          variant="highlight"
+        >
+          <div className="space-y-4">
+            <SmartInputWrapper label={tBatch("fields.isEnabled.label")}>
+              <Select
+                value={state.batch.isEnabled}
+                onValueChange={(v) =>
+                  dispatch({
+                    type: "SET_BATCH_IS_ENABLED",
+                    payload: v as "no_change" | "true" | "false",
+                  })
+                }
+              >
+                <SelectTrigger className="w-40">
+                  <SelectValue />
+                </SelectTrigger>
+                <SelectContent>
+                  <SelectItem value="no_change">{tBatch("fields.isEnabled.noChange")}</SelectItem>
+                  <SelectItem value="true">{tBatch("fields.isEnabled.enable")}</SelectItem>
+                  <SelectItem value="false">{tBatch("fields.isEnabled.disable")}</SelectItem>
+                </SelectContent>
+              </Select>
+            </SmartInputWrapper>
+
+            {providers.length > 0 && (
+              <div
+                className="rounded-md border bg-muted/50 p-3 text-sm"
+                data-testid="affected-summary"
+              >
+                <p className="font-medium">
+                  {tBatch("affectedProviders.title")} ({providers.length})
+                </p>
+                <div className="mt-1 space-y-0.5 text-muted-foreground">
+                  {displayed.map((p) => (
+                    <p key={p.id}>
+                      {p.name} ({p.maskedKey})
+                    </p>
+                  ))}
+                  {remaining > 0 && (
+                    <p className="text-xs">
+                      {tBatch("affectedProviders.more", { count: remaining })}
+                    </p>
+                  )}
+                </div>
+              </div>
+            )}
+          </div>
+        </SectionCard>
+      </motion.div>
+    );
+  }
 
   return (
     <motion.div

+ 19 - 14
src/app/[locale]/settings/providers/_components/forms/provider-form/sections/network-section.tsx

@@ -114,6 +114,7 @@ export function NetworkSection() {
   const t = useTranslations("settings.providers.form");
   const { state, dispatch, mode } = useProviderForm();
   const isEdit = mode === "edit";
+  const isBatch = mode === "batch";
 
   return (
     <motion.div
@@ -171,22 +172,26 @@ export function NetworkSection() {
                 />
               </ToggleRow>
 
-              {/* Proxy Test */}
-              <div className="flex items-center justify-between p-3 rounded-lg bg-muted/30 border border-border/50">
-                <div className="flex items-center gap-3">
-                  <Wifi className="h-4 w-4 text-primary" />
-                  <div className="space-y-0.5">
-                    <div className="text-sm font-medium">{t("sections.proxy.test.label")}</div>
-                    <p className="text-xs text-muted-foreground">{t("sections.proxy.test.desc")}</p>
+              {/* Proxy Test - hidden in batch mode */}
+              {!isBatch && (
+                <div className="flex items-center justify-between p-3 rounded-lg bg-muted/30 border border-border/50">
+                  <div className="flex items-center gap-3">
+                    <Wifi className="h-4 w-4 text-primary" />
+                    <div className="space-y-0.5">
+                      <div className="text-sm font-medium">{t("sections.proxy.test.label")}</div>
+                      <p className="text-xs text-muted-foreground">
+                        {t("sections.proxy.test.desc")}
+                      </p>
+                    </div>
                   </div>
+                  <ProxyTestButton
+                    providerUrl={state.basic.url}
+                    proxyUrl={state.network.proxyUrl}
+                    proxyFallbackToDirect={state.network.proxyFallbackToDirect}
+                    disabled={state.ui.isPending || !state.basic.url.trim()}
+                  />
                 </div>
-                <ProxyTestButton
-                  providerUrl={state.basic.url}
-                  proxyUrl={state.network.proxyUrl}
-                  proxyFallbackToDirect={state.network.proxyFallbackToDirect}
-                  disabled={state.ui.isPending || !state.basic.url.trim()}
-                />
-              </div>
+              )}
             </motion.div>
           )}
         </div>

+ 100 - 81
src/app/[locale]/settings/providers/_components/forms/provider-form/sections/routing-section.tsx

@@ -36,10 +36,13 @@ const GROUP_TAG_MAX_TOTAL_LENGTH = 50;
 
 export function RoutingSection() {
   const t = useTranslations("settings.providers.form");
+  const tBatch = useTranslations("settings.providers.batchEdit");
   const tUI = useTranslations("ui.tagInput");
   const { state, dispatch, mode, provider, enableMultiProviderTypes, groupSuggestions } =
     useProviderForm();
   const isEdit = mode === "edit";
+  const isBatch = mode === "batch";
+  const { providerType } = state.routing;
 
   const renderProviderTypeLabel = (type: ProviderType) => {
     switch (type) {
@@ -76,78 +79,81 @@ export function RoutingSection() {
         transition={{ duration: 0.2 }}
         className="space-y-6"
       >
-        {/* Provider Type & Group */}
-        <SectionCard
-          title={t("sections.routing.providerType.label")}
-          description={t("sections.routing.providerTypeDesc")}
-          icon={Route}
-          variant="highlight"
-        >
-          <div className="space-y-4">
-            <SmartInputWrapper label={t("sections.routing.providerType.label")}>
-              <Select
-                value={state.routing.providerType}
-                onValueChange={(value) =>
-                  dispatch({ type: "SET_PROVIDER_TYPE", payload: value as ProviderType })
-                }
-                disabled={state.ui.isPending}
-              >
-                <SelectTrigger id={isEdit ? "edit-provider-type" : "provider-type"}>
-                  <SelectValue placeholder={t("sections.routing.providerType.placeholder")} />
-                </SelectTrigger>
-                <SelectContent>
-                  {providerTypes.map((type) => {
-                    const typeConfig = getProviderTypeConfig(type);
-                    const TypeIcon = typeConfig.icon;
-                    const label = renderProviderTypeLabel(type);
-                    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>
-                          <span>{label}</span>
-                        </div>
-                      </SelectItem>
-                    );
-                  })}
-                </SelectContent>
-              </Select>
-              {!enableMultiProviderTypes && state.routing.providerType === "openai-compatible" && (
-                <p className="text-xs text-amber-600">
-                  {t("sections.routing.providerTypeDisabledNote")}
-                </p>
-              )}
-            </SmartInputWrapper>
+        {/* Provider Type & Group - hidden in batch mode */}
+        {!isBatch && (
+          <SectionCard
+            title={t("sections.routing.providerType.label")}
+            description={t("sections.routing.providerTypeDesc")}
+            icon={Route}
+            variant="highlight"
+          >
+            <div className="space-y-4">
+              <SmartInputWrapper label={t("sections.routing.providerType.label")}>
+                <Select
+                  value={state.routing.providerType}
+                  onValueChange={(value) =>
+                    dispatch({ type: "SET_PROVIDER_TYPE", payload: value as ProviderType })
+                  }
+                  disabled={state.ui.isPending}
+                >
+                  <SelectTrigger id={isEdit ? "edit-provider-type" : "provider-type"}>
+                    <SelectValue placeholder={t("sections.routing.providerType.placeholder")} />
+                  </SelectTrigger>
+                  <SelectContent>
+                    {providerTypes.map((type) => {
+                      const typeConfig = getProviderTypeConfig(type);
+                      const TypeIcon = typeConfig.icon;
+                      const label = renderProviderTypeLabel(type);
+                      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>
+                            <span>{label}</span>
+                          </div>
+                        </SelectItem>
+                      );
+                    })}
+                  </SelectContent>
+                </Select>
+                {!enableMultiProviderTypes &&
+                  state.routing.providerType === "openai-compatible" && (
+                    <p className="text-xs text-amber-600">
+                      {t("sections.routing.providerTypeDisabledNote")}
+                    </p>
+                  )}
+              </SmartInputWrapper>
 
-            <SmartInputWrapper
-              label={t("sections.routing.scheduleParams.group.label")}
-              description={t("sections.routing.scheduleParams.group.desc")}
-            >
-              <TagInput
-                id={isEdit ? "edit-group" : "group"}
-                value={state.routing.groupTag}
-                onChange={handleGroupTagChange}
-                placeholder={t("sections.routing.scheduleParams.group.placeholder")}
-                disabled={state.ui.isPending}
-                maxTagLength={GROUP_TAG_MAX_TOTAL_LENGTH}
-                suggestions={groupSuggestions}
-                onInvalidTag={(_tag, reason) => {
-                  const messages: Record<string, string> = {
-                    empty: tUI("emptyTag"),
-                    duplicate: tUI("duplicateTag"),
-                    too_long: tUI("tooLong", { max: GROUP_TAG_MAX_TOTAL_LENGTH }),
-                    invalid_format: tUI("invalidFormat"),
-                    max_tags: tUI("maxTags"),
-                  };
-                  toast.error(messages[reason] || reason);
-                }}
-              />
-            </SmartInputWrapper>
-          </div>
-        </SectionCard>
+              <SmartInputWrapper
+                label={t("sections.routing.scheduleParams.group.label")}
+                description={t("sections.routing.scheduleParams.group.desc")}
+              >
+                <TagInput
+                  id={isEdit ? "edit-group" : "group"}
+                  value={state.routing.groupTag}
+                  onChange={handleGroupTagChange}
+                  placeholder={t("sections.routing.scheduleParams.group.placeholder")}
+                  disabled={state.ui.isPending}
+                  maxTagLength={GROUP_TAG_MAX_TOTAL_LENGTH}
+                  suggestions={groupSuggestions}
+                  onInvalidTag={(_tag, reason) => {
+                    const messages: Record<string, string> = {
+                      empty: tUI("emptyTag"),
+                      duplicate: tUI("duplicateTag"),
+                      too_long: tUI("tooLong", { max: GROUP_TAG_MAX_TOTAL_LENGTH }),
+                      invalid_format: tUI("invalidFormat"),
+                      max_tags: tUI("maxTags"),
+                    };
+                    toast.error(messages[reason] || reason);
+                  }}
+                />
+              </SmartInputWrapper>
+            </div>
+          </SectionCard>
+        )}
 
         {/* Model Configuration */}
         <SectionCard
@@ -385,8 +391,8 @@ export function RoutingSection() {
               </Select>
             </SmartInputWrapper>
 
-            {/* 1M Context Window - Claude type only */}
-            {state.routing.providerType === "claude" && (
+            {/* 1M Context Window - Claude type only (or batch mode) */}
+            {(providerType === "claude" || isBatch) && (
               <SmartInputWrapper
                 label={t("sections.routing.context1m.label")}
                 description={t("sections.routing.context1m.desc")}
@@ -421,12 +427,17 @@ export function RoutingSection() {
           </div>
         </SectionCard>
 
-        {/* Codex Overrides - Codex type only */}
-        {state.routing.providerType === "codex" && (
+        {/* Codex Overrides - Codex type only (or batch mode) */}
+        {(providerType === "codex" || isBatch) && (
           <SectionCard
             title={t("sections.routing.codexOverrides.title")}
             description={t("sections.routing.codexOverrides.desc")}
             icon={Timer}
+            badge={
+              isBatch ? (
+                <Badge variant="outline">{tBatch("batchNotes.codexOnly")}</Badge>
+              ) : undefined
+            }
           >
             <div className="space-y-4">
               <SmartInputWrapper label={t("sections.routing.codexOverrides.reasoningEffort.label")}>
@@ -548,13 +559,17 @@ export function RoutingSection() {
           </SectionCard>
         )}
 
-        {/* Anthropic Overrides - Claude type only */}
-        {(state.routing.providerType === "claude" ||
-          state.routing.providerType === "claude-auth") && (
+        {/* Anthropic Overrides - Claude type only (or batch mode) */}
+        {(providerType === "claude" || providerType === "claude-auth" || isBatch) && (
           <SectionCard
             title={t("sections.routing.anthropicOverrides.maxTokens.label")}
             description={t("sections.routing.anthropicOverrides.maxTokens.help")}
             icon={Timer}
+            badge={
+              isBatch ? (
+                <Badge variant="outline">{tBatch("batchNotes.claudeOnly")}</Badge>
+              ) : undefined
+            }
           >
             <div className="space-y-4">
               <SmartInputWrapper label={t("sections.routing.anthropicOverrides.maxTokens.label")}>
@@ -659,13 +674,17 @@ export function RoutingSection() {
           </SectionCard>
         )}
 
-        {/* Gemini Overrides - Gemini type only */}
-        {(state.routing.providerType === "gemini" ||
-          state.routing.providerType === "gemini-cli") && (
+        {/* Gemini Overrides - Gemini type only (or batch mode) */}
+        {(providerType === "gemini" || providerType === "gemini-cli" || isBatch) && (
           <SectionCard
             title={t("sections.routing.geminiOverrides.title")}
             description={t("sections.routing.geminiOverrides.desc")}
             icon={Settings}
+            badge={
+              isBatch ? (
+                <Badge variant="outline">{tBatch("batchNotes.geminiOnly")}</Badge>
+              ) : undefined
+            }
           >
             <SmartInputWrapper label={t("sections.routing.geminiOverrides.googleSearch.label")}>
               <Select

+ 39 - 36
src/app/[locale]/settings/providers/_components/forms/provider-form/sections/testing-section.tsx

@@ -21,6 +21,7 @@ export function TestingSection() {
   const t = useTranslations("settings.providers.form");
   const { state, dispatch, mode, provider, enableMultiProviderTypes } = useProviderForm();
   const isEdit = mode === "edit";
+  const isBatch = mode === "batch";
 
   return (
     <motion.div
@@ -30,47 +31,49 @@ export function TestingSection() {
       transition={{ duration: 0.2 }}
       className="space-y-6"
     >
-      {/* API Test */}
-      <SectionCard
-        title={t("sections.apiTest.title")}
-        description={t("sections.apiTest.desc")}
-        icon={FlaskConical}
-        variant="highlight"
-      >
-        <div className="space-y-4">
-          {/* Test Summary */}
-          <div className="flex items-center gap-3 p-3 rounded-lg bg-muted/30 border border-border/50">
-            <Zap className="h-4 w-4 text-primary" />
-            <div className="flex-1 text-xs text-muted-foreground">
-              {t("sections.apiTest.summary")}
+      {/* API Test - hidden in batch mode */}
+      {!isBatch && (
+        <SectionCard
+          title={t("sections.apiTest.title")}
+          description={t("sections.apiTest.desc")}
+          icon={FlaskConical}
+          variant="highlight"
+        >
+          <div className="space-y-4">
+            {/* Test Summary */}
+            <div className="flex items-center gap-3 p-3 rounded-lg bg-muted/30 border border-border/50">
+              <Zap className="h-4 w-4 text-primary" />
+              <div className="flex-1 text-xs text-muted-foreground">
+                {t("sections.apiTest.summary")}
+              </div>
             </div>
-          </div>
 
-          {/* API Test Button */}
-          <div className="p-4 rounded-lg bg-card/50 border border-border/50 space-y-4">
-            <div className="flex items-center gap-3">
-              <span className="flex items-center justify-center w-10 h-10 rounded-lg bg-primary/10 text-primary">
-                <FlaskConical className="h-5 w-5" />
-              </span>
-              <div className="space-y-0.5">
-                <div className="text-sm font-medium">{t("sections.apiTest.testLabel")}</div>
-                <p className="text-xs text-muted-foreground">{t("sections.apiTest.desc")}</p>
+            {/* API Test Button */}
+            <div className="p-4 rounded-lg bg-card/50 border border-border/50 space-y-4">
+              <div className="flex items-center gap-3">
+                <span className="flex items-center justify-center w-10 h-10 rounded-lg bg-primary/10 text-primary">
+                  <FlaskConical className="h-5 w-5" />
+                </span>
+                <div className="space-y-0.5">
+                  <div className="text-sm font-medium">{t("sections.apiTest.testLabel")}</div>
+                  <p className="text-xs text-muted-foreground">{t("sections.apiTest.desc")}</p>
+                </div>
               </div>
+              <ApiTestButton
+                providerUrl={state.basic.url}
+                apiKey={state.basic.key}
+                proxyUrl={state.network.proxyUrl}
+                proxyFallbackToDirect={state.network.proxyFallbackToDirect}
+                providerId={isEdit ? provider?.id : undefined}
+                providerType={state.routing.providerType}
+                allowedModels={state.routing.allowedModels}
+                enableMultiProviderTypes={enableMultiProviderTypes}
+                disabled={state.ui.isPending || !state.basic.url.trim()}
+              />
             </div>
-            <ApiTestButton
-              providerUrl={state.basic.url}
-              apiKey={state.basic.key}
-              proxyUrl={state.network.proxyUrl}
-              proxyFallbackToDirect={state.network.proxyFallbackToDirect}
-              providerId={isEdit ? provider?.id : undefined}
-              providerType={state.routing.providerType}
-              allowedModels={state.routing.allowedModels}
-              enableMultiProviderTypes={enableMultiProviderTypes}
-              disabled={state.ui.isPending || !state.basic.url.trim()}
-            />
           </div>
-        </div>
-      </SectionCard>
+        </SectionCard>
+      )}
 
       {/* MCP Passthrough */}
       <SectionCard

+ 28 - 6
src/app/api/auth/login/route.ts

@@ -103,11 +103,33 @@ function shouldIncludeFailureTaxonomy(request: NextRequest): boolean {
 }
 
 function getClientIp(request: NextRequest): string {
-  return (
-    request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
-    request.headers.get("x-real-ip")?.trim() ||
-    "unknown"
-  );
+  // 1. Next.js platform-provided IP (trusted in Vercel / managed deployments)
+  const platformIp = (request as unknown as { ip?: string }).ip;
+  if (platformIp) {
+    return platformIp;
+  }
+
+  // 2. x-real-ip is typically set by the closest trusted reverse proxy
+  const realIp = request.headers.get("x-real-ip")?.trim();
+  if (realIp) {
+    return realIp;
+  }
+
+  // 3. x-forwarded-for: take the rightmost (last) entry, which is the IP
+  //    appended by the closest trusted proxy. The leftmost entry is
+  //    client-controlled and can be spoofed.
+  const forwarded = request.headers.get("x-forwarded-for");
+  if (forwarded) {
+    const ips = forwarded
+      .split(",")
+      .map((s) => s.trim())
+      .filter(Boolean);
+    if (ips.length > 0) {
+      return ips[ips.length - 1];
+    }
+  }
+
+  return "unknown";
 }
 
 let sessionStoreInstance:
@@ -165,7 +187,7 @@ export async function POST(request: NextRequest) {
   try {
     const { key } = await request.json();
 
-    if (!key) {
+    if (!key || typeof key !== "string") {
       if (!shouldIncludeFailureTaxonomy(request)) {
         return withAuthResponseHeaders(
           NextResponse.json(

+ 13 - 7
src/app/v1/_lib/cors.ts

@@ -15,12 +15,21 @@ const DEFAULT_CORS_HEADERS: Record<string, string> = {
 /**
  * 动态构建 CORS 响应头
  */
-function buildCorsHeaders(options: { origin?: string | null; requestHeaders?: string | null }) {
+function buildCorsHeaders(options: {
+  origin?: string | null;
+  requestHeaders?: string | null;
+  allowCredentials?: boolean;
+}) {
   const headers = new Headers(DEFAULT_CORS_HEADERS);
 
-  if (options.origin) {
+  // Only reflect specific origin when credentials are explicitly opted-in.
+  // The proxy API uses Bearer tokens; reflecting arbitrary origins with
+  // credentials enabled would let any malicious site make credentialed
+  // cross-origin requests.
+  if (options.allowCredentials && options.origin) {
     headers.set("Access-Control-Allow-Origin", options.origin);
     headers.append("Vary", "Origin");
+    headers.set("Access-Control-Allow-Credentials", "true");
   }
 
   if (options.requestHeaders) {
@@ -28,10 +37,6 @@ function buildCorsHeaders(options: { origin?: string | null; requestHeaders?: st
     headers.append("Vary", "Access-Control-Request-Headers");
   }
 
-  if (headers.get("Access-Control-Allow-Origin") !== "*") {
-    headers.set("Access-Control-Allow-Credentials", "true");
-  }
-
   return headers;
 }
 
@@ -75,7 +80,7 @@ function mergeVaryHeader(existing: string | null, newValue: string): string {
  */
 export function applyCors(
   res: Response,
-  ctx: { origin?: string | null; requestHeaders?: string | null }
+  ctx: { origin?: string | null; requestHeaders?: string | null; allowCredentials?: boolean }
 ): Response {
   const corsHeaders = buildCorsHeaders(ctx);
 
@@ -138,6 +143,7 @@ export function applyCors(
 export function buildPreflightResponse(options: {
   origin?: string | null;
   requestHeaders?: string | null;
+  allowCredentials?: boolean;
 }): Response {
   return new Response(null, { status: 204, headers: buildCorsHeaders(options) });
 }

+ 59 - 0
src/app/v1/_lib/proxy/auth-guard.ts

@@ -1,12 +1,67 @@
 import { logger } from "@/lib/logger";
+import { LoginAbusePolicy } from "@/lib/security/login-abuse-policy";
 import { validateApiKeyAndGetUser } from "@/repository/key";
 import { markUserExpired } from "@/repository/user";
 import { GEMINI_PROTOCOL } from "../gemini/protocol";
 import { ProxyResponses } from "./responses";
 import type { AuthState, ProxySession } from "./session";
 
+/**
+ * Pre-auth rate limiter: throttles repeated authentication failures per IP
+ * to prevent brute-force API key enumeration on /v1/* endpoints.
+ *
+ * Uses the same LoginAbusePolicy as the login route but with separate
+ * thresholds appropriate for programmatic API access.
+ */
+const proxyAuthPolicy = new LoginAbusePolicy({
+  maxAttemptsPerIp: 20,
+  maxAttemptsPerKey: 20,
+  windowSeconds: 300,
+  lockoutSeconds: 600,
+});
+
+function extractClientIp(session: ProxySession): string {
+  // Prefer x-real-ip (set by trusted reverse proxy), then rightmost
+  // x-forwarded-for entry, avoiding the client-spoofable leftmost value.
+  const realIp = session.headers.get("x-real-ip")?.trim();
+  if (realIp) return realIp;
+
+  const forwarded = session.headers.get("x-forwarded-for");
+  if (forwarded) {
+    const ips = forwarded
+      .split(",")
+      .map((s) => s.trim())
+      .filter(Boolean);
+    if (ips.length > 0) return ips[ips.length - 1];
+  }
+
+  return "unknown";
+}
+
 export class ProxyAuthenticator {
   static async ensure(session: ProxySession): Promise<Response | null> {
+    // Pre-auth rate limit: block IPs with too many recent auth failures
+    const clientIp = extractClientIp(session);
+    const rateLimitDecision = proxyAuthPolicy.check(clientIp);
+    if (!rateLimitDecision.allowed) {
+      const retryAfter = rateLimitDecision.retryAfterSeconds;
+      const response = ProxyResponses.buildError(
+        429,
+        "Too many authentication failures. Please retry later.",
+        "rate_limit_error"
+      );
+      if (retryAfter != null) {
+        const headers = new Headers(response.headers);
+        headers.set("Retry-After", String(retryAfter));
+        return new Response(response.body, {
+          status: response.status,
+          statusText: response.statusText,
+          headers,
+        });
+      }
+      return response;
+    }
+
     const authHeader = session.headers.get("authorization") ?? undefined;
     const apiKeyHeader = session.headers.get("x-api-key") ?? undefined;
     // Gemini CLI 认证:支持 x-goog-api-key 头部和 key 查询参数
@@ -22,9 +77,13 @@ export class ProxyAuthenticator {
     session.setAuthState(authState);
 
     if (authState.success) {
+      proxyAuthPolicy.recordSuccess(clientIp);
       return null;
     }
 
+    // Record failure for rate limiting
+    proxyAuthPolicy.recordFailure(clientIp);
+
     // 返回详细的错误信息,帮助用户快速定位问题
     return authState.errorResponse ?? ProxyResponses.buildError(401, "认证失败");
   }

+ 6 - 3
src/lib/auth.ts

@@ -3,6 +3,7 @@ import type { NextResponse } from "next/server";
 import { config } from "@/lib/config/config";
 import { getEnvConfig } from "@/lib/config/env.schema";
 import { logger } from "@/lib/logger";
+import { constantTimeEqual } from "@/lib/security/constant-time-compare";
 import { findKeyList, validateApiKeyAndGetUser } from "@/repository/key";
 import type { Key } from "@/types/key";
 import type { User } from "@/types/user";
@@ -166,7 +167,7 @@ export async function validateKey(
   const allowReadOnlyAccess = options?.allowReadOnlyAccess ?? false;
 
   const adminToken = config.auth.adminToken;
-  if (adminToken && keyString === adminToken) {
+  if (adminToken && constantTimeEqual(keyString, adminToken)) {
     const now = new Date();
     const adminUser: User = {
       id: -1,
@@ -362,14 +363,16 @@ async function convertToAuthSession(
     const adminToken = config.auth.adminToken;
     if (!adminToken) return null;
     const adminFingerprint = await toKeyFingerprint(adminToken);
-    return adminFingerprint === expectedFingerprint ? validateKey(adminToken, options) : null;
+    return constantTimeEqual(adminFingerprint, expectedFingerprint)
+      ? validateKey(adminToken, options)
+      : null;
   }
 
   const keyList = await findKeyList(sessionData.userId);
 
   for (const key of keyList) {
     const keyFingerprint = await toKeyFingerprint(key.key);
-    if (keyFingerprint === expectedFingerprint) {
+    if (constantTimeEqual(keyFingerprint, expectedFingerprint)) {
       return validateKey(key.key, options);
     }
   }

+ 574 - 1
src/lib/provider-patch-contract.ts

@@ -33,6 +33,41 @@ const PATCH_FIELDS: ProviderBatchPatchField[] = [
   "allowed_models",
   "anthropic_thinking_budget_preference",
   "anthropic_adaptive_thinking",
+  // Routing
+  "preserve_client_ip",
+  "group_priorities",
+  "cache_ttl_preference",
+  "swap_cache_ttl_billing",
+  "context_1m_preference",
+  "codex_reasoning_effort_preference",
+  "codex_reasoning_summary_preference",
+  "codex_text_verbosity_preference",
+  "codex_parallel_tool_calls_preference",
+  "anthropic_max_tokens_preference",
+  "gemini_google_search_preference",
+  // Rate Limit
+  "limit_5h_usd",
+  "limit_daily_usd",
+  "daily_reset_mode",
+  "daily_reset_time",
+  "limit_weekly_usd",
+  "limit_monthly_usd",
+  "limit_total_usd",
+  "limit_concurrent_sessions",
+  // Circuit Breaker
+  "circuit_breaker_failure_threshold",
+  "circuit_breaker_open_duration",
+  "circuit_breaker_half_open_success_threshold",
+  "max_retry_attempts",
+  // Network
+  "proxy_url",
+  "proxy_fallback_to_direct",
+  "first_byte_timeout_streaming_ms",
+  "streaming_idle_timeout_ms",
+  "request_timeout_non_streaming_ms",
+  // MCP
+  "mcp_passthrough_type",
+  "mcp_passthrough_url",
 ];
 const PATCH_FIELD_SET = new Set(PATCH_FIELDS);
 
@@ -46,6 +81,41 @@ const CLEARABLE_FIELDS: Record<ProviderBatchPatchField, boolean> = {
   allowed_models: true,
   anthropic_thinking_budget_preference: true,
   anthropic_adaptive_thinking: true,
+  // Routing
+  preserve_client_ip: false,
+  group_priorities: true,
+  cache_ttl_preference: true,
+  swap_cache_ttl_billing: false,
+  context_1m_preference: true,
+  codex_reasoning_effort_preference: true,
+  codex_reasoning_summary_preference: true,
+  codex_text_verbosity_preference: true,
+  codex_parallel_tool_calls_preference: true,
+  anthropic_max_tokens_preference: true,
+  gemini_google_search_preference: true,
+  // Rate Limit
+  limit_5h_usd: true,
+  limit_daily_usd: true,
+  daily_reset_mode: false,
+  daily_reset_time: false,
+  limit_weekly_usd: true,
+  limit_monthly_usd: true,
+  limit_total_usd: true,
+  limit_concurrent_sessions: false,
+  // Circuit Breaker
+  circuit_breaker_failure_threshold: false,
+  circuit_breaker_open_duration: false,
+  circuit_breaker_half_open_success_threshold: false,
+  max_retry_attempts: true,
+  // Network
+  proxy_url: true,
+  proxy_fallback_to_direct: false,
+  first_byte_timeout_streaming_ms: false,
+  streaming_idle_timeout_ms: false,
+  request_timeout_non_streaming_ms: false,
+  // MCP
+  mcp_passthrough_type: false,
+  mcp_passthrough_url: true,
 };
 
 function isStringRecord(value: unknown): value is Record<string, string> {
@@ -58,6 +128,14 @@ function isStringRecord(value: unknown): value is Record<string, string> {
   );
 }
 
+function isNumberRecord(value: unknown): value is Record<string, number> {
+  if (!isRecord(value) || Array.isArray(value)) {
+    return false;
+  }
+
+  return Object.values(value).every((v) => typeof v === "number" && Number.isFinite(v));
+}
+
 function isAdaptiveThinkingConfig(
   value: unknown
 ): value is NonNullable<ProviderBatchApplyUpdates["anthropic_adaptive_thinking"]> {
@@ -104,18 +182,84 @@ function isThinkingBudgetPreference(value: unknown): boolean {
   return parsed >= 1024 && parsed <= 32000;
 }
 
+function isMaxTokensPreference(value: unknown): boolean {
+  if (value === "inherit") {
+    return true;
+  }
+
+  if (typeof value !== "string") {
+    return false;
+  }
+
+  if (!/^\d+$/.test(value)) {
+    return false;
+  }
+
+  const parsed = Number.parseInt(value, 10);
+  return parsed > 0;
+}
+
 function isValidSetValue(field: ProviderBatchPatchField, value: unknown): boolean {
   switch (field) {
     case "is_enabled":
+    case "preserve_client_ip":
+    case "swap_cache_ttl_billing":
+    case "proxy_fallback_to_direct":
       return typeof value === "boolean";
     case "priority":
     case "weight":
     case "cost_multiplier":
+    case "limit_5h_usd":
+    case "limit_daily_usd":
+    case "limit_weekly_usd":
+    case "limit_monthly_usd":
+    case "limit_total_usd":
+    case "limit_concurrent_sessions":
+    case "circuit_breaker_failure_threshold":
+    case "circuit_breaker_open_duration":
+    case "circuit_breaker_half_open_success_threshold":
+    case "max_retry_attempts":
+    case "first_byte_timeout_streaming_ms":
+    case "streaming_idle_timeout_ms":
+    case "request_timeout_non_streaming_ms":
       return typeof value === "number" && Number.isFinite(value);
     case "group_tag":
+    case "daily_reset_time":
+    case "proxy_url":
+    case "mcp_passthrough_url":
       return typeof value === "string";
+    case "group_priorities":
+      return isNumberRecord(value);
+    case "cache_ttl_preference":
+      return value === "inherit" || value === "5m" || value === "1h";
+    case "context_1m_preference":
+      return value === "inherit" || value === "force_enable" || value === "disabled";
+    case "daily_reset_mode":
+      return value === "fixed" || value === "rolling";
+    case "codex_reasoning_effort_preference":
+      return (
+        value === "inherit" ||
+        value === "none" ||
+        value === "minimal" ||
+        value === "low" ||
+        value === "medium" ||
+        value === "high" ||
+        value === "xhigh"
+      );
+    case "codex_reasoning_summary_preference":
+      return value === "inherit" || value === "auto" || value === "detailed";
+    case "codex_text_verbosity_preference":
+      return value === "inherit" || value === "low" || value === "medium" || value === "high";
+    case "codex_parallel_tool_calls_preference":
+      return value === "inherit" || value === "true" || value === "false";
     case "anthropic_thinking_budget_preference":
       return isThinkingBudgetPreference(value);
+    case "anthropic_max_tokens_preference":
+      return isMaxTokensPreference(value);
+    case "gemini_google_search_preference":
+      return value === "inherit" || value === "enabled" || value === "disabled";
+    case "mcp_passthrough_type":
+      return value === "none" || value === "minimax" || value === "glm" || value === "custom";
     case "model_redirects":
       return isStringRecord(value);
     case "allowed_models":
@@ -263,6 +407,155 @@ export function normalizeProviderBatchPatchDraft(
   );
   if (!adaptiveThinking.ok) return adaptiveThinking;
 
+  // Routing
+  const preserveClientIp = normalizePatchField("preserve_client_ip", typedDraft.preserve_client_ip);
+  if (!preserveClientIp.ok) return preserveClientIp;
+
+  const groupPriorities = normalizePatchField("group_priorities", typedDraft.group_priorities);
+  if (!groupPriorities.ok) return groupPriorities;
+
+  const cacheTtlPref = normalizePatchField("cache_ttl_preference", typedDraft.cache_ttl_preference);
+  if (!cacheTtlPref.ok) return cacheTtlPref;
+
+  const swapCacheTtlBilling = normalizePatchField(
+    "swap_cache_ttl_billing",
+    typedDraft.swap_cache_ttl_billing
+  );
+  if (!swapCacheTtlBilling.ok) return swapCacheTtlBilling;
+
+  const context1mPref = normalizePatchField(
+    "context_1m_preference",
+    typedDraft.context_1m_preference
+  );
+  if (!context1mPref.ok) return context1mPref;
+
+  const codexReasoningEffort = normalizePatchField(
+    "codex_reasoning_effort_preference",
+    typedDraft.codex_reasoning_effort_preference
+  );
+  if (!codexReasoningEffort.ok) return codexReasoningEffort;
+
+  const codexReasoningSummary = normalizePatchField(
+    "codex_reasoning_summary_preference",
+    typedDraft.codex_reasoning_summary_preference
+  );
+  if (!codexReasoningSummary.ok) return codexReasoningSummary;
+
+  const codexTextVerbosity = normalizePatchField(
+    "codex_text_verbosity_preference",
+    typedDraft.codex_text_verbosity_preference
+  );
+  if (!codexTextVerbosity.ok) return codexTextVerbosity;
+
+  const codexParallelToolCalls = normalizePatchField(
+    "codex_parallel_tool_calls_preference",
+    typedDraft.codex_parallel_tool_calls_preference
+  );
+  if (!codexParallelToolCalls.ok) return codexParallelToolCalls;
+
+  const anthropicMaxTokens = normalizePatchField(
+    "anthropic_max_tokens_preference",
+    typedDraft.anthropic_max_tokens_preference
+  );
+  if (!anthropicMaxTokens.ok) return anthropicMaxTokens;
+
+  const geminiGoogleSearch = normalizePatchField(
+    "gemini_google_search_preference",
+    typedDraft.gemini_google_search_preference
+  );
+  if (!geminiGoogleSearch.ok) return geminiGoogleSearch;
+
+  // Rate Limit
+  const limit5hUsd = normalizePatchField("limit_5h_usd", typedDraft.limit_5h_usd);
+  if (!limit5hUsd.ok) return limit5hUsd;
+
+  const limitDailyUsd = normalizePatchField("limit_daily_usd", typedDraft.limit_daily_usd);
+  if (!limitDailyUsd.ok) return limitDailyUsd;
+
+  const dailyResetMode = normalizePatchField("daily_reset_mode", typedDraft.daily_reset_mode);
+  if (!dailyResetMode.ok) return dailyResetMode;
+
+  const dailyResetTime = normalizePatchField("daily_reset_time", typedDraft.daily_reset_time);
+  if (!dailyResetTime.ok) return dailyResetTime;
+
+  const limitWeeklyUsd = normalizePatchField("limit_weekly_usd", typedDraft.limit_weekly_usd);
+  if (!limitWeeklyUsd.ok) return limitWeeklyUsd;
+
+  const limitMonthlyUsd = normalizePatchField("limit_monthly_usd", typedDraft.limit_monthly_usd);
+  if (!limitMonthlyUsd.ok) return limitMonthlyUsd;
+
+  const limitTotalUsd = normalizePatchField("limit_total_usd", typedDraft.limit_total_usd);
+  if (!limitTotalUsd.ok) return limitTotalUsd;
+
+  const limitConcurrentSessions = normalizePatchField(
+    "limit_concurrent_sessions",
+    typedDraft.limit_concurrent_sessions
+  );
+  if (!limitConcurrentSessions.ok) return limitConcurrentSessions;
+
+  // Circuit Breaker
+  const cbFailureThreshold = normalizePatchField(
+    "circuit_breaker_failure_threshold",
+    typedDraft.circuit_breaker_failure_threshold
+  );
+  if (!cbFailureThreshold.ok) return cbFailureThreshold;
+
+  const cbOpenDuration = normalizePatchField(
+    "circuit_breaker_open_duration",
+    typedDraft.circuit_breaker_open_duration
+  );
+  if (!cbOpenDuration.ok) return cbOpenDuration;
+
+  const cbHalfOpenSuccess = normalizePatchField(
+    "circuit_breaker_half_open_success_threshold",
+    typedDraft.circuit_breaker_half_open_success_threshold
+  );
+  if (!cbHalfOpenSuccess.ok) return cbHalfOpenSuccess;
+
+  const maxRetryAttempts = normalizePatchField("max_retry_attempts", typedDraft.max_retry_attempts);
+  if (!maxRetryAttempts.ok) return maxRetryAttempts;
+
+  // Network
+  const proxyUrl = normalizePatchField("proxy_url", typedDraft.proxy_url);
+  if (!proxyUrl.ok) return proxyUrl;
+
+  const proxyFallbackToDirect = normalizePatchField(
+    "proxy_fallback_to_direct",
+    typedDraft.proxy_fallback_to_direct
+  );
+  if (!proxyFallbackToDirect.ok) return proxyFallbackToDirect;
+
+  const firstByteTimeout = normalizePatchField(
+    "first_byte_timeout_streaming_ms",
+    typedDraft.first_byte_timeout_streaming_ms
+  );
+  if (!firstByteTimeout.ok) return firstByteTimeout;
+
+  const streamingIdleTimeout = normalizePatchField(
+    "streaming_idle_timeout_ms",
+    typedDraft.streaming_idle_timeout_ms
+  );
+  if (!streamingIdleTimeout.ok) return streamingIdleTimeout;
+
+  const requestTimeoutNonStreaming = normalizePatchField(
+    "request_timeout_non_streaming_ms",
+    typedDraft.request_timeout_non_streaming_ms
+  );
+  if (!requestTimeoutNonStreaming.ok) return requestTimeoutNonStreaming;
+
+  // MCP
+  const mcpPassthroughType = normalizePatchField(
+    "mcp_passthrough_type",
+    typedDraft.mcp_passthrough_type
+  );
+  if (!mcpPassthroughType.ok) return mcpPassthroughType;
+
+  const mcpPassthroughUrl = normalizePatchField(
+    "mcp_passthrough_url",
+    typedDraft.mcp_passthrough_url
+  );
+  if (!mcpPassthroughUrl.ok) return mcpPassthroughUrl;
+
   return {
     ok: true,
     data: {
@@ -275,6 +568,41 @@ export function normalizeProviderBatchPatchDraft(
       allowed_models: allowedModels.data,
       anthropic_thinking_budget_preference: thinkingBudget.data,
       anthropic_adaptive_thinking: adaptiveThinking.data,
+      // Routing
+      preserve_client_ip: preserveClientIp.data,
+      group_priorities: groupPriorities.data,
+      cache_ttl_preference: cacheTtlPref.data,
+      swap_cache_ttl_billing: swapCacheTtlBilling.data,
+      context_1m_preference: context1mPref.data,
+      codex_reasoning_effort_preference: codexReasoningEffort.data,
+      codex_reasoning_summary_preference: codexReasoningSummary.data,
+      codex_text_verbosity_preference: codexTextVerbosity.data,
+      codex_parallel_tool_calls_preference: codexParallelToolCalls.data,
+      anthropic_max_tokens_preference: anthropicMaxTokens.data,
+      gemini_google_search_preference: geminiGoogleSearch.data,
+      // Rate Limit
+      limit_5h_usd: limit5hUsd.data,
+      limit_daily_usd: limitDailyUsd.data,
+      daily_reset_mode: dailyResetMode.data,
+      daily_reset_time: dailyResetTime.data,
+      limit_weekly_usd: limitWeeklyUsd.data,
+      limit_monthly_usd: limitMonthlyUsd.data,
+      limit_total_usd: limitTotalUsd.data,
+      limit_concurrent_sessions: limitConcurrentSessions.data,
+      // Circuit Breaker
+      circuit_breaker_failure_threshold: cbFailureThreshold.data,
+      circuit_breaker_open_duration: cbOpenDuration.data,
+      circuit_breaker_half_open_success_threshold: cbHalfOpenSuccess.data,
+      max_retry_attempts: maxRetryAttempts.data,
+      // Network
+      proxy_url: proxyUrl.data,
+      proxy_fallback_to_direct: proxyFallbackToDirect.data,
+      first_byte_timeout_streaming_ms: firstByteTimeout.data,
+      streaming_idle_timeout_ms: streamingIdleTimeout.data,
+      request_timeout_non_streaming_ms: requestTimeoutNonStreaming.data,
+      // MCP
+      mcp_passthrough_type: mcpPassthroughType.data,
+      mcp_passthrough_url: mcpPassthroughUrl.data,
     },
   };
 }
@@ -322,11 +650,126 @@ function applyPatchField<T>(
         updates.anthropic_adaptive_thinking =
           patch.value as ProviderBatchApplyUpdates["anthropic_adaptive_thinking"];
         return { ok: true, data: undefined };
+      // Routing
+      case "preserve_client_ip":
+        updates.preserve_client_ip = patch.value as ProviderBatchApplyUpdates["preserve_client_ip"];
+        return { ok: true, data: undefined };
+      case "group_priorities":
+        updates.group_priorities = patch.value as ProviderBatchApplyUpdates["group_priorities"];
+        return { ok: true, data: undefined };
+      case "cache_ttl_preference":
+        updates.cache_ttl_preference =
+          patch.value as ProviderBatchApplyUpdates["cache_ttl_preference"];
+        return { ok: true, data: undefined };
+      case "swap_cache_ttl_billing":
+        updates.swap_cache_ttl_billing =
+          patch.value as ProviderBatchApplyUpdates["swap_cache_ttl_billing"];
+        return { ok: true, data: undefined };
+      case "context_1m_preference":
+        updates.context_1m_preference =
+          patch.value as ProviderBatchApplyUpdates["context_1m_preference"];
+        return { ok: true, data: undefined };
+      case "codex_reasoning_effort_preference":
+        updates.codex_reasoning_effort_preference =
+          patch.value as ProviderBatchApplyUpdates["codex_reasoning_effort_preference"];
+        return { ok: true, data: undefined };
+      case "codex_reasoning_summary_preference":
+        updates.codex_reasoning_summary_preference =
+          patch.value as ProviderBatchApplyUpdates["codex_reasoning_summary_preference"];
+        return { ok: true, data: undefined };
+      case "codex_text_verbosity_preference":
+        updates.codex_text_verbosity_preference =
+          patch.value as ProviderBatchApplyUpdates["codex_text_verbosity_preference"];
+        return { ok: true, data: undefined };
+      case "codex_parallel_tool_calls_preference":
+        updates.codex_parallel_tool_calls_preference =
+          patch.value as ProviderBatchApplyUpdates["codex_parallel_tool_calls_preference"];
+        return { ok: true, data: undefined };
+      case "anthropic_max_tokens_preference":
+        updates.anthropic_max_tokens_preference =
+          patch.value as ProviderBatchApplyUpdates["anthropic_max_tokens_preference"];
+        return { ok: true, data: undefined };
+      case "gemini_google_search_preference":
+        updates.gemini_google_search_preference =
+          patch.value as ProviderBatchApplyUpdates["gemini_google_search_preference"];
+        return { ok: true, data: undefined };
+      // Rate Limit
+      case "limit_5h_usd":
+        updates.limit_5h_usd = patch.value as ProviderBatchApplyUpdates["limit_5h_usd"];
+        return { ok: true, data: undefined };
+      case "limit_daily_usd":
+        updates.limit_daily_usd = patch.value as ProviderBatchApplyUpdates["limit_daily_usd"];
+        return { ok: true, data: undefined };
+      case "daily_reset_mode":
+        updates.daily_reset_mode = patch.value as ProviderBatchApplyUpdates["daily_reset_mode"];
+        return { ok: true, data: undefined };
+      case "daily_reset_time":
+        updates.daily_reset_time = patch.value as ProviderBatchApplyUpdates["daily_reset_time"];
+        return { ok: true, data: undefined };
+      case "limit_weekly_usd":
+        updates.limit_weekly_usd = patch.value as ProviderBatchApplyUpdates["limit_weekly_usd"];
+        return { ok: true, data: undefined };
+      case "limit_monthly_usd":
+        updates.limit_monthly_usd = patch.value as ProviderBatchApplyUpdates["limit_monthly_usd"];
+        return { ok: true, data: undefined };
+      case "limit_total_usd":
+        updates.limit_total_usd = patch.value as ProviderBatchApplyUpdates["limit_total_usd"];
+        return { ok: true, data: undefined };
+      case "limit_concurrent_sessions":
+        updates.limit_concurrent_sessions =
+          patch.value as ProviderBatchApplyUpdates["limit_concurrent_sessions"];
+        return { ok: true, data: undefined };
+      // Circuit Breaker
+      case "circuit_breaker_failure_threshold":
+        updates.circuit_breaker_failure_threshold =
+          patch.value as ProviderBatchApplyUpdates["circuit_breaker_failure_threshold"];
+        return { ok: true, data: undefined };
+      case "circuit_breaker_open_duration":
+        updates.circuit_breaker_open_duration =
+          patch.value as ProviderBatchApplyUpdates["circuit_breaker_open_duration"];
+        return { ok: true, data: undefined };
+      case "circuit_breaker_half_open_success_threshold":
+        updates.circuit_breaker_half_open_success_threshold =
+          patch.value as ProviderBatchApplyUpdates["circuit_breaker_half_open_success_threshold"];
+        return { ok: true, data: undefined };
+      case "max_retry_attempts":
+        updates.max_retry_attempts = patch.value as ProviderBatchApplyUpdates["max_retry_attempts"];
+        return { ok: true, data: undefined };
+      // Network
+      case "proxy_url":
+        updates.proxy_url = patch.value as ProviderBatchApplyUpdates["proxy_url"];
+        return { ok: true, data: undefined };
+      case "proxy_fallback_to_direct":
+        updates.proxy_fallback_to_direct =
+          patch.value as ProviderBatchApplyUpdates["proxy_fallback_to_direct"];
+        return { ok: true, data: undefined };
+      case "first_byte_timeout_streaming_ms":
+        updates.first_byte_timeout_streaming_ms =
+          patch.value as ProviderBatchApplyUpdates["first_byte_timeout_streaming_ms"];
+        return { ok: true, data: undefined };
+      case "streaming_idle_timeout_ms":
+        updates.streaming_idle_timeout_ms =
+          patch.value as ProviderBatchApplyUpdates["streaming_idle_timeout_ms"];
+        return { ok: true, data: undefined };
+      case "request_timeout_non_streaming_ms":
+        updates.request_timeout_non_streaming_ms =
+          patch.value as ProviderBatchApplyUpdates["request_timeout_non_streaming_ms"];
+        return { ok: true, data: undefined };
+      // MCP
+      case "mcp_passthrough_type":
+        updates.mcp_passthrough_type =
+          patch.value as ProviderBatchApplyUpdates["mcp_passthrough_type"];
+        return { ok: true, data: undefined };
+      case "mcp_passthrough_url":
+        updates.mcp_passthrough_url =
+          patch.value as ProviderBatchApplyUpdates["mcp_passthrough_url"];
+        return { ok: true, data: undefined };
       default:
         return createInvalidPatchShapeError(field, "Unsupported patch field");
     }
   }
 
+  // clear mode
   switch (field) {
     case "group_tag":
       updates.group_tag = null;
@@ -343,6 +786,63 @@ function applyPatchField<T>(
     case "anthropic_adaptive_thinking":
       updates.anthropic_adaptive_thinking = null;
       return { ok: true, data: undefined };
+    // Routing - preference fields clear to "inherit"
+    case "cache_ttl_preference":
+      updates.cache_ttl_preference = "inherit";
+      return { ok: true, data: undefined };
+    case "context_1m_preference":
+      updates.context_1m_preference = "inherit";
+      return { ok: true, data: undefined };
+    case "codex_reasoning_effort_preference":
+      updates.codex_reasoning_effort_preference = "inherit";
+      return { ok: true, data: undefined };
+    case "codex_reasoning_summary_preference":
+      updates.codex_reasoning_summary_preference = "inherit";
+      return { ok: true, data: undefined };
+    case "codex_text_verbosity_preference":
+      updates.codex_text_verbosity_preference = "inherit";
+      return { ok: true, data: undefined };
+    case "codex_parallel_tool_calls_preference":
+      updates.codex_parallel_tool_calls_preference = "inherit";
+      return { ok: true, data: undefined };
+    case "anthropic_max_tokens_preference":
+      updates.anthropic_max_tokens_preference = "inherit";
+      return { ok: true, data: undefined };
+    case "gemini_google_search_preference":
+      updates.gemini_google_search_preference = "inherit";
+      return { ok: true, data: undefined };
+    // Routing - nullable fields clear to null
+    case "group_priorities":
+      updates.group_priorities = null;
+      return { ok: true, data: undefined };
+    // Rate Limit - nullable number fields clear to null
+    case "limit_5h_usd":
+      updates.limit_5h_usd = null;
+      return { ok: true, data: undefined };
+    case "limit_daily_usd":
+      updates.limit_daily_usd = null;
+      return { ok: true, data: undefined };
+    case "limit_weekly_usd":
+      updates.limit_weekly_usd = null;
+      return { ok: true, data: undefined };
+    case "limit_monthly_usd":
+      updates.limit_monthly_usd = null;
+      return { ok: true, data: undefined };
+    case "limit_total_usd":
+      updates.limit_total_usd = null;
+      return { ok: true, data: undefined };
+    // Circuit Breaker
+    case "max_retry_attempts":
+      updates.max_retry_attempts = null;
+      return { ok: true, data: undefined };
+    // Network
+    case "proxy_url":
+      updates.proxy_url = null;
+      return { ok: true, data: undefined };
+    // MCP
+    case "mcp_passthrough_url":
+      updates.mcp_passthrough_url = null;
+      return { ok: true, data: undefined };
     default:
       return createInvalidPatchShapeError(field, "clear mode is not supported for this field");
   }
@@ -363,6 +863,44 @@ export function buildProviderBatchApplyUpdates(
     ["allowed_models", patch.allowed_models],
     ["anthropic_thinking_budget_preference", patch.anthropic_thinking_budget_preference],
     ["anthropic_adaptive_thinking", patch.anthropic_adaptive_thinking],
+    // Routing
+    ["preserve_client_ip", patch.preserve_client_ip],
+    ["group_priorities", patch.group_priorities],
+    ["cache_ttl_preference", patch.cache_ttl_preference],
+    ["swap_cache_ttl_billing", patch.swap_cache_ttl_billing],
+    ["context_1m_preference", patch.context_1m_preference],
+    ["codex_reasoning_effort_preference", patch.codex_reasoning_effort_preference],
+    ["codex_reasoning_summary_preference", patch.codex_reasoning_summary_preference],
+    ["codex_text_verbosity_preference", patch.codex_text_verbosity_preference],
+    ["codex_parallel_tool_calls_preference", patch.codex_parallel_tool_calls_preference],
+    ["anthropic_max_tokens_preference", patch.anthropic_max_tokens_preference],
+    ["gemini_google_search_preference", patch.gemini_google_search_preference],
+    // Rate Limit
+    ["limit_5h_usd", patch.limit_5h_usd],
+    ["limit_daily_usd", patch.limit_daily_usd],
+    ["daily_reset_mode", patch.daily_reset_mode],
+    ["daily_reset_time", patch.daily_reset_time],
+    ["limit_weekly_usd", patch.limit_weekly_usd],
+    ["limit_monthly_usd", patch.limit_monthly_usd],
+    ["limit_total_usd", patch.limit_total_usd],
+    ["limit_concurrent_sessions", patch.limit_concurrent_sessions],
+    // Circuit Breaker
+    ["circuit_breaker_failure_threshold", patch.circuit_breaker_failure_threshold],
+    ["circuit_breaker_open_duration", patch.circuit_breaker_open_duration],
+    [
+      "circuit_breaker_half_open_success_threshold",
+      patch.circuit_breaker_half_open_success_threshold,
+    ],
+    ["max_retry_attempts", patch.max_retry_attempts],
+    // Network
+    ["proxy_url", patch.proxy_url],
+    ["proxy_fallback_to_direct", patch.proxy_fallback_to_direct],
+    ["first_byte_timeout_streaming_ms", patch.first_byte_timeout_streaming_ms],
+    ["streaming_idle_timeout_ms", patch.streaming_idle_timeout_ms],
+    ["request_timeout_non_streaming_ms", patch.request_timeout_non_streaming_ms],
+    // MCP
+    ["mcp_passthrough_type", patch.mcp_passthrough_type],
+    ["mcp_passthrough_url", patch.mcp_passthrough_url],
   ];
 
   for (const [field, operation] of operations) {
@@ -385,7 +923,42 @@ export function hasProviderBatchPatchChanges(patch: ProviderBatchPatch): boolean
     patch.model_redirects.mode !== "no_change" ||
     patch.allowed_models.mode !== "no_change" ||
     patch.anthropic_thinking_budget_preference.mode !== "no_change" ||
-    patch.anthropic_adaptive_thinking.mode !== "no_change"
+    patch.anthropic_adaptive_thinking.mode !== "no_change" ||
+    // Routing
+    patch.preserve_client_ip.mode !== "no_change" ||
+    patch.group_priorities.mode !== "no_change" ||
+    patch.cache_ttl_preference.mode !== "no_change" ||
+    patch.swap_cache_ttl_billing.mode !== "no_change" ||
+    patch.context_1m_preference.mode !== "no_change" ||
+    patch.codex_reasoning_effort_preference.mode !== "no_change" ||
+    patch.codex_reasoning_summary_preference.mode !== "no_change" ||
+    patch.codex_text_verbosity_preference.mode !== "no_change" ||
+    patch.codex_parallel_tool_calls_preference.mode !== "no_change" ||
+    patch.anthropic_max_tokens_preference.mode !== "no_change" ||
+    patch.gemini_google_search_preference.mode !== "no_change" ||
+    // Rate Limit
+    patch.limit_5h_usd.mode !== "no_change" ||
+    patch.limit_daily_usd.mode !== "no_change" ||
+    patch.daily_reset_mode.mode !== "no_change" ||
+    patch.daily_reset_time.mode !== "no_change" ||
+    patch.limit_weekly_usd.mode !== "no_change" ||
+    patch.limit_monthly_usd.mode !== "no_change" ||
+    patch.limit_total_usd.mode !== "no_change" ||
+    patch.limit_concurrent_sessions.mode !== "no_change" ||
+    // Circuit Breaker
+    patch.circuit_breaker_failure_threshold.mode !== "no_change" ||
+    patch.circuit_breaker_open_duration.mode !== "no_change" ||
+    patch.circuit_breaker_half_open_success_threshold.mode !== "no_change" ||
+    patch.max_retry_attempts.mode !== "no_change" ||
+    // Network
+    patch.proxy_url.mode !== "no_change" ||
+    patch.proxy_fallback_to_direct.mode !== "no_change" ||
+    patch.first_byte_timeout_streaming_ms.mode !== "no_change" ||
+    patch.streaming_idle_timeout_ms.mode !== "no_change" ||
+    patch.request_timeout_non_streaming_ms.mode !== "no_change" ||
+    // MCP
+    patch.mcp_passthrough_type.mode !== "no_change" ||
+    patch.mcp_passthrough_url.mode !== "no_change"
   );
 }
 

+ 27 - 0
src/lib/security/constant-time-compare.ts

@@ -0,0 +1,27 @@
+import { timingSafeEqual } from "node:crypto";
+
+/**
+ * Constant-time string comparison to prevent timing attacks.
+ *
+ * Uses crypto.timingSafeEqual internally. When lengths differ, a dummy
+ * comparison is still performed so the total CPU time does not leak
+ * length information.
+ */
+export function constantTimeEqual(a: string, b: string): boolean {
+  const bufA = Buffer.from(a, "utf-8");
+  const bufB = Buffer.from(b, "utf-8");
+
+  if (bufA.length !== bufB.length) {
+    // Pad both to the same length so the dummy comparison time does not
+    // leak which side is shorter (attacker may control either one).
+    const padLen = Math.max(bufA.length, bufB.length);
+    const padA = Buffer.alloc(padLen);
+    const padB = Buffer.alloc(padLen);
+    bufA.copy(padA);
+    bufB.copy(padB);
+    timingSafeEqual(padA, padB);
+    return false;
+  }
+
+  return timingSafeEqual(bufA, bufB);
+}

+ 131 - 0
src/repository/provider.ts

@@ -813,6 +813,41 @@ export interface BatchProviderUpdates {
   allowedModels?: string[] | null;
   anthropicThinkingBudgetPreference?: string | null;
   anthropicAdaptiveThinking?: object | null;
+  // Routing
+  preserveClientIp?: boolean;
+  groupPriorities?: Record<string, number> | null;
+  cacheTtlPreference?: string | null;
+  swapCacheTtlBilling?: boolean;
+  context1mPreference?: string | null;
+  codexReasoningEffortPreference?: string | null;
+  codexReasoningSummaryPreference?: string | null;
+  codexTextVerbosityPreference?: string | null;
+  codexParallelToolCallsPreference?: string | null;
+  anthropicMaxTokensPreference?: string | null;
+  geminiGoogleSearchPreference?: string | null;
+  // Rate Limit
+  limit5hUsd?: string | null;
+  limitDailyUsd?: string | null;
+  dailyResetMode?: string;
+  dailyResetTime?: string;
+  limitWeeklyUsd?: string | null;
+  limitMonthlyUsd?: string | null;
+  limitTotalUsd?: string | null;
+  limitConcurrentSessions?: number;
+  // Circuit Breaker
+  circuitBreakerFailureThreshold?: number;
+  circuitBreakerOpenDuration?: number;
+  circuitBreakerHalfOpenSuccessThreshold?: number;
+  maxRetryAttempts?: number | null;
+  // Network
+  proxyUrl?: string | null;
+  proxyFallbackToDirect?: boolean;
+  firstByteTimeoutStreamingMs?: number;
+  streamingIdleTimeoutMs?: number;
+  requestTimeoutNonStreamingMs?: number;
+  // MCP
+  mcpPassthroughType?: string;
+  mcpPassthroughUrl?: string | null;
 }
 
 export async function updateProvidersBatch(
@@ -854,6 +889,102 @@ export async function updateProvidersBatch(
   if (updates.anthropicAdaptiveThinking !== undefined) {
     setClauses.anthropicAdaptiveThinking = updates.anthropicAdaptiveThinking;
   }
+  // Routing
+  if (updates.preserveClientIp !== undefined) {
+    setClauses.preserveClientIp = updates.preserveClientIp;
+  }
+  if (updates.groupPriorities !== undefined) {
+    setClauses.groupPriorities = updates.groupPriorities;
+  }
+  if (updates.cacheTtlPreference !== undefined) {
+    setClauses.cacheTtlPreference = updates.cacheTtlPreference;
+  }
+  if (updates.swapCacheTtlBilling !== undefined) {
+    setClauses.swapCacheTtlBilling = updates.swapCacheTtlBilling;
+  }
+  if (updates.context1mPreference !== undefined) {
+    setClauses.context1mPreference = updates.context1mPreference;
+  }
+  if (updates.codexReasoningEffortPreference !== undefined) {
+    setClauses.codexReasoningEffortPreference = updates.codexReasoningEffortPreference;
+  }
+  if (updates.codexReasoningSummaryPreference !== undefined) {
+    setClauses.codexReasoningSummaryPreference = updates.codexReasoningSummaryPreference;
+  }
+  if (updates.codexTextVerbosityPreference !== undefined) {
+    setClauses.codexTextVerbosityPreference = updates.codexTextVerbosityPreference;
+  }
+  if (updates.codexParallelToolCallsPreference !== undefined) {
+    setClauses.codexParallelToolCallsPreference = updates.codexParallelToolCallsPreference;
+  }
+  if (updates.anthropicMaxTokensPreference !== undefined) {
+    setClauses.anthropicMaxTokensPreference = updates.anthropicMaxTokensPreference;
+  }
+  if (updates.geminiGoogleSearchPreference !== undefined) {
+    setClauses.geminiGoogleSearchPreference = updates.geminiGoogleSearchPreference;
+  }
+  // Rate Limit
+  if (updates.limit5hUsd !== undefined) {
+    setClauses.limit5hUsd = updates.limit5hUsd;
+  }
+  if (updates.limitDailyUsd !== undefined) {
+    setClauses.limitDailyUsd = updates.limitDailyUsd;
+  }
+  if (updates.dailyResetMode !== undefined) {
+    setClauses.dailyResetMode = updates.dailyResetMode;
+  }
+  if (updates.dailyResetTime !== undefined) {
+    setClauses.dailyResetTime = updates.dailyResetTime;
+  }
+  if (updates.limitWeeklyUsd !== undefined) {
+    setClauses.limitWeeklyUsd = updates.limitWeeklyUsd;
+  }
+  if (updates.limitMonthlyUsd !== undefined) {
+    setClauses.limitMonthlyUsd = updates.limitMonthlyUsd;
+  }
+  if (updates.limitTotalUsd !== undefined) {
+    setClauses.limitTotalUsd = updates.limitTotalUsd;
+  }
+  if (updates.limitConcurrentSessions !== undefined) {
+    setClauses.limitConcurrentSessions = updates.limitConcurrentSessions;
+  }
+  // Circuit Breaker
+  if (updates.circuitBreakerFailureThreshold !== undefined) {
+    setClauses.circuitBreakerFailureThreshold = updates.circuitBreakerFailureThreshold;
+  }
+  if (updates.circuitBreakerOpenDuration !== undefined) {
+    setClauses.circuitBreakerOpenDuration = updates.circuitBreakerOpenDuration;
+  }
+  if (updates.circuitBreakerHalfOpenSuccessThreshold !== undefined) {
+    setClauses.circuitBreakerHalfOpenSuccessThreshold =
+      updates.circuitBreakerHalfOpenSuccessThreshold;
+  }
+  if (updates.maxRetryAttempts !== undefined) {
+    setClauses.maxRetryAttempts = updates.maxRetryAttempts;
+  }
+  // Network
+  if (updates.proxyUrl !== undefined) {
+    setClauses.proxyUrl = updates.proxyUrl;
+  }
+  if (updates.proxyFallbackToDirect !== undefined) {
+    setClauses.proxyFallbackToDirect = updates.proxyFallbackToDirect;
+  }
+  if (updates.firstByteTimeoutStreamingMs !== undefined) {
+    setClauses.firstByteTimeoutStreamingMs = updates.firstByteTimeoutStreamingMs;
+  }
+  if (updates.streamingIdleTimeoutMs !== undefined) {
+    setClauses.streamingIdleTimeoutMs = updates.streamingIdleTimeoutMs;
+  }
+  if (updates.requestTimeoutNonStreamingMs !== undefined) {
+    setClauses.requestTimeoutNonStreamingMs = updates.requestTimeoutNonStreamingMs;
+  }
+  // MCP
+  if (updates.mcpPassthroughType !== undefined) {
+    setClauses.mcpPassthroughType = updates.mcpPassthroughType;
+  }
+  if (updates.mcpPassthroughUrl !== undefined) {
+    setClauses.mcpPassthroughUrl = updates.mcpPassthroughUrl;
+  }
 
   if (Object.keys(setClauses).length === 1) {
     return 0;

+ 145 - 1
src/types/provider.ts

@@ -57,6 +57,7 @@ export type ProviderPatchDraftInput<T> =
   | undefined;
 
 export type ProviderBatchPatchField =
+  // Basic / existing
   | "is_enabled"
   | "priority"
   | "weight"
@@ -65,9 +66,45 @@ export type ProviderBatchPatchField =
   | "model_redirects"
   | "allowed_models"
   | "anthropic_thinking_budget_preference"
-  | "anthropic_adaptive_thinking";
+  | "anthropic_adaptive_thinking"
+  // Routing
+  | "preserve_client_ip"
+  | "group_priorities"
+  | "cache_ttl_preference"
+  | "swap_cache_ttl_billing"
+  | "context_1m_preference"
+  | "codex_reasoning_effort_preference"
+  | "codex_reasoning_summary_preference"
+  | "codex_text_verbosity_preference"
+  | "codex_parallel_tool_calls_preference"
+  | "anthropic_max_tokens_preference"
+  | "gemini_google_search_preference"
+  // Rate Limit
+  | "limit_5h_usd"
+  | "limit_daily_usd"
+  | "daily_reset_mode"
+  | "daily_reset_time"
+  | "limit_weekly_usd"
+  | "limit_monthly_usd"
+  | "limit_total_usd"
+  | "limit_concurrent_sessions"
+  // Circuit Breaker
+  | "circuit_breaker_failure_threshold"
+  | "circuit_breaker_open_duration"
+  | "circuit_breaker_half_open_success_threshold"
+  | "max_retry_attempts"
+  // Network
+  | "proxy_url"
+  | "proxy_fallback_to_direct"
+  | "first_byte_timeout_streaming_ms"
+  | "streaming_idle_timeout_ms"
+  | "request_timeout_non_streaming_ms"
+  // MCP
+  | "mcp_passthrough_type"
+  | "mcp_passthrough_url";
 
 export interface ProviderBatchPatchDraft {
+  // Basic / existing
   is_enabled?: ProviderPatchDraftInput<boolean>;
   priority?: ProviderPatchDraftInput<number>;
   weight?: ProviderPatchDraftInput<number>;
@@ -77,9 +114,45 @@ export interface ProviderBatchPatchDraft {
   allowed_models?: ProviderPatchDraftInput<string[]>;
   anthropic_thinking_budget_preference?: ProviderPatchDraftInput<AnthropicThinkingBudgetPreference>;
   anthropic_adaptive_thinking?: ProviderPatchDraftInput<AnthropicAdaptiveThinkingConfig>;
+  // Routing
+  preserve_client_ip?: ProviderPatchDraftInput<boolean>;
+  group_priorities?: ProviderPatchDraftInput<Record<string, number>>;
+  cache_ttl_preference?: ProviderPatchDraftInput<CacheTtlPreference>;
+  swap_cache_ttl_billing?: ProviderPatchDraftInput<boolean>;
+  context_1m_preference?: ProviderPatchDraftInput<Context1mPreference>;
+  codex_reasoning_effort_preference?: ProviderPatchDraftInput<CodexReasoningEffortPreference>;
+  codex_reasoning_summary_preference?: ProviderPatchDraftInput<CodexReasoningSummaryPreference>;
+  codex_text_verbosity_preference?: ProviderPatchDraftInput<CodexTextVerbosityPreference>;
+  codex_parallel_tool_calls_preference?: ProviderPatchDraftInput<CodexParallelToolCallsPreference>;
+  anthropic_max_tokens_preference?: ProviderPatchDraftInput<AnthropicMaxTokensPreference>;
+  gemini_google_search_preference?: ProviderPatchDraftInput<GeminiGoogleSearchPreference>;
+  // Rate Limit
+  limit_5h_usd?: ProviderPatchDraftInput<number>;
+  limit_daily_usd?: ProviderPatchDraftInput<number>;
+  daily_reset_mode?: ProviderPatchDraftInput<"fixed" | "rolling">;
+  daily_reset_time?: ProviderPatchDraftInput<string>;
+  limit_weekly_usd?: ProviderPatchDraftInput<number>;
+  limit_monthly_usd?: ProviderPatchDraftInput<number>;
+  limit_total_usd?: ProviderPatchDraftInput<number>;
+  limit_concurrent_sessions?: ProviderPatchDraftInput<number>;
+  // Circuit Breaker
+  circuit_breaker_failure_threshold?: ProviderPatchDraftInput<number>;
+  circuit_breaker_open_duration?: ProviderPatchDraftInput<number>;
+  circuit_breaker_half_open_success_threshold?: ProviderPatchDraftInput<number>;
+  max_retry_attempts?: ProviderPatchDraftInput<number>;
+  // Network
+  proxy_url?: ProviderPatchDraftInput<string>;
+  proxy_fallback_to_direct?: ProviderPatchDraftInput<boolean>;
+  first_byte_timeout_streaming_ms?: ProviderPatchDraftInput<number>;
+  streaming_idle_timeout_ms?: ProviderPatchDraftInput<number>;
+  request_timeout_non_streaming_ms?: ProviderPatchDraftInput<number>;
+  // MCP
+  mcp_passthrough_type?: ProviderPatchDraftInput<McpPassthroughType>;
+  mcp_passthrough_url?: ProviderPatchDraftInput<string>;
 }
 
 export interface ProviderBatchPatch {
+  // Basic / existing
   is_enabled: ProviderPatchOperation<boolean>;
   priority: ProviderPatchOperation<number>;
   weight: ProviderPatchOperation<number>;
@@ -89,9 +162,45 @@ export interface ProviderBatchPatch {
   allowed_models: ProviderPatchOperation<string[]>;
   anthropic_thinking_budget_preference: ProviderPatchOperation<AnthropicThinkingBudgetPreference>;
   anthropic_adaptive_thinking: ProviderPatchOperation<AnthropicAdaptiveThinkingConfig>;
+  // Routing
+  preserve_client_ip: ProviderPatchOperation<boolean>;
+  group_priorities: ProviderPatchOperation<Record<string, number>>;
+  cache_ttl_preference: ProviderPatchOperation<CacheTtlPreference>;
+  swap_cache_ttl_billing: ProviderPatchOperation<boolean>;
+  context_1m_preference: ProviderPatchOperation<Context1mPreference>;
+  codex_reasoning_effort_preference: ProviderPatchOperation<CodexReasoningEffortPreference>;
+  codex_reasoning_summary_preference: ProviderPatchOperation<CodexReasoningSummaryPreference>;
+  codex_text_verbosity_preference: ProviderPatchOperation<CodexTextVerbosityPreference>;
+  codex_parallel_tool_calls_preference: ProviderPatchOperation<CodexParallelToolCallsPreference>;
+  anthropic_max_tokens_preference: ProviderPatchOperation<AnthropicMaxTokensPreference>;
+  gemini_google_search_preference: ProviderPatchOperation<GeminiGoogleSearchPreference>;
+  // Rate Limit
+  limit_5h_usd: ProviderPatchOperation<number>;
+  limit_daily_usd: ProviderPatchOperation<number>;
+  daily_reset_mode: ProviderPatchOperation<"fixed" | "rolling">;
+  daily_reset_time: ProviderPatchOperation<string>;
+  limit_weekly_usd: ProviderPatchOperation<number>;
+  limit_monthly_usd: ProviderPatchOperation<number>;
+  limit_total_usd: ProviderPatchOperation<number>;
+  limit_concurrent_sessions: ProviderPatchOperation<number>;
+  // Circuit Breaker
+  circuit_breaker_failure_threshold: ProviderPatchOperation<number>;
+  circuit_breaker_open_duration: ProviderPatchOperation<number>;
+  circuit_breaker_half_open_success_threshold: ProviderPatchOperation<number>;
+  max_retry_attempts: ProviderPatchOperation<number>;
+  // Network
+  proxy_url: ProviderPatchOperation<string>;
+  proxy_fallback_to_direct: ProviderPatchOperation<boolean>;
+  first_byte_timeout_streaming_ms: ProviderPatchOperation<number>;
+  streaming_idle_timeout_ms: ProviderPatchOperation<number>;
+  request_timeout_non_streaming_ms: ProviderPatchOperation<number>;
+  // MCP
+  mcp_passthrough_type: ProviderPatchOperation<McpPassthroughType>;
+  mcp_passthrough_url: ProviderPatchOperation<string>;
 }
 
 export interface ProviderBatchApplyUpdates {
+  // Basic / existing
   is_enabled?: boolean;
   priority?: number;
   weight?: number;
@@ -101,6 +210,41 @@ export interface ProviderBatchApplyUpdates {
   allowed_models?: string[] | null;
   anthropic_thinking_budget_preference?: AnthropicThinkingBudgetPreference | null;
   anthropic_adaptive_thinking?: AnthropicAdaptiveThinkingConfig | null;
+  // Routing
+  preserve_client_ip?: boolean;
+  group_priorities?: Record<string, number> | null;
+  cache_ttl_preference?: CacheTtlPreference | null;
+  swap_cache_ttl_billing?: boolean;
+  context_1m_preference?: Context1mPreference | null;
+  codex_reasoning_effort_preference?: CodexReasoningEffortPreference | null;
+  codex_reasoning_summary_preference?: CodexReasoningSummaryPreference | null;
+  codex_text_verbosity_preference?: CodexTextVerbosityPreference | null;
+  codex_parallel_tool_calls_preference?: CodexParallelToolCallsPreference | null;
+  anthropic_max_tokens_preference?: AnthropicMaxTokensPreference | null;
+  gemini_google_search_preference?: GeminiGoogleSearchPreference | null;
+  // Rate Limit
+  limit_5h_usd?: number | null;
+  limit_daily_usd?: number | null;
+  daily_reset_mode?: "fixed" | "rolling";
+  daily_reset_time?: string;
+  limit_weekly_usd?: number | null;
+  limit_monthly_usd?: number | null;
+  limit_total_usd?: number | null;
+  limit_concurrent_sessions?: number;
+  // Circuit Breaker
+  circuit_breaker_failure_threshold?: number;
+  circuit_breaker_open_duration?: number;
+  circuit_breaker_half_open_success_threshold?: number;
+  max_retry_attempts?: number | null;
+  // Network
+  proxy_url?: string | null;
+  proxy_fallback_to_direct?: boolean;
+  first_byte_timeout_streaming_ms?: number;
+  streaming_idle_timeout_ms?: number;
+  request_timeout_non_streaming_ms?: number;
+  // MCP
+  mcp_passthrough_type?: McpPassthroughType;
+  mcp_passthrough_url?: string | null;
 }
 
 // Gemini (generateContent API) parameter overrides

+ 43 - 0
tests/security/constant-time-compare.test.ts

@@ -0,0 +1,43 @@
+import { describe, expect, it } from "vitest";
+import { constantTimeEqual } from "@/lib/security/constant-time-compare";
+
+describe("constantTimeEqual", () => {
+  it("returns true for equal strings", () => {
+    expect(constantTimeEqual("hello", "hello")).toBe(true);
+  });
+
+  it("returns false for different strings of same length", () => {
+    expect(constantTimeEqual("hello", "world")).toBe(false);
+  });
+
+  it("returns false for strings of different lengths", () => {
+    expect(constantTimeEqual("short", "a-much-longer-string")).toBe(false);
+  });
+
+  it("returns true for empty strings", () => {
+    expect(constantTimeEqual("", "")).toBe(true);
+  });
+
+  it("returns false when one string is empty and the other is not", () => {
+    expect(constantTimeEqual("", "nonempty")).toBe(false);
+    expect(constantTimeEqual("nonempty", "")).toBe(false);
+  });
+
+  it("handles unicode correctly", () => {
+    expect(constantTimeEqual("\u00e9", "\u00e9")).toBe(true);
+    expect(constantTimeEqual("\u00e9", "e")).toBe(false);
+  });
+
+  it("handles long token-like strings", () => {
+    const tokenA = "sk-ant-api03-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
+    const tokenB = "sk-ant-api03-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
+    const tokenC = "sk-ant-api03-BBBBBBBBBBBBBBBBBBBBBBBBBBBBBB";
+    expect(constantTimeEqual(tokenA, tokenB)).toBe(true);
+    expect(constantTimeEqual(tokenA, tokenC)).toBe(false);
+  });
+
+  it("is reflexive", () => {
+    const s = "test-token-value";
+    expect(constantTimeEqual(s, s)).toBe(true);
+  });
+});

+ 160 - 0
tests/security/proxy-auth-rate-limit.test.ts

@@ -0,0 +1,160 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+/**
+ * Tests for the proxy auth pre-auth rate limiter.
+ *
+ * The rate limiter is a module-level LoginAbusePolicy instance inside
+ * auth-guard.ts. Since it relies on ProxySession (which depends on Hono
+ * Context), we test the underlying LoginAbusePolicy behaviour that the
+ * guard delegates to, plus the IP extraction helper logic.
+ */
+
+// We test the LoginAbusePolicy directly with proxy-specific config
+import { LoginAbusePolicy } from "@/lib/security/login-abuse-policy";
+
+describe("Proxy pre-auth rate limiter (LoginAbusePolicy with proxy config)", () => {
+  const nowMs = 1_700_000_000_000;
+
+  beforeEach(() => {
+    vi.useFakeTimers();
+    vi.setSystemTime(new Date(nowMs));
+  });
+
+  afterEach(() => {
+    vi.useRealTimers();
+  });
+
+  it("allows requests below the proxy threshold (20)", () => {
+    const policy = new LoginAbusePolicy({
+      maxAttemptsPerIp: 20,
+      maxAttemptsPerKey: 20,
+      windowSeconds: 300,
+      lockoutSeconds: 600,
+    });
+    const ip = "10.0.0.1";
+
+    for (let i = 0; i < 19; i++) {
+      policy.recordFailure(ip);
+    }
+
+    expect(policy.check(ip)).toEqual({ allowed: true });
+  });
+
+  it("blocks after 20 consecutive failures", () => {
+    const policy = new LoginAbusePolicy({
+      maxAttemptsPerIp: 20,
+      maxAttemptsPerKey: 20,
+      windowSeconds: 300,
+      lockoutSeconds: 600,
+    });
+    const ip = "10.0.0.2";
+
+    for (let i = 0; i < 20; i++) {
+      policy.recordFailure(ip);
+    }
+
+    const decision = policy.check(ip);
+    expect(decision.allowed).toBe(false);
+    expect(decision.retryAfterSeconds).toBe(600);
+  });
+
+  it("resets failure count after success", () => {
+    const policy = new LoginAbusePolicy({
+      maxAttemptsPerIp: 20,
+      maxAttemptsPerKey: 20,
+      windowSeconds: 300,
+      lockoutSeconds: 600,
+    });
+    const ip = "10.0.0.3";
+
+    for (let i = 0; i < 15; i++) {
+      policy.recordFailure(ip);
+    }
+
+    policy.recordSuccess(ip);
+
+    // After success, counter is reset — 5 more failures should be allowed
+    for (let i = 0; i < 5; i++) {
+      policy.recordFailure(ip);
+    }
+    expect(policy.check(ip)).toEqual({ allowed: true });
+  });
+
+  it("unlocks after lockout period expires", () => {
+    const policy = new LoginAbusePolicy({
+      maxAttemptsPerIp: 20,
+      maxAttemptsPerKey: 20,
+      windowSeconds: 300,
+      lockoutSeconds: 600,
+    });
+    const ip = "10.0.0.4";
+
+    for (let i = 0; i < 20; i++) {
+      policy.recordFailure(ip);
+    }
+
+    expect(policy.check(ip).allowed).toBe(false);
+
+    // Advance past lockout
+    vi.advanceTimersByTime(601_000);
+    expect(policy.check(ip).allowed).toBe(true);
+  });
+
+  it("tracks different IPs independently", () => {
+    const policy = new LoginAbusePolicy({
+      maxAttemptsPerIp: 3,
+      maxAttemptsPerKey: 3,
+      windowSeconds: 300,
+      lockoutSeconds: 600,
+    });
+
+    const ipA = "10.0.0.10";
+    const ipB = "10.0.0.11";
+
+    for (let i = 0; i < 3; i++) {
+      policy.recordFailure(ipA);
+    }
+
+    expect(policy.check(ipA).allowed).toBe(false);
+    expect(policy.check(ipB).allowed).toBe(true);
+  });
+});
+
+describe("extractClientIp logic (rightmost x-forwarded-for)", () => {
+  it("takes rightmost IP from x-forwarded-for", () => {
+    // Simulates: client spoofs leftmost, proxy appends real IP
+    const forwarded = "spoofed-ip, real-client-ip";
+    const ips = forwarded
+      .split(",")
+      .map((s) => s.trim())
+      .filter(Boolean);
+    expect(ips[ips.length - 1]).toBe("real-client-ip");
+  });
+
+  it("handles single IP in x-forwarded-for", () => {
+    const forwarded = "192.168.1.1";
+    const ips = forwarded
+      .split(",")
+      .map((s) => s.trim())
+      .filter(Boolean);
+    expect(ips[ips.length - 1]).toBe("192.168.1.1");
+  });
+
+  it("prefers x-real-ip over x-forwarded-for", () => {
+    // The implementation checks x-real-ip first
+    const realIp = "10.0.0.1";
+    const forwarded = "spoofed, 10.0.0.2";
+
+    // x-real-ip is present and non-empty → use it
+    const result = realIp.trim() || undefined;
+    expect(result).toBe("10.0.0.1");
+  });
+
+  it("returns 'unknown' when no headers present", () => {
+    const realIp: string | null = null;
+    const forwarded: string | null = null;
+
+    const result = realIp?.trim() || forwarded || "unknown";
+    expect(result).toBe("unknown");
+  });
+});

+ 15 - 1
tests/security/security-headers-integration.test.ts

@@ -172,11 +172,25 @@ describe("security headers auth route integration", () => {
       requestHeaders: "content-type,x-api-key",
     });
 
-    expect(corsRes.headers.get("Access-Control-Allow-Origin")).toBe("https://client.example.com");
+    // Without allowCredentials, origin is NOT reflected — stays as wildcard
+    expect(corsRes.headers.get("Access-Control-Allow-Origin")).toBe("*");
+    expect(corsRes.headers.get("Access-Control-Allow-Credentials")).toBeNull();
     expect(corsRes.headers.get("Access-Control-Allow-Headers")).toBe("content-type,x-api-key");
     expect(corsRes.headers.get("Content-Security-Policy-Report-Only")).toContain(
       "default-src 'self'"
     );
     expect(corsRes.headers.get("X-Content-Type-Options")).toBe("nosniff");
   });
+
+  it("CORS reflects origin only when allowCredentials is explicitly set", async () => {
+    const res = await loginPost(makeLoginRequest({ key: "valid-key" }));
+    const corsRes = applyCors(res, {
+      origin: "https://trusted.example.com",
+      requestHeaders: "content-type",
+      allowCredentials: true,
+    });
+
+    expect(corsRes.headers.get("Access-Control-Allow-Origin")).toBe("https://trusted.example.com");
+    expect(corsRes.headers.get("Access-Control-Allow-Credentials")).toBe("true");
+  });
 });

+ 730 - 0
tests/unit/actions/providers-patch-contract.test.ts

@@ -189,4 +189,734 @@ describe("provider patch contract", () => {
     expect(result.error.code).toBe(PROVIDER_PATCH_ERROR_CODES.INVALID_PATCH_SHAPE);
     expect(result.error.field).toBe("__root__");
   });
+
+  describe("routing fields", () => {
+    it("accepts boolean set for preserve_client_ip and swap_cache_ttl_billing", () => {
+      const result = prepareProviderBatchApplyUpdates({
+        preserve_client_ip: { set: true },
+        swap_cache_ttl_billing: { set: false },
+      });
+
+      expect(result.ok).toBe(true);
+      if (!result.ok) return;
+
+      expect(result.data.preserve_client_ip).toBe(true);
+      expect(result.data.swap_cache_ttl_billing).toBe(false);
+    });
+
+    it("accepts group_priorities as Record<string, number>", () => {
+      const result = prepareProviderBatchApplyUpdates({
+        group_priorities: { set: { us: 10, eu: 5 } },
+      });
+
+      expect(result.ok).toBe(true);
+      if (!result.ok) return;
+
+      expect(result.data.group_priorities).toEqual({ us: 10, eu: 5 });
+    });
+
+    it("rejects group_priorities with non-number values", () => {
+      const result = normalizeProviderBatchPatchDraft({
+        group_priorities: { set: { us: "high" } } as never,
+      });
+
+      expect(result.ok).toBe(false);
+      if (result.ok) return;
+
+      expect(result.error.field).toBe("group_priorities");
+    });
+
+    it("rejects group_priorities when array", () => {
+      const result = normalizeProviderBatchPatchDraft({
+        group_priorities: { set: [1, 2, 3] } as never,
+      });
+
+      expect(result.ok).toBe(false);
+      if (result.ok) return;
+
+      expect(result.error.field).toBe("group_priorities");
+    });
+
+    it("clears group_priorities to null", () => {
+      const result = prepareProviderBatchApplyUpdates({
+        group_priorities: { clear: true },
+      });
+
+      expect(result.ok).toBe(true);
+      if (!result.ok) return;
+
+      expect(result.data.group_priorities).toBeNull();
+    });
+
+    it.each([
+      ["cache_ttl_preference", "inherit"],
+      ["cache_ttl_preference", "5m"],
+      ["cache_ttl_preference", "1h"],
+    ] as const)("accepts valid %s value: %s", (field, value) => {
+      const result = prepareProviderBatchApplyUpdates({
+        [field]: { set: value },
+      });
+
+      expect(result.ok).toBe(true);
+      if (!result.ok) return;
+
+      expect(result.data[field]).toBe(value);
+    });
+
+    it("rejects invalid cache_ttl_preference value", () => {
+      const result = normalizeProviderBatchPatchDraft({
+        cache_ttl_preference: { set: "30m" } as never,
+      });
+
+      expect(result.ok).toBe(false);
+      if (result.ok) return;
+
+      expect(result.error.field).toBe("cache_ttl_preference");
+    });
+
+    it.each([
+      ["context_1m_preference", "inherit"],
+      ["context_1m_preference", "force_enable"],
+      ["context_1m_preference", "disabled"],
+    ] as const)("accepts valid %s value: %s", (field, value) => {
+      const result = prepareProviderBatchApplyUpdates({
+        [field]: { set: value },
+      });
+
+      expect(result.ok).toBe(true);
+      if (!result.ok) return;
+
+      expect(result.data[field]).toBe(value);
+    });
+
+    it.each([
+      ["codex_reasoning_effort_preference", "inherit"],
+      ["codex_reasoning_effort_preference", "none"],
+      ["codex_reasoning_effort_preference", "minimal"],
+      ["codex_reasoning_effort_preference", "low"],
+      ["codex_reasoning_effort_preference", "medium"],
+      ["codex_reasoning_effort_preference", "high"],
+      ["codex_reasoning_effort_preference", "xhigh"],
+    ] as const)("accepts valid %s value: %s", (field, value) => {
+      const result = prepareProviderBatchApplyUpdates({
+        [field]: { set: value },
+      });
+
+      expect(result.ok).toBe(true);
+      if (!result.ok) return;
+
+      expect(result.data[field]).toBe(value);
+    });
+
+    it("rejects invalid codex_reasoning_effort_preference value", () => {
+      const result = normalizeProviderBatchPatchDraft({
+        codex_reasoning_effort_preference: { set: "ultra" } as never,
+      });
+
+      expect(result.ok).toBe(false);
+      if (result.ok) return;
+
+      expect(result.error.field).toBe("codex_reasoning_effort_preference");
+    });
+
+    it.each([
+      ["codex_reasoning_summary_preference", "inherit"],
+      ["codex_reasoning_summary_preference", "auto"],
+      ["codex_reasoning_summary_preference", "detailed"],
+    ] as const)("accepts valid %s value: %s", (field, value) => {
+      const result = prepareProviderBatchApplyUpdates({
+        [field]: { set: value },
+      });
+
+      expect(result.ok).toBe(true);
+      if (!result.ok) return;
+
+      expect(result.data[field]).toBe(value);
+    });
+
+    it.each([
+      ["codex_text_verbosity_preference", "inherit"],
+      ["codex_text_verbosity_preference", "low"],
+      ["codex_text_verbosity_preference", "medium"],
+      ["codex_text_verbosity_preference", "high"],
+    ] as const)("accepts valid %s value: %s", (field, value) => {
+      const result = prepareProviderBatchApplyUpdates({
+        [field]: { set: value },
+      });
+
+      expect(result.ok).toBe(true);
+      if (!result.ok) return;
+
+      expect(result.data[field]).toBe(value);
+    });
+
+    it.each([
+      ["codex_parallel_tool_calls_preference", "inherit"],
+      ["codex_parallel_tool_calls_preference", "true"],
+      ["codex_parallel_tool_calls_preference", "false"],
+    ] as const)("accepts valid %s value: %s", (field, value) => {
+      const result = prepareProviderBatchApplyUpdates({
+        [field]: { set: value },
+      });
+
+      expect(result.ok).toBe(true);
+      if (!result.ok) return;
+
+      expect(result.data[field]).toBe(value);
+    });
+
+    it.each([
+      ["gemini_google_search_preference", "inherit"],
+      ["gemini_google_search_preference", "enabled"],
+      ["gemini_google_search_preference", "disabled"],
+    ] as const)("accepts valid %s value: %s", (field, value) => {
+      const result = prepareProviderBatchApplyUpdates({
+        [field]: { set: value },
+      });
+
+      expect(result.ok).toBe(true);
+      if (!result.ok) return;
+
+      expect(result.data[field]).toBe(value);
+    });
+
+    it("rejects invalid gemini_google_search_preference value", () => {
+      const result = normalizeProviderBatchPatchDraft({
+        gemini_google_search_preference: { set: "auto" } as never,
+      });
+
+      expect(result.ok).toBe(false);
+      if (result.ok) return;
+
+      expect(result.error.field).toBe("gemini_google_search_preference");
+    });
+  });
+
+  describe("anthropic_max_tokens_preference", () => {
+    it("accepts inherit", () => {
+      const result = prepareProviderBatchApplyUpdates({
+        anthropic_max_tokens_preference: { set: "inherit" },
+      });
+
+      expect(result.ok).toBe(true);
+      if (!result.ok) return;
+
+      expect(result.data.anthropic_max_tokens_preference).toBe("inherit");
+    });
+
+    it("accepts positive numeric string", () => {
+      const result = prepareProviderBatchApplyUpdates({
+        anthropic_max_tokens_preference: { set: "8192" },
+      });
+
+      expect(result.ok).toBe(true);
+      if (!result.ok) return;
+
+      expect(result.data.anthropic_max_tokens_preference).toBe("8192");
+    });
+
+    it("accepts small positive numeric string (no range restriction)", () => {
+      const result = prepareProviderBatchApplyUpdates({
+        anthropic_max_tokens_preference: { set: "1" },
+      });
+
+      expect(result.ok).toBe(true);
+      if (!result.ok) return;
+
+      expect(result.data.anthropic_max_tokens_preference).toBe("1");
+    });
+
+    it("rejects non-numeric string", () => {
+      const result = normalizeProviderBatchPatchDraft({
+        anthropic_max_tokens_preference: { set: "abc" } as never,
+      });
+
+      expect(result.ok).toBe(false);
+      if (result.ok) return;
+
+      expect(result.error.field).toBe("anthropic_max_tokens_preference");
+    });
+
+    it("rejects zero", () => {
+      const result = normalizeProviderBatchPatchDraft({
+        anthropic_max_tokens_preference: { set: "0" } as never,
+      });
+
+      expect(result.ok).toBe(false);
+      if (result.ok) return;
+
+      expect(result.error.field).toBe("anthropic_max_tokens_preference");
+    });
+
+    it("clears to inherit", () => {
+      const result = prepareProviderBatchApplyUpdates({
+        anthropic_max_tokens_preference: { clear: true },
+      });
+
+      expect(result.ok).toBe(true);
+      if (!result.ok) return;
+
+      expect(result.data.anthropic_max_tokens_preference).toBe("inherit");
+    });
+  });
+
+  describe("rate limit fields", () => {
+    it.each([
+      "limit_5h_usd",
+      "limit_daily_usd",
+      "limit_weekly_usd",
+      "limit_monthly_usd",
+      "limit_total_usd",
+    ] as const)("accepts number set and clears to null for %s", (field) => {
+      const setResult = prepareProviderBatchApplyUpdates({
+        [field]: { set: 100.5 },
+      });
+
+      expect(setResult.ok).toBe(true);
+      if (!setResult.ok) return;
+
+      expect(setResult.data[field]).toBe(100.5);
+
+      const clearResult = prepareProviderBatchApplyUpdates({
+        [field]: { clear: true },
+      });
+
+      expect(clearResult.ok).toBe(true);
+      if (!clearResult.ok) return;
+
+      expect(clearResult.data[field]).toBeNull();
+    });
+
+    it("rejects non-number for limit_5h_usd", () => {
+      const result = normalizeProviderBatchPatchDraft({
+        limit_5h_usd: { set: "100" } as never,
+      });
+
+      expect(result.ok).toBe(false);
+      if (result.ok) return;
+
+      expect(result.error.field).toBe("limit_5h_usd");
+    });
+
+    it("rejects NaN for number fields", () => {
+      const result = normalizeProviderBatchPatchDraft({
+        limit_daily_usd: { set: Number.NaN } as never,
+      });
+
+      expect(result.ok).toBe(false);
+      if (result.ok) return;
+
+      expect(result.error.field).toBe("limit_daily_usd");
+    });
+
+    it("rejects Infinity for number fields", () => {
+      const result = normalizeProviderBatchPatchDraft({
+        limit_weekly_usd: { set: Number.POSITIVE_INFINITY } as never,
+      });
+
+      expect(result.ok).toBe(false);
+      if (result.ok) return;
+
+      expect(result.error.field).toBe("limit_weekly_usd");
+    });
+
+    it("accepts limit_concurrent_sessions as number (non-clearable)", () => {
+      const result = prepareProviderBatchApplyUpdates({
+        limit_concurrent_sessions: { set: 5 },
+      });
+
+      expect(result.ok).toBe(true);
+      if (!result.ok) return;
+
+      expect(result.data.limit_concurrent_sessions).toBe(5);
+    });
+
+    it("rejects clear on limit_concurrent_sessions", () => {
+      const result = normalizeProviderBatchPatchDraft({
+        limit_concurrent_sessions: { clear: true } as never,
+      });
+
+      expect(result.ok).toBe(false);
+      if (result.ok) return;
+
+      expect(result.error.field).toBe("limit_concurrent_sessions");
+    });
+
+    it.each(["fixed", "rolling"] as const)("accepts daily_reset_mode value: %s", (value) => {
+      const result = prepareProviderBatchApplyUpdates({
+        daily_reset_mode: { set: value },
+      });
+
+      expect(result.ok).toBe(true);
+      if (!result.ok) return;
+
+      expect(result.data.daily_reset_mode).toBe(value);
+    });
+
+    it("rejects invalid daily_reset_mode value", () => {
+      const result = normalizeProviderBatchPatchDraft({
+        daily_reset_mode: { set: "hourly" } as never,
+      });
+
+      expect(result.ok).toBe(false);
+      if (result.ok) return;
+
+      expect(result.error.field).toBe("daily_reset_mode");
+    });
+
+    it("rejects clear on daily_reset_mode", () => {
+      const result = normalizeProviderBatchPatchDraft({
+        daily_reset_mode: { clear: true } as never,
+      });
+
+      expect(result.ok).toBe(false);
+      if (result.ok) return;
+
+      expect(result.error.field).toBe("daily_reset_mode");
+    });
+
+    it("accepts daily_reset_time as string (non-clearable)", () => {
+      const result = prepareProviderBatchApplyUpdates({
+        daily_reset_time: { set: "00:00" },
+      });
+
+      expect(result.ok).toBe(true);
+      if (!result.ok) return;
+
+      expect(result.data.daily_reset_time).toBe("00:00");
+    });
+
+    it("rejects clear on daily_reset_time", () => {
+      const result = normalizeProviderBatchPatchDraft({
+        daily_reset_time: { clear: true } as never,
+      });
+
+      expect(result.ok).toBe(false);
+      if (result.ok) return;
+
+      expect(result.error.field).toBe("daily_reset_time");
+    });
+  });
+
+  describe("circuit breaker fields", () => {
+    it.each([
+      "circuit_breaker_failure_threshold",
+      "circuit_breaker_open_duration",
+      "circuit_breaker_half_open_success_threshold",
+    ] as const)("accepts number set for %s (non-clearable)", (field) => {
+      const result = prepareProviderBatchApplyUpdates({
+        [field]: { set: 10 },
+      });
+
+      expect(result.ok).toBe(true);
+      if (!result.ok) return;
+
+      expect(result.data[field]).toBe(10);
+    });
+
+    it.each([
+      "circuit_breaker_failure_threshold",
+      "circuit_breaker_open_duration",
+      "circuit_breaker_half_open_success_threshold",
+    ] as const)("rejects clear on %s", (field) => {
+      const result = normalizeProviderBatchPatchDraft({
+        [field]: { clear: true } as never,
+      });
+
+      expect(result.ok).toBe(false);
+      if (result.ok) return;
+
+      expect(result.error.field).toBe(field);
+    });
+
+    it("accepts max_retry_attempts and clears to null", () => {
+      const setResult = prepareProviderBatchApplyUpdates({
+        max_retry_attempts: { set: 3 },
+      });
+
+      expect(setResult.ok).toBe(true);
+      if (!setResult.ok) return;
+
+      expect(setResult.data.max_retry_attempts).toBe(3);
+
+      const clearResult = prepareProviderBatchApplyUpdates({
+        max_retry_attempts: { clear: true },
+      });
+
+      expect(clearResult.ok).toBe(true);
+      if (!clearResult.ok) return;
+
+      expect(clearResult.data.max_retry_attempts).toBeNull();
+    });
+  });
+
+  describe("network fields", () => {
+    it("accepts proxy_url as string and clears to null", () => {
+      const setResult = prepareProviderBatchApplyUpdates({
+        proxy_url: { set: "socks5://proxy.example.com:1080" },
+      });
+
+      expect(setResult.ok).toBe(true);
+      if (!setResult.ok) return;
+
+      expect(setResult.data.proxy_url).toBe("socks5://proxy.example.com:1080");
+
+      const clearResult = prepareProviderBatchApplyUpdates({
+        proxy_url: { clear: true },
+      });
+
+      expect(clearResult.ok).toBe(true);
+      if (!clearResult.ok) return;
+
+      expect(clearResult.data.proxy_url).toBeNull();
+    });
+
+    it("accepts boolean set for proxy_fallback_to_direct (non-clearable)", () => {
+      const result = prepareProviderBatchApplyUpdates({
+        proxy_fallback_to_direct: { set: true },
+      });
+
+      expect(result.ok).toBe(true);
+      if (!result.ok) return;
+
+      expect(result.data.proxy_fallback_to_direct).toBe(true);
+    });
+
+    it("rejects clear on proxy_fallback_to_direct", () => {
+      const result = normalizeProviderBatchPatchDraft({
+        proxy_fallback_to_direct: { clear: true } as never,
+      });
+
+      expect(result.ok).toBe(false);
+      if (result.ok) return;
+
+      expect(result.error.field).toBe("proxy_fallback_to_direct");
+    });
+
+    it.each([
+      "first_byte_timeout_streaming_ms",
+      "streaming_idle_timeout_ms",
+      "request_timeout_non_streaming_ms",
+    ] as const)("accepts number set for %s (non-clearable)", (field) => {
+      const result = prepareProviderBatchApplyUpdates({
+        [field]: { set: 30000 },
+      });
+
+      expect(result.ok).toBe(true);
+      if (!result.ok) return;
+
+      expect(result.data[field]).toBe(30000);
+    });
+
+    it.each([
+      "first_byte_timeout_streaming_ms",
+      "streaming_idle_timeout_ms",
+      "request_timeout_non_streaming_ms",
+    ] as const)("rejects clear on %s", (field) => {
+      const result = normalizeProviderBatchPatchDraft({
+        [field]: { clear: true } as never,
+      });
+
+      expect(result.ok).toBe(false);
+      if (result.ok) return;
+
+      expect(result.error.field).toBe(field);
+    });
+  });
+
+  describe("MCP fields", () => {
+    it.each([
+      "none",
+      "minimax",
+      "glm",
+      "custom",
+    ] as const)("accepts mcp_passthrough_type value: %s", (value) => {
+      const result = prepareProviderBatchApplyUpdates({
+        mcp_passthrough_type: { set: value },
+      });
+
+      expect(result.ok).toBe(true);
+      if (!result.ok) return;
+
+      expect(result.data.mcp_passthrough_type).toBe(value);
+    });
+
+    it("rejects invalid mcp_passthrough_type value", () => {
+      const result = normalizeProviderBatchPatchDraft({
+        mcp_passthrough_type: { set: "openai" } as never,
+      });
+
+      expect(result.ok).toBe(false);
+      if (result.ok) return;
+
+      expect(result.error.field).toBe("mcp_passthrough_type");
+    });
+
+    it("rejects clear on mcp_passthrough_type", () => {
+      const result = normalizeProviderBatchPatchDraft({
+        mcp_passthrough_type: { clear: true } as never,
+      });
+
+      expect(result.ok).toBe(false);
+      if (result.ok) return;
+
+      expect(result.error.field).toBe("mcp_passthrough_type");
+    });
+
+    it("accepts mcp_passthrough_url as string and clears to null", () => {
+      const setResult = prepareProviderBatchApplyUpdates({
+        mcp_passthrough_url: { set: "https://api.minimaxi.com" },
+      });
+
+      expect(setResult.ok).toBe(true);
+      if (!setResult.ok) return;
+
+      expect(setResult.data.mcp_passthrough_url).toBe("https://api.minimaxi.com");
+
+      const clearResult = prepareProviderBatchApplyUpdates({
+        mcp_passthrough_url: { clear: true },
+      });
+
+      expect(clearResult.ok).toBe(true);
+      if (!clearResult.ok) return;
+
+      expect(clearResult.data.mcp_passthrough_url).toBeNull();
+    });
+  });
+
+  describe("preference fields clear to inherit", () => {
+    it.each([
+      "cache_ttl_preference",
+      "context_1m_preference",
+      "codex_reasoning_effort_preference",
+      "codex_reasoning_summary_preference",
+      "codex_text_verbosity_preference",
+      "codex_parallel_tool_calls_preference",
+      "anthropic_max_tokens_preference",
+      "gemini_google_search_preference",
+    ] as const)("clears %s to inherit", (field) => {
+      const result = prepareProviderBatchApplyUpdates({
+        [field]: { clear: true },
+      });
+
+      expect(result.ok).toBe(true);
+      if (!result.ok) return;
+
+      expect(result.data[field]).toBe("inherit");
+    });
+  });
+
+  describe("non-clearable field rejection", () => {
+    it.each([
+      "preserve_client_ip",
+      "swap_cache_ttl_billing",
+      "daily_reset_mode",
+      "daily_reset_time",
+      "limit_concurrent_sessions",
+      "circuit_breaker_failure_threshold",
+      "circuit_breaker_open_duration",
+      "circuit_breaker_half_open_success_threshold",
+      "proxy_fallback_to_direct",
+      "first_byte_timeout_streaming_ms",
+      "streaming_idle_timeout_ms",
+      "request_timeout_non_streaming_ms",
+      "mcp_passthrough_type",
+    ] as const)("rejects clear on non-clearable field: %s", (field) => {
+      const result = normalizeProviderBatchPatchDraft({
+        [field]: { clear: true } as never,
+      });
+
+      expect(result.ok).toBe(false);
+      if (result.ok) return;
+
+      expect(result.error.code).toBe(PROVIDER_PATCH_ERROR_CODES.INVALID_PATCH_SHAPE);
+      expect(result.error.field).toBe(field);
+    });
+  });
+
+  describe("hasProviderBatchPatchChanges for new fields", () => {
+    it("detects change on a single new field", () => {
+      const normalized = normalizeProviderBatchPatchDraft({
+        preserve_client_ip: { set: true },
+      });
+
+      expect(normalized.ok).toBe(true);
+      if (!normalized.ok) return;
+
+      expect(hasProviderBatchPatchChanges(normalized.data)).toBe(true);
+    });
+
+    it("detects change on mcp_passthrough_url (last field)", () => {
+      const normalized = normalizeProviderBatchPatchDraft({
+        mcp_passthrough_url: { set: "https://example.com" },
+      });
+
+      expect(normalized.ok).toBe(true);
+      if (!normalized.ok) return;
+
+      expect(hasProviderBatchPatchChanges(normalized.data)).toBe(true);
+    });
+
+    it("reports no change when all new fields are no_change", () => {
+      const normalized = normalizeProviderBatchPatchDraft({
+        preserve_client_ip: { no_change: true },
+        limit_5h_usd: { no_change: true },
+        proxy_url: { no_change: true },
+      });
+
+      expect(normalized.ok).toBe(true);
+      if (!normalized.ok) return;
+
+      expect(hasProviderBatchPatchChanges(normalized.data)).toBe(false);
+    });
+  });
+
+  describe("combined set across all categories", () => {
+    it("handles a batch patch touching all field categories at once", () => {
+      const result = prepareProviderBatchApplyUpdates({
+        // existing
+        is_enabled: { set: true },
+        group_tag: { set: "batch-test" },
+        // routing
+        preserve_client_ip: { set: false },
+        cache_ttl_preference: { set: "1h" },
+        codex_reasoning_effort_preference: { set: "high" },
+        anthropic_max_tokens_preference: { set: "16384" },
+        // rate limit
+        limit_5h_usd: { set: 50 },
+        daily_reset_mode: { set: "rolling" },
+        daily_reset_time: { set: "08:00" },
+        // circuit breaker
+        circuit_breaker_failure_threshold: { set: 5 },
+        max_retry_attempts: { set: 2 },
+        // network
+        proxy_url: { set: "https://proxy.local" },
+        proxy_fallback_to_direct: { set: true },
+        first_byte_timeout_streaming_ms: { set: 15000 },
+        // mcp
+        mcp_passthrough_type: { set: "minimax" },
+        mcp_passthrough_url: { set: "https://api.minimaxi.com" },
+      });
+
+      expect(result.ok).toBe(true);
+      if (!result.ok) return;
+
+      expect(result.data.is_enabled).toBe(true);
+      expect(result.data.group_tag).toBe("batch-test");
+      expect(result.data.preserve_client_ip).toBe(false);
+      expect(result.data.cache_ttl_preference).toBe("1h");
+      expect(result.data.codex_reasoning_effort_preference).toBe("high");
+      expect(result.data.anthropic_max_tokens_preference).toBe("16384");
+      expect(result.data.limit_5h_usd).toBe(50);
+      expect(result.data.daily_reset_mode).toBe("rolling");
+      expect(result.data.daily_reset_time).toBe("08:00");
+      expect(result.data.circuit_breaker_failure_threshold).toBe(5);
+      expect(result.data.max_retry_attempts).toBe(2);
+      expect(result.data.proxy_url).toBe("https://proxy.local");
+      expect(result.data.proxy_fallback_to_direct).toBe(true);
+      expect(result.data.first_byte_timeout_streaming_ms).toBe(15000);
+      expect(result.data.mcp_passthrough_type).toBe("minimax");
+      expect(result.data.mcp_passthrough_url).toBe("https://api.minimaxi.com");
+    });
+  });
 });

+ 647 - 0
tests/unit/settings/providers/build-patch-draft.test.ts

@@ -0,0 +1,647 @@
+import { describe, expect, it } from "vitest";
+import { buildPatchDraftFromFormState } from "@/app/[locale]/settings/providers/_components/batch-edit/build-patch-draft";
+import type { ProviderFormState } from "@/app/[locale]/settings/providers/_components/forms/provider-form/provider-form-types";
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+function createBatchState(): ProviderFormState {
+  return {
+    basic: { name: "", url: "", key: "", websiteUrl: "" },
+    routing: {
+      providerType: "claude",
+      groupTag: [],
+      preserveClientIp: false,
+      modelRedirects: {},
+      allowedModels: [],
+      priority: 0,
+      groupPriorities: {},
+      weight: 1,
+      costMultiplier: 1.0,
+      cacheTtlPreference: "inherit",
+      swapCacheTtlBilling: false,
+      context1mPreference: "inherit",
+      codexReasoningEffortPreference: "inherit",
+      codexReasoningSummaryPreference: "inherit",
+      codexTextVerbosityPreference: "inherit",
+      codexParallelToolCallsPreference: "inherit",
+      anthropicMaxTokensPreference: "inherit",
+      anthropicThinkingBudgetPreference: "inherit",
+      anthropicAdaptiveThinking: null,
+      geminiGoogleSearchPreference: "inherit",
+    },
+    rateLimit: {
+      limit5hUsd: null,
+      limitDailyUsd: null,
+      dailyResetMode: "fixed",
+      dailyResetTime: "00:00",
+      limitWeeklyUsd: null,
+      limitMonthlyUsd: null,
+      limitTotalUsd: null,
+      limitConcurrentSessions: null,
+    },
+    circuitBreaker: {
+      failureThreshold: undefined,
+      openDurationMinutes: undefined,
+      halfOpenSuccessThreshold: undefined,
+      maxRetryAttempts: null,
+    },
+    network: {
+      proxyUrl: "",
+      proxyFallbackToDirect: false,
+      firstByteTimeoutStreamingSeconds: undefined,
+      streamingIdleTimeoutSeconds: undefined,
+      requestTimeoutNonStreamingSeconds: undefined,
+    },
+    mcp: {
+      mcpPassthroughType: "none",
+      mcpPassthroughUrl: "",
+    },
+    batch: { isEnabled: "no_change" },
+    ui: {
+      activeTab: "basic",
+      isPending: false,
+      showFailureThresholdConfirm: false,
+    },
+  };
+}
+
+// ---------------------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------------------
+
+describe("buildPatchDraftFromFormState", () => {
+  it("returns empty draft when no fields are dirty", () => {
+    const state = createBatchState();
+    const dirty = new Set<string>();
+
+    const draft = buildPatchDraftFromFormState(state, dirty);
+
+    expect(draft).toEqual({});
+  });
+
+  it("includes isEnabled=true when dirty and set to true", () => {
+    const state = createBatchState();
+    state.batch.isEnabled = "true";
+    const dirty = new Set(["batch.isEnabled"]);
+
+    const draft = buildPatchDraftFromFormState(state, dirty);
+
+    expect(draft.is_enabled).toEqual({ set: true });
+  });
+
+  it("includes isEnabled=false when dirty and set to false", () => {
+    const state = createBatchState();
+    state.batch.isEnabled = "false";
+    const dirty = new Set(["batch.isEnabled"]);
+
+    const draft = buildPatchDraftFromFormState(state, dirty);
+
+    expect(draft.is_enabled).toEqual({ set: false });
+  });
+
+  it("skips isEnabled when dirty but value is no_change", () => {
+    const state = createBatchState();
+    state.batch.isEnabled = "no_change";
+    const dirty = new Set(["batch.isEnabled"]);
+
+    const draft = buildPatchDraftFromFormState(state, dirty);
+
+    expect(draft.is_enabled).toBeUndefined();
+  });
+
+  it("sets priority when dirty", () => {
+    const state = createBatchState();
+    state.routing.priority = 10;
+    const dirty = new Set(["routing.priority"]);
+
+    const draft = buildPatchDraftFromFormState(state, dirty);
+
+    expect(draft.priority).toEqual({ set: 10 });
+  });
+
+  it("sets weight when dirty", () => {
+    const state = createBatchState();
+    state.routing.weight = 5;
+    const dirty = new Set(["routing.weight"]);
+
+    const draft = buildPatchDraftFromFormState(state, dirty);
+
+    expect(draft.weight).toEqual({ set: 5 });
+  });
+
+  it("sets costMultiplier when dirty", () => {
+    const state = createBatchState();
+    state.routing.costMultiplier = 2.5;
+    const dirty = new Set(["routing.costMultiplier"]);
+
+    const draft = buildPatchDraftFromFormState(state, dirty);
+
+    expect(draft.cost_multiplier).toEqual({ set: 2.5 });
+  });
+
+  it("clears groupTag when dirty and empty array", () => {
+    const state = createBatchState();
+    state.routing.groupTag = [];
+    const dirty = new Set(["routing.groupTag"]);
+
+    const draft = buildPatchDraftFromFormState(state, dirty);
+
+    expect(draft.group_tag).toEqual({ clear: true });
+  });
+
+  it("sets groupTag with joined value when dirty and non-empty", () => {
+    const state = createBatchState();
+    state.routing.groupTag = ["tagA", "tagB"];
+    const dirty = new Set(["routing.groupTag"]);
+
+    const draft = buildPatchDraftFromFormState(state, dirty);
+
+    expect(draft.group_tag).toEqual({ set: "tagA, tagB" });
+  });
+
+  it("clears modelRedirects when dirty and empty object", () => {
+    const state = createBatchState();
+    const dirty = new Set(["routing.modelRedirects"]);
+
+    const draft = buildPatchDraftFromFormState(state, dirty);
+
+    expect(draft.model_redirects).toEqual({ clear: true });
+  });
+
+  it("sets modelRedirects when dirty and has entries", () => {
+    const state = createBatchState();
+    state.routing.modelRedirects = { "model-a": "model-b" };
+    const dirty = new Set(["routing.modelRedirects"]);
+
+    const draft = buildPatchDraftFromFormState(state, dirty);
+
+    expect(draft.model_redirects).toEqual({ set: { "model-a": "model-b" } });
+  });
+
+  it("clears allowedModels when dirty and empty array", () => {
+    const state = createBatchState();
+    const dirty = new Set(["routing.allowedModels"]);
+
+    const draft = buildPatchDraftFromFormState(state, dirty);
+
+    expect(draft.allowed_models).toEqual({ clear: true });
+  });
+
+  it("sets allowedModels when dirty and non-empty", () => {
+    const state = createBatchState();
+    state.routing.allowedModels = ["claude-opus-4-6"];
+    const dirty = new Set(["routing.allowedModels"]);
+
+    const draft = buildPatchDraftFromFormState(state, dirty);
+
+    expect(draft.allowed_models).toEqual({ set: ["claude-opus-4-6"] });
+  });
+
+  // --- inherit/clear pattern fields ---
+
+  it("clears cacheTtlPreference when dirty and inherit", () => {
+    const state = createBatchState();
+    const dirty = new Set(["routing.cacheTtlPreference"]);
+
+    const draft = buildPatchDraftFromFormState(state, dirty);
+
+    expect(draft.cache_ttl_preference).toEqual({ clear: true });
+  });
+
+  it("sets cacheTtlPreference when dirty and not inherit", () => {
+    const state = createBatchState();
+    state.routing.cacheTtlPreference = "5m";
+    const dirty = new Set(["routing.cacheTtlPreference"]);
+
+    const draft = buildPatchDraftFromFormState(state, dirty);
+
+    expect(draft.cache_ttl_preference).toEqual({ set: "5m" });
+  });
+
+  it("sets preserveClientIp when dirty", () => {
+    const state = createBatchState();
+    state.routing.preserveClientIp = true;
+    const dirty = new Set(["routing.preserveClientIp"]);
+
+    const draft = buildPatchDraftFromFormState(state, dirty);
+
+    expect(draft.preserve_client_ip).toEqual({ set: true });
+  });
+
+  it("sets swapCacheTtlBilling when dirty", () => {
+    const state = createBatchState();
+    state.routing.swapCacheTtlBilling = true;
+    const dirty = new Set(["routing.swapCacheTtlBilling"]);
+
+    const draft = buildPatchDraftFromFormState(state, dirty);
+
+    expect(draft.swap_cache_ttl_billing).toEqual({ set: true });
+  });
+
+  it("clears context1mPreference when dirty and inherit", () => {
+    const state = createBatchState();
+    const dirty = new Set(["routing.context1mPreference"]);
+
+    const draft = buildPatchDraftFromFormState(state, dirty);
+
+    expect(draft.context_1m_preference).toEqual({ clear: true });
+  });
+
+  it("sets context1mPreference when dirty and not inherit", () => {
+    const state = createBatchState();
+    state.routing.context1mPreference = "force_enable";
+    const dirty = new Set(["routing.context1mPreference"]);
+
+    const draft = buildPatchDraftFromFormState(state, dirty);
+
+    expect(draft.context_1m_preference).toEqual({ set: "force_enable" });
+  });
+
+  it("clears codexReasoningEffortPreference when dirty and inherit", () => {
+    const state = createBatchState();
+    const dirty = new Set(["routing.codexReasoningEffortPreference"]);
+
+    const draft = buildPatchDraftFromFormState(state, dirty);
+
+    expect(draft.codex_reasoning_effort_preference).toEqual({ clear: true });
+  });
+
+  it("sets codexReasoningEffortPreference when dirty and not inherit", () => {
+    const state = createBatchState();
+    state.routing.codexReasoningEffortPreference = "high";
+    const dirty = new Set(["routing.codexReasoningEffortPreference"]);
+
+    const draft = buildPatchDraftFromFormState(state, dirty);
+
+    expect(draft.codex_reasoning_effort_preference).toEqual({ set: "high" });
+  });
+
+  it("clears anthropicThinkingBudgetPreference when dirty and inherit", () => {
+    const state = createBatchState();
+    const dirty = new Set(["routing.anthropicThinkingBudgetPreference"]);
+
+    const draft = buildPatchDraftFromFormState(state, dirty);
+
+    expect(draft.anthropic_thinking_budget_preference).toEqual({ clear: true });
+  });
+
+  it("sets anthropicThinkingBudgetPreference when dirty and not inherit", () => {
+    const state = createBatchState();
+    state.routing.anthropicThinkingBudgetPreference = "32000";
+    const dirty = new Set(["routing.anthropicThinkingBudgetPreference"]);
+
+    const draft = buildPatchDraftFromFormState(state, dirty);
+
+    expect(draft.anthropic_thinking_budget_preference).toEqual({ set: "32000" });
+  });
+
+  it("clears anthropicAdaptiveThinking when dirty and null", () => {
+    const state = createBatchState();
+    const dirty = new Set(["routing.anthropicAdaptiveThinking"]);
+
+    const draft = buildPatchDraftFromFormState(state, dirty);
+
+    expect(draft.anthropic_adaptive_thinking).toEqual({ clear: true });
+  });
+
+  it("sets anthropicAdaptiveThinking when dirty and configured", () => {
+    const state = createBatchState();
+    state.routing.anthropicAdaptiveThinking = {
+      effort: "high",
+      modelMatchMode: "specific",
+      models: ["claude-opus-4-6"],
+    };
+    const dirty = new Set(["routing.anthropicAdaptiveThinking"]);
+
+    const draft = buildPatchDraftFromFormState(state, dirty);
+
+    expect(draft.anthropic_adaptive_thinking).toEqual({
+      set: {
+        effort: "high",
+        modelMatchMode: "specific",
+        models: ["claude-opus-4-6"],
+      },
+    });
+  });
+
+  it("clears geminiGoogleSearchPreference when dirty and inherit", () => {
+    const state = createBatchState();
+    const dirty = new Set(["routing.geminiGoogleSearchPreference"]);
+
+    const draft = buildPatchDraftFromFormState(state, dirty);
+
+    expect(draft.gemini_google_search_preference).toEqual({ clear: true });
+  });
+
+  it("sets geminiGoogleSearchPreference when dirty and not inherit", () => {
+    const state = createBatchState();
+    state.routing.geminiGoogleSearchPreference = "enabled";
+    const dirty = new Set(["routing.geminiGoogleSearchPreference"]);
+
+    const draft = buildPatchDraftFromFormState(state, dirty);
+
+    expect(draft.gemini_google_search_preference).toEqual({ set: "enabled" });
+  });
+
+  // --- Rate limit fields ---
+
+  it("clears limit5hUsd when dirty and null", () => {
+    const state = createBatchState();
+    const dirty = new Set(["rateLimit.limit5hUsd"]);
+
+    const draft = buildPatchDraftFromFormState(state, dirty);
+
+    expect(draft.limit_5h_usd).toEqual({ clear: true });
+  });
+
+  it("sets limit5hUsd when dirty and has value", () => {
+    const state = createBatchState();
+    state.rateLimit.limit5hUsd = 50;
+    const dirty = new Set(["rateLimit.limit5hUsd"]);
+
+    const draft = buildPatchDraftFromFormState(state, dirty);
+
+    expect(draft.limit_5h_usd).toEqual({ set: 50 });
+  });
+
+  it("sets dailyResetMode when dirty", () => {
+    const state = createBatchState();
+    state.rateLimit.dailyResetMode = "rolling";
+    const dirty = new Set(["rateLimit.dailyResetMode"]);
+
+    const draft = buildPatchDraftFromFormState(state, dirty);
+
+    expect(draft.daily_reset_mode).toEqual({ set: "rolling" });
+  });
+
+  it("sets dailyResetTime when dirty", () => {
+    const state = createBatchState();
+    state.rateLimit.dailyResetTime = "12:00";
+    const dirty = new Set(["rateLimit.dailyResetTime"]);
+
+    const draft = buildPatchDraftFromFormState(state, dirty);
+
+    expect(draft.daily_reset_time).toEqual({ set: "12:00" });
+  });
+
+  it("clears maxRetryAttempts when dirty and null", () => {
+    const state = createBatchState();
+    const dirty = new Set(["circuitBreaker.maxRetryAttempts"]);
+
+    const draft = buildPatchDraftFromFormState(state, dirty);
+
+    expect(draft.max_retry_attempts).toEqual({ clear: true });
+  });
+
+  it("sets maxRetryAttempts when dirty and has value", () => {
+    const state = createBatchState();
+    state.circuitBreaker.maxRetryAttempts = 3;
+    const dirty = new Set(["circuitBreaker.maxRetryAttempts"]);
+
+    const draft = buildPatchDraftFromFormState(state, dirty);
+
+    expect(draft.max_retry_attempts).toEqual({ set: 3 });
+  });
+
+  // --- Unit conversion: circuit breaker minutes -> ms ---
+
+  it("converts openDurationMinutes to ms", () => {
+    const state = createBatchState();
+    state.circuitBreaker.openDurationMinutes = 5;
+    const dirty = new Set(["circuitBreaker.openDurationMinutes"]);
+
+    const draft = buildPatchDraftFromFormState(state, dirty);
+
+    expect(draft.circuit_breaker_open_duration).toEqual({ set: 300000 });
+  });
+
+  it("sets openDuration to 0 when dirty and undefined", () => {
+    const state = createBatchState();
+    const dirty = new Set(["circuitBreaker.openDurationMinutes"]);
+
+    const draft = buildPatchDraftFromFormState(state, dirty);
+
+    expect(draft.circuit_breaker_open_duration).toEqual({ set: 0 });
+  });
+
+  it("sets failureThreshold to 0 when dirty and undefined", () => {
+    const state = createBatchState();
+    const dirty = new Set(["circuitBreaker.failureThreshold"]);
+
+    const draft = buildPatchDraftFromFormState(state, dirty);
+
+    expect(draft.circuit_breaker_failure_threshold).toEqual({ set: 0 });
+  });
+
+  it("sets failureThreshold when dirty and has value", () => {
+    const state = createBatchState();
+    state.circuitBreaker.failureThreshold = 10;
+    const dirty = new Set(["circuitBreaker.failureThreshold"]);
+
+    const draft = buildPatchDraftFromFormState(state, dirty);
+
+    expect(draft.circuit_breaker_failure_threshold).toEqual({ set: 10 });
+  });
+
+  // --- Unit conversion: network seconds -> ms ---
+
+  it("converts firstByteTimeoutStreamingSeconds to ms", () => {
+    const state = createBatchState();
+    state.network.firstByteTimeoutStreamingSeconds = 30;
+    const dirty = new Set(["network.firstByteTimeoutStreamingSeconds"]);
+
+    const draft = buildPatchDraftFromFormState(state, dirty);
+
+    expect(draft.first_byte_timeout_streaming_ms).toEqual({ set: 30000 });
+  });
+
+  it("sets firstByteTimeoutStreamingMs to 0 when dirty and undefined", () => {
+    const state = createBatchState();
+    const dirty = new Set(["network.firstByteTimeoutStreamingSeconds"]);
+
+    const draft = buildPatchDraftFromFormState(state, dirty);
+
+    expect(draft.first_byte_timeout_streaming_ms).toEqual({ set: 0 });
+  });
+
+  it("converts streamingIdleTimeoutSeconds to ms", () => {
+    const state = createBatchState();
+    state.network.streamingIdleTimeoutSeconds = 120;
+    const dirty = new Set(["network.streamingIdleTimeoutSeconds"]);
+
+    const draft = buildPatchDraftFromFormState(state, dirty);
+
+    expect(draft.streaming_idle_timeout_ms).toEqual({ set: 120000 });
+  });
+
+  it("converts requestTimeoutNonStreamingSeconds to ms", () => {
+    const state = createBatchState();
+    state.network.requestTimeoutNonStreamingSeconds = 60;
+    const dirty = new Set(["network.requestTimeoutNonStreamingSeconds"]);
+
+    const draft = buildPatchDraftFromFormState(state, dirty);
+
+    expect(draft.request_timeout_non_streaming_ms).toEqual({ set: 60000 });
+  });
+
+  // --- Network fields ---
+
+  it("clears proxyUrl when dirty and empty string", () => {
+    const state = createBatchState();
+    const dirty = new Set(["network.proxyUrl"]);
+
+    const draft = buildPatchDraftFromFormState(state, dirty);
+
+    expect(draft.proxy_url).toEqual({ clear: true });
+  });
+
+  it("sets proxyUrl when dirty and has value", () => {
+    const state = createBatchState();
+    state.network.proxyUrl = "socks5://proxy.example.com:1080";
+    const dirty = new Set(["network.proxyUrl"]);
+
+    const draft = buildPatchDraftFromFormState(state, dirty);
+
+    expect(draft.proxy_url).toEqual({ set: "socks5://proxy.example.com:1080" });
+  });
+
+  it("sets proxyFallbackToDirect when dirty", () => {
+    const state = createBatchState();
+    state.network.proxyFallbackToDirect = true;
+    const dirty = new Set(["network.proxyFallbackToDirect"]);
+
+    const draft = buildPatchDraftFromFormState(state, dirty);
+
+    expect(draft.proxy_fallback_to_direct).toEqual({ set: true });
+  });
+
+  // --- MCP fields ---
+
+  it("sets mcpPassthroughType when dirty", () => {
+    const state = createBatchState();
+    state.mcp.mcpPassthroughType = "minimax";
+    const dirty = new Set(["mcp.mcpPassthroughType"]);
+
+    const draft = buildPatchDraftFromFormState(state, dirty);
+
+    expect(draft.mcp_passthrough_type).toEqual({ set: "minimax" });
+  });
+
+  it("sets mcpPassthroughType to none when dirty", () => {
+    const state = createBatchState();
+    const dirty = new Set(["mcp.mcpPassthroughType"]);
+
+    const draft = buildPatchDraftFromFormState(state, dirty);
+
+    expect(draft.mcp_passthrough_type).toEqual({ set: "none" });
+  });
+
+  it("clears mcpPassthroughUrl when dirty and empty", () => {
+    const state = createBatchState();
+    const dirty = new Set(["mcp.mcpPassthroughUrl"]);
+
+    const draft = buildPatchDraftFromFormState(state, dirty);
+
+    expect(draft.mcp_passthrough_url).toEqual({ clear: true });
+  });
+
+  it("sets mcpPassthroughUrl when dirty and has value", () => {
+    const state = createBatchState();
+    state.mcp.mcpPassthroughUrl = "https://mcp.example.com";
+    const dirty = new Set(["mcp.mcpPassthroughUrl"]);
+
+    const draft = buildPatchDraftFromFormState(state, dirty);
+
+    expect(draft.mcp_passthrough_url).toEqual({ set: "https://mcp.example.com" });
+  });
+
+  // --- Multi-field scenario ---
+
+  it("only includes dirty fields in draft, ignoring non-dirty", () => {
+    const state = createBatchState();
+    state.routing.priority = 10;
+    state.routing.weight = 5;
+    state.routing.costMultiplier = 2.0;
+
+    // Only mark priority as dirty
+    const dirty = new Set(["routing.priority"]);
+
+    const draft = buildPatchDraftFromFormState(state, dirty);
+
+    expect(draft.priority).toEqual({ set: 10 });
+    expect(draft.weight).toBeUndefined();
+    expect(draft.cost_multiplier).toBeUndefined();
+  });
+
+  it("handles multiple dirty fields correctly", () => {
+    const state = createBatchState();
+    state.batch.isEnabled = "true";
+    state.routing.priority = 5;
+    state.routing.weight = 3;
+    state.rateLimit.limit5hUsd = 100;
+    state.network.proxyUrl = "http://proxy:8080";
+
+    const dirty = new Set([
+      "batch.isEnabled",
+      "routing.priority",
+      "routing.weight",
+      "rateLimit.limit5hUsd",
+      "network.proxyUrl",
+    ]);
+
+    const draft = buildPatchDraftFromFormState(state, dirty);
+
+    expect(draft.is_enabled).toEqual({ set: true });
+    expect(draft.priority).toEqual({ set: 5 });
+    expect(draft.weight).toEqual({ set: 3 });
+    expect(draft.limit_5h_usd).toEqual({ set: 100 });
+    expect(draft.proxy_url).toEqual({ set: "http://proxy:8080" });
+    // Non-dirty fields should be absent
+    expect(draft.cost_multiplier).toBeUndefined();
+    expect(draft.group_tag).toBeUndefined();
+  });
+
+  // --- groupPriorities ---
+
+  it("clears groupPriorities when dirty and empty object", () => {
+    const state = createBatchState();
+    const dirty = new Set(["routing.groupPriorities"]);
+
+    const draft = buildPatchDraftFromFormState(state, dirty);
+
+    expect(draft.group_priorities).toEqual({ clear: true });
+  });
+
+  it("sets groupPriorities when dirty and has entries", () => {
+    const state = createBatchState();
+    state.routing.groupPriorities = { groupA: 1, groupB: 2 };
+    const dirty = new Set(["routing.groupPriorities"]);
+
+    const draft = buildPatchDraftFromFormState(state, dirty);
+
+    expect(draft.group_priorities).toEqual({ set: { groupA: 1, groupB: 2 } });
+  });
+
+  // --- limitConcurrentSessions null -> 0 edge case ---
+
+  it("sets limitConcurrentSessions to 0 when dirty and null", () => {
+    const state = createBatchState();
+    const dirty = new Set(["rateLimit.limitConcurrentSessions"]);
+
+    const draft = buildPatchDraftFromFormState(state, dirty);
+
+    expect(draft.limit_concurrent_sessions).toEqual({ set: 0 });
+  });
+
+  it("sets limitConcurrentSessions when dirty and has value", () => {
+    const state = createBatchState();
+    state.rateLimit.limitConcurrentSessions = 20;
+    const dirty = new Set(["rateLimit.limitConcurrentSessions"]);
+
+    const draft = buildPatchDraftFromFormState(state, dirty);
+
+    expect(draft.limit_concurrent_sessions).toEqual({ set: 20 });
+  });
+});

+ 214 - 228
tests/unit/settings/providers/provider-batch-dialog-step1.test.tsx

@@ -8,6 +8,65 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
 import { ProviderBatchDialog } from "@/app/[locale]/settings/providers/_components/batch-edit/provider-batch-dialog";
 import type { ProviderDisplay } from "@/types/provider";
 
+// ---------------------------------------------------------------------------
+// Mutable mock state for useProviderForm
+// ---------------------------------------------------------------------------
+
+let mockDirtyFields = new Set<string>();
+const mockDispatch = vi.fn();
+let mockActiveTab = "basic";
+const mockState = {
+  ui: { activeTab: mockActiveTab, isPending: false, showFailureThresholdConfirm: false },
+  basic: { name: "", url: "", key: "", websiteUrl: "" },
+  routing: {
+    providerType: "claude" as const,
+    groupTag: [],
+    preserveClientIp: false,
+    modelRedirects: {},
+    allowedModels: [],
+    priority: 0,
+    groupPriorities: {},
+    weight: 1,
+    costMultiplier: 1,
+    cacheTtlPreference: "inherit" as const,
+    swapCacheTtlBilling: false,
+    context1mPreference: "inherit" as const,
+    codexReasoningEffortPreference: "inherit",
+    codexReasoningSummaryPreference: "inherit",
+    codexTextVerbosityPreference: "inherit",
+    codexParallelToolCallsPreference: "inherit",
+    anthropicMaxTokensPreference: "inherit",
+    anthropicThinkingBudgetPreference: "inherit",
+    anthropicAdaptiveThinking: null,
+    geminiGoogleSearchPreference: "inherit",
+  },
+  rateLimit: {
+    limit5hUsd: null,
+    limitDailyUsd: null,
+    dailyResetMode: "fixed" as const,
+    dailyResetTime: "00:00",
+    limitWeeklyUsd: null,
+    limitMonthlyUsd: null,
+    limitTotalUsd: null,
+    limitConcurrentSessions: null,
+  },
+  circuitBreaker: {
+    failureThreshold: undefined,
+    openDurationMinutes: undefined,
+    halfOpenSuccessThreshold: undefined,
+    maxRetryAttempts: null,
+  },
+  network: {
+    proxyUrl: "",
+    proxyFallbackToDirect: false,
+    firstByteTimeoutStreamingSeconds: undefined,
+    streamingIdleTimeoutSeconds: undefined,
+    requestTimeoutNonStreamingSeconds: undefined,
+  },
+  mcp: { mcpPassthroughType: "none" as const, mcpPassthroughUrl: "" },
+  batch: { isEnabled: "no_change" as const },
+};
+
 // ---------------------------------------------------------------------------
 // Mocks
 // ---------------------------------------------------------------------------
@@ -42,12 +101,96 @@ vi.mock("sonner", () => ({
 }));
 
 vi.mock("@/actions/providers", () => ({
-  batchUpdateProviders: vi.fn().mockResolvedValue({ ok: true, data: { updatedCount: 2 } }),
+  previewProviderBatchPatch: vi.fn().mockResolvedValue({
+    ok: true,
+    data: {
+      previewToken: "tok-1",
+      previewRevision: "rev-1",
+      rows: [],
+      summary: { providerCount: 0, fieldCount: 0, skipCount: 0 },
+    },
+  }),
+  applyProviderBatchPatch: vi.fn().mockResolvedValue({ ok: true, data: { updatedCount: 2 } }),
+  undoProviderPatch: vi.fn().mockResolvedValue({ ok: true, data: { revertedCount: 2 } }),
   batchDeleteProviders: vi.fn().mockResolvedValue({ ok: true, data: { deletedCount: 2 } }),
   batchResetProviderCircuits: vi.fn().mockResolvedValue({ ok: true, data: { resetCount: 2 } }),
 }));
 
-// Dialog mock - respects `open` prop
+// Mock ProviderFormProvider + useProviderForm
+vi.mock(
+  "@/app/[locale]/settings/providers/_components/forms/provider-form/provider-form-context",
+  () => ({
+    ProviderFormProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
+    useProviderForm: () => ({
+      state: mockState,
+      dispatch: mockDispatch,
+      dirtyFields: mockDirtyFields,
+      mode: "batch",
+    }),
+  })
+);
+
+// Mock all form section components as stubs
+vi.mock(
+  "@/app/[locale]/settings/providers/_components/forms/provider-form/sections/basic-info-section",
+  () => ({
+    BasicInfoSection: () => <div data-testid="basic-info-section">BasicInfoSection</div>,
+  })
+);
+vi.mock(
+  "@/app/[locale]/settings/providers/_components/forms/provider-form/sections/routing-section",
+  () => ({
+    RoutingSection: () => <div data-testid="routing-section">RoutingSection</div>,
+  })
+);
+vi.mock(
+  "@/app/[locale]/settings/providers/_components/forms/provider-form/sections/limits-section",
+  () => ({
+    LimitsSection: () => <div data-testid="limits-section">LimitsSection</div>,
+  })
+);
+vi.mock(
+  "@/app/[locale]/settings/providers/_components/forms/provider-form/sections/network-section",
+  () => ({
+    NetworkSection: () => <div data-testid="network-section">NetworkSection</div>,
+  })
+);
+vi.mock(
+  "@/app/[locale]/settings/providers/_components/forms/provider-form/sections/testing-section",
+  () => ({
+    TestingSection: () => <div data-testid="testing-section">TestingSection</div>,
+  })
+);
+
+// Mock FormTabNav
+vi.mock(
+  "@/app/[locale]/settings/providers/_components/forms/provider-form/components/form-tab-nav",
+  () => ({
+    FormTabNav: ({ activeTab }: { activeTab: string }) => (
+      <div data-testid="form-tab-nav" data-active-tab={activeTab}>
+        FormTabNav
+      </div>
+    ),
+  })
+);
+
+// Mock ProviderBatchPreviewStep
+vi.mock(
+  "@/app/[locale]/settings/providers/_components/batch-edit/provider-batch-preview-step",
+  () => ({
+    ProviderBatchPreviewStep: () => <div data-testid="preview-step">PreviewStep</div>,
+  })
+);
+
+// Mock buildPatchDraftFromFormState
+vi.mock(
+  "@/app/[locale]/settings/providers/_components/batch-edit/build-patch-draft",
+  () => ({
+    buildPatchDraftFromFormState: vi.fn().mockReturnValue({}),
+  })
+);
+
+// UI component mocks
 vi.mock("@/components/ui/dialog", () => ({
   Dialog: ({ open, children }: { open: boolean; children: React.ReactNode }) =>
     open ? <div data-testid="dialog">{children}</div> : null,
@@ -84,96 +227,6 @@ vi.mock("@/components/ui/button", () => ({
   Button: ({ children, ...props }: any) => <button {...props}>{children}</button>,
 }));
 
-vi.mock("@/components/ui/input", () => ({
-  Input: (props: any) => <input {...props} />,
-}));
-
-vi.mock("@/components/ui/label", () => ({
-  Label: ({ children, ...props }: any) => <label {...props}>{children}</label>,
-}));
-
-vi.mock("@/components/ui/separator", () => ({
-  Separator: () => <hr data-testid="separator" />,
-}));
-
-vi.mock("@/components/ui/select", () => ({
-  Select: ({
-    children,
-    value,
-    onValueChange,
-    disabled,
-  }: {
-    children: React.ReactNode;
-    value: string;
-    onValueChange: (val: string) => void;
-    disabled?: boolean;
-  }) => (
-    <div data-testid="select-mock">
-      <select
-        data-testid="select-trigger"
-        value={value}
-        onChange={(e) => onValueChange(e.target.value)}
-        disabled={disabled}
-      >
-        {children}
-      </select>
-    </div>
-  ),
-  SelectTrigger: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
-  SelectValue: () => null,
-  SelectContent: ({ children }: { children: React.ReactNode }) => <>{children}</>,
-  SelectItem: ({ value, children }: { value: string; children: React.ReactNode }) => (
-    <option value={value}>{children}</option>
-  ),
-}));
-
-// Mock ThinkingBudgetEditor
-vi.mock("@/app/[locale]/settings/providers/_components/thinking-budget-editor", () => ({
-  ThinkingBudgetEditor: ({
-    value,
-    onChange,
-    disabled,
-  }: {
-    value: string;
-    onChange: (v: string) => void;
-    disabled?: boolean;
-  }) => (
-    <div data-testid="thinking-budget-editor" data-value={value} data-disabled={disabled}>
-      <input
-        data-testid="thinking-budget-input"
-        value={value}
-        onChange={(e) => onChange(e.target.value)}
-        disabled={disabled}
-      />
-    </div>
-  ),
-}));
-
-// Mock AdaptiveThinkingEditor
-vi.mock("@/app/[locale]/settings/providers/_components/adaptive-thinking-editor", () => ({
-  AdaptiveThinkingEditor: ({
-    enabled,
-    onEnabledChange,
-    disabled,
-  }: {
-    enabled: boolean;
-    onEnabledChange: (v: boolean) => void;
-    onConfigChange: (config: any) => void;
-    config: any;
-    disabled?: boolean;
-  }) => (
-    <div
-      data-testid="adaptive-thinking-editor"
-      data-enabled={String(enabled)}
-      data-disabled={String(disabled ?? false)}
-    >
-      <button data-testid="adaptive-thinking-switch" onClick={() => onEnabledChange(!enabled)}>
-        {enabled ? "On" : "Off"}
-      </button>
-    </div>
-  ),
-}));
-
 vi.mock("lucide-react", () => ({
   Loader2: () => <div data-testid="loader-icon" />,
 }));
@@ -243,12 +296,11 @@ function createMockProvider(id: number, name: string, maskedKey: string): Provid
 function render(node: React.ReactNode) {
   const container = document.createElement("div");
   document.body.appendChild(container);
-  const root = createRoot(container);
-
+  let root: ReturnType<typeof createRoot>;
   act(() => {
+    root = createRoot(container);
     root.render(node);
   });
-
   return {
     container,
     unmount: () => {
@@ -287,61 +339,42 @@ function defaultProps(overrides: Record<string, unknown> = {}) {
 // Tests
 // ---------------------------------------------------------------------------
 
-describe("ProviderBatchDialog - Step1 Edit Mode Refactor", () => {
+describe("ProviderBatchDialog - Edit Mode Structure", () => {
   beforeEach(() => {
     vi.clearAllMocks();
+    mockDirtyFields = new Set<string>();
+    mockActiveTab = "basic";
+    mockState.ui.activeTab = "basic";
   });
 
-  it("renders edit mode with three sections", () => {
-    const { container, unmount } = render(<ProviderBatchDialog {...defaultProps()} />);
-
-    const text = container.textContent ?? "";
-
-    expect(text).toContain("sections.basic");
-    expect(text).toContain("sections.routing");
-    expect(text).toContain("sections.anthropic");
-
-    unmount();
-  });
-
-  it("isEnabled defaults to no_change - no change selected", () => {
+  it("renders edit mode with FormTabNav and basic section", () => {
     const { container, unmount } = render(<ProviderBatchDialog {...defaultProps()} />);
 
-    // The isEnabled select is identified by data-field attribute
-    const isEnabledSelect = container.querySelector(
-      '[data-field="isEnabled"] select'
-    ) as HTMLSelectElement;
-
-    expect(isEnabledSelect).toBeTruthy();
-    expect(isEnabledSelect.value).toBe("no_change");
+    expect(container.querySelector('[data-testid="dialog"]')).toBeTruthy();
+    expect(container.querySelector('[data-testid="form-tab-nav"]')).toBeTruthy();
+    expect(container.querySelector('[data-testid="basic-info-section"]')).toBeTruthy();
 
     unmount();
   });
 
-  it("changing isEnabled to true reflects in state", () => {
+  it("renders dialog title and description in edit step", () => {
     const { container, unmount } = render(<ProviderBatchDialog {...defaultProps()} />);
 
-    const isEnabledSelect = container.querySelector(
-      '[data-field="isEnabled"] select'
-    ) as HTMLSelectElement;
-
-    act(() => {
-      isEnabledSelect.value = "true";
-      isEnabledSelect.dispatchEvent(new Event("change", { bubbles: true }));
-    });
+    const titleEl = container.querySelector('[data-testid="dialog-title"]');
+    expect(titleEl?.textContent).toContain("dialog.editTitle");
 
-    expect(isEnabledSelect.value).toBe("true");
+    const descEl = container.querySelector('[data-testid="dialog-description"]');
+    expect(descEl?.textContent).toContain("dialog.editDesc");
 
     unmount();
   });
 
-  it("empty numeric fields mean no change - hasChanges is false", () => {
+  it("next button is disabled when no dirty fields", () => {
     const { container, unmount } = render(<ProviderBatchDialog {...defaultProps()} />);
 
-    // All fields are at default (empty/no_change), so Next button should be disabled
     const footer = container.querySelector('[data-testid="dialog-footer"]');
     const buttons = footer?.querySelectorAll("button") ?? [];
-    // The second button in the footer is "Next" (first is "Cancel")
+    // Second button in footer is "Next" (first is "Cancel")
     const nextButton = buttons[1] as HTMLButtonElement;
 
     expect(nextButton).toBeTruthy();
@@ -350,149 +383,102 @@ describe("ProviderBatchDialog - Step1 Edit Mode Refactor", () => {
     unmount();
   });
 
-  it("setting a priority value makes hasChanges true", () => {
-    const { container, unmount } = render(<ProviderBatchDialog {...defaultProps()} />);
-
-    // Find the priority input by data-field
-    const priorityInput = container.querySelector(
-      '[data-field="priority"] input'
-    ) as HTMLInputElement;
-    expect(priorityInput).toBeTruthy();
+  it("next button is enabled when dirty fields exist", () => {
+    mockDirtyFields = new Set(["routing.priority"]);
 
-    act(() => {
-      const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
-        window.HTMLInputElement.prototype,
-        "value"
-      )?.set;
-      nativeInputValueSetter?.call(priorityInput, "10");
-      priorityInput.dispatchEvent(new Event("change", { bubbles: true }));
-    });
+    const { container, unmount } = render(<ProviderBatchDialog {...defaultProps()} />);
 
-    // Next button should now be enabled
     const footer = container.querySelector('[data-testid="dialog-footer"]');
     const buttons = footer?.querySelectorAll("button") ?? [];
     const nextButton = buttons[1] as HTMLButtonElement;
 
+    expect(nextButton).toBeTruthy();
     expect(nextButton.disabled).toBe(false);
 
     unmount();
   });
 
-  it("groupTag clear button sets value to __clear__", () => {
-    const { container, unmount } = render(<ProviderBatchDialog {...defaultProps()} />);
+  it("cancel button calls onOpenChange(false)", () => {
+    const onOpenChange = vi.fn();
+    const { container, unmount } = render(
+      <ProviderBatchDialog {...defaultProps({ onOpenChange })} />
+    );
 
-    // Find the clear button for groupTag
-    const clearButton = container.querySelector(
-      '[data-field="groupTag"] button'
-    ) as HTMLButtonElement;
-    expect(clearButton).toBeTruthy();
+    const footer = container.querySelector('[data-testid="dialog-footer"]');
+    const buttons = footer?.querySelectorAll("button") ?? [];
+    const cancelButton = buttons[0] as HTMLButtonElement;
 
     act(() => {
-      clearButton.click();
+      cancelButton.click();
     });
 
-    // The groupTag input should now show "__clear__"
-    const groupTagInput = container.querySelector(
-      '[data-field="groupTag"] input'
-    ) as HTMLInputElement;
-    expect(groupTagInput.value).toBe("__clear__");
+    expect(onOpenChange).toHaveBeenCalledWith(false);
 
     unmount();
   });
 
-  it("affected-provider summary shows correct count and masked keys", () => {
-    const threeProviders = [
-      createMockProvider(1, "AlphaProvider", "aaaa****1111"),
-      createMockProvider(2, "BetaProvider", "bbbb****2222"),
-      createMockProvider(3, "GammaProvider", "cccc****3333"),
-    ];
-
-    const { container, unmount } = render(
-      <ProviderBatchDialog
-        {...defaultProps({
-          providers: threeProviders,
-          selectedProviderIds: new Set([1, 3]),
-        })}
-      />
-    );
+  it("next button calls preview when dirty fields exist", async () => {
+    mockDirtyFields = new Set(["routing.priority"]);
+    const { previewProviderBatchPatch } = await import("@/actions/providers");
 
-    const text = container.textContent ?? "";
+    const { container, unmount } = render(<ProviderBatchDialog {...defaultProps()} />);
 
-    // Should show count of 2 affected providers
-    expect(text).toContain("affectedProviders.title");
-    expect(text).toContain("2");
+    const footer = container.querySelector('[data-testid="dialog-footer"]');
+    const nextButton = (footer?.querySelectorAll("button") ?? [])[1] as HTMLButtonElement;
 
-    // Should show masked keys for selected providers (id 1 and 3)
-    expect(text).toContain("AlphaProvider");
-    expect(text).toContain("aaaa****1111");
-    expect(text).toContain("GammaProvider");
-    expect(text).toContain("cccc****3333");
+    await act(async () => {
+      nextButton.click();
+    });
+    await act(async () => {
+      await new Promise((r) => setTimeout(r, 10));
+    });
 
-    // Should NOT show unselected provider
-    expect(text).not.toContain("BetaProvider");
+    expect(previewProviderBatchPatch).toHaveBeenCalledTimes(1);
 
     unmount();
   });
+});
 
-  it("shows +N more when more than 5 providers are affected", () => {
-    const allIds = new Set(eightProviders.map((p) => p.id));
-
+describe("ProviderBatchDialog - Delete Mode", () => {
+  it("renders AlertDialog for delete mode", () => {
     const { container, unmount } = render(
-      <ProviderBatchDialog
-        {...defaultProps({
-          providers: eightProviders,
-          selectedProviderIds: allIds,
-        })}
-      />
+      <ProviderBatchDialog {...defaultProps({ mode: "delete" })} />
     );
 
-    const text = container.textContent ?? "";
-
-    // 8 providers selected, first 5 shown, so "+3 more"
-    expect(text).toContain("affectedProviders.more");
-    // The mock translation interpolates {count} => "3"
-    expect(text).toContain("3");
-
-    // First 5 providers should be shown
-    expect(text).toContain("Provider1");
-    expect(text).toContain("Provider5");
+    expect(container.querySelector('[data-testid="alert-dialog"]')).toBeTruthy();
+    expect(container.querySelector('[data-testid="dialog"]')).toBeFalsy();
 
-    // Provider 6-8 should NOT be listed individually
-    expect(text).not.toContain("Provider6");
+    const text = container.textContent ?? "";
+    expect(text).toContain("dialog.deleteTitle");
 
     unmount();
   });
+});
 
-  it("next button disabled when no changes", () => {
-    const { container, unmount } = render(<ProviderBatchDialog {...defaultProps()} />);
+describe("ProviderBatchDialog - Reset Circuit Mode", () => {
+  it("renders AlertDialog for resetCircuit mode", () => {
+    const { container, unmount } = render(
+      <ProviderBatchDialog {...defaultProps({ mode: "resetCircuit" })} />
+    );
 
-    const footer = container.querySelector('[data-testid="dialog-footer"]');
-    const buttons = footer?.querySelectorAll("button") ?? [];
-    const nextButton = buttons[1] as HTMLButtonElement;
+    expect(container.querySelector('[data-testid="alert-dialog"]')).toBeTruthy();
+    expect(container.querySelector('[data-testid="dialog"]')).toBeFalsy();
 
-    expect(nextButton.disabled).toBe(true);
+    const text = container.textContent ?? "";
+    expect(text).toContain("dialog.resetCircuitTitle");
 
     unmount();
   });
+});
 
-  it("renders ThinkingBudgetEditor and AdaptiveThinkingEditor in anthropic section", () => {
-    const { container, unmount } = render(<ProviderBatchDialog {...defaultProps()} />);
-
-    // Find the anthropic section by its data-section attribute
-    const anthropicSection = container.querySelector('[data-section="anthropic"]');
-    expect(anthropicSection).toBeTruthy();
-
-    // ThinkingBudgetEditor should be rendered within it
-    const thinkingEditor = anthropicSection?.querySelector(
-      '[data-testid="thinking-budget-editor"]'
+describe("ProviderBatchDialog - Closed State", () => {
+  it("renders nothing when open is false", () => {
+    const { container, unmount } = render(
+      <ProviderBatchDialog {...defaultProps({ open: false })} />
     );
-    expect(thinkingEditor).toBeTruthy();
 
-    // AdaptiveThinkingEditor should be rendered within it
-    const adaptiveEditor = anthropicSection?.querySelector(
-      '[data-testid="adaptive-thinking-editor"]'
-    );
-    expect(adaptiveEditor).toBeTruthy();
+    expect(container.querySelector('[data-testid="dialog"]')).toBeFalsy();
+    expect(container.querySelector('[data-testid="alert-dialog"]')).toBeFalsy();
 
     unmount();
   });

+ 296 - 0
tests/unit/settings/providers/provider-batch-preview-step.test.tsx

@@ -0,0 +1,296 @@
+/**
+ * @vitest-environment happy-dom
+ */
+
+import { act } from "react";
+import { createRoot } from "react-dom/client";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import type { ProviderBatchPreviewRow } from "@/actions/providers";
+import { ProviderBatchPreviewStep } from "@/app/[locale]/settings/providers/_components/batch-edit/provider-batch-preview-step";
+
+// ---------------------------------------------------------------------------
+// Mocks
+// ---------------------------------------------------------------------------
+
+vi.mock("next-intl", () => ({
+  useTranslations: () => {
+    const t = (key: string, params?: Record<string, unknown>) => {
+      if (params) {
+        let result = key;
+        for (const [k, v] of Object.entries(params)) {
+          result = result.replace(`{${k}}`, String(v));
+        }
+        return result;
+      }
+      return key;
+    };
+    return t;
+  },
+}));
+
+vi.mock("@/components/ui/checkbox", () => ({
+  Checkbox: ({ checked, onCheckedChange, ...props }: any) => (
+    <input
+      type="checkbox"
+      checked={checked}
+      onChange={() => onCheckedChange?.(!checked)}
+      {...props}
+    />
+  ),
+}));
+
+vi.mock("lucide-react", () => ({
+  Loader2: () => <div data-testid="loader-icon" />,
+}));
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+function render(ui: React.ReactElement) {
+  const container = document.createElement("div");
+  document.body.appendChild(container);
+  let root: ReturnType<typeof createRoot>;
+  act(() => {
+    root = createRoot(container);
+    root.render(ui);
+  });
+  return {
+    container,
+    unmount: () => {
+      act(() => root.unmount());
+      container.remove();
+    },
+  };
+}
+
+function makeRow(overrides: Partial<ProviderBatchPreviewRow> = {}): ProviderBatchPreviewRow {
+  return {
+    providerId: 1,
+    providerName: "TestProvider",
+    field: "priority",
+    status: "changed",
+    before: 0,
+    after: 10,
+    ...overrides,
+  };
+}
+
+const defaultSummary = { providerCount: 2, fieldCount: 3, skipCount: 1 };
+
+// ---------------------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------------------
+
+describe("ProviderBatchPreviewStep", () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+  });
+
+  it("renders changed rows with before/after values", () => {
+    const rows: ProviderBatchPreviewRow[] = [
+      makeRow({ providerId: 1, providerName: "Alpha", field: "priority", before: 0, after: 5 }),
+      makeRow({ providerId: 1, providerName: "Alpha", field: "weight", before: 1, after: 10 }),
+    ];
+
+    const { container, unmount } = render(
+      <ProviderBatchPreviewStep
+        rows={rows}
+        summary={{ providerCount: 1, fieldCount: 2, skipCount: 0 }}
+        excludedProviderIds={new Set()}
+        onExcludeToggle={() => {}}
+      />
+    );
+
+    const changedRow1 = container.querySelector('[data-testid="preview-row-1-priority"]');
+    expect(changedRow1).toBeTruthy();
+    expect(changedRow1?.getAttribute("data-status")).toBe("changed");
+    // Mock t() returns key with params substituted where {param} appears in key
+    // "preview.fieldChanged" does not contain {field} etc, so text is key with params inserted
+    expect(changedRow1?.textContent).toContain("preview.fieldChanged");
+
+    const changedRow2 = container.querySelector('[data-testid="preview-row-1-weight"]');
+    expect(changedRow2).toBeTruthy();
+    expect(changedRow2?.getAttribute("data-status")).toBe("changed");
+
+    unmount();
+  });
+
+  it("renders skipped rows with skip reason", () => {
+    const rows: ProviderBatchPreviewRow[] = [
+      makeRow({
+        providerId: 2,
+        providerName: "Beta",
+        field: "anthropic_thinking_budget_preference",
+        status: "skipped",
+        before: null,
+        after: null,
+        skipReason: "not_applicable",
+      }),
+    ];
+
+    const { container, unmount } = render(
+      <ProviderBatchPreviewStep
+        rows={rows}
+        summary={{ providerCount: 1, fieldCount: 0, skipCount: 1 }}
+        excludedProviderIds={new Set()}
+        onExcludeToggle={() => {}}
+      />
+    );
+
+    const skippedRow = container.querySelector(
+      '[data-testid="preview-row-2-anthropic_thinking_budget_preference"]'
+    );
+    expect(skippedRow).toBeTruthy();
+    expect(skippedRow?.getAttribute("data-status")).toBe("skipped");
+    expect(skippedRow?.textContent).toContain("preview.fieldSkipped");
+
+    unmount();
+  });
+
+  it("groups rows by provider", () => {
+    const rows: ProviderBatchPreviewRow[] = [
+      makeRow({ providerId: 1, providerName: "Alpha", field: "priority" }),
+      makeRow({ providerId: 2, providerName: "Beta", field: "weight" }),
+      makeRow({ providerId: 1, providerName: "Alpha", field: "is_enabled" }),
+    ];
+
+    const { container, unmount } = render(
+      <ProviderBatchPreviewStep
+        rows={rows}
+        summary={defaultSummary}
+        excludedProviderIds={new Set()}
+        onExcludeToggle={() => {}}
+      />
+    );
+
+    const provider1 = container.querySelector('[data-testid="preview-provider-1"]');
+    const provider2 = container.querySelector('[data-testid="preview-provider-2"]');
+    expect(provider1).toBeTruthy();
+    expect(provider2).toBeTruthy();
+
+    // Provider 1 should have 2 rows
+    const p1Rows = provider1?.querySelectorAll("[data-status]");
+    expect(p1Rows?.length).toBe(2);
+
+    // Provider 2 should have 1 row
+    const p2Rows = provider2?.querySelectorAll("[data-status]");
+    expect(p2Rows?.length).toBe(1);
+
+    unmount();
+  });
+
+  it("shows summary counts", () => {
+    const rows: ProviderBatchPreviewRow[] = [
+      makeRow({ providerId: 1, providerName: "Alpha", field: "priority" }),
+    ];
+
+    const { container, unmount } = render(
+      <ProviderBatchPreviewStep
+        rows={rows}
+        summary={{ providerCount: 5, fieldCount: 8, skipCount: 2 }}
+        excludedProviderIds={new Set()}
+        onExcludeToggle={() => {}}
+      />
+    );
+
+    const summary = container.querySelector('[data-testid="preview-summary"]');
+    expect(summary).toBeTruthy();
+    // The mock t() substitutes {providerCount} -> 5, {fieldCount} -> 8, {skipCount} -> 2
+    // into the key "preview.summary" which becomes "preview.summary" with params replaced
+    const text = summary?.textContent ?? "";
+    expect(text).toContain("preview.summary");
+
+    unmount();
+  });
+
+  it("exclusion checkbox toggles provider", () => {
+    const onToggle = vi.fn();
+    const rows: ProviderBatchPreviewRow[] = [
+      makeRow({ providerId: 3, providerName: "Gamma", field: "priority" }),
+    ];
+
+    const { container, unmount } = render(
+      <ProviderBatchPreviewStep
+        rows={rows}
+        summary={defaultSummary}
+        excludedProviderIds={new Set()}
+        onExcludeToggle={onToggle}
+      />
+    );
+
+    const checkbox = container.querySelector(
+      '[data-testid="exclude-checkbox-3"]'
+    ) as HTMLInputElement;
+    expect(checkbox).toBeTruthy();
+    expect(checkbox.checked).toBe(true); // not excluded = checked
+
+    act(() => {
+      checkbox.click();
+    });
+
+    expect(onToggle).toHaveBeenCalledWith(3);
+
+    unmount();
+  });
+
+  it("loading state shows spinner", () => {
+    const { container, unmount } = render(
+      <ProviderBatchPreviewStep
+        rows={[]}
+        summary={{ providerCount: 0, fieldCount: 0, skipCount: 0 }}
+        excludedProviderIds={new Set()}
+        onExcludeToggle={() => {}}
+        isLoading={true}
+      />
+    );
+
+    const loading = container.querySelector('[data-testid="preview-loading"]');
+    expect(loading).toBeTruthy();
+
+    // Should not show the empty state
+    const empty = container.querySelector('[data-testid="preview-empty"]');
+    expect(empty).toBeNull();
+
+    unmount();
+  });
+
+  it("shows empty state when no rows and not loading", () => {
+    const { container, unmount } = render(
+      <ProviderBatchPreviewStep
+        rows={[]}
+        summary={{ providerCount: 0, fieldCount: 0, skipCount: 0 }}
+        excludedProviderIds={new Set()}
+        onExcludeToggle={() => {}}
+      />
+    );
+
+    const empty = container.querySelector('[data-testid="preview-empty"]');
+    expect(empty).toBeTruthy();
+
+    unmount();
+  });
+
+  it("excluded provider checkbox shows unchecked", () => {
+    const rows: ProviderBatchPreviewRow[] = [
+      makeRow({ providerId: 7, providerName: "Excluded", field: "weight" }),
+    ];
+
+    const { container, unmount } = render(
+      <ProviderBatchPreviewStep
+        rows={rows}
+        summary={defaultSummary}
+        excludedProviderIds={new Set([7])}
+        onExcludeToggle={() => {}}
+      />
+    );
+
+    const checkbox = container.querySelector(
+      '[data-testid="exclude-checkbox-7"]'
+    ) as HTMLInputElement;
+    expect(checkbox).toBeTruthy();
+    expect(checkbox.checked).toBe(false); // excluded = unchecked
+
+    unmount();
+  });
+});

+ 190 - 0
tests/unit/settings/providers/provider-form-batch-context.test.ts

@@ -0,0 +1,190 @@
+import { describe, expect, it } from "vitest";
+import {
+  createInitialState,
+  providerFormReducer,
+} from "@/app/[locale]/settings/providers/_components/forms/provider-form/provider-form-context";
+
+// ---------------------------------------------------------------------------
+// createInitialState("batch")
+// ---------------------------------------------------------------------------
+
+describe("createInitialState - batch mode", () => {
+  it("returns batch state with isEnabled set to no_change", () => {
+    const state = createInitialState("batch");
+
+    expect(state.batch.isEnabled).toBe("no_change");
+  });
+
+  it("returns neutral routing defaults (no provider source)", () => {
+    const state = createInitialState("batch");
+
+    expect(state.routing.priority).toBe(0);
+    expect(state.routing.weight).toBe(1);
+    expect(state.routing.costMultiplier).toBe(1.0);
+    expect(state.routing.groupTag).toEqual([]);
+    expect(state.routing.preserveClientIp).toBe(false);
+    expect(state.routing.modelRedirects).toEqual({});
+    expect(state.routing.allowedModels).toEqual([]);
+    expect(state.routing.cacheTtlPreference).toBe("inherit");
+    expect(state.routing.swapCacheTtlBilling).toBe(false);
+    expect(state.routing.anthropicAdaptiveThinking).toBeNull();
+  });
+
+  it("returns neutral rate limit defaults", () => {
+    const state = createInitialState("batch");
+
+    expect(state.rateLimit.limit5hUsd).toBeNull();
+    expect(state.rateLimit.limitDailyUsd).toBeNull();
+    expect(state.rateLimit.dailyResetMode).toBe("fixed");
+    expect(state.rateLimit.dailyResetTime).toBe("00:00");
+    expect(state.rateLimit.limitWeeklyUsd).toBeNull();
+    expect(state.rateLimit.limitMonthlyUsd).toBeNull();
+    expect(state.rateLimit.limitTotalUsd).toBeNull();
+    expect(state.rateLimit.limitConcurrentSessions).toBeNull();
+  });
+
+  it("returns neutral circuit breaker defaults", () => {
+    const state = createInitialState("batch");
+
+    expect(state.circuitBreaker.failureThreshold).toBeUndefined();
+    expect(state.circuitBreaker.openDurationMinutes).toBeUndefined();
+    expect(state.circuitBreaker.halfOpenSuccessThreshold).toBeUndefined();
+    expect(state.circuitBreaker.maxRetryAttempts).toBeNull();
+  });
+
+  it("returns neutral network defaults", () => {
+    const state = createInitialState("batch");
+
+    expect(state.network.proxyUrl).toBe("");
+    expect(state.network.proxyFallbackToDirect).toBe(false);
+    expect(state.network.firstByteTimeoutStreamingSeconds).toBeUndefined();
+    expect(state.network.streamingIdleTimeoutSeconds).toBeUndefined();
+    expect(state.network.requestTimeoutNonStreamingSeconds).toBeUndefined();
+  });
+
+  it("returns neutral MCP defaults", () => {
+    const state = createInitialState("batch");
+
+    expect(state.mcp.mcpPassthroughType).toBe("none");
+    expect(state.mcp.mcpPassthroughUrl).toBe("");
+  });
+
+  it("ignores provider and cloneProvider arguments in batch mode", () => {
+    const fakeProvider = {
+      id: 99,
+      name: "Ignored",
+      url: "https://ignored.example.com",
+      maskedKey: "xxxx****xxxx",
+      isEnabled: false,
+      weight: 50,
+      priority: 99,
+      groupPriorities: null,
+      costMultiplier: 3.0,
+      groupTag: "prod",
+      providerType: "claude" as const,
+      providerVendorId: null,
+      preserveClientIp: true,
+      modelRedirects: null,
+      allowedModels: null,
+      mcpPassthroughType: "none" as const,
+      mcpPassthroughUrl: null,
+      limit5hUsd: null,
+      limitDailyUsd: null,
+      dailyResetMode: "fixed" as const,
+      dailyResetTime: "00:00",
+      limitWeeklyUsd: null,
+      limitMonthlyUsd: null,
+      limitTotalUsd: null,
+      limitConcurrentSessions: 10,
+      maxRetryAttempts: null,
+      circuitBreakerFailureThreshold: 5,
+      circuitBreakerOpenDuration: 30000,
+      circuitBreakerHalfOpenSuccessThreshold: 2,
+      proxyUrl: null,
+      proxyFallbackToDirect: false,
+      firstByteTimeoutStreamingMs: 30000,
+      streamingIdleTimeoutMs: 120000,
+      requestTimeoutNonStreamingMs: 120000,
+      websiteUrl: null,
+      faviconUrl: null,
+      cacheTtlPreference: null,
+      swapCacheTtlBilling: false,
+      context1mPreference: null,
+      codexReasoningEffortPreference: null,
+      codexReasoningSummaryPreference: null,
+      codexTextVerbosityPreference: null,
+      codexParallelToolCallsPreference: null,
+      anthropicMaxTokensPreference: null,
+      anthropicThinkingBudgetPreference: null,
+      anthropicAdaptiveThinking: null,
+      geminiGoogleSearchPreference: null,
+      tpm: null,
+      rpm: null,
+      rpd: null,
+      cc: null,
+      createdAt: "2024-01-01T00:00:00Z",
+      updatedAt: "2024-01-01T00:00:00Z",
+    };
+
+    const state = createInitialState("batch", fakeProvider, fakeProvider);
+
+    // Should still be batch defaults, not the provider values
+    expect(state.routing.priority).toBe(0);
+    expect(state.routing.weight).toBe(1);
+    expect(state.routing.costMultiplier).toBe(1.0);
+    expect(state.batch.isEnabled).toBe("no_change");
+  });
+});
+
+// ---------------------------------------------------------------------------
+// providerFormReducer - SET_BATCH_IS_ENABLED
+// ---------------------------------------------------------------------------
+
+describe("providerFormReducer - SET_BATCH_IS_ENABLED", () => {
+  const baseState = createInitialState("batch");
+
+  it("sets isEnabled to true", () => {
+    const next = providerFormReducer(baseState, {
+      type: "SET_BATCH_IS_ENABLED",
+      payload: "true",
+    });
+
+    expect(next.batch.isEnabled).toBe("true");
+  });
+
+  it("sets isEnabled to false", () => {
+    const next = providerFormReducer(baseState, {
+      type: "SET_BATCH_IS_ENABLED",
+      payload: "false",
+    });
+
+    expect(next.batch.isEnabled).toBe("false");
+  });
+
+  it("sets isEnabled back to no_change", () => {
+    const modified = providerFormReducer(baseState, {
+      type: "SET_BATCH_IS_ENABLED",
+      payload: "true",
+    });
+    const reverted = providerFormReducer(modified, {
+      type: "SET_BATCH_IS_ENABLED",
+      payload: "no_change",
+    });
+
+    expect(reverted.batch.isEnabled).toBe("no_change");
+  });
+
+  it("does not mutate other state sections", () => {
+    const next = providerFormReducer(baseState, {
+      type: "SET_BATCH_IS_ENABLED",
+      payload: "true",
+    });
+
+    expect(next.routing).toEqual(baseState.routing);
+    expect(next.rateLimit).toEqual(baseState.rateLimit);
+    expect(next.circuitBreaker).toEqual(baseState.circuitBreaker);
+    expect(next.network).toEqual(baseState.network);
+    expect(next.mcp).toEqual(baseState.mcp);
+    expect(next.ui).toEqual(baseState.ui);
+  });
+});

+ 598 - 0
tests/unit/settings/providers/provider-undo-toast.test.tsx

@@ -0,0 +1,598 @@
+/**
+ * @vitest-environment happy-dom
+ */
+
+import { act } from "react";
+import { createRoot } from "react-dom/client";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { ProviderBatchDialog } from "@/app/[locale]/settings/providers/_components/batch-edit/provider-batch-dialog";
+import type { ProviderDisplay } from "@/types/provider";
+
+// ---------------------------------------------------------------------------
+// Mutable mock state for useProviderForm
+// ---------------------------------------------------------------------------
+
+let mockDirtyFields = new Set<string>();
+const mockDispatch = vi.fn();
+const mockState = {
+  ui: { activeTab: "basic" as const, isPending: false, showFailureThresholdConfirm: false },
+  basic: { name: "", url: "", key: "", websiteUrl: "" },
+  routing: {
+    providerType: "claude" as const,
+    groupTag: [],
+    preserveClientIp: false,
+    modelRedirects: {},
+    allowedModels: [],
+    priority: 5,
+    groupPriorities: {},
+    weight: 1,
+    costMultiplier: 1,
+    cacheTtlPreference: "inherit" as const,
+    swapCacheTtlBilling: false,
+    context1mPreference: "inherit" as const,
+    codexReasoningEffortPreference: "inherit",
+    codexReasoningSummaryPreference: "inherit",
+    codexTextVerbosityPreference: "inherit",
+    codexParallelToolCallsPreference: "inherit",
+    anthropicMaxTokensPreference: "inherit",
+    anthropicThinkingBudgetPreference: "inherit",
+    anthropicAdaptiveThinking: null,
+    geminiGoogleSearchPreference: "inherit",
+  },
+  rateLimit: {
+    limit5hUsd: null,
+    limitDailyUsd: null,
+    dailyResetMode: "fixed" as const,
+    dailyResetTime: "00:00",
+    limitWeeklyUsd: null,
+    limitMonthlyUsd: null,
+    limitTotalUsd: null,
+    limitConcurrentSessions: null,
+  },
+  circuitBreaker: {
+    failureThreshold: undefined,
+    openDurationMinutes: undefined,
+    halfOpenSuccessThreshold: undefined,
+    maxRetryAttempts: null,
+  },
+  network: {
+    proxyUrl: "",
+    proxyFallbackToDirect: false,
+    firstByteTimeoutStreamingSeconds: undefined,
+    streamingIdleTimeoutSeconds: undefined,
+    requestTimeoutNonStreamingSeconds: undefined,
+  },
+  mcp: { mcpPassthroughType: "none" as const, mcpPassthroughUrl: "" },
+  batch: { isEnabled: "no_change" as const },
+};
+
+// ---------------------------------------------------------------------------
+// Mocks
+// ---------------------------------------------------------------------------
+
+vi.mock("next-intl", () => ({
+  useTranslations: () => {
+    const t = (key: string, params?: Record<string, unknown>) => {
+      if (params) {
+        let result = key;
+        for (const [k, v] of Object.entries(params)) {
+          result = result.replace(`{${k}}`, String(v));
+        }
+        return result;
+      }
+      return key;
+    };
+    return t;
+  },
+}));
+
+const mockInvalidateQueries = vi.fn().mockResolvedValue(undefined);
+vi.mock("@tanstack/react-query", () => ({
+  useQueryClient: () => ({
+    invalidateQueries: mockInvalidateQueries,
+  }),
+}));
+
+const mockToastSuccess = vi.fn();
+const mockToastError = vi.fn();
+vi.mock("sonner", () => ({
+  toast: {
+    success: (...args: unknown[]) => mockToastSuccess(...args),
+    error: (...args: unknown[]) => mockToastError(...args),
+  },
+}));
+
+const mockPreview = vi.fn();
+const mockApply = vi.fn();
+const mockUndo = vi.fn();
+vi.mock("@/actions/providers", () => ({
+  previewProviderBatchPatch: (...args: unknown[]) => mockPreview(...args),
+  applyProviderBatchPatch: (...args: unknown[]) => mockApply(...args),
+  undoProviderPatch: (...args: unknown[]) => mockUndo(...args),
+  batchDeleteProviders: vi.fn().mockResolvedValue({ ok: true, data: { deletedCount: 1 } }),
+  batchResetProviderCircuits: vi.fn().mockResolvedValue({ ok: true, data: { resetCount: 1 } }),
+}));
+
+// Mock ProviderFormProvider + useProviderForm
+vi.mock(
+  "@/app/[locale]/settings/providers/_components/forms/provider-form/provider-form-context",
+  () => ({
+    ProviderFormProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
+    useProviderForm: () => ({
+      state: mockState,
+      dispatch: mockDispatch,
+      dirtyFields: mockDirtyFields,
+      mode: "batch",
+    }),
+  })
+);
+
+// Mock all form section components as stubs
+vi.mock(
+  "@/app/[locale]/settings/providers/_components/forms/provider-form/sections/basic-info-section",
+  () => ({
+    BasicInfoSection: () => <div data-testid="basic-info-section" />,
+  })
+);
+vi.mock(
+  "@/app/[locale]/settings/providers/_components/forms/provider-form/sections/routing-section",
+  () => ({
+    RoutingSection: () => <div data-testid="routing-section" />,
+  })
+);
+vi.mock(
+  "@/app/[locale]/settings/providers/_components/forms/provider-form/sections/limits-section",
+  () => ({
+    LimitsSection: () => <div data-testid="limits-section" />,
+  })
+);
+vi.mock(
+  "@/app/[locale]/settings/providers/_components/forms/provider-form/sections/network-section",
+  () => ({
+    NetworkSection: () => <div data-testid="network-section" />,
+  })
+);
+vi.mock(
+  "@/app/[locale]/settings/providers/_components/forms/provider-form/sections/testing-section",
+  () => ({
+    TestingSection: () => <div data-testid="testing-section" />,
+  })
+);
+
+// Mock FormTabNav
+vi.mock(
+  "@/app/[locale]/settings/providers/_components/forms/provider-form/components/form-tab-nav",
+  () => ({
+    FormTabNav: () => <div data-testid="form-tab-nav" />,
+  })
+);
+
+// Mock ProviderBatchPreviewStep
+vi.mock(
+  "@/app/[locale]/settings/providers/_components/batch-edit/provider-batch-preview-step",
+  () => ({
+    ProviderBatchPreviewStep: () => <div data-testid="preview-step" />,
+  })
+);
+
+// Mock buildPatchDraftFromFormState
+vi.mock(
+  "@/app/[locale]/settings/providers/_components/batch-edit/build-patch-draft",
+  () => ({
+    buildPatchDraftFromFormState: vi.fn().mockReturnValue({ priority: { set: 5 } }),
+  })
+);
+
+// UI component mocks
+vi.mock("@/components/ui/dialog", () => ({
+  Dialog: ({ open, children }: { open: boolean; children: React.ReactNode }) =>
+    open ? <div data-testid="dialog">{children}</div> : null,
+  DialogContent: ({ children }: { children: React.ReactNode }) => (
+    <div data-testid="dialog-content">{children}</div>
+  ),
+  DialogDescription: ({ children }: { children: React.ReactNode }) => (
+    <div data-testid="dialog-description">{children}</div>
+  ),
+  DialogFooter: ({ children }: { children: React.ReactNode }) => (
+    <div data-testid="dialog-footer">{children}</div>
+  ),
+  DialogHeader: ({ children }: { children: React.ReactNode }) => (
+    <div data-testid="dialog-header">{children}</div>
+  ),
+  DialogTitle: ({ children }: { children: React.ReactNode }) => (
+    <div data-testid="dialog-title">{children}</div>
+  ),
+}));
+
+vi.mock("@/components/ui/alert-dialog", () => ({
+  AlertDialog: ({ open, children }: { open: boolean; children: React.ReactNode }) =>
+    open ? <div data-testid="alert-dialog">{children}</div> : null,
+  AlertDialogAction: ({ children, ...props }: any) => <button {...props}>{children}</button>,
+  AlertDialogCancel: ({ children, ...props }: any) => <button {...props}>{children}</button>,
+  AlertDialogContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
+  AlertDialogDescription: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
+  AlertDialogFooter: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
+  AlertDialogHeader: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
+  AlertDialogTitle: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
+}));
+
+vi.mock("@/components/ui/button", () => ({
+  Button: ({ children, ...props }: any) => <button {...props}>{children}</button>,
+}));
+
+vi.mock("lucide-react", () => ({
+  Loader2: () => <div data-testid="loader-icon" />,
+}));
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+function render(ui: React.ReactElement) {
+  const container = document.createElement("div");
+  document.body.appendChild(container);
+  let root: ReturnType<typeof createRoot>;
+  act(() => {
+    root = createRoot(container);
+    root.render(ui);
+  });
+  return {
+    container,
+    unmount: () => {
+      act(() => root.unmount());
+      container.remove();
+    },
+  };
+}
+
+function createMockProvider(id: number, name: string): ProviderDisplay {
+  return {
+    id,
+    name,
+    url: "https://api.example.com",
+    maskedKey: "xxxx****1234",
+    isEnabled: true,
+    weight: 1,
+    priority: 0,
+    groupPriorities: null,
+    costMultiplier: 1,
+    groupTag: null,
+    providerType: "claude",
+    providerVendorId: null,
+    preserveClientIp: false,
+    modelRedirects: null,
+    allowedModels: null,
+    mcpPassthroughType: "none",
+    mcpPassthroughUrl: null,
+    limit5hUsd: null,
+    limitDailyUsd: null,
+    dailyResetMode: "fixed",
+    dailyResetTime: "00:00",
+    limitWeeklyUsd: null,
+    limitMonthlyUsd: null,
+    limitTotalUsd: null,
+    limitConcurrentSessions: 10,
+    maxRetryAttempts: null,
+    circuitBreakerFailureThreshold: 5,
+    circuitBreakerOpenDuration: 30000,
+    circuitBreakerHalfOpenSuccessThreshold: 2,
+    proxyUrl: null,
+    proxyFallbackToDirect: false,
+    firstByteTimeoutStreamingMs: 30000,
+    streamingIdleTimeoutMs: 120000,
+    requestTimeoutNonStreamingMs: 120000,
+    websiteUrl: null,
+    faviconUrl: null,
+    cacheTtlPreference: null,
+    swapCacheTtlBilling: false,
+    context1mPreference: null,
+    codexReasoningEffortPreference: null,
+    codexReasoningSummaryPreference: null,
+    codexTextVerbosityPreference: null,
+    codexParallelToolCallsPreference: null,
+    anthropicMaxTokensPreference: null,
+    anthropicThinkingBudgetPreference: null,
+    anthropicAdaptiveThinking: null,
+    geminiGoogleSearchPreference: null,
+    tpm: null,
+    rpm: null,
+    rpd: null,
+    cc: null,
+    createdAt: "2024-01-01T00:00:00Z",
+    updatedAt: "2024-01-01T00:00:00Z",
+  };
+}
+
+function defaultProps(overrides: Partial<React.ComponentProps<typeof ProviderBatchDialog>> = {}) {
+  return {
+    open: true,
+    mode: "edit" as const,
+    onOpenChange: vi.fn(),
+    selectedProviderIds: new Set([1, 2]),
+    providers: [createMockProvider(1, "Provider1"), createMockProvider(2, "Provider2")],
+    onSuccess: vi.fn(),
+    ...overrides,
+  };
+}
+
+/**
+ * Drives the dialog from "edit" step through "preview" step to "apply":
+ *   1. Click "Next" (second button in edit-step footer)
+ *   2. Wait for preview to resolve
+ *   3. Click "Apply" (second button in preview-step footer)
+ *   4. Wait for apply to resolve
+ */
+async function driveToApply(container: HTMLElement) {
+  // Click Next (second button in footer)
+  const footer = container.querySelector('[data-testid="dialog-footer"]');
+  const buttons = footer?.querySelectorAll("button") ?? [];
+  const nextButton = buttons[1] as HTMLButtonElement;
+
+  await act(async () => {
+    nextButton.click();
+  });
+  await act(async () => {
+    await new Promise((r) => setTimeout(r, 10));
+  });
+
+  // Click Apply (second button in preview-step footer)
+  const applyFooter = container.querySelector('[data-testid="dialog-footer"]');
+  const applyButtons = applyFooter?.querySelectorAll("button") ?? [];
+  const applyButton = applyButtons[1] as HTMLButtonElement;
+
+  await act(async () => {
+    applyButton.click();
+  });
+  await act(async () => {
+    await new Promise((r) => setTimeout(r, 10));
+  });
+}
+
+// ---------------------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------------------
+
+describe("Provider Undo Toast", () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+    // Make hasChanges true so the "Next" button is enabled
+    mockDirtyFields = new Set(["routing.priority"]);
+  });
+
+  it("shows undo toast after successful apply", async () => {
+    mockPreview.mockResolvedValue({
+      ok: true,
+      data: {
+        previewToken: "tok-1",
+        previewRevision: "rev-1",
+        previewExpiresAt: new Date(Date.now() + 60000).toISOString(),
+        providerIds: [1, 2],
+        changedFields: ["priority"],
+        rows: [
+          {
+            providerId: 1,
+            providerName: "Provider1",
+            field: "priority",
+            status: "changed",
+            before: 0,
+            after: 5,
+          },
+          {
+            providerId: 2,
+            providerName: "Provider2",
+            field: "priority",
+            status: "changed",
+            before: 0,
+            after: 5,
+          },
+        ],
+        summary: { providerCount: 2, fieldCount: 2, skipCount: 0 },
+      },
+    });
+
+    mockApply.mockResolvedValue({
+      ok: true,
+      data: {
+        operationId: "op-1",
+        appliedAt: new Date().toISOString(),
+        updatedCount: 2,
+        undoToken: "undo-tok-1",
+        undoExpiresAt: new Date(Date.now() + 10000).toISOString(),
+      },
+    });
+
+    const props = defaultProps();
+    const { container, unmount } = render(<ProviderBatchDialog {...props} />);
+
+    await driveToApply(container);
+
+    expect(mockPreview).toHaveBeenCalledTimes(1);
+    expect(mockApply).toHaveBeenCalledTimes(1);
+
+    // Verify toast.success was called with undo action
+    expect(mockToastSuccess).toHaveBeenCalledWith(
+      "toast.updated",
+      expect.objectContaining({
+        duration: 10000,
+        action: expect.objectContaining({
+          label: expect.any(String),
+          onClick: expect.any(Function),
+        }),
+      })
+    );
+
+    unmount();
+  });
+
+  it("undo action calls undoProviderPatch on success", async () => {
+    mockPreview.mockResolvedValue({
+      ok: true,
+      data: {
+        previewToken: "tok-2",
+        previewRevision: "rev-2",
+        previewExpiresAt: new Date(Date.now() + 60000).toISOString(),
+        providerIds: [1],
+        changedFields: ["priority"],
+        rows: [
+          {
+            providerId: 1,
+            providerName: "Provider1",
+            field: "priority",
+            status: "changed",
+            before: 0,
+            after: 5,
+          },
+        ],
+        summary: { providerCount: 1, fieldCount: 1, skipCount: 0 },
+      },
+    });
+
+    mockApply.mockResolvedValue({
+      ok: true,
+      data: {
+        operationId: "op-2",
+        appliedAt: new Date().toISOString(),
+        updatedCount: 1,
+        undoToken: "undo-tok-2",
+        undoExpiresAt: new Date(Date.now() + 10000).toISOString(),
+      },
+    });
+
+    mockUndo.mockResolvedValue({
+      ok: true,
+      data: {
+        operationId: "op-2",
+        revertedAt: new Date().toISOString(),
+        revertedCount: 1,
+      },
+    });
+
+    const props = defaultProps({ selectedProviderIds: new Set([1]) });
+    const { container, unmount } = render(<ProviderBatchDialog {...props} />);
+
+    await driveToApply(container);
+
+    // Extract the undo onClick from the toast call
+    const toastCall = mockToastSuccess.mock.calls[0];
+    const toastOptions = toastCall[1] as { action: { onClick: () => Promise<void> } };
+
+    // Call the undo action
+    await act(async () => {
+      await toastOptions.action.onClick();
+    });
+
+    expect(mockUndo).toHaveBeenCalledWith({
+      undoToken: "undo-tok-2",
+      operationId: "op-2",
+    });
+
+    // Should show success toast for undo
+    expect(mockToastSuccess).toHaveBeenCalledTimes(2);
+    expect(mockToastSuccess.mock.calls[1][0]).toBe("toast.undoSuccess");
+
+    unmount();
+  });
+
+  it("undo failure shows error toast", async () => {
+    mockPreview.mockResolvedValue({
+      ok: true,
+      data: {
+        previewToken: "tok-3",
+        previewRevision: "rev-3",
+        previewExpiresAt: new Date(Date.now() + 60000).toISOString(),
+        providerIds: [1],
+        changedFields: ["priority"],
+        rows: [
+          {
+            providerId: 1,
+            providerName: "Provider1",
+            field: "priority",
+            status: "changed",
+            before: 0,
+            after: 5,
+          },
+        ],
+        summary: { providerCount: 1, fieldCount: 1, skipCount: 0 },
+      },
+    });
+
+    mockApply.mockResolvedValue({
+      ok: true,
+      data: {
+        operationId: "op-3",
+        appliedAt: new Date().toISOString(),
+        updatedCount: 1,
+        undoToken: "undo-tok-3",
+        undoExpiresAt: new Date(Date.now() + 10000).toISOString(),
+      },
+    });
+
+    mockUndo.mockResolvedValue({
+      ok: false,
+      error: "Undo window expired",
+      errorCode: "UNDO_EXPIRED",
+    });
+
+    const props = defaultProps({ selectedProviderIds: new Set([1]) });
+    const { container, unmount } = render(<ProviderBatchDialog {...props} />);
+
+    await driveToApply(container);
+
+    // Extract undo onClick
+    const toastCall = mockToastSuccess.mock.calls[0];
+    const toastOptions = toastCall[1] as { action: { onClick: () => Promise<void> } };
+
+    // Call undo - should fail
+    await act(async () => {
+      await toastOptions.action.onClick();
+    });
+
+    expect(mockUndo).toHaveBeenCalledTimes(1);
+    // After undo failure, error toast is shown via toast.error
+    expect(mockToastError).toHaveBeenCalled();
+
+    unmount();
+  });
+
+  it("apply shows error toast on failure", async () => {
+    mockPreview.mockResolvedValue({
+      ok: true,
+      data: {
+        previewToken: "tok-4",
+        previewRevision: "rev-4",
+        previewExpiresAt: new Date(Date.now() + 60000).toISOString(),
+        providerIds: [1],
+        changedFields: ["priority"],
+        rows: [
+          {
+            providerId: 1,
+            providerName: "Provider1",
+            field: "priority",
+            status: "changed",
+            before: 0,
+            after: 5,
+          },
+        ],
+        summary: { providerCount: 1, fieldCount: 1, skipCount: 0 },
+      },
+    });
+
+    mockApply.mockResolvedValue({
+      ok: false,
+      error: "Preview expired",
+      errorCode: "PREVIEW_EXPIRED",
+    });
+
+    const props = defaultProps({ selectedProviderIds: new Set([1]) });
+    const { container, unmount } = render(<ProviderBatchDialog {...props} />);
+
+    await driveToApply(container);
+
+    expect(mockApply).toHaveBeenCalledTimes(1);
+    // After apply failure, error toast is shown via toast.error
+    expect(mockToastError).toHaveBeenCalled();
+    expect(mockToastSuccess).not.toHaveBeenCalled();
+
+    unmount();
+  });
+});