feat: add round robin routing and improve token binding UX
This commit is contained in:
@@ -415,9 +415,25 @@ function ensureRouteGroupingSchema() {
|
|||||||
sqlite.exec(`ALTER TABLE token_routes ADD COLUMN decision_refreshed_at text;`);
|
sqlite.exec(`ALTER TABLE token_routes ADD COLUMN decision_refreshed_at text;`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!tableColumnExists('token_routes', 'routing_strategy')) {
|
||||||
|
sqlite.exec(`ALTER TABLE token_routes ADD COLUMN routing_strategy text DEFAULT 'weighted';`);
|
||||||
|
}
|
||||||
|
|
||||||
if (!tableColumnExists('route_channels', 'source_model')) {
|
if (!tableColumnExists('route_channels', 'source_model')) {
|
||||||
sqlite.exec(`ALTER TABLE route_channels ADD COLUMN source_model text;`);
|
sqlite.exec(`ALTER TABLE route_channels ADD COLUMN source_model text;`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!tableColumnExists('route_channels', 'last_selected_at')) {
|
||||||
|
sqlite.exec(`ALTER TABLE route_channels ADD COLUMN last_selected_at text;`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!tableColumnExists('route_channels', 'consecutive_fail_count')) {
|
||||||
|
sqlite.exec(`ALTER TABLE route_channels ADD COLUMN consecutive_fail_count integer DEFAULT 0;`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!tableColumnExists('route_channels', 'cooldown_level')) {
|
||||||
|
sqlite.exec(`ALTER TABLE route_channels ADD COLUMN cooldown_level integer DEFAULT 0;`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureDownstreamApiKeySchema() {
|
function ensureDownstreamApiKeySchema() {
|
||||||
|
|||||||
@@ -44,7 +44,11 @@ describe('ensureRouteGroupingSchemaCompatibility', () => {
|
|||||||
'ALTER TABLE "token_routes" ADD COLUMN "display_icon" TEXT',
|
'ALTER TABLE "token_routes" ADD COLUMN "display_icon" TEXT',
|
||||||
'ALTER TABLE "token_routes" ADD COLUMN "decision_snapshot" TEXT',
|
'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 "decision_refreshed_at" TEXT',
|
||||||
|
'ALTER TABLE "token_routes" ADD COLUMN "routing_strategy" TEXT DEFAULT \'weighted\'',
|
||||||
'ALTER TABLE "route_channels" ADD COLUMN "source_model" TEXT',
|
'ALTER TABLE "route_channels" ADD COLUMN "source_model" TEXT',
|
||||||
|
'ALTER TABLE "route_channels" ADD COLUMN "last_selected_at" TEXT',
|
||||||
|
'ALTER TABLE "route_channels" ADD COLUMN "consecutive_fail_count" INTEGER DEFAULT 0',
|
||||||
|
'ALTER TABLE "route_channels" ADD COLUMN "cooldown_level" INTEGER DEFAULT 0',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -54,7 +58,11 @@ describe('ensureRouteGroupingSchemaCompatibility', () => {
|
|||||||
'ALTER TABLE `token_routes` ADD COLUMN `display_icon` TEXT NULL',
|
'ALTER TABLE `token_routes` ADD COLUMN `display_icon` TEXT NULL',
|
||||||
'ALTER TABLE `token_routes` ADD COLUMN `decision_snapshot` TEXT NULL',
|
'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 `decision_refreshed_at` TEXT NULL',
|
||||||
|
'ALTER TABLE `token_routes` ADD COLUMN `routing_strategy` VARCHAR(32) NULL DEFAULT \'weighted\'',
|
||||||
'ALTER TABLE `route_channels` ADD COLUMN `source_model` TEXT NULL',
|
'ALTER TABLE `route_channels` ADD COLUMN `source_model` TEXT NULL',
|
||||||
|
'ALTER TABLE `route_channels` ADD COLUMN `last_selected_at` TEXT NULL',
|
||||||
|
'ALTER TABLE `route_channels` ADD COLUMN `consecutive_fail_count` INT NOT NULL DEFAULT 0',
|
||||||
|
'ALTER TABLE `route_channels` ADD COLUMN `cooldown_level` INT NOT NULL DEFAULT 0',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
])('adds missing route grouping columns for $dialect', async ({ dialect, expectedSql }) => {
|
])('adds missing route grouping columns for $dialect', async ({ dialect, expectedSql }) => {
|
||||||
@@ -68,8 +76,8 @@ describe('ensureRouteGroupingSchemaCompatibility', () => {
|
|||||||
it('skips existing columns', async () => {
|
it('skips existing columns', async () => {
|
||||||
const { inspector, executedSql } = createInspector('postgres', {
|
const { inspector, executedSql } = createInspector('postgres', {
|
||||||
existingColumnsByTable: {
|
existingColumnsByTable: {
|
||||||
token_routes: ['display_name', 'display_icon', 'decision_snapshot', 'decision_refreshed_at'],
|
token_routes: ['display_name', 'display_icon', 'decision_snapshot', 'decision_refreshed_at', 'routing_strategy'],
|
||||||
route_channels: ['source_model'],
|
route_channels: ['source_model', 'last_selected_at', 'consecutive_fail_count', 'cooldown_level'],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -50,6 +50,15 @@ const ROUTE_GROUPING_COLUMN_COMPATIBILITY_SPECS: RouteGroupingColumnCompatibilit
|
|||||||
postgres: 'ALTER TABLE "token_routes" ADD COLUMN "decision_refreshed_at" TEXT',
|
postgres: 'ALTER TABLE "token_routes" ADD COLUMN "decision_refreshed_at" TEXT',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
table: 'token_routes',
|
||||||
|
column: 'routing_strategy',
|
||||||
|
addSql: {
|
||||||
|
sqlite: 'ALTER TABLE token_routes ADD COLUMN routing_strategy text DEFAULT \'weighted\';',
|
||||||
|
mysql: 'ALTER TABLE `token_routes` ADD COLUMN `routing_strategy` VARCHAR(32) NULL DEFAULT \'weighted\'',
|
||||||
|
postgres: 'ALTER TABLE "token_routes" ADD COLUMN "routing_strategy" TEXT DEFAULT \'weighted\'',
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
table: 'route_channels',
|
table: 'route_channels',
|
||||||
column: 'source_model',
|
column: 'source_model',
|
||||||
@@ -59,6 +68,33 @@ const ROUTE_GROUPING_COLUMN_COMPATIBILITY_SPECS: RouteGroupingColumnCompatibilit
|
|||||||
postgres: 'ALTER TABLE "route_channels" ADD COLUMN "source_model" TEXT',
|
postgres: 'ALTER TABLE "route_channels" ADD COLUMN "source_model" TEXT',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
table: 'route_channels',
|
||||||
|
column: 'last_selected_at',
|
||||||
|
addSql: {
|
||||||
|
sqlite: 'ALTER TABLE route_channels ADD COLUMN last_selected_at text;',
|
||||||
|
mysql: 'ALTER TABLE `route_channels` ADD COLUMN `last_selected_at` TEXT NULL',
|
||||||
|
postgres: 'ALTER TABLE "route_channels" ADD COLUMN "last_selected_at" TEXT',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
table: 'route_channels',
|
||||||
|
column: 'consecutive_fail_count',
|
||||||
|
addSql: {
|
||||||
|
sqlite: 'ALTER TABLE route_channels ADD COLUMN consecutive_fail_count integer DEFAULT 0;',
|
||||||
|
mysql: 'ALTER TABLE `route_channels` ADD COLUMN `consecutive_fail_count` INT NOT NULL DEFAULT 0',
|
||||||
|
postgres: 'ALTER TABLE "route_channels" ADD COLUMN "consecutive_fail_count" INTEGER DEFAULT 0',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
table: 'route_channels',
|
||||||
|
column: 'cooldown_level',
|
||||||
|
addSql: {
|
||||||
|
sqlite: 'ALTER TABLE route_channels ADD COLUMN cooldown_level integer DEFAULT 0;',
|
||||||
|
mysql: 'ALTER TABLE `route_channels` ADD COLUMN `cooldown_level` INT NOT NULL DEFAULT 0',
|
||||||
|
postgres: 'ALTER TABLE "route_channels" ADD COLUMN "cooldown_level" INTEGER DEFAULT 0',
|
||||||
|
},
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
function normalizeSchemaErrorMessage(error: unknown): string {
|
function normalizeSchemaErrorMessage(error: unknown): string {
|
||||||
|
|||||||
@@ -192,8 +192,8 @@ export async function ensureRuntimeDatabaseSchema(client: RuntimeSchemaClient):
|
|||||||
`CREATE TABLE IF NOT EXISTS "checkin_logs" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "status" TEXT NOT NULL, "message" TEXT, "reward" TEXT, "created_at" TEXT)`,
|
`CREATE TABLE IF NOT EXISTS "checkin_logs" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "status" TEXT NOT NULL, "message" TEXT, "reward" TEXT, "created_at" TEXT)`,
|
||||||
`CREATE TABLE IF NOT EXISTS "model_availability" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "model_name" TEXT NOT NULL, "available" BOOLEAN, "latency_ms" INTEGER, "checked_at" TEXT)`,
|
`CREATE TABLE IF NOT EXISTS "model_availability" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "model_name" TEXT NOT NULL, "available" BOOLEAN, "latency_ms" INTEGER, "checked_at" TEXT)`,
|
||||||
`CREATE TABLE IF NOT EXISTS "token_model_availability" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "token_id" INTEGER NOT NULL REFERENCES "account_tokens"("id") ON DELETE CASCADE, "model_name" TEXT NOT NULL, "available" BOOLEAN, "latency_ms" INTEGER, "checked_at" TEXT)`,
|
`CREATE TABLE IF NOT EXISTS "token_model_availability" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "token_id" INTEGER NOT NULL REFERENCES "account_tokens"("id") ON DELETE CASCADE, "model_name" TEXT NOT NULL, "available" BOOLEAN, "latency_ms" INTEGER, "checked_at" TEXT)`,
|
||||||
`CREATE TABLE IF NOT EXISTS "token_routes" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "model_pattern" TEXT NOT NULL, "display_name" TEXT, "display_icon" TEXT, "model_mapping" TEXT, "decision_snapshot" TEXT, "decision_refreshed_at" TEXT, "enabled" BOOLEAN DEFAULT TRUE, "created_at" TEXT, "updated_at" TEXT)`,
|
`CREATE TABLE IF NOT EXISTS "token_routes" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "model_pattern" TEXT NOT NULL, "display_name" TEXT, "display_icon" TEXT, "model_mapping" TEXT, "decision_snapshot" TEXT, "decision_refreshed_at" TEXT, "routing_strategy" TEXT DEFAULT 'weighted', "enabled" BOOLEAN DEFAULT TRUE, "created_at" TEXT, "updated_at" TEXT)`,
|
||||||
`CREATE TABLE IF NOT EXISTS "route_channels" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "route_id" INTEGER NOT NULL REFERENCES "token_routes"("id") ON DELETE CASCADE, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "token_id" INTEGER REFERENCES "account_tokens"("id") ON DELETE SET NULL, "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" TEXT, "last_fail_at" TEXT, "cooldown_until" TEXT)`,
|
`CREATE TABLE IF NOT EXISTS "route_channels" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "route_id" INTEGER NOT NULL REFERENCES "token_routes"("id") ON DELETE CASCADE, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "token_id" INTEGER REFERENCES "account_tokens"("id") ON DELETE SET NULL, "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" TEXT, "last_selected_at" TEXT, "last_fail_at" TEXT, "consecutive_fail_count" INTEGER DEFAULT 0, "cooldown_level" INTEGER DEFAULT 0, "cooldown_until" TEXT)`,
|
||||||
`CREATE TABLE IF NOT EXISTS "proxy_logs" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY 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, "billing_details" TEXT, "error_message" TEXT, "retry_count" INTEGER DEFAULT 0, "created_at" TEXT)`,
|
`CREATE TABLE IF NOT EXISTS "proxy_logs" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY 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, "billing_details" TEXT, "error_message" TEXT, "retry_count" INTEGER DEFAULT 0, "created_at" TEXT)`,
|
||||||
`CREATE TABLE IF NOT EXISTS "proxy_video_tasks" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "public_id" TEXT NOT NULL UNIQUE, "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" TEXT, "upstream_response_meta" TEXT, "last_upstream_status" INTEGER, "last_polled_at" TEXT, "created_at" TEXT, "updated_at" TEXT)`,
|
`CREATE TABLE IF NOT EXISTS "proxy_video_tasks" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "public_id" TEXT NOT NULL UNIQUE, "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" TEXT, "upstream_response_meta" TEXT, "last_upstream_status" INTEGER, "last_polled_at" TEXT, "created_at" TEXT, "updated_at" TEXT)`,
|
||||||
`CREATE TABLE IF NOT EXISTS "proxy_files" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "public_id" TEXT NOT NULL UNIQUE, "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" TEXT, "updated_at" TEXT, "deleted_at" TEXT)`,
|
`CREATE TABLE IF NOT EXISTS "proxy_files" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "public_id" TEXT NOT NULL UNIQUE, "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" TEXT, "updated_at" TEXT, "deleted_at" TEXT)`,
|
||||||
@@ -209,8 +209,8 @@ export async function ensureRuntimeDatabaseSchema(client: RuntimeSchemaClient):
|
|||||||
`CREATE TABLE IF NOT EXISTS \`checkin_logs\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`account_id\` INT NOT NULL, \`status\` VARCHAR(32) NOT NULL, \`message\` TEXT NULL, \`reward\` TEXT NULL, \`created_at\` TEXT NULL, CONSTRAINT \`checkin_logs_account_fk\` FOREIGN KEY (\`account_id\`) REFERENCES \`accounts\`(\`id\`) ON DELETE CASCADE)`,
|
`CREATE TABLE IF NOT EXISTS \`checkin_logs\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`account_id\` INT NOT NULL, \`status\` VARCHAR(32) NOT NULL, \`message\` TEXT NULL, \`reward\` TEXT NULL, \`created_at\` TEXT NULL, CONSTRAINT \`checkin_logs_account_fk\` FOREIGN KEY (\`account_id\`) REFERENCES \`accounts\`(\`id\`) ON DELETE CASCADE)`,
|
||||||
`CREATE TABLE IF NOT EXISTS \`model_availability\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`account_id\` INT NOT NULL, \`model_name\` VARCHAR(191) NOT NULL, \`available\` BOOLEAN NULL, \`latency_ms\` INT NULL, \`checked_at\` TEXT NULL, CONSTRAINT \`model_availability_account_fk\` FOREIGN KEY (\`account_id\`) REFERENCES \`accounts\`(\`id\`) ON DELETE CASCADE)`,
|
`CREATE TABLE IF NOT EXISTS \`model_availability\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`account_id\` INT NOT NULL, \`model_name\` VARCHAR(191) NOT NULL, \`available\` BOOLEAN NULL, \`latency_ms\` INT NULL, \`checked_at\` TEXT NULL, CONSTRAINT \`model_availability_account_fk\` FOREIGN KEY (\`account_id\`) REFERENCES \`accounts\`(\`id\`) ON DELETE CASCADE)`,
|
||||||
`CREATE TABLE IF NOT EXISTS \`token_model_availability\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`token_id\` INT NOT NULL, \`model_name\` VARCHAR(191) NOT NULL, \`available\` BOOLEAN NULL, \`latency_ms\` INT NULL, \`checked_at\` TEXT NULL, CONSTRAINT \`token_model_availability_token_fk\` FOREIGN KEY (\`token_id\`) REFERENCES \`account_tokens\`(\`id\`) ON DELETE CASCADE)`,
|
`CREATE TABLE IF NOT EXISTS \`token_model_availability\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`token_id\` INT NOT NULL, \`model_name\` VARCHAR(191) NOT NULL, \`available\` BOOLEAN NULL, \`latency_ms\` INT NULL, \`checked_at\` TEXT NULL, CONSTRAINT \`token_model_availability_token_fk\` FOREIGN KEY (\`token_id\`) REFERENCES \`account_tokens\`(\`id\`) ON DELETE CASCADE)`,
|
||||||
`CREATE TABLE IF NOT EXISTS \`token_routes\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`model_pattern\` TEXT NOT NULL, \`display_name\` TEXT NULL, \`display_icon\` TEXT NULL, \`model_mapping\` TEXT NULL, \`decision_snapshot\` TEXT NULL, \`decision_refreshed_at\` TEXT NULL, \`enabled\` BOOLEAN DEFAULT TRUE, \`created_at\` TEXT NULL, \`updated_at\` TEXT NULL)`,
|
`CREATE TABLE IF NOT EXISTS \`token_routes\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`model_pattern\` TEXT NOT NULL, \`display_name\` TEXT NULL, \`display_icon\` TEXT NULL, \`model_mapping\` TEXT NULL, \`decision_snapshot\` TEXT NULL, \`decision_refreshed_at\` TEXT NULL, \`routing_strategy\` VARCHAR(32) NULL DEFAULT 'weighted', \`enabled\` BOOLEAN DEFAULT TRUE, \`created_at\` TEXT NULL, \`updated_at\` TEXT NULL)`,
|
||||||
`CREATE TABLE IF NOT EXISTS \`route_channels\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`route_id\` INT NOT NULL, \`account_id\` INT NOT NULL, \`token_id\` INT NULL, \`source_model\` TEXT NULL, \`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\` TEXT NULL, \`last_fail_at\` TEXT NULL, \`cooldown_until\` TEXT NULL, CONSTRAINT \`route_channels_route_fk\` FOREIGN KEY (\`route_id\`) REFERENCES \`token_routes\`(\`id\`) ON DELETE CASCADE, CONSTRAINT \`route_channels_account_fk\` FOREIGN KEY (\`account_id\`) REFERENCES \`accounts\`(\`id\`) ON DELETE CASCADE, CONSTRAINT \`route_channels_token_fk\` FOREIGN KEY (\`token_id\`) REFERENCES \`account_tokens\`(\`id\`) ON DELETE SET NULL)`,
|
`CREATE TABLE IF NOT EXISTS \`route_channels\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`route_id\` INT NOT NULL, \`account_id\` INT NOT NULL, \`token_id\` INT NULL, \`source_model\` TEXT NULL, \`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\` TEXT NULL, \`last_selected_at\` TEXT NULL, \`last_fail_at\` TEXT NULL, \`consecutive_fail_count\` INT DEFAULT 0, \`cooldown_level\` INT DEFAULT 0, \`cooldown_until\` TEXT NULL, CONSTRAINT \`route_channels_route_fk\` FOREIGN KEY (\`route_id\`) REFERENCES \`token_routes\`(\`id\`) ON DELETE CASCADE, CONSTRAINT \`route_channels_account_fk\` FOREIGN KEY (\`account_id\`) REFERENCES \`accounts\`(\`id\`) ON DELETE CASCADE, CONSTRAINT \`route_channels_token_fk\` FOREIGN KEY (\`token_id\`) REFERENCES \`account_tokens\`(\`id\`) ON DELETE SET NULL)`,
|
||||||
`CREATE TABLE IF NOT EXISTS \`proxy_logs\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`route_id\` INT NULL, \`channel_id\` INT NULL, \`account_id\` INT NULL, \`model_requested\` TEXT NULL, \`model_actual\` TEXT NULL, \`status\` VARCHAR(32) NULL, \`http_status\` INT NULL, \`latency_ms\` INT NULL, \`prompt_tokens\` INT NULL, \`completion_tokens\` INT NULL, \`total_tokens\` INT NULL, \`estimated_cost\` DOUBLE NULL, \`billing_details\` TEXT NULL, \`error_message\` TEXT NULL, \`retry_count\` INT DEFAULT 0, \`created_at\` TEXT NULL)`,
|
`CREATE TABLE IF NOT EXISTS \`proxy_logs\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`route_id\` INT NULL, \`channel_id\` INT NULL, \`account_id\` INT NULL, \`model_requested\` TEXT NULL, \`model_actual\` TEXT NULL, \`status\` VARCHAR(32) NULL, \`http_status\` INT NULL, \`latency_ms\` INT NULL, \`prompt_tokens\` INT NULL, \`completion_tokens\` INT NULL, \`total_tokens\` INT NULL, \`estimated_cost\` DOUBLE NULL, \`billing_details\` TEXT NULL, \`error_message\` TEXT NULL, \`retry_count\` INT DEFAULT 0, \`created_at\` TEXT NULL)`,
|
||||||
`CREATE TABLE IF NOT EXISTS \`proxy_video_tasks\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`public_id\` VARCHAR(191) NOT NULL UNIQUE, \`upstream_video_id\` TEXT NOT NULL, \`site_url\` TEXT NOT NULL, \`token_value\` TEXT NOT NULL, \`requested_model\` TEXT NULL, \`actual_model\` TEXT NULL, \`channel_id\` INT NULL, \`account_id\` INT NULL, \`status_snapshot\` TEXT NULL, \`upstream_response_meta\` TEXT NULL, \`last_upstream_status\` INT NULL, \`last_polled_at\` TEXT NULL, \`created_at\` TEXT NULL, \`updated_at\` TEXT NULL)`,
|
`CREATE TABLE IF NOT EXISTS \`proxy_video_tasks\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`public_id\` VARCHAR(191) NOT NULL UNIQUE, \`upstream_video_id\` TEXT NOT NULL, \`site_url\` TEXT NOT NULL, \`token_value\` TEXT NOT NULL, \`requested_model\` TEXT NULL, \`actual_model\` TEXT NULL, \`channel_id\` INT NULL, \`account_id\` INT NULL, \`status_snapshot\` TEXT NULL, \`upstream_response_meta\` TEXT NULL, \`last_upstream_status\` INT NULL, \`last_polled_at\` TEXT NULL, \`created_at\` TEXT NULL, \`updated_at\` TEXT NULL)`,
|
||||||
`CREATE TABLE IF NOT EXISTS \`proxy_files\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`public_id\` VARCHAR(191) NOT NULL UNIQUE, \`owner_type\` VARCHAR(64) NOT NULL, \`owner_id\` VARCHAR(191) NOT NULL, \`filename\` TEXT NOT NULL, \`mime_type\` VARCHAR(191) NOT NULL, \`purpose\` TEXT NULL, \`byte_size\` INT NOT NULL, \`sha256\` VARCHAR(191) NOT NULL, \`content_base64\` LONGTEXT NOT NULL, \`created_at\` TEXT NULL, \`updated_at\` TEXT NULL, \`deleted_at\` TEXT NULL)`,
|
`CREATE TABLE IF NOT EXISTS \`proxy_files\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`public_id\` VARCHAR(191) NOT NULL UNIQUE, \`owner_type\` VARCHAR(64) NOT NULL, \`owner_id\` VARCHAR(191) NOT NULL, \`filename\` TEXT NOT NULL, \`mime_type\` VARCHAR(191) NOT NULL, \`purpose\` TEXT NULL, \`byte_size\` INT NOT NULL, \`sha256\` VARCHAR(191) NOT NULL, \`content_base64\` LONGTEXT NOT NULL, \`created_at\` TEXT NULL, \`updated_at\` TEXT NULL, \`deleted_at\` TEXT NULL)`,
|
||||||
@@ -225,8 +225,8 @@ export async function ensureRuntimeDatabaseSchema(client: RuntimeSchemaClient):
|
|||||||
`CREATE TABLE IF NOT EXISTS "checkin_logs" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "status" TEXT NOT NULL, "message" TEXT, "reward" TEXT, "created_at" TEXT)`,
|
`CREATE TABLE IF NOT EXISTS "checkin_logs" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "status" TEXT NOT NULL, "message" TEXT, "reward" TEXT, "created_at" TEXT)`,
|
||||||
`CREATE TABLE IF NOT EXISTS "model_availability" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "model_name" TEXT NOT NULL, "available" INTEGER, "latency_ms" INTEGER, "checked_at" TEXT)`,
|
`CREATE TABLE IF NOT EXISTS "model_availability" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "model_name" TEXT NOT NULL, "available" INTEGER, "latency_ms" INTEGER, "checked_at" TEXT)`,
|
||||||
`CREATE TABLE IF NOT EXISTS "token_model_availability" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "token_id" INTEGER NOT NULL REFERENCES "account_tokens"("id") ON DELETE CASCADE, "model_name" TEXT NOT NULL, "available" INTEGER, "latency_ms" INTEGER, "checked_at" TEXT)`,
|
`CREATE TABLE IF NOT EXISTS "token_model_availability" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "token_id" INTEGER NOT NULL REFERENCES "account_tokens"("id") ON DELETE CASCADE, "model_name" TEXT NOT NULL, "available" INTEGER, "latency_ms" INTEGER, "checked_at" TEXT)`,
|
||||||
`CREATE TABLE IF NOT EXISTS "token_routes" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "model_pattern" TEXT NOT NULL, "display_name" TEXT, "display_icon" TEXT, "model_mapping" TEXT, "decision_snapshot" TEXT, "decision_refreshed_at" TEXT, "enabled" INTEGER DEFAULT 1, "created_at" TEXT, "updated_at" TEXT)`,
|
`CREATE TABLE IF NOT EXISTS "token_routes" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "model_pattern" TEXT NOT NULL, "display_name" TEXT, "display_icon" TEXT, "model_mapping" TEXT, "decision_snapshot" TEXT, "decision_refreshed_at" TEXT, "routing_strategy" TEXT DEFAULT 'weighted', "enabled" INTEGER DEFAULT 1, "created_at" TEXT, "updated_at" TEXT)`,
|
||||||
`CREATE TABLE IF NOT EXISTS "route_channels" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "route_id" INTEGER NOT NULL REFERENCES "token_routes"("id") ON DELETE CASCADE, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "token_id" INTEGER REFERENCES "account_tokens"("id") ON DELETE SET NULL, "source_model" TEXT, "priority" INTEGER DEFAULT 0, "weight" INTEGER DEFAULT 10, "enabled" INTEGER DEFAULT 1, "manual_override" INTEGER DEFAULT 0, "success_count" INTEGER DEFAULT 0, "fail_count" INTEGER DEFAULT 0, "total_latency_ms" INTEGER DEFAULT 0, "total_cost" REAL DEFAULT 0, "last_used_at" TEXT, "last_fail_at" TEXT, "cooldown_until" TEXT)`,
|
`CREATE TABLE IF NOT EXISTS "route_channels" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "route_id" INTEGER NOT NULL REFERENCES "token_routes"("id") ON DELETE CASCADE, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "token_id" INTEGER REFERENCES "account_tokens"("id") ON DELETE SET NULL, "source_model" TEXT, "priority" INTEGER DEFAULT 0, "weight" INTEGER DEFAULT 10, "enabled" INTEGER DEFAULT 1, "manual_override" INTEGER DEFAULT 0, "success_count" INTEGER DEFAULT 0, "fail_count" INTEGER DEFAULT 0, "total_latency_ms" INTEGER DEFAULT 0, "total_cost" REAL DEFAULT 0, "last_used_at" TEXT, "last_selected_at" TEXT, "last_fail_at" TEXT, "consecutive_fail_count" INTEGER DEFAULT 0, "cooldown_level" INTEGER DEFAULT 0, "cooldown_until" TEXT)`,
|
||||||
`CREATE TABLE IF NOT EXISTS "proxy_logs" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "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" REAL, "billing_details" TEXT, "error_message" TEXT, "retry_count" INTEGER DEFAULT 0, "created_at" TEXT)`,
|
`CREATE TABLE IF NOT EXISTS "proxy_logs" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "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" REAL, "billing_details" TEXT, "error_message" TEXT, "retry_count" INTEGER DEFAULT 0, "created_at" TEXT)`,
|
||||||
`CREATE TABLE IF NOT EXISTS "proxy_video_tasks" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "public_id" TEXT NOT NULL UNIQUE, "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" TEXT, "upstream_response_meta" TEXT, "last_upstream_status" INTEGER, "last_polled_at" TEXT, "created_at" TEXT, "updated_at" TEXT)`,
|
`CREATE TABLE IF NOT EXISTS "proxy_video_tasks" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "public_id" TEXT NOT NULL UNIQUE, "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" TEXT, "upstream_response_meta" TEXT, "last_upstream_status" INTEGER, "last_polled_at" TEXT, "created_at" TEXT, "updated_at" TEXT)`,
|
||||||
`CREATE TABLE IF NOT EXISTS "proxy_files" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "public_id" TEXT NOT NULL UNIQUE, "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" TEXT, "updated_at" TEXT, "deleted_at" TEXT)`,
|
`CREATE TABLE IF NOT EXISTS "proxy_files" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "public_id" TEXT NOT NULL UNIQUE, "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" TEXT, "updated_at" TEXT, "deleted_at" TEXT)`,
|
||||||
|
|||||||
@@ -111,6 +111,7 @@ export const tokenRoutes = sqliteTable('token_routes', {
|
|||||||
modelMapping: text('model_mapping'), // JSON
|
modelMapping: text('model_mapping'), // JSON
|
||||||
decisionSnapshot: text('decision_snapshot'), // JSON
|
decisionSnapshot: text('decision_snapshot'), // JSON
|
||||||
decisionRefreshedAt: text('decision_refreshed_at'),
|
decisionRefreshedAt: text('decision_refreshed_at'),
|
||||||
|
routingStrategy: text('routing_strategy').default('weighted'),
|
||||||
enabled: integer('enabled', { mode: 'boolean' }).default(true),
|
enabled: integer('enabled', { mode: 'boolean' }).default(true),
|
||||||
createdAt: text('created_at').default(sql`(datetime('now'))`),
|
createdAt: text('created_at').default(sql`(datetime('now'))`),
|
||||||
updatedAt: text('updated_at').default(sql`(datetime('now'))`),
|
updatedAt: text('updated_at').default(sql`(datetime('now'))`),
|
||||||
@@ -134,7 +135,10 @@ export const routeChannels = sqliteTable('route_channels', {
|
|||||||
totalLatencyMs: integer('total_latency_ms').default(0),
|
totalLatencyMs: integer('total_latency_ms').default(0),
|
||||||
totalCost: real('total_cost').default(0),
|
totalCost: real('total_cost').default(0),
|
||||||
lastUsedAt: text('last_used_at'),
|
lastUsedAt: text('last_used_at'),
|
||||||
|
lastSelectedAt: text('last_selected_at'),
|
||||||
lastFailAt: text('last_fail_at'),
|
lastFailAt: text('last_fail_at'),
|
||||||
|
consecutiveFailCount: integer('consecutive_fail_count').default(0),
|
||||||
|
cooldownLevel: integer('cooldown_level').default(0),
|
||||||
cooldownUntil: text('cooldown_until'),
|
cooldownUntil: text('cooldown_until'),
|
||||||
}, (table) => ({
|
}, (table) => ({
|
||||||
routeIdIdx: index('route_channels_route_id_idx').on(table.routeId),
|
routeIdIdx: index('route_channels_route_id_idx').on(table.routeId),
|
||||||
|
|||||||
@@ -882,7 +882,10 @@ export async function settingsRoutes(app: FastifyInstance) {
|
|||||||
totalLatencyMs: 0,
|
totalLatencyMs: 0,
|
||||||
totalCost: 0,
|
totalCost: 0,
|
||||||
lastUsedAt: null,
|
lastUsedAt: null,
|
||||||
|
lastSelectedAt: null,
|
||||||
lastFailAt: null,
|
lastFailAt: null,
|
||||||
|
consecutiveFailCount: 0,
|
||||||
|
cooldownLevel: 0,
|
||||||
cooldownUntil: null,
|
cooldownUntil: null,
|
||||||
}).run();
|
}).run();
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { and, eq, inArray } from 'drizzle-orm';
|
import { and, eq, inArray } from 'drizzle-orm';
|
||||||
import { db, schema } from '../../db/index.js';
|
import { db, schema } from '../../db/index.js';
|
||||||
import { rebuildTokenRoutesFromAvailability, refreshModelsAndRebuildRoutes } from '../../services/modelService.js';
|
import { rebuildTokenRoutesFromAvailability, refreshModelsAndRebuildRoutes } from '../../services/modelService.js';
|
||||||
|
import { normalizeRouteRoutingStrategy } from '../../services/routeRoutingStrategy.js';
|
||||||
import { invalidateTokenRouterCache, matchesModelPattern, tokenRouter } from '../../services/tokenRouter.js';
|
import { invalidateTokenRouterCache, matchesModelPattern, tokenRouter } from '../../services/tokenRouter.js';
|
||||||
import { startBackgroundTask } from '../../services/backgroundTaskService.js';
|
import { startBackgroundTask } from '../../services/backgroundTaskService.js';
|
||||||
import {
|
import {
|
||||||
@@ -420,6 +421,7 @@ export async function tokensRoutes(app: FastifyInstance) {
|
|||||||
modelPattern: schema.tokenRoutes.modelPattern,
|
modelPattern: schema.tokenRoutes.modelPattern,
|
||||||
displayName: schema.tokenRoutes.displayName,
|
displayName: schema.tokenRoutes.displayName,
|
||||||
displayIcon: schema.tokenRoutes.displayIcon,
|
displayIcon: schema.tokenRoutes.displayIcon,
|
||||||
|
routingStrategy: schema.tokenRoutes.routingStrategy,
|
||||||
enabled: schema.tokenRoutes.enabled,
|
enabled: schema.tokenRoutes.enabled,
|
||||||
}).from(schema.tokenRoutes).all();
|
}).from(schema.tokenRoutes).all();
|
||||||
});
|
});
|
||||||
@@ -462,6 +464,7 @@ export async function tokensRoutes(app: FastifyInstance) {
|
|||||||
displayName: route.displayName ?? null,
|
displayName: route.displayName ?? null,
|
||||||
displayIcon: route.displayIcon ?? null,
|
displayIcon: route.displayIcon ?? null,
|
||||||
modelMapping: route.modelMapping ?? null,
|
modelMapping: route.modelMapping ?? null,
|
||||||
|
routingStrategy: route.routingStrategy ?? 'weighted',
|
||||||
enabled: route.enabled,
|
enabled: route.enabled,
|
||||||
channelCount: agg?.channelCount ?? 0,
|
channelCount: agg?.channelCount ?? 0,
|
||||||
enabledChannelCount: agg?.enabledChannelCount ?? 0,
|
enabledChannelCount: agg?.enabledChannelCount ?? 0,
|
||||||
@@ -676,13 +679,14 @@ export async function tokensRoutes(app: FastifyInstance) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Create a route
|
// Create a route
|
||||||
app.post<{ Body: { modelPattern: string; displayName?: string; displayIcon?: string; modelMapping?: string; enabled?: boolean } }>('/api/routes', async (request) => {
|
app.post<{ Body: { modelPattern: string; displayName?: string; displayIcon?: string; modelMapping?: string; routingStrategy?: string; enabled?: boolean } }>('/api/routes', async (request) => {
|
||||||
const body = request.body;
|
const body = request.body;
|
||||||
const insertedRoute = await db.insert(schema.tokenRoutes).values({
|
const insertedRoute = await db.insert(schema.tokenRoutes).values({
|
||||||
modelPattern: body.modelPattern,
|
modelPattern: body.modelPattern,
|
||||||
displayName: body.displayName,
|
displayName: body.displayName,
|
||||||
displayIcon: body.displayIcon,
|
displayIcon: body.displayIcon,
|
||||||
modelMapping: body.modelMapping,
|
modelMapping: body.modelMapping,
|
||||||
|
routingStrategy: normalizeRouteRoutingStrategy(body.routingStrategy),
|
||||||
enabled: body.enabled ?? true,
|
enabled: body.enabled ?? true,
|
||||||
}).run();
|
}).run();
|
||||||
const routeId = Number(insertedRoute.lastInsertRowid || 0);
|
const routeId = Number(insertedRoute.lastInsertRowid || 0);
|
||||||
@@ -718,12 +722,16 @@ export async function tokensRoutes(app: FastifyInstance) {
|
|||||||
updates.modelPattern = nextModelPattern;
|
updates.modelPattern = nextModelPattern;
|
||||||
}
|
}
|
||||||
if (body.modelMapping !== undefined) updates.modelMapping = body.modelMapping;
|
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.enabled !== undefined) updates.enabled = body.enabled;
|
||||||
updates.updatedAt = new Date().toISOString();
|
updates.updatedAt = new Date().toISOString();
|
||||||
|
|
||||||
await db.update(schema.tokenRoutes).set(updates).where(eq(schema.tokenRoutes.id, id)).run();
|
await db.update(schema.tokenRoutes).set(updates).where(eq(schema.tokenRoutes.id, id)).run();
|
||||||
const modelPatternChanged = body.modelPattern !== undefined && nextModelPattern !== existingRoute.modelPattern;
|
const modelPatternChanged = body.modelPattern !== undefined && nextModelPattern !== existingRoute.modelPattern;
|
||||||
const routeBehaviorChanged = modelPatternChanged || body.modelMapping !== undefined || body.enabled !== undefined;
|
const routeBehaviorChanged = modelPatternChanged
|
||||||
|
|| body.modelMapping !== undefined
|
||||||
|
|| body.routingStrategy !== undefined
|
||||||
|
|| body.enabled !== undefined;
|
||||||
if (modelPatternChanged) {
|
if (modelPatternChanged) {
|
||||||
await rebuildAutomaticRouteChannelsByModelPattern(id, nextModelPattern);
|
await rebuildAutomaticRouteChannelsByModelPattern(id, nextModelPattern);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -374,7 +374,7 @@ function buildStatements(snapshot: BackupSnapshot): InsertStatement[] {
|
|||||||
for (const row of snapshot.accounts.tokenRoutes) {
|
for (const row of snapshot.accounts.tokenRoutes) {
|
||||||
statements.push({
|
statements.push({
|
||||||
table: 'token_routes',
|
table: 'token_routes',
|
||||||
columns: ['id', 'model_pattern', 'display_name', 'display_icon', 'model_mapping', 'decision_snapshot', 'decision_refreshed_at', 'enabled', 'created_at', 'updated_at'],
|
columns: ['id', 'model_pattern', 'display_name', 'display_icon', 'model_mapping', 'decision_snapshot', 'decision_refreshed_at', 'routing_strategy', 'enabled', 'created_at', 'updated_at'],
|
||||||
values: [
|
values: [
|
||||||
asNumber(row.id, 0),
|
asNumber(row.id, 0),
|
||||||
asNullableString(row.modelPattern),
|
asNullableString(row.modelPattern),
|
||||||
@@ -383,6 +383,7 @@ function buildStatements(snapshot: BackupSnapshot): InsertStatement[] {
|
|||||||
asNullableString(row.modelMapping),
|
asNullableString(row.modelMapping),
|
||||||
asNullableString(row.decisionSnapshot),
|
asNullableString(row.decisionSnapshot),
|
||||||
asNullableString(row.decisionRefreshedAt),
|
asNullableString(row.decisionRefreshedAt),
|
||||||
|
asNullableString(row.routingStrategy),
|
||||||
asBoolean(row.enabled, true),
|
asBoolean(row.enabled, true),
|
||||||
asNullableString(row.createdAt),
|
asNullableString(row.createdAt),
|
||||||
asNullableString(row.updatedAt),
|
asNullableString(row.updatedAt),
|
||||||
@@ -393,7 +394,7 @@ function buildStatements(snapshot: BackupSnapshot): InsertStatement[] {
|
|||||||
for (const row of snapshot.accounts.routeChannels) {
|
for (const row of snapshot.accounts.routeChannels) {
|
||||||
statements.push({
|
statements.push({
|
||||||
table: 'route_channels',
|
table: 'route_channels',
|
||||||
columns: ['id', 'route_id', 'account_id', 'token_id', 'source_model', 'priority', 'weight', 'enabled', 'manual_override', 'success_count', 'fail_count', 'total_latency_ms', 'total_cost', 'last_used_at', 'last_fail_at', 'cooldown_until'],
|
columns: ['id', 'route_id', 'account_id', 'token_id', 'source_model', 'priority', 'weight', 'enabled', 'manual_override', 'success_count', 'fail_count', 'total_latency_ms', 'total_cost', 'last_used_at', 'last_selected_at', 'last_fail_at', 'consecutive_fail_count', 'cooldown_level', 'cooldown_until'],
|
||||||
values: [
|
values: [
|
||||||
asNumber(row.id, 0),
|
asNumber(row.id, 0),
|
||||||
asNumber(row.routeId, 0),
|
asNumber(row.routeId, 0),
|
||||||
@@ -409,7 +410,10 @@ function buildStatements(snapshot: BackupSnapshot): InsertStatement[] {
|
|||||||
asNumber(row.totalLatencyMs, 0),
|
asNumber(row.totalLatencyMs, 0),
|
||||||
asNumber(row.totalCost, 0),
|
asNumber(row.totalCost, 0),
|
||||||
asNullableString(row.lastUsedAt),
|
asNullableString(row.lastUsedAt),
|
||||||
|
asNullableString(row.lastSelectedAt),
|
||||||
asNullableString(row.lastFailAt),
|
asNullableString(row.lastFailAt),
|
||||||
|
asNumber(row.consecutiveFailCount, 0),
|
||||||
|
asNumber(row.cooldownLevel, 0),
|
||||||
asNullableString(row.cooldownUntil),
|
asNullableString(row.cooldownUntil),
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
export type RouteRoutingStrategy = 'weighted' | 'round_robin';
|
||||||
|
|
||||||
|
export const DEFAULT_ROUTE_ROUTING_STRATEGY: RouteRoutingStrategy = 'weighted';
|
||||||
|
|
||||||
|
export function normalizeRouteRoutingStrategy(value: unknown): RouteRoutingStrategy {
|
||||||
|
const normalized = String(value || '').trim().toLowerCase();
|
||||||
|
if (normalized === 'round_robin') return 'round_robin';
|
||||||
|
return DEFAULT_ROUTE_ROUTING_STRATEGY;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isRoundRobinRouteRoutingStrategy(value: unknown): boolean {
|
||||||
|
return normalizeRouteRoutingStrategy(value) === 'round_robin';
|
||||||
|
}
|
||||||
@@ -127,6 +127,7 @@ describe('TokenRouter runtime cache', () => {
|
|||||||
|
|
||||||
const route = await db.insert(schema.tokenRoutes).values({
|
const route = await db.insert(schema.tokenRoutes).values({
|
||||||
modelPattern: 'gpt-4o-mini',
|
modelPattern: 'gpt-4o-mini',
|
||||||
|
routingStrategy: 'weighted',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
}).returning().get();
|
}).returning().get();
|
||||||
|
|
||||||
@@ -168,4 +169,154 @@ describe('TokenRouter runtime cache', () => {
|
|||||||
expect(thirdCooldownMs).toBeGreaterThanOrEqual(25_000);
|
expect(thirdCooldownMs).toBeGreaterThanOrEqual(25_000);
|
||||||
expect(thirdCooldownMs).toBeLessThanOrEqual(35_000);
|
expect(thirdCooldownMs).toBeLessThanOrEqual(35_000);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('round robins across all available channels regardless of priority', async () => {
|
||||||
|
const site = await db.insert(schema.sites).values({
|
||||||
|
name: 'round-robin-site',
|
||||||
|
url: 'https://round-robin-site.example.com',
|
||||||
|
platform: 'new-api',
|
||||||
|
status: 'active',
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const account = await db.insert(schema.accounts).values({
|
||||||
|
siteId: site.id,
|
||||||
|
username: 'round-robin-user',
|
||||||
|
accessToken: 'round-robin-access-token',
|
||||||
|
apiToken: 'round-robin-api-token',
|
||||||
|
status: 'active',
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const token = await db.insert(schema.accountTokens).values({
|
||||||
|
accountId: account.id,
|
||||||
|
name: 'round-robin-token',
|
||||||
|
token: 'sk-round-robin-token',
|
||||||
|
enabled: true,
|
||||||
|
isDefault: true,
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const route = await db.insert(schema.tokenRoutes).values({
|
||||||
|
modelPattern: 'gpt-4o-mini',
|
||||||
|
routingStrategy: 'round_robin',
|
||||||
|
enabled: true,
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const channels = await db.insert(schema.routeChannels).values([
|
||||||
|
{ routeId: route.id, accountId: account.id, tokenId: token.id, priority: 0, weight: 10, enabled: true },
|
||||||
|
{ routeId: route.id, accountId: account.id, tokenId: token.id, priority: 3, weight: 10, enabled: true },
|
||||||
|
{ routeId: route.id, accountId: account.id, tokenId: token.id, priority: 9, weight: 10, enabled: true },
|
||||||
|
]).returning().all();
|
||||||
|
|
||||||
|
const router = new TokenRouter();
|
||||||
|
|
||||||
|
const first = await router.selectChannel('gpt-4o-mini');
|
||||||
|
const second = await router.selectChannel('gpt-4o-mini');
|
||||||
|
const third = await router.selectChannel('gpt-4o-mini');
|
||||||
|
const fourth = await router.selectChannel('gpt-4o-mini');
|
||||||
|
|
||||||
|
expect(first?.channel.id).toBe(channels[0].id);
|
||||||
|
expect(second?.channel.id).toBe(channels[1].id);
|
||||||
|
expect(third?.channel.id).toBe(channels[2].id);
|
||||||
|
expect(fourth?.channel.id).toBe(channels[0].id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies staged cooldowns for round robin after every three consecutive failures', async () => {
|
||||||
|
const site = await db.insert(schema.sites).values({
|
||||||
|
name: 'round-robin-cooldown-site',
|
||||||
|
url: 'https://round-robin-cooldown-site.example.com',
|
||||||
|
platform: 'new-api',
|
||||||
|
status: 'active',
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const account = await db.insert(schema.accounts).values({
|
||||||
|
siteId: site.id,
|
||||||
|
username: 'round-robin-cooldown-user',
|
||||||
|
accessToken: 'round-robin-cooldown-access-token',
|
||||||
|
apiToken: 'round-robin-cooldown-api-token',
|
||||||
|
status: 'active',
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const token = await db.insert(schema.accountTokens).values({
|
||||||
|
accountId: account.id,
|
||||||
|
name: 'round-robin-cooldown-token',
|
||||||
|
token: 'sk-round-robin-cooldown-token',
|
||||||
|
enabled: true,
|
||||||
|
isDefault: true,
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const route = await db.insert(schema.tokenRoutes).values({
|
||||||
|
modelPattern: 'gpt-4o-mini',
|
||||||
|
routingStrategy: 'round_robin',
|
||||||
|
enabled: true,
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const channel = await db.insert(schema.routeChannels).values({
|
||||||
|
routeId: route.id,
|
||||||
|
accountId: account.id,
|
||||||
|
tokenId: token.id,
|
||||||
|
priority: 0,
|
||||||
|
weight: 10,
|
||||||
|
enabled: true,
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const router = new TokenRouter();
|
||||||
|
|
||||||
|
for (let index = 0; index < 2; index += 1) {
|
||||||
|
await router.recordFailure(channel.id);
|
||||||
|
}
|
||||||
|
let current = await db.select().from(schema.routeChannels)
|
||||||
|
.where(eq(schema.routeChannels.id, channel.id))
|
||||||
|
.get();
|
||||||
|
expect(current?.cooldownUntil).toBeNull();
|
||||||
|
expect(current?.consecutiveFailCount).toBe(2);
|
||||||
|
expect(current?.cooldownLevel).toBe(0);
|
||||||
|
|
||||||
|
let startedAt = Date.now();
|
||||||
|
await router.recordFailure(channel.id);
|
||||||
|
current = await db.select().from(schema.routeChannels)
|
||||||
|
.where(eq(schema.routeChannels.id, channel.id))
|
||||||
|
.get();
|
||||||
|
let cooldownMs = Date.parse(String(current?.cooldownUntil || '')) - startedAt;
|
||||||
|
expect(current?.consecutiveFailCount).toBe(0);
|
||||||
|
expect(current?.cooldownLevel).toBe(1);
|
||||||
|
expect(cooldownMs).toBeGreaterThanOrEqual(9 * 60 * 1000);
|
||||||
|
expect(cooldownMs).toBeLessThanOrEqual(11 * 60 * 1000);
|
||||||
|
|
||||||
|
await db.update(schema.routeChannels).set({ cooldownUntil: null }).where(eq(schema.routeChannels.id, channel.id)).run();
|
||||||
|
|
||||||
|
for (let index = 0; index < 2; index += 1) {
|
||||||
|
await router.recordFailure(channel.id);
|
||||||
|
}
|
||||||
|
startedAt = Date.now();
|
||||||
|
await router.recordFailure(channel.id);
|
||||||
|
current = await db.select().from(schema.routeChannels)
|
||||||
|
.where(eq(schema.routeChannels.id, channel.id))
|
||||||
|
.get();
|
||||||
|
cooldownMs = Date.parse(String(current?.cooldownUntil || '')) - startedAt;
|
||||||
|
expect(current?.cooldownLevel).toBe(2);
|
||||||
|
expect(cooldownMs).toBeGreaterThanOrEqual(59 * 60 * 1000);
|
||||||
|
expect(cooldownMs).toBeLessThanOrEqual(61 * 60 * 1000);
|
||||||
|
|
||||||
|
await db.update(schema.routeChannels).set({ cooldownUntil: null }).where(eq(schema.routeChannels.id, channel.id)).run();
|
||||||
|
|
||||||
|
for (let index = 0; index < 2; index += 1) {
|
||||||
|
await router.recordFailure(channel.id);
|
||||||
|
}
|
||||||
|
startedAt = Date.now();
|
||||||
|
await router.recordFailure(channel.id);
|
||||||
|
current = await db.select().from(schema.routeChannels)
|
||||||
|
.where(eq(schema.routeChannels.id, channel.id))
|
||||||
|
.get();
|
||||||
|
cooldownMs = Date.parse(String(current?.cooldownUntil || '')) - startedAt;
|
||||||
|
expect(current?.cooldownLevel).toBe(3);
|
||||||
|
expect(cooldownMs).toBeGreaterThanOrEqual(23 * 60 * 60 * 1000);
|
||||||
|
expect(cooldownMs).toBeLessThanOrEqual(25 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
await router.recordSuccess(channel.id, 320, 0.12);
|
||||||
|
current = await db.select().from(schema.routeChannels)
|
||||||
|
.where(eq(schema.routeChannels.id, channel.id))
|
||||||
|
.get();
|
||||||
|
expect(current?.consecutiveFailCount).toBe(0);
|
||||||
|
expect(current?.cooldownLevel).toBe(0);
|
||||||
|
expect(current?.cooldownUntil).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+258
-125
@@ -3,6 +3,11 @@ import { minimatch } from 'minimatch';
|
|||||||
import { db, schema } from '../db/index.js';
|
import { db, schema } from '../db/index.js';
|
||||||
import { config } from '../config.js';
|
import { config } from '../config.js';
|
||||||
import { getCachedModelRoutingReferenceCost, refreshModelPricingCatalog } from './modelPricingService.js';
|
import { getCachedModelRoutingReferenceCost, refreshModelPricingCatalog } from './modelPricingService.js';
|
||||||
|
import {
|
||||||
|
DEFAULT_ROUTE_ROUTING_STRATEGY,
|
||||||
|
normalizeRouteRoutingStrategy,
|
||||||
|
type RouteRoutingStrategy,
|
||||||
|
} from './routeRoutingStrategy.js';
|
||||||
import { type DownstreamRoutingPolicy, EMPTY_DOWNSTREAM_ROUTING_POLICY } from './downstreamPolicyTypes.js';
|
import { type DownstreamRoutingPolicy, EMPTY_DOWNSTREAM_ROUTING_POLICY } from './downstreamPolicyTypes.js';
|
||||||
|
|
||||||
interface RouteMatch {
|
interface RouteMatch {
|
||||||
@@ -34,6 +39,8 @@ type FailureAwareChannel = {
|
|||||||
|
|
||||||
const FAILURE_BACKOFF_BASE_SEC = 15;
|
const FAILURE_BACKOFF_BASE_SEC = 15;
|
||||||
const MIN_EFFECTIVE_UNIT_COST = 1e-6;
|
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;
|
||||||
|
|
||||||
function fibonacciNumber(index: number): number {
|
function fibonacciNumber(index: number): number {
|
||||||
if (index <= 2) return 1;
|
if (index <= 2) return 1;
|
||||||
@@ -52,6 +59,11 @@ function resolveFailureBackoffSec(failCount?: number | null): number {
|
|||||||
return FAILURE_BACKOFF_BASE_SEC * fibonacciNumber(normalizedFailCount);
|
return FAILURE_BACKOFF_BASE_SEC * fibonacciNumber(normalizedFailCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveRoundRobinCooldownSec(level: number): number {
|
||||||
|
const normalizedLevel = Math.max(0, Math.min(ROUND_ROBIN_COOLDOWN_LEVELS_SEC.length - 1, Math.trunc(level)));
|
||||||
|
return ROUND_ROBIN_COOLDOWN_LEVELS_SEC[normalizedLevel] ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
type RouteRow = typeof schema.tokenRoutes.$inferSelect;
|
type RouteRow = typeof schema.tokenRoutes.$inferSelect;
|
||||||
type ChannelRow = typeof schema.routeChannels.$inferSelect;
|
type ChannelRow = typeof schema.routeChannels.$inferSelect;
|
||||||
|
|
||||||
@@ -357,6 +369,25 @@ function resolveActualModelForSelectedChannel(
|
|||||||
return mappedModel;
|
return mappedModel;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveRouteStrategy(route: typeof schema.tokenRoutes.$inferSelect): RouteRoutingStrategy {
|
||||||
|
return normalizeRouteRoutingStrategy(route.routingStrategy);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseIsoTimeMs(value?: string | null): number | null {
|
||||||
|
if (!value) return null;
|
||||||
|
const parsed = Date.parse(value);
|
||||||
|
return Number.isNaN(parsed) ? null : parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareNullableTimeAsc(left?: string | null, right?: string | null): number {
|
||||||
|
const leftMs = parseIsoTimeMs(left);
|
||||||
|
const rightMs = parseIsoTimeMs(right);
|
||||||
|
if (leftMs == null && rightMs == null) return 0;
|
||||||
|
if (leftMs == null) return -1;
|
||||||
|
if (rightMs == null) return 1;
|
||||||
|
return leftMs - rightMs;
|
||||||
|
}
|
||||||
|
|
||||||
function resolveEffectiveUnitCost(candidate: RouteChannelCandidate, modelName: string): CostSignal {
|
function resolveEffectiveUnitCost(candidate: RouteChannelCandidate, modelName: string): CostSignal {
|
||||||
const successCount = Math.max(0, candidate.channel.successCount ?? 0);
|
const successCount = Math.max(0, candidate.channel.successCount ?? 0);
|
||||||
const totalCost = Math.max(0, candidate.channel.totalCost ?? 0);
|
const totalCost = Math.max(0, candidate.channel.totalCost ?? 0);
|
||||||
@@ -408,67 +439,7 @@ export class TokenRouter {
|
|||||||
|
|
||||||
const match = await this.findRoute(requestedModel, downstreamPolicy);
|
const match = await this.findRoute(requestedModel, downstreamPolicy);
|
||||||
if (!match) return null;
|
if (!match) return null;
|
||||||
|
return await this.selectFromMatch(match, requestedModel, downstreamPolicy);
|
||||||
const mappedModel = resolveMappedModel(requestedModel, match.route.modelMapping);
|
|
||||||
const requestedByDisplayName = isRouteDisplayNameMatch(requestedModel, match.route.displayName);
|
|
||||||
const bypassSourceModelCheck = requestedByDisplayName;
|
|
||||||
|
|
||||||
const nowIso = new Date().toISOString();
|
|
||||||
const nowMs = Date.now();
|
|
||||||
const available = match.channels.filter((candidate) => (
|
|
||||||
this.getCandidateEligibilityReasons(candidate, {
|
|
||||||
requestedModel,
|
|
||||||
bypassSourceModelCheck,
|
|
||||||
nowIso,
|
|
||||||
}).length === 0
|
|
||||||
));
|
|
||||||
|
|
||||||
if (available.length === 0) return null;
|
|
||||||
|
|
||||||
// Group by priority
|
|
||||||
const layers = new Map<number, typeof available>();
|
|
||||||
for (const c of available) {
|
|
||||||
const p = c.channel.priority ?? 0;
|
|
||||||
if (!layers.has(p)) layers.set(p, []);
|
|
||||||
layers.get(p)!.push(c);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort layers by priority (ascending = higher priority first)
|
|
||||||
const sortedPriorities = Array.from(layers.keys()).sort((a, b) => a - b);
|
|
||||||
|
|
||||||
// Try each priority layer
|
|
||||||
for (const priority of sortedPriorities) {
|
|
||||||
const candidates = filterRecentlyFailedCandidates(
|
|
||||||
layers.get(priority)!,
|
|
||||||
nowMs,
|
|
||||||
);
|
|
||||||
const selected = this.weightedRandomSelect(
|
|
||||||
candidates,
|
|
||||||
requestedByDisplayName
|
|
||||||
? (candidate) => normalizeChannelSourceModel(candidate.channel.sourceModel) || mappedModel
|
|
||||||
: mappedModel,
|
|
||||||
downstreamPolicy,
|
|
||||||
);
|
|
||||||
if (selected) {
|
|
||||||
const tokenValue = this.resolveChannelTokenValue(selected);
|
|
||||||
if (!tokenValue) continue;
|
|
||||||
const actualModel = resolveActualModelForSelectedChannel(
|
|
||||||
requestedModel,
|
|
||||||
match.route,
|
|
||||||
mappedModel,
|
|
||||||
selected.channel.sourceModel,
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
...selected,
|
|
||||||
tokenValue,
|
|
||||||
tokenName: selected.token?.name || 'default',
|
|
||||||
actualModel,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -483,64 +454,7 @@ export class TokenRouter {
|
|||||||
|
|
||||||
const match = await this.findRoute(requestedModel, downstreamPolicy);
|
const match = await this.findRoute(requestedModel, downstreamPolicy);
|
||||||
if (!match) return null;
|
if (!match) return null;
|
||||||
|
return await this.selectFromMatch(match, requestedModel, downstreamPolicy, excludeChannelIds);
|
||||||
const mappedModel = resolveMappedModel(requestedModel, match.route.modelMapping);
|
|
||||||
const requestedByDisplayName = isRouteDisplayNameMatch(requestedModel, match.route.displayName);
|
|
||||||
const bypassSourceModelCheck = requestedByDisplayName;
|
|
||||||
|
|
||||||
const nowIso = new Date().toISOString();
|
|
||||||
const nowMs = Date.now();
|
|
||||||
const available = match.channels.filter((candidate) => (
|
|
||||||
this.getCandidateEligibilityReasons(candidate, {
|
|
||||||
requestedModel,
|
|
||||||
bypassSourceModelCheck,
|
|
||||||
excludeChannelIds,
|
|
||||||
nowIso,
|
|
||||||
}).length === 0
|
|
||||||
));
|
|
||||||
|
|
||||||
if (available.length === 0) return null;
|
|
||||||
|
|
||||||
const layers = new Map<number, typeof available>();
|
|
||||||
for (const c of available) {
|
|
||||||
const p = c.channel.priority ?? 0;
|
|
||||||
if (!layers.has(p)) layers.set(p, []);
|
|
||||||
layers.get(p)!.push(c);
|
|
||||||
}
|
|
||||||
|
|
||||||
const sortedPriorities = Array.from(layers.keys()).sort((a, b) => a - b);
|
|
||||||
for (const priority of sortedPriorities) {
|
|
||||||
const candidates = filterRecentlyFailedCandidates(
|
|
||||||
layers.get(priority)!,
|
|
||||||
nowMs,
|
|
||||||
);
|
|
||||||
const selected = this.weightedRandomSelect(
|
|
||||||
candidates,
|
|
||||||
requestedByDisplayName
|
|
||||||
? (candidate) => normalizeChannelSourceModel(candidate.channel.sourceModel) || mappedModel
|
|
||||||
: mappedModel,
|
|
||||||
downstreamPolicy,
|
|
||||||
);
|
|
||||||
if (!selected) continue;
|
|
||||||
|
|
||||||
const tokenValue = this.resolveChannelTokenValue(selected);
|
|
||||||
if (!tokenValue) continue;
|
|
||||||
const actualModel = resolveActualModelForSelectedChannel(
|
|
||||||
requestedModel,
|
|
||||||
match.route,
|
|
||||||
mappedModel,
|
|
||||||
selected.channel.sourceModel,
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
...selected,
|
|
||||||
tokenValue,
|
|
||||||
tokenName: selected.token?.name || 'default',
|
|
||||||
actualModel,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async explainSelection(
|
async explainSelection(
|
||||||
@@ -626,10 +540,14 @@ export class TokenRouter {
|
|||||||
const bypassSourceModelCheck = (options.bypassSourceModelCheck ?? false) || requestedByDisplayName;
|
const bypassSourceModelCheck = (options.bypassSourceModelCheck ?? false) || requestedByDisplayName;
|
||||||
const useChannelSourceModelForCost = (options.useChannelSourceModelForCost ?? false) || requestedByDisplayName;
|
const useChannelSourceModelForCost = (options.useChannelSourceModelForCost ?? false) || requestedByDisplayName;
|
||||||
const mappedModel = resolveMappedModel(requestedModel, match.route.modelMapping);
|
const mappedModel = resolveMappedModel(requestedModel, match.route.modelMapping);
|
||||||
|
const routeStrategy = resolveRouteStrategy(match.route);
|
||||||
|
|
||||||
const nowIso = new Date().toISOString();
|
const nowIso = new Date().toISOString();
|
||||||
const nowMs = Date.now();
|
const nowMs = Date.now();
|
||||||
const summary: string[] = [`命中路由:${match.route.modelPattern}`];
|
const summary: string[] = [
|
||||||
|
`命中路由:${match.route.modelPattern}`,
|
||||||
|
routeStrategy === 'round_robin' ? '路由策略:轮询' : '路由策略:按权重随机',
|
||||||
|
];
|
||||||
if (requestedByDisplayName) {
|
if (requestedByDisplayName) {
|
||||||
summary.push(`按显示名命中:${normalizeRouteDisplayName(match.route.displayName)}`);
|
summary.push(`按显示名命中:${normalizeRouteDisplayName(match.route.displayName)}`);
|
||||||
summary.push('显示名仅用于聚合展示,实际转发模型按选中通道来源模型决定');
|
summary.push('显示名仅用于聚合展示,实际转发模型按选中通道来源模型决定');
|
||||||
@@ -646,7 +564,9 @@ export class TokenRouter {
|
|||||||
nowIso,
|
nowIso,
|
||||||
});
|
});
|
||||||
|
|
||||||
const recentlyFailed = isChannelRecentlyFailed(row.channel, nowMs);
|
const recentlyFailed = routeStrategy === DEFAULT_ROUTE_ROUTING_STRATEGY
|
||||||
|
? isChannelRecentlyFailed(row.channel, nowMs)
|
||||||
|
: false;
|
||||||
const eligible = reasonParts.length === 0;
|
const eligible = reasonParts.length === 0;
|
||||||
const candidate: RouteDecisionCandidate = {
|
const candidate: RouteDecisionCandidate = {
|
||||||
channelId: row.channel.id,
|
channelId: row.channel.id,
|
||||||
@@ -685,6 +605,68 @@ export class TokenRouter {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (routeStrategy === 'round_robin') {
|
||||||
|
const ordered = this.getRoundRobinCandidates(match.channels.filter((row) => {
|
||||||
|
const target = candidateMap.get(row.channel.id);
|
||||||
|
return !!target?.eligible;
|
||||||
|
}));
|
||||||
|
let selected: RouteChannelCandidate | null = null;
|
||||||
|
|
||||||
|
for (let index = 0; index < ordered.length; index += 1) {
|
||||||
|
const target = candidateMap.get(ordered[index].channel.id);
|
||||||
|
if (!target || !target.eligible) continue;
|
||||||
|
target.probability = index === 0 ? 100 : 0;
|
||||||
|
target.reason = index === 0
|
||||||
|
? `轮询命中(全局第 1 / ${ordered.length} 位,忽略优先级)`
|
||||||
|
: `轮询排队中(全局第 ${index + 1} / ${ordered.length} 位,忽略优先级)`;
|
||||||
|
if (index === 0) {
|
||||||
|
selected = ordered[index];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!selected) {
|
||||||
|
summary.push('本次未选出通道');
|
||||||
|
return {
|
||||||
|
requestedModel,
|
||||||
|
actualModel: mappedModel,
|
||||||
|
matched: true,
|
||||||
|
routeId: match.route.id,
|
||||||
|
modelPattern: match.route.modelPattern,
|
||||||
|
summary,
|
||||||
|
candidates,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedChannel = candidateMap.get(selected.channel.id);
|
||||||
|
const selectedLabel = selectedChannel
|
||||||
|
? `${selectedChannel.username} @ ${selectedChannel.siteName} / ${selectedChannel.tokenName}`
|
||||||
|
: `channel-${selected.channel.id}`;
|
||||||
|
const actualModel = resolveActualModelForSelectedChannel(
|
||||||
|
requestedModel,
|
||||||
|
match.route,
|
||||||
|
mappedModel,
|
||||||
|
selected.channel.sourceModel,
|
||||||
|
);
|
||||||
|
summary.push(`全局轮询:可用 ${ordered.length},忽略优先级`);
|
||||||
|
summary.push(`最终选择:${selectedLabel}`);
|
||||||
|
if (actualModel !== mappedModel) {
|
||||||
|
summary.push(`实际转发模型:${actualModel}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
requestedModel,
|
||||||
|
actualModel,
|
||||||
|
matched: true,
|
||||||
|
routeId: match.route.id,
|
||||||
|
modelPattern: match.route.modelPattern,
|
||||||
|
selectedChannelId: selected.channel.id,
|
||||||
|
selectedAccountId: selected.account.id,
|
||||||
|
selectedLabel,
|
||||||
|
summary,
|
||||||
|
candidates,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const sortedPriorities = Array.from(availableByPriority.keys()).sort((a, b) => a - b);
|
const sortedPriorities = Array.from(availableByPriority.keys()).sort((a, b) => a - b);
|
||||||
let selected: RouteChannelCandidate | null = null;
|
let selected: RouteChannelCandidate | null = null;
|
||||||
let selectedPriority = 0;
|
let selectedPriority = 0;
|
||||||
@@ -829,6 +811,8 @@ export class TokenRouter {
|
|||||||
lastUsedAt: nowIso,
|
lastUsedAt: nowIso,
|
||||||
cooldownUntil: null,
|
cooldownUntil: null,
|
||||||
lastFailAt: null,
|
lastFailAt: null,
|
||||||
|
consecutiveFailCount: 0,
|
||||||
|
cooldownLevel: 0,
|
||||||
}).where(eq(schema.routeChannels.id, channelId)).run();
|
}).where(eq(schema.routeChannels.id, channelId)).run();
|
||||||
|
|
||||||
patchCachedChannel(channelId, (channel) => {
|
patchCachedChannel(channelId, (channel) => {
|
||||||
@@ -838,6 +822,8 @@ export class TokenRouter {
|
|||||||
channel.lastUsedAt = nowIso;
|
channel.lastUsedAt = nowIso;
|
||||||
channel.cooldownUntil = null;
|
channel.cooldownUntil = null;
|
||||||
channel.lastFailAt = null;
|
channel.lastFailAt = null;
|
||||||
|
channel.consecutiveFailCount = 0;
|
||||||
|
channel.cooldownLevel = 0;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -845,15 +831,42 @@ export class TokenRouter {
|
|||||||
* Record failure and set cooldown.
|
* Record failure and set cooldown.
|
||||||
*/
|
*/
|
||||||
async recordFailure(channelId: number) {
|
async recordFailure(channelId: number) {
|
||||||
const ch = await db.select().from(schema.routeChannels).where(eq(schema.routeChannels.id, channelId)).get();
|
const row = await db.select()
|
||||||
if (!ch) return;
|
.from(schema.routeChannels)
|
||||||
|
.innerJoin(schema.tokenRoutes, eq(schema.routeChannels.routeId, schema.tokenRoutes.id))
|
||||||
|
.where(eq(schema.routeChannels.id, channelId))
|
||||||
|
.get();
|
||||||
|
if (!row) return;
|
||||||
|
|
||||||
|
const ch = row.route_channels;
|
||||||
|
const route = row.token_routes;
|
||||||
|
const nowMs = Date.now();
|
||||||
|
const nowIso = new Date(nowMs).toISOString();
|
||||||
const failCount = (ch.failCount ?? 0) + 1;
|
const failCount = (ch.failCount ?? 0) + 1;
|
||||||
|
const routeStrategy = resolveRouteStrategy(route);
|
||||||
|
let cooldownUntil: string | null = null;
|
||||||
|
let consecutiveFailCount = Math.max(0, ch.consecutiveFailCount ?? 0) + 1;
|
||||||
|
let cooldownLevel = Math.max(0, ch.cooldownLevel ?? 0);
|
||||||
|
|
||||||
|
if (routeStrategy === 'round_robin') {
|
||||||
|
if (consecutiveFailCount >= ROUND_ROBIN_FAILURE_THRESHOLD) {
|
||||||
|
cooldownLevel = Math.min(cooldownLevel + 1, ROUND_ROBIN_COOLDOWN_LEVELS_SEC.length - 1);
|
||||||
|
const cooldownSec = resolveRoundRobinCooldownSec(cooldownLevel);
|
||||||
|
cooldownUntil = cooldownSec > 0 ? new Date(nowMs + cooldownSec * 1000).toISOString() : null;
|
||||||
|
consecutiveFailCount = 0;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
const cooldownSec = resolveFailureBackoffSec(failCount);
|
const cooldownSec = resolveFailureBackoffSec(failCount);
|
||||||
const cooldownUntil = new Date(Date.now() + cooldownSec * 1000).toISOString();
|
cooldownUntil = new Date(nowMs + cooldownSec * 1000).toISOString();
|
||||||
const nowIso = new Date().toISOString();
|
consecutiveFailCount = 0;
|
||||||
|
cooldownLevel = 0;
|
||||||
|
}
|
||||||
|
|
||||||
await db.update(schema.routeChannels).set({
|
await db.update(schema.routeChannels).set({
|
||||||
failCount,
|
failCount,
|
||||||
lastFailAt: nowIso,
|
lastFailAt: nowIso,
|
||||||
|
consecutiveFailCount,
|
||||||
|
cooldownLevel,
|
||||||
cooldownUntil,
|
cooldownUntil,
|
||||||
}).where(eq(schema.routeChannels.id, channelId)).run();
|
}).where(eq(schema.routeChannels.id, channelId)).run();
|
||||||
|
|
||||||
@@ -861,6 +874,8 @@ export class TokenRouter {
|
|||||||
channel.failCount = failCount;
|
channel.failCount = failCount;
|
||||||
channel.lastFailAt = nowIso;
|
channel.lastFailAt = nowIso;
|
||||||
channel.cooldownUntil = cooldownUntil;
|
channel.cooldownUntil = cooldownUntil;
|
||||||
|
channel.consecutiveFailCount = consecutiveFailCount;
|
||||||
|
channel.cooldownLevel = cooldownLevel;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -877,6 +892,94 @@ export class TokenRouter {
|
|||||||
|
|
||||||
// --- Private methods ---
|
// --- Private methods ---
|
||||||
|
|
||||||
|
private async selectFromMatch(
|
||||||
|
match: RouteMatch,
|
||||||
|
requestedModel: string,
|
||||||
|
downstreamPolicy: DownstreamRoutingPolicy,
|
||||||
|
excludeChannelIds: number[] = [],
|
||||||
|
): Promise<SelectedChannel | null> {
|
||||||
|
const mappedModel = resolveMappedModel(requestedModel, match.route.modelMapping);
|
||||||
|
const requestedByDisplayName = isRouteDisplayNameMatch(requestedModel, match.route.displayName);
|
||||||
|
const bypassSourceModelCheck = requestedByDisplayName;
|
||||||
|
const routeStrategy = resolveRouteStrategy(match.route);
|
||||||
|
|
||||||
|
const nowIso = new Date().toISOString();
|
||||||
|
const nowMs = Date.now();
|
||||||
|
const available = match.channels.filter((candidate) => (
|
||||||
|
this.getCandidateEligibilityReasons(candidate, {
|
||||||
|
requestedModel,
|
||||||
|
bypassSourceModelCheck,
|
||||||
|
excludeChannelIds,
|
||||||
|
nowIso,
|
||||||
|
}).length === 0
|
||||||
|
));
|
||||||
|
|
||||||
|
if (available.length === 0) return null;
|
||||||
|
|
||||||
|
if (routeStrategy === 'round_robin') {
|
||||||
|
const selected = this.selectRoundRobinCandidate(available);
|
||||||
|
if (!selected) return null;
|
||||||
|
|
||||||
|
const tokenValue = this.resolveChannelTokenValue(selected);
|
||||||
|
if (!tokenValue) return null;
|
||||||
|
await this.recordChannelSelection(selected.channel.id);
|
||||||
|
|
||||||
|
const actualModel = resolveActualModelForSelectedChannel(
|
||||||
|
requestedModel,
|
||||||
|
match.route,
|
||||||
|
mappedModel,
|
||||||
|
selected.channel.sourceModel,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...selected,
|
||||||
|
tokenValue,
|
||||||
|
tokenName: selected.token?.name || 'default',
|
||||||
|
actualModel,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const layers = new Map<number, typeof available>();
|
||||||
|
for (const candidate of available) {
|
||||||
|
const priority = candidate.channel.priority ?? 0;
|
||||||
|
if (!layers.has(priority)) layers.set(priority, []);
|
||||||
|
layers.get(priority)!.push(candidate);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortedPriorities = Array.from(layers.keys()).sort((a, b) => a - b);
|
||||||
|
for (const priority of sortedPriorities) {
|
||||||
|
const rawLayer = layers.get(priority) ?? [];
|
||||||
|
const candidates = filterRecentlyFailedCandidates(rawLayer, nowMs);
|
||||||
|
const selected = this.weightedRandomSelect(
|
||||||
|
candidates,
|
||||||
|
requestedByDisplayName
|
||||||
|
? (candidate) => normalizeChannelSourceModel(candidate.channel.sourceModel) || mappedModel
|
||||||
|
: mappedModel,
|
||||||
|
downstreamPolicy,
|
||||||
|
);
|
||||||
|
if (!selected) continue;
|
||||||
|
|
||||||
|
const tokenValue = this.resolveChannelTokenValue(selected);
|
||||||
|
if (!tokenValue) continue;
|
||||||
|
|
||||||
|
const actualModel = resolveActualModelForSelectedChannel(
|
||||||
|
requestedModel,
|
||||||
|
match.route,
|
||||||
|
mappedModel,
|
||||||
|
selected.channel.sourceModel,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...selected,
|
||||||
|
tokenValue,
|
||||||
|
tokenName: selected.token?.name || 'default',
|
||||||
|
actualModel,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
private async findRoute(model: string, downstreamPolicy: DownstreamRoutingPolicy): Promise<RouteMatch | null> {
|
private async findRoute(model: string, downstreamPolicy: DownstreamRoutingPolicy): Promise<RouteMatch | null> {
|
||||||
let routes = await loadEnabledRoutes();
|
let routes = await loadEnabledRoutes();
|
||||||
|
|
||||||
@@ -973,6 +1076,36 @@ export class TokenRouter {
|
|||||||
return reasonParts;
|
return reasonParts;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getRoundRobinCandidates(candidates: RouteChannelCandidate[]): RouteChannelCandidate[] {
|
||||||
|
return [...candidates].sort((left, right) => {
|
||||||
|
const selectionOrder = compareNullableTimeAsc(
|
||||||
|
left.channel.lastSelectedAt || left.channel.lastUsedAt,
|
||||||
|
right.channel.lastSelectedAt || right.channel.lastUsedAt,
|
||||||
|
);
|
||||||
|
if (selectionOrder !== 0) return selectionOrder;
|
||||||
|
|
||||||
|
const usedOrder = compareNullableTimeAsc(left.channel.lastUsedAt, right.channel.lastUsedAt);
|
||||||
|
if (usedOrder !== 0) return usedOrder;
|
||||||
|
|
||||||
|
return (left.channel.id ?? 0) - (right.channel.id ?? 0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private selectRoundRobinCandidate(candidates: RouteChannelCandidate[]): RouteChannelCandidate | null {
|
||||||
|
return this.getRoundRobinCandidates(candidates)[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async recordChannelSelection(channelId: number): Promise<void> {
|
||||||
|
const nowIso = new Date().toISOString();
|
||||||
|
await db.update(schema.routeChannels).set({
|
||||||
|
lastSelectedAt: nowIso,
|
||||||
|
}).where(eq(schema.routeChannels.id, channelId)).run();
|
||||||
|
|
||||||
|
patchCachedChannel(channelId, (channel) => {
|
||||||
|
channel.lastSelectedAt = nowIso;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private weightedRandomSelect(
|
private weightedRandomSelect(
|
||||||
candidates: RouteChannelCandidate[],
|
candidates: RouteChannelCandidate[],
|
||||||
modelName: string | ((candidate: RouteChannelCandidate) => string),
|
modelName: string | ((candidate: RouteChannelCandidate) => string),
|
||||||
|
|||||||
@@ -190,4 +190,134 @@ describe('Models marketplace text', () => {
|
|||||||
root?.unmount();
|
root?.unmount();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('limits expanded account and pricing detail to the selected site filter', async () => {
|
||||||
|
apiMock.getModelsMarketplace.mockResolvedValue({
|
||||||
|
models: [
|
||||||
|
{
|
||||||
|
name: 'gpt-4o',
|
||||||
|
accountCount: 2,
|
||||||
|
tokenCount: 3,
|
||||||
|
avgLatency: 500,
|
||||||
|
successRate: 96,
|
||||||
|
description: 'demo model',
|
||||||
|
tags: ['chat'],
|
||||||
|
supportedEndpointTypes: ['openai'],
|
||||||
|
pricingSources: [
|
||||||
|
{
|
||||||
|
siteId: 1,
|
||||||
|
siteName: '站点 A',
|
||||||
|
accountId: 1,
|
||||||
|
username: 'user-a',
|
||||||
|
ownerBy: null,
|
||||||
|
enableGroups: [],
|
||||||
|
groupPricing: {
|
||||||
|
default: {
|
||||||
|
quotaType: 0,
|
||||||
|
inputPerMillion: 1,
|
||||||
|
outputPerMillion: 2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
siteId: 2,
|
||||||
|
siteName: '站点 B',
|
||||||
|
accountId: 2,
|
||||||
|
username: 'user-b',
|
||||||
|
ownerBy: null,
|
||||||
|
enableGroups: [],
|
||||||
|
groupPricing: {
|
||||||
|
default: {
|
||||||
|
quotaType: 0,
|
||||||
|
inputPerMillion: 3,
|
||||||
|
outputPerMillion: 4,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
accounts: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
site: '站点 A',
|
||||||
|
username: 'user-a',
|
||||||
|
latency: 320,
|
||||||
|
balance: 12.5,
|
||||||
|
tokens: [
|
||||||
|
{ id: 1, name: 'token-a-1', isDefault: true },
|
||||||
|
{ id: 2, name: 'token-a-2', isDefault: false },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
site: '站点 B',
|
||||||
|
username: 'user-b',
|
||||||
|
latency: 680,
|
||||||
|
balance: 8.4,
|
||||||
|
tokens: [
|
||||||
|
{ id: 3, name: 'token-b-1', isDefault: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
let root: ReturnType<typeof create> | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await act(async () => {
|
||||||
|
root = create(
|
||||||
|
<MemoryRouter initialEntries={['/models']}>
|
||||||
|
<ToastProvider>
|
||||||
|
<Models />
|
||||||
|
</ToastProvider>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
const siteFilterItem = root!.root.find((node) => (
|
||||||
|
node.type === 'div'
|
||||||
|
&& typeof node.props.className === 'string'
|
||||||
|
&& node.props.className.includes('filter-item')
|
||||||
|
&& typeof node.props.onClick === 'function'
|
||||||
|
&& collectText(node).includes('站点 A')
|
||||||
|
));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
siteFilterItem.props.onClick();
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
const cards = root!.root.findAll((node) => (
|
||||||
|
node.type === 'div'
|
||||||
|
&& typeof node.props.className === 'string'
|
||||||
|
&& node.props.className.includes('model-card')
|
||||||
|
&& typeof node.props.onClick === 'function'
|
||||||
|
));
|
||||||
|
expect(cards.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
cards[0]!.props.onClick();
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
const expandedSections = root!.root.findAll((node) => (
|
||||||
|
node.type === 'div'
|
||||||
|
&& typeof node.props.className === 'string'
|
||||||
|
&& node.props.className.includes('model-card-expand')
|
||||||
|
));
|
||||||
|
expect(expandedSections.length).toBe(1);
|
||||||
|
|
||||||
|
const expandedText = collectText(expandedSections[0]!);
|
||||||
|
expect(expandedText).toContain('站点 A');
|
||||||
|
expect(expandedText).toContain('user-a');
|
||||||
|
expect(expandedText).toContain('token-a-1');
|
||||||
|
expect(expandedText).not.toContain('站点 B');
|
||||||
|
expect(expandedText).not.toContain('user-b');
|
||||||
|
expect(expandedText).not.toContain('token-b-1');
|
||||||
|
} finally {
|
||||||
|
root?.unmount();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -292,26 +292,50 @@ export default function Models() {
|
|||||||
});
|
});
|
||||||
}, [data.models, search, activeSite, activeBrand, sortBy, sortDir]);
|
}, [data.models, search, activeSite, activeBrand, sortBy, sortDir]);
|
||||||
|
|
||||||
|
// Keep expanded detail consistent with filters (especially site filter).
|
||||||
|
// The list-level filter uses "model has at least one account on this site" semantics;
|
||||||
|
// once a model is shown, its detail should honor the active site as well.
|
||||||
|
const detailModels = useMemo(() => {
|
||||||
|
if (!activeSite) return filteredModels;
|
||||||
|
return filteredModels.map((model) => {
|
||||||
|
const accounts = model.accounts.filter((account) => account.site === activeSite);
|
||||||
|
const pricingSources = model.pricingSources.filter((source) => source.siteName === activeSite);
|
||||||
|
const latencyValues = accounts
|
||||||
|
.map((account) => account.latency)
|
||||||
|
.filter((latency): latency is number => typeof latency === 'number' && Number.isFinite(latency));
|
||||||
|
return {
|
||||||
|
...model,
|
||||||
|
accounts,
|
||||||
|
pricingSources,
|
||||||
|
accountCount: accounts.length,
|
||||||
|
tokenCount: accounts.reduce((sum, account) => sum + account.tokens.length, 0),
|
||||||
|
avgLatency: latencyValues.length > 0
|
||||||
|
? Math.round(latencyValues.reduce((sum, latency) => sum + latency, 0) / latencyValues.length)
|
||||||
|
: model.avgLatency,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [filteredModels, activeSite]);
|
||||||
|
|
||||||
/* ---- pagination ---- */
|
/* ---- pagination ---- */
|
||||||
const totalPages = Math.max(1, Math.ceil(filteredModels.length / pageSize));
|
const totalPages = Math.max(1, Math.ceil(filteredModels.length / pageSize));
|
||||||
const safePageVal = Math.min(page, totalPages);
|
const safePageVal = Math.min(page, totalPages);
|
||||||
const paged = filteredModels.slice((safePageVal - 1) * pageSize, safePageVal * pageSize);
|
const paged = detailModels.slice((safePageVal - 1) * pageSize, safePageVal * pageSize);
|
||||||
|
|
||||||
useEffect(() => { setPage(1); }, [search, activeSite, activeBrand, pageSize]);
|
useEffect(() => { setPage(1); }, [search, activeSite, activeBrand, pageSize]);
|
||||||
|
|
||||||
/* ---- stats ---- */
|
/* ---- stats ---- */
|
||||||
const totalCoverageSlots = filteredModels.reduce((s, m) => s + m.accountCount, 0);
|
const totalCoverageSlots = detailModels.reduce((s, m) => s + m.accountCount, 0);
|
||||||
const uniqueAccountCount = (() => {
|
const uniqueAccountCount = (() => {
|
||||||
const ids = new Set<number>();
|
const ids = new Set<number>();
|
||||||
for (const model of filteredModels) {
|
for (const model of detailModels) {
|
||||||
for (const account of model.accounts) {
|
for (const account of model.accounts) {
|
||||||
ids.add(account.id);
|
ids.add(account.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return ids.size;
|
return ids.size;
|
||||||
})();
|
})();
|
||||||
const avgLatency = filteredModels.length
|
const avgLatency = detailModels.length
|
||||||
? Math.round(filteredModels.reduce((s, m) => s + m.avgLatency, 0) / filteredModels.length)
|
? Math.round(detailModels.reduce((s, m) => s + m.avgLatency, 0) / detailModels.length)
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
/* ---- copy ---- */
|
/* ---- copy ---- */
|
||||||
@@ -898,5 +922,3 @@ export default function Models() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import type {
|
|||||||
RouteSortDir,
|
RouteSortDir,
|
||||||
GroupFilter,
|
GroupFilter,
|
||||||
RouteSummaryRow,
|
RouteSummaryRow,
|
||||||
|
RouteRoutingStrategy,
|
||||||
RouteDecision,
|
RouteDecision,
|
||||||
RouteIconOption,
|
RouteIconOption,
|
||||||
MissingTokenRouteSiteActionItem,
|
MissingTokenRouteSiteActionItem,
|
||||||
@@ -88,6 +89,7 @@ export default function TokenRoutes() {
|
|||||||
const [channelTokenDraft, setChannelTokenDraft] = useState<Record<number, number>>({});
|
const [channelTokenDraft, setChannelTokenDraft] = useState<Record<number, number>>({});
|
||||||
const [updatingChannel, setUpdatingChannel] = useState<Record<number, boolean>>({});
|
const [updatingChannel, setUpdatingChannel] = useState<Record<number, boolean>>({});
|
||||||
const [savingPriorityByRoute, setSavingPriorityByRoute] = useState<Record<number, boolean>>({});
|
const [savingPriorityByRoute, setSavingPriorityByRoute] = useState<Record<number, boolean>>({});
|
||||||
|
const [updatingRoutingStrategyByRoute, setUpdatingRoutingStrategyByRoute] = useState<Record<number, boolean>>({});
|
||||||
|
|
||||||
const [decisionByRoute, setDecisionByRoute] = useState<Record<number, RouteDecision | null>>({});
|
const [decisionByRoute, setDecisionByRoute] = useState<Record<number, RouteDecision | null>>({});
|
||||||
const [loadingDecision, setLoadingDecision] = useState(false);
|
const [loadingDecision, setLoadingDecision] = useState(false);
|
||||||
@@ -349,6 +351,32 @@ export default function TokenRoutes() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleRoutingStrategyChange = async (route: RouteSummaryRow, routingStrategy: RouteRoutingStrategy) => {
|
||||||
|
const currentStrategy = route.routingStrategy === 'round_robin' ? 'round_robin' : 'weighted';
|
||||||
|
if (routingStrategy === currentStrategy) return;
|
||||||
|
|
||||||
|
setUpdatingRoutingStrategyByRoute((prev) => ({ ...prev, [route.id]: true }));
|
||||||
|
setRouteSummaries((prev) => prev.map((item) => (
|
||||||
|
item.id === route.id
|
||||||
|
? { ...item, routingStrategy }
|
||||||
|
: item
|
||||||
|
)));
|
||||||
|
try {
|
||||||
|
await api.updateRoute(route.id, { routingStrategy });
|
||||||
|
toast.success(routingStrategy === 'round_robin' ? '已切换为轮询策略' : '已切换为权重随机策略');
|
||||||
|
await load();
|
||||||
|
} catch (e: any) {
|
||||||
|
setRouteSummaries((prev) => prev.map((item) => (
|
||||||
|
item.id === route.id
|
||||||
|
? { ...item, routingStrategy: currentStrategy }
|
||||||
|
: item
|
||||||
|
)));
|
||||||
|
toast.error(e.message || '更新路由策略失败');
|
||||||
|
} finally {
|
||||||
|
setUpdatingRoutingStrategyByRoute((prev) => ({ ...prev, [route.id]: false }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Stable derived value: only changes when route patterns change (not on enabled toggle)
|
// Stable derived value: only changes when route patterns change (not on enabled toggle)
|
||||||
const routePatterns = useMemo(
|
const routePatterns = useMemo(
|
||||||
() => routeSummaries.map((r) => ({ id: r.id, modelPattern: r.modelPattern })),
|
() => routeSummaries.map((r) => ({ id: r.id, modelPattern: r.modelPattern })),
|
||||||
@@ -761,6 +789,12 @@ export default function TokenRoutes() {
|
|||||||
const handleToggleEnabledRef = useRef(handleToggleRouteEnabled);
|
const handleToggleEnabledRef = useRef(handleToggleRouteEnabled);
|
||||||
handleToggleEnabledRef.current = handleToggleRouteEnabled;
|
handleToggleEnabledRef.current = handleToggleRouteEnabled;
|
||||||
const stableToggleEnabled = useCallback((route: RouteSummaryRow) => { handleToggleEnabledRef.current(route); }, []);
|
const stableToggleEnabled = useCallback((route: RouteSummaryRow) => { handleToggleEnabledRef.current(route); }, []);
|
||||||
|
const handleRoutingStrategyChangeRef = useRef(handleRoutingStrategyChange);
|
||||||
|
handleRoutingStrategyChangeRef.current = handleRoutingStrategyChange;
|
||||||
|
const stableRoutingStrategyChange = useCallback(
|
||||||
|
(route: RouteSummaryRow, strategy: RouteRoutingStrategy) => handleRoutingStrategyChangeRef.current(route, strategy),
|
||||||
|
[],
|
||||||
|
);
|
||||||
const stableTokenDraftChange = useCallback(
|
const stableTokenDraftChange = useCallback(
|
||||||
(channelId: number, tokenId: number) => setChannelTokenDraft((prev) => ({ ...prev, [channelId]: tokenId })),
|
(channelId: number, tokenId: number) => setChannelTokenDraft((prev) => ({ ...prev, [channelId]: tokenId })),
|
||||||
[],
|
[],
|
||||||
@@ -995,6 +1029,7 @@ export default function TokenRoutes() {
|
|||||||
>
|
>
|
||||||
<MobileField label="模型" value={route.modelPattern} />
|
<MobileField label="模型" value={route.modelPattern} />
|
||||||
<MobileField label="通道" value={route.channelCount} />
|
<MobileField label="通道" value={route.channelCount} />
|
||||||
|
<MobileField label="策略" value={route.routingStrategy === 'round_robin' ? tr('轮询') : tr('权重随机')} />
|
||||||
<MobileField label="状态" value={route.enabled ? tr('启用') : tr('禁用')} />
|
<MobileField label="状态" value={route.enabled ? tr('启用') : tr('禁用')} />
|
||||||
<div className="mobile-card-actions">
|
<div className="mobile-card-actions">
|
||||||
<button
|
<button
|
||||||
@@ -1019,6 +1054,8 @@ export default function TokenRoutes() {
|
|||||||
onEdit={stableEditRoute}
|
onEdit={stableEditRoute}
|
||||||
onDelete={stableDeleteRoute}
|
onDelete={stableDeleteRoute}
|
||||||
onToggleEnabled={stableToggleEnabled}
|
onToggleEnabled={stableToggleEnabled}
|
||||||
|
onRoutingStrategyChange={stableRoutingStrategyChange}
|
||||||
|
updatingRoutingStrategy={!!updatingRoutingStrategyByRoute[route.id]}
|
||||||
channels={channelsByRouteId[route.id]}
|
channels={channelsByRouteId[route.id]}
|
||||||
loadingChannels={!!loadingChannelsByRouteId[route.id]}
|
loadingChannels={!!loadingChannelsByRouteId[route.id]}
|
||||||
routeDecision={decisionByRoute[route.id] || null}
|
routeDecision={decisionByRoute[route.id] || null}
|
||||||
|
|||||||
@@ -6,6 +6,11 @@ import { useToast } from '../../components/Toast.js';
|
|||||||
import { tr } from '../../i18n.js';
|
import { tr } from '../../i18n.js';
|
||||||
import type { RouteCandidateView, RouteAccountOption, RouteTokenOption } from '../helpers/routeModelCandidatesIndex.js';
|
import type { RouteCandidateView, RouteAccountOption, RouteTokenOption } from '../helpers/routeModelCandidatesIndex.js';
|
||||||
import type { RouteMissingTokenHint } from '../helpers/routeMissingTokenHints.js';
|
import type { RouteMissingTokenHint } from '../helpers/routeMissingTokenHints.js';
|
||||||
|
import {
|
||||||
|
buildFixedTokenOptionDescription,
|
||||||
|
buildFixedTokenOptionLabel,
|
||||||
|
describeTokenBinding,
|
||||||
|
} from './tokenBindingPresentation.js';
|
||||||
|
|
||||||
type ChannelSelection = {
|
type ChannelSelection = {
|
||||||
accountId: number;
|
accountId: number;
|
||||||
@@ -79,13 +84,10 @@ export default function AddChannelModal({
|
|||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
const tokens = candidateView.tokenOptionsByAccountId[account.id] || [];
|
const tokens = candidateView.tokenOptionsByAccountId[account.id] || [];
|
||||||
const defaultToken = tokens.find((t) => t.isDefault) || tokens[0] || null;
|
|
||||||
return {
|
return {
|
||||||
...prev,
|
...prev,
|
||||||
[account.id]: {
|
[account.id]: {
|
||||||
accountId: account.id,
|
accountId: account.id,
|
||||||
tokenId: defaultToken?.id,
|
|
||||||
sourceModel: defaultToken?.sourceModel,
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -191,6 +193,7 @@ export default function AddChannelModal({
|
|||||||
const tokens = candidateView.tokenOptionsByAccountId[account.id] || [];
|
const tokens = candidateView.tokenOptionsByAccountId[account.id] || [];
|
||||||
const selection = selectedAccounts[account.id];
|
const selection = selectedAccounts[account.id];
|
||||||
const isExisting = existingChannelAccountIds?.has(account.id);
|
const isExisting = existingChannelAccountIds?.has(account.id);
|
||||||
|
const tokenBinding = describeTokenBinding(tokens, selection?.tokenId || 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -219,7 +222,7 @@ export default function AddChannelModal({
|
|||||||
|
|
||||||
{isSelected && tokens.length > 0 && (
|
{isSelected && tokens.length > 0 && (
|
||||||
<div style={{ marginTop: 6, paddingLeft: 24 }} onClick={(e) => e.stopPropagation()}>
|
<div style={{ marginTop: 6, paddingLeft: 24 }} onClick={(e) => e.stopPropagation()}>
|
||||||
<div style={{ fontSize: 11, color: 'var(--color-text-muted)', marginBottom: 4 }}>{tr('令牌')}:</div>
|
<div style={{ fontSize: 11, color: 'var(--color-text-muted)', marginBottom: 4 }}>{tr('令牌绑定')}:</div>
|
||||||
<ModernSelect
|
<ModernSelect
|
||||||
size="sm"
|
size="sm"
|
||||||
value={(() => {
|
value={(() => {
|
||||||
@@ -235,14 +238,25 @@ export default function AddChannelModal({
|
|||||||
updateTokenForAccount(account.id, Number.parseInt(tokenRaw, 10) || 0, sourceParts.join('::'));
|
updateTokenForAccount(account.id, Number.parseInt(tokenRaw, 10) || 0, sourceParts.join('::'));
|
||||||
}}
|
}}
|
||||||
options={[
|
options={[
|
||||||
{ value: '0', label: tr('默认令牌') },
|
{
|
||||||
|
value: '0',
|
||||||
|
label: tr('跟随账号默认'),
|
||||||
|
description: tokenBinding.followOptionDescription,
|
||||||
|
},
|
||||||
...tokens.map((token: RouteTokenOption) => ({
|
...tokens.map((token: RouteTokenOption) => ({
|
||||||
value: `${token.id}::${token.sourceModel || ''}`,
|
value: `${token.id}::${token.sourceModel || ''}`,
|
||||||
label: `${token.name}${token.isDefault ? '(默认)' : ''}${token.sourceModel ? ` [${token.sourceModel}]` : ''}`,
|
label: buildFixedTokenOptionLabel(token, {
|
||||||
|
includeDefaultTag: true,
|
||||||
|
includeSourceModel: true,
|
||||||
|
}),
|
||||||
|
description: buildFixedTokenOptionDescription(token),
|
||||||
})),
|
})),
|
||||||
]}
|
]}
|
||||||
placeholder={tr('选择令牌')}
|
placeholder={tr('选择绑定方式')}
|
||||||
/>
|
/>
|
||||||
|
<div style={{ marginTop: 4, fontSize: 11, color: 'var(--color-text-muted)', lineHeight: 1.4 }}>
|
||||||
|
{tokenBinding.helperText}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -14,9 +14,17 @@ import {
|
|||||||
sortableKeyboardCoordinates,
|
sortableKeyboardCoordinates,
|
||||||
} from '@dnd-kit/sortable';
|
} from '@dnd-kit/sortable';
|
||||||
import { BrandGlyph, InlineBrandIcon, type BrandInfo } from '../../components/BrandIcon.js';
|
import { BrandGlyph, InlineBrandIcon, type BrandInfo } from '../../components/BrandIcon.js';
|
||||||
|
import ModernSelect from '../../components/ModernSelect.js';
|
||||||
import { useAnimatedVisibility } from '../../components/useAnimatedVisibility.js';
|
import { useAnimatedVisibility } from '../../components/useAnimatedVisibility.js';
|
||||||
import { tr } from '../../i18n.js';
|
import { tr } from '../../i18n.js';
|
||||||
import type { RouteSummaryRow, RouteChannel, RouteDecision, RouteDecisionCandidate, MissingTokenRouteSiteActionItem } from './types.js';
|
import type {
|
||||||
|
RouteSummaryRow,
|
||||||
|
RouteChannel,
|
||||||
|
RouteDecision,
|
||||||
|
RouteDecisionCandidate,
|
||||||
|
MissingTokenRouteSiteActionItem,
|
||||||
|
RouteRoutingStrategy,
|
||||||
|
} from './types.js';
|
||||||
import type { RouteCandidateView, RouteTokenOption } from '../helpers/routeModelCandidatesIndex.js';
|
import type { RouteCandidateView, RouteTokenOption } from '../helpers/routeModelCandidatesIndex.js';
|
||||||
import { SortableChannelRow } from './SortableChannelRow.js';
|
import { SortableChannelRow } from './SortableChannelRow.js';
|
||||||
import {
|
import {
|
||||||
@@ -34,6 +42,8 @@ type RouteCardProps = {
|
|||||||
onEdit: (route: RouteSummaryRow) => void;
|
onEdit: (route: RouteSummaryRow) => void;
|
||||||
onDelete: (routeId: number) => void;
|
onDelete: (routeId: number) => void;
|
||||||
onToggleEnabled: (route: RouteSummaryRow) => void;
|
onToggleEnabled: (route: RouteSummaryRow) => void;
|
||||||
|
onRoutingStrategyChange: (route: RouteSummaryRow, strategy: RouteRoutingStrategy) => void;
|
||||||
|
updatingRoutingStrategy: boolean;
|
||||||
// Channel data (loaded on demand)
|
// Channel data (loaded on demand)
|
||||||
channels: RouteChannel[] | undefined;
|
channels: RouteChannel[] | undefined;
|
||||||
loadingChannels: boolean;
|
loadingChannels: boolean;
|
||||||
@@ -79,6 +89,8 @@ function RouteCardInner({
|
|||||||
onEdit,
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
onToggleEnabled,
|
onToggleEnabled,
|
||||||
|
onRoutingStrategyChange,
|
||||||
|
updatingRoutingStrategy,
|
||||||
channels,
|
channels,
|
||||||
loadingChannels,
|
loadingChannels,
|
||||||
routeDecision,
|
routeDecision,
|
||||||
@@ -100,6 +112,19 @@ function RouteCardInner({
|
|||||||
const routeIcon = resolveRouteIcon(route);
|
const routeIcon = resolveRouteIcon(route);
|
||||||
const exactRoute = isExactModelPattern(route.modelPattern);
|
const exactRoute = isExactModelPattern(route.modelPattern);
|
||||||
const title = resolveRouteTitle(route);
|
const title = resolveRouteTitle(route);
|
||||||
|
const routingStrategy = route.routingStrategy === 'round_robin' ? 'round_robin' : 'weighted';
|
||||||
|
const routingStrategyOptions = [
|
||||||
|
{
|
||||||
|
value: 'weighted',
|
||||||
|
label: tr('权重随机'),
|
||||||
|
description: tr('按优先级、权重和成本信号综合选择'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'round_robin',
|
||||||
|
label: tr('轮询'),
|
||||||
|
description: tr('按全局顺序轮流调用,忽略优先级,连续失败 3 次后进入分级冷却'),
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
const sensors = useSensors(
|
const sensors = useSensors(
|
||||||
useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
|
useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
|
||||||
@@ -172,6 +197,10 @@ function RouteCardInner({
|
|||||||
{route.channelCount} {tr('通道')}
|
{route.channelCount} {tr('通道')}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
|
<span className="badge badge-muted" style={{ fontSize: 10, flexShrink: 0 }}>
|
||||||
|
{routingStrategy === 'round_robin' ? tr('轮询') : tr('权重随机')}
|
||||||
|
</span>
|
||||||
|
|
||||||
<svg
|
<svg
|
||||||
width="14" height="14" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="2"
|
width="14" height="14" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="2"
|
||||||
style={{ flexShrink: 0, color: 'var(--color-text-muted)' }}
|
style={{ flexShrink: 0, color: 'var(--color-text-muted)' }}
|
||||||
@@ -251,6 +280,23 @@ function RouteCardInner({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginBottom: 12, flexWrap: 'wrap' }}>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}>
|
||||||
|
{tr('路由策略')}
|
||||||
|
</div>
|
||||||
|
<div style={{ minWidth: 220, maxWidth: 320, flex: '1 1 220px' }}>
|
||||||
|
<ModernSelect
|
||||||
|
size="sm"
|
||||||
|
value={routingStrategy}
|
||||||
|
disabled={updatingRoutingStrategy}
|
||||||
|
onChange={(nextValue) => onRoutingStrategyChange(route, nextValue as RouteRoutingStrategy)}
|
||||||
|
options={routingStrategyOptions.map((option) => ({ ...option }))}
|
||||||
|
placeholder={tr('选择路由策略')}
|
||||||
|
emptyLabel={tr('暂无可选策略')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Missing token hints + Add channel button */}
|
{/* Missing token hints + Add channel button */}
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, marginBottom: 10, flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, marginBottom: 10, flexWrap: 'wrap' }}>
|
||||||
{missingTokenSiteItems.length > 0 ? (
|
{missingTokenSiteItems.length > 0 ? (
|
||||||
|
|||||||
@@ -3,6 +3,11 @@ import { useSortable } from '@dnd-kit/sortable';
|
|||||||
import { CSS } from '@dnd-kit/utilities';
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
import ModernSelect from '../../components/ModernSelect.js';
|
import ModernSelect from '../../components/ModernSelect.js';
|
||||||
import type { SortableChannelRowProps } from './types.js';
|
import type { SortableChannelRowProps } from './types.js';
|
||||||
|
import {
|
||||||
|
buildFixedTokenOptionDescription,
|
||||||
|
buildFixedTokenOptionLabel,
|
||||||
|
describeTokenBinding,
|
||||||
|
} from './tokenBindingPresentation.js';
|
||||||
import { getChannelDecisionState, getPriorityTagStyle, getProbabilityColor } from './utils.js';
|
import { getChannelDecisionState, getPriorityTagStyle, getProbabilityColor } from './utils.js';
|
||||||
|
|
||||||
export function SortableChannelRow({
|
export function SortableChannelRow({
|
||||||
@@ -48,6 +53,7 @@ export function SortableChannelRow({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const decisionState = getChannelDecisionState(decisionCandidate, channel, isExactRoute, loadingDecision);
|
const decisionState = getChannelDecisionState(decisionCandidate, channel, isExactRoute, loadingDecision);
|
||||||
|
const tokenBinding = describeTokenBinding(tokenOptions, activeTokenId, channel.token?.name ?? null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={setNodeRef} style={rowStyle}>
|
<div ref={setNodeRef} style={rowStyle}>
|
||||||
@@ -105,11 +111,29 @@ export function SortableChannelRow({
|
|||||||
className="badge"
|
className="badge"
|
||||||
style={{
|
style={{
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
background: 'color-mix(in srgb, var(--color-info) 15%, transparent)',
|
background: tokenBinding.isFollowingAccountDefault
|
||||||
color: 'var(--color-info)',
|
? 'color-mix(in srgb, var(--color-info) 15%, transparent)'
|
||||||
|
: 'color-mix(in srgb, var(--color-warning) 15%, transparent)',
|
||||||
|
color: tokenBinding.isFollowingAccountDefault ? 'var(--color-info)' : 'var(--color-warning)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{channel.token?.name || '默认令牌'}
|
{tokenBinding.bindingModeLabel}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span
|
||||||
|
className="badge"
|
||||||
|
style={{
|
||||||
|
fontSize: 10,
|
||||||
|
background: 'color-mix(in srgb, var(--color-primary) 10%, transparent)',
|
||||||
|
color: 'var(--color-primary)',
|
||||||
|
maxWidth: 220,
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
data-tooltip={`当前生效:${tokenBinding.effectiveTokenName}`}
|
||||||
|
>
|
||||||
|
当前生效:{tokenBinding.effectiveTokenName}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{channel.sourceModel ? (
|
{channel.sourceModel ? (
|
||||||
@@ -174,28 +198,36 @@ export function SortableChannelRow({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
<div style={{ minWidth: 150, flex: 1 }}>
|
<div style={{ minWidth: 220, flex: 1 }}>
|
||||||
<ModernSelect
|
<ModernSelect
|
||||||
size="sm"
|
size="sm"
|
||||||
value={String(activeTokenId || 0)}
|
value={String(activeTokenId || 0)}
|
||||||
onChange={(nextValue) => onTokenDraftChange(channel.id, Number.parseInt(nextValue, 10) || 0)}
|
onChange={(nextValue) => onTokenDraftChange(channel.id, Number.parseInt(nextValue, 10) || 0)}
|
||||||
disabled={isUpdatingToken}
|
disabled={isUpdatingToken}
|
||||||
options={[
|
options={[
|
||||||
{ value: '0', label: '默认令牌' },
|
{
|
||||||
|
value: '0',
|
||||||
|
label: '跟随账号默认',
|
||||||
|
description: tokenBinding.followOptionDescription,
|
||||||
|
},
|
||||||
...tokenOptions.map((token) => ({
|
...tokenOptions.map((token) => ({
|
||||||
value: String(token.id),
|
value: String(token.id),
|
||||||
label: `${token.name}${token.isDefault ? '(默认)' : ''}`,
|
label: buildFixedTokenOptionLabel(token, { includeDefaultTag: true }),
|
||||||
|
description: buildFixedTokenOptionDescription(token),
|
||||||
})),
|
})),
|
||||||
]}
|
]}
|
||||||
placeholder="默认令牌"
|
placeholder="选择令牌绑定方式"
|
||||||
/>
|
/>
|
||||||
|
<div style={{ marginTop: 4, fontSize: 11, color: 'var(--color-text-muted)', lineHeight: 1.4 }}>
|
||||||
|
{tokenBinding.helperText}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={onSaveToken}
|
onClick={onSaveToken}
|
||||||
disabled={isUpdatingToken}
|
disabled={isUpdatingToken}
|
||||||
className="btn btn-link btn-link-info"
|
className="btn btn-link btn-link-info"
|
||||||
>
|
>
|
||||||
{isUpdatingToken ? <span className="spinner spinner-sm" /> : '改令牌'}
|
{isUpdatingToken ? <span className="spinner spinner-sm" /> : '保存'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
buildFixedTokenOptionDescription,
|
||||||
|
buildFixedTokenOptionLabel,
|
||||||
|
describeTokenBinding,
|
||||||
|
} from './tokenBindingPresentation.js';
|
||||||
|
|
||||||
|
describe('tokenBindingPresentation', () => {
|
||||||
|
it('describes follow-account-default mode with the current default token', () => {
|
||||||
|
const result = describeTokenBinding([
|
||||||
|
{ id: 1, name: 'token-a', isDefault: true },
|
||||||
|
{ id: 2, name: 'token-b', isDefault: false },
|
||||||
|
], 0);
|
||||||
|
|
||||||
|
expect(result.bindingModeLabel).toBe('跟随账号默认');
|
||||||
|
expect(result.effectiveTokenName).toBe('token-a');
|
||||||
|
expect(result.helperText).toContain('以后账号默认变化时会自动切换');
|
||||||
|
expect(result.followOptionDescription).toContain('当前生效:token-a');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('describes fixed mode when the selected token is also the account default', () => {
|
||||||
|
const result = describeTokenBinding([
|
||||||
|
{ id: 1, name: 'default', isDefault: true },
|
||||||
|
{ id: 2, name: 'backup', isDefault: false },
|
||||||
|
], 1);
|
||||||
|
|
||||||
|
expect(result.bindingModeLabel).toBe('固定令牌');
|
||||||
|
expect(result.effectiveTokenName).toBe('default');
|
||||||
|
expect(result.helperText).toContain('它目前也是账号默认');
|
||||||
|
expect(result.helperText).toContain('不会跟着变');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('formats fixed token options with clearer labels and descriptions', () => {
|
||||||
|
const token = {
|
||||||
|
id: 3,
|
||||||
|
name: 'shared-token',
|
||||||
|
isDefault: true,
|
||||||
|
sourceModel: 'gpt-4o-mini',
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(buildFixedTokenOptionLabel(token, {
|
||||||
|
includeDefaultTag: true,
|
||||||
|
includeSourceModel: true,
|
||||||
|
})).toBe('固定使用:shared-token(当前账号默认) [gpt-4o-mini]');
|
||||||
|
expect(buildFixedTokenOptionDescription(token)).toContain('以后不会自动跟随');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
export type TokenBindingOption = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
isDefault: boolean;
|
||||||
|
sourceModel?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TokenBindingPresentation = {
|
||||||
|
isFollowingAccountDefault: boolean;
|
||||||
|
bindingModeLabel: string;
|
||||||
|
effectiveTokenName: string;
|
||||||
|
helperText: string;
|
||||||
|
followOptionDescription: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getDefaultTokenOption(options: TokenBindingOption[]): TokenBindingOption | null {
|
||||||
|
return options.find((option) => option.isDefault) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function describeTokenBinding(
|
||||||
|
options: TokenBindingOption[],
|
||||||
|
activeTokenId: number,
|
||||||
|
fallbackTokenName?: string | null,
|
||||||
|
): TokenBindingPresentation {
|
||||||
|
const defaultToken = getDefaultTokenOption(options);
|
||||||
|
const selectedToken = activeTokenId
|
||||||
|
? options.find((option) => option.id === activeTokenId) || null
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (!activeTokenId) {
|
||||||
|
const effectiveTokenName = defaultToken?.name || fallbackTokenName || '未设置默认令牌';
|
||||||
|
return {
|
||||||
|
isFollowingAccountDefault: true,
|
||||||
|
bindingModeLabel: '跟随账号默认',
|
||||||
|
effectiveTokenName,
|
||||||
|
helperText: defaultToken
|
||||||
|
? `跟随账号默认。当前生效的是「${defaultToken.name}」,以后账号默认变化时会自动切换。`
|
||||||
|
: '跟随账号默认。当前账号还没有默认令牌。',
|
||||||
|
followOptionDescription: defaultToken
|
||||||
|
? `当前生效:${defaultToken.name};以后账号默认变化时会自动切换`
|
||||||
|
: '以后账号默认变化时会自动切换',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const effectiveTokenName = selectedToken?.name || fallbackTokenName || `token-${activeTokenId}`;
|
||||||
|
return {
|
||||||
|
isFollowingAccountDefault: false,
|
||||||
|
bindingModeLabel: '固定令牌',
|
||||||
|
effectiveTokenName,
|
||||||
|
helperText: selectedToken?.isDefault
|
||||||
|
? `已固定到「${effectiveTokenName}」。它目前也是账号默认,但以后账号默认变化时,这个通道不会跟着变。`
|
||||||
|
: `已固定到「${effectiveTokenName}」,不会随账号默认变化。`,
|
||||||
|
followOptionDescription: defaultToken
|
||||||
|
? `当前生效:${defaultToken.name};以后账号默认变化时会自动切换`
|
||||||
|
: '以后账号默认变化时会自动切换',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildFixedTokenOptionLabel(
|
||||||
|
token: TokenBindingOption,
|
||||||
|
options: {
|
||||||
|
includeDefaultTag?: boolean;
|
||||||
|
includeSourceModel?: boolean;
|
||||||
|
} = {},
|
||||||
|
): string {
|
||||||
|
let label = `固定使用:${token.name}`;
|
||||||
|
if (options.includeDefaultTag && token.isDefault) {
|
||||||
|
label += '(当前账号默认)';
|
||||||
|
}
|
||||||
|
if (options.includeSourceModel && token.sourceModel) {
|
||||||
|
label += ` [${token.sourceModel}]`;
|
||||||
|
}
|
||||||
|
return label;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildFixedTokenOptionDescription(token: TokenBindingOption): string {
|
||||||
|
return token.isDefault
|
||||||
|
? '固定到这条令牌;它目前也是账号默认,但以后不会自动跟随'
|
||||||
|
: '固定到这条令牌;不随账号默认变化';
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import type { BrandInfo } from '../../components/BrandIcon.js';
|
|||||||
export type RouteSortBy = 'modelPattern' | 'channelCount';
|
export type RouteSortBy = 'modelPattern' | 'channelCount';
|
||||||
export type RouteSortDir = 'asc' | 'desc';
|
export type RouteSortDir = 'asc' | 'desc';
|
||||||
export type GroupFilter = null | '__all__' | number;
|
export type GroupFilter = null | '__all__' | number;
|
||||||
|
export type RouteRoutingStrategy = 'weighted' | 'round_robin';
|
||||||
|
|
||||||
export type RouteChannelDraft = {
|
export type RouteChannelDraft = {
|
||||||
accountId: number;
|
accountId: number;
|
||||||
@@ -46,6 +47,7 @@ export type RouteRow = {
|
|||||||
displayName?: string | null;
|
displayName?: string | null;
|
||||||
displayIcon?: string | null;
|
displayIcon?: string | null;
|
||||||
modelMapping?: string | null;
|
modelMapping?: string | null;
|
||||||
|
routingStrategy?: RouteRoutingStrategy | null;
|
||||||
decisionSnapshot?: RouteDecision | null;
|
decisionSnapshot?: RouteDecision | null;
|
||||||
decisionRefreshedAt?: string | null;
|
decisionRefreshedAt?: string | null;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
@@ -58,6 +60,7 @@ export type RouteSummaryRow = {
|
|||||||
displayName: string | null;
|
displayName: string | null;
|
||||||
displayIcon: string | null;
|
displayIcon: string | null;
|
||||||
modelMapping: string | null;
|
modelMapping: string | null;
|
||||||
|
routingStrategy?: RouteRoutingStrategy | null;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
channelCount: number;
|
channelCount: number;
|
||||||
enabledChannelCount: number;
|
enabledChannelCount: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user