Merge remote-tracking branch 'origin/main' into feat/account-level-proxy

# Conflicts:
#	src/server/proxy-core/surfaces/chatSurface.ts
#	src/server/proxy-core/surfaces/openAiResponsesSurface.ts
#	src/server/routes/proxy/embeddings.ts
#	src/server/routes/proxy/images.ts
#	src/server/services/modelService.ts
This commit is contained in:
Hureru
2026-03-21 10:09:19 +08:00
198 changed files with 20039 additions and 1105 deletions
+22 -1
View File
@@ -10,7 +10,7 @@ vi.mock('../../services/checkinService.js', () => ({
}));
vi.mock('../../services/checkinScheduler.js', () => ({
updateCheckinCron: vi.fn(),
updateCheckinSchedule: vi.fn(),
}));
vi.mock('../../db/index.js', () => {
@@ -87,4 +87,25 @@ describe('POST /api/checkin/trigger background task dedupe', () => {
await new Promise((resolve) => setTimeout(resolve, 20));
await app.close();
});
it('accepts the legacy cron-only schedule payload', async () => {
const { checkinRoutes } = await import('./checkin.js');
const schedulerModule = await import('../../services/checkinScheduler.js');
const app = Fastify();
await app.register(checkinRoutes);
const response = await app.inject({
method: 'PUT',
url: '/api/checkin/schedule',
payload: { cron: '0 8 * * *' },
});
expect(response.statusCode).toBe(200);
expect((schedulerModule as any).updateCheckinSchedule).toHaveBeenCalledWith({
mode: 'cron',
cronExpr: '0 8 * * *',
intervalHours: undefined,
});
await app.close();
});
});
+30 -7
View File
@@ -1,9 +1,10 @@
import { FastifyInstance } from 'fastify';
import { config } from '../../config.js';
import { db, schema } from '../../db/index.js';
import { upsertSetting } from '../../db/upsertSetting.js';
import { eq, desc } from 'drizzle-orm';
import { checkinAccount, checkinAll } from '../../services/checkinService.js';
import { updateCheckinCron } from '../../services/checkinScheduler.js';
import { updateCheckinSchedule } from '../../services/checkinScheduler.js';
import { startBackgroundTask, summarizeCheckinResults } from '../../services/backgroundTaskService.js';
import { classifyFailureReason } from '../../services/failureReasonService.js';
@@ -84,7 +85,7 @@ export async function checkinRoutes(app: FastifyInstance) {
failureMessage: (currentTask) => `全部账号签到任务失败:${currentTask.error || 'unknown error'}`,
},
async () => {
const results = await checkinAll();
const results = await checkinAll({ scheduleMode: config.checkinScheduleMode });
return {
summary: summarizeCheckinResults(results),
total: results.length,
@@ -108,7 +109,7 @@ export async function checkinRoutes(app: FastifyInstance) {
// Trigger check-in for a specific account
app.post<{ Params: { id: string } }>('/api/checkin/trigger/:id', async (request) => {
const id = parseInt(request.params.id, 10);
const result = await checkinAccount(id);
const result = await checkinAccount(id, { scheduleMode: config.checkinScheduleMode });
return result;
});
@@ -142,11 +143,33 @@ export async function checkinRoutes(app: FastifyInstance) {
});
// Update check-in schedule
app.put<{ Body: { cron: string } }>('/api/checkin/schedule', async (request) => {
app.put<{ Body: { mode?: 'cron' | 'interval'; cron?: string; intervalHours?: number } }>('/api/checkin/schedule', async (request) => {
try {
updateCheckinCron(request.body.cron);
await upsertSetting('checkin_cron', request.body.cron);
return { success: true, cron: request.body.cron };
const body = request.body || {};
const nextMode: 'cron' | 'interval' = body.mode === 'interval' ? 'interval' : 'cron';
const nextCron = typeof body.cron === 'string' ? body.cron : undefined;
const nextIntervalHours = body.intervalHours !== undefined ? Number(body.intervalHours) : undefined;
const normalizedIntervalHours = typeof nextIntervalHours === 'number' && Number.isFinite(nextIntervalHours)
? Math.trunc(nextIntervalHours)
: undefined;
updateCheckinSchedule({
mode: nextMode,
cronExpr: nextCron,
intervalHours: normalizedIntervalHours,
});
await upsertSetting('checkin_schedule_mode', nextMode);
if (nextCron !== undefined) await upsertSetting('checkin_cron', nextCron);
if (normalizedIntervalHours !== undefined) {
await upsertSetting('checkin_interval_hours', normalizedIntervalHours);
}
return {
success: true,
mode: nextMode,
cron: nextCron,
intervalHours: normalizedIntervalHours,
};
} catch (err: any) {
return { error: err.message };
}
@@ -22,6 +22,10 @@ function validateKeyShape(key: string): boolean {
function looksLikeUniqueViolation(error: unknown): boolean {
const message = (error as Error | undefined)?.message || '';
if (runtimeDbDialect === 'postgres') {
return message.includes('duplicate key value violates unique constraint') &&
message.includes('downstream_api_keys_key_unique');
}
return message.includes('UNIQUE constraint failed') && message.includes('downstream_api_keys.key');
}
@@ -0,0 +1,114 @@
import Fastify, { type FastifyInstance } from 'fastify';
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';
import { eq } from 'drizzle-orm';
type DbModule = typeof import('../../db/index.js');
describe('settings backup webdav api', () => {
let app: FastifyInstance;
let db: DbModule['db'];
let schema: DbModule['schema'];
let dataDir = '';
beforeAll(async () => {
dataDir = mkdtempSync(join(tmpdir(), 'metapi-settings-backup-webdav-'));
process.env.DATA_DIR = dataDir;
await import('../../db/migrate.js');
const dbModule = await import('../../db/index.js');
const settingsRoutesModule = await import('./settings.js');
db = dbModule.db;
schema = dbModule.schema;
app = Fastify();
await app.register(settingsRoutesModule.settingsRoutes);
});
beforeEach(async () => {
await db.delete(schema.settings).run();
await db.delete(schema.events).run();
});
afterAll(async () => {
await app.close();
delete process.env.DATA_DIR;
});
it('saves and returns masked webdav config', async () => {
const response = await app.inject({
method: 'PUT',
url: '/api/settings/backup/webdav',
payload: {
enabled: true,
fileUrl: 'https://dav.example.com/backups/metapi.json',
username: 'alice',
password: 'secret-pass',
exportType: 'accounts',
autoSyncEnabled: true,
autoSyncCron: '0 */6 * * *',
},
});
expect(response.statusCode).toBe(200);
const body = response.json() as {
success?: boolean;
config?: {
fileUrl?: string;
username?: string;
exportType?: string;
autoSyncEnabled?: boolean;
autoSyncCron?: string;
hasPassword?: boolean;
passwordMasked?: string;
};
};
expect(body.success).toBe(true);
expect(body.config).toMatchObject({
fileUrl: 'https://dav.example.com/backups/metapi.json',
username: 'alice',
exportType: 'accounts',
autoSyncEnabled: true,
autoSyncCron: '0 */6 * * *',
hasPassword: true,
});
expect(body.config?.passwordMasked).toBeTruthy();
const saved = await db.select().from(schema.settings).where(eq(schema.settings.key, 'backup_webdav_config_v1')).get();
expect(saved?.value).toContain('"fileUrl":"https://dav.example.com/backups/metapi.json"');
expect(saved?.value).toContain('"password":"secret-pass"');
});
it('exports current backup to webdav through settings route', async () => {
const fetchSpy = vi.spyOn(globalThis, 'fetch');
fetchSpy.mockResolvedValue(new Response(null, { status: 201 }));
await db.insert(schema.settings).values({
key: 'backup_webdav_config_v1',
value: JSON.stringify({
enabled: true,
fileUrl: 'https://dav.example.com/backups/metapi.json',
username: 'alice',
password: 'secret-pass',
exportType: 'all',
autoSyncEnabled: false,
autoSyncCron: '0 * * * *',
}),
}).run();
const response = await app.inject({
method: 'POST',
url: '/api/settings/backup/webdav/export',
});
expect(response.statusCode).toBe(200);
const body = response.json() as { success?: boolean; fileUrl?: string };
expect(body.success).toBe(true);
expect(body.fileUrl).toBe('https://dav.example.com/backups/metapi.json');
expect(fetchSpy).toHaveBeenCalledTimes(1);
fetchSpy.mockRestore();
});
});
+81 -1
View File
@@ -44,6 +44,8 @@ describe('settings and auth events', () => {
config.proxyToken = 'sk-old-proxy-token-123';
config.systemProxyUrl = '';
config.checkinCron = '0 8 * * *';
(config as any).checkinScheduleMode = 'cron';
(config as any).checkinIntervalHours = 6;
config.balanceRefreshCron = '0 * * * *';
config.logCleanupConfigured = false;
config.logCleanupCron = '0 6 * * *';
@@ -55,6 +57,8 @@ describe('settings and auth events', () => {
(config as any).telegramApiBaseUrl = 'https://api.telegram.org';
(config as any).telegramBotToken = '';
(config as any).telegramChatId = '';
(config as any).telegramUseSystemProxy = false;
(config as any).telegramMessageThreadId = '';
});
afterAll(async () => {
@@ -85,6 +89,28 @@ describe('settings and auth events', () => {
expect(events[0].message || '').toContain('签到 Cron');
});
it('persists and returns checkin interval mode from runtime settings', async () => {
const updateResponse = await app.inject({
method: 'PUT',
url: '/api/settings/runtime',
payload: {
checkinScheduleMode: 'interval',
checkinIntervalHours: 8,
checkinCron: '0 8 * * *',
},
});
expect(updateResponse.statusCode).toBe(200);
const updated = updateResponse.json() as { checkinScheduleMode?: string; checkinIntervalHours?: number };
expect(updated.checkinScheduleMode).toBe('interval');
expect(updated.checkinIntervalHours).toBe(8);
const savedMode = await db.select().from(schema.settings).where(eq(schema.settings.key, 'checkin_schedule_mode')).get();
const savedInterval = await db.select().from(schema.settings).where(eq(schema.settings.key, 'checkin_interval_hours')).get();
expect(savedMode?.value).toBe(JSON.stringify('interval'));
expect(savedInterval?.value).toBe(JSON.stringify(8));
});
it('returns current recognized admin IP in runtime settings response', async () => {
const response = await app.inject({
method: 'GET',
@@ -96,8 +122,10 @@ describe('settings and auth events', () => {
});
expect(response.statusCode).toBe(200);
const body = response.json() as { currentAdminIp?: string };
const body = response.json() as { currentAdminIp?: string; serverTimeZone?: string };
expect(body.currentAdminIp).toBe('203.0.113.5');
expect(typeof body.serverTimeZone).toBe('string');
expect((body.serverTimeZone || '').length).toBeGreaterThan(0);
});
it('rejects proxy token that does not start with sk-', async () => {
@@ -202,6 +230,32 @@ describe('settings and auth events', () => {
expect(runtime.telegramApiBaseUrl).toBe('https://tg-proxy.example.com/custom');
});
it('persists and returns telegram message thread id from runtime settings', async () => {
const updateResponse = await app.inject({
method: 'PUT',
url: '/api/settings/runtime',
payload: {
telegramMessageThreadId: '77',
},
});
expect(updateResponse.statusCode).toBe(200);
const updated = updateResponse.json() as { telegramMessageThreadId?: string };
expect(updated.telegramMessageThreadId).toBe('77');
expect((config as any).telegramMessageThreadId).toBe('77');
const saved = await db.select().from(schema.settings).where(eq(schema.settings.key, 'telegram_message_thread_id')).get();
expect(saved?.value).toBe(JSON.stringify('77'));
const getResponse = await app.inject({
method: 'GET',
url: '/api/settings/runtime',
});
expect(getResponse.statusCode).toBe(200);
const runtime = getResponse.json() as { telegramMessageThreadId?: string };
expect(runtime.telegramMessageThreadId).toBe('77');
});
it('rejects invalid telegram api base url when telegram is enabled', async () => {
const response = await app.inject({
method: 'PUT',
@@ -219,6 +273,32 @@ describe('settings and auth events', () => {
expect(body.message).toContain('Telegram API Base URL');
});
it('persists and returns telegram use system proxy from runtime settings', async () => {
const updateResponse = await app.inject({
method: 'PUT',
url: '/api/settings/runtime',
payload: {
telegramUseSystemProxy: true,
},
});
expect(updateResponse.statusCode).toBe(200);
const updated = updateResponse.json() as { telegramUseSystemProxy?: boolean };
expect(updated.telegramUseSystemProxy).toBe(true);
expect((config as any).telegramUseSystemProxy).toBe(true);
const saved = await db.select().from(schema.settings).where(eq(schema.settings.key, 'telegram_use_system_proxy')).get();
expect(saved?.value).toBe(JSON.stringify(true));
const getResponse = await app.inject({
method: 'GET',
url: '/api/settings/runtime',
});
expect(getResponse.statusCode).toBe(200);
const runtime = getResponse.json() as { telegramUseSystemProxy?: boolean };
expect(runtime.telegramUseSystemProxy).toBe(true);
});
it('persists and returns routing fallback unit cost from runtime settings', async () => {
const updateResponse = await app.inject({
method: 'PUT',
@@ -0,0 +1,123 @@
import Fastify, { type FastifyInstance } from 'fastify';
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';
const fetchMock = vi.fn();
vi.mock('undici', async () => {
const actual = await vi.importActual<typeof import('undici')>('undici');
return {
...actual,
fetch: (...args: unknown[]) => fetchMock(...args),
};
});
type ConfigModule = typeof import('../../config.js');
describe('settings system proxy test route', () => {
let app: FastifyInstance;
let config: ConfigModule['config'];
let dataDir = '';
beforeAll(async () => {
dataDir = mkdtempSync(join(tmpdir(), 'metapi-settings-system-proxy-test-'));
process.env.DATA_DIR = dataDir;
await import('../../db/migrate.js');
const configModule = await import('../../config.js');
const settingsRoutesModule = await import('./settings.js');
config = configModule.config;
app = Fastify();
await app.register(settingsRoutesModule.settingsRoutes);
});
beforeEach(() => {
fetchMock.mockReset();
config.systemProxyUrl = '';
});
afterAll(async () => {
await app.close();
delete process.env.DATA_DIR;
});
it('tests the provided system proxy url and returns latency', async () => {
fetchMock.mockResolvedValue(new Response(null, {
status: 204,
headers: { 'content-type': 'text/plain' },
}));
const response = await app.inject({
method: 'POST',
url: '/api/settings/system-proxy/test',
payload: {
proxyUrl: 'http://127.0.0.1:7890',
},
});
expect(response.statusCode).toBe(200);
expect(response.json()).toMatchObject({
success: true,
proxyUrl: 'http://127.0.0.1:7890',
probeUrl: 'https://www.gstatic.com/generate_204',
finalUrl: 'https://www.gstatic.com/generate_204',
reachable: true,
ok: true,
statusCode: 204,
});
expect((response.json() as { latencyMs?: number }).latencyMs).toBeGreaterThanOrEqual(1);
const [url, requestInit] = fetchMock.mock.calls[0] as [string, Record<string, unknown>];
expect(url).toBe('https://www.gstatic.com/generate_204');
expect(requestInit.method).toBe('GET');
expect(requestInit.dispatcher).toBeTruthy();
});
it('uses the saved system proxy url when request body is empty', async () => {
config.systemProxyUrl = 'socks5://127.0.0.1:1080';
fetchMock.mockResolvedValue(new Response(null, { status: 204 }));
const response = await app.inject({
method: 'POST',
url: '/api/settings/system-proxy/test',
payload: {},
});
expect(response.statusCode).toBe(200);
expect((response.json() as { proxyUrl?: string }).proxyUrl).toBe('socks5://127.0.0.1:1080');
});
it('rejects missing system proxy url', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/settings/system-proxy/test',
payload: {},
});
expect(response.statusCode).toBe(400);
expect((response.json() as { message?: string }).message).toContain('请先填写系统代理地址');
expect(fetchMock).not.toHaveBeenCalled();
});
it('returns 502 when the proxy probe request fails', async () => {
fetchMock.mockRejectedValue(new TypeError('fetch failed', {
cause: new Error('connect ECONNREFUSED 127.0.0.1:7890'),
}));
const response = await app.inject({
method: 'POST',
url: '/api/settings/system-proxy/test',
payload: {
proxyUrl: 'http://127.0.0.1:7890',
},
});
expect(response.statusCode).toBe(502);
expect((response.json() as { message?: string }).message).toContain('连接被拒绝');
expect((response.json() as { message?: string }).message).not.toContain('fetch failed');
});
});
+348 -9
View File
@@ -1,12 +1,22 @@
import { FastifyInstance } from 'fastify';
import { FastifyInstance } from 'fastify';
import cron from 'node-cron';
import { fetch } from 'undici';
import { config } from '../../config.js';
import { db, runtimeDbDialect, schema } from '../../db/index.js';
import { upsertSetting } from '../../db/upsertSetting.js';
import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js';
import { updateBalanceRefreshCron, updateCheckinCron, updateLogCleanupSettings } from '../../services/checkinScheduler.js';
import { updateBalanceRefreshCron, updateCheckinSchedule, updateLogCleanupSettings } from '../../services/checkinScheduler.js';
import { sendNotification } from '../../services/notifyService.js';
import { exportBackup, importBackup, type BackupExportType } from '../../services/backupService.js';
import {
exportBackup,
exportBackupToWebdav,
getBackupWebdavConfig,
importBackup,
importBackupFromWebdav,
reloadBackupWebdavScheduler,
saveBackupWebdavConfig,
type BackupExportType,
} from '../../services/backupService.js';
import { startBackgroundTask } from '../../services/backgroundTaskService.js';
import {
maskConnectionString,
@@ -15,9 +25,9 @@ import {
testDatabaseConnection,
type MigrationDialect,
} from '../../services/databaseMigrationService.js';
import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
import { formatUtcSqlDateTime, getResolvedTimeZone } from '../../services/localTimeService.js';
import { extractClientIp, isIpAllowed } from '../../middleware/auth.js';
import { invalidateSiteProxyCache, normalizeSiteProxyUrl } from '../../services/siteProxy.js';
import { invalidateSiteProxyCache, normalizeSiteProxyUrl, withExplicitProxyRequestInit } from '../../services/siteProxy.js';
import { performFactoryReset } from '../../services/factoryResetService.js';
import { normalizeLogCleanupRetentionDays } from '../../services/logCleanupService.js';
import { stopProxyLogRetentionService } from '../../services/proxyLogRetentionService.js';
@@ -28,6 +38,8 @@ interface RuntimeSettingsBody {
proxyToken?: string;
systemProxyUrl?: string;
checkinCron?: string;
checkinScheduleMode?: 'cron' | 'interval';
checkinIntervalHours?: number;
balanceRefreshCron?: string;
logCleanupCron?: string;
logCleanupUsageLogsEnabled?: boolean;
@@ -43,6 +55,8 @@ interface RuntimeSettingsBody {
telegramApiBaseUrl?: string;
telegramBotToken?: string;
telegramChatId?: string;
telegramUseSystemProxy?: boolean;
telegramMessageThreadId?: string;
smtpEnabled?: boolean;
smtpHost?: string;
smtpPort?: number;
@@ -66,6 +80,21 @@ interface DatabaseMigrationBody {
ssl?: unknown;
}
interface SystemProxyTestBody {
proxyUrl?: unknown;
}
interface BackupWebdavConfigBody {
enabled?: unknown;
fileUrl?: unknown;
username?: unknown;
password?: unknown;
clearPassword?: unknown;
exportType?: unknown;
autoSyncEnabled?: unknown;
autoSyncCron?: unknown;
}
type RuntimeDatabaseConfig = {
dialect: MigrationDialect;
connectionString: string;
@@ -76,6 +105,8 @@ const PROXY_TOKEN_PREFIX = 'sk-';
const DB_TYPE_SETTING_KEY = 'db_type';
const DB_URL_SETTING_KEY = 'db_url';
const DB_SSL_SETTING_KEY = 'db_ssl';
const SYSTEM_PROXY_TEST_PROBE_URL = 'https://www.gstatic.com/generate_204';
const SYSTEM_PROXY_TEST_TIMEOUT_MS = 15_000;
function isValidProxyToken(value: string): boolean {
return value.startsWith(PROXY_TOKEN_PREFIX) && value.length >= 6;
@@ -114,6 +145,94 @@ function toPositiveNumberOrFallback(value: unknown, fallback: number) {
return n;
}
function extractNestedErrorMessages(error: unknown): string[] {
const messages: string[] = [];
const visited = new Set<unknown>();
let current: any = error;
while (current && !visited.has(current)) {
visited.add(current);
const message = typeof current?.message === 'string' ? current.message.trim() : '';
if (message) {
messages.push(message);
}
current = current?.cause;
}
return messages;
}
function describeSystemProxyTestFailure(error: unknown): string {
const messages = extractNestedErrorMessages(error);
const detail = messages.find((message) => message && message !== 'fetch failed')
|| messages[0]
|| '未知错误';
if (/ECONNREFUSED/i.test(detail)) {
return '系统代理测试失败:连接被拒绝,请检查代理地址、端口和本地代理程序是否已启动';
}
if (/ETIMEDOUT|timed out|timeout/i.test(detail)) {
return '系统代理测试失败:连接超时,请检查代理服务或当前网络是否可用';
}
if (/ENOTFOUND|EAI_AGAIN/i.test(detail)) {
return '系统代理测试失败:域名解析失败,请检查网络或代理的 DNS 配置';
}
if (/ECONNRESET/i.test(detail)) {
return '系统代理测试失败:连接被对端重置,请检查代理链路是否稳定';
}
if (/407/.test(detail) || /proxy authentication/i.test(detail)) {
return '系统代理测试失败:代理要求认证,请检查用户名、密码或代理配置';
}
return `系统代理测试失败:${detail}`;
}
async function testSystemProxyConnectivity(proxyUrl: string) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), SYSTEM_PROXY_TEST_TIMEOUT_MS);
const startedAt = Date.now();
try {
const response = await fetch(
SYSTEM_PROXY_TEST_PROBE_URL,
withExplicitProxyRequestInit(proxyUrl, {
method: 'GET',
signal: controller.signal,
headers: {
'cache-control': 'no-cache',
'user-agent': 'metapi-system-proxy-tester/1.0',
},
}),
);
try {
await response.arrayBuffer();
} catch {
// Ignore body drain failures; reachability is determined by receiving a response.
}
return {
reachable: true,
ok: response.ok,
statusCode: response.status,
latencyMs: Math.max(1, Date.now() - startedAt),
probeUrl: SYSTEM_PROXY_TEST_PROBE_URL,
finalUrl: response.url || SYSTEM_PROXY_TEST_PROBE_URL,
};
} catch (error: any) {
if (error?.name === 'AbortError') {
throw new Error(`系统代理测试超时(${Math.round(SYSTEM_PROXY_TEST_TIMEOUT_MS / 1000)}s)`);
}
throw new Error(describeSystemProxyTestFailure(error));
} finally {
clearTimeout(timeout);
}
}
function toStringList(value: unknown): string[] {
if (Array.isArray(value)) {
return value
@@ -171,12 +290,46 @@ function normalizeTelegramApiBaseUrl(raw: string): string {
return String(raw || '').trim().replace(/\/+$/, '');
}
function normalizeTelegramMessageThreadId(raw: unknown): string {
return String(raw || '').trim();
}
function isValidTelegramMessageThreadId(raw: string): boolean {
return /^[1-9]\d*$/.test(raw);
}
function applyImportedSettingToRuntime(key: string, value: unknown) {
switch (key) {
case 'checkin_cron': {
if (typeof value !== 'string' || !value || !cron.validate(value)) return;
config.checkinCron = value;
updateCheckinCron(value);
updateCheckinSchedule({
mode: config.checkinScheduleMode,
cronExpr: config.checkinCron,
intervalHours: config.checkinIntervalHours,
});
return;
}
case 'checkin_schedule_mode': {
if (value !== 'cron' && value !== 'interval') return;
const nextMode: 'cron' | 'interval' = value;
config.checkinScheduleMode = nextMode;
updateCheckinSchedule({
mode: config.checkinScheduleMode,
cronExpr: config.checkinCron,
intervalHours: config.checkinIntervalHours,
});
return;
}
case 'checkin_interval_hours': {
const intervalHours = Number(value);
if (!Number.isFinite(intervalHours) || intervalHours < 1 || intervalHours > 24) return;
config.checkinIntervalHours = Math.trunc(intervalHours);
updateCheckinSchedule({
mode: config.checkinScheduleMode,
cronExpr: config.checkinCron,
intervalHours: config.checkinIntervalHours,
});
return;
}
case 'balance_refresh_cron': {
@@ -288,6 +441,15 @@ function applyImportedSettingToRuntime(key: string, value: unknown) {
config.telegramChatId = value.trim();
return;
}
case 'telegram_use_system_proxy': {
config.telegramUseSystemProxy = !!value;
return;
}
case 'telegram_message_thread_id': {
if (typeof value !== 'string') return;
config.telegramMessageThreadId = value.trim();
return;
}
case 'smtp_enabled': {
config.smtpEnabled = !!value;
return;
@@ -363,6 +525,8 @@ function applyImportedSettingToRuntime(key: string, value: unknown) {
function getRuntimeSettingsResponse(currentAdminIp = '') {
return {
checkinCron: config.checkinCron,
checkinScheduleMode: config.checkinScheduleMode,
checkinIntervalHours: config.checkinIntervalHours,
balanceRefreshCron: config.balanceRefreshCron,
logCleanupCron: config.logCleanupCron,
logCleanupUsageLogsEnabled: config.logCleanupUsageLogsEnabled,
@@ -380,6 +544,8 @@ function getRuntimeSettingsResponse(currentAdminIp = '') {
telegramApiBaseUrl: config.telegramApiBaseUrl,
telegramBotTokenMasked: maskSecret(config.telegramBotToken),
telegramChatId: config.telegramChatId,
telegramUseSystemProxy: config.telegramUseSystemProxy,
telegramMessageThreadId: config.telegramMessageThreadId,
smtpEnabled: config.smtpEnabled,
smtpHost: config.smtpHost,
smtpPort: config.smtpPort,
@@ -391,6 +557,7 @@ function getRuntimeSettingsResponse(currentAdminIp = '') {
notifyCooldownSec: config.notifyCooldownSec,
adminIpAllowlist: config.adminIpAllowlist,
currentAdminIp,
serverTimeZone: getResolvedTimeZone(),
systemProxyUrl: config.systemProxyUrl,
proxyErrorKeywords: config.proxyErrorKeywords,
proxyEmptyContentFailEnabled: config.proxyEmptyContentFailEnabled,
@@ -472,6 +639,43 @@ export async function settingsRoutes(app: FastifyInstance) {
return getRuntimeSettingsResponse(currentAdminIp);
});
app.post<{ Body: SystemProxyTestBody }>('/api/settings/system-proxy/test', async (request, reply) => {
const rawProxyUrl = request.body?.proxyUrl === undefined
? config.systemProxyUrl
: String(request.body.proxyUrl || '').trim();
const normalizedProxyUrl = rawProxyUrl
? normalizeSiteProxyUrl(rawProxyUrl)
: '';
if (!rawProxyUrl) {
return reply.code(400).send({
success: false,
message: '请先填写系统代理地址',
});
}
if (!normalizedProxyUrl) {
return reply.code(400).send({
success: false,
message: '系统代理地址无效,请填写合法的 http(s)/socks 代理 URL',
});
}
try {
const result = await testSystemProxyConnectivity(normalizedProxyUrl);
return {
success: true,
proxyUrl: normalizedProxyUrl,
...result,
};
} catch (error: any) {
return reply.code(502).send({
success: false,
message: error?.message || '系统代理测试失败',
});
}
});
app.put<{ Body: RuntimeSettingsBody }>('/api/settings/runtime', async (request, reply) => {
const body = request.body || {};
const changedLabels: string[] = [];
@@ -512,7 +716,9 @@ export async function settingsRoutes(app: FastifyInstance) {
const telegramTouched = body.telegramEnabled !== undefined
|| body.telegramApiBaseUrl !== undefined
|| body.telegramBotToken !== undefined
|| body.telegramChatId !== undefined;
|| body.telegramChatId !== undefined
|| body.telegramUseSystemProxy !== undefined
|| body.telegramMessageThreadId !== undefined;
const nextTelegramEnabled = body.telegramEnabled !== undefined
? !!body.telegramEnabled
: config.telegramEnabled;
@@ -525,6 +731,9 @@ export async function settingsRoutes(app: FastifyInstance) {
const nextTelegramChatId = body.telegramChatId !== undefined
? String(body.telegramChatId || '').trim()
: config.telegramChatId;
const nextTelegramMessageThreadId = body.telegramMessageThreadId !== undefined
? normalizeTelegramMessageThreadId(body.telegramMessageThreadId)
: config.telegramMessageThreadId;
if (telegramTouched && nextTelegramEnabled) {
if (!nextTelegramBotToken) {
return reply.code(400).send({ success: false, message: 'Telegram Bot Token 不能为空(启用 Telegram 时)' });
@@ -535,13 +744,22 @@ export async function settingsRoutes(app: FastifyInstance) {
if (!nextTelegramChatId) {
return reply.code(400).send({ success: false, message: 'Telegram Chat ID 不能为空(启用 Telegram 时)' });
}
if (nextTelegramMessageThreadId && !isValidTelegramMessageThreadId(nextTelegramMessageThreadId)) {
return reply.code(400).send({ success: false, message: 'Telegram Topic ID 格式无效,需要正整数' });
}
if (nextTelegramApiBaseUrl && !isValidHttpUrl(nextTelegramApiBaseUrl)) {
return reply.code(400).send({ success: false, message: 'Telegram API Base URL 无效,请填写 http/https 地址' });
}
} else if (body.telegramApiBaseUrl !== undefined && nextTelegramApiBaseUrl && !isValidHttpUrl(nextTelegramApiBaseUrl)) {
return reply.code(400).send({ success: false, message: 'Telegram API Base URL 无效,请填写 http/https 地址' });
} else if (body.telegramMessageThreadId !== undefined && nextTelegramMessageThreadId && !isValidTelegramMessageThreadId(nextTelegramMessageThreadId)) {
return reply.code(400).send({ success: false, message: 'Telegram Topic ID 格式无效,需要正整数' });
}
const checkinScheduleTouched = body.checkinCron !== undefined
|| body.checkinScheduleMode !== undefined
|| body.checkinIntervalHours !== undefined;
if (body.checkinCron !== undefined) {
if (!cron.validate(body.checkinCron)) {
return reply.code(400).send({ success: false, message: '签到 Cron 表达式无效' });
@@ -549,8 +767,50 @@ export async function settingsRoutes(app: FastifyInstance) {
if (body.checkinCron !== config.checkinCron) {
changedLabels.push(`签到 Cron(${config.checkinCron} -> ${body.checkinCron})`);
}
updateCheckinCron(body.checkinCron);
upsertSetting('checkin_cron', body.checkinCron);
}
if (body.checkinScheduleMode !== undefined) {
if (body.checkinScheduleMode !== 'cron' && body.checkinScheduleMode !== 'interval') {
return reply.code(400).send({ success: false, message: '签到方式无效:仅支持 cron 或 interval' });
}
if (body.checkinScheduleMode !== config.checkinScheduleMode) {
changedLabels.push('签到方式');
}
config.checkinScheduleMode = body.checkinScheduleMode;
}
if (body.checkinIntervalHours !== undefined) {
const intervalHours = Number(body.checkinIntervalHours);
if (!Number.isFinite(intervalHours) || intervalHours < 1 || intervalHours > 24) {
return reply.code(400).send({ success: false, message: '签到间隔必须是 1 到 24 的整数小时' });
}
const nextIntervalHours = Math.trunc(intervalHours);
if (nextIntervalHours !== config.checkinIntervalHours) {
changedLabels.push(`签到间隔(${config.checkinIntervalHours}h -> ${nextIntervalHours}h)`);
}
config.checkinIntervalHours = nextIntervalHours;
}
if (checkinScheduleTouched) {
const nextCheckinCron = body.checkinCron !== undefined ? body.checkinCron : config.checkinCron;
const nextCheckinScheduleMode: 'cron' | 'interval' = body.checkinScheduleMode !== undefined
? body.checkinScheduleMode
: config.checkinScheduleMode;
const nextCheckinIntervalHours = body.checkinIntervalHours !== undefined
? Math.trunc(Number(body.checkinIntervalHours))
: config.checkinIntervalHours;
updateCheckinSchedule({
mode: nextCheckinScheduleMode,
cronExpr: nextCheckinCron,
intervalHours: nextCheckinIntervalHours,
});
config.checkinCron = nextCheckinCron;
config.checkinScheduleMode = nextCheckinScheduleMode;
config.checkinIntervalHours = nextCheckinIntervalHours;
upsertSetting('checkin_cron', config.checkinCron);
upsertSetting('checkin_schedule_mode', config.checkinScheduleMode);
upsertSetting('checkin_interval_hours', config.checkinIntervalHours);
}
if (body.balanceRefreshCron !== undefined) {
@@ -768,6 +1028,23 @@ export async function settingsRoutes(app: FastifyInstance) {
upsertSetting('telegram_chat_id', config.telegramChatId);
}
if (body.telegramUseSystemProxy !== undefined) {
if (!!body.telegramUseSystemProxy !== config.telegramUseSystemProxy) {
changedLabels.push('Telegram 使用系统代理');
}
config.telegramUseSystemProxy = !!body.telegramUseSystemProxy;
upsertSetting('telegram_use_system_proxy', config.telegramUseSystemProxy);
}
if (body.telegramMessageThreadId !== undefined) {
const nextTelegramMessageThreadId = normalizeTelegramMessageThreadId(body.telegramMessageThreadId);
if (nextTelegramMessageThreadId !== config.telegramMessageThreadId) {
changedLabels.push('Telegram Topic ID');
}
config.telegramMessageThreadId = nextTelegramMessageThreadId;
upsertSetting('telegram_message_thread_id', config.telegramMessageThreadId);
}
if (body.smtpEnabled !== undefined) {
if (!!body.smtpEnabled !== config.smtpEnabled) {
changedLabels.push('SMTP 开关');
@@ -1010,6 +1287,9 @@ export async function settingsRoutes(app: FastifyInstance) {
for (const item of result.appliedSettings) {
applyImportedSettingToRuntime(item.key, item.value);
}
if (result.appliedSettings.some((item) => item.key === 'backup_webdav_config_v1')) {
await reloadBackupWebdavScheduler();
}
return {
success: true,
message: '导入完成',
@@ -1023,6 +1303,65 @@ export async function settingsRoutes(app: FastifyInstance) {
}
});
app.get('/api/settings/backup/webdav', async () => {
return getBackupWebdavConfig();
});
app.put<{ Body: BackupWebdavConfigBody }>('/api/settings/backup/webdav', async (request, reply) => {
try {
const body = request.body || {};
const result = await saveBackupWebdavConfig({
enabled: body.enabled === undefined ? undefined : body.enabled === true,
fileUrl: body.fileUrl === undefined ? undefined : String(body.fileUrl || ''),
username: body.username === undefined ? undefined : String(body.username || ''),
password: body.password === undefined ? undefined : String(body.password),
clearPassword: body.clearPassword === true,
exportType: body.exportType === undefined ? undefined : String(body.exportType || '') as BackupExportType,
autoSyncEnabled: body.autoSyncEnabled === undefined ? undefined : body.autoSyncEnabled === true,
autoSyncCron: body.autoSyncCron === undefined ? undefined : String(body.autoSyncCron || ''),
});
return result;
} catch (err: any) {
return reply.code(400).send({
success: false,
message: err?.message || 'WebDAV 配置保存失败',
});
}
});
app.post<{ Body: { type?: string } }>('/api/settings/backup/webdav/export', async (request, reply) => {
try {
const rawType = typeof request.body?.type === 'string' ? request.body.type.trim().toLowerCase() : '';
const type: BackupExportType | undefined = rawType === 'all' || rawType === 'accounts' || rawType === 'preferences'
? rawType
: undefined;
return await exportBackupToWebdav(type);
} catch (err: any) {
return reply.code(400).send({
success: false,
message: err?.message || 'WebDAV 导出失败',
});
}
});
app.post('/api/settings/backup/webdav/import', async (_, reply) => {
try {
const result = await importBackupFromWebdav();
for (const item of result.appliedSettings) {
applyImportedSettingToRuntime(item.key, item.value);
}
if (result.appliedSettings.some((item) => item.key === 'backup_webdav_config_v1')) {
await reloadBackupWebdavScheduler();
}
return result;
} catch (err: any) {
return reply.code(400).send({
success: false,
message: err?.message || 'WebDAV 导入失败',
});
}
});
app.post('/api/settings/notify/test', async (_, reply) => {
try {
const result = await sendNotification(
@@ -0,0 +1,301 @@
import Fastify, { type FastifyInstance } from 'fastify';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { eq } from 'drizzle-orm';
const syncSiteAnnouncementsMock = vi.fn();
vi.mock('../../services/siteAnnouncementService.js', () => ({
syncSiteAnnouncements: (...args: unknown[]) => syncSiteAnnouncementsMock(...args),
}));
type DbModule = typeof import('../../db/index.js');
describe('site announcements routes', () => {
let app: FastifyInstance;
let db: DbModule['db'];
let schema: DbModule['schema'];
let closeDbConnections: DbModule['closeDbConnections'] | undefined;
let getBackgroundTask: ((taskId: string) => { id: string; status: string } | null) | null = null;
let resetBackgroundTasks: (() => void) | null = null;
let dataDir = '';
beforeAll(async () => {
dataDir = mkdtempSync(join(tmpdir(), 'metapi-site-announcements-routes-'));
process.env.DATA_DIR = dataDir;
await import('../../db/migrate.js');
const dbModule = await import('../../db/index.js');
const routesModule = await import('./siteAnnouncements.js');
const eventsModule = await import('./events.js');
const backgroundTaskModule = await import('../../services/backgroundTaskService.js');
db = dbModule.db;
schema = dbModule.schema;
closeDbConnections = dbModule.closeDbConnections;
getBackgroundTask = backgroundTaskModule.getBackgroundTask;
resetBackgroundTasks = backgroundTaskModule.__resetBackgroundTasksForTests;
app = Fastify();
await app.register(routesModule.siteAnnouncementsRoutes);
await app.register(eventsModule.eventsRoutes);
});
beforeEach(async () => {
syncSiteAnnouncementsMock.mockReset();
resetBackgroundTasks?.();
await db.delete(schema.siteAnnouncements).run();
await db.delete(schema.events).run();
await db.delete(schema.accounts).run();
await db.delete(schema.sites).run();
});
afterAll(async () => {
if (app) {
await app.close();
}
if (typeof closeDbConnections === 'function') {
await closeDbConnections();
}
if (dataDir) {
try {
rmSync(dataDir, { recursive: true, force: true });
} catch {}
}
delete process.env.DATA_DIR;
});
it('lists announcements with site and unread filters', async () => {
const site = await db.insert(schema.sites).values({
name: 'Sub Site',
url: 'https://sub.example.com',
platform: 'sub2api',
}).returning().get();
const otherSite = await db.insert(schema.sites).values({
name: 'Other Site',
url: 'https://other.example.com',
platform: 'new-api',
}).returning().get();
await db.insert(schema.siteAnnouncements).values([
{
siteId: site.id,
platform: 'sub2api',
sourceKey: 'announcement:11',
title: 'Maintenance',
content: 'Window starts at 10:00',
level: 'info',
firstSeenAt: '2026-03-20 10:00:00',
lastSeenAt: '2026-03-20 10:00:00',
},
{
siteId: otherSite.id,
platform: 'new-api',
sourceKey: 'announcement:12',
title: 'Other',
content: 'Other notice',
level: 'info',
firstSeenAt: '2026-03-20 09:00:00',
lastSeenAt: '2026-03-20 09:00:00',
readAt: '2026-03-20 09:05:00',
},
]).run();
const response = await app.inject({
method: 'GET',
url: `/api/site-announcements?siteId=${site.id}&read=false`,
});
expect(response.statusCode).toBe(200);
const body = response.json() as Array<{ siteId: number; sourceKey: string }>;
expect(body).toEqual([
expect.objectContaining({
siteId: site.id,
sourceKey: 'announcement:11',
}),
]);
});
it('normalizes postgres Date values for managed timestamps before filtering unread rows', async () => {
const routesModule = await import('./siteAnnouncements.js');
const rows = routesModule.buildSiteAnnouncementsResponseRows([
{
id: 1,
siteId: 7,
platform: 'sub2api',
sourceKey: 'announcement:read',
title: 'Read row',
content: 'Already read',
level: 'info',
sourceUrl: null,
startsAt: null,
endsAt: null,
upstreamCreatedAt: '2026-03-10T04:30:08.200Z',
upstreamUpdatedAt: '2026-03-10T04:30:08.200Z',
firstSeenAt: new Date('2026-03-19T22:41:26.000Z') as never,
lastSeenAt: new Date('2026-03-19T22:41:26.000Z') as never,
readAt: new Date('2026-03-19T22:45:00.000Z') as never,
dismissedAt: null,
rawPayload: null,
},
{
id: 2,
siteId: 8,
platform: 'new-api',
sourceKey: 'announcement:unread',
title: 'Unread row',
content: 'Still unread',
level: 'info',
sourceUrl: null,
startsAt: null,
endsAt: null,
upstreamCreatedAt: null,
upstreamUpdatedAt: null,
firstSeenAt: new Date('2026-03-19T22:55:27.000Z') as never,
lastSeenAt: new Date('2026-03-19T22:55:27.000Z') as never,
readAt: null,
dismissedAt: null,
rawPayload: null,
},
] as Array<typeof schema.siteAnnouncements.$inferSelect>, {
read: 'false',
timeZone: 'Asia/Shanghai',
});
expect(rows).toEqual([
expect.objectContaining({
id: 2,
firstSeenAt: '2026-03-20 06:55:27',
lastSeenAt: '2026-03-20 06:55:27',
readAt: null,
}),
]);
});
it('marks one announcement as read and then marks all as read', async () => {
const site = await db.insert(schema.sites).values({
name: 'Sub Site',
url: 'https://sub.example.com',
platform: 'sub2api',
}).returning().get();
const first = await db.insert(schema.siteAnnouncements).values({
siteId: site.id,
platform: 'sub2api',
sourceKey: 'announcement:11',
title: 'Maintenance',
content: 'Window starts at 10:00',
level: 'info',
firstSeenAt: '2026-03-20 10:00:00',
lastSeenAt: '2026-03-20 10:00:00',
}).returning().get();
await db.insert(schema.siteAnnouncements).values({
siteId: site.id,
platform: 'sub2api',
sourceKey: 'announcement:12',
title: 'Model online',
content: 'gpt-4.1 is available',
level: 'info',
firstSeenAt: '2026-03-20 11:00:00',
lastSeenAt: '2026-03-20 11:00:00',
}).run();
const singleRead = await app.inject({
method: 'POST',
url: `/api/site-announcements/${first.id}/read`,
});
expect(singleRead.statusCode).toBe(200);
const afterSingle = await db.select().from(schema.siteAnnouncements).where(eq(schema.siteAnnouncements.id, first.id)).get();
expect(afterSingle?.readAt).toBeTruthy();
const bulkRead = await app.inject({
method: 'POST',
url: '/api/site-announcements/read-all',
});
expect(bulkRead.statusCode).toBe(200);
const rows = await db.select().from(schema.siteAnnouncements).all();
expect(rows.every((row) => typeof row.readAt === 'string' && row.readAt.length > 0)).toBe(true);
});
it('clears local announcement rows without touching program events', async () => {
const site = await db.insert(schema.sites).values({
name: 'Sub Site',
url: 'https://sub.example.com',
platform: 'sub2api',
}).returning().get();
await db.insert(schema.siteAnnouncements).values({
siteId: site.id,
platform: 'sub2api',
sourceKey: 'announcement:11',
title: 'Maintenance',
content: 'Window starts at 10:00',
level: 'info',
firstSeenAt: '2026-03-20 10:00:00',
lastSeenAt: '2026-03-20 10:00:00',
}).run();
await db.insert(schema.events).values({
type: 'site_notice',
title: '站点公告:Sub Site',
message: 'Window starts at 10:00',
level: 'info',
relatedId: 123,
relatedType: 'site_announcement',
createdAt: '2026-03-20 10:00:00',
}).run();
const response = await app.inject({
method: 'DELETE',
url: '/api/site-announcements',
});
expect(response.statusCode).toBe(200);
expect(await db.select().from(schema.siteAnnouncements).all()).toEqual([]);
expect(await db.select().from(schema.events).all()).toHaveLength(1);
});
it('queues a background sync task and returns its task id', async () => {
const site = await db.insert(schema.sites).values({
name: 'Sub Site',
url: 'https://sub.example.com',
platform: 'sub2api',
}).returning().get();
syncSiteAnnouncementsMock.mockImplementation(() => new Promise(() => {}));
const response = await app.inject({
method: 'POST',
url: '/api/site-announcements/sync',
payload: { siteId: site.id },
});
expect(response.statusCode).toBe(200);
const body = response.json() as { taskId: string; queued: boolean };
expect(body.queued).toBe(true);
expect(typeof body.taskId).toBe('string');
expect(getBackgroundTask?.(body.taskId)).toBeTruthy();
});
it('returns site_notice rows through the generic events api filter', async () => {
await db.insert(schema.events).values({
type: 'site_notice',
title: '站点公告:Sub Site',
message: 'Window starts at 10:00',
level: 'info',
relatedId: 1,
relatedType: 'site',
createdAt: '2026-03-20 10:00:00',
}).run();
const response = await app.inject({
method: 'GET',
url: '/api/events?type=site_notice',
});
expect(response.statusCode).toBe(200);
const body = response.json() as Array<{ type: string }>;
expect(body).toEqual([expect.objectContaining({ type: 'site_notice' })]);
});
});
+196
View File
@@ -0,0 +1,196 @@
import { FastifyInstance } from 'fastify';
import { and, desc, eq } from 'drizzle-orm';
import { db, schema } from '../../db/index.js';
import { startBackgroundTask } from '../../services/backgroundTaskService.js';
import { formatUtcSqlDateTime, getResolvedTimeZone } from '../../services/localTimeService.js';
import { syncSiteAnnouncements } from '../../services/siteAnnouncementService.js';
type SiteAnnouncementRow = typeof schema.siteAnnouncements.$inferSelect;
type SiteAnnouncementsResponseFilters = {
read?: string;
status?: string;
timeZone?: string;
};
function formatDateTimePartsInTimeZone(value: Date, timeZone: string): string | null {
if (Number.isNaN(value.getTime())) return null;
const formatter = new Intl.DateTimeFormat('en-CA', {
timeZone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
});
const parts = formatter.formatToParts(value);
const lookup = new Map(parts.map((part) => [part.type, part.value]));
const year = lookup.get('year');
const month = lookup.get('month');
const day = lookup.get('day');
const hour = lookup.get('hour');
const minute = lookup.get('minute');
const second = lookup.get('second');
if (!year || !month || !day || !hour || !minute || !second) return null;
return `${year}-${month}-${day} ${hour}:${minute}:${second}`;
}
function normalizeManagedDateTimeValue(input: unknown, timeZone = getResolvedTimeZone()): string | null {
if (input == null) return null;
if (input instanceof Date) {
return formatDateTimePartsInTimeZone(input, timeZone);
}
const raw = String(input).trim();
return raw || null;
}
function parseTimeValue(input?: unknown): number | null {
if (input instanceof Date) {
return Number.isNaN(input.getTime()) ? null : input.getTime();
}
const raw = String(input || '').trim();
if (!raw) return null;
const parsed = Date.parse(raw.includes('T') ? raw : raw.replace(' ', 'T') + 'Z');
return Number.isFinite(parsed) ? parsed : null;
}
function hasDateTimeValue(input: unknown): boolean {
if (input instanceof Date) {
return !Number.isNaN(input.getTime());
}
return typeof input === 'string' && input.trim().length > 0;
}
export function normalizeSiteAnnouncementRowForResponse(row: SiteAnnouncementRow, timeZone = getResolvedTimeZone()) {
return {
...row,
firstSeenAt: normalizeManagedDateTimeValue((row as { firstSeenAt?: unknown }).firstSeenAt, timeZone),
lastSeenAt: normalizeManagedDateTimeValue((row as { lastSeenAt?: unknown }).lastSeenAt, timeZone),
readAt: normalizeManagedDateTimeValue((row as { readAt?: unknown }).readAt, timeZone),
dismissedAt: normalizeManagedDateTimeValue((row as { dismissedAt?: unknown }).dismissedAt, timeZone),
};
}
function applyReadFilter(rows: SiteAnnouncementRow[], read?: string): SiteAnnouncementRow[] {
if (read === 'true') {
return rows.filter((row) => hasDateTimeValue((row as { readAt?: unknown }).readAt));
}
if (read === 'false') {
return rows.filter((row) => !hasDateTimeValue((row as { readAt?: unknown }).readAt));
}
return rows;
}
function applyStatusFilter(rows: SiteAnnouncementRow[], status?: string): SiteAnnouncementRow[] {
const normalized = String(status || '').trim().toLowerCase();
if (!normalized) return rows;
const now = Date.now();
if (normalized === 'dismissed') {
return rows.filter((row) => hasDateTimeValue((row as { dismissedAt?: unknown }).dismissedAt));
}
if (normalized === 'expired') {
return rows.filter((row) => !row.dismissedAt && (() => {
const endsAt = parseTimeValue((row as { endsAt?: unknown }).endsAt);
return endsAt !== null && endsAt < now;
})());
}
if (normalized === 'active') {
return rows.filter((row) => !row.dismissedAt && (() => {
const endsAt = parseTimeValue((row as { endsAt?: unknown }).endsAt);
return endsAt === null || endsAt >= now;
})());
}
return rows;
}
export function buildSiteAnnouncementsResponseRows(
rows: SiteAnnouncementRow[],
options?: SiteAnnouncementsResponseFilters,
) {
const normalizedRows = rows.map((row) => normalizeSiteAnnouncementRowForResponse(row, options?.timeZone));
return applyStatusFilter(applyReadFilter(normalizedRows, options?.read), options?.status);
}
export async function siteAnnouncementsRoutes(app: FastifyInstance) {
app.get<{
Querystring: {
limit?: string;
offset?: string;
siteId?: string;
platform?: string;
read?: string;
status?: string;
};
}>('/api/site-announcements', async (request) => {
const limit = Math.max(1, Math.min(500, Number.parseInt(request.query.limit || '50', 10)));
const offset = Math.max(0, Number.parseInt(request.query.offset || '0', 10));
const filters: any[] = [];
const siteId = Number.parseInt(String(request.query.siteId || ''), 10);
if (Number.isFinite(siteId) && siteId > 0) {
filters.push(eq(schema.siteAnnouncements.siteId, siteId));
}
const platform = String(request.query.platform || '').trim();
if (platform) {
filters.push(eq(schema.siteAnnouncements.platform, platform));
}
const base = db.select().from(schema.siteAnnouncements);
const rows = filters.length > 0
? await base.where(and(...filters)).orderBy(desc(schema.siteAnnouncements.firstSeenAt)).all()
: await base.orderBy(desc(schema.siteAnnouncements.firstSeenAt)).all();
const filtered = buildSiteAnnouncementsResponseRows(rows, {
read: request.query.read,
status: request.query.status,
});
return filtered.slice(offset, offset + limit);
});
app.post<{ Params: { id: string } }>('/api/site-announcements/:id/read', async (request) => {
const id = Number.parseInt(request.params.id, 10);
const readAt = formatUtcSqlDateTime(new Date());
await db.update(schema.siteAnnouncements)
.set({ readAt })
.where(eq(schema.siteAnnouncements.id, id))
.run();
return { success: true };
});
app.post('/api/site-announcements/read-all', async () => {
const readAt = formatUtcSqlDateTime(new Date());
await db.update(schema.siteAnnouncements)
.set({ readAt })
.run();
return { success: true };
});
app.delete('/api/site-announcements', async () => {
await db.delete(schema.siteAnnouncements).run();
return { success: true };
});
app.post<{ Body: { siteId?: number | string | null } }>('/api/site-announcements/sync', async (request) => {
const parsedSiteId = Number.parseInt(String(request.body?.siteId ?? ''), 10);
const siteId = Number.isFinite(parsedSiteId) && parsedSiteId > 0 ? parsedSiteId : null;
const { task, reused } = startBackgroundTask(
{
type: 'site-announcements-sync',
title: siteId ? `同步站点公告 #${siteId}` : '同步站点公告',
dedupeKey: siteId ? `site-announcements:${siteId}` : 'site-announcements:all',
notifyOnSuccess: false,
notifyOnFailure: false,
},
() => syncSiteAnnouncements(siteId ? { siteId } : undefined),
);
return {
success: true,
queued: true,
reused,
taskId: task.id,
};
});
}
+26 -4
View File
@@ -6,7 +6,7 @@ import { mkdtempSync } from 'node:fs';
type DbModule = typeof import('../../db/index.js');
describe('sites system proxy settings', () => {
describe('sites proxy settings', () => {
let app: FastifyInstance;
let db: DbModule['db'];
let schema: DbModule['schema'];
@@ -36,7 +36,7 @@ describe('sites system proxy settings', () => {
delete process.env.DATA_DIR;
});
it('stores useSystemProxy, external checkin url, and custom headers when creating a site', async () => {
it('stores proxy settings, external checkin url, and custom headers when creating a site', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/sites',
@@ -44,6 +44,7 @@ describe('sites system proxy settings', () => {
name: 'proxy-site',
url: 'https://proxy-site.example.com',
platform: 'new-api',
proxyUrl: 'socks5://127.0.0.1:1080',
useSystemProxy: true,
customHeaders: JSON.stringify({
'cf-access-client-id': 'site-client-id',
@@ -56,11 +57,13 @@ describe('sites system proxy settings', () => {
expect(response.statusCode).toBe(200);
const payload = response.json() as {
proxyUrl?: string | null;
useSystemProxy?: boolean;
customHeaders?: string | null;
externalCheckinUrl?: string | null;
globalWeight?: number;
};
expect(payload.proxyUrl).toBe('socks5://127.0.0.1:1080');
expect(payload.useSystemProxy).toBe(true);
expect(payload.customHeaders).toBe('{"cf-access-client-id":"site-client-id","x-site-scope":"internal"}');
expect(payload.externalCheckinUrl).toBe('https://checkin.example.com/welfare');
@@ -83,6 +86,22 @@ describe('sites system proxy settings', () => {
expect((response.json() as { error?: string }).error).toContain('Invalid useSystemProxy');
});
it('rejects invalid proxy url', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/sites',
payload: {
name: 'proxy-site',
url: 'https://proxy-site.example.com',
platform: 'new-api',
proxyUrl: 'not-a-proxy',
},
});
expect(response.statusCode).toBe(400);
expect((response.json() as { error?: string }).error).toContain('Invalid proxyUrl');
});
it('rejects invalid site global weight', async () => {
const response = await app.inject({
method: 'POST',
@@ -115,7 +134,7 @@ describe('sites system proxy settings', () => {
expect((response.json() as { error?: string }).error).toContain('Invalid externalCheckinUrl');
});
it('updates useSystemProxy for an existing site', async () => {
it('updates per-site proxy settings for an existing site', async () => {
const created = await app.inject({
method: 'POST',
url: '/api/sites',
@@ -133,12 +152,15 @@ describe('sites system proxy settings', () => {
method: 'PUT',
url: `/api/sites/${site.id}`,
payload: {
proxyUrl: 'http://127.0.0.1:8080',
useSystemProxy: true,
},
});
expect(response.statusCode).toBe(200);
expect((response.json() as { useSystemProxy?: boolean }).useSystemProxy).toBe(true);
const payload = response.json() as { proxyUrl?: string | null; useSystemProxy?: boolean };
expect(payload.proxyUrl).toBe('http://127.0.0.1:8080');
expect(payload.useSystemProxy).toBe(true);
});
it('rejects invalid custom headers json', async () => {
@@ -0,0 +1,128 @@
import Fastify, { type FastifyInstance } from 'fastify';
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { mkdtempSync } from 'node:fs';
import {
buildStoredSub2ApiSubscriptionSummary,
mergeAccountExtraConfig,
} from '../../services/accountExtraConfig.js';
type DbModule = typeof import('../../db/index.js');
describe('sites route subscription summary aggregation', () => {
let app: FastifyInstance;
let db: DbModule['db'];
let schema: DbModule['schema'];
let dataDir = '';
beforeAll(async () => {
dataDir = mkdtempSync(join(tmpdir(), 'metapi-sites-subscription-'));
process.env.DATA_DIR = dataDir;
await import('../../db/migrate.js');
const dbModule = await import('../../db/index.js');
const routesModule = await import('./sites.js');
db = dbModule.db;
schema = dbModule.schema;
app = Fastify();
await app.register(routesModule.sitesRoutes);
});
beforeEach(async () => {
await db.delete(schema.accounts).run();
await db.delete(schema.sites).run();
});
afterAll(async () => {
await app.close();
delete process.env.DATA_DIR;
});
it('aggregates stored sub2api subscription summaries into /api/sites', async () => {
const site = await db.insert(schema.sites).values({
name: 'sub2api-site',
url: 'https://sub2api.example.com',
platform: 'sub2api',
}).returning().get();
await db.insert(schema.accounts).values([
{
siteId: site.id,
username: 'user-a',
accessToken: 'token-a',
balance: 10,
status: 'active',
extraConfig: mergeAccountExtraConfig(null, {
sub2apiSubscription: buildStoredSub2ApiSubscriptionSummary({
activeCount: 1,
totalUsedUsd: 3,
subscriptions: [
{
id: 11,
groupName: 'Pro',
expiresAt: '2026-04-10T00:00:00.000Z',
monthlyUsedUsd: 3,
monthlyLimitUsd: 20,
},
],
}, 1760000000000),
}),
},
{
siteId: site.id,
username: 'user-b',
accessToken: 'token-b',
balance: 5,
status: 'active',
extraConfig: mergeAccountExtraConfig(null, {
sub2apiSubscription: buildStoredSub2ApiSubscriptionSummary({
activeCount: 1,
totalUsedUsd: 2,
subscriptions: [
{
id: 12,
groupName: 'Lite',
expiresAt: '2026-03-25T00:00:00.000Z',
monthlyUsedUsd: 2,
monthlyLimitUsd: 10,
},
],
}, 1760000002000),
}),
},
]).run();
const response = await app.inject({
method: 'GET',
url: '/api/sites',
});
expect(response.statusCode).toBe(200);
const body = response.json() as Array<{
totalBalance: number;
subscriptionSummary: {
activeCount: number;
totalUsedUsd: number;
totalMonthlyLimitUsd: number | null;
totalRemainingUsd: number | null;
nextExpiresAt: string | null;
planNames: string[];
updatedAt: number | null;
} | null;
}>;
expect(body).toHaveLength(1);
expect(body[0]?.totalBalance).toBe(15);
expect(body[0]?.subscriptionSummary).toEqual({
activeCount: 2,
totalUsedUsd: 5,
totalMonthlyLimitUsd: 30,
totalRemainingUsd: 25,
nextExpiresAt: '2026-03-25T00:00:00.000Z',
planNames: ['Pro', 'Lite'],
updatedAt: 1760000002000,
});
});
});
+84 -10
View File
@@ -1,11 +1,12 @@
import { FastifyInstance } from 'fastify';
import { db, schema } from '../../db/index.js';
import { and, eq, sql } from 'drizzle-orm';
import { and, eq } from 'drizzle-orm';
import { detectSite } from '../../services/siteDetector.js';
import { invalidateSiteProxyCache } from '../../services/siteProxy.js';
import { invalidateSiteProxyCache, parseSiteProxyUrlInput } from '../../services/siteProxy.js';
import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
import { invalidateTokenRouterCache } from '../../services/tokenRouter.js';
import { parseSiteCustomHeadersInput } from '../../services/siteCustomHeaders.js';
import { getSub2ApiSubscriptionFromExtraConfig } from '../../services/accountExtraConfig.js';
function normalizeSiteStatus(input: unknown): 'active' | 'disabled' | null {
if (input === undefined || input === null) return null;
@@ -75,6 +76,65 @@ function normalizeOptionalExternalCheckinUrl(input: unknown): {
return { valid: true, present: true, url: parsed.toString().replace(/\/+$/, '') };
}
type SiteSubscriptionAggregate = {
activeCount: number;
totalUsedUsd: number;
totalMonthlyLimitUsd: number | null;
totalRemainingUsd: number | null;
nextExpiresAt: string | null;
planNames: string[];
updatedAt: number | null;
};
function roundMetric(value: number): number {
return Math.round(value * 1_000_000) / 1_000_000;
}
function pickEarlierIsoDate(current?: string | null, next?: string | null): string | null {
if (!current) return next || null;
if (!next) return current;
const currentMs = Date.parse(current);
const nextMs = Date.parse(next);
if (!Number.isFinite(currentMs)) return next;
if (!Number.isFinite(nextMs)) return current;
return nextMs < currentMs ? next : current;
}
function aggregateSiteSubscription(
current: SiteSubscriptionAggregate | undefined,
extraConfig?: string | null,
): SiteSubscriptionAggregate | undefined {
const stored = getSub2ApiSubscriptionFromExtraConfig(extraConfig);
if (!stored) return current;
const planNames = new Set(current?.planNames || []);
let totalMonthlyLimitUsd = current?.totalMonthlyLimitUsd ?? null;
let nextExpiresAt = current?.nextExpiresAt ?? null;
for (const item of stored.subscriptions) {
if (item.groupName) planNames.add(item.groupName);
if (typeof item.monthlyLimitUsd === 'number' && Number.isFinite(item.monthlyLimitUsd)) {
totalMonthlyLimitUsd = roundMetric((totalMonthlyLimitUsd ?? 0) + item.monthlyLimitUsd);
}
nextExpiresAt = pickEarlierIsoDate(nextExpiresAt, item.expiresAt);
}
const totalUsedUsd = roundMetric((current?.totalUsedUsd || 0) + stored.totalUsedUsd);
const totalRemainingUsd = totalMonthlyLimitUsd == null
? null
: roundMetric(Math.max(0, totalMonthlyLimitUsd - totalUsedUsd));
return {
activeCount: (current?.activeCount || 0) + stored.activeCount,
totalUsedUsd,
totalMonthlyLimitUsd,
totalRemainingUsd,
nextExpiresAt,
planNames: Array.from(planNames),
updatedAt: Math.max(current?.updatedAt || 0, stored.updatedAt || 0) || null,
};
}
export async function sitesRoutes(app: FastifyInstance) {
function invalidateSiteCaches() {
invalidateSiteProxyCache();
@@ -137,21 +197,23 @@ export async function sitesRoutes(app: FastifyInstance) {
// List all sites
app.get('/api/sites', async () => {
const siteRows = await db.select().from(schema.sites).all();
const accountBalanceRows = await db.select({
const accountRows = await db.select({
siteId: schema.accounts.siteId,
totalBalance: sql<number>`coalesce(sum(${schema.accounts.balance}), 0)`,
}).from(schema.accounts)
.groupBy(schema.accounts.siteId)
.all();
balance: schema.accounts.balance,
extraConfig: schema.accounts.extraConfig,
}).from(schema.accounts).all();
const totalBalanceBySiteId: Record<number, number> = {};
for (const row of accountBalanceRows) {
totalBalanceBySiteId[row.siteId] = Number(row.totalBalance || 0);
const subscriptionBySiteId: Record<number, SiteSubscriptionAggregate | undefined> = {};
for (const row of accountRows) {
totalBalanceBySiteId[row.siteId] = roundMetric((totalBalanceBySiteId[row.siteId] || 0) + Number(row.balance || 0));
subscriptionBySiteId[row.siteId] = aggregateSiteSubscription(subscriptionBySiteId[row.siteId], row.extraConfig);
}
return siteRows.map((site) => ({
...site,
totalBalance: Math.round((totalBalanceBySiteId[site.id] || 0) * 1_000_000) / 1_000_000,
subscriptionSummary: subscriptionBySiteId[site.id] || null,
}));
});
@@ -160,6 +222,7 @@ export async function sitesRoutes(app: FastifyInstance) {
name: string;
url: string;
platform?: string;
proxyUrl?: string | null;
useSystemProxy?: boolean;
customHeaders?: string | null;
externalCheckinUrl?: string | null;
@@ -168,7 +231,7 @@ export async function sitesRoutes(app: FastifyInstance) {
sortOrder?: number;
globalWeight?: number;
} }>('/api/sites', async (request, reply) => {
const { name, url, platform, useSystemProxy, customHeaders, externalCheckinUrl, status, isPinned, sortOrder, globalWeight } = request.body;
const { name, url, platform, proxyUrl, useSystemProxy, customHeaders, externalCheckinUrl, status, isPinned, sortOrder, globalWeight } = request.body;
const normalizedStatus = normalizeSiteStatus(status);
if (status !== undefined && !normalizedStatus) {
return reply.code(400).send({ error: 'Invalid site status. Expected active or disabled.' });
@@ -177,6 +240,10 @@ export async function sitesRoutes(app: FastifyInstance) {
if (useSystemProxy !== undefined && normalizedUseSystemProxy === null) {
return reply.code(400).send({ error: 'Invalid useSystemProxy value. Expected boolean.' });
}
const normalizedProxyUrl = parseSiteProxyUrlInput(proxyUrl);
if (!normalizedProxyUrl.valid) {
return reply.code(400).send({ error: 'Invalid proxyUrl. Expected a valid http(s)/socks proxy URL.' });
}
const normalizedExternalCheckinUrl = normalizeOptionalExternalCheckinUrl(externalCheckinUrl);
if (!normalizedExternalCheckinUrl.valid) {
return reply.code(400).send({ error: 'Invalid externalCheckinUrl. Expected a valid http(s) URL.' });
@@ -213,6 +280,7 @@ export async function sitesRoutes(app: FastifyInstance) {
name,
url: url.replace(/\/+$/, ''),
platform: detectedPlatform,
proxyUrl: normalizedProxyUrl.proxyUrl,
useSystemProxy: normalizedUseSystemProxy ?? false,
customHeaders: normalizedCustomHeaders.customHeaders,
externalCheckinUrl: normalizedExternalCheckinUrl.url,
@@ -238,6 +306,7 @@ export async function sitesRoutes(app: FastifyInstance) {
name?: string;
url?: string;
platform?: string;
proxyUrl?: string | null;
useSystemProxy?: boolean;
customHeaders?: string | null;
externalCheckinUrl?: string | null;
@@ -266,6 +335,10 @@ export async function sitesRoutes(app: FastifyInstance) {
if (body.useSystemProxy !== undefined && normalizedUseSystemProxy === null) {
return reply.code(400).send({ error: 'Invalid useSystemProxy value. Expected boolean.' });
}
const normalizedProxyUrl = parseSiteProxyUrlInput(body.proxyUrl);
if (!normalizedProxyUrl.valid) {
return reply.code(400).send({ error: 'Invalid proxyUrl. Expected a valid http(s)/socks proxy URL.' });
}
const normalizedExternalCheckinUrl = normalizeOptionalExternalCheckinUrl(body.externalCheckinUrl);
if (!normalizedExternalCheckinUrl.valid) {
return reply.code(400).send({ error: 'Invalid externalCheckinUrl. Expected a valid http(s) URL.' });
@@ -290,6 +363,7 @@ export async function sitesRoutes(app: FastifyInstance) {
if (body.name !== undefined) updates.name = body.name;
if (body.url !== undefined) updates.url = body.url.replace(/\/+$/, '');
if (body.platform !== undefined) updates.platform = body.platform;
if (normalizedProxyUrl.present) updates.proxyUrl = normalizedProxyUrl.proxyUrl;
if (body.useSystemProxy !== undefined) updates.useSystemProxy = normalizedUseSystemProxy;
if (normalizedCustomHeaders.present) updates.customHeaders = normalizedCustomHeaders.customHeaders;
if (normalizedExternalCheckinUrl.present) updates.externalCheckinUrl = normalizedExternalCheckinUrl.url;
@@ -75,6 +75,10 @@ describe('stats proxy logs routes', () => {
modelRequested: 'gpt-4o',
modelActual: 'gpt-4o',
status: 'success',
clientFamily: 'generic',
clientAppId: 'cherry_studio',
clientAppName: 'Cherry Studio',
clientConfidence: 'exact',
promptTokens: 10,
completionTokens: 5,
totalTokens: 15,
@@ -88,6 +92,7 @@ describe('stats proxy logs routes', () => {
modelRequested: 'gpt-4o-mini',
modelActual: 'gpt-4o-mini',
status: 'failed',
clientFamily: 'codex',
promptTokens: 8,
completionTokens: 2,
totalTokens: 10,
@@ -139,6 +144,10 @@ describe('stats proxy logs routes', () => {
totalCost: number;
totalTokensAll: number;
};
clientOptions: Array<{
value: string;
label: string;
}>;
};
expect(body.page).toBe(2);
@@ -150,7 +159,14 @@ describe('stats proxy logs routes', () => {
expect(body.items[0]?.downstreamKeyName).toBe('项目A-Key');
expect(body.items[0]?.downstreamKeyGroupName).toBe('项目A');
expect(body.items[0]?.downstreamKeyTags).toEqual(['VIP', '灰度']);
expect(body.items[0]?.clientFamily).toBe('codex');
expect(body.items[0]?.clientAppId).toBe(null);
expect(body.items[0]?.clientAppName).toBe(null);
expect(body.items[0]?.clientConfidence).toBe(null);
expect(body.items[0]).not.toHaveProperty('billingDetails');
expect(body.clientOptions).toEqual([
{ value: 'family:codex', label: '协议 · Codex' },
]);
expect(body.summary).toEqual({
totalCount: 3,
successCount: 1,
@@ -188,6 +204,10 @@ describe('stats proxy logs routes', () => {
modelRequested: 'gpt-5',
modelActual: 'gpt-5',
status: 'success',
clientFamily: 'codex',
clientAppId: 'cherry_studio',
clientAppName: 'Cherry Studio',
clientConfidence: 'exact',
promptTokens: 100,
completionTokens: 20,
totalTokens: 120,
@@ -214,6 +234,10 @@ describe('stats proxy logs routes', () => {
downstreamKeyName: string | null;
downstreamKeyGroupName: string | null;
downstreamKeyTags: string[];
clientFamily: string | null;
clientAppId: string | null;
clientAppName: string | null;
clientConfidence: string | null;
billingDetails: Record<string, unknown> | null;
};
@@ -223,6 +247,10 @@ describe('stats proxy logs routes', () => {
expect(body.downstreamKeyName).toBe('detail-key');
expect(body.downstreamKeyGroupName).toBe('测试项目');
expect(body.downstreamKeyTags).toEqual(['回归', '日志']);
expect(body.clientFamily).toBe('codex');
expect(body.clientAppId).toBe('cherry_studio');
expect(body.clientAppName).toBe('Cherry Studio');
expect(body.clientConfidence).toBe('exact');
expect(body.billingDetails).toMatchObject({
breakdown: { totalCost: 0.12 },
usage: { promptTokens: 100, completionTokens: 20 },
@@ -392,4 +420,118 @@ describe('stats proxy logs routes', () => {
totalTokensAll: 30,
});
});
it('filters proxy logs by app id while keeping client options scoped only by the other filters', async () => {
const site = await db.insert(schema.sites).values({
name: 'client-filter-site',
url: 'https://client-filter.example.com',
platform: 'new-api',
}).returning().get();
const account = await db.insert(schema.accounts).values({
siteId: site.id,
username: 'client-filter-user',
accessToken: 'client-filter-token',
status: 'active',
}).returning().get();
await db.insert(schema.proxyLogs).values([
{
accountId: account.id,
modelRequested: 'gpt-4o',
modelActual: 'gpt-4o',
status: 'success',
clientFamily: 'generic',
clientAppId: 'cherry_studio',
clientAppName: 'Cherry Studio',
clientConfidence: 'exact',
totalTokens: 12,
estimatedCost: 0.12,
createdAt: formatUtcSqlDateTime(new Date('2026-03-09T11:00:00.000Z')),
},
{
accountId: account.id,
modelRequested: 'gpt-4.1',
modelActual: 'gpt-4.1',
status: 'failed',
clientFamily: 'codex',
totalTokens: 22,
estimatedCost: 0.22,
createdAt: formatUtcSqlDateTime(new Date('2026-03-09T11:05:00.000Z')),
},
]).run();
const response = await app.inject({
method: 'GET',
url: '/api/stats/proxy-logs?client=app%3Acherry_studio',
});
expect(response.statusCode).toBe(200);
const body = response.json() as {
total: number;
items: Array<Record<string, unknown>>;
clientOptions: Array<{
value: string;
label: string;
}>;
};
expect(body.total).toBe(1);
expect(body.items[0]?.clientAppId).toBe('cherry_studio');
expect(body.clientOptions).toEqual([
{ value: 'app:cherry_studio', label: '应用 · Cherry Studio' },
{ value: 'family:codex', label: '协议 · Codex' },
]);
});
it('falls back to legacy client prefixes for old logs without inferring an app fingerprint', async () => {
const site = await db.insert(schema.sites).values({
name: 'legacy-site',
url: 'https://legacy.example.com',
platform: 'new-api',
}).returning().get();
const account = await db.insert(schema.accounts).values({
siteId: site.id,
username: 'legacy-user',
accessToken: 'legacy-token',
status: 'active',
}).returning().get();
const inserted = await db.insert(schema.proxyLogs).values({
accountId: account.id,
modelRequested: 'gpt-4o',
modelActual: 'gpt-4o',
status: 'failed',
errorMessage: '[client:codex] [session:turn-123] [downstream:/v1/responses] upstream error',
totalTokens: 9,
estimatedCost: 0.09,
createdAt: formatUtcSqlDateTime(new Date('2026-03-09T12:00:00.000Z')),
}).run();
const logId = Number(inserted.lastInsertRowid || 0);
const listResponse = await app.inject({
method: 'GET',
url: '/api/stats/proxy-logs',
});
const detailResponse = await app.inject({
method: 'GET',
url: `/api/stats/proxy-logs/${logId}`,
});
expect(listResponse.statusCode).toBe(200);
expect(detailResponse.statusCode).toBe(200);
const listBody = listResponse.json() as {
items: Array<Record<string, unknown>>;
};
const detailBody = detailResponse.json() as Record<string, unknown>;
expect(listBody.items[0]?.clientFamily).toBe('codex');
expect(listBody.items[0]?.clientAppId).toBe(null);
expect(listBody.items[0]?.clientAppName).toBe(null);
expect(detailBody.clientFamily).toBe('codex');
expect(detailBody.clientAppId).toBe(null);
expect(detailBody.clientAppName).toBe(null);
});
});
+154 -2
View File
@@ -17,6 +17,7 @@ import {
parseProxyLogBillingDetails,
withProxyLogSelectFields,
} from '../../services/proxyLogStore.js';
import { parseProxyLogMessageMeta } from '../proxy/logPathMeta.js';
import { requiresManagedAccountTokens } from '../../services/accountExtraConfig.js';
import { ACCOUNT_TOKEN_VALUE_STATUS_READY } from '../../services/accountTokenService.js';
import {
@@ -85,6 +86,22 @@ function proxyCostSqlExpression() {
}
type ProxyLogStatusFilter = 'all' | 'success' | 'failed';
type ProxyLogClientFilter = {
kind: 'app' | 'family';
value: string;
} | null;
type ProxyLogClientOption = {
value: string;
label: string;
};
const PROXY_LOG_CLIENT_FAMILY_LABELS: Record<string, string> = {
codex: 'Codex',
claude_code: 'Claude Code',
gemini_cli: 'Gemini CLI',
generic: '通用',
};
function normalizeProxyLogPageSize(raw?: string): number {
const parsed = Number.parseInt(raw || '50', 10);
@@ -115,6 +132,20 @@ function normalizeProxyLogSiteId(raw?: string): number | null {
return parsed;
}
function normalizeProxyLogClientFilter(raw?: string): ProxyLogClientFilter {
const text = (raw || '').trim();
if (!text) return null;
const separatorIndex = text.indexOf(':');
if (separatorIndex <= 0) return null;
const kind = text.slice(0, separatorIndex).trim().toLowerCase();
const value = text.slice(separatorIndex + 1).trim().toLowerCase();
if (!value) return null;
if (kind === 'app' || kind === 'family') {
return { kind, value };
}
return null;
}
function normalizeProxyLogTimeBoundary(raw?: string): string | null {
const text = (raw || '').trim();
if (!text) return null;
@@ -166,9 +197,18 @@ function buildProxyLogStatusCondition(status: ProxyLogStatusFilter) {
return null;
}
function buildProxyLogClientCondition(client: ProxyLogClientFilter) {
if (!client) return null;
if (client.kind === 'app') {
return eq(schema.proxyLogs.clientAppId, client.value);
}
return eq(schema.proxyLogs.clientFamily, client.value);
}
function buildProxyLogWhereClause(params: {
status?: ProxyLogStatusFilter;
search?: string;
client?: ProxyLogClientFilter;
siteId?: number | null;
fromUtc?: string | null;
toUtc?: string | null;
@@ -176,6 +216,7 @@ function buildProxyLogWhereClause(params: {
const conditions = [
params.status ? buildProxyLogStatusCondition(params.status) : null,
params.search ? buildProxyLogSearchCondition(params.search) : null,
params.client ? buildProxyLogClientCondition(params.client) : null,
params.siteId ? eq(schema.sites.id, params.siteId) : null,
params.fromUtc ? gte(schema.proxyLogs.createdAt, params.fromUtc) : null,
params.toUtc ? lt(schema.proxyLogs.createdAt, params.toUtc) : null,
@@ -189,6 +230,83 @@ function toRoundedMicroNumber(value: number | null | undefined): number {
return Math.round(Number(value || 0) * 1_000_000) / 1_000_000;
}
function normalizeNullableText(value: unknown): string | null {
if (typeof value !== 'string') return null;
const trimmed = value.trim();
return trimmed || null;
}
function normalizeClientConfidence(value: unknown): string | null {
const normalized = normalizeNullableText(value)?.toLowerCase() || null;
if (normalized === 'exact' || normalized === 'heuristic' || normalized === 'unknown') {
return normalized;
}
return null;
}
function displayProxyLogClientFamily(value: string | null): string | null {
if (!value) return null;
return PROXY_LOG_CLIENT_FAMILY_LABELS[value] || value;
}
function resolveProxyLogClientMeta(proxyLog: Record<string, unknown>) {
const clientFamily = normalizeNullableText(proxyLog.clientFamily)?.toLowerCase() || null;
const clientAppId = normalizeNullableText(proxyLog.clientAppId)?.toLowerCase() || null;
const clientAppName = normalizeNullableText(proxyLog.clientAppName) || null;
const clientConfidence = normalizeClientConfidence(proxyLog.clientConfidence);
if (clientFamily || clientAppId || clientAppName || clientConfidence) {
return {
clientFamily,
clientAppId,
clientAppName,
clientConfidence,
};
}
const legacyMeta = parseProxyLogMessageMeta(typeof proxyLog.errorMessage === 'string' ? proxyLog.errorMessage : '');
return {
clientFamily: normalizeNullableText(legacyMeta.clientKind)?.toLowerCase() || null,
clientAppId: null,
clientAppName: null,
clientConfidence: null,
};
}
function buildProxyLogClientOptions(rows: Array<{
clientFamily?: string | null;
clientAppId?: string | null;
clientAppName?: string | null;
}>): ProxyLogClientOption[] {
const appOptions = new Map<string, ProxyLogClientOption>();
const familyOptions = new Map<string, ProxyLogClientOption>();
for (const row of rows) {
const clientAppId = normalizeNullableText(row.clientAppId)?.toLowerCase() || null;
const clientAppName = normalizeNullableText(row.clientAppName) || null;
const clientFamily = normalizeNullableText(row.clientFamily)?.toLowerCase() || null;
if (clientAppId && clientAppName && !appOptions.has(clientAppId)) {
appOptions.set(clientAppId, {
value: `app:${clientAppId}`,
label: `应用 · ${clientAppName}`,
});
}
if (clientFamily && clientFamily !== 'generic' && !familyOptions.has(clientFamily)) {
familyOptions.set(clientFamily, {
value: `family:${clientFamily}`,
label: `协议 · ${displayProxyLogClientFamily(clientFamily) || clientFamily}`,
});
}
}
return [
...Array.from(appOptions.values()).sort((left, right) => left.label.localeCompare(right.label, 'zh-CN')),
...Array.from(familyOptions.values()).sort((left, right) => left.label.localeCompare(right.label, 'zh-CN')),
];
}
const SITE_AVAILABILITY_BUCKET_COUNT = 24;
const SITE_AVAILABILITY_BUCKET_MS = 60 * 60 * 1000;
@@ -351,11 +469,16 @@ function mapProxyLogRow(
},
options?: { includeBillingDetails?: boolean },
) {
const clientMeta = resolveProxyLogClientMeta(row.proxy_logs);
return {
...row.proxy_logs,
...(options?.includeBillingDetails
? { billingDetails: parseProxyLogBillingDetails(row.proxy_logs.billingDetails) }
: {}),
clientFamily: clientMeta.clientFamily,
clientAppId: clientMeta.clientAppId,
clientAppName: clientMeta.clientAppName,
clientConfidence: clientMeta.clientConfidence,
username: row.accounts?.username || null,
siteId: row.sites?.id || null,
siteName: row.sites?.name || null,
@@ -547,6 +670,7 @@ export async function statsRoutes(app: FastifyInstance) {
offset?: string;
status?: string;
search?: string;
client?: string;
siteId?: string;
from?: string;
to?: string;
@@ -555,11 +679,13 @@ export async function statsRoutes(app: FastifyInstance) {
const offset = normalizeProxyLogOffset(request.query.offset);
const status = normalizeProxyLogStatusFilter(request.query.status);
const search = normalizeProxyLogSearch(request.query.search);
const client = normalizeProxyLogClientFilter(request.query.client);
const siteId = normalizeProxyLogSiteId(request.query.siteId);
const fromUtc = normalizeProxyLogTimeBoundary(request.query.from);
const toUtc = normalizeProxyLogTimeBoundary(request.query.to);
const listWhere = buildProxyLogWhereClause({ status, search, siteId, fromUtc, toUtc });
const summaryWhere = buildProxyLogWhereClause({ search, siteId, fromUtc, toUtc });
const listWhere = buildProxyLogWhereClause({ status, search, client, siteId, fromUtc, toUtc });
const summaryWhere = buildProxyLogWhereClause({ search, client, siteId, fromUtc, toUtc });
const clientOptionsWhere = buildProxyLogWhereClause({ status, search, siteId, fromUtc, toUtc });
const listRows = await withProxyLogSelectFields(({ fields }) => {
let query = db.select({
@@ -604,6 +730,31 @@ export async function statsRoutes(app: FastifyInstance) {
}
const totalRow = await totalQuery.get();
const clientOptionRows = await withProxyLogSelectFields(({ fields, includeClientFields }) => {
if (!includeClientFields) {
return Promise.resolve([]);
}
let query = db.select({
clientFamily: fields.clientFamily!,
clientAppId: fields.clientAppId!,
clientAppName: fields.clientAppName!,
}).from(schema.proxyLogs)
.leftJoin(schema.accounts, eq(schema.proxyLogs.accountId, schema.accounts.id))
.leftJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
.leftJoin(schema.downstreamApiKeys, eq(schema.proxyLogs.downstreamApiKeyId, schema.downstreamApiKeys.id));
if (clientOptionsWhere) {
query = query.where(clientOptionsWhere) as typeof query;
}
return query.all();
}, { includeBillingDetails: false, includeClientFields: true }) as Array<{
clientFamily?: string | null;
clientAppId?: string | null;
clientAppName?: string | null;
}>;
let summaryQuery = db.select({
totalCount: sql<number>`count(*)`,
successCount: sql<number>`coalesce(sum(case when ${schema.proxyLogs.status} = 'success' then 1 else 0 end), 0)`,
@@ -624,6 +775,7 @@ export async function statsRoutes(app: FastifyInstance) {
total: Number(totalRow?.total || 0),
page: Math.floor(offset / limit) + 1,
pageSize: limit,
clientOptions: buildProxyLogClientOptions(clientOptionRows),
summary: {
totalCount: Number(summaryRow?.totalCount || 0),
successCount: Number(summaryRow?.successCount || 0),
@@ -14,6 +14,7 @@ const resolveProxyUsageWithSelfLogFallbackMock = vi.fn(async ({ usage }: any) =>
estimatedCostFromQuota: 0,
recoveredFromSelfLog: false,
}));
const refreshOauthAccessTokenSingleflightMock = vi.fn();
const dbInsertMock = vi.fn((_arg?: any) => ({
values: () => ({
run: () => undefined,
@@ -60,10 +61,16 @@ vi.mock('../../services/proxyUsageFallbackService.js', () => ({
resolveProxyUsageWithSelfLogFallback: (arg: any) => resolveProxyUsageWithSelfLogFallbackMock(arg),
}));
vi.mock('../../services/oauth/refreshSingleflight.js', () => ({
refreshOauthAccessTokenSingleflight: (...args: unknown[]) => refreshOauthAccessTokenSingleflightMock(...args),
}));
vi.mock('../../db/index.js', () => ({
db: {
insert: (arg: any) => dbInsertMock(arg),
},
hasProxyLogBillingDetailsColumn: async () => false,
hasProxyLogClientColumns: async () => false,
hasProxyLogDownstreamApiKeyIdColumn: async () => false,
schema: {
proxyLogs: {},
@@ -105,6 +112,7 @@ describe('chat proxy codex oauth compatibility', () => {
reportProxyAllFailedMock.mockReset();
reportTokenExpiredMock.mockReset();
resolveProxyUsageWithSelfLogFallbackMock.mockClear();
refreshOauthAccessTokenSingleflightMock.mockReset();
dbInsertMock.mockClear();
selectChannelMock.mockReturnValue({
@@ -128,6 +136,11 @@ describe('chat proxy codex oauth compatibility', () => {
actualModel: 'gpt-5.4',
});
selectNextChannelMock.mockReturnValue(null);
refreshOauthAccessTokenSingleflightMock.mockResolvedValue({
accessToken: 'fresh-access-token',
accountId: 33,
accountKey: 'chatgpt-account-123',
});
});
afterAll(async () => {
@@ -245,4 +258,107 @@ describe('chat proxy codex oauth compatibility', () => {
expect(response.body).not.toContain('event: response.created');
expect(response.body).not.toContain('"type":"response.output_text.delta"');
});
it('still translates raw codex SSE into OpenAI chat completion chunks when upstream mislabels the content-type', async () => {
fetchMock.mockResolvedValue(new Response([
'event: response.created\n',
'data: {"type":"response.created","response":{"id":"resp_codex_openai_stream_header_miss","model":"gpt-5.4","created_at":1706000000,"status":"in_progress","output":[]}}\n\n',
'event: response.output_item.added\n',
'data: {"type":"response.output_item.added","output_index":0,"item":{"id":"msg_codex_openai_stream_header_miss","type":"message","role":"assistant","status":"in_progress","content":[]}}\n\n',
'event: response.output_text.delta\n',
'data: {"type":"response.output_text.delta","output_index":0,"item_id":"msg_codex_openai_stream_header_miss","delta":"pong from codex"}\n\n',
'event: response.completed\n',
'data: {"type":"response.completed","response":{"id":"resp_codex_openai_stream_header_miss","model":"gpt-5.4","status":"completed","usage":{"input_tokens":9,"output_tokens":3,"total_tokens":12}}}\n\n',
'data: [DONE]\n\n',
].join(''), {
status: 200,
headers: { 'content-type': 'application/json' },
}));
const response = await app.inject({
method: 'POST',
url: '/v1/chat/completions',
payload: {
model: 'gpt-5.4',
stream: true,
messages: [{ role: 'user', content: 'hello codex' }],
},
});
expect(response.statusCode).toBe(200);
expect(response.headers['content-type']).toContain('text/event-stream');
expect(response.body).toContain('"object":"chat.completion.chunk"');
expect(response.body).toContain('pong from codex');
expect(response.body).not.toContain('event: response.created');
expect(response.body).not.toContain('"type":"response.output_text.delta"');
});
it('retries oauth chat requests with a normalized upstream URL after refresh', async () => {
selectChannelMock.mockReturnValue({
channel: { id: 11, routeId: 22 },
site: { name: 'openai-site', url: 'https://gateway.example.com/v1/', platform: 'openai' },
account: {
id: 33,
username: 'oauth-user@example.com',
extraConfig: JSON.stringify({
credentialMode: 'session',
oauth: {
provider: 'codex',
accountId: 'chatgpt-account-123',
email: 'oauth-user@example.com',
planType: 'plus',
},
}),
},
tokenName: 'default',
tokenValue: 'expired-access-token',
actualModel: 'gpt-4o-mini',
});
fetchMock
.mockResolvedValueOnce(new Response(JSON.stringify({
error: { message: 'expired token', type: 'invalid_request_error' },
}), {
status: 401,
headers: { 'content-type': 'application/json' },
}))
.mockResolvedValueOnce(new Response(JSON.stringify({
id: 'chatcmpl_refreshed',
object: 'chat.completion',
created: 1706000000,
model: 'gpt-4o-mini',
choices: [
{
index: 0,
message: { role: 'assistant', content: 'ok after refresh' },
finish_reason: 'stop',
},
],
usage: { prompt_tokens: 4, completion_tokens: 2, total_tokens: 6 },
}), {
status: 200,
headers: { 'content-type': 'application/json' },
}));
const response = await app.inject({
method: 'POST',
url: '/v1/chat/completions',
payload: {
model: 'gpt-4o-mini',
messages: [{ role: 'user', content: 'hello oauth' }],
},
});
expect(response.statusCode).toBe(200);
expect(refreshOauthAccessTokenSingleflightMock).toHaveBeenCalledWith(33);
expect(fetchMock).toHaveBeenCalledTimes(2);
const [firstUrl, firstOptions] = fetchMock.mock.calls[0] as [string, any];
const [secondUrl, secondOptions] = fetchMock.mock.calls[1] as [string, any];
expect(firstUrl).toBe('https://gateway.example.com/v1/responses');
expect(secondUrl).toBe('https://gateway.example.com/v1/responses');
expect(firstOptions.headers.Authorization).toBe('Bearer expired-access-token');
expect(secondOptions.headers.Authorization).toBe('Bearer fresh-access-token');
expect(response.json()?.choices?.[0]?.message?.content).toBe('ok after refresh');
});
});
@@ -68,6 +68,8 @@ vi.mock('../../db/index.js', () => ({
db: {
insert: (arg: any) => dbInsertMock(arg),
},
hasProxyLogBillingDetailsColumn: async () => false,
hasProxyLogClientColumns: async () => false,
hasProxyLogDownstreamApiKeyIdColumn: async () => false,
schema: {
proxyLogs: {},
+223 -7
View File
@@ -1,6 +1,7 @@
import Fastify, { type FastifyInstance } from 'fastify';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { config } from '../../config.js';
import { resetUpstreamEndpointRuntimeState } from './upstreamEndpoint.js';
const fetchMock = vi.fn();
const selectChannelMock = vi.fn();
@@ -68,6 +69,9 @@ vi.mock('../../db/index.js', () => ({
db: {
insert: (arg: any) => dbInsertMock(arg),
},
hasProxyLogBillingDetailsColumn: async () => false,
hasProxyLogClientColumns: async () => false,
hasProxyLogDownstreamApiKeyIdColumn: async () => false,
schema: {
proxyLogs: {},
},
@@ -99,6 +103,7 @@ describe('chat proxy stream behavior', () => {
fetchModelPricingCatalogMock.mockReset();
resolveProxyUsageWithSelfLogFallbackMock.mockClear();
dbInsertMock.mockClear();
resetUpstreamEndpointRuntimeState();
selectChannelMock.mockReturnValue({
channel: { id: 11, routeId: 22 },
@@ -110,6 +115,17 @@ describe('chat proxy stream behavior', () => {
});
selectNextChannelMock.mockReturnValue(null);
fetchModelPricingCatalogMock.mockResolvedValue(null);
(config as any).codexHeaderDefaults = {
userAgent: '',
betaFeatures: '',
};
(config as any).payloadRules = {
default: [],
defaultRaw: [],
override: [],
overrideRaw: [],
filter: [],
};
config.proxyEmptyContentFailEnabled = false;
config.proxyErrorKeywords = [];
});
@@ -2016,6 +2032,87 @@ describe('chat proxy stream behavior', () => {
expect(targetUrl).toContain('/v1/messages');
});
it('does not stick generic /v1/responses traffic to /v1/messages after a fallback success', async () => {
selectChannelMock.mockReturnValue({
channel: { id: 11, routeId: 22 },
site: { name: 'generic-site', url: 'https://upstream.example.com', platform: 'new-api' },
account: { id: 33, username: 'demo-user' },
tokenName: 'default',
tokenValue: 'sk-demo',
actualModel: 'upstream-gpt',
});
fetchMock
.mockResolvedValueOnce(new Response(JSON.stringify({
error: { message: 'Gateway time-out', type: 'upstream_error' },
}), {
status: 504,
headers: { 'content-type': 'application/json' },
}))
.mockResolvedValueOnce(new Response(JSON.stringify({
error: { message: 'Bad gateway', type: 'upstream_error' },
}), {
status: 502,
headers: { 'content-type': 'application/json' },
}))
.mockResolvedValueOnce(new Response(JSON.stringify({
id: 'msg_fallback_1',
type: 'message',
model: 'upstream-gpt',
content: [{ type: 'text', text: 'ok via messages fallback' }],
stop_reason: 'end_turn',
usage: { input_tokens: 7, output_tokens: 3, total_tokens: 10 },
}), {
status: 200,
headers: { 'content-type': 'application/json' },
}))
.mockResolvedValueOnce(new Response(JSON.stringify({
id: 'resp_recovered_1',
object: 'response',
model: 'upstream-gpt',
status: 'completed',
output_text: 'ok via recovered responses',
usage: { input_tokens: 6, output_tokens: 2, total_tokens: 8 },
}), {
status: 200,
headers: { 'content-type': 'application/json' },
}));
const firstResponse = await app.inject({
method: 'POST',
url: '/v1/responses',
payload: {
model: 'gpt-5.4',
input: 'hello',
},
});
expect(firstResponse.statusCode).toBe(200);
expect(firstResponse.json().output_text).toContain('ok via messages fallback');
const secondResponse = await app.inject({
method: 'POST',
url: '/v1/responses',
payload: {
model: 'gpt-5.4',
input: 'hello again',
},
});
expect(secondResponse.statusCode).toBe(200);
expect(secondResponse.json().output_text).toContain('ok via recovered responses');
expect(fetchMock).toHaveBeenCalledTimes(4);
const [firstUrl] = fetchMock.mock.calls[0] as [string, any];
const [secondUrl] = fetchMock.mock.calls[1] as [string, any];
const [thirdUrl] = fetchMock.mock.calls[2] as [string, any];
const [fourthUrl] = fetchMock.mock.calls[3] as [string, any];
expect(firstUrl).toContain('/v1/responses');
expect(secondUrl).toContain('/v1/chat/completions');
expect(thirdUrl).toContain('/v1/messages');
expect(fourthUrl).toContain('/v1/responses');
});
it('prefers native /v1/responses for claude-family /v1/responses requests that explicitly ask for encrypted reasoning', async () => {
selectChannelMock.mockReturnValue({
channel: { id: 11, routeId: 22 },
@@ -2193,7 +2290,7 @@ describe('chat proxy stream behavior', () => {
});
});
it('returns clear 400 when input_file file_url is sent to a claude-only upstream without native responses support', async () => {
it('converts input_file file_url into Claude document url blocks for claude-only upstreams', async () => {
selectChannelMock.mockReturnValue({
channel: { id: 11, routeId: 22 },
site: { name: 'claude-site', url: 'https://upstream.example.com', platform: 'claude' },
@@ -2202,6 +2299,18 @@ describe('chat proxy stream behavior', () => {
tokenValue: 'sk-demo',
actualModel: 'upstream-claude',
});
fetchMock.mockResolvedValue(new Response(JSON.stringify({
id: 'msg_file_url_1',
type: 'message',
role: 'assistant',
model: 'upstream-claude',
content: [{ type: 'text', text: 'hello from claude messages' }],
stop_reason: 'end_turn',
usage: { input_tokens: 7, output_tokens: 3 },
}), {
status: 200,
headers: { 'content-type': 'application/json' },
}));
const response = await app.inject({
method: 'POST',
@@ -2225,12 +2334,119 @@ describe('chat proxy stream behavior', () => {
},
});
expect(response.statusCode).toBe(400);
const body = response.json();
expect(body?.error?.type).toBe('invalid_request_error');
expect(body?.error?.message).toContain('input_file.file_url');
expect(body?.error?.message).toContain('/v1/responses');
expect(fetchMock).not.toHaveBeenCalled();
expect(response.statusCode).toBe(200);
expect(fetchMock).toHaveBeenCalledTimes(1);
const [targetUrl, options] = fetchMock.mock.calls[0] as [string, any];
expect(targetUrl).toContain('/v1/messages');
const forwardedBody = JSON.parse(options.body);
expect(forwardedBody.messages[0].content[1]).toMatchObject({
type: 'document',
title: 'remote.pdf',
source: {
type: 'url',
url: 'https://example.com/remote.pdf',
},
});
});
it('does not let remote document url success poison later inline document endpoint preference', async () => {
selectChannelMock.mockReturnValue({
channel: { id: 11, routeId: 22 },
site: { name: 'generic-site', url: 'https://upstream.example.com', platform: 'new-api' },
account: { id: 33, username: 'demo-user' },
tokenName: 'default',
tokenValue: 'sk-demo',
actualModel: 'upstream-gpt',
});
fetchMock.mockImplementation(async (target: unknown) => {
const url = String(target);
if (url.includes('/v1/responses')) {
return new Response(JSON.stringify({
id: 'resp_file_url_runtime_1',
object: 'response',
model: 'upstream-gpt',
output_text: 'hello from responses upstream',
output: [
{
id: 'msg_file_url_runtime_1',
type: 'message',
role: 'assistant',
content: [{ type: 'output_text', text: 'hello from responses upstream' }],
},
],
status: 'completed',
usage: { input_tokens: 7, output_tokens: 3, total_tokens: 10 },
}), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url.includes('/v1/messages')) {
return new Response(JSON.stringify({
id: 'msg_inline_runtime_1',
type: 'message',
role: 'assistant',
model: 'upstream-gpt',
content: [{ type: 'text', text: 'hello from messages upstream' }],
stop_reason: 'end_turn',
usage: { input_tokens: 7, output_tokens: 3 },
}), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
throw new Error(`unexpected target url: ${url}`);
});
const remoteResponse = await app.inject({
method: 'POST',
url: '/v1/responses',
payload: {
model: 'claude-haiku-4-5-20251001',
input: [
{
type: 'message',
role: 'user',
content: [
{ type: 'input_text', text: 'read this remote file' },
{
type: 'input_file',
filename: 'remote.pdf',
file_url: 'https://example.com/remote.pdf',
},
],
},
],
},
});
expect(remoteResponse.statusCode).toBe(200);
expect(String(fetchMock.mock.calls[0]?.[0])).toContain('/v1/responses');
const inlineResponse = await app.inject({
method: 'POST',
url: '/v1/responses',
payload: {
model: 'claude-haiku-4-5-20251001',
input: [
{
type: 'message',
role: 'user',
content: [
{ type: 'input_text', text: 'read this inline file' },
{
type: 'input_file',
filename: 'brief.pdf',
file_data: 'data:application/pdf;base64,JVBERi0xLjQK',
},
],
},
],
},
});
expect(inlineResponse.statusCode).toBe(200);
expect(String(fetchMock.mock.calls[1]?.[0])).toContain('/v1/messages');
});
it('prefers native /v1/responses for claude-family /v1/responses requests that opt into reasoning without injecting a generic default include', async () => {
+159 -66
View File
@@ -1,6 +1,5 @@
import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { fetch } from 'undici';
import { db, hasProxyLogDownstreamApiKeyIdColumn, schema } from '../../db/index.js';
import { tokenRouter } from '../../services/tokenRouter.js';
import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js';
import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js';
@@ -15,8 +14,10 @@ import { composeProxyLogMessage } from './logPathMeta.js';
import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
import { detectProxyFailure } from './proxyFailureJudge.js';
import { resolveProxyLogBilling } from './proxyBilling.js';
import { getProxyAuthContext } from '../../middleware/auth.js';
import { buildUpstreamUrl } from './upstreamUrl.js';
import { getProxyAuthContext } from '../../middleware/auth.js';
import { buildUpstreamUrl } from './upstreamUrl.js';
import { detectDownstreamClientContext, type DownstreamClientContext } from './downstreamClientContext.js';
import { insertProxyLog } from '../../services/proxyLogStore.js';
const MAX_RETRIES = 2;
@@ -26,12 +27,16 @@ export async function completionsProxyRoute(app: FastifyInstance) {
const requestedModel = body?.model;
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 downstreamApiKeyId = getProxyAuthContext(request)?.keyId ?? null;
const logDownstreamApiKeyId = downstreamApiKeyId !== null
&& await hasProxyLogDownstreamApiKeyIdColumn();
}
if (!await ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return;
const downstreamPolicy = getDownstreamRoutingPolicy(request);
const downstreamApiKeyId = getProxyAuthContext(request)?.keyId ?? null;
const downstreamPath = '/v1/completions';
const clientContext = detectDownstreamClientContext({
downstreamPath,
headers: request.headers as Record<string, unknown>,
body,
});
const isStream = body.stream === true;
const excludeChannelIds: number[] = [];
@@ -75,8 +80,28 @@ export async function completionsProxyRoute(app: FastifyInstance) {
if (!upstream.ok) {
const errText = await upstream.text().catch(() => 'unknown error');
tokenRouter.recordFailure(selected.channel.id);
logProxy(selected, requestedModel, 'failed', upstream.status, Date.now() - startTime, errText, retryCount, logDownstreamApiKeyId ? downstreamApiKeyId : null);
tokenRouter.recordFailure(selected.channel.id, {
status: upstream.status,
errorText: errText,
modelName: selected.actualModel,
});
logProxy(
selected,
requestedModel,
'failed',
upstream.status,
Date.now() - startTime,
errText,
retryCount,
downstreamApiKeyId,
0,
0,
0,
0,
null,
clientContext,
downstreamPath,
);
if (isTokenExpiredError({ status: upstream.status, message: errText })) {
await reportTokenExpired({
@@ -174,12 +199,25 @@ export async function completionsProxyRoute(app: FastifyInstance) {
parsedUsage,
resolvedUsage,
});
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
recordDownstreamCostUsage(request, estimatedCost);
logProxy(
selected, requestedModel, 'success', 200, latency, null, retryCount, logDownstreamApiKeyId ? downstreamApiKeyId : null,
resolvedUsage.promptTokens, resolvedUsage.completionTokens, resolvedUsage.totalTokens, estimatedCost, billingDetails,
);
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost, selected.actualModel);
recordDownstreamCostUsage(request, estimatedCost);
logProxy(
selected,
requestedModel,
'success',
200,
latency,
null,
retryCount,
downstreamApiKeyId,
resolvedUsage.promptTokens,
resolvedUsage.completionTokens,
resolvedUsage.totalTokens,
estimatedCost,
billingDetails,
clientContext,
downstreamPath,
);
return;
}
@@ -195,17 +233,28 @@ export async function completionsProxyRoute(app: FastifyInstance) {
const failure = detectProxyFailure({ rawText, usage: parsedUsage });
if (failure) {
const errText = failure.reason;
tokenRouter.recordFailure(selected.channel.id);
tokenRouter.recordFailure(selected.channel.id, {
status: failure.status,
errorText: errText,
modelName: selected.actualModel,
});
logProxy(
selected,
requestedModel,
'failed',
failure.status,
latency,
errText,
retryCount,
logDownstreamApiKeyId ? downstreamApiKeyId : null,
);
failure.status,
latency,
errText,
retryCount,
downstreamApiKeyId,
0,
0,
0,
0,
null,
clientContext,
downstreamPath,
);
if (shouldRetryProxyRequest(failure.status, errText) && retryCount < MAX_RETRIES) {
retryCount += 1;
@@ -245,16 +294,49 @@ export async function completionsProxyRoute(app: FastifyInstance) {
resolvedUsage,
});
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
recordDownstreamCostUsage(request, estimatedCost);
logProxy(
selected, requestedModel, 'success', 200, latency, null, retryCount, logDownstreamApiKeyId ? downstreamApiKeyId : null,
resolvedUsage.promptTokens, resolvedUsage.completionTokens, resolvedUsage.totalTokens, estimatedCost, billingDetails,
);
return reply.send(data);
} catch (err: any) {
tokenRouter.recordFailure(selected.channel.id);
logProxy(selected, requestedModel, 'failed', 0, Date.now() - startTime, err.message, retryCount, logDownstreamApiKeyId ? downstreamApiKeyId : null);
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost, selected.actualModel);
recordDownstreamCostUsage(request, estimatedCost);
logProxy(
selected,
requestedModel,
'success',
200,
latency,
null,
retryCount,
downstreamApiKeyId,
resolvedUsage.promptTokens,
resolvedUsage.completionTokens,
resolvedUsage.totalTokens,
estimatedCost,
billingDetails,
clientContext,
downstreamPath,
);
return reply.send(data);
} catch (err: any) {
tokenRouter.recordFailure(selected.channel.id, {
status: 0,
errorText: err.message,
modelName: selected.actualModel,
});
logProxy(
selected,
requestedModel,
'failed',
0,
Date.now() - startTime,
err.message,
retryCount,
downstreamApiKeyId,
0,
0,
0,
0,
null,
clientContext,
downstreamPath,
);
if (retryCount < MAX_RETRIES) {
retryCount++;
continue;
@@ -271,47 +353,58 @@ export async function completionsProxyRoute(app: FastifyInstance) {
});
}
async function logProxy(
selected: any,
modelRequested: string,
status: string,
async function logProxy(
selected: any,
modelRequested: string,
status: string,
httpStatus: number,
latencyMs: number,
errorMessage: string | null,
retryCount: number,
downstreamApiKeyId: number | null = null,
promptTokens = 0,
completionTokens = 0,
totalTokens = 0,
estimatedCost = 0,
billingDetails: unknown = null,
) {
try {
const createdAt = formatUtcSqlDateTime(new Date());
const normalizedErrorMessage = composeProxyLogMessage({
downstreamPath: '/v1/completions',
errorMessage,
});
await db.insert(schema.proxyLogs).values({
routeId: selected.channel.routeId,
channelId: selected.channel.id,
accountId: selected.account.id,
...(downstreamApiKeyId !== null ? { downstreamApiKeyId } : {}),
modelRequested,
modelActual: selected.actualModel,
status,
completionTokens = 0,
totalTokens = 0,
estimatedCost = 0,
billingDetails: unknown = null,
clientContext: DownstreamClientContext | null = null,
downstreamPath = '/v1/completions',
) {
try {
const createdAt = formatUtcSqlDateTime(new Date());
const normalizedErrorMessage = composeProxyLogMessage({
clientKind: clientContext?.clientKind && clientContext.clientKind !== 'generic'
? clientContext.clientKind
: null,
sessionId: clientContext?.sessionId || null,
traceHint: clientContext?.traceHint || null,
downstreamPath,
errorMessage,
});
await insertProxyLog({
routeId: selected.channel.routeId,
channelId: selected.channel.id,
accountId: selected.account.id,
downstreamApiKeyId,
modelRequested,
modelActual: selected.actualModel,
status,
httpStatus,
latencyMs,
promptTokens,
completionTokens,
totalTokens,
estimatedCost,
billingDetails: billingDetails ? JSON.stringify(billingDetails) : null,
errorMessage: normalizedErrorMessage,
retryCount,
createdAt,
}).run();
} catch (error) {
completionTokens,
totalTokens,
estimatedCost,
billingDetails,
clientFamily: clientContext?.clientKind || null,
clientAppId: clientContext?.clientAppId || null,
clientAppName: clientContext?.clientAppName || null,
clientConfidence: clientContext?.clientConfidence || null,
errorMessage: normalizedErrorMessage,
retryCount,
createdAt,
});
} catch (error) {
console.warn('[proxy/completions] failed to write proxy log', error);
}
}
@@ -71,6 +71,9 @@ vi.mock('../../db/index.js', () => ({
schema: {
proxyLogs: {},
},
hasProxyLogBillingDetailsColumn: async () => false,
hasProxyLogClientColumns: async () => false,
hasProxyLogDownstreamApiKeyIdColumn: async () => false,
}));
describe('downstream client context route logging', () => {
@@ -33,6 +33,16 @@ describe('isCodexResponsesSurface', () => {
})).toBe(true);
});
it('detects broader Codex official-client family headers from user-agent and originator prefixes', () => {
expect(isCodexResponsesSurface({
'user-agent': 'Mozilla/5.0 codex_chatgpt_desktop/1.2.3',
})).toBe(true);
expect(isCodexResponsesSurface({
originator: 'codex_vscode',
})).toBe(true);
});
it('returns false for generic responses clients', () => {
expect(isCodexResponsesSurface({
'content-type': 'application/json',
@@ -50,6 +60,9 @@ describe('detectDownstreamClientContext', () => {
},
})).toEqual({
clientKind: 'codex',
clientAppId: 'codex_cli_rs',
clientAppName: 'Codex CLI',
clientConfidence: 'exact',
sessionId: 'codex-session-123',
traceHint: 'codex-session-123',
});
@@ -63,6 +76,37 @@ describe('detectDownstreamClientContext', () => {
},
})).toEqual({
clientKind: 'codex',
clientAppId: 'codex',
clientAppName: 'Codex',
clientConfidence: 'heuristic',
});
});
it('recognizes broader Codex official-client user-agent families without requiring stainless headers', () => {
expect(detectDownstreamClientContext({
downstreamPath: '/v1/responses',
headers: {
'user-agent': 'Mozilla/5.0 codex_chatgpt_desktop/1.2.3',
},
})).toEqual({
clientKind: 'codex',
clientAppId: 'codex_chatgpt_desktop',
clientAppName: 'Codex Desktop',
clientConfidence: 'exact',
});
});
it('recognizes broader Codex official-client originator prefixes', () => {
expect(detectDownstreamClientContext({
downstreamPath: '/v1/responses',
headers: {
originator: 'codex_exec',
},
})).toEqual({
clientKind: 'codex',
clientAppId: 'codex_exec',
clientAppName: 'Codex Exec',
clientConfidence: 'exact',
});
});
@@ -79,6 +123,9 @@ describe('detectDownstreamClientContext', () => {
body,
})).toEqual({
clientKind: 'claude_code',
clientAppId: 'claude_code',
clientAppName: 'Claude Code',
clientConfidence: 'exact',
sessionId: 'f25958b8-e75c-455d-8b40-f006d87cc2a4',
traceHint: 'f25958b8-e75c-455d-8b40-f006d87cc2a4',
});
@@ -128,6 +175,54 @@ describe('detectDownstreamClientContext', () => {
},
})).toEqual({
clientKind: 'gemini_cli',
clientAppId: 'gemini_cli',
clientAppName: 'Gemini CLI',
clientConfidence: 'exact',
});
});
it('recognizes app fingerprints alongside a generic protocol family', () => {
expect(detectDownstreamClientContext({
downstreamPath: '/v1/chat/completions',
headers: {
'x-title': 'Cherry Studio',
'http-referer': 'https://cherry-ai.com',
},
})).toEqual({
clientKind: 'generic',
clientAppId: 'cherry_studio',
clientAppName: 'Cherry Studio',
clientConfidence: 'exact',
});
});
it('keeps protocol family detection when an app fingerprint also matches', () => {
expect(detectDownstreamClientContext({
downstreamPath: '/v1/responses',
headers: {
originator: 'codex_cli_rs',
'x-title': 'Cherry Studio',
'http-referer': 'https://cherry-ai.com',
},
})).toEqual({
clientKind: 'codex',
clientAppId: 'cherry_studio',
clientAppName: 'Cherry Studio',
clientConfidence: 'exact',
});
});
it('marks weak app-only matches as heuristic instead of upgrading protocol behavior', () => {
expect(detectDownstreamClientContext({
downstreamPath: '/v1/chat/completions',
headers: {
'user-agent': 'CherryStudio/1.2.3',
},
})).toEqual({
clientKind: 'generic',
clientAppId: 'cherry_studio',
clientAppName: 'Cherry Studio',
clientConfidence: 'heuristic',
});
});
});
@@ -6,13 +6,269 @@ import { isCodexResponsesSurface as isCodexResponsesSurfaceViaProfile } from '..
import type { CliProfileId } from '../../proxy-core/cliProfiles/types.js';
export type DownstreamClientKind = CliProfileId;
export type DownstreamClientConfidence = 'exact' | 'heuristic';
export type DownstreamClientContext = {
clientKind: DownstreamClientKind;
sessionId?: string;
traceHint?: string;
clientAppId?: string;
clientAppName?: string;
clientConfidence?: DownstreamClientConfidence;
};
type NormalizedClientHeaders = Record<string, string[]>;
type DownstreamClientBodySummary = {
topLevelKeys: string[];
metadataUserId: string | null;
};
type DownstreamClientFingerprintInput = {
downstreamPath: string;
headers: NormalizedClientHeaders;
bodySummary: DownstreamClientBodySummary;
};
type DownstreamClientFingerprintRule = {
id: string;
name: string;
priority: number;
match(input: DownstreamClientFingerprintInput): DownstreamClientConfidence | null;
};
type DownstreamProtocolClientApp = {
clientAppId: string;
clientAppName: string;
clientConfidence: DownstreamClientConfidence;
};
type HeaderPrefixMatcherRule = {
id: string;
name: string;
userAgentPrefixes?: string[];
originatorPrefixes?: string[];
};
const codexOfficialClientAppRules: HeaderPrefixMatcherRule[] = [
{
id: 'codex_cli_rs',
name: 'Codex CLI',
userAgentPrefixes: ['codex_cli_rs/'],
originatorPrefixes: ['codex_cli_rs'],
},
{
id: 'codex_vscode',
name: 'Codex VSCode',
userAgentPrefixes: ['codex_vscode/'],
originatorPrefixes: ['codex_vscode'],
},
{
id: 'codex_app',
name: 'Codex App',
userAgentPrefixes: ['codex_app/'],
originatorPrefixes: ['codex_app'],
},
{
id: 'codex_chatgpt_desktop',
name: 'Codex Desktop',
userAgentPrefixes: ['codex_chatgpt_desktop/', 'codex desktop/'],
originatorPrefixes: ['codex_chatgpt_desktop', 'codex desktop'],
},
{
id: 'codex_atlas',
name: 'Codex Atlas',
userAgentPrefixes: ['codex_atlas/'],
originatorPrefixes: ['codex_atlas'],
},
{
id: 'codex_exec',
name: 'Codex Exec',
userAgentPrefixes: ['codex_exec/'],
originatorPrefixes: ['codex_exec'],
},
{
id: 'codex_sdk_ts',
name: 'Codex SDK TS',
userAgentPrefixes: ['codex_sdk_ts/'],
originatorPrefixes: ['codex_sdk_ts'],
},
];
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value);
}
function normalizeHeaderValues(value: unknown): string[] {
if (typeof value === 'string') {
const trimmed = value.trim();
return trimmed ? [trimmed] : [];
}
if (!Array.isArray(value)) return [];
return value
.filter((item): item is string => typeof item === 'string')
.map((item) => item.trim())
.filter((item) => item.length > 0);
}
function normalizeHeaders(headers?: Record<string, unknown>): NormalizedClientHeaders {
if (!headers) return {};
const normalized: NormalizedClientHeaders = {};
for (const [rawKey, rawValue] of Object.entries(headers)) {
const key = rawKey.trim().toLowerCase();
if (!key) continue;
const values = normalizeHeaderValues(rawValue);
if (values.length === 0) continue;
normalized[key] = normalized[key]
? [...normalized[key], ...values]
: values;
}
return normalized;
}
function headerEquals(headers: NormalizedClientHeaders, key: string, expected: string): boolean {
const normalizedExpected = expected.trim().toLowerCase();
return (headers[key.trim().toLowerCase()] || []).some((value) => value.trim().toLowerCase() === normalizedExpected);
}
function headerIncludes(headers: NormalizedClientHeaders, key: string, expectedFragment: string): boolean {
const normalizedExpected = expectedFragment.trim().toLowerCase();
return (headers[key.trim().toLowerCase()] || []).some((value) => value.trim().toLowerCase().includes(normalizedExpected));
}
function headerMatchesPrefixes(headers: NormalizedClientHeaders, key: string, prefixes: string[]): boolean {
return prefixes.some((prefix) => headerIncludes(headers, key, prefix));
}
function buildBodySummary(body: unknown): DownstreamClientBodySummary {
if (!isRecord(body)) {
return {
topLevelKeys: [],
metadataUserId: null,
};
}
const metadataUserId = isRecord(body.metadata) && typeof body.metadata.user_id === 'string'
? body.metadata.user_id.trim() || null
: null;
return {
topLevelKeys: Object.keys(body).sort((left, right) => left.localeCompare(right)),
metadataUserId,
};
}
const appFingerprintRegistry: DownstreamClientFingerprintRule[] = [
{
id: 'cherry_studio',
name: 'Cherry Studio',
priority: 100,
match(input) {
const hasTitle = headerEquals(input.headers, 'x-title', 'Cherry Studio');
const hasReferer = headerEquals(input.headers, 'http-referer', 'https://cherry-ai.com')
|| headerEquals(input.headers, 'referer', 'https://cherry-ai.com');
if (hasTitle && hasReferer) {
return 'exact';
}
const weakSignals = [
headerIncludes(input.headers, 'user-agent', 'cherrystudio'),
headerIncludes(input.headers, 'x-title', 'cherry studio'),
headerIncludes(input.headers, 'http-referer', 'cherry-ai.com'),
headerIncludes(input.headers, 'referer', 'cherry-ai.com'),
];
return weakSignals.some(Boolean) ? 'heuristic' : null;
},
},
];
function detectDownstreamClientFingerprint(input: {
downstreamPath: string;
headers?: Record<string, unknown>;
body?: unknown;
}) {
const fingerprintInput: DownstreamClientFingerprintInput = {
downstreamPath: input.downstreamPath,
headers: normalizeHeaders(input.headers),
bodySummary: buildBodySummary(input.body),
};
let matchedRule: DownstreamClientFingerprintRule | null = null;
let matchedConfidence: DownstreamClientConfidence | null = null;
for (const rule of appFingerprintRegistry) {
const confidence = rule.match(fingerprintInput);
if (!confidence) continue;
if (!matchedRule || rule.priority > matchedRule.priority) {
matchedRule = rule;
matchedConfidence = confidence;
}
}
if (!matchedRule || !matchedConfidence) {
return null;
}
return {
clientAppId: matchedRule.id,
clientAppName: matchedRule.name,
clientConfidence: matchedConfidence,
};
}
function detectCodexOfficialClientApp(headers: NormalizedClientHeaders): DownstreamProtocolClientApp | null {
for (const rule of codexOfficialClientAppRules) {
const matchesOriginator = Array.isArray(rule.originatorPrefixes)
&& headerMatchesPrefixes(headers, 'originator', rule.originatorPrefixes);
const matchesUserAgent = Array.isArray(rule.userAgentPrefixes)
&& headerMatchesPrefixes(headers, 'user-agent', rule.userAgentPrefixes);
if (!matchesOriginator && !matchesUserAgent) continue;
return {
clientAppId: rule.id,
clientAppName: rule.name,
clientConfidence: 'exact',
};
}
return null;
}
function detectProtocolClientApp(input: {
clientKind: DownstreamClientKind;
headers?: Record<string, unknown>;
}): DownstreamProtocolClientApp | null {
switch (input.clientKind) {
case 'claude_code':
return {
clientAppId: 'claude_code',
clientAppName: 'Claude Code',
clientConfidence: 'exact',
};
case 'gemini_cli':
return {
clientAppId: 'gemini_cli',
clientAppName: 'Gemini CLI',
clientConfidence: 'exact',
};
case 'codex': {
const headers = normalizeHeaders(input.headers);
return detectCodexOfficialClientApp(headers) || {
clientAppId: 'codex',
clientAppName: 'Codex',
clientConfidence: 'heuristic',
};
}
default:
return null;
}
}
export function isCodexResponsesSurface(headers?: Record<string, unknown>): boolean {
return isCodexResponsesSurfaceViaProfile(headers);
}
@@ -27,9 +283,15 @@ export function detectDownstreamClientContext(input: {
body?: unknown;
}): DownstreamClientContext {
const detected = detectCliProfile(input);
const fingerprint = detectDownstreamClientFingerprint(input);
const protocolClientApp = fingerprint ? null : detectProtocolClientApp({
clientKind: detected.id,
headers: input.headers,
});
return {
clientKind: detected.id,
...(detected.sessionId ? { sessionId: detected.sessionId } : {}),
...(detected.traceHint ? { traceHint: detected.traceHint } : {}),
...(fingerprint || protocolClientApp || {}),
};
}
+3 -1
View File
@@ -4,7 +4,9 @@ import { isModelAllowedByPolicyOrAllowedRoutes, recordManagedKeyCostUsage } from
import { EMPTY_DOWNSTREAM_ROUTING_POLICY, type DownstreamRoutingPolicy } from '../../services/downstreamPolicyTypes.js';
export function getDownstreamRoutingPolicy(request: FastifyRequest): DownstreamRoutingPolicy {
return getProxyAuthContext(request)?.policy || EMPTY_DOWNSTREAM_ROUTING_POLICY;
const authContext = getProxyAuthContext(request);
if (!authContext) return EMPTY_DOWNSTREAM_ROUTING_POLICY;
return authContext.policy;
}
export async function ensureModelAllowedForDownstreamKey(
+73 -16
View File
@@ -1,6 +1,5 @@
import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { fetch } from 'undici';
import { db, hasProxyLogDownstreamApiKeyIdColumn, schema } from '../../db/index.js';
import { tokenRouter } from '../../services/tokenRouter.js';
import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js';
import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js';
@@ -16,6 +15,8 @@ import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
import { resolveProxyLogBilling } from './proxyBilling.js';
import { getProxyAuthContext } from '../../middleware/auth.js';
import { buildUpstreamUrl } from './upstreamUrl.js';
import { detectDownstreamClientContext, type DownstreamClientContext } from './downstreamClientContext.js';
import { insertProxyLog } from '../../services/proxyLogStore.js';
const MAX_RETRIES = 2;
@@ -29,8 +30,12 @@ export async function embeddingsProxyRoute(app: FastifyInstance) {
if (!await ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return;
const downstreamPolicy = getDownstreamRoutingPolicy(request);
const downstreamApiKeyId = getProxyAuthContext(request)?.keyId ?? null;
const logDownstreamApiKeyId = downstreamApiKeyId !== null
&& await hasProxyLogDownstreamApiKeyIdColumn();
const downstreamPath = '/v1/embeddings';
const clientContext = detectDownstreamClientContext({
downstreamPath,
headers: request.headers as Record<string, unknown>,
body,
});
const excludeChannelIds: number[] = [];
let retryCount = 0;
@@ -71,8 +76,28 @@ export async function embeddingsProxyRoute(app: FastifyInstance) {
const text = await upstream.text();
if (!upstream.ok) {
tokenRouter.recordFailure(selected.channel.id);
logProxy(selected, requestedModel, 'failed', upstream.status, Date.now() - startTime, text, retryCount, logDownstreamApiKeyId ? downstreamApiKeyId : null);
tokenRouter.recordFailure(selected.channel.id, {
status: upstream.status,
errorText: text,
modelName: selected.actualModel,
});
logProxy(
selected,
requestedModel,
'failed',
upstream.status,
Date.now() - startTime,
text,
retryCount,
downstreamApiKeyId,
0,
0,
0,
0,
null,
clientContext,
downstreamPath,
);
if (isTokenExpiredError({ status: upstream.status, message: text })) {
await reportTokenExpired({
@@ -97,8 +122,8 @@ export async function embeddingsProxyRoute(app: FastifyInstance) {
let data: any = {};
try { data = JSON.parse(text); } catch { data = {}; }
const parsedUsage = parseProxyUsage(data);
const latency = Date.now() - startTime;
const parsedUsage = parseProxyUsage(data);
const resolvedUsage = await resolveProxyUsageWithSelfLogFallback({
site: selected.site,
account: selected.account,
@@ -121,16 +146,37 @@ export async function embeddingsProxyRoute(app: FastifyInstance) {
parsedUsage,
resolvedUsage,
});
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost, selected.actualModel);
recordDownstreamCostUsage(request, estimatedCost);
logProxy(
selected, requestedModel, 'success', upstream.status, latency, null, retryCount, logDownstreamApiKeyId ? downstreamApiKeyId : null,
resolvedUsage.promptTokens, resolvedUsage.completionTokens, resolvedUsage.totalTokens, estimatedCost, billingDetails,
selected, requestedModel, 'success', upstream.status, latency, null, retryCount, downstreamApiKeyId,
resolvedUsage.promptTokens, resolvedUsage.completionTokens, resolvedUsage.totalTokens, estimatedCost, billingDetails, clientContext, downstreamPath,
);
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, logDownstreamApiKeyId ? downstreamApiKeyId : null);
tokenRouter.recordFailure(selected.channel.id, {
status: 0,
errorText: err.message,
modelName: selected.actualModel,
});
logProxy(
selected,
requestedModel,
'failed',
0,
Date.now() - startTime,
err.message,
retryCount,
downstreamApiKeyId,
0,
0,
0,
0,
null,
clientContext,
downstreamPath,
);
if (retryCount < MAX_RETRIES) {
retryCount++;
continue;
@@ -159,18 +205,25 @@ async function logProxy(
totalTokens = 0,
estimatedCost = 0,
billingDetails: unknown = null,
clientContext: DownstreamClientContext | null = null,
downstreamPath = '/v1/embeddings',
) {
try {
const createdAt = formatUtcSqlDateTime(new Date());
const normalizedErrorMessage = composeProxyLogMessage({
downstreamPath: '/v1/embeddings',
clientKind: clientContext?.clientKind && clientContext.clientKind !== 'generic'
? clientContext.clientKind
: null,
sessionId: clientContext?.sessionId || null,
traceHint: clientContext?.traceHint || null,
downstreamPath,
errorMessage,
});
await db.insert(schema.proxyLogs).values({
await insertProxyLog({
routeId: selected.channel.routeId,
channelId: selected.channel.id,
accountId: selected.account.id,
...(downstreamApiKeyId !== null ? { downstreamApiKeyId } : {}),
downstreamApiKeyId,
modelRequested,
modelActual: selected.actualModel,
status,
@@ -180,11 +233,15 @@ async function logProxy(
completionTokens,
totalTokens,
estimatedCost,
billingDetails: billingDetails ? JSON.stringify(billingDetails) : null,
billingDetails,
clientFamily: clientContext?.clientKind || null,
clientAppId: clientContext?.clientAppId || null,
clientAppName: clientContext?.clientAppName || null,
clientConfidence: clientContext?.clientConfidence || null,
errorMessage: normalizedErrorMessage,
retryCount,
createdAt,
}).run();
});
} catch (error) {
console.warn('[proxy/embeddings] failed to write proxy log', error);
}
+35 -16
View File
@@ -73,6 +73,7 @@ describe('executeEndpointFlow', () => {
expect(result.ok).toBe(true);
expect(dispatchRequest).toHaveBeenCalledTimes(1);
expect(dispatchRequest.mock.calls[0]?.[1]).toBe('https://example.com/v1/responses');
expect(fetchMock).not.toHaveBeenCalled();
});
@@ -155,6 +156,40 @@ describe('executeEndpointFlow', () => {
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it('emits attempt callbacks for failed and successful endpoint probes', async () => {
fetchMock
.mockResolvedValueOnce(toUndiciResponse(new Response(JSON.stringify({
error: { message: 'unsupported endpoint', type: 'invalid_request_error' },
}), {
status: 404,
headers: { 'content-type': 'application/json' },
})))
.mockResolvedValueOnce(toUndiciResponse(new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: { 'content-type': 'application/json' },
})));
const onAttemptFailure = vi.fn();
const onAttemptSuccess = vi.fn();
const result = await executeEndpointFlow({
siteUrl: 'https://example.com',
endpointCandidates: ['responses', 'chat'],
buildRequest: (endpoint) => endpoint === 'responses'
? requestFor('/v1/responses')
: { ...requestFor('/v1/chat/completions'), endpoint },
shouldDowngrade: () => true,
onAttemptFailure,
onAttemptSuccess,
});
expect(result.ok).toBe(true);
expect(onAttemptFailure).toHaveBeenCalledTimes(1);
expect(onAttemptFailure.mock.calls[0]?.[0]?.request?.path).toBe('/v1/responses');
expect(onAttemptSuccess).toHaveBeenCalledTimes(1);
expect(onAttemptSuccess.mock.calls[0]?.[0]?.request?.path).toBe('/v1/chat/completions');
});
it('accepts recovered response from tryRecover hook', async () => {
fetchMock.mockResolvedValueOnce(toUndiciResponse(new Response(JSON.stringify({
error: { message: 'upstream_error', type: 'upstream_error' },
@@ -185,22 +220,6 @@ describe('executeEndpointFlow', () => {
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it('uses proxyUrl for the default fetch path when no dispatch hook is provided', async () => {
fetchMock.mockResolvedValueOnce(toUndiciResponse(new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: { 'content-type': 'application/json' },
})));
await executeEndpointFlow({
siteUrl: 'https://example.com',
proxyUrl: 'https://proxy.internal/base',
endpointCandidates: ['responses'],
buildRequest: () => requestFor('/v1/responses'),
});
expect(fetchMock.mock.calls[0]?.[0]).toBe('https://proxy.internal/base/v1/responses');
});
it('returns normalized final error when all endpoints fail', async () => {
fetchMock.mockResolvedValueOnce(toUndiciResponse(new Response(JSON.stringify({
error: { message: 'upstream_error', type: 'upstream_error' },
+29 -11
View File
@@ -27,6 +27,14 @@ export type EndpointAttemptContext = {
rawErrText: string;
};
export type EndpointAttemptSuccessContext = {
endpointIndex: number;
endpointCount: number;
request: BuiltEndpointRequest;
targetUrl: string;
response: Awaited<ReturnType<typeof fetch>>;
};
export type EndpointRecoverResult = {
upstream: Awaited<ReturnType<typeof fetch>>;
upstreamPath: string;
@@ -51,7 +59,6 @@ export function withUpstreamPath(path: string, message: string): string {
type ExecuteEndpointFlowInput = {
siteUrl: string;
proxyUrl?: string | null;
endpointCandidates: UpstreamEndpoint[];
buildRequest: (endpoint: UpstreamEndpoint, endpointIndex: number) => BuiltEndpointRequest;
dispatchRequest?: (
@@ -61,14 +68,10 @@ type ExecuteEndpointFlowInput = {
tryRecover?: (ctx: EndpointAttemptContext) => Promise<EndpointRecoverResult>;
shouldDowngrade?: (ctx: EndpointAttemptContext) => boolean;
onDowngrade?: (ctx: EndpointAttemptContext & { errText: string }) => void | Promise<void>;
onAttemptFailure?: (ctx: EndpointAttemptContext & { errText: string }) => void | Promise<void>;
onAttemptSuccess?: (ctx: EndpointAttemptSuccessContext) => void | Promise<void>;
};
function buildAbsoluteUrl(base: string, path: string): string {
const normalizedBase = base.replace(/\/+$/, '');
const normalizedPath = path.replace(/^\/+/, '');
return normalizedPath ? `${normalizedBase}/${normalizedPath}` : normalizedBase;
}
export async function executeEndpointFlow(input: ExecuteEndpointFlowInput): Promise<EndpointFlowResult> {
const endpointCount = input.endpointCandidates.length;
if (endpointCount <= 0) {
@@ -86,10 +89,7 @@ export async function executeEndpointFlow(input: ExecuteEndpointFlowInput): Prom
for (let endpointIndex = 0; endpointIndex < endpointCount; endpointIndex += 1) {
const endpoint = input.endpointCandidates[endpointIndex] as UpstreamEndpoint;
const request = input.buildRequest(endpoint, endpointIndex);
const defaultTarget = buildUpstreamUrl(input.siteUrl, request.path);
const targetUrl = input.proxyUrl
? buildAbsoluteUrl(input.proxyUrl, request.path)
: defaultTarget;
const targetUrl = buildUpstreamUrl(input.siteUrl, request.path);
let response = input.dispatchRequest
? await input.dispatchRequest(request, targetUrl)
@@ -100,6 +100,13 @@ export async function executeEndpointFlow(input: ExecuteEndpointFlowInput): Prom
}));
if (response.ok) {
await input.onAttemptSuccess?.({
endpointIndex,
endpointCount,
request,
targetUrl,
response,
});
return {
ok: true,
upstream: response,
@@ -120,6 +127,13 @@ export async function executeEndpointFlow(input: ExecuteEndpointFlowInput): Prom
if (input.tryRecover) {
const recovered = await input.tryRecover(baseContext);
if (recovered?.upstream?.ok) {
await input.onAttemptSuccess?.({
endpointIndex,
endpointCount,
request: baseContext.request,
targetUrl: baseContext.targetUrl,
response: recovered.upstream,
});
return {
ok: true,
upstream: recovered.upstream,
@@ -135,6 +149,10 @@ export async function executeEndpointFlow(input: ExecuteEndpointFlowInput): Prom
baseContext.request.path,
summarizeUpstreamError(response.status, rawErrText),
);
await input.onAttemptFailure?.({
...baseContext,
errText,
});
const isLastEndpoint = endpointIndex >= endpointCount - 1;
const shouldDowngrade = !isLastEndpoint && !!input.shouldDowngrade?.(baseContext);
+114 -6
View File
@@ -2,6 +2,7 @@ import Fastify, { type FastifyInstance } from 'fastify';
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { resetUpstreamEndpointRuntimeState } from './upstreamEndpoint.js';
const fetchMock = vi.fn();
const fetchModelPricingCatalogMock = vi.fn();
@@ -74,6 +75,9 @@ vi.mock('../../db/index.js', () => ({
select: (..._args: unknown[]) => createDbSelectChain(),
insert: (arg: unknown) => dbInsertMock(arg),
},
hasProxyLogBillingDetailsColumn: async () => false,
hasProxyLogClientColumns: async () => false,
hasProxyLogDownstreamApiKeyIdColumn: async () => false,
schema: {
proxyLogs: {},
modelAvailability: {
@@ -185,6 +189,7 @@ describe('gemini native proxy routes', () => {
selectNextChannelMock.mockReturnValue(null);
recordSuccessMock.mockResolvedValue(undefined);
recordFailureMock.mockResolvedValue(undefined);
resetUpstreamEndpointRuntimeState();
explainSelectionMock.mockResolvedValue({ selectedChannelId: 11 });
isModelAllowedByPolicyOrAllowedRoutesMock.mockResolvedValue(true);
});
@@ -258,7 +263,10 @@ describe('gemini native proxy routes', () => {
expect(response.statusCode).toBe(200);
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(recordFailureMock).toHaveBeenCalledWith(11);
expect(recordFailureMock).toHaveBeenCalledWith(11, expect.objectContaining({
status: 500,
errorText: JSON.stringify({ error: { message: 'first channel failed' } }),
}));
const [firstUrl] = fetchMock.mock.calls[0] as [string, RequestInit];
const [secondUrl] = fetchMock.mock.calls[1] as [string, RequestInit];
expect(firstUrl).toContain('key=gemini-key');
@@ -681,6 +689,92 @@ describe('gemini native proxy routes', () => {
});
});
it('routes Gemini native document requests to responses endpoints on openai-compatible upstreams', async () => {
selectChannelMock.mockReturnValue({
channel: { id: 42, routeId: 22 },
site: { id: 78, name: 'openai-site', url: 'https://api.openai.com', platform: 'openai' },
account: { id: 38, username: 'openai-user@example.com' },
tokenName: 'default',
tokenValue: 'openai-access-token',
actualModel: 'gpt-4.1',
});
fetchMock.mockImplementation(async (target: unknown) => {
const url = String(target);
if (url === 'https://api.openai.com/v1/responses') {
return new Response(JSON.stringify({
id: 'resp-openai-file-1',
object: 'response',
model: 'gpt-4.1',
output: [
{
type: 'message',
role: 'assistant',
content: [{ type: 'output_text', text: 'document summary from responses' }],
},
],
usage: {
input_tokens: 9,
output_tokens: 4,
total_tokens: 13,
},
}), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url === 'https://api.openai.com/v1/chat/completions') {
return new Response(JSON.stringify({
id: 'chatcmpl-openai-file-1',
object: 'chat.completion',
created: 1_742_160_002,
model: 'gpt-4.1',
choices: [
{
index: 0,
message: {
role: 'assistant',
content: 'document summary from chat',
},
finish_reason: 'stop',
},
],
}), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
throw new Error(`unexpected target url: ${url}`);
});
const response = await app.inject({
method: 'POST',
url: '/v1beta/models/gemini-2.5-flash:generateContent',
headers: {
authorization: 'Bearer sk-managed-gemini',
},
payload: {
contents: [
{
role: 'user',
parts: [
{ text: 'summarize this pdf' },
{
fileData: {
fileUri: 'https://example.com/brief.pdf',
mimeType: 'application/pdf',
},
},
],
},
],
},
});
expect(response.statusCode).toBe(200);
const [targetUrl] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(targetUrl).toBe('https://api.openai.com/v1/responses');
});
it('routes Gemini native generateContent requests to antigravity upstreams through the internal content endpoint', async () => {
selectChannelMock.mockReturnValue({
channel: { id: 43, routeId: 22 },
@@ -1414,7 +1508,10 @@ describe('gemini native proxy routes', () => {
expect(response.statusCode).toBe(200);
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(recordFailureMock).toHaveBeenCalledWith(11);
expect(recordFailureMock).toHaveBeenCalledWith(11, expect.objectContaining({
status: 400,
errorText: JSON.stringify({ error: { message: 'bad request on first channel' } }),
}));
const [firstUrl] = fetchMock.mock.calls[0] as [string, RequestInit];
const [secondUrl] = fetchMock.mock.calls[1] as [string, RequestInit];
expect(firstUrl).toContain('key=gemini-key');
@@ -1464,7 +1561,10 @@ describe('gemini native proxy routes', () => {
expect(response.statusCode).toBe(200);
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(recordFailureMock).toHaveBeenCalledWith(11);
expect(recordFailureMock).toHaveBeenCalledWith(11, expect.objectContaining({
status: 403,
errorText: JSON.stringify({ error: { message: 'forbidden on first channel' } }),
}));
});
it('falls back to the next channel when first Gemini channel returns 500 before any bytes are written', async () => {
@@ -1507,7 +1607,10 @@ describe('gemini native proxy routes', () => {
expect(response.statusCode).toBe(200);
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(recordFailureMock).toHaveBeenCalledWith(11);
expect(recordFailureMock).toHaveBeenCalledWith(11, expect.objectContaining({
status: 500,
errorText: 'upstream crash',
}));
});
it('falls back to the next channel when first Gemini channel throws before any bytes are written', async () => {
@@ -1547,7 +1650,9 @@ describe('gemini native proxy routes', () => {
expect(response.statusCode).toBe(200);
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(recordFailureMock).toHaveBeenCalledWith(11);
expect(recordFailureMock).toHaveBeenCalledWith(11, expect.objectContaining({
errorText: 'socket hang up',
}));
});
it('falls back to the next channel for SSE requests before any bytes are written', async () => {
@@ -1593,7 +1698,10 @@ describe('gemini native proxy routes', () => {
expect(response.statusCode).toBe(200);
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(recordFailureMock).toHaveBeenCalledWith(11);
expect(recordFailureMock).toHaveBeenCalledWith(11, expect.objectContaining({
status: 500,
errorText: JSON.stringify({ error: { message: 'upstream unavailable' } }),
}));
expect(response.body).toContain('hello from fallback sse');
});
@@ -58,6 +58,9 @@ vi.mock('../../db/index.js', () => ({
db: {
insert: (arg: any) => dbInsertMock(arg),
},
hasProxyLogBillingDetailsColumn: async () => false,
hasProxyLogClientColumns: async () => false,
hasProxyLogDownstreamApiKeyIdColumn: async () => false,
schema: {
proxyLogs: {},
},
+103 -20
View File
@@ -1,6 +1,5 @@
import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { fetch } from 'undici';
import { db, hasProxyLogDownstreamApiKeyIdColumn, schema } from '../../db/index.js';
import { tokenRouter } from '../../services/tokenRouter.js';
import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js';
import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js';
@@ -15,6 +14,8 @@ import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
import { cloneFormDataWithOverrides, ensureMultipartBufferParser, parseMultipartFormData } from './multipart.js';
import { getProxyAuthContext } from '../../middleware/auth.js';
import { buildUpstreamUrl } from './upstreamUrl.js';
import { detectDownstreamClientContext, type DownstreamClientContext } from './downstreamClientContext.js';
import { insertProxyLog } from '../../services/proxyLogStore.js';
const MAX_RETRIES = 2;
@@ -27,8 +28,12 @@ export async function imagesProxyRoute(app: FastifyInstance) {
if (!await ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return;
const downstreamPolicy = getDownstreamRoutingPolicy(request);
const downstreamApiKeyId = getProxyAuthContext(request)?.keyId ?? null;
const logDownstreamApiKeyId = downstreamApiKeyId !== null
&& await hasProxyLogDownstreamApiKeyIdColumn();
const downstreamPath = '/v1/images/generations';
const clientContext = detectDownstreamClientContext({
downstreamPath,
headers: request.headers as Record<string, unknown>,
body,
});
const excludeChannelIds: number[] = [];
let retryCount = 0;
@@ -70,8 +75,24 @@ export async function imagesProxyRoute(app: FastifyInstance) {
const text = await upstream.text();
if (!upstream.ok) {
tokenRouter.recordFailure(selected.channel.id);
logProxy(selected, requestedModel, 'failed', upstream.status, Date.now() - startTime, text, retryCount, logDownstreamApiKeyId ? downstreamApiKeyId : null);
tokenRouter.recordFailure(selected.channel.id, {
status: upstream.status,
errorText: text,
modelName: selected.actualModel,
});
logProxy(
selected,
requestedModel,
'failed',
upstream.status,
Date.now() - startTime,
text,
retryCount,
downstreamApiKeyId,
0,
downstreamPath,
clientContext,
);
if (isTokenExpiredError({ status: upstream.status, message: text })) {
await reportTokenExpired({
accountId: selected.account.id,
@@ -103,13 +124,29 @@ export async function imagesProxyRoute(app: FastifyInstance) {
completionTokens: 0,
totalTokens: 0,
});
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost, selected.actualModel);
recordDownstreamCostUsage(request, estimatedCost);
logProxy(selected, requestedModel, 'success', upstream.status, latency, null, retryCount, logDownstreamApiKeyId ? downstreamApiKeyId : null, estimatedCost);
logProxy(selected, requestedModel, 'success', upstream.status, latency, null, retryCount, downstreamApiKeyId, estimatedCost, downstreamPath, clientContext);
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, logDownstreamApiKeyId ? downstreamApiKeyId : null);
tokenRouter.recordFailure(selected.channel.id, {
status: 0,
errorText: err.message,
modelName: selected.actualModel,
});
logProxy(
selected,
requestedModel,
'failed',
0,
Date.now() - startTime,
err.message,
retryCount,
downstreamApiKeyId,
0,
downstreamPath,
clientContext,
);
if (retryCount < MAX_RETRIES) {
retryCount++;
continue;
@@ -137,8 +174,12 @@ export async function imagesProxyRoute(app: FastifyInstance) {
if (!await ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return;
const downstreamPolicy = getDownstreamRoutingPolicy(request);
const downstreamApiKeyId = getProxyAuthContext(request)?.keyId ?? null;
const logDownstreamApiKeyId = downstreamApiKeyId !== null
&& await hasProxyLogDownstreamApiKeyIdColumn();
const downstreamPath = '/v1/images/edits';
const clientContext = detectDownstreamClientContext({
downstreamPath,
headers: request.headers as Record<string, unknown>,
body: jsonBody || Object.fromEntries(multipartForm?.entries?.() || []),
});
const excludeChannelIds: number[] = [];
let retryCount = 0;
@@ -192,8 +233,24 @@ export async function imagesProxyRoute(app: FastifyInstance) {
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, logDownstreamApiKeyId ? downstreamApiKeyId : null, 0, '/v1/images/edits');
tokenRouter.recordFailure(selected.channel.id, {
status: upstream.status,
errorText: text,
modelName: selected.actualModel,
});
logProxy(
selected,
requestedModel,
'failed',
upstream.status,
Date.now() - startTime,
text,
retryCount,
downstreamApiKeyId,
0,
downstreamPath,
clientContext,
);
if (isTokenExpiredError({ status: upstream.status, message: text })) {
await reportTokenExpired({
accountId: selected.account.id,
@@ -225,13 +282,29 @@ export async function imagesProxyRoute(app: FastifyInstance) {
completionTokens: 0,
totalTokens: 0,
});
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost, selected.actualModel);
recordDownstreamCostUsage(request, estimatedCost);
logProxy(selected, requestedModel, 'success', upstream.status, latency, null, retryCount, logDownstreamApiKeyId ? downstreamApiKeyId : null, estimatedCost, '/v1/images/edits');
logProxy(selected, requestedModel, 'success', upstream.status, latency, null, retryCount, downstreamApiKeyId, estimatedCost, downstreamPath, clientContext);
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, logDownstreamApiKeyId ? downstreamApiKeyId : null, 0, '/v1/images/edits');
tokenRouter.recordFailure(selected.channel.id, {
status: 0,
errorText: err.message,
modelName: selected.actualModel,
});
logProxy(
selected,
requestedModel,
'failed',
0,
Date.now() - startTime,
err.message,
retryCount,
downstreamApiKeyId,
0,
downstreamPath,
clientContext,
);
if (retryCount < MAX_RETRIES) {
retryCount++;
continue;
@@ -268,18 +341,24 @@ async function logProxy(
downstreamApiKeyId: number | null = null,
estimatedCost = 0,
downstreamPath = '/v1/images/generations',
clientContext: DownstreamClientContext | null = null,
) {
try {
const createdAt = formatUtcSqlDateTime(new Date());
const normalizedErrorMessage = composeProxyLogMessage({
clientKind: clientContext?.clientKind && clientContext.clientKind !== 'generic'
? clientContext.clientKind
: null,
sessionId: clientContext?.sessionId || null,
traceHint: clientContext?.traceHint || null,
downstreamPath,
errorMessage,
});
await db.insert(schema.proxyLogs).values({
await insertProxyLog({
routeId: selected.channel.routeId,
channelId: selected.channel.id,
accountId: selected.account.id,
...(downstreamApiKeyId !== null ? { downstreamApiKeyId } : {}),
downstreamApiKeyId,
modelRequested,
modelActual: selected.actualModel,
status,
@@ -289,10 +368,14 @@ async function logProxy(
completionTokens: 0,
totalTokens: 0,
estimatedCost,
clientFamily: clientContext?.clientKind || null,
clientAppId: clientContext?.clientAppId || null,
clientAppName: clientContext?.clientAppName || null,
clientConfidence: clientContext?.clientConfidence || null,
errorMessage: normalizedErrorMessage,
retryCount,
createdAt,
}).run();
});
} catch (error) {
console.warn('[proxy/images] failed to write proxy log', error);
}
+3 -3
View File
@@ -7,7 +7,7 @@ type ComposeProxyLogMessageArgs = {
errorMessage?: string | null;
};
type ParsedPathMeta = {
export type ParsedProxyLogMessageMeta = {
clientKind: string | null;
sessionId: string | null;
downstreamPath: string | null;
@@ -15,7 +15,7 @@ type ParsedPathMeta = {
messageText: string;
};
function parseExistingPathMeta(rawMessage: string): ParsedPathMeta {
export function parseProxyLogMessageMeta(rawMessage: string): ParsedProxyLogMessageMeta {
const clientMatch = rawMessage.match(/\[client:([^\]]+)\]/i);
const sessionMatch = rawMessage.match(/\[session:([^\]]+)\]/i);
const downstreamMatch = rawMessage.match(/\[downstream:([^\]]+)\]/i);
@@ -42,7 +42,7 @@ export function composeProxyLogMessage({
errorMessage,
}: ComposeProxyLogMessageArgs): string | null {
const rawMessage = typeof errorMessage === 'string' ? errorMessage.trim() : '';
const parsed = parseExistingPathMeta(rawMessage);
const parsed = parseProxyLogMessageMeta(rawMessage);
const finalClientKind = (clientKind || parsed.clientKind || '').trim();
const finalSessionId = (sessionId || traceHint || parsed.sessionId || '').trim();
const finalDownstreamPath = (downstreamPath || parsed.downstreamPath || '').trim();
+117
View File
@@ -8,6 +8,7 @@ type DbModule = typeof import('../../db/index.js');
type ProxyRouterModule = typeof import('./router.js');
type TokenRouterModule = typeof import('../../services/tokenRouter.js');
type TokensRoutesModule = typeof import('../api/tokens.js');
type ConfigModule = typeof import('../../config.js');
describe('/v1/models route', () => {
let db: DbModule['db'];
@@ -15,6 +16,7 @@ describe('/v1/models route', () => {
let proxyRoutes: ProxyRouterModule['proxyRoutes'];
let tokensRoutes: TokensRoutesModule['tokensRoutes'];
let invalidateTokenRouterCache: TokenRouterModule['invalidateTokenRouterCache'];
let config: ConfigModule['config'];
let app: FastifyInstance;
let dataDir = '';
@@ -27,12 +29,15 @@ describe('/v1/models route', () => {
const proxyRouterModule = await import('./router.js');
const tokenRouterModule = await import('../../services/tokenRouter.js');
const tokensRoutesModule = await import('../api/tokens.js');
const configModule = await import('../../config.js');
db = dbModule.db;
schema = dbModule.schema;
proxyRoutes = proxyRouterModule.proxyRoutes;
tokensRoutes = tokensRoutesModule.tokensRoutes;
invalidateTokenRouterCache = tokenRouterModule.invalidateTokenRouterCache;
config = configModule.config;
config.proxyToken = 'sk-global-proxy-token';
app = Fastify();
await app.register(tokensRoutes);
@@ -108,6 +113,7 @@ describe('/v1/models route', () => {
name: 'managed-key',
key: 'sk-managed-models',
enabled: true,
supportedModels: JSON.stringify(['routable-model']),
}).run();
const response = await app.inject({
@@ -129,6 +135,64 @@ describe('/v1/models route', () => {
expect(ids).not.toContain('orphan-model');
});
it('keeps global proxy token unrestricted when no managed key matches', async () => {
const site = await db.insert(schema.sites).values({
name: 'global-site',
url: 'https://global.example.com',
platform: 'openai',
status: 'active',
}).returning().get();
const account = await db.insert(schema.accounts).values({
siteId: site.id,
accessToken: 'global-access-token',
status: 'active',
}).returning().get();
const token = await db.insert(schema.accountTokens).values({
accountId: account.id,
name: 'default',
token: 'global-api-token',
enabled: true,
isDefault: true,
}).returning().get();
await db.insert(schema.modelAvailability).values({
accountId: account.id,
modelName: 'global-routable-model',
available: true,
}).run();
const route = await db.insert(schema.tokenRoutes).values({
modelPattern: 'global-routable-model',
enabled: true,
}).returning().get();
await db.insert(schema.routeChannels).values({
routeId: route.id,
accountId: account.id,
tokenId: token.id,
sourceModel: 'global-routable-model',
enabled: true,
}).run();
const response = await app.inject({
method: 'GET',
url: '/v1/models',
headers: {
authorization: 'Bearer sk-global-proxy-token',
},
});
expect(response.statusCode).toBe(200);
const body = response.json() as {
object: 'list';
data: Array<{ id: string }>;
};
expect(body.data.map((item) => item.id)).toContain('global-routable-model');
});
it('returns only whitelist models for managed key with supportedModels policy', async () => {
const site = await db.insert(schema.sites).values({
name: 'test-site',
@@ -290,6 +354,58 @@ describe('/v1/models route', () => {
expect(ids).not.toContain('claude-sonnet-4-5');
});
it('returns no models for managed key with empty model and group selections', async () => {
const site = await db.insert(schema.sites).values({
name: 'deny-all-site',
url: 'https://deny-all.example.com',
platform: 'openai',
status: 'active',
}).returning().get();
const account = await db.insert(schema.accounts).values({
siteId: site.id,
accessToken: 'deny-all-access-token',
status: 'active',
}).returning().get();
await db.insert(schema.modelAvailability).values([
{
accountId: account.id,
modelName: 'gpt-4o-mini',
available: true,
},
{
accountId: account.id,
modelName: 'claude-opus-4-6',
available: true,
},
]).run();
await db.insert(schema.downstreamApiKeys).values({
name: 'managed-key-deny-all',
key: 'sk-managed-deny-all',
enabled: true,
supportedModels: JSON.stringify([]),
allowedRouteIds: JSON.stringify([]),
}).run();
const response = await app.inject({
method: 'GET',
url: '/v1/models',
headers: {
authorization: 'Bearer sk-managed-deny-all',
},
});
expect(response.statusCode).toBe(200);
const body = response.json() as {
object: 'list';
data: Array<{ id: string }>;
};
expect(body.data).toEqual([]);
});
it('returns only explicit-group public name while hiding source exact routes', async () => {
const site = await db.insert(schema.sites).values({
name: 'explicit-group-site',
@@ -461,6 +577,7 @@ describe('/v1/models route', () => {
name: 'search-key',
key: 'sk-search-key',
enabled: true,
supportedModels: JSON.stringify(['gpt-4.1']),
}).run();
const response = await app.inject({
@@ -1,5 +1,6 @@
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();
const selectChannelMock = vi.fn();
@@ -17,6 +18,7 @@ const resolveProxyUsageWithSelfLogFallbackMock = vi.fn(async ({ usage }: any) =>
const refreshOauthAccessTokenSingleflightMock = vi.fn();
const recordOauthQuotaResetHintMock = vi.fn();
const insertedProxyLogs: Record<string, unknown>[] = [];
const originalProxyEmptyContentFailEnabled = config.proxyEmptyContentFailEnabled;
const dbInsertMock = vi.fn((_arg?: any) => ({
values: (values: Record<string, unknown>) => {
insertedProxyLogs.push(values);
@@ -78,6 +80,8 @@ vi.mock('../../db/index.js', () => ({
db: {
insert: (arg: any) => dbInsertMock(arg),
},
hasProxyLogBillingDetailsColumn: async () => false,
hasProxyLogClientColumns: async () => false,
hasProxyLogDownstreamApiKeyIdColumn: async () => false,
schema: {
proxyLogs: {},
@@ -109,6 +113,7 @@ describe('responses proxy codex oauth refresh', () => {
});
beforeEach(() => {
config.proxyEmptyContentFailEnabled = false;
fetchMock.mockReset();
selectChannelMock.mockReset();
selectNextChannelMock.mockReset();
@@ -152,6 +157,7 @@ describe('responses proxy codex oauth refresh', () => {
});
afterAll(async () => {
config.proxyEmptyContentFailEnabled = originalProxyEmptyContentFailEnabled;
await app.close();
});
@@ -208,6 +214,69 @@ describe('responses proxy codex oauth refresh', () => {
expect(response.json()?.output_text).toContain('ok after codex token refresh');
});
it('retries oauth responses requests with a normalized upstream URL after refresh', async () => {
selectChannelMock.mockReturnValue({
channel: { id: 11, routeId: 22 },
site: { name: 'openai-site', url: 'https://gateway.example.com/v1/', platform: 'openai' },
account: {
id: 33,
username: 'oauth-user@example.com',
extraConfig: JSON.stringify({
credentialMode: 'session',
oauth: {
provider: 'codex',
accountId: 'chatgpt-account-123',
email: 'oauth-user@example.com',
planType: 'plus',
},
}),
},
tokenName: 'default',
tokenValue: 'expired-access-token',
actualModel: 'gpt-4.1-mini',
});
fetchMock
.mockResolvedValueOnce(new Response(JSON.stringify({
error: { message: 'expired token', type: 'invalid_request_error' },
}), {
status: 401,
headers: { 'content-type': 'application/json' },
}))
.mockResolvedValueOnce(new Response(JSON.stringify({
id: 'resp_openai_refreshed',
object: 'response',
model: 'gpt-4.1-mini',
status: 'completed',
output_text: 'ok after refresh',
usage: { input_tokens: 4, output_tokens: 2, total_tokens: 6 },
}), {
status: 200,
headers: { 'content-type': 'application/json' },
}));
const response = await app.inject({
method: 'POST',
url: '/v1/responses',
payload: {
model: 'gpt-4.1-mini',
input: 'hello oauth',
},
});
expect(response.statusCode).toBe(200);
expect(refreshOauthAccessTokenSingleflightMock).toHaveBeenCalledWith(33);
expect(fetchMock).toHaveBeenCalledTimes(2);
const [firstUrl, firstOptions] = fetchMock.mock.calls[0] as [string, any];
const [secondUrl, secondOptions] = fetchMock.mock.calls[1] as [string, any];
expect(firstUrl).toBe('https://gateway.example.com/v1/responses');
expect(secondUrl).toBe('https://gateway.example.com/v1/responses');
expect(firstOptions.headers.Authorization).toBe('Bearer expired-access-token');
expect(secondOptions.headers.Authorization).toBe('Bearer fresh-access-token');
expect(response.json()?.output_text).toBe('ok after refresh');
});
it('sends an explicit empty instructions field to codex responses when downstream body has no system prompt', async () => {
fetchMock.mockResolvedValue(new Response(JSON.stringify({
id: 'resp_codex_no_system',
@@ -501,6 +570,39 @@ describe('responses proxy codex oauth refresh', () => {
expect(String(insertedProxyLogs.at(-1)?.errorMessage || '')).toContain('stream closed before response.completed');
});
it('does not record success when a native responses stream completes with empty content and empty usage while empty-content failure is enabled', async () => {
config.proxyEmptyContentFailEnabled = true;
fetchMock.mockResolvedValue(createSseResponse([
'event: response.created\n',
'data: {"type":"response.created","response":{"id":"resp_codex_empty","model":"gpt-5.4","created_at":1706000000,"status":"in_progress","output":[]}}\n\n',
'event: response.completed\n',
'data: {"type":"response.completed","response":{"id":"resp_codex_empty","model":"gpt-5.4","status":"completed","output":[],"usage":{"input_tokens":0,"output_tokens":0,"total_tokens":0}}}\n\n',
'data: [DONE]\n\n',
]));
const response = await app.inject({
method: 'POST',
url: '/v1/responses',
payload: {
model: 'gpt-5.4',
input: 'hello codex',
stream: true,
},
});
expect(response.statusCode).toBe(200);
expect(response.body).toContain('event: response.failed');
expect(response.body).not.toContain('event: response.completed');
expect(recordSuccessMock).not.toHaveBeenCalled();
expect(recordFailureMock).toHaveBeenCalledTimes(1);
expect(insertedProxyLogs.at(-1)).toMatchObject({
status: 'failed',
httpStatus: 200,
});
expect(String(insertedProxyLogs.at(-1)?.errorMessage || '')).toContain('empty content');
});
it('does not retry or mark failure after converting a non-stream upstream payload into SSE when post-stream usage accounting fails', async () => {
resolveProxyUsageWithSelfLogFallbackMock.mockRejectedValueOnce(new Error('usage accounting failed'));
fetchMock.mockResolvedValue(new Response(JSON.stringify({
@@ -578,4 +680,39 @@ describe('responses proxy codex oauth refresh', () => {
expect(recordFailureMock).not.toHaveBeenCalled();
expect(recordSuccessMock).toHaveBeenCalledTimes(1);
});
it('replays raw codex SSE when upstream mislabels a streaming responses body as application/json', async () => {
fetchMock.mockResolvedValue(new Response([
'event: response.created\n',
'data: {"type":"response.created","response":{"id":"resp_codex_stream_header_miss","model":"gpt-5.4","created_at":1706000000,"status":"in_progress","output":[]}}\n\n',
'event: response.output_item.added\n',
'data: {"type":"response.output_item.added","output_index":0,"item":{"id":"msg_codex_stream_header_miss","type":"message","role":"assistant","status":"in_progress","content":[]}}\n\n',
'event: response.output_text.delta\n',
'data: {"type":"response.output_text.delta","output_index":0,"item_id":"msg_codex_stream_header_miss","delta":"pong"}\n\n',
'event: response.completed\n',
'data: {"type":"response.completed","response":{"id":"resp_codex_stream_header_miss","model":"gpt-5.4","status":"completed","usage":{"input_tokens":3,"output_tokens":1,"total_tokens":4}}}\n\n',
'data: [DONE]\n\n',
].join(''), {
status: 200,
headers: { 'content-type': 'application/json' },
}));
const response = await app.inject({
method: 'POST',
url: '/v1/responses',
payload: {
model: 'gpt-5.4',
input: 'hello codex',
stream: true,
},
});
expect(response.statusCode).toBe(200);
expect(response.body).toContain('event: response.output_text.delta');
expect(response.body).toContain('"delta":"pong"');
expect(response.body).not.toContain('"output_text":"event: response.created');
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(recordFailureMock).not.toHaveBeenCalled();
expect(recordSuccessMock).toHaveBeenCalledTimes(1);
});
});
@@ -67,6 +67,9 @@ vi.mock('../../db/index.js', () => ({
db: {
insert: (arg: any) => dbInsertMock(arg),
},
hasProxyLogBillingDetailsColumn: async () => false,
hasProxyLogClientColumns: async () => false,
hasProxyLogDownstreamApiKeyIdColumn: async () => false,
schema: {
proxyLogs: {},
},
@@ -1,6 +1,7 @@
import Fastify, { type FastifyInstance } from 'fastify';
import { createServer, type Server } from 'node:http';
import { AddressInfo } from 'node:net';
import WebSocket from 'ws';
import WebSocket, { WebSocketServer } from 'ws';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
const fetchMock = vi.fn();
@@ -72,6 +73,8 @@ vi.mock('../../db/index.js', () => ({
db: {
insert: (arg: any) => dbInsertMock(arg),
},
hasProxyLogBillingDetailsColumn: async () => false,
hasProxyLogClientColumns: async () => false,
hasProxyLogDownstreamApiKeyIdColumn: async () => false,
schema: {
proxyLogs: {},
@@ -175,6 +178,14 @@ function waitForSocketMessages(socket: WebSocket, count: number, timeoutMs = 100
describe('responses websocket transport', () => {
let app: FastifyInstance;
let baseUrl: string;
let upstreamServer: WebSocketServer;
let upstreamSiteUrl: string;
let upstreamConnectionCount: number;
let upstreamUpgradeHeaders: Record<string, string>;
let upstreamRequests: Record<string, unknown>[];
let upstreamMessageHandler: (socket: WebSocket, parsed: Record<string, unknown>, requestIndex: number) => void;
let rejectedUpgradeServer: Server;
let rejectedUpgradeSiteUrl: string;
beforeAll(async () => {
const { responsesProxyRoute } = await import('./responses.js');
@@ -183,6 +194,39 @@ describe('responses websocket transport', () => {
await app.listen({ port: 0, host: '127.0.0.1' });
const address = app.server.address() as AddressInfo;
baseUrl = `ws://127.0.0.1:${address.port}`;
upstreamServer = new WebSocketServer({ port: 0 });
upstreamServer.on('connection', (socket, request) => {
upstreamConnectionCount += 1;
upstreamUpgradeHeaders = Object.fromEntries(
Object.entries(request.headers)
.map(([key, value]) => [key, Array.isArray(value) ? value[0] || '' : value || '']),
);
socket.on('message', (payload) => {
const parsed = JSON.parse(String(payload)) as Record<string, unknown>;
upstreamRequests.push(parsed);
upstreamMessageHandler(socket, parsed, upstreamRequests.length);
});
});
await new Promise<void>((resolve) => upstreamServer.once('listening', () => resolve()));
const upstreamAddress = upstreamServer.address() as AddressInfo;
upstreamSiteUrl = `http://127.0.0.1:${upstreamAddress.port}/backend-api/codex`;
rejectedUpgradeServer = createServer();
rejectedUpgradeServer.on('upgrade', (_request, socket) => {
socket.write(
'HTTP/1.1 426 Upgrade Required\r\n'
+ 'Content-Type: text/plain\r\n'
+ 'Content-Length: 16\r\n'
+ 'Connection: close\r\n'
+ '\r\n'
+ 'Upgrade Required',
);
socket.destroy();
});
await new Promise<void>((resolve) => rejectedUpgradeServer.listen(0, '127.0.0.1', () => resolve()));
const rejectedAddress = rejectedUpgradeServer.address() as AddressInfo;
rejectedUpgradeSiteUrl = `http://127.0.0.1:${rejectedAddress.port}/backend-api/codex`;
});
beforeEach(() => {
@@ -202,24 +246,90 @@ describe('responses websocket transport', () => {
selectChannelMock.mockReturnValue(selectedChannel);
selectNextChannelMock.mockReturnValue(null);
previewSelectedChannelMock.mockResolvedValue(selectedChannel);
upstreamConnectionCount = 0;
upstreamUpgradeHeaders = {};
upstreamRequests = [];
upstreamMessageHandler = (socket, parsed, requestIndex) => {
const responseId = `resp_upstream_${requestIndex}`;
socket.send(JSON.stringify({
type: 'response.completed',
response: {
id: responseId,
object: 'response',
model: parsed.model || 'gpt-5.4',
status: 'completed',
output: [],
usage: {
input_tokens: 1,
output_tokens: 1,
total_tokens: 2,
},
},
}));
};
});
afterAll(async () => {
await new Promise<void>((resolve) => rejectedUpgradeServer.close(() => resolve()));
await new Promise<void>((resolve) => upstreamServer.close(() => resolve()));
await app.close();
});
it('accepts response.create over GET /v1/responses websocket and forwards streamed responses events', async () => {
fetchMock.mockResolvedValue(createSseResponse([
'event: response.created\n',
'data: {"type":"response.created","response":{"id":"resp_ws","model":"gpt-5.4","created_at":1706000000,"status":"in_progress","output":[]}}\n\n',
'event: response.output_item.added\n',
'data: {"type":"response.output_item.added","output_index":0,"item":{"id":"msg_ws","type":"message","role":"assistant","status":"in_progress","content":[]}}\n\n',
'event: response.output_text.delta\n',
'data: {"type":"response.output_text.delta","output_index":0,"item_id":"msg_ws","delta":"pong"}\n\n',
'event: response.completed\n',
'data: {"type":"response.completed","response":{"id":"resp_ws","model":"gpt-5.4","status":"completed","output":[{"id":"msg_ws","type":"message","role":"assistant","status":"completed","content":[{"type":"output_text","text":"pong"}]}],"usage":{"input_tokens":3,"output_tokens":1,"total_tokens":4}}}\n\n',
'data: [DONE]\n\n',
]));
const selectedChannel = createSelectedChannel({
siteUrl: upstreamSiteUrl,
});
selectChannelMock.mockReturnValue(selectedChannel);
previewSelectedChannelMock.mockResolvedValue(selectedChannel);
upstreamMessageHandler = (socket) => {
socket.send(JSON.stringify({
type: 'response.created',
response: {
id: 'resp_ws',
model: 'gpt-5.4',
created_at: 1706000000,
status: 'in_progress',
output: [],
},
}));
socket.send(JSON.stringify({
type: 'response.output_item.added',
output_index: 0,
item: {
id: 'msg_ws',
type: 'message',
role: 'assistant',
status: 'in_progress',
content: [],
},
}));
socket.send(JSON.stringify({
type: 'response.output_text.delta',
output_index: 0,
item_id: 'msg_ws',
delta: 'pong',
}));
socket.send(JSON.stringify({
type: 'response.completed',
response: {
id: 'resp_ws',
model: 'gpt-5.4',
status: 'completed',
output: [{
id: 'msg_ws',
type: 'message',
role: 'assistant',
status: 'completed',
content: [{ type: 'output_text', text: 'pong' }],
}],
usage: {
input_tokens: 3,
output_tokens: 1,
total_tokens: 4,
},
},
}));
};
const socket = new WebSocket(`${baseUrl}/v1/responses`);
await waitForSocketOpen(socket);
@@ -247,7 +357,8 @@ describe('responses websocket transport', () => {
'response.completed',
]);
expect(messages[3]?.response?.output?.[0]?.content?.[0]?.text).toBe('pong');
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledTimes(0);
expect(upstreamConnectionCount).toBe(1);
});
it('echoes x-codex-turn-state on websocket upgrade responses', async () => {
@@ -266,6 +377,89 @@ describe('responses websocket transport', () => {
expect(upgrade.headers['x-codex-turn-state']).toBe('turn-state-123');
});
it('reuses one upstream codex websocket session across sequential websocket turns', async () => {
const selectedChannel = createSelectedChannel({
siteUrl: upstreamSiteUrl,
});
selectChannelMock.mockReturnValue(selectedChannel);
previewSelectedChannelMock.mockResolvedValue(selectedChannel);
const socket = new WebSocket(`${baseUrl}/v1/responses`, {
headers: {
'x-codex-turn-state': 'turn-state-123',
'x-codex-beta-features': 'feature-a,feature-b',
},
});
await waitForSocketOpen(socket);
const firstMessagesPromise = waitForSocketMessages(socket, 1);
socket.send(JSON.stringify({
type: 'response.create',
model: 'gpt-5.4',
input: [],
}));
const firstMessages = await firstMessagesPromise;
const secondMessagesPromise = waitForSocketMessages(socket, 1);
socket.send(JSON.stringify({
type: 'response.create',
model: 'gpt-5.4',
previous_response_id: firstMessages[0]?.response?.id,
input: [],
}));
const secondMessages = await secondMessagesPromise;
socket.close();
expect(fetchMock).toHaveBeenCalledTimes(0);
expect(firstMessages[0]?.type).toBe('response.completed');
expect(secondMessages[0]?.type).toBe('response.completed');
expect(upstreamConnectionCount).toBe(1);
expect(upstreamRequests).toHaveLength(2);
expect(upstreamRequests[0]).toMatchObject({
type: 'response.create',
model: 'gpt-5.4',
});
expect(upstreamRequests[1]).toMatchObject({
type: 'response.create',
previous_response_id: firstMessages[0]?.response?.id,
});
expect(upstreamUpgradeHeaders['x-codex-turn-state']).toBe('turn-state-123');
expect(upstreamUpgradeHeaders['x-codex-beta-features']).toBe('feature-a,feature-b');
expect(upstreamUpgradeHeaders['openai-beta']).toContain('responses_websockets=');
});
it('falls back to the HTTP responses executor when the upstream codex websocket upgrade returns 426', async () => {
const selectedChannel = createSelectedChannel({
siteUrl: rejectedUpgradeSiteUrl,
});
selectChannelMock.mockReturnValue(selectedChannel);
previewSelectedChannelMock.mockResolvedValue(selectedChannel);
fetchMock.mockResolvedValueOnce(createSseResponse([
'event: response.completed\n',
'data: {"type":"response.completed","response":{"id":"resp_http_fallback","model":"gpt-5.4","status":"completed","output":[],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}}\n\n',
'data: [DONE]\n\n',
]));
const socket = new WebSocket(`${baseUrl}/v1/responses`);
await waitForSocketOpen(socket);
const messagesPromise = waitForSocketMessages(socket, 1);
socket.send(JSON.stringify({
type: 'response.create',
model: 'gpt-5.4',
input: [],
}));
const messages = await messagesPromise;
socket.close();
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(messages.map((message) => message.type)).toEqual(['response.completed']);
expect(messages[0]?.response?.id).toBe('resp_http_fallback');
});
it('merges follow-up response.create payloads when the selected upstream does not support incremental mode', async () => {
const selectedChannel = createSelectedChannel({
sitePlatform: 'openai',
@@ -364,21 +558,56 @@ describe('responses websocket transport', () => {
});
it('preserves incremental response.create payloads with previous_response_id for websocket-capable upstreams', async () => {
fetchMock
.mockResolvedValueOnce(createSseResponse([
'event: response.output_item.done\n',
'data: {"type":"response.output_item.done","output_index":0,"item":{"id":"fc_1","type":"function_call","call_id":"call_1"}}\n\n',
'event: response.output_item.done\n',
'data: {"type":"response.output_item.done","output_index":1,"item":{"id":"msg_1","type":"message","role":"assistant","status":"completed","content":[{"type":"output_text","text":"call tool"}]}}\n\n',
'event: response.completed\n',
'data: {"type":"response.completed","response":{"id":"resp_ws_1","model":"gpt-5.4","status":"completed","output":[{"id":"fc_1","type":"function_call","call_id":"call_1"},{"id":"msg_1","type":"message","role":"assistant","status":"completed","content":[{"type":"output_text","text":"call tool"}]}],"usage":{"input_tokens":3,"output_tokens":1,"total_tokens":4}}}\n\n',
'data: [DONE]\n\n',
]))
.mockResolvedValueOnce(createSseResponse([
'event: response.completed\n',
'data: {"type":"response.completed","response":{"id":"resp_ws_2","model":"gpt-5.4","status":"completed","output":[{"id":"msg_2","type":"message","role":"assistant","status":"completed","content":[{"type":"output_text","text":"done"}]}],"usage":{"input_tokens":5,"output_tokens":1,"total_tokens":6}}}\n\n',
'data: [DONE]\n\n',
]));
const selectedChannel = createSelectedChannel({
siteUrl: upstreamSiteUrl,
});
selectChannelMock.mockReturnValue(selectedChannel);
previewSelectedChannelMock.mockResolvedValue(selectedChannel);
upstreamMessageHandler = (socket, _parsed, requestIndex) => {
if (requestIndex === 1) {
socket.send(JSON.stringify({
type: 'response.completed',
response: {
id: 'resp_ws_1',
model: 'gpt-5.4',
status: 'completed',
output: [{
id: 'msg_1',
type: 'message',
role: 'assistant',
status: 'completed',
content: [{ type: 'output_text', text: 'call tool' }],
}],
usage: {
input_tokens: 3,
output_tokens: 1,
total_tokens: 4,
},
},
}));
return;
}
socket.send(JSON.stringify({
type: 'response.completed',
response: {
id: 'resp_ws_2',
model: 'gpt-5.4',
status: 'completed',
output: [{
id: 'msg_2',
type: 'message',
role: 'assistant',
status: 'completed',
content: [{ type: 'output_text', text: 'done' }],
}],
usage: {
input_tokens: 5,
output_tokens: 1,
total_tokens: 6,
},
},
}));
};
const socket = new WebSocket(`${baseUrl}/v1/responses`);
await waitForSocketOpen(socket);
@@ -415,12 +644,92 @@ describe('responses websocket transport', () => {
await secondResponsePromise;
socket.close();
expect(fetchMock).toHaveBeenCalledTimes(0);
expect(upstreamConnectionCount).toBe(1);
expect(upstreamRequests).toHaveLength(2);
expect(upstreamRequests[0]).toMatchObject({
type: 'response.create',
model: 'gpt-5.4',
instructions: 'be helpful',
});
expect(upstreamRequests[1]).toMatchObject({
type: 'response.create',
previous_response_id: 'resp_ws_1',
model: 'gpt-5.4',
instructions: 'be helpful',
input: [
{
id: 'tool_out_1',
type: 'function_call_output',
call_id: 'call_1',
output: 'tool result',
},
],
});
});
it('disables codex websocket incremental transport when the selected account marks websockets as disabled', async () => {
const selectedChannel = createSelectedChannel({
extraConfig: JSON.stringify({
credentialMode: 'session',
websockets: false,
oauth: {
provider: 'codex',
accountId: 'chatgpt-account-123',
email: 'codex-user@example.com',
},
}),
});
selectChannelMock.mockReturnValue(selectedChannel);
previewSelectedChannelMock.mockResolvedValue(selectedChannel);
fetchMock
.mockResolvedValueOnce(createSseResponse([
'event: response.completed\n',
'data: {"type":"response.completed","response":{"id":"resp_http_1","model":"gpt-5.4","status":"completed","output":[{"id":"msg_1","type":"message","role":"assistant","status":"completed","content":[{"type":"output_text","text":"first"}]}],"usage":{"input_tokens":3,"output_tokens":1,"total_tokens":4}}}\n\n',
'data: [DONE]\n\n',
]))
.mockResolvedValueOnce(createSseResponse([
'event: response.completed\n',
'data: {"type":"response.completed","response":{"id":"resp_http_2","model":"gpt-5.4","status":"completed","output":[{"id":"msg_2","type":"message","role":"assistant","status":"completed","content":[{"type":"output_text","text":"second"}]}],"usage":{"input_tokens":5,"output_tokens":1,"total_tokens":6}}}\n\n',
'data: [DONE]\n\n',
]));
const socket = new WebSocket(`${baseUrl}/v1/responses`);
await waitForSocketOpen(socket);
const firstResponsePromise = waitForSocketMessages(socket, 1);
socket.send(JSON.stringify({
type: 'response.create',
model: 'gpt-5.4',
instructions: 'be helpful',
input: [],
}));
const firstMessages = await firstResponsePromise;
const secondResponsePromise = waitForSocketMessages(socket, 1);
socket.send(JSON.stringify({
type: 'response.create',
model: 'gpt-5.4',
previous_response_id: 'resp_http_1',
input: [
{
id: 'tool_out_1',
type: 'function_call_output',
call_id: 'call_1',
output: 'tool result',
},
],
}));
await secondResponsePromise;
socket.close();
expect(firstMessages[0]?.type).toBe('response.completed');
expect(upstreamConnectionCount).toBe(0);
expect(fetchMock).toHaveBeenCalledTimes(2);
const [, secondOptions] = fetchMock.mock.calls[1] as [string, RequestInit];
const secondBody = JSON.parse(String(secondOptions.body));
expect(secondBody.previous_response_id).toBe('resp_ws_1');
expect(secondBody.model).toBe('gpt-5.4');
expect(secondBody.instructions).toBe('be helpful');
expect(secondBody.previous_response_id).toBeUndefined();
expect(secondBody.input).toEqual([
{
id: 'tool_out_1',
@@ -490,11 +799,11 @@ describe('responses websocket transport', () => {
});
it('forwards generate=false upstream for websocket-capable upstreams instead of synthesizing prewarm events', async () => {
fetchMock.mockResolvedValueOnce(createSseResponse([
'event: response.completed\n',
'data: {"type":"response.completed","response":{"id":"resp_capable_generate_false","model":"gpt-5.4","status":"completed","output":[],"usage":{"input_tokens":0,"output_tokens":0,"total_tokens":0}}}\n\n',
'data: [DONE]\n\n',
]));
const selectedChannel = createSelectedChannel({
siteUrl: upstreamSiteUrl,
});
selectChannelMock.mockReturnValue(selectedChannel);
previewSelectedChannelMock.mockResolvedValue(selectedChannel);
const socket = new WebSocket(`${baseUrl}/v1/responses`);
await waitForSocketOpen(socket);
@@ -510,23 +819,42 @@ describe('responses websocket transport', () => {
socket.close();
expect(messages.map((message) => message.type)).toEqual(['response.completed']);
expect(fetchMock).toHaveBeenCalledTimes(1);
const [, options] = fetchMock.mock.calls[0] as [string, RequestInit];
const forwardedBody = JSON.parse(String(options.body));
expect(forwardedBody.generate).toBe(false);
expect(fetchMock).toHaveBeenCalledTimes(0);
expect(upstreamRequests[0]).toMatchObject({
type: 'response.create',
generate: false,
});
});
it('emits websocket error when the upstream stream closes before a terminal responses event', async () => {
fetchMock.mockResolvedValue(createSseResponse([
'event: response.created\n',
'data: {"type":"response.created","response":{"id":"resp_incomplete","model":"gpt-5.4","created_at":1706000000,"status":"in_progress","output":[]}}\n\n',
'event: response.output_text.delta\n',
'data: {"type":"response.output_text.delta","output_index":0,"item_id":"msg_ws","delta":"partial"}\n\n',
]));
const selectedChannel = createSelectedChannel({
siteUrl: upstreamSiteUrl,
});
selectChannelMock.mockReturnValue(selectedChannel);
previewSelectedChannelMock.mockResolvedValue(selectedChannel);
upstreamMessageHandler = (socket) => {
socket.send(JSON.stringify({
type: 'response.created',
response: {
id: 'resp_incomplete',
model: 'gpt-5.4',
created_at: 1706000000,
status: 'in_progress',
output: [],
},
}));
socket.send(JSON.stringify({
type: 'response.output_text.delta',
output_index: 0,
item_id: 'msg_ws',
delta: 'partial',
}));
socket.close();
};
const socket = new WebSocket(`${baseUrl}/v1/responses`);
await waitForSocketOpen(socket);
const messagesPromise = waitForSocketMessages(socket, 6, 400);
const messagesPromise = waitForSocketMessages(socket, 3, 400);
socket.send(JSON.stringify({
type: 'response.create',
@@ -546,12 +874,8 @@ describe('responses websocket transport', () => {
expect(messages.map((message) => message.type)).toEqual([
'response.created',
'response.output_text.delta',
'response.output_text.done',
'response.content_part.done',
'response.output_item.done',
'response.failed',
'error',
]);
expect(messages[5]?.response?.status).toBe('failed');
expect(messages[5]?.error?.message).toContain('stream closed before response.completed');
expect(messages[2]?.error?.message).toContain('stream closed before response.completed');
});
});
@@ -20,4 +20,56 @@ describe('collectResponsesFinalPayloadFromSse', () => {
.rejects
.toThrow('quota exceeded');
});
it('prefers aggregated stream content when response.completed only carries an empty output array', async () => {
const upstream = {
async text() {
return [
'event: response.created',
'data: {"type":"response.created","response":{"id":"resp_empty_completed","model":"gpt-5.4","created_at":1706000000,"status":"in_progress","output":[]}}',
'',
'event: response.output_item.added',
'data: {"type":"response.output_item.added","output_index":0,"item":{"id":"msg_empty_completed","type":"message","role":"assistant","status":"in_progress","content":[]}}',
'',
'event: response.output_text.delta',
'data: {"type":"response.output_text.delta","output_index":0,"item_id":"msg_empty_completed","delta":"pong"}',
'',
'event: response.completed',
'data: {"type":"response.completed","response":{"id":"resp_empty_completed","model":"gpt-5.4","status":"completed","output":[],"usage":{"input_tokens":3,"output_tokens":1,"total_tokens":4}}}',
'',
'data: [DONE]',
'',
].join('\n');
},
};
await expect(collectResponsesFinalPayloadFromSse(upstream, 'gpt-5.4'))
.resolves
.toMatchObject({
payload: {
id: 'resp_empty_completed',
status: 'completed',
output: [
{
id: 'msg_empty_completed',
type: 'message',
role: 'assistant',
status: 'completed',
content: [
{
type: 'output_text',
text: 'pong',
},
],
},
],
output_text: 'pong',
usage: {
input_tokens: 3,
output_tokens: 1,
total_tokens: 4,
},
},
});
});
});
+149 -10
View File
@@ -24,6 +24,36 @@ function getResponsesFailureMessage(payload: Record<string, unknown>): string {
return 'upstream stream failed';
}
function hasMeaningfulMessageContent(content: unknown): boolean {
if (!Array.isArray(content)) return false;
return content.some((part) => {
if (!isRecord(part)) return false;
const partType = typeof part.type === 'string' ? part.type.trim().toLowerCase() : '';
if (partType === 'output_text' || partType === 'text') {
return typeof part.text === 'string' && part.text.length > 0;
}
return true;
});
}
function hasMeaningfulResponsesOutput(output: unknown): boolean {
if (!Array.isArray(output)) return false;
return output.some((item) => {
if (!isRecord(item)) return false;
const itemType = typeof item.type === 'string' ? item.type.trim().toLowerCase() : '';
if (itemType === 'message') {
return hasMeaningfulMessageContent(item.content);
}
if (itemType === 'reasoning') {
return (
(Array.isArray(item.summary) && item.summary.length > 0)
|| (typeof item.encrypted_content === 'string' && item.encrypted_content.trim().length > 0)
);
}
return itemType.length > 0;
});
}
function hasCompleteFinalResponsesPayload(payload: Record<string, unknown>): boolean {
return (
payload.object === 'response.compaction'
@@ -32,11 +62,101 @@ function hasCompleteFinalResponsesPayload(payload: Record<string, unknown>): boo
);
}
export async function collectResponsesFinalPayloadFromSse(
upstream: { text(): Promise<string> },
function hasMeaningfulFinalResponsesPayload(payload: Record<string, unknown>): boolean {
if (payload.object === 'response.compaction') {
return Array.isArray(payload.output) && payload.output.length > 0;
}
if (typeof payload.output_text === 'string' && payload.output_text.length > 0) {
return true;
}
return hasMeaningfulResponsesOutput(payload.output);
}
function rememberStreamResponseEnvelope(
streamContext: ReturnType<typeof openAiResponsesTransformer.createStreamContext>,
payload: Record<string, unknown>,
): void {
const responsePayload = isRecord(payload.response) ? payload.response : payload;
if (typeof responsePayload.id === 'string' && responsePayload.id.trim().length > 0) {
streamContext.id = responsePayload.id;
}
if (typeof responsePayload.model === 'string' && responsePayload.model.trim().length > 0) {
streamContext.model = responsePayload.model;
}
const createdAt = (
typeof responsePayload.created_at === 'number' && Number.isFinite(responsePayload.created_at)
? responsePayload.created_at
: (typeof responsePayload.created === 'number' && Number.isFinite(responsePayload.created)
? responsePayload.created
: null)
);
if (createdAt !== null) {
streamContext.created = createdAt;
}
}
function materializeCompletedPayloadFromAggregate(
aggregateState: ReturnType<typeof openAiResponsesTransformer.aggregator.createState>,
streamContext: ReturnType<typeof openAiResponsesTransformer.createStreamContext>,
usage: ReturnType<typeof parseProxyUsage>,
): Record<string, unknown> | null {
const lines = openAiResponsesTransformer.aggregator.complete(
aggregateState,
streamContext,
usage,
);
const { events } = openAiResponsesTransformer.pullSseEvents(lines.join(''));
for (const event of events) {
if (event.data === '[DONE]') continue;
const payload = parseResponsesSsePayload(event.data);
if (!payload) continue;
if (event.event === 'response.completed' && isRecord(payload.response)) {
return payload.response;
}
if (payload.type === 'response.completed' && hasCompleteFinalResponsesPayload(payload)) {
return payload;
}
}
return null;
}
export function looksLikeResponsesSseText(rawText: string): boolean {
const { events, rest } = openAiResponsesTransformer.pullSseEvents(rawText);
if (events.length === 0 || rest.trim().length > 0) return false;
return events.some((event) => {
if (event.data === '[DONE]') return true;
if (event.event === 'error' || event.event.startsWith('response.')) return true;
const payload = parseResponsesSsePayload(event.data);
const payloadType = typeof payload?.type === 'string' ? payload.type : '';
return payloadType === 'error' || payloadType.startsWith('response.');
});
}
export function createSingleChunkStreamReader(rawText: string): {
read(): Promise<{ done: boolean; value?: Uint8Array }>;
cancel(reason?: unknown): Promise<unknown>;
releaseLock(): void;
} {
const chunk = Buffer.from(rawText, 'utf8');
let done = false;
return {
async read() {
if (done) return { done: true };
done = true;
return { done: false, value: chunk };
},
async cancel() {
done = true;
return undefined;
},
releaseLock() {},
};
}
export function collectResponsesFinalPayloadFromSseText(
rawText: string,
modelName: string,
): Promise<{ payload: Record<string, unknown>; rawText: string }> {
const rawText = await upstream.text();
): { payload: Record<string, unknown>; rawText: string } {
const { events } = openAiResponsesTransformer.pullSseEvents(rawText);
const streamContext = openAiResponsesTransformer.createStreamContext(modelName);
const aggregateState = openAiResponsesTransformer.aggregator.createState(modelName);
@@ -92,12 +212,17 @@ export async function collectResponsesFinalPayloadFromSse(
const payloadType = typeof payload.type === 'string' ? payload.type : '';
const eventType = payloadType || event.event;
rememberStreamResponseEnvelope(streamContext, payload);
usage = mergeProxyUsage(usage, parseProxyUsage(payload));
captureCompletedPayloadFromEvent(eventType, payload);
if (completedPayload) {
continue;
}
const normalizedEvent = openAiResponsesTransformer.transformStreamEvent(payload, streamContext, modelName);
const normalizedEvent = openAiResponsesTransformer.transformStreamEvent(
payload,
streamContext,
modelName,
);
captureCompletedPayloadFromLines(openAiResponsesTransformer.aggregator.serialize({
state: aggregateState,
streamContext,
@@ -106,12 +231,19 @@ export async function collectResponsesFinalPayloadFromSse(
}));
}
if (
completedPayload
&& !hasMeaningfulFinalResponsesPayload(completedPayload)
&& hasMeaningfulResponsesOutput(aggregateState.outputItems)
) {
completedPayload = materializeCompletedPayloadFromAggregate(aggregateState, streamContext, usage);
}
if (!completedPayload) {
captureCompletedPayloadFromLines(openAiResponsesTransformer.aggregator.complete(
aggregateState,
streamContext,
usage,
));
const materialized = materializeCompletedPayloadFromAggregate(aggregateState, streamContext, usage);
if (materialized) {
completedPayload = materialized;
}
}
if (completedPayload) {
@@ -123,3 +255,10 @@ export async function collectResponsesFinalPayloadFromSse(
throw new Error('stream disconnected before response.completed');
}
export async function collectResponsesFinalPayloadFromSse(
upstream: { text(): Promise<string> },
modelName: string,
): Promise<{ payload: Record<string, unknown>; rawText: string }> {
return collectResponsesFinalPayloadFromSseText(await upstream.text(), modelName);
}
+259 -56
View File
@@ -2,13 +2,19 @@ import { randomUUID } from 'node:crypto';
import type { FastifyInstance } from 'fastify';
import type { IncomingMessage } from 'node:http';
import { WebSocketServer, type RawData, type WebSocket } from 'ws';
import { createCodexWebsocketRuntime, CodexWebsocketRuntimeError } from '../../proxy-core/runtime/codexWebsocketRuntime.js';
import { tokenRouter } from '../../services/tokenRouter.js';
import { buildOauthProviderHeaders } from '../../services/oauth/service.js';
import { openAiResponsesTransformer } from '../../transformers/openai/responses/index.js';
import { buildUpstreamEndpointRequest } from './upstreamEndpoint.js';
const installedApps = new WeakSet<FastifyInstance>();
const WS_TURN_STATE_HEADER = 'x-codex-turn-state';
const RESPONSES_WEBSOCKET_MODE_HEADER = 'x-metapi-responses-websocket-mode';
const RESPONSES_WEBSOCKET_TRANSPORT_HEADER = 'x-metapi-responses-websocket-transport';
const codexWebsocketRuntime = createCodexWebsocketRuntime();
type SelectedChannel = NonNullable<Awaited<ReturnType<typeof tokenRouter.selectChannel>>>;
type NormalizedResponsesWebsocketRequest =
| {
@@ -42,6 +48,93 @@ function headerValueToTrimmedString(value: unknown): string {
return '';
}
function toBooleanLike(value: unknown): boolean | null {
if (typeof value === 'boolean') return value;
if (typeof value === 'string') {
const normalized = value.trim().toLowerCase();
if (normalized === 'true' || normalized === '1' || normalized === 'yes' || normalized === 'on') return true;
if (normalized === 'false' || normalized === '0' || normalized === 'no' || normalized === 'off') return false;
}
return null;
}
function parseExtraConfigRecord(extraConfig: unknown): Record<string, unknown> | null {
if (typeof extraConfig !== 'string') return null;
try {
const parsed = JSON.parse(extraConfig);
return isRecord(parsed) ? parsed : null;
} catch {
return null;
}
}
function readNestedRecord(value: unknown, key: string): Record<string, unknown> | null {
if (!isRecord(value)) return null;
const nested = value[key];
return isRecord(nested) ? nested : null;
}
function selectedChannelModelMatches(
selectedChannel: SelectedChannel | null,
requestModel: string,
): boolean {
if (!selectedChannel) return false;
const selectedModel = asTrimmedString(selectedChannel.actualModel).toLowerCase();
const normalizedRequestModel = asTrimmedString(requestModel).toLowerCase();
if (!selectedModel || !normalizedRequestModel) return true;
return selectedModel === normalizedRequestModel;
}
function selectedChannelSupportsCodexWebsocketTransport(
selectedChannel: SelectedChannel | null,
requestModel: string,
): boolean {
if (!selectedChannel) return false;
const platform = asTrimmedString(selectedChannel.site?.platform).toLowerCase();
if (platform !== 'codex') return false;
if (!selectedChannelModelMatches(selectedChannel, requestModel)) return false;
const extraConfig = parseExtraConfigRecord(selectedChannel.account.extraConfig);
const oauth = readNestedRecord(extraConfig, 'oauth');
const providerData = readNestedRecord(oauth, 'providerData');
const candidateFlags = [
extraConfig?.websockets,
readNestedRecord(extraConfig, 'attributes')?.websockets,
readNestedRecord(extraConfig, 'metadata')?.websockets,
providerData?.websockets,
readNestedRecord(providerData, 'attributes')?.websockets,
readNestedRecord(providerData, 'metadata')?.websockets,
];
for (const candidate of candidateFlags) {
const parsed = toBooleanLike(candidate);
if (parsed !== null) return parsed;
}
return true;
}
function selectedChannelSupportsIncrementalInput(
selectedChannel: SelectedChannel | null,
requestModel: string,
): boolean {
return selectedChannelSupportsCodexWebsocketTransport(selectedChannel, requestModel);
}
function shouldReuseSelectedChannel(
selectedChannel: SelectedChannel | null,
requestModel: string,
): boolean {
if (!selectedChannel) return false;
const selectedModel = asTrimmedString(selectedChannel.actualModel).toLowerCase();
const normalizedRequestModel = asTrimmedString(requestModel).toLowerCase();
if (!selectedModel || !normalizedRequestModel) return true;
return selectedModel === normalizedRequestModel;
}
function deriveCodexExplicitSessionId(body: Record<string, unknown>, sessionId: string): string {
const promptCacheKey = asTrimmedString(body.prompt_cache_key);
return promptCacheKey || sessionId;
}
function parseJsonObject(raw: RawData): Record<string, unknown> | null {
try {
const parsed = JSON.parse(String(raw));
@@ -232,6 +325,77 @@ function collectResponsesOutput(payloads: unknown[]): unknown[] {
.map(([, value]) => value);
}
async function forwardResponsesRequestViaHttp(input: {
app: FastifyInstance;
socket: WebSocket;
request: IncomingMessage;
payload: Record<string, unknown>;
preserveIncrementalMode: boolean;
}): Promise<unknown[] | null> {
const response = await input.app.inject({
method: 'POST',
url: '/v1/responses',
headers: {
...buildInjectHeaders(input.request),
[RESPONSES_WEBSOCKET_TRANSPORT_HEADER]: '1',
...(input.preserveIncrementalMode ? { [RESPONSES_WEBSOCKET_MODE_HEADER]: 'incremental' } : {}),
},
payload: input.payload,
});
if (response.statusCode < 200 || response.statusCode >= 300) {
let payload: unknown = null;
try {
payload = JSON.parse(response.body);
} catch {
payload = null;
}
writeResponsesWebsocketError(
input.socket,
response.statusCode,
response.statusMessage || 'Upstream error',
payload,
);
return null;
}
const contentType = String(response.headers['content-type'] || '').toLowerCase();
if (!contentType.includes('text/event-stream')) {
try {
const payload = JSON.parse(response.body);
input.socket.send(JSON.stringify(payload));
return isRecord(payload?.response) && Array.isArray(payload.response.output)
? cloneJsonObject(payload.response.output)
: [];
} catch {
writeResponsesWebsocketError(input.socket, 502, 'Unexpected non-JSON websocket proxy response');
return null;
}
}
const pulled = openAiResponsesTransformer.pullSseEvents(response.body);
const forwardedPayloads: unknown[] = [];
let sawTerminalPayload = false;
for (const event of pulled.events) {
if (event.data === '[DONE]') continue;
try {
const payload = JSON.parse(event.data);
forwardedPayloads.push(payload);
const type = isRecord(payload) ? asTrimmedString(payload.type) : '';
if (type === 'response.completed' || type === 'response.failed') {
sawTerminalPayload = true;
}
input.socket.send(JSON.stringify(payload));
} catch {
// Ignore malformed SSE frames; the HTTP route already normalizes them.
}
}
if (!sawTerminalPayload) {
writeResponsesWebsocketError(input.socket, 408, 'stream closed before response.completed');
}
return collectResponsesOutput(forwardedPayloads);
}
function buildInjectHeaders(request: IncomingMessage): Record<string, string | string[]> {
const headers: Record<string, string | string[]> = {};
for (const [rawKey, rawValue] of Object.entries(request.headers)) {
@@ -262,7 +426,7 @@ async function supportsResponsesWebsocketIncrementalInput(
try {
const selected = await tokenRouter.previewSelectedChannel(requestModel);
return asTrimmedString(selected?.site?.platform).toLowerCase() === 'codex';
return selectedChannelSupportsIncrementalInput(selected, requestModel);
} catch {
return false;
}
@@ -273,8 +437,16 @@ async function handleResponsesWebsocketConnection(
socket: WebSocket,
request: IncomingMessage,
) {
const websocketSessionId = headerValueToTrimmedString(request.headers['session_id'])
|| headerValueToTrimmedString(request.headers['session-id'])
|| randomUUID();
let lastRequest: Record<string, unknown> | null = null;
let lastResponseOutput: unknown[] = [];
let selectedChannel: SelectedChannel | null = null;
socket.once('close', () => {
void codexWebsocketRuntime.closeSession(websocketSessionId);
});
socket.on('message', async (raw) => {
const parsed = parseJsonObject(raw);
@@ -283,7 +455,9 @@ async function handleResponsesWebsocketConnection(
return;
}
const supportsIncrementalInput = await supportsResponsesWebsocketIncrementalInput(parsed, lastRequest);
const requestModel = asTrimmedString(parsed.model) || asTrimmedString(lastRequest?.model);
const supportsIncrementalInput = selectedChannelSupportsIncrementalInput(selectedChannel, requestModel)
|| await supportsResponsesWebsocketIncrementalInput(parsed, lastRequest);
const shouldHandleLocalPrewarm = shouldHandleResponsesWebsocketPrewarmLocally(
parsed,
lastRequest,
@@ -309,67 +483,95 @@ async function handleResponsesWebsocketConnection(
return;
}
const response = await app.inject({
method: 'POST',
url: '/v1/responses',
headers: {
...buildInjectHeaders(request),
if (!shouldReuseSelectedChannel(selectedChannel, requestModel)) {
selectedChannel = requestModel
? await tokenRouter.selectChannel(requestModel)
: null;
}
const codexWebsocketChannel = selectedChannelSupportsCodexWebsocketTransport(selectedChannel, requestModel)
? selectedChannel
: null;
if (codexWebsocketChannel) {
const downstreamHeaders: Record<string, unknown> = {
...(request.headers as Record<string, unknown>),
[RESPONSES_WEBSOCKET_TRANSPORT_HEADER]: '1',
...(supportsIncrementalInput ? { [RESPONSES_WEBSOCKET_MODE_HEADER]: 'incremental' } : {}),
},
payload: normalized.request,
});
};
const providerHeaders = buildOauthProviderHeaders({
extraConfig: typeof codexWebsocketChannel.account.extraConfig === 'string'
? codexWebsocketChannel.account.extraConfig
: null,
downstreamHeaders,
});
const prepared = buildUpstreamEndpointRequest({
endpoint: 'responses',
modelName: asTrimmedString(codexWebsocketChannel.actualModel) || requestModel,
stream: true,
tokenValue: codexWebsocketChannel.tokenValue,
sitePlatform: codexWebsocketChannel.site.platform,
siteUrl: codexWebsocketChannel.site.url,
openaiBody: normalized.request,
downstreamFormat: 'responses',
responsesOriginalBody: normalized.request,
downstreamHeaders,
providerHeaders,
codexExplicitSessionId: deriveCodexExplicitSessionId(normalized.request, websocketSessionId),
});
const requestUrl = `${codexWebsocketChannel.site.url.replace(/\/+$/, '')}${prepared.path}`;
if (response.statusCode < 200 || response.statusCode >= 300) {
let payload: unknown = null;
try {
payload = JSON.parse(response.body);
} catch {
payload = null;
}
writeResponsesWebsocketError(
socket,
response.statusCode,
response.statusMessage || 'Upstream error',
payload,
);
return;
}
const contentType = String(response.headers['content-type'] || '').toLowerCase();
if (!contentType.includes('text/event-stream')) {
try {
const payload = JSON.parse(response.body);
socket.send(JSON.stringify(payload));
lastResponseOutput = isRecord(payload?.response) && Array.isArray(payload.response.output)
? cloneJsonObject(payload.response.output)
: [];
} catch {
writeResponsesWebsocketError(socket, 502, 'Unexpected non-JSON websocket proxy response');
}
return;
}
const pulled = openAiResponsesTransformer.pullSseEvents(response.body);
const forwardedPayloads: unknown[] = [];
let sawTerminalPayload = false;
for (const event of pulled.events) {
if (event.data === '[DONE]') continue;
try {
const payload = JSON.parse(event.data);
forwardedPayloads.push(payload);
const type = isRecord(payload) ? asTrimmedString(payload.type) : '';
if (type === 'response.completed' || type === 'response.failed') {
sawTerminalPayload = true;
const runtimeResult = await codexWebsocketRuntime.sendRequest({
sessionId: websocketSessionId,
requestUrl,
headers: prepared.headers,
body: prepared.body,
});
lastResponseOutput = collectResponsesOutput(runtimeResult.events);
for (const payload of runtimeResult.events) {
socket.send(JSON.stringify(payload));
}
socket.send(JSON.stringify(payload));
} catch {
// Ignore malformed SSE frames; the HTTP route already normalizes them.
} catch (error) {
const runtimeError = error instanceof CodexWebsocketRuntimeError
? error
: new CodexWebsocketRuntimeError('upstream websocket request failed');
if (runtimeError.status === 426) {
const fallbackOutput = await forwardResponsesRequestViaHttp({
app,
socket,
request,
payload: normalized.request,
preserveIncrementalMode: false,
});
if (fallbackOutput) {
lastResponseOutput = fallbackOutput;
}
return;
}
lastResponseOutput = collectResponsesOutput(runtimeError.events);
for (const payload of runtimeError.events) {
socket.send(JSON.stringify(payload));
}
writeResponsesWebsocketError(
socket,
runtimeError.status || 408,
runtimeError.message,
runtimeError.payload,
);
}
return;
}
lastResponseOutput = collectResponsesOutput(forwardedPayloads);
if (!sawTerminalPayload) {
writeResponsesWebsocketError(socket, 408, 'stream closed before response.completed');
const forwardedOutput = await forwardResponsesRequestViaHttp({
app,
socket,
request,
payload: normalized.request,
preserveIncrementalMode: supportsIncrementalInput,
});
if (forwardedOutput) {
lastResponseOutput = forwardedOutput;
}
});
}
@@ -394,6 +596,7 @@ export function ensureResponsesWebsocketTransport(app: FastifyInstance) {
});
app.addHook('onClose', async () => {
await codexWebsocketRuntime.closeAllSessions();
await new Promise<void>((resolve) => {
websocketServer.close(() => resolve());
});
+3
View File
@@ -54,6 +54,9 @@ vi.mock('../../db/index.js', () => ({
db: {
insert: (arg: any) => dbInsertMock(arg),
},
hasProxyLogBillingDetailsColumn: async () => false,
hasProxyLogClientColumns: async () => false,
hasProxyLogDownstreamApiKeyIdColumn: async () => false,
schema: {
proxyLogs: {},
},
+59 -13
View File
@@ -1,6 +1,5 @@
import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { fetch } from 'undici';
import { db, hasProxyLogDownstreamApiKeyIdColumn, schema } from '../../db/index.js';
import { tokenRouter } from '../../services/tokenRouter.js';
import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js';
import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js';
@@ -13,6 +12,8 @@ import { composeProxyLogMessage } from './logPathMeta.js';
import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
import { getProxyAuthContext } from '../../middleware/auth.js';
import { buildUpstreamUrl } from './upstreamUrl.js';
import { detectDownstreamClientContext, type DownstreamClientContext } from './downstreamClientContext.js';
import { insertProxyLog } from '../../services/proxyLogStore.js';
const MAX_RETRIES = 2;
const DEFAULT_SEARCH_MODEL = '__search';
@@ -55,8 +56,12 @@ export async function searchProxyRoute(app: FastifyInstance) {
if (!await ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return;
const downstreamPolicy = getDownstreamRoutingPolicy(request);
const downstreamApiKeyId = getProxyAuthContext(request)?.keyId ?? null;
const logDownstreamApiKeyId = downstreamApiKeyId !== null
&& await hasProxyLogDownstreamApiKeyIdColumn();
const downstreamPath = '/v1/search';
const clientContext = detectDownstreamClientContext({
downstreamPath,
headers: request.headers as Record<string, unknown>,
body,
});
const excludeChannelIds: number[] = [];
let retryCount = 0;
@@ -101,8 +106,23 @@ export async function searchProxyRoute(app: FastifyInstance) {
const text = await upstream.text();
if (!upstream.ok) {
tokenRouter.recordFailure(selected.channel.id);
logProxy(selected, requestedModel, 'failed', upstream.status, Date.now() - startTime, text, retryCount, logDownstreamApiKeyId ? downstreamApiKeyId : null);
tokenRouter.recordFailure(selected.channel.id, {
status: upstream.status,
errorText: text,
modelName: selected.actualModel,
});
logProxy(
selected,
requestedModel,
'failed',
upstream.status,
Date.now() - startTime,
text,
retryCount,
downstreamApiKeyId,
clientContext,
downstreamPath,
);
if (isTokenExpiredError({ status: upstream.status, message: text })) {
await reportTokenExpired({
accountId: selected.account.id,
@@ -126,13 +146,28 @@ export async function searchProxyRoute(app: FastifyInstance) {
try { data = JSON.parse(text); } catch { data = { data: [] }; }
const latency = Date.now() - startTime;
tokenRouter.recordSuccess(selected.channel.id, latency, 0);
tokenRouter.recordSuccess(selected.channel.id, latency, 0, selected.actualModel);
recordDownstreamCostUsage(request, 0);
logProxy(selected, requestedModel, 'success', upstream.status, latency, null, retryCount, logDownstreamApiKeyId ? downstreamApiKeyId : null);
logProxy(selected, requestedModel, 'success', upstream.status, latency, null, retryCount, downstreamApiKeyId, clientContext, downstreamPath);
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, logDownstreamApiKeyId ? downstreamApiKeyId : null);
tokenRouter.recordFailure(selected.channel.id, {
status: 0,
errorText: error?.message || 'network error',
modelName: selected.actualModel,
});
logProxy(
selected,
requestedModel,
'failed',
0,
Date.now() - startTime,
error?.message || 'network error',
retryCount,
downstreamApiKeyId,
clientContext,
downstreamPath,
);
if (retryCount < MAX_RETRIES) {
retryCount += 1;
continue;
@@ -158,14 +193,16 @@ async function logProxy(
errorMessage: string | null,
retryCount: number,
downstreamApiKeyId: number | null = null,
clientContext: DownstreamClientContext | null = null,
downstreamPath = '/v1/search',
) {
try {
const createdAt = formatUtcSqlDateTime(new Date());
await db.insert(schema.proxyLogs).values({
await insertProxyLog({
routeId: selected.channel.routeId,
channelId: selected.channel.id,
accountId: selected.account.id,
...(downstreamApiKeyId !== null ? { downstreamApiKeyId } : {}),
downstreamApiKeyId,
modelRequested,
modelActual: selected.actualModel,
status,
@@ -176,12 +213,21 @@ async function logProxy(
totalTokens: 0,
estimatedCost: 0,
errorMessage: composeProxyLogMessage({
downstreamPath: '/v1/search',
clientKind: clientContext?.clientKind && clientContext.clientKind !== 'generic'
? clientContext.clientKind
: null,
sessionId: clientContext?.sessionId || null,
traceHint: clientContext?.traceHint || null,
downstreamPath,
errorMessage,
}),
clientFamily: clientContext?.clientKind || null,
clientAppId: clientContext?.clientAppId || null,
clientAppName: clientContext?.clientAppName || null,
clientConfidence: clientContext?.clientConfidence || null,
retryCount,
createdAt,
}).run();
});
} catch (error) {
console.warn('[proxy/search] failed to write proxy log', error);
}
@@ -1,4 +1,5 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { config } from '../../config.js';
const fetchModelPricingCatalogMock = vi.fn(async (_arg?: unknown): Promise<any> => null);
@@ -11,6 +12,9 @@ import {
buildUpstreamEndpointRequest,
isUnsupportedMediaTypeError,
isEndpointDowngradeError,
recordUpstreamEndpointFailure,
recordUpstreamEndpointSuccess,
resetUpstreamEndpointRuntimeState,
resolveUpstreamEndpointCandidates,
} from './upstreamEndpoint.js';
@@ -32,6 +36,18 @@ describe('resolveUpstreamEndpointCandidates', () => {
beforeEach(() => {
fetchModelPricingCatalogMock.mockReset();
fetchModelPricingCatalogMock.mockResolvedValue(null);
resetUpstreamEndpointRuntimeState();
(config as any).codexHeaderDefaults = {
userAgent: '',
betaFeatures: '',
};
(config as any).payloadRules = {
default: [],
defaultRaw: [],
override: [],
overrideRaw: [],
filter: [],
};
});
it('uses downstream-aligned endpoint priority for unknown platforms', async () => {
@@ -180,6 +196,129 @@ describe('resolveUpstreamEndpointCandidates', () => {
expect(order).toEqual(['responses', 'messages', 'chat']);
});
it('remembers the last successful endpoint per site capability profile', async () => {
recordUpstreamEndpointSuccess({
siteId: baseContext.site.id,
endpoint: 'responses',
downstreamFormat: 'openai',
modelName: 'gpt-5.3',
});
const order = await resolveUpstreamEndpointCandidates(
{
...baseContext,
site: { ...baseContext.site, platform: 'new-api' },
},
'gpt-5.3',
'openai',
);
expect(order).toEqual(['responses', 'chat', 'messages']);
});
it('keeps remote-document-url requests on a separate runtime preference bucket from inline document requests', async () => {
recordUpstreamEndpointSuccess({
siteId: baseContext.site.id,
endpoint: 'chat',
downstreamFormat: 'openai',
modelName: 'gpt-5.3',
requestCapabilities: {
hasNonImageFileInput: true,
conversationFileSummary: {
hasImage: false,
hasAudio: false,
hasDocument: true,
hasRemoteDocumentUrl: false,
},
},
});
const order = await resolveUpstreamEndpointCandidates(
{
...baseContext,
site: { ...baseContext.site, platform: 'new-api' },
},
'gpt-5.3',
'openai',
undefined,
{
hasNonImageFileInput: true,
conversationFileSummary: {
hasImage: false,
hasAudio: false,
hasDocument: true,
hasRemoteDocumentUrl: true,
},
},
);
expect(order).toEqual(['responses', 'messages', 'chat']);
});
it('does not remember messages fallback success for generic /v1/responses requests', async () => {
recordUpstreamEndpointSuccess({
siteId: baseContext.site.id,
endpoint: 'messages',
downstreamFormat: 'responses',
modelName: 'gpt-5.3',
});
const order = await resolveUpstreamEndpointCandidates(
{
...baseContext,
site: { ...baseContext.site, platform: 'new-api' },
},
'gpt-5.3',
'responses',
);
expect(order).toEqual(['responses', 'chat', 'messages']);
});
it('does not block generic /v1/responses endpoints on transient upstream errors', async () => {
recordUpstreamEndpointFailure({
siteId: baseContext.site.id,
endpoint: 'responses',
downstreamFormat: 'responses',
modelName: 'gpt-5.3',
status: 504,
errorText: '{"error":{"message":"Gateway time-out","type":"upstream_error"}}',
});
const order = await resolveUpstreamEndpointCandidates(
{
...baseContext,
site: { ...baseContext.site, platform: 'new-api' },
},
'gpt-5.3',
'responses',
);
expect(order).toEqual(['responses', 'chat', 'messages']);
});
it('learns a better endpoint from explicit upstream protocol errors', async () => {
recordUpstreamEndpointFailure({
siteId: baseContext.site.id,
endpoint: 'chat',
downstreamFormat: 'openai',
modelName: 'gpt-5.3',
status: 400,
errorText: 'Unsupported legacy protocol: /v1/chat/completions is not supported. Please use /v1/responses.',
});
const order = await resolveUpstreamEndpointCandidates(
{
...baseContext,
site: { ...baseContext.site, platform: 'new-api' },
},
'gpt-5.3',
'openai',
);
expect(order).toEqual(['responses', 'messages']);
});
it('keeps claude models messages-first even when openai platform catalog prefers chat', async () => {
fetchModelPricingCatalogMock.mockResolvedValue({
models: [
@@ -468,6 +607,136 @@ describe('buildUpstreamEndpointRequest', () => {
expect(request.body.prompt_cache_key).toBe('codex-cache-123');
});
it('applies configured codex header defaults with CLIProxyAPI-compatible precedence', () => {
(config as any).codexHeaderDefaults = {
userAgent: 'codex-config-ua/1.0',
betaFeatures: 'multi_agent',
};
const websocketRequest = buildUpstreamEndpointRequest({
endpoint: 'responses',
modelName: 'gpt-5.4',
stream: true,
tokenValue: 'oauth-access-token',
oauthProvider: 'codex',
sitePlatform: 'codex',
siteUrl: 'https://chatgpt.com/backend-api/codex',
openaiBody: {
model: 'gpt-5.4',
messages: [{ role: 'user', content: 'hello codex' }],
},
downstreamFormat: 'openai',
downstreamHeaders: {
'x-metapi-responses-websocket-transport': '1',
},
providerHeaders: {
Originator: 'codex_cli_rs',
},
} as any);
expect(websocketRequest.headers['User-Agent']).toBe('codex-config-ua/1.0');
expect(websocketRequest.headers['x-codex-beta-features']).toBe('multi_agent');
const clientHeaderRequest = buildUpstreamEndpointRequest({
endpoint: 'responses',
modelName: 'gpt-5.4',
stream: true,
tokenValue: 'oauth-access-token',
oauthProvider: 'codex',
sitePlatform: 'codex',
siteUrl: 'https://chatgpt.com/backend-api/codex',
openaiBody: {
model: 'gpt-5.4',
messages: [{ role: 'user', content: 'hello again codex' }],
},
downstreamFormat: 'openai',
downstreamHeaders: {
'x-metapi-responses-websocket-transport': '1',
'user-agent': 'client-ua/2.0',
'x-codex-beta-features': 'client-beta',
},
providerHeaders: {
Originator: 'codex_cli_rs',
},
} as any);
expect(clientHeaderRequest.headers['User-Agent']).toBe('codex-config-ua/1.0');
expect(clientHeaderRequest.headers['x-codex-beta-features']).toBe('client-beta');
const httpRequest = buildUpstreamEndpointRequest({
endpoint: 'responses',
modelName: 'gpt-5.4',
stream: false,
tokenValue: 'oauth-access-token',
oauthProvider: 'codex',
sitePlatform: 'codex',
siteUrl: 'https://chatgpt.com/backend-api/codex',
openaiBody: {
model: 'gpt-5.4',
messages: [{ role: 'user', content: 'plain http codex' }],
},
downstreamFormat: 'openai',
providerHeaders: {
Originator: 'codex_cli_rs',
},
} as any);
expect(httpRequest.headers['x-codex-beta-features']).toBeUndefined();
});
it('applies configured payload rules before preparing codex responses requests', () => {
(config as any).payloadRules = {
default: [
{
models: [{ name: 'gpt-*', protocol: 'codex' }],
params: {
'reasoning.effort': 'high',
},
},
],
defaultRaw: [],
override: [
{
models: [{ name: 'gpt-5.4', protocol: 'codex' }],
params: {
'text.verbosity': 'low',
},
},
],
overrideRaw: [],
filter: [
{
models: [{ name: 'gpt-5.4', protocol: 'codex' }],
params: ['safety_identifier'],
},
],
};
const request = buildUpstreamEndpointRequest({
endpoint: 'responses',
modelName: 'gpt-5.4',
stream: false,
tokenValue: 'oauth-access-token',
oauthProvider: 'codex',
sitePlatform: 'codex',
siteUrl: 'https://chatgpt.com/backend-api/codex',
openaiBody: {
model: 'gpt-5.4',
messages: [{ role: 'user', content: 'hello codex' }],
verbosity: 'high',
safety_identifier: 'drop-me',
},
downstreamFormat: 'openai',
providerHeaders: {
Originator: 'codex_cli_rs',
},
} as any);
expect(request.body.reasoning).toEqual({ effort: 'high' });
expect(request.body.text).toEqual({ verbosity: 'low' });
expect(request.body.safety_identifier).toBeUndefined();
});
it('builds gemini-cli native requests with project envelope and bearer headers', () => {
const request = buildUpstreamEndpointRequest({
endpoint: 'responses',
@@ -850,7 +1119,7 @@ describe('buildUpstreamEndpointRequest', () => {
]);
});
it('applies global responses standardization and drops non-standard fields', () => {
it('preserves unknown native responses fields while still normalizing known compatibility fields', () => {
const request = buildUpstreamEndpointRequest({
endpoint: 'responses',
modelName: 'upstream-gpt',
@@ -865,13 +1134,13 @@ describe('buildUpstreamEndpointRequest', () => {
input: 'hello',
metadata: { trace: 'abc123' },
max_completion_tokens: 512,
custom_vendor_flag: 'drop-me',
custom_vendor_flag: 'keep-me',
},
});
expect(request.path).toBe('/v1/responses');
expect(request.body.metadata).toEqual({ trace: 'abc123' });
expect(request.body.custom_vendor_flag).toBeUndefined();
expect(request.body.custom_vendor_flag).toBe('keep-me');
expect(request.body.max_completion_tokens).toBeUndefined();
expect(request.body.max_output_tokens).toBe(512);
expect(request.body.input).toEqual([
+330 -32
View File
@@ -1,6 +1,12 @@
import { createHash, randomUUID } from 'node:crypto';
import {
rankConversationFileEndpoints,
type ConversationFileInputSummary,
} from '../../proxy-core/capabilities/conversationFileCapabilities.js';
import { resolveProviderProfile } from '../../proxy-core/providers/registry.js';
import { config } from '../../config.js';
import { fetchModelPricingCatalog } from '../../services/modelPricingService.js';
import { applyPayloadRules } from '../../services/payloadRules.js';
import type { DownstreamFormat } from '../../transformers/shared/normalized.js';
import {
convertOpenAiBodyToResponsesBody as convertOpenAiBodyToResponsesBodyViaTransformer,
@@ -13,7 +19,7 @@ import {
import {
buildGeminiGenerateContentRequestFromOpenAi,
} from './geminiCliCompat.js';
export {
import {
buildMinimalJsonHeadersForCompatibility,
isEndpointDispatchDeniedError,
isEndpointDowngradeError,
@@ -21,10 +27,31 @@ export {
promoteResponsesCandidateAfterLegacyChatError,
shouldPreferResponsesAfterLegacyChatError,
} from '../../transformers/shared/endpointCompatibility.js';
export {
buildMinimalJsonHeadersForCompatibility,
isEndpointDispatchDeniedError,
isEndpointDowngradeError,
isUnsupportedMediaTypeError,
promoteResponsesCandidateAfterLegacyChatError,
shouldPreferResponsesAfterLegacyChatError,
};
export type UpstreamEndpoint = 'chat' | 'messages' | 'responses';
export type EndpointPreference = DownstreamFormat | 'responses';
type EndpointCapabilityProfile = {
preferMessagesForClaudeModel: boolean;
hasNonImageFileInput: boolean;
hasRemoteDocumentUrl: boolean;
wantsNativeResponsesReasoning: boolean;
};
type EndpointRuntimeState = {
preferredEndpoint: UpstreamEndpoint | null;
preferredUpdatedAtMs: number;
blockedUntilMsByEndpoint: Partial<Record<UpstreamEndpoint, number>>;
};
type ChannelContext = {
site: {
id: number;
@@ -39,6 +66,10 @@ type ChannelContext = {
};
};
const ENDPOINT_RUNTIME_PREFERRED_TTL_MS = 24 * 60 * 60 * 1000;
const ENDPOINT_RUNTIME_BLOCK_TTL_MS = 6 * 60 * 60 * 1000;
const endpointRuntimeStates = new Map<string, EndpointRuntimeState>();
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object';
}
@@ -47,6 +78,20 @@ function asTrimmedString(value: unknown): string {
return typeof value === 'string' ? value.trim() : '';
}
function resolveRequestedModelForPayloadRules(input: {
modelName: string;
openaiBody: Record<string, unknown>;
claudeOriginalBody?: Record<string, unknown>;
responsesOriginalBody?: Record<string, unknown>;
}): string {
return (
asTrimmedString(input.responsesOriginalBody?.model)
|| asTrimmedString(input.claudeOriginalBody?.model)
|| asTrimmedString(input.openaiBody.model)
|| asTrimmedString(input.modelName)
);
}
function normalizePlatformName(platform: unknown): string {
return asTrimmedString(platform).toLowerCase();
}
@@ -557,6 +602,225 @@ function normalizeEndpointTypes(value: unknown): UpstreamEndpoint[] {
return Array.from(normalized);
}
function buildEndpointCapabilityProfile(input?: {
modelName?: string;
requestedModelHint?: string;
requestCapabilities?: {
hasNonImageFileInput?: boolean;
conversationFileSummary?: ConversationFileInputSummary;
wantsNativeResponsesReasoning?: boolean;
};
}): EndpointCapabilityProfile {
return {
preferMessagesForClaudeModel: (
isClaudeFamilyModel(asTrimmedString(input?.modelName))
|| isClaudeFamilyModel(asTrimmedString(input?.requestedModelHint))
),
hasNonImageFileInput: (
input?.requestCapabilities?.conversationFileSummary?.hasDocument === true
|| input?.requestCapabilities?.hasNonImageFileInput === true
),
hasRemoteDocumentUrl: (
input?.requestCapabilities?.conversationFileSummary?.hasRemoteDocumentUrl === true
),
wantsNativeResponsesReasoning: input?.requestCapabilities?.wantsNativeResponsesReasoning === true,
};
}
function buildEndpointRuntimeStateKey(input: {
siteId: number;
downstreamFormat: EndpointPreference;
capabilityProfile: EndpointCapabilityProfile;
}): string {
const capabilityProfile = input.capabilityProfile;
return [
String(input.siteId),
input.downstreamFormat,
capabilityProfile.preferMessagesForClaudeModel ? 'claude' : 'generic',
capabilityProfile.hasNonImageFileInput ? 'files' : 'nofiles',
capabilityProfile.hasRemoteDocumentUrl ? 'remoteurl' : 'noremoteurl',
capabilityProfile.wantsNativeResponsesReasoning ? 'reasoning' : 'noreasoning',
].join(':');
}
function getOrCreateEndpointRuntimeState(key: string, nowMs = Date.now()): EndpointRuntimeState {
const existing = endpointRuntimeStates.get(key);
if (existing) return existing;
const initial: EndpointRuntimeState = {
preferredEndpoint: null,
preferredUpdatedAtMs: nowMs,
blockedUntilMsByEndpoint: {},
};
endpointRuntimeStates.set(key, initial);
return initial;
}
function maybeDeleteEndpointRuntimeState(key: string, nowMs = Date.now()): void {
const state = endpointRuntimeStates.get(key);
if (!state) return;
const hasActiveBlock = Object.values(state.blockedUntilMsByEndpoint).some((untilMs) => (
typeof untilMs === 'number' && untilMs > nowMs
));
const preferredFresh = (
!!state.preferredEndpoint
&& (state.preferredUpdatedAtMs + ENDPOINT_RUNTIME_PREFERRED_TTL_MS) > nowMs
);
if (!hasActiveBlock && !preferredFresh) {
endpointRuntimeStates.delete(key);
}
}
function applyEndpointRuntimePreference(
candidates: UpstreamEndpoint[],
key: string,
nowMs = Date.now(),
): UpstreamEndpoint[] {
const state = endpointRuntimeStates.get(key);
if (!state || candidates.length <= 1) return candidates;
const blocked = new Set<UpstreamEndpoint>();
for (const endpoint of candidates) {
const untilMs = state.blockedUntilMsByEndpoint[endpoint];
if (typeof untilMs === 'number' && untilMs > nowMs) {
blocked.add(endpoint);
}
}
let next = candidates.filter((endpoint) => !blocked.has(endpoint));
if (next.length === 0) {
next = [...candidates];
}
const preferredFresh = (
!!state.preferredEndpoint
&& (state.preferredUpdatedAtMs + ENDPOINT_RUNTIME_PREFERRED_TTL_MS) > nowMs
);
if (preferredFresh && state.preferredEndpoint && next.includes(state.preferredEndpoint)) {
next = [
state.preferredEndpoint,
...next.filter((endpoint) => endpoint !== state.preferredEndpoint),
];
}
maybeDeleteEndpointRuntimeState(key, nowMs);
return next;
}
function inferSuggestedEndpointFromError(errorText?: string | null): UpstreamEndpoint | null {
const text = (errorText || '').toLowerCase();
if (!text) return null;
if (text.includes('/v1/responses')) return 'responses';
if (text.includes('/v1/messages')) return 'messages';
if (text.includes('/v1/chat/completions')) return 'chat';
return null;
}
function shouldBlockEndpointByError(status: number, errorText?: string | null): boolean {
if (isEndpointDispatchDeniedError(status, errorText)) return true;
if (status === 404 || status === 405 || status === 415 || status === 501) return true;
if (isUnsupportedMediaTypeError(status, errorText)) return true;
const text = (errorText || '').toLowerCase();
return (
text.includes('convert_request_failed')
|| text.includes('endpoint_not_found')
|| text.includes('unknown_endpoint')
|| text.includes('unsupported_endpoint')
|| text.includes('unsupported_path')
|| text.includes('not_found_error')
|| text.includes('unsupported legacy protocol')
|| text.includes('please use /v1/')
|| text.includes('does not allow /v1/')
|| text.includes('unknown endpoint')
|| text.includes('unsupported endpoint')
|| text.includes('unsupported path')
|| text.includes('unrecognized request url')
|| text.includes('no route matched')
|| text.includes('does not exist')
);
}
function shouldRememberSuccessfulEndpoint(input: {
endpoint: UpstreamEndpoint;
downstreamFormat: EndpointPreference;
}): boolean {
if (input.downstreamFormat !== 'responses') return true;
return input.endpoint === 'responses';
}
export function resetUpstreamEndpointRuntimeState(): void {
endpointRuntimeStates.clear();
}
export function recordUpstreamEndpointSuccess(input: {
siteId: number;
endpoint: UpstreamEndpoint;
downstreamFormat: EndpointPreference;
modelName?: string;
requestedModelHint?: string;
requestCapabilities?: {
hasNonImageFileInput?: boolean;
conversationFileSummary?: ConversationFileInputSummary;
wantsNativeResponsesReasoning?: boolean;
};
}): void {
if (!shouldRememberSuccessfulEndpoint(input)) return;
const nowMs = Date.now();
const key = buildEndpointRuntimeStateKey({
siteId: input.siteId,
downstreamFormat: input.downstreamFormat,
capabilityProfile: buildEndpointCapabilityProfile({
modelName: input.modelName,
requestedModelHint: input.requestedModelHint,
requestCapabilities: input.requestCapabilities,
}),
});
const state = getOrCreateEndpointRuntimeState(key, nowMs);
state.preferredEndpoint = input.endpoint;
state.preferredUpdatedAtMs = nowMs;
delete state.blockedUntilMsByEndpoint[input.endpoint];
}
export function recordUpstreamEndpointFailure(input: {
siteId: number;
endpoint: UpstreamEndpoint;
downstreamFormat: EndpointPreference;
status: number;
errorText?: string | null;
modelName?: string;
requestedModelHint?: string;
requestCapabilities?: {
hasNonImageFileInput?: boolean;
conversationFileSummary?: ConversationFileInputSummary;
wantsNativeResponsesReasoning?: boolean;
};
}): void {
if (!shouldBlockEndpointByError(input.status, input.errorText)) return;
const nowMs = Date.now();
const key = buildEndpointRuntimeStateKey({
siteId: input.siteId,
downstreamFormat: input.downstreamFormat,
capabilityProfile: buildEndpointCapabilityProfile({
modelName: input.modelName,
requestedModelHint: input.requestedModelHint,
requestCapabilities: input.requestCapabilities,
}),
});
const state = getOrCreateEndpointRuntimeState(key, nowMs);
state.blockedUntilMsByEndpoint[input.endpoint] = nowMs + ENDPOINT_RUNTIME_BLOCK_TTL_MS;
const suggestedEndpoint = inferSuggestedEndpointFromError(input.errorText);
if (suggestedEndpoint && suggestedEndpoint !== input.endpoint) {
state.preferredEndpoint = suggestedEndpoint;
state.preferredUpdatedAtMs = nowMs;
delete state.blockedUntilMsByEndpoint[suggestedEndpoint];
}
}
function preferredEndpointOrder(
downstreamFormat: EndpointPreference,
sitePlatform?: string,
@@ -626,27 +890,44 @@ export async function resolveUpstreamEndpointCandidates(
requestedModelHint?: string,
requestCapabilities?: {
hasNonImageFileInput?: boolean;
conversationFileSummary?: ConversationFileInputSummary;
wantsNativeResponsesReasoning?: boolean;
},
): Promise<UpstreamEndpoint[]> {
const sitePlatform = normalizePlatformName(context.site.platform);
const preferMessagesForClaudeModel = (
isClaudeFamilyModel(modelName)
|| isClaudeFamilyModel(asTrimmedString(requestedModelHint))
const capabilityProfile = buildEndpointCapabilityProfile({
modelName,
requestedModelHint,
requestCapabilities,
});
const preferMessagesForClaudeModel = capabilityProfile.preferMessagesForClaudeModel;
const hasNonImageFileInput = capabilityProfile.hasNonImageFileInput;
const wantsNativeResponsesReasoning = capabilityProfile.wantsNativeResponsesReasoning;
const runtimeStateKey = buildEndpointRuntimeStateKey({
siteId: context.site.id,
downstreamFormat,
capabilityProfile,
});
const applyRuntimePreference = (candidates: UpstreamEndpoint[]) => (
applyEndpointRuntimePreference(candidates, runtimeStateKey)
);
const hasNonImageFileInput = requestCapabilities?.hasNonImageFileInput === true;
const wantsNativeResponsesReasoning = requestCapabilities?.wantsNativeResponsesReasoning === true;
const conversationFileSummary = requestCapabilities?.conversationFileSummary ?? {
hasImage: false,
hasAudio: false,
hasDocument: hasNonImageFileInput,
hasRemoteDocumentUrl: false,
};
if (sitePlatform === 'anyrouter') {
// anyrouter deployments are effectively anthropic-protocol first.
if (hasNonImageFileInput) {
return downstreamFormat === 'responses'
return applyRuntimePreference(downstreamFormat === 'responses'
? ['responses', 'messages', 'chat']
: ['messages', 'responses', 'chat'];
: ['messages', 'responses', 'chat']);
}
if (downstreamFormat === 'responses') {
return ['responses', 'messages', 'chat'];
return applyRuntimePreference(['responses', 'messages', 'chat']);
}
return ['messages', 'chat', 'responses'];
return applyRuntimePreference(['messages', 'chat', 'responses']);
}
const preferred = preferredEndpointOrder(
@@ -659,8 +940,14 @@ export async function resolveUpstreamEndpointCandidates(
if (sitePlatform === 'claude') return ['messages'] as UpstreamEndpoint[];
if (sitePlatform === 'gemini') return ['responses', 'chat'] as UpstreamEndpoint[];
if (sitePlatform === 'gemini-cli' || sitePlatform === 'antigravity') return ['chat'] as UpstreamEndpoint[];
if (preferMessagesForClaudeModel) return ['messages', 'responses', 'chat'] as UpstreamEndpoint[];
return ['responses', 'messages', 'chat'] as UpstreamEndpoint[];
return rankConversationFileEndpoints({
sitePlatform,
requestedOrder: preferMessagesForClaudeModel
? ['messages', 'responses', 'chat']
: ['responses', 'messages', 'chat'],
summary: conversationFileSummary,
preferMessagesForClaudeModel,
});
})()
: preferred;
const prioritizedPreferredEndpoints: UpstreamEndpoint[] = (
@@ -699,20 +986,20 @@ export async function resolveUpstreamEndpointCandidates(
});
if (!catalog || !Array.isArray(catalog.models) || catalog.models.length === 0) {
return prioritizedPreferredEndpoints;
return applyRuntimePreference(prioritizedPreferredEndpoints);
}
const matched = catalog.models.find((item) =>
asTrimmedString(item?.modelName).toLowerCase() === modelName.toLowerCase(),
);
if (!matched) return prioritizedPreferredEndpoints;
if (!matched) return applyRuntimePreference(prioritizedPreferredEndpoints);
const shouldIgnoreCatalogOrderingForClaudeMessages = (
preferMessagesForClaudeModel
&& (downstreamFormat !== 'responses' || sitePlatform !== 'openai')
);
if (shouldIgnoreCatalogOrderingForClaudeMessages) {
return prioritizedPreferredEndpoints;
return applyRuntimePreference(prioritizedPreferredEndpoints);
}
const supportedRaw = Array.isArray(matched.supportedEndpointTypes) ? matched.supportedEndpointTypes : [];
@@ -732,7 +1019,7 @@ export async function resolveUpstreamEndpointCandidates(
if (forceMessagesFirstForClaudeModel && !hasConcreteEndpointHint) {
// Generic labels like openai/anthropic are too coarse for Claude models;
// keep messages-first order in this case.
return prioritizedPreferredEndpoints;
return applyRuntimePreference(prioritizedPreferredEndpoints);
}
const supported = new Set<UpstreamEndpoint>();
@@ -743,19 +1030,19 @@ export async function resolveUpstreamEndpointCandidates(
}
}
if (supported.size === 0) return prioritizedPreferredEndpoints;
if (supported.size === 0) return applyRuntimePreference(prioritizedPreferredEndpoints);
const firstSupported = prioritizedPreferredEndpoints.find((endpoint) => supported.has(endpoint));
if (!firstSupported) return prioritizedPreferredEndpoints;
if (!firstSupported) return applyRuntimePreference(prioritizedPreferredEndpoints);
// Catalog metadata can be incomplete/inaccurate, so only use it to pick
// the first attempt. Keep downstream-driven fallback order unchanged.
return [
return applyRuntimePreference([
firstSupported,
...prioritizedPreferredEndpoints.filter((endpoint) => endpoint !== firstSupported),
];
]);
} catch {
return prioritizedPreferredEndpoints;
return applyRuntimePreference(prioritizedPreferredEndpoints);
}
}
@@ -885,6 +1172,16 @@ export function buildUpstreamEndpointRequest(input: {
stream: input.stream,
oauthProjectId: asTrimmedString(input.oauthProjectId) || null,
};
const requestedModelForPayloadRules = resolveRequestedModelForPayloadRules(input);
const applyConfiguredPayloadRules = <T extends Record<string, unknown>>(body: T): T => (
applyPayloadRules({
rules: config.payloadRules,
payload: body,
modelName: input.modelName,
requestedModel: requestedModelForPayloadRules,
protocol: sitePlatform,
}) as T
);
if (isInternalGeminiUpstream) {
const instructions = (
@@ -898,6 +1195,7 @@ export function buildUpstreamEndpointRequest(input: {
modelName: input.modelName,
instructions,
});
const configuredGeminiRequest = applyConfiguredPayloadRules(geminiRequest);
if (!providerProfile) {
throw new Error(`missing provider profile for platform: ${sitePlatform}`);
}
@@ -911,7 +1209,7 @@ export function buildUpstreamEndpointRequest(input: {
sitePlatform,
baseHeaders: commonHeaders,
providerHeaders: input.providerHeaders,
body: geminiRequest,
body: configuredGeminiRequest,
action: input.stream ? 'streamGenerateContent' : 'generateContent',
});
}
@@ -952,6 +1250,7 @@ export function buildUpstreamEndpointRequest(input: {
?? sanitizeAnthropicMessagesBody(
convertOpenAiBodyToAnthropicMessagesBody(openaiBody, input.modelName, input.stream),
);
const configuredClaudeBody = applyConfiguredPayloadRules(sanitizedBody);
if (providerProfile?.id === 'claude') {
return providerProfile.prepareRequest({
@@ -964,7 +1263,7 @@ export function buildUpstreamEndpointRequest(input: {
sitePlatform,
baseHeaders: commonHeaders,
claudeHeaders,
body: sanitizedBody,
body: configuredClaudeBody,
});
}
@@ -980,7 +1279,7 @@ export function buildUpstreamEndpointRequest(input: {
return {
path: resolveEndpointPath('messages'),
headers,
body: sanitizedBody,
body: configuredClaudeBody,
runtime,
};
}
@@ -1015,6 +1314,7 @@ export function buildUpstreamEndpointRequest(input: {
),
sitePlatform,
);
const configuredResponsesBody = applyConfiguredPayloadRules(body);
if (providerProfile?.id === 'codex') {
return providerProfile.prepareRequest({
@@ -1032,7 +1332,7 @@ export function buildUpstreamEndpointRequest(input: {
providerHeaders: input.providerHeaders,
codexSessionCacheKey: input.codexSessionCacheKey,
codexExplicitSessionId: input.codexExplicitSessionId,
body,
body: configuredResponsesBody,
});
}
@@ -1055,15 +1355,15 @@ export function buildUpstreamEndpointRequest(input: {
: null;
const shouldInjectDerivedPromptCacheKey = sitePlatform === 'codex'
&& !!codexSessionId
&& !asTrimmedString((body as Record<string, unknown>).prompt_cache_key)
&& !asTrimmedString((configuredResponsesBody as Record<string, unknown>).prompt_cache_key)
&& !asTrimmedString(input.codexExplicitSessionId)
&& !!asTrimmedString(input.codexSessionCacheKey);
const runtimeBody = shouldInjectDerivedPromptCacheKey
? {
...body,
...configuredResponsesBody,
prompt_cache_key: codexSessionId,
}
: body;
: configuredResponsesBody;
return {
path: resolveEndpointPath('responses'),
@@ -1077,11 +1377,11 @@ export function buildUpstreamEndpointRequest(input: {
return {
path: resolveEndpointPath('chat'),
headers,
body: {
body: applyConfiguredPayloadRules({
...openaiBody,
model: input.modelName,
stream: input.stream,
},
}),
runtime,
};
}
@@ -1149,5 +1449,3 @@ export function buildClaudeCountTokensUpstreamRequest(input: {
},
};
}
+3 -3
View File
@@ -102,7 +102,7 @@ export async function videosProxyRoute(app: FastifyInstance) {
const upstream = await fetch(targetUrl, requestInit);
const text = await upstream.text();
if (!upstream.ok) {
tokenRouter.recordFailure(selected.channel.id);
tokenRouter.recordFailure(selected.channel.id, selected.actualModel);
if (isTokenExpiredError({ status: upstream.status, message: text })) {
await reportTokenExpired({
accountId: selected.account.id,
@@ -155,11 +155,11 @@ export async function videosProxyRoute(app: FastifyInstance) {
completionTokens: 0,
totalTokens: 0,
});
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost, selected.actualModel);
recordDownstreamCostUsage(request, estimatedCost);
return reply.code(upstream.status).send(rewriteVideoResponsePublicId(data, mapping.publicId));
} catch (error: any) {
tokenRouter.recordFailure(selected.channel.id);
tokenRouter.recordFailure(selected.channel.id, selected.actualModel);
if (retryCount < MAX_RETRIES) {
retryCount += 1;
continue;