Browse Source

release v0.5.6 (#775)

* fix(circuit-breaker): key errors should not trip endpoint circuit breaker

Remove 3 recordEndpointFailure calls from response-handler streaming
error paths (fake-200, non-200 HTTP, stream abort). These are key-level
errors where the endpoint itself responded successfully. Only
forwarder-level failures (timeout, network error) and probe failures
should penalize the endpoint circuit breaker.

Previously, a single bad API key could trip the endpoint breaker
(threshold=3, open=5min), making ALL keys on that endpoint unavailable.

* chore: format code (dev-3d584e5)

* Merge pull request #767 from ding113/fix/provider-clone-deep-copy

fix: 修复供应商克隆时因浅拷贝引用共享导致源供应商数据被意外污染的问题

* 增强配置表单输入警告提示 (#768)

* feat: 增强配置表单输入警告提示

* fix: 修复 expiresAt 显示与配额刷新输入边界

* fix: 修复 expiresAt 解析兜底并改善刷新间隔输入体验

* fix: 刷新间隔输入取整并复用 clamp

---------

Co-authored-by: tesgth032 <[email protected]>

* feat(circuit-breaker): endpoint CB default-off + 524 decision chain audit (#773)

* feat(circuit-breaker): endpoint circuit breaker default-off + 524 decision chain audit

- Add ENABLE_ENDPOINT_CIRCUIT_BREAKER env var (default: false) to gate endpoint-level circuit breaker
- Gate isEndpointCircuitOpen, recordEndpointFailure, recordEndpointSuccess, triggerEndpointCircuitBreakerAlert behind env switch
- Add initEndpointCircuitBreaker() startup cleanup: clear stale Redis keys when feature disabled
- Gate endpoint filtering in endpoint-selector (getPreferredProviderEndpoints, getEndpointFilterStats)
- Fix 524 vendor-type timeout missing from decision chain: add chain entry with reason=vendor_type_all_timeout in forwarder
- Add vendor_type_all_timeout to ProviderChainItem reason union type (both backend session.ts and frontend message.ts)
- Add timeline rendering for vendor_type_all_timeout in provider-chain-formatter
- Replace hardcoded Chinese strings in provider-selector circuit_open details with i18n keys
- Add i18n translations for vendor_type_all_timeout and filterDetails (5 languages: zh-CN, zh-TW, en, ja, ru)
- Enhance LogicTraceTab to render filterDetails via i18n lookup with fallback
- Add endpoint_pool_exhausted and vendor_type_all_timeout to provider-chain-popover isActualRequest/getItemStatus
- Add comprehensive unit tests for all changes (endpoint-circuit-breaker, endpoint-selector, provider-chain-formatter)

* fix(i18n): fix Russian grammar errors and rate_limited translations

- Fix Russian: "конечная точкаов" -> "конечных точек" (11 occurrences)
- Fix Russian: "Ограничение стоимости" -> "Ограничение скорости" (rate_limited)
- Fix zh-CN: "费用限制" -> "速率限制" (filterDetails.rate_limited)
- Fix zh-TW: "費用限制" -> "速率限制" (filterDetails.rate_limited)
- Add initEndpointCircuitBreaker() to dev environment in instrumentation.ts

* fix(circuit-breaker): vendor type CB respects ENABLE_ENDPOINT_CIRCUIT_BREAKER

Make vendor type circuit breaker controlled by the same
ENABLE_ENDPOINT_CIRCUIT_BREAKER switch as endpoint circuit breaker.
When disabled (default), vendor type CB will never trip or block
providers, resolving user confusion about "vendor type temporary
circuit breaker" skip reasons in decision chain.

Changes:
- Add ENABLE_ENDPOINT_CIRCUIT_BREAKER check in isVendorTypeCircuitOpen()
- Add switch check in recordVendorTypeAllEndpointsTimeout()
- Add tests for switch on/off behavior

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

* 修复 Key 并发限制继承用户并发上限 (#772)

* fix: Key 并发上限默认继承用户限制

- RateLimitGuard:Key limitConcurrentSessions=0 时回退到 User limitConcurrentSessions\n- Key 配额/使用量接口:并发上限按有效值展示\n- 单测覆盖并发继承逻辑;补齐 probe 测试的 endpoint-circuit-breaker mock 导出\n- 同步更新 biome.json schema 版本以匹配当前 Biome CLI

* docs: 补齐并发上限解析工具注释

* refactor: 合并 Key 限额查询并补充并发单测

- getKeyQuotaUsage/getKeyLimitUsage:通过 leftJoin 一次取回 User 并发上限,避免额外查询\n- 新增 resolveKeyConcurrentSessionLimit 单测,覆盖关键分支\n- 修复 vacuum-filter bench 中的 Biome 报错

* fix: my-usage 并发上限继承用户限制

- getMyQuota:Key 并发为 0/null 时回退到 User 并发上限,保持与 Guard/Key 配额一致\n- 新增单测覆盖 Key->User 并发继承

* test: 补齐 my-usage 并发继承场景

- MyUsageQuota.keyLimitConcurrentSessions 收敛为 number(0 表示无限制)\n- OpenAPI 响应 schema 同步为非 nullable\n- my-usage 并发继承测试补充 Key>0 与 User=0 场景

---------

Co-authored-by: tesgth032 <[email protected]>

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: hank9999 <[email protected]>
Co-authored-by: tesgth032 <[email protected]>
Co-authored-by: tesgth032 <[email protected]>
Co-authored-by: Claude Sonnet 4.5 <[email protected]>
Ding 2 days ago
parent
commit
674e41ccfd
67 changed files with 2261 additions and 173 deletions
  1. 6 0
      .env.example
  2. 1 1
      biome.json
  3. 2 1
      messages/en/dashboard.json
  4. 13 3
      messages/en/provider-chain.json
  5. 7 1
      messages/en/settings/config.json
  6. 8 1
      messages/en/settings/providers/form/key.json
  7. 2 1
      messages/ja/dashboard.json
  8. 13 3
      messages/ja/provider-chain.json
  9. 7 1
      messages/ja/settings/config.json
  10. 8 1
      messages/ja/settings/providers/form/key.json
  11. 2 1
      messages/ru/dashboard.json
  12. 20 10
      messages/ru/provider-chain.json
  13. 7 1
      messages/ru/settings/config.json
  14. 8 1
      messages/ru/settings/providers/form/key.json
  15. 2 1
      messages/zh-CN/dashboard.json
  16. 13 3
      messages/zh-CN/provider-chain.json
  17. 7 1
      messages/zh-CN/settings/config.json
  18. 8 1
      messages/zh-CN/settings/providers/form/key.json
  19. 2 1
      messages/zh-TW/dashboard.json
  20. 13 3
      messages/zh-TW/provider-chain.json
  21. 7 1
      messages/zh-TW/settings/config.json
  22. 8 1
      messages/zh-TW/settings/providers/form/key.json
  23. 1 0
      scripts/deploy.ps1
  24. 1 0
      scripts/deploy.sh
  25. 17 5
      src/actions/key-quota.ts
  26. 21 4
      src/actions/keys.ts
  27. 8 2
      src/actions/my-usage.ts
  28. 21 11
      src/app/[locale]/dashboard/_components/user/forms/user-form.tsx
  29. 7 1
      src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/LogicTraceTab.tsx
  30. 10 0
      src/app/[locale]/dashboard/logs/_components/provider-chain-popover.tsx
  31. 67 6
      src/app/[locale]/settings/config/_components/system-settings-form.tsx
  32. 7 4
      src/app/[locale]/settings/providers/_components/forms/provider-form/provider-form-context.tsx
  33. 13 1
      src/app/[locale]/settings/providers/_components/forms/provider-form/sections/basic-info-section.tsx
  34. 1 1
      src/app/api/actions/[...route]/route.ts
  35. 20 0
      src/app/v1/_lib/proxy/forwarder.ts
  36. 3 3
      src/app/v1/_lib/proxy/provider-selector.ts
  37. 7 1
      src/app/v1/_lib/proxy/rate-limit-guard.ts
  38. 10 39
      src/app/v1/_lib/proxy/response-handler.ts
  39. 2 1
      src/app/v1/_lib/proxy/session.ts
  40. 26 0
      src/components/ui/inline-warning.tsx
  41. 20 0
      src/instrumentation.ts
  42. 4 0
      src/lib/config/env.schema.ts
  43. 65 0
      src/lib/endpoint-circuit-breaker.ts
  44. 12 0
      src/lib/provider-endpoints/endpoint-selector.ts
  45. 33 0
      src/lib/rate-limit/concurrent-session-limit.ts
  46. 36 0
      src/lib/utils/date-input.test.ts
  47. 34 0
      src/lib/utils/date-input.ts
  48. 104 0
      src/lib/utils/provider-chain-formatter.test.ts
  49. 53 3
      src/lib/utils/provider-chain-formatter.ts
  50. 44 0
      src/lib/utils/validation/api-key-warnings.test.ts
  51. 85 0
      src/lib/utils/validation/api-key-warnings.ts
  52. 39 0
      src/lib/utils/validation/quota-lease-warnings.test.ts
  53. 31 0
      src/lib/utils/validation/quota-lease-warnings.ts
  54. 11 0
      src/lib/vendor-type-circuit-breaker.ts
  55. 2 1
      src/types/message.ts
  56. 104 0
      tests/unit/actions/key-quota-concurrent-inherit.test.ts
  57. 140 0
      tests/unit/actions/my-usage-concurrent-inherit.test.ts
  58. 158 0
      tests/unit/dashboard/provider-form-clone-deep-copy.test.ts
  59. 4 2
      tests/unit/dashboard/user-form-expiry-clear-ui.test.tsx
  60. 202 10
      tests/unit/lib/endpoint-circuit-breaker.test.ts
  61. 90 0
      tests/unit/lib/provider-endpoints/endpoint-selector.test.ts
  62. 30 39
      tests/unit/lib/provider-endpoints/probe.test.ts
  63. 52 0
      tests/unit/lib/rate-limit/concurrent-session-limit.test.ts
  64. 82 0
      tests/unit/lib/vendor-type-circuit-breaker.test.ts
  65. 14 0
      tests/unit/proxy/rate-limit-guard.test.ts
  66. 405 0
      tests/unit/proxy/response-handler-endpoint-circuit-isolation.test.ts
  67. 1 1
      tests/unit/vacuum-filter/vacuum-filter-has.bench.test.ts

+ 6 - 0
.env.example

@@ -88,6 +88,12 @@ STORE_SESSION_RESPONSE_BODY=true        # 是否在 Redis 中存储会话响应
 # - 启用:适用于网络稳定环境,连续网络错误也应触发熔断保护,避免持续请求不可达的供应商
 ENABLE_CIRCUIT_BREAKER_ON_NETWORK_ERRORS=false
 
+# 端点级别熔断器
+# 功能说明:控制是否启用端点级别的熔断器
+# - false (默认):禁用端点熔断器,所有启用的端点均可使用
+# - true:启用端点熔断器,连续失败的端点会被临时屏蔽(默认 3 次失败后熔断 5 分钟)
+ENABLE_ENDPOINT_CIRCUIT_BREAKER=false
+
 # 供应商缓存配置
 # 功能说明:控制是否启用供应商进程级缓存
 # - true (默认):启用缓存,30s TTL + Redis Pub/Sub 跨实例即时失效,提升供应商查询性能

+ 1 - 1
biome.json

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

+ 2 - 1
messages/en/dashboard.json

@@ -994,7 +994,8 @@
     "expiresAt": {
       "label": "Expiration Date",
       "placeholder": "Leave empty for never expires",
-      "description": "User will be automatically disabled after expiration"
+      "description": "User will be automatically disabled after expiration",
+      "pastWarning": "Selected date is in the past. The user will expire and be disabled immediately after saving."
     },
     "allowedClients": {
       "label": "Allowed Clients",

+ 13 - 3
messages/en/provider-chain.json

@@ -38,7 +38,8 @@
     "concurrentLimit": "Concurrent Limit",
     "http2Fallback": "HTTP/2 Fallback",
     "clientError": "Client Error",
-    "endpointPoolExhausted": "Endpoint Pool Exhausted"
+    "endpointPoolExhausted": "Endpoint Pool Exhausted",
+    "vendorTypeAllTimeout": "Vendor-Type All Endpoints Timeout"
   },
   "reasons": {
     "request_success": "Success",
@@ -50,7 +51,8 @@
     "http2_fallback": "HTTP/2 Fallback",
     "session_reuse": "Session Reuse",
     "initial_selection": "Initial Selection",
-    "endpoint_pool_exhausted": "Endpoint Pool Exhausted"
+    "endpoint_pool_exhausted": "Endpoint Pool Exhausted",
+    "vendor_type_all_timeout": "Vendor-Type All Endpoints Timeout"
   },
   "filterReasons": {
     "rate_limited": "Rate Limited",
@@ -67,6 +69,12 @@
     "endpoint_circuit_open": "Endpoint Circuit Open",
     "endpoint_disabled": "Endpoint Disabled"
   },
+  "filterDetails": {
+    "vendor_type_circuit_open": "Vendor-type temporarily circuit-broken",
+    "circuit_open": "Circuit breaker open",
+    "circuit_half_open": "Circuit breaker half-open",
+    "rate_limited": "Rate limited"
+  },
   "details": {
     "selectionMethod": "Selection",
     "attemptNumber": "Attempt",
@@ -197,6 +205,8 @@
     "endpointStatsCircuitOpen": "Circuit-Open Endpoints: {count}",
     "endpointStatsAvailable": "Available Endpoints: {count}",
     "strictBlockNoEndpoints": "Strict mode: no endpoint candidates available, provider skipped without fallback",
-    "strictBlockSelectorError": "Strict mode: endpoint selector encountered an error, provider skipped without fallback"
+    "strictBlockSelectorError": "Strict mode: endpoint selector encountered an error, provider skipped without fallback",
+    "vendorTypeAllTimeout": "Vendor-Type All Endpoints Timeout (524)",
+    "vendorTypeAllTimeoutNote": "All endpoints for this vendor-type timed out. Vendor-type circuit breaker triggered."
   }
 }

+ 7 - 1
messages/en/settings/config.json

@@ -95,7 +95,13 @@
       "leasePercentMonthly": "Monthly Window Lease Percentage",
       "leasePercentMonthlyDesc": "Percentage of monthly limit for each lease slice (0-1)",
       "leaseCapUsd": "Lease Cap (USD)",
-      "leaseCapUsdDesc": "Maximum absolute cap per lease slice in USD, leave empty for no limit"
+      "leaseCapUsdDesc": "Maximum absolute cap per lease slice in USD, leave empty for no limit",
+      "warnings": {
+        "dbRefreshIntervalTooLow": "Refresh interval is {value}s. This may increase DB load.",
+        "dbRefreshIntervalTooHigh": "Refresh interval is {value}s. Quota/limit updates may be delayed.",
+        "leasePercentZero": "Percentage is 0. This may cause the lease budget to always be 0.",
+        "leaseCapZero": "Lease cap is 0. This may cause the per-lease budget to be 0."
+      }
     }
   },
   "section": {

+ 8 - 1
messages/en/settings/providers/form/key.json

@@ -4,5 +4,12 @@
   "labelEdit": "API Key (Leave empty to keep unchanged)",
   "leaveEmpty": "(Leave empty to keep unchanged)",
   "leaveEmptyDesc": "Leave empty to keep existing key",
-  "placeholder": "Enter API Key"
+  "placeholder": "Enter API Key",
+  "warnings": {
+    "looks_like_auth_header": "Looks like you pasted a request header (e.g., Bearer/Authorization/x-api-key). Please enter the key value only.",
+    "wrapped_in_quotes": "Wrapped in quotes. Usually the quotes are not needed.",
+    "contains_non_ascii": "Contains non-ASCII characters. This is uncommon for API keys.",
+    "contains_whitespace": "Contains whitespace (spaces/newlines). This is uncommon for API keys.",
+    "contains_uncommon_ascii": "Contains uncommon symbols. This is uncommon for API keys."
+  }
 }

+ 2 - 1
messages/ja/dashboard.json

@@ -981,7 +981,8 @@
     "expiresAt": {
       "label": "有効期限",
       "placeholder": "空白の場合は無期限",
-      "description": "有効期限切れ後、ユーザーは自動的に無効化されます"
+      "description": "有効期限切れ後、ユーザーは自動的に無効化されます",
+      "pastWarning": "選択した日付は過去です。保存するとユーザーは直ちに期限切れとなり無効化されます。"
     },
     "allowedClients": {
       "label": "許可されたクライアント",

+ 13 - 3
messages/ja/provider-chain.json

@@ -38,7 +38,8 @@
     "concurrentLimit": "同時実行制限",
     "http2Fallback": "HTTP/2 フォールバック",
     "clientError": "クライアントエラー",
-    "endpointPoolExhausted": "エンドポイントプール枯渇"
+    "endpointPoolExhausted": "エンドポイントプール枯渇",
+    "vendorTypeAllTimeout": "ベンダータイプ全エンドポイントタイムアウト"
   },
   "reasons": {
     "request_success": "成功",
@@ -50,7 +51,8 @@
     "http2_fallback": "HTTP/2 フォールバック",
     "session_reuse": "セッション再利用",
     "initial_selection": "初期選択",
-    "endpoint_pool_exhausted": "エンドポイントプール枯渇"
+    "endpoint_pool_exhausted": "エンドポイントプール枯渇",
+    "vendor_type_all_timeout": "ベンダータイプ全エンドポイントタイムアウト"
   },
   "filterReasons": {
     "rate_limited": "レート制限",
@@ -67,6 +69,12 @@
     "endpoint_circuit_open": "エンドポイントサーキットオープン",
     "endpoint_disabled": "エンドポイント無効"
   },
+  "filterDetails": {
+    "vendor_type_circuit_open": "ベンダータイプ一時サーキットブレイク",
+    "circuit_open": "サーキットブレーカーオープン",
+    "circuit_half_open": "サーキットブレーカーハーフオープン",
+    "rate_limited": "レート制限"
+  },
   "details": {
     "selectionMethod": "選択方法",
     "attemptNumber": "試行回数",
@@ -197,6 +205,8 @@
     "endpointStatsCircuitOpen": "サーキットオープンのエンドポイント: {count}",
     "endpointStatsAvailable": "利用可能なエンドポイント: {count}",
     "strictBlockNoEndpoints": "厳格モード:利用可能なエンドポイント候補がないため、フォールバックなしでプロバイダーをスキップ",
-    "strictBlockSelectorError": "厳格モード:エンドポイントセレクターでエラーが発生したため、フォールバックなしでプロバイダーをスキップ"
+    "strictBlockSelectorError": "厳格モード:エンドポイントセレクターでエラーが発生したため、フォールバックなしでプロバイダーをスキップ",
+    "vendorTypeAllTimeout": "ベンダータイプ全エンドポイントタイムアウト(524)",
+    "vendorTypeAllTimeoutNote": "このベンダータイプの全エンドポイントがタイムアウトしました。ベンダータイプサーキットブレーカーが発動しました。"
   }
 }

+ 7 - 1
messages/ja/settings/config.json

@@ -95,7 +95,13 @@
       "leasePercentMonthly": "月次ウィンドウリース比率",
       "leasePercentMonthlyDesc": "各リーススライスの月次制限に対する比率(0-1)",
       "leaseCapUsd": "リース上限(USD)",
-      "leaseCapUsdDesc": "リーススライスごとの絶対上限(米ドル)、空の場合は無制限"
+      "leaseCapUsdDesc": "リーススライスごとの絶対上限(米ドル)、空の場合は無制限",
+      "warnings": {
+        "dbRefreshIntervalTooLow": "更新間隔が {value}s です。DB 負荷が増える可能性があります。",
+        "dbRefreshIntervalTooHigh": "更新間隔が {value}s です。クォータ/制限の反映が遅れる可能性があります。",
+        "leasePercentZero": "比率が 0 です。リース予算が常に 0 になる可能性があります。",
+        "leaseCapZero": "上限が 0 です。リースごとの予算が 0 になる可能性があります。"
+      }
     }
   },
   "section": {

+ 8 - 1
messages/ja/settings/providers/form/key.json

@@ -4,5 +4,12 @@
   "labelEdit": "API キー(空欄のままにすると変更しません)",
   "leaveEmpty": "(空欄のままにすると変更しません)",
   "leaveEmptyDesc": "空欄のままにすると既存のキーを保持します",
-  "placeholder": "API キーを入力"
+  "placeholder": "API キーを入力",
+  "warnings": {
+    "looks_like_auth_header": "リクエストヘッダー(例: Bearer/Authorization/x-api-key)を貼り付けたようです。キー本体のみを入力してください。",
+    "wrapped_in_quotes": "前後が引用符で囲まれています。通常、引用符は不要です。",
+    "contains_non_ascii": "非 ASCII 文字を含んでいます。API キーとしては一般的ではありません。",
+    "contains_whitespace": "空白文字(スペース/改行)を含んでいます。API キーとしては一般的ではありません。",
+    "contains_uncommon_ascii": "一般的でない記号を含んでいます。API キーとしては一般的ではありません。"
+  }
 }

+ 2 - 1
messages/ru/dashboard.json

