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:
@@ -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`);
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+430
-186
@@ -22,6 +22,138 @@ function isExactModelPattern(modelPattern: string): boolean {
|
||||
if (normalized.toLowerCase().startsWith('re:')) return false;
|
||||
return !/[\*\?]/.test(normalized);
|
||||
}
|
||||
|
||||
type RouteMode = 'pattern' | 'explicit_group';
|
||||
type RouteRow = typeof schema.tokenRoutes.$inferSelect & {
|
||||
routeMode: RouteMode;
|
||||
sourceRouteIds: number[];
|
||||
};
|
||||
|
||||
function normalizeRouteMode(routeMode: unknown): RouteMode {
|
||||
return routeMode === 'explicit_group' ? 'explicit_group' : 'pattern';
|
||||
}
|
||||
|
||||
function isExplicitGroupRoute(route: Pick<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)
|
||||
@@ -390,25 +522,54 @@ function parseBatchRouteWideDecisionRouteIds(
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchChannelsForRoutes(routeIds: number[]): Promise<Map<number, any[]>> {
|
||||
if (routeIds.length === 0) return new Map();
|
||||
|
||||
const channelRows = await db.select().from(schema.routeChannels)
|
||||
.innerJoin(schema.accounts, eq(schema.routeChannels.accountId, schema.accounts.id))
|
||||
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
|
||||
.leftJoin(schema.accountTokens, eq(schema.routeChannels.tokenId, schema.accountTokens.id))
|
||||
.where(inArray(schema.routeChannels.routeId, routeIds))
|
||||
.all();
|
||||
|
||||
const channelsByRoute = new Map<number, any[]>();
|
||||
|
||||
for (const row of channelRows) {
|
||||
const routeId = row.route_channels.routeId;
|
||||
if (!channelsByRoute.has(routeId)) channelsByRoute.set(routeId, []);
|
||||
channelsByRoute.get(routeId)!.push({
|
||||
...row.route_channels,
|
||||
account: row.accounts,
|
||||
site: row.sites,
|
||||
type RouteChannelSummary = {
|
||||
channelCount: number;
|
||||
enabledChannelCount: number;
|
||||
siteNames: Set<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, actualRouteIds))
|
||||
.all();
|
||||
|
||||
const channelsByActualRouteId = new Map<number, any[]>();
|
||||
|
||||
for (const row of channelRows) {
|
||||
const routeId = row.route_channels.routeId;
|
||||
if (!channelsByActualRouteId.has(routeId)) channelsByActualRouteId.set(routeId, []);
|
||||
channelsByActualRouteId.get(routeId)!.push({
|
||||
...row.route_channels,
|
||||
account: row.accounts,
|
||||
site: row.sites,
|
||||
token: row.account_tokens
|
||||
? {
|
||||
id: row.account_tokens.id,
|
||||
@@ -417,63 +578,83 @@ async function fetchChannelsForRoutes(routeIds: number[]): Promise<Map<number, a
|
||||
enabled: row.account_tokens.enabled,
|
||||
isDefault: row.account_tokens.isDefault,
|
||||
}
|
||||
: null,
|
||||
});
|
||||
}
|
||||
|
||||
return channelsByRoute;
|
||||
}
|
||||
|
||||
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();
|
||||
: null,
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// Route summary (no channel details) for first-screen rendering
|
||||
app.get('/api/routes/summary', async () => {
|
||||
const routes = await db.select().from(schema.tokenRoutes).all();
|
||||
if (routes.length === 0) return [];
|
||||
|
||||
const routeIds = routes.map((route) => route.id);
|
||||
|
||||
// Aggregate channel counts and site names per route
|
||||
const channelRows = await db.select({
|
||||
routeId: schema.routeChannels.routeId,
|
||||
enabled: schema.routeChannels.enabled,
|
||||
siteName: schema.sites.name,
|
||||
}).from(schema.routeChannels)
|
||||
.innerJoin(schema.accounts, eq(schema.routeChannels.accountId, schema.accounts.id))
|
||||
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
|
||||
.where(inArray(schema.routeChannels.routeId, routeIds))
|
||||
.all();
|
||||
|
||||
const aggByRoute = new Map<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);
|
||||
}
|
||||
|
||||
return routes.map((route) => {
|
||||
const agg = aggByRoute.get(route.id);
|
||||
return {
|
||||
id: route.id,
|
||||
modelPattern: route.modelPattern,
|
||||
displayName: route.displayName ?? null,
|
||||
export async function tokensRoutes(app: FastifyInstance) {
|
||||
// List routes with basic info only (lightweight for selectors)
|
||||
app.get('/api/routes/lite', async () => {
|
||||
return (await listRoutesWithSources()).map((route) => ({
|
||||
id: route.id,
|
||||
modelPattern: route.modelPattern,
|
||||
displayName: route.displayName,
|
||||
displayIcon: route.displayIcon,
|
||||
routeMode: route.routeMode,
|
||||
sourceRouteIds: route.sourceRouteIds,
|
||||
routingStrategy: route.routingStrategy,
|
||||
enabled: route.enabled,
|
||||
}));
|
||||
});
|
||||
|
||||
// Route summary (no channel details) for first-screen rendering
|
||||
app.get('/api/routes/summary', async () => {
|
||||
const routes = await listRoutesWithSources();
|
||||
if (routes.length === 0) return [];
|
||||
const aggByRoute = await buildRouteChannelSummaryMap(routes);
|
||||
|
||||
return routes.map((route) => {
|
||||
const agg = aggByRoute.get(route.id);
|
||||
return {
|
||||
id: route.id,
|
||||
modelPattern: route.modelPattern,
|
||||
displayName: route.displayName ?? null,
|
||||
displayIcon: route.displayIcon ?? null,
|
||||
routeMode: route.routeMode,
|
||||
sourceRouteIds: route.sourceRouteIds,
|
||||
modelMapping: route.modelMapping ?? null,
|
||||
routingStrategy: route.routingStrategy ?? 'weighted',
|
||||
enabled: route.enabled,
|
||||
@@ -486,26 +667,29 @@ export async function tokensRoutes(app: FastifyInstance) {
|
||||
});
|
||||
});
|
||||
|
||||
// Get channels for a single route (on-demand loading)
|
||||
app.get<{ Params: { id: string } }>('/api/routes/:id/channels', async (request, reply) => {
|
||||
const routeId = parseInt(request.params.id, 10);
|
||||
const route = await db.select().from(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, routeId)).get();
|
||||
if (!route) {
|
||||
return reply.code(404).send({ success: false, message: '路由不存在' });
|
||||
}
|
||||
const channelsByRoute = await fetchChannelsForRoutes([routeId]);
|
||||
return channelsByRoute.get(routeId) || [];
|
||||
});
|
||||
// Get channels for a single route (on-demand loading)
|
||||
app.get<{ Params: { id: string } }>('/api/routes/:id/channels', async (request, reply) => {
|
||||
const routeId = parseInt(request.params.id, 10);
|
||||
const route = await getRouteWithSources(routeId);
|
||||
if (!route) {
|
||||
return reply.code(404).send({ success: false, message: '路由不存在' });
|
||||
}
|
||||
const channelsByRoute = await fetchChannelsForRouteRows([route]);
|
||||
return channelsByRoute.get(routeId) || [];
|
||||
});
|
||||
|
||||
// Batch add channels to a route
|
||||
app.post<{ Params: { id: string }; Body: { channels: Array<{ accountId: number; tokenId?: number; sourceModel?: string }> } }>('/api/routes/:id/channels/batch', async (request, reply) => {
|
||||
const routeId = parseInt(request.params.id, 10);
|
||||
const body = request.body;
|
||||
|
||||
const route = await db.select().from(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, routeId)).get();
|
||||
if (!route) {
|
||||
return reply.code(404).send({ success: false, message: '路由不存在' });
|
||||
}
|
||||
app.post<{ Params: { id: string }; Body: { channels: Array<{ accountId: number; tokenId?: number; sourceModel?: string }> } }>('/api/routes/:id/channels/batch', async (request, reply) => {
|
||||
const routeId = parseInt(request.params.id, 10);
|
||||
const body = request.body;
|
||||
|
||||
const route = await getRouteWithSources(routeId);
|
||||
if (!route) {
|
||||
return reply.code(404).send({ success: false, message: '路由不存在' });
|
||||
}
|
||||
if (isExplicitGroupRoute(route)) {
|
||||
return reply.code(400).send({ success: false, message: '显式群组不支持直接维护通道' });
|
||||
}
|
||||
|
||||
if (!body?.channels || !Array.isArray(body.channels) || body.channels.length === 0) {
|
||||
return reply.code(400).send({ success: false, message: 'channels 必须是非空数组' });
|
||||
@@ -566,24 +750,24 @@ export async function tokensRoutes(app: FastifyInstance) {
|
||||
}
|
||||
}
|
||||
|
||||
if (created > 0) {
|
||||
await clearRouteDecisionSnapshot(routeId);
|
||||
invalidateTokenRouterCache();
|
||||
}
|
||||
if (created > 0) {
|
||||
await clearRouteDecisionSnapshot(routeId);
|
||||
await clearDependentExplicitGroupSnapshotsBySourceRouteIds([routeId]);
|
||||
invalidateTokenRouterCache();
|
||||
}
|
||||
|
||||
return { success: true, created, skipped, errors };
|
||||
});
|
||||
|
||||
// List all routes
|
||||
app.get('/api/routes', async () => {
|
||||
const routes = await db.select().from(schema.tokenRoutes).all();
|
||||
if (routes.length === 0) return [];
|
||||
|
||||
const routeIds = routes.map((route) => route.id);
|
||||
const channelsByRoute = await fetchChannelsForRoutes(routeIds);
|
||||
|
||||
return routes.map((route) => ({
|
||||
...route,
|
||||
// List all routes
|
||||
app.get('/api/routes', async () => {
|
||||
const routes = await listRoutesWithSources();
|
||||
if (routes.length === 0) return [];
|
||||
|
||||
const channelsByRoute = await fetchChannelsForRouteRows(routes);
|
||||
|
||||
return routes.map((route) => ({
|
||||
...route,
|
||||
decisionSnapshot: parseRouteDecisionSnapshot(route.decisionSnapshot),
|
||||
decisionRefreshedAt: route.decisionRefreshedAt ?? null,
|
||||
channels: channelsByRoute.get(route.id) || [],
|
||||
@@ -689,87 +873,143 @@ export async function tokensRoutes(app: FastifyInstance) {
|
||||
return { success: true, decisions };
|
||||
});
|
||||
|
||||
// Create a route
|
||||
app.post<{ Body: { modelPattern: string; displayName?: string; displayIcon?: string; modelMapping?: string; routingStrategy?: string; enabled?: boolean } }>('/api/routes', async (request) => {
|
||||
// Create a route
|
||||
app.post<{ Body: { routeMode?: string; modelPattern?: string; displayName?: string; displayIcon?: string; modelMapping?: string; routingStrategy?: string; enabled?: boolean; sourceRouteIds?: number[] } }>('/api/routes', async (request, reply) => {
|
||||
const body = request.body;
|
||||
const routeMode = normalizeRouteMode(body.routeMode);
|
||||
const displayName = typeof body.displayName === 'string' ? body.displayName.trim() : '';
|
||||
const sourceRouteIds = normalizeSourceRouteIdsInput(body.sourceRouteIds);
|
||||
const modelPattern = routeMode === 'explicit_group'
|
||||
? displayName
|
||||
: (typeof body.modelPattern === 'string' ? body.modelPattern.trim() : '');
|
||||
|
||||
if (routeMode === 'explicit_group') {
|
||||
if (!displayName) {
|
||||
return reply.code(400).send({ success: false, message: '显式群组必须填写对外模型名' });
|
||||
}
|
||||
const validation = await validateExplicitGroupSourceRoutes(sourceRouteIds);
|
||||
if (!validation.ok) {
|
||||
return reply.code(400).send({ success: false, message: validation.message });
|
||||
}
|
||||
} else if (!modelPattern) {
|
||||
return reply.code(400).send({ success: false, message: '模型匹配不能为空' });
|
||||
}
|
||||
|
||||
const insertedRoute = await db.insert(schema.tokenRoutes).values({
|
||||
modelPattern: body.modelPattern,
|
||||
displayName: body.displayName,
|
||||
modelPattern,
|
||||
displayName: displayName || body.displayName,
|
||||
displayIcon: body.displayIcon,
|
||||
routeMode,
|
||||
modelMapping: body.modelMapping,
|
||||
routingStrategy: normalizeRouteRoutingStrategy(body.routingStrategy),
|
||||
enabled: body.enabled ?? true,
|
||||
}).run();
|
||||
const routeId = Number(insertedRoute.lastInsertRowid || 0);
|
||||
if (routeId <= 0) {
|
||||
return { success: false, message: '创建路由失败' };
|
||||
}
|
||||
const route = await db.select().from(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, routeId)).get();
|
||||
if (!route) {
|
||||
return { success: false, message: '创建路由失败' };
|
||||
}
|
||||
|
||||
await populateRouteChannelsByModelPattern(route.id, body.modelPattern);
|
||||
invalidateTokenRouterCache();
|
||||
return route;
|
||||
});
|
||||
|
||||
// Update a route
|
||||
app.put<{ Params: { id: string }; Body: any }>('/api/routes/:id', async (request, reply) => {
|
||||
const id = parseInt(request.params.id, 10);
|
||||
const body = request.body as Record<string, unknown>;
|
||||
const existingRoute = await db.select().from(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, id)).get();
|
||||
if (!existingRoute) {
|
||||
return reply.code(404).send({ success: false, message: '路由不存在' });
|
||||
}
|
||||
|
||||
const updates: Record<string, unknown> = {};
|
||||
let nextModelPattern = existingRoute.modelPattern;
|
||||
|
||||
if (body.displayName !== undefined) updates.displayName = body.displayName;
|
||||
if (body.displayIcon !== undefined) updates.displayIcon = body.displayIcon;
|
||||
if (body.modelPattern !== undefined) {
|
||||
const routeId = Number(insertedRoute.lastInsertRowid || 0);
|
||||
if (routeId <= 0) {
|
||||
return { success: false, message: '创建路由失败' };
|
||||
}
|
||||
const route = await getRouteWithSources(routeId);
|
||||
if (!route) {
|
||||
return { success: false, message: '创建路由失败' };
|
||||
}
|
||||
|
||||
if (routeMode === 'explicit_group') {
|
||||
await replaceRouteSourceRouteIds(route.id, sourceRouteIds);
|
||||
} else {
|
||||
await populateRouteChannelsByModelPattern(route.id, modelPattern);
|
||||
}
|
||||
invalidateTokenRouterCache();
|
||||
return await getRouteWithSources(routeId);
|
||||
});
|
||||
|
||||
// Update a route
|
||||
app.put<{ Params: { id: string }; Body: any }>('/api/routes/:id', async (request, reply) => {
|
||||
const id = parseInt(request.params.id, 10);
|
||||
const body = request.body as Record<string, unknown>;
|
||||
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) {
|
||||
nextDisplayName = String(body.displayName || '').trim();
|
||||
updates.displayName = nextDisplayName || null;
|
||||
}
|
||||
if (body.displayIcon !== undefined) updates.displayIcon = body.displayIcon;
|
||||
if (routeMode === 'explicit_group') {
|
||||
nextModelPattern = nextDisplayName;
|
||||
updates.modelPattern = nextModelPattern;
|
||||
if (body.sourceRouteIds !== undefined) {
|
||||
nextSourceRouteIds = normalizeSourceRouteIdsInput(body.sourceRouteIds);
|
||||
}
|
||||
if (!nextDisplayName) {
|
||||
return reply.code(400).send({ success: false, message: '显式群组必须填写对外模型名' });
|
||||
}
|
||||
const validation = await validateExplicitGroupSourceRoutes(nextSourceRouteIds, id);
|
||||
if (!validation.ok) {
|
||||
return reply.code(400).send({ success: false, message: validation.message });
|
||||
}
|
||||
} else if (body.modelPattern !== undefined) {
|
||||
nextModelPattern = String(body.modelPattern);
|
||||
updates.modelPattern = nextModelPattern;
|
||||
}
|
||||
if (body.modelMapping !== undefined) updates.modelMapping = body.modelMapping;
|
||||
if (body.routingStrategy !== undefined) updates.routingStrategy = normalizeRouteRoutingStrategy(body.routingStrategy);
|
||||
if (body.enabled !== undefined) updates.enabled = body.enabled;
|
||||
if (body.routeMode !== undefined) updates.routeMode = routeMode;
|
||||
updates.updatedAt = new Date().toISOString();
|
||||
|
||||
await db.update(schema.tokenRoutes).set(updates).where(eq(schema.tokenRoutes.id, id)).run();
|
||||
const modelPatternChanged = body.modelPattern !== undefined && nextModelPattern !== existingRoute.modelPattern;
|
||||
if (routeMode === 'explicit_group' && body.sourceRouteIds !== undefined) {
|
||||
await replaceRouteSourceRouteIds(id, nextSourceRouteIds);
|
||||
}
|
||||
const modelPatternChanged = nextModelPattern !== existingRoute.modelPattern;
|
||||
const routeBehaviorChanged = modelPatternChanged
|
||||
|| (routeMode === 'explicit_group' && body.sourceRouteIds !== undefined)
|
||||
|| body.modelMapping !== undefined
|
||||
|| body.routingStrategy !== undefined
|
||||
|| body.enabled !== undefined;
|
||||
if (modelPatternChanged) {
|
||||
await rebuildAutomaticRouteChannelsByModelPattern(id, nextModelPattern);
|
||||
}
|
||||
if (routeBehaviorChanged) {
|
||||
await clearRouteDecisionSnapshot(id);
|
||||
}
|
||||
invalidateTokenRouterCache();
|
||||
return await db.select().from(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, id)).get();
|
||||
});
|
||||
if (routeMode === 'pattern' && modelPatternChanged) {
|
||||
await rebuildAutomaticRouteChannelsByModelPattern(id, nextModelPattern);
|
||||
}
|
||||
if (routeBehaviorChanged) {
|
||||
await clearRouteDecisionSnapshot(id);
|
||||
await clearDependentExplicitGroupSnapshotsBySourceRouteIds([id]);
|
||||
}
|
||||
invalidateTokenRouterCache();
|
||||
return await getRouteWithSources(id);
|
||||
});
|
||||
|
||||
// Delete a route
|
||||
app.delete<{ Params: { id: string } }>('/api/routes/:id', async (request) => {
|
||||
const id = parseInt(request.params.id, 10);
|
||||
await db.delete(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, id)).run();
|
||||
invalidateTokenRouterCache();
|
||||
return { success: true };
|
||||
});
|
||||
app.delete<{ Params: { id: string } }>('/api/routes/:id', async (request) => {
|
||||
const id = parseInt(request.params.id, 10);
|
||||
await clearDependentExplicitGroupSnapshotsBySourceRouteIds([id]);
|
||||
await db.delete(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, id)).run();
|
||||
invalidateTokenRouterCache();
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
// Add a channel to a route
|
||||
app.post<{ Params: { id: string }; Body: { accountId: number; tokenId?: number; sourceModel?: string; priority?: number; weight?: number } }>('/api/routes/:id/channels', async (request, reply) => {
|
||||
const routeId = parseInt(request.params.id, 10);
|
||||
const body = request.body;
|
||||
|
||||
const route = await db.select().from(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, routeId)).get();
|
||||
if (!route) {
|
||||
return reply.code(404).send({ success: false, message: '路由不存在' });
|
||||
}
|
||||
app.post<{ Params: { id: string }; Body: { accountId: number; tokenId?: number; sourceModel?: string; priority?: number; weight?: number } }>('/api/routes/:id/channels', async (request, reply) => {
|
||||
const routeId = parseInt(request.params.id, 10);
|
||||
const body = request.body;
|
||||
|
||||
const route = await getRouteWithSources(routeId);
|
||||
if (!route) {
|
||||
return reply.code(404).send({ success: false, message: '路由不存在' });
|
||||
}
|
||||
if (isExplicitGroupRoute(route)) {
|
||||
return reply.code(400).send({ success: false, message: '显式群组不支持直接维护通道' });
|
||||
}
|
||||
|
||||
const sourceModel = typeof body.sourceModel === 'string'
|
||||
? body.sourceModel.trim()
|
||||
@@ -811,11 +1051,12 @@ export async function tokensRoutes(app: FastifyInstance) {
|
||||
const created = await db.select().from(schema.routeChannels).where(eq(schema.routeChannels.id, channelId)).get();
|
||||
if (!created) {
|
||||
return reply.code(500).send({ success: false, message: '创建通道失败' });
|
||||
}
|
||||
await clearRouteDecisionSnapshot(routeId);
|
||||
invalidateTokenRouterCache();
|
||||
return created;
|
||||
});
|
||||
}
|
||||
await clearRouteDecisionSnapshot(routeId);
|
||||
await clearDependentExplicitGroupSnapshotsBySourceRouteIds([routeId]);
|
||||
invalidateTokenRouterCache();
|
||||
return created;
|
||||
});
|
||||
|
||||
// Batch update channel priorities
|
||||
app.put<{ Body: { updates: Array<{ id: number; priority: number }> } }>('/api/channels/batch', async (request, reply) => {
|
||||
@@ -841,13 +1082,14 @@ export async function tokensRoutes(app: FastifyInstance) {
|
||||
}).where(eq(schema.routeChannels.id, update.id)).run();
|
||||
}
|
||||
|
||||
const updatedChannels = await db.select().from(schema.routeChannels)
|
||||
.where(inArray(schema.routeChannels.id, channelIds))
|
||||
.all();
|
||||
await clearRouteDecisionSnapshots(existingChannels.map((channel) => channel.routeId));
|
||||
invalidateTokenRouterCache();
|
||||
return { success: true, channels: updatedChannels };
|
||||
});
|
||||
const updatedChannels = await db.select().from(schema.routeChannels)
|
||||
.where(inArray(schema.routeChannels.id, channelIds))
|
||||
.all();
|
||||
await clearRouteDecisionSnapshots(existingChannels.map((channel) => channel.routeId));
|
||||
await clearDependentExplicitGroupSnapshotsBySourceRouteIds(existingChannels.map((channel) => channel.routeId));
|
||||
invalidateTokenRouterCache();
|
||||
return { success: true, channels: updatedChannels };
|
||||
});
|
||||
|
||||
// Update a channel
|
||||
app.put<{ Params: { channelId: string }; Body: any }>('/api/channels/:channelId', async (request, reply) => {
|
||||
@@ -889,23 +1131,25 @@ export async function tokensRoutes(app: FastifyInstance) {
|
||||
if (body[key] !== undefined) updates[key] = body[key];
|
||||
}
|
||||
|
||||
await db.update(schema.routeChannels).set(updates).where(eq(schema.routeChannels.id, channelId)).run();
|
||||
await clearRouteDecisionSnapshot(channel.routeId);
|
||||
invalidateTokenRouterCache();
|
||||
return await db.select().from(schema.routeChannels).where(eq(schema.routeChannels.id, channelId)).get();
|
||||
});
|
||||
await db.update(schema.routeChannels).set(updates).where(eq(schema.routeChannels.id, channelId)).run();
|
||||
await clearRouteDecisionSnapshot(channel.routeId);
|
||||
await clearDependentExplicitGroupSnapshotsBySourceRouteIds([channel.routeId]);
|
||||
invalidateTokenRouterCache();
|
||||
return await db.select().from(schema.routeChannels).where(eq(schema.routeChannels.id, channelId)).get();
|
||||
});
|
||||
|
||||
// Delete a channel
|
||||
app.delete<{ Params: { channelId: string } }>('/api/channels/:channelId', async (request) => {
|
||||
const channelId = parseInt(request.params.channelId, 10);
|
||||
const channel = await db.select().from(schema.routeChannels).where(eq(schema.routeChannels.id, channelId)).get();
|
||||
await db.delete(schema.routeChannels).where(eq(schema.routeChannels.id, channelId)).run();
|
||||
if (channel) {
|
||||
await clearRouteDecisionSnapshot(channel.routeId);
|
||||
}
|
||||
invalidateTokenRouterCache();
|
||||
return { success: true };
|
||||
});
|
||||
await db.delete(schema.routeChannels).where(eq(schema.routeChannels.id, channelId)).run();
|
||||
if (channel) {
|
||||
await clearRouteDecisionSnapshot(channel.routeId);
|
||||
await clearDependentExplicitGroupSnapshotsBySourceRouteIds([channel.routeId]);
|
||||
}
|
||||
invalidateTokenRouterCache();
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
// Rebuild routes/channels from model availability.
|
||||
app.post<{ Body?: { refreshModels?: boolean; wait?: boolean } }>('/api/routes/rebuild', async (request, reply) => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,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[] = [];
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,6 +122,22 @@ function normalizeResponsesWebsocketRequest(
|
||||
next.instructions = cloneJsonObject(lastRequest.instructions);
|
||||
}
|
||||
|
||||
if (supportsIncrementalInput && requestType === 'response.create' && asTrimmedString(parsed.previous_response_id)) {
|
||||
return {
|
||||
ok: true,
|
||||
request: next,
|
||||
nextRequestSnapshot: cloneJsonObject(next),
|
||||
};
|
||||
}
|
||||
|
||||
const mergedInput = [
|
||||
...toResponseInputArray(lastRequest.input),
|
||||
...cloneJsonObject(lastResponseOutput),
|
||||
...cloneJsonObject(parsed.input),
|
||||
];
|
||||
delete next.previous_response_id;
|
||||
next.input = mergedInput;
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
request: next,
|
||||
@@ -146,6 +145,16 @@ function normalizeResponsesWebsocketRequest(
|
||||
};
|
||||
}
|
||||
|
||||
function shouldHandleResponsesWebsocketPrewarmLocally(
|
||||
parsed: Record<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(
|
||||
socket: WebSocket,
|
||||
status: number,
|
||||
@@ -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');
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
@@ -615,9 +633,9 @@ async function syncPostgresSequences(client: SqlClient): Promise<void> {
|
||||
'events',
|
||||
];
|
||||
for (const table of tables) {
|
||||
await client.execute(`SELECT setval(pg_get_serial_sequence('${table}', 'id'), COALESCE((SELECT MAX(id) FROM "${table}"), 1), TRUE)`);
|
||||
await client.execute(`SELECT setval(pg_get_serial_sequence('${table}', 'id'), COALESCE((SELECT MAX(id) FROM "${table}"), 1), TRUE)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function bootstrapRuntimeDatabaseSchema(input: Pick<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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { minimatch } from 'minimatch';
|
||||
import { eq, inArray } from 'drizzle-orm';
|
||||
import { minimatch } from 'minimatch';
|
||||
import { db, schema } from '../db/index.js';
|
||||
import { config } from '../config.js';
|
||||
import { getCachedModelRoutingReferenceCost, refreshModelPricingCatalog } from './modelPricingService.js';
|
||||
@@ -12,9 +12,9 @@ import { type DownstreamRoutingPolicy, EMPTY_DOWNSTREAM_ROUTING_POLICY } from '.
|
||||
import { isUsableAccountToken } from './accountTokenService.js';
|
||||
import { getOauthInfoFromExtraConfig } from './oauth/oauthAccount.js';
|
||||
|
||||
interface RouteMatch {
|
||||
route: typeof schema.tokenRoutes.$inferSelect;
|
||||
channels: Array<{
|
||||
interface RouteMatch {
|
||||
route: RouteRow;
|
||||
channels: Array<{
|
||||
channel: typeof schema.routeChannels.$inferSelect;
|
||||
account: typeof schema.accounts.$inferSelect;
|
||||
site: typeof schema.sites.$inferSelect;
|
||||
@@ -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,8 +67,12 @@ function resolveRoundRobinCooldownSec(level: number): number {
|
||||
return ROUND_ROBIN_COOLDOWN_LEVELS_SEC[normalizedLevel] ?? 0;
|
||||
}
|
||||
|
||||
type RouteRow = typeof schema.tokenRoutes.$inferSelect;
|
||||
type ChannelRow = typeof schema.routeChannels.$inferSelect;
|
||||
type RouteMode = 'pattern' | 'explicit_group';
|
||||
type RouteRow = typeof schema.tokenRoutes.$inferSelect & {
|
||||
routeMode: RouteMode;
|
||||
sourceRouteIds: number[];
|
||||
};
|
||||
type ChannelRow = typeof schema.routeChannels.$inferSelect;
|
||||
|
||||
type RouteCacheSnapshot = {
|
||||
loadedAt: number;
|
||||
@@ -96,34 +101,71 @@ function isCacheFresh(loadedAt: number, nowMs: number): boolean {
|
||||
}
|
||||
|
||||
async function loadEnabledRoutes(nowMs = Date.now()): Promise<RouteRow[]> {
|
||||
if (isCacheFresh(routeCacheSnapshot.loadedAt, nowMs)) {
|
||||
return routeCacheSnapshot.routes;
|
||||
}
|
||||
|
||||
const routes = await db.select().from(schema.tokenRoutes)
|
||||
if (isCacheFresh(routeCacheSnapshot.loadedAt, nowMs)) {
|
||||
return routeCacheSnapshot.routes;
|
||||
}
|
||||
|
||||
const rawRoutes = await db.select().from(schema.tokenRoutes)
|
||||
.where(eq(schema.tokenRoutes.enabled, true))
|
||||
.all();
|
||||
routeCacheSnapshot = {
|
||||
loadedAt: nowMs,
|
||||
routes,
|
||||
const explicitGroupRouteIds = rawRoutes
|
||||
.filter((route) => normalizeRouteMode(route.routeMode) === 'explicit_group')
|
||||
.map((route) => route.id);
|
||||
const sourceRows = explicitGroupRouteIds.length > 0
|
||||
? await db.select().from(schema.routeGroupSources)
|
||||
.where(inArray(schema.routeGroupSources.groupRouteId, explicitGroupRouteIds))
|
||||
.all()
|
||||
: [];
|
||||
const sourceIdsByRouteId = new Map<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,
|
||||
};
|
||||
return routes;
|
||||
}
|
||||
|
||||
async function loadRouteMatch(route: RouteRow, nowMs = Date.now()): Promise<RouteMatch> {
|
||||
const cached = routeMatchCache.get(route.id);
|
||||
if (cached && isCacheFresh(cached.loadedAt, nowMs)) {
|
||||
return cached.match;
|
||||
}
|
||||
|
||||
const channels = await db
|
||||
.select()
|
||||
.from(schema.routeChannels)
|
||||
.innerJoin(schema.accounts, eq(schema.routeChannels.accountId, schema.accounts.id))
|
||||
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
|
||||
.leftJoin(schema.accountTokens, eq(schema.routeChannels.tokenId, schema.accountTokens.id))
|
||||
.where(eq(schema.routeChannels.routeId, route.id))
|
||||
.all();
|
||||
const cached = routeMatchCache.get(route.id);
|
||||
if (cached && isCacheFresh(cached.loadedAt, nowMs)) {
|
||||
return cached.match;
|
||||
}
|
||||
|
||||
const routeIds = (() => {
|
||||
if (!isExplicitGroupRoute(route)) {
|
||||
return [route.id];
|
||||
}
|
||||
return Array.from(new Set(route.sourceRouteIds.filter((routeId) => Number.isFinite(routeId) && routeId > 0)));
|
||||
})();
|
||||
const enabledSourceRouteIds = isExplicitGroupRoute(route)
|
||||
? (await loadEnabledRoutes(nowMs))
|
||||
.filter((item) => (
|
||||
routeIds.includes(item.id)
|
||||
&& !isExplicitGroupRoute(item)
|
||||
&& isExactRouteModelPattern(item.modelPattern)
|
||||
))
|
||||
.map((item) => item.id)
|
||||
: routeIds;
|
||||
const channels = enabledSourceRouteIds.length > 0
|
||||
? await db
|
||||
.select()
|
||||
.from(schema.routeChannels)
|
||||
.innerJoin(schema.accounts, eq(schema.routeChannels.accountId, schema.accounts.id))
|
||||
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
|
||||
.leftJoin(schema.accountTokens, eq(schema.routeChannels.tokenId, schema.accountTokens.id))
|
||||
.where(inArray(schema.routeChannels.routeId, enabledSourceRouteIds))
|
||||
.all()
|
||||
: [];
|
||||
|
||||
const mapped = channels.map((row) => ({
|
||||
channel: row.route_channels,
|
||||
@@ -244,19 +286,130 @@ type CostSignal = {
|
||||
source: 'observed' | 'configured' | 'catalog' | 'fallback';
|
||||
};
|
||||
|
||||
export function isRegexModelPattern(pattern: string): boolean {
|
||||
return pattern.trim().toLowerCase().startsWith('re:');
|
||||
}
|
||||
|
||||
export function parseRegexModelPattern(pattern: string): RegExp | null {
|
||||
if (!isRegexModelPattern(pattern)) return null;
|
||||
const body = pattern.trim().slice(3).trim();
|
||||
if (!body) return null;
|
||||
try {
|
||||
return new RegExp(body);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
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 {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function matchesModelPattern(model: string, pattern: string): boolean {
|
||||
@@ -279,23 +432,79 @@ function isExactRouteModelPattern(pattern: string): boolean {
|
||||
if (isRegexModelPattern(normalizedPattern)) return false;
|
||||
return !/[\*\?\[]/.test(normalizedPattern);
|
||||
}
|
||||
|
||||
function normalizeRouteMode(routeMode: string | null | undefined): RouteMode {
|
||||
return routeMode === 'explicit_group' ? 'explicit_group' : 'pattern';
|
||||
}
|
||||
|
||||
function isExplicitGroupRoute(route: Pick<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();
|
||||
}
|
||||
|
||||
function normalizeRouteDisplayName(displayName: string | null | undefined): string {
|
||||
return (displayName || '').trim();
|
||||
}
|
||||
|
||||
function isRouteDisplayNameMatch(model: string, displayName: string | null | undefined): boolean {
|
||||
const alias = normalizeRouteDisplayName(displayName);
|
||||
return !!alias && alias === model;
|
||||
}
|
||||
|
||||
function matchesRouteRequestModel(model: string, route: typeof schema.tokenRoutes.$inferSelect): boolean {
|
||||
return matchesModelPattern(model, route.modelPattern) || isRouteDisplayNameMatch(model, route.displayName);
|
||||
}
|
||||
|
||||
function getExposedModelNameForRoute(route: typeof schema.tokenRoutes.$inferSelect): string {
|
||||
return normalizeRouteDisplayName(route.displayName) || route.modelPattern;
|
||||
}
|
||||
function isRouteDisplayNameMatch(model: string, displayName: string | null | undefined): boolean {
|
||||
const alias = normalizeRouteDisplayName(displayName);
|
||||
return !!alias && alias === model;
|
||||
}
|
||||
|
||||
function matchesRouteRequestModel(model: string, route: RouteRow): boolean {
|
||||
if (isExplicitGroupRoute(route)) {
|
||||
return isRouteDisplayNameMatch(model, route.displayName);
|
||||
}
|
||||
return matchesModelPattern(model, route.modelPattern) || isRouteDisplayNameMatch(model, route.displayName);
|
||||
}
|
||||
|
||||
function getExposedModelNameForRoute(route: RouteRow): string {
|
||||
return normalizeRouteDisplayName(route.displayName) || route.modelPattern;
|
||||
}
|
||||
|
||||
function hasCustomDisplayName(route: Pick<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();
|
||||
@@ -367,10 +576,10 @@ function normalizeChannelSourceModel(channelSourceModel: string | null | undefin
|
||||
|
||||
function resolveActualModelForSelectedChannel(
|
||||
requestedModel: string,
|
||||
route: typeof schema.tokenRoutes.$inferSelect,
|
||||
route: RouteRow,
|
||||
mappedModel: string,
|
||||
channelSourceModel: string | null | undefined,
|
||||
): string {
|
||||
): string {
|
||||
const sourceModel = normalizeChannelSourceModel(channelSourceModel);
|
||||
if (isRouteDisplayNameMatch(requestedModel, route.displayName) && sourceModel) {
|
||||
return sourceModel;
|
||||
@@ -378,7 +587,7 @@ function resolveActualModelForSelectedChannel(
|
||||
return mappedModel;
|
||||
}
|
||||
|
||||
function resolveRouteStrategy(route: typeof schema.tokenRoutes.$inferSelect): RouteRoutingStrategy {
|
||||
function resolveRouteStrategy(route: RouteRow): RouteRoutingStrategy {
|
||||
return normalizeRouteRoutingStrategy(route.routingStrategy);
|
||||
}
|
||||
|
||||
@@ -450,6 +659,17 @@ export class TokenRouter {
|
||||
if (!match) return null;
|
||||
return await this.selectFromMatch(match, requestedModel, downstreamPolicy);
|
||||
}
|
||||
|
||||
async previewSelectedChannel(
|
||||
requestedModel: string,
|
||||
downstreamPolicy: DownstreamRoutingPolicy = DEFAULT_DOWNSTREAM_POLICY,
|
||||
): Promise<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;
|
||||
await this.recordChannelSelection(selected.channel.id);
|
||||
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();
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 modelPatternError = getModelPatternError(form.modelPattern);
|
||||
if (modelPatternError) {
|
||||
toast.error(modelPatternError);
|
||||
return;
|
||||
}
|
||||
|
||||
const trimmedModelPattern = form.modelPattern.trim();
|
||||
const trimmedDisplayName = form.displayName.trim() ? form.displayName.trim() : undefined;
|
||||
const trimmedDisplayIcon = form.displayIcon.trim() ? form.displayIcon.trim() : undefined;
|
||||
const trimmedModelPattern = form.modelPattern.trim();
|
||||
const routeMode = normalizeRouteMode(form.routeMode);
|
||||
if (routeMode === 'explicit_group') {
|
||||
if (!trimmedDisplayName) {
|
||||
toast.error('请填写对外模型名');
|
||||
return;
|
||||
}
|
||||
if (form.sourceRouteIds.length === 0) {
|
||||
toast.error('请至少选择一个来源模型');
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
if (!trimmedModelPattern) return;
|
||||
const modelPatternError = getModelPatternError(form.modelPattern);
|
||||
if (modelPatternError) {
|
||||
toast.error(modelPatternError);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
if (editingRouteId) {
|
||||
const currentRoute = routeSummaries.find((route) => route.id === editingRouteId) || null;
|
||||
const modelPatternChanged = !!currentRoute && currentRoute.modelPattern !== trimmedModelPattern;
|
||||
const modelPatternChanged = routeMode === 'pattern' && !!currentRoute && currentRoute.modelPattern !== trimmedModelPattern;
|
||||
await api.updateRoute(editingRouteId, {
|
||||
modelPattern: trimmedModelPattern,
|
||||
routeMode,
|
||||
...(routeMode === 'pattern' ? { modelPattern: trimmedModelPattern } : {}),
|
||||
displayName: trimmedDisplayName,
|
||||
displayIcon: trimmedDisplayIcon,
|
||||
...(routeMode === 'explicit_group' ? { sourceRouteIds: form.sourceRouteIds } : {}),
|
||||
});
|
||||
toast.success(modelPatternChanged ? tr('群组已更新并重新匹配通道') : tr('群组已更新'));
|
||||
toast.success(routeMode === 'pattern' && modelPatternChanged ? tr('群组已更新并重新匹配通道') : tr('群组已更新'));
|
||||
} else {
|
||||
await api.addRoute({
|
||||
modelPattern: trimmedModelPattern,
|
||||
routeMode,
|
||||
...(routeMode === 'pattern' ? { modelPattern: trimmedModelPattern } : {}),
|
||||
displayName: trimmedDisplayName,
|
||||
displayIcon: trimmedDisplayIcon,
|
||||
...(routeMode === 'explicit_group' ? { sourceRouteIds: form.sourceRouteIds } : {}),
|
||||
});
|
||||
toast.success(tr('群组已创建'));
|
||||
}
|
||||
@@ -330,10 +377,14 @@ export default function TokenRoutes() {
|
||||
|
||||
const handleEditRoute = (route: RouteSummaryRow) => {
|
||||
setEditingRouteId(route.id);
|
||||
const routeMode = normalizeRouteMode(route.routeMode);
|
||||
setForm({
|
||||
routeMode,
|
||||
modelPattern: route.modelPattern || '',
|
||||
displayName: route.displayName || '',
|
||||
displayIcon: normalizeRouteDisplayIconValue(route.displayIcon),
|
||||
sourceRouteIds: routeMode === 'explicit_group' ? [...(route.sourceRouteIds || [])] : [],
|
||||
advancedOpen: routeMode === 'pattern',
|
||||
});
|
||||
setShowManual(true);
|
||||
};
|
||||
@@ -403,9 +454,9 @@ export default function TokenRoutes() {
|
||||
|
||||
// Stable derived value: only changes when route patterns change (not on enabled toggle)
|
||||
const routePatterns = useMemo(
|
||||
() => visibleRouteRows.map((r) => ({ id: r.id, modelPattern: r.modelPattern })),
|
||||
() => visibleRouteRows.map((r) => ({ id: r.id, modelPattern: r.modelPattern, routeMode: r.routeMode })),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[visibleRouteRows.map((r) => `${r.id}:${r.modelPattern}`).join(',')],
|
||||
[visibleRouteRows.map((r) => `${r.id}:${r.modelPattern}:${r.routeMode || 'pattern'}`).join(',')],
|
||||
);
|
||||
|
||||
const routeBrandById = useMemo(() => {
|
||||
@@ -541,7 +592,7 @@ export default function TokenRoutes() {
|
||||
|
||||
const groupRouteList = useMemo<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();
|
||||
setShowManual(true);
|
||||
}}
|
||||
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}
|
||||
/>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,144 +119,527 @@ 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('用于创建群组路由(聚合多个上游模型为一个下游模型名,即模型重定向);自动路由仍会保持开启。')}
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 180px', gap: 10 }}>
|
||||
<input
|
||||
placeholder={tr('群组显示名(可选,例如 claude-opus-4-6)')}
|
||||
value={form.displayName}
|
||||
onChange={(e) => setForm((f) => ({ ...f, displayName: e.target.value }))}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px 14px',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
fontSize: 13,
|
||||
outline: 'none',
|
||||
background: 'var(--color-bg)',
|
||||
color: 'var(--color-text-primary)',
|
||||
}}
|
||||
/>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<ModernSelect
|
||||
value={routeIconSelectValue}
|
||||
onChange={(nextValue) => setForm((f) => ({ ...f, displayIcon: nextValue }))}
|
||||
options={routeIconSelectOptions}
|
||||
placeholder={tr('图标(可选,选择品牌图标)')}
|
||||
emptyLabel={tr('暂无可选品牌图标')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
placeholder={tr('模型匹配(如 gpt-4o、claude-*、re:^claude-.*$)')}
|
||||
value={form.modelPattern}
|
||||
onChange={(e) => setForm((f) => ({ ...f, modelPattern: e.target.value }))}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px 14px',
|
||||
border: `1px solid ${modelPatternError ? 'var(--color-danger)' : 'var(--color-border)'}`,
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
fontSize: 13,
|
||||
outline: 'none',
|
||||
background: 'var(--color-bg)',
|
||||
color: 'var(--color-text-primary)',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
}}
|
||||
/>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: -4 }}>
|
||||
{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={{
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
padding: '10px 12px',
|
||||
background: 'var(--color-bg)',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)', marginBottom: 8 }}>
|
||||
{tr('规则预览:命中样本')} {previewMatchedModels.length} / {previewModelSamples.length}
|
||||
</div>
|
||||
|
||||
{previewModelSamples.length === 0 ? (
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
|
||||
{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>
|
||||
) : previewMatchedModels.length === 0 ? (
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
|
||||
{tr('当前规则未命中任何样本模型。')}
|
||||
{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)')}
|
||||
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>
|
||||
</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((current) => ({ ...current, displayIcon: nextValue }))}
|
||||
options={routeIconSelectOptions}
|
||||
placeholder={tr('图标(可选,选择品牌图标)')}
|
||||
emptyLabel={tr('暂无可选品牌图标')}
|
||||
/>
|
||||
</label>
|
||||
</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={(event) => setForm((current) => ({ ...current, modelPattern: event.target.value }))}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px 14px',
|
||||
border: `1px solid ${modelPatternError ? 'var(--color-danger)' : 'var(--color-border)'}`,
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
fontSize: 13,
|
||||
outline: 'none',
|
||||
background: 'var(--color-bg)',
|
||||
color: 'var(--color-text-primary)',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: -4 }}>
|
||||
{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={{
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
padding: '10px 12px',
|
||||
background: 'var(--color-bg)',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)', marginBottom: 8 }}>
|
||||
{tr('规则预览:命中样本')} {previewMatchedModels.length} / {previewModelSamples.length}
|
||||
</div>
|
||||
|
||||
{previewModelSamples.length === 0 ? (
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
|
||||
{tr('当前暂无可预览模型,请先同步模型。')}
|
||||
</div>
|
||||
) : previewMatchedModels.length === 0 ? (
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
|
||||
{tr('当前规则未命中任何样本模型。')}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
||||
{previewMatchedModels.slice(0, 12).map((modelName) => (
|
||||
<code
|
||||
key={modelName}
|
||||
style={{
|
||||
fontSize: 11,
|
||||
padding: '2px 6px',
|
||||
borderRadius: 6,
|
||||
border: '1px solid var(--color-border)',
|
||||
background: 'var(--color-bg-card)',
|
||||
}}
|
||||
>
|
||||
{modelName}
|
||||
</code>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{previewMatchedModels.length > 12 && (
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: 8 }}>
|
||||
{tr('仅展示前 12 个命中样本。')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CenteredModal>
|
||||
|
||||
<CenteredModal
|
||||
open={show && showSourcePicker}
|
||||
onClose={closeSourcePicker}
|
||||
title={tr('选择来源模型')}
|
||||
footer={sourcePickerFooter}
|
||||
maxWidth={980}
|
||||
closeOnEscape
|
||||
>
|
||||
<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>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
||||
{previewMatchedModels.slice(0, 12).map((modelName) => (
|
||||
<code
|
||||
key={modelName}
|
||||
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={{
|
||||
fontSize: 11,
|
||||
padding: '2px 6px',
|
||||
borderRadius: 6,
|
||||
border: '1px solid var(--color-border)',
|
||||
background: 'var(--color-bg-card)',
|
||||
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',
|
||||
}}
|
||||
>
|
||||
{modelName}
|
||||
</code>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{previewMatchedModels.length > 12 && (
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: 8 }}>
|
||||
{tr('仅展示前 12 个命中样本。')}
|
||||
</div>
|
||||
<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>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</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 ? '保存群组' : '创建群组')
|
||||
)}
|
||||
</button>
|
||||
{editingRouteId ? (
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="btn btn-ghost"
|
||||
style={{ alignSelf: 'flex-start', border: '1px solid var(--color-border)' }}
|
||||
>
|
||||
{tr('取消编辑')}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CenteredModal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,28 +444,60 @@ function RouteCardInner({
|
||||
) : null}
|
||||
|
||||
<AnimatedCollapseSection open={isGroupExpanded}>
|
||||
<SortableContext items={group.channels.map((c) => c.id)} strategy={verticalListSortingStrategy}>
|
||||
{group.channels.map((channel) => {
|
||||
const tokenOptions = candidateView.tokenOptionsByAccountId[channel.accountId] || [];
|
||||
const activeTokenId = channelTokenDraft[channel.id] ?? channel.tokenId ?? 0;
|
||||
return (
|
||||
<SortableChannelRow
|
||||
{explicitGroupRoute ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{group.channels.map((channel) => (
|
||||
<div
|
||||
key={channel.id}
|
||||
channel={channel}
|
||||
decisionCandidate={decisionMap.get(channel.id)}
|
||||
isExactRoute={exactRoute}
|
||||
loadingDecision={loadingDecision}
|
||||
isSavingPriority={savingPriority}
|
||||
tokenOptions={tokenOptions}
|
||||
activeTokenId={activeTokenId}
|
||||
isUpdatingToken={!!updatingChannel[channel.id]}
|
||||
onTokenDraftChange={onTokenDraftChange}
|
||||
onSaveToken={() => onSaveToken(route.id, channel.id, channel.accountId)}
|
||||
onDeleteChannel={() => onDeleteChannel(channel.id, route.id)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</SortableContext>
|
||||
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] || [];
|
||||
const activeTokenId = channelTokenDraft[channel.id] ?? channel.tokenId ?? 0;
|
||||
return (
|
||||
<SortableChannelRow
|
||||
key={channel.id}
|
||||
channel={channel}
|
||||
decisionCandidate={decisionMap.get(channel.id)}
|
||||
isExactRoute={exactRoute}
|
||||
loadingDecision={loadingDecision}
|
||||
isSavingPriority={savingPriority}
|
||||
tokenOptions={tokenOptions}
|
||||
activeTokenId={activeTokenId}
|
||||
isUpdatingToken={!!updatingChannel[channel.id]}
|
||||
onTokenDraftChange={onTokenDraftChange}
|
||||
onSaveToken={() => onSaveToken(route.id, channel.id, channel.accountId)}
|
||||
onDeleteChannel={() => onDeleteChannel(channel.id, route.id)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</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,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;
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
|
||||
@@ -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();
|
||||
|
||||
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 () => {
|
||||
findInputByPlaceholder(root.root, '模型匹配').props.onChange({ target: { value: 're:^gemini-.*$' } });
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user