From 3ac09cf9b9e5a714786b1246f713f6a5c149c323 Mon Sep 17 00:00:00 2001 From: Haobin Ding Date: Wed, 14 Jan 2026 03:56:44 +0800 Subject: [PATCH 01/10] feat(providers): add vendor endpoints with probes and failover --- drizzle/meta/0055_snapshot.json | 506 ++++++++++++- drizzle/meta/_journal.json | 4 +- messages/en/dashboard.json | 22 + messages/en/settings/providers/strings.json | 39 +- messages/ja/dashboard.json | 22 + messages/ja/settings/providers/strings.json | 39 +- messages/ru/dashboard.json | 22 + messages/ru/settings/providers/strings.json | 39 +- messages/zh-CN/dashboard.json | 22 + .../zh-CN/settings/providers/strings.json | 39 +- messages/zh-TW/dashboard.json | 22 + .../zh-TW/settings/providers/strings.json | 39 +- src/actions/provider-endpoints.ts | 584 +++++++++++++++ src/actions/providers.ts | 1 + .../_components/availability-view.tsx | 4 + .../_components/endpoint-probe-history.tsx | 301 ++++++++ .../_components/provider-manager.tsx | 60 +- .../_components/provider-vendor-view.tsx | 677 ++++++++++++++++++ src/app/api/actions/[...route]/route.ts | 229 ++++++ .../endpoints/probe-logs/route.ts | 46 ++ src/app/api/availability/endpoints/route.ts | 45 ++ src/app/v1/_lib/proxy/forwarder.ts | 147 +++- src/app/v1/_lib/proxy/provider-selector.ts | 41 ++ src/drizzle/schema.ts | 118 ++- src/lib/endpoint-circuit-breaker.ts | 196 +++++ .../provider-endpoints/endpoint-selector.ts | 63 ++ src/lib/provider-endpoints/probe.ts | 129 ++++ .../redis/endpoint-circuit-breaker-state.ts | 118 +++ .../vendor-type-circuit-breaker-state.ts | 121 ++++ src/lib/vendor-type-circuit-breaker.ts | 187 +++++ src/repository/_shared/transformers.ts | 1 + src/repository/index.ts | 13 + src/repository/provider-endpoints.ts | 468 ++++++++++++ src/repository/provider.ts | 37 + src/types/provider.ts | 47 ++ tests/api/api-openapi-spec.test.ts | 2 +- .../unit/lib/endpoint-circuit-breaker.test.ts | 124 ++++ .../endpoint-selector.test.ts | 156 ++++ .../unit/lib/provider-endpoints/probe.test.ts | 292 ++++++++ .../lib/vendor-type-circuit-breaker.test.ts | 161 +++++ tests/unit/user-dialogs.test.tsx | 13 + 41 files changed, 5142 insertions(+), 54 deletions(-) create mode 100644 src/actions/provider-endpoints.ts create mode 100644 src/app/[locale]/dashboard/availability/_components/endpoint-probe-history.tsx create mode 100644 src/app/[locale]/settings/providers/_components/provider-vendor-view.tsx create mode 100644 src/app/api/availability/endpoints/probe-logs/route.ts create mode 100644 src/app/api/availability/endpoints/route.ts create mode 100644 src/lib/endpoint-circuit-breaker.ts create mode 100644 src/lib/provider-endpoints/endpoint-selector.ts create mode 100644 src/lib/provider-endpoints/probe.ts create mode 100644 src/lib/redis/endpoint-circuit-breaker-state.ts create mode 100644 src/lib/redis/vendor-type-circuit-breaker-state.ts create mode 100644 src/lib/vendor-type-circuit-breaker.ts create mode 100644 src/repository/provider-endpoints.ts create mode 100644 tests/unit/lib/endpoint-circuit-breaker.test.ts create mode 100644 tests/unit/lib/provider-endpoints/endpoint-selector.test.ts create mode 100644 tests/unit/lib/provider-endpoints/probe.test.ts create mode 100644 tests/unit/lib/vendor-type-circuit-breaker.test.ts diff --git a/drizzle/meta/0055_snapshot.json b/drizzle/meta/0055_snapshot.json index 939e558ff..d7ff0f314 100644 --- a/drizzle/meta/0055_snapshot.json +++ b/drizzle/meta/0055_snapshot.json @@ -1,5 +1,5 @@ { - "id": "b40c930a-4001-4403-90b9-652a5878893c", + "id": "4ca06202-1c61-4490-a119-3d0c8ac1f841", "prevId": "36887729-08df-4af3-98fe-d4fa87c7c5c7", "version": "7", "dialect": "postgresql", @@ -637,22 +637,6 @@ "method": "btree", "with": {} }, - "idx_message_request_session_id_prefix": { - "name": "idx_message_request_session_id_prefix", - "columns": [ - { - "expression": "\"session_id\" varchar_pattern_ops", - "asc": true, - "isExpression": true, - "nulls": "last" - } - ], - "isUnique": false, - "where": "\"message_request\".\"deleted_at\" IS NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", - "concurrently": false, - "method": "btree", - "with": {} - }, "idx_message_request_session_seq": { "name": "idx_message_request_session_seq", "columns": [ @@ -1166,6 +1150,450 @@ "checkConstraints": {}, "isRLSEnabled": false }, + "public.provider_endpoint_probe_logs": { + "name": "provider_endpoint_probe_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "endpoint_id": { + "name": "endpoint_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'scheduled'" + }, + "ok": { + "name": "ok", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "latency_ms": { + "name": "latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "error_type": { + "name": "error_type", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_provider_endpoint_probe_logs_endpoint_created_at": { + "name": "idx_provider_endpoint_probe_logs_endpoint_created_at", + "columns": [ + { + "expression": "endpoint_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoint_probe_logs_created_at": { + "name": "idx_provider_endpoint_probe_logs_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "provider_endpoint_probe_logs_endpoint_id_provider_endpoints_id_fk": { + "name": "provider_endpoint_probe_logs_endpoint_id_provider_endpoints_id_fk", + "tableFrom": "provider_endpoint_probe_logs", + "tableTo": "provider_endpoints", + "columnsFrom": [ + "endpoint_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.provider_endpoints": { + "name": "provider_endpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "vendor_id": { + "name": "vendor_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "provider_type": { + "name": "provider_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'claude'" + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_probed_at": { + "name": "last_probed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_probe_ok": { + "name": "last_probe_ok", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "last_probe_status_code": { + "name": "last_probe_status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_probe_latency_ms": { + "name": "last_probe_latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_probe_error_type": { + "name": "last_probe_error_type", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "last_probe_error_message": { + "name": "last_probe_error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "uniq_provider_endpoints_vendor_type_url": { + "name": "uniq_provider_endpoints_vendor_type_url", + "columns": [ + { + "expression": "vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoints_vendor_type": { + "name": "idx_provider_endpoints_vendor_type", + "columns": [ + { + "expression": "vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"provider_endpoints\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoints_enabled": { + "name": "idx_provider_endpoints_enabled", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"provider_endpoints\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoints_created_at": { + "name": "idx_provider_endpoints_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoints_deleted_at": { + "name": "idx_provider_endpoints_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "provider_endpoints_vendor_id_provider_vendors_id_fk": { + "name": "provider_endpoints_vendor_id_provider_vendors_id_fk", + "tableFrom": "provider_endpoints", + "tableTo": "provider_vendors", + "columnsFrom": [ + "vendor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.provider_vendors": { + "name": "provider_vendors", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "website_domain": { + "name": "website_domain", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "website_url": { + "name": "website_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "favicon_url": { + "name": "favicon_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "uniq_provider_vendors_website_domain": { + "name": "uniq_provider_vendors_website_domain", + "columns": [ + { + "expression": "website_domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_vendors_created_at": { + "name": "idx_provider_vendors_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, "public.providers": { "name": "providers", "schema": "", @@ -1200,6 +1628,12 @@ "primaryKey": false, "notNull": true }, + "provider_vendor_id": { + "name": "provider_vendor_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, "is_enabled": { "name": "is_enabled", "type": "boolean", @@ -1578,9 +2012,45 @@ "concurrently": false, "method": "btree", "with": {} + }, + "idx_providers_vendor_type": { + "name": "idx_providers_vendor_type", + "columns": [ + { + "expression": "provider_vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"providers\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "providers_provider_vendor_id_provider_vendors_id_fk": { + "name": "providers_provider_vendor_id_provider_vendors_id_fk", + "tableFrom": "providers", + "tableTo": "provider_vendors", + "columnsFrom": [ + "provider_vendor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" } }, - "foreignKeys": {}, "compositePrimaryKeys": {}, "uniqueConstraints": {}, "policies": {}, diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index a4148b04e..228582b95 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -390,8 +390,8 @@ { "idx": 55, "version": "7", - "when": 1768443427816, - "tag": "0055_neat_stepford_cuckoos", + "when": 1768325370762, + "tag": "0055_sour_wallow", "breakpoints": true } ] diff --git a/messages/en/dashboard.json b/messages/en/dashboard.json index 34d5fc29b..2cf6b7930 100644 --- a/messages/en/dashboard.json +++ b/messages/en/dashboard.json @@ -1037,6 +1037,28 @@ "noData": "No Data", "noRequests": "No Requests" }, + "probeHistory": { + "title": "Endpoint Probe History", + "description": "View probe logs and manually trigger probes for specific endpoints", + "selectVendor": "Select Vendor", + "selectType": "Select Provider Type", + "selectEndpoint": "Select Endpoint", + "noEndpoints": "No endpoints found", + "probeNow": "Probe Now", + "probing": "Probing...", + "columns": { + "time": "Time", + "method": "Method", + "status": "Status", + "latency": "Latency", + "error": "Error" + }, + "success": "Success", + "manual": "Manual Probe", + "auto": "Auto Probe", + "probeSuccess": "Probe successful", + "probeFailed": "Probe failed" + }, "toast": { "refreshSuccess": "Availability data refreshed", "refreshFailed": "Refresh failed, please retry" diff --git a/messages/en/settings/providers/strings.json b/messages/en/settings/providers/strings.json index ce754df33..adec05d6d 100644 --- a/messages/en/settings/providers/strings.json +++ b/messages/en/settings/providers/strings.json @@ -43,5 +43,42 @@ "toggleSuccessDesc": "Provider \"{name}\" status updated", "updateFailed": "Failed to update provider", "viewKey": "View Complete API Key", - "viewKeyDesc": "Please keep it safe and don't share it with others" + "viewKeyDesc": "Please keep it safe and don't share it with others", + "viewMode": "View Mode", + "viewModeList": "List", + "viewModeVendor": "Vendor", + "endpoints": "Endpoints", + "manualProbe": "Probe", + "addEndpoint": "Add Endpoint", + "lastProbed": "Last Probed", + "latency": "Latency", + "status": "Status", + "vendorKeys": "API Keys", + "probeSuccess": "Probe Successful", + "probeFailed": "Probe Failed", + "manualCircuitOpen": "Manually Open Circuit", + "manualCircuitClose": "Close Circuit", + "circuitStatus": "Circuit Status", + "vendorTypeCircuit": "Vendor Type Circuit", + "vendorFallbackName": "Vendor #{id}", + "vendorTypeCircuitUpdated": "Vendor type circuit updated", + "noEndpoints": "No endpoints configured", + "noEndpointsDesc": "Add an endpoint to enable failover routing", + "columnUrl": "URL", + "columnActions": "Actions", + "confirmDeleteEndpoint": "Are you sure you want to delete this endpoint?", + "endpointAddSuccess": "Endpoint added successfully", + "endpointAddFailed": "Failed to add endpoint", + "endpointUpdateSuccess": "Endpoint updated successfully", + "endpointUpdateFailed": "Failed to update endpoint", + "endpointDeleteSuccess": "Endpoint deleted successfully", + "endpointDeleteFailed": "Failed to delete endpoint", + "probeOk": "OK", + "probeError": "Error", + "addEndpointDesc": "Add a new {providerType} endpoint for this vendor.", + "endpointUrlLabel": "URL", + "endpointUrlPlaceholder": "https://api.example.com/v1", + "endpointLabelOptional": "Label (optional)", + "endpointLabelPlaceholder": "Production endpoint", + "editEndpoint": "Edit Endpoint" } diff --git a/messages/ja/dashboard.json b/messages/ja/dashboard.json index c25c08880..52c6e2a09 100644 --- a/messages/ja/dashboard.json +++ b/messages/ja/dashboard.json @@ -1023,6 +1023,28 @@ "noData": "データなし", "noRequests": "リクエストなし" }, + "probeHistory": { + "title": "エンドポイントプローブ履歴", + "description": "プローブログを確認し、特定のエンドポイントを手動でプローブできます", + "selectVendor": "ベンダーを選択", + "selectType": "プロバイダー種別を選択", + "selectEndpoint": "エンドポイントを選択", + "noEndpoints": "エンドポイントが見つかりません", + "probeNow": "今すぐプローブ", + "probing": "プローブ中...", + "columns": { + "time": "時間", + "method": "方法", + "status": "ステータス", + "latency": "レイテンシ", + "error": "エラー" + }, + "success": "成功", + "manual": "手動プローブ", + "auto": "自動プローブ", + "probeSuccess": "プローブ成功", + "probeFailed": "プローブ失敗" + }, "toast": { "refreshSuccess": "可用性データを更新しました", "refreshFailed": "更新に失敗しました。再試行してください" diff --git a/messages/ja/settings/providers/strings.json b/messages/ja/settings/providers/strings.json index a7f870e86..07bb6f938 100644 --- a/messages/ja/settings/providers/strings.json +++ b/messages/ja/settings/providers/strings.json @@ -43,5 +43,42 @@ "toggleSuccessDesc": "プロバイダー「{name}」の状態を更新しました", "updateFailed": "プロバイダーの更新に失敗しました", "viewKey": "完全な API キーを表示", - "viewKeyDesc": "安全に保管し、他人に共有しないでください" + "viewKeyDesc": "安全に保管し、他人に共有しないでください", + "viewMode": "表示モード", + "viewModeList": "リスト", + "viewModeVendor": "ベンダー", + "endpoints": "エンドポイント", + "manualProbe": "テスト", + "addEndpoint": "エンドポイントを追加", + "lastProbed": "最終テスト", + "latency": "レイテンシ", + "status": "状態", + "vendorKeys": "API キー", + "probeSuccess": "テスト成功", + "probeFailed": "テスト失敗", + "manualCircuitOpen": "手動で回路を開く", + "manualCircuitClose": "回路を閉じる", + "circuitStatus": "回路状態", + "vendorTypeCircuit": "ベンダー種別回路", + "vendorFallbackName": "ベンダー #{id}", + "vendorTypeCircuitUpdated": "ベンダー種別サーキットの状態を更新しました", + "noEndpoints": "エンドポイントが設定されていません", + "noEndpointsDesc": "フェイルオーバーを有効にするにはエンドポイントを追加してください", + "columnUrl": "URL", + "columnActions": "操作", + "confirmDeleteEndpoint": "このエンドポイントを削除してもよろしいですか?", + "endpointAddSuccess": "エンドポイントを追加しました", + "endpointAddFailed": "エンドポイントの追加に失敗しました", + "endpointUpdateSuccess": "エンドポイントを更新しました", + "endpointUpdateFailed": "エンドポイントの更新に失敗しました", + "endpointDeleteSuccess": "エンドポイントを削除しました", + "endpointDeleteFailed": "エンドポイントの削除に失敗しました", + "probeOk": "OK", + "probeError": "エラー", + "addEndpointDesc": "このベンダーに {providerType} エンドポイントを追加します。", + "endpointUrlLabel": "URL", + "endpointUrlPlaceholder": "https://api.example.com/v1", + "endpointLabelOptional": "ラベル (任意)", + "endpointLabelPlaceholder": "本番環境", + "editEndpoint": "エンドポイントを編集" } diff --git a/messages/ru/dashboard.json b/messages/ru/dashboard.json index 7a1caeec4..9a93d4299 100644 --- a/messages/ru/dashboard.json +++ b/messages/ru/dashboard.json @@ -1025,6 +1025,28 @@ "noData": "Нет данных", "noRequests": "Нет запросов" }, + "probeHistory": { + "title": "История проверок эндпоинтов", + "description": "Просмотр журналов проверок и запуск ручной проверки для конкретного эндпоинта", + "selectVendor": "Выберите вендора", + "selectType": "Выберите тип провайдера", + "selectEndpoint": "Выберите эндпоинт", + "noEndpoints": "Эндпоинты не найдены", + "probeNow": "Проверить", + "probing": "Проверяем...", + "columns": { + "time": "Время", + "method": "Метод", + "status": "Статус", + "latency": "Задержка", + "error": "Ошибка" + }, + "success": "Успех", + "manual": "Ручная проверка", + "auto": "Автоматическая проверка", + "probeSuccess": "Проверка успешна", + "probeFailed": "Проверка не удалась" + }, "toast": { "refreshSuccess": "Данные о доступности обновлены", "refreshFailed": "Обновление не удалось, попробуйте снова" diff --git a/messages/ru/settings/providers/strings.json b/messages/ru/settings/providers/strings.json index 2ddcf758e..d94d567a5 100644 --- a/messages/ru/settings/providers/strings.json +++ b/messages/ru/settings/providers/strings.json @@ -43,5 +43,42 @@ "toggleSuccessDesc": "Статус поставщика \"{name}\" обновлен", "updateFailed": "Не удалось обновить поставщика", "viewKey": "Просмотреть полный API ключ", - "viewKeyDesc": "Пожалуйста, храните бережно и не раскрывайте другим" + "viewKeyDesc": "Пожалуйста, храните бережно и не раскрывайте другим", + "viewMode": "Режим просмотра", + "viewModeList": "Список", + "viewModeVendor": "Вендор", + "endpoints": "Эндпоинты", + "manualProbe": "Проверка", + "addEndpoint": "Добавить эндпоинт", + "lastProbed": "Последняя проверка", + "latency": "Задержка", + "status": "Статус", + "vendorKeys": "API ключи", + "probeSuccess": "Проверка успешна", + "probeFailed": "Проверка не удалась", + "manualCircuitOpen": "Открыть цепь вручную", + "manualCircuitClose": "Закрыть цепь", + "circuitStatus": "Состояние цепи", + "vendorTypeCircuit": "Цепь по типу провайдера", + "vendorFallbackName": "Вендор #{id}", + "vendorTypeCircuitUpdated": "Состояние цепи для типа провайдера обновлено", + "noEndpoints": "Эндпоинты не настроены", + "noEndpointsDesc": "Добавьте эндпоинт, чтобы включить маршрутизацию с отказоустойчивостью", + "columnUrl": "URL", + "columnActions": "Действия", + "confirmDeleteEndpoint": "Вы уверены, что хотите удалить этот эндпоинт?", + "endpointAddSuccess": "Эндпоинт добавлен", + "endpointAddFailed": "Не удалось добавить эндпоинт", + "endpointUpdateSuccess": "Эндпоинт обновлён", + "endpointUpdateFailed": "Не удалось обновить эндпоинт", + "endpointDeleteSuccess": "Эндпоинт удалён", + "endpointDeleteFailed": "Не удалось удалить эндпоинт", + "probeOk": "OK", + "probeError": "Ошибка", + "addEndpointDesc": "Добавьте новый эндпоинт {providerType} для этого вендора.", + "endpointUrlLabel": "URL", + "endpointUrlPlaceholder": "https://api.example.com/v1", + "endpointLabelOptional": "Метка (необязательно)", + "endpointLabelPlaceholder": "Продакшн", + "editEndpoint": "Редактировать эндпоинт" } diff --git a/messages/zh-CN/dashboard.json b/messages/zh-CN/dashboard.json index ac6723668..9b04c2c95 100644 --- a/messages/zh-CN/dashboard.json +++ b/messages/zh-CN/dashboard.json @@ -1142,6 +1142,28 @@ "noData": "无数据", "noRequests": "无请求" }, + "probeHistory": { + "title": "端点探测历史", + "description": "查看探测日志并手动触发特定端点的探测", + "selectVendor": "选择供应商", + "selectType": "选择供应商类型", + "selectEndpoint": "选择端点", + "noEndpoints": "未找到端点", + "probeNow": "立即探测", + "probing": "探测中...", + "columns": { + "time": "时间", + "method": "方法", + "status": "状态码", + "latency": "延迟", + "error": "错误信息" + }, + "success": "成功", + "manual": "手动探测", + "auto": "自动探测", + "probeSuccess": "探测成功", + "probeFailed": "探测失败" + }, "toast": { "refreshSuccess": "可用性数据已刷新", "refreshFailed": "刷新失败,请重试" diff --git a/messages/zh-CN/settings/providers/strings.json b/messages/zh-CN/settings/providers/strings.json index af279a30d..4957b8de1 100644 --- a/messages/zh-CN/settings/providers/strings.json +++ b/messages/zh-CN/settings/providers/strings.json @@ -43,5 +43,42 @@ "keyLoading": "加载中...", "resetCircuit": "熔断器已重置", "resetCircuitDesc": "供应商 \"{name}\" 的熔断状态已解除", - "resetCircuitFailed": "重置熔断器失败" + "resetCircuitFailed": "重置熔断器失败", + "viewMode": "视图模式", + "viewModeList": "列表", + "viewModeVendor": "服务商", + "endpoints": "服务端点", + "manualProbe": "测速", + "addEndpoint": "添加端点", + "lastProbed": "上次测速", + "latency": "延迟", + "status": "状态", + "vendorKeys": "API 密钥", + "probeSuccess": "测速成功", + "probeFailed": "测速失败", + "manualCircuitOpen": "手动开启熔断", + "manualCircuitClose": "关闭熔断", + "circuitStatus": "熔断状态", + "vendorTypeCircuit": "服务商类型熔断", + "vendorFallbackName": "服务商 #{id}", + "vendorTypeCircuitUpdated": "服务商类型熔断状态已更新", + "noEndpoints": "暂无端点配置", + "noEndpointsDesc": "添加端点以启用故障切换路由", + "columnUrl": "URL", + "columnActions": "操作", + "confirmDeleteEndpoint": "确定要删除此端点吗?", + "endpointAddSuccess": "端点添加成功", + "endpointAddFailed": "添加端点失败", + "endpointUpdateSuccess": "端点更新成功", + "endpointUpdateFailed": "更新端点失败", + "endpointDeleteSuccess": "端点删除成功", + "endpointDeleteFailed": "删除端点失败", + "probeOk": "正常", + "probeError": "异常", + "addEndpointDesc": "为该服务商添加一个新的 {providerType} 端点。", + "endpointUrlLabel": "URL", + "endpointUrlPlaceholder": "https://api.example.com/v1", + "endpointLabelOptional": "标签(可选)", + "endpointLabelPlaceholder": "生产环境", + "editEndpoint": "编辑端点" } diff --git a/messages/zh-TW/dashboard.json b/messages/zh-TW/dashboard.json index f451f5884..10ae12463 100644 --- a/messages/zh-TW/dashboard.json +++ b/messages/zh-TW/dashboard.json @@ -1023,6 +1023,28 @@ "noData": "無資料", "noRequests": "無請求" }, + "probeHistory": { + "title": "端點探測歷史", + "description": "查看探測日誌並手動觸發特定端點的探測", + "selectVendor": "選擇供應商", + "selectType": "選擇供應商類型", + "selectEndpoint": "選擇端點", + "noEndpoints": "未找到端點", + "probeNow": "立即探測", + "probing": "探測中...", + "columns": { + "time": "時間", + "method": "方法", + "status": "狀態碼", + "latency": "延遲", + "error": "錯誤訊息" + }, + "success": "成功", + "manual": "手動探測", + "auto": "自動探測", + "probeSuccess": "探測成功", + "probeFailed": "探測失敗" + }, "toast": { "refreshSuccess": "可用性資料已重新整理", "refreshFailed": "重新整理失敗,請重試" diff --git a/messages/zh-TW/settings/providers/strings.json b/messages/zh-TW/settings/providers/strings.json index 740562585..32d3acc21 100644 --- a/messages/zh-TW/settings/providers/strings.json +++ b/messages/zh-TW/settings/providers/strings.json @@ -43,5 +43,42 @@ "toggleSuccessDesc": "供應商「{name}」狀態已更新", "updateFailed": "更新供應商失敗", "viewKey": "查看完整的 API Key", - "viewKeyDesc": "請妥善保管,不要外泄給他人" + "viewKeyDesc": "請妥善保管,不要外泄給他人", + "viewMode": "檢視模式", + "viewModeList": "列表", + "viewModeVendor": "供應商", + "endpoints": "服務端點", + "manualProbe": "測速", + "addEndpoint": "新增端點", + "lastProbed": "上次測速", + "latency": "延遲", + "status": "狀態", + "vendorKeys": "API 金鑰", + "probeSuccess": "測速成功", + "probeFailed": "測速失敗", + "manualCircuitOpen": "手動開啟熔斷", + "manualCircuitClose": "關閉熔斷", + "circuitStatus": "熔斷狀態", + "vendorTypeCircuit": "供應商類型熔斷", + "vendorFallbackName": "供應商 #{id}", + "vendorTypeCircuitUpdated": "供應商類型熔斷狀態已更新", + "noEndpoints": "尚未設定端點", + "noEndpointsDesc": "新增端點以啟用故障轉移路由", + "columnUrl": "URL", + "columnActions": "操作", + "confirmDeleteEndpoint": "確定要刪除此端點嗎?", + "endpointAddSuccess": "端點新增成功", + "endpointAddFailed": "新增端點失敗", + "endpointUpdateSuccess": "端點更新成功", + "endpointUpdateFailed": "更新端點失敗", + "endpointDeleteSuccess": "端點刪除成功", + "endpointDeleteFailed": "刪除端點失敗", + "probeOk": "正常", + "probeError": "異常", + "addEndpointDesc": "為此供應商新增一個 {providerType} 端點。", + "endpointUrlLabel": "URL", + "endpointUrlPlaceholder": "https://api.example.com/v1", + "endpointLabelOptional": "標籤(選填)", + "endpointLabelPlaceholder": "生產環境", + "editEndpoint": "編輯端點" } diff --git a/src/actions/provider-endpoints.ts b/src/actions/provider-endpoints.ts new file mode 100644 index 000000000..669be4238 --- /dev/null +++ b/src/actions/provider-endpoints.ts @@ -0,0 +1,584 @@ +"use server"; + +import { z } from "zod"; +import { getSession } from "@/lib/auth"; +import { + getEndpointHealthInfo, + resetEndpointCircuit as resetEndpointCircuitState, +} from "@/lib/endpoint-circuit-breaker"; +import { logger } from "@/lib/logger"; +import { probeProviderEndpointAndRecord } from "@/lib/provider-endpoints/probe"; +import { ERROR_CODES } from "@/lib/utils/error-messages"; +import { extractZodErrorCode, formatZodError } from "@/lib/utils/zod-i18n"; +import { + getVendorTypeCircuitInfo as getVendorTypeCircuitInfoState, + resetVendorTypeCircuit as resetVendorTypeCircuitState, + setVendorTypeCircuitManualOpen as setVendorTypeCircuitManualOpenState, +} from "@/lib/vendor-type-circuit-breaker"; +import { + createProviderEndpoint, + findProviderEndpointById, + findProviderEndpointProbeLogs, + findProviderEndpointsByVendorAndType, + findProviderVendorById, + findProviderVendors, + softDeleteProviderEndpoint, + updateProviderEndpoint, +} from "@/repository"; +import type { + ProviderEndpoint, + ProviderEndpointProbeLog, + ProviderType, + ProviderVendor, +} from "@/types/provider"; +import type { ActionResult } from "./types"; + +const ProviderTypeSchema = z.enum([ + "claude", + "claude-auth", + "codex", + "gemini-cli", + "gemini", + "openai-compatible", +]); + +type ProviderTypeInput = z.infer; + +const VendorIdSchema = z.number().int().positive(); +const EndpointIdSchema = z.number().int().positive(); + +const GetProviderEndpointsSchema = z.object({ + vendorId: VendorIdSchema, + providerType: ProviderTypeSchema, +}); + +const CreateProviderEndpointSchema = z.object({ + vendorId: VendorIdSchema, + providerType: ProviderTypeSchema, + url: z.string().trim().url(ERROR_CODES.INVALID_URL), + label: z.string().trim().max(200).optional().nullable(), + sortOrder: z.number().int().min(0).optional(), + isEnabled: z.boolean().optional(), +}); + +const UpdateProviderEndpointSchema = z + .object({ + endpointId: EndpointIdSchema, + url: z.string().trim().url(ERROR_CODES.INVALID_URL).optional(), + label: z.string().trim().max(200).optional().nullable(), + sortOrder: z.number().int().min(0).optional(), + isEnabled: z.boolean().optional(), + }) + .refine( + (value) => + value.url !== undefined || + value.label !== undefined || + value.sortOrder !== undefined || + value.isEnabled !== undefined, + { + message: ERROR_CODES.EMPTY_UPDATE, + path: ["endpointId"], + } + ); + +const DeleteProviderEndpointSchema = z.object({ + endpointId: EndpointIdSchema, +}); + +const ProbeProviderEndpointSchema = z.object({ + endpointId: EndpointIdSchema, + timeoutMs: z.number().int().min(1000).max(120_000).optional(), +}); + +const GetProbeLogsSchema = z.object({ + endpointId: EndpointIdSchema, + limit: z.number().int().min(1).max(1000).optional(), + offset: z.number().int().min(0).optional(), +}); + +const VendorTypeSchema = z.object({ + vendorId: VendorIdSchema, + providerType: ProviderTypeSchema, +}); + +const SetVendorTypeManualOpenSchema = z.object({ + vendorId: VendorIdSchema, + providerType: ProviderTypeSchema, + manualOpen: z.boolean(), +}); + +async function getAdminSession() { + const session = await getSession(); + if (!session || session.user.role !== "admin") { + return null; + } + return session; +} + +export async function getProviderVendors(): Promise { + try { + const session = await getAdminSession(); + if (!session) { + return []; + } + + return await findProviderVendors(200, 0); + } catch (error) { + logger.error("getProviderVendors:error", error); + return []; + } +} + +export async function getProviderVendorById(vendorId: number): Promise { + try { + const session = await getAdminSession(); + if (!session) { + return null; + } + + return await findProviderVendorById(vendorId); + } catch (error) { + logger.error("getProviderVendorById:error", error); + return null; + } +} + +export async function getProviderEndpoints(input: { + vendorId: number; + providerType: ProviderType; +}): Promise { + try { + const session = await getAdminSession(); + if (!session) { + return []; + } + + const parsed = GetProviderEndpointsSchema.safeParse(input); + if (!parsed.success) { + logger.debug("getProviderEndpoints:invalid_input", { + error: parsed.error, + }); + return []; + } + + return await findProviderEndpointsByVendorAndType( + parsed.data.vendorId, + parsed.data.providerType as ProviderTypeInput + ); + } catch (error) { + logger.error("getProviderEndpoints:error", error); + return []; + } +} + +export async function addProviderEndpoint( + input: unknown +): Promise> { + try { + const session = await getAdminSession(); + if (!session) { + return { + ok: false, + error: "无权限执行此操作", + errorCode: ERROR_CODES.PERMISSION_DENIED, + }; + } + + const parsed = CreateProviderEndpointSchema.safeParse(input); + if (!parsed.success) { + return { + ok: false, + error: formatZodError(parsed.error), + errorCode: extractZodErrorCode(parsed.error), + }; + } + + const endpoint = await createProviderEndpoint({ + vendorId: parsed.data.vendorId, + providerType: parsed.data.providerType as ProviderTypeInput, + url: parsed.data.url, + label: parsed.data.label ?? null, + sortOrder: parsed.data.sortOrder ?? 0, + isEnabled: parsed.data.isEnabled ?? true, + }); + + return { ok: true, data: { endpoint } }; + } catch (error) { + logger.error("addProviderEndpoint:error", error); + const message = error instanceof Error ? error.message : "创建端点失败"; + return { ok: false, error: message, errorCode: ERROR_CODES.CREATE_FAILED }; + } +} + +export async function editProviderEndpoint( + input: unknown +): Promise> { + try { + const session = await getAdminSession(); + if (!session) { + return { + ok: false, + error: "无权限执行此操作", + errorCode: ERROR_CODES.PERMISSION_DENIED, + }; + } + + const parsed = UpdateProviderEndpointSchema.safeParse(input); + if (!parsed.success) { + return { + ok: false, + error: formatZodError(parsed.error), + errorCode: extractZodErrorCode(parsed.error), + }; + } + + const endpoint = await updateProviderEndpoint(parsed.data.endpointId, { + url: parsed.data.url, + label: parsed.data.label, + sortOrder: parsed.data.sortOrder, + isEnabled: parsed.data.isEnabled, + }); + + if (!endpoint) { + return { + ok: false, + error: "端点不存在", + errorCode: ERROR_CODES.NOT_FOUND, + }; + } + + return { ok: true, data: { endpoint } }; + } catch (error) { + logger.error("editProviderEndpoint:error", error); + const message = error instanceof Error ? error.message : "更新端点失败"; + return { ok: false, error: message, errorCode: ERROR_CODES.UPDATE_FAILED }; + } +} + +export async function removeProviderEndpoint(input: unknown): Promise { + try { + const session = await getAdminSession(); + if (!session) { + return { + ok: false, + error: "无权限执行此操作", + errorCode: ERROR_CODES.PERMISSION_DENIED, + }; + } + + const parsed = DeleteProviderEndpointSchema.safeParse(input); + if (!parsed.success) { + return { + ok: false, + error: formatZodError(parsed.error), + errorCode: extractZodErrorCode(parsed.error), + }; + } + + const ok = await softDeleteProviderEndpoint(parsed.data.endpointId); + if (!ok) { + return { + ok: false, + error: "端点不存在", + errorCode: ERROR_CODES.NOT_FOUND, + }; + } + + return { ok: true }; + } catch (error) { + logger.error("removeProviderEndpoint:error", error); + const message = error instanceof Error ? error.message : "删除端点失败"; + return { ok: false, error: message, errorCode: ERROR_CODES.DELETE_FAILED }; + } +} + +export async function probeProviderEndpoint(input: unknown): Promise< + ActionResult<{ + endpoint: ProviderEndpoint; + result: { + ok: boolean; + method: "HEAD" | "GET"; + statusCode: number | null; + latencyMs: number | null; + errorType: string | null; + errorMessage: string | null; + }; + }> +> { + try { + const session = await getAdminSession(); + if (!session) { + return { + ok: false, + error: "无权限执行此操作", + errorCode: ERROR_CODES.PERMISSION_DENIED, + }; + } + + const parsed = ProbeProviderEndpointSchema.safeParse(input); + if (!parsed.success) { + return { + ok: false, + error: formatZodError(parsed.error), + errorCode: extractZodErrorCode(parsed.error), + }; + } + + const endpoint = await findProviderEndpointById(parsed.data.endpointId); + if (!endpoint) { + return { + ok: false, + error: "端点不存在", + errorCode: ERROR_CODES.NOT_FOUND, + }; + } + + const result = await probeProviderEndpointAndRecord({ + endpointId: endpoint.id, + source: "manual", + timeoutMs: parsed.data.timeoutMs, + }); + + if (!result) { + return { + ok: false, + error: "端点不存在", + errorCode: ERROR_CODES.NOT_FOUND, + }; + } + + return { ok: true, data: { endpoint, result } }; + } catch (error) { + logger.error("probeProviderEndpoint:error", error); + const message = error instanceof Error ? error.message : "端点测速失败"; + return { ok: false, error: message, errorCode: ERROR_CODES.OPERATION_FAILED }; + } +} + +export async function getProviderEndpointProbeLogs( + input: unknown +): Promise> { + try { + const session = await getAdminSession(); + if (!session) { + return { + ok: false, + error: "无权限执行此操作", + errorCode: ERROR_CODES.PERMISSION_DENIED, + }; + } + + const parsed = GetProbeLogsSchema.safeParse(input); + if (!parsed.success) { + return { + ok: false, + error: formatZodError(parsed.error), + errorCode: extractZodErrorCode(parsed.error), + }; + } + + const logs = await findProviderEndpointProbeLogs( + parsed.data.endpointId, + parsed.data.limit ?? 200, + parsed.data.offset ?? 0 + ); + + return { ok: true, data: { endpointId: parsed.data.endpointId, logs } }; + } catch (error) { + logger.error("getProviderEndpointProbeLogs:error", error); + const message = error instanceof Error ? error.message : "获取端点测活历史失败"; + return { ok: false, error: message, errorCode: ERROR_CODES.OPERATION_FAILED }; + } +} + +export async function getEndpointCircuitInfo(input: unknown): Promise< + ActionResult<{ + endpointId: number; + health: { + failureCount: number; + lastFailureTime: number | null; + circuitState: "closed" | "open" | "half-open"; + circuitOpenUntil: number | null; + halfOpenSuccessCount: number; + }; + config: { + failureThreshold: number; + openDuration: number; + halfOpenSuccessThreshold: number; + }; + }> +> { + try { + const session = await getAdminSession(); + if (!session) { + return { + ok: false, + error: "无权限执行此操作", + errorCode: ERROR_CODES.PERMISSION_DENIED, + }; + } + + const parsed = DeleteProviderEndpointSchema.safeParse(input); + if (!parsed.success) { + return { + ok: false, + error: formatZodError(parsed.error), + errorCode: extractZodErrorCode(parsed.error), + }; + } + + const { health, config } = await getEndpointHealthInfo(parsed.data.endpointId); + + return { + ok: true, + data: { + endpointId: parsed.data.endpointId, + health, + config, + }, + }; + } catch (error) { + logger.error("getEndpointCircuitInfo:error", error); + const message = error instanceof Error ? error.message : "获取端点熔断状态失败"; + return { ok: false, error: message, errorCode: ERROR_CODES.OPERATION_FAILED }; + } +} + +export async function resetEndpointCircuit(input: unknown): Promise { + try { + const session = await getAdminSession(); + if (!session) { + return { + ok: false, + error: "无权限执行此操作", + errorCode: ERROR_CODES.PERMISSION_DENIED, + }; + } + + const parsed = DeleteProviderEndpointSchema.safeParse(input); + if (!parsed.success) { + return { + ok: false, + error: formatZodError(parsed.error), + errorCode: extractZodErrorCode(parsed.error), + }; + } + + await resetEndpointCircuitState(parsed.data.endpointId); + + return { ok: true }; + } catch (error) { + logger.error("resetEndpointCircuit:error", error); + const message = error instanceof Error ? error.message : "重置端点熔断失败"; + return { ok: false, error: message, errorCode: ERROR_CODES.OPERATION_FAILED }; + } +} + +export async function getVendorTypeCircuitInfo(input: unknown): Promise< + ActionResult<{ + vendorId: number; + providerType: ProviderType; + circuitState: "closed" | "open"; + circuitOpenUntil: number | null; + lastFailureTime: number | null; + manualOpen: boolean; + }> +> { + try { + const session = await getAdminSession(); + if (!session) { + return { + ok: false, + error: "无权限执行此操作", + errorCode: ERROR_CODES.PERMISSION_DENIED, + }; + } + + const parsed = VendorTypeSchema.safeParse(input); + if (!parsed.success) { + return { + ok: false, + error: formatZodError(parsed.error), + errorCode: extractZodErrorCode(parsed.error), + }; + } + + const info = await getVendorTypeCircuitInfoState( + parsed.data.vendorId, + parsed.data.providerType as ProviderTypeInput + ); + + return { ok: true, data: info }; + } catch (error) { + logger.error("getVendorTypeCircuitInfo:error", error); + const message = error instanceof Error ? error.message : "获取临时熔断状态失败"; + return { ok: false, error: message, errorCode: ERROR_CODES.OPERATION_FAILED }; + } +} + +export async function setVendorTypeCircuitManualOpen(input: unknown): Promise { + try { + const session = await getAdminSession(); + if (!session) { + return { + ok: false, + error: "无权限执行此操作", + errorCode: ERROR_CODES.PERMISSION_DENIED, + }; + } + + const parsed = SetVendorTypeManualOpenSchema.safeParse(input); + if (!parsed.success) { + return { + ok: false, + error: formatZodError(parsed.error), + errorCode: extractZodErrorCode(parsed.error), + }; + } + + await setVendorTypeCircuitManualOpenState( + parsed.data.vendorId, + parsed.data.providerType as ProviderTypeInput, + parsed.data.manualOpen + ); + + return { ok: true }; + } catch (error) { + logger.error("setVendorTypeCircuitManualOpen:error", error); + const message = error instanceof Error ? error.message : "设置临时熔断失败"; + return { ok: false, error: message, errorCode: ERROR_CODES.OPERATION_FAILED }; + } +} + +export async function resetVendorTypeCircuit(input: unknown): Promise { + try { + const session = await getAdminSession(); + if (!session) { + return { + ok: false, + error: "无权限执行此操作", + errorCode: ERROR_CODES.PERMISSION_DENIED, + }; + } + + const parsed = VendorTypeSchema.safeParse(input); + if (!parsed.success) { + return { + ok: false, + error: formatZodError(parsed.error), + errorCode: extractZodErrorCode(parsed.error), + }; + } + + await resetVendorTypeCircuitState( + parsed.data.vendorId, + parsed.data.providerType as ProviderTypeInput + ); + + return { ok: true }; + } catch (error) { + logger.error("resetVendorTypeCircuit:error", error); + const message = error instanceof Error ? error.message : "重置临时熔断失败"; + return { ok: false, error: message, errorCode: ERROR_CODES.OPERATION_FAILED }; + } +} diff --git a/src/actions/providers.ts b/src/actions/providers.ts index 3a9d73827..d1df56412 100644 --- a/src/actions/providers.ts +++ b/src/actions/providers.ts @@ -236,6 +236,7 @@ export async function getProviders(): Promise { costMultiplier: provider.costMultiplier, groupTag: provider.groupTag, providerType: provider.providerType, + providerVendorId: provider.providerVendorId, preserveClientIp: provider.preserveClientIp, modelRedirects: provider.modelRedirects, allowedModels: provider.allowedModels, diff --git a/src/app/[locale]/dashboard/availability/_components/availability-view.tsx b/src/app/[locale]/dashboard/availability/_components/availability-view.tsx index a468801e2..5aad4c0e5 100644 --- a/src/app/[locale]/dashboard/availability/_components/availability-view.tsx +++ b/src/app/[locale]/dashboard/availability/_components/availability-view.tsx @@ -21,6 +21,7 @@ import type { TimeBucketMetrics, } from "@/lib/availability"; import { cn } from "@/lib/utils"; +import { EndpointProbeHistory } from "./endpoint-probe-history"; type TimeRangeOption = "15min" | "1h" | "6h" | "24h" | "7d"; type SortOption = "availability" | "name" | "requests"; @@ -524,6 +525,9 @@ export function AvailabilityView() { + + {/* Endpoint Probe History */} + ); diff --git a/src/app/[locale]/dashboard/availability/_components/endpoint-probe-history.tsx b/src/app/[locale]/dashboard/availability/_components/endpoint-probe-history.tsx new file mode 100644 index 000000000..26ad54dc3 --- /dev/null +++ b/src/app/[locale]/dashboard/availability/_components/endpoint-probe-history.tsx @@ -0,0 +1,301 @@ +"use client"; + +import { Activity, CheckCircle2, Play, RefreshCw, XCircle } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { useCallback, useEffect, useState } from "react"; +import { toast } from "sonner"; +import { getProviderVendors, probeProviderEndpoint } from "@/actions/provider-endpoints"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { cn } from "@/lib/utils"; +import { getErrorMessage } from "@/lib/utils/error-messages"; +import type { + ProviderEndpoint, + ProviderEndpointProbeLog, + ProviderType, + ProviderVendor, +} from "@/types/provider"; + +const PROVIDER_TYPES: ProviderType[] = [ + "claude", + "claude-auth", + "codex", + "gemini-cli", + "gemini", + "openai-compatible", +]; + +export function EndpointProbeHistory() { + const t = useTranslations("dashboard.availability"); + const tErrors = useTranslations("errors"); + + const [vendors, setVendors] = useState([]); + const [selectedVendorId, setSelectedVendorId] = useState(""); + const [selectedType, setSelectedType] = useState(""); + + const [endpoints, setEndpoints] = useState([]); + const [selectedEndpointId, setSelectedEndpointId] = useState(""); + const [loadingEndpoints, setLoadingEndpoints] = useState(false); + + const [logs, setLogs] = useState([]); + const [loadingLogs, setLoadingLogs] = useState(false); + + const [probing, setProbing] = useState(false); + + useEffect(() => { + getProviderVendors().then(setVendors).catch(console.error); + }, []); + + useEffect(() => { + if (!selectedVendorId || !selectedType) { + setEndpoints([]); + setSelectedEndpointId(""); + return; + } + + setLoadingEndpoints(true); + const params = new URLSearchParams({ + vendorId: selectedVendorId, + providerType: selectedType, + }); + + fetch(`/api/availability/endpoints?${params.toString()}`) + .then((res) => res.json()) + .then((data) => { + if (data.endpoints) { + setEndpoints(data.endpoints); + setSelectedEndpointId((prev) => + prev && !data.endpoints.some((e: ProviderEndpoint) => e.id.toString() === prev) + ? "" + : prev + ); + } + }) + .catch(console.error) + .finally(() => setLoadingEndpoints(false)); + }, [selectedVendorId, selectedType]); + + const fetchLogs = useCallback(async () => { + if (!selectedEndpointId) { + setLogs([]); + return; + } + + setLoadingLogs(true); + try { + const params = new URLSearchParams({ + endpointId: selectedEndpointId, + limit: "50", + }); + const res = await fetch(`/api/availability/endpoints/probe-logs?${params.toString()}`); + const data = await res.json(); + if (data.logs) { + setLogs(data.logs); + } + } catch (error) { + console.error("Failed to fetch logs", error); + } finally { + setLoadingLogs(false); + } + }, [selectedEndpointId]); + + useEffect(() => { + fetchLogs(); + }, [fetchLogs]); + + const handleProbe = async () => { + if (!selectedEndpointId) return; + + setProbing(true); + try { + const result = await probeProviderEndpoint({ + endpointId: Number.parseInt(selectedEndpointId, 10), + timeoutMs: 10000, + }); + + if (result.ok) { + toast.success(t("probeHistory.probeSuccess")); + fetchLogs(); + } else { + toast.error( + result.errorCode + ? getErrorMessage(tErrors, result.errorCode) + : t("probeHistory.probeFailed") + ); + } + } catch (_error) { + toast.error(t("probeHistory.probeFailed")); + } finally { + setProbing(false); + } + }; + + return ( + + + + + {t("probeHistory.title")} + + {t("probeHistory.description")} + + +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + + +
+
+ +
+ + + + {t("probeHistory.columns.time")} + {t("probeHistory.columns.status")} + {t("probeHistory.columns.latency")} + {t("probeHistory.columns.error")} + + + + {logs.length === 0 ? ( + + + {loadingLogs ? t("states.loading") : t("states.noData")} + + + ) : ( + logs.map((log) => ( + + + {new Date(log.createdAt).toLocaleString()} +
+ {t(`probeHistory.${log.source === "manual" ? "manual" : "auto"}`)} +
+
+ + {log.ok ? ( + + + {log.statusCode || 200} + + ) : ( + + + {log.statusCode ?? t("status.unknown")} + + )} + + + {log.latencyMs ? `${log.latencyMs}ms` : "-"} + + + {log.errorMessage || "-"} + +
+ )) + )} +
+
+
+
+
+ ); +} diff --git a/src/app/[locale]/settings/providers/_components/provider-manager.tsx b/src/app/[locale]/settings/providers/_components/provider-manager.tsx index 79c0718f1..97a5e149c 100644 --- a/src/app/[locale]/settings/providers/_components/provider-manager.tsx +++ b/src/app/[locale]/settings/providers/_components/provider-manager.tsx @@ -1,5 +1,5 @@ "use client"; -import { AlertTriangle, Loader2, Search } from "lucide-react"; +import { AlertTriangle, LayoutGrid, LayoutList, Loader2, Search } from "lucide-react"; import { useTranslations } from "next-intl"; import { type ReactNode, useEffect, useMemo, useState } from "react"; import { Button } from "@/components/ui/button"; @@ -21,6 +21,7 @@ import type { User } from "@/types/user"; import { ProviderList } from "./provider-list"; import { ProviderSortDropdown, type SortKey } from "./provider-sort-dropdown"; import { ProviderTypeFilter } from "./provider-type-filter"; +import { ProviderVendorView } from "./provider-vendor-view"; interface ProviderManagerProps { providers: ProviderDisplay[]; @@ -57,11 +58,13 @@ export function ProviderManager({ addDialogSlot, }: ProviderManagerProps) { const t = useTranslations("settings.providers.search"); + const tStrings = useTranslations("settings.providers.strings"); const tFilter = useTranslations("settings.providers.filter"); const tCommon = useTranslations("settings.common"); const [typeFilter, setTypeFilter] = useState("all"); const [sortBy, setSortBy] = useState("priority"); const [searchTerm, setSearchTerm] = useState(""); + const [viewMode, setViewMode] = useState<"list" | "vendor">("list"); const debouncedSearchTerm = useDebounce(searchTerm, 500); // Status and group filters @@ -202,6 +205,30 @@ export function ProviderManager({ {/* 筛选条件 */}
+ {/* View Mode Toggle */} +
+ + +
+ {/* Status filter */} @@ -317,15 +344,28 @@ export function ProviderManager({ ) : (
{refreshing ? : null} - + + {viewMode === "list" ? ( + + ) : ( + + )}
)}
diff --git a/src/app/[locale]/settings/providers/_components/provider-vendor-view.tsx b/src/app/[locale]/settings/providers/_components/provider-vendor-view.tsx new file mode 100644 index 000000000..b8ada6c2a --- /dev/null +++ b/src/app/[locale]/settings/providers/_components/provider-vendor-view.tsx @@ -0,0 +1,677 @@ +"use client"; + +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { formatDistanceToNow } from "date-fns"; +import { + Activity, + Edit2, + ExternalLink, + Loader2, + MoreHorizontal, + Play, + Plus, + Trash2, +} from "lucide-react"; +import { useTranslations } from "next-intl"; +import { useMemo, useState } from "react"; +import { toast } from "sonner"; +import { + addProviderEndpoint, + editProviderEndpoint, + getProviderEndpoints, + getProviderVendors, + getVendorTypeCircuitInfo, + probeProviderEndpoint, + removeProviderEndpoint, + setVendorTypeCircuitManualOpen, +} from "@/actions/provider-endpoints"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; + +import type { CurrencyCode } from "@/lib/utils/currency"; +import type { + ProviderDisplay, + ProviderEndpoint, + ProviderType, + ProviderVendor, +} from "@/types/provider"; +import type { User } from "@/types/user"; +import { ProviderRichListItem } from "./provider-rich-list-item"; + +interface ProviderVendorViewProps { + providers: ProviderDisplay[]; + currentUser?: User; + enableMultiProviderTypes: boolean; + healthStatus: Record; + statistics: Record; + statisticsLoading: boolean; + currencyCode: CurrencyCode; +} + +export function ProviderVendorView({ + providers, + currentUser, + enableMultiProviderTypes, + healthStatus, + statistics, + statisticsLoading, + currencyCode, +}: ProviderVendorViewProps) { + const { data: vendors = [], isLoading: isVendorsLoading } = useQuery({ + queryKey: ["provider-vendors"], + queryFn: async () => await getProviderVendors(), + staleTime: 60000, + }); + + const providersByVendor = useMemo(() => { + const grouped: Record = {}; + providers.forEach((p) => { + const vendorId = p.providerVendorId || 0; + if (!grouped[vendorId]) { + grouped[vendorId] = []; + } + grouped[vendorId].push(p); + }); + return grouped; + }, [providers]); + + const allVendorIds = useMemo(() => { + const ids = new Set(vendors.map((v) => v.id)); + Object.keys(providersByVendor).forEach((id) => ids.add(Number(id))); + return Array.from(ids).sort((a, b) => a - b); + }, [vendors, providersByVendor]); + + if (isVendorsLoading) { + return ( +
+ +
+ ); + } + + return ( +
+ {allVendorIds.map((vendorId) => { + const vendor = vendors.find((v) => v.id === vendorId); + const vendorProviders = providersByVendor[vendorId] || []; + + if (!vendor && vendorProviders.length === 0) return null; + + return ( + + ); + })} +
+ ); +} + +function VendorCard({ + vendor, + vendorId, + providers, + currentUser, + enableMultiProviderTypes, + healthStatus, + statistics, + statisticsLoading, + currencyCode, +}: { + vendor?: ProviderVendor; + vendorId: number; + providers: ProviderDisplay[]; + currentUser?: User; + enableMultiProviderTypes: boolean; + healthStatus: any; + statistics: any; + statisticsLoading: boolean; + currencyCode: CurrencyCode; +}) { + const t = useTranslations("settings.providers.strings"); + + const displayName = + vendor?.displayName || vendor?.websiteDomain || t("vendorFallbackName", { id: vendorId }); + const websiteUrl = vendor?.websiteUrl; + const faviconUrl = vendor?.faviconUrl; + + return ( + + +
+
+ + + {displayName.substring(0, 2).toUpperCase()} + +
+ + {displayName} + {websiteUrl && ( + + + + )} + + + {providers.length} {t("vendorKeys")} + +
+
+
+
+ + + {providers.length > 0 && ( +
+
+ {t("vendorKeys")} +
+
+ {providers.map((provider) => ( +
+ +
+ ))} +
+
+ )} + + {enableMultiProviderTypes && } +
+
+ ); +} + +function VendorEndpointsSection({ vendorId }: { vendorId: number }) { + const t = useTranslations("settings.providers.strings"); + const [activeType, setActiveType] = useState("claude"); + + const providerTypes: ProviderType[] = ["claude", "codex", "gemini", "openai-compatible"]; + + return ( +
+
+ {t("endpoints")} +
+ +
+
+
+
+ {providerTypes.map((type) => ( + + ))} +
+ + +
+ + + + +
+
+
+ ); +} + +function VendorTypeCircuitControl({ + vendorId, + providerType, +}: { + vendorId: number; + providerType: ProviderType; +}) { + const t = useTranslations("settings.providers.strings"); + const queryClient = useQueryClient(); + + const { data: circuitInfo, isLoading } = useQuery({ + queryKey: ["vendor-circuit", vendorId, providerType], + queryFn: async () => { + const res = await getVendorTypeCircuitInfo({ vendorId, providerType }); + if (!res.ok) throw new Error(res.error); + return res.data; + }, + }); + + const manualOpenMutation = useMutation({ + mutationFn: async (manualOpen: boolean) => { + const res = await setVendorTypeCircuitManualOpen({ vendorId, providerType, manualOpen }); + if (!res.ok) throw new Error(res.error); + return res.data; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["vendor-circuit", vendorId, providerType] }); + toast.success(t("vendorTypeCircuitUpdated")); + }, + onError: () => { + toast.error(t("toggleFailed")); + }, + }); + + if (isLoading || !circuitInfo) return null; + + return ( +
+
+ + {t("vendorTypeCircuit")} + {circuitInfo.circuitState === "open" && ( + + {t("circuitBroken")} + + )} +
+ +
+ + manualOpenMutation.mutate(checked)} + /> +
+
+ ); +} + +function EndpointsTable({ + vendorId, + providerType, +}: { + vendorId: number; + providerType: ProviderType; +}) { + const t = useTranslations("settings.providers.strings"); + + const { data: endpoints = [], isLoading } = useQuery({ + queryKey: ["provider-endpoints", vendorId, providerType], + queryFn: async () => { + const endpoints = await getProviderEndpoints({ vendorId, providerType }); + return endpoints; + }, + }); + + if (isLoading) { + return
{t("keyLoading")}
; + } + + if (endpoints.length === 0) { + return ( +
+

{t("noEndpoints")}

+

{t("noEndpointsDesc")}

+
+ ); + } + + return ( +
+ + + + {t("columnUrl")} + {t("status")} + {t("lastProbed")} + {t("columnActions")} + + + + {endpoints.map((endpoint) => ( + + ))} + +
+
+ ); +} + +function EndpointRow({ endpoint }: { endpoint: ProviderEndpoint }) { + const t = useTranslations("settings.providers.strings"); + const tCommon = useTranslations("settings.common"); + const queryClient = useQueryClient(); + const [isProbing, setIsProbing] = useState(false); + + const probeMutation = useMutation({ + mutationFn: async () => { + const res = await probeProviderEndpoint({ endpointId: endpoint.id }); + if (!res.ok) throw new Error(res.error); + return res.data; + }, + onMutate: () => setIsProbing(true), + onSettled: () => setIsProbing(false), + onSuccess: (data) => { + queryClient.invalidateQueries({ queryKey: ["provider-endpoints"] }); + if (data?.result.ok) { + toast.success(t("probeSuccess")); + } else { + toast.error( + data?.result.errorMessage + ? `${t("probeFailed")}: ${data.result.errorMessage}` + : t("probeFailed") + ); + } + }, + onError: () => { + toast.error(t("probeFailed")); + }, + }); + + const deleteMutation = useMutation({ + mutationFn: async () => { + const res = await removeProviderEndpoint({ endpointId: endpoint.id }); + if (!res.ok) throw new Error(res.error); + return res.data; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["provider-endpoints"] }); + toast.success(t("endpointDeleteSuccess")); + }, + onError: () => { + toast.error(t("endpointDeleteFailed")); + }, + }); + + return ( + + + {endpoint.url} + {endpoint.label &&
{endpoint.label}
} +
+ +
+ {endpoint.isEnabled ? ( + + {t("enabledStatus")} + + ) : ( + {t("disabledStatus")} + )} +
+
+ +
+ {endpoint.lastProbedAt ? ( +
+ + {endpoint.lastProbeOk ? t("probeOk") : t("probeError")} + {endpoint.lastProbeLatencyMs && ` (${endpoint.lastProbeLatencyMs}ms)`} + + + {formatDistanceToNow(new Date(endpoint.lastProbedAt), { addSuffix: true })} + +
+ ) : ( + - + )} +
+
+ +
+ + + + + + + + + + { + if (confirm(t("confirmDeleteEndpoint"))) { + deleteMutation.mutate(); + } + }} + > + + {tCommon("delete")} + + + +
+
+
+ ); +} + +function AddEndpointButton({ + vendorId, + providerType, +}: { + vendorId: number; + providerType: ProviderType; +}) { + const t = useTranslations("settings.providers.strings"); + const tCommon = useTranslations("settings.common"); + const [open, setOpen] = useState(false); + const queryClient = useQueryClient(); + const [isSubmitting, setIsSubmitting] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setIsSubmitting(true); + const formData = new FormData(e.currentTarget); + const url = formData.get("url") as string; + const label = formData.get("label") as string; + + try { + const res = await addProviderEndpoint({ + vendorId, + providerType, + url, + label: label || null, + sortOrder: 0, + isEnabled: true, + }); + + if (res.ok) { + toast.success(t("endpointAddSuccess")); + setOpen(false); + queryClient.invalidateQueries({ queryKey: ["provider-endpoints", vendorId, providerType] }); + } else { + toast.error(res.error || t("endpointAddFailed")); + } + } catch (_err) { + toast.error(t("endpointAddFailed")); + } finally { + setIsSubmitting(false); + } + }; + + return ( + + + + + + + {t("addEndpoint")} + {t("addEndpointDesc", { providerType })} + +
+
+ + +
+
+ + +
+ + + + +
+
+
+ ); +} + +function EditEndpointDialog({ endpoint }: { endpoint: ProviderEndpoint }) { + const t = useTranslations("settings.providers.strings"); + const tCommon = useTranslations("settings.common"); + const [open, setOpen] = useState(false); + const queryClient = useQueryClient(); + const [isSubmitting, setIsSubmitting] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setIsSubmitting(true); + const formData = new FormData(e.currentTarget); + const url = formData.get("url") as string; + const label = formData.get("label") as string; + const isEnabled = formData.get("isEnabled") === "on"; + + try { + const res = await editProviderEndpoint({ + endpointId: endpoint.id, + url, + label: label || null, + isEnabled, + }); + + if (res.ok) { + toast.success(t("endpointUpdateSuccess")); + setOpen(false); + queryClient.invalidateQueries({ queryKey: ["provider-endpoints"] }); + } else { + toast.error(res.error || t("endpointUpdateFailed")); + } + } catch (_err) { + toast.error(t("endpointUpdateFailed")); + } finally { + setIsSubmitting(false); + } + }; + + return ( + + + + + + + {t("editEndpoint")} + +
+
+ + +
+
+ + +
+
+ + +
+ + + + +
+
+
+ ); +} diff --git a/src/app/api/actions/[...route]/route.ts b/src/app/api/actions/[...route]/route.ts index 2eabe3c42..57fb72e53 100644 --- a/src/app/api/actions/[...route]/route.ts +++ b/src/app/api/actions/[...route]/route.ts @@ -24,6 +24,7 @@ import * as myUsageActions from "@/actions/my-usage"; import * as notificationBindingActions from "@/actions/notification-bindings"; import * as notificationActions from "@/actions/notifications"; import * as overviewActions from "@/actions/overview"; +import * as providerEndpointActions from "@/actions/provider-endpoints"; import * as providerActions from "@/actions/providers"; import * as sensitiveWordActions from "@/actions/sensitive-words"; import * as statisticsActions from "@/actions/statistics"; @@ -340,6 +341,15 @@ app.openapi(getKeyLimitUsageRoute, getKeyLimitUsageHandler); // ==================== 供应商管理 ==================== +const ProviderTypeSchema = z.enum([ + "claude", + "claude-auth", + "codex", + "gemini-cli", + "gemini", + "openai-compatible", +]); + const { route: getProvidersRoute, handler: getProvidersHandler } = createActionRoute( "providers", "getProviders", @@ -374,6 +384,225 @@ const { route: getProvidersRoute, handler: getProvidersHandler } = createActionR ); app.openapi(getProvidersRoute, getProvidersHandler); +const { route: getProviderVendorsRoute, handler: getProviderVendorsHandler } = createActionRoute( + "providers", + "getProviderVendors", + providerEndpointActions.getProviderVendors, + { + requestSchema: z.object({}).describe("无需请求参数"), + description: "获取供应商聚合实体列表(按官网域名归一) (管理员)", + summary: "获取供应商 Vendor 列表", + tags: ["供应商管理"], + requiredRole: "admin", + } +); +app.openapi(getProviderVendorsRoute, getProviderVendorsHandler); + +const { route: getProviderEndpointsRoute, handler: getProviderEndpointsHandler } = + createActionRoute( + "providers", + "getProviderEndpoints", + providerEndpointActions.getProviderEndpoints, + { + requestSchema: z.object({ + vendorId: z.number().int().positive(), + providerType: ProviderTypeSchema, + }), + description: "获取指定 vendor+type 下的端点列表 (管理员)", + summary: "获取端点列表", + tags: ["供应商管理"], + requiredRole: "admin", + } + ); +app.openapi(getProviderEndpointsRoute, getProviderEndpointsHandler); + +const { route: addProviderEndpointRoute, handler: addProviderEndpointHandler } = createActionRoute( + "providers", + "addProviderEndpoint", + providerEndpointActions.addProviderEndpoint, + { + requestSchema: z.object({ + vendorId: z.number().int().positive(), + providerType: ProviderTypeSchema, + url: z.string().trim().url(), + label: z.string().trim().max(200).optional().nullable(), + sortOrder: z.number().int().min(0).optional(), + isEnabled: z.boolean().optional(), + }), + description: "创建端点(vendor+type 维度) (管理员)", + summary: "创建端点", + tags: ["供应商管理"], + requiredRole: "admin", + } +); +app.openapi(addProviderEndpointRoute, addProviderEndpointHandler); + +const { route: editProviderEndpointRoute, handler: editProviderEndpointHandler } = + createActionRoute( + "providers", + "editProviderEndpoint", + providerEndpointActions.editProviderEndpoint, + { + requestSchema: z.object({ + endpointId: z.number().int().positive(), + url: z.string().trim().url().optional(), + label: z.string().trim().max(200).optional().nullable(), + sortOrder: z.number().int().min(0).optional(), + isEnabled: z.boolean().optional(), + }), + description: "更新端点 (管理员)", + summary: "更新端点", + tags: ["供应商管理"], + requiredRole: "admin", + } + ); +app.openapi(editProviderEndpointRoute, editProviderEndpointHandler); + +const { route: removeProviderEndpointRoute, handler: removeProviderEndpointHandler } = + createActionRoute( + "providers", + "removeProviderEndpoint", + providerEndpointActions.removeProviderEndpoint, + { + requestSchema: z.object({ + endpointId: z.number().int().positive(), + }), + description: "删除端点(软删除) (管理员)", + summary: "删除端点", + tags: ["供应商管理"], + requiredRole: "admin", + } + ); +app.openapi(removeProviderEndpointRoute, removeProviderEndpointHandler); + +const { route: probeProviderEndpointRoute, handler: probeProviderEndpointHandler } = + createActionRoute( + "providers", + "probeProviderEndpoint", + providerEndpointActions.probeProviderEndpoint, + { + requestSchema: z.object({ + endpointId: z.number().int().positive(), + timeoutMs: z.number().int().min(1000).max(120_000).optional(), + }), + description: "手动测速并写入测活历史 (管理员)", + summary: "端点手动测速", + tags: ["供应商管理"], + requiredRole: "admin", + } + ); +app.openapi(probeProviderEndpointRoute, probeProviderEndpointHandler); + +const { route: getProviderEndpointProbeLogsRoute, handler: getProviderEndpointProbeLogsHandler } = + createActionRoute( + "providers", + "getProviderEndpointProbeLogs", + providerEndpointActions.getProviderEndpointProbeLogs, + { + requestSchema: z.object({ + endpointId: z.number().int().positive(), + limit: z.number().int().min(1).max(1000).optional(), + offset: z.number().int().min(0).optional(), + }), + description: "读取端点测活历史 (管理员)", + summary: "读取测活历史", + tags: ["供应商管理"], + requiredRole: "admin", + } + ); +app.openapi(getProviderEndpointProbeLogsRoute, getProviderEndpointProbeLogsHandler); + +const { route: getEndpointCircuitInfoRoute, handler: getEndpointCircuitInfoHandler } = + createActionRoute( + "providers", + "getEndpointCircuitInfo", + providerEndpointActions.getEndpointCircuitInfo, + { + requestSchema: z.object({ + endpointId: z.number().int().positive(), + }), + description: "读取端点级熔断器状态 (管理员)", + summary: "读取端点熔断状态", + tags: ["供应商管理"], + requiredRole: "admin", + } + ); +app.openapi(getEndpointCircuitInfoRoute, getEndpointCircuitInfoHandler); + +const { route: resetEndpointCircuitRoute, handler: resetEndpointCircuitHandler } = + createActionRoute( + "providers", + "resetEndpointCircuit", + providerEndpointActions.resetEndpointCircuit, + { + requestSchema: z.object({ + endpointId: z.number().int().positive(), + }), + description: "重置端点级熔断器状态 (管理员)", + summary: "重置端点熔断器", + tags: ["供应商管理"], + requiredRole: "admin", + } + ); +app.openapi(resetEndpointCircuitRoute, resetEndpointCircuitHandler); + +const { route: getVendorTypeCircuitInfoRoute, handler: getVendorTypeCircuitInfoHandler } = + createActionRoute( + "providers", + "getVendorTypeCircuitInfo", + providerEndpointActions.getVendorTypeCircuitInfo, + { + requestSchema: z.object({ + vendorId: z.number().int().positive(), + providerType: ProviderTypeSchema, + }), + description: "读取 vendor+type 临时熔断状态 (管理员)", + summary: "读取临时熔断状态", + tags: ["供应商管理"], + requiredRole: "admin", + } + ); +app.openapi(getVendorTypeCircuitInfoRoute, getVendorTypeCircuitInfoHandler); + +const { + route: setVendorTypeCircuitManualOpenRoute, + handler: setVendorTypeCircuitManualOpenHandler, +} = createActionRoute( + "providers", + "setVendorTypeCircuitManualOpen", + providerEndpointActions.setVendorTypeCircuitManualOpen, + { + requestSchema: z.object({ + vendorId: z.number().int().positive(), + providerType: ProviderTypeSchema, + manualOpen: z.boolean(), + }), + description: "设置 vendor+type 临时熔断手动开关 (管理员)", + summary: "设置临时熔断开关", + tags: ["供应商管理"], + requiredRole: "admin", + } +); +app.openapi(setVendorTypeCircuitManualOpenRoute, setVendorTypeCircuitManualOpenHandler); + +const { route: resetVendorTypeCircuitRoute, handler: resetVendorTypeCircuitHandler } = + createActionRoute( + "providers", + "resetVendorTypeCircuit", + providerEndpointActions.resetVendorTypeCircuit, + { + requestSchema: z.object({ + vendorId: z.number().int().positive(), + providerType: ProviderTypeSchema, + }), + description: "重置 vendor+type 临时熔断状态 (管理员)", + summary: "重置临时熔断状态", + tags: ["供应商管理"], + requiredRole: "admin", + } + ); +app.openapi(resetVendorTypeCircuitRoute, resetVendorTypeCircuitHandler); + const { route: addProviderRoute, handler: addProviderHandler } = createActionRoute( "providers", "addProvider", diff --git a/src/app/api/availability/endpoints/probe-logs/route.ts b/src/app/api/availability/endpoints/probe-logs/route.ts new file mode 100644 index 000000000..e97cb91bd --- /dev/null +++ b/src/app/api/availability/endpoints/probe-logs/route.ts @@ -0,0 +1,46 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { getSession } from "@/lib/auth"; +import { findProviderEndpointById, findProviderEndpointProbeLogs } from "@/repository"; + +export async function GET(request: NextRequest) { + const session = await getSession(); + if (!session || session.user.role !== "admin") { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + + const endpointIdRaw = searchParams.get("endpointId"); + const endpointId = endpointIdRaw ? Number.parseInt(endpointIdRaw, 10) : Number.NaN; + + const limitRaw = searchParams.get("limit"); + const offsetRaw = searchParams.get("offset"); + + const limit = limitRaw ? Number.parseInt(limitRaw, 10) : 200; + const offset = offsetRaw ? Number.parseInt(offsetRaw, 10) : 0; + + if (!Number.isFinite(endpointId) || endpointId <= 0) { + return NextResponse.json({ error: "Invalid query" }, { status: 400 }); + } + + if (!Number.isFinite(limit) || limit <= 0 || limit > 1000) { + return NextResponse.json({ error: "Invalid query" }, { status: 400 }); + } + + if (!Number.isFinite(offset) || offset < 0) { + return NextResponse.json({ error: "Invalid query" }, { status: 400 }); + } + + try { + const endpoint = await findProviderEndpointById(endpointId); + if (!endpoint) { + return NextResponse.json({ error: "Not found" }, { status: 404 }); + } + + const logs = await findProviderEndpointProbeLogs(endpointId, limit, offset); + return NextResponse.json({ endpoint, logs }); + } catch (error) { + console.error("Endpoint probe logs API error:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/src/app/api/availability/endpoints/route.ts b/src/app/api/availability/endpoints/route.ts new file mode 100644 index 000000000..47ae8db0d --- /dev/null +++ b/src/app/api/availability/endpoints/route.ts @@ -0,0 +1,45 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { getSession } from "@/lib/auth"; +import { findProviderEndpointsByVendorAndType } from "@/repository"; +import type { ProviderType } from "@/types/provider"; + +const PROVIDER_TYPES: ProviderType[] = [ + "claude", + "claude-auth", + "codex", + "gemini-cli", + "gemini", + "openai-compatible", +]; + +function isProviderType(value: string | null): value is ProviderType { + if (!value) { + return false; + } + return PROVIDER_TYPES.includes(value as ProviderType); +} + +export async function GET(request: NextRequest) { + const session = await getSession(); + if (!session || session.user.role !== "admin") { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + + const vendorIdRaw = searchParams.get("vendorId"); + const vendorId = vendorIdRaw ? Number.parseInt(vendorIdRaw, 10) : Number.NaN; + const providerTypeRaw = searchParams.get("providerType"); + + if (!Number.isFinite(vendorId) || vendorId <= 0 || !isProviderType(providerTypeRaw)) { + return NextResponse.json({ error: "Invalid query" }, { status: 400 }); + } + + try { + const endpoints = await findProviderEndpointsByVendorAndType(vendorId, providerTypeRaw); + return NextResponse.json({ vendorId, providerType: providerTypeRaw, endpoints }); + } catch (error) { + console.error("Endpoint availability API error:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/src/app/v1/_lib/proxy/forwarder.ts b/src/app/v1/_lib/proxy/forwarder.ts index fc18c9be8..bf28375b5 100644 --- a/src/app/v1/_lib/proxy/forwarder.ts +++ b/src/app/v1/_lib/proxy/forwarder.ts @@ -13,10 +13,16 @@ import { applyCodexProviderOverridesWithAudit } from "@/lib/codex/provider-overr import { getCachedSystemSettings, isHttp2Enabled } from "@/lib/config"; import { getEnvConfig } from "@/lib/config/env.schema"; import { PROVIDER_DEFAULTS, PROVIDER_LIMITS } from "@/lib/constants/provider.constants"; +import { recordEndpointFailure, recordEndpointSuccess } from "@/lib/endpoint-circuit-breaker"; import { logger } from "@/lib/logger"; +import { getPreferredProviderEndpoints } from "@/lib/provider-endpoints/endpoint-selector"; import { createProxyAgentForProvider } from "@/lib/proxy-agent"; import { SessionManager } from "@/lib/session-manager"; import { CONTEXT_1M_BETA_HEADER, shouldApplyContext1m } from "@/lib/special-attributes"; +import { + isVendorTypeCircuitOpen, + recordVendorTypeAllEndpointsTimeout, +} from "@/lib/vendor-type-circuit-breaker"; import { updateMessageRequestDetails } from "@/repository/message"; import type { CacheTtlPreference, CacheTtlResolved } from "@/types/cache"; import { isOfficialCodexClient, sanitizeCodexRequest } from "../codex/utils/request-sanitizer"; @@ -211,6 +217,45 @@ export class ProxyForwarder { ); let thinkingSignatureRectifierRetried = false; + const requestPath = session.requestUrl.pathname; + const isMcpRequest = + currentProvider.providerType !== "gemini" && + currentProvider.providerType !== "gemini-cli" && + !STANDARD_ENDPOINTS.includes(requestPath); + + const endpointCandidates: Array<{ endpointId: number | null; baseUrl: string }> = []; + + if (isMcpRequest) { + endpointCandidates.push({ endpointId: null, baseUrl: currentProvider.url }); + } else if (currentProvider.providerVendorId > 0) { + try { + const preferred = await getPreferredProviderEndpoints({ + vendorId: currentProvider.providerVendorId, + providerType: currentProvider.providerType, + }); + endpointCandidates.push(...preferred.map((e) => ({ endpointId: e.id, baseUrl: e.url }))); + } catch (error) { + logger.warn( + "[ProxyForwarder] Failed to load provider endpoints, fallback to provider.url", + { + providerId: currentProvider.id, + vendorId: currentProvider.providerVendorId, + providerType: currentProvider.providerType, + error: error instanceof Error ? error.message : String(error), + } + ); + } + } + + if (endpointCandidates.length === 0) { + endpointCandidates.push({ endpointId: null, baseUrl: currentProvider.url }); + } + + maxAttemptsPerProvider = Math.max(maxAttemptsPerProvider, endpointCandidates.length); + + let endpointAttemptsEvaluated = 0; + let allEndpointAttemptsTimedOut = true; + logger.info("ProxyForwarder: Trying provider", { providerId: currentProvider.id, providerName: currentProvider.name, @@ -218,12 +263,36 @@ export class ProxyForwarder { maxRetryAttempts: maxAttemptsPerProvider, }); + if ( + !isMcpRequest && + (await isVendorTypeCircuitOpen( + currentProvider.providerVendorId, + currentProvider.providerType + )) + ) { + logger.warn("ProxyForwarder: Vendor-type circuit is open, skipping provider", { + providerId: currentProvider.id, + vendorId: currentProvider.providerVendorId, + providerType: currentProvider.providerType, + }); + failedProviderIds.push(currentProvider.id); + attemptCount = maxAttemptsPerProvider; + } + // ========== 内层循环:重试当前供应商(根据配置最多尝试 maxAttemptsPerProvider 次)========== while (attemptCount < maxAttemptsPerProvider) { attemptCount++; + const endpointIndex = + endpointCandidates.length > 0 ? (attemptCount - 1) % endpointCandidates.length : 0; + const activeEndpoint = endpointCandidates[endpointIndex]; + try { - const response = await ProxyForwarder.doForward(session, currentProvider); + const response = await ProxyForwarder.doForward( + session, + currentProvider, + activeEndpoint.baseUrl + ); // ========== 空响应检测(仅非流式)========== const contentType = response.headers.get("content-type") || ""; @@ -307,6 +376,10 @@ export class ProxyForwarder { } // ========== 成功分支 ========== + if (activeEndpoint.endpointId != null) { + await recordEndpointSuccess(activeEndpoint.endpointId); + } + recordSuccess(currentProvider.id); // ⭐ 成功后绑定 session 到供应商(智能绑定策略) @@ -385,6 +458,20 @@ export class ProxyForwarder { ? lastError.getDetailedErrorMessage() : lastError.message; + const isTimeoutError = lastError instanceof ProxyError && lastError.statusCode === 524; + if (attemptCount <= endpointCandidates.length) { + endpointAttemptsEvaluated = attemptCount; + if (!isTimeoutError) { + allEndpointAttemptsTimedOut = false; + } + } + + if (activeEndpoint.endpointId != null) { + if (isTimeoutError || errorCategory === ErrorCategory.SYSTEM_ERROR) { + await recordEndpointFailure(activeEndpoint.endpointId, lastError); + } + } + // ⭐ 2. 客户端中断处理(不计入熔断器,不重试,立即返回) if (errorCategory === ErrorCategory.CLIENT_ABORT) { logger.warn("ProxyForwarder: Client aborted, stopping immediately", { @@ -403,16 +490,13 @@ export class ProxyForwarder { errorDetails: { system: { errorType: "ClientAbort", - errorName: lastError.name, - errorMessage: lastError.message || "Client aborted request", - errorCode: "CLIENT_ABORT", - errorStack: lastError.stack?.split("\n").slice(0, 3).join("\n"), + errorName: "ClientAbort", + errorMessage: "Client aborted request", }, request: buildRequestDetails(session), }, }); - // 立即抛出错误,不重试 throw lastError; } @@ -807,6 +891,21 @@ export class ProxyForwarder { const statusCode = proxyError.statusCode; const willRetry = attemptCount < maxAttemptsPerProvider; + if ( + !isMcpRequest && + statusCode === 524 && + endpointCandidates.length > 0 && + endpointAttemptsEvaluated >= endpointCandidates.length && + allEndpointAttemptsTimedOut + ) { + await recordVendorTypeAllEndpointsTimeout( + currentProvider.providerVendorId, + currentProvider.providerType + ); + failedProviderIds.push(currentProvider.id); + break; + } + // 🆕 count_tokens 请求特殊处理:不计入熔断,不触发供应商切换 if (session.isCountTokensRequest()) { logger.debug( @@ -929,7 +1028,8 @@ export class ProxyForwarder { */ private static async doForward( session: ProxySession, - provider: typeof session.provider + provider: typeof session.provider, + baseUrl: string ): Promise { if (!provider) { throw new Error("Provider is required"); @@ -968,10 +1068,10 @@ export class ProxyForwarder { }); } - let proxyUrl: string; let processedHeaders: Headers; let requestBody: BodyInit | undefined; let isStreaming = false; + let proxyUrl: string; // --- GEMINI HANDLING --- if (provider.providerType === "gemini" || provider.providerType === "gemini-cli") { @@ -998,20 +1098,21 @@ export class ProxyForwarder { const isApiKey = GeminiAuth.isApiKey(provider.key); // 3. 直接透传:使用 buildProxyUrl() 拼接原始路径和查询参数 - const baseUrl = + const effectiveBaseUrl = + baseUrl || provider.url || (provider.providerType === "gemini" ? GEMINI_PROTOCOL.OFFICIAL_ENDPOINT : GEMINI_PROTOCOL.CLI_ENDPOINT); - proxyUrl = buildProxyUrl(baseUrl, session.requestUrl); + proxyUrl = buildProxyUrl(effectiveBaseUrl, session.requestUrl); // 4. Headers 处理:默认透传 session.headers(含请求过滤器修改),但移除代理认证头并覆盖上游鉴权 // 说明:之前 Gemini 分支使用 new Headers() 重建 headers,会导致 user-agent 丢失且过滤器不生效 processedHeaders = ProxyForwarder.buildGeminiHeaders( session, provider, - baseUrl, + effectiveBaseUrl, accessToken, isApiKey ); @@ -1212,7 +1313,7 @@ export class ProxyForwarder { } // ⭐ MCP 透传处理:检测是否为 MCP 请求,并使用相应的 URL - let effectiveBaseUrl = provider.url; + let effectiveBaseUrl = baseUrl || provider.url; // 检测是否为 MCP 请求(非标准 Claude/Codex/OpenAI 端点) const requestPath = session.requestUrl.pathname; @@ -1233,16 +1334,17 @@ export class ProxyForwarder { requestPath, }); } else { - // 自动从 provider.url 提取基础域名(去掉路径部分) + // 自动从 baseUrl 提取基础域名(去掉路径部分) // 例如:https://api.minimaxi.com/anthropic -> https://api.minimaxi.com try { - const baseUrlObj = new URL(provider.url); + const originalBaseUrl = effectiveBaseUrl; + const baseUrlObj = new URL(originalBaseUrl); effectiveBaseUrl = `${baseUrlObj.protocol}//${baseUrlObj.host}`; logger.debug("ProxyForwarder: Extracted base domain for MCP passthrough", { providerId: provider.id, providerName: provider.name, mcpType: provider.mcpPassthroughType, - originalUrl: provider.url, + originalUrl: originalBaseUrl, extractedBaseDomain: effectiveBaseUrl, requestPath, }); @@ -2096,10 +2198,23 @@ export class ProxyForwarder { // 使用 undici.request 获取未自动解压的响应 // ⭐ 显式配置超时:确保使用自定义 dispatcher(如 SOCKS 代理)时也能正确应用超时 + const toUndiciBody = ( + body: BodyInit | null | undefined + ): string | Uint8Array | Buffer | null | undefined => { + if (body == null || typeof body === "string") return body; + if (body instanceof Uint8Array) return body; + if (Buffer.isBuffer(body)) return body; + if (body instanceof ArrayBuffer) return new Uint8Array(body); + if (ArrayBuffer.isView(body)) { + return new Uint8Array(body.buffer, body.byteOffset, body.byteLength); + } + return undefined; + }; + const undiciRes = await undiciRequest(url, { method: init.method as string, headers: headersObj, - body: init.body as string | Buffer | undefined, + body: toUndiciBody(init.body), signal: init.signal, dispatcher: init.dispatcher, bodyTimeout, diff --git a/src/app/v1/_lib/proxy/provider-selector.ts b/src/app/v1/_lib/proxy/provider-selector.ts index 440f2d062..9ab266841 100644 --- a/src/app/v1/_lib/proxy/provider-selector.ts +++ b/src/app/v1/_lib/proxy/provider-selector.ts @@ -3,6 +3,7 @@ import { PROVIDER_GROUP } from "@/lib/constants/provider.constants"; import { logger } from "@/lib/logger"; import { RateLimitService } from "@/lib/rate-limit"; import { SessionManager } from "@/lib/session-manager"; +import { isVendorTypeCircuitOpen } from "@/lib/vendor-type-circuit-breaker"; import { findAllProviders, findProviderById } from "@/repository/provider"; import { getSystemSettings } from "@/repository/system-config"; import type { ProviderChainItem } from "@/types/message"; @@ -528,6 +529,20 @@ export class ProxyProviderResolver { return null; } + // 临时熔断(vendor+type):防止会话复用绕过故障隔离 + if ( + provider.providerVendorId > 0 && + (await isVendorTypeCircuitOpen(provider.providerVendorId, provider.providerType)) + ) { + logger.debug("ProviderSelector: Session provider vendor-type circuit is open", { + sessionId: session.sessionId, + providerId: provider.id, + vendorId: provider.providerVendorId, + providerType: provider.providerType, + }); + return null; + } + // 检查熔断器状态(TC-055 修复) if (await isCircuitOpen(provider.id)) { logger.debug("ProviderSelector: Session provider circuit is open", { @@ -857,6 +872,19 @@ export class ProxyProviderResolver { ); for (const p of filteredOut) { + if ( + p.providerVendorId > 0 && + (await isVendorTypeCircuitOpen(p.providerVendorId, p.providerType)) + ) { + context.filteredProviders?.push({ + id: p.id, + name: p.name, + reason: "circuit_open", + details: "供应商类型临时熔断", + }); + continue; + } + if (await isCircuitOpen(p.id)) { const state = getCircuitState(p.id); context.filteredProviders?.push({ @@ -936,6 +964,19 @@ export class ProxyProviderResolver { private static async filterByLimits(providers: Provider[]): Promise { const results = await Promise.all( providers.map(async (p) => { + // -1. 检查临时熔断(vendor+type) + if ( + p.providerVendorId > 0 && + (await isVendorTypeCircuitOpen(p.providerVendorId, p.providerType)) + ) { + logger.debug("ProviderSelector: Vendor-type circuit breaker is open", { + providerId: p.id, + vendorId: p.providerVendorId, + providerType: p.providerType, + }); + return null; + } + // 0. 检查熔断器状态 if (await isCircuitOpen(p.id)) { logger.debug("ProviderSelector: Provider circuit breaker is open", { diff --git a/src/drizzle/schema.ts b/src/drizzle/schema.ts index 04bd542e8..ad93d9a26 100644 --- a/src/drizzle/schema.ts +++ b/src/drizzle/schema.ts @@ -15,6 +15,7 @@ import { import { relations, sql } from 'drizzle-orm'; import type { SpecialSetting } from '@/types/special-settings'; import type { ResponseFixerConfig } from '@/types/system-config'; +import type { ProviderType } from "@/types/provider"; // Enums export const dailyResetModeEnum = pgEnum('daily_reset_mode', ['fixed', 'rolling']); @@ -127,6 +128,22 @@ export const keys = pgTable('keys', { keysDeletedAtIdx: index('idx_keys_deleted_at').on(table.deletedAt), })); +// Provider Vendors table - 以官网域名聚合的供应商实体(与 key/providerGroup 字段无关) +export const providerVendors = pgTable('provider_vendors', { + id: serial('id').primaryKey(), + websiteDomain: varchar('website_domain', { length: 255 }).notNull(), + displayName: varchar('display_name', { length: 200 }), + websiteUrl: text('website_url'), + faviconUrl: text('favicon_url'), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow(), +}, (table) => ({ + providerVendorsWebsiteDomainUnique: uniqueIndex('uniq_provider_vendors_website_domain').on( + table.websiteDomain + ), + providerVendorsCreatedAtIdx: index('idx_provider_vendors_created_at').on(table.createdAt), +})); + // Providers table export const providers = pgTable('providers', { id: serial('id').primaryKey(), @@ -134,6 +151,9 @@ export const providers = pgTable('providers', { description: text('description'), url: varchar('url').notNull(), key: varchar('key').notNull(), + providerVendorId: integer('provider_vendor_id') + .notNull() + .references(() => providerVendors.id, { onDelete: 'restrict' }), isEnabled: boolean('is_enabled').notNull().default(true), weight: integer('weight').notNull().default(1), @@ -151,7 +171,7 @@ export const providers = pgTable('providers', { providerType: varchar('provider_type', { length: 20 }) .notNull() .default('claude') - .$type<'claude' | 'claude-auth' | 'codex' | 'gemini-cli' | 'gemini' | 'openai-compatible'>(), + .$type(), // 是否透传客户端 IP(默认关闭,避免暴露真实来源) preserveClientIp: boolean('preserve_client_ip').notNull().default(false), @@ -270,6 +290,76 @@ export const providers = pgTable('providers', { // 基础索引 providersCreatedAtIdx: index('idx_providers_created_at').on(table.createdAt), providersDeletedAtIdx: index('idx_providers_deleted_at').on(table.deletedAt), + providersVendorTypeIdx: index('idx_providers_vendor_type').on(table.providerVendorId, table.providerType).where(sql`${table.deletedAt} IS NULL`), +})); + +// Provider Endpoints table - 供应商(官网域名) + 类型 维度的端点池 +export const providerEndpoints = pgTable('provider_endpoints', { + id: serial('id').primaryKey(), + vendorId: integer('vendor_id') + .notNull() + .references(() => providerVendors.id, { onDelete: 'cascade' }), + providerType: varchar('provider_type', { length: 20 }) + .notNull() + .default('claude') + .$type(), + url: text('url').notNull(), + label: varchar('label', { length: 200 }), + sortOrder: integer('sort_order').notNull().default(0), + isEnabled: boolean('is_enabled').notNull().default(true), + + // Last probe snapshot + lastProbedAt: timestamp('last_probed_at', { withTimezone: true }), + lastProbeOk: boolean('last_probe_ok'), + lastProbeStatusCode: integer('last_probe_status_code'), + lastProbeLatencyMs: integer('last_probe_latency_ms'), + lastProbeErrorType: varchar('last_probe_error_type', { length: 64 }), + lastProbeErrorMessage: text('last_probe_error_message'), + + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow(), + deletedAt: timestamp('deleted_at', { withTimezone: true }), +}, (table) => ({ + providerEndpointsUnique: uniqueIndex('uniq_provider_endpoints_vendor_type_url').on( + table.vendorId, + table.providerType, + table.url + ), + providerEndpointsVendorTypeIdx: index('idx_provider_endpoints_vendor_type').on( + table.vendorId, + table.providerType + ).where(sql`${table.deletedAt} IS NULL`), + providerEndpointsEnabledIdx: index('idx_provider_endpoints_enabled').on( + table.isEnabled, + table.vendorId, + table.providerType + ).where(sql`${table.deletedAt} IS NULL`), + providerEndpointsCreatedAtIdx: index('idx_provider_endpoints_created_at').on(table.createdAt), + providerEndpointsDeletedAtIdx: index('idx_provider_endpoints_deleted_at').on(table.deletedAt), +})); + +// Provider Endpoint Probe Logs table - 端点测活历史 +export const providerEndpointProbeLogs = pgTable('provider_endpoint_probe_logs', { + id: serial('id').primaryKey(), + endpointId: integer('endpoint_id') + .notNull() + .references(() => providerEndpoints.id, { onDelete: 'cascade' }), + source: varchar('source', { length: 20 }) + .notNull() + .default('scheduled') + .$type<'scheduled' | 'manual' | 'runtime'>(), + ok: boolean('ok').notNull(), + statusCode: integer('status_code'), + latencyMs: integer('latency_ms'), + errorType: varchar('error_type', { length: 64 }), + errorMessage: text('error_message'), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(), +}, (table) => ({ + providerEndpointProbeLogsEndpointCreatedAtIdx: index('idx_provider_endpoint_probe_logs_endpoint_created_at').on( + table.endpointId, + table.createdAt.desc() + ), + providerEndpointProbeLogsCreatedAtIdx: index('idx_provider_endpoint_probe_logs_created_at').on(table.createdAt), })); // Message Request table @@ -629,10 +719,34 @@ export const keysRelations = relations(keys, ({ one, many }) => ({ messageRequests: many(messageRequest), })); -export const providersRelations = relations(providers, ({ many }) => ({ +export const providersRelations = relations(providers, ({ many, one }) => ({ + vendor: one(providerVendors, { + fields: [providers.providerVendorId], + references: [providerVendors.id], + }), messageRequests: many(messageRequest), })); +export const providerVendorsRelations = relations(providerVendors, ({ many }) => ({ + providers: many(providers), + endpoints: many(providerEndpoints), +})); + +export const providerEndpointsRelations = relations(providerEndpoints, ({ many, one }) => ({ + vendor: one(providerVendors, { + fields: [providerEndpoints.vendorId], + references: [providerVendors.id], + }), + probeLogs: many(providerEndpointProbeLogs), +})); + +export const providerEndpointProbeLogsRelations = relations(providerEndpointProbeLogs, ({ one }) => ({ + endpoint: one(providerEndpoints, { + fields: [providerEndpointProbeLogs.endpointId], + references: [providerEndpoints.id], + }), +})); + export const messageRequestRelations = relations(messageRequest, ({ one }) => ({ user: one(users, { fields: [messageRequest.userId], diff --git a/src/lib/endpoint-circuit-breaker.ts b/src/lib/endpoint-circuit-breaker.ts new file mode 100644 index 000000000..06ce237d3 --- /dev/null +++ b/src/lib/endpoint-circuit-breaker.ts @@ -0,0 +1,196 @@ +import "server-only"; + +import { logger } from "@/lib/logger"; +import { + deleteEndpointCircuitState, + type EndpointCircuitBreakerState, + type EndpointCircuitState, + loadEndpointCircuitState, + saveEndpointCircuitState, +} from "@/lib/redis/endpoint-circuit-breaker-state"; + +export interface EndpointCircuitBreakerConfig { + failureThreshold: number; + openDuration: number; + halfOpenSuccessThreshold: number; +} + +export const DEFAULT_ENDPOINT_CIRCUIT_BREAKER_CONFIG: EndpointCircuitBreakerConfig = { + failureThreshold: 3, + openDuration: 300000, + halfOpenSuccessThreshold: 1, +}; + +export interface EndpointHealth { + failureCount: number; + lastFailureTime: number | null; + circuitState: EndpointCircuitState; + circuitOpenUntil: number | null; + halfOpenSuccessCount: number; +} + +const healthMap = new Map(); +const loadedFromRedis = new Set(); + +function getOrCreateHealthSync(endpointId: number): EndpointHealth { + let health = healthMap.get(endpointId); + if (!health) { + health = { + failureCount: 0, + lastFailureTime: null, + circuitState: "closed", + circuitOpenUntil: null, + halfOpenSuccessCount: 0, + }; + healthMap.set(endpointId, health); + } + return health; +} + +async function getOrCreateHealth(endpointId: number): Promise { + let health = healthMap.get(endpointId); + const needsRedisCheck = + (!health && !loadedFromRedis.has(endpointId)) || (health && health.circuitState !== "closed"); + + if (needsRedisCheck) { + loadedFromRedis.add(endpointId); + + try { + const redisState = await loadEndpointCircuitState(endpointId); + if (redisState) { + if (!health || redisState.circuitState !== health.circuitState) { + health = { + failureCount: redisState.failureCount, + lastFailureTime: redisState.lastFailureTime, + circuitState: redisState.circuitState, + circuitOpenUntil: redisState.circuitOpenUntil, + halfOpenSuccessCount: redisState.halfOpenSuccessCount, + }; + healthMap.set(endpointId, health); + } + return health; + } + + if (health && health.circuitState !== "closed") { + health.circuitState = "closed"; + health.failureCount = 0; + health.lastFailureTime = null; + health.circuitOpenUntil = null; + health.halfOpenSuccessCount = 0; + } + } catch (error) { + logger.warn("[EndpointCircuitBreaker] Failed to sync state from Redis", { + endpointId, + error: error instanceof Error ? error.message : String(error), + }); + } + } + + return getOrCreateHealthSync(endpointId); +} + +function persistStateToRedis(endpointId: number, health: EndpointHealth): void { + const state: EndpointCircuitBreakerState = { + failureCount: health.failureCount, + lastFailureTime: health.lastFailureTime, + circuitState: health.circuitState, + circuitOpenUntil: health.circuitOpenUntil, + halfOpenSuccessCount: health.halfOpenSuccessCount, + }; + + saveEndpointCircuitState(endpointId, state).catch((error) => { + logger.warn("[EndpointCircuitBreaker] Failed to persist state to Redis", { + endpointId, + error: error instanceof Error ? error.message : String(error), + }); + }); +} + +export async function getEndpointHealthInfo( + endpointId: number +): Promise<{ health: EndpointHealth; config: EndpointCircuitBreakerConfig }> { + const health = await getOrCreateHealth(endpointId); + return { health, config: DEFAULT_ENDPOINT_CIRCUIT_BREAKER_CONFIG }; +} + +export async function isEndpointCircuitOpen(endpointId: number): Promise { + const health = await getOrCreateHealth(endpointId); + + if (health.circuitState === "closed") { + return false; + } + + if (health.circuitState === "open") { + if (health.circuitOpenUntil && Date.now() > health.circuitOpenUntil) { + health.circuitState = "half-open"; + health.halfOpenSuccessCount = 0; + persistStateToRedis(endpointId, health); + return false; + } + + return true; + } + + return false; +} + +export async function recordEndpointFailure(endpointId: number, error: Error): Promise { + const health = await getOrCreateHealth(endpointId); + const config = DEFAULT_ENDPOINT_CIRCUIT_BREAKER_CONFIG; + + health.failureCount += 1; + health.lastFailureTime = Date.now(); + + if (config.failureThreshold > 0 && health.failureCount >= config.failureThreshold) { + health.circuitState = "open"; + health.circuitOpenUntil = Date.now() + config.openDuration; + health.halfOpenSuccessCount = 0; + + logger.warn("[EndpointCircuitBreaker] Endpoint circuit opened", { + endpointId, + failureCount: health.failureCount, + threshold: config.failureThreshold, + errorMessage: error.message, + }); + } + + persistStateToRedis(endpointId, health); +} + +export async function recordEndpointSuccess(endpointId: number): Promise { + const health = await getOrCreateHealth(endpointId); + const config = DEFAULT_ENDPOINT_CIRCUIT_BREAKER_CONFIG; + + if (health.circuitState === "half-open") { + health.halfOpenSuccessCount += 1; + + if (health.halfOpenSuccessCount >= config.halfOpenSuccessThreshold) { + health.circuitState = "closed"; + health.failureCount = 0; + health.lastFailureTime = null; + health.circuitOpenUntil = null; + health.halfOpenSuccessCount = 0; + } + + persistStateToRedis(endpointId, health); + return; + } + + if (health.failureCount > 0) { + health.failureCount = 0; + health.lastFailureTime = null; + health.circuitOpenUntil = null; + persistStateToRedis(endpointId, health); + } +} + +export async function resetEndpointCircuit(endpointId: number): Promise { + const health = getOrCreateHealthSync(endpointId); + health.circuitState = "closed"; + health.failureCount = 0; + health.lastFailureTime = null; + health.circuitOpenUntil = null; + health.halfOpenSuccessCount = 0; + + await deleteEndpointCircuitState(endpointId); +} diff --git a/src/lib/provider-endpoints/endpoint-selector.ts b/src/lib/provider-endpoints/endpoint-selector.ts new file mode 100644 index 000000000..246fadfcb --- /dev/null +++ b/src/lib/provider-endpoints/endpoint-selector.ts @@ -0,0 +1,63 @@ +import "server-only"; + +import { isEndpointCircuitOpen } from "@/lib/endpoint-circuit-breaker"; +import { findProviderEndpointsByVendorAndType } from "@/repository"; +import type { ProviderEndpoint, ProviderType } from "@/types/provider"; + +export function rankProviderEndpoints(endpoints: ProviderEndpoint[]): ProviderEndpoint[] { + const enabled = endpoints.filter((e) => e.isEnabled && !e.deletedAt); + + const priorityRank = (endpoint: ProviderEndpoint): number => { + if (endpoint.lastProbeOk === true) return 0; + if (endpoint.lastProbeOk === null) return 1; + return 2; + }; + + return enabled.slice().sort((a, b) => { + const rankDiff = priorityRank(a) - priorityRank(b); + if (rankDiff !== 0) return rankDiff; + + if (a.sortOrder !== b.sortOrder) return a.sortOrder - b.sortOrder; + + const aLatency = a.lastProbeLatencyMs ?? Number.POSITIVE_INFINITY; + const bLatency = b.lastProbeLatencyMs ?? Number.POSITIVE_INFINITY; + if (aLatency !== bLatency) return aLatency - bLatency; + + return a.id - b.id; + }); +} + +export async function getPreferredProviderEndpoints(input: { + vendorId: number; + providerType: ProviderType; + excludeEndpointIds?: number[]; +}): Promise { + const excludeSet = new Set(input.excludeEndpointIds ?? []); + + const endpoints = await findProviderEndpointsByVendorAndType(input.vendorId, input.providerType); + const filtered = endpoints.filter((e) => e.isEnabled && !e.deletedAt && !excludeSet.has(e.id)); + + if (filtered.length === 0) { + return []; + } + + const circuitResults = await Promise.all( + filtered.map(async (endpoint) => ({ + endpoint, + isOpen: await isEndpointCircuitOpen(endpoint.id), + })) + ); + + const candidates = circuitResults.filter(({ isOpen }) => !isOpen).map(({ endpoint }) => endpoint); + + return rankProviderEndpoints(candidates); +} + +export async function pickBestProviderEndpoint(input: { + vendorId: number; + providerType: ProviderType; + excludeEndpointIds?: number[]; +}): Promise { + const ordered = await getPreferredProviderEndpoints(input); + return ordered[0] ?? null; +} diff --git a/src/lib/provider-endpoints/probe.ts b/src/lib/provider-endpoints/probe.ts new file mode 100644 index 000000000..920bc596c --- /dev/null +++ b/src/lib/provider-endpoints/probe.ts @@ -0,0 +1,129 @@ +import "server-only"; + +import { logger } from "@/lib/logger"; +import { findProviderEndpointById, recordProviderEndpointProbeResult } from "@/repository"; +import type { ProviderEndpointProbeSource } from "@/types/provider"; + +export type EndpointProbeMethod = "HEAD" | "GET"; + +export interface EndpointProbeResult { + ok: boolean; + method: EndpointProbeMethod; + statusCode: number | null; + latencyMs: number | null; + errorType: string | null; + errorMessage: string | null; +} + +const DEFAULT_TIMEOUT_MS = 5000; + +async function fetchWithTimeout( + url: string, + init: RequestInit, + timeoutMs: number +): Promise<{ response: Response; latencyMs: number }> { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + const start = Date.now(); + + try { + const response = await fetch(url, { + ...init, + signal: controller.signal, + redirect: "manual", + }); + return { response, latencyMs: Date.now() - start }; + } finally { + clearTimeout(timeout); + } +} + +function toErrorInfo(error: unknown): { type: string; message: string } { + if (error instanceof Error) { + if (error.name === "AbortError") { + return { type: "timeout", message: error.message || "timeout" }; + } + return { type: "network_error", message: error.message }; + } + return { type: "unknown_error", message: String(error) }; +} + +async function tryProbe( + url: string, + method: EndpointProbeMethod, + timeoutMs: number +): Promise { + try { + const { response, latencyMs } = await fetchWithTimeout( + url, + { + method, + headers: { + "cache-control": "no-store", + }, + }, + timeoutMs + ); + + const statusCode = response.status; + const ok = statusCode < 500; + + return { + ok, + method, + statusCode, + latencyMs, + errorType: ok ? null : "http_5xx", + errorMessage: ok ? null : `HTTP ${statusCode}`, + }; + } catch (error) { + const { type, message } = toErrorInfo(error); + logger.debug("[EndpointProbe] Probe request failed", { url, method, type, error }); + return { + ok: false, + method, + statusCode: null, + latencyMs: null, + errorType: type, + errorMessage: message, + }; + } +} + +export async function probeEndpointUrl( + url: string, + timeoutMs: number = DEFAULT_TIMEOUT_MS +): Promise { + const head = await tryProbe(url, "HEAD", timeoutMs); + if (head.statusCode === null) { + return tryProbe(url, "GET", timeoutMs); + } + return head; +} + +export async function probeProviderEndpointAndRecord(input: { + endpointId: number; + source: ProviderEndpointProbeSource; + timeoutMs?: number; +}): Promise { + const endpoint = await findProviderEndpointById(input.endpointId); + if (!endpoint) { + return null; + } + + const timeoutMs = input.timeoutMs ?? DEFAULT_TIMEOUT_MS; + const result = await probeEndpointUrl(endpoint.url, timeoutMs); + + await recordProviderEndpointProbeResult({ + endpointId: endpoint.id, + source: input.source, + ok: result.ok, + statusCode: result.statusCode, + latencyMs: result.latencyMs, + errorType: result.errorType, + errorMessage: result.errorMessage, + probedAt: new Date(), + }); + + return result; +} diff --git a/src/lib/redis/endpoint-circuit-breaker-state.ts b/src/lib/redis/endpoint-circuit-breaker-state.ts new file mode 100644 index 000000000..1d7feae80 --- /dev/null +++ b/src/lib/redis/endpoint-circuit-breaker-state.ts @@ -0,0 +1,118 @@ +import "server-only"; + +import { logger } from "@/lib/logger"; +import { getRedisClient } from "./client"; + +export type EndpointCircuitState = "closed" | "open" | "half-open"; + +export interface EndpointCircuitBreakerState { + failureCount: number; + lastFailureTime: number | null; + circuitState: EndpointCircuitState; + circuitOpenUntil: number | null; + halfOpenSuccessCount: number; +} + +export const DEFAULT_ENDPOINT_CIRCUIT_STATE: EndpointCircuitBreakerState = { + failureCount: 0, + lastFailureTime: null, + circuitState: "closed", + circuitOpenUntil: null, + halfOpenSuccessCount: 0, +}; + +const STATE_TTL_SECONDS = 86400; + +function getStateKey(endpointId: number): string { + return `endpoint_circuit_breaker:state:${endpointId}`; +} + +function serializeState(state: EndpointCircuitBreakerState): Record { + return { + failureCount: state.failureCount.toString(), + lastFailureTime: state.lastFailureTime?.toString() ?? "", + circuitState: state.circuitState, + circuitOpenUntil: state.circuitOpenUntil?.toString() ?? "", + halfOpenSuccessCount: state.halfOpenSuccessCount.toString(), + }; +} + +function deserializeState(data: Record): EndpointCircuitBreakerState { + return { + failureCount: Number.parseInt(data.failureCount || "0", 10), + lastFailureTime: data.lastFailureTime ? Number.parseInt(data.lastFailureTime, 10) : null, + circuitState: (data.circuitState as EndpointCircuitState) || "closed", + circuitOpenUntil: data.circuitOpenUntil ? Number.parseInt(data.circuitOpenUntil, 10) : null, + halfOpenSuccessCount: Number.parseInt(data.halfOpenSuccessCount || "0", 10), + }; +} + +export async function loadEndpointCircuitState( + endpointId: number +): Promise { + const redis = getRedisClient(); + + if (!redis) { + logger.debug("[EndpointCircuitState] Redis not available, returning null", { endpointId }); + return null; + } + + try { + const key = getStateKey(endpointId); + const data = await redis.hgetall(key); + + if (!data || Object.keys(data).length === 0) { + return null; + } + + return deserializeState(data); + } catch (error) { + logger.warn("[EndpointCircuitState] Failed to load from Redis", { + endpointId, + error: error instanceof Error ? error.message : String(error), + }); + return null; + } +} + +export async function saveEndpointCircuitState( + endpointId: number, + state: EndpointCircuitBreakerState +): Promise { + const redis = getRedisClient(); + + if (!redis) { + logger.debug("[EndpointCircuitState] Redis not available, skip saving", { endpointId }); + return; + } + + try { + const key = getStateKey(endpointId); + const data = serializeState(state); + await redis.hset(key, data); + await redis.expire(key, STATE_TTL_SECONDS); + } catch (error) { + logger.warn("[EndpointCircuitState] Failed to save to Redis", { + endpointId, + error: error instanceof Error ? error.message : String(error), + }); + } +} + +export async function deleteEndpointCircuitState(endpointId: number): Promise { + const redis = getRedisClient(); + + if (!redis) { + return; + } + + try { + const key = getStateKey(endpointId); + await redis.del(key); + } catch (error) { + logger.warn("[EndpointCircuitState] Failed to delete from Redis", { + endpointId, + error: error instanceof Error ? error.message : String(error), + }); + } +} diff --git a/src/lib/redis/vendor-type-circuit-breaker-state.ts b/src/lib/redis/vendor-type-circuit-breaker-state.ts new file mode 100644 index 000000000..1c893db73 --- /dev/null +++ b/src/lib/redis/vendor-type-circuit-breaker-state.ts @@ -0,0 +1,121 @@ +import "server-only"; + +import { logger } from "@/lib/logger"; +import type { ProviderType } from "@/types/provider"; +import { getRedisClient } from "./client"; + +export type VendorTypeCircuitStateValue = "closed" | "open"; + +export interface VendorTypeCircuitBreakerState { + circuitState: VendorTypeCircuitStateValue; + circuitOpenUntil: number | null; + lastFailureTime: number | null; + manualOpen: boolean; +} + +export const DEFAULT_VENDOR_TYPE_CIRCUIT_STATE: VendorTypeCircuitBreakerState = { + circuitState: "closed", + circuitOpenUntil: null, + lastFailureTime: null, + manualOpen: false, +}; + +const STATE_TTL_SECONDS = 2592000; + +function getStateKey(vendorId: number, providerType: ProviderType): string { + return `vendor_type_circuit_breaker:state:${vendorId}:${providerType}`; +} + +function serializeState(state: VendorTypeCircuitBreakerState): Record { + return { + circuitState: state.circuitState, + circuitOpenUntil: state.circuitOpenUntil?.toString() ?? "", + lastFailureTime: state.lastFailureTime?.toString() ?? "", + manualOpen: state.manualOpen ? "1" : "0", + }; +} + +function deserializeState(data: Record): VendorTypeCircuitBreakerState { + return { + circuitState: (data.circuitState as VendorTypeCircuitStateValue) || "closed", + circuitOpenUntil: data.circuitOpenUntil ? Number.parseInt(data.circuitOpenUntil, 10) : null, + lastFailureTime: data.lastFailureTime ? Number.parseInt(data.lastFailureTime, 10) : null, + manualOpen: data.manualOpen === "1", + }; +} + +export async function loadVendorTypeCircuitState( + vendorId: number, + providerType: ProviderType +): Promise { + const redis = getRedisClient(); + + if (!redis) { + return null; + } + + try { + const key = getStateKey(vendorId, providerType); + const data = await redis.hgetall(key); + + if (!data || Object.keys(data).length === 0) { + return null; + } + + return deserializeState(data); + } catch (error) { + logger.warn("[VendorTypeCircuitState] Failed to load from Redis", { + vendorId, + providerType, + error: error instanceof Error ? error.message : String(error), + }); + return null; + } +} + +export async function saveVendorTypeCircuitState( + vendorId: number, + providerType: ProviderType, + state: VendorTypeCircuitBreakerState +): Promise { + const redis = getRedisClient(); + + if (!redis) { + return; + } + + try { + const key = getStateKey(vendorId, providerType); + const data = serializeState(state); + await redis.hset(key, data); + await redis.expire(key, STATE_TTL_SECONDS); + } catch (error) { + logger.warn("[VendorTypeCircuitState] Failed to save to Redis", { + vendorId, + providerType, + error: error instanceof Error ? error.message : String(error), + }); + } +} + +export async function deleteVendorTypeCircuitState( + vendorId: number, + providerType: ProviderType +): Promise { + const redis = getRedisClient(); + + if (!redis) { + return; + } + + try { + const key = getStateKey(vendorId, providerType); + await redis.del(key); + } catch (error) { + logger.warn("[VendorTypeCircuitState] Failed to delete from Redis", { + vendorId, + providerType, + error: error instanceof Error ? error.message : String(error), + }); + } +} diff --git a/src/lib/vendor-type-circuit-breaker.ts b/src/lib/vendor-type-circuit-breaker.ts new file mode 100644 index 000000000..618b9d680 --- /dev/null +++ b/src/lib/vendor-type-circuit-breaker.ts @@ -0,0 +1,187 @@ +import "server-only"; + +import { logger } from "@/lib/logger"; +import { + deleteVendorTypeCircuitState, + loadVendorTypeCircuitState, + saveVendorTypeCircuitState, + type VendorTypeCircuitBreakerState, +} from "@/lib/redis/vendor-type-circuit-breaker-state"; +import type { ProviderType } from "@/types/provider"; + +export interface VendorTypeCircuitInfo { + vendorId: number; + providerType: ProviderType; + circuitState: "closed" | "open"; + circuitOpenUntil: number | null; + lastFailureTime: number | null; + manualOpen: boolean; +} + +const AUTO_OPEN_DURATION_MS = 60000; + +const stateMap = new Map(); +const loadedFromRedis = new Set(); + +function getKey(vendorId: number, providerType: ProviderType): string { + return `${vendorId}:${providerType}`; +} + +function toInfo( + vendorId: number, + providerType: ProviderType, + state: VendorTypeCircuitBreakerState +): VendorTypeCircuitInfo { + return { + vendorId, + providerType, + circuitState: state.circuitState, + circuitOpenUntil: state.circuitOpenUntil, + lastFailureTime: state.lastFailureTime, + manualOpen: state.manualOpen, + }; +} + +async function getOrCreateState( + vendorId: number, + providerType: ProviderType +): Promise { + const key = getKey(vendorId, providerType); + let state = stateMap.get(key); + const needsRedisCheck = + (!state && !loadedFromRedis.has(key)) || + (state && (state.circuitState !== "closed" || state.manualOpen)); + + if (needsRedisCheck) { + loadedFromRedis.add(key); + + try { + const redisState = await loadVendorTypeCircuitState(vendorId, providerType); + if (redisState) { + stateMap.set(key, redisState); + return redisState; + } + + if (state && (state.circuitState !== "closed" || state.manualOpen)) { + state.circuitState = "closed"; + state.circuitOpenUntil = null; + state.lastFailureTime = null; + state.manualOpen = false; + } + } catch (error) { + logger.warn("[VendorTypeCircuitBreaker] Failed to sync state from Redis", { + vendorId, + providerType, + error: error instanceof Error ? error.message : String(error), + }); + } + } + + if (!state) { + state = { + circuitState: "closed", + circuitOpenUntil: null, + lastFailureTime: null, + manualOpen: false, + }; + stateMap.set(key, state); + } + + return state; +} + +function persist( + vendorId: number, + providerType: ProviderType, + state: VendorTypeCircuitBreakerState +): void { + saveVendorTypeCircuitState(vendorId, providerType, state).catch((error) => { + logger.warn("[VendorTypeCircuitBreaker] Failed to persist state to Redis", { + vendorId, + providerType, + error: error instanceof Error ? error.message : String(error), + }); + }); +} + +export async function getVendorTypeCircuitInfo( + vendorId: number, + providerType: ProviderType +): Promise { + const state = await getOrCreateState(vendorId, providerType); + return toInfo(vendorId, providerType, state); +} + +export async function isVendorTypeCircuitOpen( + vendorId: number, + providerType: ProviderType +): Promise { + const state = await getOrCreateState(vendorId, providerType); + + if (state.manualOpen) { + return true; + } + + if (state.circuitState === "open") { + if (state.circuitOpenUntil && Date.now() > state.circuitOpenUntil) { + state.circuitState = "closed"; + state.circuitOpenUntil = null; + state.lastFailureTime = null; + persist(vendorId, providerType, state); + return false; + } + return true; + } + + return false; +} + +export async function recordVendorTypeAllEndpointsTimeout( + vendorId: number, + providerType: ProviderType, + openDurationMs: number = AUTO_OPEN_DURATION_MS +): Promise { + const state = await getOrCreateState(vendorId, providerType); + + if (state.manualOpen) { + return; + } + + state.circuitState = "open"; + state.lastFailureTime = Date.now(); + state.circuitOpenUntil = Date.now() + Math.max(1000, openDurationMs); + + persist(vendorId, providerType, state); +} + +export async function setVendorTypeCircuitManualOpen( + vendorId: number, + providerType: ProviderType, + manualOpen: boolean +): Promise { + const state = await getOrCreateState(vendorId, providerType); + + state.manualOpen = manualOpen; + + if (manualOpen) { + state.circuitState = "open"; + state.circuitOpenUntil = null; + state.lastFailureTime = Date.now(); + } else { + state.circuitState = "closed"; + state.circuitOpenUntil = null; + state.lastFailureTime = null; + } + + persist(vendorId, providerType, state); +} + +export async function resetVendorTypeCircuit( + vendorId: number, + providerType: ProviderType +): Promise { + const key = getKey(vendorId, providerType); + stateMap.delete(key); + loadedFromRedis.delete(key); + await deleteVendorTypeCircuitState(vendorId, providerType); +} diff --git a/src/repository/_shared/transformers.ts b/src/repository/_shared/transformers.ts index 00eeee4c8..06550fa1d 100644 --- a/src/repository/_shared/transformers.ts +++ b/src/repository/_shared/transformers.ts @@ -66,6 +66,7 @@ export function toKey(dbKey: any): Key { export function toProvider(dbProvider: any): Provider { return { ...dbProvider, + providerVendorId: dbProvider?.providerVendorId ?? 0, isEnabled: dbProvider?.isEnabled ?? true, weight: dbProvider?.weight ?? 1, priority: dbProvider?.priority ?? 0, diff --git a/src/repository/index.ts b/src/repository/index.ts index 4f62b2d4d..a8652a76c 100644 --- a/src/repository/index.ts +++ b/src/repository/index.ts @@ -44,6 +44,19 @@ export { getDistinctProviderGroups, updateProvider, } from "./provider"; + +export { + createProviderEndpoint, + findProviderEndpointById, + findProviderEndpointProbeLogs, + findProviderEndpointsByVendorAndType, + findProviderVendorById, + findProviderVendors, + recordProviderEndpointProbeResult, + softDeleteProviderEndpoint, + updateProviderEndpoint, + updateProviderVendor, +} from "./provider-endpoints"; // Statistics related exports export { getActiveKeysForUserFromDB, diff --git a/src/repository/provider-endpoints.ts b/src/repository/provider-endpoints.ts new file mode 100644 index 000000000..e3fbd70d7 --- /dev/null +++ b/src/repository/provider-endpoints.ts @@ -0,0 +1,468 @@ +"use server"; + +import { and, asc, desc, eq, isNull } from "drizzle-orm"; +import { db } from "@/drizzle/db"; +import { providerEndpointProbeLogs, providerEndpoints, providerVendors } from "@/drizzle/schema"; +import { logger } from "@/lib/logger"; +import type { + ProviderEndpoint, + ProviderEndpointProbeLog, + ProviderEndpointProbeSource, + ProviderType, + ProviderVendor, +} from "@/types/provider"; + +function toDate(value: unknown): Date { + if (value instanceof Date) return value; + if (typeof value === "string" || typeof value === "number") return new Date(value); + return new Date(); +} + +function toNullableDate(value: unknown): Date | null { + if (value === null || value === undefined) return null; + return toDate(value); +} + +function normalizeWebsiteDomainFromUrl(rawUrl: string): string | null { + const trimmed = rawUrl.trim(); + if (!trimmed) return null; + + const candidates = [trimmed]; + if (!/^[a-zA-Z][a-zA-Z\d+.-]*:\/\//.test(trimmed)) { + candidates.push(`https://${trimmed}`); + } + + for (const candidate of candidates) { + try { + const parsed = new URL(candidate); + const hostname = parsed.hostname?.toLowerCase(); + if (!hostname) continue; + return hostname.startsWith("www.") ? hostname.slice(4) : hostname; + } catch (error) { + logger.debug("[ProviderVendor] Failed to parse URL", { candidate, error }); + } + } + + return null; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function toProviderVendor(row: any): ProviderVendor { + return { + id: row.id, + websiteDomain: row.websiteDomain, + displayName: row.displayName ?? null, + websiteUrl: row.websiteUrl ?? null, + faviconUrl: row.faviconUrl ?? null, + createdAt: toDate(row.createdAt), + updatedAt: toDate(row.updatedAt), + }; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function toProviderEndpoint(row: any): ProviderEndpoint { + return { + id: row.id, + vendorId: row.vendorId, + providerType: (row.providerType ?? "claude") as ProviderEndpoint["providerType"], + url: row.url, + label: row.label ?? null, + sortOrder: row.sortOrder ?? 0, + isEnabled: row.isEnabled ?? true, + lastProbedAt: toNullableDate(row.lastProbedAt), + lastProbeOk: row.lastProbeOk ?? null, + lastProbeStatusCode: row.lastProbeStatusCode ?? null, + lastProbeLatencyMs: row.lastProbeLatencyMs ?? null, + lastProbeErrorType: row.lastProbeErrorType ?? null, + lastProbeErrorMessage: row.lastProbeErrorMessage ?? null, + createdAt: toDate(row.createdAt), + updatedAt: toDate(row.updatedAt), + deletedAt: toNullableDate(row.deletedAt), + }; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function toProviderEndpointProbeLog(row: any): ProviderEndpointProbeLog { + return { + id: row.id, + endpointId: row.endpointId, + source: row.source, + ok: row.ok, + statusCode: row.statusCode ?? null, + latencyMs: row.latencyMs ?? null, + errorType: row.errorType ?? null, + errorMessage: row.errorMessage ?? null, + createdAt: toDate(row.createdAt), + }; +} + +export async function getOrCreateProviderVendorIdFromUrls(input: { + providerUrl: string; + websiteUrl?: string | null; + faviconUrl?: string | null; + displayName?: string | null; +}): Promise { + const domainSource = input.websiteUrl?.trim() ? input.websiteUrl : input.providerUrl; + const websiteDomain = normalizeWebsiteDomainFromUrl(domainSource); + if (!websiteDomain) { + throw new Error("Failed to resolve provider vendor domain"); + } + + const existing = await db + .select({ id: providerVendors.id }) + .from(providerVendors) + .where(eq(providerVendors.websiteDomain, websiteDomain)) + .limit(1); + if (existing[0]) { + return existing[0].id; + } + + const now = new Date(); + const inserted = await db + .insert(providerVendors) + .values({ + websiteDomain, + displayName: input.displayName ?? null, + websiteUrl: input.websiteUrl ?? null, + faviconUrl: input.faviconUrl ?? null, + updatedAt: now, + }) + .onConflictDoNothing({ target: providerVendors.websiteDomain }) + .returning({ id: providerVendors.id }); + + if (inserted[0]) { + return inserted[0].id; + } + + const fallback = await db + .select({ id: providerVendors.id }) + .from(providerVendors) + .where(eq(providerVendors.websiteDomain, websiteDomain)) + .limit(1); + if (!fallback[0]) { + throw new Error("Failed to create provider vendor"); + } + return fallback[0].id; +} + +export async function findProviderVendors( + limit: number = 50, + offset: number = 0 +): Promise { + const rows = await db + .select({ + id: providerVendors.id, + websiteDomain: providerVendors.websiteDomain, + displayName: providerVendors.displayName, + websiteUrl: providerVendors.websiteUrl, + faviconUrl: providerVendors.faviconUrl, + createdAt: providerVendors.createdAt, + updatedAt: providerVendors.updatedAt, + }) + .from(providerVendors) + .orderBy(desc(providerVendors.createdAt)) + .limit(limit) + .offset(offset); + + return rows.map(toProviderVendor); +} + +export async function findProviderVendorById(vendorId: number): Promise { + const rows = await db + .select({ + id: providerVendors.id, + websiteDomain: providerVendors.websiteDomain, + displayName: providerVendors.displayName, + websiteUrl: providerVendors.websiteUrl, + faviconUrl: providerVendors.faviconUrl, + createdAt: providerVendors.createdAt, + updatedAt: providerVendors.updatedAt, + }) + .from(providerVendors) + .where(eq(providerVendors.id, vendorId)) + .limit(1); + + return rows[0] ? toProviderVendor(rows[0]) : null; +} + +export async function findProviderEndpointById( + endpointId: number +): Promise { + const rows = await db + .select({ + id: providerEndpoints.id, + vendorId: providerEndpoints.vendorId, + providerType: providerEndpoints.providerType, + url: providerEndpoints.url, + label: providerEndpoints.label, + sortOrder: providerEndpoints.sortOrder, + isEnabled: providerEndpoints.isEnabled, + lastProbedAt: providerEndpoints.lastProbedAt, + lastProbeOk: providerEndpoints.lastProbeOk, + lastProbeStatusCode: providerEndpoints.lastProbeStatusCode, + lastProbeLatencyMs: providerEndpoints.lastProbeLatencyMs, + lastProbeErrorType: providerEndpoints.lastProbeErrorType, + lastProbeErrorMessage: providerEndpoints.lastProbeErrorMessage, + createdAt: providerEndpoints.createdAt, + updatedAt: providerEndpoints.updatedAt, + deletedAt: providerEndpoints.deletedAt, + }) + .from(providerEndpoints) + .where(and(eq(providerEndpoints.id, endpointId), isNull(providerEndpoints.deletedAt))) + .limit(1); + + return rows[0] ? toProviderEndpoint(rows[0]) : null; +} + +export async function updateProviderVendor( + vendorId: number, + payload: { displayName?: string | null; websiteUrl?: string | null; faviconUrl?: string | null } +): Promise { + if (Object.keys(payload).length === 0) { + return findProviderVendorById(vendorId); + } + + const now = new Date(); + const updates: Partial = { updatedAt: now }; + if (payload.displayName !== undefined) updates.displayName = payload.displayName; + if (payload.websiteUrl !== undefined) updates.websiteUrl = payload.websiteUrl; + if (payload.faviconUrl !== undefined) updates.faviconUrl = payload.faviconUrl; + + const rows = await db + .update(providerVendors) + .set(updates) + .where(eq(providerVendors.id, vendorId)) + .returning({ + id: providerVendors.id, + websiteDomain: providerVendors.websiteDomain, + displayName: providerVendors.displayName, + websiteUrl: providerVendors.websiteUrl, + faviconUrl: providerVendors.faviconUrl, + createdAt: providerVendors.createdAt, + updatedAt: providerVendors.updatedAt, + }); + + return rows[0] ? toProviderVendor(rows[0]) : null; +} + +export async function findProviderEndpointsByVendorAndType( + vendorId: number, + providerType: ProviderType +): Promise { + const rows = await db + .select({ + id: providerEndpoints.id, + vendorId: providerEndpoints.vendorId, + providerType: providerEndpoints.providerType, + url: providerEndpoints.url, + label: providerEndpoints.label, + sortOrder: providerEndpoints.sortOrder, + isEnabled: providerEndpoints.isEnabled, + lastProbedAt: providerEndpoints.lastProbedAt, + lastProbeOk: providerEndpoints.lastProbeOk, + lastProbeStatusCode: providerEndpoints.lastProbeStatusCode, + lastProbeLatencyMs: providerEndpoints.lastProbeLatencyMs, + lastProbeErrorType: providerEndpoints.lastProbeErrorType, + lastProbeErrorMessage: providerEndpoints.lastProbeErrorMessage, + createdAt: providerEndpoints.createdAt, + updatedAt: providerEndpoints.updatedAt, + deletedAt: providerEndpoints.deletedAt, + }) + .from(providerEndpoints) + .where( + and( + eq(providerEndpoints.vendorId, vendorId), + eq(providerEndpoints.providerType, providerType), + isNull(providerEndpoints.deletedAt) + ) + ) + .orderBy(asc(providerEndpoints.sortOrder), asc(providerEndpoints.id)); + + return rows.map(toProviderEndpoint); +} + +export async function createProviderEndpoint(payload: { + vendorId: number; + providerType: ProviderType; + url: string; + label?: string | null; + sortOrder?: number; + isEnabled?: boolean; +}): Promise { + const now = new Date(); + const [row] = await db + .insert(providerEndpoints) + .values({ + vendorId: payload.vendorId, + providerType: payload.providerType, + url: payload.url, + label: payload.label ?? null, + sortOrder: payload.sortOrder ?? 0, + isEnabled: payload.isEnabled ?? true, + updatedAt: now, + }) + .returning({ + id: providerEndpoints.id, + vendorId: providerEndpoints.vendorId, + providerType: providerEndpoints.providerType, + url: providerEndpoints.url, + label: providerEndpoints.label, + sortOrder: providerEndpoints.sortOrder, + isEnabled: providerEndpoints.isEnabled, + lastProbedAt: providerEndpoints.lastProbedAt, + lastProbeOk: providerEndpoints.lastProbeOk, + lastProbeStatusCode: providerEndpoints.lastProbeStatusCode, + lastProbeLatencyMs: providerEndpoints.lastProbeLatencyMs, + lastProbeErrorType: providerEndpoints.lastProbeErrorType, + lastProbeErrorMessage: providerEndpoints.lastProbeErrorMessage, + createdAt: providerEndpoints.createdAt, + updatedAt: providerEndpoints.updatedAt, + deletedAt: providerEndpoints.deletedAt, + }); + + return toProviderEndpoint(row); +} + +export async function updateProviderEndpoint( + endpointId: number, + payload: { url?: string; label?: string | null; sortOrder?: number; isEnabled?: boolean } +): Promise { + if (Object.keys(payload).length === 0) { + const existing = await db + .select({ + id: providerEndpoints.id, + vendorId: providerEndpoints.vendorId, + providerType: providerEndpoints.providerType, + url: providerEndpoints.url, + label: providerEndpoints.label, + sortOrder: providerEndpoints.sortOrder, + isEnabled: providerEndpoints.isEnabled, + lastProbedAt: providerEndpoints.lastProbedAt, + lastProbeOk: providerEndpoints.lastProbeOk, + lastProbeStatusCode: providerEndpoints.lastProbeStatusCode, + lastProbeLatencyMs: providerEndpoints.lastProbeLatencyMs, + lastProbeErrorType: providerEndpoints.lastProbeErrorType, + lastProbeErrorMessage: providerEndpoints.lastProbeErrorMessage, + createdAt: providerEndpoints.createdAt, + updatedAt: providerEndpoints.updatedAt, + deletedAt: providerEndpoints.deletedAt, + }) + .from(providerEndpoints) + .where(and(eq(providerEndpoints.id, endpointId), isNull(providerEndpoints.deletedAt))) + .limit(1); + + return existing[0] ? toProviderEndpoint(existing[0]) : null; + } + + const now = new Date(); + const updates: Partial = { updatedAt: now }; + if (payload.url !== undefined) updates.url = payload.url; + if (payload.label !== undefined) updates.label = payload.label; + if (payload.sortOrder !== undefined) updates.sortOrder = payload.sortOrder; + if (payload.isEnabled !== undefined) updates.isEnabled = payload.isEnabled; + + const rows = await db + .update(providerEndpoints) + .set(updates) + .where(and(eq(providerEndpoints.id, endpointId), isNull(providerEndpoints.deletedAt))) + .returning({ + id: providerEndpoints.id, + vendorId: providerEndpoints.vendorId, + providerType: providerEndpoints.providerType, + url: providerEndpoints.url, + label: providerEndpoints.label, + sortOrder: providerEndpoints.sortOrder, + isEnabled: providerEndpoints.isEnabled, + lastProbedAt: providerEndpoints.lastProbedAt, + lastProbeOk: providerEndpoints.lastProbeOk, + lastProbeStatusCode: providerEndpoints.lastProbeStatusCode, + lastProbeLatencyMs: providerEndpoints.lastProbeLatencyMs, + lastProbeErrorType: providerEndpoints.lastProbeErrorType, + lastProbeErrorMessage: providerEndpoints.lastProbeErrorMessage, + createdAt: providerEndpoints.createdAt, + updatedAt: providerEndpoints.updatedAt, + deletedAt: providerEndpoints.deletedAt, + }); + + return rows[0] ? toProviderEndpoint(rows[0]) : null; +} + +export async function softDeleteProviderEndpoint(endpointId: number): Promise { + const now = new Date(); + const rows = await db + .update(providerEndpoints) + .set({ + deletedAt: now, + isEnabled: false, + updatedAt: now, + }) + .where(and(eq(providerEndpoints.id, endpointId), isNull(providerEndpoints.deletedAt))) + .returning({ id: providerEndpoints.id }); + + return rows.length > 0; +} + +export async function recordProviderEndpointProbeResult(input: { + endpointId: number; + source: ProviderEndpointProbeSource; + ok: boolean; + statusCode?: number | null; + latencyMs?: number | null; + errorType?: string | null; + errorMessage?: string | null; + probedAt?: Date; +}): Promise { + const probedAt = input.probedAt ?? new Date(); + + await db.transaction(async (tx) => { + await tx.insert(providerEndpointProbeLogs).values({ + endpointId: input.endpointId, + source: input.source, + ok: input.ok, + statusCode: input.statusCode ?? null, + latencyMs: input.latencyMs ?? null, + errorType: input.errorType ?? null, + errorMessage: input.errorMessage ?? null, + createdAt: probedAt, + }); + + await tx + .update(providerEndpoints) + .set({ + lastProbedAt: probedAt, + lastProbeOk: input.ok, + lastProbeStatusCode: input.statusCode ?? null, + lastProbeLatencyMs: input.latencyMs ?? null, + lastProbeErrorType: input.ok ? null : (input.errorType ?? null), + lastProbeErrorMessage: input.ok ? null : (input.errorMessage ?? null), + updatedAt: new Date(), + }) + .where(and(eq(providerEndpoints.id, input.endpointId), isNull(providerEndpoints.deletedAt))); + }); +} + +export async function findProviderEndpointProbeLogs( + endpointId: number, + limit: number = 200, + offset: number = 0 +): Promise { + const rows = await db + .select({ + id: providerEndpointProbeLogs.id, + endpointId: providerEndpointProbeLogs.endpointId, + source: providerEndpointProbeLogs.source, + ok: providerEndpointProbeLogs.ok, + statusCode: providerEndpointProbeLogs.statusCode, + latencyMs: providerEndpointProbeLogs.latencyMs, + errorType: providerEndpointProbeLogs.errorType, + errorMessage: providerEndpointProbeLogs.errorMessage, + createdAt: providerEndpointProbeLogs.createdAt, + }) + .from(providerEndpointProbeLogs) + .where(eq(providerEndpointProbeLogs.endpointId, endpointId)) + .orderBy(desc(providerEndpointProbeLogs.createdAt)) + .limit(limit) + .offset(offset); + + return rows.map(toProviderEndpointProbeLog); +} diff --git a/src/repository/provider.ts b/src/repository/provider.ts index 357998331..b02e2d5d1 100644 --- a/src/repository/provider.ts +++ b/src/repository/provider.ts @@ -8,12 +8,21 @@ import { getEnvConfig } from "@/lib/config"; import { logger } from "@/lib/logger"; import type { CreateProviderData, Provider, UpdateProviderData } from "@/types/provider"; import { toProvider } from "./_shared/transformers"; +import { getOrCreateProviderVendorIdFromUrls } from "./provider-endpoints"; export async function createProvider(providerData: CreateProviderData): Promise { + const providerVendorId = await getOrCreateProviderVendorIdFromUrls({ + providerUrl: providerData.url, + websiteUrl: providerData.website_url ?? null, + faviconUrl: providerData.favicon_url ?? null, + displayName: providerData.name, + }); + const dbData = { name: providerData.name, url: providerData.url, key: providerData.key, + providerVendorId, isEnabled: providerData.is_enabled, weight: providerData.weight, priority: providerData.priority, @@ -69,6 +78,7 @@ export async function createProvider(providerData: CreateProviderData): Promise< name: providers.name, url: providers.url, key: providers.key, + providerVendorId: providers.providerVendorId, isEnabled: providers.isEnabled, weight: providers.weight, priority: providers.priority, @@ -130,6 +140,7 @@ export async function findProviderList( name: providers.name, url: providers.url, key: providers.key, + providerVendorId: providers.providerVendorId, isEnabled: providers.isEnabled, weight: providers.weight, priority: providers.priority, @@ -205,6 +216,7 @@ export async function findAllProvidersFresh(): Promise { name: providers.name, url: providers.url, key: providers.key, + providerVendorId: providers.providerVendorId, isEnabled: providers.isEnabled, weight: providers.weight, priority: providers.priority, @@ -284,6 +296,7 @@ export async function findProviderById(id: number): Promise { name: providers.name, url: providers.url, key: providers.key, + providerVendorId: providers.providerVendorId, isEnabled: providers.isEnabled, weight: providers.weight, priority: providers.priority, @@ -434,6 +447,29 @@ export async function updateProvider( if (providerData.rpd !== undefined) dbData.rpd = providerData.rpd; if (providerData.cc !== undefined) dbData.cc = providerData.cc; + if (providerData.url !== undefined || providerData.website_url !== undefined) { + const [current] = await db + .select({ + url: providers.url, + websiteUrl: providers.websiteUrl, + faviconUrl: providers.faviconUrl, + name: providers.name, + }) + .from(providers) + .where(and(eq(providers.id, id), isNull(providers.deletedAt))) + .limit(1); + + if (current) { + const providerVendorId = await getOrCreateProviderVendorIdFromUrls({ + providerUrl: providerData.url ?? current.url, + websiteUrl: providerData.website_url ?? current.websiteUrl, + faviconUrl: providerData.favicon_url ?? current.faviconUrl, + displayName: providerData.name ?? current.name, + }); + dbData.providerVendorId = providerVendorId; + } + } + const [provider] = await db .update(providers) .set(dbData) @@ -443,6 +479,7 @@ export async function updateProvider( name: providers.name, url: providers.url, key: providers.key, + providerVendorId: providers.providerVendorId, isEnabled: providers.isEnabled, weight: providers.weight, priority: providers.priority, diff --git a/src/types/provider.ts b/src/types/provider.ts index da1b5db60..661c31aa5 100644 --- a/src/types/provider.ts +++ b/src/types/provider.ts @@ -41,6 +41,8 @@ export interface Provider { name: string; url: string; key: string; + // 供应商聚合实体(按官网域名归一) + providerVendorId: number; // 是否启用 isEnabled: boolean; // 权重(0-100) @@ -155,6 +157,8 @@ export interface ProviderDisplay { groupTag: string | null; // 供应商类型 providerType: ProviderType; + // 供应商聚合实体(按官网域名归一) + providerVendorId: number; // 是否透传客户端 IP preserveClientIp: boolean; modelRedirects: Record | null; @@ -367,3 +371,46 @@ export interface UpdateProviderData { // CC (Concurrent Connections/Requests): 同一时刻能同时处理的请求数量 cc?: number | null; } + +export interface ProviderVendor { + id: number; + websiteDomain: string; + displayName: string | null; + websiteUrl: string | null; + faviconUrl: string | null; + createdAt: Date; + updatedAt: Date; +} + +export type ProviderEndpointProbeSource = "scheduled" | "manual" | "runtime"; + +export interface ProviderEndpoint { + id: number; + vendorId: number; + providerType: ProviderType; + url: string; + label: string | null; + sortOrder: number; + isEnabled: boolean; + lastProbedAt: Date | null; + lastProbeOk: boolean | null; + lastProbeStatusCode: number | null; + lastProbeLatencyMs: number | null; + lastProbeErrorType: string | null; + lastProbeErrorMessage: string | null; + createdAt: Date; + updatedAt: Date; + deletedAt: Date | null; +} + +export interface ProviderEndpointProbeLog { + id: number; + endpointId: number; + source: ProviderEndpointProbeSource; + ok: boolean; + statusCode: number | null; + latencyMs: number | null; + errorType: string | null; + errorMessage: string | null; + createdAt: Date; +} diff --git a/tests/api/api-openapi-spec.test.ts b/tests/api/api-openapi-spec.test.ts index 60d398198..9e5543a7f 100644 --- a/tests/api/api-openapi-spec.test.ts +++ b/tests/api/api-openapi-spec.test.ts @@ -194,7 +194,7 @@ describe("OpenAPI 规范验证", () => { // 端点数量会随着功能模块增长而变化:这里只做“合理范围”约束 expect(totalPaths).toBeGreaterThanOrEqual(40); - expect(totalPaths).toBeLessThanOrEqual(60); + expect(totalPaths).toBeLessThanOrEqual(80); }); test("summary 和 description 应该不同", () => { diff --git a/tests/unit/lib/endpoint-circuit-breaker.test.ts b/tests/unit/lib/endpoint-circuit-breaker.test.ts new file mode 100644 index 000000000..f45a31771 --- /dev/null +++ b/tests/unit/lib/endpoint-circuit-breaker.test.ts @@ -0,0 +1,124 @@ +import { afterEach, describe, expect, test, vi } from "vitest"; + +type SavedEndpointCircuitState = { + failureCount: number; + lastFailureTime: number | null; + circuitState: "closed" | "open" | "half-open"; + circuitOpenUntil: number | null; + halfOpenSuccessCount: number; +}; + +function createLoggerMock() { + return { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + trace: vi.fn(), + error: vi.fn(), + fatal: vi.fn(), + }; +} + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("endpoint-circuit-breaker", () => { + test("达到阈值后应打开熔断;到期后进入 half-open;成功后关闭并清零", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z")); + + vi.resetModules(); + + let redisState: SavedEndpointCircuitState | null = null; + const loadMock = vi.fn(async () => redisState); + const saveMock = vi.fn(async (_endpointId: number, state: SavedEndpointCircuitState) => { + redisState = state; + }); + const deleteMock = vi.fn(async () => { + redisState = null; + }); + + vi.doMock("@/lib/logger", () => ({ logger: createLoggerMock() })); + vi.doMock("@/lib/redis/endpoint-circuit-breaker-state", () => ({ + loadEndpointCircuitState: loadMock, + saveEndpointCircuitState: saveMock, + deleteEndpointCircuitState: deleteMock, + })); + + const { + isEndpointCircuitOpen, + recordEndpointFailure, + recordEndpointSuccess, + resetEndpointCircuit, + } = await import("@/lib/endpoint-circuit-breaker"); + + await recordEndpointFailure(1, new Error("boom")); + await recordEndpointFailure(1, new Error("boom")); + await recordEndpointFailure(1, new Error("boom")); + + const openState = saveMock.mock.calls[ + saveMock.mock.calls.length - 1 + ]?.[1] as SavedEndpointCircuitState; + expect(openState.circuitState).toBe("open"); + expect(openState.failureCount).toBe(3); + expect(openState.circuitOpenUntil).toBe(Date.now() + 300000); + + expect(await isEndpointCircuitOpen(1)).toBe(true); + + vi.advanceTimersByTime(300000 + 1); + + expect(await isEndpointCircuitOpen(1)).toBe(false); + const halfOpenState = saveMock.mock.calls[ + saveMock.mock.calls.length - 1 + ]?.[1] as SavedEndpointCircuitState; + expect(halfOpenState.circuitState).toBe("half-open"); + + await recordEndpointSuccess(1); + const closedState = saveMock.mock.calls[ + saveMock.mock.calls.length - 1 + ]?.[1] as SavedEndpointCircuitState; + expect(closedState.circuitState).toBe("closed"); + expect(closedState.failureCount).toBe(0); + expect(closedState.circuitOpenUntil).toBeNull(); + expect(closedState.lastFailureTime).toBeNull(); + expect(closedState.halfOpenSuccessCount).toBe(0); + + expect(await isEndpointCircuitOpen(1)).toBe(false); + + await resetEndpointCircuit(1); + expect(deleteMock).toHaveBeenCalledWith(1); + }); + + test("recordEndpointSuccess: closed 且 failureCount>0 时应清零", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z")); + + vi.resetModules(); + + const saveMock = vi.fn(async () => {}); + + vi.doMock("@/lib/logger", () => ({ logger: createLoggerMock() })); + vi.doMock("@/lib/redis/endpoint-circuit-breaker-state", () => ({ + loadEndpointCircuitState: vi.fn(async () => null), + saveEndpointCircuitState: saveMock, + deleteEndpointCircuitState: vi.fn(async () => {}), + })); + + const { recordEndpointFailure, recordEndpointSuccess, getEndpointHealthInfo } = await import( + "@/lib/endpoint-circuit-breaker" + ); + + await recordEndpointFailure(2, new Error("boom")); + await recordEndpointSuccess(2); + + const { health } = await getEndpointHealthInfo(2); + expect(health.failureCount).toBe(0); + expect(health.circuitState).toBe("closed"); + + const lastState = saveMock.mock.calls[ + saveMock.mock.calls.length - 1 + ]?.[1] as SavedEndpointCircuitState; + expect(lastState.failureCount).toBe(0); + }); +}); diff --git a/tests/unit/lib/provider-endpoints/endpoint-selector.test.ts b/tests/unit/lib/provider-endpoints/endpoint-selector.test.ts new file mode 100644 index 000000000..49f279fcf --- /dev/null +++ b/tests/unit/lib/provider-endpoints/endpoint-selector.test.ts @@ -0,0 +1,156 @@ +import { describe, expect, test, vi } from "vitest"; +import type { ProviderEndpoint } from "@/types/provider"; + +function makeEndpoint(overrides: Partial): ProviderEndpoint { + return { + id: 1, + vendorId: 1, + providerType: "claude", + url: "https://example.com", + label: null, + sortOrder: 0, + isEnabled: true, + lastProbedAt: null, + lastProbeOk: null, + lastProbeStatusCode: null, + lastProbeLatencyMs: null, + lastProbeErrorType: null, + lastProbeErrorMessage: null, + createdAt: new Date(0), + updatedAt: new Date(0), + deletedAt: null, + ...overrides, + }; +} + +describe("provider-endpoints: endpoint-selector", () => { + test("rankProviderEndpoints 应过滤 disabled/deleted,并按 lastProbeOk/sortOrder/latency/id 排序", async () => { + vi.resetModules(); + vi.doMock("@/repository", () => ({ + findProviderEndpointsByVendorAndType: vi.fn(), + })); + vi.doMock("@/lib/endpoint-circuit-breaker", () => ({ + isEndpointCircuitOpen: vi.fn(), + })); + + const { rankProviderEndpoints } = await import("@/lib/provider-endpoints/endpoint-selector"); + + const healthyHighOrder = makeEndpoint({ + id: 10, + lastProbeOk: true, + sortOrder: 1, + lastProbeLatencyMs: 50, + }); + const healthyLowOrder = makeEndpoint({ + id: 11, + lastProbeOk: true, + sortOrder: 0, + lastProbeLatencyMs: 999, + }); + const unknownFast = makeEndpoint({ + id: 20, + lastProbeOk: null, + sortOrder: 0, + lastProbeLatencyMs: 10, + }); + const unknownNoLatency = makeEndpoint({ + id: 21, + lastProbeOk: null, + sortOrder: 0, + lastProbeLatencyMs: null, + }); + const unhealthyFast30 = makeEndpoint({ + id: 30, + lastProbeOk: false, + sortOrder: 0, + lastProbeLatencyMs: 1, + }); + const unhealthyFast31 = makeEndpoint({ + id: 31, + lastProbeOk: false, + sortOrder: 0, + lastProbeLatencyMs: 1, + }); + const disabled = makeEndpoint({ id: 40, isEnabled: false, lastProbeOk: true }); + const deleted = makeEndpoint({ id: 41, deletedAt: new Date(1), lastProbeOk: true }); + + const ranked = rankProviderEndpoints([ + healthyHighOrder, + healthyLowOrder, + unknownFast, + unknownNoLatency, + unhealthyFast30, + unhealthyFast31, + disabled, + deleted, + ]); + + expect(ranked.map((e) => e.id)).toEqual([11, 10, 20, 21, 30, 31]); + }); + + test("getPreferredProviderEndpoints 应排除禁用/已删除/显式 exclude/熔断 open 的端点,并返回排序结果", async () => { + vi.resetModules(); + + const endpoints: ProviderEndpoint[] = [ + makeEndpoint({ id: 1, lastProbeOk: true, sortOrder: 1, lastProbeLatencyMs: 20 }), + makeEndpoint({ id: 2, lastProbeOk: true, sortOrder: 0, lastProbeLatencyMs: 999 }), + makeEndpoint({ id: 3, lastProbeOk: null, sortOrder: 0, lastProbeLatencyMs: 10 }), + makeEndpoint({ id: 4, isEnabled: false }), + makeEndpoint({ id: 5, deletedAt: new Date(1) }), + makeEndpoint({ id: 6, lastProbeOk: false, sortOrder: 0, lastProbeLatencyMs: 1 }), + ]; + + const findMock = vi.fn(async () => endpoints); + const isOpenMock = vi.fn(async (endpointId: number) => endpointId === 2); + + vi.doMock("@/repository", () => ({ + findProviderEndpointsByVendorAndType: findMock, + })); + vi.doMock("@/lib/endpoint-circuit-breaker", () => ({ + isEndpointCircuitOpen: isOpenMock, + })); + + const { getPreferredProviderEndpoints, pickBestProviderEndpoint } = await import( + "@/lib/provider-endpoints/endpoint-selector" + ); + + const result = await getPreferredProviderEndpoints({ + vendorId: 123, + providerType: "claude", + excludeEndpointIds: [6], + }); + + expect(findMock).toHaveBeenCalledWith(123, "claude"); + expect(isOpenMock.mock.calls.map((c) => c[0])).toEqual([1, 2, 3]); + expect(result.map((e) => e.id)).toEqual([1, 3]); + + const best = await pickBestProviderEndpoint({ vendorId: 123, providerType: "claude" }); + expect(best?.id).toBe(1); + }); + + test("getPreferredProviderEndpoints 过滤后无候选时返回空数组", async () => { + vi.resetModules(); + + const findMock = vi.fn(async () => [makeEndpoint({ id: 1, isEnabled: false })]); + const isOpenMock = vi.fn(async () => false); + + vi.doMock("@/repository", () => ({ + findProviderEndpointsByVendorAndType: findMock, + })); + vi.doMock("@/lib/endpoint-circuit-breaker", () => ({ + isEndpointCircuitOpen: isOpenMock, + })); + + const { getPreferredProviderEndpoints, pickBestProviderEndpoint } = await import( + "@/lib/provider-endpoints/endpoint-selector" + ); + + const result = await getPreferredProviderEndpoints({ vendorId: 1, providerType: "claude" }); + expect(result).toEqual([]); + + const best = await pickBestProviderEndpoint({ vendorId: 1, providerType: "claude" }); + expect(best).toBeNull(); + + expect(isOpenMock).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/lib/provider-endpoints/probe.test.ts b/tests/unit/lib/provider-endpoints/probe.test.ts new file mode 100644 index 000000000..d1bdf1a31 --- /dev/null +++ b/tests/unit/lib/provider-endpoints/probe.test.ts @@ -0,0 +1,292 @@ +import { afterEach, describe, expect, test, vi } from "vitest"; +import type { ProviderEndpoint } from "@/types/provider"; + +function makeEndpoint(overrides: Partial): ProviderEndpoint { + return { + id: 1, + vendorId: 1, + providerType: "claude", + url: "https://example.com", + label: null, + sortOrder: 0, + isEnabled: true, + lastProbedAt: null, + lastProbeOk: null, + lastProbeStatusCode: null, + lastProbeLatencyMs: null, + lastProbeErrorType: null, + lastProbeErrorMessage: null, + createdAt: new Date(0), + updatedAt: new Date(0), + deletedAt: null, + ...overrides, + }; +} + +afterEach(() => { + vi.unstubAllGlobals(); + vi.useRealTimers(); +}); + +describe("provider-endpoints: probe", () => { + test("probeEndpointUrl: HEAD 成功时直接返回,不触发 GET", async () => { + vi.resetModules(); + + const logger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + trace: vi.fn(), + error: vi.fn(), + fatal: vi.fn(), + }; + + vi.doMock("@/lib/logger", () => ({ logger })); + vi.doMock("@/repository", () => ({ + findProviderEndpointById: vi.fn(), + recordProviderEndpointProbeResult: vi.fn(), + })); + + const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => { + if (init?.method === "HEAD") { + return new Response(null, { status: 204 }); + } + throw new Error("unexpected"); + }); + vi.stubGlobal("fetch", fetchMock); + + const { probeEndpointUrl } = await import("@/lib/provider-endpoints/probe"); + const result = await probeEndpointUrl("https://example.com", 1234); + + expect(result).toEqual( + expect.objectContaining({ ok: true, method: "HEAD", statusCode: 204, errorType: null }) + ); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + test("probeEndpointUrl: HEAD 网络错误时回退 GET", async () => { + vi.resetModules(); + + const logger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + trace: vi.fn(), + error: vi.fn(), + fatal: vi.fn(), + }; + + vi.doMock("@/lib/logger", () => ({ logger })); + vi.doMock("@/repository", () => ({ + findProviderEndpointById: vi.fn(), + recordProviderEndpointProbeResult: vi.fn(), + })); + + const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => { + if (init?.method === "HEAD") { + throw new Error("boom"); + } + if (init?.method === "GET") { + return new Response(null, { status: 200 }); + } + throw new Error("unexpected"); + }); + vi.stubGlobal("fetch", fetchMock); + + const { probeEndpointUrl } = await import("@/lib/provider-endpoints/probe"); + const result = await probeEndpointUrl("https://example.com", 1234); + + expect(result).toEqual( + expect.objectContaining({ ok: true, method: "GET", statusCode: 200, errorType: null }) + ); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + test("probeEndpointUrl: 5xx 返回 ok=false 且标注 http_5xx", async () => { + vi.resetModules(); + + const logger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + trace: vi.fn(), + error: vi.fn(), + fatal: vi.fn(), + }; + + vi.doMock("@/lib/logger", () => ({ logger })); + vi.doMock("@/repository", () => ({ + findProviderEndpointById: vi.fn(), + recordProviderEndpointProbeResult: vi.fn(), + })); + + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response(null, { status: 503 })) + ); + + const { probeEndpointUrl } = await import("@/lib/provider-endpoints/probe"); + const result = await probeEndpointUrl("https://example.com", 1234); + + expect(result.ok).toBe(false); + expect(result.method).toBe("HEAD"); + expect(result.statusCode).toBe(503); + expect(result.errorType).toBe("http_5xx"); + expect(result.errorMessage).toBe("HTTP 503"); + }); + + test("probeEndpointUrl: 4xx 仍视为 ok=true", async () => { + vi.resetModules(); + + const logger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + trace: vi.fn(), + error: vi.fn(), + fatal: vi.fn(), + }; + + vi.doMock("@/lib/logger", () => ({ logger })); + vi.doMock("@/repository", () => ({ + findProviderEndpointById: vi.fn(), + recordProviderEndpointProbeResult: vi.fn(), + })); + + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response(null, { status: 404 })) + ); + + const { probeEndpointUrl } = await import("@/lib/provider-endpoints/probe"); + const result = await probeEndpointUrl("https://example.com", 1234); + + expect(result.ok).toBe(true); + expect(result.statusCode).toBe(404); + expect(result.errorType).toBeNull(); + }); + + test("probeEndpointUrl: AbortError 归类为 timeout", async () => { + vi.resetModules(); + + const logger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + trace: vi.fn(), + error: vi.fn(), + fatal: vi.fn(), + }; + + vi.doMock("@/lib/logger", () => ({ logger })); + vi.doMock("@/repository", () => ({ + findProviderEndpointById: vi.fn(), + recordProviderEndpointProbeResult: vi.fn(), + })); + + const fetchMock = vi.fn(async () => { + const err = new Error(""); + err.name = "AbortError"; + throw err; + }); + vi.stubGlobal("fetch", fetchMock); + + const { probeEndpointUrl } = await import("@/lib/provider-endpoints/probe"); + const result = await probeEndpointUrl("https://example.com", 1); + + expect(result.ok).toBe(false); + expect(result.method).toBe("GET"); + expect(result.statusCode).toBeNull(); + expect(result.errorType).toBe("timeout"); + expect(result.errorMessage).toBe("timeout"); + }); + + test("probeProviderEndpointAndRecord: endpoint 不存在时返回 null", async () => { + vi.resetModules(); + + const recordMock = vi.fn(async () => {}); + const findMock = vi.fn(async () => null); + + const logger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + trace: vi.fn(), + error: vi.fn(), + fatal: vi.fn(), + }; + + vi.doMock("@/lib/logger", () => ({ logger })); + vi.doMock("@/repository", () => ({ + findProviderEndpointById: findMock, + recordProviderEndpointProbeResult: recordMock, + })); + + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response(null, { status: 200 })) + ); + + const { probeProviderEndpointAndRecord } = await import("@/lib/provider-endpoints/probe"); + const result = await probeProviderEndpointAndRecord({ endpointId: 123, source: "manual" }); + + expect(result).toBeNull(); + expect(recordMock).not.toHaveBeenCalled(); + }); + + test("probeProviderEndpointAndRecord: 记录入库字段包含 source/ok/statusCode/latency/probedAt", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z")); + + vi.resetModules(); + + const recordMock = vi.fn(async () => {}); + const findMock = vi.fn(async () => makeEndpoint({ id: 123, url: "https://example.com" })); + + const logger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + trace: vi.fn(), + error: vi.fn(), + fatal: vi.fn(), + }; + + vi.doMock("@/lib/logger", () => ({ logger })); + vi.doMock("@/repository", () => ({ + findProviderEndpointById: findMock, + recordProviderEndpointProbeResult: recordMock, + })); + + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response(null, { status: 200 })) + ); + + const { probeProviderEndpointAndRecord } = await import("@/lib/provider-endpoints/probe"); + const result = await probeProviderEndpointAndRecord({ + endpointId: 123, + source: "manual", + timeoutMs: 1111, + }); + + expect(result).toEqual(expect.objectContaining({ ok: true, statusCode: 200, errorType: null })); + + expect(recordMock).toHaveBeenCalledTimes(1); + const payload = recordMock.mock.calls[0]?.[0]; + expect(payload).toEqual( + expect.objectContaining({ + endpointId: 123, + source: "manual", + ok: true, + statusCode: 200, + errorType: null, + errorMessage: null, + }) + ); + + const probedAt = (payload as { probedAt: Date }).probedAt; + expect(probedAt).toBeInstanceOf(Date); + expect(probedAt.toISOString()).toBe("2026-01-01T00:00:00.000Z"); + }); +}); diff --git a/tests/unit/lib/vendor-type-circuit-breaker.test.ts b/tests/unit/lib/vendor-type-circuit-breaker.test.ts new file mode 100644 index 000000000..8875926be --- /dev/null +++ b/tests/unit/lib/vendor-type-circuit-breaker.test.ts @@ -0,0 +1,161 @@ +import { afterEach, describe, expect, test, vi } from "vitest"; +import type { ProviderType } from "@/types/provider"; + +type SavedVendorTypeCircuitState = { + circuitState: "closed" | "open"; + circuitOpenUntil: number | null; + lastFailureTime: number | null; + manualOpen: boolean; +}; + +function createLoggerMock() { + return { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + trace: vi.fn(), + error: vi.fn(), + fatal: vi.fn(), + }; +} + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("vendor-type-circuit-breaker", () => { + test("manual open 时 isVendorTypeCircuitOpen 始终为 true,且自动 open 不应覆盖", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z")); + + vi.resetModules(); + + let redisState: SavedVendorTypeCircuitState | null = null; + const loadMock = vi.fn(async () => redisState); + const saveMock = vi.fn( + async ( + _vendorId: number, + _providerType: ProviderType, + state: SavedVendorTypeCircuitState + ) => { + redisState = state; + } + ); + const deleteMock = vi.fn(async () => { + redisState = null; + }); + + vi.doMock("@/lib/logger", () => ({ logger: createLoggerMock() })); + vi.doMock("@/lib/redis/vendor-type-circuit-breaker-state", () => ({ + loadVendorTypeCircuitState: loadMock, + saveVendorTypeCircuitState: saveMock, + deleteVendorTypeCircuitState: deleteMock, + })); + + const { + isVendorTypeCircuitOpen, + setVendorTypeCircuitManualOpen, + recordVendorTypeAllEndpointsTimeout, + getVendorTypeCircuitInfo, + } = await import("@/lib/vendor-type-circuit-breaker"); + + const vendorId = 1; + const providerType: ProviderType = "claude"; + + await setVendorTypeCircuitManualOpen(vendorId, providerType, true); + + const info = await getVendorTypeCircuitInfo(vendorId, providerType); + expect(info.manualOpen).toBe(true); + expect(info.circuitState).toBe("open"); + expect(info.circuitOpenUntil).toBeNull(); + + expect(await isVendorTypeCircuitOpen(vendorId, providerType)).toBe(true); + + await recordVendorTypeAllEndpointsTimeout(vendorId, providerType, 60000); + expect(saveMock).toHaveBeenCalledTimes(1); + }); + + test("auto open 应应用最小 1000ms,并在到期后自动关闭", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z")); + + vi.resetModules(); + + let redisState: SavedVendorTypeCircuitState | null = null; + const loadMock = vi.fn(async () => redisState); + const saveMock = vi.fn( + async ( + _vendorId: number, + _providerType: ProviderType, + state: SavedVendorTypeCircuitState + ) => { + redisState = state; + } + ); + const deleteMock = vi.fn(async () => { + redisState = null; + }); + + vi.doMock("@/lib/logger", () => ({ logger: createLoggerMock() })); + vi.doMock("@/lib/redis/vendor-type-circuit-breaker-state", () => ({ + loadVendorTypeCircuitState: loadMock, + saveVendorTypeCircuitState: saveMock, + deleteVendorTypeCircuitState: deleteMock, + })); + + const { isVendorTypeCircuitOpen, recordVendorTypeAllEndpointsTimeout } = await import( + "@/lib/vendor-type-circuit-breaker" + ); + + await recordVendorTypeAllEndpointsTimeout(2, "claude", 0); + + const openState = saveMock.mock.calls[ + saveMock.mock.calls.length - 1 + ]?.[2] as SavedVendorTypeCircuitState; + expect(openState.circuitState).toBe("open"); + expect(openState.manualOpen).toBe(false); + expect(openState.circuitOpenUntil).toBe(Date.now() + 1000); + + expect(await isVendorTypeCircuitOpen(2, "claude")).toBe(true); + + vi.advanceTimersByTime(1000 + 1); + + expect(await isVendorTypeCircuitOpen(2, "claude")).toBe(false); + + const closedState = saveMock.mock.calls[ + saveMock.mock.calls.length - 1 + ]?.[2] as SavedVendorTypeCircuitState; + expect(closedState.circuitState).toBe("closed"); + expect(closedState.circuitOpenUntil).toBeNull(); + }); + + test("resetVendorTypeCircuit 应清理缓存并删除 redis", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z")); + + vi.resetModules(); + + const deleteMock = vi.fn(async () => {}); + const loadMock = vi.fn(async () => null); + + vi.doMock("@/lib/logger", () => ({ logger: createLoggerMock() })); + vi.doMock("@/lib/redis/vendor-type-circuit-breaker-state", () => ({ + loadVendorTypeCircuitState: loadMock, + saveVendorTypeCircuitState: vi.fn(async () => {}), + deleteVendorTypeCircuitState: deleteMock, + })); + + const { isVendorTypeCircuitOpen, resetVendorTypeCircuit } = await import( + "@/lib/vendor-type-circuit-breaker" + ); + + expect(await isVendorTypeCircuitOpen(3, "claude")).toBe(false); + expect(loadMock).toHaveBeenCalledTimes(1); + + await resetVendorTypeCircuit(3, "claude"); + expect(deleteMock).toHaveBeenCalledWith(3, "claude"); + + expect(await isVendorTypeCircuitOpen(3, "claude")).toBe(false); + expect(loadMock).toHaveBeenCalledTimes(2); + }); +}); diff --git a/tests/unit/user-dialogs.test.tsx b/tests/unit/user-dialogs.test.tsx index fefd024bd..32aa965c2 100644 --- a/tests/unit/user-dialogs.test.tsx +++ b/tests/unit/user-dialogs.test.tsx @@ -192,6 +192,15 @@ const messages = { copySuccess: "Copied", copyFailed: "Copy failed", }, + ui: { + tagInput: { + emptyTag: "Empty tag", + duplicateTag: "Duplicate tag", + tooLong: "Too long", + invalidFormat: "Invalid format", + maxTags: "Too many tags", + }, + }, dashboard: { userManagement: { editDialog: { @@ -319,6 +328,10 @@ const messages = { threeMonths: "3 Months", oneYear: "1 Year", }, + providerGroupSelect: { + providersSuffix: "providers", + loadFailed: "Failed to load provider groups", + }, }, addKeyForm: { title: "Add Key", From 02ea315d765edd181d0775b2922358467e64c5d6 Mon Sep 17 00:00:00 2001 From: Haobin Ding Date: Wed, 14 Jan 2026 11:56:08 +0800 Subject: [PATCH 02/10] feat(providers): audit endpoint usage and seed endpoint pool Records the selected endpoint in provider chain and seeds/backfills provider_endpoints so endpoint routing works without manual setup. --- src/app/v1/_lib/proxy/forwarder.ts | 32 +- src/app/v1/_lib/proxy/session.ts | 6 + src/instrumentation.ts | 26 ++ src/repository/provider-endpoints.ts | 129 +++++- src/repository/provider.ts | 48 ++- src/types/message.ts | 9 + .../proxy-forwarder-endpoint-audit.test.ts | 373 ++++++++++++++++++ tests/unit/proxy/session.test.ts | 103 +++++ .../repository/provider-endpoints.test.ts | 254 ++++++++++++ 9 files changed, 969 insertions(+), 11 deletions(-) create mode 100644 tests/unit/proxy/proxy-forwarder-endpoint-audit.test.ts create mode 100644 tests/unit/repository/provider-endpoints.test.ts diff --git a/src/app/v1/_lib/proxy/forwarder.ts b/src/app/v1/_lib/proxy/forwarder.ts index bf28375b5..f456422d4 100644 --- a/src/app/v1/_lib/proxy/forwarder.ts +++ b/src/app/v1/_lib/proxy/forwarder.ts @@ -42,6 +42,7 @@ import { isEmptyResponseError, isHttp2Error, ProxyError, + sanitizeUrl, } from "./errors"; import { mapClientFormatToTransformer, mapProviderTypeToTransformer } from "./format-mapper"; import { ModelRedirector } from "./model-redirector"; @@ -286,12 +287,18 @@ export class ProxyForwarder { const endpointIndex = endpointCandidates.length > 0 ? (attemptCount - 1) % endpointCandidates.length : 0; const activeEndpoint = endpointCandidates[endpointIndex]; + const endpointAudit = { + endpointId: activeEndpoint.endpointId, + endpointUrl: sanitizeUrl(activeEndpoint.baseUrl), + }; try { const response = await ProxyForwarder.doForward( session, currentProvider, - activeEndpoint.baseUrl + activeEndpoint.baseUrl, + endpointAudit, + attemptCount ); // ========== 空响应检测(仅非流式)========== @@ -429,6 +436,7 @@ export class ProxyForwarder { // 记录到决策链 session.addProviderToChain(currentProvider, { + ...endpointAudit, reason: totalProvidersAttempted === 1 && attemptCount === 1 ? "request_success" @@ -483,6 +491,7 @@ export class ProxyForwarder { // 记录到决策链(标记为客户端中断) session.addProviderToChain(currentProvider, { + ...endpointAudit, reason: "system_error", // 使用 system_error 作为客户端中断的原因 circuitState: getCircuitState(currentProvider.id), attemptNumber: attemptCount, @@ -599,6 +608,7 @@ export class ProxyForwarder { // 记录失败的第一次请求(以 retry_failed 体现“发生过一次重试”) if (lastError instanceof ProxyError) { session.addProviderToChain(currentProvider, { + ...endpointAudit, reason: "retry_failed", circuitState: getCircuitState(currentProvider.id), attemptNumber: attemptCount, @@ -618,6 +628,7 @@ export class ProxyForwarder { }); } else { session.addProviderToChain(currentProvider, { + ...endpointAudit, reason: "retry_failed", circuitState: getCircuitState(currentProvider.id), attemptNumber: attemptCount, @@ -678,6 +689,7 @@ export class ProxyForwarder { // 记录到决策链(标记为不可重试的客户端错误) // 注意:不调用 recordFailure(),因为这不是供应商的问题,是客户端输入问题 session.addProviderToChain(currentProvider, { + ...endpointAudit, reason: "client_error_non_retryable", // 新增的 reason 值 circuitState: getCircuitState(currentProvider.id), attemptNumber: attemptCount, @@ -706,8 +718,9 @@ export class ProxyForwarder { // ⭐ 4. 系统错误处理(不计入熔断器,先重试1次当前供应商) if (errorCategory === ErrorCategory.SYSTEM_ERROR) { const err = lastError as Error & { - code?: string; - syscall?: string; + code?: string; // Node.js 错误码:如 'ENOTFOUND'、'ECONNREFUSED'、'ETIMEDOUT'、'ECONNRESET' + errno?: number; + syscall?: string; // 系统调用:如 'getaddrinfo'、'connect'、'read'、'write' }; logger.warn("ProxyForwarder: System/network error occurred", { @@ -721,6 +734,7 @@ export class ProxyForwarder { // 记录到决策链(不计入 failedProviderIds) session.addProviderToChain(currentProvider, { + ...endpointAudit, reason: "system_error", circuitState: getCircuitState(currentProvider.id), attemptNumber: attemptCount, @@ -800,6 +814,7 @@ export class ProxyForwarder { // 记录到决策链(标记为 resource_not_found,不计入熔断) session.addProviderToChain(currentProvider, { + ...endpointAudit, reason: "resource_not_found", circuitState: getCircuitState(currentProvider.id), attemptNumber: attemptCount, @@ -853,6 +868,7 @@ export class ProxyForwarder { // 记录到决策链 session.addProviderToChain(currentProvider, { + ...endpointAudit, reason: "retry_failed", circuitState: getCircuitState(currentProvider.id), attemptNumber: attemptCount, @@ -936,6 +952,7 @@ export class ProxyForwarder { // 记录到决策链 session.addProviderToChain(currentProvider, { + ...endpointAudit, reason: "retry_failed", circuitState: getCircuitState(currentProvider.id), attemptNumber: attemptCount, @@ -975,7 +992,7 @@ export class ProxyForwarder { // 加入失败列表并切换供应商 failedProviderIds.push(currentProvider.id); - break; // ⭐ 跳出内层循环,进入供应商切换逻辑 + break; // 跳出内层循环,进入供应商切换逻辑 } } } // ========== 内层循环结束 ========== @@ -1029,7 +1046,9 @@ export class ProxyForwarder { private static async doForward( session: ProxySession, provider: typeof session.provider, - baseUrl: string + baseUrl: string, + endpointAudit?: { endpointId: number | null; endpointUrl: string }, + attemptNumber?: number ): Promise { if (!provider) { throw new Error("Provider is required"); @@ -1721,9 +1740,10 @@ export class ProxyForwarder { // 记录到决策链(标记为 HTTP/2 回退) session.addProviderToChain(provider, { + ...(endpointAudit ?? { endpointId: null, endpointUrl: sanitizeUrl(baseUrl) }), reason: "http2_fallback", circuitState: getCircuitState(provider.id), - attemptNumber: 1, + attemptNumber: attemptNumber ?? 1, errorMessage: `HTTP/2 error: ${err.message}`, errorDetails: { system: { diff --git a/src/app/v1/_lib/proxy/session.ts b/src/app/v1/_lib/proxy/session.ts index 9f9366d62..484f8f21a 100644 --- a/src/app/v1/_lib/proxy/session.ts +++ b/src/app/v1/_lib/proxy/session.ts @@ -468,6 +468,8 @@ export class ProxySession { circuitState?: "closed" | "open" | "half-open"; attemptNumber?: number; errorMessage?: string; // 错误信息(失败时记录) + endpointId?: number | null; + endpointUrl?: string; // 修复:添加新字段 statusCode?: number; // 成功时的状态码 circuitFailureCount?: number; // 熔断失败计数 @@ -479,6 +481,10 @@ export class ProxySession { const item: ProviderChainItem = { id: provider.id, name: provider.name, + vendorId: provider.providerVendorId, + providerType: provider.providerType, + endpointId: metadata?.endpointId, + endpointUrl: metadata?.endpointUrl, // 元数据 reason: metadata?.reason, selectionMethod: metadata?.selectionMethod, diff --git a/src/instrumentation.ts b/src/instrumentation.ts index 572307ace..1ed389f7f 100644 --- a/src/instrumentation.ts +++ b/src/instrumentation.ts @@ -180,6 +180,19 @@ export async function register() { // 执行迁移 await runMigrations(); + // 回填 provider_endpoints(从 providers.url/类型 生成端点池,幂等) + try { + const { backfillProviderEndpointsFromProviders } = await import( + "@/repository/provider-endpoints" + ); + const result = await backfillProviderEndpointsFromProviders(); + logger.info("[Instrumentation] Provider endpoints backfill completed", result); + } catch (error) { + logger.warn("[Instrumentation] Failed to backfill provider endpoints", { + error: error instanceof Error ? error.message : String(error), + }); + } + // 初始化价格表(如果数据库为空) const { ensurePriceTable } = await import("@/lib/price-sync/seed-initializer"); await ensurePriceTable(); @@ -226,6 +239,19 @@ export async function register() { const isConnected = await checkDatabaseConnection(); if (isConnected) { await runMigrations(); + + // 回填 provider_endpoints(幂等;避免老数据缺少端点池) + try { + const { backfillProviderEndpointsFromProviders } = await import( + "@/repository/provider-endpoints" + ); + const result = await backfillProviderEndpointsFromProviders(); + logger.info("[Instrumentation] Provider endpoints backfill completed", result); + } catch (error) { + logger.warn("[Instrumentation] Failed to backfill provider endpoints", { + error: error instanceof Error ? error.message : String(error), + }); + } } else { logger.warn("Database connection failed, skipping migrations"); } diff --git a/src/repository/provider-endpoints.ts b/src/repository/provider-endpoints.ts index e3fbd70d7..54c971427 100644 --- a/src/repository/provider-endpoints.ts +++ b/src/repository/provider-endpoints.ts @@ -1,8 +1,13 @@ "use server"; -import { and, asc, desc, eq, isNull } from "drizzle-orm"; +import { and, asc, desc, eq, gt, isNull } from "drizzle-orm"; import { db } from "@/drizzle/db"; -import { providerEndpointProbeLogs, providerEndpoints, providerVendors } from "@/drizzle/schema"; +import { + providerEndpointProbeLogs, + providerEndpoints, + providers, + providerVendors, +} from "@/drizzle/schema"; import { logger } from "@/lib/logger"; import type { ProviderEndpoint, @@ -323,6 +328,126 @@ export async function createProviderEndpoint(payload: { return toProviderEndpoint(row); } +export async function ensureProviderEndpointExistsForUrl(input: { + vendorId: number; + providerType: ProviderType; + url: string; + label?: string | null; +}): Promise { + const trimmedUrl = input.url.trim(); + if (!trimmedUrl) { + return false; + } + + try { + // eslint-disable-next-line no-new + new URL(trimmedUrl); + } catch { + return false; + } + + const now = new Date(); + const inserted = await db + .insert(providerEndpoints) + .values({ + vendorId: input.vendorId, + providerType: input.providerType, + url: trimmedUrl, + label: input.label ?? null, + updatedAt: now, + }) + .onConflictDoNothing({ + target: [providerEndpoints.vendorId, providerEndpoints.providerType, providerEndpoints.url], + }) + .returning({ id: providerEndpoints.id }); + + return inserted.length > 0; +} + +export async function backfillProviderEndpointsFromProviders(): Promise<{ + inserted: number; + uniqueCandidates: number; + skippedInvalid: number; +}> { + const pageSize = 1000; + const insertBatchSize = 500; + + let lastProviderId = 0; + let skippedInvalid = 0; + let insertedTotal = 0; + const seen = new Set(); + const pending: Array<{ vendorId: number; providerType: ProviderType; url: string }> = []; + + const flush = async (): Promise => { + if (pending.length === 0) return; + const now = new Date(); + const inserted = await db + .insert(providerEndpoints) + .values(pending.map((value) => ({ ...value, updatedAt: now }))) + .onConflictDoNothing({ + target: [providerEndpoints.vendorId, providerEndpoints.providerType, providerEndpoints.url], + }) + .returning({ id: providerEndpoints.id }); + insertedTotal += inserted.length; + pending.length = 0; + }; + + while (true) { + const rows = await db + .select({ + id: providers.id, + vendorId: providers.providerVendorId, + providerType: providers.providerType, + url: providers.url, + }) + .from(providers) + .where(and(isNull(providers.deletedAt), gt(providers.id, lastProviderId))) + .orderBy(asc(providers.id)) + .limit(pageSize); + + if (rows.length === 0) { + break; + } + + for (const row of rows) { + lastProviderId = Math.max(lastProviderId, row.id); + + if (!row.vendorId || row.vendorId <= 0) { + skippedInvalid++; + continue; + } + + const trimmedUrl = row.url.trim(); + if (!trimmedUrl) { + skippedInvalid++; + continue; + } + + try { + // eslint-disable-next-line no-new + new URL(trimmedUrl); + } catch { + skippedInvalid++; + continue; + } + + const key = `${row.vendorId}|${row.providerType}|${trimmedUrl}`; + if (seen.has(key)) { + continue; + } + seen.add(key); + pending.push({ vendorId: row.vendorId, providerType: row.providerType, url: trimmedUrl }); + + if (pending.length >= insertBatchSize) { + await flush(); + } + } + } + + await flush(); + return { inserted: insertedTotal, uniqueCandidates: seen.size, skippedInvalid }; +} + export async function updateProviderEndpoint( endpointId: number, payload: { url?: string; label?: string | null; sortOrder?: number; isEnabled?: boolean } diff --git a/src/repository/provider.ts b/src/repository/provider.ts index b02e2d5d1..ce8082d66 100644 --- a/src/repository/provider.ts +++ b/src/repository/provider.ts @@ -8,7 +8,10 @@ import { getEnvConfig } from "@/lib/config"; import { logger } from "@/lib/logger"; import type { CreateProviderData, Provider, UpdateProviderData } from "@/types/provider"; import { toProvider } from "./_shared/transformers"; -import { getOrCreateProviderVendorIdFromUrls } from "./provider-endpoints"; +import { + ensureProviderEndpointExistsForUrl, + getOrCreateProviderVendorIdFromUrls, +} from "./provider-endpoints"; export async function createProvider(providerData: CreateProviderData): Promise { const providerVendorId = await getOrCreateProviderVendorIdFromUrls({ @@ -127,7 +130,23 @@ export async function createProvider(providerData: CreateProviderData): Promise< deletedAt: providers.deletedAt, }); - return toProvider(provider); + const created = toProvider(provider); + + try { + await ensureProviderEndpointExistsForUrl({ + vendorId: created.providerVendorId, + providerType: created.providerType, + url: created.url, + }); + } catch (error) { + logger.warn("[Provider] Failed to seed provider endpoint from provider.url", { + providerVendorId, + providerType: created.providerType, + error: error instanceof Error ? error.message : String(error), + }); + } + + return created; } export async function findProviderList( @@ -529,7 +548,30 @@ export async function updateProvider( }); if (!provider) return null; - return toProvider(provider); + const transformed = toProvider(provider); + + if ( + providerData.url !== undefined || + providerData.provider_type !== undefined || + providerData.website_url !== undefined + ) { + try { + await ensureProviderEndpointExistsForUrl({ + vendorId: transformed.providerVendorId, + providerType: transformed.providerType, + url: transformed.url, + }); + } catch (error) { + logger.warn("[Provider] Failed to seed provider endpoint after provider update", { + providerId: transformed.id, + providerVendorId: transformed.providerVendorId, + providerType: transformed.providerType, + error: error instanceof Error ? error.message : String(error), + }); + } + } + + return transformed; } export async function updateProviderPrioritiesBatch( diff --git a/src/types/message.ts b/src/types/message.ts index 3be02d63a..e5b20b62b 100644 --- a/src/types/message.ts +++ b/src/types/message.ts @@ -1,5 +1,6 @@ import type { Numeric } from "decimal.js-light"; import type { CacheTtlApplied } from "./cache"; +import type { ProviderType } from "./provider"; import type { SpecialSetting } from "./special-settings"; /** @@ -10,6 +11,14 @@ export interface ProviderChainItem { id: number; name: string; + // 供应商维度(便于日志审计,无需额外 join) + vendorId?: number; + providerType?: ProviderType; + + // 端点维度(记录本次请求实际使用的 baseUrl) + endpointId?: number | null; + endpointUrl?: string; + // === 选择原因(细化) === reason?: | "session_reuse" // 会话复用 diff --git a/tests/unit/proxy/proxy-forwarder-endpoint-audit.test.ts b/tests/unit/proxy/proxy-forwarder-endpoint-audit.test.ts new file mode 100644 index 000000000..368f67f17 --- /dev/null +++ b/tests/unit/proxy/proxy-forwarder-endpoint-audit.test.ts @@ -0,0 +1,373 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; + +const mocks = vi.hoisted(() => { + return { + getPreferredProviderEndpoints: vi.fn(), + recordEndpointSuccess: vi.fn(async () => {}), + recordEndpointFailure: vi.fn(async () => {}), + recordSuccess: vi.fn(), + recordFailure: vi.fn(async () => {}), + getCircuitState: vi.fn(() => "closed"), + getProviderHealthInfo: vi.fn(async () => ({ + health: { failureCount: 0 }, + config: { failureThreshold: 3 }, + })), + isVendorTypeCircuitOpen: vi.fn(async () => false), + recordVendorTypeAllEndpointsTimeout: vi.fn(async () => {}), + }; +}); + +vi.mock("@/lib/logger", () => ({ + logger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + trace: vi.fn(), + error: vi.fn(), + fatal: vi.fn(), + }, +})); + +vi.mock("@/lib/provider-endpoints/endpoint-selector", () => ({ + getPreferredProviderEndpoints: mocks.getPreferredProviderEndpoints, +})); + +vi.mock("@/lib/endpoint-circuit-breaker", () => ({ + recordEndpointSuccess: mocks.recordEndpointSuccess, + recordEndpointFailure: mocks.recordEndpointFailure, +})); + +vi.mock("@/lib/circuit-breaker", () => ({ + getCircuitState: mocks.getCircuitState, + getProviderHealthInfo: mocks.getProviderHealthInfo, + recordSuccess: mocks.recordSuccess, + recordFailure: mocks.recordFailure, +})); + +vi.mock("@/lib/vendor-type-circuit-breaker", () => ({ + isVendorTypeCircuitOpen: mocks.isVendorTypeCircuitOpen, + recordVendorTypeAllEndpointsTimeout: mocks.recordVendorTypeAllEndpointsTimeout, +})); + +vi.mock("@/app/v1/_lib/proxy/errors", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + categorizeErrorAsync: vi.fn(async () => actual.ErrorCategory.PROVIDER_ERROR), + }; +}); + +import { ProxyForwarder } from "@/app/v1/_lib/proxy/forwarder"; +import { ProxyError } from "@/app/v1/_lib/proxy/errors"; +import { ProxySession } from "@/app/v1/_lib/proxy/session"; +import type { Provider, ProviderEndpoint, ProviderType } from "@/types/provider"; + +function makeEndpoint(input: { + id: number; + vendorId: number; + providerType: ProviderType; + url: string; +}): ProviderEndpoint { + const now = new Date("2026-01-01T00:00:00.000Z"); + return { + id: input.id, + vendorId: input.vendorId, + providerType: input.providerType, + url: input.url, + label: null, + sortOrder: 0, + isEnabled: true, + lastProbedAt: null, + lastProbeOk: null, + lastProbeStatusCode: null, + lastProbeLatencyMs: null, + lastProbeErrorType: null, + lastProbeErrorMessage: null, + createdAt: now, + updatedAt: now, + deletedAt: null, + }; +} + +function createProvider(overrides: Partial = {}): Provider { + return { + id: 1, + name: "p1", + url: "https://provider.example.com", + key: "k", + providerVendorId: 123, + isEnabled: true, + weight: 1, + priority: 0, + costMultiplier: 1, + groupTag: null, + providerType: "claude", + preserveClientIp: false, + modelRedirects: null, + allowedModels: null, + joinClaudePool: false, + codexInstructionsStrategy: "auto", + mcpPassthroughType: "none", + mcpPassthroughUrl: null, + limit5hUsd: null, + limitDailyUsd: null, + dailyResetMode: "fixed", + dailyResetTime: "00:00", + limitWeeklyUsd: null, + limitMonthlyUsd: null, + limitTotalUsd: null, + totalCostResetAt: null, + limitConcurrentSessions: 0, + maxRetryAttempts: null, + circuitBreakerFailureThreshold: 5, + circuitBreakerOpenDuration: 1_800_000, + circuitBreakerHalfOpenSuccessThreshold: 2, + proxyUrl: null, + proxyFallbackToDirect: false, + firstByteTimeoutStreamingMs: 30_000, + streamingIdleTimeoutMs: 10_000, + requestTimeoutNonStreamingMs: 600_000, + websiteUrl: null, + faviconUrl: null, + cacheTtlPreference: null, + context1mPreference: null, + codexReasoningEffortPreference: null, + codexReasoningSummaryPreference: null, + codexTextVerbosityPreference: null, + codexParallelToolCallsPreference: null, + tpm: 0, + rpm: 0, + rpd: 0, + cc: 0, + createdAt: new Date(), + updatedAt: new Date(), + deletedAt: null, + ...overrides, + }; +} + +function createSession(requestUrl: URL = new URL("https://example.com/v1/messages")): ProxySession { + const headers = new Headers(); + const session = Object.create(ProxySession.prototype); + + Object.assign(session, { + startTime: Date.now(), + method: "POST", + requestUrl, + headers, + originalHeaders: new Headers(headers), + headerLog: JSON.stringify(Object.fromEntries(headers.entries())), + request: { + model: "model-x", + log: "(test)", + message: { + model: "model-x", + messages: [ + { role: "user", content: "hello" }, + { role: "assistant", content: "ok" }, + ], + }, + }, + userAgent: null, + context: null, + clientAbortSignal: null, + userName: "test-user", + authState: { success: true, user: null, key: null, apiKey: null }, + provider: null, + messageContext: null, + sessionId: null, + requestSequence: 1, + originalFormat: "claude", + providerType: null, + originalModelName: null, + originalUrlPathname: null, + providerChain: [], + cacheTtlResolved: null, + context1mApplied: false, + specialSettings: [], + cachedPriceData: undefined, + cachedBillingModelSource: undefined, + isHeaderModified: () => false, + }); + + return session as ProxySession; +} + +describe("ProxyForwarder - endpoint audit", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test("成功时应记录 endpointId 且对 endpointUrl 做脱敏", async () => { + const session = createSession(); + const provider = createProvider({ providerType: "claude", providerVendorId: 123 }); + session.setProvider(provider); + + mocks.getPreferredProviderEndpoints.mockResolvedValue([ + makeEndpoint({ + id: 42, + vendorId: 123, + providerType: provider.providerType, + url: "https://api.example.com/v1/messages?api_key=SECRET&foo=bar", + }), + ]); + + const doForward = vi.spyOn( + ProxyForwarder as unknown as { doForward: (...args: unknown[]) => unknown }, + "doForward" + ); + doForward.mockResolvedValueOnce( + new Response("{}", { + status: 200, + headers: { + "content-type": "application/json", + "content-length": "2", + }, + }) + ); + + const response = await ProxyForwarder.send(session); + expect(response.status).toBe(200); + + const chain = session.getProviderChain(); + expect(chain).toHaveLength(1); + + const item = chain[0]; + expect(item).toEqual( + expect.objectContaining({ + reason: "request_success", + attemptNumber: 1, + statusCode: 200, + vendorId: 123, + providerType: "claude", + endpointId: 42, + }) + ); + + expect(item.endpointUrl).toContain("[REDACTED]"); + expect(item.endpointUrl).not.toContain("SECRET"); + }); + + test("重试时应分别记录每次 attempt 的 endpoint 审计字段", async () => { + vi.useFakeTimers(); + + try { + const session = createSession(new URL("https://example.com/v1/chat/completions")); + const provider = createProvider({ + providerType: "openai-compatible", + providerVendorId: 123, + }); + session.setProvider(provider); + + mocks.getPreferredProviderEndpoints.mockResolvedValue([ + makeEndpoint({ + id: 1, + vendorId: 123, + providerType: provider.providerType, + url: "https://api.example.com/v1?token=SECRET_1", + }), + makeEndpoint({ + id: 2, + vendorId: 123, + providerType: provider.providerType, + url: "https://api.example.com/v1?api_key=SECRET_2", + }), + ]); + + const doForward = vi.spyOn( + ProxyForwarder as unknown as { doForward: (...args: unknown[]) => unknown }, + "doForward" + ); + doForward.mockImplementationOnce(async () => { + throw new ProxyError("boom", 500); + }); + doForward.mockResolvedValueOnce( + new Response("{}", { + status: 200, + headers: { + "content-type": "application/json", + "content-length": "2", + }, + }) + ); + + const sendPromise = ProxyForwarder.send(session); + await vi.advanceTimersByTimeAsync(100); + const response = await sendPromise; + expect(response.status).toBe(200); + + const chain = session.getProviderChain(); + expect(chain).toHaveLength(2); + + const first = chain[0]; + const second = chain[1]; + + expect(first).toEqual( + expect.objectContaining({ + reason: "retry_failed", + attemptNumber: 1, + vendorId: 123, + providerType: "openai-compatible", + endpointId: 1, + }) + ); + expect(first.endpointUrl).toContain("[REDACTED]"); + expect(first.endpointUrl).not.toContain("SECRET_1"); + + expect(second).toEqual( + expect.objectContaining({ + reason: "retry_success", + attemptNumber: 2, + vendorId: 123, + providerType: "openai-compatible", + endpointId: 2, + }) + ); + expect(second.endpointUrl).toContain("[REDACTED]"); + expect(second.endpointUrl).not.toContain("SECRET_2"); + } finally { + vi.useRealTimers(); + } + }); + + test("endpoint 选择失败时应回退到 provider.url,并记录 endpointId=null", async () => { + const session = createSession(); + const provider = createProvider({ + providerType: "claude", + providerVendorId: 123, + url: "https://provider.example.com/v1/messages?key=SECRET", + }); + session.setProvider(provider); + + mocks.getPreferredProviderEndpoints.mockRejectedValue(new Error("boom")); + + const doForward = vi.spyOn( + ProxyForwarder as unknown as { doForward: (...args: unknown[]) => unknown }, + "doForward" + ); + doForward.mockResolvedValueOnce( + new Response("{}", { + status: 200, + headers: { + "content-type": "application/json", + "content-length": "2", + }, + }) + ); + + const response = await ProxyForwarder.send(session); + expect(response.status).toBe(200); + + const chain = session.getProviderChain(); + expect(chain).toHaveLength(1); + + const item = chain[0]; + expect(item).toEqual( + expect.objectContaining({ + endpointId: null, + }) + ); + expect(item.endpointUrl).toContain("[REDACTED]"); + expect(item.endpointUrl).not.toContain("SECRET"); + }); +}); diff --git a/tests/unit/proxy/session.test.ts b/tests/unit/proxy/session.test.ts index c72eb2ed3..b247a1415 100644 --- a/tests/unit/proxy/session.test.ts +++ b/tests/unit/proxy/session.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from "vitest"; import type { ModelPrice, ModelPriceData } from "@/types/model-price"; import type { SystemSettings } from "@/types/system-config"; +import type { Provider } from "@/types/provider"; vi.mock("@/repository/model-price", () => ({ findLatestPriceByModel: vi.fn(), @@ -675,3 +676,105 @@ describe("ProxySession.isWarmupRequest", () => { expect(cacheControlNotObject.isWarmupRequest()).toBe(false); }); }); + +describe("ProxySession.addProviderToChain - endpoint audit", () => { + it("应写入 vendorId/providerType/endpointId/endpointUrl", () => { + const session = createSession({ redirectedModel: null }); + const provider = { + id: 1, + name: "p1", + providerVendorId: 123, + providerType: "claude", + priority: 0, + weight: 1, + costMultiplier: 1, + groupTag: null, + } as unknown as Provider; + + session.addProviderToChain(provider, { + endpointId: 42, + endpointUrl: "https://api.example.com", + }); + + const chain = session.getProviderChain(); + expect(chain).toHaveLength(1); + expect(chain[0]).toEqual( + expect.objectContaining({ + id: 1, + name: "p1", + vendorId: 123, + providerType: "claude", + endpointId: 42, + endpointUrl: "https://api.example.com", + }) + ); + }); + + it("同一 provider 连续写入且不带 attemptNumber 时应去重", () => { + const session = createSession({ redirectedModel: null }); + const provider = { + id: 1, + name: "p1", + providerVendorId: 123, + providerType: "claude", + priority: 0, + weight: 1, + costMultiplier: 1, + groupTag: null, + } as unknown as Provider; + + session.addProviderToChain(provider, { endpointId: 1, endpointUrl: "https://a.example.com" }); + session.addProviderToChain(provider, { endpointId: 2, endpointUrl: "https://b.example.com" }); + + const chain = session.getProviderChain(); + expect(chain).toHaveLength(1); + expect(chain[0]).toEqual( + expect.objectContaining({ + endpointId: 1, + endpointUrl: "https://a.example.com", + }) + ); + }); + + it("同一 provider 连续写入且带 attemptNumber 时应保留多条", () => { + const session = createSession({ redirectedModel: null }); + const provider = { + id: 1, + name: "p1", + providerVendorId: 123, + providerType: "claude", + priority: 0, + weight: 1, + costMultiplier: 1, + groupTag: null, + } as unknown as Provider; + + session.addProviderToChain(provider, { + attemptNumber: 1, + endpointId: 1, + endpointUrl: "https://a.example.com", + }); + session.addProviderToChain(provider, { + attemptNumber: 2, + endpointId: 2, + endpointUrl: "https://b.example.com", + }); + + const chain = session.getProviderChain(); + expect(chain).toHaveLength(2); + expect(chain[0]).toEqual( + expect.objectContaining({ + attemptNumber: 1, + endpointId: 1, + endpointUrl: "https://a.example.com", + }) + ); + expect(chain[1]).toEqual( + expect.objectContaining({ + attemptNumber: 2, + endpointId: 2, + endpointUrl: "https://b.example.com", + }) + ); + }); +}); diff --git a/tests/unit/repository/provider-endpoints.test.ts b/tests/unit/repository/provider-endpoints.test.ts new file mode 100644 index 000000000..352402924 --- /dev/null +++ b/tests/unit/repository/provider-endpoints.test.ts @@ -0,0 +1,254 @@ +import { describe, expect, test, vi } from "vitest"; + +function createThenableQuery(result: T) { + type Query = Promise & { + from: (...args: unknown[]) => Query; + where: (...args: unknown[]) => Query; + orderBy: (...args: unknown[]) => Query; + limit: (...args: unknown[]) => Query; + }; + + const query = Promise.resolve(result) as unknown as Query; + query.from = () => query; + query.where = () => query; + query.orderBy = () => query; + query.limit = () => query; + return query; +} + +describe("provider-endpoints repository", () => { + test("ensureProviderEndpointExistsForUrl: url 为空时返回 false 且不写 DB", async () => { + vi.resetModules(); + + const insertMock = vi.fn(); + vi.doMock("@/drizzle/db", () => ({ + db: { + insert: insertMock, + }, + })); + + const { ensureProviderEndpointExistsForUrl } = await import("@/repository/provider-endpoints"); + const ok = await ensureProviderEndpointExistsForUrl({ + vendorId: 1, + providerType: "claude", + url: " ", + }); + + expect(ok).toBe(false); + expect(insertMock).not.toHaveBeenCalled(); + }); + + test("ensureProviderEndpointExistsForUrl: url 非法时返回 false 且不写 DB", async () => { + vi.resetModules(); + + const insertMock = vi.fn(); + vi.doMock("@/drizzle/db", () => ({ + db: { + insert: insertMock, + }, + })); + + const { ensureProviderEndpointExistsForUrl } = await import("@/repository/provider-endpoints"); + const ok = await ensureProviderEndpointExistsForUrl({ + vendorId: 1, + providerType: "claude", + url: "not a url", + }); + + expect(ok).toBe(false); + expect(insertMock).not.toHaveBeenCalled(); + }); + + test("ensureProviderEndpointExistsForUrl: 插入成功时返回 true(trim + label=null)", async () => { + vi.resetModules(); + + const state = { values: undefined as unknown }; + const returning = vi.fn(async () => [{ id: 1 }]); + const onConflictDoNothing = vi.fn(() => ({ returning })); + const values = vi.fn((payload: unknown) => { + state.values = payload; + return { onConflictDoNothing }; + }); + const insertMock = vi.fn(() => ({ values })); + + vi.doMock("@/drizzle/db", () => ({ + db: { + insert: insertMock, + }, + })); + + const { ensureProviderEndpointExistsForUrl } = await import("@/repository/provider-endpoints"); + const ok = await ensureProviderEndpointExistsForUrl({ + vendorId: 1, + providerType: "claude", + url: " https://api.example.com ", + }); + + expect(ok).toBe(true); + expect(insertMock).toHaveBeenCalledTimes(1); + expect(values).toHaveBeenCalledTimes(1); + + expect(state.values).toEqual( + expect.objectContaining({ + vendorId: 1, + providerType: "claude", + url: "https://api.example.com", + label: null, + }) + ); + }); + + test("ensureProviderEndpointExistsForUrl: 冲突不插入时返回 false", async () => { + vi.resetModules(); + + const returning = vi.fn(async () => []); + const onConflictDoNothing = vi.fn(() => ({ returning })); + const values = vi.fn(() => ({ onConflictDoNothing })); + const insertMock = vi.fn(() => ({ values })); + + vi.doMock("@/drizzle/db", () => ({ + db: { + insert: insertMock, + }, + })); + + const { ensureProviderEndpointExistsForUrl } = await import("@/repository/provider-endpoints"); + const ok = await ensureProviderEndpointExistsForUrl({ + vendorId: 1, + providerType: "claude", + url: "https://api.example.com", + }); + + expect(ok).toBe(false); + }); + + test("backfillProviderEndpointsFromProviders: 全部无效时不写 DB", async () => { + vi.resetModules(); + + const selectPages = [ + [ + { id: 1, vendorId: 0, providerType: "claude", url: "https://ok.example.com" }, + { id: 2, vendorId: 1, providerType: "claude", url: " " }, + { id: 3, vendorId: 1, providerType: "claude", url: "not a url" }, + ], + [], + ]; + + const selectMock = vi.fn(() => createThenableQuery(selectPages.shift() ?? [])); + const insertMock = vi.fn(); + + vi.doMock("@/drizzle/db", () => ({ + db: { + select: selectMock, + insert: insertMock, + }, + })); + + const { backfillProviderEndpointsFromProviders } = await import( + "@/repository/provider-endpoints" + ); + const result = await backfillProviderEndpointsFromProviders(); + + expect(result).toEqual({ inserted: 0, uniqueCandidates: 0, skippedInvalid: 3 }); + expect(insertMock).not.toHaveBeenCalled(); + }); + + test("backfillProviderEndpointsFromProviders: 去重 + trim + 统计 inserted/uniqueCandidates/skippedInvalid", async () => { + vi.resetModules(); + + const capturedValues: unknown[] = []; + + const insertState = { values: undefined as unknown }; + const returning = vi.fn(async () => { + const values = insertState.values; + if (!Array.isArray(values)) return []; + return values.map((_, idx) => ({ id: idx + 1 })); + }); + const onConflictDoNothing = vi.fn(() => ({ returning })); + const values = vi.fn((payload: unknown) => { + insertState.values = payload; + if (Array.isArray(payload)) capturedValues.push(...payload); + return { onConflictDoNothing }; + }); + const insertMock = vi.fn(() => ({ values })); + + const selectPages = [ + [ + { id: 1, vendorId: 1, providerType: "claude", url: " https://a.com " }, + { id: 2, vendorId: 1, providerType: "claude", url: "https://a.com" }, + { id: 3, vendorId: 1, providerType: "openai-compatible", url: "https://a.com" }, + ], + [ + { id: 4, vendorId: 2, providerType: "claude", url: "https://a.com" }, + { id: 5, vendorId: 0, providerType: "claude", url: "https://bad-vendor.com" }, + { id: 6, vendorId: 1, providerType: "claude", url: "not a url" }, + ], + [], + ]; + + const selectMock = vi.fn(() => createThenableQuery(selectPages.shift() ?? [])); + + vi.doMock("@/drizzle/db", () => ({ + db: { + select: selectMock, + insert: insertMock, + }, + })); + + const { backfillProviderEndpointsFromProviders } = await import( + "@/repository/provider-endpoints" + ); + const result = await backfillProviderEndpointsFromProviders(); + + expect(result).toEqual({ inserted: 3, uniqueCandidates: 3, skippedInvalid: 2 }); + + expect(capturedValues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ vendorId: 1, providerType: "claude", url: "https://a.com" }), + expect.objectContaining({ + vendorId: 1, + providerType: "openai-compatible", + url: "https://a.com", + }), + expect.objectContaining({ vendorId: 2, providerType: "claude", url: "https://a.com" }), + ]) + ); + }); + + test("backfillProviderEndpointsFromProviders: 冲突不插入时 inserted=0 但 uniqueCandidates 仍统计", async () => { + vi.resetModules(); + + const insertState = { values: undefined as unknown }; + const returning = vi.fn(async () => []); + const onConflictDoNothing = vi.fn(() => ({ returning })); + const values = vi.fn((payload: unknown) => { + insertState.values = payload; + return { onConflictDoNothing }; + }); + const insertMock = vi.fn(() => ({ values })); + + const selectPages = [ + [ + { id: 1, vendorId: 1, providerType: "claude", url: "https://a.com" }, + { id: 2, vendorId: 1, providerType: "openai-compatible", url: "https://a.com" }, + ], + [], + ]; + + const selectMock = vi.fn(() => createThenableQuery(selectPages.shift() ?? [])); + + vi.doMock("@/drizzle/db", () => ({ + db: { + select: selectMock, + insert: insertMock, + }, + })); + + const { backfillProviderEndpointsFromProviders } = await import( + "@/repository/provider-endpoints" + ); + const result = await backfillProviderEndpointsFromProviders(); + + expect(result).toEqual({ inserted: 0, uniqueCandidates: 2, skippedInvalid: 0 }); + }); +}); From 5d3a02ba69d0094fba0a672288689eab599e68c8 Mon Sep 17 00:00:00 2001 From: Ding Date: Wed, 14 Jan 2026 18:01:43 +0800 Subject: [PATCH 03/10] fix(migrations): support legacy provider_vendors vendor_key Regenerates the 0054 migration and adds conditional backfill/insert logic so migrations succeed when provider_vendors has a NOT NULL vendor_key column. --- drizzle/meta/0054_snapshot.json | 490 +++++++++++++++++++++++++++++++- drizzle/meta/_journal.json | 11 +- 2 files changed, 490 insertions(+), 11 deletions(-) diff --git a/drizzle/meta/0054_snapshot.json b/drizzle/meta/0054_snapshot.json index 2ea76fc77..65c978784 100644 --- a/drizzle/meta/0054_snapshot.json +++ b/drizzle/meta/0054_snapshot.json @@ -1,5 +1,5 @@ { - "id": "36887729-08df-4af3-98fe-d4fa87c7c5c7", + "id": "6b4f6d92-6765-4cf3-8ee2-d4d33a934ff2", "prevId": "3d8f6ad1-ff20-411e-87a0-78476ee22dd3", "version": "7", "dialect": "postgresql", @@ -1150,6 +1150,450 @@ "checkConstraints": {}, "isRLSEnabled": false }, + "public.provider_endpoint_probe_logs": { + "name": "provider_endpoint_probe_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "endpoint_id": { + "name": "endpoint_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'scheduled'" + }, + "ok": { + "name": "ok", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "latency_ms": { + "name": "latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "error_type": { + "name": "error_type", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_provider_endpoint_probe_logs_endpoint_created_at": { + "name": "idx_provider_endpoint_probe_logs_endpoint_created_at", + "columns": [ + { + "expression": "endpoint_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoint_probe_logs_created_at": { + "name": "idx_provider_endpoint_probe_logs_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "provider_endpoint_probe_logs_endpoint_id_provider_endpoints_id_fk": { + "name": "provider_endpoint_probe_logs_endpoint_id_provider_endpoints_id_fk", + "tableFrom": "provider_endpoint_probe_logs", + "tableTo": "provider_endpoints", + "columnsFrom": [ + "endpoint_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.provider_endpoints": { + "name": "provider_endpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "vendor_id": { + "name": "vendor_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "provider_type": { + "name": "provider_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'claude'" + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_probed_at": { + "name": "last_probed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_probe_ok": { + "name": "last_probe_ok", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "last_probe_status_code": { + "name": "last_probe_status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_probe_latency_ms": { + "name": "last_probe_latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_probe_error_type": { + "name": "last_probe_error_type", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "last_probe_error_message": { + "name": "last_probe_error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "uniq_provider_endpoints_vendor_type_url": { + "name": "uniq_provider_endpoints_vendor_type_url", + "columns": [ + { + "expression": "vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoints_vendor_type": { + "name": "idx_provider_endpoints_vendor_type", + "columns": [ + { + "expression": "vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"provider_endpoints\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoints_enabled": { + "name": "idx_provider_endpoints_enabled", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"provider_endpoints\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoints_created_at": { + "name": "idx_provider_endpoints_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoints_deleted_at": { + "name": "idx_provider_endpoints_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "provider_endpoints_vendor_id_provider_vendors_id_fk": { + "name": "provider_endpoints_vendor_id_provider_vendors_id_fk", + "tableFrom": "provider_endpoints", + "tableTo": "provider_vendors", + "columnsFrom": [ + "vendor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.provider_vendors": { + "name": "provider_vendors", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "website_domain": { + "name": "website_domain", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "website_url": { + "name": "website_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "favicon_url": { + "name": "favicon_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "uniq_provider_vendors_website_domain": { + "name": "uniq_provider_vendors_website_domain", + "columns": [ + { + "expression": "website_domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_vendors_created_at": { + "name": "idx_provider_vendors_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, "public.providers": { "name": "providers", "schema": "", @@ -1184,6 +1628,12 @@ "primaryKey": false, "notNull": true }, + "provider_vendor_id": { + "name": "provider_vendor_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, "is_enabled": { "name": "is_enabled", "type": "boolean", @@ -1562,9 +2012,45 @@ "concurrently": false, "method": "btree", "with": {} + }, + "idx_providers_vendor_type": { + "name": "idx_providers_vendor_type", + "columns": [ + { + "expression": "provider_vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"providers\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "providers_provider_vendor_id_provider_vendors_id_fk": { + "name": "providers_provider_vendor_id_provider_vendors_id_fk", + "tableFrom": "providers", + "tableTo": "provider_vendors", + "columnsFrom": [ + "provider_vendor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" } }, - "foreignKeys": {}, "compositePrimaryKeys": {}, "uniqueConstraints": {}, "policies": {}, diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 228582b95..dea0dc2e5 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -383,15 +383,8 @@ { "idx": 54, "version": "7", - "when": 1768240715707, - "tag": "0054_tidy_winter_soldier", - "breakpoints": true - }, - { - "idx": 55, - "version": "7", - "when": 1768325370762, - "tag": "0055_sour_wallow", + "when": 1768383979816, + "tag": "0054_mixed_eternity", "breakpoints": true } ] From af0d6153b1e3ca1dfd6204a561a50392014ba3aa Mon Sep 17 00:00:00 2001 From: Ding Date: Thu, 15 Jan 2026 00:25:44 +0800 Subject: [PATCH 04/10] feat(providers): enable vendor endpoint management by default --- .env.example | 6 ------ src/app/[locale]/dashboard/providers/page.tsx | 9 +-------- .../_components/provider-manager-loader.tsx | 4 ++-- .../providers/_components/provider-manager.tsx | 2 +- .../providers/_components/provider-vendor-view.tsx | 14 +++++++------- src/app/[locale]/settings/providers/page.tsx | 9 +-------- src/lib/config/env.schema.ts | 1 - 7 files changed, 12 insertions(+), 33 deletions(-) diff --git a/.env.example b/.env.example index aca76ffb9..d64a1cf94 100644 --- a/.env.example +++ b/.env.example @@ -118,9 +118,3 @@ MAX_RETRY_ATTEMPTS_DEFAULT=2 # 单供应商最大尝试次数( ENABLE_SMART_PROBING=false PROBE_INTERVAL_MS=30000 PROBE_TIMEOUT_MS=5000 - -# 多提供商类型支持(实验性功能) -# - false (默认):仅支持 Claude、Codex类型供应商 -# - true:支持 Gemini CLI、OpenAI Compatible 等其他类型 -# 警告:其他类型功能仍在开发中,暂不建议启用 -ENABLE_MULTI_PROVIDER_TYPES=false diff --git a/src/app/[locale]/dashboard/providers/page.tsx b/src/app/[locale]/dashboard/providers/page.tsx index 250895d9f..e95f29720 100644 --- a/src/app/[locale]/dashboard/providers/page.tsx +++ b/src/app/[locale]/dashboard/providers/page.tsx @@ -7,7 +7,6 @@ import { Section } from "@/components/section"; import { Button } from "@/components/ui/button"; import { Link, redirect } from "@/i18n/routing"; import { getSession } from "@/lib/auth"; -import { getEnvConfig } from "@/lib/config/env.schema"; export const dynamic = "force-dynamic"; @@ -30,9 +29,6 @@ export default async function DashboardProvidersPage({ const t = await getTranslations("settings"); - // 读取多供应商类型支持配置 - const enableMultiProviderTypes = getEnvConfig().ENABLE_MULTI_PROVIDER_TYPES; - return (
@@ -56,10 +52,7 @@ export default async function DashboardProvidersPage({ } > - +
); diff --git a/src/app/[locale]/settings/providers/_components/provider-manager-loader.tsx b/src/app/[locale]/settings/providers/_components/provider-manager-loader.tsx index b07fb3e0b..848fb8fc2 100644 --- a/src/app/[locale]/settings/providers/_components/provider-manager-loader.tsx +++ b/src/app/[locale]/settings/providers/_components/provider-manager-loader.tsx @@ -33,12 +33,12 @@ async function fetchSystemSettings(): Promise<{ currencyDisplay: CurrencyCode }> interface ProviderManagerLoaderProps { currentUser?: User; - enableMultiProviderTypes: boolean; + enableMultiProviderTypes?: boolean; } function ProviderManagerLoaderContent({ currentUser, - enableMultiProviderTypes, + enableMultiProviderTypes = true, }: ProviderManagerLoaderProps) { const { data: providers = [], diff --git a/src/app/[locale]/settings/providers/_components/provider-manager.tsx b/src/app/[locale]/settings/providers/_components/provider-manager.tsx index 97a5e149c..0d070d5ea 100644 --- a/src/app/[locale]/settings/providers/_components/provider-manager.tsx +++ b/src/app/[locale]/settings/providers/_components/provider-manager.tsx @@ -58,7 +58,7 @@ export function ProviderManager({ addDialogSlot, }: ProviderManagerProps) { const t = useTranslations("settings.providers.search"); - const tStrings = useTranslations("settings.providers.strings"); + const tStrings = useTranslations("settings.providers"); const tFilter = useTranslations("settings.providers.filter"); const tCommon = useTranslations("settings.common"); const [typeFilter, setTypeFilter] = useState("all"); diff --git a/src/app/[locale]/settings/providers/_components/provider-vendor-view.tsx b/src/app/[locale]/settings/providers/_components/provider-vendor-view.tsx index b8ada6c2a..44f58cc6b 100644 --- a/src/app/[locale]/settings/providers/_components/provider-vendor-view.tsx +++ b/src/app/[locale]/settings/providers/_components/provider-vendor-view.tsx @@ -165,7 +165,7 @@ function VendorCard({ statisticsLoading: boolean; currencyCode: CurrencyCode; }) { - const t = useTranslations("settings.providers.strings"); + const t = useTranslations("settings.providers"); const displayName = vendor?.displayName || vendor?.websiteDomain || t("vendorFallbackName", { id: vendorId }); @@ -234,7 +234,7 @@ function VendorCard({ } function VendorEndpointsSection({ vendorId }: { vendorId: number }) { - const t = useTranslations("settings.providers.strings"); + const t = useTranslations("settings.providers"); const [activeType, setActiveType] = useState("claude"); const providerTypes: ProviderType[] = ["claude", "codex", "gemini", "openai-compatible"]; @@ -281,7 +281,7 @@ function VendorTypeCircuitControl({ vendorId: number; providerType: ProviderType; }) { - const t = useTranslations("settings.providers.strings"); + const t = useTranslations("settings.providers"); const queryClient = useQueryClient(); const { data: circuitInfo, isLoading } = useQuery({ @@ -348,7 +348,7 @@ function EndpointsTable({ vendorId: number; providerType: ProviderType; }) { - const t = useTranslations("settings.providers.strings"); + const t = useTranslations("settings.providers"); const { data: endpoints = [], isLoading } = useQuery({ queryKey: ["provider-endpoints", vendorId, providerType], @@ -393,7 +393,7 @@ function EndpointsTable({ } function EndpointRow({ endpoint }: { endpoint: ProviderEndpoint }) { - const t = useTranslations("settings.providers.strings"); + const t = useTranslations("settings.providers"); const tCommon = useTranslations("settings.common"); const queryClient = useQueryClient(); const [isProbing, setIsProbing] = useState(false); @@ -526,7 +526,7 @@ function AddEndpointButton({ vendorId: number; providerType: ProviderType; }) { - const t = useTranslations("settings.providers.strings"); + const t = useTranslations("settings.providers"); const tCommon = useTranslations("settings.common"); const [open, setOpen] = useState(false); const queryClient = useQueryClient(); @@ -601,7 +601,7 @@ function AddEndpointButton({ } function EditEndpointDialog({ endpoint }: { endpoint: ProviderEndpoint }) { - const t = useTranslations("settings.providers.strings"); + const t = useTranslations("settings.providers"); const tCommon = useTranslations("settings.common"); const [open, setOpen] = useState(false); const queryClient = useQueryClient(); diff --git a/src/app/[locale]/settings/providers/page.tsx b/src/app/[locale]/settings/providers/page.tsx index 9f06333ae..3db554604 100644 --- a/src/app/[locale]/settings/providers/page.tsx +++ b/src/app/[locale]/settings/providers/page.tsx @@ -4,7 +4,6 @@ import { Section } from "@/components/section"; import { Button } from "@/components/ui/button"; import { Link } from "@/i18n/routing"; import { getSession } from "@/lib/auth"; -import { getEnvConfig } from "@/lib/config/env.schema"; import { SettingsPageHeader } from "../_components/settings-page-header"; import { AutoSortPriorityDialog } from "./_components/auto-sort-priority-dialog"; import { ProviderManagerLoader } from "./_components/provider-manager-loader"; @@ -16,9 +15,6 @@ export default async function SettingsProvidersPage() { const t = await getTranslations("settings"); const session = await getSession(); - // 读取多供应商类型支持配置 - const enableMultiProviderTypes = getEnvConfig().ENABLE_MULTI_PROVIDER_TYPES; - return ( <> @@ -39,10 +35,7 @@ export default async function SettingsProvidersPage() { } > - + ); diff --git a/src/lib/config/env.schema.ts b/src/lib/config/env.schema.ts index fd9eaf74f..89a081a54 100644 --- a/src/lib/config/env.schema.ts +++ b/src/lib/config/env.schema.ts @@ -97,7 +97,6 @@ export const EnvSchema = z.object({ DEBUG_MODE: z.string().default("false").transform(booleanTransform), LOG_LEVEL: z.enum(["fatal", "error", "warn", "info", "debug", "trace"]).default("info"), TZ: z.string().default("Asia/Shanghai"), - ENABLE_MULTI_PROVIDER_TYPES: z.string().default("false").transform(booleanTransform), ENABLE_CIRCUIT_BREAKER_ON_NETWORK_ERRORS: z.string().default("false").transform(booleanTransform), // 供应商缓存开关 // - true (默认):启用进程级缓存,30s TTL,提升供应商查询性能 From 40ed5b0ee5d7802724527a38058a5ab2f19cdc23 Mon Sep 17 00:00:00 2001 From: Ding Date: Thu, 15 Jan 2026 13:04:00 +0800 Subject: [PATCH 05/10] feat(providers): add vendor edit/delete, official flag, and auto cleanup --- drizzle/0055_familiar_shatterstar.sql | 1 + drizzle/meta/0055_snapshot.json | 11 +- drizzle/meta/_journal.json | 7 + .../settings/providers/form/urlPreview.json | 13 +- messages/en/settings/providers/strings.json | 16 +- .../settings/providers/form/urlPreview.json | 13 +- messages/ja/settings/providers/strings.json | 16 +- .../settings/providers/form/urlPreview.json | 13 +- messages/ru/settings/providers/strings.json | 16 +- .../settings/providers/form/urlPreview.json | 13 +- .../zh-CN/settings/providers/strings.json | 16 +- .../settings/providers/form/urlPreview.json | 13 +- .../zh-TW/settings/providers/strings.json | 16 +- src/actions/provider-endpoints.ts | 134 ++++++++++ src/actions/providers.ts | 10 +- .../_components/forms/url-preview.tsx | 9 +- .../_components/provider-rich-list-item.tsx | 1 + .../_components/provider-vendor-view.tsx | 229 ++++++++++++++++- src/app/v1/_lib/url.ts | 126 ++++------ src/drizzle/schema.ts | 1 + src/repository/index.ts | 2 + src/repository/provider-endpoints.ts | 85 ++++++- src/repository/provider.ts | 9 + src/types/provider.ts | 1 + tests/unit/actions/provider-endpoints.test.ts | 203 +++++++++++++++ tests/unit/app/v1/url.test.ts | 57 +++++ .../repository/provider-endpoints.test.ts | 233 ++++++++++++++++++ 27 files changed, 1166 insertions(+), 98 deletions(-) create mode 100644 drizzle/0055_familiar_shatterstar.sql create mode 100644 tests/unit/actions/provider-endpoints.test.ts create mode 100644 tests/unit/app/v1/url.test.ts diff --git a/drizzle/0055_familiar_shatterstar.sql b/drizzle/0055_familiar_shatterstar.sql new file mode 100644 index 000000000..1e6980236 --- /dev/null +++ b/drizzle/0055_familiar_shatterstar.sql @@ -0,0 +1 @@ +ALTER TABLE "provider_vendors" ADD COLUMN "is_official" boolean DEFAULT false NOT NULL; \ No newline at end of file diff --git a/drizzle/meta/0055_snapshot.json b/drizzle/meta/0055_snapshot.json index d7ff0f314..f6da90876 100644 --- a/drizzle/meta/0055_snapshot.json +++ b/drizzle/meta/0055_snapshot.json @@ -1,6 +1,6 @@ { - "id": "4ca06202-1c61-4490-a119-3d0c8ac1f841", - "prevId": "36887729-08df-4af3-98fe-d4fa87c7c5c7", + "id": "390e30e9-a88b-4b14-b1d7-e51ccc03a913", + "prevId": "6b4f6d92-6765-4cf3-8ee2-d4d33a934ff2", "version": "7", "dialect": "postgresql", "tables": { @@ -1540,6 +1540,13 @@ "primaryKey": false, "notNull": false }, + "is_official": { + "name": "is_official", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, "created_at": { "name": "created_at", "type": "timestamp with time zone", diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index dea0dc2e5..fb2a918af 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -386,6 +386,13 @@ "when": 1768383979816, "tag": "0054_mixed_eternity", "breakpoints": true + }, + { + "idx": 55, + "version": "7", + "when": 1768451294331, + "tag": "0055_familiar_shatterstar", + "breakpoints": true } ] } \ No newline at end of file diff --git a/messages/en/settings/providers/form/urlPreview.json b/messages/en/settings/providers/form/urlPreview.json index 20db3a9d5..d030bd410 100644 --- a/messages/en/settings/providers/form/urlPreview.json +++ b/messages/en/settings/providers/form/urlPreview.json @@ -5,5 +5,16 @@ "duplicatePath": "Duplicate path detected", "invalidUrl": "Invalid URL format", "invalidUrlDesc": "Please enter a valid HTTP/HTTPS address", - "title": "URL Concatenation Preview" + "title": "URL Concatenation Preview", + "endpoints": { + "claudeMessages": "Claude Messages", + "claudeCountTokens": "Claude Count Tokens", + "codexResponses": "Codex Responses", + "openaiChatCompletions": "OpenAI Chat Completions", + "openaiModels": "OpenAI Models", + "geminiGenerateContent": "Gemini Generate Content", + "geminiStreamContent": "Gemini Stream Content", + "geminiCliGenerate": "Gemini CLI Generate", + "geminiCliStream": "Gemini CLI Stream" + } } diff --git a/messages/en/settings/providers/strings.json b/messages/en/settings/providers/strings.json index adec05d6d..b1c4be72e 100644 --- a/messages/en/settings/providers/strings.json +++ b/messages/en/settings/providers/strings.json @@ -80,5 +80,19 @@ "endpointUrlPlaceholder": "https://api.example.com/v1", "endpointLabelOptional": "Label (optional)", "endpointLabelPlaceholder": "Production endpoint", - "editEndpoint": "Edit Endpoint" + "editEndpoint": "Edit Endpoint", + "editVendor": "Edit Vendor", + "vendorName": "Vendor Name", + "vendorWebsite": "Website URL", + "vendorWebsitePlaceholder": "https://example.com", + "vendorOfficial": "Official", + "deleteVendor": "Delete Vendor", + "deleteVendorConfirmTitle": "Delete Vendor?", + "deleteVendorDoubleConfirmTitle": "Confirm deletion?", + "deleteVendorConfirmDesc": "This will permanently delete the vendor \"{name}\" and all its associated API keys and endpoints.", + "deleteVendorDoubleConfirmDesc": "This action is irreversible. Please confirm you want to wipe out everything related to \"{name}\".", + "vendorUpdateSuccess": "Vendor updated successfully", + "vendorUpdateFailed": "Failed to update vendor", + "vendorDeleteSuccess": "Vendor deleted successfully", + "vendorDeleteFailed": "Failed to delete vendor" } diff --git a/messages/ja/settings/providers/form/urlPreview.json b/messages/ja/settings/providers/form/urlPreview.json index 9efeab434..f62bb8f30 100644 --- a/messages/ja/settings/providers/form/urlPreview.json +++ b/messages/ja/settings/providers/form/urlPreview.json @@ -5,5 +5,16 @@ "duplicatePath": "重複パス検出", "invalidUrl": "無効なURL形式", "invalidUrlDesc": "有効なHTTP/HTTPSアドレスを入力してください", - "title": "URL結合プレビュー" + "title": "URL結合プレビュー", + "endpoints": { + "claudeMessages": "Claude メッセージ", + "claudeCountTokens": "Claude トークン数", + "codexResponses": "Codex Responses", + "openaiChatCompletions": "OpenAI Chat Completions", + "openaiModels": "OpenAI Models", + "geminiGenerateContent": "Gemini Generate Content", + "geminiStreamContent": "Gemini Stream Content", + "geminiCliGenerate": "Gemini CLI Generate", + "geminiCliStream": "Gemini CLI Stream" + } } diff --git a/messages/ja/settings/providers/strings.json b/messages/ja/settings/providers/strings.json index 07bb6f938..dd475604a 100644 --- a/messages/ja/settings/providers/strings.json +++ b/messages/ja/settings/providers/strings.json @@ -80,5 +80,19 @@ "endpointUrlPlaceholder": "https://api.example.com/v1", "endpointLabelOptional": "ラベル (任意)", "endpointLabelPlaceholder": "本番環境", - "editEndpoint": "エンドポイントを編集" + "editEndpoint": "エンドポイントを編集", + "editVendor": "ベンダーを編集", + "vendorName": "ベンダー名", + "vendorWebsite": "Webサイト URL", + "vendorWebsitePlaceholder": "https://example.com", + "vendorOfficial": "公式", + "deleteVendor": "ベンダーを削除", + "deleteVendorConfirmTitle": "ベンダーを削除しますか?", + "deleteVendorDoubleConfirmTitle": "削除を確定しますか?", + "deleteVendorConfirmDesc": "ベンダー「{name}」と関連する API キーおよびエンドポイントをすべて永久に削除します。", + "deleteVendorDoubleConfirmDesc": "この操作は元に戻せません。「{name}」に関するすべてのデータを削除してよいか確認してください。", + "vendorUpdateSuccess": "ベンダーを更新しました", + "vendorUpdateFailed": "ベンダーの更新に失敗しました", + "vendorDeleteSuccess": "ベンダーを削除しました", + "vendorDeleteFailed": "ベンダーの削除に失敗しました" } diff --git a/messages/ru/settings/providers/form/urlPreview.json b/messages/ru/settings/providers/form/urlPreview.json index 1d01b571c..7297b5060 100644 --- a/messages/ru/settings/providers/form/urlPreview.json +++ b/messages/ru/settings/providers/form/urlPreview.json @@ -5,5 +5,16 @@ "duplicatePath": "Обнаружен дублирующийся путь", "invalidUrl": "Неверный формат URL", "invalidUrlDesc": "Пожалуйста, введите действительный HTTP/HTTPS адрес", - "title": "Предварительный просмотр URL" + "title": "Предварительный просмотр URL", + "endpoints": { + "claudeMessages": "Claude Messages", + "claudeCountTokens": "Claude Count Tokens", + "codexResponses": "Codex Responses", + "openaiChatCompletions": "OpenAI Chat Completions", + "openaiModels": "OpenAI Models", + "geminiGenerateContent": "Gemini Generate Content", + "geminiStreamContent": "Gemini Stream Content", + "geminiCliGenerate": "Gemini CLI Generate", + "geminiCliStream": "Gemini CLI Stream" + } } diff --git a/messages/ru/settings/providers/strings.json b/messages/ru/settings/providers/strings.json index d94d567a5..62897f61e 100644 --- a/messages/ru/settings/providers/strings.json +++ b/messages/ru/settings/providers/strings.json @@ -80,5 +80,19 @@ "endpointUrlPlaceholder": "https://api.example.com/v1", "endpointLabelOptional": "Метка (необязательно)", "endpointLabelPlaceholder": "Продакшн", - "editEndpoint": "Редактировать эндпоинт" + "editEndpoint": "Редактировать эндпоинт", + "editVendor": "Редактировать вендора", + "vendorName": "Название вендора", + "vendorWebsite": "URL сайта", + "vendorWebsitePlaceholder": "https://example.com", + "vendorOfficial": "Официальный", + "deleteVendor": "Удалить вендора", + "deleteVendorConfirmTitle": "Удалить вендора?", + "deleteVendorDoubleConfirmTitle": "Подтвердить удаление?", + "deleteVendorConfirmDesc": "Это навсегда удалит вендора \"{name}\" и все связанные с ним API ключи и эндпоинты.", + "deleteVendorDoubleConfirmDesc": "Это действие необратимо. Подтвердите, что хотите удалить всё, связанное с \"{name}\".", + "vendorUpdateSuccess": "Вендор обновлён", + "vendorUpdateFailed": "Не удалось обновить вендора", + "vendorDeleteSuccess": "Вендор удалён", + "vendorDeleteFailed": "Не удалось удалить вендора" } diff --git a/messages/zh-CN/settings/providers/form/urlPreview.json b/messages/zh-CN/settings/providers/form/urlPreview.json index c2566d4b0..98ad326b5 100644 --- a/messages/zh-CN/settings/providers/form/urlPreview.json +++ b/messages/zh-CN/settings/providers/form/urlPreview.json @@ -5,5 +5,16 @@ "duplicatePath": "检测到重复路径", "copy": "复制", "copySuccess": "已复制 {name} 到剪贴板", - "copyFailed": "复制失败" + "copyFailed": "复制失败", + "endpoints": { + "claudeMessages": "Claude 消息", + "claudeCountTokens": "Claude 统计 Token", + "codexResponses": "Codex Responses", + "openaiChatCompletions": "OpenAI Chat Completions", + "openaiModels": "OpenAI Models", + "geminiGenerateContent": "Gemini 生成内容", + "geminiStreamContent": "Gemini 流式内容", + "geminiCliGenerate": "Gemini CLI 生成", + "geminiCliStream": "Gemini CLI 流式" + } } diff --git a/messages/zh-CN/settings/providers/strings.json b/messages/zh-CN/settings/providers/strings.json index 4957b8de1..f4ea57818 100644 --- a/messages/zh-CN/settings/providers/strings.json +++ b/messages/zh-CN/settings/providers/strings.json @@ -80,5 +80,19 @@ "endpointUrlPlaceholder": "https://api.example.com/v1", "endpointLabelOptional": "标签(可选)", "endpointLabelPlaceholder": "生产环境", - "editEndpoint": "编辑端点" + "editEndpoint": "编辑端点", + "editVendor": "编辑服务商", + "vendorName": "服务商名称", + "vendorWebsite": "官网地址", + "vendorWebsitePlaceholder": "https://example.com", + "vendorOfficial": "官方", + "deleteVendor": "删除服务商", + "deleteVendorConfirmTitle": "删除服务商?", + "deleteVendorDoubleConfirmTitle": "再次确认删除?", + "deleteVendorConfirmDesc": "此操作将永久删除服务商 \"{name}\" 及其所有 API 密钥和端点。", + "deleteVendorDoubleConfirmDesc": "该操作不可恢复。请确认你要删除与 \"{name}\" 相关的所有内容。", + "vendorUpdateSuccess": "服务商更新成功", + "vendorUpdateFailed": "更新服务商失败", + "vendorDeleteSuccess": "服务商删除成功", + "vendorDeleteFailed": "删除服务商失败" } diff --git a/messages/zh-TW/settings/providers/form/urlPreview.json b/messages/zh-TW/settings/providers/form/urlPreview.json index 101b00a65..a891b8f8c 100644 --- a/messages/zh-TW/settings/providers/form/urlPreview.json +++ b/messages/zh-TW/settings/providers/form/urlPreview.json @@ -5,5 +5,16 @@ "duplicatePath": "檢測到重複路徑", "invalidUrl": "無效的 URL 格式", "invalidUrlDesc": "請輸入有效的 HTTP/HTTPS 地址", - "title": "URL 拼接預覽" + "title": "URL 拼接預覽", + "endpoints": { + "claudeMessages": "Claude 訊息", + "claudeCountTokens": "Claude 統計 Token", + "codexResponses": "Codex Responses", + "openaiChatCompletions": "OpenAI Chat Completions", + "openaiModels": "OpenAI Models", + "geminiGenerateContent": "Gemini 產生內容", + "geminiStreamContent": "Gemini 串流內容", + "geminiCliGenerate": "Gemini CLI 產生", + "geminiCliStream": "Gemini CLI 串流" + } } diff --git a/messages/zh-TW/settings/providers/strings.json b/messages/zh-TW/settings/providers/strings.json index 32d3acc21..f1a52d39f 100644 --- a/messages/zh-TW/settings/providers/strings.json +++ b/messages/zh-TW/settings/providers/strings.json @@ -80,5 +80,19 @@ "endpointUrlPlaceholder": "https://api.example.com/v1", "endpointLabelOptional": "標籤(選填)", "endpointLabelPlaceholder": "生產環境", - "editEndpoint": "編輯端點" + "editEndpoint": "編輯端點", + "editVendor": "編輯供應商", + "vendorName": "供應商名稱", + "vendorWebsite": "官網地址", + "vendorWebsitePlaceholder": "https://example.com", + "vendorOfficial": "官方", + "deleteVendor": "刪除供應商", + "deleteVendorConfirmTitle": "刪除供應商?", + "deleteVendorDoubleConfirmTitle": "再次確認刪除?", + "deleteVendorConfirmDesc": "此操作將永久刪除供應商「{name}」及其所有 API 金鑰與端點。", + "deleteVendorDoubleConfirmDesc": "此操作無法復原。請確認要清除所有與「{name}」相關的內容。", + "vendorUpdateSuccess": "供應商已更新", + "vendorUpdateFailed": "更新供應商失敗", + "vendorDeleteSuccess": "供應商已刪除", + "vendorDeleteFailed": "刪除供應商失敗" } diff --git a/src/actions/provider-endpoints.ts b/src/actions/provider-endpoints.ts index 669be4238..e3f878566 100644 --- a/src/actions/provider-endpoints.ts +++ b/src/actions/provider-endpoints.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import { getSession } from "@/lib/auth"; +import { publishProviderCacheInvalidation } from "@/lib/cache/provider-cache"; import { getEndpointHealthInfo, resetEndpointCircuit as resetEndpointCircuitState, @@ -17,13 +18,16 @@ import { } from "@/lib/vendor-type-circuit-breaker"; import { createProviderEndpoint, + deleteProviderVendor, findProviderEndpointById, findProviderEndpointProbeLogs, findProviderEndpointsByVendorAndType, findProviderVendorById, findProviderVendors, softDeleteProviderEndpoint, + tryDeleteProviderVendorIfEmpty, updateProviderEndpoint, + updateProviderVendor, } from "@/repository"; import type { ProviderEndpoint, @@ -90,6 +94,17 @@ const ProbeProviderEndpointSchema = z.object({ timeoutMs: z.number().int().min(1000).max(120_000).optional(), }); +const EditProviderVendorSchema = z.object({ + vendorId: VendorIdSchema, + displayName: z.string().trim().max(200).optional().nullable(), + websiteUrl: z.string().trim().url(ERROR_CODES.INVALID_URL).optional().nullable(), + isOfficial: z.boolean().optional(), +}); + +const DeleteProviderVendorSchema = z.object({ + vendorId: VendorIdSchema, +}); + const GetProbeLogsSchema = z.object({ endpointId: EndpointIdSchema, limit: z.number().int().min(1).max(1000).optional(), @@ -275,6 +290,15 @@ export async function removeProviderEndpoint(input: unknown): Promise> { + try { + const session = await getAdminSession(); + if (!session) { + return { + ok: false, + error: "无权限执行此操作", + errorCode: ERROR_CODES.PERMISSION_DENIED, + }; + } + + const parsed = EditProviderVendorSchema.safeParse(input); + if (!parsed.success) { + return { + ok: false, + error: formatZodError(parsed.error), + errorCode: extractZodErrorCode(parsed.error), + }; + } + + let faviconUrl: string | null | undefined; + if (parsed.data.websiteUrl !== undefined) { + if (parsed.data.websiteUrl) { + try { + const url = new URL(parsed.data.websiteUrl); + const domain = url.hostname; + faviconUrl = `https://www.google.com/s2/favicons?domain=${domain}&sz=32`; + } catch (_error) { + faviconUrl = null; + } + } else { + // websiteUrl explicitly cleared (null) -> clear favicon as well + faviconUrl = null; + } + } + + const vendor = await updateProviderVendor(parsed.data.vendorId, { + displayName: parsed.data.displayName, + websiteUrl: parsed.data.websiteUrl, + faviconUrl, + isOfficial: parsed.data.isOfficial, + }); + + if (!vendor) { + return { + ok: false, + error: "Vendor not found", + errorCode: ERROR_CODES.NOT_FOUND, + }; + } + + return { ok: true, data: { vendor } }; + } catch (error) { + logger.error("editProviderVendor:error", error); + const message = error instanceof Error ? error.message : "更新供应商失败"; + return { ok: false, error: message, errorCode: ERROR_CODES.UPDATE_FAILED }; + } +} + +export async function removeProviderVendor(input: unknown): Promise { + try { + const session = await getAdminSession(); + if (!session) { + return { + ok: false, + error: "无权限执行此操作", + errorCode: ERROR_CODES.PERMISSION_DENIED, + }; + } + + const parsed = DeleteProviderVendorSchema.safeParse(input); + if (!parsed.success) { + return { + ok: false, + error: formatZodError(parsed.error), + errorCode: extractZodErrorCode(parsed.error), + }; + } + + const ok = await deleteProviderVendor(parsed.data.vendorId); + if (!ok) { + return { + ok: false, + error: "Vendor not found or could not be deleted", + errorCode: ERROR_CODES.DELETE_FAILED, + }; + } + + try { + await publishProviderCacheInvalidation(); + } catch (error) { + logger.warn("removeProviderVendor:cache_invalidation_failed", { + vendorId: parsed.data.vendorId, + error: error instanceof Error ? error.message : String(error), + }); + } + + return { ok: true }; + } catch (error) { + logger.error("removeProviderVendor:error", error); + const message = error instanceof Error ? error.message : "删除供应商失败"; + return { ok: false, error: message, errorCode: ERROR_CODES.DELETE_FAILED }; + } +} diff --git a/src/actions/providers.ts b/src/actions/providers.ts index d1df56412..a9a8c725e 100644 --- a/src/actions/providers.ts +++ b/src/actions/providers.ts @@ -2,6 +2,7 @@ import { GeminiAuth } from "@/app/v1/_lib/gemini/auth"; import { isClientAbortError } from "@/app/v1/_lib/proxy/errors"; +import { buildProxyUrl } from "@/app/v1/_lib/url"; import { getSession } from "@/lib/auth"; import { publishProviderCacheInvalidation } from "@/lib/cache/provider-cache"; import { @@ -43,6 +44,7 @@ import { updateProvider, updateProviderPrioritiesBatch, } from "@/repository/provider"; +import { tryDeleteProviderVendorIfEmpty } from "@/repository/provider-endpoints"; import type { CacheTtlPreference } from "@/types/cache"; import type { CodexParallelToolCallsPreference, @@ -735,6 +737,7 @@ export async function removeProvider(providerId: number): Promise return { ok: false, error: "无权限执行此操作" }; } + const provider = await findProviderById(providerId); await deleteProvider(providerId); // 清除内存缓存(无论 Redis 是否成功都要执行) @@ -752,6 +755,11 @@ export async function removeProvider(providerId: number): Promise }); } + // Auto cleanup: delete vendor if it has no active providers/endpoints. + if (provider) { + await tryDeleteProviderVendorIfEmpty(provider.providerVendorId); + } + // 广播缓存更新(跨实例即时生效) await broadcastProviderCacheInvalidation({ operation: "remove", providerId }); @@ -2118,7 +2126,7 @@ async function executeProviderApiTest( const model = data.model || options.defaultModel; const path = typeof options.path === "function" ? options.path(model, data.apiKey) : options.path; - const url = normalizedProviderUrl + path; + const url = buildProxyUrl(normalizedProviderUrl, new URL(`https://dummy.com${path}`)); try { const proxyConfig = createProxyAgentForProvider(tempProvider, url); diff --git a/src/app/[locale]/settings/providers/_components/forms/url-preview.tsx b/src/app/[locale]/settings/providers/_components/forms/url-preview.tsx index 8c4b240e8..5b6923f25 100644 --- a/src/app/[locale]/settings/providers/_components/forms/url-preview.tsx +++ b/src/app/[locale]/settings/providers/_components/forms/url-preview.tsx @@ -109,13 +109,14 @@ export function UrlPreview({ baseUrl, providerType }: UrlPreviewProps) { {/* 预览列表 */}
- {Object.entries(previews).map(([name, url]) => { + {Object.entries(previews).map(([endpointKey, url]) => { const hasDuplicate = detectDuplicatePath(url); const isCopied = copiedUrl === url; + const label = t(`endpoints.${endpointKey}`); return (
{/* 端点名称 */}
- {name} + {label} {hasDuplicate && ( {t("duplicatePath")} @@ -141,7 +142,7 @@ export function UrlPreview({ baseUrl, providerType }: UrlPreviewProps) { variant="ghost" size="sm" className="h-8 w-8 p-0 flex-shrink-0" - onClick={() => copyToClipboard(url, name)} + onClick={() => copyToClipboard(url, label)} title={t("copy")} > {isCopied ? ( diff --git a/src/app/[locale]/settings/providers/_components/provider-rich-list-item.tsx b/src/app/[locale]/settings/providers/_components/provider-rich-list-item.tsx index 51b26ca60..3708b14b0 100644 --- a/src/app/[locale]/settings/providers/_components/provider-rich-list-item.tsx +++ b/src/app/[locale]/settings/providers/_components/provider-rich-list-item.tsx @@ -176,6 +176,7 @@ export function ProviderRichListItem({ }); queryClient.invalidateQueries({ queryKey: ["providers"] }); queryClient.invalidateQueries({ queryKey: ["providers-health"] }); + queryClient.invalidateQueries({ queryKey: ["provider-vendors"] }); router.refresh(); } else { toast.error(tList("deleteFailed"), { diff --git a/src/app/[locale]/settings/providers/_components/provider-vendor-view.tsx b/src/app/[locale]/settings/providers/_components/provider-vendor-view.tsx index 44f58cc6b..b0344eacf 100644 --- a/src/app/[locale]/settings/providers/_components/provider-vendor-view.tsx +++ b/src/app/[locale]/settings/providers/_components/provider-vendor-view.tsx @@ -13,18 +13,31 @@ import { Trash2, } from "lucide-react"; import { useTranslations } from "next-intl"; -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; import { addProviderEndpoint, editProviderEndpoint, + editProviderVendor, getProviderEndpoints, getProviderVendors, getVendorTypeCircuitInfo, probeProviderEndpoint, removeProviderEndpoint, + removeProviderVendor, setVendorTypeCircuitManualOpen, } from "@/actions/provider-endpoints"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -57,6 +70,7 @@ import { } from "@/components/ui/table"; import type { CurrencyCode } from "@/lib/utils/currency"; +import { getErrorMessage } from "@/lib/utils/error-messages"; import type { ProviderDisplay, ProviderEndpoint, @@ -64,6 +78,7 @@ import type { ProviderVendor, } from "@/types/provider"; import type { User } from "@/types/user"; +import { UrlPreview } from "./forms/url-preview"; import { ProviderRichListItem } from "./provider-rich-list-item"; interface ProviderVendorViewProps { @@ -184,6 +199,11 @@ function VendorCard({
{displayName} + {vendor?.isOfficial ? ( + + {t("vendorOfficial")} + + ) : null} {websiteUrl && (
+
+ + +
@@ -431,6 +455,7 @@ function EndpointRow({ endpoint }: { endpoint: ProviderEndpoint }) { }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["provider-endpoints"] }); + queryClient.invalidateQueries({ queryKey: ["provider-vendors"] }); toast.success(t("endpointDeleteSuccess")); }, onError: () => { @@ -531,19 +556,24 @@ function AddEndpointButton({ const [open, setOpen] = useState(false); const queryClient = useQueryClient(); const [isSubmitting, setIsSubmitting] = useState(false); + const [url, setUrl] = useState(""); + + useEffect(() => { + if (!open) setUrl(""); + }, [open]); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setIsSubmitting(true); const formData = new FormData(e.currentTarget); - const url = formData.get("url") as string; + const endpointUrl = formData.get("url") as string; const label = formData.get("label") as string; try { const res = await addProviderEndpoint({ vendorId, providerType, - url, + url: endpointUrl, label: label || null, sortOrder: 0, isEnabled: true, @@ -571,7 +601,7 @@ function AddEndpointButton({ {t("addEndpoint")} - + {t("addEndpoint")} {t("addEndpointDesc", { providerType })} @@ -579,12 +609,21 @@ function AddEndpointButton({
- + setUrl(e.target.value)} + />
+ + + + + + + {t("editVendor")} + + +
+ + +
+
+ + +
+
+ + +
+ + + + + +
+ + ); +} + +function DeleteVendorDialog({ vendor, vendorId }: { vendor?: ProviderVendor; vendorId: number }) { + const t = useTranslations("settings.providers"); + const tCommon = useTranslations("settings.common"); + const tErrors = useTranslations("errors"); + const [open, setOpen] = useState(false); + const [step, setStep] = useState<"confirm" | "double-confirm">("confirm"); + const queryClient = useQueryClient(); + const [isDeleting, setIsDeleting] = useState(false); + + const handleDelete = async () => { + setIsDeleting(true); + try { + const res = await removeProviderVendor({ vendorId }); + + if (res.ok) { + toast.success(t("vendorDeleteSuccess")); + setOpen(false); + queryClient.invalidateQueries({ queryKey: ["provider-vendors"] }); + } else { + toast.error( + res.errorCode ? getErrorMessage(tErrors, res.errorCode) : t("vendorDeleteFailed") + ); + } + } catch (_err) { + toast.error(t("vendorDeleteFailed")); + } finally { + setIsDeleting(false); + } + }; + + const displayName = vendor?.displayName || t("vendorFallbackName", { id: vendorId }); + + return ( + { + setOpen(val); + if (!val) setStep("confirm"); + }} + > + + + + + + + {step === "confirm" + ? t("deleteVendorConfirmTitle") + : t("deleteVendorDoubleConfirmTitle")} + + + {step === "confirm" + ? t("deleteVendorConfirmDesc", { name: displayName }) + : t("deleteVendorDoubleConfirmDesc", { name: displayName })} + + + + {tCommon("cancel")} + {step === "confirm" ? ( + + ) : ( + { + e.preventDefault(); + handleDelete(); + }} + disabled={isDeleting} + > + {isDeleting && } + {tCommon("confirm")} + + )} + + + + ); +} diff --git a/src/app/v1/_lib/url.ts b/src/app/v1/_lib/url.ts index ccdab28f0..0bb9bdcc3 100644 --- a/src/app/v1/_lib/url.ts +++ b/src/app/v1/_lib/url.ts @@ -29,7 +29,15 @@ export function buildProxyUrl(baseUrl: string, requestUrl: URL): string { const basePath = baseUrlObj.pathname.replace(/\/$/, ""); // 移除末尾斜杠 const requestPath = requestUrl.pathname; // 原始请求路径(如 /v1/messages) - // ⭐ 智能检测:base_url 是否已包含完整目标路径 + // Case 1: baseUrl 已是 requestPath 的前缀(例如 base=/v1/messages, req=/v1/messages/count_tokens) + // 直接使用 requestPath,避免丢失子路径。 + if (requestPath === basePath || requestPath.startsWith(`${basePath}/`)) { + baseUrlObj.pathname = requestPath; + baseUrlObj.search = requestUrl.search; + return baseUrlObj.toString(); + } + + // Case 2: baseUrl 已包含“端点根路径”(可能带有额外前缀),仅追加 requestPath 的子路径部分。 const targetEndpoints = [ "/responses", // Codex Response API "/messages", // Claude Messages API @@ -37,44 +45,30 @@ export function buildProxyUrl(baseUrl: string, requestUrl: URL): string { "/models", // Gemini & OpenAI models ]; - let shouldSkipConcatenation = false; for (const endpoint of targetEndpoints) { - // 检查 1:base_url 末尾是否包含目标端点 - if (basePath.endsWith(endpoint)) { - shouldSkipConcatenation = true; - logger.debug("[buildProxyUrl] Detected complete path in baseUrl", { - basePath, - endpoint, - action: "skip_concatenation", - }); - break; - } + const requestRoot = `/v1${endpoint}`; // /v1/messages, /v1/responses 等 + if (requestPath === requestRoot || requestPath.startsWith(`${requestRoot}/`)) { + if (basePath.endsWith(endpoint) || basePath.endsWith(requestRoot)) { + const suffix = requestPath.slice(requestRoot.length); // 例如 /count_tokens + baseUrlObj.pathname = basePath + suffix; + baseUrlObj.search = requestUrl.search; - // 检查 2:base_url 是否已包含完整的 API 路径(如 /v1/messages) - // 这处理类似 "https://xxx.com/api/v1/messages" 的情况 - const fullApiPath = `/v1${endpoint}`; // /v1/messages, /v1/responses 等 - if (basePath.endsWith(fullApiPath)) { - shouldSkipConcatenation = true; - logger.debug("[buildProxyUrl] Detected complete API path in baseUrl", { - basePath, - fullApiPath, - action: "skip_concatenation", - }); - break; + logger.debug("[buildProxyUrl] Detected endpoint root in baseUrl", { + basePath, + requestPath, + endpoint, + action: "append_suffix", + }); + + return baseUrlObj.toString(); + } } } - // 构建最终URL - if (shouldSkipConcatenation) { - // 已包含完整路径:直接使用 baseUrl + 查询参数 - baseUrlObj.search = requestUrl.search; - return baseUrlObj.toString(); - } else { - // 标准拼接:basePath + requestPath - baseUrlObj.pathname = basePath + requestPath; - baseUrlObj.search = requestUrl.search; - return baseUrlObj.toString(); - } + // 标准拼接:basePath + requestPath + baseUrlObj.pathname = basePath + requestPath; + baseUrlObj.search = requestUrl.search; + return baseUrlObj.toString(); } catch (error) { logger.error("URL构建失败:", error); // 降级到字符串拼接 @@ -103,38 +97,38 @@ export function previewProxyUrls(baseUrl: string, providerType?: string): Record return previews; } - // 根据供应商类型定义端点映射 - const endpointsByType: Record> = { + // 根据供应商类型定义端点映射(key 由 UI 负责 i18n) + const endpointsByType: Record> = { claude: [ - { name: "Claude Messages", path: "/v1/messages" }, - { name: "Claude Count Tokens", path: "/v1/messages/count_tokens" }, + { key: "claudeMessages", path: "/v1/messages" }, + { key: "claudeCountTokens", path: "/v1/messages/count_tokens" }, ], "claude-auth": [ - { name: "Claude Messages", path: "/v1/messages" }, - { name: "Claude Count Tokens", path: "/v1/messages/count_tokens" }, + { key: "claudeMessages", path: "/v1/messages" }, + { key: "claudeCountTokens", path: "/v1/messages/count_tokens" }, ], - codex: [{ name: "Codex Responses", path: "/v1/responses" }], + codex: [{ key: "codexResponses", path: "/v1/responses" }], "openai-compatible": [ - { name: "OpenAI Chat Completions", path: "/v1/chat/completions" }, - { name: "OpenAI Models", path: "/v1/models" }, + { key: "openaiChatCompletions", path: "/v1/chat/completions" }, + { key: "openaiModels", path: "/v1/models" }, ], gemini: [ { - name: "Gemini Generate Content", + key: "geminiGenerateContent", path: "/v1beta/models/gemini-1.5-pro:generateContent", }, { - name: "Gemini Stream Content", + key: "geminiStreamContent", path: "/v1beta/models/gemini-1.5-pro:streamGenerateContent", }, ], "gemini-cli": [ { - name: "Gemini CLI Generate", + key: "geminiCliGenerate", path: "/v1internal/models/gemini-2.5-flash:generateContent", }, { - name: "Gemini CLI Stream", + key: "geminiCliStream", path: "/v1internal/models/gemini-2.5-flash:streamGenerateContent", }, ], @@ -144,35 +138,19 @@ export function previewProxyUrls(baseUrl: string, providerType?: string): Record const endpoints = providerType ? endpointsByType[providerType] || [] : endpointsByType.claude; // 如果没有匹配的端点,显示常见端点 - if (endpoints.length === 0) { - const commonEndpoints = [ - { name: "Claude Messages", path: "/v1/messages" }, - { name: "Codex Responses", path: "/v1/responses" }, - { name: "OpenAI Chat", path: "/v1/chat/completions" }, - ]; - - for (const { name, path } of commonEndpoints) { - try { - const fakeRequestUrl = new URL(`https://dummy.com${path}`); - const result = buildProxyUrl(baseUrl, fakeRequestUrl); - previews[name] = result; - } catch { - previews[name] = "❌ 无效的 URL"; - } - } - - return previews; - } + const effectiveEndpoints = + endpoints.length > 0 + ? endpoints + : [ + { key: "claudeMessages", path: "/v1/messages" }, + { key: "codexResponses", path: "/v1/responses" }, + { key: "openaiChatCompletions", path: "/v1/chat/completions" }, + ]; // 生成当前供应商类型的端点预览 - for (const { name, path } of endpoints) { - try { - const fakeRequestUrl = new URL(`https://dummy.com${path}`); - const result = buildProxyUrl(baseUrl, fakeRequestUrl); - previews[name] = result; - } catch { - previews[name] = "❌ 无效的 URL"; - } + for (const { key, path } of effectiveEndpoints) { + const fakeRequestUrl = new URL(`https://dummy.com${path}`); + previews[key] = buildProxyUrl(baseUrl, fakeRequestUrl); } return previews; diff --git a/src/drizzle/schema.ts b/src/drizzle/schema.ts index ad93d9a26..a044e9755 100644 --- a/src/drizzle/schema.ts +++ b/src/drizzle/schema.ts @@ -135,6 +135,7 @@ export const providerVendors = pgTable('provider_vendors', { displayName: varchar('display_name', { length: 200 }), websiteUrl: text('website_url'), faviconUrl: text('favicon_url'), + isOfficial: boolean('is_official').notNull().default(false), createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(), updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow(), }, (table) => ({ diff --git a/src/repository/index.ts b/src/repository/index.ts index a8652a76c..8b5478890 100644 --- a/src/repository/index.ts +++ b/src/repository/index.ts @@ -47,6 +47,7 @@ export { export { createProviderEndpoint, + deleteProviderVendor, findProviderEndpointById, findProviderEndpointProbeLogs, findProviderEndpointsByVendorAndType, @@ -54,6 +55,7 @@ export { findProviderVendors, recordProviderEndpointProbeResult, softDeleteProviderEndpoint, + tryDeleteProviderVendorIfEmpty, updateProviderEndpoint, updateProviderVendor, } from "./provider-endpoints"; diff --git a/src/repository/provider-endpoints.ts b/src/repository/provider-endpoints.ts index 54c971427..db8fbd34e 100644 --- a/src/repository/provider-endpoints.ts +++ b/src/repository/provider-endpoints.ts @@ -1,6 +1,6 @@ "use server"; -import { and, asc, desc, eq, gt, isNull } from "drizzle-orm"; +import { and, asc, desc, eq, gt, isNotNull, isNull, sql } from "drizzle-orm"; import { db } from "@/drizzle/db"; import { providerEndpointProbeLogs, @@ -59,6 +59,7 @@ function toProviderVendor(row: any): ProviderVendor { displayName: row.displayName ?? null, websiteUrl: row.websiteUrl ?? null, faviconUrl: row.faviconUrl ?? null, + isOfficial: row.isOfficial ?? false, createdAt: toDate(row.createdAt), updatedAt: toDate(row.updatedAt), }; @@ -161,6 +162,7 @@ export async function findProviderVendors( displayName: providerVendors.displayName, websiteUrl: providerVendors.websiteUrl, faviconUrl: providerVendors.faviconUrl, + isOfficial: providerVendors.isOfficial, createdAt: providerVendors.createdAt, updatedAt: providerVendors.updatedAt, }) @@ -180,6 +182,7 @@ export async function findProviderVendorById(vendorId: number): Promise { if (Object.keys(payload).length === 0) { return findProviderVendorById(vendorId); @@ -232,6 +240,7 @@ export async function updateProviderVendor( if (payload.displayName !== undefined) updates.displayName = payload.displayName; if (payload.websiteUrl !== undefined) updates.websiteUrl = payload.websiteUrl; if (payload.faviconUrl !== undefined) updates.faviconUrl = payload.faviconUrl; + if (payload.isOfficial !== undefined) updates.isOfficial = payload.isOfficial; const rows = await db .update(providerVendors) @@ -243,6 +252,7 @@ export async function updateProviderVendor( displayName: providerVendors.displayName, websiteUrl: providerVendors.websiteUrl, faviconUrl: providerVendors.faviconUrl, + isOfficial: providerVendors.isOfficial, createdAt: providerVendors.createdAt, updatedAt: providerVendors.updatedAt, }); @@ -250,6 +260,77 @@ export async function updateProviderVendor( return rows[0] ? toProviderVendor(rows[0]) : null; } +export async function deleteProviderVendor(vendorId: number): Promise { + const deleted = await db.transaction(async (tx) => { + // 1. Delete endpoints (cascade would handle this, but manual is safe) + await tx.delete(providerEndpoints).where(eq(providerEndpoints.vendorId, vendorId)); + // 2. Delete providers (keys) - explicit delete required due to 'restrict' + await tx.delete(providers).where(eq(providers.providerVendorId, vendorId)); + // 3. Delete vendor + const result = await tx + .delete(providerVendors) + .where(eq(providerVendors.id, vendorId)) + .returning({ id: providerVendors.id }); + + return result.length > 0; + }); + + return deleted; +} + +export async function tryDeleteProviderVendorIfEmpty(vendorId: number): Promise { + try { + return await db.transaction(async (tx) => { + // 1) Must have no active providers (soft-deleted rows still exist but should not block). + const [activeProvider] = await tx + .select({ id: providers.id }) + .from(providers) + .where(and(eq(providers.providerVendorId, vendorId), isNull(providers.deletedAt))) + .limit(1); + + if (activeProvider) { + return false; + } + + // 2) Must have no active endpoints. + const [activeEndpoint] = await tx + .select({ id: providerEndpoints.id }) + .from(providerEndpoints) + .where(and(eq(providerEndpoints.vendorId, vendorId), isNull(providerEndpoints.deletedAt))) + .limit(1); + + if (activeEndpoint) { + return false; + } + + // 3) Hard delete soft-deleted providers to satisfy FK `onDelete: restrict`. + await tx + .delete(providers) + .where(and(eq(providers.providerVendorId, vendorId), isNotNull(providers.deletedAt))); + + // 4) Delete vendor. Endpoints will be physically removed by FK cascade. + const deleted = await tx + .delete(providerVendors) + .where( + and( + eq(providerVendors.id, vendorId), + sql`NOT EXISTS (SELECT 1 FROM providers p WHERE p.provider_vendor_id = ${vendorId} AND p.deleted_at IS NULL)`, + sql`NOT EXISTS (SELECT 1 FROM provider_endpoints e WHERE e.vendor_id = ${vendorId} AND e.deleted_at IS NULL)` + ) + ) + .returning({ id: providerVendors.id }); + + return deleted.length > 0; + }); + } catch (error) { + logger.warn("[ProviderVendor] Auto delete failed", { + vendorId, + error: error instanceof Error ? error.message : String(error), + }); + return false; + } +} + export async function findProviderEndpointsByVendorAndType( vendorId: number, providerType: ProviderType diff --git a/src/repository/provider.ts b/src/repository/provider.ts index ce8082d66..f9d90b3e4 100644 --- a/src/repository/provider.ts +++ b/src/repository/provider.ts @@ -11,6 +11,7 @@ import { toProvider } from "./_shared/transformers"; import { ensureProviderEndpointExistsForUrl, getOrCreateProviderVendorIdFromUrls, + tryDeleteProviderVendorIfEmpty, } from "./provider-endpoints"; export async function createProvider(providerData: CreateProviderData): Promise { @@ -382,6 +383,7 @@ export async function updateProvider( const dbData: any = { updatedAt: new Date(), }; + if (providerData.name !== undefined) dbData.name = providerData.name; if (providerData.url !== undefined) dbData.url = providerData.url; if (providerData.key !== undefined) dbData.key = providerData.key; @@ -466,6 +468,7 @@ export async function updateProvider( if (providerData.rpd !== undefined) dbData.rpd = providerData.rpd; if (providerData.cc !== undefined) dbData.cc = providerData.cc; + let previousVendorId: number | null = null; if (providerData.url !== undefined || providerData.website_url !== undefined) { const [current] = await db .select({ @@ -473,12 +476,14 @@ export async function updateProvider( websiteUrl: providers.websiteUrl, faviconUrl: providers.faviconUrl, name: providers.name, + providerVendorId: providers.providerVendorId, }) .from(providers) .where(and(eq(providers.id, id), isNull(providers.deletedAt))) .limit(1); if (current) { + previousVendorId = current.providerVendorId; const providerVendorId = await getOrCreateProviderVendorIdFromUrls({ providerUrl: providerData.url ?? current.url, websiteUrl: providerData.website_url ?? current.websiteUrl, @@ -571,6 +576,10 @@ export async function updateProvider( } } + if (previousVendorId && transformed.providerVendorId !== previousVendorId) { + await tryDeleteProviderVendorIfEmpty(previousVendorId); + } + return transformed; } diff --git a/src/types/provider.ts b/src/types/provider.ts index 661c31aa5..8b205aa4c 100644 --- a/src/types/provider.ts +++ b/src/types/provider.ts @@ -378,6 +378,7 @@ export interface ProviderVendor { displayName: string | null; websiteUrl: string | null; faviconUrl: string | null; + isOfficial: boolean; createdAt: Date; updatedAt: Date; } diff --git a/tests/unit/actions/provider-endpoints.test.ts b/tests/unit/actions/provider-endpoints.test.ts new file mode 100644 index 000000000..34ec702dc --- /dev/null +++ b/tests/unit/actions/provider-endpoints.test.ts @@ -0,0 +1,203 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const getSessionMock = vi.fn(); + +const updateProviderVendorMock = vi.fn(); +const deleteProviderVendorMock = vi.fn(); +const publishProviderCacheInvalidationMock = vi.fn(); + +const findProviderEndpointByIdMock = vi.fn(); +const softDeleteProviderEndpointMock = vi.fn(); +const tryDeleteProviderVendorIfEmptyMock = vi.fn(); + +vi.mock("@/lib/auth", () => ({ + getSession: getSessionMock, +})); + +vi.mock("@/lib/cache/provider-cache", () => ({ + publishProviderCacheInvalidation: publishProviderCacheInvalidationMock, +})); + +vi.mock("@/lib/logger", () => ({ + logger: { + trace: vi.fn(), + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); + +vi.mock("@/lib/endpoint-circuit-breaker", () => ({ + getEndpointHealthInfo: vi.fn(async () => ({ health: {}, config: {} })), + resetEndpointCircuit: vi.fn(async () => {}), +})); + +vi.mock("@/lib/vendor-type-circuit-breaker", () => ({ + getVendorTypeCircuitInfo: vi.fn(async () => ({ + vendorId: 1, + providerType: "claude", + circuitState: "closed", + circuitOpenUntil: null, + lastFailureTime: null, + manualOpen: false, + })), + resetVendorTypeCircuit: vi.fn(async () => {}), + setVendorTypeCircuitManualOpen: vi.fn(async () => {}), +})); + +vi.mock("@/lib/provider-endpoints/probe", () => ({ + probeProviderEndpointAndRecord: vi.fn(async () => null), +})); + +vi.mock("@/repository", () => ({ + createProviderEndpoint: vi.fn(async () => ({})), + deleteProviderVendor: deleteProviderVendorMock, + findProviderEndpointById: findProviderEndpointByIdMock, + findProviderEndpointProbeLogs: vi.fn(async () => []), + findProviderEndpointsByVendorAndType: vi.fn(async () => []), + findProviderVendorById: vi.fn(async () => null), + findProviderVendors: vi.fn(async () => []), + softDeleteProviderEndpoint: softDeleteProviderEndpointMock, + tryDeleteProviderVendorIfEmpty: tryDeleteProviderVendorIfEmptyMock, + updateProviderEndpoint: vi.fn(async () => null), + updateProviderVendor: updateProviderVendorMock, +})); + +describe("provider-endpoints actions", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("editProviderVendor: requires admin", async () => { + getSessionMock.mockResolvedValue({ user: { role: "user" } }); + + const { editProviderVendor } = await import("@/actions/provider-endpoints"); + const res = await editProviderVendor({ vendorId: 1, displayName: "x" }); + + expect(res.ok).toBe(false); + expect(res.errorCode).toBe("PERMISSION_DENIED"); + }); + + it("editProviderVendor: computes favicon and forwards isOfficial", async () => { + getSessionMock.mockResolvedValue({ user: { role: "admin" } }); + + updateProviderVendorMock.mockResolvedValue({ + id: 1, + websiteDomain: "example.com", + displayName: "Example", + websiteUrl: "https://example.com/path", + faviconUrl: "https://www.google.com/s2/favicons?domain=example.com&sz=32", + isOfficial: true, + createdAt: new Date(), + updatedAt: new Date(), + }); + + const { editProviderVendor } = await import("@/actions/provider-endpoints"); + const res = await editProviderVendor({ + vendorId: 1, + displayName: "Example", + websiteUrl: "https://example.com/path", + isOfficial: true, + }); + + expect(res.ok).toBe(true); + expect(updateProviderVendorMock).toHaveBeenCalledWith( + 1, + expect.objectContaining({ + displayName: "Example", + websiteUrl: "https://example.com/path", + faviconUrl: "https://www.google.com/s2/favicons?domain=example.com&sz=32", + isOfficial: true, + }) + ); + }); + + it("editProviderVendor: clearing websiteUrl clears faviconUrl", async () => { + getSessionMock.mockResolvedValue({ user: { role: "admin" } }); + + updateProviderVendorMock.mockResolvedValue({ + id: 1, + websiteDomain: "example.com", + displayName: null, + websiteUrl: null, + faviconUrl: null, + isOfficial: false, + createdAt: new Date(), + updatedAt: new Date(), + }); + + const { editProviderVendor } = await import("@/actions/provider-endpoints"); + const res = await editProviderVendor({ + vendorId: 1, + websiteUrl: null, + }); + + expect(res.ok).toBe(true); + expect(updateProviderVendorMock).toHaveBeenCalledWith( + 1, + expect.objectContaining({ + websiteUrl: null, + faviconUrl: null, + }) + ); + }); + + it("removeProviderVendor: deletes vendor and publishes cache invalidation", async () => { + getSessionMock.mockResolvedValue({ user: { role: "admin" } }); + + deleteProviderVendorMock.mockResolvedValue(true); + publishProviderCacheInvalidationMock.mockResolvedValue(undefined); + + const { removeProviderVendor } = await import("@/actions/provider-endpoints"); + const res = await removeProviderVendor({ vendorId: 1 }); + + expect(res.ok).toBe(true); + expect(deleteProviderVendorMock).toHaveBeenCalledWith(1); + expect(publishProviderCacheInvalidationMock).toHaveBeenCalledTimes(1); + }); + + it("removeProviderVendor: still ok when cache invalidation fails", async () => { + getSessionMock.mockResolvedValue({ user: { role: "admin" } }); + + deleteProviderVendorMock.mockResolvedValue(true); + publishProviderCacheInvalidationMock.mockRejectedValue(new Error("boom")); + + const { removeProviderVendor } = await import("@/actions/provider-endpoints"); + const res = await removeProviderVendor({ vendorId: 1 }); + + expect(res.ok).toBe(true); + expect(deleteProviderVendorMock).toHaveBeenCalledWith(1); + }); + + it("removeProviderEndpoint: triggers vendor cleanup after soft delete", async () => { + getSessionMock.mockResolvedValue({ user: { role: "admin" } }); + + findProviderEndpointByIdMock.mockResolvedValue({ + id: 99, + vendorId: 123, + providerType: "claude", + url: "https://api.example.com", + label: null, + sortOrder: 0, + isEnabled: true, + lastProbedAt: null, + lastProbeOk: null, + lastProbeStatusCode: null, + lastProbeLatencyMs: null, + lastProbeErrorType: null, + lastProbeErrorMessage: null, + createdAt: new Date(), + updatedAt: new Date(), + deletedAt: null, + }); + softDeleteProviderEndpointMock.mockResolvedValue(true); + tryDeleteProviderVendorIfEmptyMock.mockResolvedValue(true); + + const { removeProviderEndpoint } = await import("@/actions/provider-endpoints"); + const res = await removeProviderEndpoint({ endpointId: 99 }); + + expect(res.ok).toBe(true); + expect(tryDeleteProviderVendorIfEmptyMock).toHaveBeenCalledWith(123); + }); +}); diff --git a/tests/unit/app/v1/url.test.ts b/tests/unit/app/v1/url.test.ts new file mode 100644 index 000000000..bec47b9fa --- /dev/null +++ b/tests/unit/app/v1/url.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, test, vi } from "vitest"; + +vi.mock("@/lib/logger", () => ({ + logger: { + debug: vi.fn(), + error: vi.fn(), + }, +})); + +import { buildProxyUrl } from "@/app/v1/_lib/url"; + +describe("buildProxyUrl", () => { + test("标准拼接:baseUrl 无路径时使用 requestPath + search", () => { + const out = buildProxyUrl( + "https://api.example.com", + new URL("https://dummy.com/v1/messages?x=1") + ); + + expect(out).toBe("https://api.example.com/v1/messages?x=1"); + }); + + test("避免重复拼接:baseUrl 已包含 /responses 时不追加 /v1/responses", () => { + const out = buildProxyUrl( + "https://example.com/openai/responses", + new URL("https://dummy.com/v1/responses?x=1") + ); + + expect(out).toBe("https://example.com/openai/responses?x=1"); + }); + + test("子路径不丢失:baseUrl=/v1/messages + request=/v1/messages/count_tokens", () => { + const out = buildProxyUrl( + "https://api.example.com/v1/messages", + new URL("https://dummy.com/v1/messages/count_tokens") + ); + + expect(out).toBe("https://api.example.com/v1/messages/count_tokens"); + }); + + test("带前缀路径的 baseUrl:/openai/messages + /v1/messages/count_tokens", () => { + const out = buildProxyUrl( + "https://example.com/openai/messages", + new URL("https://dummy.com/v1/messages/count_tokens") + ); + + expect(out).toBe("https://example.com/openai/messages/count_tokens"); + }); + + test("query 以 requestUrl 为准(覆盖 baseUrl 自带 query)", () => { + const out = buildProxyUrl( + "https://api.example.com/v1/messages?from=base", + new URL("https://dummy.com/v1/messages?from=request") + ); + + expect(out).toBe("https://api.example.com/v1/messages?from=request"); + }); +}); diff --git a/tests/unit/repository/provider-endpoints.test.ts b/tests/unit/repository/provider-endpoints.test.ts index 352402924..a8a4015bc 100644 --- a/tests/unit/repository/provider-endpoints.test.ts +++ b/tests/unit/repository/provider-endpoints.test.ts @@ -251,4 +251,237 @@ describe("provider-endpoints repository", () => { expect(result).toEqual({ inserted: 0, uniqueCandidates: 2, skippedInvalid: 0 }); }); + + test("tryDeleteProviderVendorIfEmpty: 有 active provider 时不删除", async () => { + vi.resetModules(); + + const selectPages = [[{ id: 1 }], []]; + const selectMock = vi.fn(() => createThenableQuery(selectPages.shift() ?? [])); + const deleteMock = vi.fn(); + const transactionMock = vi.fn(async (fn: (tx: any) => Promise) => { + return fn({ + select: selectMock, + delete: deleteMock, + }); + }); + + vi.doMock("@/drizzle/db", () => ({ + db: { + transaction: transactionMock, + }, + })); + + const { tryDeleteProviderVendorIfEmpty } = await import("@/repository/provider-endpoints"); + const ok = await tryDeleteProviderVendorIfEmpty(123); + + expect(ok).toBe(false); + expect(selectMock).toHaveBeenCalledTimes(1); + expect(deleteMock).not.toHaveBeenCalled(); + }); + + test("tryDeleteProviderVendorIfEmpty: 有 active endpoint 时不删除", async () => { + vi.resetModules(); + + const selectPages = [[], [{ id: 1 }]]; + const selectMock = vi.fn(() => createThenableQuery(selectPages.shift() ?? [])); + const deleteMock = vi.fn(); + const transactionMock = vi.fn(async (fn: (tx: any) => Promise) => { + return fn({ + select: selectMock, + delete: deleteMock, + }); + }); + + vi.doMock("@/drizzle/db", () => ({ + db: { + transaction: transactionMock, + }, + })); + + const { tryDeleteProviderVendorIfEmpty } = await import("@/repository/provider-endpoints"); + const ok = await tryDeleteProviderVendorIfEmpty(123); + + expect(ok).toBe(false); + expect(selectMock).toHaveBeenCalledTimes(2); + expect(deleteMock).not.toHaveBeenCalled(); + }); + + test("tryDeleteProviderVendorIfEmpty: 无 active provider/endpoint 时删除 vendor", async () => { + vi.resetModules(); + + const selectPages = [[], []]; + const selectMock = vi.fn(() => createThenableQuery(selectPages.shift() ?? [])); + + let deleteCallIndex = 0; + const deleteMock = vi.fn(() => { + deleteCallIndex += 1; + if (deleteCallIndex === 1) { + return { + where: vi.fn(async () => []), + }; + } + return { + where: vi.fn(() => ({ + returning: vi.fn(async () => [{ id: 123 }]), + })), + }; + }); + + const transactionMock = vi.fn(async (fn: (tx: any) => Promise) => { + return fn({ + select: selectMock, + delete: deleteMock, + }); + }); + + vi.doMock("@/drizzle/db", () => ({ + db: { + transaction: transactionMock, + }, + })); + + const { tryDeleteProviderVendorIfEmpty } = await import("@/repository/provider-endpoints"); + const ok = await tryDeleteProviderVendorIfEmpty(123); + + expect(ok).toBe(true); + expect(selectMock).toHaveBeenCalledTimes(2); + expect(deleteMock).toHaveBeenCalledTimes(2); + }); + + test("tryDeleteProviderVendorIfEmpty: vendor 不存在时返回 false", async () => { + vi.resetModules(); + + const selectPages = [[], []]; + const selectMock = vi.fn(() => createThenableQuery(selectPages.shift() ?? [])); + + let deleteCallIndex = 0; + const deleteMock = vi.fn(() => { + deleteCallIndex += 1; + if (deleteCallIndex === 1) { + return { + where: vi.fn(async () => []), + }; + } + return { + where: vi.fn(() => ({ + returning: vi.fn(async () => []), + })), + }; + }); + + const transactionMock = vi.fn(async (fn: (tx: any) => Promise) => { + return fn({ + select: selectMock, + delete: deleteMock, + }); + }); + + vi.doMock("@/drizzle/db", () => ({ + db: { + transaction: transactionMock, + }, + })); + + const { tryDeleteProviderVendorIfEmpty } = await import("@/repository/provider-endpoints"); + const ok = await tryDeleteProviderVendorIfEmpty(123); + + expect(ok).toBe(false); + expect(selectMock).toHaveBeenCalledTimes(2); + expect(deleteMock).toHaveBeenCalledTimes(2); + }); + + test("tryDeleteProviderVendorIfEmpty: transaction 抛错时返回 false", async () => { + vi.resetModules(); + + const transactionMock = vi.fn(async () => { + throw new Error("boom"); + }); + + vi.doMock("@/drizzle/db", () => ({ + db: { + transaction: transactionMock, + }, + })); + + const { tryDeleteProviderVendorIfEmpty } = await import("@/repository/provider-endpoints"); + const ok = await tryDeleteProviderVendorIfEmpty(123); + + expect(ok).toBe(false); + }); + + test("deleteProviderVendor: vendor 存在时返回 true 且执行级联删除", async () => { + vi.resetModules(); + + let deleteCallIndex = 0; + const deleteMock = vi.fn(() => { + deleteCallIndex += 1; + // 1) delete endpoints, 2) delete providers + if (deleteCallIndex <= 2) { + return { + where: vi.fn(async () => []), + }; + } + // 3) delete vendor + return { + where: vi.fn(() => ({ + returning: vi.fn(async () => [{ id: 123 }]), + })), + }; + }); + + const transactionMock = vi.fn(async (fn: (tx: any) => Promise) => { + return fn({ + delete: deleteMock, + }); + }); + + vi.doMock("@/drizzle/db", () => ({ + db: { + transaction: transactionMock, + }, + })); + + const { deleteProviderVendor } = await import("@/repository/provider-endpoints"); + const ok = await deleteProviderVendor(123); + + expect(ok).toBe(true); + expect(deleteMock).toHaveBeenCalledTimes(3); + }); + + test("deleteProviderVendor: vendor 不存在时返回 false", async () => { + vi.resetModules(); + + let deleteCallIndex = 0; + const deleteMock = vi.fn(() => { + deleteCallIndex += 1; + if (deleteCallIndex <= 2) { + return { + where: vi.fn(async () => []), + }; + } + return { + where: vi.fn(() => ({ + returning: vi.fn(async () => []), + })), + }; + }); + + const transactionMock = vi.fn(async (fn: (tx: any) => Promise) => { + return fn({ + delete: deleteMock, + }); + }); + + vi.doMock("@/drizzle/db", () => ({ + db: { + transaction: transactionMock, + }, + })); + + const { deleteProviderVendor } = await import("@/repository/provider-endpoints"); + const ok = await deleteProviderVendor(123); + + expect(ok).toBe(false); + expect(deleteMock).toHaveBeenCalledTimes(3); + }); }); From ecf78b4f940a42570c5845b377cb14241945bbd3 Mon Sep 17 00:00:00 2001 From: ding113 Date: Thu, 15 Jan 2026 21:17:42 +0800 Subject: [PATCH 06/10] refactor(providers): remove isOfficial flag from vendor schema and related components --- drizzle/0056_colorful_nightshade.sql | 1 + drizzle/meta/0056_snapshot.json | 2874 +++++++++++++++++ drizzle/meta/_journal.json | 7 + src/actions/provider-endpoints.ts | 2 - .../_components/provider-vendor-view.tsx | 17 +- src/drizzle/schema.ts | 1 - src/lib/circuit-breaker-probe.ts | 4 +- src/repository/provider-endpoints.ts | 6 - src/types/provider.ts | 1 - tests/unit/actions/provider-endpoints.test.ts | 6 +- 10 files changed, 2886 insertions(+), 33 deletions(-) create mode 100644 drizzle/0056_colorful_nightshade.sql create mode 100644 drizzle/meta/0056_snapshot.json diff --git a/drizzle/0056_colorful_nightshade.sql b/drizzle/0056_colorful_nightshade.sql new file mode 100644 index 000000000..e04096773 --- /dev/null +++ b/drizzle/0056_colorful_nightshade.sql @@ -0,0 +1 @@ +ALTER TABLE "provider_vendors" DROP COLUMN "is_official"; \ No newline at end of file diff --git a/drizzle/meta/0056_snapshot.json b/drizzle/meta/0056_snapshot.json new file mode 100644 index 000000000..6d76ac604 --- /dev/null +++ b/drizzle/meta/0056_snapshot.json @@ -0,0 +1,2874 @@ +{ + "id": "4f67adbe-ba12-42b2-a553-558d6064974f", + "prevId": "390e30e9-a88b-4b14-b1d7-e51ccc03a913", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.error_rules": { + "name": "error_rules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "pattern": { + "name": "pattern", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "match_type": { + "name": "match_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'regex'" + }, + "category": { + "name": "category", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "override_response": { + "name": "override_response", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "override_status_code": { + "name": "override_status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_error_rules_enabled": { + "name": "idx_error_rules_enabled", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "unique_pattern": { + "name": "unique_pattern", + "columns": [ + { + "expression": "pattern", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_category": { + "name": "idx_category", + "columns": [ + { + "expression": "category", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_match_type": { + "name": "idx_match_type", + "columns": [ + { + "expression": "match_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.keys": { + "name": "keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "can_login_web_ui": { + "name": "can_login_web_ui", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "limit_5h_usd": { + "name": "limit_5h_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_daily_usd": { + "name": "limit_daily_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "daily_reset_mode": { + "name": "daily_reset_mode", + "type": "daily_reset_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'fixed'" + }, + "daily_reset_time": { + "name": "daily_reset_time", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true, + "default": "'00:00'" + }, + "limit_weekly_usd": { + "name": "limit_weekly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_monthly_usd": { + "name": "limit_monthly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_total_usd": { + "name": "limit_total_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_concurrent_sessions": { + "name": "limit_concurrent_sessions", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "provider_group": { + "name": "provider_group", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false, + "default": "'default'" + }, + "cache_ttl_preference": { + "name": "cache_ttl_preference", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_keys_user_id": { + "name": "idx_keys_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_keys_created_at": { + "name": "idx_keys_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_keys_deleted_at": { + "name": "idx_keys_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.message_request": { + "name": "message_request", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cost_usd": { + "name": "cost_usd", + "type": "numeric(21, 15)", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "cost_multiplier": { + "name": "cost_multiplier", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "request_sequence": { + "name": "request_sequence", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 1 + }, + "provider_chain": { + "name": "provider_chain", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "api_type": { + "name": "api_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "endpoint": { + "name": "endpoint", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "original_model": { + "name": "original_model", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "input_tokens": { + "name": "input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "output_tokens": { + "name": "output_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "ttfb_ms": { + "name": "ttfb_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cache_creation_input_tokens": { + "name": "cache_creation_input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cache_read_input_tokens": { + "name": "cache_read_input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cache_creation_5m_input_tokens": { + "name": "cache_creation_5m_input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cache_creation_1h_input_tokens": { + "name": "cache_creation_1h_input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cache_ttl_applied": { + "name": "cache_ttl_applied", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "context_1m_applied": { + "name": "context_1m_applied", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "special_settings": { + "name": "special_settings", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_stack": { + "name": "error_stack", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_cause": { + "name": "error_cause", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "blocked_by": { + "name": "blocked_by", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "blocked_reason": { + "name": "blocked_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "messages_count": { + "name": "messages_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_message_request_user_date_cost": { + "name": "idx_message_request_user_date_cost", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_usd", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_user_query": { + "name": "idx_message_request_user_query", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_session_id": { + "name": "idx_message_request_session_id", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_session_seq": { + "name": "idx_message_request_session_seq", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "request_sequence", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_endpoint": { + "name": "idx_message_request_endpoint", + "columns": [ + { + "expression": "endpoint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_blocked_by": { + "name": "idx_message_request_blocked_by", + "columns": [ + { + "expression": "blocked_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_provider_id": { + "name": "idx_message_request_provider_id", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_user_id": { + "name": "idx_message_request_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_key": { + "name": "idx_message_request_key", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_created_at": { + "name": "idx_message_request_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_deleted_at": { + "name": "idx_message_request_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.model_prices": { + "name": "model_prices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "model_name": { + "name": "model_name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "price_data": { + "name": "price_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'litellm'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_model_prices_latest": { + "name": "idx_model_prices_latest", + "columns": [ + { + "expression": "model_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_model_prices_model_name": { + "name": "idx_model_prices_model_name", + "columns": [ + { + "expression": "model_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_model_prices_created_at": { + "name": "idx_model_prices_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_model_prices_source": { + "name": "idx_model_prices_source", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification_settings": { + "name": "notification_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "use_legacy_mode": { + "name": "use_legacy_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "circuit_breaker_enabled": { + "name": "circuit_breaker_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "circuit_breaker_webhook": { + "name": "circuit_breaker_webhook", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "daily_leaderboard_enabled": { + "name": "daily_leaderboard_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "daily_leaderboard_webhook": { + "name": "daily_leaderboard_webhook", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "daily_leaderboard_time": { + "name": "daily_leaderboard_time", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false, + "default": "'09:00'" + }, + "daily_leaderboard_top_n": { + "name": "daily_leaderboard_top_n", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 5 + }, + "cost_alert_enabled": { + "name": "cost_alert_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cost_alert_webhook": { + "name": "cost_alert_webhook", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "cost_alert_threshold": { + "name": "cost_alert_threshold", + "type": "numeric(5, 2)", + "primaryKey": false, + "notNull": false, + "default": "'0.80'" + }, + "cost_alert_check_interval": { + "name": "cost_alert_check_interval", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 60 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification_target_bindings": { + "name": "notification_target_bindings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "notification_type": { + "name": "notification_type", + "type": "notification_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "schedule_cron": { + "name": "schedule_cron", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "schedule_timezone": { + "name": "schedule_timezone", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "default": "'Asia/Shanghai'" + }, + "template_override": { + "name": "template_override", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "unique_notification_target_binding": { + "name": "unique_notification_target_binding", + "columns": [ + { + "expression": "notification_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_notification_bindings_type": { + "name": "idx_notification_bindings_type", + "columns": [ + { + "expression": "notification_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_notification_bindings_target": { + "name": "idx_notification_bindings_target", + "columns": [ + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "notification_target_bindings_target_id_webhook_targets_id_fk": { + "name": "notification_target_bindings_target_id_webhook_targets_id_fk", + "tableFrom": "notification_target_bindings", + "tableTo": "webhook_targets", + "columnsFrom": [ + "target_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.provider_endpoint_probe_logs": { + "name": "provider_endpoint_probe_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "endpoint_id": { + "name": "endpoint_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'scheduled'" + }, + "ok": { + "name": "ok", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "latency_ms": { + "name": "latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "error_type": { + "name": "error_type", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_provider_endpoint_probe_logs_endpoint_created_at": { + "name": "idx_provider_endpoint_probe_logs_endpoint_created_at", + "columns": [ + { + "expression": "endpoint_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoint_probe_logs_created_at": { + "name": "idx_provider_endpoint_probe_logs_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "provider_endpoint_probe_logs_endpoint_id_provider_endpoints_id_fk": { + "name": "provider_endpoint_probe_logs_endpoint_id_provider_endpoints_id_fk", + "tableFrom": "provider_endpoint_probe_logs", + "tableTo": "provider_endpoints", + "columnsFrom": [ + "endpoint_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.provider_endpoints": { + "name": "provider_endpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "vendor_id": { + "name": "vendor_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "provider_type": { + "name": "provider_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'claude'" + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_probed_at": { + "name": "last_probed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_probe_ok": { + "name": "last_probe_ok", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "last_probe_status_code": { + "name": "last_probe_status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_probe_latency_ms": { + "name": "last_probe_latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_probe_error_type": { + "name": "last_probe_error_type", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "last_probe_error_message": { + "name": "last_probe_error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "uniq_provider_endpoints_vendor_type_url": { + "name": "uniq_provider_endpoints_vendor_type_url", + "columns": [ + { + "expression": "vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoints_vendor_type": { + "name": "idx_provider_endpoints_vendor_type", + "columns": [ + { + "expression": "vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"provider_endpoints\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoints_enabled": { + "name": "idx_provider_endpoints_enabled", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"provider_endpoints\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoints_created_at": { + "name": "idx_provider_endpoints_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoints_deleted_at": { + "name": "idx_provider_endpoints_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "provider_endpoints_vendor_id_provider_vendors_id_fk": { + "name": "provider_endpoints_vendor_id_provider_vendors_id_fk", + "tableFrom": "provider_endpoints", + "tableTo": "provider_vendors", + "columnsFrom": [ + "vendor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.provider_vendors": { + "name": "provider_vendors", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "website_domain": { + "name": "website_domain", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "website_url": { + "name": "website_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "favicon_url": { + "name": "favicon_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "uniq_provider_vendors_website_domain": { + "name": "uniq_provider_vendors_website_domain", + "columns": [ + { + "expression": "website_domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_vendors_created_at": { + "name": "idx_provider_vendors_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.providers": { + "name": "providers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "url": { + "name": "url", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "provider_vendor_id": { + "name": "provider_vendor_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "weight": { + "name": "weight", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cost_multiplier": { + "name": "cost_multiplier", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false, + "default": "'1.0'" + }, + "group_tag": { + "name": "group_tag", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "provider_type": { + "name": "provider_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'claude'" + }, + "preserve_client_ip": { + "name": "preserve_client_ip", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "model_redirects": { + "name": "model_redirects", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "allowed_models": { + "name": "allowed_models", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'null'::jsonb" + }, + "join_claude_pool": { + "name": "join_claude_pool", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "codex_instructions_strategy": { + "name": "codex_instructions_strategy", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "default": "'auto'" + }, + "mcp_passthrough_type": { + "name": "mcp_passthrough_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "mcp_passthrough_url": { + "name": "mcp_passthrough_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "limit_5h_usd": { + "name": "limit_5h_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_daily_usd": { + "name": "limit_daily_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "daily_reset_mode": { + "name": "daily_reset_mode", + "type": "daily_reset_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'fixed'" + }, + "daily_reset_time": { + "name": "daily_reset_time", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true, + "default": "'00:00'" + }, + "limit_weekly_usd": { + "name": "limit_weekly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_monthly_usd": { + "name": "limit_monthly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_total_usd": { + "name": "limit_total_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "total_cost_reset_at": { + "name": "total_cost_reset_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "limit_concurrent_sessions": { + "name": "limit_concurrent_sessions", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "max_retry_attempts": { + "name": "max_retry_attempts", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "circuit_breaker_failure_threshold": { + "name": "circuit_breaker_failure_threshold", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 5 + }, + "circuit_breaker_open_duration": { + "name": "circuit_breaker_open_duration", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 1800000 + }, + "circuit_breaker_half_open_success_threshold": { + "name": "circuit_breaker_half_open_success_threshold", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 2 + }, + "proxy_url": { + "name": "proxy_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "proxy_fallback_to_direct": { + "name": "proxy_fallback_to_direct", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "first_byte_timeout_streaming_ms": { + "name": "first_byte_timeout_streaming_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "streaming_idle_timeout_ms": { + "name": "streaming_idle_timeout_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "request_timeout_non_streaming_ms": { + "name": "request_timeout_non_streaming_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "website_url": { + "name": "website_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "favicon_url": { + "name": "favicon_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cache_ttl_preference": { + "name": "cache_ttl_preference", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "context_1m_preference": { + "name": "context_1m_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "codex_reasoning_effort_preference": { + "name": "codex_reasoning_effort_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "codex_reasoning_summary_preference": { + "name": "codex_reasoning_summary_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "codex_text_verbosity_preference": { + "name": "codex_text_verbosity_preference", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "codex_parallel_tool_calls_preference": { + "name": "codex_parallel_tool_calls_preference", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "tpm": { + "name": "tpm", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "rpm": { + "name": "rpm", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "rpd": { + "name": "rpd", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "cc": { + "name": "cc", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_providers_enabled_priority": { + "name": "idx_providers_enabled_priority", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "weight", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"providers\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_group": { + "name": "idx_providers_group", + "columns": [ + { + "expression": "group_tag", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"providers\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_created_at": { + "name": "idx_providers_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_deleted_at": { + "name": "idx_providers_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_vendor_type": { + "name": "idx_providers_vendor_type", + "columns": [ + { + "expression": "provider_vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"providers\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "providers_provider_vendor_id_provider_vendors_id_fk": { + "name": "providers_provider_vendor_id_provider_vendors_id_fk", + "tableFrom": "providers", + "tableTo": "provider_vendors", + "columnsFrom": [ + "provider_vendor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.request_filters": { + "name": "request_filters", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true + }, + "match_type": { + "name": "match_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "target": { + "name": "target", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "replacement": { + "name": "replacement", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "binding_type": { + "name": "binding_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'global'" + }, + "provider_ids": { + "name": "provider_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "group_tags": { + "name": "group_tags", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_request_filters_enabled": { + "name": "idx_request_filters_enabled", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_request_filters_scope": { + "name": "idx_request_filters_scope", + "columns": [ + { + "expression": "scope", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_request_filters_action": { + "name": "idx_request_filters_action", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_request_filters_binding": { + "name": "idx_request_filters_binding", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "binding_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sensitive_words": { + "name": "sensitive_words", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "word": { + "name": "word", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "match_type": { + "name": "match_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'contains'" + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_sensitive_words_enabled": { + "name": "idx_sensitive_words_enabled", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "match_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_sensitive_words_created_at": { + "name": "idx_sensitive_words_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.system_settings": { + "name": "system_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "site_title": { + "name": "site_title", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "default": "'Claude Code Hub'" + }, + "allow_global_usage_view": { + "name": "allow_global_usage_view", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "currency_display": { + "name": "currency_display", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "default": "'USD'" + }, + "billing_model_source": { + "name": "billing_model_source", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'original'" + }, + "enable_auto_cleanup": { + "name": "enable_auto_cleanup", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "cleanup_retention_days": { + "name": "cleanup_retention_days", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 30 + }, + "cleanup_schedule": { + "name": "cleanup_schedule", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "default": "'0 2 * * *'" + }, + "cleanup_batch_size": { + "name": "cleanup_batch_size", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 10000 + }, + "enable_client_version_check": { + "name": "enable_client_version_check", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "verbose_provider_error": { + "name": "verbose_provider_error", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "enable_http2": { + "name": "enable_http2", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "intercept_anthropic_warmup_requests": { + "name": "intercept_anthropic_warmup_requests", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "enable_thinking_signature_rectifier": { + "name": "enable_thinking_signature_rectifier", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_codex_session_id_completion": { + "name": "enable_codex_session_id_completion", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_response_fixer": { + "name": "enable_response_fixer", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "response_fixer_config": { + "name": "response_fixer_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{\"fixTruncatedJson\":true,\"fixSseFormat\":true,\"fixEncoding\":true,\"maxJsonDepth\":200,\"maxFixSize\":1048576}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "varchar", + "primaryKey": false, + "notNull": false, + "default": "'user'" + }, + "rpm_limit": { + "name": "rpm_limit", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "daily_limit_usd": { + "name": "daily_limit_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "provider_group": { + "name": "provider_group", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false, + "default": "'default'" + }, + "tags": { + "name": "tags", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "limit_5h_usd": { + "name": "limit_5h_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_weekly_usd": { + "name": "limit_weekly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_monthly_usd": { + "name": "limit_monthly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_total_usd": { + "name": "limit_total_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_concurrent_sessions": { + "name": "limit_concurrent_sessions", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "daily_reset_mode": { + "name": "daily_reset_mode", + "type": "daily_reset_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'fixed'" + }, + "daily_reset_time": { + "name": "daily_reset_time", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true, + "default": "'00:00'" + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "allowed_clients": { + "name": "allowed_clients", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "allowed_models": { + "name": "allowed_models", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_users_active_role_sort": { + "name": "idx_users_active_role_sort", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"users\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_users_enabled_expires_at": { + "name": "idx_users_enabled_expires_at", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"users\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_users_created_at": { + "name": "idx_users_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_users_deleted_at": { + "name": "idx_users_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook_targets": { + "name": "webhook_targets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "provider_type": { + "name": "provider_type", + "type": "webhook_provider_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "webhook_url": { + "name": "webhook_url", + "type": "varchar(1024)", + "primaryKey": false, + "notNull": false + }, + "telegram_bot_token": { + "name": "telegram_bot_token", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "telegram_chat_id": { + "name": "telegram_chat_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "dingtalk_secret": { + "name": "dingtalk_secret", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "custom_template": { + "name": "custom_template", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "custom_headers": { + "name": "custom_headers", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "proxy_url": { + "name": "proxy_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "proxy_fallback_to_direct": { + "name": "proxy_fallback_to_direct", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_test_at": { + "name": "last_test_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_test_result": { + "name": "last_test_result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.daily_reset_mode": { + "name": "daily_reset_mode", + "schema": "public", + "values": [ + "fixed", + "rolling" + ] + }, + "public.notification_type": { + "name": "notification_type", + "schema": "public", + "values": [ + "circuit_breaker", + "daily_leaderboard", + "cost_alert" + ] + }, + "public.webhook_provider_type": { + "name": "webhook_provider_type", + "schema": "public", + "values": [ + "wechat", + "feishu", + "dingtalk", + "telegram", + "custom" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index fb2a918af..5c7de6236 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -393,6 +393,13 @@ "when": 1768451294331, "tag": "0055_familiar_shatterstar", "breakpoints": true + }, + { + "idx": 56, + "version": "7", + "when": 1768482149963, + "tag": "0056_colorful_nightshade", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/actions/provider-endpoints.ts b/src/actions/provider-endpoints.ts index e3f878566..be3e8bcda 100644 --- a/src/actions/provider-endpoints.ts +++ b/src/actions/provider-endpoints.ts @@ -98,7 +98,6 @@ const EditProviderVendorSchema = z.object({ vendorId: VendorIdSchema, displayName: z.string().trim().max(200).optional().nullable(), websiteUrl: z.string().trim().url(ERROR_CODES.INVALID_URL).optional().nullable(), - isOfficial: z.boolean().optional(), }); const DeleteProviderVendorSchema = z.object({ @@ -652,7 +651,6 @@ export async function editProviderVendor( displayName: parsed.data.displayName, websiteUrl: parsed.data.websiteUrl, faviconUrl, - isOfficial: parsed.data.isOfficial, }); if (!vendor) { diff --git a/src/app/[locale]/settings/providers/_components/provider-vendor-view.tsx b/src/app/[locale]/settings/providers/_components/provider-vendor-view.tsx index b0344eacf..0e202893f 100644 --- a/src/app/[locale]/settings/providers/_components/provider-vendor-view.tsx +++ b/src/app/[locale]/settings/providers/_components/provider-vendor-view.tsx @@ -199,11 +199,6 @@ function VendorCard({
{displayName} - {vendor?.isOfficial ? ( - - {t("vendorOfficial")} - - ) : null} {websiteUrl && ( - + {t("editEndpoint")} @@ -722,11 +717,6 @@ function EditVendorDialog({ vendor, vendorId }: { vendor?: ProviderVendor; vendo const [open, setOpen] = useState(false); const queryClient = useQueryClient(); const [isSubmitting, setIsSubmitting] = useState(false); - const [isOfficial, setIsOfficial] = useState(vendor?.isOfficial ?? false); - - useEffect(() => { - if (open) setIsOfficial(vendor?.isOfficial ?? false); - }, [open, vendor?.isOfficial]); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -740,7 +730,6 @@ function EditVendorDialog({ vendor, vendorId }: { vendor?: ProviderVendor; vendo vendorId, displayName: displayName || null, websiteUrl: websiteUrl || null, - isOfficial, }); if (res.ok) { @@ -789,10 +778,6 @@ function EditVendorDialog({ vendor, vendorId }: { vendor?: ProviderVendor; vendo placeholder={t("vendorWebsitePlaceholder")} />
-
- - -
-
- - manualOpenMutation.mutate(checked)} - /> -
+ {resetMutation.isPending && } + {t("manualCircuitClose")} + + ) : null}
); } @@ -397,7 +363,7 @@ function EndpointsTable({ {t("columnUrl")} {t("status")} - {t("lastProbed")} + {t("latency")} {t("columnActions")} @@ -479,19 +445,14 @@ function EndpointRow({ endpoint }: { endpoint: ProviderEndpoint }) {
-
+
+ {endpoint.lastProbedAt ? ( -
- - {endpoint.lastProbeOk ? t("probeOk") : t("probeError")} - {endpoint.lastProbeLatencyMs && ` (${endpoint.lastProbeLatencyMs}ms)`} - - - {formatDistanceToNow(new Date(endpoint.lastProbedAt), { addSuffix: true })} - -
+ + {formatDistanceToNow(new Date(endpoint.lastProbedAt), { addSuffix: true })} + ) : ( - - + - )}
@@ -710,89 +671,6 @@ function EditEndpointDialog({ endpoint }: { endpoint: ProviderEndpoint }) { ); } -function EditVendorDialog({ vendor, vendorId }: { vendor?: ProviderVendor; vendorId: number }) { - const t = useTranslations("settings.providers"); - const tCommon = useTranslations("settings.common"); - const tErrors = useTranslations("errors"); - const [open, setOpen] = useState(false); - const queryClient = useQueryClient(); - const [isSubmitting, setIsSubmitting] = useState(false); - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - setIsSubmitting(true); - const formData = new FormData(e.currentTarget); - const displayName = formData.get("displayName") as string; - const websiteUrl = formData.get("websiteUrl") as string; - - try { - const res = await editProviderVendor({ - vendorId, - displayName: displayName || null, - websiteUrl: websiteUrl || null, - }); - - if (res.ok) { - toast.success(t("vendorUpdateSuccess")); - setOpen(false); - queryClient.invalidateQueries({ queryKey: ["provider-vendors"] }); - } else { - toast.error( - res.errorCode ? getErrorMessage(tErrors, res.errorCode) : t("vendorUpdateFailed") - ); - } - } catch (_err) { - toast.error(t("vendorUpdateFailed")); - } finally { - setIsSubmitting(false); - } - }; - - return ( - - - - - - - {t("editVendor")} - -
-
- - -
-
- - -
- - - - -
-
-
- ); -} - function DeleteVendorDialog({ vendor, vendorId }: { vendor?: ProviderVendor; vendorId: number }) { const t = useTranslations("settings.providers"); const tCommon = useTranslations("settings.common"); diff --git a/src/app/[locale]/settings/providers/_components/vendor-keys-compact-list.tsx b/src/app/[locale]/settings/providers/_components/vendor-keys-compact-list.tsx new file mode 100644 index 000000000..e534a0b6b --- /dev/null +++ b/src/app/[locale]/settings/providers/_components/vendor-keys-compact-list.tsx @@ -0,0 +1,469 @@ +"use client"; + +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { CheckCircle, Copy, Loader2, Plus, Trash2 } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { useEffect, useMemo, useState } from "react"; +import { toast } from "sonner"; +import { getProviderEndpoints } from "@/actions/provider-endpoints"; +import { + addProvider, + editProvider, + getUnmaskedProviderKey, + removeProvider, +} from "@/actions/providers"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Switch } from "@/components/ui/switch"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { getProviderTypeTranslationKey } from "@/lib/provider-type-utils"; +import { copyToClipboard, isClipboardSupported } from "@/lib/utils/clipboard"; +import type { ProviderDisplay, ProviderType } from "@/types/provider"; +import type { User } from "@/types/user"; + +function buildKeyProviderName(input: { + vendorWebsiteDomain: string; + providerType: ProviderType; + apiKey: string; +}): string { + const keySuffix = input.apiKey.trim().slice(-4); + const base = input.vendorWebsiteDomain.trim() || "vendor"; + const type = input.providerType; + const suffix = keySuffix ? `-${keySuffix}` : ""; + return `${base}-${type}${suffix}`.slice(0, 64); +} + +export function VendorKeysCompactList(props: { + vendorId: number; + vendorWebsiteDomain: string; + vendorWebsiteUrl?: string | null; + providers: ProviderDisplay[]; + currentUser?: User; + enableMultiProviderTypes: boolean; +}) { + const t = useTranslations("settings.providers"); + const tCommon = useTranslations("settings.common"); + const tForm = useTranslations("settings.providers.form"); + const tTypes = useTranslations("settings.providers.types"); + + const canEdit = props.currentUser?.role === "admin"; + + const [addOpen, setAddOpen] = useState(false); + const [addKeyValue, setAddKeyValue] = useState(""); + const [addProviderType, setAddProviderType] = useState( + props.providers[0]?.providerType ?? "claude" + ); + + const queryClient = useQueryClient(); + + useEffect(() => { + if (!addOpen) { + setAddKeyValue(""); + setAddProviderType(props.providers[0]?.providerType ?? "claude"); + } + }, [addOpen, props.providers]); + + const { data: endpoints = [], isLoading: isEndpointsLoading } = useQuery({ + queryKey: ["provider-endpoints", props.vendorId, addProviderType], + queryFn: async () => + await getProviderEndpoints({ vendorId: props.vendorId, providerType: addProviderType }), + enabled: addOpen, + staleTime: 30_000, + }); + + const firstEndpointUrl = useMemo(() => { + const enabled = endpoints.find((e) => e.isEnabled); + return (enabled ?? endpoints[0])?.url ?? null; + }, [endpoints]); + + const addKeyMutation = useMutation({ + mutationFn: async () => { + const apiKey = addKeyValue.trim(); + if (!apiKey) { + throw new Error(tForm("key.placeholder")); + } + + if (!firstEndpointUrl) { + throw new Error(t("noEndpoints")); + } + + const name = buildKeyProviderName({ + vendorWebsiteDomain: props.vendorWebsiteDomain, + providerType: addProviderType, + apiKey, + }); + + const res = await addProvider({ + name, + url: firstEndpointUrl, + key: apiKey, + provider_type: addProviderType, + website_url: props.vendorWebsiteUrl ?? null, + tpm: null, + rpm: null, + rpd: null, + cc: null, + }); + + if (!res.ok) { + throw new Error(res.error || t("addVendorKeyFailed")); + } + }, + onSuccess: () => { + toast.success(t("addVendorKeySuccess")); + queryClient.invalidateQueries({ queryKey: ["providers"] }); + queryClient.invalidateQueries({ queryKey: ["provider-vendors"] }); + setAddOpen(false); + }, + onError: (error) => { + toast.error(error instanceof Error ? error.message : t("addVendorKeyFailed")); + }, + }); + + const handleAddSubmit = (e: React.FormEvent) => { + e.preventDefault(); + addKeyMutation.mutate(); + }; + + const providerTypeItems: ProviderType[] = ["claude", "codex", "gemini", "openai-compatible"]; + + return ( +
+
+ {t("vendorKeys")} + {canEdit && ( + + )} +
+ + {props.providers.length === 0 ? ( +
+ {t("noProviders")} +
+ ) : ( +
+ + + + {tForm("providerType")} + {tForm("key.label")} + {t("columnActions")} + + + + {props.providers.map((provider) => ( + + ))} + +
+
+ )} + + + + + {t("addVendorKey")} + {t("addVendorKeyDesc")} + + +
+ {props.enableMultiProviderTypes && ( +
+ + +
+ )} + +
+ + setAddKeyValue(e.target.value)} + placeholder={tForm("key.placeholder")} + disabled={addKeyMutation.isPending} + required + /> +
+ + {!isEndpointsLoading && !firstEndpointUrl && ( +
{t("noEndpointsDesc")}
+ )} + + + + + +
+
+
+
+ ); +} + +function VendorKeyRow(props: { provider: ProviderDisplay; canEdit: boolean }) { + const t = useTranslations("settings.providers"); + const tList = useTranslations("settings.providers.list"); + const tTypes = useTranslations("settings.providers.types"); + + const queryClient = useQueryClient(); + + const typeLabel = tTypes(`${getProviderTypeTranslationKey(props.provider.providerType)}.label`); + + const [keyDialogOpen, setKeyDialogOpen] = useState(false); + const [unmaskedKey, setUnmaskedKey] = useState(null); + const [copied, setCopied] = useState(false); + const [clipboardAvailable, setClipboardAvailable] = useState(false); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + + useEffect(() => { + setClipboardAvailable(isClipboardSupported()); + }, []); + + const toggleMutation = useMutation({ + mutationFn: async (checked: boolean) => { + const res = await editProvider(props.provider.id, { is_enabled: checked }); + if (!res.ok) throw new Error(res.error); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["providers"] }); + queryClient.invalidateQueries({ queryKey: ["provider-vendors"] }); + }, + onError: () => { + toast.error(t("toggleFailed")); + }, + }); + + const deleteMutation = useMutation({ + mutationFn: async () => { + const res = await removeProvider(props.provider.id); + if (!res.ok) throw new Error(res.error); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["providers"] }); + queryClient.invalidateQueries({ queryKey: ["provider-vendors"] }); + setDeleteDialogOpen(false); + toast.success(tList("deleteSuccess"), { + description: tList("deleteSuccessDesc", { name: props.provider.name }), + }); + }, + onError: () => { + toast.error(tList("deleteFailed")); + }, + }); + + const handleShowKey = async () => { + setKeyDialogOpen(true); + setUnmaskedKey(null); + setCopied(false); + try { + const result = await getUnmaskedProviderKey(props.provider.id); + if (result.ok) { + setUnmaskedKey(result.data.key); + } else { + toast.error(tList("getKeyFailed"), { + description: result.error || tList("unknownError"), + }); + setKeyDialogOpen(false); + } + } catch { + toast.error(tList("getKeyFailed")); + setKeyDialogOpen(false); + } + }; + + const handleCopy = async () => { + if (!unmaskedKey) return; + + const success = await copyToClipboard(unmaskedKey); + if (!success) { + toast.error(tList("copyFailed")); + return; + } + + setCopied(true); + toast.success(tList("keyCopied")); + setTimeout(() => setCopied(false), 3000); + }; + + const handleCloseDialog = () => { + setKeyDialogOpen(false); + setUnmaskedKey(null); + setCopied(false); + }; + + return ( + <> + + {typeLabel} + + {props.canEdit ? ( + + ) : ( + + {props.provider.maskedKey} + + )} + + +
+ {props.canEdit && ( + toggleMutation.mutate(checked)} + className="scale-75 data-[state=checked]:bg-green-500" + /> + )} + {props.canEdit && ( + + + + + + + {tList("confirmDeleteTitle")} + + {tList("confirmDeleteMessage", { name: props.provider.name })} + + + + + {tList("cancelButton")} + + { + e.preventDefault(); + deleteMutation.mutate(); + }} + > + {deleteMutation.isPending && ( + + )} + {tList("deleteButton")} + + + + + )} +
+
+
+ + { + if (!open) handleCloseDialog(); + }} + > + + + {tList("viewFullKey")} + {tList("viewFullKeyDesc")} + +
+
+ + {unmaskedKey || tList("keyLoading")} + + {clipboardAvailable && ( + + )} +
+ {!clipboardAvailable && ( +

{tList("clipboardUnavailable")}

+ )} +
+
+
+ + ); +} diff --git a/tests/unit/settings/providers/endpoint-latency-sparkline-ui.test.tsx b/tests/unit/settings/providers/endpoint-latency-sparkline-ui.test.tsx new file mode 100644 index 000000000..ba1866f75 --- /dev/null +++ b/tests/unit/settings/providers/endpoint-latency-sparkline-ui.test.tsx @@ -0,0 +1,149 @@ +/** + * @vitest-environment happy-dom + */ + +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { ReactNode } from "react"; +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { EndpointLatencySparkline } from "@/app/[locale]/settings/providers/_components/endpoint-latency-sparkline"; + +const providerEndpointsActionMocks = vi.hoisted(() => ({ + getProviderEndpointProbeLogs: vi.fn(async () => ({ ok: true, data: { logs: [] as any[] } })), +})); +vi.mock("@/actions/provider-endpoints", () => providerEndpointsActionMocks); + +vi.mock("recharts", async () => { + const React = await import("react"); + return { + ResponsiveContainer: ({ children }: any) => + React.createElement("div", { "data-testid": "recharts-responsive" }, children), + LineChart: ({ children, data }: any) => + React.createElement( + "div", + { "data-testid": "recharts-linechart", "data-points": JSON.stringify(data) }, + children + ), + YAxis: () => null, + Line: ({ stroke, dataKey }: any) => + React.createElement("div", { + "data-testid": "recharts-line", + "data-stroke": stroke, + "data-key": dataKey, + }), + }; +}); + +let queryClient: QueryClient; + +function renderWithProviders(node: ReactNode) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + act(() => { + root.render({node}); + }); + + return { + container, + unmount: () => { + act(() => root.unmount()); + container.remove(); + }, + }; +} + +async function flushTicks(times = 3) { + for (let i = 0; i < times; i++) { + await act(async () => { + await new Promise((r) => setTimeout(r, 0)); + }); + } +} + +describe("EndpointLatencySparkline", () => { + beforeEach(() => { + queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + vi.clearAllMocks(); + document.body.innerHTML = ""; + }); + + test("无日志时渲染占位块", async () => { + providerEndpointsActionMocks.getProviderEndpointProbeLogs.mockResolvedValueOnce({ + ok: true, + data: { logs: [] }, + }); + + const { container, unmount } = renderWithProviders( + + ); + + await flushTicks(5); + + expect(container.querySelector('[data-testid="recharts-line"]')).toBeNull(); + expect(container.querySelector('div[class*="bg-muted/20"]')).not.toBeNull(); + + unmount(); + }); + + test("有日志时使用最近一次探测的 ok 状态决定线条颜色", async () => { + providerEndpointsActionMocks.getProviderEndpointProbeLogs.mockResolvedValueOnce({ + ok: true, + data: { + logs: [ + { ok: true, latencyMs: 120 }, + { ok: false, latencyMs: 200 }, + ], + }, + } as any); + + const { container, unmount } = renderWithProviders( + + ); + + await flushTicks(5); + + expect(providerEndpointsActionMocks.getProviderEndpointProbeLogs).toHaveBeenCalledWith({ + endpointId: 123, + limit: 2, + }); + + const line = container.querySelector('[data-testid="recharts-line"]') as HTMLElement | null; + expect(line).toBeTruthy(); + expect(line?.getAttribute("data-key")).toBe("latencyMs"); + expect(line?.getAttribute("data-stroke")).toBe("#16a34a"); + + unmount(); + }); + + test("最近一次探测为失败时使用红色线条", async () => { + providerEndpointsActionMocks.getProviderEndpointProbeLogs.mockResolvedValueOnce({ + ok: true, + data: { + logs: [ + { ok: false, latencyMs: 120 }, + { ok: true, latencyMs: 200 }, + ], + }, + } as any); + + const { container, unmount } = renderWithProviders( + + ); + + await flushTicks(5); + + const line = container.querySelector('[data-testid="recharts-line"]') as HTMLElement | null; + expect(line).toBeTruthy(); + expect(line?.getAttribute("data-stroke")).toBe("#dc2626"); + + unmount(); + }); +}); diff --git a/tests/unit/settings/providers/provider-vendor-view-circuit-ui.test.tsx b/tests/unit/settings/providers/provider-vendor-view-circuit-ui.test.tsx new file mode 100644 index 000000000..9441e40a8 --- /dev/null +++ b/tests/unit/settings/providers/provider-vendor-view-circuit-ui.test.tsx @@ -0,0 +1,187 @@ +/** + * @vitest-environment happy-dom + */ + +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { ReactNode } from "react"; +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { NextIntlClientProvider } from "next-intl"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { ProviderVendorView } from "@/app/[locale]/settings/providers/_components/provider-vendor-view"; +import enMessages from "../../../../messages/en"; + +const sonnerMocks = vi.hoisted(() => ({ + toast: { + success: vi.fn(), + error: vi.fn(), + }, +})); +vi.mock("sonner", () => sonnerMocks); + +const providerEndpointsActionMocks = vi.hoisted(() => ({ + addProviderEndpoint: vi.fn(async () => ({ ok: true, data: { endpoint: {} } })), + editProviderEndpoint: vi.fn(async () => ({ ok: true, data: { endpoint: {} } })), + getProviderEndpoints: vi.fn(async () => []), + getProviderVendors: vi.fn(async () => [ + { + id: 1, + displayName: "Vendor A", + websiteDomain: "vendor.example", + websiteUrl: "https://vendor.example", + faviconUrl: null, + createdAt: "2026-01-01", + updatedAt: "2026-01-01", + }, + ]), + getVendorTypeCircuitInfo: vi.fn(async () => ({ + ok: true, + data: { + vendorId: 1, + providerType: "claude", + circuitState: "open", + circuitOpenUntil: null, + lastFailureTime: null, + manualOpen: false, + }, + })), + probeProviderEndpoint: vi.fn(async () => ({ ok: true, data: { result: { ok: true } } })), + removeProviderEndpoint: vi.fn(async () => ({ ok: true })), + removeProviderVendor: vi.fn(async () => ({ ok: true })), + resetVendorTypeCircuit: vi.fn(async () => ({ ok: true })), +})); +vi.mock("@/actions/provider-endpoints", () => providerEndpointsActionMocks); + +const providersActionMocks = vi.hoisted(() => ({ + addProvider: vi.fn(async () => ({ ok: true })), + editProvider: vi.fn(async () => ({ ok: true })), + removeProvider: vi.fn(async () => ({ ok: true })), + getUnmaskedProviderKey: vi.fn(async () => ({ ok: false })), +})); +vi.mock("@/actions/providers", () => providersActionMocks); + +function loadMessages() { + return { + common: enMessages.common, + errors: enMessages.errors, + ui: enMessages.ui, + forms: enMessages.forms, + settings: enMessages.settings, + }; +} + +let queryClient: QueryClient; + +function renderWithProviders(node: ReactNode) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + act(() => { + root.render( + + + {node} + + + ); + }); + + return { + unmount: () => { + act(() => root.unmount()); + container.remove(); + }, + }; +} + +async function flushTicks(times = 3) { + for (let i = 0; i < times; i++) { + await act(async () => { + await new Promise((r) => setTimeout(r, 0)); + }); + } +} + +describe("ProviderVendorView: VendorTypeCircuitControl 仅在熔断时展示关闭按钮", () => { + beforeEach(() => { + queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + vi.clearAllMocks(); + document.body.innerHTML = ""; + }); + + test("circuitState=open 时显示 Close Circuit,且不显示 Manually Open Circuit", async () => { + providerEndpointsActionMocks.getVendorTypeCircuitInfo.mockResolvedValueOnce({ + ok: true, + data: { + vendorId: 1, + providerType: "claude", + circuitState: "open", + circuitOpenUntil: null, + lastFailureTime: null, + manualOpen: false, + }, + }); + + const { unmount } = renderWithProviders( + + ); + + await flushTicks(6); + + expect(document.body.textContent || "").toContain("Close Circuit"); + expect(document.body.textContent || "").not.toContain("Manually Open Circuit"); + + unmount(); + }); + + test("circuitState=closed 时不显示 Close Circuit,也不显示 Manually Open Circuit", async () => { + providerEndpointsActionMocks.getVendorTypeCircuitInfo.mockResolvedValueOnce({ + ok: true, + data: { + vendorId: 1, + providerType: "claude", + circuitState: "closed", + circuitOpenUntil: null, + lastFailureTime: null, + manualOpen: false, + }, + }); + + const { unmount } = renderWithProviders( + + ); + + await flushTicks(6); + + expect(document.body.textContent || "").not.toContain("Close Circuit"); + expect(document.body.textContent || "").not.toContain("Manually Open Circuit"); + + unmount(); + }); +}); diff --git a/tests/unit/settings/providers/vendor-keys-compact-list-ui.test.tsx b/tests/unit/settings/providers/vendor-keys-compact-list-ui.test.tsx new file mode 100644 index 000000000..26335cf36 --- /dev/null +++ b/tests/unit/settings/providers/vendor-keys-compact-list-ui.test.tsx @@ -0,0 +1,230 @@ +/** + * @vitest-environment happy-dom + */ + +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { ReactNode } from "react"; +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { NextIntlClientProvider } from "next-intl"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { VendorKeysCompactList } from "@/app/[locale]/settings/providers/_components/vendor-keys-compact-list"; +import enMessages from "../../../../messages/en"; + +const sonnerMocks = vi.hoisted(() => ({ + toast: { + success: vi.fn(), + error: vi.fn(), + }, +})); +vi.mock("sonner", () => sonnerMocks); + +const providerEndpointsActionMocks = vi.hoisted(() => ({ + getProviderEndpoints: vi.fn(async () => [ + { + id: 1, + vendorId: 1, + providerType: "claude", + url: "https://api.example.com/v1", + label: null, + sortOrder: 0, + isEnabled: true, + lastProbedAt: null, + lastOk: null, + lastLatencyMs: null, + createdAt: "2026-01-01", + updatedAt: "2026-01-01", + }, + ]), +})); +vi.mock("@/actions/provider-endpoints", () => providerEndpointsActionMocks); + +const providersActionMocks = vi.hoisted(() => ({ + addProvider: vi.fn(async () => ({ ok: true })), + editProvider: vi.fn(async () => ({ ok: true })), + removeProvider: vi.fn(async () => ({ ok: true })), + getUnmaskedProviderKey: vi.fn(async () => ({ ok: true, data: { key: "sk-test" } })), +})); +vi.mock("@/actions/providers", () => providersActionMocks); + +function loadMessages() { + return { + common: enMessages.common, + errors: enMessages.errors, + ui: enMessages.ui, + forms: enMessages.forms, + settings: enMessages.settings, + }; +} + +let queryClient: QueryClient; + +function renderWithProviders(node: ReactNode) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + act(() => { + root.render( + + + {node} + + + ); + }); + + return { + unmount: () => { + act(() => root.unmount()); + container.remove(); + }, + }; +} + +async function flushTicks(times = 3) { + for (let i = 0; i < times; i++) { + await act(async () => { + await new Promise((r) => setTimeout(r, 0)); + }); + } +} + +function setNativeValue(element: HTMLInputElement, value: string) { + const prototype = Object.getPrototypeOf(element) as unknown as { value?: unknown }; + const descriptor = Object.getOwnPropertyDescriptor(prototype, "value"); + if (descriptor?.set) { + descriptor.set.call(element, value); + return; + } + element.value = value; +} + +describe("VendorKeysCompactList: 新增密钥不要求填写 API 地址", () => { + beforeEach(() => { + queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + vi.clearAllMocks(); + document.body.innerHTML = ""; + }); + + test("新增密钥对话框不显示 URL 输入与 URL 预览", async () => { + const provider = { + id: 10, + name: "p", + providerType: "claude", + maskedKey: "sk-***", + isEnabled: true, + } as any; + + const { unmount } = renderWithProviders( + + ); + + const addButton = Array.from(document.querySelectorAll("button")).find((btn) => + (btn.textContent || "").includes("Add API Key") + ) as HTMLButtonElement | undefined; + expect(addButton).toBeTruthy(); + + await act(async () => { + addButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + await flushTicks(5); + + const keyInput = document.getElementById("vendor-key-api-key") as HTMLInputElement | null; + expect(keyInput).toBeTruthy(); + + // 回归点:不再要求填写 API 地址 + expect(document.querySelector("input[name='url']")).toBeNull(); + + // 回归点:不显示 UrlPreview 的拼接预览 + expect(document.body.textContent || "").not.toContain("URL Concatenation Preview"); + + unmount(); + }); + + test("提交新增密钥应调用 addProvider,且 url 来自端点列表", async () => { + const provider = { + id: 10, + name: "p", + providerType: "claude", + maskedKey: "sk-***", + isEnabled: true, + } as any; + + const { unmount } = renderWithProviders( + + ); + + const addButton = Array.from(document.querySelectorAll("button")).find((btn) => + (btn.textContent || "").includes("Add API Key") + ) as HTMLButtonElement | undefined; + expect(addButton).toBeTruthy(); + + await act(async () => { + addButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + await flushTicks(5); + + // 打开后应拉取端点列表,用于自动填充 url + expect(providerEndpointsActionMocks.getProviderEndpoints).toHaveBeenCalledTimes(1); + expect(providerEndpointsActionMocks.getProviderEndpoints).toHaveBeenCalledWith({ + vendorId: 1, + providerType: "claude", + }); + + const keyInput = document.getElementById("vendor-key-api-key") as HTMLInputElement | null; + expect(keyInput).toBeTruthy(); + + await act(async () => { + if (!keyInput) return; + setNativeValue(keyInput, "sk-test-1234"); + keyInput.dispatchEvent(new Event("input", { bubbles: true })); + keyInput.dispatchEvent(new Event("change", { bubbles: true })); + }); + + const form = document.body.querySelector("form") as HTMLFormElement | null; + expect(form).toBeTruthy(); + + await act(async () => { + form?.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true })); + }); + + for (let i = 0; i < 10; i++) { + if (providersActionMocks.addProvider.mock.calls.length > 0) break; + await flushTicks(1); + } + + expect(providersActionMocks.addProvider).toHaveBeenCalledTimes(1); + const [payload] = providersActionMocks.addProvider.mock.calls[0] as [any]; + expect(payload.url).toBe("https://api.example.com/v1"); + expect(payload.key).toBe("sk-test-1234"); + expect(payload.provider_type).toBe("claude"); + expect(payload.website_url).toBe("https://vendor.example"); + expect(payload.name).toBe("vendor.example-claude-1234"); + + expect(sonnerMocks.toast.success).toHaveBeenCalledWith("API key added"); + + unmount(); + }); +}); From c4555771b9340e544bafacb7f818f0f417c251bf Mon Sep 17 00:00:00 2001 From: Ding Date: Fri, 16 Jan 2026 12:18:48 +0800 Subject: [PATCH 09/10] feat(providers): reuse ProviderForm for vendor keys --- .../endpoint-latency-sparkline.tsx | 4 +- .../_components/forms/provider-form.tsx | 648 ++++++++++-------- .../_components/provider-vendor-view.tsx | 60 +- .../_components/vendor-keys-compact-list.tsx | 472 ++++++++----- .../provider-vendor-view-circuit-ui.test.tsx | 55 +- .../vendor-keys-compact-list-ui.test.tsx | 214 +++++- 6 files changed, 942 insertions(+), 511 deletions(-) diff --git a/src/app/[locale]/settings/providers/_components/endpoint-latency-sparkline.tsx b/src/app/[locale]/settings/providers/_components/endpoint-latency-sparkline.tsx index 2436219ae..8a82c3d77 100644 --- a/src/app/[locale]/settings/providers/_components/endpoint-latency-sparkline.tsx +++ b/src/app/[locale]/settings/providers/_components/endpoint-latency-sparkline.tsx @@ -36,14 +36,14 @@ export function EndpointLatencySparkline(props: { endpointId: number; limit?: nu }); if (points.length === 0) { - return
; + return
; } const lastPoint = points[points.length - 1]; const stroke = lastPoint?.ok ? "#16a34a" : "#dc2626"; return ( -
+
diff --git a/src/app/[locale]/settings/providers/_components/forms/provider-form.tsx b/src/app/[locale]/settings/providers/_components/forms/provider-form.tsx index 3deabfac3..df552850e 100644 --- a/src/app/[locale]/settings/providers/_components/forms/provider-form.tsx +++ b/src/app/[locale]/settings/providers/_components/forms/provider-form.tsx @@ -33,6 +33,7 @@ import { Switch } from "@/components/ui/switch"; import { TagInput } from "@/components/ui/tag-input"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { PROVIDER_DEFAULTS, PROVIDER_TIMEOUT_DEFAULTS } from "@/lib/constants/provider.constants"; +import { getProviderTypeConfig } from "@/lib/provider-type-utils"; import type { Context1mPreference } from "@/lib/special-attributes"; import { extractBaseUrl, @@ -65,6 +66,16 @@ interface ProviderFormProps { provider?: ProviderDisplay; // edit 模式需要,create 可空 cloneProvider?: ProviderDisplay; // create 模式用于克隆数据 enableMultiProviderTypes: boolean; + hideUrl?: boolean; + hideWebsiteUrl?: boolean; + preset?: { + name?: string; + url?: string; + websiteUrl?: string; + providerType?: ProviderType; + }; + urlResolver?: (providerType: ProviderType) => Promise; + allowedProviderTypes?: ProviderType[]; } function FieldLabelWithTooltip({ @@ -103,12 +114,39 @@ export function ProviderForm({ provider, cloneProvider, enableMultiProviderTypes, + hideUrl = false, + hideWebsiteUrl = false, + preset, + urlResolver, + allowedProviderTypes, }: ProviderFormProps) { const t = useTranslations("settings.providers.form"); const tUI = useTranslations("ui.tagInput"); + const tProviders = useTranslations("settings.providers"); const isEdit = mode === "edit"; const [isPending, startTransition] = useTransition(); + const renderProviderTypeLabel = (type: ProviderType) => { + switch (type) { + case "claude": + return t("providerTypes.claude"); + case "claude-auth": + return t("providerTypes.claudeAuth"); + case "codex": + return t("providerTypes.codex"); + case "gemini": + return t("providerTypes.gemini"); + case "gemini-cli": + return t("providerTypes.geminiCli"); + case "openai-compatible": + return enableMultiProviderTypes + ? t("providerTypes.openaiCompatible") + : `${t("providerTypes.openaiCompatible")}${t("providerTypes.openaiCompatibleDisabled")}`; + default: + return type; + } + }; + // 名称输入框引用,用于自动聚焦 const nameInputRef = useRef(null); @@ -116,12 +154,16 @@ export function ProviderForm({ const sourceProvider = isEdit ? provider : cloneProvider; const [name, setName] = useState( - isEdit ? (provider?.name ?? "") : cloneProvider ? `${cloneProvider.name}_Copy` : "" + isEdit + ? (provider?.name ?? "") + : cloneProvider + ? `${cloneProvider.name}_Copy` + : (preset?.name ?? "") ); - const [url, setUrl] = useState(sourceProvider?.url ?? ""); + const [url, setUrl] = useState(sourceProvider?.url ?? preset?.url ?? ""); const [key, setKey] = useState(""); // 编辑时留空代表不更新 const [providerType, setProviderType] = useState( - sourceProvider?.providerType ?? "claude" + sourceProvider?.providerType ?? preset?.providerType ?? "claude" ); const [preserveClientIp, setPreserveClientIp] = useState( sourceProvider?.preserveClientIp ?? false @@ -241,7 +283,35 @@ export function ProviderForm({ }); // 供应商官网地址 - const [websiteUrl, setWebsiteUrl] = useState(sourceProvider?.websiteUrl ?? ""); + const [websiteUrl, setWebsiteUrl] = useState( + sourceProvider?.websiteUrl ?? preset?.websiteUrl ?? "" + ); + const [autoUrlPending, setAutoUrlPending] = useState(false); + + useEffect(() => { + if (isEdit) return; + if (!hideUrl || !urlResolver) return; + + let cancelled = false; + setAutoUrlPending(true); + urlResolver(providerType) + .then((resolved) => { + if (cancelled) return; + setUrl(resolved?.trim() ? resolved.trim() : ""); + }) + .catch(() => { + if (cancelled) return; + setUrl(""); + }) + .finally(() => { + if (cancelled) return; + setAutoUrlPending(false); + }); + + return () => { + cancelled = true; + }; + }, [isEdit, hideUrl, urlResolver, providerType]); // MCP 透传配置 const [mcpPassthroughType, setMcpPassthroughType] = useState( @@ -347,7 +417,14 @@ export function ProviderForm({ const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); - if (!name.trim() || !url.trim() || (!isEdit && !key.trim())) { + if (!name.trim() || (!isEdit && !key.trim())) { + return; + } + + if (!url.trim()) { + if (hideUrl) { + toast.error(tProviders("noEndpoints")); + } return; } @@ -475,7 +552,6 @@ export function ProviderForm({ circuit_breaker_half_open_success_threshold: halfOpenSuccessThreshold ?? 2, proxy_url: proxyUrl.trim() || null, proxy_fallback_to_direct: proxyFallbackToDirect, - // ⭐ 编辑模式:undefined 代表不更新(沿用数据库旧值),不能回退到默认值 first_byte_timeout_streaming_ms: firstByteTimeoutStreamingSeconds != null ? firstByteTimeoutStreamingSeconds * 1000 @@ -639,19 +715,33 @@ export function ProviderForm({ {/* 移除描述字段 */} -
- - setUrl(e.target.value)} - placeholder={t("url.placeholder")} - disabled={isPending} - required - /> - {/* URL 预览组件 - 实时显示端点拼接结果 */} - {url.trim() && } -
+ {!hideUrl ? ( +
+ + setUrl(e.target.value)} + placeholder={t("url.placeholder")} + disabled={isPending} + required + /> + {url.trim() && } +
+ ) : null} + + {hideUrl && !isEdit && !autoUrlPending && !url.trim() ? ( +
+
{tProviders("noEndpoints")}
+
+ {tProviders("noEndpointsDesc")} +
+
+ ) : null} + + {hideUrl && !isEdit && autoUrlPending ? ( +
{tProviders("keyLoading")}
+ ) : null}
-
- - setWebsiteUrl(e.target.value)} - placeholder={t("websiteUrl.placeholder")} - disabled={isPending} - /> -
{t("websiteUrl.desc")}
-
+ {!hideWebsiteUrl ? ( +
+ + setWebsiteUrl(e.target.value)} + placeholder={t("websiteUrl.placeholder")} + disabled={isPending} + /> +
{t("websiteUrl.desc")}
+
+ ) : null} {/* 展开/折叠全部按钮 */}
@@ -765,15 +857,34 @@ export function ProviderForm({ - {t("providerTypes.claude")} - {t("providerTypes.claudeAuth")} - {t("providerTypes.codex")} - {t("providerTypes.gemini")} - {t("providerTypes.geminiCli")} - - {t("providerTypes.openaiCompatible")}{" "} - {!enableMultiProviderTypes && t("providerTypes.openaiCompatibleDisabled")} - + {( + allowedProviderTypes ?? [ + "claude", + "claude-auth", + "codex", + "gemini", + "gemini-cli", + "openai-compatible", + ] + ).map((type) => { + const typeConfig = getProviderTypeConfig(type); + const TypeIcon = typeConfig.icon; + const label = renderProviderTypeLabel(type); + const disabled = + type === "openai-compatible" ? !enableMultiProviderTypes : false; + return ( + +
+ + + + {label} +
+
+ ); + })}

@@ -957,81 +1068,106 @@ export function ProviderForm({ {/* 路由配置 - 优先级、权重、成本 */}

-
- {t("sections.routing.scheduleParams.title")} +
+ + setPriority(parseInt(e.target.value, 10) || 0)} + placeholder={t("sections.routing.scheduleParams.priority.placeholder")} + disabled={isPending} + min="0" + step="1" + /> +

+ {t("sections.routing.scheduleParams.priority.desc")} +

-
-
- - setPriority(parseInt(e.target.value, 10) || 0)} - placeholder={t("sections.routing.scheduleParams.priority.placeholder")} - disabled={isPending} - min="0" - step="1" - /> -

- {t("sections.routing.scheduleParams.priority.desc")} -

-
-
- - setWeight(parseInt(e.target.value, 10) || 1)} - placeholder={t("sections.routing.scheduleParams.weight.placeholder")} - disabled={isPending} - min="1" - step="1" - /> -

- {t("sections.routing.scheduleParams.weight.desc")} -

-
-
- - { - const value = e.target.value; - if (value === "") { - setCostMultiplier(1.0); - return; - } - const num = parseFloat(value); - setCostMultiplier(Number.isNaN(num) ? 1.0 : num); - }} - onFocus={(e) => e.target.select()} - placeholder={t( - "sections.routing.scheduleParams.costMultiplier.placeholder" - )} - disabled={isPending} - min="0" - step="0.0001" - /> -

- {t("sections.routing.scheduleParams.costMultiplier.desc")} -

-
+
+ + setWeight(parseInt(e.target.value, 10) || 1)} + placeholder={t("sections.routing.scheduleParams.weight.placeholder")} + disabled={isPending} + min="1" + step="1" + /> +

+ {t("sections.routing.scheduleParams.weight.desc")} +

+
+
+ + { + const value = e.target.value; + if (value === "") { + setCostMultiplier(1.0); + return; + } + const num = parseFloat(value); + setCostMultiplier(Number.isNaN(num) ? 1.0 : num); + }} + onFocus={(e) => e.target.select()} + placeholder={t("sections.routing.scheduleParams.costMultiplier.placeholder")} + disabled={isPending} + min="0" + step="0.0001" + /> +

+ {t("sections.routing.scheduleParams.costMultiplier.desc")} +

+
+
+ + +

+ {t("sections.routing.cacheTtl.desc")} +

+
+ + {/* 1M Context Window 配置 - 仅 Anthropic 类型供应商显示 */} + {(providerType === "claude" || providerType === "claude-auth") && (
- +

- {t("sections.routing.cacheTtl.desc")} + {t("sections.routing.context1m.desc")}

+ )} - {/* 1M Context Window 配置 - 仅 Anthropic 类型供应商显示 */} - {(providerType === "claude" || providerType === "claude-auth") && ( + {/* Codex 参数覆写 - 仅 Codex 类型供应商显示 */} + {providerType === "codex" && ( +
- + -

- {t("sections.routing.context1m.desc")} -

- )} - - {/* Codex 参数覆写 - 仅 Codex 类型供应商显示 */} - {providerType === "codex" && ( -
-
- - -
-
- - -
+
+ + +
-
- - -
+
+ + +
-
- - -
+
+ +
- )} -
+
+ )}
diff --git a/src/app/[locale]/settings/providers/_components/provider-vendor-view.tsx b/src/app/[locale]/settings/providers/_components/provider-vendor-view.tsx index 6ec0e4eeb..447370e65 100644 --- a/src/app/[locale]/settings/providers/_components/provider-vendor-view.tsx +++ b/src/app/[locale]/settings/providers/_components/provider-vendor-view.tsx @@ -67,6 +67,7 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; +import { getProviderTypeConfig, getProviderTypeTranslationKey } from "@/lib/provider-type-utils"; import type { CurrencyCode } from "@/lib/utils/currency"; import { getErrorMessage } from "@/lib/utils/error-messages"; import type { @@ -91,7 +92,14 @@ interface ProviderVendorViewProps { } export function ProviderVendorView(props: ProviderVendorViewProps) { - const { providers, currentUser, enableMultiProviderTypes } = props; + const { + providers, + currentUser, + enableMultiProviderTypes, + statistics, + statisticsLoading, + currencyCode, + } = props; const { data: vendors = [], isLoading: isVendorsLoading } = useQuery({ queryKey: ["provider-vendors"], @@ -141,6 +149,9 @@ export function ProviderVendorView(props: ProviderVendorViewProps) { providers={vendorProviders} currentUser={currentUser} enableMultiProviderTypes={enableMultiProviderTypes} + statistics={statistics} + statisticsLoading={statisticsLoading} + currencyCode={currencyCode} /> ); })} @@ -154,12 +165,18 @@ function VendorCard({ providers, currentUser, enableMultiProviderTypes, + statistics, + statisticsLoading, + currencyCode, }: { vendor?: ProviderVendor; vendorId: number; providers: ProviderDisplay[]; currentUser?: User; enableMultiProviderTypes: boolean; + statistics: Record; + statisticsLoading: boolean; + currencyCode: CurrencyCode; }) { const t = useTranslations("settings.providers"); @@ -210,6 +227,9 @@ function VendorCard({ providers={providers} currentUser={currentUser} enableMultiProviderTypes={enableMultiProviderTypes} + statistics={statistics} + statisticsLoading={statisticsLoading} + currencyCode={currencyCode} /> {enableMultiProviderTypes && } @@ -220,9 +240,10 @@ function VendorCard({ function VendorEndpointsSection({ vendorId }: { vendorId: number }) { const t = useTranslations("settings.providers"); + const tTypes = useTranslations("settings.providers.types"); const [activeType, setActiveType] = useState("claude"); - const providerTypes: ProviderType[] = ["claude", "codex", "gemini", "openai-compatible"]; + const providerTypes: ProviderType[] = ["claude", "claude-auth", "codex", "gemini", "gemini-cli"]; return (
@@ -234,17 +255,28 @@ function VendorEndpointsSection({ vendorId }: { vendorId: number }) {
- {providerTypes.map((type) => ( - - ))} + {providerTypes.map((type) => { + const typeConfig = getProviderTypeConfig(type); + const TypeIcon = typeConfig.icon; + const typeKey = getProviderTypeTranslationKey(type); + const label = tTypes(`${typeKey}.label`); + return ( + + ); + })}
@@ -363,7 +395,7 @@ function EndpointsTable({ {t("columnUrl")} {t("status")} - {t("latency")} + {t("latency")} {t("columnActions")} diff --git a/src/app/[locale]/settings/providers/_components/vendor-keys-compact-list.tsx b/src/app/[locale]/settings/providers/_components/vendor-keys-compact-list.tsx index e534a0b6b..e66cdf369 100644 --- a/src/app/[locale]/settings/providers/_components/vendor-keys-compact-list.tsx +++ b/src/app/[locale]/settings/providers/_components/vendor-keys-compact-list.tsx @@ -1,17 +1,14 @@ "use client"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { CheckCircle, Copy, Loader2, Plus, Trash2 } from "lucide-react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { CheckCircle, Copy, Edit2, Loader2, Plus, Trash2 } from "lucide-react"; +import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useState } from "react"; import { toast } from "sonner"; import { getProviderEndpoints } from "@/actions/provider-endpoints"; -import { - addProvider, - editProvider, - getUnmaskedProviderKey, - removeProvider, -} from "@/actions/providers"; +import { editProvider, getUnmaskedProviderKey, removeProvider } from "@/actions/providers"; +import { FormErrorBoundary } from "@/components/form-error-boundary"; import { AlertDialog, AlertDialogAction, @@ -28,19 +25,10 @@ import { Dialog, DialogContent, DialogDescription, - DialogFooter, DialogHeader, DialogTitle, + DialogTrigger, } from "@/components/ui/dialog"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; import { Switch } from "@/components/ui/switch"; import { Table, @@ -50,21 +38,21 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; -import { getProviderTypeTranslationKey } from "@/lib/provider-type-utils"; +import { PROVIDER_LIMITS } from "@/lib/constants/provider.constants"; +import { getProviderTypeConfig, getProviderTypeTranslationKey } from "@/lib/provider-type-utils"; import { copyToClipboard, isClipboardSupported } from "@/lib/utils/clipboard"; -import type { ProviderDisplay, ProviderType } from "@/types/provider"; +import { type CurrencyCode, formatCurrency } from "@/lib/utils/currency"; +import type { ProviderDisplay, ProviderStatisticsMap, ProviderType } from "@/types/provider"; import type { User } from "@/types/user"; +import { ProviderForm } from "./forms/provider-form"; +import { InlineEditPopover } from "./inline-edit-popover"; -function buildKeyProviderName(input: { +function buildDefaultProviderName(input: { vendorWebsiteDomain: string; providerType: ProviderType; - apiKey: string; }): string { - const keySuffix = input.apiKey.trim().slice(-4); const base = input.vendorWebsiteDomain.trim() || "vendor"; - const type = input.providerType; - const suffix = keySuffix ? `-${keySuffix}` : ""; - return `${base}-${type}${suffix}`.slice(0, 64); + return `${base}-${input.providerType}`.slice(0, 64); } export function VendorKeysCompactList(props: { @@ -74,103 +62,78 @@ export function VendorKeysCompactList(props: { providers: ProviderDisplay[]; currentUser?: User; enableMultiProviderTypes: boolean; + statistics?: ProviderStatisticsMap; + statisticsLoading?: boolean; + currencyCode?: CurrencyCode; }) { const t = useTranslations("settings.providers"); - const tCommon = useTranslations("settings.common"); const tForm = useTranslations("settings.providers.form"); - const tTypes = useTranslations("settings.providers.types"); + const tList = useTranslations("settings.providers.list"); const canEdit = props.currentUser?.role === "admin"; - const [addOpen, setAddOpen] = useState(false); - const [addKeyValue, setAddKeyValue] = useState(""); - const [addProviderType, setAddProviderType] = useState( - props.providers[0]?.providerType ?? "claude" - ); - const queryClient = useQueryClient(); - useEffect(() => { - if (!addOpen) { - setAddKeyValue(""); - setAddProviderType(props.providers[0]?.providerType ?? "claude"); - } - }, [addOpen, props.providers]); - - const { data: endpoints = [], isLoading: isEndpointsLoading } = useQuery({ - queryKey: ["provider-endpoints", props.vendorId, addProviderType], - queryFn: async () => - await getProviderEndpoints({ vendorId: props.vendorId, providerType: addProviderType }), - enabled: addOpen, - staleTime: 30_000, - }); - - const firstEndpointUrl = useMemo(() => { - const enabled = endpoints.find((e) => e.isEnabled); - return (enabled ?? endpoints[0])?.url ?? null; - }, [endpoints]); - - const addKeyMutation = useMutation({ - mutationFn: async () => { - const apiKey = addKeyValue.trim(); - if (!apiKey) { - throw new Error(tForm("key.placeholder")); - } - - if (!firstEndpointUrl) { - throw new Error(t("noEndpoints")); - } - - const name = buildKeyProviderName({ - vendorWebsiteDomain: props.vendorWebsiteDomain, - providerType: addProviderType, - apiKey, - }); - - const res = await addProvider({ - name, - url: firstEndpointUrl, - key: apiKey, - provider_type: addProviderType, - website_url: props.vendorWebsiteUrl ?? null, - tpm: null, - rpm: null, - rpd: null, - cc: null, - }); - - if (!res.ok) { - throw new Error(res.error || t("addVendorKeyFailed")); - } - }, - onSuccess: () => { - toast.success(t("addVendorKeySuccess")); - queryClient.invalidateQueries({ queryKey: ["providers"] }); - queryClient.invalidateQueries({ queryKey: ["provider-vendors"] }); - setAddOpen(false); - }, - onError: (error) => { - toast.error(error instanceof Error ? error.message : t("addVendorKeyFailed")); - }, - }); - - const handleAddSubmit = (e: React.FormEvent) => { - e.preventDefault(); - addKeyMutation.mutate(); - }; + const [createOpen, setCreateOpen] = useState(false); - const providerTypeItems: ProviderType[] = ["claude", "codex", "gemini", "openai-compatible"]; + const defaultProviderType: ProviderType = props.providers[0]?.providerType ?? "claude"; + const vendorAllowedTypes: ProviderType[] = [ + "claude", + "claude-auth", + "codex", + "gemini", + "gemini-cli", + ]; + const statistics = props.statistics ?? {}; + const statisticsLoading = props.statisticsLoading ?? false; + const currencyCode = props.currencyCode ?? "USD"; return (
{t("vendorKeys")} - {canEdit && ( - - )} + {canEdit ? ( + + + + + + + { + const endpoints = await getProviderEndpoints({ + vendorId: props.vendorId, + providerType: type, + }); + const enabled = endpoints.find((e) => e.isEnabled); + return (enabled ?? endpoints[0])?.url ?? null; + }} + onSuccess={() => { + setCreateOpen(false); + queryClient.invalidateQueries({ queryKey: ["providers"] }); + queryClient.invalidateQueries({ queryKey: ["provider-vendors"] }); + }} + /> + + + + ) : null}
{props.providers.length === 0 ? ( @@ -182,96 +145,124 @@ export function VendorKeysCompactList(props: { - {tForm("providerType")} + + {tForm("providerType")} + + {tForm("name.label")} {tForm("key.label")} - {t("columnActions")} + + {tList("priority")} + + + {tList("weight")} + + + {tList("costMultiplier")} + + + {tList("todayUsageLabel")} + + {t("columnActions")} {props.providers.map((provider) => ( - + ))}
)} - - - - - {t("addVendorKey")} - {t("addVendorKeyDesc")} - - -
- {props.enableMultiProviderTypes && ( -
- - -
- )} - -
- - setAddKeyValue(e.target.value)} - placeholder={tForm("key.placeholder")} - disabled={addKeyMutation.isPending} - required - /> -
- - {!isEndpointsLoading && !firstEndpointUrl && ( -
{t("noEndpointsDesc")}
- )} - - - - - -
-
-
); } -function VendorKeyRow(props: { provider: ProviderDisplay; canEdit: boolean }) { +function VendorKeyRow(props: { + provider: ProviderDisplay; + canEdit: boolean; + statistics?: { + todayCost: string; + todayCalls: number; + lastCallTime: string | null; + lastCallModel: string | null; + }; + statisticsLoading: boolean; + currencyCode: CurrencyCode; + allowedProviderTypes: ProviderType[]; + enableMultiProviderTypes: boolean; +}) { const t = useTranslations("settings.providers"); const tList = useTranslations("settings.providers.list"); + const tInline = useTranslations("settings.providers.inlineEdit"); const tTypes = useTranslations("settings.providers.types"); const queryClient = useQueryClient(); + const router = useRouter(); + + const validatePriority = (raw: string) => { + if (raw.length === 0) return tInline("priorityInvalid"); + const value = Number(raw); + if (!Number.isFinite(value) || !Number.isInteger(value) || value < 0 || value > 2147483647) + return tInline("priorityInvalid"); + return null; + }; + + const validateWeight = (raw: string) => { + if (raw.length === 0) return tInline("weightInvalid"); + const value = Number(raw); + if ( + !Number.isFinite(value) || + !Number.isInteger(value) || + value < PROVIDER_LIMITS.WEIGHT.MIN || + value > PROVIDER_LIMITS.WEIGHT.MAX + ) + return tInline("weightInvalid"); + return null; + }; + + const validateCostMultiplier = (raw: string) => { + if (raw.length === 0) return tInline("costMultiplierInvalid"); + const value = Number(raw); + if (!Number.isFinite(value) || value < 0) return tInline("costMultiplierInvalid"); + return null; + }; + const createSaveHandler = (fieldName: "priority" | "weight" | "cost_multiplier") => { + return async (value: number) => { + try { + const res = await editProvider(props.provider.id, { [fieldName]: value }); + if (res.ok) { + toast.success(tInline("saveSuccess")); + queryClient.invalidateQueries({ queryKey: ["providers"] }); + queryClient.invalidateQueries({ queryKey: ["provider-vendors"] }); + router.refresh(); + return true; + } + toast.error(tInline("saveFailed"), { description: res.error || tList("unknownError") }); + return false; + } catch (error) { + console.error(`Failed to update ${fieldName}:`, error); + toast.error(tInline("saveFailed"), { description: tList("unknownError") }); + return false; + } + }; + }; + + const handleSavePriority = createSaveHandler("priority"); + const handleSaveWeight = createSaveHandler("weight"); + const handleSaveCostMultiplier = createSaveHandler("cost_multiplier"); + + const typeConfig = getProviderTypeConfig(props.provider.providerType); + const TypeIcon = typeConfig.icon; const typeLabel = tTypes(`${getProviderTypeTranslationKey(props.provider.providerType)}.label`); const [keyDialogOpen, setKeyDialogOpen] = useState(false); @@ -279,6 +270,7 @@ function VendorKeyRow(props: { provider: ProviderDisplay; canEdit: boolean }) { const [copied, setCopied] = useState(false); const [clipboardAvailable, setClipboardAvailable] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [editOpen, setEditOpen] = useState(false); useEffect(() => { setClipboardAvailable(isClipboardSupported()); @@ -292,6 +284,7 @@ function VendorKeyRow(props: { provider: ProviderDisplay; canEdit: boolean }) { onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["providers"] }); queryClient.invalidateQueries({ queryKey: ["provider-vendors"] }); + router.refresh(); }, onError: () => { toast.error(t("toggleFailed")); @@ -310,6 +303,7 @@ function VendorKeyRow(props: { provider: ProviderDisplay; canEdit: boolean }) { toast.success(tList("deleteSuccess"), { description: tList("deleteSuccessDesc", { name: props.provider.name }), }); + router.refresh(); }, onError: () => { toast.error(tList("deleteFailed")); @@ -358,8 +352,23 @@ function VendorKeyRow(props: { provider: ProviderDisplay; canEdit: boolean }) { return ( <> - - {typeLabel} + + +
+
+ +
+ {typeLabel} +
+
+ +
+ {props.provider.name} +
+
{props.canEdit ? ( + + + + { + setEditOpen(false); + queryClient.invalidateQueries({ queryKey: ["providers"] }); + queryClient.invalidateQueries({ queryKey: ["provider-vendors"] }); + router.refresh(); + }} + /> + + + + ) : null} {props.canEdit && ( {deleteMutation.isPending ? ( diff --git a/tests/unit/settings/providers/provider-vendor-view-circuit-ui.test.tsx b/tests/unit/settings/providers/provider-vendor-view-circuit-ui.test.tsx index 9441e40a8..1ef937b82 100644 --- a/tests/unit/settings/providers/provider-vendor-view-circuit-ui.test.tsx +++ b/tests/unit/settings/providers/provider-vendor-view-circuit-ui.test.tsx @@ -9,6 +9,7 @@ import { createRoot } from "react-dom/client"; import { NextIntlClientProvider } from "next-intl"; import { beforeEach, describe, expect, test, vi } from "vitest"; import { ProviderVendorView } from "@/app/[locale]/settings/providers/_components/provider-vendor-view"; +import type { User } from "@/types/user"; import enMessages from "../../../../messages/en"; const sonnerMocks = vi.hoisted(() => ({ @@ -22,7 +23,23 @@ vi.mock("sonner", () => sonnerMocks); const providerEndpointsActionMocks = vi.hoisted(() => ({ addProviderEndpoint: vi.fn(async () => ({ ok: true, data: { endpoint: {} } })), editProviderEndpoint: vi.fn(async () => ({ ok: true, data: { endpoint: {} } })), - getProviderEndpoints: vi.fn(async () => []), + getProviderEndpointProbeLogs: vi.fn(async () => ({ ok: true, data: { logs: [] } })), + getProviderEndpoints: vi.fn(async () => [ + { + id: 1, + vendorId: 1, + providerType: "claude", + url: "https://api.example.com/v1", + label: null, + sortOrder: 0, + isEnabled: true, + lastProbedAt: null, + lastOk: null, + lastLatencyMs: null, + createdAt: "2026-01-01", + updatedAt: "2026-01-01", + }, + ]), getProviderVendors: vi.fn(async () => [ { id: 1, @@ -60,6 +77,22 @@ const providersActionMocks = vi.hoisted(() => ({ })); vi.mock("@/actions/providers", () => providersActionMocks); +const ADMIN_USER: User = { + id: 1, + name: "admin", + description: "", + role: "admin", + rpm: null, + dailyQuota: null, + providerGroup: null, + tags: [], + createdAt: new Date("2026-01-01T00:00:00.000Z"), + updatedAt: new Date("2026-01-01T00:00:00.000Z"), + dailyResetMode: "fixed", + dailyResetTime: "00:00", + isEnabled: true, +}; + function loadMessages() { return { common: enMessages.common, @@ -131,14 +164,12 @@ describe("ProviderVendorView: VendorTypeCircuitControl 仅在熔断时展示关 const { unmount } = renderWithProviders( ); @@ -146,6 +177,12 @@ describe("ProviderVendorView: VendorTypeCircuitControl 仅在熔断时展示关 expect(document.body.textContent || "").toContain("Close Circuit"); expect(document.body.textContent || "").not.toContain("Manually Open Circuit"); + expect(document.body.textContent || "").toContain("Gemini CLI"); + expect(document.body.textContent || "").toContain("Claude Auth"); + expect(document.body.textContent || "").not.toContain("OpenAI Compatible"); + + const latencyHeader = document.querySelector('th[class*="w-[220px]"]'); + expect(latencyHeader?.textContent || "").toContain("Latency"); unmount(); }); @@ -166,14 +203,12 @@ describe("ProviderVendorView: VendorTypeCircuitControl 仅在熔断时展示关 const { unmount } = renderWithProviders( ); @@ -181,6 +216,12 @@ describe("ProviderVendorView: VendorTypeCircuitControl 仅在熔断时展示关 expect(document.body.textContent || "").not.toContain("Close Circuit"); expect(document.body.textContent || "").not.toContain("Manually Open Circuit"); + expect(document.body.textContent || "").toContain("Gemini CLI"); + expect(document.body.textContent || "").toContain("Claude Auth"); + expect(document.body.textContent || "").not.toContain("OpenAI Compatible"); + + const latencyHeader = document.querySelector('th[class*="w-[220px]"]'); + expect(latencyHeader?.textContent || "").toContain("Latency"); unmount(); }); diff --git a/tests/unit/settings/providers/vendor-keys-compact-list-ui.test.tsx b/tests/unit/settings/providers/vendor-keys-compact-list-ui.test.tsx index 26335cf36..c5f2d1069 100644 --- a/tests/unit/settings/providers/vendor-keys-compact-list-ui.test.tsx +++ b/tests/unit/settings/providers/vendor-keys-compact-list-ui.test.tsx @@ -9,6 +9,8 @@ import { createRoot } from "react-dom/client"; import { NextIntlClientProvider } from "next-intl"; import { beforeEach, describe, expect, test, vi } from "vitest"; import { VendorKeysCompactList } from "@/app/[locale]/settings/providers/_components/vendor-keys-compact-list"; +import type { ProviderDisplay, ProviderStatisticsMap } from "@/types/provider"; +import type { User } from "@/types/user"; import enMessages from "../../../../messages/en"; const sonnerMocks = vi.hoisted(() => ({ @@ -19,6 +21,13 @@ const sonnerMocks = vi.hoisted(() => ({ })); vi.mock("sonner", () => sonnerMocks); +const nextNavigationMocks = vi.hoisted(() => ({ + useRouter: () => ({ + refresh: vi.fn(), + }), +})); +vi.mock("next/navigation", () => nextNavigationMocks); + const providerEndpointsActionMocks = vi.hoisted(() => ({ getProviderEndpoints: vi.fn(async () => [ { @@ -47,6 +56,82 @@ const providersActionMocks = vi.hoisted(() => ({ })); vi.mock("@/actions/providers", () => providersActionMocks); +const requestFiltersActionMocks = vi.hoisted(() => ({ + getDistinctProviderGroupsAction: vi.fn(async () => ({ ok: true, data: [] })), +})); +vi.mock("@/actions/request-filters", () => requestFiltersActionMocks); + +const ADMIN_USER: User = { + id: 1, + name: "admin", + description: "", + role: "admin", + rpm: null, + dailyQuota: null, + providerGroup: null, + tags: [], + createdAt: new Date("2026-01-01T00:00:00.000Z"), + updatedAt: new Date("2026-01-01T00:00:00.000Z"), + dailyResetMode: "fixed", + dailyResetTime: "00:00", + isEnabled: true, +}; + +function makeProviderDisplay(overrides: Partial = {}): ProviderDisplay { + return { + id: 1, + name: "p", + url: "https://api.example.com", + maskedKey: "sk-***", + isEnabled: true, + weight: 1, + priority: 0, + costMultiplier: 1, + groupTag: null, + providerType: "claude", + providerVendorId: 1, + preserveClientIp: false, + modelRedirects: null, + allowedModels: null, + joinClaudePool: false, + codexInstructionsStrategy: "auto", + mcpPassthroughType: "none", + mcpPassthroughUrl: null, + limit5hUsd: null, + limitDailyUsd: null, + dailyResetMode: "fixed", + dailyResetTime: "00:00", + limitWeeklyUsd: null, + limitMonthlyUsd: null, + limitTotalUsd: null, + limitConcurrentSessions: 0, + maxRetryAttempts: null, + circuitBreakerFailureThreshold: 5, + circuitBreakerOpenDuration: 30_000, + circuitBreakerHalfOpenSuccessThreshold: 2, + proxyUrl: null, + proxyFallbackToDirect: false, + firstByteTimeoutStreamingMs: 15_000, + streamingIdleTimeoutMs: 30_000, + requestTimeoutNonStreamingMs: 60_000, + websiteUrl: null, + faviconUrl: null, + cacheTtlPreference: null, + context1mPreference: null, + codexReasoningEffortPreference: null, + codexReasoningSummaryPreference: null, + codexTextVerbosityPreference: null, + codexParallelToolCallsPreference: null, + tpm: null, + rpm: null, + rpd: null, + cc: null, + createdAt: "2026-01-01", + updatedAt: "2026-01-01", + ...overrides, + }; +} + function loadMessages() { return { common: enMessages.common, @@ -110,16 +195,38 @@ describe("VendorKeysCompactList: 新增密钥不要求填写 API 地址", () => }); vi.clearAllMocks(); document.body.innerHTML = ""; + + const storage = (() => { + let store: Record = {}; + return { + getItem: (key: string) => (Object.hasOwn(store, key) ? store[key] : null), + setItem: (key: string, value: string) => { + store[key] = String(value); + }, + removeItem: (key: string) => { + delete store[key]; + }, + clear: () => { + store = {}; + }, + key: (index: number) => Object.keys(store)[index] ?? null, + get length() { + return Object.keys(store).length; + }, + }; + })(); + + Object.defineProperty(globalThis, "localStorage", { + value: storage, + configurable: true, + }); }); test("新增密钥对话框不显示 URL 输入与 URL 预览", async () => { - const provider = { + const provider = makeProviderDisplay({ id: 10, - name: "p", providerType: "claude", - maskedKey: "sk-***", - isEnabled: true, - } as any; + }); const { unmount } = renderWithProviders( vendorWebsiteDomain="vendor.example" vendorWebsiteUrl="https://vendor.example" providers={[provider]} - currentUser={{ role: "admin" } as any} + currentUser={ADMIN_USER} enableMultiProviderTypes={false} /> ); @@ -141,28 +248,30 @@ describe("VendorKeysCompactList: 新增密钥不要求填写 API 地址", () => addButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); - await flushTicks(5); + await flushTicks(8); - const keyInput = document.getElementById("vendor-key-api-key") as HTMLInputElement | null; + const nameInput = document.getElementById("name") as HTMLInputElement | null; + const keyInput = document.getElementById("key") as HTMLInputElement | null; + expect(nameInput).toBeTruthy(); expect(keyInput).toBeTruthy(); - // 回归点:不再要求填写 API 地址 - expect(document.querySelector("input[name='url']")).toBeNull(); + // 不显示 URL 输入 + expect(document.getElementById("url")).toBeNull(); + + // 不显示官网输入 + expect(document.getElementById("website-url")).toBeNull(); - // 回归点:不显示 UrlPreview 的拼接预览 + // 不显示 UrlPreview 的拼接预览 expect(document.body.textContent || "").not.toContain("URL Concatenation Preview"); unmount(); }); test("提交新增密钥应调用 addProvider,且 url 来自端点列表", async () => { - const provider = { + const provider = makeProviderDisplay({ id: 10, - name: "p", providerType: "claude", - maskedKey: "sk-***", - isEnabled: true, - } as any; + }); const { unmount } = renderWithProviders( vendorWebsiteDomain="vendor.example" vendorWebsiteUrl="https://vendor.example" providers={[provider]} - currentUser={{ role: "admin" } as any} + currentUser={ADMIN_USER} enableMultiProviderTypes={false} /> ); @@ -184,16 +293,15 @@ describe("VendorKeysCompactList: 新增密钥不要求填写 API 地址", () => addButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); - await flushTicks(5); + await flushTicks(10); - // 打开后应拉取端点列表,用于自动填充 url - expect(providerEndpointsActionMocks.getProviderEndpoints).toHaveBeenCalledTimes(1); + expect(providerEndpointsActionMocks.getProviderEndpoints).toHaveBeenCalled(); expect(providerEndpointsActionMocks.getProviderEndpoints).toHaveBeenCalledWith({ vendorId: 1, providerType: "claude", }); - const keyInput = document.getElementById("vendor-key-api-key") as HTMLInputElement | null; + const keyInput = document.getElementById("key") as HTMLInputElement | null; expect(keyInput).toBeTruthy(); await act(async () => { @@ -216,14 +324,62 @@ describe("VendorKeysCompactList: 新增密钥不要求填写 API 地址", () => } expect(providersActionMocks.addProvider).toHaveBeenCalledTimes(1); - const [payload] = providersActionMocks.addProvider.mock.calls[0] as [any]; - expect(payload.url).toBe("https://api.example.com/v1"); - expect(payload.key).toBe("sk-test-1234"); - expect(payload.provider_type).toBe("claude"); - expect(payload.website_url).toBe("https://vendor.example"); - expect(payload.name).toBe("vendor.example-claude-1234"); - - expect(sonnerMocks.toast.success).toHaveBeenCalledWith("API key added"); + expect(providersActionMocks.addProvider).toHaveBeenCalledWith( + expect.objectContaining({ + url: "https://api.example.com/v1", + key: "sk-test-1234", + provider_type: "claude", + website_url: "https://vendor.example", + name: "vendor.example-claude", + }) + ); + + unmount(); + }); + + test("列表应显示名称/指标列,并提供编辑按钮", async () => { + const provider = makeProviderDisplay({ + id: 10, + name: "VendorKey-1", + providerType: "claude", + priority: 3, + weight: 10, + costMultiplier: 1.5, + todayCallCount: 2, + todayTotalCostUsd: "0.12", + }); + + const statistics: ProviderStatisticsMap = { + 10: { + todayCalls: 2, + todayCost: "0.12", + lastCallTime: null, + lastCallModel: null, + }, + }; + + const { unmount } = renderWithProviders( + + ); + + expect(document.body.textContent || "").toContain("VendorKey-1"); + expect(document.body.textContent || "").toContain("Priority"); + expect(document.body.textContent || "").toContain("Weight"); + + const editButtons = Array.from( + document.querySelectorAll('button[aria-label="Edit Provider"]') + ); + expect(editButtons.length).toBeGreaterThan(0); unmount(); }); From f89f208dfeb88feae1ce7d7d55315026cc015722 Mon Sep 17 00:00:00 2001 From: ding113 Date: Wed, 21 Jan 2026 23:20:59 +0800 Subject: [PATCH 10/10] fix(migrations): regenerate vendor endpoints migration based on dev Co-Authored-By: Claude Opus 4.5 --- drizzle/0055_familiar_shatterstar.sql | 1 - drizzle/0056_colorful_nightshade.sql | 1 - drizzle/0056_tidy_quasar.sql | 67 ++++ drizzle/meta/0054_snapshot.json | 490 +----------------------- drizzle/meta/0055_snapshot.json | 515 +------------------------- drizzle/meta/0056_snapshot.json | 20 +- drizzle/meta/_journal.json | 12 +- 7 files changed, 112 insertions(+), 994 deletions(-) delete mode 100644 drizzle/0055_familiar_shatterstar.sql delete mode 100644 drizzle/0056_colorful_nightshade.sql create mode 100644 drizzle/0056_tidy_quasar.sql diff --git a/drizzle/0055_familiar_shatterstar.sql b/drizzle/0055_familiar_shatterstar.sql deleted file mode 100644 index 1e6980236..000000000 --- a/drizzle/0055_familiar_shatterstar.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE "provider_vendors" ADD COLUMN "is_official" boolean DEFAULT false NOT NULL; \ No newline at end of file diff --git a/drizzle/0056_colorful_nightshade.sql b/drizzle/0056_colorful_nightshade.sql deleted file mode 100644 index e04096773..000000000 --- a/drizzle/0056_colorful_nightshade.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE "provider_vendors" DROP COLUMN "is_official"; \ No newline at end of file diff --git a/drizzle/0056_tidy_quasar.sql b/drizzle/0056_tidy_quasar.sql new file mode 100644 index 000000000..7ee6e7f56 --- /dev/null +++ b/drizzle/0056_tidy_quasar.sql @@ -0,0 +1,67 @@ +CREATE TABLE IF NOT EXISTS "provider_endpoint_probe_logs" ( + "id" serial PRIMARY KEY NOT NULL, + "endpoint_id" integer NOT NULL, + "source" varchar(20) DEFAULT 'scheduled' NOT NULL, + "ok" boolean NOT NULL, + "status_code" integer, + "latency_ms" integer, + "error_type" varchar(64), + "error_message" text, + "created_at" timestamp with time zone DEFAULT now() +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "provider_endpoints" ( + "id" serial PRIMARY KEY NOT NULL, + "vendor_id" integer NOT NULL, + "provider_type" varchar(20) DEFAULT 'claude' NOT NULL, + "url" text NOT NULL, + "label" varchar(200), + "sort_order" integer DEFAULT 0 NOT NULL, + "is_enabled" boolean DEFAULT true NOT NULL, + "last_probed_at" timestamp with time zone, + "last_probe_ok" boolean, + "last_probe_status_code" integer, + "last_probe_latency_ms" integer, + "last_probe_error_type" varchar(64), + "last_probe_error_message" text, + "created_at" timestamp with time zone DEFAULT now(), + "updated_at" timestamp with time zone DEFAULT now(), + "deleted_at" timestamp with time zone +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "provider_vendors" ( + "id" serial PRIMARY KEY NOT NULL, + "website_domain" varchar(255) NOT NULL, + "display_name" varchar(200), + "website_url" text, + "favicon_url" text, + "created_at" timestamp with time zone DEFAULT now(), + "updated_at" timestamp with time zone DEFAULT now() +); +--> statement-breakpoint +ALTER TABLE "providers" ADD COLUMN IF NOT EXISTS "provider_vendor_id" integer;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "provider_endpoint_probe_logs" ADD CONSTRAINT "provider_endpoint_probe_logs_endpoint_id_provider_endpoints_id_fk" FOREIGN KEY ("endpoint_id") REFERENCES "public"."provider_endpoints"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "provider_endpoints" ADD CONSTRAINT "provider_endpoints_vendor_id_provider_vendors_id_fk" FOREIGN KEY ("vendor_id") REFERENCES "public"."provider_vendors"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$;--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "idx_provider_endpoint_probe_logs_endpoint_created_at" ON "provider_endpoint_probe_logs" USING btree ("endpoint_id","created_at" DESC NULLS LAST);--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "idx_provider_endpoint_probe_logs_created_at" ON "provider_endpoint_probe_logs" USING btree ("created_at");--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "uniq_provider_endpoints_vendor_type_url" ON "provider_endpoints" USING btree ("vendor_id","provider_type","url");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "idx_provider_endpoints_vendor_type" ON "provider_endpoints" USING btree ("vendor_id","provider_type") WHERE "provider_endpoints"."deleted_at" IS NULL;--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "idx_provider_endpoints_enabled" ON "provider_endpoints" USING btree ("is_enabled","vendor_id","provider_type") WHERE "provider_endpoints"."deleted_at" IS NULL;--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "idx_provider_endpoints_created_at" ON "provider_endpoints" USING btree ("created_at");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "idx_provider_endpoints_deleted_at" ON "provider_endpoints" USING btree ("deleted_at");--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "uniq_provider_vendors_website_domain" ON "provider_vendors" USING btree ("website_domain");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "idx_provider_vendors_created_at" ON "provider_vendors" USING btree ("created_at");--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "providers" ADD CONSTRAINT "providers_provider_vendor_id_provider_vendors_id_fk" FOREIGN KEY ("provider_vendor_id") REFERENCES "public"."provider_vendors"("id") ON DELETE restrict ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$;--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "idx_providers_vendor_type" ON "providers" USING btree ("provider_vendor_id","provider_type") WHERE "providers"."deleted_at" IS NULL; diff --git a/drizzle/meta/0054_snapshot.json b/drizzle/meta/0054_snapshot.json index 65c978784..2ea76fc77 100644 --- a/drizzle/meta/0054_snapshot.json +++ b/drizzle/meta/0054_snapshot.json @@ -1,5 +1,5 @@ { - "id": "6b4f6d92-6765-4cf3-8ee2-d4d33a934ff2", + "id": "36887729-08df-4af3-98fe-d4fa87c7c5c7", "prevId": "3d8f6ad1-ff20-411e-87a0-78476ee22dd3", "version": "7", "dialect": "postgresql", @@ -1150,450 +1150,6 @@ "checkConstraints": {}, "isRLSEnabled": false }, - "public.provider_endpoint_probe_logs": { - "name": "provider_endpoint_probe_logs", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "serial", - "primaryKey": true, - "notNull": true - }, - "endpoint_id": { - "name": "endpoint_id", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "source": { - "name": "source", - "type": "varchar(20)", - "primaryKey": false, - "notNull": true, - "default": "'scheduled'" - }, - "ok": { - "name": "ok", - "type": "boolean", - "primaryKey": false, - "notNull": true - }, - "status_code": { - "name": "status_code", - "type": "integer", - "primaryKey": false, - "notNull": false - }, - "latency_ms": { - "name": "latency_ms", - "type": "integer", - "primaryKey": false, - "notNull": false - }, - "error_type": { - "name": "error_type", - "type": "varchar(64)", - "primaryKey": false, - "notNull": false - }, - "error_message": { - "name": "error_message", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false, - "default": "now()" - } - }, - "indexes": { - "idx_provider_endpoint_probe_logs_endpoint_created_at": { - "name": "idx_provider_endpoint_probe_logs_endpoint_created_at", - "columns": [ - { - "expression": "endpoint_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "created_at", - "isExpression": false, - "asc": false, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "idx_provider_endpoint_probe_logs_created_at": { - "name": "idx_provider_endpoint_probe_logs_created_at", - "columns": [ - { - "expression": "created_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "provider_endpoint_probe_logs_endpoint_id_provider_endpoints_id_fk": { - "name": "provider_endpoint_probe_logs_endpoint_id_provider_endpoints_id_fk", - "tableFrom": "provider_endpoint_probe_logs", - "tableTo": "provider_endpoints", - "columnsFrom": [ - "endpoint_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.provider_endpoints": { - "name": "provider_endpoints", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "serial", - "primaryKey": true, - "notNull": true - }, - "vendor_id": { - "name": "vendor_id", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "provider_type": { - "name": "provider_type", - "type": "varchar(20)", - "primaryKey": false, - "notNull": true, - "default": "'claude'" - }, - "url": { - "name": "url", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "label": { - "name": "label", - "type": "varchar(200)", - "primaryKey": false, - "notNull": false - }, - "sort_order": { - "name": "sort_order", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 0 - }, - "is_enabled": { - "name": "is_enabled", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": true - }, - "last_probed_at": { - "name": "last_probed_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "last_probe_ok": { - "name": "last_probe_ok", - "type": "boolean", - "primaryKey": false, - "notNull": false - }, - "last_probe_status_code": { - "name": "last_probe_status_code", - "type": "integer", - "primaryKey": false, - "notNull": false - }, - "last_probe_latency_ms": { - "name": "last_probe_latency_ms", - "type": "integer", - "primaryKey": false, - "notNull": false - }, - "last_probe_error_type": { - "name": "last_probe_error_type", - "type": "varchar(64)", - "primaryKey": false, - "notNull": false - }, - "last_probe_error_message": { - "name": "last_probe_error_message", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false, - "default": "now()" - }, - "deleted_at": { - "name": "deleted_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - } - }, - "indexes": { - "uniq_provider_endpoints_vendor_type_url": { - "name": "uniq_provider_endpoints_vendor_type_url", - "columns": [ - { - "expression": "vendor_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "provider_type", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "url", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "concurrently": false, - "method": "btree", - "with": {} - }, - "idx_provider_endpoints_vendor_type": { - "name": "idx_provider_endpoints_vendor_type", - "columns": [ - { - "expression": "vendor_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "provider_type", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "where": "\"provider_endpoints\".\"deleted_at\" IS NULL", - "concurrently": false, - "method": "btree", - "with": {} - }, - "idx_provider_endpoints_enabled": { - "name": "idx_provider_endpoints_enabled", - "columns": [ - { - "expression": "is_enabled", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "vendor_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "provider_type", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "where": "\"provider_endpoints\".\"deleted_at\" IS NULL", - "concurrently": false, - "method": "btree", - "with": {} - }, - "idx_provider_endpoints_created_at": { - "name": "idx_provider_endpoints_created_at", - "columns": [ - { - "expression": "created_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "idx_provider_endpoints_deleted_at": { - "name": "idx_provider_endpoints_deleted_at", - "columns": [ - { - "expression": "deleted_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "provider_endpoints_vendor_id_provider_vendors_id_fk": { - "name": "provider_endpoints_vendor_id_provider_vendors_id_fk", - "tableFrom": "provider_endpoints", - "tableTo": "provider_vendors", - "columnsFrom": [ - "vendor_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.provider_vendors": { - "name": "provider_vendors", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "serial", - "primaryKey": true, - "notNull": true - }, - "website_domain": { - "name": "website_domain", - "type": "varchar(255)", - "primaryKey": false, - "notNull": true - }, - "display_name": { - "name": "display_name", - "type": "varchar(200)", - "primaryKey": false, - "notNull": false - }, - "website_url": { - "name": "website_url", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "favicon_url": { - "name": "favicon_url", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false, - "default": "now()" - } - }, - "indexes": { - "uniq_provider_vendors_website_domain": { - "name": "uniq_provider_vendors_website_domain", - "columns": [ - { - "expression": "website_domain", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "concurrently": false, - "method": "btree", - "with": {} - }, - "idx_provider_vendors_created_at": { - "name": "idx_provider_vendors_created_at", - "columns": [ - { - "expression": "created_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, "public.providers": { "name": "providers", "schema": "", @@ -1628,12 +1184,6 @@ "primaryKey": false, "notNull": true }, - "provider_vendor_id": { - "name": "provider_vendor_id", - "type": "integer", - "primaryKey": false, - "notNull": true - }, "is_enabled": { "name": "is_enabled", "type": "boolean", @@ -2012,45 +1562,9 @@ "concurrently": false, "method": "btree", "with": {} - }, - "idx_providers_vendor_type": { - "name": "idx_providers_vendor_type", - "columns": [ - { - "expression": "provider_vendor_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "provider_type", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "where": "\"providers\".\"deleted_at\" IS NULL", - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "providers_provider_vendor_id_provider_vendors_id_fk": { - "name": "providers_provider_vendor_id_provider_vendors_id_fk", - "tableFrom": "providers", - "tableTo": "provider_vendors", - "columnsFrom": [ - "provider_vendor_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "restrict", - "onUpdate": "no action" } }, + "foreignKeys": {}, "compositePrimaryKeys": {}, "uniqueConstraints": {}, "policies": {}, diff --git a/drizzle/meta/0055_snapshot.json b/drizzle/meta/0055_snapshot.json index f6da90876..939e558ff 100644 --- a/drizzle/meta/0055_snapshot.json +++ b/drizzle/meta/0055_snapshot.json @@ -1,6 +1,6 @@ { - "id": "390e30e9-a88b-4b14-b1d7-e51ccc03a913", - "prevId": "6b4f6d92-6765-4cf3-8ee2-d4d33a934ff2", + "id": "b40c930a-4001-4403-90b9-652a5878893c", + "prevId": "36887729-08df-4af3-98fe-d4fa87c7c5c7", "version": "7", "dialect": "postgresql", "tables": { @@ -637,6 +637,22 @@ "method": "btree", "with": {} }, + "idx_message_request_session_id_prefix": { + "name": "idx_message_request_session_id_prefix", + "columns": [ + { + "expression": "\"session_id\" varchar_pattern_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, "idx_message_request_session_seq": { "name": "idx_message_request_session_seq", "columns": [ @@ -1150,457 +1166,6 @@ "checkConstraints": {}, "isRLSEnabled": false }, - "public.provider_endpoint_probe_logs": { - "name": "provider_endpoint_probe_logs", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "serial", - "primaryKey": true, - "notNull": true - }, - "endpoint_id": { - "name": "endpoint_id", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "source": { - "name": "source", - "type": "varchar(20)", - "primaryKey": false, - "notNull": true, - "default": "'scheduled'" - }, - "ok": { - "name": "ok", - "type": "boolean", - "primaryKey": false, - "notNull": true - }, - "status_code": { - "name": "status_code", - "type": "integer", - "primaryKey": false, - "notNull": false - }, - "latency_ms": { - "name": "latency_ms", - "type": "integer", - "primaryKey": false, - "notNull": false - }, - "error_type": { - "name": "error_type", - "type": "varchar(64)", - "primaryKey": false, - "notNull": false - }, - "error_message": { - "name": "error_message", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false, - "default": "now()" - } - }, - "indexes": { - "idx_provider_endpoint_probe_logs_endpoint_created_at": { - "name": "idx_provider_endpoint_probe_logs_endpoint_created_at", - "columns": [ - { - "expression": "endpoint_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "created_at", - "isExpression": false, - "asc": false, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "idx_provider_endpoint_probe_logs_created_at": { - "name": "idx_provider_endpoint_probe_logs_created_at", - "columns": [ - { - "expression": "created_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "provider_endpoint_probe_logs_endpoint_id_provider_endpoints_id_fk": { - "name": "provider_endpoint_probe_logs_endpoint_id_provider_endpoints_id_fk", - "tableFrom": "provider_endpoint_probe_logs", - "tableTo": "provider_endpoints", - "columnsFrom": [ - "endpoint_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.provider_endpoints": { - "name": "provider_endpoints", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "serial", - "primaryKey": true, - "notNull": true - }, - "vendor_id": { - "name": "vendor_id", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "provider_type": { - "name": "provider_type", - "type": "varchar(20)", - "primaryKey": false, - "notNull": true, - "default": "'claude'" - }, - "url": { - "name": "url", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "label": { - "name": "label", - "type": "varchar(200)", - "primaryKey": false, - "notNull": false - }, - "sort_order": { - "name": "sort_order", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 0 - }, - "is_enabled": { - "name": "is_enabled", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": true - }, - "last_probed_at": { - "name": "last_probed_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "last_probe_ok": { - "name": "last_probe_ok", - "type": "boolean", - "primaryKey": false, - "notNull": false - }, - "last_probe_status_code": { - "name": "last_probe_status_code", - "type": "integer", - "primaryKey": false, - "notNull": false - }, - "last_probe_latency_ms": { - "name": "last_probe_latency_ms", - "type": "integer", - "primaryKey": false, - "notNull": false - }, - "last_probe_error_type": { - "name": "last_probe_error_type", - "type": "varchar(64)", - "primaryKey": false, - "notNull": false - }, - "last_probe_error_message": { - "name": "last_probe_error_message", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false, - "default": "now()" - }, - "deleted_at": { - "name": "deleted_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - } - }, - "indexes": { - "uniq_provider_endpoints_vendor_type_url": { - "name": "uniq_provider_endpoints_vendor_type_url", - "columns": [ - { - "expression": "vendor_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "provider_type", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "url", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "concurrently": false, - "method": "btree", - "with": {} - }, - "idx_provider_endpoints_vendor_type": { - "name": "idx_provider_endpoints_vendor_type", - "columns": [ - { - "expression": "vendor_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "provider_type", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "where": "\"provider_endpoints\".\"deleted_at\" IS NULL", - "concurrently": false, - "method": "btree", - "with": {} - }, - "idx_provider_endpoints_enabled": { - "name": "idx_provider_endpoints_enabled", - "columns": [ - { - "expression": "is_enabled", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "vendor_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "provider_type", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "where": "\"provider_endpoints\".\"deleted_at\" IS NULL", - "concurrently": false, - "method": "btree", - "with": {} - }, - "idx_provider_endpoints_created_at": { - "name": "idx_provider_endpoints_created_at", - "columns": [ - { - "expression": "created_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "idx_provider_endpoints_deleted_at": { - "name": "idx_provider_endpoints_deleted_at", - "columns": [ - { - "expression": "deleted_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "provider_endpoints_vendor_id_provider_vendors_id_fk": { - "name": "provider_endpoints_vendor_id_provider_vendors_id_fk", - "tableFrom": "provider_endpoints", - "tableTo": "provider_vendors", - "columnsFrom": [ - "vendor_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.provider_vendors": { - "name": "provider_vendors", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "serial", - "primaryKey": true, - "notNull": true - }, - "website_domain": { - "name": "website_domain", - "type": "varchar(255)", - "primaryKey": false, - "notNull": true - }, - "display_name": { - "name": "display_name", - "type": "varchar(200)", - "primaryKey": false, - "notNull": false - }, - "website_url": { - "name": "website_url", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "favicon_url": { - "name": "favicon_url", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "is_official": { - "name": "is_official", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false, - "default": "now()" - } - }, - "indexes": { - "uniq_provider_vendors_website_domain": { - "name": "uniq_provider_vendors_website_domain", - "columns": [ - { - "expression": "website_domain", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "concurrently": false, - "method": "btree", - "with": {} - }, - "idx_provider_vendors_created_at": { - "name": "idx_provider_vendors_created_at", - "columns": [ - { - "expression": "created_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, "public.providers": { "name": "providers", "schema": "", @@ -1635,12 +1200,6 @@ "primaryKey": false, "notNull": true }, - "provider_vendor_id": { - "name": "provider_vendor_id", - "type": "integer", - "primaryKey": false, - "notNull": true - }, "is_enabled": { "name": "is_enabled", "type": "boolean", @@ -2019,45 +1578,9 @@ "concurrently": false, "method": "btree", "with": {} - }, - "idx_providers_vendor_type": { - "name": "idx_providers_vendor_type", - "columns": [ - { - "expression": "provider_vendor_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "provider_type", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "where": "\"providers\".\"deleted_at\" IS NULL", - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "providers_provider_vendor_id_provider_vendors_id_fk": { - "name": "providers_provider_vendor_id_provider_vendors_id_fk", - "tableFrom": "providers", - "tableTo": "provider_vendors", - "columnsFrom": [ - "provider_vendor_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "restrict", - "onUpdate": "no action" } }, + "foreignKeys": {}, "compositePrimaryKeys": {}, "uniqueConstraints": {}, "policies": {}, diff --git a/drizzle/meta/0056_snapshot.json b/drizzle/meta/0056_snapshot.json index 6d76ac604..6a54a04a9 100644 --- a/drizzle/meta/0056_snapshot.json +++ b/drizzle/meta/0056_snapshot.json @@ -1,6 +1,6 @@ { - "id": "4f67adbe-ba12-42b2-a553-558d6064974f", - "prevId": "390e30e9-a88b-4b14-b1d7-e51ccc03a913", + "id": "75eef188-0cac-4ae8-9deb-9b0db4f046c2", + "prevId": "b40c930a-4001-4403-90b9-652a5878893c", "version": "7", "dialect": "postgresql", "tables": { @@ -637,6 +637,22 @@ "method": "btree", "with": {} }, + "idx_message_request_session_id_prefix": { + "name": "idx_message_request_session_id_prefix", + "columns": [ + { + "expression": "\"session_id\" varchar_pattern_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, "idx_message_request_session_seq": { "name": "idx_message_request_session_seq", "columns": [ diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 5c7de6236..87303a2fd 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -383,22 +383,22 @@ { "idx": 54, "version": "7", - "when": 1768383979816, - "tag": "0054_mixed_eternity", + "when": 1768240715707, + "tag": "0054_tidy_winter_soldier", "breakpoints": true }, { "idx": 55, "version": "7", - "when": 1768451294331, - "tag": "0055_familiar_shatterstar", + "when": 1768443427816, + "tag": "0055_neat_stepford_cuckoos", "breakpoints": true }, { "idx": 56, "version": "7", - "when": 1768482149963, - "tag": "0056_colorful_nightshade", + "when": 1769008812140, + "tag": "0056_tidy_quasar", "breakpoints": true } ]