forked from boos/BoosAPI
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:
+304
-293
@@ -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,19 +370,30 @@ 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,
|
||||
};
|
||||
}
|
||||
|
||||
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
|
||||
app.get('/api/routes', async () => {
|
||||
const routes = await db.select().from(schema.tokenRoutes).all();
|
||||
if (routes.length === 0) return [];
|
||||
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 channelRows = await db.select().from(schema.routeChannels)
|
||||
@@ -413,13 +424,13 @@ export async function tokensRoutes(app: FastifyInstance) {
|
||||
});
|
||||
}
|
||||
|
||||
return routes.map((route) => ({
|
||||
...route,
|
||||
decisionSnapshot: parseRouteDecisionSnapshot(route.decisionSnapshot),
|
||||
decisionRefreshedAt: route.decisionRefreshedAt ?? null,
|
||||
channels: channelsByRoute.get(route.id) || [],
|
||||
}));
|
||||
});
|
||||
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 +438,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 +611,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 +633,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 +678,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 +703,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 +725,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) => {
|
||||
|
||||
@@ -273,6 +273,7 @@ export const api = {
|
||||
|
||||
// Routes
|
||||
getRoutes: () => request('/api/routes'),
|
||||
getRoutesLite: () => request('/api/routes/lite'),
|
||||
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) }),
|
||||
deleteRoute: (id: number) => request(`/api/routes/${id}`, { method: 'DELETE' }),
|
||||
|
||||
@@ -352,7 +352,7 @@ export default function Settings() {
|
||||
const loadRouteSelectorRoutes = async () => {
|
||||
setSelectorLoading(true);
|
||||
try {
|
||||
const rows = await api.getRoutes();
|
||||
const rows = await api.getRoutesLite();
|
||||
setSelectorRoutes((Array.isArray(rows) ? rows : []).map((row: any) => ({
|
||||
id: row.id,
|
||||
modelPattern: row.modelPattern,
|
||||
@@ -374,7 +374,7 @@ export default function Settings() {
|
||||
api.getAuthInfo(),
|
||||
api.getRuntimeSettings(),
|
||||
api.getDownstreamApiKeys(),
|
||||
api.getRoutes(),
|
||||
api.getRoutesLite(),
|
||||
api.getRuntimeDatabaseConfig(),
|
||||
]);
|
||||
setMaskedToken(authInfo.masked || '****');
|
||||
|
||||
Reference in New Issue
Block a user