Merge pull request #155 from cita-777/codex/fix-connection-and-release-regressions

[codex] fix route grouping and codex websocket regressions
This commit is contained in:
Cita
2026-03-18 21:49:30 +08:00
committed by GitHub
43 changed files with 2937 additions and 686 deletions
+13
View File
@@ -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`);
+7
View File
@@ -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
}
]
}
+4 -1
View File
@@ -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`);
+4 -6
View File
@@ -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`);
@@ -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");
+4 -6
View File
@@ -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");
@@ -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": [
+21
View File
@@ -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() {
+4
View File
@@ -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),
]);
+48 -16
View File
@@ -274,6 +274,14 @@ function markMigrationRecordIfMissing(sqlite: Database.Database, record: Migrati
return true;
}
function hasMigrationRecord(sqlite: Database.Database, record: MigrationRecord): boolean {
if (!tableExists(sqlite, '__drizzle_migrations')) return false;
const row = sqlite
.prepare('SELECT 1 FROM "__drizzle_migrations" WHERE "hash" = ? LIMIT 1')
.get(record.hash);
return !!row;
}
function normalizeSchemaErrorMessage(error: unknown): string {
if (!error || typeof error !== 'object') {
return String(error || '');
@@ -332,25 +340,28 @@ function isSitesPlatformUrlUniqueConflictError(error: unknown): boolean {
=== normalizeSqlForMatch('CREATE UNIQUE INDEX `sites_platform_url_unique` ON `sites` (`platform`,`url`);');
}
function getLatestRecordedMigrationCreatedAt(sqlite: Database.Database): number | null {
if (!tableExists(sqlite, '__drizzle_migrations')) return null;
const row = sqlite
.prepare('SELECT created_at FROM "__drizzle_migrations" ORDER BY created_at DESC LIMIT 1')
.get() as { created_at?: number } | undefined;
if (!row || row.created_at === undefined || row.created_at === null) {
return null;
}
return Number(row.created_at);
}
function replayMigrationStatements(sqlite: Database.Database, statements: string[]): void {
for (const statement of statements) {
try {
sqlite.exec(statement);
} catch (error) {
if (!isRecoverableSchemaConflictError(error)) {
throw error;
if (isRecoverableSchemaConflictError(error)) {
continue;
}
if (isSitesPlatformUrlUniqueConflictError(error) && deduplicateLegacySitesForUniqueIndex(sqlite)) {
try {
sqlite.exec(statement);
continue;
} catch (retryError) {
if (isRecoverableSchemaConflictError(retryError)) {
continue;
}
throw retryError;
}
}
throw error;
}
}
}
@@ -366,20 +377,40 @@ function recoverMigrationSequence(
return false;
}
let latestRecordedCreatedAt = getLatestRecordedMigrationCreatedAt(sqlite);
for (const migration of migrations.slice(0, failedMigrationIndex + 1)) {
if (latestRecordedCreatedAt !== null && latestRecordedCreatedAt >= migration.createdAt) {
if (hasMigrationRecord(sqlite, migration)) {
continue;
}
replayMigrationStatements(sqlite, migration.statements);
markMigrationRecordIfMissing(sqlite, migration);
latestRecordedCreatedAt = migration.createdAt;
}
return true;
}
function backfillMissingRecordedMigrations(sqlite: Database.Database, migrationsFolder: string): number {
if (!tableExists(sqlite, '__drizzle_migrations')) return 0;
let recoveredCount = 0;
for (const migration of readRecoveryMigrations(migrationsFolder)) {
if (hasMigrationRecord(sqlite, migration)) {
continue;
}
replayMigrationStatements(sqlite, migration.statements);
if (markMigrationRecordIfMissing(sqlite, migration)) {
recoveredCount += 1;
}
}
if (recoveredCount > 0) {
console.warn(`[db] Backfilled ${recoveredCount} missing drizzle migration record(s).`);
}
return recoveredCount;
}
function tryRecoverDuplicateColumnMigrationError(
sqlite: Database.Database,
migrationsFolder: string,
@@ -557,6 +588,7 @@ export function runSqliteMigrations(): void {
const sqlite = new Database(dbPath);
bootstrapLegacyDrizzleMigrations(sqlite, migrationsFolder);
backfillMissingRecordedMigrations(sqlite, migrationsFolder);
try {
migrate(drizzle(sqlite), { migrationsFolder });
@@ -8,9 +8,11 @@ function createInspector(
dialect: RouteGroupingSchemaInspector['dialect'],
options?: {
existingColumnsByTable?: Partial<Record<'token_routes' | 'route_channels', string[]>>;
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);
@@ -13,6 +13,11 @@ type RouteGroupingColumnCompatibilitySpec = {
addSql: Record<RouteGroupingSchemaDialect, string>;
};
type RouteGroupingTableCompatibilitySpec = {
table: 'route_group_sources';
createSql: Record<RouteGroupingSchemaDialect, string[]>;
};
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<void> {
const tableExistsCache = new Map<string, boolean>();
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) {
+10
View File
@@ -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' }),
@@ -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',
},
});
});
});
+303 -59
View File
@@ -23,6 +23,138 @@ function isExactModelPattern(modelPattern: string): boolean {
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<RouteRow, 'routeMode'> | Pick<typeof schema.tokenRoutes.$inferSelect, 'routeMode'>): 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<Map<number, number[]>> {
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<number, number[]>();
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<typeof schema.tokenRoutes.$inferSelect>,
sourceRouteIdsByRouteId: Map<number, number[]>,
): RouteRow[] {
return routes.map((route) => ({
...route,
routeMode: normalizeRouteMode(route.routeMode),
sourceRouteIds: sourceRouteIdsByRouteId.get(route.id) ?? [],
}));
}
async function listRoutesWithSources(): Promise<RouteRow[]> {
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<RouteRow | null> {
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<void> {
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<void> {
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<number>();
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<number | null> {
const token = await db.select().from(schema.accountTokens)
.where(and(
@@ -390,22 +522,51 @@ function parseBatchRouteWideDecisionRouteIds(
};
}
async function fetchChannelsForRoutes(routeIds: number[]): Promise<Map<number, any[]>> {
if (routeIds.length === 0) return new Map();
type RouteChannelSummary = {
channelCount: number;
enabledChannelCount: number;
siteNames: Set<string>;
};
async function fetchChannelsForRouteRows(routes: RouteRow[]): Promise<Map<number, any[]>> {
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, routeIds))
.where(inArray(schema.routeChannels.routeId, actualRouteIds))
.all();
const channelsByRoute = new Map<number, any[]>();
const channelsByActualRouteId = new Map<number, any[]>();
for (const row of channelRows) {
const routeId = row.route_channels.routeId;
if (!channelsByRoute.has(routeId)) channelsByRoute.set(routeId, []);
channelsByRoute.get(routeId)!.push({
if (!channelsByActualRouteId.has(routeId)) channelsByActualRouteId.set(routeId, []);
channelsByActualRouteId.get(routeId)!.push({
...row.route_channels,
account: row.accounts,
site: row.sites,
@@ -421,51 +582,69 @@ async function fetchChannelsForRoutes(routeIds: number[]): Promise<Map<number, a
});
}
const channelsByRoute = new Map<number, any[]>();
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<Map<number, any[]>> {
if (routeIds.length === 0) return new Map();
return await fetchChannelsForRouteRows(await listRoutesWithSources()).then((channelsByRoute) => {
const filtered = new Map<number, any[]>();
for (const routeId of routeIds) {
filtered.set(routeId, channelsByRoute.get(routeId) || []);
}
return filtered;
});
}
async function buildRouteChannelSummaryMap(routes: RouteRow[]): Promise<Map<number, RouteChannelSummary>> {
const channelsByRoute = await fetchChannelsForRouteRows(routes);
const summaryByRoute = new Map<number, RouteChannelSummary>();
for (const route of routes) {
const channels = channelsByRoute.get(route.id) || [];
const siteNames = new Set<string>();
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;
}
export async function tokensRoutes(app: FastifyInstance) {
// List routes with basic info only (lightweight for selectors)
app.get('/api/routes/lite', async () => {
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();
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 db.select().from(schema.tokenRoutes).all();
const routes = await listRoutesWithSources();
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<number, { channelCount: number; enabledChannelCount: number; siteNames: Set<string> }>();
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);
}
const aggByRoute = await buildRouteChannelSummaryMap(routes);
return routes.map((route) => {
const agg = aggByRoute.get(route.id);
@@ -474,6 +653,8 @@ export async function tokensRoutes(app: FastifyInstance) {
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,
@@ -489,11 +670,11 @@ 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();
const route = await getRouteWithSources(routeId);
if (!route) {
return reply.code(404).send({ success: false, message: '路由不存在' });
}
const channelsByRoute = await fetchChannelsForRoutes([routeId]);
const channelsByRoute = await fetchChannelsForRouteRows([route]);
return channelsByRoute.get(routeId) || [];
});
@@ -502,10 +683,13 @@ export async function tokensRoutes(app: FastifyInstance) {
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();
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 必须是非空数组' });
@@ -568,6 +752,7 @@ export async function tokensRoutes(app: FastifyInstance) {
if (created > 0) {
await clearRouteDecisionSnapshot(routeId);
await clearDependentExplicitGroupSnapshotsBySourceRouteIds([routeId]);
invalidateTokenRouterCache();
}
@@ -576,11 +761,10 @@ export async function tokensRoutes(app: FastifyInstance) {
// List all routes
app.get('/api/routes', async () => {
const routes = await db.select().from(schema.tokenRoutes).all();
const routes = await listRoutesWithSources();
if (routes.length === 0) return [];
const routeIds = routes.map((route) => route.id);
const channelsByRoute = await fetchChannelsForRoutes(routeIds);
const channelsByRoute = await fetchChannelsForRouteRows(routes);
return routes.map((route) => ({
...route,
@@ -690,12 +874,32 @@ export async function tokensRoutes(app: FastifyInstance) {
});
// Create a route
app.post<{ Body: { modelPattern: string; displayName?: string; displayIcon?: string; modelMapping?: string; routingStrategy?: string; enabled?: boolean } }>('/api/routes', async (request) => {
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,
@@ -704,58 +908,91 @@ export async function tokensRoutes(app: FastifyInstance) {
if (routeId <= 0) {
return { success: false, message: '创建路由失败' };
}
const route = await db.select().from(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, routeId)).get();
const route = await getRouteWithSources(routeId);
if (!route) {
return { success: false, message: '创建路由失败' };
}
await populateRouteChannelsByModelPattern(route.id, body.modelPattern);
if (routeMode === 'explicit_group') {
await replaceRouteSourceRouteIds(route.id, sourceRouteIds);
} else {
await populateRouteChannelsByModelPattern(route.id, modelPattern);
}
invalidateTokenRouterCache();
return route;
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<string, unknown>;
const existingRoute = await db.select().from(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, id)).get();
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<string, unknown> = {};
let nextModelPattern = existingRoute.modelPattern;
let nextDisplayName = existingRoute.displayName ?? '';
let nextSourceRouteIds = existingRoute.sourceRouteIds;
if (body.displayName !== undefined) updates.displayName = body.displayName;
if (body.displayName !== undefined) {
nextDisplayName = String(body.displayName || '').trim();
updates.displayName = nextDisplayName || null;
}
if (body.displayIcon !== undefined) updates.displayIcon = body.displayIcon;
if (body.modelPattern !== undefined) {
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) {
if (routeMode === 'pattern' && modelPatternChanged) {
await rebuildAutomaticRouteChannelsByModelPattern(id, nextModelPattern);
}
if (routeBehaviorChanged) {
await clearRouteDecisionSnapshot(id);
await clearDependentExplicitGroupSnapshotsBySourceRouteIds([id]);
}
invalidateTokenRouterCache();
return await db.select().from(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, id)).get();
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 clearDependentExplicitGroupSnapshotsBySourceRouteIds([id]);
await db.delete(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, id)).run();
invalidateTokenRouterCache();
return { success: true };
@@ -766,10 +1003,13 @@ export async function tokensRoutes(app: FastifyInstance) {
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();
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()
@@ -813,6 +1053,7 @@ export async function tokensRoutes(app: FastifyInstance) {
return reply.code(500).send({ success: false, message: '创建通道失败' });
}
await clearRouteDecisionSnapshot(routeId);
await clearDependentExplicitGroupSnapshotsBySourceRouteIds([routeId]);
invalidateTokenRouterCache();
return created;
});
@@ -845,6 +1086,7 @@ export async function tokensRoutes(app: FastifyInstance) {
.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 };
});
@@ -891,6 +1133,7 @@ export async function tokensRoutes(app: FastifyInstance) {
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();
});
@@ -902,6 +1145,7 @@ export async function tokensRoutes(app: FastifyInstance) {
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 };
+17 -2
View File
@@ -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 () => {
+105
View File
@@ -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',
+1 -24
View File
@@ -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[] = [];
+6
View File
@@ -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<string, unknown>)
.some(([rawKey, rawValue]) => rawKey.trim().toLowerCase() === 'x-metapi-responses-websocket-transport'
&& String(rawValue).trim() === '1'),
getUsage: () => parsedUsage,
onParsedPayload: (payload) => {
if (payload && typeof payload === 'object') {
@@ -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<void>((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<string, string | string[] | undefined> }>((resolve, reject) => {
socket.once('upgrade', (response) => resolve({ headers: response.headers as Record<string, string | string[] | undefined> }));
socket.once('error', reject);
});
}
function waitForSocketMessages(socket: WebSocket, count: number, timeoutMs = 1000) {
return new Promise<any[]>((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');
});
});
+93 -41
View File
@@ -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<FastifyInstance>();
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<string, unknown> | null {
try {
const parsed = JSON.parse(String(raw));
@@ -44,19 +60,11 @@ function toResponseInputArray(value: unknown): unknown[] {
}
function normalizeResponsesWebsocketRequest(
raw: RawData,
parsed: Record<string, unknown>,
lastRequest: Record<string, unknown> | 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,11 +122,37 @@ 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,
nextRequestSnapshot: cloneJsonObject(next),
};
}
function shouldHandleResponsesWebsocketPrewarmLocally(
parsed: Record<string, unknown>,
lastRequest: Record<string, unknown> | null,
supportsIncrementalInput: boolean,
): boolean {
if (supportsIncrementalInput || lastRequest) return false;
if (asTrimmedString(parsed.type) !== 'response.create') return false;
return parsed.generate === false;
}
function writeResponsesWebsocketError(
@@ -244,6 +253,21 @@ function buildInjectHeaders(request: IncomingMessage): Record<string, string | s
return headers;
}
async function supportsResponsesWebsocketIncrementalInput(
parsed: Record<string, unknown>,
lastRequest: Record<string, unknown> | null,
): Promise<boolean> {
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');
+14 -2
View File
@@ -470,6 +470,9 @@ function convertCodexSystemRoleToDeveloper(input: unknown): unknown {
function applyCodexResponsesCompatibility(
body: Record<string, unknown>,
sitePlatform: string,
options?: {
preservePreviousResponseId?: boolean;
},
): Record<string, unknown> {
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,
),
+25
View File
@@ -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');
});
+22
View File
@@ -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<AccountsBackupSection> {
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<AccountsBackupSection> {
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<void> {
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<vo
displayName: row.displayName ?? null,
displayIcon: row.displayIcon ?? null,
modelMapping: row.modelMapping,
routeMode: row.routeMode ?? 'pattern',
decisionSnapshot: row.decisionSnapshot ?? null,
decisionRefreshedAt: row.decisionRefreshedAt ?? null,
routingStrategy: row.routingStrategy ?? 'weighted',
enabled: row.enabled,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
}).run();
}
for (const row of section.routeGroupSources || []) {
await tx.insert(schema.routeGroupSources).values({
id: row.id,
groupRouteId: row.groupRouteId,
sourceRouteId: row.sourceRouteId,
}).run();
}
for (const row of section.routeChannels) {
await tx.insert(schema.routeChannels).values({
id: row.id,
@@ -225,7 +225,20 @@ describe('databaseMigrationService', () => {
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');
});
});
@@ -36,6 +36,7 @@ type BackupSnapshot = {
tokenModelAvailability: Array<Record<string, unknown>>;
tokenRoutes: Array<Record<string, unknown>>;
routeChannels: Array<Record<string, unknown>>;
routeGroupSources: Array<Record<string, unknown>>;
proxyLogs: Array<Record<string, unknown>>;
proxyVideoTasks: Array<Record<string, unknown>>;
proxyFiles: Array<Record<string, unknown>>;
@@ -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<BackupSnapshot> {
tokenModelAvailability: await db.select().from(schema.tokenModelAvailability).all() as Array<Record<string, unknown>>,
tokenRoutes: await db.select().from(schema.tokenRoutes).all() as Array<Record<string, unknown>>,
routeChannels: await db.select().from(schema.routeChannels).all() as Array<Record<string, unknown>>,
routeGroupSources: await db.select().from(schema.routeGroupSources).all() as Array<Record<string, unknown>>,
proxyLogs: await db.select().from(schema.proxyLogs).all() as Array<Record<string, unknown>>,
proxyVideoTasks: await db.select().from(schema.proxyVideoTasks).all() as Array<Record<string, unknown>>,
proxyFiles: await db.select().from(schema.proxyFiles).all() as Array<Record<string, unknown>>,
@@ -245,6 +248,7 @@ async function ensureTargetState(client: SqlClient, overwrite: boolean): Promise
async function clearTargetData(client: SqlClient): Promise<void> {
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<void> {
'token_model_availability',
'token_routes',
'route_channels',
'route_group_sources',
'proxy_logs',
'proxy_video_tasks',
'proxy_files',
@@ -617,7 +635,7 @@ async function syncPostgresSequences(client: SqlClient): Promise<void> {
for (const table of tables) {
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<NormalizedDatabaseMigrationInput, 'dialect' | 'connectionString' | 'ssl'>): Promise<void> {
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,
},
};
}
+4 -1
View File
@@ -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<string>(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;
+6 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { filterRecentlyFailedCandidates, isChannelRecentlyFailed } from './tokenRouter.js';
import { filterRecentlyFailedCandidates, isChannelRecentlyFailed, matchesModelPattern, parseRegexModelPattern } from './tokenRouter.js';
type Candidate = {
channel: {
@@ -10,6 +10,11 @@ type Candidate = {
};
describe('filterRecentlyFailedCandidates', () => {
it('rejects unsafe nested-quantifier regex route patterns', () => {
expect(parseRegexModelPattern('re:^(a+)+$')).toBeNull();
expect(matchesModelPattern('aaaa', 're:^(a+)+$')).toBe(false);
});
it('uses a short default recent-failure window', () => {
const nowMs = Date.now();
expect(isChannelRecentlyFailed({
+241 -16
View File
@@ -1,4 +1,4 @@
import { eq } from 'drizzle-orm';
import { eq, inArray } from 'drizzle-orm';
import { minimatch } from 'minimatch';
import { db, schema } from '../db/index.js';
import { config } from '../config.js';
@@ -13,7 +13,7 @@ import { isUsableAccountToken } from './accountTokenService.js';
import { getOauthInfoFromExtraConfig } from './oauth/oauthAccount.js';
interface RouteMatch {
route: typeof schema.tokenRoutes.$inferSelect;
route: RouteRow;
channels: Array<{
channel: typeof schema.routeChannels.$inferSelect;
account: typeof schema.accounts.$inferSelect;
@@ -43,6 +43,7 @@ const FAILURE_BACKOFF_BASE_SEC = 15;
const MIN_EFFECTIVE_UNIT_COST = 1e-6;
const ROUND_ROBIN_FAILURE_THRESHOLD = 3;
const ROUND_ROBIN_COOLDOWN_LEVELS_SEC = [0, 10 * 60, 60 * 60, 24 * 60 * 60] as const;
const MAX_ROUTE_REGEX_BODY_LENGTH = 256;
function fibonacciNumber(index: number): number {
if (index <= 2) return 1;
@@ -66,7 +67,11 @@ function resolveRoundRobinCooldownSec(level: number): number {
return ROUND_ROBIN_COOLDOWN_LEVELS_SEC[normalizedLevel] ?? 0;
}
type RouteRow = typeof schema.tokenRoutes.$inferSelect;
type RouteMode = 'pattern' | 'explicit_group';
type RouteRow = typeof schema.tokenRoutes.$inferSelect & {
routeMode: RouteMode;
sourceRouteIds: number[];
};
type ChannelRow = typeof schema.routeChannels.$inferSelect;
type RouteCacheSnapshot = {
@@ -100,9 +105,29 @@ async function loadEnabledRoutes(nowMs = Date.now()): Promise<RouteRow[]> {
return routeCacheSnapshot.routes;
}
const routes = await db.select().from(schema.tokenRoutes)
const rawRoutes = await db.select().from(schema.tokenRoutes)
.where(eq(schema.tokenRoutes.enabled, true))
.all();
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<number, number[]>();
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,
@@ -116,14 +141,31 @@ async function loadRouteMatch(route: RouteRow, nowMs = Date.now()): Promise<Rout
return cached.match;
}
const channels = await db
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(eq(schema.routeChannels.routeId, route.id))
.all();
.where(inArray(schema.routeChannels.routeId, enabledSourceRouteIds))
.all()
: [];
const mapped = channels.map((row) => ({
channel: row.route_channels,
@@ -248,10 +290,121 @@ export function isRegexModelPattern(pattern: string): boolean {
return pattern.trim().toLowerCase().startsWith('re:');
}
function readRegexQuantifierLength(pattern: string, startIndex: number): number {
const ch = pattern[startIndex];
if (ch === '*' || ch === '+' || ch === '?') return 1;
if (ch !== '{') return 0;
let index = startIndex + 1;
let sawDigit = false;
while (index < pattern.length && /\d/.test(pattern[index])) {
sawDigit = true;
index += 1;
}
if (!sawDigit) return 0;
if (pattern[index] === ',') {
index += 1;
while (index < pattern.length && /\d/.test(pattern[index])) {
index += 1;
}
}
if (pattern[index] !== '}') return 0;
return index - startIndex + 1;
}
function isSafeRegexPatternBody(body: string): boolean {
if (!body || body.length > MAX_ROUTE_REGEX_BODY_LENGTH) return false;
if (!/^[a-z0-9\s.^$|()[\]{}+*?\\:_/-]+$/i.test(body)) return false;
if (body.includes('(?=') || body.includes('(?!') || body.includes('(?<=') || body.includes('(?<!') || body.includes('(?<')) {
return false;
}
if (/(^|[^\\])\\[1-9]/.test(body)) {
return false;
}
const groupStack: Array<{ hasInnerQuantifier: boolean; hasAlternation: boolean }> = [];
let escaped = false;
let inCharClass = false;
for (let index = 0; index < body.length; index += 1) {
const ch = body[index];
if (escaped) {
escaped = false;
continue;
}
if (ch === '\\') {
escaped = true;
continue;
}
if (inCharClass) {
if (ch === ']') inCharClass = false;
continue;
}
if (ch === '[') {
inCharClass = true;
continue;
}
if (ch === '(') {
if (body[index + 1] === '?') {
if (body[index + 2] !== ':') {
return false;
}
groupStack.push({ hasInnerQuantifier: false, hasAlternation: false });
index += 2;
continue;
}
groupStack.push({ hasInnerQuantifier: false, hasAlternation: false });
continue;
}
if (ch === '|') {
if (groupStack.length > 0) {
groupStack[groupStack.length - 1].hasAlternation = true;
}
continue;
}
if (ch === ')') {
const group = groupStack.pop();
if (!group) return false;
const quantifierLength = readRegexQuantifierLength(body, index + 1);
if (quantifierLength > 0 && (group.hasInnerQuantifier || group.hasAlternation)) {
return false;
}
const parent = groupStack[groupStack.length - 1];
if (parent && (group.hasInnerQuantifier || quantifierLength > 0)) {
parent.hasInnerQuantifier = true;
}
continue;
}
const quantifierLength = readRegexQuantifierLength(body, index);
if (quantifierLength > 0) {
if (groupStack.length > 0) {
groupStack[groupStack.length - 1].hasInnerQuantifier = true;
}
index += quantifierLength - 1;
}
}
return !escaped && !inCharClass && groupStack.length === 0;
}
export function parseRegexModelPattern(pattern: string): RegExp | null {
if (!isRegexModelPattern(pattern)) return null;
const body = pattern.trim().slice(3).trim();
if (!body) return null;
if (!isSafeRegexPatternBody(body)) return null;
try {
return new RegExp(body);
} catch {
@@ -280,6 +433,14 @@ function isExactRouteModelPattern(pattern: string): boolean {
return !/[\*\?\[]/.test(normalizedPattern);
}
function normalizeRouteMode(routeMode: string | null | undefined): RouteMode {
return routeMode === 'explicit_group' ? 'explicit_group' : 'pattern';
}
function isExplicitGroupRoute(route: Pick<RouteRow, 'routeMode'> | Pick<typeof schema.tokenRoutes.$inferSelect, 'routeMode'>): boolean {
return normalizeRouteMode(route.routeMode) === 'explicit_group';
}
function normalizeRouteDisplayName(displayName: string | null | undefined): string {
return (displayName || '').trim();
}
@@ -289,14 +450,62 @@ function isRouteDisplayNameMatch(model: string, displayName: string | null | und
return !!alias && alias === model;
}
function matchesRouteRequestModel(model: string, route: typeof schema.tokenRoutes.$inferSelect): boolean {
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: typeof schema.tokenRoutes.$inferSelect): string {
function getExposedModelNameForRoute(route: RouteRow): string {
return normalizeRouteDisplayName(route.displayName) || route.modelPattern;
}
function hasCustomDisplayName(route: Pick<RouteRow, 'modelPattern' | 'displayName'>): 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();
if (!normalized) return '';
@@ -367,7 +576,7 @@ function normalizeChannelSourceModel(channelSourceModel: string | null | undefin
function resolveActualModelForSelectedChannel(
requestedModel: string,
route: typeof schema.tokenRoutes.$inferSelect,
route: RouteRow,
mappedModel: string,
channelSourceModel: string | null | undefined,
): string {
@@ -378,7 +587,7 @@ function resolveActualModelForSelectedChannel(
return mappedModel;
}
function resolveRouteStrategy(route: typeof schema.tokenRoutes.$inferSelect): RouteRoutingStrategy {
function resolveRouteStrategy(route: RouteRow): RouteRoutingStrategy {
return normalizeRouteRoutingStrategy(route.routingStrategy);
}
@@ -451,6 +660,17 @@ export class TokenRouter {
return await this.selectFromMatch(match, requestedModel, downstreamPolicy);
}
async previewSelectedChannel(
requestedModel: string,
downstreamPolicy: DownstreamRoutingPolicy = DEFAULT_DOWNSTREAM_POLICY,
): Promise<SelectedChannel | null> {
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 +1113,7 @@ export class TokenRouter {
*/
async getAvailableModels(): Promise<string[]> {
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 +1126,7 @@ export class TokenRouter {
requestedModel: string,
downstreamPolicy: DownstreamRoutingPolicy,
excludeChannelIds: number[] = [],
recordSelection = true,
): Promise<SelectedChannel | null> {
const mappedModel = resolveMappedModel(requestedModel, match.route.modelMapping);
const requestedByDisplayName = isRouteDisplayNameMatch(requestedModel, match.route.displayName);
@@ -931,7 +1152,9 @@ export class TokenRouter {
const tokenValue = this.resolveChannelTokenValue(selected);
if (!tokenValue) return null;
if (recordSelection) {
await this.recordChannelSelection(selected.channel.id);
}
const actualModel = resolveActualModelForSelectedChannel(
requestedModel,
@@ -1003,11 +1226,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 +1250,7 @@ export class TokenRouter {
return await this.loadRouteMatch(route);
}
private async loadRouteMatch(route: typeof schema.tokenRoutes.$inferSelect): Promise<RouteMatch> {
private async loadRouteMatch(route: RouteRow): Promise<RouteMatch> {
return await loadRouteMatch(route);
}
@@ -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',
@@ -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',
},
});
});
@@ -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();
};
+4 -2
View File
@@ -16,6 +16,7 @@ import { fuzzyMatch } from './helpers/fuzzySearch.js';
import { clearAuthSession } from '../authSession.js';
import { clearAppInstallationState } from '../appLocalState.js';
import { tr } from '../i18n.js';
import { ROUTE_ICON_NONE_VALUE } from './token-routes/utils.js';
const PROXY_TOKEN_PREFIX = 'sk-';
const ROUTE_BRAND_ICON_PREFIX = 'brand:';
@@ -1677,7 +1678,8 @@ export default function Settings() {
) : filteredGroupRouteOptions.map((route) => {
const checked = downstreamCreate.selectedGroupRouteIds.includes(route.id);
const explicitBrandIcon = parseBrandIconValue(route.displayIcon);
const textIcon = explicitBrandIcon ? '' : (route.displayIcon || '').trim();
const explicitNoIcon = (route.displayIcon || '').trim() === ROUTE_ICON_NONE_VALUE;
const textIcon = explicitBrandIcon || explicitNoIcon ? '' : (route.displayIcon || '').trim();
return (
<label
key={route.id}
@@ -1740,7 +1742,7 @@ export default function Settings() {
/>
) : textIcon ? (
textIcon
) : (
) : explicitNoIcon ? null : (
<InlineBrandIcon model={resolveRouteBrandSource(route)} size={18} />
)}
</span>
+76 -31
View File
@@ -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<RouteSummaryRow[]>([]);
@@ -86,7 +108,7 @@ export default function TokenRoutes() {
const [sortDir, setSortDir] = useState<RouteSortDir>('desc');
const [showManual, setShowManual] = useState(false);
const [form, setForm] = useState({ modelPattern: '', displayName: '', displayIcon: '' });
const [form, setForm] = useState<RouteEditorForm>(EMPTY_ROUTE_FORM);
const [editingRouteId, setEditingRouteId] = useState<number | null>(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<string>(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<string>();
@@ -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 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;
}
}
const trimmedModelPattern = form.modelPattern.trim();
const trimmedDisplayName = form.displayName.trim() ? form.displayName.trim() : undefined;
const trimmedDisplayIcon = form.displayIcon.trim() ? form.displayIcon.trim() : undefined;
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<GroupRouteItem[]>(() => (
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() {
<button
onClick={() => {
if (showManual) {
setShowManual(false);
resetRouteForm();
return;
}
setShowManual(true);
resetRouteForm();
}}
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)', padding: '8px 14px' }}
>
{editingRouteId
? tr('取消编辑')
: (showManual ? tr('收起群组创建') : tr('新建群组'))}
{tr('新建群组')}
</button>
<button
@@ -1107,6 +1151,7 @@ export default function TokenRoutes() {
canSave={canSaveRoute}
routeIconSelectOptions={routeIconSelectOptions}
previewModelSamples={previewModelSamples}
exactSourceRouteOptions={exactSourceRouteOptions}
onSave={handleAddRoute}
onCancel={handleCancelEditRoute}
/>
+20 -4
View File
@@ -2,9 +2,19 @@ export type RouteListVisibilityItem = {
id: number;
modelPattern: string;
displayName?: string | null;
routeMode?: string | null;
sourceRouteIds?: number[];
enabled: boolean;
};
function normalizeRouteMode(routeMode: string | null | undefined): 'pattern' | 'explicit_group' {
return routeMode === 'explicit_group' ? 'explicit_group' : 'pattern';
}
function isExplicitGroupRoute(route: Pick<RouteListVisibilityItem, 'routeMode'>): boolean {
return normalizeRouteMode(route.routeMode) === 'explicit_group';
}
function hasCustomDisplayName(route: Pick<RouteListVisibilityItem, 'modelPattern' | 'displayName'>): boolean {
const displayName = (route.displayName || '').trim();
const modelPattern = (route.modelPattern || '').trim();
@@ -18,19 +28,22 @@ export function buildVisibleRouteList<T extends RouteListVisibilityItem>(
): T[] {
const exactModelNames = new Set(
routes
.filter((route) => isExactModelPattern(route.modelPattern))
.filter((route) => !isExplicitGroupRoute(route) && isExactModelPattern(route.modelPattern))
.map((route) => (route.modelPattern || '').trim())
.filter(Boolean),
);
const coveringGroups = routes.filter((route) => (
route.enabled
&& !isExactModelPattern(route.modelPattern)
&& hasCustomDisplayName(route)
&& (
(isExplicitGroupRoute(route) && ((route.displayName || '').trim().length > 0) && (route.sourceRouteIds || []).length > 0)
|| (!isExplicitGroupRoute(route) && !isExactModelPattern(route.modelPattern) && hasCustomDisplayName(route))
)
));
if (coveringGroups.length === 0) return routes;
return routes.filter((route) => {
if (isExplicitGroupRoute(route)) return true;
if (!isExactModelPattern(route.modelPattern)) return true;
if (hasCustomDisplayName(route)) return true;
@@ -40,7 +53,10 @@ export function buildVisibleRouteList<T extends RouteListVisibilityItem>(
return !coveringGroups.some((groupRoute) => (
groupRoute.id !== route.id
&& !exactModelNames.has((groupRoute.displayName || '').trim())
&& matchesModelPattern(exactModel, groupRoute.modelPattern)
&& (
(isExplicitGroupRoute(groupRoute) && (groupRoute.sourceRouteIds || []).includes(route.id))
|| (!isExplicitGroupRoute(groupRoute) && matchesModelPattern(exactModel, groupRoute.modelPattern))
)
));
});
}
@@ -32,6 +32,7 @@ export type RouteCandidateView = {
export type RouteModelPatternLike = {
id: number;
modelPattern: string;
routeMode?: string | null;
};
const EMPTY_ROUTE_CANDIDATE_VIEW: RouteCandidateView = {
@@ -48,6 +49,10 @@ export function buildRouteModelCandidatesIndex(
const index: Record<number, RouteCandidateView> = {};
for (const route of routes || []) {
if (route.routeMode === 'explicit_group') {
index[route.id] = EMPTY_ROUTE_CANDIDATE_VIEW;
continue;
}
const modelPattern = (route.modelPattern || '').trim();
if (!modelPattern) {
index[route.id] = EMPTY_ROUTE_CANDIDATE_VIEW;
+499 -50
View File
@@ -1,24 +1,76 @@
import { useMemo } from 'react';
import { BrandGlyph } from '../../components/BrandIcon.js';
import { useEffect, useMemo, useState, type Dispatch, type SetStateAction } from 'react';
import CenteredModal from '../../components/CenteredModal.js';
import ModernSelect from '../../components/ModernSelect.js';
import { useAnimatedVisibility } from '../../components/useAnimatedVisibility.js';
import { tr } from '../../i18n.js';
import type { RouteIconOption } from './types.js';
import { getModelPatternError, matchesModelPattern, normalizeRouteDisplayIconValue } from './utils.js';
import type { RouteIconOption, RouteMode, RouteSummaryRow } from './types.js';
import {
ROUTE_ICON_NONE_VALUE,
getModelPatternError,
isExactModelPattern,
isRouteIconNoneValue,
matchesModelPattern,
normalizeRouteDisplayIconValue,
normalizeRouteMode,
} from './utils.js';
type RouteEditorForm = {
routeMode: RouteMode;
displayName: string;
displayIcon: string;
modelPattern: string;
sourceRouteIds: number[];
advancedOpen: boolean;
};
type ManualRoutePanelProps = {
show: boolean;
editingRouteId: number | null;
form: { modelPattern: string; displayName: string; displayIcon: string };
setForm: (updater: (f: { modelPattern: string; displayName: string; displayIcon: string }) => { modelPattern: string; displayName: string; displayIcon: string }) => void;
form: RouteEditorForm;
setForm: Dispatch<SetStateAction<RouteEditorForm>>;
saving: boolean;
canSave: boolean;
routeIconSelectOptions: RouteIconOption[];
previewModelSamples: string[];
exactSourceRouteOptions: RouteSummaryRow[];
onSave: () => void;
onCancel: () => void;
};
function renderRouteOptionLabel(route: RouteSummaryRow): string {
const displayName = (route.displayName || '').trim();
return displayName || route.modelPattern;
}
function toggleSourceRouteId(sourceRouteIds: number[], routeId: number): number[] {
if (sourceRouteIds.includes(routeId)) {
return sourceRouteIds.filter((id) => id !== routeId);
}
return [...sourceRouteIds, routeId].sort((a, b) => a - b);
}
function SearchField({
value,
onChange,
placeholder,
}: {
value: string;
onChange: (nextValue: string) => void;
placeholder: string;
}) {
return (
<div className="toolbar-search" style={{ width: '100%' }}>
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input
value={value}
onChange={(event) => onChange(event.target.value)}
placeholder={placeholder}
/>
</div>
);
}
export default function ManualRoutePanel({
show,
editingRouteId,
@@ -28,10 +80,24 @@ export default function ManualRoutePanel({
canSave,
routeIconSelectOptions,
previewModelSamples,
exactSourceRouteOptions,
onSave,
onCancel,
}: ManualRoutePanelProps) {
const presence = useAnimatedVisibility(show, 220);
const [showSourcePicker, setShowSourcePicker] = useState(false);
const [sourceSearch, setSourceSearch] = useState('');
const [sourcePickerSelection, setSourcePickerSelection] = useState<number[]>([]);
useEffect(() => {
if (!show) {
setShowSourcePicker(false);
setSourceSearch('');
setSourcePickerSelection([]);
}
}, [show]);
const routeMode = normalizeRouteMode(form.routeMode);
const editingLegacyPatternGroup = editingRouteId !== null && routeMode === 'pattern';
const modelPatternError = useMemo(
() => getModelPatternError(form.modelPattern),
@@ -53,21 +119,174 @@ export default function ManualRoutePanel({
return previewModelSamples.filter((modelName) => matchesModelPattern(modelName, normalizedPattern));
}, [form.modelPattern, modelPatternError, previewModelSamples]);
if (!presence.shouldRender) return null;
const filteredSourceRoutes = useMemo(() => {
const normalizedSearch = sourceSearch.trim().toLowerCase();
if (!normalizedSearch) return exactSourceRouteOptions;
return exactSourceRouteOptions.filter((route) => {
const label = renderRouteOptionLabel(route).toLowerCase();
return label.includes(normalizedSearch) || route.modelPattern.toLowerCase().includes(normalizedSearch);
});
}, [exactSourceRouteOptions, sourceSearch]);
const selectedSourceRoutes = useMemo(() => {
const routeById = new Map(exactSourceRouteOptions.map((route) => [route.id, route]));
return form.sourceRouteIds
.map((routeId) => routeById.get(routeId))
.filter((route): route is RouteSummaryRow => !!route);
}, [exactSourceRouteOptions, form.sourceRouteIds]);
const autoBrandIconEnabled = !isRouteIconNoneValue(form.displayIcon);
const hasExplicitIconValue = !!normalizeRouteDisplayIconValue(form.displayIcon);
const openSourcePicker = () => {
setSourcePickerSelection([...form.sourceRouteIds]);
setSourceSearch('');
setShowSourcePicker(true);
};
const closeSourcePicker = () => {
setShowSourcePicker(false);
setSourceSearch('');
};
const confirmSourcePicker = () => {
setForm((current) => ({
...current,
sourceRouteIds: [...sourcePickerSelection].sort((a, b) => a - b),
}));
setShowSourcePicker(false);
setSourceSearch('');
};
const footer = (
<>
<button
type="button"
onClick={onCancel}
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)' }}
>
{editingRouteId ? tr('取消编辑') : tr('取消')}
</button>
<button
type="button"
onClick={onSave}
disabled={!canSave}
className="btn btn-success"
>
{saving ? (
<>
<span
className="spinner spinner-sm"
style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }}
/>{' '}
{tr('保存中...')}
</>
) : (
tr(editingRouteId ? '保存群组' : '创建群组')
)}
</button>
</>
);
const sourcePickerFooter = (
<>
<button
type="button"
onClick={closeSourcePicker}
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)' }}
>
{tr('取消')}
</button>
<button
type="button"
onClick={() => setSourcePickerSelection([])}
className="btn btn-ghost"
disabled={sourcePickerSelection.length === 0}
>
{tr('清空')}
</button>
<button
type="button"
onClick={confirmSourcePicker}
className="btn btn-primary"
>
{`确认选择 (${sourcePickerSelection.length})`}
</button>
</>
);
return (
<div className={`card panel-presence ${presence.isVisible ? '' : 'is-closing'}`.trim()} style={{ padding: 20, marginBottom: 16 }}>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 10 }}>
{editingRouteId
? tr('编辑群组路由名称、图标和模型匹配规则;若修改正则,将按当前可用模型重新匹配自动通道。')
: tr('用于创建群组路由(聚合多个上游模型为一个下游模型名,即模型重定向);自动路由仍会保持开启。')}
<>
<CenteredModal
open={show}
onClose={onCancel}
title={editingRouteId ? tr('编辑群组') : tr('新建群组')}
footer={footer}
maxWidth={860}
closeOnEscape
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{editingLegacyPatternGroup ? (
<div
style={{
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-md)',
padding: '12px 14px',
background: 'var(--color-bg-card)',
}}
>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text-primary)', marginBottom: 6 }}>
{tr('高级规则群组')}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 180px', gap: 10 }}>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
{tr('该群组使用高级模型匹配规则创建,普通模式不可编辑;修改后会按当前可用模型重新匹配自动通道。')}
</div>
</div>
) : routeMode === 'explicit_group' ? (
<div
style={{
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-md)',
padding: '12px 14px',
background: 'var(--color-bg-card)',
}}
>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text-primary)', marginBottom: 6 }}>
{tr('模型重定向')}
</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
{tr('将多个现有模型合并为一个对外模型名,实现模型重定向。')}
</div>
</div>
) : (
<div
style={{
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-md)',
padding: '12px 14px',
background: 'var(--color-bg-card)',
}}
>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text-primary)', marginBottom: 6 }}>
{tr('高级规则群组')}
</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
{tr('使用模型匹配规则创建群组,适合需要 regex / glob 的高级场景。')}
</div>
</div>
)}
{routeMode === 'explicit_group' ? (
<>
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(0, 1fr)', gap: 12 }}>
<label style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<span style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}>{tr('对外模型名')}</span>
<input
placeholder={tr('群组显示名(可选,例如 claude-opus-4-6)')}
placeholder={tr('对外模型名(例如 claude-opus-4-6)')}
value={form.displayName}
onChange={(e) => setForm((f) => ({ ...f, displayName: e.target.value }))}
onChange={(event) => setForm((current) => ({ ...current, displayName: event.target.value }))}
style={{
width: '100%',
padding: '10px 14px',
@@ -79,20 +298,188 @@ export default function ManualRoutePanel({
color: 'var(--color-text-primary)',
}}
/>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
</label>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
<div>
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)', marginBottom: 4 }}>
{tr('来源模型')}
</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
{tr('选择一个或多个现有精确模型路由作为来源。')}
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
{!editingRouteId && (
<button
type="button"
className="btn btn-link"
onClick={() => setForm((current) => ({ ...current, routeMode: 'pattern', advancedOpen: true }))}
>
{tr('改用高级规则')}
</button>
)}
<button
type="button"
className="btn btn-ghost"
onClick={openSourcePicker}
>
{tr('选择来源模型')}
</button>
</div>
</div>
<div
style={{
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-md)',
padding: '12px 14px',
background: 'var(--color-bg-card)',
display: 'flex',
flexDirection: 'column',
gap: 12,
}}
>
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}>
{selectedSourceRoutes.length > 0
? `已选择 ${selectedSourceRoutes.length} 个来源模型`
: tr('尚未选择来源模型。')}
</div>
{selectedSourceRoutes.length > 0 ? (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{selectedSourceRoutes.slice(0, 8).map((route) => (
<button
key={`selected-${route.id}`}
type="button"
className="badge badge-info"
style={{ fontSize: 11, cursor: 'pointer' }}
onClick={() => setForm((current) => ({
...current,
sourceRouteIds: toggleSourceRouteId(current.sourceRouteIds, route.id),
}))}
>
{renderRouteOptionLabel(route)} ×
</button>
))}
{selectedSourceRoutes.length > 8 && (
<span className="badge badge-muted" style={{ fontSize: 11 }}>
{`+${selectedSourceRoutes.length - 8}`}
</span>
)}
</div>
) : (
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
{tr('点击“选择来源模型”打开大列表,搜索后快速勾选。')}
</div>
)}
</div>
</div>
<div
style={{
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-md)',
padding: '12px 14px',
background: 'var(--color-bg-card)',
display: 'flex',
flexDirection: 'column',
gap: 12,
}}
>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text-primary)' }}>
{tr('高级配置')}
</div>
<label style={{ display: 'flex', alignItems: 'flex-start', gap: 10, cursor: 'pointer' }}>
<input
type="checkbox"
checked={autoBrandIconEnabled}
onChange={(event) => {
const checked = !!event.target.checked;
setForm((current) => ({
...current,
displayIcon: checked
? (isRouteIconNoneValue(current.displayIcon) ? '' : current.displayIcon)
: ROUTE_ICON_NONE_VALUE,
}));
}}
style={{ marginTop: 2, width: 16, height: 16, accentColor: 'var(--color-primary)' }}
/>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<span style={{ fontSize: 13, color: 'var(--color-text-primary)' }}>{tr('自动品牌图标')}</span>
<span style={{ fontSize: 12, color: 'var(--color-text-muted)', lineHeight: 1.5 }}>
{autoBrandIconEnabled
? tr('开启后会优先保留现有图标策略;未设置固定图标时按模型自动识别品牌。')
: tr('关闭后该群组不显示品牌图标,只保留文字标题。')}
</span>
{hasExplicitIconValue && autoBrandIconEnabled && (
<span style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
{tr('当前群组如果已设置固定品牌图标,保持开启不会移除它。')}
</span>
)}
</div>
</label>
</div>
</>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{!editingLegacyPatternGroup && (
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<button
type="button"
className="btn btn-link"
onClick={() => setForm((current) => ({
...current,
routeMode: 'explicit_group',
advancedOpen: false,
}))}
>
{tr('返回简单模式')}
</button>
</div>
)}
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(0, 1fr) 220px', gap: 12 }}>
<label style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<span style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}>{tr('群组显示名')}</span>
<input
placeholder={tr('群组显示名(可选,例如 claude-opus-4-6)')}
value={form.displayName}
onChange={(event) => setForm((current) => ({ ...current, displayName: event.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)',
}}
/>
</label>
<label style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<span style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}>{tr('群组图标')}</span>
<ModernSelect
value={routeIconSelectValue}
onChange={(nextValue) => setForm((f) => ({ ...f, displayIcon: nextValue }))}
onChange={(nextValue) => setForm((current) => ({ ...current, displayIcon: nextValue }))}
options={routeIconSelectOptions}
placeholder={tr('图标(可选,选择品牌图标)')}
emptyLabel={tr('暂无可选品牌图标')}
/>
</label>
</div>
</div>
<label style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<span style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}>{tr('模型匹配')}</span>
<input
placeholder={tr('模型匹配(如 gpt-4o、claude-*、re:^claude-.*$)')}
value={form.modelPattern}
onChange={(e) => setForm((f) => ({ ...f, modelPattern: e.target.value }))}
onChange={(event) => setForm((current) => ({ ...current, modelPattern: event.target.value }))}
style={{
width: '100%',
padding: '10px 14px',
@@ -105,14 +492,20 @@ export default function ManualRoutePanel({
fontFamily: 'var(--font-mono)',
}}
/>
</label>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: -4 }}>
{tr('正则请使用 re: 前缀;例如 re:^claude-(opus|sonnet)-4-6$')}
{isExactModelPattern(form.modelPattern)
? tr('当前为精确模型匹配。')
: tr('正则请使用 re: 前缀;例如 re:^claude-(opus|sonnet)-4-6$')}
</div>
{modelPatternError && (
<div style={{ fontSize: 12, color: 'var(--color-danger)', marginTop: -4 }}>
{modelPatternError}
</div>
)}
{form.modelPattern.trim() && !modelPatternError && (
<div
style={{
@@ -160,37 +553,93 @@ export default function ManualRoutePanel({
)}
</div>
)}
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<button
onClick={onSave}
disabled={!canSave}
className="btn btn-success"
style={{ alignSelf: 'flex-start' }}
>
{saving ? (
<>
<span
className="spinner spinner-sm"
style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }}
/>{' '}
{tr('保存中...')}
</>
) : (
tr(editingRouteId ? '保存群组' : '创建群组')
</div>
)}
</button>
{editingRouteId ? (
<button
onClick={onCancel}
className="btn btn-ghost"
style={{ alignSelf: 'flex-start', border: '1px solid var(--color-border)' }}
</div>
</CenteredModal>
<CenteredModal
open={show && showSourcePicker}
onClose={closeSourcePicker}
title={tr('选择来源模型')}
footer={sourcePickerFooter}
maxWidth={980}
closeOnEscape
>
{tr('取消编辑')}
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}>
{`已选择 ${sourcePickerSelection.length} 个来源模型`}
</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
{`候选 ${filteredSourceRoutes.length} / ${exactSourceRouteOptions.length}`}
</div>
</div>
</div>
<SearchField
value={sourceSearch}
onChange={setSourceSearch}
placeholder={tr('搜索来源模型')}
/>
<div style={{ maxHeight: 440, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 6 }}>
{filteredSourceRoutes.length === 0 ? (
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', padding: '12px 0', textAlign: 'center' }}>
{exactSourceRouteOptions.length === 0
? tr('当前没有可选的精确模型路由。')
: tr('没有匹配的来源模型。')}
</div>
) : (
filteredSourceRoutes.map((route) => {
const selected = sourcePickerSelection.includes(route.id);
const label = renderRouteOptionLabel(route);
return (
<button
key={route.id}
type="button"
onClick={() => setSourcePickerSelection((current) => toggleSourceRouteId(current, route.id))}
className="btn btn-ghost"
style={{
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'space-between',
gap: 12,
padding: '12px 14px',
border: `1px solid ${selected ? 'var(--color-primary)' : 'var(--color-border)'}`,
background: selected
? 'color-mix(in srgb, var(--color-primary) 8%, var(--color-bg-card))'
: 'var(--color-bg-card)',
boxShadow: selected
? '0 0 0 1px color-mix(in srgb, var(--color-primary) 20%, transparent)'
: 'none',
}}
>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 10, minWidth: 0, flex: 1 }}>
<input
type="checkbox"
checked={selected}
readOnly
style={{ marginTop: 2, cursor: 'pointer', pointerEvents: 'none' }}
/>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, minWidth: 0 }}>
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text-primary)' }}>{label}</span>
{label !== route.modelPattern && (
<code style={{ fontSize: 11, color: 'var(--color-text-muted)' }}>{route.modelPattern}</code>
)}
</div>
</div>
<span className={selected ? 'badge badge-info' : 'badge badge-muted'} style={{ fontSize: 10, flexShrink: 0 }}>
{selected ? tr('已选中') : tr('可选择')}
</span>
</button>
) : null}
</div>
);
})
)}
</div>
</div>
</CenteredModal>
</>
);
}
+52 -13
View File
@@ -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 = [
@@ -173,11 +176,11 @@ function RouteCardInner({
<BrandGlyph icon={routeIcon.value} alt={title} size={18} fallbackText={title} />
) : routeIcon.kind === 'text' ? (
<span style={{ fontSize: 14, lineHeight: 1 }}>{routeIcon.value}</span>
) : brand ? (
) : routeIcon.kind === 'auto' && brand ? (
<BrandGlyph brand={brand} alt={title} size={18} fallbackText={title} />
) : (
) : routeIcon.kind === 'auto' ? (
<InlineBrandIcon model={route.modelPattern} size={18} />
)}
) : null}
</span>
<code style={{ fontWeight: 600, fontSize: 13, color: 'var(--color-text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', minWidth: 0, flex: 1 }}>
@@ -242,11 +245,11 @@ function RouteCardInner({
<span style={{ width: 20, height: 20, display: 'inline-flex', alignItems: 'center', justifyContent: 'center', borderRadius: 8, background: 'var(--color-bg-card)', fontSize: 14, lineHeight: 1 }}>
{routeIcon.value}
</span>
) : brand ? (
) : routeIcon.kind === 'auto' && brand ? (
<BrandGlyph brand={brand} alt={title} size={20} fallbackText={title} />
) : (
) : routeIcon.kind === 'auto' ? (
<InlineBrandIcon model={route.modelPattern} size={20} />
)}
) : null}
{title}
</code>
{route.displayName && route.displayName.trim() !== route.modelPattern ? (
@@ -280,7 +283,7 @@ function RouteCardInner({
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
{!readOnlyRoute && !exactRoute && (
{!readOnlyRoute && (explicitGroupRoute || !exactRoute) && (
<button onClick={() => onEdit(route)} className="btn btn-link">{tr('编辑群组')}</button>
)}
{!readOnlyRoute && <button onClick={() => onDelete(route.id)} className="btn btn-link btn-link-danger">{tr('删除路由')}</button>}
@@ -301,11 +304,15 @@ function RouteCardInner({
</div>
</div>
{!exactRoute && (
{explicitGroupRoute ? (
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 10 }}>
{tr('该群组会将多个来源模型聚合为一个对外模型名;通道信息继承自来源模型,当前仅支持查看,不支持直接维护。')}
</div>
) : !exactRoute ? (
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 10 }}>
{tr('通配符路由按请求实时决策;概率解释在当前路由内统一估算。')}
</div>
)}
) : null}
{!readOnlyRoute && (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginBottom: 12, flexWrap: 'wrap' }}>
@@ -328,7 +335,7 @@ function RouteCardInner({
{/* Missing token hints + Add channel button */}
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 8, marginBottom: 10, flexWrap: 'wrap' }}>
{(missingTokenSiteItems.length > 0 || missingTokenGroupItems.length > 0) ? (
{!channelManagementDisabled && (missingTokenSiteItems.length > 0 || missingTokenGroupItems.length > 0) ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, flex: 1 }}>
{missingTokenSiteItems.length > 0 && (
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
@@ -366,7 +373,7 @@ function RouteCardInner({
)}
</div>
) : <div />}
{!readOnlyRoute && (
{!readOnlyRoute && !channelManagementDisabled && (
<button
onClick={() => onAddChannel(route.id)}
className="btn btn-ghost"
@@ -437,6 +444,37 @@ function RouteCardInner({
) : null}
<AnimatedCollapseSection open={isGroupExpanded}>
{explicitGroupRoute ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{group.channels.map((channel) => (
<div
key={channel.id}
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
flexWrap: 'wrap',
padding: '8px 12px',
borderLeft: '2px solid var(--color-primary)',
borderRadius: '0 var(--radius-sm) var(--radius-sm) 0',
background: 'rgba(79,70,229,0.02)',
}}
>
<span style={{ fontWeight: 600, color: 'var(--color-text-primary)' }}>
{channel.account?.username || `account-${channel.accountId}`}
</span>
<span className="badge badge-muted" style={{ fontSize: 10 }}>
{channel.site?.name || 'unknown'}
</span>
{channel.sourceModel ? (
<span className="badge badge-info" style={{ fontSize: 10 }}>
{channel.sourceModel}
</span>
) : null}
</div>
))}
</div>
) : (
<SortableContext items={group.channels.map((c) => c.id)} strategy={verticalListSortingStrategy}>
{group.channels.map((channel) => {
const tokenOptions = candidateView.tokenOptionsByAccountId[channel.accountId] || [];
@@ -459,6 +497,7 @@ function RouteCardInner({
);
})}
</SortableContext>
)}
</AnimatedCollapseSection>
{!isGroupExpanded && (
<div style={{ fontSize: 11, color: 'var(--color-text-muted)', paddingLeft: 6 }}>
@@ -209,11 +209,11 @@ export default function RouteFilterBar(props: RouteFilterBarProps) {
<BrandGlyph icon={groupRoute.icon.value} alt={groupRoute.title} size={12} fallbackText={groupRoute.title} />
) : groupRoute.icon.kind === 'text' ? (
<span style={{ fontSize: 10, lineHeight: 1 }}>{groupRoute.icon.value}</span>
) : groupRoute.brand ? (
) : groupRoute.icon.kind === 'auto' && groupRoute.brand ? (
<BrandGlyph brand={groupRoute.brand} alt={groupRoute.title} size={12} fallbackText={groupRoute.title} />
) : (
) : groupRoute.icon.kind === 'auto' ? (
<InlineBrandIcon model={groupRoute.modelPattern} size={12} />
)
) : undefined
}
onClick={() => setActiveGroupFilter(activeGroupFilter === groupRoute.id ? null : groupRoute.id)}
/>
+6 -1
View File
@@ -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;
@@ -156,7 +161,7 @@ export type SortableChannelRowProps = {
export type GroupRouteItem = {
id: number;
title: string;
icon: { kind: 'none' } | { kind: 'text'; value: string } | { kind: 'brand'; value: string };
icon: { kind: 'auto' } | { kind: 'none' } | { kind: 'text'; value: string } | { kind: 'brand'; value: string };
brand: BrandInfo | null;
modelPattern: string;
channelCount: number;
+22
View File
@@ -0,0 +1,22 @@
import { describe, expect, it, vi } from 'vitest';
vi.mock('../../components/BrandIcon.js', () => ({
getBrand: () => null,
normalizeBrandIconKey: (icon: string) => icon.trim().toLowerCase(),
}));
import {
ROUTE_ICON_NONE_VALUE,
normalizeRouteDisplayIconValue,
resolveRouteIcon,
} from './utils.js';
describe('token route icon helpers', () => {
it('preserves the explicit no-icon sentinel during normalization', () => {
expect(normalizeRouteDisplayIconValue(ROUTE_ICON_NONE_VALUE)).toBe(ROUTE_ICON_NONE_VALUE);
});
it('treats the explicit no-icon sentinel as no icon', () => {
expect(resolveRouteIcon({ displayIcon: ROUTE_ICON_NONE_VALUE })).toEqual({ kind: 'none' });
});
});
+22 -3
View File
@@ -1,10 +1,11 @@
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;
export const ROUTE_BRAND_ICON_PREFIX = 'brand:';
export const ROUTE_ICON_NONE_VALUE = '__route_icon_none__';
export const ENDPOINT_TYPE_ICON_MODEL_MAP: Record<string, string> = {
openai: 'chatgpt',
@@ -48,6 +49,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<RouteRow | RouteSummaryRow, 'routeMode'>): boolean {
return normalizeRouteMode(route.routeMode) === 'explicit_group';
}
export function isRouteExactModel(route: Pick<RouteRow | RouteSummaryRow, 'modelPattern' | 'routeMode'>): 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();
@@ -160,8 +173,13 @@ export function parseBrandIconValue(raw: string): string | null {
return normalizeBrandIconKey(icon);
}
export function isRouteIconNoneValue(raw: string | null | undefined): boolean {
return (raw || '').trim() === ROUTE_ICON_NONE_VALUE;
}
export function normalizeRouteDisplayIconValue(raw: string | null | undefined): string {
const normalized = (raw || '').trim();
if (isRouteIconNoneValue(normalized)) return ROUTE_ICON_NONE_VALUE;
const brandIcon = parseBrandIconValue(normalized);
if (brandIcon) return toBrandIconValue(brandIcon);
return normalized;
@@ -200,9 +218,10 @@ export function siteAvatarLetters(siteName: string): string {
return compact.slice(0, 2).toUpperCase();
}
export function resolveRouteIcon(route: Pick<RouteRow | RouteSummaryRow, 'displayIcon'>): { kind: 'none' } | { kind: 'text'; value: string } | { kind: 'brand'; value: string } {
export function resolveRouteIcon(route: Pick<RouteRow | RouteSummaryRow, 'displayIcon'>): { kind: 'auto' } | { kind: 'none' } | { kind: 'text'; value: string } | { kind: 'brand'; value: string } {
const icon = (route.displayIcon || '').trim();
if (!icon) return { kind: 'none' };
if (isRouteIconNoneValue(icon)) return { kind: 'none' };
if (!icon) return { kind: 'auto' };
const brandIcon = parseBrandIconValue(icon);
if (brandIcon) return { kind: 'brand', value: brandIcon };
return { kind: 'text', value: icon };
+225 -88
View File
@@ -3,6 +3,7 @@ import { act, create, type ReactTestInstance } from 'react-test-renderer';
import { MemoryRouter } from 'react-router-dom';
import { ToastProvider } from '../components/Toast.js';
import TokenRoutes from './TokenRoutes.js';
import { ROUTE_ICON_NONE_VALUE } from './token-routes/utils.js';
const { apiMock, getBrandMock } = vi.hoisted(() => ({
apiMock: {
@@ -55,6 +56,15 @@ function findInputByPlaceholder(root: ReactTestInstance, placeholderText: string
));
}
function findCheckboxByLabelText(root: ReactTestInstance, text: string): ReactTestInstance {
return root.find((node) => (
node.type === 'input'
&& node.props.type === 'checkbox'
&& !!node.parent
&& collectText(node.parent).includes(text)
));
}
async function flushMicrotasks() {
await act(async () => {
await Promise.resolve();
@@ -855,16 +865,23 @@ describe('TokenRoutes grouped source models', () => {
}
});
it('enters edit mode and seeds the group form with the current route values', async () => {
it('uses a dedicated source picker modal 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<typeof create> | null = null;
try {
@@ -879,109 +896,129 @@ 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<typeof create> | null = null;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/routes']}>
<ToastProvider>
<TokenRoutes />
</ToastProvider>
</MemoryRouter>,
);
});
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();
expect(root.root.findAll((node) => typeof node.props?.placeholder === 'string' && node.props.placeholder.includes('搜索来源模型'))).toHaveLength(0);
await act(async () => {
findButtonByText(root.root, '选择来源模型').props.onClick();
});
await flushMicrotasks();
expect(findInputByPlaceholder(root.root, '搜索来源模型')).toBeTruthy();
await act(async () => {
findButtonByText(root.root, 'claude-opus-4-5').props.onClick();
findButtonByText(root.root, 'claude-sonnet-4-5').props.onClick();
});
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, '确认选择').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('saves explicit groups with auto brand icon disabled as a no-icon sentinel', async () => {
apiMock.getRoutesSummary.mockResolvedValue([
{
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,
},
]);
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/routes']}>
<ToastProvider>
<TokenRoutes />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
await act(async () => {
findButtonByText(root.root, '新建群组').props.onClick();
});
await flushMicrotasks();
await act(async () => {
findInputByPlaceholder(root.root, '对外模型名').props.onChange({ target: { value: 'claude-opus-4-6' } });
});
await flushMicrotasks();
await act(async () => {
findButtonByText(root.root, '选择来源模型').props.onClick();
});
await flushMicrotasks();
await act(async () => {
findButtonByText(root.root, 'claude-opus-4-5').props.onClick();
});
await flushMicrotasks();
await act(async () => {
findButtonByText(root.root, '确认选择').props.onClick();
});
await flushMicrotasks();
await act(async () => {
findCheckboxByLabelText(root.root, '自动品牌图标').props.onChange({ target: { checked: false } });
});
await flushMicrotasks();
await act(async () => {
findButtonByText(root.root, '创建群组').props.onClick();
});
await flushMicrotasks();
expect(apiMock.addRoute).toHaveBeenCalledWith(expect.objectContaining({
routeMode: 'explicit_group',
displayName: 'claude-opus-4-6',
sourceRouteIds: [11],
displayIcon: ROUTE_ICON_NONE_VALUE,
}));
} finally {
root?.unmount();
}
});
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 +1052,117 @@ describe('TokenRoutes grouped source models', () => {
});
await flushMicrotasks();
await act(async () => {
findInputByPlaceholder(root.root, '模型匹配').props.onChange({ target: { value: 're:^gemini-.*$' } });
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<typeof create> | null = null;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/routes']}>
<ToastProvider>
<TokenRoutes />
</ToastProvider>
</MemoryRouter>,
);
});
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, '选择来源模型').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();
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 {