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
@@ -149,4 +149,180 @@ describe('PUT /api/routes/:id route rebuild', () => {
expect(rebuiltAuto?.priority).toBe(0);
expect(rebuiltAuto?.weight).toBe(10);
});
it('creates explicit-group routes with sourceRouteIds and aggregates source channels', async () => {
const sourceA = await seedAccountWithToken('claude-opus-4-5');
const sourceB = await seedAccountWithToken('claude-sonnet-4-5');
const exactRouteA = await db.insert(schema.tokenRoutes).values({
modelPattern: 'claude-opus-4-5',
enabled: true,
}).returning().get();
const exactRouteB = await db.insert(schema.tokenRoutes).values({
modelPattern: 'claude-sonnet-4-5',
enabled: true,
}).returning().get();
await db.insert(schema.routeChannels).values([
{
routeId: exactRouteA.id,
accountId: sourceA.account.id,
tokenId: sourceA.token.id,
sourceModel: 'claude-opus-4-5',
priority: 0,
weight: 10,
enabled: true,
manualOverride: false,
},
{
routeId: exactRouteB.id,
accountId: sourceB.account.id,
tokenId: sourceB.token.id,
sourceModel: 'claude-sonnet-4-5',
priority: 1,
weight: 8,
enabled: true,
manualOverride: false,
},
]).run();
const createResponse = await app.inject({
method: 'POST',
url: '/api/routes',
payload: {
routeMode: 'explicit_group',
displayName: 'claude-opus-4-6',
sourceRouteIds: [exactRouteA.id, exactRouteB.id],
routingStrategy: 'weighted',
},
});
expect(createResponse.statusCode).toBe(200);
expect(createResponse.json()).toMatchObject({
displayName: 'claude-opus-4-6',
routeMode: 'explicit_group',
sourceRouteIds: [exactRouteA.id, exactRouteB.id],
});
const createdRouteId = (createResponse.json() as { id: number }).id;
const storedChannels = await db.select().from(schema.routeChannels)
.where(eq(schema.routeChannels.routeId, createdRouteId))
.all();
expect(storedChannels).toHaveLength(0);
const summaryResponse = await app.inject({
method: 'GET',
url: '/api/routes/summary',
});
expect(summaryResponse.statusCode).toBe(200);
expect(summaryResponse.json()).toContainEqual(expect.objectContaining({
id: createdRouteId,
routeMode: 'explicit_group',
sourceRouteIds: [exactRouteA.id, exactRouteB.id],
channelCount: 2,
enabledChannelCount: 2,
siteNames: expect.arrayContaining([sourceA.site.name, sourceB.site.name]),
}));
const channelsResponse = await app.inject({
method: 'GET',
url: `/api/routes/${createdRouteId}/channels`,
});
expect(channelsResponse.statusCode).toBe(200);
expect(channelsResponse.json()).toEqual(expect.arrayContaining([
expect.objectContaining({
routeId: exactRouteA.id,
accountId: sourceA.account.id,
sourceModel: 'claude-opus-4-5',
}),
expect.objectContaining({
routeId: exactRouteB.id,
accountId: sourceB.account.id,
sourceModel: 'claude-sonnet-4-5',
}),
]));
});
it('rejects invalid explicit-group payloads', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/routes',
payload: {
routeMode: 'explicit_group',
displayName: '',
sourceRouteIds: [],
},
});
expect(response.statusCode).toBe(400);
expect(response.json()).toMatchObject({
success: false,
});
});
it('prefers an exact route over a colliding explicit-group display name', async () => {
const exactCandidate = await seedAccountWithToken('claude-opus-4-6');
const groupedCandidate = await seedAccountWithToken('claude-opus-4-5');
const exactRoute = await db.insert(schema.tokenRoutes).values({
modelPattern: 'claude-opus-4-6',
enabled: true,
}).returning().get();
const sourceRoute = await db.insert(schema.tokenRoutes).values({
modelPattern: 'claude-opus-4-5',
enabled: true,
}).returning().get();
await db.insert(schema.routeChannels).values([
{
routeId: exactRoute.id,
accountId: exactCandidate.account.id,
tokenId: exactCandidate.token.id,
sourceModel: 'claude-opus-4-6',
priority: 0,
weight: 10,
enabled: true,
manualOverride: false,
},
{
routeId: sourceRoute.id,
accountId: groupedCandidate.account.id,
tokenId: groupedCandidate.token.id,
sourceModel: 'claude-opus-4-5',
priority: 0,
weight: 10,
enabled: true,
manualOverride: false,
},
]).run();
const groupResponse = await app.inject({
method: 'POST',
url: '/api/routes',
payload: {
routeMode: 'explicit_group',
displayName: 'claude-opus-4-6',
sourceRouteIds: [sourceRoute.id],
},
});
expect(groupResponse.statusCode).toBe(200);
const decisionResponse = await app.inject({
method: 'GET',
url: '/api/routes/decision?model=claude-opus-4-6',
});
expect(decisionResponse.statusCode).toBe(200);
expect(decisionResponse.json()).toMatchObject({
success: true,
decision: {
matched: true,
routeId: exactRoute.id,
modelPattern: 'claude-opus-4-6',
actualModel: 'claude-opus-4-6',
},
});
});
});
+430 -186
View File
@@ -22,6 +22,138 @@ function isExactModelPattern(modelPattern: string): boolean {
if (normalized.toLowerCase().startsWith('re:')) return false;
return !/[\*\?]/.test(normalized);
}
type RouteMode = 'pattern' | 'explicit_group';
type RouteRow = typeof schema.tokenRoutes.$inferSelect & {
routeMode: RouteMode;
sourceRouteIds: number[];
};
function normalizeRouteMode(routeMode: unknown): 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 normalizeSourceRouteIdsInput(input: unknown): number[] {
const rawValues = Array.isArray(input) ? input : [];
const normalized: number[] = [];
for (const raw of rawValues) {
const value = Number(raw);
if (!Number.isFinite(value)) continue;
const routeId = Math.trunc(value);
if (routeId <= 0 || normalized.includes(routeId)) continue;
normalized.push(routeId);
if (normalized.length >= 500) break;
}
return normalized;
}
async function loadRouteSourceIdsMap(routeIds: number[]): Promise<Map<number, number[]>> {
const normalizedRouteIds = Array.from(new Set(routeIds.filter((routeId) => Number.isFinite(routeId) && routeId > 0)));
if (normalizedRouteIds.length === 0) return new Map();
const rows = await db.select().from(schema.routeGroupSources)
.where(inArray(schema.routeGroupSources.groupRouteId, normalizedRouteIds))
.all();
const sourceRouteIdsByRouteId = new Map<number, number[]>();
for (const row of rows) {
if (!sourceRouteIdsByRouteId.has(row.groupRouteId)) {
sourceRouteIdsByRouteId.set(row.groupRouteId, []);
}
sourceRouteIdsByRouteId.get(row.groupRouteId)!.push(row.sourceRouteId);
}
for (const [routeId, sourceRouteIds] of sourceRouteIdsByRouteId.entries()) {
sourceRouteIdsByRouteId.set(routeId, Array.from(new Set(sourceRouteIds)));
}
return sourceRouteIdsByRouteId;
}
function decorateRoutesWithSources(
routes: Array<typeof schema.tokenRoutes.$inferSelect>,
sourceRouteIdsByRouteId: Map<number, number[]>,
): RouteRow[] {
return routes.map((route) => ({
...route,
routeMode: normalizeRouteMode(route.routeMode),
sourceRouteIds: sourceRouteIdsByRouteId.get(route.id) ?? [],
}));
}
async function listRoutesWithSources(): Promise<RouteRow[]> {
const routes = await db.select().from(schema.tokenRoutes).all();
const sourceRouteIdsByRouteId = await loadRouteSourceIdsMap(routes.map((route) => route.id));
return decorateRoutesWithSources(routes, sourceRouteIdsByRouteId);
}
async function getRouteWithSources(routeId: number): Promise<RouteRow | null> {
const route = await db.select().from(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, routeId)).get();
if (!route) return null;
const sourceRouteIdsByRouteId = await loadRouteSourceIdsMap([routeId]);
return decorateRoutesWithSources([route], sourceRouteIdsByRouteId)[0] ?? null;
}
async function validateExplicitGroupSourceRoutes(sourceRouteIds: number[], currentRouteId?: number): Promise<{ ok: true } | { ok: false; message: string }> {
if (sourceRouteIds.length === 0) {
return { ok: false, message: '显式群组至少需要选择一个来源模型' };
}
const routes = await db.select().from(schema.tokenRoutes)
.where(inArray(schema.tokenRoutes.id, sourceRouteIds))
.all();
if (routes.length !== sourceRouteIds.length) {
return { ok: false, message: '来源模型中存在不存在的路由' };
}
for (const route of routes) {
if (currentRouteId && route.id === currentRouteId) {
return { ok: false, message: '显式群组不能引用自身作为来源模型' };
}
if (normalizeRouteMode(route.routeMode) === 'explicit_group') {
return { ok: false, message: '显式群组只能选择精确模型路由作为来源模型' };
}
if (!isExactModelPattern(route.modelPattern)) {
return { ok: false, message: '显式群组只能选择精确模型路由作为来源模型' };
}
}
return { ok: true };
}
async function replaceRouteSourceRouteIds(routeId: number, sourceRouteIds: number[]): Promise<void> {
await db.delete(schema.routeGroupSources).where(eq(schema.routeGroupSources.groupRouteId, routeId)).run();
if (sourceRouteIds.length === 0) return;
await db.insert(schema.routeGroupSources).values(
sourceRouteIds.map((sourceRouteId) => ({
groupRouteId: routeId,
sourceRouteId,
})),
).run();
}
async function clearDependentExplicitGroupSnapshotsBySourceRouteIds(sourceRouteIds: number[]): Promise<void> {
const normalizedSourceRouteIds = Array.from(new Set(
sourceRouteIds.filter((routeId): routeId is number => Number.isFinite(routeId) && routeId > 0),
));
if (normalizedSourceRouteIds.length === 0) return;
const rows = await db.select({ groupRouteId: schema.routeGroupSources.groupRouteId })
.from(schema.routeGroupSources)
.where(inArray(schema.routeGroupSources.sourceRouteId, normalizedSourceRouteIds))
.all();
const dependentRouteIdSet = new Set<number>();
for (const row of rows) {
const routeId = Number(row.groupRouteId);
if (Number.isFinite(routeId) && routeId > 0) {
dependentRouteIdSet.add(routeId);
}
}
const dependentRouteIds = Array.from(dependentRouteIdSet);
if (dependentRouteIds.length === 0) return;
await clearRouteDecisionSnapshots(dependentRouteIds);
}
async function getDefaultTokenId(accountId: number): Promise<number | null> {
const token = await db.select().from(schema.accountTokens)
@@ -390,25 +522,54 @@ function parseBatchRouteWideDecisionRouteIds(
};
}
async function fetchChannelsForRoutes(routeIds: number[]): Promise<Map<number, any[]>> {
if (routeIds.length === 0) return new Map();
const channelRows = 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, routeIds))
.all();
const channelsByRoute = new Map<number, any[]>();
for (const row of channelRows) {
const routeId = row.route_channels.routeId;
if (!channelsByRoute.has(routeId)) channelsByRoute.set(routeId, []);
channelsByRoute.get(routeId)!.push({
...row.route_channels,
account: row.accounts,
site: row.sites,
type RouteChannelSummary = {
channelCount: number;
enabledChannelCount: number;
siteNames: Set<string>;
};
async function fetchChannelsForRouteRows(routes: RouteRow[]): Promise<Map<number, any[]>> {
if (routes.length === 0) return new Map();
const explicitSourceRouteIds = Array.from(new Set(routes
.filter((route) => isExplicitGroupRoute(route))
.flatMap((route) => route.sourceRouteIds)));
const enabledExplicitSourceRouteIds = explicitSourceRouteIds.length > 0
? (await db.select({
id: schema.tokenRoutes.id,
modelPattern: schema.tokenRoutes.modelPattern,
routeMode: schema.tokenRoutes.routeMode,
enabled: schema.tokenRoutes.enabled,
}).from(schema.tokenRoutes)
.where(inArray(schema.tokenRoutes.id, explicitSourceRouteIds))
.all())
.filter((route) => route.enabled && !isExplicitGroupRoute(route) && isExactModelPattern(route.modelPattern))
.map((route) => route.id)
: [];
const actualRouteIds = Array.from(new Set([
...routes.filter((route) => !isExplicitGroupRoute(route)).map((route) => route.id),
...enabledExplicitSourceRouteIds,
]));
if (actualRouteIds.length === 0) {
return new Map(routes.map((route) => [route.id, []]));
}
const channelRows = 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, actualRouteIds))
.all();
const channelsByActualRouteId = new Map<number, any[]>();
for (const row of channelRows) {
const routeId = row.route_channels.routeId;
if (!channelsByActualRouteId.has(routeId)) channelsByActualRouteId.set(routeId, []);
channelsByActualRouteId.get(routeId)!.push({
...row.route_channels,
account: row.accounts,
site: row.sites,
token: row.account_tokens
? {
id: row.account_tokens.id,
@@ -417,63 +578,83 @@ async function fetchChannelsForRoutes(routeIds: number[]): Promise<Map<number, a
enabled: row.account_tokens.enabled,
isDefault: row.account_tokens.isDefault,
}
: null,
});
}
return channelsByRoute;
}
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,
routingStrategy: schema.tokenRoutes.routingStrategy,
enabled: schema.tokenRoutes.enabled,
}).from(schema.tokenRoutes).all();
: null,
});
}
const channelsByRoute = new Map<number, any[]>();
for (const route of routes) {
if (isExplicitGroupRoute(route)) {
channelsByRoute.set(route.id, route.sourceRouteIds.flatMap((sourceRouteId) => channelsByActualRouteId.get(sourceRouteId) || []));
continue;
}
channelsByRoute.set(route.id, channelsByActualRouteId.get(route.id) || []);
}
return channelsByRoute;
}
async function fetchChannelsForRoutes(routeIds: number[]): Promise<Map<number, any[]>> {
if (routeIds.length === 0) return new Map();
return await fetchChannelsForRouteRows(await listRoutesWithSources()).then((channelsByRoute) => {
const filtered = new Map<number, any[]>();
for (const routeId of routeIds) {
filtered.set(routeId, channelsByRoute.get(routeId) || []);
}
return filtered;
});
}
async function buildRouteChannelSummaryMap(routes: RouteRow[]): Promise<Map<number, RouteChannelSummary>> {
const channelsByRoute = await fetchChannelsForRouteRows(routes);
const summaryByRoute = new Map<number, RouteChannelSummary>();
for (const route of routes) {
const channels = channelsByRoute.get(route.id) || [];
const siteNames = new Set<string>();
let enabledChannelCount = 0;
for (const channel of channels) {
if (channel.enabled) enabledChannelCount += 1;
if (channel.site?.name) siteNames.add(channel.site.name);
}
summaryByRoute.set(route.id, {
channelCount: channels.length,
enabledChannelCount,
siteNames,
});
}
return summaryByRoute;
}
// Route summary (no channel details) for first-screen rendering
app.get('/api/routes/summary', async () => {
const routes = await db.select().from(schema.tokenRoutes).all();
if (routes.length === 0) return [];
const routeIds = routes.map((route) => route.id);
// Aggregate channel counts and site names per route
const channelRows = await db.select({
routeId: schema.routeChannels.routeId,
enabled: schema.routeChannels.enabled,
siteName: schema.sites.name,
}).from(schema.routeChannels)
.innerJoin(schema.accounts, eq(schema.routeChannels.accountId, schema.accounts.id))
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
.where(inArray(schema.routeChannels.routeId, routeIds))
.all();
const aggByRoute = new Map<number, { channelCount: number; enabledChannelCount: number; siteNames: Set<string> }>();
for (const row of channelRows) {
let agg = aggByRoute.get(row.routeId);
if (!agg) {
agg = { channelCount: 0, enabledChannelCount: 0, siteNames: new Set() };
aggByRoute.set(row.routeId, agg);
}
agg.channelCount += 1;
if (row.enabled) agg.enabledChannelCount += 1;
if (row.siteName) agg.siteNames.add(row.siteName);
}
return routes.map((route) => {
const agg = aggByRoute.get(route.id);
return {
id: route.id,
modelPattern: route.modelPattern,
displayName: route.displayName ?? null,
export async function tokensRoutes(app: FastifyInstance) {
// List routes with basic info only (lightweight for selectors)
app.get('/api/routes/lite', async () => {
return (await listRoutesWithSources()).map((route) => ({
id: route.id,
modelPattern: route.modelPattern,
displayName: route.displayName,
displayIcon: route.displayIcon,
routeMode: route.routeMode,
sourceRouteIds: route.sourceRouteIds,
routingStrategy: route.routingStrategy,
enabled: route.enabled,
}));
});
// Route summary (no channel details) for first-screen rendering
app.get('/api/routes/summary', async () => {
const routes = await listRoutesWithSources();
if (routes.length === 0) return [];
const aggByRoute = await buildRouteChannelSummaryMap(routes);
return routes.map((route) => {
const agg = aggByRoute.get(route.id);
return {
id: route.id,
modelPattern: route.modelPattern,
displayName: route.displayName ?? null,
displayIcon: route.displayIcon ?? null,
routeMode: route.routeMode,
sourceRouteIds: route.sourceRouteIds,
modelMapping: route.modelMapping ?? null,
routingStrategy: route.routingStrategy ?? 'weighted',
enabled: route.enabled,
@@ -486,26 +667,29 @@ export async function tokensRoutes(app: FastifyInstance) {
});
});
// Get channels for a single route (on-demand loading)
app.get<{ Params: { id: string } }>('/api/routes/:id/channels', async (request, reply) => {
const routeId = parseInt(request.params.id, 10);
const route = await db.select().from(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, routeId)).get();
if (!route) {
return reply.code(404).send({ success: false, message: '路由不存在' });
}
const channelsByRoute = await fetchChannelsForRoutes([routeId]);
return channelsByRoute.get(routeId) || [];
});
// Get channels for a single route (on-demand loading)
app.get<{ Params: { id: string } }>('/api/routes/:id/channels', async (request, reply) => {
const routeId = parseInt(request.params.id, 10);
const route = await getRouteWithSources(routeId);
if (!route) {
return reply.code(404).send({ success: false, message: '路由不存在' });
}
const channelsByRoute = await fetchChannelsForRouteRows([route]);
return channelsByRoute.get(routeId) || [];
});
// Batch add channels to a route
app.post<{ Params: { id: string }; Body: { channels: Array<{ accountId: number; tokenId?: number; sourceModel?: string }> } }>('/api/routes/:id/channels/batch', async (request, reply) => {
const routeId = parseInt(request.params.id, 10);
const body = request.body;
const route = await db.select().from(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, routeId)).get();
if (!route) {
return reply.code(404).send({ success: false, message: '路由不存在' });
}
app.post<{ Params: { id: string }; Body: { channels: Array<{ accountId: number; tokenId?: number; sourceModel?: string }> } }>('/api/routes/:id/channels/batch', async (request, reply) => {
const routeId = parseInt(request.params.id, 10);
const body = request.body;
const route = await getRouteWithSources(routeId);
if (!route) {
return reply.code(404).send({ success: false, message: '路由不存在' });
}
if (isExplicitGroupRoute(route)) {
return reply.code(400).send({ success: false, message: '显式群组不支持直接维护通道' });
}
if (!body?.channels || !Array.isArray(body.channels) || body.channels.length === 0) {
return reply.code(400).send({ success: false, message: 'channels 必须是非空数组' });
@@ -566,24 +750,24 @@ export async function tokensRoutes(app: FastifyInstance) {
}
}
if (created > 0) {
await clearRouteDecisionSnapshot(routeId);
invalidateTokenRouterCache();
}
if (created > 0) {
await clearRouteDecisionSnapshot(routeId);
await clearDependentExplicitGroupSnapshotsBySourceRouteIds([routeId]);
invalidateTokenRouterCache();
}
return { success: true, created, skipped, errors };
});
// List all routes
app.get('/api/routes', async () => {
const routes = await db.select().from(schema.tokenRoutes).all();
if (routes.length === 0) return [];
const routeIds = routes.map((route) => route.id);
const channelsByRoute = await fetchChannelsForRoutes(routeIds);
return routes.map((route) => ({
...route,
// List all routes
app.get('/api/routes', async () => {
const routes = await listRoutesWithSources();
if (routes.length === 0) return [];
const channelsByRoute = await fetchChannelsForRouteRows(routes);
return routes.map((route) => ({
...route,
decisionSnapshot: parseRouteDecisionSnapshot(route.decisionSnapshot),
decisionRefreshedAt: route.decisionRefreshedAt ?? null,
channels: channelsByRoute.get(route.id) || [],
@@ -689,87 +873,143 @@ export async function tokensRoutes(app: FastifyInstance) {
return { success: true, decisions };
});
// Create a route
app.post<{ Body: { modelPattern: string; displayName?: string; displayIcon?: string; modelMapping?: string; routingStrategy?: string; enabled?: boolean } }>('/api/routes', async (request) => {
// Create a route
app.post<{ Body: { routeMode?: string; modelPattern?: string; displayName?: string; displayIcon?: string; modelMapping?: string; routingStrategy?: string; enabled?: boolean; sourceRouteIds?: number[] } }>('/api/routes', async (request, reply) => {
const body = request.body;
const routeMode = normalizeRouteMode(body.routeMode);
const displayName = typeof body.displayName === 'string' ? body.displayName.trim() : '';
const sourceRouteIds = normalizeSourceRouteIdsInput(body.sourceRouteIds);
const modelPattern = routeMode === 'explicit_group'
? displayName
: (typeof body.modelPattern === 'string' ? body.modelPattern.trim() : '');
if (routeMode === 'explicit_group') {
if (!displayName) {
return reply.code(400).send({ success: false, message: '显式群组必须填写对外模型名' });
}
const validation = await validateExplicitGroupSourceRoutes(sourceRouteIds);
if (!validation.ok) {
return reply.code(400).send({ success: false, message: validation.message });
}
} else if (!modelPattern) {
return reply.code(400).send({ success: false, message: '模型匹配不能为空' });
}
const insertedRoute = await db.insert(schema.tokenRoutes).values({
modelPattern: body.modelPattern,
displayName: body.displayName,
modelPattern,
displayName: displayName || body.displayName,
displayIcon: body.displayIcon,
routeMode,
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: '创建路由失败' };
}
const route = await db.select().from(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, routeId)).get();
if (!route) {
return { success: false, message: '创建路由失败' };
}
await populateRouteChannelsByModelPattern(route.id, body.modelPattern);
invalidateTokenRouterCache();
return route;
});
// Update a route
app.put<{ Params: { id: string }; Body: any }>('/api/routes/:id', async (request, reply) => {
const id = parseInt(request.params.id, 10);
const body = request.body as Record<string, unknown>;
const existingRoute = await db.select().from(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, id)).get();
if (!existingRoute) {
return reply.code(404).send({ success: false, message: '路由不存在' });
}
const updates: Record<string, unknown> = {};
let nextModelPattern = existingRoute.modelPattern;
if (body.displayName !== undefined) updates.displayName = body.displayName;
if (body.displayIcon !== undefined) updates.displayIcon = body.displayIcon;
if (body.modelPattern !== undefined) {
const routeId = Number(insertedRoute.lastInsertRowid || 0);
if (routeId <= 0) {
return { success: false, message: '创建路由失败' };
}
const route = await getRouteWithSources(routeId);
if (!route) {
return { success: false, message: '创建路由失败' };
}
if (routeMode === 'explicit_group') {
await replaceRouteSourceRouteIds(route.id, sourceRouteIds);
} else {
await populateRouteChannelsByModelPattern(route.id, modelPattern);
}
invalidateTokenRouterCache();
return await getRouteWithSources(routeId);
});
// Update a route
app.put<{ Params: { id: string }; Body: any }>('/api/routes/:id', async (request, reply) => {
const id = parseInt(request.params.id, 10);
const body = request.body as Record<string, unknown>;
const existingRoute = await getRouteWithSources(id);
if (!existingRoute) {
return reply.code(404).send({ success: false, message: '路由不存在' });
}
const routeMode = normalizeRouteMode(body.routeMode ?? existingRoute.routeMode);
if (routeMode !== existingRoute.routeMode) {
return reply.code(400).send({ success: false, message: '暂不支持在不同群组模式之间直接切换' });
}
const updates: Record<string, unknown> = {};
let nextModelPattern = existingRoute.modelPattern;
let nextDisplayName = existingRoute.displayName ?? '';
let nextSourceRouteIds = existingRoute.sourceRouteIds;
if (body.displayName !== undefined) {
nextDisplayName = String(body.displayName || '').trim();
updates.displayName = nextDisplayName || null;
}
if (body.displayIcon !== undefined) updates.displayIcon = body.displayIcon;
if (routeMode === 'explicit_group') {
nextModelPattern = nextDisplayName;
updates.modelPattern = nextModelPattern;
if (body.sourceRouteIds !== undefined) {
nextSourceRouteIds = normalizeSourceRouteIdsInput(body.sourceRouteIds);
}
if (!nextDisplayName) {
return reply.code(400).send({ success: false, message: '显式群组必须填写对外模型名' });
}
const validation = await validateExplicitGroupSourceRoutes(nextSourceRouteIds, id);
if (!validation.ok) {
return reply.code(400).send({ success: false, message: validation.message });
}
} else 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;
if (body.routeMode !== undefined) updates.routeMode = routeMode;
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;
if (routeMode === 'explicit_group' && body.sourceRouteIds !== undefined) {
await replaceRouteSourceRouteIds(id, nextSourceRouteIds);
}
const modelPatternChanged = nextModelPattern !== existingRoute.modelPattern;
const routeBehaviorChanged = modelPatternChanged
|| (routeMode === 'explicit_group' && body.sourceRouteIds !== undefined)
|| body.modelMapping !== undefined
|| body.routingStrategy !== undefined
|| body.enabled !== undefined;
if (modelPatternChanged) {
await rebuildAutomaticRouteChannelsByModelPattern(id, nextModelPattern);
}
if (routeBehaviorChanged) {
await clearRouteDecisionSnapshot(id);
}
invalidateTokenRouterCache();
return await db.select().from(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, id)).get();
});
if (routeMode === 'pattern' && modelPatternChanged) {
await rebuildAutomaticRouteChannelsByModelPattern(id, nextModelPattern);
}
if (routeBehaviorChanged) {
await clearRouteDecisionSnapshot(id);
await clearDependentExplicitGroupSnapshotsBySourceRouteIds([id]);
}
invalidateTokenRouterCache();
return await getRouteWithSources(id);
});
// Delete a route
app.delete<{ Params: { id: string } }>('/api/routes/:id', async (request) => {
const id = parseInt(request.params.id, 10);
await db.delete(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, id)).run();
invalidateTokenRouterCache();
return { success: true };
});
app.delete<{ Params: { id: string } }>('/api/routes/:id', async (request) => {
const id = parseInt(request.params.id, 10);
await clearDependentExplicitGroupSnapshotsBySourceRouteIds([id]);
await db.delete(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, id)).run();
invalidateTokenRouterCache();
return { success: true };
});
// Add a channel to a route
app.post<{ Params: { id: string }; Body: { accountId: number; tokenId?: number; sourceModel?: string; priority?: number; weight?: number } }>('/api/routes/:id/channels', async (request, reply) => {
const routeId = parseInt(request.params.id, 10);
const body = request.body;
const route = await db.select().from(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, routeId)).get();
if (!route) {
return reply.code(404).send({ success: false, message: '路由不存在' });
}
app.post<{ Params: { id: string }; Body: { accountId: number; tokenId?: number; sourceModel?: string; priority?: number; weight?: number } }>('/api/routes/:id/channels', async (request, reply) => {
const routeId = parseInt(request.params.id, 10);
const body = request.body;
const route = await getRouteWithSources(routeId);
if (!route) {
return reply.code(404).send({ success: false, message: '路由不存在' });
}
if (isExplicitGroupRoute(route)) {
return reply.code(400).send({ success: false, message: '显式群组不支持直接维护通道' });
}
const sourceModel = typeof body.sourceModel === 'string'
? body.sourceModel.trim()
@@ -811,11 +1051,12 @@ export async function tokensRoutes(app: FastifyInstance) {
const created = await db.select().from(schema.routeChannels).where(eq(schema.routeChannels.id, channelId)).get();
if (!created) {
return reply.code(500).send({ success: false, message: '创建通道失败' });
}
await clearRouteDecisionSnapshot(routeId);
invalidateTokenRouterCache();
return created;
});
}
await clearRouteDecisionSnapshot(routeId);
await clearDependentExplicitGroupSnapshotsBySourceRouteIds([routeId]);
invalidateTokenRouterCache();
return created;
});
// Batch update channel priorities
app.put<{ Body: { updates: Array<{ id: number; priority: number }> } }>('/api/channels/batch', async (request, reply) => {
@@ -841,13 +1082,14 @@ export async function tokensRoutes(app: FastifyInstance) {
}).where(eq(schema.routeChannels.id, update.id)).run();
}
const updatedChannels = await db.select().from(schema.routeChannels)
.where(inArray(schema.routeChannels.id, channelIds))
.all();
await clearRouteDecisionSnapshots(existingChannels.map((channel) => channel.routeId));
invalidateTokenRouterCache();
return { success: true, channels: updatedChannels };
});
const updatedChannels = await db.select().from(schema.routeChannels)
.where(inArray(schema.routeChannels.id, channelIds))
.all();
await clearRouteDecisionSnapshots(existingChannels.map((channel) => channel.routeId));
await clearDependentExplicitGroupSnapshotsBySourceRouteIds(existingChannels.map((channel) => channel.routeId));
invalidateTokenRouterCache();
return { success: true, channels: updatedChannels };
});
// Update a channel
app.put<{ Params: { channelId: string }; Body: any }>('/api/channels/:channelId', async (request, reply) => {
@@ -889,23 +1131,25 @@ export async function tokensRoutes(app: FastifyInstance) {
if (body[key] !== undefined) updates[key] = body[key];
}
await db.update(schema.routeChannels).set(updates).where(eq(schema.routeChannels.id, channelId)).run();
await clearRouteDecisionSnapshot(channel.routeId);
invalidateTokenRouterCache();
return await db.select().from(schema.routeChannels).where(eq(schema.routeChannels.id, channelId)).get();
});
await db.update(schema.routeChannels).set(updates).where(eq(schema.routeChannels.id, channelId)).run();
await clearRouteDecisionSnapshot(channel.routeId);
await clearDependentExplicitGroupSnapshotsBySourceRouteIds([channel.routeId]);
invalidateTokenRouterCache();
return await db.select().from(schema.routeChannels).where(eq(schema.routeChannels.id, channelId)).get();
});
// Delete a channel
app.delete<{ Params: { channelId: string } }>('/api/channels/:channelId', async (request) => {
const channelId = parseInt(request.params.channelId, 10);
const channel = await db.select().from(schema.routeChannels).where(eq(schema.routeChannels.id, channelId)).get();
await db.delete(schema.routeChannels).where(eq(schema.routeChannels.id, channelId)).run();
if (channel) {
await clearRouteDecisionSnapshot(channel.routeId);
}
invalidateTokenRouterCache();
return { success: true };
});
await db.delete(schema.routeChannels).where(eq(schema.routeChannels.id, channelId)).run();
if (channel) {
await clearRouteDecisionSnapshot(channel.routeId);
await clearDependentExplicitGroupSnapshotsBySourceRouteIds([channel.routeId]);
}
invalidateTokenRouterCache();
return { success: true };
});
// Rebuild routes/channels from model availability.
app.post<{ Body?: { refreshModels?: boolean; wait?: boolean } }>('/api/routes/rebuild', async (request, reply) => {