@@ -983,7 +983,8 @@
     "expiresAt": {
       "label": "Срок действия",
       "placeholder": "Оставьте пустым для бессрочного",
-      "description": "Пользователь будет автоматически отключен после истечения срока"
+      "description": "Пользователь будет автоматически отключен после истечения срока",
+      "pastWarning": "Выбранная дата в прошлом. После сохранения пользователь сразу станет просроченным и будет отключен."
     },
     "allowedClients": {
       "label": "Разрешённые клиенты",

+ 20 - 10
messages/ru/provider-chain.json

@@ -38,7 +38,8 @@
     "concurrentLimit": "Лимит параллельных запросов",
     "http2Fallback": "Откат HTTP/2",
     "clientError": "Ошибка клиента",
-    "endpointPoolExhausted": "Пул конечная точкаов исчерпан"
+    "endpointPoolExhausted": "Пул конечных точек исчерпан",
+    "vendorTypeAllTimeout": "Тайм-аут всех конечных точек"
   },
   "reasons": {
     "request_success": "Успешно",
@@ -50,7 +51,8 @@
     "http2_fallback": "Откат HTTP/2",
     "session_reuse": "Повторное использование сессии",
     "initial_selection": "Первоначальный выбор",
-    "endpoint_pool_exhausted": "Пул конечная точкаов исчерпан"
+    "endpoint_pool_exhausted": "Пул конечных точек исчерпан",
+    "vendor_type_all_timeout": "Тайм-аут всех конечных точек типа поставщика"
   },
   "filterReasons": {
     "rate_limited": "Ограничение скорости",
@@ -64,9 +66,15 @@
     "model_not_supported": "Модель не поддерживается",
     "group_mismatch": "Несоответствие группы",
     "health_check_failed": "Проверка состояния не пройдена",
-    "endpoint_circuit_open": "Автомат конечная точкаа открыт",
+    "endpoint_circuit_open": "Автомат конечной точки открыт",
     "endpoint_disabled": "Эндпоинт отключен"
   },
+  "filterDetails": {
+    "vendor_type_circuit_open": "Временное размыкание типа поставщика",
+    "circuit_open": "Размыкатель открыт",
+    "circuit_half_open": "Размыкатель полуоткрыт",
+    "rate_limited": "Ограничение скорости"
+  },
   "details": {
     "selectionMethod": "Метод выбора",
     "attemptNumber": "Номер попытки",
@@ -190,13 +198,15 @@
     "ruleDescription": "Описание: {description}",
     "ruleHasOverride": "Переопределения: response={response}, statusCode={statusCode}",
     "clientErrorNote": "Эта ошибка вызвана вводом клиента, не повторяется и не учитывается в автомате защиты.",
-    "endpointPoolExhausted": "Пул конечная точкаов исчерпан (все конечная точкаы недоступны)",
-    "endpointStats": "Статистика фильтрации конечная точкаов",
-    "endpointStatsTotal": "Всего конечная точкаов: {count}",
-    "endpointStatsEnabled": "Включено конечная точкаов: {count}",
+    "endpointPoolExhausted": "Пул конечных точек исчерпан (все конечные точки недоступны)",
+    "endpointStats": "Статистика фильтрации конечных точек",
+    "endpointStatsTotal": "Всего конечных точек: {count}",
+    "endpointStatsEnabled": "Включено конечных точек: {count}",
     "endpointStatsCircuitOpen": "Эндпоинтов с открытым автоматом: {count}",
-    "endpointStatsAvailable": "Доступных конечная точкаов: {count}",
-    "strictBlockNoEndpoints": "Строгий режим: нет доступных кандидатов конечная точкаов, провайдер пропущен без отката",
-    "strictBlockSelectorError": "Строгий режим: ошибка селектора конечная точкаов, провайдер пропущен без отката"
+    "endpointStatsAvailable": "Доступных конечных точек: {count}",
+    "strictBlockNoEndpoints": "Строгий режим: нет доступных кандидатов конечных точек, провайдер пропущен без отката",
+    "strictBlockSelectorError": "Строгий режим: ошибка селектора конечных точек, провайдер пропущен без отката",
+    "vendorTypeAllTimeout": "Тайм-аут всех конечных точек типа поставщика (524)",
+    "vendorTypeAllTimeoutNote": "Все конечные точки этого типа поставщика превысили тайм-аут. Активирован размыкатель типа поставщика."
   }
 }

+ 7 - 1
messages/ru/settings/config.json

@@ -95,7 +95,13 @@
       "leasePercentMonthly": "Процент аренды месячного окна",
       "leasePercentMonthlyDesc": "Процент месячного лимита для каждого среза аренды (0-1)",
       "leaseCapUsd": "Предел аренды (USD)",
-      "leaseCapUsdDesc": "Максимальный абсолютный предел на срез аренды в долларах США, оставьте пустым для отсутствия ограничения"
+      "leaseCapUsdDesc": "Максимальный абсолютный предел на срез аренды в долларах США, оставьте пустым для отсутствия ограничения",
+      "warnings": {
+        "dbRefreshIntervalTooLow": "Интервал {value}s. Это может увеличить нагрузку на БД.",
+        "dbRefreshIntervalTooHigh": "Интервал {value}s. Обновление квот/лимитов может запаздывать.",
+        "leasePercentZero": "Процент равен 0. Бюджет аренды может всегда быть 0.",
+        "leaseCapZero": "Предел аренды равен 0. Бюджет на срез может быть 0."
+      }
     }
   },
   "section": {

+ 8 - 1
messages/ru/settings/providers/form/key.json

@@ -4,5 +4,12 @@
   "labelEdit": "API ключ (Оставьте пустым, чтобы не менять)",
   "leaveEmpty": "(Оставьте пустым, чтобы не менять)",
   "leaveEmptyDesc": "Пустое значение — без изменений",
-  "placeholder": "Введите API ключ"
+  "placeholder": "Введите API ключ",
+  "warnings": {
+    "looks_like_auth_header": "Похоже, вы вставили заголовок запроса (например, Bearer/Authorization/x-api-key). Введите только значение ключа.",
+    "wrapped_in_quotes": "Обрамлено кавычками. Обычно кавычки не нужны.",
+    "contains_non_ascii": "Содержит не-ASCII символы. Для API ключей это обычно нетипично.",
+    "contains_whitespace": "Содержит пробелы/переносы строк. Для API ключей это обычно нетипично.",
+    "contains_uncommon_ascii": "Содержит нетипичные символы. Для API ключей это обычно нетипично."
+  }
 }

+ 2 - 1
messages/zh-CN/dashboard.json

@@ -995,7 +995,8 @@
     "expiresAt": {
       "label": "过期时间",
       "placeholder": "留空表示永不过期",
-      "description": "用户过期后将自动禁用"
+      "description": "用户过期后将自动禁用",
+      "pastWarning": "选择的日期已在过去,保存后用户将立即过期并被禁用。"
     },
     "allowedClients": {
       "label": "允许的客户端",

+ 13 - 3
messages/zh-CN/provider-chain.json

@@ -38,7 +38,8 @@
     "concurrentLimit": "并发限制",
     "http2Fallback": "HTTP/2 回退",
     "clientError": "客户端错误",
-    "endpointPoolExhausted": "端点池耗尽"
+    "endpointPoolExhausted": "端点池耗尽",
+    "vendorTypeAllTimeout": "供应商类型全端点超时"
   },
   "reasons": {
     "request_success": "成功",
@@ -50,7 +51,8 @@
     "http2_fallback": "HTTP/2 回退",
     "session_reuse": "会话复用",
     "initial_selection": "首次选择",
-    "endpoint_pool_exhausted": "端点池耗尽"
+    "endpoint_pool_exhausted": "端点池耗尽",
+    "vendor_type_all_timeout": "供应商类型全端点超时"
   },
   "filterReasons": {
     "rate_limited": "速率限制",
@@ -67,6 +69,12 @@
     "endpoint_circuit_open": "端点已熔断",
     "endpoint_disabled": "端点已禁用"
   },
+  "filterDetails": {
+    "vendor_type_circuit_open": "供应商类型临时熔断",
+    "circuit_open": "熔断器打开",
+    "circuit_half_open": "熔断器半开",
+    "rate_limited": "速率限制"
+  },
   "details": {
     "selectionMethod": "选择方式",
     "attemptNumber": "尝试次数",
@@ -197,6 +205,8 @@
     "endpointStatsCircuitOpen": "已熔断端点: {count}",
     "endpointStatsAvailable": "可用端点: {count}",
     "strictBlockNoEndpoints": "严格模式:无可用端点候选,跳过该供应商且不降级",
-    "strictBlockSelectorError": "严格模式:端点选择器发生错误,跳过该供应商且不降级"
+    "strictBlockSelectorError": "严格模式:端点选择器发生错误,跳过该供应商且不降级",
+    "vendorTypeAllTimeout": "供应商类型全端点超时(524)",
+    "vendorTypeAllTimeoutNote": "该供应商类型的所有端点均超时,已触发供应商类型临时熔断。"
   }
 }

+ 7 - 1
messages/zh-CN/settings/config.json

@@ -108,7 +108,13 @@
       "leasePercentMonthly": "每月窗口租约比例",
       "leasePercentMonthlyDesc": "每次租约切片占每月限额的比例(0-1)",
       "leaseCapUsd": "租约上限(USD)",
-      "leaseCapUsdDesc": "单次租约切片的绝对上限(美元),为空则不限制"
+      "leaseCapUsdDesc": "单次租约切片的绝对上限(美元),为空则不限制",
+      "warnings": {
+        "dbRefreshIntervalTooLow": "当前刷新间隔为 {value}s,可能增加 DB 压力。",
+        "dbRefreshIntervalTooHigh": "当前刷新间隔为 {value}s,配额/限额状态可能更新不及时。",
+        "leasePercentZero": "当前比例为 0,可能导致租约预算始终为 0。",
+        "leaseCapZero": "租约上限为 0 可能导致单次租约预算为 0。"
+      }
     }
   }
 }

+ 8 - 1
messages/zh-CN/settings/providers/form/key.json

@@ -4,5 +4,12 @@
   "leaveEmpty": "(留空不更改)",
   "placeholder": "输入 API 密钥",
   "leaveEmptyDesc": "留空则不更改密钥",
-  "currentKey": "当前密钥: {key}"
+  "currentKey": "当前密钥: {key}",
+  "warnings": {
+    "looks_like_auth_header": "看起来像粘贴了请求头(如 Bearer/Authorization/x-api-key)。请仅填写 Key 本身。",
+    "wrapped_in_quotes": "检测到首尾引号,通常不需要引号。",
+    "contains_non_ascii": "包含非 ASCII 字符(如中文),通常不是常见 API Key。",
+    "contains_whitespace": "包含空白字符(空格/换行),通常不是常见 API Key。",
+    "contains_uncommon_ascii": "包含不常见符号,通常不是常见 API Key。"
+  }
 }

+ 2 - 1
messages/zh-TW/dashboard.json

@@ -980,7 +980,8 @@
     "expiresAt": {
       "label": "到期時間",
       "placeholder": "留空表示永不過期",
-      "description": "使用者過期後將自動停用"
+      "description": "使用者過期後將自動停用",
+      "pastWarning": "選擇的日期已在過去,儲存後使用者將立即到期並被停用。"
     },
     "allowedClients": {
       "label": "允許的用戶端",

+ 13 - 3
messages/zh-TW/provider-chain.json

@@ -38,7 +38,8 @@
     "concurrentLimit": "並發限制",
     "http2Fallback": "HTTP/2 回退",
     "clientError": "客戶端錯誤",
-    "endpointPoolExhausted": "端點池耗盡"
+    "endpointPoolExhausted": "端點池耗盡",
+    "vendorTypeAllTimeout": "供應商類型全端點逾時"
   },
   "reasons": {
     "request_success": "成功",
@@ -50,7 +51,8 @@
     "http2_fallback": "HTTP/2 回退",
     "session_reuse": "會話複用",
     "initial_selection": "首次選擇",
-    "endpoint_pool_exhausted": "端點池耗盡"
+    "endpoint_pool_exhausted": "端點池耗盡",
+    "vendor_type_all_timeout": "供應商類型全端點逾時"
   },
   "filterReasons": {
     "rate_limited": "速率限制",
@@ -67,6 +69,12 @@
     "endpoint_circuit_open": "端點已熔斷",
     "endpoint_disabled": "端點已停用"
   },
+  "filterDetails": {
+    "vendor_type_circuit_open": "供應商類型臨時熔斷",
+    "circuit_open": "熔斷器打開",
+    "circuit_half_open": "熔斷器半開",
+    "rate_limited": "速率限制"
+  },
   "details": {
     "selectionMethod": "選擇方式",
     "attemptNumber": "嘗試次數",
@@ -197,6 +205,8 @@
     "endpointStatsCircuitOpen": "已熔斷端點: {count}",
     "endpointStatsAvailable": "可用端點: {count}",
     "strictBlockNoEndpoints": "嚴格模式:無可用端點候選,跳過該供應商且不降級",
-    "strictBlockSelectorError": "嚴格模式:端點選擇器發生錯誤,跳過該供應商且不降級"
+    "strictBlockSelectorError": "嚴格模式:端點選擇器發生錯誤,跳過該供應商且不降級",
+    "vendorTypeAllTimeout": "供應商類型全端點逾時(524)",
+    "vendorTypeAllTimeoutNote": "該供應商類型的所有端點均逾時,已觸發供應商類型臨時熔斷。"
   }
 }

+ 7 - 1
messages/zh-TW/settings/config.json

@@ -95,7 +95,13 @@
       "leasePercentMonthly": "每月窗口租約比例",
       "leasePercentMonthlyDesc": "每次租約切片佔每月限額的比例(0-1)",
       "leaseCapUsd": "租約上限(USD)",
-      "leaseCapUsdDesc": "單次租約切片的絕對上限(美元),為空則不限制"
+      "leaseCapUsdDesc": "單次租約切片的絕對上限(美元),為空則不限制",
+      "warnings": {
+        "dbRefreshIntervalTooLow": "目前刷新間隔為 {value}s,可能增加 DB 壓力。",
+        "dbRefreshIntervalTooHigh": "目前刷新間隔為 {value}s,配額/限額狀態可能更新不及時。",
+        "leasePercentZero": "目前比例為 0,可能導致租約預算始終為 0。",
+        "leaseCapZero": "租約上限為 0 可能導致單次租約預算為 0。"
+      }
     }
   },
   "section": {

+ 8 - 1
messages/zh-TW/settings/providers/form/key.json

@@ -4,5 +4,12 @@
   "labelEdit": "API 金鑰(留空不變更)",
   "leaveEmpty": "(留空不變更)",
   "leaveEmptyDesc": "留空則不變更金鑰",
-  "placeholder": "輸入 API 金鑰"
+  "placeholder": "輸入 API 金鑰",
+  "warnings": {
+    "looks_like_auth_header": "看起來像貼上了請求標頭(例如 Bearer/Authorization/x-api-key)。請只填入 Key 本身。",
+    "wrapped_in_quotes": "偵測到首尾引號,通常不需要引號。",
+    "contains_non_ascii": "包含非 ASCII 字元(例如中文),通常不是常見 API Key。",
+    "contains_whitespace": "包含空白字元(空格/換行),通常不是常見 API Key。",
+    "contains_uncommon_ascii": "包含不常見符號,通常不是常見 API Key。"
+  }
 }

+ 1 - 0
scripts/deploy.ps1

@@ -503,6 +503,7 @@ ENABLE_SECURE_COOKIES=$secureCookies
 
 # Circuit Breaker Configuration
 ENABLE_CIRCUIT_BREAKER_ON_NETWORK_ERRORS=false
+ENABLE_ENDPOINT_CIRCUIT_BREAKER=false
 
 # Environment
 NODE_ENV=production

+ 1 - 0
scripts/deploy.sh

@@ -585,6 +585,7 @@ ENABLE_SECURE_COOKIES=${secure_cookies}
 
 # Circuit Breaker Configuration
 ENABLE_CIRCUIT_BREAKER_ON_NETWORK_ERRORS=false
+ENABLE_ENDPOINT_CIRCUIT_BREAKER=false
 
 # Environment
 NODE_ENV=production

+ 17 - 5
src/actions/key-quota.ts

@@ -3,9 +3,10 @@
 import { and, eq, isNull } from "drizzle-orm";
 import { getTranslations } from "next-intl/server";
 import { db } from "@/drizzle/db";
-import { keys as keysTable } from "@/drizzle/schema";
+import { keys as keysTable, users as usersTable } from "@/drizzle/schema";
 import { getSession } from "@/lib/auth";
 import { logger } from "@/lib/logger";
+import { resolveKeyConcurrentSessionLimit } from "@/lib/rate-limit/concurrent-session-limit";
 import type { DailyResetMode } from "@/lib/rate-limit/time-utils";
 import { SessionTracker } from "@/lib/session-tracker";
 import type { CurrencyCode } from "@/lib/utils";
@@ -48,13 +49,17 @@ export async function getKeyQuotaUsage(keyId: number): Promise<ActionResult<KeyQ
       };
     }
 
-    const [keyRow] = await db
-      .select()
+    const [result] = await db
+      .select({
+        key: keysTable,
+        userLimitConcurrentSessions: usersTable.limitConcurrentSessions,
+      })
       .from(keysTable)
+      .leftJoin(usersTable, and(eq(keysTable.userId, usersTable.id), isNull(usersTable.deletedAt)))
       .where(and(eq(keysTable.id, keyId), isNull(keysTable.deletedAt)))
       .limit(1);
 
-    if (!keyRow) {
+    if (!result) {
       return {
         ok: false,
         error: tError?.("KEY_NOT_FOUND") ?? "",
@@ -62,6 +67,8 @@ export async function getKeyQuotaUsage(keyId: number): Promise<ActionResult<KeyQ
       };
     }
 
+    const keyRow = result.key;
+
     // Allow admin to view any key, users can only view their own keys
     if (session.user.role !== "admin" && keyRow.userId !== session.user.id) {
       return {
@@ -71,6 +78,11 @@ export async function getKeyQuotaUsage(keyId: number): Promise<ActionResult<KeyQ
       };
     }
 
+    const effectiveConcurrentLimit = resolveKeyConcurrentSessionLimit(
+      keyRow.limitConcurrentSessions ?? 0,
+      result.userLimitConcurrentSessions ?? null
+    );
+
     const settings = await getSystemSettings();
     const currencyCode = settings.currencyDisplay;
 
@@ -141,7 +153,7 @@ export async function getKeyQuotaUsage(keyId: number): Promise<ActionResult<KeyQ
       {
         type: "limitSessions",
         current: concurrentSessions,
-        limit: keyRow.limitConcurrentSessions ?? null,
+        limit: effectiveConcurrentLimit > 0 ? effectiveConcurrentLimit : null,
       },
     ];
 

+ 21 - 4
src/actions/keys.ts

@@ -5,15 +5,17 @@ import { and, count, eq, inArray, isNull } from "drizzle-orm";
 import { revalidatePath } from "next/cache";
 import { getTranslations } from "next-intl/server";
 import { db } from "@/drizzle/db";
-import { keys as keysTable } from "@/drizzle/schema";
+import { keys as keysTable, users as usersTable } from "@/drizzle/schema";
 import { getSession } from "@/lib/auth";
 import { PROVIDER_GROUP } from "@/lib/constants/provider.constants";
 import { logger } from "@/lib/logger";
+import { resolveKeyConcurrentSessionLimit } from "@/lib/rate-limit/concurrent-session-limit";
 import { parseDateInputAsTimezone } from "@/lib/utils/date-input";
 import { ERROR_CODES } from "@/lib/utils/error-messages";
 import { normalizeProviderGroup, parseProviderGroups } from "@/lib/utils/provider-group";
 import { resolveSystemTimezone } from "@/lib/utils/timezone";
 import { KeyFormSchema } from "@/lib/validation/schemas";
+import { toKey } from "@/repository/_shared/transformers";
 import type { KeyStatistics } from "@/repository/key";
 import {
   countActiveKeysByUser,
@@ -696,11 +698,22 @@ export async function getKeyLimitUsage(keyId: number): Promise<
       return { ok: false, error: "未登录" };
     }
 
-    const key = await findKeyById(keyId);
-    if (!key) {
+    const [result] = await db
+      .select({
+        key: keysTable,
+        userLimitConcurrentSessions: usersTable.limitConcurrentSessions,
+      })
+      .from(keysTable)
+      .leftJoin(usersTable, and(eq(keysTable.userId, usersTable.id), isNull(usersTable.deletedAt)))
+      .where(and(eq(keysTable.id, keyId), isNull(keysTable.deletedAt)))
+      .limit(1);
+
+    if (!result) {
       return { ok: false, error: "密钥不存在" };
     }
 
+    const key = toKey(result.key);
+
     // 权限检查
     if (session.user.role !== "admin" && session.user.id !== key.userId) {
       return { ok: false, error: "无权限执行此操作" };
@@ -715,6 +728,10 @@ export async function getKeyLimitUsage(keyId: number): Promise<
       getTimeRangeForPeriodWithMode,
     } = await import("@/lib/rate-limit/time-utils");
     const { sumKeyTotalCost, sumKeyCostInTimeRange } = await import("@/repository/statistics");
+    const effectiveConcurrentLimit = resolveKeyConcurrentSessionLimit(
+      key.limitConcurrentSessions,
+      result.userLimitConcurrentSessions ?? null
+    );
 
     // Calculate time ranges using Key's dailyResetTime/dailyResetMode configuration
     const keyDailyTimeRange = await getTimeRangeForPeriodWithMode(
@@ -778,7 +795,7 @@ export async function getKeyLimitUsage(keyId: number): Promise<
         },
         concurrentSessions: {
           current: concurrentSessions,
-          limit: key.limitConcurrentSessions || 0,
+          limit: effectiveConcurrentLimit,
         },
       },
     };

