From 5a7531f635aa591bc4c12bd780a36e6eedd88f9c Mon Sep 17 00:00:00 2001 From: bnvnvnv Date: Thu, 12 Mar 2026 05:17:10 +0000 Subject: [PATCH] feat: add round robin routing and improve token binding UX --- src/server/db/index.ts | 16 + .../routeGroupingSchemaCompatibility.test.ts | 12 +- .../db/routeGroupingSchemaCompatibility.ts | 36 + src/server/db/runtimeSchemaBootstrap.ts | 12 +- src/server/db/schema.ts | 4 + src/server/routes/api/settings.ts | 3 + src/server/routes/api/tokens.ts | 76 +- .../services/databaseMigrationService.ts | 8 +- src/server/services/routeRoutingStrategy.ts | 13 + src/server/services/tokenRouter.cache.test.ts | 151 ++++ src/server/services/tokenRouter.ts | 649 +++++++++++------- .../pages/Models.marketplace-text.test.tsx | 130 ++++ src/web/pages/Models.tsx | 36 +- src/web/pages/TokenRoutes.tsx | 37 + .../pages/token-routes/AddChannelModal.tsx | 28 +- src/web/pages/token-routes/RouteCard.tsx | 48 +- .../pages/token-routes/SortableChannelRow.tsx | 48 +- .../tokenBindingPresentation.test.ts | 47 ++ .../token-routes/tokenBindingPresentation.ts | 80 +++ src/web/pages/token-routes/types.ts | 3 + 20 files changed, 1112 insertions(+), 325 deletions(-) create mode 100644 src/server/services/routeRoutingStrategy.ts create mode 100644 src/web/pages/token-routes/tokenBindingPresentation.test.ts create mode 100644 src/web/pages/token-routes/tokenBindingPresentation.ts diff --git a/src/server/db/index.ts b/src/server/db/index.ts index c664543..0fb8001 100644 --- a/src/server/db/index.ts +++ b/src/server/db/index.ts @@ -415,9 +415,25 @@ function ensureRouteGroupingSchema() { 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')) { 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() { diff --git a/src/server/db/routeGroupingSchemaCompatibility.test.ts b/src/server/db/routeGroupingSchemaCompatibility.test.ts index 3c9560e..b3d7166 100644 --- a/src/server/db/routeGroupingSchemaCompatibility.test.ts +++ b/src/server/db/routeGroupingSchemaCompatibility.test.ts @@ -44,7 +44,11 @@ describe('ensureRouteGroupingSchemaCompatibility', () => { '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_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 "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 `decision_snapshot` TEXT NULL', 'ALTER TABLE `token_routes` ADD COLUMN `decision_refreshed_at` TEXT NULL', + 'ALTER TABLE `token_routes` ADD COLUMN `routing_strategy` VARCHAR(32) NULL DEFAULT \'weighted\'', '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 }) => { @@ -68,8 +76,8 @@ describe('ensureRouteGroupingSchemaCompatibility', () => { it('skips existing columns', async () => { const { inspector, executedSql } = createInspector('postgres', { existingColumnsByTable: { - token_routes: ['display_name', 'display_icon', 'decision_snapshot', 'decision_refreshed_at'], - route_channels: ['source_model'], + token_routes: ['display_name', 'display_icon', 'decision_snapshot', 'decision_refreshed_at', 'routing_strategy'], + route_channels: ['source_model', 'last_selected_at', 'consecutive_fail_count', 'cooldown_level'], }, }); diff --git a/src/server/db/routeGroupingSchemaCompatibility.ts b/src/server/db/routeGroupingSchemaCompatibility.ts index 15e3230..d602909 100644 --- a/src/server/db/routeGroupingSchemaCompatibility.ts +++ b/src/server/db/routeGroupingSchemaCompatibility.ts @@ -50,6 +50,15 @@ const ROUTE_GROUPING_COLUMN_COMPATIBILITY_SPECS: RouteGroupingColumnCompatibilit 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', column: 'source_model', @@ -59,6 +68,33 @@ const ROUTE_GROUPING_COLUMN_COMPATIBILITY_SPECS: RouteGroupingColumnCompatibilit 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 { diff --git a/src/server/db/runtimeSchemaBootstrap.ts b/src/server/db/runtimeSchemaBootstrap.ts index 613714e..8910e3f 100644 --- a/src/server/db/runtimeSchemaBootstrap.ts +++ b/src/server/db/runtimeSchemaBootstrap.ts @@ -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 "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_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 "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 "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_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_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)`, @@ -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 \`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_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 \`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 \`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_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_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)`, @@ -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 "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_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 "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 "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_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_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)`, diff --git a/src/server/db/schema.ts b/src/server/db/schema.ts index 621486a..16cdeb0 100644 --- a/src/server/db/schema.ts +++ b/src/server/db/schema.ts @@ -111,6 +111,7 @@ export const tokenRoutes = sqliteTable('token_routes', { modelMapping: text('model_mapping'), // JSON decisionSnapshot: text('decision_snapshot'), // JSON decisionRefreshedAt: text('decision_refreshed_at'), + routingStrategy: text('routing_strategy').default('weighted'), enabled: integer('enabled', { mode: 'boolean' }).default(true), createdAt: text('created_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), totalCost: real('total_cost').default(0), lastUsedAt: text('last_used_at'), + lastSelectedAt: text('last_selected_at'), lastFailAt: text('last_fail_at'), + consecutiveFailCount: integer('consecutive_fail_count').default(0), + cooldownLevel: integer('cooldown_level').default(0), cooldownUntil: text('cooldown_until'), }, (table) => ({ routeIdIdx: index('route_channels_route_id_idx').on(table.routeId), diff --git a/src/server/routes/api/settings.ts b/src/server/routes/api/settings.ts index 3961ee3..cc79f7f 100644 --- a/src/server/routes/api/settings.ts +++ b/src/server/routes/api/settings.ts @@ -882,7 +882,10 @@ export async function settingsRoutes(app: FastifyInstance) { totalLatencyMs: 0, totalCost: 0, lastUsedAt: null, + lastSelectedAt: null, lastFailAt: null, + consecutiveFailCount: 0, + cooldownLevel: 0, cooldownUntil: null, }).run(); diff --git a/src/server/routes/api/tokens.ts b/src/server/routes/api/tokens.ts index 5ab1ac0..7b0f6a5 100644 --- a/src/server/routes/api/tokens.ts +++ b/src/server/routes/api/tokens.ts @@ -1,8 +1,9 @@ import { FastifyInstance } from 'fastify'; import { and, eq, inArray } from 'drizzle-orm'; import { db, schema } from '../../db/index.js'; -import { rebuildTokenRoutesFromAvailability, refreshModelsAndRebuildRoutes } from '../../services/modelService.js'; -import { invalidateTokenRouterCache, matchesModelPattern, tokenRouter } from '../../services/tokenRouter.js'; +import { rebuildTokenRoutesFromAvailability, refreshModelsAndRebuildRoutes } from '../../services/modelService.js'; +import { normalizeRouteRoutingStrategy } from '../../services/routeRoutingStrategy.js'; +import { invalidateTokenRouterCache, matchesModelPattern, tokenRouter } from '../../services/tokenRouter.js'; import { startBackgroundTask } from '../../services/backgroundTaskService.js'; import { clearRouteDecisionSnapshot, @@ -415,14 +416,15 @@ async function fetchChannelsForRoutes(routeIds: number[]): Promise { - return await db.select({ - id: schema.tokenRoutes.id, - modelPattern: schema.tokenRoutes.modelPattern, - displayName: schema.tokenRoutes.displayName, - displayIcon: schema.tokenRoutes.displayIcon, - enabled: schema.tokenRoutes.enabled, - }).from(schema.tokenRoutes).all(); - }); + return await db.select({ + id: schema.tokenRoutes.id, + modelPattern: schema.tokenRoutes.modelPattern, + displayName: schema.tokenRoutes.displayName, + displayIcon: schema.tokenRoutes.displayIcon, + routingStrategy: schema.tokenRoutes.routingStrategy, + enabled: schema.tokenRoutes.enabled, + }).from(schema.tokenRoutes).all(); + }); // Route summary (no channel details) for first-screen rendering app.get('/api/routes/summary', async () => { @@ -460,10 +462,11 @@ export async function tokensRoutes(app: FastifyInstance) { id: route.id, modelPattern: route.modelPattern, displayName: route.displayName ?? null, - displayIcon: route.displayIcon ?? null, - modelMapping: route.modelMapping ?? null, - enabled: route.enabled, - channelCount: agg?.channelCount ?? 0, + displayIcon: route.displayIcon ?? null, + modelMapping: route.modelMapping ?? null, + routingStrategy: route.routingStrategy ?? 'weighted', + enabled: route.enabled, + channelCount: agg?.channelCount ?? 0, enabledChannelCount: agg?.enabledChannelCount ?? 0, siteNames: agg ? Array.from(agg.siteNames) : [], decisionSnapshot: parseRouteDecisionSnapshot(route.decisionSnapshot), @@ -676,15 +679,16 @@ export async function tokensRoutes(app: FastifyInstance) { }); // Create a route - app.post<{ Body: { modelPattern: string; displayName?: string; displayIcon?: string; modelMapping?: string; enabled?: boolean } }>('/api/routes', async (request) => { - const body = request.body; - const insertedRoute = await db.insert(schema.tokenRoutes).values({ - modelPattern: body.modelPattern, - displayName: body.displayName, - displayIcon: body.displayIcon, - modelMapping: body.modelMapping, - enabled: body.enabled ?? true, - }).run(); + app.post<{ Body: { modelPattern: string; displayName?: string; displayIcon?: string; modelMapping?: string; routingStrategy?: string; enabled?: boolean } }>('/api/routes', async (request) => { + const body = request.body; + const insertedRoute = await db.insert(schema.tokenRoutes).values({ + modelPattern: body.modelPattern, + displayName: body.displayName, + displayIcon: body.displayIcon, + modelMapping: body.modelMapping, + routingStrategy: normalizeRouteRoutingStrategy(body.routingStrategy), + enabled: body.enabled ?? true, + }).run(); const routeId = Number(insertedRoute.lastInsertRowid || 0); if (routeId <= 0) { return { success: false, message: '创建路由失败' }; @@ -713,17 +717,21 @@ export async function tokensRoutes(app: FastifyInstance) { if (body.displayName !== undefined) updates.displayName = body.displayName; if (body.displayIcon !== undefined) updates.displayIcon = body.displayIcon; - if (body.modelPattern !== undefined) { - nextModelPattern = String(body.modelPattern); - updates.modelPattern = nextModelPattern; - } - if (body.modelMapping !== undefined) updates.modelMapping = body.modelMapping; - if (body.enabled !== undefined) updates.enabled = body.enabled; - updates.updatedAt = new Date().toISOString(); - - await db.update(schema.tokenRoutes).set(updates).where(eq(schema.tokenRoutes.id, id)).run(); - const modelPatternChanged = body.modelPattern !== undefined && nextModelPattern !== existingRoute.modelPattern; - const routeBehaviorChanged = modelPatternChanged || body.modelMapping !== undefined || body.enabled !== undefined; + if (body.modelPattern !== undefined) { + nextModelPattern = String(body.modelPattern); + updates.modelPattern = nextModelPattern; + } + if (body.modelMapping !== undefined) updates.modelMapping = body.modelMapping; + if (body.routingStrategy !== undefined) updates.routingStrategy = normalizeRouteRoutingStrategy(body.routingStrategy); + if (body.enabled !== undefined) updates.enabled = body.enabled; + updates.updatedAt = new Date().toISOString(); + + await db.update(schema.tokenRoutes).set(updates).where(eq(schema.tokenRoutes.id, id)).run(); + const modelPatternChanged = body.modelPattern !== undefined && nextModelPattern !== existingRoute.modelPattern; + const routeBehaviorChanged = modelPatternChanged + || body.modelMapping !== undefined + || body.routingStrategy !== undefined + || body.enabled !== undefined; if (modelPatternChanged) { await rebuildAutomaticRouteChannelsByModelPattern(id, nextModelPattern); } diff --git a/src/server/services/databaseMigrationService.ts b/src/server/services/databaseMigrationService.ts index b1323d1..d0019e5 100644 --- a/src/server/services/databaseMigrationService.ts +++ b/src/server/services/databaseMigrationService.ts @@ -374,7 +374,7 @@ function buildStatements(snapshot: BackupSnapshot): InsertStatement[] { for (const row of snapshot.accounts.tokenRoutes) { statements.push({ table: 'token_routes', - columns: ['id', 'model_pattern', 'display_name', 'display_icon', 'model_mapping', 'decision_snapshot', 'decision_refreshed_at', '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: [ asNumber(row.id, 0), asNullableString(row.modelPattern), @@ -383,6 +383,7 @@ function buildStatements(snapshot: BackupSnapshot): InsertStatement[] { asNullableString(row.modelMapping), asNullableString(row.decisionSnapshot), asNullableString(row.decisionRefreshedAt), + asNullableString(row.routingStrategy), asBoolean(row.enabled, true), asNullableString(row.createdAt), asNullableString(row.updatedAt), @@ -393,7 +394,7 @@ function buildStatements(snapshot: BackupSnapshot): InsertStatement[] { for (const row of snapshot.accounts.routeChannels) { statements.push({ 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: [ asNumber(row.id, 0), asNumber(row.routeId, 0), @@ -409,7 +410,10 @@ function buildStatements(snapshot: BackupSnapshot): InsertStatement[] { asNumber(row.totalLatencyMs, 0), asNumber(row.totalCost, 0), asNullableString(row.lastUsedAt), + asNullableString(row.lastSelectedAt), asNullableString(row.lastFailAt), + asNumber(row.consecutiveFailCount, 0), + asNumber(row.cooldownLevel, 0), asNullableString(row.cooldownUntil), ], }); diff --git a/src/server/services/routeRoutingStrategy.ts b/src/server/services/routeRoutingStrategy.ts new file mode 100644 index 0000000..046674e --- /dev/null +++ b/src/server/services/routeRoutingStrategy.ts @@ -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'; +} diff --git a/src/server/services/tokenRouter.cache.test.ts b/src/server/services/tokenRouter.cache.test.ts index 793e66f..7e04094 100644 --- a/src/server/services/tokenRouter.cache.test.ts +++ b/src/server/services/tokenRouter.cache.test.ts @@ -127,6 +127,7 @@ describe('TokenRouter runtime cache', () => { const route = await db.insert(schema.tokenRoutes).values({ modelPattern: 'gpt-4o-mini', + routingStrategy: 'weighted', enabled: true, }).returning().get(); @@ -168,4 +169,154 @@ describe('TokenRouter runtime cache', () => { expect(thirdCooldownMs).toBeGreaterThanOrEqual(25_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(); + }); }); diff --git a/src/server/services/tokenRouter.ts b/src/server/services/tokenRouter.ts index 4919e1d..454e02d 100644 --- a/src/server/services/tokenRouter.ts +++ b/src/server/services/tokenRouter.ts @@ -1,9 +1,14 @@ import { eq } from 'drizzle-orm'; import { minimatch } from 'minimatch'; -import { db, schema } from '../db/index.js'; -import { config } from '../config.js'; +import { db, schema } from '../db/index.js'; +import { config } from '../config.js'; import { getCachedModelRoutingReferenceCost, refreshModelPricingCatalog } from './modelPricingService.js'; -import { type DownstreamRoutingPolicy, EMPTY_DOWNSTREAM_ROUTING_POLICY } from './downstreamPolicyTypes.js'; +import { + DEFAULT_ROUTE_ROUTING_STRATEGY, + normalizeRouteRoutingStrategy, + type RouteRoutingStrategy, +} from './routeRoutingStrategy.js'; +import { type DownstreamRoutingPolicy, EMPTY_DOWNSTREAM_ROUTING_POLICY } from './downstreamPolicyTypes.js'; interface RouteMatch { route: typeof schema.tokenRoutes.$inferSelect; @@ -34,6 +39,8 @@ type FailureAwareChannel = { const FAILURE_BACKOFF_BASE_SEC = 15; const MIN_EFFECTIVE_UNIT_COST = 1e-6; +const ROUND_ROBIN_FAILURE_THRESHOLD = 3; +const ROUND_ROBIN_COOLDOWN_LEVELS_SEC = [0, 10 * 60, 60 * 60, 24 * 60 * 60] as const; function fibonacciNumber(index: number): number { if (index <= 2) return 1; @@ -51,6 +58,11 @@ function resolveFailureBackoffSec(failCount?: number | null): number { const normalizedFailCount = Math.max(1, Math.trunc(failCount ?? 0)); 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 ChannelRow = typeof schema.routeChannels.$inferSelect; @@ -348,14 +360,33 @@ function resolveActualModelForSelectedChannel( requestedModel: string, route: typeof schema.tokenRoutes.$inferSelect, mappedModel: string, - channelSourceModel: string | null | undefined, + channelSourceModel: string | null | undefined, ): string { const sourceModel = normalizeChannelSourceModel(channelSourceModel); if (isRouteDisplayNameMatch(requestedModel, route.displayName) && sourceModel) { return sourceModel; - } - 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 { const successCount = Math.max(0, candidate.channel.successCount ?? 0); @@ -399,149 +430,32 @@ function isExplicitTokenChannel(candidate: RouteChannelCandidate): boolean { } export class TokenRouter { - /** - * Find matching route and select a channel for the given model. - * Returns null if no route/channel available. - */ + /** + * Find matching route and select a channel for the given model. + * Returns null if no route/channel available. + */ async selectChannel(requestedModel: string, downstreamPolicy: DownstreamRoutingPolicy = DEFAULT_DOWNSTREAM_POLICY): Promise { - if (!isModelAllowedByDownstreamPolicy(requestedModel, downstreamPolicy)) return null; - - const match = await this.findRoute(requestedModel, downstreamPolicy); - if (!match) return null; - - const mappedModel = resolveMappedModel(requestedModel, match.route.modelMapping); - const requestedByDisplayName = isRouteDisplayNameMatch(requestedModel, match.route.displayName); - const bypassSourceModelCheck = requestedByDisplayName; + if (!isModelAllowedByDownstreamPolicy(requestedModel, downstreamPolicy)) return null; - 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(); - 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; - } + const match = await this.findRoute(requestedModel, downstreamPolicy); + if (!match) return null; + return await this.selectFromMatch(match, requestedModel, downstreamPolicy); + } /** * Select next channel for failover (exclude already-tried channels). */ async selectNextChannel( - requestedModel: string, - excludeChannelIds: number[], - downstreamPolicy: DownstreamRoutingPolicy = DEFAULT_DOWNSTREAM_POLICY, + requestedModel: string, + excludeChannelIds: number[], + downstreamPolicy: DownstreamRoutingPolicy = DEFAULT_DOWNSTREAM_POLICY, ): Promise { - if (!isModelAllowedByDownstreamPolicy(requestedModel, downstreamPolicy)) return null; - + if (!isModelAllowedByDownstreamPolicy(requestedModel, downstreamPolicy)) return null; + const match = await this.findRoute(requestedModel, downstreamPolicy); - if (!match) return null; - - 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(); - 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; - } + if (!match) return null; + return await this.selectFromMatch(match, requestedModel, downstreamPolicy, excludeChannelIds); + } async explainSelection( requestedModel: string, @@ -608,7 +522,7 @@ export class TokenRouter { match: RouteMatch | null, requestedModel: string, options: ExplainSelectionOptions = {}, - ): RouteDecisionExplanation { + ): RouteDecisionExplanation { const excludeChannelIds = options.excludeChannelIds ?? []; const downstreamPolicy = options.downstreamPolicy ?? DEFAULT_DOWNSTREAM_POLICY; @@ -621,20 +535,24 @@ export class TokenRouter { candidates: [], }; } - - const requestedByDisplayName = isRouteDisplayNameMatch(requestedModel, match.route.displayName); - const bypassSourceModelCheck = (options.bypassSourceModelCheck ?? false) || requestedByDisplayName; - const useChannelSourceModelForCost = (options.useChannelSourceModelForCost ?? false) || requestedByDisplayName; - const mappedModel = resolveMappedModel(requestedModel, match.route.modelMapping); - - const nowIso = new Date().toISOString(); - const nowMs = Date.now(); - const summary: string[] = [`命中路由:${match.route.modelPattern}`]; - if (requestedByDisplayName) { - summary.push(`按显示名命中:${normalizeRouteDisplayName(match.route.displayName)}`); - summary.push('显示名仅用于聚合展示,实际转发模型按选中通道来源模型决定'); - } - const availableByPriority = new Map(); + + const requestedByDisplayName = isRouteDisplayNameMatch(requestedModel, match.route.displayName); + const bypassSourceModelCheck = (options.bypassSourceModelCheck ?? false) || requestedByDisplayName; + const useChannelSourceModelForCost = (options.useChannelSourceModelForCost ?? false) || requestedByDisplayName; + const mappedModel = resolveMappedModel(requestedModel, match.route.modelMapping); + const routeStrategy = resolveRouteStrategy(match.route); + + const nowIso = new Date().toISOString(); + const nowMs = Date.now(); + const summary: string[] = [ + `命中路由:${match.route.modelPattern}`, + routeStrategy === 'round_robin' ? '路由策略:轮询' : '路由策略:按权重随机', + ]; + if (requestedByDisplayName) { + summary.push(`按显示名命中:${normalizeRouteDisplayName(match.route.displayName)}`); + summary.push('显示名仅用于聚合展示,实际转发模型按选中通道来源模型决定'); + } + const availableByPriority = new Map(); const candidates: RouteDecisionCandidate[] = []; const candidateMap = new Map(); @@ -646,12 +564,14 @@ export class TokenRouter { nowIso, }); - const recentlyFailed = isChannelRecentlyFailed(row.channel, nowMs); - const eligible = reasonParts.length === 0; - const candidate: RouteDecisionCandidate = { - channelId: row.channel.id, - accountId: row.account.id, - username: row.account.username || `account-${row.account.id}`, + const recentlyFailed = routeStrategy === DEFAULT_ROUTE_ROUTING_STRATEGY + ? isChannelRecentlyFailed(row.channel, nowMs) + : false; + const eligible = reasonParts.length === 0; + const candidate: RouteDecisionCandidate = { + channelId: row.channel.id, + accountId: row.account.id, + username: row.account.username || `account-${row.account.id}`, siteName: row.site.name || 'unknown', tokenName: row.token?.name || 'default', priority: row.channel.priority ?? 0, @@ -672,64 +592,126 @@ export class TokenRouter { } } - if (availableByPriority.size === 0) { - summary.push('没有可用通道(全部被禁用、站点不可用、冷却或令牌不可用)'); - return { - requestedModel, - actualModel: mappedModel, + if (availableByPriority.size === 0) { + summary.push('没有可用通道(全部被禁用、站点不可用、冷却或令牌不可用)'); + return { + requestedModel, + actualModel: mappedModel, matched: true, routeId: match.route.id, modelPattern: match.route.modelPattern, summary, - candidates, - }; - } - - const sortedPriorities = Array.from(availableByPriority.keys()).sort((a, b) => a - b); - let selected: RouteChannelCandidate | null = null; - let selectedPriority = 0; - - for (const priority of sortedPriorities) { - const rawLayer = availableByPriority.get(priority) ?? []; - if (rawLayer.length === 0) continue; - + candidates, + }; + } + + 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); + let selected: RouteChannelCandidate | null = null; + let selectedPriority = 0; + + for (const priority of sortedPriorities) { + const rawLayer = availableByPriority.get(priority) ?? []; + if (rawLayer.length === 0) continue; + const filteredLayer = filterRecentlyFailedCandidates(rawLayer, nowMs); - const avoided = rawLayer.filter((row) => !filteredLayer.some((item) => item.channel.id === row.channel.id)); - if (avoided.length > 0) { - for (const row of avoided) { + const avoided = rawLayer.filter((row) => !filteredLayer.some((item) => item.channel.id === row.channel.id)); + if (avoided.length > 0) { + for (const row of avoided) { const target = candidateMap.get(row.channel.id); if (!target) continue; target.avoidedByRecentFailure = true; target.reason = `最近失败,优先避让(${resolveFailureBackoffSec(row.channel.failCount)} 秒窗口)`; } } - - const weighted = this.calculateWeightedSelection( - filteredLayer, - useChannelSourceModelForCost - ? (candidate) => (normalizeChannelSourceModel(candidate.channel.sourceModel) || mappedModel) - : mappedModel, - downstreamPolicy, - ); - for (const detail of weighted.details) { - const target = candidateMap.get(detail.candidate.channel.id); - if (!target) continue; - target.probability = Number((detail.probability * 100).toFixed(2)); - if (target.eligible && !target.avoidedByRecentFailure) { - target.reason = detail.reason; - } - } - - if (!weighted.selected) continue; - selected = weighted.selected; - selectedPriority = priority; - summary.push( - avoided.length > 0 - ? `优先级 P${priority}:可用 ${rawLayer.length},因最近失败避让 ${avoided.length}` - : `优先级 P${priority}:可用 ${rawLayer.length}`, - ); - break; - } + + const weighted = this.calculateWeightedSelection( + filteredLayer, + useChannelSourceModelForCost + ? (candidate) => (normalizeChannelSourceModel(candidate.channel.sourceModel) || mappedModel) + : mappedModel, + downstreamPolicy, + ); + for (const detail of weighted.details) { + const target = candidateMap.get(detail.candidate.channel.id); + if (!target) continue; + target.probability = Number((detail.probability * 100).toFixed(2)); + if (target.eligible && !target.avoidedByRecentFailure) { + target.reason = detail.reason; + } + } + + if (!weighted.selected) continue; + selected = weighted.selected; + selectedPriority = priority; + summary.push( + avoided.length > 0 + ? `优先级 P${priority}:可用 ${rawLayer.length},因最近失败避让 ${avoided.length}` + : `优先级 P${priority}:可用 ${rawLayer.length}`, + ); + break; + } if (!selected) { summary.push('本次未选出通道'); @@ -815,54 +797,87 @@ export class TokenRouter { /** * Record success for a channel. */ - async recordSuccess(channelId: number, latencyMs: number, cost: number) { - const ch = await db.select().from(schema.routeChannels).where(eq(schema.routeChannels.id, channelId)).get(); - if (!ch) return; - const nowIso = new Date().toISOString(); - const nextSuccessCount = (ch.successCount ?? 0) + 1; + async recordSuccess(channelId: number, latencyMs: number, cost: number) { + const ch = await db.select().from(schema.routeChannels).where(eq(schema.routeChannels.id, channelId)).get(); + if (!ch) return; + const nowIso = new Date().toISOString(); + const nextSuccessCount = (ch.successCount ?? 0) + 1; const nextTotalLatencyMs = (ch.totalLatencyMs ?? 0) + latencyMs; const nextTotalCost = (ch.totalCost ?? 0) + cost; await db.update(schema.routeChannels).set({ successCount: nextSuccessCount, - totalLatencyMs: nextTotalLatencyMs, - totalCost: nextTotalCost, - lastUsedAt: nowIso, - cooldownUntil: null, - lastFailAt: null, - }).where(eq(schema.routeChannels.id, channelId)).run(); - - patchCachedChannel(channelId, (channel) => { - channel.successCount = nextSuccessCount; - channel.totalLatencyMs = nextTotalLatencyMs; - channel.totalCost = nextTotalCost; - channel.lastUsedAt = nowIso; - channel.cooldownUntil = null; - channel.lastFailAt = null; - }); - } - - /** - * Record failure and set cooldown. - */ + totalLatencyMs: nextTotalLatencyMs, + totalCost: nextTotalCost, + lastUsedAt: nowIso, + cooldownUntil: null, + lastFailAt: null, + consecutiveFailCount: 0, + cooldownLevel: 0, + }).where(eq(schema.routeChannels.id, channelId)).run(); + + patchCachedChannel(channelId, (channel) => { + channel.successCount = nextSuccessCount; + channel.totalLatencyMs = nextTotalLatencyMs; + channel.totalCost = nextTotalCost; + channel.lastUsedAt = nowIso; + channel.cooldownUntil = null; + channel.lastFailAt = null; + channel.consecutiveFailCount = 0; + channel.cooldownLevel = 0; + }); + } + + /** + * Record failure and set cooldown. + */ async recordFailure(channelId: number) { - const ch = await db.select().from(schema.routeChannels).where(eq(schema.routeChannels.id, channelId)).get(); - if (!ch) return; + const row = await db.select() + .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 cooldownSec = resolveFailureBackoffSec(failCount); - const cooldownUntil = new Date(Date.now() + cooldownSec * 1000).toISOString(); - const nowIso = new Date().toISOString(); - await db.update(schema.routeChannels).set({ - failCount, - lastFailAt: nowIso, - cooldownUntil, - }).where(eq(schema.routeChannels.id, channelId)).run(); - - patchCachedChannel(channelId, (channel) => { - channel.failCount = failCount; - channel.lastFailAt = nowIso; - channel.cooldownUntil = cooldownUntil; - }); - } + 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); + cooldownUntil = new Date(nowMs + cooldownSec * 1000).toISOString(); + consecutiveFailCount = 0; + cooldownLevel = 0; + } + + await db.update(schema.routeChannels).set({ + failCount, + lastFailAt: nowIso, + consecutiveFailCount, + cooldownLevel, + cooldownUntil, + }).where(eq(schema.routeChannels.id, channelId)).run(); + + patchCachedChannel(channelId, (channel) => { + channel.failCount = failCount; + channel.lastFailAt = nowIso; + channel.cooldownUntil = cooldownUntil; + channel.consecutiveFailCount = consecutiveFailCount; + channel.cooldownLevel = cooldownLevel; + }); + } /** * Get all available models (aggregated from all routes). @@ -872,11 +887,99 @@ export class TokenRouter { const exposed = routes .map((route) => getExposedModelNameForRoute(route).trim()) .filter((name) => name.length > 0); - return Array.from(new Set(exposed)); - } - - // --- Private methods --- - + return Array.from(new Set(exposed)); + } + + // --- Private methods --- + + private async selectFromMatch( + match: RouteMatch, + requestedModel: string, + downstreamPolicy: DownstreamRoutingPolicy, + excludeChannelIds: number[] = [], + ): Promise { + 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(); + 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 { let routes = await loadEnabledRoutes(); @@ -973,10 +1076,40 @@ export class TokenRouter { 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 { + 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( candidates: RouteChannelCandidate[], - modelName: string | ((candidate: RouteChannelCandidate) => string), - downstreamPolicy: DownstreamRoutingPolicy, + modelName: string | ((candidate: RouteChannelCandidate) => string), + downstreamPolicy: DownstreamRoutingPolicy, ) { return this.calculateWeightedSelection(candidates, modelName, downstreamPolicy).selected; } diff --git a/src/web/pages/Models.marketplace-text.test.tsx b/src/web/pages/Models.marketplace-text.test.tsx index 71fd061..b3de51a 100644 --- a/src/web/pages/Models.marketplace-text.test.tsx +++ b/src/web/pages/Models.marketplace-text.test.tsx @@ -190,4 +190,134 @@ describe('Models marketplace text', () => { 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 | null = null; + + try { + await act(async () => { + root = create( + + + + + , + ); + }); + 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(); + } + }); }); diff --git a/src/web/pages/Models.tsx b/src/web/pages/Models.tsx index 912c511..0b5378a 100644 --- a/src/web/pages/Models.tsx +++ b/src/web/pages/Models.tsx @@ -292,26 +292,50 @@ export default function Models() { }); }, [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 ---- */ const totalPages = Math.max(1, Math.ceil(filteredModels.length / pageSize)); 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]); /* ---- stats ---- */ - const totalCoverageSlots = filteredModels.reduce((s, m) => s + m.accountCount, 0); + const totalCoverageSlots = detailModels.reduce((s, m) => s + m.accountCount, 0); const uniqueAccountCount = (() => { const ids = new Set(); - for (const model of filteredModels) { + for (const model of detailModels) { for (const account of model.accounts) { ids.add(account.id); } } return ids.size; })(); - const avgLatency = filteredModels.length - ? Math.round(filteredModels.reduce((s, m) => s + m.avgLatency, 0) / filteredModels.length) + const avgLatency = detailModels.length + ? Math.round(detailModels.reduce((s, m) => s + m.avgLatency, 0) / detailModels.length) : 0; /* ---- copy ---- */ @@ -898,5 +922,3 @@ export default function Models() { ); } - - diff --git a/src/web/pages/TokenRoutes.tsx b/src/web/pages/TokenRoutes.tsx index 282566d..e0156f1 100644 --- a/src/web/pages/TokenRoutes.tsx +++ b/src/web/pages/TokenRoutes.tsx @@ -28,6 +28,7 @@ import type { RouteSortDir, GroupFilter, RouteSummaryRow, + RouteRoutingStrategy, RouteDecision, RouteIconOption, MissingTokenRouteSiteActionItem, @@ -88,6 +89,7 @@ export default function TokenRoutes() { const [channelTokenDraft, setChannelTokenDraft] = useState>({}); const [updatingChannel, setUpdatingChannel] = useState>({}); const [savingPriorityByRoute, setSavingPriorityByRoute] = useState>({}); + const [updatingRoutingStrategyByRoute, setUpdatingRoutingStrategyByRoute] = useState>({}); const [decisionByRoute, setDecisionByRoute] = useState>({}); 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) const routePatterns = useMemo( () => routeSummaries.map((r) => ({ id: r.id, modelPattern: r.modelPattern })), @@ -761,6 +789,12 @@ export default function TokenRoutes() { const handleToggleEnabledRef = useRef(handleToggleRouteEnabled); handleToggleEnabledRef.current = handleToggleRouteEnabled; 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( (channelId: number, tokenId: number) => setChannelTokenDraft((prev) => ({ ...prev, [channelId]: tokenId })), [], @@ -995,6 +1029,7 @@ export default function TokenRoutes() { > +
diff --git a/src/web/pages/token-routes/tokenBindingPresentation.test.ts b/src/web/pages/token-routes/tokenBindingPresentation.test.ts new file mode 100644 index 0000000..75ea861 --- /dev/null +++ b/src/web/pages/token-routes/tokenBindingPresentation.test.ts @@ -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('以后不会自动跟随'); + }); +}); diff --git a/src/web/pages/token-routes/tokenBindingPresentation.ts b/src/web/pages/token-routes/tokenBindingPresentation.ts new file mode 100644 index 0000000..306db3c --- /dev/null +++ b/src/web/pages/token-routes/tokenBindingPresentation.ts @@ -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 + ? '固定到这条令牌;它目前也是账号默认,但以后不会自动跟随' + : '固定到这条令牌;不随账号默认变化'; +} diff --git a/src/web/pages/token-routes/types.ts b/src/web/pages/token-routes/types.ts index 3d71960..389a63a 100644 --- a/src/web/pages/token-routes/types.ts +++ b/src/web/pages/token-routes/types.ts @@ -4,6 +4,7 @@ import type { BrandInfo } from '../../components/BrandIcon.js'; export type RouteSortBy = 'modelPattern' | 'channelCount'; export type RouteSortDir = 'asc' | 'desc'; export type GroupFilter = null | '__all__' | number; +export type RouteRoutingStrategy = 'weighted' | 'round_robin'; export type RouteChannelDraft = { accountId: number; @@ -46,6 +47,7 @@ export type RouteRow = { displayName?: string | null; displayIcon?: string | null; modelMapping?: string | null; + routingStrategy?: RouteRoutingStrategy | null; decisionSnapshot?: RouteDecision | null; decisionRefreshedAt?: string | null; enabled: boolean; @@ -58,6 +60,7 @@ export type RouteSummaryRow = { displayName: string | null; displayIcon: string | null; modelMapping: string | null; + routingStrategy?: RouteRoutingStrategy | null; enabled: boolean; channelCount: number; enabledChannelCount: number;