forked from boos/BoosAPI
feat(server): add proactive channel probes and load-aware stable routing / 服务端新增主动探活与负载感知稳定路由 (#330)
* wip: preserve local changes before upstream merge * Improve proxy routing stability and add stable-first strategy * Sync explicit-group routing strategy to eligible source routes * Fix proxy retry and runtime health review issues * fix: make proxy channel retry attempts configurable * fix: harden proxy retry recovery and bookkeeping * fix: address pr 257 review comments * feat(proxy): add sticky session channel coordination * feat(server): add proactive channel probes and load-aware stable routing * test(server): stabilize session load routing selection assertion * fix(proxy): harden codex forwarding stability * fix(router): rotate stable-first channels by healthy order * fix(server): address probe review feedback * fix(server): address probe review follow-ups * fix(ci): address CodeQL and core test failures * feat(server): refine stable-first routing recovery and observation * Finish PR 330 settings and review fixes * Fix chat site endpoint CI mock * Address latest PR 330 review comments --------- Co-authored-by: Cita <juricek.chen@gmail.com>
This commit is contained in:
@@ -0,0 +1,106 @@
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
const { startModelAvailabilityProbeSchedulerMock, stopModelAvailabilityProbeSchedulerMock } = vi.hoisted(() => ({
|
||||
startModelAvailabilityProbeSchedulerMock: vi.fn(),
|
||||
stopModelAvailabilityProbeSchedulerMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/modelAvailabilityProbeService.js', () => ({
|
||||
startModelAvailabilityProbeScheduler: startModelAvailabilityProbeSchedulerMock,
|
||||
stopModelAvailabilityProbeScheduler: stopModelAvailabilityProbeSchedulerMock,
|
||||
}));
|
||||
|
||||
type ConfigModule = typeof import('../../config.js');
|
||||
type DbModule = typeof import('../../db/index.js');
|
||||
|
||||
describe('settings model availability probe runtime setting', () => {
|
||||
let app: FastifyInstance;
|
||||
let config: ConfigModule['config'];
|
||||
let db: DbModule['db'];
|
||||
let schema: DbModule['schema'];
|
||||
let dataDir = '';
|
||||
|
||||
beforeAll(async () => {
|
||||
dataDir = mkdtempSync(join(tmpdir(), 'metapi-settings-model-probe-'));
|
||||
process.env.DATA_DIR = dataDir;
|
||||
|
||||
await import('../../db/migrate.js');
|
||||
const dbModule = await import('../../db/index.js');
|
||||
const configModule = await import('../../config.js');
|
||||
const settingsRoutesModule = await import('./settings.js');
|
||||
|
||||
db = dbModule.db;
|
||||
schema = dbModule.schema;
|
||||
config = configModule.config;
|
||||
|
||||
app = Fastify();
|
||||
await app.register(settingsRoutesModule.settingsRoutes);
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await db.delete(schema.settings).run();
|
||||
config.modelAvailabilityProbeEnabled = false;
|
||||
startModelAvailabilityProbeSchedulerMock.mockReset();
|
||||
stopModelAvailabilityProbeSchedulerMock.mockReset();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
rmSync(dataDir, { recursive: true, force: true });
|
||||
delete process.env.DATA_DIR;
|
||||
});
|
||||
|
||||
it('persists enabling the model availability probe and starts the scheduler', async () => {
|
||||
const updateResponse = await app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/settings/runtime',
|
||||
payload: {
|
||||
modelAvailabilityProbeEnabled: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(updateResponse.statusCode).toBe(200);
|
||||
const updated = updateResponse.json() as { modelAvailabilityProbeEnabled?: boolean };
|
||||
expect(updated.modelAvailabilityProbeEnabled).toBe(true);
|
||||
expect(config.modelAvailabilityProbeEnabled).toBe(true);
|
||||
expect(startModelAvailabilityProbeSchedulerMock).toHaveBeenCalledTimes(1);
|
||||
expect(stopModelAvailabilityProbeSchedulerMock).not.toHaveBeenCalled();
|
||||
|
||||
const saved = await db.select().from(schema.settings).where(eq(schema.settings.key, 'model_availability_probe_enabled')).get();
|
||||
expect(saved?.value).toBe(JSON.stringify(true));
|
||||
|
||||
const getResponse = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/settings/runtime',
|
||||
});
|
||||
expect(getResponse.statusCode).toBe(200);
|
||||
expect((getResponse.json() as { modelAvailabilityProbeEnabled?: boolean }).modelAvailabilityProbeEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it('persists disabling the model availability probe and stops the scheduler', async () => {
|
||||
config.modelAvailabilityProbeEnabled = true;
|
||||
|
||||
const updateResponse = await app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/settings/runtime',
|
||||
payload: {
|
||||
modelAvailabilityProbeEnabled: false,
|
||||
},
|
||||
});
|
||||
|
||||
expect(updateResponse.statusCode).toBe(200);
|
||||
const updated = updateResponse.json() as { modelAvailabilityProbeEnabled?: boolean };
|
||||
expect(updated.modelAvailabilityProbeEnabled).toBe(false);
|
||||
expect(config.modelAvailabilityProbeEnabled).toBe(false);
|
||||
expect(stopModelAvailabilityProbeSchedulerMock).toHaveBeenCalledTimes(1);
|
||||
expect(startModelAvailabilityProbeSchedulerMock).not.toHaveBeenCalled();
|
||||
|
||||
const saved = await db.select().from(schema.settings).where(eq(schema.settings.key, 'model_availability_probe_enabled')).get();
|
||||
expect(saved?.value).toBe(JSON.stringify(false));
|
||||
});
|
||||
});
|
||||
@@ -40,12 +40,17 @@ import { invalidateSiteProxyCache, normalizeSiteProxyUrl, withExplicitProxyReque
|
||||
import { performFactoryReset } from '../../services/factoryResetService.js';
|
||||
import { normalizeLogCleanupRetentionDays } from '../../services/logCleanupService.js';
|
||||
import { stopProxyLogRetentionService } from '../../services/proxyLogRetentionService.js';
|
||||
import {
|
||||
startModelAvailabilityProbeScheduler,
|
||||
stopModelAvailabilityProbeScheduler,
|
||||
} from '../../services/modelAvailabilityProbeService.js';
|
||||
|
||||
type RoutingWeights = typeof config.routingWeights;
|
||||
|
||||
interface RuntimeSettingsBody {
|
||||
proxyToken?: string;
|
||||
systemProxyUrl?: string;
|
||||
modelAvailabilityProbeEnabled?: boolean;
|
||||
codexUpstreamWebsocketEnabled?: boolean;
|
||||
disableCrossProtocolFallback?: boolean;
|
||||
proxySessionChannelConcurrencyLimit?: number;
|
||||
@@ -404,6 +409,16 @@ function applyImportedSettingToRuntime(key: string, value: unknown) {
|
||||
config.systemProxyUrl = normalizeSiteProxyUrl(value) || '';
|
||||
return;
|
||||
}
|
||||
case 'model_availability_probe_enabled': {
|
||||
if (typeof value !== 'boolean') return;
|
||||
config.modelAvailabilityProbeEnabled = value;
|
||||
if (value) {
|
||||
startModelAvailabilityProbeScheduler();
|
||||
} else {
|
||||
stopModelAvailabilityProbeScheduler();
|
||||
}
|
||||
return;
|
||||
}
|
||||
case 'codex_upstream_websocket_enabled': {
|
||||
if (typeof value !== 'boolean') return;
|
||||
config.codexUpstreamWebsocketEnabled = value;
|
||||
@@ -687,6 +702,7 @@ function getRuntimeSettingsResponse(currentAdminIp = '') {
|
||||
logCleanupUsageLogsEnabled: config.logCleanupUsageLogsEnabled,
|
||||
logCleanupProgramLogsEnabled: config.logCleanupProgramLogsEnabled,
|
||||
logCleanupRetentionDays: config.logCleanupRetentionDays,
|
||||
modelAvailabilityProbeEnabled: config.modelAvailabilityProbeEnabled,
|
||||
codexUpstreamWebsocketEnabled: config.codexUpstreamWebsocketEnabled,
|
||||
disableCrossProtocolFallback: config.disableCrossProtocolFallback,
|
||||
proxySessionChannelConcurrencyLimit: config.proxySessionChannelConcurrencyLimit,
|
||||
@@ -1101,6 +1117,29 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
invalidateSiteProxyCache();
|
||||
}
|
||||
|
||||
if (body.modelAvailabilityProbeEnabled !== undefined) {
|
||||
let nextValue = false;
|
||||
try {
|
||||
nextValue = parseBooleanFlag(body.modelAvailabilityProbeEnabled, '批量测活开关');
|
||||
} catch (err: any) {
|
||||
return reply.code(400).send({
|
||||
success: false,
|
||||
message: err?.message || '批量测活开关格式无效',
|
||||
});
|
||||
}
|
||||
|
||||
if (nextValue !== config.modelAvailabilityProbeEnabled) {
|
||||
changedLabels.push(nextValue ? '开启批量测活' : '关闭批量测活');
|
||||
}
|
||||
await upsertSetting('model_availability_probe_enabled', nextValue);
|
||||
config.modelAvailabilityProbeEnabled = nextValue;
|
||||
if (nextValue) {
|
||||
startModelAvailabilityProbeScheduler();
|
||||
} else {
|
||||
stopModelAvailabilityProbeScheduler();
|
||||
}
|
||||
}
|
||||
|
||||
if (body.codexUpstreamWebsocketEnabled !== undefined) {
|
||||
let nextValue = false;
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { mkdtempSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
const buildModelAvailabilityProbeTaskDedupeKeyMock = vi.fn();
|
||||
const queueModelAvailabilityProbeTaskMock = vi.fn();
|
||||
const getBackgroundTaskMock = vi.fn();
|
||||
const getRunningTaskByDedupeKeyMock = vi.fn();
|
||||
const waitForBackgroundTaskCompletionMock = vi.fn();
|
||||
|
||||
vi.mock('../../services/modelAvailabilityProbeService.js', async () => {
|
||||
const actual = await vi.importActual<typeof import('../../services/modelAvailabilityProbeService.js')>('../../services/modelAvailabilityProbeService.js');
|
||||
return {
|
||||
...actual,
|
||||
buildModelAvailabilityProbeTaskDedupeKey: (...args: unknown[]) => buildModelAvailabilityProbeTaskDedupeKeyMock(...args),
|
||||
queueModelAvailabilityProbeTask: (...args: unknown[]) => queueModelAvailabilityProbeTaskMock(...args),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../services/backgroundTaskService.js', async () => {
|
||||
const actual = await vi.importActual<typeof import('../../services/backgroundTaskService.js')>('../../services/backgroundTaskService.js');
|
||||
return {
|
||||
...actual,
|
||||
getBackgroundTask: (...args: unknown[]) => getBackgroundTaskMock(...args),
|
||||
getRunningTaskByDedupeKey: (...args: unknown[]) => getRunningTaskByDedupeKeyMock(...args),
|
||||
waitForBackgroundTaskCompletion: (...args: unknown[]) => waitForBackgroundTaskCompletionMock(...args),
|
||||
};
|
||||
});
|
||||
|
||||
describe('/api/models/probe', () => {
|
||||
let app: FastifyInstance;
|
||||
let dataDir = '';
|
||||
let originalDataDir: string | undefined;
|
||||
|
||||
beforeAll(async () => {
|
||||
dataDir = mkdtempSync(join(tmpdir(), 'metapi-stats-model-probe-'));
|
||||
originalDataDir = process.env.DATA_DIR;
|
||||
process.env.DATA_DIR = dataDir;
|
||||
|
||||
await import('../../db/migrate.js');
|
||||
const routesModule = await import('./stats.js');
|
||||
app = Fastify();
|
||||
await app.register(routesModule.statsRoutes);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
buildModelAvailabilityProbeTaskDedupeKeyMock.mockReset();
|
||||
queueModelAvailabilityProbeTaskMock.mockReset();
|
||||
getBackgroundTaskMock.mockReset();
|
||||
getRunningTaskByDedupeKeyMock.mockReset();
|
||||
waitForBackgroundTaskCompletionMock.mockReset();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
if (originalDataDir === undefined) {
|
||||
delete process.env.DATA_DIR;
|
||||
} else {
|
||||
process.env.DATA_DIR = originalDataDir;
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects non-object request bodies instead of defaulting to a full probe', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/models/probe',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
payload: '"oops"',
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(queueModelAvailabilityProbeTaskMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects loosely formatted account ids instead of truncating them', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/models/probe',
|
||||
payload: {
|
||||
accountId: '1e2',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(queueModelAvailabilityProbeTaskMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects account ids that exceed the safe integer range', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/models/probe',
|
||||
payload: {
|
||||
accountId: '9007199254740993',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(queueModelAvailabilityProbeTaskMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reuses the running deduped task for wait=true requests', async () => {
|
||||
buildModelAvailabilityProbeTaskDedupeKeyMock.mockReturnValue('model-availability-probe-7');
|
||||
getRunningTaskByDedupeKeyMock.mockReturnValue({
|
||||
id: 'task-7',
|
||||
status: 'running',
|
||||
});
|
||||
const completedTask = {
|
||||
id: 'task-7',
|
||||
status: 'succeeded',
|
||||
result: {
|
||||
results: [],
|
||||
summary: {
|
||||
totalAccounts: 1,
|
||||
success: 1,
|
||||
failed: 0,
|
||||
skipped: 0,
|
||||
scanned: 3,
|
||||
supported: 2,
|
||||
unsupported: 1,
|
||||
inconclusive: 0,
|
||||
skippedModels: 0,
|
||||
updatedRows: 1,
|
||||
rebuiltRoutes: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
waitForBackgroundTaskCompletionMock.mockResolvedValue(completedTask);
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/models/probe',
|
||||
payload: {
|
||||
accountId: 7,
|
||||
wait: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(queueModelAvailabilityProbeTaskMock).not.toHaveBeenCalled();
|
||||
expect(waitForBackgroundTaskCompletionMock).toHaveBeenCalledWith('task-7');
|
||||
expect(response.json()).toMatchObject({
|
||||
success: true,
|
||||
reused: true,
|
||||
jobId: 'task-7',
|
||||
summary: {
|
||||
totalAccounts: 1,
|
||||
scanned: 3,
|
||||
supported: 2,
|
||||
unsupported: 1,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -6,8 +6,18 @@ import { refreshModelsForAccount } from '../../services/modelService.js';
|
||||
import * as routeRefreshWorkflow from '../../services/routeRefreshWorkflow.js';
|
||||
import { buildModelAnalysis } from '../../services/modelAnalysisService.js';
|
||||
import { fallbackTokenCost, fetchModelPricingCatalog } from '../../services/modelPricingService.js';
|
||||
import {
|
||||
buildModelAvailabilityProbeTaskDedupeKey,
|
||||
queueModelAvailabilityProbeTask,
|
||||
type ModelAvailabilityProbeExecutionResult,
|
||||
} from '../../services/modelAvailabilityProbeService.js';
|
||||
import { getUpstreamModelDescriptionsCached } from '../../services/upstreamModelDescriptionService.js';
|
||||
import { getRunningTaskByDedupeKey, startBackgroundTask } from '../../services/backgroundTaskService.js';
|
||||
import {
|
||||
getBackgroundTask,
|
||||
getRunningTaskByDedupeKey,
|
||||
startBackgroundTask,
|
||||
waitForBackgroundTaskCompletion,
|
||||
} from '../../services/backgroundTaskService.js';
|
||||
import { parseCheckinRewardAmount } from '../../services/checkinRewardParser.js';
|
||||
import { estimateRewardWithTodayIncomeFallback } from '../../services/todayIncomeRewardService.js';
|
||||
import {
|
||||
@@ -39,6 +49,10 @@ function parseBooleanFlag(raw?: string): boolean {
|
||||
return normalized === '1' || normalized === 'true' || normalized === 'yes';
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
const MODELS_MARKETPLACE_BASE_TTL_MS = 15_000;
|
||||
const MODELS_MARKETPLACE_PRICING_TTL_MS = 90_000;
|
||||
const limitModelTokenCandidatesRead = createRateLimitGuard({
|
||||
@@ -1462,6 +1476,92 @@ export async function statsRoutes(app: FastifyInstance) {
|
||||
return { success: true, refresh, rebuild };
|
||||
});
|
||||
|
||||
app.post<{ Body?: { accountId?: number; wait?: boolean } }>('/api/models/probe', async (request, reply) => {
|
||||
const requestBody = request.body;
|
||||
if (requestBody !== undefined && !isRecord(requestBody)) {
|
||||
return reply.code(400).send({ success: false, message: '请求体必须是对象' });
|
||||
}
|
||||
|
||||
const rawAccountId = requestBody?.accountId as unknown;
|
||||
const normalizedAccountId = rawAccountId === undefined || rawAccountId === null
|
||||
? ''
|
||||
: String(rawAccountId).trim();
|
||||
const hasAccountId = normalizedAccountId !== '';
|
||||
const parsedAccountId = hasAccountId && /^[1-9]\d*$/.test(normalizedAccountId)
|
||||
? Number(normalizedAccountId)
|
||||
: undefined;
|
||||
const accountId = parsedAccountId !== undefined && Number.isSafeInteger(parsedAccountId)
|
||||
? parsedAccountId
|
||||
: undefined;
|
||||
const wait = requestBody?.wait === true;
|
||||
|
||||
if (hasAccountId && accountId === undefined) {
|
||||
return reply.code(400).send({ success: false, message: '账号 ID 无效' });
|
||||
}
|
||||
|
||||
if (wait) {
|
||||
const taskTitle = accountId ? `探测模型可用性 #${accountId}` : '探测全部模型可用性';
|
||||
const dedupeKey = buildModelAvailabilityProbeTaskDedupeKey(accountId);
|
||||
const runningTask = getRunningTaskByDedupeKey(dedupeKey);
|
||||
const { task, reused } = runningTask
|
||||
? { task: runningTask, reused: true }
|
||||
: queueModelAvailabilityProbeTask({
|
||||
accountId,
|
||||
title: taskTitle,
|
||||
});
|
||||
const completedTask = await waitForBackgroundTaskCompletion(task.id);
|
||||
if (!completedTask) {
|
||||
return reply.code(500).send({ success: false, message: '模型可用性探测任务不存在或已过期' });
|
||||
}
|
||||
if (completedTask.status === 'failed') {
|
||||
return reply.code(500).send({
|
||||
success: false,
|
||||
reused,
|
||||
jobId: completedTask.id,
|
||||
status: completedTask.status,
|
||||
message: completedTask.error || '模型可用性探测失败',
|
||||
});
|
||||
}
|
||||
const result = completedTask.result as ModelAvailabilityProbeExecutionResult | null;
|
||||
if (!result) {
|
||||
return reply.code(500).send({
|
||||
success: false,
|
||||
reused,
|
||||
jobId: completedTask.id,
|
||||
status: completedTask.status,
|
||||
message: '模型可用性探测结果为空',
|
||||
});
|
||||
}
|
||||
if (accountId && result.summary.totalAccounts === 0) {
|
||||
return reply.code(404).send({ success: false, message: '账号不存在' });
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
reused,
|
||||
jobId: completedTask.id,
|
||||
status: completedTask.status,
|
||||
...result,
|
||||
};
|
||||
}
|
||||
|
||||
const taskTitle = accountId ? `探测模型可用性 #${accountId}` : '探测全部模型可用性';
|
||||
const { task, reused } = queueModelAvailabilityProbeTask({
|
||||
accountId,
|
||||
title: taskTitle,
|
||||
});
|
||||
|
||||
return reply.code(202).send({
|
||||
success: true,
|
||||
queued: true,
|
||||
reused,
|
||||
jobId: task.id,
|
||||
status: task.status,
|
||||
message: reused
|
||||
? '模型可用性探测任务进行中,请稍后查看任务列表'
|
||||
: '已开始模型可用性探测,请稍后查看任务列表',
|
||||
});
|
||||
});
|
||||
|
||||
// Site distribution – per-site aggregate data
|
||||
app.get('/api/stats/site-distribution', async () => {
|
||||
const accountRows = await db.select({
|
||||
|
||||
@@ -104,6 +104,13 @@ describe('proxy route architecture boundaries', () => {
|
||||
expect(source).not.toContain('export function getUpstreamEndpointRuntimeStateSnapshot(');
|
||||
});
|
||||
|
||||
it('keeps endpoint flow orchestration owned by proxy-core instead of the route layer', () => {
|
||||
const source = readSource('./endpointFlow.ts');
|
||||
expect(source).toContain("from '../../proxy-core/orchestration/endpointFlow.js'");
|
||||
expect(source).not.toContain('async function runEndpointFlowHook<');
|
||||
expect(source).not.toContain('export async function executeEndpointFlow(');
|
||||
});
|
||||
|
||||
it('keeps gemini runtime closure in transformer-owned helpers', () => {
|
||||
const source = readSource('./gemini.ts');
|
||||
const surfaceSource = readSource('../../proxy-core/surfaces/geminiSurface.ts');
|
||||
|
||||
@@ -55,6 +55,7 @@ vi.mock('../../services/modelPricingService.js', () => ({
|
||||
|
||||
vi.mock('../../services/proxyRetryPolicy.js', () => ({
|
||||
shouldRetryProxyRequest: () => false,
|
||||
shouldAbortSameSiteEndpointFallback: () => false,
|
||||
RETRYABLE_TIMEOUT_PATTERNS: [/(request timed out|connection timed out|read timeout|\btimed out\b)/i],
|
||||
}));
|
||||
|
||||
|
||||
@@ -59,6 +59,7 @@ vi.mock('../../services/modelPricingService.js', () => ({
|
||||
|
||||
vi.mock('../../services/proxyRetryPolicy.js', () => ({
|
||||
shouldRetryProxyRequest: () => false,
|
||||
shouldAbortSameSiteEndpointFallback: () => false,
|
||||
RETRYABLE_TIMEOUT_PATTERNS: [/(request timed out|connection timed out|read timeout|\btimed out\b)/i],
|
||||
}));
|
||||
|
||||
|
||||
@@ -59,6 +59,7 @@ vi.mock('../../services/modelPricingService.js', () => ({
|
||||
|
||||
vi.mock('../../services/proxyRetryPolicy.js', () => ({
|
||||
shouldRetryProxyRequest: () => false,
|
||||
shouldAbortSameSiteEndpointFallback: () => false,
|
||||
RETRYABLE_TIMEOUT_PATTERNS: [/(request timed out|connection timed out|read timeout|\btimed out\b)/i],
|
||||
}));
|
||||
|
||||
|
||||
@@ -60,6 +60,7 @@ vi.mock('../../services/modelPricingService.js', () => ({
|
||||
|
||||
vi.mock('../../services/proxyRetryPolicy.js', () => ({
|
||||
shouldRetryProxyRequest: () => false,
|
||||
shouldAbortSameSiteEndpointFallback: () => false,
|
||||
RETRYABLE_TIMEOUT_PATTERNS: [/(request timed out|connection timed out|read timeout|\btimed out\b)/i],
|
||||
}));
|
||||
|
||||
@@ -817,6 +818,122 @@ describe('chat proxy stream behavior', () => {
|
||||
expect(response.body).toContain('event: message_stop');
|
||||
});
|
||||
|
||||
it('normalizes null Claude message content before proxying on /v1/messages', async () => {
|
||||
selectChannelMock.mockReturnValue({
|
||||
channel: { id: 11, routeId: 22 },
|
||||
site: {
|
||||
name: 'claude-site',
|
||||
url: 'https://upstream.example.com',
|
||||
platform: 'claude',
|
||||
},
|
||||
account: { id: 33, username: 'demo-user' },
|
||||
tokenName: 'default',
|
||||
tokenValue: 'sk-demo',
|
||||
actualModel: 'claude-opus-4-6',
|
||||
});
|
||||
fetchMock.mockResolvedValue(new Response(JSON.stringify({
|
||||
id: 'msg_1',
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
model: 'claude-opus-4-6',
|
||||
content: [{ type: 'text', text: 'ok' }],
|
||||
stop_reason: 'end_turn',
|
||||
usage: { input_tokens: 1, output_tokens: 1 },
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}));
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/v1/messages',
|
||||
payload: {
|
||||
model: 'claude-opus-4-6',
|
||||
max_tokens: 256,
|
||||
messages: [
|
||||
{ role: 'user', content: 'hello' },
|
||||
{ role: 'assistant', content: null },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const forwardedBody = JSON.parse(fetchMock.mock.calls[0]?.[1]?.body as string);
|
||||
expect(forwardedBody.messages).toEqual([
|
||||
{ role: 'user', content: 'hello' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('prefers responses for Claude tool_result follow-ups that include continuation hints', async () => {
|
||||
selectChannelMock.mockReturnValue({
|
||||
channel: { id: 11, routeId: 22 },
|
||||
site: {
|
||||
name: 'openai-site',
|
||||
url: 'https://upstream.example.com',
|
||||
platform: 'openai',
|
||||
},
|
||||
account: { id: 33, username: 'demo-user' },
|
||||
tokenName: 'default',
|
||||
tokenValue: 'sk-demo',
|
||||
actualModel: 'gpt-5.4',
|
||||
});
|
||||
fetchMock.mockResolvedValue(new Response(JSON.stringify({
|
||||
id: 'resp_1',
|
||||
object: 'response',
|
||||
model: 'gpt-5.4',
|
||||
created_at: 1_706_000_000,
|
||||
status: 'completed',
|
||||
output: [{
|
||||
id: 'msg_1',
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
status: 'completed',
|
||||
content: [{ type: 'output_text', text: 'done' }],
|
||||
}],
|
||||
usage: {
|
||||
input_tokens: 5,
|
||||
output_tokens: 1,
|
||||
total_tokens: 6,
|
||||
},
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}));
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/v1/messages',
|
||||
payload: {
|
||||
model: 'claude-opus-4-6',
|
||||
max_tokens: 256,
|
||||
previous_response_id: 'resp_prev_1',
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_result',
|
||||
tool_use_id: 'toolu_missing',
|
||||
content: [{ type: 'text', text: '{"matches":1}' }],
|
||||
},
|
||||
{ type: 'text', text: 'continue' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(String(fetchMock.mock.calls[0]?.[0])).toContain('/v1/responses');
|
||||
const forwardedBody = JSON.parse(fetchMock.mock.calls[0]?.[1]?.body as string);
|
||||
expect(forwardedBody.previous_response_id).toBe('resp_prev_1');
|
||||
expect(forwardedBody.input[0]).toEqual({
|
||||
type: 'function_call_output',
|
||||
call_id: 'toolu_missing',
|
||||
output: '{"matches":1}',
|
||||
});
|
||||
});
|
||||
|
||||
it('converts OpenAI tool_calls SSE into Claude tool_use stream events on /v1/messages', async () => {
|
||||
const encoder = new TextEncoder();
|
||||
const upstreamBody = new ReadableStream<Uint8Array>({
|
||||
|
||||
@@ -58,6 +58,7 @@ vi.mock('../../services/modelPricingService.js', () => ({
|
||||
|
||||
vi.mock('../../services/proxyRetryPolicy.js', () => ({
|
||||
shouldRetryProxyRequest: () => false,
|
||||
shouldAbortSameSiteEndpointFallback: () => false,
|
||||
RETRYABLE_TIMEOUT_PATTERNS: [/(request timed out|connection timed out|read timeout|\btimed out\b)/i],
|
||||
}));
|
||||
|
||||
|
||||
@@ -224,6 +224,37 @@ describe('executeEndpointFlow', () => {
|
||||
expect(onAttemptSuccess.mock.calls[0]?.[0]?.request?.path).toBe('/v1/chat/completions');
|
||||
});
|
||||
|
||||
it('stops same-site endpoint fallback when the failure is classified as a site outage', async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(toUndiciResponse(new Response(JSON.stringify({
|
||||
error: { message: 'Service temporarily unavailable', type: 'upstream_error' },
|
||||
}), {
|
||||
status: 503,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
})))
|
||||
.mockResolvedValueOnce(toUndiciResponse(new Response(JSON.stringify({ ok: true }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
})));
|
||||
|
||||
const result = await executeEndpointFlow({
|
||||
siteUrl: 'https://example.com',
|
||||
endpointCandidates: ['responses', 'chat'],
|
||||
buildRequest: (endpoint) => endpoint === 'responses'
|
||||
? requestFor('/v1/responses')
|
||||
: { ...requestFor('/v1/chat/completions'), endpoint },
|
||||
shouldAbortRemainingEndpoints: () => true,
|
||||
shouldDowngrade: () => true,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.status).toBe(503);
|
||||
expect(result.errText).toContain('Service temporarily unavailable');
|
||||
}
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('accepts recovered response from tryRecover hook', async () => {
|
||||
fetchMock.mockResolvedValueOnce(toUndiciResponse(new Response(JSON.stringify({
|
||||
error: { message: 'upstream_error', type: 'upstream_error' },
|
||||
|
||||
@@ -1,220 +1,13 @@
|
||||
import { fetch } from 'undici';
|
||||
import { readRuntimeResponseText } from '../../proxy-core/executors/types.js';
|
||||
import { withSiteProxyRequestInit } from '../../services/siteProxy.js';
|
||||
import { summarizeUpstreamError } from './upstreamError.js';
|
||||
import type { UpstreamEndpoint } from './upstreamEndpoint.js';
|
||||
import { buildUpstreamUrl } from './upstreamUrl.js';
|
||||
export {
|
||||
executeEndpointFlow,
|
||||
withUpstreamPath,
|
||||
} from '../../proxy-core/orchestration/endpointFlow.js';
|
||||
|
||||
export type BuiltEndpointRequest = {
|
||||
endpoint: UpstreamEndpoint;
|
||||
path: string;
|
||||
headers: Record<string, string>;
|
||||
body: Record<string, unknown>;
|
||||
runtime?: {
|
||||
executor: 'default' | 'codex' | 'gemini-cli' | 'antigravity' | 'claude';
|
||||
modelName?: string;
|
||||
stream?: boolean;
|
||||
oauthProjectId?: string | null;
|
||||
action?: 'generateContent' | 'streamGenerateContent' | 'countTokens';
|
||||
};
|
||||
};
|
||||
|
||||
export type EndpointAttemptContext = {
|
||||
endpointIndex: number;
|
||||
endpointCount: number;
|
||||
request: BuiltEndpointRequest;
|
||||
targetUrl: string;
|
||||
response: Awaited<ReturnType<typeof fetch>>;
|
||||
rawErrText: string;
|
||||
recoverApplied?: boolean;
|
||||
};
|
||||
|
||||
export type EndpointAttemptSuccessContext = {
|
||||
endpointIndex: number;
|
||||
endpointCount: number;
|
||||
request: BuiltEndpointRequest;
|
||||
targetUrl: string;
|
||||
response: Awaited<ReturnType<typeof fetch>>;
|
||||
recoverApplied?: boolean;
|
||||
};
|
||||
|
||||
export type EndpointRecoverResult = {
|
||||
upstream: Awaited<ReturnType<typeof fetch>>;
|
||||
upstreamPath: string;
|
||||
request?: BuiltEndpointRequest;
|
||||
targetUrl?: string;
|
||||
} | null;
|
||||
|
||||
export type EndpointFlowResult =
|
||||
| {
|
||||
ok: true;
|
||||
upstream: Awaited<ReturnType<typeof fetch>>;
|
||||
upstreamPath: string;
|
||||
}
|
||||
| {
|
||||
ok: false;
|
||||
status: number;
|
||||
errText: string;
|
||||
rawErrText?: string;
|
||||
};
|
||||
|
||||
export function withUpstreamPath(path: string, message: string): string {
|
||||
return `[upstream:${path}] ${message}`;
|
||||
}
|
||||
|
||||
type ExecuteEndpointFlowInput = {
|
||||
siteUrl: string;
|
||||
proxyUrl?: string | null;
|
||||
disableCrossProtocolFallback?: boolean;
|
||||
endpointCandidates: UpstreamEndpoint[];
|
||||
buildRequest: (endpoint: UpstreamEndpoint, endpointIndex: number) => BuiltEndpointRequest;
|
||||
dispatchRequest?: (
|
||||
request: BuiltEndpointRequest,
|
||||
targetUrl: string,
|
||||
) => Promise<Awaited<ReturnType<typeof fetch>>>;
|
||||
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>;
|
||||
};
|
||||
|
||||
async function runEndpointFlowHook<T>(
|
||||
hook: ((ctx: T) => void | Promise<void>) | undefined,
|
||||
ctx: T,
|
||||
hookName: string,
|
||||
): Promise<void> {
|
||||
if (!hook) return;
|
||||
try {
|
||||
await hook(ctx);
|
||||
} catch (error) {
|
||||
console.error(`endpointFlow ${hookName} hook failed`, error);
|
||||
}
|
||||
}
|
||||
export async function executeEndpointFlow(input: ExecuteEndpointFlowInput): Promise<EndpointFlowResult> {
|
||||
const endpointCount = input.endpointCandidates.length;
|
||||
if (endpointCount <= 0) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 502,
|
||||
errText: 'Upstream request failed',
|
||||
};
|
||||
}
|
||||
|
||||
let finalStatus = 0;
|
||||
let finalErrText = 'unknown error';
|
||||
let finalRawErrText: string | undefined;
|
||||
|
||||
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
|
||||
? buildUpstreamUrl(input.proxyUrl, request.path)
|
||||
: defaultTarget;
|
||||
|
||||
let response = input.dispatchRequest
|
||||
? await input.dispatchRequest(request, targetUrl)
|
||||
: await fetch(targetUrl, await withSiteProxyRequestInit(targetUrl, {
|
||||
method: 'POST',
|
||||
headers: request.headers,
|
||||
body: JSON.stringify(request.body),
|
||||
}));
|
||||
|
||||
if (response.ok) {
|
||||
await runEndpointFlowHook(input.onAttemptSuccess, {
|
||||
endpointIndex,
|
||||
endpointCount,
|
||||
request,
|
||||
targetUrl,
|
||||
response,
|
||||
recoverApplied: false,
|
||||
}, 'onAttemptSuccess');
|
||||
return {
|
||||
ok: true,
|
||||
upstream: response,
|
||||
upstreamPath: request.path,
|
||||
};
|
||||
}
|
||||
|
||||
let rawErrText = await readRuntimeResponseText(response).catch(() => 'unknown error');
|
||||
const baseContext: EndpointAttemptContext = {
|
||||
endpointIndex,
|
||||
endpointCount,
|
||||
request,
|
||||
targetUrl,
|
||||
response,
|
||||
rawErrText,
|
||||
recoverApplied: false,
|
||||
};
|
||||
|
||||
if (input.tryRecover) {
|
||||
const recovered = await input.tryRecover(baseContext);
|
||||
baseContext.recoverApplied = recovered !== null
|
||||
|| baseContext.request !== request
|
||||
|| baseContext.response !== response
|
||||
|| baseContext.rawErrText !== rawErrText;
|
||||
if (recovered?.upstream?.ok) {
|
||||
const recoveredRequest = recovered.request ?? baseContext.request;
|
||||
const recoveredTargetUrl = recovered.targetUrl ?? (
|
||||
input.proxyUrl
|
||||
? buildUpstreamUrl(input.proxyUrl, recovered.upstreamPath)
|
||||
: buildUpstreamUrl(input.siteUrl, recovered.upstreamPath)
|
||||
);
|
||||
await runEndpointFlowHook(input.onAttemptSuccess, {
|
||||
endpointIndex,
|
||||
endpointCount,
|
||||
request: recoveredRequest,
|
||||
targetUrl: recoveredTargetUrl,
|
||||
response: recovered.upstream,
|
||||
recoverApplied: true,
|
||||
}, 'onAttemptSuccess');
|
||||
return {
|
||||
ok: true,
|
||||
upstream: recovered.upstream,
|
||||
upstreamPath: recovered.upstreamPath,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize again in case recoverer performed additional probes and updated the response text.
|
||||
rawErrText = baseContext.rawErrText;
|
||||
response = baseContext.response;
|
||||
const errText = withUpstreamPath(
|
||||
baseContext.request.path,
|
||||
summarizeUpstreamError(response.status, rawErrText),
|
||||
);
|
||||
await runEndpointFlowHook(input.onAttemptFailure, {
|
||||
...baseContext,
|
||||
errText,
|
||||
}, 'onAttemptFailure');
|
||||
|
||||
const isLastEndpoint = endpointIndex >= endpointCount - 1;
|
||||
if (input.disableCrossProtocolFallback && !isLastEndpoint) {
|
||||
finalStatus = response.status;
|
||||
finalErrText = errText;
|
||||
finalRawErrText = rawErrText;
|
||||
break;
|
||||
}
|
||||
const shouldDowngrade = !isLastEndpoint && !!input.shouldDowngrade?.(baseContext);
|
||||
if (shouldDowngrade) {
|
||||
await runEndpointFlowHook(input.onDowngrade, {
|
||||
...baseContext,
|
||||
errText,
|
||||
}, 'onDowngrade');
|
||||
continue;
|
||||
}
|
||||
|
||||
finalStatus = response.status;
|
||||
finalErrText = errText;
|
||||
finalRawErrText = rawErrText;
|
||||
break;
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
status: finalStatus || 502,
|
||||
errText: finalErrText || 'unknown error',
|
||||
rawErrText: finalRawErrText,
|
||||
};
|
||||
}
|
||||
export type {
|
||||
BuiltEndpointRequest,
|
||||
EndpointAttemptContext,
|
||||
EndpointAttemptSuccessContext,
|
||||
EndpointRecoverResult,
|
||||
EndpointFlowResult,
|
||||
ExecuteEndpointFlowInput,
|
||||
} from '../../proxy-core/orchestration/endpointFlow.js';
|
||||
|
||||
@@ -52,6 +52,7 @@ vi.mock('../../services/modelPricingService.js', () => ({
|
||||
|
||||
vi.mock('../../services/proxyRetryPolicy.js', () => ({
|
||||
shouldRetryProxyRequest: () => false,
|
||||
shouldAbortSameSiteEndpointFallback: () => false,
|
||||
RETRYABLE_TIMEOUT_PATTERNS: [/(request timed out|connection timed out|read timeout|\btimed out\b)/i],
|
||||
}));
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { config } from '../../config.js';
|
||||
import { resetCodexHttpSessionQueue } from '../../proxy-core/runtime/codexHttpSessionQueue.js';
|
||||
import { resetCodexSessionResponseStore } from '../../proxy-core/runtime/codexSessionResponseStore.js';
|
||||
|
||||
const fetchMock = vi.fn();
|
||||
const selectChannelMock = vi.fn();
|
||||
@@ -69,6 +70,7 @@ vi.mock('../../services/modelPricingService.js', () => ({
|
||||
|
||||
vi.mock('../../services/proxyRetryPolicy.js', () => ({
|
||||
shouldRetryProxyRequest: () => false,
|
||||
shouldAbortSameSiteEndpointFallback: () => false,
|
||||
RETRYABLE_TIMEOUT_PATTERNS: [/(request timed out|connection timed out|read timeout|\btimed out\b)/i],
|
||||
}));
|
||||
|
||||
@@ -174,6 +176,7 @@ describe('responses proxy codex oauth refresh', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
resetCodexHttpSessionQueue();
|
||||
resetCodexSessionResponseStore();
|
||||
config.proxyEmptyContentFailEnabled = false;
|
||||
config.proxyStickySessionEnabled = originalProxyStickySessionEnabled;
|
||||
config.proxySessionChannelConcurrencyLimit = originalProxySessionChannelConcurrencyLimit;
|
||||
@@ -215,6 +218,7 @@ describe('responses proxy codex oauth refresh', () => {
|
||||
});
|
||||
selectPreferredChannelMock.mockReturnValue(null);
|
||||
selectNextChannelMock.mockReturnValue(null);
|
||||
selectPreferredChannelMock.mockReturnValue(null);
|
||||
refreshOauthAccessTokenSingleflightMock.mockResolvedValue({
|
||||
accessToken: 'fresh-access-token',
|
||||
accountId: 33,
|
||||
@@ -488,6 +492,137 @@ describe('responses proxy codex oauth refresh', () => {
|
||||
expect(forwardedBody.prompt_cache_key).toBe('codex-cache-123');
|
||||
});
|
||||
|
||||
it('infers previous_response_id for codex tool-output follow-up turns on the same downstream session', async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({
|
||||
id: 'resp_codex_prev_1',
|
||||
object: 'response',
|
||||
model: 'gpt-5.2-codex',
|
||||
status: 'completed',
|
||||
output_text: 'tool call issued',
|
||||
usage: { input_tokens: 4, output_tokens: 2, total_tokens: 6 },
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}))
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({
|
||||
id: 'resp_codex_prev_2',
|
||||
object: 'response',
|
||||
model: 'gpt-5.2-codex',
|
||||
status: 'completed',
|
||||
output_text: 'tool result accepted',
|
||||
usage: { input_tokens: 5, output_tokens: 2, total_tokens: 7 },
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}));
|
||||
|
||||
const headers = {
|
||||
session_id: 'session-http-prev-1',
|
||||
'user-agent': 'CodexClient/1.0',
|
||||
};
|
||||
|
||||
const firstResponse = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/v1/responses',
|
||||
headers,
|
||||
payload: {
|
||||
model: 'gpt-5.2-codex',
|
||||
input: 'start codex tool flow',
|
||||
},
|
||||
});
|
||||
expect(firstResponse.statusCode).toBe(200);
|
||||
|
||||
const secondResponse = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/v1/responses',
|
||||
headers,
|
||||
payload: {
|
||||
model: 'gpt-5.2-codex',
|
||||
input: [
|
||||
{
|
||||
id: 'tool_out_1',
|
||||
type: 'function_call_output',
|
||||
call_id: 'call_1',
|
||||
output: '{"ok":true}',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(secondResponse.statusCode).toBe(200);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
|
||||
const [, secondOptions] = fetchMock.mock.calls[1] as [string, any];
|
||||
const secondBody = JSON.parse(secondOptions.body);
|
||||
expect(secondBody.previous_response_id).toBe('resp_codex_prev_1');
|
||||
expect(secondBody.input).toEqual([
|
||||
{
|
||||
id: 'tool_out_1',
|
||||
type: 'function_call_output',
|
||||
call_id: 'call_1',
|
||||
output: '{"ok":true}',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('drops stale previous_response_id and retries codex responses requests once', async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({
|
||||
error: {
|
||||
message: 'previous_response_not_found',
|
||||
code: 'previous_response_not_found',
|
||||
type: 'invalid_request_error',
|
||||
},
|
||||
}), {
|
||||
status: 400,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}))
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({
|
||||
id: 'resp_codex_prev_recovered',
|
||||
object: 'response',
|
||||
model: 'gpt-5.2-codex',
|
||||
status: 'completed',
|
||||
output_text: 'recovered after dropping stale previous_response_id',
|
||||
usage: { input_tokens: 5, output_tokens: 2, total_tokens: 7 },
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}));
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/v1/responses',
|
||||
headers: {
|
||||
session_id: 'session-http-prev-recovery',
|
||||
},
|
||||
payload: {
|
||||
model: 'gpt-5.2-codex',
|
||||
previous_response_id: 'resp_stale',
|
||||
input: [
|
||||
{
|
||||
id: 'tool_out_retry_1',
|
||||
type: 'function_call_output',
|
||||
call_id: 'call_retry_1',
|
||||
output: '{"retry":true}',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
|
||||
const [, firstOptions] = fetchMock.mock.calls[0] as [string, any];
|
||||
const [, secondOptions] = fetchMock.mock.calls[1] as [string, any];
|
||||
const firstBody = JSON.parse(firstOptions.body);
|
||||
const secondBody = JSON.parse(secondOptions.body);
|
||||
|
||||
expect(firstBody.previous_response_id).toBe('resp_stale');
|
||||
expect(secondBody.previous_response_id).toBeUndefined();
|
||||
expect(secondBody.input).toEqual(firstBody.input);
|
||||
});
|
||||
|
||||
it('strips generic downstream headers before forwarding codex responses upstream', async () => {
|
||||
fetchMock.mockResolvedValue(new Response(JSON.stringify({
|
||||
id: 'resp_codex_header_filter',
|
||||
|
||||
@@ -57,6 +57,7 @@ vi.mock('../../services/modelPricingService.js', () => ({
|
||||
|
||||
vi.mock('../../services/proxyRetryPolicy.js', () => ({
|
||||
shouldRetryProxyRequest: () => false,
|
||||
shouldAbortSameSiteEndpointFallback: () => false,
|
||||
RETRYABLE_TIMEOUT_PATTERNS: [/(request timed out|connection timed out|read timeout|\btimed out\b)/i],
|
||||
}));
|
||||
|
||||
|
||||
@@ -2,12 +2,14 @@ import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import { createServer, type Server } from 'node:http';
|
||||
import { AddressInfo } from 'node:net';
|
||||
import WebSocket, { WebSocketServer } from 'ws';
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { config } from '../../config.js';
|
||||
import { resetCodexSessionResponseStore } from '../../proxy-core/runtime/codexSessionResponseStore.js';
|
||||
|
||||
const fetchMock = vi.fn();
|
||||
const selectChannelMock = vi.fn();
|
||||
const selectNextChannelMock = vi.fn();
|
||||
const selectPreferredChannelMock = vi.fn();
|
||||
const previewSelectedChannelMock = vi.fn();
|
||||
const recordSuccessMock = vi.fn();
|
||||
const recordFailureMock = vi.fn();
|
||||
@@ -21,6 +23,7 @@ const resolveProxyUsageWithSelfLogFallbackMock = vi.fn(async ({ usage }: any) =>
|
||||
estimatedCostFromQuota: 0,
|
||||
recoveredFromSelfLog: false,
|
||||
}));
|
||||
const trackedClientSockets = new Set<WebSocket>();
|
||||
const dbInsertMock = vi.fn((_arg?: any) => ({
|
||||
values: () => ({
|
||||
run: () => undefined,
|
||||
@@ -35,6 +38,7 @@ vi.mock('../../services/tokenRouter.js', () => ({
|
||||
tokenRouter: {
|
||||
selectChannel: (...args: unknown[]) => selectChannelMock(...args),
|
||||
selectNextChannel: (...args: unknown[]) => selectNextChannelMock(...args),
|
||||
selectPreferredChannel: (...args: unknown[]) => selectPreferredChannelMock(...args),
|
||||
previewSelectedChannel: (...args: unknown[]) => previewSelectedChannelMock(...args),
|
||||
recordSuccess: (...args: unknown[]) => recordSuccessMock(...args),
|
||||
recordFailure: (...args: unknown[]) => recordFailureMock(...args),
|
||||
@@ -78,6 +82,7 @@ vi.mock('../../services/modelPricingService.js', () => ({
|
||||
|
||||
vi.mock('../../services/proxyRetryPolicy.js', () => ({
|
||||
shouldRetryProxyRequest: () => false,
|
||||
shouldAbortSameSiteEndpointFallback: () => false,
|
||||
RETRYABLE_TIMEOUT_PATTERNS: [/(request timed out|connection timed out|read timeout|\btimed out\b)/i],
|
||||
}));
|
||||
|
||||
@@ -256,16 +261,26 @@ function createDeferred<T>() {
|
||||
}
|
||||
|
||||
function createClientSocket(baseUrl: string, headers: Record<string, string> = {}) {
|
||||
return new WebSocket(`${baseUrl}/v1/responses`, {
|
||||
const socket = new WebSocket(`${baseUrl}/v1/responses`, {
|
||||
headers: {
|
||||
Authorization: 'Bearer sk-global-proxy-token',
|
||||
...headers,
|
||||
},
|
||||
});
|
||||
trackedClientSockets.add(socket);
|
||||
socket.once('close', () => {
|
||||
trackedClientSockets.delete(socket);
|
||||
});
|
||||
return socket;
|
||||
}
|
||||
|
||||
function createClientSocketForPath(path: string, headers: Record<string, string> = {}) {
|
||||
return new WebSocket(path, { headers });
|
||||
const socket = new WebSocket(path, { headers });
|
||||
trackedClientSockets.add(socket);
|
||||
socket.once('close', () => {
|
||||
trackedClientSockets.delete(socket);
|
||||
});
|
||||
return socket;
|
||||
}
|
||||
|
||||
describe('responses websocket transport', () => {
|
||||
@@ -274,6 +289,7 @@ describe('responses websocket transport', () => {
|
||||
let app: FastifyInstance;
|
||||
let baseUrl: string;
|
||||
let upstreamServer: WebSocketServer;
|
||||
let upstreamSockets: Set<WebSocket>;
|
||||
let upstreamSiteUrl: string;
|
||||
let upstreamConnectionCount: number;
|
||||
let upstreamUpgradeHeaders: Record<string, string>;
|
||||
@@ -294,7 +310,12 @@ describe('responses websocket transport', () => {
|
||||
baseUrl = `ws://127.0.0.1:${address.port}`;
|
||||
|
||||
upstreamServer = new WebSocketServer({ port: 0 });
|
||||
upstreamSockets = new Set();
|
||||
upstreamServer.on('connection', (socket, request) => {
|
||||
upstreamSockets.add(socket);
|
||||
socket.once('close', () => {
|
||||
upstreamSockets.delete(socket);
|
||||
});
|
||||
upstreamConnectionCount += 1;
|
||||
upstreamUpgradeHeaders = Object.fromEntries(
|
||||
Object.entries(request.headers)
|
||||
@@ -329,9 +350,11 @@ describe('responses websocket transport', () => {
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetCodexSessionResponseStore();
|
||||
fetchMock.mockReset();
|
||||
selectChannelMock.mockReset();
|
||||
selectNextChannelMock.mockReset();
|
||||
selectPreferredChannelMock.mockReset();
|
||||
previewSelectedChannelMock.mockReset();
|
||||
recordSuccessMock.mockReset();
|
||||
recordFailureMock.mockReset();
|
||||
@@ -346,6 +369,7 @@ describe('responses websocket transport', () => {
|
||||
const selectedChannel = createSelectedChannel();
|
||||
selectChannelMock.mockReturnValue(selectedChannel);
|
||||
selectNextChannelMock.mockReturnValue(null);
|
||||
selectPreferredChannelMock.mockReturnValue(null);
|
||||
previewSelectedChannelMock.mockResolvedValue(selectedChannel);
|
||||
upstreamConnectionCount = 0;
|
||||
upstreamUpgradeHeaders = {};
|
||||
@@ -386,8 +410,29 @@ describe('responses websocket transport', () => {
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const socket of trackedClientSockets) {
|
||||
try {
|
||||
socket.terminate();
|
||||
} catch {}
|
||||
}
|
||||
trackedClientSockets.clear();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
(config as any).codexUpstreamWebsocketEnabled = originalCodexUpstreamWebsocketEnabled;
|
||||
for (const socket of trackedClientSockets) {
|
||||
try {
|
||||
socket.terminate();
|
||||
} catch {}
|
||||
}
|
||||
trackedClientSockets.clear();
|
||||
for (const socket of upstreamSockets || []) {
|
||||
try {
|
||||
socket.terminate();
|
||||
} catch {}
|
||||
}
|
||||
upstreamSockets?.clear();
|
||||
if (rejectedUpgradeServer) {
|
||||
await new Promise<void>((resolve) => rejectedUpgradeServer.close(() => resolve()));
|
||||
}
|
||||
@@ -551,6 +596,143 @@ describe('responses websocket transport', () => {
|
||||
expect(upstreamUpgradeHeaders['openai-beta']).toContain('responses_websockets=2099-01-01');
|
||||
});
|
||||
|
||||
it('infers previous_response_id for websocket tool-output follow-up turns when the client omits it', async () => {
|
||||
const selectedChannel = createSelectedChannel({
|
||||
siteUrl: upstreamSiteUrl,
|
||||
});
|
||||
selectChannelMock.mockReturnValue(selectedChannel);
|
||||
previewSelectedChannelMock.mockResolvedValue(selectedChannel);
|
||||
|
||||
const socket = createClientSocket(baseUrl, {
|
||||
session_id: 'ws-session-prev-infer',
|
||||
});
|
||||
await waitForSocketOpen(socket);
|
||||
|
||||
const firstResponsePromise = waitForSocketMessageMatching(
|
||||
socket,
|
||||
(message) => message?.type === 'response.completed',
|
||||
);
|
||||
socket.send(JSON.stringify({
|
||||
type: 'response.create',
|
||||
model: 'gpt-5.4',
|
||||
input: [],
|
||||
}));
|
||||
const firstResponse = await firstResponsePromise;
|
||||
|
||||
const secondResponsePromise = waitForSocketMessageMatching(
|
||||
socket,
|
||||
(message) => message?.type === 'response.completed' && message?.response?.id === 'resp_upstream_2',
|
||||
);
|
||||
socket.send(JSON.stringify({
|
||||
type: 'response.create',
|
||||
model: 'gpt-5.4',
|
||||
input: [
|
||||
{
|
||||
id: 'tool_out_ws_1',
|
||||
type: 'function_call_output',
|
||||
call_id: 'call_ws_1',
|
||||
output: '{"ok":true}',
|
||||
},
|
||||
],
|
||||
}));
|
||||
await secondResponsePromise;
|
||||
socket.close();
|
||||
|
||||
expect(firstResponse?.response?.id).toBe('resp_upstream_1');
|
||||
expect(upstreamRequests).toHaveLength(2);
|
||||
expect(upstreamRequests[1]).toMatchObject({
|
||||
type: 'response.create',
|
||||
previous_response_id: 'resp_upstream_1',
|
||||
input: [
|
||||
{
|
||||
id: 'tool_out_ws_1',
|
||||
type: 'function_call_output',
|
||||
call_id: 'call_ws_1',
|
||||
output: '{"ok":true}',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('retries websocket turns once without previous_response_id when the upstream reports previous_response_not_found', async () => {
|
||||
const selectedChannel = createSelectedChannel({
|
||||
siteUrl: upstreamSiteUrl,
|
||||
});
|
||||
selectChannelMock.mockReturnValue(selectedChannel);
|
||||
previewSelectedChannelMock.mockResolvedValue(selectedChannel);
|
||||
upstreamMessageHandler = (socket, parsed, requestIndex) => {
|
||||
if (requestIndex === 1) {
|
||||
socket.send(JSON.stringify({
|
||||
type: 'error',
|
||||
error: {
|
||||
message: 'previous_response_not_found',
|
||||
code: 'previous_response_not_found',
|
||||
type: 'invalid_request_error',
|
||||
},
|
||||
}));
|
||||
return;
|
||||
}
|
||||
socket.send(JSON.stringify({
|
||||
type: 'response.completed',
|
||||
response: {
|
||||
id: 'resp_upstream_recovered',
|
||||
object: 'response',
|
||||
model: parsed.model || 'gpt-5.4',
|
||||
status: 'completed',
|
||||
output: [],
|
||||
usage: {
|
||||
input_tokens: 1,
|
||||
output_tokens: 1,
|
||||
total_tokens: 2,
|
||||
},
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
const socket = createClientSocket(baseUrl, {
|
||||
session_id: 'ws-session-prev-recovery',
|
||||
});
|
||||
await waitForSocketOpen(socket);
|
||||
|
||||
const responsePromise = waitForSocketMessageMatching(
|
||||
socket,
|
||||
(message) => message?.type === 'response.completed' && message?.response?.id === 'resp_upstream_recovered',
|
||||
);
|
||||
socket.send(JSON.stringify({
|
||||
type: 'response.create',
|
||||
model: 'gpt-5.4',
|
||||
previous_response_id: 'resp_stale_ws',
|
||||
input: [
|
||||
{
|
||||
id: 'tool_out_ws_retry_1',
|
||||
type: 'function_call_output',
|
||||
call_id: 'call_ws_retry_1',
|
||||
output: '{"retry":true}',
|
||||
},
|
||||
],
|
||||
}));
|
||||
await responsePromise;
|
||||
socket.close();
|
||||
|
||||
expect(upstreamRequests).toHaveLength(2);
|
||||
expect(upstreamRequests[0]).toMatchObject({
|
||||
type: 'response.create',
|
||||
previous_response_id: 'resp_stale_ws',
|
||||
});
|
||||
expect(upstreamRequests[1]).toMatchObject({
|
||||
type: 'response.create',
|
||||
input: [
|
||||
{
|
||||
id: 'tool_out_ws_retry_1',
|
||||
type: 'function_call_output',
|
||||
call_id: 'call_ws_retry_1',
|
||||
output: '{"retry":true}',
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(upstreamRequests[1]?.previous_response_id).toBeUndefined();
|
||||
});
|
||||
|
||||
it('falls back to the HTTP responses executor when the upstream codex websocket upgrade returns 426', async () => {
|
||||
const selectedChannel = createSelectedChannel({
|
||||
siteUrl: rejectedUpgradeSiteUrl,
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { IncomingMessage } from 'node:http';
|
||||
import type { Duplex } from 'node:stream';
|
||||
import { WebSocketServer, type RawData, type WebSocket } from 'ws';
|
||||
import { createCodexWebsocketRuntime, CodexWebsocketRuntimeError } from '../../proxy-core/runtime/codexWebsocketRuntime.js';
|
||||
import { buildCodexSessionResponseStoreKey } from '../../proxy-core/runtime/codexSessionResponseStore.js';
|
||||
import {
|
||||
authorizeDownstreamToken,
|
||||
consumeManagedKeyRequest,
|
||||
@@ -541,13 +542,23 @@ async function handleResponsesWebsocketConnection(
|
||||
const websocketSessionId = headerValueToTrimmedString(request.headers['session_id'])
|
||||
|| headerValueToTrimmedString(request.headers['session-id'])
|
||||
|| randomUUID();
|
||||
const runtimeSessionKeys = new Set<string>();
|
||||
let lastRequest: Record<string, unknown> | null = null;
|
||||
let lastResponseOutput: unknown[] = [];
|
||||
let selectedChannel: SelectedChannel | null = null;
|
||||
let messageQueue = Promise.resolve();
|
||||
|
||||
socket.once('close', () => {
|
||||
void codexWebsocketRuntime.closeSession(websocketSessionId);
|
||||
const sessionKeys = runtimeSessionKeys.size > 0
|
||||
? Array.from(runtimeSessionKeys)
|
||||
: [websocketSessionId];
|
||||
void Promise.all(sessionKeys.map(async (sessionKey) => {
|
||||
try {
|
||||
await codexWebsocketRuntime.closeSession(sessionKey);
|
||||
} catch {
|
||||
// Ignore close-time cleanup failures after downstream disconnects.
|
||||
}
|
||||
}));
|
||||
});
|
||||
|
||||
socket.on('message', (raw) => {
|
||||
@@ -634,8 +645,15 @@ async function handleResponsesWebsocketConnection(
|
||||
const requestUrl = `${codexWebsocketChannel.site.url.replace(/\/+$/, '')}${prepared.path}`;
|
||||
|
||||
try {
|
||||
const runtimeResult = await codexWebsocketRuntime.sendRequest({
|
||||
const websocketRuntimeSessionKey = buildCodexSessionResponseStoreKey({
|
||||
sessionId: websocketSessionId,
|
||||
siteId: codexWebsocketChannel.site.id,
|
||||
accountId: codexWebsocketChannel.account.id,
|
||||
channelId: codexWebsocketChannel.channel.id,
|
||||
}) || websocketSessionId;
|
||||
runtimeSessionKeys.add(websocketRuntimeSessionKey);
|
||||
const runtimeResult = await codexWebsocketRuntime.sendRequest({
|
||||
sessionId: websocketRuntimeSessionKey,
|
||||
requestUrl,
|
||||
headers: prepared.headers,
|
||||
body: prepared.body,
|
||||
|
||||
@@ -48,6 +48,7 @@ vi.mock('../../services/modelPricingService.js', () => ({
|
||||
|
||||
vi.mock('../../services/proxyRetryPolicy.js', () => ({
|
||||
shouldRetryProxyRequest: () => false,
|
||||
shouldAbortSameSiteEndpointFallback: () => false,
|
||||
RETRYABLE_TIMEOUT_PATTERNS: [/(request timed out|connection timed out|read timeout|\btimed out\b)/i],
|
||||
}));
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ vi.mock('../../services/modelPricingService.js', () => ({
|
||||
}));
|
||||
|
||||
import {
|
||||
buildClaudeCountTokensUpstreamRequest,
|
||||
buildMinimalJsonHeadersForCompatibility,
|
||||
buildUpstreamEndpointRequest,
|
||||
isUnsupportedMediaTypeError,
|
||||
@@ -188,6 +189,23 @@ describe('resolveUpstreamEndpointCandidates', () => {
|
||||
expect(codexOrder).toEqual(['responses']);
|
||||
});
|
||||
|
||||
it('prefers responses for claude continuation follow-ups that carry orphan tool results', async () => {
|
||||
const order = await resolveUpstreamEndpointCandidates(
|
||||
{
|
||||
...baseContext,
|
||||
site: { ...baseContext.site, platform: 'openai' },
|
||||
},
|
||||
'gpt-5.4',
|
||||
'claude',
|
||||
'claude-opus-4-6',
|
||||
{
|
||||
wantsContinuationAwareResponses: true,
|
||||
},
|
||||
);
|
||||
|
||||
expect(order).toEqual(['responses', 'messages', 'chat']);
|
||||
});
|
||||
|
||||
it('prefers document-capable endpoints when downstream content contains non-image files', async () => {
|
||||
const order = await resolveUpstreamEndpointCandidates(
|
||||
{
|
||||
@@ -2053,6 +2071,60 @@ describe('buildUpstreamEndpointRequest', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('drops responses-style continuation fields before proxying native Claude messages upstream', () => {
|
||||
const request = buildUpstreamEndpointRequest({
|
||||
endpoint: 'messages',
|
||||
modelName: 'claude-opus-4-6',
|
||||
stream: false,
|
||||
tokenValue: 'sk-test',
|
||||
sitePlatform: 'claude',
|
||||
siteUrl: 'https://example.com',
|
||||
downstreamFormat: 'claude',
|
||||
openaiBody: {
|
||||
model: 'ignored',
|
||||
messages: [{ role: 'user', content: 'ignored' }],
|
||||
},
|
||||
claudeOriginalBody: {
|
||||
model: 'claude-opus-4-6',
|
||||
max_tokens: 256,
|
||||
previous_response_id: 'resp_prev_1',
|
||||
prompt_cache_key: 'cache-key-1',
|
||||
messages: [{ role: 'user', content: 'hello' }],
|
||||
},
|
||||
});
|
||||
|
||||
expect(request.body).toEqual({
|
||||
model: 'claude-opus-4-6',
|
||||
max_tokens: 256,
|
||||
messages: [{ role: 'user', content: 'hello' }],
|
||||
stream: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('drops responses-style continuation fields before proxying Claude count_tokens upstream', () => {
|
||||
const request = buildClaudeCountTokensUpstreamRequest({
|
||||
modelName: 'claude-opus-4-6',
|
||||
tokenValue: 'sk-test',
|
||||
sitePlatform: 'claude',
|
||||
claudeBody: {
|
||||
model: 'claude-opus-4-6',
|
||||
max_tokens: 256,
|
||||
previous_response_id: 'resp_prev_1',
|
||||
prompt_cache_key: 'cache-key-1',
|
||||
messages: [{ role: 'user', content: 'hello' }],
|
||||
},
|
||||
});
|
||||
|
||||
expect(request.body).toMatchObject({
|
||||
model: 'claude-opus-4-6',
|
||||
messages: [{ role: 'user' }],
|
||||
});
|
||||
expect(request.body).not.toHaveProperty('previous_response_id');
|
||||
expect(request.body).not.toHaveProperty('prompt_cache_key');
|
||||
expect(request.body).not.toHaveProperty('max_tokens');
|
||||
expect(request.body).not.toHaveProperty('maxTokens');
|
||||
});
|
||||
|
||||
it('preserves multimodal OpenAI user content when converting to Responses input', () => {
|
||||
const request = buildUpstreamEndpointRequest({
|
||||
endpoint: 'responses',
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
rankConversationFileEndpoints,
|
||||
type ConversationFileInputSummary,
|
||||
} from '../../proxy-core/capabilities/conversationFileCapabilities.js';
|
||||
import type { UpstreamEndpoint } from '../../proxy-core/orchestration/upstreamRequest.js';
|
||||
import { resolveProviderProfile } from '../../proxy-core/providers/registry.js';
|
||||
import { config } from '../../config.js';
|
||||
import { fetchModelPricingCatalog } from '../../services/modelPricingService.js';
|
||||
@@ -46,7 +47,7 @@ export {
|
||||
shouldPreferResponsesAfterLegacyChatError,
|
||||
};
|
||||
|
||||
export type UpstreamEndpoint = 'chat' | 'messages' | 'responses';
|
||||
export type { UpstreamEndpoint } from '../../proxy-core/orchestration/upstreamRequest.js';
|
||||
export type EndpointPreference = DownstreamFormat | 'responses';
|
||||
|
||||
type ChannelContext = {
|
||||
@@ -225,6 +226,15 @@ function extractClaudeBetasFromBody(body: Record<string, unknown>): {
|
||||
};
|
||||
}
|
||||
|
||||
function stripClaudeMessagesContinuationFields(
|
||||
body: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
const next = { ...body };
|
||||
delete next.previous_response_id;
|
||||
delete next.prompt_cache_key;
|
||||
return next;
|
||||
}
|
||||
|
||||
function buildAntigravityRuntimeHeaders(input: {
|
||||
baseHeaders: Record<string, string>;
|
||||
stream: boolean;
|
||||
@@ -529,6 +539,7 @@ export async function resolveUpstreamEndpointCandidates(
|
||||
hasNonImageFileInput?: boolean;
|
||||
conversationFileSummary?: ConversationFileInputSummary;
|
||||
wantsNativeResponsesReasoning?: boolean;
|
||||
wantsContinuationAwareResponses?: boolean;
|
||||
},
|
||||
): Promise<UpstreamEndpoint[]> {
|
||||
const sitePlatform = normalizePlatformName(context.site.platform);
|
||||
@@ -540,6 +551,7 @@ export async function resolveUpstreamEndpointCandidates(
|
||||
const preferMessagesForClaudeModel = capabilityProfile.preferMessagesForClaudeModel;
|
||||
const hasNonImageFileInput = capabilityProfile.hasNonImageFileInput;
|
||||
const wantsNativeResponsesReasoning = capabilityProfile.wantsNativeResponsesReasoning;
|
||||
const wantsContinuationAwareResponses = capabilityProfile.wantsContinuationAwareResponses;
|
||||
const applyRuntimePreference = (candidates: UpstreamEndpoint[]) => (
|
||||
applyUpstreamEndpointRuntimePreference(candidates, {
|
||||
siteId: context.site.id,
|
||||
@@ -587,9 +599,11 @@ export async function resolveUpstreamEndpointCandidates(
|
||||
})()
|
||||
: preferred;
|
||||
const prioritizedPreferredEndpoints: UpstreamEndpoint[] = (
|
||||
wantsNativeResponsesReasoning
|
||||
&& preferMessagesForClaudeModel
|
||||
&& preferredWithCapabilities.includes('responses')
|
||||
preferredWithCapabilities.includes('responses')
|
||||
&& (
|
||||
wantsContinuationAwareResponses
|
||||
|| (wantsNativeResponsesReasoning && preferMessagesForClaudeModel)
|
||||
)
|
||||
)
|
||||
? [
|
||||
'responses',
|
||||
@@ -865,7 +879,7 @@ export function buildUpstreamEndpointRequest(input: {
|
||||
&& input.forceNormalizeClaudeBody !== true
|
||||
)
|
||||
? {
|
||||
...input.claudeOriginalBody,
|
||||
...stripClaudeMessagesContinuationFields(input.claudeOriginalBody),
|
||||
model: input.modelName,
|
||||
stream: input.stream,
|
||||
}
|
||||
@@ -876,7 +890,7 @@ export function buildUpstreamEndpointRequest(input: {
|
||||
&& input.forceNormalizeClaudeBody === true
|
||||
)
|
||||
? sanitizeAnthropicMessagesBody({
|
||||
...input.claudeOriginalBody,
|
||||
...stripClaudeMessagesContinuationFields(input.claudeOriginalBody),
|
||||
model: input.modelName,
|
||||
stream: input.stream,
|
||||
})
|
||||
@@ -1034,7 +1048,7 @@ export function buildClaudeCountTokensUpstreamRequest(input: {
|
||||
const sitePlatform = normalizePlatformName(input.sitePlatform);
|
||||
const claudeHeaders = extractClaudePassthroughHeaders(input.downstreamHeaders);
|
||||
const { body: bodyWithoutBetas, betas } = extractClaudeBetasFromBody({
|
||||
...input.claudeBody,
|
||||
...stripClaudeMessagesContinuationFields(input.claudeBody),
|
||||
model: input.modelName,
|
||||
});
|
||||
const sanitizedBody = sanitizeAnthropicMessagesBody(bodyWithoutBetas);
|
||||
|
||||
@@ -1,81 +1 @@
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object';
|
||||
}
|
||||
|
||||
function collapseWhitespace(text: string): string {
|
||||
return text.replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function extractJsonErrorMessage(rawText: string): string {
|
||||
try {
|
||||
const parsed = JSON.parse(rawText) as unknown;
|
||||
const root = isRecord(parsed) ? parsed : null;
|
||||
const error = (root && isRecord(root.error)) ? root.error : root;
|
||||
if (!error) return '';
|
||||
|
||||
const message = typeof error.message === 'string' ? collapseWhitespace(error.message) : '';
|
||||
if (message) return message;
|
||||
|
||||
const code = typeof error.code === 'string' ? collapseWhitespace(error.code) : '';
|
||||
const type = typeof error.type === 'string' ? collapseWhitespace(error.type) : '';
|
||||
return [type, code].filter((part) => part.length > 0).join('/');
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function extractHtmlTitle(rawText: string): string {
|
||||
const match = rawText.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
|
||||
if (!match?.[1]) return '';
|
||||
return collapseWhitespace(match[1].replace(/<[^>]+>/g, ''));
|
||||
}
|
||||
|
||||
function extractCloudflareHtmlSummary(rawText: string, status: number): string {
|
||||
if (!/cloudflare/i.test(rawText)) return '';
|
||||
const title = extractHtmlTitle(rawText);
|
||||
const codeMatch = (
|
||||
title.match(/\b(\d{3,4})\s*:\s*([^\|<]+)/i)
|
||||
|| rawText.match(/Error code\s*(\d{3,4})/i)
|
||||
);
|
||||
const code = codeMatch?.[1] || (status > 0 ? String(status) : '');
|
||||
const reason = collapseWhitespace(
|
||||
(typeof codeMatch?.[2] === 'string' ? codeMatch[2] : '')
|
||||
|| (status >= 500 ? 'origin host error' : 'request blocked')
|
||||
);
|
||||
if (code) return `Cloudflare ${code}: ${reason}`;
|
||||
return `Cloudflare: ${reason}`;
|
||||
}
|
||||
|
||||
function extractHtmlSummary(rawText: string, status: number): string {
|
||||
if (!/(<!doctype|<html)/i.test(rawText)) return '';
|
||||
|
||||
const cloudflareSummary = extractCloudflareHtmlSummary(rawText, status);
|
||||
if (cloudflareSummary) return cloudflareSummary;
|
||||
|
||||
const title = extractHtmlTitle(rawText);
|
||||
if (title) return title;
|
||||
|
||||
const heading = rawText.match(/<h1[^>]*>([\s\S]*?)<\/h1>/i)?.[1] || '';
|
||||
return collapseWhitespace(heading.replace(/<[^>]+>/g, ''));
|
||||
}
|
||||
|
||||
export function summarizeUpstreamError(status: number, rawErrorText: string): string {
|
||||
const statusPrefix = status > 0
|
||||
? `Upstream returned HTTP ${status}`
|
||||
: 'Upstream request failed';
|
||||
|
||||
const raw = typeof rawErrorText === 'string' ? rawErrorText.trim() : '';
|
||||
if (!raw) return statusPrefix;
|
||||
|
||||
const jsonMessage = extractJsonErrorMessage(raw);
|
||||
if (jsonMessage) return `${statusPrefix}: ${jsonMessage}`;
|
||||
|
||||
const htmlMessage = extractHtmlSummary(raw, status);
|
||||
if (htmlMessage) return `${statusPrefix}: ${htmlMessage}`;
|
||||
|
||||
const compact = collapseWhitespace(raw);
|
||||
if (!compact) return statusPrefix;
|
||||
if (compact.length <= 400) return `${statusPrefix}: ${compact}`;
|
||||
return `${statusPrefix}: ${compact.slice(0, 400)}...(truncated)`;
|
||||
}
|
||||
|
||||
export { summarizeUpstreamError } from '../../proxy-core/orchestration/upstreamRequest.js';
|
||||
|
||||
@@ -1,58 +1 @@
|
||||
function formatUrlOrigin(url: URL): string {
|
||||
// URL.origin drops credentials; preserve them if present.
|
||||
const username = url.username ? encodeURIComponent(url.username) : '';
|
||||
const password = url.password ? encodeURIComponent(url.password) : '';
|
||||
const auth = username
|
||||
? `${username}${password ? `:${password}` : ''}@`
|
||||
: '';
|
||||
|
||||
return `${url.protocol}//${auth}${url.host}`;
|
||||
}
|
||||
|
||||
function joinPath(basePath: string, requestPath: string): string {
|
||||
const base = basePath.replace(/\/+$/, '');
|
||||
const path = requestPath.startsWith('/') ? requestPath : `/${requestPath}`;
|
||||
|
||||
if (!base || base === '/') return path || '/';
|
||||
if (!path || path === '/') return base;
|
||||
return `${base}${path}`;
|
||||
}
|
||||
|
||||
export function buildUpstreamUrl(siteUrl: string, requestPath: string): string {
|
||||
const baseRaw = typeof siteUrl === 'string' ? siteUrl.trim() : '';
|
||||
const pathRaw = typeof requestPath === 'string' ? requestPath.trim() : '';
|
||||
const fallbackBase = baseRaw.replace(/\/+$/, '');
|
||||
let path = pathRaw.startsWith('/') ? pathRaw : `/${pathRaw}`;
|
||||
|
||||
if (!fallbackBase) return path || '/';
|
||||
if (!path || path === '/') return fallbackBase;
|
||||
|
||||
try {
|
||||
const parsed = new URL(baseRaw);
|
||||
const basePath = parsed.pathname.replace(/\/+$/, '');
|
||||
const baseHasVersionSuffix = /\/(?:api\/)?v1$/i.test(basePath);
|
||||
if (baseHasVersionSuffix) {
|
||||
if (path === '/v1') {
|
||||
path = '/';
|
||||
} else if (path.startsWith('/v1/')) {
|
||||
path = path.slice('/v1'.length) || '/';
|
||||
}
|
||||
}
|
||||
|
||||
const joinedPath = joinPath(basePath, path);
|
||||
return `${formatUrlOrigin(parsed)}${joinedPath}${parsed.search}${parsed.hash}`;
|
||||
} catch {
|
||||
// Ignore URL parsing errors and fall back to naive join.
|
||||
const baseHasVersionSuffix = /\/(?:api\/)?v1$/i.test(fallbackBase);
|
||||
if (baseHasVersionSuffix) {
|
||||
if (path === '/v1') {
|
||||
path = '/';
|
||||
} else if (path.startsWith('/v1/')) {
|
||||
path = path.slice('/v1'.length) || '/';
|
||||
}
|
||||
}
|
||||
|
||||
return `${fallbackBase}${path}`;
|
||||
}
|
||||
}
|
||||
|
||||
export { buildUpstreamUrl } from '../../proxy-core/orchestration/upstreamRequest.js';
|
||||
|
||||
@@ -53,6 +53,7 @@ vi.mock('../../services/modelPricingService.js', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('../../services/proxyRetryPolicy.js', () => ({
|
||||
shouldAbortSameSiteEndpointFallback: () => false,
|
||||
shouldRetryProxyRequest: (...args: unknown[]) => shouldRetryProxyRequestMock(...args),
|
||||
RETRYABLE_TIMEOUT_PATTERNS: [/(request timed out|connection timed out|read timeout|\btimed out\b)/i],
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user