1
0
forked from boos/BoosAPI

[codex] HTTP-first Codex upstream websocket settings (#280)

* http-first codex upstream websocket settings

* address websocket runtime review comments

---------

Co-authored-by: apple <apple@appledeMacBook-Pro.local>
This commit is contained in:
Cita
2026-03-25 21:01:01 +08:00
committed by GitHub
parent 5d2596285b
commit 7e1fb8eb1b
14 changed files with 677 additions and 86 deletions
+1
View File
@@ -115,6 +115,7 @@ export function buildConfig(env: NodeJS.ProcessEnv) {
proxySessionChannelQueueWaitMs: Math.max(0, Math.trunc(parseNumber(env.PROXY_SESSION_CHANNEL_QUEUE_WAIT_MS, 1_500))), proxySessionChannelQueueWaitMs: Math.max(0, Math.trunc(parseNumber(env.PROXY_SESSION_CHANNEL_QUEUE_WAIT_MS, 1_500))),
proxySessionChannelLeaseTtlMs: Math.max(5_000, Math.trunc(parseNumber(env.PROXY_SESSION_CHANNEL_LEASE_TTL_MS, 90_000))), proxySessionChannelLeaseTtlMs: Math.max(5_000, Math.trunc(parseNumber(env.PROXY_SESSION_CHANNEL_LEASE_TTL_MS, 90_000))),
proxySessionChannelLeaseKeepaliveMs: Math.max(1_000, Math.trunc(parseNumber(env.PROXY_SESSION_CHANNEL_LEASE_KEEPALIVE_MS, 15_000))), proxySessionChannelLeaseKeepaliveMs: Math.max(1_000, Math.trunc(parseNumber(env.PROXY_SESSION_CHANNEL_LEASE_KEEPALIVE_MS, 15_000))),
codexUpstreamWebsocketEnabled: parseBoolean(env.CODEX_UPSTREAM_WEBSOCKET_ENABLED, false),
proxyLogRetentionDays: Math.max(0, Math.trunc(parseNumber(env.PROXY_LOG_RETENTION_DAYS, 30))), proxyLogRetentionDays: Math.max(0, Math.trunc(parseNumber(env.PROXY_LOG_RETENTION_DAYS, 30))),
proxyLogRetentionPruneIntervalMinutes: Math.max(1, Math.trunc(parseNumber(env.PROXY_LOG_RETENTION_PRUNE_INTERVAL_MINUTES, 30))), proxyLogRetentionPruneIntervalMinutes: Math.max(1, Math.trunc(parseNumber(env.PROXY_LOG_RETENTION_PRUNE_INTERVAL_MINUTES, 30))),
proxyFileRetentionDays: Math.max(0, Math.trunc(parseNumber(env.PROXY_FILE_RETENTION_DAYS, 30))), proxyFileRetentionDays: Math.max(0, Math.trunc(parseNumber(env.PROXY_FILE_RETENTION_DAYS, 30))),
+23
View File
@@ -137,6 +137,11 @@ function applyRuntimeSettings(settingsMap: Map<string, string>) {
const systemProxyUrl = parseSettingFromMap<string>(settingsMap, 'system_proxy_url'); const systemProxyUrl = parseSettingFromMap<string>(settingsMap, 'system_proxy_url');
if (typeof systemProxyUrl === 'string') config.systemProxyUrl = systemProxyUrl; if (typeof systemProxyUrl === 'string') config.systemProxyUrl = systemProxyUrl;
const codexUpstreamWebsocketEnabled = parseSettingFromMap<boolean>(settingsMap, 'codex_upstream_websocket_enabled');
if (typeof codexUpstreamWebsocketEnabled === 'boolean') {
config.codexUpstreamWebsocketEnabled = codexUpstreamWebsocketEnabled;
}
const proxyErrorKeywords = parseSettingFromMap<string[] | string>(settingsMap, 'proxy_error_keywords'); const proxyErrorKeywords = parseSettingFromMap<string[] | string>(settingsMap, 'proxy_error_keywords');
if (proxyErrorKeywords !== undefined) { if (proxyErrorKeywords !== undefined) {
config.proxyErrorKeywords = toStringList(proxyErrorKeywords); config.proxyErrorKeywords = toStringList(proxyErrorKeywords);
@@ -203,6 +208,24 @@ function applyRuntimeSettings(settingsMap: Map<string, string>) {
config.logCleanupRetentionDays = normalizeLogCleanupRetentionDays(logCleanupRetentionDays); config.logCleanupRetentionDays = normalizeLogCleanupRetentionDays(logCleanupRetentionDays);
} }
const proxySessionChannelConcurrencyLimit = parseSettingFromMap<number>(settingsMap, 'proxy_session_channel_concurrency_limit');
if (
typeof proxySessionChannelConcurrencyLimit === 'number'
&& Number.isFinite(proxySessionChannelConcurrencyLimit)
&& proxySessionChannelConcurrencyLimit >= 0
) {
config.proxySessionChannelConcurrencyLimit = Math.trunc(proxySessionChannelConcurrencyLimit);
}
const proxySessionChannelQueueWaitMs = parseSettingFromMap<number>(settingsMap, 'proxy_session_channel_queue_wait_ms');
if (
typeof proxySessionChannelQueueWaitMs === 'number'
&& Number.isFinite(proxySessionChannelQueueWaitMs)
&& proxySessionChannelQueueWaitMs >= 0
) {
config.proxySessionChannelQueueWaitMs = Math.trunc(proxySessionChannelQueueWaitMs);
}
const routingWeights = parseSettingFromMap<Partial<typeof config.routingWeights>>(settingsMap, 'routing_weights'); const routingWeights = parseSettingFromMap<Partial<typeof config.routingWeights>>(settingsMap, 'routing_weights');
if (routingWeights && typeof routingWeights === 'object') { if (routingWeights && typeof routingWeights === 'object') {
config.routingWeights = { config.routingWeights = {
@@ -139,7 +139,7 @@ describe('codexWebsocketRuntime', () => {
await runtime.closeSession('exec-session-close'); await runtime.closeSession('exec-session-close');
}); });
it('treats response.incomplete as a terminal websocket failure instead of waiting for socket close', async () => { it('returns response.incomplete as a terminal websocket event without rejecting the session turn', async () => {
upstreamMessageHandler = (socket, parsed) => { upstreamMessageHandler = (socket, parsed) => {
socket.send(JSON.stringify({ socket.send(JSON.stringify({
type: 'response.incomplete', type: 'response.incomplete',
@@ -154,12 +154,10 @@ describe('codexWebsocketRuntime', () => {
})); }));
}; };
const { createCodexWebsocketRuntime, CodexWebsocketRuntimeError } = await import('./codexWebsocketRuntime.js'); const { createCodexWebsocketRuntime } = await import('./codexWebsocketRuntime.js');
const runtime = createCodexWebsocketRuntime(); const runtime = createCodexWebsocketRuntime();
let error: unknown; const result = await runtime.sendRequest({
try {
await runtime.sendRequest({
sessionId: 'exec-session-incomplete', sessionId: 'exec-session-incomplete',
requestUrl: upstreamWsUrl, requestUrl: upstreamWsUrl,
headers: { headers: {
@@ -171,20 +169,209 @@ describe('codexWebsocketRuntime', () => {
input: [], input: [],
}, },
}); });
} catch (caught) {
error = caught;
}
expect(error).toBeInstanceOf(CodexWebsocketRuntimeError); expect(result.events).toEqual([
expect(error).toMatchObject({
message: 'max_output_tokens',
status: 502,
});
expect((error as CodexWebsocketRuntimeError).events).toEqual([
expect.objectContaining({ expect.objectContaining({
type: 'response.incomplete', type: 'response.incomplete',
}), }),
]); ]);
expect(result.reusedSession).toBe(false);
await runtime.closeSession('exec-session-incomplete');
});
it('keeps the upstream websocket session alive across response.failed terminal turns', async () => {
upstreamMessageHandler = (socket, parsed, requestIndex) => {
if (requestIndex === 1) {
socket.send(JSON.stringify({
type: 'response.failed',
response: {
id: 'resp-failed',
model: parsed.model || 'gpt-5.4',
status: 'failed',
error: {
message: 'tool execution failed',
type: 'server_error',
},
},
}));
return;
}
socket.send(JSON.stringify({
type: 'response.completed',
response: {
id: `resp-${requestIndex}`,
object: 'response',
model: parsed.model || 'gpt-5.4',
status: 'completed',
output: [],
usage: {
input_tokens: 1,
output_tokens: 1,
total_tokens: 2,
},
},
}));
};
const { createCodexWebsocketRuntime } = await import('./codexWebsocketRuntime.js');
const runtime = createCodexWebsocketRuntime();
const first = await runtime.sendRequest({
sessionId: 'exec-session-failed-turn',
requestUrl: upstreamWsUrl,
headers: {
Authorization: 'Bearer oauth-access-token',
'OpenAI-Beta': 'responses_websockets=2026-02-06',
},
body: {
model: 'gpt-5.4',
input: [],
},
});
const second = await runtime.sendRequest({
sessionId: 'exec-session-failed-turn',
requestUrl: upstreamWsUrl,
headers: {
Authorization: 'Bearer oauth-access-token',
'OpenAI-Beta': 'responses_websockets=2026-02-06',
},
body: {
model: 'gpt-5.4',
previous_response_id: 'resp-failed',
input: [],
},
});
expect(first.events).toEqual([
expect.objectContaining({
type: 'response.failed',
response: expect.objectContaining({
id: 'resp-failed',
error: expect.objectContaining({
message: 'tool execution failed',
}),
}),
}),
]);
expect(first.reusedSession).toBe(false);
expect(second.events[0]).toMatchObject({
type: 'response.completed',
response: { id: 'resp-2' },
});
expect(second.reusedSession).toBe(true);
expect(upstreamConnectionCount).toBe(1);
expect(upstreamRequests).toHaveLength(2);
await runtime.closeSession('exec-session-failed-turn');
});
it('fails the current turn and opens a fresh websocket on the next turn when a reused session closes before yielding any events', async () => {
upstreamMessageHandler = (socket, parsed, requestIndex) => {
if (requestIndex === 1) {
socket.send(JSON.stringify({
type: 'response.completed',
response: {
id: 'resp-1',
object: 'response',
model: parsed.model || 'gpt-5.4',
status: 'completed',
output: [],
usage: {
input_tokens: 1,
output_tokens: 1,
total_tokens: 2,
},
},
}));
return;
}
if (requestIndex === 2) {
socket.close();
return;
}
socket.send(JSON.stringify({
type: 'response.completed',
response: {
id: 'resp-3',
object: 'response',
model: parsed.model || 'gpt-5.4',
status: 'completed',
output: [],
usage: {
input_tokens: 1,
output_tokens: 1,
total_tokens: 2,
},
},
}));
};
const { createCodexWebsocketRuntime } = await import('./codexWebsocketRuntime.js');
const runtime = createCodexWebsocketRuntime();
await runtime.sendRequest({
sessionId: 'exec-session-retry-stale',
requestUrl: upstreamWsUrl,
headers: {
Authorization: 'Bearer oauth-access-token',
'OpenAI-Beta': 'responses_websockets=2026-02-06',
},
body: {
model: 'gpt-5.4',
input: [],
},
});
await expect(runtime.sendRequest({
sessionId: 'exec-session-retry-stale',
requestUrl: upstreamWsUrl,
headers: {
Authorization: 'Bearer oauth-access-token',
'OpenAI-Beta': 'responses_websockets=2026-02-06',
},
body: {
model: 'gpt-5.4',
previous_response_id: 'resp-1',
input: [],
},
})).rejects.toThrow('stream closed before response.completed');
const recovered = await runtime.sendRequest({
sessionId: 'exec-session-retry-stale',
requestUrl: upstreamWsUrl,
headers: {
Authorization: 'Bearer oauth-access-token',
'OpenAI-Beta': 'responses_websockets=2026-02-06',
},
body: {
model: 'gpt-5.4',
previous_response_id: 'resp-1',
input: [],
},
});
expect(recovered.events[0]).toMatchObject({
type: 'response.completed',
response: { id: 'resp-3' },
});
expect(recovered.reusedSession).toBe(false);
expect(upstreamConnectionCount).toBe(2);
expect(upstreamRequests).toHaveLength(3);
expect(upstreamRequests[1]).toMatchObject({
type: 'response.create',
previous_response_id: 'resp-1',
});
expect(upstreamRequests[2]).toMatchObject({
type: 'response.create',
previous_response_id: 'resp-1',
});
await runtime.closeSession('exec-session-retry-stale');
}); });
it('treats top-level error frames as terminal websocket failures', async () => { it('treats top-level error frames as terminal websocket failures', async () => {
@@ -29,9 +29,9 @@ function isTerminalEvent(payload: Record<string, unknown>): boolean {
|| type === 'error'; || type === 'error';
} }
function isFailureTerminalEvent(payload: Record<string, unknown>): boolean { function isRuntimeErrorEvent(payload: Record<string, unknown>): boolean {
const type = asTrimmedString(payload.type); const type = asTrimmedString(payload.type);
return type === 'response.failed' || type === 'response.incomplete' || type === 'error'; return type === 'error';
} }
function asFiniteNumber(value: unknown): number | undefined { function asFiniteNumber(value: unknown): number | undefined {
@@ -262,7 +262,7 @@ async function sendSessionRequest(
events.push(parsed); events.push(parsed);
if (!isTerminalEvent(parsed)) return; if (!isTerminalEvent(parsed)) return;
if (settled) return; if (settled) return;
if (isFailureTerminalEvent(parsed)) { if (isRuntimeErrorEvent(parsed)) {
settled = true; settled = true;
cleanup(); cleanup();
clearSessionSocket(session, socket); clearSessionSocket(session, socket);
@@ -264,6 +264,7 @@ export async function handleChatSurfaceRequest(
}; };
let startTime = Date.now(); let startTime = Date.now();
const leaseResult = await acquireSurfaceChannelLease({ const leaseResult = await acquireSurfaceChannelLease({
stickySessionKey,
selected, selected,
}); });
if (leaseResult.status === 'timeout') { if (leaseResult.status === 'timeout') {
@@ -789,6 +790,7 @@ export async function handleClaudeCountTokensSurfaceRequest(
const oauth = getOauthInfoFromAccount(selected.account); const oauth = getOauthInfoFromAccount(selected.account);
const startTime = Date.now(); const startTime = Date.now();
const leaseResult = await acquireSurfaceChannelLease({ const leaseResult = await acquireSurfaceChannelLease({
stickySessionKey,
selected, selected,
}); });
if (leaseResult.status === 'timeout') { if (leaseResult.status === 'timeout') {
@@ -380,6 +380,7 @@ export async function handleOpenAiResponsesSurfaceRequest(
const startTime = Date.now(); const startTime = Date.now();
const leaseResult = await acquireSurfaceChannelLease({ const leaseResult = await acquireSurfaceChannelLease({
stickySessionKey,
selected, selected,
}); });
if (leaseResult.status === 'timeout') { if (leaseResult.status === 'timeout') {
@@ -187,13 +187,17 @@ export function clearSurfaceStickyChannel(input: {
} }
export async function acquireSurfaceChannelLease(input: { export async function acquireSurfaceChannelLease(input: {
stickySessionKey?: string | null;
selected: { selected: {
channel: { id: number }; channel: { id: number };
account?: { extraConfig?: string | null } | null; account?: { extraConfig?: string | null } | null;
}; };
}) { }) {
return await proxyChannelCoordinator.acquireChannelLease({ return await proxyChannelCoordinator.acquireChannelLease({
channelId: input.selected.channel.id, // Only session-addressable requests should consume the guarded per-channel
// lease pool. Requests without a stable downstream session key should keep
// the pre-sticky-session parallel behavior instead of contending globally.
channelId: input.stickySessionKey ? input.selected.channel.id : 0,
accountExtraConfig: input.selected.account?.extraConfig, accountExtraConfig: input.selected.account?.extraConfig,
}); });
} }
@@ -52,6 +52,9 @@ describe('settings and auth events', () => {
config.logCleanupUsageLogsEnabled = false; config.logCleanupUsageLogsEnabled = false;
config.logCleanupProgramLogsEnabled = false; config.logCleanupProgramLogsEnabled = false;
config.logCleanupRetentionDays = 30; config.logCleanupRetentionDays = 30;
config.codexUpstreamWebsocketEnabled = false;
config.proxySessionChannelConcurrencyLimit = 2;
config.proxySessionChannelQueueWaitMs = 1500;
config.routingFallbackUnitCost = 1; config.routingFallbackUnitCost = 1;
(config as any).telegramEnabled = false; (config as any).telegramEnabled = false;
(config as any).telegramApiBaseUrl = 'https://api.telegram.org'; (config as any).telegramApiBaseUrl = 'https://api.telegram.org';
@@ -111,6 +114,38 @@ describe('settings and auth events', () => {
expect(savedInterval?.value).toBe(JSON.stringify(8)); expect(savedInterval?.value).toBe(JSON.stringify(8));
}); });
it('persists codex upstream websocket and session lease settings from runtime settings', async () => {
const updateResponse = await app.inject({
method: 'PUT',
url: '/api/settings/runtime',
payload: {
codexUpstreamWebsocketEnabled: true,
proxySessionChannelConcurrencyLimit: 6,
proxySessionChannelQueueWaitMs: 4200,
},
});
expect(updateResponse.statusCode).toBe(200);
const updated = updateResponse.json() as {
codexUpstreamWebsocketEnabled?: boolean;
proxySessionChannelConcurrencyLimit?: number;
proxySessionChannelQueueWaitMs?: number;
};
expect(updated.codexUpstreamWebsocketEnabled).toBe(true);
expect(updated.proxySessionChannelConcurrencyLimit).toBe(6);
expect(updated.proxySessionChannelQueueWaitMs).toBe(4200);
expect(config.codexUpstreamWebsocketEnabled).toBe(true);
expect(config.proxySessionChannelConcurrencyLimit).toBe(6);
expect(config.proxySessionChannelQueueWaitMs).toBe(4200);
const savedWebsocket = await db.select().from(schema.settings).where(eq(schema.settings.key, 'codex_upstream_websocket_enabled')).get();
const savedConcurrency = await db.select().from(schema.settings).where(eq(schema.settings.key, 'proxy_session_channel_concurrency_limit')).get();
const savedQueueWait = await db.select().from(schema.settings).where(eq(schema.settings.key, 'proxy_session_channel_queue_wait_ms')).get();
expect(savedWebsocket?.value).toBe(JSON.stringify(true));
expect(savedConcurrency?.value).toBe(JSON.stringify(6));
expect(savedQueueWait?.value).toBe(JSON.stringify(4200));
});
it('returns current recognized admin IP in runtime settings response', async () => { it('returns current recognized admin IP in runtime settings response', async () => {
const response = await app.inject({ const response = await app.inject({
method: 'GET', method: 'GET',
+67
View File
@@ -38,6 +38,9 @@ type RoutingWeights = typeof config.routingWeights;
interface RuntimeSettingsBody { interface RuntimeSettingsBody {
proxyToken?: string; proxyToken?: string;
systemProxyUrl?: string; systemProxyUrl?: string;
codexUpstreamWebsocketEnabled?: boolean;
proxySessionChannelConcurrencyLimit?: number;
proxySessionChannelQueueWaitMs?: number;
checkinCron?: string; checkinCron?: string;
checkinScheduleMode?: 'cron' | 'interval'; checkinScheduleMode?: 'cron' | 'interval';
checkinIntervalHours?: number; checkinIntervalHours?: number;
@@ -381,6 +384,11 @@ function applyImportedSettingToRuntime(key: string, value: unknown) {
config.systemProxyUrl = normalizeSiteProxyUrl(value) || ''; config.systemProxyUrl = normalizeSiteProxyUrl(value) || '';
return; return;
} }
case 'codex_upstream_websocket_enabled': {
if (typeof value !== 'boolean') return;
config.codexUpstreamWebsocketEnabled = value;
return;
}
case 'proxy_error_keywords': { case 'proxy_error_keywords': {
try { try {
config.proxyErrorKeywords = parseProxyErrorKeywords(value); config.proxyErrorKeywords = parseProxyErrorKeywords(value);
@@ -389,6 +397,18 @@ function applyImportedSettingToRuntime(key: string, value: unknown) {
} }
return; return;
} }
case 'proxy_session_channel_concurrency_limit': {
const limit = Number(value);
if (!Number.isFinite(limit) || limit < 0) return;
config.proxySessionChannelConcurrencyLimit = Math.trunc(limit);
return;
}
case 'proxy_session_channel_queue_wait_ms': {
const queueWaitMs = Number(value);
if (!Number.isFinite(queueWaitMs) || queueWaitMs < 0) return;
config.proxySessionChannelQueueWaitMs = Math.trunc(queueWaitMs);
return;
}
case 'proxy_empty_content_fail_enabled': { case 'proxy_empty_content_fail_enabled': {
try { try {
config.proxyEmptyContentFailEnabled = parseBooleanFlag(value, '空内容判定失败开关'); config.proxyEmptyContentFailEnabled = parseBooleanFlag(value, '空内容判定失败开关');
@@ -557,6 +577,9 @@ function getRuntimeSettingsResponse(currentAdminIp = '') {
logCleanupUsageLogsEnabled: config.logCleanupUsageLogsEnabled, logCleanupUsageLogsEnabled: config.logCleanupUsageLogsEnabled,
logCleanupProgramLogsEnabled: config.logCleanupProgramLogsEnabled, logCleanupProgramLogsEnabled: config.logCleanupProgramLogsEnabled,
logCleanupRetentionDays: config.logCleanupRetentionDays, logCleanupRetentionDays: config.logCleanupRetentionDays,
codexUpstreamWebsocketEnabled: config.codexUpstreamWebsocketEnabled,
proxySessionChannelConcurrencyLimit: config.proxySessionChannelConcurrencyLimit,
proxySessionChannelQueueWaitMs: config.proxySessionChannelQueueWaitMs,
routingFallbackUnitCost: config.routingFallbackUnitCost, routingFallbackUnitCost: config.routingFallbackUnitCost,
routingWeights: config.routingWeights, routingWeights: config.routingWeights,
webhookUrl: config.webhookUrl, webhookUrl: config.webhookUrl,
@@ -940,6 +963,50 @@ export async function settingsRoutes(app: FastifyInstance) {
invalidateSiteProxyCache(); invalidateSiteProxyCache();
} }
if (body.codexUpstreamWebsocketEnabled !== undefined) {
let nextValue = false;
try {
nextValue = parseBooleanFlag(body.codexUpstreamWebsocketEnabled, 'Codex 上游 WebSocket 开关');
} catch (err: any) {
return reply.code(400).send({
success: false,
message: err?.message || 'Codex 上游 WebSocket 开关格式无效',
});
}
if (nextValue !== config.codexUpstreamWebsocketEnabled) {
changedLabels.push('Codex 上游 WebSocket 默认策略');
}
config.codexUpstreamWebsocketEnabled = nextValue;
upsertSetting('codex_upstream_websocket_enabled', config.codexUpstreamWebsocketEnabled);
}
if (body.proxySessionChannelConcurrencyLimit !== undefined) {
const limit = Number(body.proxySessionChannelConcurrencyLimit);
if (!Number.isFinite(limit) || limit < 0) {
return reply.code(400).send({ success: false, message: '会话通道并发上限必须是大于等于 0 的整数' });
}
const nextLimit = Math.trunc(limit);
if (nextLimit !== config.proxySessionChannelConcurrencyLimit) {
changedLabels.push(`会话通道并发上限(${config.proxySessionChannelConcurrencyLimit} -> ${nextLimit})`);
}
config.proxySessionChannelConcurrencyLimit = nextLimit;
upsertSetting('proxy_session_channel_concurrency_limit', config.proxySessionChannelConcurrencyLimit);
}
if (body.proxySessionChannelQueueWaitMs !== undefined) {
const rawQueueWaitMs = Number(body.proxySessionChannelQueueWaitMs);
if (!Number.isFinite(rawQueueWaitMs) || rawQueueWaitMs < 0) {
return reply.code(400).send({ success: false, message: '会话通道排队等待时间必须是大于等于 0 的整数毫秒' });
}
const nextQueueWaitMs = Math.trunc(rawQueueWaitMs);
if (nextQueueWaitMs !== config.proxySessionChannelQueueWaitMs) {
changedLabels.push(`会话通道排队等待(${config.proxySessionChannelQueueWaitMs}ms -> ${nextQueueWaitMs}ms)`);
}
config.proxySessionChannelQueueWaitMs = nextQueueWaitMs;
upsertSetting('proxy_session_channel_queue_wait_ms', config.proxySessionChannelQueueWaitMs);
}
if (body.proxyErrorKeywords !== undefined) { if (body.proxyErrorKeywords !== undefined) {
let nextKeywords: string[] = []; let nextKeywords: string[] = [];
try { try {
@@ -20,6 +20,9 @@ const refreshOauthAccessTokenSingleflightMock = vi.fn();
const recordOauthQuotaResetHintMock = vi.fn(); const recordOauthQuotaResetHintMock = vi.fn();
const insertedProxyLogs: Record<string, unknown>[] = []; const insertedProxyLogs: Record<string, unknown>[] = [];
const originalProxyEmptyContentFailEnabled = config.proxyEmptyContentFailEnabled; const originalProxyEmptyContentFailEnabled = config.proxyEmptyContentFailEnabled;
const originalProxyStickySessionEnabled = config.proxyStickySessionEnabled;
const originalProxySessionChannelConcurrencyLimit = config.proxySessionChannelConcurrencyLimit;
const originalProxySessionChannelQueueWaitMs = config.proxySessionChannelQueueWaitMs;
const dbInsertMock = vi.fn((_arg?: any) => ({ const dbInsertMock = vi.fn((_arg?: any) => ({
values: (values: Record<string, unknown>) => { values: (values: Record<string, unknown>) => {
insertedProxyLogs.push(values); insertedProxyLogs.push(values);
@@ -136,6 +139,9 @@ describe('responses proxy codex oauth refresh', () => {
beforeEach(() => { beforeEach(() => {
resetCodexHttpSessionQueue(); resetCodexHttpSessionQueue();
config.proxyEmptyContentFailEnabled = false; config.proxyEmptyContentFailEnabled = false;
config.proxyStickySessionEnabled = originalProxyStickySessionEnabled;
config.proxySessionChannelConcurrencyLimit = originalProxySessionChannelConcurrencyLimit;
config.proxySessionChannelQueueWaitMs = originalProxySessionChannelQueueWaitMs;
fetchMock.mockReset(); fetchMock.mockReset();
selectChannelMock.mockReset(); selectChannelMock.mockReset();
selectNextChannelMock.mockReset(); selectNextChannelMock.mockReset();
@@ -180,6 +186,9 @@ describe('responses proxy codex oauth refresh', () => {
afterAll(async () => { afterAll(async () => {
config.proxyEmptyContentFailEnabled = originalProxyEmptyContentFailEnabled; config.proxyEmptyContentFailEnabled = originalProxyEmptyContentFailEnabled;
config.proxyStickySessionEnabled = originalProxyStickySessionEnabled;
config.proxySessionChannelConcurrencyLimit = originalProxySessionChannelConcurrencyLimit;
config.proxySessionChannelQueueWaitMs = originalProxySessionChannelQueueWaitMs;
await app.close(); await app.close();
}); });
@@ -561,6 +570,68 @@ describe('responses proxy codex oauth refresh', () => {
expect(secondOptions.headers.Session_id || secondOptions.headers.session_id).toBe('session-http-serial-1'); expect(secondOptions.headers.Session_id || secondOptions.headers.session_id).toBe('session-http-serial-1');
}); });
it('does not gate codex responses requests without a downstream session id behind the session lease queue', async () => {
config.proxyStickySessionEnabled = true;
config.proxySessionChannelConcurrencyLimit = 1;
config.proxySessionChannelQueueWaitMs = 20;
const firstUpstream = createDeferred<Response>();
fetchMock
.mockImplementationOnce(() => firstUpstream.promise)
.mockResolvedValueOnce(new Response(JSON.stringify({
id: 'resp_codex_parallel_2',
object: 'response',
model: 'gpt-5.2-codex',
status: 'completed',
output_text: 'second request finished',
usage: { input_tokens: 4, output_tokens: 2, total_tokens: 6 },
}), {
status: 200,
headers: { 'content-type': 'application/json' },
}));
const firstResponsePromise = app.inject({
method: 'POST',
url: '/v1/responses',
payload: {
model: 'gpt-5.2-codex',
input: 'first request',
},
});
await waitFor(() => fetchMock.mock.calls.length === 1);
const secondResponsePromise = app.inject({
method: 'POST',
url: '/v1/responses',
payload: {
model: 'gpt-5.2-codex',
input: 'second request',
},
});
await waitFor(() => fetchMock.mock.calls.length === 2);
firstUpstream.resolve(new Response(JSON.stringify({
id: 'resp_codex_parallel_1',
object: 'response',
model: 'gpt-5.2-codex',
status: 'completed',
output_text: 'first request finished',
usage: { input_tokens: 4, output_tokens: 2, total_tokens: 6 },
}), {
status: 200,
headers: { 'content-type': 'application/json' },
}));
const [firstResponse, secondResponse] = await Promise.all([
firstResponsePromise,
secondResponsePromise,
]);
expect(firstResponse.statusCode).toBe(200);
expect(secondResponse.statusCode).toBe(200);
});
it('records codex usage_limit_reached reset hints on upstream 429 failures', async () => { it('records codex usage_limit_reached reset hints on upstream 429 failures', async () => {
fetchMock.mockResolvedValue(new Response(JSON.stringify({ fetchMock.mockResolvedValue(new Response(JSON.stringify({
error: { error: {
@@ -248,6 +248,7 @@ function createClientSocketForPath(path: string, headers: Record<string, string>
describe('responses websocket transport', () => { describe('responses websocket transport', () => {
const originalCodexResponsesWebsocketBeta = config.codexResponsesWebsocketBeta; const originalCodexResponsesWebsocketBeta = config.codexResponsesWebsocketBeta;
const originalCodexUpstreamWebsocketEnabled = config.codexUpstreamWebsocketEnabled;
let app: FastifyInstance; let app: FastifyInstance;
let baseUrl: string; let baseUrl: string;
let upstreamServer: WebSocketServer; let upstreamServer: WebSocketServer;
@@ -328,6 +329,7 @@ describe('responses websocket transport', () => {
upstreamUpgradeHeaders = {}; upstreamUpgradeHeaders = {};
upstreamRequests = []; upstreamRequests = [];
(config as any).codexResponsesWebsocketBeta = originalCodexResponsesWebsocketBeta; (config as any).codexResponsesWebsocketBeta = originalCodexResponsesWebsocketBeta;
(config as any).codexUpstreamWebsocketEnabled = true;
rejectedUpgradeStatus = 426; rejectedUpgradeStatus = 426;
rejectedUpgradeStatusText = 'Upgrade Required'; rejectedUpgradeStatusText = 'Upgrade Required';
rejectedUpgradeBody = 'Upgrade Required'; rejectedUpgradeBody = 'Upgrade Required';
@@ -363,6 +365,7 @@ describe('responses websocket transport', () => {
}); });
afterAll(async () => { afterAll(async () => {
(config as any).codexUpstreamWebsocketEnabled = originalCodexUpstreamWebsocketEnabled;
await new Promise<void>((resolve) => rejectedUpgradeServer.close(() => resolve())); await new Promise<void>((resolve) => rejectedUpgradeServer.close(() => resolve()));
await new Promise<void>((resolve) => upstreamServer.close(() => resolve())); await new Promise<void>((resolve) => upstreamServer.close(() => resolve()));
await app.close(); await app.close();
@@ -1430,20 +1433,8 @@ describe('responses websocket transport', () => {
}); });
}); });
it('disables codex websocket incremental transport when the selected account marks websockets as disabled', async () => { it('falls back to the HTTP responses route when codex upstream websocket is globally disabled', async () => {
const selectedChannel = createSelectedChannel({ (config as any).codexUpstreamWebsocketEnabled = false;
extraConfig: JSON.stringify({
credentialMode: 'session',
websockets: false,
oauth: {
provider: 'codex',
accountId: 'chatgpt-account-123',
email: 'codex-user@example.com',
},
}),
});
selectChannelMock.mockReturnValue(selectedChannel);
previewSelectedChannelMock.mockResolvedValue(selectedChannel);
fetchMock fetchMock
.mockResolvedValueOnce(createSseResponse([ .mockResolvedValueOnce(createSseResponse([
'event: response.completed\n', 'event: response.completed\n',
+2 -43
View File
@@ -14,6 +14,7 @@ import { tokenRouter } from '../../services/tokenRouter.js';
import { buildOauthProviderHeaders } from '../../services/oauth/service.js'; import { buildOauthProviderHeaders } from '../../services/oauth/service.js';
import { openAiResponsesTransformer } from '../../transformers/openai/responses/index.js'; import { openAiResponsesTransformer } from '../../transformers/openai/responses/index.js';
import { buildUpstreamEndpointRequest } from './upstreamEndpoint.js'; import { buildUpstreamEndpointRequest } from './upstreamEndpoint.js';
import { config } from '../../config.js';
const installedApps = new WeakSet<FastifyInstance>(); const installedApps = new WeakSet<FastifyInstance>();
const WS_TURN_STATE_HEADER = 'x-codex-turn-state'; const WS_TURN_STATE_HEADER = 'x-codex-turn-state';
@@ -56,32 +57,6 @@ function headerValueToTrimmedString(value: unknown): string {
return ''; return '';
} }
function toBooleanLike(value: unknown): boolean | null {
if (typeof value === 'boolean') return value;
if (typeof value === 'string') {
const normalized = value.trim().toLowerCase();
if (normalized === 'true' || normalized === '1' || normalized === 'yes' || normalized === 'on') return true;
if (normalized === 'false' || normalized === '0' || normalized === 'no' || normalized === 'off') return false;
}
return null;
}
function parseExtraConfigRecord(extraConfig: unknown): Record<string, unknown> | null {
if (typeof extraConfig !== 'string') return null;
try {
const parsed = JSON.parse(extraConfig);
return isRecord(parsed) ? parsed : null;
} catch {
return null;
}
}
function readNestedRecord(value: unknown, key: string): Record<string, unknown> | null {
if (!isRecord(value)) return null;
const nested = value[key];
return isRecord(nested) ? nested : null;
}
function selectedChannelModelMatches( function selectedChannelModelMatches(
selectedChannel: SelectedChannel | null, selectedChannel: SelectedChannel | null,
requestModel: string, requestModel: string,
@@ -101,23 +76,7 @@ function selectedChannelSupportsCodexWebsocketTransport(
const platform = asTrimmedString(selectedChannel.site?.platform).toLowerCase(); const platform = asTrimmedString(selectedChannel.site?.platform).toLowerCase();
if (platform !== 'codex') return false; if (platform !== 'codex') return false;
if (!selectedChannelModelMatches(selectedChannel, requestModel)) return false; if (!selectedChannelModelMatches(selectedChannel, requestModel)) return false;
return !!config.codexUpstreamWebsocketEnabled;
const extraConfig = parseExtraConfigRecord(selectedChannel.account.extraConfig);
const oauth = readNestedRecord(extraConfig, 'oauth');
const providerData = readNestedRecord(oauth, 'providerData');
const candidateFlags = [
extraConfig?.websockets,
readNestedRecord(extraConfig, 'attributes')?.websockets,
readNestedRecord(extraConfig, 'metadata')?.websockets,
providerData?.websockets,
readNestedRecord(providerData, 'attributes')?.websockets,
readNestedRecord(providerData, 'metadata')?.websockets,
];
for (const candidate of candidateFlags) {
const parsed = toBooleanLike(candidate);
if (parsed !== null) return parsed;
}
return true;
} }
function selectedChannelSupportsIncrementalInput( function selectedChannelSupportsIncrementalInput(
+108
View File
@@ -49,6 +49,9 @@ type RuntimeSettings = {
logCleanupUsageLogsEnabled: boolean; logCleanupUsageLogsEnabled: boolean;
logCleanupProgramLogsEnabled: boolean; logCleanupProgramLogsEnabled: boolean;
logCleanupRetentionDays: number; logCleanupRetentionDays: number;
codexUpstreamWebsocketEnabled: boolean;
proxySessionChannelConcurrencyLimit: number;
proxySessionChannelQueueWaitMs: number;
routingFallbackUnitCost: number; routingFallbackUnitCost: number;
routingWeights: RoutingWeights; routingWeights: RoutingWeights;
systemProxyUrl: string; systemProxyUrl: string;
@@ -190,6 +193,9 @@ export default function Settings() {
logCleanupUsageLogsEnabled: false, logCleanupUsageLogsEnabled: false,
logCleanupProgramLogsEnabled: false, logCleanupProgramLogsEnabled: false,
logCleanupRetentionDays: 30, logCleanupRetentionDays: 30,
codexUpstreamWebsocketEnabled: false,
proxySessionChannelConcurrencyLimit: 2,
proxySessionChannelQueueWaitMs: 1500,
routingFallbackUnitCost: 1, routingFallbackUnitCost: 1,
routingWeights: defaultWeights, routingWeights: defaultWeights,
systemProxyUrl: '', systemProxyUrl: '',
@@ -204,6 +210,7 @@ export default function Settings() {
const [testingCheckin, setTestingCheckin] = useState(false); const [testingCheckin, setTestingCheckin] = useState(false);
const [savingToken, setSavingToken] = useState(false); const [savingToken, setSavingToken] = useState(false);
const [savingSystemProxy, setSavingSystemProxy] = useState(false); const [savingSystemProxy, setSavingSystemProxy] = useState(false);
const [savingProxyTransport, setSavingProxyTransport] = useState(false);
const [testingSystemProxy, setTestingSystemProxy] = useState(false); const [testingSystemProxy, setTestingSystemProxy] = useState(false);
const [systemProxyTestState, setSystemProxyTestState] = useState<SystemProxyTestState>(null); const [systemProxyTestState, setSystemProxyTestState] = useState<SystemProxyTestState>(null);
const [savingProxyFailureRules, setSavingProxyFailureRules] = useState(false); const [savingProxyFailureRules, setSavingProxyFailureRules] = useState(false);
@@ -412,6 +419,13 @@ export default function Settings() {
logCleanupRetentionDays: Number(runtimeInfo.logCleanupRetentionDays) >= 1 logCleanupRetentionDays: Number(runtimeInfo.logCleanupRetentionDays) >= 1
? Math.trunc(Number(runtimeInfo.logCleanupRetentionDays)) ? Math.trunc(Number(runtimeInfo.logCleanupRetentionDays))
: 30, : 30,
codexUpstreamWebsocketEnabled: !!runtimeInfo.codexUpstreamWebsocketEnabled,
proxySessionChannelConcurrencyLimit: Number(runtimeInfo.proxySessionChannelConcurrencyLimit) >= 0
? Math.trunc(Number(runtimeInfo.proxySessionChannelConcurrencyLimit))
: 2,
proxySessionChannelQueueWaitMs: Number(runtimeInfo.proxySessionChannelQueueWaitMs) >= 0
? Math.trunc(Number(runtimeInfo.proxySessionChannelQueueWaitMs))
: 1500,
routingFallbackUnitCost: Number(runtimeInfo.routingFallbackUnitCost) > 0 routingFallbackUnitCost: Number(runtimeInfo.routingFallbackUnitCost) > 0
? Number(runtimeInfo.routingFallbackUnitCost) ? Number(runtimeInfo.routingFallbackUnitCost)
: 1, : 1,
@@ -569,6 +583,34 @@ export default function Settings() {
} }
}; };
const saveProxyTransportSettings = async () => {
setSavingProxyTransport(true);
try {
const res = await api.updateRuntimeSettings({
codexUpstreamWebsocketEnabled: runtime.codexUpstreamWebsocketEnabled,
proxySessionChannelConcurrencyLimit: runtime.proxySessionChannelConcurrencyLimit,
proxySessionChannelQueueWaitMs: runtime.proxySessionChannelQueueWaitMs,
});
setRuntime((prev) => ({
...prev,
codexUpstreamWebsocketEnabled: typeof res?.codexUpstreamWebsocketEnabled === 'boolean'
? res.codexUpstreamWebsocketEnabled
: prev.codexUpstreamWebsocketEnabled,
proxySessionChannelConcurrencyLimit: Number(res?.proxySessionChannelConcurrencyLimit) >= 0
? Math.trunc(Number(res.proxySessionChannelConcurrencyLimit))
: prev.proxySessionChannelConcurrencyLimit,
proxySessionChannelQueueWaitMs: Number(res?.proxySessionChannelQueueWaitMs) >= 0
? Math.trunc(Number(res.proxySessionChannelQueueWaitMs))
: prev.proxySessionChannelQueueWaitMs,
}));
toast.success('传输与会话并发设置已保存');
} catch (err: any) {
toast.error(err?.message || '保存失败');
} finally {
setSavingProxyTransport(false);
}
};
const testSystemProxy = async () => { const testSystemProxy = async () => {
const proxyUrl = runtime.systemProxyUrl.trim(); const proxyUrl = runtime.systemProxyUrl.trim();
if (!proxyUrl) { if (!proxyUrl) {
@@ -1220,6 +1262,72 @@ export default function Settings() {
</div> </div>
</div> </div>
<div className="card animate-slide-up stagger-4" style={{ padding: 20 }}>
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 8 }}>Codex 上游传输与会话并发</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 12, lineHeight: 1.7 }}>
默认采用 HTTP 优先。只有这里开启后,metapi 才会在 Codex 请求上尝试把上游升级为 WebSocket。
下游 Codex 客户端也必须同时启用 `/v1/responses` websocket,单开这里不会生效。
</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 12, lineHeight: 1.7 }}>
从旧版本升级时,原先账号 `extraConfig.websockets` 的行为不再单独生效;现在统一以这里的全局设置和下游客户端是否开启为准。
</div>
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 8, fontSize: 13, color: 'var(--color-text-secondary)', marginBottom: 14 }}>
<input
type="checkbox"
checked={runtime.codexUpstreamWebsocketEnabled}
onChange={(e) => setRuntime((prev) => ({ ...prev, codexUpstreamWebsocketEnabled: e.target.checked }))}
/>
允许 metapi 到 Codex 上游使用 WebSocket
</label>
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : '1fr 1fr', gap: 12, marginBottom: 10 }}>
<div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>会话通道并发上限</div>
<input
type="number"
min={0}
value={runtime.proxySessionChannelConcurrencyLimit}
onChange={(e) => {
const nextValue = Number(e.target.value);
setRuntime((prev) => ({
...prev,
proxySessionChannelConcurrencyLimit: Number.isFinite(nextValue) && nextValue >= 0
? Math.trunc(nextValue)
: prev.proxySessionChannelConcurrencyLimit,
}));
}}
style={inputStyle}
/>
</div>
<div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>排队等待时间(毫秒)</div>
<input
type="number"
min={0}
step={100}
value={runtime.proxySessionChannelQueueWaitMs}
onChange={(e) => {
const nextValue = Number(e.target.value);
setRuntime((prev) => ({
...prev,
proxySessionChannelQueueWaitMs: Number.isFinite(nextValue) && nextValue >= 0
? Math.trunc(nextValue)
: prev.proxySessionChannelQueueWaitMs,
}));
}}
style={inputStyle}
/>
</div>
</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 12, lineHeight: 1.7 }}>
这组 lease 只作用于能识别稳定 `session_id` 的会话型请求;没有稳定会话标识的普通请求不会进入这个池。
</div>
<div>
<button onClick={saveProxyTransportSettings} disabled={savingProxyTransport} className="btn btn-primary">
{savingProxyTransport ? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> 保存中...</> : '保存传输与并发'}
</button>
</div>
</div>
<div className="card animate-slide-up stagger-4" style={{ padding: 20 }}> <div className="card animate-slide-up stagger-4" style={{ padding: 20 }}>
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 8 }}>下游访问令牌(PROXY_TOKEN)</div> <div style={{ fontWeight: 600, fontSize: 14, marginBottom: 8 }}>下游访问令牌(PROXY_TOKEN)</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 12 }}> <div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 12 }}>
@@ -0,0 +1,142 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer';
import { MemoryRouter } from 'react-router-dom';
import { ToastProvider } from '../components/Toast.js';
import Settings from './Settings.js';
const { apiMock } = vi.hoisted(() => ({
apiMock: {
getAuthInfo: vi.fn(),
getRuntimeSettings: vi.fn(),
getDownstreamApiKeys: vi.fn(),
getRoutesLite: vi.fn(),
getRuntimeDatabaseConfig: vi.fn(),
getBrandList: vi.fn(),
updateRuntimeSettings: vi.fn(),
},
}));
vi.mock('../api.js', () => ({
api: apiMock,
}));
vi.mock('../components/BrandIcon.js', () => ({
BrandGlyph: () => null,
InlineBrandIcon: () => null,
getBrand: () => null,
normalizeBrandIconKey: (icon: string) => icon,
}));
function collectText(node: ReactTestInstance): string {
return (node.children || []).map((child) => {
if (typeof child === 'string') return child;
return collectText(child);
}).join('');
}
async function flushMicrotasks() {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
}
describe('Settings proxy transport', () => {
beforeEach(() => {
vi.clearAllMocks();
apiMock.getAuthInfo.mockResolvedValue({ masked: 'sk-****' });
apiMock.getRuntimeSettings.mockResolvedValue({
checkinCron: '0 8 * * *',
checkinScheduleMode: 'interval',
checkinIntervalHours: 6,
balanceRefreshCron: '0 * * * *',
logCleanupCron: '15 4 * * *',
logCleanupUsageLogsEnabled: true,
logCleanupProgramLogsEnabled: true,
logCleanupRetentionDays: 14,
codexUpstreamWebsocketEnabled: false,
proxySessionChannelConcurrencyLimit: 4,
proxySessionChannelQueueWaitMs: 3200,
routingFallbackUnitCost: 1,
routingWeights: {},
adminIpAllowlist: [],
systemProxyUrl: '',
});
apiMock.getDownstreamApiKeys.mockResolvedValue({ items: [] });
apiMock.getRoutesLite.mockResolvedValue([]);
apiMock.getBrandList.mockResolvedValue({ brands: [] });
apiMock.getRuntimeDatabaseConfig.mockResolvedValue({
active: { dialect: 'sqlite', connection: '(default sqlite path)', ssl: false },
saved: null,
restartRequired: false,
});
apiMock.updateRuntimeSettings.mockResolvedValue({
success: true,
codexUpstreamWebsocketEnabled: true,
proxySessionChannelConcurrencyLimit: 6,
proxySessionChannelQueueWaitMs: 4200,
});
});
afterEach(() => {
vi.clearAllMocks();
});
it('saves codex upstream websocket and session lease settings from the settings page', async () => {
let root!: ReactTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter>
<ToastProvider>
<Settings />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const websocketToggleLabel = root.root.find((node) => (
node.type === 'label'
&& collectText(node).includes('允许 metapi 到 Codex 上游使用 WebSocket')
));
const websocketToggle = websocketToggleLabel.findByType('input');
expect(websocketToggle.props.checked).toBe(false);
const concurrencyInput = root.root.find((node) => (
node.type === 'input'
&& node.props.type === 'number'
&& node.props.value === 4
));
const queueWaitInput = root.root.find((node) => (
node.type === 'input'
&& node.props.type === 'number'
&& node.props.value === 3200
));
await act(async () => {
websocketToggle.props.onChange({ target: { checked: true } });
concurrencyInput.props.onChange({ target: { value: '6' } });
queueWaitInput.props.onChange({ target: { value: '4200' } });
});
const saveButton = root.root.find((node) => (
node.type === 'button'
&& typeof node.props.onClick === 'function'
&& collectText(node).trim() === '保存传输与并发'
));
await act(async () => {
saveButton.props.onClick();
});
await flushMicrotasks();
expect(apiMock.updateRuntimeSettings).toHaveBeenCalledWith({
codexUpstreamWebsocketEnabled: true,
proxySessionChannelConcurrencyLimit: 6,
proxySessionChannelQueueWaitMs: 4200,
});
} finally {
root?.unmount();
}
});
});