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:
Bainily
2026-04-02 09:49:04 +08:00
committed by GitHub
parent 1133846998
commit 596d1b93cb
75 changed files with 6365 additions and 507 deletions
@@ -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));
});
});
+39
View File
@@ -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,
},
});
});
});
+101 -1
View File
@@ -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],
}));
+117
View File
@@ -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' },
+12 -219
View File
@@ -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,
+20 -2
View File
@@ -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,
+1
View File
@@ -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',
+21 -7
View File
@@ -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
View File
@@ -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
View File
@@ -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';
+1
View File
@@ -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],
}));