feat: add round robin routing and improve token binding UX

This commit is contained in:
bnvnvnv
2026-03-12 05:17:10 +00:00
parent cb93b28c71
commit 5a7531f635
20 changed files with 1112 additions and 325 deletions
+16
View File
@@ -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() {
@@ -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'],
},
});
@@ -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 {
+6 -6
View File
@@ -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)`,
+4
View File
@@ -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),
+3
View File
@@ -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();
+42 -34
View File
@@ -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<Map<number, a
export async function tokensRoutes(app: FastifyInstance) {
// List routes with basic info only (lightweight for selectors)
app.get('/api/routes/lite', async () => {
return await db.select({
id: schema.tokenRoutes.id,
modelPattern: schema.tokenRoutes.modelPattern,
displayName: schema.tokenRoutes.displayName,
displayIcon: schema.tokenRoutes.displayIcon,
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);
}
@@ -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),
],
});
@@ -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({
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();
});
});
+391 -258
View File
@@ -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<SelectedChannel | 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;
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<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;
}
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<SelectedChannel | null> {
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<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;
}
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<number, RouteChannelCandidate[]>();
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<number, RouteChannelCandidate[]>();
const candidates: RouteDecisionCandidate[] = [];
const candidateMap = new Map<number, RouteDecisionCandidate>();
@@ -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<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> {
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<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(
candidates: RouteChannelCandidate[],
modelName: string | ((candidate: RouteChannelCandidate) => string),
downstreamPolicy: DownstreamRoutingPolicy,
modelName: string | ((candidate: RouteChannelCandidate) => string),
downstreamPolicy: DownstreamRoutingPolicy,
) {
return this.calculateWeightedSelection(candidates, modelName, downstreamPolicy).selected;
}