diff --git a/.env.example b/.env.example index 3fc6e5611..5e2c5031e 100644 --- a/.env.example +++ b/.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 跨实例即时失效,提升供应商查询性能 diff --git a/biome.json b/biome.json index 87362d2ac..4e430dd39 100644 --- a/biome.json +++ b/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", diff --git a/messages/en/dashboard.json b/messages/en/dashboard.json index f26229c75..6256e867e 100644 --- a/messages/en/dashboard.json +++ b/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", diff --git a/messages/en/provider-chain.json b/messages/en/provider-chain.json index a347e8dc8..e8f6678a5 100644 --- a/messages/en/provider-chain.json +++ b/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." } } diff --git a/messages/en/settings/config.json b/messages/en/settings/config.json index f7aacfa05..c7bd7ae2b 100644 --- a/messages/en/settings/config.json +++ b/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": { diff --git a/messages/en/settings/providers/form/key.json b/messages/en/settings/providers/form/key.json index 7764faf0b..4bcd75153 100644 --- a/messages/en/settings/providers/form/key.json +++ b/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." + } } diff --git a/messages/ja/dashboard.json b/messages/ja/dashboard.json index fd6a42f10..3a4aae78d 100644 --- a/messages/ja/dashboard.json +++ b/messages/ja/dashboard.json @@ -981,7 +981,8 @@ "expiresAt": { "label": "有効期限", "placeholder": "空白の場合は無期限", - "description": "有効期限切れ後、ユーザーは自動的に無効化されます" + "description": "有効期限切れ後、ユーザーは自動的に無効化されます", + "pastWarning": "選択した日付は過去です。保存するとユーザーは直ちに期限切れとなり無効化されます。" }, "allowedClients": { "label": "許可されたクライアント", diff --git a/messages/ja/provider-chain.json b/messages/ja/provider-chain.json index 37adb84f9..cf9ebdb78 100644 --- a/messages/ja/provider-chain.json +++ b/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": "このベンダータイプの全エンドポイントがタイムアウトしました。ベンダータイプサーキットブレーカーが発動しました。" } } diff --git a/messages/ja/settings/config.json b/messages/ja/settings/config.json index 6bf7f8790..7a9a6204e 100644 --- a/messages/ja/settings/config.json +++ b/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": { diff --git a/messages/ja/settings/providers/form/key.json b/messages/ja/settings/providers/form/key.json index f40d9137a..0d0385683 100644 --- a/messages/ja/settings/providers/form/key.json +++ b/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 キーとしては一般的ではありません。" + } } diff --git a/messages/ru/dashboard.json b/messages/ru/dashboard.json index 48337f945..6c46440e6 100644 --- a/messages/ru/dashboard.json +++ b/messages/ru/dashboard.json @@ -983,7 +983,8 @@ "expiresAt": { "label": "Срок действия", "placeholder": "Оставьте пустым для бессрочного", - "description": "Пользователь будет автоматически отключен после истечения срока" + "description": "Пользователь будет автоматически отключен после истечения срока", + "pastWarning": "Выбранная дата в прошлом. После сохранения пользователь сразу станет просроченным и будет отключен." }, "allowedClients": { "label": "Разрешённые клиенты", diff --git a/messages/ru/provider-chain.json b/messages/ru/provider-chain.json index e37650b04..c123208b8 100644 --- a/messages/ru/provider-chain.json +++ b/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": "Все конечные точки этого типа поставщика превысили тайм-аут. Активирован размыкатель типа поставщика." } } diff --git a/messages/ru/settings/config.json b/messages/ru/settings/config.json index 44f9883c1..a65535f31 100644 --- a/messages/ru/settings/config.json +++ b/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": { diff --git a/messages/ru/settings/providers/form/key.json b/messages/ru/settings/providers/form/key.json index 7bb499d2e..941b1b451 100644 --- a/messages/ru/settings/providers/form/key.json +++ b/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 ключей это обычно нетипично." + } } diff --git a/messages/zh-CN/dashboard.json b/messages/zh-CN/dashboard.json index 823caee5a..e08a15acf 100644 --- a/messages/zh-CN/dashboard.json +++ b/messages/zh-CN/dashboard.json @@ -995,7 +995,8 @@ "expiresAt": { "label": "过期时间", "placeholder": "留空表示永不过期", - "description": "用户过期后将自动禁用" + "description": "用户过期后将自动禁用", + "pastWarning": "选择的日期已在过去,保存后用户将立即过期并被禁用。" }, "allowedClients": { "label": "允许的客户端", diff --git a/messages/zh-CN/provider-chain.json b/messages/zh-CN/provider-chain.json index fe75d85a1..8691d9aa1 100644 --- a/messages/zh-CN/provider-chain.json +++ b/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": "该供应商类型的所有端点均超时,已触发供应商类型临时熔断。" } } diff --git a/messages/zh-CN/settings/config.json b/messages/zh-CN/settings/config.json index b553ef9d5..91c876140 100644 --- a/messages/zh-CN/settings/config.json +++ b/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。" + } } } } diff --git a/messages/zh-CN/settings/providers/form/key.json b/messages/zh-CN/settings/providers/form/key.json index 4658c4ca9..ff5a99e12 100644 --- a/messages/zh-CN/settings/providers/form/key.json +++ b/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。" + } } diff --git a/messages/zh-TW/dashboard.json b/messages/zh-TW/dashboard.json index ce0739f82..a4d2d8fe7 100644 --- a/messages/zh-TW/dashboard.json +++ b/messages/zh-TW/dashboard.json @@ -980,7 +980,8 @@ "expiresAt": { "label": "到期時間", "placeholder": "留空表示永不過期", - "description": "使用者過期後將自動停用" + "description": "使用者過期後將自動停用", + "pastWarning": "選擇的日期已在過去,儲存後使用者將立即到期並被停用。" }, "allowedClients": { "label": "允許的用戶端", diff --git a/messages/zh-TW/provider-chain.json b/messages/zh-TW/provider-chain.json index 04aa28488..699b37bc6 100644 --- a/messages/zh-TW/provider-chain.json +++ b/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": "該供應商類型的所有端點均逾時,已觸發供應商類型臨時熔斷。" } } diff --git a/messages/zh-TW/settings/config.json b/messages/zh-TW/settings/config.json index b57c2e355..4a3c7ee01 100644 --- a/messages/zh-TW/settings/config.json +++ b/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": { diff --git a/messages/zh-TW/settings/providers/form/key.json b/messages/zh-TW/settings/providers/form/key.json index f89c40db5..19f6ccd31 100644 --- a/messages/zh-TW/settings/providers/form/key.json +++ b/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。" + } } diff --git a/scripts/deploy.ps1 b/scripts/deploy.ps1 index d7f1e41de..d8a353200 100644 --- a/scripts/deploy.ps1 +++ b/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 diff --git a/scripts/deploy.sh b/scripts/deploy.sh index 51e457a8e..b777a333c 100755 --- a/scripts/deploy.sh +++ b/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 diff --git a/src/actions/key-quota.ts b/src/actions/key-quota.ts index a484b3700..8089a6c60 100644 --- a/src/actions/key-quota.ts +++ b/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 0 ? effectiveConcurrentLimit : null, }, ]; diff --git a/src/actions/keys.ts b/src/actions/keys.ts index 711d38526..d329c138f 100644 --- a/src/actions/keys.ts +++ b/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, }, }, }; diff --git a/src/actions/my-usage.ts b/src/actions/my-usage.ts index a59d427d6..1ae320d43 100644 --- a/src/actions/my-usage.ts +++ b/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> { 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> { keyLimitWeeklyUsd: key.limitWeeklyUsd ?? null, keyLimitMonthlyUsd: key.limitMonthlyUsd ?? null, keyLimitTotalUsd: key.limitTotalUsd ?? null, - keyLimitConcurrentSessions: key.limitConcurrentSessions ?? null, + keyLimitConcurrentSessions: effectiveKeyConcurrentLimit, keyCurrent5hUsd: keyCost5h, keyCurrentDailyUsd: keyCostDaily, keyCurrentWeeklyUsd: keyCostWeekly, diff --git a/src/app/[locale]/dashboard/_components/user/forms/user-form.tsx b/src/app/[locale]/dashboard/_components/user/forms/user-form.tsx index 010cbceca..e3688e1c1 100644 --- a/src/app/[locale]/dashboard/_components/user/forms/user-form.tsx +++ b/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 ( + {expiresAtPastWarning && {expiresAtPastWarning}} {/* Allowed Clients (CLI/IDE restrictions) */}
diff --git a/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/LogicTraceTab.tsx b/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/LogicTraceTab.tsx index d732f846b..2a10408ec 100644 --- a/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/LogicTraceTab.tsx +++ b/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/LogicTraceTab.tsx @@ -353,7 +353,13 @@ export function LogicTraceTab({ {tChain(`filterReasons.${p.reason}`)} {p.details && ( - ({p.details}) + + ( + {tChain.has(`filterDetails.${p.details}`) + ? tChain(`filterDetails.${p.details}`) + : p.details} + ) + )}
))} diff --git a/src/app/[locale]/dashboard/logs/_components/provider-chain-popover.tsx b/src/app/[locale]/dashboard/logs/_components/provider-chain-popover.tsx index 7a2c99a76..cdc061212 100644 --- a/src/app/[locale]/dashboard/logs/_components/provider-chain-popover.tsx +++ b/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", diff --git a/src/app/[locale]/settings/config/_components/system-settings-form.tsx b/src/app/[locale]/settings/config/_components/system-settings-form.tsx index da47f4c87..6ecdf50f6 100644 --- a/src/app/[locale]/settings/config/_components/system-settings-form.tsx +++ b/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} />

{t("quotaLease.dbRefreshIntervalDesc")}

+ {shouldWarnQuotaDbRefreshIntervalTooLow(quotaDbRefreshIntervalSeconds) && ( + + {t("quotaLease.warnings.dbRefreshIntervalTooLow", { + value: quotaDbRefreshIntervalSeconds, + })} + + )} + {shouldWarnQuotaDbRefreshIntervalTooHigh(quotaDbRefreshIntervalSeconds) && ( + + {t("quotaLease.warnings.dbRefreshIntervalTooHigh", { + value: quotaDbRefreshIntervalSeconds, + })} + + )} {/* Lease Percent 5h */} @@ -652,6 +698,9 @@ export function SystemSettingsForm({ initialSettings }: SystemSettingsFormProps)

{t("quotaLease.leasePercent5hDesc")}

+ {shouldWarnQuotaLeasePercentZero(quotaLeasePercent5h) && ( + {t("quotaLease.warnings.leasePercentZero")} + )} {/* Lease Percent Daily */} @@ -676,6 +725,9 @@ export function SystemSettingsForm({ initialSettings }: SystemSettingsFormProps)

{t("quotaLease.leasePercentDailyDesc")}

+ {shouldWarnQuotaLeasePercentZero(quotaLeasePercentDaily) && ( + {t("quotaLease.warnings.leasePercentZero")} + )} {/* Lease Percent Weekly */} @@ -700,6 +752,9 @@ export function SystemSettingsForm({ initialSettings }: SystemSettingsFormProps)

{t("quotaLease.leasePercentWeeklyDesc")}

+ {shouldWarnQuotaLeasePercentZero(quotaLeasePercentWeekly) && ( + {t("quotaLease.warnings.leasePercentZero")} + )} {/* Lease Percent Monthly */} @@ -724,6 +779,9 @@ export function SystemSettingsForm({ initialSettings }: SystemSettingsFormProps)

{t("quotaLease.leasePercentMonthlyDesc")}

+ {shouldWarnQuotaLeasePercentZero(quotaLeasePercentMonthly) && ( + {t("quotaLease.warnings.leasePercentZero")} + )} {/* Lease Cap USD */} @@ -746,6 +804,9 @@ export function SystemSettingsForm({ initialSettings }: SystemSettingsFormProps) className={inputClassName} />

{t("quotaLease.leaseCapUsdDesc")}

+ {shouldWarnQuotaLeaseCapZero(quotaLeaseCapUsd) && ( + {t("quotaLease.warnings.leaseCapZero")} + )} diff --git a/src/app/[locale]/settings/providers/_components/forms/provider-form/provider-form-context.tsx b/src/app/[locale]/settings/providers/_components/forms/provider-form/provider-form-context.tsx index 7b89a5dbe..9f9c890a2 100644 --- a/src/app/[locale]/settings/providers/_components/forms/provider-form/provider-form-context.tsx +++ b/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": diff --git a/src/app/[locale]/settings/providers/_components/forms/provider-form/sections/basic-info-section.tsx b/src/app/[locale]/settings/providers/_components/forms/provider-form/sections/basic-info-section.tsx index 0b4866d88..eb7258fd8 100644 --- a/src/app/[locale]/settings/providers/_components/forms/provider-form/sections/basic-info-section.tsx +++ b/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(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 ? : } + + {apiKeyWarnings.length > 0 && ( +
+ {apiKeyWarnings.map((warningId) => ( + {t(`key.warnings.${warningId}`)} + ))} +
+ )} diff --git a/src/app/api/actions/[...route]/route.ts b/src/app/api/actions/[...route]/route.ts index 9bbace4e1..08a1f52ca 100644 --- a/src/app/api/actions/[...route]/route.ts +++ b/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(), diff --git a/src/app/v1/_lib/proxy/forwarder.ts b/src/app/v1/_lib/proxy/forwarder.ts index c33082343..18a430d19 100644 --- a/src/app/v1/_lib/proxy/forwarder.ts +++ b/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 diff --git a/src/app/v1/_lib/proxy/provider-selector.ts b/src/app/v1/_lib/proxy/provider-selector.ts index 1f6c62a12..9f108cd80 100644 --- a/src/app/v1/_lib/proxy/provider-selector.ts +++ b/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", }); } } diff --git a/src/app/v1/_lib/proxy/rate-limit-guard.ts b/src/app/v1/_lib/proxy/rate-limit-guard.ts index 750eb951b..99592a5b7 100644 --- a/src/app/v1/_lib/proxy/rate-limit-guard.ts +++ b/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) { diff --git a/src/app/v1/_lib/proxy/response-handler.ts b/src/app/v1/_lib/proxy/response-handler.ts index 7aa4b06f2..fa7f9bfcb 100644 --- a/src/app/v1/_lib/proxy/response-handler.ts +++ b/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, { diff --git a/src/app/v1/_lib/proxy/session.ts b/src/app/v1/_lib/proxy/session.ts index 5e93b76f3..22cf12dca 100644 --- a/src/app/v1/_lib/proxy/session.ts +++ b/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" diff --git a/src/components/ui/inline-warning.tsx b/src/components/ui/inline-warning.tsx new file mode 100644 index 000000000..d4f1a2a39 --- /dev/null +++ b/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 ( +
+
+ ); +} diff --git a/src/instrumentation.ts b/src/instrumentation.ts index d81b33ae9..a3303e51d 100644 --- a/src/instrumentation.ts +++ b/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" diff --git a/src/lib/config/env.schema.ts b/src/lib/config/env.schema.ts index b120fd8c8..a845a0db5 100644 --- a/src/lib/config/env.schema.ts +++ b/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:禁用缓存,每次请求直接查询数据库 diff --git a/src/lib/endpoint-circuit-breaker.ts b/src/lib/endpoint-circuit-breaker.ts index e65696fa6..b3560421b 100644 --- a/src/lib/endpoint-circuit-breaker.ts +++ b/src/lib/endpoint-circuit-breaker.ts @@ -114,6 +114,11 @@ export async function getEndpointHealthInfo( } export async function isEndpointCircuitOpen(endpointId: number): Promise { + 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 { + 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 { + 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 { + 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 { + 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), + }); + } +} diff --git a/src/lib/provider-endpoints/endpoint-selector.ts b/src/lib/provider-endpoints/endpoint-selector.ts index cda4ddd3a..dda76af15 100644 --- a/src/lib/provider-endpoints/endpoint-selector.ts +++ b/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) diff --git a/src/lib/rate-limit/concurrent-session-limit.ts b/src/lib/rate-limit/concurrent-session-limit.ts new file mode 100644 index 000000000..24c9a205c --- /dev/null +++ b/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); +} diff --git a/src/lib/utils/date-input.test.ts b/src/lib/utils/date-input.test.ts new file mode 100644 index 000000000..98f1b242f --- /dev/null +++ b/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(""); + }); +}); diff --git a/src/lib/utils/date-input.ts b/src/lib/utils/date-input.ts index b872e26c0..77e5e957c 100644 --- a/src/lib/utils/date-input.ts +++ b/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; +} diff --git a/src/lib/utils/provider-chain-formatter.test.ts b/src/lib/utils/provider-chain-formatter.test.ts index 8caf5ed97..ace105ca8 100644 --- a/src/lib/utils/provider-chain-formatter.test.ts +++ b/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 // ============================================================================= diff --git a/src/lib/utils/provider-chain-formatter.ts b/src/lib/utils/provider-chain-formatter.ts index 5369bf1b0..46d2f4e24 100644 --- a/src/lib/utils/provider-chain-formatter.ts +++ b/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`; diff --git a/src/lib/utils/validation/api-key-warnings.test.ts b/src/lib/utils/validation/api-key-warnings.test.ts new file mode 100644 index 000000000..e7eb48a7c --- /dev/null +++ b/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"); + }); +}); diff --git a/src/lib/utils/validation/api-key-warnings.ts b/src/lib/utils/validation/api-key-warnings.ts new file mode 100644 index 000000000..ff32c5821 --- /dev/null +++ b/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; +} diff --git a/src/lib/utils/validation/quota-lease-warnings.test.ts b/src/lib/utils/validation/quota-lease-warnings.test.ts new file mode 100644 index 000000000..ff5e1598a --- /dev/null +++ b/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); + }); +}); diff --git a/src/lib/utils/validation/quota-lease-warnings.ts b/src/lib/utils/validation/quota-lease-warnings.ts new file mode 100644 index 000000000..0eb77c874 --- /dev/null +++ b/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; +} diff --git a/src/lib/vendor-type-circuit-breaker.ts b/src/lib/vendor-type-circuit-breaker.ts index 618b9d680..6fed23b88 100644 --- a/src/lib/vendor-type-circuit-breaker.ts +++ b/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 { + // 检查端点熔断器开关,供应商类型熔断复用此开关 + 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 { + // 检查端点熔断器开关,供应商类型熔断复用此开关 + if (!getEnvConfig().ENABLE_ENDPOINT_CIRCUIT_BREAKER) { + return; + } + const state = await getOrCreateState(vendorId, providerType); if (state.manualOpen) { diff --git a/src/types/message.ts b/src/types/message.ts index ee3784ed8..56fab4abd 100644 --- a/src/types/message.ts +++ b/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?: diff --git a/tests/unit/actions/key-quota-concurrent-inherit.test.ts b/tests/unit/actions/key-quota-concurrent-inherit.test.ts new file mode 100644 index 000000000..f07d9f834 --- /dev/null +++ b/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 }); + } + }); +}); diff --git a/tests/unit/actions/my-usage-concurrent-inherit.test.ts b/tests/unit/actions/my-usage-concurrent-inherit.test.ts new file mode 100644 index 000000000..1f5146519 --- /dev/null +++ b/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); + } + }); +}); diff --git a/tests/unit/dashboard/provider-form-clone-deep-copy.test.ts b/tests/unit/dashboard/provider-form-clone-deep-copy.test.ts new file mode 100644 index 000000000..d5a0932ba --- /dev/null +++ b/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 { + 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(); + }); + }); +}); diff --git a/tests/unit/dashboard/user-form-expiry-clear-ui.test.tsx b/tests/unit/dashboard/user-form-expiry-clear-ui.test.tsx index 9e50f7b77..e9f260ffb 100644 --- a/tests/unit/dashboard/user-form-expiry-clear-ui.test.tsx +++ b/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( @@ -97,7 +99,7 @@ describe("UserForm: 清除 expiresAt 后应提交 null", () => { ); await act(async () => { - clickButtonByText("2026-01-04"); + clickButtonByText(expectedYmd); }); await act(async () => { diff --git a/tests/unit/lib/endpoint-circuit-breaker.test.ts b/tests/unit/lib/endpoint-circuit-breaker.test.ts index 107e35536..c38adabf2 100644 --- a/tests/unit/lib/endpoint-circuit-breaker.test.ts +++ b/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(); + }); + }); }); diff --git a/tests/unit/lib/provider-endpoints/endpoint-selector.test.ts b/tests/unit/lib/provider-endpoints/endpoint-selector.test.ts index 8aa1291c0..59ac0312d 100644 --- a/tests/unit/lib/provider-endpoints/endpoint-selector.test.ts +++ b/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 + }); + }); +}); diff --git a/tests/unit/lib/provider-endpoints/probe.test.ts b/tests/unit/lib/provider-endpoints/probe.test.ts index c77b04845..b4842d128 100644 --- a/tests/unit/lib/provider-endpoints/probe.test.ts +++ b/tests/unit/lib/provider-endpoints/probe.test.ts @@ -23,6 +23,15 @@ function makeEndpoint(overrides: Partial): ProviderEndpoint { }; } +function createCircuitBreakerMock(overrides: Partial> = {}) { + 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(), diff --git a/tests/unit/lib/rate-limit/concurrent-session-limit.test.ts b/tests/unit/lib/rate-limit/concurrent-session-limit.test.ts new file mode 100644 index 000000000..706989644 --- /dev/null +++ b/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 + ); + }); + } +}); diff --git a/tests/unit/lib/vendor-type-circuit-breaker.test.ts b/tests/unit/lib/vendor-type-circuit-breaker.test.ts index 8875926be..f1eabdb6b 100644 --- a/tests/unit/lib/vendor-type-circuit-breaker.test.ts +++ b/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")); diff --git a/tests/unit/proxy/rate-limit-guard.test.ts b/tests/unit/proxy/rate-limit-guard.test.ts index 55c761683..c8aec8665 100644 --- a/tests/unit/proxy/rate-limit-guard.test.ts +++ b/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"); diff --git a/tests/unit/proxy/response-handler-endpoint-circuit-isolation.test.ts b/tests/unit/proxy/response-handler-endpoint-circuit-isolation.test.ts new file mode 100644 index 000000000..16c531d24 --- /dev/null +++ b/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[] = []; + +vi.mock("@/lib/async-task-manager", () => ({ + AsyncTaskManager: { + register: (_taskId: string, promise: Promise) => { + 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({ + 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({ + 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({ + 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 { + 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 extends Promise ? 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(); + }); +}); diff --git a/tests/unit/vacuum-filter/vacuum-filter-has.bench.test.ts b/tests/unit/vacuum-filter/vacuum-filter-has.bench.test.ts index 2c5aec50a..1ea732238 100644 --- a/tests/unit/vacuum-filter/vacuum-filter-has.bench.test.ts +++ b/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 {