fix route grouping and codex websocket regressions

This commit is contained in:
cita
2026-03-18 20:38:01 +08:00
parent 332007d4fb
commit f769f43909
38 changed files with 2453 additions and 636 deletions
+25
View File
@@ -28,6 +28,7 @@ describe('backupService', () => {
beforeEach(async () => {
await db.delete(schema.routeChannels).run();
await db.delete(schema.routeGroupSources).run();
await db.delete(schema.tokenRoutes).run();
await db.delete(schema.tokenModelAvailability).run();
await db.delete(schema.modelAvailability).run();
@@ -96,16 +97,34 @@ describe('backupService', () => {
updatedAt: now,
}).returning().get();
const sourceRoute = await db.insert(schema.tokenRoutes).values({
modelPattern: 'gpt-source-*',
displayName: 'gpt-source',
routeMode: 'pattern',
enabled: true,
createdAt: now,
updatedAt: now,
}).returning().get();
const route = await db.insert(schema.tokenRoutes).values({
modelPattern: 'gpt-*',
displayName: 'gpt-route',
displayIcon: 'icon-gpt',
modelMapping: JSON.stringify({ to: 'gpt-4o-mini' }),
routeMode: 'explicit_group',
decisionSnapshot: JSON.stringify({ channelIds: [1, 2] }),
decisionRefreshedAt: now,
routingStrategy: 'round_robin',
enabled: true,
createdAt: now,
updatedAt: now,
}).returning().get();
await db.insert(schema.routeGroupSources).values({
groupRouteId: route.id,
sourceRouteId: sourceRoute.id,
}).run();
await db.insert(schema.routeChannels).values({
routeId: route.id,
accountId: account.id,
@@ -150,6 +169,12 @@ describe('backupService', () => {
expect(restoredRoute?.displayName).toBe('gpt-route');
expect(restoredRoute?.displayIcon).toBe('icon-gpt');
expect(restoredRoute?.routeMode).toBe('explicit_group');
expect(restoredRoute?.decisionSnapshot).toBe('{"channelIds":[1,2]}');
expect(restoredRoute?.decisionRefreshedAt).toBe(now);
expect(restoredRoute?.routingStrategy).toBe('round_robin');
const restoredGroupSource = await db.select().from(schema.routeGroupSources).where(eq(schema.routeGroupSources.groupRouteId, route.id)).get();
expect(restoredGroupSource?.sourceRouteId).toBe(sourceRoute.id);
expect(restoredChannel?.sourceModel).toBe('gpt-4o');
});
+22
View File
@@ -13,6 +13,7 @@ type AccountRow = typeof schema.accounts.$inferSelect;
type AccountTokenRow = typeof schema.accountTokens.$inferSelect;
type TokenRouteRow = typeof schema.tokenRoutes.$inferSelect;
type RouteChannelRow = typeof schema.routeChannels.$inferSelect;
type RouteGroupSourceRow = typeof schema.routeGroupSources.$inferSelect;
interface AccountsBackupSection {
sites: SiteRow[];
@@ -20,6 +21,7 @@ interface AccountsBackupSection {
accountTokens: AccountTokenRow[];
tokenRoutes: TokenRouteRow[];
routeChannels: RouteChannelRow[];
routeGroupSources: RouteGroupSourceRow[];
}
interface PreferencesBackupSection {
@@ -270,6 +272,7 @@ function buildAccountsSectionFromRefBackup(data: RawBackupData): AccountsBackupS
accountTokens,
tokenRoutes,
routeChannels,
routeGroupSources: [],
};
}
@@ -348,6 +351,7 @@ async function exportAccountsSection(): Promise<AccountsBackupSection> {
const accountTokens = await db.select().from(schema.accountTokens).orderBy(asc(schema.accountTokens.id)).all();
const tokenRoutes = await db.select().from(schema.tokenRoutes).orderBy(asc(schema.tokenRoutes.id)).all();
const routeChannels = await db.select().from(schema.routeChannels).orderBy(asc(schema.routeChannels.id)).all();
const routeGroupSources = await db.select().from(schema.routeGroupSources).orderBy(asc(schema.routeGroupSources.id)).all();
return {
sites,
@@ -355,6 +359,7 @@ async function exportAccountsSection(): Promise<AccountsBackupSection> {
accountTokens,
tokenRoutes,
routeChannels,
routeGroupSources,
};
}
@@ -405,6 +410,9 @@ function coerceAccountsSection(input: unknown): AccountsBackupSection | null {
const accountTokens = Array.isArray(input.accountTokens) ? input.accountTokens as AccountTokenRow[] : null;
const tokenRoutes = Array.isArray(input.tokenRoutes) ? input.tokenRoutes as TokenRouteRow[] : null;
const routeChannels = Array.isArray(input.routeChannels) ? input.routeChannels as RouteChannelRow[] : null;
const routeGroupSources = Array.isArray(input.routeGroupSources)
? input.routeGroupSources as RouteGroupSourceRow[]
: [];
if (!sites || !accounts || !accountTokens || !tokenRoutes || !routeChannels) return null;
@@ -414,6 +422,7 @@ function coerceAccountsSection(input: unknown): AccountsBackupSection | null {
accountTokens,
tokenRoutes,
routeChannels,
routeGroupSources,
};
}
@@ -477,6 +486,7 @@ function detectPreferencesSection(data: RawBackupData): PreferencesBackupSection
async function importAccountsSection(section: AccountsBackupSection): Promise<void> {
await db.transaction(async (tx) => {
await tx.delete(schema.routeChannels).run();
await tx.delete(schema.routeGroupSources).run();
await tx.delete(schema.tokenRoutes).run();
await tx.delete(schema.tokenModelAvailability).run();
await tx.delete(schema.modelAvailability).run();
@@ -557,12 +567,24 @@ async function importAccountsSection(section: AccountsBackupSection): Promise<vo
displayName: row.displayName ?? null,
displayIcon: row.displayIcon ?? null,
modelMapping: row.modelMapping,
routeMode: row.routeMode ?? 'pattern',
decisionSnapshot: row.decisionSnapshot ?? null,
decisionRefreshedAt: row.decisionRefreshedAt ?? null,
routingStrategy: row.routingStrategy ?? 'weighted',
enabled: row.enabled,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
}).run();
}
for (const row of section.routeGroupSources || []) {
await tx.insert(schema.routeGroupSources).values({
id: row.id,
groupRouteId: row.groupRouteId,
sourceRouteId: row.sourceRouteId,
}).run();
}
for (const row of section.routeChannels) {
await tx.insert(schema.routeChannels).values({
id: row.id,
@@ -225,7 +225,20 @@ describe('databaseMigrationService', () => {
checkinLogs: [],
modelAvailability: [],
tokenModelAvailability: [],
tokenRoutes: [],
tokenRoutes: [{
id: 10,
modelPattern: 'claude-opus-4-6',
displayName: 'claude-opus-4-6',
displayIcon: 'icon-claude',
modelMapping: null,
routeMode: 'explicit_group',
decisionSnapshot: '{"channels":[1]}',
decisionRefreshedAt: '2026-03-14T01:30:00.000Z',
routingStrategy: 'round_robin',
enabled: true,
createdAt: '2026-03-14T00:00:00.000Z',
updatedAt: '2026-03-14T01:00:00.000Z',
}],
routeChannels: [],
proxyLogs: [],
proxyVideoTasks: [{
@@ -260,6 +273,11 @@ describe('databaseMigrationService', () => {
updatedAt: '2026-03-14T01:00:00.000Z',
deletedAt: null,
}],
routeGroupSources: [{
id: 9,
groupRouteId: 12,
sourceRouteId: 13,
}],
downstreamApiKeys: [],
events: [],
},
@@ -271,5 +289,10 @@ describe('databaseMigrationService', () => {
expect(statements.some((statement) => statement.table === 'site_disabled_models')).toBe(true);
expect(statements.some((statement) => statement.table === 'proxy_video_tasks')).toBe(true);
expect(statements.some((statement) => statement.table === 'proxy_files')).toBe(true);
expect(statements.some((statement) => statement.table === 'route_group_sources')).toBe(true);
const tokenRouteStatement = statements.find((statement) => statement.table === 'token_routes');
const routeModeIndex = tokenRouteStatement?.columns.indexOf('route_mode') ?? -1;
expect(routeModeIndex).toBeGreaterThanOrEqual(0);
expect(tokenRouteStatement?.values[routeModeIndex]).toBe('explicit_group');
});
});
@@ -36,6 +36,7 @@ type BackupSnapshot = {
tokenModelAvailability: Array<Record<string, unknown>>;
tokenRoutes: Array<Record<string, unknown>>;
routeChannels: Array<Record<string, unknown>>;
routeGroupSources: Array<Record<string, unknown>>;
proxyLogs: Array<Record<string, unknown>>;
proxyVideoTasks: Array<Record<string, unknown>>;
proxyFiles: Array<Record<string, unknown>>;
@@ -60,6 +61,7 @@ export interface DatabaseMigrationSummary {
accountTokens: number;
tokenRoutes: number;
routeChannels: number;
routeGroupSources: number;
checkinLogs: number;
modelAvailability: number;
tokenModelAvailability: number;
@@ -218,6 +220,7 @@ async function toBackupSnapshot(): Promise<BackupSnapshot> {
tokenModelAvailability: await db.select().from(schema.tokenModelAvailability).all() as Array<Record<string, unknown>>,
tokenRoutes: await db.select().from(schema.tokenRoutes).all() as Array<Record<string, unknown>>,
routeChannels: await db.select().from(schema.routeChannels).all() as Array<Record<string, unknown>>,
routeGroupSources: await db.select().from(schema.routeGroupSources).all() as Array<Record<string, unknown>>,
proxyLogs: await db.select().from(schema.proxyLogs).all() as Array<Record<string, unknown>>,
proxyVideoTasks: await db.select().from(schema.proxyVideoTasks).all() as Array<Record<string, unknown>>,
proxyFiles: await db.select().from(schema.proxyFiles).all() as Array<Record<string, unknown>>,
@@ -245,6 +248,7 @@ async function ensureTargetState(client: SqlClient, overwrite: boolean): Promise
async function clearTargetData(client: SqlClient): Promise<void> {
const tables = [
'route_channels',
'route_group_sources',
'token_model_availability',
'model_availability',
'checkin_logs',
@@ -401,13 +405,14 @@ function buildStatements(snapshot: BackupSnapshot): InsertStatement[] {
for (const row of snapshot.accounts.tokenRoutes) {
statements.push({
table: 'token_routes',
columns: ['id', 'model_pattern', 'display_name', 'display_icon', 'model_mapping', 'decision_snapshot', 'decision_refreshed_at', 'routing_strategy', 'enabled', 'created_at', 'updated_at'],
columns: ['id', 'model_pattern', 'display_name', 'display_icon', 'model_mapping', 'route_mode', 'decision_snapshot', 'decision_refreshed_at', 'routing_strategy', 'enabled', 'created_at', 'updated_at'],
values: [
asNumber(row.id, 0),
asNullableString(row.modelPattern),
asNullableString(row.displayName),
asNullableString(row.displayIcon),
asNullableString(row.modelMapping),
asNullableString(row.routeMode) ?? 'pattern',
asNullableString(row.decisionSnapshot),
asNullableString(row.decisionRefreshedAt),
asNullableString(row.routingStrategy),
@@ -446,6 +451,18 @@ function buildStatements(snapshot: BackupSnapshot): InsertStatement[] {
});
}
for (const row of (snapshot.accounts.routeGroupSources || [])) {
statements.push({
table: 'route_group_sources',
columns: ['id', 'group_route_id', 'source_route_id'],
values: [
asNumber(row.id, 0),
asNumber(row.groupRouteId, 0),
asNumber(row.sourceRouteId, 0),
],
});
}
for (const row of snapshot.accounts.proxyLogs) {
statements.push({
table: 'proxy_logs',
@@ -608,6 +625,7 @@ async function syncPostgresSequences(client: SqlClient): Promise<void> {
'token_model_availability',
'token_routes',
'route_channels',
'route_group_sources',
'proxy_logs',
'proxy_video_tasks',
'proxy_files',
@@ -615,9 +633,9 @@ async function syncPostgresSequences(client: SqlClient): Promise<void> {
'events',
];
for (const table of tables) {
await client.execute(`SELECT setval(pg_get_serial_sequence('${table}', 'id'), COALESCE((SELECT MAX(id) FROM "${table}"), 1), TRUE)`);
await client.execute(`SELECT setval(pg_get_serial_sequence('${table}', 'id'), COALESCE((SELECT MAX(id) FROM "${table}"), 1), TRUE)`);
}
}
}
export async function bootstrapRuntimeDatabaseSchema(input: Pick<NormalizedDatabaseMigrationInput, 'dialect' | 'connectionString' | 'ssl'>): Promise<void> {
const client = await createClient({
@@ -680,6 +698,7 @@ export async function migrateCurrentDatabase(input: DatabaseMigrationInput): Pro
downstreamApiKeys: snapshot.accounts.downstreamApiKeys.length,
events: snapshot.accounts.events.length,
settings: snapshot.preferences.settings.length,
routeGroupSources: snapshot.accounts.routeGroupSources.length,
},
};
}
+4 -1
View File
@@ -1069,7 +1069,7 @@ export async function rebuildTokenRoutesFromAvailability() {
let removedRoutes = 0;
for (const [modelName, candidateMap] of modelCandidates.entries()) {
let route = routes.find((r) => r.modelPattern === modelName);
let route = routes.find((r) => (r.routeMode || 'pattern') !== 'explicit_group' && r.modelPattern === modelName);
if (!route) {
const inserted = await db.insert(schema.tokenRoutes).values({
modelPattern: modelName,
@@ -1138,6 +1138,9 @@ export async function rebuildTokenRoutesFromAvailability() {
const latestModelNames = new Set<string>(Array.from(modelCandidates.keys()));
for (const route of routes) {
if ((route.routeMode || 'pattern') === 'explicit_group') {
continue;
}
const modelPattern = (route.modelPattern || '').trim();
if (!modelPattern || !isExactModelPattern(modelPattern) || latestModelNames.has(modelPattern)) {
continue;
+166 -53
View File
@@ -1,5 +1,5 @@
import { eq } from 'drizzle-orm';
import { minimatch } from 'minimatch';
import { eq, inArray } from 'drizzle-orm';
import { minimatch } from 'minimatch';
import { db, schema } from '../db/index.js';
import { config } from '../config.js';
import { getCachedModelRoutingReferenceCost, refreshModelPricingCatalog } from './modelPricingService.js';
@@ -12,9 +12,9 @@ import { type DownstreamRoutingPolicy, EMPTY_DOWNSTREAM_ROUTING_POLICY } from '.
import { isUsableAccountToken } from './accountTokenService.js';
import { getOauthInfoFromExtraConfig } from './oauth/oauthAccount.js';
interface RouteMatch {
route: typeof schema.tokenRoutes.$inferSelect;
channels: Array<{
interface RouteMatch {
route: RouteRow;
channels: Array<{
channel: typeof schema.routeChannels.$inferSelect;
account: typeof schema.accounts.$inferSelect;
site: typeof schema.sites.$inferSelect;
@@ -66,8 +66,12 @@ function resolveRoundRobinCooldownSec(level: number): number {
return ROUND_ROBIN_COOLDOWN_LEVELS_SEC[normalizedLevel] ?? 0;
}
type RouteRow = typeof schema.tokenRoutes.$inferSelect;
type ChannelRow = typeof schema.routeChannels.$inferSelect;
type RouteMode = 'pattern' | 'explicit_group';
type RouteRow = typeof schema.tokenRoutes.$inferSelect & {
routeMode: RouteMode;
sourceRouteIds: number[];
};
type ChannelRow = typeof schema.routeChannels.$inferSelect;
type RouteCacheSnapshot = {
loadedAt: number;
@@ -96,34 +100,71 @@ function isCacheFresh(loadedAt: number, nowMs: number): boolean {
}
async function loadEnabledRoutes(nowMs = Date.now()): Promise<RouteRow[]> {
if (isCacheFresh(routeCacheSnapshot.loadedAt, nowMs)) {
return routeCacheSnapshot.routes;
}
const routes = await db.select().from(schema.tokenRoutes)
if (isCacheFresh(routeCacheSnapshot.loadedAt, nowMs)) {
return routeCacheSnapshot.routes;
}
const rawRoutes = await db.select().from(schema.tokenRoutes)
.where(eq(schema.tokenRoutes.enabled, true))
.all();
routeCacheSnapshot = {
loadedAt: nowMs,
routes,
const explicitGroupRouteIds = rawRoutes
.filter((route) => normalizeRouteMode(route.routeMode) === 'explicit_group')
.map((route) => route.id);
const sourceRows = explicitGroupRouteIds.length > 0
? await db.select().from(schema.routeGroupSources)
.where(inArray(schema.routeGroupSources.groupRouteId, explicitGroupRouteIds))
.all()
: [];
const sourceIdsByRouteId = new Map<number, number[]>();
for (const row of sourceRows) {
if (!sourceIdsByRouteId.has(row.groupRouteId)) {
sourceIdsByRouteId.set(row.groupRouteId, []);
}
sourceIdsByRouteId.get(row.groupRouteId)!.push(row.sourceRouteId);
}
const routes = rawRoutes.map((route) => ({
...route,
routeMode: normalizeRouteMode(route.routeMode),
sourceRouteIds: Array.from(new Set(sourceIdsByRouteId.get(route.id) ?? [])),
}));
routeCacheSnapshot = {
loadedAt: nowMs,
routes,
};
return routes;
}
async function loadRouteMatch(route: RouteRow, nowMs = Date.now()): Promise<RouteMatch> {
const cached = routeMatchCache.get(route.id);
if (cached && isCacheFresh(cached.loadedAt, nowMs)) {
return cached.match;
}
const channels = await db
.select()
.from(schema.routeChannels)
.innerJoin(schema.accounts, eq(schema.routeChannels.accountId, schema.accounts.id))
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
.leftJoin(schema.accountTokens, eq(schema.routeChannels.tokenId, schema.accountTokens.id))
.where(eq(schema.routeChannels.routeId, route.id))
.all();
const cached = routeMatchCache.get(route.id);
if (cached && isCacheFresh(cached.loadedAt, nowMs)) {
return cached.match;
}
const routeIds = (() => {
if (!isExplicitGroupRoute(route)) {
return [route.id];
}
return Array.from(new Set(route.sourceRouteIds.filter((routeId) => Number.isFinite(routeId) && routeId > 0)));
})();
const enabledSourceRouteIds = isExplicitGroupRoute(route)
? (await loadEnabledRoutes(nowMs))
.filter((item) => (
routeIds.includes(item.id)
&& !isExplicitGroupRoute(item)
&& isExactRouteModelPattern(item.modelPattern)
))
.map((item) => item.id)
: routeIds;
const channels = enabledSourceRouteIds.length > 0
? await db
.select()
.from(schema.routeChannels)
.innerJoin(schema.accounts, eq(schema.routeChannels.accountId, schema.accounts.id))
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
.leftJoin(schema.accountTokens, eq(schema.routeChannels.tokenId, schema.accountTokens.id))
.where(inArray(schema.routeChannels.routeId, enabledSourceRouteIds))
.all()
: [];
const mapped = channels.map((row) => ({
channel: row.route_channels,
@@ -279,23 +320,79 @@ function isExactRouteModelPattern(pattern: string): boolean {
if (isRegexModelPattern(normalizedPattern)) return false;
return !/[\*\?\[]/.test(normalizedPattern);
}
function normalizeRouteMode(routeMode: string | null | undefined): RouteMode {
return routeMode === 'explicit_group' ? 'explicit_group' : 'pattern';
}
function isExplicitGroupRoute(route: Pick<RouteRow, 'routeMode'> | Pick<typeof schema.tokenRoutes.$inferSelect, 'routeMode'>): boolean {
return normalizeRouteMode(route.routeMode) === 'explicit_group';
}
function normalizeRouteDisplayName(displayName: string | null | undefined): string {
return (displayName || '').trim();
}
function normalizeRouteDisplayName(displayName: string | null | undefined): string {
return (displayName || '').trim();
}
function isRouteDisplayNameMatch(model: string, displayName: string | null | undefined): boolean {
const alias = normalizeRouteDisplayName(displayName);
return !!alias && alias === model;
}
function matchesRouteRequestModel(model: string, route: typeof schema.tokenRoutes.$inferSelect): boolean {
return matchesModelPattern(model, route.modelPattern) || isRouteDisplayNameMatch(model, route.displayName);
}
function getExposedModelNameForRoute(route: typeof schema.tokenRoutes.$inferSelect): string {
return normalizeRouteDisplayName(route.displayName) || route.modelPattern;
}
function isRouteDisplayNameMatch(model: string, displayName: string | null | undefined): boolean {
const alias = normalizeRouteDisplayName(displayName);
return !!alias && alias === model;
}
function matchesRouteRequestModel(model: string, route: RouteRow): boolean {
if (isExplicitGroupRoute(route)) {
return isRouteDisplayNameMatch(model, route.displayName);
}
return matchesModelPattern(model, route.modelPattern) || isRouteDisplayNameMatch(model, route.displayName);
}
function getExposedModelNameForRoute(route: RouteRow): string {
return normalizeRouteDisplayName(route.displayName) || route.modelPattern;
}
function hasCustomDisplayName(route: Pick<RouteRow, 'modelPattern' | 'displayName'>): boolean {
const displayName = normalizeRouteDisplayName(route.displayName);
const modelPattern = (route.modelPattern || '').trim();
return !!displayName && displayName !== modelPattern;
}
function buildVisibleEnabledRoutes(routes: RouteRow[]): RouteRow[] {
const exactModelNames = new Set(
routes
.filter((route) => !isExplicitGroupRoute(route) && isExactRouteModelPattern(route.modelPattern))
.map((route) => (route.modelPattern || '').trim())
.filter(Boolean),
);
const coveringGroups = routes.filter((route) => (
route.enabled
&& (
(isExplicitGroupRoute(route) && normalizeRouteDisplayName(route.displayName).length > 0 && route.sourceRouteIds.length > 0)
|| (!isExplicitGroupRoute(route) && !isExactRouteModelPattern(route.modelPattern) && hasCustomDisplayName(route))
)
));
if (coveringGroups.length === 0) return routes;
return routes.filter((route) => {
if (isExplicitGroupRoute(route)) {
return normalizeRouteDisplayName(route.displayName).length > 0;
}
if (!isExactRouteModelPattern(route.modelPattern)) return true;
if (hasCustomDisplayName(route)) return true;
const exactModel = (route.modelPattern || '').trim();
if (!exactModel) return true;
return !coveringGroups.some((groupRoute) => {
if (groupRoute.id === route.id) return false;
const groupDisplayName = normalizeRouteDisplayName(groupRoute.displayName);
if (!groupDisplayName || exactModelNames.has(groupDisplayName)) return false;
if (isExplicitGroupRoute(groupRoute)) {
return groupRoute.sourceRouteIds.includes(route.id);
}
return matchesModelPattern(exactModel, groupRoute.modelPattern);
});
});
}
function normalizeModelAlias(modelName: string): string {
const normalized = (modelName || '').trim().toLowerCase();
@@ -367,10 +464,10 @@ function normalizeChannelSourceModel(channelSourceModel: string | null | undefin
function resolveActualModelForSelectedChannel(
requestedModel: string,
route: typeof schema.tokenRoutes.$inferSelect,
route: RouteRow,
mappedModel: string,
channelSourceModel: string | null | undefined,
): string {
): string {
const sourceModel = normalizeChannelSourceModel(channelSourceModel);
if (isRouteDisplayNameMatch(requestedModel, route.displayName) && sourceModel) {
return sourceModel;
@@ -378,7 +475,7 @@ function resolveActualModelForSelectedChannel(
return mappedModel;
}
function resolveRouteStrategy(route: typeof schema.tokenRoutes.$inferSelect): RouteRoutingStrategy {
function resolveRouteStrategy(route: RouteRow): RouteRoutingStrategy {
return normalizeRouteRoutingStrategy(route.routingStrategy);
}
@@ -450,6 +547,17 @@ export class TokenRouter {
if (!match) return null;
return await this.selectFromMatch(match, requestedModel, downstreamPolicy);
}
async previewSelectedChannel(
requestedModel: string,
downstreamPolicy: DownstreamRoutingPolicy = DEFAULT_DOWNSTREAM_POLICY,
): Promise<SelectedChannel | null> {
if (!isModelAllowedByDownstreamPolicy(requestedModel, downstreamPolicy)) return null;
const match = await this.findRoute(requestedModel, downstreamPolicy);
if (!match) return null;
return await this.selectFromMatch(match, requestedModel, downstreamPolicy, [], false);
}
/**
* Select next channel for failover (exclude already-tried channels).
@@ -893,7 +1001,7 @@ export class TokenRouter {
*/
async getAvailableModels(): Promise<string[]> {
const routes = await loadEnabledRoutes();
const exposed = routes
const exposed = buildVisibleEnabledRoutes(routes)
.map((route) => getExposedModelNameForRoute(route).trim())
.filter((name) => name.length > 0);
return Array.from(new Set(exposed));
@@ -906,6 +1014,7 @@ export class TokenRouter {
requestedModel: string,
downstreamPolicy: DownstreamRoutingPolicy,
excludeChannelIds: number[] = [],
recordSelection = true,
): Promise<SelectedChannel | null> {
const mappedModel = resolveMappedModel(requestedModel, match.route.modelMapping);
const requestedByDisplayName = isRouteDisplayNameMatch(requestedModel, match.route.displayName);
@@ -931,7 +1040,9 @@ export class TokenRouter {
const tokenValue = this.resolveChannelTokenValue(selected);
if (!tokenValue) return null;
await this.recordChannelSelection(selected.channel.id);
if (recordSelection) {
await this.recordChannelSelection(selected.channel.id);
}
const actualModel = resolveActualModelForSelectedChannel(
requestedModel,
@@ -1003,11 +1114,13 @@ export class TokenRouter {
}
const matchedRoute = routes.find((route) => (
isExactRouteModelPattern(route.modelPattern)
!isExplicitGroupRoute(route)
&& isExactRouteModelPattern(route.modelPattern)
&& (route.modelPattern || '').trim() === model
))
|| routes.find((route) => isRouteDisplayNameMatch(model, route.displayName))
|| routes.find((route) => matchesModelPattern(model, route.modelPattern));
|| routes.find((route) => isExplicitGroupRoute(route) && isRouteDisplayNameMatch(model, route.displayName))
|| routes.find((route) => !isExplicitGroupRoute(route) && isRouteDisplayNameMatch(model, route.displayName))
|| routes.find((route) => !isExplicitGroupRoute(route) && matchesModelPattern(model, route.modelPattern));
if (!matchedRoute) return null;
@@ -1025,7 +1138,7 @@ export class TokenRouter {
return await this.loadRouteMatch(route);
}
private async loadRouteMatch(route: typeof schema.tokenRoutes.$inferSelect): Promise<RouteMatch> {
private async loadRouteMatch(route: RouteRow): Promise<RouteMatch> {
return await loadRouteMatch(route);
}