feat: Add normalized types and utility functions for handling chat formats

- Introduced `NormalizedContentBlock`, `NormalizedUsage`, and related types for structured data handling.
- Implemented utility functions for creating and merging normalized usage statistics.
- Added a new module for shared transformers to manage chat formats.

feat: Implement app local state management

- Created functions to clear application installation state and manage local storage keys.
- Added constants for theme and user profile storage keys.

test: Add tests for BrandIcon component rendering

- Implemented tests to ensure BrandIcon does not force a colored background for recognized brands.

test: Enhance ModelAnalysisPanel tests

- Added tests for rendering token summaries with compact units in ModelAnalysisPanel.

test: Add tests for ModernSelect component

- Implemented tests to verify icon rendering for selected options in ModernSelect.

test: Add number formatting tests

- Created tests for `formatCompactTokenMetric` to validate token formatting logic.

feat: Implement account creation intent handling

- Added logic to handle session and API key creation intents in the Accounts page.

test: Add tests for dashboard performance card

- Implemented tests to verify rendering of performance metrics in the dashboard.

feat: Implement default connection segment resolution

- Added logic to determine initial connection segments based on platform types.

test: Add tests for settings factory reset functionality

- Implemented tests to verify the factory reset process and local state clearing.

test: Add tests for site creation redirect logic

- Implemented tests to verify redirection based on site capabilities during creation.

test: Add tests for token routes refresh decision action

- Implemented tests to verify refresh decision actions in TokenRoutes.

test: Add tests for token routes snapshot load

- Implemented tests to verify loading of cached snapshot data in TokenRoutes.
This commit is contained in:
cita
2026-03-08 12:08:57 +08:00
parent ed05b37626
commit 23922a5965
115 changed files with 18597 additions and 4411 deletions
+53
View File
@@ -23,6 +23,7 @@ const TABLES_WITH_NUMERIC_ID = new Set([
'token_routes', 'token_routes',
'route_channels', 'route_channels',
'proxy_logs', 'proxy_logs',
'proxy_video_tasks',
'downstream_api_keys', 'downstream_api_keys',
'events', 'events',
]); ]);
@@ -138,6 +139,49 @@ function ensureTokenManagementSchema() {
`); `);
} }
function ensureProxyVideoTaskSchema() {
const sqlite = requireSqliteConnection();
sqlite.exec(`
CREATE TABLE IF NOT EXISTS proxy_video_tasks (
id integer PRIMARY KEY AUTOINCREMENT NOT NULL,
public_id text NOT NULL,
upstream_video_id text NOT NULL,
site_url text NOT NULL,
token_value text NOT NULL,
requested_model text,
actual_model text,
channel_id integer,
account_id integer,
status_snapshot text,
upstream_response_meta text,
last_upstream_status integer,
last_polled_at text,
created_at text DEFAULT (datetime('now')),
updated_at text DEFAULT (datetime('now'))
);
`);
if (!tableColumnExists('proxy_video_tasks', 'status_snapshot')) {
sqlite.exec('ALTER TABLE proxy_video_tasks ADD COLUMN status_snapshot text;');
}
if (!tableColumnExists('proxy_video_tasks', 'upstream_response_meta')) {
sqlite.exec('ALTER TABLE proxy_video_tasks ADD COLUMN upstream_response_meta text;');
}
if (!tableColumnExists('proxy_video_tasks', 'last_upstream_status')) {
sqlite.exec('ALTER TABLE proxy_video_tasks ADD COLUMN last_upstream_status integer;');
}
if (!tableColumnExists('proxy_video_tasks', 'last_polled_at')) {
sqlite.exec('ALTER TABLE proxy_video_tasks ADD COLUMN last_polled_at text;');
}
sqlite.exec(`
CREATE UNIQUE INDEX IF NOT EXISTS proxy_video_tasks_public_id_unique
ON proxy_video_tasks(public_id);
`);
sqlite.exec(`
CREATE INDEX IF NOT EXISTS proxy_video_tasks_upstream_video_id_idx
ON proxy_video_tasks(upstream_video_id);
`);
}
function ensureSiteStatusSchema() { function ensureSiteStatusSchema() {
const sqlite = requireSqliteConnection(); const sqlite = requireSqliteConnection();
if (!tableExists('sites')) { if (!tableExists('sites')) {
@@ -303,6 +347,14 @@ function ensureRouteGroupingSchema() {
sqlite.exec(`ALTER TABLE token_routes ADD COLUMN display_icon text;`); sqlite.exec(`ALTER TABLE token_routes ADD COLUMN display_icon text;`);
} }
if (!tableColumnExists('token_routes', 'decision_snapshot')) {
sqlite.exec(`ALTER TABLE token_routes ADD COLUMN decision_snapshot text;`);
}
if (!tableColumnExists('token_routes', 'decision_refreshed_at')) {
sqlite.exec(`ALTER TABLE token_routes ADD COLUMN decision_refreshed_at text;`);
}
if (!tableColumnExists('route_channels', 'source_model')) { if (!tableColumnExists('route_channels', 'source_model')) {
sqlite.exec(`ALTER TABLE route_channels ADD COLUMN source_model text;`); sqlite.exec(`ALTER TABLE route_channels ADD COLUMN source_model text;`);
} }
@@ -695,6 +747,7 @@ function initSqliteDb() {
ensureRouteGroupingSchema(); ensureRouteGroupingSchema();
ensureDownstreamApiKeySchema(); ensureDownstreamApiKeySchema();
ensureProxyLogBillingDetailsSchema(); ensureProxyLogBillingDetailsSchema();
ensureProxyVideoTaskSchema();
const rawDb = drizzleSqliteProxy( const rawDb = drizzleSqliteProxy(
(sqlText, params, method) => sqliteProxyQuery(sqlText, params, method as SqlMethod), (sqlText, params, method) => sqliteProxyQuery(sqlText, params, method as SqlMethod),
+24
View File
@@ -109,6 +109,8 @@ export const tokenRoutes = sqliteTable('token_routes', {
displayName: text('display_name'), displayName: text('display_name'),
displayIcon: text('display_icon'), displayIcon: text('display_icon'),
modelMapping: text('model_mapping'), // JSON modelMapping: text('model_mapping'), // JSON
decisionSnapshot: text('decision_snapshot'), // JSON
decisionRefreshedAt: text('decision_refreshed_at'),
enabled: integer('enabled', { mode: 'boolean' }).default(true), enabled: integer('enabled', { mode: 'boolean' }).default(true),
createdAt: text('created_at').default(sql`(datetime('now'))`), createdAt: text('created_at').default(sql`(datetime('now'))`),
updatedAt: text('updated_at').default(sql`(datetime('now'))`), updatedAt: text('updated_at').default(sql`(datetime('now'))`),
@@ -167,6 +169,28 @@ export const proxyLogs = sqliteTable('proxy_logs', {
modelActualCreatedIdx: index('proxy_logs_model_actual_created_at_idx').on(table.modelActual, table.createdAt), modelActualCreatedIdx: index('proxy_logs_model_actual_created_at_idx').on(table.modelActual, table.createdAt),
})); }));
export const proxyVideoTasks = sqliteTable('proxy_video_tasks', {
id: integer('id').primaryKey({ autoIncrement: true }),
publicId: text('public_id').notNull(),
upstreamVideoId: text('upstream_video_id').notNull(),
siteUrl: text('site_url').notNull(),
tokenValue: text('token_value').notNull(),
requestedModel: text('requested_model'),
actualModel: text('actual_model'),
channelId: integer('channel_id'),
accountId: integer('account_id'),
statusSnapshot: text('status_snapshot'),
upstreamResponseMeta: text('upstream_response_meta'),
lastUpstreamStatus: integer('last_upstream_status'),
lastPolledAt: text('last_polled_at'),
createdAt: text('created_at').default(sql`(datetime('now'))`),
updatedAt: text('updated_at').default(sql`(datetime('now'))`),
}, (table) => ({
publicIdUnique: uniqueIndex('proxy_video_tasks_public_id_unique').on(table.publicId),
upstreamVideoIdIdx: index('proxy_video_tasks_upstream_video_id_idx').on(table.upstreamVideoId),
createdAtIdx: index('proxy_video_tasks_created_at_idx').on(table.createdAt),
}));
export const settings = sqliteTable('settings', { export const settings = sqliteTable('settings', {
key: text('key').primaryKey(), key: text('key').primaryKey(),
value: text('value'), // JSON value: text('value'), // JSON
+2
View File
@@ -23,6 +23,7 @@ import { startProxyLogRetentionService, stopProxyLogRetentionService } from './s
import { buildStartupSummaryLines } from './services/startupInfo.js'; import { buildStartupSummaryLines } from './services/startupInfo.js';
import { repairStoredCreatedAtValues } from './services/storedTimestampRepairService.js'; import { repairStoredCreatedAtValues } from './services/storedTimestampRepairService.js';
import { migrateSiteApiKeysToAccounts } from './services/siteApiKeyMigrationService.js'; import { migrateSiteApiKeysToAccounts } from './services/siteApiKeyMigrationService.js';
import { ensureDefaultSitesSeeded } from './services/defaultSiteSeedService.js';
import { isPublicApiRoute, registerDesktopRoutes } from './desktop.js'; import { isPublicApiRoute, registerDesktopRoutes } from './desktop.js';
import { existsSync } from 'fs'; import { existsSync } from 'fs';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
@@ -222,6 +223,7 @@ try {
await ensureProxyLogBillingDetailsColumn(); await ensureProxyLogBillingDetailsColumn();
await repairStoredCreatedAtValues(); await repairStoredCreatedAtValues();
await migrateSiteApiKeysToAccounts(); await migrateSiteApiKeysToAccounts();
await ensureDefaultSitesSeeded();
console.log('Loaded runtime settings overrides'); console.log('Loaded runtime settings overrides');
} catch (error) { } catch (error) {
+12 -2
View File
@@ -66,12 +66,22 @@ export async function proxyAuthMiddleware(request: FastifyRequest, reply: Fastif
const apiKeyHeader = typeof request.headers['x-api-key'] === 'string' const apiKeyHeader = typeof request.headers['x-api-key'] === 'string'
? request.headers['x-api-key'] ? request.headers['x-api-key']
: ''; : '';
const googApiKeyHeader = typeof request.headers['x-goog-api-key'] === 'string'
? request.headers['x-goog-api-key']
: '';
const queryKey = (
request.query
&& typeof request.query === 'object'
&& typeof (request.query as Record<string, unknown>).key === 'string'
)
? String((request.query as Record<string, unknown>).key).trim()
: '';
const token = auth const token = auth
? auth.replace(/^Bearer\s+/i, '').trim() ? auth.replace(/^Bearer\s+/i, '').trim()
: apiKeyHeader.trim(); : (apiKeyHeader.trim() || googApiKeyHeader.trim() || queryKey);
if (!token) { if (!token) {
reply.code(401).send({ error: 'Missing Authorization or x-api-key header' }); reply.code(401).send({ error: 'Missing Authorization, x-api-key, x-goog-api-key, or key query parameter' });
return; return;
} }
@@ -0,0 +1,186 @@
import Fastify, { type FastifyInstance } from 'fastify';
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { mkdtempSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { eq } from 'drizzle-orm';
type DbModule = typeof import('../../db/index.js');
type ConfigModule = typeof import('../../config.js');
type ServiceModule = typeof import('../../services/factoryResetService.js');
describe('settings factory reset api', () => {
let app: FastifyInstance;
let db: DbModule['db'];
let schema: DbModule['schema'];
let config: ConfigModule['config'];
let FACTORY_RESET_ADMIN_TOKEN: ServiceModule['FACTORY_RESET_ADMIN_TOKEN'];
let dataDir = '';
beforeAll(async () => {
dataDir = mkdtempSync(join(tmpdir(), 'metapi-settings-factory-reset-'));
process.env.DATA_DIR = dataDir;
await import('../../db/migrate.js');
const dbModule = await import('../../db/index.js');
const configModule = await import('../../config.js');
const serviceModule = await import('../../services/factoryResetService.js');
const settingsRoutesModule = await import('./settings.js');
db = dbModule.db;
schema = dbModule.schema;
config = configModule.config;
FACTORY_RESET_ADMIN_TOKEN = serviceModule.FACTORY_RESET_ADMIN_TOKEN;
app = Fastify();
await app.register(settingsRoutesModule.settingsRoutes);
});
beforeEach(async () => {
await db.delete(schema.routeChannels).run();
await db.delete(schema.tokenModelAvailability).run();
await db.delete(schema.modelAvailability).run();
await db.delete(schema.proxyLogs).run();
await db.delete(schema.proxyVideoTasks).run();
await db.delete(schema.checkinLogs).run();
await db.delete(schema.accountTokens).run();
await db.delete(schema.accounts).run();
await db.delete(schema.tokenRoutes).run();
await db.delete(schema.sites).run();
await db.delete(schema.downstreamApiKeys).run();
await db.delete(schema.events).run();
await db.delete(schema.settings).run();
config.authToken = 'before-reset-token';
config.dbType = 'sqlite';
config.dbUrl = '';
config.dbSsl = true;
config.systemProxyUrl = 'http://127.0.0.1:7890';
});
afterAll(async () => {
await app.close();
delete process.env.DATA_DIR;
});
it('clears data, resets runtime config, and returns to initial sqlite state', async () => {
const siteInsert = await db.insert(schema.sites).values({
name: 'Reset Me',
url: 'https://reset.example.com',
platform: 'new-api',
status: 'disabled',
}).run();
const siteId = Number(siteInsert.lastInsertRowid);
const accountInsert = await db.insert(schema.accounts).values({
siteId,
accessToken: 'session-token',
status: 'expired',
}).run();
const accountId = Number(accountInsert.lastInsertRowid);
const tokenInsert = await db.insert(schema.accountTokens).values({
accountId,
name: 'Reset token',
token: 'token-value',
}).run();
const tokenId = Number(tokenInsert.lastInsertRowid);
const routeInsert = await db.insert(schema.tokenRoutes).values({
modelPattern: 'gpt-*',
enabled: true,
}).run();
const routeId = Number(routeInsert.lastInsertRowid);
await db.insert(schema.routeChannels).values({
routeId,
accountId,
tokenId,
enabled: true,
}).run();
await db.insert(schema.modelAvailability).values({
accountId,
modelName: 'gpt-4.1',
available: true,
}).run();
await db.insert(schema.tokenModelAvailability).values({
tokenId,
modelName: 'gpt-4.1',
available: true,
}).run();
await db.insert(schema.checkinLogs).values({
accountId,
status: 'success',
message: 'ok',
}).run();
await db.insert(schema.proxyLogs).values({
accountId,
status: 'success',
modelRequested: 'gpt-4.1',
}).run();
await db.insert(schema.proxyVideoTasks).values({
publicId: 'public-video-1',
upstreamVideoId: 'upstream-video-1',
siteUrl: 'https://reset.example.com',
tokenValue: 'session-token',
}).run();
await db.insert(schema.downstreamApiKeys).values({
name: 'Downstream',
key: 'downstream-key',
}).run();
await db.insert(schema.events).values({
type: 'status',
title: 'Before reset',
message: 'should be gone',
level: 'warning',
}).run();
await db.insert(schema.settings).values([
{ key: 'auth_token', value: JSON.stringify('before-reset-token') },
{ key: 'db_type', value: JSON.stringify('postgres') },
{ key: 'db_url', value: JSON.stringify('postgres://user:pass@127.0.0.1:5432/metapi') },
{ key: 'db_ssl', value: JSON.stringify(true) },
{ key: 'system_proxy_url', value: JSON.stringify('http://127.0.0.1:7890') },
]).run();
const response = await app.inject({
method: 'POST',
url: '/api/settings/maintenance/factory-reset',
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ success: true });
expect(config.authToken).toBe(FACTORY_RESET_ADMIN_TOKEN);
expect(config.dbType).toBe('sqlite');
expect(config.dbUrl).toBe('');
expect(config.dbSsl).toBe(false);
const sites = await db.select().from(schema.sites).all();
expect(sites.map((site) => site.name)).toEqual([
'OpenAI 官方',
'Claude 官方',
'Gemini 官方',
'CLIProxyAPI',
]);
const authTokenSetting = await db.select().from(schema.settings).where(eq(schema.settings.key, 'auth_token')).get();
const dbTypeSetting = await db.select().from(schema.settings).where(eq(schema.settings.key, 'db_type')).get();
const dbUrlSetting = await db.select().from(schema.settings).where(eq(schema.settings.key, 'db_url')).get();
const dbSslSetting = await db.select().from(schema.settings).where(eq(schema.settings.key, 'db_ssl')).get();
expect(authTokenSetting).toBeUndefined();
expect(dbTypeSetting).toBeUndefined();
expect(dbUrlSetting).toBeUndefined();
expect(dbSslSetting).toBeUndefined();
expect(await db.select().from(schema.accounts).all()).toHaveLength(0);
expect(await db.select().from(schema.accountTokens).all()).toHaveLength(0);
expect(await db.select().from(schema.tokenRoutes).all()).toHaveLength(0);
expect(await db.select().from(schema.routeChannels).all()).toHaveLength(0);
expect(await db.select().from(schema.modelAvailability).all()).toHaveLength(0);
expect(await db.select().from(schema.tokenModelAvailability).all()).toHaveLength(0);
expect(await db.select().from(schema.proxyLogs).all()).toHaveLength(0);
expect(await db.select().from(schema.proxyVideoTasks).all()).toHaveLength(0);
expect(await db.select().from(schema.checkinLogs).all()).toHaveLength(0);
expect(await db.select().from(schema.downstreamApiKeys).all()).toHaveLength(0);
expect(await db.select().from(schema.events).all()).toHaveLength(0);
});
});
+15
View File
@@ -17,6 +17,7 @@ import {
import { formatUtcSqlDateTime } from '../../services/localTimeService.js'; import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
import { extractClientIp, isIpAllowed } from '../../middleware/auth.js'; import { extractClientIp, isIpAllowed } from '../../middleware/auth.js';
import { invalidateSiteProxyCache, normalizeSiteProxyUrl } from '../../services/siteProxy.js'; import { invalidateSiteProxyCache, normalizeSiteProxyUrl } from '../../services/siteProxy.js';
import { performFactoryReset } from '../../services/factoryResetService.js';
type RoutingWeights = typeof config.routingWeights; type RoutingWeights = typeof config.routingWeights;
@@ -910,4 +911,18 @@ export async function settingsRoutes(app: FastifyInstance) {
deletedProxyLogs, deletedProxyLogs,
}; };
}); });
app.post('/api/settings/maintenance/factory-reset', async (_, reply) => {
try {
await performFactoryReset();
return {
success: true,
};
} catch (err: any) {
return reply.code(500).send({
success: false,
message: err?.message || '重新初始化系统失败',
});
}
});
} }
+90 -1
View File
@@ -4,7 +4,7 @@ import { tmpdir } from 'node:os';
import { join } from 'node:path'; import { join } from 'node:path';
import { mkdtempSync } from 'node:fs'; import { mkdtempSync } from 'node:fs';
import { sql } from 'drizzle-orm'; 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'); type DbModule = typeof import('../../db/index.js');
@@ -153,4 +153,93 @@ describe('stats dashboard filters disabled sites', () => {
total: 3, total: 3,
}); });
}); });
it('returns recent RPM and TPM stats using only active-site proxy logs', async () => {
const activeSite = await db.insert(schema.sites).values({
name: 'active-performance-site',
url: 'https://active-performance.example.com',
platform: 'new-api',
}).returning().get();
const disabledSite = await db.insert(schema.sites).values({
name: 'disabled-performance-site',
url: 'https://disabled-performance.example.com',
platform: 'new-api',
}).returning().get();
await db.run(sql`update sites set status = 'disabled' where id = ${disabledSite.id}`);
const activeAccount = await db.insert(schema.accounts).values({
siteId: activeSite.id,
username: 'active-performance-user',
accessToken: 'active-performance-token',
balance: 100,
status: 'active',
}).returning().get();
const disabledAccount = await db.insert(schema.accounts).values({
siteId: disabledSite.id,
username: 'disabled-performance-user',
accessToken: 'disabled-performance-token',
balance: 100,
status: 'active',
}).returning().get();
const now = Date.now();
const withinWindow = formatUtcSqlDateTime(new Date(now - 30_000));
const outsideWindow = formatUtcSqlDateTime(new Date(now - 90_000));
await db.insert(schema.proxyLogs).values([
{
accountId: activeAccount.id,
status: 'success',
totalTokens: 1200,
createdAt: withinWindow,
},
{
accountId: activeAccount.id,
status: 'failed',
totalTokens: 600,
createdAt: withinWindow,
},
{
accountId: activeAccount.id,
status: 'success',
totalTokens: null,
createdAt: withinWindow,
},
{
accountId: activeAccount.id,
status: 'success',
totalTokens: 9999,
createdAt: outsideWindow,
},
{
accountId: disabledAccount.id,
status: 'success',
totalTokens: 7777,
createdAt: withinWindow,
},
]).run();
const response = await app.inject({
method: 'GET',
url: '/api/stats/dashboard',
});
expect(response.statusCode).toBe(200);
const body = response.json() as {
performance?: {
windowSeconds: number;
requestsPerMinute: number;
tokensPerMinute: number;
};
};
expect(body.performance).toEqual({
windowSeconds: 60,
requestsPerMinute: 3,
tokensPerMinute: 1800,
});
});
}); });
+17
View File
@@ -115,6 +115,7 @@ export async function statsRoutes(app: FastifyInstance) {
const nowTs = Date.now(); const nowTs = Date.now();
const last24hDate = formatUtcSqlDateTime(new Date(nowTs - 86400000)); const last24hDate = formatUtcSqlDateTime(new Date(nowTs - 86400000));
const lastMinuteDate = formatUtcSqlDateTime(new Date(nowTs - 60_000));
const last7dDate = getLocalRangeStartUtc(7); const last7dDate = getLocalRangeStartUtc(7);
const recentProxyLogs = (await db.select({ const recentProxyLogs = (await db.select({
proxy_logs: proxyLogBaseFields, proxy_logs: proxyLogBaseFields,
@@ -145,6 +146,15 @@ export async function statsRoutes(app: FastifyInstance) {
.leftJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id)) .leftJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
.where(and(gte(schema.proxyLogs.createdAt, last24hDate), eq(schema.sites.status, 'active'))) .where(and(gte(schema.proxyLogs.createdAt, last24hDate), eq(schema.sites.status, 'active')))
.get(); .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({ const todaySpendRow = await db.select({
todaySpend: sql<number>`coalesce(sum(coalesce(${schema.proxyLogs.estimatedCost}, 0)), 0)`, 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 proxyFailed = Number(proxy24hRow?.failed || 0);
const proxyTotal = Number(proxy24hRow?.total || 0); const proxyTotal = Number(proxy24hRow?.total || 0);
const totalTokens = Number(proxy24hRow?.totalTokens || 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 totalUsed = Number(totalUsedRow?.totalUsed || 0);
const todaySpend = Number(todaySpendRow?.todaySpend || 0); const todaySpend = Number(todaySpendRow?.todaySpend || 0);
const todayReward = accounts.reduce((sum, account) => sum + estimateRewardWithTodayIncomeFallback({ const todayReward = accounts.reduce((sum, account) => sum + estimateRewardWithTodayIncomeFallback({
@@ -182,6 +194,11 @@ export async function statsRoutes(app: FastifyInstance) {
totalAccounts: accounts.length, totalAccounts: accounts.length,
todayCheckin: { success: checkinSuccess, failed: checkinFailed, total: todayCheckins.length }, todayCheckin: { success: checkinSuccess, failed: checkinFailed, total: todayCheckins.length },
proxy24h: { success: proxySuccess, failed: proxyFailed, total: proxyTotal, totalTokens }, proxy24h: { success: proxySuccess, failed: proxyFailed, total: proxyTotal, totalTokens },
performance: {
windowSeconds: 60,
requestsPerMinute,
tokensPerMinute,
},
modelAnalysis, modelAnalysis,
}; };
}); });
+152
View File
@@ -0,0 +1,152 @@
import Fastify, { type FastifyInstance } from 'fastify';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
const fetchMock = vi.fn();
vi.mock('undici', async () => {
const actual = await vi.importActual<typeof import('undici')>('undici');
return {
...actual,
fetch: (...args: unknown[]) => fetchMock(...args),
};
});
describe('testRoutes proxy tester transport', () => {
let app: FastifyInstance;
beforeAll(async () => {
const { testRoutes } = await import('./test.js');
app = Fastify();
await app.register(testRoutes);
});
beforeEach(() => {
fetchMock.mockReset();
});
afterAll(async () => {
await app.close();
});
it('rejects proxy test paths outside the whitelist', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/test/proxy',
payload: {
method: 'POST',
path: '/api/accounts',
requestKind: 'json',
stream: false,
jobMode: false,
rawMode: false,
jsonBody: {},
},
});
expect(response.statusCode).toBe(400);
expect(response.json()).toEqual({
error: {
message: 'path is not allowed: /api/accounts',
type: 'validation_error',
},
});
expect(fetchMock).not.toHaveBeenCalled();
});
it('forwards raw json bodies without dropping unknown fields', async () => {
fetchMock.mockResolvedValue(new Response(JSON.stringify({
object: 'response',
output_text: 'ok',
}), {
status: 200,
headers: { 'content-type': 'application/json' },
}));
const response = await app.inject({
method: 'POST',
url: '/api/test/proxy',
payload: {
method: 'POST',
path: '/v1/responses',
requestKind: 'json',
stream: false,
jobMode: false,
rawMode: true,
rawJsonText: JSON.stringify({
model: 'gpt-4o-mini',
include: ['reasoning.encrypted_content'],
metadata: { source: 'playground' },
custom_field: 'keep-me',
}),
},
});
expect(response.statusCode).toBe(200);
const [url, requestInit] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(url).toMatch(/\/v1\/responses$/);
expect(JSON.parse(String(requestInit.body))).toEqual({
model: 'gpt-4o-mini',
include: ['reasoning.encrypted_content'],
metadata: { source: 'playground' },
custom_field: 'keep-me',
stream: false,
});
});
it('translates legacy claude chat tests through the compatibility wrapper', async () => {
fetchMock.mockResolvedValue(new Response(JSON.stringify({
type: 'message',
content: [{ type: 'text', text: 'pong' }],
}), {
status: 200,
headers: { 'content-type': 'application/json' },
}));
const response = await app.inject({
method: 'POST',
url: '/api/test/chat',
payload: {
model: 'claude-3-7-sonnet',
targetFormat: 'claude',
messages: [
{ role: 'system', content: 'be concise' },
{ role: 'user', content: 'ping' },
],
},
});
expect(response.statusCode).toBe(200);
const [url, requestInit] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(url).toMatch(/\/v1\/messages$/);
expect(JSON.parse(String(requestInit.body))).toEqual({
model: 'claude-3-7-sonnet',
stream: false,
max_tokens: 4096,
system: 'be concise',
messages: [
{ role: 'user', content: 'ping' },
],
});
});
it('accepts stream=true when creating proxy jobs and stores a pending job', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/test/proxy/jobs',
payload: {
method: 'POST',
path: '/v1/chat/completions',
requestKind: 'json',
stream: true,
jobMode: true,
rawMode: false,
jsonBody: { model: 'gpt-4o-mini', messages: [{ role: 'user', content: 'ping' }] },
},
});
expect(response.statusCode).toBe(202);
expect(response.json()).toMatchObject({
status: 'pending',
});
});
});
+145
View File
@@ -0,0 +1,145 @@
import Fastify, { type FastifyInstance } from 'fastify';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { config } from '../../config.js';
const fetchMock = vi.fn();
vi.mock('undici', async () => {
const actual = await vi.importActual<typeof import('undici')>('undici');
return {
...actual,
fetch: (...args: unknown[]) => fetchMock(...args),
};
});
describe('/api/test proxy tester routes', () => {
let app: FastifyInstance;
beforeAll(async () => {
const { testRoutes } = await import('./test.js');
app = Fastify();
await app.register(testRoutes);
});
beforeEach(() => {
fetchMock.mockReset();
});
afterAll(async () => {
await app.close();
});
it('rejects proxy paths outside the allowlist', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/test/proxy',
payload: {
method: 'POST',
path: '/admin/secret',
requestKind: 'json',
jsonBody: {},
},
});
expect(response.statusCode).toBe(400);
expect(response.json()).toMatchObject({
error: {
message: 'path is not allowed: /admin/secret',
type: 'validation_error',
},
});
expect(fetchMock).not.toHaveBeenCalled();
});
it('forwards raw json bodies without dropping extra proxy fields', async () => {
fetchMock.mockResolvedValue(new Response(JSON.stringify({
object: 'response',
output_text: 'ok',
}), {
status: 200,
headers: { 'content-type': 'application/json' },
}));
const rawPayload = {
model: 'gpt-5.2',
include: ['reasoning.encrypted_content'],
reasoning: { effort: 'medium' },
metadata: { source: 'tester' },
};
const response = await app.inject({
method: 'POST',
url: '/api/test/proxy',
payload: {
method: 'POST',
path: '/v1/responses',
requestKind: 'json',
rawMode: true,
rawJsonText: JSON.stringify(rawPayload),
},
});
expect(response.statusCode).toBe(200);
const [targetUrl, requestInit] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(targetUrl).toBe(`http://127.0.0.1:${config.port}/v1/responses`);
expect(JSON.parse(String(requestInit.body))).toEqual({
...rawPayload,
stream: false,
});
});
it('rejects empty multipart envelopes', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/test/proxy',
payload: {
method: 'POST',
path: '/v1/images/edits',
requestKind: 'multipart',
},
});
expect(response.statusCode).toBe(400);
expect(response.json()).toMatchObject({
error: {
message: 'multipart requests require multipartFields or multipartFiles',
type: 'validation_error',
},
});
expect(fetchMock).not.toHaveBeenCalled();
});
it('keeps legacy /api/test/chat wrapper working for responses payloads', async () => {
fetchMock.mockResolvedValue(new Response(JSON.stringify({
object: 'response',
output_text: 'hello',
}), {
status: 200,
headers: { 'content-type': 'application/json' },
}));
const response = await app.inject({
method: 'POST',
url: '/api/test/chat',
payload: {
model: 'gpt-5.2',
targetFormat: 'responses',
messages: [
{ role: 'system', content: 'be concise' },
{ role: 'user', content: 'hello' },
],
},
});
expect(response.statusCode).toBe(200);
const [targetUrl, requestInit] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(targetUrl).toBe(`http://127.0.0.1:${config.port}/v1/responses`);
expect(JSON.parse(String(requestInit.body))).toEqual({
model: 'gpt-5.2',
stream: false,
input: [{ role: 'user', content: 'hello' }],
instructions: 'be concise',
max_output_tokens: 4096,
});
});
});
+498 -105
View File
@@ -1,10 +1,12 @@
import { FastifyInstance, FastifyReply } from 'fastify'; import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { fetch } from 'undici'; import { fetch, File, FormData } from 'undici';
import { config } from '../../config.js'; import { config } from '../../config.js';
type UndiciRequestInit = Parameters<typeof fetch>[1];
type TestChatMessage = { role: string; content: string }; type TestChatMessage = { role: string; content: string };
type TestTargetFormat = 'openai' | 'claude' | 'responses'; type TestTargetFormat = 'openai' | 'claude' | 'responses' | 'gemini';
type TestChatRequestBody = { type TestChatRequestBody = {
model?: string; model?: string;
@@ -32,12 +34,48 @@ type ValidatedTestChatPayload = {
seed?: number; 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; id: string;
status: TestChatJobStatus; status: TestJobStatus;
payload: ValidatedTestChatPayload; envelope: ValidatedProxyTestEnvelope;
result?: unknown; result?: unknown;
error?: unknown; error?: unknown;
controller?: AbortController | null; controller?: AbortController | null;
@@ -48,7 +86,22 @@ type TestChatJob = {
const JOB_TTL_MS = 10 * 60 * 1000; const JOB_TTL_MS = 10 * 60 * 1000;
const JOB_CLEANUP_INTERVAL_MS = 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 { class UpstreamProxyError extends Error {
statusCode: number; 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 { try {
return JSON.parse(text); return JSON.parse(text);
} catch { } catch {
return { error: { message: text, type: 'upstream_error' } }; 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, body: TestChatRequestBody,
reply: FastifyReply, reply: FastifyReply,
): ValidatedTestChatPayload | null => { ): ValidatedTestChatPayload | null {
if (!body.model || body.model.trim().length === 0) { if (!body.model || body.model.trim().length === 0) {
reply.code(400).send({ error: 'model is required' }); reply.code(400).send({ error: 'model is required' });
return null; return null;
@@ -88,6 +164,8 @@ const validatePayload = (
? 'claude' ? 'claude'
: body.targetFormat === 'responses' : body.targetFormat === 'responses'
? 'responses' ? 'responses'
: body.targetFormat === 'gemini'
? 'gemini'
: 'openai'; : 'openai';
return { return {
@@ -102,12 +180,12 @@ const validatePayload = (
presence_penalty: body.presence_penalty, presence_penalty: body.presence_penalty,
seed: body.seed, seed: body.seed,
}; };
}; }
const convertOpenAiPayloadToClaudeBody = ( function convertOpenAiPayloadToClaudeBody(
payload: ValidatedTestChatPayload, payload: ValidatedTestChatPayload,
forceStream: boolean, forceStream: boolean,
): Record<string, unknown> => { ): Record<string, unknown> {
const systemContents: string[] = []; const systemContents: string[] = [];
const messages: Array<{ role: 'user' | 'assistant'; content: string }> = []; const messages: Array<{ role: 'user' | 'assistant'; content: string }> = [];
@@ -136,10 +214,7 @@ const convertOpenAiPayloadToClaudeBody = (
messages, messages,
}; };
if (systemContents.length > 0) { if (systemContents.length > 0) body.system = systemContents.join('\n\n');
body.system = systemContents.join('\n\n');
}
if (typeof payload.temperature === 'number' && Number.isFinite(payload.temperature)) { if (typeof payload.temperature === 'number' && Number.isFinite(payload.temperature)) {
body.temperature = payload.temperature; body.temperature = payload.temperature;
} }
@@ -148,12 +223,12 @@ const convertOpenAiPayloadToClaudeBody = (
} }
return body; return body;
}; }
const convertOpenAiPayloadToResponsesBody = ( function convertOpenAiPayloadToResponsesBody(
payload: ValidatedTestChatPayload, payload: ValidatedTestChatPayload,
forceStream: boolean, forceStream: boolean,
): Record<string, unknown> => { ): Record<string, unknown> {
const systemContents: string[] = []; const systemContents: string[] = [];
const messages: Array<{ role: 'user' | 'assistant'; content: string }> = []; const messages: Array<{ role: 'user' | 'assistant'; content: string }> = [];
@@ -198,81 +273,282 @@ const convertOpenAiPayloadToResponsesBody = (
: 4096; : 4096;
return body; return body;
}; }
const buildUpstreamRequest = ( function convertLegacyPayloadToEnvelope(
payload: ValidatedTestChatPayload, payload: ValidatedTestChatPayload,
forceStream: boolean, forceStream: boolean,
): { url: string; headers: Record<string, string>; body: Record<string, unknown> } => { ): ValidatedProxyTestEnvelope {
if (payload.targetFormat === 'claude') { if (payload.targetFormat === 'claude') {
return { return {
url: `http://127.0.0.1:${config.port}/v1/messages`, method: 'POST',
headers: { path: '/v1/messages',
'Content-Type': 'application/json', requestKind: 'json',
'x-api-key': config.proxyToken, stream: forceStream,
'anthropic-version': '2023-06-01', jobMode: false,
}, rawMode: false,
body: convertOpenAiPayloadToClaudeBody(payload, forceStream), jsonBody: convertOpenAiPayloadToClaudeBody(payload, forceStream),
}; };
} }
if (payload.targetFormat === 'responses') { if (payload.targetFormat === 'responses') {
return { return {
url: `http://127.0.0.1:${config.port}/v1/responses`, method: 'POST',
headers: { path: '/v1/responses',
'Content-Type': 'application/json', requestKind: 'json',
Authorization: `Bearer ${config.proxyToken}`, stream: forceStream,
}, jobMode: false,
body: convertOpenAiPayloadToResponsesBody(payload, forceStream), rawMode: false,
jsonBody: convertOpenAiPayloadToResponsesBody(payload, forceStream),
}; };
} }
const { targetFormat: _targetFormat, ...openAiPayload } = payload;
return { return {
url: `http://127.0.0.1:${config.port}/v1/chat/completions`, method: 'POST',
headers: { path: '/v1/chat/completions',
'Content-Type': 'application/json', requestKind: 'json',
Authorization: `Bearer ${config.proxyToken}`, stream: forceStream,
jobMode: false,
rawMode: false,
jsonBody: {
...payload,
stream: forceStream,
}, },
body: { ...openAiPayload, stream: forceStream },
}; };
}
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,
}; };
const requestUpstreamChat = async ( if (requestKind === 'json') {
payload: ValidatedTestChatPayload, 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, signal?: AbortSignal,
forceStream = false, forceStream = false,
): Promise<unknown> => { ): Promise<unknown> {
const upstreamRequest = buildUpstreamRequest(payload, forceStream); const upstream = await fetch(`http://127.0.0.1:${config.port}${envelope.path}`, {
const upstream = await fetch(upstreamRequest.url, { ...(await buildUpstreamRequestInit(envelope, forceStream)),
method: 'POST',
headers: upstreamRequest.headers,
body: JSON.stringify(upstreamRequest.body),
signal, signal,
}); });
const contentType = upstream.headers.get('content-type') || '';
const text = await upstream.text(); const text = await upstream.text();
if (!upstream.ok) { if (!upstream.ok) {
throw new UpstreamProxyError(upstream.status, normalizeErrorPayload(text)); throw new UpstreamProxyError(upstream.status, normalizeErrorPayload(text));
} }
if (contentType.includes('application/json')) {
try {
return JSON.parse(text);
} catch {
return { raw: text };
}
}
try { try {
return JSON.parse(text); return JSON.parse(text);
} catch { } catch {
return { raw: text }; return { raw: text };
} }
}; }
const cleanupExpiredJobs = () => { function cleanupExpiredJobs() {
const now = Date.now(); const now = Date.now();
for (const [jobId, job] of jobs.entries()) { for (const [jobId, job] of jobs.entries()) {
if (job.expiresAt <= now) { if (job.expiresAt <= now) {
jobs.delete(jobId); jobs.delete(jobId);
} }
} }
}; }
const runJob = async (jobId: string) => { async function runJob(jobId: string) {
const job = jobs.get(jobId); const job = jobs.get(jobId);
if (!job || job.status !== 'pending') return; if (!job || job.status !== 'pending') return;
@@ -280,7 +556,7 @@ const runJob = async (jobId: string) => {
job.controller = controller; job.controller = controller;
try { 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); const current = jobs.get(jobId);
if (!current) return; if (!current) return;
current.controller = null; current.controller = null;
@@ -308,25 +584,15 @@ const runJob = async (jobId: string) => {
current.updatedAt = Date.now(); current.updatedAt = Date.now();
current.expiresAt = current.updatedAt + JOB_TTL_MS; current.expiresAt = current.updatedAt + JOB_TTL_MS;
} }
}; }
export async function testRoutes(app: FastifyInstance) {
const cleanupTimer = setInterval(cleanupExpiredJobs, JOB_CLEANUP_INTERVAL_MS);
cleanupTimer.unref?.();
app.addHook('onClose', async () => {
clearInterval(cleanupTimer);
});
app.post<{ Body: TestChatRequestBody }>(
'/api/test/chat',
async (request, reply) => {
const body = request.body || {};
const payload = validatePayload(body, reply);
if (!payload) return;
async function sendBufferedEnvelope(
envelope: ValidatedProxyTestEnvelope,
reply: FastifyReply,
forceStream = false,
) {
try { try {
const data = await requestUpstreamChat(payload, undefined, false); const data = await fetchProxyBuffered(envelope, undefined, forceStream);
return reply.send(data); return reply.send(data);
} catch (error) { } catch (error) {
if (error instanceof UpstreamProxyError) { if (error instanceof UpstreamProxyError) {
@@ -339,16 +605,14 @@ export async function testRoutes(app: FastifyInstance) {
}, },
}); });
} }
}, }
);
app.post<{ Body: TestChatRequestBody }>(
'/api/test/chat/stream',
async (request, reply) => {
const body = request.body || {};
const payload = validatePayload(body, reply);
if (!payload) return;
async function sendStreamingEnvelope(
envelope: ValidatedProxyTestEnvelope,
request: FastifyRequest,
reply: FastifyReply,
forceStream = true,
) {
const controller = new AbortController(); const controller = new AbortController();
const abortUpstream = () => { const abortUpstream = () => {
try { try {
@@ -359,28 +623,23 @@ export async function testRoutes(app: FastifyInstance) {
// no-op // no-op
} }
}; };
const onClientAborted = () => {
abortUpstream(); const onClientAborted = () => abortUpstream();
};
const onClientClosed = () => { const onClientClosed = () => {
if (!reply.raw.writableEnded) { if (!reply.raw.writableEnded) abortUpstream();
abortUpstream();
}
}; };
const cleanupClientListeners = () => { const cleanupClientListeners = () => {
request.raw.off?.('aborted', onClientAborted); request.raw.off?.('aborted', onClientAborted);
reply.raw.off?.('close', onClientClosed); reply.raw.off?.('close', onClientClosed);
}; };
request.raw.on('aborted', onClientAborted); request.raw.on('aborted', onClientAborted);
reply.raw.on('close', onClientClosed); reply.raw.on('close', onClientClosed);
let upstream; let upstream;
try { try {
const upstreamRequest = buildUpstreamRequest(payload, true); upstream = await fetch(`http://127.0.0.1:${config.port}${envelope.path}`, {
upstream = await fetch(upstreamRequest.url, { ...(await buildUpstreamRequestInit(envelope, forceStream)),
method: 'POST',
headers: upstreamRequest.headers,
body: JSON.stringify(upstreamRequest.body),
signal: controller.signal, signal: controller.signal,
}); });
} catch (error) { } catch (error) {
@@ -399,6 +658,18 @@ export async function testRoutes(app: FastifyInstance) {
return reply.code(upstream.status).send(normalizeErrorPayload(text)); 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.hijack();
reply.raw.statusCode = 200; reply.raw.statusCode = 200;
reply.raw.setHeader('Content-Type', 'text/event-stream; charset=utf-8'); reply.raw.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
@@ -406,14 +677,8 @@ export async function testRoutes(app: FastifyInstance) {
reply.raw.setHeader('Connection', 'keep-alive'); reply.raw.setHeader('Connection', 'keep-alive');
reply.raw.setHeader('X-Accel-Buffering', 'no'); reply.raw.setHeader('X-Accel-Buffering', 'no');
const reader = upstream.body?.getReader();
if (!reader) {
cleanupClientListeners();
reply.raw.end();
return;
}
try { try {
if (contentType.includes('text/event-stream')) {
while (true) { while (true) {
const { done, value } = await reader.read(); const { done, value } = await reader.read();
if (done) break; if (done) break;
@@ -421,6 +686,20 @@ export async function testRoutes(app: FastifyInstance) {
reply.raw.write(Buffer.from(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) { } catch (error) {
if (!reply.raw.writableEnded) { if (!reply.raw.writableEnded) {
const message = JSON.stringify({ const message = JSON.stringify({
@@ -439,22 +718,136 @@ export async function testRoutes(app: FastifyInstance) {
reply.raw.end(); reply.raw.end();
} }
} }
}
export async function testRoutes(app: FastifyInstance) {
const cleanupTimer = setInterval(cleanupExpiredJobs, JOB_CLEANUP_INTERVAL_MS);
cleanupTimer.unref?.();
app.addHook('onClose', async () => {
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 payload = validateLegacyPayload(request.body || {}, reply);
if (!payload) return;
return sendBufferedEnvelope(convertLegacyPayloadToEnvelope(payload, false), reply, false);
},
);
app.post<{ Body: TestChatRequestBody }>(
'/api/test/chat/stream',
async (request, reply) => {
const payload = validateLegacyPayload(request.body || {}, reply);
if (!payload) return;
return sendStreamingEnvelope(convertLegacyPayloadToEnvelope(payload, true), request, reply, true);
}, },
); );
app.post<{ Body: TestChatRequestBody }>( app.post<{ Body: TestChatRequestBody }>(
'/api/test/chat/jobs', '/api/test/chat/jobs',
async (request, reply) => { async (request, reply) => {
const body = request.body || {}; const payload = validateLegacyPayload(request.body || {}, reply);
const payload = validatePayload(body, reply);
if (!payload) return; if (!payload) return;
const envelope = convertLegacyPayloadToEnvelope(payload, false);
const now = Date.now(); const now = Date.now();
const jobId = randomUUID(); const jobId = randomUUID();
const job: TestChatJob = { const job: TestProxyJob = {
id: jobId, id: jobId,
status: 'pending', status: 'pending',
payload, envelope,
controller: null, controller: null,
createdAt: now, createdAt: now,
updatedAt: 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');
});
});
+93 -6
View File
@@ -4,6 +4,12 @@ import { db, schema } from '../../db/index.js';
import { rebuildTokenRoutesFromAvailability, refreshModelsAndRebuildRoutes } from '../../services/modelService.js'; import { rebuildTokenRoutesFromAvailability, refreshModelsAndRebuildRoutes } from '../../services/modelService.js';
import { invalidateTokenRouterCache, matchesModelPattern, tokenRouter } from '../../services/tokenRouter.js'; import { invalidateTokenRouterCache, matchesModelPattern, tokenRouter } from '../../services/tokenRouter.js';
import { startBackgroundTask } from '../../services/backgroundTaskService.js'; import { startBackgroundTask } from '../../services/backgroundTaskService.js';
import {
clearRouteDecisionSnapshot,
clearRouteDecisionSnapshots,
parseRouteDecisionSnapshot,
saveRouteDecisionSnapshots,
} from '../../services/routeDecisionSnapshotStore.js';
function isExactModelPattern(modelPattern: string): boolean { function isExactModelPattern(modelPattern: string): boolean {
const normalized = modelPattern.trim(); const normalized = modelPattern.trim();
@@ -200,6 +206,8 @@ type BatchChannelPriorityUpdate = {
type BatchRouteDecisionModels = { type BatchRouteDecisionModels = {
models: string[]; models: string[];
refreshPricingCatalog?: boolean;
persistSnapshots?: boolean;
}; };
type BatchRouteDecisionRouteModels = { type BatchRouteDecisionRouteModels = {
@@ -207,10 +215,14 @@ type BatchRouteDecisionRouteModels = {
routeId: number; routeId: number;
model: string; model: string;
}>; }>;
refreshPricingCatalog?: boolean;
persistSnapshots?: boolean;
}; };
type BatchRouteWideDecisionRouteIds = { type BatchRouteWideDecisionRouteIds = {
routeIds: number[]; routeIds: number[];
refreshPricingCatalog?: boolean;
persistSnapshots?: boolean;
}; };
function parseBatchChannelUpdates(input: unknown): { ok: true; updates: BatchChannelPriorityUpdate[] } | { ok: false; message: string } { function parseBatchChannelUpdates(input: unknown): { ok: true; updates: BatchChannelPriorityUpdate[] } | { ok: false; message: string } {
@@ -252,7 +264,9 @@ function parseBatchChannelUpdates(input: unknown): { ok: true; updates: BatchCha
return { ok: true, updates: normalized }; 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') { if (!input || typeof input !== 'object') {
return { ok: false, message: '请求体必须是对象' }; return { ok: false, message: '请求体必须是对象' };
} }
@@ -277,12 +291,17 @@ function parseBatchRouteDecisionModels(input: unknown): { ok: true; models: stri
return { ok: false, message: 'models 中没有有效模型名称' }; return { ok: false, message: 'models 中没有有效模型名称' };
} }
return { ok: true, models: normalized }; return {
ok: true,
models: normalized,
refreshPricingCatalog: (input as { refreshPricingCatalog?: unknown }).refreshPricingCatalog === true,
persistSnapshots: (input as { persistSnapshots?: unknown }).persistSnapshots === true,
};
} }
function parseBatchRouteDecisionRouteModels( function parseBatchRouteDecisionRouteModels(
input: unknown, input: unknown,
): { ok: true; items: Array<{ routeId: number; model: string }> } | { ok: false; message: string } { ): { ok: true; items: Array<{ routeId: number; model: string }>; refreshPricingCatalog: boolean; persistSnapshots: boolean } | { ok: false; message: string } {
if (!input || typeof input !== 'object') { if (!input || typeof input !== 'object') {
return { ok: false, message: '请求体必须是对象' }; return { ok: false, message: '请求体必须是对象' };
} }
@@ -316,12 +335,17 @@ function parseBatchRouteDecisionRouteModels(
return { ok: false, message: 'items 中没有有效 routeId/model' }; return { ok: false, message: 'items 中没有有效 routeId/model' };
} }
return { ok: true, items: normalized }; return {
ok: true,
items: normalized,
refreshPricingCatalog: (input as { refreshPricingCatalog?: unknown }).refreshPricingCatalog === true,
persistSnapshots: (input as { persistSnapshots?: unknown }).persistSnapshots === true,
};
} }
function parseBatchRouteWideDecisionRouteIds( function parseBatchRouteWideDecisionRouteIds(
input: unknown, input: unknown,
): { ok: true; routeIds: number[] } | { ok: false; message: string } { ): { ok: true; routeIds: number[]; refreshPricingCatalog: boolean; persistSnapshots: boolean } | { ok: false; message: string } {
if (!input || typeof input !== 'object') { if (!input || typeof input !== 'object') {
return { ok: false, message: '请求体必须是对象' }; return { ok: false, message: '请求体必须是对象' };
} }
@@ -346,7 +370,12 @@ function parseBatchRouteWideDecisionRouteIds(
return { ok: false, message: 'routeIds 中没有有效 routeId' }; 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) { export async function tokensRoutes(app: FastifyInstance) {
@@ -386,6 +415,8 @@ export async function tokensRoutes(app: FastifyInstance) {
return routes.map((route) => ({ return routes.map((route) => ({
...route, ...route,
decisionSnapshot: parseRouteDecisionSnapshot(route.decisionSnapshot),
decisionRefreshedAt: route.decisionRefreshedAt ?? null,
channels: channelsByRoute.get(route.id) || [], channels: channelsByRoute.get(route.id) || [],
})); }));
}); });
@@ -407,10 +438,33 @@ export async function tokensRoutes(app: FastifyInstance) {
} }
const decisions: Record<string, Awaited<ReturnType<typeof tokenRouter.explainSelection>>> = {}; 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) { for (const model of parsed.models) {
if (parsed.refreshPricingCatalog) {
await tokenRouter.refreshPricingReferenceCosts(model, { refreshedKeys });
}
decisions[model] = await tokenRouter.explainSelection(model); 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 };
}); });
@@ -421,12 +475,23 @@ export async function tokensRoutes(app: FastifyInstance) {
} }
const decisions: Record<string, Record<string, Awaited<ReturnType<typeof tokenRouter.explainSelectionForRoute>>>> = {}; 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) { for (const item of parsed.items) {
const routeKey = String(item.routeId); const routeKey = String(item.routeId);
if (!decisions[routeKey]) decisions[routeKey] = {}; 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); 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 };
}); });
@@ -437,10 +502,21 @@ export async function tokensRoutes(app: FastifyInstance) {
} }
const decisions: Record<string, Awaited<ReturnType<typeof tokenRouter.explainSelectionRouteWide>>> = {}; const decisions: Record<string, Awaited<ReturnType<typeof tokenRouter.explainSelectionRouteWide>>> = {};
const refreshedKeys = parsed.refreshPricingCatalog ? new Set<string>() : undefined;
for (const routeId of parsed.routeIds) { for (const routeId of parsed.routeIds) {
if (parsed.refreshPricingCatalog) {
await tokenRouter.refreshRouteWidePricingReferenceCosts(routeId, { refreshedKeys });
}
decisions[String(routeId)] = await tokenRouter.explainSelectionRouteWide(routeId); decisions[String(routeId)] = await tokenRouter.explainSelectionRouteWide(routeId);
} }
if (parsed.persistSnapshots) {
await saveRouteDecisionSnapshots(parsed.routeIds.map((routeId) => ({
routeId,
snapshot: decisions[String(routeId)] ?? null,
})));
}
return { success: true, decisions }; return { success: true, decisions };
}); });
@@ -492,9 +568,13 @@ export async function tokensRoutes(app: FastifyInstance) {
await db.update(schema.tokenRoutes).set(updates).where(eq(schema.tokenRoutes.id, id)).run(); await db.update(schema.tokenRoutes).set(updates).where(eq(schema.tokenRoutes.id, id)).run();
const modelPatternChanged = body.modelPattern !== undefined && nextModelPattern !== existingRoute.modelPattern; const modelPatternChanged = body.modelPattern !== undefined && nextModelPattern !== existingRoute.modelPattern;
const routeBehaviorChanged = modelPatternChanged || body.modelMapping !== undefined || body.enabled !== undefined;
if (modelPatternChanged) { if (modelPatternChanged) {
await rebuildAutomaticRouteChannelsByModelPattern(id, nextModelPattern); await rebuildAutomaticRouteChannelsByModelPattern(id, nextModelPattern);
} }
if (routeBehaviorChanged) {
await clearRouteDecisionSnapshot(id);
}
invalidateTokenRouterCache(); invalidateTokenRouterCache();
return await db.select().from(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, id)).get(); return await db.select().from(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, id)).get();
}); });
@@ -558,6 +638,7 @@ export async function tokensRoutes(app: FastifyInstance) {
if (!created) { if (!created) {
return reply.code(500).send({ success: false, message: '创建通道失败' }); return reply.code(500).send({ success: false, message: '创建通道失败' });
} }
await clearRouteDecisionSnapshot(routeId);
invalidateTokenRouterCache(); invalidateTokenRouterCache();
return created; return created;
}); });
@@ -589,6 +670,7 @@ export async function tokensRoutes(app: FastifyInstance) {
const updatedChannels = await db.select().from(schema.routeChannels) const updatedChannels = await db.select().from(schema.routeChannels)
.where(inArray(schema.routeChannels.id, channelIds)) .where(inArray(schema.routeChannels.id, channelIds))
.all(); .all();
await clearRouteDecisionSnapshots(existingChannels.map((channel) => channel.routeId));
invalidateTokenRouterCache(); invalidateTokenRouterCache();
return { success: true, channels: updatedChannels }; return { success: true, channels: updatedChannels };
}); });
@@ -634,6 +716,7 @@ export async function tokensRoutes(app: FastifyInstance) {
} }
await db.update(schema.routeChannels).set(updates).where(eq(schema.routeChannels.id, channelId)).run(); await db.update(schema.routeChannels).set(updates).where(eq(schema.routeChannels.id, channelId)).run();
await clearRouteDecisionSnapshot(channel.routeId);
invalidateTokenRouterCache(); invalidateTokenRouterCache();
return await db.select().from(schema.routeChannels).where(eq(schema.routeChannels.id, channelId)).get(); return await db.select().from(schema.routeChannels).where(eq(schema.routeChannels.id, channelId)).get();
}); });
@@ -641,7 +724,11 @@ export async function tokensRoutes(app: FastifyInstance) {
// Delete a channel // Delete a channel
app.delete<{ Params: { channelId: string } }>('/api/channels/:channelId', async (request) => { app.delete<{ Params: { channelId: string } }>('/api/channels/:channelId', async (request) => {
const channelId = parseInt(request.params.channelId, 10); 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(); await db.delete(schema.routeChannels).where(eq(schema.routeChannels.id, channelId)).run();
if (channel) {
await clearRouteDecisionSnapshot(channel.routeId);
}
invalidateTokenRouterCache(); invalidateTokenRouterCache();
return { success: true }; return { success: true };
}); });
@@ -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
View File
@@ -9,19 +9,7 @@ import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js';
import { resolveProxyUsageWithSelfLogFallback } from '../../services/proxyUsageFallbackService.js'; import { resolveProxyUsageWithSelfLogFallback } from '../../services/proxyUsageFallbackService.js';
import { mergeProxyUsage, parseProxyUsage } from '../../services/proxyUsageParser.js'; import { mergeProxyUsage, parseProxyUsage } from '../../services/proxyUsageParser.js';
import { resolveProxyUrlForSite, withSiteRecordProxyRequestInit } from '../../services/siteProxy.js'; import { resolveProxyUrlForSite, withSiteRecordProxyRequestInit } from '../../services/siteProxy.js';
import { import { type DownstreamFormat } from '../../transformers/shared/normalized.js';
type DownstreamFormat,
createStreamTransformContext,
createClaudeDownstreamContext,
parseDownstreamChatRequest,
pullSseEventsWithDone,
normalizeUpstreamStreamEvent,
serializeNormalizedStreamEvent,
serializeStreamDone,
normalizeUpstreamFinalResponse,
serializeFinalResponse,
buildSyntheticOpenAiChunks,
} from './chatFormats.js';
import { import {
buildMinimalJsonHeadersForCompatibility, buildMinimalJsonHeadersForCompatibility,
buildUpstreamEndpointRequest, buildUpstreamEndpointRequest,
@@ -38,19 +26,16 @@ import { composeProxyLogMessage } from './logPathMeta.js';
import { executeEndpointFlow, withUpstreamPath } from './endpointFlow.js'; import { executeEndpointFlow, withUpstreamPath } from './endpointFlow.js';
import { formatUtcSqlDateTime } from '../../services/localTimeService.js'; import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
import { resolveProxyLogBilling } from './proxyBilling.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 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( function shouldRetryClaudeMessagesWithNormalizedBody(
downstreamFormat: DownstreamFormat, downstreamFormat: DownstreamFormat,
endpointPath: string, endpointPath: string,
@@ -71,53 +56,6 @@ function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object'; 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) { export async function chatProxyRoute(app: FastifyInstance) {
app.post('/v1/chat/completions', async (request: FastifyRequest, reply: FastifyReply) => app.post('/v1/chat/completions', async (request: FastifyRequest, reply: FastifyReply) =>
handleChatProxyRequest(request, reply, 'openai')); handleChatProxyRequest(request, reply, 'openai'));
@@ -133,7 +71,10 @@ async function handleChatProxyRequest(
reply: FastifyReply, reply: FastifyReply,
downstreamFormat: DownstreamFormat, downstreamFormat: DownstreamFormat,
) { ) {
const parsedRequest = parseDownstreamChatRequest(request.body, downstreamFormat); const downstreamTransformer = downstreamFormat === 'claude'
? anthropicMessagesTransformer
: openAiChatTransformer;
const parsedRequest = downstreamTransformer.transformRequest(request.body);
if (parsedRequest.error) { if (parsedRequest.error) {
return reply.code(parsedRequest.error.statusCode).send(parsedRequest.error.payload); 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('Connection', 'keep-alive');
reply.raw.setHeader('X-Accel-Buffering', 'no'); reply.raw.setHeader('X-Accel-Buffering', 'no');
const streamContext = createStreamTransformContext(modelName); const streamContext = downstreamTransformer.createStreamContext(modelName);
const claudeContext = createClaudeDownstreamContext(); const claudeContext = anthropicMessagesTransformer.createDownstreamContext();
let parsedUsage: ReturnType<typeof parseProxyUsage> = { let parsedUsage: ReturnType<typeof parseProxyUsage> = {
promptTokens: 0, promptTokens: 0,
completionTokens: 0, completionTokens: 0,
@@ -363,38 +304,24 @@ async function handleChatProxyRequest(
}; };
const writeDone = () => { const writeDone = () => {
writeLines(serializeStreamDone(downstreamFormat, streamContext, claudeContext)); writeLines(downstreamTransformer.serializeDone(streamContext, claudeContext));
}; };
const emitNormalizedFinalAsStream = (upstreamData: unknown, fallbackText = '') => { const emitNormalizedFinalAsStream = (upstreamData: unknown, fallbackText = '') => {
const normalizedFinal = normalizeUpstreamFinalResponse(upstreamData, modelName, fallbackText); const normalizedFinal = downstreamTransformer.transformFinalResponse(upstreamData, modelName, fallbackText);
streamContext.id = normalizedFinal.id; streamContext.id = normalizedFinal.id;
streamContext.model = normalizedFinal.model; streamContext.model = normalizedFinal.model;
streamContext.created = normalizedFinal.created; streamContext.created = normalizedFinal.created;
if (downstreamFormat === 'openai') { if (downstreamFormat === 'openai') {
const syntheticChunks = buildSyntheticOpenAiChunks(normalizedFinal); const syntheticChunks = openAiChatTransformer.buildSyntheticChunks(normalizedFinal);
for (const chunk of syntheticChunks) { for (const chunk of syntheticChunks) {
reply.raw.write(`data: ${JSON.stringify(chunk)}\n\n`); reply.raw.write(`data: ${JSON.stringify(chunk)}\n\n`);
} }
return; return;
} }
writeLines(serializeNormalizedStreamEvent('claude', { role: 'assistant' }, streamContext, claudeContext)); writeLines(serializeAnthropicFinalAsStream(normalizedFinal, 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));
}; };
const upstreamContentType = (upstream.headers.get('content-type') || '').toLowerCase(); const upstreamContentType = (upstream.headers.get('content-type') || '').toLowerCase();
@@ -470,7 +397,7 @@ async function handleChatProxyRequest(
let shouldTerminateEarly = false; let shouldTerminateEarly = false;
const consumeSseBuffer = (incoming: string): string => { const consumeSseBuffer = (incoming: string): string => {
const pulled = pullSseEventsWithDone(incoming); const pulled = downstreamTransformer.pullSseEvents(incoming);
for (const eventBlock of pulled.events) { for (const eventBlock of pulled.events) {
if (eventBlock.data === '[DONE]') { if (eventBlock.data === '[DONE]') {
writeDone(); writeDone();
@@ -487,23 +414,22 @@ async function handleChatProxyRequest(
if (parsedPayload && typeof parsedPayload === 'object') { if (parsedPayload && typeof parsedPayload === 'object') {
parsedUsage = mergeProxyUsage(parsedUsage, parseProxyUsage(parsedPayload)); parsedUsage = mergeProxyUsage(parsedUsage, parseProxyUsage(parsedPayload));
if (downstreamFormat === 'claude') { if (downstreamFormat === 'claude') {
const payloadType = (isRecord(parsedPayload) && typeof parsedPayload.type === 'string') const payloadType = (isRecord(parsedPayload) && typeof parsedPayload.type === 'string')
? parsedPayload.type ? parsedPayload.type
: ''; : '';
const claudeEventName = isClaudeSseEventName(eventBlock.event) const claudeEventName = isAnthropicRawSseEventName(eventBlock.event)
? eventBlock.event ? eventBlock.event
: (isClaudeSseEventName(payloadType) ? payloadType : ''); : (isAnthropicRawSseEventName(payloadType) ? payloadType : '');
if (claudeEventName) { if (claudeEventName) {
syncClaudeStreamStateFromRawEvent( syncAnthropicRawStreamStateFromEvent(
claudeEventName, claudeEventName,
parsedPayload, parsedPayload,
streamContext, streamContext,
claudeContext, claudeContext,
); );
reply.raw.write(serializeRawSseEvent(claudeEventName, eventBlock.data)); reply.raw.write(serializeAnthropicRawSseEvent(claudeEventName, eventBlock.data));
if (claudeContext.doneSent) { if (claudeContext.doneSent) {
shouldTerminateEarly = true; shouldTerminateEarly = true;
break; break;
@@ -511,19 +437,13 @@ async function handleChatProxyRequest(
continue; continue;
} }
} }
const normalizedEvent = downstreamTransformer.transformStreamEvent(parsedPayload, streamContext, modelName);
const normalizedEvent = normalizeUpstreamStreamEvent(parsedPayload, streamContext, modelName); writeLines(downstreamTransformer.serializeStreamEvent(normalizedEvent, streamContext, claudeContext));
writeLines(serializeNormalizedStreamEvent(
downstreamFormat,
normalizedEvent,
streamContext,
claudeContext,
));
if (downstreamFormat === 'claude' && claudeContext.doneSent) { if (downstreamFormat === 'claude' && claudeContext.doneSent) {
shouldTerminateEarly = true; shouldTerminateEarly = true;
break; break;
} }
if (downstreamFormat === 'openai' && streamContext.doneSent) { if (streamContext.doneSent) {
shouldTerminateEarly = true; shouldTerminateEarly = true;
break; break;
} }
@@ -533,7 +453,7 @@ async function handleChatProxyRequest(
if (downstreamFormat === 'openai') { if (downstreamFormat === 'openai') {
reply.raw.write(`data: ${eventBlock.data}\n\n`); reply.raw.write(`data: ${eventBlock.data}\n\n`);
} else { } else {
writeLines(serializeNormalizedStreamEvent('claude', { writeLines(anthropicMessagesTransformer.serializeStreamEvent({
contentDelta: eventBlock.data, contentDelta: eventBlock.data,
}, streamContext, claudeContext)); }, streamContext, claudeContext));
if (claudeContext.doneSent) { if (claudeContext.doneSent) {
@@ -628,8 +548,8 @@ async function handleChatProxyRequest(
const latency = Date.now() - startTime; const latency = Date.now() - startTime;
const parsedUsage = parseProxyUsage(upstreamData); const parsedUsage = parseProxyUsage(upstreamData);
const normalizedFinal = normalizeUpstreamFinalResponse(upstreamData, modelName, rawText); const normalizedFinal = downstreamTransformer.transformFinalResponse(upstreamData, modelName, rawText);
const downstreamResponse = serializeFinalResponse(downstreamFormat, normalizedFinal, parsedUsage); const downstreamResponse = downstreamTransformer.serializeFinalResponse(normalizedFinal, parsedUsage);
const resolvedUsage = await resolveProxyUsageWithSelfLogFallback({ const resolvedUsage = await resolveProxyUsageWithSelfLogFallback({
site: selected.site, site: selected.site,
File diff suppressed because it is too large Load Diff
+430
View File
@@ -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');
});
});
+196
View File
@@ -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',
},
});
});
});
+133 -1
View File
@@ -11,10 +11,13 @@ import { ensureModelAllowedForDownstreamKey, getDownstreamRoutingPolicy, recordD
import { withSiteRecordProxyRequestInit } from '../../services/siteProxy.js'; import { withSiteRecordProxyRequestInit } from '../../services/siteProxy.js';
import { composeProxyLogMessage } from './logPathMeta.js'; import { composeProxyLogMessage } from './logPathMeta.js';
import { formatUtcSqlDateTime } from '../../services/localTimeService.js'; import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
import { cloneFormDataWithOverrides, ensureMultipartBufferParser, parseMultipartFormData } from './multipart.js';
const MAX_RETRIES = 2; const MAX_RETRIES = 2;
export async function imagesProxyRoute(app: FastifyInstance) { export async function imagesProxyRoute(app: FastifyInstance) {
ensureMultipartBufferParser(app);
app.post('/v1/images/generations', async (request: FastifyRequest, reply: FastifyReply) => { app.post('/v1/images/generations', async (request: FastifyRequest, reply: FastifyReply) => {
const body = request.body as any; const body = request.body as any;
const requestedModel = body?.model || 'gpt-image-1'; const requestedModel = body?.model || 'gpt-image-1';
@@ -115,6 +118,134 @@ export async function imagesProxyRoute(app: FastifyInstance) {
} }
} }
}); });
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( async function logProxy(
@@ -126,11 +257,12 @@ async function logProxy(
errorMessage: string | null, errorMessage: string | null,
retryCount: number, retryCount: number,
estimatedCost = 0, estimatedCost = 0,
downstreamPath = '/v1/images/generations',
) { ) {
try { try {
const createdAt = formatUtcSqlDateTime(new Date()); const createdAt = formatUtcSqlDateTime(new Date());
const normalizedErrorMessage = composeProxyLogMessage({ const normalizedErrorMessage = composeProxyLogMessage({
downstreamPath: '/v1/images/generations', downstreamPath,
errorMessage, errorMessage,
}); });
await db.insert(schema.proxyLogs).values({ await db.insert(schema.proxyLogs).values({
+86
View File
@@ -284,4 +284,90 @@ describe('/v1/models route', () => {
expect(ids).not.toContain('claude-opus-4-5'); expect(ids).not.toContain('claude-opus-4-5');
expect(ids).not.toContain('claude-sonnet-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');
});
}); });
+9 -1
View File
@@ -6,6 +6,12 @@ import { getDownstreamRoutingPolicy } from './downstreamPolicy.js';
import { tokenRouter } from '../../services/tokenRouter.js'; import { tokenRouter } from '../../services/tokenRouter.js';
import { isModelAllowedByPolicyOrAllowedRoutes } from '../../services/downstreamApiKeyService.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) { export async function modelsProxyRoute(app: FastifyInstance) {
app.get('/v1/models', async (request) => { app.get('/v1/models', async (request) => {
const downstreamPolicy = getDownstreamRoutingPolicy(request); const downstreamPolicy = getDownstreamRoutingPolicy(request);
@@ -32,7 +38,9 @@ export async function modelsProxyRoute(app: FastifyInstance) {
const deduped = Array.from(new Set([ const deduped = Array.from(new Set([
...rows.map((r) => r.modelName), ...rows.map((r) => r.modelName),
...routeAliases, ...routeAliases,
])).sort(); ]))
.filter((modelName) => !isSearchPseudoModel(modelName))
.sort();
const allowed: string[] = []; const allowed: string[] = [];
for (const modelName of deduped) { for (const modelName of deduped) {
if (!await isModelAllowedByPolicyOrAllowedRoutes(modelName, downstreamPolicy)) { if (!await isModelAllowedByPolicyOrAllowedRoutes(modelName, downstreamPolicy)) {
+71
View File
@@ -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;
}
+19 -898
View File
@@ -9,13 +9,16 @@ import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js';
import { resolveProxyUsageWithSelfLogFallback } from '../../services/proxyUsageFallbackService.js'; import { resolveProxyUsageWithSelfLogFallback } from '../../services/proxyUsageFallbackService.js';
import { mergeProxyUsage, parseProxyUsage } from '../../services/proxyUsageParser.js'; import { mergeProxyUsage, parseProxyUsage } from '../../services/proxyUsageParser.js';
import { resolveProxyUrlForSite, withSiteRecordProxyRequestInit } from '../../services/siteProxy.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 { import {
createStreamTransformContext, completeResponsesStream,
normalizeUpstreamFinalResponse, createOpenAiResponsesAggregateState,
normalizeUpstreamStreamEvent, failResponsesStream,
pullSseEventsWithDone, serializeConvertedResponsesEvents,
type NormalizedStreamEvent, } from '../../transformers/openai/responses/aggregator.js';
} from './chatFormats.js';
import { import {
buildMinimalJsonHeadersForCompatibility, buildMinimalJsonHeadersForCompatibility,
buildUpstreamEndpointRequest, buildUpstreamEndpointRequest,
@@ -248,894 +251,8 @@ function normalizeText(value: unknown): string {
return ''; 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 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) { export async function responsesProxyRoute(app: FastifyInstance) {
const handleResponsesRequest = async ( const handleResponsesRequest = async (
request: FastifyRequest, request: FastifyRequest,
@@ -1374,15 +491,15 @@ export async function responsesProxyRoute(app: FastifyInstance) {
let sseBuffer = ''; let sseBuffer = '';
const passthroughResponsesStream = successfulUpstreamPath === '/v1/responses'; const passthroughResponsesStream = successfulUpstreamPath === '/v1/responses';
const streamContext = createStreamTransformContext(modelName); const streamContext = openAiResponsesTransformer.createStreamContext(modelName);
const responsesState = createResponsesStreamState(modelName); const responsesState = createOpenAiResponsesAggregateState(modelName);
const writeLines = (lines: string[]) => { const writeLines = (lines: string[]) => {
for (const line of lines) reply.raw.write(line); for (const line of lines) reply.raw.write(line);
}; };
const consumeSseBuffer = (incoming: string): string => { const consumeSseBuffer = (incoming: string): string => {
const pulled = pullSseEventsWithDone(incoming); const pulled = openAiResponsesTransformer.pullSseEvents(incoming);
for (const eventBlock of pulled.events) { for (const eventBlock of pulled.events) {
if (eventBlock.data === '[DONE]') { if (eventBlock.data === '[DONE]') {
if (passthroughResponsesStream) { if (passthroughResponsesStream) {
@@ -1425,7 +542,7 @@ export async function responsesProxyRoute(app: FastifyInstance) {
} }
if (parsedPayload && typeof parsedPayload === 'object') { if (parsedPayload && typeof parsedPayload === 'object') {
const normalizedEvent = normalizeUpstreamStreamEvent(parsedPayload, streamContext, modelName); const normalizedEvent = openAiResponsesTransformer.transformStreamEvent(parsedPayload, streamContext, modelName);
writeLines(serializeConvertedResponsesEvents({ writeLines(serializeConvertedResponsesEvents({
state: responsesState, state: responsesState,
streamContext, streamContext,
@@ -1510,12 +627,16 @@ export async function responsesProxyRoute(app: FastifyInstance) {
} }
const latency = Date.now() - startTime; const latency = Date.now() - startTime;
const parsedUsage = parseProxyUsage(upstreamData); const parsedUsage = parseProxyUsage(upstreamData);
const normalized = normalizeUpstreamFinalResponse( const normalized = openAiResponsesTransformer.transformFinalResponse(
upstreamData, upstreamData,
modelName, modelName,
rawText, rawText,
); );
const downstreamData = toResponsesPayload(upstreamData, normalized, parsedUsage); const downstreamData = serializeResponsesFinalPayload({
upstreamPayload: upstreamData,
normalized,
usage: parsedUsage,
});
const resolvedUsage = await resolveProxyUsageWithSelfLogFallback({ const resolvedUsage = await resolveProxyUsageWithSelfLogFallback({
site: selected.site, site: selected.site,
account: selected.account, account: selected.account,
+6
View File
@@ -6,6 +6,9 @@ import { embeddingsProxyRoute } from './embeddings.js';
import { completionsProxyRoute } from './completions.js'; import { completionsProxyRoute } from './completions.js';
import { responsesProxyRoute } from './responses.js'; import { responsesProxyRoute } from './responses.js';
import { imagesProxyRoute } from './images.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) { export async function proxyRoutes(app: FastifyInstance) {
// Auth middleware for all /v1 routes // Auth middleware for all /v1 routes
@@ -19,5 +22,8 @@ export async function proxyRoutes(app: FastifyInstance) {
await app.register(responsesProxyRoute); await app.register(responsesProxyRoute);
await app.register(modelsProxyRoute); await app.register(modelsProxyRoute);
await app.register(embeddingsProxyRoute); await app.register(embeddingsProxyRoute);
await app.register(searchProxyRoute);
await app.register(imagesProxyRoute); await app.register(imagesProxyRoute);
await app.register(videosProxyRoute);
await app.register(geminiProxyRoute);
} }
+197
View File
@@ -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();
});
});
+180
View File
@@ -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', () => { it('applies global responses standardization and drops non-standard fields', () => {
const request = buildUpstreamEndpointRequest({ const request = buildUpstreamEndpointRequest({
endpoint: 'responses', endpoint: 'responses',
@@ -419,4 +497,283 @@ describe('buildUpstreamEndpointRequest', () => {
expect(request.headers['content-type']).toBeUndefined(); expect(request.headers['content-type']).toBeUndefined();
expect(request.headers['Content-Type']).toBe('application/json'); 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' },
},
],
},
]);
});
}); });
+14 -659
View File
@@ -1,5 +1,13 @@
import { fetchModelPricingCatalog } from '../../services/modelPricingService.js'; 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 UpstreamEndpoint = 'chat' | 'messages' | 'responses';
export type EndpointPreference = DownstreamFormat | '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; 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[] { function normalizeEndpointTypes(value: unknown): UpstreamEndpoint[] {
const raw = asTrimmedString(value).toLowerCase(); const raw = asTrimmedString(value).toLowerCase();
@@ -1104,9 +458,9 @@ export function buildUpstreamEndpointRequest(input: {
model: input.modelName, model: input.modelName,
stream: input.stream, 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({ const headers = ensureStreamAcceptHeader({
...commonHeaders, ...commonHeaders,
@@ -1133,9 +487,9 @@ export function buildUpstreamEndpointRequest(input: {
model: input.modelName, model: input.modelName,
stream: input.stream, 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({ const headers = ensureStreamAcceptHeader({
...commonHeaders, ...commonHeaders,
@@ -1291,3 +645,4 @@ export function isEndpointDowngradeError(status: number, upstreamErrorText?: str
) )
); );
} }
+248
View File
@@ -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');
});
});
+229
View File
@@ -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' },
});
});
}
@@ -388,9 +388,10 @@ async function ensureSchema(client: SqlClient): Promise<void> {
`CREATE TABLE IF NOT EXISTS "checkin_logs" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "status" TEXT NOT NULL, "message" TEXT, "reward" TEXT, "created_at" TEXT)`, `CREATE TABLE IF NOT EXISTS "checkin_logs" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "status" TEXT NOT NULL, "message" TEXT, "reward" TEXT, "created_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "model_availability" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "model_name" TEXT NOT NULL, "available" BOOLEAN, "latency_ms" INTEGER, "checked_at" TEXT)`, `CREATE TABLE IF NOT EXISTS "model_availability" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "model_name" TEXT NOT NULL, "available" BOOLEAN, "latency_ms" INTEGER, "checked_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "token_model_availability" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "token_id" INTEGER NOT NULL REFERENCES "account_tokens"("id") ON DELETE CASCADE, "model_name" TEXT NOT NULL, "available" BOOLEAN, "latency_ms" INTEGER, "checked_at" TEXT)`, `CREATE TABLE IF NOT EXISTS "token_model_availability" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "token_id" INTEGER NOT NULL REFERENCES "account_tokens"("id") ON DELETE CASCADE, "model_name" TEXT NOT NULL, "available" BOOLEAN, "latency_ms" INTEGER, "checked_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "token_routes" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "model_pattern" TEXT NOT NULL, "display_name" TEXT, "display_icon" TEXT, "model_mapping" TEXT, "enabled" BOOLEAN DEFAULT TRUE, "created_at" TEXT, "updated_at" TEXT)`, `CREATE TABLE IF NOT EXISTS "token_routes" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "model_pattern" TEXT NOT NULL, "display_name" TEXT, "display_icon" TEXT, "model_mapping" TEXT, "decision_snapshot" TEXT, "decision_refreshed_at" TEXT, "enabled" BOOLEAN DEFAULT TRUE, "created_at" TEXT, "updated_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "route_channels" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "route_id" INTEGER NOT NULL REFERENCES "token_routes"("id") ON DELETE CASCADE, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "token_id" INTEGER REFERENCES "account_tokens"("id") ON DELETE SET NULL, "source_model" TEXT, "priority" INTEGER DEFAULT 0, "weight" INTEGER DEFAULT 10, "enabled" BOOLEAN DEFAULT TRUE, "manual_override" BOOLEAN DEFAULT FALSE, "success_count" INTEGER DEFAULT 0, "fail_count" INTEGER DEFAULT 0, "total_latency_ms" INTEGER DEFAULT 0, "total_cost" DOUBLE PRECISION DEFAULT 0, "last_used_at" TEXT, "last_fail_at" TEXT, "cooldown_until" TEXT)`, `CREATE TABLE IF NOT EXISTS "route_channels" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "route_id" INTEGER NOT NULL REFERENCES "token_routes"("id") ON DELETE CASCADE, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "token_id" INTEGER REFERENCES "account_tokens"("id") ON DELETE SET NULL, "source_model" TEXT, "priority" INTEGER DEFAULT 0, "weight" INTEGER DEFAULT 10, "enabled" BOOLEAN DEFAULT TRUE, "manual_override" BOOLEAN DEFAULT FALSE, "success_count" INTEGER DEFAULT 0, "fail_count" INTEGER DEFAULT 0, "total_latency_ms" INTEGER DEFAULT 0, "total_cost" DOUBLE PRECISION DEFAULT 0, "last_used_at" TEXT, "last_fail_at" TEXT, "cooldown_until" TEXT)`,
`CREATE TABLE IF NOT EXISTS "proxy_logs" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "route_id" INTEGER, "channel_id" INTEGER, "account_id" INTEGER, "model_requested" TEXT, "model_actual" TEXT, "status" TEXT, "http_status" INTEGER, "latency_ms" INTEGER, "prompt_tokens" INTEGER, "completion_tokens" INTEGER, "total_tokens" INTEGER, "estimated_cost" DOUBLE PRECISION, "billing_details" TEXT, "error_message" TEXT, "retry_count" INTEGER DEFAULT 0, "created_at" TEXT)`, `CREATE TABLE IF NOT EXISTS "proxy_logs" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "route_id" INTEGER, "channel_id" INTEGER, "account_id" INTEGER, "model_requested" TEXT, "model_actual" TEXT, "status" TEXT, "http_status" INTEGER, "latency_ms" INTEGER, "prompt_tokens" INTEGER, "completion_tokens" INTEGER, "total_tokens" INTEGER, "estimated_cost" DOUBLE PRECISION, "billing_details" TEXT, "error_message" TEXT, "retry_count" INTEGER DEFAULT 0, "created_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "proxy_video_tasks" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "public_id" TEXT NOT NULL UNIQUE, "upstream_video_id" TEXT NOT NULL, "site_url" TEXT NOT NULL, "token_value" TEXT NOT NULL, "requested_model" TEXT, "actual_model" TEXT, "channel_id" INTEGER, "account_id" INTEGER, "status_snapshot" TEXT, "upstream_response_meta" TEXT, "last_upstream_status" INTEGER, "last_polled_at" TEXT, "created_at" TEXT, "updated_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "downstream_api_keys" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "name" TEXT NOT NULL, "key" TEXT NOT NULL UNIQUE, "description" TEXT, "enabled" BOOLEAN DEFAULT TRUE, "expires_at" TEXT, "max_cost" DOUBLE PRECISION, "used_cost" DOUBLE PRECISION DEFAULT 0, "max_requests" INTEGER, "used_requests" INTEGER DEFAULT 0, "supported_models" TEXT, "allowed_route_ids" TEXT, "site_weight_multipliers" TEXT, "last_used_at" TEXT, "created_at" TEXT, "updated_at" TEXT)`, `CREATE TABLE IF NOT EXISTS "downstream_api_keys" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "name" TEXT NOT NULL, "key" TEXT NOT NULL UNIQUE, "description" TEXT, "enabled" BOOLEAN DEFAULT TRUE, "expires_at" TEXT, "max_cost" DOUBLE PRECISION, "used_cost" DOUBLE PRECISION DEFAULT 0, "max_requests" INTEGER, "used_requests" INTEGER DEFAULT 0, "supported_models" TEXT, "allowed_route_ids" TEXT, "site_weight_multipliers" TEXT, "last_used_at" TEXT, "created_at" TEXT, "updated_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "events" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "type" TEXT NOT NULL, "title" TEXT NOT NULL, "message" TEXT, "level" TEXT DEFAULT 'info', "read" BOOLEAN DEFAULT FALSE, "related_id" INTEGER, "related_type" TEXT, "created_at" TEXT)`, `CREATE TABLE IF NOT EXISTS "events" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "type" TEXT NOT NULL, "title" TEXT NOT NULL, "message" TEXT, "level" TEXT DEFAULT 'info', "read" BOOLEAN DEFAULT FALSE, "related_id" INTEGER, "related_type" TEXT, "created_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "settings" ("key" TEXT PRIMARY KEY, "value" TEXT)`, `CREATE TABLE IF NOT EXISTS "settings" ("key" TEXT PRIMARY KEY, "value" TEXT)`,
@@ -403,9 +404,10 @@ async function ensureSchema(client: SqlClient): Promise<void> {
`CREATE TABLE IF NOT EXISTS \`checkin_logs\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`account_id\` INT NOT NULL, \`status\` VARCHAR(32) NOT NULL, \`message\` TEXT NULL, \`reward\` TEXT NULL, \`created_at\` TEXT NULL, CONSTRAINT \`checkin_logs_account_fk\` FOREIGN KEY (\`account_id\`) REFERENCES \`accounts\`(\`id\`) ON DELETE CASCADE)`, `CREATE TABLE IF NOT EXISTS \`checkin_logs\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`account_id\` INT NOT NULL, \`status\` VARCHAR(32) NOT NULL, \`message\` TEXT NULL, \`reward\` TEXT NULL, \`created_at\` TEXT NULL, CONSTRAINT \`checkin_logs_account_fk\` FOREIGN KEY (\`account_id\`) REFERENCES \`accounts\`(\`id\`) ON DELETE CASCADE)`,
`CREATE TABLE IF NOT EXISTS \`model_availability\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`account_id\` INT NOT NULL, \`model_name\` TEXT NOT NULL, \`available\` BOOLEAN NULL, \`latency_ms\` INT NULL, \`checked_at\` TEXT NULL, CONSTRAINT \`model_availability_account_fk\` FOREIGN KEY (\`account_id\`) REFERENCES \`accounts\`(\`id\`) ON DELETE CASCADE)`, `CREATE TABLE IF NOT EXISTS \`model_availability\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`account_id\` INT NOT NULL, \`model_name\` TEXT NOT NULL, \`available\` BOOLEAN NULL, \`latency_ms\` INT NULL, \`checked_at\` TEXT NULL, CONSTRAINT \`model_availability_account_fk\` FOREIGN KEY (\`account_id\`) REFERENCES \`accounts\`(\`id\`) ON DELETE CASCADE)`,
`CREATE TABLE IF NOT EXISTS \`token_model_availability\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`token_id\` INT NOT NULL, \`model_name\` TEXT NOT NULL, \`available\` BOOLEAN NULL, \`latency_ms\` INT NULL, \`checked_at\` TEXT NULL, CONSTRAINT \`token_model_availability_token_fk\` FOREIGN KEY (\`token_id\`) REFERENCES \`account_tokens\`(\`id\`) ON DELETE CASCADE)`, `CREATE TABLE IF NOT EXISTS \`token_model_availability\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`token_id\` INT NOT NULL, \`model_name\` TEXT NOT NULL, \`available\` BOOLEAN NULL, \`latency_ms\` INT NULL, \`checked_at\` TEXT NULL, CONSTRAINT \`token_model_availability_token_fk\` FOREIGN KEY (\`token_id\`) REFERENCES \`account_tokens\`(\`id\`) ON DELETE CASCADE)`,
`CREATE TABLE IF NOT EXISTS \`token_routes\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`model_pattern\` TEXT NOT NULL, \`display_name\` TEXT NULL, \`display_icon\` TEXT NULL, \`model_mapping\` TEXT NULL, \`enabled\` BOOLEAN DEFAULT TRUE, \`created_at\` TEXT NULL, \`updated_at\` TEXT NULL)`, `CREATE TABLE IF NOT EXISTS \`token_routes\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`model_pattern\` TEXT NOT NULL, \`display_name\` TEXT NULL, \`display_icon\` TEXT NULL, \`model_mapping\` TEXT NULL, \`decision_snapshot\` TEXT NULL, \`decision_refreshed_at\` TEXT NULL, \`enabled\` BOOLEAN DEFAULT TRUE, \`created_at\` TEXT NULL, \`updated_at\` TEXT NULL)`,
`CREATE TABLE IF NOT EXISTS \`route_channels\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`route_id\` INT NOT NULL, \`account_id\` INT NOT NULL, \`token_id\` INT NULL, \`source_model\` TEXT NULL, \`priority\` INT DEFAULT 0, \`weight\` INT DEFAULT 10, \`enabled\` BOOLEAN DEFAULT TRUE, \`manual_override\` BOOLEAN DEFAULT FALSE, \`success_count\` INT DEFAULT 0, \`fail_count\` INT DEFAULT 0, \`total_latency_ms\` INT DEFAULT 0, \`total_cost\` DOUBLE DEFAULT 0, \`last_used_at\` TEXT NULL, \`last_fail_at\` TEXT NULL, \`cooldown_until\` TEXT NULL, CONSTRAINT \`route_channels_route_fk\` FOREIGN KEY (\`route_id\`) REFERENCES \`token_routes\`(\`id\`) ON DELETE CASCADE, CONSTRAINT \`route_channels_account_fk\` FOREIGN KEY (\`account_id\`) REFERENCES \`accounts\`(\`id\`) ON DELETE CASCADE, CONSTRAINT \`route_channels_token_fk\` FOREIGN KEY (\`token_id\`) REFERENCES \`account_tokens\`(\`id\`) ON DELETE SET NULL)`, `CREATE TABLE IF NOT EXISTS \`route_channels\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`route_id\` INT NOT NULL, \`account_id\` INT NOT NULL, \`token_id\` INT NULL, \`source_model\` TEXT NULL, \`priority\` INT DEFAULT 0, \`weight\` INT DEFAULT 10, \`enabled\` BOOLEAN DEFAULT TRUE, \`manual_override\` BOOLEAN DEFAULT FALSE, \`success_count\` INT DEFAULT 0, \`fail_count\` INT DEFAULT 0, \`total_latency_ms\` INT DEFAULT 0, \`total_cost\` DOUBLE DEFAULT 0, \`last_used_at\` TEXT NULL, \`last_fail_at\` TEXT NULL, \`cooldown_until\` TEXT NULL, CONSTRAINT \`route_channels_route_fk\` FOREIGN KEY (\`route_id\`) REFERENCES \`token_routes\`(\`id\`) ON DELETE CASCADE, CONSTRAINT \`route_channels_account_fk\` FOREIGN KEY (\`account_id\`) REFERENCES \`accounts\`(\`id\`) ON DELETE CASCADE, CONSTRAINT \`route_channels_token_fk\` FOREIGN KEY (\`token_id\`) REFERENCES \`account_tokens\`(\`id\`) ON DELETE SET NULL)`,
`CREATE TABLE IF NOT EXISTS \`proxy_logs\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`route_id\` INT NULL, \`channel_id\` INT NULL, \`account_id\` INT NULL, \`model_requested\` TEXT NULL, \`model_actual\` TEXT NULL, \`status\` VARCHAR(32) NULL, \`http_status\` INT NULL, \`latency_ms\` INT NULL, \`prompt_tokens\` INT NULL, \`completion_tokens\` INT NULL, \`total_tokens\` INT NULL, \`estimated_cost\` DOUBLE NULL, \`billing_details\` TEXT NULL, \`error_message\` TEXT NULL, \`retry_count\` INT DEFAULT 0, \`created_at\` TEXT NULL)`, `CREATE TABLE IF NOT EXISTS \`proxy_logs\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`route_id\` INT NULL, \`channel_id\` INT NULL, \`account_id\` INT NULL, \`model_requested\` TEXT NULL, \`model_actual\` TEXT NULL, \`status\` VARCHAR(32) NULL, \`http_status\` INT NULL, \`latency_ms\` INT NULL, \`prompt_tokens\` INT NULL, \`completion_tokens\` INT NULL, \`total_tokens\` INT NULL, \`estimated_cost\` DOUBLE NULL, \`billing_details\` TEXT NULL, \`error_message\` TEXT NULL, \`retry_count\` INT DEFAULT 0, \`created_at\` TEXT NULL)`,
`CREATE TABLE IF NOT EXISTS \`proxy_video_tasks\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`public_id\` VARCHAR(191) NOT NULL UNIQUE, \`upstream_video_id\` TEXT NOT NULL, \`site_url\` TEXT NOT NULL, \`token_value\` TEXT NOT NULL, \`requested_model\` TEXT NULL, \`actual_model\` TEXT NULL, \`channel_id\` INT NULL, \`account_id\` INT NULL, \`status_snapshot\` TEXT NULL, \`upstream_response_meta\` TEXT NULL, \`last_upstream_status\` INT NULL, \`last_polled_at\` TEXT NULL, \`created_at\` TEXT NULL, \`updated_at\` TEXT NULL)`,
`CREATE TABLE IF NOT EXISTS \`downstream_api_keys\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`name\` TEXT NOT NULL, \`key\` VARCHAR(191) NOT NULL UNIQUE, \`description\` TEXT NULL, \`enabled\` BOOLEAN DEFAULT TRUE, \`expires_at\` TEXT NULL, \`max_cost\` DOUBLE NULL, \`used_cost\` DOUBLE DEFAULT 0, \`max_requests\` INT NULL, \`used_requests\` INT DEFAULT 0, \`supported_models\` TEXT NULL, \`allowed_route_ids\` TEXT NULL, \`site_weight_multipliers\` TEXT NULL, \`last_used_at\` TEXT NULL, \`created_at\` TEXT NULL, \`updated_at\` TEXT NULL)`, `CREATE TABLE IF NOT EXISTS \`downstream_api_keys\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`name\` TEXT NOT NULL, \`key\` VARCHAR(191) NOT NULL UNIQUE, \`description\` TEXT NULL, \`enabled\` BOOLEAN DEFAULT TRUE, \`expires_at\` TEXT NULL, \`max_cost\` DOUBLE NULL, \`used_cost\` DOUBLE DEFAULT 0, \`max_requests\` INT NULL, \`used_requests\` INT DEFAULT 0, \`supported_models\` TEXT NULL, \`allowed_route_ids\` TEXT NULL, \`site_weight_multipliers\` TEXT NULL, \`last_used_at\` TEXT NULL, \`created_at\` TEXT NULL, \`updated_at\` TEXT NULL)`,
`CREATE TABLE IF NOT EXISTS \`events\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`type\` VARCHAR(32) NOT NULL, \`title\` TEXT NOT NULL, \`message\` TEXT NULL, \`level\` VARCHAR(16) DEFAULT 'info', \`read\` BOOLEAN DEFAULT FALSE, \`related_id\` INT NULL, \`related_type\` VARCHAR(32) NULL, \`created_at\` TEXT NULL)`, `CREATE TABLE IF NOT EXISTS \`events\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`type\` VARCHAR(32) NOT NULL, \`title\` TEXT NOT NULL, \`message\` TEXT NULL, \`level\` VARCHAR(16) DEFAULT 'info', \`read\` BOOLEAN DEFAULT FALSE, \`related_id\` INT NULL, \`related_type\` VARCHAR(32) NULL, \`created_at\` TEXT NULL)`,
`CREATE TABLE IF NOT EXISTS \`settings\` (\`key\` VARCHAR(191) PRIMARY KEY, \`value\` TEXT NULL)`, `CREATE TABLE IF NOT EXISTS \`settings\` (\`key\` VARCHAR(191) PRIMARY KEY, \`value\` TEXT NULL)`,
@@ -417,9 +419,10 @@ async function ensureSchema(client: SqlClient): Promise<void> {
`CREATE TABLE IF NOT EXISTS "checkin_logs" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "status" TEXT NOT NULL, "message" TEXT, "reward" TEXT, "created_at" TEXT)`, `CREATE TABLE IF NOT EXISTS "checkin_logs" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "status" TEXT NOT NULL, "message" TEXT, "reward" TEXT, "created_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "model_availability" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "model_name" TEXT NOT NULL, "available" INTEGER, "latency_ms" INTEGER, "checked_at" TEXT)`, `CREATE TABLE IF NOT EXISTS "model_availability" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "model_name" TEXT NOT NULL, "available" INTEGER, "latency_ms" INTEGER, "checked_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "token_model_availability" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "token_id" INTEGER NOT NULL REFERENCES "account_tokens"("id") ON DELETE CASCADE, "model_name" TEXT NOT NULL, "available" INTEGER, "latency_ms" INTEGER, "checked_at" TEXT)`, `CREATE TABLE IF NOT EXISTS "token_model_availability" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "token_id" INTEGER NOT NULL REFERENCES "account_tokens"("id") ON DELETE CASCADE, "model_name" TEXT NOT NULL, "available" INTEGER, "latency_ms" INTEGER, "checked_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "token_routes" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "model_pattern" TEXT NOT NULL, "display_name" TEXT, "display_icon" TEXT, "model_mapping" TEXT, "enabled" INTEGER DEFAULT 1, "created_at" TEXT, "updated_at" TEXT)`, `CREATE TABLE IF NOT EXISTS "token_routes" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "model_pattern" TEXT NOT NULL, "display_name" TEXT, "display_icon" TEXT, "model_mapping" TEXT, "decision_snapshot" TEXT, "decision_refreshed_at" TEXT, "enabled" INTEGER DEFAULT 1, "created_at" TEXT, "updated_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "route_channels" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "route_id" INTEGER NOT NULL REFERENCES "token_routes"("id") ON DELETE CASCADE, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "token_id" INTEGER REFERENCES "account_tokens"("id") ON DELETE SET NULL, "source_model" TEXT, "priority" INTEGER DEFAULT 0, "weight" INTEGER DEFAULT 10, "enabled" INTEGER DEFAULT 1, "manual_override" INTEGER DEFAULT 0, "success_count" INTEGER DEFAULT 0, "fail_count" INTEGER DEFAULT 0, "total_latency_ms" INTEGER DEFAULT 0, "total_cost" REAL DEFAULT 0, "last_used_at" TEXT, "last_fail_at" TEXT, "cooldown_until" TEXT)`, `CREATE TABLE IF NOT EXISTS "route_channels" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "route_id" INTEGER NOT NULL REFERENCES "token_routes"("id") ON DELETE CASCADE, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "token_id" INTEGER REFERENCES "account_tokens"("id") ON DELETE SET NULL, "source_model" TEXT, "priority" INTEGER DEFAULT 0, "weight" INTEGER DEFAULT 10, "enabled" INTEGER DEFAULT 1, "manual_override" INTEGER DEFAULT 0, "success_count" INTEGER DEFAULT 0, "fail_count" INTEGER DEFAULT 0, "total_latency_ms" INTEGER DEFAULT 0, "total_cost" REAL DEFAULT 0, "last_used_at" TEXT, "last_fail_at" TEXT, "cooldown_until" TEXT)`,
`CREATE TABLE IF NOT EXISTS "proxy_logs" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "route_id" INTEGER, "channel_id" INTEGER, "account_id" INTEGER, "model_requested" TEXT, "model_actual" TEXT, "status" TEXT, "http_status" INTEGER, "latency_ms" INTEGER, "prompt_tokens" INTEGER, "completion_tokens" INTEGER, "total_tokens" INTEGER, "estimated_cost" REAL, "billing_details" TEXT, "error_message" TEXT, "retry_count" INTEGER DEFAULT 0, "created_at" TEXT)`, `CREATE TABLE IF NOT EXISTS "proxy_logs" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "route_id" INTEGER, "channel_id" INTEGER, "account_id" INTEGER, "model_requested" TEXT, "model_actual" TEXT, "status" TEXT, "http_status" INTEGER, "latency_ms" INTEGER, "prompt_tokens" INTEGER, "completion_tokens" INTEGER, "total_tokens" INTEGER, "estimated_cost" REAL, "billing_details" TEXT, "error_message" TEXT, "retry_count" INTEGER DEFAULT 0, "created_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "proxy_video_tasks" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "public_id" TEXT NOT NULL UNIQUE, "upstream_video_id" TEXT NOT NULL, "site_url" TEXT NOT NULL, "token_value" TEXT NOT NULL, "requested_model" TEXT, "actual_model" TEXT, "channel_id" INTEGER, "account_id" INTEGER, "status_snapshot" TEXT, "upstream_response_meta" TEXT, "last_upstream_status" INTEGER, "last_polled_at" TEXT, "created_at" TEXT, "updated_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "downstream_api_keys" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "name" TEXT NOT NULL, "key" TEXT NOT NULL UNIQUE, "description" TEXT, "enabled" INTEGER DEFAULT 1, "expires_at" TEXT, "max_cost" REAL, "used_cost" REAL DEFAULT 0, "max_requests" INTEGER, "used_requests" INTEGER DEFAULT 0, "supported_models" TEXT, "allowed_route_ids" TEXT, "site_weight_multipliers" TEXT, "last_used_at" TEXT, "created_at" TEXT, "updated_at" TEXT)`, `CREATE TABLE IF NOT EXISTS "downstream_api_keys" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "name" TEXT NOT NULL, "key" TEXT NOT NULL UNIQUE, "description" TEXT, "enabled" INTEGER DEFAULT 1, "expires_at" TEXT, "max_cost" REAL, "used_cost" REAL DEFAULT 0, "max_requests" INTEGER, "used_requests" INTEGER DEFAULT 0, "supported_models" TEXT, "allowed_route_ids" TEXT, "site_weight_multipliers" TEXT, "last_used_at" TEXT, "created_at" TEXT, "updated_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "events" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "type" TEXT NOT NULL, "title" TEXT NOT NULL, "message" TEXT, "level" TEXT DEFAULT 'info', "read" INTEGER DEFAULT 0, "related_id" INTEGER, "related_type" TEXT, "created_at" TEXT)`, `CREATE TABLE IF NOT EXISTS "events" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "type" TEXT NOT NULL, "title" TEXT NOT NULL, "message" TEXT, "level" TEXT DEFAULT 'info', "read" INTEGER DEFAULT 0, "related_id" INTEGER, "related_type" TEXT, "created_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "settings" ("key" TEXT PRIMARY KEY, "value" TEXT)`, `CREATE TABLE IF NOT EXISTS "settings" ("key" TEXT PRIMARY KEY, "value" TEXT)`,
@@ -581,13 +584,15 @@ function buildStatements(snapshot: BackupSnapshot): InsertStatement[] {
for (const row of snapshot.accounts.tokenRoutes) { for (const row of snapshot.accounts.tokenRoutes) {
statements.push({ statements.push({
table: 'token_routes', table: 'token_routes',
columns: ['id', 'model_pattern', 'display_name', 'display_icon', 'model_mapping', 'enabled', 'created_at', 'updated_at'], columns: ['id', 'model_pattern', 'display_name', 'display_icon', 'model_mapping', 'decision_snapshot', 'decision_refreshed_at', 'enabled', 'created_at', 'updated_at'],
values: [ values: [
asNumber(row.id, 0), asNumber(row.id, 0),
asNullableString(row.modelPattern), asNullableString(row.modelPattern),
asNullableString(row.displayName), asNullableString(row.displayName),
asNullableString(row.displayIcon), asNullableString(row.displayIcon),
asNullableString(row.modelMapping), asNullableString(row.modelMapping),
asNullableString(row.decisionSnapshot),
asNullableString(row.decisionRefreshedAt),
asBoolean(row.enabled, true), asBoolean(row.enabled, true),
asNullableString(row.createdAt), asNullableString(row.createdAt),
asNullableString(row.updatedAt), asNullableString(row.updatedAt),
@@ -0,0 +1,129 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { eq } from 'drizzle-orm';
type DbModule = typeof import('../db/index.js');
type SeedModule = typeof import('./defaultSiteSeedService.js');
describe('defaultSiteSeedService', () => {
let db: DbModule['db'];
let schema: DbModule['schema'];
let ensureDefaultSitesSeeded: SeedModule['ensureDefaultSitesSeeded'];
let DEFAULT_SITE_SEED_SETTING_KEY: SeedModule['DEFAULT_SITE_SEED_SETTING_KEY'];
let dataDir = '';
beforeAll(async () => {
dataDir = mkdtempSync(join(tmpdir(), 'metapi-default-site-seed-'));
process.env.DATA_DIR = dataDir;
await import('../db/migrate.js');
const dbModule = await import('../db/index.js');
const seedModule = await import('./defaultSiteSeedService.js');
db = dbModule.db;
schema = dbModule.schema;
ensureDefaultSitesSeeded = seedModule.ensureDefaultSitesSeeded;
DEFAULT_SITE_SEED_SETTING_KEY = seedModule.DEFAULT_SITE_SEED_SETTING_KEY;
});
beforeEach(async () => {
await db.delete(schema.settings).run();
await db.delete(schema.sites).run();
});
afterAll(() => {
delete process.env.DATA_DIR;
});
it('seeds default official sites once when no site exists yet', async () => {
await ensureDefaultSitesSeeded();
const sites = await db.select().from(schema.sites).all();
const sorted = [...sites].sort((left, right) => (left.sortOrder || 0) - (right.sortOrder || 0));
expect(sorted.map((site) => ({
name: site.name,
url: site.url,
platform: site.platform,
status: site.status,
useSystemProxy: site.useSystemProxy,
isPinned: site.isPinned,
globalWeight: site.globalWeight,
sortOrder: site.sortOrder,
}))).toEqual([
{
name: 'OpenAI 官方',
url: 'https://api.openai.com',
platform: 'openai',
status: 'active',
useSystemProxy: false,
isPinned: false,
globalWeight: 1,
sortOrder: 0,
},
{
name: 'Claude 官方',
url: 'https://api.anthropic.com',
platform: 'claude',
status: 'active',
useSystemProxy: false,
isPinned: false,
globalWeight: 1,
sortOrder: 1,
},
{
name: 'Gemini 官方',
url: 'https://generativelanguage.googleapis.com',
platform: 'gemini',
status: 'active',
useSystemProxy: false,
isPinned: false,
globalWeight: 1,
sortOrder: 2,
},
{
name: 'CLIProxyAPI',
url: 'http://127.0.0.1:8317',
platform: 'cliproxyapi',
status: 'active',
useSystemProxy: false,
isPinned: false,
globalWeight: 1,
sortOrder: 3,
},
]);
const setting = await db.select().from(schema.settings).where(eq(schema.settings.key, DEFAULT_SITE_SEED_SETTING_KEY)).get();
expect(JSON.parse(setting?.value || 'false')).toBe(true);
});
it('marks first-run evaluation without seeding when sites already exist', async () => {
await db.insert(schema.sites).values({
name: 'Existing Site',
url: 'https://existing.example.com',
platform: 'new-api',
}).run();
await ensureDefaultSitesSeeded();
const sites = await db.select().from(schema.sites).all();
expect(sites).toHaveLength(1);
expect(sites[0]?.name).toBe('Existing Site');
const setting = await db.select().from(schema.settings).where(eq(schema.settings.key, DEFAULT_SITE_SEED_SETTING_KEY)).get();
expect(JSON.parse(setting?.value || 'false')).toBe(true);
});
it('does not seed again after the evaluation marker exists', async () => {
await db.insert(schema.settings).values({
key: DEFAULT_SITE_SEED_SETTING_KEY,
value: JSON.stringify(true),
}).run();
await ensureDefaultSitesSeeded();
const sites = await db.select().from(schema.sites).all();
expect(sites).toHaveLength(0);
});
});
@@ -0,0 +1,105 @@
import { eq } from 'drizzle-orm';
import { db, schema } from '../db/index.js';
export const DEFAULT_SITE_SEED_SETTING_KEY = 'default_site_seed_v1';
const DEFAULT_SITE_ROWS: Array<typeof schema.sites.$inferInsert> = [
{
name: 'OpenAI 官方',
url: 'https://api.openai.com',
platform: 'openai',
status: 'active',
useSystemProxy: false,
isPinned: false,
globalWeight: 1,
sortOrder: 0,
},
{
name: 'Claude 官方',
url: 'https://api.anthropic.com',
platform: 'claude',
status: 'active',
useSystemProxy: false,
isPinned: false,
globalWeight: 1,
sortOrder: 1,
},
{
name: 'Gemini 官方',
url: 'https://generativelanguage.googleapis.com',
platform: 'gemini',
status: 'active',
useSystemProxy: false,
isPinned: false,
globalWeight: 1,
sortOrder: 2,
},
{
name: 'CLIProxyAPI',
url: 'http://127.0.0.1:8317',
platform: 'cliproxyapi',
status: 'active',
useSystemProxy: false,
isPinned: false,
globalWeight: 1,
sortOrder: 3,
},
];
type SeedSummary = {
seeded: number;
alreadyMarked: boolean;
hadExistingSites: boolean;
};
async function writeSeedMarker(tx: typeof db) {
await tx.insert(schema.settings)
.values({
key: DEFAULT_SITE_SEED_SETTING_KEY,
value: JSON.stringify(true),
})
.onConflictDoUpdate({
target: schema.settings.key,
set: { value: JSON.stringify(true) },
})
.run();
}
export async function ensureDefaultSitesSeeded(): Promise<SeedSummary> {
return db.transaction(async (tx) => {
const marker = await tx.select({ key: schema.settings.key })
.from(schema.settings)
.where(eq(schema.settings.key, DEFAULT_SITE_SEED_SETTING_KEY))
.get();
if (marker) {
return {
seeded: 0,
alreadyMarked: true,
hadExistingSites: false,
};
}
const existingSite = await tx.select({ id: schema.sites.id })
.from(schema.sites)
.limit(1)
.get();
if (existingSite) {
await writeSeedMarker(tx);
return {
seeded: 0,
alreadyMarked: false,
hadExistingSites: true,
};
}
await tx.insert(schema.sites).values(DEFAULT_SITE_ROWS).run();
await writeSeedMarker(tx);
return {
seeded: DEFAULT_SITE_ROWS.length,
alreadyMarked: false,
hadExistingSites: false,
};
});
}
@@ -0,0 +1,90 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { mkdtempSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
type DbModule = typeof import('../db/index.js');
type ConfigModule = typeof import('../config.js');
type ServiceModule = typeof import('./factoryResetService.js');
describe('factoryResetService', () => {
let db: DbModule['db'];
let schema: DbModule['schema'];
let config: ConfigModule['config'];
let performFactoryReset: ServiceModule['performFactoryReset'];
let dataDir = '';
beforeAll(async () => {
dataDir = mkdtempSync(join(tmpdir(), 'metapi-factory-reset-service-'));
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('./factoryResetService.js');
db = dbModule.db;
schema = dbModule.schema;
config = configModule.config;
performFactoryReset = serviceModule.performFactoryReset;
});
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 = 'external-reset-token';
config.dbType = 'postgres';
config.dbUrl = 'postgres://user:pass@127.0.0.1:5432/metapi';
config.dbSsl = true;
});
afterAll(() => {
delete process.env.DATA_DIR;
});
it('clears current active data before switching runtime back to sqlite', async () => {
await db.insert(schema.sites).values({
name: 'External Runtime Site',
url: 'https://external.example.com',
platform: 'new-api',
}).run();
await db.insert(schema.settings).values([
{ key: 'auth_token', value: JSON.stringify('external-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) },
]).run();
const switchRuntimeDatabase = vi.fn(async () => undefined);
const runSqliteMigrations = vi.fn(() => undefined);
const ensureDefaultSitesSeeded = vi.fn(async () => ({
seeded: 0,
alreadyMarked: false,
hadExistingSites: false,
}));
await performFactoryReset({
switchRuntimeDatabase,
runSqliteMigrations,
ensureDefaultSitesSeeded,
});
expect(switchRuntimeDatabase).toHaveBeenCalledWith('sqlite', '', false);
expect(runSqliteMigrations).toHaveBeenCalledTimes(1);
expect(ensureDefaultSitesSeeded).toHaveBeenCalledTimes(1);
expect(await db.select().from(schema.sites).all()).toHaveLength(0);
expect(await db.select().from(schema.settings).all()).toHaveLength(0);
});
});
@@ -0,0 +1,61 @@
import { buildConfig, config } from '../config.js';
import { db, schema, switchRuntimeDatabase } from '../db/index.js';
import { updateBalanceRefreshCron, updateCheckinCron } from './checkinScheduler.js';
import { ensureDefaultSitesSeeded } from './defaultSiteSeedService.js';
import { invalidateSiteProxyCache } from './siteProxy.js';
export const FACTORY_RESET_ADMIN_TOKEN = 'change-me-admin-token';
type FactoryResetDependencies = {
switchRuntimeDatabase?: typeof switchRuntimeDatabase;
runSqliteMigrations?: () => Promise<void> | void;
ensureDefaultSitesSeeded?: typeof ensureDefaultSitesSeeded;
};
async function clearAllBusinessData() {
await db.transaction(async (tx) => {
await tx.delete(schema.routeChannels).run();
await tx.delete(schema.tokenModelAvailability).run();
await tx.delete(schema.modelAvailability).run();
await tx.delete(schema.proxyLogs).run();
await tx.delete(schema.proxyVideoTasks).run();
await tx.delete(schema.checkinLogs).run();
await tx.delete(schema.accountTokens).run();
await tx.delete(schema.accounts).run();
await tx.delete(schema.tokenRoutes).run();
await tx.delete(schema.sites).run();
await tx.delete(schema.downstreamApiKeys).run();
await tx.delete(schema.events).run();
await tx.delete(schema.settings).run();
});
}
function resetRuntimeConfigToInitialState() {
const baseline = buildConfig(process.env);
Object.assign(config, baseline);
config.authToken = FACTORY_RESET_ADMIN_TOKEN;
config.dbType = 'sqlite';
config.dbUrl = '';
config.dbSsl = false;
updateCheckinCron(config.checkinCron);
updateBalanceRefreshCron(config.balanceRefreshCron);
invalidateSiteProxyCache();
}
async function runDefaultSqliteMigrations() {
const migrateModule = await import('../db/migrate.js');
migrateModule.runSqliteMigrations();
}
export async function performFactoryReset(deps: FactoryResetDependencies = {}): Promise<void> {
const switchRuntimeDatabaseImpl = deps.switchRuntimeDatabase ?? switchRuntimeDatabase;
const runSqliteMigrationsImpl = deps.runSqliteMigrations ?? runDefaultSqliteMigrations;
const ensureDefaultSitesSeededImpl = deps.ensureDefaultSitesSeeded ?? ensureDefaultSitesSeeded;
await clearAllBusinessData();
resetRuntimeConfigToInitialState();
await switchRuntimeDatabaseImpl('sqlite', '', false);
await runSqliteMigrationsImpl();
await clearAllBusinessData();
await ensureDefaultSitesSeededImpl();
}
+28 -5
View File
@@ -57,7 +57,7 @@ interface RoutingReferenceCostCacheEntry {
costs: Map<string, number>; costs: Map<string, number>;
} }
interface EstimateProxyCostInput { export interface EstimateProxyCostInput {
site: { site: {
id: number; id: number;
url: string; url: string;
@@ -497,6 +497,20 @@ async function getPricingDataCached(input: EstimateProxyCostInput): Promise<Pric
return data; return data;
} }
async function refreshPricingDataCache(input: EstimateProxyCostInput): Promise<PricingData | null> {
const key = getCacheKey(input);
const now = Date.now();
const data = await fetchPricingData(input);
const ttlMs = data ? PRICE_CACHE_TTL_MS : PRICE_CACHE_FAILURE_TTL_MS;
pricingCache.set(key, {
fetchedAt: now,
ttlMs,
data,
});
syncRoutingReferenceCostCache(key, now, ttlMs, data);
return data;
}
export function getCachedModelRoutingReferenceCost(input: { export function getCachedModelRoutingReferenceCost(input: {
siteId: number; siteId: number;
accountId: number; accountId: number;
@@ -708,10 +722,7 @@ export function calculateModelUsageCost(
return calculateModelUsageBreakdown(model, usage, groupRatio)?.breakdown.totalCost ?? 0; return calculateModelUsageBreakdown(model, usage, groupRatio)?.breakdown.totalCost ?? 0;
} }
export async function fetchModelPricingCatalog(input: EstimateProxyCostInput): Promise<ModelPricingCatalog | null> { function buildModelPricingCatalogFromData(pricingData: PricingData): ModelPricingCatalog {
const pricingData = await getPricingDataCached(input);
if (!pricingData) return null;
const groups = Array.from(new Set([DEFAULT_GROUP, ...Object.keys(pricingData.groupRatio)])); const groups = Array.from(new Set([DEFAULT_GROUP, ...Object.keys(pricingData.groupRatio)]));
const defaultMultiplier = pricingData.groupRatio[DEFAULT_GROUP] || 1; const defaultMultiplier = pricingData.groupRatio[DEFAULT_GROUP] || 1;
@@ -763,6 +774,18 @@ export async function fetchModelPricingCatalog(input: EstimateProxyCostInput): P
}; };
} }
export async function fetchModelPricingCatalog(input: EstimateProxyCostInput): Promise<ModelPricingCatalog | null> {
const pricingData = await getPricingDataCached(input);
if (!pricingData) return null;
return buildModelPricingCatalogFromData(pricingData);
}
export async function refreshModelPricingCatalog(input: EstimateProxyCostInput): Promise<ModelPricingCatalog | null> {
const pricingData = await refreshPricingDataCache(input);
if (!pricingData) return null;
return buildModelPricingCatalogFromData(pricingData);
}
export function fallbackTokenCost(totalTokens: number, platform: string): number { export function fallbackTokenCost(totalTokens: number, platform: string): number {
const divisor = platform === 'veloera' ? 1_000_000 : 500_000; const divisor = platform === 'veloera' ? 1_000_000 : 500_000;
return roundCost(toPositiveInt(totalTokens) / divisor); return roundCost(toPositiveInt(totalTokens) / divisor);
+5
View File
@@ -5,6 +5,7 @@ import { ensureDefaultTokenForAccount, getPreferredAccountToken } from './accoun
import { getCredentialModeFromExtraConfig, resolvePlatformUserId } from './accountExtraConfig.js'; import { getCredentialModeFromExtraConfig, resolvePlatformUserId } from './accountExtraConfig.js';
import { invalidateTokenRouterCache } from './tokenRouter.js'; import { invalidateTokenRouterCache } from './tokenRouter.js';
import { setAccountRuntimeHealth } from './accountHealthService.js'; import { setAccountRuntimeHealth } from './accountHealthService.js';
import { clearAllRouteDecisionSnapshots } from './routeDecisionSnapshotStore.js';
const API_TOKEN_DISCOVERY_TIMEOUT_MS = 8_000; const API_TOKEN_DISCOVERY_TIMEOUT_MS = 8_000;
const MODEL_DISCOVERY_TIMEOUT_MS = 12_000; const MODEL_DISCOVERY_TIMEOUT_MS = 12_000;
@@ -360,6 +361,10 @@ export async function rebuildTokenRoutesFromAvailability() {
} }
} }
if (createdRoutes > 0 || createdChannels > 0 || removedChannels > 0 || removedRoutes > 0) {
await clearAllRouteDecisionSnapshots();
}
invalidateTokenRouterCache(); invalidateTokenRouterCache();
return { return {
+127
View File
@@ -0,0 +1,127 @@
import { eq } from 'drizzle-orm';
import { db, schema } from '../db/index.js';
import { formatUtcSqlDateTime } from './localTimeService.js';
export type SaveProxyVideoTaskInput = {
publicId?: string;
upstreamVideoId: string;
siteUrl: string;
tokenValue: string;
requestedModel: string;
actualModel: string;
channelId: number | null;
accountId: number | null;
statusSnapshot?: unknown;
upstreamResponseMeta?: unknown;
lastUpstreamStatus?: number | null;
lastPolledAt?: string | null;
};
export type ProxyVideoTaskRecord = {
publicId: string;
upstreamVideoId: string;
siteUrl: string;
tokenValue: string;
requestedModel: string | null;
actualModel: string | null;
channelId: number | null;
accountId: number | null;
statusSnapshot: unknown | null;
upstreamResponseMeta: unknown | null;
lastUpstreamStatus: number | null;
lastPolledAt: string | null;
};
function buildPublicVideoId(): string {
const timePart = Date.now().toString(36);
const randomPart = Math.random().toString(36).slice(2, 10);
return `vid_${timePart}_${randomPart}`;
}
export async function saveProxyVideoTask(input: SaveProxyVideoTaskInput): Promise<{
publicId: string;
upstreamVideoId: string;
}> {
const publicId = input.publicId?.trim() || buildPublicVideoId();
const now = formatUtcSqlDateTime(new Date());
await db.insert(schema.proxyVideoTasks).values({
publicId,
upstreamVideoId: input.upstreamVideoId,
siteUrl: input.siteUrl,
tokenValue: input.tokenValue,
requestedModel: input.requestedModel,
actualModel: input.actualModel,
channelId: input.channelId,
accountId: input.accountId,
statusSnapshot: input.statusSnapshot === undefined ? null : JSON.stringify(input.statusSnapshot),
upstreamResponseMeta: input.upstreamResponseMeta === undefined ? null : JSON.stringify(input.upstreamResponseMeta),
lastUpstreamStatus: input.lastUpstreamStatus ?? null,
lastPolledAt: input.lastPolledAt ?? (input.lastUpstreamStatus == null ? null : now),
createdAt: now,
updatedAt: now,
}).run();
return {
publicId,
upstreamVideoId: input.upstreamVideoId,
};
}
export async function getProxyVideoTaskByPublicId(publicId: string): Promise<ProxyVideoTaskRecord | null> {
const row = await db.select().from(schema.proxyVideoTasks)
.where(eq(schema.proxyVideoTasks.publicId, publicId))
.get();
if (!row) return null;
return {
publicId: row.publicId,
upstreamVideoId: row.upstreamVideoId,
siteUrl: row.siteUrl,
tokenValue: row.tokenValue,
requestedModel: row.requestedModel,
actualModel: row.actualModel,
channelId: row.channelId,
accountId: row.accountId,
statusSnapshot: parseJsonColumn(row.statusSnapshot),
upstreamResponseMeta: parseJsonColumn(row.upstreamResponseMeta),
lastUpstreamStatus: row.lastUpstreamStatus,
lastPolledAt: row.lastPolledAt,
};
}
export async function deleteProxyVideoTaskByPublicId(publicId: string): Promise<void> {
await db.delete(schema.proxyVideoTasks)
.where(eq(schema.proxyVideoTasks.publicId, publicId))
.run();
}
export async function refreshProxyVideoTaskSnapshot(
publicId: string,
input: {
statusSnapshot?: unknown;
upstreamResponseMeta?: unknown;
lastUpstreamStatus?: number | null;
lastPolledAt?: string | null;
},
): Promise<void> {
const now = formatUtcSqlDateTime(new Date());
await db.update(schema.proxyVideoTasks)
.set({
statusSnapshot: input.statusSnapshot === undefined ? null : JSON.stringify(input.statusSnapshot),
upstreamResponseMeta: input.upstreamResponseMeta === undefined ? null : JSON.stringify(input.upstreamResponseMeta),
lastUpstreamStatus: input.lastUpstreamStatus ?? null,
lastPolledAt: input.lastPolledAt ?? now,
updatedAt: now,
})
.where(eq(schema.proxyVideoTasks.publicId, publicId))
.run();
}
function parseJsonColumn(value: string | null | undefined): unknown | null {
if (typeof value !== 'string' || value.trim().length === 0) return null;
try {
return JSON.parse(value);
} catch {
return value;
}
}
@@ -0,0 +1,64 @@
import { eq, inArray } from 'drizzle-orm';
import { db, schema } from '../db/index.js';
import { formatUtcSqlDateTime } from './localTimeService.js';
function serializeSnapshot(snapshot: unknown): string | null {
if (snapshot == null) return null;
return JSON.stringify(snapshot);
}
export function parseRouteDecisionSnapshot(value: string | null | undefined): unknown | null {
if (typeof value !== 'string' || value.trim().length === 0) return null;
try {
return JSON.parse(value);
} catch {
return null;
}
}
export async function saveRouteDecisionSnapshot(routeId: number, snapshot: unknown): Promise<void> {
const refreshedAt = formatUtcSqlDateTime(new Date());
await db.update(schema.tokenRoutes)
.set({
decisionSnapshot: serializeSnapshot(snapshot),
decisionRefreshedAt: refreshedAt,
})
.where(eq(schema.tokenRoutes.id, routeId))
.run();
}
export async function saveRouteDecisionSnapshots(entries: Array<{ routeId: number; snapshot: unknown }>): Promise<void> {
for (const entry of entries) {
await saveRouteDecisionSnapshot(entry.routeId, entry.snapshot);
}
}
export async function clearRouteDecisionSnapshot(routeId: number): Promise<void> {
await clearRouteDecisionSnapshots([routeId]);
}
export async function clearRouteDecisionSnapshots(routeIds: number[]): Promise<void> {
const normalizedRouteIds = Array.from(new Set(
routeIds
.map((routeId) => Math.trunc(routeId))
.filter((routeId) => routeId > 0),
));
if (normalizedRouteIds.length === 0) return;
await db.update(schema.tokenRoutes)
.set({
decisionSnapshot: null,
decisionRefreshedAt: null,
})
.where(inArray(schema.tokenRoutes.id, normalizedRouteIds))
.run();
}
export async function clearAllRouteDecisionSnapshots(): Promise<void> {
await db.update(schema.tokenRoutes)
.set({
decisionSnapshot: null,
decisionRefreshedAt: null,
})
.run();
}
+78 -1
View File
@@ -2,7 +2,7 @@
import { minimatch } from 'minimatch'; import { minimatch } from 'minimatch';
import { db, schema } from '../db/index.js'; import { db, schema } from '../db/index.js';
import { config } from '../config.js'; import { config } from '../config.js';
import { getCachedModelRoutingReferenceCost } from './modelPricingService.js'; import { getCachedModelRoutingReferenceCost, refreshModelPricingCatalog } from './modelPricingService.js';
import { type DownstreamRoutingPolicy, EMPTY_DOWNSTREAM_ROUTING_POLICY } from './downstreamPolicyTypes.js'; import { type DownstreamRoutingPolicy, EMPTY_DOWNSTREAM_ROUTING_POLICY } from './downstreamPolicyTypes.js';
interface RouteMatch { interface RouteMatch {
@@ -195,6 +195,12 @@ type ExplainSelectionOptions = {
downstreamPolicy?: DownstreamRoutingPolicy; downstreamPolicy?: DownstreamRoutingPolicy;
}; };
type PricingReferenceRefreshOptions = {
useChannelSourceModelForCost?: boolean;
downstreamPolicy?: DownstreamRoutingPolicy;
refreshedKeys?: Set<string>;
};
type CandidateEligibilityOptions = { type CandidateEligibilityOptions = {
requestedModel: string; requestedModel: string;
bypassSourceModelCheck?: boolean; bypassSourceModelCheck?: boolean;
@@ -551,6 +557,38 @@ export class TokenRouter {
}); });
} }
async refreshPricingReferenceCosts(
requestedModel: string,
options: PricingReferenceRefreshOptions = {},
): Promise<void> {
const downstreamPolicy = options.downstreamPolicy ?? DEFAULT_DOWNSTREAM_POLICY;
const match = await this.findRoute(requestedModel, downstreamPolicy);
await this.refreshPricingReferenceCostsForMatch(match, requestedModel, options);
}
async refreshPricingReferenceCostsForRoute(
routeId: number,
requestedModel: string,
options: PricingReferenceRefreshOptions = {},
): Promise<void> {
const downstreamPolicy = options.downstreamPolicy ?? DEFAULT_DOWNSTREAM_POLICY;
const match = await this.findRouteById(routeId, downstreamPolicy);
await this.refreshPricingReferenceCostsForMatch(match, requestedModel, options);
}
async refreshRouteWidePricingReferenceCosts(
routeId: number,
options: Omit<PricingReferenceRefreshOptions, 'useChannelSourceModelForCost'> = {},
): Promise<void> {
const downstreamPolicy = options.downstreamPolicy ?? DEFAULT_DOWNSTREAM_POLICY;
const match = await this.findRouteById(routeId, downstreamPolicy);
const requestedModel = match?.route.modelPattern || `route:${routeId}`;
await this.refreshPricingReferenceCostsForMatch(match, requestedModel, {
...options,
useChannelSourceModelForCost: true,
});
}
private explainSelectionFromMatch( private explainSelectionFromMatch(
match: RouteMatch | null, match: RouteMatch | null,
requestedModel: string, requestedModel: string,
@@ -720,6 +758,45 @@ export class TokenRouter {
}; };
} }
private async refreshPricingReferenceCostsForMatch(
match: RouteMatch | null,
requestedModel: string,
options: PricingReferenceRefreshOptions = {},
): Promise<void> {
if (!match) return;
const requestedByDisplayName = isRouteDisplayNameMatch(requestedModel, match.route.displayName);
const useChannelSourceModelForCost = (options.useChannelSourceModelForCost ?? false) || requestedByDisplayName;
const mappedModel = resolveMappedModel(requestedModel, match.route.modelMapping);
const refreshedKeys = options.refreshedKeys ?? new Set<string>();
await Promise.allSettled(match.channels.map(async (candidate) => {
const refreshKey = `${candidate.site.id}:${candidate.account.id}`;
if (refreshedKeys.has(refreshKey)) return;
refreshedKeys.add(refreshKey);
const modelName = useChannelSourceModelForCost
? (normalizeChannelSourceModel(candidate.channel.sourceModel) || mappedModel)
: mappedModel;
if (!modelName) return;
await refreshModelPricingCatalog({
site: {
id: candidate.site.id,
url: candidate.site.url,
platform: candidate.site.platform,
apiKey: candidate.site.apiKey,
},
account: {
id: candidate.account.id,
accessToken: candidate.account.accessToken,
apiToken: candidate.account.apiToken,
},
modelName,
});
}));
}
/** /**
* Record success for a channel. * Record success for a channel.
*/ */
@@ -0,0 +1,179 @@
import { type NormalizedStreamEvent } from '../../shared/normalized.js';
export type AnthropicStreamExtension = {
signatureDelta?: string;
redactedThinkingData?: string;
startBlock?: {
kind: 'thinking' | 'text' | 'tool_use' | 'redacted_thinking';
index?: number;
};
stopBlockIndex?: number | null;
};
export type AnthropicExtendedStreamEvent = NormalizedStreamEvent & {
anthropic?: AnthropicStreamExtension;
};
export type AnthropicBlockLifecycleEntry = {
kind: 'thinking' | 'redacted_thinking';
phase: 'start' | 'stop';
index?: number;
};
export type AnthropicMessagesAggregateState = {
text: string[];
reasoning: string[];
redactedReasoning: string[];
signatures: string[];
toolCalls: Record<number, { id: string; name: string; arguments: string }>;
finishReason: string | null;
pendingSignature: string | null;
thinkingBlockIndex: number | null;
redactedBlockIndexes: number[];
blockLifecycle: AnthropicBlockLifecycleEntry[];
};
export function createAnthropicMessagesAggregateState(): AnthropicMessagesAggregateState {
return {
text: [],
reasoning: [],
redactedReasoning: [],
signatures: [],
toolCalls: {},
finishReason: null,
pendingSignature: null,
thinkingBlockIndex: null,
redactedBlockIndexes: [],
blockLifecycle: [],
};
}
function ensureToolState(
state: AnthropicMessagesAggregateState,
index: number,
): { id: string; name: string; arguments: string } {
if (!state.toolCalls[index]) {
state.toolCalls[index] = {
id: `toolu_${index}`,
name: '',
arguments: '',
};
}
return state.toolCalls[index];
}
function flushPendingSignature(state: AnthropicMessagesAggregateState): void {
if (!state.pendingSignature) return;
state.signatures.push(state.pendingSignature);
state.pendingSignature = null;
}
function recordBlockStart(
state: AnthropicMessagesAggregateState,
kind: AnthropicBlockLifecycleEntry['kind'],
index?: number,
): void {
state.blockLifecycle.push({ kind, phase: 'start', index });
if (kind === 'thinking') {
state.thinkingBlockIndex = typeof index === 'number' ? index : state.thinkingBlockIndex;
return;
}
if (kind === 'redacted_thinking' && typeof index === 'number') {
state.redactedBlockIndexes.push(index);
}
}
function recordBlockStop(state: AnthropicMessagesAggregateState, index: number): void {
if (state.thinkingBlockIndex === index) {
state.blockLifecycle.push({ kind: 'thinking', phase: 'stop', index });
state.thinkingBlockIndex = null;
flushPendingSignature(state);
return;
}
const redactedIndex = state.redactedBlockIndexes.indexOf(index);
if (redactedIndex >= 0) {
state.blockLifecycle.push({ kind: 'redacted_thinking', phase: 'stop', index });
state.redactedBlockIndexes.splice(redactedIndex, 1);
}
}
function finalizeOpenBlocks(state: AnthropicMessagesAggregateState): void {
if (state.thinkingBlockIndex !== null) {
const thinkingIndex = state.thinkingBlockIndex;
recordBlockStop(state, thinkingIndex);
}
if (state.pendingSignature) {
flushPendingSignature(state);
}
const openRedactedIndexes = [...state.redactedBlockIndexes];
for (const redactedIndex of openRedactedIndexes) {
recordBlockStop(state, redactedIndex);
}
}
export function applyAnthropicMessagesAggregateEvent(
state: AnthropicMessagesAggregateState,
event: AnthropicExtendedStreamEvent,
): AnthropicMessagesAggregateState {
if (event.anthropic?.startBlock?.kind === 'thinking') {
recordBlockStart(state, 'thinking', event.anthropic.startBlock.index);
}
if (event.anthropic?.startBlock?.kind === 'redacted_thinking') {
if (state.thinkingBlockIndex !== null) {
recordBlockStop(state, state.thinkingBlockIndex);
}
recordBlockStart(state, 'redacted_thinking', event.anthropic.startBlock.index);
}
if (event.reasoningDelta) {
state.reasoning.push(event.reasoningDelta);
}
if (event.contentDelta) {
state.text.push(event.contentDelta);
}
if (Array.isArray(event.toolCallDeltas)) {
for (const toolDelta of event.toolCallDeltas) {
const toolState = ensureToolState(state, toolDelta.index);
if (toolDelta.id) toolState.id = toolDelta.id;
if (toolDelta.name) toolState.name = toolDelta.name;
if (toolDelta.argumentsDelta) {
toolState.arguments += toolDelta.argumentsDelta;
}
}
}
if (event.anthropic?.signatureDelta) {
if (state.thinkingBlockIndex !== null) {
state.signatures.push(event.anthropic.signatureDelta);
} else {
state.pendingSignature = event.anthropic.signatureDelta;
}
}
if (event.anthropic?.redactedThinkingData) {
state.redactedReasoning.push(event.anthropic.redactedThinkingData);
}
if (
event.anthropic?.stopBlockIndex !== undefined
&& event.anthropic.stopBlockIndex !== null
) {
recordBlockStop(state, event.anthropic.stopBlockIndex);
}
if (event.finishReason) {
finalizeOpenBlocks(state);
state.finishReason = event.finishReason;
}
if (event.done) {
finalizeOpenBlocks(state);
}
return state;
}
@@ -0,0 +1,666 @@
import { describe, expect, it } from 'vitest';
import {
convertOpenAiBodyToAnthropicMessagesBody,
convertOpenAiToolChoiceToAnthropic,
sanitizeAnthropicMessagesBody,
} from './conversion.js';
import { anthropicMessagesInbound } from './inbound.js';
import { anthropicMessagesTransformer } from './index.js';
import { extractAnthropicUsage, extractAnthropicUsageMetadata } from './usage.js';
import { applyAnthropicMessagesAggregateEvent, createAnthropicMessagesAggregateState } from './aggregator.js';
describe('sanitizeAnthropicMessagesBody', () => {
it('drops top_p when temperature is present and normalizes adaptive thinking', () => {
const result = sanitizeAnthropicMessagesBody({
model: 'claude-opus',
temperature: 0.6,
top_p: 0.9,
thinking: {
type: 'adaptive',
budget_tokens: 512,
},
});
expect(result.top_p).toBeUndefined();
expect(result.thinking).toEqual({
type: 'enabled',
budget_tokens: 512,
});
});
it('drops non-ephemeral cache_control markers while preserving valid tool block markers', () => {
const result = sanitizeAnthropicMessagesBody({
model: 'claude-opus-4-6',
messages: [
{
role: 'assistant',
content: [
{
type: 'thinking',
thinking: 'internal',
cache_control: { type: 'persistent' },
},
{
type: 'tool_use',
id: 'tool_1',
name: 'lookup',
input: { city: 'paris' },
cache_control: { type: 'ephemeral' },
},
{
type: 'tool_result',
tool_use_id: 'tool_1',
content: 'done',
cache_control: { type: 'persistent' },
},
],
},
],
});
expect(result.messages).toEqual([
{
role: 'assistant',
content: [
{
type: 'thinking',
thinking: 'internal',
},
{
type: 'tool_use',
id: 'tool_1',
name: 'lookup',
input: { city: 'paris' },
},
{
type: 'tool_result',
tool_use_id: 'tool_1',
content: 'done',
cache_control: { type: 'ephemeral' },
},
],
},
]);
});
});
describe('convertOpenAiBodyToAnthropicMessagesBody', () => {
it('preserves image blocks, tool_use blocks, and tool_result chains', () => {
const body = convertOpenAiBodyToAnthropicMessagesBody(
{
model: 'gpt-5',
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',
},
],
},
'claude-opus-4-6',
false,
);
expect(body.system).toBe('system prompt');
expect(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',
},
],
},
]);
});
it('strips cache_control from thinking blocks and empty text blocks', () => {
const body = convertOpenAiBodyToAnthropicMessagesBody(
{
model: 'gpt-5',
messages: [
{
role: 'assistant',
content: [
{
type: 'thinking',
thinking: 'internal',
cache_control: { type: 'ephemeral' },
},
{
type: 'text',
text: ' ',
cache_control: { type: 'ephemeral' },
},
],
},
],
},
'claude-opus-4-6',
false,
);
expect(body.messages).toEqual([
{
role: 'assistant',
content: [
{
type: 'thinking',
thinking: 'internal',
},
],
},
]);
});
it('preserves cache_control on tool blocks while stripping unsupported thinking/text markers', () => {
const result = sanitizeAnthropicMessagesBody({
model: 'claude-opus-4-6',
messages: [
{
role: 'assistant',
content: [
{
type: 'thinking',
thinking: 'internal',
cache_control: { type: 'ephemeral' },
},
{
type: 'tool_use',
id: 'tool_1',
name: 'lookup',
input: { city: 'paris' },
cache_control: { type: 'ephemeral' },
},
{
type: 'text',
text: '',
cache_control: { type: 'ephemeral' },
},
],
},
],
});
expect(result.messages).toEqual([
{
role: 'assistant',
content: [
{
type: 'thinking',
thinking: 'internal',
},
{
type: 'tool_use',
id: 'tool_1',
name: 'lookup',
input: { city: 'paris' },
cache_control: { type: 'ephemeral' },
},
],
},
]);
});
it('rebuilds cache_control anchors on structural blocks and the last cacheable message block', () => {
const result = sanitizeAnthropicMessagesBody({
model: 'claude-opus-4-6',
tools: [
{ name: 'lookup', input_schema: { type: 'object' }, cache_control: { type: 'ephemeral' } },
{ name: 'search', input_schema: { type: 'object' } },
],
system: [
{ type: 'text', text: 'system 1' },
{ type: 'text', text: 'system 2', cache_control: { type: 'ephemeral' } },
],
messages: [
{
role: 'user',
content: [
{ type: 'text', text: 'prefix' },
{ type: 'thinking', thinking: 'internal', cache_control: { type: 'ephemeral' } },
{ type: 'tool_use', id: 'tool_1', name: 'lookup', input: { city: 'paris' } },
],
},
],
});
expect(result.tools).toEqual([
{ name: 'lookup', input_schema: { type: 'object' } },
{ name: 'search', input_schema: { type: 'object' }, cache_control: { type: 'ephemeral' } },
]);
expect(result.system).toEqual([
{ type: 'text', text: 'system 1' },
{ type: 'text', text: 'system 2', cache_control: { type: 'ephemeral' } },
]);
expect(result.messages).toEqual([
{
role: 'user',
content: [
{ type: 'text', text: 'prefix' },
{ type: 'thinking', thinking: 'internal' },
{ type: 'tool_use', id: 'tool_1', name: 'lookup', input: { city: 'paris' }, cache_control: { type: 'ephemeral' } },
],
},
]);
});
it('adds a second message cache anchor around the 20-block window for long prompts', () => {
const longContent = Array.from({ length: 25 }, (_, index) => ({
type: 'text',
text: `block-${index + 1}`,
}));
const result = sanitizeAnthropicMessagesBody({
model: 'claude-opus-4-6',
messages: [
{
role: 'user',
content: longContent,
},
],
});
const content = (result.messages as Array<any>)[0].content as Array<any>;
const anchoredIndexes = content
.map((item, index) => item?.cache_control ? index : -1)
.filter((index) => index >= 0);
expect(anchoredIndexes).toEqual([4, 24]);
});
it('maps reasoning effort to anthropic output_config.effort with adaptive thinking', () => {
const body = convertOpenAiBodyToAnthropicMessagesBody(
{
model: 'gpt-5',
reasoning_effort: 'high',
messages: [
{ role: 'user', content: 'hello' },
],
},
'claude-opus-4-6',
true,
);
expect(body.thinking).toEqual({ type: 'adaptive' });
expect(body.output_config).toEqual({ effort: 'high' });
});
});
describe('convertOpenAiToolChoiceToAnthropic', () => {
it('maps required to any and function choice to tool', () => {
expect(convertOpenAiToolChoiceToAnthropic('required')).toEqual({ type: 'any' });
expect(convertOpenAiToolChoiceToAnthropic({
type: 'function',
function: { name: 'lookup' },
})).toEqual({
type: 'tool',
name: 'lookup',
});
});
it('normalizes tool-style objects and strips unsupported name on non-tool variants', () => {
expect(anthropicMessagesInbound.parse({
model: 'claude-opus-4-6',
max_tokens: 512,
messages: [{ role: 'user', content: 'hello' }],
tool_choice: { type: 'tool', tool: { name: 'lookup' } },
}).value?.claudeOriginalBody?.tool_choice).toEqual({
type: 'tool',
name: 'lookup',
});
expect(anthropicMessagesInbound.parse({
model: 'claude-opus-4-6',
max_tokens: 512,
messages: [{ role: 'user', content: 'hello' }],
tool_choice: { type: 'none', name: 'should-disappear' },
}).value?.claudeOriginalBody?.tool_choice).toEqual({
type: 'none',
});
});
});
describe('anthropicMessagesInbound', () => {
it('rejects invalid tool_choice.type and invalid output_config.effort', () => {
const invalidToolChoice = anthropicMessagesInbound.parse({
model: 'claude-opus-4-6',
max_tokens: 512,
messages: [{ role: 'user', content: 'hello' }],
tool_choice: { type: 'invalid' },
});
expect(invalidToolChoice.error?.statusCode).toBe(400);
const invalidEffort = anthropicMessagesInbound.parse({
model: 'claude-opus-4-6',
max_tokens: 512,
messages: [{ role: 'user', content: 'hello' }],
thinking: { type: 'adaptive' },
output_config: { effort: 'turbo' },
});
expect(invalidEffort.error?.statusCode).toBe(400);
});
it('rejects tool choice without a tool name', () => {
const result = anthropicMessagesInbound.parse({
model: 'claude-opus-4-6',
max_tokens: 512,
messages: [{ role: 'user', content: 'hello' }],
tool_choice: { type: 'tool' },
});
expect(result.error?.statusCode).toBe(400);
});
});
describe('anthropicMessagesTransformer.stream', () => {
it('serializes reasoning separately from text for Claude downstream', () => {
const streamContext = anthropicMessagesTransformer.createStreamContext('claude-opus-4-6');
const downstreamContext = anthropicMessagesTransformer.createDownstreamContext();
const chunks = [
...anthropicMessagesTransformer.serializeStreamEvent({ role: 'assistant' }, streamContext, downstreamContext),
...anthropicMessagesTransformer.serializeStreamEvent({ reasoningDelta: 'internal thought' }, streamContext, downstreamContext),
...anthropicMessagesTransformer.serializeStreamEvent({ contentDelta: 'final answer' }, streamContext, downstreamContext),
...anthropicMessagesTransformer.serializeStreamEvent({ finishReason: 'stop', done: true }, streamContext, downstreamContext),
].join('');
expect(chunks).toContain('"type":"thinking_delta"');
expect(chunks).toContain('"type":"text_delta"');
expect(chunks).not.toContain('internal thought\\n\\nfinal answer');
});
it('buffers signature_delta until a thinking block is closed', () => {
const streamContext = anthropicMessagesTransformer.createStreamContext('claude-opus-4-6');
const downstreamContext = anthropicMessagesTransformer.createDownstreamContext();
const signatureEvent = anthropicMessagesTransformer.transformStreamEvent({
type: 'content_block_delta',
index: 0,
delta: { type: 'signature_delta', signature: 'sig-1' },
}, streamContext, 'claude-opus-4-6');
const thinkingStart = anthropicMessagesTransformer.transformStreamEvent({
type: 'content_block_start',
index: 0,
content_block: { type: 'thinking', thinking: '' },
}, streamContext, 'claude-opus-4-6');
const thinkingDelta = anthropicMessagesTransformer.transformStreamEvent({
type: 'content_block_delta',
index: 0,
delta: { type: 'thinking_delta', thinking: 'internal' },
}, streamContext, 'claude-opus-4-6');
const textDelta = anthropicMessagesTransformer.transformStreamEvent({
type: 'content_block_delta',
index: 1,
delta: { type: 'text_delta', text: 'final' },
}, streamContext, 'claude-opus-4-6');
const serialized = [
...anthropicMessagesTransformer.serializeStreamEvent(signatureEvent, streamContext, downstreamContext),
...anthropicMessagesTransformer.serializeStreamEvent(thinkingStart, streamContext, downstreamContext),
...anthropicMessagesTransformer.serializeStreamEvent(thinkingDelta, streamContext, downstreamContext),
...anthropicMessagesTransformer.serializeStreamEvent(textDelta, streamContext, downstreamContext),
].join('');
expect(serialized).toContain('"type":"signature_delta"');
expect(serialized.indexOf('"type":"signature_delta"')).toBeGreaterThan(serialized.indexOf('"type":"thinking_delta"'));
expect(serialized.indexOf('"type":"signature_delta"')).toBeLessThan(serialized.indexOf('"type":"text_delta"'));
});
it('emits redacted_thinking as its own lifecycle block', () => {
const streamContext = anthropicMessagesTransformer.createStreamContext('claude-opus-4-6');
const downstreamContext = anthropicMessagesTransformer.createDownstreamContext();
const redacted = anthropicMessagesTransformer.transformStreamEvent({
type: 'content_block_start',
index: 0,
content_block: { type: 'redacted_thinking', data: 'ciphertext' },
}, streamContext, 'claude-opus-4-6');
const serialized = anthropicMessagesTransformer.serializeStreamEvent(redacted, streamContext, downstreamContext).join('');
expect(serialized).toContain('"type":"redacted_thinking"');
expect(serialized).toContain('content_block_start');
expect(serialized).toContain('content_block_stop');
});
});
describe('extractAnthropicUsage', () => {
it('maps cache read and cache creation tokens into normalized usage', () => {
expect(extractAnthropicUsage({
usage: {
input_tokens: 100,
output_tokens: 30,
cache_read_input_tokens: 12,
cache_creation_input_tokens: 8,
},
})).toEqual({
promptTokens: 120,
completionTokens: 30,
totalTokens: 150,
cachedTokens: 12,
reasoningTokens: 0,
audioInputTokens: 0,
audioOutputTokens: 0,
acceptedPredictionTokens: 0,
rejectedPredictionTokens: 0,
});
});
it('exposes cache read/cache creation details for metadata consumers', () => {
expect(extractAnthropicUsageMetadata({
usage: {
input_tokens: 100,
output_tokens: 30,
cache_read_input_tokens: 12,
cache_creation_input_tokens: 8,
cache_creation: {
ephemeral_5m_input_tokens: 5,
ephemeral_1h_input_tokens: 3,
},
},
})).toEqual({
cacheReadInputTokens: 12,
cacheCreationInputTokens: 8,
ephemeral5mInputTokens: 5,
ephemeral1hInputTokens: 3,
promptTokensIncludingCache: 120,
});
});
it('derives cache creation input tokens from nested cache_creation when the top-level field is missing', () => {
expect(extractAnthropicUsageMetadata({
usage: {
input_tokens: 100,
output_tokens: 30,
cache_read_input_tokens: 12,
cache_creation: {
ephemeral_5m_input_tokens: 5,
ephemeral_1h_input_tokens: 3,
},
},
})).toEqual({
cacheReadInputTokens: 12,
cacheCreationInputTokens: 8,
ephemeral5mInputTokens: 5,
ephemeral1hInputTokens: 3,
promptTokensIncludingCache: 120,
});
});
});
describe('applyAnthropicMessagesAggregateEvent', () => {
it('tracks text, reasoning, redacted thinking, and tool call arguments', () => {
const state = createAnthropicMessagesAggregateState();
applyAnthropicMessagesAggregateEvent(state, { reasoningDelta: 'think-1' });
applyAnthropicMessagesAggregateEvent(state, { contentDelta: 'answer-1' });
applyAnthropicMessagesAggregateEvent(state, {
toolCallDeltas: [{ index: 0, id: 'call_1', name: 'lookup', argumentsDelta: '{"city":"par' }],
});
applyAnthropicMessagesAggregateEvent(state, {
toolCallDeltas: [{ index: 0, argumentsDelta: 'is"}' }],
});
applyAnthropicMessagesAggregateEvent(state, {
anthropic: {
redactedThinkingData: 'cipher',
},
} as any);
expect(state.reasoning).toEqual(['think-1']);
expect(state.text).toEqual(['answer-1']);
expect(state.redactedReasoning).toEqual(['cipher']);
expect(state.toolCalls[0]).toEqual({
id: 'call_1',
name: 'lookup',
arguments: '{"city":"paris"}',
});
});
it('buffers signature until the thinking block closes and records lifecycle markers', () => {
const state = createAnthropicMessagesAggregateState();
applyAnthropicMessagesAggregateEvent(state, {
anthropic: {
signatureDelta: 'sig-buffered',
},
} as any);
applyAnthropicMessagesAggregateEvent(state, {
anthropic: {
startBlock: {
kind: 'thinking',
index: 2,
},
},
} as any);
applyAnthropicMessagesAggregateEvent(state, { reasoningDelta: 'deliberating' });
applyAnthropicMessagesAggregateEvent(state, {
anthropic: {
stopBlockIndex: 2,
},
} as any);
applyAnthropicMessagesAggregateEvent(state, {
anthropic: {
startBlock: {
kind: 'redacted_thinking',
index: 3,
},
redactedThinkingData: 'cipher-2',
},
} as any);
applyAnthropicMessagesAggregateEvent(state, {
anthropic: {
stopBlockIndex: 3,
},
} as any);
expect(state.pendingSignature).toBeNull();
expect(state.signatures).toEqual(['sig-buffered']);
expect(state.blockLifecycle).toEqual([
{ kind: 'thinking', phase: 'start', index: 2 },
{ kind: 'thinking', phase: 'stop', index: 2 },
{ kind: 'redacted_thinking', phase: 'start', index: 3 },
{ kind: 'redacted_thinking', phase: 'stop', index: 3 },
]);
expect(state.redactedReasoning).toEqual(['cipher-2']);
});
it('flushes pending signatures and closes open thinking/redacted blocks on finish', () => {
const state = createAnthropicMessagesAggregateState();
applyAnthropicMessagesAggregateEvent(state, {
anthropic: {
signatureDelta: 'sig-finish',
},
} as any);
applyAnthropicMessagesAggregateEvent(state, {
anthropic: {
startBlock: {
kind: 'thinking',
index: 4,
},
},
} as any);
applyAnthropicMessagesAggregateEvent(state, {
anthropic: {
startBlock: {
kind: 'redacted_thinking',
index: 5,
},
redactedThinkingData: 'cipher-finish',
},
} as any);
applyAnthropicMessagesAggregateEvent(state, {
finishReason: 'stop',
done: true,
});
expect(state.pendingSignature).toBeNull();
expect(state.signatures).toEqual(['sig-finish']);
expect(state.blockLifecycle).toEqual([
{ kind: 'thinking', phase: 'start', index: 4 },
{ kind: 'thinking', phase: 'stop', index: 4 },
{ kind: 'redacted_thinking', phase: 'start', index: 5 },
{ kind: 'redacted_thinking', phase: 'stop', index: 5 },
]);
expect(state.finishReason).toBe('stop');
});
});
@@ -0,0 +1,881 @@
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object';
}
const VALID_ANTHROPIC_TOOL_CHOICE_TYPES = new Set(['auto', 'none', 'any', 'tool']);
const VALID_ANTHROPIC_THINKING_TYPES = new Set(['enabled', 'disabled', 'adaptive']);
const VALID_ANTHROPIC_EFFORTS = new Set(['low', 'medium', 'high', 'max']);
function asTrimmedString(value: unknown): string {
return typeof value === 'string' ? value.trim() : '';
}
function toFiniteNumber(value: unknown): number | null {
return typeof value === 'number' && Number.isFinite(value) ? value : null;
}
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 toPositiveInteger(value: unknown): number | null {
const numberValue = toFiniteNumber(value);
if (numberValue === null || numberValue <= 0) return null;
return Math.trunc(numberValue);
}
function sanitizeCacheControl(value: unknown): Record<string, unknown> | undefined {
if (!isRecord(value)) return undefined;
const type = asTrimmedString(value.type).toLowerCase();
if (type !== 'ephemeral') return undefined;
return { type };
}
function sanitizeAnthropicOutputConfig(
value: unknown,
): { value?: Record<string, unknown>; error?: string } {
if (!isRecord(value)) return {};
const next: Record<string, unknown> = {};
if ('effort' in value) {
const effort = asTrimmedString(value.effort).toLowerCase();
if (!effort) return { value: next };
if (!VALID_ANTHROPIC_EFFORTS.has(effort)) {
return { error: 'output_config.effort must be one of: low, medium, high, max' };
}
next.effort = effort;
}
for (const [key, entry] of Object.entries(value)) {
if (key === 'effort') continue;
next[key] = entry;
}
return Object.keys(next).length > 0 ? { value: next } : {};
}
function sanitizeAnthropicToolChoiceValue(
value: unknown,
): { value?: Record<string, unknown>; error?: string } {
if (value === undefined) return {};
if (typeof value === 'string') {
const normalized = asTrimmedString(value).toLowerCase();
if (!normalized) return {};
if (normalized === 'required') return { value: { type: 'any' } };
if (!VALID_ANTHROPIC_TOOL_CHOICE_TYPES.has(normalized)) {
return { error: 'tool_choice.type must be one of: auto, none, any, tool' };
}
return { value: { type: normalized } };
}
if (!isRecord(value)) {
return { error: 'tool_choice must be an object or string' };
}
const type = asTrimmedString(value.type).toLowerCase();
if (!VALID_ANTHROPIC_TOOL_CHOICE_TYPES.has(type)) {
return { error: 'tool_choice.type must be one of: auto, none, any, tool' };
}
const next: Record<string, unknown> = { ...value, type };
if (type === 'tool') {
const name = asTrimmedString(value.name ?? (isRecord(value.tool) ? value.tool.name : undefined));
if (!name) {
return { error: 'tool_choice.name is required when type is tool' };
}
next.name = name;
delete next.tool;
} else {
delete next.name;
delete next.tool;
}
return { value: next };
}
function sanitizeAnthropicThinkingConfig(
value: unknown,
): { value?: Record<string, unknown>; error?: string } {
if (!isRecord(value)) return {};
const type = asTrimmedString(value.type).toLowerCase();
if (!type) return {};
if (!VALID_ANTHROPIC_THINKING_TYPES.has(type)) {
return { error: 'thinking.type must be one of: enabled, disabled, adaptive' };
}
if (type === 'enabled') {
const budgetTokens = toPositiveInteger(value.budget_tokens ?? value.budgetTokens);
if (!budgetTokens) {
return { error: 'budget_tokens is required and must be positive when thinking.type is enabled' };
}
return {
value: {
type,
budget_tokens: budgetTokens,
},
};
}
if (type === 'adaptive') {
const budgetTokens = toPositiveInteger(value.budget_tokens ?? value.budgetTokens);
if (budgetTokens) {
return {
value: {
type: 'enabled',
budget_tokens: budgetTokens,
},
};
}
}
return { value: { type } };
}
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 '';
}
export 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 normalizeTextCandidate(value: unknown): string {
if (typeof value === 'string') return value;
if (!isRecord(value)) return '';
return asTrimmedString(value.text ?? value.content ?? value.output_text);
}
function normalizeToolMessageContent(raw: unknown): string {
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)) {
const textParts = raw
.map((item) => {
if (typeof item === 'string') return item;
if (!isRecord(item)) return '';
return normalizeTextCandidate(item);
})
.filter((item) => item.length > 0);
if (textParts.length > 0) return textParts.join('\n');
return safeJsonStringify(raw);
}
if (isRecord(raw)) {
const text = normalizeTextCandidate(raw);
return text || safeJsonStringify(raw);
}
return '';
}
function maybeParseDataUrlImage(url: string): Record<string, unknown> | null {
const match = /^data:(image\/[a-zA-Z0-9.+-]+);base64,(.+)$/i.exec(url);
if (!match) return null;
return {
type: 'image',
source: {
type: 'base64',
media_type: match[1],
data: match[2],
},
};
}
function toAnthropicImageBlock(item: Record<string, unknown>): Record<string, unknown> | null {
const rawImageUrl = item.image_url ?? item.url;
if (typeof rawImageUrl === 'string' && rawImageUrl.trim()) {
const parsed = maybeParseDataUrlImage(rawImageUrl.trim());
if (parsed) return parsed;
return {
type: 'image',
source: {
type: 'url',
url: rawImageUrl.trim(),
},
};
}
if (isRecord(rawImageUrl)) {
const nestedUrl = asTrimmedString(rawImageUrl.url ?? rawImageUrl.image_url);
if (!nestedUrl) return null;
const parsed = maybeParseDataUrlImage(nestedUrl);
if (parsed) return parsed;
return {
type: 'image',
source: {
type: 'url',
url: nestedUrl,
},
};
}
return null;
}
function toAnthropicTextBlock(text: string): Record<string, unknown> | null {
const normalized = text.trim();
return normalized ? { type: 'text', text: normalized } : null;
}
function sanitizeAnthropicContentBlock(item: Record<string, unknown>): Record<string, unknown> | null {
const type = asTrimmedString(item.type).toLowerCase();
if (!type) {
return toAnthropicTextBlock(normalizeTextCandidate(item));
}
if (type === 'text' || type === 'input_text' || type === 'output_text') {
const textBlock = toAnthropicTextBlock(normalizeTextCandidate(item));
if (!textBlock) return null;
const next: Record<string, unknown> = { ...item, ...textBlock, type: 'text' };
const cacheControl = sanitizeCacheControl(item.cache_control);
if (cacheControl) next.cache_control = cacheControl;
else delete next.cache_control;
return next;
}
if (type === 'image_url' || type === 'input_image') {
return toAnthropicImageBlock(item);
}
if (type === 'thinking' || type === 'redacted_thinking' || type === 'reasoning') {
const text = asTrimmedString(item.thinking ?? item.text ?? item.content ?? item.data);
if (!text) return null;
const next: Record<string, unknown> = { ...item };
if (type === 'redacted_thinking') {
next.type = 'redacted_thinking';
next.data = text;
delete next.thinking;
} else {
next.type = type === 'reasoning' ? 'thinking' : type;
next.thinking = text;
}
delete next.cache_control;
delete next.text;
delete next.content;
return next;
}
if (type === 'tool_result') {
const toolUseId = asTrimmedString(item.tool_use_id ?? item.toolUseId);
const content = normalizeToolMessageContent(item.content ?? item.result);
if (!toolUseId || !content) return null;
const next: Record<string, unknown> = {
type: 'tool_result',
tool_use_id: toolUseId,
content,
};
const cacheControl = sanitizeCacheControl(item.cache_control);
if (cacheControl) next.cache_control = cacheControl;
return next;
}
if (type === 'tool_use') {
const id = asTrimmedString(item.id);
const name = asTrimmedString(item.name ?? item.toolName);
if (!id || !name) return null;
const next: Record<string, unknown> = {
type: 'tool_use',
id,
name,
input: normalizeAnthropicToolInput(item.input ?? item.arguments ?? item.argumentsText),
};
const cacheControl = sanitizeCacheControl(item.cache_control);
if (cacheControl) next.cache_control = cacheControl;
return next;
}
return { ...item };
}
function isCacheableAnthropicMessageBlock(item: Record<string, unknown>): boolean {
const type = asTrimmedString(item.type).toLowerCase();
if (type === 'thinking' || type === 'redacted_thinking') return false;
if (type === 'text' || type === 'input_text' || type === 'output_text') {
return normalizeTextCandidate(item).trim().length > 0;
}
return true;
}
function clearAnthropicCacheControls(body: Record<string, unknown>) {
if (Array.isArray(body.tools)) {
body.tools = body.tools.map((tool) => {
if (!isRecord(tool)) return tool;
const next = { ...tool };
delete next.cache_control;
return next;
});
}
if (Array.isArray(body.system)) {
body.system = body.system.map((entry) => {
if (!isRecord(entry)) return entry;
const next = { ...entry };
delete next.cache_control;
return next;
});
}
const rawMessages = Array.isArray(body.messages) ? body.messages : [];
body.messages = rawMessages.map((message) => {
if (!isRecord(message)) return message;
const next = { ...message };
if (Array.isArray(next.content)) {
next.content = next.content.map((item) => {
if (!isRecord(item)) return item;
const block = { ...item };
delete block.cache_control;
return block;
});
}
return next;
});
}
function ensureStructuralAnthropicCacheControls(body: Record<string, unknown>): number {
let count = 0;
if (Array.isArray(body.tools) && body.tools.length > 0) {
const lastIndex = body.tools.length - 1;
const lastTool = body.tools[lastIndex];
if (isRecord(lastTool)) {
body.tools[lastIndex] = {
...lastTool,
cache_control: { type: 'ephemeral' },
};
count += 1;
}
}
if (Array.isArray(body.system) && body.system.length > 0) {
const lastIndex = body.system.length - 1;
const lastPrompt = body.system[lastIndex];
if (isRecord(lastPrompt)) {
body.system[lastIndex] = {
...lastPrompt,
cache_control: { type: 'ephemeral' },
};
count += 1;
}
}
return count;
}
type AnthropicCacheRef = {
get value(): Record<string, unknown>;
set value(next: Record<string, unknown>);
};
function collectAnthropicCacheableMessageRefs(body: Record<string, unknown>): AnthropicCacheRef[] {
const refs: AnthropicCacheRef[] = [];
const messages = Array.isArray(body.messages) ? body.messages : [];
messages.forEach((message, messageIndex) => {
if (!isRecord(message) || !Array.isArray(message.content)) return;
message.content.forEach((item, contentIndex) => {
if (!isRecord(item) || !isCacheableAnthropicMessageBlock(item)) return;
refs.push({
get value() {
const currentMessages = Array.isArray(body.messages) ? body.messages : [];
const currentMessage = currentMessages[messageIndex];
if (!isRecord(currentMessage) || !Array.isArray(currentMessage.content)) return item;
const currentItem = currentMessage.content[contentIndex];
return isRecord(currentItem) ? currentItem : item;
},
set value(next: Record<string, unknown>) {
const currentMessages = Array.isArray(body.messages) ? [...body.messages] : [];
const currentMessage = currentMessages[messageIndex];
if (!isRecord(currentMessage) || !Array.isArray(currentMessage.content)) return;
const nextContent = [...currentMessage.content];
nextContent[contentIndex] = next;
currentMessages[messageIndex] = {
...currentMessage,
content: nextContent,
};
body.messages = currentMessages;
},
});
});
});
return refs;
}
function addEphemeralCacheControl(item: Record<string, unknown>): Record<string, unknown> {
return {
...item,
cache_control: { type: 'ephemeral' },
};
}
function sanitizeUnsupportedAnthropicCacheControls(body: Record<string, unknown>) {
const rawMessages = Array.isArray(body.messages) ? body.messages : [];
body.messages = rawMessages.map((message) => {
if (!isRecord(message) || !Array.isArray(message.content)) return message;
return {
...message,
content: message.content.map((item) => {
if (!isRecord(item)) return item;
if (isCacheableAnthropicMessageBlock(item)) return item;
const next = { ...item };
delete next.cache_control;
return next;
}),
};
});
}
function optimizeAnthropicCacheControls(body: Record<string, unknown>) {
clearAnthropicCacheControls(body);
const structuralAnchors = ensureStructuralAnthropicCacheControls(body);
const remaining = Math.max(0, 4 - structuralAnchors);
if (remaining <= 0) {
sanitizeUnsupportedAnthropicCacheControls(body);
return;
}
const refs = collectAnthropicCacheableMessageRefs(body);
if (refs.length <= 0) {
sanitizeUnsupportedAnthropicCacheControls(body);
return;
}
const desiredMessageAnchors = refs.length >= 20 ? 2 : 1;
const targetAnchors = Math.min(desiredMessageAnchors, remaining);
const usedIndexes = new Set<number>();
const applyAnchor = (index: number) => {
if (index < 0 || index >= refs.length || usedIndexes.has(index)) return;
usedIndexes.add(index);
refs[index].value = addEphemeralCacheControl(refs[index].value);
};
applyAnchor(refs.length - 1);
if (targetAnchors > 1) {
const targetIndex = Math.max(refs.length - 1 - 20, 0);
let chosen = -1;
for (let index = targetIndex; index >= 0; index -= 1) {
if (!usedIndexes.has(index)) {
chosen = index;
break;
}
}
if (chosen < 0) {
for (let index = targetIndex + 1; index < refs.length; index += 1) {
if (!usedIndexes.has(index)) {
chosen = index;
break;
}
}
}
applyAnchor(chosen);
}
sanitizeUnsupportedAnthropicCacheControls(body);
}
function convertOpenAiContentToAnthropicBlocks(content: unknown): Array<Record<string, unknown>> {
if (typeof content === 'string') {
const textBlock = toAnthropicTextBlock(content);
return textBlock ? [textBlock] : [];
}
if (!Array.isArray(content)) {
if (isRecord(content)) {
const single = sanitizeAnthropicContentBlock(content);
return single ? [single] : [];
}
return [];
}
return content
.map((item) => {
if (typeof item === 'string') return toAnthropicTextBlock(item);
if (!isRecord(item)) return null;
return sanitizeAnthropicContentBlock(item);
})
.filter((item): item is Record<string, unknown> => !!item);
}
export function sanitizeAnthropicMessagesBody(
body: Record<string, unknown>,
options: { autoOptimizeCacheControls?: boolean } = {},
): Record<string, unknown> {
const sanitized: Record<string, unknown> = { ...body };
const autoOptimizeCacheControls = options.autoOptimizeCacheControls !== false;
const hasTemperature = toFiniteNumber(sanitized.temperature) !== null;
const hasTopP = toFiniteNumber(sanitized.top_p) !== null;
if (hasTemperature && hasTopP) {
delete sanitized.top_p;
}
const thinkingResult = sanitizeAnthropicThinkingConfig(sanitized.thinking);
if (thinkingResult.value) {
sanitized.thinking = thinkingResult.value;
} else {
delete sanitized.thinking;
}
const outputConfigResult = sanitizeAnthropicOutputConfig(sanitized.output_config ?? sanitized.outputConfig);
if (outputConfigResult.value) {
sanitized.output_config = outputConfigResult.value;
} else {
delete sanitized.output_config;
}
delete sanitized.outputConfig;
const toolChoiceResult = sanitizeAnthropicToolChoiceValue(sanitized.tool_choice ?? sanitized.toolChoice);
if (toolChoiceResult.value) {
sanitized.tool_choice = toolChoiceResult.value;
} else {
delete sanitized.tool_choice;
}
delete sanitized.toolChoice;
const rawMessages = Array.isArray(sanitized.messages) ? sanitized.messages : [];
if (rawMessages.length > 0) {
sanitized.messages = rawMessages.map((message) => {
if (!isRecord(message)) return message;
const next = { ...message };
if (Array.isArray(next.content)) {
next.content = next.content
.map((item) => (isRecord(item) ? sanitizeAnthropicContentBlock(item) : item))
.filter((item) => item !== null);
}
return next;
});
}
if (autoOptimizeCacheControls) {
optimizeAnthropicCacheControls(sanitized);
} else {
sanitizeUnsupportedAnthropicCacheControls(sanitized);
}
return sanitized;
}
function resolveOpenAiReasoningSettings(
openaiBody: Record<string, unknown>,
): { thinking?: Record<string, unknown>; outputConfig?: Record<string, unknown> } {
const explicitReasoning = isRecord(openaiBody.reasoning) ? openaiBody.reasoning : null;
const reasoningEffort = asTrimmedString(
openaiBody.reasoning_effort
?? explicitReasoning?.effort
?? explicitReasoning?.reasoning_effort,
).toLowerCase();
const reasoningBudget = toPositiveInteger(
openaiBody.reasoning_budget
?? explicitReasoning?.budget_tokens
?? explicitReasoning?.max_tokens,
);
const result: { thinking?: Record<string, unknown>; outputConfig?: Record<string, unknown> } = {};
if (reasoningBudget) {
result.thinking = {
type: 'enabled',
budget_tokens: reasoningBudget,
};
} else if (reasoningEffort && VALID_ANTHROPIC_EFFORTS.has(reasoningEffort)) {
result.thinking = { type: 'adaptive' };
}
if (reasoningEffort && VALID_ANTHROPIC_EFFORTS.has(reasoningEffort)) {
result.outputConfig = { effort: reasoningEffort };
}
return result;
}
export 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;
}
export 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;
}
export function convertOpenAiBodyToAnthropicMessagesBody(
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 = convertOpenAiContentToAnthropicBlocks(item.content);
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}`
);
contentBlocks.push({
type: 'tool_use',
id,
name,
input: normalizeAnthropicToolInput(functionPart.arguments ?? toolCall.arguments),
});
}
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 contentBlocks = convertOpenAiContentToAnthropicBlocks(item.content);
const text = contentBlocks
.filter((block) => block.type === 'text' && typeof block.text === 'string')
.map((block) => block.text as string)
.filter(Boolean)
.join('\n\n');
if (text) systemContents.push(text);
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) && asTrimmedString(nextItem.role).toLowerCase() === 'user') {
const userBlocks = convertOpenAiContentToAnthropicBlocks(nextItem.content);
toolResultBlocks.push(...userBlocks);
cursor += 1;
}
}
if (toolResultBlocks.length > 0) {
messages.push({
role: 'user',
content: toolResultBlocks,
});
}
messageIndex = cursor - 1;
continue;
}
if (role === 'assistant') {
appendAssistantMessage(item);
continue;
}
const contentBlocks = convertOpenAiContentToAnthropicBlocks(item.content);
if (contentBlocks.length <= 0) continue;
if (contentBlocks.length === 1 && contentBlocks[0].type === 'text') {
const singleText = asTrimmedString(contentBlocks[0].text);
if (singleText) {
messages.push({ role: 'user', content: singleText });
}
continue;
}
messages.push({
role: 'user',
content: contentBlocks,
});
}
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;
const reasoningSettings = resolveOpenAiReasoningSettings(openaiBody);
if (reasoningSettings.thinking) body.thinking = reasoningSettings.thinking;
if (reasoningSettings.outputConfig) body.output_config = reasoningSettings.outputConfig;
return sanitizeAnthropicMessagesBody(body, { autoOptimizeCacheControls: false });
}
export function validateAnthropicMessagesBody(
body: Record<string, unknown>,
): { sanitizedBody?: Record<string, unknown>; error?: { statusCode: number; payload: unknown } } {
const thinkingResult = sanitizeAnthropicThinkingConfig(body.thinking);
if (thinkingResult.error) {
return {
error: {
statusCode: 400,
payload: { error: { message: thinkingResult.error, type: 'invalid_request_error' } },
},
};
}
const outputConfigResult = sanitizeAnthropicOutputConfig(body.output_config ?? body.outputConfig);
if (outputConfigResult.error) {
return {
error: {
statusCode: 400,
payload: { error: { message: outputConfigResult.error, type: 'invalid_request_error' } },
},
};
}
const toolChoiceResult = sanitizeAnthropicToolChoiceValue(body.tool_choice ?? body.toolChoice);
if (toolChoiceResult.error) {
return {
error: {
statusCode: 400,
payload: { error: { message: toolChoiceResult.error, type: 'invalid_request_error' } },
},
};
}
return { sanitizedBody: sanitizeAnthropicMessagesBody(body) };
}
@@ -0,0 +1,22 @@
import { parseDownstreamChatRequest, type ParsedDownstreamChatRequest } from '../../shared/normalized.js';
import { validateAnthropicMessagesBody } from './conversion.js';
export const anthropicMessagesInbound = {
parse(body: unknown): { value?: ParsedDownstreamChatRequest; error?: { statusCode: number; payload: unknown } } {
const parsed = parseDownstreamChatRequest(body, 'claude');
if (parsed.error || !parsed.value) return parsed;
const originalBody = parsed.value.claudeOriginalBody;
if (originalBody) {
const validation = validateAnthropicMessagesBody(originalBody);
if (validation.error) {
return { error: validation.error };
}
if (validation.sanitizedBody) {
parsed.value.claudeOriginalBody = validation.sanitizedBody;
}
}
return parsed;
},
};
@@ -0,0 +1,95 @@
import { type NormalizedFinalResponse, type NormalizedStreamEvent, type ParsedDownstreamChatRequest, type StreamTransformContext, type ClaudeDownstreamContext } from '../../shared/normalized.js';
import { anthropicMessagesInbound } from './inbound.js';
import { anthropicMessagesOutbound } from './outbound.js';
import { anthropicMessagesStream, consumeAnthropicSseEvent } from './stream.js';
import { anthropicMessagesUsage } from './usage.js';
import { createAnthropicMessagesAggregateState } from './aggregator.js';
export {
ANTHROPIC_RAW_SSE_EVENT_NAMES,
consumeAnthropicSseEvent,
isAnthropicRawSseEventName,
serializeAnthropicFinalAsStream,
serializeAnthropicUpstreamFinalAsStream,
serializeAnthropicRawSseEvent,
syncAnthropicRawStreamStateFromEvent,
} from './stream.js';
export const anthropicMessagesTransformer = {
protocol: 'anthropic/messages' as const,
inbound: anthropicMessagesInbound,
outbound: anthropicMessagesOutbound,
stream: anthropicMessagesStream,
usage: anthropicMessagesUsage,
aggregator: {
createState: createAnthropicMessagesAggregateState,
},
transformRequest(body: unknown): ReturnType<typeof anthropicMessagesInbound.parse> {
return anthropicMessagesInbound.parse(body);
},
createStreamContext(modelName: string): StreamTransformContext {
return anthropicMessagesStream.createContext(modelName);
},
createDownstreamContext(): ClaudeDownstreamContext {
return anthropicMessagesStream.createDownstreamContext();
},
transformFinalResponse(payload: unknown, modelName: string, fallbackText = ''): NormalizedFinalResponse {
return anthropicMessagesOutbound.normalizeFinal(payload, modelName, fallbackText);
},
transformStreamEvent(payload: unknown, context: StreamTransformContext, modelName: string): NormalizedStreamEvent {
return anthropicMessagesStream.normalizeEvent(payload, context, modelName);
},
serializeStreamEvent(
event: NormalizedStreamEvent,
context: StreamTransformContext,
claudeContext: ClaudeDownstreamContext,
): string[] {
return anthropicMessagesStream.serializeEvent(event, context, claudeContext);
},
serializeDone(
context: StreamTransformContext,
claudeContext: ClaudeDownstreamContext,
): string[] {
return anthropicMessagesStream.serializeDone(context, claudeContext);
},
serializeFinalResponse(
normalized: NormalizedFinalResponse,
usage: Parameters<typeof anthropicMessagesOutbound.serializeFinal>[1],
) {
return anthropicMessagesOutbound.serializeFinal(normalized, usage);
},
serializeUpstreamFinalAsStream(
payload: unknown,
modelName: string,
fallbackText: string,
streamContext: StreamTransformContext,
claudeContext: ClaudeDownstreamContext,
) {
return anthropicMessagesStream.serializeUpstreamFinalAsStream(
payload,
modelName,
fallbackText,
anthropicMessagesOutbound.normalizeFinal,
streamContext,
claudeContext,
);
},
consumeSseEventBlock(
eventBlock: { event: string; data: string },
streamContext: StreamTransformContext,
claudeContext: ClaudeDownstreamContext,
modelName: string,
) {
return consumeAnthropicSseEvent(
eventBlock,
streamContext,
claudeContext,
modelName,
);
},
pullSseEvents(buffer: string) {
return anthropicMessagesStream.pullSseEvents(buffer);
},
};
export type AnthropicMessagesTransformer = typeof anthropicMessagesTransformer;
export type AnthropicMessagesParsedRequest = ParsedDownstreamChatRequest;
@@ -0,0 +1,10 @@
import { normalizeUpstreamFinalResponse, serializeFinalResponse, type NormalizedFinalResponse } from '../../shared/normalized.js';
export const anthropicMessagesOutbound = {
normalizeFinal(payload: unknown, modelName: string, fallbackText = ''): NormalizedFinalResponse {
return normalizeUpstreamFinalResponse(payload, modelName, fallbackText);
},
serializeFinal(normalized: NormalizedFinalResponse, usage?: unknown) {
return serializeFinalResponse('claude', normalized, usage as any);
},
};
@@ -0,0 +1,700 @@
import {
createClaudeDownstreamContext,
createStreamTransformContext,
normalizeStopReason,
normalizeUpstreamStreamEvent,
pullSseEventsWithDone,
type ClaudeDownstreamContext,
type NormalizedFinalResponse,
type NormalizedStreamEvent,
type StreamTransformContext,
} from '../../shared/normalized.js';
import { type AnthropicExtendedStreamEvent } from './aggregator.js';
type AnthropicStreamPayload = Record<string, unknown>;
type ExtendedClaudeDownstreamContext = ClaudeDownstreamContext & {
thinkingBlockIndex?: number | null;
pendingSignature?: string | null;
};
export const ANTHROPIC_RAW_SSE_EVENT_NAMES = new Set([
'message_start',
'content_block_start',
'content_block_delta',
'content_block_stop',
'message_delta',
'message_stop',
'ping',
'error',
]);
function isRecord(value: unknown): value is AnthropicStreamPayload {
return !!value && typeof value === 'object' && !Array.isArray(value);
}
function asTrimmedString(value: unknown): string {
return typeof value === 'string' ? value.trim() : '';
}
function serializeSse(event: string, payload: Record<string, unknown>): string {
return `event: ${event}\ndata: ${JSON.stringify(payload)}\n\n`;
}
export function isAnthropicRawSseEventName(value: unknown): value is string {
return typeof value === 'string' && ANTHROPIC_RAW_SSE_EVENT_NAMES.has(value);
}
export function serializeAnthropicRawSseEvent(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 ensureContext(context: ClaudeDownstreamContext): ExtendedClaudeDownstreamContext {
const extended = context as ExtendedClaudeDownstreamContext;
if (extended.thinkingBlockIndex === undefined) extended.thinkingBlockIndex = null;
if (extended.pendingSignature === undefined) extended.pendingSignature = null;
return extended;
}
export function syncAnthropicRawStreamStateFromEvent(
eventName: string,
parsedPayload: unknown,
streamContext: StreamTransformContext,
downstreamContext: ClaudeDownstreamContext,
) {
const context = ensureContext(downstreamContext);
if (eventName === 'message_start') {
context.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') {
context.contentBlockStarted = true;
return;
}
if (eventName === 'content_block_stop') {
context.contentBlockStarted = false;
return;
}
if (eventName === 'message_stop') {
context.doneSent = true;
}
}
function buildClaudeMessageId(sourceId: string): string {
if (sourceId.startsWith('msg_')) return sourceId;
const sanitized = sourceId.replace(/[^A-Za-z0-9_-]/g, '_');
return `msg_${sanitized || Date.now()}`;
}
function ensureClaudeStartEvents(
streamContext: StreamTransformContext,
context: ExtendedClaudeDownstreamContext,
): string[] {
if (context.messageStarted) return [];
context.messageStarted = true;
return [
serializeSse('message_start', {
type: 'message_start',
message: {
id: buildClaudeMessageId(streamContext.id),
type: 'message',
role: 'assistant',
model: streamContext.model,
content: [],
stop_reason: null,
stop_sequence: null,
usage: {
input_tokens: 0,
output_tokens: 0,
},
},
}),
];
}
function closeTextBlock(context: ExtendedClaudeDownstreamContext): string[] {
if (context.textBlockIndex === null || context.textBlockIndex === undefined) return [];
const index = context.textBlockIndex;
context.textBlockIndex = null;
context.contentBlockStarted = false;
return [
serializeSse('content_block_stop', {
type: 'content_block_stop',
index,
}),
];
}
function emitPendingSignature(context: ExtendedClaudeDownstreamContext): string[] {
if (!context.pendingSignature || context.thinkingBlockIndex === null || context.thinkingBlockIndex === undefined) {
return [];
}
const signature = context.pendingSignature;
context.pendingSignature = null;
return [
serializeSse('content_block_delta', {
type: 'content_block_delta',
index: context.thinkingBlockIndex,
delta: {
type: 'signature_delta',
signature,
},
}),
];
}
function closeThinkingBlock(context: ExtendedClaudeDownstreamContext): string[] {
if (context.thinkingBlockIndex === null || context.thinkingBlockIndex === undefined) return [];
const index = context.thinkingBlockIndex;
const events = [
...emitPendingSignature(context),
serializeSse('content_block_stop', {
type: 'content_block_stop',
index,
}),
];
context.thinkingBlockIndex = null;
return events;
}
function closeToolBlocks(context: ExtendedClaudeDownstreamContext): string[] {
const openBlocks = Object.values(context.toolBlocks)
.filter((item) => item.open)
.sort((a, b) => a.contentIndex - b.contentIndex);
if (openBlocks.length <= 0) return [];
const events: string[] = [];
for (const block of openBlocks) {
block.open = false;
events.push(serializeSse('content_block_stop', {
type: 'content_block_stop',
index: block.contentIndex,
}));
}
return events;
}
function closeAllBlocks(context: ExtendedClaudeDownstreamContext): string[] {
return [
...closeTextBlock(context),
...closeThinkingBlock(context),
...closeToolBlocks(context),
];
}
function ensureTextBlockStart(context: ExtendedClaudeDownstreamContext): string[] {
if (context.textBlockIndex !== null && context.textBlockIndex !== undefined) return [];
const index = context.nextContentBlockIndex;
context.nextContentBlockIndex += 1;
context.textBlockIndex = index;
context.contentBlockStarted = true;
return [
serializeSse('content_block_start', {
type: 'content_block_start',
index,
content_block: {
type: 'text',
text: '',
},
}),
];
}
function ensureThinkingBlockStart(context: ExtendedClaudeDownstreamContext): string[] {
if (context.thinkingBlockIndex !== null && context.thinkingBlockIndex !== undefined) return [];
const index = context.nextContentBlockIndex;
context.nextContentBlockIndex += 1;
context.thinkingBlockIndex = index;
return [
serializeSse('content_block_start', {
type: 'content_block_start',
index,
content_block: {
type: 'thinking',
thinking: '',
},
}),
];
}
function ensureToolBlockStart(
context: ExtendedClaudeDownstreamContext,
toolDelta: NonNullable<AnthropicExtendedStreamEvent['toolCallDeltas']>[number],
): { events: string[]; contentIndex: number } {
const toolSlot = Number.isFinite(toolDelta.index) ? Math.max(0, Math.trunc(toolDelta.index)) : 0;
let state = context.toolBlocks[toolSlot];
if (!state) {
state = {
contentIndex: context.nextContentBlockIndex,
id: toolDelta.id || `toolu_${toolSlot}`,
name: toolDelta.name || `tool_${toolSlot}`,
open: false,
};
context.nextContentBlockIndex += 1;
context.toolBlocks[toolSlot] = state;
} else {
if (toolDelta.id) state.id = toolDelta.id;
if (toolDelta.name) state.name = toolDelta.name;
}
const events: string[] = [];
if (!state.open) {
state.open = true;
events.push(serializeSse('content_block_start', {
type: 'content_block_start',
index: state.contentIndex,
content_block: {
type: 'tool_use',
id: state.id,
name: state.name,
input: {},
},
}));
}
return { events, contentIndex: state.contentIndex };
}
function buildDoneEvents(
streamContext: StreamTransformContext,
context: ExtendedClaudeDownstreamContext,
finishReason?: string | null,
): string[] {
if (context.doneSent) return [];
const events = [
...ensureClaudeStartEvents(streamContext, context),
...closeAllBlocks(context),
serializeSse('message_delta', {
type: 'message_delta',
delta: {
stop_reason: toClaudeStopReason(finishReason),
stop_sequence: null,
},
usage: {
output_tokens: 0,
},
}),
serializeSse('message_stop', {
type: 'message_stop',
}),
];
context.doneSent = true;
return events;
}
export function serializeAnthropicFinalAsStream(
normalizedFinal: NormalizedFinalResponse,
streamContext: StreamTransformContext,
downstreamContext: ClaudeDownstreamContext,
): string[] {
streamContext.id = normalizedFinal.id;
streamContext.model = normalizedFinal.model;
streamContext.created = normalizedFinal.created;
const lines = [
...anthropicMessagesStream.serializeEvent({ role: 'assistant' }, streamContext, downstreamContext),
];
if (normalizedFinal.reasoningContent) {
lines.push(
...anthropicMessagesStream.serializeEvent(
{ reasoningDelta: normalizedFinal.reasoningContent },
streamContext,
downstreamContext,
),
);
}
if (normalizedFinal.content) {
lines.push(
...anthropicMessagesStream.serializeEvent(
{ contentDelta: normalizedFinal.content },
streamContext,
downstreamContext,
),
);
}
lines.push(
...anthropicMessagesStream.serializeEvent(
{ finishReason: normalizedFinal.finishReason },
streamContext,
downstreamContext,
),
);
return lines;
}
export function serializeAnthropicUpstreamFinalAsStream(
payload: unknown,
modelName: string,
fallbackText: string,
normalizeFinal: (payload: unknown, modelName: string, fallbackText?: string) => NormalizedFinalResponse,
streamContext: StreamTransformContext,
downstreamContext: ClaudeDownstreamContext,
): string[] {
const normalizedFinal = normalizeFinal(payload, modelName, fallbackText);
return serializeAnthropicFinalAsStream(normalizedFinal, streamContext, downstreamContext);
}
function toClaudeStopReason(finishReason: string | null | undefined): string {
const normalized = normalizeStopReason(finishReason);
if (normalized === 'length') return 'max_tokens';
if (normalized === 'tool_calls') return 'tool_use';
return 'end_turn';
}
function normalizeAnthropicRawEvent(
payload: AnthropicStreamPayload,
context: StreamTransformContext,
fallbackModel: string,
): AnthropicExtendedStreamEvent | null {
const type = asTrimmedString(payload.type);
if (!type) return null;
if (type === 'message_start' && isRecord(payload.message)) {
const message = payload.message;
if (asTrimmedString(message.id)) context.id = asTrimmedString(message.id);
if (asTrimmedString(message.model)) context.model = asTrimmedString(message.model);
else if (!context.model) context.model = fallbackModel;
return { role: 'assistant' };
}
if (type === 'content_block_start' && isRecord(payload.content_block)) {
const contentBlock = payload.content_block;
const blockType = asTrimmedString(contentBlock.type);
const index = typeof payload.index === 'number' ? payload.index : undefined;
if (blockType === 'thinking') {
return {
anthropic: {
startBlock: {
kind: 'thinking',
index,
},
},
};
}
if (blockType === 'redacted_thinking') {
return {
anthropic: {
startBlock: {
kind: 'redacted_thinking',
index,
},
redactedThinkingData: asTrimmedString(contentBlock.data),
},
};
}
if (blockType === 'tool_use') {
return {
toolCallDeltas: [{
index: typeof index === 'number' ? index : 0,
id: asTrimmedString(contentBlock.id) || undefined,
name: asTrimmedString(contentBlock.name) || undefined,
}],
};
}
}
if (type === 'content_block_delta' && isRecord(payload.delta)) {
const delta = payload.delta;
const deltaType = asTrimmedString(delta.type);
const index = typeof payload.index === 'number' ? payload.index : 0;
if (deltaType === 'thinking_delta') {
return {
reasoningDelta: asTrimmedString(delta.thinking ?? delta.text) || undefined,
};
}
if (deltaType === 'signature_delta') {
return {
anthropic: {
signatureDelta: asTrimmedString(delta.signature) || undefined,
},
};
}
if (deltaType === 'text_delta') {
return {
contentDelta: asTrimmedString(delta.text) || undefined,
};
}
if (deltaType === 'input_json_delta') {
return {
toolCallDeltas: [{
index,
argumentsDelta: asTrimmedString(delta.partial_json),
}],
};
}
}
if (type === 'content_block_stop') {
return {
anthropic: {
stopBlockIndex: typeof payload.index === 'number' ? payload.index : null,
},
};
}
if (type === 'message_delta' && isRecord(payload.delta)) {
return {
finishReason: normalizeStopReason(payload.delta.stop_reason ?? payload.stop_reason),
};
}
if (type === 'message_stop') {
return { done: true };
}
return null;
}
export type AnthropicConsumedSseEvent = {
handled: boolean;
lines: string[];
done: boolean;
parsedPayload: unknown | null;
};
export function consumeAnthropicSseEvent(
eventBlock: { event: string; data: string },
streamContext: StreamTransformContext,
downstreamContext: ClaudeDownstreamContext,
fallbackModel: string,
): AnthropicConsumedSseEvent {
const context = ensureContext(downstreamContext);
let parsedPayload: unknown = null;
try {
parsedPayload = JSON.parse(eventBlock.data);
} catch {
parsedPayload = null;
}
if (parsedPayload && isRecord(parsedPayload)) {
const payloadType = typeof parsedPayload.type === 'string' ? parsedPayload.type : '';
const claudeEventName = isAnthropicRawSseEventName(eventBlock.event)
? eventBlock.event
: (isAnthropicRawSseEventName(payloadType) ? payloadType : '');
if (claudeEventName) {
syncAnthropicRawStreamStateFromEvent(
claudeEventName,
parsedPayload,
streamContext,
context,
);
return {
handled: true,
lines: [serializeAnthropicRawSseEvent(claudeEventName, eventBlock.data)],
done: context.doneSent,
parsedPayload,
};
}
}
return {
handled: false,
lines: [],
done: false,
parsedPayload,
};
}
export const anthropicMessagesStream = {
createContext(modelName: string): StreamTransformContext {
return createStreamTransformContext(modelName);
},
createDownstreamContext(): ClaudeDownstreamContext {
return ensureContext(createClaudeDownstreamContext());
},
normalizeEvent(payload: unknown, context: StreamTransformContext, modelName: string): AnthropicExtendedStreamEvent {
if (isRecord(payload)) {
const anthropicEvent = normalizeAnthropicRawEvent(payload, context, modelName);
if (anthropicEvent) return anthropicEvent;
}
return normalizeUpstreamStreamEvent(payload, context, modelName) as AnthropicExtendedStreamEvent;
},
serializeEvent(
event: NormalizedStreamEvent,
streamContext: StreamTransformContext,
downstreamContext: ClaudeDownstreamContext,
): string[] {
const context = ensureContext(downstreamContext);
const anthropicEvent = event as AnthropicExtendedStreamEvent;
const events: string[] = [];
const needsStart = (
event.role === 'assistant'
|| !!event.contentDelta
|| !!event.reasoningDelta
|| (Array.isArray(event.toolCallDeltas) && event.toolCallDeltas.length > 0)
|| !!anthropicEvent.anthropic
|| !!event.finishReason
|| !!event.done
);
if (needsStart) {
events.push(...ensureClaudeStartEvents(streamContext, context));
}
if (anthropicEvent.anthropic?.signatureDelta) {
if (context.thinkingBlockIndex !== null && context.thinkingBlockIndex !== undefined) {
events.push(serializeSse('content_block_delta', {
type: 'content_block_delta',
index: context.thinkingBlockIndex,
delta: {
type: 'signature_delta',
signature: anthropicEvent.anthropic.signatureDelta,
},
}));
} else {
context.pendingSignature = anthropicEvent.anthropic.signatureDelta;
}
}
if (anthropicEvent.anthropic?.redactedThinkingData) {
events.push(...closeAllBlocks(context));
const index = context.nextContentBlockIndex;
context.nextContentBlockIndex += 1;
events.push(serializeSse('content_block_start', {
type: 'content_block_start',
index,
content_block: {
type: 'redacted_thinking',
data: anthropicEvent.anthropic.redactedThinkingData,
},
}));
events.push(serializeSse('content_block_stop', {
type: 'content_block_stop',
index,
}));
}
if (event.reasoningDelta) {
events.push(...closeToolBlocks(context));
events.push(...closeTextBlock(context));
events.push(...ensureThinkingBlockStart(context));
events.push(serializeSse('content_block_delta', {
type: 'content_block_delta',
index: context.thinkingBlockIndex ?? 0,
delta: {
type: 'thinking_delta',
thinking: event.reasoningDelta,
},
}));
}
if (Array.isArray(event.toolCallDeltas) && event.toolCallDeltas.length > 0) {
events.push(...closeTextBlock(context));
events.push(...closeThinkingBlock(context));
for (const toolDelta of event.toolCallDeltas) {
const toolBlock = ensureToolBlockStart(context, toolDelta);
events.push(...toolBlock.events);
if (toolDelta.argumentsDelta) {
events.push(serializeSse('content_block_delta', {
type: 'content_block_delta',
index: toolBlock.contentIndex,
delta: {
type: 'input_json_delta',
partial_json: toolDelta.argumentsDelta,
},
}));
}
}
}
if (event.contentDelta) {
events.push(...closeToolBlocks(context));
events.push(...closeThinkingBlock(context));
events.push(...ensureTextBlockStart(context));
events.push(serializeSse('content_block_delta', {
type: 'content_block_delta',
index: context.textBlockIndex ?? 0,
delta: {
type: 'text_delta',
text: event.contentDelta,
},
}));
}
if (
anthropicEvent.anthropic?.stopBlockIndex !== undefined
&& anthropicEvent.anthropic.stopBlockIndex !== null
) {
const targetIndex = anthropicEvent.anthropic.stopBlockIndex;
if (context.thinkingBlockIndex === targetIndex) {
events.push(...closeThinkingBlock(context));
}
if (context.textBlockIndex === targetIndex) {
events.push(...closeTextBlock(context));
}
const matchingToolBlock = Object.values(context.toolBlocks).find((item) => item.open && item.contentIndex === targetIndex);
if (matchingToolBlock) {
matchingToolBlock.open = false;
events.push(serializeSse('content_block_stop', {
type: 'content_block_stop',
index: matchingToolBlock.contentIndex,
}));
}
}
if (event.finishReason || event.done) {
events.push(...buildDoneEvents(streamContext, context, event.finishReason));
}
return events;
},
serializeDone(
streamContext: StreamTransformContext,
downstreamContext: ClaudeDownstreamContext,
): string[] {
const context = ensureContext(downstreamContext);
return buildDoneEvents(streamContext, context, 'stop');
},
pullSseEvents(buffer: string) {
return pullSseEventsWithDone(buffer);
},
consumeAnthropicSseEvent,
serializeUpstreamFinalAsStream(
payload: unknown,
modelName: string,
fallbackText: string,
normalizeFinal: (payload: unknown, modelName: string, fallbackText?: string) => NormalizedFinalResponse,
streamContext: StreamTransformContext,
downstreamContext: ClaudeDownstreamContext,
) {
return serializeAnthropicUpstreamFinalAsStream(
payload,
modelName,
fallbackText,
normalizeFinal,
streamContext,
downstreamContext,
);
},
};
@@ -0,0 +1,113 @@
import { createEmptyNormalizedUsage, mergeNormalizedUsage, type NormalizedUsage } from '../../shared/normalized.js';
type AnthropicRecord = Record<string, unknown>;
function isRecord(value: unknown): value is AnthropicRecord {
return !!value && typeof value === 'object' && !Array.isArray(value);
}
function toPositiveInt(value: unknown): number {
const numberValue = typeof value === 'number' ? value : Number(value);
if (!Number.isFinite(numberValue)) return 0;
return Math.max(0, Math.trunc(numberValue));
}
function resolveAnthropicUsageRecord(payload: unknown): AnthropicRecord | null {
if (isRecord(payload) && isRecord(payload.usage)) return payload.usage;
if (isRecord(payload)) return payload;
return null;
}
export type AnthropicUsageMetadata = {
cacheReadInputTokens: number;
cacheCreationInputTokens: number;
ephemeral5mInputTokens: number;
ephemeral1hInputTokens: number;
promptTokensIncludingCache: number;
};
export function extractAnthropicUsage(payload: unknown): NormalizedUsage {
const usageRecord = resolveAnthropicUsageRecord(payload);
if (!usageRecord) {
return createEmptyNormalizedUsage();
}
const inputTokens = toPositiveInt(usageRecord.input_tokens ?? usageRecord.inputTokens);
const outputTokens = toPositiveInt(usageRecord.output_tokens ?? usageRecord.outputTokens);
const cacheReadTokens = Math.max(
toPositiveInt(usageRecord.cache_read_input_tokens ?? usageRecord.cacheReadInputTokens),
toPositiveInt(usageRecord.cached_tokens ?? usageRecord.cachedTokens),
);
const cacheCreationTokens = Math.max(
toPositiveInt(usageRecord.cache_creation_input_tokens ?? usageRecord.cacheCreationInputTokens),
toPositiveInt((usageRecord.cache_creation as AnthropicRecord | undefined)?.ephemeral_5m_input_tokens)
+ toPositiveInt((usageRecord.cache_creation as AnthropicRecord | undefined)?.ephemeral_1h_input_tokens),
toPositiveInt((usageRecord.cacheCreation as AnthropicRecord | undefined)?.ephemeral5mInputTokens)
+ toPositiveInt((usageRecord.cacheCreation as AnthropicRecord | undefined)?.ephemeral1hInputTokens),
);
const promptTokens = inputTokens + cacheReadTokens + cacheCreationTokens;
const completionTokens = outputTokens;
const totalTokens = Math.max(
toPositiveInt(usageRecord.total_tokens ?? usageRecord.totalTokens),
promptTokens + completionTokens,
);
return {
promptTokens,
completionTokens,
totalTokens,
cachedTokens: cacheReadTokens,
reasoningTokens: 0,
audioInputTokens: 0,
audioOutputTokens: 0,
acceptedPredictionTokens: 0,
rejectedPredictionTokens: 0,
};
}
export function extractAnthropicUsageMetadata(payload: unknown): AnthropicUsageMetadata {
const usageRecord = resolveAnthropicUsageRecord(payload);
if (!usageRecord) {
return {
cacheReadInputTokens: 0,
cacheCreationInputTokens: 0,
ephemeral5mInputTokens: 0,
ephemeral1hInputTokens: 0,
promptTokensIncludingCache: 0,
};
}
const inputTokens = toPositiveInt(usageRecord.input_tokens ?? usageRecord.inputTokens);
const cacheReadInputTokens = Math.max(
toPositiveInt(usageRecord.cache_read_input_tokens ?? usageRecord.cacheReadInputTokens),
toPositiveInt(usageRecord.cached_tokens ?? usageRecord.cachedTokens),
);
const ephemeral5mInputTokens = Math.max(
toPositiveInt((usageRecord.cache_creation as AnthropicRecord | undefined)?.ephemeral_5m_input_tokens),
toPositiveInt((usageRecord.cacheCreation as AnthropicRecord | undefined)?.ephemeral5mInputTokens),
);
const ephemeral1hInputTokens = Math.max(
toPositiveInt((usageRecord.cache_creation as AnthropicRecord | undefined)?.ephemeral_1h_input_tokens),
toPositiveInt((usageRecord.cacheCreation as AnthropicRecord | undefined)?.ephemeral1hInputTokens),
);
const cacheCreationInputTokens = Math.max(
toPositiveInt(usageRecord.cache_creation_input_tokens ?? usageRecord.cacheCreationInputTokens),
ephemeral5mInputTokens + ephemeral1hInputTokens,
);
return {
cacheReadInputTokens,
cacheCreationInputTokens,
ephemeral5mInputTokens,
ephemeral1hInputTokens,
promptTokensIncludingCache: inputTokens + cacheReadInputTokens + cacheCreationInputTokens,
};
}
export const anthropicMessagesUsage = {
empty: createEmptyNormalizedUsage,
fromPayload: extractAnthropicUsage,
metadataFromPayload: extractAnthropicUsageMetadata,
merge: mergeNormalizedUsage,
};
@@ -0,0 +1,40 @@
import { describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs';
import path from 'node:path';
function readWorkspaceFile(relativePath: string): string {
return readFileSync(path.resolve(process.cwd(), relativePath), 'utf8');
}
describe('final transformer hard-cut architecture', () => {
it('keeps shared normalized helpers independent from route chatFormats', () => {
const sharedNormalized = readWorkspaceFile('src/server/transformers/shared/normalized.ts');
expect(sharedNormalized).not.toContain("../../routes/proxy/chatFormats.js");
expect(sharedNormalized).not.toContain("from '../../routes/proxy/chatFormats.js'");
});
it('removes normalizeContentText from upstream endpoint compatibility path', () => {
const upstreamEndpoint = readWorkspaceFile('src/server/routes/proxy/upstreamEndpoint.ts');
expect(upstreamEndpoint).not.toContain('function normalizeContentText(');
expect(upstreamEndpoint).not.toContain('normalizeContentText(');
});
it('keeps responses protocol shaping out of route-local helpers', () => {
const responsesRoute = readWorkspaceFile('src/server/routes/proxy/responses.ts');
expect(responsesRoute).not.toContain('function toResponsesPayload(');
expect(responsesRoute).not.toContain('function createResponsesStreamState(');
});
it('replaces gemini passthrough placeholders with protocol-aware helpers', () => {
const geminiInbound = readWorkspaceFile('src/server/transformers/gemini/generate-content/inbound.ts');
const geminiStream = readWorkspaceFile('src/server/transformers/gemini/generate-content/stream.ts');
const geminiAggregator = readWorkspaceFile('src/server/transformers/gemini/generate-content/aggregator.ts');
expect(geminiInbound).not.toContain('passthrough');
expect(geminiStream).not.toContain('passthrough');
expect(geminiAggregator).not.toContain('parts: unknown[]');
});
});
@@ -0,0 +1,182 @@
type GeminiRecord = Record<string, unknown>;
function isRecord(value: unknown): value is GeminiRecord {
return !!value && typeof value === 'object' && !Array.isArray(value);
}
function cloneJsonValue<T>(value: T): T {
if (Array.isArray(value)) {
return value.map((item) => cloneJsonValue(item)) as T;
}
if (isRecord(value)) {
return Object.fromEntries(
Object.entries(value).map(([key, item]) => [key, cloneJsonValue(item)]),
) as T;
}
return value;
}
function pushUniqueJson(target: GeminiRecord[], incoming: unknown): void {
if (!isRecord(incoming)) return;
const serialized = JSON.stringify(incoming);
if (target.some((item) => JSON.stringify(item) === serialized)) return;
target.push(cloneJsonValue(incoming));
}
function collectPartsFromPayload(payload: unknown): GeminiRecord[] {
if (Array.isArray(payload)) {
return payload.flatMap((item) => collectPartsFromPayload(item));
}
if (!isRecord(payload)) return [];
const candidates = Array.isArray(payload.candidates) ? payload.candidates : [];
const parts: GeminiRecord[] = [];
for (const candidate of candidates) {
if (!isRecord(candidate)) continue;
const content = isRecord(candidate.content) ? candidate.content : null;
if (!content || !Array.isArray(content.parts)) continue;
for (const part of content.parts) {
if (isRecord(part)) parts.push(cloneJsonValue(part));
}
}
return parts;
}
function updateIfDefined<T extends keyof GeminiGenerateContentUsageSummary>(
usage: GeminiGenerateContentUsageSummary,
key: T,
value: unknown,
): void {
if (typeof value === 'number' && Number.isFinite(value)) {
usage[key] = value;
}
}
export type GeminiGenerateContentUsageSummary = {
promptTokenCount?: number | null;
candidatesTokenCount?: number | null;
totalTokenCount?: number | null;
cachedContentTokenCount?: number | null;
thoughtsTokenCount?: number | null;
};
export type GeminiGenerateContentCandidateAggregate = {
index: number;
finishReason: string | null;
parts: GeminiRecord[];
groundingMetadata?: GeminiRecord;
citationMetadata?: GeminiRecord;
};
export type GeminiGenerateContentAggregateState = {
responseId: string | null;
modelVersion: string | null;
finishReason: string | null;
parts: GeminiRecord[];
citations: GeminiRecord[];
groundingMetadata: GeminiRecord[];
thoughtSignatures: string[];
usage: GeminiGenerateContentUsageSummary;
candidates: GeminiGenerateContentCandidateAggregate[];
};
export function createGeminiGenerateContentAggregateState(): GeminiGenerateContentAggregateState {
return {
responseId: null,
modelVersion: null,
finishReason: null,
parts: [],
citations: [],
groundingMetadata: [],
thoughtSignatures: [],
usage: {},
candidates: [],
};
}
function ensureCandidateAggregate(
state: GeminiGenerateContentAggregateState,
rawIndex: unknown,
): GeminiGenerateContentCandidateAggregate {
const normalizedIndex = typeof rawIndex === 'number' && Number.isFinite(rawIndex)
? Math.max(0, Math.trunc(rawIndex))
: 0;
let existing = state.candidates.find((candidate) => candidate.index === normalizedIndex);
if (!existing) {
existing = {
index: normalizedIndex,
finishReason: null,
parts: [],
};
state.candidates.push(existing);
state.candidates.sort((left, right) => left.index - right.index);
}
return existing;
}
export function applyGeminiGenerateContentAggregate(
state: GeminiGenerateContentAggregateState,
payload: unknown,
): GeminiGenerateContentAggregateState {
for (const part of collectPartsFromPayload(payload)) {
state.parts.push(part);
if (typeof part.thoughtSignature === 'string' && part.thoughtSignature.trim()) {
if (!state.thoughtSignatures.includes(part.thoughtSignature)) {
state.thoughtSignatures.push(part.thoughtSignature);
}
}
}
const payloads = Array.isArray(payload) ? payload : [payload];
for (const item of payloads) {
if (!isRecord(item)) continue;
if (typeof item.responseId === 'string' && item.responseId.trim()) {
state.responseId = item.responseId.trim();
}
if (typeof item.modelVersion === 'string' && item.modelVersion.trim()) {
state.modelVersion = item.modelVersion.trim();
}
const candidates = Array.isArray(item.candidates) ? item.candidates : [];
for (const candidate of candidates) {
if (!isRecord(candidate)) continue;
const candidateAggregate = ensureCandidateAggregate(state, candidate.index);
if (candidate.groundingMetadata !== undefined) {
pushUniqueJson(state.groundingMetadata, candidate.groundingMetadata);
if (isRecord(candidate.groundingMetadata)) {
candidateAggregate.groundingMetadata = cloneJsonValue(candidate.groundingMetadata);
}
}
if (candidate.citationMetadata !== undefined) {
pushUniqueJson(state.citations, candidate.citationMetadata);
if (isRecord(candidate.citationMetadata)) {
candidateAggregate.citationMetadata = cloneJsonValue(candidate.citationMetadata);
}
}
if (typeof candidate.finishReason === 'string' && candidate.finishReason.trim()) {
state.finishReason = candidate.finishReason.trim();
candidateAggregate.finishReason = candidate.finishReason.trim();
}
const content = isRecord(candidate.content) ? candidate.content : null;
if (content && Array.isArray(content.parts)) {
for (const part of content.parts) {
if (!isRecord(part)) continue;
candidateAggregate.parts.push(cloneJsonValue(part));
}
}
}
const usageMetadata = isRecord(item.usageMetadata) ? item.usageMetadata : null;
if (usageMetadata) {
updateIfDefined(state.usage, 'promptTokenCount', usageMetadata.promptTokenCount);
updateIfDefined(state.usage, 'candidatesTokenCount', usageMetadata.candidatesTokenCount);
updateIfDefined(state.usage, 'totalTokenCount', usageMetadata.totalTokenCount);
updateIfDefined(state.usage, 'cachedContentTokenCount', usageMetadata.cachedContentTokenCount);
updateIfDefined(state.usage, 'thoughtsTokenCount', usageMetadata.thoughtsTokenCount);
}
}
return state;
}
@@ -0,0 +1,165 @@
export type GeminiReasoningEffort = 'none' | 'low' | 'medium' | 'high' | 'max';
type GeminiThinkingConfig = {
thinkingBudget?: number;
thinkingLevel?: string;
};
function normalizeReasoningEffort(value: unknown): GeminiReasoningEffort {
const normalized = typeof value === 'string' ? value.trim().toLowerCase() : '';
if (
normalized === 'none'
|| normalized === 'low'
|| normalized === 'medium'
|| normalized === 'high'
|| normalized === 'max'
) {
return normalized;
}
return 'medium';
}
function normalizeThinkingLevel(value: unknown): GeminiReasoningEffort | null {
const normalized = typeof value === 'string' ? value.trim().toLowerCase() : '';
if (normalized === 'minimal') return 'low';
if (
normalized === 'none'
|| normalized === 'low'
|| normalized === 'medium'
|| normalized === 'high'
|| normalized === 'max'
) {
return normalized;
}
return null;
}
function thinkingBudgetToReasoningEffort(budget: number): GeminiReasoningEffort {
if (budget <= 0) return 'none';
if (budget <= 1024) return 'low';
if (budget <= 8192) return 'medium';
if (budget <= 32768) return 'high';
return 'max';
}
function shouldUseThinkingLevel(model: string, effort: GeminiReasoningEffort): boolean {
if (!/gemini-3-/i.test(model)) return false;
return effort === 'none' || effort === 'low' || effort === 'medium' || effort === 'high';
}
function reasoningEffortToThinkingBudget(effort: GeminiReasoningEffort): number {
switch (effort) {
case 'none':
return 0;
case 'low':
return 1024;
case 'high':
return 32768;
case 'max':
return 65536;
case 'medium':
default:
return 8192;
}
}
export function reasoningEffortToGeminiThinkingConfig(
model: string,
reasoningEffort: unknown,
): GeminiThinkingConfig {
const effort = normalizeReasoningEffort(reasoningEffort);
if (shouldUseThinkingLevel(model, effort)) {
return { thinkingLevel: effort };
}
return { thinkingBudget: reasoningEffortToThinkingBudget(effort) };
}
export function geminiThinkingConfigToReasoning(
thinkingConfig: Record<string, unknown> | null | undefined,
): { reasoningEffort: GeminiReasoningEffort; reasoningBudget: number } | null {
if (!thinkingConfig || typeof thinkingConfig !== 'object') return null;
const level = normalizeThinkingLevel(thinkingConfig.thinkingLevel);
if (level) {
return {
reasoningEffort: level,
reasoningBudget: reasoningEffortToThinkingBudget(level),
};
}
const budget = typeof thinkingConfig.thinkingBudget === 'number' && Number.isFinite(thinkingConfig.thinkingBudget)
? Math.max(0, Math.trunc(thinkingConfig.thinkingBudget))
: null;
if (budget === null) return null;
return {
reasoningEffort: thinkingBudgetToReasoningEffort(budget),
reasoningBudget: budget,
};
}
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value);
}
function toFiniteInt(value: unknown): number | null {
const numeric = typeof value === 'number' ? value : Number(value);
if (!Number.isFinite(numeric)) return null;
return Math.max(0, Math.trunc(numeric));
}
export function resolveGeminiThinkingConfigFromRequest(
model: string,
body: Record<string, unknown>,
): GeminiThinkingConfig | null {
const generationConfig = isRecord(body.generationConfig) ? body.generationConfig : null;
const nativeThinkingConfig = isRecord(generationConfig?.thinkingConfig)
? generationConfig!.thinkingConfig as Record<string, unknown>
: null;
if (nativeThinkingConfig) {
const nativeLevel = normalizeThinkingLevel(nativeThinkingConfig.thinkingLevel);
const nativeBudget = toFiniteInt(nativeThinkingConfig.thinkingBudget);
if (nativeLevel) {
return { thinkingLevel: nativeLevel };
}
if (nativeBudget !== null) {
return { thinkingBudget: nativeBudget };
}
}
const reasoning = isRecord(body.reasoning) ? body.reasoning : null;
const reasoningEffort = (
typeof body.reasoning_effort === 'string' ? body.reasoning_effort
: (typeof reasoning?.effort === 'string' ? reasoning.effort : undefined)
);
const reasoningBudget = (
body.reasoning_budget
?? reasoning?.budget_tokens
?? reasoning?.thinkingBudget
?? reasoning?.max_tokens
);
const parsedBudget = toFiniteInt(reasoningBudget);
if (parsedBudget !== null) {
return reasoningEffortToGeminiThinkingConfig(model, thinkingBudgetToReasoningEffort(parsedBudget));
}
if (reasoningEffort !== undefined) {
return reasoningEffortToGeminiThinkingConfig(model, reasoningEffort);
}
return null;
}
export function extractReasoningMetadataFromGeminiRequest(
body: Record<string, unknown> | null | undefined,
): { reasoningEffort: GeminiReasoningEffort; reasoningBudget: number } | null {
if (!body) return null;
const generationConfig = isRecord(body.generationConfig) ? body.generationConfig : null;
const thinkingConfig = isRecord(generationConfig?.thinkingConfig)
? generationConfig!.thinkingConfig as Record<string, unknown>
: null;
return geminiThinkingConfigToReasoning(thinkingConfig);
}
@@ -0,0 +1,119 @@
type GeminiRecord = Record<string, unknown>;
import { resolveGeminiThinkingConfigFromRequest } from './convert.js';
function isRecord(value: unknown): value is GeminiRecord {
return !!value && typeof value === 'object' && !Array.isArray(value);
}
function cloneJsonValue<T>(value: T): T {
if (Array.isArray(value)) {
return value.map((item) => cloneJsonValue(item)) as T;
}
if (isRecord(value)) {
return Object.fromEntries(
Object.entries(value).map(([key, item]) => [key, cloneJsonValue(item)]),
) as T;
}
return value;
}
function cloneContents(value: unknown): unknown[] | undefined {
if (!Array.isArray(value)) return undefined;
return value
.filter((item) => isRecord(item))
.map((item) => {
const next: GeminiRecord = { ...item };
if (Array.isArray(item.parts)) {
next.parts = item.parts.map((part) => (isRecord(part) ? cloneJsonValue(part) : part));
}
return next;
});
}
function cloneGenerationConfig(value: unknown): GeminiRecord | undefined {
if (!isRecord(value)) return undefined;
const allowedKeys = [
'stopSequences',
'responseModalities',
'responseMimeType',
'responseSchema',
'candidateCount',
'maxOutputTokens',
'temperature',
'topP',
'topK',
'presencePenalty',
'frequencyPenalty',
'seed',
'responseLogprobs',
'logprobs',
'thinkingConfig',
'imageConfig',
];
const next: GeminiRecord = {};
for (const key of allowedKeys) {
if (value[key] !== undefined) next[key] = cloneJsonValue(value[key]);
}
return Object.keys(next).length > 0 ? next : undefined;
}
function cloneTools(value: unknown): unknown[] | undefined {
if (!Array.isArray(value)) return undefined;
return value
.filter((item) => isRecord(item))
.map((item) => {
const next: GeminiRecord = {};
if (item.functionDeclarations !== undefined) next.functionDeclarations = cloneJsonValue(item.functionDeclarations);
if (item.googleSearch !== undefined) next.googleSearch = cloneJsonValue(item.googleSearch);
if (item.urlContext !== undefined) next.urlContext = cloneJsonValue(item.urlContext);
if (item.codeExecution !== undefined) next.codeExecution = cloneJsonValue(item.codeExecution);
return Object.keys(next).length > 0 ? next : cloneJsonValue(item);
});
}
export type GeminiGenerateContentRequest = GeminiRecord;
export const geminiGenerateContentInbound = {
normalizeRequest(body: unknown, modelName = ''): GeminiGenerateContentRequest {
if (!isRecord(body)) return {};
const next: GeminiGenerateContentRequest = {};
if (body.contents !== undefined) next.contents = cloneContents(body.contents) ?? cloneJsonValue(body.contents);
if (body.systemInstruction !== undefined) next.systemInstruction = cloneJsonValue(body.systemInstruction);
if (body.cachedContent !== undefined) next.cachedContent = cloneJsonValue(body.cachedContent);
if (body.safetySettings !== undefined) next.safetySettings = cloneJsonValue(body.safetySettings);
if (body.generationConfig !== undefined) {
next.generationConfig = cloneGenerationConfig(body.generationConfig) ?? cloneJsonValue(body.generationConfig);
}
if (body.tools !== undefined) next.tools = cloneTools(body.tools) ?? cloneJsonValue(body.tools);
if (body.toolConfig !== undefined) next.toolConfig = cloneJsonValue(body.toolConfig);
const derivedThinkingConfig = resolveGeminiThinkingConfigFromRequest(
modelName || (typeof body.model === 'string' ? body.model : ''),
body,
);
if (derivedThinkingConfig) {
const generationConfig = isRecord(next.generationConfig)
? { ...next.generationConfig }
: {};
if (!isRecord(generationConfig.thinkingConfig)) {
generationConfig.thinkingConfig = derivedThinkingConfig;
}
next.generationConfig = generationConfig;
}
const strippedKeys = new Set([
'reasoning',
'reasoning_effort',
'reasoning_budget',
]);
for (const [key, value] of Object.entries(body)) {
if (strippedKeys.has(key)) continue;
if (next[key] !== undefined) continue;
next[key] = cloneJsonValue(value);
}
return next;
},
};
@@ -0,0 +1,439 @@
import { describe, expect, it } from 'vitest';
import {
geminiGenerateContentTransformer,
reasoningEffortToGeminiThinkingConfig,
geminiThinkingConfigToReasoning,
} from './index.js';
import { extractGeminiUsage } from './usage.js';
import { serializeGeminiAggregateResponse, extractResponseMetadata } from './outbound.js';
describe('geminiGenerateContentTransformer.inbound', () => {
it('preserves native Gemini request fields through normalization', () => {
const body = geminiGenerateContentTransformer.inbound.normalizeRequest({
contents: [
{
role: 'user',
parts: [{ text: 'hello' }],
},
],
systemInstruction: {
role: 'system',
parts: [{ text: 'system prompt' }],
},
cachedContent: 'cached/abc',
generationConfig: {
responseModalities: ['TEXT'],
responseMimeType: 'application/json',
responseSchema: {
type: 'object',
properties: {
answer: { type: 'string' },
},
},
thinkingConfig: {
thinkingBudget: 512,
},
imageConfig: {
aspectRatio: '1:1',
},
},
tools: [
{
functionDeclarations: [
{
name: 'lookup',
description: 'find facts',
},
],
},
{ googleSearch: {} },
{ urlContext: {} },
{ codeExecution: {} },
],
});
expect(body).toEqual({
contents: [
{
role: 'user',
parts: [{ text: 'hello' }],
},
],
systemInstruction: {
role: 'system',
parts: [{ text: 'system prompt' }],
},
cachedContent: 'cached/abc',
generationConfig: {
responseModalities: ['TEXT'],
responseMimeType: 'application/json',
responseSchema: {
type: 'object',
properties: {
answer: { type: 'string' },
},
},
thinkingConfig: {
thinkingBudget: 512,
},
imageConfig: {
aspectRatio: '1:1',
},
},
tools: [
{
functionDeclarations: [
{
name: 'lookup',
description: 'find facts',
},
],
},
{ googleSearch: {} },
{ urlContext: {} },
{ codeExecution: {} },
],
});
});
});
describe('geminiGenerateContentTransformer.aggregator', () => {
it('collects grounding metadata, citations, thought signatures, and usage from streamed chunks', () => {
const state = geminiGenerateContentTransformer.aggregator.createState();
geminiGenerateContentTransformer.aggregator.apply(state, [
{
candidates: [
{
groundingMetadata: { webSearchQueries: ['cat'] },
citationMetadata: { citations: [{ uri: 'https://example.com' }] },
content: {
parts: [
{ text: 'hello', thoughtSignature: 'sig-1' },
],
},
},
],
usageMetadata: {
promptTokenCount: 10,
candidatesTokenCount: 4,
totalTokenCount: 14,
cachedContentTokenCount: 3,
thoughtsTokenCount: 2,
},
},
]);
expect(state.groundingMetadata).toEqual([{ webSearchQueries: ['cat'] }]);
expect(state.citations).toEqual([{ citations: [{ uri: 'https://example.com' }] }]);
expect(state.thoughtSignatures).toEqual(['sig-1']);
expect(state.usage).toEqual({
promptTokenCount: 10,
candidatesTokenCount: 4,
totalTokenCount: 14,
cachedContentTokenCount: 3,
thoughtsTokenCount: 2,
});
});
it('serializes aggregate state back into Gemini response semantics', () => {
const state = geminiGenerateContentTransformer.aggregator.createState();
geminiGenerateContentTransformer.aggregator.apply(state, {
responseId: 'resp-1',
modelVersion: 'gemini-2.5-pro',
candidates: [
{
groundingMetadata: { webSearchQueries: ['cat'] },
citationMetadata: { citations: [{ uri: 'https://example.com' }] },
content: {
parts: [
{ text: 'thinking', thought: true, thoughtSignature: 'sig-1' },
{ text: 'answer' },
],
},
finishReason: 'STOP',
},
],
usageMetadata: {
promptTokenCount: 10,
candidatesTokenCount: 4,
totalTokenCount: 14,
cachedContentTokenCount: 3,
thoughtsTokenCount: 2,
},
});
const response = serializeGeminiAggregateResponse(state);
expect(response).toEqual({
responseId: 'resp-1',
modelVersion: 'gemini-2.5-pro',
candidates: [
{
index: 0,
finishReason: 'STOP',
content: {
role: 'model',
parts: [
{ text: 'thinking', thought: true, thoughtSignature: 'sig-1' },
{ text: 'answer' },
],
},
groundingMetadata: { webSearchQueries: ['cat'] },
citationMetadata: { citations: [{ uri: 'https://example.com' }] },
},
],
usageMetadata: {
promptTokenCount: 10,
candidatesTokenCount: 4,
totalTokenCount: 14,
cachedContentTokenCount: 3,
thoughtsTokenCount: 2,
},
});
expect(extractResponseMetadata(state)).toEqual({
citations: [{ citations: [{ uri: 'https://example.com' }] }],
groundingMetadata: [{ webSearchQueries: ['cat'] }],
thoughtSignature: 'sig-1',
thoughtSignatures: ['sig-1'],
usageMetadata: {
promptTokenCount: 10,
candidatesTokenCount: 4,
totalTokenCount: 14,
cachedContentTokenCount: 3,
thoughtsTokenCount: 2,
},
});
});
it('preserves multiple candidate streams instead of collapsing them into one candidate', () => {
const state = geminiGenerateContentTransformer.aggregator.createState();
geminiGenerateContentTransformer.aggregator.apply(state, {
responseId: 'resp-multi',
modelVersion: 'gemini-2.5-pro',
candidates: [
{
index: 0,
content: {
parts: [{ text: 'candidate-a' }],
},
finishReason: 'STOP',
groundingMetadata: { webSearchQueries: ['a'] },
citationMetadata: { citations: [{ uri: 'https://a.example.com' }] },
},
{
index: 1,
content: {
parts: [{ text: 'candidate-b' }],
},
finishReason: 'MAX_TOKENS',
groundingMetadata: { webSearchQueries: ['b'] },
citationMetadata: { citations: [{ uri: 'https://b.example.com' }] },
},
],
});
expect(serializeGeminiAggregateResponse(state)).toEqual({
responseId: 'resp-multi',
modelVersion: 'gemini-2.5-pro',
candidates: [
{
index: 0,
finishReason: 'STOP',
content: {
role: 'model',
parts: [{ text: 'candidate-a' }],
},
groundingMetadata: { webSearchQueries: ['a'] },
citationMetadata: { citations: [{ uri: 'https://a.example.com' }] },
},
{
index: 1,
finishReason: 'MAX_TOKENS',
content: {
role: 'model',
parts: [{ text: 'candidate-b' }],
},
groundingMetadata: { webSearchQueries: ['b'] },
citationMetadata: { citations: [{ uri: 'https://b.example.com' }] },
},
],
});
});
it('merges preserved request semantics into extracted metadata when provided', () => {
const metadata = extractResponseMetadata(
{
candidates: [
{
groundingMetadata: { webSearchQueries: ['cat'] },
citationMetadata: { citations: [{ uri: 'https://example.com' }] },
content: {
parts: [{ text: 'answer', thoughtSignature: 'sig-1' }],
},
},
],
},
{
systemInstruction: { role: 'system', parts: [{ text: 'system prompt' }] },
cachedContent: 'cached/abc',
generationConfig: {
responseModalities: ['TEXT'],
responseSchema: { type: 'object' },
responseMimeType: 'application/json',
},
tools: [
{ googleSearch: {} },
{ urlContext: {} },
{ codeExecution: {} },
],
},
);
expect(metadata.systemInstruction).toEqual({ role: 'system', parts: [{ text: 'system prompt' }] });
expect(metadata.cachedContent).toBe('cached/abc');
expect(metadata.responseModalities).toEqual(['TEXT']);
expect(metadata.responseSchema).toEqual({ type: 'object' });
expect(metadata.responseMimeType).toBe('application/json');
expect(metadata.tools).toEqual([
{ googleSearch: {} },
{ urlContext: {} },
{ codeExecution: {} },
]);
});
});
describe('extractGeminiUsage', () => {
it('maps cached and thought token counts into normalized usage', () => {
expect(extractGeminiUsage({
usageMetadata: {
promptTokenCount: 11,
candidatesTokenCount: 7,
totalTokenCount: 18,
cachedContentTokenCount: 5,
thoughtsTokenCount: 3,
},
})).toEqual({
promptTokens: 11,
completionTokens: 10,
totalTokens: 18,
cachedTokens: 5,
reasoningTokens: 3,
});
});
});
describe('Gemini reasoning mapping', () => {
it('supports explicit none reasoning effort and maps it back from thinking config', () => {
expect(reasoningEffortToGeminiThinkingConfig('gemini-3-5m', 'none')).toEqual({
thinkingLevel: 'none',
});
expect(geminiThinkingConfigToReasoning({
thinkingLevel: 'none',
})).toEqual({
reasoningEffort: 'none',
reasoningBudget: 0,
});
});
it('uses thinkingLevel for Gemini 3 models when reasoning effort is provided', () => {
expect(reasoningEffortToGeminiThinkingConfig('gemini-3-5m', 'high')).toEqual({
thinkingLevel: 'high',
});
});
it('uses thinkingBudget for non-Gemini 3 models when reasoning effort is provided', () => {
expect(reasoningEffortToGeminiThinkingConfig('gemini-2.5-flash', 'medium')).toEqual({
thinkingBudget: 8192,
});
});
it('maps Gemini thinking config back to normalized reasoning hints', () => {
expect(geminiThinkingConfigToReasoning({
thinkingLevel: 'medium',
})).toEqual({
reasoningEffort: 'medium',
reasoningBudget: 8192,
});
expect(geminiThinkingConfigToReasoning({
thinkingBudget: 512,
})).toEqual({
reasoningEffort: 'low',
reasoningBudget: 512,
});
});
});
describe('geminiGenerateContentTransformer.stream', () => {
it('aggregates SSE payloads and JSON-array payloads to the same final semantics', () => {
const chunks = [
{
responseId: 'resp-1',
modelVersion: 'gemini-2.5-pro',
candidates: [
{
groundingMetadata: { webSearchQueries: ['cat'] },
citationMetadata: { citations: [{ uri: 'https://example.com' }] },
content: {
parts: [
{ text: 'thinking', thought: true, thoughtSignature: 'sig-1' },
],
},
finishReason: '',
},
],
},
{
responseId: 'resp-1',
modelVersion: 'gemini-2.5-pro',
candidates: [
{
groundingMetadata: { webSearchQueries: ['cat'] },
citationMetadata: { citations: [{ uri: 'https://example.com' }] },
content: {
parts: [
{ text: 'answer' },
],
},
finishReason: 'STOP',
},
],
usageMetadata: {
promptTokenCount: 10,
candidatesTokenCount: 4,
totalTokenCount: 14,
cachedContentTokenCount: 3,
thoughtsTokenCount: 2,
},
},
];
const ssePayload = chunks
.map((chunk) => `data: ${JSON.stringify(chunk)}\n\n`)
.join('');
const sseParsed = geminiGenerateContentTransformer.stream.parseSsePayloads(ssePayload);
const sseState = geminiGenerateContentTransformer.aggregator.createState();
for (const payload of sseParsed.events) {
geminiGenerateContentTransformer.aggregator.apply(sseState, payload);
}
const jsonState = geminiGenerateContentTransformer.aggregator.createState();
for (const payload of geminiGenerateContentTransformer.stream.parseJsonArrayPayload(chunks)) {
geminiGenerateContentTransformer.aggregator.apply(jsonState, payload);
}
expect(serializeGeminiAggregateResponse(sseState)).toEqual(
serializeGeminiAggregateResponse(jsonState),
);
expect(extractResponseMetadata(sseState)).toEqual(
extractResponseMetadata(jsonState),
);
expect(extractGeminiUsage(sseState)).toEqual(
extractGeminiUsage(jsonState),
);
});
});
@@ -0,0 +1,75 @@
function normalizeBaseUrl(baseUrl: string): string {
return (baseUrl || '').replace(/\/+$/, '');
}
function baseIncludesVersion(baseUrl: string): boolean {
return /\/v\d+(?:beta)?(?:\/|$)/i.test(baseUrl);
}
export function resolveGeminiNativeBaseUrl(baseUrl: string, apiVersion: string): string {
const normalized = normalizeBaseUrl(baseUrl);
if (baseIncludesVersion(normalized)) return normalized;
return `${normalized}/${apiVersion}`;
}
export function resolveGeminiModelsUrl(
baseUrl: string,
apiVersion: string,
apiKey: string,
): string {
const base = resolveGeminiNativeBaseUrl(baseUrl, apiVersion);
const separator = base.includes('?') ? '&' : '?';
return `${base}/models${separator}key=${encodeURIComponent(apiKey)}`;
}
export function resolveGeminiGenerateContentUrl(
baseUrl: string,
apiVersion: string,
modelActionPath: string,
apiKey: string,
search: string,
): string {
const base = resolveGeminiNativeBaseUrl(baseUrl, apiVersion);
const normalizedAction = modelActionPath.replace(/^\/+/, '');
const params = new URLSearchParams(search.startsWith('?') ? search.slice(1) : search);
params.set('key', apiKey);
const query = params.toString();
return `${base}/${normalizedAction}${query ? `?${query}` : ''}`;
}
import { geminiGenerateContentInbound } from './inbound.js';
import { geminiGenerateContentOutbound } from './outbound.js';
import { geminiGenerateContentStream } from './stream.js';
import { createGeminiGenerateContentAggregateState, applyGeminiGenerateContentAggregate } from './aggregator.js';
import { geminiGenerateContentUsage } from './usage.js';
import { reasoningEffortToGeminiThinkingConfig, geminiThinkingConfigToReasoning } from './convert.js';
export const geminiGenerateContentTransformer = {
protocol: 'gemini/generate-content' as const,
inbound: geminiGenerateContentInbound,
outbound: geminiGenerateContentOutbound,
stream: geminiGenerateContentStream,
aggregator: {
createState: createGeminiGenerateContentAggregateState,
apply: applyGeminiGenerateContentAggregate,
},
usage: geminiGenerateContentUsage,
convert: {
reasoningEffortToGeminiThinkingConfig,
geminiThinkingConfigToReasoning,
},
resolveBaseUrl: resolveGeminiNativeBaseUrl,
resolveModelsUrl: resolveGeminiModelsUrl,
resolveActionUrl: resolveGeminiGenerateContentUrl,
};
export {
geminiGenerateContentInbound,
geminiGenerateContentOutbound,
geminiGenerateContentStream,
createGeminiGenerateContentAggregateState,
applyGeminiGenerateContentAggregate,
geminiGenerateContentUsage,
reasoningEffortToGeminiThinkingConfig,
geminiThinkingConfigToReasoning,
};
@@ -0,0 +1,190 @@
import {
applyGeminiGenerateContentAggregate,
createGeminiGenerateContentAggregateState,
type GeminiGenerateContentAggregateState,
} from './aggregator.js';
function normalizeBaseUrl(baseUrl: string): string {
return (baseUrl || '').replace(/\/+$/, '');
}
function baseIncludesVersion(baseUrl: string): boolean {
return /\/v\d+(?:beta)?(?:\/|$)/i.test(baseUrl);
}
function resolveBaseUrl(baseUrl: string, apiVersion: string): string {
const normalized = normalizeBaseUrl(baseUrl);
if (baseIncludesVersion(normalized)) return normalized;
return `${normalized}/${apiVersion}`;
}
function resolveModelsUrl(
baseUrl: string,
apiVersion: string,
apiKey: string,
): string {
const resolvedBaseUrl = resolveBaseUrl(baseUrl, apiVersion);
const separator = resolvedBaseUrl.includes('?') ? '&' : '?';
return `${resolvedBaseUrl}/models${separator}key=${encodeURIComponent(apiKey)}`;
}
function resolveActionUrl(
baseUrl: string,
apiVersion: string,
modelActionPath: string,
apiKey: string,
search: string,
): string {
const resolvedBaseUrl = resolveBaseUrl(baseUrl, apiVersion);
const normalizedAction = modelActionPath.replace(/^\/+/, '');
const params = new URLSearchParams(search.startsWith('?') ? search.slice(1) : search);
params.set('key', apiKey);
const query = params.toString();
return `${resolvedBaseUrl}/${normalizedAction}${query ? `?${query}` : ''}`;
}
type GeminiRecord = Record<string, unknown>;
function isRecord(value: unknown): value is GeminiRecord {
return !!value && typeof value === 'object' && !Array.isArray(value);
}
function isAggregateState(value: unknown): value is GeminiGenerateContentAggregateState {
return isRecord(value) && Array.isArray(value.parts) && Array.isArray(value.groundingMetadata) && Array.isArray(value.citations);
}
function ensureAggregateState(payload: unknown): GeminiGenerateContentAggregateState {
if (isAggregateState(payload)) return payload;
const state = createGeminiGenerateContentAggregateState();
const chunks = Array.isArray(payload) ? payload : [payload];
for (const chunk of chunks) {
applyGeminiGenerateContentAggregate(state, chunk);
}
return state;
}
function buildUsageMetadata(state: GeminiGenerateContentAggregateState): GeminiRecord | undefined {
const usage = state.usage;
const next: GeminiRecord = {};
if (typeof usage.promptTokenCount === 'number') next.promptTokenCount = usage.promptTokenCount;
if (typeof usage.candidatesTokenCount === 'number') next.candidatesTokenCount = usage.candidatesTokenCount;
if (typeof usage.totalTokenCount === 'number') next.totalTokenCount = usage.totalTokenCount;
if (typeof usage.cachedContentTokenCount === 'number') next.cachedContentTokenCount = usage.cachedContentTokenCount;
if (typeof usage.thoughtsTokenCount === 'number') next.thoughtsTokenCount = usage.thoughtsTokenCount;
return Object.keys(next).length > 0 ? next : undefined;
}
function buildCandidates(state: GeminiGenerateContentAggregateState): GeminiRecord[] {
if (state.candidates.length > 0) {
return state.candidates
.slice()
.sort((left, right) => left.index - right.index)
.map((candidate) => {
const next: GeminiRecord = {
index: candidate.index,
finishReason: candidate.finishReason || 'STOP',
content: {
role: 'model',
parts: candidate.parts,
},
};
if (candidate.groundingMetadata) next.groundingMetadata = candidate.groundingMetadata;
if (candidate.citationMetadata) next.citationMetadata = candidate.citationMetadata;
return next;
});
}
const fallback: GeminiRecord = {
index: 0,
finishReason: state.finishReason || 'STOP',
content: {
role: 'model',
parts: state.parts,
},
};
if (state.groundingMetadata.length > 0) {
fallback.groundingMetadata = state.groundingMetadata[0];
}
if (state.citations.length > 0) {
fallback.citationMetadata = state.citations[0];
}
return [fallback];
}
function extractRequestSemantics(requestPayload: unknown): GeminiRecord {
if (!isRecord(requestPayload)) return {};
const metadata: GeminiRecord = {};
if (requestPayload.systemInstruction !== undefined) metadata.systemInstruction = requestPayload.systemInstruction;
if (requestPayload.cachedContent !== undefined) metadata.cachedContent = requestPayload.cachedContent;
const generationConfig = isRecord(requestPayload.generationConfig) ? requestPayload.generationConfig : null;
if (generationConfig) {
if (generationConfig.responseModalities !== undefined) metadata.responseModalities = generationConfig.responseModalities;
if (generationConfig.responseSchema !== undefined) metadata.responseSchema = generationConfig.responseSchema;
if (generationConfig.responseMimeType !== undefined) metadata.responseMimeType = generationConfig.responseMimeType;
}
if (Array.isArray(requestPayload.tools)) {
const requestTools = requestPayload.tools
.filter((item) => isRecord(item))
.map((item) => {
const next: GeminiRecord = {};
if (item.googleSearch !== undefined) next.googleSearch = item.googleSearch;
if (item.urlContext !== undefined) next.urlContext = item.urlContext;
if (item.codeExecution !== undefined) next.codeExecution = item.codeExecution;
if (item.functionDeclarations !== undefined) next.functionDeclarations = item.functionDeclarations;
return next;
})
.filter((item) => Object.keys(item).length > 0);
if (requestTools.length > 0) {
metadata.tools = requestTools;
}
}
return metadata;
}
export function serializeGeminiAggregateResponse(
payload: GeminiGenerateContentAggregateState | unknown,
): GeminiRecord {
const state = ensureAggregateState(payload);
const response: GeminiRecord = {
responseId: state.responseId || '',
modelVersion: state.modelVersion || '',
candidates: buildCandidates(state),
};
const usageMetadata = buildUsageMetadata(state);
if (usageMetadata) {
response.usageMetadata = usageMetadata;
}
return response;
}
export function extractResponseMetadata(payload: unknown, requestPayload?: unknown): GeminiRecord {
const state = ensureAggregateState(payload);
const metadata: GeminiRecord = extractRequestSemantics(requestPayload);
if (state.citations.length > 0) metadata.citations = state.citations;
if (state.groundingMetadata.length > 0) metadata.groundingMetadata = state.groundingMetadata;
if (state.thoughtSignatures.length > 0) {
metadata.thoughtSignature = state.thoughtSignatures[0];
metadata.thoughtSignatures = state.thoughtSignatures;
}
const usageMetadata = buildUsageMetadata(state);
if (usageMetadata) metadata.usageMetadata = usageMetadata;
return metadata;
}
export const geminiGenerateContentOutbound = {
resolveBaseUrl,
resolveModelsUrl,
resolveActionUrl,
extractResponseMetadata,
serializeAggregateResponse: serializeGeminiAggregateResponse,
};
@@ -0,0 +1,67 @@
import {
applyGeminiGenerateContentAggregate,
createGeminiGenerateContentAggregateState,
type GeminiGenerateContentAggregateState,
} from './aggregator.js';
type ParsedSsePayloads = {
events: unknown[];
rest: string;
};
function serializeSsePayload(payload: unknown): string {
return `data: ${JSON.stringify(payload)}\n\n`;
}
function parseSsePayloads(buffer: string): ParsedSsePayloads {
const normalized = buffer.replace(/\r\n/g, '\n');
const events: unknown[] = [];
let rest = normalized;
while (true) {
const boundary = rest.indexOf('\n\n');
if (boundary < 0) break;
const block = rest.slice(0, boundary);
rest = rest.slice(boundary + 2);
if (!block.trim()) continue;
const data = block
.split('\n')
.filter((line) => line.startsWith('data:'))
.map((line) => line.slice(5).trimStart())
.join('\n')
.trim();
if (!data || data === '[DONE]') continue;
try {
events.push(JSON.parse(data));
} catch {
// Ignore malformed event payloads so aggregation remains tolerant.
}
}
return { events, rest };
}
export const geminiGenerateContentStream = {
isSseContentType(contentType: string | null | undefined): boolean {
return (contentType || '').toLowerCase().includes('text/event-stream');
},
parseJsonArrayPayload(payload: unknown): unknown[] {
return Array.isArray(payload) ? payload : [];
},
parseSsePayloads,
serializeSsePayload,
createAggregateState(): GeminiGenerateContentAggregateState {
return createGeminiGenerateContentAggregateState();
},
applyAggregate(state: GeminiGenerateContentAggregateState, payload: unknown): GeminiGenerateContentAggregateState {
return applyGeminiGenerateContentAggregate(state, payload);
},
};
@@ -0,0 +1,54 @@
import { createEmptyNormalizedUsage, mergeNormalizedUsage, type NormalizedUsage } from '../../shared/normalized.js';
import { type GeminiGenerateContentAggregateState } from './aggregator.js';
type GeminiRecord = Record<string, unknown>;
function isRecord(value: unknown): value is GeminiRecord {
return !!value && typeof value === 'object' && !Array.isArray(value);
}
function isAggregateState(value: unknown): value is GeminiGenerateContentAggregateState {
return isRecord(value) && Array.isArray(value.parts) && isRecord(value.usage);
}
export function extractGeminiUsage(payload: unknown): NormalizedUsage {
const usageMetadata = isAggregateState(payload)
? payload.usage
: (isRecord(payload) && isRecord(payload.usageMetadata) ? payload.usageMetadata : null);
if (!usageMetadata) {
return createEmptyNormalizedUsage();
}
const promptTokens = typeof usageMetadata.promptTokenCount === 'number'
? usageMetadata.promptTokenCount
: 0;
const candidatesTokenCount = typeof usageMetadata.candidatesTokenCount === 'number'
? usageMetadata.candidatesTokenCount
: 0;
const thoughtsTokenCount = typeof usageMetadata.thoughtsTokenCount === 'number'
? usageMetadata.thoughtsTokenCount
: 0;
const completionTokens = candidatesTokenCount + thoughtsTokenCount;
const totalTokens = typeof usageMetadata.totalTokenCount === 'number'
? usageMetadata.totalTokenCount
: promptTokens + completionTokens;
const cachedTokens = typeof usageMetadata.cachedContentTokenCount === 'number'
? usageMetadata.cachedContentTokenCount
: 0;
const reasoningTokens = thoughtsTokenCount;
return {
promptTokens,
completionTokens,
totalTokens,
cachedTokens,
reasoningTokens,
};
}
export const geminiGenerateContentUsage = {
empty: createEmptyNormalizedUsage,
fromPayload: extractGeminiUsage,
merge: mergeNormalizedUsage,
};
@@ -0,0 +1,121 @@
import { type NormalizedFinalResponse, type NormalizedStreamEvent } from '../../shared/normalized.js';
import type {
OpenAiChatNormalizedFinalResponse,
OpenAiChatNormalizedStreamEvent,
OpenAiChatUsageDetails,
} from './model.js';
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object';
}
function pushUniqueAnnotation(
annotations: Array<Record<string, unknown>>,
seenUrls: Set<string>,
candidate: unknown,
) {
if (!isRecord(candidate)) return;
const citation = isRecord(candidate.url_citation) ? candidate.url_citation : null;
const url = citation && typeof citation.url === 'string' ? citation.url.trim() : '';
if (!url || seenUrls.has(url)) return;
seenUrls.add(url);
annotations.push(candidate);
}
function mergeUsageDetails(
target: OpenAiChatUsageDetails | undefined,
next: OpenAiChatUsageDetails | undefined,
): OpenAiChatUsageDetails | undefined {
if (!target && !next) return undefined;
return {
...(target || {}),
...(next || {}),
};
}
export type OpenAiChatAggregateState = {
content: string[];
reasoning: string[];
toolCalls: Array<{ id: string; name: string; arguments: string }>;
finishReason: string | null;
annotations: Array<Record<string, unknown>>;
annotationUrls: Set<string>;
citations: Set<string>;
usageDetails?: OpenAiChatUsageDetails;
};
export function createOpenAiChatAggregateState(): OpenAiChatAggregateState {
return {
content: [],
reasoning: [],
toolCalls: [],
finishReason: null,
annotations: [],
annotationUrls: new Set<string>(),
citations: new Set<string>(),
usageDetails: undefined,
};
}
export function applyOpenAiChatStreamEvent(
state: OpenAiChatAggregateState,
event: OpenAiChatNormalizedStreamEvent,
): OpenAiChatAggregateState {
if (event.contentDelta) state.content.push(event.contentDelta);
if (event.reasoningDelta) state.reasoning.push(event.reasoningDelta);
if (event.finishReason !== undefined) state.finishReason = event.finishReason ?? null;
if (Array.isArray(event.toolCallDeltas)) {
for (const delta of event.toolCallDeltas) {
if (!delta.id && !delta.name && !delta.argumentsDelta) continue;
const index = Number.isFinite(delta.index) ? Math.max(0, Math.trunc(delta.index)) : state.toolCalls.length;
while (state.toolCalls.length <= index) {
state.toolCalls.push({ id: '', name: '', arguments: '' });
}
const existing = state.toolCalls[index];
if (delta.id) existing.id = delta.id;
if (delta.name) existing.name = delta.name;
if (delta.argumentsDelta) existing.arguments += delta.argumentsDelta;
}
}
if (Array.isArray(event.annotations)) {
for (const annotation of event.annotations) {
pushUniqueAnnotation(state.annotations, state.annotationUrls, annotation);
}
}
if (Array.isArray(event.citations)) {
for (const citation of event.citations) {
if (typeof citation === 'string' && citation.trim()) {
state.citations.add(citation.trim());
}
}
}
state.usageDetails = mergeUsageDetails(state.usageDetails, event.usageDetails);
return state;
}
export function finalizeOpenAiChatAggregate(
state: OpenAiChatAggregateState,
normalized: OpenAiChatNormalizedFinalResponse,
): OpenAiChatNormalizedFinalResponse {
const mergedToolCalls = state.toolCalls.filter((item) => item.id || item.name || item.arguments);
if (
state.content.length === 0
&& state.reasoning.length === 0
&& mergedToolCalls.length === 0
&& state.annotations.length === 0
&& state.citations.size === 0
&& !state.usageDetails
) {
return normalized;
}
return {
...normalized,
content: state.content.join('') || normalized.content,
reasoningContent: state.reasoning.join('') || normalized.reasoningContent,
finishReason: state.finishReason ?? normalized.finishReason,
toolCalls: mergedToolCalls.length > 0 ? mergedToolCalls : normalized.toolCalls,
annotations: state.annotations.length > 0 ? state.annotations : normalized.annotations,
citations: state.citations.size > 0 ? Array.from(state.citations).sort() : normalized.citations,
usageDetails: mergeUsageDetails(normalized.usageDetails, state.usageDetails),
};
}
@@ -0,0 +1,238 @@
import type {
OpenAiChatNormalizedFinalResponse,
OpenAiChatNormalizedStreamEvent,
OpenAiChatRequestMetadata,
OpenAiChatUsageDetails,
} from './model.js';
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value);
}
function asRecord(value: unknown): Record<string, unknown> | null {
return isRecord(value) ? value : null;
}
function toStringArray(value: unknown): string[] {
if (!Array.isArray(value)) return [];
return value
.map((item) => {
if (typeof item === 'string') return item.trim();
if (isRecord(item)) {
const direct = typeof item.url === 'string'
? item.url
: typeof item.uri === 'string'
? item.uri
: '';
return direct.trim();
}
return '';
})
.filter((item) => item.length > 0);
}
function toNumericRecord(value: unknown): Record<string, number> | undefined {
if (!isRecord(value)) return undefined;
const entries = Object.entries(value)
.map(([key, raw]) => {
const numeric = typeof raw === 'number' ? raw : Number(raw);
if (!Number.isFinite(numeric)) return null;
return [key, numeric] as const;
})
.filter((entry): entry is readonly [string, number] => !!entry);
if (entries.length <= 0) return undefined;
return Object.fromEntries(entries);
}
function stableKey(value: unknown): string {
if (Array.isArray(value)) {
return `[${value.map((item) => stableKey(item)).join(',')}]`;
}
if (isRecord(value)) {
const entries = Object.entries(value)
.sort(([left], [right]) => left.localeCompare(right))
.map(([key, nested]) => `${JSON.stringify(key)}:${stableKey(nested)}`);
return `{${entries.join(',')}}`;
}
return JSON.stringify(value);
}
function annotationUrl(annotation: Record<string, unknown>): string {
const urlCitation = asRecord(annotation.url_citation) ?? asRecord(annotation.urlCitation);
const direct = typeof annotation.url === 'string' ? annotation.url : '';
const nested = urlCitation && typeof urlCitation.url === 'string' ? urlCitation.url : '';
return (direct || nested).trim();
}
export function dedupeAnnotations(input: unknown): Array<Record<string, unknown>> {
if (!Array.isArray(input)) return [];
const seen = new Set<string>();
const result: Array<Record<string, unknown>> = [];
for (const item of input) {
if (!isRecord(item)) continue;
const key = annotationUrl(item) || stableKey(item);
if (!key || seen.has(key)) continue;
seen.add(key);
result.push(item);
}
return result;
}
export function dedupeCitations(input: unknown): string[] {
const citations = new Set<string>();
for (const citation of toStringArray(input)) {
citations.add(citation);
}
if (Array.isArray(input)) {
for (const item of input) {
if (!isRecord(item)) continue;
const nestedUrl = annotationUrl(item);
if (nestedUrl) citations.add(nestedUrl);
}
}
return Array.from(citations);
}
function extractAnnotationsFromPayload(payload: Record<string, unknown>): Array<Record<string, unknown>> {
const collected: Array<Record<string, unknown>> = [];
const append = (value: unknown) => {
for (const item of dedupeAnnotations(value)) {
collected.push(item);
}
};
append(payload.annotations);
const choices = Array.isArray(payload.choices) ? payload.choices : [];
for (const choice of choices) {
const choiceRecord = asRecord(choice);
if (!choiceRecord) continue;
append(asRecord(choiceRecord.message)?.annotations);
append(asRecord(choiceRecord.delta)?.annotations);
}
return dedupeAnnotations(collected);
}
function extractCitationsFromPayload(
payload: Record<string, unknown>,
annotations: Array<Record<string, unknown>>,
): string[] {
const citations = new Set<string>();
for (const citation of dedupeCitations(payload.citations)) {
citations.add(citation);
}
const transformerMetadata = asRecord(payload.transformer_metadata) ?? asRecord(payload.transformerMetadata);
if (transformerMetadata) {
for (const citation of dedupeCitations(transformerMetadata.citations)) {
citations.add(citation);
}
}
for (const annotation of annotations) {
const url = annotationUrl(annotation);
if (url) citations.add(url);
}
return Array.from(citations);
}
export function extractChatUsageDetails(payload: Record<string, unknown>): OpenAiChatUsageDetails | undefined {
const usage = asRecord(payload.usage);
if (!usage) return undefined;
const promptDetails = toNumericRecord(usage.prompt_tokens_details ?? usage.promptTokensDetails);
const completionDetails = toNumericRecord(usage.completion_tokens_details ?? usage.completionTokensDetails);
if (!promptDetails && !completionDetails) return undefined;
return {
...(promptDetails ? { prompt_tokens_details: promptDetails } : {}),
...(completionDetails ? { completion_tokens_details: completionDetails } : {}),
};
}
export function mergeChatUsageDetails(
base: OpenAiChatUsageDetails | undefined,
next: OpenAiChatUsageDetails | undefined,
): OpenAiChatUsageDetails | undefined {
if (!base && !next) return undefined;
const mergeNumericMaps = (
left: Record<string, number> | undefined,
right: Record<string, number> | undefined,
): Record<string, number> | undefined => {
if (!left && !right) return undefined;
return Object.fromEntries(
Array.from(new Set([...Object.keys(left || {}), ...Object.keys(right || {})]))
.map((key) => [key, Math.max(left?.[key] || 0, right?.[key] || 0)]),
);
};
return {
...(mergeNumericMaps(base?.prompt_tokens_details, next?.prompt_tokens_details)
? { prompt_tokens_details: mergeNumericMaps(base?.prompt_tokens_details, next?.prompt_tokens_details)! }
: {}),
...(mergeNumericMaps(base?.completion_tokens_details, next?.completion_tokens_details)
? { completion_tokens_details: mergeNumericMaps(base?.completion_tokens_details, next?.completion_tokens_details)! }
: {}),
};
}
export function extractUsagePayload(payload: Record<string, unknown>): Record<string, unknown> | undefined {
const usage = asRecord(payload.usage);
if (!usage) return undefined;
return usage;
}
export function extractChatResponseExtras(payload: unknown): Pick<
OpenAiChatNormalizedFinalResponse,
'annotations' | 'citations' | 'usageDetails' | 'usagePayload'
> & Pick<OpenAiChatNormalizedStreamEvent, 'annotations' | 'citations' | 'usageDetails' | 'usagePayload'> {
const record = asRecord(payload);
if (!record) return {};
const annotations = extractAnnotationsFromPayload(record);
const citations = extractCitationsFromPayload(record, annotations);
const usageDetails = extractChatUsageDetails(record);
const usagePayload = extractUsagePayload(record);
return {
...(annotations.length > 0 ? { annotations } : {}),
...(citations.length > 0 ? { citations } : {}),
...(usageDetails ? { usageDetails } : {}),
...(usagePayload ? { usagePayload } : {}),
};
}
export function extractChatRequestMetadata(body: unknown): OpenAiChatRequestMetadata | undefined {
const raw = asRecord(body);
if (!raw) return undefined;
const streamOptions = asRecord(raw.stream_options) ?? asRecord(raw.streamOptions);
const metadata: OpenAiChatRequestMetadata = {
...(raw.modalities !== undefined ? { modalities: raw.modalities } : {}),
...(raw.audio !== undefined ? { audio: raw.audio } : {}),
...(raw.reasoning_effort !== undefined ? { reasoningEffort: raw.reasoning_effort } : {}),
...(raw.reasoning_budget !== undefined ? { reasoningBudget: raw.reasoning_budget } : {}),
...(raw.reasoning_summary !== undefined ? { reasoningSummary: raw.reasoning_summary } : {}),
...(raw.service_tier !== undefined ? { serviceTier: raw.service_tier } : {}),
...(raw.top_logprobs !== undefined ? { topLogprobs: raw.top_logprobs } : {}),
...(raw.logit_bias !== undefined ? { logitBias: raw.logit_bias } : {}),
...(raw.prompt_cache_key !== undefined ? { promptCacheKey: raw.prompt_cache_key } : {}),
...(raw.safety_identifier !== undefined ? { safetyIdentifier: raw.safety_identifier } : {}),
...(raw.verbosity !== undefined ? { verbosity: raw.verbosity } : {}),
...(raw.response_format !== undefined ? { responseFormat: raw.response_format } : {}),
...(streamOptions && streamOptions.include_usage !== undefined
? { streamOptionsIncludeUsage: Boolean(streamOptions.include_usage) }
: {}),
};
return Object.keys(metadata).length > 0 ? metadata : undefined;
}
@@ -0,0 +1,19 @@
import { parseDownstreamChatRequest } from '../../shared/normalized.js';
import { extractChatRequestMetadata } from './helpers.js';
import type { OpenAiChatParsedRequest } from './model.js';
export const openAiChatInbound = {
parse(body: unknown): { value?: OpenAiChatParsedRequest; error?: { statusCode: number; payload: unknown } } {
const parsed = parseDownstreamChatRequest(body, 'openai') as {
value?: OpenAiChatParsedRequest;
error?: { statusCode: number; payload: unknown };
};
if (!parsed.value) return parsed;
parsed.value = {
...parsed.value,
requestMetadata: extractChatRequestMetadata(body),
};
return parsed;
},
};
@@ -0,0 +1,234 @@
import { describe, expect, it } from 'vitest';
import { createClaudeDownstreamContext } from '../../shared/normalized.js';
import { openAiChatTransformer } from './index.js';
function parseSsePayloads(lines: string[]): Array<Record<string, unknown>> {
return lines
.filter((line) => line.startsWith('data: ') && line.trim() !== 'data: [DONE]')
.map((line) => JSON.parse(line.slice(6)) as Record<string, unknown>);
}
describe('openAiChatTransformer.inbound', () => {
it('captures chat request metadata fields without changing upstream body', () => {
const result = openAiChatTransformer.transformRequest({
model: 'gpt-5',
messages: [{ role: 'user', content: 'hello' }],
modalities: ['text', 'audio'],
audio: { voice: 'alloy', format: 'mp3' },
reasoning_effort: 'high',
reasoning_budget: 1024,
reasoning_summary: 'detailed',
service_tier: 'priority',
top_logprobs: 3,
logit_bias: { '42': 5 },
prompt_cache_key: 'cache-key',
safety_identifier: 'safety-id',
verbosity: 'low',
response_format: { type: 'json_object' },
stream_options: { include_usage: true },
});
expect(result.error).toBeUndefined();
expect(result.value?.upstreamBody).toMatchObject({
modalities: ['text', 'audio'],
audio: { voice: 'alloy', format: 'mp3' },
reasoning_effort: 'high',
reasoning_budget: 1024,
reasoning_summary: 'detailed',
service_tier: 'priority',
top_logprobs: 3,
logit_bias: { '42': 5 },
prompt_cache_key: 'cache-key',
safety_identifier: 'safety-id',
verbosity: 'low',
response_format: { type: 'json_object' },
stream_options: { include_usage: true },
});
expect((result.value as any)?.requestMetadata).toEqual({
modalities: ['text', 'audio'],
audio: { voice: 'alloy', format: 'mp3' },
reasoningEffort: 'high',
reasoningBudget: 1024,
reasoningSummary: 'detailed',
serviceTier: 'priority',
topLogprobs: 3,
logitBias: { '42': 5 },
promptCacheKey: 'cache-key',
safetyIdentifier: 'safety-id',
verbosity: 'low',
responseFormat: { type: 'json_object' },
streamOptionsIncludeUsage: true,
});
});
});
describe('openAiChatTransformer.outbound', () => {
it('carries annotations, citations, and detailed usage through final serialization', () => {
const normalized = openAiChatTransformer.transformFinalResponse({
id: 'chatcmpl-1',
model: 'gpt-5',
created: 123,
choices: [{
index: 0,
finish_reason: 'stop',
message: {
role: 'assistant',
content: 'hello',
reasoning_content: 'think',
annotations: [
{ type: 'url_citation', url_citation: { url: 'https://a.example' } },
{ type: 'url_citation', url_citation: { url: 'https://a.example' } },
],
},
}],
citations: ['https://c.example', 'https://c.example'],
usage: {
prompt_tokens: 11,
completion_tokens: 7,
total_tokens: 18,
prompt_tokens_details: { cached_tokens: 3 },
completion_tokens_details: { reasoning_tokens: 2 },
},
}, 'gpt-5');
const payload = openAiChatTransformer.serializeFinalResponse(normalized, {
promptTokens: 11,
completionTokens: 7,
totalTokens: 18,
});
const choice = (payload as any).choices[0];
expect(choice.message.annotations).toEqual([
{ type: 'url_citation', url_citation: { url: 'https://a.example' } },
]);
expect((payload as any).citations).toEqual(['https://c.example', 'https://a.example']);
expect((payload as any).usage).toMatchObject({
prompt_tokens: 11,
completion_tokens: 7,
total_tokens: 18,
prompt_tokens_details: { cached_tokens: 3 },
completion_tokens_details: { reasoning_tokens: 2 },
});
});
});
describe('openAiChatTransformer.stream', () => {
it('preserves annotations, citations, and usage payload on serialized stream chunks', () => {
const context = openAiChatTransformer.createStreamContext('gpt-5');
const event = openAiChatTransformer.transformStreamEvent({
id: 'chatcmpl-1',
model: 'gpt-5',
choices: [{
index: 0,
finish_reason: null,
delta: {
role: 'assistant',
content: 'hello',
reasoning_content: 'why',
annotations: [
{ type: 'url_citation', url_citation: { url: 'https://a.example' } },
],
},
}],
citations: ['https://c.example', 'https://a.example'],
usage: {
prompt_tokens: 11,
completion_tokens: 7,
total_tokens: 18,
prompt_tokens_details: { cached_tokens: 3 },
},
}, context, 'gpt-5');
const payloads = parseSsePayloads(
openAiChatTransformer.serializeStreamEvent(event, context, createClaudeDownstreamContext()),
);
expect(payloads[0]).toMatchObject({
citations: ['https://c.example', 'https://a.example'],
usage: {
prompt_tokens: 11,
completion_tokens: 7,
total_tokens: 18,
prompt_tokens_details: { cached_tokens: 3 },
},
});
expect(((payloads[0] as any).choices[0] as any).delta.annotations).toEqual([
{ type: 'url_citation', url_citation: { url: 'https://a.example' } },
]);
});
});
describe('openAiChatTransformer.aggregator', () => {
it('deduplicates annotations/citations while aggregating reasoning, tool calls, and usage details', () => {
const state = openAiChatTransformer.aggregator.createState();
openAiChatTransformer.aggregator.applyEvent(state, {
contentDelta: 'hel',
reasoningDelta: 'why',
toolCallDeltas: [{
index: 0,
id: 'call_1',
name: 'search',
argumentsDelta: '{"q":"cat"',
}],
annotations: [
{ type: 'url_citation', url_citation: { url: 'https://a.example' } },
],
citations: ['https://c.example'],
usageDetails: {
prompt_tokens_details: { cached_tokens: 2 },
},
} as any);
openAiChatTransformer.aggregator.applyEvent(state, {
contentDelta: 'lo',
reasoningDelta: ' now',
toolCallDeltas: [{
index: 0,
id: 'call_1',
name: 'search',
argumentsDelta: ',"k":1}',
}],
annotations: [
{ type: 'url_citation', url_citation: { url: 'https://a.example' } },
{ type: 'url_citation', url_citation: { url: 'https://b.example' } },
],
citations: ['https://c.example', 'https://d.example'],
usageDetails: {
completion_tokens_details: { reasoning_tokens: 4 },
},
finishReason: 'tool_calls',
} as any);
const normalized = openAiChatTransformer.aggregator.finalize(state, {
id: 'chatcmpl-1',
model: 'gpt-5',
created: 123,
content: '',
reasoningContent: '',
finishReason: 'stop',
toolCalls: [],
});
expect(normalized).toMatchObject({
content: 'hello',
reasoningContent: 'why now',
finishReason: 'tool_calls',
toolCalls: [{
id: 'call_1',
name: 'search',
arguments: '{"q":"cat","k":1}',
}],
citations: ['https://c.example', 'https://d.example'],
usageDetails: {
prompt_tokens_details: { cached_tokens: 2 },
completion_tokens_details: { reasoning_tokens: 4 },
},
});
expect((normalized as any).annotations).toEqual([
{ type: 'url_citation', url_citation: { url: 'https://a.example' } },
{ type: 'url_citation', url_citation: { url: 'https://b.example' } },
]);
});
});
@@ -0,0 +1,59 @@
import { type NormalizedFinalResponse, type NormalizedStreamEvent, type ParsedDownstreamChatRequest, type StreamTransformContext } from '../../shared/normalized.js';
import { openAiChatInbound } from './inbound.js';
import { openAiChatOutbound } from './outbound.js';
import { openAiChatStream } from './stream.js';
import { openAiChatUsage } from './usage.js';
import { createOpenAiChatAggregateState, applyOpenAiChatStreamEvent, finalizeOpenAiChatAggregate } from './aggregator.js';
export const openAiChatTransformer = {
protocol: 'openai/chat' as const,
inbound: openAiChatInbound,
outbound: openAiChatOutbound,
stream: openAiChatStream,
usage: openAiChatUsage,
aggregator: {
createState: createOpenAiChatAggregateState,
applyEvent: applyOpenAiChatStreamEvent,
finalize: finalizeOpenAiChatAggregate,
},
transformRequest(body: unknown): ReturnType<typeof openAiChatInbound.parse> {
return openAiChatInbound.parse(body);
},
createStreamContext(modelName: string): StreamTransformContext {
return openAiChatStream.createContext(modelName);
},
transformFinalResponse(payload: unknown, modelName: string, fallbackText = ''): NormalizedFinalResponse {
return openAiChatOutbound.normalizeFinal(payload, modelName, fallbackText);
},
transformStreamEvent(payload: unknown, context: StreamTransformContext, modelName: string): NormalizedStreamEvent {
return openAiChatStream.normalizeEvent(payload, context, modelName);
},
serializeStreamEvent(
event: NormalizedStreamEvent,
context: StreamTransformContext,
claudeContext: Parameters<typeof openAiChatStream.serializeEvent>[2],
): string[] {
return openAiChatStream.serializeEvent(event, context, claudeContext);
},
serializeDone(
context: StreamTransformContext,
claudeContext: Parameters<typeof openAiChatStream.serializeDone>[1],
): string[] {
return openAiChatStream.serializeDone(context, claudeContext);
},
serializeFinalResponse(
normalized: NormalizedFinalResponse,
usage: Parameters<typeof openAiChatOutbound.serializeFinal>[1],
) {
return openAiChatOutbound.serializeFinal(normalized, usage);
},
buildSyntheticChunks(normalized: NormalizedFinalResponse) {
return openAiChatOutbound.buildSyntheticChunks(normalized);
},
pullSseEvents(buffer: string) {
return openAiChatStream.pullSseEvents(buffer);
},
};
export type OpenAiChatTransformer = typeof openAiChatTransformer;
export type OpenAiChatParsedRequest = ParsedDownstreamChatRequest;
@@ -0,0 +1,44 @@
import type {
NormalizedFinalResponse,
NormalizedStreamEvent,
ParsedDownstreamChatRequest,
} from '../../shared/normalized.js';
export type OpenAiChatRequestMetadata = {
modalities?: unknown;
audio?: unknown;
reasoningEffort?: unknown;
reasoningBudget?: unknown;
reasoningSummary?: unknown;
serviceTier?: unknown;
topLogprobs?: unknown;
logitBias?: unknown;
promptCacheKey?: unknown;
safetyIdentifier?: unknown;
verbosity?: unknown;
responseFormat?: unknown;
streamOptionsIncludeUsage?: boolean | null;
};
export type OpenAiChatUsageDetails = {
prompt_tokens_details?: Record<string, number>;
completion_tokens_details?: Record<string, number>;
};
export type OpenAiChatParsedRequest = ParsedDownstreamChatRequest & {
requestMetadata?: OpenAiChatRequestMetadata;
};
export type OpenAiChatNormalizedFinalResponse = NormalizedFinalResponse & {
annotations?: Array<Record<string, unknown>>;
citations?: string[];
usageDetails?: OpenAiChatUsageDetails;
usagePayload?: Record<string, unknown>;
};
export type OpenAiChatNormalizedStreamEvent = NormalizedStreamEvent & {
annotations?: Array<Record<string, unknown>>;
citations?: string[];
usageDetails?: OpenAiChatUsageDetails;
usagePayload?: Record<string, unknown>;
};
@@ -0,0 +1,99 @@
import {
buildSyntheticOpenAiChunks,
normalizeUpstreamFinalResponse,
serializeFinalResponse,
type NormalizedFinalResponse,
} from '../../shared/normalized.js';
import { extractChatResponseExtras } from './helpers.js';
import type { OpenAiChatNormalizedFinalResponse } from './model.js';
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value);
}
function mergeUsagePayload(
usage: unknown,
normalized: OpenAiChatNormalizedFinalResponse,
): Record<string, unknown> | undefined {
const merged = isRecord(usage) ? { ...usage } : {};
const usagePayload = normalized.usagePayload && isRecord(normalized.usagePayload)
? normalized.usagePayload
: null;
if (usagePayload) {
for (const [key, value] of Object.entries(usagePayload)) {
if (key === 'prompt_tokens' || key === 'completion_tokens' || key === 'total_tokens') continue;
if (merged[key] === undefined) merged[key] = value;
}
}
if (normalized.usageDetails?.prompt_tokens_details) {
merged.prompt_tokens_details = normalized.usageDetails.prompt_tokens_details;
}
if (normalized.usageDetails?.completion_tokens_details) {
merged.completion_tokens_details = normalized.usageDetails.completion_tokens_details;
}
return Object.keys(merged).length > 0 ? merged : undefined;
}
export const openAiChatOutbound = {
normalizeFinal(payload: unknown, modelName: string, fallbackText = ''): OpenAiChatNormalizedFinalResponse {
return {
...normalizeUpstreamFinalResponse(payload, modelName, fallbackText),
...extractChatResponseExtras(payload),
};
},
serializeFinal(normalized: NormalizedFinalResponse, usage?: unknown) {
const chatNormalized = normalized as OpenAiChatNormalizedFinalResponse;
const payload = serializeFinalResponse(
'openai',
normalized,
usage as { promptTokens: number; completionTokens: number; totalTokens: number },
) as Record<string, unknown>;
if (Array.isArray(chatNormalized.citations) && chatNormalized.citations.length > 0) {
payload.citations = chatNormalized.citations;
}
if (Array.isArray(chatNormalized.annotations) && chatNormalized.annotations.length > 0) {
const choices = Array.isArray(payload.choices) ? payload.choices : [];
const firstChoice = choices[0];
if (isRecord(firstChoice) && isRecord(firstChoice.message)) {
firstChoice.message.annotations = chatNormalized.annotations;
}
}
const mergedUsage = mergeUsagePayload(payload.usage, chatNormalized);
if (mergedUsage) {
payload.usage = {
...(isRecord(payload.usage) ? payload.usage : {}),
...mergedUsage,
};
}
return payload;
},
buildSyntheticChunks(normalized: NormalizedFinalResponse) {
const chunks = buildSyntheticOpenAiChunks(normalized);
const chatNormalized = normalized as OpenAiChatNormalizedFinalResponse;
if (chunks.length <= 0) return chunks;
if (Array.isArray(chatNormalized.citations) && chatNormalized.citations.length > 0) {
chunks[0] = {
...chunks[0],
citations: chatNormalized.citations,
};
}
if (Array.isArray(chatNormalized.annotations) && chatNormalized.annotations.length > 0) {
const firstChunk = isRecord(chunks[0]) ? chunks[0] : null;
const firstChoice = firstChunk && Array.isArray(firstChunk.choices) ? firstChunk.choices[0] : null;
if (isRecord(firstChoice) && isRecord(firstChoice.delta)) {
firstChoice.delta.annotations = chatNormalized.annotations;
}
}
return chunks;
},
};
@@ -0,0 +1,109 @@
import {
createClaudeDownstreamContext,
createStreamTransformContext,
normalizeUpstreamStreamEvent,
pullSseEventsWithDone,
serializeNormalizedStreamEvent,
serializeStreamDone,
type ClaudeDownstreamContext,
type StreamTransformContext,
} from '../../shared/normalized.js';
import { extractChatResponseExtras } from './helpers.js';
import type { OpenAiChatNormalizedStreamEvent } from './model.js';
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value);
}
function parseSerializedSse(lines: string[]): Array<{ index: number; payload: Record<string, unknown> }> {
return lines
.map((line, index) => {
if (!line.startsWith('data: ') || line.trim() === 'data: [DONE]') return null;
try {
return {
index,
payload: JSON.parse(line.slice(6)) as Record<string, unknown>,
};
} catch {
return null;
}
})
.filter((item): item is { index: number; payload: Record<string, unknown> } => !!item);
}
export const openAiChatStream = {
createContext(modelName: string): StreamTransformContext {
return createStreamTransformContext(modelName);
},
normalizeEvent(payload: unknown, context: StreamTransformContext, modelName: string): OpenAiChatNormalizedStreamEvent {
return {
...normalizeUpstreamStreamEvent(payload, context, modelName),
...extractChatResponseExtras(payload),
};
},
serializeEvent(
event: OpenAiChatNormalizedStreamEvent,
context: StreamTransformContext,
downstreamContext?: ClaudeDownstreamContext,
): string[] {
const lines = serializeNormalizedStreamEvent(
'openai',
event,
context,
downstreamContext ?? createClaudeDownstreamContext(),
);
if (
(!Array.isArray(event.annotations) || event.annotations.length <= 0)
&& (!Array.isArray(event.citations) || event.citations.length <= 0)
&& !event.usagePayload
&& !event.usageDetails
) {
return lines;
}
const parsedEvents = parseSerializedSse(lines);
if (parsedEvents.length <= 0) return lines;
for (const parsed of parsedEvents) {
const payload = parsed.payload;
if (Array.isArray(event.citations) && event.citations.length > 0) {
payload.citations = event.citations;
}
const firstChoice = Array.isArray(payload.choices) ? payload.choices[0] : null;
if (Array.isArray(event.annotations) && event.annotations.length > 0 && isRecord(firstChoice) && isRecord(firstChoice.delta)) {
firstChoice.delta.annotations = event.annotations;
}
if (event.usagePayload || event.usageDetails) {
payload.usage = {
...(isRecord(event.usagePayload) ? event.usagePayload : {}),
...(event.usageDetails?.prompt_tokens_details
? { prompt_tokens_details: event.usageDetails.prompt_tokens_details }
: {}),
...(event.usageDetails?.completion_tokens_details
? { completion_tokens_details: event.usageDetails.completion_tokens_details }
: {}),
};
}
lines[parsed.index] = `data: ${JSON.stringify(payload)}\n\n`;
}
return lines;
},
serializeDone(
context: StreamTransformContext,
downstreamContext?: ClaudeDownstreamContext,
): string[] {
return serializeStreamDone(
'openai',
context,
downstreamContext ?? createClaudeDownstreamContext(),
);
},
pullSseEvents(buffer: string) {
return pullSseEventsWithDone(buffer);
},
};
@@ -0,0 +1,6 @@
import { createEmptyNormalizedUsage, mergeNormalizedUsage } from '../../shared/normalized.js';
export const openAiChatUsage = {
empty: createEmptyNormalizedUsage,
merge: mergeNormalizedUsage,
};
@@ -0,0 +1,336 @@
import { describe, expect, it } from 'vitest';
import {
createOpenAiResponsesAggregateState,
serializeConvertedResponsesEvents,
type OpenAiResponsesAggregateState,
completeResponsesStream,
} from './aggregator.js';
import { openAiResponsesStream } from './stream.js';
function collectSerializedEvents(input: string[]): Array<{ event: string; data: any }> {
const events: Array<{ event: string; data: any }> = [];
for (const chunk of input) {
const blocks = chunk.split('\n\n').filter((item) => item.trim().length > 0);
for (const block of blocks) {
const lines = block.split('\n');
const eventLine = lines.find((line) => line.startsWith('event: '));
const dataLine = lines.find((line) => line.startsWith('data: '));
if (!dataLine) continue;
const rawData = dataLine.slice('data: '.length);
if (rawData === '[DONE]') {
events.push({ event: eventLine?.slice('event: '.length) || 'done', data: '[DONE]' });
continue;
}
events.push({
event: eventLine?.slice('event: '.length) || '',
data: JSON.parse(rawData),
});
}
}
return events;
}
function feedResponsesEvent(
state: OpenAiResponsesAggregateState,
payload: Record<string, unknown>,
): Array<{ event: string; data: any }> {
const context = openAiResponsesStream.createContext('gpt-5');
const normalizedEvent = openAiResponsesStream.normalizeEvent(payload, context, 'gpt-5');
return collectSerializedEvents(
serializeConvertedResponsesEvents({
state,
streamContext: context,
event: normalizedEvent,
usage: {
promptTokens: 0,
completionTokens: 0,
totalTokens: 0,
},
}),
);
}
describe('openAiResponsesStream / aggregator', () => {
it('keeps reasoning separate from assistant text for normalized events and merges usage details', () => {
const state = createOpenAiResponsesAggregateState('gpt-5');
const context = openAiResponsesStream.createContext('gpt-5');
const contentEvent = {
role: 'assistant',
contentDelta: 'hello',
};
const reasoningEvent = {
reasoningDelta: 'plan first',
responsesPayload: {
usage: {
input_tokens: 5,
output_tokens: 3,
total_tokens: 8,
output_tokens_details: { reasoning_tokens: 2 },
},
},
};
collectSerializedEvents(
serializeConvertedResponsesEvents({
state,
streamContext: context,
event: contentEvent as any,
usage: { promptTokens: 5, completionTokens: 3, totalTokens: 8 },
}),
);
const reasoningEmitted = collectSerializedEvents(
serializeConvertedResponsesEvents({
state,
streamContext: context,
event: reasoningEvent as any,
usage: { promptTokens: 5, completionTokens: 3, totalTokens: 8 },
}),
);
expect(reasoningEmitted.some((item) => item.event === 'response.reasoning_summary_part.added')).toBe(true);
expect(reasoningEmitted.some((item) => item.event === 'response.reasoning_summary_text.delta')).toBe(true);
const completed = collectSerializedEvents(
completeResponsesStream(state, context, { promptTokens: 5, completionTokens: 3, totalTokens: 8 }),
).find((item) => item.event === 'response.completed');
expect(completed?.data.response.output).toEqual([
{
id: expect.any(String),
type: 'message',
role: 'assistant',
status: 'completed',
content: [
{
type: 'output_text',
text: 'hello',
},
],
},
{
id: expect.any(String),
type: 'reasoning',
status: 'completed',
summary: [
{
type: 'summary_text',
text: 'plan first',
},
],
},
]);
expect(completed?.data.response.usage).toMatchObject({
input_tokens: 5,
output_tokens: 3,
total_tokens: 8,
output_tokens_details: { reasoning_tokens: 2 },
});
});
it('tracks reasoning summary events into completed response output', () => {
const state = createOpenAiResponsesAggregateState('gpt-5');
const emitted = [
...feedResponsesEvent(state, {
type: 'response.output_item.added',
output_index: 0,
item: {
id: 'rs_1',
type: 'reasoning',
status: 'in_progress',
summary: [],
},
}),
...feedResponsesEvent(state, {
type: 'response.reasoning_summary_part.added',
item_id: 'rs_1',
output_index: 0,
summary_index: 0,
part: {
type: 'summary_text',
text: '',
},
}),
...feedResponsesEvent(state, {
type: 'response.reasoning_summary_text.delta',
item_id: 'rs_1',
output_index: 0,
summary_index: 0,
delta: 'Plan',
}),
...feedResponsesEvent(state, {
type: 'response.reasoning_summary_text.done',
item_id: 'rs_1',
output_index: 0,
summary_index: 0,
text: 'Plan it out',
}),
...feedResponsesEvent(state, {
type: 'response.reasoning_summary_part.done',
item_id: 'rs_1',
output_index: 0,
summary_index: 0,
part: {
type: 'summary_text',
text: 'Plan it out',
},
}),
...feedResponsesEvent(state, {
type: 'response.output_item.done',
output_index: 0,
item: {
id: 'rs_1',
type: 'reasoning',
status: 'completed',
summary: [],
},
}),
...feedResponsesEvent(state, {
type: 'response.completed',
response: {
id: 'resp_reasoning',
object: 'response',
created_at: 1700000000,
status: 'completed',
model: 'gpt-5',
output: [],
},
}),
];
expect(emitted.some((item) => item.event === 'response.reasoning_summary_part.added')).toBe(true);
expect(emitted.some((item) => item.event === 'response.reasoning_summary_text.done')).toBe(true);
const completed = emitted.find((item) => item.event === 'response.completed');
expect(completed?.data.response.output).toEqual([
{
id: 'rs_1',
type: 'reasoning',
status: 'completed',
summary: [
{
type: 'summary_text',
text: 'Plan it out',
},
],
},
]);
});
it('tracks custom tool input and image generation results', () => {
const state = createOpenAiResponsesAggregateState('gpt-5');
const emitted = [
...feedResponsesEvent(state, {
type: 'response.output_item.added',
output_index: 0,
item: {
id: 'ct_1',
type: 'custom_tool_call',
status: 'in_progress',
call_id: 'ct_1',
name: 'browser',
input: '',
},
}),
...feedResponsesEvent(state, {
type: 'response.custom_tool_call_input.delta',
item_id: 'ct_1',
output_index: 0,
delta: 'open ',
}),
...feedResponsesEvent(state, {
type: 'response.custom_tool_call_input.done',
item_id: 'ct_1',
output_index: 0,
input: 'open example.com',
}),
...feedResponsesEvent(state, {
type: 'response.output_item.done',
output_index: 0,
item: {
id: 'ct_1',
type: 'custom_tool_call',
status: 'completed',
call_id: 'ct_1',
name: 'browser',
input: 'open example.com',
},
}),
...feedResponsesEvent(state, {
type: 'response.output_item.added',
output_index: 1,
item: {
id: 'img_1',
type: 'image_generation_call',
status: 'in_progress',
result: null,
},
}),
...feedResponsesEvent(state, {
type: 'response.image_generation_call.partial_image',
item_id: 'img_1',
output_index: 1,
partial_image_index: 0,
partial_image_b64: 'abc123',
}),
...feedResponsesEvent(state, {
type: 'response.image_generation_call.completed',
item_id: 'img_1',
output_index: 1,
result: 'data:image/png;base64,final',
}),
...feedResponsesEvent(state, {
type: 'response.output_item.done',
output_index: 1,
item: {
id: 'img_1',
type: 'image_generation_call',
status: 'completed',
result: 'data:image/png;base64,final',
},
}),
...feedResponsesEvent(state, {
type: 'response.completed',
response: {
id: 'resp_tools',
object: 'response',
created_at: 1700000001,
status: 'completed',
model: 'gpt-5',
output: [],
},
}),
];
expect(emitted.some((item) => item.event === 'response.custom_tool_call_input.done')).toBe(true);
expect(emitted.some((item) => item.event === 'response.image_generation_call.completed')).toBe(true);
const completed = emitted.find((item) => item.event === 'response.completed');
expect(completed?.data.response.output).toEqual([
{
id: 'ct_1',
type: 'custom_tool_call',
status: 'completed',
call_id: 'ct_1',
name: 'browser',
input: 'open example.com',
},
{
id: 'img_1',
type: 'image_generation_call',
status: 'completed',
result: 'data:image/png;base64,final',
partial_images: [
{
partial_image_index: 0,
partial_image_b64: 'abc123',
},
],
},
]);
});
});
@@ -0,0 +1,762 @@
import { type StreamTransformContext } from '../../shared/normalized.js';
import type { OpenAiResponsesStreamEvent } from './stream.js';
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value);
}
function asTrimmedString(value: unknown): string {
return typeof value === 'string' ? value.trim() : '';
}
function cloneRecord(value: unknown): Record<string, unknown> | null {
if (!isRecord(value)) return null;
return { ...value };
}
function cloneJson<T>(value: T): T {
if (Array.isArray(value)) {
return value.map((item) => cloneJson(item)) as T;
}
if (isRecord(value)) {
return Object.fromEntries(
Object.entries(value).map(([key, item]) => [key, cloneJson(item)]),
) as T;
}
return value;
}
function ensureResponseId(rawId: string): string {
const trimmed = rawId.trim() || `resp_${Date.now()}`;
return trimmed.startsWith('resp_') ? trimmed : `resp_${trimmed}`;
}
function ensureOutputItemId(rawId: string, prefix: string, index: number): string {
const trimmed = rawId.trim();
if (trimmed) return trimmed;
return `${prefix}_${index}`;
}
function serializeSse(event: string, payload: unknown): string {
return `event: ${event}\ndata: ${JSON.stringify(payload)}\n\n`;
}
function serializeDone(): string {
return 'data: [DONE]\n\n';
}
type ResponsesUsageSummary = {
promptTokens: number;
completionTokens: number;
totalTokens: number;
cacheReadTokens?: number;
cacheCreationTokens?: number;
promptTokensIncludeCache?: boolean | null;
};
type AggregateOutputItem = Record<string, unknown>;
export type OpenAiResponsesAggregateState = {
modelName: string;
responseId: string | null;
createdAt: number | null;
outputItems: Array<AggregateOutputItem | undefined>;
outputIndexById: Record<string, number>;
messageIndex: number | null;
reasoningIndexById: Record<string, number>;
functionIndexById: Record<string, number>;
customToolIndexById: Record<string, number>;
imageGenerationIndexById: Record<string, number>;
usageExtras: Record<string, unknown>;
completed: boolean;
failed: boolean;
};
export function createOpenAiResponsesAggregateState(modelName: string): OpenAiResponsesAggregateState {
return {
modelName,
responseId: null,
createdAt: null,
outputItems: [],
outputIndexById: {},
messageIndex: null,
reasoningIndexById: {},
functionIndexById: {},
customToolIndexById: {},
imageGenerationIndexById: {},
usageExtras: {},
completed: false,
failed: false,
};
}
function mergeUsageExtras(
state: OpenAiResponsesAggregateState,
usagePayload: unknown,
): void {
if (!isRecord(usagePayload)) return;
for (const [key, value] of Object.entries(usagePayload)) {
if (key === 'input_tokens' || key === 'output_tokens' || key === 'total_tokens') continue;
state.usageExtras[key] = cloneJson(value);
}
}
function rememberOutputId(state: OpenAiResponsesAggregateState, index: number, item: AggregateOutputItem) {
const itemId = asTrimmedString(item.id);
if (itemId) {
state.outputIndexById[itemId] = index;
}
const itemType = asTrimmedString(item.type).toLowerCase();
const callId = asTrimmedString(item.call_id);
if (itemType === 'function_call') {
if (callId) state.functionIndexById[callId] = index;
if (itemId) state.functionIndexById[itemId] = index;
}
if (itemType === 'custom_tool_call') {
if (callId) state.customToolIndexById[callId] = index;
if (itemId) state.customToolIndexById[itemId] = index;
}
if (itemType === 'image_generation_call' && itemId) {
state.imageGenerationIndexById[itemId] = index;
}
if (itemType === 'reasoning' && itemId) {
state.reasoningIndexById[itemId] = index;
}
if (itemType === 'message' && state.messageIndex === null) {
state.messageIndex = index;
}
}
function setOutputItem(
state: OpenAiResponsesAggregateState,
index: number,
item: AggregateOutputItem,
): AggregateOutputItem {
const current = cloneRecord(state.outputItems[index]) || {};
const incoming = cloneJson(item);
const next = {
...current,
...incoming,
};
if (Array.isArray(current.summary) && (!Array.isArray(incoming.summary) || incoming.summary.length <= 0)) {
next.summary = current.summary;
}
if (Array.isArray(current.content) && (!Array.isArray(incoming.content) || incoming.content.length <= 0)) {
next.content = current.content;
}
if (
Array.isArray(current.partial_images)
&& (!Array.isArray(incoming.partial_images) || incoming.partial_images.length <= 0)
) {
next.partial_images = current.partial_images;
}
state.outputItems[index] = next;
rememberOutputId(state, index, next);
return next;
}
function ensureOutputItem(
state: OpenAiResponsesAggregateState,
index: number,
factory: () => AggregateOutputItem,
): AggregateOutputItem {
const existing = state.outputItems[index];
if (existing) return existing;
return setOutputItem(state, index, factory());
}
function resolveOutputIndex(
state: OpenAiResponsesAggregateState,
rawIndex: unknown,
...candidateIds: Array<unknown>
): number {
if (typeof rawIndex === 'number' && Number.isFinite(rawIndex)) {
return Math.max(0, Math.trunc(rawIndex));
}
for (const rawId of candidateIds) {
const itemId = asTrimmedString(rawId);
if (itemId && state.outputIndexById[itemId] !== undefined) {
return state.outputIndexById[itemId];
}
}
return state.outputItems.length;
}
function ensureMessageItem(state: OpenAiResponsesAggregateState, indexHint?: number): { index: number; item: AggregateOutputItem } {
const index = state.messageIndex ?? indexHint ?? 0;
const item = ensureOutputItem(state, index, () => ({
id: ensureOutputItemId('', 'msg', index),
type: 'message',
role: 'assistant',
status: 'in_progress',
content: [],
}));
if (!Array.isArray(item.content)) item.content = [];
if (state.messageIndex === null) state.messageIndex = index;
return { index, item };
}
function ensureMessageOutputTextPart(
state: OpenAiResponsesAggregateState,
indexHint?: number,
): { index: number; item: AggregateOutputItem; part: AggregateOutputItem; created: boolean } {
const { index, item } = ensureMessageItem(state, indexHint);
const content = Array.isArray(item.content) ? item.content as AggregateOutputItem[] : [];
if (!Array.isArray(item.content)) item.content = content;
let part = content[0];
const created = !isRecord(part) || asTrimmedString(part.type).toLowerCase() !== 'output_text';
if (created) {
part = { type: 'output_text', text: '' };
content[0] = part;
}
return { index, item, part, created };
}
function ensureReasoningItem(
state: OpenAiResponsesAggregateState,
itemIdRaw: unknown,
indexHint?: unknown,
): { index: number; item: AggregateOutputItem; created: boolean } {
const itemId = asTrimmedString(itemIdRaw);
const existingIndex = itemId ? state.reasoningIndexById[itemId] : undefined;
const index = existingIndex ?? resolveOutputIndex(state, indexHint, itemId);
const created = !state.outputItems[index];
const item = ensureOutputItem(state, index, () => ({
id: ensureOutputItemId(itemId, 'rs', index),
type: 'reasoning',
status: 'in_progress',
summary: [],
}));
if (!Array.isArray(item.summary)) item.summary = [];
return { index, item, created };
}
function ensureReasoningSummaryPart(
state: OpenAiResponsesAggregateState,
itemIdRaw: unknown,
summaryIndexRaw: unknown,
indexHint?: unknown,
): { item: AggregateOutputItem; summary: AggregateOutputItem; index: number; summaryIndex: number; created: boolean } {
const reasoningState = ensureReasoningItem(state, itemIdRaw, indexHint);
const summaryIndex = typeof summaryIndexRaw === 'number' && Number.isFinite(summaryIndexRaw)
? Math.max(0, Math.trunc(summaryIndexRaw))
: 0;
const summary = Array.isArray(reasoningState.item.summary) ? reasoningState.item.summary as AggregateOutputItem[] : [];
if (!Array.isArray(reasoningState.item.summary)) reasoningState.item.summary = summary;
let part = summary[summaryIndex];
const created = !isRecord(part);
if (!isRecord(part)) {
part = { type: 'summary_text', text: '' };
summary[summaryIndex] = part;
}
return { item: reasoningState.item, summary: part, index: reasoningState.index, summaryIndex, created: reasoningState.created || created };
}
function ensureFunctionCallItem(
state: OpenAiResponsesAggregateState,
callIdRaw: unknown,
nameRaw: unknown,
indexHint?: number,
): { index: number; item: AggregateOutputItem; created: boolean } {
const callId = asTrimmedString(callIdRaw);
const name = asTrimmedString(nameRaw);
const existingIndex = callId ? state.functionIndexById[callId] : undefined;
const index = existingIndex ?? resolveOutputIndex(state, indexHint, callId);
const created = !state.outputItems[index];
const item = ensureOutputItem(state, index, () => ({
id: ensureOutputItemId(callId, 'fc', index),
type: 'function_call',
status: 'in_progress',
call_id: ensureOutputItemId(callId, 'call', index),
name,
arguments: '',
}));
if (callId) item.call_id = ensureOutputItemId(callId, 'call', index);
if (name) item.name = name;
if (typeof item.arguments !== 'string') item.arguments = '';
return { index, item, created };
}
function ensureCustomToolItem(
state: OpenAiResponsesAggregateState,
itemIdRaw: unknown,
callIdRaw: unknown,
nameRaw: unknown,
indexHint?: number,
): { index: number; item: AggregateOutputItem; created: boolean } {
const itemId = asTrimmedString(itemIdRaw);
const callId = asTrimmedString(callIdRaw);
const name = asTrimmedString(nameRaw);
const existingIndex = (callId && state.customToolIndexById[callId] !== undefined)
? state.customToolIndexById[callId]
: (itemId && state.customToolIndexById[itemId] !== undefined ? state.customToolIndexById[itemId] : undefined);
const index = existingIndex ?? resolveOutputIndex(state, indexHint, itemId, callId);
const created = !state.outputItems[index];
const item = ensureOutputItem(state, index, () => ({
id: ensureOutputItemId(itemId, 'ct', index),
type: 'custom_tool_call',
status: 'in_progress',
call_id: ensureOutputItemId(callId || itemId, 'call', index),
name,
input: '',
}));
if (callId || itemId) item.call_id = ensureOutputItemId(callId || itemId, 'call', index);
if (name) item.name = name;
if (typeof item.input !== 'string') item.input = '';
return { index, item, created };
}
function ensureImageGenerationItem(
state: OpenAiResponsesAggregateState,
itemIdRaw: unknown,
indexHint?: number,
): { index: number; item: AggregateOutputItem; created: boolean } {
const itemId = asTrimmedString(itemIdRaw);
const existingIndex = itemId ? state.imageGenerationIndexById[itemId] : undefined;
const index = existingIndex ?? resolveOutputIndex(state, indexHint, itemId);
const created = !state.outputItems[index];
const item = ensureOutputItem(state, index, () => ({
id: ensureOutputItemId(itemId, 'img', index),
type: 'image_generation_call',
status: 'in_progress',
result: null,
partial_images: [],
}));
if (!Array.isArray(item.partial_images)) item.partial_images = [];
return { index, item, created };
}
function buildUsagePayload(usage: ResponsesUsageSummary): Record<string, unknown> {
const payload: Record<string, unknown> = {
input_tokens: usage.promptTokens,
output_tokens: usage.completionTokens,
total_tokens: usage.totalTokens,
};
const inputDetails: Record<string, unknown> = {};
if ((usage.cacheReadTokens || 0) > 0) inputDetails.cached_tokens = usage.cacheReadTokens;
if ((usage.cacheCreationTokens || 0) > 0) inputDetails.cache_creation_tokens = usage.cacheCreationTokens;
if (Object.keys(inputDetails).length > 0) payload.input_tokens_details = inputDetails;
return {
...payload,
};
}
function collectOutputText(state: OpenAiResponsesAggregateState): string {
const parts: string[] = [];
for (const item of state.outputItems) {
if (!isRecord(item)) continue;
if (asTrimmedString(item.type).toLowerCase() !== 'message') continue;
const content = Array.isArray(item.content) ? item.content : [];
for (const part of content) {
if (!isRecord(part)) continue;
const type = asTrimmedString(part.type).toLowerCase();
if ((type === 'output_text' || type === 'text') && typeof part.text === 'string' && part.text) {
parts.push(part.text);
}
}
}
return parts.join('');
}
function materializeResponse(
state: OpenAiResponsesAggregateState,
streamContext: StreamTransformContext,
usage: ResponsesUsageSummary,
responseTemplate?: Record<string, unknown> | null,
statusOverride?: 'completed' | 'failed',
): Record<string, unknown> {
const base = cloneRecord(responseTemplate) || {};
const responseId = ensureResponseId(
asTrimmedString(base.id)
|| state.responseId
|| streamContext.id
|| state.modelName,
);
const createdAt = (
typeof base.created_at === 'number' && Number.isFinite(base.created_at)
? base.created_at
: (typeof base.created === 'number' && Number.isFinite(base.created) ? base.created : null)
) ?? state.createdAt ?? Math.floor(Date.now() / 1000);
const output = state.outputItems
.filter((item): item is AggregateOutputItem => isRecord(item))
.map((item) => {
const status = asTrimmedString(item.status).toLowerCase();
return {
...item,
status: status && status !== 'in_progress'
? status
: (state.failed ? 'failed' : 'completed'),
};
});
return {
...base,
id: responseId,
object: 'response',
created_at: createdAt,
status: statusOverride ?? (state.failed ? 'failed' : 'completed'),
model: asTrimmedString(base.model) || streamContext.model || state.modelName,
output,
output_text: collectOutputText(state),
usage: buildUsagePayload(usage),
...Object.keys(state.usageExtras).length > 0 ? { usage: { ...buildUsagePayload(usage), ...state.usageExtras } } : {},
};
}
function serializeOriginalResponsesEvent(eventType: string, payload: Record<string, unknown>): string[] {
return [serializeSse(eventType, payload)];
}
function computeNovelDelta(existingText: string, incomingDelta: string): string {
if (!incomingDelta) return '';
if (!existingText) return incomingDelta;
if (existingText.endsWith(incomingDelta)) return '';
if (incomingDelta.startsWith(existingText)) {
return incomingDelta.slice(existingText.length);
}
if (existingText.includes(incomingDelta)) return '';
const maxOverlap = Math.min(existingText.length, incomingDelta.length);
for (let overlap = maxOverlap; overlap > 0; overlap -= 1) {
if (existingText.slice(-overlap) === incomingDelta.slice(0, overlap)) {
return incomingDelta.slice(overlap);
}
}
return incomingDelta;
}
function applyOriginalResponsesPayload(
state: OpenAiResponsesAggregateState,
eventType: string,
payload: Record<string, unknown>,
streamContext: StreamTransformContext,
usage: ResponsesUsageSummary,
): string[] {
switch (eventType) {
case 'response.output_item.added':
case 'response.output_item.done': {
const outputIndex = resolveOutputIndex(state, payload.output_index, (payload.item as Record<string, unknown> | undefined)?.id);
const item = cloneRecord(payload.item) || {};
if (Object.keys(item).length > 0) {
const existing = isRecord(state.outputItems[outputIndex]) ? cloneRecord(state.outputItems[outputIndex]) || {} : {};
const next = {
...existing,
...item,
};
if ((!Array.isArray(item.content) || item.content.length === 0) && Array.isArray(existing.content)) {
next.content = existing.content;
}
if ((!Array.isArray(item.summary) || item.summary.length === 0) && Array.isArray(existing.summary)) {
next.summary = existing.summary;
}
if ((!Array.isArray(item.partial_images) || item.partial_images.length === 0) && Array.isArray(existing.partial_images)) {
next.partial_images = existing.partial_images;
}
setOutputItem(state, outputIndex, next);
}
return serializeOriginalResponsesEvent(eventType, {
...payload,
item: state.outputItems[outputIndex] ?? payload.item,
});
}
case 'response.content_part.added':
case 'response.content_part.done': {
const outputIndex = resolveOutputIndex(state, payload.output_index, payload.item_id);
const contentIndex = typeof payload.content_index === 'number' && Number.isFinite(payload.content_index)
? Math.max(0, Math.trunc(payload.content_index))
: 0;
const message = ensureMessageItem(state, outputIndex).item;
const content = Array.isArray(message.content) ? message.content as AggregateOutputItem[] : [];
if (!Array.isArray(message.content)) message.content = content;
const part = cloneRecord(payload.part);
if (part) content[contentIndex] = part;
return serializeOriginalResponsesEvent(eventType, payload);
}
case 'response.output_text.delta':
case 'response.output_text.done': {
const outputIndex = resolveOutputIndex(state, payload.output_index, payload.item_id);
const textPart = ensureMessageOutputTextPart(state, outputIndex);
if (eventType === 'response.output_text.done') {
textPart.part.text = typeof payload.text === 'string' ? payload.text : String(payload.text ?? '');
} else {
textPart.part.text = `${typeof textPart.part.text === 'string' ? textPart.part.text : ''}${typeof payload.delta === 'string' ? payload.delta : ''}`;
}
return serializeOriginalResponsesEvent(eventType, payload);
}
case 'response.function_call_arguments.delta':
case 'response.function_call_arguments.done': {
const entry = ensureFunctionCallItem(
state,
payload.call_id ?? payload.item_id,
payload.name,
resolveOutputIndex(state, payload.output_index, payload.item_id, payload.call_id),
);
if (eventType === 'response.function_call_arguments.done') {
entry.item.arguments = typeof payload.arguments === 'string' ? payload.arguments : String(payload.arguments ?? '');
} else {
entry.item.arguments = `${typeof entry.item.arguments === 'string' ? entry.item.arguments : ''}${typeof payload.delta === 'string' ? payload.delta : ''}`;
}
return serializeOriginalResponsesEvent(eventType, payload);
}
case 'response.custom_tool_call_input.delta':
case 'response.custom_tool_call_input.done': {
const entry = ensureCustomToolItem(
state,
payload.item_id,
payload.call_id,
payload.name,
resolveOutputIndex(state, payload.output_index, payload.item_id, payload.call_id),
);
if (eventType === 'response.custom_tool_call_input.done') {
entry.item.input = typeof payload.input === 'string' ? payload.input : String(payload.input ?? '');
} else {
entry.item.input = `${typeof entry.item.input === 'string' ? entry.item.input : ''}${typeof payload.delta === 'string' ? payload.delta : ''}`;
}
return serializeOriginalResponsesEvent(eventType, payload);
}
case 'response.reasoning_summary_part.added':
case 'response.reasoning_summary_part.done': {
const summaryState = ensureReasoningSummaryPart(state, payload.item_id, payload.summary_index, payload.output_index);
const part = cloneRecord(payload.part);
if (part) {
const summary = Array.isArray(summaryState.item.summary) ? summaryState.item.summary as AggregateOutputItem[] : [];
summary[summaryState.summaryIndex] = {
...summaryState.summary,
...part,
};
summaryState.item.summary = summary;
}
return serializeOriginalResponsesEvent(eventType, payload);
}
case 'response.reasoning_summary_text.delta':
case 'response.reasoning_summary_text.done': {
const summaryState = ensureReasoningSummaryPart(state, payload.item_id, payload.summary_index, payload.output_index);
if (eventType === 'response.reasoning_summary_text.done') {
summaryState.summary.text = typeof payload.text === 'string' ? payload.text : String(payload.text ?? '');
} else {
summaryState.summary.text = `${typeof summaryState.summary.text === 'string' ? summaryState.summary.text : ''}${typeof payload.delta === 'string' ? payload.delta : ''}`;
}
return serializeOriginalResponsesEvent(eventType, payload);
}
case 'response.image_generation_call.generating':
case 'response.image_generation_call.in_progress':
case 'response.image_generation_call.partial_image':
case 'response.image_generation_call.completed': {
const entry = ensureImageGenerationItem(
state,
payload.item_id,
resolveOutputIndex(state, payload.output_index, payload.item_id),
);
if (eventType === 'response.image_generation_call.partial_image') {
const partialImages = Array.isArray(entry.item.partial_images) ? entry.item.partial_images as AggregateOutputItem[] : [];
partialImages.push({
partial_image_index: payload.partial_image_index,
partial_image_b64: payload.partial_image_b64,
});
entry.item.partial_images = partialImages;
}
if (payload.result !== undefined) {
entry.item.result = payload.result;
}
if (eventType === 'response.image_generation_call.completed') {
entry.item.status = 'completed';
}
return serializeOriginalResponsesEvent(eventType, payload);
}
case 'response.completed': {
mergeUsageExtras(state, payload.response && isRecord(payload.response) ? payload.response.usage : payload.usage);
state.completed = true;
const responsePayload = cloneRecord(payload.response);
const materialized = materializeResponse(state, streamContext, usage, responsePayload, 'completed');
return [serializeSse('response.completed', { ...payload, response: materialized })];
}
case 'response.failed': {
mergeUsageExtras(state, payload.response && isRecord(payload.response) ? payload.response.usage : payload.usage);
state.failed = true;
const responsePayload = cloneRecord(payload.response);
const materialized = materializeResponse(state, streamContext, usage, responsePayload, 'failed');
return [serializeSse('response.failed', { ...payload, response: materialized })];
}
default:
mergeUsageExtras(state, payload.usage);
return serializeOriginalResponsesEvent(eventType, payload);
}
}
function buildSyntheticMessageEvents(
state: OpenAiResponsesAggregateState,
delta: string,
): string[] {
const { index, item } = ensureMessageItem(state);
const textPartState = ensureMessageOutputTextPart(state, index);
const lines: string[] = [];
const currentText = typeof textPartState.part.text === 'string' ? textPartState.part.text : '';
const novelDelta = computeNovelDelta(currentText, delta);
if (textPartState.created) {
lines.push(serializeSse('response.output_item.added', {
type: 'response.output_item.added',
output_index: index,
item,
}));
lines.push(serializeSse('response.content_part.added', {
type: 'response.content_part.added',
output_index: index,
item_id: item.id,
content_index: 0,
part: { type: 'output_text', text: '' },
}));
}
if (novelDelta) {
textPartState.part.text = `${currentText}${novelDelta}`;
lines.push(serializeSse('response.output_text.delta', {
type: 'response.output_text.delta',
output_index: index,
item_id: item.id,
delta: novelDelta,
}));
}
return lines;
}
function buildSyntheticReasoningEvents(
state: OpenAiResponsesAggregateState,
delta: string,
): string[] {
const summaryState = ensureReasoningSummaryPart(state, '', 0, state.outputItems.length);
const itemId = asTrimmedString(summaryState.item.id);
const lines: string[] = [];
const currentText = typeof summaryState.summary.text === 'string' ? summaryState.summary.text : '';
const novelDelta = computeNovelDelta(currentText, delta);
if (summaryState.created) {
lines.push(serializeSse('response.output_item.added', {
type: 'response.output_item.added',
output_index: summaryState.index,
item: summaryState.item,
}));
lines.push(serializeSse('response.reasoning_summary_part.added', {
type: 'response.reasoning_summary_part.added',
item_id: itemId,
output_index: summaryState.index,
summary_index: 0,
part: { type: 'summary_text', text: '' },
}));
}
if (novelDelta) {
summaryState.summary.text = `${currentText}${novelDelta}`;
lines.push(serializeSse('response.reasoning_summary_text.delta', {
type: 'response.reasoning_summary_text.delta',
item_id: itemId,
output_index: summaryState.index,
summary_index: 0,
delta: novelDelta,
}));
}
return lines;
}
function buildSyntheticToolEvents(
state: OpenAiResponsesAggregateState,
event: OpenAiResponsesStreamEvent,
): string[] {
const lines: string[] = [];
if (!Array.isArray(event.toolCallDeltas)) return lines;
for (const toolDelta of event.toolCallDeltas) {
const entry = ensureFunctionCallItem(state, toolDelta.id, toolDelta.name, toolDelta.index);
if (entry.created) {
lines.push(serializeSse('response.output_item.added', {
type: 'response.output_item.added',
output_index: entry.index,
item: entry.item,
}));
}
if (toolDelta.argumentsDelta !== undefined && toolDelta.argumentsDelta.length > 0) {
entry.item.arguments = `${typeof entry.item.arguments === 'string' ? entry.item.arguments : ''}${toolDelta.argumentsDelta}`;
lines.push(serializeSse('response.function_call_arguments.delta', {
type: 'response.function_call_arguments.delta',
item_id: entry.item.id,
call_id: entry.item.call_id,
output_index: entry.index,
name: entry.item.name,
delta: toolDelta.argumentsDelta,
}));
}
}
return lines;
}
export function serializeConvertedResponsesEvents(input: {
state: OpenAiResponsesAggregateState;
streamContext: StreamTransformContext;
event: OpenAiResponsesStreamEvent;
usage: ResponsesUsageSummary;
}): string[] {
const { state, streamContext, event, usage } = input;
mergeUsageExtras(state, event.responsesPayload && isRecord(event.responsesPayload) ? event.responsesPayload.usage : undefined);
if (event.responsesEventType && event.responsesPayload) {
return applyOriginalResponsesPayload(
state,
event.responsesEventType,
event.responsesPayload,
streamContext,
usage,
);
}
const lines: string[] = [];
if (event.contentDelta) {
lines.push(...buildSyntheticMessageEvents(state, event.contentDelta));
}
if (event.reasoningDelta) {
lines.push(...buildSyntheticReasoningEvents(state, event.reasoningDelta));
}
lines.push(...buildSyntheticToolEvents(state, event));
return lines;
}
export function completeResponsesStream(
state: OpenAiResponsesAggregateState,
streamContext: StreamTransformContext,
usage: ResponsesUsageSummary,
): string[] {
if (state.failed || state.completed) {
return [serializeDone()];
}
state.completed = true;
return [
serializeSse('response.completed', {
type: 'response.completed',
response: materializeResponse(state, streamContext, usage, null, 'completed'),
}),
serializeDone(),
];
}
export function failResponsesStream(
state: OpenAiResponsesAggregateState,
streamContext: StreamTransformContext,
usage: ResponsesUsageSummary,
payload: unknown,
): string[] {
if (state.failed) {
return [serializeDone()];
}
state.failed = true;
const errorPayload = cloneRecord(payload);
const message = (
isRecord(errorPayload?.error) && typeof errorPayload.error.message === 'string'
? errorPayload.error.message
: (typeof errorPayload?.message === 'string' ? errorPayload.message : 'upstream stream failed')
);
return [
serializeSse('response.failed', {
type: 'response.failed',
response: materializeResponse(state, streamContext, usage, cloneRecord(errorPayload?.response), 'failed'),
error: {
message,
type: 'upstream_error',
},
}),
serializeDone(),
];
}
@@ -0,0 +1,409 @@
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object';
}
function asTrimmedString(value: unknown): string {
return typeof value === 'string' ? value.trim() : '';
}
function toTextBlockType(role: string): 'input_text' | 'output_text' {
return role === 'assistant' ? 'output_text' : 'input_text';
}
function normalizeImageUrlValue(value: unknown): string | Record<string, unknown> | null {
if (typeof value === 'string' && value.trim().length > 0) return value;
if (!isRecord(value)) return null;
const url = asTrimmedString(value.url);
if (url) return { ...value, url };
const imageUrl = asTrimmedString(value.image_url);
if (imageUrl) return imageUrl;
return Object.keys(value).length > 0 ? value : null;
}
function normalizeAudioInputValue(value: unknown): Record<string, unknown> | null {
if (!isRecord(value)) return null;
const data = asTrimmedString(value.data);
const format = asTrimmedString(value.format);
if (!data && !format) return Object.keys(value).length > 0 ? value : null;
return {
...value,
...(data ? { data } : {}),
...(format ? { format } : {}),
};
}
function normalizeResponsesContentItem(
item: unknown,
role: string,
): Record<string, unknown> | null {
if (typeof item === 'string') {
const text = item.trim();
return text ? { type: toTextBlockType(role), text } : null;
}
if (!isRecord(item)) return null;
const type = asTrimmedString(item.type).toLowerCase();
if (!type) {
const text = asTrimmedString(item.text ?? item.content ?? item.output_text);
return text ? { type: toTextBlockType(role), text } : null;
}
if (type === 'input_text' || type === 'output_text' || type === 'text') {
const text = asTrimmedString(item.text ?? item.content ?? item.output_text);
if (!text) return null;
return {
...item,
type: type === 'text' ? toTextBlockType(role) : type,
text,
};
}
if (type === 'input_image' || type === 'image_url') {
const imageUrl = normalizeImageUrlValue(item.image_url ?? item.url);
if (!imageUrl) return null;
return {
...item,
type: 'input_image',
image_url: imageUrl,
};
}
if (type === 'input_audio') {
const inputAudio = normalizeAudioInputValue(item.input_audio);
if (!inputAudio) return null;
return {
...item,
type: 'input_audio',
input_audio: inputAudio,
};
}
if (type === 'function_call' || type === 'function_call_output') {
return item;
}
return item;
}
export function normalizeResponsesMessageContent(content: unknown, role: string): unknown {
if (Array.isArray(content)) {
const normalized = content
.map((item) => normalizeResponsesContentItem(item, role))
.filter((item): item is Record<string, unknown> => !!item);
return normalized.length > 0 ? normalized : content;
}
const single = normalizeResponsesContentItem(content, role);
if (single) return [single];
return content;
}
function toResponsesInputMessageFromText(text: string): Record<string, unknown> {
return {
type: 'message',
role: 'user',
content: [{ type: 'input_text', text }],
};
}
export 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() || 'user';
const normalizedContent = normalizeResponsesMessageContent(item.content ?? item.text, role);
if (type === 'message') {
return {
...item,
role,
content: normalizedContent,
};
}
if (asTrimmedString(item.role)) {
return {
type: 'message',
role,
content: normalizedContent,
};
}
if (typeof item.content === 'string') {
const text = item.content.trim();
return text ? toResponsesInputMessageFromText(text) : item;
}
return item;
}
export 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 extractTextFromResponsesContent(content: unknown): string {
if (typeof content === 'string') return content.trim();
if (!Array.isArray(content)) return '';
return content
.map((item) => {
if (!isRecord(item)) return '';
return asTrimmedString(item.text ?? item.content ?? item.output_text);
})
.filter((item) => item.length > 0)
.join('\n');
}
function safeJsonStringify(value: unknown): string {
try {
return JSON.stringify(value);
} catch {
return '';
}
}
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 normalizeToolMessageContent(raw: unknown): string {
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)) {
const normalized = normalizeResponsesMessageContent(raw, 'user');
const text = extractTextFromResponsesContent(normalized);
return text || safeJsonStringify(raw);
}
if (isRecord(raw)) {
const normalized = normalizeResponsesMessageContent(raw, 'user');
const text = extractTextFromResponsesContent(normalized);
return text || safeJsonStringify(raw);
}
return '';
}
function toFiniteNumber(value: unknown): number | null {
return typeof value === 'number' && Number.isFinite(value) ? value : null;
}
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',
]);
export 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 normalized = normalizeResponsesMessageContent(item.content, 'user');
const content = extractTextFromResponsesContent(normalized).trim();
if (content) systemContents.push(content);
continue;
}
if (role === 'assistant') {
const normalizedAssistantContent = normalizeResponsesMessageContent(item.content, 'assistant');
if (Array.isArray(normalizedAssistantContent) && normalizedAssistantContent.length > 0) {
inputItems.push({
type: 'message',
role: 'assistant',
content: normalizedAssistantContent,
});
}
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 normalizedUserContent = normalizeResponsesMessageContent(item.content, 'user');
if (Array.isArray(normalizedUserContent) && normalizedUserContent.length > 0) {
inputItems.push({
type: 'message',
role: 'user',
content: normalizedUserContent,
});
}
}
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 = openaiBody.tools;
if (openaiBody.tool_choice !== undefined) body.tool_choice = openaiBody.tool_choice;
return {
...body,
input: normalizeResponsesInputForCompatibility(body.input),
};
}
export function sanitizeResponsesBodyForProxy(
body: Record<string, unknown>,
modelName: string,
stream: boolean,
): Record<string, unknown> {
let normalized: Record<string, unknown> = {
...body,
model: modelName,
stream,
};
if (normalized.input === undefined) {
if (Array.isArray((normalized as Record<string, unknown>).messages)) {
normalized = convertOpenAiBodyToResponsesBody(normalized, modelName, stream);
} else {
const prompt = asTrimmedString((normalized as Record<string, unknown>).prompt);
if (prompt) {
normalized = {
...normalized,
input: [toResponsesInputMessageFromText(prompt)],
};
}
}
} else {
normalized = {
...normalized,
input: normalizeResponsesInputForCompatibility(normalized.input),
};
}
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;
}
@@ -0,0 +1,261 @@
import { describe, expect, it } from 'vitest';
import {
convertOpenAiBodyToResponsesBody,
convertResponsesBodyToOpenAiBody,
sanitizeResponsesBodyForProxy,
} from './conversion.js';
describe('sanitizeResponsesBodyForProxy', () => {
it('preserves newer Responses request fields needed by the proxy', () => {
const result = sanitizeResponsesBodyForProxy(
{
model: 'gpt-5',
input: 'hello',
safety_identifier: 'safe-user-1',
max_tool_calls: 3,
prompt_cache_key: 'cache-key',
prompt_cache_retention: { scope: 'session' },
stream_options: { include_obfuscation: true },
background: true,
text: { format: { type: 'text' }, verbosity: 'high' },
},
'gpt-5',
true,
);
expect(result).toMatchObject({
model: 'gpt-5',
stream: true,
safety_identifier: 'safe-user-1',
max_tool_calls: 3,
prompt_cache_key: 'cache-key',
prompt_cache_retention: { scope: 'session' },
stream_options: { include_obfuscation: true },
background: true,
text: { format: { type: 'text' }, verbosity: 'high' },
});
});
});
describe('convertOpenAiBodyToResponsesBody', () => {
it('maps extra request fields and preserves custom/image_generation tools', () => {
const result = convertOpenAiBodyToResponsesBody(
{
model: 'gpt-5',
messages: [{ role: 'user', content: 'draw a cat' }],
safety_identifier: 'safe-user-2',
max_tool_calls: 2,
prompt_cache_key: 'prompt-key',
prompt_cache_retention: { scope: 'workspace' },
stream_options: { include_obfuscation: true },
background: false,
verbosity: 'low',
tools: [
{
type: 'custom',
name: 'browser',
description: 'browse the web',
format: { type: 'text' },
},
{
type: 'image_generation',
background: 'transparent',
size: '1024x1024',
},
],
},
'gpt-5',
false,
);
expect(result).toMatchObject({
model: 'gpt-5',
stream: false,
safety_identifier: 'safe-user-2',
max_tool_calls: 2,
prompt_cache_key: 'prompt-key',
prompt_cache_retention: { scope: 'workspace' },
stream_options: { include_obfuscation: true },
background: false,
text: { verbosity: 'low' },
tools: [
{
type: 'custom',
name: 'browser',
description: 'browse the web',
format: { type: 'text' },
},
{
type: 'image_generation',
background: 'transparent',
size: '1024x1024',
},
],
});
});
});
describe('convertResponsesBodyToOpenAiBody', () => {
it('preserves richer Responses request fields back onto the OpenAI-compatible body', () => {
const result = convertResponsesBodyToOpenAiBody(
{
model: 'gpt-5',
input: [{ type: 'message', role: 'user', content: [{ type: 'input_text', text: 'hello' }] }],
safety_identifier: 'safe-user-3',
max_tool_calls: 4,
prompt_cache_key: 'prompt-key-2',
prompt_cache_retention: { scope: 'project' },
stream_options: { include_obfuscation: true },
background: true,
text: { format: { type: 'json_object' }, verbosity: 'high' },
tools: [
{
type: 'custom',
name: 'browser',
format: { type: 'grammar', syntax: 'lark' },
},
{
type: 'image_generation',
background: 'transparent',
partial_images: 2,
output_format: 'png',
},
],
},
'gpt-5',
true,
);
expect(result).toMatchObject({
model: 'gpt-5',
stream: true,
safety_identifier: 'safe-user-3',
max_tool_calls: 4,
prompt_cache_key: 'prompt-key-2',
prompt_cache_retention: { scope: 'project' },
stream_options: { include_obfuscation: true },
background: true,
verbosity: 'high',
tools: [
{
type: 'custom',
name: 'browser',
format: { type: 'grammar', syntax: 'lark' },
},
{
type: 'image_generation',
background: 'transparent',
partial_images: 2,
output_format: 'png',
},
],
});
});
it('converts custom tool calls and outputs into OpenAI-compatible tool messages', () => {
const result = convertResponsesBodyToOpenAiBody(
{
model: 'gpt-5',
input: [
{
type: 'custom_tool_call',
id: 'ct_1',
call_id: 'ct_1',
name: 'browser',
input: 'open example.com',
},
{
type: 'custom_tool_call_output',
call_id: 'ct_1',
output: 'done',
},
],
},
'gpt-5',
false,
);
expect(result.messages).toEqual([
{
role: 'assistant',
content: '',
tool_calls: [
{
id: 'ct_1',
type: 'function',
function: {
name: 'browser',
arguments: 'open example.com',
},
},
],
},
{
role: 'tool',
tool_call_id: 'ct_1',
content: 'done',
},
]);
});
it('converts reasoning items back into assistant content instead of dropping them', () => {
const result = convertResponsesBodyToOpenAiBody(
{
model: 'gpt-5',
input: [
{
type: 'reasoning',
id: 'rs_1',
status: 'completed',
summary: [
{ type: 'summary_text', text: 'Think step by step' },
],
},
],
},
'gpt-5',
false,
);
expect(result.messages).toEqual([
{
role: 'assistant',
content: [
{
type: 'text',
text: 'Think step by step',
},
],
},
]);
});
it('preserves remaining request fields needed for OpenAI-compatible downstream fallback', () => {
const result = convertResponsesBodyToOpenAiBody(
{
model: 'gpt-5',
input: 'hello',
user: 'user-123',
include: ['reasoning.encrypted_content'],
previous_response_id: 'resp_prev',
truncation: 'auto',
service_tier: 'priority',
top_logprobs: 4,
},
'gpt-5',
true,
);
expect(result).toMatchObject({
model: 'gpt-5',
stream: true,
user: 'user-123',
include: ['reasoning.encrypted_content'],
previous_response_id: 'resp_prev',
truncation: 'auto',
service_tier: 'priority',
top_logprobs: 4,
});
});
});
@@ -0,0 +1,698 @@
import {
normalizeResponsesInputForCompatibility as normalizeResponsesInputForCompatibilityViaCompatibility,
normalizeResponsesMessageItem,
} from './compatibility.js';
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object';
}
function asTrimmedString(value: unknown): string {
return typeof value === 'string' ? value.trim() : '';
}
function toFiniteNumber(value: unknown): number | null {
return typeof value === 'number' && Number.isFinite(value) ? value : null;
}
function safeJsonStringify(value: unknown): string {
try {
return JSON.stringify(value);
} catch {
return '';
}
}
function cloneRecord(value: unknown): Record<string, unknown> | null {
if (!isRecord(value)) return null;
return { ...value };
}
function cloneJsonValue<T>(value: T): T {
if (Array.isArray(value)) {
return value.map((item) => cloneJsonValue(item)) as T;
}
if (isRecord(value)) {
return Object.fromEntries(
Object.entries(value).map(([key, item]) => [key, cloneJsonValue(item)]),
) as T;
}
return value;
}
function parseJsonString(raw: string): unknown {
const trimmed = raw.trim();
if (!trimmed) return {};
try {
return JSON.parse(trimmed);
} catch {
return { value: raw };
}
}
export function extractTextContent(value: unknown): string {
if (typeof value === 'string') return value;
if (Array.isArray(value)) {
return value
.map((item) => extractTextContent(item))
.filter((item) => item.length > 0)
.join('\n');
}
if (!isRecord(value)) return '';
const direct = asTrimmedString(
value.text
?? value.content
?? value.input_text
?? value.output_text
?? value.reasoning
?? value.reasoning_content
?? value.thinking,
);
if (direct) return direct;
if (Array.isArray(value.parts)) return extractTextContent(value.parts);
if (Array.isArray(value.content)) return extractTextContent(value.content);
if (Array.isArray(value.output)) return extractTextContent(value.output);
return '';
}
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 normalizeToolOutput(raw: unknown): string {
const text = extractTextContent(raw).trim();
if (text) return text;
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 '';
}
export function normalizeResponsesInputForCompatibility(input: unknown): unknown {
return normalizeResponsesInputForCompatibilityViaCompatibility(input);
}
function toResponsesInputMessageFromText(text: string): Record<string, unknown> {
return {
type: 'message',
role: 'user',
content: [{ type: 'input_text', text }],
};
}
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;
}
if (type === 'custom' && asTrimmedString(item.name)) {
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 normalizeResponsesBodyForCompatibility(
body: Record<string, unknown>,
): Record<string, unknown> {
const nextInput = normalizeResponsesInputForCompatibility(body.input);
if (nextInput === body.input) return body;
return {
...body,
input: nextInput,
};
}
export function normalizeResponsesMessageContent(role: string, content: unknown): Array<Record<string, unknown>> {
const normalized = normalizeResponsesMessageItem({
type: 'message',
role,
content,
});
if (isRecord(normalized) && Array.isArray(normalized.content)) {
return normalized.content.filter((item): item is Record<string, unknown> => isRecord(item));
}
return [];
}
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',
'safety_identifier',
'stream',
'stream_options',
'user',
'max_tool_calls',
'prompt_cache_key',
'prompt_cache_retention',
'background',
'previous_response_id',
'text',
'audio',
'include',
'response_format',
'service_tier',
'stop',
'n',
]);
export 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)) {
normalized = normalizeResponsesBodyForCompatibility(
convertOpenAiBodyToResponsesBody(normalized, modelName, stream),
);
} 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;
}
export 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 = extractTextContent(item.content).trim();
if (content) systemContents.push(content);
continue;
}
if (role === 'assistant') {
const normalizedContent = normalizeResponsesMessageContent('assistant', item.content);
if (normalizedContent.length > 0) {
inputItems.push({
type: 'message',
role: 'assistant',
content: normalizedContent,
});
}
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: normalizeToolOutput(item.content),
});
continue;
}
const normalizedContent = normalizeResponsesMessageContent('user', item.content);
if (normalizedContent.length <= 0) continue;
inputItems.push({
type: 'message',
role: 'user',
content: normalizedContent,
});
}
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);
if (openaiBody.safety_identifier !== undefined) body.safety_identifier = openaiBody.safety_identifier;
if (openaiBody.max_tool_calls !== undefined) body.max_tool_calls = openaiBody.max_tool_calls;
if (openaiBody.prompt_cache_key !== undefined) body.prompt_cache_key = openaiBody.prompt_cache_key;
if (openaiBody.prompt_cache_retention !== undefined) {
body.prompt_cache_retention = openaiBody.prompt_cache_retention;
}
if (openaiBody.background !== undefined) body.background = openaiBody.background;
if (openaiBody.user !== undefined) body.user = openaiBody.user;
if (openaiBody.include !== undefined) body.include = cloneJsonValue(openaiBody.include);
if (openaiBody.previous_response_id !== undefined) body.previous_response_id = openaiBody.previous_response_id;
if (openaiBody.truncation !== undefined) body.truncation = openaiBody.truncation;
if (openaiBody.service_tier !== undefined) body.service_tier = openaiBody.service_tier;
if (openaiBody.top_logprobs !== undefined) body.top_logprobs = openaiBody.top_logprobs;
if (openaiBody.stream_options !== undefined) body.stream_options = openaiBody.stream_options;
const textConfig = cloneRecord(openaiBody.text) || {};
const verbosity = (
asTrimmedString(openaiBody.verbosity)
|| asTrimmedString(isRecord(openaiBody.text) ? openaiBody.text.verbosity : undefined)
);
if (verbosity) {
textConfig.verbosity = verbosity;
}
if (Object.keys(textConfig).length > 0) {
body.text = textConfig;
}
const responsesToolChoice = convertOpenAiToolChoiceToResponses(openaiBody.tool_choice);
if (responsesToolChoice !== undefined) body.tool_choice = responsesToolChoice;
return normalizeResponsesBodyForCompatibility(body);
}
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: normalizeOpenAiToolArguments(item.arguments ?? item.input),
},
};
}
function normalizeOpenAiContentBlock(item: Record<string, unknown>): string | Record<string, unknown> | null {
const type = asTrimmedString(item.type).toLowerCase();
if (!type) {
const text = extractTextContent(item).trim();
return text ? { type: 'text', text } : null;
}
if (
type === 'input_text'
|| type === 'output_text'
|| type === 'text'
|| type === 'summary_text'
|| type === 'reasoning_text'
) {
const text = extractTextContent(item).trim();
return text ? { type: 'text', text } : null;
}
if (type === 'input_image') {
const imageUrl = item.image_url ?? item.url;
if (imageUrl === undefined) return null;
return {
type: 'image_url',
image_url: imageUrl,
};
}
if (type === 'input_audio' && item.input_audio !== undefined) {
return {
type: 'input_audio',
input_audio: item.input_audio,
};
}
if (type === 'reasoning' || type === 'thinking' || type === 'redacted_reasoning') {
const text = extractTextContent(item).trim();
return text ? { type: 'text', text } : null;
}
return item;
}
function toOpenAiMessageContent(content: unknown): string | Array<string | Record<string, unknown>> {
if (typeof content === 'string') return content;
if (!Array.isArray(content)) {
if (isRecord(content)) {
const single = normalizeOpenAiContentBlock(content);
if (!single) return '';
return typeof single === 'string' ? single : [single];
}
return '';
}
const blocks = content
.map((item) => {
if (typeof item === 'string') return item.trim() ? item : null;
if (!isRecord(item)) return null;
return normalizeOpenAiContentBlock(item);
})
.filter((item): item is string | Record<string, unknown> => !!item);
if (blocks.length === 1 && typeof blocks[0] === 'string') {
return blocks[0];
}
return blocks;
}
function convertResponsesToolsToOpenAi(rawTools: unknown): unknown {
if (!Array.isArray(rawTools)) return rawTools;
return rawTools.map((item) => {
if (!isRecord(item)) return item;
const type = asTrimmedString(item.type).toLowerCase();
if (type === 'custom' || type === 'image_generation') return item;
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,
};
});
}
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;
}
export 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;
messages.push({
role: 'tool',
tool_call_id: toolCallId,
content: normalizeToolOutput(outputRaw),
});
};
const processInputItem = (item: unknown) => {
if (typeof item === 'string') {
flushPendingToolCalls();
const text = item.trim();
if (text) messages.push({ role: 'user', content: text });
return;
}
if (!isRecord(item)) return;
const itemType = asTrimmedString(item.type).toLowerCase();
if (itemType === 'function_call' || itemType === 'custom_tool_call') {
const toolCall = toOpenAiToolCall(item, functionCallIndex);
functionCallIndex += 1;
if (toolCall) pendingToolCalls.push(toolCall);
return;
}
if (itemType === 'function_call_output' || itemType === 'custom_tool_call_output') {
flushPendingToolCalls();
pushToolOutputMessage(item.call_id ?? item.id, item.output ?? item.content);
return;
}
if (itemType === 'reasoning') {
flushPendingToolCalls();
const reasoningContent = toOpenAiMessageContent(item.summary ?? item.content ?? item);
const hasReasoningContent = typeof reasoningContent === 'string'
? reasoningContent.trim().length > 0
: Array.isArray(reasoningContent) && reasoningContent.length > 0;
if (!hasReasoningContent) return;
messages.push({
role: 'assistant',
content: reasoningContent,
});
return;
}
flushPendingToolCalls();
const role = asTrimmedString(item.role).toLowerCase() || 'user';
const normalizedRole = role === 'developer' ? 'system' : role;
const content = toOpenAiMessageContent(item.content ?? item.input ?? item);
if (normalizedRole === 'tool') {
pushToolOutputMessage(item.tool_call_id ?? item.call_id ?? item.id, item.content);
return;
}
const hasContent = typeof content === 'string'
? content.trim().length > 0
: Array.isArray(content) && content.length > 0;
if (!hasContent) return;
messages.push({
role: normalizedRole,
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);
if (body.safety_identifier !== undefined) payload.safety_identifier = body.safety_identifier;
if (body.max_tool_calls !== undefined) payload.max_tool_calls = body.max_tool_calls;
if (body.prompt_cache_key !== undefined) payload.prompt_cache_key = body.prompt_cache_key;
if (body.prompt_cache_retention !== undefined) payload.prompt_cache_retention = body.prompt_cache_retention;
if (body.background !== undefined) payload.background = body.background;
if (body.user !== undefined) payload.user = body.user;
if (body.include !== undefined) payload.include = cloneJsonValue(body.include);
if (body.previous_response_id !== undefined) payload.previous_response_id = body.previous_response_id;
if (body.truncation !== undefined) payload.truncation = body.truncation;
if (body.service_tier !== undefined) payload.service_tier = body.service_tier;
if (body.top_logprobs !== undefined) payload.top_logprobs = body.top_logprobs;
if (body.stream_options !== undefined) payload.stream_options = body.stream_options;
if (isRecord(body.text) && asTrimmedString(body.text.verbosity)) {
payload.verbosity = asTrimmedString(body.text.verbosity);
}
return payload;
}
@@ -0,0 +1,6 @@
import { normalizeResponsesInputForCompatibility, normalizeResponsesMessageItem } from './compatibility.js';
export const openAiResponsesInbound = {
normalizeInput: normalizeResponsesInputForCompatibility,
normalizeMessage: normalizeResponsesMessageItem,
};
@@ -0,0 +1,66 @@
import { type StreamTransformContext } from '../../shared/normalized.js';
import {
convertOpenAiBodyToResponsesBody,
convertResponsesBodyToOpenAiBody,
normalizeResponsesInputForCompatibility,
normalizeResponsesMessageContent,
sanitizeResponsesBodyForProxy,
} from './conversion.js';
import { normalizeResponsesMessageItem } from './compatibility.js';
import {
type OpenAiResponsesAggregateState,
completeResponsesStream,
createOpenAiResponsesAggregateState,
failResponsesStream,
serializeConvertedResponsesEvents,
} from './aggregator.js';
import { openAiResponsesOutbound } from './outbound.js';
import { openAiResponsesStream } from './stream.js';
import { openAiResponsesUsage } from './usage.js';
export const openAiResponsesTransformer = {
protocol: 'openai/responses' as const,
inbound: {
normalizeInput: normalizeResponsesInputForCompatibility,
normalizeMessage: normalizeResponsesMessageItem,
normalizeContent: normalizeResponsesMessageContent,
sanitizeProxyBody: sanitizeResponsesBodyForProxy,
fromOpenAiBody: convertOpenAiBodyToResponsesBody,
toOpenAiBody: convertResponsesBodyToOpenAiBody,
},
outbound: openAiResponsesOutbound,
stream: openAiResponsesStream,
usage: openAiResponsesUsage,
aggregator: {
createState: createOpenAiResponsesAggregateState,
serialize: serializeConvertedResponsesEvents,
complete: completeResponsesStream,
fail: failResponsesStream,
},
transformRequest(body: unknown) {
return body;
},
createStreamContext(modelName: string): StreamTransformContext {
return openAiResponsesStream.createContext(modelName);
},
transformFinalResponse(payload: unknown, modelName: string, fallbackText = '') {
return openAiResponsesOutbound.normalizeFinal(payload, modelName, fallbackText);
},
transformStreamEvent(payload: unknown, context: StreamTransformContext, modelName: string) {
return openAiResponsesStream.normalizeEvent(payload, context, modelName);
},
pullSseEvents(buffer: string) {
return openAiResponsesStream.pullSseEvents(buffer);
},
};
export type OpenAiResponsesTransformer = typeof openAiResponsesTransformer;
export type OpenAiResponsesAggregate = OpenAiResponsesAggregateState;
export {
convertOpenAiBodyToResponsesBody,
convertResponsesBodyToOpenAiBody,
normalizeResponsesInputForCompatibility,
normalizeResponsesMessageContent,
normalizeResponsesMessageItem,
sanitizeResponsesBodyForProxy,
};
@@ -0,0 +1,182 @@
import {
normalizeUpstreamFinalResponse,
type NormalizedFinalResponse,
} from '../../shared/normalized.js';
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object';
}
function asTrimmedString(value: unknown): string {
return typeof value === 'string' ? value.trim() : '';
}
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 '';
}
}
export type ResponsesUsageSummary = {
promptTokens: number;
completionTokens: number;
totalTokens: number;
};
export type ResponsesToolCall = {
id: string;
name: string;
arguments: string;
};
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): 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): item is ResponsesToolCall => !!item);
}
return [];
}
export function normalizeResponsesFinalPayload(
payload: unknown,
modelName: string,
fallbackText = '',
): NormalizedFinalResponse {
return normalizeUpstreamFinalResponse(payload, modelName, fallbackText);
}
export function serializeResponsesFinalPayload(input: {
upstreamPayload: unknown;
normalized: NormalizedFinalResponse;
usage: ResponsesUsageSummary;
}): Record<string, unknown> {
const { upstreamPayload, normalized, usage } = input;
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.reasoningContent) {
output.push({
id: ensureMessageId(`${normalizedId}_reasoning`),
type: 'reasoning',
status: 'completed',
summary: [{
type: 'summary_text',
text: normalized.reasoningContent,
}],
});
}
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,
},
};
}
export const openAiResponsesOutbound = {
normalizeFinal: normalizeResponsesFinalPayload,
serializeFinal: serializeResponsesFinalPayload,
};
export {
serializeResponsesFinalPayload as toResponsesPayload,
};
@@ -0,0 +1,80 @@
import {
createStreamTransformContext,
normalizeUpstreamStreamEvent,
pullSseEventsWithDone,
type NormalizedStreamEvent,
type StreamTransformContext,
} from '../../shared/normalized.js';
import {
completeResponsesStream,
createOpenAiResponsesAggregateState,
failResponsesStream,
serializeConvertedResponsesEvents,
type OpenAiResponsesAggregateState,
} from './aggregator.js';
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object';
}
export type OpenAiResponsesStreamEvent = NormalizedStreamEvent & {
responsesEventType?: string;
responsesPayload?: Record<string, unknown>;
usagePayload?: Record<string, unknown>;
};
export const openAiResponsesStream = {
eventNames: [
'response.created',
'response.in_progress',
'response.output_item.added',
'response.output_item.done',
'response.content_part.added',
'response.content_part.done',
'response.output_text.delta',
'response.output_text.done',
'response.function_call_arguments.delta',
'response.function_call_arguments.done',
'response.custom_tool_call_input.delta',
'response.custom_tool_call_input.done',
'response.reasoning_summary_part.added',
'response.reasoning_summary_part.done',
'response.reasoning_summary_text.delta',
'response.reasoning_summary_text.done',
'response.image_generation_call.generating',
'response.image_generation_call.in_progress',
'response.image_generation_call.partial_image',
'response.image_generation_call.completed',
'response.completed',
'response.failed',
] as const,
createContext(modelName: string): StreamTransformContext {
return createStreamTransformContext(modelName);
},
normalizeEvent(payload: unknown, context: StreamTransformContext, modelName: string): OpenAiResponsesStreamEvent {
const normalized = normalizeUpstreamStreamEvent(payload, context, modelName) as OpenAiResponsesStreamEvent;
if (isRecord(payload) && typeof payload.type === 'string' && payload.type.startsWith('response.')) {
normalized.responsesEventType = payload.type;
normalized.responsesPayload = payload;
}
if (isRecord(payload) && isRecord(payload.usage)) {
normalized.usagePayload = payload.usage;
}
return normalized;
},
pullSseEvents(buffer: string) {
return pullSseEventsWithDone(buffer);
},
};
export type ResponsesStreamState = OpenAiResponsesAggregateState;
export function createResponsesStreamState(modelName: string): ResponsesStreamState {
return createOpenAiResponsesAggregateState(modelName);
}
export {
completeResponsesStream,
failResponsesStream,
serializeConvertedResponsesEvents,
};
@@ -0,0 +1,6 @@
import { createEmptyNormalizedUsage, mergeNormalizedUsage } from '../../shared/normalized.js';
export const openAiResponsesUsage = {
empty: createEmptyNormalizedUsage,
merge: mergeNormalizedUsage,
};
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,110 @@
import { readFileSync } from 'node:fs';
import { describe, expect, it } from 'vitest';
import {
normalizeStopReason,
normalizeUpstreamFinalResponse,
pullSseEventsWithDone,
serializeFinalResponse,
type NormalizedFinalResponse,
} from './normalized.js';
describe('shared normalized helpers', () => {
it('does not depend on route-level chatFormats helpers', () => {
const source = readFileSync(new URL('./normalized.ts', import.meta.url), 'utf8');
expect(source).not.toContain('routes/proxy/chatFormats');
expect(source).not.toContain('chatFormats.js');
});
it('parses SSE events and keeps the trailing partial block', () => {
const pulled = pullSseEventsWithDone([
'event: message',
'data: {"id":"1"}',
'',
'data: [DONE]',
'',
'data: {"partial":true}',
].join('\n'));
expect(pulled.events).toEqual([
{ event: 'message', data: '{"id":"1"}' },
{ event: '', data: '[DONE]' },
]);
expect(pulled.rest).toBe('data: {"partial":true}');
});
it('normalizes responses payloads with tool calls', () => {
expect(normalizeUpstreamFinalResponse({
object: 'response',
id: 'resp_1',
model: 'gpt-test',
created: 123,
output: [
{
type: 'message',
content: [{ type: 'output_text', text: 'hello' }],
},
{
type: 'function_call',
call_id: 'call_1',
name: 'lookup',
arguments: '{"q":"x"}',
},
],
status: 'completed',
}, 'fallback-model')).toEqual({
id: 'resp_1',
model: 'gpt-test',
created: 123,
content: 'hello',
reasoningContent: '',
finishReason: 'tool_calls',
toolCalls: [{
id: 'call_1',
name: 'lookup',
arguments: '{"q":"x"}',
}],
});
});
it('serializes normalized final responses for claude', () => {
const normalized: NormalizedFinalResponse = {
id: 'chatcmpl-1',
model: 'claude-test',
created: 456,
content: 'done',
reasoningContent: 'thinking',
finishReason: 'tool_calls',
toolCalls: [{ id: 'tool_1', name: 'lookup', arguments: '{"q":"x"}' }],
};
expect(serializeFinalResponse('claude', normalized, {
promptTokens: 10,
completionTokens: 5,
totalTokens: 15,
})).toEqual({
id: 'msg_chatcmpl-1',
type: 'message',
role: 'assistant',
model: 'claude-test',
content: [
{ type: 'thinking', thinking: 'thinking' },
{ type: 'text', text: 'done' },
{ type: 'tool_use', id: 'tool_1', name: 'lookup', input: { q: 'x' } },
],
stop_reason: 'tool_use',
stop_sequence: null,
usage: {
input_tokens: 10,
output_tokens: 5,
},
});
});
it('normalizes known stop reasons', () => {
expect(normalizeStopReason('max_output_tokens')).toBe('length');
expect(normalizeStopReason('tool_use')).toBe('tool_calls');
expect(normalizeStopReason('completed')).toBe('stop');
expect(normalizeStopReason('mystery')).toBeNull();
});
});
@@ -0,0 +1,136 @@
export {
buildSyntheticOpenAiChunks,
createClaudeDownstreamContext,
createStreamTransformContext,
normalizeStopReason,
normalizeUpstreamFinalResponse,
normalizeUpstreamStreamEvent,
parseDownstreamChatRequest,
pullSseEventsWithDone,
serializeFinalResponse,
serializeNormalizedStreamEvent,
serializeStreamDone,
toClaudeStopReason,
type ClaudeDownstreamContext,
type DownstreamFormat,
type NormalizedFinalResponse,
type NormalizedStreamEvent,
type ParsedDownstreamChatRequest,
type ParsedSseEvent,
type StreamTransformContext,
} from './chatFormatsCore.js';
export type NormalizedContentBlockType =
| 'text'
| 'image_url'
| 'image_inline'
| 'input_audio'
| 'output_audio'
| 'tool_call'
| 'tool_result'
| 'function_response'
| 'reasoning'
| 'redacted_reasoning';
export type NormalizedContentBlock = {
type: NormalizedContentBlockType;
role?: string | null;
text?: string | null;
mimeType?: string | null;
url?: string | null;
data?: string | null;
toolName?: string | null;
toolCallId?: string | null;
argumentsText?: string | null;
result?: unknown;
metadata?: Record<string, unknown> | null;
};
export type NormalizedUsage = {
promptTokens?: number | null;
completionTokens?: number | null;
totalTokens?: number | null;
cachedTokens?: number | null;
reasoningTokens?: number | null;
audioInputTokens?: number | null;
audioOutputTokens?: number | null;
acceptedPredictionTokens?: number | null;
rejectedPredictionTokens?: number | null;
};
export type ParsedDownstreamChatRequestResult = {
value?: import('./chatFormatsCore.js').ParsedDownstreamChatRequest;
error?: { statusCode: number; payload: unknown };
};
export type TransformerMetadata = {
include?: unknown;
maxToolCalls?: number | null;
promptCacheRetention?: unknown;
includeObfuscation?: boolean | null;
citations?: unknown;
annotations?: unknown;
geminiSafetySettings?: unknown;
geminiImageConfig?: unknown;
thoughtSignature?: string | null;
passthrough?: Record<string, unknown>;
};
export type NormalizedRequest = {
protocol: import('./chatFormatsCore.js').DownstreamFormat | 'responses' | 'gemini';
model: string;
stream: boolean;
rawBody: unknown;
parsed: import('./chatFormatsCore.js').ParsedDownstreamChatRequest | null;
contentBlocks?: NormalizedContentBlock[];
metadata?: TransformerMetadata;
};
export type NormalizedResponseEnvelope = {
protocol: import('./chatFormatsCore.js').DownstreamFormat | 'responses' | 'gemini';
model: string;
final: import('./chatFormatsCore.js').NormalizedFinalResponse;
usage?: unknown;
contentBlocks?: NormalizedContentBlock[];
metadata?: TransformerMetadata;
};
export type NormalizedStreamEnvelope = {
protocol: import('./chatFormatsCore.js').DownstreamFormat | 'responses' | 'gemini';
model: string;
event: import('./chatFormatsCore.js').NormalizedStreamEvent;
metadata?: TransformerMetadata;
};
export function createEmptyNormalizedUsage(): NormalizedUsage {
return {
promptTokens: 0,
completionTokens: 0,
totalTokens: 0,
cachedTokens: 0,
reasoningTokens: 0,
audioInputTokens: 0,
audioOutputTokens: 0,
acceptedPredictionTokens: 0,
rejectedPredictionTokens: 0,
};
}
export function mergeNormalizedUsage(
base: NormalizedUsage | undefined,
next: NormalizedUsage | undefined,
): NormalizedUsage {
const merged = { ...createEmptyNormalizedUsage(), ...(base || {}) };
const incoming = next || {};
return {
promptTokens: (merged.promptTokens || 0) + (incoming.promptTokens || 0),
completionTokens: (merged.completionTokens || 0) + (incoming.completionTokens || 0),
totalTokens: (merged.totalTokens || 0) + (incoming.totalTokens || 0),
cachedTokens: (merged.cachedTokens || 0) + (incoming.cachedTokens || 0),
reasoningTokens: (merged.reasoningTokens || 0) + (incoming.reasoningTokens || 0),
audioInputTokens: (merged.audioInputTokens || 0) + (incoming.audioInputTokens || 0),
audioOutputTokens: (merged.audioOutputTokens || 0) + (incoming.audioOutputTokens || 0),
acceptedPredictionTokens: (merged.acceptedPredictionTokens || 0) + (incoming.acceptedPredictionTokens || 0),
rejectedPredictionTokens: (merged.rejectedPredictionTokens || 0) + (incoming.rejectedPredictionTokens || 0),
};
}
+7 -5
View File
@@ -5,6 +5,12 @@ import SearchModal from './components/SearchModal.js';
import NotificationPanel from './components/NotificationPanel.js'; import NotificationPanel from './components/NotificationPanel.js';
import { api } from './api.js'; import { api } from './api.js';
import { clearAuthSession, hasValidAuthSession, persistAuthSession } from './authSession.js'; import { clearAuthSession, hasValidAuthSession, persistAuthSession } from './authSession.js';
import {
FIRST_USE_DOC_REMINDER_KEY,
LEGACY_THEME_STORAGE_KEY,
THEME_MODE_STORAGE_KEY,
USER_PROFILE_STORAGE_KEY,
} from './appLocalState.js';
import { I18nProvider, useI18n } from './i18n.js'; import { I18nProvider, useI18n } from './i18n.js';
import { resolveLoginErrorMessage } from './loginError.js'; import { resolveLoginErrorMessage } from './loginError.js';
import { SITE_DOCS_URL } from './docsLink.js'; import { SITE_DOCS_URL } from './docsLink.js';
@@ -32,10 +38,6 @@ type UserProfile = {
avatarSeed: string; avatarSeed: string;
avatarStyle: string; avatarStyle: string;
}; };
const THEME_MODE_STORAGE_KEY = 'theme_mode';
const USER_PROFILE_STORAGE_KEY = 'user_profile';
const FIRST_USE_DOC_REMINDER_KEY = 'metapi_first_use_docs_reminder_seen_v1';
const DICEBEAR_STYLES = [ const DICEBEAR_STYLES = [
'pixel-art', 'pixel-art',
'pixel-art-neutral', 'pixel-art-neutral',
@@ -56,7 +58,7 @@ type DicebearStyle = typeof DICEBEAR_STYLES[number];
function resolveStoredThemeMode(): ThemeMode { function resolveStoredThemeMode(): ThemeMode {
const saved = localStorage.getItem(THEME_MODE_STORAGE_KEY); const saved = localStorage.getItem(THEME_MODE_STORAGE_KEY);
if (saved === 'system' || saved === 'light' || saved === 'dark') return saved; if (saved === 'system' || saved === 'light' || saved === 'dark') return saved;
const legacy = localStorage.getItem('theme'); const legacy = localStorage.getItem(LEGACY_THEME_STORAGE_KEY);
if (legacy === 'light' || legacy === 'dark') return legacy; if (legacy === 'light' || legacy === 'dark') return legacy;
return 'system'; return 'system';
} }
+94 -8
View File
@@ -87,7 +87,7 @@ async function request(url: string, options: RequestOptions = {}) {
type TestChatRequestPayload = { type TestChatRequestPayload = {
model: string; model: string;
messages: Array<{ role: string; content: string }>; messages: Array<{ role: string; content: string }>;
targetFormat?: 'openai' | 'claude' | 'responses'; targetFormat?: 'openai' | 'claude' | 'responses' | 'gemini';
stream?: boolean; stream?: boolean;
temperature?: number; temperature?: number;
top_p?: number; top_p?: number;
@@ -97,6 +97,39 @@ type TestChatRequestPayload = {
seed?: number; seed?: number;
}; };
export type ProxyTestMethod = 'POST' | 'GET' | 'DELETE';
export type ProxyTestRequestKind = 'json' | 'multipart' | 'empty';
export type ProxyTestMultipartFile = {
field: string;
name: string;
mimeType: string;
dataUrl: string;
};
export type ProxyTestRequestEnvelope = {
method: ProxyTestMethod;
path: string;
requestKind: ProxyTestRequestKind;
stream?: boolean;
jobMode?: boolean;
rawMode?: boolean;
jsonBody?: unknown;
rawJsonText?: string;
multipartFields?: Record<string, string>;
multipartFiles?: ProxyTestMultipartFile[];
};
export type ProxyTestJobResponse = {
jobId: string;
status: 'pending' | 'succeeded' | 'failed' | 'cancelled';
result?: unknown;
error?: unknown;
createdAt?: string;
updatedAt?: string;
expiresAt?: string;
};
export const api = { export const api = {
// Sites // Sites
getSites: () => request('/api/sites'), getSites: () => request('/api/sites'),
@@ -161,17 +194,29 @@ export const api = {
timeoutMs: wait ? 150_000 : 30_000, timeoutMs: wait ? 150_000 : 30_000,
}), }),
getRouteDecision: (model: string) => request(`/api/routes/decision?model=${encodeURIComponent(model)}`), getRouteDecision: (model: string) => request(`/api/routes/decision?model=${encodeURIComponent(model)}`),
getRouteDecisionsBatch: (models: string[]) => request('/api/routes/decision/batch', { getRouteDecisionsBatch: (models: string[], options?: { refreshPricingCatalog?: boolean; persistSnapshots?: boolean }) => request('/api/routes/decision/batch', {
method: 'POST', method: 'POST',
body: JSON.stringify({ models }), body: JSON.stringify({
models,
...(options?.refreshPricingCatalog ? { refreshPricingCatalog: true } : {}),
...(options?.persistSnapshots ? { persistSnapshots: true } : {}),
}), }),
getRouteDecisionsByRouteBatch: (items: Array<{ routeId: number; model: string }>) => request('/api/routes/decision/by-route/batch', {
method: 'POST',
body: JSON.stringify({ items }),
}), }),
getRouteWideDecisionsBatch: (routeIds: number[]) => request('/api/routes/decision/route-wide/batch', { getRouteDecisionsByRouteBatch: (items: Array<{ routeId: number; model: string }>, options?: { refreshPricingCatalog?: boolean; persistSnapshots?: boolean }) => request('/api/routes/decision/by-route/batch', {
method: 'POST', method: 'POST',
body: JSON.stringify({ routeIds }), body: JSON.stringify({
items,
...(options?.refreshPricingCatalog ? { refreshPricingCatalog: true } : {}),
...(options?.persistSnapshots ? { persistSnapshots: true } : {}),
}),
}),
getRouteWideDecisionsBatch: (routeIds: number[], options?: { refreshPricingCatalog?: boolean; persistSnapshots?: boolean }) => request('/api/routes/decision/route-wide/batch', {
method: 'POST',
body: JSON.stringify({
routeIds,
...(options?.refreshPricingCatalog ? { refreshPricingCatalog: true } : {}),
...(options?.persistSnapshots ? { persistSnapshots: true } : {}),
}),
}), }),
// Stats // Stats
@@ -246,6 +291,7 @@ export const api = {
}), }),
clearRuntimeCache: () => request('/api/settings/maintenance/clear-cache', { method: 'POST' }), clearRuntimeCache: () => request('/api/settings/maintenance/clear-cache', { method: 'POST' }),
clearUsageData: () => request('/api/settings/maintenance/clear-usage', { method: 'POST' }), clearUsageData: () => request('/api/settings/maintenance/clear-usage', { method: 'POST' }),
factoryReset: () => request('/api/settings/maintenance/factory-reset', { method: 'POST' }),
testNotification: () => request('/api/settings/notify/test', { method: 'POST' }), testNotification: () => request('/api/settings/notify/test', { method: 'POST' }),
// Monitor embed // Monitor embed
@@ -271,8 +317,48 @@ export const api = {
request('/api/test/chat/jobs', { method: 'POST', body: JSON.stringify(data) }), request('/api/test/chat/jobs', { method: 'POST', body: JSON.stringify(data) }),
getTestChatJob: (jobId: string) => request(`/api/test/chat/jobs/${encodeURIComponent(jobId)}`), getTestChatJob: (jobId: string) => request(`/api/test/chat/jobs/${encodeURIComponent(jobId)}`),
deleteTestChatJob: (jobId: string) => request(`/api/test/chat/jobs/${encodeURIComponent(jobId)}`, { method: 'DELETE' }), deleteTestChatJob: (jobId: string) => request(`/api/test/chat/jobs/${encodeURIComponent(jobId)}`, { method: 'DELETE' }),
startProxyTestJob: (data: ProxyTestRequestEnvelope) =>
request('/api/test/proxy/jobs', { method: 'POST', body: JSON.stringify(data) }),
getProxyTestJob: (jobId: string) => request(`/api/test/proxy/jobs/${encodeURIComponent(jobId)}`),
deleteProxyTestJob: (jobId: string) => request(`/api/test/proxy/jobs/${encodeURIComponent(jobId)}`, { method: 'DELETE' }),
testProxy: (data: ProxyTestRequestEnvelope) =>
request('/api/test/proxy', { method: 'POST', body: JSON.stringify(data) }),
proxyTest: (data: ProxyTestRequestEnvelope) =>
request('/api/test/proxy', { method: 'POST', body: JSON.stringify(data) }),
testChat: (data: TestChatRequestPayload) => testChat: (data: TestChatRequestPayload) =>
request('/api/test/chat', { method: 'POST', body: JSON.stringify(data) }), request('/api/test/chat', { method: 'POST', body: JSON.stringify(data) }),
testProxyStream: async (data: ProxyTestRequestEnvelope, signal?: AbortSignal) => {
const token = getAuthToken(localStorage);
if (!token) {
clearAuthSession(localStorage);
throw new Error('Session expired');
}
return fetch('/api/test/proxy/stream', {
method: 'POST',
signal,
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
});
},
proxyTestStream: async (data: ProxyTestRequestEnvelope, signal?: AbortSignal) => {
const token = getAuthToken(localStorage);
if (!token) {
clearAuthSession(localStorage);
throw new Error('Session expired');
}
return fetch('/api/test/proxy/stream', {
method: 'POST',
signal,
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
});
},
testChatStream: async (data: TestChatRequestPayload, signal?: AbortSignal) => { testChatStream: async (data: TestChatRequestPayload, signal?: AbortSignal) => {
const token = getAuthToken(localStorage); const token = getAuthToken(localStorage);
if (!token) { if (!token) {
+28
View File
@@ -0,0 +1,28 @@
import { clearAuthSession } from './authSession.js';
export const THEME_MODE_STORAGE_KEY = 'theme_mode';
export const LEGACY_THEME_STORAGE_KEY = 'theme';
export const USER_PROFILE_STORAGE_KEY = 'user_profile';
export const FIRST_USE_DOC_REMINDER_KEY = 'metapi_first_use_docs_reminder_seen_v1';
type StorageLike = {
getItem?: (key: string) => string | null;
setItem?: (key: string, value: string) => void;
removeItem: (key: string) => void;
};
function resolveStorage(storage?: StorageLike | null): StorageLike | null {
if (storage) return storage;
if (typeof localStorage !== 'undefined') return localStorage;
return null;
}
export function clearAppInstallationState(storage?: StorageLike | null): void {
const target = resolveStorage(storage);
if (!target) return;
clearAuthSession(target as never);
target.removeItem(THEME_MODE_STORAGE_KEY);
target.removeItem(LEGACY_THEME_STORAGE_KEY);
target.removeItem(USER_PROFILE_STORAGE_KEY);
target.removeItem(FIRST_USE_DOC_REMINDER_KEY);
}
@@ -0,0 +1,14 @@
import { describe, expect, it } from 'vitest';
import { create } from 'react-test-renderer';
import { BrandIcon } from './BrandIcon.js';
describe('BrandIcon rendering', () => {
it('does not force a colored background shell for recognized brands', () => {
const root = create(<BrandIcon model="nvidia/vila" size={44} />);
const wrappers = root.root.findAll((node) => node.type === 'div');
const brandedWrapper = wrappers.find((node) => node.props.style?.width === 44 && node.props.style?.height === 44);
expect(brandedWrapper).toBeDefined();
expect(brandedWrapper?.props.style?.background).not.toBe('linear-gradient(135deg, #76b900, #4a8c0b)');
});
});
+34
View File
@@ -17,7 +17,41 @@ describe('getBrand', () => {
expect(getBrand('openrouter/google/gemini-2.5-pro')?.name).toBe('Google'); expect(getBrand('openrouter/google/gemini-2.5-pro')?.name).toBe('Google');
}); });
it('detects issue #38 existing-brand additions', () => {
expect(getBrand('google/shieldgemma-9b')).toMatchObject({ name: 'Google', icon: expect.any(String) });
expect(getBrand('PaLM-2')).toMatchObject({ name: 'Google', icon: expect.any(String) });
expect(getBrand('imagen3-fast')).toMatchObject({ name: 'Google', icon: expect.any(String) });
expect(getBrand('veo3-pro')).toMatchObject({ name: 'Google', icon: expect.any(String) });
expect(getBrand('davinci-002')).toMatchObject({ name: 'OpenAI', icon: expect.any(String) });
expect(getBrand('babbage-002')).toMatchObject({ name: 'OpenAI', icon: expect.any(String) });
expect(getBrand('codex-mini-2025-05-16')).toMatchObject({ name: 'OpenAI', icon: expect.any(String) });
expect(getBrand('ds-chat')).toMatchObject({ name: 'DeepSeek', icon: expect.any(String) });
expect(getBrand('tongyi-deepresearch-30b-a3b')).toMatchObject({ name: '通义千问', icon: expect.any(String) });
expect(getBrand('microsoft/kosmos-2')).toMatchObject({ name: 'Microsoft', icon: expect.any(String) });
expect(getBrand('phi4')).toMatchObject({ name: 'Microsoft', icon: expect.any(String) });
expect(getBrand('stablediffusion3.5-l')).toMatchObject({ name: 'Stability', icon: expect.any(String) });
expect(getBrand('sd3-medium')).toMatchObject({ name: 'Stability', icon: expect.any(String) });
expect(getBrand('tencent-hunyuanvideo-hd')).toMatchObject({ name: '腾讯混元', icon: expect.any(String) });
expect(getBrand('mini2.1')).toMatchObject({ name: 'MiniMax', icon: expect.any(String) });
expect(getBrand('stepfun-ai/step3')).toMatchObject({ name: '阶跃星辰', icon: expect.any(String) });
});
it('detects issue #38 newly added brands with registered icons', () => {
expect(getBrand('nvidia/vila')).toMatchObject({ name: 'NVIDIA', icon: expect.any(String) });
expect(getBrand('ibm/granite-3.3-8b-instruct')).toMatchObject({ name: 'IBM', icon: expect.any(String) });
expect(getBrand('BAAI/bge-m3')).toMatchObject({ name: 'BAAI', icon: expect.any(String) });
expect(getBrand('bytedance/seed-oss-36b-instruct')).toMatchObject({ name: 'ByteDance', icon: expect.any(String) });
expect(getBrand('internlm/internlm2_5-7b-chat')).toMatchObject({ name: 'InternLM', icon: expect.any(String) });
expect(getBrand('mj_turbo')).toMatchObject({ name: 'Midjourney', icon: expect.any(String) });
expect(getBrand('deepl-zh-en')).toMatchObject({ name: 'DeepL', icon: expect.any(String) });
expect(getBrand('jina-embeddings-v3')).toMatchObject({ name: 'Jina AI', icon: expect.any(String) });
});
it('returns null for unknown model names', () => { it('returns null for unknown model names', () => {
expect(getBrand('totally-unknown-model')).toBeNull(); expect(getBrand('totally-unknown-model')).toBeNull();
}); });
it('does not misclassify GPTQ llama variants as OpenAI', () => {
expect(getBrand('TheBloke/Llama-2-7B-GPTQ')?.name).toBe('Meta');
});
}); });
+405 -138
View File
@@ -1,9 +1,39 @@
import React, { useState, useEffect } from 'react'; import React, { useEffect, useState, type CSSProperties } from 'react';
const ICON_CDN = 'https://registry.npmmirror.com/@lobehub/icons-static-png/latest/files/dark';
const ICON_CDN_LIGHT = 'https://registry.npmmirror.com/@lobehub/icons-static-png/latest/files/light'; const BRAND_ICON_VERSION = '1.83.0';
const ICON_CDN = `https://registry.npmmirror.com/@lobehub/icons-static-png/${BRAND_ICON_VERSION}/files/dark`;
const ICON_CDN_LIGHT = `https://registry.npmmirror.com/@lobehub/icons-static-png/${BRAND_ICON_VERSION}/files/light`;
const LEGACY_ICON_ALIASES: Record<string, string> = {
'anthropic': 'claude-color',
'claude.color': 'claude-color',
'cohere.color': 'cohere-color',
'doubao.color': 'doubao-color',
'gemini.color': 'gemini-color',
'hunyuan.color': 'hunyuan-color',
'meta': 'meta-color',
'meta-brand-color': 'meta-color',
'minimax.color': 'minimax-color',
'qwen.color': 'qwen-color',
'spark.color': 'spark-color',
'stability': 'stability-color',
'stability-brand-color': 'stability-color',
'stepfun': 'stepfun-color',
'wenxin.color': 'wenxin-color',
'xai': 'xai',
'yi.color': 'yi-color',
'zhipu.color': 'zhipu-color',
'azure': 'microsoft-color',
'bytedance-brand-color': 'bytedance-color',
};
export function useIconCdn() { export function useIconCdn() {
const [isDark, setIsDark] = useState(() => document.documentElement.getAttribute('data-theme') === 'dark'); const [isDark, setIsDark] = useState(() => {
if (typeof document === 'undefined') return false;
return document.documentElement.getAttribute('data-theme') === 'dark';
});
useEffect(() => { useEffect(() => {
if (typeof document === 'undefined' || typeof MutationObserver === 'undefined') return undefined;
const observer = new MutationObserver(() => { const observer = new MutationObserver(() => {
setIsDark(document.documentElement.getAttribute('data-theme') === 'dark'); setIsDark(document.documentElement.getAttribute('data-theme') === 'dark');
}); });
@@ -13,92 +43,297 @@ export function useIconCdn() {
return isDark ? ICON_CDN : ICON_CDN_LIGHT; return isDark ? ICON_CDN : ICON_CDN_LIGHT;
} }
export interface BrandMatchContext {
raw: string;
cleaned: string;
segments: string[];
}
interface InternalBrandMatchContext extends BrandMatchContext {
candidates: string[];
}
export interface BrandInfo { export interface BrandInfo {
name: string; name: string;
icon: string; icon: string;
color: string; color: string;
} }
const BRAND_MAP: Array<{ prefixes: string[]; brand: BrandInfo }> = [ type BrandDefinition = BrandInfo & {
match: (context: InternalBrandMatchContext) => boolean;
};
function normalizeInput(value: string): string {
return String(value || '').trim().toLowerCase();
}
function stripCommonWrappers(value: string): string {
return value
.replace(/^(?:\[[^\]]+\]|【[^】]+】)\s*/g, '')
.replace(/^re:\s*/g, '')
.replace(/^\^+/, '')
.replace(/\$+$/, '')
.trim();
}
function collectBrandCandidates(modelName: string): string[] {
const queue: string[] = [];
const seen = new Set<string>();
const push = (value: string) => {
const normalized = normalizeInput(value);
if (!normalized || seen.has(normalized)) return;
seen.add(normalized);
queue.push(normalized);
};
push(modelName);
for (let index = 0; index < queue.length; index += 1) {
const candidate = queue[index]!;
const cleaned = stripCommonWrappers(candidate);
push(cleaned);
if (cleaned.includes('/')) {
for (const part of cleaned.split('/')) push(part);
}
if (cleaned.includes(':')) {
for (const part of cleaned.split(':')) push(part);
}
if (cleaned.includes(',')) {
for (const part of cleaned.split(',')) push(part);
}
}
return queue;
}
function buildMatchContext(modelName: string): InternalBrandMatchContext {
const candidates = collectBrandCandidates(modelName);
const raw = candidates[0] || normalizeInput(modelName);
const cleaned = stripCommonWrappers(raw);
const segments = Array.from(new Set(
candidates
.flatMap((candidate) => candidate.split(/[/:,\s]+/g))
.map((segment) => segment.trim())
.filter(Boolean),
));
return {
raw,
cleaned,
segments,
candidates,
};
}
function includesAny(context: InternalBrandMatchContext, needles: string[]): boolean {
return needles.some((needle) => (
context.raw.includes(needle)
|| context.cleaned.includes(needle)
|| context.candidates.some((candidate) => candidate.includes(needle))
));
}
function startsWithAny(context: InternalBrandMatchContext, needles: string[]): boolean {
return needles.some((needle) => (
context.raw.startsWith(needle)
|| context.cleaned.startsWith(needle)
|| context.segments.some((segment) => segment.startsWith(needle))
|| context.candidates.some((candidate) => candidate.startsWith(needle))
));
}
function hasExactSegment(context: InternalBrandMatchContext, needles: string[]): boolean {
return needles.some((needle) => context.segments.includes(needle));
}
function matchesBoundary(context: InternalBrandMatchContext, pattern: RegExp): boolean {
return pattern.test(context.raw) || pattern.test(context.cleaned) || context.candidates.some((candidate) => pattern.test(candidate));
}
const BRAND_DEFINITIONS: BrandDefinition[] = [
{ {
prefixes: ['gpt', 'o1', 'o3', 'o4', 'chatgpt', 'dall-e', 'text-embedding', 'text-davinci', 'text-curie', 'text-babbage', 'text-ada', 'whisper', 'tts'], name: 'OpenAI',
brand: { name: 'OpenAI', icon: 'openai', color: 'linear-gradient(135deg, #10a37f, #1a7f5a)' } icon: 'openai',
color: 'linear-gradient(135deg, #10a37f, #1a7f5a)',
match: (context) => (
startsWithAny(context, ['gpt', 'chatgpt', 'dall-e', 'whisper', 'text-embedding', 'text-moderation', 'davinci', 'babbage', 'codex-mini'])
|| startsWithAny(context, ['o1', 'o3', 'o4', 'tts'])
),
}, },
{ {
prefixes: ['claude'], name: 'Anthropic',
brand: { name: 'Anthropic', icon: 'claude.color', color: 'linear-gradient(135deg, #d4a574, #c4956a)' } icon: 'claude-color',
color: 'linear-gradient(135deg, #d4a574, #c4956a)',
match: (context) => includesAny(context, ['claude']),
}, },
{ {
prefixes: ['gemini', 'gemma'], name: 'Google',
brand: { name: 'Google', icon: 'gemini.color', color: 'linear-gradient(135deg, #4285f4, #34a853)' } icon: 'gemini-color',
color: 'linear-gradient(135deg, #4285f4, #34a853)',
match: (context) => (
includesAny(context, ['gemini', 'gemma', 'google/', 'palm', 'paligemma', 'shieldgemma', 'recurrentgemma', 'deplot', 'codegemma', 'imagen', 'learnlm', 'aqa'])
|| startsWithAny(context, ['veo', 'google/'])
),
}, },
{ {
prefixes: ['deepseek'], name: 'DeepSeek',
brand: { name: 'DeepSeek', icon: 'deepseek.color', color: 'linear-gradient(135deg, #4d6bfe, #44a3ec)' } icon: 'deepseek-color',
color: 'linear-gradient(135deg, #4d6bfe, #44a3ec)',
match: (context) => includesAny(context, ['deepseek']) || hasExactSegment(context, ['ds-chat']),
}, },
{ {
prefixes: ['qwen', 'qwq'], name: '通义千问',
brand: { name: '通义千问', icon: 'qwen.color', color: 'linear-gradient(135deg, #615cf7, #9b8afb)' } icon: 'qwen-color',
color: 'linear-gradient(135deg, #615cf7, #9b8afb)',
match: (context) => includesAny(context, ['qwen', 'qwq', 'tongyi']),
}, },
{ {
prefixes: ['glm', 'chatglm', 'codegeex', 'cogview'], name: '智谱 AI',
brand: { name: '智谱 AI', icon: 'zhipu.color', color: 'linear-gradient(135deg, #3b6cf5, #6366f1)' } icon: 'zhipu-color',
color: 'linear-gradient(135deg, #3b6cf5, #6366f1)',
match: (context) => includesAny(context, ['glm', 'chatglm', 'codegeex', 'cogview', 'cogvideo']),
}, },
{ {
prefixes: ['llama', 'code-llama', 'codellama'], name: 'Meta',
brand: { name: 'Meta', icon: 'meta', color: 'linear-gradient(135deg, #0668E1, #1877f2)' } icon: 'meta-color',
color: 'linear-gradient(135deg, #0668E1, #1877f2)',
match: (context) => includesAny(context, ['llama', 'code-llama', 'codellama']),
}, },
{ {
prefixes: ['mistral', 'mixtral', 'codestral', 'pixtral', 'ministral'], name: 'Mistral',
brand: { name: 'Mistral', icon: 'mistral.color', color: 'linear-gradient(135deg, #f7d046, #f2a900)' } icon: 'mistral-color',
color: 'linear-gradient(135deg, #f7d046, #f2a900)',
match: (context) => includesAny(context, ['mistral', 'mixtral', 'codestral', 'pixtral', 'ministral', 'voxtral', 'magistral']),
}, },
{ {
prefixes: ['moonshot', 'kimi'], name: 'Moonshot',
brand: { name: 'Moonshot', icon: 'moonshot', color: 'linear-gradient(135deg, #000000, #333333)' } icon: 'moonshot',
color: 'linear-gradient(135deg, #000000, #333333)',
match: (context) => includesAny(context, ['moonshot', 'kimi']),
}, },
{ {
prefixes: ['yi-'], name: '零一万物',
brand: { name: '零一万物', icon: 'yi.color', color: 'linear-gradient(135deg, #1d4ed8, #3b82f6)' } icon: 'yi-color',
color: 'linear-gradient(135deg, #1d4ed8, #3b82f6)',
match: (context) => (
startsWithAny(context, ['yi-'])
|| matchesBoundary(context, /(^|[/:_\-\s])yi(?=$|[/:_\-\s])/)
),
}, },
{ {
prefixes: ['ernie', 'eb-'], name: '文心一言',
brand: { name: '文心一言', icon: 'wenxin.color', color: 'linear-gradient(135deg, #2932e1, #4468f2)' } icon: 'wenxin-color',
color: 'linear-gradient(135deg, #2932e1, #4468f2)',
match: (context) => includesAny(context, ['ernie', 'eb-']),
}, },
{ {
prefixes: ['spark', 'generalv'], name: '讯飞星火',
brand: { name: '讯飞星火', icon: 'spark.color', color: 'linear-gradient(135deg, #0070f3, #00d4ff)' } icon: 'spark-color',
color: 'linear-gradient(135deg, #0070f3, #00d4ff)',
match: (context) => includesAny(context, ['spark', 'generalv']),
}, },
{ {
prefixes: ['hunyuan'], name: '腾讯混元',
brand: { name: '腾讯混元', icon: 'hunyuan.color', color: 'linear-gradient(135deg, #00b7ff, #0052d9)' } icon: 'hunyuan-color',
color: 'linear-gradient(135deg, #00b7ff, #0052d9)',
match: (context) => includesAny(context, ['hunyuan', 'tencent-hunyuan']),
}, },
{ {
prefixes: ['doubao'], name: '豆包',
brand: { name: '豆包', icon: 'doubao.color', color: 'linear-gradient(135deg, #3b5bdb, #7048e8)' } icon: 'doubao-color',
color: 'linear-gradient(135deg, #3b5bdb, #7048e8)',
match: (context) => includesAny(context, ['doubao']),
}, },
{ {
prefixes: ['minimax', 'abab'], name: 'MiniMax',
brand: { name: 'MiniMax', icon: 'minimax.color', color: 'linear-gradient(135deg, #6366f1, #818cf8)' } icon: 'minimax-color',
color: 'linear-gradient(135deg, #6366f1, #818cf8)',
match: (context) => includesAny(context, ['minimax', 'abab']) || hasExactSegment(context, ['mini2.1']),
}, },
{ {
prefixes: ['command', 'embed-'], name: 'Cohere',
brand: { name: 'Cohere', icon: 'cohere.color', color: 'linear-gradient(135deg, #39594d, #5ba77f)' } icon: 'cohere-color',
color: 'linear-gradient(135deg, #39594d, #5ba77f)',
match: (context) => includesAny(context, ['command', 'c4ai-']) || startsWithAny(context, ['embed-']),
}, },
{ {
prefixes: ['phi-'], name: 'Microsoft',
brand: { name: 'Microsoft', icon: 'azure', color: 'linear-gradient(135deg, #00bcf2, #0078d4)' } icon: 'microsoft-color',
color: 'linear-gradient(135deg, #00bcf2, #0078d4)',
match: (context) => (
includesAny(context, ['microsoft/', 'phi-', 'kosmos'])
|| hasExactSegment(context, ['phi4'])
),
}, },
{ {
prefixes: ['grok'], name: 'xAI',
brand: { name: 'xAI', icon: 'xai', color: 'linear-gradient(135deg, #111, #444)' } icon: 'xai',
color: 'linear-gradient(135deg, #111, #444)',
match: (context) => includesAny(context, ['grok']),
}, },
{ {
prefixes: ['step-'], name: '阶跃星辰',
brand: { name: '阶跃星辰', icon: 'stepfun', color: 'linear-gradient(135deg, #0066ff, #3399ff)' } icon: 'stepfun-color',
color: 'linear-gradient(135deg, #0066ff, #3399ff)',
match: (context) => includesAny(context, ['stepfun']) || startsWithAny(context, ['step-', 'step3']),
}, },
{ {
prefixes: ['flux', 'sd-', 'stable-diffusion', 'sdxl'], name: 'Stability',
brand: { name: 'Stability', icon: 'stability', color: 'linear-gradient(135deg, #8b5cf6, #a855f7)' } icon: 'stability-color',
color: 'linear-gradient(135deg, #8b5cf6, #a855f7)',
match: (context) => includesAny(context, ['flux', 'stablediffusion', 'stable-diffusion', 'sdxl']) || startsWithAny(context, ['sd3']),
},
{
name: 'NVIDIA',
icon: 'nvidia-color',
color: 'linear-gradient(135deg, #76b900, #4a8c0b)',
match: (context) => (
includesAny(context, ['nvidia/', 'nvclip', 'nemotron', 'nemoretriever', 'neva', 'riva-translate', 'cosmos'])
|| startsWithAny(context, ['nv-'])
),
},
{
name: 'IBM',
icon: 'ibm',
color: 'linear-gradient(135deg, #0f62fe, #4589ff)',
match: (context) => includesAny(context, ['ibm/', 'granite']),
},
{
name: 'BAAI',
icon: 'baai',
color: 'linear-gradient(135deg, #111827, #374151)',
match: (context) => includesAny(context, ['baai/', 'bge-']),
},
{
name: 'ByteDance',
icon: 'bytedance-color',
color: 'linear-gradient(135deg, #325ab4, #0f66ff)',
match: (context) => includesAny(context, ['bytedance', 'seed-oss', 'kolors', 'kwai', 'kwaipilot']) || startsWithAny(context, ['wan-', 'kat-']),
},
{
name: 'InternLM',
icon: 'internlm-color',
color: 'linear-gradient(135deg, #1b3882, #4063c5)',
match: (context) => includesAny(context, ['internlm']),
},
{
name: 'Midjourney',
icon: 'midjourney',
color: 'linear-gradient(135deg, #4c6ef5, #748ffc)',
match: (context) => includesAny(context, ['midjourney']) || startsWithAny(context, ['mj_']),
},
{
name: 'DeepL',
icon: 'deepl-color',
color: 'linear-gradient(135deg, #0f2b46, #21476f)',
match: (context) => startsWithAny(context, ['deepl-']) || includesAny(context, ['deepl/']),
},
{
name: 'Jina AI',
icon: 'jina',
color: 'linear-gradient(135deg, #111827, #4b5563)',
match: (context) => includesAny(context, ['jina']),
}, },
]; ];
@@ -106,64 +341,44 @@ function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
} }
const BRAND_RULES = BRAND_MAP.map((entry) => ({ const BRAND_FALLBACK_BOUNDARY_RULES = BRAND_DEFINITIONS.map((brand) => ({
brand: entry.brand, brand,
prefixMatchers: entry.prefixes.map((prefix) => ({ boundaryRegex: new RegExp(`(^|[^a-z0-9])${escapeRegExp(brand.name.toLowerCase())}(?=$|[^a-z0-9])`),
prefix,
boundaryRegex: new RegExp(`(^|[^a-z0-9])${escapeRegExp(prefix)}(?=$|[^a-z0-9])`),
})),
})); }));
function collectBrandCandidates(modelName: string): string[] { export function normalizeBrandIconKey(icon: string | null | undefined): string | null {
const queue: string[] = []; const normalized = normalizeInput(icon).replace(/\./g, '-');
const seen = new Set<string>(); if (!normalized) return null;
const push = (value: string) => { return LEGACY_ICON_ALIASES[normalized] || normalized;
const normalized = (value || '').trim().toLowerCase();
if (!normalized || seen.has(normalized)) return;
seen.add(normalized);
queue.push(normalized);
};
push(modelName);
for (let i = 0; i < queue.length; i += 1) {
const candidate = queue[i];
// Strip common route/model wrappers, e.g. "[Summer] gpt-5.2".
push(candidate.replace(/^(?:\[[^\]]+\]|【[^】]+】)\s*/g, ''));
if (candidate.startsWith('re:')) {
push(candidate.slice(3).trim());
}
push(candidate.replace(/^\^+/, '').replace(/\$+$/, ''));
if (candidate.includes('/')) {
for (const part of candidate.split('/')) push(part);
}
if (candidate.includes(':')) {
for (const part of candidate.split(':')) push(part);
}
} }
return queue; export function getBrandIconUrl(icon: string | null | undefined, cdn: string): string | null {
const normalized = normalizeBrandIconKey(icon);
if (!normalized) return null;
return `${cdn}/${normalized}.png`;
} }
export function getBrand(modelName: string): BrandInfo | null { export function getBrand(modelName: string): BrandInfo | null {
const candidates = collectBrandCandidates(modelName); const context = buildMatchContext(modelName);
// Prefer strict prefix matching first for predictable behavior. for (const definition of BRAND_DEFINITIONS) {
for (const candidate of candidates) { if (definition.match(context)) {
for (const rule of BRAND_RULES) { return {
for (const matcher of rule.prefixMatchers) { name: definition.name,
if (candidate.startsWith(matcher.prefix)) return rule.brand; icon: definition.icon,
} color: definition.color,
};
} }
} }
// Fallback for regex/model wrappers where brand token appears inside the pattern. for (const candidate of context.candidates) {
for (const candidate of candidates) { for (const rule of BRAND_FALLBACK_BOUNDARY_RULES) {
for (const rule of BRAND_RULES) { if (rule.boundaryRegex.test(candidate)) {
for (const matcher of rule.prefixMatchers) { return {
if (matcher.boundaryRegex.test(candidate)) return rule.brand; name: rule.brand.name,
icon: rule.brand.icon,
color: rule.brand.color,
};
} }
} }
} }
@@ -184,34 +399,103 @@ const FALLBACK_COLORS = [
export function hashColor(name: string): string { export function hashColor(name: string): string {
let h = 0; let h = 0;
for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) | 0; for (let i = 0; i < name.length; i += 1) h = (h * 31 + name.charCodeAt(i)) | 0;
return FALLBACK_COLORS[Math.abs(h) % FALLBACK_COLORS.length]; return FALLBACK_COLORS[Math.abs(h) % FALLBACK_COLORS.length];
} }
function avatarLetters(name: string): string { function avatarLetters(name: string): string {
const parts = name.replace(/[-_/.]/g, ' ').trim().split(/\s+/); const parts = name.replace(/[-_/.]/g, ' ').trim().split(/\s+/).filter(Boolean);
if (parts.length >= 2) return (parts[0][0] + parts[1][0]).toUpperCase(); if (parts.length >= 2) return (parts[0]![0] + parts[1]![0]).toUpperCase();
return name.slice(0, 2).toUpperCase(); return name.slice(0, 2).toUpperCase();
} }
type BrandGlyphProps = {
brand?: Pick<BrandInfo, 'name' | 'icon'> | null;
model?: string | null;
icon?: string | null;
alt?: string;
size?: number;
fallbackText?: string | null;
style?: CSSProperties;
};
export function BrandGlyph({ brand, model, icon, alt, size = 16, fallbackText, style }: BrandGlyphProps) {
const cdn = useIconCdn();
const resolvedBrand = brand || (model ? getBrand(model) : null);
const resolvedIcon = normalizeBrandIconKey(icon || resolvedBrand?.icon || null);
const [imgError, setImgError] = useState(false);
useEffect(() => {
setImgError(false);
}, [resolvedIcon]);
if (resolvedIcon && !imgError) {
const src = getBrandIconUrl(resolvedIcon, cdn);
if (src) {
return (
<img
src={src}
alt={alt || resolvedBrand?.name || model || 'brand'}
style={{
width: size,
height: size,
objectFit: 'contain',
flexShrink: 0,
verticalAlign: 'middle',
...style,
}}
onError={() => setImgError(true)}
loading="lazy"
/>
);
}
}
const fallback = (fallbackText ?? resolvedBrand?.name ?? model ?? '').trim();
if (!fallback) return null;
return (
<span
aria-hidden="true"
style={{
width: size,
height: size,
borderRadius: Math.max(4, Math.round(size * 0.33)),
background: hashColor(fallback),
color: 'white',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: Math.max(9, Math.round(size * 0.5)),
fontWeight: 700,
lineHeight: 1,
flexShrink: 0,
overflow: 'hidden',
...style,
}}
>
{avatarLetters(fallback)}
</span>
);
}
export function BrandIcon({ model, size = 44 }: { model: string; size?: number }) { export function BrandIcon({ model, size = 44 }: { model: string; size?: number }) {
const brand = getBrand(model); const brand = getBrand(model);
const [imgError, setImgError] = useState(false);
if (brand && !imgError) { if (brand) {
return ( return (
<div style={{ <div style={{
width: size, height: size, borderRadius: 10, background: brand.color, width: size,
display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0, height: size,
padding: 6, borderRadius: 10,
}}> display: 'flex',
<img alignItems: 'center',
src={`${ICON_CDN}/${brand.icon.replace(/\./g, '-')}.png`} justifyContent: 'center',
alt={brand.name} flexShrink: 0,
style={{ width: size - 14, height: size - 14, objectFit: 'contain', filter: 'brightness(10)' }} background: 'transparent',
onError={() => setImgError(true)} }}
loading="lazy" >
/> <BrandGlyph brand={brand} size={size} fallbackText={brand.name} />
</div> </div>
); );
} }
@@ -225,40 +509,22 @@ export function BrandIcon({ model, size = 44 }: { model: string; size?: number }
export function InlineBrandIcon({ model, size = 16 }: { model: string; size?: number }) { export function InlineBrandIcon({ model, size = 16 }: { model: string; size?: number }) {
const brand = getBrand(model); const brand = getBrand(model);
const [imgError, setImgError] = useState(false); if (!brand) return null;
const cdn = useIconCdn(); return <BrandGlyph brand={brand} size={size} fallbackText={brand.name} />;
if (brand && !imgError) {
return (
<img
src={`${cdn}/${brand.icon.replace(/\./g, '-')}.png`}
alt={brand.name}
style={{
width: size,
height: size,
objectFit: 'contain',
flexShrink: 0,
verticalAlign: 'middle',
}}
onError={() => setImgError(true)}
loading="lazy"
/>
);
} }
return null; export function ModelBadge({ model, style }: { model: string; style?: CSSProperties }) {
}
export function ModelBadge({ model, style }: { model: string; style?: React.CSSProperties }) {
const brand = getBrand(model); const brand = getBrand(model);
// Brand-specific colors for badge background
const badgeColors: Record<string, { bg: string; border: string; text: string }> = { const badgeColors: Record<string, { bg: string; border: string; text: string }> = {
'OpenAI': { bg: 'rgba(16,163,127,0.08)', border: 'rgba(16,163,127,0.2)', text: '#0d9668' }, OpenAI: { bg: 'rgba(16,163,127,0.08)', border: 'rgba(16,163,127,0.2)', text: '#0d9668' },
'Anthropic': { bg: 'rgba(212,165,116,0.1)', border: 'rgba(212,165,116,0.25)', text: '#9a6e3a' }, Anthropic: { bg: 'rgba(212,165,116,0.1)', border: 'rgba(212,165,116,0.25)', text: '#9a6e3a' },
'Google': { bg: 'rgba(66,133,244,0.08)', border: 'rgba(66,133,244,0.2)', text: '#2563eb' }, Google: { bg: 'rgba(66,133,244,0.08)', border: 'rgba(66,133,244,0.2)', text: '#2563eb' },
'DeepSeek': { bg: 'rgba(77,108,254,0.08)', border: 'rgba(77,108,254,0.2)', text: '#4d6bfe' }, DeepSeek: { bg: 'rgba(77,108,254,0.08)', border: 'rgba(77,108,254,0.2)', text: '#4d6bfe' },
'xAI': { bg: 'rgba(0,0,0,0.06)', border: 'rgba(0,0,0,0.12)', text: '#333' }, 'Jina AI': { bg: 'rgba(17,24,39,0.08)', border: 'rgba(17,24,39,0.16)', text: '#111827' },
Microsoft: { bg: 'rgba(0,164,239,0.08)', border: 'rgba(0,164,239,0.18)', text: '#0f62fe' },
NVIDIA: { bg: 'rgba(118,185,0,0.10)', border: 'rgba(118,185,0,0.18)', text: '#4a8c0b' },
xAI: { bg: 'rgba(0,0,0,0.06)', border: 'rgba(0,0,0,0.12)', text: '#333' },
}; };
const brandName = brand?.name || ''; const brandName = brand?.name || '';
@@ -282,7 +548,8 @@ export function ModelBadge({ model, style }: { model: string; style?: React.CSSP
border: `1px solid ${colors.border}`, border: `1px solid ${colors.border}`,
whiteSpace: 'nowrap', whiteSpace: 'nowrap',
...style, ...style,
}}> }}
>
<InlineBrandIcon model={model} size={14} /> <InlineBrandIcon model={model} size={14} />
{model} {model}
</span> </span>
@@ -0,0 +1,68 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, create, type ReactTestInstance } from 'react-test-renderer';
import ModelAnalysisPanel from './ModelAnalysisPanel.js';
vi.mock('@visactor/react-vchart', () => ({
VChart: () => <div>mock-chart</div>,
}));
vi.mock('./BrandIcon.js', () => ({
InlineBrandIcon: () => <span>brand-icon</span>,
}));
function collectText(node: ReactTestInstance): string {
return (node.children || []).map((child) => {
if (typeof child === 'string') return child;
return collectText(child);
}).join('');
}
describe('ModelAnalysisPanel token summaries', () => {
const originalDocument = globalThis.document;
const originalGetComputedStyle = globalThis.getComputedStyle;
const originalMutationObserver = globalThis.MutationObserver;
beforeEach(() => {
globalThis.document = {
documentElement: {},
} as unknown as Document;
globalThis.getComputedStyle = vi.fn(() => ({
getPropertyValue: () => '#9ca3af',
})) as unknown as typeof getComputedStyle;
globalThis.MutationObserver = class {
observe() {}
disconnect() {}
} as unknown as typeof MutationObserver;
});
afterEach(() => {
globalThis.document = originalDocument;
globalThis.getComputedStyle = originalGetComputedStyle;
globalThis.MutationObserver = originalMutationObserver;
});
it('renders total token summaries with compact units', () => {
let root: ReturnType<typeof create> | null = null;
act(() => {
root = create(
<ModelAnalysisPanel
data={{
totals: {
spend: 0.123456,
calls: 10,
tokens: 611_540_335,
},
}}
/>,
);
});
const rendered = collectText(root!.root);
expect(rendered).toContain('总 Tokens');
expect(rendered).toContain('611.5M');
root?.unmount();
});
});
+2 -1
View File
@@ -1,6 +1,7 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { VChart } from '@visactor/react-vchart'; import { VChart } from '@visactor/react-vchart';
import { InlineBrandIcon } from './BrandIcon.js'; import { InlineBrandIcon } from './BrandIcon.js';
import { formatCompactTokenMetric } from '../numberFormat.js';
type TabKey = 'spend' | 'trend' | 'calls' | 'rank'; type TabKey = 'spend' | 'trend' | 'calls' | 'rank';
@@ -135,7 +136,7 @@ export default function ModelAnalysisPanel({ data }: ModelAnalysisPanelProps) {
</div> </div>
<div className="stat-summary-card stat-summary-green"> <div className="stat-summary-card stat-summary-green">
<div className="stat-summary-card-label">总 Tokens</div> <div className="stat-summary-card-label">总 Tokens</div>
<div className="stat-summary-card-value">{Math.round(totals.tokens).toLocaleString()}</div> <div className="stat-summary-card-value">{formatCompactTokenMetric(totals.tokens)}</div>
</div> </div>
</div> </div>
+32
View File
@@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest';
import { create } from 'react-test-renderer';
import ModernSelect from './ModernSelect.js';
function collectText(node: ReturnType<typeof create>['root']): string {
return node.findAll(() => true)
.flatMap((instance) => instance.children)
.filter((child): child is string => typeof child === 'string')
.join('');
}
describe('ModernSelect', () => {
it('renders icon nodes for the selected option', () => {
const root = create(
<ModernSelect
value="nvidia"
onChange={() => {}}
options={[
{
value: 'nvidia',
label: 'NVIDIA',
description: 'NVIDIA 品牌图标',
iconNode: <span>🟢</span>,
} as any,
]}
/>,
);
expect(collectText(root.root)).toContain('🟢');
expect(collectText(root.root)).toContain('NVIDIA');
});
});
+6 -2
View File
@@ -1,9 +1,11 @@
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
type ModernSelectOption = { type ModernSelectOption = {
value: string; value: string;
label: string; label: string;
description?: string; description?: string;
disabled?: boolean; disabled?: boolean;
iconNode?: ReactNode;
iconUrl?: string; iconUrl?: string;
iconText?: string; iconText?: string;
}; };
@@ -66,6 +68,9 @@ export default function ModernSelect({
}, [disabled]); }, [disabled]);
const renderOptionIcon = (item: ModernSelectOption) => { const renderOptionIcon = (item: ModernSelectOption) => {
if (item.iconNode) {
return item.iconNode;
}
if (item.iconUrl) { if (item.iconUrl) {
return <img className="modern-select-option-icon" src={item.iconUrl} alt="" loading="lazy" />; return <img className="modern-select-option-icon" src={item.iconUrl} alt="" loading="lazy" />;
} }
@@ -151,4 +156,3 @@ export default function ModernSelect({
</div> </div>
); );
} }
+113
View File
@@ -1110,6 +1110,119 @@ body {
letter-spacing: -0.02em; letter-spacing: -0.02em;
} }
.dashboard-stat-grid {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 16px;
margin-bottom: 24px;
}
.dashboard-stat-grid .stat-card {
min-width: 0;
}
.dashboard-stat-grid .stat-card-row {
min-width: 0;
}
.dashboard-stat-content {
min-width: 0;
}
.dashboard-stat-grid .stat-label,
.dashboard-stat-grid .stat-value,
.dashboard-stat-note {
white-space: nowrap;
}
.dashboard-stat-grid .stat-label {
font-size: clamp(11px, 0.85vw, 12px);
}
.dashboard-stat-grid .stat-value {
font-size: clamp(14px, 1.25vw, 18px);
}
.dashboard-stat-note {
font-size: 11px;
color: var(--color-text-muted);
margin-top: 2px;
overflow: hidden;
text-overflow: ellipsis;
}
@media (max-width: 1440px) {
.dashboard-stat-grid {
gap: 12px;
}
.dashboard-stat-grid .stat-card {
padding: 16px;
}
}
@media (max-width: 1200px) {
.dashboard-stat-grid {
gap: 10px;
}
.dashboard-stat-grid .stat-card {
padding: 14px;
}
.dashboard-stat-grid .stat-icon {
width: 30px;
height: 30px;
}
.dashboard-stat-grid .stat-card-row {
gap: 10px;
}
.dashboard-stat-grid .stat-card-header {
margin-bottom: 14px;
}
}
@media (max-width: 1024px) {
.dashboard-stat-grid {
gap: 8px;
}
.dashboard-stat-grid .stat-card {
padding: 12px;
}
.dashboard-stat-grid .stat-icon {
width: 24px;
height: 24px;
font-size: 12px;
}
.dashboard-stat-grid .stat-card-row {
gap: 8px;
}
.dashboard-stat-grid .stat-card-header {
gap: 6px;
margin-bottom: 12px;
font-size: 12px;
}
.dashboard-stat-grid .stat-card-header svg {
width: 14px;
height: 14px;
}
.dashboard-stat-grid .stat-value {
font-size: clamp(12px, 1.4vw, 16px);
}
.dashboard-stat-note {
font-size: 10px;
}
}
/* ===== Nav ===== */ /* ===== Nav ===== */
.nav-transition { .nav-transition {
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
+17
View File
@@ -0,0 +1,17 @@
import { describe, expect, it } from 'vitest';
import { formatCompactTokenMetric } from './numberFormat.js';
describe('formatCompactTokenMetric', () => {
it('keeps small values uncompressed', () => {
expect(formatCompactTokenMetric(950)).toBe('950');
});
it('formats thousands with K suffix', () => {
expect(formatCompactTokenMetric(7_974)).toBe('8K');
});
it('formats millions with M suffix', () => {
expect(formatCompactTokenMetric(1_800_000)).toBe('1.8M');
expect(formatCompactTokenMetric(611_540_335)).toBe('611.5M');
});
});
+27
View File
@@ -0,0 +1,27 @@
function toSafeNumber(value: unknown): number {
if (typeof value !== 'number' || Number.isNaN(value) || !Number.isFinite(value)) return 0;
return value;
}
function trimTrailingZero(value: string): string {
return value.replace(/\.0$/, '');
}
export function formatCompactTokenMetric(value: number): string {
const normalized = Math.round(toSafeNumber(value));
const absoluteValue = Math.abs(normalized);
if (absoluteValue >= 1_000_000_000) {
return `${trimTrailingZero((normalized / 1_000_000_000).toFixed(1))}B`;
}
if (absoluteValue >= 1_000_000) {
return `${trimTrailingZero((normalized / 1_000_000).toFixed(1))}M`;
}
if (absoluteValue >= 1_000) {
return `${trimTrailingZero((normalized / 1_000).toFixed(1))}K`;
}
return normalized.toLocaleString();
}
+7 -4
View File
@@ -197,18 +197,19 @@ export default function Accounts() {
}, [activeSegment]); }, [activeSegment]);
useEffect(() => { useEffect(() => {
if (activeSegment !== 'apikey' || !loaded) return; if (activeSegment === 'tokens' || !loaded) return;
const params = new URLSearchParams(location.search); const params = new URLSearchParams(location.search);
const shouldOpenCreate = isTruthyFlag(params.get('create')); const shouldOpenCreate = isTruthyFlag(params.get('create'));
const requestedSiteId = parsePositiveInt(params.get('siteId')); const requestedSiteId = parsePositiveInt(params.get('siteId'));
if (!shouldOpenCreate || !requestedSiteId) return; if (!shouldOpenCreate || !requestedSiteId) return;
const credentialMode = activeSegment === 'apikey' ? 'apikey' : 'session';
setShowAdd(true); setShowAdd(true);
setAddMode('token'); setAddMode('token');
setVerifyResult(null); setVerifyResult(null);
setLoginForm(createLoginForm()); setLoginForm(createLoginForm());
setTokenForm({ setTokenForm({
...createTokenForm('apikey'), ...createTokenForm(credentialMode),
siteId: requestedSiteId, siteId: requestedSiteId,
}); });
@@ -1013,7 +1014,7 @@ export default function Accounts() {
) : ( ) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div className="info-tip"> <div className="info-tip">
API Key 连接只用于代理转发,不会自动派生账号令牌。站点保存后也会跳到这里继续补充首个 API Key。 API Key 连接只用于代理转发,不会自动派生账号令牌。系统会按站点平台能力自动引导到 Session 或 API Key 创建流程。
</div> </div>
<ModernSelect <ModernSelect
value={String(tokenForm.siteId || 0)} value={String(tokenForm.siteId || 0)}
@@ -1483,7 +1484,9 @@ export default function Accounts() {
<svg className="empty-state-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" /></svg> <svg className="empty-state-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" /></svg>
<div className="empty-state-title">{activeSegment === 'apikey' ? '暂无 API Key 连接' : '暂无 Session 连接'}</div> <div className="empty-state-title">{activeSegment === 'apikey' ? '暂无 API Key 连接' : '暂无 Session 连接'}</div>
<div className="empty-state-desc"> <div className="empty-state-desc">
{activeSegment === 'apikey' ? '请先添加站点,然后为站点补充 API Key 连接' : '请先添加站点,然后添加 Session 连接'} {activeSegment === 'apikey'
? (sites.length > 0 ? '请为现有站点补充 API Key 连接' : '请先添加站点,然后为站点补充 API Key 连接')
: (sites.length > 0 ? '请为现有站点添加 Session 连接' : '请先添加站点,然后添加 Session 连接')}
</div> </div>
</div> </div>
)} )}
+47 -16
View File
@@ -1,6 +1,7 @@
import { Suspense, lazy, useEffect, useState, useCallback } from 'react'; import { Suspense, lazy, useEffect, useState, useCallback } from 'react';
import { api } from '../api.js'; import { api } from '../api.js';
import { useToast } from '../components/Toast.js'; import { useToast } from '../components/Toast.js';
import { formatCompactTokenMetric } from '../numberFormat.js';
const ModelAnalysisPanel = lazy(() => import('../components/ModelAnalysisPanel.js')); const ModelAnalysisPanel = lazy(() => import('../components/ModelAnalysisPanel.js'));
const SiteDistributionChart = lazy(() => import('../components/charts/SiteDistributionChart.js')); const SiteDistributionChart = lazy(() => import('../components/charts/SiteDistributionChart.js'));
@@ -153,8 +154,8 @@ export default function Dashboard({ adminName = '\u7ba1\u7406\u5458' }: { adminN
return ( return (
<div className="animate-fade-in"> <div className="animate-fade-in">
<div className="skeleton" style={{ width: 280, height: 32, marginBottom: 24, borderRadius: 'var(--radius-sm)' }} /> <div className="skeleton" style={{ width: 280, height: 32, marginBottom: 24, borderRadius: 'var(--radius-sm)' }} />
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 16, marginBottom: 24 }}> <div className="dashboard-stat-grid">
{[...Array(4)].map((_, i) => ( {[...Array(5)].map((_, i) => (
<div key={i} className={`stat-card animate-slide-up stagger-${i + 1}`}> <div key={i} className={`stat-card animate-slide-up stagger-${i + 1}`}>
<div className="skeleton" style={{ width: 80, height: 14, marginBottom: 16 }} /> <div className="skeleton" style={{ width: 80, height: 14, marginBottom: 16 }} />
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
@@ -209,6 +210,9 @@ export default function Dashboard({ adminName = '\u7ba1\u7406\u5458' }: { adminN
const proxy24hSuccess = safeNumber(data?.proxy24h?.success); const proxy24hSuccess = safeNumber(data?.proxy24h?.success);
const proxy24hTotal = safeNumber(data?.proxy24h?.total); const proxy24hTotal = safeNumber(data?.proxy24h?.total);
const totalTokens = safeNumber(data?.proxy24h?.totalTokens); const totalTokens = safeNumber(data?.proxy24h?.totalTokens);
const performanceWindowSeconds = Math.max(1, safeNumber(data?.performance?.windowSeconds) || 60);
const requestsPerMinute = safeNumber(data?.performance?.requestsPerMinute);
const tokensPerMinute = safeNumber(data?.performance?.tokensPerMinute);
const getLatencyColor = (ms: number) => ( const getLatencyColor = (ms: number) => (
ms <= 500 ms <= 500
@@ -272,7 +276,7 @@ export default function Dashboard({ adminName = '\u7ba1\u7406\u5458' }: { adminN
</div> </div>
</div> </div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 16, marginBottom: 24 }}> <div className="dashboard-stat-grid">
<div className="stat-card animate-slide-up stagger-1"> <div className="stat-card animate-slide-up stagger-1">
<div className="stat-card-header"> <div className="stat-card-header">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z" /></svg> <svg fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z" /></svg>
@@ -282,7 +286,7 @@ export default function Dashboard({ adminName = '\u7ba1\u7406\u5458' }: { adminN
<div className="stat-icon stat-icon-blue"> <div className="stat-icon stat-icon-blue">
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg> <svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
</div> </div>
<div> <div className="dashboard-stat-content">
<div className="stat-label">当前余额</div> <div className="stat-label">当前余额</div>
<div className="stat-value animate-count-up">${totalBalance.toFixed(2)}</div> <div className="stat-value animate-count-up">${totalBalance.toFixed(2)}</div>
<div <div
@@ -301,7 +305,7 @@ export default function Dashboard({ adminName = '\u7ba1\u7406\u5458' }: { adminN
<div className="stat-icon stat-icon-green"> <div className="stat-icon stat-icon-green">
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" /></svg> <svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" /></svg>
</div> </div>
<div> <div className="dashboard-stat-content">
<div className="stat-label">累计消耗</div> <div className="stat-label">累计消耗</div>
<div className="stat-value animate-count-up">${totalUsed.toFixed(2)}</div> <div className="stat-value animate-count-up">${totalUsed.toFixed(2)}</div>
<div <div
@@ -327,7 +331,7 @@ export default function Dashboard({ adminName = '\u7ba1\u7406\u5458' }: { adminN
<div className="stat-icon stat-icon-yellow"> <div className="stat-icon stat-icon-yellow">
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" /></svg> <svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" /></svg>
</div> </div>
<div> <div className="dashboard-stat-content">
<div className="stat-label">24h 请求</div> <div className="stat-label">24h 请求</div>
<div className="stat-value animate-count-up">{Math.round(proxy24hTotal).toLocaleString()}</div> <div className="stat-value animate-count-up">{Math.round(proxy24hTotal).toLocaleString()}</div>
</div> </div>
@@ -336,7 +340,7 @@ export default function Dashboard({ adminName = '\u7ba1\u7406\u5458' }: { adminN
<div className="stat-icon stat-icon-cyan"> <div className="stat-icon stat-icon-cyan">
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 12l3-3 3 3 4-4M8 21l4-4 4 4M3 4h18M4 4h16v12a1 1 0 01-1 1H5a1 1 0 01-1-1V4z" /></svg> <svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 12l3-3 3 3 4-4M8 21l4-4 4 4M3 4h18M4 4h16v12a1 1 0 01-1 1H5a1 1 0 01-1-1V4z" /></svg>
</div> </div>
<div> <div className="dashboard-stat-content">
<div className="stat-label">成功请求</div> <div className="stat-label">成功请求</div>
<div className="stat-value animate-count-up">{Math.round(proxy24hSuccess).toLocaleString()}</div> <div className="stat-value animate-count-up">{Math.round(proxy24hSuccess).toLocaleString()}</div>
</div> </div>
@@ -352,7 +356,7 @@ export default function Dashboard({ adminName = '\u7ba1\u7406\u5458' }: { adminN
<div className="stat-icon stat-icon-pink"> <div className="stat-icon stat-icon-pink">
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" /></svg> <svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" /></svg>
</div> </div>
<div> <div className="dashboard-stat-content">
<div className="stat-label">活跃账户</div> <div className="stat-label">活跃账户</div>
<div className="stat-value animate-count-up">{Math.round(activeAccounts)}/{Math.round(totalAccounts)}</div> <div className="stat-value animate-count-up">{Math.round(activeAccounts)}/{Math.round(totalAccounts)}</div>
</div> </div>
@@ -361,9 +365,9 @@ export default function Dashboard({ adminName = '\u7ba1\u7406\u5458' }: { adminN
<div className="stat-icon stat-icon-red"> <div className="stat-icon stat-icon-red">
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z" /></svg> <svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z" /></svg>
</div> </div>
<div> <div className="dashboard-stat-content">
<div className="stat-label">24h Tokens</div> <div className="stat-label">24h Tokens</div>
<div className="stat-value animate-count-up">{Math.round(totalTokens).toLocaleString()}</div> <div className="stat-value animate-count-up">{formatCompactTokenMetric(totalTokens)}</div>
</div> </div>
</div> </div>
</div> </div>
@@ -377,7 +381,7 @@ export default function Dashboard({ adminName = '\u7ba1\u7406\u5458' }: { adminN
<div className="stat-icon stat-icon-purple"> <div className="stat-icon stat-icon-purple">
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg> <svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
</div> </div>
<div> <div className="dashboard-stat-content">
<div className="stat-label">今日签到</div> <div className="stat-label">今日签到</div>
<div className="stat-value animate-count-up">{Math.round(todaySuccess)}/{Math.round(todayTotal)}</div> <div className="stat-value animate-count-up">{Math.round(todaySuccess)}/{Math.round(todayTotal)}</div>
</div> </div>
@@ -386,7 +390,7 @@ export default function Dashboard({ adminName = '\u7ba1\u7406\u5458' }: { adminN
<div className="stat-icon stat-icon-orange"> <div className="stat-icon stat-icon-orange">
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" /></svg> <svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
</div> </div>
<div> <div className="dashboard-stat-content">
<div className="stat-label">成功率</div> <div className="stat-label">成功率</div>
<div className="stat-value animate-count-up"> <div className="stat-value animate-count-up">
{todayTotal > 0 ? Math.round((todaySuccess / todayTotal) * 100) : 0}% {todayTotal > 0 ? Math.round((todaySuccess / todayTotal) * 100) : 0}%
@@ -394,6 +398,33 @@ export default function Dashboard({ adminName = '\u7ba1\u7406\u5458' }: { adminN
</div> </div>
</div> </div>
</div> </div>
<div className="stat-card animate-slide-up stagger-5">
<div className="stat-card-header">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 3v10h8M5 12h3m-3 4h6m-6 4h10a2 2 0 002-2V8.828a2 2 0 00-.586-1.414l-4.828-4.828A2 2 0 0010.172 2H5a2 2 0 00-2 2v14a2 2 0 002 2z" /></svg>
性能指标
</div>
<div className="stat-card-row">
<div className="stat-icon stat-icon-blue">
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 13h4v7H4zm6-9h4v16h-4zm6 5h4v11h-4z" /></svg>
</div>
<div className="dashboard-stat-content">
<div className="stat-label">RPM</div>
<div className="stat-value animate-count-up">{Math.round(requestsPerMinute).toLocaleString()}</div>
<div className="dashboard-stat-note">最近 {performanceWindowSeconds} 秒请求</div>
</div>
</div>
<div className="stat-card-row">
<div className="stat-icon stat-icon-cyan">
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4m13-5l3 3-3 3M8 7L5 10l3 3" /></svg>
</div>
<div className="dashboard-stat-content">
<div className="stat-label">TPM</div>
<div className="stat-value animate-count-up">{formatCompactTokenMetric(tokensPerMinute)}</div>
<div className="dashboard-stat-note">最近 {performanceWindowSeconds} 秒 Tokens</div>
</div>
</div>
</div>
</div> </div>
{/* 站点级分析 */} {/* 站点级分析 */}
@@ -418,12 +449,12 @@ export default function Dashboard({ adminName = '\u7ba1\u7406\u5458' }: { adminN
</div> </div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16, marginBottom: 24 }}> <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16, marginBottom: 24 }}>
<div className="chart-panel-enter animate-slide-up stagger-5"> <div className="chart-panel-enter animate-slide-up stagger-6">
<Suspense fallback={<ChartFallback height={320} />}> <Suspense fallback={<ChartFallback height={320} />}>
<SiteDistributionChart data={siteDistribution} loading={siteLoading} /> <SiteDistributionChart data={siteDistribution} loading={siteLoading} />
</Suspense> </Suspense>
</div> </div>
<div className="chart-panel-enter animate-slide-up stagger-6"> <div className="chart-panel-enter animate-slide-up stagger-7">
<Suspense fallback={<ChartFallback height={320} />}> <Suspense fallback={<ChartFallback height={320} />}>
<SiteTrendChart data={siteTrend} loading={siteLoading} /> <SiteTrendChart data={siteTrend} loading={siteLoading} />
</Suspense> </Suspense>
@@ -431,7 +462,7 @@ export default function Dashboard({ adminName = '\u7ba1\u7406\u5458' }: { adminN
</div> </div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 300px', gap: 16 }}> <div style={{ display: 'grid', gridTemplateColumns: '1fr 300px', gap: 16 }}>
<div className="chart-container animate-slide-up stagger-7"> <div className="chart-container animate-slide-up stagger-8">
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 14 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 14 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 14, fontWeight: 600, color: 'var(--color-text-primary)' }}> <div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 14, fontWeight: 600, color: 'var(--color-text-primary)' }}>
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" /></svg> <svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
@@ -443,7 +474,7 @@ export default function Dashboard({ adminName = '\u7ba1\u7406\u5458' }: { adminN
</Suspense> </Suspense>
</div> </div>
<div className="chart-container animate-slide-up stagger-8" style={{ display: 'flex', flexDirection: 'column' }}> <div className="chart-container animate-slide-up stagger-9" style={{ display: 'flex', flexDirection: 'column' }}>
<div style={{ fontSize: 14, fontWeight: 600, marginBottom: 16, display: 'flex', alignItems: 'center', justifyContent: 'space-between', color: 'var(--color-text-primary)' }}> <div style={{ fontSize: 14, fontWeight: 600, marginBottom: 16, display: 'flex', alignItems: 'center', justifyContent: 'space-between', color: 'var(--color-text-primary)' }}>
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}> <span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" /></svg> <svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" /></svg>
File diff suppressed because it is too large Load Diff
@@ -119,4 +119,75 @@ describe('Models marketplace text', () => {
root?.unmount(); root?.unmount();
} }
}); });
it('shows newly recognized brands in the marketplace filter panel', async () => {
apiMock.getModelsMarketplace.mockResolvedValue({
models: [
{
name: 'nvidia/vila',
accountCount: 1,
tokenCount: 1,
avgLatency: 210,
successRate: 97,
description: null,
tags: [],
supportedEndpointTypes: [],
pricingSources: [],
accounts: [
{
id: 1,
site: '公益站 A',
username: 'tester',
latency: 210,
balance: 6.5,
tokens: [{ id: 1, name: 'default', isDefault: true }],
},
],
},
{
name: 'deepl-zh-en',
accountCount: 1,
tokenCount: 1,
avgLatency: 160,
successRate: 99,
description: null,
tags: [],
supportedEndpointTypes: [],
pricingSources: [],
accounts: [
{
id: 2,
site: '公益站 B',
username: 'tester',
latency: 160,
balance: 8.8,
tokens: [{ id: 2, name: 'default', isDefault: true }],
},
],
},
],
});
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/models']}>
<ToastProvider>
<Models />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const text = collectText(root!.root);
expect(text).toContain('NVIDIA');
expect(text).toContain('DeepL');
expect(text).not.toContain('其他未归类的模型');
} finally {
root?.unmount();
}
});
}); });
+2 -9
View File
@@ -1,7 +1,7 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useLocation } from 'react-router-dom'; import { useLocation } from 'react-router-dom';
import { api } from '../api.js'; import { api } from '../api.js';
import { getBrand, hashColor, BrandIcon, type BrandInfo, useIconCdn } from '../components/BrandIcon.js'; import { BrandGlyph, getBrand, hashColor, BrandIcon, type BrandInfo } from '../components/BrandIcon.js';
import { useToast } from '../components/Toast.js'; import { useToast } from '../components/Toast.js';
import ModernSelect from '../components/ModernSelect.js'; import ModernSelect from '../components/ModernSelect.js';
import { useAnimatedVisibility } from '../components/useAnimatedVisibility.js'; import { useAnimatedVisibility } from '../components/useAnimatedVisibility.js';
@@ -115,7 +115,6 @@ const PAGE_SIZES = [10, 20, 50];
/* ---- component ---- */ /* ---- component ---- */
export default function Models() { export default function Models() {
const cdn = useIconCdn();
const toast = useToast(); const toast = useToast();
const [data, setData] = useState<ModelsMarketplaceResponse>({ models: [] }); const [data, setData] = useState<ModelsMarketplaceResponse>({ models: [] });
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -365,13 +364,7 @@ export default function Models() {
onClick={() => setActiveBrand(activeBrand === brandName ? null : brandName)} onClick={() => setActiveBrand(activeBrand === brandName ? null : brandName)}
> >
<span className="filter-item-icon" style={{ background: 'var(--color-bg)', borderRadius: 4, overflow: 'hidden', display: 'flex', alignItems: 'center', justifyContent: 'center' }}> <span className="filter-item-icon" style={{ background: 'var(--color-bg)', borderRadius: 4, overflow: 'hidden', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<img <BrandGlyph brand={brand} size={14} fallbackText={brandName} />
src={`${cdn}/${brand.icon.replace(/\./g, '-')}.png`}
alt={brandName}
style={{ width: 14, height: 14, objectFit: 'contain' }}
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
loading="lazy"
/>
</span> </span>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{brandName}</span> <span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{brandName}</span>
<span className="filter-item-count">{count}</span> <span className="filter-item-count">{count}</span>
+94 -8
View File
@@ -4,7 +4,7 @@ import { api } from '../api.js';
import { useToast } from '../components/Toast.js'; import { useToast } from '../components/Toast.js';
import ChangeKeyModal from '../components/ChangeKeyModal.js'; import ChangeKeyModal from '../components/ChangeKeyModal.js';
import { useAnimatedVisibility } from '../components/useAnimatedVisibility.js'; import { useAnimatedVisibility } from '../components/useAnimatedVisibility.js';
import { InlineBrandIcon, getBrand, useIconCdn } from '../components/BrandIcon.js'; import { BrandGlyph, InlineBrandIcon, getBrand, normalizeBrandIconKey } from '../components/BrandIcon.js';
import ModernSelect from '../components/ModernSelect.js'; import ModernSelect from '../components/ModernSelect.js';
import { import {
applyRoutingProfilePreset, applyRoutingProfilePreset,
@@ -13,10 +13,13 @@ import {
} from './helpers/routingProfiles.js'; } from './helpers/routingProfiles.js';
import { fuzzyMatch } from './helpers/fuzzySearch.js'; import { fuzzyMatch } from './helpers/fuzzySearch.js';
import { clearAuthSession } from '../authSession.js'; import { clearAuthSession } from '../authSession.js';
import { clearAppInstallationState } from '../appLocalState.js';
import { tr } from '../i18n.js'; import { tr } from '../i18n.js';
const PROXY_TOKEN_PREFIX = 'sk-'; const PROXY_TOKEN_PREFIX = 'sk-';
const ROUTE_BRAND_ICON_PREFIX = 'brand:'; const ROUTE_BRAND_ICON_PREFIX = 'brand:';
const FACTORY_RESET_ADMIN_TOKEN = 'change-me-admin-token';
const FACTORY_RESET_CONFIRM_SECONDS = 3;
type DbDialect = 'sqlite' | 'mysql' | 'postgres'; type DbDialect = 'sqlite' | 'mysql' | 'postgres';
type RuntimeSettings = { type RuntimeSettings = {
@@ -163,7 +166,7 @@ function parseBrandIconValue(raw: string | null | undefined): string | null {
const normalized = (raw || '').trim(); const normalized = (raw || '').trim();
if (!normalized.startsWith(ROUTE_BRAND_ICON_PREFIX)) return null; if (!normalized.startsWith(ROUTE_BRAND_ICON_PREFIX)) return null;
const icon = normalized.slice(ROUTE_BRAND_ICON_PREFIX.length).trim(); const icon = normalized.slice(ROUTE_BRAND_ICON_PREFIX.length).trim();
return icon || null; return normalizeBrandIconKey(icon);
} }
function resolveRouteBrandSource(route: RouteSelectorItem): string { function resolveRouteBrandSource(route: RouteSelectorItem): string {
@@ -173,7 +176,6 @@ function resolveRouteBrandSource(route: RouteSelectorItem): string {
} }
export default function Settings() { export default function Settings() {
const iconCdn = useIconCdn();
const [runtime, setRuntime] = useState<RuntimeSettings>({ const [runtime, setRuntime] = useState<RuntimeSettings>({
checkinCron: '0 8 * * *', checkinCron: '0 8 * * *',
balanceRefreshCron: '0 * * * *', balanceRefreshCron: '0 * * * *',
@@ -221,6 +223,10 @@ export default function Settings() {
const downstreamModalPresence = useAnimatedVisibility(downstreamModalOpen, 220); const downstreamModalPresence = useAnimatedVisibility(downstreamModalOpen, 220);
const [selectorOpen, setSelectorOpen] = useState(false); const [selectorOpen, setSelectorOpen] = useState(false);
const selectorModalPresence = useAnimatedVisibility(selectorOpen, 220); const selectorModalPresence = useAnimatedVisibility(selectorOpen, 220);
const [factoryResetOpen, setFactoryResetOpen] = useState(false);
const factoryResetPresence = useAnimatedVisibility(factoryResetOpen, 220);
const [factoryResetting, setFactoryResetting] = useState(false);
const [factoryResetSecondsLeft, setFactoryResetSecondsLeft] = useState(FACTORY_RESET_CONFIRM_SECONDS);
const [selectorLoading, setSelectorLoading] = useState(false); const [selectorLoading, setSelectorLoading] = useState(false);
const [selectorRoutes, setSelectorRoutes] = useState<RouteSelectorItem[]>([]); const [selectorRoutes, setSelectorRoutes] = useState<RouteSelectorItem[]>([]);
const [selectorModelSearch, setSelectorModelSearch] = useState(''); const [selectorModelSearch, setSelectorModelSearch] = useState('');
@@ -294,6 +300,18 @@ export default function Settings() {
})); }));
}, [migrationDialect]); }, [migrationDialect]);
useEffect(() => {
if (!factoryResetOpen) {
setFactoryResetSecondsLeft(FACTORY_RESET_CONFIRM_SECONDS);
return;
}
setFactoryResetSecondsLeft(FACTORY_RESET_CONFIRM_SECONDS);
const timer = globalThis.setInterval(() => {
setFactoryResetSecondsLeft((prev) => (prev <= 1 ? 0 : prev - 1));
}, 1000);
return () => globalThis.clearInterval(timer);
}, [factoryResetOpen]);
const inputStyle: React.CSSProperties = { const inputStyle: React.CSSProperties = {
width: '100%', width: '100%',
padding: '10px 14px', padding: '10px 14px',
@@ -713,6 +731,24 @@ export default function Settings() {
} }
}; };
const closeFactoryResetModal = () => {
if (factoryResetting) return;
setFactoryResetOpen(false);
};
const handleFactoryReset = async () => {
if (factoryResetSecondsLeft > 0 || factoryResetting) return;
setFactoryResetting(true);
try {
await api.factoryReset();
clearAppInstallationState(localStorage);
window.location.reload();
} catch (err: any) {
toast.error(err?.message || '重新初始化系统失败');
setFactoryResetting(false);
}
};
const handleTestExternalDatabaseConnection = async () => { const handleTestExternalDatabaseConnection = async () => {
if (!effectiveMigrationConnectionString) { if (!effectiveMigrationConnectionString) {
toast.info('Please fill target database connection first'); toast.info('Please fill target database connection first');
@@ -1297,6 +1333,19 @@ export default function Settings() {
</div> </div>
</div> </div>
<div className="card animate-slide-up stagger-7" style={{ padding: 20, border: '1px solid color-mix(in srgb, var(--color-danger) 30%, var(--color-border))' }}>
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 10, color: 'var(--color-danger)' }}>危险操作</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', lineHeight: 1.8, marginBottom: 12 }}>
重新初始化系统会清空当前 metapi 使用中的全部数据库内容;若当前运行在外部 MySQL/Postgres,也会先清空该外部库中的 metapi 数据,然后切回默认 SQLite。
</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', lineHeight: 1.8, marginBottom: 14 }}>
完成后管理员 Token 会重置为 <code style={{ fontFamily: 'var(--font-mono)' }}>{FACTORY_RESET_ADMIN_TOKEN}</code>,当前会话会立即退出并刷新页面。
</div>
<button onClick={() => setFactoryResetOpen(true)} className="btn btn-danger">
重新初始化系统
</button>
</div>
<div className="card animate-slide-up stagger-7" style={{ padding: 20 }}> <div className="card animate-slide-up stagger-7" style={{ padding: 20 }}>
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 10 }}>会话与安全</div> <div style={{ fontWeight: 600, fontSize: 14, marginBottom: 10 }}>会话与安全</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 12 }}> <div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 12 }}>
@@ -1430,6 +1479,44 @@ export default function Settings() {
); );
return typeof document !== 'undefined' ? createPortal(modal, document.body) : modal; return typeof document !== 'undefined' ? createPortal(modal, document.body) : modal;
})()} })()}
{factoryResetPresence.shouldRender && (() => {
const confirmLabel = factoryResetting
? '重新初始化中...'
: (factoryResetSecondsLeft > 0
? `确认重新初始化系统(${factoryResetSecondsLeft}s)`
: '确认重新初始化系统');
const modal = (
<div className={`modal-backdrop ${factoryResetPresence.isVisible ? '' : 'is-closing'}`.trim()} onClick={closeFactoryResetModal}>
<div
className={`modal-content ${factoryResetPresence.isVisible ? '' : 'is-closing'}`.trim()}
style={{ maxWidth: 720, border: '1px solid color-mix(in srgb, var(--color-danger) 35%, var(--color-border))' }}
onClick={(e) => e.stopPropagation()}
>
<div className="modal-header" style={{ color: 'var(--color-danger)' }}>确认重新初始化系统</div>
<div className="modal-body" style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ padding: 12, borderRadius: 'var(--radius-sm)', background: 'var(--color-danger-bg)', color: 'var(--color-danger)', fontSize: 12, lineHeight: 1.8 }}>
这是不可逆操作。系统会清空当前 metapi 使用中的全部数据库内容,并在成功后立即退出当前登录状态。
</div>
<div style={{ fontSize: 13, color: 'var(--color-text-secondary)', lineHeight: 1.9 }}>
<div>• 当前若使用外部 MySQL/Postgres,也会先清空该外部库中的 metapi 数据。</div>
<div>• 系统随后会强制切回默认 SQLite。</div>
<div>• 管理员 Token 将重置为 <code style={{ fontFamily: 'var(--font-mono)' }}>{FACTORY_RESET_ADMIN_TOKEN}</code>。</div>
<div>• 完成后会立即退出登录并刷新页面,回到当前首装初始状态。</div>
</div>
</div>
<div className="modal-footer">
<button onClick={closeFactoryResetModal} disabled={factoryResetting} className="btn btn-ghost">取消</button>
<button onClick={handleFactoryReset} disabled={factoryResetting || factoryResetSecondsLeft > 0} className="btn btn-danger">
{factoryResetting
? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> {confirmLabel}</>
: confirmLabel}
</button>
</div>
</div>
</div>
);
return typeof document !== 'undefined' ? createPortal(modal, document.body) : modal;
})()}
{selectorModalPresence.shouldRender && (() => { {selectorModalPresence.shouldRender && (() => {
const modal = ( const modal = (
<div className={`modal-backdrop ${selectorModalPresence.isVisible ? '' : 'is-closing'}`.trim()} onClick={closeSelectorModal}> <div className={`modal-backdrop ${selectorModalPresence.isVisible ? '' : 'is-closing'}`.trim()} onClick={closeSelectorModal}>
@@ -1619,12 +1706,11 @@ export default function Settings() {
}} }}
> >
{explicitBrandIcon ? ( {explicitBrandIcon ? (
<img <BrandGlyph
src={`${iconCdn}/${explicitBrandIcon.replace(/\./g, '-')}.png`} icon={explicitBrandIcon}
alt={routeTitle(route)} alt={routeTitle(route)}
style={{ width: 18, height: 18, objectFit: 'contain' }} size={18}
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }} fallbackText={routeTitle(route)}
loading="lazy"
/> />
) : textIcon ? ( ) : textIcon ? (
textIcon textIcon

Some files were not shown because too many files have changed in this diff Show More