fix: harden codex runtime routing and oauth recovery
This commit is contained in:
@@ -4,7 +4,6 @@ import { config } from '../../config.js';
|
||||
|
||||
const CODEX_CLIENT_VERSION = '0.101.0';
|
||||
const CODEX_DEFAULT_USER_AGENT = 'codex_cli_rs/0.101.0 (Mac OS 26.0.1; arm64) Apple_Terminal/464';
|
||||
const CODEX_RESPONSES_WEBSOCKET_BETA = 'responses_websockets=2026-02-06';
|
||||
|
||||
function asTrimmedString(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
@@ -81,7 +80,7 @@ function buildCodexRuntimeHeaders(input: {
|
||||
const codexTurnMetadata = getInputHeader(input.baseHeaders, 'x-codex-turn-metadata');
|
||||
const timingMetrics = getInputHeader(input.baseHeaders, 'x-responsesapi-include-timing-metrics');
|
||||
const openAiBeta = getInputHeader(input.baseHeaders, 'openai-beta')
|
||||
|| (websocketTransport ? CODEX_RESPONSES_WEBSOCKET_BETA : null);
|
||||
|| (websocketTransport ? asTrimmedString(config.codexResponsesWebsocketBeta) : null);
|
||||
const explicitSessionId = asTrimmedString(input.explicitSessionId);
|
||||
const continuityKey = asTrimmedString(input.continuityKey);
|
||||
const sessionId = (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const CODEX_RESPONSES_WEBSOCKET_BETA = 'responses_websockets=2026-02-06';
|
||||
import { config } from '../../config.js';
|
||||
|
||||
function getHeaderValue(headers: Record<string, string>, key: string): string {
|
||||
const expected = key.trim().toLowerCase();
|
||||
@@ -11,13 +11,14 @@ function getHeaderValue(headers: Record<string, string>, key: string): string {
|
||||
|
||||
export function buildCodexWebsocketHandshakeHeaders(headers: Record<string, string>): Record<string, string> {
|
||||
const next = { ...headers };
|
||||
const websocketBeta = (config.codexResponsesWebsocketBeta || '').trim() || 'responses_websockets=2026-02-06';
|
||||
const openAiBeta = getHeaderValue(next, 'openai-beta').trim();
|
||||
if (!openAiBeta) {
|
||||
next['OpenAI-Beta'] = CODEX_RESPONSES_WEBSOCKET_BETA;
|
||||
next['OpenAI-Beta'] = websocketBeta;
|
||||
return next;
|
||||
}
|
||||
if (!openAiBeta.includes('responses_websockets=')) {
|
||||
next['OpenAI-Beta'] = `${openAiBeta},${CODEX_RESPONSES_WEBSOCKET_BETA}`;
|
||||
next['OpenAI-Beta'] = `${openAiBeta},${websocketBeta}`;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ describe('codexWebsocketRuntime', () => {
|
||||
let upstreamWsUrl: string;
|
||||
let upstreamConnectionCount = 0;
|
||||
let upstreamRequests: Record<string, unknown>[] = [];
|
||||
let upstreamMessageHandler: (socket: import('ws').WebSocket, parsed: Record<string, unknown>, requestIndex: number) => void;
|
||||
|
||||
beforeAll(async () => {
|
||||
upstreamServer = new WebSocketServer({ port: 0 });
|
||||
@@ -15,22 +16,7 @@ describe('codexWebsocketRuntime', () => {
|
||||
socket.on('message', (payload) => {
|
||||
const parsed = JSON.parse(String(payload)) as Record<string, unknown>;
|
||||
upstreamRequests.push(parsed);
|
||||
const responseId = `resp-${upstreamRequests.length}`;
|
||||
socket.send(JSON.stringify({
|
||||
type: 'response.completed',
|
||||
response: {
|
||||
id: responseId,
|
||||
object: 'response',
|
||||
model: parsed.model || 'gpt-5.4',
|
||||
status: 'completed',
|
||||
output: [],
|
||||
usage: {
|
||||
input_tokens: 1,
|
||||
output_tokens: 1,
|
||||
total_tokens: 2,
|
||||
},
|
||||
},
|
||||
}));
|
||||
upstreamMessageHandler(socket, parsed, upstreamRequests.length);
|
||||
});
|
||||
});
|
||||
await new Promise<void>((resolve) => upstreamServer.once('listening', () => resolve()));
|
||||
@@ -41,6 +27,24 @@ describe('codexWebsocketRuntime', () => {
|
||||
beforeEach(() => {
|
||||
upstreamConnectionCount = 0;
|
||||
upstreamRequests = [];
|
||||
upstreamMessageHandler = (socket, parsed, requestIndex) => {
|
||||
const responseId = `resp-${requestIndex}`;
|
||||
socket.send(JSON.stringify({
|
||||
type: 'response.completed',
|
||||
response: {
|
||||
id: responseId,
|
||||
object: 'response',
|
||||
model: parsed.model || 'gpt-5.4',
|
||||
status: 'completed',
|
||||
output: [],
|
||||
usage: {
|
||||
input_tokens: 1,
|
||||
output_tokens: 1,
|
||||
total_tokens: 2,
|
||||
},
|
||||
},
|
||||
}));
|
||||
};
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -134,4 +138,61 @@ describe('codexWebsocketRuntime', () => {
|
||||
expect(upstreamConnectionCount).toBe(2);
|
||||
await runtime.closeSession('exec-session-close');
|
||||
});
|
||||
|
||||
it('treats response.incomplete as a terminal websocket failure instead of waiting for socket close', async () => {
|
||||
upstreamMessageHandler = (socket, parsed) => {
|
||||
socket.send(JSON.stringify({
|
||||
type: 'response.incomplete',
|
||||
response: {
|
||||
id: 'resp-incomplete',
|
||||
model: parsed.model || 'gpt-5.4',
|
||||
status: 'incomplete',
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
const { createCodexWebsocketRuntime, CodexWebsocketRuntimeError } = await import('./codexWebsocketRuntime.js');
|
||||
const runtime = createCodexWebsocketRuntime();
|
||||
|
||||
await expect(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: [],
|
||||
},
|
||||
})).rejects.toBeInstanceOf(CodexWebsocketRuntimeError);
|
||||
});
|
||||
|
||||
it('treats top-level error frames as terminal websocket failures', async () => {
|
||||
upstreamMessageHandler = (socket) => {
|
||||
socket.send(JSON.stringify({
|
||||
type: 'error',
|
||||
error: {
|
||||
message: 'account mismatch',
|
||||
type: 'invalid_request_error',
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
const { createCodexWebsocketRuntime, CodexWebsocketRuntimeError } = await import('./codexWebsocketRuntime.js');
|
||||
const runtime = createCodexWebsocketRuntime();
|
||||
|
||||
await expect(runtime.sendRequest({
|
||||
sessionId: 'exec-session-error',
|
||||
requestUrl: upstreamWsUrl,
|
||||
headers: {
|
||||
Authorization: 'Bearer oauth-access-token',
|
||||
'OpenAI-Beta': 'responses_websockets=2026-02-06',
|
||||
},
|
||||
body: {
|
||||
model: 'gpt-5.4',
|
||||
input: [],
|
||||
},
|
||||
})).rejects.toBeInstanceOf(CodexWebsocketRuntimeError);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,7 +23,31 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
|
||||
function isTerminalEvent(payload: Record<string, unknown>): boolean {
|
||||
const type = asTrimmedString(payload.type);
|
||||
return type === 'response.completed' || type === 'response.failed';
|
||||
return type === 'response.completed'
|
||||
|| type === 'response.failed'
|
||||
|| type === 'response.incomplete'
|
||||
|| type === 'error';
|
||||
}
|
||||
|
||||
function isFailureTerminalEvent(payload: Record<string, unknown>): boolean {
|
||||
const type = asTrimmedString(payload.type);
|
||||
return type === 'response.failed' || type === 'response.incomplete' || type === 'error';
|
||||
}
|
||||
|
||||
function extractTerminalErrorMessage(payload: Record<string, unknown>): string {
|
||||
const type = asTrimmedString(payload.type);
|
||||
if (type === 'error' && isRecord(payload.error)) {
|
||||
return asTrimmedString(payload.error.message) || 'upstream websocket error';
|
||||
}
|
||||
if ((type === 'response.failed' || type === 'response.incomplete') && isRecord(payload.response)) {
|
||||
if (isRecord(payload.response.error)) {
|
||||
return asTrimmedString(payload.response.error.message) || `upstream ${type}`;
|
||||
}
|
||||
if (isRecord(payload.response.incomplete_details)) {
|
||||
return asTrimmedString(payload.response.incomplete_details.reason) || `upstream ${type}`;
|
||||
}
|
||||
}
|
||||
return `upstream ${type || 'websocket error'}`;
|
||||
}
|
||||
|
||||
export class CodexWebsocketRuntimeError extends Error {
|
||||
@@ -212,6 +236,18 @@ async function sendSessionRequest(
|
||||
events.push(parsed);
|
||||
if (!isTerminalEvent(parsed)) return;
|
||||
if (settled) return;
|
||||
if (isFailureTerminalEvent(parsed)) {
|
||||
settled = true;
|
||||
cleanup();
|
||||
clearSessionSocket(session, socket);
|
||||
void closeSocket(socket);
|
||||
reject(new CodexWebsocketRuntimeError(extractTerminalErrorMessage(parsed), {
|
||||
events: [...events],
|
||||
status: typeof parsed.status === 'number' && Number.isFinite(parsed.status) ? parsed.status : undefined,
|
||||
payload: parsed,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
cleanup();
|
||||
resolve({
|
||||
|
||||
@@ -234,7 +234,7 @@ export async function handleChatSurfaceRequest(
|
||||
dispatchRequest,
|
||||
});
|
||||
const tryRecover = async (ctx: Parameters<NonNullable<typeof endpointStrategy.tryRecover>>[0]) => {
|
||||
if (ctx.response.status === 401 && oauth) {
|
||||
if ((ctx.response.status === 401 || ctx.response.status === 403) && oauth) {
|
||||
try {
|
||||
const refreshed = await refreshOauthAccessTokenSingleflight(selected.account.id);
|
||||
selected.tokenValue = refreshed.accessToken;
|
||||
@@ -916,7 +916,7 @@ export async function handleClaudeCountTokensSurfaceRequest(
|
||||
}, resolveChannelProxyUrl(selected.site, selected.account.extraConfig)),
|
||||
});
|
||||
|
||||
if (upstream.status === 401 && oauth) {
|
||||
if ((upstream.status === 401 || upstream.status === 403) && oauth) {
|
||||
try {
|
||||
const refreshed = await refreshOauthAccessTokenSingleflight(selected.account.id);
|
||||
selected.tokenValue = refreshed.accessToken;
|
||||
|
||||
@@ -137,13 +137,6 @@ function carriesResponsesFileUrlInput(value: unknown): boolean {
|
||||
|
||||
type UsageSummary = ReturnType<typeof parseProxyUsage>;
|
||||
|
||||
function deriveCodexExplicitSessionId(body: Record<string, unknown>): string | null {
|
||||
const promptCacheKey = typeof body.prompt_cache_key === 'string'
|
||||
? body.prompt_cache_key.trim()
|
||||
: '';
|
||||
return promptCacheKey || null;
|
||||
}
|
||||
|
||||
export async function handleOpenAiResponsesSurfaceRequest(
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
@@ -274,7 +267,6 @@ export async function handleOpenAiResponsesSurfaceRequest(
|
||||
);
|
||||
const buildEndpointRequest = (endpoint: 'chat' | 'messages' | 'responses') => {
|
||||
const upstreamStream = isStream || (isCodexSite && endpoint === 'responses');
|
||||
const codexExplicitSessionId = deriveCodexExplicitSessionId(normalizedResponsesBody);
|
||||
const endpointRequest = buildUpstreamEndpointRequest({
|
||||
endpoint,
|
||||
modelName,
|
||||
@@ -289,7 +281,6 @@ export async function handleOpenAiResponsesSurfaceRequest(
|
||||
responsesOriginalBody: normalizedResponsesBody,
|
||||
downstreamHeaders: request.headers as Record<string, unknown>,
|
||||
providerHeaders: buildProviderHeaders(),
|
||||
codexExplicitSessionId,
|
||||
});
|
||||
const upstreamPath = (
|
||||
isCompactRequest && endpoint === 'responses'
|
||||
@@ -323,7 +314,7 @@ export async function handleOpenAiResponsesSurfaceRequest(
|
||||
dispatchRequest,
|
||||
});
|
||||
const tryRecover = async (ctx: Parameters<NonNullable<typeof endpointStrategy.tryRecover>>[0]) => {
|
||||
if (ctx.response.status === 401 && oauth) {
|
||||
if ((ctx.response.status === 401 || ctx.response.status === 403) && oauth) {
|
||||
try {
|
||||
const refreshed = await refreshOauthAccessTokenSingleflight(selected.account.id);
|
||||
selected.tokenValue = refreshed.accessToken;
|
||||
|
||||
Reference in New Issue
Block a user