Merge pull request #53 from Hureru/refactor/token-routes-page
refactor: 重构 TokenRoutes 页面,支持分层加载、网格布局和性能优化
This commit is contained in:
+469
-314
@@ -1,15 +1,15 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { and, eq, inArray } from 'drizzle-orm';
|
||||
import { db, schema } from '../../db/index.js';
|
||||
import { rebuildTokenRoutesFromAvailability, refreshModelsAndRebuildRoutes } from '../../services/modelService.js';
|
||||
import { invalidateTokenRouterCache, matchesModelPattern, tokenRouter } from '../../services/tokenRouter.js';
|
||||
import { startBackgroundTask } from '../../services/backgroundTaskService.js';
|
||||
import {
|
||||
clearRouteDecisionSnapshot,
|
||||
clearRouteDecisionSnapshots,
|
||||
parseRouteDecisionSnapshot,
|
||||
saveRouteDecisionSnapshots,
|
||||
} from '../../services/routeDecisionSnapshotStore.js';
|
||||
import { and, eq, inArray } from 'drizzle-orm';
|
||||
import { db, schema } from '../../db/index.js';
|
||||
import { rebuildTokenRoutesFromAvailability, refreshModelsAndRebuildRoutes } from '../../services/modelService.js';
|
||||
import { invalidateTokenRouterCache, matchesModelPattern, tokenRouter } from '../../services/tokenRouter.js';
|
||||
import { startBackgroundTask } from '../../services/backgroundTaskService.js';
|
||||
import {
|
||||
clearRouteDecisionSnapshot,
|
||||
clearRouteDecisionSnapshots,
|
||||
parseRouteDecisionSnapshot,
|
||||
saveRouteDecisionSnapshots,
|
||||
} from '../../services/routeDecisionSnapshotStore.js';
|
||||
|
||||
function isExactModelPattern(modelPattern: string): boolean {
|
||||
const normalized = modelPattern.trim();
|
||||
@@ -127,9 +127,9 @@ async function getMatchedExactRouteChannelCandidates(modelPattern: string): Prom
|
||||
})).filter((candidate) => candidate.sourceModel.length > 0);
|
||||
}
|
||||
|
||||
async function populateRouteChannelsByModelPattern(routeId: number, modelPattern: string): Promise<number> {
|
||||
const routeCandidates = await getMatchedExactRouteChannelCandidates(modelPattern);
|
||||
const availabilityCandidates = (await getPatternTokenCandidates(modelPattern)).map((candidate) => ({
|
||||
async function populateRouteChannelsByModelPattern(routeId: number, modelPattern: string): Promise<number> {
|
||||
const routeCandidates = await getMatchedExactRouteChannelCandidates(modelPattern);
|
||||
const availabilityCandidates = (await getPatternTokenCandidates(modelPattern)).map((candidate) => ({
|
||||
tokenId: candidate.tokenId,
|
||||
accountId: candidate.accountId,
|
||||
sourceModel: candidate.sourceModel,
|
||||
@@ -171,59 +171,59 @@ async function populateRouteChannelsByModelPattern(routeId: number, modelPattern
|
||||
existingPairs.add(pairKey);
|
||||
created += 1;
|
||||
}
|
||||
|
||||
return created;
|
||||
}
|
||||
|
||||
async function rebuildAutomaticRouteChannelsByModelPattern(routeId: number, modelPattern: string): Promise<{
|
||||
removedChannels: number;
|
||||
createdChannels: number;
|
||||
}> {
|
||||
const removableChannels = await db.select().from(schema.routeChannels)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.routeChannels.routeId, routeId),
|
||||
eq(schema.routeChannels.manualOverride, false),
|
||||
),
|
||||
)
|
||||
.all();
|
||||
|
||||
for (const channel of removableChannels) {
|
||||
await db.delete(schema.routeChannels).where(eq(schema.routeChannels.id, channel.id)).run();
|
||||
}
|
||||
|
||||
const createdChannels = await populateRouteChannelsByModelPattern(routeId, modelPattern);
|
||||
return {
|
||||
removedChannels: removableChannels.length,
|
||||
createdChannels,
|
||||
};
|
||||
}
|
||||
|
||||
return created;
|
||||
}
|
||||
|
||||
async function rebuildAutomaticRouteChannelsByModelPattern(routeId: number, modelPattern: string): Promise<{
|
||||
removedChannels: number;
|
||||
createdChannels: number;
|
||||
}> {
|
||||
const removableChannels = await db.select().from(schema.routeChannels)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.routeChannels.routeId, routeId),
|
||||
eq(schema.routeChannels.manualOverride, false),
|
||||
),
|
||||
)
|
||||
.all();
|
||||
|
||||
for (const channel of removableChannels) {
|
||||
await db.delete(schema.routeChannels).where(eq(schema.routeChannels.id, channel.id)).run();
|
||||
}
|
||||
|
||||
const createdChannels = await populateRouteChannelsByModelPattern(routeId, modelPattern);
|
||||
return {
|
||||
removedChannels: removableChannels.length,
|
||||
createdChannels,
|
||||
};
|
||||
}
|
||||
|
||||
type BatchChannelPriorityUpdate = {
|
||||
id: number;
|
||||
priority: number;
|
||||
};
|
||||
|
||||
type BatchRouteDecisionModels = {
|
||||
models: string[];
|
||||
refreshPricingCatalog?: boolean;
|
||||
persistSnapshots?: boolean;
|
||||
};
|
||||
|
||||
type BatchRouteDecisionRouteModels = {
|
||||
items: Array<{
|
||||
routeId: number;
|
||||
model: string;
|
||||
}>;
|
||||
refreshPricingCatalog?: boolean;
|
||||
persistSnapshots?: boolean;
|
||||
};
|
||||
|
||||
type BatchRouteWideDecisionRouteIds = {
|
||||
routeIds: number[];
|
||||
refreshPricingCatalog?: boolean;
|
||||
persistSnapshots?: boolean;
|
||||
};
|
||||
type BatchRouteDecisionModels = {
|
||||
models: string[];
|
||||
refreshPricingCatalog?: boolean;
|
||||
persistSnapshots?: boolean;
|
||||
};
|
||||
|
||||
type BatchRouteDecisionRouteModels = {
|
||||
items: Array<{
|
||||
routeId: number;
|
||||
model: string;
|
||||
}>;
|
||||
refreshPricingCatalog?: boolean;
|
||||
persistSnapshots?: boolean;
|
||||
};
|
||||
|
||||
type BatchRouteWideDecisionRouteIds = {
|
||||
routeIds: number[];
|
||||
refreshPricingCatalog?: boolean;
|
||||
persistSnapshots?: boolean;
|
||||
};
|
||||
|
||||
function parseBatchChannelUpdates(input: unknown): { ok: true; updates: BatchChannelPriorityUpdate[] } | { ok: false; message: string } {
|
||||
if (!input || typeof input !== 'object') {
|
||||
@@ -264,9 +264,9 @@ function parseBatchChannelUpdates(input: unknown): { ok: true; updates: BatchCha
|
||||
return { ok: true, updates: normalized };
|
||||
}
|
||||
|
||||
function parseBatchRouteDecisionModels(
|
||||
input: unknown,
|
||||
): { ok: true; models: string[]; refreshPricingCatalog: boolean; persistSnapshots: boolean } | { ok: false; message: string } {
|
||||
function parseBatchRouteDecisionModels(
|
||||
input: unknown,
|
||||
): { ok: true; models: string[]; refreshPricingCatalog: boolean; persistSnapshots: boolean } | { ok: false; message: string } {
|
||||
if (!input || typeof input !== 'object') {
|
||||
return { ok: false, message: '请求体必须是对象' };
|
||||
}
|
||||
@@ -291,17 +291,17 @@ function parseBatchRouteDecisionModels(
|
||||
return { ok: false, message: 'models 中没有有效模型名称' };
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
models: normalized,
|
||||
refreshPricingCatalog: (input as { refreshPricingCatalog?: unknown }).refreshPricingCatalog === true,
|
||||
persistSnapshots: (input as { persistSnapshots?: unknown }).persistSnapshots === true,
|
||||
};
|
||||
}
|
||||
|
||||
function parseBatchRouteDecisionRouteModels(
|
||||
input: unknown,
|
||||
): { ok: true; items: Array<{ routeId: number; model: string }>; refreshPricingCatalog: boolean; persistSnapshots: boolean } | { ok: false; message: string } {
|
||||
return {
|
||||
ok: true,
|
||||
models: normalized,
|
||||
refreshPricingCatalog: (input as { refreshPricingCatalog?: unknown }).refreshPricingCatalog === true,
|
||||
persistSnapshots: (input as { persistSnapshots?: unknown }).persistSnapshots === true,
|
||||
};
|
||||
}
|
||||
|
||||
function parseBatchRouteDecisionRouteModels(
|
||||
input: unknown,
|
||||
): { ok: true; items: Array<{ routeId: number; model: string }>; refreshPricingCatalog: boolean; persistSnapshots: boolean } | { ok: false; message: string } {
|
||||
if (!input || typeof input !== 'object') {
|
||||
return { ok: false, message: '请求体必须是对象' };
|
||||
}
|
||||
@@ -335,17 +335,17 @@ function parseBatchRouteDecisionRouteModels(
|
||||
return { ok: false, message: 'items 中没有有效 routeId/model' };
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
items: normalized,
|
||||
refreshPricingCatalog: (input as { refreshPricingCatalog?: unknown }).refreshPricingCatalog === true,
|
||||
persistSnapshots: (input as { persistSnapshots?: unknown }).persistSnapshots === true,
|
||||
};
|
||||
}
|
||||
|
||||
function parseBatchRouteWideDecisionRouteIds(
|
||||
input: unknown,
|
||||
): { ok: true; routeIds: number[]; refreshPricingCatalog: boolean; persistSnapshots: boolean } | { ok: false; message: string } {
|
||||
return {
|
||||
ok: true,
|
||||
items: normalized,
|
||||
refreshPricingCatalog: (input as { refreshPricingCatalog?: unknown }).refreshPricingCatalog === true,
|
||||
persistSnapshots: (input as { persistSnapshots?: unknown }).persistSnapshots === true,
|
||||
};
|
||||
}
|
||||
|
||||
function parseBatchRouteWideDecisionRouteIds(
|
||||
input: unknown,
|
||||
): { ok: true; routeIds: number[]; refreshPricingCatalog: boolean; persistSnapshots: boolean } | { ok: false; message: string } {
|
||||
if (!input || typeof input !== 'object') {
|
||||
return { ok: false, message: '请求体必须是对象' };
|
||||
}
|
||||
@@ -370,56 +370,211 @@ function parseBatchRouteWideDecisionRouteIds(
|
||||
return { ok: false, message: 'routeIds 中没有有效 routeId' };
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
routeIds: normalized,
|
||||
refreshPricingCatalog: (input as { refreshPricingCatalog?: unknown }).refreshPricingCatalog === true,
|
||||
persistSnapshots: (input as { persistSnapshots?: unknown }).persistSnapshots === true,
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
routeIds: normalized,
|
||||
refreshPricingCatalog: (input as { refreshPricingCatalog?: unknown }).refreshPricingCatalog === true,
|
||||
persistSnapshots: (input as { persistSnapshots?: unknown }).persistSnapshots === true,
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
token: row.account_tokens
|
||||
? {
|
||||
id: row.account_tokens.id,
|
||||
name: row.account_tokens.name,
|
||||
accountId: row.account_tokens.accountId,
|
||||
enabled: row.account_tokens.enabled,
|
||||
isDefault: row.account_tokens.isDefault,
|
||||
}
|
||||
: null,
|
||||
});
|
||||
}
|
||||
|
||||
return channelsByRoute;
|
||||
}
|
||||
|
||||
export async function tokensRoutes(app: FastifyInstance) {
|
||||
// List all routes
|
||||
app.get('/api/routes', async () => {
|
||||
const routes = await db.select().from(schema.tokenRoutes).all();
|
||||
if (routes.length === 0) return [];
|
||||
// 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();
|
||||
});
|
||||
|
||||
// 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);
|
||||
const channelRows = await db.select().from(schema.routeChannels)
|
||||
|
||||
// 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))
|
||||
.leftJoin(schema.accountTokens, eq(schema.routeChannels.tokenId, schema.accountTokens.id))
|
||||
.where(inArray(schema.routeChannels.routeId, routeIds))
|
||||
.all();
|
||||
|
||||
const channelsByRoute = new Map<number, any[]>();
|
||||
|
||||
const aggByRoute = new Map<number, { channelCount: number; enabledChannelCount: number; siteNames: Set<string> }>();
|
||||
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,
|
||||
token: row.account_tokens
|
||||
? {
|
||||
id: row.account_tokens.id,
|
||||
name: row.account_tokens.name,
|
||||
accountId: row.account_tokens.accountId,
|
||||
enabled: row.account_tokens.enabled,
|
||||
isDefault: row.account_tokens.isDefault,
|
||||
}
|
||||
: null,
|
||||
});
|
||||
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) => ({
|
||||
...route,
|
||||
decisionSnapshot: parseRouteDecisionSnapshot(route.decisionSnapshot),
|
||||
decisionRefreshedAt: route.decisionRefreshedAt ?? null,
|
||||
channels: channelsByRoute.get(route.id) || [],
|
||||
}));
|
||||
});
|
||||
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,
|
||||
modelMapping: route.modelMapping ?? null,
|
||||
enabled: route.enabled,
|
||||
channelCount: agg?.channelCount ?? 0,
|
||||
enabledChannelCount: agg?.enabledChannelCount ?? 0,
|
||||
siteNames: agg ? Array.from(agg.siteNames) : [],
|
||||
decisionSnapshot: parseRouteDecisionSnapshot(route.decisionSnapshot),
|
||||
decisionRefreshedAt: route.decisionRefreshedAt ?? null,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
// 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) || [];
|
||||
});
|
||||
|
||||
// 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: '路由不存在' });
|
||||
}
|
||||
|
||||
if (!body?.channels || !Array.isArray(body.channels) || body.channels.length === 0) {
|
||||
return reply.code(400).send({ success: false, message: 'channels 必须是非空数组' });
|
||||
}
|
||||
|
||||
const existingChannels = await db.select().from(schema.routeChannels)
|
||||
.where(eq(schema.routeChannels.routeId, routeId))
|
||||
.all();
|
||||
const existingPairs = new Set<string>(
|
||||
existingChannels.map((channel) => {
|
||||
const tokenId = typeof channel.tokenId === 'number' && Number.isFinite(channel.tokenId) ? channel.tokenId : 0;
|
||||
const sourceModel = (channel.sourceModel || '').trim().toLowerCase();
|
||||
return `${channel.accountId}::${tokenId}::${sourceModel}`;
|
||||
}),
|
||||
);
|
||||
|
||||
let created = 0;
|
||||
let skipped = 0;
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const item of body.channels) {
|
||||
if (!item?.accountId || typeof item.accountId !== 'number') {
|
||||
errors.push('无效的 accountId');
|
||||
continue;
|
||||
}
|
||||
|
||||
const sourceModel = typeof item.sourceModel === 'string'
|
||||
? item.sourceModel.trim()
|
||||
: (isExactModelPattern(route.modelPattern) ? route.modelPattern.trim() : '');
|
||||
const effectiveTokenId = item.tokenId ?? await getDefaultTokenId(item.accountId);
|
||||
|
||||
if (item.tokenId && !await checkTokenBelongsToAccount(item.tokenId, item.accountId)) {
|
||||
errors.push(`令牌 ${item.tokenId} 不属于账号 ${item.accountId}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const tokenIdForKey = typeof effectiveTokenId === 'number' && Number.isFinite(effectiveTokenId) ? effectiveTokenId : 0;
|
||||
const pairKey = `${item.accountId}::${tokenIdForKey}::${sourceModel.toLowerCase()}`;
|
||||
if (existingPairs.has(pairKey)) {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await db.insert(schema.routeChannels).values({
|
||||
routeId,
|
||||
accountId: item.accountId,
|
||||
tokenId: effectiveTokenId,
|
||||
sourceModel: sourceModel || null,
|
||||
priority: 0,
|
||||
weight: 10,
|
||||
manualOverride: true,
|
||||
}).run();
|
||||
existingPairs.add(pairKey);
|
||||
created += 1;
|
||||
} catch (e: any) {
|
||||
errors.push(e.message || `添加通道失败: accountId=${item.accountId}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (created > 0) {
|
||||
await clearRouteDecisionSnapshot(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,
|
||||
decisionSnapshot: parseRouteDecisionSnapshot(route.decisionSnapshot),
|
||||
decisionRefreshedAt: route.decisionRefreshedAt ?? null,
|
||||
channels: channelsByRoute.get(route.id) || [],
|
||||
}));
|
||||
});
|
||||
|
||||
app.get<{ Querystring: { model?: string } }>('/api/routes/decision', async (request, reply) => {
|
||||
const model = (request.query.model || '').trim();
|
||||
@@ -427,157 +582,157 @@ export async function tokensRoutes(app: FastifyInstance) {
|
||||
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 };
|
||||
});
|
||||
|
||||
app.post<{ Body: BatchRouteDecisionModels }>('/api/routes/decision/batch', async (request, reply) => {
|
||||
const parsed = parseBatchRouteDecisionModels(request.body);
|
||||
if (!parsed.ok) {
|
||||
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: BatchRouteDecisionModels }>('/api/routes/decision/batch', async (request, reply) => {
|
||||
const parsed = parseBatchRouteDecisionModels(request.body);
|
||||
if (!parsed.ok) {
|
||||
return reply.code(400).send({ success: false, message: parsed.message });
|
||||
}
|
||||
|
||||
app.post<{ Body: BatchRouteDecisionRouteModels }>('/api/routes/decision/by-route/batch', async (request, reply) => {
|
||||
const parsed = parseBatchRouteDecisionRouteModels(request.body);
|
||||
if (!parsed.ok) {
|
||||
return reply.code(400).send({ success: false, message: parsed.message });
|
||||
}
|
||||
|
||||
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 };
|
||||
});
|
||||
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);
|
||||
}
|
||||
|
||||
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 };
|
||||
});
|
||||
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 parsed = parseBatchRouteDecisionRouteModels(request.body);
|
||||
if (!parsed.ok) {
|
||||
return reply.code(400).send({ success: false, message: parsed.message });
|
||||
}
|
||||
|
||||
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
|
||||
app.post<{ Body: { modelPattern: string; displayName?: string; displayIcon?: string; modelMapping?: string; enabled?: boolean } }>('/api/routes', async (request) => {
|
||||
const body = request.body;
|
||||
const insertedRoute = await db.insert(schema.tokenRoutes).values({
|
||||
modelPattern: body.modelPattern,
|
||||
displayName: body.displayName,
|
||||
displayIcon: body.displayIcon,
|
||||
modelMapping: body.modelMapping,
|
||||
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: '创建路由失败' };
|
||||
}
|
||||
const insertedRoute = await db.insert(schema.tokenRoutes).values({
|
||||
modelPattern: body.modelPattern,
|
||||
displayName: body.displayName,
|
||||
displayIcon: body.displayIcon,
|
||||
modelMapping: body.modelMapping,
|
||||
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);
|
||||
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) {
|
||||
nextModelPattern = String(body.modelPattern);
|
||||
updates.modelPattern = nextModelPattern;
|
||||
}
|
||||
if (body.modelMapping !== undefined) updates.modelMapping = body.modelMapping;
|
||||
if (body.enabled !== undefined) updates.enabled = body.enabled;
|
||||
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;
|
||||
const routeBehaviorChanged = modelPatternChanged || body.modelMapping !== 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();
|
||||
});
|
||||
// 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) {
|
||||
nextModelPattern = String(body.modelPattern);
|
||||
updates.modelPattern = nextModelPattern;
|
||||
}
|
||||
if (body.modelMapping !== undefined) updates.modelMapping = body.modelMapping;
|
||||
if (body.enabled !== undefined) updates.enabled = body.enabled;
|
||||
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;
|
||||
const routeBehaviorChanged = modelPatternChanged || body.modelMapping !== 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();
|
||||
});
|
||||
|
||||
// Delete a route
|
||||
app.delete<{ Params: { id: string } }>('/api/routes/:id', async (request) => {
|
||||
@@ -600,13 +755,13 @@ export async function tokensRoutes(app: FastifyInstance) {
|
||||
const sourceModel = typeof body.sourceModel === 'string'
|
||||
? body.sourceModel.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: '令牌不存在或不属于当前账号' });
|
||||
}
|
||||
|
||||
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: '该令牌不支持当前模型' });
|
||||
}
|
||||
|
||||
@@ -622,26 +777,26 @@ export async function tokensRoutes(app: FastifyInstance) {
|
||||
return reply.code(400).send({ success: false, message: '该来源模型的通道已存在' });
|
||||
}
|
||||
|
||||
const insertedChannel = await db.insert(schema.routeChannels).values({
|
||||
routeId,
|
||||
accountId: body.accountId,
|
||||
tokenId: body.tokenId,
|
||||
sourceModel: sourceModel || null,
|
||||
priority: body.priority ?? 0,
|
||||
weight: body.weight ?? 10,
|
||||
}).run();
|
||||
const channelId = Number(insertedChannel.lastInsertRowid || 0);
|
||||
if (channelId <= 0) {
|
||||
return reply.code(500).send({ success: false, message: '创建通道失败' });
|
||||
}
|
||||
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;
|
||||
});
|
||||
const insertedChannel = await db.insert(schema.routeChannels).values({
|
||||
routeId,
|
||||
accountId: body.accountId,
|
||||
tokenId: body.tokenId,
|
||||
sourceModel: sourceModel || null,
|
||||
priority: body.priority ?? 0,
|
||||
weight: body.weight ?? 10,
|
||||
}).run();
|
||||
const channelId = Number(insertedChannel.lastInsertRowid || 0);
|
||||
if (channelId <= 0) {
|
||||
return reply.code(500).send({ success: false, message: '创建通道失败' });
|
||||
}
|
||||
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;
|
||||
});
|
||||
|
||||
// Batch update channel priorities
|
||||
app.put<{ Body: { updates: Array<{ id: number; priority: number }> } }>('/api/channels/batch', async (request, reply) => {
|
||||
@@ -667,13 +822,13 @@ 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));
|
||||
invalidateTokenRouterCache();
|
||||
return { success: true, channels: updatedChannels };
|
||||
});
|
||||
|
||||
// Update a channel
|
||||
app.put<{ Params: { channelId: string }; Body: any }>('/api/channels/:channelId', async (request, reply) => {
|
||||
@@ -692,16 +847,16 @@ export async function tokensRoutes(app: FastifyInstance) {
|
||||
|
||||
if (body.tokenId !== undefined && body.tokenId !== null) {
|
||||
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: '令牌不存在或不属于通道账号' });
|
||||
}
|
||||
}
|
||||
|
||||
const nextTokenId = body.tokenId === undefined
|
||||
? (channel.tokenId ?? await getDefaultTokenId(channel.accountId))
|
||||
: (body.tokenId === null ? await getDefaultTokenId(channel.accountId) : Number(body.tokenId));
|
||||
const nextTokenId = body.tokenId === undefined
|
||||
? (channel.tokenId ?? await getDefaultTokenId(channel.accountId))
|
||||
: (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: '该令牌不支持当前模型' });
|
||||
}
|
||||
|
||||
@@ -714,24 +869,24 @@ export async function tokensRoutes(app: FastifyInstance) {
|
||||
for (const key of ['priority', 'weight', 'enabled', 'tokenId']) {
|
||||
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();
|
||||
});
|
||||
|
||||
// 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.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();
|
||||
});
|
||||
|
||||
// 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 };
|
||||
});
|
||||
|
||||
// Rebuild routes/channels from model availability.
|
||||
app.post<{ Body?: { refreshModels?: boolean; wait?: boolean } }>('/api/routes/rebuild', async (request, reply) => {
|
||||
|
||||
Reference in New Issue
Block a user