From 73c960fa1f75c93411864db0ba59b8faf0636a12 Mon Sep 17 00:00:00 2001 From: Abner <22141172+Silentely@users.noreply.github.com> Date: Wed, 26 Nov 2025 23:19:08 +0800 Subject: [PATCH 01/19] =?UTF-8?q?fix:=20=E5=A2=9E=E5=BC=BA=E9=94=99?= =?UTF-8?q?=E8=AF=AF=E8=A7=A3=E6=9E=90=E4=BB=A5=E6=94=AF=E6=8C=81=E4=B8=AD?= =?UTF-8?q?=E8=BD=AC=E6=9C=8D=E5=8A=A1=E7=9A=84=E5=B5=8C=E5=A5=97=E9=94=99?= =?UTF-8?q?=E8=AF=AF=E7=BB=93=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 优先提取 upstream_error 中的错误信息 - 解决 cubence 等中转服务的错误显示问题 - 修复前端显示"you appear to be making a request using a non-Claude code cli client, which is prohibited" - 保持向后兼容的错误提取逻辑 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/actions/providers.ts | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/src/actions/providers.ts b/src/actions/providers.ts index b9a7f855d..991a19087 100644 --- a/src/actions/providers.ts +++ b/src/actions/providers.ts @@ -1057,6 +1057,33 @@ function extractErrorMessage(errorJson: unknown): string | undefined { return undefined; } + const obj = errorJson as Record; + + // 优先提取 upstream_error 中的错误信息(针对中转服务的嵌套错误) + // 例如: { error: { message: "请求目标分享错误", upstream_error: { error: { message: "真正的错误" } } } } + if (obj.error && typeof obj.error === "object") { + const errorObj = obj.error as Record; + if (errorObj.upstream_error && typeof errorObj.upstream_error === "object") { + const upstreamError = errorObj.upstream_error as Record; + + // 尝试从 upstream_error.error.message 提取 + if (upstreamError.error && typeof upstreamError.error === "object") { + const upstreamErrorObj = upstreamError.error as Record; + const upstreamMessage = normalizeErrorValue(upstreamErrorObj.message); + if (upstreamMessage) { + return upstreamMessage; + } + } + + // 尝试从 upstream_error.message 提取 + const upstreamMessage = normalizeErrorValue(upstreamError.message); + if (upstreamMessage) { + return upstreamMessage; + } + } + } + + // 常规错误提取逻辑(保持原有优先级) const candidates: Array<(obj: Record) => unknown> = [ (obj) => (obj.error as Record | undefined)?.message, (obj) => obj.message, @@ -1069,7 +1096,7 @@ function extractErrorMessage(errorJson: unknown): string | undefined { for (const getter of candidates) { let value: unknown; try { - value = getter(errorJson as Record); + value = getter(obj); } catch { continue; } From a589e018ee6ddca29556831c97c0e340c8154fb5 Mon Sep 17 00:00:00 2001 From: Abner <22141172+Silentely@users.noreply.github.com> Date: Wed, 26 Nov 2025 23:55:38 +0800 Subject: [PATCH 02/19] =?UTF-8?q?refactor:=20=E4=BD=BF=E7=94=A8=E5=8F=AF?= =?UTF-8?q?=E9=80=89=E9=93=BE=E7=AE=80=E5=8C=96=E9=94=99=E8=AF=AF=E6=8F=90?= =?UTF-8?q?=E5=8F=96=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 减少嵌套层级,提高代码可读性 - 使用可选链操作符简化属性访问 - 保持相同的功能逻辑 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/actions/providers.ts | 35 ++++++++++++++++------------------- 1 file changed, 16 insertions(+), 19 deletions(-) diff --git a/src/actions/providers.ts b/src/actions/providers.ts index 991a19087..eb35cc871 100644 --- a/src/actions/providers.ts +++ b/src/actions/providers.ts @@ -1060,26 +1060,23 @@ function extractErrorMessage(errorJson: unknown): string | undefined { const obj = errorJson as Record; // 优先提取 upstream_error 中的错误信息(针对中转服务的嵌套错误) - // 例如: { error: { message: "请求目标分享错误", upstream_error: { error: { message: "真正的错误" } } } } - if (obj.error && typeof obj.error === "object") { - const errorObj = obj.error as Record; - if (errorObj.upstream_error && typeof errorObj.upstream_error === "object") { - const upstreamError = errorObj.upstream_error as Record; - - // 尝试从 upstream_error.error.message 提取 - if (upstreamError.error && typeof upstreamError.error === "object") { - const upstreamErrorObj = upstreamError.error as Record; - const upstreamMessage = normalizeErrorValue(upstreamErrorObj.message); - if (upstreamMessage) { - return upstreamMessage; - } - } + const upstreamError = (obj.error as { upstream_error?: unknown } | undefined)?.upstream_error; - // 尝试从 upstream_error.message 提取 - const upstreamMessage = normalizeErrorValue(upstreamError.message); - if (upstreamMessage) { - return upstreamMessage; - } + if (upstreamError && typeof upstreamError === "object") { + const upstreamErrorObj = upstreamError as Record; + + // 尝试从 upstream_error.error.message 提取 + const nestedMessage = normalizeErrorValue( + (upstreamErrorObj.error as { message?: unknown } | undefined)?.message + ); + if (nestedMessage) { + return nestedMessage; + } + + // 尝试从 upstream_error.message 提取 + const directMessage = normalizeErrorValue(upstreamErrorObj.message); + if (directMessage) { + return directMessage; } } From f43538762ce7c589c2d52b59eac555b0bed8bfb6 Mon Sep 17 00:00:00 2001 From: ding113 Date: Thu, 27 Nov 2025 00:35:48 +0800 Subject: [PATCH 03/19] =?UTF-8?q?feat(availability):=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E4=BE=9B=E5=BA=94=E5=95=86=E5=8F=AF=E7=94=A8=E6=80=A7=E7=9B=91?= =?UTF-8?q?=E6=8E=A7=E6=A8=A1=E5=9D=97=E5=B9=B6=E7=AE=80=E5=8C=96=E7=8A=B6?= =?UTF-8?q?=E6=80=81=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增供应商可用性监控页面,支持热力图可视化 - 添加排序功能(按可用性/名称/请求数),默认按可用性排序 - 简化状态逻辑:移除"波动"状态,成功请求=绿色,失败请求=红色 - 响应式热力图宽度,自动适配时间跨度 - 支持5种语言的i18n翻译 (zh-CN, en, ja, ru, zh-TW) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .env.example | 13 + messages/en/dashboard.json | 113 ++++ messages/en/settings.json | 83 +++ messages/ja/dashboard.json | 122 +++++ messages/ja/settings.json | 134 +++++ messages/ru/dashboard.json | 122 +++++ messages/ru/settings.json | 134 +++++ messages/zh-CN/dashboard.json | 113 ++++ messages/zh-CN/settings.json | 83 +++ messages/zh-TW/dashboard.json | 122 +++++ messages/zh-TW/settings.json | 83 +++ src/actions/providers.ts | 185 +++++++ .../_components/dashboard-header.tsx | 1 + .../_components/availability-view.tsx | 513 ++++++++++++++++++ .../[locale]/dashboard/availability/page.tsx | 58 ++ .../_components/forms/api-test-button.tsx | 431 ++------------- .../_components/forms/test-result-card.tsx | 511 +++++++++++++++++ src/app/api/availability/current/route.ts | 45 ++ src/app/api/availability/route.ts | 80 +++ src/instrumentation.ts | 18 + src/lib/availability/availability-service.ts | 443 +++++++++++++++ src/lib/availability/index.ts | 22 + src/lib/availability/types.ts | 161 ++++++ src/lib/circuit-breaker-probe.ts | 283 ++++++++++ src/lib/provider-testing/index.ts | 61 +++ .../parsers/anthropic-parser.ts | 93 ++++ .../provider-testing/parsers/codex-parser.ts | 142 +++++ .../provider-testing/parsers/gemini-parser.ts | 99 ++++ src/lib/provider-testing/parsers/index.ts | 56 ++ .../provider-testing/parsers/openai-parser.ts | 94 ++++ src/lib/provider-testing/test-service.ts | 267 +++++++++ src/lib/provider-testing/types.ts | 268 +++++++++ src/lib/provider-testing/utils/index.ts | 28 + .../provider-testing/utils/sse-collector.ts | 279 ++++++++++ .../provider-testing/utils/test-prompts.ts | 283 ++++++++++ .../validators/content-validator.ts | 144 +++++ .../validators/http-validator.ts | 96 ++++ src/lib/provider-testing/validators/index.ts | 17 + 38 files changed, 5426 insertions(+), 374 deletions(-) create mode 100644 src/app/[locale]/dashboard/availability/_components/availability-view.tsx create mode 100644 src/app/[locale]/dashboard/availability/page.tsx create mode 100644 src/app/[locale]/settings/providers/_components/forms/test-result-card.tsx create mode 100644 src/app/api/availability/current/route.ts create mode 100644 src/app/api/availability/route.ts create mode 100644 src/lib/availability/availability-service.ts create mode 100644 src/lib/availability/index.ts create mode 100644 src/lib/availability/types.ts create mode 100644 src/lib/circuit-breaker-probe.ts create mode 100644 src/lib/provider-testing/index.ts create mode 100644 src/lib/provider-testing/parsers/anthropic-parser.ts create mode 100644 src/lib/provider-testing/parsers/codex-parser.ts create mode 100644 src/lib/provider-testing/parsers/gemini-parser.ts create mode 100644 src/lib/provider-testing/parsers/index.ts create mode 100644 src/lib/provider-testing/parsers/openai-parser.ts create mode 100644 src/lib/provider-testing/test-service.ts create mode 100644 src/lib/provider-testing/types.ts create mode 100644 src/lib/provider-testing/utils/index.ts create mode 100644 src/lib/provider-testing/utils/sse-collector.ts create mode 100644 src/lib/provider-testing/utils/test-prompts.ts create mode 100644 src/lib/provider-testing/validators/content-validator.ts create mode 100644 src/lib/provider-testing/validators/http-validator.ts create mode 100644 src/lib/provider-testing/validators/index.ts diff --git a/.env.example b/.env.example index fe033f869..750f89076 100644 --- a/.env.example +++ b/.env.example @@ -51,6 +51,19 @@ STORE_SESSION_MESSAGES=false # 是否存储请求 messages 到 Redis # - 启用:适用于网络稳定环境,连续网络错误也应触发熔断保护,避免持续请求不可达的供应商 ENABLE_CIRCUIT_BREAKER_ON_NETWORK_ERRORS=false +# 智能探测配置 +# 功能说明:当熔断器处于 OPEN 状态时,定期探测供应商以实现更快恢复 +# - ENABLE_SMART_PROBING:是否启用智能探测(默认:false) +# - PROBE_INTERVAL_MS:探测周期间隔(毫秒,默认:30000 = 30秒) +# - PROBE_TIMEOUT_MS:单次探测超时时间(毫秒,默认:5000 = 5秒) +# 工作原理: +# - 定期检查处于 OPEN 状态的熔断器 +# - 使用轻量级测试请求探测供应商 +# - 探测成功则提前将熔断器转为 HALF_OPEN 状态 +ENABLE_SMART_PROBING=false +PROBE_INTERVAL_MS=30000 +PROBE_TIMEOUT_MS=5000 + # 多提供商类型支持(实验性功能) # - false (默认):仅支持 Claude、Codex类型供应商 # - true:支持 Gemini CLI、OpenAI Compatible 等其他类型 diff --git a/messages/en/dashboard.json b/messages/en/dashboard.json index 0a7ef652c..0f74e6e5c 100644 --- a/messages/en/dashboard.json +++ b/messages/en/dashboard.json @@ -370,6 +370,7 @@ "dashboard": "Dashboard", "usageLogs": "Usage Logs", "leaderboard": "Leaderboard", + "availability": "Availability", "quotasManagement": "Quota Management", "userManagement": "User Management", "documentation": "Documentation", @@ -655,5 +656,117 @@ "groupFilter": "Filter by Group", "allGroups": "All Groups" } + }, + "availability": { + "title": "Provider Availability Monitor", + "description": "Real-time monitoring of provider availability and performance metrics", + "nav": "Availability Monitor", + "status": { + "green": "Healthy", + "red": "Unhealthy", + "unknown": "Unknown" + }, + "statusDescription": { + "green": "Service is healthy, requests successful", + "red": "Service unavailable or error", + "unknown": "No data available" + }, + "metrics": { + "systemAvailability": "System Availability", + "totalRequests": "Total Requests", + "successRate": "Success Rate", + "avgLatency": "Avg Latency", + "p50Latency": "P50 Latency", + "p95Latency": "P95 Latency", + "p99Latency": "P99 Latency", + "lastRequest": "Last Request", + "requestCount": "Request Count" + }, + "timeRange": { + "label": "Time Range", + "last15min": "Last 15 minutes", + "last1h": "Last 1 hour", + "last6h": "Last 6 hours", + "last24h": "Last 24 hours", + "last7d": "Last 7 days", + "custom": "Custom" + }, + "filters": { + "provider": "Provider", + "allProviders": "All Providers", + "includeDisabled": "Include Disabled" + }, + "sort": { + "label": "Sort By", + "availability": "Availability", + "name": "Name", + "requests": "Requests" + }, + "columns": { + "provider": "Provider", + "type": "Type", + "status": "Status", + "availability": "Availability", + "requests": "Requests", + "successRate": "Success Rate", + "avgLatency": "Avg Latency", + "lastRequest": "Last Request", + "actions": "Actions" + }, + "chart": { + "title": "Availability Trend", + "description": "Availability changes over time periods", + "availabilityScore": "Availability Score", + "requestVolume": "Request Volume", + "latencyTrend": "Latency Trend", + "noData": "No Data" + }, + "details": { + "title": "Provider Details", + "overview": "Overview", + "timeBuckets": "Time Buckets", + "greenCount": "Successful Requests", + "redCount": "Failed Requests" + }, + "actions": { + "refresh": "Refresh", + "refreshing": "Refreshing...", + "autoRefresh": "Auto Refresh", + "stopAutoRefresh": "Stop Auto Refresh", + "viewDetails": "View Details", + "testProvider": "Test Provider" + }, + "states": { + "loading": "Loading...", + "error": "Load Failed", + "noProviders": "No Providers", + "noData": "No availability data", + "fetchFailed": "Failed to fetch availability data" + }, + "legend": { + "green": "Excellent (95%+ availability)", + "lime": "Good (80-95% availability)", + "orange": "Warning (50-80% availability)", + "red": "Unhealthy (<50% availability)", + "noData": "No Data" + }, + "summary": { + "title": "Availability Summary", + "healthyProviders": "Healthy Providers", + "unhealthyProviders": "Unhealthy Providers", + "unknownProviders": "No Data", + "totalProviders": "Total Providers" + }, + "heatmap": { + "bucketSize": "Bucket Size", + "minutes": "min", + "requests": "requests", + "noData": "No Data", + "noRequests": "No Requests" + }, + "toast": { + "refreshSuccess": "Availability data refreshed", + "refreshFailed": "Refresh failed, please retry" + } } } diff --git a/messages/en/settings.json b/messages/en/settings.json index a07645052..0433c4b83 100644 --- a/messages/en/settings.json +++ b/messages/en/settings.json @@ -644,6 +644,89 @@ "resultReference": "[IMPORTANT] Results may vary by provider and are for reference only", "realRequest": "This test sends a real request to the provider and may consume a small quota", "confirmConfig": "Please verify provider URL, API key, and model configuration" + }, + "resultCard": { + "status": { + "green": "Available", + "yellow": "Degraded", + "red": "Unavailable" + }, + "dialogTitle": "Provider Test Details", + "validation": { + "title": "Three-tier Validation Details", + "http": { + "title": "Tier 1: HTTP Status", + "statusCode": "Status Code", + "passed": "2xx/3xx Success", + "failed": "4xx/5xx Failed" + }, + "latency": { + "title": "Tier 2: Latency Threshold", + "actual": "Actual Latency", + "passed": "Within threshold", + "failed": "Exceeded threshold" + }, + "content": { + "title": "Tier 3: Content Validation", + "target": "Target", + "passed": "Contains target string", + "failed": "Target not found" + }, + "passed": "Passed", + "failed": "Failed", + "timeout": "Timeout" + }, + "labels": { + "http": "HTTP", + "latency": "Latency", + "content": "Content", + "model": "Model", + "firstByte": "First Byte", + "totalLatency": "Total Latency", + "error": "Error", + "responsePreview": "Response Preview" + }, + "timing": { + "title": "Timing Info", + "totalLatency": "Total Latency", + "firstByte": "First Byte", + "testedAt": "Tested At" + }, + "tokenUsage": { + "title": "Token Usage", + "input": "Input", + "output": "Output", + "cacheCreation": "Cache Creation", + "cacheRead": "Cache Read" + }, + "streamInfo": { + "title": "Stream Response Info", + "isStreaming": "Streaming", + "chunksCount": "Chunks Count", + "yes": "Yes", + "no": "No" + }, + "errorDetails": { + "title": "Error Details", + "type": "Error Type" + }, + "copyText": { + "status": "Status", + "message": "Message", + "latency": "Latency", + "httpStatus": "HTTP Status", + "model": "Model", + "usage": "Usage", + "inputOutput": "Input {input} / Output {output} tokens", + "response": "Response", + "error": "Error", + "testedAt": "Tested At", + "validationDetails": "Validation Details", + "httpCheck": "HTTP Check", + "latencyCheck": "Latency Check", + "contentCheck": "Content Check" + }, + "judgment": "Judgment" } }, "proxyTest": { diff --git a/messages/ja/dashboard.json b/messages/ja/dashboard.json index 2dcb6c21c..00809fa84 100644 --- a/messages/ja/dashboard.json +++ b/messages/ja/dashboard.json @@ -370,6 +370,7 @@ "dashboard": "ダッシュボード", "usageLogs": "使用ログ", "leaderboard": "ランキング", + "availability": "可用性監視", "quotasManagement": "クォータ管理", "userManagement": "ユーザー管理", "documentation": "ドキュメント", @@ -645,5 +646,126 @@ "delete": "ユーザーを削除", "editAriaLabel": "ユーザーを編集", "deleteAriaLabel": "ユーザーを削除" + }, + "users": { + "title": "ユーザー管理", + "description": "{count} 人のユーザーを表示中", + "toolbar": { + "searchPlaceholder": "ユーザー名で検索...", + "groupFilter": "グループでフィルター", + "allGroups": "すべてのグループ" + } + }, + "availability": { + "title": "プロバイダー可用性モニター", + "description": "プロバイダーの可用性とパフォーマンス指標をリアルタイムで監視", + "nav": "可用性モニター", + "status": { + "green": "正常", + "red": "異常", + "unknown": "不明" + }, + "statusDescription": { + "green": "サービスは正常、リクエスト成功", + "red": "サービスが利用不可またはエラー", + "unknown": "データがありません" + }, + "metrics": { + "systemAvailability": "システム可用性", + "totalRequests": "総リクエスト数", + "successRate": "成功率", + "avgLatency": "平均遅延", + "p50Latency": "P50 遅延", + "p95Latency": "P95 遅延", + "p99Latency": "P99 遅延", + "lastRequest": "最終リクエスト", + "requestCount": "リクエスト数" + }, + "timeRange": { + "label": "時間範囲", + "last15min": "過去15分", + "last1h": "過去1時間", + "last6h": "過去6時間", + "last24h": "過去24時間", + "last7d": "過去7日間", + "custom": "カスタム" + }, + "filters": { + "provider": "プロバイダー", + "allProviders": "すべてのプロバイダー", + "includeDisabled": "無効を含む" + }, + "sort": { + "label": "並び替え", + "availability": "可用性", + "name": "名前", + "requests": "リクエスト数" + }, + "columns": { + "provider": "プロバイダー", + "type": "タイプ", + "status": "ステータス", + "availability": "可用性", + "requests": "リクエスト", + "successRate": "成功率", + "avgLatency": "平均遅延", + "lastRequest": "最終リクエスト", + "actions": "操作" + }, + "chart": { + "title": "可用性トレンド", + "description": "時間経過による可用性の変化", + "availabilityScore": "可用性スコア", + "requestVolume": "リクエスト量", + "latencyTrend": "遅延トレンド", + "noData": "データなし" + }, + "details": { + "title": "プロバイダー詳細", + "overview": "概要", + "timeBuckets": "時間バケット", + "greenCount": "成功リクエスト", + "redCount": "失敗リクエスト" + }, + "actions": { + "refresh": "更新", + "refreshing": "更新中...", + "autoRefresh": "自動更新", + "stopAutoRefresh": "自動更新を停止", + "viewDetails": "詳細を表示", + "testProvider": "プロバイダーをテスト" + }, + "states": { + "loading": "読み込み中...", + "error": "読み込み失敗", + "noProviders": "プロバイダーなし", + "noData": "可用性データなし", + "fetchFailed": "可用性データの取得に失敗しました" + }, + "legend": { + "green": "優秀 (可用性 95%+)", + "lime": "良好 (可用性 80-95%)", + "orange": "警告 (可用性 50-80%)", + "red": "異常 (可用性 <50%)", + "noData": "データなし" + }, + "summary": { + "title": "可用性サマリー", + "healthyProviders": "正常なプロバイダー", + "unhealthyProviders": "異常なプロバイダー", + "unknownProviders": "データなし", + "totalProviders": "プロバイダー総数" + }, + "heatmap": { + "bucketSize": "バケットサイズ", + "minutes": "分", + "requests": "リクエスト", + "noData": "データなし", + "noRequests": "リクエストなし" + }, + "toast": { + "refreshSuccess": "可用性データを更新しました", + "refreshFailed": "更新に失敗しました。再試行してください" + } } } diff --git a/messages/ja/settings.json b/messages/ja/settings.json index 184f14a4a..37cde574a 100644 --- a/messages/ja/settings.json +++ b/messages/ja/settings.json @@ -542,6 +542,140 @@ "proxyError": "プロキシエラー:", "networkError": "ネットワークエラー:" }, + "apiTest": { + "fillUrlFirst": "まずプロバイダーURLを入力してください", + "invalidUrl": "プロバイダーURLが無効です(http/httpsのみ対応)", + "fillKeyFirst": "まずAPIキーを入力してください", + "testFailed": "テスト失敗", + "testFailedRetry": "テスト失敗、再試行してください", + "noResult": "テスト成功ですが結果が返されませんでした", + "testSuccess": "モデルテスト成功", + "testApi": "プロバイダーモデルテスト", + "testing": "テスト中...", + "apiFormat": "プロバイダータイプ", + "selectApiFormat": "テストするプロバイダータイプを選択", + "apiFormatDesc": "手動で変更しない限り、ルーティング設定のプロバイダータイプと同期", + "formatAnthropicMessages": "Claude (Anthropic Messages API)", + "formatOpenAIChat": "OpenAI Compatible", + "formatOpenAIResponses": "Codex (Response API)", + "testModel": "テストモデル", + "testModelDesc": "空欄の場合はデフォルトモデルを使用、手動入力も可能", + "model": "モデル", + "responseModel": "応答モデル", + "responseTime": "応答時間", + "usage": "トークン使用量", + "response": "応答内容", + "error": "エラーメッセージ", + "unknown": "不明", + "viewDetails": "詳細を見る", + "copySuccess": "クリップボードにコピーしました", + "copyFailed": "コピー失敗", + "copyResult": "結果をコピー", + "close": "閉じる", + "success": "成功", + "failed": "失敗", + "streamInfo": "ストリーム応答情報", + "chunksReceived": "受信したチャンク", + "streamFormat": "ストリーム形式", + "streamResponse": "ストリーム応答", + "chunksCount": "{count} チャンク受信 ({format})", + "truncatedPreview": "先頭 {length} 文字を表示、全文はコピーして確認", + "truncatedBrief": "先頭 {length} 文字を表示、全文は「詳細を見る」をクリック", + "copyFormat": { + "testResult": "テスト結果", + "message": "メッセージ", + "errorDetails": "エラー詳細" + }, + "disclaimer": { + "title": "注意", + "realRequest": "テストはプロバイダーに実際のリクエストを送信し、少量のクォータを消費する可能性があります", + "resultReference": "プロバイダーによって結果が異なる場合があり、参考用です", + "confirmConfig": "プロバイダーURL、APIキー、モデル設定を確認してください" + }, + "resultCard": { + "status": { + "green": "利用可能", + "yellow": "不安定", + "red": "利用不可" + }, + "dialogTitle": "プロバイダーテスト詳細", + "validation": { + "title": "三層検証詳細", + "http": { + "title": "Tier 1: HTTPステータス", + "statusCode": "ステータスコード", + "passed": "2xx/3xx 成功", + "failed": "4xx/5xx 失敗" + }, + "latency": { + "title": "Tier 2: レイテンシ閾値", + "actual": "実際のレイテンシ", + "passed": "閾値内", + "failed": "閾値超過" + }, + "content": { + "title": "Tier 3: コンテンツ検証", + "target": "ターゲット", + "passed": "ターゲット文字列を含む", + "failed": "ターゲットが見つかりません" + }, + "passed": "合格", + "failed": "失敗", + "timeout": "タイムアウト" + }, + "labels": { + "http": "HTTP", + "latency": "レイテンシ", + "content": "コンテンツ", + "model": "モデル", + "firstByte": "最初のバイト", + "totalLatency": "合計レイテンシ", + "error": "エラー", + "responsePreview": "応答プレビュー" + }, + "timing": { + "title": "タイミング情報", + "totalLatency": "合計レイテンシ", + "firstByte": "最初のバイト", + "testedAt": "テスト日時" + }, + "tokenUsage": { + "title": "トークン使用量", + "input": "入力", + "output": "出力", + "cacheCreation": "キャッシュ作成", + "cacheRead": "キャッシュ読取" + }, + "streamInfo": { + "title": "ストリーム応答情報", + "isStreaming": "ストリーミング", + "chunksCount": "チャンク数", + "yes": "はい", + "no": "いいえ" + }, + "errorDetails": { + "title": "エラー詳細", + "type": "エラータイプ" + }, + "copyText": { + "status": "ステータス", + "message": "メッセージ", + "latency": "レイテンシ", + "httpStatus": "HTTPステータス", + "model": "モデル", + "usage": "使用量", + "inputOutput": "入力 {input} / 出力 {output} トークン", + "response": "応答", + "error": "エラー", + "testedAt": "テスト日時", + "validationDetails": "検証詳細", + "httpCheck": "HTTPチェック", + "latencyCheck": "レイテンシチェック", + "contentCheck": "コンテンツ検証" + }, + "judgment": "判定" + } + }, "urlPreview": { "title": "URL結合プレビュー", "invalidUrl": "無効なURL形式", diff --git a/messages/ru/dashboard.json b/messages/ru/dashboard.json index d247b87cf..d33bfac14 100644 --- a/messages/ru/dashboard.json +++ b/messages/ru/dashboard.json @@ -369,6 +369,7 @@ "dashboard": "Панель", "usageLogs": "Журналы", "leaderboard": "Лидеры", + "availability": "Доступность", "quotasManagement": "Квоты", "userManagement": "Пользователи", "documentation": "Документация", @@ -644,5 +645,126 @@ "delete": "Удалить пользователя", "editAriaLabel": "Редактировать пользователя", "deleteAriaLabel": "Удалить пользователя" + }, + "users": { + "title": "Управление пользователями", + "description": "Показано {count} пользователей", + "toolbar": { + "searchPlaceholder": "Поиск по имени пользователя...", + "groupFilter": "Фильтр по группе", + "allGroups": "Все группы" + } + }, + "availability": { + "title": "Мониторинг доступности провайдеров", + "description": "Мониторинг доступности и показателей производительности провайдеров в реальном времени", + "nav": "Мониторинг доступности", + "status": { + "green": "Здоров", + "red": "Недоступен", + "unknown": "Неизвестно" + }, + "statusDescription": { + "green": "Сервис работает нормально, запросы успешны", + "red": "Сервис недоступен или ошибка", + "unknown": "Данные отсутствуют" + }, + "metrics": { + "systemAvailability": "Доступность системы", + "totalRequests": "Всего запросов", + "successRate": "Успешность", + "avgLatency": "Средняя задержка", + "p50Latency": "P50 задержка", + "p95Latency": "P95 задержка", + "p99Latency": "P99 задержка", + "lastRequest": "Последний запрос", + "requestCount": "Кол-во запросов" + }, + "timeRange": { + "label": "Временной диапазон", + "last15min": "Последние 15 минут", + "last1h": "Последний час", + "last6h": "Последние 6 часов", + "last24h": "Последние 24 часа", + "last7d": "Последние 7 дней", + "custom": "Настраиваемый" + }, + "filters": { + "provider": "Провайдер", + "allProviders": "Все провайдеры", + "includeDisabled": "Включить отключённые" + }, + "sort": { + "label": "Сортировка", + "availability": "Доступность", + "name": "Название", + "requests": "Запросы" + }, + "columns": { + "provider": "Провайдер", + "type": "Тип", + "status": "Статус", + "availability": "Доступность", + "requests": "Запросы", + "successRate": "Успешность", + "avgLatency": "Средняя задержка", + "lastRequest": "Последний запрос", + "actions": "Действия" + }, + "chart": { + "title": "Тренд доступности", + "description": "Изменение доступности во времени", + "availabilityScore": "Оценка доступности", + "requestVolume": "Объём запросов", + "latencyTrend": "Тренд задержки", + "noData": "Нет данных" + }, + "details": { + "title": "Детали провайдера", + "overview": "Обзор", + "timeBuckets": "Временные интервалы", + "greenCount": "Успешные запросы", + "redCount": "Неудачные запросы" + }, + "actions": { + "refresh": "Обновить", + "refreshing": "Обновление...", + "autoRefresh": "Автообновление", + "stopAutoRefresh": "Остановить автообновление", + "viewDetails": "Подробнее", + "testProvider": "Тестировать провайдера" + }, + "states": { + "loading": "Загрузка...", + "error": "Ошибка загрузки", + "noProviders": "Нет провайдеров", + "noData": "Нет данных о доступности", + "fetchFailed": "Не удалось получить данные о доступности" + }, + "legend": { + "green": "Отлично (доступность 95%+)", + "lime": "Хорошо (доступность 80-95%)", + "orange": "Внимание (доступность 50-80%)", + "red": "Недоступен (доступность <50%)", + "noData": "Нет данных" + }, + "summary": { + "title": "Сводка доступности", + "healthyProviders": "Здоровые провайдеры", + "unhealthyProviders": "Недоступные провайдеры", + "unknownProviders": "Нет данных", + "totalProviders": "Всего провайдеров" + }, + "heatmap": { + "bucketSize": "Размер интервала", + "minutes": "мин", + "requests": "запросов", + "noData": "Нет данных", + "noRequests": "Нет запросов" + }, + "toast": { + "refreshSuccess": "Данные о доступности обновлены", + "refreshFailed": "Обновление не удалось, попробуйте снова" + } } } diff --git a/messages/ru/settings.json b/messages/ru/settings.json index bbabf814e..0d3e347b8 100644 --- a/messages/ru/settings.json +++ b/messages/ru/settings.json @@ -542,6 +542,140 @@ "proxyError": "Ошибка прокси:", "networkError": "Сетевая ошибка:" }, + "apiTest": { + "fillUrlFirst": "Пожалуйста, сначала заполните URL провайдера", + "invalidUrl": "URL провайдера недействителен (только http/https)", + "fillKeyFirst": "Пожалуйста, сначала заполните API ключ", + "testFailed": "Тест не пройден", + "testFailedRetry": "Тест не пройден, попробуйте снова", + "noResult": "Тест успешен, но результат не возвращен", + "testSuccess": "Тест модели успешен", + "testApi": "Тест модели провайдера", + "testing": "Тестирование...", + "apiFormat": "Тип провайдера", + "selectApiFormat": "Выберите тип провайдера для тестирования", + "apiFormatDesc": "По умолчанию синхронизируется с типом провайдера в конфигурации маршрутизации", + "formatAnthropicMessages": "Claude (Anthropic Messages API)", + "formatOpenAIChat": "OpenAI Compatible", + "formatOpenAIResponses": "Codex (Response API)", + "testModel": "Тестовая модель", + "testModelDesc": "Оставьте пустым для использования модели по умолчанию или введите вручную", + "model": "Модель", + "responseModel": "Модель ответа", + "responseTime": "Время ответа", + "usage": "Использование токенов", + "response": "Предварительный просмотр ответа", + "error": "Сообщение об ошибке", + "unknown": "Неизвестно", + "viewDetails": "Подробнее", + "copySuccess": "Скопировано в буфер обмена", + "copyFailed": "Не удалось скопировать", + "copyResult": "Копировать результат", + "close": "Закрыть", + "success": "Успешно", + "failed": "Не удалось", + "streamInfo": "Информация о потоковом ответе", + "chunksReceived": "Полученные чанки", + "streamFormat": "Формат потока", + "streamResponse": "Потоковый ответ", + "chunksCount": "Получено {count} чанков ({format})", + "truncatedPreview": "Показаны первые {length} символов, скопируйте для просмотра полного текста", + "truncatedBrief": "Показаны первые {length} символов, нажмите «Подробнее» для полного просмотра", + "copyFormat": { + "testResult": "Результат теста", + "message": "Сообщение", + "errorDetails": "Детали ошибки" + }, + "disclaimer": { + "title": "Внимание", + "realRequest": "Этот тест отправляет реальный запрос провайдеру и может потреблять небольшую квоту", + "resultReference": "Результаты могут варьироваться в зависимости от провайдера и служат только для справки", + "confirmConfig": "Пожалуйста, проверьте URL провайдера, API ключ и конфигурацию модели" + }, + "resultCard": { + "status": { + "green": "Доступен", + "yellow": "Нестабильно", + "red": "Недоступен" + }, + "dialogTitle": "Детали теста провайдера", + "validation": { + "title": "Детали трехуровневой валидации", + "http": { + "title": "Уровень 1: HTTP статус", + "statusCode": "Код статуса", + "passed": "2xx/3xx успех", + "failed": "4xx/5xx ошибка" + }, + "latency": { + "title": "Уровень 2: Порог задержки", + "actual": "Фактическая задержка", + "passed": "В пределах порога", + "failed": "Превышен порог" + }, + "content": { + "title": "Уровень 3: Валидация контента", + "target": "Цель", + "passed": "Содержит целевую строку", + "failed": "Цель не найдена" + }, + "passed": "Пройдено", + "failed": "Не пройдено", + "timeout": "Тайм-аут" + }, + "labels": { + "http": "HTTP", + "latency": "Задержка", + "content": "Контент", + "model": "Модель", + "firstByte": "Первый байт", + "totalLatency": "Общая задержка", + "error": "Ошибка", + "responsePreview": "Предпросмотр ответа" + }, + "timing": { + "title": "Информация о времени", + "totalLatency": "Общая задержка", + "firstByte": "Первый байт", + "testedAt": "Время теста" + }, + "tokenUsage": { + "title": "Использование токенов", + "input": "Ввод", + "output": "Вывод", + "cacheCreation": "Создание кэша", + "cacheRead": "Чтение кэша" + }, + "streamInfo": { + "title": "Информация о потоковом ответе", + "isStreaming": "Потоковая передача", + "chunksCount": "Количество чанков", + "yes": "Да", + "no": "Нет" + }, + "errorDetails": { + "title": "Детали ошибки", + "type": "Тип ошибки" + }, + "copyText": { + "status": "Статус", + "message": "Сообщение", + "latency": "Задержка", + "httpStatus": "HTTP статус", + "model": "Модель", + "usage": "Использование", + "inputOutput": "Ввод {input} / Вывод {output} токенов", + "response": "Ответ", + "error": "Ошибка", + "testedAt": "Время теста", + "validationDetails": "Детали валидации", + "httpCheck": "Проверка HTTP", + "latencyCheck": "Проверка задержки", + "contentCheck": "Проверка контента" + }, + "judgment": "Решение" + } + }, "urlPreview": { "title": "Предварительный просмотр URL", "invalidUrl": "Неверный формат URL", diff --git a/messages/zh-CN/dashboard.json b/messages/zh-CN/dashboard.json index ab07c666a..7c6f5fbb0 100644 --- a/messages/zh-CN/dashboard.json +++ b/messages/zh-CN/dashboard.json @@ -372,6 +372,7 @@ "userManagement": "用户管理", "usageLogs": "使用记录", "leaderboard": "排行榜", + "availability": "可用性监控", "quotasManagement": "限额管理", "documentation": "文档", "systemSettings": "系统设置", @@ -725,5 +726,117 @@ "groupFilter": "按分组筛选", "allGroups": "所有分组" } + }, + "availability": { + "title": "供应商可用性监控", + "description": "实时监控供应商的可用性状态和性能指标", + "nav": "可用性监控", + "status": { + "green": "正常", + "red": "异常", + "unknown": "未知" + }, + "statusDescription": { + "green": "服务正常,请求成功", + "red": "服务异常或不可用", + "unknown": "暂无数据" + }, + "metrics": { + "systemAvailability": "系统可用性", + "totalRequests": "总请求数", + "successRate": "成功率", + "avgLatency": "平均延迟", + "p50Latency": "P50 延迟", + "p95Latency": "P95 延迟", + "p99Latency": "P99 延迟", + "lastRequest": "最后请求", + "requestCount": "请求数" + }, + "timeRange": { + "label": "时间范围", + "last15min": "最近 15 分钟", + "last1h": "最近 1 小时", + "last6h": "最近 6 小时", + "last24h": "最近 24 小时", + "last7d": "最近 7 天", + "custom": "自定义" + }, + "filters": { + "provider": "供应商", + "allProviders": "全部供应商", + "includeDisabled": "包含已禁用" + }, + "sort": { + "label": "排序", + "availability": "可用性", + "name": "名称", + "requests": "请求数" + }, + "columns": { + "provider": "供应商", + "type": "类型", + "status": "状态", + "availability": "可用性", + "requests": "请求数", + "successRate": "成功率", + "avgLatency": "平均延迟", + "lastRequest": "最后请求", + "actions": "操作" + }, + "chart": { + "title": "可用性趋势", + "description": "按时间段统计的可用性变化", + "availabilityScore": "可用性评分", + "requestVolume": "请求量", + "latencyTrend": "延迟趋势", + "noData": "暂无数据" + }, + "details": { + "title": "供应商详情", + "overview": "概览", + "timeBuckets": "时间分段", + "greenCount": "成功请求", + "redCount": "失败请求" + }, + "actions": { + "refresh": "刷新", + "refreshing": "刷新中...", + "autoRefresh": "自动刷新", + "stopAutoRefresh": "停止自动刷新", + "viewDetails": "查看详情", + "testProvider": "测试供应商" + }, + "states": { + "loading": "加载中...", + "error": "加载失败", + "noProviders": "暂无供应商", + "noData": "暂无可用性数据", + "fetchFailed": "获取可用性数据失败" + }, + "legend": { + "green": "优秀 (可用性 95%+)", + "lime": "良好 (可用性 80-95%)", + "orange": "警告 (可用性 50-80%)", + "red": "异常 (可用性 <50%)", + "noData": "无数据" + }, + "summary": { + "title": "可用性摘要", + "healthyProviders": "健康供应商", + "unhealthyProviders": "异常供应商", + "unknownProviders": "无数据", + "totalProviders": "供应商总数" + }, + "heatmap": { + "bucketSize": "时间分段", + "minutes": "分钟", + "requests": "请求", + "noData": "无数据", + "noRequests": "无请求" + }, + "toast": { + "refreshSuccess": "可用性数据已刷新", + "refreshFailed": "刷新失败,请重试" + } } } diff --git a/messages/zh-CN/settings.json b/messages/zh-CN/settings.json index 0386a225e..b50773839 100644 --- a/messages/zh-CN/settings.json +++ b/messages/zh-CN/settings.json @@ -270,6 +270,89 @@ "resultReference": "【重要】因各家供应商情况不同,测试结果仅供参考,不代表实际调用效果", "realRequest": "测试将向供应商发送真实请求,可能消耗少量额度", "confirmConfig": "请确认供应商 URL、API 密钥及模型配置正确" + }, + "resultCard": { + "status": { + "green": "可用", + "yellow": "波动", + "red": "不可用" + }, + "dialogTitle": "供应商测试详情", + "validation": { + "title": "三层验证详情", + "http": { + "title": "Tier 1: HTTP 状态", + "statusCode": "状态码", + "passed": "2xx/3xx 成功", + "failed": "4xx/5xx 失败" + }, + "latency": { + "title": "Tier 2: 延迟阈值", + "actual": "实际延迟", + "passed": "在阈值内", + "failed": "超过阈值" + }, + "content": { + "title": "Tier 3: 内容验证", + "target": "目标", + "passed": "包含目标字符串", + "failed": "未找到目标" + }, + "passed": "通过", + "failed": "失败", + "timeout": "超时" + }, + "labels": { + "http": "HTTP", + "latency": "延迟", + "content": "内容", + "model": "模型", + "firstByte": "首字节", + "totalLatency": "总延迟", + "error": "错误", + "responsePreview": "响应预览" + }, + "timing": { + "title": "时间信息", + "totalLatency": "总延迟", + "firstByte": "首字节", + "testedAt": "测试时间" + }, + "tokenUsage": { + "title": "Token 用量", + "input": "输入", + "output": "输出", + "cacheCreation": "缓存创建", + "cacheRead": "缓存读取" + }, + "streamInfo": { + "title": "流式响应信息", + "isStreaming": "流式响应", + "chunksCount": "数据块数", + "yes": "是", + "no": "否" + }, + "errorDetails": { + "title": "错误详情", + "type": "错误类型" + }, + "copyText": { + "status": "状态", + "message": "消息", + "latency": "延迟", + "httpStatus": "HTTP 状态", + "model": "模型", + "usage": "用量", + "inputOutput": "输入 {input} / 输出 {output} tokens", + "response": "响应", + "error": "错误", + "testedAt": "测试时间", + "validationDetails": "验证详情", + "httpCheck": "HTTP 检查", + "latencyCheck": "延迟检查", + "contentCheck": "内容验证" + }, + "judgment": "判定" } }, "urlPreview": { diff --git a/messages/zh-TW/dashboard.json b/messages/zh-TW/dashboard.json index a25150ac6..a54704f20 100644 --- a/messages/zh-TW/dashboard.json +++ b/messages/zh-TW/dashboard.json @@ -371,6 +371,7 @@ "dashboard": "儀表板", "usageLogs": "使用記錄", "leaderboard": "排行榜", + "availability": "可用性監控", "quotasManagement": "額度管理", "userManagement": "使用者管理", "documentation": "文件", @@ -646,5 +647,126 @@ "delete": "刪除使用者", "editAriaLabel": "編輯使用者", "deleteAriaLabel": "刪除使用者" + }, + "users": { + "title": "使用者管理", + "description": "顯示 {count} 位使用者", + "toolbar": { + "searchPlaceholder": "依使用者名稱搜尋...", + "groupFilter": "依群組篩選", + "allGroups": "所有群組" + } + }, + "availability": { + "title": "供應商可用性監控", + "description": "即時監控供應商的可用性狀態和效能指標", + "nav": "可用性監控", + "status": { + "green": "正常", + "red": "異常", + "unknown": "未知" + }, + "statusDescription": { + "green": "服務正常,請求成功", + "red": "服務異常或不可用", + "unknown": "暫無資料" + }, + "metrics": { + "systemAvailability": "系統可用性", + "totalRequests": "總請求數", + "successRate": "成功率", + "avgLatency": "平均延遲", + "p50Latency": "P50 延遲", + "p95Latency": "P95 延遲", + "p99Latency": "P99 延遲", + "lastRequest": "最後請求", + "requestCount": "請求數" + }, + "timeRange": { + "label": "時間範圍", + "last15min": "最近 15 分鐘", + "last1h": "最近 1 小時", + "last6h": "最近 6 小時", + "last24h": "最近 24 小時", + "last7d": "最近 7 天", + "custom": "自訂" + }, + "filters": { + "provider": "供應商", + "allProviders": "全部供應商", + "includeDisabled": "包含已停用" + }, + "sort": { + "label": "排序", + "availability": "可用性", + "name": "名稱", + "requests": "請求數" + }, + "columns": { + "provider": "供應商", + "type": "類型", + "status": "狀態", + "availability": "可用性", + "requests": "請求數", + "successRate": "成功率", + "avgLatency": "平均延遲", + "lastRequest": "最後請求", + "actions": "操作" + }, + "chart": { + "title": "可用性趨勢", + "description": "依時間段統計的可用性變化", + "availabilityScore": "可用性評分", + "requestVolume": "請求量", + "latencyTrend": "延遲趨勢", + "noData": "暫無資料" + }, + "details": { + "title": "供應商詳情", + "overview": "概覽", + "timeBuckets": "時間分段", + "greenCount": "成功請求", + "redCount": "失敗請求" + }, + "actions": { + "refresh": "重新整理", + "refreshing": "重新整理中...", + "autoRefresh": "自動重新整理", + "stopAutoRefresh": "停止自動重新整理", + "viewDetails": "檢視詳情", + "testProvider": "測試供應商" + }, + "states": { + "loading": "載入中...", + "error": "載入失敗", + "noProviders": "暫無供應商", + "noData": "暫無可用性資料", + "fetchFailed": "取得可用性資料失敗" + }, + "legend": { + "green": "優秀 (可用性 95%+)", + "lime": "良好 (可用性 80-95%)", + "orange": "警告 (可用性 50-80%)", + "red": "異常 (可用性 <50%)", + "noData": "無資料" + }, + "summary": { + "title": "可用性摘要", + "healthyProviders": "健康供應商", + "unhealthyProviders": "異常供應商", + "unknownProviders": "無資料", + "totalProviders": "供應商總數" + }, + "heatmap": { + "bucketSize": "時間分段", + "minutes": "分鐘", + "requests": "請求", + "noData": "無資料", + "noRequests": "無請求" + }, + "toast": { + "refreshSuccess": "可用性資料已重新整理", + "refreshFailed": "重新整理失敗,請重試" + } } } diff --git a/messages/zh-TW/settings.json b/messages/zh-TW/settings.json index a94c43f3a..9da49d0a4 100644 --- a/messages/zh-TW/settings.json +++ b/messages/zh-TW/settings.json @@ -617,6 +617,89 @@ "resultReference": "【重要】因各家供應商情況不同,測試結果僅供參考,不代表實際呼叫效果", "realRequest": "測試將向供應商發送真實請求,可能消耗少量額度", "confirmConfig": "請確認供應商 URL、API 金鑰及模型設定正確" + }, + "resultCard": { + "status": { + "green": "可用", + "yellow": "波動", + "red": "不可用" + }, + "dialogTitle": "供應商測試詳情", + "validation": { + "title": "三層驗證詳情", + "http": { + "title": "Tier 1: HTTP 狀態", + "statusCode": "狀態碼", + "passed": "2xx/3xx 成功", + "failed": "4xx/5xx 失敗" + }, + "latency": { + "title": "Tier 2: 延遲閾值", + "actual": "實際延遲", + "passed": "在閾值內", + "failed": "超過閾值" + }, + "content": { + "title": "Tier 3: 內容驗證", + "target": "目標", + "passed": "包含目標字串", + "failed": "未找到目標" + }, + "passed": "通過", + "failed": "失敗", + "timeout": "逾時" + }, + "labels": { + "http": "HTTP", + "latency": "延遲", + "content": "內容", + "model": "模型", + "firstByte": "首位元組", + "totalLatency": "總延遲", + "error": "錯誤", + "responsePreview": "回應預覽" + }, + "timing": { + "title": "時間資訊", + "totalLatency": "總延遲", + "firstByte": "首位元組", + "testedAt": "測試時間" + }, + "tokenUsage": { + "title": "Token 用量", + "input": "輸入", + "output": "輸出", + "cacheCreation": "快取建立", + "cacheRead": "快取讀取" + }, + "streamInfo": { + "title": "串流回應資訊", + "isStreaming": "串流回應", + "chunksCount": "資料區塊數", + "yes": "是", + "no": "否" + }, + "errorDetails": { + "title": "錯誤詳情", + "type": "錯誤類型" + }, + "copyText": { + "status": "狀態", + "message": "訊息", + "latency": "延遲", + "httpStatus": "HTTP 狀態", + "model": "模型", + "usage": "用量", + "inputOutput": "輸入 {input} / 輸出 {output} tokens", + "response": "回應", + "error": "錯誤", + "testedAt": "測試時間", + "validationDetails": "驗證詳情", + "httpCheck": "HTTP 檢查", + "latencyCheck": "延遲檢查", + "contentCheck": "內容驗證" + }, + "judgment": "判定" } }, "urlPreview": { diff --git a/src/actions/providers.ts b/src/actions/providers.ts index eb35cc871..0bf73fb93 100644 --- a/src/actions/providers.ts +++ b/src/actions/providers.ts @@ -29,6 +29,12 @@ import { CodexInstructionsCache } from "@/lib/codex-instructions-cache"; import { isClientAbortError } from "@/app/v1/_lib/proxy/errors"; import { PROVIDER_TIMEOUT_DEFAULTS } from "@/lib/constants/provider.constants"; import { GeminiAuth } from "@/app/v1/_lib/gemini/auth"; +import { + executeProviderTest, + type ProviderTestConfig, + type TestStatus, + type TestSubStatus, +} from "@/lib/provider-testing"; const API_TEST_TIMEOUT_LIMITS = { DEFAULT: 15000, @@ -2199,3 +2205,182 @@ export async function testProviderGemini( } ); } + +// ============================================================================ +// Unified Provider Testing (relay-pulse style three-tier validation) +// ============================================================================ + +/** + * Arguments for unified provider testing + */ +export type UnifiedTestArgs = { + providerUrl: string; + apiKey: string; + providerType: ProviderType; + model?: string; + proxyUrl?: string | null; + proxyFallbackToDirect?: boolean; + /** Latency threshold in ms for YELLOW status (default: 5000) */ + latencyThresholdMs?: number; + /** String that must be present in response (default: type-specific) */ + successContains?: string; + /** Request timeout in ms (default: 10000) */ + timeoutMs?: number; +}; + +/** + * Result type for unified provider testing + * Includes three-tier validation details + */ +export type UnifiedTestResult = ActionResult<{ + success: boolean; + status: TestStatus; + subStatus: TestSubStatus; + message: string; + latencyMs: number; + firstByteMs?: number; + httpStatusCode?: number; + httpStatusText?: string; + model?: string; + content?: string; + usage?: { + inputTokens: number; + outputTokens: number; + cacheCreationInputTokens?: number; + cacheReadInputTokens?: number; + }; + streamInfo?: { + isStreaming: boolean; + chunksReceived?: number; + }; + errorMessage?: string; + errorType?: string; + testedAt: string; + validationDetails: { + httpPassed: boolean; + httpStatusCode?: number; + latencyPassed: boolean; + latencyMs?: number; + contentPassed: boolean; + contentTarget?: string; + }; +}>; + +/** + * Human-readable messages for sub-status + */ +const SUB_STATUS_MESSAGES: Record = { + success: "所有检查通过", + slow_latency: "响应成功但较慢", + rate_limit: "请求被限流 (429)", + server_error: "服务器错误 (5xx)", + client_error: "客户端错误 (4xx)", + auth_error: "认证失败 (401/403)", + invalid_request: "无效请求 (400)", + network_error: "网络连接失败", + content_mismatch: "响应内容验证失败", +}; + +/** + * Check if a URL is safe for API testing (SSRF prevention) + * Wraps validateProviderUrlForConnectivity with a simpler interface + */ +async function isUrlSafeForApiTest( + providerUrl: string +): Promise<{ safe: boolean; reason?: string }> { + const validation = validateProviderUrlForConnectivity(providerUrl); + if (validation.valid) { + return { safe: true }; + } + return { safe: false, reason: validation.error.message }; +} + +/** + * Unified provider testing with three-tier validation + * + * Validation tiers (from relay-pulse): + * 1. HTTP Status Code - 2xx/3xx = pass, 4xx/5xx = fail + * 2. Latency Threshold - Below threshold = GREEN, above = YELLOW + * 3. Content Validation - Response contains expected string + * + * Status meanings: + * - green: All validations passed + * - yellow: HTTP OK but slow (degraded) + * - red: Any validation failed + */ +export async function testProviderUnified( + data: UnifiedTestArgs +): Promise { + const session = await getSession(); + if (!session || session.user.role !== "admin") { + return { + ok: false, + error: "未授权", + }; + } + + // Validate URL + const urlValidation = await isUrlSafeForApiTest(data.providerUrl); + if (!urlValidation.safe) { + return { + ok: false, + error: urlValidation.reason ?? "无效的 URL", + }; + } + + try { + // Build test configuration + const config: ProviderTestConfig = { + providerUrl: data.providerUrl, + apiKey: data.apiKey, + providerType: data.providerType, + model: data.model, + proxyUrl: data.proxyUrl ?? undefined, + proxyFallbackToDirect: data.proxyFallbackToDirect, + latencyThresholdMs: data.latencyThresholdMs, + successContains: data.successContains, + timeoutMs: data.timeoutMs, + }; + + // Execute test + const result = await executeProviderTest(config); + + // Build response message + const statusText = + result.status === "green" + ? "可用" + : result.status === "yellow" + ? "波动" + : "不可用"; + + const message = `供应商 ${statusText}: ${SUB_STATUS_MESSAGES[result.subStatus]}`; + + return { + ok: true, + data: { + success: result.success, + status: result.status, + subStatus: result.subStatus, + message, + latencyMs: result.latencyMs, + firstByteMs: result.firstByteMs, + httpStatusCode: result.httpStatusCode, + httpStatusText: result.httpStatusText, + model: result.model, + content: result.content, + usage: result.usage, + streamInfo: result.streamInfo, + errorMessage: result.errorMessage, + errorType: result.errorType, + testedAt: result.testedAt.toISOString(), + validationDetails: result.validationDetails, + }, + }; + } catch (error) { + logger.error("testProviderUnified error", { error }); + return { + ok: false, + error: error instanceof Error ? error.message : "测试执行失败", + }; + } +} diff --git a/src/app/[locale]/dashboard/_components/dashboard-header.tsx b/src/app/[locale]/dashboard/_components/dashboard-header.tsx index 28d4c1bf1..2915e9063 100644 --- a/src/app/[locale]/dashboard/_components/dashboard-header.tsx +++ b/src/app/[locale]/dashboard/_components/dashboard-header.tsx @@ -21,6 +21,7 @@ export function DashboardHeader({ session }: DashboardHeaderProps) { { href: "/dashboard", label: t("dashboard") }, { href: "/dashboard/logs", label: t("usageLogs") }, { href: "/dashboard/leaderboard", label: t("leaderboard") }, + { href: "/dashboard/availability", label: t("availability"), adminOnly: true }, { href: "/dashboard/quotas", label: t("quotasManagement") }, { href: "/dashboard/users", label: t("userManagement") }, { href: "/usage-doc", label: t("documentation") }, diff --git a/src/app/[locale]/dashboard/availability/_components/availability-view.tsx b/src/app/[locale]/dashboard/availability/_components/availability-view.tsx new file mode 100644 index 000000000..aa128a99f --- /dev/null +++ b/src/app/[locale]/dashboard/availability/_components/availability-view.tsx @@ -0,0 +1,513 @@ +'use client'; + +import { useState, useEffect, useCallback, useMemo } from 'react'; +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { Badge } from '@/components/ui/badge'; +import { Skeleton } from '@/components/ui/skeleton'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/tooltip'; +import { RefreshCw, Activity, CheckCircle2, XCircle, HelpCircle } from 'lucide-react'; +import { useTranslations } from 'next-intl'; +import { cn } from '@/lib/utils'; +import type { + AvailabilityQueryResult, + ProviderAvailabilitySummary, + TimeBucketMetrics, +} from '@/lib/availability'; + +type TimeRangeOption = '15min' | '1h' | '6h' | '24h' | '7d'; +type SortOption = 'availability' | 'name' | 'requests'; + +// Target number of buckets to fill the heatmap width consistently +const TARGET_BUCKETS = 60; + +const TIME_RANGE_MAP: Record = { + '15min': 15 * 60 * 1000, + '1h': 60 * 60 * 1000, + '6h': 6 * 60 * 60 * 1000, + '24h': 24 * 60 * 60 * 1000, + '7d': 7 * 24 * 60 * 60 * 1000, +}; + +/** + * Calculate bucket size to achieve target bucket count + */ +function calculateBucketSize(timeRangeMs: number): number { + const bucketSizeMs = timeRangeMs / TARGET_BUCKETS; + const bucketSizeMinutes = bucketSizeMs / (60 * 1000); + // Round to reasonable precision (0.25 min = 15 seconds minimum) + return Math.max(0.25, Math.round(bucketSizeMinutes * 4) / 4); +} + +/** + * Get color class based on availability score + * Simple gradient: gray(no data) -> red -> green + */ +function getAvailabilityColor(score: number, hasData: boolean): string { + if (!hasData) return 'bg-slate-300 dark:bg-slate-600'; // Gray = no data + + if (score < 0.5) return 'bg-red-500'; + if (score < 0.8) return 'bg-orange-500'; + if (score < 0.95) return 'bg-lime-500'; + return 'bg-green-500'; +} + +/** + * Format bucket time for display in tooltip + */ +function formatBucketTime(isoString: string, bucketSizeMinutes: number): string { + const date = new Date(isoString); + if (bucketSizeMinutes >= 1440) { + // Daily buckets: show date + return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); + } + if (bucketSizeMinutes >= 60) { + // Hourly buckets: show date + hour + return date.toLocaleString(undefined, { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + }); + } + // Sub-hour buckets: show full time with seconds for precision + if (bucketSizeMinutes < 1) { + return date.toLocaleTimeString(undefined, { + hour: '2-digit', + minute: '2-digit', + second: '2-digit' + }); + } + // Minute buckets: show time + return date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }); +} + +/** + * Format bucket size for display + */ +function formatBucketSizeDisplay(minutes: number): string { + if (minutes >= 60) { + const hours = minutes / 60; + return hours === 1 ? '1 hour' : `${hours.toFixed(1)} hours`; + } + if (minutes >= 1) { + return `${Math.round(minutes)} min`; + } + const seconds = Math.round(minutes * 60); + return `${seconds} sec`; +} + +export function AvailabilityView() { + const t = useTranslations('dashboard.availability'); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [timeRange, setTimeRange] = useState('24h'); + const [sortBy, setSortBy] = useState('availability'); + const [refreshing, setRefreshing] = useState(false); + + const fetchData = useCallback(async () => { + try { + setRefreshing(true); + const now = new Date(); + const timeRangeMs = TIME_RANGE_MAP[timeRange]; + const startTime = new Date(now.getTime() - timeRangeMs); + const bucketSizeMinutes = calculateBucketSize(timeRangeMs); + + const params = new URLSearchParams({ + startTime: startTime.toISOString(), + endTime: now.toISOString(), + bucketSizeMinutes: bucketSizeMinutes.toString(), + maxBuckets: TARGET_BUCKETS.toString(), + }); + + const res = await fetch(`/api/availability?${params}`); + if (!res.ok) { + throw new Error(t('states.fetchFailed')); + } + + const result: AvailabilityQueryResult = await res.json(); + setData(result); + setError(null); + } catch (err) { + console.error('Failed to fetch availability data:', err); + setError(err instanceof Error ? err.message : t('states.fetchFailed')); + } finally { + setLoading(false); + setRefreshing(false); + } + }, [timeRange, t]); + + useEffect(() => { + fetchData(); + }, [fetchData]); + + // Generate unified time buckets for all providers + const unifiedBuckets = useMemo(() => { + if (!data) return []; + + const startTime = new Date(data.startTime); + const endTime = new Date(data.endTime); + const bucketSizeMs = data.bucketSizeMinutes * 60 * 1000; + + const buckets: string[] = []; + let current = new Date(Math.floor(startTime.getTime() / bucketSizeMs) * bucketSizeMs); + + while (current.getTime() < endTime.getTime()) { + buckets.push(current.toISOString()); + current = new Date(current.getTime() + bucketSizeMs); + } + + return buckets; + }, [data]); + + // Sort providers based on selected option + const sortedProviders = useMemo(() => { + if (!data?.providers) return []; + + return [...data.providers].sort((a, b) => { + switch (sortBy) { + case 'availability': + // Unknown status (no data) goes to the end + if (a.currentStatus === 'unknown' && b.currentStatus !== 'unknown') return 1; + if (b.currentStatus === 'unknown' && a.currentStatus !== 'unknown') return -1; + // Sort by availability ascending (worst first for monitoring) + return a.currentAvailability - b.currentAvailability; + case 'name': + return a.providerName.localeCompare(b.providerName); + case 'requests': + // Sort by requests descending (most active first) + return b.totalRequests - a.totalRequests; + default: + return 0; + } + }); + }, [data?.providers, sortBy]); + + const getStatusIcon = (status: string) => { + switch (status) { + case 'green': + return ; + case 'red': + return ; + case 'unknown': + return ; + default: + return ; + } + }; + + const getStatusBadge = (status: string) => { + const statusKey = status as 'green' | 'red' | 'unknown'; + const variants: Record = { + green: 'default', + red: 'destructive', + unknown: 'outline', + }; + return ( + + {getStatusIcon(status)} + {t(`status.${statusKey}`)} + + ); + }; + + const formatLatency = (ms: number) => { + if (ms < 1000) return `${Math.round(ms)}ms`; + return `${(ms / 1000).toFixed(2)}s`; + }; + + const formatPercentage = (value: number) => `${(value * 100).toFixed(1)}%`; + + // Summary counts + const getSummaryCounts = () => { + if (!data?.providers) return { healthy: 0, unhealthy: 0, unknown: 0, total: 0 }; + return { + healthy: data.providers.filter((p) => p.currentStatus === 'green').length, + unhealthy: data.providers.filter((p) => p.currentStatus === 'red').length, + unknown: data.providers.filter((p) => p.currentStatus === 'unknown').length, + total: data.providers.length, + }; + }; + + const summary = getSummaryCounts(); + + // Get bucket data for a provider at a specific time + const getBucketData = ( + provider: ProviderAvailabilitySummary, + bucketStart: string + ): TimeBucketMetrics | null => { + return ( + provider.timeBuckets.find((b) => b.bucketStart === bucketStart) || null + ); + }; + + if (loading) { + return ( +
+
+ {[...Array(4)].map((_, i) => ( + + + + + + + + + ))} +
+ + +
{t('states.loading')}
+
+
+
+ ); + } + + if (error) { + return ( + + +
{error}
+
+
+ ); + } + + return ( + +
+ {/* Summary Cards */} +
+ + + + {t('metrics.systemAvailability')} + + + +
+ {formatPercentage(data?.systemAvailability ?? 0)} +
+
+
+ + + + + {t('summary.healthyProviders')} + + + +
{summary.healthy}
+
+
+ + + + + {t('summary.unhealthyProviders')} + + + +
{summary.unhealthy}
+
+
+ + + + + {t('summary.unknownProviders')} + + + +
{summary.unknown}
+
+
+
+ + {/* Controls */} +
+
+ + + {data && ( + + {t('heatmap.bucketSize')}: {data.bucketSizeMinutes} {t('heatmap.minutes')} + + )} +
+ +
+ + {/* Heatmap */} + + + {t('chart.title')} + {t('chart.description')} + + + {!sortedProviders.length ? ( +
{t('states.noProviders')}
+ ) : ( +
+ {/* Provider rows with heatmap */} + {sortedProviders.map((provider) => ( +
+ {/* Provider name */} +
+ + {provider.providerName} + + {getStatusBadge(provider.currentStatus)} +
+ + {/* Heatmap cells - CSS grid for responsive width */} +
+
+ {unifiedBuckets.map((bucketStart) => { + const bucket = getBucketData(provider, bucketStart); + const hasData = bucket !== null && bucket.totalRequests > 0; + const score = hasData ? bucket.availabilityScore : 0; + + return ( + + +
+ + +
+
+ {formatBucketTime(bucketStart, data!.bucketSizeMinutes)} +
+ {hasData && bucket ? ( + <> +
+ {t('heatmap.requests')}: {bucket.totalRequests} +
+
+ {t('columns.availability')}: {formatPercentage(bucket.availabilityScore)} +
+
+ {t('columns.avgLatency')}: {formatLatency(bucket.avgLatencyMs)} +
+
+ + {t('details.greenCount')}: {bucket.greenCount} + + + {t('details.redCount')}: {bucket.redCount} + +
+ + ) : ( +
+ {t('heatmap.noData')} +
+ )} +
+
+ + ); + })} +
+
+ + {/* Summary stats */} +
+
+ {provider.currentStatus === 'unknown' + ? t('heatmap.noData') + : formatPercentage(provider.currentAvailability)} +
+
+ {provider.totalRequests > 0 + ? `${provider.totalRequests.toLocaleString()} ${t('heatmap.requests')}` + : t('heatmap.noRequests')} +
+
+
+ ))} +
+ )} + + + + {/* Legend */} + + +
+
+
+ {t('legend.green')} +
+
+
+ {t('legend.lime')} +
+
+
+ {t('legend.orange')} +
+
+
+ {t('legend.red')} +
+
+
+ {t('legend.noData')} +
+
+ + +
+ + ); +} diff --git a/src/app/[locale]/dashboard/availability/page.tsx b/src/app/[locale]/dashboard/availability/page.tsx new file mode 100644 index 000000000..780f36df8 --- /dev/null +++ b/src/app/[locale]/dashboard/availability/page.tsx @@ -0,0 +1,58 @@ +import { Section } from '@/components/section'; +import { AvailabilityView } from './_components/availability-view'; +import { getSession } from '@/lib/auth'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { AlertCircle } from 'lucide-react'; +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; +import { Link } from '@/i18n/routing'; +import { getTranslations } from 'next-intl/server'; + +export const dynamic = 'force-dynamic'; + +export default async function AvailabilityPage() { + const t = await getTranslations('dashboard'); + const session = await getSession(); + + // Only admin can access availability monitoring + const isAdmin = session?.user.role === 'admin'; + + if (!isAdmin) { + return ( +
+
+ + + + + {t('leaderboard.permission.title')} + + + + + + {t('leaderboard.permission.restricted')} + + {t('leaderboard.permission.userAction')} + + + + +
+
+ ); + } + + return ( +
+
+ +
+
+ ); +} diff --git a/src/app/[locale]/settings/providers/_components/forms/api-test-button.tsx b/src/app/[locale]/settings/providers/_components/forms/api-test-button.tsx index 52cf83ed0..dc92e4473 100644 --- a/src/app/[locale]/settings/providers/_components/forms/api-test-button.tsx +++ b/src/app/[locale]/settings/providers/_components/forms/api-test-button.tsx @@ -2,21 +2,9 @@ import { useEffect, useMemo, useState } from "react"; import { Button } from "@/components/ui/button"; -import { Loader2, CheckCircle2, XCircle, Activity, Copy, ExternalLink } from "lucide-react"; +import { Loader2, CheckCircle2, XCircle, Activity, AlertTriangle } from "lucide-react"; import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "@/components/ui/dialog"; -import { Badge } from "@/components/ui/badge"; -import { - testProviderAnthropicMessages, - testProviderOpenAIChatCompletions, - testProviderOpenAIResponses, - testProviderGemini, + testProviderUnified, getUnmaskedProviderKey, } from "@/actions/providers"; import { toast } from "sonner"; @@ -32,6 +20,7 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { isValidUrl } from "@/lib/utils/validation"; import type { ProviderType } from "@/types/provider"; +import { TestResultCard, type UnifiedTestResultData } from "./test-result-card"; type ApiFormat = "anthropic-messages" | "openai-chat" | "openai-responses" | "gemini"; @@ -117,22 +106,7 @@ export function ApiTestButton({ return whitelistDefault ?? getDefaultModelForFormat(initialApiFormat); }); const [isModelManuallyEdited, setIsModelManuallyEdited] = useState(false); - const [isDetailDialogOpen, setIsDetailDialogOpen] = useState(false); - const [testResult, setTestResult] = useState<{ - success: boolean; - message: string; - details?: { - responseTime?: number; - model?: string; - usage?: Record | string | number; - content?: string; - error?: string; - streamInfo?: { - chunksReceived: number; - format: "sse" | "ndjson"; - }; - }; - } | null>(null); + const [testResult, setTestResult] = useState(null); useEffect(() => { if (isApiFormatManuallySelected) return; @@ -152,6 +126,14 @@ export function ApiTestButton({ setTestModel(defaultModel); }, [apiFormat, isModelManuallyEdited, normalizedAllowedModels]); + // Map API format to provider type + const apiFormatToProviderType: Record = { + "anthropic-messages": providerType === "claude-auth" ? "claude-auth" : "claude", + "openai-chat": "openai-compatible", + "openai-responses": "codex", + gemini: providerType === "gemini-cli" ? "gemini-cli" : "gemini", + }; + const handleTest = async () => { // 验证必填字段 if (!providerUrl.trim()) { @@ -191,49 +173,15 @@ export function ApiTestButton({ return; } - let response; - - switch (apiFormat) { - case "anthropic-messages": - response = await testProviderAnthropicMessages({ - providerUrl: providerUrl.trim(), - apiKey: resolvedKey, - model: testModel.trim() || undefined, - proxyUrl: proxyUrl?.trim() || null, - proxyFallbackToDirect, - }); - break; - - case "openai-chat": - response = await testProviderOpenAIChatCompletions({ - providerUrl: providerUrl.trim(), - apiKey: resolvedKey, - model: testModel.trim() || undefined, - proxyUrl: proxyUrl?.trim() || null, - proxyFallbackToDirect, - }); - break; - - case "openai-responses": - response = await testProviderOpenAIResponses({ - providerUrl: providerUrl.trim(), - apiKey: resolvedKey, - model: testModel.trim() || undefined, - proxyUrl: proxyUrl?.trim() || null, - proxyFallbackToDirect, - }); - break; - - case "gemini": - response = await testProviderGemini({ - providerUrl: providerUrl.trim(), - apiKey: resolvedKey, - model: testModel.trim() || undefined, - proxyUrl: proxyUrl?.trim() || null, - proxyFallbackToDirect, - }); - break; - } + // Use unified testing service + const response = await testProviderUnified({ + providerUrl: providerUrl.trim(), + apiKey: resolvedKey, + providerType: apiFormatToProviderType[apiFormat], + model: testModel.trim() || undefined, + proxyUrl: proxyUrl?.trim() || null, + proxyFallbackToDirect, + }); if (!response.ok) { toast.error(response.error || t("testFailed")); @@ -247,21 +195,26 @@ export function ApiTestButton({ setTestResult(response.data); - // 显示测试结果 - if (response.data.success) { - const details = response.data.details; - const responseTime = details?.responseTime ? `${details.responseTime}ms` : "N/A"; - const model = details?.model || t("unknown"); + // 显示测试结果 toast + const statusLabels = { + green: t("testSuccess"), + yellow: "波动", + red: t("testFailed"), + }; - toast.success(t("testSuccess"), { - description: `${t("responseModel")}: ${model} | ${t("responseTime")}: ${responseTime}`, + if (response.data.status === "green") { + toast.success(statusLabels.green, { + description: `${t("responseModel")}: ${response.data.model || t("unknown")} | ${t("responseTime")}: ${response.data.latencyMs}ms`, + duration: API_TEST_UI_CONFIG.TOAST_SUCCESS_DURATION, + }); + } else if (response.data.status === "yellow") { + toast.warning(statusLabels.yellow, { + description: response.data.message, duration: API_TEST_UI_CONFIG.TOAST_SUCCESS_DURATION, }); } else { - const errorMessage = response.data.details?.error || response.data.message; - - toast.error(t("testFailed"), { - description: errorMessage, + toast.error(statusLabels.red, { + description: response.data.errorMessage || response.data.message, duration: API_TEST_UI_CONFIG.TOAST_ERROR_DURATION, }); } @@ -285,13 +238,20 @@ export function ApiTestButton({ } if (testResult) { - if (testResult.success) { + if (testResult.status === "green") { return ( <> {t("testSuccess")} ); + } else if (testResult.status === "yellow") { + return ( + <> + + 波动 + + ); } else { return ( <> @@ -310,43 +270,6 @@ export function ApiTestButton({ ); }; - // 复制测试结果到剪贴板 - const handleCopyResult = async () => { - if (!testResult) return; - - const resultText = [ - `${t("copyFormat.testResult")}: ${testResult.success ? t("success") : t("failed")}`, - `${t("copyFormat.message")}: ${testResult.message}`, - testResult.details?.model && `${t("responseModel")}: ${testResult.details.model}`, - testResult.details?.responseTime !== undefined && - `${t("responseTime")}: ${testResult.details.responseTime}ms`, - testResult.details?.usage && - `${t("usage")}: ${ - typeof testResult.details.usage === "object" - ? JSON.stringify(testResult.details.usage, null, 2) - : String(testResult.details.usage) - }`, - testResult.details?.content && - `${t("response")}: ${testResult.details.content.slice(0, API_TEST_UI_CONFIG.MAX_PREVIEW_LENGTH)}${ - testResult.details.content.length > API_TEST_UI_CONFIG.MAX_PREVIEW_LENGTH ? "..." : "" - }`, - testResult.details?.streamInfo && - `${t("streamResponse")}: ${t("chunksCount", { count: testResult.details.streamInfo.chunksReceived, format: testResult.details.streamInfo.format.toUpperCase() })}`, - testResult.details?.error && `${t("copyFormat.errorDetails")}: ${testResult.details.error}`, - ] - .filter(Boolean) - .join("\n"); - - try { - await navigator.clipboard.writeText(resultText); - toast.success(t("copySuccess")); - } catch (error) { - console.error("Copy failed:", error); - toast.error(t("copyFailed")); - } - }; - - // 获取默认模型占位符 return (
@@ -402,259 +325,19 @@ export function ApiTestButton({
-
- - - {/* 查看详细结果按钮 */} - {testResult && !isTesting && ( - - - - - - - - {testResult.success ? ( - <> - - {t("testSuccess")} - - ) : ( - <> - - {t("testFailed")} - - )} - - {testResult.message} - - -
- {/* 状态徽章 */} -
- - {testResult.success ? t("success") : t("failed")} - - {testResult.details?.model && ( - {testResult.details.model} - )} -
- - {/* 详细信息 */} - {testResult.details && ( -
- {/* 响应时间 */} - {testResult.details.responseTime !== undefined && ( -
-

{t("responseTime")}

-
-
{testResult.details.responseTime}ms
-
-
- )} - - {/* Token 用量 */} - {testResult.details.usage && ( -
-

{t("usage")}

-
-
-                            {typeof testResult.details.usage === "object"
-                              ? JSON.stringify(testResult.details.usage, null, 2)
-                              : String(testResult.details.usage)}
-                          
-
-
- )} - - {/* 响应内容 */} - {testResult.details.content && ( -
-

{t("response")}

-
-
-                            {testResult.details.content.slice(
-                              0,
-                              API_TEST_UI_CONFIG.MAX_PREVIEW_LENGTH
-                            )}
-                            {testResult.details.content.length >
-                              API_TEST_UI_CONFIG.MAX_PREVIEW_LENGTH && "..."}
-                          
- {testResult.details.content.length > - API_TEST_UI_CONFIG.MAX_PREVIEW_LENGTH && ( -
- {t("truncatedPreview", { - length: API_TEST_UI_CONFIG.MAX_PREVIEW_LENGTH, - })} -
- )} -
-
- )} - - {/* 流式响应信息 */} - {testResult.details.streamInfo && ( -
-

{t("streamInfo")}

-
-
-
- {t("chunksReceived")}:{" "} - {testResult.details.streamInfo.chunksReceived} -
-
- {t("streamFormat")}:{" "} - {testResult.details.streamInfo.format.toUpperCase()} -
-
-
-
- )} - - {/* 错误详情 */} - {testResult.details.error && ( -
-

- - {t("error")} -

-
-
-                            {testResult.details.error}
-                          
-
-
- )} -
- )} - - {/* 操作按钮 */} -
- - -
-
-
-
- )} -
- - {/* 显示简要测试结果 */} + + + {/* 显示测试结果卡片 */} {testResult && !isTesting && ( -
-
{testResult.message}
- {testResult.details && ( -
- {testResult.details.model && ( -
- {t("responseModel")}:{" "} - {testResult.details.model} -
- )} - {testResult.details.responseTime !== undefined && ( -
- {t("responseTime")}:{" "} - {testResult.details.responseTime}ms -
- )} - {testResult.details.usage && ( -
-
- {t("usage")}: -
-
-
-                      {typeof testResult.details.usage === "object"
-                        ? JSON.stringify(testResult.details.usage, null, 2)
-                        : String(testResult.details.usage)}
-                    
-
-
- )} - {testResult.details.content && ( -
-
- {t("response")}: -
-
-
-                      {testResult.details.content.slice(0, API_TEST_UI_CONFIG.BRIEF_PREVIEW_LENGTH)}
-                      {testResult.details.content.length >
-                        API_TEST_UI_CONFIG.BRIEF_PREVIEW_LENGTH && "..."}
-                    
- {testResult.details.content.length > - API_TEST_UI_CONFIG.BRIEF_PREVIEW_LENGTH && ( -
- {t("truncatedBrief", { length: API_TEST_UI_CONFIG.BRIEF_PREVIEW_LENGTH })} -
- )} -
-
- )} - {testResult.details.streamInfo && ( -
-
- {t("streamResponse")}: -
-
-
- {t("chunksCount", { - count: testResult.details.streamInfo.chunksReceived, - format: testResult.details.streamInfo.format.toUpperCase(), - })} -
-
-
- )} - {testResult.details.error && ( -
-
- {t("error")}: -
-
-
-                      {testResult.details.error}
-                    
-
-
- )} -
- )} -
+ )}
); diff --git a/src/app/[locale]/settings/providers/_components/forms/test-result-card.tsx b/src/app/[locale]/settings/providers/_components/forms/test-result-card.tsx new file mode 100644 index 000000000..cd1a0b6c5 --- /dev/null +++ b/src/app/[locale]/settings/providers/_components/forms/test-result-card.tsx @@ -0,0 +1,511 @@ +"use client"; + +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + CheckCircle2, + XCircle, + AlertTriangle, + Clock, + Zap, + FileText, + Copy, + ExternalLink, +} from "lucide-react"; +import { useState } from "react"; +import { toast } from "sonner"; +import { useTranslations } from "next-intl"; +import type { TestStatus, TestSubStatus } from "@/lib/provider-testing"; + +/** + * Unified test result data structure + */ +export interface UnifiedTestResultData { + success: boolean; + status: TestStatus; + subStatus: TestSubStatus; + message: string; + latencyMs: number; + firstByteMs?: number; + httpStatusCode?: number; + httpStatusText?: string; + model?: string; + content?: string; + usage?: { + inputTokens: number; + outputTokens: number; + cacheCreationInputTokens?: number; + cacheReadInputTokens?: number; + }; + streamInfo?: { + isStreaming: boolean; + chunksReceived?: number; + }; + errorMessage?: string; + errorType?: string; + testedAt: string; + validationDetails: { + httpPassed: boolean; + httpStatusCode?: number; + latencyPassed: boolean; + latencyMs?: number; + contentPassed: boolean; + contentTarget?: string; + }; +} + +interface TestResultCardProps { + result: UnifiedTestResultData; + onClose?: () => void; +} + +const STATUS_COLORS: Record = { + green: { + bg: "bg-green-50 dark:bg-green-950", + text: "text-green-700 dark:text-green-300", + border: "border-green-200 dark:border-green-800", + }, + yellow: { + bg: "bg-yellow-50 dark:bg-yellow-950", + text: "text-yellow-700 dark:text-yellow-300", + border: "border-yellow-200 dark:border-yellow-800", + }, + red: { + bg: "bg-red-50 dark:bg-red-950", + text: "text-red-700 dark:text-red-300", + border: "border-red-200 dark:border-red-800", + }, +}; + +const STATUS_ICONS: Record = { + green: , + yellow: , + red: , +}; + +/** + * Test result card component with three-tier validation display + * Shows status, latency, HTTP code, and content validation details + */ +export function TestResultCard({ result, onClose }: TestResultCardProps) { + const t = useTranslations("settings.providers.form.apiTest"); + const [isDetailDialogOpen, setIsDetailDialogOpen] = useState(false); + + const colors = STATUS_COLORS[result.status]; + const icon = STATUS_ICONS[result.status]; + const statusLabel = t(`resultCard.status.${result.status}`); + + const handleCopyResult = async () => { + const ct = (key: string) => t(`resultCard.copyText.${key}`); + const vp = (passed: boolean, type: "http" | "latency" | "content") => { + if (type === "latency") { + return passed ? `✓ ${t("resultCard.validation.passed")}` : `✗ ${t("resultCard.validation.timeout")}`; + } + return passed ? `✓ ${t("resultCard.validation.passed")}` : `✗ ${t("resultCard.validation.failed")}`; + }; + + const resultText = [ + `${ct("status")}: ${statusLabel} (${result.subStatus})`, + `${ct("message")}: ${result.message}`, + `${ct("latency")}: ${result.latencyMs}ms`, + result.httpStatusCode && `${ct("httpStatus")}: ${result.httpStatusCode} ${result.httpStatusText || ""}`, + result.model && `${ct("model")}: ${result.model}`, + result.usage && + t("resultCard.copyText.inputOutput", { input: result.usage.inputTokens, output: result.usage.outputTokens }), + result.content && `${ct("response")}: ${result.content.slice(0, 200)}${result.content.length > 200 ? "..." : ""}`, + result.errorMessage && `${ct("error")}: ${result.errorMessage}`, + `${ct("testedAt")}: ${new Date(result.testedAt).toLocaleString()}`, + "", + `${ct("validationDetails")}:`, + ` ${ct("httpCheck")}: ${vp(result.validationDetails.httpPassed, "http")}`, + ` ${ct("latencyCheck")}: ${vp(result.validationDetails.latencyPassed, "latency")}`, + ` ${ct("contentCheck")}: ${vp(result.validationDetails.contentPassed, "content")}`, + ] + .filter(Boolean) + .join("\n"); + + try { + await navigator.clipboard.writeText(resultText); + toast.success(t("copySuccess")); + } catch (error) { + console.error("Copy failed:", error); + toast.error(t("copyFailed")); + } + }; + + return ( +
+ {/* Header with status */} +
+
+ {icon} + {statusLabel} + + {result.subStatus} + +
+
+ + + + + + + + {icon} + {t("resultCard.dialogTitle")} + + {result.message} + + + + +
+
+ + {/* Message */} +

{result.message}

+ + {/* Three-tier validation indicators */} +
+ + + +
+ + {/* Quick stats */} +
+ {result.model && ( +
+ + {t("resultCard.labels.model")}: + {result.model} +
+ )} + {result.firstByteMs !== undefined && ( +
+ + {t("resultCard.labels.firstByte")}: + {result.firstByteMs}ms +
+ )} +
+ + {t("resultCard.labels.totalLatency")}: + {result.latencyMs}ms +
+
+ + {/* Error message if failed */} + {result.errorMessage && ( +
+ {t("resultCard.labels.error")}: {result.errorMessage} +
+ )} + + {/* Content preview if success */} + {result.content && result.status !== "red" && ( +
+ {t("resultCard.labels.responsePreview")}: +
+            {result.content.slice(0, 150)}
+            {result.content.length > 150 && "..."}
+          
+
+ )} +
+ ); +} + +/** + * Validation indicator component for each tier + */ +function ValidationIndicator({ + label, + passed, + value, +}: { + label: string; + passed: boolean; + value?: string; +}) { + return ( +
+
+ {passed ? ( + + ) : ( + + )} + {label} +
+ {value && ( + {value} + )} +
+ ); +} + +/** + * Detailed test result view in dialog + */ +function TestResultDetails({ + result, + onCopy, +}: { + result: UnifiedTestResultData; + onCopy: () => void; +}) { + const t = useTranslations("settings.providers.form.apiTest"); + + return ( +
+ {/* Status badges */} +
+ + {t(`resultCard.status.${result.status}`)} + + {result.subStatus} + {result.model && {result.model}} + {result.httpStatusCode && ( + HTTP {result.httpStatusCode} + )} +
+ + {/* Validation Details */} +
+

{t("resultCard.validation.title")}

+
+ + + +
+
+ + {/* Timing Info */} +
+

{t("resultCard.timing.title")}

+
+
+ {t("resultCard.timing.totalLatency")}:{" "} + {result.latencyMs}ms +
+ {result.firstByteMs !== undefined && ( +
+ {t("resultCard.timing.firstByte")}:{" "} + {result.firstByteMs}ms +
+ )} +
+ {t("resultCard.timing.testedAt")}:{" "} + {new Date(result.testedAt).toLocaleString()} +
+
+
+ + {/* Token Usage */} + {result.usage && ( +
+

{t("resultCard.tokenUsage.title")}

+
+
+
+ {t("resultCard.tokenUsage.input")}:{" "} + {result.usage.inputTokens} +
+
+ {t("resultCard.tokenUsage.output")}:{" "} + {result.usage.outputTokens} +
+ {result.usage.cacheCreationInputTokens !== undefined && ( +
+ {t("resultCard.tokenUsage.cacheCreation")}:{" "} + {result.usage.cacheCreationInputTokens} +
+ )} + {result.usage.cacheReadInputTokens !== undefined && ( +
+ {t("resultCard.tokenUsage.cacheRead")}:{" "} + {result.usage.cacheReadInputTokens} +
+ )} +
+
+
+ )} + + {/* Stream Info */} + {result.streamInfo && ( +
+

{t("resultCard.streamInfo.title")}

+
+
+
+ {t("resultCard.streamInfo.isStreaming")}:{" "} + {result.streamInfo.isStreaming ? t("resultCard.streamInfo.yes") : t("resultCard.streamInfo.no")} +
+ {result.streamInfo.chunksReceived !== undefined && ( +
+ {t("resultCard.streamInfo.chunksCount")}:{" "} + {result.streamInfo.chunksReceived} +
+ )} +
+
+
+ )} + + {/* Response Content */} + {result.content && ( +
+

{t("response")}

+
+
+              {result.content}
+            
+
+
+ )} + + {/* Error Details */} + {result.errorMessage && ( +
+

+ + {t("resultCard.errorDetails.title")} +

+
+
+ {result.errorType && ( +
+ {t("resultCard.errorDetails.type")}: {result.errorType} +
+ )} +
+                {result.errorMessage}
+              
+
+
+
+ )} + + {/* Action buttons */} +
+ +
+
+ ); +} + +/** + * Validation detail card for each tier + */ +function ValidationDetailCard({ + title, + passed, + statusCodeLabel, + statusCode, + judgmentLabel, + judgmentText, +}: { + title: string; + passed: boolean; + statusCodeLabel: string; + statusCode: string | number | undefined; + judgmentLabel: string; + judgmentText: string; +}) { + return ( +
+
+ {passed ? ( + + ) : ( + + )} + {title} +
+
+
+ {statusCodeLabel}: {statusCode || "N/A"} +
+
+ {judgmentLabel}: {judgmentText} +
+
+
+ ); +} diff --git a/src/app/api/availability/current/route.ts b/src/app/api/availability/current/route.ts new file mode 100644 index 000000000..0ac845b5d --- /dev/null +++ b/src/app/api/availability/current/route.ts @@ -0,0 +1,45 @@ +/** + * Provider Current Status API Endpoint + * + * GET /api/availability/current + * Returns current status for all providers (lightweight query, last 15 minutes) + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { getCurrentProviderStatus } from '@/lib/availability'; +import { validateKey } from '@/lib/auth'; + +/** + * GET /api/availability/current + */ +export async function GET(request: NextRequest) { + // Verify admin authentication + const authHeader = request.headers.get('Authorization'); + const token = authHeader?.replace('Bearer ', ''); + + if (!token) { + return NextResponse.json( + { error: 'Unauthorized' }, + { status: 401 } + ); + } + + const session = await validateKey(token); + if (!session || session.user.role !== 'admin') { + return NextResponse.json( + { error: 'Forbidden: Admin access required' }, + { status: 403 } + ); + } + + try { + const result = await getCurrentProviderStatus(); + return NextResponse.json(result); + } catch (error) { + console.error('Current availability API error:', error); + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/availability/route.ts b/src/app/api/availability/route.ts new file mode 100644 index 000000000..9561c0941 --- /dev/null +++ b/src/app/api/availability/route.ts @@ -0,0 +1,80 @@ +/** + * Provider Availability API Endpoint + * + * GET /api/availability + * Query parameters: + * - startTime: ISO string, start of query range (default: 24h ago) + * - endTime: ISO string, end of query range (default: now) + * - providerIds: comma-separated provider IDs (default: all) + * - bucketSizeMinutes: number, time bucket size (default: auto) + * - includeDisabled: boolean, include disabled providers (default: false) + * - maxBuckets: number, max time buckets (default: 100) + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { + queryProviderAvailability, + type AvailabilityQueryOptions, +} from '@/lib/availability'; +import { getSession } from '@/lib/auth'; + +/** + * GET /api/availability + */ +export async function GET(request: NextRequest) { + // Verify admin authentication using session cookies + const session = await getSession(); + if (!session || session.user.role !== 'admin') { + return NextResponse.json( + { error: 'Unauthorized' }, + { status: 401 } + ); + } + + try { + const { searchParams } = new URL(request.url); + + // Parse query options + const options: AvailabilityQueryOptions = {}; + + const startTime = searchParams.get('startTime'); + if (startTime) { + options.startTime = startTime; + } + + const endTime = searchParams.get('endTime'); + if (endTime) { + options.endTime = endTime; + } + + const providerIds = searchParams.get('providerIds'); + if (providerIds) { + options.providerIds = providerIds.split(',').map((id) => parseInt(id.trim(), 10)).filter((id) => !isNaN(id)); + } + + const bucketSizeMinutes = searchParams.get('bucketSizeMinutes'); + if (bucketSizeMinutes) { + options.bucketSizeMinutes = parseInt(bucketSizeMinutes, 10); + } + + const includeDisabled = searchParams.get('includeDisabled'); + if (includeDisabled) { + options.includeDisabled = includeDisabled === 'true'; + } + + const maxBuckets = searchParams.get('maxBuckets'); + if (maxBuckets) { + options.maxBuckets = parseInt(maxBuckets, 10); + } + + const result = await queryProviderAvailability(options); + + return NextResponse.json(result); + } catch (error) { + console.error('Availability API error:', error); + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ); + } +} diff --git a/src/instrumentation.ts b/src/instrumentation.ts index e3f58b0a9..5dc01746f 100644 --- a/src/instrumentation.ts +++ b/src/instrumentation.ts @@ -78,6 +78,15 @@ export async function register() { const { scheduleNotifications } = await import("@/lib/notification/notification-queue"); await scheduleNotifications(); + // 初始化智能探测调度器(如果启用) + const { startProbeScheduler, isSmartProbingEnabled } = await import( + "@/lib/circuit-breaker-probe" + ); + if (isSmartProbingEnabled()) { + startProbeScheduler(); + logger.info("Smart probing scheduler started"); + } + logger.info("Application ready"); } // 开发环境: 执行迁移 + 初始化价格表(禁用 Bull Queue 避免 Turbopack 冲突) @@ -115,6 +124,15 @@ export async function register() { "Notification features are only available in production environment." ); + // 初始化智能探测调度器(开发环境也支持) + const { startProbeScheduler, isSmartProbingEnabled } = await import( + "@/lib/circuit-breaker-probe" + ); + if (isSmartProbingEnabled()) { + startProbeScheduler(); + logger.info("Smart probing scheduler started (development mode)"); + } + logger.info("Development environment ready"); } } diff --git a/src/lib/availability/availability-service.ts b/src/lib/availability/availability-service.ts new file mode 100644 index 000000000..3d0163be5 --- /dev/null +++ b/src/lib/availability/availability-service.ts @@ -0,0 +1,443 @@ +/** + * Provider Availability Aggregation Service + * Calculates availability metrics from request logs + * Simple two-tier status: success (green) or failure (red) + */ + +import { db } from '@/drizzle/db'; +import { messageRequest, providers } from '@/drizzle/schema'; +import { and, eq, gte, lte, sql, isNull, desc, inArray } from 'drizzle-orm'; +import { + type AvailabilityStatus, + type AvailabilityQueryOptions, + type AvailabilityQueryResult, + type ProviderAvailabilitySummary, + type TimeBucketMetrics, + type RequestStatusClassification, + AVAILABILITY_WEIGHTS, + AVAILABILITY_DEFAULTS, +} from './types'; + +/** + * Classify a single request's status + * Simple: success (2xx/3xx) = green, failure = red + */ +export function classifyRequestStatus( + statusCode: number | null +): RequestStatusClassification { + // No status code means network error or timeout + if (statusCode === null) { + return { + status: 'red', + isSuccess: false, + isError: true, + }; + } + + // HTTP error (4xx/5xx) + if (statusCode >= 400) { + return { + status: 'red', + isSuccess: false, + isError: true, + }; + } + + // HTTP success (2xx/3xx) - all successful requests are green + return { + status: 'green', + isSuccess: true, + isError: false, + }; +} + +/** + * Calculate availability score from counts (simple: green / total) + */ +export function calculateAvailabilityScore( + greenCount: number, + redCount: number +): number { + const total = greenCount + redCount; + if (total === 0) return 0; + + return greenCount / total; +} + +/** + * Calculate percentile from sorted array + */ +function calculatePercentile(sortedValues: number[], percentile: number): number { + if (sortedValues.length === 0) return 0; + const index = Math.ceil((percentile / 100) * sortedValues.length) - 1; + return sortedValues[Math.max(0, Math.min(index, sortedValues.length - 1))]; +} + +/** + * Determine optimal time bucket size based on data density + */ +export function determineOptimalBucketSize( + totalRequests: number, + timeRangeMinutes: number +): number { + // Target: 20-100 data points per time series for good visualization + const targetBuckets = 50; + const idealBucketMinutes = timeRangeMinutes / targetBuckets; + + // Round to nearest standard bucket size + const standardSizes = [1, 5, 15, 60, 1440]; // 1min, 5min, 15min, 1hour, 1day + + for (const size of standardSizes) { + if (idealBucketMinutes <= size) { + return size; + } + } + + return 1440; // Default to daily for very long ranges +} + +/** + * Query availability data for providers + */ +export async function queryProviderAvailability( + options: AvailabilityQueryOptions = {} +): Promise { + const now = new Date(); + const { + startTime = new Date(now.getTime() - 24 * 60 * 60 * 1000), // Default: last 24 hours + endTime = now, + providerIds = [], + bucketSizeMinutes: explicitBucketSize, + includeDisabled = false, + maxBuckets = 100, + } = options; + + const startDate = typeof startTime === 'string' ? new Date(startTime) : startTime; + const endDate = typeof endTime === 'string' ? new Date(endTime) : endTime; + const timeRangeMinutes = (endDate.getTime() - startDate.getTime()) / (1000 * 60); + + // Get provider list + const providerConditions = [isNull(providers.deletedAt)]; + if (!includeDisabled) { + providerConditions.push(eq(providers.isEnabled, true)); + } + if (providerIds.length > 0) { + providerConditions.push(inArray(providers.id, providerIds)); + } + + const providerList = await db + .select({ + id: providers.id, + name: providers.name, + providerType: providers.providerType, + enabled: providers.isEnabled, + }) + .from(providers) + .where(and(...providerConditions)); + + if (providerList.length === 0) { + return { + queriedAt: now.toISOString(), + startTime: startDate.toISOString(), + endTime: endDate.toISOString(), + bucketSizeMinutes: explicitBucketSize ?? 60, + providers: [], + systemAvailability: 0, + }; + } + + const providerIdList = providerList.map((p) => p.id); + + // Query raw request data + const requestConditions = [ + inArray(messageRequest.providerId, providerIdList), + gte(messageRequest.createdAt, startDate), + lte(messageRequest.createdAt, endDate), + isNull(messageRequest.deletedAt), + ]; + + const requests = await db + .select({ + id: messageRequest.id, + providerId: messageRequest.providerId, + statusCode: messageRequest.statusCode, + durationMs: messageRequest.durationMs, + errorMessage: messageRequest.errorMessage, + createdAt: messageRequest.createdAt, + }) + .from(messageRequest) + .where(and(...requestConditions)) + .orderBy(messageRequest.createdAt); + + // Determine bucket size if not explicitly specified + const bucketSizeMinutes = + explicitBucketSize ?? determineOptimalBucketSize(requests.length, timeRangeMinutes); + const bucketSizeMs = bucketSizeMinutes * 60 * 1000; + + // Group requests by provider and time bucket + const providerBuckets = new Map< + number, + Map< + string, + { + greenCount: number; + redCount: number; + latencies: number[]; + } + > + >(); + + // Initialize provider buckets + for (const provider of providerList) { + providerBuckets.set(provider.id, new Map()); + } + + // Process requests + for (const req of requests) { + if (!req.createdAt) continue; + + const bucketStart = new Date( + Math.floor(req.createdAt.getTime() / bucketSizeMs) * bucketSizeMs + ); + const bucketKey = bucketStart.toISOString(); + + const providerData = providerBuckets.get(req.providerId); + if (!providerData) continue; + + if (!providerData.has(bucketKey)) { + providerData.set(bucketKey, { + greenCount: 0, + redCount: 0, + latencies: [], + }); + } + + const bucket = providerData.get(bucketKey)!; + const classification = classifyRequestStatus(req.statusCode); + + if (classification.status === 'green') { + bucket.greenCount++; + } else { + bucket.redCount++; + } + + if (req.durationMs !== null) { + bucket.latencies.push(req.durationMs); + } + } + + // Build provider summaries + const providerSummaries: ProviderAvailabilitySummary[] = []; + + for (const provider of providerList) { + const bucketData = providerBuckets.get(provider.id)!; + const timeBuckets: TimeBucketMetrics[] = []; + + let totalGreen = 0; + let totalRed = 0; + const allLatencies: number[] = []; + let lastRequestAt: string | null = null; + + // Sort buckets by time and limit + const sortedBucketKeys = Array.from(bucketData.keys()).sort().slice(-maxBuckets); + + for (const bucketKey of sortedBucketKeys) { + const bucket = bucketData.get(bucketKey)!; + const bucketStart = new Date(bucketKey); + const bucketEnd = new Date(bucketStart.getTime() + bucketSizeMs); + + totalGreen += bucket.greenCount; + totalRed += bucket.redCount; + allLatencies.push(...bucket.latencies); + + const sortedLatencies = [...bucket.latencies].sort((a, b) => a - b); + const total = bucket.greenCount + bucket.redCount; + + timeBuckets.push({ + bucketStart: bucketStart.toISOString(), + bucketEnd: bucketEnd.toISOString(), + totalRequests: total, + greenCount: bucket.greenCount, + redCount: bucket.redCount, + availabilityScore: calculateAvailabilityScore(bucket.greenCount, bucket.redCount), + avgLatencyMs: + sortedLatencies.length > 0 + ? sortedLatencies.reduce((a, b) => a + b, 0) / sortedLatencies.length + : 0, + p50LatencyMs: calculatePercentile(sortedLatencies, 50), + p95LatencyMs: calculatePercentile(sortedLatencies, 95), + p99LatencyMs: calculatePercentile(sortedLatencies, 99), + }); + + // Track last request time + if (bucket.latencies.length > 0) { + lastRequestAt = bucketEnd.toISOString(); + } + } + + const totalRequests = totalGreen + totalRed; + const sortedAllLatencies = allLatencies.sort((a, b) => a - b); + + // Determine current status based on last few buckets + // IMPORTANT: No data = 'unknown', NOT 'green'! Must be honest. + let currentStatus: AvailabilityStatus = 'unknown'; + if (timeBuckets.length > 0) { + const recentBuckets = timeBuckets.slice(-3); // Last 3 buckets + const recentScore = + recentBuckets.reduce((sum, b) => sum + b.availabilityScore, 0) / + recentBuckets.length; + + // Simple: >= 50% success = green, otherwise red + currentStatus = recentScore >= 0.5 ? 'green' : 'red'; + } + + providerSummaries.push({ + providerId: provider.id, + providerName: provider.name, + providerType: provider.providerType ?? 'claude', + isEnabled: provider.enabled ?? true, + currentStatus, + currentAvailability: calculateAvailabilityScore(totalGreen, totalRed), + totalRequests, + successRate: totalRequests > 0 ? totalGreen / totalRequests : 0, + avgLatencyMs: + sortedAllLatencies.length > 0 + ? sortedAllLatencies.reduce((a, b) => a + b, 0) / sortedAllLatencies.length + : 0, + lastRequestAt, + timeBuckets, + }); + } + + // Calculate system-wide availability + const totalSystemRequests = providerSummaries.reduce( + (sum, p) => sum + p.totalRequests, + 0 + ); + const weightedSystemAvailability = + totalSystemRequests > 0 + ? providerSummaries.reduce( + (sum, p) => sum + p.currentAvailability * p.totalRequests, + 0 + ) / totalSystemRequests + : 0; + + return { + queriedAt: now.toISOString(), + startTime: startDate.toISOString(), + endTime: endDate.toISOString(), + bucketSizeMinutes, + providers: providerSummaries, + systemAvailability: weightedSystemAvailability, + }; +} + +/** + * Get current availability status for all providers (lightweight query) + */ +export async function getCurrentProviderStatus(): Promise< + Array<{ + providerId: number; + providerName: string; + status: AvailabilityStatus; + availability: number; + requestCount: number; + lastRequestAt: string | null; + }> +> { + // Query last 15 minutes of data for current status + const now = new Date(); + const fifteenMinutesAgo = new Date(now.getTime() - 15 * 60 * 1000); + + // Get enabled providers + const providerList = await db + .select({ + id: providers.id, + name: providers.name, + }) + .from(providers) + .where(and(eq(providers.isEnabled, true), isNull(providers.deletedAt))); + + if (providerList.length === 0) { + return []; + } + + const providerIdList = providerList.map((p) => p.id); + + // Query recent requests + const requests = await db + .select({ + providerId: messageRequest.providerId, + statusCode: messageRequest.statusCode, + durationMs: messageRequest.durationMs, + createdAt: messageRequest.createdAt, + }) + .from(messageRequest) + .where( + and( + inArray(messageRequest.providerId, providerIdList), + gte(messageRequest.createdAt, fifteenMinutesAgo), + isNull(messageRequest.deletedAt) + ) + ) + .orderBy(desc(messageRequest.createdAt)); + + // Aggregate by provider + const providerStats = new Map< + number, + { + greenCount: number; + redCount: number; + lastRequestAt: string | null; + } + >(); + + for (const provider of providerList) { + providerStats.set(provider.id, { + greenCount: 0, + redCount: 0, + lastRequestAt: null, + }); + } + + for (const req of requests) { + const stats = providerStats.get(req.providerId); + if (!stats) continue; + + const classification = classifyRequestStatus(req.statusCode); + + if (classification.status === 'green') { + stats.greenCount++; + } else { + stats.redCount++; + } + + if (!stats.lastRequestAt && req.createdAt) { + stats.lastRequestAt = req.createdAt.toISOString(); + } + } + + return providerList.map((provider) => { + const stats = providerStats.get(provider.id)!; + const total = stats.greenCount + stats.redCount; + const availability = calculateAvailabilityScore(stats.greenCount, stats.redCount); + + // IMPORTANT: No data = 'unknown', NOT 'green'! Must be honest. + let status: AvailabilityStatus = 'unknown'; + if (total === 0) { + status = 'unknown'; // No data - must be honest, don't assume healthy! + } else { + // Simple: >= 50% success = green, otherwise red + status = availability >= 0.5 ? 'green' : 'red'; + } + + return { + providerId: provider.id, + providerName: provider.name, + status, + availability, + requestCount: total, + lastRequestAt: stats.lastRequestAt, + }; + }); +} diff --git a/src/lib/availability/index.ts b/src/lib/availability/index.ts new file mode 100644 index 000000000..ac59bd778 --- /dev/null +++ b/src/lib/availability/index.ts @@ -0,0 +1,22 @@ +/** + * Provider Availability Module + * + * This module provides availability monitoring based on request log data. + * Simple two-tier validation: success or failure. + * + * 1. HTTP Status Check: 2xx/3xx = success (green), 4xx/5xx or error = failure (red) + * + * Availability scoring: + * - GREEN (1.0): Successful requests (any HTTP 2xx/3xx) + * - RED (0.0): Failed requests (HTTP 4xx/5xx or network error) + * - UNKNOWN: No data available + */ + +export * from './types'; +export { + classifyRequestStatus, + calculateAvailabilityScore, + determineOptimalBucketSize, + queryProviderAvailability, + getCurrentProviderStatus, +} from './availability-service'; diff --git a/src/lib/availability/types.ts b/src/lib/availability/types.ts new file mode 100644 index 000000000..ca4326de6 --- /dev/null +++ b/src/lib/availability/types.ts @@ -0,0 +1,161 @@ +/** + * Provider Availability Service Types + * Based on relay-pulse aggregation patterns + */ + +/** + * Status values for availability calculation + * - GREEN (1.0): HTTP 2xx/3xx (all successful requests) + * - RED (0.0): HTTP 4xx/5xx or error + * - UNKNOWN (-1): No data available (must be displayed honestly as "no data") + */ +export type AvailabilityStatus = 'green' | 'red' | 'unknown'; + +/** + * Numeric weights for availability calculation + */ +export const AVAILABILITY_WEIGHTS: Record = { + green: 1.0, + red: 0.0, + unknown: -1, // Special value for "no data" - must be honest! +}; + +/** + * Default thresholds + */ +export const AVAILABILITY_DEFAULTS = { + /** Minimum sample size for reliable metrics */ + MIN_SAMPLE_SIZE: 5, + /** Time bucket granularities in minutes */ + TIME_BUCKETS: { + MINUTE_1: 1, + MINUTE_5: 5, + MINUTE_15: 15, + HOUR_1: 60, + DAY_1: 1440, + }, +} as const; + +/** + * Request status classification result + */ +export interface RequestStatusClassification { + status: AvailabilityStatus; + isSuccess: boolean; + isError: boolean; +} + +/** + * Single time bucket aggregation + */ +export interface TimeBucketMetrics { + /** Bucket start time (ISO string) */ + bucketStart: string; + /** Bucket end time (ISO string) */ + bucketEnd: string; + /** Total request count */ + totalRequests: number; + /** Successful requests (2xx/3xx) */ + greenCount: number; + /** Failed requests (4xx/5xx or error) */ + redCount: number; + /** Weighted availability score (0.0-1.0) */ + availabilityScore: number; + /** Average latency in ms */ + avgLatencyMs: number; + /** P50 latency in ms */ + p50LatencyMs: number; + /** P95 latency in ms */ + p95LatencyMs: number; + /** P99 latency in ms */ + p99LatencyMs: number; +} + +/** + * Provider availability summary + */ +export interface ProviderAvailabilitySummary { + /** Provider ID */ + providerId: number; + /** Provider name */ + providerName: string; + /** Provider type */ + providerType: string; + /** Whether provider is enabled */ + isEnabled: boolean; + /** Current status based on recent requests */ + currentStatus: AvailabilityStatus; + /** Current weighted availability (0.0-1.0) */ + currentAvailability: number; + /** Total request count in period */ + totalRequests: number; + /** Success rate (green requests / total) */ + successRate: number; + /** Average latency in ms */ + avgLatencyMs: number; + /** Last request timestamp */ + lastRequestAt: string | null; + /** Time bucket metrics */ + timeBuckets: TimeBucketMetrics[]; +} + +/** + * Availability query options + */ +export interface AvailabilityQueryOptions { + /** Start time for query (ISO string or Date) */ + startTime?: string | Date; + /** End time for query (ISO string or Date) */ + endTime?: string | Date; + /** Provider IDs to filter (empty = all providers) */ + providerIds?: number[]; + /** Time bucket size in minutes */ + bucketSizeMinutes?: number; + /** Whether to include disabled providers */ + includeDisabled?: boolean; + /** Maximum number of time buckets to return */ + maxBuckets?: number; +} + +/** + * Availability query result + */ +export interface AvailabilityQueryResult { + /** Query timestamp */ + queriedAt: string; + /** Query time range start */ + startTime: string; + /** Query time range end */ + endTime: string; + /** Time bucket size used (in minutes) */ + bucketSizeMinutes: number; + /** Provider summaries */ + providers: ProviderAvailabilitySummary[]; + /** Overall system availability (weighted average) */ + systemAvailability: number; +} + +/** + * Raw request data from database + */ +export interface RawRequestData { + id: number; + providerId: number; + statusCode: number | null; + durationMs: number | null; + errorMessage: string | null; + createdAt: Date | null; +} + +/** + * Aggregated bucket data from database + */ +export interface AggregatedBucketData { + providerId: number; + bucketStart: Date; + totalRequests: number; + greenCount: number; + redCount: number; + avgLatencyMs: number; + latencies: number[]; +} diff --git a/src/lib/circuit-breaker-probe.ts b/src/lib/circuit-breaker-probe.ts new file mode 100644 index 000000000..9708a4199 --- /dev/null +++ b/src/lib/circuit-breaker-probe.ts @@ -0,0 +1,283 @@ +/** + * Circuit Breaker Smart Probe Scheduler + * + * Periodically probes providers in OPEN circuit state to enable faster recovery. + * When a probe succeeds, the circuit transitions to HALF_OPEN state earlier. + * + * Configuration via environment variables: + * - ENABLE_SMART_PROBING: Enable/disable smart probing (default: false) + * - PROBE_INTERVAL_MS: Interval between probe cycles (default: 30000ms = 30s) + * - PROBE_TIMEOUT_MS: Timeout for each probe request (default: 5000ms = 5s) + */ + +import { logger } from '@/lib/logger'; +import { getAllHealthStatus, getCircuitState, resetCircuit } from './circuit-breaker'; +import { executeProviderTest } from './provider-testing/test-service'; +import type { ProviderType } from '@/types/provider'; + +// Configuration +const ENABLE_SMART_PROBING = process.env.ENABLE_SMART_PROBING === 'true'; +const PROBE_INTERVAL_MS = parseInt(process.env.PROBE_INTERVAL_MS || '30000', 10); +const PROBE_TIMEOUT_MS = parseInt(process.env.PROBE_TIMEOUT_MS || '5000', 10); + +// Probe state +let probeIntervalId: NodeJS.Timeout | null = null; +let isProbing = false; + +// In-memory cache of provider configs for probing +interface ProbeProviderConfig { + id: number; + name: string; + url: string; + key: string; + providerType: ProviderType; + model?: string; +} + +let providerConfigCache: Map = new Map(); +let lastProviderCacheUpdate = 0; +const PROVIDER_CACHE_TTL = 60000; // 1 minute + +/** + * Load provider configurations for probing + */ +async function loadProviderConfigs(): Promise { + const now = Date.now(); + if (now - lastProviderCacheUpdate < PROVIDER_CACHE_TTL && providerConfigCache.size > 0) { + return; // Cache still valid + } + + try { + // Dynamic import to avoid circular dependencies + const { db } = await import('@/drizzle/db'); + const { providers } = await import('@/drizzle/schema'); + const { eq, isNull, and } = await import('drizzle-orm'); + + const providerList = await db + .select({ + id: providers.id, + name: providers.name, + url: providers.url, + key: providers.key, + providerType: providers.providerType, + }) + .from(providers) + .where(and(eq(providers.isEnabled, true), isNull(providers.deletedAt))); + + providerConfigCache = new Map( + providerList.map((p) => [ + p.id, + { + id: p.id, + name: p.name, + url: p.url, + key: p.key, + providerType: (p.providerType || 'claude') as ProviderType, + }, + ]) + ); + lastProviderCacheUpdate = now; + + logger.debug('[SmartProbe] Updated provider cache', { + count: providerConfigCache.size, + }); + } catch (error) { + logger.error('[SmartProbe] Failed to load provider configs', { + error: error instanceof Error ? error.message : String(error), + }); + } +} + +/** + * Probe a single provider + */ +async function probeProvider(providerId: number): Promise { + const config = providerConfigCache.get(providerId); + if (!config) { + logger.warn('[SmartProbe] Provider config not found', { providerId }); + return false; + } + + try { + logger.info('[SmartProbe] Probing provider', { + providerId, + providerName: config.name, + }); + + const result = await executeProviderTest({ + providerUrl: config.url, + apiKey: config.key, + providerType: config.providerType, + timeoutMs: PROBE_TIMEOUT_MS, + }); + + if (result.success) { + logger.info('[SmartProbe] Probe succeeded, transitioning to half-open', { + providerId, + providerName: config.name, + latencyMs: result.latencyMs, + status: result.status, + }); + + // Reset circuit to closed (will transition through half-open on next real request) + // Using resetCircuit here since we want to give the provider another chance + resetCircuit(providerId); + return true; + } + + logger.info('[SmartProbe] Probe failed, keeping circuit open', { + providerId, + providerName: config.name, + status: result.status, + subStatus: result.subStatus, + errorMessage: result.errorMessage, + }); + return false; + } catch (error) { + logger.error('[SmartProbe] Probe execution error', { + providerId, + error: error instanceof Error ? error.message : String(error), + }); + return false; + } +} + +/** + * Run a single probe cycle + */ +async function runProbeCycle(): Promise { + if (isProbing) { + logger.debug('[SmartProbe] Skipping cycle, previous cycle still running'); + return; + } + + isProbing = true; + + try { + // Load fresh provider configs + await loadProviderConfigs(); + + // Get all providers with open circuits + const healthStatus = getAllHealthStatus(); + const openCircuits: number[] = []; + + for (const [providerId, health] of Object.entries(healthStatus)) { + if (health.circuitState === 'open') { + openCircuits.push(parseInt(providerId, 10)); + } + } + + if (openCircuits.length === 0) { + logger.debug('[SmartProbe] No open circuits to probe'); + return; + } + + logger.info('[SmartProbe] Starting probe cycle', { + openCircuitCount: openCircuits.length, + providerIds: openCircuits, + }); + + // Probe each provider with open circuit + const results = await Promise.allSettled(openCircuits.map((id) => probeProvider(id))); + + const succeeded = results.filter( + (r) => r.status === 'fulfilled' && r.value === true + ).length; + const failed = results.length - succeeded; + + logger.info('[SmartProbe] Probe cycle completed', { + total: results.length, + succeeded, + failed, + }); + } catch (error) { + logger.error('[SmartProbe] Probe cycle error', { + error: error instanceof Error ? error.message : String(error), + }); + } finally { + isProbing = false; + } +} + +/** + * Start the probe scheduler + */ +export function startProbeScheduler(): void { + if (!ENABLE_SMART_PROBING) { + logger.info('[SmartProbe] Smart probing is disabled'); + return; + } + + if (probeIntervalId) { + logger.warn('[SmartProbe] Scheduler already running'); + return; + } + + logger.info('[SmartProbe] Starting probe scheduler', { + intervalMs: PROBE_INTERVAL_MS, + timeoutMs: PROBE_TIMEOUT_MS, + }); + + // Run immediately on startup + runProbeCycle().catch((error) => { + logger.error('[SmartProbe] Initial probe cycle failed', { + error: error instanceof Error ? error.message : String(error), + }); + }); + + // Schedule periodic probes + probeIntervalId = setInterval(() => { + runProbeCycle().catch((error) => { + logger.error('[SmartProbe] Scheduled probe cycle failed', { + error: error instanceof Error ? error.message : String(error), + }); + }); + }, PROBE_INTERVAL_MS); + + // Ensure cleanup on process exit + process.on('SIGTERM', stopProbeScheduler); + process.on('SIGINT', stopProbeScheduler); +} + +/** + * Stop the probe scheduler + */ +export function stopProbeScheduler(): void { + if (probeIntervalId) { + clearInterval(probeIntervalId); + probeIntervalId = null; + logger.info('[SmartProbe] Probe scheduler stopped'); + } +} + +/** + * Check if smart probing is enabled + */ +export function isSmartProbingEnabled(): boolean { + return ENABLE_SMART_PROBING; +} + +/** + * Get probe scheduler status + */ +export function getProbeSchedulerStatus(): { + enabled: boolean; + running: boolean; + intervalMs: number; + timeoutMs: number; +} { + return { + enabled: ENABLE_SMART_PROBING, + running: probeIntervalId !== null, + intervalMs: PROBE_INTERVAL_MS, + timeoutMs: PROBE_TIMEOUT_MS, + }; +} + +/** + * Manually trigger a probe for a specific provider + */ +export async function triggerManualProbe(providerId: number): Promise { + await loadProviderConfigs(); + return probeProvider(providerId); +} diff --git a/src/lib/provider-testing/index.ts b/src/lib/provider-testing/index.ts new file mode 100644 index 000000000..ddd4893ac --- /dev/null +++ b/src/lib/provider-testing/index.ts @@ -0,0 +1,61 @@ +/** + * Provider Testing Service + * Unified provider testing with three-tier validation + * + * Based on relay-pulse implementation patterns: + * https://github.com/prehisle/relay-pulse + */ + +// Main test service +export { executeProviderTest, getStatusWeight } from './test-service'; + +// Types +export type { + TestStatus, + TestSubStatus, + StatusValue, + ProviderTestConfig, + ProviderTestResult, + TokenUsage, + ValidationDetails, + ParsedResponse, + ClaudeTestBody, + CodexTestBody, + OpenAITestBody, + GeminiTestBody, +} from './types'; + +export { TEST_DEFAULTS, STATUS_VALUES } from './types'; + +// Validators +export { + classifyHttpStatus, + isHttpSuccess, + getSubStatusDescription, + evaluateContentValidation, + extractTextContent, +} from './validators'; + +// Parsers +export { + parseResponse, + getParser, + parseAnthropicResponse, + parseOpenAIResponse, + parseCodexResponse, + parseGeminiResponse, +} from './parsers'; + +// Utils +export { + getTestBody, + getTestHeaders, + getTestUrl, + DEFAULT_MODELS, + DEFAULT_SUCCESS_CONTAINS, + API_ENDPOINTS, + extractTextFromSSE, + parseSSEStream, + isSSEResponse, + parseNDJSONStream, +} from './utils'; diff --git a/src/lib/provider-testing/parsers/anthropic-parser.ts b/src/lib/provider-testing/parsers/anthropic-parser.ts new file mode 100644 index 000000000..eff3f416c --- /dev/null +++ b/src/lib/provider-testing/parsers/anthropic-parser.ts @@ -0,0 +1,93 @@ +/** + * Anthropic Messages API Response Parser + * Handles both streaming and non-streaming responses + */ + +import type { ParsedResponse, TokenUsage } from '../types'; +import { parseSSEStream, isSSEResponse } from '../utils/sse-collector'; + +/** + * Anthropic non-streaming response structure + */ +interface AnthropicResponse { + id?: string; + type?: string; + role?: string; + model?: string; + content?: Array<{ + type: string; + text?: string; + }>; + stop_reason?: string; + usage?: { + input_tokens?: number; + output_tokens?: number; + cache_creation_input_tokens?: number; + cache_read_input_tokens?: number; + }; + error?: { + type?: string; + message?: string; + }; +} + +/** + * Parse Anthropic Messages API response + */ +export function parseAnthropicResponse( + body: string, + contentType?: string +): ParsedResponse { + // Check if streaming response + if (isSSEResponse(body, contentType)) { + return parseSSEStream(body); + } + + // Parse non-streaming JSON response + try { + const data = JSON.parse(body) as AnthropicResponse; + + // Handle error response + if (data.error) { + return { + content: data.error.message || 'Unknown error', + model: undefined, + usage: undefined, + isStreaming: false, + }; + } + + // Extract text content + const textParts = + data.content?.filter((c) => c.type === 'text').map((c) => c.text || '') || + []; + + const content = textParts.join(''); + + // Extract usage + let usage: TokenUsage | undefined; + if (data.usage) { + usage = { + inputTokens: data.usage.input_tokens || 0, + outputTokens: data.usage.output_tokens || 0, + cacheCreationInputTokens: data.usage.cache_creation_input_tokens, + cacheReadInputTokens: data.usage.cache_read_input_tokens, + }; + } + + return { + content, + model: data.model, + usage, + isStreaming: false, + }; + } catch { + // Return raw body if JSON parsing fails + return { + content: body.slice(0, 500), + model: undefined, + usage: undefined, + isStreaming: false, + }; + } +} diff --git a/src/lib/provider-testing/parsers/codex-parser.ts b/src/lib/provider-testing/parsers/codex-parser.ts new file mode 100644 index 000000000..7758f7764 --- /dev/null +++ b/src/lib/provider-testing/parsers/codex-parser.ts @@ -0,0 +1,142 @@ +/** + * Codex Response API Parser + * Handles streaming and non-streaming responses for /v1/responses endpoint + */ + +import type { ParsedResponse, TokenUsage } from '../types'; +import { + parseSSEStream, + isSSEResponse, + parseNDJSONStream, +} from '../utils/sse-collector'; + +/** + * Codex Response API response structure + */ +interface CodexResponse { + id?: string; + object?: string; + created_at?: number; + model?: string; + output?: Array<{ + type?: string; + id?: string; + status?: string; + role?: string; + content?: Array<{ + type?: string; + text?: string; + annotations?: unknown[]; + }>; + }>; + usage?: { + input_tokens?: number; + output_tokens?: number; + total_tokens?: number; + }; + error?: { + message?: string; + type?: string; + code?: string; + }; +} + +/** + * Check if response is NDJSON format + * Codex often uses NDJSON instead of SSE + */ +function isNDJSONResponse(body: string, contentType?: string): boolean { + if (contentType?.includes('application/x-ndjson')) { + return true; + } + + // Check if body has multiple JSON objects on separate lines + const lines = body.split('\n').filter((l) => l.trim()); + if (lines.length > 1) { + try { + // Try to parse first two lines as JSON + JSON.parse(lines[0]); + JSON.parse(lines[1]); + return true; + } catch { + return false; + } + } + + return false; +} + +/** + * Parse Codex Response API response + */ +export function parseCodexResponse( + body: string, + contentType?: string +): ParsedResponse { + // Check if streaming SSE response + if (isSSEResponse(body, contentType)) { + return parseSSEStream(body); + } + + // Check if NDJSON streaming response + if (isNDJSONResponse(body, contentType)) { + return parseNDJSONStream(body); + } + + // Parse non-streaming JSON response + try { + const data = JSON.parse(body) as CodexResponse; + + // Handle error response + if (data.error) { + return { + content: data.error.message || 'Unknown error', + model: undefined, + usage: undefined, + isStreaming: false, + }; + } + + // Extract text content from output array + const texts: string[] = []; + if (data.output && Array.isArray(data.output)) { + for (const item of data.output) { + if (item.content && Array.isArray(item.content)) { + for (const c of item.content) { + if (c.type === 'output_text' && c.text) { + texts.push(c.text); + } else if (c.text) { + texts.push(c.text); + } + } + } + } + } + + const content = texts.join(''); + + // Extract usage + let usage: TokenUsage | undefined; + if (data.usage) { + usage = { + inputTokens: data.usage.input_tokens || 0, + outputTokens: data.usage.output_tokens || 0, + }; + } + + return { + content, + model: data.model, + usage, + isStreaming: false, + }; + } catch { + // Return raw body if JSON parsing fails + return { + content: body.slice(0, 500), + model: undefined, + usage: undefined, + isStreaming: false, + }; + } +} diff --git a/src/lib/provider-testing/parsers/gemini-parser.ts b/src/lib/provider-testing/parsers/gemini-parser.ts new file mode 100644 index 000000000..8f2bb4765 --- /dev/null +++ b/src/lib/provider-testing/parsers/gemini-parser.ts @@ -0,0 +1,99 @@ +/** + * Gemini GenerateContent API Response Parser + * Handles non-streaming responses (Gemini uses different endpoint for streaming) + */ + +import type { ParsedResponse, TokenUsage } from '../types'; + +/** + * Gemini GenerateContent response structure + */ +interface GeminiResponse { + candidates?: Array<{ + content?: { + parts?: Array<{ + text?: string; + }>; + role?: string; + }; + finishReason?: string; + index?: number; + safetyRatings?: Array<{ + category?: string; + probability?: string; + }>; + }>; + usageMetadata?: { + promptTokenCount?: number; + candidatesTokenCount?: number; + totalTokenCount?: number; + }; + modelVersion?: string; + error?: { + code?: number; + message?: string; + status?: string; + }; +} + +/** + * Parse Gemini GenerateContent API response + */ +export function parseGeminiResponse( + body: string, + _contentType?: string +): ParsedResponse { + try { + const data = JSON.parse(body) as GeminiResponse; + + // Handle error response + if (data.error) { + return { + content: data.error.message || 'Unknown error', + model: undefined, + usage: undefined, + isStreaming: false, + }; + } + + // Extract text content from candidates + const texts: string[] = []; + if (data.candidates && Array.isArray(data.candidates)) { + for (const candidate of data.candidates) { + if (candidate.content?.parts && Array.isArray(candidate.content.parts)) { + for (const part of candidate.content.parts) { + if (part.text) { + texts.push(part.text); + } + } + } + } + } + + const content = texts.join(''); + + // Extract usage + let usage: TokenUsage | undefined; + if (data.usageMetadata) { + usage = { + inputTokens: data.usageMetadata.promptTokenCount || 0, + outputTokens: data.usageMetadata.candidatesTokenCount || 0, + }; + } + + return { + content, + model: data.modelVersion, + usage, + isStreaming: false, + }; + } catch { + // Return raw body if JSON parsing fails + return { + content: body.slice(0, 500), + model: undefined, + usage: undefined, + isStreaming: false, + }; + } +} diff --git a/src/lib/provider-testing/parsers/index.ts b/src/lib/provider-testing/parsers/index.ts new file mode 100644 index 000000000..f83d4b182 --- /dev/null +++ b/src/lib/provider-testing/parsers/index.ts @@ -0,0 +1,56 @@ +/** + * Response Parsers Index + * Provides unified parser selection based on provider type + */ + +import type { ProviderType } from '@/types/provider'; +import type { ParsedResponse } from '../types'; +import { parseAnthropicResponse } from './anthropic-parser'; +import { parseOpenAIResponse } from './openai-parser'; +import { parseCodexResponse } from './codex-parser'; +import { parseGeminiResponse } from './gemini-parser'; + +export { parseAnthropicResponse } from './anthropic-parser'; +export { parseOpenAIResponse } from './openai-parser'; +export { parseCodexResponse } from './codex-parser'; +export { parseGeminiResponse } from './gemini-parser'; + +/** + * Parser function type + */ +export type ResponseParser = (body: string, contentType?: string) => ParsedResponse; + +/** + * Parser registry by provider type + */ +const parserRegistry: Record = { + claude: parseAnthropicResponse, + 'claude-auth': parseAnthropicResponse, + codex: parseCodexResponse, + 'openai-compatible': parseOpenAIResponse, + gemini: parseGeminiResponse, + 'gemini-cli': parseGeminiResponse, +}; + +/** + * Get the appropriate parser for a provider type + */ +export function getParser(providerType: ProviderType): ResponseParser { + const parser = parserRegistry[providerType]; + if (!parser) { + throw new Error(`No parser available for provider type: ${providerType}`); + } + return parser; +} + +/** + * Parse response using the appropriate parser for the provider type + */ +export function parseResponse( + providerType: ProviderType, + body: string, + contentType?: string +): ParsedResponse { + const parser = getParser(providerType); + return parser(body, contentType); +} diff --git a/src/lib/provider-testing/parsers/openai-parser.ts b/src/lib/provider-testing/parsers/openai-parser.ts new file mode 100644 index 000000000..39fbbb33f --- /dev/null +++ b/src/lib/provider-testing/parsers/openai-parser.ts @@ -0,0 +1,94 @@ +/** + * OpenAI Chat Completions API Response Parser + * Handles both streaming and non-streaming responses + */ + +import type { ParsedResponse, TokenUsage } from '../types'; +import { parseSSEStream, isSSEResponse } from '../utils/sse-collector'; + +/** + * OpenAI non-streaming response structure + */ +interface OpenAIResponse { + id?: string; + object?: string; + created?: number; + model?: string; + choices?: Array<{ + index?: number; + message?: { + role?: string; + content?: string; + }; + finish_reason?: string; + }>; + usage?: { + prompt_tokens?: number; + completion_tokens?: number; + total_tokens?: number; + }; + error?: { + message?: string; + type?: string; + code?: string; + }; +} + +/** + * Parse OpenAI Chat Completions API response + */ +export function parseOpenAIResponse( + body: string, + contentType?: string +): ParsedResponse { + // Check if streaming response + if (isSSEResponse(body, contentType)) { + return parseSSEStream(body); + } + + // Parse non-streaming JSON response + try { + const data = JSON.parse(body) as OpenAIResponse; + + // Handle error response + if (data.error) { + return { + content: data.error.message || 'Unknown error', + model: undefined, + usage: undefined, + isStreaming: false, + }; + } + + // Extract text content from choices + const content = + data.choices + ?.map((c) => c.message?.content || '') + .filter(Boolean) + .join('') || ''; + + // Extract usage + let usage: TokenUsage | undefined; + if (data.usage) { + usage = { + inputTokens: data.usage.prompt_tokens || 0, + outputTokens: data.usage.completion_tokens || 0, + }; + } + + return { + content, + model: data.model, + usage, + isStreaming: false, + }; + } catch { + // Return raw body if JSON parsing fails + return { + content: body.slice(0, 500), + model: undefined, + usage: undefined, + isStreaming: false, + }; + } +} diff --git a/src/lib/provider-testing/test-service.ts b/src/lib/provider-testing/test-service.ts new file mode 100644 index 000000000..03ef76724 --- /dev/null +++ b/src/lib/provider-testing/test-service.ts @@ -0,0 +1,267 @@ +/** + * Provider Testing Service + * Main entry point for unified provider testing + * + * Implements three-tier validation from relay-pulse: + * 1. HTTP Status Code validation + * 2. Latency threshold validation + * 3. Content validation (success_contains) + */ + +import type { + ProviderTestConfig, + ProviderTestResult, + TestStatus, + TestSubStatus, + ValidationDetails, +} from './types'; +import { TEST_DEFAULTS } from './types'; +import { classifyHttpStatus } from './validators/http-validator'; +import { evaluateContentValidation } from './validators/content-validator'; +import { parseResponse } from './parsers'; +import { + getTestBody, + getTestHeaders, + getTestUrl, + DEFAULT_SUCCESS_CONTAINS, +} from './utils/test-prompts'; + +/** + * Execute a provider test with three-tier validation + */ +export async function executeProviderTest( + config: ProviderTestConfig +): Promise { + const startTime = Date.now(); + let firstByteMs: number | undefined; + + // Build test configuration with defaults + const timeoutMs = config.timeoutMs ?? TEST_DEFAULTS.TIMEOUT_MS; + const slowThresholdMs = + config.latencyThresholdMs ?? TEST_DEFAULTS.SLOW_LATENCY_MS; + const successContains = + config.successContains ?? DEFAULT_SUCCESS_CONTAINS[config.providerType]; + + // Build request URL + const url = getTestUrl( + config.providerUrl, + config.providerType, + config.model, + // Only pass API key for Gemini (URL parameter) + config.providerType === 'gemini' || config.providerType === 'gemini-cli' + ? config.apiKey + : undefined + ); + + // Build request body and headers + const body = getTestBody(config.providerType, config.model); + const headers = getTestHeaders(config.providerType, config.apiKey); + + try { + // Create abort controller for timeout + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeoutMs); + + // Execute request + const response = await fetch(url, { + method: 'POST', + headers, + body: JSON.stringify(body), + signal: controller.signal, + }); + + clearTimeout(timeoutId); + firstByteMs = Date.now() - startTime; + + // Read response body + const responseBody = await response.text(); + const latencyMs = Date.now() - startTime; + const contentType = response.headers.get('content-type') || undefined; + + // Tier 1: HTTP Status validation + const httpResult = classifyHttpStatus( + response.status, + latencyMs, + slowThresholdMs + ); + + // Parse response content + const parsedResponse = parseResponse( + config.providerType, + responseBody, + contentType + ); + + // Tier 2 & 3: Content validation (only if HTTP passed) + const contentResult = evaluateContentValidation( + httpResult.status, + httpResult.subStatus, + parsedResponse.content, + successContains + ); + + // Build validation details + const validationDetails: ValidationDetails = { + httpPassed: response.ok, + httpStatusCode: response.status, + latencyPassed: latencyMs <= slowThresholdMs, + latencyMs, + contentPassed: contentResult.contentPassed, + contentTarget: successContains, + }; + + // Build result + return { + success: contentResult.status !== 'red', + status: contentResult.status, + subStatus: contentResult.subStatus, + latencyMs, + firstByteMs, + httpStatusCode: response.status, + httpStatusText: response.statusText, + model: parsedResponse.model, + content: parsedResponse.content.slice(0, 500), // Truncate for safety + usage: parsedResponse.usage, + streamInfo: parsedResponse.isStreaming + ? { + isStreaming: true, + chunksReceived: parsedResponse.chunksReceived, + } + : undefined, + testedAt: new Date(), + validationDetails, + }; + } catch (error) { + const latencyMs = Date.now() - startTime; + + // Classify error type + const { subStatus, errorType, errorMessage } = classifyError(error); + + // Build validation details for failure + const validationDetails: ValidationDetails = { + httpPassed: false, + latencyPassed: false, + latencyMs, + contentPassed: false, + contentTarget: successContains, + }; + + return { + success: false, + status: 'red', + subStatus, + latencyMs, + firstByteMs, + errorMessage, + errorType, + rawError: error, + testedAt: new Date(), + validationDetails, + }; + } +} + +/** + * Classify error into sub-status and message + */ +function classifyError(error: unknown): { + subStatus: TestSubStatus; + errorType: string; + errorMessage: string; +} { + if (error instanceof Error) { + const message = error.message.toLowerCase(); + + // Timeout errors + if ( + error.name === 'AbortError' || + message.includes('timeout') || + message.includes('aborted') + ) { + return { + subStatus: 'network_error', + errorType: 'timeout', + errorMessage: 'Request timed out', + }; + } + + // DNS/connection errors + if ( + message.includes('getaddrinfo') || + message.includes('enotfound') || + message.includes('dns') + ) { + return { + subStatus: 'network_error', + errorType: 'dns_error', + errorMessage: 'DNS resolution failed', + }; + } + + // Connection refused + if ( + message.includes('econnrefused') || + message.includes('connection refused') + ) { + return { + subStatus: 'network_error', + errorType: 'connection_refused', + errorMessage: 'Connection refused', + }; + } + + // Connection reset + if (message.includes('econnreset') || message.includes('connection reset')) { + return { + subStatus: 'network_error', + errorType: 'connection_reset', + errorMessage: 'Connection reset by peer', + }; + } + + // SSL/TLS errors + if ( + message.includes('ssl') || + message.includes('tls') || + message.includes('certificate') + ) { + return { + subStatus: 'network_error', + errorType: 'ssl_error', + errorMessage: 'SSL/TLS error', + }; + } + + // Generic network error + return { + subStatus: 'network_error', + errorType: 'network_error', + errorMessage: error.message, + }; + } + + // Unknown error type + return { + subStatus: 'network_error', + errorType: 'unknown_error', + errorMessage: String(error), + }; +} + +/** + * Get availability weight for a status + * Used for calculating weighted availability scores + */ +export function getStatusWeight( + status: TestStatus, + degradedWeight: number = TEST_DEFAULTS.DEGRADED_WEIGHT +): number { + switch (status) { + case 'green': + return 1.0; + case 'yellow': + return degradedWeight; + case 'red': + return 0.0; + } +} diff --git a/src/lib/provider-testing/types.ts b/src/lib/provider-testing/types.ts new file mode 100644 index 000000000..e8c416de5 --- /dev/null +++ b/src/lib/provider-testing/types.ts @@ -0,0 +1,268 @@ +/** + * Provider Testing Service Types + * Based on relay-pulse implementation patterns + */ + +import type { ProviderType } from '@/types/provider'; + +// ============================================================================ +// Test Status Types (3-level system from relay-pulse) +// ============================================================================ + +/** + * Primary test status + * - green: All validations passed + * - yellow: HTTP OK but degraded (slow latency) + * - red: Any failure + */ +export type TestStatus = 'green' | 'yellow' | 'red'; + +/** + * Detailed sub-status for granular error classification + * Maps to relay-pulse's 8 SubStatus categories + */ +export type TestSubStatus = + | 'success' // All validations passed + | 'slow_latency' // HTTP OK but latency exceeds threshold + | 'rate_limit' // HTTP 429 + | 'server_error' // HTTP 5xx + | 'client_error' // HTTP 4xx (excluding specific codes) + | 'auth_error' // HTTP 401/403 + | 'invalid_request' // HTTP 400 + | 'network_error' // Connection/DNS/timeout errors + | 'content_mismatch'; // Response content validation failed + +/** + * Numeric status values for availability calculation + * Matches relay-pulse internal representation + */ +export const STATUS_VALUES = { + GREEN: 1, + YELLOW: 2, + RED: 0, + MISSING: -1, +} as const; + +export type StatusValue = (typeof STATUS_VALUES)[keyof typeof STATUS_VALUES]; + +// ============================================================================ +// Configuration Types +// ============================================================================ + +/** + * Default configuration constants from relay-pulse + */ +export const TEST_DEFAULTS = { + /** Total request timeout in milliseconds */ + TIMEOUT_MS: 10000, + /** Latency threshold for YELLOW status (ms) */ + SLOW_LATENCY_MS: 5000, + /** Weight for degraded (YELLOW) status in availability calculation */ + DEGRADED_WEIGHT: 0.7, + /** Default success validation string for Claude */ + SUCCESS_CONTAINS_CLAUDE: 'pong', + /** Default success validation string for Codex */ + SUCCESS_CONTAINS_CODEX: 'pong', + /** Default success validation string for OpenAI */ + SUCCESS_CONTAINS_OPENAI: 'pong', + /** Default success validation string for Gemini */ + SUCCESS_CONTAINS_GEMINI: 'pong', +} as const; + +/** + * Configuration for provider test execution + */ +export interface ProviderTestConfig { + /** Provider ID (for existing providers) */ + providerId?: string; + /** Provider base URL */ + providerUrl: string; + /** API key for authentication */ + apiKey: string; + /** Provider type determines request format */ + providerType: ProviderType; + /** Model to test (uses type-specific default if not provided) */ + model?: string; + /** Proxy URL (optional) */ + proxyUrl?: string; + /** Whether to fallback to direct if proxy fails */ + proxyFallbackToDirect?: boolean; + /** Latency threshold in ms (default: 5000) */ + latencyThresholdMs?: number; + /** String that must be present in response (default: type-specific) */ + successContains?: string; + /** Request timeout in ms (default: 10000) */ + timeoutMs?: number; +} + +// ============================================================================ +// Result Types +// ============================================================================ + +/** + * Token usage information from response + */ +export interface TokenUsage { + inputTokens: number; + outputTokens: number; + cacheCreationInputTokens?: number; + cacheReadInputTokens?: number; +} + +/** + * Validation details for each tier + */ +export interface ValidationDetails { + /** Tier 1: HTTP status code check passed */ + httpPassed: boolean; + /** HTTP status code received */ + httpStatusCode?: number; + /** Tier 2: Latency within threshold */ + latencyPassed: boolean; + /** Actual latency in ms */ + latencyMs?: number; + /** Tier 3: Content validation passed */ + contentPassed: boolean; + /** Content validation target string */ + contentTarget?: string; +} + +/** + * Complete test result + */ +export interface ProviderTestResult { + /** Overall success (status is green or yellow) */ + success: boolean; + /** Primary status */ + status: TestStatus; + /** Detailed sub-status */ + subStatus: TestSubStatus; + /** Total request latency in ms */ + latencyMs: number; + /** Time to first byte in ms (if available) */ + firstByteMs?: number; + /** HTTP status code */ + httpStatusCode?: number; + /** HTTP status text */ + httpStatusText?: string; + /** Model used in response */ + model?: string; + /** Response content (truncated) */ + content?: string; + /** Token usage */ + usage?: TokenUsage; + /** Stream info (if streaming response) */ + streamInfo?: { + isStreaming: boolean; + chunksReceived?: number; + }; + /** Error message (if failed) */ + errorMessage?: string; + /** Error type classification */ + errorType?: string; + /** Raw error object (for debugging) */ + rawError?: unknown; + /** Test timestamp */ + testedAt: Date; + /** Detailed validation results */ + validationDetails: ValidationDetails; + /** Whether proxy was used */ + usedProxy?: boolean; +} + +// ============================================================================ +// Parser Types +// ============================================================================ + +/** + * Parsed response from any provider format + */ +export interface ParsedResponse { + /** Extracted text content */ + content: string; + /** Model from response */ + model?: string; + /** Token usage */ + usage?: TokenUsage; + /** Whether response was streaming */ + isStreaming: boolean; + /** Number of chunks (for streaming) */ + chunksReceived?: number; +} + +/** + * Parser function signature + */ +export type ResponseParser = ( + body: string, + contentType?: string +) => ParsedResponse; + +// ============================================================================ +// Test Request Body Types +// ============================================================================ + +/** + * Claude Messages API request body + */ +export interface ClaudeTestBody { + model: string; + messages: Array<{ + role: 'user' | 'assistant'; + content: Array<{ type: 'text'; text: string }>; + }>; + system?: Array<{ + type: 'text'; + text: string; + cache_control?: { type: 'ephemeral' }; + }>; + max_tokens: number; + stream: boolean; + metadata?: { user_id: string }; +} + +/** + * Codex Response API request body + */ +export interface CodexTestBody { + model: string; + instructions: string; + input: Array<{ + type: 'message'; + role: 'user'; + content: Array<{ type: 'input_text'; text: string }>; + }>; + tools: unknown[]; + tool_choice: string; + reasoning?: { effort: string; summary: string }; + store: boolean; + stream: boolean; +} + +/** + * OpenAI Chat Completions request body + */ +export interface OpenAITestBody { + model: string; + messages: Array<{ + role: 'system' | 'user' | 'assistant'; + content: string; + }>; + max_tokens: number; + stream: boolean; +} + +/** + * Gemini GenerateContent request body + */ +export interface GeminiTestBody { + contents: Array<{ + parts: Array<{ text: string }>; + }>; + systemInstruction?: { + parts: Array<{ text: string }>; + }; + generationConfig?: { + maxOutputTokens: number; + }; +} diff --git a/src/lib/provider-testing/utils/index.ts b/src/lib/provider-testing/utils/index.ts new file mode 100644 index 000000000..466343eb1 --- /dev/null +++ b/src/lib/provider-testing/utils/index.ts @@ -0,0 +1,28 @@ +/** + * Utils Index + * Exports all utility functions + */ + +export { + CLAUDE_TEST_BODY, + CLAUDE_TEST_HEADERS, + CODEX_TEST_BODY, + CODEX_TEST_HEADERS, + OPENAI_TEST_BODY, + OPENAI_TEST_HEADERS, + GEMINI_TEST_BODY, + GEMINI_TEST_HEADERS, + DEFAULT_MODELS, + DEFAULT_SUCCESS_CONTAINS, + API_ENDPOINTS, + getTestBody, + getTestHeaders, + getTestUrl, +} from './test-prompts'; + +export { + extractTextFromSSE, + parseSSEStream, + isSSEResponse, + parseNDJSONStream, +} from './sse-collector'; diff --git a/src/lib/provider-testing/utils/sse-collector.ts b/src/lib/provider-testing/utils/sse-collector.ts new file mode 100644 index 000000000..3fbac40ce --- /dev/null +++ b/src/lib/provider-testing/utils/sse-collector.ts @@ -0,0 +1,279 @@ +/** + * SSE Stream Collector + * Parses Server-Sent Events (SSE) streams and extracts text content + * Based on relay-pulse extractTextFromSSE implementation + * + * Supports multiple formats: + * - Anthropic: {"delta":{"text":"..."}} + * - OpenAI: {"choices":[{"delta":{"content":"..."}}]} + * - Codex Response API: {"output":[{"content":[{"text":"..."}]}]} + */ + +import type { TokenUsage, ParsedResponse } from '../types'; + +/** + * Extract text content from an SSE stream body + * Handles both Anthropic and OpenAI streaming formats + */ +export function extractTextFromSSE(body: string): string { + const lines = body.split('\n'); + const texts: string[] = []; + + for (const line of lines) { + const trimmed = line.trim(); + + // Skip non-data lines + if (!trimmed.startsWith('data:')) { + continue; + } + + // Extract payload after "data:" + const payload = trimmed.slice(5).trim(); + + // Skip empty or [DONE] markers + if (!payload || payload === '[DONE]') { + continue; + } + + try { + const obj = JSON.parse(payload) as Record; + + // Anthropic format: {"type":"content_block_delta", "delta":{"type":"text_delta","text":"..."}} + const delta = obj.delta as Record | undefined; + if (delta?.text && typeof delta.text === 'string') { + texts.push(delta.text); + continue; + } + + // OpenAI format: {"choices":[{"delta":{"content":"..."}}]} + const choices = obj.choices as Array<{ + delta?: { content?: string }; + }> | undefined; + if (choices && Array.isArray(choices)) { + for (const choice of choices) { + if (choice.delta?.content) { + texts.push(choice.delta.content); + } + } + continue; + } + + // Codex Response API format: {"output":[{"content":[{"text":"..."}]}]} + const output = obj.output as Array<{ + content?: Array<{ text?: string }>; + }> | undefined; + if (output && Array.isArray(output)) { + for (const item of output) { + if (item.content && Array.isArray(item.content)) { + for (const c of item.content) { + if (c.text) { + texts.push(c.text); + } + } + } + } + continue; + } + + // Generic fallback: top-level content/message fields + if (obj.content && typeof obj.content === 'string') { + texts.push(obj.content); + continue; + } + if (obj.message && typeof obj.message === 'string') { + texts.push(obj.message); + continue; + } + if (obj.text && typeof obj.text === 'string') { + texts.push(obj.text); + continue; + } + } catch { + // Not valid JSON, use raw payload (could be error message) + if (payload.length < 500) { + texts.push(payload); + } + } + } + + return texts.join(''); +} + +/** + * Parse a complete SSE stream into a structured response + */ +export function parseSSEStream(body: string): ParsedResponse { + const lines = body.split('\n'); + const texts: string[] = []; + let model: string | undefined; + let usage: TokenUsage | undefined; + let chunksReceived = 0; + + for (const line of lines) { + const trimmed = line.trim(); + + if (!trimmed.startsWith('data:')) { + continue; + } + + const payload = trimmed.slice(5).trim(); + if (!payload || payload === '[DONE]') { + continue; + } + + chunksReceived++; + + try { + const obj = JSON.parse(payload) as Record; + + // Extract model from first chunk + if (!model && obj.model && typeof obj.model === 'string') { + model = obj.model; + } + + // Anthropic format + const delta = obj.delta as Record | undefined; + if (delta?.text && typeof delta.text === 'string') { + texts.push(delta.text); + } + + // OpenAI format + const choices = obj.choices as Array<{ + delta?: { content?: string }; + }> | undefined; + if (choices) { + for (const choice of choices) { + if (choice.delta?.content) { + texts.push(choice.delta.content); + } + } + } + + // Codex Response API format + const output = obj.output as Array<{ + content?: Array<{ text?: string }>; + }> | undefined; + if (output) { + for (const item of output) { + if (item.content) { + for (const c of item.content) { + if (c.text) texts.push(c.text); + } + } + } + } + + // Extract usage from final chunk (Anthropic message_delta) + if (obj.type === 'message_delta') { + const msgUsage = obj.usage as { + output_tokens?: number; + } | undefined; + if (msgUsage?.output_tokens) { + usage = { + inputTokens: 0, + outputTokens: msgUsage.output_tokens, + }; + } + } + + // OpenAI usage in final chunk + const objUsage = obj.usage as { + prompt_tokens?: number; + completion_tokens?: number; + total_tokens?: number; + } | undefined; + if (objUsage) { + usage = { + inputTokens: objUsage.prompt_tokens || 0, + outputTokens: objUsage.completion_tokens || 0, + }; + } + } catch { + // Skip invalid JSON chunks + } + } + + return { + content: texts.join(''), + model, + usage, + isStreaming: true, + chunksReceived, + }; +} + +/** + * Check if a response body appears to be an SSE stream + */ +export function isSSEResponse(body: string, contentType?: string): boolean { + // Check Content-Type header + if ( + contentType?.includes('text/event-stream') || + contentType?.includes('text/x-event-stream') + ) { + return true; + } + + // Check body pattern (multiple data: lines) + const dataLineCount = (body.match(/^data:/gm) || []).length; + return dataLineCount > 1; +} + +/** + * Parse NDJSON stream (newline-delimited JSON) + * Used by some streaming APIs + */ +export function parseNDJSONStream(body: string): ParsedResponse { + const lines = body.split('\n').filter((l) => l.trim()); + const texts: string[] = []; + let model: string | undefined; + let usage: TokenUsage | undefined; + + for (const line of lines) { + try { + const obj = JSON.parse(line) as Record; + + // Extract model + if (!model && obj.model && typeof obj.model === 'string') { + model = obj.model; + } + + // Extract content from various formats + const choices = obj.choices as Array<{ + delta?: { content?: string }; + message?: { content?: string }; + }> | undefined; + if (choices) { + for (const choice of choices) { + if (choice.delta?.content) { + texts.push(choice.delta.content); + } else if (choice.message?.content) { + texts.push(choice.message.content); + } + } + } + + // Extract usage + const objUsage = obj.usage as { + prompt_tokens?: number; + completion_tokens?: number; + } | undefined; + if (objUsage) { + usage = { + inputTokens: objUsage.prompt_tokens || 0, + outputTokens: objUsage.completion_tokens || 0, + }; + } + } catch { + // Skip invalid JSON lines + } + } + + return { + content: texts.join(''), + model, + usage, + isStreaming: true, + chunksReceived: lines.length, + }; +} diff --git a/src/lib/provider-testing/utils/test-prompts.ts b/src/lib/provider-testing/utils/test-prompts.ts new file mode 100644 index 000000000..6fd561160 --- /dev/null +++ b/src/lib/provider-testing/utils/test-prompts.ts @@ -0,0 +1,283 @@ +/** + * Default Test Prompts for Provider Testing + * Based on relay-pulse exact patterns + * + * These are minimal request bodies designed to: + * 1. Minimize token consumption (small prompts, low max_tokens) + * 2. Provide reliable content validation ("pong" response) + * 3. Support both streaming and non-streaming modes + */ + +import type { ProviderType } from '@/types/provider'; +import type { + ClaudeTestBody, + CodexTestBody, + OpenAITestBody, + GeminiTestBody, +} from '../types'; + +// ============================================================================ +// Claude / Claude-Auth Test Body +// ============================================================================ + +/** + * Claude Messages API test request body + * - Uses claude-sonnet-4-5-20250929 as default model + * - Non-streaming for faster response validation + * - Minimal token usage with echo bot pattern + */ +export const CLAUDE_TEST_BODY: ClaudeTestBody = { + model: 'claude-sonnet-4-5-20250929', + messages: [ + { + role: 'user', + content: [{ type: 'text', text: 'ping, please reply pong' }], + }, + ], + system: [ + { + type: 'text', + text: 'You are a echo bot. Always say pong.', + cache_control: { type: 'ephemeral' }, + }, + ], + max_tokens: 20, + stream: false, + metadata: { user_id: 'cch_probe_test' }, +}; + +/** + * Headers for Claude API + */ +export const CLAUDE_TEST_HEADERS = { + 'anthropic-version': '2023-06-01', + 'content-type': 'application/json', +}; + +// ============================================================================ +// Codex Test Body (Response API format) +// ============================================================================ + +/** + * Codex Response API test request body + * - Uses gpt-5-codex as default model + * - Streaming enabled (Codex typically streams) + * - Low reasoning effort for faster response + */ +export const CODEX_TEST_BODY: CodexTestBody = { + model: 'gpt-5-codex', + instructions: 'You are a echo bot. Always say pong.', + input: [ + { + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: 'ping' }], + }, + ], + tools: [], + tool_choice: 'auto', + reasoning: { effort: 'low', summary: 'auto' }, + store: false, + stream: true, +}; + +/** + * Headers for Codex API (uses Bearer token) + */ +export const CODEX_TEST_HEADERS = { + 'content-type': 'application/json', +}; + +// ============================================================================ +// OpenAI-Compatible Test Body +// ============================================================================ + +/** + * OpenAI Chat Completions test request body + * - Uses gpt-4o as default model + * - Non-streaming for simpler validation + */ +export const OPENAI_TEST_BODY: OpenAITestBody = { + model: 'gpt-4o', + messages: [ + { role: 'system', content: 'You are a echo bot. Always say pong.' }, + { role: 'user', content: 'ping' }, + ], + max_tokens: 20, + stream: false, +}; + +/** + * Headers for OpenAI-Compatible API (uses Bearer token) + */ +export const OPENAI_TEST_HEADERS = { + 'content-type': 'application/json', +}; + +// ============================================================================ +// Gemini Test Body +// ============================================================================ + +/** + * Gemini GenerateContent test request body + * - Uses gemini-2.0-flash as default model + * - Simple content structure + */ +export const GEMINI_TEST_BODY: GeminiTestBody = { + contents: [ + { + parts: [{ text: 'ping, please reply pong' }], + }, + ], + systemInstruction: { + parts: [{ text: 'You are a echo bot. Always say pong.' }], + }, + generationConfig: { + maxOutputTokens: 20, + }, +}; + +/** + * Headers for Gemini API + */ +export const GEMINI_TEST_HEADERS = { + 'content-type': 'application/json', +}; + +// ============================================================================ +// Provider Type Mappings +// ============================================================================ + +/** + * Default models per provider type + */ +export const DEFAULT_MODELS: Record = { + claude: 'claude-sonnet-4-5-20250929', + 'claude-auth': 'claude-sonnet-4-5-20250929', + codex: 'gpt-5-codex', + 'openai-compatible': 'gpt-4o', + gemini: 'gemini-2.0-flash', + 'gemini-cli': 'gemini-2.0-flash', +}; + +/** + * Default success_contains patterns per provider type + */ +export const DEFAULT_SUCCESS_CONTAINS: Record = { + claude: 'pong', + 'claude-auth': 'pong', + codex: 'pong', + 'openai-compatible': 'pong', + gemini: 'pong', + 'gemini-cli': 'pong', +}; + +/** + * API endpoints per provider type + */ +export const API_ENDPOINTS: Record = { + claude: '/v1/messages', + 'claude-auth': '/v1/messages', + codex: '/v1/responses', + 'openai-compatible': '/v1/chat/completions', + gemini: '/v1beta/models/{model}:generateContent', + 'gemini-cli': '/v1beta/models/{model}:generateContent', +}; + +/** + * Get test body for a specific provider type + */ +export function getTestBody( + providerType: ProviderType, + model?: string +): Record { + const targetModel = model || DEFAULT_MODELS[providerType]; + + switch (providerType) { + case 'claude': + case 'claude-auth': + return { ...CLAUDE_TEST_BODY, model: targetModel }; + + case 'codex': + return { ...CODEX_TEST_BODY, model: targetModel }; + + case 'openai-compatible': + return { ...OPENAI_TEST_BODY, model: targetModel }; + + case 'gemini': + case 'gemini-cli': + // Gemini model is in URL, not body + return { ...GEMINI_TEST_BODY }; + + default: + throw new Error(`Unsupported provider type: ${providerType}`); + } +} + +/** + * Get test headers for a specific provider type + */ +export function getTestHeaders( + providerType: ProviderType, + apiKey: string +): Record { + switch (providerType) { + case 'claude': + return { + ...CLAUDE_TEST_HEADERS, + 'x-api-key': apiKey, + }; + + case 'claude-auth': + // Claude-auth uses Bearer token + return { + ...CLAUDE_TEST_HEADERS, + Authorization: `Bearer ${apiKey}`, + }; + + case 'codex': + case 'openai-compatible': + return { + ...OPENAI_TEST_HEADERS, + Authorization: `Bearer ${apiKey}`, + }; + + case 'gemini': + case 'gemini-cli': + // Gemini uses URL parameter for API key + return { + ...GEMINI_TEST_HEADERS, + }; + + default: + throw new Error(`Unsupported provider type: ${providerType}`); + } +} + +/** + * Get the full test URL for a provider + */ +export function getTestUrl( + baseUrl: string, + providerType: ProviderType, + model?: string, + apiKey?: string +): string { + // Remove trailing slash + const cleanBaseUrl = baseUrl.replace(/\/$/, ''); + const endpoint = API_ENDPOINTS[providerType]; + const targetModel = model || DEFAULT_MODELS[providerType]; + + let url = `${cleanBaseUrl}${endpoint}`; + + // Gemini needs model in URL + if (providerType === 'gemini' || providerType === 'gemini-cli') { + url = url.replace('{model}', targetModel); + // Add API key as query parameter for Gemini + if (apiKey) { + url += `?key=${apiKey}`; + } + } + + return url; +} diff --git a/src/lib/provider-testing/validators/content-validator.ts b/src/lib/provider-testing/validators/content-validator.ts new file mode 100644 index 000000000..f2b406ef4 --- /dev/null +++ b/src/lib/provider-testing/validators/content-validator.ts @@ -0,0 +1,144 @@ +/** + * Content Validator (Tier 2 & 3) + * Validates response content contains expected string + * Based on relay-pulse implementation + */ + +import type { TestStatus, TestSubStatus } from '../types'; + +export interface ContentValidationResult { + status: TestStatus; + subStatus: TestSubStatus; + contentPassed: boolean; +} + +/** + * Evaluate content validation on a response + * + * Rules from relay-pulse: + * 1. If no successContains configured, skip validation + * 2. If already RED, no need to validate (can't get worse) + * 3. If rate_limit (429), skip content validation (error response) + * 4. Empty response = content mismatch + * 5. Check if response contains expected content + */ +export function evaluateContentValidation( + baseStatus: TestStatus, + baseSubStatus: TestSubStatus, + responseBody: string, + successContains?: string +): ContentValidationResult { + // No validation configured - pass through + if (!successContains) { + return { + status: baseStatus, + subStatus: baseSubStatus, + contentPassed: true, + }; + } + + // Already red - no need to validate (can't get worse) + if (baseStatus === 'red') { + return { + status: baseStatus, + subStatus: baseSubStatus, + contentPassed: false, + }; + } + + // 429 rate limit: response body is error message, skip content validation + if (baseSubStatus === 'rate_limit') { + return { + status: baseStatus, + subStatus: baseSubStatus, + contentPassed: false, + }; + } + + // Empty response = content mismatch + if (!responseBody || !responseBody.trim()) { + return { + status: 'red', + subStatus: 'content_mismatch', + contentPassed: false, + }; + } + + // Check if response contains expected content + if (!responseBody.includes(successContains)) { + return { + status: 'red', + subStatus: 'content_mismatch', + contentPassed: false, + }; + } + + // Content validation passed + return { + status: baseStatus, + subStatus: baseSubStatus, + contentPassed: true, + }; +} + +/** + * Extract readable text content from various response formats + * This is a simplified version - actual parsing is done in parsers + */ +export function extractTextContent(responseBody: string): string { + // Try to parse as JSON and extract common text fields + try { + const obj = JSON.parse(responseBody); + + // Anthropic format + if (obj.content && Array.isArray(obj.content)) { + return obj.content + .filter((c: { type: string }) => c.type === 'text') + .map((c: { text: string }) => c.text) + .join(''); + } + + // OpenAI format + if (obj.choices && Array.isArray(obj.choices)) { + return obj.choices + .map( + (c: { message?: { content: string }; text?: string }) => + c.message?.content || c.text || '' + ) + .join(''); + } + + // Codex Response API format + if (obj.output && Array.isArray(obj.output)) { + return obj.output + .flatMap((o: { content?: Array<{ text?: string }> }) => + o.content?.map((c) => c.text || '').filter(Boolean) || [] + ) + .join(''); + } + + // Gemini format + if (obj.candidates && Array.isArray(obj.candidates)) { + return obj.candidates + .flatMap( + (c: { content?: { parts?: Array<{ text?: string }> } }) => + c.content?.parts?.map((p) => p.text || '').filter(Boolean) || [] + ) + .join(''); + } + + // Direct content field + if (typeof obj.content === 'string') { + return obj.content; + } + + // Direct text field + if (typeof obj.text === 'string') { + return obj.text; + } + } catch { + // Not JSON, return as-is + } + + return responseBody; +} diff --git a/src/lib/provider-testing/validators/http-validator.ts b/src/lib/provider-testing/validators/http-validator.ts new file mode 100644 index 000000000..c39676a33 --- /dev/null +++ b/src/lib/provider-testing/validators/http-validator.ts @@ -0,0 +1,96 @@ +/** + * HTTP Status Validator (Tier 1) + * Classifies HTTP status codes into test status and sub-status + * Based on relay-pulse implementation + */ + +import { TEST_DEFAULTS, type TestStatus, type TestSubStatus } from '../types'; + +export interface HttpValidationResult { + status: TestStatus; + subStatus: TestSubStatus; +} + +/** + * Classify HTTP status code into test status + * + * Classification rules from relay-pulse: + * - 2xx: GREEN (or YELLOW if slow) + * - 3xx: GREEN (redirects handled by client) + * - 400: RED (invalid_request) + * - 401/403: RED (auth_error) + * - 429: RED (rate_limit) + * - 4xx: RED (client_error) + * - 5xx: RED (server_error) + */ +export function classifyHttpStatus( + statusCode: number, + latencyMs: number, + slowThresholdMs: number = TEST_DEFAULTS.SLOW_LATENCY_MS +): HttpValidationResult { + // 2xx = Green (or Yellow if slow) + if (statusCode >= 200 && statusCode < 300) { + if (latencyMs > slowThresholdMs) { + return { status: 'yellow', subStatus: 'slow_latency' }; + } + return { status: 'green', subStatus: 'success' }; + } + + // 3xx = Green (redirects handled by HTTP client) + if (statusCode >= 300 && statusCode < 400) { + return { status: 'green', subStatus: 'success' }; + } + + // 401/403 = Red (auth failure) + if (statusCode === 401 || statusCode === 403) { + return { status: 'red', subStatus: 'auth_error' }; + } + + // 400 = Red (invalid request) + if (statusCode === 400) { + return { status: 'red', subStatus: 'invalid_request' }; + } + + // 429 = Red (rate limit) + if (statusCode === 429) { + return { status: 'red', subStatus: 'rate_limit' }; + } + + // 5xx = Red (server error) + if (statusCode >= 500) { + return { status: 'red', subStatus: 'server_error' }; + } + + // Other 4xx = Red (client error) + if (statusCode >= 400) { + return { status: 'red', subStatus: 'client_error' }; + } + + // 1xx or other non-standard = Red (client error) + return { status: 'red', subStatus: 'client_error' }; +} + +/** + * Check if HTTP status indicates success (2xx or 3xx) + */ +export function isHttpSuccess(statusCode: number): boolean { + return statusCode >= 200 && statusCode < 400; +} + +/** + * Get human-readable description for sub-status + */ +export function getSubStatusDescription(subStatus: TestSubStatus): string { + const descriptions: Record = { + success: 'All checks passed', + slow_latency: 'Response was slow but successful', + rate_limit: 'Rate limited (HTTP 429)', + server_error: 'Server error (HTTP 5xx)', + client_error: 'Client error (HTTP 4xx)', + auth_error: 'Authentication failed (HTTP 401/403)', + invalid_request: 'Invalid request (HTTP 400)', + network_error: 'Network connection failed', + content_mismatch: 'Response content validation failed', + }; + return descriptions[subStatus]; +} diff --git a/src/lib/provider-testing/validators/index.ts b/src/lib/provider-testing/validators/index.ts new file mode 100644 index 000000000..cfc34e915 --- /dev/null +++ b/src/lib/provider-testing/validators/index.ts @@ -0,0 +1,17 @@ +/** + * Validators Index + * Exports all validation utilities + */ + +export { + classifyHttpStatus, + isHttpSuccess, + getSubStatusDescription, + type HttpValidationResult, +} from './http-validator'; + +export { + evaluateContentValidation, + extractTextContent, + type ContentValidationResult, +} from './content-validator'; From 0dc1e81a6078510820c7e806ca292e2f7eccac99 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 26 Nov 2025 16:36:23 +0000 Subject: [PATCH 04/19] chore: format code (dev-f435387) --- src/actions/providers.ts | 10 +- .../_components/availability-view.tsx | 201 +++++++++--------- .../[locale]/dashboard/availability/page.tsx | 40 ++-- .../_components/forms/api-test-button.tsx | 9 +- .../_components/forms/test-result-card.tsx | 84 +++++--- src/app/api/availability/current/route.ts | 29 +-- src/app/api/availability/route.ts | 42 ++-- src/lib/availability/availability-service.ts | 61 +++--- src/lib/availability/index.ts | 4 +- src/lib/availability/types.ts | 2 +- src/lib/circuit-breaker-probe.ts | 68 +++--- src/lib/provider-testing/index.ts | 12 +- .../parsers/anthropic-parser.ts | 17 +- .../provider-testing/parsers/codex-parser.ts | 23 +- .../provider-testing/parsers/gemini-parser.ts | 11 +- src/lib/provider-testing/parsers/index.ts | 26 +-- .../provider-testing/parsers/openai-parser.ts | 15 +- src/lib/provider-testing/test-service.ts | 113 ++++------ src/lib/provider-testing/types.ts | 51 +++-- src/lib/provider-testing/utils/index.ts | 4 +- .../provider-testing/utils/sse-collector.ts | 115 +++++----- .../provider-testing/utils/test-prompts.ts | 135 ++++++------ .../validators/content-validator.ts | 37 ++-- .../validators/http-validator.ts | 38 ++-- src/lib/provider-testing/validators/index.ts | 4 +- 25 files changed, 541 insertions(+), 610 deletions(-) diff --git a/src/actions/providers.ts b/src/actions/providers.ts index 0bf73fb93..c3b2fbfaa 100644 --- a/src/actions/providers.ts +++ b/src/actions/providers.ts @@ -2308,9 +2308,7 @@ async function isUrlSafeForApiTest( * - yellow: HTTP OK but slow (degraded) * - red: Any validation failed */ -export async function testProviderUnified( - data: UnifiedTestArgs -): Promise { +export async function testProviderUnified(data: UnifiedTestArgs): Promise { const session = await getSession(); if (!session || session.user.role !== "admin") { return { @@ -2347,11 +2345,7 @@ export async function testProviderUnified( // Build response message const statusText = - result.status === "green" - ? "可用" - : result.status === "yellow" - ? "波动" - : "不可用"; + result.status === "green" ? "可用" : result.status === "yellow" ? "波动" : "不可用"; const message = `供应商 ${statusText}: ${SUB_STATUS_MESSAGES[result.subStatus]}`; diff --git a/src/app/[locale]/dashboard/availability/_components/availability-view.tsx b/src/app/[locale]/dashboard/availability/_components/availability-view.tsx index aa128a99f..0196ef7d1 100644 --- a/src/app/[locale]/dashboard/availability/_components/availability-view.tsx +++ b/src/app/[locale]/dashboard/availability/_components/availability-view.tsx @@ -1,44 +1,39 @@ -'use client'; +"use client"; -import { useState, useEffect, useCallback, useMemo } from 'react'; -import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; -import { Button } from '@/components/ui/button'; +import { useState, useEffect, useCallback, useMemo } from "react"; +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, -} from '@/components/ui/select'; -import { Badge } from '@/components/ui/badge'; -import { Skeleton } from '@/components/ui/skeleton'; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from '@/components/ui/tooltip'; -import { RefreshCw, Activity, CheckCircle2, XCircle, HelpCircle } from 'lucide-react'; -import { useTranslations } from 'next-intl'; -import { cn } from '@/lib/utils'; +} from "@/components/ui/select"; +import { Badge } from "@/components/ui/badge"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import { RefreshCw, Activity, CheckCircle2, XCircle, HelpCircle } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { cn } from "@/lib/utils"; import type { AvailabilityQueryResult, ProviderAvailabilitySummary, TimeBucketMetrics, -} from '@/lib/availability'; +} from "@/lib/availability"; -type TimeRangeOption = '15min' | '1h' | '6h' | '24h' | '7d'; -type SortOption = 'availability' | 'name' | 'requests'; +type TimeRangeOption = "15min" | "1h" | "6h" | "24h" | "7d"; +type SortOption = "availability" | "name" | "requests"; // Target number of buckets to fill the heatmap width consistently const TARGET_BUCKETS = 60; const TIME_RANGE_MAP: Record = { - '15min': 15 * 60 * 1000, - '1h': 60 * 60 * 1000, - '6h': 6 * 60 * 60 * 1000, - '24h': 24 * 60 * 60 * 1000, - '7d': 7 * 24 * 60 * 60 * 1000, + "15min": 15 * 60 * 1000, + "1h": 60 * 60 * 1000, + "6h": 6 * 60 * 60 * 1000, + "24h": 24 * 60 * 60 * 1000, + "7d": 7 * 24 * 60 * 60 * 1000, }; /** @@ -56,12 +51,12 @@ function calculateBucketSize(timeRangeMs: number): number { * Simple gradient: gray(no data) -> red -> green */ function getAvailabilityColor(score: number, hasData: boolean): string { - if (!hasData) return 'bg-slate-300 dark:bg-slate-600'; // Gray = no data + if (!hasData) return "bg-slate-300 dark:bg-slate-600"; // Gray = no data - if (score < 0.5) return 'bg-red-500'; - if (score < 0.8) return 'bg-orange-500'; - if (score < 0.95) return 'bg-lime-500'; - return 'bg-green-500'; + if (score < 0.5) return "bg-red-500"; + if (score < 0.8) return "bg-orange-500"; + if (score < 0.95) return "bg-lime-500"; + return "bg-green-500"; } /** @@ -71,27 +66,27 @@ function formatBucketTime(isoString: string, bucketSizeMinutes: number): string const date = new Date(isoString); if (bucketSizeMinutes >= 1440) { // Daily buckets: show date - return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); + return date.toLocaleDateString(undefined, { month: "short", day: "numeric" }); } if (bucketSizeMinutes >= 60) { // Hourly buckets: show date + hour return date.toLocaleString(undefined, { - month: 'short', - day: 'numeric', - hour: '2-digit', - minute: '2-digit' + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", }); } // Sub-hour buckets: show full time with seconds for precision if (bucketSizeMinutes < 1) { return date.toLocaleTimeString(undefined, { - hour: '2-digit', - minute: '2-digit', - second: '2-digit' + hour: "2-digit", + minute: "2-digit", + second: "2-digit", }); } // Minute buckets: show time - return date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }); + return date.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" }); } /** @@ -100,7 +95,7 @@ function formatBucketTime(isoString: string, bucketSizeMinutes: number): string function formatBucketSizeDisplay(minutes: number): string { if (minutes >= 60) { const hours = minutes / 60; - return hours === 1 ? '1 hour' : `${hours.toFixed(1)} hours`; + return hours === 1 ? "1 hour" : `${hours.toFixed(1)} hours`; } if (minutes >= 1) { return `${Math.round(minutes)} min`; @@ -110,12 +105,12 @@ function formatBucketSizeDisplay(minutes: number): string { } export function AvailabilityView() { - const t = useTranslations('dashboard.availability'); + const t = useTranslations("dashboard.availability"); const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const [timeRange, setTimeRange] = useState('24h'); - const [sortBy, setSortBy] = useState('availability'); + const [timeRange, setTimeRange] = useState("24h"); + const [sortBy, setSortBy] = useState("availability"); const [refreshing, setRefreshing] = useState(false); const fetchData = useCallback(async () => { @@ -135,15 +130,15 @@ export function AvailabilityView() { const res = await fetch(`/api/availability?${params}`); if (!res.ok) { - throw new Error(t('states.fetchFailed')); + throw new Error(t("states.fetchFailed")); } const result: AvailabilityQueryResult = await res.json(); setData(result); setError(null); } catch (err) { - console.error('Failed to fetch availability data:', err); - setError(err instanceof Error ? err.message : t('states.fetchFailed')); + console.error("Failed to fetch availability data:", err); + setError(err instanceof Error ? err.message : t("states.fetchFailed")); } finally { setLoading(false); setRefreshing(false); @@ -179,15 +174,15 @@ export function AvailabilityView() { return [...data.providers].sort((a, b) => { switch (sortBy) { - case 'availability': + case "availability": // Unknown status (no data) goes to the end - if (a.currentStatus === 'unknown' && b.currentStatus !== 'unknown') return 1; - if (b.currentStatus === 'unknown' && a.currentStatus !== 'unknown') return -1; + if (a.currentStatus === "unknown" && b.currentStatus !== "unknown") return 1; + if (b.currentStatus === "unknown" && a.currentStatus !== "unknown") return -1; // Sort by availability ascending (worst first for monitoring) return a.currentAvailability - b.currentAvailability; - case 'name': + case "name": return a.providerName.localeCompare(b.providerName); - case 'requests': + case "requests": // Sort by requests descending (most active first) return b.totalRequests - a.totalRequests; default: @@ -198,11 +193,11 @@ export function AvailabilityView() { const getStatusIcon = (status: string) => { switch (status) { - case 'green': + case "green": return ; - case 'red': + case "red": return ; - case 'unknown': + case "unknown": return ; default: return ; @@ -210,14 +205,14 @@ export function AvailabilityView() { }; const getStatusBadge = (status: string) => { - const statusKey = status as 'green' | 'red' | 'unknown'; - const variants: Record = { - green: 'default', - red: 'destructive', - unknown: 'outline', + const statusKey = status as "green" | "red" | "unknown"; + const variants: Record = { + green: "default", + red: "destructive", + unknown: "outline", }; return ( - + {getStatusIcon(status)} {t(`status.${statusKey}`)} @@ -235,9 +230,9 @@ export function AvailabilityView() { const getSummaryCounts = () => { if (!data?.providers) return { healthy: 0, unhealthy: 0, unknown: 0, total: 0 }; return { - healthy: data.providers.filter((p) => p.currentStatus === 'green').length, - unhealthy: data.providers.filter((p) => p.currentStatus === 'red').length, - unknown: data.providers.filter((p) => p.currentStatus === 'unknown').length, + healthy: data.providers.filter((p) => p.currentStatus === "green").length, + unhealthy: data.providers.filter((p) => p.currentStatus === "red").length, + unknown: data.providers.filter((p) => p.currentStatus === "unknown").length, total: data.providers.length, }; }; @@ -249,9 +244,7 @@ export function AvailabilityView() { provider: ProviderAvailabilitySummary, bucketStart: string ): TimeBucketMetrics | null => { - return ( - provider.timeBuckets.find((b) => b.bucketStart === bucketStart) || null - ); + return provider.timeBuckets.find((b) => b.bucketStart === bucketStart) || null; }; if (loading) { @@ -271,7 +264,7 @@ export function AvailabilityView() {
-
{t('states.loading')}
+
{t("states.loading")}
@@ -296,7 +289,7 @@ export function AvailabilityView() { - {t('metrics.systemAvailability')} + {t("metrics.systemAvailability")} @@ -309,7 +302,7 @@ export function AvailabilityView() { - {t('summary.healthyProviders')} + {t("summary.healthyProviders")} @@ -320,7 +313,7 @@ export function AvailabilityView() { - {t('summary.unhealthyProviders')} + {t("summary.unhealthyProviders")} @@ -331,7 +324,7 @@ export function AvailabilityView() { - {t('summary.unknownProviders')} + {t("summary.unknownProviders")} @@ -345,47 +338,49 @@ export function AvailabilityView() {
{data && ( - {t('heatmap.bucketSize')}: {data.bucketSizeMinutes} {t('heatmap.minutes')} + {t("heatmap.bucketSize")}: {data.bucketSizeMinutes} {t("heatmap.minutes")} )}
{/* Heatmap */} - {t('chart.title')} - {t('chart.description')} + {t("chart.title")} + {t("chart.description")} {!sortedProviders.length ? ( -
{t('states.noProviders')}
+
+ {t("states.noProviders")} +
) : (
{/* Provider rows with heatmap */} @@ -417,7 +412,7 @@ export function AvailabilityView() {
@@ -430,26 +425,28 @@ export function AvailabilityView() { {hasData && bucket ? ( <>
- {t('heatmap.requests')}: {bucket.totalRequests} + {t("heatmap.requests")}: {bucket.totalRequests}
- {t('columns.availability')}: {formatPercentage(bucket.availabilityScore)} + {t("columns.availability")}:{" "} + {formatPercentage(bucket.availabilityScore)}
- {t('columns.avgLatency')}: {formatLatency(bucket.avgLatencyMs)} + {t("columns.avgLatency")}:{" "} + {formatLatency(bucket.avgLatencyMs)}
- {t('details.greenCount')}: {bucket.greenCount} + {t("details.greenCount")}: {bucket.greenCount} - {t('details.redCount')}: {bucket.redCount} + {t("details.redCount")}: {bucket.redCount}
) : (
- {t('heatmap.noData')} + {t("heatmap.noData")}
)}
@@ -463,14 +460,14 @@ export function AvailabilityView() { {/* Summary stats */}
- {provider.currentStatus === 'unknown' - ? t('heatmap.noData') + {provider.currentStatus === "unknown" + ? t("heatmap.noData") : formatPercentage(provider.currentAvailability)}
{provider.totalRequests > 0 - ? `${provider.totalRequests.toLocaleString()} ${t('heatmap.requests')}` - : t('heatmap.noRequests')} + ? `${provider.totalRequests.toLocaleString()} ${t("heatmap.requests")}` + : t("heatmap.noRequests")}
@@ -486,23 +483,23 @@ export function AvailabilityView() {
- {t('legend.green')} + {t("legend.green")}
- {t('legend.lime')} + {t("legend.lime")}
- {t('legend.orange')} + {t("legend.orange")}
- {t('legend.red')} + {t("legend.red")}
- {t('legend.noData')} + {t("legend.noData")}
diff --git a/src/app/[locale]/dashboard/availability/page.tsx b/src/app/[locale]/dashboard/availability/page.tsx index 780f36df8..c2984fb16 100644 --- a/src/app/[locale]/dashboard/availability/page.tsx +++ b/src/app/[locale]/dashboard/availability/page.tsx @@ -1,42 +1,37 @@ -import { Section } from '@/components/section'; -import { AvailabilityView } from './_components/availability-view'; -import { getSession } from '@/lib/auth'; -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; -import { AlertCircle } from 'lucide-react'; -import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; -import { Link } from '@/i18n/routing'; -import { getTranslations } from 'next-intl/server'; +import { Section } from "@/components/section"; +import { AvailabilityView } from "./_components/availability-view"; +import { getSession } from "@/lib/auth"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { AlertCircle } from "lucide-react"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { Link } from "@/i18n/routing"; +import { getTranslations } from "next-intl/server"; -export const dynamic = 'force-dynamic'; +export const dynamic = "force-dynamic"; export default async function AvailabilityPage() { - const t = await getTranslations('dashboard'); + const t = await getTranslations("dashboard"); const session = await getSession(); // Only admin can access availability monitoring - const isAdmin = session?.user.role === 'admin'; + const isAdmin = session?.user.role === "admin"; if (!isAdmin) { return (
-
+
- {t('leaderboard.permission.title')} + {t("leaderboard.permission.title")} - {t('leaderboard.permission.restricted')} - - {t('leaderboard.permission.userAction')} - + {t("leaderboard.permission.restricted")} + {t("leaderboard.permission.userAction")} @@ -47,10 +42,7 @@ export default async function AvailabilityPage() { return (
-
+
diff --git a/src/app/[locale]/settings/providers/_components/forms/api-test-button.tsx b/src/app/[locale]/settings/providers/_components/forms/api-test-button.tsx index dc92e4473..d7b4af68c 100644 --- a/src/app/[locale]/settings/providers/_components/forms/api-test-button.tsx +++ b/src/app/[locale]/settings/providers/_components/forms/api-test-button.tsx @@ -3,10 +3,7 @@ import { useEffect, useMemo, useState } from "react"; import { Button } from "@/components/ui/button"; import { Loader2, CheckCircle2, XCircle, Activity, AlertTriangle } from "lucide-react"; -import { - testProviderUnified, - getUnmaskedProviderKey, -} from "@/actions/providers"; +import { testProviderUnified, getUnmaskedProviderKey } from "@/actions/providers"; import { toast } from "sonner"; import { useTranslations } from "next-intl"; import { @@ -336,9 +333,7 @@ export function ApiTestButton({ {/* 显示测试结果卡片 */} - {testResult && !isTesting && ( - - )} + {testResult && !isTesting && }
); } diff --git a/src/app/[locale]/settings/providers/_components/forms/test-result-card.tsx b/src/app/[locale]/settings/providers/_components/forms/test-result-card.tsx index cd1a0b6c5..d33aece7c 100644 --- a/src/app/[locale]/settings/providers/_components/forms/test-result-card.tsx +++ b/src/app/[locale]/settings/providers/_components/forms/test-result-card.tsx @@ -107,20 +107,29 @@ export function TestResultCard({ result, onClose }: TestResultCardProps) { const ct = (key: string) => t(`resultCard.copyText.${key}`); const vp = (passed: boolean, type: "http" | "latency" | "content") => { if (type === "latency") { - return passed ? `✓ ${t("resultCard.validation.passed")}` : `✗ ${t("resultCard.validation.timeout")}`; + return passed + ? `✓ ${t("resultCard.validation.passed")}` + : `✗ ${t("resultCard.validation.timeout")}`; } - return passed ? `✓ ${t("resultCard.validation.passed")}` : `✗ ${t("resultCard.validation.failed")}`; + return passed + ? `✓ ${t("resultCard.validation.passed")}` + : `✗ ${t("resultCard.validation.failed")}`; }; const resultText = [ `${ct("status")}: ${statusLabel} (${result.subStatus})`, `${ct("message")}: ${result.message}`, `${ct("latency")}: ${result.latencyMs}ms`, - result.httpStatusCode && `${ct("httpStatus")}: ${result.httpStatusCode} ${result.httpStatusText || ""}`, + result.httpStatusCode && + `${ct("httpStatus")}: ${result.httpStatusCode} ${result.httpStatusText || ""}`, result.model && `${ct("model")}: ${result.model}`, result.usage && - t("resultCard.copyText.inputOutput", { input: result.usage.inputTokens, output: result.usage.outputTokens }), - result.content && `${ct("response")}: ${result.content.slice(0, 200)}${result.content.length > 200 ? "..." : ""}`, + t("resultCard.copyText.inputOutput", { + input: result.usage.inputTokens, + output: result.usage.outputTokens, + }), + result.content && + `${ct("response")}: ${result.content.slice(0, 200)}${result.content.length > 200 ? "..." : ""}`, result.errorMessage && `${ct("error")}: ${result.errorMessage}`, `${ct("testedAt")}: ${new Date(result.testedAt).toLocaleString()}`, "", @@ -229,7 +238,9 @@ export function TestResultCard({ result, onClose }: TestResultCardProps) { {/* Content preview if success */} {result.content && result.status !== "red" && (
- {t("resultCard.labels.responsePreview")}: + + {t("resultCard.labels.responsePreview")}: +
             {result.content.slice(0, 150)}
             {result.content.length > 150 && "..."}
@@ -255,9 +266,7 @@ function ValidationIndicator({
   return (
     
@@ -268,9 +277,7 @@ function ValidationIndicator({ )} {label}
- {value && ( - {value} - )} + {value && {value}}
); } @@ -304,9 +311,7 @@ function TestResultDetails({ {result.subStatus} {result.model && {result.model}} - {result.httpStatusCode && ( - HTTP {result.httpStatusCode} - )} + {result.httpStatusCode && HTTP {result.httpStatusCode}}
{/* Validation Details */} @@ -319,9 +324,11 @@ function TestResultDetails({ statusCodeLabel={t("resultCard.validation.http.statusCode")} statusCode={result.validationDetails.httpStatusCode} judgmentLabel={t("resultCard.judgment")} - judgmentText={result.validationDetails.httpPassed - ? t("resultCard.validation.http.passed") - : t("resultCard.validation.http.failed")} + judgmentText={ + result.validationDetails.httpPassed + ? t("resultCard.validation.http.passed") + : t("resultCard.validation.http.failed") + } />
@@ -383,13 +394,17 @@ function TestResultDetails({
{result.usage.cacheCreationInputTokens !== undefined && (
- {t("resultCard.tokenUsage.cacheCreation")}:{" "} + + {t("resultCard.tokenUsage.cacheCreation")}: + {" "} {result.usage.cacheCreationInputTokens}
)} {result.usage.cacheReadInputTokens !== undefined && (
- {t("resultCard.tokenUsage.cacheRead")}:{" "} + + {t("resultCard.tokenUsage.cacheRead")}: + {" "} {result.usage.cacheReadInputTokens}
)} @@ -405,12 +420,20 @@ function TestResultDetails({
- {t("resultCard.streamInfo.isStreaming")}:{" "} - {result.streamInfo.isStreaming ? t("resultCard.streamInfo.yes") : t("resultCard.streamInfo.no")} + + {t("resultCard.streamInfo.isStreaming")}: + {" "} + + {result.streamInfo.isStreaming + ? t("resultCard.streamInfo.yes") + : t("resultCard.streamInfo.no")} +
{result.streamInfo.chunksReceived !== undefined && (
- {t("resultCard.streamInfo.chunksCount")}:{" "} + + {t("resultCard.streamInfo.chunksCount")}: + {" "} {result.streamInfo.chunksReceived}
)} @@ -442,7 +465,8 @@ function TestResultDetails({
{result.errorType && (
- {t("resultCard.errorDetails.type")}: {result.errorType} + {t("resultCard.errorDetails.type")}:{" "} + {result.errorType}
)}
diff --git a/src/app/api/availability/current/route.ts b/src/app/api/availability/current/route.ts
index 0ac845b5d..a0dd7a91b 100644
--- a/src/app/api/availability/current/route.ts
+++ b/src/app/api/availability/current/route.ts
@@ -5,41 +5,32 @@
  * Returns current status for all providers (lightweight query, last 15 minutes)
  */
 
-import { NextRequest, NextResponse } from 'next/server';
-import { getCurrentProviderStatus } from '@/lib/availability';
-import { validateKey } from '@/lib/auth';
+import { NextRequest, NextResponse } from "next/server";
+import { getCurrentProviderStatus } from "@/lib/availability";
+import { validateKey } from "@/lib/auth";
 
 /**
  * GET /api/availability/current
  */
 export async function GET(request: NextRequest) {
   // Verify admin authentication
-  const authHeader = request.headers.get('Authorization');
-  const token = authHeader?.replace('Bearer ', '');
+  const authHeader = request.headers.get("Authorization");
+  const token = authHeader?.replace("Bearer ", "");
 
   if (!token) {
-    return NextResponse.json(
-      { error: 'Unauthorized' },
-      { status: 401 }
-    );
+    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
   }
 
   const session = await validateKey(token);
-  if (!session || session.user.role !== 'admin') {
-    return NextResponse.json(
-      { error: 'Forbidden: Admin access required' },
-      { status: 403 }
-    );
+  if (!session || session.user.role !== "admin") {
+    return NextResponse.json({ error: "Forbidden: Admin access required" }, { status: 403 });
   }
 
   try {
     const result = await getCurrentProviderStatus();
     return NextResponse.json(result);
   } catch (error) {
-    console.error('Current availability API error:', error);
-    return NextResponse.json(
-      { error: 'Internal server error' },
-      { status: 500 }
-    );
+    console.error("Current availability API error:", error);
+    return NextResponse.json({ error: "Internal server error" }, { status: 500 });
   }
 }
diff --git a/src/app/api/availability/route.ts b/src/app/api/availability/route.ts
index 9561c0941..4ba36f705 100644
--- a/src/app/api/availability/route.ts
+++ b/src/app/api/availability/route.ts
@@ -11,12 +11,9 @@
  *   - maxBuckets: number, max time buckets (default: 100)
  */
 
-import { NextRequest, NextResponse } from 'next/server';
-import {
-  queryProviderAvailability,
-  type AvailabilityQueryOptions,
-} from '@/lib/availability';
-import { getSession } from '@/lib/auth';
+import { NextRequest, NextResponse } from "next/server";
+import { queryProviderAvailability, type AvailabilityQueryOptions } from "@/lib/availability";
+import { getSession } from "@/lib/auth";
 
 /**
  * GET /api/availability
@@ -24,11 +21,8 @@ import { getSession } from '@/lib/auth';
 export async function GET(request: NextRequest) {
   // Verify admin authentication using session cookies
   const session = await getSession();
-  if (!session || session.user.role !== 'admin') {
-    return NextResponse.json(
-      { error: 'Unauthorized' },
-      { status: 401 }
-    );
+  if (!session || session.user.role !== "admin") {
+    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
   }
 
   try {
@@ -37,32 +31,35 @@ export async function GET(request: NextRequest) {
     // Parse query options
     const options: AvailabilityQueryOptions = {};
 
-    const startTime = searchParams.get('startTime');
+    const startTime = searchParams.get("startTime");
     if (startTime) {
       options.startTime = startTime;
     }
 
-    const endTime = searchParams.get('endTime');
+    const endTime = searchParams.get("endTime");
     if (endTime) {
       options.endTime = endTime;
     }
 
-    const providerIds = searchParams.get('providerIds');
+    const providerIds = searchParams.get("providerIds");
     if (providerIds) {
-      options.providerIds = providerIds.split(',').map((id) => parseInt(id.trim(), 10)).filter((id) => !isNaN(id));
+      options.providerIds = providerIds
+        .split(",")
+        .map((id) => parseInt(id.trim(), 10))
+        .filter((id) => !isNaN(id));
     }
 
-    const bucketSizeMinutes = searchParams.get('bucketSizeMinutes');
+    const bucketSizeMinutes = searchParams.get("bucketSizeMinutes");
     if (bucketSizeMinutes) {
       options.bucketSizeMinutes = parseInt(bucketSizeMinutes, 10);
     }
 
-    const includeDisabled = searchParams.get('includeDisabled');
+    const includeDisabled = searchParams.get("includeDisabled");
     if (includeDisabled) {
-      options.includeDisabled = includeDisabled === 'true';
+      options.includeDisabled = includeDisabled === "true";
     }
 
-    const maxBuckets = searchParams.get('maxBuckets');
+    const maxBuckets = searchParams.get("maxBuckets");
     if (maxBuckets) {
       options.maxBuckets = parseInt(maxBuckets, 10);
     }
@@ -71,10 +68,7 @@ export async function GET(request: NextRequest) {
 
     return NextResponse.json(result);
   } catch (error) {
-    console.error('Availability API error:', error);
-    return NextResponse.json(
-      { error: 'Internal server error' },
-      { status: 500 }
-    );
+    console.error("Availability API error:", error);
+    return NextResponse.json({ error: "Internal server error" }, { status: 500 });
   }
 }
diff --git a/src/lib/availability/availability-service.ts b/src/lib/availability/availability-service.ts
index 3d0163be5..8c1750e3c 100644
--- a/src/lib/availability/availability-service.ts
+++ b/src/lib/availability/availability-service.ts
@@ -4,9 +4,9 @@
  * Simple two-tier status: success (green) or failure (red)
  */
 
-import { db } from '@/drizzle/db';
-import { messageRequest, providers } from '@/drizzle/schema';
-import { and, eq, gte, lte, sql, isNull, desc, inArray } from 'drizzle-orm';
+import { db } from "@/drizzle/db";
+import { messageRequest, providers } from "@/drizzle/schema";
+import { and, eq, gte, lte, sql, isNull, desc, inArray } from "drizzle-orm";
 import {
   type AvailabilityStatus,
   type AvailabilityQueryOptions,
@@ -16,19 +16,17 @@ import {
   type RequestStatusClassification,
   AVAILABILITY_WEIGHTS,
   AVAILABILITY_DEFAULTS,
-} from './types';
+} from "./types";
 
 /**
  * Classify a single request's status
  * Simple: success (2xx/3xx) = green, failure = red
  */
-export function classifyRequestStatus(
-  statusCode: number | null
-): RequestStatusClassification {
+export function classifyRequestStatus(statusCode: number | null): RequestStatusClassification {
   // No status code means network error or timeout
   if (statusCode === null) {
     return {
-      status: 'red',
+      status: "red",
       isSuccess: false,
       isError: true,
     };
@@ -37,7 +35,7 @@ export function classifyRequestStatus(
   // HTTP error (4xx/5xx)
   if (statusCode >= 400) {
     return {
-      status: 'red',
+      status: "red",
       isSuccess: false,
       isError: true,
     };
@@ -45,7 +43,7 @@ export function classifyRequestStatus(
 
   // HTTP success (2xx/3xx) - all successful requests are green
   return {
-    status: 'green',
+    status: "green",
     isSuccess: true,
     isError: false,
   };
@@ -54,10 +52,7 @@ export function classifyRequestStatus(
 /**
  * Calculate availability score from counts (simple: green / total)
  */
-export function calculateAvailabilityScore(
-  greenCount: number,
-  redCount: number
-): number {
+export function calculateAvailabilityScore(greenCount: number, redCount: number): number {
   const total = greenCount + redCount;
   if (total === 0) return 0;
 
@@ -112,8 +107,8 @@ export async function queryProviderAvailability(
     maxBuckets = 100,
   } = options;
 
-  const startDate = typeof startTime === 'string' ? new Date(startTime) : startTime;
-  const endDate = typeof endTime === 'string' ? new Date(endTime) : endTime;
+  const startDate = typeof startTime === "string" ? new Date(startTime) : startTime;
+  const endDate = typeof endTime === "string" ? new Date(endTime) : endTime;
   const timeRangeMinutes = (endDate.getTime() - startDate.getTime()) / (1000 * 60);
 
   // Get provider list
@@ -196,9 +191,7 @@ export async function queryProviderAvailability(
   for (const req of requests) {
     if (!req.createdAt) continue;
 
-    const bucketStart = new Date(
-      Math.floor(req.createdAt.getTime() / bucketSizeMs) * bucketSizeMs
-    );
+    const bucketStart = new Date(Math.floor(req.createdAt.getTime() / bucketSizeMs) * bucketSizeMs);
     const bucketKey = bucketStart.toISOString();
 
     const providerData = providerBuckets.get(req.providerId);
@@ -215,7 +208,7 @@ export async function queryProviderAvailability(
     const bucket = providerData.get(bucketKey)!;
     const classification = classifyRequestStatus(req.statusCode);
 
-    if (classification.status === 'green') {
+    if (classification.status === "green") {
       bucket.greenCount++;
     } else {
       bucket.redCount++;
@@ -280,21 +273,20 @@ export async function queryProviderAvailability(
 
     // Determine current status based on last few buckets
     // IMPORTANT: No data = 'unknown', NOT 'green'! Must be honest.
-    let currentStatus: AvailabilityStatus = 'unknown';
+    let currentStatus: AvailabilityStatus = "unknown";
     if (timeBuckets.length > 0) {
       const recentBuckets = timeBuckets.slice(-3); // Last 3 buckets
       const recentScore =
-        recentBuckets.reduce((sum, b) => sum + b.availabilityScore, 0) /
-        recentBuckets.length;
+        recentBuckets.reduce((sum, b) => sum + b.availabilityScore, 0) / recentBuckets.length;
 
       // Simple: >= 50% success = green, otherwise red
-      currentStatus = recentScore >= 0.5 ? 'green' : 'red';
+      currentStatus = recentScore >= 0.5 ? "green" : "red";
     }
 
     providerSummaries.push({
       providerId: provider.id,
       providerName: provider.name,
-      providerType: provider.providerType ?? 'claude',
+      providerType: provider.providerType ?? "claude",
       isEnabled: provider.enabled ?? true,
       currentStatus,
       currentAvailability: calculateAvailabilityScore(totalGreen, totalRed),
@@ -310,16 +302,11 @@ export async function queryProviderAvailability(
   }
 
   // Calculate system-wide availability
-  const totalSystemRequests = providerSummaries.reduce(
-    (sum, p) => sum + p.totalRequests,
-    0
-  );
+  const totalSystemRequests = providerSummaries.reduce((sum, p) => sum + p.totalRequests, 0);
   const weightedSystemAvailability =
     totalSystemRequests > 0
-      ? providerSummaries.reduce(
-          (sum, p) => sum + p.currentAvailability * p.totalRequests,
-          0
-        ) / totalSystemRequests
+      ? providerSummaries.reduce((sum, p) => sum + p.currentAvailability * p.totalRequests, 0) /
+        totalSystemRequests
       : 0;
 
   return {
@@ -406,7 +393,7 @@ export async function getCurrentProviderStatus(): Promise<
 
     const classification = classifyRequestStatus(req.statusCode);
 
-    if (classification.status === 'green') {
+    if (classification.status === "green") {
       stats.greenCount++;
     } else {
       stats.redCount++;
@@ -423,12 +410,12 @@ export async function getCurrentProviderStatus(): Promise<
     const availability = calculateAvailabilityScore(stats.greenCount, stats.redCount);
 
     // IMPORTANT: No data = 'unknown', NOT 'green'! Must be honest.
-    let status: AvailabilityStatus = 'unknown';
+    let status: AvailabilityStatus = "unknown";
     if (total === 0) {
-      status = 'unknown'; // No data - must be honest, don't assume healthy!
+      status = "unknown"; // No data - must be honest, don't assume healthy!
     } else {
       // Simple: >= 50% success = green, otherwise red
-      status = availability >= 0.5 ? 'green' : 'red';
+      status = availability >= 0.5 ? "green" : "red";
     }
 
     return {
diff --git a/src/lib/availability/index.ts b/src/lib/availability/index.ts
index ac59bd778..c784533b5 100644
--- a/src/lib/availability/index.ts
+++ b/src/lib/availability/index.ts
@@ -12,11 +12,11 @@
  * - UNKNOWN: No data available
  */
 
-export * from './types';
+export * from "./types";
 export {
   classifyRequestStatus,
   calculateAvailabilityScore,
   determineOptimalBucketSize,
   queryProviderAvailability,
   getCurrentProviderStatus,
-} from './availability-service';
+} from "./availability-service";
diff --git a/src/lib/availability/types.ts b/src/lib/availability/types.ts
index ca4326de6..895c630fd 100644
--- a/src/lib/availability/types.ts
+++ b/src/lib/availability/types.ts
@@ -9,7 +9,7 @@
  * - RED (0.0): HTTP 4xx/5xx or error
  * - UNKNOWN (-1): No data available (must be displayed honestly as "no data")
  */
-export type AvailabilityStatus = 'green' | 'red' | 'unknown';
+export type AvailabilityStatus = "green" | "red" | "unknown";
 
 /**
  * Numeric weights for availability calculation
diff --git a/src/lib/circuit-breaker-probe.ts b/src/lib/circuit-breaker-probe.ts
index 9708a4199..407146d0e 100644
--- a/src/lib/circuit-breaker-probe.ts
+++ b/src/lib/circuit-breaker-probe.ts
@@ -10,15 +10,15 @@
  * - PROBE_TIMEOUT_MS: Timeout for each probe request (default: 5000ms = 5s)
  */
 
-import { logger } from '@/lib/logger';
-import { getAllHealthStatus, getCircuitState, resetCircuit } from './circuit-breaker';
-import { executeProviderTest } from './provider-testing/test-service';
-import type { ProviderType } from '@/types/provider';
+import { logger } from "@/lib/logger";
+import { getAllHealthStatus, getCircuitState, resetCircuit } from "./circuit-breaker";
+import { executeProviderTest } from "./provider-testing/test-service";
+import type { ProviderType } from "@/types/provider";
 
 // Configuration
-const ENABLE_SMART_PROBING = process.env.ENABLE_SMART_PROBING === 'true';
-const PROBE_INTERVAL_MS = parseInt(process.env.PROBE_INTERVAL_MS || '30000', 10);
-const PROBE_TIMEOUT_MS = parseInt(process.env.PROBE_TIMEOUT_MS || '5000', 10);
+const ENABLE_SMART_PROBING = process.env.ENABLE_SMART_PROBING === "true";
+const PROBE_INTERVAL_MS = parseInt(process.env.PROBE_INTERVAL_MS || "30000", 10);
+const PROBE_TIMEOUT_MS = parseInt(process.env.PROBE_TIMEOUT_MS || "5000", 10);
 
 // Probe state
 let probeIntervalId: NodeJS.Timeout | null = null;
@@ -49,9 +49,9 @@ async function loadProviderConfigs(): Promise {
 
   try {
     // Dynamic import to avoid circular dependencies
-    const { db } = await import('@/drizzle/db');
-    const { providers } = await import('@/drizzle/schema');
-    const { eq, isNull, and } = await import('drizzle-orm');
+    const { db } = await import("@/drizzle/db");
+    const { providers } = await import("@/drizzle/schema");
+    const { eq, isNull, and } = await import("drizzle-orm");
 
     const providerList = await db
       .select({
@@ -72,17 +72,17 @@ async function loadProviderConfigs(): Promise {
           name: p.name,
           url: p.url,
           key: p.key,
-          providerType: (p.providerType || 'claude') as ProviderType,
+          providerType: (p.providerType || "claude") as ProviderType,
         },
       ])
     );
     lastProviderCacheUpdate = now;
 
-    logger.debug('[SmartProbe] Updated provider cache', {
+    logger.debug("[SmartProbe] Updated provider cache", {
       count: providerConfigCache.size,
     });
   } catch (error) {
-    logger.error('[SmartProbe] Failed to load provider configs', {
+    logger.error("[SmartProbe] Failed to load provider configs", {
       error: error instanceof Error ? error.message : String(error),
     });
   }
@@ -94,12 +94,12 @@ async function loadProviderConfigs(): Promise {
 async function probeProvider(providerId: number): Promise {
   const config = providerConfigCache.get(providerId);
   if (!config) {
-    logger.warn('[SmartProbe] Provider config not found', { providerId });
+    logger.warn("[SmartProbe] Provider config not found", { providerId });
     return false;
   }
 
   try {
-    logger.info('[SmartProbe] Probing provider', {
+    logger.info("[SmartProbe] Probing provider", {
       providerId,
       providerName: config.name,
     });
@@ -112,7 +112,7 @@ async function probeProvider(providerId: number): Promise {
     });
 
     if (result.success) {
-      logger.info('[SmartProbe] Probe succeeded, transitioning to half-open', {
+      logger.info("[SmartProbe] Probe succeeded, transitioning to half-open", {
         providerId,
         providerName: config.name,
         latencyMs: result.latencyMs,
@@ -125,7 +125,7 @@ async function probeProvider(providerId: number): Promise {
       return true;
     }
 
-    logger.info('[SmartProbe] Probe failed, keeping circuit open', {
+    logger.info("[SmartProbe] Probe failed, keeping circuit open", {
       providerId,
       providerName: config.name,
       status: result.status,
@@ -134,7 +134,7 @@ async function probeProvider(providerId: number): Promise {
     });
     return false;
   } catch (error) {
-    logger.error('[SmartProbe] Probe execution error', {
+    logger.error("[SmartProbe] Probe execution error", {
       providerId,
       error: error instanceof Error ? error.message : String(error),
     });
@@ -147,7 +147,7 @@ async function probeProvider(providerId: number): Promise {
  */
 async function runProbeCycle(): Promise {
   if (isProbing) {
-    logger.debug('[SmartProbe] Skipping cycle, previous cycle still running');
+    logger.debug("[SmartProbe] Skipping cycle, previous cycle still running");
     return;
   }
 
@@ -162,17 +162,17 @@ async function runProbeCycle(): Promise {
     const openCircuits: number[] = [];
 
     for (const [providerId, health] of Object.entries(healthStatus)) {
-      if (health.circuitState === 'open') {
+      if (health.circuitState === "open") {
         openCircuits.push(parseInt(providerId, 10));
       }
     }
 
     if (openCircuits.length === 0) {
-      logger.debug('[SmartProbe] No open circuits to probe');
+      logger.debug("[SmartProbe] No open circuits to probe");
       return;
     }
 
-    logger.info('[SmartProbe] Starting probe cycle', {
+    logger.info("[SmartProbe] Starting probe cycle", {
       openCircuitCount: openCircuits.length,
       providerIds: openCircuits,
     });
@@ -180,18 +180,16 @@ async function runProbeCycle(): Promise {
     // Probe each provider with open circuit
     const results = await Promise.allSettled(openCircuits.map((id) => probeProvider(id)));
 
-    const succeeded = results.filter(
-      (r) => r.status === 'fulfilled' && r.value === true
-    ).length;
+    const succeeded = results.filter((r) => r.status === "fulfilled" && r.value === true).length;
     const failed = results.length - succeeded;
 
-    logger.info('[SmartProbe] Probe cycle completed', {
+    logger.info("[SmartProbe] Probe cycle completed", {
       total: results.length,
       succeeded,
       failed,
     });
   } catch (error) {
-    logger.error('[SmartProbe] Probe cycle error', {
+    logger.error("[SmartProbe] Probe cycle error", {
       error: error instanceof Error ? error.message : String(error),
     });
   } finally {
@@ -204,23 +202,23 @@ async function runProbeCycle(): Promise {
  */
 export function startProbeScheduler(): void {
   if (!ENABLE_SMART_PROBING) {
-    logger.info('[SmartProbe] Smart probing is disabled');
+    logger.info("[SmartProbe] Smart probing is disabled");
     return;
   }
 
   if (probeIntervalId) {
-    logger.warn('[SmartProbe] Scheduler already running');
+    logger.warn("[SmartProbe] Scheduler already running");
     return;
   }
 
-  logger.info('[SmartProbe] Starting probe scheduler', {
+  logger.info("[SmartProbe] Starting probe scheduler", {
     intervalMs: PROBE_INTERVAL_MS,
     timeoutMs: PROBE_TIMEOUT_MS,
   });
 
   // Run immediately on startup
   runProbeCycle().catch((error) => {
-    logger.error('[SmartProbe] Initial probe cycle failed', {
+    logger.error("[SmartProbe] Initial probe cycle failed", {
       error: error instanceof Error ? error.message : String(error),
     });
   });
@@ -228,15 +226,15 @@ export function startProbeScheduler(): void {
   // Schedule periodic probes
   probeIntervalId = setInterval(() => {
     runProbeCycle().catch((error) => {
-      logger.error('[SmartProbe] Scheduled probe cycle failed', {
+      logger.error("[SmartProbe] Scheduled probe cycle failed", {
         error: error instanceof Error ? error.message : String(error),
       });
     });
   }, PROBE_INTERVAL_MS);
 
   // Ensure cleanup on process exit
-  process.on('SIGTERM', stopProbeScheduler);
-  process.on('SIGINT', stopProbeScheduler);
+  process.on("SIGTERM", stopProbeScheduler);
+  process.on("SIGINT", stopProbeScheduler);
 }
 
 /**
@@ -246,7 +244,7 @@ export function stopProbeScheduler(): void {
   if (probeIntervalId) {
     clearInterval(probeIntervalId);
     probeIntervalId = null;
-    logger.info('[SmartProbe] Probe scheduler stopped');
+    logger.info("[SmartProbe] Probe scheduler stopped");
   }
 }
 
diff --git a/src/lib/provider-testing/index.ts b/src/lib/provider-testing/index.ts
index ddd4893ac..6402bb126 100644
--- a/src/lib/provider-testing/index.ts
+++ b/src/lib/provider-testing/index.ts
@@ -7,7 +7,7 @@
  */
 
 // Main test service
-export { executeProviderTest, getStatusWeight } from './test-service';
+export { executeProviderTest, getStatusWeight } from "./test-service";
 
 // Types
 export type {
@@ -23,9 +23,9 @@ export type {
   CodexTestBody,
   OpenAITestBody,
   GeminiTestBody,
-} from './types';
+} from "./types";
 
-export { TEST_DEFAULTS, STATUS_VALUES } from './types';
+export { TEST_DEFAULTS, STATUS_VALUES } from "./types";
 
 // Validators
 export {
@@ -34,7 +34,7 @@ export {
   getSubStatusDescription,
   evaluateContentValidation,
   extractTextContent,
-} from './validators';
+} from "./validators";
 
 // Parsers
 export {
@@ -44,7 +44,7 @@ export {
   parseOpenAIResponse,
   parseCodexResponse,
   parseGeminiResponse,
-} from './parsers';
+} from "./parsers";
 
 // Utils
 export {
@@ -58,4 +58,4 @@ export {
   parseSSEStream,
   isSSEResponse,
   parseNDJSONStream,
-} from './utils';
+} from "./utils";
diff --git a/src/lib/provider-testing/parsers/anthropic-parser.ts b/src/lib/provider-testing/parsers/anthropic-parser.ts
index eff3f416c..a0c3018ae 100644
--- a/src/lib/provider-testing/parsers/anthropic-parser.ts
+++ b/src/lib/provider-testing/parsers/anthropic-parser.ts
@@ -3,8 +3,8 @@
  * Handles both streaming and non-streaming responses
  */
 
-import type { ParsedResponse, TokenUsage } from '../types';
-import { parseSSEStream, isSSEResponse } from '../utils/sse-collector';
+import type { ParsedResponse, TokenUsage } from "../types";
+import { parseSSEStream, isSSEResponse } from "../utils/sse-collector";
 
 /**
  * Anthropic non-streaming response structure
@@ -34,10 +34,7 @@ interface AnthropicResponse {
 /**
  * Parse Anthropic Messages API response
  */
-export function parseAnthropicResponse(
-  body: string,
-  contentType?: string
-): ParsedResponse {
+export function parseAnthropicResponse(body: string, contentType?: string): ParsedResponse {
   // Check if streaming response
   if (isSSEResponse(body, contentType)) {
     return parseSSEStream(body);
@@ -50,7 +47,7 @@ export function parseAnthropicResponse(
     // Handle error response
     if (data.error) {
       return {
-        content: data.error.message || 'Unknown error',
+        content: data.error.message || "Unknown error",
         model: undefined,
         usage: undefined,
         isStreaming: false,
@@ -58,11 +55,9 @@ export function parseAnthropicResponse(
     }
 
     // Extract text content
-    const textParts =
-      data.content?.filter((c) => c.type === 'text').map((c) => c.text || '') ||
-      [];
+    const textParts = data.content?.filter((c) => c.type === "text").map((c) => c.text || "") || [];
 
-    const content = textParts.join('');
+    const content = textParts.join("");
 
     // Extract usage
     let usage: TokenUsage | undefined;
diff --git a/src/lib/provider-testing/parsers/codex-parser.ts b/src/lib/provider-testing/parsers/codex-parser.ts
index 7758f7764..6d72ddd35 100644
--- a/src/lib/provider-testing/parsers/codex-parser.ts
+++ b/src/lib/provider-testing/parsers/codex-parser.ts
@@ -3,12 +3,8 @@
  * Handles streaming and non-streaming responses for /v1/responses endpoint
  */
 
-import type { ParsedResponse, TokenUsage } from '../types';
-import {
-  parseSSEStream,
-  isSSEResponse,
-  parseNDJSONStream,
-} from '../utils/sse-collector';
+import type { ParsedResponse, TokenUsage } from "../types";
+import { parseSSEStream, isSSEResponse, parseNDJSONStream } from "../utils/sse-collector";
 
 /**
  * Codex Response API response structure
@@ -46,12 +42,12 @@ interface CodexResponse {
  * Codex often uses NDJSON instead of SSE
  */
 function isNDJSONResponse(body: string, contentType?: string): boolean {
-  if (contentType?.includes('application/x-ndjson')) {
+  if (contentType?.includes("application/x-ndjson")) {
     return true;
   }
 
   // Check if body has multiple JSON objects on separate lines
-  const lines = body.split('\n').filter((l) => l.trim());
+  const lines = body.split("\n").filter((l) => l.trim());
   if (lines.length > 1) {
     try {
       // Try to parse first two lines as JSON
@@ -69,10 +65,7 @@ function isNDJSONResponse(body: string, contentType?: string): boolean {
 /**
  * Parse Codex Response API response
  */
-export function parseCodexResponse(
-  body: string,
-  contentType?: string
-): ParsedResponse {
+export function parseCodexResponse(body: string, contentType?: string): ParsedResponse {
   // Check if streaming SSE response
   if (isSSEResponse(body, contentType)) {
     return parseSSEStream(body);
@@ -90,7 +83,7 @@ export function parseCodexResponse(
     // Handle error response
     if (data.error) {
       return {
-        content: data.error.message || 'Unknown error',
+        content: data.error.message || "Unknown error",
         model: undefined,
         usage: undefined,
         isStreaming: false,
@@ -103,7 +96,7 @@ export function parseCodexResponse(
       for (const item of data.output) {
         if (item.content && Array.isArray(item.content)) {
           for (const c of item.content) {
-            if (c.type === 'output_text' && c.text) {
+            if (c.type === "output_text" && c.text) {
               texts.push(c.text);
             } else if (c.text) {
               texts.push(c.text);
@@ -113,7 +106,7 @@ export function parseCodexResponse(
       }
     }
 
-    const content = texts.join('');
+    const content = texts.join("");
 
     // Extract usage
     let usage: TokenUsage | undefined;
diff --git a/src/lib/provider-testing/parsers/gemini-parser.ts b/src/lib/provider-testing/parsers/gemini-parser.ts
index 8f2bb4765..3ec7aebc5 100644
--- a/src/lib/provider-testing/parsers/gemini-parser.ts
+++ b/src/lib/provider-testing/parsers/gemini-parser.ts
@@ -3,7 +3,7 @@
  * Handles non-streaming responses (Gemini uses different endpoint for streaming)
  */
 
-import type { ParsedResponse, TokenUsage } from '../types';
+import type { ParsedResponse, TokenUsage } from "../types";
 
 /**
  * Gemini GenerateContent response structure
@@ -39,17 +39,14 @@ interface GeminiResponse {
 /**
  * Parse Gemini GenerateContent API response
  */
-export function parseGeminiResponse(
-  body: string,
-  _contentType?: string
-): ParsedResponse {
+export function parseGeminiResponse(body: string, _contentType?: string): ParsedResponse {
   try {
     const data = JSON.parse(body) as GeminiResponse;
 
     // Handle error response
     if (data.error) {
       return {
-        content: data.error.message || 'Unknown error',
+        content: data.error.message || "Unknown error",
         model: undefined,
         usage: undefined,
         isStreaming: false,
@@ -70,7 +67,7 @@ export function parseGeminiResponse(
       }
     }
 
-    const content = texts.join('');
+    const content = texts.join("");
 
     // Extract usage
     let usage: TokenUsage | undefined;
diff --git a/src/lib/provider-testing/parsers/index.ts b/src/lib/provider-testing/parsers/index.ts
index f83d4b182..06b6fd74f 100644
--- a/src/lib/provider-testing/parsers/index.ts
+++ b/src/lib/provider-testing/parsers/index.ts
@@ -3,17 +3,17 @@
  * Provides unified parser selection based on provider type
  */
 
-import type { ProviderType } from '@/types/provider';
-import type { ParsedResponse } from '../types';
-import { parseAnthropicResponse } from './anthropic-parser';
-import { parseOpenAIResponse } from './openai-parser';
-import { parseCodexResponse } from './codex-parser';
-import { parseGeminiResponse } from './gemini-parser';
+import type { ProviderType } from "@/types/provider";
+import type { ParsedResponse } from "../types";
+import { parseAnthropicResponse } from "./anthropic-parser";
+import { parseOpenAIResponse } from "./openai-parser";
+import { parseCodexResponse } from "./codex-parser";
+import { parseGeminiResponse } from "./gemini-parser";
 
-export { parseAnthropicResponse } from './anthropic-parser';
-export { parseOpenAIResponse } from './openai-parser';
-export { parseCodexResponse } from './codex-parser';
-export { parseGeminiResponse } from './gemini-parser';
+export { parseAnthropicResponse } from "./anthropic-parser";
+export { parseOpenAIResponse } from "./openai-parser";
+export { parseCodexResponse } from "./codex-parser";
+export { parseGeminiResponse } from "./gemini-parser";
 
 /**
  * Parser function type
@@ -25,11 +25,11 @@ export type ResponseParser = (body: string, contentType?: string) => ParsedRespo
  */
 const parserRegistry: Record = {
   claude: parseAnthropicResponse,
-  'claude-auth': parseAnthropicResponse,
+  "claude-auth": parseAnthropicResponse,
   codex: parseCodexResponse,
-  'openai-compatible': parseOpenAIResponse,
+  "openai-compatible": parseOpenAIResponse,
   gemini: parseGeminiResponse,
-  'gemini-cli': parseGeminiResponse,
+  "gemini-cli": parseGeminiResponse,
 };
 
 /**
diff --git a/src/lib/provider-testing/parsers/openai-parser.ts b/src/lib/provider-testing/parsers/openai-parser.ts
index 39fbbb33f..f8e2bae30 100644
--- a/src/lib/provider-testing/parsers/openai-parser.ts
+++ b/src/lib/provider-testing/parsers/openai-parser.ts
@@ -3,8 +3,8 @@
  * Handles both streaming and non-streaming responses
  */
 
-import type { ParsedResponse, TokenUsage } from '../types';
-import { parseSSEStream, isSSEResponse } from '../utils/sse-collector';
+import type { ParsedResponse, TokenUsage } from "../types";
+import { parseSSEStream, isSSEResponse } from "../utils/sse-collector";
 
 /**
  * OpenAI non-streaming response structure
@@ -37,10 +37,7 @@ interface OpenAIResponse {
 /**
  * Parse OpenAI Chat Completions API response
  */
-export function parseOpenAIResponse(
-  body: string,
-  contentType?: string
-): ParsedResponse {
+export function parseOpenAIResponse(body: string, contentType?: string): ParsedResponse {
   // Check if streaming response
   if (isSSEResponse(body, contentType)) {
     return parseSSEStream(body);
@@ -53,7 +50,7 @@ export function parseOpenAIResponse(
     // Handle error response
     if (data.error) {
       return {
-        content: data.error.message || 'Unknown error',
+        content: data.error.message || "Unknown error",
         model: undefined,
         usage: undefined,
         isStreaming: false,
@@ -63,9 +60,9 @@ export function parseOpenAIResponse(
     // Extract text content from choices
     const content =
       data.choices
-        ?.map((c) => c.message?.content || '')
+        ?.map((c) => c.message?.content || "")
         .filter(Boolean)
-        .join('') || '';
+        .join("") || "";
 
     // Extract usage
     let usage: TokenUsage | undefined;
diff --git a/src/lib/provider-testing/test-service.ts b/src/lib/provider-testing/test-service.ts
index 03ef76724..9ea3e9793 100644
--- a/src/lib/provider-testing/test-service.ts
+++ b/src/lib/provider-testing/test-service.ts
@@ -14,33 +14,29 @@ import type {
   TestStatus,
   TestSubStatus,
   ValidationDetails,
-} from './types';
-import { TEST_DEFAULTS } from './types';
-import { classifyHttpStatus } from './validators/http-validator';
-import { evaluateContentValidation } from './validators/content-validator';
-import { parseResponse } from './parsers';
+} from "./types";
+import { TEST_DEFAULTS } from "./types";
+import { classifyHttpStatus } from "./validators/http-validator";
+import { evaluateContentValidation } from "./validators/content-validator";
+import { parseResponse } from "./parsers";
 import {
   getTestBody,
   getTestHeaders,
   getTestUrl,
   DEFAULT_SUCCESS_CONTAINS,
-} from './utils/test-prompts';
+} from "./utils/test-prompts";
 
 /**
  * Execute a provider test with three-tier validation
  */
-export async function executeProviderTest(
-  config: ProviderTestConfig
-): Promise {
+export async function executeProviderTest(config: ProviderTestConfig): Promise {
   const startTime = Date.now();
   let firstByteMs: number | undefined;
 
   // Build test configuration with defaults
   const timeoutMs = config.timeoutMs ?? TEST_DEFAULTS.TIMEOUT_MS;
-  const slowThresholdMs =
-    config.latencyThresholdMs ?? TEST_DEFAULTS.SLOW_LATENCY_MS;
-  const successContains =
-    config.successContains ?? DEFAULT_SUCCESS_CONTAINS[config.providerType];
+  const slowThresholdMs = config.latencyThresholdMs ?? TEST_DEFAULTS.SLOW_LATENCY_MS;
+  const successContains = config.successContains ?? DEFAULT_SUCCESS_CONTAINS[config.providerType];
 
   // Build request URL
   const url = getTestUrl(
@@ -48,7 +44,7 @@ export async function executeProviderTest(
     config.providerType,
     config.model,
     // Only pass API key for Gemini (URL parameter)
-    config.providerType === 'gemini' || config.providerType === 'gemini-cli'
+    config.providerType === "gemini" || config.providerType === "gemini-cli"
       ? config.apiKey
       : undefined
   );
@@ -64,7 +60,7 @@ export async function executeProviderTest(
 
     // Execute request
     const response = await fetch(url, {
-      method: 'POST',
+      method: "POST",
       headers,
       body: JSON.stringify(body),
       signal: controller.signal,
@@ -76,21 +72,13 @@ export async function executeProviderTest(
     // Read response body
     const responseBody = await response.text();
     const latencyMs = Date.now() - startTime;
-    const contentType = response.headers.get('content-type') || undefined;
+    const contentType = response.headers.get("content-type") || undefined;
 
     // Tier 1: HTTP Status validation
-    const httpResult = classifyHttpStatus(
-      response.status,
-      latencyMs,
-      slowThresholdMs
-    );
+    const httpResult = classifyHttpStatus(response.status, latencyMs, slowThresholdMs);
 
     // Parse response content
-    const parsedResponse = parseResponse(
-      config.providerType,
-      responseBody,
-      contentType
-    );
+    const parsedResponse = parseResponse(config.providerType, responseBody, contentType);
 
     // Tier 2 & 3: Content validation (only if HTTP passed)
     const contentResult = evaluateContentValidation(
@@ -112,7 +100,7 @@ export async function executeProviderTest(
 
     // Build result
     return {
-      success: contentResult.status !== 'red',
+      success: contentResult.status !== "red",
       status: contentResult.status,
       subStatus: contentResult.subStatus,
       latencyMs,
@@ -148,7 +136,7 @@ export async function executeProviderTest(
 
     return {
       success: false,
-      status: 'red',
+      status: "red",
       subStatus,
       latencyMs,
       firstByteMs,
@@ -173,77 +161,66 @@ function classifyError(error: unknown): {
     const message = error.message.toLowerCase();
 
     // Timeout errors
-    if (
-      error.name === 'AbortError' ||
-      message.includes('timeout') ||
-      message.includes('aborted')
-    ) {
+    if (error.name === "AbortError" || message.includes("timeout") || message.includes("aborted")) {
       return {
-        subStatus: 'network_error',
-        errorType: 'timeout',
-        errorMessage: 'Request timed out',
+        subStatus: "network_error",
+        errorType: "timeout",
+        errorMessage: "Request timed out",
       };
     }
 
     // DNS/connection errors
     if (
-      message.includes('getaddrinfo') ||
-      message.includes('enotfound') ||
-      message.includes('dns')
+      message.includes("getaddrinfo") ||
+      message.includes("enotfound") ||
+      message.includes("dns")
     ) {
       return {
-        subStatus: 'network_error',
-        errorType: 'dns_error',
-        errorMessage: 'DNS resolution failed',
+        subStatus: "network_error",
+        errorType: "dns_error",
+        errorMessage: "DNS resolution failed",
       };
     }
 
     // Connection refused
-    if (
-      message.includes('econnrefused') ||
-      message.includes('connection refused')
-    ) {
+    if (message.includes("econnrefused") || message.includes("connection refused")) {
       return {
-        subStatus: 'network_error',
-        errorType: 'connection_refused',
-        errorMessage: 'Connection refused',
+        subStatus: "network_error",
+        errorType: "connection_refused",
+        errorMessage: "Connection refused",
       };
     }
 
     // Connection reset
-    if (message.includes('econnreset') || message.includes('connection reset')) {
+    if (message.includes("econnreset") || message.includes("connection reset")) {
       return {
-        subStatus: 'network_error',
-        errorType: 'connection_reset',
-        errorMessage: 'Connection reset by peer',
+        subStatus: "network_error",
+        errorType: "connection_reset",
+        errorMessage: "Connection reset by peer",
       };
     }
 
     // SSL/TLS errors
-    if (
-      message.includes('ssl') ||
-      message.includes('tls') ||
-      message.includes('certificate')
-    ) {
+    if (message.includes("ssl") || message.includes("tls") || message.includes("certificate")) {
       return {
-        subStatus: 'network_error',
-        errorType: 'ssl_error',
-        errorMessage: 'SSL/TLS error',
+        subStatus: "network_error",
+        errorType: "ssl_error",
+        errorMessage: "SSL/TLS error",
       };
     }
 
     // Generic network error
     return {
-      subStatus: 'network_error',
-      errorType: 'network_error',
+      subStatus: "network_error",
+      errorType: "network_error",
       errorMessage: error.message,
     };
   }
 
   // Unknown error type
   return {
-    subStatus: 'network_error',
-    errorType: 'unknown_error',
+    subStatus: "network_error",
+    errorType: "unknown_error",
     errorMessage: String(error),
   };
 }
@@ -257,11 +234,11 @@ export function getStatusWeight(
   degradedWeight: number = TEST_DEFAULTS.DEGRADED_WEIGHT
 ): number {
   switch (status) {
-    case 'green':
+    case "green":
       return 1.0;
-    case 'yellow':
+    case "yellow":
       return degradedWeight;
-    case 'red':
+    case "red":
       return 0.0;
   }
 }
diff --git a/src/lib/provider-testing/types.ts b/src/lib/provider-testing/types.ts
index e8c416de5..b57c0881e 100644
--- a/src/lib/provider-testing/types.ts
+++ b/src/lib/provider-testing/types.ts
@@ -3,7 +3,7 @@
  * Based on relay-pulse implementation patterns
  */
 
-import type { ProviderType } from '@/types/provider';
+import type { ProviderType } from "@/types/provider";
 
 // ============================================================================
 // Test Status Types (3-level system from relay-pulse)
@@ -15,22 +15,22 @@ import type { ProviderType } from '@/types/provider';
  * - yellow: HTTP OK but degraded (slow latency)
  * - red: Any failure
  */
-export type TestStatus = 'green' | 'yellow' | 'red';
+export type TestStatus = "green" | "yellow" | "red";
 
 /**
  * Detailed sub-status for granular error classification
  * Maps to relay-pulse's 8 SubStatus categories
  */
 export type TestSubStatus =
-  | 'success' // All validations passed
-  | 'slow_latency' // HTTP OK but latency exceeds threshold
-  | 'rate_limit' // HTTP 429
-  | 'server_error' // HTTP 5xx
-  | 'client_error' // HTTP 4xx (excluding specific codes)
-  | 'auth_error' // HTTP 401/403
-  | 'invalid_request' // HTTP 400
-  | 'network_error' // Connection/DNS/timeout errors
-  | 'content_mismatch'; // Response content validation failed
+  | "success" // All validations passed
+  | "slow_latency" // HTTP OK but latency exceeds threshold
+  | "rate_limit" // HTTP 429
+  | "server_error" // HTTP 5xx
+  | "client_error" // HTTP 4xx (excluding specific codes)
+  | "auth_error" // HTTP 401/403
+  | "invalid_request" // HTTP 400
+  | "network_error" // Connection/DNS/timeout errors
+  | "content_mismatch"; // Response content validation failed
 
 /**
  * Numeric status values for availability calculation
@@ -60,13 +60,13 @@ export const TEST_DEFAULTS = {
   /** Weight for degraded (YELLOW) status in availability calculation */
   DEGRADED_WEIGHT: 0.7,
   /** Default success validation string for Claude */
-  SUCCESS_CONTAINS_CLAUDE: 'pong',
+  SUCCESS_CONTAINS_CLAUDE: "pong",
   /** Default success validation string for Codex */
-  SUCCESS_CONTAINS_CODEX: 'pong',
+  SUCCESS_CONTAINS_CODEX: "pong",
   /** Default success validation string for OpenAI */
-  SUCCESS_CONTAINS_OPENAI: 'pong',
+  SUCCESS_CONTAINS_OPENAI: "pong",
   /** Default success validation string for Gemini */
-  SUCCESS_CONTAINS_GEMINI: 'pong',
+  SUCCESS_CONTAINS_GEMINI: "pong",
 } as const;
 
 /**
@@ -193,10 +193,7 @@ export interface ParsedResponse {
 /**
  * Parser function signature
  */
-export type ResponseParser = (
-  body: string,
-  contentType?: string
-) => ParsedResponse;
+export type ResponseParser = (body: string, contentType?: string) => ParsedResponse;
 
 // ============================================================================
 // Test Request Body Types
@@ -208,13 +205,13 @@ export type ResponseParser = (
 export interface ClaudeTestBody {
   model: string;
   messages: Array<{
-    role: 'user' | 'assistant';
-    content: Array<{ type: 'text'; text: string }>;
+    role: "user" | "assistant";
+    content: Array<{ type: "text"; text: string }>;
   }>;
   system?: Array<{
-    type: 'text';
+    type: "text";
     text: string;
-    cache_control?: { type: 'ephemeral' };
+    cache_control?: { type: "ephemeral" };
   }>;
   max_tokens: number;
   stream: boolean;
@@ -228,9 +225,9 @@ export interface CodexTestBody {
   model: string;
   instructions: string;
   input: Array<{
-    type: 'message';
-    role: 'user';
-    content: Array<{ type: 'input_text'; text: string }>;
+    type: "message";
+    role: "user";
+    content: Array<{ type: "input_text"; text: string }>;
   }>;
   tools: unknown[];
   tool_choice: string;
@@ -245,7 +242,7 @@ export interface CodexTestBody {
 export interface OpenAITestBody {
   model: string;
   messages: Array<{
-    role: 'system' | 'user' | 'assistant';
+    role: "system" | "user" | "assistant";
     content: string;
   }>;
   max_tokens: number;
diff --git a/src/lib/provider-testing/utils/index.ts b/src/lib/provider-testing/utils/index.ts
index 466343eb1..8049f2d37 100644
--- a/src/lib/provider-testing/utils/index.ts
+++ b/src/lib/provider-testing/utils/index.ts
@@ -18,11 +18,11 @@ export {
   getTestBody,
   getTestHeaders,
   getTestUrl,
-} from './test-prompts';
+} from "./test-prompts";
 
 export {
   extractTextFromSSE,
   parseSSEStream,
   isSSEResponse,
   parseNDJSONStream,
-} from './sse-collector';
+} from "./sse-collector";
diff --git a/src/lib/provider-testing/utils/sse-collector.ts b/src/lib/provider-testing/utils/sse-collector.ts
index 3fbac40ce..226ca0985 100644
--- a/src/lib/provider-testing/utils/sse-collector.ts
+++ b/src/lib/provider-testing/utils/sse-collector.ts
@@ -9,21 +9,21 @@
  * - Codex Response API: {"output":[{"content":[{"text":"..."}]}]}
  */
 
-import type { TokenUsage, ParsedResponse } from '../types';
+import type { TokenUsage, ParsedResponse } from "../types";
 
 /**
  * Extract text content from an SSE stream body
  * Handles both Anthropic and OpenAI streaming formats
  */
 export function extractTextFromSSE(body: string): string {
-  const lines = body.split('\n');
+  const lines = body.split("\n");
   const texts: string[] = [];
 
   for (const line of lines) {
     const trimmed = line.trim();
 
     // Skip non-data lines
-    if (!trimmed.startsWith('data:')) {
+    if (!trimmed.startsWith("data:")) {
       continue;
     }
 
@@ -31,7 +31,7 @@ export function extractTextFromSSE(body: string): string {
     const payload = trimmed.slice(5).trim();
 
     // Skip empty or [DONE] markers
-    if (!payload || payload === '[DONE]') {
+    if (!payload || payload === "[DONE]") {
       continue;
     }
 
@@ -40,15 +40,17 @@ export function extractTextFromSSE(body: string): string {
 
       // Anthropic format: {"type":"content_block_delta", "delta":{"type":"text_delta","text":"..."}}
       const delta = obj.delta as Record | undefined;
-      if (delta?.text && typeof delta.text === 'string') {
+      if (delta?.text && typeof delta.text === "string") {
         texts.push(delta.text);
         continue;
       }
 
       // OpenAI format: {"choices":[{"delta":{"content":"..."}}]}
-      const choices = obj.choices as Array<{
-        delta?: { content?: string };
-      }> | undefined;
+      const choices = obj.choices as
+        | Array<{
+            delta?: { content?: string };
+          }>
+        | undefined;
       if (choices && Array.isArray(choices)) {
         for (const choice of choices) {
           if (choice.delta?.content) {
@@ -59,9 +61,11 @@ export function extractTextFromSSE(body: string): string {
       }
 
       // Codex Response API format: {"output":[{"content":[{"text":"..."}]}]}
-      const output = obj.output as Array<{
-        content?: Array<{ text?: string }>;
-      }> | undefined;
+      const output = obj.output as
+        | Array<{
+            content?: Array<{ text?: string }>;
+          }>
+        | undefined;
       if (output && Array.isArray(output)) {
         for (const item of output) {
           if (item.content && Array.isArray(item.content)) {
@@ -76,15 +80,15 @@ export function extractTextFromSSE(body: string): string {
       }
 
       // Generic fallback: top-level content/message fields
-      if (obj.content && typeof obj.content === 'string') {
+      if (obj.content && typeof obj.content === "string") {
         texts.push(obj.content);
         continue;
       }
-      if (obj.message && typeof obj.message === 'string') {
+      if (obj.message && typeof obj.message === "string") {
         texts.push(obj.message);
         continue;
       }
-      if (obj.text && typeof obj.text === 'string') {
+      if (obj.text && typeof obj.text === "string") {
         texts.push(obj.text);
         continue;
       }
@@ -96,14 +100,14 @@ export function extractTextFromSSE(body: string): string {
     }
   }
 
-  return texts.join('');
+  return texts.join("");
 }
 
 /**
  * Parse a complete SSE stream into a structured response
  */
 export function parseSSEStream(body: string): ParsedResponse {
-  const lines = body.split('\n');
+  const lines = body.split("\n");
   const texts: string[] = [];
   let model: string | undefined;
   let usage: TokenUsage | undefined;
@@ -112,12 +116,12 @@ export function parseSSEStream(body: string): ParsedResponse {
   for (const line of lines) {
     const trimmed = line.trim();
 
-    if (!trimmed.startsWith('data:')) {
+    if (!trimmed.startsWith("data:")) {
       continue;
     }
 
     const payload = trimmed.slice(5).trim();
-    if (!payload || payload === '[DONE]') {
+    if (!payload || payload === "[DONE]") {
       continue;
     }
 
@@ -127,20 +131,22 @@ export function parseSSEStream(body: string): ParsedResponse {
       const obj = JSON.parse(payload) as Record;
 
       // Extract model from first chunk
-      if (!model && obj.model && typeof obj.model === 'string') {
+      if (!model && obj.model && typeof obj.model === "string") {
         model = obj.model;
       }
 
       // Anthropic format
       const delta = obj.delta as Record | undefined;
-      if (delta?.text && typeof delta.text === 'string') {
+      if (delta?.text && typeof delta.text === "string") {
         texts.push(delta.text);
       }
 
       // OpenAI format
-      const choices = obj.choices as Array<{
-        delta?: { content?: string };
-      }> | undefined;
+      const choices = obj.choices as
+        | Array<{
+            delta?: { content?: string };
+          }>
+        | undefined;
       if (choices) {
         for (const choice of choices) {
           if (choice.delta?.content) {
@@ -150,9 +156,11 @@ export function parseSSEStream(body: string): ParsedResponse {
       }
 
       // Codex Response API format
-      const output = obj.output as Array<{
-        content?: Array<{ text?: string }>;
-      }> | undefined;
+      const output = obj.output as
+        | Array<{
+            content?: Array<{ text?: string }>;
+          }>
+        | undefined;
       if (output) {
         for (const item of output) {
           if (item.content) {
@@ -164,10 +172,12 @@ export function parseSSEStream(body: string): ParsedResponse {
       }
 
       // Extract usage from final chunk (Anthropic message_delta)
-      if (obj.type === 'message_delta') {
-        const msgUsage = obj.usage as {
-          output_tokens?: number;
-        } | undefined;
+      if (obj.type === "message_delta") {
+        const msgUsage = obj.usage as
+          | {
+              output_tokens?: number;
+            }
+          | undefined;
         if (msgUsage?.output_tokens) {
           usage = {
             inputTokens: 0,
@@ -177,11 +187,13 @@ export function parseSSEStream(body: string): ParsedResponse {
       }
 
       // OpenAI usage in final chunk
-      const objUsage = obj.usage as {
-        prompt_tokens?: number;
-        completion_tokens?: number;
-        total_tokens?: number;
-      } | undefined;
+      const objUsage = obj.usage as
+        | {
+            prompt_tokens?: number;
+            completion_tokens?: number;
+            total_tokens?: number;
+          }
+        | undefined;
       if (objUsage) {
         usage = {
           inputTokens: objUsage.prompt_tokens || 0,
@@ -194,7 +206,7 @@ export function parseSSEStream(body: string): ParsedResponse {
   }
 
   return {
-    content: texts.join(''),
+    content: texts.join(""),
     model,
     usage,
     isStreaming: true,
@@ -207,10 +219,7 @@ export function parseSSEStream(body: string): ParsedResponse {
  */
 export function isSSEResponse(body: string, contentType?: string): boolean {
   // Check Content-Type header
-  if (
-    contentType?.includes('text/event-stream') ||
-    contentType?.includes('text/x-event-stream')
-  ) {
+  if (contentType?.includes("text/event-stream") || contentType?.includes("text/x-event-stream")) {
     return true;
   }
 
@@ -224,7 +233,7 @@ export function isSSEResponse(body: string, contentType?: string): boolean {
  * Used by some streaming APIs
  */
 export function parseNDJSONStream(body: string): ParsedResponse {
-  const lines = body.split('\n').filter((l) => l.trim());
+  const lines = body.split("\n").filter((l) => l.trim());
   const texts: string[] = [];
   let model: string | undefined;
   let usage: TokenUsage | undefined;
@@ -234,15 +243,17 @@ export function parseNDJSONStream(body: string): ParsedResponse {
       const obj = JSON.parse(line) as Record;
 
       // Extract model
-      if (!model && obj.model && typeof obj.model === 'string') {
+      if (!model && obj.model && typeof obj.model === "string") {
         model = obj.model;
       }
 
       // Extract content from various formats
-      const choices = obj.choices as Array<{
-        delta?: { content?: string };
-        message?: { content?: string };
-      }> | undefined;
+      const choices = obj.choices as
+        | Array<{
+            delta?: { content?: string };
+            message?: { content?: string };
+          }>
+        | undefined;
       if (choices) {
         for (const choice of choices) {
           if (choice.delta?.content) {
@@ -254,10 +265,12 @@ export function parseNDJSONStream(body: string): ParsedResponse {
       }
 
       // Extract usage
-      const objUsage = obj.usage as {
-        prompt_tokens?: number;
-        completion_tokens?: number;
-      } | undefined;
+      const objUsage = obj.usage as
+        | {
+            prompt_tokens?: number;
+            completion_tokens?: number;
+          }
+        | undefined;
       if (objUsage) {
         usage = {
           inputTokens: objUsage.prompt_tokens || 0,
@@ -270,7 +283,7 @@ export function parseNDJSONStream(body: string): ParsedResponse {
   }
 
   return {
-    content: texts.join(''),
+    content: texts.join(""),
     model,
     usage,
     isStreaming: true,
diff --git a/src/lib/provider-testing/utils/test-prompts.ts b/src/lib/provider-testing/utils/test-prompts.ts
index 6fd561160..ab9f65c1b 100644
--- a/src/lib/provider-testing/utils/test-prompts.ts
+++ b/src/lib/provider-testing/utils/test-prompts.ts
@@ -8,13 +8,8 @@
  * 3. Support both streaming and non-streaming modes
  */
 
-import type { ProviderType } from '@/types/provider';
-import type {
-  ClaudeTestBody,
-  CodexTestBody,
-  OpenAITestBody,
-  GeminiTestBody,
-} from '../types';
+import type { ProviderType } from "@/types/provider";
+import type { ClaudeTestBody, CodexTestBody, OpenAITestBody, GeminiTestBody } from "../types";
 
 // ============================================================================
 // Claude / Claude-Auth Test Body
@@ -27,31 +22,31 @@ import type {
  * - Minimal token usage with echo bot pattern
  */
 export const CLAUDE_TEST_BODY: ClaudeTestBody = {
-  model: 'claude-sonnet-4-5-20250929',
+  model: "claude-sonnet-4-5-20250929",
   messages: [
     {
-      role: 'user',
-      content: [{ type: 'text', text: 'ping, please reply pong' }],
+      role: "user",
+      content: [{ type: "text", text: "ping, please reply pong" }],
     },
   ],
   system: [
     {
-      type: 'text',
-      text: 'You are a echo bot. Always say pong.',
-      cache_control: { type: 'ephemeral' },
+      type: "text",
+      text: "You are a echo bot. Always say pong.",
+      cache_control: { type: "ephemeral" },
     },
   ],
   max_tokens: 20,
   stream: false,
-  metadata: { user_id: 'cch_probe_test' },
+  metadata: { user_id: "cch_probe_test" },
 };
 
 /**
  * Headers for Claude API
  */
 export const CLAUDE_TEST_HEADERS = {
-  'anthropic-version': '2023-06-01',
-  'content-type': 'application/json',
+  "anthropic-version": "2023-06-01",
+  "content-type": "application/json",
 };
 
 // ============================================================================
@@ -65,18 +60,18 @@ export const CLAUDE_TEST_HEADERS = {
  * - Low reasoning effort for faster response
  */
 export const CODEX_TEST_BODY: CodexTestBody = {
-  model: 'gpt-5-codex',
-  instructions: 'You are a echo bot. Always say pong.',
+  model: "gpt-5-codex",
+  instructions: "You are a echo bot. Always say pong.",
   input: [
     {
-      type: 'message',
-      role: 'user',
-      content: [{ type: 'input_text', text: 'ping' }],
+      type: "message",
+      role: "user",
+      content: [{ type: "input_text", text: "ping" }],
     },
   ],
   tools: [],
-  tool_choice: 'auto',
-  reasoning: { effort: 'low', summary: 'auto' },
+  tool_choice: "auto",
+  reasoning: { effort: "low", summary: "auto" },
   store: false,
   stream: true,
 };
@@ -85,7 +80,7 @@ export const CODEX_TEST_BODY: CodexTestBody = {
  * Headers for Codex API (uses Bearer token)
  */
 export const CODEX_TEST_HEADERS = {
-  'content-type': 'application/json',
+  "content-type": "application/json",
 };
 
 // ============================================================================
@@ -98,10 +93,10 @@ export const CODEX_TEST_HEADERS = {
  * - Non-streaming for simpler validation
  */
 export const OPENAI_TEST_BODY: OpenAITestBody = {
-  model: 'gpt-4o',
+  model: "gpt-4o",
   messages: [
-    { role: 'system', content: 'You are a echo bot. Always say pong.' },
-    { role: 'user', content: 'ping' },
+    { role: "system", content: "You are a echo bot. Always say pong." },
+    { role: "user", content: "ping" },
   ],
   max_tokens: 20,
   stream: false,
@@ -111,7 +106,7 @@ export const OPENAI_TEST_BODY: OpenAITestBody = {
  * Headers for OpenAI-Compatible API (uses Bearer token)
  */
 export const OPENAI_TEST_HEADERS = {
-  'content-type': 'application/json',
+  "content-type": "application/json",
 };
 
 // ============================================================================
@@ -126,11 +121,11 @@ export const OPENAI_TEST_HEADERS = {
 export const GEMINI_TEST_BODY: GeminiTestBody = {
   contents: [
     {
-      parts: [{ text: 'ping, please reply pong' }],
+      parts: [{ text: "ping, please reply pong" }],
     },
   ],
   systemInstruction: {
-    parts: [{ text: 'You are a echo bot. Always say pong.' }],
+    parts: [{ text: "You are a echo bot. Always say pong." }],
   },
   generationConfig: {
     maxOutputTokens: 20,
@@ -141,7 +136,7 @@ export const GEMINI_TEST_BODY: GeminiTestBody = {
  * Headers for Gemini API
  */
 export const GEMINI_TEST_HEADERS = {
-  'content-type': 'application/json',
+  "content-type": "application/json",
 };
 
 // ============================================================================
@@ -152,60 +147,57 @@ export const GEMINI_TEST_HEADERS = {
  * Default models per provider type
  */
 export const DEFAULT_MODELS: Record = {
-  claude: 'claude-sonnet-4-5-20250929',
-  'claude-auth': 'claude-sonnet-4-5-20250929',
-  codex: 'gpt-5-codex',
-  'openai-compatible': 'gpt-4o',
-  gemini: 'gemini-2.0-flash',
-  'gemini-cli': 'gemini-2.0-flash',
+  claude: "claude-sonnet-4-5-20250929",
+  "claude-auth": "claude-sonnet-4-5-20250929",
+  codex: "gpt-5-codex",
+  "openai-compatible": "gpt-4o",
+  gemini: "gemini-2.0-flash",
+  "gemini-cli": "gemini-2.0-flash",
 };
 
 /**
  * Default success_contains patterns per provider type
  */
 export const DEFAULT_SUCCESS_CONTAINS: Record = {
-  claude: 'pong',
-  'claude-auth': 'pong',
-  codex: 'pong',
-  'openai-compatible': 'pong',
-  gemini: 'pong',
-  'gemini-cli': 'pong',
+  claude: "pong",
+  "claude-auth": "pong",
+  codex: "pong",
+  "openai-compatible": "pong",
+  gemini: "pong",
+  "gemini-cli": "pong",
 };
 
 /**
  * API endpoints per provider type
  */
 export const API_ENDPOINTS: Record = {
-  claude: '/v1/messages',
-  'claude-auth': '/v1/messages',
-  codex: '/v1/responses',
-  'openai-compatible': '/v1/chat/completions',
-  gemini: '/v1beta/models/{model}:generateContent',
-  'gemini-cli': '/v1beta/models/{model}:generateContent',
+  claude: "/v1/messages",
+  "claude-auth": "/v1/messages",
+  codex: "/v1/responses",
+  "openai-compatible": "/v1/chat/completions",
+  gemini: "/v1beta/models/{model}:generateContent",
+  "gemini-cli": "/v1beta/models/{model}:generateContent",
 };
 
 /**
  * Get test body for a specific provider type
  */
-export function getTestBody(
-  providerType: ProviderType,
-  model?: string
-): Record {
+export function getTestBody(providerType: ProviderType, model?: string): Record {
   const targetModel = model || DEFAULT_MODELS[providerType];
 
   switch (providerType) {
-    case 'claude':
-    case 'claude-auth':
+    case "claude":
+    case "claude-auth":
       return { ...CLAUDE_TEST_BODY, model: targetModel };
 
-    case 'codex':
+    case "codex":
       return { ...CODEX_TEST_BODY, model: targetModel };
 
-    case 'openai-compatible':
+    case "openai-compatible":
       return { ...OPENAI_TEST_BODY, model: targetModel };
 
-    case 'gemini':
-    case 'gemini-cli':
+    case "gemini":
+    case "gemini-cli":
       // Gemini model is in URL, not body
       return { ...GEMINI_TEST_BODY };
 
@@ -217,33 +209,30 @@ export function getTestBody(
 /**
  * Get test headers for a specific provider type
  */
-export function getTestHeaders(
-  providerType: ProviderType,
-  apiKey: string
-): Record {
+export function getTestHeaders(providerType: ProviderType, apiKey: string): Record {
   switch (providerType) {
-    case 'claude':
+    case "claude":
       return {
         ...CLAUDE_TEST_HEADERS,
-        'x-api-key': apiKey,
+        "x-api-key": apiKey,
       };
 
-    case 'claude-auth':
+    case "claude-auth":
       // Claude-auth uses Bearer token
       return {
         ...CLAUDE_TEST_HEADERS,
         Authorization: `Bearer ${apiKey}`,
       };
 
-    case 'codex':
-    case 'openai-compatible':
+    case "codex":
+    case "openai-compatible":
       return {
         ...OPENAI_TEST_HEADERS,
         Authorization: `Bearer ${apiKey}`,
       };
 
-    case 'gemini':
-    case 'gemini-cli':
+    case "gemini":
+    case "gemini-cli":
       // Gemini uses URL parameter for API key
       return {
         ...GEMINI_TEST_HEADERS,
@@ -264,15 +253,15 @@ export function getTestUrl(
   apiKey?: string
 ): string {
   // Remove trailing slash
-  const cleanBaseUrl = baseUrl.replace(/\/$/, '');
+  const cleanBaseUrl = baseUrl.replace(/\/$/, "");
   const endpoint = API_ENDPOINTS[providerType];
   const targetModel = model || DEFAULT_MODELS[providerType];
 
   let url = `${cleanBaseUrl}${endpoint}`;
 
   // Gemini needs model in URL
-  if (providerType === 'gemini' || providerType === 'gemini-cli') {
-    url = url.replace('{model}', targetModel);
+  if (providerType === "gemini" || providerType === "gemini-cli") {
+    url = url.replace("{model}", targetModel);
     // Add API key as query parameter for Gemini
     if (apiKey) {
       url += `?key=${apiKey}`;
diff --git a/src/lib/provider-testing/validators/content-validator.ts b/src/lib/provider-testing/validators/content-validator.ts
index f2b406ef4..5765100b7 100644
--- a/src/lib/provider-testing/validators/content-validator.ts
+++ b/src/lib/provider-testing/validators/content-validator.ts
@@ -4,7 +4,7 @@
  * Based on relay-pulse implementation
  */
 
-import type { TestStatus, TestSubStatus } from '../types';
+import type { TestStatus, TestSubStatus } from "../types";
 
 export interface ContentValidationResult {
   status: TestStatus;
@@ -38,7 +38,7 @@ export function evaluateContentValidation(
   }
 
   // Already red - no need to validate (can't get worse)
-  if (baseStatus === 'red') {
+  if (baseStatus === "red") {
     return {
       status: baseStatus,
       subStatus: baseSubStatus,
@@ -47,7 +47,7 @@ export function evaluateContentValidation(
   }
 
   // 429 rate limit: response body is error message, skip content validation
-  if (baseSubStatus === 'rate_limit') {
+  if (baseSubStatus === "rate_limit") {
     return {
       status: baseStatus,
       subStatus: baseSubStatus,
@@ -58,8 +58,8 @@ export function evaluateContentValidation(
   // Empty response = content mismatch
   if (!responseBody || !responseBody.trim()) {
     return {
-      status: 'red',
-      subStatus: 'content_mismatch',
+      status: "red",
+      subStatus: "content_mismatch",
       contentPassed: false,
     };
   }
@@ -67,8 +67,8 @@ export function evaluateContentValidation(
   // Check if response contains expected content
   if (!responseBody.includes(successContains)) {
     return {
-      status: 'red',
-      subStatus: 'content_mismatch',
+      status: "red",
+      subStatus: "content_mismatch",
       contentPassed: false,
     };
   }
@@ -93,9 +93,9 @@ export function extractTextContent(responseBody: string): string {
     // Anthropic format
     if (obj.content && Array.isArray(obj.content)) {
       return obj.content
-        .filter((c: { type: string }) => c.type === 'text')
+        .filter((c: { type: string }) => c.type === "text")
         .map((c: { text: string }) => c.text)
-        .join('');
+        .join("");
     }
 
     // OpenAI format
@@ -103,18 +103,19 @@ export function extractTextContent(responseBody: string): string {
       return obj.choices
         .map(
           (c: { message?: { content: string }; text?: string }) =>
-            c.message?.content || c.text || ''
+            c.message?.content || c.text || ""
         )
-        .join('');
+        .join("");
     }
 
     // Codex Response API format
     if (obj.output && Array.isArray(obj.output)) {
       return obj.output
-        .flatMap((o: { content?: Array<{ text?: string }> }) =>
-          o.content?.map((c) => c.text || '').filter(Boolean) || []
+        .flatMap(
+          (o: { content?: Array<{ text?: string }> }) =>
+            o.content?.map((c) => c.text || "").filter(Boolean) || []
         )
-        .join('');
+        .join("");
     }
 
     // Gemini format
@@ -122,18 +123,18 @@ export function extractTextContent(responseBody: string): string {
       return obj.candidates
         .flatMap(
           (c: { content?: { parts?: Array<{ text?: string }> } }) =>
-            c.content?.parts?.map((p) => p.text || '').filter(Boolean) || []
+            c.content?.parts?.map((p) => p.text || "").filter(Boolean) || []
         )
-        .join('');
+        .join("");
     }
 
     // Direct content field
-    if (typeof obj.content === 'string') {
+    if (typeof obj.content === "string") {
       return obj.content;
     }
 
     // Direct text field
-    if (typeof obj.text === 'string') {
+    if (typeof obj.text === "string") {
       return obj.text;
     }
   } catch {
diff --git a/src/lib/provider-testing/validators/http-validator.ts b/src/lib/provider-testing/validators/http-validator.ts
index c39676a33..70a7ff3b6 100644
--- a/src/lib/provider-testing/validators/http-validator.ts
+++ b/src/lib/provider-testing/validators/http-validator.ts
@@ -4,7 +4,7 @@
  * Based on relay-pulse implementation
  */
 
-import { TEST_DEFAULTS, type TestStatus, type TestSubStatus } from '../types';
+import { TEST_DEFAULTS, type TestStatus, type TestSubStatus } from "../types";
 
 export interface HttpValidationResult {
   status: TestStatus;
@@ -31,43 +31,43 @@ export function classifyHttpStatus(
   // 2xx = Green (or Yellow if slow)
   if (statusCode >= 200 && statusCode < 300) {
     if (latencyMs > slowThresholdMs) {
-      return { status: 'yellow', subStatus: 'slow_latency' };
+      return { status: "yellow", subStatus: "slow_latency" };
     }
-    return { status: 'green', subStatus: 'success' };
+    return { status: "green", subStatus: "success" };
   }
 
   // 3xx = Green (redirects handled by HTTP client)
   if (statusCode >= 300 && statusCode < 400) {
-    return { status: 'green', subStatus: 'success' };
+    return { status: "green", subStatus: "success" };
   }
 
   // 401/403 = Red (auth failure)
   if (statusCode === 401 || statusCode === 403) {
-    return { status: 'red', subStatus: 'auth_error' };
+    return { status: "red", subStatus: "auth_error" };
   }
 
   // 400 = Red (invalid request)
   if (statusCode === 400) {
-    return { status: 'red', subStatus: 'invalid_request' };
+    return { status: "red", subStatus: "invalid_request" };
   }
 
   // 429 = Red (rate limit)
   if (statusCode === 429) {
-    return { status: 'red', subStatus: 'rate_limit' };
+    return { status: "red", subStatus: "rate_limit" };
   }
 
   // 5xx = Red (server error)
   if (statusCode >= 500) {
-    return { status: 'red', subStatus: 'server_error' };
+    return { status: "red", subStatus: "server_error" };
   }
 
   // Other 4xx = Red (client error)
   if (statusCode >= 400) {
-    return { status: 'red', subStatus: 'client_error' };
+    return { status: "red", subStatus: "client_error" };
   }
 
   // 1xx or other non-standard = Red (client error)
-  return { status: 'red', subStatus: 'client_error' };
+  return { status: "red", subStatus: "client_error" };
 }
 
 /**
@@ -82,15 +82,15 @@ export function isHttpSuccess(statusCode: number): boolean {
  */
 export function getSubStatusDescription(subStatus: TestSubStatus): string {
   const descriptions: Record = {
-    success: 'All checks passed',
-    slow_latency: 'Response was slow but successful',
-    rate_limit: 'Rate limited (HTTP 429)',
-    server_error: 'Server error (HTTP 5xx)',
-    client_error: 'Client error (HTTP 4xx)',
-    auth_error: 'Authentication failed (HTTP 401/403)',
-    invalid_request: 'Invalid request (HTTP 400)',
-    network_error: 'Network connection failed',
-    content_mismatch: 'Response content validation failed',
+    success: "All checks passed",
+    slow_latency: "Response was slow but successful",
+    rate_limit: "Rate limited (HTTP 429)",
+    server_error: "Server error (HTTP 5xx)",
+    client_error: "Client error (HTTP 4xx)",
+    auth_error: "Authentication failed (HTTP 401/403)",
+    invalid_request: "Invalid request (HTTP 400)",
+    network_error: "Network connection failed",
+    content_mismatch: "Response content validation failed",
   };
   return descriptions[subStatus];
 }
diff --git a/src/lib/provider-testing/validators/index.ts b/src/lib/provider-testing/validators/index.ts
index cfc34e915..d2120ac6f 100644
--- a/src/lib/provider-testing/validators/index.ts
+++ b/src/lib/provider-testing/validators/index.ts
@@ -8,10 +8,10 @@ export {
   isHttpSuccess,
   getSubStatusDescription,
   type HttpValidationResult,
-} from './http-validator';
+} from "./http-validator";
 
 export {
   evaluateContentValidation,
   extractTextContent,
   type ContentValidationResult,
-} from './content-validator';
+} from "./content-validator";

From 1881a94c891e3cf9da73c7a98e12ff350494e834 Mon Sep 17 00:00:00 2001
From: ding113 
Date: Thu, 27 Nov 2025 00:53:05 +0800
Subject: [PATCH 05/19] =?UTF-8?q?perf:=20=E4=BC=98=E5=8C=96=E4=BE=9B?=
 =?UTF-8?q?=E5=BA=94=E5=95=86=E9=A1=B5=E9=9D=A2=E6=80=A7=E8=83=BD=20-=20?=
 =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20N+1=20=E6=9F=A5=E8=AF=A2=E5=92=8C=20SQL=20?=
 =?UTF-8?q?=E5=85=A8=E8=A1=A8=E6=89=AB=E6=8F=8F=E9=97=AE=E9=A2=98?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

问题分析:
- 供应商限额页面:每个供应商触发 1 DB + 5 Redis 查询(N+1 问题)
- 供应商管理页面:getProviderStatistics SQL 缺少日期过滤(全表扫描)

修复内容:
1. 新增批量查询方法避免 N+1:
   - RateLimitService.getCurrentCostBatch() - Redis Pipeline 批量获取限额
   - SessionTracker.getProviderSessionCountBatch() - 批量获取 session 计数
   - getProviderLimitUsageBatch() - 整合批量查询入口

2. 优化 getProviderStatistics SQL:
   - provider_stats CTE: LEFT JOIN 添加 created_at >= CURRENT_DATE 过滤
   - latest_call CTE: 添加 7 天时间范围限制

性能提升(50 个供应商):
- 限额页面:52 DB + 250 Redis → 2 DB + 2 Redis Pipeline
- 管理页面:全表扫描 → 仅扫描今日/7 天数据

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude 
---
 messages/en/settings.json                     |  10 +
 messages/ja/settings.json                     |  10 +
 messages/ru/settings.json                     |  10 +
 messages/zh-CN/settings.json                  |  10 +
 messages/zh-TW/settings.json                  |  10 +
 src/actions/providers.ts                      | 180 ++++++++++++++++++
 .../dashboard/quotas/providers/page.tsx       |  40 ++--
 .../_components/forms/api-test-button.tsx     | 144 ++++++++++++--
 src/lib/provider-testing/data/cc_base.json    |  29 +++
 src/lib/provider-testing/data/cc_sonnet.json  |  37 ++++
 src/lib/provider-testing/data/cx_base.json    |  22 +++
 .../provider-testing/data/public_cc_base.json |  44 +++++
 src/lib/provider-testing/presets.ts           | 159 ++++++++++++++++
 src/lib/provider-testing/test-service.ts      |  40 +++-
 src/lib/provider-testing/types.ts             |   9 +
 .../provider-testing/utils/test-prompts.ts    |  44 ++++-
 src/lib/rate-limit/service.ts                 | 131 +++++++++++++
 src/lib/session-tracker.ts                    | 107 +++++++++++
 src/repository/provider.ts                    |   8 +
 19 files changed, 1010 insertions(+), 34 deletions(-)
 create mode 100644 src/lib/provider-testing/data/cc_base.json
 create mode 100644 src/lib/provider-testing/data/cc_sonnet.json
 create mode 100644 src/lib/provider-testing/data/cx_base.json
 create mode 100644 src/lib/provider-testing/data/public_cc_base.json
 create mode 100644 src/lib/provider-testing/presets.ts

diff --git a/messages/en/settings.json b/messages/en/settings.json
index 0433c4b83..a8f68a80c 100644
--- a/messages/en/settings.json
+++ b/messages/en/settings.json
@@ -613,6 +613,16 @@
         "formatOpenAIResponses": "Codex (Response API)",
         "testModel": "Test model",
         "testModelDesc": "Leave empty to use the default model or type one manually",
+        "requestConfig": "Request Configuration",
+        "presetConfig": "Preset",
+        "customConfig": "Custom",
+        "selectPreset": "Select preset template",
+        "presetDesc": "Preset templates contain authentic CLI request patterns for relay service verification",
+        "customPayloadPlaceholder": "{\"model\": \"...\", \"messages\": [...]}",
+        "customPayloadDesc": "Enter custom JSON payload to override default request body",
+        "successContains": "Success Keyword",
+        "successContainsPlaceholder": "pong",
+        "successContainsDesc": "Response must contain this keyword to be considered successful",
         "model": "Model",
         "responseModel": "Response model",
         "responseTime": "Response time",
diff --git a/messages/ja/settings.json b/messages/ja/settings.json
index 37cde574a..b770087f0 100644
--- a/messages/ja/settings.json
+++ b/messages/ja/settings.json
@@ -560,6 +560,16 @@
         "formatOpenAIResponses": "Codex (Response API)",
         "testModel": "テストモデル",
         "testModelDesc": "空欄の場合はデフォルトモデルを使用、手動入力も可能",
+        "requestConfig": "リクエスト設定",
+        "presetConfig": "プリセット",
+        "customConfig": "カスタム",
+        "selectPreset": "プリセットテンプレートを選択",
+        "presetDesc": "プリセットテンプレートには、リレーサービス検証用の本物のCLIリクエストパターンが含まれています",
+        "customPayloadPlaceholder": "{\"model\": \"...\", \"messages\": [...]}",
+        "customPayloadDesc": "カスタムJSONペイロードを入力してデフォルトのリクエストボディを上書き",
+        "successContains": "成功検出キーワード",
+        "successContainsPlaceholder": "pong",
+        "successContainsDesc": "成功と見なすには、レスポンスにこのキーワードが含まれている必要があります",
         "model": "モデル",
         "responseModel": "応答モデル",
         "responseTime": "応答時間",
diff --git a/messages/ru/settings.json b/messages/ru/settings.json
index 0d3e347b8..c783f4f55 100644
--- a/messages/ru/settings.json
+++ b/messages/ru/settings.json
@@ -560,6 +560,16 @@
         "formatOpenAIResponses": "Codex (Response API)",
         "testModel": "Тестовая модель",
         "testModelDesc": "Оставьте пустым для использования модели по умолчанию или введите вручную",
+        "requestConfig": "Конфигурация запроса",
+        "presetConfig": "Пресет",
+        "customConfig": "Пользовательский",
+        "selectPreset": "Выберите шаблон пресета",
+        "presetDesc": "Шаблоны пресетов содержат аутентичные паттерны CLI-запросов для верификации релейного сервиса",
+        "customPayloadPlaceholder": "{\"model\": \"...\", \"messages\": [...]}",
+        "customPayloadDesc": "Введите пользовательский JSON payload для замены тела запроса по умолчанию",
+        "successContains": "Ключевое слово успеха",
+        "successContainsPlaceholder": "pong",
+        "successContainsDesc": "Ответ должен содержать это ключевое слово для признания успешным",
         "model": "Модель",
         "responseModel": "Модель ответа",
         "responseTime": "Время ответа",
diff --git a/messages/zh-CN/settings.json b/messages/zh-CN/settings.json
index b50773839..a6abb84e2 100644
--- a/messages/zh-CN/settings.json
+++ b/messages/zh-CN/settings.json
@@ -239,6 +239,16 @@
         "formatOpenAIResponses": "Codex (Response API)",
         "testModel": "测试模型",
         "testModelDesc": "可手动输入,不填写则使用默认模型",
+        "requestConfig": "请求配置",
+        "presetConfig": "预置配置",
+        "customConfig": "自定义",
+        "selectPreset": "选择预置模板",
+        "presetDesc": "预置模板包含真实 CLI 请求特征,用于通过中转服务验证",
+        "customPayloadPlaceholder": "{\"model\": \"...\", \"messages\": [...]}",
+        "customPayloadDesc": "输入自定义 JSON payload,将覆盖默认请求体",
+        "successContains": "成功检测词",
+        "successContainsPlaceholder": "pong",
+        "successContainsDesc": "响应需包含此内容才算测试成功",
         "model": "模型",
         "responseModel": "响应模型",
         "responseTime": "响应时间",
diff --git a/messages/zh-TW/settings.json b/messages/zh-TW/settings.json
index 9da49d0a4..3dc525279 100644
--- a/messages/zh-TW/settings.json
+++ b/messages/zh-TW/settings.json
@@ -598,6 +598,16 @@
         "formatOpenAIResponses": "Codex (Response API)",
         "testModel": "測試模型",
         "testModelDesc": "可手動輸入,留空則使用預設模型",
+        "requestConfig": "請求配置",
+        "presetConfig": "預置配置",
+        "customConfig": "自訂",
+        "selectPreset": "選擇預置範本",
+        "presetDesc": "預置範本包含真實 CLI 請求特徵,用於通過中轉服務驗證",
+        "customPayloadPlaceholder": "{\"model\": \"...\", \"messages\": [...]}",
+        "customPayloadDesc": "輸入自訂 JSON payload,將覆蓋預設請求主體",
+        "successContains": "成功檢測詞",
+        "successContainsPlaceholder": "pong",
+        "successContainsDesc": "回應需包含此內容才算測試成功",
         "model": "模型",
         "responseModel": "回應模型",
         "responseTime": "回應時間",
diff --git a/src/actions/providers.ts b/src/actions/providers.ts
index c3b2fbfaa..70fae86c0 100644
--- a/src/actions/providers.ts
+++ b/src/actions/providers.ts
@@ -35,6 +35,10 @@ import {
   type TestStatus,
   type TestSubStatus,
 } from "@/lib/provider-testing";
+import {
+  getPresetsForProvider,
+  type PresetConfig,
+} from "@/lib/provider-testing/presets";
 
 const API_TEST_TIMEOUT_LIMITS = {
   DEFAULT: 15000,
@@ -691,6 +695,119 @@ export async function getProviderLimitUsage(providerId: number): Promise<
   }
 }
 
+/**
+ * 供应商限额使用情况数据结构
+ */
+export type ProviderLimitUsageData = {
+  cost5h: { current: number; limit: number | null; resetInfo: string };
+  costDaily: { current: number; limit: number | null; resetAt?: Date };
+  costWeekly: { current: number; limit: number | null; resetAt: Date };
+  costMonthly: { current: number; limit: number | null; resetAt: Date };
+  concurrentSessions: { current: number; limit: number };
+};
+
+/**
+ * 批量获取多个供应商的限额使用情况
+ * 使用 Redis Pipeline 避免 N+1 查询问题
+ *
+ * @param providers - 供应商数据数组(必须包含限额相关字段)
+ * @returns Map
+ */
+export async function getProviderLimitUsageBatch(
+  providers: Array<{
+    id: number;
+    dailyResetTime?: string | null;
+    dailyResetMode?: string | null;
+    limit5hUsd?: number | null;
+    limitDailyUsd?: number | null;
+    limitWeeklyUsd?: number | null;
+    limitMonthlyUsd?: number | null;
+    limitConcurrentSessions?: number | null;
+  }>
+): Promise> {
+  const result = new Map();
+
+  if (providers.length === 0) {
+    return result;
+  }
+
+  try {
+    const session = await getSession();
+    if (!session || session.user.role !== "admin") {
+      logger.warn("getProviderLimitUsageBatch: 无权限执行此操作");
+      return result;
+    }
+
+    // 动态导入避免循环依赖
+    const { RateLimitService } = await import("@/lib/rate-limit");
+    const { SessionTracker } = await import("@/lib/session-tracker");
+    const { getResetInfo, getResetInfoWithMode } = await import("@/lib/rate-limit/time-utils");
+
+    const providerIds = providers.map((p) => p.id);
+
+    // 构建日限额重置配置
+    const dailyResetConfigs = new Map();
+    for (const provider of providers) {
+      dailyResetConfigs.set(provider.id, {
+        resetTime: provider.dailyResetTime,
+        resetMode: provider.dailyResetMode,
+      });
+    }
+
+    // 批量获取限额消费和并发 session 计数(2 次 Redis Pipeline 调用)
+    const [costMap, sessionCountMap] = await Promise.all([
+      RateLimitService.getCurrentCostBatch(providerIds, dailyResetConfigs),
+      SessionTracker.getProviderSessionCountBatch(providerIds),
+    ]);
+
+    // 组装结果
+    for (const provider of providers) {
+      const costs = costMap.get(provider.id) || { cost5h: 0, costDaily: 0, costWeekly: 0, costMonthly: 0 };
+      const sessionCount = sessionCountMap.get(provider.id) || 0;
+
+      // 获取重置时间信息
+      const reset5h = getResetInfo("5h");
+      const dailyResetMode = (provider.dailyResetMode ?? "fixed") as "fixed" | "rolling";
+      const resetDaily = getResetInfoWithMode("daily", provider.dailyResetTime ?? undefined, dailyResetMode);
+      const resetWeekly = getResetInfo("weekly");
+      const resetMonthly = getResetInfo("monthly");
+
+      result.set(provider.id, {
+        cost5h: {
+          current: costs.cost5h,
+          limit: provider.limit5hUsd ?? null,
+          resetInfo: reset5h.type === "rolling" ? `滚动窗口(${reset5h.period})` : "自然时间窗口",
+        },
+        costDaily: {
+          current: costs.costDaily,
+          limit: provider.limitDailyUsd ?? null,
+          resetAt: resetDaily.type === "rolling" ? undefined : resetDaily.resetAt!,
+        },
+        costWeekly: {
+          current: costs.costWeekly,
+          limit: provider.limitWeeklyUsd ?? null,
+          resetAt: resetWeekly.resetAt!,
+        },
+        costMonthly: {
+          current: costs.costMonthly,
+          limit: provider.limitMonthlyUsd ?? null,
+          resetAt: resetMonthly.resetAt!,
+        },
+        concurrentSessions: {
+          current: sessionCount,
+          limit: provider.limitConcurrentSessions || 0,
+        },
+      });
+    }
+
+    logger.debug(`getProviderLimitUsageBatch: 批量获取 ${providers.length} 个供应商限额数据完成`);
+    return result;
+  } catch (error) {
+    logger.error("批量获取供应商限额使用情况失败:", error);
+    return result;
+  }
+}
+
 /**
  * 测试代理连接
  * 通过代理访问供应商 URL,验证代理配置是否正确
@@ -2226,6 +2343,12 @@ export type UnifiedTestArgs = {
   successContains?: string;
   /** Request timeout in ms (default: 10000) */
   timeoutMs?: number;
+  /** Preset configuration ID (e.g., 'cc_base', 'cx_base') */
+  preset?: string;
+  /** Custom JSON payload (overrides preset and default body) */
+  customPayload?: string;
+  /** Custom headers to merge with default headers */
+  customHeaders?: Record;
 };
 
 /**
@@ -2338,6 +2461,10 @@ export async function testProviderUnified(data: UnifiedTestArgs): Promise> {
+  const session = await getSession();
+  if (!session || session.user.role !== "admin") {
+    return {
+      ok: false,
+      error: "未授权",
+    };
+  }
+
+  try {
+    const presets = getPresetsForProvider(providerType);
+    const response: PresetConfigResponse[] = presets.map((preset) => ({
+      id: preset.id,
+      description: preset.description,
+      defaultSuccessContains: preset.defaultSuccessContains,
+      defaultModel: preset.defaultModel,
+    }));
+
+    return {
+      ok: true,
+      data: response,
+    };
+  } catch (error) {
+    logger.error("getProviderTestPresets error", { error, providerType });
+    return {
+      ok: false,
+      error: "获取预置配置失败",
+    };
+  }
+}
diff --git a/src/app/[locale]/dashboard/quotas/providers/page.tsx b/src/app/[locale]/dashboard/quotas/providers/page.tsx
index a3ac4e162..d3d1be2cf 100644
--- a/src/app/[locale]/dashboard/quotas/providers/page.tsx
+++ b/src/app/[locale]/dashboard/quotas/providers/page.tsx
@@ -1,5 +1,4 @@
-import { getProviders } from "@/actions/providers";
-import { getProviderLimitUsage } from "@/actions/providers";
+import { getProviders, getProviderLimitUsageBatch } from "@/actions/providers";
 import { ProvidersQuotaManager } from "./_components/providers-quota-manager";
 import { getSystemSettings } from "@/repository/system-config";
 import { getTranslations } from "next-intl/server";
@@ -10,22 +9,31 @@ export const dynamic = "force-dynamic";
 async function getProvidersWithQuotas() {
   const providers = await getProviders();
 
-  const providersWithQuotas = await Promise.all(
-    providers.map(async (provider) => {
-      const result = await getProviderLimitUsage(provider.id);
-      return {
-        id: provider.id,
-        name: provider.name,
-        providerType: provider.providerType,
-        isEnabled: provider.isEnabled,
-        priority: provider.priority,
-        weight: provider.weight,
-        quota: result.ok ? result.data : null,
-      };
-    })
+  // 使用批量查询获取所有供应商的限额数据(避免 N+1 查询问题)
+  // 优化前: 50 个供应商 = 52 DB + 250 Redis 查询
+  // 优化后: 50 个供应商 = 2 DB + 2 Redis Pipeline 查询
+  const quotaMap = await getProviderLimitUsageBatch(
+    providers.map((p) => ({
+      id: p.id,
+      dailyResetTime: p.dailyResetTime,
+      dailyResetMode: p.dailyResetMode,
+      limit5hUsd: p.limit5hUsd,
+      limitDailyUsd: p.limitDailyUsd,
+      limitWeeklyUsd: p.limitWeeklyUsd,
+      limitMonthlyUsd: p.limitMonthlyUsd,
+      limitConcurrentSessions: p.limitConcurrentSessions,
+    }))
   );
 
-  return providersWithQuotas;
+  return providers.map((provider) => ({
+    id: provider.id,
+    name: provider.name,
+    providerType: provider.providerType,
+    isEnabled: provider.isEnabled,
+    priority: provider.priority,
+    weight: provider.weight,
+    quota: quotaMap.get(provider.id) ?? null,
+  }));
 }
 
 export default async function ProvidersQuotaPage() {
diff --git a/src/app/[locale]/settings/providers/_components/forms/api-test-button.tsx b/src/app/[locale]/settings/providers/_components/forms/api-test-button.tsx
index d7b4af68c..ec6be998e 100644
--- a/src/app/[locale]/settings/providers/_components/forms/api-test-button.tsx
+++ b/src/app/[locale]/settings/providers/_components/forms/api-test-button.tsx
@@ -3,7 +3,12 @@
 import { useEffect, useMemo, useState } from "react";
 import { Button } from "@/components/ui/button";
 import { Loader2, CheckCircle2, XCircle, Activity, AlertTriangle } from "lucide-react";
-import { testProviderUnified, getUnmaskedProviderKey } from "@/actions/providers";
+import {
+  testProviderUnified,
+  getUnmaskedProviderKey,
+  getProviderTestPresets,
+  type PresetConfigResponse,
+} from "@/actions/providers";
 import { toast } from "sonner";
 import { useTranslations } from "next-intl";
 import {
@@ -15,6 +20,7 @@ import {
 } from "@/components/ui/select";
 import { Input } from "@/components/ui/input";
 import { Label } from "@/components/ui/label";
+import { Textarea } from "@/components/ui/textarea";
 import { isValidUrl } from "@/lib/utils/validation";
 import type { ProviderType } from "@/types/provider";
 import { TestResultCard, type UnifiedTestResultData } from "./test-result-card";
@@ -105,6 +111,13 @@ export function ApiTestButton({
   const [isModelManuallyEdited, setIsModelManuallyEdited] = useState(false);
   const [testResult, setTestResult] = useState(null);
 
+  // Custom configuration state
+  const [configMode, setConfigMode] = useState<"preset" | "custom">("preset");
+  const [presets, setPresets] = useState([]);
+  const [selectedPreset, setSelectedPreset] = useState("");
+  const [customPayload, setCustomPayload] = useState("");
+  const [successContains, setSuccessContains] = useState("pong");
+
   useEffect(() => {
     if (isApiFormatManuallySelected) return;
     const resolvedFormat = resolveApiFormatFromProvider(providerType);
@@ -113,6 +126,33 @@ export function ApiTestButton({
     }
   }, [apiFormat, isApiFormatManuallySelected, providerType]);
 
+  // Map API format to provider type (defined before useEffect that depends on it)
+  const apiFormatToProviderType: Record = useMemo(() => ({
+    "anthropic-messages": providerType === "claude-auth" ? "claude-auth" : "claude",
+    "openai-chat": "openai-compatible",
+    "openai-responses": "codex",
+    gemini: providerType === "gemini-cli" ? "gemini-cli" : "gemini",
+  }), [providerType]);
+
+  // Load presets when provider type changes
+  useEffect(() => {
+    const currentProviderType = apiFormatToProviderType[apiFormat];
+    if (!currentProviderType) return;
+
+    getProviderTestPresets(currentProviderType).then((result) => {
+      if (result.ok && result.data) {
+        setPresets(result.data);
+        // Auto-select first preset if available
+        if (result.data.length > 0 && !selectedPreset) {
+          setSelectedPreset(result.data[0].id);
+          setSuccessContains(result.data[0].defaultSuccessContains);
+        }
+      } else {
+        setPresets([]);
+      }
+    });
+  }, [apiFormat, apiFormatToProviderType]);
+
   useEffect(() => {
     if (isModelManuallyEdited) {
       return;
@@ -123,14 +163,6 @@ export function ApiTestButton({
     setTestModel(defaultModel);
   }, [apiFormat, isModelManuallyEdited, normalizedAllowedModels]);
 
-  // Map API format to provider type
-  const apiFormatToProviderType: Record = {
-    "anthropic-messages": providerType === "claude-auth" ? "claude-auth" : "claude",
-    "openai-chat": "openai-compatible",
-    "openai-responses": "codex",
-    gemini: providerType === "gemini-cli" ? "gemini-cli" : "gemini",
-  };
-
   const handleTest = async () => {
     // 验证必填字段
     if (!providerUrl.trim()) {
@@ -178,6 +210,10 @@ export function ApiTestButton({
         model: testModel.trim() || undefined,
         proxyUrl: proxyUrl?.trim() || null,
         proxyFallbackToDirect,
+        // Custom configuration
+        preset: configMode === "preset" && selectedPreset ? selectedPreset : undefined,
+        customPayload: configMode === "custom" && customPayload ? customPayload : undefined,
+        successContains: successContains || undefined,
       });
 
       if (!response.ok) {
@@ -195,7 +231,7 @@ export function ApiTestButton({
       // 显示测试结果 toast
       const statusLabels = {
         green: t("testSuccess"),
-        yellow: "波动",
+        yellow: t("resultCard.status.yellow"),
         red: t("testFailed"),
       };
 
@@ -246,7 +282,7 @@ export function ApiTestButton({
         return (
           <>
             
-            波动
+            {t("resultCard.status.yellow")}
           
         );
       } else {
@@ -312,6 +348,92 @@ export function ApiTestButton({
         
{t("testModelDesc")}
+ {/* Request Configuration - Preset/Custom */} + {presets.length > 0 && ( +
+ +
+ + +
+ + {configMode === "preset" && ( +
+ +
+ {t("presetDesc")} +
+
+ )} + + {configMode === "custom" && ( +
+