forked from boos/BoosAPI
feat: Add normalized types and utility functions for handling chat formats
- Introduced `NormalizedContentBlock`, `NormalizedUsage`, and related types for structured data handling. - Implemented utility functions for creating and merging normalized usage statistics. - Added a new module for shared transformers to manage chat formats. feat: Implement app local state management - Created functions to clear application installation state and manage local storage keys. - Added constants for theme and user profile storage keys. test: Add tests for BrandIcon component rendering - Implemented tests to ensure BrandIcon does not force a colored background for recognized brands. test: Enhance ModelAnalysisPanel tests - Added tests for rendering token summaries with compact units in ModelAnalysisPanel. test: Add tests for ModernSelect component - Implemented tests to verify icon rendering for selected options in ModernSelect. test: Add number formatting tests - Created tests for `formatCompactTokenMetric` to validate token formatting logic. feat: Implement account creation intent handling - Added logic to handle session and API key creation intents in the Accounts page. test: Add tests for dashboard performance card - Implemented tests to verify rendering of performance metrics in the dashboard. feat: Implement default connection segment resolution - Added logic to determine initial connection segments based on platform types. test: Add tests for settings factory reset functionality - Implemented tests to verify the factory reset process and local state clearing. test: Add tests for site creation redirect logic - Implemented tests to verify redirection based on site capabilities during creation. test: Add tests for token routes refresh decision action - Implemented tests to verify refresh decision actions in TokenRoutes. test: Add tests for token routes snapshot load - Implemented tests to verify loading of cached snapshot data in TokenRoutes.
This commit is contained in:
@@ -0,0 +1,186 @@
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { mkdtempSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
type DbModule = typeof import('../../db/index.js');
|
||||
type ConfigModule = typeof import('../../config.js');
|
||||
type ServiceModule = typeof import('../../services/factoryResetService.js');
|
||||
|
||||
describe('settings factory reset api', () => {
|
||||
let app: FastifyInstance;
|
||||
let db: DbModule['db'];
|
||||
let schema: DbModule['schema'];
|
||||
let config: ConfigModule['config'];
|
||||
let FACTORY_RESET_ADMIN_TOKEN: ServiceModule['FACTORY_RESET_ADMIN_TOKEN'];
|
||||
let dataDir = '';
|
||||
|
||||
beforeAll(async () => {
|
||||
dataDir = mkdtempSync(join(tmpdir(), 'metapi-settings-factory-reset-'));
|
||||
process.env.DATA_DIR = dataDir;
|
||||
|
||||
await import('../../db/migrate.js');
|
||||
const dbModule = await import('../../db/index.js');
|
||||
const configModule = await import('../../config.js');
|
||||
const serviceModule = await import('../../services/factoryResetService.js');
|
||||
const settingsRoutesModule = await import('./settings.js');
|
||||
|
||||
db = dbModule.db;
|
||||
schema = dbModule.schema;
|
||||
config = configModule.config;
|
||||
FACTORY_RESET_ADMIN_TOKEN = serviceModule.FACTORY_RESET_ADMIN_TOKEN;
|
||||
|
||||
app = Fastify();
|
||||
await app.register(settingsRoutesModule.settingsRoutes);
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await db.delete(schema.routeChannels).run();
|
||||
await db.delete(schema.tokenModelAvailability).run();
|
||||
await db.delete(schema.modelAvailability).run();
|
||||
await db.delete(schema.proxyLogs).run();
|
||||
await db.delete(schema.proxyVideoTasks).run();
|
||||
await db.delete(schema.checkinLogs).run();
|
||||
await db.delete(schema.accountTokens).run();
|
||||
await db.delete(schema.accounts).run();
|
||||
await db.delete(schema.tokenRoutes).run();
|
||||
await db.delete(schema.sites).run();
|
||||
await db.delete(schema.downstreamApiKeys).run();
|
||||
await db.delete(schema.events).run();
|
||||
await db.delete(schema.settings).run();
|
||||
|
||||
config.authToken = 'before-reset-token';
|
||||
config.dbType = 'sqlite';
|
||||
config.dbUrl = '';
|
||||
config.dbSsl = true;
|
||||
config.systemProxyUrl = 'http://127.0.0.1:7890';
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
delete process.env.DATA_DIR;
|
||||
});
|
||||
|
||||
it('clears data, resets runtime config, and returns to initial sqlite state', async () => {
|
||||
const siteInsert = await db.insert(schema.sites).values({
|
||||
name: 'Reset Me',
|
||||
url: 'https://reset.example.com',
|
||||
platform: 'new-api',
|
||||
status: 'disabled',
|
||||
}).run();
|
||||
const siteId = Number(siteInsert.lastInsertRowid);
|
||||
|
||||
const accountInsert = await db.insert(schema.accounts).values({
|
||||
siteId,
|
||||
accessToken: 'session-token',
|
||||
status: 'expired',
|
||||
}).run();
|
||||
const accountId = Number(accountInsert.lastInsertRowid);
|
||||
|
||||
const tokenInsert = await db.insert(schema.accountTokens).values({
|
||||
accountId,
|
||||
name: 'Reset token',
|
||||
token: 'token-value',
|
||||
}).run();
|
||||
const tokenId = Number(tokenInsert.lastInsertRowid);
|
||||
|
||||
const routeInsert = await db.insert(schema.tokenRoutes).values({
|
||||
modelPattern: 'gpt-*',
|
||||
enabled: true,
|
||||
}).run();
|
||||
const routeId = Number(routeInsert.lastInsertRowid);
|
||||
|
||||
await db.insert(schema.routeChannels).values({
|
||||
routeId,
|
||||
accountId,
|
||||
tokenId,
|
||||
enabled: true,
|
||||
}).run();
|
||||
await db.insert(schema.modelAvailability).values({
|
||||
accountId,
|
||||
modelName: 'gpt-4.1',
|
||||
available: true,
|
||||
}).run();
|
||||
await db.insert(schema.tokenModelAvailability).values({
|
||||
tokenId,
|
||||
modelName: 'gpt-4.1',
|
||||
available: true,
|
||||
}).run();
|
||||
await db.insert(schema.checkinLogs).values({
|
||||
accountId,
|
||||
status: 'success',
|
||||
message: 'ok',
|
||||
}).run();
|
||||
await db.insert(schema.proxyLogs).values({
|
||||
accountId,
|
||||
status: 'success',
|
||||
modelRequested: 'gpt-4.1',
|
||||
}).run();
|
||||
await db.insert(schema.proxyVideoTasks).values({
|
||||
publicId: 'public-video-1',
|
||||
upstreamVideoId: 'upstream-video-1',
|
||||
siteUrl: 'https://reset.example.com',
|
||||
tokenValue: 'session-token',
|
||||
}).run();
|
||||
await db.insert(schema.downstreamApiKeys).values({
|
||||
name: 'Downstream',
|
||||
key: 'downstream-key',
|
||||
}).run();
|
||||
await db.insert(schema.events).values({
|
||||
type: 'status',
|
||||
title: 'Before reset',
|
||||
message: 'should be gone',
|
||||
level: 'warning',
|
||||
}).run();
|
||||
await db.insert(schema.settings).values([
|
||||
{ key: 'auth_token', value: JSON.stringify('before-reset-token') },
|
||||
{ key: 'db_type', value: JSON.stringify('postgres') },
|
||||
{ key: 'db_url', value: JSON.stringify('postgres://user:pass@127.0.0.1:5432/metapi') },
|
||||
{ key: 'db_ssl', value: JSON.stringify(true) },
|
||||
{ key: 'system_proxy_url', value: JSON.stringify('http://127.0.0.1:7890') },
|
||||
]).run();
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/settings/maintenance/factory-reset',
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual({ success: true });
|
||||
expect(config.authToken).toBe(FACTORY_RESET_ADMIN_TOKEN);
|
||||
expect(config.dbType).toBe('sqlite');
|
||||
expect(config.dbUrl).toBe('');
|
||||
expect(config.dbSsl).toBe(false);
|
||||
|
||||
const sites = await db.select().from(schema.sites).all();
|
||||
expect(sites.map((site) => site.name)).toEqual([
|
||||
'OpenAI 官方',
|
||||
'Claude 官方',
|
||||
'Gemini 官方',
|
||||
'CLIProxyAPI',
|
||||
]);
|
||||
|
||||
const authTokenSetting = await db.select().from(schema.settings).where(eq(schema.settings.key, 'auth_token')).get();
|
||||
const dbTypeSetting = await db.select().from(schema.settings).where(eq(schema.settings.key, 'db_type')).get();
|
||||
const dbUrlSetting = await db.select().from(schema.settings).where(eq(schema.settings.key, 'db_url')).get();
|
||||
const dbSslSetting = await db.select().from(schema.settings).where(eq(schema.settings.key, 'db_ssl')).get();
|
||||
expect(authTokenSetting).toBeUndefined();
|
||||
expect(dbTypeSetting).toBeUndefined();
|
||||
expect(dbUrlSetting).toBeUndefined();
|
||||
expect(dbSslSetting).toBeUndefined();
|
||||
|
||||
expect(await db.select().from(schema.accounts).all()).toHaveLength(0);
|
||||
expect(await db.select().from(schema.accountTokens).all()).toHaveLength(0);
|
||||
expect(await db.select().from(schema.tokenRoutes).all()).toHaveLength(0);
|
||||
expect(await db.select().from(schema.routeChannels).all()).toHaveLength(0);
|
||||
expect(await db.select().from(schema.modelAvailability).all()).toHaveLength(0);
|
||||
expect(await db.select().from(schema.tokenModelAvailability).all()).toHaveLength(0);
|
||||
expect(await db.select().from(schema.proxyLogs).all()).toHaveLength(0);
|
||||
expect(await db.select().from(schema.proxyVideoTasks).all()).toHaveLength(0);
|
||||
expect(await db.select().from(schema.checkinLogs).all()).toHaveLength(0);
|
||||
expect(await db.select().from(schema.downstreamApiKeys).all()).toHaveLength(0);
|
||||
expect(await db.select().from(schema.events).all()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
|
||||
import { extractClientIp, isIpAllowed } from '../../middleware/auth.js';
|
||||
import { invalidateSiteProxyCache, normalizeSiteProxyUrl } from '../../services/siteProxy.js';
|
||||
import { performFactoryReset } from '../../services/factoryResetService.js';
|
||||
|
||||
type RoutingWeights = typeof config.routingWeights;
|
||||
|
||||
@@ -910,4 +911,18 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
deletedProxyLogs,
|
||||
};
|
||||
});
|
||||
|
||||
app.post('/api/settings/maintenance/factory-reset', async (_, reply) => {
|
||||
try {
|
||||
await performFactoryReset();
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
} catch (err: any) {
|
||||
return reply.code(500).send({
|
||||
success: false,
|
||||
message: err?.message || '重新初始化系统失败',
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { mkdtempSync } from 'node:fs';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { formatLocalDate } from '../../services/localTimeService.js';
|
||||
import { formatLocalDate, formatUtcSqlDateTime } from '../../services/localTimeService.js';
|
||||
|
||||
type DbModule = typeof import('../../db/index.js');
|
||||
|
||||
@@ -153,4 +153,93 @@ describe('stats dashboard filters disabled sites', () => {
|
||||
total: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns recent RPM and TPM stats using only active-site proxy logs', async () => {
|
||||
const activeSite = await db.insert(schema.sites).values({
|
||||
name: 'active-performance-site',
|
||||
url: 'https://active-performance.example.com',
|
||||
platform: 'new-api',
|
||||
}).returning().get();
|
||||
|
||||
const disabledSite = await db.insert(schema.sites).values({
|
||||
name: 'disabled-performance-site',
|
||||
url: 'https://disabled-performance.example.com',
|
||||
platform: 'new-api',
|
||||
}).returning().get();
|
||||
|
||||
await db.run(sql`update sites set status = 'disabled' where id = ${disabledSite.id}`);
|
||||
|
||||
const activeAccount = await db.insert(schema.accounts).values({
|
||||
siteId: activeSite.id,
|
||||
username: 'active-performance-user',
|
||||
accessToken: 'active-performance-token',
|
||||
balance: 100,
|
||||
status: 'active',
|
||||
}).returning().get();
|
||||
|
||||
const disabledAccount = await db.insert(schema.accounts).values({
|
||||
siteId: disabledSite.id,
|
||||
username: 'disabled-performance-user',
|
||||
accessToken: 'disabled-performance-token',
|
||||
balance: 100,
|
||||
status: 'active',
|
||||
}).returning().get();
|
||||
|
||||
const now = Date.now();
|
||||
const withinWindow = formatUtcSqlDateTime(new Date(now - 30_000));
|
||||
const outsideWindow = formatUtcSqlDateTime(new Date(now - 90_000));
|
||||
|
||||
await db.insert(schema.proxyLogs).values([
|
||||
{
|
||||
accountId: activeAccount.id,
|
||||
status: 'success',
|
||||
totalTokens: 1200,
|
||||
createdAt: withinWindow,
|
||||
},
|
||||
{
|
||||
accountId: activeAccount.id,
|
||||
status: 'failed',
|
||||
totalTokens: 600,
|
||||
createdAt: withinWindow,
|
||||
},
|
||||
{
|
||||
accountId: activeAccount.id,
|
||||
status: 'success',
|
||||
totalTokens: null,
|
||||
createdAt: withinWindow,
|
||||
},
|
||||
{
|
||||
accountId: activeAccount.id,
|
||||
status: 'success',
|
||||
totalTokens: 9999,
|
||||
createdAt: outsideWindow,
|
||||
},
|
||||
{
|
||||
accountId: disabledAccount.id,
|
||||
status: 'success',
|
||||
totalTokens: 7777,
|
||||
createdAt: withinWindow,
|
||||
},
|
||||
]).run();
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/stats/dashboard',
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const body = response.json() as {
|
||||
performance?: {
|
||||
windowSeconds: number;
|
||||
requestsPerMinute: number;
|
||||
tokensPerMinute: number;
|
||||
};
|
||||
};
|
||||
|
||||
expect(body.performance).toEqual({
|
||||
windowSeconds: 60,
|
||||
requestsPerMinute: 3,
|
||||
tokensPerMinute: 1800,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -115,6 +115,7 @@ export async function statsRoutes(app: FastifyInstance) {
|
||||
|
||||
const nowTs = Date.now();
|
||||
const last24hDate = formatUtcSqlDateTime(new Date(nowTs - 86400000));
|
||||
const lastMinuteDate = formatUtcSqlDateTime(new Date(nowTs - 60_000));
|
||||
const last7dDate = getLocalRangeStartUtc(7);
|
||||
const recentProxyLogs = (await db.select({
|
||||
proxy_logs: proxyLogBaseFields,
|
||||
@@ -145,6 +146,15 @@ export async function statsRoutes(app: FastifyInstance) {
|
||||
.leftJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
|
||||
.where(and(gte(schema.proxyLogs.createdAt, last24hDate), eq(schema.sites.status, 'active')))
|
||||
.get();
|
||||
const proxyPerformanceRow = await db.select({
|
||||
total: sql<number>`count(*)`,
|
||||
totalTokens: sql<number>`coalesce(sum(coalesce(${schema.proxyLogs.totalTokens}, 0)), 0)`,
|
||||
})
|
||||
.from(schema.proxyLogs)
|
||||
.leftJoin(schema.accounts, eq(schema.proxyLogs.accountId, schema.accounts.id))
|
||||
.leftJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
|
||||
.where(and(gte(schema.proxyLogs.createdAt, lastMinuteDate), eq(schema.sites.status, 'active')))
|
||||
.get();
|
||||
const todaySpendRow = await db.select({
|
||||
todaySpend: sql<number>`coalesce(sum(coalesce(${schema.proxyLogs.estimatedCost}, 0)), 0)`,
|
||||
})
|
||||
@@ -162,6 +172,8 @@ export async function statsRoutes(app: FastifyInstance) {
|
||||
const proxyFailed = Number(proxy24hRow?.failed || 0);
|
||||
const proxyTotal = Number(proxy24hRow?.total || 0);
|
||||
const totalTokens = Number(proxy24hRow?.totalTokens || 0);
|
||||
const requestsPerMinute = Number(proxyPerformanceRow?.total || 0);
|
||||
const tokensPerMinute = Number(proxyPerformanceRow?.totalTokens || 0);
|
||||
const totalUsed = Number(totalUsedRow?.totalUsed || 0);
|
||||
const todaySpend = Number(todaySpendRow?.todaySpend || 0);
|
||||
const todayReward = accounts.reduce((sum, account) => sum + estimateRewardWithTodayIncomeFallback({
|
||||
@@ -182,6 +194,11 @@ export async function statsRoutes(app: FastifyInstance) {
|
||||
totalAccounts: accounts.length,
|
||||
todayCheckin: { success: checkinSuccess, failed: checkinFailed, total: todayCheckins.length },
|
||||
proxy24h: { success: proxySuccess, failed: proxyFailed, total: proxyTotal, totalTokens },
|
||||
performance: {
|
||||
windowSeconds: 60,
|
||||
requestsPerMinute,
|
||||
tokensPerMinute,
|
||||
},
|
||||
modelAnalysis,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const fetchMock = vi.fn();
|
||||
|
||||
vi.mock('undici', async () => {
|
||||
const actual = await vi.importActual<typeof import('undici')>('undici');
|
||||
return {
|
||||
...actual,
|
||||
fetch: (...args: unknown[]) => fetchMock(...args),
|
||||
};
|
||||
});
|
||||
|
||||
describe('testRoutes proxy tester transport', () => {
|
||||
let app: FastifyInstance;
|
||||
|
||||
beforeAll(async () => {
|
||||
const { testRoutes } = await import('./test.js');
|
||||
app = Fastify();
|
||||
await app.register(testRoutes);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock.mockReset();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('rejects proxy test paths outside the whitelist', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/test/proxy',
|
||||
payload: {
|
||||
method: 'POST',
|
||||
path: '/api/accounts',
|
||||
requestKind: 'json',
|
||||
stream: false,
|
||||
jobMode: false,
|
||||
rawMode: false,
|
||||
jsonBody: {},
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json()).toEqual({
|
||||
error: {
|
||||
message: 'path is not allowed: /api/accounts',
|
||||
type: 'validation_error',
|
||||
},
|
||||
});
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('forwards raw json bodies without dropping unknown fields', async () => {
|
||||
fetchMock.mockResolvedValue(new Response(JSON.stringify({
|
||||
object: 'response',
|
||||
output_text: 'ok',
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}));
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/test/proxy',
|
||||
payload: {
|
||||
method: 'POST',
|
||||
path: '/v1/responses',
|
||||
requestKind: 'json',
|
||||
stream: false,
|
||||
jobMode: false,
|
||||
rawMode: true,
|
||||
rawJsonText: JSON.stringify({
|
||||
model: 'gpt-4o-mini',
|
||||
include: ['reasoning.encrypted_content'],
|
||||
metadata: { source: 'playground' },
|
||||
custom_field: 'keep-me',
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const [url, requestInit] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
expect(url).toMatch(/\/v1\/responses$/);
|
||||
expect(JSON.parse(String(requestInit.body))).toEqual({
|
||||
model: 'gpt-4o-mini',
|
||||
include: ['reasoning.encrypted_content'],
|
||||
metadata: { source: 'playground' },
|
||||
custom_field: 'keep-me',
|
||||
stream: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('translates legacy claude chat tests through the compatibility wrapper', async () => {
|
||||
fetchMock.mockResolvedValue(new Response(JSON.stringify({
|
||||
type: 'message',
|
||||
content: [{ type: 'text', text: 'pong' }],
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}));
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/test/chat',
|
||||
payload: {
|
||||
model: 'claude-3-7-sonnet',
|
||||
targetFormat: 'claude',
|
||||
messages: [
|
||||
{ role: 'system', content: 'be concise' },
|
||||
{ role: 'user', content: 'ping' },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const [url, requestInit] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
expect(url).toMatch(/\/v1\/messages$/);
|
||||
expect(JSON.parse(String(requestInit.body))).toEqual({
|
||||
model: 'claude-3-7-sonnet',
|
||||
stream: false,
|
||||
max_tokens: 4096,
|
||||
system: 'be concise',
|
||||
messages: [
|
||||
{ role: 'user', content: 'ping' },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts stream=true when creating proxy jobs and stores a pending job', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/test/proxy/jobs',
|
||||
payload: {
|
||||
method: 'POST',
|
||||
path: '/v1/chat/completions',
|
||||
requestKind: 'json',
|
||||
stream: true,
|
||||
jobMode: true,
|
||||
rawMode: false,
|
||||
jsonBody: { model: 'gpt-4o-mini', messages: [{ role: 'user', content: 'ping' }] },
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(202);
|
||||
expect(response.json()).toMatchObject({
|
||||
status: 'pending',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,145 @@
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { config } from '../../config.js';
|
||||
|
||||
const fetchMock = vi.fn();
|
||||
|
||||
vi.mock('undici', async () => {
|
||||
const actual = await vi.importActual<typeof import('undici')>('undici');
|
||||
return {
|
||||
...actual,
|
||||
fetch: (...args: unknown[]) => fetchMock(...args),
|
||||
};
|
||||
});
|
||||
|
||||
describe('/api/test proxy tester routes', () => {
|
||||
let app: FastifyInstance;
|
||||
|
||||
beforeAll(async () => {
|
||||
const { testRoutes } = await import('./test.js');
|
||||
app = Fastify();
|
||||
await app.register(testRoutes);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock.mockReset();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('rejects proxy paths outside the allowlist', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/test/proxy',
|
||||
payload: {
|
||||
method: 'POST',
|
||||
path: '/admin/secret',
|
||||
requestKind: 'json',
|
||||
jsonBody: {},
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json()).toMatchObject({
|
||||
error: {
|
||||
message: 'path is not allowed: /admin/secret',
|
||||
type: 'validation_error',
|
||||
},
|
||||
});
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('forwards raw json bodies without dropping extra proxy fields', async () => {
|
||||
fetchMock.mockResolvedValue(new Response(JSON.stringify({
|
||||
object: 'response',
|
||||
output_text: 'ok',
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}));
|
||||
|
||||
const rawPayload = {
|
||||
model: 'gpt-5.2',
|
||||
include: ['reasoning.encrypted_content'],
|
||||
reasoning: { effort: 'medium' },
|
||||
metadata: { source: 'tester' },
|
||||
};
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/test/proxy',
|
||||
payload: {
|
||||
method: 'POST',
|
||||
path: '/v1/responses',
|
||||
requestKind: 'json',
|
||||
rawMode: true,
|
||||
rawJsonText: JSON.stringify(rawPayload),
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const [targetUrl, requestInit] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
expect(targetUrl).toBe(`http://127.0.0.1:${config.port}/v1/responses`);
|
||||
expect(JSON.parse(String(requestInit.body))).toEqual({
|
||||
...rawPayload,
|
||||
stream: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects empty multipart envelopes', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/test/proxy',
|
||||
payload: {
|
||||
method: 'POST',
|
||||
path: '/v1/images/edits',
|
||||
requestKind: 'multipart',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json()).toMatchObject({
|
||||
error: {
|
||||
message: 'multipart requests require multipartFields or multipartFiles',
|
||||
type: 'validation_error',
|
||||
},
|
||||
});
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps legacy /api/test/chat wrapper working for responses payloads', async () => {
|
||||
fetchMock.mockResolvedValue(new Response(JSON.stringify({
|
||||
object: 'response',
|
||||
output_text: 'hello',
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}));
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/test/chat',
|
||||
payload: {
|
||||
model: 'gpt-5.2',
|
||||
targetFormat: 'responses',
|
||||
messages: [
|
||||
{ role: 'system', content: 'be concise' },
|
||||
{ role: 'user', content: 'hello' },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const [targetUrl, requestInit] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
expect(targetUrl).toBe(`http://127.0.0.1:${config.port}/v1/responses`);
|
||||
expect(JSON.parse(String(requestInit.body))).toEqual({
|
||||
model: 'gpt-5.2',
|
||||
stream: false,
|
||||
input: [{ role: 'user', content: 'hello' }],
|
||||
instructions: 'be concise',
|
||||
max_output_tokens: 4096,
|
||||
});
|
||||
});
|
||||
});
|
||||
+567
-174
@@ -1,10 +1,12 @@
|
||||
import { FastifyInstance, FastifyReply } from 'fastify';
|
||||
import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { fetch } from 'undici';
|
||||
import { fetch, File, FormData } from 'undici';
|
||||
import { config } from '../../config.js';
|
||||
|
||||
type UndiciRequestInit = Parameters<typeof fetch>[1];
|
||||
|
||||
type TestChatMessage = { role: string; content: string };
|
||||
type TestTargetFormat = 'openai' | 'claude' | 'responses';
|
||||
type TestTargetFormat = 'openai' | 'claude' | 'responses' | 'gemini';
|
||||
|
||||
type TestChatRequestBody = {
|
||||
model?: string;
|
||||
@@ -32,12 +34,48 @@ type ValidatedTestChatPayload = {
|
||||
seed?: number;
|
||||
};
|
||||
|
||||
type TestChatJobStatus = 'pending' | 'succeeded' | 'failed' | 'cancelled';
|
||||
type ProxyTestMethod = 'POST' | 'GET' | 'DELETE';
|
||||
type ProxyTestRequestKind = 'json' | 'multipart' | 'empty';
|
||||
|
||||
type TestChatJob = {
|
||||
type ProxyTestMultipartFile = {
|
||||
field: string;
|
||||
name: string;
|
||||
mimeType: string;
|
||||
dataUrl: string;
|
||||
};
|
||||
|
||||
type ProxyTestEnvelope = {
|
||||
method?: ProxyTestMethod;
|
||||
path?: string;
|
||||
requestKind?: ProxyTestRequestKind;
|
||||
stream?: boolean;
|
||||
jobMode?: boolean;
|
||||
rawMode?: boolean;
|
||||
jsonBody?: unknown;
|
||||
rawJsonText?: string;
|
||||
multipartFields?: Record<string, string>;
|
||||
multipartFiles?: ProxyTestMultipartFile[];
|
||||
};
|
||||
|
||||
type ValidatedProxyTestEnvelope = {
|
||||
method: ProxyTestMethod;
|
||||
path: string;
|
||||
requestKind: ProxyTestRequestKind;
|
||||
stream: boolean;
|
||||
jobMode: boolean;
|
||||
rawMode: boolean;
|
||||
jsonBody?: unknown;
|
||||
rawJsonText?: string;
|
||||
multipartFields?: Record<string, string>;
|
||||
multipartFiles?: ProxyTestMultipartFile[];
|
||||
};
|
||||
|
||||
type TestJobStatus = 'pending' | 'succeeded' | 'failed' | 'cancelled';
|
||||
|
||||
type TestProxyJob = {
|
||||
id: string;
|
||||
status: TestChatJobStatus;
|
||||
payload: ValidatedTestChatPayload;
|
||||
status: TestJobStatus;
|
||||
envelope: ValidatedProxyTestEnvelope;
|
||||
result?: unknown;
|
||||
error?: unknown;
|
||||
controller?: AbortController | null;
|
||||
@@ -48,7 +86,22 @@ type TestChatJob = {
|
||||
|
||||
const JOB_TTL_MS = 10 * 60 * 1000;
|
||||
const JOB_CLEANUP_INTERVAL_MS = 60 * 1000;
|
||||
const jobs = new Map<string, TestChatJob>();
|
||||
const jobs = new Map<string, TestProxyJob>();
|
||||
|
||||
const ALLOWED_PROXY_PATH_PATTERNS: RegExp[] = [
|
||||
/^\/v1\/chat\/completions(?:\?.*)?$/i,
|
||||
/^\/v1\/responses(?:\/compact)?(?:\?.*)?$/i,
|
||||
/^\/v1\/messages(?:\?.*)?$/i,
|
||||
/^\/v1\/embeddings(?:\?.*)?$/i,
|
||||
/^\/v1\/search(?:\?.*)?$/i,
|
||||
/^\/v1\/images\/(?:generations|edits)(?:\?.*)?$/i,
|
||||
/^\/v1\/videos(?:\?.*)?$/i,
|
||||
/^\/v1\/videos\/[^/?#]+(?:\?.*)?$/i,
|
||||
/^\/gemini\/[^/]+\/models(?:\?.*)?$/i,
|
||||
/^\/gemini\/[^/]+\/models\/.+(?:\?.*)?$/i,
|
||||
/^\/v1beta\/models(?:\?.*)?$/i,
|
||||
/^\/v1beta\/models\/.+(?:\?.*)?$/i,
|
||||
];
|
||||
|
||||
class UpstreamProxyError extends Error {
|
||||
statusCode: number;
|
||||
@@ -62,18 +115,41 @@ class UpstreamProxyError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
const normalizeErrorPayload = (text: string): unknown => {
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function normalizeErrorPayload(text: string): unknown {
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return { error: { message: text, type: 'upstream_error' } };
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const validatePayload = (
|
||||
function normalizeProxyPath(value: unknown): string {
|
||||
if (typeof value !== 'string') return '';
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return '';
|
||||
try {
|
||||
if (/^https?:\/\//i.test(trimmed)) {
|
||||
const url = new URL(trimmed);
|
||||
return `${url.pathname}${url.search}`;
|
||||
}
|
||||
} catch {
|
||||
// ignore invalid absolute URL
|
||||
}
|
||||
return trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
|
||||
}
|
||||
|
||||
function isAllowedProxyPath(path: string): boolean {
|
||||
return ALLOWED_PROXY_PATH_PATTERNS.some((pattern) => pattern.test(path));
|
||||
}
|
||||
|
||||
function validateLegacyPayload(
|
||||
body: TestChatRequestBody,
|
||||
reply: FastifyReply,
|
||||
): ValidatedTestChatPayload | null => {
|
||||
): ValidatedTestChatPayload | null {
|
||||
if (!body.model || body.model.trim().length === 0) {
|
||||
reply.code(400).send({ error: 'model is required' });
|
||||
return null;
|
||||
@@ -88,7 +164,9 @@ const validatePayload = (
|
||||
? 'claude'
|
||||
: body.targetFormat === 'responses'
|
||||
? 'responses'
|
||||
: 'openai';
|
||||
: body.targetFormat === 'gemini'
|
||||
? 'gemini'
|
||||
: 'openai';
|
||||
|
||||
return {
|
||||
model: body.model,
|
||||
@@ -102,12 +180,12 @@ const validatePayload = (
|
||||
presence_penalty: body.presence_penalty,
|
||||
seed: body.seed,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
const convertOpenAiPayloadToClaudeBody = (
|
||||
function convertOpenAiPayloadToClaudeBody(
|
||||
payload: ValidatedTestChatPayload,
|
||||
forceStream: boolean,
|
||||
): Record<string, unknown> => {
|
||||
): Record<string, unknown> {
|
||||
const systemContents: string[] = [];
|
||||
const messages: Array<{ role: 'user' | 'assistant'; content: string }> = [];
|
||||
|
||||
@@ -136,10 +214,7 @@ const convertOpenAiPayloadToClaudeBody = (
|
||||
messages,
|
||||
};
|
||||
|
||||
if (systemContents.length > 0) {
|
||||
body.system = systemContents.join('\n\n');
|
||||
}
|
||||
|
||||
if (systemContents.length > 0) body.system = systemContents.join('\n\n');
|
||||
if (typeof payload.temperature === 'number' && Number.isFinite(payload.temperature)) {
|
||||
body.temperature = payload.temperature;
|
||||
}
|
||||
@@ -148,12 +223,12 @@ const convertOpenAiPayloadToClaudeBody = (
|
||||
}
|
||||
|
||||
return body;
|
||||
};
|
||||
}
|
||||
|
||||
const convertOpenAiPayloadToResponsesBody = (
|
||||
function convertOpenAiPayloadToResponsesBody(
|
||||
payload: ValidatedTestChatPayload,
|
||||
forceStream: boolean,
|
||||
): Record<string, unknown> => {
|
||||
): Record<string, unknown> {
|
||||
const systemContents: string[] = [];
|
||||
const messages: Array<{ role: 'user' | 'assistant'; content: string }> = [];
|
||||
|
||||
@@ -198,81 +273,282 @@ const convertOpenAiPayloadToResponsesBody = (
|
||||
: 4096;
|
||||
|
||||
return body;
|
||||
};
|
||||
}
|
||||
|
||||
const buildUpstreamRequest = (
|
||||
function convertLegacyPayloadToEnvelope(
|
||||
payload: ValidatedTestChatPayload,
|
||||
forceStream: boolean,
|
||||
): { url: string; headers: Record<string, string>; body: Record<string, unknown> } => {
|
||||
): ValidatedProxyTestEnvelope {
|
||||
if (payload.targetFormat === 'claude') {
|
||||
return {
|
||||
url: `http://127.0.0.1:${config.port}/v1/messages`,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-api-key': config.proxyToken,
|
||||
'anthropic-version': '2023-06-01',
|
||||
},
|
||||
body: convertOpenAiPayloadToClaudeBody(payload, forceStream),
|
||||
method: 'POST',
|
||||
path: '/v1/messages',
|
||||
requestKind: 'json',
|
||||
stream: forceStream,
|
||||
jobMode: false,
|
||||
rawMode: false,
|
||||
jsonBody: convertOpenAiPayloadToClaudeBody(payload, forceStream),
|
||||
};
|
||||
}
|
||||
|
||||
if (payload.targetFormat === 'responses') {
|
||||
return {
|
||||
url: `http://127.0.0.1:${config.port}/v1/responses`,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${config.proxyToken}`,
|
||||
},
|
||||
body: convertOpenAiPayloadToResponsesBody(payload, forceStream),
|
||||
method: 'POST',
|
||||
path: '/v1/responses',
|
||||
requestKind: 'json',
|
||||
stream: forceStream,
|
||||
jobMode: false,
|
||||
rawMode: false,
|
||||
jsonBody: convertOpenAiPayloadToResponsesBody(payload, forceStream),
|
||||
};
|
||||
}
|
||||
|
||||
const { targetFormat: _targetFormat, ...openAiPayload } = payload;
|
||||
return {
|
||||
url: `http://127.0.0.1:${config.port}/v1/chat/completions`,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${config.proxyToken}`,
|
||||
method: 'POST',
|
||||
path: '/v1/chat/completions',
|
||||
requestKind: 'json',
|
||||
stream: forceStream,
|
||||
jobMode: false,
|
||||
rawMode: false,
|
||||
jsonBody: {
|
||||
...payload,
|
||||
stream: forceStream,
|
||||
},
|
||||
body: { ...openAiPayload, stream: forceStream },
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
const requestUpstreamChat = async (
|
||||
payload: ValidatedTestChatPayload,
|
||||
function validateProxyEnvelope(
|
||||
body: ProxyTestEnvelope,
|
||||
reply: FastifyReply,
|
||||
): ValidatedProxyTestEnvelope | null {
|
||||
const method = body.method === 'GET' || body.method === 'DELETE' ? body.method : 'POST';
|
||||
const path = normalizeProxyPath(body.path);
|
||||
|
||||
if (!path) {
|
||||
reply.code(400).send({ error: { message: 'path is required', type: 'validation_error' } });
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!isAllowedProxyPath(path)) {
|
||||
reply.code(400).send({ error: { message: `path is not allowed: ${path}`, type: 'validation_error' } });
|
||||
return null;
|
||||
}
|
||||
|
||||
const requestKind: ProxyTestRequestKind = body.requestKind === 'multipart'
|
||||
? 'multipart'
|
||||
: body.requestKind === 'empty'
|
||||
? 'empty'
|
||||
: 'json';
|
||||
|
||||
if (method !== 'POST' && requestKind !== 'empty') {
|
||||
reply.code(400).send({
|
||||
error: { message: `${method} only supports requestKind=empty in tester`, type: 'validation_error' },
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
const envelope: ValidatedProxyTestEnvelope = {
|
||||
method,
|
||||
path,
|
||||
requestKind,
|
||||
stream: body.stream === true,
|
||||
jobMode: body.jobMode === true,
|
||||
rawMode: body.rawMode === true,
|
||||
};
|
||||
|
||||
if (requestKind === 'json') {
|
||||
if (typeof body.rawJsonText === 'string' && body.rawJsonText.trim().length > 0) {
|
||||
envelope.rawJsonText = body.rawJsonText;
|
||||
}
|
||||
if (body.jsonBody !== undefined) {
|
||||
envelope.jsonBody = body.jsonBody;
|
||||
}
|
||||
if (envelope.rawMode && typeof envelope.rawJsonText !== 'string') {
|
||||
reply.code(400).send({
|
||||
error: { message: 'rawJsonText is required when rawMode is enabled', type: 'validation_error' },
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
if (requestKind === 'multipart') {
|
||||
envelope.multipartFields = isRecord(body.multipartFields)
|
||||
? Object.fromEntries(
|
||||
Object.entries(body.multipartFields)
|
||||
.filter((entry): entry is [string, string] => typeof entry[0] === 'string' && typeof entry[1] === 'string'),
|
||||
)
|
||||
: {};
|
||||
envelope.multipartFiles = Array.isArray(body.multipartFiles)
|
||||
? body.multipartFiles
|
||||
.filter((item): item is ProxyTestMultipartFile => (
|
||||
!!item
|
||||
&& typeof item.field === 'string'
|
||||
&& item.field.trim().length > 0
|
||||
&& typeof item.name === 'string'
|
||||
&& item.name.trim().length > 0
|
||||
&& typeof item.mimeType === 'string'
|
||||
&& item.mimeType.trim().length > 0
|
||||
&& typeof item.dataUrl === 'string'
|
||||
&& item.dataUrl.trim().length > 0
|
||||
))
|
||||
: [];
|
||||
if ((envelope.multipartFiles?.length || 0) === 0 && Object.keys(envelope.multipartFields || {}).length === 0) {
|
||||
reply.code(400).send({
|
||||
error: { message: 'multipart requests require multipartFields or multipartFiles', type: 'validation_error' },
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
if (requestKind === 'empty' && (method === 'POST' || path === '/v1/search')) {
|
||||
// keep explicit empty body path legal, no additional validation
|
||||
}
|
||||
|
||||
return envelope;
|
||||
}
|
||||
|
||||
function decodeDataUrl(dataUrl: string): { mimeType: string; buffer: Buffer } {
|
||||
const match = /^data:([^;,]+)?;base64,(.+)$/i.exec(dataUrl.trim());
|
||||
if (!match) {
|
||||
throw new Error('multipartFiles[].dataUrl must be a base64 data URL');
|
||||
}
|
||||
return {
|
||||
mimeType: match[1] || 'application/octet-stream',
|
||||
buffer: Buffer.from(match[2], 'base64'),
|
||||
};
|
||||
}
|
||||
|
||||
function createDefaultHeadersForPath(path: string): Record<string, string> {
|
||||
if (/^\/v1\/messages$/i.test(path)) {
|
||||
return {
|
||||
'x-api-key': config.proxyToken,
|
||||
'anthropic-version': '2023-06-01',
|
||||
};
|
||||
}
|
||||
|
||||
if (/^\/(?:gemini\/[^/]+\/models\/.+|v1beta\/models\/.+)$/i.test(path)) {
|
||||
return {
|
||||
'x-goog-api-key': config.proxyToken,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
Authorization: `Bearer ${config.proxyToken}`,
|
||||
};
|
||||
}
|
||||
|
||||
function applyStreamOverride(value: unknown, forceStream: boolean): unknown {
|
||||
if (!isRecord(value)) return value;
|
||||
return {
|
||||
...value,
|
||||
stream: forceStream,
|
||||
};
|
||||
}
|
||||
|
||||
function serializeJsonEnvelopeBody(
|
||||
envelope: ValidatedProxyTestEnvelope,
|
||||
forceStream: boolean,
|
||||
): string | undefined {
|
||||
if (typeof envelope.rawJsonText === 'string' && envelope.rawJsonText.trim().length > 0) {
|
||||
try {
|
||||
const parsed = JSON.parse(envelope.rawJsonText);
|
||||
return JSON.stringify(applyStreamOverride(parsed, forceStream));
|
||||
} catch {
|
||||
return envelope.rawJsonText;
|
||||
}
|
||||
}
|
||||
|
||||
if (envelope.jsonBody !== undefined) {
|
||||
return JSON.stringify(applyStreamOverride(envelope.jsonBody, forceStream));
|
||||
}
|
||||
|
||||
return JSON.stringify({ stream: forceStream });
|
||||
}
|
||||
|
||||
async function buildUpstreamRequestInit(
|
||||
envelope: ValidatedProxyTestEnvelope,
|
||||
forceStream: boolean,
|
||||
): Promise<UndiciRequestInit> {
|
||||
const headers: Record<string, string> = createDefaultHeadersForPath(envelope.path);
|
||||
|
||||
if (envelope.requestKind === 'json') {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
return {
|
||||
method: envelope.method,
|
||||
headers,
|
||||
body: serializeJsonEnvelopeBody(envelope, forceStream),
|
||||
};
|
||||
}
|
||||
|
||||
if (envelope.requestKind === 'multipart') {
|
||||
const formData = new FormData();
|
||||
for (const [field, value] of Object.entries(envelope.multipartFields || {})) {
|
||||
formData.append(field, value);
|
||||
}
|
||||
for (const file of envelope.multipartFiles || []) {
|
||||
const decoded = decodeDataUrl(file.dataUrl);
|
||||
const bytes = Uint8Array.from(decoded.buffer);
|
||||
formData.append(
|
||||
file.field,
|
||||
new File([bytes], file.name, { type: file.mimeType || decoded.mimeType }),
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
method: envelope.method,
|
||||
headers,
|
||||
body: formData,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
method: envelope.method,
|
||||
headers,
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchProxyBuffered(
|
||||
envelope: ValidatedProxyTestEnvelope,
|
||||
signal?: AbortSignal,
|
||||
forceStream = false,
|
||||
): Promise<unknown> => {
|
||||
const upstreamRequest = buildUpstreamRequest(payload, forceStream);
|
||||
const upstream = await fetch(upstreamRequest.url, {
|
||||
method: 'POST',
|
||||
headers: upstreamRequest.headers,
|
||||
body: JSON.stringify(upstreamRequest.body),
|
||||
): Promise<unknown> {
|
||||
const upstream = await fetch(`http://127.0.0.1:${config.port}${envelope.path}`, {
|
||||
...(await buildUpstreamRequestInit(envelope, forceStream)),
|
||||
signal,
|
||||
});
|
||||
|
||||
const contentType = upstream.headers.get('content-type') || '';
|
||||
const text = await upstream.text();
|
||||
|
||||
if (!upstream.ok) {
|
||||
throw new UpstreamProxyError(upstream.status, normalizeErrorPayload(text));
|
||||
}
|
||||
|
||||
if (contentType.includes('application/json')) {
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return { raw: text };
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return { raw: text };
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const cleanupExpiredJobs = () => {
|
||||
function cleanupExpiredJobs() {
|
||||
const now = Date.now();
|
||||
for (const [jobId, job] of jobs.entries()) {
|
||||
if (job.expiresAt <= now) {
|
||||
jobs.delete(jobId);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const runJob = async (jobId: string) => {
|
||||
async function runJob(jobId: string) {
|
||||
const job = jobs.get(jobId);
|
||||
if (!job || job.status !== 'pending') return;
|
||||
|
||||
@@ -280,7 +556,7 @@ const runJob = async (jobId: string) => {
|
||||
job.controller = controller;
|
||||
|
||||
try {
|
||||
const result = await requestUpstreamChat(job.payload, controller.signal);
|
||||
const result = await fetchProxyBuffered(job.envelope, controller.signal, job.envelope.stream);
|
||||
const current = jobs.get(jobId);
|
||||
if (!current) return;
|
||||
current.controller = null;
|
||||
@@ -308,7 +584,141 @@ const runJob = async (jobId: string) => {
|
||||
current.updatedAt = Date.now();
|
||||
current.expiresAt = current.updatedAt + JOB_TTL_MS;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function sendBufferedEnvelope(
|
||||
envelope: ValidatedProxyTestEnvelope,
|
||||
reply: FastifyReply,
|
||||
forceStream = false,
|
||||
) {
|
||||
try {
|
||||
const data = await fetchProxyBuffered(envelope, undefined, forceStream);
|
||||
return reply.send(data);
|
||||
} catch (error) {
|
||||
if (error instanceof UpstreamProxyError) {
|
||||
return reply.code(error.statusCode).send(error.responsePayload);
|
||||
}
|
||||
return reply.code(502).send({
|
||||
error: {
|
||||
message: (error as any)?.message || 'proxy request failed',
|
||||
type: 'server_error',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function sendStreamingEnvelope(
|
||||
envelope: ValidatedProxyTestEnvelope,
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
forceStream = true,
|
||||
) {
|
||||
const controller = new AbortController();
|
||||
const abortUpstream = () => {
|
||||
try {
|
||||
if (!controller.signal.aborted) {
|
||||
controller.abort();
|
||||
}
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
};
|
||||
|
||||
const onClientAborted = () => abortUpstream();
|
||||
const onClientClosed = () => {
|
||||
if (!reply.raw.writableEnded) abortUpstream();
|
||||
};
|
||||
const cleanupClientListeners = () => {
|
||||
request.raw.off?.('aborted', onClientAborted);
|
||||
reply.raw.off?.('close', onClientClosed);
|
||||
};
|
||||
|
||||
request.raw.on('aborted', onClientAborted);
|
||||
reply.raw.on('close', onClientClosed);
|
||||
|
||||
let upstream;
|
||||
try {
|
||||
upstream = await fetch(`http://127.0.0.1:${config.port}${envelope.path}`, {
|
||||
...(await buildUpstreamRequestInit(envelope, forceStream)),
|
||||
signal: controller.signal,
|
||||
});
|
||||
} catch (error) {
|
||||
cleanupClientListeners();
|
||||
return reply.code(502).send({
|
||||
error: {
|
||||
message: (error as any)?.message || 'proxy request failed',
|
||||
type: 'server_error',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (!upstream.ok) {
|
||||
const text = await upstream.text();
|
||||
cleanupClientListeners();
|
||||
return reply.code(upstream.status).send(normalizeErrorPayload(text));
|
||||
}
|
||||
|
||||
const contentType = upstream.headers.get('content-type') || '';
|
||||
const reader = upstream.body?.getReader();
|
||||
if (!reader) {
|
||||
cleanupClientListeners();
|
||||
return reply.code(502).send({
|
||||
error: {
|
||||
message: 'upstream stream body missing',
|
||||
type: 'server_error',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
reply.hijack();
|
||||
reply.raw.statusCode = 200;
|
||||
reply.raw.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
|
||||
reply.raw.setHeader('Cache-Control', 'no-cache, no-transform');
|
||||
reply.raw.setHeader('Connection', 'keep-alive');
|
||||
reply.raw.setHeader('X-Accel-Buffering', 'no');
|
||||
|
||||
try {
|
||||
if (contentType.includes('text/event-stream')) {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
if (value) {
|
||||
reply.raw.write(Buffer.from(value));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let text = '';
|
||||
const decoder = new TextDecoder('utf-8');
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
if (value) {
|
||||
text += decoder.decode(value, { stream: true });
|
||||
}
|
||||
}
|
||||
text += decoder.decode();
|
||||
reply.raw.write(`data: ${text}\n\n`);
|
||||
reply.raw.write('data: [DONE]\n\n');
|
||||
}
|
||||
} catch (error) {
|
||||
if (!reply.raw.writableEnded) {
|
||||
const message = JSON.stringify({
|
||||
error: { message: (error as any)?.message || 'stream interrupted', type: 'stream_error' },
|
||||
});
|
||||
reply.raw.write(`event: error\ndata: ${message}\n\n`);
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
await reader.cancel();
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
cleanupClientListeners();
|
||||
if (!reply.raw.writableEnded) {
|
||||
reply.raw.end();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function testRoutes(app: FastifyInstance) {
|
||||
const cleanupTimer = setInterval(cleanupExpiredJobs, JOB_CLEANUP_INTERVAL_MS);
|
||||
@@ -318,143 +728,126 @@ export async function testRoutes(app: FastifyInstance) {
|
||||
clearInterval(cleanupTimer);
|
||||
});
|
||||
|
||||
app.post<{ Body: ProxyTestEnvelope }>(
|
||||
'/api/test/proxy',
|
||||
async (request, reply) => {
|
||||
const envelope = validateProxyEnvelope(request.body || {}, reply);
|
||||
if (!envelope) return;
|
||||
return sendBufferedEnvelope(envelope, reply, false);
|
||||
},
|
||||
);
|
||||
|
||||
app.post<{ Body: ProxyTestEnvelope }>(
|
||||
'/api/test/proxy/stream',
|
||||
async (request, reply) => {
|
||||
const envelope = validateProxyEnvelope(request.body || {}, reply);
|
||||
if (!envelope) return;
|
||||
return sendStreamingEnvelope(envelope, request, reply, true);
|
||||
},
|
||||
);
|
||||
|
||||
app.post<{ Body: ProxyTestEnvelope }>(
|
||||
'/api/test/proxy/jobs',
|
||||
async (request, reply) => {
|
||||
const envelope = validateProxyEnvelope(request.body || {}, reply);
|
||||
if (!envelope) return;
|
||||
|
||||
const now = Date.now();
|
||||
const jobId = randomUUID();
|
||||
const job: TestProxyJob = {
|
||||
id: jobId,
|
||||
status: 'pending',
|
||||
envelope,
|
||||
controller: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
expiresAt: now + JOB_TTL_MS,
|
||||
};
|
||||
|
||||
jobs.set(jobId, job);
|
||||
void runJob(jobId);
|
||||
|
||||
return reply.code(202).send({
|
||||
jobId,
|
||||
status: job.status,
|
||||
createdAt: new Date(job.createdAt).toISOString(),
|
||||
expiresAt: new Date(job.expiresAt).toISOString(),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
app.get<{ Params: { jobId: string } }>(
|
||||
'/api/test/proxy/jobs/:jobId',
|
||||
async (request, reply) => {
|
||||
const job = jobs.get(request.params.jobId);
|
||||
if (!job) {
|
||||
return reply.code(404).send({ error: { message: 'job not found', type: 'not_found' } });
|
||||
}
|
||||
|
||||
return reply.send({
|
||||
jobId: job.id,
|
||||
status: job.status,
|
||||
result: job.result,
|
||||
error: job.error,
|
||||
createdAt: new Date(job.createdAt).toISOString(),
|
||||
updatedAt: new Date(job.updatedAt).toISOString(),
|
||||
expiresAt: new Date(job.expiresAt).toISOString(),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
app.delete<{ Params: { jobId: string } }>(
|
||||
'/api/test/proxy/jobs/:jobId',
|
||||
async (request, reply) => {
|
||||
const job = jobs.get(request.params.jobId);
|
||||
if (!job) {
|
||||
return reply.code(404).send({ error: { message: 'job not found', type: 'not_found' } });
|
||||
}
|
||||
|
||||
if (job.status === 'pending' && job.controller) {
|
||||
try {
|
||||
job.controller.abort();
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
}
|
||||
|
||||
jobs.delete(request.params.jobId);
|
||||
return reply.send({ success: true });
|
||||
},
|
||||
);
|
||||
|
||||
app.post<{ Body: TestChatRequestBody }>(
|
||||
'/api/test/chat',
|
||||
async (request, reply) => {
|
||||
const body = request.body || {};
|
||||
const payload = validatePayload(body, reply);
|
||||
const payload = validateLegacyPayload(request.body || {}, reply);
|
||||
if (!payload) return;
|
||||
|
||||
try {
|
||||
const data = await requestUpstreamChat(payload, undefined, false);
|
||||
return reply.send(data);
|
||||
} catch (error) {
|
||||
if (error instanceof UpstreamProxyError) {
|
||||
return reply.code(error.statusCode).send(error.responsePayload);
|
||||
}
|
||||
return reply.code(502).send({
|
||||
error: {
|
||||
message: (error as any)?.message || 'proxy request failed',
|
||||
type: 'server_error',
|
||||
},
|
||||
});
|
||||
}
|
||||
return sendBufferedEnvelope(convertLegacyPayloadToEnvelope(payload, false), reply, false);
|
||||
},
|
||||
);
|
||||
|
||||
app.post<{ Body: TestChatRequestBody }>(
|
||||
'/api/test/chat/stream',
|
||||
async (request, reply) => {
|
||||
const body = request.body || {};
|
||||
const payload = validatePayload(body, reply);
|
||||
const payload = validateLegacyPayload(request.body || {}, reply);
|
||||
if (!payload) return;
|
||||
|
||||
const controller = new AbortController();
|
||||
const abortUpstream = () => {
|
||||
try {
|
||||
if (!controller.signal.aborted) {
|
||||
controller.abort();
|
||||
}
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
};
|
||||
const onClientAborted = () => {
|
||||
abortUpstream();
|
||||
};
|
||||
const onClientClosed = () => {
|
||||
if (!reply.raw.writableEnded) {
|
||||
abortUpstream();
|
||||
}
|
||||
};
|
||||
const cleanupClientListeners = () => {
|
||||
request.raw.off?.('aborted', onClientAborted);
|
||||
reply.raw.off?.('close', onClientClosed);
|
||||
};
|
||||
request.raw.on('aborted', onClientAborted);
|
||||
reply.raw.on('close', onClientClosed);
|
||||
|
||||
let upstream;
|
||||
try {
|
||||
const upstreamRequest = buildUpstreamRequest(payload, true);
|
||||
upstream = await fetch(upstreamRequest.url, {
|
||||
method: 'POST',
|
||||
headers: upstreamRequest.headers,
|
||||
body: JSON.stringify(upstreamRequest.body),
|
||||
signal: controller.signal,
|
||||
});
|
||||
} catch (error) {
|
||||
cleanupClientListeners();
|
||||
return reply.code(502).send({
|
||||
error: {
|
||||
message: (error as any)?.message || 'proxy request failed',
|
||||
type: 'server_error',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (!upstream.ok) {
|
||||
const text = await upstream.text();
|
||||
cleanupClientListeners();
|
||||
return reply.code(upstream.status).send(normalizeErrorPayload(text));
|
||||
}
|
||||
|
||||
reply.hijack();
|
||||
reply.raw.statusCode = 200;
|
||||
reply.raw.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
|
||||
reply.raw.setHeader('Cache-Control', 'no-cache, no-transform');
|
||||
reply.raw.setHeader('Connection', 'keep-alive');
|
||||
reply.raw.setHeader('X-Accel-Buffering', 'no');
|
||||
|
||||
const reader = upstream.body?.getReader();
|
||||
if (!reader) {
|
||||
cleanupClientListeners();
|
||||
reply.raw.end();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
if (value) {
|
||||
reply.raw.write(Buffer.from(value));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (!reply.raw.writableEnded) {
|
||||
const message = JSON.stringify({
|
||||
error: { message: (error as any)?.message || 'stream interrupted', type: 'stream_error' },
|
||||
});
|
||||
reply.raw.write(`event: error\ndata: ${message}\n\n`);
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
await reader.cancel();
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
cleanupClientListeners();
|
||||
if (!reply.raw.writableEnded) {
|
||||
reply.raw.end();
|
||||
}
|
||||
}
|
||||
return sendStreamingEnvelope(convertLegacyPayloadToEnvelope(payload, true), request, reply, true);
|
||||
},
|
||||
);
|
||||
|
||||
app.post<{ Body: TestChatRequestBody }>(
|
||||
'/api/test/chat/jobs',
|
||||
async (request, reply) => {
|
||||
const body = request.body || {};
|
||||
const payload = validatePayload(body, reply);
|
||||
const payload = validateLegacyPayload(request.body || {}, reply);
|
||||
if (!payload) return;
|
||||
|
||||
const envelope = convertLegacyPayloadToEnvelope(payload, false);
|
||||
const now = Date.now();
|
||||
const jobId = randomUUID();
|
||||
const job: TestChatJob = {
|
||||
const job: TestProxyJob = {
|
||||
id: jobId,
|
||||
status: 'pending',
|
||||
payload,
|
||||
envelope,
|
||||
controller: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import { describe, expect, it, beforeAll, beforeEach, afterAll, vi } from 'vitest';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { mkdtempSync } from 'node:fs';
|
||||
|
||||
type DbModule = typeof import('../../db/index.js');
|
||||
type TokenRouterModule = typeof import('../../services/tokenRouter.js');
|
||||
type ModelPricingServiceModule = typeof import('../../services/modelPricingService.js');
|
||||
type ConfigModule = typeof import('../../config.js');
|
||||
|
||||
const { fetchMock, withSiteProxyRequestInitMock } = vi.hoisted(() => ({
|
||||
fetchMock: vi.fn(),
|
||||
withSiteProxyRequestInitMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('undici', () => ({
|
||||
fetch: (...args: unknown[]) => fetchMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/siteProxy.js', () => ({
|
||||
withSiteProxyRequestInit: (...args: unknown[]) => withSiteProxyRequestInitMock(...args),
|
||||
}));
|
||||
|
||||
function buildPricingPayload(modelName: string, modelRatio: number) {
|
||||
return {
|
||||
data: [{
|
||||
model_name: modelName,
|
||||
quota_type: 0,
|
||||
model_ratio: modelRatio,
|
||||
completion_ratio: 1,
|
||||
enable_groups: ['default'],
|
||||
}],
|
||||
group_ratio: { default: 1 },
|
||||
};
|
||||
}
|
||||
|
||||
describe('POST /api/routes/decision/batch refreshPricingCatalog', () => {
|
||||
let app: FastifyInstance;
|
||||
let db: DbModule['db'];
|
||||
let schema: DbModule['schema'];
|
||||
let invalidateTokenRouterCache: TokenRouterModule['invalidateTokenRouterCache'];
|
||||
let fetchModelPricingCatalog: ModelPricingServiceModule['fetchModelPricingCatalog'];
|
||||
let config: ConfigModule['config'];
|
||||
let originalRoutingWeights: typeof config.routingWeights;
|
||||
let originalRoutingFallbackUnitCost: number;
|
||||
let dataDir = '';
|
||||
let pricingPhase: 'old' | 'new' = 'old';
|
||||
|
||||
beforeAll(async () => {
|
||||
dataDir = mkdtempSync(join(tmpdir(), 'metapi-route-decision-refresh-pricing-'));
|
||||
process.env.DATA_DIR = dataDir;
|
||||
|
||||
await import('../../db/migrate.js');
|
||||
const dbModule = await import('../../db/index.js');
|
||||
const routesModule = await import('./tokens.js');
|
||||
const tokenRouterModule = await import('../../services/tokenRouter.js');
|
||||
const modelPricingServiceModule = await import('../../services/modelPricingService.js');
|
||||
const configModule = await import('../../config.js');
|
||||
db = dbModule.db;
|
||||
schema = dbModule.schema;
|
||||
invalidateTokenRouterCache = tokenRouterModule.invalidateTokenRouterCache;
|
||||
fetchModelPricingCatalog = modelPricingServiceModule.fetchModelPricingCatalog;
|
||||
config = configModule.config;
|
||||
originalRoutingWeights = { ...config.routingWeights };
|
||||
originalRoutingFallbackUnitCost = config.routingFallbackUnitCost;
|
||||
|
||||
app = Fastify();
|
||||
await app.register(routesModule.tokensRoutes);
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
pricingPhase = 'old';
|
||||
fetchMock.mockReset();
|
||||
withSiteProxyRequestInitMock.mockReset();
|
||||
withSiteProxyRequestInitMock.mockImplementation(async (_url: string, init: Record<string, unknown>) => init);
|
||||
fetchMock.mockImplementation(async (url: string | URL) => {
|
||||
const normalizedUrl = String(url);
|
||||
let payload: ReturnType<typeof buildPricingPayload> | null = null;
|
||||
if (normalizedUrl.includes('pricing-a.example.com')) {
|
||||
payload = buildPricingPayload('gpt-4o-mini', pricingPhase === 'old' ? 0.1 : 10);
|
||||
} else if (normalizedUrl.includes('pricing-b.example.com')) {
|
||||
payload = buildPricingPayload('gpt-4o-mini', pricingPhase === 'old' ? 10 : 0.1);
|
||||
}
|
||||
|
||||
if (!payload) {
|
||||
return new Response(JSON.stringify({ data: [] }), {
|
||||
status: 404,
|
||||
headers: { 'content-type': 'application/json; charset=utf-8' },
|
||||
});
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify(payload), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json; charset=utf-8' },
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
invalidateTokenRouterCache();
|
||||
|
||||
config.routingWeights = {
|
||||
baseWeightFactor: 0.35,
|
||||
valueScoreFactor: 0.65,
|
||||
costWeight: 1,
|
||||
balanceWeight: 0,
|
||||
usageWeight: 0,
|
||||
};
|
||||
config.routingFallbackUnitCost = 100;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
config.routingWeights = { ...originalRoutingWeights };
|
||||
config.routingFallbackUnitCost = originalRoutingFallbackUnitCost;
|
||||
invalidateTokenRouterCache();
|
||||
delete process.env.DATA_DIR;
|
||||
});
|
||||
|
||||
it('forces pricing catalog refresh before recomputing exact-route probabilities', async () => {
|
||||
const route = await db.insert(schema.tokenRoutes).values({
|
||||
modelPattern: 'gpt-4o-mini',
|
||||
enabled: true,
|
||||
}).returning().get();
|
||||
|
||||
const siteA = await db.insert(schema.sites).values({
|
||||
name: 'site-a',
|
||||
url: 'https://pricing-a.example.com',
|
||||
platform: 'new-api',
|
||||
status: 'active',
|
||||
}).returning().get();
|
||||
|
||||
const siteB = await db.insert(schema.sites).values({
|
||||
name: 'site-b',
|
||||
url: 'https://pricing-b.example.com',
|
||||
platform: 'new-api',
|
||||
status: 'active',
|
||||
}).returning().get();
|
||||
|
||||
const accountA = await db.insert(schema.accounts).values({
|
||||
siteId: siteA.id,
|
||||
username: 'user-a',
|
||||
accessToken: 'access-a',
|
||||
apiToken: 'api-a',
|
||||
status: 'active',
|
||||
}).returning().get();
|
||||
|
||||
const accountB = await db.insert(schema.accounts).values({
|
||||
siteId: siteB.id,
|
||||
username: 'user-b',
|
||||
accessToken: 'access-b',
|
||||
apiToken: 'api-b',
|
||||
status: 'active',
|
||||
}).returning().get();
|
||||
|
||||
await db.insert(schema.routeChannels).values([
|
||||
{
|
||||
routeId: route.id,
|
||||
accountId: accountA.id,
|
||||
tokenId: null,
|
||||
priority: 0,
|
||||
weight: 10,
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
routeId: route.id,
|
||||
accountId: accountB.id,
|
||||
tokenId: null,
|
||||
priority: 0,
|
||||
weight: 10,
|
||||
enabled: true,
|
||||
},
|
||||
]).run();
|
||||
|
||||
await fetchModelPricingCatalog({
|
||||
site: {
|
||||
id: siteA.id,
|
||||
url: siteA.url,
|
||||
platform: siteA.platform,
|
||||
apiKey: siteA.apiKey,
|
||||
},
|
||||
account: {
|
||||
id: accountA.id,
|
||||
accessToken: accountA.accessToken,
|
||||
apiToken: accountA.apiToken,
|
||||
},
|
||||
modelName: 'gpt-4o-mini',
|
||||
});
|
||||
await fetchModelPricingCatalog({
|
||||
site: {
|
||||
id: siteB.id,
|
||||
url: siteB.url,
|
||||
platform: siteB.platform,
|
||||
apiKey: siteB.apiKey,
|
||||
},
|
||||
account: {
|
||||
id: accountB.id,
|
||||
accessToken: accountB.accessToken,
|
||||
apiToken: accountB.apiToken,
|
||||
},
|
||||
modelName: 'gpt-4o-mini',
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
|
||||
pricingPhase = 'new';
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/routes/decision/batch',
|
||||
payload: {
|
||||
models: ['gpt-4o-mini'],
|
||||
refreshPricingCatalog: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const body = response.json() as {
|
||||
success: boolean;
|
||||
decisions: Record<string, {
|
||||
matched: boolean;
|
||||
candidates: Array<{ siteName: string; probability: number; reason: string }>;
|
||||
}>;
|
||||
};
|
||||
expect(body.success).toBe(true);
|
||||
|
||||
const decision = body.decisions['gpt-4o-mini'];
|
||||
expect(decision?.matched).toBe(true);
|
||||
|
||||
const candidateA = decision?.candidates.find((candidate) => candidate.siteName === 'site-a');
|
||||
const candidateB = decision?.candidates.find((candidate) => candidate.siteName === 'site-b');
|
||||
|
||||
expect(candidateA).toBeTruthy();
|
||||
expect(candidateB).toBeTruthy();
|
||||
expect(candidateB?.probability || 0).toBeGreaterThan(candidateA?.probability || 0);
|
||||
expect(candidateA?.reason || '').toContain('成本=目录');
|
||||
expect(candidateB?.reason || '').toContain('成本=目录');
|
||||
expect(fetchMock).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,179 @@
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import { describe, expect, it, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { mkdtempSync } from 'node:fs';
|
||||
|
||||
type DbModule = typeof import('../../db/index.js');
|
||||
type TokenRouterModule = typeof import('../../services/tokenRouter.js');
|
||||
|
||||
describe('route decision snapshots', () => {
|
||||
let app: FastifyInstance;
|
||||
let db: DbModule['db'];
|
||||
let schema: DbModule['schema'];
|
||||
let invalidateTokenRouterCache: TokenRouterModule['invalidateTokenRouterCache'];
|
||||
let dataDir = '';
|
||||
|
||||
beforeAll(async () => {
|
||||
dataDir = mkdtempSync(join(tmpdir(), 'metapi-route-decision-snapshot-'));
|
||||
process.env.DATA_DIR = dataDir;
|
||||
|
||||
await import('../../db/migrate.js');
|
||||
const dbModule = await import('../../db/index.js');
|
||||
const routesModule = await import('./tokens.js');
|
||||
const tokenRouterModule = await import('../../services/tokenRouter.js');
|
||||
db = dbModule.db;
|
||||
schema = dbModule.schema;
|
||||
invalidateTokenRouterCache = tokenRouterModule.invalidateTokenRouterCache;
|
||||
|
||||
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.accountTokens).run();
|
||||
await db.delete(schema.accounts).run();
|
||||
await db.delete(schema.sites).run();
|
||||
await db.delete(schema.settings).run();
|
||||
invalidateTokenRouterCache();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
invalidateTokenRouterCache();
|
||||
delete process.env.DATA_DIR;
|
||||
});
|
||||
|
||||
it('persists exact-route decision snapshots and exposes them from getRoutes', async () => {
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'snapshot-site',
|
||||
url: 'https://snapshot-site.example.com',
|
||||
platform: 'new-api',
|
||||
status: 'active',
|
||||
}).returning().get();
|
||||
|
||||
const account = await db.insert(schema.accounts).values({
|
||||
siteId: site.id,
|
||||
username: 'snapshot-user',
|
||||
accessToken: 'snapshot-access',
|
||||
apiToken: 'snapshot-api',
|
||||
status: 'active',
|
||||
}).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: account.id,
|
||||
tokenId: null,
|
||||
priority: 0,
|
||||
weight: 10,
|
||||
enabled: true,
|
||||
}).run();
|
||||
|
||||
const refreshResponse = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/routes/decision/batch',
|
||||
payload: {
|
||||
models: ['gpt-4o-mini'],
|
||||
persistSnapshots: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(refreshResponse.statusCode).toBe(200);
|
||||
|
||||
const routesResponse = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/routes',
|
||||
});
|
||||
|
||||
expect(routesResponse.statusCode).toBe(200);
|
||||
const body = routesResponse.json() as Array<{
|
||||
id: number;
|
||||
decisionSnapshot?: { matched: boolean; candidates: Array<unknown> } | null;
|
||||
decisionRefreshedAt?: string | null;
|
||||
}>;
|
||||
const refreshedRoute = body.find((item) => item.id === route.id);
|
||||
|
||||
expect(refreshedRoute?.decisionSnapshot?.matched).toBe(true);
|
||||
expect(Array.isArray(refreshedRoute?.decisionSnapshot?.candidates)).toBe(true);
|
||||
expect(refreshedRoute?.decisionSnapshot?.candidates.length).toBeGreaterThan(0);
|
||||
expect(typeof refreshedRoute?.decisionRefreshedAt).toBe('string');
|
||||
});
|
||||
|
||||
it('persists wildcard route-wide decision snapshots and exposes them from getRoutes', async () => {
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'wildcard-snapshot-site',
|
||||
url: 'https://wildcard-snapshot-site.example.com',
|
||||
platform: 'new-api',
|
||||
status: 'active',
|
||||
}).returning().get();
|
||||
|
||||
const account = await db.insert(schema.accounts).values({
|
||||
siteId: site.id,
|
||||
username: 'wildcard-snapshot-user',
|
||||
accessToken: 'wildcard-snapshot-access',
|
||||
apiToken: 'wildcard-snapshot-api',
|
||||
status: 'active',
|
||||
}).returning().get();
|
||||
|
||||
const route = await db.insert(schema.tokenRoutes).values({
|
||||
modelPattern: 're:^claude-(opus|sonnet)-4-6$',
|
||||
enabled: true,
|
||||
}).returning().get();
|
||||
|
||||
await db.insert(schema.routeChannels).values([
|
||||
{
|
||||
routeId: route.id,
|
||||
accountId: account.id,
|
||||
tokenId: null,
|
||||
sourceModel: 'claude-opus-4-6',
|
||||
priority: 0,
|
||||
weight: 10,
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
routeId: route.id,
|
||||
accountId: account.id,
|
||||
tokenId: null,
|
||||
sourceModel: 'claude-sonnet-4-6',
|
||||
priority: 0,
|
||||
weight: 10,
|
||||
enabled: true,
|
||||
},
|
||||
]).run();
|
||||
|
||||
const refreshResponse = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/routes/decision/route-wide/batch',
|
||||
payload: {
|
||||
routeIds: [route.id],
|
||||
persistSnapshots: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(refreshResponse.statusCode).toBe(200);
|
||||
|
||||
const routesResponse = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/routes',
|
||||
});
|
||||
|
||||
expect(routesResponse.statusCode).toBe(200);
|
||||
const body = routesResponse.json() as Array<{
|
||||
id: number;
|
||||
decisionSnapshot?: { matched: boolean; routeId?: number } | null;
|
||||
decisionRefreshedAt?: string | null;
|
||||
}>;
|
||||
const refreshedRoute = body.find((item) => item.id === route.id);
|
||||
|
||||
expect(refreshedRoute?.decisionSnapshot?.matched).toBe(true);
|
||||
expect(refreshedRoute?.decisionSnapshot?.routeId).toBe(route.id);
|
||||
expect(typeof refreshedRoute?.decisionRefreshedAt).toBe('string');
|
||||
});
|
||||
});
|
||||
+178
-91
@@ -1,9 +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 { 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();
|
||||
@@ -198,20 +204,26 @@ type BatchChannelPriorityUpdate = {
|
||||
priority: number;
|
||||
};
|
||||
|
||||
type BatchRouteDecisionModels = {
|
||||
models: string[];
|
||||
};
|
||||
|
||||
type BatchRouteDecisionRouteModels = {
|
||||
items: Array<{
|
||||
routeId: number;
|
||||
model: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
type BatchRouteWideDecisionRouteIds = {
|
||||
routeIds: 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;
|
||||
};
|
||||
|
||||
function parseBatchChannelUpdates(input: unknown): { ok: true; updates: BatchChannelPriorityUpdate[] } | { ok: false; message: string } {
|
||||
if (!input || typeof input !== 'object') {
|
||||
@@ -252,7 +264,9 @@ function parseBatchChannelUpdates(input: unknown): { ok: true; updates: BatchCha
|
||||
return { ok: true, updates: normalized };
|
||||
}
|
||||
|
||||
function parseBatchRouteDecisionModels(input: unknown): { ok: true; models: string[] } | { 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: '请求体必须是对象' };
|
||||
}
|
||||
@@ -277,12 +291,17 @@ function parseBatchRouteDecisionModels(input: unknown): { ok: true; models: stri
|
||||
return { ok: false, message: 'models 中没有有效模型名称' };
|
||||
}
|
||||
|
||||
return { ok: true, models: normalized };
|
||||
}
|
||||
|
||||
function parseBatchRouteDecisionRouteModels(
|
||||
input: unknown,
|
||||
): { ok: true; items: Array<{ routeId: number; model: string }> } | { 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: '请求体必须是对象' };
|
||||
}
|
||||
@@ -316,12 +335,17 @@ function parseBatchRouteDecisionRouteModels(
|
||||
return { ok: false, message: 'items 中没有有效 routeId/model' };
|
||||
}
|
||||
|
||||
return { ok: true, items: normalized };
|
||||
}
|
||||
|
||||
function parseBatchRouteWideDecisionRouteIds(
|
||||
input: unknown,
|
||||
): { ok: true; routeIds: number[] } | { 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: '请求体必须是对象' };
|
||||
}
|
||||
@@ -346,14 +370,19 @@ function parseBatchRouteWideDecisionRouteIds(
|
||||
return { ok: false, message: 'routeIds 中没有有效 routeId' };
|
||||
}
|
||||
|
||||
return { ok: true, routeIds: normalized };
|
||||
}
|
||||
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 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)
|
||||
@@ -384,11 +413,13 @@ export async function tokensRoutes(app: FastifyInstance) {
|
||||
});
|
||||
}
|
||||
|
||||
return routes.map((route) => ({
|
||||
...route,
|
||||
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();
|
||||
@@ -400,49 +431,94 @@ export async function tokensRoutes(app: FastifyInstance) {
|
||||
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 });
|
||||
}
|
||||
|
||||
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 };
|
||||
});
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
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 };
|
||||
});
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
return { success: true, decisions };
|
||||
});
|
||||
|
||||
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) => {
|
||||
@@ -492,9 +568,13 @@ export async function tokensRoutes(app: FastifyInstance) {
|
||||
|
||||
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();
|
||||
});
|
||||
@@ -558,9 +638,10 @@ export async function tokensRoutes(app: FastifyInstance) {
|
||||
if (!created) {
|
||||
return reply.code(500).send({ success: false, message: '创建通道失败' });
|
||||
}
|
||||
invalidateTokenRouterCache();
|
||||
return created;
|
||||
});
|
||||
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) => {
|
||||
@@ -586,12 +667,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();
|
||||
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) => {
|
||||
@@ -632,19 +714,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();
|
||||
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);
|
||||
await db.delete(schema.routeChannels).where(eq(schema.routeChannels.id, channelId)).run();
|
||||
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