feat: 下游 API 密钥管理 (#103)

* 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 <DeliciousBuding@users.noreply.github.com>
This commit is contained in:
Delicious
2026-03-15 22:00:02 +08:00
committed by GitHub
parent 251348f784
commit a64a51aa0c
44 changed files with 4162 additions and 183 deletions
+3 -2
View File
@@ -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`);
+2 -1
View File
@@ -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;
@@ -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");
+2 -1
View File
@@ -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;
@@ -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",
+90
View File
@@ -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<boolean> {
}
}
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<boolean> {
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<boolean> {
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) {
+4
View File
@@ -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),
]);
+2
View File
@@ -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));
+6 -1
View File
@@ -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' },
];
+4
View File
@@ -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<string>
enabled: integer('enabled', { mode: 'boolean' }).default(true),
expiresAt: text('expires_at'),
maxCost: real('max_cost'),
@@ -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")',
],
},
@@ -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',
+2
View File
@@ -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);
+79
View File
@@ -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();
});
});
@@ -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'];
@@ -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'];
@@ -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: [],
});
});
});
+485 -6
View File
@@ -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<number>`floor(unix_timestamp(${schema.proxyLogs.createdAt}) / ${bucketSeconds}) * ${bucketSeconds}`;
}
if (runtimeDbDialect === 'postgres') {
if (bucketSeconds === 86400) {
return sql<number>`extract(epoch from date_trunc('day', ${schema.proxyLogs.createdAt}))::bigint`;
}
return sql<number>`extract(epoch from date_trunc('hour', ${schema.proxyLogs.createdAt}))::bigint`;
}
// sqlite
return sql<number>`cast(cast(strftime('%s', ${schema.proxyLogs.createdAt}) as integer) / ${bucketSeconds} as integer) * ${bucketSeconds}`;
}
async function validatePolicyReferences(input: {
allowedRouteIds: number[];
siteWeightMultipliers: Record<number, number>;
}): Promise<string | null> {
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<number>`count(*)`,
successRequests: sql<number>`coalesce(sum(case when ${schema.proxyLogs.status} = 'success' then 1 else 0 end), 0)`,
failedRequests: sql<number>`coalesce(sum(case when ${schema.proxyLogs.status} = 'success' then 0 else 1 end), 0)`,
totalTokens: sql<number>`coalesce(sum(coalesce(${schema.proxyLogs.totalTokens}, 0)), 0)`,
totalCost: sql<number>`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<number, {
totalRequests: number;
successRequests: number;
failedRequests: number;
totalTokens: number;
totalCost: number;
}>();
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<number>`count(*)`,
successRequests: sql<number>`coalesce(sum(case when ${schema.proxyLogs.status} = 'success' then 1 else 0 end), 0)`,
failedRequests: sql<number>`coalesce(sum(case when ${schema.proxyLogs.status} = 'success' then 0 else 1 end), 0)`,
totalTokens: sql<number>`coalesce(sum(coalesce(${schema.proxyLogs.totalTokens}, 0)), 0)`,
totalCost: sql<number>`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<number>`count(*)`,
successRequests: sql<number>`coalesce(sum(case when ${schema.proxyLogs.status} = 'success' then 1 else 0 end), 0)`,
failedRequests: sql<number>`coalesce(sum(case when ${schema.proxyLogs.status} = 'success' then 0 else 1 end), 0)`,
totalTokens: sql<number>`coalesce(sum(coalesce(${schema.proxyLogs.totalTokens}, 0)), 0)`,
totalCost: sql<number>`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,
};
});
}
@@ -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<string, unknown> | 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<Record<string, unknown>>;
};
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',
+55 -3
View File
@@ -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<string>();
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<boolean>`(
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<string, unknown> & { 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<string, unknown> & { 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<number>`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<number>`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<string, unknown> & { 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) {
+25 -11
View File
@@ -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,
+30 -24
View File
@@ -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,
+24 -18
View File
@@ -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,
+35 -26
View File
@@ -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,
+14 -2
View File
@@ -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,
+10 -4
View File
@@ -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,
@@ -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),
@@ -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<string>();
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,
+26 -11
View File
@@ -7,19 +7,34 @@ async function withHttpServer(
handler: (req: IncomingMessage, res: ServerResponse) => void,
run: (baseUrl: string) => Promise<void>,
) {
const server = createServer(handler);
await new Promise<void>((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<void>((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<void>((resolve) => {
server.listen(0, '127.0.0.1', () => resolve());
});
const { port } = server.address() as AddressInfo;
if (port === 8317) {
await new Promise<void>((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<void>((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', () => {
+37
View File
@@ -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();
});
});
+47 -4
View File
@@ -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<void> {
const baseValues = {
routeId: input.routeId ?? null,
@@ -131,16 +147,43 @@ export async function insertProxyLog(input: ProxyLogInsertInput): Promise<void>
: 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;
+11
View File
@@ -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);
});
});
+3
View File
@@ -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: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M4 5a1 1 0 011-1h4a1 1 0 011 1v5a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM14 5a1 1 0 011-1h4a1 1 0 011 1v2a1 1 0 01-1 1h-4a1 1 0 01-1-1V5zM4 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1v-4zM14 12a1 1 0 011-1h4a1 1 0 011 1v7a1 1 0 01-1 1h-4a1 1 0 01-1-1v-7z" /></svg> },
{ to: '/sites', label: '站点', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" /></svg> },
{ to: '/accounts', label: '连接管理', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" /></svg> },
{ to: '/downstream-keys', label: '下游密钥', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M15 7a4 4 0 11-8 0 4 4 0 018 0z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M7 21a6 6 0 0110.8-3.6M15.5 18.5l2-2m0 0l2 2m-2-2V21" /></svg> },
{ to: '/checkin', label: '签到记录', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg> },
{ to: '/routes', label: '路由', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" /></svg> },
{ to: '/logs', label: '使用日志', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" /></svg> },
@@ -767,6 +769,7 @@ function AppShell() {
<Route path="/logs" element={<ProxyLogs />} />
<Route path="/monitor" element={<Monitors />} />
<Route path="/settings" element={<Settings />} />
<Route path="/downstream-keys" element={<DownstreamKeys />} />
<Route path="/events" element={<ProgramLogs />} />
<Route path="/settings/import-export" element={<ImportExport />} />
<Route path="/settings/notify" element={<NotificationSettings />} />
+21
View File
@@ -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) =>
@@ -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<Metric>('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 (
<div style={containerStyle}>
<div style={headerStyle}>
<div className="skeleton" style={{ width: 160, height: 30, borderRadius: 'var(--radius-sm)' }} />
</div>
<div className="skeleton" style={{ width: '100%', height, borderRadius: 'var(--radius-sm)' }} />
</div>
);
}
if (!flatData || flatData.length === 0) {
return (
<div style={containerStyle}>
<div style={headerStyle}>
<MetricToggle metric={metric} onChange={setMetric} />
</div>
<div className="empty-state" style={{ padding: 32 }}>
<div className="empty-state-title">暂无趋势数据</div>
<div className="empty-state-desc">该 Key 在所选时间范围内没有可用的 tokens 记录</div>
</div>
</div>
);
}
const spec: Record<string, unknown> = {
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, unknown>) => String(datum?.date || '') },
content: [
{
key: () => METRIC_OPTIONS.find((opt) => opt.key === metric)?.label || 'Value',
value: (datum: Record<string, unknown>) => {
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 (
<div style={containerStyle}>
<div style={headerStyle}>
<MetricToggle metric={metric} onChange={setMetric} />
</div>
<div style={{ width: '100%', height }}>
<VChart spec={spec as any} style={{ width: '100%', height: '100%' }} />
</div>
</div>
);
}
function MetricToggle({
metric,
onChange,
}: {
metric: Metric;
onChange: (m: Metric) => void;
}) {
return (
<div style={toggleGroupStyle}>
{METRIC_OPTIONS.map((opt) => (
<button
key={opt.key}
onClick={() => onChange(opt.key)}
style={{
...toggleBtnBase,
...(metric === opt.key ? toggleBtnActive : toggleBtnInactive),
}}
>
{opt.label}
</button>
))}
</div>
);
}
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)',
};
+446
View File
@@ -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<typeof import('react-dom')>('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 }> }) => (
<div data-testid="downstream-trend-chart">{`trend:${buckets.length}`}</div>
),
}));
vi.mock('../components/ModernSelect.js', () => ({
default: ({ value, onChange, options }: { value: string; onChange: (value: string) => void; options: Array<{ value: string; label: string }> }) => (
<select value={value} onChange={(e) => onChange(e.target.value)}>
{options.map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
),
}));
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<any>) {
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<any>) {
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<typeof create> | null = null;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/downstream-keys']}>
<ToastProvider>
<DownstreamKeys />
</ToastProvider>
</MemoryRouter>,
);
});
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<typeof create> | null = null;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/downstream-keys']}>
<ToastProvider>
<DownstreamKeys />
</ToastProvider>
</MemoryRouter>,
);
});
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<typeof create> | null = null;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/downstream-keys']}>
<ToastProvider>
<DownstreamKeys />
</ToastProvider>
</MemoryRouter>,
);
});
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<typeof create> | null = null;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/downstream-keys']}>
<ToastProvider>
<DownstreamKeys />
</ToastProvider>
</MemoryRouter>,
);
});
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<typeof create> | null = null;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/downstream-keys']}>
<ToastProvider>
<DownstreamKeys />
</ToastProvider>
</MemoryRouter>,
);
});
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();
}
});
});
File diff suppressed because it is too large Load Diff
@@ -62,6 +62,9 @@ function buildListResponse(overrides?: Partial<{
username: 'tester',
siteName: 'main-site',
siteUrl: 'https://main-site.example.com',
downstreamKeyName: '移动端灰度',
downstreamKeyGroupName: '项目A',
downstreamKeyTags: ['VIP', '灰度'],
},
],
total: 1,
@@ -102,6 +105,9 @@ describe('ProxyLogs server-driven page', () => {
username: 'tester',
siteName: 'main-site',
siteUrl: 'https://main-site.example.com',
downstreamKeyName: '移动端灰度',
downstreamKeyGroupName: '项目A',
downstreamKeyTags: ['VIP', '灰度'],
billingDetails: {
breakdown: {
inputPerMillion: 1,
@@ -165,6 +171,7 @@ describe('ProxyLogs server-driven page', () => {
expect(text).toContain('全部 12');
expect(text).toContain('成功 8');
expect(text).toContain('失败 4');
expect(text).toContain('下游 Key: 移动端灰度');
} finally {
root?.unmount();
}
@@ -194,7 +201,7 @@ describe('ProxyLogs server-driven page', () => {
await flushMicrotasks();
const searchInput = root!.root.find((node) => (
node.type === 'input' && node.props.placeholder === '搜索模型名称...'
node.type === 'input' && node.props.placeholder === '搜索模型、下游 Key、主分组、标签...'
));
await act(async () => {
searchInput.props.onChange({ target: { value: 'mini' } });
+25 -2
View File
@@ -80,6 +80,15 @@ function formatBillingDetailSummary(log: ProxyLogRenderItem) {
return `模型倍率 ${formatCompactNumber(detail.pricing.modelRatio)},输出倍率 ${formatCompactNumber(detail.pricing.completionRatio)},缓存倍率 ${formatCompactNumber(detail.pricing.cacheRatio)},缓存创建倍率 ${formatCompactNumber(detail.pricing.cacheCreationRatio)},分组倍率 ${formatCompactNumber(detail.pricing.groupRatio)}`;
}
function renderDownstreamKeySummary(log: ProxyLogRenderItem) {
const parts = [
log.downstreamKeyName ? `下游 Key: ${log.downstreamKeyName}` : null,
log.downstreamKeyGroupName ? `主分组: ${log.downstreamKeyGroupName}` : null,
Array.isArray(log.downstreamKeyTags) && log.downstreamKeyTags.length > 0 ? `标签: ${log.downstreamKeyTags.join(' / ')}` : null,
].filter(Boolean);
return parts.length > 0 ? parts.join(',') : null;
}
function buildBillingProcessLines(log: ProxyLogRenderItem) {
const detail = log.billingDetails;
if (!detail) return [];
@@ -476,7 +485,7 @@ export default function ProxyLogs() {
setSearchInput(e.target.value);
setPage(1);
}}
placeholder="搜索模型名称..."
placeholder="搜索模型、下游 Key、主分组、标签..."
/>
</div>
<button
@@ -585,6 +594,7 @@ export default function ProxyLogs() {
const pathMeta = parseProxyLogPathMeta(detailLog.errorMessage);
const billingDetailSummary = detail ? formatBillingDetailSummary(detailLog) : null;
const billingProcessLines = detail ? buildBillingProcessLines(detailLog) : [];
const downstreamKeySummary = renderDownstreamKeySummary(detailLog);
const isExpanded = expanded === log.id;
return (
@@ -599,6 +609,7 @@ export default function ProxyLogs() {
>
<MobileField label="时间" value={formatDateTimeLocal(log.createdAt)} />
<MobileField label="站点" value={log.siteName || '-'} />
{downstreamKeySummary ? <MobileField label="下游 Key" value={downstreamKeySummary} /> : null}
<MobileField label="用时" value={formatLatency(log.latencyMs)} />
<MobileField label="输入" value={log.promptTokens?.toLocaleString() || '-'} />
<MobileField label="输出" value={log.completionTokens?.toLocaleString() || '-'} />
@@ -612,6 +623,7 @@ export default function ProxyLogs() {
{detailState?.loading && <div style={{ color: 'var(--color-text-muted)' }}>加载详情中...</div>}
{detailState?.error && <div style={{ color: 'var(--color-danger)' }}>{detailState.error}</div>}
{billingDetailSummary && <div style={{ color: 'var(--color-text-muted)' }}>{billingDetailSummary}</div>}
{downstreamKeySummary && <div style={{ color: 'var(--color-text-muted)' }}>{downstreamKeySummary}</div>}
{billingProcessLines.length > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{billingProcessLines.map((line, index) => (
@@ -661,6 +673,7 @@ export default function ProxyLogs() {
const pathMeta = parseProxyLogPathMeta(detailLog.errorMessage);
const billingDetailSummary = detail ? formatBillingDetailSummary(detailLog) : null;
const billingProcessLines = detail ? buildBillingProcessLines(detailLog) : [];
const downstreamKeySummary = renderDownstreamKeySummary(detailLog);
return (
<React.Fragment key={log.id}>
@@ -686,7 +699,14 @@ export default function ProxyLogs() {
{formatDateTimeLocal(log.createdAt)}
</td>
<td>
<ModelBadge model={log.modelRequested} />
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<ModelBadge model={log.modelRequested} />
{downstreamKeySummary ? (
<div style={{ fontSize: 11, lineHeight: 1.45, color: 'var(--color-text-muted)' }}>
{downstreamKeySummary}
</div>
) : null}
</div>
</td>
<td style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}>
{log.siteName || '-'}
@@ -765,6 +785,9 @@ export default function ProxyLogs() {
{billingDetailSummary && (
<div style={{ color: 'var(--color-text-muted)' }}>{billingDetailSummary}</div>
)}
{downstreamKeySummary && (
<div style={{ color: 'var(--color-text-muted)' }}>{downstreamKeySummary}</div>
)}
</div>
</div>
+8 -60
View File
@@ -1,5 +1,6 @@
import React, { useEffect, useMemo, useState } from 'react';
import { createPortal } from 'react-dom';
import { useNavigate } from 'react-router-dom';
import { api } from '../api.js';
import { useToast } from '../components/Toast.js';
import ChangeKeyModal from '../components/ChangeKeyModal.js';
@@ -180,6 +181,7 @@ function resolveRouteBrandSource(route: RouteSelectorItem): string {
}
export default function Settings() {
const navigate = useNavigate();
const [runtime, setRuntime] = useState<RuntimeSettings>({
checkinCron: '0 8 * * *',
balanceRefreshCron: '0 * * * *',
@@ -1057,68 +1059,14 @@ export default function Settings() {
</div>
<div className="card animate-slide-up stagger-5" style={{ padding: 20 }}>
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 8 }}>下游 API Key 策略(按项目/分组)</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 12 }}>
每个下游 Key 可独立配置过期、额度,并通过勾选界面限制可访问的模型与群组。
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 8 }}>下游密钥管理入口已迁移</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 12, lineHeight: 1.8 }}>
下游 API Key 的新增、编辑、模型白名单、群组限制、趋势与用量分析,现统一收口到「控制台 / 下游密钥」页面,设置页不再保留重复管理入口。
</div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginBottom: 12 }}>
<button onClick={openCreateDownstreamModal} className="btn btn-primary">
+ 新增 API Key
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<button onClick={() => navigate('/downstream-keys')} className="btn btn-primary">
打开下游密钥管理页
</button>
<button onClick={loadDownstreamKeys} disabled={downstreamLoading} className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }}>
{downstreamLoading ? '刷新中...' : '刷新列表'}
</button>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{downstreamKeys.length === 0 ? (
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>暂无下游 API Key</div>
) : downstreamKeys.map((item) => {
const opLoading = !!downstreamOps[item.id];
const quotaText = `${item.usedRequests}${item.maxRequests !== null ? `/${item.maxRequests}` : ''}`;
const costText = `$${item.usedCost.toFixed(6)}${item.maxCost !== null ? `/$${item.maxCost}` : ''}`;
return (
<div key={item.id} style={{ border: '1px solid var(--color-border-light)', borderRadius: 'var(--radius-sm)', padding: 10 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
<strong style={{ fontSize: 13 }}>{item.name}</strong>
<code style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--color-text-secondary)' }}>{item.keyMasked}</code>
<span style={{
fontSize: 12,
padding: '2px 8px',
borderRadius: 999,
background: item.enabled ? 'var(--color-success-bg)' : 'var(--color-danger-bg)',
color: item.enabled ? 'var(--color-success)' : 'var(--color-danger)',
}}>
{item.enabled ? '启用' : '禁用'}
</span>
</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
<button onClick={() => beginEditDownstream(item)} className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }}>
编辑
</button>
<button onClick={() => toggleDownstreamEnabled(item)} disabled={opLoading} className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }}>
{item.enabled ? '禁用' : '启用'}
</button>
<button onClick={() => resetDownstreamUsage(item)} disabled={opLoading} className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }}>
重置用量
</button>
<button onClick={() => deleteDownstreamKey(item)} disabled={opLoading} className="btn btn-link btn-link-warning">
删除
</button>
</div>
</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: 6, display: 'flex', gap: 14, flexWrap: 'wrap' }}>
<span>请求用量: {quotaText}</span>
<span>费用用量: {costText}</span>
<span>过期: {item.expiresAt ? new Date(item.expiresAt).toLocaleString() : '永久'}</span>
<span>模型规则: {item.supportedModels.length || 0}</span>
<span>群组限制: {item.allowedRouteIds.length || 0}</span>
</div>
</div>
);
})}
</div>
</div>