From f769f43909a96c4b1614468b1f447535e4bdb6a8 Mon Sep 17 00:00:00 2001 From: cita Date: Wed, 18 Mar 2026 20:38:01 +0800 Subject: [PATCH] fix route grouping and codex websocket regressions --- drizzle/0014_explicit_group_routes.sql | 13 + drizzle/meta/_journal.json | 7 + src/server/db/generated/mysql.bootstrap.sql | 5 +- src/server/db/generated/mysql.upgrade.sql | 10 +- .../db/generated/postgres.bootstrap.sql | 5 +- src/server/db/generated/postgres.upgrade.sql | 10 +- src/server/db/generated/schemaContract.json | 75 +++ src/server/db/index.ts | 21 + src/server/db/legacySchemaCompat.ts | 4 + .../routeGroupingSchemaCompatibility.test.ts | 19 +- .../db/routeGroupingSchemaCompatibility.ts | 49 ++ src/server/db/schema.ts | 10 + .../api/tokens.route-update-rebuild.test.ts | 176 +++++ src/server/routes/api/tokens.ts | 616 ++++++++++++------ src/server/routes/proxy/chat.stream.test.ts | 19 +- src/server/routes/proxy/models.test.ts | 105 +++ src/server/routes/proxy/models.ts | 25 +- src/server/routes/proxy/responses.ts | 6 + .../routes/proxy/responses.websocket.test.ts | 389 ++++++++++- src/server/routes/proxy/responsesWebsocket.ts | 134 ++-- src/server/routes/proxy/upstreamEndpoint.ts | 16 +- src/server/services/backupService.test.ts | 25 + src/server/services/backupService.ts | 22 + .../services/databaseMigrationService.test.ts | 25 +- .../services/databaseMigrationService.ts | 25 +- src/server/services/modelService.ts | 5 +- src/server/services/tokenRouter.ts | 219 +++++-- .../openai/responses/compatibility.ts | 6 + .../openai/responses/conversion.test.ts | 15 + .../openai/responses/proxyStream.ts | 9 + src/web/pages/TokenRoutes.tsx | 117 +++- src/web/pages/helpers/routeListVisibility.ts | 24 +- .../helpers/routeModelCandidatesIndex.ts | 5 + .../pages/token-routes/ManualRoutePanel.tsx | 557 ++++++++++++---- src/web/pages/token-routes/RouteCard.tsx | 95 ++- src/web/pages/token-routes/types.ts | 5 + src/web/pages/token-routes/utils.ts | 14 +- .../pages/tokenRoutes.group-collapse.test.tsx | 207 +++--- 38 files changed, 2453 insertions(+), 636 deletions(-) create mode 100644 drizzle/0014_explicit_group_routes.sql diff --git a/drizzle/0014_explicit_group_routes.sql b/drizzle/0014_explicit_group_routes.sql new file mode 100644 index 0000000..24d5722 --- /dev/null +++ b/drizzle/0014_explicit_group_routes.sql @@ -0,0 +1,13 @@ +ALTER TABLE `token_routes` ADD `route_mode` text DEFAULT 'pattern'; +--> statement-breakpoint +CREATE TABLE `route_group_sources` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `group_route_id` integer NOT NULL, + `source_route_id` integer NOT NULL, + FOREIGN KEY (`group_route_id`) REFERENCES `token_routes`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`source_route_id`) REFERENCES `token_routes`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `route_group_sources_group_source_unique` ON `route_group_sources` (`group_route_id`,`source_route_id`); +--> statement-breakpoint +CREATE INDEX `route_group_sources_source_route_id_idx` ON `route_group_sources` (`source_route_id`); diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 49e9b9a..6e870bf 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -92,6 +92,13 @@ "when": 1773682200000, "tag": "0013_oauth_multi_provider", "breakpoints": true + }, + { + "idx": 13, + "version": "6", + "when": 1773855000000, + "tag": "0014_explicit_group_routes", + "breakpoints": true } ] } diff --git a/src/server/db/generated/mysql.bootstrap.sql b/src/server/db/generated/mysql.bootstrap.sql index 5d947a9..b29fe93 100644 --- a/src/server/db/generated/mysql.bootstrap.sql +++ b/src/server/db/generated/mysql.bootstrap.sql @@ -8,8 +8,9 @@ CREATE TABLE IF NOT EXISTS `model_availability` (`id` INT AUTO_INCREMENT NOT NUL 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_video_tasks` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `public_id` TEXT NOT NULL, `upstream_video_id` TEXT NOT NULL, `site_url` TEXT NOT NULL, `token_value` TEXT NOT NULL, `requested_model` TEXT, `actual_model` TEXT, `channel_id` INT, `account_id` INT, `status_snapshot` JSON, `upstream_response_meta` JSON, `last_upstream_status` INT, `last_polled_at` DATETIME, `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP); -CREATE TABLE IF NOT EXISTS `token_routes` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `model_pattern` TEXT NOT NULL, `model_mapping` JSON, `enabled` BOOLEAN DEFAULT true, `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP, `display_name` TEXT, `display_icon` TEXT, `decision_snapshot` JSON, `decision_refreshed_at` DATETIME, `routing_strategy` VARCHAR(191) DEFAULT 'weighted'); +CREATE TABLE IF NOT EXISTS `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_group_sources` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `group_route_id` INT NOT NULL, `source_route_id` INT NOT NULL, FOREIGN KEY (`group_route_id`) REFERENCES `token_routes`(`id`) ON DELETE CASCADE, FOREIGN KEY (`source_route_id`) REFERENCES `token_routes`(`id`) ON DELETE CASCADE); CREATE TABLE IF NOT EXISTS `settings` (`key` VARCHAR(191) NOT NULL PRIMARY KEY, `value` TEXT); CREATE TABLE IF NOT EXISTS `site_disabled_models` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `site_id` INT NOT NULL, `model_name` TEXT NOT NULL, `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (`site_id`) REFERENCES `sites`(`id`) ON DELETE CASCADE); CREATE TABLE IF NOT EXISTS `token_model_availability` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `token_id` INT NOT NULL, `model_name` TEXT NOT NULL, `available` BOOLEAN, `latency_ms` INT, `checked_at` DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (`token_id`) REFERENCES `account_tokens`(`id`) ON DELETE CASCADE); @@ -17,6 +18,7 @@ CREATE UNIQUE INDEX `downstream_api_keys_key_unique` ON `downstream_api_keys` (` CREATE UNIQUE INDEX `model_availability_account_model_unique` ON `model_availability` (`account_id`, `model_name`(191)); CREATE UNIQUE INDEX `proxy_files_public_id_unique` ON `proxy_files` (`public_id`(191)); CREATE UNIQUE INDEX `proxy_video_tasks_public_id_unique` ON `proxy_video_tasks` (`public_id`(191)); +CREATE UNIQUE INDEX `route_group_sources_group_source_unique` ON `route_group_sources` (`group_route_id`, `source_route_id`); CREATE UNIQUE INDEX `site_disabled_models_site_model_unique` ON `site_disabled_models` (`site_id`, `model_name`(191)); CREATE UNIQUE INDEX `sites_platform_url_unique` ON `sites` (`platform`(191), `url`(191)); CREATE UNIQUE INDEX `token_model_availability_token_model_unique` ON `token_model_availability` (`token_id`, `model_name`(191)); @@ -52,6 +54,7 @@ CREATE INDEX `route_channels_route_enabled_idx` ON `route_channels` (`route_id`, CREATE INDEX `route_channels_route_id_idx` ON `route_channels` (`route_id`); CREATE INDEX `route_channels_route_token_idx` ON `route_channels` (`route_id`, `token_id`); CREATE INDEX `route_channels_token_id_idx` ON `route_channels` (`token_id`); +CREATE INDEX `route_group_sources_source_route_id_idx` ON `route_group_sources` (`source_route_id`); CREATE INDEX `site_disabled_models_site_id_idx` ON `site_disabled_models` (`site_id`); CREATE INDEX `sites_status_idx` ON `sites` (`status`(191)); CREATE INDEX `token_model_availability_available_idx` ON `token_model_availability` (`available`); diff --git a/src/server/db/generated/mysql.upgrade.sql b/src/server/db/generated/mysql.upgrade.sql index 01e88a1..2e455b5 100644 --- a/src/server/db/generated/mysql.upgrade.sql +++ b/src/server/db/generated/mysql.upgrade.sql @@ -1,6 +1,4 @@ -ALTER TABLE `accounts` ADD COLUMN `oauth_provider` TEXT; -ALTER TABLE `accounts` ADD COLUMN `oauth_account_key` TEXT; -ALTER TABLE `accounts` ADD COLUMN `oauth_project_id` TEXT; -CREATE UNIQUE INDEX `sites_platform_url_unique` ON `sites` (`platform`(191), `url`(191)); -CREATE INDEX `accounts_oauth_identity_idx` ON `accounts` (`oauth_provider`(191), `oauth_account_key`(191), `oauth_project_id`(191)); -CREATE INDEX `accounts_oauth_provider_idx` ON `accounts` (`oauth_provider`(191)); +CREATE TABLE IF NOT EXISTS `route_group_sources` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `group_route_id` INT NOT NULL, `source_route_id` INT NOT NULL, FOREIGN KEY (`group_route_id`) REFERENCES `token_routes`(`id`) ON DELETE CASCADE, FOREIGN KEY (`source_route_id`) REFERENCES `token_routes`(`id`) ON DELETE CASCADE); +ALTER TABLE `token_routes` ADD COLUMN `route_mode` VARCHAR(191) DEFAULT 'pattern'; +CREATE UNIQUE INDEX `route_group_sources_group_source_unique` ON `route_group_sources` (`group_route_id`, `source_route_id`); +CREATE INDEX `route_group_sources_source_route_id_idx` ON `route_group_sources` (`source_route_id`); diff --git a/src/server/db/generated/postgres.bootstrap.sql b/src/server/db/generated/postgres.bootstrap.sql index a1d33ad..29b8f81 100644 --- a/src/server/db/generated/postgres.bootstrap.sql +++ b/src/server/db/generated/postgres.bootstrap.sql @@ -8,8 +8,9 @@ CREATE TABLE IF NOT EXISTS "model_availability" ("id" INTEGER GENERATED BY DEFAU 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_video_tasks" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "public_id" TEXT NOT NULL, "upstream_video_id" TEXT NOT NULL, "site_url" TEXT NOT NULL, "token_value" TEXT NOT NULL, "requested_model" TEXT, "actual_model" TEXT, "channel_id" INTEGER, "account_id" INTEGER, "status_snapshot" JSONB, "upstream_response_meta" JSONB, "last_upstream_status" INTEGER, "last_polled_at" TIMESTAMP, "created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "updated_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP); -CREATE TABLE IF NOT EXISTS "token_routes" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "model_pattern" TEXT NOT NULL, "model_mapping" JSONB, "enabled" BOOLEAN DEFAULT true, "created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "updated_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "display_name" TEXT, "display_icon" TEXT, "decision_snapshot" JSONB, "decision_refreshed_at" TIMESTAMP, "routing_strategy" TEXT DEFAULT 'weighted'); +CREATE TABLE IF NOT EXISTS "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_group_sources" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "group_route_id" INTEGER NOT NULL, "source_route_id" INTEGER NOT NULL, FOREIGN KEY ("group_route_id") REFERENCES "token_routes"("id") ON DELETE CASCADE, FOREIGN KEY ("source_route_id") REFERENCES "token_routes"("id") ON DELETE CASCADE); CREATE TABLE IF NOT EXISTS "settings" ("key" TEXT NOT NULL PRIMARY KEY, "value" TEXT); CREATE TABLE IF NOT EXISTS "site_disabled_models" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "site_id" INTEGER NOT NULL, "model_name" TEXT NOT NULL, "created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY ("site_id") REFERENCES "sites"("id") ON DELETE CASCADE); CREATE TABLE IF NOT EXISTS "token_model_availability" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "token_id" INTEGER NOT NULL, "model_name" TEXT NOT NULL, "available" BOOLEAN, "latency_ms" INTEGER, "checked_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY ("token_id") REFERENCES "account_tokens"("id") ON DELETE CASCADE); @@ -17,6 +18,7 @@ CREATE UNIQUE INDEX "downstream_api_keys_key_unique" ON "downstream_api_keys" (" CREATE UNIQUE INDEX "model_availability_account_model_unique" ON "model_availability" ("account_id", "model_name"); CREATE UNIQUE INDEX "proxy_files_public_id_unique" ON "proxy_files" ("public_id"); CREATE UNIQUE INDEX "proxy_video_tasks_public_id_unique" ON "proxy_video_tasks" ("public_id"); +CREATE UNIQUE INDEX "route_group_sources_group_source_unique" ON "route_group_sources" ("group_route_id", "source_route_id"); CREATE UNIQUE INDEX "site_disabled_models_site_model_unique" ON "site_disabled_models" ("site_id", "model_name"); CREATE UNIQUE INDEX "sites_platform_url_unique" ON "sites" ("platform", "url"); CREATE UNIQUE INDEX "token_model_availability_token_model_unique" ON "token_model_availability" ("token_id", "model_name"); @@ -52,6 +54,7 @@ CREATE INDEX "route_channels_route_enabled_idx" ON "route_channels" ("route_id", CREATE INDEX "route_channels_route_id_idx" ON "route_channels" ("route_id"); CREATE INDEX "route_channels_route_token_idx" ON "route_channels" ("route_id", "token_id"); CREATE INDEX "route_channels_token_id_idx" ON "route_channels" ("token_id"); +CREATE INDEX "route_group_sources_source_route_id_idx" ON "route_group_sources" ("source_route_id"); CREATE INDEX "site_disabled_models_site_id_idx" ON "site_disabled_models" ("site_id"); CREATE INDEX "sites_status_idx" ON "sites" ("status"); CREATE INDEX "token_model_availability_available_idx" ON "token_model_availability" ("available"); diff --git a/src/server/db/generated/postgres.upgrade.sql b/src/server/db/generated/postgres.upgrade.sql index 5b12f97..f8ca612 100644 --- a/src/server/db/generated/postgres.upgrade.sql +++ b/src/server/db/generated/postgres.upgrade.sql @@ -1,6 +1,4 @@ -ALTER TABLE "accounts" ADD COLUMN "oauth_provider" TEXT; -ALTER TABLE "accounts" ADD COLUMN "oauth_account_key" TEXT; -ALTER TABLE "accounts" ADD COLUMN "oauth_project_id" TEXT; -CREATE UNIQUE INDEX "sites_platform_url_unique" ON "sites" ("platform", "url"); -CREATE INDEX "accounts_oauth_identity_idx" ON "accounts" ("oauth_provider", "oauth_account_key", "oauth_project_id"); -CREATE INDEX "accounts_oauth_provider_idx" ON "accounts" ("oauth_provider"); +CREATE TABLE IF NOT EXISTS "route_group_sources" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "group_route_id" INTEGER NOT NULL, "source_route_id" INTEGER NOT NULL, FOREIGN KEY ("group_route_id") REFERENCES "token_routes"("id") ON DELETE CASCADE, FOREIGN KEY ("source_route_id") REFERENCES "token_routes"("id") ON DELETE CASCADE); +ALTER TABLE "token_routes" ADD COLUMN "route_mode" TEXT DEFAULT 'pattern'; +CREATE UNIQUE INDEX "route_group_sources_group_source_unique" ON "route_group_sources" ("group_route_id", "source_route_id"); +CREATE INDEX "route_group_sources_source_route_id_idx" ON "route_group_sources" ("source_route_id"); diff --git a/src/server/db/generated/schemaContract.json b/src/server/db/generated/schemaContract.json index 79efbd9..75e577d 100644 --- a/src/server/db/generated/schemaContract.json +++ b/src/server/db/generated/schemaContract.json @@ -868,6 +868,28 @@ } } }, + "route_group_sources": { + "columns": { + "id": { + "logicalType": "integer", + "notNull": true, + "defaultValue": null, + "primaryKey": true + }, + "group_route_id": { + "logicalType": "integer", + "notNull": true, + "defaultValue": null, + "primaryKey": false + }, + "source_route_id": { + "logicalType": "integer", + "notNull": true, + "defaultValue": null, + "primaryKey": false + } + } + }, "settings": { "columns": { "key": { @@ -1113,6 +1135,12 @@ "notNull": false, "defaultValue": "'weighted'", "primaryKey": false + }, + "route_mode": { + "logicalType": "text", + "notNull": false, + "defaultValue": "'pattern'", + "primaryKey": false } } } @@ -1423,6 +1451,23 @@ ], "unique": false }, + { + "name": "route_group_sources_group_source_unique", + "table": "route_group_sources", + "columns": [ + "group_route_id", + "source_route_id" + ], + "unique": true + }, + { + "name": "route_group_sources_source_route_id_idx", + "table": "route_group_sources", + "columns": [ + "source_route_id" + ], + "unique": false + }, { "name": "site_disabled_models_site_id_idx", "table": "site_disabled_models", @@ -1538,6 +1583,14 @@ "public_id" ] }, + { + "name": "route_group_sources_group_source_unique", + "table": "route_group_sources", + "columns": [ + "group_route_id", + "source_route_id" + ] + }, { "name": "site_disabled_models_site_model_unique", "table": "site_disabled_models", @@ -1641,6 +1694,28 @@ ], "onDelete": "SET NULL" }, + { + "table": "route_group_sources", + "columns": [ + "group_route_id" + ], + "referencedTable": "token_routes", + "referencedColumns": [ + "id" + ], + "onDelete": "CASCADE" + }, + { + "table": "route_group_sources", + "columns": [ + "source_route_id" + ], + "referencedTable": "token_routes", + "referencedColumns": [ + "id" + ], + "onDelete": "CASCADE" + }, { "table": "site_disabled_models", "columns": [ diff --git a/src/server/db/index.ts b/src/server/db/index.ts index a78fdb3..72627be 100644 --- a/src/server/db/index.ts +++ b/src/server/db/index.ts @@ -27,6 +27,7 @@ const TABLES_WITH_NUMERIC_ID = new Set([ 'model_availability', 'token_model_availability', 'token_routes', + 'route_group_sources', 'route_channels', 'proxy_logs', 'proxy_video_tasks', @@ -463,6 +464,10 @@ function ensureRouteGroupingSchema() { execSqliteLegacyCompat(`ALTER TABLE token_routes ADD COLUMN display_icon text;`); } + if (!tableColumnExists('token_routes', 'route_mode')) { + execSqliteLegacyCompat(`ALTER TABLE token_routes ADD COLUMN route_mode text DEFAULT 'pattern';`); + } + if (!tableColumnExists('token_routes', 'decision_snapshot')) { execSqliteLegacyCompat(`ALTER TABLE token_routes ADD COLUMN decision_snapshot text;`); } @@ -490,6 +495,22 @@ function ensureRouteGroupingSchema() { if (!tableColumnExists('route_channels', 'cooldown_level')) { execSqliteLegacyCompat(`ALTER TABLE route_channels ADD COLUMN cooldown_level integer NOT NULL DEFAULT 0;`); } + + execSqliteLegacyCompat(` + CREATE TABLE IF NOT EXISTS route_group_sources ( + id integer PRIMARY KEY AUTOINCREMENT NOT NULL, + group_route_id integer NOT NULL REFERENCES token_routes(id) ON DELETE cascade, + source_route_id integer NOT NULL REFERENCES token_routes(id) ON DELETE cascade + ); + `); + execSqliteLegacyCompat(` + CREATE UNIQUE INDEX IF NOT EXISTS route_group_sources_group_source_unique + ON route_group_sources(group_route_id, source_route_id); + `); + execSqliteLegacyCompat(` + CREATE INDEX IF NOT EXISTS route_group_sources_source_route_id_idx + ON route_group_sources(source_route_id); + `); } function ensureDownstreamApiKeySchema() { diff --git a/src/server/db/legacySchemaCompat.ts b/src/server/db/legacySchemaCompat.ts index a480ac2..38bcdb9 100644 --- a/src/server/db/legacySchemaCompat.ts +++ b/src/server/db/legacySchemaCompat.ts @@ -32,6 +32,7 @@ export interface LegacySchemaCompatInspector extends const LEGACY_COMPAT_TABLES = new Set([ 'account_tokens', 'token_model_availability', + 'route_group_sources', 'proxy_video_tasks', 'proxy_files', 'downstream_api_keys', @@ -49,6 +50,7 @@ const LEGACY_COMPAT_COLUMNS = new Set([ 'account_tokens.value_status', 'token_routes.display_name', 'token_routes.display_icon', + 'token_routes.route_mode', 'token_routes.decision_snapshot', 'token_routes.decision_refreshed_at', 'token_routes.routing_strategy', @@ -80,6 +82,8 @@ const LEGACY_COMPAT_INDEXES = new Set([ 'downstream_api_keys_enabled_idx', 'downstream_api_keys_expires_at_idx', 'proxy_logs_downstream_api_key_created_at_idx', + 'route_group_sources_group_source_unique', + 'route_group_sources_source_route_id_idx', ...SHARED_INDEX_COMPATIBILITY_SPECS.map((spec) => spec.indexName), ]); diff --git a/src/server/db/routeGroupingSchemaCompatibility.test.ts b/src/server/db/routeGroupingSchemaCompatibility.test.ts index 3c1db60..bf500b9 100644 --- a/src/server/db/routeGroupingSchemaCompatibility.test.ts +++ b/src/server/db/routeGroupingSchemaCompatibility.test.ts @@ -8,9 +8,11 @@ function createInspector( dialect: RouteGroupingSchemaInspector['dialect'], options?: { existingColumnsByTable?: Partial>; + existingTables?: Array<'token_routes' | 'route_channels' | 'route_group_sources'>; }, ) { const executedSql: string[] = []; + const existingTables = new Set(options?.existingTables ?? ['token_routes', 'route_channels']); const existingColumnsByTable = { token_routes: new Set(options?.existingColumnsByTable?.token_routes ?? []), route_channels: new Set(options?.existingColumnsByTable?.route_channels ?? []), @@ -19,7 +21,7 @@ function createInspector( const inspector: RouteGroupingSchemaInspector = { dialect, async tableExists(table) { - return table === 'token_routes' || table === 'route_channels'; + return existingTables.has(table as 'token_routes' | 'route_channels' | 'route_group_sources'); }, async columnExists(table, column) { if (table === 'token_routes' || table === 'route_channels') { @@ -40,8 +42,12 @@ describe('ensureRouteGroupingSchemaCompatibility', () => { { dialect: 'sqlite' as const, expectedSql: [ + 'CREATE TABLE IF NOT EXISTS route_group_sources (id integer PRIMARY KEY AUTOINCREMENT NOT NULL, group_route_id integer NOT NULL REFERENCES token_routes(id) ON DELETE cascade, source_route_id integer NOT NULL REFERENCES token_routes(id) ON DELETE cascade);', + 'CREATE UNIQUE INDEX IF NOT EXISTS route_group_sources_group_source_unique ON route_group_sources(group_route_id, source_route_id);', + 'CREATE INDEX IF NOT EXISTS route_group_sources_source_route_id_idx ON route_group_sources(source_route_id);', 'ALTER TABLE token_routes ADD COLUMN display_name text;', 'ALTER TABLE token_routes ADD COLUMN display_icon text;', + 'ALTER TABLE token_routes ADD COLUMN route_mode text DEFAULT \'pattern\';', 'ALTER TABLE token_routes ADD COLUMN decision_snapshot text;', 'ALTER TABLE token_routes ADD COLUMN decision_refreshed_at text;', 'ALTER TABLE token_routes ADD COLUMN routing_strategy text DEFAULT \'weighted\';', @@ -54,8 +60,12 @@ describe('ensureRouteGroupingSchemaCompatibility', () => { { dialect: 'postgres' as const, expectedSql: [ + 'CREATE TABLE IF NOT EXISTS "route_group_sources" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "group_route_id" INTEGER NOT NULL, "source_route_id" INTEGER NOT NULL, CONSTRAINT "route_group_sources_group_route_id_token_routes_id_fk" FOREIGN KEY ("group_route_id") REFERENCES "token_routes"("id") ON DELETE CASCADE, CONSTRAINT "route_group_sources_source_route_id_token_routes_id_fk" FOREIGN KEY ("source_route_id") REFERENCES "token_routes"("id") ON DELETE CASCADE)', + 'CREATE UNIQUE INDEX IF NOT EXISTS "route_group_sources_group_source_unique" ON "route_group_sources" ("group_route_id", "source_route_id")', + 'CREATE INDEX IF NOT EXISTS "route_group_sources_source_route_id_idx" ON "route_group_sources" ("source_route_id")', 'ALTER TABLE "token_routes" ADD COLUMN "display_name" TEXT', 'ALTER TABLE "token_routes" ADD COLUMN "display_icon" TEXT', + 'ALTER TABLE "token_routes" ADD COLUMN "route_mode" TEXT DEFAULT \'pattern\'', 'ALTER TABLE "token_routes" ADD COLUMN "decision_snapshot" TEXT', 'ALTER TABLE "token_routes" ADD COLUMN "decision_refreshed_at" TEXT', 'ALTER TABLE "token_routes" ADD COLUMN "routing_strategy" TEXT DEFAULT \'weighted\'', @@ -68,8 +78,12 @@ describe('ensureRouteGroupingSchemaCompatibility', () => { { dialect: 'mysql' as const, expectedSql: [ + 'CREATE TABLE IF NOT EXISTS `route_group_sources` (`id` int AUTO_INCREMENT NOT NULL PRIMARY KEY, `group_route_id` int NOT NULL, `source_route_id` int NOT NULL, CONSTRAINT `route_group_sources_group_route_id_token_routes_id_fk` FOREIGN KEY (`group_route_id`) REFERENCES `token_routes`(`id`) ON DELETE cascade, CONSTRAINT `route_group_sources_source_route_id_token_routes_id_fk` FOREIGN KEY (`source_route_id`) REFERENCES `token_routes`(`id`) ON DELETE cascade)', + 'CREATE UNIQUE INDEX IF NOT EXISTS `route_group_sources_group_source_unique` ON `route_group_sources` (`group_route_id`,`source_route_id`)', + 'CREATE INDEX IF NOT EXISTS `route_group_sources_source_route_id_idx` ON `route_group_sources` (`source_route_id`)', 'ALTER TABLE `token_routes` ADD COLUMN `display_name` TEXT NULL', 'ALTER TABLE `token_routes` ADD COLUMN `display_icon` TEXT NULL', + 'ALTER TABLE `token_routes` ADD COLUMN `route_mode` VARCHAR(32) NULL DEFAULT \'pattern\'', 'ALTER TABLE `token_routes` ADD COLUMN `decision_snapshot` TEXT NULL', 'ALTER TABLE `token_routes` ADD COLUMN `decision_refreshed_at` TEXT NULL', 'ALTER TABLE `token_routes` ADD COLUMN `routing_strategy` VARCHAR(32) NULL DEFAULT \'weighted\'', @@ -90,9 +104,10 @@ describe('ensureRouteGroupingSchemaCompatibility', () => { it('skips existing columns', async () => { const { inspector, executedSql } = createInspector('postgres', { existingColumnsByTable: { - token_routes: ['display_name', 'display_icon', 'decision_snapshot', 'decision_refreshed_at', 'routing_strategy'], + token_routes: ['display_name', 'display_icon', 'route_mode', 'decision_snapshot', 'decision_refreshed_at', 'routing_strategy'], route_channels: ['source_model', 'last_selected_at', 'consecutive_fail_count', 'cooldown_level'], }, + existingTables: ['token_routes', 'route_channels', 'route_group_sources'], }); await ensureRouteGroupingSchemaCompatibility(inspector); diff --git a/src/server/db/routeGroupingSchemaCompatibility.ts b/src/server/db/routeGroupingSchemaCompatibility.ts index c36183c..18b6c59 100644 --- a/src/server/db/routeGroupingSchemaCompatibility.ts +++ b/src/server/db/routeGroupingSchemaCompatibility.ts @@ -13,6 +13,11 @@ type RouteGroupingColumnCompatibilitySpec = { addSql: Record; }; +type RouteGroupingTableCompatibilitySpec = { + table: 'route_group_sources'; + createSql: Record; +}; + const ROUTE_GROUPING_COLUMN_COMPATIBILITY_SPECS: RouteGroupingColumnCompatibilitySpec[] = [ { table: 'token_routes', @@ -32,6 +37,15 @@ const ROUTE_GROUPING_COLUMN_COMPATIBILITY_SPECS: RouteGroupingColumnCompatibilit postgres: 'ALTER TABLE "token_routes" ADD COLUMN "display_icon" TEXT', }, }, + { + table: 'token_routes', + column: 'route_mode', + addSql: { + sqlite: 'ALTER TABLE token_routes ADD COLUMN route_mode text DEFAULT \'pattern\';', + mysql: 'ALTER TABLE `token_routes` ADD COLUMN `route_mode` VARCHAR(32) NULL DEFAULT \'pattern\'', + postgres: 'ALTER TABLE "token_routes" ADD COLUMN "route_mode" TEXT DEFAULT \'pattern\'', + }, + }, { table: 'token_routes', column: 'decision_snapshot', @@ -97,6 +111,29 @@ const ROUTE_GROUPING_COLUMN_COMPATIBILITY_SPECS: RouteGroupingColumnCompatibilit }, ]; +const ROUTE_GROUPING_TABLE_COMPATIBILITY_SPECS: RouteGroupingTableCompatibilitySpec[] = [ + { + table: 'route_group_sources', + createSql: { + sqlite: [ + 'CREATE TABLE IF NOT EXISTS route_group_sources (id integer PRIMARY KEY AUTOINCREMENT NOT NULL, group_route_id integer NOT NULL REFERENCES token_routes(id) ON DELETE cascade, source_route_id integer NOT NULL REFERENCES token_routes(id) ON DELETE cascade);', + 'CREATE UNIQUE INDEX IF NOT EXISTS route_group_sources_group_source_unique ON route_group_sources(group_route_id, source_route_id);', + 'CREATE INDEX IF NOT EXISTS route_group_sources_source_route_id_idx ON route_group_sources(source_route_id);', + ], + mysql: [ + 'CREATE TABLE IF NOT EXISTS `route_group_sources` (`id` int AUTO_INCREMENT NOT NULL PRIMARY KEY, `group_route_id` int NOT NULL, `source_route_id` int NOT NULL, CONSTRAINT `route_group_sources_group_route_id_token_routes_id_fk` FOREIGN KEY (`group_route_id`) REFERENCES `token_routes`(`id`) ON DELETE cascade, CONSTRAINT `route_group_sources_source_route_id_token_routes_id_fk` FOREIGN KEY (`source_route_id`) REFERENCES `token_routes`(`id`) ON DELETE cascade)', + 'CREATE UNIQUE INDEX IF NOT EXISTS `route_group_sources_group_source_unique` ON `route_group_sources` (`group_route_id`,`source_route_id`)', + 'CREATE INDEX IF NOT EXISTS `route_group_sources_source_route_id_idx` ON `route_group_sources` (`source_route_id`)', + ], + postgres: [ + 'CREATE TABLE IF NOT EXISTS "route_group_sources" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "group_route_id" INTEGER NOT NULL, "source_route_id" INTEGER NOT NULL, CONSTRAINT "route_group_sources_group_route_id_token_routes_id_fk" FOREIGN KEY ("group_route_id") REFERENCES "token_routes"("id") ON DELETE CASCADE, CONSTRAINT "route_group_sources_source_route_id_token_routes_id_fk" FOREIGN KEY ("source_route_id") REFERENCES "token_routes"("id") ON DELETE CASCADE)', + 'CREATE UNIQUE INDEX IF NOT EXISTS "route_group_sources_group_source_unique" ON "route_group_sources" ("group_route_id", "source_route_id")', + 'CREATE INDEX IF NOT EXISTS "route_group_sources_source_route_id_idx" ON "route_group_sources" ("source_route_id")', + ], + }, + }, +]; + function normalizeSchemaErrorMessage(error: unknown): string { if (typeof error === 'object' && error && 'message' in error) { return String((error as { message?: unknown }).message || ''); @@ -124,6 +161,18 @@ async function executeAddColumn(inspector: RouteGroupingSchemaInspector, sqlText export async function ensureRouteGroupingSchemaCompatibility(inspector: RouteGroupingSchemaInspector): Promise { const tableExistsCache = new Map(); + for (const spec of ROUTE_GROUPING_TABLE_COMPATIBILITY_SPECS) { + const hasTable = await inspector.tableExists(spec.table); + tableExistsCache.set(spec.table, hasTable); + if (hasTable) { + continue; + } + for (const sqlText of spec.createSql[inspector.dialect]) { + await executeAddColumn(inspector, sqlText); + } + tableExistsCache.set(spec.table, true); + } + for (const spec of ROUTE_GROUPING_COLUMN_COMPATIBILITY_SPECS) { let hasTable = tableExistsCache.get(spec.table); if (hasTable === undefined) { diff --git a/src/server/db/schema.ts b/src/server/db/schema.ts index 9b4f665..b6956e3 100644 --- a/src/server/db/schema.ts +++ b/src/server/db/schema.ts @@ -127,6 +127,7 @@ export const tokenRoutes = sqliteTable('token_routes', { modelPattern: text('model_pattern').notNull(), displayName: text('display_name'), displayIcon: text('display_icon'), + routeMode: text('route_mode').default('pattern'), modelMapping: text('model_mapping'), // JSON decisionSnapshot: text('decision_snapshot'), // JSON decisionRefreshedAt: text('decision_refreshed_at'), @@ -139,6 +140,15 @@ export const tokenRoutes = sqliteTable('token_routes', { enabledIdx: index('token_routes_enabled_idx').on(table.enabled), })); +export const routeGroupSources = sqliteTable('route_group_sources', { + id: integer('id').primaryKey({ autoIncrement: true }), + groupRouteId: integer('group_route_id').notNull().references(() => tokenRoutes.id, { onDelete: 'cascade' }), + sourceRouteId: integer('source_route_id').notNull().references(() => tokenRoutes.id, { onDelete: 'cascade' }), +}, (table) => ({ + groupSourceUnique: uniqueIndex('route_group_sources_group_source_unique').on(table.groupRouteId, table.sourceRouteId), + sourceRouteIdx: index('route_group_sources_source_route_id_idx').on(table.sourceRouteId), +})); + export const routeChannels = sqliteTable('route_channels', { id: integer('id').primaryKey({ autoIncrement: true }), routeId: integer('route_id').notNull().references(() => tokenRoutes.id, { onDelete: 'cascade' }), diff --git a/src/server/routes/api/tokens.route-update-rebuild.test.ts b/src/server/routes/api/tokens.route-update-rebuild.test.ts index ea25501..d3479be 100644 --- a/src/server/routes/api/tokens.route-update-rebuild.test.ts +++ b/src/server/routes/api/tokens.route-update-rebuild.test.ts @@ -149,4 +149,180 @@ describe('PUT /api/routes/:id route rebuild', () => { expect(rebuiltAuto?.priority).toBe(0); expect(rebuiltAuto?.weight).toBe(10); }); + + it('creates explicit-group routes with sourceRouteIds and aggregates source channels', async () => { + const sourceA = await seedAccountWithToken('claude-opus-4-5'); + const sourceB = await seedAccountWithToken('claude-sonnet-4-5'); + + const exactRouteA = await db.insert(schema.tokenRoutes).values({ + modelPattern: 'claude-opus-4-5', + enabled: true, + }).returning().get(); + const exactRouteB = await db.insert(schema.tokenRoutes).values({ + modelPattern: 'claude-sonnet-4-5', + enabled: true, + }).returning().get(); + + await db.insert(schema.routeChannels).values([ + { + routeId: exactRouteA.id, + accountId: sourceA.account.id, + tokenId: sourceA.token.id, + sourceModel: 'claude-opus-4-5', + priority: 0, + weight: 10, + enabled: true, + manualOverride: false, + }, + { + routeId: exactRouteB.id, + accountId: sourceB.account.id, + tokenId: sourceB.token.id, + sourceModel: 'claude-sonnet-4-5', + priority: 1, + weight: 8, + enabled: true, + manualOverride: false, + }, + ]).run(); + + const createResponse = await app.inject({ + method: 'POST', + url: '/api/routes', + payload: { + routeMode: 'explicit_group', + displayName: 'claude-opus-4-6', + sourceRouteIds: [exactRouteA.id, exactRouteB.id], + routingStrategy: 'weighted', + }, + }); + + expect(createResponse.statusCode).toBe(200); + expect(createResponse.json()).toMatchObject({ + displayName: 'claude-opus-4-6', + routeMode: 'explicit_group', + sourceRouteIds: [exactRouteA.id, exactRouteB.id], + }); + + const createdRouteId = (createResponse.json() as { id: number }).id; + + const storedChannels = await db.select().from(schema.routeChannels) + .where(eq(schema.routeChannels.routeId, createdRouteId)) + .all(); + expect(storedChannels).toHaveLength(0); + + const summaryResponse = await app.inject({ + method: 'GET', + url: '/api/routes/summary', + }); + expect(summaryResponse.statusCode).toBe(200); + expect(summaryResponse.json()).toContainEqual(expect.objectContaining({ + id: createdRouteId, + routeMode: 'explicit_group', + sourceRouteIds: [exactRouteA.id, exactRouteB.id], + channelCount: 2, + enabledChannelCount: 2, + siteNames: expect.arrayContaining([sourceA.site.name, sourceB.site.name]), + })); + + const channelsResponse = await app.inject({ + method: 'GET', + url: `/api/routes/${createdRouteId}/channels`, + }); + expect(channelsResponse.statusCode).toBe(200); + expect(channelsResponse.json()).toEqual(expect.arrayContaining([ + expect.objectContaining({ + routeId: exactRouteA.id, + accountId: sourceA.account.id, + sourceModel: 'claude-opus-4-5', + }), + expect.objectContaining({ + routeId: exactRouteB.id, + accountId: sourceB.account.id, + sourceModel: 'claude-sonnet-4-5', + }), + ])); + }); + + it('rejects invalid explicit-group payloads', async () => { + const response = await app.inject({ + method: 'POST', + url: '/api/routes', + payload: { + routeMode: 'explicit_group', + displayName: '', + sourceRouteIds: [], + }, + }); + + expect(response.statusCode).toBe(400); + expect(response.json()).toMatchObject({ + success: false, + }); + }); + + it('prefers an exact route over a colliding explicit-group display name', async () => { + const exactCandidate = await seedAccountWithToken('claude-opus-4-6'); + const groupedCandidate = await seedAccountWithToken('claude-opus-4-5'); + + const exactRoute = await db.insert(schema.tokenRoutes).values({ + modelPattern: 'claude-opus-4-6', + enabled: true, + }).returning().get(); + const sourceRoute = await db.insert(schema.tokenRoutes).values({ + modelPattern: 'claude-opus-4-5', + enabled: true, + }).returning().get(); + + await db.insert(schema.routeChannels).values([ + { + routeId: exactRoute.id, + accountId: exactCandidate.account.id, + tokenId: exactCandidate.token.id, + sourceModel: 'claude-opus-4-6', + priority: 0, + weight: 10, + enabled: true, + manualOverride: false, + }, + { + routeId: sourceRoute.id, + accountId: groupedCandidate.account.id, + tokenId: groupedCandidate.token.id, + sourceModel: 'claude-opus-4-5', + priority: 0, + weight: 10, + enabled: true, + manualOverride: false, + }, + ]).run(); + + const groupResponse = await app.inject({ + method: 'POST', + url: '/api/routes', + payload: { + routeMode: 'explicit_group', + displayName: 'claude-opus-4-6', + sourceRouteIds: [sourceRoute.id], + }, + }); + + expect(groupResponse.statusCode).toBe(200); + + const decisionResponse = await app.inject({ + method: 'GET', + url: '/api/routes/decision?model=claude-opus-4-6', + }); + + expect(decisionResponse.statusCode).toBe(200); + expect(decisionResponse.json()).toMatchObject({ + success: true, + decision: { + matched: true, + routeId: exactRoute.id, + modelPattern: 'claude-opus-4-6', + actualModel: 'claude-opus-4-6', + }, + }); + }); }); diff --git a/src/server/routes/api/tokens.ts b/src/server/routes/api/tokens.ts index bb759db..298f531 100644 --- a/src/server/routes/api/tokens.ts +++ b/src/server/routes/api/tokens.ts @@ -22,6 +22,138 @@ function isExactModelPattern(modelPattern: string): boolean { if (normalized.toLowerCase().startsWith('re:')) return false; return !/[\*\?]/.test(normalized); } + +type RouteMode = 'pattern' | 'explicit_group'; +type RouteRow = typeof schema.tokenRoutes.$inferSelect & { + routeMode: RouteMode; + sourceRouteIds: number[]; +}; + +function normalizeRouteMode(routeMode: unknown): RouteMode { + return routeMode === 'explicit_group' ? 'explicit_group' : 'pattern'; +} + +function isExplicitGroupRoute(route: Pick | Pick): boolean { + return normalizeRouteMode(route.routeMode) === 'explicit_group'; +} + +function normalizeSourceRouteIdsInput(input: unknown): number[] { + const rawValues = Array.isArray(input) ? input : []; + const normalized: number[] = []; + for (const raw of rawValues) { + const value = Number(raw); + if (!Number.isFinite(value)) continue; + const routeId = Math.trunc(value); + if (routeId <= 0 || normalized.includes(routeId)) continue; + normalized.push(routeId); + if (normalized.length >= 500) break; + } + return normalized; +} + +async function loadRouteSourceIdsMap(routeIds: number[]): Promise> { + const normalizedRouteIds = Array.from(new Set(routeIds.filter((routeId) => Number.isFinite(routeId) && routeId > 0))); + if (normalizedRouteIds.length === 0) return new Map(); + + const rows = await db.select().from(schema.routeGroupSources) + .where(inArray(schema.routeGroupSources.groupRouteId, normalizedRouteIds)) + .all(); + const sourceRouteIdsByRouteId = new Map(); + for (const row of rows) { + if (!sourceRouteIdsByRouteId.has(row.groupRouteId)) { + sourceRouteIdsByRouteId.set(row.groupRouteId, []); + } + sourceRouteIdsByRouteId.get(row.groupRouteId)!.push(row.sourceRouteId); + } + for (const [routeId, sourceRouteIds] of sourceRouteIdsByRouteId.entries()) { + sourceRouteIdsByRouteId.set(routeId, Array.from(new Set(sourceRouteIds))); + } + return sourceRouteIdsByRouteId; +} + +function decorateRoutesWithSources( + routes: Array, + sourceRouteIdsByRouteId: Map, +): RouteRow[] { + return routes.map((route) => ({ + ...route, + routeMode: normalizeRouteMode(route.routeMode), + sourceRouteIds: sourceRouteIdsByRouteId.get(route.id) ?? [], + })); +} + +async function listRoutesWithSources(): Promise { + const routes = await db.select().from(schema.tokenRoutes).all(); + const sourceRouteIdsByRouteId = await loadRouteSourceIdsMap(routes.map((route) => route.id)); + return decorateRoutesWithSources(routes, sourceRouteIdsByRouteId); +} + +async function getRouteWithSources(routeId: number): Promise { + const route = await db.select().from(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, routeId)).get(); + if (!route) return null; + const sourceRouteIdsByRouteId = await loadRouteSourceIdsMap([routeId]); + return decorateRoutesWithSources([route], sourceRouteIdsByRouteId)[0] ?? null; +} + +async function validateExplicitGroupSourceRoutes(sourceRouteIds: number[], currentRouteId?: number): Promise<{ ok: true } | { ok: false; message: string }> { + if (sourceRouteIds.length === 0) { + return { ok: false, message: '显式群组至少需要选择一个来源模型' }; + } + + const routes = await db.select().from(schema.tokenRoutes) + .where(inArray(schema.tokenRoutes.id, sourceRouteIds)) + .all(); + if (routes.length !== sourceRouteIds.length) { + return { ok: false, message: '来源模型中存在不存在的路由' }; + } + + for (const route of routes) { + if (currentRouteId && route.id === currentRouteId) { + return { ok: false, message: '显式群组不能引用自身作为来源模型' }; + } + if (normalizeRouteMode(route.routeMode) === 'explicit_group') { + return { ok: false, message: '显式群组只能选择精确模型路由作为来源模型' }; + } + if (!isExactModelPattern(route.modelPattern)) { + return { ok: false, message: '显式群组只能选择精确模型路由作为来源模型' }; + } + } + + return { ok: true }; +} + +async function replaceRouteSourceRouteIds(routeId: number, sourceRouteIds: number[]): Promise { + await db.delete(schema.routeGroupSources).where(eq(schema.routeGroupSources.groupRouteId, routeId)).run(); + if (sourceRouteIds.length === 0) return; + await db.insert(schema.routeGroupSources).values( + sourceRouteIds.map((sourceRouteId) => ({ + groupRouteId: routeId, + sourceRouteId, + })), + ).run(); +} + +async function clearDependentExplicitGroupSnapshotsBySourceRouteIds(sourceRouteIds: number[]): Promise { + const normalizedSourceRouteIds = Array.from(new Set( + sourceRouteIds.filter((routeId): routeId is number => Number.isFinite(routeId) && routeId > 0), + )); + if (normalizedSourceRouteIds.length === 0) return; + + const rows = await db.select({ groupRouteId: schema.routeGroupSources.groupRouteId }) + .from(schema.routeGroupSources) + .where(inArray(schema.routeGroupSources.sourceRouteId, normalizedSourceRouteIds)) + .all(); + const dependentRouteIdSet = new Set(); + for (const row of rows) { + const routeId = Number(row.groupRouteId); + if (Number.isFinite(routeId) && routeId > 0) { + dependentRouteIdSet.add(routeId); + } + } + const dependentRouteIds = Array.from(dependentRouteIdSet); + if (dependentRouteIds.length === 0) return; + await clearRouteDecisionSnapshots(dependentRouteIds); +} async function getDefaultTokenId(accountId: number): Promise { const token = await db.select().from(schema.accountTokens) @@ -390,25 +522,54 @@ function parseBatchRouteWideDecisionRouteIds( }; } -async function fetchChannelsForRoutes(routeIds: number[]): Promise> { - if (routeIds.length === 0) return new Map(); - - const channelRows = await db.select().from(schema.routeChannels) - .innerJoin(schema.accounts, eq(schema.routeChannels.accountId, schema.accounts.id)) - .innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id)) - .leftJoin(schema.accountTokens, eq(schema.routeChannels.tokenId, schema.accountTokens.id)) - .where(inArray(schema.routeChannels.routeId, routeIds)) - .all(); - - const channelsByRoute = new Map(); - - for (const row of channelRows) { - const routeId = row.route_channels.routeId; - if (!channelsByRoute.has(routeId)) channelsByRoute.set(routeId, []); - channelsByRoute.get(routeId)!.push({ - ...row.route_channels, - account: row.accounts, - site: row.sites, +type RouteChannelSummary = { + channelCount: number; + enabledChannelCount: number; + siteNames: Set; +}; + +async function fetchChannelsForRouteRows(routes: RouteRow[]): Promise> { + if (routes.length === 0) return new Map(); + + const explicitSourceRouteIds = Array.from(new Set(routes + .filter((route) => isExplicitGroupRoute(route)) + .flatMap((route) => route.sourceRouteIds))); + const enabledExplicitSourceRouteIds = explicitSourceRouteIds.length > 0 + ? (await db.select({ + id: schema.tokenRoutes.id, + modelPattern: schema.tokenRoutes.modelPattern, + routeMode: schema.tokenRoutes.routeMode, + enabled: schema.tokenRoutes.enabled, + }).from(schema.tokenRoutes) + .where(inArray(schema.tokenRoutes.id, explicitSourceRouteIds)) + .all()) + .filter((route) => route.enabled && !isExplicitGroupRoute(route) && isExactModelPattern(route.modelPattern)) + .map((route) => route.id) + : []; + const actualRouteIds = Array.from(new Set([ + ...routes.filter((route) => !isExplicitGroupRoute(route)).map((route) => route.id), + ...enabledExplicitSourceRouteIds, + ])); + if (actualRouteIds.length === 0) { + return new Map(routes.map((route) => [route.id, []])); + } + + const channelRows = await db.select().from(schema.routeChannels) + .innerJoin(schema.accounts, eq(schema.routeChannels.accountId, schema.accounts.id)) + .innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id)) + .leftJoin(schema.accountTokens, eq(schema.routeChannels.tokenId, schema.accountTokens.id)) + .where(inArray(schema.routeChannels.routeId, actualRouteIds)) + .all(); + + const channelsByActualRouteId = new Map(); + + for (const row of channelRows) { + const routeId = row.route_channels.routeId; + if (!channelsByActualRouteId.has(routeId)) channelsByActualRouteId.set(routeId, []); + channelsByActualRouteId.get(routeId)!.push({ + ...row.route_channels, + account: row.accounts, + site: row.sites, token: row.account_tokens ? { id: row.account_tokens.id, @@ -417,63 +578,83 @@ async function fetchChannelsForRoutes(routeIds: number[]): Promise { - return await db.select({ - id: schema.tokenRoutes.id, - modelPattern: schema.tokenRoutes.modelPattern, - displayName: schema.tokenRoutes.displayName, - displayIcon: schema.tokenRoutes.displayIcon, - routingStrategy: schema.tokenRoutes.routingStrategy, - enabled: schema.tokenRoutes.enabled, - }).from(schema.tokenRoutes).all(); + : null, + }); + } + + const channelsByRoute = new Map(); + for (const route of routes) { + if (isExplicitGroupRoute(route)) { + channelsByRoute.set(route.id, route.sourceRouteIds.flatMap((sourceRouteId) => channelsByActualRouteId.get(sourceRouteId) || [])); + continue; + } + channelsByRoute.set(route.id, channelsByActualRouteId.get(route.id) || []); + } + + return channelsByRoute; +} + +async function fetchChannelsForRoutes(routeIds: number[]): Promise> { + if (routeIds.length === 0) return new Map(); + return await fetchChannelsForRouteRows(await listRoutesWithSources()).then((channelsByRoute) => { + const filtered = new Map(); + for (const routeId of routeIds) { + filtered.set(routeId, channelsByRoute.get(routeId) || []); + } + return filtered; }); +} + +async function buildRouteChannelSummaryMap(routes: RouteRow[]): Promise> { + const channelsByRoute = await fetchChannelsForRouteRows(routes); + const summaryByRoute = new Map(); + for (const route of routes) { + const channels = channelsByRoute.get(route.id) || []; + const siteNames = new Set(); + let enabledChannelCount = 0; + for (const channel of channels) { + if (channel.enabled) enabledChannelCount += 1; + if (channel.site?.name) siteNames.add(channel.site.name); + } + summaryByRoute.set(route.id, { + channelCount: channels.length, + enabledChannelCount, + siteNames, + }); + } + return summaryByRoute; +} - // Route summary (no channel details) for first-screen rendering - app.get('/api/routes/summary', async () => { - const routes = await db.select().from(schema.tokenRoutes).all(); - if (routes.length === 0) return []; - - const routeIds = routes.map((route) => route.id); - - // Aggregate channel counts and site names per route - const channelRows = await db.select({ - routeId: schema.routeChannels.routeId, - enabled: schema.routeChannels.enabled, - siteName: schema.sites.name, - }).from(schema.routeChannels) - .innerJoin(schema.accounts, eq(schema.routeChannels.accountId, schema.accounts.id)) - .innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id)) - .where(inArray(schema.routeChannels.routeId, routeIds)) - .all(); - - const aggByRoute = new Map }>(); - for (const row of channelRows) { - let agg = aggByRoute.get(row.routeId); - if (!agg) { - agg = { channelCount: 0, enabledChannelCount: 0, siteNames: new Set() }; - aggByRoute.set(row.routeId, agg); - } - agg.channelCount += 1; - if (row.enabled) agg.enabledChannelCount += 1; - if (row.siteName) agg.siteNames.add(row.siteName); - } - - return routes.map((route) => { - const agg = aggByRoute.get(route.id); - return { - id: route.id, - modelPattern: route.modelPattern, - displayName: route.displayName ?? null, +export async function tokensRoutes(app: FastifyInstance) { + // List routes with basic info only (lightweight for selectors) + app.get('/api/routes/lite', async () => { + return (await listRoutesWithSources()).map((route) => ({ + id: route.id, + modelPattern: route.modelPattern, + displayName: route.displayName, + displayIcon: route.displayIcon, + routeMode: route.routeMode, + sourceRouteIds: route.sourceRouteIds, + routingStrategy: route.routingStrategy, + enabled: route.enabled, + })); + }); + + // Route summary (no channel details) for first-screen rendering + app.get('/api/routes/summary', async () => { + const routes = await listRoutesWithSources(); + if (routes.length === 0) return []; + const aggByRoute = await buildRouteChannelSummaryMap(routes); + + return routes.map((route) => { + const agg = aggByRoute.get(route.id); + return { + id: route.id, + modelPattern: route.modelPattern, + displayName: route.displayName ?? null, displayIcon: route.displayIcon ?? null, + routeMode: route.routeMode, + sourceRouteIds: route.sourceRouteIds, modelMapping: route.modelMapping ?? null, routingStrategy: route.routingStrategy ?? 'weighted', enabled: route.enabled, @@ -486,26 +667,29 @@ export async function tokensRoutes(app: FastifyInstance) { }); }); - // Get channels for a single route (on-demand loading) - app.get<{ Params: { id: string } }>('/api/routes/:id/channels', async (request, reply) => { - const routeId = parseInt(request.params.id, 10); - const route = await db.select().from(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, routeId)).get(); - if (!route) { - return reply.code(404).send({ success: false, message: '路由不存在' }); - } - const channelsByRoute = await fetchChannelsForRoutes([routeId]); - return channelsByRoute.get(routeId) || []; - }); + // Get channels for a single route (on-demand loading) + app.get<{ Params: { id: string } }>('/api/routes/:id/channels', async (request, reply) => { + const routeId = parseInt(request.params.id, 10); + const route = await getRouteWithSources(routeId); + if (!route) { + return reply.code(404).send({ success: false, message: '路由不存在' }); + } + const channelsByRoute = await fetchChannelsForRouteRows([route]); + return channelsByRoute.get(routeId) || []; + }); // Batch add channels to a route - app.post<{ Params: { id: string }; Body: { channels: Array<{ accountId: number; tokenId?: number; sourceModel?: string }> } }>('/api/routes/:id/channels/batch', async (request, reply) => { - const routeId = parseInt(request.params.id, 10); - const body = request.body; - - const route = await db.select().from(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, routeId)).get(); - if (!route) { - return reply.code(404).send({ success: false, message: '路由不存在' }); - } + app.post<{ Params: { id: string }; Body: { channels: Array<{ accountId: number; tokenId?: number; sourceModel?: string }> } }>('/api/routes/:id/channels/batch', async (request, reply) => { + const routeId = parseInt(request.params.id, 10); + const body = request.body; + + const route = await getRouteWithSources(routeId); + if (!route) { + return reply.code(404).send({ success: false, message: '路由不存在' }); + } + if (isExplicitGroupRoute(route)) { + return reply.code(400).send({ success: false, message: '显式群组不支持直接维护通道' }); + } if (!body?.channels || !Array.isArray(body.channels) || body.channels.length === 0) { return reply.code(400).send({ success: false, message: 'channels 必须是非空数组' }); @@ -566,24 +750,24 @@ export async function tokensRoutes(app: FastifyInstance) { } } - if (created > 0) { - await clearRouteDecisionSnapshot(routeId); - invalidateTokenRouterCache(); - } + if (created > 0) { + await clearRouteDecisionSnapshot(routeId); + await clearDependentExplicitGroupSnapshotsBySourceRouteIds([routeId]); + invalidateTokenRouterCache(); + } return { success: true, created, skipped, errors }; }); - // List all routes - app.get('/api/routes', async () => { - const routes = await db.select().from(schema.tokenRoutes).all(); - if (routes.length === 0) return []; - - const routeIds = routes.map((route) => route.id); - const channelsByRoute = await fetchChannelsForRoutes(routeIds); - - return routes.map((route) => ({ - ...route, + // List all routes + app.get('/api/routes', async () => { + const routes = await listRoutesWithSources(); + if (routes.length === 0) return []; + + const channelsByRoute = await fetchChannelsForRouteRows(routes); + + return routes.map((route) => ({ + ...route, decisionSnapshot: parseRouteDecisionSnapshot(route.decisionSnapshot), decisionRefreshedAt: route.decisionRefreshedAt ?? null, channels: channelsByRoute.get(route.id) || [], @@ -689,87 +873,143 @@ export async function tokensRoutes(app: FastifyInstance) { return { success: true, decisions }; }); - // Create a route - app.post<{ Body: { modelPattern: string; displayName?: string; displayIcon?: string; modelMapping?: string; routingStrategy?: string; enabled?: boolean } }>('/api/routes', async (request) => { + // Create a route + app.post<{ Body: { routeMode?: string; modelPattern?: string; displayName?: string; displayIcon?: string; modelMapping?: string; routingStrategy?: string; enabled?: boolean; sourceRouteIds?: number[] } }>('/api/routes', async (request, reply) => { const body = request.body; + const routeMode = normalizeRouteMode(body.routeMode); + const displayName = typeof body.displayName === 'string' ? body.displayName.trim() : ''; + const sourceRouteIds = normalizeSourceRouteIdsInput(body.sourceRouteIds); + const modelPattern = routeMode === 'explicit_group' + ? displayName + : (typeof body.modelPattern === 'string' ? body.modelPattern.trim() : ''); + + if (routeMode === 'explicit_group') { + if (!displayName) { + return reply.code(400).send({ success: false, message: '显式群组必须填写对外模型名' }); + } + const validation = await validateExplicitGroupSourceRoutes(sourceRouteIds); + if (!validation.ok) { + return reply.code(400).send({ success: false, message: validation.message }); + } + } else if (!modelPattern) { + return reply.code(400).send({ success: false, message: '模型匹配不能为空' }); + } + const insertedRoute = await db.insert(schema.tokenRoutes).values({ - modelPattern: body.modelPattern, - displayName: body.displayName, + modelPattern, + displayName: displayName || body.displayName, displayIcon: body.displayIcon, + routeMode, modelMapping: body.modelMapping, routingStrategy: normalizeRouteRoutingStrategy(body.routingStrategy), enabled: body.enabled ?? true, }).run(); - const routeId = Number(insertedRoute.lastInsertRowid || 0); - if (routeId <= 0) { - return { success: false, message: '创建路由失败' }; - } - const route = await db.select().from(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, routeId)).get(); - if (!route) { - return { success: false, message: '创建路由失败' }; - } - - await populateRouteChannelsByModelPattern(route.id, body.modelPattern); - invalidateTokenRouterCache(); - return route; - }); - - // Update a route - app.put<{ Params: { id: string }; Body: any }>('/api/routes/:id', async (request, reply) => { - const id = parseInt(request.params.id, 10); - const body = request.body as Record; - const existingRoute = await db.select().from(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, id)).get(); - if (!existingRoute) { - return reply.code(404).send({ success: false, message: '路由不存在' }); - } - - const updates: Record = {}; - let nextModelPattern = existingRoute.modelPattern; - - if (body.displayName !== undefined) updates.displayName = body.displayName; - if (body.displayIcon !== undefined) updates.displayIcon = body.displayIcon; - if (body.modelPattern !== undefined) { + const routeId = Number(insertedRoute.lastInsertRowid || 0); + if (routeId <= 0) { + return { success: false, message: '创建路由失败' }; + } + const route = await getRouteWithSources(routeId); + if (!route) { + return { success: false, message: '创建路由失败' }; + } + + if (routeMode === 'explicit_group') { + await replaceRouteSourceRouteIds(route.id, sourceRouteIds); + } else { + await populateRouteChannelsByModelPattern(route.id, modelPattern); + } + invalidateTokenRouterCache(); + return await getRouteWithSources(routeId); + }); + + // Update a route + app.put<{ Params: { id: string }; Body: any }>('/api/routes/:id', async (request, reply) => { + const id = parseInt(request.params.id, 10); + const body = request.body as Record; + const existingRoute = await getRouteWithSources(id); + if (!existingRoute) { + return reply.code(404).send({ success: false, message: '路由不存在' }); + } + const routeMode = normalizeRouteMode(body.routeMode ?? existingRoute.routeMode); + if (routeMode !== existingRoute.routeMode) { + return reply.code(400).send({ success: false, message: '暂不支持在不同群组模式之间直接切换' }); + } + + const updates: Record = {}; + let nextModelPattern = existingRoute.modelPattern; + let nextDisplayName = existingRoute.displayName ?? ''; + let nextSourceRouteIds = existingRoute.sourceRouteIds; + + if (body.displayName !== undefined) { + nextDisplayName = String(body.displayName || '').trim(); + updates.displayName = nextDisplayName || null; + } + if (body.displayIcon !== undefined) updates.displayIcon = body.displayIcon; + if (routeMode === 'explicit_group') { + nextModelPattern = nextDisplayName; + updates.modelPattern = nextModelPattern; + if (body.sourceRouteIds !== undefined) { + nextSourceRouteIds = normalizeSourceRouteIdsInput(body.sourceRouteIds); + } + if (!nextDisplayName) { + return reply.code(400).send({ success: false, message: '显式群组必须填写对外模型名' }); + } + const validation = await validateExplicitGroupSourceRoutes(nextSourceRouteIds, id); + if (!validation.ok) { + return reply.code(400).send({ success: false, message: validation.message }); + } + } else if (body.modelPattern !== undefined) { nextModelPattern = String(body.modelPattern); updates.modelPattern = nextModelPattern; } if (body.modelMapping !== undefined) updates.modelMapping = body.modelMapping; if (body.routingStrategy !== undefined) updates.routingStrategy = normalizeRouteRoutingStrategy(body.routingStrategy); if (body.enabled !== undefined) updates.enabled = body.enabled; + if (body.routeMode !== undefined) updates.routeMode = routeMode; updates.updatedAt = new Date().toISOString(); await db.update(schema.tokenRoutes).set(updates).where(eq(schema.tokenRoutes.id, id)).run(); - const modelPatternChanged = body.modelPattern !== undefined && nextModelPattern !== existingRoute.modelPattern; + if (routeMode === 'explicit_group' && body.sourceRouteIds !== undefined) { + await replaceRouteSourceRouteIds(id, nextSourceRouteIds); + } + const modelPatternChanged = nextModelPattern !== existingRoute.modelPattern; const routeBehaviorChanged = modelPatternChanged + || (routeMode === 'explicit_group' && body.sourceRouteIds !== undefined) || body.modelMapping !== undefined || body.routingStrategy !== undefined || body.enabled !== undefined; - if (modelPatternChanged) { - await rebuildAutomaticRouteChannelsByModelPattern(id, nextModelPattern); - } - if (routeBehaviorChanged) { - await clearRouteDecisionSnapshot(id); - } - invalidateTokenRouterCache(); - return await db.select().from(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, id)).get(); - }); + if (routeMode === 'pattern' && modelPatternChanged) { + await rebuildAutomaticRouteChannelsByModelPattern(id, nextModelPattern); + } + if (routeBehaviorChanged) { + await clearRouteDecisionSnapshot(id); + await clearDependentExplicitGroupSnapshotsBySourceRouteIds([id]); + } + invalidateTokenRouterCache(); + return await getRouteWithSources(id); + }); // Delete a route - app.delete<{ Params: { id: string } }>('/api/routes/:id', async (request) => { - const id = parseInt(request.params.id, 10); - await db.delete(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, id)).run(); - invalidateTokenRouterCache(); - return { success: true }; - }); + app.delete<{ Params: { id: string } }>('/api/routes/:id', async (request) => { + const id = parseInt(request.params.id, 10); + await clearDependentExplicitGroupSnapshotsBySourceRouteIds([id]); + await db.delete(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, id)).run(); + invalidateTokenRouterCache(); + return { success: true }; + }); // Add a channel to a route - app.post<{ Params: { id: string }; Body: { accountId: number; tokenId?: number; sourceModel?: string; priority?: number; weight?: number } }>('/api/routes/:id/channels', async (request, reply) => { - const routeId = parseInt(request.params.id, 10); - const body = request.body; - - const route = await db.select().from(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, routeId)).get(); - if (!route) { - return reply.code(404).send({ success: false, message: '路由不存在' }); - } + app.post<{ Params: { id: string }; Body: { accountId: number; tokenId?: number; sourceModel?: string; priority?: number; weight?: number } }>('/api/routes/:id/channels', async (request, reply) => { + const routeId = parseInt(request.params.id, 10); + const body = request.body; + + const route = await getRouteWithSources(routeId); + if (!route) { + return reply.code(404).send({ success: false, message: '路由不存在' }); + } + if (isExplicitGroupRoute(route)) { + return reply.code(400).send({ success: false, message: '显式群组不支持直接维护通道' }); + } const sourceModel = typeof body.sourceModel === 'string' ? body.sourceModel.trim() @@ -811,11 +1051,12 @@ export async function tokensRoutes(app: FastifyInstance) { const created = await db.select().from(schema.routeChannels).where(eq(schema.routeChannels.id, channelId)).get(); if (!created) { return reply.code(500).send({ success: false, message: '创建通道失败' }); - } - await clearRouteDecisionSnapshot(routeId); - invalidateTokenRouterCache(); - return created; - }); + } + await clearRouteDecisionSnapshot(routeId); + await clearDependentExplicitGroupSnapshotsBySourceRouteIds([routeId]); + invalidateTokenRouterCache(); + return created; + }); // Batch update channel priorities app.put<{ Body: { updates: Array<{ id: number; priority: number }> } }>('/api/channels/batch', async (request, reply) => { @@ -841,13 +1082,14 @@ export async function tokensRoutes(app: FastifyInstance) { }).where(eq(schema.routeChannels.id, update.id)).run(); } - const updatedChannels = await db.select().from(schema.routeChannels) - .where(inArray(schema.routeChannels.id, channelIds)) - .all(); - await clearRouteDecisionSnapshots(existingChannels.map((channel) => channel.routeId)); - invalidateTokenRouterCache(); - return { success: true, channels: updatedChannels }; - }); + const updatedChannels = await db.select().from(schema.routeChannels) + .where(inArray(schema.routeChannels.id, channelIds)) + .all(); + await clearRouteDecisionSnapshots(existingChannels.map((channel) => channel.routeId)); + await clearDependentExplicitGroupSnapshotsBySourceRouteIds(existingChannels.map((channel) => channel.routeId)); + invalidateTokenRouterCache(); + return { success: true, channels: updatedChannels }; + }); // Update a channel app.put<{ Params: { channelId: string }; Body: any }>('/api/channels/:channelId', async (request, reply) => { @@ -889,23 +1131,25 @@ export async function tokensRoutes(app: FastifyInstance) { if (body[key] !== undefined) updates[key] = body[key]; } - await db.update(schema.routeChannels).set(updates).where(eq(schema.routeChannels.id, channelId)).run(); - await clearRouteDecisionSnapshot(channel.routeId); - invalidateTokenRouterCache(); - return await db.select().from(schema.routeChannels).where(eq(schema.routeChannels.id, channelId)).get(); - }); + await db.update(schema.routeChannels).set(updates).where(eq(schema.routeChannels.id, channelId)).run(); + await clearRouteDecisionSnapshot(channel.routeId); + await clearDependentExplicitGroupSnapshotsBySourceRouteIds([channel.routeId]); + invalidateTokenRouterCache(); + return await db.select().from(schema.routeChannels).where(eq(schema.routeChannels.id, channelId)).get(); + }); // Delete a channel app.delete<{ Params: { channelId: string } }>('/api/channels/:channelId', async (request) => { const channelId = parseInt(request.params.channelId, 10); const channel = await db.select().from(schema.routeChannels).where(eq(schema.routeChannels.id, channelId)).get(); - await db.delete(schema.routeChannels).where(eq(schema.routeChannels.id, channelId)).run(); - if (channel) { - await clearRouteDecisionSnapshot(channel.routeId); - } - invalidateTokenRouterCache(); - return { success: true }; - }); + await db.delete(schema.routeChannels).where(eq(schema.routeChannels.id, channelId)).run(); + if (channel) { + await clearRouteDecisionSnapshot(channel.routeId); + await clearDependentExplicitGroupSnapshotsBySourceRouteIds([channel.routeId]); + } + invalidateTokenRouterCache(); + return { success: true }; + }); // Rebuild routes/channels from model availability. app.post<{ Body?: { refreshModels?: boolean; wait?: boolean } }>('/api/routes/rebuild', async (request, reply) => { diff --git a/src/server/routes/proxy/chat.stream.test.ts b/src/server/routes/proxy/chat.stream.test.ts index ed1e39b..80cfcaf 100644 --- a/src/server/routes/proxy/chat.stream.test.ts +++ b/src/server/routes/proxy/chat.stream.test.ts @@ -1384,6 +1384,15 @@ describe('chat proxy stream behavior', () => { status: 400, headers: { 'content-type': 'application/json' }, })) + .mockResolvedValueOnce(new Response(JSON.stringify({ + error: { + message: 'request validation failed', + type: 'invalid_request_error', + }, + }), { + status: 400, + headers: { 'content-type': 'application/json' }, + })) .mockResolvedValueOnce(new Response(JSON.stringify({ id: 'resp_retry_minimal_headers', object: 'response', @@ -1413,21 +1422,27 @@ describe('chat proxy stream behavior', () => { const body = response.json(); expect(body.output_text).toContain('ok after minimal headers retry'); - expect(fetchMock).toHaveBeenCalledTimes(3); + expect(fetchMock).toHaveBeenCalledTimes(4); const [, firstOptions] = fetchMock.mock.calls[0] as [string, any]; const [, secondOptions] = fetchMock.mock.calls[1] as [string, any]; const [, thirdOptions] = fetchMock.mock.calls[2] as [string, any]; + const [, fourthOptions] = fetchMock.mock.calls[3] as [string, any]; expect(firstOptions.headers['openai-beta']).toBe('responses-2025-03-11'); expect(secondOptions.headers['openai-beta']).toBe('responses-2025-03-11'); - expect(thirdOptions.headers['openai-beta']).toBeUndefined(); + expect(thirdOptions.headers['openai-beta']).toBe('responses-2025-03-11'); + expect(fourthOptions.headers['openai-beta']).toBeUndefined(); const firstBody = JSON.parse(firstOptions.body); const secondBody = JSON.parse(secondOptions.body); const thirdBody = JSON.parse(thirdOptions.body); + const fourthBody = JSON.parse(fourthOptions.body); expect(firstBody.user).toBe('user-123'); expect(secondBody.user).toBeUndefined(); expect(thirdBody.user).toBeUndefined(); + expect(secondBody.include).toEqual(['reasoning.encrypted_content']); + expect(thirdBody.include).toBeUndefined(); + expect(fourthBody.user).toBeUndefined(); }); it('returns concise Cloudflare host error on /v1/responses 502 html failures', async () => { diff --git a/src/server/routes/proxy/models.test.ts b/src/server/routes/proxy/models.test.ts index fad0be3..bdeea62 100644 --- a/src/server/routes/proxy/models.test.ts +++ b/src/server/routes/proxy/models.test.ts @@ -7,11 +7,13 @@ import { join } from 'node:path'; type DbModule = typeof import('../../db/index.js'); type ProxyRouterModule = typeof import('./router.js'); type TokenRouterModule = typeof import('../../services/tokenRouter.js'); +type TokensRoutesModule = typeof import('../api/tokens.js'); describe('/v1/models route', () => { let db: DbModule['db']; let schema: DbModule['schema']; let proxyRoutes: ProxyRouterModule['proxyRoutes']; + let tokensRoutes: TokensRoutesModule['tokensRoutes']; let invalidateTokenRouterCache: TokenRouterModule['invalidateTokenRouterCache']; let app: FastifyInstance; let dataDir = ''; @@ -24,13 +26,16 @@ describe('/v1/models route', () => { const dbModule = await import('../../db/index.js'); const proxyRouterModule = await import('./router.js'); const tokenRouterModule = await import('../../services/tokenRouter.js'); + const tokensRoutesModule = await import('../api/tokens.js'); db = dbModule.db; schema = dbModule.schema; proxyRoutes = proxyRouterModule.proxyRoutes; + tokensRoutes = tokensRoutesModule.tokensRoutes; invalidateTokenRouterCache = tokenRouterModule.invalidateTokenRouterCache; app = Fastify(); + await app.register(tokensRoutes); await app.register(proxyRoutes); }); @@ -285,6 +290,106 @@ describe('/v1/models route', () => { expect(ids).not.toContain('claude-sonnet-4-5'); }); + it('returns only explicit-group public name while hiding source exact routes', async () => { + const site = await db.insert(schema.sites).values({ + name: 'explicit-group-site', + url: 'https://explicit-group.example.com', + platform: 'openai', + status: 'active', + }).returning().get(); + + const account = await db.insert(schema.accounts).values({ + siteId: site.id, + accessToken: 'explicit-group-access-token', + status: 'active', + }).returning().get(); + + const token = await db.insert(schema.accountTokens).values({ + accountId: account.id, + name: 'default', + token: 'explicit-group-api-token', + enabled: true, + isDefault: true, + }).returning().get(); + + await db.insert(schema.modelAvailability).values([ + { + accountId: account.id, + modelName: 'claude-opus-4-5', + available: true, + }, + { + accountId: account.id, + modelName: 'claude-sonnet-4-5', + available: true, + }, + ]).run(); + + const sourceRouteA = await db.insert(schema.tokenRoutes).values({ + modelPattern: 'claude-opus-4-5', + enabled: true, + }).returning().get(); + const sourceRouteB = await db.insert(schema.tokenRoutes).values({ + modelPattern: 'claude-sonnet-4-5', + enabled: true, + }).returning().get(); + + await db.insert(schema.routeChannels).values([ + { + routeId: sourceRouteA.id, + accountId: account.id, + tokenId: token.id, + sourceModel: 'claude-opus-4-5', + enabled: true, + }, + { + routeId: sourceRouteB.id, + accountId: account.id, + tokenId: token.id, + sourceModel: 'claude-sonnet-4-5', + enabled: true, + }, + ]).run(); + + const groupResponse = await app.inject({ + method: 'POST', + url: '/api/routes', + payload: { + routeMode: 'explicit_group', + displayName: 'claude-opus-4-6', + sourceRouteIds: [sourceRouteA.id, sourceRouteB.id], + }, + }); + expect(groupResponse.statusCode).toBe(200); + const groupId = (groupResponse.json() as { id: number }).id; + + await db.insert(schema.downstreamApiKeys).values({ + name: 'managed-explicit-group-key', + key: 'sk-managed-explicit-group', + enabled: true, + allowedRouteIds: JSON.stringify([groupId]), + }).run(); + + const response = await app.inject({ + method: 'GET', + url: '/v1/models', + headers: { + authorization: 'Bearer sk-managed-explicit-group', + }, + }); + + expect(response.statusCode).toBe(200); + const body = response.json() as { + object: 'list'; + data: Array<{ id: string }>; + }; + + const ids = body.data.map((item) => item.id); + expect(ids).toContain('claude-opus-4-6'); + expect(ids).not.toContain('claude-opus-4-5'); + expect(ids).not.toContain('claude-sonnet-4-5'); + }); + it('filters search pseudo models out of /v1/models', async () => { const site = await db.insert(schema.sites).values({ name: 'search-site', diff --git a/src/server/routes/proxy/models.ts b/src/server/routes/proxy/models.ts index 065ad9b..eba96ea 100644 --- a/src/server/routes/proxy/models.ts +++ b/src/server/routes/proxy/models.ts @@ -1,6 +1,4 @@ import { FastifyInstance } from 'fastify'; -import { db, schema } from '../../db/index.js'; -import { and, eq } from 'drizzle-orm'; import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js'; import { getDownstreamRoutingPolicy } from './downstreamPolicy.js'; import { tokenRouter } from '../../services/tokenRouter.js'; @@ -17,28 +15,7 @@ export async function modelsProxyRoute(app: FastifyInstance) { const downstreamPolicy = getDownstreamRoutingPolicy(request); const readModels = async () => { - const rows = await db.select({ modelName: schema.modelAvailability.modelName }) - .from(schema.modelAvailability) - .innerJoin(schema.accounts, eq(schema.modelAvailability.accountId, schema.accounts.id)) - .innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id)) - .where( - and( - eq(schema.modelAvailability.available, true), - eq(schema.accounts.status, 'active'), - eq(schema.sites.status, 'active'), - ), - ) - .all(); - const routeAliases = (await db.select({ displayName: schema.tokenRoutes.displayName }) - .from(schema.tokenRoutes) - .where(eq(schema.tokenRoutes.enabled, true)) - .all()) - .map((row) => (row.displayName || '').trim()) - .filter((name) => name.length > 0); - const deduped = Array.from(new Set([ - ...rows.map((r) => r.modelName), - ...routeAliases, - ])) + const deduped = Array.from(new Set(await tokenRouter.getAvailableModels())) .filter((modelName) => !isSearchPseudoModel(modelName)) .sort(); const allowed: string[] = []; diff --git a/src/server/routes/proxy/responses.ts b/src/server/routes/proxy/responses.ts index 6edc5ff..df58c68 100644 --- a/src/server/routes/proxy/responses.ts +++ b/src/server/routes/proxy/responses.ts @@ -204,6 +204,9 @@ export async function responsesProxyRoute(app: FastifyInstance) { model: modelName, stream: isStream, }; + if (body.generate === false) { + normalizedResponsesBody.generate = false; + } if (owner) { try { normalizedResponsesBody = await resolveResponsesBodyInputFiles(normalizedResponsesBody, owner); @@ -446,6 +449,9 @@ export async function responsesProxyRoute(app: FastifyInstance) { const streamSession = openAiResponsesTransformer.proxyStream.createSession({ modelName, successfulUpstreamPath, + strictTerminalEvents: Object.entries(request.headers as Record) + .some(([rawKey, rawValue]) => rawKey.trim().toLowerCase() === 'x-metapi-responses-websocket-transport' + && String(rawValue).trim() === '1'), getUsage: () => parsedUsage, onParsedPayload: (payload) => { if (payload && typeof payload === 'object') { diff --git a/src/server/routes/proxy/responses.websocket.test.ts b/src/server/routes/proxy/responses.websocket.test.ts index e006a67..a88c896 100644 --- a/src/server/routes/proxy/responses.websocket.test.ts +++ b/src/server/routes/proxy/responses.websocket.test.ts @@ -6,6 +6,7 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vites const fetchMock = vi.fn(); const selectChannelMock = vi.fn(); const selectNextChannelMock = vi.fn(); +const previewSelectedChannelMock = vi.fn(); const recordSuccessMock = vi.fn(); const recordFailureMock = vi.fn(); const refreshModelsAndRebuildRoutesMock = vi.fn(); @@ -30,6 +31,7 @@ vi.mock('../../services/tokenRouter.js', () => ({ tokenRouter: { selectChannel: (...args: unknown[]) => selectChannelMock(...args), selectNextChannel: (...args: unknown[]) => selectNextChannelMock(...args), + previewSelectedChannel: (...args: unknown[]) => previewSelectedChannelMock(...args), recordSuccess: (...args: unknown[]) => recordSuccessMock(...args), recordFailure: (...args: unknown[]) => recordFailureMock(...args), }, @@ -91,6 +93,44 @@ function createSseResponse(chunks: string[], status = 200) { }); } +function createSelectedChannel(options?: { + siteName?: string; + siteUrl?: string; + sitePlatform?: string; + username?: string; + extraConfig?: string; + tokenValue?: string; + actualModel?: string; +}) { + const sitePlatform = options?.sitePlatform ?? 'codex'; + const isCodex = sitePlatform === 'codex'; + return { + channel: { id: 11, routeId: 22 }, + site: { + name: options?.siteName ?? (isCodex ? 'codex-site' : 'openai-site'), + url: options?.siteUrl ?? (isCodex ? 'https://chatgpt.com/backend-api/codex' : 'https://api.openai.com'), + platform: sitePlatform, + }, + account: { + id: 33, + username: options?.username ?? (isCodex ? 'codex-user@example.com' : 'openai-user@example.com'), + extraConfig: options?.extraConfig ?? (isCodex + ? JSON.stringify({ + credentialMode: 'session', + oauth: { + provider: 'codex', + accountId: 'chatgpt-account-123', + email: 'codex-user@example.com', + }, + }) + : '{}'), + }, + tokenName: 'default', + tokenValue: options?.tokenValue ?? (isCodex ? 'oauth-access-token' : 'sk-openai-token'), + actualModel: options?.actualModel ?? (isCodex ? 'gpt-5.4' : 'gpt-4.1'), + }; +} + function waitForSocketOpen(socket: WebSocket) { return new Promise((resolve, reject) => { socket.once('open', () => resolve()); @@ -98,19 +138,37 @@ function waitForSocketOpen(socket: WebSocket) { }); } -function waitForSocketMessages(socket: WebSocket, count: number) { +function waitForSocketUpgrade(socket: WebSocket) { + return new Promise<{ headers: Record }>((resolve, reject) => { + socket.once('upgrade', (response) => resolve({ headers: response.headers as Record })); + socket.once('error', reject); + }); +} + +function waitForSocketMessages(socket: WebSocket, count: number, timeoutMs = 1000) { return new Promise((resolve, reject) => { const messages: any[] = []; + const timeout = setTimeout(() => { + socket.off('message', onMessage); + socket.off('error', onError); + reject(new Error(`Timed out waiting for ${count} websocket messages`)); + }, timeoutMs); const onMessage = (payload: WebSocket.RawData) => { messages.push(JSON.parse(String(payload))); if (messages.length >= count) { + clearTimeout(timeout); socket.off('message', onMessage); - socket.off('error', reject); + socket.off('error', onError); resolve(messages); } }; + const onError = (error: Error) => { + clearTimeout(timeout); + socket.off('message', onMessage); + reject(error); + }; socket.on('message', onMessage); - socket.once('error', reject); + socket.once('error', onError); }); } @@ -131,6 +189,7 @@ describe('responses websocket transport', () => { fetchMock.mockReset(); selectChannelMock.mockReset(); selectNextChannelMock.mockReset(); + previewSelectedChannelMock.mockReset(); recordSuccessMock.mockReset(); recordFailureMock.mockReset(); refreshModelsAndRebuildRoutesMock.mockReset(); @@ -139,26 +198,10 @@ describe('responses websocket transport', () => { resolveProxyUsageWithSelfLogFallbackMock.mockClear(); dbInsertMock.mockClear(); - selectChannelMock.mockReturnValue({ - channel: { id: 11, routeId: 22 }, - site: { name: 'codex-site', url: 'https://chatgpt.com/backend-api/codex', platform: 'codex' }, - account: { - id: 33, - username: 'codex-user@example.com', - extraConfig: JSON.stringify({ - credentialMode: 'session', - oauth: { - provider: 'codex', - accountId: 'chatgpt-account-123', - email: 'codex-user@example.com', - }, - }), - }, - tokenName: 'default', - tokenValue: 'oauth-access-token', - actualModel: 'gpt-5.4', - }); + const selectedChannel = createSelectedChannel(); + selectChannelMock.mockReturnValue(selectedChannel); selectNextChannelMock.mockReturnValue(null); + previewSelectedChannelMock.mockResolvedValue(selectedChannel); }); afterAll(async () => { @@ -206,4 +249,306 @@ describe('responses websocket transport', () => { expect(messages[3]?.response?.output?.[0]?.content?.[0]?.text).toBe('pong'); expect(fetchMock).toHaveBeenCalledTimes(1); }); + + it('echoes x-codex-turn-state on websocket upgrade responses', async () => { + const socket = new WebSocket(`${baseUrl}/v1/responses`, { + headers: { + 'x-codex-turn-state': 'turn-state-123', + }, + }); + + const [upgrade] = await Promise.all([ + waitForSocketUpgrade(socket), + waitForSocketOpen(socket), + ]); + socket.close(); + + expect(upgrade.headers['x-codex-turn-state']).toBe('turn-state-123'); + }); + + it('merges follow-up response.create payloads when the selected upstream does not support incremental mode', async () => { + const selectedChannel = createSelectedChannel({ + sitePlatform: 'openai', + actualModel: 'gpt-4.1', + }); + selectChannelMock.mockReturnValue(selectedChannel); + previewSelectedChannelMock.mockResolvedValue(selectedChannel); + fetchMock + .mockResolvedValueOnce(createSseResponse([ + 'event: response.output_item.done\n', + 'data: {"type":"response.output_item.done","output_index":0,"item":{"id":"fc_1","type":"function_call","call_id":"call_1"}}\n\n', + 'event: response.output_item.done\n', + 'data: {"type":"response.output_item.done","output_index":1,"item":{"id":"msg_1","type":"message","role":"assistant","status":"completed","content":[{"type":"output_text","text":"call tool"}]}}\n\n', + 'event: response.completed\n', + 'data: {"type":"response.completed","response":{"id":"resp_ws_1","model":"gpt-4.1","status":"completed","output":[{"id":"fc_1","type":"function_call","call_id":"call_1"},{"id":"msg_1","type":"message","role":"assistant","status":"completed","content":[{"type":"output_text","text":"call tool"}]}],"usage":{"input_tokens":3,"output_tokens":1,"total_tokens":4}}}\n\n', + 'data: [DONE]\n\n', + ])) + .mockResolvedValueOnce(createSseResponse([ + 'event: response.completed\n', + 'data: {"type":"response.completed","response":{"id":"resp_ws_2","model":"gpt-4.1","status":"completed","output":[{"id":"msg_2","type":"message","role":"assistant","status":"completed","content":[{"type":"output_text","text":"done"}]}],"usage":{"input_tokens":5,"output_tokens":1,"total_tokens":6}}}\n\n', + 'data: [DONE]\n\n', + ])); + + const socket = new WebSocket(`${baseUrl}/v1/responses`); + await waitForSocketOpen(socket); + + const firstResponsePromise = waitForSocketMessages(socket, 1); + socket.send(JSON.stringify({ + type: 'response.create', + model: 'gpt-4.1', + instructions: 'be helpful', + input: [ + { + id: 'msg_user_1', + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: 'call the tool' }], + }, + ], + })); + await firstResponsePromise; + + const secondResponsePromise = waitForSocketMessages(socket, 1); + socket.send(JSON.stringify({ + type: 'response.create', + previous_response_id: 'resp_ws_1', + input: [ + { + id: 'tool_out_1', + type: 'function_call_output', + call_id: 'call_1', + output: 'tool result', + }, + ], + })); + await secondResponsePromise; + socket.close(); + + expect(fetchMock).toHaveBeenCalledTimes(2); + const [, firstOptions] = fetchMock.mock.calls[0] as [string, RequestInit]; + const [, secondOptions] = fetchMock.mock.calls[1] as [string, RequestInit]; + const firstBody = JSON.parse(String(firstOptions.body)); + const secondBody = JSON.parse(String(secondOptions.body)); + + expect(firstBody.input).toHaveLength(1); + expect(secondBody.previous_response_id).toBeUndefined(); + expect(secondBody.model).toBe('gpt-4.1'); + expect(secondBody.instructions).toBe('be helpful'); + expect(secondBody.input).toEqual([ + { + id: 'msg_user_1', + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: 'call the tool' }], + }, + { + id: 'fc_1', + type: 'function_call', + call_id: 'call_1', + status: 'completed', + }, + { + id: 'msg_1', + type: 'message', + role: 'assistant', + status: 'completed', + content: [{ type: 'output_text', text: 'call tool' }], + }, + { + id: 'tool_out_1', + type: 'function_call_output', + call_id: 'call_1', + output: 'tool result', + }, + ]); + }); + + it('preserves incremental response.create payloads with previous_response_id for websocket-capable upstreams', async () => { + fetchMock + .mockResolvedValueOnce(createSseResponse([ + 'event: response.output_item.done\n', + 'data: {"type":"response.output_item.done","output_index":0,"item":{"id":"fc_1","type":"function_call","call_id":"call_1"}}\n\n', + 'event: response.output_item.done\n', + 'data: {"type":"response.output_item.done","output_index":1,"item":{"id":"msg_1","type":"message","role":"assistant","status":"completed","content":[{"type":"output_text","text":"call tool"}]}}\n\n', + 'event: response.completed\n', + 'data: {"type":"response.completed","response":{"id":"resp_ws_1","model":"gpt-5.4","status":"completed","output":[{"id":"fc_1","type":"function_call","call_id":"call_1"},{"id":"msg_1","type":"message","role":"assistant","status":"completed","content":[{"type":"output_text","text":"call tool"}]}],"usage":{"input_tokens":3,"output_tokens":1,"total_tokens":4}}}\n\n', + 'data: [DONE]\n\n', + ])) + .mockResolvedValueOnce(createSseResponse([ + 'event: response.completed\n', + 'data: {"type":"response.completed","response":{"id":"resp_ws_2","model":"gpt-5.4","status":"completed","output":[{"id":"msg_2","type":"message","role":"assistant","status":"completed","content":[{"type":"output_text","text":"done"}]}],"usage":{"input_tokens":5,"output_tokens":1,"total_tokens":6}}}\n\n', + 'data: [DONE]\n\n', + ])); + + const socket = new WebSocket(`${baseUrl}/v1/responses`); + await waitForSocketOpen(socket); + + const firstResponsePromise = waitForSocketMessages(socket, 1); + socket.send(JSON.stringify({ + type: 'response.create', + model: 'gpt-5.4', + instructions: 'be helpful', + input: [ + { + id: 'msg_user_1', + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: 'call the tool' }], + }, + ], + })); + await firstResponsePromise; + + const secondResponsePromise = waitForSocketMessages(socket, 1); + socket.send(JSON.stringify({ + type: 'response.create', + previous_response_id: 'resp_ws_1', + input: [ + { + id: 'tool_out_1', + type: 'function_call_output', + call_id: 'call_1', + output: 'tool result', + }, + ], + })); + await secondResponsePromise; + socket.close(); + + expect(fetchMock).toHaveBeenCalledTimes(2); + const [, secondOptions] = fetchMock.mock.calls[1] as [string, RequestInit]; + const secondBody = JSON.parse(String(secondOptions.body)); + expect(secondBody.previous_response_id).toBe('resp_ws_1'); + expect(secondBody.model).toBe('gpt-5.4'); + expect(secondBody.instructions).toBe('be helpful'); + expect(secondBody.input).toEqual([ + { + id: 'tool_out_1', + type: 'function_call_output', + call_id: 'call_1', + output: 'tool result', + }, + ]); + }); + + it('handles generate=false locally only for non-websocket-capable upstreams', async () => { + const selectedChannel = createSelectedChannel({ + sitePlatform: 'openai', + actualModel: 'gpt-4.1', + }); + selectChannelMock.mockReturnValue(selectedChannel); + previewSelectedChannelMock.mockResolvedValue(selectedChannel); + fetchMock.mockResolvedValueOnce(createSseResponse([ + 'event: response.completed\n', + 'data: {"type":"response.completed","response":{"id":"resp_ws_after_prewarm","model":"gpt-4.1","status":"completed","output":[{"id":"msg_2","type":"message","role":"assistant","status":"completed","content":[{"type":"output_text","text":"done"}]}],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}}\n\n', + 'data: [DONE]\n\n', + ])); + + const socket = new WebSocket(`${baseUrl}/v1/responses`); + await waitForSocketOpen(socket); + + const prewarmMessagesPromise = waitForSocketMessages(socket, 2); + socket.send(JSON.stringify({ + type: 'response.create', + model: 'gpt-4.1', + generate: false, + })); + const prewarmMessages = await prewarmMessagesPromise; + expect(prewarmMessages.map((message) => message.type)).toEqual(['response.created', 'response.completed']); + expect(fetchMock).toHaveBeenCalledTimes(0); + + const secondResponsePromise = waitForSocketMessages(socket, 1); + socket.send(JSON.stringify({ + type: 'response.create', + previous_response_id: prewarmMessages[0]?.response?.id, + input: [ + { + id: 'msg_followup_1', + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: 'continue' }], + }, + ], + })); + await secondResponsePromise; + socket.close(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [, options] = fetchMock.mock.calls[0] as [string, RequestInit]; + const forwardedBody = JSON.parse(String(options.body)); + expect(forwardedBody.generate).toBeUndefined(); + expect(forwardedBody.previous_response_id).toBeUndefined(); + expect(forwardedBody.model).toBe('gpt-4.1'); + expect(forwardedBody.input).toEqual([ + { + id: 'msg_followup_1', + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: 'continue' }], + }, + ]); + }); + + it('forwards generate=false upstream for websocket-capable upstreams instead of synthesizing prewarm events', async () => { + fetchMock.mockResolvedValueOnce(createSseResponse([ + 'event: response.completed\n', + 'data: {"type":"response.completed","response":{"id":"resp_capable_generate_false","model":"gpt-5.4","status":"completed","output":[],"usage":{"input_tokens":0,"output_tokens":0,"total_tokens":0}}}\n\n', + 'data: [DONE]\n\n', + ])); + + const socket = new WebSocket(`${baseUrl}/v1/responses`); + await waitForSocketOpen(socket); + const messagesPromise = waitForSocketMessages(socket, 1); + + socket.send(JSON.stringify({ + type: 'response.create', + model: 'gpt-5.4', + generate: false, + })); + + const messages = await messagesPromise; + socket.close(); + + expect(messages.map((message) => message.type)).toEqual(['response.completed']); + expect(fetchMock).toHaveBeenCalledTimes(1); + const [, options] = fetchMock.mock.calls[0] as [string, RequestInit]; + const forwardedBody = JSON.parse(String(options.body)); + expect(forwardedBody.generate).toBe(false); + }); + + it('emits websocket error when the upstream stream closes before a terminal responses event', async () => { + fetchMock.mockResolvedValue(createSseResponse([ + 'event: response.created\n', + 'data: {"type":"response.created","response":{"id":"resp_incomplete","model":"gpt-5.4","created_at":1706000000,"status":"in_progress","output":[]}}\n\n', + 'event: response.output_text.delta\n', + 'data: {"type":"response.output_text.delta","output_index":0,"item_id":"msg_ws","delta":"partial"}\n\n', + ])); + + const socket = new WebSocket(`${baseUrl}/v1/responses`); + await waitForSocketOpen(socket); + const messagesPromise = waitForSocketMessages(socket, 3, 400); + + socket.send(JSON.stringify({ + type: 'response.create', + model: 'gpt-5.4', + input: [ + { + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: 'hello websocket' }], + }, + ], + })); + + const messages = await messagesPromise; + socket.close(); + + expect(messages.map((message) => message.type)).toEqual([ + 'response.created', + 'response.output_text.delta', + 'error', + ]); + expect(messages[2]?.status).toBe(408); + expect(messages[2]?.error?.message).toContain('stream closed before response.completed'); + }); }); diff --git a/src/server/routes/proxy/responsesWebsocket.ts b/src/server/routes/proxy/responsesWebsocket.ts index 2c269f7..c663f58 100644 --- a/src/server/routes/proxy/responsesWebsocket.ts +++ b/src/server/routes/proxy/responsesWebsocket.ts @@ -2,9 +2,13 @@ import { randomUUID } from 'node:crypto'; import type { FastifyInstance } from 'fastify'; import type { IncomingMessage } from 'node:http'; import { WebSocketServer, type RawData, type WebSocket } from 'ws'; +import { tokenRouter } from '../../services/tokenRouter.js'; import { openAiResponsesTransformer } from '../../transformers/openai/responses/index.js'; const installedApps = new WeakSet(); +const WS_TURN_STATE_HEADER = 'x-codex-turn-state'; +const RESPONSES_WEBSOCKET_MODE_HEADER = 'x-metapi-responses-websocket-mode'; +const RESPONSES_WEBSOCKET_TRANSPORT_HEADER = 'x-metapi-responses-websocket-transport'; type NormalizedResponsesWebsocketRequest = | { @@ -26,6 +30,18 @@ function asTrimmedString(value: unknown): string { return typeof value === 'string' ? value.trim() : ''; } +function headerValueToTrimmedString(value: unknown): string { + if (typeof value === 'string') return value.trim(); + if (Array.isArray(value)) { + for (const item of value) { + if (typeof item !== 'string') continue; + const trimmed = item.trim(); + if (trimmed) return trimmed; + } + } + return ''; +} + function parseJsonObject(raw: RawData): Record | null { try { const parsed = JSON.parse(String(raw)); @@ -44,19 +60,11 @@ function toResponseInputArray(value: unknown): unknown[] { } function normalizeResponsesWebsocketRequest( - raw: RawData, + parsed: Record, lastRequest: Record | null, lastResponseOutput: unknown[], + supportsIncrementalInput: boolean, ): NormalizedResponsesWebsocketRequest { - const parsed = parseJsonObject(raw); - if (!parsed) { - return { - ok: false, - status: 400, - message: 'Invalid websocket JSON payload', - }; - } - const requestType = asTrimmedString(parsed.type); if (requestType !== 'response.create' && requestType !== 'response.append') { return { @@ -66,27 +74,6 @@ function normalizeResponsesWebsocketRequest( }; } - if (requestType === 'response.create' && parsed.generate === false) { - const next = cloneJsonObject(parsed); - delete next.type; - delete next.generate; - next.stream = true; - if (!Array.isArray(next.input)) next.input = []; - const modelName = asTrimmedString(next.model); - if (!modelName) { - return { - ok: false, - status: 400, - message: 'missing model in response.create request', - }; - } - return { - ok: true, - request: next, - nextRequestSnapshot: cloneJsonObject(next), - }; - } - if (!lastRequest) { if (requestType !== 'response.create') { return { @@ -97,6 +84,9 @@ function normalizeResponsesWebsocketRequest( } const next = cloneJsonObject(parsed); delete next.type; + if (!supportsIncrementalInput && parsed.generate === false) { + delete next.generate; + } next.stream = true; if (!Array.isArray(next.input)) next.input = []; const modelName = asTrimmedString(next.model); @@ -122,15 +112,8 @@ function normalizeResponsesWebsocketRequest( }; } - const mergedInput = [ - ...toResponseInputArray(lastRequest.input), - ...cloneJsonObject(lastResponseOutput), - ...cloneJsonObject(parsed.input), - ]; const next = cloneJsonObject(parsed); delete next.type; - delete next.previous_response_id; - next.input = mergedInput; next.stream = true; if (!('model' in next) && typeof lastRequest.model === 'string') { next.model = lastRequest.model; @@ -139,6 +122,22 @@ function normalizeResponsesWebsocketRequest( next.instructions = cloneJsonObject(lastRequest.instructions); } + if (supportsIncrementalInput && requestType === 'response.create' && asTrimmedString(parsed.previous_response_id)) { + return { + ok: true, + request: next, + nextRequestSnapshot: cloneJsonObject(next), + }; + } + + const mergedInput = [ + ...toResponseInputArray(lastRequest.input), + ...cloneJsonObject(lastResponseOutput), + ...cloneJsonObject(parsed.input), + ]; + delete next.previous_response_id; + next.input = mergedInput; + return { ok: true, request: next, @@ -146,6 +145,16 @@ function normalizeResponsesWebsocketRequest( }; } +function shouldHandleResponsesWebsocketPrewarmLocally( + parsed: Record, + lastRequest: Record | null, + supportsIncrementalInput: boolean, +): boolean { + if (supportsIncrementalInput || lastRequest) return false; + if (asTrimmedString(parsed.type) !== 'response.create') return false; + return parsed.generate === false; +} + function writeResponsesWebsocketError( socket: WebSocket, status: number, @@ -244,6 +253,21 @@ function buildInjectHeaders(request: IncomingMessage): Record, + lastRequest: Record | null, +): Promise { + const requestModel = asTrimmedString(parsed.model) || asTrimmedString(lastRequest?.model); + if (!requestModel) return false; + + try { + const selected = await tokenRouter.previewSelectedChannel(requestModel); + return asTrimmedString(selected?.site?.platform).toLowerCase() === 'codex'; + } catch { + return false; + } +} + async function handleResponsesWebsocketConnection( app: FastifyInstance, socket: WebSocket, @@ -259,14 +283,25 @@ async function handleResponsesWebsocketConnection( return; } - const normalized = normalizeResponsesWebsocketRequest(raw, lastRequest, lastResponseOutput); + const supportsIncrementalInput = await supportsResponsesWebsocketIncrementalInput(parsed, lastRequest); + const shouldHandleLocalPrewarm = shouldHandleResponsesWebsocketPrewarmLocally( + parsed, + lastRequest, + supportsIncrementalInput, + ); + const normalized = normalizeResponsesWebsocketRequest( + parsed, + lastRequest, + lastResponseOutput, + supportsIncrementalInput, + ); if (!normalized.ok) { writeResponsesWebsocketError(socket, normalized.status, normalized.message); return; } lastRequest = normalized.nextRequestSnapshot; - if (parsed.generate === false && asTrimmedString(parsed.type) === 'response.create') { + if (shouldHandleLocalPrewarm) { lastResponseOutput = []; for (const payload of synthesizePrewarmResponsePayloads(normalized.request)) { socket.send(JSON.stringify(payload)); @@ -277,7 +312,11 @@ async function handleResponsesWebsocketConnection( const response = await app.inject({ method: 'POST', url: '/v1/responses', - headers: buildInjectHeaders(request), + headers: { + ...buildInjectHeaders(request), + [RESPONSES_WEBSOCKET_TRANSPORT_HEADER]: '1', + ...(supportsIncrementalInput ? { [RESPONSES_WEBSOCKET_MODE_HEADER]: 'incremental' } : {}), + }, payload: normalized.request, }); @@ -313,17 +352,25 @@ async function handleResponsesWebsocketConnection( const pulled = openAiResponsesTransformer.pullSseEvents(response.body); const forwardedPayloads: unknown[] = []; + let sawTerminalPayload = false; for (const event of pulled.events) { if (event.data === '[DONE]') continue; try { const payload = JSON.parse(event.data); forwardedPayloads.push(payload); + const type = isRecord(payload) ? asTrimmedString(payload.type) : ''; + if (type === 'response.completed' || type === 'response.failed') { + sawTerminalPayload = true; + } socket.send(JSON.stringify(payload)); } catch { // Ignore malformed SSE frames; the HTTP route already normalizes them. } } lastResponseOutput = collectResponsesOutput(forwardedPayloads); + if (!sawTerminalPayload) { + writeResponsesWebsocketError(socket, 408, 'stream closed before response.completed'); + } }); } @@ -332,6 +379,11 @@ export function ensureResponsesWebsocketTransport(app: FastifyInstance) { installedApps.add(app); const websocketServer = new WebSocketServer({ noServer: true }); + websocketServer.on('headers', (headers, request) => { + const turnState = headerValueToTrimmedString(request.headers[WS_TURN_STATE_HEADER]); + if (!turnState) return; + headers.push(`${WS_TURN_STATE_HEADER}: ${turnState}`); + }); app.server.on('upgrade', (request, socket, head) => { const url = new URL(request.url || '/', 'http://localhost'); diff --git a/src/server/routes/proxy/upstreamEndpoint.ts b/src/server/routes/proxy/upstreamEndpoint.ts index 88c3bf7..8ffdc25 100644 --- a/src/server/routes/proxy/upstreamEndpoint.ts +++ b/src/server/routes/proxy/upstreamEndpoint.ts @@ -470,6 +470,9 @@ function convertCodexSystemRoleToDeveloper(input: unknown): unknown { function applyCodexResponsesCompatibility( body: Record, sitePlatform: string, + options?: { + preservePreviousResponseId?: boolean; + }, ): Record { if (sitePlatform !== 'codex') return body; @@ -494,12 +497,14 @@ function applyCodexResponsesCompatibility( 'truncation', 'user', 'context_management', - 'previous_response_id', 'prompt_cache_retention', 'safety_identifier', ]) { delete next[key]; } + if (!options?.preservePreviousResponseId) { + delete next.previous_response_id; + } if (asTrimmedString(next.service_tier).toLowerCase() !== 'priority') { delete next.service_tier; @@ -974,6 +979,8 @@ export function buildUpstreamEndpointRequest(input: { } if (input.endpoint === 'responses') { + const websocketMode = Object.entries(input.downstreamHeaders || {}).find(([rawKey]) => rawKey.trim().toLowerCase() === 'x-metapi-responses-websocket-mode'); + const preserveWebsocketIncrementalMode = asTrimmedString(websocketMode?.[1]).toLowerCase() === 'incremental'; const responsesHeaders = input.downstreamFormat === 'responses' ? extractResponsesPassthroughHeaders(input.downstreamHeaders) : {}; @@ -986,11 +993,16 @@ export function buildUpstreamEndpointRequest(input: { } : convertOpenAiBodyToResponsesBodyViaTransformer(openaiBody, input.modelName, input.stream) ); + const sanitizedResponsesBody = sanitizeResponsesBodyForProxyViaTransformer(rawBody, input.modelName, input.stream); + if (preserveWebsocketIncrementalMode && rawBody.generate === false) { + sanitizedResponsesBody.generate = false; + } const body = ensureCodexResponsesStoreFalse( ensureCodexResponsesInstructions( applyCodexResponsesCompatibility( - sanitizeResponsesBodyForProxyViaTransformer(rawBody, input.modelName, input.stream), + sanitizedResponsesBody, sitePlatform, + { preservePreviousResponseId: preserveWebsocketIncrementalMode }, ), sitePlatform, ), diff --git a/src/server/services/backupService.test.ts b/src/server/services/backupService.test.ts index 95ec12d..7b5d926 100644 --- a/src/server/services/backupService.test.ts +++ b/src/server/services/backupService.test.ts @@ -28,6 +28,7 @@ describe('backupService', () => { beforeEach(async () => { await db.delete(schema.routeChannels).run(); + await db.delete(schema.routeGroupSources).run(); await db.delete(schema.tokenRoutes).run(); await db.delete(schema.tokenModelAvailability).run(); await db.delete(schema.modelAvailability).run(); @@ -96,16 +97,34 @@ describe('backupService', () => { updatedAt: now, }).returning().get(); + const sourceRoute = await db.insert(schema.tokenRoutes).values({ + modelPattern: 'gpt-source-*', + displayName: 'gpt-source', + routeMode: 'pattern', + enabled: true, + createdAt: now, + updatedAt: now, + }).returning().get(); + const route = await db.insert(schema.tokenRoutes).values({ modelPattern: 'gpt-*', displayName: 'gpt-route', displayIcon: 'icon-gpt', modelMapping: JSON.stringify({ to: 'gpt-4o-mini' }), + routeMode: 'explicit_group', + decisionSnapshot: JSON.stringify({ channelIds: [1, 2] }), + decisionRefreshedAt: now, + routingStrategy: 'round_robin', enabled: true, createdAt: now, updatedAt: now, }).returning().get(); + await db.insert(schema.routeGroupSources).values({ + groupRouteId: route.id, + sourceRouteId: sourceRoute.id, + }).run(); + await db.insert(schema.routeChannels).values({ routeId: route.id, accountId: account.id, @@ -150,6 +169,12 @@ describe('backupService', () => { expect(restoredRoute?.displayName).toBe('gpt-route'); expect(restoredRoute?.displayIcon).toBe('icon-gpt'); + expect(restoredRoute?.routeMode).toBe('explicit_group'); + expect(restoredRoute?.decisionSnapshot).toBe('{"channelIds":[1,2]}'); + expect(restoredRoute?.decisionRefreshedAt).toBe(now); + expect(restoredRoute?.routingStrategy).toBe('round_robin'); + const restoredGroupSource = await db.select().from(schema.routeGroupSources).where(eq(schema.routeGroupSources.groupRouteId, route.id)).get(); + expect(restoredGroupSource?.sourceRouteId).toBe(sourceRoute.id); expect(restoredChannel?.sourceModel).toBe('gpt-4o'); }); diff --git a/src/server/services/backupService.ts b/src/server/services/backupService.ts index 5e23fec..9e36a21 100644 --- a/src/server/services/backupService.ts +++ b/src/server/services/backupService.ts @@ -13,6 +13,7 @@ type AccountRow = typeof schema.accounts.$inferSelect; type AccountTokenRow = typeof schema.accountTokens.$inferSelect; type TokenRouteRow = typeof schema.tokenRoutes.$inferSelect; type RouteChannelRow = typeof schema.routeChannels.$inferSelect; +type RouteGroupSourceRow = typeof schema.routeGroupSources.$inferSelect; interface AccountsBackupSection { sites: SiteRow[]; @@ -20,6 +21,7 @@ interface AccountsBackupSection { accountTokens: AccountTokenRow[]; tokenRoutes: TokenRouteRow[]; routeChannels: RouteChannelRow[]; + routeGroupSources: RouteGroupSourceRow[]; } interface PreferencesBackupSection { @@ -270,6 +272,7 @@ function buildAccountsSectionFromRefBackup(data: RawBackupData): AccountsBackupS accountTokens, tokenRoutes, routeChannels, + routeGroupSources: [], }; } @@ -348,6 +351,7 @@ async function exportAccountsSection(): Promise { const accountTokens = await db.select().from(schema.accountTokens).orderBy(asc(schema.accountTokens.id)).all(); const tokenRoutes = await db.select().from(schema.tokenRoutes).orderBy(asc(schema.tokenRoutes.id)).all(); const routeChannels = await db.select().from(schema.routeChannels).orderBy(asc(schema.routeChannels.id)).all(); + const routeGroupSources = await db.select().from(schema.routeGroupSources).orderBy(asc(schema.routeGroupSources.id)).all(); return { sites, @@ -355,6 +359,7 @@ async function exportAccountsSection(): Promise { accountTokens, tokenRoutes, routeChannels, + routeGroupSources, }; } @@ -405,6 +410,9 @@ function coerceAccountsSection(input: unknown): AccountsBackupSection | null { const accountTokens = Array.isArray(input.accountTokens) ? input.accountTokens as AccountTokenRow[] : null; const tokenRoutes = Array.isArray(input.tokenRoutes) ? input.tokenRoutes as TokenRouteRow[] : null; const routeChannels = Array.isArray(input.routeChannels) ? input.routeChannels as RouteChannelRow[] : null; + const routeGroupSources = Array.isArray(input.routeGroupSources) + ? input.routeGroupSources as RouteGroupSourceRow[] + : []; if (!sites || !accounts || !accountTokens || !tokenRoutes || !routeChannels) return null; @@ -414,6 +422,7 @@ function coerceAccountsSection(input: unknown): AccountsBackupSection | null { accountTokens, tokenRoutes, routeChannels, + routeGroupSources, }; } @@ -477,6 +486,7 @@ function detectPreferencesSection(data: RawBackupData): PreferencesBackupSection async function importAccountsSection(section: AccountsBackupSection): Promise { await db.transaction(async (tx) => { await tx.delete(schema.routeChannels).run(); + await tx.delete(schema.routeGroupSources).run(); await tx.delete(schema.tokenRoutes).run(); await tx.delete(schema.tokenModelAvailability).run(); await tx.delete(schema.modelAvailability).run(); @@ -557,12 +567,24 @@ async function importAccountsSection(section: AccountsBackupSection): Promise { checkinLogs: [], modelAvailability: [], tokenModelAvailability: [], - tokenRoutes: [], + tokenRoutes: [{ + id: 10, + modelPattern: 'claude-opus-4-6', + displayName: 'claude-opus-4-6', + displayIcon: 'icon-claude', + modelMapping: null, + routeMode: 'explicit_group', + decisionSnapshot: '{"channels":[1]}', + decisionRefreshedAt: '2026-03-14T01:30:00.000Z', + routingStrategy: 'round_robin', + enabled: true, + createdAt: '2026-03-14T00:00:00.000Z', + updatedAt: '2026-03-14T01:00:00.000Z', + }], routeChannels: [], proxyLogs: [], proxyVideoTasks: [{ @@ -260,6 +273,11 @@ describe('databaseMigrationService', () => { updatedAt: '2026-03-14T01:00:00.000Z', deletedAt: null, }], + routeGroupSources: [{ + id: 9, + groupRouteId: 12, + sourceRouteId: 13, + }], downstreamApiKeys: [], events: [], }, @@ -271,5 +289,10 @@ describe('databaseMigrationService', () => { expect(statements.some((statement) => statement.table === 'site_disabled_models')).toBe(true); expect(statements.some((statement) => statement.table === 'proxy_video_tasks')).toBe(true); expect(statements.some((statement) => statement.table === 'proxy_files')).toBe(true); + expect(statements.some((statement) => statement.table === 'route_group_sources')).toBe(true); + const tokenRouteStatement = statements.find((statement) => statement.table === 'token_routes'); + const routeModeIndex = tokenRouteStatement?.columns.indexOf('route_mode') ?? -1; + expect(routeModeIndex).toBeGreaterThanOrEqual(0); + expect(tokenRouteStatement?.values[routeModeIndex]).toBe('explicit_group'); }); }); diff --git a/src/server/services/databaseMigrationService.ts b/src/server/services/databaseMigrationService.ts index b137d09..0559cc5 100644 --- a/src/server/services/databaseMigrationService.ts +++ b/src/server/services/databaseMigrationService.ts @@ -36,6 +36,7 @@ type BackupSnapshot = { tokenModelAvailability: Array>; tokenRoutes: Array>; routeChannels: Array>; + routeGroupSources: Array>; proxyLogs: Array>; proxyVideoTasks: Array>; proxyFiles: Array>; @@ -60,6 +61,7 @@ export interface DatabaseMigrationSummary { accountTokens: number; tokenRoutes: number; routeChannels: number; + routeGroupSources: number; checkinLogs: number; modelAvailability: number; tokenModelAvailability: number; @@ -218,6 +220,7 @@ async function toBackupSnapshot(): Promise { tokenModelAvailability: await db.select().from(schema.tokenModelAvailability).all() as Array>, tokenRoutes: await db.select().from(schema.tokenRoutes).all() as Array>, routeChannels: await db.select().from(schema.routeChannels).all() as Array>, + routeGroupSources: await db.select().from(schema.routeGroupSources).all() as Array>, proxyLogs: await db.select().from(schema.proxyLogs).all() as Array>, proxyVideoTasks: await db.select().from(schema.proxyVideoTasks).all() as Array>, proxyFiles: await db.select().from(schema.proxyFiles).all() as Array>, @@ -245,6 +248,7 @@ async function ensureTargetState(client: SqlClient, overwrite: boolean): Promise async function clearTargetData(client: SqlClient): Promise { const tables = [ 'route_channels', + 'route_group_sources', 'token_model_availability', 'model_availability', 'checkin_logs', @@ -401,13 +405,14 @@ function buildStatements(snapshot: BackupSnapshot): InsertStatement[] { for (const row of snapshot.accounts.tokenRoutes) { statements.push({ table: 'token_routes', - columns: ['id', 'model_pattern', 'display_name', 'display_icon', 'model_mapping', 'decision_snapshot', 'decision_refreshed_at', 'routing_strategy', 'enabled', 'created_at', 'updated_at'], + columns: ['id', 'model_pattern', 'display_name', 'display_icon', 'model_mapping', 'route_mode', 'decision_snapshot', 'decision_refreshed_at', 'routing_strategy', 'enabled', 'created_at', 'updated_at'], values: [ asNumber(row.id, 0), asNullableString(row.modelPattern), asNullableString(row.displayName), asNullableString(row.displayIcon), asNullableString(row.modelMapping), + asNullableString(row.routeMode) ?? 'pattern', asNullableString(row.decisionSnapshot), asNullableString(row.decisionRefreshedAt), asNullableString(row.routingStrategy), @@ -446,6 +451,18 @@ function buildStatements(snapshot: BackupSnapshot): InsertStatement[] { }); } + for (const row of (snapshot.accounts.routeGroupSources || [])) { + statements.push({ + table: 'route_group_sources', + columns: ['id', 'group_route_id', 'source_route_id'], + values: [ + asNumber(row.id, 0), + asNumber(row.groupRouteId, 0), + asNumber(row.sourceRouteId, 0), + ], + }); + } + for (const row of snapshot.accounts.proxyLogs) { statements.push({ table: 'proxy_logs', @@ -608,6 +625,7 @@ async function syncPostgresSequences(client: SqlClient): Promise { 'token_model_availability', 'token_routes', 'route_channels', + 'route_group_sources', 'proxy_logs', 'proxy_video_tasks', 'proxy_files', @@ -615,9 +633,9 @@ async function syncPostgresSequences(client: SqlClient): Promise { 'events', ]; for (const table of tables) { - await client.execute(`SELECT setval(pg_get_serial_sequence('${table}', 'id'), COALESCE((SELECT MAX(id) FROM "${table}"), 1), TRUE)`); + await client.execute(`SELECT setval(pg_get_serial_sequence('${table}', 'id'), COALESCE((SELECT MAX(id) FROM "${table}"), 1), TRUE)`); + } } -} export async function bootstrapRuntimeDatabaseSchema(input: Pick): Promise { const client = await createClient({ @@ -680,6 +698,7 @@ export async function migrateCurrentDatabase(input: DatabaseMigrationInput): Pro downstreamApiKeys: snapshot.accounts.downstreamApiKeys.length, events: snapshot.accounts.events.length, settings: snapshot.preferences.settings.length, + routeGroupSources: snapshot.accounts.routeGroupSources.length, }, }; } diff --git a/src/server/services/modelService.ts b/src/server/services/modelService.ts index 0494e48..aa085cf 100644 --- a/src/server/services/modelService.ts +++ b/src/server/services/modelService.ts @@ -1069,7 +1069,7 @@ export async function rebuildTokenRoutesFromAvailability() { let removedRoutes = 0; for (const [modelName, candidateMap] of modelCandidates.entries()) { - let route = routes.find((r) => r.modelPattern === modelName); + let route = routes.find((r) => (r.routeMode || 'pattern') !== 'explicit_group' && r.modelPattern === modelName); if (!route) { const inserted = await db.insert(schema.tokenRoutes).values({ modelPattern: modelName, @@ -1138,6 +1138,9 @@ export async function rebuildTokenRoutesFromAvailability() { const latestModelNames = new Set(Array.from(modelCandidates.keys())); for (const route of routes) { + if ((route.routeMode || 'pattern') === 'explicit_group') { + continue; + } const modelPattern = (route.modelPattern || '').trim(); if (!modelPattern || !isExactModelPattern(modelPattern) || latestModelNames.has(modelPattern)) { continue; diff --git a/src/server/services/tokenRouter.ts b/src/server/services/tokenRouter.ts index f3afffd..4d40af4 100644 --- a/src/server/services/tokenRouter.ts +++ b/src/server/services/tokenRouter.ts @@ -1,5 +1,5 @@ -import { eq } from 'drizzle-orm'; -import { minimatch } from 'minimatch'; +import { eq, inArray } from 'drizzle-orm'; +import { minimatch } from 'minimatch'; import { db, schema } from '../db/index.js'; import { config } from '../config.js'; import { getCachedModelRoutingReferenceCost, refreshModelPricingCatalog } from './modelPricingService.js'; @@ -12,9 +12,9 @@ import { type DownstreamRoutingPolicy, EMPTY_DOWNSTREAM_ROUTING_POLICY } from '. import { isUsableAccountToken } from './accountTokenService.js'; import { getOauthInfoFromExtraConfig } from './oauth/oauthAccount.js'; -interface RouteMatch { - route: typeof schema.tokenRoutes.$inferSelect; - channels: Array<{ +interface RouteMatch { + route: RouteRow; + channels: Array<{ channel: typeof schema.routeChannels.$inferSelect; account: typeof schema.accounts.$inferSelect; site: typeof schema.sites.$inferSelect; @@ -66,8 +66,12 @@ function resolveRoundRobinCooldownSec(level: number): number { return ROUND_ROBIN_COOLDOWN_LEVELS_SEC[normalizedLevel] ?? 0; } -type RouteRow = typeof schema.tokenRoutes.$inferSelect; -type ChannelRow = typeof schema.routeChannels.$inferSelect; +type RouteMode = 'pattern' | 'explicit_group'; +type RouteRow = typeof schema.tokenRoutes.$inferSelect & { + routeMode: RouteMode; + sourceRouteIds: number[]; +}; +type ChannelRow = typeof schema.routeChannels.$inferSelect; type RouteCacheSnapshot = { loadedAt: number; @@ -96,34 +100,71 @@ function isCacheFresh(loadedAt: number, nowMs: number): boolean { } async function loadEnabledRoutes(nowMs = Date.now()): Promise { - if (isCacheFresh(routeCacheSnapshot.loadedAt, nowMs)) { - return routeCacheSnapshot.routes; - } - - const routes = await db.select().from(schema.tokenRoutes) + if (isCacheFresh(routeCacheSnapshot.loadedAt, nowMs)) { + return routeCacheSnapshot.routes; + } + + const rawRoutes = await db.select().from(schema.tokenRoutes) .where(eq(schema.tokenRoutes.enabled, true)) .all(); - routeCacheSnapshot = { - loadedAt: nowMs, - routes, + const explicitGroupRouteIds = rawRoutes + .filter((route) => normalizeRouteMode(route.routeMode) === 'explicit_group') + .map((route) => route.id); + const sourceRows = explicitGroupRouteIds.length > 0 + ? await db.select().from(schema.routeGroupSources) + .where(inArray(schema.routeGroupSources.groupRouteId, explicitGroupRouteIds)) + .all() + : []; + const sourceIdsByRouteId = new Map(); + for (const row of sourceRows) { + if (!sourceIdsByRouteId.has(row.groupRouteId)) { + sourceIdsByRouteId.set(row.groupRouteId, []); + } + sourceIdsByRouteId.get(row.groupRouteId)!.push(row.sourceRouteId); + } + const routes = rawRoutes.map((route) => ({ + ...route, + routeMode: normalizeRouteMode(route.routeMode), + sourceRouteIds: Array.from(new Set(sourceIdsByRouteId.get(route.id) ?? [])), + })); + routeCacheSnapshot = { + loadedAt: nowMs, + routes, }; return routes; } async function loadRouteMatch(route: RouteRow, nowMs = Date.now()): Promise { - const cached = routeMatchCache.get(route.id); - if (cached && isCacheFresh(cached.loadedAt, nowMs)) { - return cached.match; - } - - const channels = await db - .select() - .from(schema.routeChannels) - .innerJoin(schema.accounts, eq(schema.routeChannels.accountId, schema.accounts.id)) - .innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id)) - .leftJoin(schema.accountTokens, eq(schema.routeChannels.tokenId, schema.accountTokens.id)) - .where(eq(schema.routeChannels.routeId, route.id)) - .all(); + const cached = routeMatchCache.get(route.id); + if (cached && isCacheFresh(cached.loadedAt, nowMs)) { + return cached.match; + } + + const routeIds = (() => { + if (!isExplicitGroupRoute(route)) { + return [route.id]; + } + return Array.from(new Set(route.sourceRouteIds.filter((routeId) => Number.isFinite(routeId) && routeId > 0))); + })(); + const enabledSourceRouteIds = isExplicitGroupRoute(route) + ? (await loadEnabledRoutes(nowMs)) + .filter((item) => ( + routeIds.includes(item.id) + && !isExplicitGroupRoute(item) + && isExactRouteModelPattern(item.modelPattern) + )) + .map((item) => item.id) + : routeIds; + const channels = enabledSourceRouteIds.length > 0 + ? await db + .select() + .from(schema.routeChannels) + .innerJoin(schema.accounts, eq(schema.routeChannels.accountId, schema.accounts.id)) + .innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id)) + .leftJoin(schema.accountTokens, eq(schema.routeChannels.tokenId, schema.accountTokens.id)) + .where(inArray(schema.routeChannels.routeId, enabledSourceRouteIds)) + .all() + : []; const mapped = channels.map((row) => ({ channel: row.route_channels, @@ -279,23 +320,79 @@ function isExactRouteModelPattern(pattern: string): boolean { if (isRegexModelPattern(normalizedPattern)) return false; return !/[\*\?\[]/.test(normalizedPattern); } + +function normalizeRouteMode(routeMode: string | null | undefined): RouteMode { + return routeMode === 'explicit_group' ? 'explicit_group' : 'pattern'; +} + +function isExplicitGroupRoute(route: Pick | Pick): boolean { + return normalizeRouteMode(route.routeMode) === 'explicit_group'; +} + +function normalizeRouteDisplayName(displayName: string | null | undefined): string { + return (displayName || '').trim(); +} -function normalizeRouteDisplayName(displayName: string | null | undefined): string { - return (displayName || '').trim(); -} - -function isRouteDisplayNameMatch(model: string, displayName: string | null | undefined): boolean { - const alias = normalizeRouteDisplayName(displayName); - return !!alias && alias === model; -} - -function matchesRouteRequestModel(model: string, route: typeof schema.tokenRoutes.$inferSelect): boolean { - return matchesModelPattern(model, route.modelPattern) || isRouteDisplayNameMatch(model, route.displayName); -} - -function getExposedModelNameForRoute(route: typeof schema.tokenRoutes.$inferSelect): string { - return normalizeRouteDisplayName(route.displayName) || route.modelPattern; -} +function isRouteDisplayNameMatch(model: string, displayName: string | null | undefined): boolean { + const alias = normalizeRouteDisplayName(displayName); + return !!alias && alias === model; +} + +function matchesRouteRequestModel(model: string, route: RouteRow): boolean { + if (isExplicitGroupRoute(route)) { + return isRouteDisplayNameMatch(model, route.displayName); + } + return matchesModelPattern(model, route.modelPattern) || isRouteDisplayNameMatch(model, route.displayName); +} + +function getExposedModelNameForRoute(route: RouteRow): string { + return normalizeRouteDisplayName(route.displayName) || route.modelPattern; +} + +function hasCustomDisplayName(route: Pick): boolean { + const displayName = normalizeRouteDisplayName(route.displayName); + const modelPattern = (route.modelPattern || '').trim(); + return !!displayName && displayName !== modelPattern; +} + +function buildVisibleEnabledRoutes(routes: RouteRow[]): RouteRow[] { + const exactModelNames = new Set( + routes + .filter((route) => !isExplicitGroupRoute(route) && isExactRouteModelPattern(route.modelPattern)) + .map((route) => (route.modelPattern || '').trim()) + .filter(Boolean), + ); + const coveringGroups = routes.filter((route) => ( + route.enabled + && ( + (isExplicitGroupRoute(route) && normalizeRouteDisplayName(route.displayName).length > 0 && route.sourceRouteIds.length > 0) + || (!isExplicitGroupRoute(route) && !isExactRouteModelPattern(route.modelPattern) && hasCustomDisplayName(route)) + ) + )); + + if (coveringGroups.length === 0) return routes; + + return routes.filter((route) => { + if (isExplicitGroupRoute(route)) { + return normalizeRouteDisplayName(route.displayName).length > 0; + } + if (!isExactRouteModelPattern(route.modelPattern)) return true; + if (hasCustomDisplayName(route)) return true; + + const exactModel = (route.modelPattern || '').trim(); + if (!exactModel) return true; + + return !coveringGroups.some((groupRoute) => { + if (groupRoute.id === route.id) return false; + const groupDisplayName = normalizeRouteDisplayName(groupRoute.displayName); + if (!groupDisplayName || exactModelNames.has(groupDisplayName)) return false; + if (isExplicitGroupRoute(groupRoute)) { + return groupRoute.sourceRouteIds.includes(route.id); + } + return matchesModelPattern(exactModel, groupRoute.modelPattern); + }); + }); +} function normalizeModelAlias(modelName: string): string { const normalized = (modelName || '').trim().toLowerCase(); @@ -367,10 +464,10 @@ function normalizeChannelSourceModel(channelSourceModel: string | null | undefin function resolveActualModelForSelectedChannel( requestedModel: string, - route: typeof schema.tokenRoutes.$inferSelect, + route: RouteRow, mappedModel: string, channelSourceModel: string | null | undefined, -): string { +): string { const sourceModel = normalizeChannelSourceModel(channelSourceModel); if (isRouteDisplayNameMatch(requestedModel, route.displayName) && sourceModel) { return sourceModel; @@ -378,7 +475,7 @@ function resolveActualModelForSelectedChannel( return mappedModel; } -function resolveRouteStrategy(route: typeof schema.tokenRoutes.$inferSelect): RouteRoutingStrategy { +function resolveRouteStrategy(route: RouteRow): RouteRoutingStrategy { return normalizeRouteRoutingStrategy(route.routingStrategy); } @@ -450,6 +547,17 @@ export class TokenRouter { if (!match) return null; return await this.selectFromMatch(match, requestedModel, downstreamPolicy); } + + async previewSelectedChannel( + requestedModel: string, + downstreamPolicy: DownstreamRoutingPolicy = DEFAULT_DOWNSTREAM_POLICY, + ): Promise { + if (!isModelAllowedByDownstreamPolicy(requestedModel, downstreamPolicy)) return null; + + const match = await this.findRoute(requestedModel, downstreamPolicy); + if (!match) return null; + return await this.selectFromMatch(match, requestedModel, downstreamPolicy, [], false); + } /** * Select next channel for failover (exclude already-tried channels). @@ -893,7 +1001,7 @@ export class TokenRouter { */ async getAvailableModels(): Promise { const routes = await loadEnabledRoutes(); - const exposed = routes + const exposed = buildVisibleEnabledRoutes(routes) .map((route) => getExposedModelNameForRoute(route).trim()) .filter((name) => name.length > 0); return Array.from(new Set(exposed)); @@ -906,6 +1014,7 @@ export class TokenRouter { requestedModel: string, downstreamPolicy: DownstreamRoutingPolicy, excludeChannelIds: number[] = [], + recordSelection = true, ): Promise { const mappedModel = resolveMappedModel(requestedModel, match.route.modelMapping); const requestedByDisplayName = isRouteDisplayNameMatch(requestedModel, match.route.displayName); @@ -931,7 +1040,9 @@ export class TokenRouter { const tokenValue = this.resolveChannelTokenValue(selected); if (!tokenValue) return null; - await this.recordChannelSelection(selected.channel.id); + if (recordSelection) { + await this.recordChannelSelection(selected.channel.id); + } const actualModel = resolveActualModelForSelectedChannel( requestedModel, @@ -1003,11 +1114,13 @@ export class TokenRouter { } const matchedRoute = routes.find((route) => ( - isExactRouteModelPattern(route.modelPattern) + !isExplicitGroupRoute(route) + && isExactRouteModelPattern(route.modelPattern) && (route.modelPattern || '').trim() === model )) - || routes.find((route) => isRouteDisplayNameMatch(model, route.displayName)) - || routes.find((route) => matchesModelPattern(model, route.modelPattern)); + || routes.find((route) => isExplicitGroupRoute(route) && isRouteDisplayNameMatch(model, route.displayName)) + || routes.find((route) => !isExplicitGroupRoute(route) && isRouteDisplayNameMatch(model, route.displayName)) + || routes.find((route) => !isExplicitGroupRoute(route) && matchesModelPattern(model, route.modelPattern)); if (!matchedRoute) return null; @@ -1025,7 +1138,7 @@ export class TokenRouter { return await this.loadRouteMatch(route); } - private async loadRouteMatch(route: typeof schema.tokenRoutes.$inferSelect): Promise { + private async loadRouteMatch(route: RouteRow): Promise { return await loadRouteMatch(route); } diff --git a/src/server/transformers/openai/responses/compatibility.ts b/src/server/transformers/openai/responses/compatibility.ts index fdf08a0..ca6b323 100644 --- a/src/server/transformers/openai/responses/compatibility.ts +++ b/src/server/transformers/openai/responses/compatibility.ts @@ -217,7 +217,13 @@ export function buildResponsesCompatibilityBodies( if (instructions !== null) richCandidate.instructions = instructions; const passthroughFields = [ + 'parallel_tool_calls', + 'include', 'reasoning', + 'previous_response_id', + 'truncation', + 'text', + 'service_tier', 'safety_identifier', 'max_tool_calls', 'prompt_cache_key', diff --git a/src/server/transformers/openai/responses/conversion.test.ts b/src/server/transformers/openai/responses/conversion.test.ts index 31c2956..c6e8395 100644 --- a/src/server/transformers/openai/responses/conversion.test.ts +++ b/src/server/transformers/openai/responses/conversion.test.ts @@ -853,6 +853,7 @@ describe('convertResponsesBodyToOpenAiBody', () => { model: 'gpt-5', input: 'hello', stream: true, + parallel_tool_calls: true, include: ['reasoning.encrypted_content'], reasoning: { effort: 'high', @@ -864,12 +865,20 @@ describe('convertResponsesBodyToOpenAiBody', () => { prompt_cache_retention: { scope: 'workspace' }, background: true, top_logprobs: 2, + previous_response_id: 'resp_prev_9', + truncation: 'auto', + service_tier: 'priority', + text: { + verbosity: 'high', + }, }); expect(candidates).toContainEqual({ model: 'gpt-5', input: 'hello', stream: true, + parallel_tool_calls: true, + include: ['reasoning.encrypted_content'], reasoning: { effort: 'high', summary: 'auto', @@ -880,6 +889,12 @@ describe('convertResponsesBodyToOpenAiBody', () => { prompt_cache_retention: { scope: 'workspace' }, background: true, top_logprobs: 2, + previous_response_id: 'resp_prev_9', + truncation: 'auto', + service_tier: 'priority', + text: { + verbosity: 'high', + }, }); }); diff --git a/src/server/transformers/openai/responses/proxyStream.ts b/src/server/transformers/openai/responses/proxyStream.ts index 7fbc9e3..c7e73a3 100644 --- a/src/server/transformers/openai/responses/proxyStream.ts +++ b/src/server/transformers/openai/responses/proxyStream.ts @@ -21,6 +21,7 @@ type ResponsesProxyStreamResult = { type ResponsesProxyStreamSessionInput = { modelName: string; successfulUpstreamPath: string; + strictTerminalEvents?: boolean; getUsage: () => { promptTokens: number; completionTokens: number; @@ -84,6 +85,14 @@ export function createResponsesProxyStreamSession(input: ResponsesProxyStreamSes const closeOut = () => { if (finalized) return; + if (input.strictTerminalEvents) { + finalized = true; + terminalResult = { + status: 'failed', + errorMessage: 'stream closed before response.completed', + }; + return; + } finalize(); }; diff --git a/src/web/pages/TokenRoutes.tsx b/src/web/pages/TokenRoutes.tsx index 6511a05..7e6df4f 100644 --- a/src/web/pages/TokenRoutes.tsx +++ b/src/web/pages/TokenRoutes.tsx @@ -30,6 +30,7 @@ import type { GroupFilter, RouteSummaryRow, RouteRoutingStrategy, + RouteMode, RouteDecision, RouteIconOption, MissingTokenRouteSiteActionItem, @@ -40,7 +41,10 @@ import { AUTO_ROUTE_DECISION_LIMIT, ROUTE_RENDER_CHUNK, isExactModelPattern, + isExplicitGroupRoute, + isRouteExactModel, matchesModelPattern, + normalizeRouteMode, resolveRouteTitle, resolveRouteBrand, resolveRouteIcon, @@ -66,6 +70,24 @@ const ROUTE_ICON_OPTIONS: RouteIconOption[] = [ { value: '', label: '自动品牌图标', description: '按模型匹配规则自动识别品牌', iconText: '✦' }, ]; +type RouteEditorForm = { + routeMode: RouteMode; + displayName: string; + displayIcon: string; + modelPattern: string; + sourceRouteIds: number[]; + advancedOpen: boolean; +}; + +const EMPTY_ROUTE_FORM: RouteEditorForm = { + routeMode: 'explicit_group', + displayName: '', + displayIcon: '', + modelPattern: '', + sourceRouteIds: [], + advancedOpen: false, +}; + export default function TokenRoutes() { const navigate = useNavigate(); const [routeSummaries, setRouteSummaries] = useState([]); @@ -86,7 +108,7 @@ export default function TokenRoutes() { const [sortDir, setSortDir] = useState('desc'); const [showManual, setShowManual] = useState(false); - const [form, setForm] = useState({ modelPattern: '', displayName: '', displayIcon: '' }); + const [form, setForm] = useState(EMPTY_ROUTE_FORM); const [editingRouteId, setEditingRouteId] = useState(null); const [saving, setSaving] = useState(false); const [rebuilding, setRebuilding] = useState(false); @@ -120,9 +142,9 @@ export default function TokenRoutes() { options?: { force?: boolean; refreshPricingCatalog?: boolean; persistSnapshots?: boolean }, ) => { const rows = routeRows || []; - const exactRoutes = rows.filter((route) => isExactModelPattern(route.modelPattern)); + const exactRoutes = rows.filter((route) => isRouteExactModel(route)); const wildcardRouteIds = rows - .filter((route) => !isExactModelPattern(route.modelPattern)) + .filter((route) => !isRouteExactModel(route)) .map((route) => route.id); const requestedModels = Array.from(new Set(exactRoutes.map((route) => route.modelPattern))); @@ -204,7 +226,7 @@ export default function TokenRoutes() { } setDecisionByRoute(decisionPlaceholder); setDecisionAutoSkipped( - summaries.some((route) => isExactModelPattern(route.modelPattern) && !route.decisionSnapshot), + summaries.some((route) => isRouteExactModel(route) && !route.decisionSnapshot), ); }; @@ -249,7 +271,7 @@ export default function TokenRoutes() { const exactRouteCount = useMemo( () => buildVisibleRouteList(routeSummaries, isExactModelPattern, matchesModelPattern) - .filter((route) => isExactModelPattern(route.modelPattern)).length, + .filter((route) => isRouteExactModel(route)).length, [routeSummaries], ); @@ -263,9 +285,13 @@ export default function TokenRoutes() { [routeSummaries, showZeroChannelRoutes, zeroChannelPlaceholderRoutes], ); - const canSaveRoute = !saving - && !!form.modelPattern.trim() - && !getModelPatternError(form.modelPattern); + const canSaveRoute = useMemo(() => { + if (saving) return false; + if (form.routeMode === 'explicit_group') { + return !!form.displayName.trim() && form.sourceRouteIds.length > 0; + } + return !!form.modelPattern.trim() && !getModelPatternError(form.modelPattern); + }, [form.displayName, form.modelPattern, form.routeMode, form.sourceRouteIds.length, saving]); const previewModelSamples = useMemo(() => { const names = new Set(); @@ -274,7 +300,7 @@ export default function TokenRoutes() { if (normalized) names.add(normalized); } for (const route of routeSummaries) { - if (!isExactModelPattern(route.modelPattern)) continue; + if (!isRouteExactModel(route)) continue; const normalized = route.modelPattern.trim(); if (normalized) names.add(normalized); } @@ -283,38 +309,59 @@ export default function TokenRoutes() { .slice(0, 800); }, [modelCandidates, routeSummaries]); + const exactSourceRouteOptions = useMemo( + () => routeSummaries.filter((route) => isRouteExactModel(route)), + [routeSummaries], + ); + const resetRouteForm = () => { - setForm({ modelPattern: '', displayName: '', displayIcon: '' }); + setForm(EMPTY_ROUTE_FORM); setEditingRouteId(null); }; const handleAddRoute = async () => { - if (!form.modelPattern.trim()) return; - const modelPatternError = getModelPatternError(form.modelPattern); - if (modelPatternError) { - toast.error(modelPatternError); - return; - } - - const trimmedModelPattern = form.modelPattern.trim(); const trimmedDisplayName = form.displayName.trim() ? form.displayName.trim() : undefined; const trimmedDisplayIcon = form.displayIcon.trim() ? form.displayIcon.trim() : undefined; + const trimmedModelPattern = form.modelPattern.trim(); + const routeMode = normalizeRouteMode(form.routeMode); + if (routeMode === 'explicit_group') { + if (!trimmedDisplayName) { + toast.error('请填写对外模型名'); + return; + } + if (form.sourceRouteIds.length === 0) { + toast.error('请至少选择一个来源模型'); + return; + } + } else { + if (!trimmedModelPattern) return; + const modelPatternError = getModelPatternError(form.modelPattern); + if (modelPatternError) { + toast.error(modelPatternError); + return; + } + } + setSaving(true); try { if (editingRouteId) { const currentRoute = routeSummaries.find((route) => route.id === editingRouteId) || null; - const modelPatternChanged = !!currentRoute && currentRoute.modelPattern !== trimmedModelPattern; + const modelPatternChanged = routeMode === 'pattern' && !!currentRoute && currentRoute.modelPattern !== trimmedModelPattern; await api.updateRoute(editingRouteId, { - modelPattern: trimmedModelPattern, + routeMode, + ...(routeMode === 'pattern' ? { modelPattern: trimmedModelPattern } : {}), displayName: trimmedDisplayName, displayIcon: trimmedDisplayIcon, + ...(routeMode === 'explicit_group' ? { sourceRouteIds: form.sourceRouteIds } : {}), }); - toast.success(modelPatternChanged ? tr('群组已更新并重新匹配通道') : tr('群组已更新')); + toast.success(routeMode === 'pattern' && modelPatternChanged ? tr('群组已更新并重新匹配通道') : tr('群组已更新')); } else { await api.addRoute({ - modelPattern: trimmedModelPattern, + routeMode, + ...(routeMode === 'pattern' ? { modelPattern: trimmedModelPattern } : {}), displayName: trimmedDisplayName, displayIcon: trimmedDisplayIcon, + ...(routeMode === 'explicit_group' ? { sourceRouteIds: form.sourceRouteIds } : {}), }); toast.success(tr('群组已创建')); } @@ -330,10 +377,14 @@ export default function TokenRoutes() { const handleEditRoute = (route: RouteSummaryRow) => { setEditingRouteId(route.id); + const routeMode = normalizeRouteMode(route.routeMode); setForm({ + routeMode, modelPattern: route.modelPattern || '', displayName: route.displayName || '', displayIcon: normalizeRouteDisplayIconValue(route.displayIcon), + sourceRouteIds: routeMode === 'explicit_group' ? [...(route.sourceRouteIds || [])] : [], + advancedOpen: routeMode === 'pattern', }); setShowManual(true); }; @@ -403,9 +454,9 @@ export default function TokenRoutes() { // Stable derived value: only changes when route patterns change (not on enabled toggle) const routePatterns = useMemo( - () => visibleRouteRows.map((r) => ({ id: r.id, modelPattern: r.modelPattern })), + () => visibleRouteRows.map((r) => ({ id: r.id, modelPattern: r.modelPattern, routeMode: r.routeMode })), // eslint-disable-next-line react-hooks/exhaustive-deps - [visibleRouteRows.map((r) => `${r.id}:${r.modelPattern}`).join(',')], + [visibleRouteRows.map((r) => `${r.id}:${r.modelPattern}:${r.routeMode || 'pattern'}`).join(',')], ); const routeBrandById = useMemo(() => { @@ -541,7 +592,7 @@ export default function TokenRoutes() { const groupRouteList = useMemo(() => ( listVisibleRoutes - .filter((route) => !isExactModelPattern(route.modelPattern)) + .filter((route) => !isRouteExactModel(route)) .map((route) => ({ id: route.id, title: resolveRouteTitle(route), @@ -577,7 +628,7 @@ export default function TokenRoutes() { let list = sortedRoutes; if (activeGroupFilter === '__all__') { - list = list.filter((route) => !isExactModelPattern(route.modelPattern)); + list = list.filter((route) => !isRouteExactModel(route)); } else if (typeof activeGroupFilter === 'number') { list = list.filter((route) => route.id === activeGroupFilter); } @@ -749,7 +800,7 @@ export default function TokenRoutes() { ); const route = routeSummaries.find((r) => r.id === routeId); - if (route && isExactModelPattern(route.modelPattern)) { + if (route && isRouteExactModel(route)) { try { const res = await api.getRouteDecision(route.modelPattern); setDecisionByRoute((prev) => ({ @@ -1008,20 +1059,13 @@ export default function TokenRoutes() { + + + ); return ( -
-
- {editingRouteId - ? tr('编辑群组路由名称、图标和模型匹配规则;若修改正则,将按当前可用模型重新匹配自动通道。') - : tr('用于创建群组路由(聚合多个上游模型为一个下游模型名,即模型重定向);自动路由仍会保持开启。')} -
-
-
- setForm((f) => ({ ...f, displayName: e.target.value }))} - style={{ - width: '100%', - padding: '10px 14px', - border: '1px solid var(--color-border)', - borderRadius: 'var(--radius-sm)', - fontSize: 13, - outline: 'none', - background: 'var(--color-bg)', - color: 'var(--color-text-primary)', - }} - /> -
- setForm((f) => ({ ...f, displayIcon: nextValue }))} - options={routeIconSelectOptions} - placeholder={tr('图标(可选,选择品牌图标)')} - emptyLabel={tr('暂无可选品牌图标')} - /> -
-
- setForm((f) => ({ ...f, modelPattern: e.target.value }))} - style={{ - width: '100%', - padding: '10px 14px', - border: `1px solid ${modelPatternError ? 'var(--color-danger)' : 'var(--color-border)'}`, - borderRadius: 'var(--radius-sm)', - fontSize: 13, - outline: 'none', - background: 'var(--color-bg)', - color: 'var(--color-text-primary)', - fontFamily: 'var(--font-mono)', - }} - /> -
- {tr('正则请使用 re: 前缀;例如 re:^claude-(opus|sonnet)-4-6$')} -
- {modelPatternError && ( -
- {modelPatternError} -
- )} - {form.modelPattern.trim() && !modelPatternError && ( + +
+ {editingLegacyPatternGroup ? (
-
- {tr('规则预览:命中样本')} {previewMatchedModels.length} / {previewModelSamples.length} +
+ {tr('高级规则群组')} +
+
+ {tr('该群组使用高级模型匹配规则创建,普通模式不可编辑;修改后会按当前可用模型重新匹配自动通道。')} +
+
+ ) : routeMode === 'explicit_group' ? ( +
+
+ {tr('模型重定向')} +
+
+ {tr('将多个现有模型合并为一个对外模型名,实现模型重定向。')} +
+
+ ) : ( +
+
+ {tr('高级规则群组')} +
+
+ {tr('使用模型匹配规则创建群组,适合需要 regex / glob 的高级场景。')} +
+
+ )} + + {routeMode === 'explicit_group' ? ( + <> +
+
- {previewModelSamples.length === 0 ? ( -
- {tr('当前暂无可预览模型,请先同步模型。')} -
- ) : previewMatchedModels.length === 0 ? ( -
- {tr('当前规则未命中任何样本模型。')} -
- ) : ( -
- {previewMatchedModels.slice(0, 12).map((modelName) => ( - +
+
+
+ {tr('来源模型')} +
+
+ {tr('选择一个或多个现有精确模型路由作为来源。')} +
+
+ {!editingRouteId && ( +
+ )} +
+ + setSourceSearch(e.target.value)} + style={{ + width: '100%', + padding: '10px 14px', + border: '1px solid var(--color-border)', + borderRadius: 'var(--radius-sm)', + fontSize: 13, + outline: 'none', + background: 'var(--color-bg)', + color: 'var(--color-text-primary)', + }} + /> + + {selectedSourceRoutes.length > 0 && ( +
+ {selectedSourceRoutes.map((route) => ( + + ))} +
+ )} + +
+ {filteredSourceRoutes.map((route) => { + const selected = form.sourceRouteIds.includes(route.id); + const label = renderRouteOptionLabel(route); + return ( + + ); + })} +
+ + {filteredSourceRoutes.length === 0 && ( +
+ {tr('没有匹配的来源模型。')} +
+ )} +
+ +
{ + const nextOpen = (event.currentTarget as HTMLDetailsElement).open; + setForm((current) => ({ ...current, advancedOpen: nextOpen })); + }} + style={{ + border: '1px solid var(--color-border)', + borderRadius: 'var(--radius-md)', + padding: '0 14px', + background: 'var(--color-bg-card)', + }} + > + + {tr('高级配置')} + +
+ +
+
+ + ) : ( +
+ {!editingLegacyPatternGroup && ( +
+
)} - {previewMatchedModels.length > 12 && ( -
- {tr('仅展示前 12 个命中样本。')} +
+ + + +
+ + + +
+ {isExactModelPattern(form.modelPattern) + ? tr('当前为精确模型匹配。') + : tr('正则请使用 re: 前缀;例如 re:^claude-(opus|sonnet)-4-6$')} +
+ + {modelPatternError && ( +
+ {modelPatternError} +
+ )} + + {form.modelPattern.trim() && !modelPatternError && ( +
+
+ {tr('规则预览:命中样本')} {previewMatchedModels.length} / {previewModelSamples.length} +
+ + {previewModelSamples.length === 0 ? ( +
+ {tr('当前暂无可预览模型,请先同步模型。')} +
+ ) : previewMatchedModels.length === 0 ? ( +
+ {tr('当前规则未命中任何样本模型。')} +
+ ) : ( +
+ {previewMatchedModels.slice(0, 12).map((modelName) => ( + + {modelName} + + ))} +
+ )} + + {previewMatchedModels.length > 12 && ( +
+ {tr('仅展示前 12 个命中样本。')} +
+ )}
)}
)} - -
- - {editingRouteId ? ( - - ) : null} -
-
+ ); } diff --git a/src/web/pages/token-routes/RouteCard.tsx b/src/web/pages/token-routes/RouteCard.tsx index 663f6d3..854524d 100644 --- a/src/web/pages/token-routes/RouteCard.tsx +++ b/src/web/pages/token-routes/RouteCard.tsx @@ -29,7 +29,8 @@ import type { import type { RouteCandidateView, RouteTokenOption } from '../helpers/routeModelCandidatesIndex.js'; import { SortableChannelRow } from './SortableChannelRow.js'; import { - isExactModelPattern, + isRouteExactModel, + isExplicitGroupRoute, resolveRouteTitle, resolveRouteIcon, buildSourceGroupKey, @@ -113,8 +114,10 @@ function RouteCardInner({ onToggleSourceGroup, }: RouteCardProps) { const routeIcon = resolveRouteIcon(route); - const exactRoute = isExactModelPattern(route.modelPattern); + const exactRoute = isRouteExactModel(route); + const explicitGroupRoute = isExplicitGroupRoute(route); const readOnlyRoute = route.kind === 'zero_channel' || route.readOnly === true || route.isVirtual === true; + const channelManagementDisabled = explicitGroupRoute; const title = resolveRouteTitle(route); const routingStrategy = route.routingStrategy === 'round_robin' ? 'round_robin' : 'weighted'; const routingStrategyOptions = [ @@ -280,7 +283,7 @@ function RouteCardInner({
- {!readOnlyRoute && !exactRoute && ( + {!readOnlyRoute && (explicitGroupRoute || !exactRoute) && ( )} {!readOnlyRoute && } @@ -301,11 +304,15 @@ function RouteCardInner({
- {!exactRoute && ( + {explicitGroupRoute ? ( +
+ {tr('该群组会将多个来源模型聚合为一个对外模型名;通道信息继承自来源模型,当前仅支持查看,不支持直接维护。')} +
+ ) : !exactRoute ? (
{tr('通配符路由按请求实时决策;概率解释在当前路由内统一估算。')}
- )} + ) : null} {!readOnlyRoute && (
@@ -328,7 +335,7 @@ function RouteCardInner({ {/* Missing token hints + Add channel button */}
- {(missingTokenSiteItems.length > 0 || missingTokenGroupItems.length > 0) ? ( + {!channelManagementDisabled && (missingTokenSiteItems.length > 0 || missingTokenGroupItems.length > 0) ? (
{missingTokenSiteItems.length > 0 && (
@@ -366,7 +373,7 @@ function RouteCardInner({ )}
) :
} - {!readOnlyRoute && ( + {!readOnlyRoute && !channelManagementDisabled && (
+ ) : ( + c.id)} strategy={verticalListSortingStrategy}> + {group.channels.map((channel) => { + const tokenOptions = candidateView.tokenOptionsByAccountId[channel.accountId] || []; + const activeTokenId = channelTokenDraft[channel.id] ?? channel.tokenId ?? 0; + return ( + onSaveToken(route.id, channel.id, channel.accountId)} + onDeleteChannel={() => onDeleteChannel(channel.id, route.id)} + /> + ); + })} + + )} {!isGroupExpanded && (
diff --git a/src/web/pages/token-routes/types.ts b/src/web/pages/token-routes/types.ts index 02f98cf..2977b5f 100644 --- a/src/web/pages/token-routes/types.ts +++ b/src/web/pages/token-routes/types.ts @@ -6,6 +6,7 @@ export type RouteSortDir = 'asc' | 'desc'; export type GroupFilter = null | '__all__' | number; export type RouteRoutingStrategy = 'weighted' | 'round_robin'; export type RouteRowKind = 'persisted' | 'zero_channel'; +export type RouteMode = 'pattern' | 'explicit_group'; export type RouteChannelDraft = { accountId: number; @@ -47,6 +48,8 @@ export type RouteRow = { modelPattern: string; displayName?: string | null; displayIcon?: string | null; + routeMode?: RouteMode | null; + sourceRouteIds?: number[]; modelMapping?: string | null; routingStrategy?: RouteRoutingStrategy | null; decisionSnapshot?: RouteDecision | null; @@ -60,6 +63,8 @@ export type RouteSummaryRow = { modelPattern: string; displayName: string | null; displayIcon: string | null; + routeMode?: RouteMode | null; + sourceRouteIds?: number[]; modelMapping: string | null; routingStrategy?: RouteRoutingStrategy | null; enabled: boolean; diff --git a/src/web/pages/token-routes/utils.ts b/src/web/pages/token-routes/utils.ts index 4f70031..cfec687 100644 --- a/src/web/pages/token-routes/utils.ts +++ b/src/web/pages/token-routes/utils.ts @@ -1,6 +1,6 @@ import type { CSSProperties } from 'react'; import { getBrand, normalizeBrandIconKey, type BrandInfo } from '../../components/BrandIcon.js'; -import type { RouteRow, RouteChannel, RouteDecisionCandidate, ChannelDecisionState, RouteSummaryRow } from './types.js'; +import type { RouteRow, RouteChannel, RouteDecisionCandidate, ChannelDecisionState, RouteSummaryRow, RouteMode } from './types.js'; export const AUTO_ROUTE_DECISION_LIMIT = 80; export const ROUTE_RENDER_CHUNK = 40; @@ -48,6 +48,18 @@ export function isExactModelPattern(modelPattern: string): boolean { return !/[\*\?]/.test(normalized); } +export function normalizeRouteMode(routeMode: RouteMode | string | null | undefined): RouteMode { + return routeMode === 'explicit_group' ? 'explicit_group' : 'pattern'; +} + +export function isExplicitGroupRoute(route: Pick): boolean { + return normalizeRouteMode(route.routeMode) === 'explicit_group'; +} + +export function isRouteExactModel(route: Pick): boolean { + return !isExplicitGroupRoute(route) && isExactModelPattern(route.modelPattern); +} + export function parseRegexModelPattern(modelPattern: string): { regex: RegExp | null; error: string | null } { if (!isRegexModelPattern(modelPattern)) return { regex: null, error: null }; const body = modelPattern.trim().slice(3).trim(); diff --git a/src/web/pages/tokenRoutes.group-collapse.test.tsx b/src/web/pages/tokenRoutes.group-collapse.test.tsx index f76dca5..6295bbf 100644 --- a/src/web/pages/tokenRoutes.group-collapse.test.tsx +++ b/src/web/pages/tokenRoutes.group-collapse.test.tsx @@ -855,16 +855,23 @@ describe('TokenRoutes grouped source models', () => { } }); - it('enters edit mode and seeds the group form with the current route values', async () => { + it('opens a modal-based group creator and submits explicit-group sourceRouteIds', async () => { apiMock.getRoutesSummary.mockResolvedValue([ { - id: 31, modelPattern: 're:^claude-(opus|sonnet)-4-6$', displayName: 'claude-4-6-group', - displayIcon: 'anthropic', modelMapping: null, enabled: true, + id: 11, modelPattern: 'claude-opus-4-5', displayName: null, + displayIcon: null, modelMapping: null, enabled: true, + routeMode: 'pattern', sourceRouteIds: [], + channelCount: 1, enabledChannelCount: 1, siteNames: ['site-a'], + decisionSnapshot: null, decisionRefreshedAt: null, + }, + { + id: 12, modelPattern: 'claude-sonnet-4-5', displayName: null, + displayIcon: null, modelMapping: null, enabled: true, + routeMode: 'pattern', sourceRouteIds: [], channelCount: 0, enabledChannelCount: 0, siteNames: [], decisionSnapshot: null, decisionRefreshedAt: null, }, ]); - apiMock.getRouteChannels.mockResolvedValue([]); let root: ReturnType | null = null; try { @@ -879,109 +886,45 @@ describe('TokenRoutes grouped source models', () => { }); await flushMicrotasks(); - // Expand the card to access edit button - const expandBtn = root.root.find((node) => - node.type === 'div' && String(node.props.className || '').includes('route-card-collapsed'), - ); await act(async () => { - expandBtn.props.onClick(); - }); - await flushMicrotasks(); - - const editButton = findButtonByText(root.root, '编辑群组'); - await act(async () => { - editButton.props.onClick(); - }); - await flushMicrotasks(); - - expect(findInputByPlaceholder(root.root, '群组显示名').props.value).toBe('claude-4-6-group'); - expect(findInputByPlaceholder(root.root, '模型匹配').props.value).toBe('re:^claude-(opus|sonnet)-4-6$'); - expect(collectText(root.root)).toContain('保存群组'); - } finally { - root?.unmount(); - } - }); - - it('updates route metadata from edit mode and reloads routes afterwards', async () => { - apiMock.getRoutesSummary - .mockResolvedValueOnce([ - { - id: 41, modelPattern: 're:^claude-.*$', displayName: 'old-group', - displayIcon: '', modelMapping: null, enabled: true, - channelCount: 0, enabledChannelCount: 0, siteNames: [], - decisionSnapshot: null, decisionRefreshedAt: null, - }, - ]) - .mockResolvedValueOnce([ - { - id: 41, modelPattern: 're:^claude-.*$', displayName: 'new-group', - displayIcon: '', modelMapping: null, enabled: true, - channelCount: 0, enabledChannelCount: 0, siteNames: [], - decisionSnapshot: null, decisionRefreshedAt: null, - }, - ]); - apiMock.getRouteChannels.mockResolvedValue([]); - - let root: ReturnType | null = null; - try { - await act(async () => { - root = create( - - - - - , - ); - }); - await flushMicrotasks(); - - // Expand the card - const expandBtn = root.root.find((node) => - node.type === 'div' && String(node.props.className || '').includes('route-card-collapsed'), - ); - await act(async () => { - expandBtn.props.onClick(); + findButtonByText(root.root, '新建群组').props.onClick(); }); await flushMicrotasks(); await act(async () => { - findButtonByText(root.root, '编辑群组').props.onClick(); + findInputByPlaceholder(root.root, '对外模型名').props.onChange({ target: { value: 'claude-opus-4-6' } }); }); await flushMicrotasks(); await act(async () => { - findInputByPlaceholder(root.root, '群组显示名').props.onChange({ target: { value: 'new-group' } }); - }); - - await act(async () => { - findButtonByText(root.root, '保存群组').props.onClick(); + findButtonByText(root.root, 'claude-opus-4-5').props.onClick(); + findButtonByText(root.root, 'claude-sonnet-4-5').props.onClick(); }); await flushMicrotasks(); - expect(apiMock.updateRoute).toHaveBeenCalledWith(41, expect.objectContaining({ - displayName: 'new-group', - modelPattern: 're:^claude-.*$', + await act(async () => { + findButtonByText(root.root, '创建群组').props.onClick(); + }); + await flushMicrotasks(); + + expect(collectText(root.root)).toContain('模型重定向'); + expect(apiMock.addRoute).toHaveBeenCalledWith(expect.objectContaining({ + routeMode: 'explicit_group', + displayName: 'claude-opus-4-6', + sourceRouteIds: [11, 12], })); - expect(apiMock.getRoutesSummary).toHaveBeenCalledTimes(2); } finally { root?.unmount(); } }); - it('reloads route data after saving an edited model pattern', async () => { + it('edits legacy regex groups in advanced mode only', async () => { apiMock.getRoutesSummary .mockResolvedValueOnce([ { id: 51, modelPattern: 're:^claude-.*$', displayName: 'group-a', displayIcon: '', modelMapping: null, enabled: true, - channelCount: 0, enabledChannelCount: 0, siteNames: [], - decisionSnapshot: null, decisionRefreshedAt: null, - }, - ]) - .mockResolvedValueOnce([ - { - id: 51, modelPattern: 're:^gemini-.*$', displayName: 'group-a', - displayIcon: '', modelMapping: null, enabled: true, + routeMode: 'pattern', sourceRouteIds: [], channelCount: 0, enabledChannelCount: 0, siteNames: [], decisionSnapshot: null, decisionRefreshedAt: null, }, @@ -1015,17 +958,107 @@ describe('TokenRoutes grouped source models', () => { }); await flushMicrotasks(); + expect(collectText(root.root)).toContain('高级规则群组'); + expect(findInputByPlaceholder(root.root, '模型匹配').props.value).toBe('re:^claude-.*$'); + expect(root.root.findAll((node) => typeof node.props?.placeholder === 'string' && node.props.placeholder.includes('搜索来源模型'))).toHaveLength(0); + expect(root.root.findAll((node) => typeof node.props?.placeholder === 'string' && node.props.placeholder.includes('对外模型名'))).toHaveLength(0); + } finally { + root?.unmount(); + } + }); + + it('updates explicit-group sources from the modal and reloads routes afterwards', async () => { + apiMock.getRoutesSummary + .mockResolvedValueOnce([ + { + id: 11, modelPattern: 'claude-opus-4-5', displayName: null, + displayIcon: null, modelMapping: null, enabled: true, + routeMode: 'pattern', sourceRouteIds: [], + channelCount: 1, enabledChannelCount: 1, siteNames: ['site-a'], + decisionSnapshot: null, decisionRefreshedAt: null, + }, + { + id: 12, modelPattern: 'claude-sonnet-4-5', displayName: null, + displayIcon: null, modelMapping: null, enabled: true, + routeMode: 'pattern', sourceRouteIds: [], + channelCount: 1, enabledChannelCount: 1, siteNames: ['site-b'], + decisionSnapshot: null, decisionRefreshedAt: null, + }, + { + id: 21, modelPattern: 'claude-opus-4-6', displayName: 'claude-opus-4-6', + displayIcon: '', modelMapping: null, enabled: true, + routeMode: 'explicit_group', sourceRouteIds: [11], + channelCount: 1, enabledChannelCount: 1, siteNames: ['site-a'], + decisionSnapshot: null, decisionRefreshedAt: null, + }, + ]) + .mockResolvedValueOnce([ + { + id: 11, modelPattern: 'claude-opus-4-5', displayName: null, + displayIcon: null, modelMapping: null, enabled: true, + routeMode: 'pattern', sourceRouteIds: [], + channelCount: 1, enabledChannelCount: 1, siteNames: ['site-a'], + decisionSnapshot: null, decisionRefreshedAt: null, + }, + { + id: 12, modelPattern: 'claude-sonnet-4-5', displayName: null, + displayIcon: null, modelMapping: null, enabled: true, + routeMode: 'pattern', sourceRouteIds: [], + channelCount: 1, enabledChannelCount: 1, siteNames: ['site-b'], + decisionSnapshot: null, decisionRefreshedAt: null, + }, + { + id: 21, modelPattern: 'claude-opus-4-6', displayName: 'claude-opus-4-6', + displayIcon: '', modelMapping: null, enabled: true, + routeMode: 'explicit_group', sourceRouteIds: [11, 12], + channelCount: 2, enabledChannelCount: 2, siteNames: ['site-a', 'site-b'], + decisionSnapshot: null, decisionRefreshedAt: null, + }, + ]); + apiMock.getRouteChannels.mockResolvedValue([]); + + let root: ReturnType | null = null; + try { await act(async () => { - findInputByPlaceholder(root.root, '模型匹配').props.onChange({ target: { value: 're:^gemini-.*$' } }); + root = create( + + + + + , + ); }); + await flushMicrotasks(); + + const expandBtn = root.root.find((node) => + node.type === 'div' + && String(node.props.className || '').includes('route-card-collapsed') + && collectText(node).includes('claude-opus-4-6'), + ); + await act(async () => { + expandBtn.props.onClick(); + }); + await flushMicrotasks(); + + await act(async () => { + findButtonByText(root.root, '编辑群组').props.onClick(); + }); + await flushMicrotasks(); + + await act(async () => { + findButtonByText(root.root, 'claude-sonnet-4-5').props.onClick(); + }); + await flushMicrotasks(); await act(async () => { findButtonByText(root.root, '保存群组').props.onClick(); }); await flushMicrotasks(); - expect(apiMock.updateRoute).toHaveBeenCalledWith(51, expect.objectContaining({ - modelPattern: 're:^gemini-.*$', + expect(apiMock.updateRoute).toHaveBeenCalledWith(21, expect.objectContaining({ + routeMode: 'explicit_group', + displayName: 'claude-opus-4-6', + sourceRouteIds: [11, 12], })); expect(apiMock.getRoutesSummary).toHaveBeenCalledTimes(2); } finally {