feat: enhance TokenRoutes with edit functionality and route visibility management

- Added editing capabilities for route management in TokenRoutes, allowing users to modify existing routes.
- Introduced a reset function for the route form to clear inputs after adding or editing routes.
- Implemented a visibility filter for routes, ensuring exact routes covered by group routes are hidden from the main list.
- Updated tests to cover new functionalities, including editing routes and verifying visibility logic.
- Created a new helper function to build a list of visible routes based on defined criteria.
- Enhanced schema compatibility checks for site configurations, ensuring necessary columns are added to the database.
This commit is contained in:
cita
2026-03-07 19:56:40 +08:00
parent 5ad5b87b16
commit ed05b37626
18 changed files with 1476 additions and 153 deletions
@@ -0,0 +1,140 @@
import Fastify, { type FastifyInstance } from 'fastify';
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
type DbModule = typeof import('../../db/index.js');
type TokenRouterModule = typeof import('../../services/tokenRouter.js');
type ConfigModule = typeof import('../../config.js');
describe('sites token-router cache invalidation', () => {
let app: FastifyInstance;
let db: DbModule['db'];
let schema: DbModule['schema'];
let tokenRouter: TokenRouterModule['tokenRouter'];
let invalidateTokenRouterCache: TokenRouterModule['invalidateTokenRouterCache'];
let config: ConfigModule['config'];
let originalCacheTtlMs = 0;
let dataDir = '';
beforeAll(async () => {
dataDir = mkdtempSync(join(tmpdir(), 'metapi-sites-token-router-cache-'));
process.env.DATA_DIR = dataDir;
await import('../../db/migrate.js');
const dbModule = await import('../../db/index.js');
const routesModule = await import('./sites.js');
const tokenRouterModule = await import('../../services/tokenRouter.js');
const configModule = await import('../../config.js');
db = dbModule.db;
schema = dbModule.schema;
tokenRouter = tokenRouterModule.tokenRouter;
invalidateTokenRouterCache = tokenRouterModule.invalidateTokenRouterCache;
config = configModule.config;
originalCacheTtlMs = config.tokenRouterCacheTtlMs;
app = Fastify();
await app.register(routesModule.sitesRoutes);
});
beforeEach(async () => {
await db.delete(schema.routeChannels).run();
await db.delete(schema.tokenRoutes).run();
await db.delete(schema.accountTokens).run();
await db.delete(schema.accounts).run();
await db.delete(schema.sites).run();
config.tokenRouterCacheTtlMs = 60_000;
invalidateTokenRouterCache();
});
afterAll(async () => {
await app.close();
config.tokenRouterCacheTtlMs = originalCacheTtlMs;
invalidateTokenRouterCache();
delete process.env.DATA_DIR;
});
it('recomputes probabilities with the new site weight after updating a site', async () => {
const targetSite = await db.insert(schema.sites).values({
name: 'weighted-target',
url: 'https://weighted-target.example.com',
platform: 'new-api',
status: 'active',
globalWeight: 1,
}).returning().get();
const competitorSite = await db.insert(schema.sites).values({
name: 'weighted-competitor',
url: 'https://weighted-competitor.example.com',
platform: 'new-api',
status: 'active',
globalWeight: 1,
}).returning().get();
const targetAccount = await db.insert(schema.accounts).values({
siteId: targetSite.id,
username: 'target-user',
accessToken: 'target-access-token',
apiToken: 'sk-target-api-token',
status: 'active',
unitCost: 1,
balance: 0,
}).returning().get();
const competitorAccount = await db.insert(schema.accounts).values({
siteId: competitorSite.id,
username: 'competitor-user',
accessToken: 'competitor-access-token',
apiToken: 'sk-competitor-api-token',
status: 'active',
unitCost: 1,
balance: 0,
}).returning().get();
const route = await db.insert(schema.tokenRoutes).values({
modelPattern: 'gpt-4o-mini',
enabled: true,
}).returning().get();
await db.insert(schema.routeChannels).values([
{
routeId: route.id,
accountId: targetAccount.id,
tokenId: null,
priority: 0,
weight: 10,
enabled: true,
},
{
routeId: route.id,
accountId: competitorAccount.id,
tokenId: null,
priority: 0,
weight: 10,
enabled: true,
},
]).run();
const before = await tokenRouter.explainSelection('gpt-4o-mini');
const beforeTarget = before.candidates.find((candidate) => candidate.accountId === targetAccount.id);
expect(beforeTarget?.probability).toBeCloseTo(50, 1);
const response = await app.inject({
method: 'PUT',
url: `/api/sites/${targetSite.id}`,
payload: {
globalWeight: 100,
},
});
expect(response.statusCode).toBe(200);
const after = await tokenRouter.explainSelection('gpt-4o-mini');
const afterTarget = after.candidates.find((candidate) => candidate.accountId === targetAccount.id);
expect(afterTarget?.probability || 0).toBeGreaterThan(90);
expect(afterTarget?.reason || '').toContain('站点权重=100.00');
});
});
+11 -4
View File
@@ -4,6 +4,7 @@ import { and, eq, sql } from 'drizzle-orm';
import { detectSite } from '../../services/siteDetector.js';
import { invalidateSiteProxyCache } from '../../services/siteProxy.js';
import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
import { invalidateTokenRouterCache } from '../../services/tokenRouter.js';
function normalizeSiteStatus(input: unknown): 'active' | 'disabled' | null {
if (input === undefined || input === null) return null;
@@ -74,6 +75,11 @@ function normalizeOptionalExternalCheckinUrl(input: unknown): {
}
export async function sitesRoutes(app: FastifyInstance) {
function invalidateSiteCaches() {
invalidateSiteProxyCache();
invalidateTokenRouterCache();
}
async function applySiteStatusSideEffects(
siteId: number,
existingSiteName: string,
@@ -216,7 +222,7 @@ export async function sitesRoutes(app: FastifyInstance) {
if (!result) {
return reply.code(500).send({ error: 'Create site failed' });
}
invalidateSiteProxyCache();
invalidateSiteCaches();
return result;
});
@@ -280,12 +286,13 @@ export async function sitesRoutes(app: FastifyInstance) {
if (body.globalWeight !== undefined) updates.globalWeight = normalizedGlobalWeight;
updates.updatedAt = new Date().toISOString();
await db.update(schema.sites).set(updates).where(eq(schema.sites.id, id)).run();
invalidateSiteProxyCache();
if (body.status !== undefined && normalizedStatus) {
await applySiteStatusSideEffects(id, existingSite.name, normalizedStatus);
}
invalidateSiteCaches();
return await db.select().from(schema.sites).where(eq(schema.sites.id, id)).get();
});
@@ -293,7 +300,7 @@ export async function sitesRoutes(app: FastifyInstance) {
app.delete<{ Params: { id: string } }>('/api/sites/:id', async (request) => {
const id = parseInt(request.params.id);
await db.delete(schema.sites).where(eq(schema.sites.id, id)).run();
invalidateSiteProxyCache();
invalidateSiteCaches();
return { success: true };
});
@@ -344,7 +351,7 @@ export async function sitesRoutes(app: FastifyInstance) {
}
}
invalidateSiteProxyCache();
invalidateSiteCaches();
return {
success: true,
successIds,
@@ -0,0 +1,152 @@
import Fastify, { type FastifyInstance } from 'fastify';
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { eq } from 'drizzle-orm';
type DbModule = typeof import('../../db/index.js');
describe('PUT /api/routes/:id route rebuild', () => {
let app: FastifyInstance;
let db: DbModule['db'];
let schema: DbModule['schema'];
let dataDir = '';
let seedId = 0;
const nextId = () => {
seedId += 1;
return seedId;
};
const seedAccountWithToken = async (modelName: string) => {
const id = nextId();
const site = await db.insert(schema.sites).values({
name: `site-${id}`,
url: `https://example.com/${id}`,
platform: 'new-api',
status: 'active',
}).returning().get();
const account = await db.insert(schema.accounts).values({
siteId: site.id,
username: `user-${id}`,
accessToken: `access-${id}`,
status: 'active',
}).returning().get();
const token = await db.insert(schema.accountTokens).values({
accountId: account.id,
name: `token-${id}`,
token: `sk-token-${id}`,
enabled: true,
isDefault: true,
}).returning().get();
await db.insert(schema.tokenModelAvailability).values({
tokenId: token.id,
modelName,
available: true,
}).run();
return { site, account, token };
};
beforeAll(async () => {
dataDir = mkdtempSync(join(tmpdir(), 'metapi-route-update-rebuild-'));
process.env.DATA_DIR = dataDir;
await import('../../db/migrate.js');
const dbModule = await import('../../db/index.js');
const routesModule = await import('./tokens.js');
db = dbModule.db;
schema = dbModule.schema;
app = Fastify();
await app.register(routesModule.tokensRoutes);
});
beforeEach(async () => {
await db.delete(schema.routeChannels).run();
await db.delete(schema.tokenRoutes).run();
await db.delete(schema.tokenModelAvailability).run();
await db.delete(schema.modelAvailability).run();
await db.delete(schema.accountTokens).run();
await db.delete(schema.accounts).run();
await db.delete(schema.sites).run();
seedId = 0;
});
afterAll(async () => {
await app.close();
delete process.env.DATA_DIR;
});
it('rebuilds only automatic channels when modelPattern changes', async () => {
const oldCandidate = await seedAccountWithToken('claude-opus-4-5');
const newCandidate = await seedAccountWithToken('gemini-2.0-flash');
const manualCandidate = await seedAccountWithToken('manual-special');
const route = await db.insert(schema.tokenRoutes).values({
modelPattern: 're:^claude-.*$',
displayName: 'old-group',
enabled: true,
}).returning().get();
const autoChannel = await db.insert(schema.routeChannels).values({
routeId: route.id,
accountId: oldCandidate.account.id,
tokenId: oldCandidate.token.id,
sourceModel: 'claude-opus-4-5',
priority: 0,
weight: 10,
enabled: true,
manualOverride: false,
}).returning().get();
const manualChannel = await db.insert(schema.routeChannels).values({
routeId: route.id,
accountId: manualCandidate.account.id,
tokenId: manualCandidate.token.id,
sourceModel: 'manual-special',
priority: 7,
weight: 3,
enabled: true,
manualOverride: true,
}).returning().get();
const response = await app.inject({
method: 'PUT',
url: `/api/routes/${route.id}`,
payload: {
modelPattern: 're:^gemini-.*$',
displayName: 'new-group',
},
});
expect(response.statusCode).toBe(200);
expect(response.json()).toMatchObject({
id: route.id,
modelPattern: 're:^gemini-.*$',
displayName: 'new-group',
});
const routeChannels = await db.select().from(schema.routeChannels)
.where(eq(schema.routeChannels.routeId, route.id))
.all();
expect(routeChannels.some((channel) => channel.id === manualChannel.id)).toBe(true);
expect(routeChannels.some((channel) => channel.id === autoChannel.id)).toBe(false);
const rebuiltAuto = routeChannels.find((channel) =>
channel.accountId === newCandidate.account.id
&& channel.tokenId === newCandidate.token.id
&& channel.sourceModel === 'gemini-2.0-flash',
);
expect(rebuiltAuto).toBeDefined();
expect(rebuiltAuto?.manualOverride).toBe(false);
expect(rebuiltAuto?.priority).toBe(0);
expect(rebuiltAuto?.weight).toBe(10);
});
});
+58 -21
View File
@@ -121,7 +121,7 @@ async function getMatchedExactRouteChannelCandidates(modelPattern: string): Prom
})).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 availabilityCandidates = (await getPatternTokenCandidates(modelPattern)).map((candidate) => ({
tokenId: candidate.tokenId,
@@ -165,9 +165,33 @@ async function populateRouteChannelsByModelPattern(routeId: number, modelPattern
existingPairs.add(pairKey);
created += 1;
}
return created;
}
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;
@@ -444,23 +468,36 @@ export async function tokensRoutes(app: FastifyInstance) {
return route;
});
// Update a route
app.put<{ Params: { id: string }; Body: any }>('/api/routes/:id', async (request) => {
const id = parseInt(request.params.id, 10);
const body = request.body as Record<string, unknown>;
const updates: Record<string, unknown> = {};
if (body.displayName !== undefined) updates.displayName = body.displayName;
if (body.displayIcon !== undefined) updates.displayIcon = body.displayIcon;
if (body.modelPattern !== undefined) updates.modelPattern = body.modelPattern;
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();
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;
if (modelPatternChanged) {
await rebuildAutomaticRouteChannelsByModelPattern(id, nextModelPattern);
}
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) => {
+75
View File
@@ -209,4 +209,79 @@ describe('/v1/models route', () => {
expect(ids).toContain('allowed-model');
expect(ids).not.toContain('blocked-model');
});
it('returns only selected group route alias for managed key with allowedRouteIds policy', async () => {
const site = await db.insert(schema.sites).values({
name: 'test-site',
url: 'https://upstream.example.com',
platform: 'openai',
status: 'active',
}).returning().get();
const account = await db.insert(schema.accounts).values({
siteId: site.id,
accessToken: 'account-access-token',
status: 'active',
}).returning().get();
const token = await db.insert(schema.accountTokens).values({
accountId: account.id,
name: 'default',
token: 'account-api-token',
enabled: true,
isDefault: true,
}).returning().get();
await db.insert(schema.modelAvailability).values([
{
accountId: account.id,
modelName: 'claude-opus-4-5',
available: true,
},
{
accountId: account.id,
modelName: 'claude-sonnet-4-5',
available: true,
},
]).run();
const groupRoute = await db.insert(schema.tokenRoutes).values({
modelPattern: 're:^claude-(opus|sonnet)-4-5$',
displayName: 'claude-opus-4-6',
enabled: true,
}).returning().get();
await db.insert(schema.routeChannels).values({
routeId: groupRoute.id,
accountId: account.id,
tokenId: token.id,
enabled: true,
}).run();
await db.insert(schema.downstreamApiKeys).values({
name: 'managed-key',
key: 'sk-managed-group-only',
enabled: true,
allowedRouteIds: JSON.stringify([groupRoute.id]),
}).run();
const response = await app.inject({
method: 'GET',
url: '/v1/models',
headers: {
authorization: 'Bearer sk-managed-group-only',
},
});
expect(response.statusCode).toBe(200);
const body = response.json() as {
object: 'list';
data: Array<{ id: string }>;
};
const ids = body.data.map((item) => item.id);
expect(ids).toContain('claude-opus-4-6');
expect(ids).not.toContain('claude-opus-4-5');
expect(ids).not.toContain('claude-sonnet-4-5');
});
});