+ 8 - 2
src/actions/my-usage.ts

@@ -6,6 +6,7 @@ import { db } from "@/drizzle/db";
 import { keys as keysTable, messageRequest } from "@/drizzle/schema";
 import { getSession } from "@/lib/auth";
 import { logger } from "@/lib/logger";
+import { resolveKeyConcurrentSessionLimit } from "@/lib/rate-limit/concurrent-session-limit";
 import type { DailyResetMode } from "@/lib/rate-limit/time-utils";
 import { SessionTracker } from "@/lib/session-tracker";
 import type { CurrencyCode } from "@/lib/utils";
@@ -91,7 +92,7 @@ export interface MyUsageQuota {
   keyLimitWeeklyUsd: number | null;
   keyLimitMonthlyUsd: number | null;
   keyLimitTotalUsd: number | null;
-  keyLimitConcurrentSessions: number | null;
+  keyLimitConcurrentSessions: number;
   keyCurrent5hUsd: number;
   keyCurrentDailyUsd: number;
   keyCurrentWeeklyUsd: number;
@@ -266,6 +267,11 @@ export async function getMyQuota(): Promise<ActionResult<MyUsageQuota>> {
     const rangeWeekly = await getTimeRangeForPeriod("weekly");
     const rangeMonthly = await getTimeRangeForPeriod("monthly");
 
+    const effectiveKeyConcurrentLimit = resolveKeyConcurrentSessionLimit(
+      key.limitConcurrentSessions ?? 0,
+      user.limitConcurrentSessions ?? null
+    );
+
     const [
       keyCost5h,
       keyCostDaily,
@@ -302,7 +308,7 @@ export async function getMyQuota(): Promise<ActionResult<MyUsageQuota>> {
       keyLimitWeeklyUsd: key.limitWeeklyUsd ?? null,
       keyLimitMonthlyUsd: key.limitMonthlyUsd ?? null,
       keyLimitTotalUsd: key.limitTotalUsd ?? null,
-      keyLimitConcurrentSessions: key.limitConcurrentSessions ?? null,
+      keyLimitConcurrentSessions: effectiveKeyConcurrentLimit,
       keyCurrent5hUsd: keyCost5h,
       keyCurrentDailyUsd: keyCostDaily,
       keyCurrentWeeklyUsd: keyCostWeekly,

+ 21 - 11
src/app/[locale]/dashboard/_components/user/forms/user-form.tsx

@@ -1,7 +1,7 @@
 "use client";
 import { useRouter } from "next/navigation";
 import { useTranslations } from "next-intl";
-import { useEffect, useState, useTransition } from "react";
+import { useEffect, useMemo, useState, useTransition } from "react";
 import { toast } from "sonner";
 import { z } from "zod";
 import { getAvailableProviderGroups } from "@/actions/providers";
@@ -10,11 +10,13 @@ import { DatePickerField } from "@/components/form/date-picker-field";
 import { ArrayTagInputField, TagInputField, TextField } from "@/components/form/form-field";
 import { DialogFormLayout, FormGrid } from "@/components/form/form-layout";
 import { Checkbox } from "@/components/ui/checkbox";
+import { InlineWarning } from "@/components/ui/inline-warning";
 import { Label } from "@/components/ui/label";
 import { Switch } from "@/components/ui/switch";
 import { PROVIDER_GROUP } from "@/lib/constants/provider.constants";
 import { USER_LIMITS } from "@/lib/constants/user.constants";
 import { useZodForm } from "@/lib/hooks/use-zod-form";
+import { formatDateToLocalYmd, parseYmdToLocalEndOfDay } from "@/lib/utils/date-input";
 import { getErrorMessage } from "@/lib/utils/error-messages";
 import { setZodErrorMap } from "@/lib/utils/zod-i18n";
 import { CreateUserSchema } from "@/lib/validation/schemas";
@@ -99,20 +101,19 @@ export function UserForm({ user, onSuccess, currentUser }: UserFormProps) {
       limitTotalUsd: user?.limitTotalUsd ?? null,
       limitConcurrentSessions: user?.limitConcurrentSessions ?? null,
       isEnabled: user?.isEnabled ?? true,
-      expiresAt: user?.expiresAt ? user.expiresAt.toISOString().split("T")[0] : "",
+      expiresAt: user?.expiresAt ? formatDateToLocalYmd(user.expiresAt) : "",
       allowedClients: user?.allowedClients || [],
       allowedModels: user?.allowedModels || [],
     },
     onSubmit: async (data) => {
-      // 将纯日期转换为当天结束时间(本地时区 23:59:59.999),避免默认 UTC 零点导致提前过期
-      const toEndOfDay = (dateStr: string) => {
-        const d = new Date(dateStr);
-        d.setHours(23, 59, 59, 999);
-        return d;
-      };
-
       startTransition(async () => {
         try {
+          const expiresAt = data.expiresAt ? parseYmdToLocalEndOfDay(data.expiresAt) : null;
+          if (data.expiresAt && !expiresAt) {
+            toast.error(tErrors("INVALID_FORMAT", { field: tErrors("EXPIRES_AT_FIELD") }));
+            return;
+          }
+
           let res;
           if (isEdit && user?.id) {
             res = await editUser(user.id, {
@@ -128,7 +129,7 @@ export function UserForm({ user, onSuccess, currentUser }: UserFormProps) {
               limitTotalUsd: data.limitTotalUsd,
               limitConcurrentSessions: data.limitConcurrentSessions,
               isEnabled: data.isEnabled,
-              expiresAt: data.expiresAt ? toEndOfDay(data.expiresAt) : null,
+              expiresAt,
               allowedClients: data.allowedClients,
               allowedModels: data.allowedModels,
             });
@@ -146,7 +147,7 @@ export function UserForm({ user, onSuccess, currentUser }: UserFormProps) {
               limitTotalUsd: data.limitTotalUsd,
               limitConcurrentSessions: data.limitConcurrentSessions,
               isEnabled: data.isEnabled,
-              expiresAt: data.expiresAt ? toEndOfDay(data.expiresAt) : null,
+              expiresAt,
               allowedClients: data.allowedClients,
               allowedModels: data.allowedModels,
             });
@@ -176,6 +177,14 @@ export function UserForm({ user, onSuccess, currentUser }: UserFormProps) {
   // Use dashboard translations for form
   const tForm = useTranslations("dashboard.userForm");
 
+  const expiresAtPastWarning = useMemo(() => {
+    const expiresAtYmd = form.values.expiresAt ?? "";
+    if (!expiresAtYmd) return null;
+    const date = parseYmdToLocalEndOfDay(expiresAtYmd);
+    if (!date) return null;
+    return date.getTime() <= Date.now() ? tForm("expiresAt.pastWarning") : null;
+  }, [form.values.expiresAt, tForm]);
+
   return (
     <DialogFormLayout
       config={{
@@ -352,6 +361,7 @@ export function UserForm({ user, onSuccess, currentUser }: UserFormProps) {
             error={form.getFieldProps("expiresAt").error}
             touched={form.getFieldProps("expiresAt").touched}
           />
+          {expiresAtPastWarning && <InlineWarning>{expiresAtPastWarning}</InlineWarning>}
 
           {/* Allowed Clients (CLI/IDE restrictions) */}
           <div className="space-y-3">

+ 7 - 1
src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/LogicTraceTab.tsx

@@ -353,7 +353,13 @@ export function LogicTraceTab({
                         {tChain(`filterReasons.${p.reason}`)}
                       </span>
                       {p.details && (
-                        <span className="text-muted-foreground break-all">({p.details})</span>
+                        <span className="text-muted-foreground break-all">
+                          (
+                          {tChain.has(`filterDetails.${p.details}`)
+                            ? tChain(`filterDetails.${p.details}`)
+                            : p.details}
+                          )
+                        </span>
                       )}
                     </div>
                   ))}

+ 10 - 0
src/app/[locale]/dashboard/logs/_components/provider-chain-popover.tsx

@@ -34,6 +34,9 @@ interface ProviderChainPopoverProps {
 function isActualRequest(item: ProviderChainItem): boolean {
   if (item.reason === "concurrent_limit_failed") return true;
   if (item.reason === "retry_failed" || item.reason === "system_error") return true;
+  if (item.reason === "endpoint_pool_exhausted") return true;
+  if (item.reason === "vendor_type_all_timeout") return true;
+  if (item.reason === "client_error_non_retryable") return true;
   if ((item.reason === "request_success" || item.reason === "retry_success") && item.statusCode) {
     return true;
   }
@@ -89,6 +92,13 @@ function getItemStatus(item: ProviderChainItem): {
       bgColor: "bg-orange-50 dark:bg-orange-950/30",
     };
   }
+  if (item.reason === "endpoint_pool_exhausted" || item.reason === "vendor_type_all_timeout") {
+    return {
+      icon: XCircle,
+      color: "text-rose-600",
+      bgColor: "bg-rose-50 dark:bg-rose-950/30",
+    };
+  }
   return {
     icon: RefreshCw,
     color: "text-slate-500",

+ 67 - 6
src/app/[locale]/settings/config/_components/system-settings-form.tsx

@@ -21,6 +21,7 @@ import { toast } from "sonner";
 import { saveSystemSettings } from "@/actions/system-config";
 import { Button } from "@/components/ui/button";
 import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
+import { InlineWarning } from "@/components/ui/inline-warning";
 import { Input } from "@/components/ui/input";
 import { Label } from "@/components/ui/label";
 import {
@@ -34,6 +35,12 @@ import { Switch } from "@/components/ui/switch";
 import type { CurrencyCode } from "@/lib/utils";
 import { CURRENCY_CONFIG } from "@/lib/utils";
 import { COMMON_TIMEZONES, getTimezoneLabel } from "@/lib/utils/timezone";
+import {
+  shouldWarnQuotaDbRefreshIntervalTooHigh,
+  shouldWarnQuotaDbRefreshIntervalTooLow,
+  shouldWarnQuotaLeaseCapZero,
+  shouldWarnQuotaLeasePercentZero,
+} from "@/lib/utils/validation/quota-lease-warnings";
 import type { BillingModelSource, SystemSettings } from "@/types/system-config";
 
 interface SystemSettingsFormProps {
@@ -62,6 +69,13 @@ interface SystemSettingsFormProps {
   >;
 }
 
+function clampQuotaDbRefreshIntervalSeconds(raw: string): number {
+  const parsed = Number(raw);
+  if (!Number.isFinite(parsed)) return 1;
+  const rounded = Math.round(parsed);
+  return Math.min(300, Math.max(1, rounded));
+}
+
 export function SystemSettingsForm({ initialSettings }: SystemSettingsFormProps) {
   const router = useRouter();
   const t = useTranslations("settings.config.form");
@@ -102,9 +116,15 @@ export function SystemSettingsForm({ initialSettings }: SystemSettingsFormProps)
   const [responseFixerConfig, setResponseFixerConfig] = useState(
     initialSettings.responseFixerConfig
   );
-  const [quotaDbRefreshIntervalSeconds, setQuotaDbRefreshIntervalSeconds] = useState(
-    initialSettings.quotaDbRefreshIntervalSeconds ?? 10
+  const [quotaDbRefreshIntervalSecondsStr, setQuotaDbRefreshIntervalSecondsStr] = useState(
+    String(initialSettings.quotaDbRefreshIntervalSeconds ?? 10)
   );
+  const quotaDbRefreshIntervalSeconds = (() => {
+    const trimmed = quotaDbRefreshIntervalSecondsStr.trim();
+    if (!trimmed) return Number.NaN;
+    const parsed = Number(trimmed);
+    return Number.isFinite(parsed) ? parsed : Number.NaN;
+  })();
   const [quotaLeasePercent5h, setQuotaLeasePercent5h] = useState(
     initialSettings.quotaLeasePercent5h ?? 0.05
   );
@@ -132,6 +152,10 @@ export function SystemSettingsForm({ initialSettings }: SystemSettingsFormProps)
       return;
     }
 
+    const quotaDbRefreshIntervalSecondsToSave = clampQuotaDbRefreshIntervalSeconds(
+      quotaDbRefreshIntervalSecondsStr
+    );
+
     startTransition(async () => {
       const result = await saveSystemSettings({
         siteTitle,
@@ -148,7 +172,7 @@ export function SystemSettingsForm({ initialSettings }: SystemSettingsFormProps)
         enableClaudeMetadataUserIdInjection,
         enableResponseFixer,
         responseFixerConfig,
-        quotaDbRefreshIntervalSeconds,
+        quotaDbRefreshIntervalSeconds: quotaDbRefreshIntervalSecondsToSave,
         quotaLeasePercent5h,
         quotaLeasePercentDaily,
         quotaLeasePercentWeekly,
@@ -176,7 +200,9 @@ export function SystemSettingsForm({ initialSettings }: SystemSettingsFormProps)
         setEnableClaudeMetadataUserIdInjection(result.data.enableClaudeMetadataUserIdInjection);
         setEnableResponseFixer(result.data.enableResponseFixer);
         setResponseFixerConfig(result.data.responseFixerConfig);
-        setQuotaDbRefreshIntervalSeconds(result.data.quotaDbRefreshIntervalSeconds ?? 10);
+        setQuotaDbRefreshIntervalSecondsStr(
+          String(result.data.quotaDbRefreshIntervalSeconds ?? 10)
+        );
         setQuotaLeasePercent5h(result.data.quotaLeasePercent5h ?? 0.05);
         setQuotaLeasePercentDaily(result.data.quotaLeasePercentDaily ?? 0.05);
         setQuotaLeasePercentWeekly(result.data.quotaLeasePercentWeekly ?? 0.05);
@@ -620,14 +646,34 @@ export function SystemSettingsForm({ initialSettings }: SystemSettingsFormProps)
                     type="number"
                     min={1}
                     max={300}
-                    value={quotaDbRefreshIntervalSeconds}
-                    onChange={(e) => setQuotaDbRefreshIntervalSeconds(Number(e.target.value))}
+                    step={1}
+                    value={quotaDbRefreshIntervalSecondsStr}
+                    onChange={(e) => setQuotaDbRefreshIntervalSecondsStr(e.target.value)}
+                    onBlur={() => {
+                      setQuotaDbRefreshIntervalSecondsStr(
+                        String(clampQuotaDbRefreshIntervalSeconds(quotaDbRefreshIntervalSecondsStr))
+                      );
+                    }}
                     disabled={isPending}
                     className={inputClassName}
                   />
                   <p className="text-xs text-muted-foreground">
                     {t("quotaLease.dbRefreshIntervalDesc")}
                   </p>
+                  {shouldWarnQuotaDbRefreshIntervalTooLow(quotaDbRefreshIntervalSeconds) && (
+                    <InlineWarning>
+                      {t("quotaLease.warnings.dbRefreshIntervalTooLow", {
+                        value: quotaDbRefreshIntervalSeconds,
+                      })}
+                    </InlineWarning>
+                  )}
+                  {shouldWarnQuotaDbRefreshIntervalTooHigh(quotaDbRefreshIntervalSeconds) && (
+                    <InlineWarning>
+                      {t("quotaLease.warnings.dbRefreshIntervalTooHigh", {
+                        value: quotaDbRefreshIntervalSeconds,
+                      })}
+                    </InlineWarning>
+                  )}
                 </div>
 
                 {/* Lease Percent 5h */}
@@ -652,6 +698,9 @@ export function SystemSettingsForm({ initialSettings }: SystemSettingsFormProps)
                   <p className="text-xs text-muted-foreground">
                     {t("quotaLease.leasePercent5hDesc")}
                   </p>
+                  {shouldWarnQuotaLeasePercentZero(quotaLeasePercent5h) && (
+                    <InlineWarning>{t("quotaLease.warnings.leasePercentZero")}</InlineWarning>
+                  )}
                 </div>
 
                 {/* Lease Percent Daily */}
@@ -676,6 +725,9 @@ export function SystemSettingsForm({ initialSettings }: SystemSettingsFormProps)
                   <p className="text-xs text-muted-foreground">
                     {t("quotaLease.leasePercentDailyDesc")}
                   </p>
+                  {shouldWarnQuotaLeasePercentZero(quotaLeasePercentDaily) && (
+                    <InlineWarning>{t("quotaLease.warnings.leasePercentZero")}</InlineWarning>
+                  )}
                 </div>
 
                 {/* Lease Percent Weekly */}
@@ -700,6 +752,9 @@ export function SystemSettingsForm({ initialSettings }: SystemSettingsFormProps)
                   <p className="text-xs text-muted-foreground">
                     {t("quotaLease.leasePercentWeeklyDesc")}
                   </p>
+                  {shouldWarnQuotaLeasePercentZero(quotaLeasePercentWeekly) && (
+                    <InlineWarning>{t("quotaLease.warnings.leasePercentZero")}</InlineWarning>
+                  )}
                 </div>
 
                 {/* Lease Percent Monthly */}
@@ -724,6 +779,9 @@ export function SystemSettingsForm({ initialSettings }: SystemSettingsFormProps)
                   <p className="text-xs text-muted-foreground">
                     {t("quotaLease.leasePercentMonthlyDesc")}
                   </p>
+                  {shouldWarnQuotaLeasePercentZero(quotaLeasePercentMonthly) && (
+                    <InlineWarning>{t("quotaLease.warnings.leasePercentZero")}</InlineWarning>
+                  )}
                 </div>
 
                 {/* Lease Cap USD */}
@@ -746,6 +804,9 @@ export function SystemSettingsForm({ initialSettings }: SystemSettingsFormProps)
                     className={inputClassName}
                   />
                   <p className="text-xs text-muted-foreground">{t("quotaLease.leaseCapUsdDesc")}</p>
+                  {shouldWarnQuotaLeaseCapZero(quotaLeaseCapUsd) && (
+                    <InlineWarning>{t("quotaLease.warnings.leaseCapZero")}</InlineWarning>
+                  )}
                 </div>
               </div>
             </CollapsibleContent>

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

@@ -22,7 +22,8 @@ export function createInitialState(
   }
 ): ProviderFormState {
   const isEdit = mode === "edit";
-  const sourceProvider = isEdit ? provider : cloneProvider;
+  const raw = isEdit ? provider : cloneProvider;
+  const sourceProvider = raw ? structuredClone(raw) : undefined;
 
   return {
     basic: {
@@ -322,11 +323,13 @@ export function providerFormReducer(
       return { ...state, ui: { ...state.ui, showFailureThresholdConfirm: action.payload } };
 
     // Reset
-    case "RESET_FORM":
+    case "RESET_FORM": {
+      const fresh = structuredClone(defaultInitialState);
       return {
-        ...defaultInitialState,
-        ui: { ...defaultInitialState.ui, activeTab: state.ui.activeTab },
+        ...fresh,
+        ui: { ...fresh.ui, activeTab: state.ui.activeTab },
       };
+    }
 
     // Load provider data
     case "LOAD_PROVIDER":

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

@@ -3,9 +3,11 @@
 import { motion } from "framer-motion";
 import { ExternalLink, Eye, EyeOff, Globe, Key, Link2, User } from "lucide-react";
 import { useTranslations } from "next-intl";
-import { useEffect, useRef, useState } from "react";
+import { 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 { detectApiKeyWarnings } from "@/lib/utils/validation/api-key-warnings";
 import type { ProviderType } from "@/types/provider";
 import { UrlPreview } from "../../url-preview";
 import { QuickPasteDialog } from "../components/quick-paste-dialog";
@@ -29,6 +31,8 @@ export function BasicInfoSection({ autoUrlPending, endpointPool }: BasicInfoSect
   const nameInputRef = useRef<HTMLInputElement>(null);
   const [showKey, setShowKey] = useState(false);
 
+  const apiKeyWarnings = useMemo(() => detectApiKeyWarnings(state.basic.key), [state.basic.key]);
+
   // Auto-focus name input
   useEffect(() => {
     const timer = setTimeout(() => {
@@ -199,6 +203,14 @@ export function BasicInfoSection({ autoUrlPending, endpointPool }: BasicInfoSect
                 {showKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
               </button>
             </div>
+
+            {apiKeyWarnings.length > 0 && (
+              <div className="mt-2 space-y-1">
+                {apiKeyWarnings.map((warningId) => (
+                  <InlineWarning key={warningId}>{t(`key.warnings.${warningId}`)}</InlineWarning>
+                ))}
+              </div>
+            )}
           </SmartInputWrapper>
         </div>
       </SectionCard>

+ 1 - 1
src/app/api/actions/[...route]/route.ts

@@ -891,7 +891,7 @@ const { route: getMyQuotaRoute, handler: getMyQuotaHandler } = createActionRoute
       keyLimitWeeklyUsd: z.number().nullable(),
       keyLimitMonthlyUsd: z.number().nullable(),
       keyLimitTotalUsd: z.number().nullable(),
-      keyLimitConcurrentSessions: z.number().nullable(),
+      keyLimitConcurrentSessions: z.number(),
       keyCurrent5hUsd: z.number(),
       keyCurrentDailyUsd: z.number(),
       keyCurrentWeeklyUsd: z.number(),

+ 20 - 0
src/app/v1/_lib/proxy/forwarder.ts

@@ -1407,6 +1407,26 @@ export class ProxyForwarder {
               allEndpointAttemptsTimedOut &&
               currentProvider.providerVendorId
             ) {
+              // Record to decision chain BEFORE triggering vendor-type circuit breaker
+              session.addProviderToChain(currentProvider, {
+                ...endpointAudit,
+                reason: "vendor_type_all_timeout",
+                attemptNumber: attemptCount,
+                statusCode: 524,
+                errorMessage: errorMessage,
+                errorDetails: {
+                  provider: {
+                    id: currentProvider.id,
+                    name: currentProvider.name,
+                    statusCode: 524,
+                    statusText: proxyError.message,
+                    upstreamBody: proxyError.upstreamError?.body,
+                    upstreamParsed: proxyError.upstreamError?.parsed,
+                  },
+                  request: buildRequestDetails(session),
+                },
+              });
+
               await recordVendorTypeAllEndpointsTimeout(
                 currentProvider.providerVendorId,
                 currentProvider.providerType

+ 3 - 3
src/app/v1/_lib/proxy/provider-selector.ts

@@ -885,7 +885,7 @@ export class ProxyProviderResolver {
           id: p.id,
           name: p.name,
           reason: "circuit_open",
-          details: "供应商类型临时熔断",
+          details: "vendor_type_circuit_open",
         });
         continue;
       }
@@ -896,14 +896,14 @@ export class ProxyProviderResolver {
           id: p.id,
           name: p.name,
           reason: "circuit_open",
-          details: `熔断器${state === "open" ? "打开" : "半开"}`,
+          details: state === "open" ? "circuit_open" : "circuit_half_open",
         });
       } else {
         context.filteredProviders?.push({
           id: p.id,
           name: p.name,
           reason: "rate_limited",
-          details: "费用限制",
+          details: "rate_limited",
         });
       }
     }

+ 7 - 1
src/app/v1/_lib/proxy/rate-limit-guard.ts

@@ -1,5 +1,6 @@
 import { logger } from "@/lib/logger";
 import { RateLimitService } from "@/lib/rate-limit";
+import { resolveKeyConcurrentSessionLimit } from "@/lib/rate-limit/concurrent-session-limit";
 import { getResetInfo, getResetInfoWithMode } from "@/lib/rate-limit/time-utils";
 import { ERROR_CODES, getErrorMessageServer } from "@/lib/utils/error-messages";
 import { RateLimitError } from "./errors";
@@ -118,10 +119,15 @@ export class ProxyRateLimitGuard {
     // ========== 第二层:资源/频率保护 ==========
 
     // 3. Key 并发 Session(避免创建上游连接)
+    // Key 未设置时,继承 User 并发上限(避免 UI/心智模型不一致:User 设置了并发,但 Key 仍显示“无限制”)
+    const effectiveKeyConcurrentLimit = resolveKeyConcurrentSessionLimit(
+      key.limitConcurrentSessions ?? 0,
+      user.limitConcurrentSessions
+    );
     const sessionCheck = await RateLimitService.checkSessionLimit(
       key.id,
       "key",
-      key.limitConcurrentSessions ?? 0
+      effectiveKeyConcurrentLimit
     );
 
     if (!sessionCheck.allowed) {

+ 10 - 39
src/app/v1/_lib/proxy/response-handler.ts

@@ -209,19 +209,10 @@ async function finalizeDeferredStreamingFinalizationIfNeeded(
         });
       }
 
-      if (meta.endpointId != null) {
-        try {
-          const { recordEndpointFailure } = await import("@/lib/endpoint-circuit-breaker");
-          await recordEndpointFailure(meta.endpointId, new Error(errorMessage ?? "STREAM_ABORTED"));
-        } catch (endpointError) {
-          logger.warn("[ResponseHandler] Failed to record endpoint failure (stream aborted)", {
-            endpointId: meta.endpointId,
-            providerId: meta.providerId,
-            sessionId: session.sessionId ?? null,
-            error: endpointError,
-          });
-        }
-      }
+      // NOTE: Do NOT call recordEndpointFailure here. Stream aborts are key-level
+      // errors (auth, rate limit, bad key). The endpoint itself delivered HTTP 200
+      // successfully. Only forwarder-level failures (timeout, network error) and
+      // probe failures should penalize the endpoint circuit breaker.
     }
 
     session.addProviderToChain(providerForChain, {
@@ -259,19 +250,9 @@ async function finalizeDeferredStreamingFinalizationIfNeeded(
       });
     }
 
-    // endpoint 级熔断:与成功路径保持对称,避免“假 200”只影响 provider 而不影响 endpoint 健康度
-    if (meta.endpointId != null) {
-      try {
-        const { recordEndpointFailure } = await import("@/lib/endpoint-circuit-breaker");
-        await recordEndpointFailure(meta.endpointId, new Error(detected.code));
-      } catch (endpointError) {
-        logger.warn("[ResponseHandler] Failed to record endpoint failure (fake 200)", {
-          endpointId: meta.endpointId,
-          providerId: meta.providerId,
-          error: endpointError,
-        });
-      }
-    }
+    // NOTE: Do NOT call recordEndpointFailure here. Fake-200 errors are key-level
+    // issues (invalid key, auth failure). The endpoint returned HTTP 200 successfully;
+    // the error is in the response content, not endpoint connectivity.
 
     // 记录到决策链(用于日志展示与 DB 持久化)。
     // 注意:这里用 effectiveStatusCode(502)而不是 upstreamStatusCode(200),
@@ -310,19 +291,9 @@ async function finalizeDeferredStreamingFinalizationIfNeeded(
       });
     }
 
-    // endpoint 级熔断:与成功路径保持对称
-    if (meta.endpointId != null) {
-      try {
-        const { recordEndpointFailure } = await import("@/lib/endpoint-circuit-breaker");
-        await recordEndpointFailure(meta.endpointId, new Error(errorMessage));
-      } catch (endpointError) {
-        logger.warn("[ResponseHandler] Failed to record endpoint failure (non-200)", {
-          endpointId: meta.endpointId,
-          providerId: meta.providerId,
-          error: endpointError,
-        });
-      }
-    }
+    // NOTE: Do NOT call recordEndpointFailure here. Non-200 HTTP errors (401, 429,
+    // etc.) are typically key/auth-level errors. The endpoint was reachable and
+    // responded; only forwarder-level failures should penalize the endpoint breaker.
 
     // 记录到决策链
     session.addProviderToChain(providerForChain, {

+ 2 - 1
src/app/v1/_lib/proxy/session.ts

@@ -460,7 +460,8 @@ export class ProxySession {
         | "retry_with_cached_instructions" // Codex instructions 智能重试(缓存)
         | "client_error_non_retryable" // 不可重试的客户端错误(Prompt 超限、内容过滤、PDF 限制、Thinking 格式)
         | "http2_fallback" // HTTP/2 协议错误,回退到 HTTP/1.1(不切换供应商、不计入熔断器)
-        | "endpoint_pool_exhausted"; // 端点池耗尽(strict endpoint policy 阻止了 fallback)
+        | "endpoint_pool_exhausted" // 端点池耗尽(strict endpoint policy 阻止了 fallback)
+        | "vendor_type_all_timeout"; // 供应商类型全端点超时(524),触发 vendor-type 临时熔断
       selectionMethod?:
         | "session_reuse"
         | "weighted_random"

+ 26 - 0
src/components/ui/inline-warning.tsx

@@ -0,0 +1,26 @@
+import { AlertTriangle } from "lucide-react";
+import type { ReactNode } from "react";
+
+import { cn } from "@/lib/utils";
+
+interface InlineWarningProps {
+  children: ReactNode;
+  className?: string;
+}
+
+/**
+ * 表单字段的内联警告提示组件(仅提示,不阻止提交)。
+ */
+export function InlineWarning({ children, className }: InlineWarningProps) {
+  return (
+    <div
+      className={cn(
+        "flex items-start gap-1.5 text-xs text-amber-600 dark:text-amber-400",
+        className
+      )}
+    >
+      <AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" aria-hidden="true" />
+      <span className="min-w-0">{children}</span>
+    </div>
+  );
+}

+ 20 - 0
src/instrumentation.ts

@@ -349,6 +349,16 @@ export async function register() {
         });
       }
 
+      // 初始化端点熔断器(禁用时清理残留状态)
+      try {
+        const { initEndpointCircuitBreaker } = await import("@/lib/endpoint-circuit-breaker");
+        await initEndpointCircuitBreaker();
+      } catch (error) {
+        logger.warn("[Instrumentation] Failed to initialize endpoint circuit breaker", {
+          error: error instanceof Error ? error.message : String(error),
+        });
+      }
+
       try {
         const { startEndpointProbeLogCleanup } = await import(
           "@/lib/provider-endpoints/probe-log-cleanup"
@@ -456,6 +466,16 @@ export async function register() {
           });
         }
 
+        // 初始化端点熔断器(禁用时清理残留状态)
+        try {
+          const { initEndpointCircuitBreaker } = await import("@/lib/endpoint-circuit-breaker");
+          await initEndpointCircuitBreaker();
+        } catch (error) {
+          logger.warn("[Instrumentation] Failed to initialize endpoint circuit breaker", {
+            error: error instanceof Error ? error.message : String(error),
+          });
+        }
+
         try {
           const { startEndpointProbeLogCleanup } = await import(
             "@/lib/provider-endpoints/probe-log-cleanup"

+ 4 - 0
src/lib/config/env.schema.ts

@@ -110,6 +110,10 @@ export const EnvSchema = z.object({
   LOG_LEVEL: z.enum(["fatal", "error", "warn", "info", "debug", "trace"]).default("info"),
   TZ: z.string().default("Asia/Shanghai"),
   ENABLE_CIRCUIT_BREAKER_ON_NETWORK_ERRORS: z.string().default("false").transform(booleanTransform),
+  // 端点级别熔断器开关
+  // - false (默认):禁用端点熔断器,所有端点均可使用
+  // - true:启用端点熔断器,连续失败的端点会被临时屏蔽
+  ENABLE_ENDPOINT_CIRCUIT_BREAKER: z.string().default("false").transform(booleanTransform),
   // 供应商缓存开关
   // - true (默认):启用进程级缓存,30s TTL,提升供应商查询性能
   // - false:禁用缓存,每次请求直接查询数据库

+ 65 - 0
src/lib/endpoint-circuit-breaker.ts

@@ -114,6 +114,11 @@ export async function getEndpointHealthInfo(
 }
 
 export async function isEndpointCircuitOpen(endpointId: number): Promise<boolean> {
+  const { getEnvConfig } = await import("@/lib/config/env.schema");
+  if (!getEnvConfig().ENABLE_ENDPOINT_CIRCUIT_BREAKER) {
+    return false;
+  }
+
   const health = await getOrCreateHealth(endpointId);
 
   if (health.circuitState === "closed") {
@@ -135,6 +140,11 @@ export async function isEndpointCircuitOpen(endpointId: number): Promise<boolean
 }
 
 export async function recordEndpointFailure(endpointId: number, error: Error): Promise<void> {
+  const { getEnvConfig } = await import("@/lib/config/env.schema");
+  if (!getEnvConfig().ENABLE_ENDPOINT_CIRCUIT_BREAKER) {
+    return;
+  }
+
   const health = await getOrCreateHealth(endpointId);
   const config = DEFAULT_ENDPOINT_CIRCUIT_BREAKER_CONFIG;
 
@@ -178,6 +188,11 @@ export async function recordEndpointFailure(endpointId: number, error: Error): P
 }
 
 export async function recordEndpointSuccess(endpointId: number): Promise<void> {
+  const { getEnvConfig } = await import("@/lib/config/env.schema");
+  if (!getEnvConfig().ENABLE_ENDPOINT_CIRCUIT_BREAKER) {
+    return;
+  }
+
   const health = await getOrCreateHealth(endpointId);
   const config = DEFAULT_ENDPOINT_CIRCUIT_BREAKER_CONFIG;
 
@@ -240,6 +255,11 @@ export async function triggerEndpointCircuitBreakerAlert(
   retryAt: string,
   lastError: string
 ): Promise<void> {
+  const { getEnvConfig } = await import("@/lib/config/env.schema");
+  if (!getEnvConfig().ENABLE_ENDPOINT_CIRCUIT_BREAKER) {
+    return;
+  }
+
   try {
     const { sendCircuitBreakerAlert } = await import("@/lib/notification/notifier");
 
@@ -280,3 +300,48 @@ export async function triggerEndpointCircuitBreakerAlert(
     });
   }
 }
+
+/**
+ * Startup initialization: when ENABLE_ENDPOINT_CIRCUIT_BREAKER is disabled,
+ * clear all endpoint circuit breaker states from both in-memory map and Redis
+ * to ensure no stale open states block endpoints.
+ *
+ * Called once at application startup.
+ */
+export async function initEndpointCircuitBreaker(): Promise<void> {
+  const { getEnvConfig } = await import("@/lib/config/env.schema");
+  if (getEnvConfig().ENABLE_ENDPOINT_CIRCUIT_BREAKER) {
+    return;
+  }
+
+  healthMap.clear();
+  loadedFromRedis.clear();
+
+  try {
+    const { getRedisClient } = await import("@/lib/redis/client");
+    const redis = getRedisClient();
+    if (!redis) return;
+
+    const pattern = "endpoint_circuit_breaker:state:*";
+    let cursor = "0";
+    let deletedCount = 0;
+    do {
+      const [nextCursor, keys] = await redis.scan(cursor, "MATCH", pattern, "COUNT", 100);
+      cursor = nextCursor;
+      if (keys.length > 0) {
+        await redis.del(...keys);
+        deletedCount += keys.length;
+      }
+    } while (cursor !== "0");
+
+    if (deletedCount > 0) {
+      logger.info("[EndpointCircuitBreaker] Cleared stale states on startup (feature disabled)", {
+        deletedCount,
+      });
+    }
+  } catch (error) {
+    logger.warn("[EndpointCircuitBreaker] Failed to clear stale states on startup", {
+      error: error instanceof Error ? error.message : String(error),
+    });
+  }
+}

+ 12 - 0
src/lib/provider-endpoints/endpoint-selector.ts

@@ -41,6 +41,12 @@ export async function getPreferredProviderEndpoints(input: {
     return [];
   }
 
+  // When endpoint circuit breaker is disabled, skip circuit check entirely
+  const { getEnvConfig } = await import("@/lib/config/env.schema");
+  if (!getEnvConfig().ENABLE_ENDPOINT_CIRCUIT_BREAKER) {
+    return rankProviderEndpoints(filtered);
+  }
+
   const circuitResults = await Promise.all(
     filtered.map(async (endpoint) => ({
       endpoint,
@@ -74,6 +80,12 @@ export async function getEndpointFilterStats(input: {
   const total = endpoints.length;
   const enabled = endpoints.filter((e) => e.isEnabled && !e.deletedAt).length;
 
+  // When endpoint circuit breaker is disabled, no endpoints can be circuit-open
+  const { getEnvConfig } = await import("@/lib/config/env.schema");
+  if (!getEnvConfig().ENABLE_ENDPOINT_CIRCUIT_BREAKER) {
+    return { total, enabled, circuitOpen: 0, available: enabled };
+  }
+
   const circuitResults = await Promise.all(
     endpoints
       .filter((e) => e.isEnabled && !e.deletedAt)

+ 33 - 0
src/lib/rate-limit/concurrent-session-limit.ts

@@ -0,0 +1,33 @@
+/**
+ * 将输入归一化为正整数限额。
+ *
+ * - 非数字 / 非有限值 / <= 0 视为 0(无限制)
+ * - > 0 时向下取整
+ */
+function normalizePositiveLimit(value: unknown): number {
+  if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
+    return 0;
+  }
+
+  return Math.floor(value);
+}
+
+/**
+ * 解析 Key 的“有效并发 Session 上限”。
+ *
+ * 规则:
+ * - Key 自身设置(>0)优先生效
+ * - Key 未设置/为 0 时,回退到 User 并发上限(>0)
+ * - 都未设置/为 0 时,返回 0(表示无限制)
+ */
+export function resolveKeyConcurrentSessionLimit(
+  keyLimit: number | null | undefined,
+  userLimit: number | null | undefined
+): number {
+  const normalizedKeyLimit = normalizePositiveLimit(keyLimit);
+  if (normalizedKeyLimit > 0) {
+    return normalizedKeyLimit;
+  }
+
+  return normalizePositiveLimit(userLimit);
+}

+ 36 - 0
src/lib/utils/date-input.test.ts

@@ -0,0 +1,36 @@
+import { describe, expect, test } from "vitest";
+
+import { formatDateToLocalYmd, parseYmdToLocalEndOfDay } from "./date-input";
+
+describe("parseYmdToLocalEndOfDay", () => {
+  test("empty/invalid input returns null", () => {
+    expect(parseYmdToLocalEndOfDay("")).toBeNull();
+    expect(parseYmdToLocalEndOfDay("not-a-date")).toBeNull();
+    expect(parseYmdToLocalEndOfDay("2026-13-40")).toBeNull();
+  });
+
+  test("parses YYYY-MM-DD as local end-of-day", () => {
+    const d = parseYmdToLocalEndOfDay("2026-02-11");
+    expect(d).not.toBeNull();
+    if (!d) return;
+
+    expect(d.getFullYear()).toBe(2026);
+    expect(d.getMonth()).toBe(1);
+    expect(d.getDate()).toBe(11);
+    expect(d.getHours()).toBe(23);
+    expect(d.getMinutes()).toBe(59);
+    expect(d.getSeconds()).toBe(59);
+    expect(d.getMilliseconds()).toBe(999);
+  });
+});
+
+describe("formatDateToLocalYmd", () => {
+  test("formats Date as local YYYY-MM-DD", () => {
+    const d = new Date(2026, 1, 11, 12, 0, 0);
+    expect(formatDateToLocalYmd(d)).toBe("2026-02-11");
+  });
+
+  test("invalid date returns empty string", () => {
+    expect(formatDateToLocalYmd(new Date("invalid"))).toBe("");
+  });
+});

+ 34 - 0
src/lib/utils/date-input.ts

@@ -61,3 +61,37 @@ export function parseDateInputAsTimezone(input: string, timezone: string): Date
   // Convert from timezone local time to UTC
   return fromZonedTime(localDate, timezone);
 }
+
+/**
+ * 将 Date 格式化为本地时区的 YYYY-MM-DD(用于 date-only 输入控件)。
+ */
+export function formatDateToLocalYmd(value: Date): string {
+  if (Number.isNaN(value.getTime())) return "";
+  const year = value.getFullYear();
+  const month = String(value.getMonth() + 1).padStart(2, "0");
+  const day = String(value.getDate()).padStart(2, "0");
+  return `${year}-${month}-${day}`;
+}
+
+/**
+ * 将 YYYY-MM-DD 的纯日期字符串解析为“本地时区当天结束时间”(23:59:59.999)。
+ *
+ * 注意:刻意避免 `new Date("YYYY-MM-DD")`,因为该形式在 JS 中按 UTC 解析,
+ * 后续再转换为本地时间时可能出现日期偏差(提前/延后一日)。
+ */
+export function parseYmdToLocalEndOfDay(input: string): Date | null {
+  if (!input) return null;
+  const [year, month, day] = input.split("-").map((v) => Number(v));
+  if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day)) return null;
+  if (month < 1 || month > 12) return null;
+  if (day < 1 || day > 31) return null;
+
+  const date = new Date(year, month - 1, day);
+  if (Number.isNaN(date.getTime())) return null;
+  if (date.getFullYear() !== year || date.getMonth() !== month - 1 || date.getDate() !== day) {
+    return null;
+  }
+
+  date.setHours(23, 59, 59, 999);
+  return date;
+}

+ 104 - 0
src/lib/utils/provider-chain-formatter.test.ts

@@ -271,6 +271,110 @@ describe("endpoint_pool_exhausted", () => {
   });
 });
 
+// =============================================================================
+// vendor_type_all_timeout reason tests
+// =============================================================================
+
+describe("vendor_type_all_timeout", () => {
+  // ---------------------------------------------------------------------------
+  // Shared fixtures
+  // ---------------------------------------------------------------------------
+  const vendorTypeTimeoutItem: ProviderChainItem = {
+    id: 1,
+    name: "provider-timeout",
+    reason: "vendor_type_all_timeout",
+    timestamp: 1000,
+    statusCode: 524,
+    attemptNumber: 1,
+    errorMessage: "All endpoints timed out",
+    errorDetails: {
+      provider: {
+        id: 1,
+        name: "provider-timeout",
+        statusCode: 524,
+        statusText: "Origin Time-out",
+      },
+      request: {
+        method: "POST",
+        url: "https://api.example.com/v1/messages",
+        headers: "content-type: application/json",
+      },
+    },
+  };
+
+  const vendorTypeTimeoutNoDetails: ProviderChainItem = {
+    id: 1,
+    name: "provider-timeout",
+    reason: "vendor_type_all_timeout",
+    timestamp: 1000,
+    statusCode: 524,
+    errorMessage: "All endpoints timed out",
+  };
+
+  // ---------------------------------------------------------------------------
+  // formatProviderSummary
+  // ---------------------------------------------------------------------------
+
+  describe("formatProviderSummary", () => {
+    test("renders vendor_type_all_timeout with failure mark", () => {
+      const chain: ProviderChainItem[] = [vendorTypeTimeoutItem];
+      const result = formatProviderSummary(chain, mockT);
+
+      expect(result).toContain("provider-timeout");
+      expect(result).toContain("\u2717");
+    });
+  });
+
+  // ---------------------------------------------------------------------------
+  // formatProviderDescription
+  // ---------------------------------------------------------------------------
+
+  describe("formatProviderDescription", () => {
+    test("shows vendor type all timeout label", () => {
+      const chain: ProviderChainItem[] = [vendorTypeTimeoutItem];
+      const result = formatProviderDescription(chain, mockT);
+
+      expect(result).toContain("description.vendorTypeAllTimeout");
+    });
+  });
+
+  // ---------------------------------------------------------------------------
+  // formatProviderTimeline
+  // ---------------------------------------------------------------------------
+
+  describe("formatProviderTimeline", () => {
+    test("renders vendor_type_all_timeout with provider, statusCode, error, and note", () => {
+      const chain: ProviderChainItem[] = [vendorTypeTimeoutItem];
+      const { timeline } = formatProviderTimeline(chain, mockT);
+
+      // Title
+      expect(timeline).toContain("timeline.vendorTypeAllTimeout");
+      // Provider
+      expect(timeline).toContain("timeline.provider [provider=provider-timeout]");
+      // Status code
+      expect(timeline).toContain("timeline.statusCode [code=524]");
+      // Error from statusText
+      expect(timeline).toContain("timeline.error [error=Origin Time-out]");
+      // Note
+      expect(timeline).toContain("timeline.vendorTypeAllTimeoutNote");
+    });
+
+    test("renders vendor_type_all_timeout without error details", () => {
+      const chain: ProviderChainItem[] = [vendorTypeTimeoutNoDetails];
+      const { timeline } = formatProviderTimeline(chain, mockT);
+
+      // Should still render without crashing
+      expect(timeline).toContain("timeline.vendorTypeAllTimeout");
+      // Falls back to item-level fields
+      expect(timeline).toContain("timeline.provider [provider=provider-timeout]");
+      expect(timeline).toContain("timeline.statusCode [code=524]");
+      expect(timeline).toContain("timeline.error [error=All endpoints timed out]");
+      // Note is always present
+      expect(timeline).toContain("timeline.vendorTypeAllTimeoutNote");
+    });
+  });
+});
+
 // =============================================================================
 // Unknown reason graceful degradation
 // =============================================================================

+ 53 - 3
src/lib/utils/provider-chain-formatter.ts

@@ -64,7 +64,8 @@ function getProviderStatus(item: ProviderChainItem): "✓" | "✗" | "⚡" | "
     item.reason === "retry_failed" ||
     item.reason === "system_error" ||
     item.reason === "client_error_non_retryable" ||
-    item.reason === "endpoint_pool_exhausted"
+    item.reason === "endpoint_pool_exhausted" ||
+    item.reason === "vendor_type_all_timeout"
   ) {
     return "✗";
   }
@@ -92,7 +93,8 @@ function isActualRequest(item: ProviderChainItem): boolean {
     item.reason === "retry_failed" ||
     item.reason === "system_error" ||
     item.reason === "client_error_non_retryable" ||
-    item.reason === "endpoint_pool_exhausted"
+    item.reason === "endpoint_pool_exhausted" ||
+    item.reason === "vendor_type_all_timeout"
   ) {
     return true;
   }
@@ -313,6 +315,8 @@ export function formatProviderDescription(
         desc += ` ${t("description.clientError")}`;
       } else if (item.reason === "endpoint_pool_exhausted") {
         desc += ` ${t("description.endpointPoolExhausted")}`;
+      } else if (item.reason === "vendor_type_all_timeout") {
+        desc += ` ${t("description.vendorTypeAllTimeout")}`;
       }
 
       desc += "\n";
@@ -408,7 +412,12 @@ export function formatProviderTimeline(
         timeline += `\n${t("timeline.filtered")}:\n`;
         for (const f of ctx.filteredProviders) {
           const icon = f.reason === "circuit_open" ? "⚡" : "💰";
-          timeline += `  ${icon} ${f.name} (${f.details || f.reason})\n`;
+          const detailsText = f.details
+            ? t(`filterDetails.${f.details}`) !== `filterDetails.${f.details}`
+              ? t(`filterDetails.${f.details}`)
+              : f.details
+            : f.reason;
+          timeline += `  ${icon} ${f.name} (${detailsText})\n`;
         }
       }
 
@@ -742,6 +751,47 @@ export function formatProviderTimeline(
       continue;
     }
 
+    // === 供应商类型全端点超时(524) ===
+    if (item.reason === "vendor_type_all_timeout") {
+      timeline += `${t("timeline.vendorTypeAllTimeout")}\n\n`;
+
+      if (item.errorDetails?.provider) {
+        const p = item.errorDetails.provider;
+        timeline += `${t("timeline.provider", { provider: p.name })}\n`;
+        timeline += `${t("timeline.statusCode", { code: p.statusCode })}\n`;
+        timeline += `${t("timeline.error", { error: p.statusText })}\n`;
+
+        if (i > 0 && item.timestamp && chain[i - 1]?.timestamp) {
+          const duration = item.timestamp - (chain[i - 1]?.timestamp || 0);
+          timeline += `${t("timeline.requestDuration", { duration })}\n`;
+        }
+
+        if (p.upstreamParsed) {
+          timeline += `\n${t("timeline.errorDetails")}:\n`;
+          timeline += JSON.stringify(p.upstreamParsed, null, 2);
+        } else if (p.upstreamBody) {
+          timeline += `\n${t("timeline.errorDetails")}:\n${p.upstreamBody}`;
+        }
+
+        if (item.errorDetails?.request) {
+          timeline += formatRequestDetails(item.errorDetails.request, t);
+        }
+      } else {
+        timeline += `${t("timeline.provider", { provider: item.name })}\n`;
+        if (item.statusCode) {
+          timeline += `${t("timeline.statusCode", { code: item.statusCode })}\n`;
+        }
+        timeline += `${t("timeline.error", { error: item.errorMessage || t("timeline.unknown") })}\n`;
+
+        if (item.errorDetails?.request) {
+          timeline += formatRequestDetails(item.errorDetails.request, t);
+        }
+      }
+
+      timeline += `\n${t("timeline.vendorTypeAllTimeoutNote")}`;
+      continue;
+    }
+
     // 并发限制失败
     if (item.reason === "concurrent_limit_failed") {
       timeline += `${t("timeline.attemptFailed", { attempt: actualAttemptNumber ?? 0 })}\n\n`;

+ 44 - 0
src/lib/utils/validation/api-key-warnings.test.ts

@@ -0,0 +1,44 @@
+import { describe, expect, test } from "vitest";
+
+import { detectApiKeyWarnings } from "./api-key-warnings";
+
+describe("detectApiKeyWarnings", () => {
+  test("空值/空白:应返回空数组", () => {
+    expect(detectApiKeyWarnings("")).toEqual([]);
+    expect(detectApiKeyWarnings("   ")).toEqual([]);
+  });
+
+  test("包含中文:应提示 contains_non_ascii", () => {
+    expect(detectApiKeyWarnings("sk-中文")).toContain("contains_non_ascii");
+  });
+
+  test("看起来像 Authorization/Bearer header:应提示 looks_like_auth_header", () => {
+    expect(detectApiKeyWarnings("Bearer sk-123")).toContain("looks_like_auth_header");
+    expect(detectApiKeyWarnings("Authorization: Bearer sk-123")).toContain(
+      "looks_like_auth_header"
+    );
+    expect(detectApiKeyWarnings("x-api-key: sk-123")).toContain("looks_like_auth_header");
+    expect(detectApiKeyWarnings("x-goog-api-key: sk-123")).toContain("looks_like_auth_header");
+  });
+
+  test("被引号包裹:应提示 wrapped_in_quotes", () => {
+    expect(detectApiKeyWarnings('"sk-123"')).toContain("wrapped_in_quotes");
+    expect(detectApiKeyWarnings("'sk-123'")).toContain("wrapped_in_quotes");
+  });
+
+  test("包含空白:非 JSON 时应提示 contains_whitespace", () => {
+    expect(detectApiKeyWarnings("sk-12 3")).toContain("contains_whitespace");
+    expect(detectApiKeyWarnings(" sk-123 ")).toContain("contains_whitespace");
+    expect(detectApiKeyWarnings("sk-123\n456")).toContain("contains_whitespace");
+  });
+
+  test("包含不常见 ASCII 符号:非 JSON 时应提示 contains_uncommon_ascii", () => {
+    expect(detectApiKeyWarnings("sk-123@456")).toContain("contains_uncommon_ascii");
+    expect(detectApiKeyWarnings("sk-123;456")).toContain("contains_uncommon_ascii");
+  });
+
+  test("JSON 凭据:不应提示 contains_whitespace(避免误报)", () => {
+    const json = `{\n  "access_token": "ya29.abc"\n}`;
+    expect(detectApiKeyWarnings(json)).not.toContain("contains_whitespace");
+  });
+});

+ 85 - 0
src/lib/utils/validation/api-key-warnings.ts

@@ -0,0 +1,85 @@
+export type ApiKeyWarningId =
+  | "looks_like_auth_header"
+  | "wrapped_in_quotes"
+  | "contains_non_ascii"
+  | "contains_whitespace"
+  | "contains_uncommon_ascii";
+
+function isWrappedInQuotes(value: string): boolean {
+  return (
+    (value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))
+  );
+}
+
+function looksLikeAuthHeader(value: string): boolean {
+  const lower = value.toLowerCase();
+  return (
+    lower.startsWith("bearer ") ||
+    lower.startsWith("authorization:") ||
+    lower.startsWith("x-api-key:") ||
+    lower.startsWith("api-key:") ||
+    lower.startsWith("x-goog-api-key:")
+  );
+}
+
+function containsNonAscii(value: string): boolean {
+  for (const ch of value) {
+    const code = ch.codePointAt(0);
+    if (code != null && code > 0x7f) return true;
+  }
+  return false;
+}
+
+function containsUncommonAscii(value: string): boolean {
+  // 常见 token 格式:base64/base64url/jwt 等通常仅由如下字符组成
+  // - 字母数字
+  // - _ - .
+  // - base64 的 + / =
+  // 其它 ASCII 标点大多来自误粘贴(如引号、逗号、分号、@ 等),因此仅作提醒。
+  for (const ch of value) {
+    const code = ch.codePointAt(0);
+    if (code == null) continue;
+    if (code > 0x7f) continue; // 非 ASCII 在别处提示
+    if (code <= 0x20 || code === 0x7f) continue; // 空白/控制字符在别处提示
+    if (/[a-zA-Z0-9._\-+/=]/.test(ch)) continue;
+    return true;
+  }
+
+  return false;
+}
+
+/**
+ * 检测“很可能不是常见 API Key”的输入特征,仅用于 UI 警告(不阻止保存)。
+ *
+ * 注意:某些上游可能允许非 ASCII / 含空白的 key,但一般情况下不常见,因此仅作提醒。
+ */
+export function detectApiKeyWarnings(rawKey: string): ApiKeyWarningId[] {
+  const trimmed = rawKey.trim();
+  if (!trimmed) return [];
+
+  const warnings: ApiKeyWarningId[] = [];
+
+  const isLikelyJsonCredentials = trimmed.startsWith("{");
+
+  if (looksLikeAuthHeader(trimmed)) {
+    warnings.push("looks_like_auth_header");
+  }
+
+  if (isWrappedInQuotes(trimmed)) {
+    warnings.push("wrapped_in_quotes");
+  }
+
+  if (containsNonAscii(trimmed)) {
+    warnings.push("contains_non_ascii");
+  }
+
+  if (!isLikelyJsonCredentials && /\s/.test(rawKey)) {
+    warnings.push("contains_whitespace");
+  }
+
+  if (!isLikelyJsonCredentials && containsUncommonAscii(trimmed)) {
+    warnings.push("contains_uncommon_ascii");
+  }
+
+  return warnings;
+}

+ 39 - 0
src/lib/utils/validation/quota-lease-warnings.test.ts

@@ -0,0 +1,39 @@
+import { describe, expect, test } from "vitest";
+
+import {
+  shouldWarnQuotaDbRefreshIntervalTooHigh,
+  shouldWarnQuotaDbRefreshIntervalTooLow,
+  shouldWarnQuotaLeaseCapZero,
+  shouldWarnQuotaLeasePercentZero,
+} from "./quota-lease-warnings";
+
+describe("quota-lease-warnings", () => {
+  test("shouldWarnQuotaDbRefreshIntervalTooLow", () => {
+    expect(shouldWarnQuotaDbRefreshIntervalTooLow(0)).toBe(false);
+    expect(shouldWarnQuotaDbRefreshIntervalTooLow(1)).toBe(true);
+    expect(shouldWarnQuotaDbRefreshIntervalTooLow(2)).toBe(true);
+    expect(shouldWarnQuotaDbRefreshIntervalTooLow(3)).toBe(false);
+    expect(shouldWarnQuotaDbRefreshIntervalTooLow(10)).toBe(false);
+  });
+
+  test("shouldWarnQuotaDbRefreshIntervalTooHigh", () => {
+    expect(shouldWarnQuotaDbRefreshIntervalTooHigh(59)).toBe(false);
+    expect(shouldWarnQuotaDbRefreshIntervalTooHigh(60)).toBe(true);
+    expect(shouldWarnQuotaDbRefreshIntervalTooHigh(300)).toBe(true);
+  });
+
+  test("shouldWarnQuotaLeasePercentZero", () => {
+    expect(shouldWarnQuotaLeasePercentZero(0)).toBe(true);
+    expect(shouldWarnQuotaLeasePercentZero(0.01)).toBe(false);
+    expect(shouldWarnQuotaLeasePercentZero(1)).toBe(false);
+  });
+
+  test("shouldWarnQuotaLeaseCapZero", () => {
+    expect(shouldWarnQuotaLeaseCapZero("")).toBe(false);
+    expect(shouldWarnQuotaLeaseCapZero("   ")).toBe(false);
+    expect(shouldWarnQuotaLeaseCapZero("0")).toBe(true);
+    expect(shouldWarnQuotaLeaseCapZero("0.0")).toBe(true);
+    expect(shouldWarnQuotaLeaseCapZero("0.01")).toBe(false);
+    expect(shouldWarnQuotaLeaseCapZero("abc")).toBe(false);
+  });
+});

+ 31 - 0
src/lib/utils/validation/quota-lease-warnings.ts

@@ -0,0 +1,31 @@
+/**
+ * 仅用于 UI 警告:DB 刷新频率过低可能带来较高 DB 负载(不阻止保存)。
+ */
+export function shouldWarnQuotaDbRefreshIntervalTooLow(value: number): boolean {
+  return value > 0 && value <= 2;
+}
+
+/**
+ * 仅用于 UI 警告:DB 刷新频率过高可能导致配额/限额更新延迟(不阻止保存)。
+ */
+export function shouldWarnQuotaDbRefreshIntervalTooHigh(value: number): boolean {
+  return value >= 60;
+}
+
+/**
+ * 仅用于 UI 警告:租约比例为 0 可能导致租约预算始终为 0(不阻止保存)。
+ */
+export function shouldWarnQuotaLeasePercentZero(value: number): boolean {
+  return value === 0;
+}
+
+/**
+ * 仅用于 UI 警告:租约 cap 为 0 可能导致每次租约预算为 0(不阻止保存)。
+ */
+export function shouldWarnQuotaLeaseCapZero(rawValue: string): boolean {
+  const trimmed = rawValue.trim();
+  if (!trimmed) return false;
+  const parsed = Number.parseFloat(trimmed);
+  if (!Number.isFinite(parsed)) return false;
+  return parsed === 0;
+}

+ 11 - 0
src/lib/vendor-type-circuit-breaker.ts

@@ -1,5 +1,6 @@
 import "server-only";
 
+import { getEnvConfig } from "@/lib/config/env.schema";
 import { logger } from "@/lib/logger";
 import {
   deleteVendorTypeCircuitState,
@@ -116,6 +117,11 @@ export async function isVendorTypeCircuitOpen(
   vendorId: number,
   providerType: ProviderType
 ): Promise<boolean> {
+  // 检查端点熔断器开关,供应商类型熔断复用此开关
+  if (!getEnvConfig().ENABLE_ENDPOINT_CIRCUIT_BREAKER) {
+    return false;
+  }
+
   const state = await getOrCreateState(vendorId, providerType);
 
   if (state.manualOpen) {
@@ -141,6 +147,11 @@ export async function recordVendorTypeAllEndpointsTimeout(
   providerType: ProviderType,
   openDurationMs: number = AUTO_OPEN_DURATION_MS
 ): Promise<void> {
+  // 检查端点熔断器开关,供应商类型熔断复用此开关
+  if (!getEnvConfig().ENABLE_ENDPOINT_CIRCUIT_BREAKER) {
+    return;
+  }
+
   const state = await getOrCreateState(vendorId, providerType);
 
   if (state.manualOpen) {

+ 2 - 1
src/types/message.ts

@@ -33,7 +33,8 @@ export interface ProviderChainItem {
     | "retry_with_cached_instructions" // Codex instructions 智能重试(缓存)
     | "client_error_non_retryable" // 不可重试的客户端错误(Prompt 超限、内容过滤、PDF 限制、Thinking 格式)
     | "http2_fallback" // HTTP/2 协议错误,回退到 HTTP/1.1(不切换供应商、不计入熔断器)
-    | "endpoint_pool_exhausted"; // 端点池耗尽(所有端点熔断或不可用,严格模式阻止降级)
+    | "endpoint_pool_exhausted" // 端点池耗尽(所有端点熔断或不可用,严格模式阻止降级)
+    | "vendor_type_all_timeout"; // 供应商类型全端点超时(524),触发 vendor-type 临时熔断
 
   // === 选择方法(细化) ===
   selectionMethod?:

+ 104 - 0
tests/unit/actions/key-quota-concurrent-inherit.test.ts

@@ -0,0 +1,104 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const getSessionMock = vi.fn();
+vi.mock("@/lib/auth", () => ({
+  getSession: getSessionMock,
+}));
+
+const getTranslationsMock = vi.fn(async () => (key: string) => key);
+vi.mock("next-intl/server", () => ({
+  getTranslations: getTranslationsMock,
+}));
+
+const getSystemSettingsMock = vi.fn(async () => ({ currencyDisplay: "USD" }));
+vi.mock("@/repository/system-config", () => ({
+  getSystemSettings: getSystemSettingsMock,
+}));
+
+const getTotalUsageForKeyMock = vi.fn(async () => 0);
+vi.mock("@/repository/usage-logs", () => ({
+  getTotalUsageForKey: getTotalUsageForKeyMock,
+}));
+
+const getKeySessionCountMock = vi.fn(async () => 2);
+vi.mock("@/lib/session-tracker", () => ({
+  SessionTracker: {
+    getKeySessionCount: getKeySessionCountMock,
+  },
+}));
+
+const getTimeRangeForPeriodWithModeMock = vi.fn(async () => ({
+  startTime: new Date("2026-02-11T00:00:00.000Z"),
+  endTime: new Date("2026-02-12T00:00:00.000Z"),
+}));
+const getTimeRangeForPeriodMock = vi.fn(async () => ({
+  startTime: new Date("2026-02-11T00:00:00.000Z"),
+  endTime: new Date("2026-02-12T00:00:00.000Z"),
+}));
+vi.mock("@/lib/rate-limit/time-utils", () => ({
+  getTimeRangeForPeriodWithMode: getTimeRangeForPeriodWithModeMock,
+  getTimeRangeForPeriod: getTimeRangeForPeriodMock,
+}));
+
+const sumKeyCostInTimeRangeMock = vi.fn(async () => 0);
+vi.mock("@/repository/statistics", () => ({
+  sumKeyCostInTimeRange: sumKeyCostInTimeRangeMock,
+}));
+
+const limitMock = vi.fn();
+const whereMock = vi.fn(() => ({ limit: limitMock }));
+const leftJoinMock = vi.fn(() => ({ where: whereMock }));
+const fromMock = vi.fn(() => ({ leftJoin: leftJoinMock }));
+const selectMock = vi.fn(() => ({ from: fromMock }));
+
+vi.mock("@/drizzle/db", () => ({
+  db: {
+    select: selectMock,
+  },
+}));
+
+vi.mock("@/lib/logger", () => ({
+  logger: {
+    warn: vi.fn(),
+    error: vi.fn(),
+  },
+}));
+
+describe("getKeyQuotaUsage - concurrent limit inheritance", () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+    getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } });
+  });
+
+  it("Key 并发为 0 时应回退到 User 并发上限", async () => {
+    limitMock.mockResolvedValueOnce([
+      {
+        key: {
+          id: 1,
+          userId: 10,
+          key: "sk-test",
+          name: "k",
+          deletedAt: null,
+          limit5hUsd: null,
+          limitDailyUsd: null,
+          limitWeeklyUsd: null,
+          limitMonthlyUsd: null,
+          limitTotalUsd: null,
+          dailyResetTime: "00:00",
+          dailyResetMode: "fixed",
+          limitConcurrentSessions: 0,
+        },
+        userLimitConcurrentSessions: 15,
+      },
+    ]);
+
+    const { getKeyQuotaUsage } = await import("@/actions/key-quota");
+    const result = await getKeyQuotaUsage(1);
+
+    expect(result.ok).toBe(true);
+    if (result.ok) {
+      const item = result.data.items.find((i) => i.type === "limitSessions");
+      expect(item).toMatchObject({ current: 2, limit: 15 });
+    }
+  });
+});

+ 140 - 0
tests/unit/actions/my-usage-concurrent-inherit.test.ts

@@ -0,0 +1,140 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const getSessionMock = vi.fn();
+vi.mock("@/lib/auth", () => ({
+  getSession: getSessionMock,
+}));
+
+const getKeySessionCountMock = vi.fn(async () => 2);
+vi.mock("@/lib/session-tracker", () => ({
+  SessionTracker: {
+    getKeySessionCount: getKeySessionCountMock,
+  },
+}));
+
+const getTimeRangeForPeriodWithModeMock = vi.fn(async () => ({
+  startTime: new Date("2026-02-11T00:00:00.000Z"),
+  endTime: new Date("2026-02-12T00:00:00.000Z"),
+}));
+const getTimeRangeForPeriodMock = vi.fn(async () => ({
+  startTime: new Date("2026-02-11T00:00:00.000Z"),
+  endTime: new Date("2026-02-12T00:00:00.000Z"),
+}));
+vi.mock("@/lib/rate-limit/time-utils", () => ({
+  getTimeRangeForPeriodWithMode: getTimeRangeForPeriodWithModeMock,
+  getTimeRangeForPeriod: getTimeRangeForPeriodMock,
+}));
+
+const statisticsMock = {
+  sumUserCostInTimeRange: vi.fn(async () => 0),
+  sumUserTotalCost: vi.fn(async () => 0),
+  sumKeyCostInTimeRange: vi.fn(async () => 0),
+  sumKeyTotalCostById: vi.fn(async () => 0),
+};
+vi.mock("@/repository/statistics", () => statisticsMock);
+
+const whereMock = vi.fn(async () => [{ id: 1 }]);
+const fromMock = vi.fn(() => ({ where: whereMock }));
+const selectMock = vi.fn(() => ({ from: fromMock }));
+vi.mock("@/drizzle/db", () => ({
+  db: {
+    select: selectMock,
+  },
+}));
+
+vi.mock("@/lib/logger", () => ({
+  logger: {
+    warn: vi.fn(),
+    error: vi.fn(),
+  },
+}));
+
+function createSession(params: {
+  keyLimitConcurrentSessions: number | null;
+  userLimitConcurrentSessions: number | null;
+}) {
+  return {
+    key: {
+      id: 1,
+      key: "sk-test",
+      name: "k",
+      dailyResetTime: "00:00",
+      dailyResetMode: "fixed",
+      limit5hUsd: null,
+      limitDailyUsd: null,
+      limitWeeklyUsd: null,
+      limitMonthlyUsd: null,
+      limitTotalUsd: null,
+      limitConcurrentSessions: params.keyLimitConcurrentSessions,
+      providerGroup: null,
+      isEnabled: true,
+      expiresAt: null,
+    },
+    user: {
+      id: 10,
+      name: "u",
+      dailyResetTime: "00:00",
+      dailyResetMode: "fixed",
+      limit5hUsd: null,
+      dailyQuota: null,
+      limitWeeklyUsd: null,
+      limitMonthlyUsd: null,
+      limitTotalUsd: null,
+      limitConcurrentSessions: params.userLimitConcurrentSessions,
+      rpm: null,
+      providerGroup: null,
+      isEnabled: true,
+      expiresAt: null,
+      allowedModels: [],
+      allowedClients: [],
+    },
+  };
+}
+
+describe("getMyQuota - concurrent limit inheritance", () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+
+    getSessionMock.mockResolvedValue(
+      createSession({ keyLimitConcurrentSessions: 0, userLimitConcurrentSessions: 15 })
+    );
+  });
+
+  it("Key 并发为 0 时应回退到 User 并发上限", async () => {
+    const { getMyQuota } = await import("@/actions/my-usage");
+    const result = await getMyQuota();
+
+    expect(result.ok).toBe(true);
+    if (result.ok) {
+      expect(result.data.keyLimitConcurrentSessions).toBe(15);
+    }
+  });
+
+  it("Key 并发为正数时应优先使用 Key 自身上限", async () => {
+    getSessionMock.mockResolvedValue(
+      createSession({ keyLimitConcurrentSessions: 5, userLimitConcurrentSessions: 15 })
+    );
+
+    const { getMyQuota } = await import("@/actions/my-usage");
+    const result = await getMyQuota();
+
+    expect(result.ok).toBe(true);
+    if (result.ok) {
+      expect(result.data.keyLimitConcurrentSessions).toBe(5);
+    }
+  });
+
+  it("Key=0 且 User=0 时应返回 0(无限制)", async () => {
+    getSessionMock.mockResolvedValue(
+      createSession({ keyLimitConcurrentSessions: 0, userLimitConcurrentSessions: 0 })
+    );
+
+    const { getMyQuota } = await import("@/actions/my-usage");
+    const result = await getMyQuota();
+
+    expect(result.ok).toBe(true);
+    if (result.ok) {
+      expect(result.data.keyLimitConcurrentSessions).toBe(0);
+    }
+  });
+});

+ 158 - 0
tests/unit/dashboard/provider-form-clone-deep-copy.test.ts

@@ -0,0 +1,158 @@
+import { describe, expect, it } from "vitest";
+import { createInitialState } from "@/app/[locale]/settings/providers/_components/forms/provider-form/provider-form-context";
+import type { ProviderDisplay } from "@/types/provider";
+
+function makeProvider(overrides?: Partial<ProviderDisplay>): ProviderDisplay {
+  return {
+    id: 1,
+    name: "TestProvider",
+    url: "https://api.example.com",
+    maskedKey: "sk-****1234",
+    isEnabled: true,
+    weight: 1,
+    priority: 0,
+    groupPriorities: { groupA: 10, groupB: 20 },
+    costMultiplier: 1.0,
+    groupTag: "groupA,groupB",
+    providerType: "claude",
+    providerVendorId: null,
+    preserveClientIp: false,
+    modelRedirects: { "claude-3": "claude-3.5" },
+    allowedModels: ["claude-3", "claude-3.5"],
+    mcpPassthroughType: "none",
+    mcpPassthroughUrl: null,
+    limit5hUsd: null,
+    limitDailyUsd: null,
+    dailyResetMode: "fixed",
+    dailyResetTime: "00:00",
+    limitWeeklyUsd: null,
+    limitMonthlyUsd: null,
+    limitTotalUsd: null,
+    limitConcurrentSessions: 0,
+    maxRetryAttempts: null,
+    circuitBreakerFailureThreshold: 3,
+    circuitBreakerOpenDuration: 60000,
+    circuitBreakerHalfOpenSuccessThreshold: 2,
+    proxyUrl: null,
+    proxyFallbackToDirect: false,
+    firstByteTimeoutStreamingMs: 30000,
+    streamingIdleTimeoutMs: 60000,
+    requestTimeoutNonStreamingMs: 120000,
+    websiteUrl: null,
+    faviconUrl: null,
+    cacheTtlPreference: null,
+    context1mPreference: null,
+    codexReasoningEffortPreference: null,
+    codexReasoningSummaryPreference: null,
+    codexTextVerbosityPreference: null,
+    codexParallelToolCallsPreference: null,
+    anthropicMaxTokensPreference: null,
+    anthropicThinkingBudgetPreference: null,
+    anthropicAdaptiveThinking: {
+      effort: "high",
+      modelMatchMode: "specific",
+      models: ["claude-opus-4-6"],
+    },
+    geminiGoogleSearchPreference: null,
+    tpm: null,
+    rpm: null,
+    rpd: null,
+    cc: null,
+    createdAt: "2025-01-01T00:00:00.000Z",
+    updatedAt: "2025-01-01T00:00:00.000Z",
+    ...overrides,
+  } as ProviderDisplay;
+}
+
+describe("createInitialState deep-copy safety", () => {
+  describe("clone mode", () => {
+    it("modelRedirects is a distinct object with equal values", () => {
+      const source = makeProvider();
+      const state = createInitialState("create", undefined, source);
+      expect(state.routing.modelRedirects).toEqual(source.modelRedirects);
+      expect(state.routing.modelRedirects).not.toBe(source.modelRedirects);
+    });
+
+    it("allowedModels is a distinct array with equal values", () => {
+      const source = makeProvider();
+      const state = createInitialState("create", undefined, source);
+      expect(state.routing.allowedModels).toEqual(source.allowedModels);
+      expect(state.routing.allowedModels).not.toBe(source.allowedModels);
+    });
+
+    it("groupPriorities is a distinct object with equal values", () => {
+      const source = makeProvider();
+      const state = createInitialState("create", undefined, source);
+      expect(state.routing.groupPriorities).toEqual(source.groupPriorities);
+      expect(state.routing.groupPriorities).not.toBe(source.groupPriorities);
+    });
+
+    it("anthropicAdaptiveThinking is a distinct object with distinct models array", () => {
+      const source = makeProvider();
+      const state = createInitialState("create", undefined, source);
+      expect(state.routing.anthropicAdaptiveThinking).toEqual(source.anthropicAdaptiveThinking);
+      expect(state.routing.anthropicAdaptiveThinking).not.toBe(source.anthropicAdaptiveThinking);
+      expect(state.routing.anthropicAdaptiveThinking!.models).not.toBe(
+        source.anthropicAdaptiveThinking!.models
+      );
+    });
+
+    it("null anthropicAdaptiveThinking stays null", () => {
+      const source = makeProvider({ anthropicAdaptiveThinking: null });
+      const state = createInitialState("create", undefined, source);
+      expect(state.routing.anthropicAdaptiveThinking).toBeNull();
+    });
+
+    it("null modelRedirects falls back to empty object", () => {
+      const source = makeProvider({ modelRedirects: null });
+      const state = createInitialState("create", undefined, source);
+      expect(state.routing.modelRedirects).toEqual({});
+    });
+
+    it("null allowedModels falls back to empty array", () => {
+      const source = makeProvider({ allowedModels: null });
+      const state = createInitialState("create", undefined, source);
+      expect(state.routing.allowedModels).toEqual([]);
+    });
+
+    it("null groupPriorities falls back to empty object", () => {
+      const source = makeProvider({ groupPriorities: null });
+      const state = createInitialState("create", undefined, source);
+      expect(state.routing.groupPriorities).toEqual({});
+    });
+
+    it("name gets _Copy suffix", () => {
+      const source = makeProvider({ name: "MyProvider" });
+      const state = createInitialState("create", undefined, source);
+      expect(state.basic.name).toBe("MyProvider_Copy");
+    });
+
+    it("key is always empty", () => {
+      const source = makeProvider();
+      const state = createInitialState("create", undefined, source);
+      expect(state.basic.key).toBe("");
+    });
+  });
+
+  describe("edit mode", () => {
+    it("nested objects are isolated from source provider", () => {
+      const source = makeProvider();
+      const state = createInitialState("edit", source);
+      expect(state.routing.modelRedirects).toEqual(source.modelRedirects);
+      expect(state.routing.modelRedirects).not.toBe(source.modelRedirects);
+      expect(state.routing.allowedModels).not.toBe(source.allowedModels);
+      expect(state.routing.groupPriorities).not.toBe(source.groupPriorities);
+      expect(state.routing.anthropicAdaptiveThinking).not.toBe(source.anthropicAdaptiveThinking);
+    });
+  });
+
+  describe("create mode without clone source", () => {
+    it("nested objects use fresh defaults", () => {
+      const state = createInitialState("create");
+      expect(state.routing.modelRedirects).toEqual({});
+      expect(state.routing.allowedModels).toEqual([]);
+      expect(state.routing.groupPriorities).toEqual({});
+      expect(state.routing.anthropicAdaptiveThinking).toBeNull();
+    });
+  });
+});

+ 4 - 2
tests/unit/dashboard/user-form-expiry-clear-ui.test.tsx

@@ -11,6 +11,7 @@ import { NextIntlClientProvider } from "next-intl";
 import { beforeEach, describe, expect, test, vi } from "vitest";
 import { Dialog } from "@/components/ui/dialog";
 import { UserForm } from "@/app/[locale]/dashboard/_components/user/forms/user-form";
+import { formatDateToLocalYmd } from "@/lib/utils/date-input";
 
 vi.mock("next/navigation", () => ({
   useRouter: () => ({ refresh: vi.fn() }),
@@ -71,7 +72,7 @@ function clickButtonByText(text: string) {
   const buttons = Array.from(document.body.querySelectorAll("button"));
   const btn = buttons.find((b) => (b.textContent || "").includes(text));
   if (!btn) {
-    throw new Error(`未找到按钮: ${text}`);
+    throw new Error(`Button not found: ${text}`);
   }
   btn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
 }
@@ -84,6 +85,7 @@ describe("UserForm: 清除 expiresAt 后应提交 null", () => {
   test("编辑模式:点击 Clear Date 后提交应调用 editUser(..., { expiresAt: null })", async () => {
     const messages = loadMessages();
     const expiresAt = new Date("2026-01-04T23:59:59.999Z");
+    const expectedYmd = formatDateToLocalYmd(expiresAt);
 
     const { unmount } = render(
       <NextIntlClientProvider locale="en" messages={messages} timeZone="UTC">
@@ -97,7 +99,7 @@ describe("UserForm: 清除 expiresAt 后应提交 null", () => {
     );
 
     await act(async () => {
-      clickButtonByText("2026-01-04");
+      clickButtonByText(expectedYmd);
     });
 
     await act(async () => {

+ 202 - 10
tests/unit/lib/endpoint-circuit-breaker.test.ts

@@ -31,9 +31,6 @@ afterEach(() => {
 
 describe("endpoint-circuit-breaker", () => {
   test("达到阈值后应打开熔断;到期后进入 half-open;成功后关闭并清零", async () => {
-    vi.useFakeTimers();
-    vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
-
     vi.resetModules();
 
     let redisState: SavedEndpointCircuitState | null = null;
@@ -45,6 +42,9 @@ describe("endpoint-circuit-breaker", () => {
       redisState = null;
     });
 
+    vi.doMock("@/lib/config/env.schema", () => ({
+      getEnvConfig: () => ({ ENABLE_ENDPOINT_CIRCUIT_BREAKER: true }),
+    }));
     vi.doMock("@/lib/logger", () => ({ logger: createLoggerMock() }));
     const sendAlertMock = vi.fn(async () => {});
     vi.doMock("@/lib/notification/notifier", () => ({
@@ -56,6 +56,9 @@ describe("endpoint-circuit-breaker", () => {
       deleteEndpointCircuitState: deleteMock,
     }));
 
+    vi.useFakeTimers();
+    vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
+
     const {
       isEndpointCircuitOpen,
       recordEndpointFailure,
@@ -74,6 +77,10 @@ describe("endpoint-circuit-breaker", () => {
     expect(openState.failureCount).toBe(3);
     expect(openState.circuitOpenUntil).toBe(Date.now() + 300000);
 
+    // Prime env module cache: under fake timers, dynamic import() inside isEndpointCircuitOpen
+    // may fail to resolve the vi.doMock unless the module is already in the import cache.
+    await import("@/lib/config/env.schema");
+
     expect(await isEndpointCircuitOpen(1)).toBe(true);
 
     vi.advanceTimersByTime(300000 + 1);
@@ -110,14 +117,17 @@ describe("endpoint-circuit-breaker", () => {
   });
 
   test("recordEndpointSuccess: closed 且 failureCount>0 时应清零", async () => {
-    vi.useFakeTimers();
-    vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
-
     vi.resetModules();
 
     const saveMock = vi.fn(async () => {});
 
+    vi.doMock("@/lib/config/env.schema", () => ({
+      getEnvConfig: () => ({ ENABLE_ENDPOINT_CIRCUIT_BREAKER: true }),
+    }));
     vi.doMock("@/lib/logger", () => ({ logger: createLoggerMock() }));
+
+    vi.useFakeTimers();
+    vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
     vi.doMock("@/lib/redis/endpoint-circuit-breaker-state", () => ({
       loadEndpointCircuitState: vi.fn(async () => null),
       saveEndpointCircuitState: saveMock,
@@ -145,6 +155,9 @@ describe("endpoint-circuit-breaker", () => {
     vi.resetModules();
 
     const sendAlertMock = vi.fn(async () => {});
+    vi.doMock("@/lib/config/env.schema", () => ({
+      getEnvConfig: () => ({ ENABLE_ENDPOINT_CIRCUIT_BREAKER: true }),
+    }));
     vi.doMock("@/lib/logger", () => ({ logger: createLoggerMock() }));
     vi.doMock("@/lib/notification/notifier", () => ({
       sendCircuitBreakerAlert: sendAlertMock,
@@ -183,6 +196,9 @@ describe("endpoint-circuit-breaker", () => {
     vi.resetModules();
 
     const sendAlertMock = vi.fn(async () => {});
+    vi.doMock("@/lib/config/env.schema", () => ({
+      getEnvConfig: () => ({ ENABLE_ENDPOINT_CIRCUIT_BREAKER: true }),
+    }));
     vi.doMock("@/lib/notification/notifier", () => ({
       sendCircuitBreakerAlert: sendAlertMock,
     }));
@@ -229,9 +245,6 @@ describe("endpoint-circuit-breaker", () => {
   });
 
   test("recordEndpointFailure should NOT reset circuitOpenUntil when already open", async () => {
-    vi.useFakeTimers();
-    vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
-
     vi.resetModules();
 
     let redisState: SavedEndpointCircuitState | null = null;
@@ -239,6 +252,9 @@ describe("endpoint-circuit-breaker", () => {
       redisState = state;
     });
 
+    vi.doMock("@/lib/config/env.schema", () => ({
+      getEnvConfig: () => ({ ENABLE_ENDPOINT_CIRCUIT_BREAKER: true }),
+    }));
     vi.doMock("@/lib/logger", () => ({ logger: createLoggerMock() }));
     vi.doMock("@/lib/notification/notifier", () => ({
       sendCircuitBreakerAlert: vi.fn(async () => {}),
@@ -249,7 +265,10 @@ describe("endpoint-circuit-breaker", () => {
       deleteEndpointCircuitState: vi.fn(async () => {}),
     }));
 
-    const { recordEndpointFailure, isEndpointCircuitOpen } = await import(
+    vi.useFakeTimers();
+    vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
+
+    const { recordEndpointFailure, isEndpointCircuitOpen, getEndpointHealthInfo } = await import(
       "@/lib/endpoint-circuit-breaker"
     );
 
@@ -258,6 +277,15 @@ describe("endpoint-circuit-breaker", () => {
     await recordEndpointFailure(100, new Error("fail"));
     await recordEndpointFailure(100, new Error("fail"));
 
+    // Verify circuit was opened (also serves as async flush before isEndpointCircuitOpen)
+    const { health: healthSnap } = await getEndpointHealthInfo(100);
+    expect(healthSnap.circuitState).toBe("open");
+
+    // Prime the env module cache: under fake timers, the dynamic import("@/lib/config/env.schema")
+    // inside isEndpointCircuitOpen may fail to resolve the mock unless the module is already cached.
+    const envMod = await import("@/lib/config/env.schema");
+    expect(envMod.getEnvConfig().ENABLE_ENDPOINT_CIRCUIT_BREAKER).toBe(true);
+
     expect(await isEndpointCircuitOpen(100)).toBe(true);
     const originalOpenUntil = redisState!.circuitOpenUntil;
     expect(originalOpenUntil).toBe(Date.now() + 300000);
@@ -274,6 +302,9 @@ describe("endpoint-circuit-breaker", () => {
   test("getEndpointCircuitStateSync returns correct state for known and unknown endpoints", async () => {
     vi.resetModules();
 
+    vi.doMock("@/lib/config/env.schema", () => ({
+      getEnvConfig: () => ({ ENABLE_ENDPOINT_CIRCUIT_BREAKER: true }),
+    }));
     vi.doMock("@/lib/logger", () => ({ logger: createLoggerMock() }));
     vi.doMock("@/lib/notification/notifier", () => ({
       sendCircuitBreakerAlert: vi.fn(async () => {}),
@@ -297,4 +328,165 @@ describe("endpoint-circuit-breaker", () => {
     await recordEndpointFailure(200, new Error("c"));
     expect(getEndpointCircuitStateSync(200)).toBe("open");
   });
+
+  describe("ENABLE_ENDPOINT_CIRCUIT_BREAKER disabled", () => {
+    test("isEndpointCircuitOpen returns false when ENABLE_ENDPOINT_CIRCUIT_BREAKER=false", async () => {
+      vi.resetModules();
+
+      vi.doMock("@/lib/config/env.schema", () => ({
+        getEnvConfig: () => ({ ENABLE_ENDPOINT_CIRCUIT_BREAKER: false }),
+      }));
+      vi.doMock("@/lib/logger", () => ({ logger: createLoggerMock() }));
+      vi.doMock("@/lib/redis/endpoint-circuit-breaker-state", () => ({
+        loadEndpointCircuitState: vi.fn(async () => null),
+        saveEndpointCircuitState: vi.fn(async () => {}),
+        deleteEndpointCircuitState: vi.fn(async () => {}),
+      }));
+
+      const { isEndpointCircuitOpen } = await import("@/lib/endpoint-circuit-breaker");
+
+      expect(await isEndpointCircuitOpen(1)).toBe(false);
+      expect(await isEndpointCircuitOpen(999)).toBe(false);
+    });
+
+    test("recordEndpointFailure is no-op when disabled", async () => {
+      vi.resetModules();
+
+      const saveMock = vi.fn(async () => {});
+
+      vi.doMock("@/lib/config/env.schema", () => ({
+        getEnvConfig: () => ({ ENABLE_ENDPOINT_CIRCUIT_BREAKER: false }),
+      }));
+      vi.doMock("@/lib/logger", () => ({ logger: createLoggerMock() }));
+      vi.doMock("@/lib/redis/endpoint-circuit-breaker-state", () => ({
+        loadEndpointCircuitState: vi.fn(async () => null),
+        saveEndpointCircuitState: saveMock,
+        deleteEndpointCircuitState: vi.fn(async () => {}),
+      }));
+
+      const { recordEndpointFailure } = await import("@/lib/endpoint-circuit-breaker");
+
+      await recordEndpointFailure(1, new Error("boom"));
+      await recordEndpointFailure(1, new Error("boom"));
+      await recordEndpointFailure(1, new Error("boom"));
+
+      expect(saveMock).not.toHaveBeenCalled();
+    });
+
+    test("recordEndpointSuccess is no-op when disabled", async () => {
+      vi.resetModules();
+
+      const saveMock = vi.fn(async () => {});
+
+      vi.doMock("@/lib/config/env.schema", () => ({
+        getEnvConfig: () => ({ ENABLE_ENDPOINT_CIRCUIT_BREAKER: false }),
+      }));
+      vi.doMock("@/lib/logger", () => ({ logger: createLoggerMock() }));
+      vi.doMock("@/lib/redis/endpoint-circuit-breaker-state", () => ({
+        loadEndpointCircuitState: vi.fn(async () => null),
+        saveEndpointCircuitState: saveMock,
+        deleteEndpointCircuitState: vi.fn(async () => {}),
+      }));
+
+      const { recordEndpointSuccess } = await import("@/lib/endpoint-circuit-breaker");
+
+      await recordEndpointSuccess(1);
+
+      expect(saveMock).not.toHaveBeenCalled();
+    });
+
+    test("triggerEndpointCircuitBreakerAlert is no-op when disabled", async () => {
+      vi.resetModules();
+
+      const sendAlertMock = vi.fn(async () => {});
+
+      vi.doMock("@/lib/config/env.schema", () => ({
+        getEnvConfig: () => ({ ENABLE_ENDPOINT_CIRCUIT_BREAKER: false }),
+      }));
+      vi.doMock("@/lib/logger", () => ({ logger: createLoggerMock() }));
+      vi.doMock("@/lib/notification/notifier", () => ({
+        sendCircuitBreakerAlert: sendAlertMock,
+      }));
+      vi.doMock("@/lib/redis/endpoint-circuit-breaker-state", () => ({
+        loadEndpointCircuitState: vi.fn(async () => null),
+        saveEndpointCircuitState: vi.fn(async () => {}),
+        deleteEndpointCircuitState: vi.fn(async () => {}),
+      }));
+
+      const { triggerEndpointCircuitBreakerAlert } = await import("@/lib/endpoint-circuit-breaker");
+
+      await triggerEndpointCircuitBreakerAlert(
+        5,
+        3,
+        "2026-01-01T00:05:00.000Z",
+        "connection refused"
+      );
+
+      expect(sendAlertMock).not.toHaveBeenCalled();
+    });
+
+    test("initEndpointCircuitBreaker clears in-memory state and Redis keys when disabled", async () => {
+      vi.resetModules();
+
+      const redisMock = {
+        scan: vi
+          .fn()
+          .mockResolvedValueOnce([
+            "0",
+            ["endpoint_circuit_breaker:state:1", "endpoint_circuit_breaker:state:2"],
+          ]),
+        del: vi.fn(async () => {}),
+      };
+
+      vi.doMock("@/lib/config/env.schema", () => ({
+        getEnvConfig: () => ({ ENABLE_ENDPOINT_CIRCUIT_BREAKER: false }),
+      }));
+      vi.doMock("@/lib/logger", () => ({ logger: createLoggerMock() }));
+      vi.doMock("@/lib/redis/client", () => ({
+        getRedisClient: () => redisMock,
+      }));
+      vi.doMock("@/lib/redis/endpoint-circuit-breaker-state", () => ({
+        loadEndpointCircuitState: vi.fn(async () => null),
+        saveEndpointCircuitState: vi.fn(async () => {}),
+        deleteEndpointCircuitState: vi.fn(async () => {}),
+      }));
+
+      const { initEndpointCircuitBreaker } = await import("@/lib/endpoint-circuit-breaker");
+      await initEndpointCircuitBreaker();
+
+      expect(redisMock.scan).toHaveBeenCalled();
+      expect(redisMock.del).toHaveBeenCalledWith(
+        "endpoint_circuit_breaker:state:1",
+        "endpoint_circuit_breaker:state:2"
+      );
+    });
+
+    test("initEndpointCircuitBreaker is no-op when enabled", async () => {
+      vi.resetModules();
+
+      const redisMock = {
+        scan: vi.fn(),
+        del: vi.fn(),
+      };
+
+      vi.doMock("@/lib/config/env.schema", () => ({
+        getEnvConfig: () => ({ ENABLE_ENDPOINT_CIRCUIT_BREAKER: true }),
+      }));
+      vi.doMock("@/lib/logger", () => ({ logger: createLoggerMock() }));
+      vi.doMock("@/lib/redis/client", () => ({
+        getRedisClient: () => redisMock,
+      }));
+      vi.doMock("@/lib/redis/endpoint-circuit-breaker-state", () => ({
+        loadEndpointCircuitState: vi.fn(async () => null),
+        saveEndpointCircuitState: vi.fn(async () => {}),
+        deleteEndpointCircuitState: vi.fn(async () => {}),
+      }));
+
+      const { initEndpointCircuitBreaker } = await import("@/lib/endpoint-circuit-breaker");
+      await initEndpointCircuitBreaker();
+
+      expect(redisMock.scan).not.toHaveBeenCalled();
+      expect(redisMock.del).not.toHaveBeenCalled();
+    });
+  });
 });

+ 90 - 0
tests/unit/lib/provider-endpoints/endpoint-selector.test.ts

@@ -109,6 +109,9 @@ describe("provider-endpoints: endpoint-selector", () => {
     vi.doMock("@/lib/endpoint-circuit-breaker", () => ({
       isEndpointCircuitOpen: isOpenMock,
     }));
+    vi.doMock("@/lib/config/env.schema", () => ({
+      getEnvConfig: () => ({ ENABLE_ENDPOINT_CIRCUIT_BREAKER: true }),
+    }));
 
     const { getPreferredProviderEndpoints, pickBestProviderEndpoint } = await import(
       "@/lib/provider-endpoints/endpoint-selector"
@@ -140,6 +143,9 @@ describe("provider-endpoints: endpoint-selector", () => {
     vi.doMock("@/lib/endpoint-circuit-breaker", () => ({
       isEndpointCircuitOpen: isOpenMock,
     }));
+    vi.doMock("@/lib/config/env.schema", () => ({
+      getEnvConfig: () => ({ ENABLE_ENDPOINT_CIRCUIT_BREAKER: true }),
+    }));
 
     const { getPreferredProviderEndpoints, pickBestProviderEndpoint } = await import(
       "@/lib/provider-endpoints/endpoint-selector"
@@ -177,6 +183,9 @@ describe("getEndpointFilterStats", () => {
     vi.doMock("@/lib/endpoint-circuit-breaker", () => ({
       isEndpointCircuitOpen: isOpenMock,
     }));
+    vi.doMock("@/lib/config/env.schema", () => ({
+      getEnvConfig: () => ({ ENABLE_ENDPOINT_CIRCUIT_BREAKER: true }),
+    }));
 
     const { getEndpointFilterStats } = await import("@/lib/provider-endpoints/endpoint-selector");
     const stats = await getEndpointFilterStats({ vendorId: 10, providerType: "claude" });
@@ -202,6 +211,9 @@ describe("getEndpointFilterStats", () => {
     vi.doMock("@/lib/endpoint-circuit-breaker", () => ({
       isEndpointCircuitOpen: isOpenMock,
     }));
+    vi.doMock("@/lib/config/env.schema", () => ({
+      getEnvConfig: () => ({ ENABLE_ENDPOINT_CIRCUIT_BREAKER: true }),
+    }));
 
     const { getEndpointFilterStats } = await import("@/lib/provider-endpoints/endpoint-selector");
     const stats = await getEndpointFilterStats({ vendorId: 99, providerType: "codex" });
@@ -232,6 +244,9 @@ describe("getEndpointFilterStats", () => {
     vi.doMock("@/lib/endpoint-circuit-breaker", () => ({
       isEndpointCircuitOpen: isOpenMock,
     }));
+    vi.doMock("@/lib/config/env.schema", () => ({
+      getEnvConfig: () => ({ ENABLE_ENDPOINT_CIRCUIT_BREAKER: true }),
+    }));
 
     const { getEndpointFilterStats } = await import("@/lib/provider-endpoints/endpoint-selector");
     const stats = await getEndpointFilterStats({ vendorId: 1, providerType: "openai-compatible" });
@@ -244,3 +259,78 @@ describe("getEndpointFilterStats", () => {
     });
   });
 });
+
+describe("ENABLE_ENDPOINT_CIRCUIT_BREAKER disabled", () => {
+  test("getPreferredProviderEndpoints skips circuit check when disabled", async () => {
+    vi.resetModules();
+
+    const endpoints: ProviderEndpoint[] = [
+      makeEndpoint({ id: 1, lastProbeOk: true, sortOrder: 0, lastProbeLatencyMs: 100 }),
+      makeEndpoint({ id: 2, lastProbeOk: true, sortOrder: 1, lastProbeLatencyMs: 50 }),
+      makeEndpoint({ id: 3, lastProbeOk: false, sortOrder: 0, lastProbeLatencyMs: 10 }),
+      makeEndpoint({ id: 4, isEnabled: false }),
+      makeEndpoint({ id: 5, deletedAt: new Date(1) }),
+    ];
+
+    const findMock = vi.fn(async () => endpoints);
+    const isOpenMock = vi.fn(async () => true);
+
+    vi.doMock("@/repository", () => ({
+      findProviderEndpointsByVendorAndType: findMock,
+    }));
+    vi.doMock("@/lib/endpoint-circuit-breaker", () => ({
+      isEndpointCircuitOpen: isOpenMock,
+    }));
+    vi.doMock("@/lib/config/env.schema", () => ({
+      getEnvConfig: () => ({ ENABLE_ENDPOINT_CIRCUIT_BREAKER: false }),
+    }));
+
+    const { getPreferredProviderEndpoints } = await import(
+      "@/lib/provider-endpoints/endpoint-selector"
+    );
+
+    const result = await getPreferredProviderEndpoints({
+      vendorId: 1,
+      providerType: "claude",
+    });
+
+    expect(isOpenMock).not.toHaveBeenCalled();
+    // All enabled, non-deleted endpoints returned (id=1,2,3), ranked by sortOrder/health
+    expect(result.map((e) => e.id)).toEqual([1, 2, 3]);
+  });
+
+  test("getEndpointFilterStats returns circuitOpen=0 when disabled", async () => {
+    vi.resetModules();
+
+    const endpoints: ProviderEndpoint[] = [
+      makeEndpoint({ id: 1, isEnabled: true, lastProbeOk: true }),
+      makeEndpoint({ id: 2, isEnabled: true, lastProbeOk: false }),
+      makeEndpoint({ id: 3, isEnabled: false }),
+      makeEndpoint({ id: 4, deletedAt: new Date(1) }),
+    ];
+
+    const findMock = vi.fn(async () => endpoints);
+    const isOpenMock = vi.fn(async () => true);
+
+    vi.doMock("@/repository", () => ({
+      findProviderEndpointsByVendorAndType: findMock,
+    }));
+    vi.doMock("@/lib/endpoint-circuit-breaker", () => ({
+      isEndpointCircuitOpen: isOpenMock,
+    }));
+    vi.doMock("@/lib/config/env.schema", () => ({
+      getEnvConfig: () => ({ ENABLE_ENDPOINT_CIRCUIT_BREAKER: false }),
+    }));
+
+    const { getEndpointFilterStats } = await import("@/lib/provider-endpoints/endpoint-selector");
+    const stats = await getEndpointFilterStats({ vendorId: 10, providerType: "claude" });
+
+    expect(isOpenMock).not.toHaveBeenCalled();
+    expect(stats).toEqual({
+      total: 4,
+      enabled: 2, // id=1,2 (isEnabled && !deletedAt)
+      circuitOpen: 0, // always 0 when disabled
+      available: 2, // equals enabled when disabled
+    });
+  });
+});

+ 30 - 39
tests/unit/lib/provider-endpoints/probe.test.ts

@@ -23,6 +23,15 @@ function makeEndpoint(overrides: Partial<ProviderEndpoint>): ProviderEndpoint {
   };
 }
 
+function createCircuitBreakerMock(overrides: Partial<Record<string, unknown>> = {}) {
+  return {
+    getEndpointCircuitStateSync: vi.fn(() => "closed"),
+    resetEndpointCircuit: vi.fn(async () => {}),
+    recordEndpointFailure: vi.fn(async () => {}),
+    ...overrides,
+  };
+}
+
 afterEach(() => {
   vi.unstubAllGlobals();
   vi.useRealTimers();
@@ -49,9 +58,7 @@ describe("provider-endpoints: probe", () => {
       recordProviderEndpointProbeResult: vi.fn(),
       updateProviderEndpointProbeSnapshot: vi.fn(),
     }));
-    vi.doMock("@/lib/endpoint-circuit-breaker", () => ({
-      recordEndpointFailure: vi.fn(async () => {}),
-    }));
+    vi.doMock("@/lib/endpoint-circuit-breaker", () => createCircuitBreakerMock());
 
     const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => {
       if (init?.method === "HEAD") {
@@ -89,9 +96,7 @@ describe("provider-endpoints: probe", () => {
       recordProviderEndpointProbeResult: vi.fn(),
       updateProviderEndpointProbeSnapshot: vi.fn(),
     }));
-    vi.doMock("@/lib/endpoint-circuit-breaker", () => ({
-      recordEndpointFailure: vi.fn(async () => {}),
-    }));
+    vi.doMock("@/lib/endpoint-circuit-breaker", () => createCircuitBreakerMock());
 
     const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => {
       if (init?.method === "HEAD") {
@@ -132,9 +137,7 @@ describe("provider-endpoints: probe", () => {
       recordProviderEndpointProbeResult: vi.fn(),
       updateProviderEndpointProbeSnapshot: vi.fn(),
     }));
-    vi.doMock("@/lib/endpoint-circuit-breaker", () => ({
-      recordEndpointFailure: vi.fn(async () => {}),
-    }));
+    vi.doMock("@/lib/endpoint-circuit-breaker", () => createCircuitBreakerMock());
 
     vi.stubGlobal(
       "fetch",
@@ -170,9 +173,7 @@ describe("provider-endpoints: probe", () => {
       recordProviderEndpointProbeResult: vi.fn(),
       updateProviderEndpointProbeSnapshot: vi.fn(),
     }));
-    vi.doMock("@/lib/endpoint-circuit-breaker", () => ({
-      recordEndpointFailure: vi.fn(async () => {}),
-    }));
+    vi.doMock("@/lib/endpoint-circuit-breaker", () => createCircuitBreakerMock());
 
     vi.stubGlobal(
       "fetch",
@@ -206,9 +207,7 @@ describe("provider-endpoints: probe", () => {
       recordProviderEndpointProbeResult: vi.fn(),
       updateProviderEndpointProbeSnapshot: vi.fn(),
     }));
-    vi.doMock("@/lib/endpoint-circuit-breaker", () => ({
-      recordEndpointFailure: vi.fn(async () => {}),
-    }));
+    vi.doMock("@/lib/endpoint-circuit-breaker", () => createCircuitBreakerMock());
 
     const fetchMock = vi.fn(async () => {
       const err = new Error("");
@@ -251,9 +250,9 @@ describe("provider-endpoints: probe", () => {
       recordProviderEndpointProbeResult: recordMock,
       updateProviderEndpointProbeSnapshot: snapshotMock,
     }));
-    vi.doMock("@/lib/endpoint-circuit-breaker", () => ({
-      recordEndpointFailure: recordFailureMock,
-    }));
+    vi.doMock("@/lib/endpoint-circuit-breaker", () =>
+      createCircuitBreakerMock({ recordEndpointFailure: recordFailureMock })
+    );
 
     vi.stubGlobal(
       "fetch",
@@ -297,9 +296,9 @@ describe("provider-endpoints: probe", () => {
       recordProviderEndpointProbeResult: recordMock,
       updateProviderEndpointProbeSnapshot: snapshotMock,
     }));
-    vi.doMock("@/lib/endpoint-circuit-breaker", () => ({
-      recordEndpointFailure: recordFailureMock,
-    }));
+    vi.doMock("@/lib/endpoint-circuit-breaker", () =>
+      createCircuitBreakerMock({ recordEndpointFailure: recordFailureMock })
+    );
 
     vi.stubGlobal(
       "fetch",
@@ -367,9 +366,9 @@ describe("provider-endpoints: probe", () => {
       findProviderEndpointById: vi.fn(async () => endpoint),
       recordProviderEndpointProbeResult: recordMock,
     }));
-    vi.doMock("@/lib/endpoint-circuit-breaker", () => ({
-      recordEndpointFailure: recordFailureMock,
-    }));
+    vi.doMock("@/lib/endpoint-circuit-breaker", () =>
+      createCircuitBreakerMock({ recordEndpointFailure: recordFailureMock })
+    );
 
     vi.stubGlobal(
       "fetch",
@@ -407,9 +406,9 @@ describe("provider-endpoints: probe", () => {
       ),
       recordProviderEndpointProbeResult: recordMock,
     }));
-    vi.doMock("@/lib/endpoint-circuit-breaker", () => ({
-      recordEndpointFailure: recordFailureMock,
-    }));
+    vi.doMock("@/lib/endpoint-circuit-breaker", () =>
+      createCircuitBreakerMock({ recordEndpointFailure: recordFailureMock })
+    );
 
     vi.stubGlobal(
       "fetch",
@@ -443,9 +442,7 @@ describe("provider-endpoints: probe", () => {
       findProviderEndpointById: vi.fn(),
       recordProviderEndpointProbeResult: vi.fn(),
     }));
-    vi.doMock("@/lib/endpoint-circuit-breaker", () => ({
-      recordEndpointFailure: vi.fn(async () => {}),
-    }));
+    vi.doMock("@/lib/endpoint-circuit-breaker", () => createCircuitBreakerMock());
 
     // Mock net.createConnection to simulate successful TCP connection
     const mockSocket = {
@@ -496,9 +493,7 @@ describe("provider-endpoints: probe", () => {
       findProviderEndpointById: vi.fn(),
       recordProviderEndpointProbeResult: vi.fn(),
     }));
-    vi.doMock("@/lib/endpoint-circuit-breaker", () => ({
-      recordEndpointFailure: vi.fn(async () => {}),
-    }));
+    vi.doMock("@/lib/endpoint-circuit-breaker", () => createCircuitBreakerMock());
 
     const mockSocket = {
       destroy: vi.fn(),
@@ -541,9 +536,7 @@ describe("provider-endpoints: probe", () => {
       findProviderEndpointById: vi.fn(),
       recordProviderEndpointProbeResult: vi.fn(),
     }));
-    vi.doMock("@/lib/endpoint-circuit-breaker", () => ({
-      recordEndpointFailure: vi.fn(async () => {}),
-    }));
+    vi.doMock("@/lib/endpoint-circuit-breaker", () => createCircuitBreakerMock());
 
     const { probeEndpointUrl } = await import("@/lib/provider-endpoints/probe");
     const result = await probeEndpointUrl("not-a-valid-url", 5000);
@@ -571,9 +564,7 @@ describe("provider-endpoints: probe", () => {
       findProviderEndpointById: vi.fn(),
       recordProviderEndpointProbeResult: vi.fn(),
     }));
-    vi.doMock("@/lib/endpoint-circuit-breaker", () => ({
-      recordEndpointFailure: vi.fn(async () => {}),
-    }));
+    vi.doMock("@/lib/endpoint-circuit-breaker", () => createCircuitBreakerMock());
 
     const mockSocket = {
       destroy: vi.fn(),

+ 52 - 0
tests/unit/lib/rate-limit/concurrent-session-limit.test.ts

@@ -0,0 +1,52 @@
+import { describe, expect, it } from "vitest";
+import { resolveKeyConcurrentSessionLimit } from "@/lib/rate-limit/concurrent-session-limit";
+
+describe("resolveKeyConcurrentSessionLimit", () => {
+  const cases: Array<{
+    title: string;
+    keyLimit: number | null | undefined;
+    userLimit: number | null | undefined;
+    expected: number;
+  }> = [
+    { title: "Key > 0 时应优先使用 Key", keyLimit: 10, userLimit: 15, expected: 10 },
+    { title: "Key 为 0 时应回退到 User", keyLimit: 0, userLimit: 15, expected: 15 },
+    { title: "Key 为 null 时应回退到 User", keyLimit: null, userLimit: 15, expected: 15 },
+    { title: "Key 为 undefined 时应回退到 User", keyLimit: undefined, userLimit: 15, expected: 15 },
+    {
+      title: "Key 为 NaN 时应回退到 User",
+      keyLimit: Number.NaN,
+      userLimit: 15,
+      expected: 15,
+    },
+    {
+      title: "Key 为 Infinity 时应回退到 User",
+      keyLimit: Number.POSITIVE_INFINITY,
+      userLimit: 15,
+      expected: 15,
+    },
+    { title: "Key < 0 时应回退到 User", keyLimit: -1, userLimit: 15, expected: 15 },
+    { title: "Key 为小数时应向下取整", keyLimit: 5.9, userLimit: 15, expected: 5 },
+    { title: "Key 小数 < 1 时应回退到 User", keyLimit: 0.9, userLimit: 15, expected: 15 },
+    { title: "User 为小数时应向下取整", keyLimit: 0, userLimit: 7.8, expected: 7 },
+    {
+      title: "Key 与 User 均未设置/无效时应返回 0(无限制)",
+      keyLimit: undefined,
+      userLimit: null,
+      expected: 0,
+    },
+    {
+      title: "Key 为 0 且 User 为 Infinity 时应返回 0(无限制)",
+      keyLimit: 0,
+      userLimit: Number.POSITIVE_INFINITY,
+      expected: 0,
+    },
+  ];
+
+  for (const testCase of cases) {
+    it(testCase.title, () => {
+      expect(resolveKeyConcurrentSessionLimit(testCase.keyLimit, testCase.userLimit)).toBe(
+        testCase.expected
+      );
+    });
+  }
+});

+ 82 - 0
tests/unit/lib/vendor-type-circuit-breaker.test.ts

@@ -24,6 +24,88 @@ afterEach(() => {
 });
 
 describe("vendor-type-circuit-breaker", () => {
+  test("ENABLE_ENDPOINT_CIRCUIT_BREAKER=false 时,isVendorTypeCircuitOpen 始终返回 false", async () => {
+    vi.useFakeTimers();
+    vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
+
+    vi.resetModules();
+
+    const loadMock = vi.fn(async () => null);
+    const saveMock = vi.fn(async () => {});
+
+    vi.doMock("@/lib/logger", () => ({ logger: createLoggerMock() }));
+    vi.doMock("@/lib/redis/vendor-type-circuit-breaker-state", () => ({
+      loadVendorTypeCircuitState: loadMock,
+      saveVendorTypeCircuitState: saveMock,
+      deleteVendorTypeCircuitState: vi.fn(async () => {}),
+    }));
+    vi.doMock("@/lib/config/env.schema", () => ({
+      getEnvConfig: () => ({
+        ENABLE_ENDPOINT_CIRCUIT_BREAKER: false,
+        NODE_ENV: "test",
+      }),
+    }));
+
+    const { isVendorTypeCircuitOpen, recordVendorTypeAllEndpointsTimeout } = await import(
+      "@/lib/vendor-type-circuit-breaker"
+    );
+
+    // 尝试记录熔断
+    await recordVendorTypeAllEndpointsTimeout(100, "claude", 60000);
+    // 不应调用 save
+    expect(saveMock).not.toHaveBeenCalled();
+
+    // 应始终返回 false
+    expect(await isVendorTypeCircuitOpen(100, "claude")).toBe(false);
+  });
+
+  test("ENABLE_ENDPOINT_CIRCUIT_BREAKER=true 时,熔断功能正常工作", async () => {
+    vi.useFakeTimers();
+    vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
+
+    vi.resetModules();
+
+    let redisState: SavedVendorTypeCircuitState | null = null;
+    const loadMock = vi.fn(async () => redisState);
+    const saveMock = vi.fn(
+      async (
+        _vendorId: number,
+        _providerType: ProviderType,
+        state: SavedVendorTypeCircuitState
+      ) => {
+        redisState = state;
+      }
+    );
+
+    vi.doMock("@/lib/logger", () => ({ logger: createLoggerMock() }));
+    vi.doMock("@/lib/redis/vendor-type-circuit-breaker-state", () => ({
+      loadVendorTypeCircuitState: loadMock,
+      saveVendorTypeCircuitState: saveMock,
+      deleteVendorTypeCircuitState: vi.fn(async () => {}),
+    }));
+    vi.doMock("@/lib/config/env.schema", () => ({
+      getEnvConfig: () => ({
+        ENABLE_ENDPOINT_CIRCUIT_BREAKER: true,
+        NODE_ENV: "test",
+      }),
+    }));
+
+    const { isVendorTypeCircuitOpen, recordVendorTypeAllEndpointsTimeout } = await import(
+      "@/lib/vendor-type-circuit-breaker"
+    );
+
+    // 记录熔断
+    await recordVendorTypeAllEndpointsTimeout(101, "claude", 60000);
+    expect(saveMock).toHaveBeenCalled();
+
+    // 应返回 true
+    expect(await isVendorTypeCircuitOpen(101, "claude")).toBe(true);
+
+    // 等待熔断过期
+    vi.advanceTimersByTime(60000 + 1);
+    expect(await isVendorTypeCircuitOpen(101, "claude")).toBe(false);
+  });
+
   test("manual open 时 isVendorTypeCircuitOpen 始终为 true,且自动 open 不应覆盖", async () => {
     vi.useFakeTimers();
     vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));

+ 14 - 0
tests/unit/proxy/rate-limit-guard.test.ts

@@ -284,6 +284,20 @@ describe("ProxyRateLimitGuard - key daily limit enforcement", () => {
     });
   });
 
+  it("当 Key 并发未设置(0)且 User 并发已设置时,Key 并发检查应继承 User 并发上限", async () => {
+    const { ProxyRateLimitGuard } = await import("@/app/v1/_lib/proxy/rate-limit-guard");
+
+    const session = createSession({
+      user: { limitConcurrentSessions: 15 },
+      key: { limitConcurrentSessions: 0 },
+    });
+
+    await expect(ProxyRateLimitGuard.ensure(session)).resolves.toBeUndefined();
+
+    expect(rateLimitServiceMock.checkSessionLimit).toHaveBeenNthCalledWith(1, 2, "key", 15);
+    expect(rateLimitServiceMock.checkSessionLimit).toHaveBeenNthCalledWith(2, 1, "user", 15);
+  });
+
   it("User RPM 超限应拦截(rpm)", async () => {
     const { ProxyRateLimitGuard } = await import("@/app/v1/_lib/proxy/rate-limit-guard");
 

+ 405 - 0
tests/unit/proxy/response-handler-endpoint-circuit-isolation.test.ts

@@ -0,0 +1,405 @@
+/**
+ * Tests for endpoint circuit breaker isolation in response-handler.ts
+ *
+ * Verifies that key-level errors (fake 200, non-200 HTTP, stream abort) do NOT
+ * call recordEndpointFailure. Only forwarder-level failures (timeout, network
+ * error) and probe failures should penalize the endpoint circuit breaker.
+ *
+ * Streaming success DOES call recordEndpointSuccess (regression guard).
+ */
+
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import type { ModelPriceData } from "@/types/model-price";
+
+// Track async tasks for draining
+const asyncTasks: Promise<void>[] = [];
+
+vi.mock("@/lib/async-task-manager", () => ({
+  AsyncTaskManager: {
+    register: (_taskId: string, promise: Promise<void>) => {
+      asyncTasks.push(promise);
+      return new AbortController();
+    },
+    cleanup: () => {},
+    cancel: () => {},
+  },
+}));
+
+vi.mock("@/lib/logger", () => ({
+  logger: {
+    debug: () => {},
+    info: () => {},
+    warn: () => {},
+    error: () => {},
+    trace: () => {},
+  },
+}));
+
+vi.mock("@/lib/price-sync/cloud-price-updater", () => ({
+  requestCloudPriceTableSync: () => {},
+}));
+
+vi.mock("@/repository/model-price", () => ({
+  findLatestPriceByModel: vi.fn(),
+}));
+
+vi.mock("@/repository/system-config", () => ({
+  getSystemSettings: vi.fn(),
+}));
+
+vi.mock("@/repository/message", () => ({
+  updateMessageRequestCost: vi.fn(),
+  updateMessageRequestDetails: vi.fn(),
+  updateMessageRequestDuration: vi.fn(),
+}));
+
+vi.mock("@/lib/session-manager", () => ({
+  SessionManager: {
+    updateSessionUsage: vi.fn(),
+    storeSessionResponse: vi.fn(),
+    extractCodexPromptCacheKey: vi.fn(),
+    updateSessionWithCodexCacheKey: vi.fn(),
+  },
+}));
+
+vi.mock("@/lib/rate-limit", () => ({
+  RateLimitService: {
+    trackCost: vi.fn(),
+    trackUserDailyCost: vi.fn(),
+    decrementLeaseBudget: vi.fn(),
+  },
+}));
+
+vi.mock("@/lib/session-tracker", () => ({
+  SessionTracker: {
+    refreshSession: vi.fn(),
+  },
+}));
+
+vi.mock("@/lib/proxy-status-tracker", () => ({
+  ProxyStatusTracker: {
+    getInstance: () => ({
+      endRequest: () => {},
+    }),
+  },
+}));
+
+// Mock circuit breakers with tracked spies (vi.hoisted to avoid TDZ with vi.mock hoisting)
+const { mockRecordFailure, mockRecordEndpointFailure, mockRecordEndpointSuccess } = vi.hoisted(
+  () => ({
+    mockRecordFailure: vi.fn(),
+    mockRecordEndpointFailure: vi.fn(),
+    mockRecordEndpointSuccess: vi.fn(),
+  })
+);
+
+vi.mock("@/lib/circuit-breaker", () => ({
+  recordFailure: mockRecordFailure,
+}));
+
+vi.mock("@/lib/endpoint-circuit-breaker", () => ({
+  recordEndpointFailure: mockRecordEndpointFailure,
+  recordEndpointSuccess: mockRecordEndpointSuccess,
+  resetEndpointCircuit: vi.fn(),
+}));
+
+import { ProxyResponseHandler } from "@/app/v1/_lib/proxy/response-handler";
+import { ProxySession } from "@/app/v1/_lib/proxy/session";
+import { setDeferredStreamingFinalization } from "@/app/v1/_lib/proxy/stream-finalization";
+import { getSystemSettings } from "@/repository/system-config";
+import { findLatestPriceByModel } from "@/repository/model-price";
+import { updateMessageRequestDetails, updateMessageRequestDuration } from "@/repository/message";
+import { SessionManager } from "@/lib/session-manager";
+import { RateLimitService } from "@/lib/rate-limit";
+import { SessionTracker } from "@/lib/session-tracker";
+
+const testPriceData: ModelPriceData = {
+  input_cost_per_token: 0.000003,
+  output_cost_per_token: 0.000015,
+};
+
+function createSession(opts?: { sessionId?: string | null }): ProxySession {
+  const session = Object.create(ProxySession.prototype) as ProxySession;
+  const provider = {
+    id: 1,
+    name: "test-provider",
+    providerType: "claude" as const,
+    baseUrl: "https://api.test.com",
+    priority: 10,
+    weight: 1,
+    costMultiplier: 1,
+    groupTag: "default",
+    isEnabled: true,
+    models: [],
+    createdAt: new Date(),
+    updatedAt: new Date(),
+    streamingIdleTimeoutMs: 0,
+    dailyResetTime: "00:00",
+    dailyResetMode: "fixed",
+  };
+
+  const user = { id: 123, name: "test-user", dailyResetTime: "00:00", dailyResetMode: "fixed" };
+  const key = { id: 456, name: "test-key", dailyResetTime: "00:00", dailyResetMode: "fixed" };
+
+  Object.assign(session, {
+    request: { message: {}, log: "(test)", model: "test-model" },
+    startTime: Date.now(),
+    method: "POST",
+    requestUrl: new URL("http://localhost/v1/messages"),
+    headers: new Headers(),
+    headerLog: "",
+    userAgent: null,
+    context: {},
+    clientAbortSignal: null,
+    userName: "test-user",
+    authState: { user, key, apiKey: "sk-test", success: true },
+    provider,
+    messageContext: {
+      id: 1,
+      createdAt: new Date(),
+      user,
+      key,
+      apiKey: "sk-test",
+    },
+    sessionId: opts?.sessionId ?? null,
+    requestSequence: 1,
+    originalFormat: "claude",
+    providerType: null,
+    originalModelName: null,
+    originalUrlPathname: null,
+    providerChain: [],
+    cacheTtlResolved: null,
+    context1mApplied: false,
+    specialSettings: [],
+    cachedPriceData: undefined,
+    cachedBillingModelSource: undefined,
+    isHeaderModified: () => false,
+    getContext1mApplied: () => false,
+    getOriginalModel: () => "test-model",
+    getCurrentModel: () => "test-model",
+    getProviderChain: () => session.providerChain,
+    getCachedPriceDataByBillingSource: async () => testPriceData,
+    recordTtfb: () => 100,
+    ttfbMs: null,
+    getRequestSequence: () => 1,
+    addProviderToChain: function (
+      this: ProxySession & { providerChain: unknown[] },
+      prov: {
+        id: number;
+        name: string;
+        providerType: string;
+        priority: number;
+        weight: number;
+        costMultiplier: number;
+        groupTag: string;
+        providerVendorId?: string;
+      }
+    ) {
+      this.providerChain.push({
+        id: prov.id,
+        name: prov.name,
+        vendorId: prov.providerVendorId,
+        providerType: prov.providerType,
+        priority: prov.priority,
+        weight: prov.weight,
+        costMultiplier: prov.costMultiplier,
+        groupTag: prov.groupTag,
+        timestamp: Date.now(),
+      });
+    },
+  });
+
+  // Helper setters
+  (session as { setOriginalModel(m: string | null): void }).setOriginalModel = function (
+    m: string | null
+  ) {
+    (this as { originalModelName: string | null }).originalModelName = m;
+  };
+  (session as { setSessionId(s: string): void }).setSessionId = function (s: string) {
+    (this as { sessionId: string | null }).sessionId = s;
+  };
+  (session as { setProvider(p: unknown): void }).setProvider = function (p: unknown) {
+    (this as { provider: unknown }).provider = p;
+  };
+  (session as { setAuthState(a: unknown): void }).setAuthState = function (a: unknown) {
+    (this as { authState: unknown }).authState = a;
+  };
+  (session as { setMessageContext(c: unknown): void }).setMessageContext = function (c: unknown) {
+    (this as { messageContext: unknown }).messageContext = c;
+  };
+
+  session.setOriginalModel("test-model");
+
+  return session;
+}
+
+function setDeferredMeta(session: ProxySession, endpointId: number | null = 42) {
+  setDeferredStreamingFinalization(session, {
+    providerId: 1,
+    providerName: "test-provider",
+    providerPriority: 10,
+    attemptNumber: 1,
+    totalProvidersAttempted: 1,
+    isFirstAttempt: true,
+    isFailoverSuccess: false,
+    endpointId,
+    endpointUrl: "https://api.test.com",
+    upstreamStatusCode: 200,
+  });
+}
+
+/** Create an SSE stream that emits a fake-200 error body (valid HTTP 200 but error in content). */
+function createFake200StreamResponse(): Response {
+  const body = `data: ${JSON.stringify({ error: { message: "invalid api key" } })}\n\n`;
+  const encoder = new TextEncoder();
+  const stream = new ReadableStream<Uint8Array>({
+    start(controller) {
+      controller.enqueue(encoder.encode(body));
+      controller.close();
+    },
+  });
+  return new Response(stream, {
+    status: 200,
+    headers: { "content-type": "text/event-stream" },
+  });
+}
+
+/** Create an SSE stream that returns non-200 HTTP status with error body. */
+function createNon200StreamResponse(statusCode: number): Response {
+  const body = `data: ${JSON.stringify({ error: "rate limit exceeded" })}\n\n`;
+  const encoder = new TextEncoder();
+  const stream = new ReadableStream<Uint8Array>({
+    start(controller) {
+      controller.enqueue(encoder.encode(body));
+      controller.close();
+    },
+  });
+  return new Response(stream, {
+    status: statusCode,
+    headers: { "content-type": "text/event-stream" },
+  });
+}
+
+/** Create a successful SSE stream with usage data. */
+function createSuccessStreamResponse(): Response {
+  const sseText = `event: message_delta\ndata: ${JSON.stringify({ usage: { input_tokens: 100, output_tokens: 50 } })}\n\n`;
+  const encoder = new TextEncoder();
+  const stream = new ReadableStream<Uint8Array>({
+    start(controller) {
+      controller.enqueue(encoder.encode(sseText));
+      controller.close();
+    },
+  });
+  return new Response(stream, {
+    status: 200,
+    headers: { "content-type": "text/event-stream" },
+  });
+}
+
+async function drainAsyncTasks(): Promise<void> {
+  const tasks = asyncTasks.splice(0, asyncTasks.length);
+  await Promise.all(tasks);
+}
+
+function setupCommonMocks() {
+  vi.mocked(getSystemSettings).mockResolvedValue({
+    billingModelSource: "original",
+    streamBufferEnabled: false,
+    streamBufferMode: "none",
+    streamBufferSize: 0,
+  } as ReturnType<typeof getSystemSettings> extends Promise<infer T> ? T : never);
+  vi.mocked(findLatestPriceByModel).mockResolvedValue({
+    id: 1,
+    modelName: "test-model",
+    priceData: testPriceData,
+    createdAt: new Date(),
+    updatedAt: new Date(),
+  });
+  vi.mocked(updateMessageRequestDetails).mockResolvedValue(undefined);
+  vi.mocked(updateMessageRequestDuration).mockResolvedValue(undefined);
+  vi.mocked(SessionManager.storeSessionResponse).mockResolvedValue(undefined);
+  vi.mocked(RateLimitService.trackCost).mockResolvedValue(undefined);
+  vi.mocked(RateLimitService.trackUserDailyCost).mockResolvedValue(undefined);
+  vi.mocked(RateLimitService.decrementLeaseBudget).mockResolvedValue({
+    success: true,
+    newRemaining: 10,
+  });
+  vi.mocked(SessionTracker.refreshSession).mockResolvedValue(undefined);
+  mockRecordFailure.mockResolvedValue(undefined);
+  mockRecordEndpointFailure.mockResolvedValue(undefined);
+  mockRecordEndpointSuccess.mockResolvedValue(undefined);
+}
+
+beforeEach(() => {
+  vi.clearAllMocks();
+  asyncTasks.splice(0, asyncTasks.length);
+});
+
+describe("Endpoint circuit breaker isolation", () => {
+  beforeEach(() => {
+    setupCommonMocks();
+  });
+
+  it("fake-200 error should call recordFailure but NOT recordEndpointFailure", async () => {
+    const session = createSession();
+    setDeferredMeta(session, 42);
+
+    const response = createFake200StreamResponse();
+    await ProxyResponseHandler.dispatch(session, response);
+    await drainAsyncTasks();
+
+    expect(mockRecordFailure).toHaveBeenCalledWith(
+      1,
+      expect.objectContaining({ message: expect.stringContaining("FAKE_200") })
+    );
+    expect(mockRecordEndpointFailure).not.toHaveBeenCalled();
+  });
+
+  it("non-200 HTTP status should call recordFailure but NOT recordEndpointFailure", async () => {
+    const session = createSession();
+    // Set upstream status to 429 in deferred meta
+    setDeferredStreamingFinalization(session, {
+      providerId: 1,
+      providerName: "test-provider",
+      providerPriority: 10,
+      attemptNumber: 1,
+      totalProvidersAttempted: 1,
+      isFirstAttempt: true,
+      isFailoverSuccess: false,
+      endpointId: 42,
+      endpointUrl: "https://api.test.com",
+      upstreamStatusCode: 429,
+    });
+
+    const response = createNon200StreamResponse(429);
+    await ProxyResponseHandler.dispatch(session, response);
+    await drainAsyncTasks();
+
+    expect(mockRecordFailure).toHaveBeenCalledWith(1, expect.any(Error));
+    expect(mockRecordEndpointFailure).not.toHaveBeenCalled();
+  });
+
+  it("streaming success DOES call recordEndpointSuccess (regression guard)", async () => {
+    const session = createSession();
+    setDeferredMeta(session, 42);
+
+    const response = createSuccessStreamResponse();
+    await ProxyResponseHandler.dispatch(session, response);
+    await drainAsyncTasks();
+
+    expect(mockRecordEndpointSuccess).toHaveBeenCalledWith(42);
+    expect(mockRecordEndpointFailure).not.toHaveBeenCalled();
+  });
+
+  it("streaming success without endpointId should NOT call any endpoint circuit breaker function", async () => {
+    const session = createSession();
+    setDeferredMeta(session, null);
+
+    const response = createSuccessStreamResponse();
+    await ProxyResponseHandler.dispatch(session, response);
+    await drainAsyncTasks();
+
+    expect(mockRecordEndpointSuccess).not.toHaveBeenCalled();
+    expect(mockRecordEndpointFailure).not.toHaveBeenCalled();
+  });
+});

+ 1 - 1
tests/unit/vacuum-filter/vacuum-filter-has.bench.test.ts

@@ -31,7 +31,7 @@ function makeAsciiKey(rng: () => number, len: number): string {
 
 function freshSameContent(s: string): string {
   // 让 V8 很难复用同一个 string 实例(模拟“请求头解析后每次都是新字符串对象”)
-  return (" " + s).slice(1);
+  return ` ${s}`.slice(1);
 }
 
 function median(values: number[]): number {