feat: enhance TokenRoutes with edit functionality and route visibility management

- Added editing capabilities for route management in TokenRoutes, allowing users to modify existing routes.
- Introduced a reset function for the route form to clear inputs after adding or editing routes.
- Implemented a visibility filter for routes, ensuring exact routes covered by group routes are hidden from the main list.
- Updated tests to cover new functionalities, including editing routes and verifying visibility logic.
- Created a new helper function to build a list of visible routes based on defined criteria.
- Enhanced schema compatibility checks for site configurations, ensuring necessary columns are added to the database.
This commit is contained in:
cita
2026-03-07 19:56:40 +08:00
parent 5ad5b87b16
commit ed05b37626
18 changed files with 1476 additions and 153 deletions
@@ -1,5 +1,9 @@
import { describe, expect, it } from 'vitest';
import { maskConnectionString, normalizeMigrationInput } from './databaseMigrationService.js';
import { describe, expect, it } from 'vitest';
import {
__databaseMigrationServiceTestUtils,
maskConnectionString,
normalizeMigrationInput,
} from './databaseMigrationService.js';
describe('databaseMigrationService', () => {
it('accepts postgres migration input with normalized url', () => {
@@ -96,5 +100,69 @@ describe('databaseMigrationService', () => {
});
expect(normalized.ssl).toBe(false);
});
});
it.each(['postgres', 'mysql', 'sqlite'] as const)('creates or patches sites schema with use_system_proxy for %s', async (dialect) => {
const executedSql: string[] = [];
await __databaseMigrationServiceTestUtils.ensureSchema({
dialect,
begin: async () => {},
commit: async () => {},
rollback: async () => {},
execute: async (sqlText) => {
executedSql.push(sqlText);
return [];
},
queryScalar: async (sqlText, params = []) => {
if (sqlText.includes('sqlite_master') || sqlText.includes('information_schema.tables')) {
return 1;
}
if (sqlText.includes('pragma_table_info') || sqlText.includes('information_schema.columns')) {
const columnName = String(params[1] ?? sqlText.match(/name = '([^']+)'/)?.[1] ?? '');
return columnName === 'use_system_proxy' ? 0 : 1;
}
return 0;
},
close: async () => {},
});
const useSystemProxySql = executedSql.find((sqlText) => sqlText.includes('use_system_proxy'));
expect(useSystemProxySql).toContain('use_system_proxy');
});
it('includes useSystemProxy when building site migration statements', () => {
const statements = __databaseMigrationServiceTestUtils.buildStatements({
version: 'test',
timestamp: Date.now(),
accounts: {
sites: [{
id: 1,
name: 'demo',
url: 'https://example.com',
platform: 'openai',
useSystemProxy: true,
status: 'active',
}],
accounts: [],
accountTokens: [],
checkinLogs: [],
modelAvailability: [],
tokenModelAvailability: [],
tokenRoutes: [],
routeChannels: [],
proxyLogs: [],
downstreamApiKeys: [],
events: [],
},
preferences: {
settings: [],
},
});
const siteStatement = statements.find((statement) => statement.table === 'sites');
const useSystemProxyIndex = siteStatement?.columns.indexOf('use_system_proxy') ?? -1;
expect(useSystemProxyIndex).toBeGreaterThanOrEqual(0);
expect(siteStatement?.values[useSystemProxyIndex]).toBe(true);
});
});
@@ -4,6 +4,7 @@ import pg from 'pg';
import { mkdirSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { db, schema } from '../db/index.js';
import { ensureSiteSchemaCompatibility, type SiteSchemaInspector } from '../db/siteSchemaCompatibility.js';
export type MigrationDialect = 'sqlite' | 'mysql' | 'postgres';
@@ -307,10 +308,81 @@ async function createClient(input: NormalizedDatabaseMigrationInput): Promise<Sq
return createSqliteClient(input.connectionString);
}
function validateIdentifier(identifier: string): string {
if (!/^[a-z_][a-z0-9_]*$/i.test(identifier)) {
throw new Error(`Invalid SQL identifier: ${identifier}`);
}
return identifier;
}
function createSiteSchemaInspector(client: SqlClient): SiteSchemaInspector {
if (client.dialect === 'sqlite') {
return {
dialect: 'sqlite',
tableExists: async (table) => {
const normalizedTable = validateIdentifier(table);
return (await client.queryScalar(
`SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = '${normalizedTable}'`,
)) > 0;
},
columnExists: async (table, column) => {
const normalizedTable = validateIdentifier(table);
const normalizedColumn = validateIdentifier(column);
return (await client.queryScalar(
`SELECT COUNT(*) FROM pragma_table_info('${normalizedTable}') WHERE name = '${normalizedColumn}'`,
)) > 0;
},
execute: async (sqlText) => {
await client.execute(sqlText);
},
};
}
if (client.dialect === 'mysql') {
return {
dialect: 'mysql',
tableExists: async (table) => {
return (await client.queryScalar(
'SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = ?',
[table],
)) > 0;
},
columnExists: async (table, column) => {
return (await client.queryScalar(
'SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = ? AND column_name = ?',
[table, column],
)) > 0;
},
execute: async (sqlText) => {
await client.execute(sqlText);
},
};
}
return {
dialect: 'postgres',
tableExists: async (table) => {
return (await client.queryScalar(
'SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = current_schema() AND table_name = $1',
[table],
)) > 0;
},
columnExists: async (table, column) => {
return (await client.queryScalar(
'SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = $1 AND column_name = $2',
[table, column],
)) > 0;
},
execute: async (sqlText) => {
await client.execute(sqlText);
},
};
}
async function ensureSchema(client: SqlClient): Promise<void> {
const statements = client.dialect === 'postgres'
? [
`CREATE TABLE IF NOT EXISTS "sites" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "name" TEXT NOT NULL, "url" TEXT NOT NULL, "external_checkin_url" TEXT, "platform" TEXT NOT NULL, "proxy_url" TEXT, "status" TEXT DEFAULT 'active', "is_pinned" BOOLEAN DEFAULT FALSE, "sort_order" INTEGER DEFAULT 0, "global_weight" DOUBLE PRECISION DEFAULT 1, "api_key" TEXT, "created_at" TEXT, "updated_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "sites" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "name" TEXT NOT NULL, "url" TEXT NOT NULL, "external_checkin_url" TEXT, "platform" TEXT NOT NULL, "proxy_url" TEXT, "use_system_proxy" BOOLEAN DEFAULT FALSE, "status" TEXT DEFAULT 'active', "is_pinned" BOOLEAN DEFAULT FALSE, "sort_order" INTEGER DEFAULT 0, "global_weight" DOUBLE PRECISION DEFAULT 1, "api_key" TEXT, "created_at" TEXT, "updated_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "accounts" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "site_id" INTEGER NOT NULL REFERENCES "sites"("id") ON DELETE CASCADE, "username" TEXT, "access_token" TEXT NOT NULL, "api_token" TEXT, "balance" DOUBLE PRECISION DEFAULT 0, "balance_used" DOUBLE PRECISION DEFAULT 0, "quota" DOUBLE PRECISION DEFAULT 0, "unit_cost" DOUBLE PRECISION, "value_score" DOUBLE PRECISION DEFAULT 0, "status" TEXT DEFAULT 'active', "is_pinned" BOOLEAN DEFAULT FALSE, "sort_order" INTEGER DEFAULT 0, "checkin_enabled" BOOLEAN DEFAULT TRUE, "last_checkin_at" TEXT, "last_balance_refresh" TEXT, "extra_config" TEXT, "created_at" TEXT, "updated_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "account_tokens" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "name" TEXT NOT NULL, "token" TEXT NOT NULL, "token_group" TEXT, "source" TEXT DEFAULT 'manual', "enabled" BOOLEAN DEFAULT TRUE, "is_default" BOOLEAN DEFAULT FALSE, "created_at" TEXT, "updated_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "checkin_logs" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "status" TEXT NOT NULL, "message" TEXT, "reward" TEXT, "created_at" TEXT)`,
@@ -325,7 +397,7 @@ async function ensureSchema(client: SqlClient): Promise<void> {
]
: client.dialect === 'mysql'
? [
`CREATE TABLE IF NOT EXISTS \`sites\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`name\` TEXT NOT NULL, \`url\` TEXT NOT NULL, \`external_checkin_url\` TEXT NULL, \`platform\` VARCHAR(64) NOT NULL, \`proxy_url\` TEXT NULL, \`status\` VARCHAR(32) DEFAULT 'active', \`is_pinned\` BOOLEAN DEFAULT FALSE, \`sort_order\` INT DEFAULT 0, \`global_weight\` DOUBLE DEFAULT 1, \`api_key\` TEXT NULL, \`created_at\` TEXT NULL, \`updated_at\` TEXT NULL)`,
`CREATE TABLE IF NOT EXISTS \`sites\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`name\` TEXT NOT NULL, \`url\` TEXT NOT NULL, \`external_checkin_url\` TEXT NULL, \`platform\` VARCHAR(64) NOT NULL, \`proxy_url\` TEXT NULL, \`use_system_proxy\` BOOLEAN DEFAULT FALSE, \`status\` VARCHAR(32) DEFAULT 'active', \`is_pinned\` BOOLEAN DEFAULT FALSE, \`sort_order\` INT DEFAULT 0, \`global_weight\` DOUBLE DEFAULT 1, \`api_key\` TEXT NULL, \`created_at\` TEXT NULL, \`updated_at\` TEXT NULL)`,
`CREATE TABLE IF NOT EXISTS \`accounts\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`site_id\` INT NOT NULL, \`username\` TEXT NULL, \`access_token\` TEXT NOT NULL, \`api_token\` TEXT NULL, \`balance\` DOUBLE DEFAULT 0, \`balance_used\` DOUBLE DEFAULT 0, \`quota\` DOUBLE DEFAULT 0, \`unit_cost\` DOUBLE NULL, \`value_score\` DOUBLE DEFAULT 0, \`status\` VARCHAR(32) DEFAULT 'active', \`is_pinned\` BOOLEAN DEFAULT FALSE, \`sort_order\` INT DEFAULT 0, \`checkin_enabled\` BOOLEAN DEFAULT TRUE, \`last_checkin_at\` TEXT NULL, \`last_balance_refresh\` TEXT NULL, \`extra_config\` TEXT NULL, \`created_at\` TEXT NULL, \`updated_at\` TEXT NULL, CONSTRAINT \`accounts_site_fk\` FOREIGN KEY (\`site_id\`) REFERENCES \`sites\`(\`id\`) ON DELETE CASCADE)`,
`CREATE TABLE IF NOT EXISTS \`account_tokens\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`account_id\` INT NOT NULL, \`name\` TEXT NOT NULL, \`token\` TEXT NOT NULL, \`token_group\` TEXT NULL, \`source\` VARCHAR(32) DEFAULT 'manual', \`enabled\` BOOLEAN DEFAULT TRUE, \`is_default\` BOOLEAN DEFAULT FALSE, \`created_at\` TEXT NULL, \`updated_at\` TEXT NULL, CONSTRAINT \`account_tokens_account_fk\` FOREIGN KEY (\`account_id\`) REFERENCES \`accounts\`(\`id\`) ON DELETE CASCADE)`,
`CREATE TABLE IF NOT EXISTS \`checkin_logs\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`account_id\` INT NOT NULL, \`status\` VARCHAR(32) NOT NULL, \`message\` TEXT NULL, \`reward\` TEXT NULL, \`created_at\` TEXT NULL, CONSTRAINT \`checkin_logs_account_fk\` FOREIGN KEY (\`account_id\`) REFERENCES \`accounts\`(\`id\`) ON DELETE CASCADE)`,
@@ -339,7 +411,7 @@ async function ensureSchema(client: SqlClient): Promise<void> {
`CREATE TABLE IF NOT EXISTS \`settings\` (\`key\` VARCHAR(191) PRIMARY KEY, \`value\` TEXT NULL)`,
]
: [
`CREATE TABLE IF NOT EXISTS "sites" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "name" TEXT NOT NULL, "url" TEXT NOT NULL, "external_checkin_url" TEXT, "platform" TEXT NOT NULL, "proxy_url" TEXT, "status" TEXT DEFAULT 'active', "is_pinned" INTEGER DEFAULT 0, "sort_order" INTEGER DEFAULT 0, "global_weight" REAL DEFAULT 1, "api_key" TEXT, "created_at" TEXT, "updated_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "sites" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "name" TEXT NOT NULL, "url" TEXT NOT NULL, "external_checkin_url" TEXT, "platform" TEXT NOT NULL, "proxy_url" TEXT, "use_system_proxy" INTEGER DEFAULT 0, "status" TEXT DEFAULT 'active', "is_pinned" INTEGER DEFAULT 0, "sort_order" INTEGER DEFAULT 0, "global_weight" REAL DEFAULT 1, "api_key" TEXT, "created_at" TEXT, "updated_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "accounts" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "site_id" INTEGER NOT NULL REFERENCES "sites"("id") ON DELETE CASCADE, "username" TEXT, "access_token" TEXT NOT NULL, "api_token" TEXT, "balance" REAL DEFAULT 0, "balance_used" REAL DEFAULT 0, "quota" REAL DEFAULT 0, "unit_cost" REAL, "value_score" REAL DEFAULT 0, "status" TEXT DEFAULT 'active', "is_pinned" INTEGER DEFAULT 0, "sort_order" INTEGER DEFAULT 0, "checkin_enabled" INTEGER DEFAULT 1, "last_checkin_at" TEXT, "last_balance_refresh" TEXT, "extra_config" TEXT, "created_at" TEXT, "updated_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "account_tokens" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "name" TEXT NOT NULL, "token" TEXT NOT NULL, "token_group" TEXT, "source" TEXT DEFAULT 'manual', "enabled" INTEGER DEFAULT 1, "is_default" INTEGER DEFAULT 0, "created_at" TEXT, "updated_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "checkin_logs" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "status" TEXT NOT NULL, "message" TEXT, "reward" TEXT, "created_at" TEXT)`,
@@ -356,6 +428,8 @@ async function ensureSchema(client: SqlClient): Promise<void> {
for (const sqlText of statements) {
await client.execute(sqlText);
}
await ensureSiteSchemaCompatibility(createSiteSchemaInspector(client));
}
async function ensureTargetState(client: SqlClient, overwrite: boolean): Promise<void> {
@@ -392,7 +466,7 @@ function buildStatements(snapshot: BackupSnapshot): InsertStatement[] {
for (const row of snapshot.accounts.sites) {
statements.push({
table: 'sites',
columns: ['id', 'name', 'url', 'external_checkin_url', 'platform', 'proxy_url', 'status', 'is_pinned', 'sort_order', 'global_weight', 'api_key', 'created_at', 'updated_at'],
columns: ['id', 'name', 'url', 'external_checkin_url', 'platform', 'proxy_url', 'use_system_proxy', 'status', 'is_pinned', 'sort_order', 'global_weight', 'api_key', 'created_at', 'updated_at'],
values: [
asNumber(row.id, 0),
asNullableString(row.name),
@@ -400,6 +474,7 @@ function buildStatements(snapshot: BackupSnapshot): InsertStatement[] {
asNullableString(row.externalCheckinUrl),
asNullableString(row.platform),
asNullableString(row.proxyUrl),
asBoolean(row.useSystemProxy, false),
asNullableString(row.status) ?? 'active',
asBoolean(row.isPinned, false),
asNumber(row.sortOrder, 0),
@@ -730,3 +805,8 @@ export async function testDatabaseConnection(input: DatabaseMigrationInput): Pro
connection: maskConnectionString(normalized.connectionString),
};
}
export const __databaseMigrationServiceTestUtils = {
ensureSchema,
buildStatements,
};
@@ -116,9 +116,10 @@ describe('downstreamApiKeyService', () => {
expect(service.isModelAllowedByPolicy('gemini-2.0-flash', result.policy)).toBe(false);
});
it('treats selected groups as additional allowed model scope (union semantics)', async () => {
it('treats selected groups as additional allowed exposed route scope (union semantics)', async () => {
const claudeGroup = await db.insert(schema.tokenRoutes).values({
modelPattern: 're:^claude-(opus|sonnet)-4-6$',
displayName: 'claude-4-6-group',
enabled: true,
}).returning().get();
@@ -128,8 +129,10 @@ describe('downstreamApiKeyService', () => {
siteWeightMultipliers: {},
};
expect(service.isModelAllowedByPolicy('claude-opus-4-6', policy)).toBe(false);
expect(await service.isModelAllowedByPolicyOrAllowedRoutes('claude-opus-4-6', policy)).toBe(true);
expect(service.isModelAllowedByPolicy('claude-4-6-group', policy)).toBe(false);
expect(await service.isModelAllowedByPolicyOrAllowedRoutes('claude-4-6-group', policy)).toBe(true);
expect(await service.isModelAllowedByPolicyOrAllowedRoutes('claude-opus-4-6', policy)).toBe(false);
expect(await service.isModelAllowedByPolicyOrAllowedRoutes('gpt-4o-mini', policy)).toBe(true);
expect(await service.isModelAllowedByPolicyOrAllowedRoutes('gemini-2.0-flash', policy)).toBe(false);
});
@@ -149,7 +152,7 @@ describe('downstreamApiKeyService', () => {
expect(await service.isModelAllowedByPolicyOrAllowedRoutes('claude-sonnet-4-6', policy)).toBe(false);
});
it('authorizes models by selected route display name alias', async () => {
it('only authorizes selected route display name alias, not models covered by group pattern', async () => {
const aliasRoute = await db.insert(schema.tokenRoutes).values({
modelPattern: 're:^claude-(opus|sonnet)-4-5$',
displayName: 'claude-opus-4-6',
@@ -163,8 +166,8 @@ describe('downstreamApiKeyService', () => {
};
expect(await service.isModelAllowedByPolicyOrAllowedRoutes('claude-opus-4-6', policy)).toBe(true);
expect(await service.isModelAllowedByPolicyOrAllowedRoutes('claude-sonnet-4-5', policy)).toBe(true);
expect(await service.isModelAllowedByPolicyOrAllowedRoutes('claude-opus-4-5', policy)).toBe(true);
expect(await service.isModelAllowedByPolicyOrAllowedRoutes('claude-sonnet-4-5', policy)).toBe(false);
expect(await service.isModelAllowedByPolicyOrAllowedRoutes('claude-opus-4-5', policy)).toBe(false);
expect(await service.isModelAllowedByPolicyOrAllowedRoutes('gpt-4o-mini', policy)).toBe(false);
});
@@ -68,6 +68,10 @@ function maskSecret(value: string): string {
return `${value.slice(0, 4)}****${value.slice(-4)}`;
}
function getExposedRouteName(route: { modelPattern: string; displayName: string | null }): string {
return (route.displayName || '').trim() || route.modelPattern.trim();
}
function normalizePositiveNumberOrNull(value: unknown): number | null {
if (value === null || value === undefined || value === '') return null;
const n = Number(value);
@@ -191,11 +195,7 @@ async function isModelMatchedByAllowedRoutes(model: string, allowedRouteIds: num
))
.all();
return routes.some((route) => {
if (matchesDownstreamModelPattern(model, route.modelPattern)) return true;
const alias = (route.displayName || '').trim();
return !!alias && alias === model;
});
return routes.some((route) => getExposedRouteName(route) === model);
}
export async function isModelAllowedByPolicyOrAllowedRoutes(model: string, policy: DownstreamRoutingPolicy): Promise<boolean> {
@@ -0,0 +1,138 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
type DbModule = typeof import('../db/index.js');
type TokenRouterModule = typeof import('./tokenRouter.js');
describe('TokenRouter session decoupling', () => {
let db: DbModule['db'];
let schema: DbModule['schema'];
let TokenRouter: TokenRouterModule['TokenRouter'];
let invalidateTokenRouterCache: TokenRouterModule['invalidateTokenRouterCache'];
let dataDir = '';
beforeAll(async () => {
dataDir = mkdtempSync(join(tmpdir(), 'metapi-token-router-session-decoupling-'));
process.env.DATA_DIR = dataDir;
await import('../db/migrate.js');
const dbModule = await import('../db/index.js');
const tokenRouterModule = await import('./tokenRouter.js');
db = dbModule.db;
schema = dbModule.schema;
TokenRouter = tokenRouterModule.TokenRouter;
invalidateTokenRouterCache = tokenRouterModule.invalidateTokenRouterCache;
});
beforeEach(async () => {
await db.delete(schema.routeChannels).run();
await db.delete(schema.tokenRoutes).run();
await db.delete(schema.tokenModelAvailability).run();
await db.delete(schema.modelAvailability).run();
await db.delete(schema.accountTokens).run();
await db.delete(schema.accounts).run();
await db.delete(schema.sites).run();
invalidateTokenRouterCache();
});
afterAll(() => {
invalidateTokenRouterCache();
delete process.env.DATA_DIR;
});
it('keeps explicit token-bound channels routable when account session is expired', async () => {
const site = await db.insert(schema.sites).values({
name: 'managed-site',
url: 'https://managed.example.com',
platform: 'new-api',
status: 'active',
}).returning().get();
const account = await db.insert(schema.accounts).values({
siteId: site.id,
username: 'expired-session-user',
accessToken: 'expired-session-token',
apiToken: null,
status: 'expired',
}).returning().get();
const token = await db.insert(schema.accountTokens).values({
accountId: account.id,
name: 'stable-token',
token: 'sk-stable-token',
enabled: true,
isDefault: true,
}).returning().get();
const route = await db.insert(schema.tokenRoutes).values({
modelPattern: 'gpt-4o-mini',
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,
manualOverride: false,
}).returning().get();
const router = new TokenRouter();
const selected = await router.selectChannel('gpt-4o-mini');
expect(selected).not.toBeNull();
expect(selected?.channel.id).toBe(channel.id);
expect(selected?.tokenValue).toBe('sk-stable-token');
expect(selected?.account.id).toBe(account.id);
const decision = await router.explainSelection('gpt-4o-mini');
const candidate = decision.candidates.find((item) => item.channelId === channel.id);
expect(candidate?.eligible).toBe(true);
expect(candidate?.reason).not.toContain('账号状态=expired');
});
it('still blocks fallback account-token channels when account session is expired', async () => {
const site = await db.insert(schema.sites).values({
name: 'legacy-site',
url: 'https://legacy.example.com',
platform: 'new-api',
status: 'active',
}).returning().get();
const account = await db.insert(schema.accounts).values({
siteId: site.id,
username: 'legacy-user',
accessToken: 'expired-session-token',
apiToken: 'sk-fallback-account-token',
status: 'expired',
}).returning().get();
const route = await db.insert(schema.tokenRoutes).values({
modelPattern: 'gpt-4.1-mini',
enabled: true,
}).returning().get();
const channel = await db.insert(schema.routeChannels).values({
routeId: route.id,
accountId: account.id,
tokenId: null,
priority: 0,
weight: 10,
enabled: true,
manualOverride: false,
}).returning().get();
const router = new TokenRouter();
const selected = await router.selectChannel('gpt-4.1-mini');
expect(selected).toBeNull();
const decision = await router.explainSelection('gpt-4.1-mini');
const candidate = decision.candidates.find((item) => item.channelId === channel.id);
expect(candidate?.eligible).toBe(false);
expect(candidate?.reason).toContain('账号状态=expired');
});
});
+105 -61
View File
@@ -188,12 +188,19 @@ export interface RouteDecisionExplanation {
const DEFAULT_DOWNSTREAM_POLICY: DownstreamRoutingPolicy = EMPTY_DOWNSTREAM_ROUTING_POLICY;
type ExplainSelectionOptions = {
excludeChannelIds?: number[];
bypassSourceModelCheck?: boolean;
useChannelSourceModelForCost?: boolean;
downstreamPolicy?: DownstreamRoutingPolicy;
};
type ExplainSelectionOptions = {
excludeChannelIds?: number[];
bypassSourceModelCheck?: boolean;
useChannelSourceModelForCost?: boolean;
downstreamPolicy?: DownstreamRoutingPolicy;
};
type CandidateEligibilityOptions = {
requestedModel: string;
bypassSourceModelCheck?: boolean;
excludeChannelIds?: number[];
nowIso?: string;
};
type CostSignal = {
unitCost: number;
@@ -314,10 +321,10 @@ function normalizeChannelSourceModel(channelSourceModel: string | null | undefin
return (channelSourceModel || '').trim();
}
function resolveActualModelForSelectedChannel(
requestedModel: string,
route: typeof schema.tokenRoutes.$inferSelect,
mappedModel: string,
function resolveActualModelForSelectedChannel(
requestedModel: string,
route: typeof schema.tokenRoutes.$inferSelect,
mappedModel: string,
channelSourceModel: string | null | undefined,
): string {
const sourceModel = normalizeChannelSourceModel(channelSourceModel);
@@ -358,12 +365,16 @@ function resolveEffectiveUnitCost(candidate: RouteChannelCandidate, modelName: s
};
}
return {
unitCost: Math.max(config.routingFallbackUnitCost || 1, MIN_EFFECTIVE_UNIT_COST),
source: 'fallback',
};
}
return {
unitCost: Math.max(config.routingFallbackUnitCost || 1, MIN_EFFECTIVE_UNIT_COST),
source: 'fallback',
};
}
function isExplicitTokenChannel(candidate: RouteChannelCandidate): boolean {
return typeof candidate.channel.tokenId === 'number' && candidate.channel.tokenId > 0;
}
export class TokenRouter {
/**
* Find matching route and select a channel for the given model.
@@ -377,19 +388,17 @@ export class TokenRouter {
const mappedModel = resolveMappedModel(requestedModel, match.route.modelMapping);
const requestedByDisplayName = isRouteDisplayNameMatch(requestedModel, match.route.displayName);
const bypassSourceModelCheck = requestedByDisplayName;
// Filter channels: enabled, not in cooldown, account/site active with token
const nowIso = new Date().toISOString();
const nowMs = Date.now();
const available = match.channels.filter((c) =>
(bypassSourceModelCheck || channelSupportsRequestedModel(c.channel.sourceModel, requestedModel)) &&
c.channel.enabled &&
c.account.status === 'active' &&
!isSiteDisabled(c.site.status) &&
!!this.resolveChannelTokenValue(c) &&
(!c.channel.cooldownUntil || c.channel.cooldownUntil <= nowIso),
);
const bypassSourceModelCheck = requestedByDisplayName;
const nowIso = new Date().toISOString();
const nowMs = Date.now();
const available = match.channels.filter((candidate) => (
this.getCandidateEligibilityReasons(candidate, {
requestedModel,
bypassSourceModelCheck,
nowIso,
}).length === 0
));
if (available.length === 0) return null;
@@ -457,17 +466,16 @@ export class TokenRouter {
const requestedByDisplayName = isRouteDisplayNameMatch(requestedModel, match.route.displayName);
const bypassSourceModelCheck = requestedByDisplayName;
const nowIso = new Date().toISOString();
const nowMs = Date.now();
const available = match.channels.filter((c) =>
(bypassSourceModelCheck || channelSupportsRequestedModel(c.channel.sourceModel, requestedModel)) &&
c.channel.enabled &&
c.account.status === 'active' &&
!isSiteDisabled(c.site.status) &&
!!this.resolveChannelTokenValue(c) &&
(!c.channel.cooldownUntil || c.channel.cooldownUntil <= nowIso) &&
!excludeChannelIds.includes(c.channel.id),
);
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;
@@ -577,20 +585,15 @@ export class TokenRouter {
const candidates: RouteDecisionCandidate[] = [];
const candidateMap = new Map<number, RouteDecisionCandidate>();
for (const row of match.channels) {
const reasonParts: string[] = [];
if (!bypassSourceModelCheck && !channelSupportsRequestedModel(row.channel.sourceModel, requestedModel)) {
reasonParts.push(`来源模型不匹配=${row.channel.sourceModel || ''}`);
}
if (!row.channel.enabled) reasonParts.push('通道禁用');
if (row.account.status !== 'active') reasonParts.push(`账号状态=${row.account.status}`);
if (isSiteDisabled(row.site.status)) reasonParts.push(`站点状态=${row.site.status || 'disabled'}`);
if (excludeChannelIds.includes(row.channel.id)) reasonParts.push('当前请求已尝试');
const tokenValue = this.resolveChannelTokenValue(row);
if (!tokenValue) reasonParts.push('令牌不可用');
if (row.channel.cooldownUntil && row.channel.cooldownUntil > nowIso) reasonParts.push('冷却中');
const recentlyFailed = isChannelRecentlyFailed(row.channel, nowMs, RECENT_FAILURE_AVOID_SEC);
for (const row of match.channels) {
const reasonParts = this.getCandidateEligibilityReasons(row, {
requestedModel,
bypassSourceModelCheck,
excludeChannelIds,
nowIso,
});
const recentlyFailed = isChannelRecentlyFailed(row.channel, nowMs, RECENT_FAILURE_AVOID_SEC);
const eligible = reasonParts.length === 0;
const candidate: RouteDecisionCandidate = {
channelId: row.channel.id,
@@ -821,10 +824,10 @@ export class TokenRouter {
return await loadRouteMatch(route);
}
private resolveChannelTokenValue(candidate: {
channel: typeof schema.routeChannels.$inferSelect;
account: typeof schema.accounts.$inferSelect;
site?: typeof schema.sites.$inferSelect | null;
private resolveChannelTokenValue(candidate: {
channel: typeof schema.routeChannels.$inferSelect;
account: typeof schema.accounts.$inferSelect;
site?: typeof schema.sites.$inferSelect | null;
token: typeof schema.accountTokens.$inferSelect | null;
}): string | null {
if (candidate.channel.tokenId) {
@@ -837,9 +840,50 @@ export class TokenRouter {
const fallback = candidate.account.apiToken?.trim();
return fallback || null;
}
private weightedRandomSelect(
candidates: RouteChannelCandidate[],
private getCandidateEligibilityReasons(
candidate: RouteChannelCandidate,
options: CandidateEligibilityOptions,
): string[] {
const reasonParts: string[] = [];
const bypassSourceModelCheck = options.bypassSourceModelCheck ?? false;
const excludeChannelIds = options.excludeChannelIds ?? [];
const nowIso = options.nowIso ?? new Date().toISOString();
if (!bypassSourceModelCheck && !channelSupportsRequestedModel(candidate.channel.sourceModel, options.requestedModel)) {
reasonParts.push(`来源模型不匹配=${candidate.channel.sourceModel || ''}`);
}
if (!candidate.channel.enabled) reasonParts.push('通道禁用');
if (isExplicitTokenChannel(candidate)) {
if (candidate.account.status === 'disabled') {
reasonParts.push(`账号状态=${candidate.account.status}`);
}
} else if (candidate.account.status !== 'active') {
reasonParts.push(`账号状态=${candidate.account.status}`);
}
if (isSiteDisabled(candidate.site.status)) {
reasonParts.push(`站点状态=${candidate.site.status || 'disabled'}`);
}
if (excludeChannelIds.includes(candidate.channel.id)) {
reasonParts.push('当前请求已尝试');
}
const tokenValue = this.resolveChannelTokenValue(candidate);
if (!tokenValue) reasonParts.push('令牌不可用');
if (candidate.channel.cooldownUntil && candidate.channel.cooldownUntil > nowIso) {
reasonParts.push('冷却中');
}
return reasonParts;
}
private weightedRandomSelect(
candidates: RouteChannelCandidate[],
modelName: string | ((candidate: RouteChannelCandidate) => string),
downstreamPolicy: DownstreamRoutingPolicy,
) {