feat: add lightweight /api/routes/lite endpoint for route selectors

Avoid loading full route data (channels, decision snapshots) when Settings
page only needs basic route info for selectors.
This commit is contained in:
Hureru
2026-03-10 23:06:56 +08:00
parent 5e14c2282f
commit 2343383729
3 changed files with 307 additions and 295 deletions
+304 -293
View File
@@ -1,15 +1,15 @@
import { FastifyInstance } from 'fastify'; import { FastifyInstance } from 'fastify';
import { and, eq, inArray } from 'drizzle-orm'; import { and, eq, inArray } from 'drizzle-orm';
import { db, schema } from '../../db/index.js'; import { db, schema } from '../../db/index.js';
import { rebuildTokenRoutesFromAvailability, refreshModelsAndRebuildRoutes } from '../../services/modelService.js'; import { rebuildTokenRoutesFromAvailability, refreshModelsAndRebuildRoutes } from '../../services/modelService.js';
import { invalidateTokenRouterCache, matchesModelPattern, tokenRouter } from '../../services/tokenRouter.js'; import { invalidateTokenRouterCache, matchesModelPattern, tokenRouter } from '../../services/tokenRouter.js';
import { startBackgroundTask } from '../../services/backgroundTaskService.js'; import { startBackgroundTask } from '../../services/backgroundTaskService.js';
import { import {
clearRouteDecisionSnapshot, clearRouteDecisionSnapshot,
clearRouteDecisionSnapshots, clearRouteDecisionSnapshots,
parseRouteDecisionSnapshot, parseRouteDecisionSnapshot,
saveRouteDecisionSnapshots, saveRouteDecisionSnapshots,
} from '../../services/routeDecisionSnapshotStore.js'; } from '../../services/routeDecisionSnapshotStore.js';
function isExactModelPattern(modelPattern: string): boolean { function isExactModelPattern(modelPattern: string): boolean {
const normalized = modelPattern.trim(); const normalized = modelPattern.trim();
@@ -127,9 +127,9 @@ async function getMatchedExactRouteChannelCandidates(modelPattern: string): Prom
})).filter((candidate) => candidate.sourceModel.length > 0); })).filter((candidate) => candidate.sourceModel.length > 0);
} }
async function populateRouteChannelsByModelPattern(routeId: number, modelPattern: string): Promise<number> { async function populateRouteChannelsByModelPattern(routeId: number, modelPattern: string): Promise<number> {
const routeCandidates = await getMatchedExactRouteChannelCandidates(modelPattern); const routeCandidates = await getMatchedExactRouteChannelCandidates(modelPattern);
const availabilityCandidates = (await getPatternTokenCandidates(modelPattern)).map((candidate) => ({ const availabilityCandidates = (await getPatternTokenCandidates(modelPattern)).map((candidate) => ({
tokenId: candidate.tokenId, tokenId: candidate.tokenId,
accountId: candidate.accountId, accountId: candidate.accountId,
sourceModel: candidate.sourceModel, sourceModel: candidate.sourceModel,
@@ -171,59 +171,59 @@ async function populateRouteChannelsByModelPattern(routeId: number, modelPattern
existingPairs.add(pairKey); existingPairs.add(pairKey);
created += 1; created += 1;
} }
return created; return created;
} }
async function rebuildAutomaticRouteChannelsByModelPattern(routeId: number, modelPattern: string): Promise<{ async function rebuildAutomaticRouteChannelsByModelPattern(routeId: number, modelPattern: string): Promise<{
removedChannels: number; removedChannels: number;
createdChannels: number; createdChannels: number;
}> { }> {
const removableChannels = await db.select().from(schema.routeChannels) const removableChannels = await db.select().from(schema.routeChannels)
.where( .where(
and( and(
eq(schema.routeChannels.routeId, routeId), eq(schema.routeChannels.routeId, routeId),
eq(schema.routeChannels.manualOverride, false), eq(schema.routeChannels.manualOverride, false),
), ),
) )
.all(); .all();
for (const channel of removableChannels) { for (const channel of removableChannels) {
await db.delete(schema.routeChannels).where(eq(schema.routeChannels.id, channel.id)).run(); await db.delete(schema.routeChannels).where(eq(schema.routeChannels.id, channel.id)).run();
} }
const createdChannels = await populateRouteChannelsByModelPattern(routeId, modelPattern); const createdChannels = await populateRouteChannelsByModelPattern(routeId, modelPattern);
return { return {
removedChannels: removableChannels.length, removedChannels: removableChannels.length,
createdChannels, createdChannels,
}; };
} }
type BatchChannelPriorityUpdate = { type BatchChannelPriorityUpdate = {
id: number; id: number;
priority: number; priority: number;
}; };
type BatchRouteDecisionModels = { type BatchRouteDecisionModels = {
models: string[]; models: string[];
refreshPricingCatalog?: boolean; refreshPricingCatalog?: boolean;
persistSnapshots?: boolean; persistSnapshots?: boolean;
}; };
type BatchRouteDecisionRouteModels = { type BatchRouteDecisionRouteModels = {
items: Array<{ items: Array<{
routeId: number; routeId: number;
model: string; model: string;
}>; }>;
refreshPricingCatalog?: boolean; refreshPricingCatalog?: boolean;
persistSnapshots?: boolean; persistSnapshots?: boolean;
}; };
type BatchRouteWideDecisionRouteIds = { type BatchRouteWideDecisionRouteIds = {
routeIds: number[]; routeIds: number[];
refreshPricingCatalog?: boolean; refreshPricingCatalog?: boolean;
persistSnapshots?: boolean; persistSnapshots?: boolean;
}; };
function parseBatchChannelUpdates(input: unknown): { ok: true; updates: BatchChannelPriorityUpdate[] } | { ok: false; message: string } { function parseBatchChannelUpdates(input: unknown): { ok: true; updates: BatchChannelPriorityUpdate[] } | { ok: false; message: string } {
if (!input || typeof input !== 'object') { if (!input || typeof input !== 'object') {
@@ -264,9 +264,9 @@ function parseBatchChannelUpdates(input: unknown): { ok: true; updates: BatchCha
return { ok: true, updates: normalized }; return { ok: true, updates: normalized };
} }
function parseBatchRouteDecisionModels( function parseBatchRouteDecisionModels(
input: unknown, input: unknown,
): { ok: true; models: string[]; refreshPricingCatalog: boolean; persistSnapshots: boolean } | { ok: false; message: string } { ): { ok: true; models: string[]; refreshPricingCatalog: boolean; persistSnapshots: boolean } | { ok: false; message: string } {
if (!input || typeof input !== 'object') { if (!input || typeof input !== 'object') {
return { ok: false, message: '请求体必须是对象' }; return { ok: false, message: '请求体必须是对象' };
} }
@@ -291,17 +291,17 @@ function parseBatchRouteDecisionModels(
return { ok: false, message: 'models 中没有有效模型名称' }; return { ok: false, message: 'models 中没有有效模型名称' };
} }
return { return {
ok: true, ok: true,
models: normalized, models: normalized,
refreshPricingCatalog: (input as { refreshPricingCatalog?: unknown }).refreshPricingCatalog === true, refreshPricingCatalog: (input as { refreshPricingCatalog?: unknown }).refreshPricingCatalog === true,
persistSnapshots: (input as { persistSnapshots?: unknown }).persistSnapshots === true, persistSnapshots: (input as { persistSnapshots?: unknown }).persistSnapshots === true,
}; };
} }
function parseBatchRouteDecisionRouteModels( function parseBatchRouteDecisionRouteModels(
input: unknown, input: unknown,
): { ok: true; items: Array<{ routeId: number; model: string }>; refreshPricingCatalog: boolean; persistSnapshots: boolean } | { ok: false; message: string } { ): { ok: true; items: Array<{ routeId: number; model: string }>; refreshPricingCatalog: boolean; persistSnapshots: boolean } | { ok: false; message: string } {
if (!input || typeof input !== 'object') { if (!input || typeof input !== 'object') {
return { ok: false, message: '请求体必须是对象' }; return { ok: false, message: '请求体必须是对象' };
} }
@@ -335,17 +335,17 @@ function parseBatchRouteDecisionRouteModels(
return { ok: false, message: 'items 中没有有效 routeId/model' }; return { ok: false, message: 'items 中没有有效 routeId/model' };
} }
return { return {
ok: true, ok: true,
items: normalized, items: normalized,
refreshPricingCatalog: (input as { refreshPricingCatalog?: unknown }).refreshPricingCatalog === true, refreshPricingCatalog: (input as { refreshPricingCatalog?: unknown }).refreshPricingCatalog === true,
persistSnapshots: (input as { persistSnapshots?: unknown }).persistSnapshots === true, persistSnapshots: (input as { persistSnapshots?: unknown }).persistSnapshots === true,
}; };
} }
function parseBatchRouteWideDecisionRouteIds( function parseBatchRouteWideDecisionRouteIds(
input: unknown, input: unknown,
): { ok: true; routeIds: number[]; refreshPricingCatalog: boolean; persistSnapshots: boolean } | { ok: false; message: string } { ): { ok: true; routeIds: number[]; refreshPricingCatalog: boolean; persistSnapshots: boolean } | { ok: false; message: string } {
if (!input || typeof input !== 'object') { if (!input || typeof input !== 'object') {
return { ok: false, message: '请求体必须是对象' }; return { ok: false, message: '请求体必须是对象' };
} }
@@ -370,19 +370,30 @@ function parseBatchRouteWideDecisionRouteIds(
return { ok: false, message: 'routeIds 中没有有效 routeId' }; return { ok: false, message: 'routeIds 中没有有效 routeId' };
} }
return { return {
ok: true, ok: true,
routeIds: normalized, routeIds: normalized,
refreshPricingCatalog: (input as { refreshPricingCatalog?: unknown }).refreshPricingCatalog === true, refreshPricingCatalog: (input as { refreshPricingCatalog?: unknown }).refreshPricingCatalog === true,
persistSnapshots: (input as { persistSnapshots?: unknown }).persistSnapshots === true, persistSnapshots: (input as { persistSnapshots?: unknown }).persistSnapshots === true,
}; };
} }
export async function tokensRoutes(app: FastifyInstance) { export async function tokensRoutes(app: FastifyInstance) {
// List routes with basic info only (lightweight for selectors)
app.get('/api/routes/lite', async () => {
return await db.select({
id: schema.tokenRoutes.id,
modelPattern: schema.tokenRoutes.modelPattern,
displayName: schema.tokenRoutes.displayName,
displayIcon: schema.tokenRoutes.displayIcon,
enabled: schema.tokenRoutes.enabled,
}).from(schema.tokenRoutes).all();
});
// List all routes // List all routes
app.get('/api/routes', async () => { app.get('/api/routes', async () => {
const routes = await db.select().from(schema.tokenRoutes).all(); const routes = await db.select().from(schema.tokenRoutes).all();
if (routes.length === 0) return []; if (routes.length === 0) return [];
const routeIds = routes.map((route) => route.id); const routeIds = routes.map((route) => route.id);
const channelRows = await db.select().from(schema.routeChannels) const channelRows = await db.select().from(schema.routeChannels)
@@ -413,13 +424,13 @@ export async function tokensRoutes(app: FastifyInstance) {
}); });
} }
return routes.map((route) => ({ return routes.map((route) => ({
...route, ...route,
decisionSnapshot: parseRouteDecisionSnapshot(route.decisionSnapshot), decisionSnapshot: parseRouteDecisionSnapshot(route.decisionSnapshot),
decisionRefreshedAt: route.decisionRefreshedAt ?? null, decisionRefreshedAt: route.decisionRefreshedAt ?? null,
channels: channelsByRoute.get(route.id) || [], channels: channelsByRoute.get(route.id) || [],
})); }));
}); });
app.get<{ Querystring: { model?: string } }>('/api/routes/decision', async (request, reply) => { app.get<{ Querystring: { model?: string } }>('/api/routes/decision', async (request, reply) => {
const model = (request.query.model || '').trim(); const model = (request.query.model || '').trim();
@@ -427,157 +438,157 @@ export async function tokensRoutes(app: FastifyInstance) {
return reply.code(400).send({ success: false, message: 'model 不能为空' }); return reply.code(400).send({ success: false, message: 'model 不能为空' });
} }
const decision = await tokenRouter.explainSelection(model); const decision = await tokenRouter.explainSelection(model);
return { success: true, decision }; return { success: true, decision };
}); });
app.post<{ Body: BatchRouteDecisionModels }>('/api/routes/decision/batch', async (request, reply) => { app.post<{ Body: BatchRouteDecisionModels }>('/api/routes/decision/batch', async (request, reply) => {
const parsed = parseBatchRouteDecisionModels(request.body); const parsed = parseBatchRouteDecisionModels(request.body);
if (!parsed.ok) { if (!parsed.ok) {
return reply.code(400).send({ success: false, message: parsed.message }); return reply.code(400).send({ success: false, message: parsed.message });
} }
const decisions: Record<string, Awaited<ReturnType<typeof tokenRouter.explainSelection>>> = {};
const routes = parsed.persistSnapshots
? await db.select({
id: schema.tokenRoutes.id,
modelPattern: schema.tokenRoutes.modelPattern,
}).from(schema.tokenRoutes).all()
: [];
const refreshedKeys = parsed.refreshPricingCatalog ? new Set<string>() : undefined;
for (const model of parsed.models) {
if (parsed.refreshPricingCatalog) {
await tokenRouter.refreshPricingReferenceCosts(model, { refreshedKeys });
}
decisions[model] = await tokenRouter.explainSelection(model);
}
if (parsed.persistSnapshots) {
const snapshotWrites: Array<{ routeId: number; snapshot: unknown }> = [];
for (const model of parsed.models) {
const decision = decisions[model];
for (const route of routes) {
if (!isExactModelPattern(route.modelPattern)) continue;
if (!matchesModelPattern(model, route.modelPattern)) continue;
snapshotWrites.push({ routeId: route.id, snapshot: decision });
}
}
await saveRouteDecisionSnapshots(snapshotWrites);
}
return { success: true, decisions };
});
app.post<{ Body: BatchRouteDecisionRouteModels }>('/api/routes/decision/by-route/batch', async (request, reply) => { const decisions: Record<string, Awaited<ReturnType<typeof tokenRouter.explainSelection>>> = {};
const parsed = parseBatchRouteDecisionRouteModels(request.body); const routes = parsed.persistSnapshots
if (!parsed.ok) { ? await db.select({
return reply.code(400).send({ success: false, message: parsed.message }); id: schema.tokenRoutes.id,
} modelPattern: schema.tokenRoutes.modelPattern,
}).from(schema.tokenRoutes).all()
const decisions: Record<string, Record<string, Awaited<ReturnType<typeof tokenRouter.explainSelectionForRoute>>>> = {}; : [];
const refreshedKeys = parsed.refreshPricingCatalog ? new Set<string>() : undefined; const refreshedKeys = parsed.refreshPricingCatalog ? new Set<string>() : undefined;
for (const item of parsed.items) { for (const model of parsed.models) {
const routeKey = String(item.routeId); if (parsed.refreshPricingCatalog) {
if (!decisions[routeKey]) decisions[routeKey] = {}; await tokenRouter.refreshPricingReferenceCosts(model, { refreshedKeys });
if (parsed.refreshPricingCatalog) { }
await tokenRouter.refreshPricingReferenceCostsForRoute(item.routeId, item.model, { refreshedKeys }); decisions[model] = await tokenRouter.explainSelection(model);
} }
decisions[routeKey][item.model] = await tokenRouter.explainSelectionForRoute(item.routeId, item.model);
}
if (parsed.persistSnapshots) {
await saveRouteDecisionSnapshots(parsed.items.map((item) => ({
routeId: item.routeId,
snapshot: decisions[String(item.routeId)]?.[item.model] ?? null,
})));
}
return { success: true, decisions };
});
app.post<{ Body: BatchRouteWideDecisionRouteIds }>('/api/routes/decision/route-wide/batch', async (request, reply) => { if (parsed.persistSnapshots) {
const parsed = parseBatchRouteWideDecisionRouteIds(request.body); const snapshotWrites: Array<{ routeId: number; snapshot: unknown }> = [];
if (!parsed.ok) { for (const model of parsed.models) {
return reply.code(400).send({ success: false, message: parsed.message }); const decision = decisions[model];
} for (const route of routes) {
if (!isExactModelPattern(route.modelPattern)) continue;
const decisions: Record<string, Awaited<ReturnType<typeof tokenRouter.explainSelectionRouteWide>>> = {}; if (!matchesModelPattern(model, route.modelPattern)) continue;
const refreshedKeys = parsed.refreshPricingCatalog ? new Set<string>() : undefined; snapshotWrites.push({ routeId: route.id, snapshot: decision });
for (const routeId of parsed.routeIds) { }
if (parsed.refreshPricingCatalog) { }
await tokenRouter.refreshRouteWidePricingReferenceCosts(routeId, { refreshedKeys }); await saveRouteDecisionSnapshots(snapshotWrites);
} }
decisions[String(routeId)] = await tokenRouter.explainSelectionRouteWide(routeId);
} return { success: true, decisions };
});
if (parsed.persistSnapshots) {
await saveRouteDecisionSnapshots(parsed.routeIds.map((routeId) => ({ app.post<{ Body: BatchRouteDecisionRouteModels }>('/api/routes/decision/by-route/batch', async (request, reply) => {
routeId, const parsed = parseBatchRouteDecisionRouteModels(request.body);
snapshot: decisions[String(routeId)] ?? null, if (!parsed.ok) {
}))); return reply.code(400).send({ success: false, message: parsed.message });
} }
return { success: true, decisions }; const decisions: Record<string, Record<string, Awaited<ReturnType<typeof tokenRouter.explainSelectionForRoute>>>> = {};
}); const refreshedKeys = parsed.refreshPricingCatalog ? new Set<string>() : undefined;
for (const item of parsed.items) {
const routeKey = String(item.routeId);
if (!decisions[routeKey]) decisions[routeKey] = {};
if (parsed.refreshPricingCatalog) {
await tokenRouter.refreshPricingReferenceCostsForRoute(item.routeId, item.model, { refreshedKeys });
}
decisions[routeKey][item.model] = await tokenRouter.explainSelectionForRoute(item.routeId, item.model);
}
if (parsed.persistSnapshots) {
await saveRouteDecisionSnapshots(parsed.items.map((item) => ({
routeId: item.routeId,
snapshot: decisions[String(item.routeId)]?.[item.model] ?? null,
})));
}
return { success: true, decisions };
});
app.post<{ Body: BatchRouteWideDecisionRouteIds }>('/api/routes/decision/route-wide/batch', async (request, reply) => {
const parsed = parseBatchRouteWideDecisionRouteIds(request.body);
if (!parsed.ok) {
return reply.code(400).send({ success: false, message: parsed.message });
}
const decisions: Record<string, Awaited<ReturnType<typeof tokenRouter.explainSelectionRouteWide>>> = {};
const refreshedKeys = parsed.refreshPricingCatalog ? new Set<string>() : undefined;
for (const routeId of parsed.routeIds) {
if (parsed.refreshPricingCatalog) {
await tokenRouter.refreshRouteWidePricingReferenceCosts(routeId, { refreshedKeys });
}
decisions[String(routeId)] = await tokenRouter.explainSelectionRouteWide(routeId);
}
if (parsed.persistSnapshots) {
await saveRouteDecisionSnapshots(parsed.routeIds.map((routeId) => ({
routeId,
snapshot: decisions[String(routeId)] ?? null,
})));
}
return { success: true, decisions };
});
// Create a route // Create a route
app.post<{ Body: { modelPattern: string; displayName?: string; displayIcon?: string; modelMapping?: string; enabled?: boolean } }>('/api/routes', async (request) => { app.post<{ Body: { modelPattern: string; displayName?: string; displayIcon?: string; modelMapping?: string; enabled?: boolean } }>('/api/routes', async (request) => {
const body = request.body; const body = request.body;
const insertedRoute = await db.insert(schema.tokenRoutes).values({ const insertedRoute = await db.insert(schema.tokenRoutes).values({
modelPattern: body.modelPattern, modelPattern: body.modelPattern,
displayName: body.displayName, displayName: body.displayName,
displayIcon: body.displayIcon, displayIcon: body.displayIcon,
modelMapping: body.modelMapping, modelMapping: body.modelMapping,
enabled: body.enabled ?? true, enabled: body.enabled ?? true,
}).run(); }).run();
const routeId = Number(insertedRoute.lastInsertRowid || 0); const routeId = Number(insertedRoute.lastInsertRowid || 0);
if (routeId <= 0) { if (routeId <= 0) {
return { success: false, message: '创建路由失败' }; return { success: false, message: '创建路由失败' };
} }
const route = await db.select().from(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, routeId)).get(); const route = await db.select().from(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, routeId)).get();
if (!route) { if (!route) {
return { success: false, message: '创建路由失败' }; return { success: false, message: '创建路由失败' };
} }
await populateRouteChannelsByModelPattern(route.id, body.modelPattern); await populateRouteChannelsByModelPattern(route.id, body.modelPattern);
invalidateTokenRouterCache(); invalidateTokenRouterCache();
return route; return route;
}); });
// Update a route // Update a route
app.put<{ Params: { id: string }; Body: any }>('/api/routes/:id', async (request, reply) => { app.put<{ Params: { id: string }; Body: any }>('/api/routes/:id', async (request, reply) => {
const id = parseInt(request.params.id, 10); const id = parseInt(request.params.id, 10);
const body = request.body as Record<string, unknown>; const body = request.body as Record<string, unknown>;
const existingRoute = await db.select().from(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, id)).get(); const existingRoute = await db.select().from(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, id)).get();
if (!existingRoute) { if (!existingRoute) {
return reply.code(404).send({ success: false, message: '路由不存在' }); return reply.code(404).send({ success: false, message: '路由不存在' });
} }
const updates: Record<string, unknown> = {}; const updates: Record<string, unknown> = {};
let nextModelPattern = existingRoute.modelPattern; let nextModelPattern = existingRoute.modelPattern;
if (body.displayName !== undefined) updates.displayName = body.displayName; if (body.displayName !== undefined) updates.displayName = body.displayName;
if (body.displayIcon !== undefined) updates.displayIcon = body.displayIcon; if (body.displayIcon !== undefined) updates.displayIcon = body.displayIcon;
if (body.modelPattern !== undefined) { if (body.modelPattern !== undefined) {
nextModelPattern = String(body.modelPattern); nextModelPattern = String(body.modelPattern);
updates.modelPattern = nextModelPattern; updates.modelPattern = nextModelPattern;
} }
if (body.modelMapping !== undefined) updates.modelMapping = body.modelMapping; if (body.modelMapping !== undefined) updates.modelMapping = body.modelMapping;
if (body.enabled !== undefined) updates.enabled = body.enabled; if (body.enabled !== undefined) updates.enabled = body.enabled;
updates.updatedAt = new Date().toISOString(); updates.updatedAt = new Date().toISOString();
await db.update(schema.tokenRoutes).set(updates).where(eq(schema.tokenRoutes.id, id)).run(); await db.update(schema.tokenRoutes).set(updates).where(eq(schema.tokenRoutes.id, id)).run();
const modelPatternChanged = body.modelPattern !== undefined && nextModelPattern !== existingRoute.modelPattern; const modelPatternChanged = body.modelPattern !== undefined && nextModelPattern !== existingRoute.modelPattern;
const routeBehaviorChanged = modelPatternChanged || body.modelMapping !== undefined || body.enabled !== undefined; const routeBehaviorChanged = modelPatternChanged || body.modelMapping !== undefined || body.enabled !== undefined;
if (modelPatternChanged) { if (modelPatternChanged) {
await rebuildAutomaticRouteChannelsByModelPattern(id, nextModelPattern); await rebuildAutomaticRouteChannelsByModelPattern(id, nextModelPattern);
} }
if (routeBehaviorChanged) { if (routeBehaviorChanged) {
await clearRouteDecisionSnapshot(id); await clearRouteDecisionSnapshot(id);
} }
invalidateTokenRouterCache(); invalidateTokenRouterCache();
return await db.select().from(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, id)).get(); return await db.select().from(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, id)).get();
}); });
// Delete a route // Delete a route
app.delete<{ Params: { id: string } }>('/api/routes/:id', async (request) => { app.delete<{ Params: { id: string } }>('/api/routes/:id', async (request) => {
@@ -600,13 +611,13 @@ export async function tokensRoutes(app: FastifyInstance) {
const sourceModel = typeof body.sourceModel === 'string' const sourceModel = typeof body.sourceModel === 'string'
? body.sourceModel.trim() ? body.sourceModel.trim()
: (isExactModelPattern(route.modelPattern) ? route.modelPattern.trim() : ''); : (isExactModelPattern(route.modelPattern) ? route.modelPattern.trim() : '');
const effectiveTokenId = body.tokenId ?? await getDefaultTokenId(body.accountId); const effectiveTokenId = body.tokenId ?? await getDefaultTokenId(body.accountId);
if (body.tokenId && !await checkTokenBelongsToAccount(body.tokenId, body.accountId)) { if (body.tokenId && !await checkTokenBelongsToAccount(body.tokenId, body.accountId)) {
return reply.code(400).send({ success: false, message: '令牌不存在或不属于当前账号' }); return reply.code(400).send({ success: false, message: '令牌不存在或不属于当前账号' });
} }
if (isExactModelPattern(route.modelPattern) && effectiveTokenId && !await tokenSupportsModel(effectiveTokenId, route.modelPattern)) { if (isExactModelPattern(route.modelPattern) && effectiveTokenId && !await tokenSupportsModel(effectiveTokenId, route.modelPattern)) {
return reply.code(400).send({ success: false, message: '该令牌不支持当前模型' }); return reply.code(400).send({ success: false, message: '该令牌不支持当前模型' });
} }
@@ -622,26 +633,26 @@ export async function tokensRoutes(app: FastifyInstance) {
return reply.code(400).send({ success: false, message: '该来源模型的通道已存在' }); return reply.code(400).send({ success: false, message: '该来源模型的通道已存在' });
} }
const insertedChannel = await db.insert(schema.routeChannels).values({ const insertedChannel = await db.insert(schema.routeChannels).values({
routeId, routeId,
accountId: body.accountId, accountId: body.accountId,
tokenId: body.tokenId, tokenId: body.tokenId,
sourceModel: sourceModel || null, sourceModel: sourceModel || null,
priority: body.priority ?? 0, priority: body.priority ?? 0,
weight: body.weight ?? 10, weight: body.weight ?? 10,
}).run(); }).run();
const channelId = Number(insertedChannel.lastInsertRowid || 0); const channelId = Number(insertedChannel.lastInsertRowid || 0);
if (channelId <= 0) { if (channelId <= 0) {
return reply.code(500).send({ success: false, message: '创建通道失败' }); return reply.code(500).send({ success: false, message: '创建通道失败' });
} }
const created = await db.select().from(schema.routeChannels).where(eq(schema.routeChannels.id, channelId)).get(); const created = await db.select().from(schema.routeChannels).where(eq(schema.routeChannels.id, channelId)).get();
if (!created) { if (!created) {
return reply.code(500).send({ success: false, message: '创建通道失败' }); return reply.code(500).send({ success: false, message: '创建通道失败' });
} }
await clearRouteDecisionSnapshot(routeId); await clearRouteDecisionSnapshot(routeId);
invalidateTokenRouterCache(); invalidateTokenRouterCache();
return created; return created;
}); });
// Batch update channel priorities // Batch update channel priorities
app.put<{ Body: { updates: Array<{ id: number; priority: number }> } }>('/api/channels/batch', async (request, reply) => { app.put<{ Body: { updates: Array<{ id: number; priority: number }> } }>('/api/channels/batch', async (request, reply) => {
@@ -667,13 +678,13 @@ export async function tokensRoutes(app: FastifyInstance) {
}).where(eq(schema.routeChannels.id, update.id)).run(); }).where(eq(schema.routeChannels.id, update.id)).run();
} }
const updatedChannels = await db.select().from(schema.routeChannels) const updatedChannels = await db.select().from(schema.routeChannels)
.where(inArray(schema.routeChannels.id, channelIds)) .where(inArray(schema.routeChannels.id, channelIds))
.all(); .all();
await clearRouteDecisionSnapshots(existingChannels.map((channel) => channel.routeId)); await clearRouteDecisionSnapshots(existingChannels.map((channel) => channel.routeId));
invalidateTokenRouterCache(); invalidateTokenRouterCache();
return { success: true, channels: updatedChannels }; return { success: true, channels: updatedChannels };
}); });
// Update a channel // Update a channel
app.put<{ Params: { channelId: string }; Body: any }>('/api/channels/:channelId', async (request, reply) => { app.put<{ Params: { channelId: string }; Body: any }>('/api/channels/:channelId', async (request, reply) => {
@@ -692,16 +703,16 @@ export async function tokensRoutes(app: FastifyInstance) {
if (body.tokenId !== undefined && body.tokenId !== null) { if (body.tokenId !== undefined && body.tokenId !== null) {
const tokenId = Number(body.tokenId); const tokenId = Number(body.tokenId);
if (!Number.isFinite(tokenId) || !await checkTokenBelongsToAccount(tokenId, channel.accountId)) { if (!Number.isFinite(tokenId) || !await checkTokenBelongsToAccount(tokenId, channel.accountId)) {
return reply.code(400).send({ success: false, message: '令牌不存在或不属于通道账号' }); return reply.code(400).send({ success: false, message: '令牌不存在或不属于通道账号' });
} }
} }
const nextTokenId = body.tokenId === undefined const nextTokenId = body.tokenId === undefined
? (channel.tokenId ?? await getDefaultTokenId(channel.accountId)) ? (channel.tokenId ?? await getDefaultTokenId(channel.accountId))
: (body.tokenId === null ? await getDefaultTokenId(channel.accountId) : Number(body.tokenId)); : (body.tokenId === null ? await getDefaultTokenId(channel.accountId) : Number(body.tokenId));
if (isExactModelPattern(route.modelPattern) && nextTokenId && !await tokenSupportsModel(nextTokenId, route.modelPattern)) { if (isExactModelPattern(route.modelPattern) && nextTokenId && !await tokenSupportsModel(nextTokenId, route.modelPattern)) {
return reply.code(400).send({ success: false, message: '该令牌不支持当前模型' }); return reply.code(400).send({ success: false, message: '该令牌不支持当前模型' });
} }
@@ -714,24 +725,24 @@ export async function tokensRoutes(app: FastifyInstance) {
for (const key of ['priority', 'weight', 'enabled', 'tokenId']) { for (const key of ['priority', 'weight', 'enabled', 'tokenId']) {
if (body[key] !== undefined) updates[key] = body[key]; if (body[key] !== undefined) updates[key] = body[key];
} }
await db.update(schema.routeChannels).set(updates).where(eq(schema.routeChannels.id, channelId)).run(); await db.update(schema.routeChannels).set(updates).where(eq(schema.routeChannels.id, channelId)).run();
await clearRouteDecisionSnapshot(channel.routeId); await clearRouteDecisionSnapshot(channel.routeId);
invalidateTokenRouterCache(); invalidateTokenRouterCache();
return await db.select().from(schema.routeChannels).where(eq(schema.routeChannels.id, channelId)).get(); return await db.select().from(schema.routeChannels).where(eq(schema.routeChannels.id, channelId)).get();
}); });
// Delete a channel // Delete a channel
app.delete<{ Params: { channelId: string } }>('/api/channels/:channelId', async (request) => { app.delete<{ Params: { channelId: string } }>('/api/channels/:channelId', async (request) => {
const channelId = parseInt(request.params.channelId, 10); const channelId = parseInt(request.params.channelId, 10);
const channel = await db.select().from(schema.routeChannels).where(eq(schema.routeChannels.id, channelId)).get(); 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(); await db.delete(schema.routeChannels).where(eq(schema.routeChannels.id, channelId)).run();
if (channel) { if (channel) {
await clearRouteDecisionSnapshot(channel.routeId); await clearRouteDecisionSnapshot(channel.routeId);
} }
invalidateTokenRouterCache(); invalidateTokenRouterCache();
return { success: true }; return { success: true };
}); });
// Rebuild routes/channels from model availability. // Rebuild routes/channels from model availability.
app.post<{ Body?: { refreshModels?: boolean; wait?: boolean } }>('/api/routes/rebuild', async (request, reply) => { app.post<{ Body?: { refreshModels?: boolean; wait?: boolean } }>('/api/routes/rebuild', async (request, reply) => {
+1
View File
@@ -273,6 +273,7 @@ export const api = {
// Routes // Routes
getRoutes: () => request('/api/routes'), getRoutes: () => request('/api/routes'),
getRoutesLite: () => request('/api/routes/lite'),
addRoute: (data: any) => request('/api/routes', { method: 'POST', body: JSON.stringify(data) }), addRoute: (data: any) => request('/api/routes', { method: 'POST', body: JSON.stringify(data) }),
updateRoute: (id: number, data: any) => request(`/api/routes/${id}`, { method: 'PUT', body: JSON.stringify(data) }), updateRoute: (id: number, data: any) => request(`/api/routes/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
deleteRoute: (id: number) => request(`/api/routes/${id}`, { method: 'DELETE' }), deleteRoute: (id: number) => request(`/api/routes/${id}`, { method: 'DELETE' }),
+2 -2
View File
@@ -352,7 +352,7 @@ export default function Settings() {
const loadRouteSelectorRoutes = async () => { const loadRouteSelectorRoutes = async () => {
setSelectorLoading(true); setSelectorLoading(true);
try { try {
const rows = await api.getRoutes(); const rows = await api.getRoutesLite();
setSelectorRoutes((Array.isArray(rows) ? rows : []).map((row: any) => ({ setSelectorRoutes((Array.isArray(rows) ? rows : []).map((row: any) => ({
id: row.id, id: row.id,
modelPattern: row.modelPattern, modelPattern: row.modelPattern,
@@ -374,7 +374,7 @@ export default function Settings() {
api.getAuthInfo(), api.getAuthInfo(),
api.getRuntimeSettings(), api.getRuntimeSettings(),
api.getDownstreamApiKeys(), api.getDownstreamApiKeys(),
api.getRoutes(), api.getRoutesLite(),
api.getRuntimeDatabaseConfig(), api.getRuntimeDatabaseConfig(),
]); ]);
setMaskedToken(authInfo.masked || '****'); setMaskedToken(authInfo.masked || '****');