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:
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user