complete codex cliproxyapi parity
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
const CODEX_RESPONSES_WEBSOCKET_BETA = 'responses_websockets=2026-02-06';
|
||||
|
||||
function getHeaderValue(headers: Record<string, string>, key: string): string {
|
||||
const expected = key.trim().toLowerCase();
|
||||
for (const [candidateKey, candidateValue] of Object.entries(headers)) {
|
||||
if (candidateKey.trim().toLowerCase() !== expected) continue;
|
||||
return candidateValue;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
export function buildCodexWebsocketHandshakeHeaders(headers: Record<string, string>): Record<string, string> {
|
||||
const next = { ...headers };
|
||||
const openAiBeta = getHeaderValue(next, 'openai-beta').trim();
|
||||
if (!openAiBeta) {
|
||||
next['OpenAI-Beta'] = CODEX_RESPONSES_WEBSOCKET_BETA;
|
||||
return next;
|
||||
}
|
||||
if (!openAiBeta.includes('responses_websockets=')) {
|
||||
next['OpenAI-Beta'] = `${openAiBeta},${CODEX_RESPONSES_WEBSOCKET_BETA}`;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
export function buildCodexWebsocketRequestBody(body: Record<string, unknown>): Record<string, unknown> {
|
||||
return {
|
||||
type: 'response.create',
|
||||
...body,
|
||||
};
|
||||
}
|
||||
|
||||
export function toCodexWebsocketUrl(requestUrl: string): string {
|
||||
const parsed = new URL(requestUrl);
|
||||
if (parsed.protocol === 'https:') parsed.protocol = 'wss:';
|
||||
if (parsed.protocol === 'http:') parsed.protocol = 'ws:';
|
||||
return parsed.toString();
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { AddressInfo } from 'node:net';
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { WebSocketServer } from 'ws';
|
||||
|
||||
describe('codexWebsocketRuntime', () => {
|
||||
let upstreamServer: WebSocketServer;
|
||||
let upstreamWsUrl: string;
|
||||
let upstreamConnectionCount = 0;
|
||||
let upstreamRequests: Record<string, unknown>[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
upstreamServer = new WebSocketServer({ port: 0 });
|
||||
upstreamServer.on('connection', (socket) => {
|
||||
upstreamConnectionCount += 1;
|
||||
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,
|
||||
},
|
||||
},
|
||||
}));
|
||||
});
|
||||
});
|
||||
await new Promise<void>((resolve) => upstreamServer.once('listening', () => resolve()));
|
||||
const address = upstreamServer.address() as AddressInfo;
|
||||
upstreamWsUrl = `ws://127.0.0.1:${address.port}/backend-api/codex/responses`;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
upstreamConnectionCount = 0;
|
||||
upstreamRequests = [];
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await new Promise<void>((resolve) => upstreamServer.close(() => resolve()));
|
||||
});
|
||||
|
||||
it('reuses the same upstream websocket connection across turns for one execution session', async () => {
|
||||
const { createCodexWebsocketRuntime } = await import('./codexWebsocketRuntime.js');
|
||||
const runtime = createCodexWebsocketRuntime();
|
||||
|
||||
const first = await runtime.sendRequest({
|
||||
sessionId: 'exec-session-1',
|
||||
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-1',
|
||||
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(first.events[0]).toMatchObject({
|
||||
type: 'response.completed',
|
||||
response: { id: 'resp-1' },
|
||||
});
|
||||
expect(second.events[0]).toMatchObject({
|
||||
type: 'response.completed',
|
||||
response: { id: 'resp-2' },
|
||||
});
|
||||
expect(upstreamConnectionCount).toBe(1);
|
||||
expect(upstreamRequests).toHaveLength(2);
|
||||
expect(upstreamRequests[0]).toMatchObject({
|
||||
type: 'response.create',
|
||||
model: 'gpt-5.4',
|
||||
});
|
||||
expect(upstreamRequests[1]).toMatchObject({
|
||||
type: 'response.create',
|
||||
previous_response_id: 'resp-1',
|
||||
});
|
||||
|
||||
await runtime.closeSession('exec-session-1');
|
||||
});
|
||||
|
||||
it('closes the upstream websocket when the execution session is closed explicitly', async () => {
|
||||
const { createCodexWebsocketRuntime } = await import('./codexWebsocketRuntime.js');
|
||||
const runtime = createCodexWebsocketRuntime();
|
||||
|
||||
await runtime.sendRequest({
|
||||
sessionId: 'exec-session-close',
|
||||
requestUrl: upstreamWsUrl,
|
||||
headers: {
|
||||
Authorization: 'Bearer oauth-access-token',
|
||||
'OpenAI-Beta': 'responses_websockets=2026-02-06',
|
||||
},
|
||||
body: {
|
||||
model: 'gpt-5.4',
|
||||
input: [],
|
||||
},
|
||||
});
|
||||
await runtime.closeSession('exec-session-close');
|
||||
|
||||
await runtime.sendRequest({
|
||||
sessionId: 'exec-session-close',
|
||||
requestUrl: upstreamWsUrl,
|
||||
headers: {
|
||||
Authorization: 'Bearer oauth-access-token',
|
||||
'OpenAI-Beta': 'responses_websockets=2026-02-06',
|
||||
},
|
||||
body: {
|
||||
model: 'gpt-5.4',
|
||||
input: [],
|
||||
},
|
||||
});
|
||||
|
||||
expect(upstreamConnectionCount).toBe(2);
|
||||
await runtime.closeSession('exec-session-close');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,284 @@
|
||||
import type { IncomingMessage } from 'node:http';
|
||||
import WebSocket from 'ws';
|
||||
import {
|
||||
buildCodexWebsocketHandshakeHeaders,
|
||||
buildCodexWebsocketRequestBody,
|
||||
toCodexWebsocketUrl,
|
||||
} from './codexWebsocketHeaders.js';
|
||||
import { createCodexWebsocketSessionStore } from './codexWebsocketSessionStore.js';
|
||||
import type {
|
||||
CodexWebsocketRuntimeResult,
|
||||
CodexWebsocketRuntimeSendInput,
|
||||
CodexWebsocketSession,
|
||||
CodexWebsocketSessionStore,
|
||||
} from './types.js';
|
||||
|
||||
function asTrimmedString(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isTerminalEvent(payload: Record<string, unknown>): boolean {
|
||||
const type = asTrimmedString(payload.type);
|
||||
return type === 'response.completed' || type === 'response.failed';
|
||||
}
|
||||
|
||||
export class CodexWebsocketRuntimeError extends Error {
|
||||
events: Array<Record<string, unknown>>;
|
||||
status?: number;
|
||||
payload?: unknown;
|
||||
|
||||
constructor(
|
||||
message: string,
|
||||
options?: {
|
||||
events?: Array<Record<string, unknown>>;
|
||||
status?: number;
|
||||
payload?: unknown;
|
||||
},
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'CodexWebsocketRuntimeError';
|
||||
this.events = options?.events ?? [];
|
||||
this.status = options?.status;
|
||||
this.payload = options?.payload;
|
||||
}
|
||||
}
|
||||
|
||||
function tryParseJson(raw: string): unknown {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return undefined;
|
||||
try {
|
||||
return JSON.parse(trimmed);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function readUnexpectedResponseBody(response: IncomingMessage): Promise<string> {
|
||||
return await new Promise<string>((resolve) => {
|
||||
const chunks: Buffer[] = [];
|
||||
response.on('data', (chunk) => {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||
});
|
||||
response.once('end', () => {
|
||||
resolve(Buffer.concat(chunks).toString('utf8'));
|
||||
});
|
||||
response.once('error', () => {
|
||||
resolve('');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForSocketOpen(socket: WebSocket): Promise<void> {
|
||||
if (socket.readyState === WebSocket.OPEN) return;
|
||||
if (socket.readyState !== WebSocket.CONNECTING) {
|
||||
throw new Error('upstream websocket is not open');
|
||||
}
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const cleanup = () => {
|
||||
socket.off('open', onOpen);
|
||||
socket.off('error', onError);
|
||||
socket.off('close', onClose);
|
||||
socket.off('unexpected-response', onUnexpectedResponse);
|
||||
};
|
||||
const onOpen = () => {
|
||||
cleanup();
|
||||
resolve();
|
||||
};
|
||||
const onError = (error: Error) => {
|
||||
cleanup();
|
||||
reject(error);
|
||||
};
|
||||
const onClose = () => {
|
||||
cleanup();
|
||||
reject(new Error('upstream websocket closed before opening'));
|
||||
};
|
||||
const onUnexpectedResponse = (_request: unknown, response: IncomingMessage) => {
|
||||
void readUnexpectedResponseBody(response).then((body) => {
|
||||
cleanup();
|
||||
reject(new CodexWebsocketRuntimeError(
|
||||
body.trim() || response.statusMessage || `upstream websocket upgrade failed with status ${response.statusCode || 502}`,
|
||||
{
|
||||
status: response.statusCode || 502,
|
||||
payload: tryParseJson(body),
|
||||
},
|
||||
));
|
||||
});
|
||||
};
|
||||
socket.once('open', onOpen);
|
||||
socket.once('error', onError);
|
||||
socket.once('close', onClose);
|
||||
socket.once('unexpected-response', onUnexpectedResponse);
|
||||
});
|
||||
}
|
||||
|
||||
async function closeSocket(socket: WebSocket | null): Promise<void> {
|
||||
if (!socket) return;
|
||||
if (socket.readyState === WebSocket.CLOSED) return;
|
||||
await new Promise<void>((resolve) => {
|
||||
const onClose = () => resolve();
|
||||
socket.once('close', onClose);
|
||||
try {
|
||||
socket.close();
|
||||
} catch {
|
||||
socket.off('close', onClose);
|
||||
resolve();
|
||||
}
|
||||
setTimeout(() => {
|
||||
socket.off('close', onClose);
|
||||
resolve();
|
||||
}, 200);
|
||||
});
|
||||
}
|
||||
|
||||
function clearSessionSocket(session: CodexWebsocketSession, socket: WebSocket): void {
|
||||
if (session.socket !== socket) return;
|
||||
session.socket = null;
|
||||
session.socketUrl = null;
|
||||
}
|
||||
|
||||
async function ensureSessionSocket(
|
||||
session: CodexWebsocketSession,
|
||||
input: CodexWebsocketRuntimeSendInput,
|
||||
): Promise<{ socket: WebSocket; reusedSession: boolean }> {
|
||||
const requestUrl = toCodexWebsocketUrl(input.requestUrl);
|
||||
const existing = session.socket;
|
||||
if (
|
||||
existing
|
||||
&& session.socketUrl === requestUrl
|
||||
&& existing.readyState === WebSocket.OPEN
|
||||
) {
|
||||
return {
|
||||
socket: existing,
|
||||
reusedSession: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (existing) {
|
||||
await closeSocket(existing);
|
||||
clearSessionSocket(session, existing);
|
||||
}
|
||||
|
||||
const nextSocket = new WebSocket(requestUrl, {
|
||||
headers: buildCodexWebsocketHandshakeHeaders(input.headers),
|
||||
});
|
||||
await waitForSocketOpen(nextSocket);
|
||||
session.socket = nextSocket;
|
||||
session.socketUrl = requestUrl;
|
||||
|
||||
nextSocket.on('close', () => {
|
||||
clearSessionSocket(session, nextSocket);
|
||||
});
|
||||
nextSocket.on('error', () => {
|
||||
clearSessionSocket(session, nextSocket);
|
||||
});
|
||||
|
||||
return {
|
||||
socket: nextSocket,
|
||||
reusedSession: false,
|
||||
};
|
||||
}
|
||||
|
||||
async function sendSessionRequest(
|
||||
session: CodexWebsocketSession,
|
||||
input: CodexWebsocketRuntimeSendInput,
|
||||
): Promise<CodexWebsocketRuntimeResult> {
|
||||
const { socket, reusedSession } = await ensureSessionSocket(session, input);
|
||||
const events: Array<Record<string, unknown>> = [];
|
||||
|
||||
return new Promise<CodexWebsocketRuntimeResult>((resolve, reject) => {
|
||||
let settled = false;
|
||||
|
||||
const cleanup = () => {
|
||||
socket.off('message', onMessage);
|
||||
socket.off('error', onError);
|
||||
socket.off('close', onClose);
|
||||
};
|
||||
|
||||
const rejectWith = (message: string) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanup();
|
||||
reject(new CodexWebsocketRuntimeError(message, { events: [...events] }));
|
||||
};
|
||||
|
||||
const onMessage = (payload: WebSocket.RawData) => {
|
||||
try {
|
||||
const parsed = JSON.parse(String(payload));
|
||||
if (!isRecord(parsed)) return;
|
||||
events.push(parsed);
|
||||
if (!isTerminalEvent(parsed)) return;
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanup();
|
||||
resolve({
|
||||
events: [...events],
|
||||
reusedSession,
|
||||
});
|
||||
} catch {
|
||||
// Ignore malformed frames and wait for a terminal event.
|
||||
}
|
||||
};
|
||||
|
||||
const onError = (error: Error) => {
|
||||
clearSessionSocket(session, socket);
|
||||
rejectWith(error.message || 'upstream websocket error');
|
||||
};
|
||||
|
||||
const onClose = () => {
|
||||
clearSessionSocket(session, socket);
|
||||
rejectWith('stream closed before response.completed');
|
||||
};
|
||||
|
||||
socket.on('message', onMessage);
|
||||
socket.once('error', onError);
|
||||
socket.once('close', onClose);
|
||||
|
||||
socket.send(JSON.stringify(buildCodexWebsocketRequestBody(input.body)), (error) => {
|
||||
if (!error) return;
|
||||
clearSessionSocket(session, socket);
|
||||
rejectWith(error.message || 'failed to send upstream websocket request');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function createCodexWebsocketRuntime(input?: {
|
||||
sessionStore?: CodexWebsocketSessionStore;
|
||||
}) {
|
||||
const sessionStore = input?.sessionStore || createCodexWebsocketSessionStore();
|
||||
|
||||
return {
|
||||
async sendRequest(payload: CodexWebsocketRuntimeSendInput): Promise<CodexWebsocketRuntimeResult> {
|
||||
const sessionId = payload.sessionId.trim();
|
||||
if (!sessionId) {
|
||||
throw new CodexWebsocketRuntimeError('missing websocket session id');
|
||||
}
|
||||
|
||||
const session = sessionStore.getOrCreate(sessionId);
|
||||
const run = session.queue
|
||||
.catch(() => undefined)
|
||||
.then(() => sendSessionRequest(session, payload));
|
||||
session.queue = run.then(() => undefined, () => undefined);
|
||||
return run;
|
||||
},
|
||||
|
||||
async closeSession(sessionId: string): Promise<void> {
|
||||
const session = sessionStore.take(sessionId);
|
||||
if (!session) return;
|
||||
await session.queue.catch(() => undefined);
|
||||
await closeSocket(session.socket);
|
||||
session.socket = null;
|
||||
session.socketUrl = null;
|
||||
},
|
||||
|
||||
async closeAllSessions(): Promise<void> {
|
||||
const sessions = sessionStore.list();
|
||||
for (const session of sessions) {
|
||||
await this.closeSession(session.sessionId);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { CodexWebsocketSession, CodexWebsocketSessionStore } from './types.js';
|
||||
|
||||
export function createCodexWebsocketSessionStore(): CodexWebsocketSessionStore {
|
||||
const sessions = new Map<string, CodexWebsocketSession>();
|
||||
|
||||
return {
|
||||
getOrCreate(sessionId) {
|
||||
const normalized = sessionId.trim();
|
||||
const existing = sessions.get(normalized);
|
||||
if (existing) return existing;
|
||||
|
||||
const created: CodexWebsocketSession = {
|
||||
sessionId: normalized,
|
||||
socket: null,
|
||||
socketUrl: null,
|
||||
queue: Promise.resolve(),
|
||||
};
|
||||
sessions.set(normalized, created);
|
||||
return created;
|
||||
},
|
||||
take(sessionId) {
|
||||
const normalized = sessionId.trim();
|
||||
if (!normalized) return null;
|
||||
const existing = sessions.get(normalized) || null;
|
||||
if (existing) {
|
||||
sessions.delete(normalized);
|
||||
}
|
||||
return existing;
|
||||
},
|
||||
list() {
|
||||
return [...sessions.values()];
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type WebSocket from 'ws';
|
||||
|
||||
export type CodexWebsocketRuntimeSendInput = {
|
||||
sessionId: string;
|
||||
requestUrl: string;
|
||||
headers: Record<string, string>;
|
||||
body: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type CodexWebsocketRuntimeResult = {
|
||||
events: Array<Record<string, unknown>>;
|
||||
reusedSession: boolean;
|
||||
};
|
||||
|
||||
export type CodexWebsocketSession = {
|
||||
sessionId: string;
|
||||
socket: WebSocket | null;
|
||||
socketUrl: string | null;
|
||||
queue: Promise<unknown>;
|
||||
};
|
||||
|
||||
export type CodexWebsocketSessionStore = {
|
||||
getOrCreate(sessionId: string): CodexWebsocketSession;
|
||||
take(sessionId: string): CodexWebsocketSession | null;
|
||||
list(): CodexWebsocketSession[];
|
||||
};
|
||||
Reference in New Issue
Block a user