[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:
@@ -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))),
|
||||
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))),
|
||||
codexUpstreamWebsocketEnabled: parseBoolean(env.CODEX_UPSTREAM_WEBSOCKET_ENABLED, false),
|
||||
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))),
|
||||
proxyFileRetentionDays: Math.max(0, Math.trunc(parseNumber(env.PROXY_FILE_RETENTION_DAYS, 30))),
|
||||
|
||||
@@ -137,6 +137,11 @@ function applyRuntimeSettings(settingsMap: Map<string, string>) {
|
||||
const systemProxyUrl = parseSettingFromMap<string>(settingsMap, 'system_proxy_url');
|
||||
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');
|
||||
if (proxyErrorKeywords !== undefined) {
|
||||
config.proxyErrorKeywords = toStringList(proxyErrorKeywords);
|
||||
@@ -203,6 +208,24 @@ function applyRuntimeSettings(settingsMap: Map<string, string>) {
|
||||
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');
|
||||
if (routingWeights && typeof routingWeights === 'object') {
|
||||
config.routingWeights = {
|
||||
|
||||
@@ -139,7 +139,7 @@ describe('codexWebsocketRuntime', () => {
|
||||
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) => {
|
||||
socket.send(JSON.stringify({
|
||||
type: 'response.incomplete',
|
||||
@@ -154,37 +154,224 @@ describe('codexWebsocketRuntime', () => {
|
||||
}));
|
||||
};
|
||||
|
||||
const { createCodexWebsocketRuntime, CodexWebsocketRuntimeError } = await import('./codexWebsocketRuntime.js');
|
||||
const { createCodexWebsocketRuntime } = await import('./codexWebsocketRuntime.js');
|
||||
const runtime = createCodexWebsocketRuntime();
|
||||
|
||||
let error: unknown;
|
||||
try {
|
||||
await runtime.sendRequest({
|
||||
sessionId: 'exec-session-incomplete',
|
||||
requestUrl: upstreamWsUrl,
|
||||
headers: {
|
||||
Authorization: 'Bearer oauth-access-token',
|
||||
'OpenAI-Beta': 'responses_websockets=2026-02-06',
|
||||
},
|
||||
body: {
|
||||
model: 'gpt-5.4',
|
||||
input: [],
|
||||
},
|
||||
});
|
||||
} catch (caught) {
|
||||
error = caught;
|
||||
}
|
||||
|
||||
expect(error).toBeInstanceOf(CodexWebsocketRuntimeError);
|
||||
expect(error).toMatchObject({
|
||||
message: 'max_output_tokens',
|
||||
status: 502,
|
||||
const result = await runtime.sendRequest({
|
||||
sessionId: 'exec-session-incomplete',
|
||||
requestUrl: upstreamWsUrl,
|
||||
headers: {
|
||||
Authorization: 'Bearer oauth-access-token',
|
||||
'OpenAI-Beta': 'responses_websockets=2026-02-06',
|
||||
},
|
||||
body: {
|
||||
model: 'gpt-5.4',
|
||||
input: [],
|
||||
},
|
||||
});
|
||||
expect((error as CodexWebsocketRuntimeError).events).toEqual([
|
||||
|
||||
expect(result.events).toEqual([
|
||||
expect.objectContaining({
|
||||
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 () => {
|
||||
|
||||
@@ -29,9 +29,9 @@ function isTerminalEvent(payload: Record<string, unknown>): boolean {
|
||||
|| type === 'error';
|
||||
}
|
||||
|
||||
function isFailureTerminalEvent(payload: Record<string, unknown>): boolean {
|
||||
function isRuntimeErrorEvent(payload: Record<string, unknown>): boolean {
|
||||
const type = asTrimmedString(payload.type);
|
||||
return type === 'response.failed' || type === 'response.incomplete' || type === 'error';
|
||||
return type === 'error';
|
||||
}
|
||||
|
||||
function asFiniteNumber(value: unknown): number | undefined {
|
||||
@@ -262,7 +262,7 @@ async function sendSessionRequest(
|
||||
events.push(parsed);
|
||||
if (!isTerminalEvent(parsed)) return;
|
||||
if (settled) return;
|
||||
if (isFailureTerminalEvent(parsed)) {
|
||||
if (isRuntimeErrorEvent(parsed)) {
|
||||
settled = true;
|
||||
cleanup();
|
||||
clearSessionSocket(session, socket);
|
||||
|
||||
@@ -264,6 +264,7 @@ export async function handleChatSurfaceRequest(
|
||||
};
|
||||
let startTime = Date.now();
|
||||
const leaseResult = await acquireSurfaceChannelLease({
|
||||
stickySessionKey,
|
||||
selected,
|
||||
});
|
||||
if (leaseResult.status === 'timeout') {
|
||||
@@ -789,6 +790,7 @@ export async function handleClaudeCountTokensSurfaceRequest(
|
||||
const oauth = getOauthInfoFromAccount(selected.account);
|
||||
const startTime = Date.now();
|
||||
const leaseResult = await acquireSurfaceChannelLease({
|
||||
stickySessionKey,
|
||||
selected,
|
||||
});
|
||||
if (leaseResult.status === 'timeout') {
|
||||
|
||||
@@ -380,6 +380,7 @@ export async function handleOpenAiResponsesSurfaceRequest(
|
||||
|
||||
const startTime = Date.now();
|
||||
const leaseResult = await acquireSurfaceChannelLease({
|
||||
stickySessionKey,
|
||||
selected,
|
||||
});
|
||||
if (leaseResult.status === 'timeout') {
|
||||
|
||||
@@ -187,13 +187,17 @@ export function clearSurfaceStickyChannel(input: {
|
||||
}
|
||||
|
||||
export async function acquireSurfaceChannelLease(input: {
|
||||
stickySessionKey?: string | null;
|
||||
selected: {
|
||||
channel: { id: number };
|
||||
account?: { extraConfig?: string | null } | null;
|
||||
};
|
||||
}) {
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -52,6 +52,9 @@ describe('settings and auth events', () => {
|
||||
config.logCleanupUsageLogsEnabled = false;
|
||||
config.logCleanupProgramLogsEnabled = false;
|
||||
config.logCleanupRetentionDays = 30;
|
||||
config.codexUpstreamWebsocketEnabled = false;
|
||||
config.proxySessionChannelConcurrencyLimit = 2;
|
||||
config.proxySessionChannelQueueWaitMs = 1500;
|
||||
config.routingFallbackUnitCost = 1;
|
||||
(config as any).telegramEnabled = false;
|
||||
(config as any).telegramApiBaseUrl = 'https://api.telegram.org';
|
||||
@@ -111,6 +114,38 @@ describe('settings and auth events', () => {
|
||||
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 () => {
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
|
||||
@@ -38,6 +38,9 @@ type RoutingWeights = typeof config.routingWeights;
|
||||
interface RuntimeSettingsBody {
|
||||
proxyToken?: string;
|
||||
systemProxyUrl?: string;
|
||||
codexUpstreamWebsocketEnabled?: boolean;
|
||||
proxySessionChannelConcurrencyLimit?: number;
|
||||
proxySessionChannelQueueWaitMs?: number;
|
||||
checkinCron?: string;
|
||||
checkinScheduleMode?: 'cron' | 'interval';
|
||||
checkinIntervalHours?: number;
|
||||
@@ -381,6 +384,11 @@ function applyImportedSettingToRuntime(key: string, value: unknown) {
|
||||
config.systemProxyUrl = normalizeSiteProxyUrl(value) || '';
|
||||
return;
|
||||
}
|
||||
case 'codex_upstream_websocket_enabled': {
|
||||
if (typeof value !== 'boolean') return;
|
||||
config.codexUpstreamWebsocketEnabled = value;
|
||||
return;
|
||||
}
|
||||
case 'proxy_error_keywords': {
|
||||
try {
|
||||
config.proxyErrorKeywords = parseProxyErrorKeywords(value);
|
||||
@@ -389,6 +397,18 @@ function applyImportedSettingToRuntime(key: string, value: unknown) {
|
||||
}
|
||||
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': {
|
||||
try {
|
||||
config.proxyEmptyContentFailEnabled = parseBooleanFlag(value, '空内容判定失败开关');
|
||||
@@ -557,6 +577,9 @@ function getRuntimeSettingsResponse(currentAdminIp = '') {
|
||||
logCleanupUsageLogsEnabled: config.logCleanupUsageLogsEnabled,
|
||||
logCleanupProgramLogsEnabled: config.logCleanupProgramLogsEnabled,
|
||||
logCleanupRetentionDays: config.logCleanupRetentionDays,
|
||||
codexUpstreamWebsocketEnabled: config.codexUpstreamWebsocketEnabled,
|
||||
proxySessionChannelConcurrencyLimit: config.proxySessionChannelConcurrencyLimit,
|
||||
proxySessionChannelQueueWaitMs: config.proxySessionChannelQueueWaitMs,
|
||||
routingFallbackUnitCost: config.routingFallbackUnitCost,
|
||||
routingWeights: config.routingWeights,
|
||||
webhookUrl: config.webhookUrl,
|
||||
@@ -940,6 +963,50 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
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) {
|
||||
let nextKeywords: string[] = [];
|
||||
try {
|
||||
|
||||
@@ -20,6 +20,9 @@ const refreshOauthAccessTokenSingleflightMock = vi.fn();
|
||||
const recordOauthQuotaResetHintMock = vi.fn();
|
||||
const insertedProxyLogs: Record<string, unknown>[] = [];
|
||||
const originalProxyEmptyContentFailEnabled = config.proxyEmptyContentFailEnabled;
|
||||
const originalProxyStickySessionEnabled = config.proxyStickySessionEnabled;
|
||||
const originalProxySessionChannelConcurrencyLimit = config.proxySessionChannelConcurrencyLimit;
|
||||
const originalProxySessionChannelQueueWaitMs = config.proxySessionChannelQueueWaitMs;
|
||||
const dbInsertMock = vi.fn((_arg?: any) => ({
|
||||
values: (values: Record<string, unknown>) => {
|
||||
insertedProxyLogs.push(values);
|
||||
@@ -136,6 +139,9 @@ describe('responses proxy codex oauth refresh', () => {
|
||||
beforeEach(() => {
|
||||
resetCodexHttpSessionQueue();
|
||||
config.proxyEmptyContentFailEnabled = false;
|
||||
config.proxyStickySessionEnabled = originalProxyStickySessionEnabled;
|
||||
config.proxySessionChannelConcurrencyLimit = originalProxySessionChannelConcurrencyLimit;
|
||||
config.proxySessionChannelQueueWaitMs = originalProxySessionChannelQueueWaitMs;
|
||||
fetchMock.mockReset();
|
||||
selectChannelMock.mockReset();
|
||||
selectNextChannelMock.mockReset();
|
||||
@@ -180,6 +186,9 @@ describe('responses proxy codex oauth refresh', () => {
|
||||
|
||||
afterAll(async () => {
|
||||
config.proxyEmptyContentFailEnabled = originalProxyEmptyContentFailEnabled;
|
||||
config.proxyStickySessionEnabled = originalProxyStickySessionEnabled;
|
||||
config.proxySessionChannelConcurrencyLimit = originalProxySessionChannelConcurrencyLimit;
|
||||
config.proxySessionChannelQueueWaitMs = originalProxySessionChannelQueueWaitMs;
|
||||
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');
|
||||
});
|
||||
|
||||
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 () => {
|
||||
fetchMock.mockResolvedValue(new Response(JSON.stringify({
|
||||
error: {
|
||||
|
||||
@@ -248,6 +248,7 @@ function createClientSocketForPath(path: string, headers: Record<string, string>
|
||||
|
||||
describe('responses websocket transport', () => {
|
||||
const originalCodexResponsesWebsocketBeta = config.codexResponsesWebsocketBeta;
|
||||
const originalCodexUpstreamWebsocketEnabled = config.codexUpstreamWebsocketEnabled;
|
||||
let app: FastifyInstance;
|
||||
let baseUrl: string;
|
||||
let upstreamServer: WebSocketServer;
|
||||
@@ -328,6 +329,7 @@ describe('responses websocket transport', () => {
|
||||
upstreamUpgradeHeaders = {};
|
||||
upstreamRequests = [];
|
||||
(config as any).codexResponsesWebsocketBeta = originalCodexResponsesWebsocketBeta;
|
||||
(config as any).codexUpstreamWebsocketEnabled = true;
|
||||
rejectedUpgradeStatus = 426;
|
||||
rejectedUpgradeStatusText = 'Upgrade Required';
|
||||
rejectedUpgradeBody = 'Upgrade Required';
|
||||
@@ -363,6 +365,7 @@ describe('responses websocket transport', () => {
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
(config as any).codexUpstreamWebsocketEnabled = originalCodexUpstreamWebsocketEnabled;
|
||||
await new Promise<void>((resolve) => rejectedUpgradeServer.close(() => resolve()));
|
||||
await new Promise<void>((resolve) => upstreamServer.close(() => resolve()));
|
||||
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 () => {
|
||||
const selectedChannel = createSelectedChannel({
|
||||
extraConfig: JSON.stringify({
|
||||
credentialMode: 'session',
|
||||
websockets: false,
|
||||
oauth: {
|
||||
provider: 'codex',
|
||||
accountId: 'chatgpt-account-123',
|
||||
email: 'codex-user@example.com',
|
||||
},
|
||||
}),
|
||||
});
|
||||
selectChannelMock.mockReturnValue(selectedChannel);
|
||||
previewSelectedChannelMock.mockResolvedValue(selectedChannel);
|
||||
it('falls back to the HTTP responses route when codex upstream websocket is globally disabled', async () => {
|
||||
(config as any).codexUpstreamWebsocketEnabled = false;
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(createSseResponse([
|
||||
'event: response.completed\n',
|
||||
|
||||
@@ -14,6 +14,7 @@ import { tokenRouter } from '../../services/tokenRouter.js';
|
||||
import { buildOauthProviderHeaders } from '../../services/oauth/service.js';
|
||||
import { openAiResponsesTransformer } from '../../transformers/openai/responses/index.js';
|
||||
import { buildUpstreamEndpointRequest } from './upstreamEndpoint.js';
|
||||
import { config } from '../../config.js';
|
||||
|
||||
const installedApps = new WeakSet<FastifyInstance>();
|
||||
const WS_TURN_STATE_HEADER = 'x-codex-turn-state';
|
||||
@@ -56,32 +57,6 @@ function headerValueToTrimmedString(value: unknown): string {
|
||||
return '';
|
||||
}
|
||||
|
||||
function toBooleanLike(value: unknown): boolean | null {
|
||||
if (typeof value === 'boolean') return value;
|
||||
if (typeof value === 'string') {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (normalized === 'true' || normalized === '1' || normalized === 'yes' || normalized === 'on') return true;
|
||||
if (normalized === 'false' || normalized === '0' || normalized === 'no' || normalized === 'off') return false;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseExtraConfigRecord(extraConfig: unknown): Record<string, unknown> | null {
|
||||
if (typeof extraConfig !== 'string') return null;
|
||||
try {
|
||||
const parsed = JSON.parse(extraConfig);
|
||||
return isRecord(parsed) ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readNestedRecord(value: unknown, key: string): Record<string, unknown> | null {
|
||||
if (!isRecord(value)) return null;
|
||||
const nested = value[key];
|
||||
return isRecord(nested) ? nested : null;
|
||||
}
|
||||
|
||||
function selectedChannelModelMatches(
|
||||
selectedChannel: SelectedChannel | null,
|
||||
requestModel: string,
|
||||
@@ -101,23 +76,7 @@ function selectedChannelSupportsCodexWebsocketTransport(
|
||||
const platform = asTrimmedString(selectedChannel.site?.platform).toLowerCase();
|
||||
if (platform !== 'codex') return false;
|
||||
if (!selectedChannelModelMatches(selectedChannel, requestModel)) return false;
|
||||
|
||||
const extraConfig = parseExtraConfigRecord(selectedChannel.account.extraConfig);
|
||||
const oauth = readNestedRecord(extraConfig, 'oauth');
|
||||
const providerData = readNestedRecord(oauth, 'providerData');
|
||||
const candidateFlags = [
|
||||
extraConfig?.websockets,
|
||||
readNestedRecord(extraConfig, 'attributes')?.websockets,
|
||||
readNestedRecord(extraConfig, 'metadata')?.websockets,
|
||||
providerData?.websockets,
|
||||
readNestedRecord(providerData, 'attributes')?.websockets,
|
||||
readNestedRecord(providerData, 'metadata')?.websockets,
|
||||
];
|
||||
for (const candidate of candidateFlags) {
|
||||
const parsed = toBooleanLike(candidate);
|
||||
if (parsed !== null) return parsed;
|
||||
}
|
||||
return true;
|
||||
return !!config.codexUpstreamWebsocketEnabled;
|
||||
}
|
||||
|
||||
function selectedChannelSupportsIncrementalInput(
|
||||
|
||||
Reference in New Issue
Block a user