forked from boos/BoosAPI
fix: harden codex runtime routing and oauth recovery
This commit is contained in:
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user