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) => {
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
function readSource(relativePath: string): string {
|
||||
return readFileSync(new URL(relativePath, import.meta.url), 'utf8');
|
||||
}
|
||||
|
||||
describe('proxy route architecture boundaries', () => {
|
||||
it('keeps shared protocol helpers out of chat route', () => {
|
||||
const source = readSource('./chat.ts');
|
||||
expect(source).not.toContain("from './chatFormats.js'");
|
||||
expect(source).toContain("from '../../transformers/openai/chat/index.js'");
|
||||
expect(source).toContain("from '../../transformers/anthropic/messages/index.js'");
|
||||
});
|
||||
it('keeps anthropic-specific stream orchestration out of chat route', () => {
|
||||
const source = readSource('./chat.ts');
|
||||
expect(source).not.toContain('function syncClaudeStreamStateFromRawEvent(');
|
||||
expect(source).not.toContain('function serializeRawSseEvent(');
|
||||
expect(source).not.toContain('function isClaudeSseEventName(');
|
||||
});
|
||||
|
||||
it('keeps responses protocol assembly out of responses route', () => {
|
||||
const source = readSource('./responses.ts');
|
||||
expect(source).not.toContain('function toResponsesPayload(');
|
||||
expect(source).not.toContain('function createResponsesStreamState(');
|
||||
expect(source).not.toContain('function convertResponsesBodyToOpenAiBody(');
|
||||
});
|
||||
|
||||
it('removes normalizeContentText from upstream endpoint routing', () => {
|
||||
const source = readSource('./upstreamEndpoint.ts');
|
||||
expect(source).not.toContain('function normalizeContentText(');
|
||||
expect(source).not.toContain('normalizeContentText(');
|
||||
});
|
||||
|
||||
it('keeps gemini runtime closure in transformer-owned helpers', () => {
|
||||
const source = readSource('./gemini.ts');
|
||||
expect(source).toContain('serializeAggregateResponse');
|
||||
expect(source).toContain('parseSsePayloads');
|
||||
expect(source).toContain('aggregator.apply(aggregateState, parsed)');
|
||||
});
|
||||
});
|
||||
+30
-110
@@ -9,19 +9,7 @@ import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js';
|
||||
import { resolveProxyUsageWithSelfLogFallback } from '../../services/proxyUsageFallbackService.js';
|
||||
import { mergeProxyUsage, parseProxyUsage } from '../../services/proxyUsageParser.js';
|
||||
import { resolveProxyUrlForSite, withSiteRecordProxyRequestInit } from '../../services/siteProxy.js';
|
||||
import {
|
||||
type DownstreamFormat,
|
||||
createStreamTransformContext,
|
||||
createClaudeDownstreamContext,
|
||||
parseDownstreamChatRequest,
|
||||
pullSseEventsWithDone,
|
||||
normalizeUpstreamStreamEvent,
|
||||
serializeNormalizedStreamEvent,
|
||||
serializeStreamDone,
|
||||
normalizeUpstreamFinalResponse,
|
||||
serializeFinalResponse,
|
||||
buildSyntheticOpenAiChunks,
|
||||
} from './chatFormats.js';
|
||||
import { type DownstreamFormat } from '../../transformers/shared/normalized.js';
|
||||
import {
|
||||
buildMinimalJsonHeadersForCompatibility,
|
||||
buildUpstreamEndpointRequest,
|
||||
@@ -38,19 +26,16 @@ import { composeProxyLogMessage } from './logPathMeta.js';
|
||||
import { executeEndpointFlow, withUpstreamPath } from './endpointFlow.js';
|
||||
import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
|
||||
import { resolveProxyLogBilling } from './proxyBilling.js';
|
||||
import { openAiChatTransformer } from '../../transformers/openai/chat/index.js';
|
||||
import {
|
||||
anthropicMessagesTransformer,
|
||||
isAnthropicRawSseEventName,
|
||||
serializeAnthropicFinalAsStream,
|
||||
serializeAnthropicRawSseEvent,
|
||||
syncAnthropicRawStreamStateFromEvent,
|
||||
} from '../../transformers/anthropic/messages/index.js';
|
||||
|
||||
const MAX_RETRIES = 2;
|
||||
const CLAUDE_SSE_EVENT_NAMES = new Set([
|
||||
'message_start',
|
||||
'content_block_start',
|
||||
'content_block_delta',
|
||||
'content_block_stop',
|
||||
'message_delta',
|
||||
'message_stop',
|
||||
'ping',
|
||||
'error',
|
||||
]);
|
||||
|
||||
function shouldRetryClaudeMessagesWithNormalizedBody(
|
||||
downstreamFormat: DownstreamFormat,
|
||||
endpointPath: string,
|
||||
@@ -71,53 +56,6 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object';
|
||||
}
|
||||
|
||||
function isClaudeSseEventName(value: unknown): value is string {
|
||||
return typeof value === 'string' && CLAUDE_SSE_EVENT_NAMES.has(value);
|
||||
}
|
||||
|
||||
function serializeRawSseEvent(event: string, data: string): string {
|
||||
const dataLines = data.split('\n').map((line) => `data: ${line}`).join('\n');
|
||||
if (event) {
|
||||
return `event: ${event}\n${dataLines}\n\n`;
|
||||
}
|
||||
return `${dataLines}\n\n`;
|
||||
}
|
||||
|
||||
function syncClaudeStreamStateFromRawEvent(
|
||||
eventName: string,
|
||||
parsedPayload: unknown,
|
||||
streamContext: { id: string; model: string },
|
||||
claudeContext: { messageStarted: boolean; contentBlockStarted: boolean; doneSent: boolean },
|
||||
) {
|
||||
if (eventName === 'message_start') {
|
||||
claudeContext.messageStarted = true;
|
||||
if (isRecord(parsedPayload) && isRecord(parsedPayload.message)) {
|
||||
const message = parsedPayload.message;
|
||||
if (typeof message.id === 'string' && message.id.trim().length > 0) {
|
||||
streamContext.id = message.id;
|
||||
}
|
||||
if (typeof message.model === 'string' && message.model.trim().length > 0) {
|
||||
streamContext.model = message.model;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (eventName === 'content_block_start') {
|
||||
claudeContext.contentBlockStarted = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (eventName === 'content_block_stop') {
|
||||
claudeContext.contentBlockStarted = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (eventName === 'message_stop') {
|
||||
claudeContext.doneSent = true;
|
||||
}
|
||||
}
|
||||
|
||||
export async function chatProxyRoute(app: FastifyInstance) {
|
||||
app.post('/v1/chat/completions', async (request: FastifyRequest, reply: FastifyReply) =>
|
||||
handleChatProxyRequest(request, reply, 'openai'));
|
||||
@@ -133,7 +71,10 @@ async function handleChatProxyRequest(
|
||||
reply: FastifyReply,
|
||||
downstreamFormat: DownstreamFormat,
|
||||
) {
|
||||
const parsedRequest = parseDownstreamChatRequest(request.body, downstreamFormat);
|
||||
const downstreamTransformer = downstreamFormat === 'claude'
|
||||
? anthropicMessagesTransformer
|
||||
: openAiChatTransformer;
|
||||
const parsedRequest = downstreamTransformer.transformRequest(request.body);
|
||||
if (parsedRequest.error) {
|
||||
return reply.code(parsedRequest.error.statusCode).send(parsedRequest.error.payload);
|
||||
}
|
||||
@@ -345,8 +286,8 @@ async function handleChatProxyRequest(
|
||||
reply.raw.setHeader('Connection', 'keep-alive');
|
||||
reply.raw.setHeader('X-Accel-Buffering', 'no');
|
||||
|
||||
const streamContext = createStreamTransformContext(modelName);
|
||||
const claudeContext = createClaudeDownstreamContext();
|
||||
const streamContext = downstreamTransformer.createStreamContext(modelName);
|
||||
const claudeContext = anthropicMessagesTransformer.createDownstreamContext();
|
||||
let parsedUsage: ReturnType<typeof parseProxyUsage> = {
|
||||
promptTokens: 0,
|
||||
completionTokens: 0,
|
||||
@@ -363,38 +304,24 @@ async function handleChatProxyRequest(
|
||||
};
|
||||
|
||||
const writeDone = () => {
|
||||
writeLines(serializeStreamDone(downstreamFormat, streamContext, claudeContext));
|
||||
writeLines(downstreamTransformer.serializeDone(streamContext, claudeContext));
|
||||
};
|
||||
|
||||
const emitNormalizedFinalAsStream = (upstreamData: unknown, fallbackText = '') => {
|
||||
const normalizedFinal = normalizeUpstreamFinalResponse(upstreamData, modelName, fallbackText);
|
||||
const normalizedFinal = downstreamTransformer.transformFinalResponse(upstreamData, modelName, fallbackText);
|
||||
streamContext.id = normalizedFinal.id;
|
||||
streamContext.model = normalizedFinal.model;
|
||||
streamContext.created = normalizedFinal.created;
|
||||
|
||||
if (downstreamFormat === 'openai') {
|
||||
const syntheticChunks = buildSyntheticOpenAiChunks(normalizedFinal);
|
||||
const syntheticChunks = openAiChatTransformer.buildSyntheticChunks(normalizedFinal);
|
||||
for (const chunk of syntheticChunks) {
|
||||
reply.raw.write(`data: ${JSON.stringify(chunk)}\n\n`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
writeLines(serializeNormalizedStreamEvent('claude', { role: 'assistant' }, streamContext, claudeContext));
|
||||
|
||||
const combinedText = [normalizedFinal.reasoningContent, normalizedFinal.content]
|
||||
.filter((item) => typeof item === 'string' && item.trim().length > 0)
|
||||
.join('\n\n');
|
||||
|
||||
if (combinedText) {
|
||||
writeLines(serializeNormalizedStreamEvent('claude', {
|
||||
contentDelta: combinedText,
|
||||
}, streamContext, claudeContext));
|
||||
}
|
||||
|
||||
writeLines(serializeNormalizedStreamEvent('claude', {
|
||||
finishReason: normalizedFinal.finishReason,
|
||||
}, streamContext, claudeContext));
|
||||
writeLines(serializeAnthropicFinalAsStream(normalizedFinal, streamContext, claudeContext));
|
||||
};
|
||||
|
||||
const upstreamContentType = (upstream.headers.get('content-type') || '').toLowerCase();
|
||||
@@ -470,7 +397,7 @@ async function handleChatProxyRequest(
|
||||
let shouldTerminateEarly = false;
|
||||
|
||||
const consumeSseBuffer = (incoming: string): string => {
|
||||
const pulled = pullSseEventsWithDone(incoming);
|
||||
const pulled = downstreamTransformer.pullSseEvents(incoming);
|
||||
for (const eventBlock of pulled.events) {
|
||||
if (eventBlock.data === '[DONE]') {
|
||||
writeDone();
|
||||
@@ -487,23 +414,22 @@ async function handleChatProxyRequest(
|
||||
|
||||
if (parsedPayload && typeof parsedPayload === 'object') {
|
||||
parsedUsage = mergeProxyUsage(parsedUsage, parseProxyUsage(parsedPayload));
|
||||
|
||||
if (downstreamFormat === 'claude') {
|
||||
const payloadType = (isRecord(parsedPayload) && typeof parsedPayload.type === 'string')
|
||||
? parsedPayload.type
|
||||
: '';
|
||||
const claudeEventName = isClaudeSseEventName(eventBlock.event)
|
||||
const claudeEventName = isAnthropicRawSseEventName(eventBlock.event)
|
||||
? eventBlock.event
|
||||
: (isClaudeSseEventName(payloadType) ? payloadType : '');
|
||||
: (isAnthropicRawSseEventName(payloadType) ? payloadType : '');
|
||||
|
||||
if (claudeEventName) {
|
||||
syncClaudeStreamStateFromRawEvent(
|
||||
syncAnthropicRawStreamStateFromEvent(
|
||||
claudeEventName,
|
||||
parsedPayload,
|
||||
streamContext,
|
||||
claudeContext,
|
||||
);
|
||||
reply.raw.write(serializeRawSseEvent(claudeEventName, eventBlock.data));
|
||||
reply.raw.write(serializeAnthropicRawSseEvent(claudeEventName, eventBlock.data));
|
||||
if (claudeContext.doneSent) {
|
||||
shouldTerminateEarly = true;
|
||||
break;
|
||||
@@ -511,19 +437,13 @@ async function handleChatProxyRequest(
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedEvent = normalizeUpstreamStreamEvent(parsedPayload, streamContext, modelName);
|
||||
writeLines(serializeNormalizedStreamEvent(
|
||||
downstreamFormat,
|
||||
normalizedEvent,
|
||||
streamContext,
|
||||
claudeContext,
|
||||
));
|
||||
const normalizedEvent = downstreamTransformer.transformStreamEvent(parsedPayload, streamContext, modelName);
|
||||
writeLines(downstreamTransformer.serializeStreamEvent(normalizedEvent, streamContext, claudeContext));
|
||||
if (downstreamFormat === 'claude' && claudeContext.doneSent) {
|
||||
shouldTerminateEarly = true;
|
||||
break;
|
||||
}
|
||||
if (downstreamFormat === 'openai' && streamContext.doneSent) {
|
||||
if (streamContext.doneSent) {
|
||||
shouldTerminateEarly = true;
|
||||
break;
|
||||
}
|
||||
@@ -533,7 +453,7 @@ async function handleChatProxyRequest(
|
||||
if (downstreamFormat === 'openai') {
|
||||
reply.raw.write(`data: ${eventBlock.data}\n\n`);
|
||||
} else {
|
||||
writeLines(serializeNormalizedStreamEvent('claude', {
|
||||
writeLines(anthropicMessagesTransformer.serializeStreamEvent({
|
||||
contentDelta: eventBlock.data,
|
||||
}, streamContext, claudeContext));
|
||||
if (claudeContext.doneSent) {
|
||||
@@ -628,8 +548,8 @@ async function handleChatProxyRequest(
|
||||
|
||||
const latency = Date.now() - startTime;
|
||||
const parsedUsage = parseProxyUsage(upstreamData);
|
||||
const normalizedFinal = normalizeUpstreamFinalResponse(upstreamData, modelName, rawText);
|
||||
const downstreamResponse = serializeFinalResponse(downstreamFormat, normalizedFinal, parsedUsage);
|
||||
const normalizedFinal = downstreamTransformer.transformFinalResponse(upstreamData, modelName, rawText);
|
||||
const downstreamResponse = downstreamTransformer.serializeFinalResponse(normalizedFinal, parsedUsage);
|
||||
|
||||
const resolvedUsage = await resolveProxyUsageWithSelfLogFallback({
|
||||
site: selected.site,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,430 @@
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const fetchMock = vi.fn();
|
||||
const selectChannelMock = vi.fn();
|
||||
const selectNextChannelMock = vi.fn();
|
||||
const invalidateTokenRouterCacheMock = vi.fn();
|
||||
const authorizeDownstreamTokenMock = vi.fn();
|
||||
const consumeManagedKeyRequestMock = vi.fn();
|
||||
|
||||
vi.mock('undici', () => ({
|
||||
fetch: (...args: unknown[]) => fetchMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/tokenRouter.js', () => ({
|
||||
tokenRouter: {
|
||||
selectChannel: (...args: unknown[]) => selectChannelMock(...args),
|
||||
selectNextChannel: (...args: unknown[]) => selectNextChannelMock(...args),
|
||||
explainSelection: vi.fn(async () => ({ selectedChannelId: 11 })),
|
||||
},
|
||||
invalidateTokenRouterCache: (...args: unknown[]) => invalidateTokenRouterCacheMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/downstreamApiKeyService.js', () => ({
|
||||
authorizeDownstreamToken: (...args: unknown[]) => authorizeDownstreamTokenMock(...args),
|
||||
consumeManagedKeyRequest: (...args: unknown[]) => consumeManagedKeyRequestMock(...args),
|
||||
}));
|
||||
|
||||
describe('gemini native proxy routes', () => {
|
||||
let app: FastifyInstance;
|
||||
|
||||
beforeAll(async () => {
|
||||
const { proxyRoutes } = await import('./router.js');
|
||||
app = Fastify();
|
||||
await app.register(proxyRoutes);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock.mockReset();
|
||||
selectChannelMock.mockReset();
|
||||
selectNextChannelMock.mockReset();
|
||||
authorizeDownstreamTokenMock.mockReset();
|
||||
consumeManagedKeyRequestMock.mockReset();
|
||||
|
||||
authorizeDownstreamTokenMock.mockResolvedValue({
|
||||
ok: true,
|
||||
source: 'global',
|
||||
token: 'sk-managed-gemini',
|
||||
policy: {},
|
||||
});
|
||||
|
||||
selectChannelMock.mockReturnValue({
|
||||
channel: { id: 11, routeId: 22 },
|
||||
site: { id: 44, name: 'gemini-site', url: 'https://generativelanguage.googleapis.com', platform: 'gemini' },
|
||||
account: { id: 33, username: 'demo-user' },
|
||||
tokenName: 'default',
|
||||
tokenValue: 'gemini-key',
|
||||
actualModel: 'gemini-2.5-flash',
|
||||
});
|
||||
selectNextChannelMock.mockReturnValue(null);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('accepts x-goog-api-key on /v1beta/models and returns gemini model list shape', async () => {
|
||||
fetchMock.mockResolvedValue(new Response(JSON.stringify({
|
||||
models: [
|
||||
{ name: 'models/gemini-2.5-flash', displayName: 'Gemini 2.5 Flash' },
|
||||
],
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}));
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/v1beta/models',
|
||||
headers: {
|
||||
'x-goog-api-key': 'sk-managed-gemini',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual({
|
||||
models: [
|
||||
{
|
||||
name: 'models/gemini-2.5-flash',
|
||||
displayName: 'Gemini 2.5 Flash',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('forwards native generateContent requests through the gemini route group', async () => {
|
||||
fetchMock.mockResolvedValue(new Response(JSON.stringify({
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
parts: [{ text: 'hello from gemini' }],
|
||||
role: 'model',
|
||||
},
|
||||
finishReason: 'STOP',
|
||||
},
|
||||
],
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}));
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/v1beta/models/gemini-2.5-flash:generateContent',
|
||||
headers: {
|
||||
'x-goog-api-key': 'sk-managed-gemini',
|
||||
},
|
||||
payload: {
|
||||
contents: [
|
||||
{
|
||||
role: 'user',
|
||||
parts: [{ text: 'hello' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const [targetUrl, requestInit] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
expect(targetUrl).toContain('/v1beta/models/gemini-2.5-flash:generateContent');
|
||||
expect(targetUrl).toContain('key=gemini-key');
|
||||
expect(JSON.parse(String(requestInit.body))).toEqual({
|
||||
contents: [
|
||||
{
|
||||
role: 'user',
|
||||
parts: [{ text: 'hello' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(response.json()).toEqual({
|
||||
responseId: '',
|
||||
modelVersion: '',
|
||||
candidates: [
|
||||
{
|
||||
index: 0,
|
||||
content: {
|
||||
parts: [{ text: 'hello from gemini' }],
|
||||
role: 'model',
|
||||
},
|
||||
finishReason: 'STOP',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves structured Gemini-native fields instead of narrowing them to a bare passthrough shell', async () => {
|
||||
fetchMock.mockResolvedValue(new Response(JSON.stringify({
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
parts: [{ text: 'ok' }],
|
||||
role: 'model',
|
||||
},
|
||||
finishReason: 'STOP',
|
||||
},
|
||||
],
|
||||
usageMetadata: {
|
||||
promptTokenCount: 10,
|
||||
candidatesTokenCount: 5,
|
||||
totalTokenCount: 15,
|
||||
cachedContentTokenCount: 2,
|
||||
thoughtsTokenCount: 1,
|
||||
},
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}));
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/v1beta/models/gemini-2.5-flash:generateContent',
|
||||
headers: {
|
||||
'x-goog-api-key': 'sk-managed-gemini',
|
||||
},
|
||||
payload: {
|
||||
systemInstruction: {
|
||||
parts: [{ text: 'be concise' }],
|
||||
},
|
||||
cachedContent: 'cached/abc123',
|
||||
safetySettings: [{ category: 'HARM_CATEGORY_HARASSMENT', threshold: 'BLOCK_NONE' }],
|
||||
generationConfig: {
|
||||
responseModalities: ['TEXT'],
|
||||
responseMimeType: 'application/json',
|
||||
temperature: 0.2,
|
||||
topP: 0.8,
|
||||
topK: 20,
|
||||
maxOutputTokens: 256,
|
||||
thinkingConfig: { thinkingBudget: 512 },
|
||||
imageConfig: { aspectRatio: '1:1' },
|
||||
},
|
||||
tools: [
|
||||
{
|
||||
functionDeclarations: [
|
||||
{
|
||||
name: 'lookup_weather',
|
||||
description: 'look up weather',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
contents: [
|
||||
{
|
||||
role: 'user',
|
||||
parts: [{ text: 'weather in shanghai' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const [, requestInit] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
expect(JSON.parse(String(requestInit.body))).toEqual({
|
||||
systemInstruction: {
|
||||
parts: [{ text: 'be concise' }],
|
||||
},
|
||||
cachedContent: 'cached/abc123',
|
||||
safetySettings: [{ category: 'HARM_CATEGORY_HARASSMENT', threshold: 'BLOCK_NONE' }],
|
||||
generationConfig: {
|
||||
responseModalities: ['TEXT'],
|
||||
responseMimeType: 'application/json',
|
||||
temperature: 0.2,
|
||||
topP: 0.8,
|
||||
topK: 20,
|
||||
maxOutputTokens: 256,
|
||||
thinkingConfig: { thinkingBudget: 512 },
|
||||
imageConfig: { aspectRatio: '1:1' },
|
||||
},
|
||||
tools: [
|
||||
{
|
||||
functionDeclarations: [
|
||||
{
|
||||
name: 'lookup_weather',
|
||||
description: 'look up weather',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
contents: [
|
||||
{
|
||||
role: 'user',
|
||||
parts: [{ text: 'weather in shanghai' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('aggregates non-sse json-array streaming payloads into a final Gemini response', async () => {
|
||||
fetchMock.mockResolvedValue(new Response(JSON.stringify([
|
||||
{
|
||||
candidates: [
|
||||
{
|
||||
content: { role: 'model', parts: [{ text: 'first' }] },
|
||||
groundingMetadata: { source: 'web' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
candidates: [
|
||||
{
|
||||
content: { role: 'model', parts: [{ text: 'second', thoughtSignature: 'sig-1' }] },
|
||||
citationMetadata: { citations: [{ startIndex: 0, endIndex: 5 }] },
|
||||
},
|
||||
],
|
||||
usageMetadata: {
|
||||
promptTokenCount: 11,
|
||||
candidatesTokenCount: 6,
|
||||
totalTokenCount: 17,
|
||||
cachedContentTokenCount: 2,
|
||||
thoughtsTokenCount: 3,
|
||||
},
|
||||
},
|
||||
]), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}));
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/v1beta/models/gemini-2.5-flash:streamGenerateContent',
|
||||
headers: {
|
||||
'x-goog-api-key': 'sk-managed-gemini',
|
||||
},
|
||||
payload: {
|
||||
contents: [
|
||||
{
|
||||
role: 'user',
|
||||
parts: [{ text: 'hello' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual({
|
||||
responseId: '',
|
||||
modelVersion: '',
|
||||
candidates: [
|
||||
{
|
||||
index: 0,
|
||||
finishReason: 'STOP',
|
||||
content: {
|
||||
role: 'model',
|
||||
parts: [
|
||||
{ text: 'first' },
|
||||
{ text: 'second', thoughtSignature: 'sig-1' },
|
||||
],
|
||||
},
|
||||
groundingMetadata: { source: 'web' },
|
||||
citationMetadata: { citations: [{ startIndex: 0, endIndex: 5 }] },
|
||||
},
|
||||
],
|
||||
usageMetadata: {
|
||||
promptTokenCount: 11,
|
||||
candidatesTokenCount: 6,
|
||||
totalTokenCount: 17,
|
||||
cachedContentTokenCount: 2,
|
||||
thoughtsTokenCount: 3,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('derives gemini-3 thinkingLevel from OpenAI-style reasoning inputs in the runtime request path', async () => {
|
||||
selectChannelMock.mockReturnValue({
|
||||
channel: { id: 11, routeId: 22 },
|
||||
site: { id: 44, name: 'gemini-site', url: 'https://generativelanguage.googleapis.com', platform: 'gemini' },
|
||||
account: { id: 33, username: 'demo-user' },
|
||||
tokenName: 'default',
|
||||
tokenValue: 'gemini-key',
|
||||
actualModel: 'gemini-3-pro',
|
||||
});
|
||||
|
||||
fetchMock.mockResolvedValue(new Response(JSON.stringify({
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
parts: [{ text: 'ok' }],
|
||||
role: 'model',
|
||||
},
|
||||
finishReason: 'STOP',
|
||||
},
|
||||
],
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}));
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/v1beta/models/gemini-3-pro:generateContent',
|
||||
headers: {
|
||||
'x-goog-api-key': 'sk-managed-gemini',
|
||||
},
|
||||
payload: {
|
||||
reasoning_effort: 'high',
|
||||
contents: [
|
||||
{
|
||||
role: 'user',
|
||||
parts: [{ text: 'hello' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const [, requestInit] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
expect(JSON.parse(String(requestInit.body))).toEqual({
|
||||
contents: [
|
||||
{
|
||||
role: 'user',
|
||||
parts: [{ text: 'hello' }],
|
||||
},
|
||||
],
|
||||
generationConfig: {
|
||||
thinkingConfig: { thinkingLevel: 'high' },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('aggregates SSE payloads through the transformer instead of raw passthrough', async () => {
|
||||
const encoder = new TextEncoder();
|
||||
const upstreamBody = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(encoder.encode('data: {"responseId":"resp-sse","modelVersion":"gemini-2.5-flash","candidates":[{"content":{"role":"model","parts":[{"text":"first"}]},"groundingMetadata":{"source":"web"}}]}\r\n\r\n'));
|
||||
controller.enqueue(encoder.encode('data: {"candidates":[{"content":{"role":"model","parts":[{"text":"second","thoughtSignature":"sig-1"}]},"citationMetadata":{"citations":[{"startIndex":0,"endIndex":5}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":11,"candidatesTokenCount":6,"totalTokenCount":17,"cachedContentTokenCount":2,"thoughtsTokenCount":3}}\r\n\r\n'));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
|
||||
fetchMock.mockResolvedValue(new Response(upstreamBody, {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'text/event-stream; charset=utf-8' },
|
||||
}));
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse',
|
||||
headers: {
|
||||
'x-goog-api-key': 'sk-managed-gemini',
|
||||
},
|
||||
payload: {
|
||||
contents: [
|
||||
{
|
||||
role: 'user',
|
||||
parts: [{ text: 'hello' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.headers['content-type']).toContain('text/event-stream');
|
||||
expect(response.body).toContain('data: {"responseId":"resp-sse"');
|
||||
expect(response.body).toContain('"groundingMetadata":{"source":"web"}');
|
||||
expect(response.body).toContain('"citationMetadata":{"citations":[{"startIndex":0,"endIndex":5}]}');
|
||||
expect(response.body).toContain('"cachedContentTokenCount":2');
|
||||
expect(response.body).toContain('"thoughtsTokenCount":3');
|
||||
expect(response.body).toContain('"parts":[{"text":"first"},{"text":"second","thoughtSignature":"sig-1"}]');
|
||||
expect(response.body).not.toContain('\r\n\r\n');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,196 @@
|
||||
import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { TextDecoder } from 'node:util';
|
||||
import { fetch } from 'undici';
|
||||
import { tokenRouter } from '../../services/tokenRouter.js';
|
||||
import { getDownstreamRoutingPolicy } from './downstreamPolicy.js';
|
||||
import {
|
||||
geminiGenerateContentTransformer,
|
||||
} from '../../transformers/gemini/generate-content/index.js';
|
||||
|
||||
const GEMINI_MODEL_PROBES = [
|
||||
'gemini-2.5-flash',
|
||||
'gemini-2.0-flash',
|
||||
'gemini-1.5-flash',
|
||||
'gemini-pro',
|
||||
];
|
||||
|
||||
async function selectGeminiChannel(request: FastifyRequest) {
|
||||
const policy = getDownstreamRoutingPolicy(request);
|
||||
for (const candidate of GEMINI_MODEL_PROBES) {
|
||||
const selected = await tokenRouter.selectChannel(candidate, policy);
|
||||
if (selected) return selected;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveGeminiApiVersion(request: FastifyRequest): string {
|
||||
const params = request.params as { geminiApiVersion?: string } | undefined;
|
||||
return (params?.geminiApiVersion || 'v1beta').trim() || 'v1beta';
|
||||
}
|
||||
|
||||
function getRawRequestUrl(request: FastifyRequest): string {
|
||||
return request.raw.url || request.url || '';
|
||||
}
|
||||
|
||||
function extractGeminiModelActionPath(request: FastifyRequest, apiVersion: string): string {
|
||||
const rawUrl = getRawRequestUrl(request);
|
||||
const withoutQuery = rawUrl.split('?')[0] || rawUrl;
|
||||
const normalizedVersion = apiVersion.replace(/^\/+/, '');
|
||||
const geminiPrefix = `/gemini/${normalizedVersion}/`;
|
||||
const aliasPrefix = `/${normalizedVersion}/`;
|
||||
if (withoutQuery.startsWith(geminiPrefix)) {
|
||||
return withoutQuery.slice(geminiPrefix.length);
|
||||
}
|
||||
if (withoutQuery.startsWith(aliasPrefix)) {
|
||||
return withoutQuery.slice(aliasPrefix.length);
|
||||
}
|
||||
return withoutQuery.replace(/^\/+/, '');
|
||||
}
|
||||
|
||||
export async function geminiProxyRoute(app: FastifyInstance) {
|
||||
const listModels = async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const selected = await selectGeminiChannel(request);
|
||||
if (!selected) {
|
||||
return reply.code(503).send({
|
||||
error: { message: 'No available channels for Gemini models', type: 'server_error' },
|
||||
});
|
||||
}
|
||||
|
||||
const apiVersion = resolveGeminiApiVersion(request);
|
||||
const upstream = await fetch(
|
||||
geminiGenerateContentTransformer.resolveModelsUrl(selected.site.url, apiVersion, selected.tokenValue),
|
||||
{ method: 'GET' },
|
||||
);
|
||||
const text = await upstream.text();
|
||||
try {
|
||||
return reply.code(upstream.status).send(JSON.parse(text));
|
||||
} catch {
|
||||
return reply.code(upstream.status).type(upstream.headers.get('content-type') || 'application/json').send(text);
|
||||
}
|
||||
};
|
||||
|
||||
const generateContent = async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const apiVersion = resolveGeminiApiVersion(request);
|
||||
const modelActionPath = extractGeminiModelActionPath(request, apiVersion);
|
||||
const requestedModel = modelActionPath.replace(/^models\//, '').split(':')[0].trim();
|
||||
if (!requestedModel) {
|
||||
return reply.code(400).send({
|
||||
error: { message: 'Gemini model path is required', type: 'invalid_request_error' },
|
||||
});
|
||||
}
|
||||
|
||||
const policy = getDownstreamRoutingPolicy(request);
|
||||
const selected = await tokenRouter.selectChannel(requestedModel, policy);
|
||||
if (!selected) {
|
||||
return reply.code(503).send({
|
||||
error: { message: 'No available channels for this model', type: 'server_error' },
|
||||
});
|
||||
}
|
||||
|
||||
const body = geminiGenerateContentTransformer.inbound.normalizeRequest(
|
||||
request.body || {},
|
||||
selected.actualModel || requestedModel,
|
||||
);
|
||||
|
||||
const actualModelAction = modelActionPath.replace(
|
||||
/^models\/[^:]+/,
|
||||
`models/${selected.actualModel || requestedModel}`,
|
||||
);
|
||||
const query = new URLSearchParams(request.query as Record<string, string>).toString();
|
||||
const upstream = await fetch(
|
||||
geminiGenerateContentTransformer.resolveActionUrl(
|
||||
selected.site.url,
|
||||
apiVersion,
|
||||
actualModelAction,
|
||||
selected.tokenValue,
|
||||
query,
|
||||
),
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
);
|
||||
|
||||
const contentType = upstream.headers.get('content-type');
|
||||
if (geminiGenerateContentTransformer.stream.isSseContentType(contentType)) {
|
||||
reply.hijack();
|
||||
reply.raw.statusCode = upstream.status;
|
||||
reply.raw.setHeader('Content-Type', contentType || 'text/event-stream');
|
||||
const reader = upstream.body?.getReader();
|
||||
if (!reader) {
|
||||
reply.raw.end();
|
||||
return;
|
||||
}
|
||||
const aggregateState = geminiGenerateContentTransformer.stream.createAggregateState();
|
||||
const decoder = new TextDecoder();
|
||||
let rest = '';
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
if (!value) continue;
|
||||
const chunkText = decoder.decode(value, { stream: true });
|
||||
const parsed = geminiGenerateContentTransformer.stream.parseSsePayloads(rest + chunkText);
|
||||
rest = parsed.rest;
|
||||
for (const event of parsed.events) {
|
||||
geminiGenerateContentTransformer.stream.applyAggregate(aggregateState, event);
|
||||
reply.raw.write(
|
||||
geminiGenerateContentTransformer.stream.serializeSsePayload(
|
||||
geminiGenerateContentTransformer.outbound.serializeAggregateResponse(aggregateState),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
const tail = decoder.decode();
|
||||
if (tail) {
|
||||
const parsed = geminiGenerateContentTransformer.stream.parseSsePayloads(rest + tail);
|
||||
for (const event of parsed.events) {
|
||||
geminiGenerateContentTransformer.stream.applyAggregate(aggregateState, event);
|
||||
reply.raw.write(
|
||||
geminiGenerateContentTransformer.stream.serializeSsePayload(
|
||||
geminiGenerateContentTransformer.outbound.serializeAggregateResponse(aggregateState),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
reply.raw.end();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const text = await upstream.text();
|
||||
try {
|
||||
const parsed = JSON.parse(text);
|
||||
if (!upstream.ok) {
|
||||
return reply.code(upstream.status).send(parsed);
|
||||
}
|
||||
|
||||
const aggregateState = geminiGenerateContentTransformer.aggregator.createState();
|
||||
if (Array.isArray(parsed)) {
|
||||
for (const chunk of geminiGenerateContentTransformer.stream.parseJsonArrayPayload(parsed)) {
|
||||
geminiGenerateContentTransformer.aggregator.apply(aggregateState, chunk);
|
||||
}
|
||||
return reply.code(upstream.status).send(
|
||||
geminiGenerateContentTransformer.outbound.serializeAggregateResponse(aggregateState),
|
||||
);
|
||||
}
|
||||
|
||||
geminiGenerateContentTransformer.aggregator.apply(aggregateState, parsed);
|
||||
return reply.code(upstream.status).send(
|
||||
geminiGenerateContentTransformer.outbound.serializeAggregateResponse(aggregateState),
|
||||
);
|
||||
} catch {
|
||||
return reply.code(upstream.status).type(contentType || 'application/json').send(text);
|
||||
}
|
||||
};
|
||||
|
||||
app.get('/v1beta/models', listModels);
|
||||
app.get('/gemini/:geminiApiVersion/models', listModels);
|
||||
app.post('/v1beta/models/*', generateContent);
|
||||
app.post('/gemini/:geminiApiVersion/models/*', generateContent);
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const fetchMock = vi.fn();
|
||||
const selectChannelMock = vi.fn();
|
||||
const selectNextChannelMock = vi.fn();
|
||||
const recordSuccessMock = vi.fn();
|
||||
const recordFailureMock = vi.fn();
|
||||
const refreshModelsAndRebuildRoutesMock = vi.fn();
|
||||
const reportProxyAllFailedMock = vi.fn();
|
||||
const reportTokenExpiredMock = vi.fn();
|
||||
const estimateProxyCostMock = vi.fn(async () => 0);
|
||||
const dbInsertMock = vi.fn((_arg?: any) => ({
|
||||
values: () => ({
|
||||
run: () => undefined,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('undici', async () => {
|
||||
const actual = await vi.importActual<typeof import('undici')>('undici');
|
||||
return {
|
||||
...actual,
|
||||
fetch: (...args: unknown[]) => fetchMock(...args),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../services/tokenRouter.js', () => ({
|
||||
tokenRouter: {
|
||||
selectChannel: (...args: unknown[]) => selectChannelMock(...args),
|
||||
selectNextChannel: (...args: unknown[]) => selectNextChannelMock(...args),
|
||||
recordSuccess: (...args: unknown[]) => recordSuccessMock(...args),
|
||||
recordFailure: (...args: unknown[]) => recordFailureMock(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../services/modelService.js', () => ({
|
||||
refreshModelsAndRebuildRoutes: (...args: unknown[]) => refreshModelsAndRebuildRoutesMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/alertService.js', () => ({
|
||||
reportProxyAllFailed: (...args: unknown[]) => reportProxyAllFailedMock(...args),
|
||||
reportTokenExpired: (...args: unknown[]) => reportTokenExpiredMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/alertRules.js', () => ({
|
||||
isTokenExpiredError: () => false,
|
||||
}));
|
||||
|
||||
vi.mock('../../services/modelPricingService.js', () => ({
|
||||
estimateProxyCost: (arg: any) => estimateProxyCostMock(arg),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/proxyRetryPolicy.js', () => ({
|
||||
shouldRetryProxyRequest: () => false,
|
||||
}));
|
||||
|
||||
vi.mock('../../db/index.js', () => ({
|
||||
db: {
|
||||
insert: (arg: any) => dbInsertMock(arg),
|
||||
},
|
||||
schema: {
|
||||
proxyLogs: {},
|
||||
},
|
||||
}));
|
||||
|
||||
function buildMultipartBody(boundary: string) {
|
||||
return Buffer.from(
|
||||
`--${boundary}\r\n`
|
||||
+ `Content-Disposition: form-data; name="model"\r\n\r\n`
|
||||
+ `gpt-image-1\r\n`
|
||||
+ `--${boundary}\r\n`
|
||||
+ `Content-Disposition: form-data; name="prompt"\r\n\r\n`
|
||||
+ `edit this\r\n`
|
||||
+ `--${boundary}\r\n`
|
||||
+ `Content-Disposition: form-data; name="image"; filename="cat.png"\r\n`
|
||||
+ `Content-Type: image/png\r\n\r\n`
|
||||
+ `pngdata\r\n`
|
||||
+ `--${boundary}--\r\n`,
|
||||
);
|
||||
}
|
||||
|
||||
describe('/v1/images/edits route', () => {
|
||||
let app: FastifyInstance;
|
||||
|
||||
beforeAll(async () => {
|
||||
const { imagesProxyRoute } = await import('./images.js');
|
||||
app = Fastify();
|
||||
await app.register(imagesProxyRoute);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock.mockReset();
|
||||
selectChannelMock.mockReset();
|
||||
selectNextChannelMock.mockReset();
|
||||
recordSuccessMock.mockReset();
|
||||
recordFailureMock.mockReset();
|
||||
refreshModelsAndRebuildRoutesMock.mockReset();
|
||||
reportProxyAllFailedMock.mockReset();
|
||||
reportTokenExpiredMock.mockReset();
|
||||
estimateProxyCostMock.mockClear();
|
||||
dbInsertMock.mockClear();
|
||||
|
||||
selectChannelMock.mockReturnValue({
|
||||
channel: { id: 11, routeId: 22 },
|
||||
site: { id: 44, name: 'demo-site', url: 'https://upstream.example.com', platform: 'openai' },
|
||||
account: { id: 33, username: 'demo-user' },
|
||||
tokenName: 'default',
|
||||
tokenValue: 'sk-demo',
|
||||
actualModel: 'upstream-gpt-image',
|
||||
});
|
||||
selectNextChannelMock.mockReturnValue(null);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('accepts multipart image edit requests and forwards them to /v1/images/edits', async () => {
|
||||
fetchMock.mockResolvedValue(new Response(JSON.stringify({
|
||||
created: 1,
|
||||
data: [{ b64_json: 'iVBORw0KGgo=' }],
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}));
|
||||
|
||||
const boundary = 'metapi-boundary';
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/v1/images/edits',
|
||||
headers: {
|
||||
authorization: 'Bearer sk-demo',
|
||||
'content-type': `multipart/form-data; boundary=${boundary}`,
|
||||
},
|
||||
payload: buildMultipartBody(boundary),
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const [targetUrl] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
expect(targetUrl).toBe('https://upstream.example.com/v1/images/edits');
|
||||
});
|
||||
|
||||
it('returns explicit not-supported error for /v1/images/variations', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/v1/images/variations',
|
||||
payload: {
|
||||
model: 'gpt-image-1',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json()).toEqual({
|
||||
error: {
|
||||
message: 'Image variations are not supported',
|
||||
type: 'invalid_request_error',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -11,11 +11,14 @@ import { ensureModelAllowedForDownstreamKey, getDownstreamRoutingPolicy, recordD
|
||||
import { withSiteRecordProxyRequestInit } from '../../services/siteProxy.js';
|
||||
import { composeProxyLogMessage } from './logPathMeta.js';
|
||||
import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
|
||||
import { cloneFormDataWithOverrides, ensureMultipartBufferParser, parseMultipartFormData } from './multipart.js';
|
||||
|
||||
const MAX_RETRIES = 2;
|
||||
|
||||
export async function imagesProxyRoute(app: FastifyInstance) {
|
||||
app.post('/v1/images/generations', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
export async function imagesProxyRoute(app: FastifyInstance) {
|
||||
ensureMultipartBufferParser(app);
|
||||
|
||||
app.post('/v1/images/generations', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const body = request.body as any;
|
||||
const requestedModel = body?.model || 'gpt-image-1';
|
||||
if (!await ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return;
|
||||
@@ -112,25 +115,154 @@ export async function imagesProxyRoute(app: FastifyInstance) {
|
||||
return reply.code(502).send({
|
||||
error: { message: `Upstream error: ${err.message}`, type: 'upstream_error' },
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/v1/images/edits', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const multipartForm = await parseMultipartFormData(request);
|
||||
const jsonBody = (!multipartForm && request.body && typeof request.body === 'object')
|
||||
? request.body as Record<string, unknown>
|
||||
: null;
|
||||
const requestedModel = typeof multipartForm?.get('model') === 'string'
|
||||
? String(multipartForm.get('model')).trim()
|
||||
: (typeof jsonBody?.model === 'string' ? jsonBody.model.trim() : '') || 'gpt-image-1';
|
||||
|
||||
if (!await ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return;
|
||||
const downstreamPolicy = getDownstreamRoutingPolicy(request);
|
||||
const excludeChannelIds: number[] = [];
|
||||
let retryCount = 0;
|
||||
|
||||
while (retryCount <= MAX_RETRIES) {
|
||||
let selected = retryCount === 0
|
||||
? await tokenRouter.selectChannel(requestedModel, downstreamPolicy)
|
||||
: await tokenRouter.selectNextChannel(requestedModel, excludeChannelIds, downstreamPolicy);
|
||||
|
||||
if (!selected && retryCount === 0) {
|
||||
await refreshModelsAndRebuildRoutes();
|
||||
selected = await tokenRouter.selectChannel(requestedModel, downstreamPolicy);
|
||||
}
|
||||
|
||||
if (!selected) {
|
||||
await reportProxyAllFailed({
|
||||
model: requestedModel,
|
||||
reason: 'No available channels after retries',
|
||||
});
|
||||
return reply.code(503).send({
|
||||
error: { message: 'No available channels for this model', type: 'server_error' },
|
||||
});
|
||||
}
|
||||
|
||||
excludeChannelIds.push(selected.channel.id);
|
||||
const targetUrl = `${selected.site.url}/v1/images/edits`;
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
const requestInit = multipartForm
|
||||
? withSiteRecordProxyRequestInit(selected.site, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${selected.tokenValue}`,
|
||||
},
|
||||
body: cloneFormDataWithOverrides(multipartForm, {
|
||||
model: selected.actualModel || requestedModel,
|
||||
}) as any,
|
||||
})
|
||||
: withSiteRecordProxyRequestInit(selected.site, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${selected.tokenValue}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
...(jsonBody || {}),
|
||||
model: selected.actualModel || requestedModel,
|
||||
}),
|
||||
});
|
||||
|
||||
const upstream = await fetch(targetUrl, requestInit);
|
||||
const text = await upstream.text();
|
||||
if (!upstream.ok) {
|
||||
tokenRouter.recordFailure(selected.channel.id);
|
||||
logProxy(selected, requestedModel, 'failed', upstream.status, Date.now() - startTime, text, retryCount, 0, '/v1/images/edits');
|
||||
if (isTokenExpiredError({ status: upstream.status, message: text })) {
|
||||
await reportTokenExpired({
|
||||
accountId: selected.account.id,
|
||||
username: selected.account.username,
|
||||
siteName: selected.site.name,
|
||||
detail: `HTTP ${upstream.status}`,
|
||||
});
|
||||
}
|
||||
if (shouldRetryProxyRequest(upstream.status, text) && retryCount < MAX_RETRIES) {
|
||||
retryCount++;
|
||||
continue;
|
||||
}
|
||||
await reportProxyAllFailed({
|
||||
model: requestedModel,
|
||||
reason: `upstream returned HTTP ${upstream.status}`,
|
||||
});
|
||||
return reply.code(upstream.status).send({ error: { message: text, type: 'upstream_error' } });
|
||||
}
|
||||
|
||||
let data: any = {};
|
||||
try { data = JSON.parse(text); } catch { data = { data: [] }; }
|
||||
|
||||
const latency = Date.now() - startTime;
|
||||
const estimatedCost = await estimateProxyCost({
|
||||
site: selected.site,
|
||||
account: selected.account,
|
||||
modelName: selected.actualModel || requestedModel,
|
||||
promptTokens: 0,
|
||||
completionTokens: 0,
|
||||
totalTokens: 0,
|
||||
});
|
||||
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
|
||||
recordDownstreamCostUsage(request, estimatedCost);
|
||||
logProxy(selected, requestedModel, 'success', upstream.status, latency, null, retryCount, estimatedCost, '/v1/images/edits');
|
||||
return reply.code(upstream.status).send(data);
|
||||
} catch (err: any) {
|
||||
tokenRouter.recordFailure(selected.channel.id);
|
||||
logProxy(selected, requestedModel, 'failed', 0, Date.now() - startTime, err.message, retryCount, 0, '/v1/images/edits');
|
||||
if (retryCount < MAX_RETRIES) {
|
||||
retryCount++;
|
||||
continue;
|
||||
}
|
||||
await reportProxyAllFailed({
|
||||
model: requestedModel,
|
||||
reason: err.message || 'network failure',
|
||||
});
|
||||
return reply.code(502).send({
|
||||
error: { message: `Upstream error: ${err.message}`, type: 'upstream_error' },
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/v1/images/variations', async (_request: FastifyRequest, reply: FastifyReply) => {
|
||||
return reply.code(400).send({
|
||||
error: {
|
||||
message: 'Image variations are not supported',
|
||||
type: 'invalid_request_error',
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function logProxy(
|
||||
selected: any,
|
||||
modelRequested: string,
|
||||
status: string,
|
||||
httpStatus: number,
|
||||
latencyMs: number,
|
||||
errorMessage: string | null,
|
||||
retryCount: number,
|
||||
estimatedCost = 0,
|
||||
latencyMs: number,
|
||||
errorMessage: string | null,
|
||||
retryCount: number,
|
||||
estimatedCost = 0,
|
||||
downstreamPath = '/v1/images/generations',
|
||||
) {
|
||||
try {
|
||||
const createdAt = formatUtcSqlDateTime(new Date());
|
||||
const normalizedErrorMessage = composeProxyLogMessage({
|
||||
downstreamPath: '/v1/images/generations',
|
||||
downstreamPath,
|
||||
errorMessage,
|
||||
});
|
||||
await db.insert(schema.proxyLogs).values({
|
||||
|
||||
@@ -284,4 +284,90 @@ describe('/v1/models route', () => {
|
||||
expect(ids).not.toContain('claude-opus-4-5');
|
||||
expect(ids).not.toContain('claude-sonnet-4-5');
|
||||
});
|
||||
|
||||
it('filters search pseudo models out of /v1/models', async () => {
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'search-site',
|
||||
url: 'https://search.example.com',
|
||||
platform: 'openai',
|
||||
status: 'active',
|
||||
}).returning().get();
|
||||
|
||||
const account = await db.insert(schema.accounts).values({
|
||||
siteId: site.id,
|
||||
accessToken: 'search-access-token',
|
||||
status: 'active',
|
||||
}).returning().get();
|
||||
|
||||
const token = await db.insert(schema.accountTokens).values({
|
||||
accountId: account.id,
|
||||
name: 'default',
|
||||
token: 'search-api-token',
|
||||
enabled: true,
|
||||
isDefault: true,
|
||||
}).returning().get();
|
||||
|
||||
await db.insert(schema.modelAvailability).values([
|
||||
{
|
||||
accountId: account.id,
|
||||
modelName: '__search',
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
accountId: account.id,
|
||||
modelName: '__tavily_search',
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
accountId: account.id,
|
||||
modelName: 'gpt-4.1',
|
||||
available: true,
|
||||
},
|
||||
]).run();
|
||||
|
||||
const searchRoute = await db.insert(schema.tokenRoutes).values({
|
||||
modelPattern: '__search',
|
||||
enabled: true,
|
||||
}).returning().get();
|
||||
|
||||
const llmRoute = await db.insert(schema.tokenRoutes).values({
|
||||
modelPattern: 'gpt-4.1',
|
||||
enabled: true,
|
||||
}).returning().get();
|
||||
|
||||
await db.insert(schema.routeChannels).values([
|
||||
{
|
||||
routeId: searchRoute.id,
|
||||
accountId: account.id,
|
||||
tokenId: token.id,
|
||||
sourceModel: '__search',
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
routeId: llmRoute.id,
|
||||
accountId: account.id,
|
||||
tokenId: token.id,
|
||||
sourceModel: 'gpt-4.1',
|
||||
enabled: true,
|
||||
},
|
||||
]).run();
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/v1/models',
|
||||
headers: {
|
||||
authorization: 'Bearer change-me-proxy-sk-token',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const body = response.json() as {
|
||||
object: 'list';
|
||||
data: Array<{ id: string }>;
|
||||
};
|
||||
const ids = body.data.map((item) => item.id);
|
||||
expect(ids).toContain('gpt-4.1');
|
||||
expect(ids).not.toContain('__search');
|
||||
expect(ids).not.toContain('__tavily_search');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,12 @@ import { getDownstreamRoutingPolicy } from './downstreamPolicy.js';
|
||||
import { tokenRouter } from '../../services/tokenRouter.js';
|
||||
import { isModelAllowedByPolicyOrAllowedRoutes } from '../../services/downstreamApiKeyService.js';
|
||||
|
||||
function isSearchPseudoModel(modelName: string): boolean {
|
||||
const normalized = (modelName || '').trim().toLowerCase();
|
||||
if (!normalized) return false;
|
||||
return normalized === '__search' || /^__.+_search$/.test(normalized);
|
||||
}
|
||||
|
||||
export async function modelsProxyRoute(app: FastifyInstance) {
|
||||
app.get('/v1/models', async (request) => {
|
||||
const downstreamPolicy = getDownstreamRoutingPolicy(request);
|
||||
@@ -32,7 +38,9 @@ export async function modelsProxyRoute(app: FastifyInstance) {
|
||||
const deduped = Array.from(new Set([
|
||||
...rows.map((r) => r.modelName),
|
||||
...routeAliases,
|
||||
])).sort();
|
||||
]))
|
||||
.filter((modelName) => !isSearchPseudoModel(modelName))
|
||||
.sort();
|
||||
const allowed: string[] = [];
|
||||
for (const modelName of deduped) {
|
||||
if (!await isModelAllowedByPolicyOrAllowedRoutes(modelName, downstreamPolicy)) {
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { FastifyInstance, FastifyRequest } from 'fastify';
|
||||
|
||||
type MultipartAwareFastify = FastifyInstance & {
|
||||
__metapiMultipartParserRegistered?: boolean;
|
||||
};
|
||||
|
||||
function getContentType(request: FastifyRequest): string {
|
||||
return typeof request.headers['content-type'] === 'string'
|
||||
? request.headers['content-type']
|
||||
: '';
|
||||
}
|
||||
|
||||
export function ensureMultipartBufferParser(app: FastifyInstance): void {
|
||||
const target = app as MultipartAwareFastify;
|
||||
if (target.__metapiMultipartParserRegistered) return;
|
||||
|
||||
app.addContentTypeParser(/^multipart\/form-data(?:;.*)?$/i, { parseAs: 'buffer' }, (_request, body, done) => {
|
||||
done(null, body);
|
||||
});
|
||||
|
||||
target.__metapiMultipartParserRegistered = true;
|
||||
}
|
||||
|
||||
export function isMultipartRequest(request: FastifyRequest): boolean {
|
||||
return /^multipart\/form-data(?:;.*)?$/i.test(getContentType(request));
|
||||
}
|
||||
|
||||
export async function parseMultipartFormData(request: FastifyRequest): Promise<FormData | null> {
|
||||
if (!isMultipartRequest(request)) return null;
|
||||
const contentType = getContentType(request);
|
||||
const body = request.body;
|
||||
if (!Buffer.isBuffer(body) && !(body instanceof Uint8Array)) return null;
|
||||
|
||||
const response = new Response(new Blob([Buffer.from(body)]), {
|
||||
headers: {
|
||||
'content-type': contentType,
|
||||
},
|
||||
});
|
||||
return response.formData();
|
||||
}
|
||||
|
||||
export function cloneFormDataWithOverrides(
|
||||
formData: FormData,
|
||||
overrides: Record<string, string>,
|
||||
): FormData {
|
||||
const next = new FormData();
|
||||
const applied = new Set<string>();
|
||||
|
||||
for (const [key, value] of formData.entries()) {
|
||||
if (Object.prototype.hasOwnProperty.call(overrides, key)) {
|
||||
next.append(key, overrides[key] ?? '');
|
||||
applied.add(key);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
next.append(key, value);
|
||||
continue;
|
||||
}
|
||||
|
||||
const fileLike = value as unknown as File;
|
||||
next.append(key, value, fileLike.name || 'upload.bin');
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(overrides)) {
|
||||
if (applied.has(key)) continue;
|
||||
next.append(key, value);
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
@@ -9,13 +9,16 @@ import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js';
|
||||
import { resolveProxyUsageWithSelfLogFallback } from '../../services/proxyUsageFallbackService.js';
|
||||
import { mergeProxyUsage, parseProxyUsage } from '../../services/proxyUsageParser.js';
|
||||
import { resolveProxyUrlForSite, withSiteRecordProxyRequestInit } from '../../services/siteProxy.js';
|
||||
import { type NormalizedStreamEvent } from '../../transformers/shared/normalized.js';
|
||||
import { openAiResponsesTransformer } from '../../transformers/openai/responses/index.js';
|
||||
import { serializeResponsesFinalPayload } from '../../transformers/openai/responses/outbound.js';
|
||||
import { convertResponsesBodyToOpenAiBody } from '../../transformers/openai/responses/conversion.js';
|
||||
import {
|
||||
createStreamTransformContext,
|
||||
normalizeUpstreamFinalResponse,
|
||||
normalizeUpstreamStreamEvent,
|
||||
pullSseEventsWithDone,
|
||||
type NormalizedStreamEvent,
|
||||
} from './chatFormats.js';
|
||||
completeResponsesStream,
|
||||
createOpenAiResponsesAggregateState,
|
||||
failResponsesStream,
|
||||
serializeConvertedResponsesEvents,
|
||||
} from '../../transformers/openai/responses/aggregator.js';
|
||||
import {
|
||||
buildMinimalJsonHeadersForCompatibility,
|
||||
buildUpstreamEndpointRequest,
|
||||
@@ -248,894 +251,8 @@ function normalizeText(value: unknown): string {
|
||||
return '';
|
||||
}
|
||||
|
||||
function stringifyJson(value: unknown): string {
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeToolArguments(value: unknown): string {
|
||||
if (typeof value === 'string') return value;
|
||||
if (value === undefined || value === null) return '';
|
||||
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
|
||||
if (Array.isArray(value) || isRecord(value)) return stringifyJson(value);
|
||||
return '';
|
||||
}
|
||||
|
||||
function normalizeToolOutput(value: unknown): string {
|
||||
const text = normalizeText(value).trim();
|
||||
if (text) return text;
|
||||
if (typeof value === 'string') return value;
|
||||
if (value === undefined || value === null) return '';
|
||||
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
|
||||
if (Array.isArray(value) || isRecord(value)) return stringifyJson(value);
|
||||
return '';
|
||||
}
|
||||
|
||||
type OpenAiToolCall = {
|
||||
id: string;
|
||||
type: 'function';
|
||||
function: {
|
||||
name: string;
|
||||
arguments: string;
|
||||
};
|
||||
};
|
||||
|
||||
function toOpenAiToolCall(item: Record<string, unknown>, fallbackIndex: number): OpenAiToolCall | null {
|
||||
const callId = (
|
||||
asTrimmedString(item.call_id)
|
||||
|| asTrimmedString(item.id)
|
||||
|| `call_${Date.now()}_${fallbackIndex}`
|
||||
);
|
||||
const name = asTrimmedString(item.name);
|
||||
if (!name) return null;
|
||||
|
||||
return {
|
||||
id: callId,
|
||||
type: 'function',
|
||||
function: {
|
||||
name,
|
||||
arguments: normalizeToolArguments(item.arguments),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function extractTextAndToolsFromResponsesMessage(
|
||||
rawContent: unknown,
|
||||
fallbackPrefix: string,
|
||||
): {
|
||||
text: string;
|
||||
toolCalls: OpenAiToolCall[];
|
||||
toolResults: Array<{ id: string; content: string }>;
|
||||
} {
|
||||
const contentItems = Array.isArray(rawContent) ? rawContent : [rawContent];
|
||||
const textParts: string[] = [];
|
||||
const toolCalls: OpenAiToolCall[] = [];
|
||||
const toolResults: Array<{ id: string; content: string }> = [];
|
||||
|
||||
for (let index = 0; index < contentItems.length; index += 1) {
|
||||
const part = contentItems[index];
|
||||
if (!isRecord(part)) {
|
||||
const text = normalizeText(part).trim();
|
||||
if (text) textParts.push(text);
|
||||
continue;
|
||||
}
|
||||
|
||||
const partType = asTrimmedString(part.type).toLowerCase();
|
||||
if (partType === 'tool_use') {
|
||||
const callId = asTrimmedString(part.id) || `call_${fallbackPrefix}_${index}`;
|
||||
const name = asTrimmedString(part.name);
|
||||
if (!name) continue;
|
||||
|
||||
toolCalls.push({
|
||||
id: callId,
|
||||
type: 'function',
|
||||
function: {
|
||||
name,
|
||||
arguments: normalizeToolArguments(part.input),
|
||||
},
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (partType === 'tool_result') {
|
||||
const id = asTrimmedString(part.tool_use_id) || asTrimmedString(part.id);
|
||||
if (!id) continue;
|
||||
toolResults.push({
|
||||
id,
|
||||
content: normalizeToolOutput(part.content),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const text = normalizeText(part).trim();
|
||||
if (text) textParts.push(text);
|
||||
}
|
||||
|
||||
return {
|
||||
text: textParts.join('\n').trim(),
|
||||
toolCalls,
|
||||
toolResults,
|
||||
};
|
||||
}
|
||||
|
||||
function convertResponsesToolsToOpenAi(rawTools: unknown): unknown {
|
||||
if (!Array.isArray(rawTools)) return rawTools;
|
||||
|
||||
const converted = rawTools
|
||||
.map((item) => {
|
||||
if (!isRecord(item)) return item;
|
||||
const type = asTrimmedString(item.type).toLowerCase();
|
||||
|
||||
if (type !== 'function') return item;
|
||||
|
||||
if (isRecord(item.function) && asTrimmedString(item.function.name)) {
|
||||
return item;
|
||||
}
|
||||
|
||||
const name = asTrimmedString(item.name);
|
||||
if (!name) return item;
|
||||
|
||||
const fn: Record<string, unknown> = { name };
|
||||
const description = asTrimmedString(item.description);
|
||||
if (description) fn.description = description;
|
||||
if (item.parameters !== undefined) fn.parameters = item.parameters;
|
||||
if (item.strict !== undefined) fn.strict = item.strict;
|
||||
|
||||
return {
|
||||
type: 'function',
|
||||
function: fn,
|
||||
};
|
||||
});
|
||||
|
||||
return converted;
|
||||
}
|
||||
|
||||
function convertResponsesToolChoiceToOpenAi(rawToolChoice: unknown): unknown {
|
||||
if (rawToolChoice === undefined) return undefined;
|
||||
if (typeof rawToolChoice === 'string') return rawToolChoice;
|
||||
if (!isRecord(rawToolChoice)) return rawToolChoice;
|
||||
|
||||
const type = asTrimmedString(rawToolChoice.type).toLowerCase();
|
||||
if (type === 'function') {
|
||||
if (isRecord(rawToolChoice.function) && asTrimmedString(rawToolChoice.function.name)) {
|
||||
return rawToolChoice;
|
||||
}
|
||||
|
||||
const name = asTrimmedString(rawToolChoice.name);
|
||||
if (!name) return 'required';
|
||||
return {
|
||||
type: 'function',
|
||||
function: { name },
|
||||
};
|
||||
}
|
||||
|
||||
if (type === 'auto' || type === 'none' || type === 'required') {
|
||||
return type;
|
||||
}
|
||||
|
||||
return rawToolChoice;
|
||||
}
|
||||
|
||||
function convertResponsesBodyToOpenAiBody(
|
||||
body: Record<string, unknown>,
|
||||
modelName: string,
|
||||
stream: boolean,
|
||||
): Record<string, unknown> {
|
||||
const messages: Array<Record<string, unknown>> = [];
|
||||
const input = body.input;
|
||||
let functionCallIndex = 0;
|
||||
let pendingToolCalls: OpenAiToolCall[] = [];
|
||||
|
||||
const flushPendingToolCalls = () => {
|
||||
if (pendingToolCalls.length <= 0) return;
|
||||
messages.push({
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
tool_calls: pendingToolCalls,
|
||||
});
|
||||
pendingToolCalls = [];
|
||||
};
|
||||
|
||||
const pushToolOutputMessage = (callIdRaw: unknown, outputRaw: unknown) => {
|
||||
const toolCallId = asTrimmedString(callIdRaw);
|
||||
if (!toolCallId) return;
|
||||
const content = normalizeToolOutput(outputRaw);
|
||||
messages.push({
|
||||
role: 'tool',
|
||||
tool_call_id: toolCallId,
|
||||
content,
|
||||
});
|
||||
};
|
||||
|
||||
const pushUserMessage = (content: string) => {
|
||||
if (!content) return;
|
||||
messages.push({ role: 'user', content });
|
||||
};
|
||||
|
||||
const processInputItem = (item: unknown) => {
|
||||
if (typeof item === 'string') {
|
||||
flushPendingToolCalls();
|
||||
const text = item.trim();
|
||||
if (text) pushUserMessage(text);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isRecord(item)) return;
|
||||
|
||||
const itemType = asTrimmedString(item.type).toLowerCase();
|
||||
if (itemType === 'function_call') {
|
||||
const toolCall = toOpenAiToolCall(item, functionCallIndex);
|
||||
functionCallIndex += 1;
|
||||
if (!toolCall) return;
|
||||
pendingToolCalls.push(toolCall);
|
||||
return;
|
||||
}
|
||||
|
||||
if (itemType === 'function_call_output') {
|
||||
flushPendingToolCalls();
|
||||
pushToolOutputMessage(item.call_id ?? item.id, item.output ?? item.content);
|
||||
return;
|
||||
}
|
||||
|
||||
flushPendingToolCalls();
|
||||
|
||||
const role = asTrimmedString(item.role).toLowerCase() || 'user';
|
||||
const messageContent = item.content ?? item.input ?? item;
|
||||
const parsed = extractTextAndToolsFromResponsesMessage(
|
||||
messageContent,
|
||||
`${Date.now()}_${messages.length}`,
|
||||
);
|
||||
|
||||
if (role === 'assistant') {
|
||||
if (!parsed.text && parsed.toolCalls.length <= 0) return;
|
||||
|
||||
const assistantMessage: Record<string, unknown> = {
|
||||
role: 'assistant',
|
||||
content: parsed.text,
|
||||
};
|
||||
if (parsed.toolCalls.length > 0) {
|
||||
assistantMessage.tool_calls = parsed.toolCalls;
|
||||
}
|
||||
messages.push(assistantMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedRole = role === 'system' || role === 'developer'
|
||||
? 'system'
|
||||
: role === 'tool'
|
||||
? 'tool'
|
||||
: 'user';
|
||||
|
||||
if (normalizedRole === 'tool') {
|
||||
pushToolOutputMessage(item.tool_call_id ?? item.call_id ?? item.id, messageContent);
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsed.text) {
|
||||
messages.push({
|
||||
role: normalizedRole,
|
||||
content: parsed.text,
|
||||
});
|
||||
}
|
||||
|
||||
for (const toolResult of parsed.toolResults) {
|
||||
messages.push({
|
||||
role: 'tool',
|
||||
tool_call_id: toolResult.id,
|
||||
content: toolResult.content,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (typeof input === 'string') {
|
||||
const text = input.trim();
|
||||
if (text) {
|
||||
messages.push({ role: 'user', content: text });
|
||||
}
|
||||
} else if (Array.isArray(input)) {
|
||||
for (const item of input) {
|
||||
processInputItem(item);
|
||||
}
|
||||
} else if (isRecord(input)) {
|
||||
processInputItem(input);
|
||||
}
|
||||
flushPendingToolCalls();
|
||||
|
||||
const instructions = asTrimmedString(body.instructions);
|
||||
if (instructions) {
|
||||
messages.unshift({ role: 'system', content: instructions });
|
||||
}
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
model: modelName,
|
||||
stream,
|
||||
messages,
|
||||
};
|
||||
|
||||
if (typeof body.temperature === 'number' && Number.isFinite(body.temperature)) {
|
||||
payload.temperature = body.temperature;
|
||||
}
|
||||
if (typeof body.top_p === 'number' && Number.isFinite(body.top_p)) {
|
||||
payload.top_p = body.top_p;
|
||||
}
|
||||
if (typeof body.max_output_tokens === 'number' && Number.isFinite(body.max_output_tokens)) {
|
||||
payload.max_tokens = body.max_output_tokens;
|
||||
}
|
||||
if (body.parallel_tool_calls !== undefined) payload.parallel_tool_calls = body.parallel_tool_calls;
|
||||
if (body.tools !== undefined) payload.tools = convertResponsesToolsToOpenAi(body.tools);
|
||||
if (body.tool_choice !== undefined) payload.tool_choice = convertResponsesToolChoiceToOpenAi(body.tool_choice);
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
type UsageSummary = ReturnType<typeof parseProxyUsage>;
|
||||
|
||||
type ResponsesToolCall = {
|
||||
id: string;
|
||||
name: string;
|
||||
arguments: string;
|
||||
};
|
||||
|
||||
type ResponsesMessageItemState = {
|
||||
itemId: string;
|
||||
outputIndex: number;
|
||||
contentIndex: number;
|
||||
text: string;
|
||||
};
|
||||
|
||||
type ResponsesToolItemState = {
|
||||
toolIndex: number;
|
||||
itemId: string;
|
||||
callId: string;
|
||||
outputIndex: number;
|
||||
name: string;
|
||||
arguments: string;
|
||||
};
|
||||
|
||||
type ResponsesStreamState = {
|
||||
started: boolean;
|
||||
completed: boolean;
|
||||
responseId: string;
|
||||
model: string;
|
||||
createdAt: number;
|
||||
sequenceNumber: number;
|
||||
outputCursor: number;
|
||||
messageItem: ResponsesMessageItemState | null;
|
||||
toolItems: Map<number, ResponsesToolItemState>;
|
||||
};
|
||||
|
||||
function ensureResponseId(rawId: string): string {
|
||||
const trimmed = rawId.trim() || `resp_${Date.now()}`;
|
||||
return trimmed.startsWith('resp_') ? trimmed : `resp_${trimmed}`;
|
||||
}
|
||||
|
||||
function ensureMessageId(rawId: string): string {
|
||||
const trimmed = rawId.trim() || `msg_${Date.now()}`;
|
||||
return trimmed.startsWith('msg_') ? trimmed : `msg_${trimmed}`;
|
||||
}
|
||||
|
||||
function ensureFunctionCallId(rawId: string): string {
|
||||
const trimmed = rawId.trim();
|
||||
if (!trimmed) return `call_${Date.now()}`;
|
||||
return trimmed.startsWith('call_') ? trimmed : `call_${trimmed}`;
|
||||
}
|
||||
|
||||
function stringifyToolInput(input: unknown): string {
|
||||
if (typeof input === 'string') return input;
|
||||
if (!input || typeof input !== 'object') return '';
|
||||
try {
|
||||
return JSON.stringify(input);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function extractToolCallsFromUpstream(payload: unknown): ResponsesToolCall[] {
|
||||
if (!isRecord(payload)) return [];
|
||||
|
||||
if (Array.isArray(payload.choices)) {
|
||||
const choice = payload.choices[0];
|
||||
const message = isRecord((choice as any)?.message) ? (choice as any).message : {};
|
||||
const toolCalls = Array.isArray((message as any).tool_calls) ? (message as any).tool_calls : [];
|
||||
return toolCalls
|
||||
.map((item: unknown, index: number) => {
|
||||
if (!isRecord(item)) return null;
|
||||
const fn = isRecord(item.function) ? item.function : {};
|
||||
const id = typeof item.id === 'string' && item.id.trim().length > 0
|
||||
? item.id
|
||||
: `call_${index}`;
|
||||
const name = typeof fn.name === 'string' ? fn.name : '';
|
||||
const args = typeof fn.arguments === 'string' ? fn.arguments : '';
|
||||
return {
|
||||
id: ensureFunctionCallId(id),
|
||||
name,
|
||||
arguments: args,
|
||||
};
|
||||
})
|
||||
.filter((item: ResponsesToolCall | null): item is ResponsesToolCall => !!item);
|
||||
}
|
||||
|
||||
if (payload.type === 'message' && Array.isArray(payload.content)) {
|
||||
return payload.content
|
||||
.map((item: unknown, index: number) => {
|
||||
if (!isRecord(item) || item.type !== 'tool_use') return null;
|
||||
const id = typeof item.id === 'string' && item.id.trim().length > 0
|
||||
? item.id
|
||||
: `call_${index}`;
|
||||
const name = typeof item.name === 'string' ? item.name : '';
|
||||
const args = stringifyToolInput(item.input);
|
||||
return {
|
||||
id: ensureFunctionCallId(id),
|
||||
name,
|
||||
arguments: args,
|
||||
};
|
||||
})
|
||||
.filter((item: ResponsesToolCall | null): item is ResponsesToolCall => !!item);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
function toResponsesPayload(
|
||||
upstreamPayload: unknown,
|
||||
normalized: ReturnType<typeof normalizeUpstreamFinalResponse>,
|
||||
usage: UsageSummary,
|
||||
): Record<string, unknown> {
|
||||
if (isRecord(upstreamPayload) && upstreamPayload.object === 'response') {
|
||||
return upstreamPayload;
|
||||
}
|
||||
|
||||
const normalizedId = typeof normalized.id === 'string' && normalized.id.trim()
|
||||
? normalized.id.trim()
|
||||
: `resp_${Date.now()}`;
|
||||
const responseId = ensureResponseId(normalizedId);
|
||||
const messageId = ensureMessageId(normalizedId);
|
||||
const toolCalls = extractToolCallsFromUpstream(upstreamPayload);
|
||||
|
||||
const output: Array<Record<string, unknown>> = [];
|
||||
if (normalized.content || toolCalls.length === 0) {
|
||||
output.push({
|
||||
id: messageId,
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
status: 'completed',
|
||||
content: [{
|
||||
type: 'output_text',
|
||||
text: normalized.content || '',
|
||||
}],
|
||||
});
|
||||
}
|
||||
|
||||
for (const toolCall of toolCalls) {
|
||||
output.push({
|
||||
id: toolCall.id,
|
||||
type: 'function_call',
|
||||
status: 'completed',
|
||||
call_id: toolCall.id,
|
||||
name: toolCall.name,
|
||||
arguments: toolCall.arguments,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
id: responseId,
|
||||
object: 'response',
|
||||
created: normalized.created,
|
||||
status: 'completed',
|
||||
model: normalized.model,
|
||||
output,
|
||||
output_text: normalized.content || '',
|
||||
usage: {
|
||||
input_tokens: usage.promptTokens,
|
||||
output_tokens: usage.completionTokens,
|
||||
total_tokens: usage.totalTokens,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createResponsesStreamState(modelName: string): ResponsesStreamState {
|
||||
return {
|
||||
started: false,
|
||||
completed: false,
|
||||
responseId: `resp_meta_${Date.now()}`,
|
||||
model: modelName,
|
||||
createdAt: Math.floor(Date.now() / 1000),
|
||||
sequenceNumber: 0,
|
||||
outputCursor: 0,
|
||||
messageItem: null,
|
||||
toolItems: new Map(),
|
||||
};
|
||||
}
|
||||
|
||||
function toResponsesUsage(usage: UsageSummary): Record<string, number> {
|
||||
return {
|
||||
input_tokens: usage.promptTokens,
|
||||
output_tokens: usage.completionTokens,
|
||||
total_tokens: usage.totalTokens,
|
||||
};
|
||||
}
|
||||
|
||||
function nextSequence(state: ResponsesStreamState): number {
|
||||
const value = state.sequenceNumber;
|
||||
state.sequenceNumber += 1;
|
||||
return value;
|
||||
}
|
||||
|
||||
function serializeResponsesSse(eventType: string, payload: Record<string, unknown>): string {
|
||||
return `event: ${eventType}\ndata: ${JSON.stringify(payload)}\n\n`;
|
||||
}
|
||||
|
||||
function emitResponsesEvent(
|
||||
state: ResponsesStreamState,
|
||||
eventType: string,
|
||||
payload: Record<string, unknown>,
|
||||
): string {
|
||||
return serializeResponsesSse(eventType, {
|
||||
type: eventType,
|
||||
sequence_number: nextSequence(state),
|
||||
...payload,
|
||||
});
|
||||
}
|
||||
|
||||
function buildResponseObject(
|
||||
state: ResponsesStreamState,
|
||||
status: 'in_progress' | 'completed' | 'failed',
|
||||
usage?: UsageSummary,
|
||||
): Record<string, unknown> {
|
||||
const response: Record<string, unknown> = {
|
||||
id: state.responseId,
|
||||
object: 'response',
|
||||
created_at: state.createdAt,
|
||||
status,
|
||||
model: state.model,
|
||||
output: [],
|
||||
};
|
||||
if (usage) {
|
||||
response.usage = toResponsesUsage(usage);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
function ensureResponsesStarted(
|
||||
state: ResponsesStreamState,
|
||||
streamContext: { id: string; model: string; created: number },
|
||||
): string[] {
|
||||
if (state.started) return [];
|
||||
|
||||
state.started = true;
|
||||
state.responseId = ensureResponseId(streamContext.id || state.responseId);
|
||||
state.model = streamContext.model || state.model;
|
||||
state.createdAt = streamContext.created || state.createdAt;
|
||||
|
||||
const inProgress = buildResponseObject(state, 'in_progress');
|
||||
return [
|
||||
emitResponsesEvent(state, 'response.created', { response: inProgress }),
|
||||
emitResponsesEvent(state, 'response.in_progress', { response: inProgress }),
|
||||
];
|
||||
}
|
||||
|
||||
function ensureMessageItem(state: ResponsesStreamState): string[] {
|
||||
if (state.messageItem) return [];
|
||||
|
||||
const outputIndex = state.outputCursor;
|
||||
state.outputCursor += 1;
|
||||
const itemId = ensureMessageId(`${state.responseId}_${outputIndex}`);
|
||||
state.messageItem = {
|
||||
itemId,
|
||||
outputIndex,
|
||||
contentIndex: 0,
|
||||
text: '',
|
||||
};
|
||||
|
||||
return [
|
||||
emitResponsesEvent(state, 'response.output_item.added', {
|
||||
output_index: outputIndex,
|
||||
item: {
|
||||
id: itemId,
|
||||
type: 'message',
|
||||
status: 'in_progress',
|
||||
role: 'assistant',
|
||||
content: [],
|
||||
},
|
||||
}),
|
||||
emitResponsesEvent(state, 'response.content_part.added', {
|
||||
item_id: itemId,
|
||||
output_index: outputIndex,
|
||||
content_index: 0,
|
||||
part: {
|
||||
type: 'output_text',
|
||||
text: '',
|
||||
},
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
function toIncrementalText(existingText: string, incomingText: string): string {
|
||||
if (!incomingText) return '';
|
||||
if (!existingText) return incomingText;
|
||||
|
||||
if (incomingText === existingText) return '';
|
||||
if (incomingText.startsWith(existingText)) {
|
||||
return incomingText.slice(existingText.length);
|
||||
}
|
||||
if (existingText.endsWith(incomingText)) return '';
|
||||
|
||||
// Some upstreams emit overlapping windows instead of strict deltas.
|
||||
// Keep only the non-overlapping suffix to avoid duplicated text.
|
||||
const maxOverlap = Math.min(existingText.length, incomingText.length);
|
||||
const MIN_OVERLAP = 8;
|
||||
for (let overlap = maxOverlap; overlap >= MIN_OVERLAP; overlap -= 1) {
|
||||
if (existingText.slice(-overlap) === incomingText.slice(0, overlap)) {
|
||||
return incomingText.slice(overlap);
|
||||
}
|
||||
}
|
||||
|
||||
return incomingText;
|
||||
}
|
||||
|
||||
function appendMessageDelta(state: ResponsesStreamState, delta: string): string[] {
|
||||
if (!delta) return [];
|
||||
|
||||
const events: string[] = [];
|
||||
events.push(...ensureMessageItem(state));
|
||||
if (!state.messageItem) return events;
|
||||
|
||||
const normalizedDelta = toIncrementalText(state.messageItem.text, delta);
|
||||
if (!normalizedDelta) return events;
|
||||
|
||||
state.messageItem.text += normalizedDelta;
|
||||
events.push(emitResponsesEvent(state, 'response.output_text.delta', {
|
||||
item_id: state.messageItem.itemId,
|
||||
output_index: state.messageItem.outputIndex,
|
||||
content_index: state.messageItem.contentIndex,
|
||||
delta: normalizedDelta,
|
||||
}));
|
||||
return events;
|
||||
}
|
||||
|
||||
function closeMessageItem(state: ResponsesStreamState): string[] {
|
||||
if (!state.messageItem) return [];
|
||||
|
||||
const item = state.messageItem;
|
||||
const events = [
|
||||
emitResponsesEvent(state, 'response.output_text.done', {
|
||||
item_id: item.itemId,
|
||||
output_index: item.outputIndex,
|
||||
content_index: item.contentIndex,
|
||||
text: item.text,
|
||||
}),
|
||||
emitResponsesEvent(state, 'response.content_part.done', {
|
||||
item_id: item.itemId,
|
||||
output_index: item.outputIndex,
|
||||
content_index: item.contentIndex,
|
||||
part: {
|
||||
type: 'output_text',
|
||||
text: item.text,
|
||||
},
|
||||
}),
|
||||
emitResponsesEvent(state, 'response.output_item.done', {
|
||||
output_index: item.outputIndex,
|
||||
item: {
|
||||
id: item.itemId,
|
||||
type: 'message',
|
||||
status: 'completed',
|
||||
role: 'assistant',
|
||||
content: [{
|
||||
type: 'output_text',
|
||||
text: item.text,
|
||||
}],
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
state.messageItem = null;
|
||||
return events;
|
||||
}
|
||||
|
||||
function ensureToolItem(
|
||||
state: ResponsesStreamState,
|
||||
toolIndex: number,
|
||||
id?: string,
|
||||
name?: string,
|
||||
): string[] {
|
||||
const existing = state.toolItems.get(toolIndex);
|
||||
if (existing) {
|
||||
if (id && !existing.callId) existing.callId = ensureFunctionCallId(id);
|
||||
if (id && !existing.itemId) existing.itemId = ensureFunctionCallId(id);
|
||||
if (name && !existing.name) existing.name = name;
|
||||
return [];
|
||||
}
|
||||
|
||||
const outputIndex = state.outputCursor;
|
||||
state.outputCursor += 1;
|
||||
const callId = ensureFunctionCallId(id || `${state.responseId}_${toolIndex}`);
|
||||
const itemId = callId;
|
||||
const toolState: ResponsesToolItemState = {
|
||||
toolIndex,
|
||||
itemId,
|
||||
callId,
|
||||
outputIndex,
|
||||
name: name || '',
|
||||
arguments: '',
|
||||
};
|
||||
state.toolItems.set(toolIndex, toolState);
|
||||
|
||||
return [
|
||||
emitResponsesEvent(state, 'response.output_item.added', {
|
||||
output_index: outputIndex,
|
||||
item: {
|
||||
id: itemId,
|
||||
type: 'function_call',
|
||||
status: 'in_progress',
|
||||
call_id: callId,
|
||||
name: toolState.name,
|
||||
arguments: '',
|
||||
},
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
function appendToolCallDelta(
|
||||
state: ResponsesStreamState,
|
||||
toolDelta: NonNullable<NormalizedStreamEvent['toolCallDeltas']>[number],
|
||||
): string[] {
|
||||
const toolIndex = Number.isFinite(toolDelta.index) ? Math.max(0, Math.trunc(toolDelta.index)) : 0;
|
||||
const events: string[] = [];
|
||||
events.push(...ensureToolItem(state, toolIndex, toolDelta.id, toolDelta.name));
|
||||
|
||||
const toolState = state.toolItems.get(toolIndex);
|
||||
if (!toolState) return events;
|
||||
if (toolDelta.name && !toolState.name) toolState.name = toolDelta.name;
|
||||
|
||||
if (toolDelta.argumentsDelta !== undefined) {
|
||||
toolState.arguments += toolDelta.argumentsDelta;
|
||||
events.push(emitResponsesEvent(state, 'response.function_call_arguments.delta', {
|
||||
item_id: toolState.itemId,
|
||||
output_index: toolState.outputIndex,
|
||||
delta: toolDelta.argumentsDelta,
|
||||
}));
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
function closeToolItems(state: ResponsesStreamState): string[] {
|
||||
if (state.toolItems.size <= 0) return [];
|
||||
|
||||
const ordered = Array.from(state.toolItems.values())
|
||||
.sort((a, b) => a.outputIndex - b.outputIndex);
|
||||
const events: string[] = [];
|
||||
for (const toolItem of ordered) {
|
||||
events.push(emitResponsesEvent(state, 'response.function_call_arguments.done', {
|
||||
item_id: toolItem.itemId,
|
||||
output_index: toolItem.outputIndex,
|
||||
arguments: toolItem.arguments,
|
||||
}));
|
||||
events.push(emitResponsesEvent(state, 'response.output_item.done', {
|
||||
output_index: toolItem.outputIndex,
|
||||
item: {
|
||||
id: toolItem.itemId,
|
||||
type: 'function_call',
|
||||
status: 'completed',
|
||||
call_id: toolItem.callId,
|
||||
name: toolItem.name,
|
||||
arguments: toolItem.arguments,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
state.toolItems.clear();
|
||||
return events;
|
||||
}
|
||||
|
||||
function completeResponsesStream(
|
||||
state: ResponsesStreamState,
|
||||
streamContext: { id: string; model: string; created: number },
|
||||
usage: UsageSummary,
|
||||
): string[] {
|
||||
if (state.completed) return [];
|
||||
|
||||
const events: string[] = [];
|
||||
events.push(...ensureResponsesStarted(state, streamContext));
|
||||
events.push(...closeMessageItem(state));
|
||||
events.push(...closeToolItems(state));
|
||||
events.push(emitResponsesEvent(state, 'response.completed', {
|
||||
response: buildResponseObject(state, 'completed', usage),
|
||||
}));
|
||||
events.push('data: [DONE]\n\n');
|
||||
state.completed = true;
|
||||
return events;
|
||||
}
|
||||
|
||||
function failResponsesStream(
|
||||
state: ResponsesStreamState,
|
||||
streamContext: { id: string; model: string; created: number },
|
||||
usage: UsageSummary,
|
||||
payload?: unknown,
|
||||
): string[] {
|
||||
if (state.completed) return [];
|
||||
|
||||
const events: string[] = [];
|
||||
events.push(...ensureResponsesStarted(state, streamContext));
|
||||
|
||||
const responsePayload = isRecord(payload) && isRecord((payload as any).response)
|
||||
? ((payload as any).response as Record<string, unknown>)
|
||||
: null;
|
||||
const errorPayload = isRecord(payload) && isRecord((payload as any).error)
|
||||
? ((payload as any).error as Record<string, unknown>)
|
||||
: null;
|
||||
|
||||
if (responsePayload && asTrimmedString(responsePayload.id)) {
|
||||
state.responseId = ensureResponseId(asTrimmedString(responsePayload.id));
|
||||
}
|
||||
if (responsePayload && asTrimmedString(responsePayload.model)) {
|
||||
state.model = asTrimmedString(responsePayload.model);
|
||||
}
|
||||
|
||||
const failedResponse = buildResponseObject(state, 'failed', usage);
|
||||
if (errorPayload) {
|
||||
failedResponse.error = errorPayload;
|
||||
}
|
||||
|
||||
events.push(emitResponsesEvent(state, 'response.failed', {
|
||||
response: failedResponse,
|
||||
}));
|
||||
events.push('data: [DONE]\n\n');
|
||||
state.completed = true;
|
||||
return events;
|
||||
}
|
||||
|
||||
function serializeConvertedResponsesEvents(input: {
|
||||
state: ResponsesStreamState;
|
||||
streamContext: { id: string; model: string; created: number };
|
||||
event: NormalizedStreamEvent;
|
||||
usage: UsageSummary;
|
||||
}): string[] {
|
||||
const { state, streamContext, event, usage } = input;
|
||||
if (state.completed) return [];
|
||||
|
||||
const shouldStart = (
|
||||
event.role === 'assistant'
|
||||
|| !!event.contentDelta
|
||||
|| !!event.reasoningDelta
|
||||
|| (Array.isArray(event.toolCallDeltas) && event.toolCallDeltas.length > 0)
|
||||
|| !!event.done
|
||||
|| !!event.finishReason
|
||||
);
|
||||
|
||||
const events: string[] = [];
|
||||
if (shouldStart) {
|
||||
events.push(...ensureResponsesStarted(state, streamContext));
|
||||
}
|
||||
|
||||
if (event.contentDelta) {
|
||||
events.push(...appendMessageDelta(state, event.contentDelta));
|
||||
}
|
||||
if (event.reasoningDelta) {
|
||||
events.push(...appendMessageDelta(state, event.reasoningDelta));
|
||||
}
|
||||
|
||||
if (Array.isArray(event.toolCallDeltas) && event.toolCallDeltas.length > 0) {
|
||||
events.push(...closeMessageItem(state));
|
||||
for (const toolDelta of event.toolCallDeltas) {
|
||||
events.push(...appendToolCallDelta(state, toolDelta));
|
||||
}
|
||||
}
|
||||
|
||||
if (event.finishReason) {
|
||||
// Keep compatibility with clients that expect finish before completed.
|
||||
}
|
||||
|
||||
if (event.done) {
|
||||
events.push(...completeResponsesStream(state, streamContext, usage));
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
export async function responsesProxyRoute(app: FastifyInstance) {
|
||||
const handleResponsesRequest = async (
|
||||
request: FastifyRequest,
|
||||
@@ -1374,15 +491,15 @@ export async function responsesProxyRoute(app: FastifyInstance) {
|
||||
let sseBuffer = '';
|
||||
|
||||
const passthroughResponsesStream = successfulUpstreamPath === '/v1/responses';
|
||||
const streamContext = createStreamTransformContext(modelName);
|
||||
const responsesState = createResponsesStreamState(modelName);
|
||||
const streamContext = openAiResponsesTransformer.createStreamContext(modelName);
|
||||
const responsesState = createOpenAiResponsesAggregateState(modelName);
|
||||
|
||||
const writeLines = (lines: string[]) => {
|
||||
for (const line of lines) reply.raw.write(line);
|
||||
};
|
||||
|
||||
const consumeSseBuffer = (incoming: string): string => {
|
||||
const pulled = pullSseEventsWithDone(incoming);
|
||||
const pulled = openAiResponsesTransformer.pullSseEvents(incoming);
|
||||
for (const eventBlock of pulled.events) {
|
||||
if (eventBlock.data === '[DONE]') {
|
||||
if (passthroughResponsesStream) {
|
||||
@@ -1425,7 +542,7 @@ export async function responsesProxyRoute(app: FastifyInstance) {
|
||||
}
|
||||
|
||||
if (parsedPayload && typeof parsedPayload === 'object') {
|
||||
const normalizedEvent = normalizeUpstreamStreamEvent(parsedPayload, streamContext, modelName);
|
||||
const normalizedEvent = openAiResponsesTransformer.transformStreamEvent(parsedPayload, streamContext, modelName);
|
||||
writeLines(serializeConvertedResponsesEvents({
|
||||
state: responsesState,
|
||||
streamContext,
|
||||
@@ -1510,12 +627,16 @@ export async function responsesProxyRoute(app: FastifyInstance) {
|
||||
}
|
||||
const latency = Date.now() - startTime;
|
||||
const parsedUsage = parseProxyUsage(upstreamData);
|
||||
const normalized = normalizeUpstreamFinalResponse(
|
||||
const normalized = openAiResponsesTransformer.transformFinalResponse(
|
||||
upstreamData,
|
||||
modelName,
|
||||
rawText,
|
||||
);
|
||||
const downstreamData = toResponsesPayload(upstreamData, normalized, parsedUsage);
|
||||
const downstreamData = serializeResponsesFinalPayload({
|
||||
upstreamPayload: upstreamData,
|
||||
normalized,
|
||||
usage: parsedUsage,
|
||||
});
|
||||
const resolvedUsage = await resolveProxyUsageWithSelfLogFallback({
|
||||
site: selected.site,
|
||||
account: selected.account,
|
||||
|
||||
@@ -6,6 +6,9 @@ import { embeddingsProxyRoute } from './embeddings.js';
|
||||
import { completionsProxyRoute } from './completions.js';
|
||||
import { responsesProxyRoute } from './responses.js';
|
||||
import { imagesProxyRoute } from './images.js';
|
||||
import { searchProxyRoute } from './search.js';
|
||||
import { geminiProxyRoute } from './gemini.js';
|
||||
import { videosProxyRoute } from './videos.js';
|
||||
|
||||
export async function proxyRoutes(app: FastifyInstance) {
|
||||
// Auth middleware for all /v1 routes
|
||||
@@ -19,5 +22,8 @@ export async function proxyRoutes(app: FastifyInstance) {
|
||||
await app.register(responsesProxyRoute);
|
||||
await app.register(modelsProxyRoute);
|
||||
await app.register(embeddingsProxyRoute);
|
||||
await app.register(searchProxyRoute);
|
||||
await app.register(imagesProxyRoute);
|
||||
await app.register(videosProxyRoute);
|
||||
await app.register(geminiProxyRoute);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const fetchMock = vi.fn();
|
||||
const selectChannelMock = vi.fn();
|
||||
const selectNextChannelMock = vi.fn();
|
||||
const recordSuccessMock = vi.fn();
|
||||
const recordFailureMock = vi.fn();
|
||||
const refreshModelsAndRebuildRoutesMock = vi.fn();
|
||||
const reportProxyAllFailedMock = vi.fn();
|
||||
const reportTokenExpiredMock = vi.fn();
|
||||
const estimateProxyCostMock = vi.fn(async () => 0);
|
||||
const dbInsertMock = vi.fn((_arg?: any) => ({
|
||||
values: () => ({
|
||||
run: () => undefined,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('undici', () => ({
|
||||
fetch: (...args: unknown[]) => fetchMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/tokenRouter.js', () => ({
|
||||
tokenRouter: {
|
||||
selectChannel: (...args: unknown[]) => selectChannelMock(...args),
|
||||
selectNextChannel: (...args: unknown[]) => selectNextChannelMock(...args),
|
||||
recordSuccess: (...args: unknown[]) => recordSuccessMock(...args),
|
||||
recordFailure: (...args: unknown[]) => recordFailureMock(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../services/modelService.js', () => ({
|
||||
refreshModelsAndRebuildRoutes: (...args: unknown[]) => refreshModelsAndRebuildRoutesMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/alertService.js', () => ({
|
||||
reportProxyAllFailed: (...args: unknown[]) => reportProxyAllFailedMock(...args),
|
||||
reportTokenExpired: (...args: unknown[]) => reportTokenExpiredMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/alertRules.js', () => ({
|
||||
isTokenExpiredError: () => false,
|
||||
}));
|
||||
|
||||
vi.mock('../../services/modelPricingService.js', () => ({
|
||||
estimateProxyCost: (arg: any) => estimateProxyCostMock(arg),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/proxyRetryPolicy.js', () => ({
|
||||
shouldRetryProxyRequest: () => false,
|
||||
}));
|
||||
|
||||
vi.mock('../../db/index.js', () => ({
|
||||
db: {
|
||||
insert: (arg: any) => dbInsertMock(arg),
|
||||
},
|
||||
schema: {
|
||||
proxyLogs: {},
|
||||
},
|
||||
}));
|
||||
|
||||
describe('/v1/search route', () => {
|
||||
let app: FastifyInstance;
|
||||
|
||||
beforeAll(async () => {
|
||||
const { searchProxyRoute } = await import('./search.js');
|
||||
app = Fastify();
|
||||
await app.register(searchProxyRoute);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock.mockReset();
|
||||
selectChannelMock.mockReset();
|
||||
selectNextChannelMock.mockReset();
|
||||
recordSuccessMock.mockReset();
|
||||
recordFailureMock.mockReset();
|
||||
refreshModelsAndRebuildRoutesMock.mockReset();
|
||||
reportProxyAllFailedMock.mockReset();
|
||||
reportTokenExpiredMock.mockReset();
|
||||
estimateProxyCostMock.mockClear();
|
||||
dbInsertMock.mockClear();
|
||||
|
||||
selectChannelMock.mockReturnValue({
|
||||
channel: { id: 11, routeId: 22 },
|
||||
site: { id: 44, name: 'demo-site', url: 'https://upstream.example.com', platform: 'openai' },
|
||||
account: { id: 33, username: 'demo-user' },
|
||||
tokenName: 'default',
|
||||
tokenValue: 'sk-demo',
|
||||
actualModel: '__search',
|
||||
});
|
||||
selectNextChannelMock.mockReturnValue(null);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('defaults model to __search and forwards to the upstream /v1/search endpoint', async () => {
|
||||
fetchMock.mockResolvedValue(new Response(JSON.stringify({
|
||||
object: 'search.result',
|
||||
data: [{ title: 'AxonHub' }],
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}));
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/v1/search',
|
||||
headers: {
|
||||
authorization: 'Bearer sk-demo',
|
||||
},
|
||||
payload: {
|
||||
query: 'axonhub',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(selectChannelMock).toHaveBeenCalledWith('__search', expect.anything());
|
||||
const [targetUrl, requestInit] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
expect(targetUrl).toBe('https://upstream.example.com/v1/search');
|
||||
expect(JSON.parse(String(requestInit.body))).toEqual({
|
||||
query: 'axonhub',
|
||||
max_results: 10,
|
||||
model: '__search',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects max_results outside the allowed range', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/v1/search',
|
||||
headers: {
|
||||
authorization: 'Bearer sk-demo',
|
||||
},
|
||||
payload: {
|
||||
query: 'axonhub',
|
||||
max_results: 21,
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json()).toMatchObject({
|
||||
error: {
|
||||
message: 'max_results must be an integer between 1 and 20',
|
||||
type: 'invalid_request_error',
|
||||
},
|
||||
});
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects max_results below the allowed range', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/v1/search',
|
||||
headers: {
|
||||
authorization: 'Bearer sk-demo',
|
||||
},
|
||||
payload: {
|
||||
query: 'axonhub',
|
||||
max_results: 0,
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json()).toMatchObject({
|
||||
error: {
|
||||
message: 'max_results must be an integer between 1 and 20',
|
||||
type: 'invalid_request_error',
|
||||
},
|
||||
});
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects streaming search requests', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/v1/search',
|
||||
headers: {
|
||||
authorization: 'Bearer sk-demo',
|
||||
},
|
||||
payload: {
|
||||
query: 'axonhub',
|
||||
stream: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json()).toMatchObject({
|
||||
error: {
|
||||
message: 'search does not support streaming',
|
||||
type: 'invalid_request_error',
|
||||
},
|
||||
});
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { fetch } from 'undici';
|
||||
import { db, schema } from '../../db/index.js';
|
||||
import { tokenRouter } from '../../services/tokenRouter.js';
|
||||
import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js';
|
||||
import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js';
|
||||
import { isTokenExpiredError } from '../../services/alertRules.js';
|
||||
import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js';
|
||||
import { ensureModelAllowedForDownstreamKey, getDownstreamRoutingPolicy, recordDownstreamCostUsage } from './downstreamPolicy.js';
|
||||
import { withSiteRecordProxyRequestInit } from '../../services/siteProxy.js';
|
||||
import { composeProxyLogMessage } from './logPathMeta.js';
|
||||
import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
|
||||
|
||||
const MAX_RETRIES = 2;
|
||||
const DEFAULT_SEARCH_MODEL = '__search';
|
||||
const DEFAULT_MAX_RESULTS = 10;
|
||||
const MAX_MAX_RESULTS = 20;
|
||||
|
||||
export async function searchProxyRoute(app: FastifyInstance) {
|
||||
app.post('/v1/search', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const body = (request.body || {}) as Record<string, unknown>;
|
||||
const query = typeof body.query === 'string' ? body.query.trim() : '';
|
||||
if (!query) {
|
||||
return reply.code(400).send({
|
||||
error: { message: 'query is required', type: 'invalid_request_error' },
|
||||
});
|
||||
}
|
||||
if (body.stream === true) {
|
||||
return reply.code(400).send({
|
||||
error: { message: 'search does not support streaming', type: 'invalid_request_error' },
|
||||
});
|
||||
}
|
||||
const rawMaxResults = body.max_results;
|
||||
const maxResults = rawMaxResults == null
|
||||
? DEFAULT_MAX_RESULTS
|
||||
: (typeof rawMaxResults === 'number' && Number.isInteger(rawMaxResults)
|
||||
? rawMaxResults
|
||||
: NaN);
|
||||
if (!Number.isInteger(maxResults) || maxResults < 1 || maxResults > MAX_MAX_RESULTS) {
|
||||
return reply.code(400).send({
|
||||
error: {
|
||||
message: `max_results must be an integer between 1 and ${MAX_MAX_RESULTS}`,
|
||||
type: 'invalid_request_error',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const requestedModel = typeof body.model === 'string' && body.model.trim().length > 0
|
||||
? body.model.trim()
|
||||
: DEFAULT_SEARCH_MODEL;
|
||||
|
||||
if (!await ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return;
|
||||
const downstreamPolicy = getDownstreamRoutingPolicy(request);
|
||||
const excludeChannelIds: number[] = [];
|
||||
let retryCount = 0;
|
||||
|
||||
while (retryCount <= MAX_RETRIES) {
|
||||
let selected = retryCount === 0
|
||||
? await tokenRouter.selectChannel(requestedModel, downstreamPolicy)
|
||||
: await tokenRouter.selectNextChannel(requestedModel, excludeChannelIds, downstreamPolicy);
|
||||
|
||||
if (!selected && retryCount === 0) {
|
||||
await refreshModelsAndRebuildRoutes();
|
||||
selected = await tokenRouter.selectChannel(requestedModel, downstreamPolicy);
|
||||
}
|
||||
|
||||
if (!selected) {
|
||||
await reportProxyAllFailed({
|
||||
model: requestedModel,
|
||||
reason: 'No available channels after retries',
|
||||
});
|
||||
return reply.code(503).send({
|
||||
error: { message: 'No available channels for this model', type: 'server_error' },
|
||||
});
|
||||
}
|
||||
|
||||
excludeChannelIds.push(selected.channel.id);
|
||||
const targetUrl = `${selected.site.url}/v1/search`;
|
||||
const forwardBody = {
|
||||
...body,
|
||||
max_results: maxResults,
|
||||
model: selected.actualModel || requestedModel,
|
||||
};
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
const upstream = await fetch(targetUrl, withSiteRecordProxyRequestInit(selected.site, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${selected.tokenValue}`,
|
||||
},
|
||||
body: JSON.stringify(forwardBody),
|
||||
}));
|
||||
|
||||
const text = await upstream.text();
|
||||
if (!upstream.ok) {
|
||||
tokenRouter.recordFailure(selected.channel.id);
|
||||
logProxy(selected, requestedModel, 'failed', upstream.status, Date.now() - startTime, text, retryCount);
|
||||
if (isTokenExpiredError({ status: upstream.status, message: text })) {
|
||||
await reportTokenExpired({
|
||||
accountId: selected.account.id,
|
||||
username: selected.account.username,
|
||||
siteName: selected.site.name,
|
||||
detail: `HTTP ${upstream.status}`,
|
||||
});
|
||||
}
|
||||
if (shouldRetryProxyRequest(upstream.status, text) && retryCount < MAX_RETRIES) {
|
||||
retryCount += 1;
|
||||
continue;
|
||||
}
|
||||
await reportProxyAllFailed({
|
||||
model: requestedModel,
|
||||
reason: `upstream returned HTTP ${upstream.status}`,
|
||||
});
|
||||
return reply.code(upstream.status).send({ error: { message: text, type: 'upstream_error' } });
|
||||
}
|
||||
|
||||
let data: any = {};
|
||||
try { data = JSON.parse(text); } catch { data = { data: [] }; }
|
||||
|
||||
const latency = Date.now() - startTime;
|
||||
tokenRouter.recordSuccess(selected.channel.id, latency, 0);
|
||||
recordDownstreamCostUsage(request, 0);
|
||||
logProxy(selected, requestedModel, 'success', upstream.status, latency, null, retryCount);
|
||||
return reply.code(upstream.status).send(data);
|
||||
} catch (error: any) {
|
||||
tokenRouter.recordFailure(selected.channel.id);
|
||||
logProxy(selected, requestedModel, 'failed', 0, Date.now() - startTime, error?.message || 'network error', retryCount);
|
||||
if (retryCount < MAX_RETRIES) {
|
||||
retryCount += 1;
|
||||
continue;
|
||||
}
|
||||
await reportProxyAllFailed({
|
||||
model: requestedModel,
|
||||
reason: error?.message || 'network failure',
|
||||
});
|
||||
return reply.code(502).send({
|
||||
error: { message: error?.message || 'network failure', type: 'upstream_error' },
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function logProxy(
|
||||
selected: any,
|
||||
modelRequested: string,
|
||||
status: string,
|
||||
httpStatus: number,
|
||||
latencyMs: number,
|
||||
errorMessage: string | null,
|
||||
retryCount: number,
|
||||
) {
|
||||
try {
|
||||
const createdAt = formatUtcSqlDateTime(new Date());
|
||||
await db.insert(schema.proxyLogs).values({
|
||||
routeId: selected.channel.routeId,
|
||||
channelId: selected.channel.id,
|
||||
accountId: selected.account.id,
|
||||
modelRequested,
|
||||
modelActual: selected.actualModel,
|
||||
status,
|
||||
httpStatus,
|
||||
latencyMs,
|
||||
promptTokens: 0,
|
||||
completionTokens: 0,
|
||||
totalTokens: 0,
|
||||
estimatedCost: 0,
|
||||
errorMessage: composeProxyLogMessage({
|
||||
downstreamPath: '/v1/search',
|
||||
errorMessage,
|
||||
}),
|
||||
retryCount,
|
||||
createdAt,
|
||||
}).run();
|
||||
} catch (error) {
|
||||
console.warn('[proxy/search] failed to write proxy log', error);
|
||||
}
|
||||
}
|
||||
@@ -313,6 +313,84 @@ describe('buildUpstreamEndpointRequest', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('preserves multimodal user content when converting chat to messages', () => {
|
||||
const request = buildUpstreamEndpointRequest({
|
||||
endpoint: 'messages',
|
||||
modelName: 'claude-3-7-sonnet',
|
||||
stream: false,
|
||||
tokenValue: 'sk-test',
|
||||
sitePlatform: 'anthropic',
|
||||
siteUrl: 'https://example.com',
|
||||
openaiBody: {
|
||||
model: 'gpt-4o-mini',
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: 'describe this' },
|
||||
{ type: 'image_url', image_url: { url: 'https://example.com/cat.png' } },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
downstreamFormat: 'openai',
|
||||
});
|
||||
|
||||
expect(request.path).toBe('/v1/messages');
|
||||
expect(request.body.messages).toEqual([
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: 'describe this' },
|
||||
{
|
||||
type: 'image',
|
||||
cache_control: { type: 'ephemeral' },
|
||||
source: {
|
||||
type: 'url',
|
||||
url: 'https://example.com/cat.png',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('preserves multimodal user content when converting chat to responses', () => {
|
||||
const request = buildUpstreamEndpointRequest({
|
||||
endpoint: 'responses',
|
||||
modelName: 'gpt-4.1',
|
||||
stream: false,
|
||||
tokenValue: 'sk-test',
|
||||
sitePlatform: 'openai',
|
||||
siteUrl: 'https://example.com',
|
||||
openaiBody: {
|
||||
model: 'gpt-4o-mini',
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: 'describe this' },
|
||||
{ type: 'image_url', image_url: { url: 'https://example.com/cat.png' } },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
downstreamFormat: 'openai',
|
||||
});
|
||||
|
||||
expect(request.path).toBe('/v1/responses');
|
||||
expect(request.body.input).toEqual([
|
||||
{
|
||||
type: 'message',
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'input_text', text: 'describe this' },
|
||||
{ type: 'input_image', image_url: { url: 'https://example.com/cat.png' } },
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('applies global responses standardization and drops non-standard fields', () => {
|
||||
const request = buildUpstreamEndpointRequest({
|
||||
endpoint: 'responses',
|
||||
@@ -419,4 +497,283 @@ describe('buildUpstreamEndpointRequest', () => {
|
||||
expect(request.headers['content-type']).toBeUndefined();
|
||||
expect(request.headers['Content-Type']).toBe('application/json');
|
||||
});
|
||||
|
||||
it('preserves structured responses content blocks instead of flattening them', () => {
|
||||
const request = buildUpstreamEndpointRequest({
|
||||
endpoint: 'responses',
|
||||
modelName: 'upstream-gpt',
|
||||
stream: false,
|
||||
tokenValue: 'sk-test',
|
||||
sitePlatform: 'openai',
|
||||
siteUrl: 'https://example.com',
|
||||
openaiBody: {},
|
||||
downstreamFormat: 'responses',
|
||||
responsesOriginalBody: {
|
||||
model: 'gpt-5.2',
|
||||
input: [
|
||||
{
|
||||
type: 'message',
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'input_text', text: 'describe this image' },
|
||||
{ type: 'input_image', image_url: 'https://example.com/cat.png' },
|
||||
{
|
||||
type: 'input_audio',
|
||||
input_audio: {
|
||||
data: 'UklGRg==',
|
||||
format: 'wav',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(request.path).toBe('/v1/responses');
|
||||
expect(request.body.input).toEqual([
|
||||
{
|
||||
type: 'message',
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'input_text', text: 'describe this image' },
|
||||
{ type: 'input_image', image_url: 'https://example.com/cat.png' },
|
||||
{
|
||||
type: 'input_audio',
|
||||
input_audio: {
|
||||
data: 'UklGRg==',
|
||||
format: 'wav',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('preserves multimodal OpenAI user content when converting to Anthropic messages', () => {
|
||||
const request = buildUpstreamEndpointRequest({
|
||||
endpoint: 'messages',
|
||||
modelName: 'claude-sonnet-4-5',
|
||||
stream: false,
|
||||
tokenValue: 'sk-test',
|
||||
sitePlatform: 'claude',
|
||||
siteUrl: 'https://example.com',
|
||||
downstreamFormat: 'openai',
|
||||
openaiBody: {
|
||||
model: 'claude-sonnet-4-5',
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: 'be careful',
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: 'describe this image' },
|
||||
{ type: 'image_url', image_url: { url: 'https://example.com/cat.png' } },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(request.path).toBe('/v1/messages');
|
||||
expect(request.body.system).toBe('be careful');
|
||||
expect(request.body.messages).toEqual([
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: 'describe this image' },
|
||||
{
|
||||
type: 'image',
|
||||
cache_control: { type: 'ephemeral' },
|
||||
source: {
|
||||
type: 'url',
|
||||
url: 'https://example.com/cat.png',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('preserves assistant reasoning blocks for Anthropic and strips cache_control from thinking blocks', () => {
|
||||
const request = buildUpstreamEndpointRequest({
|
||||
endpoint: 'messages',
|
||||
modelName: 'claude-sonnet-4-5',
|
||||
stream: false,
|
||||
tokenValue: 'sk-test',
|
||||
sitePlatform: 'claude',
|
||||
siteUrl: 'https://example.com',
|
||||
downstreamFormat: 'openai',
|
||||
openaiBody: {
|
||||
model: 'claude-sonnet-4-5',
|
||||
messages: [
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'reasoning',
|
||||
text: 'internal thinking',
|
||||
cache_control: { type: 'ephemeral' },
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
text: 'final answer',
|
||||
cache_control: { type: 'ephemeral' },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(request.path).toBe('/v1/messages');
|
||||
expect(request.body.messages).toEqual([
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'thinking',
|
||||
thinking: 'internal thinking',
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
text: 'final answer',
|
||||
cache_control: { type: 'ephemeral' },
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('preserves multimodal OpenAI user content when converting to Responses input', () => {
|
||||
const request = buildUpstreamEndpointRequest({
|
||||
endpoint: 'responses',
|
||||
modelName: 'gpt-5.2',
|
||||
stream: false,
|
||||
tokenValue: 'sk-test',
|
||||
sitePlatform: 'openai',
|
||||
siteUrl: 'https://example.com',
|
||||
downstreamFormat: 'openai',
|
||||
openaiBody: {
|
||||
model: 'gpt-5.2',
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: 'describe the upload' },
|
||||
{ type: 'image_url', image_url: { url: 'https://example.com/cat.png' } },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(request.path).toBe('/v1/responses');
|
||||
expect(request.body.input).toEqual([
|
||||
{
|
||||
type: 'message',
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'input_text', text: 'describe the upload' },
|
||||
{ type: 'input_image', image_url: { url: 'https://example.com/cat.png' } },
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('preserves Anthropic image and tool_result blocks instead of flattening to plain text', () => {
|
||||
const request = buildUpstreamEndpointRequest({
|
||||
endpoint: 'messages',
|
||||
modelName: 'claude-opus-4-6',
|
||||
stream: false,
|
||||
tokenValue: 'sk-test',
|
||||
sitePlatform: 'claude',
|
||||
siteUrl: 'https://example.com',
|
||||
openaiBody: {
|
||||
model: 'gpt-5.2',
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: 'system prompt',
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: 'look at this' },
|
||||
{ type: 'image_url', image_url: 'https://example.com/cat.png' },
|
||||
],
|
||||
},
|
||||
{
|
||||
role: 'assistant',
|
||||
tool_calls: [
|
||||
{
|
||||
id: 'call_1',
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'lookup',
|
||||
arguments: '{"topic":"cat"}',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: 'tool',
|
||||
tool_call_id: 'call_1',
|
||||
content: '{"ok":true}',
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: 'thanks',
|
||||
},
|
||||
],
|
||||
},
|
||||
downstreamFormat: 'openai',
|
||||
});
|
||||
|
||||
expect(request.path).toBe('/v1/messages');
|
||||
expect(request.body.system).toBe('system prompt');
|
||||
expect(request.body.messages).toEqual([
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: 'look at this' },
|
||||
{
|
||||
type: 'image',
|
||||
source: {
|
||||
type: 'url',
|
||||
url: 'https://example.com/cat.png',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_use',
|
||||
id: 'call_1',
|
||||
name: 'lookup',
|
||||
input: { topic: 'cat' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_result',
|
||||
tool_use_id: 'call_1',
|
||||
content: '{"ok":true}',
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
text: 'thanks',
|
||||
cache_control: { type: 'ephemeral' },
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import { fetchModelPricingCatalog } from '../../services/modelPricingService.js';
|
||||
import type { DownstreamFormat } from './chatFormats.js';
|
||||
import type { DownstreamFormat } from '../../transformers/shared/normalized.js';
|
||||
import {
|
||||
convertOpenAiBodyToResponsesBody as convertOpenAiBodyToResponsesBodyViaTransformer,
|
||||
sanitizeResponsesBodyForProxy as sanitizeResponsesBodyForProxyViaTransformer,
|
||||
} from '../../transformers/openai/responses/conversion.js';
|
||||
import {
|
||||
convertOpenAiBodyToAnthropicMessagesBody,
|
||||
sanitizeAnthropicMessagesBody,
|
||||
} from '../../transformers/anthropic/messages/conversion.js';
|
||||
|
||||
export type UpstreamEndpoint = 'chat' | 'messages' | 'responses';
|
||||
export type EndpointPreference = DownstreamFormat | 'responses';
|
||||
@@ -170,660 +178,6 @@ function toFiniteNumber(value: unknown): number | null {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
function sanitizeMessagesBodyForAnthropic(body: Record<string, unknown>): Record<string, unknown> {
|
||||
const sanitized: Record<string, unknown> = { ...body };
|
||||
const hasTemperature = toFiniteNumber(sanitized.temperature) !== null;
|
||||
const hasTopP = toFiniteNumber(sanitized.top_p) !== null;
|
||||
// Some Anthropic-compatible upstreams reject requests carrying both fields.
|
||||
if (hasTemperature && hasTopP) {
|
||||
delete sanitized.top_p;
|
||||
}
|
||||
|
||||
// Claude Code may send thinking.type = "adaptive". Many Anthropic-compatible
|
||||
// upstreams only accept enabled/disabled.
|
||||
const thinking = sanitized.thinking;
|
||||
if (isRecord(thinking)) {
|
||||
const rawType = asTrimmedString(thinking.type).toLowerCase();
|
||||
if (rawType === 'adaptive') {
|
||||
const budgetTokens = toFiniteNumber(thinking.budget_tokens);
|
||||
if (budgetTokens !== null && budgetTokens > 0) {
|
||||
sanitized.thinking = {
|
||||
...thinking,
|
||||
type: 'enabled',
|
||||
budget_tokens: Math.trunc(budgetTokens),
|
||||
};
|
||||
} else {
|
||||
sanitized.thinking = { type: 'disabled' };
|
||||
}
|
||||
} else if (rawType && rawType !== 'enabled' && rawType !== 'disabled') {
|
||||
sanitized.thinking = { type: 'disabled' };
|
||||
}
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
function normalizeContentText(content: unknown): string {
|
||||
if (typeof content === 'string') return content;
|
||||
if (Array.isArray(content)) {
|
||||
return content
|
||||
.map((item) => {
|
||||
if (typeof item === 'string') return item;
|
||||
if (!isRecord(item)) return '';
|
||||
const text = asTrimmedString(item.text ?? item.content ?? item.output_text);
|
||||
return text;
|
||||
})
|
||||
.filter((item) => item.length > 0)
|
||||
.join('\n');
|
||||
}
|
||||
if (isRecord(content)) {
|
||||
const text = asTrimmedString(content.text ?? content.content ?? content.output_text);
|
||||
return text;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function safeJsonStringify(value: unknown): string {
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function parseJsonString(raw: string): unknown {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return {};
|
||||
try {
|
||||
return JSON.parse(trimmed);
|
||||
} catch {
|
||||
return { value: raw };
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeOpenAiToolArguments(raw: unknown): string {
|
||||
if (typeof raw === 'string') return raw;
|
||||
if (raw === undefined || raw === null) return '';
|
||||
if (typeof raw === 'number' || typeof raw === 'boolean') return String(raw);
|
||||
if (Array.isArray(raw) || isRecord(raw)) {
|
||||
return safeJsonStringify(raw);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function normalizeAnthropicToolInput(raw: unknown): unknown {
|
||||
if (raw === undefined || raw === null) return {};
|
||||
if (isRecord(raw) || Array.isArray(raw)) return raw;
|
||||
if (typeof raw === 'string') return parseJsonString(raw);
|
||||
if (typeof raw === 'number' || typeof raw === 'boolean') return raw;
|
||||
return {};
|
||||
}
|
||||
|
||||
function normalizeToolMessageContent(raw: unknown): string {
|
||||
const contentText = normalizeContentText(raw).trim();
|
||||
if (contentText) return contentText;
|
||||
|
||||
if (raw === undefined || raw === null) return '';
|
||||
if (typeof raw === 'string') return raw;
|
||||
if (typeof raw === 'number' || typeof raw === 'boolean') return String(raw);
|
||||
if (Array.isArray(raw) || isRecord(raw)) return safeJsonStringify(raw);
|
||||
return '';
|
||||
}
|
||||
|
||||
function convertOpenAiToolsToAnthropic(rawTools: unknown): unknown {
|
||||
if (!Array.isArray(rawTools)) return rawTools;
|
||||
|
||||
const converted = rawTools
|
||||
.map((item) => {
|
||||
if (!isRecord(item)) return null;
|
||||
|
||||
const type = asTrimmedString(item.type).toLowerCase();
|
||||
if (type === 'function' && isRecord(item.function)) {
|
||||
const fn = item.function;
|
||||
const name = asTrimmedString(fn.name);
|
||||
if (!name) return null;
|
||||
|
||||
const mapped: Record<string, unknown> = { name };
|
||||
const description = asTrimmedString(fn.description);
|
||||
if (description) mapped.description = description;
|
||||
if (fn.parameters !== undefined) mapped.input_schema = fn.parameters;
|
||||
if (item.cache_control !== undefined) mapped.cache_control = item.cache_control;
|
||||
return mapped;
|
||||
}
|
||||
|
||||
if (asTrimmedString(item.name) && item.input_schema !== undefined) {
|
||||
return item;
|
||||
}
|
||||
|
||||
return null;
|
||||
})
|
||||
.filter((item): item is Record<string, unknown> => !!item);
|
||||
|
||||
return converted.length > 0 ? converted : rawTools;
|
||||
}
|
||||
|
||||
function convertOpenAiToolChoiceToAnthropic(rawToolChoice: unknown): unknown {
|
||||
if (rawToolChoice === undefined) return undefined;
|
||||
|
||||
if (typeof rawToolChoice === 'string') {
|
||||
const normalized = rawToolChoice.trim().toLowerCase();
|
||||
if (normalized === 'required') return { type: 'any' };
|
||||
if (normalized === 'none') return { type: 'none' };
|
||||
if (normalized === 'auto') return { type: 'auto' };
|
||||
if (normalized === 'any') return { type: 'any' };
|
||||
return rawToolChoice;
|
||||
}
|
||||
|
||||
if (!isRecord(rawToolChoice)) return rawToolChoice;
|
||||
|
||||
const type = asTrimmedString(rawToolChoice.type).toLowerCase();
|
||||
if (type === 'function' && isRecord(rawToolChoice.function)) {
|
||||
const name = asTrimmedString(rawToolChoice.function.name);
|
||||
return name ? { type: 'tool', name } : { type: 'any' };
|
||||
}
|
||||
|
||||
if (type === 'tool' || type === 'auto' || type === 'any' || type === 'none') {
|
||||
return rawToolChoice;
|
||||
}
|
||||
|
||||
return rawToolChoice;
|
||||
}
|
||||
|
||||
function convertOpenAiToolsToResponses(rawTools: unknown): unknown {
|
||||
if (!Array.isArray(rawTools)) return rawTools;
|
||||
|
||||
const converted = rawTools
|
||||
.map((item) => {
|
||||
if (!isRecord(item)) return null;
|
||||
|
||||
const type = asTrimmedString(item.type).toLowerCase();
|
||||
if (type === 'function' && isRecord(item.function)) {
|
||||
const fn = item.function;
|
||||
const name = asTrimmedString(fn.name);
|
||||
if (!name) return null;
|
||||
|
||||
const mapped: Record<string, unknown> = {
|
||||
type: 'function',
|
||||
name,
|
||||
};
|
||||
const description = asTrimmedString(fn.description);
|
||||
if (description) mapped.description = description;
|
||||
if (fn.parameters !== undefined) mapped.parameters = fn.parameters;
|
||||
if (fn.strict !== undefined) mapped.strict = fn.strict;
|
||||
return mapped;
|
||||
}
|
||||
|
||||
if (type === 'function' && asTrimmedString(item.name)) {
|
||||
return item;
|
||||
}
|
||||
|
||||
if (type === 'image_generation') {
|
||||
return item;
|
||||
}
|
||||
|
||||
return null;
|
||||
})
|
||||
.filter((item): item is Record<string, unknown> => !!item);
|
||||
|
||||
return converted.length > 0 ? converted : rawTools;
|
||||
}
|
||||
|
||||
function convertOpenAiToolChoiceToResponses(rawToolChoice: unknown): unknown {
|
||||
if (rawToolChoice === undefined) return undefined;
|
||||
if (typeof rawToolChoice === 'string') return rawToolChoice;
|
||||
if (!isRecord(rawToolChoice)) return rawToolChoice;
|
||||
|
||||
const type = asTrimmedString(rawToolChoice.type).toLowerCase();
|
||||
if (type === 'function' && isRecord(rawToolChoice.function)) {
|
||||
const name = asTrimmedString(rawToolChoice.function.name);
|
||||
if (!name) return 'required';
|
||||
return { type: 'function', name };
|
||||
}
|
||||
|
||||
return rawToolChoice;
|
||||
}
|
||||
|
||||
function toResponsesInputMessageFromText(text: string): Record<string, unknown> {
|
||||
return {
|
||||
type: 'message',
|
||||
role: 'user',
|
||||
content: [{ type: 'input_text', text }],
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeResponsesMessageItem(item: Record<string, unknown>): Record<string, unknown> {
|
||||
const type = asTrimmedString(item.type).toLowerCase();
|
||||
if (type === 'function_call' || type === 'function_call_output') {
|
||||
return item;
|
||||
}
|
||||
|
||||
const role = asTrimmedString(item.role).toLowerCase();
|
||||
const textContent = normalizeContentText(item.content ?? item.text).trim();
|
||||
|
||||
if (type === 'message') {
|
||||
if (!textContent) return item;
|
||||
const normalizedRole = role || 'user';
|
||||
const textType = normalizedRole === 'assistant' ? 'output_text' : 'input_text';
|
||||
return {
|
||||
...item,
|
||||
role: normalizedRole,
|
||||
content: [{ type: textType, text: textContent }],
|
||||
};
|
||||
}
|
||||
|
||||
if (role) {
|
||||
if (!textContent) return item;
|
||||
const textType = role === 'assistant' ? 'output_text' : 'input_text';
|
||||
return {
|
||||
type: 'message',
|
||||
role,
|
||||
content: [{ type: textType, text: textContent }],
|
||||
};
|
||||
}
|
||||
|
||||
if (textContent) {
|
||||
return toResponsesInputMessageFromText(textContent);
|
||||
}
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
function normalizeResponsesInputForCompatibility(input: unknown): unknown {
|
||||
if (typeof input === 'string') {
|
||||
const normalized = input.trim();
|
||||
if (!normalized) return input;
|
||||
return [toResponsesInputMessageFromText(normalized)];
|
||||
}
|
||||
|
||||
if (Array.isArray(input)) {
|
||||
return input.map((item) => {
|
||||
if (typeof item === 'string') {
|
||||
const normalized = item.trim();
|
||||
return normalized ? toResponsesInputMessageFromText(normalized) : item;
|
||||
}
|
||||
if (!isRecord(item)) return item;
|
||||
return normalizeResponsesMessageItem(item);
|
||||
});
|
||||
}
|
||||
|
||||
if (isRecord(input)) {
|
||||
return [normalizeResponsesMessageItem(input)];
|
||||
}
|
||||
|
||||
return input;
|
||||
}
|
||||
|
||||
function normalizeResponsesBodyForCompatibility(
|
||||
body: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
const nextInput = normalizeResponsesInputForCompatibility(body.input);
|
||||
if (nextInput === body.input) return body;
|
||||
return {
|
||||
...body,
|
||||
input: nextInput,
|
||||
};
|
||||
}
|
||||
|
||||
const ALLOWED_RESPONSES_FIELDS = new Set([
|
||||
'model',
|
||||
'input',
|
||||
'instructions',
|
||||
'max_output_tokens',
|
||||
'max_completion_tokens',
|
||||
'temperature',
|
||||
'top_p',
|
||||
'truncation',
|
||||
'tools',
|
||||
'tool_choice',
|
||||
'parallel_tool_calls',
|
||||
'metadata',
|
||||
'reasoning',
|
||||
'store',
|
||||
'stream',
|
||||
'user',
|
||||
'previous_response_id',
|
||||
'text',
|
||||
'audio',
|
||||
'include',
|
||||
'response_format',
|
||||
'service_tier',
|
||||
'stop',
|
||||
'n',
|
||||
]);
|
||||
|
||||
function sanitizeResponsesBodyForProxy(
|
||||
body: Record<string, unknown>,
|
||||
modelName: string,
|
||||
stream: boolean,
|
||||
): Record<string, unknown> {
|
||||
let normalized = normalizeResponsesBodyForCompatibility({
|
||||
...body,
|
||||
model: modelName,
|
||||
stream,
|
||||
});
|
||||
|
||||
if (normalized.input === undefined) {
|
||||
if (Array.isArray((normalized as Record<string, unknown>).messages)) {
|
||||
const converted = convertOpenAiBodyToResponsesBody(normalized, modelName, stream);
|
||||
normalized = normalizeResponsesBodyForCompatibility(converted);
|
||||
} else {
|
||||
const prompt = asTrimmedString((normalized as Record<string, unknown>).prompt);
|
||||
if (prompt) {
|
||||
normalized = {
|
||||
...normalized,
|
||||
input: [toResponsesInputMessageFromText(prompt)],
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const sanitized: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(normalized)) {
|
||||
if (!ALLOWED_RESPONSES_FIELDS.has(key)) continue;
|
||||
if (key === 'max_completion_tokens') continue;
|
||||
sanitized[key] = value;
|
||||
}
|
||||
|
||||
const maxOutputTokens = toFiniteNumber(normalized.max_output_tokens);
|
||||
if (maxOutputTokens !== null && maxOutputTokens > 0) {
|
||||
sanitized.max_output_tokens = Math.trunc(maxOutputTokens);
|
||||
} else {
|
||||
const maxCompletionTokens = toFiniteNumber(normalized.max_completion_tokens);
|
||||
if (maxCompletionTokens !== null && maxCompletionTokens > 0) {
|
||||
sanitized.max_output_tokens = Math.trunc(maxCompletionTokens);
|
||||
}
|
||||
}
|
||||
|
||||
sanitized.model = modelName;
|
||||
sanitized.stream = stream;
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
function convertOpenAiBodyToMessagesBody(
|
||||
openaiBody: Record<string, unknown>,
|
||||
modelName: string,
|
||||
stream: boolean,
|
||||
): Record<string, unknown> {
|
||||
const rawMessages = Array.isArray(openaiBody.messages) ? openaiBody.messages : [];
|
||||
const systemContents: string[] = [];
|
||||
const messages: Array<{
|
||||
role: 'user' | 'assistant';
|
||||
content: string | Array<Record<string, unknown>>;
|
||||
}> = [];
|
||||
|
||||
const appendAssistantMessage = (item: Record<string, unknown>) => {
|
||||
const role = asTrimmedString(item.role).toLowerCase() || 'assistant';
|
||||
if (role !== 'assistant') return;
|
||||
|
||||
const contentBlocks: Array<Record<string, unknown>> = [];
|
||||
|
||||
const textContent = normalizeContentText(item.content).trim();
|
||||
if (textContent) {
|
||||
contentBlocks.push({
|
||||
type: 'text',
|
||||
text: textContent,
|
||||
});
|
||||
}
|
||||
|
||||
const rawToolCalls = Array.isArray(item.tool_calls) ? item.tool_calls : [];
|
||||
for (let index = 0; index < rawToolCalls.length; index += 1) {
|
||||
const toolCall = rawToolCalls[index];
|
||||
if (!isRecord(toolCall)) continue;
|
||||
const functionPart = isRecord(toolCall.function) ? toolCall.function : {};
|
||||
|
||||
const id = asTrimmedString(toolCall.id) || `toolu_${Date.now()}_${index}`;
|
||||
const name = (
|
||||
asTrimmedString(functionPart.name)
|
||||
|| asTrimmedString(toolCall.name)
|
||||
|| `tool_${index}`
|
||||
);
|
||||
const input = normalizeAnthropicToolInput(functionPart.arguments ?? toolCall.arguments);
|
||||
|
||||
contentBlocks.push({
|
||||
type: 'tool_use',
|
||||
id,
|
||||
name,
|
||||
input,
|
||||
});
|
||||
}
|
||||
|
||||
if (contentBlocks.length <= 0) return;
|
||||
|
||||
if (contentBlocks.length === 1 && contentBlocks[0].type === 'text') {
|
||||
const singleText = asTrimmedString(contentBlocks[0].text);
|
||||
if (singleText) {
|
||||
messages.push({
|
||||
role: 'assistant',
|
||||
content: singleText,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
messages.push({
|
||||
role: 'assistant',
|
||||
content: contentBlocks,
|
||||
});
|
||||
};
|
||||
|
||||
for (let messageIndex = 0; messageIndex < rawMessages.length; messageIndex += 1) {
|
||||
const item = rawMessages[messageIndex];
|
||||
if (!isRecord(item)) continue;
|
||||
const role = asTrimmedString(item.role).toLowerCase() || 'user';
|
||||
|
||||
if (role === 'system' || role === 'developer') {
|
||||
const content = normalizeContentText(item.content);
|
||||
if (!content) continue;
|
||||
systemContents.push(content);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (role === 'tool') {
|
||||
const toolResultBlocks: Array<Record<string, unknown>> = [];
|
||||
let cursor = messageIndex;
|
||||
while (cursor < rawMessages.length) {
|
||||
const toolCandidate = rawMessages[cursor];
|
||||
if (!isRecord(toolCandidate)) break;
|
||||
const toolRole = asTrimmedString(toolCandidate.role).toLowerCase();
|
||||
if (toolRole !== 'tool') break;
|
||||
|
||||
const toolUseId = (
|
||||
asTrimmedString(toolCandidate.tool_call_id)
|
||||
|| asTrimmedString(toolCandidate.id)
|
||||
);
|
||||
const toolResultContent = normalizeToolMessageContent(toolCandidate.content).trim();
|
||||
if (toolUseId && toolResultContent) {
|
||||
toolResultBlocks.push({
|
||||
type: 'tool_result',
|
||||
tool_use_id: toolUseId,
|
||||
content: toolResultContent,
|
||||
});
|
||||
}
|
||||
cursor += 1;
|
||||
}
|
||||
|
||||
if (toolResultBlocks.length > 0 && cursor < rawMessages.length) {
|
||||
const nextItem = rawMessages[cursor];
|
||||
if (isRecord(nextItem)) {
|
||||
const nextRole = asTrimmedString(nextItem.role).toLowerCase();
|
||||
if (nextRole === 'user') {
|
||||
const userText = normalizeContentText(nextItem.content).trim();
|
||||
if (userText) {
|
||||
toolResultBlocks.push({
|
||||
type: 'text',
|
||||
text: userText,
|
||||
});
|
||||
}
|
||||
cursor += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (toolResultBlocks.length > 0) {
|
||||
messages.push({
|
||||
role: 'user',
|
||||
content: toolResultBlocks,
|
||||
});
|
||||
}
|
||||
|
||||
messageIndex = cursor - 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (role === 'assistant') {
|
||||
appendAssistantMessage(item);
|
||||
continue;
|
||||
}
|
||||
|
||||
const content = normalizeContentText(item.content).trim();
|
||||
if (!content) continue;
|
||||
|
||||
messages.push({
|
||||
role: 'user',
|
||||
content,
|
||||
});
|
||||
}
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
model: modelName,
|
||||
stream,
|
||||
messages,
|
||||
max_tokens: toFiniteNumber(openaiBody.max_tokens) ?? 4096,
|
||||
};
|
||||
|
||||
if (systemContents.length > 0) {
|
||||
body.system = systemContents.join('\n\n');
|
||||
}
|
||||
|
||||
const temperature = toFiniteNumber(openaiBody.temperature);
|
||||
if (temperature !== null) body.temperature = temperature;
|
||||
|
||||
const topP = toFiniteNumber(openaiBody.top_p);
|
||||
if (topP !== null) body.top_p = topP;
|
||||
|
||||
if (Array.isArray(openaiBody.stop) && openaiBody.stop.length > 0) {
|
||||
body.stop_sequences = openaiBody.stop;
|
||||
}
|
||||
|
||||
if (openaiBody.tools !== undefined) body.tools = convertOpenAiToolsToAnthropic(openaiBody.tools);
|
||||
|
||||
const anthropicToolChoice = convertOpenAiToolChoiceToAnthropic(openaiBody.tool_choice);
|
||||
if (anthropicToolChoice !== undefined) body.tool_choice = anthropicToolChoice;
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
function convertOpenAiBodyToResponsesBody(
|
||||
openaiBody: Record<string, unknown>,
|
||||
modelName: string,
|
||||
stream: boolean,
|
||||
): Record<string, unknown> {
|
||||
const rawMessages = Array.isArray(openaiBody.messages) ? openaiBody.messages : [];
|
||||
const systemContents: string[] = [];
|
||||
const inputItems: Array<Record<string, unknown>> = [];
|
||||
|
||||
for (const item of rawMessages) {
|
||||
if (!isRecord(item)) continue;
|
||||
const role = asTrimmedString(item.role).toLowerCase() || 'user';
|
||||
|
||||
if (role === 'system' || role === 'developer') {
|
||||
const content = normalizeContentText(item.content).trim();
|
||||
if (!content) continue;
|
||||
systemContents.push(content);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (role === 'assistant') {
|
||||
const textContent = normalizeContentText(item.content).trim();
|
||||
if (textContent) {
|
||||
inputItems.push({
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
content: [{ type: 'output_text', text: textContent }],
|
||||
});
|
||||
}
|
||||
|
||||
const rawToolCalls = Array.isArray(item.tool_calls) ? item.tool_calls : [];
|
||||
for (let index = 0; index < rawToolCalls.length; index += 1) {
|
||||
const toolCall = rawToolCalls[index];
|
||||
if (!isRecord(toolCall)) continue;
|
||||
const functionPart = isRecord(toolCall.function) ? toolCall.function : {};
|
||||
const callId = asTrimmedString(toolCall.id) || `call_${Date.now()}_${index}`;
|
||||
const name = (
|
||||
asTrimmedString(functionPart.name)
|
||||
|| asTrimmedString(toolCall.name)
|
||||
|| `tool_${index}`
|
||||
);
|
||||
const argumentsValue = normalizeOpenAiToolArguments(
|
||||
functionPart.arguments ?? toolCall.arguments,
|
||||
);
|
||||
|
||||
inputItems.push({
|
||||
type: 'function_call',
|
||||
call_id: callId,
|
||||
name,
|
||||
arguments: argumentsValue,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (role === 'tool') {
|
||||
const callId = asTrimmedString(item.tool_call_id) || asTrimmedString(item.id);
|
||||
if (!callId) continue;
|
||||
|
||||
inputItems.push({
|
||||
type: 'function_call_output',
|
||||
call_id: callId,
|
||||
output: normalizeToolMessageContent(item.content),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const content = normalizeContentText(item.content).trim();
|
||||
if (!content) continue;
|
||||
inputItems.push({
|
||||
type: 'message',
|
||||
role: 'user',
|
||||
content: [{ type: 'input_text', text: content }],
|
||||
});
|
||||
}
|
||||
|
||||
const maxOutputTokens = (
|
||||
toFiniteNumber(openaiBody.max_output_tokens)
|
||||
?? toFiniteNumber(openaiBody.max_completion_tokens)
|
||||
?? toFiniteNumber(openaiBody.max_tokens)
|
||||
?? 4096
|
||||
);
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
model: modelName,
|
||||
stream,
|
||||
max_output_tokens: maxOutputTokens,
|
||||
input: inputItems,
|
||||
};
|
||||
|
||||
if (systemContents.length > 0) {
|
||||
body.instructions = systemContents.join('\n\n');
|
||||
}
|
||||
|
||||
const temperature = toFiniteNumber(openaiBody.temperature);
|
||||
if (temperature !== null) body.temperature = temperature;
|
||||
|
||||
const topP = toFiniteNumber(openaiBody.top_p);
|
||||
if (topP !== null) body.top_p = topP;
|
||||
|
||||
if (openaiBody.metadata !== undefined) body.metadata = openaiBody.metadata;
|
||||
if (openaiBody.reasoning !== undefined) body.reasoning = openaiBody.reasoning;
|
||||
if (openaiBody.parallel_tool_calls !== undefined) body.parallel_tool_calls = openaiBody.parallel_tool_calls;
|
||||
|
||||
if (openaiBody.tools !== undefined) body.tools = convertOpenAiToolsToResponses(openaiBody.tools);
|
||||
|
||||
const responsesToolChoice = convertOpenAiToolChoiceToResponses(openaiBody.tool_choice);
|
||||
if (responsesToolChoice !== undefined) body.tool_choice = responsesToolChoice;
|
||||
|
||||
return normalizeResponsesBodyForCompatibility(body);
|
||||
}
|
||||
|
||||
function normalizeEndpointTypes(value: unknown): UpstreamEndpoint[] {
|
||||
const raw = asTrimmedString(value).toLowerCase();
|
||||
@@ -1104,9 +458,9 @@ export function buildUpstreamEndpointRequest(input: {
|
||||
model: input.modelName,
|
||||
stream: input.stream,
|
||||
}
|
||||
: convertOpenAiBodyToMessagesBody(input.openaiBody, input.modelName, input.stream)
|
||||
: convertOpenAiBodyToAnthropicMessagesBody(input.openaiBody, input.modelName, input.stream)
|
||||
);
|
||||
const sanitizedBody = sanitizeMessagesBodyForAnthropic(body);
|
||||
const sanitizedBody = sanitizeAnthropicMessagesBody(body);
|
||||
|
||||
const headers = ensureStreamAcceptHeader({
|
||||
...commonHeaders,
|
||||
@@ -1133,9 +487,9 @@ export function buildUpstreamEndpointRequest(input: {
|
||||
model: input.modelName,
|
||||
stream: input.stream,
|
||||
}
|
||||
: convertOpenAiBodyToResponsesBody(input.openaiBody, input.modelName, input.stream)
|
||||
: convertOpenAiBodyToResponsesBodyViaTransformer(input.openaiBody, input.modelName, input.stream)
|
||||
);
|
||||
const body = sanitizeResponsesBodyForProxy(rawBody, input.modelName, input.stream);
|
||||
const body = sanitizeResponsesBodyForProxyViaTransformer(rawBody, input.modelName, input.stream);
|
||||
|
||||
const headers = ensureStreamAcceptHeader({
|
||||
...commonHeaders,
|
||||
@@ -1291,3 +645,4 @@ export function isEndpointDowngradeError(status: number, upstreamErrorText?: str
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const fetchMock = vi.fn();
|
||||
const selectChannelMock = vi.fn();
|
||||
const selectNextChannelMock = vi.fn();
|
||||
const recordSuccessMock = vi.fn();
|
||||
const recordFailureMock = vi.fn();
|
||||
const refreshModelsAndRebuildRoutesMock = vi.fn();
|
||||
const reportProxyAllFailedMock = vi.fn();
|
||||
const reportTokenExpiredMock = vi.fn();
|
||||
const estimateProxyCostMock = vi.fn(async () => 0);
|
||||
const saveProxyVideoTaskMock = vi.fn();
|
||||
const getProxyVideoTaskByPublicIdMock = vi.fn();
|
||||
const deleteProxyVideoTaskByPublicIdMock = vi.fn();
|
||||
const refreshProxyVideoTaskSnapshotMock = vi.fn();
|
||||
|
||||
vi.mock('undici', async () => {
|
||||
const actual = await vi.importActual<typeof import('undici')>('undici');
|
||||
return {
|
||||
...actual,
|
||||
fetch: (...args: unknown[]) => fetchMock(...args),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../services/tokenRouter.js', () => ({
|
||||
tokenRouter: {
|
||||
selectChannel: (...args: unknown[]) => selectChannelMock(...args),
|
||||
selectNextChannel: (...args: unknown[]) => selectNextChannelMock(...args),
|
||||
recordSuccess: (...args: unknown[]) => recordSuccessMock(...args),
|
||||
recordFailure: (...args: unknown[]) => recordFailureMock(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../services/modelService.js', () => ({
|
||||
refreshModelsAndRebuildRoutes: (...args: unknown[]) => refreshModelsAndRebuildRoutesMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/alertService.js', () => ({
|
||||
reportProxyAllFailed: (...args: unknown[]) => reportProxyAllFailedMock(...args),
|
||||
reportTokenExpired: (...args: unknown[]) => reportTokenExpiredMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/alertRules.js', () => ({
|
||||
isTokenExpiredError: () => false,
|
||||
}));
|
||||
|
||||
vi.mock('../../services/modelPricingService.js', () => ({
|
||||
estimateProxyCost: (arg: any) => estimateProxyCostMock(arg),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/proxyRetryPolicy.js', () => ({
|
||||
shouldRetryProxyRequest: () => false,
|
||||
}));
|
||||
|
||||
vi.mock('../../services/proxyVideoTaskStore.js', () => ({
|
||||
saveProxyVideoTask: (...args: unknown[]) => saveProxyVideoTaskMock(...args),
|
||||
getProxyVideoTaskByPublicId: (...args: unknown[]) => getProxyVideoTaskByPublicIdMock(...args),
|
||||
deleteProxyVideoTaskByPublicId: (...args: unknown[]) => deleteProxyVideoTaskByPublicIdMock(...args),
|
||||
refreshProxyVideoTaskSnapshot: (...args: unknown[]) => refreshProxyVideoTaskSnapshotMock(...args),
|
||||
}));
|
||||
|
||||
describe('/v1/videos routes', () => {
|
||||
let app: FastifyInstance;
|
||||
|
||||
const buildMultipartBody = (boundary: string) => Buffer.from(
|
||||
`--${boundary}\r\n`
|
||||
+ `Content-Disposition: form-data; name="model"\r\n\r\n`
|
||||
+ `sora-2\r\n`
|
||||
+ `--${boundary}\r\n`
|
||||
+ `Content-Disposition: form-data; name="prompt"\r\n\r\n`
|
||||
+ `a cat walking\r\n`
|
||||
+ `--${boundary}\r\n`
|
||||
+ `Content-Disposition: form-data; name="input_reference"; filename="cat.png"\r\n`
|
||||
+ `Content-Type: image/png\r\n\r\n`
|
||||
+ `pngdata\r\n`
|
||||
+ `--${boundary}--\r\n`,
|
||||
);
|
||||
|
||||
beforeAll(async () => {
|
||||
const { videosProxyRoute } = await import('./videos.js');
|
||||
app = Fastify();
|
||||
await app.register(videosProxyRoute);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock.mockReset();
|
||||
selectChannelMock.mockReset();
|
||||
selectNextChannelMock.mockReset();
|
||||
recordSuccessMock.mockReset();
|
||||
recordFailureMock.mockReset();
|
||||
refreshModelsAndRebuildRoutesMock.mockReset();
|
||||
reportProxyAllFailedMock.mockReset();
|
||||
reportTokenExpiredMock.mockReset();
|
||||
estimateProxyCostMock.mockClear();
|
||||
saveProxyVideoTaskMock.mockReset();
|
||||
getProxyVideoTaskByPublicIdMock.mockReset();
|
||||
deleteProxyVideoTaskByPublicIdMock.mockReset();
|
||||
refreshProxyVideoTaskSnapshotMock.mockReset();
|
||||
|
||||
selectChannelMock.mockReturnValue({
|
||||
channel: { id: 11, routeId: 22 },
|
||||
site: { id: 44, name: 'demo-site', url: 'https://upstream.example.com', platform: 'openai' },
|
||||
account: { id: 33, username: 'demo-user' },
|
||||
tokenName: 'default',
|
||||
tokenValue: 'sk-demo',
|
||||
actualModel: 'sora-2',
|
||||
});
|
||||
selectNextChannelMock.mockReturnValue(null);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('creates an upstream video task and stores a local public id mapping', async () => {
|
||||
saveProxyVideoTaskMock.mockResolvedValue({
|
||||
publicId: 'vid_local_123',
|
||||
upstreamVideoId: 'vid_upstream_123',
|
||||
});
|
||||
fetchMock.mockResolvedValue(new Response(JSON.stringify({
|
||||
id: 'vid_upstream_123',
|
||||
object: 'video',
|
||||
status: 'queued',
|
||||
model: 'sora-2',
|
||||
prompt: 'a cat walking',
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}));
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/v1/videos',
|
||||
payload: {
|
||||
model: 'sora-2',
|
||||
prompt: 'a cat walking',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(saveProxyVideoTaskMock).toHaveBeenCalledWith(expect.objectContaining({
|
||||
upstreamVideoId: 'vid_upstream_123',
|
||||
requestedModel: 'sora-2',
|
||||
actualModel: 'sora-2',
|
||||
lastUpstreamStatus: 200,
|
||||
statusSnapshot: expect.objectContaining({
|
||||
id: 'vid_upstream_123',
|
||||
status: 'queued',
|
||||
}),
|
||||
}));
|
||||
expect(response.json()).toMatchObject({
|
||||
id: 'vid_local_123',
|
||||
object: 'video',
|
||||
status: 'queued',
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts multipart video create requests', async () => {
|
||||
saveProxyVideoTaskMock.mockResolvedValue({
|
||||
publicId: 'vid_local_456',
|
||||
upstreamVideoId: 'vid_upstream_456',
|
||||
});
|
||||
fetchMock.mockResolvedValue(new Response(JSON.stringify({
|
||||
id: 'vid_upstream_456',
|
||||
object: 'video',
|
||||
status: 'queued',
|
||||
model: 'sora-2',
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}));
|
||||
|
||||
const boundary = 'metapi-video-boundary';
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/v1/videos',
|
||||
headers: {
|
||||
'content-type': `multipart/form-data; boundary=${boundary}`,
|
||||
},
|
||||
payload: buildMultipartBody(boundary),
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toMatchObject({
|
||||
id: 'vid_local_456',
|
||||
object: 'video',
|
||||
status: 'queued',
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves local video ids back to the upstream task on GET', async () => {
|
||||
getProxyVideoTaskByPublicIdMock.mockResolvedValue({
|
||||
publicId: 'vid_local_123',
|
||||
upstreamVideoId: 'vid_upstream_123',
|
||||
siteUrl: 'https://upstream.example.com',
|
||||
tokenValue: 'sk-demo',
|
||||
});
|
||||
fetchMock.mockResolvedValue(new Response(JSON.stringify({
|
||||
id: 'vid_upstream_123',
|
||||
object: 'video',
|
||||
status: 'running',
|
||||
model: 'sora-2',
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}));
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/v1/videos/vid_local_123',
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const [targetUrl] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
expect(targetUrl).toBe('https://upstream.example.com/v1/videos/vid_upstream_123');
|
||||
expect(refreshProxyVideoTaskSnapshotMock).toHaveBeenCalledWith('vid_local_123', expect.objectContaining({
|
||||
lastUpstreamStatus: 200,
|
||||
statusSnapshot: expect.objectContaining({
|
||||
id: 'vid_upstream_123',
|
||||
status: 'running',
|
||||
}),
|
||||
}));
|
||||
expect(response.json()).toMatchObject({
|
||||
id: 'vid_local_123',
|
||||
object: 'video',
|
||||
status: 'running',
|
||||
});
|
||||
});
|
||||
|
||||
it('deletes the upstream task and local mapping on DELETE', async () => {
|
||||
getProxyVideoTaskByPublicIdMock.mockResolvedValue({
|
||||
publicId: 'vid_local_123',
|
||||
upstreamVideoId: 'vid_upstream_123',
|
||||
siteUrl: 'https://upstream.example.com',
|
||||
tokenValue: 'sk-demo',
|
||||
});
|
||||
fetchMock.mockResolvedValue(new Response(null, { status: 204 }));
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'DELETE',
|
||||
url: '/v1/videos/vid_local_123',
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(204);
|
||||
expect(deleteProxyVideoTaskByPublicIdMock).toHaveBeenCalledWith('vid_local_123');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,229 @@
|
||||
import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { fetch } from 'undici';
|
||||
import { tokenRouter } from '../../services/tokenRouter.js';
|
||||
import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js';
|
||||
import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js';
|
||||
import { isTokenExpiredError } from '../../services/alertRules.js';
|
||||
import { estimateProxyCost } from '../../services/modelPricingService.js';
|
||||
import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js';
|
||||
import { ensureModelAllowedForDownstreamKey, getDownstreamRoutingPolicy, recordDownstreamCostUsage } from './downstreamPolicy.js';
|
||||
import { withSiteRecordProxyRequestInit } from '../../services/siteProxy.js';
|
||||
import { cloneFormDataWithOverrides, ensureMultipartBufferParser, parseMultipartFormData } from './multipart.js';
|
||||
import {
|
||||
deleteProxyVideoTaskByPublicId,
|
||||
getProxyVideoTaskByPublicId,
|
||||
refreshProxyVideoTaskSnapshot,
|
||||
saveProxyVideoTask,
|
||||
} from '../../services/proxyVideoTaskStore.js';
|
||||
|
||||
const MAX_RETRIES = 2;
|
||||
|
||||
function rewriteVideoResponsePublicId(payload: unknown, publicId: string): unknown {
|
||||
if (!payload || typeof payload !== 'object') return payload;
|
||||
return {
|
||||
...(payload as Record<string, unknown>),
|
||||
id: publicId,
|
||||
};
|
||||
}
|
||||
|
||||
export async function videosProxyRoute(app: FastifyInstance) {
|
||||
ensureMultipartBufferParser(app);
|
||||
|
||||
app.post('/v1/videos', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const multipartForm = await parseMultipartFormData(request);
|
||||
const jsonBody = (!multipartForm && request.body && typeof request.body === 'object')
|
||||
? request.body as Record<string, unknown>
|
||||
: null;
|
||||
const requestedModel = typeof multipartForm?.get('model') === 'string'
|
||||
? String(multipartForm.get('model')).trim()
|
||||
: (typeof jsonBody?.model === 'string' ? jsonBody.model.trim() : '');
|
||||
|
||||
if (!requestedModel) {
|
||||
return reply.code(400).send({
|
||||
error: { message: 'model is required', type: 'invalid_request_error' },
|
||||
});
|
||||
}
|
||||
if (!await ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return;
|
||||
|
||||
const downstreamPolicy = getDownstreamRoutingPolicy(request);
|
||||
const excludeChannelIds: number[] = [];
|
||||
let retryCount = 0;
|
||||
|
||||
while (retryCount <= MAX_RETRIES) {
|
||||
let selected = retryCount === 0
|
||||
? await tokenRouter.selectChannel(requestedModel, downstreamPolicy)
|
||||
: await tokenRouter.selectNextChannel(requestedModel, excludeChannelIds, downstreamPolicy);
|
||||
|
||||
if (!selected && retryCount === 0) {
|
||||
await refreshModelsAndRebuildRoutes();
|
||||
selected = await tokenRouter.selectChannel(requestedModel, downstreamPolicy);
|
||||
}
|
||||
|
||||
if (!selected) {
|
||||
await reportProxyAllFailed({
|
||||
model: requestedModel,
|
||||
reason: 'No available channels after retries',
|
||||
});
|
||||
return reply.code(503).send({
|
||||
error: { message: 'No available channels for this model', type: 'server_error' },
|
||||
});
|
||||
}
|
||||
|
||||
excludeChannelIds.push(selected.channel.id);
|
||||
const targetUrl = `${selected.site.url}/v1/videos`;
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
const requestInit = multipartForm
|
||||
? withSiteRecordProxyRequestInit(selected.site, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${selected.tokenValue}`,
|
||||
},
|
||||
body: cloneFormDataWithOverrides(multipartForm, {
|
||||
model: selected.actualModel || requestedModel,
|
||||
}) as any,
|
||||
})
|
||||
: withSiteRecordProxyRequestInit(selected.site, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${selected.tokenValue}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
...(jsonBody || {}),
|
||||
model: selected.actualModel || requestedModel,
|
||||
}),
|
||||
});
|
||||
|
||||
const upstream = await fetch(targetUrl, requestInit);
|
||||
const text = await upstream.text();
|
||||
if (!upstream.ok) {
|
||||
tokenRouter.recordFailure(selected.channel.id);
|
||||
if (isTokenExpiredError({ status: upstream.status, message: text })) {
|
||||
await reportTokenExpired({
|
||||
accountId: selected.account.id,
|
||||
username: selected.account.username,
|
||||
siteName: selected.site.name,
|
||||
detail: `HTTP ${upstream.status}`,
|
||||
});
|
||||
}
|
||||
if (shouldRetryProxyRequest(upstream.status, text) && retryCount < MAX_RETRIES) {
|
||||
retryCount += 1;
|
||||
continue;
|
||||
}
|
||||
await reportProxyAllFailed({
|
||||
model: requestedModel,
|
||||
reason: `upstream returned HTTP ${upstream.status}`,
|
||||
});
|
||||
return reply.code(upstream.status).send({ error: { message: text, type: 'upstream_error' } });
|
||||
}
|
||||
|
||||
let data: any = {};
|
||||
try { data = JSON.parse(text); } catch { data = {}; }
|
||||
const upstreamVideoId = typeof data?.id === 'string' ? data.id.trim() : '';
|
||||
if (!upstreamVideoId) {
|
||||
return reply.code(502).send({
|
||||
error: { message: 'Upstream video response did not include id', type: 'upstream_error' },
|
||||
});
|
||||
}
|
||||
|
||||
const mapping = await saveProxyVideoTask({
|
||||
upstreamVideoId,
|
||||
siteUrl: selected.site.url,
|
||||
tokenValue: selected.tokenValue,
|
||||
requestedModel,
|
||||
actualModel: selected.actualModel || requestedModel,
|
||||
channelId: typeof selected.channel.id === 'number' ? selected.channel.id : null,
|
||||
accountId: typeof selected.account.id === 'number' ? selected.account.id : null,
|
||||
statusSnapshot: data,
|
||||
upstreamResponseMeta: {
|
||||
contentType: upstream.headers.get('content-type') || 'application/json',
|
||||
},
|
||||
lastUpstreamStatus: upstream.status,
|
||||
});
|
||||
|
||||
const latency = Date.now() - startTime;
|
||||
const estimatedCost = await estimateProxyCost({
|
||||
site: selected.site,
|
||||
account: selected.account,
|
||||
modelName: selected.actualModel || requestedModel,
|
||||
promptTokens: 0,
|
||||
completionTokens: 0,
|
||||
totalTokens: 0,
|
||||
});
|
||||
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
|
||||
recordDownstreamCostUsage(request, estimatedCost);
|
||||
return reply.code(upstream.status).send(rewriteVideoResponsePublicId(data, mapping.publicId));
|
||||
} catch (error: any) {
|
||||
tokenRouter.recordFailure(selected.channel.id);
|
||||
if (retryCount < MAX_RETRIES) {
|
||||
retryCount += 1;
|
||||
continue;
|
||||
}
|
||||
await reportProxyAllFailed({
|
||||
model: requestedModel,
|
||||
reason: error?.message || 'network failure',
|
||||
});
|
||||
return reply.code(502).send({
|
||||
error: { message: error?.message || 'network failure', type: 'upstream_error' },
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/v1/videos/:id', async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => {
|
||||
const mapping = await getProxyVideoTaskByPublicId(request.params.id);
|
||||
if (!mapping) {
|
||||
return reply.code(404).send({
|
||||
error: { message: 'Video task not found', type: 'not_found_error' },
|
||||
});
|
||||
}
|
||||
|
||||
const upstream = await fetch(`${mapping.siteUrl}/v1/videos/${encodeURIComponent(mapping.upstreamVideoId)}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${mapping.tokenValue}`,
|
||||
},
|
||||
});
|
||||
const text = await upstream.text();
|
||||
try {
|
||||
const data = JSON.parse(text);
|
||||
await refreshProxyVideoTaskSnapshot(mapping.publicId, {
|
||||
statusSnapshot: data,
|
||||
upstreamResponseMeta: {
|
||||
contentType: upstream.headers.get('content-type') || 'application/json',
|
||||
},
|
||||
lastUpstreamStatus: upstream.status,
|
||||
});
|
||||
return reply.code(upstream.status).send(rewriteVideoResponsePublicId(data, mapping.publicId));
|
||||
} catch {
|
||||
return reply.code(upstream.status).type(upstream.headers.get('content-type') || 'application/json').send(text);
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/v1/videos/:id', async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => {
|
||||
const mapping = await getProxyVideoTaskByPublicId(request.params.id);
|
||||
if (!mapping) {
|
||||
return reply.code(404).send({
|
||||
error: { message: 'Video task not found', type: 'not_found_error' },
|
||||
});
|
||||
}
|
||||
|
||||
const upstream = await fetch(`${mapping.siteUrl}/v1/videos/${encodeURIComponent(mapping.upstreamVideoId)}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Authorization: `Bearer ${mapping.tokenValue}`,
|
||||
},
|
||||
});
|
||||
if (upstream.ok) {
|
||||
await deleteProxyVideoTaskByPublicId(mapping.publicId);
|
||||
return reply.code(upstream.status).send();
|
||||
}
|
||||
|
||||
const text = await upstream.text();
|
||||
return reply.code(upstream.status).send({
|
||||
error: { message: text || 'Upstream delete failed', type: 'upstream_error' },
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user