Merge pull request #187 from cita-777/codex/proxy-log-client-detection-main
feat: add proxy log client detection filters
This commit is contained in:
@@ -0,0 +1,6 @@
|
|||||||
|
ALTER TABLE `proxy_logs` ADD `client_family` text;--> statement-breakpoint
|
||||||
|
ALTER TABLE `proxy_logs` ADD `client_app_id` text;--> statement-breakpoint
|
||||||
|
ALTER TABLE `proxy_logs` ADD `client_app_name` text;--> statement-breakpoint
|
||||||
|
ALTER TABLE `proxy_logs` ADD `client_confidence` text;--> statement-breakpoint
|
||||||
|
CREATE INDEX `proxy_logs_client_app_id_created_at_idx` ON `proxy_logs` (`client_app_id`,`created_at`);--> statement-breakpoint
|
||||||
|
CREATE INDEX `proxy_logs_client_family_created_at_idx` ON `proxy_logs` (`client_family`,`created_at`);
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -106,6 +106,13 @@
|
|||||||
"when": 1773900000000,
|
"when": 1773900000000,
|
||||||
"tag": "0015_site_announcements",
|
"tag": "0015_site_announcements",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 15,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1773979652606,
|
||||||
|
"tag": "0016_proxy_logs_client_fields",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ CREATE TABLE IF NOT EXISTS `downstream_api_keys` (`id` INT AUTO_INCREMENT NOT NU
|
|||||||
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 `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 `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_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, `downstream_api_key_id` INT);
|
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, `client_family` TEXT, `client_app_id` TEXT, `client_app_name` TEXT, `client_confidence` TEXT);
|
||||||
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 `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', `route_mode` VARCHAR(191) DEFAULT 'pattern');
|
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', `route_mode` VARCHAR(191) DEFAULT 'pattern');
|
||||||
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);
|
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);
|
||||||
@@ -45,6 +45,8 @@ CREATE INDEX `model_availability_account_available_idx` ON `model_availability`
|
|||||||
CREATE INDEX `model_availability_model_name_idx` ON `model_availability` (`model_name`(191));
|
CREATE INDEX `model_availability_model_name_idx` ON `model_availability` (`model_name`(191));
|
||||||
CREATE INDEX `proxy_files_owner_lookup_idx` ON `proxy_files` (`owner_type`(191), `owner_id`(191), `deleted_at`);
|
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_account_created_at_idx` ON `proxy_logs` (`account_id`, `created_at`);
|
||||||
|
CREATE INDEX `proxy_logs_client_app_id_created_at_idx` ON `proxy_logs` (`client_app_id`(191), `created_at`);
|
||||||
|
CREATE INDEX `proxy_logs_client_family_created_at_idx` ON `proxy_logs` (`client_family`(191), `created_at`);
|
||||||
CREATE INDEX `proxy_logs_created_at_idx` ON `proxy_logs` (`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_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_model_actual_created_at_idx` ON `proxy_logs` (`model_actual`(191), `created_at`);
|
||||||
|
|||||||
@@ -1,4 +1 @@
|
|||||||
CREATE TABLE IF NOT EXISTS `site_announcements` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `site_id` INT NOT NULL, `platform` TEXT NOT NULL, `source_key` TEXT NOT NULL, `title` TEXT NOT NULL, `content` TEXT NOT NULL, `level` VARCHAR(191) NOT NULL DEFAULT 'info', `source_url` TEXT, `starts_at` DATETIME, `ends_at` DATETIME, `upstream_created_at` DATETIME, `upstream_updated_at` DATETIME, `first_seen_at` DATETIME DEFAULT CURRENT_TIMESTAMP, `last_seen_at` DATETIME DEFAULT CURRENT_TIMESTAMP, `read_at` DATETIME, `dismissed_at` DATETIME, `raw_payload` TEXT, FOREIGN KEY (`site_id`) REFERENCES `sites`(`id`) ON DELETE CASCADE);
|
-- no schema changes detected for mysql
|
||||||
CREATE UNIQUE INDEX `site_announcements_site_source_key_unique` ON `site_announcements` (`site_id`, `source_key`(191));
|
|
||||||
CREATE INDEX `site_announcements_read_at_idx` ON `site_announcements` (`read_at`);
|
|
||||||
CREATE INDEX `site_announcements_site_id_first_seen_at_idx` ON `site_announcements` (`site_id`, `first_seen_at`);
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ CREATE TABLE IF NOT EXISTS "downstream_api_keys" ("id" INTEGER GENERATED BY DEFA
|
|||||||
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 "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 "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_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, "downstream_api_key_id" INTEGER);
|
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, "client_family" TEXT, "client_app_id" TEXT, "client_app_name" TEXT, "client_confidence" TEXT);
|
||||||
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 "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', "route_mode" TEXT DEFAULT 'pattern');
|
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', "route_mode" TEXT DEFAULT 'pattern');
|
||||||
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);
|
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);
|
||||||
@@ -45,6 +45,8 @@ CREATE INDEX "model_availability_account_available_idx" ON "model_availability"
|
|||||||
CREATE INDEX "model_availability_model_name_idx" ON "model_availability" ("model_name");
|
CREATE INDEX "model_availability_model_name_idx" ON "model_availability" ("model_name");
|
||||||
CREATE INDEX "proxy_files_owner_lookup_idx" ON "proxy_files" ("owner_type", "owner_id", "deleted_at");
|
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_account_created_at_idx" ON "proxy_logs" ("account_id", "created_at");
|
||||||
|
CREATE INDEX "proxy_logs_client_app_id_created_at_idx" ON "proxy_logs" ("client_app_id", "created_at");
|
||||||
|
CREATE INDEX "proxy_logs_client_family_created_at_idx" ON "proxy_logs" ("client_family", "created_at");
|
||||||
CREATE INDEX "proxy_logs_created_at_idx" ON "proxy_logs" ("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_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_model_actual_created_at_idx" ON "proxy_logs" ("model_actual", "created_at");
|
||||||
|
|||||||
@@ -1,4 +1 @@
|
|||||||
CREATE TABLE IF NOT EXISTS "site_announcements" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "site_id" INTEGER NOT NULL, "platform" TEXT NOT NULL, "source_key" TEXT NOT NULL, "title" TEXT NOT NULL, "content" TEXT NOT NULL, "level" TEXT NOT NULL DEFAULT 'info', "source_url" TEXT, "starts_at" TIMESTAMP, "ends_at" TIMESTAMP, "upstream_created_at" TIMESTAMP, "upstream_updated_at" TIMESTAMP, "first_seen_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "last_seen_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "read_at" TIMESTAMP, "dismissed_at" TIMESTAMP, "raw_payload" TEXT, FOREIGN KEY ("site_id") REFERENCES "sites"("id") ON DELETE CASCADE);
|
-- no schema changes detected for postgres
|
||||||
CREATE UNIQUE INDEX "site_announcements_site_source_key_unique" ON "site_announcements" ("site_id", "source_key");
|
|
||||||
CREATE INDEX "site_announcements_read_at_idx" ON "site_announcements" ("read_at");
|
|
||||||
CREATE INDEX "site_announcements_site_id_first_seen_at_idx" ON "site_announcements" ("site_id", "first_seen_at");
|
|
||||||
|
|||||||
@@ -653,6 +653,30 @@
|
|||||||
"notNull": false,
|
"notNull": false,
|
||||||
"defaultValue": null,
|
"defaultValue": null,
|
||||||
"primaryKey": false
|
"primaryKey": false
|
||||||
|
},
|
||||||
|
"client_family": {
|
||||||
|
"logicalType": "text",
|
||||||
|
"notNull": false,
|
||||||
|
"defaultValue": null,
|
||||||
|
"primaryKey": false
|
||||||
|
},
|
||||||
|
"client_app_id": {
|
||||||
|
"logicalType": "text",
|
||||||
|
"notNull": false,
|
||||||
|
"defaultValue": null,
|
||||||
|
"primaryKey": false
|
||||||
|
},
|
||||||
|
"client_app_name": {
|
||||||
|
"logicalType": "text",
|
||||||
|
"notNull": false,
|
||||||
|
"defaultValue": null,
|
||||||
|
"primaryKey": false
|
||||||
|
},
|
||||||
|
"client_confidence": {
|
||||||
|
"logicalType": "text",
|
||||||
|
"notNull": false,
|
||||||
|
"defaultValue": null,
|
||||||
|
"primaryKey": false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1456,6 +1480,24 @@
|
|||||||
],
|
],
|
||||||
"unique": false
|
"unique": false
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "proxy_logs_client_app_id_created_at_idx",
|
||||||
|
"table": "proxy_logs",
|
||||||
|
"columns": [
|
||||||
|
"client_app_id",
|
||||||
|
"created_at"
|
||||||
|
],
|
||||||
|
"unique": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "proxy_logs_client_family_created_at_idx",
|
||||||
|
"table": "proxy_logs",
|
||||||
|
"columns": [
|
||||||
|
"client_family",
|
||||||
|
"created_at"
|
||||||
|
],
|
||||||
|
"unique": false
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "proxy_logs_created_at_idx",
|
"name": "proxy_logs_created_at_idx",
|
||||||
"table": "proxy_logs",
|
"table": "proxy_logs",
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ let mysqlPool: mysql.Pool | null = null;
|
|||||||
let pgPool: pg.Pool | null = null;
|
let pgPool: pg.Pool | null = null;
|
||||||
let proxyLogBillingDetailsColumnAvailable: boolean | null = null;
|
let proxyLogBillingDetailsColumnAvailable: boolean | null = null;
|
||||||
let proxyLogDownstreamApiKeyIdColumnAvailable: boolean | null = null;
|
let proxyLogDownstreamApiKeyIdColumnAvailable: boolean | null = null;
|
||||||
|
let proxyLogClientColumnsAvailable: boolean | null = null;
|
||||||
|
|
||||||
function resolveSqlitePath(): string {
|
function resolveSqlitePath(): string {
|
||||||
const raw = (config.dbUrl || '').trim();
|
const raw = (config.dbUrl || '').trim();
|
||||||
@@ -120,6 +121,13 @@ function tableColumnExists(table: string, column: string): boolean {
|
|||||||
return rows.some((row) => row.name === column);
|
return rows.some((row) => row.name === column);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function tableIndexExists(indexName: string): boolean {
|
||||||
|
const sqlite = requireSqliteConnection();
|
||||||
|
const row = sqlite.prepare("SELECT name FROM sqlite_master WHERE type = 'index' AND name = ? LIMIT 1")
|
||||||
|
.get(indexName) as { name?: string } | undefined;
|
||||||
|
return !!row?.name;
|
||||||
|
}
|
||||||
|
|
||||||
function execSqliteStatement(sqlText: string): void {
|
function execSqliteStatement(sqlText: string): void {
|
||||||
requireSqliteConnection().exec(sqlText);
|
requireSqliteConnection().exec(sqlText);
|
||||||
}
|
}
|
||||||
@@ -588,6 +596,40 @@ function ensureProxyLogDownstreamApiKeyIdSchema() {
|
|||||||
proxyLogDownstreamApiKeyIdColumnAvailable = true;
|
proxyLogDownstreamApiKeyIdColumnAvailable = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ensureProxyLogClientSchema() {
|
||||||
|
if (!tableExists('proxy_logs')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!tableColumnExists('proxy_logs', 'client_family')) {
|
||||||
|
execSqliteLegacyCompat('ALTER TABLE proxy_logs ADD COLUMN client_family text;');
|
||||||
|
}
|
||||||
|
if (!tableColumnExists('proxy_logs', 'client_app_id')) {
|
||||||
|
execSqliteLegacyCompat('ALTER TABLE proxy_logs ADD COLUMN client_app_id text;');
|
||||||
|
}
|
||||||
|
if (!tableColumnExists('proxy_logs', 'client_app_name')) {
|
||||||
|
execSqliteLegacyCompat('ALTER TABLE proxy_logs ADD COLUMN client_app_name text;');
|
||||||
|
}
|
||||||
|
if (!tableColumnExists('proxy_logs', 'client_confidence')) {
|
||||||
|
execSqliteLegacyCompat('ALTER TABLE proxy_logs ADD COLUMN client_confidence text;');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!tableIndexExists('proxy_logs_client_app_id_created_at_idx')) {
|
||||||
|
execSqliteLegacyCompat(`
|
||||||
|
CREATE INDEX IF NOT EXISTS proxy_logs_client_app_id_created_at_idx
|
||||||
|
ON proxy_logs(client_app_id, created_at);
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
if (!tableIndexExists('proxy_logs_client_family_created_at_idx')) {
|
||||||
|
execSqliteLegacyCompat(`
|
||||||
|
CREATE INDEX IF NOT EXISTS proxy_logs_client_family_created_at_idx
|
||||||
|
ON proxy_logs(client_family, created_at);
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
proxyLogClientColumnsAvailable = true;
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeSchemaErrorMessage(error: unknown): string {
|
function normalizeSchemaErrorMessage(error: unknown): string {
|
||||||
if (typeof error === 'object' && error && 'message' in error) {
|
if (typeof error === 'object' && error && 'message' in error) {
|
||||||
return String((error as { message?: unknown }).message || '');
|
return String((error as { message?: unknown }).message || '');
|
||||||
@@ -600,6 +642,14 @@ function isDuplicateColumnError(error: unknown): boolean {
|
|||||||
return lowered.includes('duplicate column') || lowered.includes('already exists');
|
return lowered.includes('duplicate column') || lowered.includes('already exists');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isDuplicateIndexError(error: unknown): boolean {
|
||||||
|
const lowered = normalizeSchemaErrorMessage(error).toLowerCase();
|
||||||
|
return lowered.includes('duplicate key name')
|
||||||
|
|| lowered.includes('already exists')
|
||||||
|
|| lowered.includes('relation')
|
||||||
|
|| lowered.includes('duplicate index');
|
||||||
|
}
|
||||||
|
|
||||||
export async function hasProxyLogBillingDetailsColumn(): Promise<boolean> {
|
export async function hasProxyLogBillingDetailsColumn(): Promise<boolean> {
|
||||||
if (proxyLogBillingDetailsColumnAvailable !== null) {
|
if (proxyLogBillingDetailsColumnAvailable !== null) {
|
||||||
return proxyLogBillingDetailsColumnAvailable;
|
return proxyLogBillingDetailsColumnAvailable;
|
||||||
@@ -732,9 +782,183 @@ export async function ensureProxyLogDownstreamApiKeyIdColumn(): Promise<boolean>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function hasMysqlIndex(indexName: string): Promise<boolean> {
|
||||||
|
if (!mysqlPool) return false;
|
||||||
|
const [rows] = await mysqlPool.query(
|
||||||
|
'SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE() AND table_name = ? AND index_name = ? LIMIT 1',
|
||||||
|
['proxy_logs', indexName],
|
||||||
|
);
|
||||||
|
return Array.isArray(rows) && rows.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hasPostgresIndex(indexName: string): Promise<boolean> {
|
||||||
|
if (!pgPool) return false;
|
||||||
|
const result = await pgPool.query(
|
||||||
|
'SELECT 1 FROM pg_indexes WHERE schemaname = current_schema() AND tablename = $1 AND indexname = $2 LIMIT 1',
|
||||||
|
['proxy_logs', indexName],
|
||||||
|
);
|
||||||
|
return Number(result.rowCount || 0) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function hasProxyLogClientColumns(): Promise<boolean> {
|
||||||
|
if (proxyLogClientColumnsAvailable !== null) {
|
||||||
|
return proxyLogClientColumnsAvailable;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requiredColumns = [
|
||||||
|
'client_family',
|
||||||
|
'client_app_id',
|
||||||
|
'client_app_name',
|
||||||
|
'client_confidence',
|
||||||
|
];
|
||||||
|
|
||||||
|
if (runtimeDbDialect === 'sqlite') {
|
||||||
|
proxyLogClientColumnsAvailable = tableExists('proxy_logs')
|
||||||
|
&& requiredColumns.every((columnName) => tableColumnExists('proxy_logs', columnName));
|
||||||
|
return proxyLogClientColumnsAvailable;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (runtimeDbDialect === 'mysql') {
|
||||||
|
if (!mysqlPool) return false;
|
||||||
|
const [rows] = await mysqlPool.query(
|
||||||
|
'SELECT column_name FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = ? AND column_name IN (?, ?, ?, ?)',
|
||||||
|
['proxy_logs', ...requiredColumns],
|
||||||
|
) as [Array<{ column_name?: string }>, unknown];
|
||||||
|
const available = new Set(
|
||||||
|
Array.isArray(rows)
|
||||||
|
? rows.map((row) => String(row?.column_name || '').trim().toLowerCase()).filter(Boolean)
|
||||||
|
: [],
|
||||||
|
);
|
||||||
|
proxyLogClientColumnsAvailable = requiredColumns.every((columnName) => available.has(columnName));
|
||||||
|
return proxyLogClientColumnsAvailable;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!pgPool) return false;
|
||||||
|
const result = await pgPool.query(
|
||||||
|
'SELECT column_name FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = $1 AND column_name = ANY($2::text[])',
|
||||||
|
['proxy_logs', requiredColumns],
|
||||||
|
);
|
||||||
|
const available = new Set(
|
||||||
|
result.rows.map((row) => String((row as { column_name?: string }).column_name || '').trim().toLowerCase()).filter(Boolean),
|
||||||
|
);
|
||||||
|
proxyLogClientColumnsAvailable = requiredColumns.every((columnName) => available.has(columnName));
|
||||||
|
return proxyLogClientColumnsAvailable;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensureProxyLogClientColumns(): Promise<boolean> {
|
||||||
|
const requiredColumns = [
|
||||||
|
{ name: 'client_family', sqliteType: 'text', mysqlType: 'TEXT NULL', postgresType: 'TEXT' },
|
||||||
|
{ name: 'client_app_id', sqliteType: 'text', mysqlType: 'TEXT NULL', postgresType: 'TEXT' },
|
||||||
|
{ name: 'client_app_name', sqliteType: 'text', mysqlType: 'TEXT NULL', postgresType: 'TEXT' },
|
||||||
|
{ name: 'client_confidence', sqliteType: 'text', mysqlType: 'TEXT NULL', postgresType: 'TEXT' },
|
||||||
|
];
|
||||||
|
const requiredIndexes = [
|
||||||
|
{
|
||||||
|
name: 'proxy_logs_client_app_id_created_at_idx',
|
||||||
|
sqliteSql: 'CREATE INDEX IF NOT EXISTS proxy_logs_client_app_id_created_at_idx ON proxy_logs(client_app_id, created_at);',
|
||||||
|
mysqlSql: 'CREATE INDEX `proxy_logs_client_app_id_created_at_idx` ON `proxy_logs` (`client_app_id`(191), `created_at`(191))',
|
||||||
|
postgresSql: 'CREATE INDEX "proxy_logs_client_app_id_created_at_idx" ON "proxy_logs" ("client_app_id", "created_at")',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'proxy_logs_client_family_created_at_idx',
|
||||||
|
sqliteSql: 'CREATE INDEX IF NOT EXISTS proxy_logs_client_family_created_at_idx ON proxy_logs(client_family, created_at);',
|
||||||
|
mysqlSql: 'CREATE INDEX `proxy_logs_client_family_created_at_idx` ON `proxy_logs` (`client_family`(191), `created_at`(191))',
|
||||||
|
postgresSql: 'CREATE INDEX "proxy_logs_client_family_created_at_idx" ON "proxy_logs" ("client_family", "created_at")',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
if (runtimeDbDialect === 'sqlite') {
|
||||||
|
ensureProxyLogClientSchema();
|
||||||
|
proxyLogClientColumnsAvailable = tableExists('proxy_logs')
|
||||||
|
&& requiredColumns.every((column) => tableColumnExists('proxy_logs', column.name));
|
||||||
|
return proxyLogClientColumnsAvailable;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await hasProxyLogClientColumns()) {
|
||||||
|
for (const requiredIndex of requiredIndexes) {
|
||||||
|
const indexExists = runtimeDbDialect === 'mysql'
|
||||||
|
? await hasMysqlIndex(requiredIndex.name)
|
||||||
|
: await hasPostgresIndex(requiredIndex.name);
|
||||||
|
if (indexExists) continue;
|
||||||
|
try {
|
||||||
|
if (runtimeDbDialect === 'mysql') {
|
||||||
|
if (!mysqlPool) return false;
|
||||||
|
await executeLegacyCompat(
|
||||||
|
(statement) => mysqlPool!.query(statement).then(() => undefined),
|
||||||
|
requiredIndex.mysqlSql,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
if (!pgPool) return false;
|
||||||
|
await executeLegacyCompat(
|
||||||
|
(statement) => pgPool!.query(statement).then(() => undefined),
|
||||||
|
requiredIndex.postgresSql,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (!isDuplicateIndexError(error)) {
|
||||||
|
console.warn(`[db] failed to ensure ${requiredIndex.name}`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (runtimeDbDialect === 'mysql') {
|
||||||
|
if (!mysqlPool) return false;
|
||||||
|
for (const column of requiredColumns) {
|
||||||
|
const [rows] = await mysqlPool.query('SHOW COLUMNS FROM `proxy_logs` LIKE ?', [column.name]);
|
||||||
|
if (Array.isArray(rows) && rows.length > 0) continue;
|
||||||
|
await executeLegacyCompat(
|
||||||
|
(statement) => mysqlPool!.query(statement).then(() => undefined),
|
||||||
|
`ALTER TABLE \`proxy_logs\` ADD COLUMN \`${column.name}\` ${column.mysqlType}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const requiredIndex of requiredIndexes) {
|
||||||
|
if (await hasMysqlIndex(requiredIndex.name)) continue;
|
||||||
|
await executeLegacyCompat(
|
||||||
|
(statement) => mysqlPool!.query(statement).then(() => undefined),
|
||||||
|
requiredIndex.mysqlSql,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (!pgPool) return false;
|
||||||
|
for (const column of requiredColumns) {
|
||||||
|
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', column.name],
|
||||||
|
);
|
||||||
|
if (Number(result.rowCount || 0) > 0) continue;
|
||||||
|
await executeLegacyCompat(
|
||||||
|
(statement) => pgPool!.query(statement).then(() => undefined),
|
||||||
|
`ALTER TABLE "proxy_logs" ADD COLUMN "${column.name}" ${column.postgresType}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const requiredIndex of requiredIndexes) {
|
||||||
|
if (await hasPostgresIndex(requiredIndex.name)) continue;
|
||||||
|
await executeLegacyCompat(
|
||||||
|
(statement) => pgPool!.query(statement).then(() => undefined),
|
||||||
|
requiredIndex.postgresSql,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
proxyLogClientColumnsAvailable = true;
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
if (isDuplicateColumnError(error) || isDuplicateIndexError(error)) {
|
||||||
|
proxyLogClientColumnsAvailable = await hasProxyLogClientColumns();
|
||||||
|
return proxyLogClientColumnsAvailable;
|
||||||
|
}
|
||||||
|
proxyLogClientColumnsAvailable = false;
|
||||||
|
console.warn('[db] failed to ensure proxy_logs client columns', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function resetSchemaCapabilityCache() {
|
function resetSchemaCapabilityCache() {
|
||||||
proxyLogBillingDetailsColumnAvailable = null;
|
proxyLogBillingDetailsColumnAvailable = null;
|
||||||
proxyLogDownstreamApiKeyIdColumnAvailable = null;
|
proxyLogDownstreamApiKeyIdColumnAvailable = null;
|
||||||
|
proxyLogClientColumnsAvailable = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sqliteProxyQuery(sqlText: string, params: unknown[], method: SqlMethod) {
|
async function sqliteProxyQuery(sqlText: string, params: unknown[], method: SqlMethod) {
|
||||||
@@ -995,6 +1219,7 @@ function initSqliteDb() {
|
|||||||
ensureRouteGroupingSchema();
|
ensureRouteGroupingSchema();
|
||||||
ensureDownstreamApiKeySchema();
|
ensureDownstreamApiKeySchema();
|
||||||
ensureProxyLogBillingDetailsSchema();
|
ensureProxyLogBillingDetailsSchema();
|
||||||
|
ensureProxyLogClientSchema();
|
||||||
ensureProxyVideoTaskSchema();
|
ensureProxyVideoTaskSchema();
|
||||||
ensureProxyFileSchema();
|
ensureProxyFileSchema();
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { classifyLegacyCompatMutation } from './legacySchemaCompat.js';
|
|||||||
describe('legacy schema compat boundary', () => {
|
describe('legacy schema compat boundary', () => {
|
||||||
it('allows only explicitly registered legacy upgrade shims', () => {
|
it('allows only explicitly registered legacy upgrade shims', () => {
|
||||||
expect(classifyLegacyCompatMutation('ALTER TABLE proxy_logs ADD COLUMN billing_details text;')).toBe('legacy');
|
expect(classifyLegacyCompatMutation('ALTER TABLE proxy_logs ADD COLUMN billing_details text;')).toBe('legacy');
|
||||||
|
expect(classifyLegacyCompatMutation('ALTER TABLE proxy_logs ADD COLUMN client_app_id text;')).toBe('legacy');
|
||||||
|
expect(classifyLegacyCompatMutation('CREATE INDEX proxy_logs_client_app_id_created_at_idx ON proxy_logs(client_app_id, created_at);')).toBe('legacy');
|
||||||
expect(classifyLegacyCompatMutation('UPDATE "sites" SET "use_system_proxy" = FALSE WHERE "use_system_proxy" IS NULL')).toBe('legacy');
|
expect(classifyLegacyCompatMutation('UPDATE "sites" SET "use_system_proxy" = FALSE WHERE "use_system_proxy" IS NULL')).toBe('legacy');
|
||||||
expect(classifyLegacyCompatMutation('ALTER TABLE sites ADD COLUMN brand_new_column text;')).toBe('forbidden');
|
expect(classifyLegacyCompatMutation('ALTER TABLE sites ADD COLUMN brand_new_column text;')).toBe('forbidden');
|
||||||
expect(classifyLegacyCompatMutation('UPDATE "sites" SET "brand_new_column" = 1')).toBe('forbidden');
|
expect(classifyLegacyCompatMutation('UPDATE "sites" SET "brand_new_column" = 1')).toBe('forbidden');
|
||||||
|
|||||||
@@ -66,6 +66,10 @@ const LEGACY_COMPAT_COLUMNS = new Set([
|
|||||||
'downstream_api_keys.group_name',
|
'downstream_api_keys.group_name',
|
||||||
'downstream_api_keys.tags',
|
'downstream_api_keys.tags',
|
||||||
'proxy_logs.billing_details',
|
'proxy_logs.billing_details',
|
||||||
|
'proxy_logs.client_family',
|
||||||
|
'proxy_logs.client_app_id',
|
||||||
|
'proxy_logs.client_app_name',
|
||||||
|
'proxy_logs.client_confidence',
|
||||||
'proxy_logs.downstream_api_key_id',
|
'proxy_logs.downstream_api_key_id',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -81,6 +85,8 @@ const LEGACY_COMPAT_INDEXES = new Set([
|
|||||||
'downstream_api_keys_name_idx',
|
'downstream_api_keys_name_idx',
|
||||||
'downstream_api_keys_enabled_idx',
|
'downstream_api_keys_enabled_idx',
|
||||||
'downstream_api_keys_expires_at_idx',
|
'downstream_api_keys_expires_at_idx',
|
||||||
|
'proxy_logs_client_app_id_created_at_idx',
|
||||||
|
'proxy_logs_client_family_created_at_idx',
|
||||||
'proxy_logs_downstream_api_key_created_at_idx',
|
'proxy_logs_downstream_api_key_created_at_idx',
|
||||||
'route_group_sources_group_source_unique',
|
'route_group_sources_group_source_unique',
|
||||||
'route_group_sources_source_route_id_idx',
|
'route_group_sources_source_route_id_idx',
|
||||||
|
|||||||
@@ -193,6 +193,10 @@ export const proxyLogs = sqliteTable('proxy_logs', {
|
|||||||
totalTokens: integer('total_tokens'),
|
totalTokens: integer('total_tokens'),
|
||||||
estimatedCost: real('estimated_cost'),
|
estimatedCost: real('estimated_cost'),
|
||||||
billingDetails: text('billing_details'),
|
billingDetails: text('billing_details'),
|
||||||
|
clientFamily: text('client_family'),
|
||||||
|
clientAppId: text('client_app_id'),
|
||||||
|
clientAppName: text('client_app_name'),
|
||||||
|
clientConfidence: text('client_confidence'),
|
||||||
errorMessage: text('error_message'),
|
errorMessage: text('error_message'),
|
||||||
retryCount: integer('retry_count').default(0),
|
retryCount: integer('retry_count').default(0),
|
||||||
createdAt: text('created_at').default(sql`(datetime('now'))`),
|
createdAt: text('created_at').default(sql`(datetime('now'))`),
|
||||||
@@ -202,6 +206,8 @@ export const proxyLogs = sqliteTable('proxy_logs', {
|
|||||||
statusCreatedIdx: index('proxy_logs_status_created_at_idx').on(table.status, 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),
|
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),
|
downstreamKeyCreatedIdx: index('proxy_logs_downstream_api_key_created_at_idx').on(table.downstreamApiKeyId, table.createdAt),
|
||||||
|
clientAppCreatedIdx: index('proxy_logs_client_app_id_created_at_idx').on(table.clientAppId, table.createdAt),
|
||||||
|
clientFamilyCreatedIdx: index('proxy_logs_client_family_created_at_idx').on(table.clientFamily, table.createdAt),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const proxyVideoTasks = sqliteTable('proxy_video_tasks', {
|
export const proxyVideoTasks = sqliteTable('proxy_video_tasks', {
|
||||||
|
|||||||
@@ -85,10 +85,18 @@ describe('database schema parity', () => {
|
|||||||
const postgresBootstrap = readFileSync(resolve(generatedDir, 'postgres.bootstrap.sql'), 'utf8');
|
const postgresBootstrap = readFileSync(resolve(generatedDir, 'postgres.bootstrap.sql'), 'utf8');
|
||||||
|
|
||||||
expect(contract.tables.proxy_logs?.columns.downstream_api_key_id?.logicalType).toBe('integer');
|
expect(contract.tables.proxy_logs?.columns.downstream_api_key_id?.logicalType).toBe('integer');
|
||||||
|
expect(contract.tables.proxy_logs?.columns.client_app_id?.logicalType).toBe('text');
|
||||||
|
expect(contract.tables.proxy_logs?.columns.client_family?.logicalType).toBe('text');
|
||||||
expect(contract.indexes.some((index) => index.name === 'proxy_logs_downstream_api_key_created_at_idx')).toBe(true);
|
expect(contract.indexes.some((index) => index.name === 'proxy_logs_downstream_api_key_created_at_idx')).toBe(true);
|
||||||
|
expect(contract.indexes.some((index) => index.name === 'proxy_logs_client_app_id_created_at_idx')).toBe(true);
|
||||||
|
expect(contract.indexes.some((index) => index.name === 'proxy_logs_client_family_created_at_idx')).toBe(true);
|
||||||
expect(mysqlBootstrap).toContain('`downstream_api_key_id`');
|
expect(mysqlBootstrap).toContain('`downstream_api_key_id`');
|
||||||
expect(mysqlBootstrap).toContain('`proxy_logs_downstream_api_key_created_at_idx`');
|
expect(mysqlBootstrap).toContain('`proxy_logs_downstream_api_key_created_at_idx`');
|
||||||
|
expect(mysqlBootstrap).toContain('`client_app_id`');
|
||||||
|
expect(mysqlBootstrap).toContain('`proxy_logs_client_app_id_created_at_idx`');
|
||||||
expect(postgresBootstrap).toContain('"downstream_api_key_id"');
|
expect(postgresBootstrap).toContain('"downstream_api_key_id"');
|
||||||
expect(postgresBootstrap).toContain('"proxy_logs_downstream_api_key_created_at_idx"');
|
expect(postgresBootstrap).toContain('"proxy_logs_downstream_api_key_created_at_idx"');
|
||||||
|
expect(postgresBootstrap).toContain('"client_app_id"');
|
||||||
|
expect(postgresBootstrap).toContain('"proxy_logs_client_app_id_created_at_idx"');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import { normalizeLogCleanupRetentionDays } from './services/logCleanupService.j
|
|||||||
import {
|
import {
|
||||||
db,
|
db,
|
||||||
ensureProxyFileCompatibilityColumns,
|
ensureProxyFileCompatibilityColumns,
|
||||||
|
ensureProxyLogClientColumns,
|
||||||
ensureProxyLogDownstreamApiKeyIdColumn,
|
ensureProxyLogDownstreamApiKeyIdColumn,
|
||||||
ensureProxyLogBillingDetailsColumn,
|
ensureProxyLogBillingDetailsColumn,
|
||||||
ensureRouteGroupingCompatibilityColumns,
|
ensureRouteGroupingCompatibilityColumns,
|
||||||
@@ -313,6 +314,7 @@ try {
|
|||||||
await ensureSiteCompatibilityColumns();
|
await ensureSiteCompatibilityColumns();
|
||||||
await ensureRouteGroupingCompatibilityColumns();
|
await ensureRouteGroupingCompatibilityColumns();
|
||||||
await ensureProxyFileCompatibilityColumns();
|
await ensureProxyFileCompatibilityColumns();
|
||||||
|
await ensureProxyLogClientColumns();
|
||||||
await ensureProxyLogDownstreamApiKeyIdColumn();
|
await ensureProxyLogDownstreamApiKeyIdColumn();
|
||||||
const finalRows = await db.select().from(schema.settings).all();
|
const finalRows = await db.select().from(schema.settings).all();
|
||||||
const finalMap = toSettingsMap(finalRows);
|
const finalMap = toSettingsMap(finalRows);
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { TextDecoder } from 'node:util';
|
import { TextDecoder } from 'node:util';
|
||||||
import type { FastifyRequest, FastifyReply } from 'fastify';
|
import type { FastifyRequest, FastifyReply } from 'fastify';
|
||||||
import { tokenRouter } from '../../services/tokenRouter.js';
|
import { tokenRouter } from '../../services/tokenRouter.js';
|
||||||
import { db, hasProxyLogDownstreamApiKeyIdColumn, schema } from '../../db/index.js';
|
|
||||||
import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js';
|
import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js';
|
||||||
import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js';
|
import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js';
|
||||||
import { isTokenExpiredError } from '../../services/alertRules.js';
|
import { isTokenExpiredError } from '../../services/alertRules.js';
|
||||||
@@ -49,9 +48,9 @@ import {
|
|||||||
unwrapGeminiCliPayload,
|
unwrapGeminiCliPayload,
|
||||||
} from '../../routes/proxy/geminiCliCompat.js';
|
} from '../../routes/proxy/geminiCliCompat.js';
|
||||||
import { dispatchRuntimeRequest } from '../../routes/proxy/runtimeExecutor.js';
|
import { dispatchRuntimeRequest } from '../../routes/proxy/runtimeExecutor.js';
|
||||||
import { detectCliProfile } from '../cliProfiles/registry.js';
|
|
||||||
import type { CliProfileId } from '../cliProfiles/types.js';
|
|
||||||
import { summarizeConversationFileInputsInOpenAiBody } from '../capabilities/conversationFileCapabilities.js';
|
import { summarizeConversationFileInputsInOpenAiBody } from '../capabilities/conversationFileCapabilities.js';
|
||||||
|
import { detectDownstreamClientContext, type DownstreamClientContext } from '../../routes/proxy/downstreamClientContext.js';
|
||||||
|
import { insertProxyLog } from '../../services/proxyLogStore.js';
|
||||||
|
|
||||||
const MAX_RETRIES = 2;
|
const MAX_RETRIES = 2;
|
||||||
|
|
||||||
@@ -63,25 +62,6 @@ function asTrimmedString(value: unknown): string {
|
|||||||
return typeof value === 'string' ? value.trim() : '';
|
return typeof value === 'string' ? value.trim() : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
type DownstreamClientContext = {
|
|
||||||
clientKind: CliProfileId;
|
|
||||||
sessionId?: string;
|
|
||||||
traceHint?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
function detectDownstreamClientContext(input: {
|
|
||||||
downstreamPath: string;
|
|
||||||
headers?: Record<string, unknown>;
|
|
||||||
body?: unknown;
|
|
||||||
}): DownstreamClientContext {
|
|
||||||
const detected = detectCliProfile(input);
|
|
||||||
return {
|
|
||||||
clientKind: detected.id,
|
|
||||||
...(detected.sessionId ? { sessionId: detected.sessionId } : {}),
|
|
||||||
...(detected.traceHint ? { traceHint: detected.traceHint } : {}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function handleChatSurfaceRequest(
|
export async function handleChatSurfaceRequest(
|
||||||
request: FastifyRequest,
|
request: FastifyRequest,
|
||||||
reply: FastifyReply,
|
reply: FastifyReply,
|
||||||
@@ -131,8 +111,6 @@ export async function handleChatSurfaceRequest(
|
|||||||
proxyToken: getProxyAuthContext(request)?.token || null,
|
proxyToken: getProxyAuthContext(request)?.token || null,
|
||||||
});
|
});
|
||||||
const downstreamApiKeyId = getProxyAuthContext(request)?.keyId ?? null;
|
const downstreamApiKeyId = getProxyAuthContext(request)?.keyId ?? null;
|
||||||
const logDownstreamApiKeyId = downstreamApiKeyId !== null
|
|
||||||
&& await hasProxyLogDownstreamApiKeyIdColumn();
|
|
||||||
|
|
||||||
const excludeChannelIds: number[] = [];
|
const excludeChannelIds: number[] = [];
|
||||||
let retryCount = 0;
|
let retryCount = 0;
|
||||||
@@ -297,7 +275,7 @@ export async function handleChatSurfaceRequest(
|
|||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
clientContext,
|
clientContext,
|
||||||
logDownstreamApiKeyId ? downstreamApiKeyId : null,
|
downstreamApiKeyId,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -323,7 +301,7 @@ export async function handleChatSurfaceRequest(
|
|||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
clientContext,
|
clientContext,
|
||||||
logDownstreamApiKeyId ? downstreamApiKeyId : null,
|
downstreamApiKeyId,
|
||||||
);
|
);
|
||||||
await recordOauthQuotaResetHint({
|
await recordOauthQuotaResetHint({
|
||||||
accountId: selected.account.id,
|
accountId: selected.account.id,
|
||||||
@@ -426,7 +404,7 @@ export async function handleChatSurfaceRequest(
|
|||||||
null,
|
null,
|
||||||
successfulUpstreamPath,
|
successfulUpstreamPath,
|
||||||
clientContext,
|
clientContext,
|
||||||
logDownstreamApiKeyId ? downstreamApiKeyId : null,
|
downstreamApiKeyId,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -462,7 +440,7 @@ export async function handleChatSurfaceRequest(
|
|||||||
null,
|
null,
|
||||||
successfulUpstreamPath,
|
successfulUpstreamPath,
|
||||||
clientContext,
|
clientContext,
|
||||||
logDownstreamApiKeyId ? downstreamApiKeyId : null,
|
downstreamApiKeyId,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (shouldRetryProxyRequest(failure.status, failure.reason) && retryCount < MAX_RETRIES) {
|
if (shouldRetryProxyRequest(failure.status, failure.reason) && retryCount < MAX_RETRIES) {
|
||||||
@@ -500,7 +478,7 @@ export async function handleChatSurfaceRequest(
|
|||||||
null,
|
null,
|
||||||
successfulUpstreamPath,
|
successfulUpstreamPath,
|
||||||
clientContext,
|
clientContext,
|
||||||
logDownstreamApiKeyId ? downstreamApiKeyId : null,
|
downstreamApiKeyId,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -550,7 +528,7 @@ export async function handleChatSurfaceRequest(
|
|||||||
null,
|
null,
|
||||||
successfulUpstreamPath,
|
successfulUpstreamPath,
|
||||||
clientContext,
|
clientContext,
|
||||||
logDownstreamApiKeyId ? downstreamApiKeyId : null,
|
downstreamApiKeyId,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -604,7 +582,7 @@ export async function handleChatSurfaceRequest(
|
|||||||
billingDetails,
|
billingDetails,
|
||||||
successfulUpstreamPath,
|
successfulUpstreamPath,
|
||||||
clientContext,
|
clientContext,
|
||||||
logDownstreamApiKeyId ? downstreamApiKeyId : null,
|
downstreamApiKeyId,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -654,7 +632,7 @@ export async function handleChatSurfaceRequest(
|
|||||||
null,
|
null,
|
||||||
successfulUpstreamPath,
|
successfulUpstreamPath,
|
||||||
clientContext,
|
clientContext,
|
||||||
logDownstreamApiKeyId ? downstreamApiKeyId : null,
|
downstreamApiKeyId,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (shouldRetryProxyRequest(failure.status, failure.reason) && retryCount < MAX_RETRIES) {
|
if (shouldRetryProxyRequest(failure.status, failure.reason) && retryCount < MAX_RETRIES) {
|
||||||
@@ -716,7 +694,7 @@ export async function handleChatSurfaceRequest(
|
|||||||
billingDetails,
|
billingDetails,
|
||||||
successfulUpstreamPath,
|
successfulUpstreamPath,
|
||||||
clientContext,
|
clientContext,
|
||||||
logDownstreamApiKeyId ? downstreamApiKeyId : null,
|
downstreamApiKeyId,
|
||||||
);
|
);
|
||||||
|
|
||||||
return reply.send(downstreamResponse);
|
return reply.send(downstreamResponse);
|
||||||
@@ -738,7 +716,7 @@ export async function handleChatSurfaceRequest(
|
|||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
clientContext,
|
clientContext,
|
||||||
logDownstreamApiKeyId ? downstreamApiKeyId : null,
|
downstreamApiKeyId,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (retryCount < MAX_RETRIES) {
|
if (retryCount < MAX_RETRIES) {
|
||||||
@@ -817,8 +795,6 @@ export async function handleClaudeCountTokensSurfaceRequest(
|
|||||||
});
|
});
|
||||||
const downstreamPolicy = getDownstreamRoutingPolicy(request);
|
const downstreamPolicy = getDownstreamRoutingPolicy(request);
|
||||||
const downstreamApiKeyId = getProxyAuthContext(request)?.keyId ?? null;
|
const downstreamApiKeyId = getProxyAuthContext(request)?.keyId ?? null;
|
||||||
const logDownstreamApiKeyId = downstreamApiKeyId !== null
|
|
||||||
&& await hasProxyLogDownstreamApiKeyIdColumn();
|
|
||||||
const excludeChannelIds: number[] = [];
|
const excludeChannelIds: number[] = [];
|
||||||
let retryCount = 0;
|
let retryCount = 0;
|
||||||
|
|
||||||
@@ -942,7 +918,7 @@ export async function handleClaudeCountTokensSurfaceRequest(
|
|||||||
null,
|
null,
|
||||||
upstreamRequest.path,
|
upstreamRequest.path,
|
||||||
clientContext,
|
clientContext,
|
||||||
logDownstreamApiKeyId ? downstreamApiKeyId : null,
|
downstreamApiKeyId,
|
||||||
);
|
);
|
||||||
if (shouldRetryProxyRequest(upstream.status, typeof payload === 'string' ? payload : text) && retryCount < MAX_RETRIES) {
|
if (shouldRetryProxyRequest(upstream.status, typeof payload === 'string' ? payload : text) && retryCount < MAX_RETRIES) {
|
||||||
retryCount += 1;
|
retryCount += 1;
|
||||||
@@ -969,7 +945,7 @@ export async function handleClaudeCountTokensSurfaceRequest(
|
|||||||
null,
|
null,
|
||||||
upstreamRequest.path,
|
upstreamRequest.path,
|
||||||
clientContext,
|
clientContext,
|
||||||
logDownstreamApiKeyId ? downstreamApiKeyId : null,
|
downstreamApiKeyId,
|
||||||
);
|
);
|
||||||
return reply.code(upstream.status).type(contentType).send(payload);
|
return reply.code(upstream.status).type(contentType).send(payload);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@@ -990,7 +966,7 @@ export async function handleClaudeCountTokensSurfaceRequest(
|
|||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
clientContext,
|
clientContext,
|
||||||
logDownstreamApiKeyId ? downstreamApiKeyId : null,
|
downstreamApiKeyId,
|
||||||
);
|
);
|
||||||
if (retryCount < MAX_RETRIES) {
|
if (retryCount < MAX_RETRIES) {
|
||||||
retryCount += 1;
|
retryCount += 1;
|
||||||
@@ -1036,11 +1012,11 @@ async function logProxy(
|
|||||||
upstreamPath,
|
upstreamPath,
|
||||||
errorMessage,
|
errorMessage,
|
||||||
});
|
});
|
||||||
await db.insert(schema.proxyLogs).values({
|
await insertProxyLog({
|
||||||
routeId: selected.channel.routeId,
|
routeId: selected.channel.routeId,
|
||||||
channelId: selected.channel.id,
|
channelId: selected.channel.id,
|
||||||
accountId: selected.account.id,
|
accountId: selected.account.id,
|
||||||
...(downstreamApiKeyId !== null ? { downstreamApiKeyId } : {}),
|
downstreamApiKeyId,
|
||||||
modelRequested,
|
modelRequested,
|
||||||
modelActual: selected.actualModel,
|
modelActual: selected.actualModel,
|
||||||
status,
|
status,
|
||||||
@@ -1050,11 +1026,15 @@ async function logProxy(
|
|||||||
completionTokens,
|
completionTokens,
|
||||||
totalTokens,
|
totalTokens,
|
||||||
estimatedCost,
|
estimatedCost,
|
||||||
billingDetails: billingDetails ? JSON.stringify(billingDetails) : null,
|
billingDetails,
|
||||||
|
clientFamily: clientContext?.clientKind || null,
|
||||||
|
clientAppId: clientContext?.clientAppId || null,
|
||||||
|
clientAppName: clientContext?.clientAppName || null,
|
||||||
|
clientConfidence: clientContext?.clientConfidence || null,
|
||||||
errorMessage: normalizedErrorMessage,
|
errorMessage: normalizedErrorMessage,
|
||||||
retryCount,
|
retryCount,
|
||||||
createdAt,
|
createdAt,
|
||||||
}).run();
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('[proxy/chat] failed to write proxy log', error);
|
console.warn('[proxy/chat] failed to write proxy log', error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ import {
|
|||||||
wrapGeminiCliRequest,
|
wrapGeminiCliRequest,
|
||||||
} from '../../routes/proxy/geminiCliCompat.js';
|
} from '../../routes/proxy/geminiCliCompat.js';
|
||||||
import { dispatchRuntimeRequest } from '../../routes/proxy/runtimeExecutor.js';
|
import { dispatchRuntimeRequest } from '../../routes/proxy/runtimeExecutor.js';
|
||||||
|
import { detectDownstreamClientContext, type DownstreamClientContext } from '../../routes/proxy/downstreamClientContext.js';
|
||||||
|
import { insertProxyLog } from '../../services/proxyLogStore.js';
|
||||||
|
|
||||||
const MAX_RETRIES = 2;
|
const MAX_RETRIES = 2;
|
||||||
const GEMINI_MODEL_PROBES = [
|
const GEMINI_MODEL_PROBES = [
|
||||||
@@ -203,6 +205,7 @@ async function logProxy(
|
|||||||
retryCount: number,
|
retryCount: number,
|
||||||
downstreamPath: string,
|
downstreamPath: string,
|
||||||
upstreamPath: string | null,
|
upstreamPath: string | null,
|
||||||
|
clientContext: DownstreamClientContext | null = null,
|
||||||
promptTokens = 0,
|
promptTokens = 0,
|
||||||
completionTokens = 0,
|
completionTokens = 0,
|
||||||
totalTokens = 0,
|
totalTokens = 0,
|
||||||
@@ -210,11 +213,16 @@ async function logProxy(
|
|||||||
try {
|
try {
|
||||||
const createdAt = formatUtcSqlDateTime(new Date());
|
const createdAt = formatUtcSqlDateTime(new Date());
|
||||||
const normalizedErrorMessage = composeProxyLogMessage({
|
const normalizedErrorMessage = composeProxyLogMessage({
|
||||||
|
clientKind: clientContext?.clientKind && clientContext.clientKind !== 'generic'
|
||||||
|
? clientContext.clientKind
|
||||||
|
: null,
|
||||||
|
sessionId: clientContext?.sessionId || null,
|
||||||
|
traceHint: clientContext?.traceHint || null,
|
||||||
downstreamPath,
|
downstreamPath,
|
||||||
upstreamPath,
|
upstreamPath,
|
||||||
errorMessage,
|
errorMessage,
|
||||||
});
|
});
|
||||||
await db.insert(schema.proxyLogs).values({
|
await insertProxyLog({
|
||||||
routeId: selected.channel.routeId,
|
routeId: selected.channel.routeId,
|
||||||
channelId: selected.channel.id,
|
channelId: selected.channel.id,
|
||||||
accountId: selected.account.id,
|
accountId: selected.account.id,
|
||||||
@@ -227,11 +235,14 @@ async function logProxy(
|
|||||||
completionTokens,
|
completionTokens,
|
||||||
totalTokens,
|
totalTokens,
|
||||||
estimatedCost: 0,
|
estimatedCost: 0,
|
||||||
billingDetails: null,
|
clientFamily: clientContext?.clientKind || null,
|
||||||
|
clientAppId: clientContext?.clientAppId || null,
|
||||||
|
clientAppName: clientContext?.clientAppName || null,
|
||||||
|
clientConfidence: clientContext?.clientConfidence || null,
|
||||||
errorMessage: normalizedErrorMessage,
|
errorMessage: normalizedErrorMessage,
|
||||||
retryCount,
|
retryCount,
|
||||||
createdAt,
|
createdAt,
|
||||||
}).run();
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('[proxy/gemini] failed to write proxy log', error);
|
console.warn('[proxy/gemini] failed to write proxy log', error);
|
||||||
}
|
}
|
||||||
@@ -355,6 +366,11 @@ export async function geminiProxyRoute(app: FastifyInstance) {
|
|||||||
|
|
||||||
const policy = getDownstreamRoutingPolicy(request);
|
const policy = getDownstreamRoutingPolicy(request);
|
||||||
const downstreamPath = resolveDownstreamPath(request);
|
const downstreamPath = resolveDownstreamPath(request);
|
||||||
|
const clientContext = detectDownstreamClientContext({
|
||||||
|
downstreamPath,
|
||||||
|
headers: request.headers as Record<string, unknown>,
|
||||||
|
body: request.body,
|
||||||
|
});
|
||||||
const excludeChannelIds: number[] = [];
|
const excludeChannelIds: number[] = [];
|
||||||
let retryCount = 0;
|
let retryCount = 0;
|
||||||
let lastStatus = 503;
|
let lastStatus = 503;
|
||||||
@@ -489,6 +505,7 @@ export async function geminiProxyRoute(app: FastifyInstance) {
|
|||||||
retryCount,
|
retryCount,
|
||||||
downstreamPath,
|
downstreamPath,
|
||||||
upstreamPath,
|
upstreamPath,
|
||||||
|
clientContext,
|
||||||
);
|
);
|
||||||
if (retryCount < MAX_RETRIES) {
|
if (retryCount < MAX_RETRIES) {
|
||||||
retryCount += 1;
|
retryCount += 1;
|
||||||
@@ -523,6 +540,7 @@ export async function geminiProxyRoute(app: FastifyInstance) {
|
|||||||
retryCount,
|
retryCount,
|
||||||
downstreamPath,
|
downstreamPath,
|
||||||
upstreamPath,
|
upstreamPath,
|
||||||
|
clientContext,
|
||||||
);
|
);
|
||||||
reply.raw.end();
|
reply.raw.end();
|
||||||
return;
|
return;
|
||||||
@@ -572,6 +590,7 @@ export async function geminiProxyRoute(app: FastifyInstance) {
|
|||||||
retryCount,
|
retryCount,
|
||||||
downstreamPath,
|
downstreamPath,
|
||||||
upstreamPath,
|
upstreamPath,
|
||||||
|
clientContext,
|
||||||
parsedUsage.promptTokens,
|
parsedUsage.promptTokens,
|
||||||
parsedUsage.completionTokens,
|
parsedUsage.completionTokens,
|
||||||
parsedUsage.totalTokens,
|
parsedUsage.totalTokens,
|
||||||
@@ -607,6 +626,7 @@ export async function geminiProxyRoute(app: FastifyInstance) {
|
|||||||
retryCount,
|
retryCount,
|
||||||
downstreamPath,
|
downstreamPath,
|
||||||
upstreamPath,
|
upstreamPath,
|
||||||
|
clientContext,
|
||||||
parsedUsage.promptTokens,
|
parsedUsage.promptTokens,
|
||||||
parsedUsage.completionTokens,
|
parsedUsage.completionTokens,
|
||||||
parsedUsage.totalTokens,
|
parsedUsage.totalTokens,
|
||||||
@@ -629,6 +649,7 @@ export async function geminiProxyRoute(app: FastifyInstance) {
|
|||||||
retryCount,
|
retryCount,
|
||||||
downstreamPath,
|
downstreamPath,
|
||||||
upstreamPath,
|
upstreamPath,
|
||||||
|
clientContext,
|
||||||
);
|
);
|
||||||
return reply.code(upstream.status).type(contentType || 'application/json').send(text);
|
return reply.code(upstream.status).type(contentType || 'application/json').send(text);
|
||||||
}
|
}
|
||||||
@@ -743,6 +764,7 @@ export async function geminiProxyRoute(app: FastifyInstance) {
|
|||||||
retryCount,
|
retryCount,
|
||||||
downstreamPath,
|
downstreamPath,
|
||||||
null,
|
null,
|
||||||
|
clientContext,
|
||||||
);
|
);
|
||||||
if (retryCount < MAX_RETRIES) {
|
if (retryCount < MAX_RETRIES) {
|
||||||
retryCount += 1;
|
retryCount += 1;
|
||||||
@@ -780,6 +802,7 @@ export async function geminiProxyRoute(app: FastifyInstance) {
|
|||||||
retryCount,
|
retryCount,
|
||||||
downstreamPath,
|
downstreamPath,
|
||||||
upstreamPath,
|
upstreamPath,
|
||||||
|
clientContext,
|
||||||
parsedUsage.promptTokens,
|
parsedUsage.promptTokens,
|
||||||
parsedUsage.completionTokens,
|
parsedUsage.completionTokens,
|
||||||
parsedUsage.totalTokens,
|
parsedUsage.totalTokens,
|
||||||
@@ -809,6 +832,7 @@ export async function geminiProxyRoute(app: FastifyInstance) {
|
|||||||
retryCount,
|
retryCount,
|
||||||
downstreamPath,
|
downstreamPath,
|
||||||
upstreamPath || null,
|
upstreamPath || null,
|
||||||
|
clientContext,
|
||||||
);
|
);
|
||||||
if (retryCount < MAX_RETRIES) {
|
if (retryCount < MAX_RETRIES) {
|
||||||
retryCount += 1;
|
retryCount += 1;
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { TextDecoder } from 'node:util';
|
import { TextDecoder } from 'node:util';
|
||||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||||
import { db, hasProxyLogDownstreamApiKeyIdColumn, schema } from '../../db/index.js';
|
|
||||||
import { tokenRouter } from '../../services/tokenRouter.js';
|
import { tokenRouter } from '../../services/tokenRouter.js';
|
||||||
import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js';
|
import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js';
|
||||||
import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js';
|
import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js';
|
||||||
@@ -44,12 +43,12 @@ import {
|
|||||||
unwrapGeminiCliPayload,
|
unwrapGeminiCliPayload,
|
||||||
} from '../../routes/proxy/geminiCliCompat.js';
|
} from '../../routes/proxy/geminiCliCompat.js';
|
||||||
import { isCodexResponsesSurface } from '../cliProfiles/codexProfile.js';
|
import { isCodexResponsesSurface } from '../cliProfiles/codexProfile.js';
|
||||||
import { detectCliProfile } from '../cliProfiles/registry.js';
|
|
||||||
import type { CliProfileId } from '../cliProfiles/types.js';
|
|
||||||
import {
|
import {
|
||||||
summarizeConversationFileInputsInOpenAiBody,
|
summarizeConversationFileInputsInOpenAiBody,
|
||||||
summarizeConversationFileInputsInResponsesBody,
|
summarizeConversationFileInputsInResponsesBody,
|
||||||
} from '../capabilities/conversationFileCapabilities.js';
|
} from '../capabilities/conversationFileCapabilities.js';
|
||||||
|
import { detectDownstreamClientContext, type DownstreamClientContext } from '../../routes/proxy/downstreamClientContext.js';
|
||||||
|
import { insertProxyLog } from '../../services/proxyLogStore.js';
|
||||||
|
|
||||||
const MAX_RETRIES = 2;
|
const MAX_RETRIES = 2;
|
||||||
|
|
||||||
@@ -135,25 +134,6 @@ function carriesResponsesFileUrlInput(value: unknown): boolean {
|
|||||||
|
|
||||||
type UsageSummary = ReturnType<typeof parseProxyUsage>;
|
type UsageSummary = ReturnType<typeof parseProxyUsage>;
|
||||||
|
|
||||||
type DownstreamClientContext = {
|
|
||||||
clientKind: CliProfileId;
|
|
||||||
sessionId?: string;
|
|
||||||
traceHint?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
function detectDownstreamClientContext(input: {
|
|
||||||
downstreamPath: string;
|
|
||||||
headers?: Record<string, unknown>;
|
|
||||||
body?: unknown;
|
|
||||||
}): DownstreamClientContext {
|
|
||||||
const detected = detectCliProfile(input);
|
|
||||||
return {
|
|
||||||
clientKind: detected.id,
|
|
||||||
...(detected.sessionId ? { sessionId: detected.sessionId } : {}),
|
|
||||||
...(detected.traceHint ? { traceHint: detected.traceHint } : {}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function deriveCodexExplicitSessionId(body: Record<string, unknown>): string | null {
|
function deriveCodexExplicitSessionId(body: Record<string, unknown>): string | null {
|
||||||
const promptCacheKey = typeof body.prompt_cache_key === 'string'
|
const promptCacheKey = typeof body.prompt_cache_key === 'string'
|
||||||
? body.prompt_cache_key.trim()
|
? body.prompt_cache_key.trim()
|
||||||
@@ -196,8 +176,6 @@ export async function handleOpenAiResponsesSurfaceRequest(
|
|||||||
if (!await ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return;
|
if (!await ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return;
|
||||||
const downstreamPolicy = getDownstreamRoutingPolicy(request);
|
const downstreamPolicy = getDownstreamRoutingPolicy(request);
|
||||||
const downstreamApiKeyId = getProxyAuthContext(request)?.keyId ?? null;
|
const downstreamApiKeyId = getProxyAuthContext(request)?.keyId ?? null;
|
||||||
const logDownstreamApiKeyId = downstreamApiKeyId !== null
|
|
||||||
&& await hasProxyLogDownstreamApiKeyIdColumn();
|
|
||||||
const excludeChannelIds: number[] = [];
|
const excludeChannelIds: number[] = [];
|
||||||
let retryCount = 0;
|
let retryCount = 0;
|
||||||
|
|
||||||
@@ -397,7 +375,7 @@ export async function handleOpenAiResponsesSurfaceRequest(
|
|||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
clientContext,
|
clientContext,
|
||||||
logDownstreamApiKeyId ? downstreamApiKeyId : null,
|
downstreamApiKeyId,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -423,7 +401,7 @@ export async function handleOpenAiResponsesSurfaceRequest(
|
|||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
clientContext,
|
clientContext,
|
||||||
logDownstreamApiKeyId ? downstreamApiKeyId : null,
|
downstreamApiKeyId,
|
||||||
);
|
);
|
||||||
await recordOauthQuotaResetHint({
|
await recordOauthQuotaResetHint({
|
||||||
accountId: selected.account.id,
|
accountId: selected.account.id,
|
||||||
@@ -505,7 +483,7 @@ export async function handleOpenAiResponsesSurfaceRequest(
|
|||||||
usageForLog.promptTokens, usageForLog.completionTokens, usageForLog.totalTokens, estimatedCost, billingDetails,
|
usageForLog.promptTokens, usageForLog.completionTokens, usageForLog.totalTokens, estimatedCost, billingDetails,
|
||||||
successfulUpstreamPath,
|
successfulUpstreamPath,
|
||||||
clientContext,
|
clientContext,
|
||||||
logDownstreamApiKeyId ? downstreamApiKeyId : null,
|
downstreamApiKeyId,
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[responses] post-stream success logging failed:', error);
|
console.error('[responses] post-stream success logging failed:', error);
|
||||||
@@ -578,7 +556,7 @@ export async function handleOpenAiResponsesSurfaceRequest(
|
|||||||
null,
|
null,
|
||||||
successfulUpstreamPath,
|
successfulUpstreamPath,
|
||||||
clientContext,
|
clientContext,
|
||||||
logDownstreamApiKeyId ? downstreamApiKeyId : null,
|
downstreamApiKeyId,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -617,7 +595,7 @@ export async function handleOpenAiResponsesSurfaceRequest(
|
|||||||
null,
|
null,
|
||||||
successfulUpstreamPath,
|
successfulUpstreamPath,
|
||||||
clientContext,
|
clientContext,
|
||||||
logDownstreamApiKeyId ? downstreamApiKeyId : null,
|
downstreamApiKeyId,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (shouldRetryProxyRequest(failure.status, failure.reason) && retryCount < MAX_RETRIES) {
|
if (shouldRetryProxyRequest(failure.status, failure.reason) && retryCount < MAX_RETRIES) {
|
||||||
@@ -652,7 +630,7 @@ export async function handleOpenAiResponsesSurfaceRequest(
|
|||||||
null,
|
null,
|
||||||
successfulUpstreamPath,
|
successfulUpstreamPath,
|
||||||
clientContext,
|
clientContext,
|
||||||
logDownstreamApiKeyId ? downstreamApiKeyId : null,
|
downstreamApiKeyId,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -708,7 +686,7 @@ export async function handleOpenAiResponsesSurfaceRequest(
|
|||||||
null,
|
null,
|
||||||
successfulUpstreamPath,
|
successfulUpstreamPath,
|
||||||
clientContext,
|
clientContext,
|
||||||
logDownstreamApiKeyId ? downstreamApiKeyId : null,
|
downstreamApiKeyId,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -772,7 +750,7 @@ export async function handleOpenAiResponsesSurfaceRequest(
|
|||||||
null,
|
null,
|
||||||
successfulUpstreamPath,
|
successfulUpstreamPath,
|
||||||
clientContext,
|
clientContext,
|
||||||
logDownstreamApiKeyId ? downstreamApiKeyId : null,
|
downstreamApiKeyId,
|
||||||
);
|
);
|
||||||
if (shouldRetryProxyRequest(failure.status, failure.reason) && retryCount < MAX_RETRIES) {
|
if (shouldRetryProxyRequest(failure.status, failure.reason) && retryCount < MAX_RETRIES) {
|
||||||
retryCount += 1;
|
retryCount += 1;
|
||||||
@@ -826,7 +804,7 @@ export async function handleOpenAiResponsesSurfaceRequest(
|
|||||||
resolvedUsage.promptTokens, resolvedUsage.completionTokens, resolvedUsage.totalTokens, estimatedCost, billingDetails,
|
resolvedUsage.promptTokens, resolvedUsage.completionTokens, resolvedUsage.totalTokens, estimatedCost, billingDetails,
|
||||||
successfulUpstreamPath,
|
successfulUpstreamPath,
|
||||||
clientContext,
|
clientContext,
|
||||||
logDownstreamApiKeyId ? downstreamApiKeyId : null,
|
downstreamApiKeyId,
|
||||||
);
|
);
|
||||||
return reply.send(downstreamData);
|
return reply.send(downstreamData);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
@@ -847,7 +825,7 @@ export async function handleOpenAiResponsesSurfaceRequest(
|
|||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
clientContext,
|
clientContext,
|
||||||
logDownstreamApiKeyId ? downstreamApiKeyId : null,
|
downstreamApiKeyId,
|
||||||
);
|
);
|
||||||
if (retryCount < MAX_RETRIES) {
|
if (retryCount < MAX_RETRIES) {
|
||||||
retryCount += 1;
|
retryCount += 1;
|
||||||
@@ -894,11 +872,11 @@ async function logProxy(
|
|||||||
upstreamPath,
|
upstreamPath,
|
||||||
errorMessage,
|
errorMessage,
|
||||||
});
|
});
|
||||||
await db.insert(schema.proxyLogs).values({
|
await insertProxyLog({
|
||||||
routeId: selected.channel.routeId,
|
routeId: selected.channel.routeId,
|
||||||
channelId: selected.channel.id,
|
channelId: selected.channel.id,
|
||||||
accountId: selected.account.id,
|
accountId: selected.account.id,
|
||||||
...(downstreamApiKeyId !== null ? { downstreamApiKeyId } : {}),
|
downstreamApiKeyId,
|
||||||
modelRequested,
|
modelRequested,
|
||||||
modelActual: selected.actualModel,
|
modelActual: selected.actualModel,
|
||||||
status,
|
status,
|
||||||
@@ -908,11 +886,15 @@ async function logProxy(
|
|||||||
completionTokens,
|
completionTokens,
|
||||||
totalTokens,
|
totalTokens,
|
||||||
estimatedCost,
|
estimatedCost,
|
||||||
billingDetails: billingDetails ? JSON.stringify(billingDetails) : null,
|
billingDetails,
|
||||||
|
clientFamily: clientContext?.clientKind || null,
|
||||||
|
clientAppId: clientContext?.clientAppId || null,
|
||||||
|
clientAppName: clientContext?.clientAppName || null,
|
||||||
|
clientConfidence: clientContext?.clientConfidence || null,
|
||||||
errorMessage: normalizedErrorMessage,
|
errorMessage: normalizedErrorMessage,
|
||||||
retryCount,
|
retryCount,
|
||||||
createdAt,
|
createdAt,
|
||||||
}).run();
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('[proxy/responses] failed to write proxy log', error);
|
console.warn('[proxy/responses] failed to write proxy log', error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,6 +75,10 @@ describe('stats proxy logs routes', () => {
|
|||||||
modelRequested: 'gpt-4o',
|
modelRequested: 'gpt-4o',
|
||||||
modelActual: 'gpt-4o',
|
modelActual: 'gpt-4o',
|
||||||
status: 'success',
|
status: 'success',
|
||||||
|
clientFamily: 'generic',
|
||||||
|
clientAppId: 'cherry_studio',
|
||||||
|
clientAppName: 'Cherry Studio',
|
||||||
|
clientConfidence: 'exact',
|
||||||
promptTokens: 10,
|
promptTokens: 10,
|
||||||
completionTokens: 5,
|
completionTokens: 5,
|
||||||
totalTokens: 15,
|
totalTokens: 15,
|
||||||
@@ -88,6 +92,7 @@ describe('stats proxy logs routes', () => {
|
|||||||
modelRequested: 'gpt-4o-mini',
|
modelRequested: 'gpt-4o-mini',
|
||||||
modelActual: 'gpt-4o-mini',
|
modelActual: 'gpt-4o-mini',
|
||||||
status: 'failed',
|
status: 'failed',
|
||||||
|
clientFamily: 'codex',
|
||||||
promptTokens: 8,
|
promptTokens: 8,
|
||||||
completionTokens: 2,
|
completionTokens: 2,
|
||||||
totalTokens: 10,
|
totalTokens: 10,
|
||||||
@@ -139,6 +144,10 @@ describe('stats proxy logs routes', () => {
|
|||||||
totalCost: number;
|
totalCost: number;
|
||||||
totalTokensAll: number;
|
totalTokensAll: number;
|
||||||
};
|
};
|
||||||
|
clientOptions: Array<{
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
}>;
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(body.page).toBe(2);
|
expect(body.page).toBe(2);
|
||||||
@@ -150,7 +159,14 @@ describe('stats proxy logs routes', () => {
|
|||||||
expect(body.items[0]?.downstreamKeyName).toBe('项目A-Key');
|
expect(body.items[0]?.downstreamKeyName).toBe('项目A-Key');
|
||||||
expect(body.items[0]?.downstreamKeyGroupName).toBe('项目A');
|
expect(body.items[0]?.downstreamKeyGroupName).toBe('项目A');
|
||||||
expect(body.items[0]?.downstreamKeyTags).toEqual(['VIP', '灰度']);
|
expect(body.items[0]?.downstreamKeyTags).toEqual(['VIP', '灰度']);
|
||||||
|
expect(body.items[0]?.clientFamily).toBe('codex');
|
||||||
|
expect(body.items[0]?.clientAppId).toBe(null);
|
||||||
|
expect(body.items[0]?.clientAppName).toBe(null);
|
||||||
|
expect(body.items[0]?.clientConfidence).toBe(null);
|
||||||
expect(body.items[0]).not.toHaveProperty('billingDetails');
|
expect(body.items[0]).not.toHaveProperty('billingDetails');
|
||||||
|
expect(body.clientOptions).toEqual([
|
||||||
|
{ value: 'family:codex', label: '协议 · Codex' },
|
||||||
|
]);
|
||||||
expect(body.summary).toEqual({
|
expect(body.summary).toEqual({
|
||||||
totalCount: 3,
|
totalCount: 3,
|
||||||
successCount: 1,
|
successCount: 1,
|
||||||
@@ -188,6 +204,10 @@ describe('stats proxy logs routes', () => {
|
|||||||
modelRequested: 'gpt-5',
|
modelRequested: 'gpt-5',
|
||||||
modelActual: 'gpt-5',
|
modelActual: 'gpt-5',
|
||||||
status: 'success',
|
status: 'success',
|
||||||
|
clientFamily: 'codex',
|
||||||
|
clientAppId: 'cherry_studio',
|
||||||
|
clientAppName: 'Cherry Studio',
|
||||||
|
clientConfidence: 'exact',
|
||||||
promptTokens: 100,
|
promptTokens: 100,
|
||||||
completionTokens: 20,
|
completionTokens: 20,
|
||||||
totalTokens: 120,
|
totalTokens: 120,
|
||||||
@@ -214,6 +234,10 @@ describe('stats proxy logs routes', () => {
|
|||||||
downstreamKeyName: string | null;
|
downstreamKeyName: string | null;
|
||||||
downstreamKeyGroupName: string | null;
|
downstreamKeyGroupName: string | null;
|
||||||
downstreamKeyTags: string[];
|
downstreamKeyTags: string[];
|
||||||
|
clientFamily: string | null;
|
||||||
|
clientAppId: string | null;
|
||||||
|
clientAppName: string | null;
|
||||||
|
clientConfidence: string | null;
|
||||||
billingDetails: Record<string, unknown> | null;
|
billingDetails: Record<string, unknown> | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -223,6 +247,10 @@ describe('stats proxy logs routes', () => {
|
|||||||
expect(body.downstreamKeyName).toBe('detail-key');
|
expect(body.downstreamKeyName).toBe('detail-key');
|
||||||
expect(body.downstreamKeyGroupName).toBe('测试项目');
|
expect(body.downstreamKeyGroupName).toBe('测试项目');
|
||||||
expect(body.downstreamKeyTags).toEqual(['回归', '日志']);
|
expect(body.downstreamKeyTags).toEqual(['回归', '日志']);
|
||||||
|
expect(body.clientFamily).toBe('codex');
|
||||||
|
expect(body.clientAppId).toBe('cherry_studio');
|
||||||
|
expect(body.clientAppName).toBe('Cherry Studio');
|
||||||
|
expect(body.clientConfidence).toBe('exact');
|
||||||
expect(body.billingDetails).toMatchObject({
|
expect(body.billingDetails).toMatchObject({
|
||||||
breakdown: { totalCost: 0.12 },
|
breakdown: { totalCost: 0.12 },
|
||||||
usage: { promptTokens: 100, completionTokens: 20 },
|
usage: { promptTokens: 100, completionTokens: 20 },
|
||||||
@@ -392,4 +420,118 @@ describe('stats proxy logs routes', () => {
|
|||||||
totalTokensAll: 30,
|
totalTokensAll: 30,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('filters proxy logs by app id while keeping client options scoped only by the other filters', async () => {
|
||||||
|
const site = await db.insert(schema.sites).values({
|
||||||
|
name: 'client-filter-site',
|
||||||
|
url: 'https://client-filter.example.com',
|
||||||
|
platform: 'new-api',
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const account = await db.insert(schema.accounts).values({
|
||||||
|
siteId: site.id,
|
||||||
|
username: 'client-filter-user',
|
||||||
|
accessToken: 'client-filter-token',
|
||||||
|
status: 'active',
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
await db.insert(schema.proxyLogs).values([
|
||||||
|
{
|
||||||
|
accountId: account.id,
|
||||||
|
modelRequested: 'gpt-4o',
|
||||||
|
modelActual: 'gpt-4o',
|
||||||
|
status: 'success',
|
||||||
|
clientFamily: 'generic',
|
||||||
|
clientAppId: 'cherry_studio',
|
||||||
|
clientAppName: 'Cherry Studio',
|
||||||
|
clientConfidence: 'exact',
|
||||||
|
totalTokens: 12,
|
||||||
|
estimatedCost: 0.12,
|
||||||
|
createdAt: formatUtcSqlDateTime(new Date('2026-03-09T11:00:00.000Z')),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accountId: account.id,
|
||||||
|
modelRequested: 'gpt-4.1',
|
||||||
|
modelActual: 'gpt-4.1',
|
||||||
|
status: 'failed',
|
||||||
|
clientFamily: 'codex',
|
||||||
|
totalTokens: 22,
|
||||||
|
estimatedCost: 0.22,
|
||||||
|
createdAt: formatUtcSqlDateTime(new Date('2026-03-09T11:05:00.000Z')),
|
||||||
|
},
|
||||||
|
]).run();
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/stats/proxy-logs?client=app%3Acherry_studio',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
const body = response.json() as {
|
||||||
|
total: number;
|
||||||
|
items: Array<Record<string, unknown>>;
|
||||||
|
clientOptions: Array<{
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(body.total).toBe(1);
|
||||||
|
expect(body.items[0]?.clientAppId).toBe('cherry_studio');
|
||||||
|
expect(body.clientOptions).toEqual([
|
||||||
|
{ value: 'app:cherry_studio', label: '应用 · Cherry Studio' },
|
||||||
|
{ value: 'family:codex', label: '协议 · Codex' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to legacy client prefixes for old logs without inferring an app fingerprint', async () => {
|
||||||
|
const site = await db.insert(schema.sites).values({
|
||||||
|
name: 'legacy-site',
|
||||||
|
url: 'https://legacy.example.com',
|
||||||
|
platform: 'new-api',
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const account = await db.insert(schema.accounts).values({
|
||||||
|
siteId: site.id,
|
||||||
|
username: 'legacy-user',
|
||||||
|
accessToken: 'legacy-token',
|
||||||
|
status: 'active',
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const inserted = await db.insert(schema.proxyLogs).values({
|
||||||
|
accountId: account.id,
|
||||||
|
modelRequested: 'gpt-4o',
|
||||||
|
modelActual: 'gpt-4o',
|
||||||
|
status: 'failed',
|
||||||
|
errorMessage: '[client:codex] [session:turn-123] [downstream:/v1/responses] upstream error',
|
||||||
|
totalTokens: 9,
|
||||||
|
estimatedCost: 0.09,
|
||||||
|
createdAt: formatUtcSqlDateTime(new Date('2026-03-09T12:00:00.000Z')),
|
||||||
|
}).run();
|
||||||
|
|
||||||
|
const logId = Number(inserted.lastInsertRowid || 0);
|
||||||
|
const listResponse = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/stats/proxy-logs',
|
||||||
|
});
|
||||||
|
const detailResponse = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `/api/stats/proxy-logs/${logId}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(listResponse.statusCode).toBe(200);
|
||||||
|
expect(detailResponse.statusCode).toBe(200);
|
||||||
|
|
||||||
|
const listBody = listResponse.json() as {
|
||||||
|
items: Array<Record<string, unknown>>;
|
||||||
|
};
|
||||||
|
const detailBody = detailResponse.json() as Record<string, unknown>;
|
||||||
|
|
||||||
|
expect(listBody.items[0]?.clientFamily).toBe('codex');
|
||||||
|
expect(listBody.items[0]?.clientAppId).toBe(null);
|
||||||
|
expect(listBody.items[0]?.clientAppName).toBe(null);
|
||||||
|
expect(detailBody.clientFamily).toBe('codex');
|
||||||
|
expect(detailBody.clientAppId).toBe(null);
|
||||||
|
expect(detailBody.clientAppName).toBe(null);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
parseProxyLogBillingDetails,
|
parseProxyLogBillingDetails,
|
||||||
withProxyLogSelectFields,
|
withProxyLogSelectFields,
|
||||||
} from '../../services/proxyLogStore.js';
|
} from '../../services/proxyLogStore.js';
|
||||||
|
import { parseProxyLogMessageMeta } from '../proxy/logPathMeta.js';
|
||||||
import { requiresManagedAccountTokens } from '../../services/accountExtraConfig.js';
|
import { requiresManagedAccountTokens } from '../../services/accountExtraConfig.js';
|
||||||
import { ACCOUNT_TOKEN_VALUE_STATUS_READY } from '../../services/accountTokenService.js';
|
import { ACCOUNT_TOKEN_VALUE_STATUS_READY } from '../../services/accountTokenService.js';
|
||||||
import {
|
import {
|
||||||
@@ -85,6 +86,22 @@ function proxyCostSqlExpression() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ProxyLogStatusFilter = 'all' | 'success' | 'failed';
|
type ProxyLogStatusFilter = 'all' | 'success' | 'failed';
|
||||||
|
type ProxyLogClientFilter = {
|
||||||
|
kind: 'app' | 'family';
|
||||||
|
value: string;
|
||||||
|
} | null;
|
||||||
|
|
||||||
|
type ProxyLogClientOption = {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const PROXY_LOG_CLIENT_FAMILY_LABELS: Record<string, string> = {
|
||||||
|
codex: 'Codex',
|
||||||
|
claude_code: 'Claude Code',
|
||||||
|
gemini_cli: 'Gemini CLI',
|
||||||
|
generic: '通用',
|
||||||
|
};
|
||||||
|
|
||||||
function normalizeProxyLogPageSize(raw?: string): number {
|
function normalizeProxyLogPageSize(raw?: string): number {
|
||||||
const parsed = Number.parseInt(raw || '50', 10);
|
const parsed = Number.parseInt(raw || '50', 10);
|
||||||
@@ -115,6 +132,20 @@ function normalizeProxyLogSiteId(raw?: string): number | null {
|
|||||||
return parsed;
|
return parsed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeProxyLogClientFilter(raw?: string): ProxyLogClientFilter {
|
||||||
|
const text = (raw || '').trim();
|
||||||
|
if (!text) return null;
|
||||||
|
const separatorIndex = text.indexOf(':');
|
||||||
|
if (separatorIndex <= 0) return null;
|
||||||
|
const kind = text.slice(0, separatorIndex).trim().toLowerCase();
|
||||||
|
const value = text.slice(separatorIndex + 1).trim().toLowerCase();
|
||||||
|
if (!value) return null;
|
||||||
|
if (kind === 'app' || kind === 'family') {
|
||||||
|
return { kind, value };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeProxyLogTimeBoundary(raw?: string): string | null {
|
function normalizeProxyLogTimeBoundary(raw?: string): string | null {
|
||||||
const text = (raw || '').trim();
|
const text = (raw || '').trim();
|
||||||
if (!text) return null;
|
if (!text) return null;
|
||||||
@@ -166,9 +197,18 @@ function buildProxyLogStatusCondition(status: ProxyLogStatusFilter) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildProxyLogClientCondition(client: ProxyLogClientFilter) {
|
||||||
|
if (!client) return null;
|
||||||
|
if (client.kind === 'app') {
|
||||||
|
return eq(schema.proxyLogs.clientAppId, client.value);
|
||||||
|
}
|
||||||
|
return eq(schema.proxyLogs.clientFamily, client.value);
|
||||||
|
}
|
||||||
|
|
||||||
function buildProxyLogWhereClause(params: {
|
function buildProxyLogWhereClause(params: {
|
||||||
status?: ProxyLogStatusFilter;
|
status?: ProxyLogStatusFilter;
|
||||||
search?: string;
|
search?: string;
|
||||||
|
client?: ProxyLogClientFilter;
|
||||||
siteId?: number | null;
|
siteId?: number | null;
|
||||||
fromUtc?: string | null;
|
fromUtc?: string | null;
|
||||||
toUtc?: string | null;
|
toUtc?: string | null;
|
||||||
@@ -176,6 +216,7 @@ function buildProxyLogWhereClause(params: {
|
|||||||
const conditions = [
|
const conditions = [
|
||||||
params.status ? buildProxyLogStatusCondition(params.status) : null,
|
params.status ? buildProxyLogStatusCondition(params.status) : null,
|
||||||
params.search ? buildProxyLogSearchCondition(params.search) : null,
|
params.search ? buildProxyLogSearchCondition(params.search) : null,
|
||||||
|
params.client ? buildProxyLogClientCondition(params.client) : null,
|
||||||
params.siteId ? eq(schema.sites.id, params.siteId) : null,
|
params.siteId ? eq(schema.sites.id, params.siteId) : null,
|
||||||
params.fromUtc ? gte(schema.proxyLogs.createdAt, params.fromUtc) : null,
|
params.fromUtc ? gte(schema.proxyLogs.createdAt, params.fromUtc) : null,
|
||||||
params.toUtc ? lt(schema.proxyLogs.createdAt, params.toUtc) : null,
|
params.toUtc ? lt(schema.proxyLogs.createdAt, params.toUtc) : null,
|
||||||
@@ -189,6 +230,83 @@ function toRoundedMicroNumber(value: number | null | undefined): number {
|
|||||||
return Math.round(Number(value || 0) * 1_000_000) / 1_000_000;
|
return Math.round(Number(value || 0) * 1_000_000) / 1_000_000;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeNullableText(value: unknown): string | null {
|
||||||
|
if (typeof value !== 'string') return null;
|
||||||
|
const trimmed = value.trim();
|
||||||
|
return trimmed || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeClientConfidence(value: unknown): string | null {
|
||||||
|
const normalized = normalizeNullableText(value)?.toLowerCase() || null;
|
||||||
|
if (normalized === 'exact' || normalized === 'heuristic' || normalized === 'unknown') {
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayProxyLogClientFamily(value: string | null): string | null {
|
||||||
|
if (!value) return null;
|
||||||
|
return PROXY_LOG_CLIENT_FAMILY_LABELS[value] || value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveProxyLogClientMeta(proxyLog: Record<string, unknown>) {
|
||||||
|
const clientFamily = normalizeNullableText(proxyLog.clientFamily)?.toLowerCase() || null;
|
||||||
|
const clientAppId = normalizeNullableText(proxyLog.clientAppId)?.toLowerCase() || null;
|
||||||
|
const clientAppName = normalizeNullableText(proxyLog.clientAppName) || null;
|
||||||
|
const clientConfidence = normalizeClientConfidence(proxyLog.clientConfidence);
|
||||||
|
|
||||||
|
if (clientFamily || clientAppId || clientAppName || clientConfidence) {
|
||||||
|
return {
|
||||||
|
clientFamily,
|
||||||
|
clientAppId,
|
||||||
|
clientAppName,
|
||||||
|
clientConfidence,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const legacyMeta = parseProxyLogMessageMeta(typeof proxyLog.errorMessage === 'string' ? proxyLog.errorMessage : '');
|
||||||
|
return {
|
||||||
|
clientFamily: normalizeNullableText(legacyMeta.clientKind)?.toLowerCase() || null,
|
||||||
|
clientAppId: null,
|
||||||
|
clientAppName: null,
|
||||||
|
clientConfidence: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildProxyLogClientOptions(rows: Array<{
|
||||||
|
clientFamily?: string | null;
|
||||||
|
clientAppId?: string | null;
|
||||||
|
clientAppName?: string | null;
|
||||||
|
}>): ProxyLogClientOption[] {
|
||||||
|
const appOptions = new Map<string, ProxyLogClientOption>();
|
||||||
|
const familyOptions = new Map<string, ProxyLogClientOption>();
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
const clientAppId = normalizeNullableText(row.clientAppId)?.toLowerCase() || null;
|
||||||
|
const clientAppName = normalizeNullableText(row.clientAppName) || null;
|
||||||
|
const clientFamily = normalizeNullableText(row.clientFamily)?.toLowerCase() || null;
|
||||||
|
|
||||||
|
if (clientAppId && clientAppName && !appOptions.has(clientAppId)) {
|
||||||
|
appOptions.set(clientAppId, {
|
||||||
|
value: `app:${clientAppId}`,
|
||||||
|
label: `应用 · ${clientAppName}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (clientFamily && clientFamily !== 'generic' && !familyOptions.has(clientFamily)) {
|
||||||
|
familyOptions.set(clientFamily, {
|
||||||
|
value: `family:${clientFamily}`,
|
||||||
|
label: `协议 · ${displayProxyLogClientFamily(clientFamily) || clientFamily}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
...Array.from(appOptions.values()).sort((left, right) => left.label.localeCompare(right.label, 'zh-CN')),
|
||||||
|
...Array.from(familyOptions.values()).sort((left, right) => left.label.localeCompare(right.label, 'zh-CN')),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
const SITE_AVAILABILITY_BUCKET_COUNT = 24;
|
const SITE_AVAILABILITY_BUCKET_COUNT = 24;
|
||||||
const SITE_AVAILABILITY_BUCKET_MS = 60 * 60 * 1000;
|
const SITE_AVAILABILITY_BUCKET_MS = 60 * 60 * 1000;
|
||||||
|
|
||||||
@@ -351,11 +469,16 @@ function mapProxyLogRow(
|
|||||||
},
|
},
|
||||||
options?: { includeBillingDetails?: boolean },
|
options?: { includeBillingDetails?: boolean },
|
||||||
) {
|
) {
|
||||||
|
const clientMeta = resolveProxyLogClientMeta(row.proxy_logs);
|
||||||
return {
|
return {
|
||||||
...row.proxy_logs,
|
...row.proxy_logs,
|
||||||
...(options?.includeBillingDetails
|
...(options?.includeBillingDetails
|
||||||
? { billingDetails: parseProxyLogBillingDetails(row.proxy_logs.billingDetails) }
|
? { billingDetails: parseProxyLogBillingDetails(row.proxy_logs.billingDetails) }
|
||||||
: {}),
|
: {}),
|
||||||
|
clientFamily: clientMeta.clientFamily,
|
||||||
|
clientAppId: clientMeta.clientAppId,
|
||||||
|
clientAppName: clientMeta.clientAppName,
|
||||||
|
clientConfidence: clientMeta.clientConfidence,
|
||||||
username: row.accounts?.username || null,
|
username: row.accounts?.username || null,
|
||||||
siteId: row.sites?.id || null,
|
siteId: row.sites?.id || null,
|
||||||
siteName: row.sites?.name || null,
|
siteName: row.sites?.name || null,
|
||||||
@@ -547,6 +670,7 @@ export async function statsRoutes(app: FastifyInstance) {
|
|||||||
offset?: string;
|
offset?: string;
|
||||||
status?: string;
|
status?: string;
|
||||||
search?: string;
|
search?: string;
|
||||||
|
client?: string;
|
||||||
siteId?: string;
|
siteId?: string;
|
||||||
from?: string;
|
from?: string;
|
||||||
to?: string;
|
to?: string;
|
||||||
@@ -555,11 +679,13 @@ export async function statsRoutes(app: FastifyInstance) {
|
|||||||
const offset = normalizeProxyLogOffset(request.query.offset);
|
const offset = normalizeProxyLogOffset(request.query.offset);
|
||||||
const status = normalizeProxyLogStatusFilter(request.query.status);
|
const status = normalizeProxyLogStatusFilter(request.query.status);
|
||||||
const search = normalizeProxyLogSearch(request.query.search);
|
const search = normalizeProxyLogSearch(request.query.search);
|
||||||
|
const client = normalizeProxyLogClientFilter(request.query.client);
|
||||||
const siteId = normalizeProxyLogSiteId(request.query.siteId);
|
const siteId = normalizeProxyLogSiteId(request.query.siteId);
|
||||||
const fromUtc = normalizeProxyLogTimeBoundary(request.query.from);
|
const fromUtc = normalizeProxyLogTimeBoundary(request.query.from);
|
||||||
const toUtc = normalizeProxyLogTimeBoundary(request.query.to);
|
const toUtc = normalizeProxyLogTimeBoundary(request.query.to);
|
||||||
const listWhere = buildProxyLogWhereClause({ status, search, siteId, fromUtc, toUtc });
|
const listWhere = buildProxyLogWhereClause({ status, search, client, siteId, fromUtc, toUtc });
|
||||||
const summaryWhere = buildProxyLogWhereClause({ search, siteId, fromUtc, toUtc });
|
const summaryWhere = buildProxyLogWhereClause({ search, client, siteId, fromUtc, toUtc });
|
||||||
|
const clientOptionsWhere = buildProxyLogWhereClause({ status, search, siteId, fromUtc, toUtc });
|
||||||
|
|
||||||
const listRows = await withProxyLogSelectFields(({ fields }) => {
|
const listRows = await withProxyLogSelectFields(({ fields }) => {
|
||||||
let query = db.select({
|
let query = db.select({
|
||||||
@@ -604,6 +730,31 @@ export async function statsRoutes(app: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
const totalRow = await totalQuery.get();
|
const totalRow = await totalQuery.get();
|
||||||
|
|
||||||
|
const clientOptionRows = await withProxyLogSelectFields(({ fields, includeClientFields }) => {
|
||||||
|
if (!includeClientFields) {
|
||||||
|
return Promise.resolve([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
let query = db.select({
|
||||||
|
clientFamily: fields.clientFamily!,
|
||||||
|
clientAppId: fields.clientAppId!,
|
||||||
|
clientAppName: fields.clientAppName!,
|
||||||
|
}).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));
|
||||||
|
|
||||||
|
if (clientOptionsWhere) {
|
||||||
|
query = query.where(clientOptionsWhere) as typeof query;
|
||||||
|
}
|
||||||
|
|
||||||
|
return query.all();
|
||||||
|
}, { includeBillingDetails: false, includeClientFields: true }) as Array<{
|
||||||
|
clientFamily?: string | null;
|
||||||
|
clientAppId?: string | null;
|
||||||
|
clientAppName?: string | null;
|
||||||
|
}>;
|
||||||
|
|
||||||
let summaryQuery = db.select({
|
let summaryQuery = db.select({
|
||||||
totalCount: sql<number>`count(*)`,
|
totalCount: sql<number>`count(*)`,
|
||||||
successCount: sql<number>`coalesce(sum(case when ${schema.proxyLogs.status} = 'success' then 1 else 0 end), 0)`,
|
successCount: sql<number>`coalesce(sum(case when ${schema.proxyLogs.status} = 'success' then 1 else 0 end), 0)`,
|
||||||
@@ -624,6 +775,7 @@ export async function statsRoutes(app: FastifyInstance) {
|
|||||||
total: Number(totalRow?.total || 0),
|
total: Number(totalRow?.total || 0),
|
||||||
page: Math.floor(offset / limit) + 1,
|
page: Math.floor(offset / limit) + 1,
|
||||||
pageSize: limit,
|
pageSize: limit,
|
||||||
|
clientOptions: buildProxyLogClientOptions(clientOptionRows),
|
||||||
summary: {
|
summary: {
|
||||||
totalCount: Number(summaryRow?.totalCount || 0),
|
totalCount: Number(summaryRow?.totalCount || 0),
|
||||||
successCount: Number(summaryRow?.successCount || 0),
|
successCount: Number(summaryRow?.successCount || 0),
|
||||||
|
|||||||
@@ -64,6 +64,8 @@ vi.mock('../../db/index.js', () => ({
|
|||||||
db: {
|
db: {
|
||||||
insert: (arg: any) => dbInsertMock(arg),
|
insert: (arg: any) => dbInsertMock(arg),
|
||||||
},
|
},
|
||||||
|
hasProxyLogBillingDetailsColumn: async () => false,
|
||||||
|
hasProxyLogClientColumns: async () => false,
|
||||||
hasProxyLogDownstreamApiKeyIdColumn: async () => false,
|
hasProxyLogDownstreamApiKeyIdColumn: async () => false,
|
||||||
schema: {
|
schema: {
|
||||||
proxyLogs: {},
|
proxyLogs: {},
|
||||||
|
|||||||
@@ -68,6 +68,8 @@ vi.mock('../../db/index.js', () => ({
|
|||||||
db: {
|
db: {
|
||||||
insert: (arg: any) => dbInsertMock(arg),
|
insert: (arg: any) => dbInsertMock(arg),
|
||||||
},
|
},
|
||||||
|
hasProxyLogBillingDetailsColumn: async () => false,
|
||||||
|
hasProxyLogClientColumns: async () => false,
|
||||||
hasProxyLogDownstreamApiKeyIdColumn: async () => false,
|
hasProxyLogDownstreamApiKeyIdColumn: async () => false,
|
||||||
schema: {
|
schema: {
|
||||||
proxyLogs: {},
|
proxyLogs: {},
|
||||||
|
|||||||
@@ -68,6 +68,9 @@ vi.mock('../../db/index.js', () => ({
|
|||||||
db: {
|
db: {
|
||||||
insert: (arg: any) => dbInsertMock(arg),
|
insert: (arg: any) => dbInsertMock(arg),
|
||||||
},
|
},
|
||||||
|
hasProxyLogBillingDetailsColumn: async () => false,
|
||||||
|
hasProxyLogClientColumns: async () => false,
|
||||||
|
hasProxyLogDownstreamApiKeyIdColumn: async () => false,
|
||||||
schema: {
|
schema: {
|
||||||
proxyLogs: {},
|
proxyLogs: {},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||||
import { fetch } from 'undici';
|
import { fetch } from 'undici';
|
||||||
import { db, hasProxyLogDownstreamApiKeyIdColumn, schema } from '../../db/index.js';
|
|
||||||
import { tokenRouter } from '../../services/tokenRouter.js';
|
import { tokenRouter } from '../../services/tokenRouter.js';
|
||||||
import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js';
|
import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js';
|
||||||
import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js';
|
import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js';
|
||||||
@@ -14,8 +13,10 @@ import { composeProxyLogMessage } from './logPathMeta.js';
|
|||||||
import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
|
import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
|
||||||
import { detectProxyFailure } from './proxyFailureJudge.js';
|
import { detectProxyFailure } from './proxyFailureJudge.js';
|
||||||
import { resolveProxyLogBilling } from './proxyBilling.js';
|
import { resolveProxyLogBilling } from './proxyBilling.js';
|
||||||
import { getProxyAuthContext } from '../../middleware/auth.js';
|
import { getProxyAuthContext } from '../../middleware/auth.js';
|
||||||
import { buildUpstreamUrl } from './upstreamUrl.js';
|
import { buildUpstreamUrl } from './upstreamUrl.js';
|
||||||
|
import { detectDownstreamClientContext, type DownstreamClientContext } from './downstreamClientContext.js';
|
||||||
|
import { insertProxyLog } from '../../services/proxyLogStore.js';
|
||||||
|
|
||||||
const MAX_RETRIES = 2;
|
const MAX_RETRIES = 2;
|
||||||
|
|
||||||
@@ -25,12 +26,16 @@ export async function completionsProxyRoute(app: FastifyInstance) {
|
|||||||
const requestedModel = body?.model;
|
const requestedModel = body?.model;
|
||||||
if (!requestedModel) {
|
if (!requestedModel) {
|
||||||
return reply.code(400).send({ error: { message: 'model is required', type: 'invalid_request_error' } });
|
return reply.code(400).send({ error: { message: 'model is required', type: 'invalid_request_error' } });
|
||||||
}
|
}
|
||||||
if (!await ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return;
|
if (!await ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return;
|
||||||
const downstreamPolicy = getDownstreamRoutingPolicy(request);
|
const downstreamPolicy = getDownstreamRoutingPolicy(request);
|
||||||
const downstreamApiKeyId = getProxyAuthContext(request)?.keyId ?? null;
|
const downstreamApiKeyId = getProxyAuthContext(request)?.keyId ?? null;
|
||||||
const logDownstreamApiKeyId = downstreamApiKeyId !== null
|
const downstreamPath = '/v1/completions';
|
||||||
&& await hasProxyLogDownstreamApiKeyIdColumn();
|
const clientContext = detectDownstreamClientContext({
|
||||||
|
downstreamPath,
|
||||||
|
headers: request.headers as Record<string, unknown>,
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
|
||||||
const isStream = body.stream === true;
|
const isStream = body.stream === true;
|
||||||
const excludeChannelIds: number[] = [];
|
const excludeChannelIds: number[] = [];
|
||||||
@@ -75,7 +80,7 @@ export async function completionsProxyRoute(app: FastifyInstance) {
|
|||||||
if (!upstream.ok) {
|
if (!upstream.ok) {
|
||||||
const errText = await upstream.text().catch(() => 'unknown error');
|
const errText = await upstream.text().catch(() => 'unknown error');
|
||||||
tokenRouter.recordFailure(selected.channel.id);
|
tokenRouter.recordFailure(selected.channel.id);
|
||||||
logProxy(selected, requestedModel, 'failed', upstream.status, Date.now() - startTime, errText, retryCount, logDownstreamApiKeyId ? downstreamApiKeyId : null);
|
logProxy(selected, requestedModel, 'failed', upstream.status, Date.now() - startTime, errText, retryCount, downstreamApiKeyId, 0, 0, 0, 0, null, clientContext, downstreamPath);
|
||||||
|
|
||||||
if (isTokenExpiredError({ status: upstream.status, message: errText })) {
|
if (isTokenExpiredError({ status: upstream.status, message: errText })) {
|
||||||
await reportTokenExpired({
|
await reportTokenExpired({
|
||||||
@@ -173,12 +178,12 @@ export async function completionsProxyRoute(app: FastifyInstance) {
|
|||||||
parsedUsage,
|
parsedUsage,
|
||||||
resolvedUsage,
|
resolvedUsage,
|
||||||
});
|
});
|
||||||
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
|
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
|
||||||
recordDownstreamCostUsage(request, estimatedCost);
|
recordDownstreamCostUsage(request, estimatedCost);
|
||||||
logProxy(
|
logProxy(
|
||||||
selected, requestedModel, 'success', 200, latency, null, retryCount, logDownstreamApiKeyId ? downstreamApiKeyId : null,
|
selected, requestedModel, 'success', 200, latency, null, retryCount, downstreamApiKeyId,
|
||||||
resolvedUsage.promptTokens, resolvedUsage.completionTokens, resolvedUsage.totalTokens, estimatedCost, billingDetails,
|
resolvedUsage.promptTokens, resolvedUsage.completionTokens, resolvedUsage.totalTokens, estimatedCost, billingDetails, clientContext, downstreamPath,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -199,12 +204,19 @@ export async function completionsProxyRoute(app: FastifyInstance) {
|
|||||||
selected,
|
selected,
|
||||||
requestedModel,
|
requestedModel,
|
||||||
'failed',
|
'failed',
|
||||||
failure.status,
|
failure.status,
|
||||||
latency,
|
latency,
|
||||||
errText,
|
errText,
|
||||||
retryCount,
|
retryCount,
|
||||||
logDownstreamApiKeyId ? downstreamApiKeyId : null,
|
downstreamApiKeyId,
|
||||||
);
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
null,
|
||||||
|
clientContext,
|
||||||
|
downstreamPath,
|
||||||
|
);
|
||||||
|
|
||||||
if (shouldRetryProxyRequest(failure.status, errText) && retryCount < MAX_RETRIES) {
|
if (shouldRetryProxyRequest(failure.status, errText) && retryCount < MAX_RETRIES) {
|
||||||
retryCount += 1;
|
retryCount += 1;
|
||||||
@@ -244,16 +256,16 @@ export async function completionsProxyRoute(app: FastifyInstance) {
|
|||||||
resolvedUsage,
|
resolvedUsage,
|
||||||
});
|
});
|
||||||
|
|
||||||
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
|
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
|
||||||
recordDownstreamCostUsage(request, estimatedCost);
|
recordDownstreamCostUsage(request, estimatedCost);
|
||||||
logProxy(
|
logProxy(
|
||||||
selected, requestedModel, 'success', 200, latency, null, retryCount, logDownstreamApiKeyId ? downstreamApiKeyId : null,
|
selected, requestedModel, 'success', 200, latency, null, retryCount, downstreamApiKeyId,
|
||||||
resolvedUsage.promptTokens, resolvedUsage.completionTokens, resolvedUsage.totalTokens, estimatedCost, billingDetails,
|
resolvedUsage.promptTokens, resolvedUsage.completionTokens, resolvedUsage.totalTokens, estimatedCost, billingDetails, clientContext, downstreamPath,
|
||||||
);
|
);
|
||||||
return reply.send(data);
|
return reply.send(data);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
tokenRouter.recordFailure(selected.channel.id);
|
tokenRouter.recordFailure(selected.channel.id);
|
||||||
logProxy(selected, requestedModel, 'failed', 0, Date.now() - startTime, err.message, retryCount, logDownstreamApiKeyId ? downstreamApiKeyId : null);
|
logProxy(selected, requestedModel, 'failed', 0, Date.now() - startTime, err.message, retryCount, downstreamApiKeyId, 0, 0, 0, 0, null, clientContext, downstreamPath);
|
||||||
if (retryCount < MAX_RETRIES) {
|
if (retryCount < MAX_RETRIES) {
|
||||||
retryCount++;
|
retryCount++;
|
||||||
continue;
|
continue;
|
||||||
@@ -270,47 +282,58 @@ export async function completionsProxyRoute(app: FastifyInstance) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function logProxy(
|
async function logProxy(
|
||||||
selected: any,
|
selected: any,
|
||||||
modelRequested: string,
|
modelRequested: string,
|
||||||
status: string,
|
status: string,
|
||||||
httpStatus: number,
|
httpStatus: number,
|
||||||
latencyMs: number,
|
latencyMs: number,
|
||||||
errorMessage: string | null,
|
errorMessage: string | null,
|
||||||
retryCount: number,
|
retryCount: number,
|
||||||
downstreamApiKeyId: number | null = null,
|
downstreamApiKeyId: number | null = null,
|
||||||
promptTokens = 0,
|
promptTokens = 0,
|
||||||
completionTokens = 0,
|
completionTokens = 0,
|
||||||
totalTokens = 0,
|
totalTokens = 0,
|
||||||
estimatedCost = 0,
|
estimatedCost = 0,
|
||||||
billingDetails: unknown = null,
|
billingDetails: unknown = null,
|
||||||
) {
|
clientContext: DownstreamClientContext | null = null,
|
||||||
try {
|
downstreamPath = '/v1/completions',
|
||||||
const createdAt = formatUtcSqlDateTime(new Date());
|
) {
|
||||||
const normalizedErrorMessage = composeProxyLogMessage({
|
try {
|
||||||
downstreamPath: '/v1/completions',
|
const createdAt = formatUtcSqlDateTime(new Date());
|
||||||
errorMessage,
|
const normalizedErrorMessage = composeProxyLogMessage({
|
||||||
});
|
clientKind: clientContext?.clientKind && clientContext.clientKind !== 'generic'
|
||||||
await db.insert(schema.proxyLogs).values({
|
? clientContext.clientKind
|
||||||
routeId: selected.channel.routeId,
|
: null,
|
||||||
channelId: selected.channel.id,
|
sessionId: clientContext?.sessionId || null,
|
||||||
accountId: selected.account.id,
|
traceHint: clientContext?.traceHint || null,
|
||||||
...(downstreamApiKeyId !== null ? { downstreamApiKeyId } : {}),
|
downstreamPath,
|
||||||
modelRequested,
|
errorMessage,
|
||||||
modelActual: selected.actualModel,
|
});
|
||||||
status,
|
await insertProxyLog({
|
||||||
|
routeId: selected.channel.routeId,
|
||||||
|
channelId: selected.channel.id,
|
||||||
|
accountId: selected.account.id,
|
||||||
|
downstreamApiKeyId,
|
||||||
|
modelRequested,
|
||||||
|
modelActual: selected.actualModel,
|
||||||
|
status,
|
||||||
httpStatus,
|
httpStatus,
|
||||||
latencyMs,
|
latencyMs,
|
||||||
promptTokens,
|
promptTokens,
|
||||||
completionTokens,
|
completionTokens,
|
||||||
totalTokens,
|
totalTokens,
|
||||||
estimatedCost,
|
estimatedCost,
|
||||||
billingDetails: billingDetails ? JSON.stringify(billingDetails) : null,
|
billingDetails,
|
||||||
errorMessage: normalizedErrorMessage,
|
clientFamily: clientContext?.clientKind || null,
|
||||||
retryCount,
|
clientAppId: clientContext?.clientAppId || null,
|
||||||
createdAt,
|
clientAppName: clientContext?.clientAppName || null,
|
||||||
}).run();
|
clientConfidence: clientContext?.clientConfidence || null,
|
||||||
} catch (error) {
|
errorMessage: normalizedErrorMessage,
|
||||||
|
retryCount,
|
||||||
|
createdAt,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
console.warn('[proxy/completions] failed to write proxy log', error);
|
console.warn('[proxy/completions] failed to write proxy log', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,6 +71,9 @@ vi.mock('../../db/index.js', () => ({
|
|||||||
schema: {
|
schema: {
|
||||||
proxyLogs: {},
|
proxyLogs: {},
|
||||||
},
|
},
|
||||||
|
hasProxyLogBillingDetailsColumn: async () => false,
|
||||||
|
hasProxyLogClientColumns: async () => false,
|
||||||
|
hasProxyLogDownstreamApiKeyIdColumn: async () => false,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe('downstream client context route logging', () => {
|
describe('downstream client context route logging', () => {
|
||||||
|
|||||||
@@ -130,4 +130,49 @@ describe('detectDownstreamClientContext', () => {
|
|||||||
clientKind: 'gemini_cli',
|
clientKind: 'gemini_cli',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('recognizes app fingerprints alongside a generic protocol family', () => {
|
||||||
|
expect(detectDownstreamClientContext({
|
||||||
|
downstreamPath: '/v1/chat/completions',
|
||||||
|
headers: {
|
||||||
|
'x-title': 'Cherry Studio',
|
||||||
|
'http-referer': 'https://cherry-ai.com',
|
||||||
|
},
|
||||||
|
})).toEqual({
|
||||||
|
clientKind: 'generic',
|
||||||
|
clientAppId: 'cherry_studio',
|
||||||
|
clientAppName: 'Cherry Studio',
|
||||||
|
clientConfidence: 'exact',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps protocol family detection when an app fingerprint also matches', () => {
|
||||||
|
expect(detectDownstreamClientContext({
|
||||||
|
downstreamPath: '/v1/responses',
|
||||||
|
headers: {
|
||||||
|
originator: 'codex_cli_rs',
|
||||||
|
'x-title': 'Cherry Studio',
|
||||||
|
'http-referer': 'https://cherry-ai.com',
|
||||||
|
},
|
||||||
|
})).toEqual({
|
||||||
|
clientKind: 'codex',
|
||||||
|
clientAppId: 'cherry_studio',
|
||||||
|
clientAppName: 'Cherry Studio',
|
||||||
|
clientConfidence: 'exact',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks weak app-only matches as heuristic instead of upgrading protocol behavior', () => {
|
||||||
|
expect(detectDownstreamClientContext({
|
||||||
|
downstreamPath: '/v1/chat/completions',
|
||||||
|
headers: {
|
||||||
|
'user-agent': 'CherryStudio/1.2.3',
|
||||||
|
},
|
||||||
|
})).toEqual({
|
||||||
|
clientKind: 'generic',
|
||||||
|
clientAppId: 'cherry_studio',
|
||||||
|
clientAppName: 'Cherry Studio',
|
||||||
|
clientConfidence: 'heuristic',
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,13 +6,158 @@ import { isCodexResponsesSurface as isCodexResponsesSurfaceViaProfile } from '..
|
|||||||
import type { CliProfileId } from '../../proxy-core/cliProfiles/types.js';
|
import type { CliProfileId } from '../../proxy-core/cliProfiles/types.js';
|
||||||
|
|
||||||
export type DownstreamClientKind = CliProfileId;
|
export type DownstreamClientKind = CliProfileId;
|
||||||
|
export type DownstreamClientConfidence = 'exact' | 'heuristic';
|
||||||
|
|
||||||
export type DownstreamClientContext = {
|
export type DownstreamClientContext = {
|
||||||
clientKind: DownstreamClientKind;
|
clientKind: DownstreamClientKind;
|
||||||
sessionId?: string;
|
sessionId?: string;
|
||||||
traceHint?: string;
|
traceHint?: string;
|
||||||
|
clientAppId?: string;
|
||||||
|
clientAppName?: string;
|
||||||
|
clientConfidence?: DownstreamClientConfidence;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type NormalizedClientHeaders = Record<string, string[]>;
|
||||||
|
|
||||||
|
type DownstreamClientBodySummary = {
|
||||||
|
topLevelKeys: string[];
|
||||||
|
metadataUserId: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DownstreamClientFingerprintInput = {
|
||||||
|
downstreamPath: string;
|
||||||
|
headers: NormalizedClientHeaders;
|
||||||
|
bodySummary: DownstreamClientBodySummary;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DownstreamClientFingerprintRule = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
priority: number;
|
||||||
|
match(input: DownstreamClientFingerprintInput): DownstreamClientConfidence | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeHeaderValues(value: unknown): string[] {
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
return trimmed ? [trimmed] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(value)) return [];
|
||||||
|
return value
|
||||||
|
.filter((item): item is string => typeof item === 'string')
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter((item) => item.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeHeaders(headers?: Record<string, unknown>): NormalizedClientHeaders {
|
||||||
|
if (!headers) return {};
|
||||||
|
|
||||||
|
const normalized: NormalizedClientHeaders = {};
|
||||||
|
for (const [rawKey, rawValue] of Object.entries(headers)) {
|
||||||
|
const key = rawKey.trim().toLowerCase();
|
||||||
|
if (!key) continue;
|
||||||
|
const values = normalizeHeaderValues(rawValue);
|
||||||
|
if (values.length === 0) continue;
|
||||||
|
normalized[key] = normalized[key]
|
||||||
|
? [...normalized[key], ...values]
|
||||||
|
: values;
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function headerEquals(headers: NormalizedClientHeaders, key: string, expected: string): boolean {
|
||||||
|
const normalizedExpected = expected.trim().toLowerCase();
|
||||||
|
return (headers[key.trim().toLowerCase()] || []).some((value) => value.trim().toLowerCase() === normalizedExpected);
|
||||||
|
}
|
||||||
|
|
||||||
|
function headerIncludes(headers: NormalizedClientHeaders, key: string, expectedFragment: string): boolean {
|
||||||
|
const normalizedExpected = expectedFragment.trim().toLowerCase();
|
||||||
|
return (headers[key.trim().toLowerCase()] || []).some((value) => value.trim().toLowerCase().includes(normalizedExpected));
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildBodySummary(body: unknown): DownstreamClientBodySummary {
|
||||||
|
if (!isRecord(body)) {
|
||||||
|
return {
|
||||||
|
topLevelKeys: [],
|
||||||
|
metadataUserId: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const metadataUserId = isRecord(body.metadata) && typeof body.metadata.user_id === 'string'
|
||||||
|
? body.metadata.user_id.trim() || null
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
topLevelKeys: Object.keys(body).sort((left, right) => left.localeCompare(right)),
|
||||||
|
metadataUserId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const appFingerprintRegistry: DownstreamClientFingerprintRule[] = [
|
||||||
|
{
|
||||||
|
id: 'cherry_studio',
|
||||||
|
name: 'Cherry Studio',
|
||||||
|
priority: 100,
|
||||||
|
match(input) {
|
||||||
|
const hasTitle = headerEquals(input.headers, 'x-title', 'Cherry Studio');
|
||||||
|
const hasReferer = headerEquals(input.headers, 'http-referer', 'https://cherry-ai.com')
|
||||||
|
|| headerEquals(input.headers, 'referer', 'https://cherry-ai.com');
|
||||||
|
|
||||||
|
if (hasTitle && hasReferer) {
|
||||||
|
return 'exact';
|
||||||
|
}
|
||||||
|
|
||||||
|
const weakSignals = [
|
||||||
|
headerIncludes(input.headers, 'user-agent', 'cherrystudio'),
|
||||||
|
headerIncludes(input.headers, 'x-title', 'cherry studio'),
|
||||||
|
headerIncludes(input.headers, 'http-referer', 'cherry-ai.com'),
|
||||||
|
headerIncludes(input.headers, 'referer', 'cherry-ai.com'),
|
||||||
|
];
|
||||||
|
|
||||||
|
return weakSignals.some(Boolean) ? 'heuristic' : null;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function detectDownstreamClientFingerprint(input: {
|
||||||
|
downstreamPath: string;
|
||||||
|
headers?: Record<string, unknown>;
|
||||||
|
body?: unknown;
|
||||||
|
}) {
|
||||||
|
const fingerprintInput: DownstreamClientFingerprintInput = {
|
||||||
|
downstreamPath: input.downstreamPath,
|
||||||
|
headers: normalizeHeaders(input.headers),
|
||||||
|
bodySummary: buildBodySummary(input.body),
|
||||||
|
};
|
||||||
|
|
||||||
|
let matchedRule: DownstreamClientFingerprintRule | null = null;
|
||||||
|
let matchedConfidence: DownstreamClientConfidence | null = null;
|
||||||
|
|
||||||
|
for (const rule of appFingerprintRegistry) {
|
||||||
|
const confidence = rule.match(fingerprintInput);
|
||||||
|
if (!confidence) continue;
|
||||||
|
if (!matchedRule || rule.priority > matchedRule.priority) {
|
||||||
|
matchedRule = rule;
|
||||||
|
matchedConfidence = confidence;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!matchedRule || !matchedConfidence) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
clientAppId: matchedRule.id,
|
||||||
|
clientAppName: matchedRule.name,
|
||||||
|
clientConfidence: matchedConfidence,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function isCodexResponsesSurface(headers?: Record<string, unknown>): boolean {
|
export function isCodexResponsesSurface(headers?: Record<string, unknown>): boolean {
|
||||||
return isCodexResponsesSurfaceViaProfile(headers);
|
return isCodexResponsesSurfaceViaProfile(headers);
|
||||||
}
|
}
|
||||||
@@ -27,9 +172,11 @@ export function detectDownstreamClientContext(input: {
|
|||||||
body?: unknown;
|
body?: unknown;
|
||||||
}): DownstreamClientContext {
|
}): DownstreamClientContext {
|
||||||
const detected = detectCliProfile(input);
|
const detected = detectCliProfile(input);
|
||||||
|
const fingerprint = detectDownstreamClientFingerprint(input);
|
||||||
return {
|
return {
|
||||||
clientKind: detected.id,
|
clientKind: detected.id,
|
||||||
...(detected.sessionId ? { sessionId: detected.sessionId } : {}),
|
...(detected.sessionId ? { sessionId: detected.sessionId } : {}),
|
||||||
...(detected.traceHint ? { traceHint: detected.traceHint } : {}),
|
...(detected.traceHint ? { traceHint: detected.traceHint } : {}),
|
||||||
|
...(fingerprint || {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||||
import { fetch } from 'undici';
|
import { fetch } from 'undici';
|
||||||
import { db, hasProxyLogDownstreamApiKeyIdColumn, schema } from '../../db/index.js';
|
|
||||||
import { tokenRouter } from '../../services/tokenRouter.js';
|
import { tokenRouter } from '../../services/tokenRouter.js';
|
||||||
import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js';
|
import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js';
|
||||||
import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js';
|
import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js';
|
||||||
@@ -15,6 +14,8 @@ import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
|
|||||||
import { resolveProxyLogBilling } from './proxyBilling.js';
|
import { resolveProxyLogBilling } from './proxyBilling.js';
|
||||||
import { getProxyAuthContext } from '../../middleware/auth.js';
|
import { getProxyAuthContext } from '../../middleware/auth.js';
|
||||||
import { buildUpstreamUrl } from './upstreamUrl.js';
|
import { buildUpstreamUrl } from './upstreamUrl.js';
|
||||||
|
import { detectDownstreamClientContext, type DownstreamClientContext } from './downstreamClientContext.js';
|
||||||
|
import { insertProxyLog } from '../../services/proxyLogStore.js';
|
||||||
|
|
||||||
const MAX_RETRIES = 2;
|
const MAX_RETRIES = 2;
|
||||||
|
|
||||||
@@ -28,8 +29,12 @@ export async function embeddingsProxyRoute(app: FastifyInstance) {
|
|||||||
if (!await ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return;
|
if (!await ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return;
|
||||||
const downstreamPolicy = getDownstreamRoutingPolicy(request);
|
const downstreamPolicy = getDownstreamRoutingPolicy(request);
|
||||||
const downstreamApiKeyId = getProxyAuthContext(request)?.keyId ?? null;
|
const downstreamApiKeyId = getProxyAuthContext(request)?.keyId ?? null;
|
||||||
const logDownstreamApiKeyId = downstreamApiKeyId !== null
|
const downstreamPath = '/v1/embeddings';
|
||||||
&& await hasProxyLogDownstreamApiKeyIdColumn();
|
const clientContext = detectDownstreamClientContext({
|
||||||
|
downstreamPath,
|
||||||
|
headers: request.headers as Record<string, unknown>,
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
|
||||||
const excludeChannelIds: number[] = [];
|
const excludeChannelIds: number[] = [];
|
||||||
let retryCount = 0;
|
let retryCount = 0;
|
||||||
@@ -71,7 +76,7 @@ export async function embeddingsProxyRoute(app: FastifyInstance) {
|
|||||||
const text = await upstream.text();
|
const text = await upstream.text();
|
||||||
if (!upstream.ok) {
|
if (!upstream.ok) {
|
||||||
tokenRouter.recordFailure(selected.channel.id);
|
tokenRouter.recordFailure(selected.channel.id);
|
||||||
logProxy(selected, requestedModel, 'failed', upstream.status, Date.now() - startTime, text, retryCount, logDownstreamApiKeyId ? downstreamApiKeyId : null);
|
logProxy(selected, requestedModel, 'failed', upstream.status, Date.now() - startTime, text, retryCount, downstreamApiKeyId, 0, 0, 0, 0, null, clientContext, downstreamPath);
|
||||||
|
|
||||||
if (isTokenExpiredError({ status: upstream.status, message: text })) {
|
if (isTokenExpiredError({ status: upstream.status, message: text })) {
|
||||||
await reportTokenExpired({
|
await reportTokenExpired({
|
||||||
@@ -124,13 +129,13 @@ export async function embeddingsProxyRoute(app: FastifyInstance) {
|
|||||||
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
|
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
|
||||||
recordDownstreamCostUsage(request, estimatedCost);
|
recordDownstreamCostUsage(request, estimatedCost);
|
||||||
logProxy(
|
logProxy(
|
||||||
selected, requestedModel, 'success', upstream.status, latency, null, retryCount, logDownstreamApiKeyId ? downstreamApiKeyId : null,
|
selected, requestedModel, 'success', upstream.status, latency, null, retryCount, downstreamApiKeyId,
|
||||||
resolvedUsage.promptTokens, resolvedUsage.completionTokens, resolvedUsage.totalTokens, estimatedCost, billingDetails,
|
resolvedUsage.promptTokens, resolvedUsage.completionTokens, resolvedUsage.totalTokens, estimatedCost, billingDetails, clientContext, downstreamPath,
|
||||||
);
|
);
|
||||||
return reply.code(upstream.status).send(data);
|
return reply.code(upstream.status).send(data);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
tokenRouter.recordFailure(selected.channel.id);
|
tokenRouter.recordFailure(selected.channel.id);
|
||||||
logProxy(selected, requestedModel, 'failed', 0, Date.now() - startTime, err.message, retryCount, logDownstreamApiKeyId ? downstreamApiKeyId : null);
|
logProxy(selected, requestedModel, 'failed', 0, Date.now() - startTime, err.message, retryCount, downstreamApiKeyId, 0, 0, 0, 0, null, clientContext, downstreamPath);
|
||||||
if (retryCount < MAX_RETRIES) {
|
if (retryCount < MAX_RETRIES) {
|
||||||
retryCount++;
|
retryCount++;
|
||||||
continue;
|
continue;
|
||||||
@@ -159,18 +164,25 @@ async function logProxy(
|
|||||||
totalTokens = 0,
|
totalTokens = 0,
|
||||||
estimatedCost = 0,
|
estimatedCost = 0,
|
||||||
billingDetails: unknown = null,
|
billingDetails: unknown = null,
|
||||||
|
clientContext: DownstreamClientContext | null = null,
|
||||||
|
downstreamPath = '/v1/embeddings',
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const createdAt = formatUtcSqlDateTime(new Date());
|
const createdAt = formatUtcSqlDateTime(new Date());
|
||||||
const normalizedErrorMessage = composeProxyLogMessage({
|
const normalizedErrorMessage = composeProxyLogMessage({
|
||||||
downstreamPath: '/v1/embeddings',
|
clientKind: clientContext?.clientKind && clientContext.clientKind !== 'generic'
|
||||||
|
? clientContext.clientKind
|
||||||
|
: null,
|
||||||
|
sessionId: clientContext?.sessionId || null,
|
||||||
|
traceHint: clientContext?.traceHint || null,
|
||||||
|
downstreamPath,
|
||||||
errorMessage,
|
errorMessage,
|
||||||
});
|
});
|
||||||
await db.insert(schema.proxyLogs).values({
|
await insertProxyLog({
|
||||||
routeId: selected.channel.routeId,
|
routeId: selected.channel.routeId,
|
||||||
channelId: selected.channel.id,
|
channelId: selected.channel.id,
|
||||||
accountId: selected.account.id,
|
accountId: selected.account.id,
|
||||||
...(downstreamApiKeyId !== null ? { downstreamApiKeyId } : {}),
|
downstreamApiKeyId,
|
||||||
modelRequested,
|
modelRequested,
|
||||||
modelActual: selected.actualModel,
|
modelActual: selected.actualModel,
|
||||||
status,
|
status,
|
||||||
@@ -180,11 +192,15 @@ async function logProxy(
|
|||||||
completionTokens,
|
completionTokens,
|
||||||
totalTokens,
|
totalTokens,
|
||||||
estimatedCost,
|
estimatedCost,
|
||||||
billingDetails: billingDetails ? JSON.stringify(billingDetails) : null,
|
billingDetails,
|
||||||
|
clientFamily: clientContext?.clientKind || null,
|
||||||
|
clientAppId: clientContext?.clientAppId || null,
|
||||||
|
clientAppName: clientContext?.clientAppName || null,
|
||||||
|
clientConfidence: clientContext?.clientConfidence || null,
|
||||||
errorMessage: normalizedErrorMessage,
|
errorMessage: normalizedErrorMessage,
|
||||||
retryCount,
|
retryCount,
|
||||||
createdAt,
|
createdAt,
|
||||||
}).run();
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('[proxy/embeddings] failed to write proxy log', error);
|
console.warn('[proxy/embeddings] failed to write proxy log', error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,6 +74,9 @@ vi.mock('../../db/index.js', () => ({
|
|||||||
select: (..._args: unknown[]) => createDbSelectChain(),
|
select: (..._args: unknown[]) => createDbSelectChain(),
|
||||||
insert: (arg: unknown) => dbInsertMock(arg),
|
insert: (arg: unknown) => dbInsertMock(arg),
|
||||||
},
|
},
|
||||||
|
hasProxyLogBillingDetailsColumn: async () => false,
|
||||||
|
hasProxyLogClientColumns: async () => false,
|
||||||
|
hasProxyLogDownstreamApiKeyIdColumn: async () => false,
|
||||||
schema: {
|
schema: {
|
||||||
proxyLogs: {},
|
proxyLogs: {},
|
||||||
modelAvailability: {
|
modelAvailability: {
|
||||||
|
|||||||
@@ -58,6 +58,9 @@ vi.mock('../../db/index.js', () => ({
|
|||||||
db: {
|
db: {
|
||||||
insert: (arg: any) => dbInsertMock(arg),
|
insert: (arg: any) => dbInsertMock(arg),
|
||||||
},
|
},
|
||||||
|
hasProxyLogBillingDetailsColumn: async () => false,
|
||||||
|
hasProxyLogClientColumns: async () => false,
|
||||||
|
hasProxyLogDownstreamApiKeyIdColumn: async () => false,
|
||||||
schema: {
|
schema: {
|
||||||
proxyLogs: {},
|
proxyLogs: {},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||||
import { fetch } from 'undici';
|
import { fetch } from 'undici';
|
||||||
import { db, hasProxyLogDownstreamApiKeyIdColumn, schema } from '../../db/index.js';
|
import { tokenRouter } from '../../services/tokenRouter.js';
|
||||||
import { tokenRouter } from '../../services/tokenRouter.js';
|
|
||||||
import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js';
|
import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js';
|
||||||
import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js';
|
import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js';
|
||||||
import { isTokenExpiredError } from '../../services/alertRules.js';
|
import { isTokenExpiredError } from '../../services/alertRules.js';
|
||||||
@@ -14,6 +13,8 @@ import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
|
|||||||
import { cloneFormDataWithOverrides, ensureMultipartBufferParser, parseMultipartFormData } from './multipart.js';
|
import { cloneFormDataWithOverrides, ensureMultipartBufferParser, parseMultipartFormData } from './multipart.js';
|
||||||
import { getProxyAuthContext } from '../../middleware/auth.js';
|
import { getProxyAuthContext } from '../../middleware/auth.js';
|
||||||
import { buildUpstreamUrl } from './upstreamUrl.js';
|
import { buildUpstreamUrl } from './upstreamUrl.js';
|
||||||
|
import { detectDownstreamClientContext, type DownstreamClientContext } from './downstreamClientContext.js';
|
||||||
|
import { insertProxyLog } from '../../services/proxyLogStore.js';
|
||||||
|
|
||||||
const MAX_RETRIES = 2;
|
const MAX_RETRIES = 2;
|
||||||
|
|
||||||
@@ -26,8 +27,12 @@ export async function imagesProxyRoute(app: FastifyInstance) {
|
|||||||
if (!await ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return;
|
if (!await ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return;
|
||||||
const downstreamPolicy = getDownstreamRoutingPolicy(request);
|
const downstreamPolicy = getDownstreamRoutingPolicy(request);
|
||||||
const downstreamApiKeyId = getProxyAuthContext(request)?.keyId ?? null;
|
const downstreamApiKeyId = getProxyAuthContext(request)?.keyId ?? null;
|
||||||
const logDownstreamApiKeyId = downstreamApiKeyId !== null
|
const downstreamPath = '/v1/images/generations';
|
||||||
&& await hasProxyLogDownstreamApiKeyIdColumn();
|
const clientContext = detectDownstreamClientContext({
|
||||||
|
downstreamPath,
|
||||||
|
headers: request.headers as Record<string, unknown>,
|
||||||
|
body,
|
||||||
|
});
|
||||||
const excludeChannelIds: number[] = [];
|
const excludeChannelIds: number[] = [];
|
||||||
let retryCount = 0;
|
let retryCount = 0;
|
||||||
|
|
||||||
@@ -70,7 +75,7 @@ export async function imagesProxyRoute(app: FastifyInstance) {
|
|||||||
const text = await upstream.text();
|
const text = await upstream.text();
|
||||||
if (!upstream.ok) {
|
if (!upstream.ok) {
|
||||||
tokenRouter.recordFailure(selected.channel.id);
|
tokenRouter.recordFailure(selected.channel.id);
|
||||||
logProxy(selected, requestedModel, 'failed', upstream.status, Date.now() - startTime, text, retryCount, logDownstreamApiKeyId ? downstreamApiKeyId : null);
|
logProxy(selected, requestedModel, 'failed', upstream.status, Date.now() - startTime, text, retryCount, downstreamApiKeyId, 0, downstreamPath, clientContext);
|
||||||
if (isTokenExpiredError({ status: upstream.status, message: text })) {
|
if (isTokenExpiredError({ status: upstream.status, message: text })) {
|
||||||
await reportTokenExpired({
|
await reportTokenExpired({
|
||||||
accountId: selected.account.id,
|
accountId: selected.account.id,
|
||||||
@@ -104,11 +109,11 @@ export async function imagesProxyRoute(app: FastifyInstance) {
|
|||||||
});
|
});
|
||||||
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
|
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
|
||||||
recordDownstreamCostUsage(request, estimatedCost);
|
recordDownstreamCostUsage(request, estimatedCost);
|
||||||
logProxy(selected, requestedModel, 'success', upstream.status, latency, null, retryCount, logDownstreamApiKeyId ? downstreamApiKeyId : null, estimatedCost);
|
logProxy(selected, requestedModel, 'success', upstream.status, latency, null, retryCount, downstreamApiKeyId, estimatedCost, downstreamPath, clientContext);
|
||||||
return reply.code(upstream.status).send(data);
|
return reply.code(upstream.status).send(data);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
tokenRouter.recordFailure(selected.channel.id);
|
tokenRouter.recordFailure(selected.channel.id);
|
||||||
logProxy(selected, requestedModel, 'failed', 0, Date.now() - startTime, err.message, retryCount, logDownstreamApiKeyId ? downstreamApiKeyId : null);
|
logProxy(selected, requestedModel, 'failed', 0, Date.now() - startTime, err.message, retryCount, downstreamApiKeyId, 0, downstreamPath, clientContext);
|
||||||
if (retryCount < MAX_RETRIES) {
|
if (retryCount < MAX_RETRIES) {
|
||||||
retryCount++;
|
retryCount++;
|
||||||
continue;
|
continue;
|
||||||
@@ -136,8 +141,12 @@ export async function imagesProxyRoute(app: FastifyInstance) {
|
|||||||
if (!await ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return;
|
if (!await ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return;
|
||||||
const downstreamPolicy = getDownstreamRoutingPolicy(request);
|
const downstreamPolicy = getDownstreamRoutingPolicy(request);
|
||||||
const downstreamApiKeyId = getProxyAuthContext(request)?.keyId ?? null;
|
const downstreamApiKeyId = getProxyAuthContext(request)?.keyId ?? null;
|
||||||
const logDownstreamApiKeyId = downstreamApiKeyId !== null
|
const downstreamPath = '/v1/images/edits';
|
||||||
&& await hasProxyLogDownstreamApiKeyIdColumn();
|
const clientContext = detectDownstreamClientContext({
|
||||||
|
downstreamPath,
|
||||||
|
headers: request.headers as Record<string, unknown>,
|
||||||
|
body: jsonBody || Object.fromEntries(multipartForm?.entries?.() || []),
|
||||||
|
});
|
||||||
const excludeChannelIds: number[] = [];
|
const excludeChannelIds: number[] = [];
|
||||||
let retryCount = 0;
|
let retryCount = 0;
|
||||||
|
|
||||||
@@ -192,7 +201,7 @@ export async function imagesProxyRoute(app: FastifyInstance) {
|
|||||||
const text = await upstream.text();
|
const text = await upstream.text();
|
||||||
if (!upstream.ok) {
|
if (!upstream.ok) {
|
||||||
tokenRouter.recordFailure(selected.channel.id);
|
tokenRouter.recordFailure(selected.channel.id);
|
||||||
logProxy(selected, requestedModel, 'failed', upstream.status, Date.now() - startTime, text, retryCount, logDownstreamApiKeyId ? downstreamApiKeyId : null, 0, '/v1/images/edits');
|
logProxy(selected, requestedModel, 'failed', upstream.status, Date.now() - startTime, text, retryCount, downstreamApiKeyId, 0, downstreamPath, clientContext);
|
||||||
if (isTokenExpiredError({ status: upstream.status, message: text })) {
|
if (isTokenExpiredError({ status: upstream.status, message: text })) {
|
||||||
await reportTokenExpired({
|
await reportTokenExpired({
|
||||||
accountId: selected.account.id,
|
accountId: selected.account.id,
|
||||||
@@ -226,11 +235,11 @@ export async function imagesProxyRoute(app: FastifyInstance) {
|
|||||||
});
|
});
|
||||||
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
|
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
|
||||||
recordDownstreamCostUsage(request, estimatedCost);
|
recordDownstreamCostUsage(request, estimatedCost);
|
||||||
logProxy(selected, requestedModel, 'success', upstream.status, latency, null, retryCount, logDownstreamApiKeyId ? downstreamApiKeyId : null, estimatedCost, '/v1/images/edits');
|
logProxy(selected, requestedModel, 'success', upstream.status, latency, null, retryCount, downstreamApiKeyId, estimatedCost, downstreamPath, clientContext);
|
||||||
return reply.code(upstream.status).send(data);
|
return reply.code(upstream.status).send(data);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
tokenRouter.recordFailure(selected.channel.id);
|
tokenRouter.recordFailure(selected.channel.id);
|
||||||
logProxy(selected, requestedModel, 'failed', 0, Date.now() - startTime, err.message, retryCount, logDownstreamApiKeyId ? downstreamApiKeyId : null, 0, '/v1/images/edits');
|
logProxy(selected, requestedModel, 'failed', 0, Date.now() - startTime, err.message, retryCount, downstreamApiKeyId, 0, downstreamPath, clientContext);
|
||||||
if (retryCount < MAX_RETRIES) {
|
if (retryCount < MAX_RETRIES) {
|
||||||
retryCount++;
|
retryCount++;
|
||||||
continue;
|
continue;
|
||||||
@@ -267,18 +276,24 @@ async function logProxy(
|
|||||||
downstreamApiKeyId: number | null = null,
|
downstreamApiKeyId: number | null = null,
|
||||||
estimatedCost = 0,
|
estimatedCost = 0,
|
||||||
downstreamPath = '/v1/images/generations',
|
downstreamPath = '/v1/images/generations',
|
||||||
|
clientContext: DownstreamClientContext | null = null,
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const createdAt = formatUtcSqlDateTime(new Date());
|
const createdAt = formatUtcSqlDateTime(new Date());
|
||||||
const normalizedErrorMessage = composeProxyLogMessage({
|
const normalizedErrorMessage = composeProxyLogMessage({
|
||||||
|
clientKind: clientContext?.clientKind && clientContext.clientKind !== 'generic'
|
||||||
|
? clientContext.clientKind
|
||||||
|
: null,
|
||||||
|
sessionId: clientContext?.sessionId || null,
|
||||||
|
traceHint: clientContext?.traceHint || null,
|
||||||
downstreamPath,
|
downstreamPath,
|
||||||
errorMessage,
|
errorMessage,
|
||||||
});
|
});
|
||||||
await db.insert(schema.proxyLogs).values({
|
await insertProxyLog({
|
||||||
routeId: selected.channel.routeId,
|
routeId: selected.channel.routeId,
|
||||||
channelId: selected.channel.id,
|
channelId: selected.channel.id,
|
||||||
accountId: selected.account.id,
|
accountId: selected.account.id,
|
||||||
...(downstreamApiKeyId !== null ? { downstreamApiKeyId } : {}),
|
downstreamApiKeyId,
|
||||||
modelRequested,
|
modelRequested,
|
||||||
modelActual: selected.actualModel,
|
modelActual: selected.actualModel,
|
||||||
status,
|
status,
|
||||||
@@ -288,10 +303,14 @@ async function logProxy(
|
|||||||
completionTokens: 0,
|
completionTokens: 0,
|
||||||
totalTokens: 0,
|
totalTokens: 0,
|
||||||
estimatedCost,
|
estimatedCost,
|
||||||
|
clientFamily: clientContext?.clientKind || null,
|
||||||
|
clientAppId: clientContext?.clientAppId || null,
|
||||||
|
clientAppName: clientContext?.clientAppName || null,
|
||||||
|
clientConfidence: clientContext?.clientConfidence || null,
|
||||||
errorMessage: normalizedErrorMessage,
|
errorMessage: normalizedErrorMessage,
|
||||||
retryCount,
|
retryCount,
|
||||||
createdAt,
|
createdAt,
|
||||||
}).run();
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('[proxy/images] failed to write proxy log', error);
|
console.warn('[proxy/images] failed to write proxy log', error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ type ComposeProxyLogMessageArgs = {
|
|||||||
errorMessage?: string | null;
|
errorMessage?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ParsedPathMeta = {
|
export type ParsedProxyLogMessageMeta = {
|
||||||
clientKind: string | null;
|
clientKind: string | null;
|
||||||
sessionId: string | null;
|
sessionId: string | null;
|
||||||
downstreamPath: string | null;
|
downstreamPath: string | null;
|
||||||
@@ -15,7 +15,7 @@ type ParsedPathMeta = {
|
|||||||
messageText: string;
|
messageText: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
function parseExistingPathMeta(rawMessage: string): ParsedPathMeta {
|
export function parseProxyLogMessageMeta(rawMessage: string): ParsedProxyLogMessageMeta {
|
||||||
const clientMatch = rawMessage.match(/\[client:([^\]]+)\]/i);
|
const clientMatch = rawMessage.match(/\[client:([^\]]+)\]/i);
|
||||||
const sessionMatch = rawMessage.match(/\[session:([^\]]+)\]/i);
|
const sessionMatch = rawMessage.match(/\[session:([^\]]+)\]/i);
|
||||||
const downstreamMatch = rawMessage.match(/\[downstream:([^\]]+)\]/i);
|
const downstreamMatch = rawMessage.match(/\[downstream:([^\]]+)\]/i);
|
||||||
@@ -42,7 +42,7 @@ export function composeProxyLogMessage({
|
|||||||
errorMessage,
|
errorMessage,
|
||||||
}: ComposeProxyLogMessageArgs): string | null {
|
}: ComposeProxyLogMessageArgs): string | null {
|
||||||
const rawMessage = typeof errorMessage === 'string' ? errorMessage.trim() : '';
|
const rawMessage = typeof errorMessage === 'string' ? errorMessage.trim() : '';
|
||||||
const parsed = parseExistingPathMeta(rawMessage);
|
const parsed = parseProxyLogMessageMeta(rawMessage);
|
||||||
const finalClientKind = (clientKind || parsed.clientKind || '').trim();
|
const finalClientKind = (clientKind || parsed.clientKind || '').trim();
|
||||||
const finalSessionId = (sessionId || traceHint || parsed.sessionId || '').trim();
|
const finalSessionId = (sessionId || traceHint || parsed.sessionId || '').trim();
|
||||||
const finalDownstreamPath = (downstreamPath || parsed.downstreamPath || '').trim();
|
const finalDownstreamPath = (downstreamPath || parsed.downstreamPath || '').trim();
|
||||||
|
|||||||
@@ -80,6 +80,8 @@ vi.mock('../../db/index.js', () => ({
|
|||||||
db: {
|
db: {
|
||||||
insert: (arg: any) => dbInsertMock(arg),
|
insert: (arg: any) => dbInsertMock(arg),
|
||||||
},
|
},
|
||||||
|
hasProxyLogBillingDetailsColumn: async () => false,
|
||||||
|
hasProxyLogClientColumns: async () => false,
|
||||||
hasProxyLogDownstreamApiKeyIdColumn: async () => false,
|
hasProxyLogDownstreamApiKeyIdColumn: async () => false,
|
||||||
schema: {
|
schema: {
|
||||||
proxyLogs: {},
|
proxyLogs: {},
|
||||||
|
|||||||
@@ -67,6 +67,9 @@ vi.mock('../../db/index.js', () => ({
|
|||||||
db: {
|
db: {
|
||||||
insert: (arg: any) => dbInsertMock(arg),
|
insert: (arg: any) => dbInsertMock(arg),
|
||||||
},
|
},
|
||||||
|
hasProxyLogBillingDetailsColumn: async () => false,
|
||||||
|
hasProxyLogClientColumns: async () => false,
|
||||||
|
hasProxyLogDownstreamApiKeyIdColumn: async () => false,
|
||||||
schema: {
|
schema: {
|
||||||
proxyLogs: {},
|
proxyLogs: {},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -73,6 +73,8 @@ vi.mock('../../db/index.js', () => ({
|
|||||||
db: {
|
db: {
|
||||||
insert: (arg: any) => dbInsertMock(arg),
|
insert: (arg: any) => dbInsertMock(arg),
|
||||||
},
|
},
|
||||||
|
hasProxyLogBillingDetailsColumn: async () => false,
|
||||||
|
hasProxyLogClientColumns: async () => false,
|
||||||
hasProxyLogDownstreamApiKeyIdColumn: async () => false,
|
hasProxyLogDownstreamApiKeyIdColumn: async () => false,
|
||||||
schema: {
|
schema: {
|
||||||
proxyLogs: {},
|
proxyLogs: {},
|
||||||
|
|||||||
@@ -54,6 +54,9 @@ vi.mock('../../db/index.js', () => ({
|
|||||||
db: {
|
db: {
|
||||||
insert: (arg: any) => dbInsertMock(arg),
|
insert: (arg: any) => dbInsertMock(arg),
|
||||||
},
|
},
|
||||||
|
hasProxyLogBillingDetailsColumn: async () => false,
|
||||||
|
hasProxyLogClientColumns: async () => false,
|
||||||
|
hasProxyLogDownstreamApiKeyIdColumn: async () => false,
|
||||||
schema: {
|
schema: {
|
||||||
proxyLogs: {},
|
proxyLogs: {},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||||
import { fetch } from 'undici';
|
import { fetch } from 'undici';
|
||||||
import { db, hasProxyLogDownstreamApiKeyIdColumn, schema } from '../../db/index.js';
|
|
||||||
import { tokenRouter } from '../../services/tokenRouter.js';
|
import { tokenRouter } from '../../services/tokenRouter.js';
|
||||||
import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js';
|
import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js';
|
||||||
import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js';
|
import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js';
|
||||||
@@ -12,6 +11,8 @@ import { composeProxyLogMessage } from './logPathMeta.js';
|
|||||||
import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
|
import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
|
||||||
import { getProxyAuthContext } from '../../middleware/auth.js';
|
import { getProxyAuthContext } from '../../middleware/auth.js';
|
||||||
import { buildUpstreamUrl } from './upstreamUrl.js';
|
import { buildUpstreamUrl } from './upstreamUrl.js';
|
||||||
|
import { detectDownstreamClientContext, type DownstreamClientContext } from './downstreamClientContext.js';
|
||||||
|
import { insertProxyLog } from '../../services/proxyLogStore.js';
|
||||||
|
|
||||||
const MAX_RETRIES = 2;
|
const MAX_RETRIES = 2;
|
||||||
const DEFAULT_SEARCH_MODEL = '__search';
|
const DEFAULT_SEARCH_MODEL = '__search';
|
||||||
@@ -54,8 +55,12 @@ export async function searchProxyRoute(app: FastifyInstance) {
|
|||||||
if (!await ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return;
|
if (!await ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return;
|
||||||
const downstreamPolicy = getDownstreamRoutingPolicy(request);
|
const downstreamPolicy = getDownstreamRoutingPolicy(request);
|
||||||
const downstreamApiKeyId = getProxyAuthContext(request)?.keyId ?? null;
|
const downstreamApiKeyId = getProxyAuthContext(request)?.keyId ?? null;
|
||||||
const logDownstreamApiKeyId = downstreamApiKeyId !== null
|
const downstreamPath = '/v1/search';
|
||||||
&& await hasProxyLogDownstreamApiKeyIdColumn();
|
const clientContext = detectDownstreamClientContext({
|
||||||
|
downstreamPath,
|
||||||
|
headers: request.headers as Record<string, unknown>,
|
||||||
|
body,
|
||||||
|
});
|
||||||
const excludeChannelIds: number[] = [];
|
const excludeChannelIds: number[] = [];
|
||||||
let retryCount = 0;
|
let retryCount = 0;
|
||||||
|
|
||||||
@@ -101,7 +106,7 @@ export async function searchProxyRoute(app: FastifyInstance) {
|
|||||||
const text = await upstream.text();
|
const text = await upstream.text();
|
||||||
if (!upstream.ok) {
|
if (!upstream.ok) {
|
||||||
tokenRouter.recordFailure(selected.channel.id);
|
tokenRouter.recordFailure(selected.channel.id);
|
||||||
logProxy(selected, requestedModel, 'failed', upstream.status, Date.now() - startTime, text, retryCount, logDownstreamApiKeyId ? downstreamApiKeyId : null);
|
logProxy(selected, requestedModel, 'failed', upstream.status, Date.now() - startTime, text, retryCount, downstreamApiKeyId, clientContext, downstreamPath);
|
||||||
if (isTokenExpiredError({ status: upstream.status, message: text })) {
|
if (isTokenExpiredError({ status: upstream.status, message: text })) {
|
||||||
await reportTokenExpired({
|
await reportTokenExpired({
|
||||||
accountId: selected.account.id,
|
accountId: selected.account.id,
|
||||||
@@ -127,11 +132,11 @@ export async function searchProxyRoute(app: FastifyInstance) {
|
|||||||
const latency = Date.now() - startTime;
|
const latency = Date.now() - startTime;
|
||||||
tokenRouter.recordSuccess(selected.channel.id, latency, 0);
|
tokenRouter.recordSuccess(selected.channel.id, latency, 0);
|
||||||
recordDownstreamCostUsage(request, 0);
|
recordDownstreamCostUsage(request, 0);
|
||||||
logProxy(selected, requestedModel, 'success', upstream.status, latency, null, retryCount, logDownstreamApiKeyId ? downstreamApiKeyId : null);
|
logProxy(selected, requestedModel, 'success', upstream.status, latency, null, retryCount, downstreamApiKeyId, clientContext, downstreamPath);
|
||||||
return reply.code(upstream.status).send(data);
|
return reply.code(upstream.status).send(data);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
tokenRouter.recordFailure(selected.channel.id);
|
tokenRouter.recordFailure(selected.channel.id);
|
||||||
logProxy(selected, requestedModel, 'failed', 0, Date.now() - startTime, error?.message || 'network error', retryCount, logDownstreamApiKeyId ? downstreamApiKeyId : null);
|
logProxy(selected, requestedModel, 'failed', 0, Date.now() - startTime, error?.message || 'network error', retryCount, downstreamApiKeyId, clientContext, downstreamPath);
|
||||||
if (retryCount < MAX_RETRIES) {
|
if (retryCount < MAX_RETRIES) {
|
||||||
retryCount += 1;
|
retryCount += 1;
|
||||||
continue;
|
continue;
|
||||||
@@ -157,14 +162,16 @@ async function logProxy(
|
|||||||
errorMessage: string | null,
|
errorMessage: string | null,
|
||||||
retryCount: number,
|
retryCount: number,
|
||||||
downstreamApiKeyId: number | null = null,
|
downstreamApiKeyId: number | null = null,
|
||||||
|
clientContext: DownstreamClientContext | null = null,
|
||||||
|
downstreamPath = '/v1/search',
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const createdAt = formatUtcSqlDateTime(new Date());
|
const createdAt = formatUtcSqlDateTime(new Date());
|
||||||
await db.insert(schema.proxyLogs).values({
|
await insertProxyLog({
|
||||||
routeId: selected.channel.routeId,
|
routeId: selected.channel.routeId,
|
||||||
channelId: selected.channel.id,
|
channelId: selected.channel.id,
|
||||||
accountId: selected.account.id,
|
accountId: selected.account.id,
|
||||||
...(downstreamApiKeyId !== null ? { downstreamApiKeyId } : {}),
|
downstreamApiKeyId,
|
||||||
modelRequested,
|
modelRequested,
|
||||||
modelActual: selected.actualModel,
|
modelActual: selected.actualModel,
|
||||||
status,
|
status,
|
||||||
@@ -175,12 +182,21 @@ async function logProxy(
|
|||||||
totalTokens: 0,
|
totalTokens: 0,
|
||||||
estimatedCost: 0,
|
estimatedCost: 0,
|
||||||
errorMessage: composeProxyLogMessage({
|
errorMessage: composeProxyLogMessage({
|
||||||
downstreamPath: '/v1/search',
|
clientKind: clientContext?.clientKind && clientContext.clientKind !== 'generic'
|
||||||
|
? clientContext.clientKind
|
||||||
|
: null,
|
||||||
|
sessionId: clientContext?.sessionId || null,
|
||||||
|
traceHint: clientContext?.traceHint || null,
|
||||||
|
downstreamPath,
|
||||||
errorMessage,
|
errorMessage,
|
||||||
}),
|
}),
|
||||||
|
clientFamily: clientContext?.clientKind || null,
|
||||||
|
clientAppId: clientContext?.clientAppId || null,
|
||||||
|
clientAppName: clientContext?.clientAppName || null,
|
||||||
|
clientConfidence: clientContext?.clientConfidence || null,
|
||||||
retryCount,
|
retryCount,
|
||||||
createdAt,
|
createdAt,
|
||||||
}).run();
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('[proxy/search] failed to write proxy log', error);
|
console.warn('[proxy/search] failed to write proxy log', error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|||||||
|
|
||||||
const {
|
const {
|
||||||
hasProxyLogBillingDetailsColumnMock,
|
hasProxyLogBillingDetailsColumnMock,
|
||||||
|
hasProxyLogClientColumnsMock,
|
||||||
hasProxyLogDownstreamApiKeyIdColumnMock,
|
hasProxyLogDownstreamApiKeyIdColumnMock,
|
||||||
dbInsertMock,
|
dbInsertMock,
|
||||||
dbInsertValuesMock,
|
dbInsertValuesMock,
|
||||||
@@ -9,6 +10,7 @@ const {
|
|||||||
proxyLogsSchema,
|
proxyLogsSchema,
|
||||||
} = vi.hoisted(() => ({
|
} = vi.hoisted(() => ({
|
||||||
hasProxyLogBillingDetailsColumnMock: vi.fn(),
|
hasProxyLogBillingDetailsColumnMock: vi.fn(),
|
||||||
|
hasProxyLogClientColumnsMock: vi.fn(),
|
||||||
hasProxyLogDownstreamApiKeyIdColumnMock: vi.fn(),
|
hasProxyLogDownstreamApiKeyIdColumnMock: vi.fn(),
|
||||||
dbInsertMock: vi.fn(),
|
dbInsertMock: vi.fn(),
|
||||||
dbInsertValuesMock: vi.fn(),
|
dbInsertValuesMock: vi.fn(),
|
||||||
@@ -28,6 +30,10 @@ const {
|
|||||||
totalTokens: 'total_tokens',
|
totalTokens: 'total_tokens',
|
||||||
estimatedCost: 'estimated_cost',
|
estimatedCost: 'estimated_cost',
|
||||||
billingDetails: 'billing_details',
|
billingDetails: 'billing_details',
|
||||||
|
clientFamily: 'client_family',
|
||||||
|
clientAppId: 'client_app_id',
|
||||||
|
clientAppName: 'client_app_name',
|
||||||
|
clientConfidence: 'client_confidence',
|
||||||
errorMessage: 'error_message',
|
errorMessage: 'error_message',
|
||||||
retryCount: 'retry_count',
|
retryCount: 'retry_count',
|
||||||
createdAt: 'created_at',
|
createdAt: 'created_at',
|
||||||
@@ -42,6 +48,7 @@ vi.mock('../db/index.js', () => ({
|
|||||||
proxyLogs: proxyLogsSchema,
|
proxyLogs: proxyLogsSchema,
|
||||||
},
|
},
|
||||||
hasProxyLogBillingDetailsColumn: (...args: unknown[]) => hasProxyLogBillingDetailsColumnMock(...args),
|
hasProxyLogBillingDetailsColumn: (...args: unknown[]) => hasProxyLogBillingDetailsColumnMock(...args),
|
||||||
|
hasProxyLogClientColumns: (...args: unknown[]) => hasProxyLogClientColumnsMock(...args),
|
||||||
hasProxyLogDownstreamApiKeyIdColumn: (...args: unknown[]) => hasProxyLogDownstreamApiKeyIdColumnMock(...args),
|
hasProxyLogDownstreamApiKeyIdColumn: (...args: unknown[]) => hasProxyLogDownstreamApiKeyIdColumnMock(...args),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -50,11 +57,13 @@ import { insertProxyLog, withProxyLogSelectFields } from './proxyLogStore.js';
|
|||||||
describe('proxyLogStore', () => {
|
describe('proxyLogStore', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
hasProxyLogBillingDetailsColumnMock.mockReset();
|
hasProxyLogBillingDetailsColumnMock.mockReset();
|
||||||
|
hasProxyLogClientColumnsMock.mockReset();
|
||||||
hasProxyLogDownstreamApiKeyIdColumnMock.mockReset();
|
hasProxyLogDownstreamApiKeyIdColumnMock.mockReset();
|
||||||
dbInsertMock.mockReset();
|
dbInsertMock.mockReset();
|
||||||
dbInsertValuesMock.mockReset();
|
dbInsertValuesMock.mockReset();
|
||||||
dbInsertRunMock.mockReset();
|
dbInsertRunMock.mockReset();
|
||||||
hasProxyLogBillingDetailsColumnMock.mockResolvedValue(false);
|
hasProxyLogBillingDetailsColumnMock.mockResolvedValue(false);
|
||||||
|
hasProxyLogClientColumnsMock.mockResolvedValue(false);
|
||||||
hasProxyLogDownstreamApiKeyIdColumnMock.mockResolvedValue(false);
|
hasProxyLogDownstreamApiKeyIdColumnMock.mockResolvedValue(false);
|
||||||
|
|
||||||
dbInsertMock.mockReturnValue({
|
dbInsertMock.mockReturnValue({
|
||||||
@@ -132,4 +141,56 @@ describe('proxyLogStore', () => {
|
|||||||
expect(dbInsertValuesMock.mock.calls[2][0].billingDetails).toBeUndefined();
|
expect(dbInsertValuesMock.mock.calls[2][0].billingDetails).toBeUndefined();
|
||||||
expect(dbInsertValuesMock.mock.calls[2][0].downstreamApiKeyId).toBeUndefined();
|
expect(dbInsertValuesMock.mock.calls[2][0].downstreamApiKeyId).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('writes structured client fields when the schema supports them', async () => {
|
||||||
|
hasProxyLogClientColumnsMock.mockResolvedValue(true);
|
||||||
|
|
||||||
|
await insertProxyLog({
|
||||||
|
modelRequested: 'gpt-5',
|
||||||
|
clientFamily: 'codex',
|
||||||
|
clientAppId: 'cherry_studio',
|
||||||
|
clientAppName: 'Cherry Studio',
|
||||||
|
clientConfidence: 'exact',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(dbInsertValuesMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(dbInsertValuesMock.mock.calls[0][0]).toMatchObject({
|
||||||
|
modelRequested: 'gpt-5',
|
||||||
|
clientFamily: 'codex',
|
||||||
|
clientAppId: 'cherry_studio',
|
||||||
|
clientAppName: 'Cherry Studio',
|
||||||
|
clientConfidence: 'exact',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retries proxy log inserts without structured client fields when those columns are missing', async () => {
|
||||||
|
hasProxyLogClientColumnsMock.mockResolvedValue(true);
|
||||||
|
dbInsertRunMock
|
||||||
|
.mockRejectedValueOnce(new Error('column proxy_logs.client_app_id does not exist'))
|
||||||
|
.mockResolvedValueOnce(undefined);
|
||||||
|
|
||||||
|
await insertProxyLog({
|
||||||
|
modelRequested: 'gpt-5',
|
||||||
|
clientFamily: 'codex',
|
||||||
|
clientAppId: 'cherry_studio',
|
||||||
|
clientAppName: 'Cherry Studio',
|
||||||
|
clientConfidence: 'exact',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(dbInsertValuesMock).toHaveBeenCalledTimes(2);
|
||||||
|
expect(dbInsertValuesMock.mock.calls[0][0]).toMatchObject({
|
||||||
|
modelRequested: 'gpt-5',
|
||||||
|
clientFamily: 'codex',
|
||||||
|
clientAppId: 'cherry_studio',
|
||||||
|
clientAppName: 'Cherry Studio',
|
||||||
|
clientConfidence: 'exact',
|
||||||
|
});
|
||||||
|
expect(dbInsertValuesMock.mock.calls[1][0]).toMatchObject({
|
||||||
|
modelRequested: 'gpt-5',
|
||||||
|
});
|
||||||
|
expect(dbInsertValuesMock.mock.calls[1][0].clientFamily).toBeUndefined();
|
||||||
|
expect(dbInsertValuesMock.mock.calls[1][0].clientAppId).toBeUndefined();
|
||||||
|
expect(dbInsertValuesMock.mock.calls[1][0].clientAppName).toBeUndefined();
|
||||||
|
expect(dbInsertValuesMock.mock.calls[1][0].clientConfidence).toBeUndefined();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
import { db, schema, hasProxyLogBillingDetailsColumn, hasProxyLogDownstreamApiKeyIdColumn } from '../db/index.js';
|
import {
|
||||||
|
db,
|
||||||
|
schema,
|
||||||
|
hasProxyLogBillingDetailsColumn,
|
||||||
|
hasProxyLogClientColumns,
|
||||||
|
hasProxyLogDownstreamApiKeyIdColumn,
|
||||||
|
} from '../db/index.js';
|
||||||
|
|
||||||
export type ProxyLogInsertInput = {
|
export type ProxyLogInsertInput = {
|
||||||
routeId?: number | null;
|
routeId?: number | null;
|
||||||
@@ -15,12 +21,16 @@ export type ProxyLogInsertInput = {
|
|||||||
totalTokens?: number | null;
|
totalTokens?: number | null;
|
||||||
estimatedCost?: number | null;
|
estimatedCost?: number | null;
|
||||||
billingDetails?: unknown;
|
billingDetails?: unknown;
|
||||||
|
clientFamily?: string | null;
|
||||||
|
clientAppId?: string | null;
|
||||||
|
clientAppName?: string | null;
|
||||||
|
clientConfidence?: string | null;
|
||||||
errorMessage?: string | null;
|
errorMessage?: string | null;
|
||||||
retryCount?: number | null;
|
retryCount?: number | null;
|
||||||
createdAt?: string | null;
|
createdAt?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
function buildProxyLogBaseSelectFields() {
|
function buildProxyLogCoreSelectFields() {
|
||||||
return {
|
return {
|
||||||
id: schema.proxyLogs.id,
|
id: schema.proxyLogs.id,
|
||||||
routeId: schema.proxyLogs.routeId,
|
routeId: schema.proxyLogs.routeId,
|
||||||
@@ -42,47 +52,93 @@ function buildProxyLogBaseSelectFields() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getProxyLogBaseSelectFields() {
|
function buildProxyLogClientSelectFields() {
|
||||||
return buildProxyLogBaseSelectFields();
|
return {
|
||||||
|
clientFamily: schema.proxyLogs.clientFamily,
|
||||||
|
clientAppId: schema.proxyLogs.clientAppId,
|
||||||
|
clientAppName: schema.proxyLogs.clientAppName,
|
||||||
|
clientConfidence: schema.proxyLogs.clientConfidence,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ProxyLogSelectFields = ReturnType<typeof buildProxyLogBaseSelectFields> & {
|
function buildProxyLogSelectFields(options?: {
|
||||||
billingDetails?: typeof schema.proxyLogs.billingDetails;
|
includeBillingDetails?: boolean;
|
||||||
};
|
includeClientFields?: boolean;
|
||||||
|
}) {
|
||||||
|
return {
|
||||||
|
...buildProxyLogCoreSelectFields(),
|
||||||
|
...(options?.includeClientFields ? buildProxyLogClientSelectFields() : {}),
|
||||||
|
...(options?.includeBillingDetails ? { billingDetails: schema.proxyLogs.billingDetails } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getProxyLogBaseSelectFields() {
|
||||||
|
return buildProxyLogCoreSelectFields();
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ProxyLogSelectFields = ReturnType<typeof buildProxyLogSelectFields>;
|
||||||
|
|
||||||
export type ResolvedProxyLogSelectFields = {
|
export type ResolvedProxyLogSelectFields = {
|
||||||
includeBillingDetails: boolean;
|
includeBillingDetails: boolean;
|
||||||
|
includeClientFields: boolean;
|
||||||
fields: ProxyLogSelectFields;
|
fields: ProxyLogSelectFields;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function resolveProxyLogSelectFields(options?: { includeBillingDetails?: boolean }) {
|
export async function resolveProxyLogSelectFields(options?: {
|
||||||
|
includeBillingDetails?: boolean;
|
||||||
|
includeClientFields?: boolean;
|
||||||
|
}) {
|
||||||
const includeBillingDetails = options?.includeBillingDetails === true
|
const includeBillingDetails = options?.includeBillingDetails === true
|
||||||
&& await hasProxyLogBillingDetailsColumn();
|
&& await hasProxyLogBillingDetailsColumn();
|
||||||
|
const includeClientFields = options?.includeClientFields !== false
|
||||||
|
&& await hasProxyLogClientColumns();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
includeBillingDetails,
|
includeBillingDetails,
|
||||||
fields: includeBillingDetails
|
includeClientFields,
|
||||||
? { ...buildProxyLogBaseSelectFields(), billingDetails: schema.proxyLogs.billingDetails }
|
fields: buildProxyLogSelectFields({
|
||||||
: buildProxyLogBaseSelectFields(),
|
includeBillingDetails,
|
||||||
|
includeClientFields,
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function withProxyLogSelectFields<T>(
|
export async function withProxyLogSelectFields<T>(
|
||||||
runner: (selection: ResolvedProxyLogSelectFields) => Promise<T>,
|
runner: (selection: ResolvedProxyLogSelectFields) => Promise<T>,
|
||||||
options?: { includeBillingDetails?: boolean },
|
options?: { includeBillingDetails?: boolean; includeClientFields?: boolean },
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const selection = await resolveProxyLogSelectFields(options);
|
let selection = await resolveProxyLogSelectFields(options);
|
||||||
|
|
||||||
try {
|
while (true) {
|
||||||
return await runner(selection);
|
try {
|
||||||
} catch (error) {
|
return await runner(selection);
|
||||||
if (selection.includeBillingDetails && isMissingBillingDetailsColumnError(error)) {
|
} catch (error) {
|
||||||
return await runner({
|
if (selection.includeBillingDetails && isMissingBillingDetailsColumnError(error)) {
|
||||||
includeBillingDetails: false,
|
selection = {
|
||||||
fields: buildProxyLogBaseSelectFields(),
|
includeBillingDetails: false,
|
||||||
});
|
includeClientFields: selection.includeClientFields,
|
||||||
|
fields: buildProxyLogSelectFields({
|
||||||
|
includeBillingDetails: false,
|
||||||
|
includeClientFields: selection.includeClientFields,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selection.includeClientFields && isMissingProxyLogClientColumnsError(error)) {
|
||||||
|
selection = {
|
||||||
|
includeBillingDetails: selection.includeBillingDetails,
|
||||||
|
includeClientFields: false,
|
||||||
|
fields: buildProxyLogSelectFields({
|
||||||
|
includeBillingDetails: selection.includeBillingDetails,
|
||||||
|
includeClientFields: false,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
throw error;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,11 +152,15 @@ export function parseProxyLogBillingDetails(value: unknown): Record<string, unkn
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isMissingBillingDetailsColumnError(error: unknown): boolean {
|
function normalizeProxyLogStoreErrorMessage(error: unknown): string {
|
||||||
const message = typeof error === 'object' && error && 'message' in error
|
const message = typeof error === 'object' && error && 'message' in error
|
||||||
? String((error as { message?: unknown }).message || '')
|
? String((error as { message?: unknown }).message || '')
|
||||||
: String(error || '');
|
: String(error || '');
|
||||||
const lowered = message.toLowerCase();
|
return message.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isMissingBillingDetailsColumnError(error: unknown): boolean {
|
||||||
|
const lowered = normalizeProxyLogStoreErrorMessage(error);
|
||||||
return lowered.includes('billing_details')
|
return lowered.includes('billing_details')
|
||||||
&& (
|
&& (
|
||||||
lowered.includes('does not exist')
|
lowered.includes('does not exist')
|
||||||
@@ -111,10 +171,7 @@ export function isMissingBillingDetailsColumnError(error: unknown): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function isMissingDownstreamApiKeyIdColumnError(error: unknown): boolean {
|
export function isMissingDownstreamApiKeyIdColumnError(error: unknown): boolean {
|
||||||
const message = typeof error === 'object' && error && 'message' in error
|
const lowered = normalizeProxyLogStoreErrorMessage(error);
|
||||||
? String((error as { message?: unknown }).message || '')
|
|
||||||
: String(error || '');
|
|
||||||
const lowered = message.toLowerCase();
|
|
||||||
return lowered.includes('downstream_api_key_id')
|
return lowered.includes('downstream_api_key_id')
|
||||||
&& (
|
&& (
|
||||||
lowered.includes('does not exist')
|
lowered.includes('does not exist')
|
||||||
@@ -124,6 +181,24 @@ export function isMissingDownstreamApiKeyIdColumnError(error: unknown): boolean
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isMissingProxyLogClientColumnsError(error: unknown): boolean {
|
||||||
|
const lowered = normalizeProxyLogStoreErrorMessage(error);
|
||||||
|
const hasClientColumnReference = [
|
||||||
|
'client_family',
|
||||||
|
'client_app_id',
|
||||||
|
'client_app_name',
|
||||||
|
'client_confidence',
|
||||||
|
].some((columnName) => lowered.includes(columnName));
|
||||||
|
|
||||||
|
return hasClientColumnReference
|
||||||
|
&& (
|
||||||
|
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> {
|
export async function insertProxyLog(input: ProxyLogInsertInput): Promise<void> {
|
||||||
const baseValues = {
|
const baseValues = {
|
||||||
routeId: input.routeId ?? null,
|
routeId: input.routeId ?? null,
|
||||||
@@ -149,43 +224,54 @@ export async function insertProxyLog(input: ProxyLogInsertInput): Promise<void>
|
|||||||
&& await hasProxyLogBillingDetailsColumn();
|
&& await hasProxyLogBillingDetailsColumn();
|
||||||
const includeDownstreamApiKeyId = input.downstreamApiKeyId != null
|
const includeDownstreamApiKeyId = input.downstreamApiKeyId != null
|
||||||
&& await hasProxyLogDownstreamApiKeyIdColumn();
|
&& await hasProxyLogDownstreamApiKeyIdColumn();
|
||||||
|
const requestedClientFields = [
|
||||||
|
input.clientFamily,
|
||||||
|
input.clientAppId,
|
||||||
|
input.clientAppName,
|
||||||
|
input.clientConfidence,
|
||||||
|
].some((value) => value != null && String(value).trim().length > 0);
|
||||||
|
const includeClientFields = requestedClientFields
|
||||||
|
&& await hasProxyLogClientColumns();
|
||||||
|
|
||||||
try {
|
let allowBillingDetails = includeBillingDetails;
|
||||||
await db.insert(schema.proxyLogs).values(
|
let allowDownstreamApiKeyId = includeDownstreamApiKeyId;
|
||||||
includeBillingDetails || includeDownstreamApiKeyId
|
let allowClientFields = includeClientFields;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const values = {
|
||||||
|
...baseValues,
|
||||||
|
...(allowBillingDetails ? { billingDetails: serializedBillingDetails } : {}),
|
||||||
|
...(allowDownstreamApiKeyId ? { downstreamApiKeyId: input.downstreamApiKeyId } : {}),
|
||||||
|
...(allowClientFields
|
||||||
? {
|
? {
|
||||||
...baseValues,
|
clientFamily: input.clientFamily ?? null,
|
||||||
...(includeBillingDetails ? { billingDetails: serializedBillingDetails } : {}),
|
clientAppId: input.clientAppId ?? null,
|
||||||
...(includeDownstreamApiKeyId ? { downstreamApiKeyId: input.downstreamApiKeyId } : {}),
|
clientAppName: input.clientAppName ?? null,
|
||||||
|
clientConfidence: input.clientConfidence ?? null,
|
||||||
}
|
}
|
||||||
: baseValues,
|
: {}),
|
||||||
).run();
|
};
|
||||||
} catch (error) {
|
|
||||||
if (includeBillingDetails && isMissingBillingDetailsColumnError(error)) {
|
|
||||||
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)) {
|
try {
|
||||||
await db.insert(schema.proxyLogs).values(
|
await db.insert(schema.proxyLogs).values(values).run();
|
||||||
includeBillingDetails
|
|
||||||
? { ...baseValues, billingDetails: serializedBillingDetails }
|
|
||||||
: baseValues,
|
|
||||||
).run();
|
|
||||||
return;
|
return;
|
||||||
|
} catch (error) {
|
||||||
|
if (allowBillingDetails && isMissingBillingDetailsColumnError(error)) {
|
||||||
|
allowBillingDetails = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allowDownstreamApiKeyId && isMissingDownstreamApiKeyIdColumnError(error)) {
|
||||||
|
allowDownstreamApiKeyId = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allowClientFields && isMissingProxyLogClientColumnsError(error)) {
|
||||||
|
allowClientFields = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
throw error;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -157,6 +157,7 @@ export type ProxyTestJobResponse = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type ProxyLogStatusFilter = 'all' | 'success' | 'failed';
|
export type ProxyLogStatusFilter = 'all' | 'success' | 'failed';
|
||||||
|
export type ProxyLogClientConfidence = 'exact' | 'heuristic' | 'unknown' | null;
|
||||||
|
|
||||||
export type ProxyLogBillingDetails = {
|
export type ProxyLogBillingDetails = {
|
||||||
quotaType: number;
|
quotaType: number;
|
||||||
@@ -208,6 +209,10 @@ export type ProxyLogListItem = {
|
|||||||
downstreamKeyName?: string | null;
|
downstreamKeyName?: string | null;
|
||||||
downstreamKeyGroupName?: string | null;
|
downstreamKeyGroupName?: string | null;
|
||||||
downstreamKeyTags?: string[];
|
downstreamKeyTags?: string[];
|
||||||
|
clientFamily?: string | null;
|
||||||
|
clientAppId?: string | null;
|
||||||
|
clientAppName?: string | null;
|
||||||
|
clientConfidence?: ProxyLogClientConfidence;
|
||||||
promptTokens?: number | null;
|
promptTokens?: number | null;
|
||||||
completionTokens?: number | null;
|
completionTokens?: number | null;
|
||||||
estimatedCost?: number | null;
|
estimatedCost?: number | null;
|
||||||
@@ -233,16 +238,23 @@ export type ProxyLogsQuery = {
|
|||||||
offset?: number;
|
offset?: number;
|
||||||
status?: ProxyLogStatusFilter;
|
status?: ProxyLogStatusFilter;
|
||||||
search?: string;
|
search?: string;
|
||||||
|
client?: string;
|
||||||
siteId?: number;
|
siteId?: number;
|
||||||
from?: string;
|
from?: string;
|
||||||
to?: string;
|
to?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ProxyLogClientOption = {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type ProxyLogsResponse = {
|
export type ProxyLogsResponse = {
|
||||||
items: ProxyLogListItem[];
|
items: ProxyLogListItem[];
|
||||||
total: number;
|
total: number;
|
||||||
page: number;
|
page: number;
|
||||||
pageSize: number;
|
pageSize: number;
|
||||||
|
clientOptions: ProxyLogClientOption[];
|
||||||
summary: ProxyLogsSummary;
|
summary: ProxyLogsSummary;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { act, create, type ReactTestInstance } from 'react-test-renderer';
|
import { act, create, type ReactTestInstance } from 'react-test-renderer';
|
||||||
import { MemoryRouter } from 'react-router-dom';
|
import { MemoryRouter } from 'react-router-dom';
|
||||||
|
import ModernSelect from '../components/ModernSelect.js';
|
||||||
import { ToastProvider } from '../components/Toast.js';
|
import { ToastProvider } from '../components/Toast.js';
|
||||||
import ProxyLogs from './ProxyLogs.js';
|
import ProxyLogs from './ProxyLogs.js';
|
||||||
|
|
||||||
@@ -62,6 +63,10 @@ function buildListResponse(overrides?: Partial<{
|
|||||||
username: 'tester',
|
username: 'tester',
|
||||||
siteName: 'main-site',
|
siteName: 'main-site',
|
||||||
siteUrl: 'https://main-site.example.com',
|
siteUrl: 'https://main-site.example.com',
|
||||||
|
clientFamily: 'codex',
|
||||||
|
clientAppId: 'cherry_studio',
|
||||||
|
clientAppName: 'Cherry Studio',
|
||||||
|
clientConfidence: 'heuristic',
|
||||||
downstreamKeyName: '移动端灰度',
|
downstreamKeyName: '移动端灰度',
|
||||||
downstreamKeyGroupName: '项目A',
|
downstreamKeyGroupName: '项目A',
|
||||||
downstreamKeyTags: ['VIP', '灰度'],
|
downstreamKeyTags: ['VIP', '灰度'],
|
||||||
@@ -77,6 +82,10 @@ function buildListResponse(overrides?: Partial<{
|
|||||||
totalCost: 1.23,
|
totalCost: 1.23,
|
||||||
totalTokensAll: 15,
|
totalTokensAll: 15,
|
||||||
},
|
},
|
||||||
|
clientOptions: [
|
||||||
|
{ value: 'app:cherry_studio', label: '应用 · Cherry Studio' },
|
||||||
|
{ value: 'family:codex', label: '协议 · Codex' },
|
||||||
|
],
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -105,6 +114,10 @@ describe('ProxyLogs server-driven page', () => {
|
|||||||
username: 'tester',
|
username: 'tester',
|
||||||
siteName: 'main-site',
|
siteName: 'main-site',
|
||||||
siteUrl: 'https://main-site.example.com',
|
siteUrl: 'https://main-site.example.com',
|
||||||
|
clientFamily: 'codex',
|
||||||
|
clientAppId: 'cherry_studio',
|
||||||
|
clientAppName: 'Cherry Studio',
|
||||||
|
clientConfidence: 'heuristic',
|
||||||
downstreamKeyName: '移动端灰度',
|
downstreamKeyName: '移动端灰度',
|
||||||
downstreamKeyGroupName: '项目A',
|
downstreamKeyGroupName: '项目A',
|
||||||
downstreamKeyTags: ['VIP', '灰度'],
|
downstreamKeyTags: ['VIP', '灰度'],
|
||||||
@@ -171,13 +184,16 @@ describe('ProxyLogs server-driven page', () => {
|
|||||||
expect(text).toContain('全部 12');
|
expect(text).toContain('全部 12');
|
||||||
expect(text).toContain('成功 8');
|
expect(text).toContain('成功 8');
|
||||||
expect(text).toContain('失败 4');
|
expect(text).toContain('失败 4');
|
||||||
|
expect(text).toContain('Cherry Studio');
|
||||||
|
expect(text).toContain('Codex');
|
||||||
|
expect(text).toContain('推测');
|
||||||
expect(text).toContain('下游 Key: 移动端灰度');
|
expect(text).toContain('下游 Key: 移动端灰度');
|
||||||
} finally {
|
} finally {
|
||||||
root?.unmount();
|
root?.unmount();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('re-queries the server for status and search changes instead of filtering locally', async () => {
|
it('re-queries the server for status, client, and search changes instead of filtering locally', async () => {
|
||||||
let root: ReturnType<typeof create> | null = null;
|
let root: ReturnType<typeof create> | null = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -200,6 +216,15 @@ describe('ProxyLogs server-driven page', () => {
|
|||||||
});
|
});
|
||||||
await flushMicrotasks();
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
const selects = root!.root.findAllByType(ModernSelect);
|
||||||
|
const clientSelect = selects.find((node) => node.props.placeholder === '全部客户端');
|
||||||
|
expect(clientSelect).toBeDefined();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
clientSelect!.props.onChange('app:cherry_studio');
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
const searchInput = root!.root.find((node) => (
|
const searchInput = root!.root.find((node) => (
|
||||||
node.type === 'input' && node.props.placeholder === '搜索模型、下游 Key、主分组、标签...'
|
node.type === 'input' && node.props.placeholder === '搜索模型、下游 Key、主分组、标签...'
|
||||||
));
|
));
|
||||||
@@ -214,11 +239,19 @@ describe('ProxyLogs server-driven page', () => {
|
|||||||
status: 'failed',
|
status: 'failed',
|
||||||
search: '',
|
search: '',
|
||||||
});
|
});
|
||||||
|
expect(apiMock.getProxyLogs).toHaveBeenNthCalledWith(3, {
|
||||||
|
limit: 50,
|
||||||
|
offset: 0,
|
||||||
|
status: 'failed',
|
||||||
|
search: '',
|
||||||
|
client: 'app:cherry_studio',
|
||||||
|
});
|
||||||
expect(apiMock.getProxyLogs).toHaveBeenLastCalledWith({
|
expect(apiMock.getProxyLogs).toHaveBeenLastCalledWith({
|
||||||
limit: 50,
|
limit: 50,
|
||||||
offset: 0,
|
offset: 0,
|
||||||
status: 'failed',
|
status: 'failed',
|
||||||
search: 'mini',
|
search: 'mini',
|
||||||
|
client: 'app:cherry_studio',
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
root?.unmount();
|
root?.unmount();
|
||||||
@@ -274,7 +307,7 @@ describe('ProxyLogs server-driven page', () => {
|
|||||||
try {
|
try {
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
root = create(
|
root = create(
|
||||||
<MemoryRouter initialEntries={['/logs?siteId=9&from=2026-03-09T08:00&to=2026-03-09T09:00']}>
|
<MemoryRouter initialEntries={['/logs?siteId=9&client=family%3Acodex&from=2026-03-09T08:00&to=2026-03-09T09:00']}>
|
||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
<ProxyLogs />
|
<ProxyLogs />
|
||||||
</ToastProvider>
|
</ToastProvider>
|
||||||
@@ -291,6 +324,7 @@ describe('ProxyLogs server-driven page', () => {
|
|||||||
status: 'all',
|
status: 'all',
|
||||||
search: '',
|
search: '',
|
||||||
siteId: 9,
|
siteId: 9,
|
||||||
|
client: 'family:codex',
|
||||||
from: expectedFrom,
|
from: expectedFrom,
|
||||||
to: expectedTo,
|
to: expectedTo,
|
||||||
});
|
});
|
||||||
|
|||||||
+124
-3
@@ -3,6 +3,7 @@ import { useLocation, useNavigate } from 'react-router-dom';
|
|||||||
import {
|
import {
|
||||||
api,
|
api,
|
||||||
type ProxyLogBillingDetails,
|
type ProxyLogBillingDetails,
|
||||||
|
type ProxyLogClientOption,
|
||||||
type ProxyLogDetail,
|
type ProxyLogDetail,
|
||||||
type ProxyLogListItem,
|
type ProxyLogListItem,
|
||||||
type ProxyLogsSummary,
|
type ProxyLogsSummary,
|
||||||
@@ -35,6 +36,12 @@ type ProxyLogDetailState = {
|
|||||||
|
|
||||||
const PAGE_SIZES = [20, 50, 100];
|
const PAGE_SIZES = [20, 50, 100];
|
||||||
const DEFAULT_PAGE_SIZE = 50;
|
const DEFAULT_PAGE_SIZE = 50;
|
||||||
|
const PROXY_LOG_CLIENT_FAMILY_LABELS: Record<string, string> = {
|
||||||
|
codex: 'Codex',
|
||||||
|
claude_code: 'Claude Code',
|
||||||
|
gemini_cli: 'Gemini CLI',
|
||||||
|
generic: '通用',
|
||||||
|
};
|
||||||
const EMPTY_SUMMARY: ProxyLogsSummary = {
|
const EMPTY_SUMMARY: ProxyLogsSummary = {
|
||||||
totalCount: 0,
|
totalCount: 0,
|
||||||
successCount: 0,
|
successCount: 0,
|
||||||
@@ -153,6 +160,12 @@ function normalizeRouteSearch(raw: string | null): string {
|
|||||||
return (raw || '').trim();
|
return (raw || '').trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeRouteClient(raw: string | null): string {
|
||||||
|
const text = (raw || '').trim();
|
||||||
|
if (!text) return '';
|
||||||
|
return /^((app|family):)/i.test(text) ? text : '';
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeRouteSiteId(raw: string | null): number | null {
|
function normalizeRouteSiteId(raw: string | null): number | null {
|
||||||
const parsed = Number.parseInt(raw || '', 10);
|
const parsed = Number.parseInt(raw || '', 10);
|
||||||
if (!Number.isFinite(parsed) || parsed <= 0) return null;
|
if (!Number.isFinite(parsed) || parsed <= 0) return null;
|
||||||
@@ -174,6 +187,7 @@ function readProxyLogsRouteState(search: string) {
|
|||||||
pageSize: normalizeRoutePageSize(params.get('pageSize')),
|
pageSize: normalizeRoutePageSize(params.get('pageSize')),
|
||||||
status: normalizeRouteStatus(params.get('status')),
|
status: normalizeRouteStatus(params.get('status')),
|
||||||
search: normalizeRouteSearch(params.get('q')),
|
search: normalizeRouteSearch(params.get('q')),
|
||||||
|
client: normalizeRouteClient(params.get('client')),
|
||||||
siteId: normalizeRouteSiteId(params.get('siteId')),
|
siteId: normalizeRouteSiteId(params.get('siteId')),
|
||||||
from: normalizeRouteDateTimeInput(params.get('from')),
|
from: normalizeRouteDateTimeInput(params.get('from')),
|
||||||
to: normalizeRouteDateTimeInput(params.get('to')),
|
to: normalizeRouteDateTimeInput(params.get('to')),
|
||||||
@@ -185,6 +199,7 @@ function buildProxyLogsRouteSearch(input: {
|
|||||||
pageSize: number;
|
pageSize: number;
|
||||||
status: ProxyLogStatusFilter;
|
status: ProxyLogStatusFilter;
|
||||||
search: string;
|
search: string;
|
||||||
|
client: string;
|
||||||
siteId: number | null;
|
siteId: number | null;
|
||||||
from: string;
|
from: string;
|
||||||
to: string;
|
to: string;
|
||||||
@@ -194,6 +209,7 @@ function buildProxyLogsRouteSearch(input: {
|
|||||||
if (input.pageSize !== DEFAULT_PAGE_SIZE) params.set('pageSize', String(input.pageSize));
|
if (input.pageSize !== DEFAULT_PAGE_SIZE) params.set('pageSize', String(input.pageSize));
|
||||||
if (input.status !== 'all') params.set('status', input.status);
|
if (input.status !== 'all') params.set('status', input.status);
|
||||||
if (input.search.trim()) params.set('q', input.search.trim());
|
if (input.search.trim()) params.set('q', input.search.trim());
|
||||||
|
if (input.client.trim()) params.set('client', input.client.trim());
|
||||||
if (input.siteId) params.set('siteId', String(input.siteId));
|
if (input.siteId) params.set('siteId', String(input.siteId));
|
||||||
if (input.from.trim()) params.set('from', input.from.trim());
|
if (input.from.trim()) params.set('from', input.from.trim());
|
||||||
if (input.to.trim()) params.set('to', input.to.trim());
|
if (input.to.trim()) params.set('to', input.to.trim());
|
||||||
@@ -201,6 +217,66 @@ function buildProxyLogsRouteSearch(input: {
|
|||||||
return next ? `?${next}` : '';
|
return next ? `?${next}` : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatProxyLogClientFamilyLabel(clientFamily?: string | null, options?: { includeGeneric?: boolean }) {
|
||||||
|
const normalized = typeof clientFamily === 'string' ? clientFamily.trim().toLowerCase() : '';
|
||||||
|
if (!normalized) return null;
|
||||||
|
if (!options?.includeGeneric && normalized === 'generic') return null;
|
||||||
|
return PROXY_LOG_CLIENT_FAMILY_LABELS[normalized] || clientFamily || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveProxyLogClientDisplay(
|
||||||
|
log: Pick<ProxyLogRenderItem, 'clientFamily' | 'clientAppName' | 'clientConfidence'>,
|
||||||
|
options?: { includeGeneric?: boolean },
|
||||||
|
) {
|
||||||
|
const familyLabel = formatProxyLogClientFamilyLabel(log.clientFamily, options);
|
||||||
|
const appName = typeof log.clientAppName === 'string' ? log.clientAppName.trim() : '';
|
||||||
|
if (appName) {
|
||||||
|
return {
|
||||||
|
primary: appName,
|
||||||
|
secondary: familyLabel,
|
||||||
|
heuristic: log.clientConfidence === 'heuristic',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
primary: familyLabel,
|
||||||
|
secondary: null,
|
||||||
|
heuristic: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderProxyLogClientCell(
|
||||||
|
log: Pick<ProxyLogRenderItem, 'clientFamily' | 'clientAppName' | 'clientConfidence'>,
|
||||||
|
options?: { includeGeneric?: boolean },
|
||||||
|
) {
|
||||||
|
const display = resolveProxyLogClientDisplay(log, options);
|
||||||
|
if (!display.primary) {
|
||||||
|
return <span style={{ color: 'var(--color-text-muted)' }}>-</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
|
||||||
|
<span>{display.primary}</span>
|
||||||
|
{display.heuristic ? (
|
||||||
|
<span
|
||||||
|
className="badge"
|
||||||
|
style={{
|
||||||
|
fontSize: 10,
|
||||||
|
color: 'var(--color-text-muted)',
|
||||||
|
borderColor: 'var(--color-border)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
推测
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{display.secondary ? (
|
||||||
|
<span style={{ fontSize: 11, color: 'var(--color-text-muted)' }}>{display.secondary}</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function toApiTimeBoundary(value: string): string | undefined {
|
function toApiTimeBoundary(value: string): string | undefined {
|
||||||
const text = value.trim();
|
const text = value.trim();
|
||||||
if (!text) return undefined;
|
if (!text) return undefined;
|
||||||
@@ -220,6 +296,7 @@ export default function ProxyLogs() {
|
|||||||
const [statusFilter, setStatusFilter] = useState<ProxyLogStatusFilter>(initialRouteState.status);
|
const [statusFilter, setStatusFilter] = useState<ProxyLogStatusFilter>(initialRouteState.status);
|
||||||
const [searchInput, setSearchInput] = useState(initialRouteState.search);
|
const [searchInput, setSearchInput] = useState(initialRouteState.search);
|
||||||
const deferredSearchInput = useDeferredValue(searchInput.trim());
|
const deferredSearchInput = useDeferredValue(searchInput.trim());
|
||||||
|
const [clientFilter, setClientFilter] = useState(initialRouteState.client);
|
||||||
const [siteFilter, setSiteFilter] = useState<number | null>(initialRouteState.siteId);
|
const [siteFilter, setSiteFilter] = useState<number | null>(initialRouteState.siteId);
|
||||||
const [fromInput, setFromInput] = useState(initialRouteState.from);
|
const [fromInput, setFromInput] = useState(initialRouteState.from);
|
||||||
const [toInput, setToInput] = useState(initialRouteState.to);
|
const [toInput, setToInput] = useState(initialRouteState.to);
|
||||||
@@ -229,6 +306,7 @@ export default function ProxyLogs() {
|
|||||||
const [detailById, setDetailById] = useState<Record<number, ProxyLogDetailState>>({});
|
const [detailById, setDetailById] = useState<Record<number, ProxyLogDetailState>>({});
|
||||||
const [showFilters, setShowFilters] = useState(false);
|
const [showFilters, setShowFilters] = useState(false);
|
||||||
const [sites, setSites] = useState<Array<{ id: number; name: string; status?: string | null }>>([]);
|
const [sites, setSites] = useState<Array<{ id: number; name: string; status?: string | null }>>([]);
|
||||||
|
const [clientOptions, setClientOptions] = useState<ProxyLogClientOption[]>([]);
|
||||||
const [autoRefresh, setAutoRefresh] = useState(false);
|
const [autoRefresh, setAutoRefresh] = useState(false);
|
||||||
const isMobile = useIsMobile(768);
|
const isMobile = useIsMobile(768);
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
@@ -245,6 +323,7 @@ export default function ProxyLogs() {
|
|||||||
const next = readProxyLogsRouteState(location.search);
|
const next = readProxyLogsRouteState(location.search);
|
||||||
setStatusFilter((current) => (current === next.status ? current : next.status));
|
setStatusFilter((current) => (current === next.status ? current : next.status));
|
||||||
setSearchInput((current) => (current === next.search ? current : next.search));
|
setSearchInput((current) => (current === next.search ? current : next.search));
|
||||||
|
setClientFilter((current) => (current === next.client ? current : next.client));
|
||||||
setSiteFilter((current) => (current === next.siteId ? current : next.siteId));
|
setSiteFilter((current) => (current === next.siteId ? current : next.siteId));
|
||||||
setFromInput((current) => (current === next.from ? current : next.from));
|
setFromInput((current) => (current === next.from ? current : next.from));
|
||||||
setToInput((current) => (current === next.to ? current : next.to));
|
setToInput((current) => (current === next.to ? current : next.to));
|
||||||
@@ -258,13 +337,14 @@ export default function ProxyLogs() {
|
|||||||
pageSize,
|
pageSize,
|
||||||
status: statusFilter,
|
status: statusFilter,
|
||||||
search: searchInput,
|
search: searchInput,
|
||||||
|
client: clientFilter,
|
||||||
siteId: siteFilter,
|
siteId: siteFilter,
|
||||||
from: fromInput,
|
from: fromInput,
|
||||||
to: toInput,
|
to: toInput,
|
||||||
});
|
});
|
||||||
if (nextSearch === location.search) return;
|
if (nextSearch === location.search) return;
|
||||||
navigate({ pathname: location.pathname, search: nextSearch }, { replace: true });
|
navigate({ pathname: location.pathname, search: nextSearch }, { replace: true });
|
||||||
}, [fromInput, location.pathname, location.search, navigate, page, pageSize, searchInput, siteFilter, statusFilter, toInput]);
|
}, [clientFilter, fromInput, location.pathname, location.search, navigate, page, pageSize, searchInput, siteFilter, statusFilter, toInput]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -326,6 +406,20 @@ export default function ProxyLogs() {
|
|||||||
];
|
];
|
||||||
}, [siteFilter, sites]);
|
}, [siteFilter, sites]);
|
||||||
|
|
||||||
|
const resolvedClientOptions = useMemo(() => {
|
||||||
|
const options = [...clientOptions];
|
||||||
|
if (clientFilter && !options.some((option) => option.value === clientFilter)) {
|
||||||
|
options.unshift({
|
||||||
|
value: clientFilter,
|
||||||
|
label: clientFilter,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
{ value: '', label: '全部客户端' },
|
||||||
|
...options,
|
||||||
|
];
|
||||||
|
}, [clientFilter, clientOptions]);
|
||||||
|
|
||||||
const activeSiteLabel = useMemo(() => {
|
const activeSiteLabel = useMemo(() => {
|
||||||
if (!siteFilter) return '全部站点';
|
if (!siteFilter) return '全部站点';
|
||||||
return siteOptions.find((option) => option.value === String(siteFilter))?.label || `站点 #${siteFilter}`;
|
return siteOptions.find((option) => option.value === String(siteFilter))?.label || `站点 #${siteFilter}`;
|
||||||
@@ -357,6 +451,7 @@ export default function ProxyLogs() {
|
|||||||
offset: currentOffset,
|
offset: currentOffset,
|
||||||
status: statusFilter,
|
status: statusFilter,
|
||||||
search: deferredSearchInput,
|
search: deferredSearchInput,
|
||||||
|
...(clientFilter ? { client: clientFilter } : {}),
|
||||||
...(siteFilter ? { siteId: siteFilter } : {}),
|
...(siteFilter ? { siteId: siteFilter } : {}),
|
||||||
...(fromApiBoundary ? { from: fromApiBoundary } : {}),
|
...(fromApiBoundary ? { from: fromApiBoundary } : {}),
|
||||||
...(toApiBoundaryValue ? { to: toApiBoundaryValue } : {}),
|
...(toApiBoundaryValue ? { to: toApiBoundaryValue } : {}),
|
||||||
@@ -366,13 +461,14 @@ export default function ProxyLogs() {
|
|||||||
setLogs(Array.isArray(data.items) ? data.items : []);
|
setLogs(Array.isArray(data.items) ? data.items : []);
|
||||||
setTotal(Number(data.total || 0));
|
setTotal(Number(data.total || 0));
|
||||||
setSummary(data.summary || EMPTY_SUMMARY);
|
setSummary(data.summary || EMPTY_SUMMARY);
|
||||||
|
setClientOptions(Array.isArray(data.clientOptions) ? data.clientOptions : []);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
if (seq !== loadSeq.current) return;
|
if (seq !== loadSeq.current) return;
|
||||||
if (!silent) toast.error(e.message || '加载日志失败');
|
if (!silent) toast.error(e.message || '加载日志失败');
|
||||||
} finally {
|
} finally {
|
||||||
if (seq === loadSeq.current) setLoading(false);
|
if (seq === loadSeq.current) setLoading(false);
|
||||||
}
|
}
|
||||||
}, [currentOffset, deferredSearchInput, fromApiBoundary, hasInvalidTimeRange, pageSize, siteFilter, statusFilter, toApiBoundaryValue, toast]);
|
}, [clientFilter, currentOffset, deferredSearchInput, fromApiBoundary, hasInvalidTimeRange, pageSize, siteFilter, statusFilter, toApiBoundaryValue, toast]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void load();
|
void load();
|
||||||
@@ -450,6 +546,18 @@ export default function ProxyLogs() {
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="proxy-logs-filter-select">
|
||||||
|
<ModernSelect
|
||||||
|
size="sm"
|
||||||
|
value={clientFilter}
|
||||||
|
onChange={(nextValue) => {
|
||||||
|
setClientFilter(nextValue);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
options={resolvedClientOptions}
|
||||||
|
placeholder="全部客户端"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div className="proxy-logs-filter-select">
|
<div className="proxy-logs-filter-select">
|
||||||
<ModernSelect
|
<ModernSelect
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -504,6 +612,7 @@ export default function ProxyLogs() {
|
|||||||
className="btn btn-ghost proxy-logs-filter-reset"
|
className="btn btn-ghost proxy-logs-filter-reset"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setStatusFilter('all');
|
setStatusFilter('all');
|
||||||
|
setClientFilter('');
|
||||||
setSiteFilter(null);
|
setSiteFilter(null);
|
||||||
setFromInput('');
|
setFromInput('');
|
||||||
setToInput('');
|
setToInput('');
|
||||||
@@ -620,6 +729,7 @@ export default function ProxyLogs() {
|
|||||||
>
|
>
|
||||||
<MobileField label="时间" value={formatDateTimeLocal(log.createdAt)} />
|
<MobileField label="时间" value={formatDateTimeLocal(log.createdAt)} />
|
||||||
<MobileField label="站点" value={<SiteBadgeLink siteId={siteIdByName.get(String(log.siteName || '').trim())} siteName={log.siteName} badgeStyle={{ fontSize: 11 }} />} />
|
<MobileField label="站点" value={<SiteBadgeLink siteId={siteIdByName.get(String(log.siteName || '').trim())} siteName={log.siteName} badgeStyle={{ fontSize: 11 }} />} />
|
||||||
|
<MobileField label="客户端" value={renderProxyLogClientCell(detailLog)} />
|
||||||
{downstreamKeySummary ? <MobileField label="下游 Key" value={downstreamKeySummary} /> : null}
|
{downstreamKeySummary ? <MobileField label="下游 Key" value={downstreamKeySummary} /> : null}
|
||||||
<MobileField label="用时" value={formatLatency(log.latencyMs)} />
|
<MobileField label="用时" value={formatLatency(log.latencyMs)} />
|
||||||
<MobileField label="输入" value={log.promptTokens?.toLocaleString() || '-'} />
|
<MobileField label="输入" value={log.promptTokens?.toLocaleString() || '-'} />
|
||||||
@@ -634,6 +744,7 @@ export default function ProxyLogs() {
|
|||||||
{detailState?.loading && <div style={{ color: 'var(--color-text-muted)' }}>加载详情中...</div>}
|
{detailState?.loading && <div style={{ color: 'var(--color-text-muted)' }}>加载详情中...</div>}
|
||||||
{detailState?.error && <div style={{ color: 'var(--color-danger)' }}>{detailState.error}</div>}
|
{detailState?.error && <div style={{ color: 'var(--color-danger)' }}>{detailState.error}</div>}
|
||||||
{billingDetailSummary && <div style={{ color: 'var(--color-text-muted)' }}>{billingDetailSummary}</div>}
|
{billingDetailSummary && <div style={{ color: 'var(--color-text-muted)' }}>{billingDetailSummary}</div>}
|
||||||
|
<MobileField label="客户端详情" value={renderProxyLogClientCell(detailLog, { includeGeneric: true })} />
|
||||||
{downstreamKeySummary && <div style={{ color: 'var(--color-text-muted)' }}>{downstreamKeySummary}</div>}
|
{downstreamKeySummary && <div style={{ color: 'var(--color-text-muted)' }}>{downstreamKeySummary}</div>}
|
||||||
{billingProcessLines.length > 0 && (
|
{billingProcessLines.length > 0 && (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||||
@@ -668,6 +779,7 @@ export default function ProxyLogs() {
|
|||||||
<th>时间</th>
|
<th>时间</th>
|
||||||
<th>模型</th>
|
<th>模型</th>
|
||||||
<th>站点</th>
|
<th>站点</th>
|
||||||
|
<th>客户端</th>
|
||||||
<th>{tr('状态')}</th>
|
<th>{tr('状态')}</th>
|
||||||
<th style={{ textAlign: 'center' }}>用时</th>
|
<th style={{ textAlign: 'center' }}>用时</th>
|
||||||
<th style={{ textAlign: 'right' }}>输入</th>
|
<th style={{ textAlign: 'right' }}>输入</th>
|
||||||
@@ -722,6 +834,9 @@ export default function ProxyLogs() {
|
|||||||
<td style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}>
|
<td style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}>
|
||||||
<SiteBadgeLink siteId={siteIdByName.get(String(log.siteName || '').trim())} siteName={log.siteName} badgeStyle={{ fontSize: 11 }} />
|
<SiteBadgeLink siteId={siteIdByName.get(String(log.siteName || '').trim())} siteName={log.siteName} badgeStyle={{ fontSize: 11 }} />
|
||||||
</td>
|
</td>
|
||||||
|
<td style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}>
|
||||||
|
{renderProxyLogClientCell(detailLog)}
|
||||||
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<span className={`badge ${log.status === 'success' ? 'badge-success' : 'badge-error'}`} style={{ fontSize: 11, fontWeight: 600 }}>
|
<span className={`badge ${log.status === 'success' ? 'badge-success' : 'badge-error'}`} style={{ fontSize: 11, fontWeight: 600 }}>
|
||||||
<span style={{
|
<span style={{
|
||||||
@@ -763,7 +878,7 @@ export default function ProxyLogs() {
|
|||||||
</tr>
|
</tr>
|
||||||
{expanded === log.id && (
|
{expanded === log.id && (
|
||||||
<tr style={{ background: 'var(--color-bg)' }}>
|
<tr style={{ background: 'var(--color-bg)' }}>
|
||||||
<td colSpan={10} style={{ padding: 0 }}>
|
<td colSpan={11} style={{ padding: 0 }}>
|
||||||
<div className="anim-collapse is-open">
|
<div className="anim-collapse is-open">
|
||||||
<div className="anim-collapse-inner">
|
<div className="anim-collapse-inner">
|
||||||
<div className="animate-fade-in" style={{
|
<div className="animate-fade-in" style={{
|
||||||
@@ -796,6 +911,12 @@ export default function ProxyLogs() {
|
|||||||
{billingDetailSummary && (
|
{billingDetailSummary && (
|
||||||
<div style={{ color: 'var(--color-text-muted)' }}>{billingDetailSummary}</div>
|
<div style={{ color: 'var(--color-text-muted)' }}>{billingDetailSummary}</div>
|
||||||
)}
|
)}
|
||||||
|
<div style={{ display: 'flex', gap: 6, alignItems: 'flex-start' }}>
|
||||||
|
<span style={{ color: 'var(--color-text-muted)', flexShrink: 0 }}>客户端</span>
|
||||||
|
<div style={{ minWidth: 0 }}>
|
||||||
|
{renderProxyLogClientCell(detailLog, { includeGeneric: true })}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{downstreamKeySummary && (
|
{downstreamKeySummary && (
|
||||||
<div style={{ color: 'var(--color-text-muted)' }}>{downstreamKeySummary}</div>
|
<div style={{ color: 'var(--color-text-muted)' }}>{downstreamKeySummary}</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -16,6 +16,14 @@ describe('parseProxyLogPathMeta', () => {
|
|||||||
expect(parsed.errorMessage).toBe('messages is required');
|
expect(parsed.errorMessage).toBe('messages is required');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('strips legacy client and session prefixes while keeping them available for display', () => {
|
||||||
|
const parsed = parseProxyLogPathMeta('[client:codex] [session:turn-123] [downstream:/v1/responses] boom');
|
||||||
|
expect(parsed.clientFamily).toBe('codex');
|
||||||
|
expect(parsed.sessionId).toBe('turn-123');
|
||||||
|
expect(parsed.downstreamPath).toBe('/v1/responses');
|
||||||
|
expect(parsed.errorMessage).toBe('boom');
|
||||||
|
});
|
||||||
|
|
||||||
it('keeps plain message when no metadata exists', () => {
|
it('keeps plain message when no metadata exists', () => {
|
||||||
const parsed = parseProxyLogPathMeta('network timeout');
|
const parsed = parseProxyLogPathMeta('network timeout');
|
||||||
expect(parsed.downstreamPath).toBe(null);
|
expect(parsed.downstreamPath).toBe(null);
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
type ProxyLogPathMeta = {
|
type ProxyLogPathMeta = {
|
||||||
|
clientFamily: string | null;
|
||||||
|
sessionId: string | null;
|
||||||
downstreamPath: string | null;
|
downstreamPath: string | null;
|
||||||
upstreamPath: string | null;
|
upstreamPath: string | null;
|
||||||
errorMessage: string;
|
errorMessage: string;
|
||||||
@@ -6,11 +8,18 @@ type ProxyLogPathMeta = {
|
|||||||
|
|
||||||
export function parseProxyLogPathMeta(message?: string): ProxyLogPathMeta {
|
export function parseProxyLogPathMeta(message?: string): ProxyLogPathMeta {
|
||||||
const raw = typeof message === 'string' ? message.trim() : '';
|
const raw = typeof message === 'string' ? message.trim() : '';
|
||||||
|
const clientMatch = raw.match(/\[client:([^\]]+)\]/i);
|
||||||
|
const sessionMatch = raw.match(/\[session:([^\]]+)\]/i);
|
||||||
const downstreamMatch = raw.match(/\[downstream:([^\]]+)\]/i);
|
const downstreamMatch = raw.match(/\[downstream:([^\]]+)\]/i);
|
||||||
const upstreamMatch = raw.match(/\[upstream:([^\]]+)\]/i);
|
const upstreamMatch = raw.match(/\[upstream:([^\]]+)\]/i);
|
||||||
const stripped = raw.replace(/^\s*(?:\[(?:downstream|upstream):[^\]]+\]\s*)+/i, '').trim();
|
const stripped = raw.replace(
|
||||||
|
/^\s*(?:\[(?:client|session|downstream|upstream):[^\]]+\]\s*)+/i,
|
||||||
|
'',
|
||||||
|
).trim();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
clientFamily: clientMatch?.[1]?.trim() || null,
|
||||||
|
sessionId: sessionMatch?.[1]?.trim() || null,
|
||||||
downstreamPath: downstreamMatch?.[1]?.trim() || null,
|
downstreamPath: downstreamMatch?.[1]?.trim() || null,
|
||||||
upstreamPath: upstreamMatch?.[1]?.trim() || null,
|
upstreamPath: upstreamMatch?.[1]?.trim() || null,
|
||||||
errorMessage: stripped,
|
errorMessage: stripped,
|
||||||
|
|||||||
Reference in New Issue
Block a user