From a64a51aa0cfd73b342e9b59e89aca953e883d5fb Mon Sep 17 00:00:00 2001 From: Delicious <2991573049@qq.com> Date: Sun, 15 Mar 2026 22:00:02 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=B8=8B=E6=B8=B8=20API=20=E5=AF=86?= =?UTF-8?q?=E9=92=A5=E7=AE=A1=E7=90=86=20(#103)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: downstream API key usage + proxy log attribution * chore(schema): regenerate generated artifacts * test: keep migration replay + platform detection tests stable * feat: complete downstream api key management validation * refactor: move downstream keys into console nav * feat: upgrade downstream keys management console * feat: harden downstream key management flows * test: cover downstream key policy guards * feat: add downstream key grouping and tags * feat: polish downstream key search and log metadata * test: fix sqlite migration bootstrap coverage * perf: isolate vchart bundle loading * fix: align downstream key drawer metrics --------- Co-authored-by: DeliciousBuding --- .../0010_proxy_logs_downstream_api_key.sql | 3 + drizzle/0011_downstream_api_key_metadata.sql | 3 + drizzle/meta/_journal.json | 16 +- src/server/db/generated/mysql.bootstrap.sql | 5 +- src/server/db/generated/mysql.upgrade.sql | 3 +- .../db/generated/postgres.bootstrap.sql | 5 +- src/server/db/generated/postgres.upgrade.sql | 3 +- src/server/db/generated/schemaContract.json | 27 + src/server/db/index.ts | 90 + src/server/db/legacySchemaCompat.ts | 4 + src/server/db/migrate.test.ts | 2 + src/server/db/migrate.ts | 7 +- src/server/db/schema.ts | 4 + .../db/sharedIndexSchemaCompatibility.test.ts | 2 + .../db/sharedIndexSchemaCompatibility.ts | 9 + src/server/index.ts | 2 + src/server/middleware/auth.proxy.test.ts | 79 + .../api/accounts.credentialMode.test.ts | 2 +- .../api/accounts.rebind-session.test.ts | 2 +- .../routes/api/downstreamApiKeys.test.ts | 496 +++++ src/server/routes/api/downstreamApiKeys.ts | 491 ++++- .../routes/api/stats.proxy-logs.test.ts | 98 + src/server/routes/api/stats.ts | 58 +- src/server/routes/proxy/chat.ts | 36 +- src/server/routes/proxy/completions.ts | 54 +- src/server/routes/proxy/embeddings.ts | 42 +- src/server/routes/proxy/images.ts | 61 +- src/server/routes/proxy/responses.ts | 16 +- src/server/routes/proxy/search.ts | 14 +- .../services/databaseMigrationService.ts | 3 +- .../services/downstreamApiKeyService.ts | 38 + src/server/services/platforms/index.test.ts | 37 +- src/server/services/proxyLogStore.test.ts | 37 + src/server/services/proxyLogStore.ts | 51 +- src/web/App.sidebar.test.ts | 11 + src/web/App.tsx | 3 + src/web/api.ts | 21 + .../charts/DownstreamKeyTrendChart.tsx | 205 ++ src/web/pages/DownstreamKeys.test.tsx | 446 +++++ src/web/pages/DownstreamKeys.tsx | 1745 +++++++++++++++++ .../pages/ProxyLogs.server-driven.test.tsx | 9 +- src/web/pages/ProxyLogs.tsx | 27 +- src/web/pages/Settings.tsx | 68 +- vite.config.ts | 10 + 44 files changed, 4162 insertions(+), 183 deletions(-) create mode 100644 drizzle/0010_proxy_logs_downstream_api_key.sql create mode 100644 drizzle/0011_downstream_api_key_metadata.sql create mode 100644 src/server/middleware/auth.proxy.test.ts create mode 100644 src/server/routes/api/downstreamApiKeys.test.ts create mode 100644 src/web/components/charts/DownstreamKeyTrendChart.tsx create mode 100644 src/web/pages/DownstreamKeys.test.tsx create mode 100644 src/web/pages/DownstreamKeys.tsx diff --git a/drizzle/0010_proxy_logs_downstream_api_key.sql b/drizzle/0010_proxy_logs_downstream_api_key.sql new file mode 100644 index 0000000..67ccbf8 --- /dev/null +++ b/drizzle/0010_proxy_logs_downstream_api_key.sql @@ -0,0 +1,3 @@ +ALTER TABLE `proxy_logs` ADD `downstream_api_key_id` integer; +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS `proxy_logs_downstream_api_key_created_at_idx` ON `proxy_logs` (`downstream_api_key_id`, `created_at`); diff --git a/drizzle/0011_downstream_api_key_metadata.sql b/drizzle/0011_downstream_api_key_metadata.sql new file mode 100644 index 0000000..5a99895 --- /dev/null +++ b/drizzle/0011_downstream_api_key_metadata.sql @@ -0,0 +1,3 @@ +ALTER TABLE `downstream_api_keys` ADD `group_name` text; +--> statement-breakpoint +ALTER TABLE `downstream_api_keys` ADD `tags` text; diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 701397c..d5c3ccf 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -64,6 +64,20 @@ "when": 1773528832339, "tag": "0009_model_availability_is_manual", "breakpoints": true + }, + { + "idx": 9, + "version": "6", + "when": 1773553945763, + "tag": "0010_proxy_logs_downstream_api_key", + "breakpoints": true + }, + { + "idx": 10, + "version": "6", + "when": 1773605400000, + "tag": "0011_downstream_api_key_metadata", + "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/src/server/db/generated/mysql.bootstrap.sql b/src/server/db/generated/mysql.bootstrap.sql index 233bca1..0e7fa75 100644 --- a/src/server/db/generated/mysql.bootstrap.sql +++ b/src/server/db/generated/mysql.bootstrap.sql @@ -2,11 +2,11 @@ CREATE TABLE IF NOT EXISTS `sites` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY CREATE TABLE IF NOT EXISTS `accounts` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `site_id` INT NOT NULL, `username` TEXT, `access_token` TEXT NOT NULL, `api_token` TEXT, `balance` DOUBLE DEFAULT 0, `balance_used` DOUBLE DEFAULT 0, `quota` DOUBLE DEFAULT 0, `unit_cost` DOUBLE, `value_score` DOUBLE DEFAULT 0, `status` VARCHAR(191) DEFAULT 'active', `checkin_enabled` BOOLEAN DEFAULT true, `last_checkin_at` DATETIME, `last_balance_refresh` DATETIME, `extra_config` JSON, `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP, `is_pinned` BOOLEAN DEFAULT false, `sort_order` INT DEFAULT 0, FOREIGN KEY (`site_id`) REFERENCES `sites`(`id`) ON DELETE CASCADE); CREATE TABLE IF NOT EXISTS `account_tokens` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `account_id` INT NOT NULL, `name` TEXT NOT NULL, `token` TEXT NOT NULL, `source` VARCHAR(191) DEFAULT 'manual', `enabled` BOOLEAN DEFAULT true, `is_default` BOOLEAN DEFAULT false, `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP, `token_group` TEXT, FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`) ON DELETE CASCADE); CREATE TABLE IF NOT EXISTS `checkin_logs` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `account_id` INT NOT NULL, `status` TEXT NOT NULL, `message` TEXT, `reward` TEXT, `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`) ON DELETE CASCADE); -CREATE TABLE IF NOT EXISTS `downstream_api_keys` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `name` TEXT NOT NULL, `key` TEXT NOT NULL, `description` TEXT, `enabled` BOOLEAN DEFAULT true, `expires_at` DATETIME, `max_cost` DOUBLE, `used_cost` DOUBLE DEFAULT 0, `max_requests` INT, `used_requests` INT DEFAULT 0, `supported_models` JSON, `allowed_route_ids` JSON, `site_weight_multipliers` JSON, `last_used_at` DATETIME, `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP); +CREATE TABLE IF NOT EXISTS `downstream_api_keys` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `name` TEXT NOT NULL, `key` TEXT NOT NULL, `description` TEXT, `enabled` BOOLEAN DEFAULT true, `expires_at` DATETIME, `max_cost` DOUBLE, `used_cost` DOUBLE DEFAULT 0, `max_requests` INT, `used_requests` INT DEFAULT 0, `supported_models` JSON, `allowed_route_ids` JSON, `site_weight_multipliers` JSON, `last_used_at` DATETIME, `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP, `group_name` TEXT, `tags` TEXT); CREATE TABLE IF NOT EXISTS `events` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `type` TEXT NOT NULL, `title` TEXT NOT NULL, `message` TEXT, `level` VARCHAR(191) NOT NULL DEFAULT 'info', `read` BOOLEAN DEFAULT false, `related_id` INT, `related_type` TEXT, `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP); CREATE TABLE IF NOT EXISTS `model_availability` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `account_id` INT NOT NULL, `model_name` TEXT NOT NULL, `available` BOOLEAN, `latency_ms` INT, `checked_at` DATETIME DEFAULT CURRENT_TIMESTAMP, `is_manual` BOOLEAN DEFAULT false, FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`) ON DELETE CASCADE); CREATE TABLE IF NOT EXISTS `proxy_files` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `public_id` TEXT NOT NULL, `owner_type` TEXT NOT NULL, `owner_id` TEXT NOT NULL, `filename` TEXT NOT NULL, `mime_type` TEXT NOT NULL, `purpose` TEXT, `byte_size` INT NOT NULL, `sha256` TEXT NOT NULL, `content_base64` TEXT NOT NULL, `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP, `deleted_at` DATETIME); -CREATE TABLE IF NOT EXISTS `proxy_logs` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `route_id` INT, `channel_id` INT, `account_id` INT, `model_requested` TEXT, `model_actual` TEXT, `status` TEXT, `http_status` INT, `latency_ms` INT, `prompt_tokens` INT, `completion_tokens` INT, `total_tokens` INT, `estimated_cost` DOUBLE, `error_message` TEXT, `retry_count` INT DEFAULT 0, `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, `billing_details` JSON); +CREATE TABLE IF NOT EXISTS `proxy_logs` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `route_id` INT, `channel_id` INT, `account_id` INT, `model_requested` TEXT, `model_actual` TEXT, `status` TEXT, `http_status` INT, `latency_ms` INT, `prompt_tokens` INT, `completion_tokens` INT, `total_tokens` INT, `estimated_cost` DOUBLE, `error_message` TEXT, `retry_count` INT DEFAULT 0, `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, `billing_details` JSON, `downstream_api_key_id` INT); CREATE TABLE IF NOT EXISTS `proxy_video_tasks` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `public_id` TEXT NOT NULL, `upstream_video_id` TEXT NOT NULL, `site_url` TEXT NOT NULL, `token_value` TEXT NOT NULL, `requested_model` TEXT, `actual_model` TEXT, `channel_id` INT, `account_id` INT, `status_snapshot` JSON, `upstream_response_meta` JSON, `last_upstream_status` INT, `last_polled_at` DATETIME, `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP); CREATE TABLE IF NOT EXISTS `token_routes` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `model_pattern` TEXT NOT NULL, `model_mapping` JSON, `enabled` BOOLEAN DEFAULT true, `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP, `display_name` TEXT, `display_icon` TEXT, `decision_snapshot` JSON, `decision_refreshed_at` DATETIME, `routing_strategy` VARCHAR(191) DEFAULT 'weighted'); CREATE TABLE IF NOT EXISTS `route_channels` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `route_id` INT NOT NULL, `account_id` INT NOT NULL, `token_id` INT, `source_model` TEXT, `priority` INT DEFAULT 0, `weight` INT DEFAULT 10, `enabled` BOOLEAN DEFAULT true, `manual_override` BOOLEAN DEFAULT false, `success_count` INT DEFAULT 0, `fail_count` INT DEFAULT 0, `total_latency_ms` INT DEFAULT 0, `total_cost` DOUBLE DEFAULT 0, `last_used_at` DATETIME, `last_selected_at` DATETIME, `last_fail_at` DATETIME, `consecutive_fail_count` INT NOT NULL DEFAULT 0, `cooldown_level` INT NOT NULL DEFAULT 0, `cooldown_until` DATETIME, FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`) ON DELETE CASCADE, FOREIGN KEY (`route_id`) REFERENCES `token_routes`(`id`) ON DELETE CASCADE, FOREIGN KEY (`token_id`) REFERENCES `account_tokens`(`id`) ON DELETE SET NULL); @@ -39,6 +39,7 @@ CREATE INDEX `model_availability_model_name_idx` ON `model_availability` (`model CREATE INDEX `proxy_files_owner_lookup_idx` ON `proxy_files` (`owner_type`(191), `owner_id`(191), `deleted_at`); CREATE INDEX `proxy_logs_account_created_at_idx` ON `proxy_logs` (`account_id`, `created_at`); CREATE INDEX `proxy_logs_created_at_idx` ON `proxy_logs` (`created_at`); +CREATE INDEX `proxy_logs_downstream_api_key_created_at_idx` ON `proxy_logs` (`downstream_api_key_id`, `created_at`); CREATE INDEX `proxy_logs_model_actual_created_at_idx` ON `proxy_logs` (`model_actual`(191), `created_at`); CREATE INDEX `proxy_logs_status_created_at_idx` ON `proxy_logs` (`status`(191), `created_at`); CREATE INDEX `proxy_video_tasks_created_at_idx` ON `proxy_video_tasks` (`created_at`); diff --git a/src/server/db/generated/mysql.upgrade.sql b/src/server/db/generated/mysql.upgrade.sql index 9c88768..082deef 100644 --- a/src/server/db/generated/mysql.upgrade.sql +++ b/src/server/db/generated/mysql.upgrade.sql @@ -1 +1,2 @@ -ALTER TABLE `model_availability` ADD COLUMN `is_manual` BOOLEAN DEFAULT false; +ALTER TABLE `downstream_api_keys` ADD COLUMN `group_name` TEXT; +ALTER TABLE `downstream_api_keys` ADD COLUMN `tags` TEXT; diff --git a/src/server/db/generated/postgres.bootstrap.sql b/src/server/db/generated/postgres.bootstrap.sql index 8402c44..d8762b6 100644 --- a/src/server/db/generated/postgres.bootstrap.sql +++ b/src/server/db/generated/postgres.bootstrap.sql @@ -2,11 +2,11 @@ CREATE TABLE IF NOT EXISTS "sites" ("id" INTEGER GENERATED BY DEFAULT AS IDENTIT CREATE TABLE IF NOT EXISTS "accounts" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "site_id" INTEGER NOT NULL, "username" TEXT, "access_token" TEXT NOT NULL, "api_token" TEXT, "balance" DOUBLE PRECISION DEFAULT 0, "balance_used" DOUBLE PRECISION DEFAULT 0, "quota" DOUBLE PRECISION DEFAULT 0, "unit_cost" DOUBLE PRECISION, "value_score" DOUBLE PRECISION DEFAULT 0, "status" TEXT DEFAULT 'active', "checkin_enabled" BOOLEAN DEFAULT true, "last_checkin_at" TIMESTAMP, "last_balance_refresh" TIMESTAMP, "extra_config" JSONB, "created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "updated_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "is_pinned" BOOLEAN DEFAULT false, "sort_order" INTEGER DEFAULT 0, FOREIGN KEY ("site_id") REFERENCES "sites"("id") ON DELETE CASCADE); CREATE TABLE IF NOT EXISTS "account_tokens" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "account_id" INTEGER NOT NULL, "name" TEXT NOT NULL, "token" TEXT NOT NULL, "source" TEXT DEFAULT 'manual', "enabled" BOOLEAN DEFAULT true, "is_default" BOOLEAN DEFAULT false, "created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "updated_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "token_group" TEXT, FOREIGN KEY ("account_id") REFERENCES "accounts"("id") ON DELETE CASCADE); CREATE TABLE IF NOT EXISTS "checkin_logs" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "account_id" INTEGER NOT NULL, "status" TEXT NOT NULL, "message" TEXT, "reward" TEXT, "created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY ("account_id") REFERENCES "accounts"("id") ON DELETE CASCADE); -CREATE TABLE IF NOT EXISTS "downstream_api_keys" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "name" TEXT NOT NULL, "key" TEXT NOT NULL, "description" TEXT, "enabled" BOOLEAN DEFAULT true, "expires_at" TIMESTAMP, "max_cost" DOUBLE PRECISION, "used_cost" DOUBLE PRECISION DEFAULT 0, "max_requests" INTEGER, "used_requests" INTEGER DEFAULT 0, "supported_models" JSONB, "allowed_route_ids" JSONB, "site_weight_multipliers" JSONB, "last_used_at" TIMESTAMP, "created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "updated_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP); +CREATE TABLE IF NOT EXISTS "downstream_api_keys" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "name" TEXT NOT NULL, "key" TEXT NOT NULL, "description" TEXT, "enabled" BOOLEAN DEFAULT true, "expires_at" TIMESTAMP, "max_cost" DOUBLE PRECISION, "used_cost" DOUBLE PRECISION DEFAULT 0, "max_requests" INTEGER, "used_requests" INTEGER DEFAULT 0, "supported_models" JSONB, "allowed_route_ids" JSONB, "site_weight_multipliers" JSONB, "last_used_at" TIMESTAMP, "created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "updated_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "group_name" TEXT, "tags" TEXT); CREATE TABLE IF NOT EXISTS "events" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "type" TEXT NOT NULL, "title" TEXT NOT NULL, "message" TEXT, "level" TEXT NOT NULL DEFAULT 'info', "read" BOOLEAN DEFAULT false, "related_id" INTEGER, "related_type" TEXT, "created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP); CREATE TABLE IF NOT EXISTS "model_availability" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "account_id" INTEGER NOT NULL, "model_name" TEXT NOT NULL, "available" BOOLEAN, "latency_ms" INTEGER, "checked_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "is_manual" BOOLEAN DEFAULT false, FOREIGN KEY ("account_id") REFERENCES "accounts"("id") ON DELETE CASCADE); CREATE TABLE IF NOT EXISTS "proxy_files" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "public_id" TEXT NOT NULL, "owner_type" TEXT NOT NULL, "owner_id" TEXT NOT NULL, "filename" TEXT NOT NULL, "mime_type" TEXT NOT NULL, "purpose" TEXT, "byte_size" INTEGER NOT NULL, "sha256" TEXT NOT NULL, "content_base64" TEXT NOT NULL, "created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "updated_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "deleted_at" TIMESTAMP); -CREATE TABLE IF NOT EXISTS "proxy_logs" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "route_id" INTEGER, "channel_id" INTEGER, "account_id" INTEGER, "model_requested" TEXT, "model_actual" TEXT, "status" TEXT, "http_status" INTEGER, "latency_ms" INTEGER, "prompt_tokens" INTEGER, "completion_tokens" INTEGER, "total_tokens" INTEGER, "estimated_cost" DOUBLE PRECISION, "error_message" TEXT, "retry_count" INTEGER DEFAULT 0, "created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "billing_details" JSONB); +CREATE TABLE IF NOT EXISTS "proxy_logs" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "route_id" INTEGER, "channel_id" INTEGER, "account_id" INTEGER, "model_requested" TEXT, "model_actual" TEXT, "status" TEXT, "http_status" INTEGER, "latency_ms" INTEGER, "prompt_tokens" INTEGER, "completion_tokens" INTEGER, "total_tokens" INTEGER, "estimated_cost" DOUBLE PRECISION, "error_message" TEXT, "retry_count" INTEGER DEFAULT 0, "created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "billing_details" JSONB, "downstream_api_key_id" INTEGER); CREATE TABLE IF NOT EXISTS "proxy_video_tasks" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "public_id" TEXT NOT NULL, "upstream_video_id" TEXT NOT NULL, "site_url" TEXT NOT NULL, "token_value" TEXT NOT NULL, "requested_model" TEXT, "actual_model" TEXT, "channel_id" INTEGER, "account_id" INTEGER, "status_snapshot" JSONB, "upstream_response_meta" JSONB, "last_upstream_status" INTEGER, "last_polled_at" TIMESTAMP, "created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "updated_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP); CREATE TABLE IF NOT EXISTS "token_routes" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "model_pattern" TEXT NOT NULL, "model_mapping" JSONB, "enabled" BOOLEAN DEFAULT true, "created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "updated_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "display_name" TEXT, "display_icon" TEXT, "decision_snapshot" JSONB, "decision_refreshed_at" TIMESTAMP, "routing_strategy" TEXT DEFAULT 'weighted'); CREATE TABLE IF NOT EXISTS "route_channels" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "route_id" INTEGER NOT NULL, "account_id" INTEGER NOT NULL, "token_id" INTEGER, "source_model" TEXT, "priority" INTEGER DEFAULT 0, "weight" INTEGER DEFAULT 10, "enabled" BOOLEAN DEFAULT true, "manual_override" BOOLEAN DEFAULT false, "success_count" INTEGER DEFAULT 0, "fail_count" INTEGER DEFAULT 0, "total_latency_ms" INTEGER DEFAULT 0, "total_cost" DOUBLE PRECISION DEFAULT 0, "last_used_at" TIMESTAMP, "last_selected_at" TIMESTAMP, "last_fail_at" TIMESTAMP, "consecutive_fail_count" INTEGER NOT NULL DEFAULT 0, "cooldown_level" INTEGER NOT NULL DEFAULT 0, "cooldown_until" TIMESTAMP, FOREIGN KEY ("account_id") REFERENCES "accounts"("id") ON DELETE CASCADE, FOREIGN KEY ("route_id") REFERENCES "token_routes"("id") ON DELETE CASCADE, FOREIGN KEY ("token_id") REFERENCES "account_tokens"("id") ON DELETE SET NULL); @@ -39,6 +39,7 @@ CREATE INDEX "model_availability_model_name_idx" ON "model_availability" ("model CREATE INDEX "proxy_files_owner_lookup_idx" ON "proxy_files" ("owner_type", "owner_id", "deleted_at"); CREATE INDEX "proxy_logs_account_created_at_idx" ON "proxy_logs" ("account_id", "created_at"); CREATE INDEX "proxy_logs_created_at_idx" ON "proxy_logs" ("created_at"); +CREATE INDEX "proxy_logs_downstream_api_key_created_at_idx" ON "proxy_logs" ("downstream_api_key_id", "created_at"); CREATE INDEX "proxy_logs_model_actual_created_at_idx" ON "proxy_logs" ("model_actual", "created_at"); CREATE INDEX "proxy_logs_status_created_at_idx" ON "proxy_logs" ("status", "created_at"); CREATE INDEX "proxy_video_tasks_created_at_idx" ON "proxy_video_tasks" ("created_at"); diff --git a/src/server/db/generated/postgres.upgrade.sql b/src/server/db/generated/postgres.upgrade.sql index 266130b..f6e05ac 100644 --- a/src/server/db/generated/postgres.upgrade.sql +++ b/src/server/db/generated/postgres.upgrade.sql @@ -1 +1,2 @@ -ALTER TABLE "model_availability" ADD COLUMN "is_manual" BOOLEAN DEFAULT false; +ALTER TABLE "downstream_api_keys" ADD COLUMN "group_name" TEXT; +ALTER TABLE "downstream_api_keys" ADD COLUMN "tags" TEXT; diff --git a/src/server/db/generated/schemaContract.json b/src/server/db/generated/schemaContract.json index f57e87e..e7cc01a 100644 --- a/src/server/db/generated/schemaContract.json +++ b/src/server/db/generated/schemaContract.json @@ -319,6 +319,18 @@ "notNull": false, "defaultValue": "datetime('now')", "primaryKey": false + }, + "group_name": { + "logicalType": "text", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "tags": { + "logicalType": "text", + "notNull": false, + "defaultValue": null, + "primaryKey": false } } }, @@ -611,6 +623,12 @@ "notNull": false, "defaultValue": null, "primaryKey": false + }, + "downstream_api_key_id": { + "logicalType": "integer", + "notNull": false, + "defaultValue": null, + "primaryKey": false } } }, @@ -1270,6 +1288,15 @@ ], "unique": false }, + { + "name": "proxy_logs_downstream_api_key_created_at_idx", + "table": "proxy_logs", + "columns": [ + "downstream_api_key_id", + "created_at" + ], + "unique": false + }, { "name": "proxy_logs_model_actual_created_at_idx", "table": "proxy_logs", diff --git a/src/server/db/index.ts b/src/server/db/index.ts index bb27a1b..71bbf9d 100644 --- a/src/server/db/index.ts +++ b/src/server/db/index.ts @@ -42,6 +42,7 @@ let sqliteConnection: Database.Database | null = null; let mysqlPool: mysql.Pool | null = null; let pgPool: pg.Pool | null = null; let proxyLogBillingDetailsColumnAvailable: boolean | null = null; +let proxyLogDownstreamApiKeyIdColumnAvailable: boolean | null = null; function resolveSqlitePath(): string { const raw = (config.dbUrl || '').trim(); @@ -501,6 +502,8 @@ function ensureDownstreamApiKeySchema() { name text NOT NULL, key text NOT NULL, description text, + group_name text, + tags text, enabled integer DEFAULT true, expires_at text, max_cost real, @@ -532,6 +535,14 @@ function ensureDownstreamApiKeySchema() { CREATE INDEX IF NOT EXISTS downstream_api_keys_expires_at_idx ON downstream_api_keys(expires_at); `); + + if (!tableColumnExists('downstream_api_keys', 'group_name')) { + execSqliteLegacyCompat('ALTER TABLE downstream_api_keys ADD COLUMN group_name text;'); + } + + if (!tableColumnExists('downstream_api_keys', 'tags')) { + execSqliteLegacyCompat('ALTER TABLE downstream_api_keys ADD COLUMN tags text;'); + } } function ensureProxyLogBillingDetailsSchema() { @@ -624,8 +635,87 @@ export async function ensureProxyLogBillingDetailsColumn(): Promise { } } +function ensureProxyLogDownstreamApiKeyIdSchema() { + if (!tableExists('proxy_logs')) { + return; + } + + if (!tableColumnExists('proxy_logs', 'downstream_api_key_id')) { + execSqliteLegacyCompat('ALTER TABLE proxy_logs ADD COLUMN downstream_api_key_id integer;'); + } + + proxyLogDownstreamApiKeyIdColumnAvailable = true; +} + +export async function hasProxyLogDownstreamApiKeyIdColumn(): Promise { + if (proxyLogDownstreamApiKeyIdColumnAvailable !== null) { + return proxyLogDownstreamApiKeyIdColumnAvailable; + } + + if (runtimeDbDialect === 'sqlite') { + proxyLogDownstreamApiKeyIdColumnAvailable = tableExists('proxy_logs') + && tableColumnExists('proxy_logs', 'downstream_api_key_id'); + return proxyLogDownstreamApiKeyIdColumnAvailable; + } + + if (runtimeDbDialect === 'mysql') { + if (!mysqlPool) return false; + const [rows] = await mysqlPool.query('SHOW COLUMNS FROM `proxy_logs` LIKE ?', ['downstream_api_key_id']); + proxyLogDownstreamApiKeyIdColumnAvailable = Array.isArray(rows) && rows.length > 0; + return proxyLogDownstreamApiKeyIdColumnAvailable; + } + + if (!pgPool) return false; + const result = await pgPool.query( + 'SELECT 1 FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = $1 AND column_name = $2 LIMIT 1', + ['proxy_logs', 'downstream_api_key_id'], + ); + proxyLogDownstreamApiKeyIdColumnAvailable = Number(result.rowCount || 0) > 0; + return proxyLogDownstreamApiKeyIdColumnAvailable; +} + +export async function ensureProxyLogDownstreamApiKeyIdColumn(): Promise { + if (runtimeDbDialect === 'sqlite') { + ensureProxyLogDownstreamApiKeyIdSchema(); + proxyLogDownstreamApiKeyIdColumnAvailable = tableExists('proxy_logs') + && tableColumnExists('proxy_logs', 'downstream_api_key_id'); + return proxyLogDownstreamApiKeyIdColumnAvailable; + } + + if (await hasProxyLogDownstreamApiKeyIdColumn()) { + return true; + } + + try { + if (runtimeDbDialect === 'mysql') { + if (!mysqlPool) return false; + await executeLegacyCompat( + (statement) => mysqlPool!.query(statement).then(() => undefined), + 'ALTER TABLE `proxy_logs` ADD COLUMN `downstream_api_key_id` INT NULL', + ); + } else { + if (!pgPool) return false; + await executeLegacyCompat( + (statement) => pgPool!.query(statement).then(() => undefined), + 'ALTER TABLE "proxy_logs" ADD COLUMN "downstream_api_key_id" INTEGER', + ); + } + proxyLogDownstreamApiKeyIdColumnAvailable = true; + return true; + } catch (error) { + if (isDuplicateColumnError(error)) { + proxyLogDownstreamApiKeyIdColumnAvailable = true; + return true; + } + proxyLogDownstreamApiKeyIdColumnAvailable = false; + console.warn('[db] failed to ensure proxy_logs.downstream_api_key_id column', error); + return false; + } +} + function resetSchemaCapabilityCache() { proxyLogBillingDetailsColumnAvailable = null; + proxyLogDownstreamApiKeyIdColumnAvailable = null; } async function sqliteProxyQuery(sqlText: string, params: unknown[], method: SqlMethod) { diff --git a/src/server/db/legacySchemaCompat.ts b/src/server/db/legacySchemaCompat.ts index 0e20a3e..5a2ecfb 100644 --- a/src/server/db/legacySchemaCompat.ts +++ b/src/server/db/legacySchemaCompat.ts @@ -60,7 +60,10 @@ const LEGACY_COMPAT_COLUMNS = new Set([ 'proxy_video_tasks.upstream_response_meta', 'proxy_video_tasks.last_upstream_status', 'proxy_video_tasks.last_polled_at', + 'downstream_api_keys.group_name', + 'downstream_api_keys.tags', 'proxy_logs.billing_details', + 'proxy_logs.downstream_api_key_id', ]); const LEGACY_COMPAT_INDEXES = new Set([ @@ -75,6 +78,7 @@ const LEGACY_COMPAT_INDEXES = new Set([ 'downstream_api_keys_name_idx', 'downstream_api_keys_enabled_idx', 'downstream_api_keys_expires_at_idx', + 'proxy_logs_downstream_api_key_created_at_idx', ...SHARED_INDEX_COMPATIBILITY_SPECS.map((spec) => spec.indexName), ]); diff --git a/src/server/db/migrate.test.ts b/src/server/db/migrate.test.ts index d9cc867..aa1ed4a 100644 --- a/src/server/db/migrate.test.ts +++ b/src/server/db/migrate.test.ts @@ -283,6 +283,8 @@ describe('sqlite migrate bootstrap', () => { '0007_account_token_group', '0008_sqlite_schema_backfill', '0009_model_availability_is_manual', + '0010_proxy_logs_downstream_api_key', + '0011_downstream_api_key_metadata', ]); const appliedEntries = journalEntries.filter((entry) => !missingTags.has(entry.tag)); diff --git a/src/server/db/migrate.ts b/src/server/db/migrate.ts index 5321d06..0740ca9 100644 --- a/src/server/db/migrate.ts +++ b/src/server/db/migrate.ts @@ -34,7 +34,7 @@ type RecoveryMigration = RecoveryMigrationRecord & { statements: string[]; }; -const VERIFIED_BOOTSTRAP_TAG = '0009_model_availability_is_manual'; +const VERIFIED_BOOTSTRAP_TAG = '0011_downstream_api_key_metadata'; const VERIFIED_SCHEMA_MARKERS: SchemaMarker[] = [ { table: 'sites' }, { table: 'settings' }, @@ -57,6 +57,11 @@ const VERIFIED_SCHEMA_MARKERS: SchemaMarker[] = [ { table: 'account_tokens', column: 'token_group' }, // 0009: is_manual column on model_availability { table: 'model_availability', column: 'is_manual' }, + // 0010: downstream_api_key_id column on proxy_logs + { table: 'proxy_logs', column: 'downstream_api_key_id' }, + // 0011: downstream key metadata columns + { table: 'downstream_api_keys', column: 'group_name' }, + { table: 'downstream_api_keys', column: 'tags' }, ]; diff --git a/src/server/db/schema.ts b/src/server/db/schema.ts index 413ea31..8c9c356 100644 --- a/src/server/db/schema.ts +++ b/src/server/db/schema.ts @@ -165,6 +165,7 @@ export const proxyLogs = sqliteTable('proxy_logs', { routeId: integer('route_id'), channelId: integer('channel_id'), accountId: integer('account_id'), + downstreamApiKeyId: integer('downstream_api_key_id'), modelRequested: text('model_requested'), modelActual: text('model_actual'), status: text('status'), // 'success' | 'failed' | 'retried' @@ -183,6 +184,7 @@ export const proxyLogs = sqliteTable('proxy_logs', { accountCreatedIdx: index('proxy_logs_account_created_at_idx').on(table.accountId, table.createdAt), statusCreatedIdx: index('proxy_logs_status_created_at_idx').on(table.status, table.createdAt), modelActualCreatedIdx: index('proxy_logs_model_actual_created_at_idx').on(table.modelActual, table.createdAt), + downstreamKeyCreatedIdx: index('proxy_logs_downstream_api_key_created_at_idx').on(table.downstreamApiKeyId, table.createdAt), })); export const proxyVideoTasks = sqliteTable('proxy_video_tasks', { @@ -236,6 +238,8 @@ export const downstreamApiKeys = sqliteTable('downstream_api_keys', { name: text('name').notNull(), key: text('key').notNull(), description: text('description'), + groupName: text('group_name'), + tags: text('tags'), // JSON array enabled: integer('enabled', { mode: 'boolean' }).default(true), expiresAt: text('expires_at'), maxCost: real('max_cost'), diff --git a/src/server/db/sharedIndexSchemaCompatibility.test.ts b/src/server/db/sharedIndexSchemaCompatibility.test.ts index a551607..c234e24 100644 --- a/src/server/db/sharedIndexSchemaCompatibility.test.ts +++ b/src/server/db/sharedIndexSchemaCompatibility.test.ts @@ -38,6 +38,7 @@ describe('ensureSharedIndexSchemaCompatibility', () => { expectedSql: [ 'CREATE UNIQUE INDEX `model_availability_account_model_unique` ON `model_availability` (`account_id`, `model_name`(191))', 'CREATE INDEX `proxy_logs_status_created_at_idx` ON `proxy_logs` (`status`, `created_at`(191))', + 'CREATE INDEX `proxy_logs_downstream_api_key_created_at_idx` ON `proxy_logs` (`downstream_api_key_id`, `created_at`(191))', 'CREATE INDEX `events_created_at_idx` ON `events` (`created_at`(191))', ], }, @@ -46,6 +47,7 @@ describe('ensureSharedIndexSchemaCompatibility', () => { expectedSql: [ 'CREATE UNIQUE INDEX IF NOT EXISTS "model_availability_account_model_unique" ON "model_availability" ("account_id", "model_name")', 'CREATE INDEX IF NOT EXISTS "proxy_logs_status_created_at_idx" ON "proxy_logs" ("status", "created_at")', + 'CREATE INDEX IF NOT EXISTS "proxy_logs_downstream_api_key_created_at_idx" ON "proxy_logs" ("downstream_api_key_id", "created_at")', 'CREATE INDEX IF NOT EXISTS "events_created_at_idx" ON "events" ("created_at")', ], }, diff --git a/src/server/db/sharedIndexSchemaCompatibility.ts b/src/server/db/sharedIndexSchemaCompatibility.ts index e0bf843..04a5c8d 100644 --- a/src/server/db/sharedIndexSchemaCompatibility.ts +++ b/src/server/db/sharedIndexSchemaCompatibility.ts @@ -265,6 +265,15 @@ export const SHARED_INDEX_COMPATIBILITY_SPECS: SharedIndexCompatibilitySpec[] = postgres: 'CREATE INDEX IF NOT EXISTS "proxy_logs_model_actual_created_at_idx" ON "proxy_logs" ("model_actual", "created_at")', }, }, + { + table: 'proxy_logs', + indexName: 'proxy_logs_downstream_api_key_created_at_idx', + createSql: { + sqlite: 'CREATE INDEX IF NOT EXISTS proxy_logs_downstream_api_key_created_at_idx ON proxy_logs (downstream_api_key_id, created_at);', + mysql: 'CREATE INDEX `proxy_logs_downstream_api_key_created_at_idx` ON `proxy_logs` (`downstream_api_key_id`, `created_at`(191))', + postgres: 'CREATE INDEX IF NOT EXISTS "proxy_logs_downstream_api_key_created_at_idx" ON "proxy_logs" ("downstream_api_key_id", "created_at")', + }, + }, { table: 'proxy_video_tasks', indexName: 'proxy_video_tasks_upstream_video_id_idx', diff --git a/src/server/index.ts b/src/server/index.ts index c4f40c0..0274891 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -33,6 +33,7 @@ import { normalizeLogCleanupRetentionDays } from './services/logCleanupService.j import { db, ensureProxyFileCompatibilityColumns, + ensureProxyLogDownstreamApiKeyIdColumn, ensureProxyLogBillingDetailsColumn, ensureRouteGroupingCompatibilityColumns, ensureSharedIndexCompatibility, @@ -265,6 +266,7 @@ try { await ensureSiteCompatibilityColumns(); await ensureRouteGroupingCompatibilityColumns(); await ensureProxyFileCompatibilityColumns(); + await ensureProxyLogDownstreamApiKeyIdColumn(); await ensureSharedIndexCompatibility(); const finalRows = await db.select().from(schema.settings).all(); const finalMap = toSettingsMap(finalRows); diff --git a/src/server/middleware/auth.proxy.test.ts b/src/server/middleware/auth.proxy.test.ts new file mode 100644 index 0000000..5a959bc --- /dev/null +++ b/src/server/middleware/auth.proxy.test.ts @@ -0,0 +1,79 @@ +import Fastify from 'fastify'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const authorizeDownstreamTokenMock = vi.fn(); +const consumeManagedKeyRequestMock = vi.fn(); + +vi.mock('../services/downstreamApiKeyService.js', () => ({ + authorizeDownstreamToken: (...args: unknown[]) => authorizeDownstreamTokenMock(...args), + consumeManagedKeyRequest: (...args: unknown[]) => consumeManagedKeyRequestMock(...args), +})); + +describe('proxyAuthMiddleware', () => { + beforeEach(() => { + authorizeDownstreamTokenMock.mockReset(); + consumeManagedKeyRequestMock.mockReset(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('rejects missing proxy credentials', async () => { + const { proxyAuthMiddleware } = await import('./auth.js'); + const app = Fastify(); + app.addHook('onRequest', proxyAuthMiddleware); + app.get('/v1/ping', async () => ({ ok: true })); + + const res = await app.inject({ method: 'GET', url: '/v1/ping' }); + expect(res.statusCode).toBe(401); + expect(res.json()).toMatchObject({ error: expect.stringContaining('Missing Authorization') }); + await app.close(); + }); + + it('stores managed key context and consumes request usage', async () => { + authorizeDownstreamTokenMock.mockResolvedValue({ + ok: true, + source: 'managed', + token: 'sk-managed-001', + key: { id: 12, name: 'project-key' }, + policy: { supportedModels: ['gpt-5.2'], allowedRouteIds: [3], siteWeightMultipliers: { 1: 1.2 } }, + }); + consumeManagedKeyRequestMock.mockResolvedValue(undefined); + + const { proxyAuthMiddleware, getProxyAuthContext, getProxyResourceOwner } = await import('./auth.js'); + const app = Fastify(); + app.addHook('onRequest', proxyAuthMiddleware); + app.get('/v1/ping', async (request) => ({ + auth: getProxyAuthContext(request), + owner: getProxyResourceOwner(request), + })); + + const res = await app.inject({ + method: 'GET', + url: '/v1/ping', + headers: { Authorization: 'Bearer sk-managed-001' }, + }); + + expect(res.statusCode).toBe(200); + expect(authorizeDownstreamTokenMock).toHaveBeenCalledWith('sk-managed-001'); + expect(consumeManagedKeyRequestMock).toHaveBeenCalledWith(12); + expect(res.json()).toMatchObject({ + auth: { + source: 'managed', + keyId: 12, + keyName: 'project-key', + policy: { + supportedModels: ['gpt-5.2'], + allowedRouteIds: [3], + siteWeightMultipliers: { 1: 1.2 }, + }, + }, + owner: { + ownerType: 'managed_key', + ownerId: '12', + }, + }); + await app.close(); + }); +}); diff --git a/src/server/routes/api/accounts.credentialMode.test.ts b/src/server/routes/api/accounts.credentialMode.test.ts index 46702eb..d8eb354 100644 --- a/src/server/routes/api/accounts.credentialMode.test.ts +++ b/src/server/routes/api/accounts.credentialMode.test.ts @@ -19,7 +19,7 @@ vi.mock('../../services/platforms/index.js', () => ({ type DbModule = typeof import('../../db/index.js'); -describe('accounts credential mode', () => { +describe('accounts credential mode', { timeout: 15_000 }, () => { let app: FastifyInstance; let db: DbModule['db']; let schema: DbModule['schema']; diff --git a/src/server/routes/api/accounts.rebind-session.test.ts b/src/server/routes/api/accounts.rebind-session.test.ts index da558d4..f754eef 100644 --- a/src/server/routes/api/accounts.rebind-session.test.ts +++ b/src/server/routes/api/accounts.rebind-session.test.ts @@ -15,7 +15,7 @@ vi.mock('../../services/platforms/index.js', () => ({ type DbModule = typeof import('../../db/index.js'); -describe('accounts rebind-session api', () => { +describe('accounts rebind-session api', { timeout: 15_000 }, () => { let app: FastifyInstance; let db: DbModule['db']; let schema: DbModule['schema']; diff --git a/src/server/routes/api/downstreamApiKeys.test.ts b/src/server/routes/api/downstreamApiKeys.test.ts new file mode 100644 index 0000000..a06610d --- /dev/null +++ b/src/server/routes/api/downstreamApiKeys.test.ts @@ -0,0 +1,496 @@ +import Fastify, { type FastifyInstance } from 'fastify'; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { eq } from 'drizzle-orm'; + +type DbModule = typeof import('../../db/index.js'); + +describe('downstream api keys routes', () => { + let app: FastifyInstance; + let db: DbModule['db']; + let schema: DbModule['schema']; + let dataDir = ''; + + beforeAll(async () => { + dataDir = mkdtempSync(join(tmpdir(), 'metapi-downstream-routes-')); + process.env.DATA_DIR = dataDir; + + await import('../../db/migrate.js'); + const dbModule = await import('../../db/index.js'); + const routesModule = await import('./downstreamApiKeys.js'); + db = dbModule.db; + schema = dbModule.schema; + + app = Fastify(); + await app.register(routesModule.downstreamApiKeysRoutes); + }); + + beforeEach(async () => { + await db.delete(schema.proxyLogs).run(); + await db.delete(schema.downstreamApiKeys).run(); + await db.delete(schema.tokenRoutes).run(); + await db.delete(schema.sites).run(); + }); + + afterAll(async () => { + await app.close(); + delete process.env.DATA_DIR; + }); + + it('creates, updates, resets and deletes downstream api keys', async () => { + const site = await db.insert(schema.sites).values({ + name: 'portal-site', + url: 'https://portal.example.com', + status: 'active', + platform: 'openai', + }).returning().get(); + const route = await db.insert(schema.tokenRoutes).values({ + modelPattern: 'gpt-5.2', + displayName: 'portal-route', + enabled: true, + }).returning().get(); + + const createRes = await app.inject({ + method: 'POST', + url: '/api/downstream-keys', + payload: { + name: 'portal-key', + key: 'sk-portal-key-001', + description: 'portal consumer', + groupName: '项目A', + tags: ['移动端', 'VIP'], + enabled: true, + maxCost: 12.5, + maxRequests: 500, + supportedModels: ['gpt-5.2', 'claude-sonnet-4-5'], + allowedRouteIds: [route.id], + siteWeightMultipliers: { [site.id]: 1.2 }, + }, + }); + + expect(createRes.statusCode).toBe(200); + const createdBody = createRes.json(); + expect(createdBody.success).toBe(true); + expect(createdBody.item).toMatchObject({ + name: 'portal-key', + keyMasked: expect.any(String), + groupName: '项目A', + tags: ['移动端', 'VIP'], + maxCost: 12.5, + maxRequests: 500, + supportedModels: ['gpt-5.2', 'claude-sonnet-4-5'], + allowedRouteIds: [route.id], + }); + + const keyId = createdBody.item.id as number; + + const updateRes = await app.inject({ + method: 'PUT', + url: `/api/downstream-keys/${keyId}`, + payload: { + name: 'portal-key-updated', + key: 'sk-portal-key-001', + groupName: '项目B', + tags: ['批量候选', 'VIP'], + enabled: false, + maxCost: 20, + maxRequests: 900, + }, + }); + + expect(updateRes.statusCode).toBe(200); + expect(updateRes.json()).toMatchObject({ + success: true, + item: { + id: keyId, + name: 'portal-key-updated', + groupName: '项目B', + tags: ['批量候选', 'VIP'], + enabled: false, + maxCost: 20, + maxRequests: 900, + }, + }); + + await db.update(schema.downstreamApiKeys).set({ + usedCost: 5.5, + usedRequests: 123, + }).where(eq(schema.downstreamApiKeys.id, keyId)).run(); + + const resetRes = await app.inject({ + method: 'POST', + url: `/api/downstream-keys/${keyId}/reset-usage`, + }); + + expect(resetRes.statusCode).toBe(200); + expect(resetRes.json()).toMatchObject({ success: true }); + + const resetRow = await db.select().from(schema.downstreamApiKeys) + .where(eq(schema.downstreamApiKeys.id, keyId)) + .get(); + expect(resetRow?.usedCost).toBe(0); + expect(resetRow?.usedRequests).toBe(0); + + const deleteRes = await app.inject({ + method: 'DELETE', + url: `/api/downstream-keys/${keyId}`, + }); + + expect(deleteRes.statusCode).toBe(200); + expect(deleteRes.json()).toMatchObject({ success: true }); + + const listRes = await app.inject({ + method: 'GET', + url: '/api/downstream-keys', + }); + expect(listRes.statusCode).toBe(200); + expect(listRes.json()).toMatchObject({ success: true, items: [] }); + }); + + it('supports batch enable/disable/reset/delete operations', async () => { + const inserted = await db.insert(schema.downstreamApiKeys).values([ + { + name: 'batch-a', + key: 'sk-batch-a-001', + enabled: true, + usedCost: 1.2, + usedRequests: 12, + }, + { + name: 'batch-b', + key: 'sk-batch-b-001', + enabled: false, + usedCost: 2.4, + usedRequests: 24, + }, + ]).returning().all(); + + const disableRes = await app.inject({ + method: 'POST', + url: '/api/downstream-keys/batch', + payload: { + ids: [inserted[0].id, inserted[1].id], + action: 'disable', + }, + }); + expect(disableRes.statusCode).toBe(200); + expect(disableRes.json()).toMatchObject({ + success: true, + successIds: [inserted[0].id, inserted[1].id], + failedItems: [], + }); + + const disabledRows = await db.select().from(schema.downstreamApiKeys).all(); + expect(disabledRows.every((row) => row.enabled === false)).toBe(true); + + const resetRes = await app.inject({ + method: 'POST', + url: '/api/downstream-keys/batch', + payload: { + ids: [inserted[0].id, inserted[1].id], + action: 'resetUsage', + }, + }); + expect(resetRes.statusCode).toBe(200); + const resetRows = await db.select().from(schema.downstreamApiKeys).all(); + expect(resetRows.every((row) => Number(row.usedCost) === 0 && Number(row.usedRequests) === 0)).toBe(true); + + const deleteRes = await app.inject({ + method: 'POST', + url: '/api/downstream-keys/batch', + payload: { + ids: [inserted[0].id, inserted[1].id], + action: 'delete', + }, + }); + expect(deleteRes.statusCode).toBe(200); + expect(await db.select().from(schema.downstreamApiKeys).all()).toHaveLength(0); + }); + + it('supports batch metadata update for group and tags', async () => { + const inserted = await db.insert(schema.downstreamApiKeys).values([ + { + name: 'meta-a', + key: 'sk-meta-a-001', + groupName: '旧分组', + tags: JSON.stringify(['旧标签']), + enabled: true, + }, + { + name: 'meta-b', + key: 'sk-meta-b-001', + tags: JSON.stringify(['旧标签', '公共']), + enabled: true, + }, + ]).returning().all(); + + const res = await app.inject({ + method: 'POST', + url: '/api/downstream-keys/batch', + payload: { + ids: inserted.map((item) => item.id), + action: 'updateMetadata', + groupOperation: 'set', + groupName: '新分组', + tagOperation: 'append', + tags: ['项目A', '公共'], + }, + }); + + expect(res.statusCode).toBe(200); + expect(res.json()).toMatchObject({ + success: true, + successIds: inserted.map((item) => item.id), + failedItems: [], + }); + + const rows = await db.select().from(schema.downstreamApiKeys).all(); + expect(rows).toHaveLength(2); + for (const row of rows) { + expect(row.groupName).toBe('新分组'); + } + const views = rows.map((row) => ({ + id: row.id, + groupName: row.groupName, + tags: JSON.parse(String(row.tags || '[]')), + })); + expect(views).toContainEqual(expect.objectContaining({ tags: ['旧标签', '项目A', '公共'] })); + expect(views).toContainEqual(expect.objectContaining({ tags: ['旧标签', '公共', '项目A'] })); + }); + + it('rejects duplicate key creation and invalid batch action', async () => { + const firstRes = await app.inject({ + method: 'POST', + url: '/api/downstream-keys', + payload: { + name: 'dup-a', + key: 'sk-dup-key-001', + }, + }); + expect(firstRes.statusCode).toBe(200); + + const duplicateRes = await app.inject({ + method: 'POST', + url: '/api/downstream-keys', + payload: { + name: 'dup-b', + key: 'sk-dup-key-001', + }, + }); + expect(duplicateRes.statusCode).toBe(409); + expect(duplicateRes.json()).toMatchObject({ + success: false, + message: 'API key 已存在', + }); + + const invalidBatchRes = await app.inject({ + method: 'POST', + url: '/api/downstream-keys/batch', + payload: { + ids: [999], + action: 'archive', + }, + }); + expect(invalidBatchRes.statusCode).toBe(400); + expect(invalidBatchRes.json()).toMatchObject({ + success: false, + message: 'Invalid action', + }); + }); + + it('returns summary, overview and trend aggregated from proxy logs', async () => { + const inserted = await db.insert(schema.downstreamApiKeys).values({ + name: 'analytics-key', + key: 'sk-analytics-key-001', + enabled: true, + description: 'analytics', + usedCost: 0, + usedRequests: 0, + }).returning().get(); + + const now = Date.now(); + const within24h = new Date(now - 2 * 60 * 60 * 1000).toISOString(); + const within7d = new Date(now - 2 * 24 * 60 * 60 * 1000).toISOString(); + const outside7d = new Date(now - 10 * 24 * 60 * 60 * 1000).toISOString(); + + await db.insert(schema.proxyLogs).values([ + { + downstreamApiKeyId: inserted.id, + status: 'success', + totalTokens: 1200, + estimatedCost: 0.12, + createdAt: within24h, + }, + { + downstreamApiKeyId: inserted.id, + status: 'failed', + totalTokens: 300, + estimatedCost: 0.03, + createdAt: within24h, + }, + { + downstreamApiKeyId: inserted.id, + status: 'success', + totalTokens: 600, + estimatedCost: 0.06, + createdAt: within7d, + }, + { + downstreamApiKeyId: inserted.id, + status: 'success', + totalTokens: 900, + estimatedCost: 0.09, + createdAt: outside7d, + }, + ]).run(); + + const summaryRes = await app.inject({ + method: 'GET', + url: '/api/downstream-keys/summary?range=24h&status=enabled&search=analytics', + }); + + expect(summaryRes.statusCode).toBe(200); + expect(summaryRes.json()).toMatchObject({ + success: true, + range: '24h', + status: 'enabled', + search: 'analytics', + group: '', + tags: [], + tagMatch: 'any', + items: [ + { + id: inserted.id, + name: 'analytics-key', + rangeUsage: { + totalRequests: 2, + successRequests: 1, + failedRequests: 1, + successRate: 50, + totalTokens: 1500, + totalCost: 0.15, + }, + }, + ], + }); + + const overviewRes = await app.inject({ + method: 'GET', + url: `/api/downstream-keys/${inserted.id}/overview`, + }); + + expect(overviewRes.statusCode).toBe(200); + expect(overviewRes.json()).toMatchObject({ + success: true, + item: { id: inserted.id, name: 'analytics-key' }, + usage: { + last24h: { + totalRequests: 2, + successRequests: 1, + failedRequests: 1, + totalTokens: 1500, + totalCost: 0.15, + }, + last7d: { + totalRequests: 3, + successRequests: 2, + failedRequests: 1, + totalTokens: 2100, + totalCost: 0.21, + }, + all: { + totalRequests: 4, + successRequests: 3, + failedRequests: 1, + totalTokens: 3000, + totalCost: 0.3, + }, + }, + }); + + const trendRes = await app.inject({ + method: 'GET', + url: `/api/downstream-keys/${inserted.id}/trend?range=all`, + }); + + expect(trendRes.statusCode).toBe(200); + const trendBody = trendRes.json(); + expect(trendBody.success).toBe(true); + expect(trendBody.range).toBe('all'); + expect(trendBody.item).toMatchObject({ id: inserted.id, name: 'analytics-key' }); + expect(Array.isArray(trendBody.buckets)).toBe(true); + expect(trendBody.buckets.length).toBeGreaterThanOrEqual(3); + expect(trendBody.buckets.some((bucket: any) => bucket.totalTokens === 1500)).toBe(true); + expect(trendBody.buckets.some((bucket: any) => bucket.totalTokens === 600)).toBe(true); + expect(trendBody.buckets.some((bucket: any) => bucket.totalTokens === 900)).toBe(true); + }); + + it('rejects unknown route ids and site ids in downstream policy payloads', async () => { + const createRes = await app.inject({ + method: 'POST', + url: '/api/downstream-keys', + payload: { + name: 'invalid-policy', + key: 'sk-invalid-policy-001', + allowedRouteIds: [999], + }, + }); + expect(createRes.statusCode).toBe(400); + expect(createRes.json()).toMatchObject({ + success: false, + message: 'allowedRouteIds 包含不存在的路由: 999', + }); + + const site = await db.insert(schema.sites).values({ + name: 'site-a', + url: 'https://example.com', + status: 'active', + platform: 'openai', + }).returning().get(); + const route = await db.insert(schema.tokenRoutes).values({ + modelPattern: 'gpt-5.2', + enabled: true, + }).returning().get(); + const created = await app.inject({ + method: 'POST', + url: '/api/downstream-keys', + payload: { + name: 'valid-policy', + key: 'sk-valid-policy-001', + allowedRouteIds: [route.id], + siteWeightMultipliers: { [site.id]: 1.2 }, + }, + }); + expect(created.statusCode).toBe(200); + const keyId = created.json().item.id as number; + + const updateRes = await app.inject({ + method: 'PUT', + url: `/api/downstream-keys/${keyId}`, + payload: { + siteWeightMultipliers: { 999: 1.5 }, + }, + }); + expect(updateRes.statusCode).toBe(400); + expect(updateRes.json()).toMatchObject({ + success: false, + message: 'siteWeightMultipliers 包含不存在的站点: 999', + }); + + const filteredSummaryRes = await app.inject({ + method: 'GET', + url: '/api/downstream-keys/summary?range=all&group=__ungrouped__&tags=foo,bar&tagMatch=all', + }); + expect(filteredSummaryRes.statusCode).toBe(200); + expect(filteredSummaryRes.json()).toMatchObject({ + success: true, + range: 'all', + group: '__ungrouped__', + tags: ['foo', 'bar'], + tagMatch: 'all', + items: [], + }); + }); +}); diff --git a/src/server/routes/api/downstreamApiKeys.ts b/src/server/routes/api/downstreamApiKeys.ts index 987c489..36ad249 100644 --- a/src/server/routes/api/downstreamApiKeys.ts +++ b/src/server/routes/api/downstreamApiKeys.ts @@ -1,6 +1,6 @@ import { FastifyInstance } from 'fastify'; -import { eq } from 'drizzle-orm'; -import { db, schema } from '../../db/index.js'; +import { and, eq, inArray, sql, type SQL } from 'drizzle-orm'; +import { db, hasProxyLogDownstreamApiKeyIdColumn, runtimeDbDialect, schema } from '../../db/index.js'; import { getDownstreamApiKeyById, listDownstreamApiKeys, @@ -8,6 +8,7 @@ import { toDownstreamApiKeyPolicyView, toPersistenceJson, } from '../../services/downstreamApiKeyService.js'; +import { formatUtcSqlDateTime } from '../../services/localTimeService.js'; function parseRouteId(raw: string): number | null { const id = Number.parseInt(raw, 10); @@ -24,7 +25,371 @@ function looksLikeUniqueViolation(error: unknown): boolean { return message.includes('UNIQUE constraint failed') && message.includes('downstream_api_keys.key'); } +function normalizeBatchIds(raw: unknown): number[] { + const values = Array.isArray(raw) ? raw : []; + const ids: number[] = []; + for (const item of values) { + const parsed = Number(item); + if (!Number.isFinite(parsed)) continue; + const id = Math.trunc(parsed); + if (id <= 0 || ids.includes(id)) continue; + ids.push(id); + if (ids.length >= 500) break; + } + return ids; +} + +type DownstreamKeyRange = '24h' | '7d' | 'all'; +type DownstreamKeyStatus = 'all' | 'enabled' | 'disabled'; + +function normalizeDownstreamKeyRange(raw: unknown): DownstreamKeyRange { + const value = typeof raw === 'string' ? raw.trim().toLowerCase() : ''; + if (value === '24h') return '24h'; + if (value === '7d') return '7d'; + if (value === 'all') return 'all'; + return '24h'; +} + +function normalizeDownstreamKeyStatus(raw: unknown): DownstreamKeyStatus { + const value = typeof raw === 'string' ? raw.trim().toLowerCase() : ''; + if (value === 'enabled') return 'enabled'; + if (value === 'disabled') return 'disabled'; + return 'all'; +} + +function normalizeSearchQuery(raw: unknown): string { + const value = typeof raw === 'string' ? raw.trim() : ''; + if (!value) return ''; + return value.slice(0, 80); +} + +function normalizeGroupQuery(raw: unknown): string { + const value = typeof raw === 'string' ? raw.trim() : ''; + return value.slice(0, 64); +} + +function normalizeTagQuery(raw: unknown): string[] { + const value = typeof raw === 'string' ? raw : Array.isArray(raw) ? raw.join(',') : ''; + return value + .split(/[\r\n,,]+/g) + .map((item) => item.trim()) + .filter(Boolean) + .map((item) => item.slice(0, 32)) + .filter((item, index, arr) => arr.findIndex((candidate) => candidate.toLowerCase() === item.toLowerCase()) === index) + .slice(0, 20); +} + +function normalizeTagMatchMode(raw: unknown): 'any' | 'all' { + const value = typeof raw === 'string' ? raw.trim().toLowerCase() : ''; + return value === 'all' ? 'all' : 'any'; +} + +function resolveRangeSinceUtc(range: DownstreamKeyRange): string | null { + const nowTs = Date.now(); + if (range === '24h') return formatUtcSqlDateTime(new Date(nowTs - 24 * 60 * 60 * 1000)); + if (range === '7d') return formatUtcSqlDateTime(new Date(nowTs - 7 * 24 * 60 * 60 * 1000)); + return null; +} + +function resolveBucketSeconds(range: DownstreamKeyRange): number { + return range === 'all' ? 86400 : 3600; +} + +function resolveBucketTsExpression(bucketSeconds: number) { + if (runtimeDbDialect === 'mysql') { + return sql`floor(unix_timestamp(${schema.proxyLogs.createdAt}) / ${bucketSeconds}) * ${bucketSeconds}`; + } + if (runtimeDbDialect === 'postgres') { + if (bucketSeconds === 86400) { + return sql`extract(epoch from date_trunc('day', ${schema.proxyLogs.createdAt}))::bigint`; + } + return sql`extract(epoch from date_trunc('hour', ${schema.proxyLogs.createdAt}))::bigint`; + } + // sqlite + return sql`cast(cast(strftime('%s', ${schema.proxyLogs.createdAt}) as integer) / ${bucketSeconds} as integer) * ${bucketSeconds}`; +} + +async function validatePolicyReferences(input: { + allowedRouteIds: number[]; + siteWeightMultipliers: Record; +}): Promise { + const routeIds = input.allowedRouteIds || []; + if (routeIds.length > 0) { + const rows = await db.select({ id: schema.tokenRoutes.id }) + .from(schema.tokenRoutes) + .where(inArray(schema.tokenRoutes.id, routeIds)) + .all(); + const existingIds = new Set(rows.map((row) => Number(row.id))); + const missingIds = routeIds.filter((id) => !existingIds.has(id)); + if (missingIds.length > 0) { + return `allowedRouteIds 包含不存在的路由: ${missingIds.join(', ')}`; + } + } + + const siteIds = Object.keys(input.siteWeightMultipliers || {}) + .map((key) => Number(key)) + .filter((value) => Number.isFinite(value) && value > 0) + .map((value) => Math.trunc(value)); + if (siteIds.length > 0) { + const rows = await db.select({ id: schema.sites.id }) + .from(schema.sites) + .where(inArray(schema.sites.id, siteIds)) + .all(); + const existingIds = new Set(rows.map((row) => Number(row.id))); + const missingIds = siteIds.filter((id) => !existingIds.has(id)); + if (missingIds.length > 0) { + return `siteWeightMultipliers 包含不存在的站点: ${missingIds.join(', ')}`; + } + } + + return null; +} + export async function downstreamApiKeysRoutes(app: FastifyInstance) { + app.get<{ Querystring: { range?: string; status?: string; search?: string; group?: string; tags?: string | string[]; tagMatch?: string } }>('/api/downstream-keys/summary', async (request) => { + const range = normalizeDownstreamKeyRange(request.query?.range); + const status = normalizeDownstreamKeyStatus(request.query?.status); + const search = normalizeSearchQuery(request.query?.search); + const group = normalizeGroupQuery(request.query?.group); + const tags = normalizeTagQuery(request.query?.tags); + const tagMatch = normalizeTagMatchMode(request.query?.tagMatch); + + const whereClauses: SQL[] = []; + if (status === 'enabled') { + whereClauses.push(eq(schema.downstreamApiKeys.enabled, true)); + } else if (status === 'disabled') { + whereClauses.push(eq(schema.downstreamApiKeys.enabled, false)); + } + if (search) { + const pattern = `%${search.toLowerCase()}%`; + whereClauses.push(sql`(lower(${schema.downstreamApiKeys.name}) like ${pattern} or lower(coalesce(${schema.downstreamApiKeys.description}, '')) like ${pattern})`); + } + + let keysQuery = db.select().from(schema.downstreamApiKeys); + if (whereClauses.length > 0) { + keysQuery = keysQuery.where(and(...whereClauses)); + } + const keys = (await keysQuery.all()) + .map((row) => toDownstreamApiKeyPolicyView(row)) + .filter((item) => { + if (group === '__ungrouped__') { + if (item.groupName) return false; + } else if (group && item.groupName !== group) { + return false; + } + + if (!search && tags.length === 0) return true; + const haystack = [ + item.name, + item.description || '', + item.keyMasked, + item.groupName || '', + ...item.tags, + ...item.supportedModels, + ].join(' ').toLowerCase(); + if (search && !haystack.includes(search.toLowerCase())) return false; + if (tags.length === 0) return true; + const itemTags = new Set(item.tags.map((tag) => tag.toLowerCase())); + return tagMatch === 'all' + ? tags.every((tag) => itemTags.has(tag.toLowerCase())) + : tags.some((tag) => itemTags.has(tag.toLowerCase())); + }) + .sort((a, b) => b.id - a.id); + + if (keys.length === 0) { + return { success: true, range, status, search, group, tags, tagMatch, items: [] }; + } + + const columnReady = await hasProxyLogDownstreamApiKeyIdColumn(); + const sinceUtc = resolveRangeSinceUtc(range); + const ids = keys.map((k) => k.id); + + const usageRows = columnReady + ? await db.select({ + keyId: schema.proxyLogs.downstreamApiKeyId, + totalRequests: sql`count(*)`, + successRequests: sql`coalesce(sum(case when ${schema.proxyLogs.status} = 'success' then 1 else 0 end), 0)`, + failedRequests: sql`coalesce(sum(case when ${schema.proxyLogs.status} = 'success' then 0 else 1 end), 0)`, + totalTokens: sql`coalesce(sum(coalesce(${schema.proxyLogs.totalTokens}, 0)), 0)`, + totalCost: sql`coalesce(sum(coalesce(${schema.proxyLogs.estimatedCost}, 0)), 0)`, + }) + .from(schema.proxyLogs) + .where(and( + inArray(schema.proxyLogs.downstreamApiKeyId, ids), + ...(sinceUtc ? [sql`${schema.proxyLogs.createdAt} >= ${sinceUtc}`] : []), + )) + .groupBy(schema.proxyLogs.downstreamApiKeyId) + .all() + : []; + + const usageByKey = new Map(); + + for (const row of usageRows) { + const keyId = Number((row as any).keyId ?? 0); + if (!Number.isFinite(keyId) || keyId <= 0) continue; + usageByKey.set(keyId, { + totalRequests: Number((row as any).totalRequests || 0), + successRequests: Number((row as any).successRequests || 0), + failedRequests: Number((row as any).failedRequests || 0), + totalTokens: Number((row as any).totalTokens || 0), + totalCost: Number((row as any).totalCost || 0), + }); + } + + return { + success: true, + range, + status, + search, + group, + tags, + tagMatch, + items: keys.map((key) => { + const usage = usageByKey.get(key.id) || { + totalRequests: 0, + successRequests: 0, + failedRequests: 0, + totalTokens: 0, + totalCost: 0, + }; + const successRate = usage.totalRequests > 0 + ? Math.round((usage.successRequests / usage.totalRequests) * 1000) / 10 + : null; + return { + ...key, + rangeUsage: { + totalRequests: usage.totalRequests, + successRequests: usage.successRequests, + failedRequests: usage.failedRequests, + successRate, + totalTokens: usage.totalTokens, + totalCost: Math.round(usage.totalCost * 1_000_000) / 1_000_000, + }, + }; + }), + }; + }); + + app.get<{ Params: { id: string } }>('/api/downstream-keys/:id/overview', async (request, reply) => { + const id = parseRouteId(request.params.id); + if (!id) { + return reply.code(400).send({ success: false, message: 'id 无效' }); + } + + const item = await getDownstreamApiKeyById(id); + if (!item) { + return reply.code(404).send({ success: false, message: 'API key 不存在' }); + } + + const columnReady = await hasProxyLogDownstreamApiKeyIdColumn(); + if (!columnReady) { + return { success: true, item, usage: { last24h: null, last7d: null, all: null } }; + } + + const readAggregate = async (range: DownstreamKeyRange) => { + const sinceUtc = resolveRangeSinceUtc(range); + const row = await db.select({ + totalRequests: sql`count(*)`, + successRequests: sql`coalesce(sum(case when ${schema.proxyLogs.status} = 'success' then 1 else 0 end), 0)`, + failedRequests: sql`coalesce(sum(case when ${schema.proxyLogs.status} = 'success' then 0 else 1 end), 0)`, + totalTokens: sql`coalesce(sum(coalesce(${schema.proxyLogs.totalTokens}, 0)), 0)`, + totalCost: sql`coalesce(sum(coalesce(${schema.proxyLogs.estimatedCost}, 0)), 0)`, + }) + .from(schema.proxyLogs) + .where(and( + eq(schema.proxyLogs.downstreamApiKeyId, id), + ...(sinceUtc ? [sql`${schema.proxyLogs.createdAt} >= ${sinceUtc}`] : []), + )) + .get(); + + const totalRequests = Number((row as any)?.totalRequests || 0); + const successRequests = Number((row as any)?.successRequests || 0); + const totalCost = Number((row as any)?.totalCost || 0); + return { + totalRequests, + successRequests, + failedRequests: Number((row as any)?.failedRequests || 0), + successRate: totalRequests > 0 ? Math.round((successRequests / totalRequests) * 1000) / 10 : null, + totalTokens: Number((row as any)?.totalTokens || 0), + totalCost: Math.round(totalCost * 1_000_000) / 1_000_000, + }; + }; + + const [last24h, last7d, all] = await Promise.all([ + readAggregate('24h'), + readAggregate('7d'), + readAggregate('all'), + ]); + + return { success: true, item, usage: { last24h, last7d, all } }; + }); + + app.get<{ Params: { id: string }; Querystring: { range?: string } }>('/api/downstream-keys/:id/trend', async (request, reply) => { + const id = parseRouteId(request.params.id); + if (!id) { + return reply.code(400).send({ success: false, message: 'id 无效' }); + } + + const range = normalizeDownstreamKeyRange(request.query?.range); + const item = await getDownstreamApiKeyById(id); + if (!item) { + return reply.code(404).send({ success: false, message: 'API key 不存在' }); + } + + const columnReady = await hasProxyLogDownstreamApiKeyIdColumn(); + if (!columnReady) { + return { success: true, range, item: { id: item.id, name: item.name }, buckets: [] }; + } + + const bucketSeconds = resolveBucketSeconds(range); + const bucketTs = resolveBucketTsExpression(bucketSeconds); + const sinceUtc = resolveRangeSinceUtc(range); + + const rows = await db.select({ + bucketTs, + totalRequests: sql`count(*)`, + successRequests: sql`coalesce(sum(case when ${schema.proxyLogs.status} = 'success' then 1 else 0 end), 0)`, + failedRequests: sql`coalesce(sum(case when ${schema.proxyLogs.status} = 'success' then 0 else 1 end), 0)`, + totalTokens: sql`coalesce(sum(coalesce(${schema.proxyLogs.totalTokens}, 0)), 0)`, + totalCost: sql`coalesce(sum(coalesce(${schema.proxyLogs.estimatedCost}, 0)), 0)`, + }) + .from(schema.proxyLogs) + .where(and( + eq(schema.proxyLogs.downstreamApiKeyId, id), + ...(sinceUtc ? [sql`${schema.proxyLogs.createdAt} >= ${sinceUtc}`] : []), + )) + .groupBy(bucketTs) + .orderBy(bucketTs) + .all(); + + return { + success: true, + range, + item: { id: item.id, name: item.name }, + bucketSeconds, + buckets: rows.map((row: any) => { + const tsSeconds = Number(row.bucketTs || 0); + const totalRequests = Number(row.totalRequests || 0); + const successRequests = Number(row.successRequests || 0); + return { + startUtc: tsSeconds > 0 ? new Date(tsSeconds * 1000).toISOString() : null, + totalRequests, + successRequests, + failedRequests: Number(row.failedRequests || 0), + successRate: totalRequests > 0 ? Math.round((successRequests / totalRequests) * 1000) / 10 : null, + totalTokens: Number(row.totalTokens || 0), + totalCost: Math.round(Number(row.totalCost || 0) * 1_000_000) / 1_000_000, + }; + }), + }; + }); + app.get('/api/downstream-keys', async () => { return { success: true, @@ -37,6 +402,8 @@ export async function downstreamApiKeysRoutes(app: FastifyInstance) { name?: unknown; key?: unknown; description?: unknown; + groupName?: unknown; + tags?: unknown; enabled?: unknown; expiresAt?: unknown; maxCost?: unknown; @@ -62,6 +429,13 @@ export async function downstreamApiKeysRoutes(app: FastifyInstance) { if (!validateKeyShape(normalized.key)) { return reply.code(400).send({ success: false, message: 'key 必须以 sk- 开头且长度至少 6' }); } + const policyRefError = await validatePolicyReferences({ + allowedRouteIds: normalized.allowedRouteIds, + siteWeightMultipliers: normalized.siteWeightMultipliers, + }); + if (policyRefError) { + return reply.code(400).send({ success: false, message: policyRefError }); + } const nowIso = new Date().toISOString(); @@ -70,6 +444,8 @@ export async function downstreamApiKeysRoutes(app: FastifyInstance) { name: normalized.name, key: normalized.key, description: normalized.description, + groupName: normalized.groupName, + tags: toPersistenceJson(normalized.tags), enabled: normalized.enabled, expiresAt: normalized.expiresAt, maxCost: normalized.maxCost, @@ -111,6 +487,8 @@ export async function downstreamApiKeysRoutes(app: FastifyInstance) { name?: unknown; key?: unknown; description?: unknown; + groupName?: unknown; + tags?: unknown; enabled?: unknown; expiresAt?: unknown; maxCost?: unknown; @@ -140,6 +518,8 @@ export async function downstreamApiKeysRoutes(app: FastifyInstance) { name: request.body?.name ?? existing.name, key: request.body?.key ?? existing.key, description: request.body?.description ?? existing.description, + groupName: request.body?.groupName ?? existing.groupName, + tags: request.body?.tags ?? existingView.tags, enabled: request.body?.enabled ?? existing.enabled, expiresAt: request.body?.expiresAt ?? existing.expiresAt, maxCost: request.body?.maxCost ?? existing.maxCost, @@ -161,6 +541,13 @@ export async function downstreamApiKeysRoutes(app: FastifyInstance) { if (!validateKeyShape(normalized.key)) { return reply.code(400).send({ success: false, message: 'key 必须以 sk- 开头且长度至少 6' }); } + const policyRefError = await validatePolicyReferences({ + allowedRouteIds: normalized.allowedRouteIds, + siteWeightMultipliers: normalized.siteWeightMultipliers, + }); + if (policyRefError) { + return reply.code(400).send({ success: false, message: policyRefError }); + } const nowIso = new Date().toISOString(); try { @@ -168,6 +555,8 @@ export async function downstreamApiKeysRoutes(app: FastifyInstance) { name: normalized.name, key: normalized.key, description: normalized.description, + groupName: normalized.groupName, + tags: toPersistenceJson(normalized.tags), enabled: normalized.enabled, expiresAt: normalized.expiresAt, maxCost: normalized.maxCost, @@ -178,7 +567,7 @@ export async function downstreamApiKeysRoutes(app: FastifyInstance) { updatedAt: nowIso, }).where(eq(schema.downstreamApiKeys.id, id)).run(); - const updated = getDownstreamApiKeyById(id); + const updated = await getDownstreamApiKeyById(id); return { success: true, item: updated, @@ -197,7 +586,7 @@ export async function downstreamApiKeysRoutes(app: FastifyInstance) { return reply.code(400).send({ success: false, message: 'id 无效' }); } - const existing = getDownstreamApiKeyById(id); + const existing = await getDownstreamApiKeyById(id); if (!existing) { return reply.code(404).send({ success: false, message: 'API key 不存在' }); } @@ -210,7 +599,7 @@ export async function downstreamApiKeysRoutes(app: FastifyInstance) { return { success: true, - item: getDownstreamApiKeyById(id), + item: await getDownstreamApiKeyById(id), }; }); @@ -220,7 +609,7 @@ export async function downstreamApiKeysRoutes(app: FastifyInstance) { return reply.code(400).send({ success: false, message: 'id 无效' }); } - const existing = getDownstreamApiKeyById(id); + const existing = await getDownstreamApiKeyById(id); if (!existing) { return reply.code(404).send({ success: false, message: 'API key 不存在' }); } @@ -231,4 +620,94 @@ export async function downstreamApiKeysRoutes(app: FastifyInstance) { return { success: true }; }); + + app.post<{ Body?: { + ids?: number[]; + action?: string; + groupOperation?: string; + groupName?: unknown; + tagOperation?: string; + tags?: unknown; + } }>('/api/downstream-keys/batch', async (request, reply) => { + const ids = normalizeBatchIds(request.body?.ids); + const action = String(request.body?.action || '').trim(); + if (ids.length === 0) { + return reply.code(400).send({ success: false, message: 'ids is required' }); + } + if (!['enable', 'disable', 'delete', 'resetUsage', 'updateMetadata'].includes(action)) { + return reply.code(400).send({ success: false, message: 'Invalid action' }); + } + + const groupOperation = String(request.body?.groupOperation || 'keep').trim(); + const tagOperation = String(request.body?.tagOperation || 'keep').trim(); + const normalizedGroupName = normalizeDownstreamApiKeyPayload({ groupName: request.body?.groupName }).groupName; + const normalizedTags = normalizeDownstreamApiKeyPayload({ tags: request.body?.tags }).tags; + + if (action === 'updateMetadata') { + if (!['keep', 'set', 'clear'].includes(groupOperation)) { + return reply.code(400).send({ success: false, message: 'Invalid groupOperation' }); + } + if (!['keep', 'append'].includes(tagOperation)) { + return reply.code(400).send({ success: false, message: 'Invalid tagOperation' }); + } + if (groupOperation === 'set' && !normalizedGroupName) { + return reply.code(400).send({ success: false, message: 'groupName is required when groupOperation=set' }); + } + if (tagOperation === 'append' && normalizedTags.length === 0) { + return reply.code(400).send({ success: false, message: 'tags is required when tagOperation=append' }); + } + } + + const successIds: number[] = []; + const failedItems: Array<{ id: number; message: string }> = []; + + for (const id of ids) { + try { + const existing = await getDownstreamApiKeyById(id); + if (!existing) { + failedItems.push({ id, message: 'API key 不存在' }); + continue; + } + + if (action === 'delete') { + await db.delete(schema.downstreamApiKeys) + .where(eq(schema.downstreamApiKeys.id, id)) + .run(); + } else if (action === 'resetUsage') { + await db.update(schema.downstreamApiKeys).set({ + usedCost: 0, + usedRequests: 0, + updatedAt: new Date().toISOString(), + }).where(eq(schema.downstreamApiKeys.id, id)).run(); + } else if (action === 'updateMetadata') { + const nextGroupName = groupOperation === 'keep' + ? existing.groupName + : (groupOperation === 'clear' ? null : normalizedGroupName); + const nextTags = tagOperation === 'append' + ? Array.from(new Map([...existing.tags, ...normalizedTags].map((tag) => [tag.toLowerCase(), tag])).values()) + : existing.tags; + await db.update(schema.downstreamApiKeys).set({ + groupName: nextGroupName, + tags: toPersistenceJson(nextTags), + updatedAt: new Date().toISOString(), + }).where(eq(schema.downstreamApiKeys.id, id)).run(); + } else { + await db.update(schema.downstreamApiKeys).set({ + enabled: action === 'enable', + updatedAt: new Date().toISOString(), + }).where(eq(schema.downstreamApiKeys.id, id)).run(); + } + + successIds.push(id); + } catch (error: any) { + failedItems.push({ id, message: error?.message || 'Batch operation failed' }); + } + } + + return { + success: true, + successIds, + failedItems, + }; + }); } diff --git a/src/server/routes/api/stats.proxy-logs.test.ts b/src/server/routes/api/stats.proxy-logs.test.ts index 3fe554a..760f1ef 100644 --- a/src/server/routes/api/stats.proxy-logs.test.ts +++ b/src/server/routes/api/stats.proxy-logs.test.ts @@ -29,6 +29,7 @@ describe('stats proxy logs routes', () => { beforeEach(async () => { await db.delete(schema.proxyLogs).run(); + await db.delete(schema.downstreamApiKeys).run(); await db.delete(schema.accounts).run(); await db.delete(schema.sites).run(); }); @@ -52,6 +53,14 @@ describe('stats proxy logs routes', () => { status: 'active', }).returning().get(); + const downstreamKey = await db.insert(schema.downstreamApiKeys).values({ + name: '项目A-Key', + key: 'sk-project-a-001', + groupName: '项目A', + tags: JSON.stringify(['VIP', '灰度']), + enabled: true, + }).returning().get(); + const timestamps = [ formatUtcSqlDateTime(new Date('2026-03-09T08:00:00.000Z')), formatUtcSqlDateTime(new Date('2026-03-09T08:01:00.000Z')), @@ -62,6 +71,7 @@ describe('stats proxy logs routes', () => { await db.insert(schema.proxyLogs).values([ { accountId: account.id, + downstreamApiKeyId: downstreamKey.id, modelRequested: 'gpt-4o', modelActual: 'gpt-4o', status: 'success', @@ -74,6 +84,7 @@ describe('stats proxy logs routes', () => { }, { accountId: account.id, + downstreamApiKeyId: downstreamKey.id, modelRequested: 'gpt-4o-mini', modelActual: 'gpt-4o-mini', status: 'failed', @@ -136,6 +147,9 @@ describe('stats proxy logs routes', () => { expect(body.items).toHaveLength(1); expect(body.items[0]?.modelRequested).toBe('gpt-4o-mini'); expect(body.items[0]?.status).toBe('failed'); + expect(body.items[0]?.downstreamKeyName).toBe('项目A-Key'); + expect(body.items[0]?.downstreamKeyGroupName).toBe('项目A'); + expect(body.items[0]?.downstreamKeyTags).toEqual(['VIP', '灰度']); expect(body.items[0]).not.toHaveProperty('billingDetails'); expect(body.summary).toEqual({ totalCount: 3, @@ -160,8 +174,17 @@ describe('stats proxy logs routes', () => { status: 'active', }).returning().get(); + const downstreamKey = await db.insert(schema.downstreamApiKeys).values({ + name: 'detail-key', + key: 'sk-detail-key-001', + groupName: '测试项目', + tags: JSON.stringify(['回归', '日志']), + enabled: true, + }).returning().get(); + const inserted = await db.insert(schema.proxyLogs).values({ accountId: account.id, + downstreamApiKeyId: downstreamKey.id, modelRequested: 'gpt-5', modelActual: 'gpt-5', status: 'success', @@ -188,18 +211,93 @@ describe('stats proxy logs routes', () => { id: number; siteName: string | null; username: string | null; + downstreamKeyName: string | null; + downstreamKeyGroupName: string | null; + downstreamKeyTags: string[]; billingDetails: Record | null; }; expect(body.id).toBe(logId); expect(body.siteName).toBe('detail-site'); expect(body.username).toBe('detail-user'); + expect(body.downstreamKeyName).toBe('detail-key'); + expect(body.downstreamKeyGroupName).toBe('测试项目'); + expect(body.downstreamKeyTags).toEqual(['回归', '日志']); expect(body.billingDetails).toMatchObject({ breakdown: { totalCost: 0.12 }, usage: { promptTokens: 100, completionTokens: 20 }, }); }); + it('supports searching proxy logs by downstream key metadata', async () => { + const site = await db.insert(schema.sites).values({ + name: 'meta-site', + url: 'https://meta.example.com', + platform: 'new-api', + }).returning().get(); + + const account = await db.insert(schema.accounts).values({ + siteId: site.id, + username: 'meta-user', + accessToken: 'meta-token', + status: 'active', + }).returning().get(); + + const alphaKey = await db.insert(schema.downstreamApiKeys).values({ + name: '渠道-A', + key: 'sk-channel-a', + groupName: '项目甲', + tags: JSON.stringify(['商务', 'VIP']), + enabled: true, + }).returning().get(); + + const betaKey = await db.insert(schema.downstreamApiKeys).values({ + name: '渠道-B', + key: 'sk-channel-b', + groupName: '项目乙', + tags: JSON.stringify(['灰度']), + enabled: true, + }).returning().get(); + + await db.insert(schema.proxyLogs).values([ + { + accountId: account.id, + downstreamApiKeyId: alphaKey.id, + modelRequested: 'gpt-4o', + modelActual: 'gpt-4o', + status: 'success', + totalTokens: 12, + estimatedCost: 0.12, + createdAt: formatUtcSqlDateTime(new Date('2026-03-09T10:00:00.000Z')), + }, + { + accountId: account.id, + downstreamApiKeyId: betaKey.id, + modelRequested: 'gpt-4.1-mini', + modelActual: 'gpt-4.1-mini', + status: 'success', + totalTokens: 22, + estimatedCost: 0.22, + createdAt: formatUtcSqlDateTime(new Date('2026-03-09T10:05:00.000Z')), + }, + ]).run(); + + const response = await app.inject({ + method: 'GET', + url: '/api/stats/proxy-logs?search=%E9%A1%B9%E7%9B%AE%E7%94%B2', + }); + + expect(response.statusCode).toBe(200); + const body = response.json() as { + total: number; + items: Array>; + }; + + expect(body.total).toBe(1); + expect(body.items[0]?.downstreamKeyName).toBe('渠道-A'); + expect(body.items[0]?.downstreamKeyGroupName).toBe('项目甲'); + }); + it('filters proxy logs by site and time range', async () => { const alphaSite = await db.insert(schema.sites).values({ name: 'alpha-site', diff --git a/src/server/routes/api/stats.ts b/src/server/routes/api/stats.ts index 3b11cb7..f09b084 100644 --- a/src/server/routes/api/stats.ts +++ b/src/server/routes/api/stats.ts @@ -121,12 +121,36 @@ function normalizeProxyLogTimeBoundary(raw?: string): string | null { return formatUtcSqlDateTime(parsed); } +function parseDownstreamKeyTags(raw: unknown): string[] { + if (typeof raw !== 'string' || !raw.trim()) return []; + try { + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) return []; + const seen = new Set(); + const result: string[] = []; + for (const value of parsed) { + const text = String(value || '').trim(); + if (!text) continue; + const dedupeKey = text.toLowerCase(); + if (seen.has(dedupeKey)) continue; + seen.add(dedupeKey); + result.push(text); + } + return result; + } catch { + return []; + } +} + function buildProxyLogSearchCondition(search: string) { if (!search) return null; const likeTerm = `%${search}%`; return sql`( lower(coalesce(${schema.proxyLogs.modelRequested}, '')) like ${likeTerm} or lower(coalesce(${schema.proxyLogs.modelActual}, '')) like ${likeTerm} + or lower(coalesce(${schema.downstreamApiKeys.name}, '')) like ${likeTerm} + or lower(coalesce(${schema.downstreamApiKeys.groupName}, '')) like ${likeTerm} + or lower(coalesce(${schema.downstreamApiKeys.tags}, '')) like ${likeTerm} )`; } @@ -316,6 +340,12 @@ function mapProxyLogRow( proxy_logs: Record & { billingDetails?: string | null }; accounts: { username?: string | null } | null; sites: { id?: number | null; name?: string | null; url?: string | null } | null; + downstream_api_keys: { + id?: number | null; + name?: string | null; + groupName?: string | null; + tags?: string | null; + } | null; }, options?: { includeBillingDetails?: boolean }, ) { @@ -328,6 +358,10 @@ function mapProxyLogRow( siteId: row.sites?.id || null, siteName: row.sites?.name || null, siteUrl: row.sites?.url || null, + downstreamKeyId: row.downstream_api_keys?.id || null, + downstreamKeyName: row.downstream_api_keys?.name || null, + downstreamKeyGroupName: row.downstream_api_keys?.groupName || null, + downstreamKeyTags: parseDownstreamKeyTags(row.downstream_api_keys?.tags), }; } @@ -530,9 +564,16 @@ export async function statsRoutes(app: FastifyInstance) { proxy_logs: fields, accounts: schema.accounts, sites: schema.sites, + downstream_api_keys: { + id: schema.downstreamApiKeys.id, + name: schema.downstreamApiKeys.name, + groupName: schema.downstreamApiKeys.groupName, + tags: schema.downstreamApiKeys.tags, + }, }).from(schema.proxyLogs) .leftJoin(schema.accounts, eq(schema.proxyLogs.accountId, schema.accounts.id)) - .leftJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id)); + .leftJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id)) + .leftJoin(schema.downstreamApiKeys, eq(schema.proxyLogs.downstreamApiKeyId, schema.downstreamApiKeys.id)); if (listWhere) { query = query.where(listWhere) as typeof query; @@ -547,13 +588,15 @@ export async function statsRoutes(app: FastifyInstance) { proxy_logs: Record & { billingDetails?: string | null }; accounts: { username?: string | null } | null; sites: { id?: number | null; name?: string | null; url?: string | null } | null; + downstream_api_keys: { id?: number | null; name?: string | null; groupName?: string | null; tags?: string | null } | null; }>; let totalQuery = db.select({ total: sql`count(*)`, }).from(schema.proxyLogs) .leftJoin(schema.accounts, eq(schema.proxyLogs.accountId, schema.accounts.id)) - .leftJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id)); + .leftJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id)) + .leftJoin(schema.downstreamApiKeys, eq(schema.proxyLogs.downstreamApiKeyId, schema.downstreamApiKeys.id)); if (listWhere) { totalQuery = totalQuery.where(listWhere) as typeof totalQuery; } @@ -567,7 +610,8 @@ export async function statsRoutes(app: FastifyInstance) { totalTokensAll: sql`coalesce(sum(coalesce(${schema.proxyLogs.totalTokens}, 0)), 0)`, }).from(schema.proxyLogs) .leftJoin(schema.accounts, eq(schema.proxyLogs.accountId, schema.accounts.id)) - .leftJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id)); + .leftJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id)) + .leftJoin(schema.downstreamApiKeys, eq(schema.proxyLogs.downstreamApiKeyId, schema.downstreamApiKeys.id)); if (summaryWhere) { summaryQuery = summaryQuery.where(summaryWhere) as typeof summaryQuery; } @@ -599,15 +643,23 @@ export async function statsRoutes(app: FastifyInstance) { proxy_logs: fields, accounts: schema.accounts, sites: schema.sites, + downstream_api_keys: { + id: schema.downstreamApiKeys.id, + name: schema.downstreamApiKeys.name, + groupName: schema.downstreamApiKeys.groupName, + tags: schema.downstreamApiKeys.tags, + }, }).from(schema.proxyLogs) .leftJoin(schema.accounts, eq(schema.proxyLogs.accountId, schema.accounts.id)) .leftJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id)) + .leftJoin(schema.downstreamApiKeys, eq(schema.proxyLogs.downstreamApiKeyId, schema.downstreamApiKeys.id)) .where(eq(schema.proxyLogs.id, id)) .get() ), { includeBillingDetails: true }) as { proxy_logs: Record & { billingDetails?: string | null }; accounts: { username?: string | null } | null; sites: { id?: number | null; name?: string | null; url?: string | null } | null; + downstream_api_keys: { id?: number | null; name?: string | null; groupName?: string | null; tags?: string | null } | null; } | undefined; if (!row) { diff --git a/src/server/routes/proxy/chat.ts b/src/server/routes/proxy/chat.ts index 556e44d..3ac16f6 100644 --- a/src/server/routes/proxy/chat.ts +++ b/src/server/routes/proxy/chat.ts @@ -1,6 +1,6 @@ import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; import { tokenRouter } from '../../services/tokenRouter.js'; -import { db, schema } from '../../db/index.js'; +import { db, hasProxyLogDownstreamApiKeyIdColumn, schema } from '../../db/index.js'; import { fetch } from 'undici'; import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js'; import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js'; @@ -26,7 +26,7 @@ import { resolveProxyLogBilling } from './proxyBilling.js'; import { openAiChatTransformer } from '../../transformers/openai/chat/index.js'; import { anthropicMessagesTransformer } from '../../transformers/anthropic/messages/index.js'; import { detectDownstreamClientContext, type DownstreamClientContext } from './downstreamClientContext.js'; -import { getProxyResourceOwner } from '../../middleware/auth.js'; +import { getProxyAuthContext, getProxyResourceOwner } from '../../middleware/auth.js'; import { ProxyInputFileResolutionError, hasNonImageFileInputInOpenAiBody, @@ -86,6 +86,9 @@ async function handleChatProxyRequest( } } const hasNonImageFileInput = hasNonImageFileInputInOpenAiBody(resolvedOpenAiBody); + const downstreamApiKeyId = getProxyAuthContext(request)?.keyId ?? null; + const logDownstreamApiKeyId = downstreamApiKeyId !== null + && await hasProxyLogDownstreamApiKeyIdColumn(); const excludeChannelIds: number[] = []; let retryCount = 0; @@ -191,15 +194,16 @@ async function handleChatProxyRequest( ctx.errText, retryCount, downstreamPath, - 0, - 0, - 0, - 0, - null, - null, - clientContext, - ); - }, + 0, + 0, + 0, + 0, + null, + null, + clientContext, + logDownstreamApiKeyId ? downstreamApiKeyId : null, + ); + }, }); if (!endpointResult.ok) { @@ -222,6 +226,7 @@ async function handleChatProxyRequest( null, null, clientContext, + logDownstreamApiKeyId ? downstreamApiKeyId : null, ); if (isTokenExpiredError({ status, message: errText })) { @@ -340,6 +345,8 @@ async function handleChatProxyRequest( estimatedCost, billingDetails, successfulUpstreamPath, + clientContext, + logDownstreamApiKeyId ? downstreamApiKeyId : null, ); return; } @@ -389,6 +396,8 @@ async function handleChatProxyRequest( estimatedCost, billingDetails, successfulUpstreamPath, + clientContext, + logDownstreamApiKeyId ? downstreamApiKeyId : null, ); return; } @@ -447,6 +456,8 @@ async function handleChatProxyRequest( estimatedCost, billingDetails, successfulUpstreamPath, + clientContext, + logDownstreamApiKeyId ? downstreamApiKeyId : null, ); return reply.send(downstreamResponse); @@ -468,6 +479,7 @@ async function handleChatProxyRequest( null, null, clientContext, + logDownstreamApiKeyId ? downstreamApiKeyId : null, ); if (retryCount < MAX_RETRIES) { @@ -506,6 +518,7 @@ async function logProxy( billingDetails: unknown = null, upstreamPath: string | null = null, clientContext: DownstreamClientContext | null = null, + downstreamApiKeyId: number | null = null, ) { try { const createdAt = formatUtcSqlDateTime(new Date()); @@ -523,6 +536,7 @@ async function logProxy( routeId: selected.channel.routeId, channelId: selected.channel.id, accountId: selected.account.id, + ...(downstreamApiKeyId !== null ? { downstreamApiKeyId } : {}), modelRequested, modelActual: selected.actualModel, status, diff --git a/src/server/routes/proxy/completions.ts b/src/server/routes/proxy/completions.ts index 17686e3..3f2873c 100644 --- a/src/server/routes/proxy/completions.ts +++ b/src/server/routes/proxy/completions.ts @@ -1,6 +1,6 @@ import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; import { fetch } from 'undici'; -import { db, schema } from '../../db/index.js'; +import { db, hasProxyLogDownstreamApiKeyIdColumn, schema } from '../../db/index.js'; import { tokenRouter } from '../../services/tokenRouter.js'; import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js'; import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js'; @@ -13,6 +13,7 @@ import { withSiteRecordProxyRequestInit } from '../../services/siteProxy.js'; import { composeProxyLogMessage } from './logPathMeta.js'; import { formatUtcSqlDateTime } from '../../services/localTimeService.js'; import { resolveProxyLogBilling } from './proxyBilling.js'; +import { getProxyAuthContext } from '../../middleware/auth.js'; import { buildUpstreamUrl } from './upstreamUrl.js'; const MAX_RETRIES = 2; @@ -26,10 +27,13 @@ export async function completionsProxyRoute(app: FastifyInstance) { } if (!await ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return; const downstreamPolicy = getDownstreamRoutingPolicy(request); + const downstreamApiKeyId = getProxyAuthContext(request)?.keyId ?? null; + const logDownstreamApiKeyId = downstreamApiKeyId !== null + && await hasProxyLogDownstreamApiKeyIdColumn(); const isStream = body.stream === true; - const excludeChannelIds: number[] = []; - let retryCount = 0; + const excludeChannelIds: number[] = []; + let retryCount = 0; while (retryCount <= MAX_RETRIES) { let selected = retryCount === 0 @@ -67,10 +71,10 @@ export async function completionsProxyRoute(app: FastifyInstance) { body: JSON.stringify(forwardBody), })); - if (!upstream.ok) { - const errText = await upstream.text().catch(() => 'unknown error'); - tokenRouter.recordFailure(selected.channel.id); - logProxy(selected, requestedModel, 'failed', upstream.status, Date.now() - startTime, errText, retryCount); + if (!upstream.ok) { + const errText = await upstream.text().catch(() => 'unknown error'); + tokenRouter.recordFailure(selected.channel.id); + logProxy(selected, requestedModel, 'failed', upstream.status, Date.now() - startTime, errText, retryCount, logDownstreamApiKeyId ? downstreamApiKeyId : null); if (isTokenExpiredError({ status: upstream.status, message: errText })) { await reportTokenExpired({ @@ -171,7 +175,7 @@ export async function completionsProxyRoute(app: FastifyInstance) { tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost); recordDownstreamCostUsage(request, estimatedCost); logProxy( - selected, requestedModel, 'success', 200, latency, null, retryCount, + selected, requestedModel, 'success', 200, latency, null, retryCount, logDownstreamApiKeyId ? downstreamApiKeyId : null, resolvedUsage.promptTokens, resolvedUsage.completionTokens, resolvedUsage.totalTokens, estimatedCost, billingDetails, ); return; @@ -206,17 +210,17 @@ export async function completionsProxyRoute(app: FastifyInstance) { tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost); recordDownstreamCostUsage(request, estimatedCost); logProxy( - selected, requestedModel, 'success', 200, latency, null, retryCount, + selected, requestedModel, 'success', 200, latency, null, retryCount, logDownstreamApiKeyId ? downstreamApiKeyId : null, resolvedUsage.promptTokens, resolvedUsage.completionTokens, resolvedUsage.totalTokens, estimatedCost, billingDetails, ); return reply.send(data); - } catch (err: any) { - tokenRouter.recordFailure(selected.channel.id); - logProxy(selected, requestedModel, 'failed', 0, Date.now() - startTime, err.message, retryCount); - if (retryCount < MAX_RETRIES) { - retryCount++; - continue; - } + } catch (err: any) { + tokenRouter.recordFailure(selected.channel.id); + logProxy(selected, requestedModel, 'failed', 0, Date.now() - startTime, err.message, retryCount, logDownstreamApiKeyId ? downstreamApiKeyId : null); + if (retryCount < MAX_RETRIES) { + retryCount++; + continue; + } await reportProxyAllFailed({ model: requestedModel, reason: err.message || 'network failure', @@ -229,14 +233,15 @@ export async function completionsProxyRoute(app: FastifyInstance) { }); } -async function logProxy( - selected: any, - modelRequested: string, - status: string, - httpStatus: number, - latencyMs: number, - errorMessage: string | null, - retryCount: number, +async function logProxy( + selected: any, + modelRequested: string, + status: string, + httpStatus: number, + latencyMs: number, + errorMessage: string | null, + retryCount: number, + downstreamApiKeyId: number | null = null, promptTokens = 0, completionTokens = 0, totalTokens = 0, @@ -253,6 +258,7 @@ async function logProxy( routeId: selected.channel.routeId, channelId: selected.channel.id, accountId: selected.account.id, + ...(downstreamApiKeyId !== null ? { downstreamApiKeyId } : {}), modelRequested, modelActual: selected.actualModel, status, diff --git a/src/server/routes/proxy/embeddings.ts b/src/server/routes/proxy/embeddings.ts index 2299512..3f52b21 100644 --- a/src/server/routes/proxy/embeddings.ts +++ b/src/server/routes/proxy/embeddings.ts @@ -1,6 +1,6 @@ import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; import { fetch } from 'undici'; -import { db, schema } from '../../db/index.js'; +import { db, hasProxyLogDownstreamApiKeyIdColumn, schema } from '../../db/index.js'; import { tokenRouter } from '../../services/tokenRouter.js'; import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js'; import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js'; @@ -13,6 +13,7 @@ import { withSiteRecordProxyRequestInit } from '../../services/siteProxy.js'; import { composeProxyLogMessage } from './logPathMeta.js'; import { formatUtcSqlDateTime } from '../../services/localTimeService.js'; import { resolveProxyLogBilling } from './proxyBilling.js'; +import { getProxyAuthContext } from '../../middleware/auth.js'; import { buildUpstreamUrl } from './upstreamUrl.js'; const MAX_RETRIES = 2; @@ -26,9 +27,12 @@ export async function embeddingsProxyRoute(app: FastifyInstance) { } if (!await ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return; const downstreamPolicy = getDownstreamRoutingPolicy(request); + const downstreamApiKeyId = getProxyAuthContext(request)?.keyId ?? null; + const logDownstreamApiKeyId = downstreamApiKeyId !== null + && await hasProxyLogDownstreamApiKeyIdColumn(); const excludeChannelIds: number[] = []; - let retryCount = 0; + let retryCount = 0; while (retryCount <= MAX_RETRIES) { let selected = retryCount === 0 @@ -64,10 +68,10 @@ export async function embeddingsProxyRoute(app: FastifyInstance) { body: JSON.stringify(forwardBody), })); - const text = await upstream.text(); - if (!upstream.ok) { - tokenRouter.recordFailure(selected.channel.id); - logProxy(selected, requestedModel, 'failed', upstream.status, Date.now() - startTime, text, retryCount); + const text = await upstream.text(); + if (!upstream.ok) { + tokenRouter.recordFailure(selected.channel.id); + logProxy(selected, requestedModel, 'failed', upstream.status, Date.now() - startTime, text, retryCount, logDownstreamApiKeyId ? downstreamApiKeyId : null); if (isTokenExpiredError({ status: upstream.status, message: text })) { await reportTokenExpired({ @@ -120,13 +124,13 @@ export async function embeddingsProxyRoute(app: FastifyInstance) { tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost); recordDownstreamCostUsage(request, estimatedCost); logProxy( - selected, requestedModel, 'success', upstream.status, latency, null, retryCount, + selected, requestedModel, 'success', upstream.status, latency, null, retryCount, logDownstreamApiKeyId ? downstreamApiKeyId : null, resolvedUsage.promptTokens, resolvedUsage.completionTokens, resolvedUsage.totalTokens, estimatedCost, billingDetails, ); return reply.code(upstream.status).send(data); - } catch (err: any) { - tokenRouter.recordFailure(selected.channel.id); - logProxy(selected, requestedModel, 'failed', 0, Date.now() - startTime, err.message, retryCount); + } catch (err: any) { + tokenRouter.recordFailure(selected.channel.id); + logProxy(selected, requestedModel, 'failed', 0, Date.now() - startTime, err.message, retryCount, logDownstreamApiKeyId ? downstreamApiKeyId : null); if (retryCount < MAX_RETRIES) { retryCount++; continue; @@ -141,14 +145,15 @@ export async function embeddingsProxyRoute(app: FastifyInstance) { }); } -async function logProxy( - selected: any, - modelRequested: string, - status: string, - httpStatus: number, - latencyMs: number, - errorMessage: string | null, - retryCount: number, +async function logProxy( + selected: any, + modelRequested: string, + status: string, + httpStatus: number, + latencyMs: number, + errorMessage: string | null, + retryCount: number, + downstreamApiKeyId: number | null = null, promptTokens = 0, completionTokens = 0, totalTokens = 0, @@ -165,6 +170,7 @@ async function logProxy( routeId: selected.channel.routeId, channelId: selected.channel.id, accountId: selected.account.id, + ...(downstreamApiKeyId !== null ? { downstreamApiKeyId } : {}), modelRequested, modelActual: selected.actualModel, status, diff --git a/src/server/routes/proxy/images.ts b/src/server/routes/proxy/images.ts index 27ca200..46e13a5 100644 --- a/src/server/routes/proxy/images.ts +++ b/src/server/routes/proxy/images.ts @@ -1,6 +1,6 @@ import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; import { fetch } from 'undici'; -import { db, schema } from '../../db/index.js'; +import { db, hasProxyLogDownstreamApiKeyIdColumn, schema } from '../../db/index.js'; import { tokenRouter } from '../../services/tokenRouter.js'; import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js'; import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js'; @@ -12,6 +12,7 @@ import { withSiteRecordProxyRequestInit } from '../../services/siteProxy.js'; import { composeProxyLogMessage } from './logPathMeta.js'; import { formatUtcSqlDateTime } from '../../services/localTimeService.js'; import { cloneFormDataWithOverrides, ensureMultipartBufferParser, parseMultipartFormData } from './multipart.js'; +import { getProxyAuthContext } from '../../middleware/auth.js'; import { buildUpstreamUrl } from './upstreamUrl.js'; const MAX_RETRIES = 2; @@ -24,6 +25,9 @@ export async function imagesProxyRoute(app: FastifyInstance) { const requestedModel = body?.model || 'gpt-image-1'; if (!await ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return; const downstreamPolicy = getDownstreamRoutingPolicy(request); + const downstreamApiKeyId = getProxyAuthContext(request)?.keyId ?? null; + const logDownstreamApiKeyId = downstreamApiKeyId !== null + && await hasProxyLogDownstreamApiKeyIdColumn(); const excludeChannelIds: number[] = []; let retryCount = 0; @@ -63,15 +67,15 @@ export async function imagesProxyRoute(app: FastifyInstance) { body: JSON.stringify(forwardBody), })); - const text = await upstream.text(); - if (!upstream.ok) { - tokenRouter.recordFailure(selected.channel.id); - logProxy(selected, requestedModel, 'failed', upstream.status, Date.now() - startTime, text, retryCount); - if (isTokenExpiredError({ status: upstream.status, message: text })) { - await reportTokenExpired({ - accountId: selected.account.id, - username: selected.account.username, - siteName: selected.site.name, + const text = await upstream.text(); + if (!upstream.ok) { + tokenRouter.recordFailure(selected.channel.id); + logProxy(selected, requestedModel, 'failed', upstream.status, Date.now() - startTime, text, retryCount, logDownstreamApiKeyId ? downstreamApiKeyId : null); + if (isTokenExpiredError({ status: upstream.status, message: text })) { + await reportTokenExpired({ + accountId: selected.account.id, + username: selected.account.username, + siteName: selected.site.name, detail: `HTTP ${upstream.status}`, }); } @@ -100,15 +104,15 @@ export async function imagesProxyRoute(app: FastifyInstance) { }); tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost); recordDownstreamCostUsage(request, estimatedCost); - logProxy(selected, requestedModel, 'success', upstream.status, latency, null, retryCount, estimatedCost); + logProxy(selected, requestedModel, 'success', upstream.status, latency, null, retryCount, logDownstreamApiKeyId ? downstreamApiKeyId : null, estimatedCost); return reply.code(upstream.status).send(data); - } catch (err: any) { - tokenRouter.recordFailure(selected.channel.id); - logProxy(selected, requestedModel, 'failed', 0, Date.now() - startTime, err.message, retryCount); - if (retryCount < MAX_RETRIES) { - retryCount++; - continue; - } + } catch (err: any) { + tokenRouter.recordFailure(selected.channel.id); + logProxy(selected, requestedModel, 'failed', 0, Date.now() - startTime, err.message, retryCount, logDownstreamApiKeyId ? downstreamApiKeyId : null); + if (retryCount < MAX_RETRIES) { + retryCount++; + continue; + } await reportProxyAllFailed({ model: requestedModel, reason: err.message || 'network failure', @@ -131,6 +135,9 @@ export async function imagesProxyRoute(app: FastifyInstance) { if (!await ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return; const downstreamPolicy = getDownstreamRoutingPolicy(request); + const downstreamApiKeyId = getProxyAuthContext(request)?.keyId ?? null; + const logDownstreamApiKeyId = downstreamApiKeyId !== null + && await hasProxyLogDownstreamApiKeyIdColumn(); const excludeChannelIds: number[] = []; let retryCount = 0; @@ -185,7 +192,7 @@ export async function imagesProxyRoute(app: FastifyInstance) { const text = await upstream.text(); if (!upstream.ok) { tokenRouter.recordFailure(selected.channel.id); - logProxy(selected, requestedModel, 'failed', upstream.status, Date.now() - startTime, text, retryCount, 0, '/v1/images/edits'); + logProxy(selected, requestedModel, 'failed', upstream.status, Date.now() - startTime, text, retryCount, logDownstreamApiKeyId ? downstreamApiKeyId : null, 0, '/v1/images/edits'); if (isTokenExpiredError({ status: upstream.status, message: text })) { await reportTokenExpired({ accountId: selected.account.id, @@ -219,11 +226,11 @@ export async function imagesProxyRoute(app: FastifyInstance) { }); tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost); recordDownstreamCostUsage(request, estimatedCost); - logProxy(selected, requestedModel, 'success', upstream.status, latency, null, retryCount, estimatedCost, '/v1/images/edits'); + logProxy(selected, requestedModel, 'success', upstream.status, latency, null, retryCount, logDownstreamApiKeyId ? downstreamApiKeyId : null, estimatedCost, '/v1/images/edits'); return reply.code(upstream.status).send(data); } catch (err: any) { tokenRouter.recordFailure(selected.channel.id); - logProxy(selected, requestedModel, 'failed', 0, Date.now() - startTime, err.message, retryCount, 0, '/v1/images/edits'); + logProxy(selected, requestedModel, 'failed', 0, Date.now() - startTime, err.message, retryCount, logDownstreamApiKeyId ? downstreamApiKeyId : null, 0, '/v1/images/edits'); if (retryCount < MAX_RETRIES) { retryCount++; continue; @@ -249,14 +256,15 @@ export async function imagesProxyRoute(app: FastifyInstance) { }); } -async function logProxy( - selected: any, - modelRequested: string, - status: string, - httpStatus: number, +async function logProxy( + selected: any, + modelRequested: string, + status: string, + httpStatus: number, latencyMs: number, errorMessage: string | null, retryCount: number, + downstreamApiKeyId: number | null = null, estimatedCost = 0, downstreamPath = '/v1/images/generations', ) { @@ -270,6 +278,7 @@ async function logProxy( routeId: selected.channel.routeId, channelId: selected.channel.id, accountId: selected.account.id, + ...(downstreamApiKeyId !== null ? { downstreamApiKeyId } : {}), modelRequested, modelActual: selected.actualModel, status, diff --git a/src/server/routes/proxy/responses.ts b/src/server/routes/proxy/responses.ts index 0cd79aa..21c7d35 100644 --- a/src/server/routes/proxy/responses.ts +++ b/src/server/routes/proxy/responses.ts @@ -1,6 +1,6 @@ import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; import { fetch } from 'undici'; -import { db, schema } from '../../db/index.js'; +import { db, hasProxyLogDownstreamApiKeyIdColumn, schema } from '../../db/index.js'; import { tokenRouter } from '../../services/tokenRouter.js'; import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js'; import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js'; @@ -19,7 +19,7 @@ import { composeProxyLogMessage } from './logPathMeta.js'; import { executeEndpointFlow, withUpstreamPath } from './endpointFlow.js'; import { formatUtcSqlDateTime } from '../../services/localTimeService.js'; import { resolveProxyLogBilling } from './proxyBilling.js'; -import { getProxyResourceOwner } from '../../middleware/auth.js'; +import { getProxyAuthContext, getProxyResourceOwner } from '../../middleware/auth.js'; import { detectDownstreamClientContext, isCodexResponsesSurface, type DownstreamClientContext } from './downstreamClientContext.js'; import { normalizeInputFileBlock } from '../../transformers/shared/inputFile.js'; import { @@ -147,6 +147,9 @@ export async function responsesProxyRoute(app: FastifyInstance) { } if (!await ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return; const downstreamPolicy = getDownstreamRoutingPolicy(request); + const downstreamApiKeyId = getProxyAuthContext(request)?.keyId ?? null; + const logDownstreamApiKeyId = downstreamApiKeyId !== null + && await hasProxyLogDownstreamApiKeyIdColumn(); const excludeChannelIds: number[] = []; let retryCount = 0; @@ -290,6 +293,7 @@ export async function responsesProxyRoute(app: FastifyInstance) { null, null, clientContext, + logDownstreamApiKeyId ? downstreamApiKeyId : null, ); }, }); @@ -314,6 +318,7 @@ export async function responsesProxyRoute(app: FastifyInstance) { null, null, clientContext, + logDownstreamApiKeyId ? downstreamApiKeyId : null, ); if (isTokenExpiredError({ status, message: errText })) { @@ -405,6 +410,8 @@ export async function responsesProxyRoute(app: FastifyInstance) { selected, requestedModel, 'success', 200, latency, null, retryCount, downstreamPath, resolvedUsage.promptTokens, resolvedUsage.completionTokens, resolvedUsage.totalTokens, estimatedCost, billingDetails, successfulUpstreamPath, + clientContext, + logDownstreamApiKeyId ? downstreamApiKeyId : null, ); return; } @@ -458,6 +465,8 @@ export async function responsesProxyRoute(app: FastifyInstance) { selected, requestedModel, 'success', 200, latency, null, retryCount, downstreamPath, resolvedUsage.promptTokens, resolvedUsage.completionTokens, resolvedUsage.totalTokens, estimatedCost, billingDetails, successfulUpstreamPath, + clientContext, + logDownstreamApiKeyId ? downstreamApiKeyId : null, ); return reply.send(downstreamData); } catch (err: any) { @@ -478,6 +487,7 @@ export async function responsesProxyRoute(app: FastifyInstance) { null, null, clientContext, + logDownstreamApiKeyId ? downstreamApiKeyId : null, ); if (retryCount < MAX_RETRIES) { retryCount += 1; @@ -516,6 +526,7 @@ async function logProxy( billingDetails: unknown = null, upstreamPath: string | null = null, clientContext: DownstreamClientContext | null = null, + downstreamApiKeyId: number | null = null, ) { try { const createdAt = formatUtcSqlDateTime(new Date()); @@ -533,6 +544,7 @@ async function logProxy( routeId: selected.channel.routeId, channelId: selected.channel.id, accountId: selected.account.id, + ...(downstreamApiKeyId !== null ? { downstreamApiKeyId } : {}), modelRequested, modelActual: selected.actualModel, status, diff --git a/src/server/routes/proxy/search.ts b/src/server/routes/proxy/search.ts index 4a3c8b0..3bb26b9 100644 --- a/src/server/routes/proxy/search.ts +++ b/src/server/routes/proxy/search.ts @@ -1,6 +1,6 @@ import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; import { fetch } from 'undici'; -import { db, schema } from '../../db/index.js'; +import { db, hasProxyLogDownstreamApiKeyIdColumn, schema } from '../../db/index.js'; import { tokenRouter } from '../../services/tokenRouter.js'; import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js'; import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js'; @@ -10,6 +10,7 @@ import { ensureModelAllowedForDownstreamKey, getDownstreamRoutingPolicy, recordD import { withSiteRecordProxyRequestInit } from '../../services/siteProxy.js'; import { composeProxyLogMessage } from './logPathMeta.js'; import { formatUtcSqlDateTime } from '../../services/localTimeService.js'; +import { getProxyAuthContext } from '../../middleware/auth.js'; import { buildUpstreamUrl } from './upstreamUrl.js'; const MAX_RETRIES = 2; @@ -52,6 +53,9 @@ export async function searchProxyRoute(app: FastifyInstance) { if (!await ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return; const downstreamPolicy = getDownstreamRoutingPolicy(request); + const downstreamApiKeyId = getProxyAuthContext(request)?.keyId ?? null; + const logDownstreamApiKeyId = downstreamApiKeyId !== null + && await hasProxyLogDownstreamApiKeyIdColumn(); const excludeChannelIds: number[] = []; let retryCount = 0; @@ -97,7 +101,7 @@ export async function searchProxyRoute(app: FastifyInstance) { const text = await upstream.text(); if (!upstream.ok) { tokenRouter.recordFailure(selected.channel.id); - logProxy(selected, requestedModel, 'failed', upstream.status, Date.now() - startTime, text, retryCount); + logProxy(selected, requestedModel, 'failed', upstream.status, Date.now() - startTime, text, retryCount, logDownstreamApiKeyId ? downstreamApiKeyId : null); if (isTokenExpiredError({ status: upstream.status, message: text })) { await reportTokenExpired({ accountId: selected.account.id, @@ -123,11 +127,11 @@ export async function searchProxyRoute(app: FastifyInstance) { const latency = Date.now() - startTime; tokenRouter.recordSuccess(selected.channel.id, latency, 0); recordDownstreamCostUsage(request, 0); - logProxy(selected, requestedModel, 'success', upstream.status, latency, null, retryCount); + logProxy(selected, requestedModel, 'success', upstream.status, latency, null, retryCount, logDownstreamApiKeyId ? downstreamApiKeyId : null); return reply.code(upstream.status).send(data); } catch (error: any) { tokenRouter.recordFailure(selected.channel.id); - logProxy(selected, requestedModel, 'failed', 0, Date.now() - startTime, error?.message || 'network error', retryCount); + logProxy(selected, requestedModel, 'failed', 0, Date.now() - startTime, error?.message || 'network error', retryCount, logDownstreamApiKeyId ? downstreamApiKeyId : null); if (retryCount < MAX_RETRIES) { retryCount += 1; continue; @@ -152,6 +156,7 @@ async function logProxy( latencyMs: number, errorMessage: string | null, retryCount: number, + downstreamApiKeyId: number | null = null, ) { try { const createdAt = formatUtcSqlDateTime(new Date()); @@ -159,6 +164,7 @@ async function logProxy( routeId: selected.channel.routeId, channelId: selected.channel.id, accountId: selected.account.id, + ...(downstreamApiKeyId !== null ? { downstreamApiKeyId } : {}), modelRequested, modelActual: selected.actualModel, status, diff --git a/src/server/services/databaseMigrationService.ts b/src/server/services/databaseMigrationService.ts index 154ed83..003b039 100644 --- a/src/server/services/databaseMigrationService.ts +++ b/src/server/services/databaseMigrationService.ts @@ -448,12 +448,13 @@ function buildStatements(snapshot: BackupSnapshot): InsertStatement[] { for (const row of snapshot.accounts.proxyLogs) { statements.push({ table: 'proxy_logs', - columns: ['id', 'route_id', 'channel_id', 'account_id', 'model_requested', 'model_actual', 'status', 'http_status', 'latency_ms', 'prompt_tokens', 'completion_tokens', 'total_tokens', 'estimated_cost', 'billing_details', 'error_message', 'retry_count', 'created_at'], + columns: ['id', 'route_id', 'channel_id', 'account_id', 'downstream_api_key_id', 'model_requested', 'model_actual', 'status', 'http_status', 'latency_ms', 'prompt_tokens', 'completion_tokens', 'total_tokens', 'estimated_cost', 'billing_details', 'error_message', 'retry_count', 'created_at'], values: [ asNumber(row.id, 0), asNumber(row.routeId, null), asNumber(row.channelId, null), asNumber(row.accountId, null), + asNumber((row as any).downstreamApiKeyId ?? (row as any).downstream_api_key_id, null), asNullableString(row.modelRequested), asNullableString(row.modelActual), asNullableString(row.status), diff --git a/src/server/services/downstreamApiKeyService.ts b/src/server/services/downstreamApiKeyService.ts index ebba7d4..7cfc24f 100644 --- a/src/server/services/downstreamApiKeyService.ts +++ b/src/server/services/downstreamApiKeyService.ts @@ -12,6 +12,8 @@ export type DownstreamApiKeyPolicyView = { key: string; keyMasked: string; description: string | null; + groupName: string | null; + tags: string[]; enabled: boolean; expiresAt: string | null; maxCost: number | null; @@ -97,6 +99,34 @@ function parseJson(value: string | null | undefined): unknown { } } +export function normalizeGroupNameInput(input: unknown): string | null { + if (typeof input !== 'string') return null; + const value = input.trim(); + if (!value) return null; + return value.slice(0, 64); +} + +export function normalizeTagsInput(input: unknown): string[] { + const rawValues = Array.isArray(input) + ? input + : (typeof input === 'string' ? input.split(/[\r\n,,]+/g) : []); + + const tags: string[] = []; + const seen = new Set(); + for (const raw of rawValues) { + const value = String(raw || '').trim(); + if (!value) continue; + const normalized = value.slice(0, 32); + const dedupeKey = normalized.toLowerCase(); + if (seen.has(dedupeKey)) continue; + seen.add(dedupeKey); + tags.push(normalized); + if (tags.length >= 20) break; + } + + return tags; +} + export function normalizeSupportedModelsInput(input: unknown): string[] { if (Array.isArray(input)) { return input @@ -226,6 +256,8 @@ export function toDownstreamApiKeyPolicyView(row: DownstreamApiKeyRow): Downstre key: row.key, keyMasked: maskSecret(row.key), description: row.description || null, + groupName: normalizeGroupNameInput(row.groupName), + tags: normalizeTagsInput(parseJson(row.tags)), enabled: !!row.enabled, expiresAt: row.expiresAt || null, maxCost: row.maxCost ?? null, @@ -385,6 +417,8 @@ export function normalizeDownstreamApiKeyPayload(input: { name?: unknown; key?: unknown; description?: unknown; + groupName?: unknown; + tags?: unknown; enabled?: unknown; expiresAt?: unknown; maxCost?: unknown; @@ -398,6 +432,8 @@ export function normalizeDownstreamApiKeyPayload(input: { const description = typeof input.description === 'string' ? input.description.trim() : ''; + const groupName = normalizeGroupNameInput(input.groupName); + const tags = normalizeTagsInput(input.tags); const enabled = input.enabled === undefined ? true : !!input.enabled; const expiresAtRaw = typeof input.expiresAt === 'string' @@ -422,6 +458,8 @@ export function normalizeDownstreamApiKeyPayload(input: { name, key, description: description || null, + groupName, + tags, enabled, expiresAt, maxCost, diff --git a/src/server/services/platforms/index.test.ts b/src/server/services/platforms/index.test.ts index 12b025b..7ca94e3 100644 --- a/src/server/services/platforms/index.test.ts +++ b/src/server/services/platforms/index.test.ts @@ -7,19 +7,34 @@ async function withHttpServer( handler: (req: IncomingMessage, res: ServerResponse) => void, run: (baseUrl: string) => Promise, ) { - const server = createServer(handler); - await new Promise((resolve) => { - server.listen(0, '127.0.0.1', () => resolve()); - }); - const { port } = server.address() as AddressInfo; - const baseUrl = `http://127.0.0.1:${port}`; - try { - await run(baseUrl); - } finally { - await new Promise((resolve, reject) => { - server.close((err?: Error) => (err ? reject(err) : resolve())); + // Avoid flakiness: CPA uses port 8317 by convention, and our platform detection + // includes a fast-path for localhost:8317. Random ephemeral ports can collide. + for (let attempt = 0; attempt < 5; attempt += 1) { + const server = createServer(handler); + await new Promise((resolve) => { + server.listen(0, '127.0.0.1', () => resolve()); }); + + const { port } = server.address() as AddressInfo; + if (port === 8317) { + await new Promise((resolve, reject) => { + server.close((err?: Error) => (err ? reject(err) : resolve())); + }); + continue; + } + + const baseUrl = `http://127.0.0.1:${port}`; + try { + await run(baseUrl); + return; + } finally { + await new Promise((resolve, reject) => { + server.close((err?: Error) => (err ? reject(err) : resolve())); + }); + } } + + throw new Error('withHttpServer: unable to allocate a test port that avoids 8317'); } describe('getAdapter platform aliases', () => { diff --git a/src/server/services/proxyLogStore.test.ts b/src/server/services/proxyLogStore.test.ts index 0f57c6c..851299d 100644 --- a/src/server/services/proxyLogStore.test.ts +++ b/src/server/services/proxyLogStore.test.ts @@ -2,12 +2,14 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; const { hasProxyLogBillingDetailsColumnMock, + hasProxyLogDownstreamApiKeyIdColumnMock, dbInsertMock, dbInsertValuesMock, dbInsertRunMock, proxyLogsSchema, } = vi.hoisted(() => ({ hasProxyLogBillingDetailsColumnMock: vi.fn(), + hasProxyLogDownstreamApiKeyIdColumnMock: vi.fn(), dbInsertMock: vi.fn(), dbInsertValuesMock: vi.fn(), dbInsertRunMock: vi.fn(), @@ -40,6 +42,7 @@ vi.mock('../db/index.js', () => ({ proxyLogs: proxyLogsSchema, }, hasProxyLogBillingDetailsColumn: (...args: unknown[]) => hasProxyLogBillingDetailsColumnMock(...args), + hasProxyLogDownstreamApiKeyIdColumn: (...args: unknown[]) => hasProxyLogDownstreamApiKeyIdColumnMock(...args), })); import { insertProxyLog, withProxyLogSelectFields } from './proxyLogStore.js'; @@ -47,9 +50,12 @@ import { insertProxyLog, withProxyLogSelectFields } from './proxyLogStore.js'; describe('proxyLogStore', () => { beforeEach(() => { hasProxyLogBillingDetailsColumnMock.mockReset(); + hasProxyLogDownstreamApiKeyIdColumnMock.mockReset(); dbInsertMock.mockReset(); dbInsertValuesMock.mockReset(); dbInsertRunMock.mockReset(); + hasProxyLogBillingDetailsColumnMock.mockResolvedValue(false); + hasProxyLogDownstreamApiKeyIdColumnMock.mockResolvedValue(false); dbInsertMock.mockReturnValue({ values: (...args: unknown[]) => dbInsertValuesMock(...args), @@ -95,4 +101,35 @@ describe('proxyLogStore', () => { }); expect(dbInsertValuesMock.mock.calls[1][0].billingDetails).toBeUndefined(); }); + + it('falls back to base values when both billing details and downstream key columns are missing', async () => { + hasProxyLogBillingDetailsColumnMock.mockResolvedValue(true); + hasProxyLogDownstreamApiKeyIdColumnMock.mockResolvedValue(true); + dbInsertRunMock + .mockRejectedValueOnce(new Error('column proxy_logs.billing_details does not exist')) + .mockRejectedValueOnce(new Error('column proxy_logs.downstream_api_key_id does not exist')) + .mockResolvedValueOnce(undefined); + + await insertProxyLog({ + modelRequested: 'gpt-5', + billingDetails: { total: 1 }, + downstreamApiKeyId: 12, + }); + + expect(dbInsertValuesMock).toHaveBeenCalledTimes(3); + expect(dbInsertValuesMock.mock.calls[0][0]).toMatchObject({ + modelRequested: 'gpt-5', + billingDetails: JSON.stringify({ total: 1 }), + downstreamApiKeyId: 12, + }); + expect(dbInsertValuesMock.mock.calls[1][0]).toMatchObject({ + modelRequested: 'gpt-5', + downstreamApiKeyId: 12, + }); + expect(dbInsertValuesMock.mock.calls[2][0]).toMatchObject({ + modelRequested: 'gpt-5', + }); + expect(dbInsertValuesMock.mock.calls[2][0].billingDetails).toBeUndefined(); + expect(dbInsertValuesMock.mock.calls[2][0].downstreamApiKeyId).toBeUndefined(); + }); }); diff --git a/src/server/services/proxyLogStore.ts b/src/server/services/proxyLogStore.ts index 1e3b4ca..b03f736 100644 --- a/src/server/services/proxyLogStore.ts +++ b/src/server/services/proxyLogStore.ts @@ -1,9 +1,10 @@ -import { db, schema, hasProxyLogBillingDetailsColumn } from '../db/index.js'; +import { db, schema, hasProxyLogBillingDetailsColumn, hasProxyLogDownstreamApiKeyIdColumn } from '../db/index.js'; export type ProxyLogInsertInput = { routeId?: number | null; channelId?: number | null; accountId?: number | null; + downstreamApiKeyId?: number | null; modelRequested?: string | null; modelActual?: string | null; status?: string | null; @@ -25,6 +26,7 @@ function buildProxyLogBaseSelectFields() { routeId: schema.proxyLogs.routeId, channelId: schema.proxyLogs.channelId, accountId: schema.proxyLogs.accountId, + downstreamApiKeyId: schema.proxyLogs.downstreamApiKeyId, modelRequested: schema.proxyLogs.modelRequested, modelActual: schema.proxyLogs.modelActual, status: schema.proxyLogs.status, @@ -108,6 +110,20 @@ export function isMissingBillingDetailsColumnError(error: unknown): boolean { ); } +export function isMissingDownstreamApiKeyIdColumnError(error: unknown): boolean { + const message = typeof error === 'object' && error && 'message' in error + ? String((error as { message?: unknown }).message || '') + : String(error || ''); + const lowered = message.toLowerCase(); + return lowered.includes('downstream_api_key_id') + && ( + lowered.includes('does not exist') + || lowered.includes('unknown column') + || lowered.includes('no such column') + || lowered.includes('has no column named') + ); +} + export async function insertProxyLog(input: ProxyLogInsertInput): Promise { const baseValues = { routeId: input.routeId ?? null, @@ -131,16 +147,43 @@ export async function insertProxyLog(input: ProxyLogInsertInput): Promise : JSON.stringify(input.billingDetails); const includeBillingDetails = serializedBillingDetails !== null && await hasProxyLogBillingDetailsColumn(); + const includeDownstreamApiKeyId = input.downstreamApiKeyId != null + && await hasProxyLogDownstreamApiKeyIdColumn(); try { await db.insert(schema.proxyLogs).values( - includeBillingDetails - ? { ...baseValues, billingDetails: serializedBillingDetails } + includeBillingDetails || includeDownstreamApiKeyId + ? { + ...baseValues, + ...(includeBillingDetails ? { billingDetails: serializedBillingDetails } : {}), + ...(includeDownstreamApiKeyId ? { downstreamApiKeyId: input.downstreamApiKeyId } : {}), + } : baseValues, ).run(); } catch (error) { if (includeBillingDetails && isMissingBillingDetailsColumnError(error)) { - await db.insert(schema.proxyLogs).values(baseValues).run(); + try { + await db.insert(schema.proxyLogs).values( + includeDownstreamApiKeyId + ? { ...baseValues, downstreamApiKeyId: input.downstreamApiKeyId } + : baseValues, + ).run(); + } catch (fallbackError) { + if (includeDownstreamApiKeyId && isMissingDownstreamApiKeyIdColumnError(fallbackError)) { + await db.insert(schema.proxyLogs).values(baseValues).run(); + return; + } + throw fallbackError; + } + return; + } + + if (includeDownstreamApiKeyId && isMissingDownstreamApiKeyIdColumnError(error)) { + await db.insert(schema.proxyLogs).values( + includeBillingDetails + ? { ...baseValues, billingDetails: serializedBillingDetails } + : baseValues, + ).run(); return; } throw error; diff --git a/src/web/App.sidebar.test.ts b/src/web/App.sidebar.test.ts index 6b41f8e..1e8ec9b 100644 --- a/src/web/App.sidebar.test.ts +++ b/src/web/App.sidebar.test.ts @@ -10,4 +10,15 @@ describe('App sidebar config', () => { expect(source).not.toContain("{ to: '/accounts', label: '账号'"); expect(source).not.toContain("{ to: '/tokens', label: '令牌管理'"); }); + + it('places downstream key navigation under 控制台 instead of 系统', () => { + const source = readFileSync(resolve(process.cwd(), 'src/web/App.tsx'), 'utf8'); + const consoleGroupIndex = source.indexOf("label: '控制台'"); + const downstreamIndex = source.indexOf("{ to: '/downstream-keys', label: '下游密钥'"); + const systemGroupIndex = source.indexOf("label: '系统'"); + + expect(consoleGroupIndex).toBeGreaterThanOrEqual(0); + expect(downstreamIndex).toBeGreaterThan(consoleGroupIndex); + expect(systemGroupIndex).toBeGreaterThan(downstreamIndex); + }); }); diff --git a/src/web/App.tsx b/src/web/App.tsx index bd941f7..af517c6 100644 --- a/src/web/App.tsx +++ b/src/web/App.tsx @@ -26,6 +26,7 @@ const CheckinLog = lazy(() => import('./pages/CheckinLog.js')); const TokenRoutes = lazy(() => import('./pages/TokenRoutes.js')); const ProxyLogs = lazy(() => import('./pages/ProxyLogs.js')); const Settings = lazy(() => import('./pages/Settings.js')); +const DownstreamKeys = lazy(() => import('./pages/DownstreamKeys.js')); const ImportExport = lazy(() => import('./pages/ImportExport.js')); const NotificationSettings = lazy(() => import('./pages/NotificationSettings.js')); const ProgramLogs = lazy(() => import('./pages/ProgramLogs.js')); @@ -327,6 +328,7 @@ const sidebarGroups = [ { to: '/', label: '仪表盘', icon: }, { to: '/sites', label: '站点', icon: }, { to: '/accounts', label: '连接管理', icon: }, + { to: '/downstream-keys', label: '下游密钥', icon: }, { to: '/checkin', label: '签到记录', icon: }, { to: '/routes', label: '路由', icon: }, { to: '/logs', label: '使用日志', icon: }, @@ -767,6 +769,7 @@ function AppShell() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/web/api.ts b/src/web/api.ts index ee1c165..70a6978 100644 --- a/src/web/api.ts +++ b/src/web/api.ts @@ -204,6 +204,10 @@ export type ProxyLogListItem = { siteName?: string | null; siteUrl?: string | null; errorMessage?: string | null; + downstreamKeyId?: number | null; + downstreamKeyName?: string | null; + downstreamKeyGroupName?: string | null; + downstreamKeyTags?: string[]; promptTokens?: number | null; completionTokens?: number | null; estimatedCost?: number | null; @@ -401,9 +405,26 @@ export const api = { deleteDownstreamApiKey: (id: number) => request(`/api/downstream-keys/${id}`, { method: 'DELETE', }), + batchDownstreamApiKeys: (data: { + ids: number[]; + action: 'enable' | 'disable' | 'delete' | 'resetUsage' | 'updateMetadata'; + groupOperation?: 'keep' | 'set' | 'clear'; + groupName?: string; + tagOperation?: 'keep' | 'append'; + tags?: string[]; + }) => + request('/api/downstream-keys/batch', { + method: 'POST', + body: JSON.stringify(data), + }), resetDownstreamApiKeyUsage: (id: number) => request(`/api/downstream-keys/${id}/reset-usage`, { method: 'POST', }), + getDownstreamApiKeysSummary: (params?: { range?: '24h' | '7d' | 'all'; status?: 'all' | 'enabled' | 'disabled'; search?: string }) => + request(`/api/downstream-keys/summary${buildQueryString(params)}`), + getDownstreamApiKeyOverview: (id: number) => request(`/api/downstream-keys/${id}/overview`), + getDownstreamApiKeyTrend: (id: number, params?: { range?: '24h' | '7d' | 'all' }) => + request(`/api/downstream-keys/${id}/trend${buildQueryString(params)}`), exportBackup: (type: 'all' | 'accounts' | 'preferences' = 'all') => request(`/api/settings/backup/export?type=${encodeURIComponent(type)}`), importBackup: (data: any) => diff --git a/src/web/components/charts/DownstreamKeyTrendChart.tsx b/src/web/components/charts/DownstreamKeyTrendChart.tsx new file mode 100644 index 0000000..fe88470 --- /dev/null +++ b/src/web/components/charts/DownstreamKeyTrendChart.tsx @@ -0,0 +1,205 @@ +import React, { useMemo, useState } from 'react'; +import { VChart } from '@visactor/react-vchart'; + +type Metric = 'tokens' | 'requests' | 'cost'; + +const METRIC_OPTIONS: Array<{ key: Metric; label: string }> = [ + { key: 'tokens', label: 'Tokens' }, + { key: 'requests', label: '请求数' }, + { key: 'cost', label: '成本' }, +]; + +export type DownstreamKeyTrendBucket = { + startUtc: string | null; + totalRequests: number; + totalTokens: number; + totalCost: number; + successRate: number | null; +}; + +export default function DownstreamKeyTrendChart({ + buckets, + loading, + height = 260, +}: { + buckets: DownstreamKeyTrendBucket[]; + loading?: boolean; + height?: number; +}) { + const [metric, setMetric] = useState('tokens'); + + const flatData = useMemo(() => { + if (!Array.isArray(buckets) || buckets.length === 0) return []; + return buckets + .map((bucket) => { + const date = bucket.startUtc || ''; + const value = metric === 'tokens' + ? Number(bucket.totalTokens || 0) + : (metric === 'requests' + ? Number(bucket.totalRequests || 0) + : Number(bucket.totalCost || 0)); + return { date, value }; + }) + .filter((row) => row.date.length > 0); + }, [buckets, metric]); + + if (loading) { + return ( +
+
+
+
+
+
+ ); + } + + if (!flatData || flatData.length === 0) { + return ( +
+
+ +
+
+
暂无趋势数据
+
该 Key 在所选时间范围内没有可用的 tokens 记录
+
+
+ ); + } + + const spec: Record = { + type: 'area' as const, + data: [{ id: 'data', values: flatData }], + xField: 'date', + yField: 'value', + area: { + style: { + curveType: 'monotone', + fillOpacity: 0.2, + }, + }, + line: { + style: { + curveType: 'monotone', + lineWidth: 2, + }, + }, + point: { visible: false }, + axes: [ + { + orient: 'bottom', + label: { style: { fontSize: 11, fill: 'var(--color-text-muted)' } }, + domainLine: { style: { stroke: 'var(--color-border-light)' } }, + tick: { style: { stroke: 'var(--color-border-light)' } }, + }, + { + orient: 'left', + label: { style: { fontSize: 11, fill: 'var(--color-text-muted)' } }, + grid: { style: { stroke: 'var(--color-border-light)', lineDash: [4, 4] } }, + domainLine: { visible: false }, + }, + ], + tooltip: { + dimension: { + title: { value: (datum: Record) => String(datum?.date || '') }, + content: [ + { + key: () => METRIC_OPTIONS.find((opt) => opt.key === metric)?.label || 'Value', + value: (datum: Record) => { + const value = Number(datum?.value ?? 0); + if (metric === 'cost') return `$${value.toFixed(6)}`; + return value.toLocaleString(); + }, + }, + ], + }, + }, + color: ['var(--color-primary)'], + background: 'transparent', + animationAppear: { + area: { type: 'fadeIn', duration: 500, easing: 'cubicOut' }, + line: { type: 'clipIn', duration: 700, easing: 'cubicOut' }, + }, + padding: { left: 8, right: 16, top: 8, bottom: 8 }, + }; + + return ( +
+
+ +
+
+ +
+
+ ); +} + +function MetricToggle({ + metric, + onChange, +}: { + metric: Metric; + onChange: (m: Metric) => void; +}) { + return ( +
+ {METRIC_OPTIONS.map((opt) => ( + + ))} +
+ ); +} + +const containerStyle: React.CSSProperties = { + background: 'var(--color-bg-card)', + borderRadius: 'var(--radius-md)', + border: '1px solid var(--color-border-light)', + boxShadow: 'var(--shadow-card)', + padding: 16, +}; + +const headerStyle: React.CSSProperties = { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + marginBottom: 10, +}; + +const toggleGroupStyle: React.CSSProperties = { + display: 'inline-flex', + gap: 0, + borderRadius: 'var(--radius-sm)', + border: '1px solid var(--color-border)', + overflow: 'hidden', +}; + +const toggleBtnBase: React.CSSProperties = { + padding: '6px 12px', + fontSize: 12, + fontWeight: 500, + cursor: 'pointer', + border: 'none', + transition: 'all 0.2s ease', + fontFamily: 'inherit', +}; + +const toggleBtnActive: React.CSSProperties = { + background: 'var(--color-primary)', + color: '#ffffff', +}; + +const toggleBtnInactive: React.CSSProperties = { + background: 'var(--color-bg-card)', + color: 'var(--color-text-secondary)', +}; diff --git a/src/web/pages/DownstreamKeys.test.tsx b/src/web/pages/DownstreamKeys.test.tsx new file mode 100644 index 0000000..af8153b --- /dev/null +++ b/src/web/pages/DownstreamKeys.test.tsx @@ -0,0 +1,446 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, create, type ReactTestInstance } from 'react-test-renderer'; +import { MemoryRouter } from 'react-router-dom'; +import { ToastProvider } from '../components/Toast.js'; +import DownstreamKeys from './DownstreamKeys.js'; + +const { apiMock } = vi.hoisted(() => ({ + apiMock: { + getDownstreamApiKeysSummary: vi.fn(), + getDownstreamApiKeys: vi.fn(), + getRoutesLite: vi.fn(), + getDownstreamApiKeyOverview: vi.fn(), + getDownstreamApiKeyTrend: vi.fn(), + createDownstreamApiKey: vi.fn(), + batchDownstreamApiKeys: vi.fn(), + updateDownstreamApiKey: vi.fn(), + deleteDownstreamApiKey: vi.fn(), + resetDownstreamApiKeyUsage: vi.fn(), + }, +})); + +vi.mock('../api.js', () => ({ api: apiMock })); + +vi.mock('react-dom', async () => { + const actual = await vi.importActual('react-dom'); + return { + ...actual, + createPortal: (node: unknown) => node, + }; +}); + +vi.mock('../components/useAnimatedVisibility.js', () => ({ + useAnimatedVisibility: (open: boolean) => ({ + shouldRender: open, + isVisible: open, + }), +})); + +vi.mock('../components/charts/DownstreamKeyTrendChart.js', () => ({ + default: ({ buckets }: { buckets: Array<{ totalTokens: number }> }) => ( +
{`trend:${buckets.length}`}
+ ), +})); + +vi.mock('../components/ModernSelect.js', () => ({ + default: ({ value, onChange, options }: { value: string; onChange: (value: string) => void; options: Array<{ value: string; label: string }> }) => ( + + ), +})); + +function collectText(node: ReactTestInstance): string { + return (node.children || []).map((child) => { + if (typeof child === 'string') return child; + return collectText(child); + }).join(''); +} + +async function flushMicrotasks() { + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); +} + +function buildSummaryItem(overrides?: Partial) { + return { + id: 1, + name: 'smoke-key', + keyMasked: 'sk-s****0315', + enabled: true, + description: 'local smoke', + groupName: '项目A', + tags: ['移动端', 'VIP'], + expiresAt: null, + maxCost: null, + usedCost: 0, + maxRequests: null, + usedRequests: 0, + supportedModels: ['gpt-4.1-mini'], + allowedRouteIds: [11], + siteWeightMultipliers: {}, + lastUsedAt: '2026-03-15T08:27:25.378Z', + createdAt: '2026-03-15T08:27:25.378Z', + updatedAt: '2026-03-15T08:27:25.378Z', + rangeUsage: { + totalRequests: 3, + successRequests: 2, + failedRequests: 1, + successRate: 66.7, + totalTokens: 4200, + totalCost: 0.42, + }, + ...overrides, + }; +} + +function buildRawItem(overrides?: Partial) { + return { + id: 1, + name: 'smoke-key', + key: 'sk-smoke-0315', + keyMasked: 'sk-s****0315', + description: 'local smoke', + groupName: '项目A', + tags: ['移动端', 'VIP'], + enabled: true, + expiresAt: null, + maxCost: null, + usedCost: 0, + maxRequests: null, + usedRequests: 0, + supportedModels: ['gpt-4.1-mini'], + allowedRouteIds: [11], + siteWeightMultipliers: {}, + lastUsedAt: '2026-03-15T08:27:25.378Z', + ...overrides, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + (globalThis as any).document = { + body: { style: {} }, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }; + apiMock.getDownstreamApiKeysSummary.mockResolvedValue({ success: true, items: [buildSummaryItem()] }); + apiMock.getDownstreamApiKeys.mockResolvedValue({ success: true, items: [buildRawItem()] }); + apiMock.getRoutesLite.mockResolvedValue([ + { id: 11, modelPattern: 'team/default', displayName: '默认群组', enabled: true }, + { id: 12, modelPattern: 'gpt-4.1-mini', displayName: 'GPT 4.1 Mini', enabled: true }, + ]); + apiMock.getDownstreamApiKeyOverview.mockResolvedValue({ + success: true, + item: buildSummaryItem(), + usage: { + last24h: { totalRequests: 3, successRequests: 2, failedRequests: 1, successRate: 66.7, totalTokens: 4200, totalCost: 0.42 }, + last7d: { totalRequests: 9, successRequests: 8, failedRequests: 1, successRate: 88.9, totalTokens: 12400, totalCost: 1.24 }, + all: { totalRequests: 20, successRequests: 18, failedRequests: 2, successRate: 90, totalTokens: 55200, totalCost: 5.52 }, + }, + }); + apiMock.getDownstreamApiKeyTrend.mockResolvedValue({ + success: true, + buckets: [ + { startUtc: '2026-03-15T08:00:00.000Z', totalRequests: 2, totalTokens: 1200, totalCost: 0.12, successRate: 100 }, + { startUtc: '2026-03-15T09:00:00.000Z', totalRequests: 1, totalTokens: 3000, totalCost: 0.3, successRate: 0 }, + ], + }); + apiMock.createDownstreamApiKey.mockResolvedValue({ success: true }); + apiMock.batchDownstreamApiKeys.mockResolvedValue({ success: true, successIds: [1], failedItems: [] }); + apiMock.updateDownstreamApiKey.mockResolvedValue({ success: true }); + apiMock.deleteDownstreamApiKey.mockResolvedValue({ success: true }); + apiMock.resetDownstreamApiKeyUsage.mockResolvedValue({ success: true }); +}); + +afterEach(() => { + vi.clearAllMocks(); + delete (globalThis as any).document; +}); + +describe('DownstreamKeys page', () => { + it('loads management data and renders merged row content', async () => { + let root: ReturnType | null = null; + try { + await act(async () => { + root = create( + + + + + , + ); + }); + await flushMicrotasks(); + + expect(apiMock.getDownstreamApiKeysSummary).toHaveBeenCalledWith({ range: '24h' }); + expect(apiMock.getDownstreamApiKeys).toHaveBeenCalled(); + expect(apiMock.getRoutesLite).toHaveBeenCalled(); + + const text = collectText(root!.root); + expect(text).toContain('下游密钥'); + expect(text).toContain('smoke-key'); + expect(text).toContain('sk-s****0315'); + expect(text).toContain('默认群组'); + expect(text).toContain('4.2K'); + expect(text).toContain('主分组'); + expect(text).toContain('移动端'); + } finally { + root?.unmount(); + } + }); + + it('filters rows locally by search and status', async () => { + apiMock.getDownstreamApiKeysSummary.mockResolvedValue({ + success: true, + items: [ + buildSummaryItem(), + buildSummaryItem({ id: 2, name: 'batch-key', enabled: false, description: 'other project', keyMasked: 'sk-b****0315' }), + ], + }); + apiMock.getDownstreamApiKeys.mockResolvedValue({ + success: true, + items: [ + buildRawItem(), + buildRawItem({ id: 2, name: 'batch-key', enabled: false, description: 'other project', key: 'sk-batch-0315', keyMasked: 'sk-b****0315', supportedModels: ['gpt-4o-mini'], allowedRouteIds: [] }), + ], + }); + + let root: ReturnType | null = null; + try { + await act(async () => { + root = create( + + + + + , + ); + }); + await flushMicrotasks(); + + const input = root!.root.findAllByType('input').find((node) => node.props.placeholder === '搜索名称、备注、模型、主分组或标签'); + await act(async () => { + input!.props.onChange({ target: { value: 'batch' } }); + }); + await flushMicrotasks(); + expect(collectText(root!.root)).toContain('batch-key'); + expect(collectText(root!.root)).not.toContain('smoke-key'); + + const select = root!.root.findAllByType('select').find((node) => collectText(node).includes('仅禁用')); + await act(async () => { + select.props.onChange({ target: { value: 'disabled' } }); + }); + await flushMicrotasks(); + expect(collectText(root!.root)).toContain('batch-key'); + expect(collectText(root!.root)).not.toContain('smoke-key'); + } finally { + root?.unmount(); + } + }); + + it('supports create flow and drawer trend loading', async () => { + let root: ReturnType | null = null; + try { + await act(async () => { + root = create( + + + + + , + ); + }); + await flushMicrotasks(); + + const createBtn = root!.root.findAll((node) => node.type === 'button' && collectText(node).includes('新增下游密钥'))[0]; + await act(async () => { + createBtn.props.onClick(); + }); + await flushMicrotasks(); + + const inputs = root!.root.findAllByType('input'); + const nameInput = inputs.find((node) => node.props.placeholder === '例如:项目 A / 移动端'); + const keyInput = inputs.find((node) => node.props.placeholder === 'sk-...'); + await act(async () => { + nameInput!.props.onChange({ target: { value: 'new-key' } }); + keyInput!.props.onChange({ target: { value: 'sk-new-key-0315' } }); + }); + await flushMicrotasks(); + + const saveBtn = root!.root.findAll((node) => node.type === 'button' && collectText(node).includes('创建密钥'))[0]; + await act(async () => { + saveBtn.props.onClick(); + }); + await flushMicrotasks(); + + expect(apiMock.createDownstreamApiKey).toHaveBeenCalledWith(expect.objectContaining({ + name: 'new-key', + key: 'sk-new-key-0315', + siteWeightMultipliers: {}, + })); + + const row = root!.root.findAll((node) => node.type === 'tr' && typeof node.props.onClick === 'function')[0]; + await act(async () => { + row.props.onClick(); + }); + await flushMicrotasks(); + + expect(apiMock.getDownstreamApiKeyOverview).toHaveBeenCalledWith(1); + expect(apiMock.getDownstreamApiKeyTrend).toHaveBeenCalledWith(1, { range: '24h' }); + expect(collectText(root!.root)).toContain('trend:2'); + + const range7d = root!.root.findAll((node) => node.type === 'button' && collectText(node) === '7d')[0]; + await act(async () => { + range7d.props.onClick(); + }); + await flushMicrotasks(); + + expect(apiMock.getDownstreamApiKeyTrend).toHaveBeenCalledWith(1, { range: '7d' }); + expect(collectText(root!.root)).toContain('12.4K'); + expect(collectText(root!.root)).toContain('9'); + expect(collectText(root!.root)).toContain('88.9%'); + } finally { + root?.unmount(); + } + }); + + it('uses backend batch api for selected rows', async () => { + let root: ReturnType | null = null; + try { + await act(async () => { + root = create( + + + + + , + ); + }); + await flushMicrotasks(); + + const checkbox = root!.root.findAllByType('input').find((node) => node.props.type === 'checkbox' && typeof node.props.onChange === 'function' && node.props.checked === false); + await act(async () => { + checkbox!.props.onChange({ target: { checked: true } }); + }); + await flushMicrotasks(); + + const batchButton = root!.root.findAll((node) => node.type === 'button' && collectText(node).includes('批量启用'))[0]; + await act(async () => { + batchButton.props.onClick(); + }); + await flushMicrotasks(); + + expect(apiMock.batchDownstreamApiKeys).toHaveBeenCalledWith({ + ids: [1], + action: 'enable', + }); + } finally { + root?.unmount(); + } + }); + + it('supports group and tag editing plus batch metadata update', async () => { + let root: ReturnType | null = null; + try { + await act(async () => { + root = create( + + + + + , + ); + }); + await flushMicrotasks(); + + const createBtn = root!.root.findAll((node) => node.type === 'button' && collectText(node).includes('新增下游密钥'))[0]; + await act(async () => { + createBtn.props.onClick(); + }); + await flushMicrotasks(); + + const inputs = root!.root.findAllByType('input'); + const nameInput = inputs.find((node) => node.props.placeholder === '例如:项目 A / 移动端'); + const keyInput = inputs.find((node) => node.props.placeholder === 'sk-...'); + const groupInput = inputs.find((node) => node.props.placeholder === '例如:VIP / 内部项目 / A组'); + const tagInput = inputs.find((node) => node.props.placeholder === '输入标签后按回车或逗号,例如:移动端、VIP、项目A'); + await act(async () => { + nameInput!.props.onChange({ target: { value: 'grouped-key' } }); + keyInput!.props.onChange({ target: { value: 'sk-grouped-key-0315' } }); + groupInput!.props.onChange({ target: { value: '项目B' } }); + tagInput!.props.onChange({ target: { value: '灰度,高优' } }); + }); + await flushMicrotasks(); + await act(async () => { + tagInput!.props.onKeyDown({ key: 'Enter', preventDefault: vi.fn() }); + }); + await flushMicrotasks(); + + const saveBtn = root!.root.findAll((node) => node.type === 'button' && collectText(node).includes('创建密钥'))[0]; + await act(async () => { + saveBtn.props.onClick(); + }); + await flushMicrotasks(); + + expect(apiMock.createDownstreamApiKey).toHaveBeenCalledWith(expect.objectContaining({ + groupName: '项目B', + tags: ['灰度', '高优'], + })); + + const checkbox = root!.root.findAllByType('input').find((node) => node.props.type === 'checkbox' && typeof node.props.onChange === 'function' && node.props.checked === false); + await act(async () => { + checkbox!.props.onChange({ target: { checked: true } }); + }); + await flushMicrotasks(); + + const batchMetadataBtn = root!.root.findAll((node) => node.type === 'button' && collectText(node).includes('批量归类/标签'))[0]; + await act(async () => { + batchMetadataBtn.props.onClick(); + }); + await flushMicrotasks(); + + const groupModeSelect = root!.root.findAllByType('select').find((node) => Array.isArray(node.props.children) && collectText(node).includes('统一设为主分组')); + const tagModeSelect = root!.root.findAllByType('select').find((node) => Array.isArray(node.props.children) && collectText(node).includes('追加标签')); + await act(async () => { + groupModeSelect!.props.onChange({ target: { value: 'set' } }); + tagModeSelect!.props.onChange({ target: { value: 'append' } }); + }); + await flushMicrotasks(); + + const batchGroupInput = root!.root.findAllByType('input').find((node) => node.props.placeholder === '例如:VIP / 内部项目'); + const batchTagInput = root!.root.findAllByType('input').find((node) => node.props.placeholder === '批量追加标签'); + await act(async () => { + batchGroupInput!.props.onChange({ target: { value: '统一项目组' } }); + batchTagInput!.props.onChange({ target: { value: '批量标签' } }); + }); + await flushMicrotasks(); + await act(async () => { + batchTagInput!.props.onKeyDown({ key: 'Enter', preventDefault: vi.fn() }); + }); + await flushMicrotasks(); + + const applyBtn = root!.root.findAll((node) => node.type === 'button' && collectText(node).includes('应用到所选密钥'))[0]; + await act(async () => { + applyBtn.props.onClick(); + }); + await flushMicrotasks(); + + expect(apiMock.batchDownstreamApiKeys).toHaveBeenCalledWith(expect.objectContaining({ + ids: [1], + action: 'updateMetadata', + groupOperation: 'set', + groupName: '统一项目组', + tagOperation: 'append', + tags: ['批量标签'], + })); + } finally { + root?.unmount(); + } + }); +}); diff --git a/src/web/pages/DownstreamKeys.tsx b/src/web/pages/DownstreamKeys.tsx new file mode 100644 index 0000000..13cd2a0 --- /dev/null +++ b/src/web/pages/DownstreamKeys.tsx @@ -0,0 +1,1745 @@ +import React, { Suspense, lazy, useDeferredValue, useEffect, useMemo, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { api } from '../api.js'; +import CenteredModal from '../components/CenteredModal.js'; +import DeleteConfirmModal from '../components/DeleteConfirmModal.js'; +import { useToast } from '../components/Toast.js'; +import ModernSelect from '../components/ModernSelect.js'; +import { useAnimatedVisibility } from '../components/useAnimatedVisibility.js'; +import { tr } from '../i18n.js'; + +const DownstreamKeyTrendChart = lazy(() => import('../components/charts/DownstreamKeyTrendChart.js')); +type DownstreamKeyTrendBucket = import('../components/charts/DownstreamKeyTrendChart.js').DownstreamKeyTrendBucket; + +type Range = '24h' | '7d' | 'all'; +type Status = 'all' | 'enabled' | 'disabled'; + +type SummaryItem = { + id: number; + name: string; + keyMasked: string; + enabled: boolean; + description: string | null; + groupName: string | null; + tags: string[]; + expiresAt: string | null; + maxCost: number | null; + usedCost: number; + maxRequests: number | null; + usedRequests: number; + supportedModels: string[]; + allowedRouteIds: number[]; + siteWeightMultipliers: Record; + lastUsedAt: string | null; + createdAt: string | null; + updatedAt: string | null; + rangeUsage: { + totalRequests: number; + successRequests: number; + failedRequests: number; + successRate: number | null; + totalTokens: number; + totalCost: number; + }; +}; + +type AggregateUsage = { + totalRequests: number; + successRequests: number; + failedRequests: number; + successRate: number | null; + totalTokens: number; + totalCost: number; +}; + +type OverviewResponse = { + success: boolean; + item: SummaryItem; + usage: null | { + last24h: AggregateUsage | null; + last7d: AggregateUsage | null; + all: AggregateUsage | null; + }; +}; + +type DownstreamApiKeyItem = { + id: number; + name: string; + key: string; + keyMasked: string; + description: string | null; + groupName: string | null; + tags: string[]; + enabled: boolean; + expiresAt: string | null; + maxCost: number | null; + usedCost: number; + maxRequests: number | null; + usedRequests: number; + supportedModels: string[]; + allowedRouteIds: number[]; + siteWeightMultipliers: Record; + lastUsedAt: string | null; +}; + +type ManagedItem = SummaryItem & { + key?: string; +}; + +type RouteSelectorItem = { + id: number; + modelPattern: string; + displayName?: string | null; + enabled: boolean; +}; + +type EditorForm = { + name: string; + key: string; + description: string; + groupName: string; + tags: string[]; + maxCost: string; + maxRequests: string; + expiresAt: string; + enabled: boolean; + selectedModels: string[]; + selectedGroupRouteIds: number[]; + siteWeightMultipliersText: string; +}; + +type DeleteConfirmState = + | null + | { mode: 'single'; item: ManagedItem } + | { mode: 'batch'; ids: number[] }; + +type TagMatchMode = 'any' | 'all'; + +type BatchMetadataForm = { + groupOperation: 'keep' | 'set' | 'clear'; + groupName: string; + tagOperation: 'keep' | 'append'; + tags: string[]; +}; + +function formatIso(value: string | null | undefined): string { + const text = (value || '').trim(); + if (!text) return '--'; + const date = new Date(text); + if (Number.isNaN(date.getTime())) return text; + const pad = (n: number) => String(n).padStart(2, '0'); + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`; +} + +function formatMoney(value: number): string { + if (!Number.isFinite(value)) return '$0'; + if (value >= 1) return `$${value.toFixed(3)}`; + return `$${value.toFixed(6)}`; +} + +function formatCompactTokens(value: number): string { + if (!Number.isFinite(value)) return '0'; + if (value >= 1_000_000_000) return `${(value / 1_000_000_000).toFixed(2)}B`; + if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(2)}M`; + if (value >= 1_000) return `${(value / 1_000).toFixed(1)}K`; + return String(Math.trunc(value)); +} + +function toDateTimeLocal(isoString: string | null | undefined): string { + if (!isoString) return ''; + const ts = Date.parse(isoString); + if (!Number.isFinite(ts)) return ''; + const date = new Date(ts); + const yyyy = date.getFullYear(); + const mm = String(date.getMonth() + 1).padStart(2, '0'); + const dd = String(date.getDate()).padStart(2, '0'); + const hh = String(date.getHours()).padStart(2, '0'); + const mi = String(date.getMinutes()).padStart(2, '0'); + return `${yyyy}-${mm}-${dd}T${hh}:${mi}`; +} + +function isExactModelPattern(modelPattern: string): boolean { + const normalized = modelPattern.trim(); + if (!normalized) return false; + if (normalized.toLowerCase().startsWith('re:')) return false; + return !/[\*\?\[]/.test(normalized); +} + +function routeTitle(route: RouteSelectorItem): string { + const displayName = (route.displayName || '').trim(); + return displayName || route.modelPattern; +} + +function uniqStrings(values: string[]): string[] { + return [...new Set(values.map((value) => String(value || '').trim()).filter(Boolean))]; +} + +function normalizeTags(values: string[]): string[] { + const result: string[] = []; + const seen = new Set(); + for (const raw of values) { + const value = String(raw || '').trim(); + if (!value) continue; + const normalized = value.slice(0, 32); + const dedupeKey = normalized.toLowerCase(); + if (seen.has(dedupeKey)) continue; + seen.add(dedupeKey); + result.push(normalized); + if (result.length >= 20) break; + } + return result; +} + +function uniqIds(values: number[]): number[] { + return [...new Set(values.map((value) => Number(value)).filter((value) => Number.isFinite(value) && value > 0).map((value) => Math.trunc(value)))]; +} + +function parseTagText(value: string): string[] { + return normalizeTags(value.split(/[\r\n,,]+/g)); +} + +function parseInlineRegex(value: string): RegExp | null { + const text = value.trim(); + if (!text.startsWith('/') || text.length < 2) return null; + const lastSlash = text.lastIndexOf('/'); + if (lastSlash <= 0) return null; + const pattern = text.slice(1, lastSlash); + const flags = text.slice(lastSlash + 1); + if (!pattern) return null; + if (!/^[dgimsuvy]*$/i.test(flags)) return null; + try { + return new RegExp(pattern, flags || 'i'); + } catch { + return null; + } +} + +function buildSearchMatcher(search: string): ((haystack: string) => boolean) | null { + const text = search.trim(); + if (!text) return null; + const regex = parseInlineRegex(text); + if (regex) { + return (haystack: string) => regex.test(haystack); + } + const normalized = text.toLowerCase(); + return (haystack: string) => haystack.toLowerCase().includes(normalized); +} + +function splitSearchInput(value: string): { textSearch: string; inlineTags: string[] } { + const raw = value.trim(); + if (!raw) return { textSearch: '', inlineTags: [] }; + if (parseInlineRegex(raw)) return { textSearch: raw, inlineTags: [] }; + + const parts = raw.split(/[\r\n,,]+/g).map((item) => item.trim()).filter(Boolean); + if (parts.length <= 1) return { textSearch: raw, inlineTags: [] }; + + return { + textSearch: '', + inlineTags: normalizeTags(parts), + }; +} + +function tagChipStyle(kind: 'normal' | 'accent' = 'normal'): React.CSSProperties { + return { + display: 'inline-flex', + alignItems: 'center', + gap: 6, + padding: '2px 8px', + borderRadius: 999, + fontSize: 11, + border: '1px solid var(--color-border-light)', + color: kind === 'accent' ? 'var(--color-primary)' : 'var(--color-text-secondary)', + background: kind === 'accent' + ? 'color-mix(in srgb, var(--color-primary) 10%, transparent)' + : 'var(--color-bg-card)', + }; +} + +function buildEditorForm(item?: ManagedItem | DownstreamApiKeyItem | null): EditorForm { + return { + name: item?.name || '', + key: item?.key || '', + description: item?.description || '', + groupName: item?.groupName || '', + tags: normalizeTags(Array.isArray(item?.tags) ? item!.tags : []), + maxCost: item?.maxCost === null || item?.maxCost === undefined ? '' : String(item.maxCost), + maxRequests: item?.maxRequests === null || item?.maxRequests === undefined ? '' : String(item.maxRequests), + expiresAt: toDateTimeLocal(item?.expiresAt), + enabled: item?.enabled ?? true, + selectedModels: uniqStrings(Array.isArray(item?.supportedModels) ? item!.supportedModels : []), + selectedGroupRouteIds: uniqIds(Array.isArray(item?.allowedRouteIds) ? item!.allowedRouteIds : []), + siteWeightMultipliersText: JSON.stringify(item?.siteWeightMultipliers || {}, null, 2), + }; +} + +function summarizeModelLimit(models: string[]): string { + if (!Array.isArray(models) || models.length === 0) return '全部模型'; + if (models.length === 1) return models[0]; + return `${models[0]} +${models.length - 1}`; +} + +function summarizeRouteLimit(routeIds: number[], routeMap: Map): string { + if (!Array.isArray(routeIds) || routeIds.length === 0) return '全部群组'; + const names = routeIds + .map((id) => routeMap.get(id)) + .filter(Boolean) + .map((item) => routeTitle(item!)); + if (names.length === 0) return `${routeIds.length} 个群组`; + if (names.length === 1) return names[0]; + return `${names[0]} +${names.length - 1}`; +} + +function summarizeSiteWeightMultipliers(weights: Record | undefined): string { + const entries = Object.entries(weights || {}); + if (entries.length === 0) return '默认倍率'; + if (entries.length === 1) return `${entries[0][0]} => ${entries[0][1]}`; + return `${entries[0][0]} => ${entries[0][1]} +${entries.length - 1}`; +} + +function summarizeTags(tags: string[]): string { + if (!Array.isArray(tags) || tags.length === 0) return '无标签'; + if (tags.length === 1) return tags[0]; + return `${tags[0]} +${tags.length - 1}`; +} + +function TagChips({ + tags, + accent = false, + maxVisible = 3, +}: { + tags: string[]; + accent?: boolean; + maxVisible?: number; +}) { + if (!Array.isArray(tags) || tags.length === 0) { + return 无标签; + } + + const visible = tags.slice(0, maxVisible); + const hidden = tags.length - visible.length; + return ( +
+ {visible.map((tag) => ( + + {tag} + + ))} + {hidden > 0 ? {`+${hidden}`} : null} +
+ ); +} + +function TagInput({ + tags, + onChange, + suggestions = [], + placeholder, +}: { + tags: string[]; + onChange: (tags: string[]) => void; + suggestions?: string[]; + placeholder?: string; +}) { + const [draft, setDraft] = useState(''); + + useEffect(() => { + setDraft(''); + }, [tags.length]); + + const commitDraft = () => { + const nextTags = normalizeTags([...tags, ...parseTagText(draft)]); + if (nextTags.length !== tags.length) { + onChange(nextTags); + } + setDraft(''); + }; + + const removeTag = (target: string) => { + onChange(tags.filter((tag) => tag !== target)); + }; + + const suggestionPool = suggestions.filter((tag) => !tags.some((current) => current.toLowerCase() === tag.toLowerCase())).slice(0, 12); + + return ( +
+
+
+ {tags.map((tag) => ( + + ))} +
+ setDraft(e.target.value)} + onBlur={commitDraft} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ',') { + e.preventDefault(); + commitDraft(); + } else if (e.key === 'Backspace' && !draft && tags.length > 0) { + e.preventDefault(); + onChange(tags.slice(0, -1)); + } + }} + placeholder={placeholder || '输入标签后按回车或逗号'} + style={{ width: '100%', border: 'none', outline: 'none', background: 'transparent', color: 'var(--color-text-primary)', padding: 0 }} + /> +
+ {suggestionPool.length > 0 ? ( +
+ {suggestionPool.map((tag) => ( + + ))} +
+ ) : null} +
+ ); +} + +function SummaryMetric({ + label, + value, +}: { + label: string; + value: string; +}) { + return ( +
+ {label} + {value} +
+ ); +} + +function resolveOverviewUsageByRange( + overview: OverviewResponse | null, + range: Range, +): AggregateUsage | null { + if (!overview?.usage) return null; + if (range === '24h') return overview.usage.last24h; + if (range === '7d') return overview.usage.last7d; + return overview.usage.all; +} + +function TrendChartFallback({ height = 260 }: { height?: number }) { + return ( +
+
+
+
+ ); +} + +function InlineToggle({ + value, + onChange, +}: { + value: TagMatchMode; + onChange: (value: TagMatchMode) => void; +}) { + const options: Array<{ value: TagMatchMode; label: string }> = [ + { value: 'any', label: '匹配任一标签' }, + { value: 'all', label: '匹配全部标签' }, + ]; + + const base: React.CSSProperties = { + padding: '6px 12px', + fontSize: 12, + fontWeight: 600, + border: '1px solid var(--color-border)', + background: 'var(--color-bg-card)', + color: 'var(--color-text-muted)', + cursor: 'pointer', + }; + + const active: React.CSSProperties = { + background: 'var(--color-primary)', + color: '#fff', + borderColor: 'var(--color-primary)', + }; + + return ( +
+ {options.map((option, index) => ( + + ))} +
+ ); +} + +function RangeToggle({ range, onChange }: { range: Range; onChange: (r: Range) => void }) { + const base: React.CSSProperties = { + padding: '6px 12px', + fontSize: 12, + fontWeight: 600, + border: '1px solid var(--color-border)', + background: 'var(--color-bg-card)', + color: 'var(--color-text-muted)', + cursor: 'pointer', + }; + + const active: React.CSSProperties = { + background: 'var(--color-primary)', + color: '#fff', + borderColor: 'var(--color-primary)', + }; + + return ( +
+ + + +
+ ); +} + +function StatusBadge({ enabled }: { enabled: boolean }) { + return ( + + + {enabled ? '启用' : '禁用'} + + ); +} + +function Drawer({ + open, + onClose, + item, + initialRange, +}: { + open: boolean; + onClose: () => void; + item: SummaryItem | null; + initialRange: Range; +}) { + const toast = useToast(); + const presence = useAnimatedVisibility(open, 220); + const [overview, setOverview] = useState(null); + const [overviewLoading, setOverviewLoading] = useState(false); + const [trendRange, setTrendRange] = useState(initialRange); + const [trendLoading, setTrendLoading] = useState(false); + const [buckets, setBuckets] = useState([]); + + useEffect(() => { + if (!open) return; + setTrendRange(initialRange); + }, [open, initialRange]); + + useEffect(() => { + if (!open || !item?.id) return; + let cancelled = false; + setOverviewLoading(true); + api.getDownstreamApiKeyOverview(item.id) + .then((res: any) => { + if (cancelled) return; + setOverview(res as OverviewResponse); + }) + .catch((err: any) => { + if (cancelled) return; + toast.error(err?.message || '加载 Key 概览失败'); + }) + .finally(() => { + if (cancelled) return; + setOverviewLoading(false); + }); + return () => { + cancelled = true; + }; + }, [open, item?.id, toast]); + + useEffect(() => { + if (!open || !item?.id) return; + let cancelled = false; + setTrendLoading(true); + api.getDownstreamApiKeyTrend(item.id, { range: trendRange }) + .then((res: any) => { + if (cancelled) return; + setBuckets(Array.isArray(res?.buckets) ? res.buckets : []); + }) + .catch((err: any) => { + if (cancelled) return; + toast.error(err?.message || '加载趋势失败'); + }) + .finally(() => { + if (cancelled) return; + setTrendLoading(false); + }); + return () => { + cancelled = true; + }; + }, [open, item?.id, trendRange, toast]); + + if (!presence.shouldRender) return null; + + const currentRangeUsage = resolveOverviewUsageByRange(overview, trendRange) || item?.rangeUsage || null; + + const panel = ( +
+
e.stopPropagation()} + style={{ + width: 'min(92vw, 560px)', + maxWidth: 560, + height: '100vh', + maxHeight: '100vh', + borderRadius: 0, + animation: presence.isVisible ? 'drawer-slide-in 0.3s cubic-bezier(0.22, 1, 0.36, 1) both' : 'drawer-slide-out 0.22s cubic-bezier(0.4, 0, 1, 1) both', + }} + > +
+
+
+ {item?.name || '--'} + +
+
+ {item?.keyMasked || '****'} +
+
+ + {item?.groupName ? `主分组 · ${item.groupName}` : '未分组'} + + +
+
+ +
+ +
+
+
+ 趋势 +
+ +
+ + }> + + + +
+ +
+
+ 概览 +
+ {overviewLoading ? ( +
+ ) : ( +
+
+
最近使用
+
{formatIso(item?.lastUsedAt)}
+
+
+
累计请求
+
{(item?.usedRequests || 0).toLocaleString()}
+
+
+
累计成本
+
{formatMoney(Number(item?.usedCost || 0))}
+
+
+
到期时间
+
{formatIso(item?.expiresAt)}
+
+
+
主分组
+
{item?.groupName || '未分组'}
+
+
+
标签
+ +
+
+ )} +
+ +
+ +
+
+ 当前范围汇总 +
+
+
+
Tokens
+
{formatCompactTokens(currentRangeUsage?.totalTokens || 0)}
+
+
+
请求数
+
{(currentRangeUsage?.totalRequests || 0).toLocaleString()}
+
+
+
成功率
+
{currentRangeUsage?.successRate == null ? '--' : `${currentRangeUsage.successRate}%`}
+
+
+
成本
+
{formatMoney(Number(currentRangeUsage?.totalCost || 0))}
+
+
+
+ + {overview?.usage ? ( + <> +
+
+
+ 固定窗口对比 +
+
+ {[ + { label: '24h', data: overview.usage.last24h }, + { label: '7d', data: overview.usage.last7d }, + { label: '全部', data: overview.usage.all }, + ].map((section) => ( +
+
{section.label}
+
Tokens
+
{formatCompactTokens(section.data?.totalTokens || 0)}
+
请求数
+
{(section.data?.totalRequests || 0).toLocaleString()}
+
成功率
+
{section.data?.successRate == null ? '--' : `${section.data.successRate}%`}
+
+ ))} +
+
+ + ) : null} +
+
+
+ ); + + return createPortal(panel, document.body); +} + +function EditorModal({ + open, + editingItem, + form, + onChange, + onClose, + onSave, + saving, + routeOptions, + groupSuggestions, + tagSuggestions, +}: { + open: boolean; + editingItem: ManagedItem | null; + form: EditorForm; + onChange: (updater: (prev: EditorForm) => EditorForm) => void; + onClose: () => void; + onSave: () => void; + saving: boolean; + routeOptions: RouteSelectorItem[]; + groupSuggestions: string[]; + tagSuggestions: string[]; +}) { + const [modelSearch, setModelSearch] = useState(''); + const [groupSearch, setGroupSearch] = useState(''); + + useEffect(() => { + if (!open) { + setModelSearch(''); + setGroupSearch(''); + } + }, [open]); + + const exactModels = useMemo( + () => uniqStrings(routeOptions.filter((item) => isExactModelPattern(item.modelPattern)).map((item) => item.modelPattern)).sort((a, b) => a.localeCompare(b)), + [routeOptions], + ); + + const filteredModels = useMemo(() => { + const keyword = modelSearch.trim().toLowerCase(); + if (!keyword) return exactModels; + return exactModels.filter((model) => model.toLowerCase().includes(keyword)); + }, [exactModels, modelSearch]); + + const filteredGroups = useMemo(() => { + const keyword = groupSearch.trim().toLowerCase(); + if (!keyword) return routeOptions; + return routeOptions.filter((route) => { + const title = routeTitle(route).toLowerCase(); + return title.includes(keyword) || route.modelPattern.toLowerCase().includes(keyword); + }); + }, [groupSearch, routeOptions]); + + const selectedModelCount = form.selectedModels.length; + const selectedGroupCount = form.selectedGroupRouteIds.length; + const inputStyle: React.CSSProperties = { + width: '100%', + padding: '10px 12px', + border: '1px solid var(--color-border)', + borderRadius: 'var(--radius-sm)', + background: 'var(--color-bg)', + color: 'var(--color-text-primary)', + }; + + return ( + + + + + )} + > +
+ 支持为每个下游密钥独立配置主分组、标签、过期时间、额度上限、模型白名单与群组范围。留空表示不限制。 +
+ +
+
+
名称
+ onChange((prev) => ({ ...prev, name: e.target.value }))} placeholder="例如:项目 A / 移动端" style={inputStyle} /> +
+
+
下游密钥
+ onChange((prev) => ({ ...prev, key: e.target.value }))} placeholder="sk-..." style={{ ...inputStyle, fontFamily: 'var(--font-mono)' }} /> +
+
+
主分组
+ onChange((prev) => ({ ...prev, groupName: e.target.value }))} + placeholder="例如:VIP / 内部项目 / A组" + list="downstream-group-suggestions" + style={inputStyle} + /> +
+
+
请求额度
+ onChange((prev) => ({ ...prev, maxRequests: e.target.value }))} placeholder="留空表示不限" style={inputStyle} /> +
+
+
成本额度
+ onChange((prev) => ({ ...prev, maxCost: e.target.value }))} placeholder="留空表示不限" style={inputStyle} /> +
+
+
过期时间
+ onChange((prev) => ({ ...prev, expiresAt: e.target.value }))} style={inputStyle} /> +
+ +
+ +
+
备注说明
+