fix route grouping and codex websocket regressions
This commit is contained in:
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user