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:
cita
2026-03-08 12:08:57 +08:00
parent ed05b37626
commit 23922a5965
115 changed files with 18597 additions and 4411 deletions
@@ -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);
});
});
+15
View File
@@ -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 || '重新初始化系统失败',
});
}
});
}
+90 -1
View File
@@ -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,
});
});
});
+17
View File
@@ -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,
};
});
+152
View File
@@ -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',
});
});
});
+145
View File
@@ -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
View File
@@ -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
View File
@@ -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) => {