1
0
forked from boos/BoosAPI

complete codex cliproxyapi parity

This commit is contained in:
cita
2026-03-20 11:23:29 +08:00
parent 5403a946c6
commit adc4fb1be7
28 changed files with 2969 additions and 204 deletions
@@ -0,0 +1,79 @@
import { describe, expect, it } from 'vitest';
import {
rankConversationFileEndpoints,
resolveConversationFileEndpointCapability,
summarizeConversationFileInputsInOpenAiBody,
} from './conversationFileCapabilities.js';
describe('conversationFileCapabilities', () => {
it('summarizes image, audio, document, and remote file inputs from OpenAI bodies', () => {
const summary = summarizeConversationFileInputsInOpenAiBody({
messages: [
{
role: 'user',
content: [
{ type: 'image_url', image_url: { url: 'data:image/png;base64,AAAA' } },
{ type: 'input_audio', input_audio: { data: 'UklGRg==', format: 'wav' } },
{
type: 'file',
file: {
filename: 'brief.pdf',
file_data: 'JVBERi0xLjc=',
},
},
{
type: 'input_file',
filename: 'remote.pdf',
file_url: 'https://example.com/remote.pdf',
},
],
},
],
});
expect(summary).toEqual({
hasImage: true,
hasAudio: true,
hasDocument: true,
hasRemoteDocumentUrl: true,
});
});
it('describes inline-only document support for claude messages and gemini chat paths', () => {
expect(resolveConversationFileEndpointCapability({
sitePlatform: 'claude',
endpoint: 'messages',
})).toMatchObject({
image: 'native',
audio: 'unsupported',
document: 'inline_only',
preservesRemoteDocumentUrl: false,
});
expect(resolveConversationFileEndpointCapability({
sitePlatform: 'gemini-cli',
endpoint: 'chat',
})).toMatchObject({
image: 'native',
audio: 'native',
document: 'inline_only',
preservesRemoteDocumentUrl: false,
});
});
it('ranks document-capable endpoints ahead of lossy fallbacks', () => {
const ranked = rankConversationFileEndpoints({
sitePlatform: 'new-api',
requestedOrder: ['chat', 'messages', 'responses'],
summary: {
hasImage: false,
hasAudio: false,
hasDocument: true,
hasRemoteDocumentUrl: false,
},
});
expect(ranked).toEqual(['responses', 'messages', 'chat']);
});
});
@@ -0,0 +1,267 @@
import { inferInputFileMimeType, normalizeInputFileBlock } from '../../transformers/shared/inputFile.js';
export type ConversationFileTransport = 'unsupported' | 'inline_only' | 'native';
export type ConversationFileEndpoint = 'chat' | 'messages' | 'responses';
export type ConversationFileInputSummary = {
hasImage: boolean;
hasAudio: boolean;
hasDocument: boolean;
hasRemoteDocumentUrl: boolean;
};
export type ConversationFileEndpointCapability = {
image: ConversationFileTransport;
audio: ConversationFileTransport;
document: ConversationFileTransport;
preservesRemoteDocumentUrl: boolean;
};
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value);
}
function asTrimmedString(value: unknown): string {
return typeof value === 'string' ? value.trim() : '';
}
function classifyMimeFamily(mimeType: string | null): 'image' | 'audio' | 'document' {
const normalized = asTrimmedString(mimeType).toLowerCase();
if (normalized.startsWith('image/')) return 'image';
if (normalized.startsWith('audio/')) return 'audio';
return 'document';
}
function appendConversationFileSummary(
summary: ConversationFileInputSummary,
item: Record<string, unknown>,
): void {
const normalizedFile = normalizeInputFileBlock(item);
if (normalizedFile) {
const mimeType = inferInputFileMimeType(normalizedFile);
const family = classifyMimeFamily(mimeType);
if (family === 'image') {
summary.hasImage = true;
return;
}
if (family === 'audio') {
summary.hasAudio = true;
return;
}
summary.hasDocument = true;
if (normalizedFile.fileUrl) {
summary.hasRemoteDocumentUrl = true;
}
return;
}
const type = asTrimmedString(item.type).toLowerCase();
if (type === 'image_url' || type === 'input_image') {
summary.hasImage = true;
return;
}
if (type === 'input_audio') {
summary.hasAudio = true;
return;
}
if (Array.isArray(item.content)) {
for (const nested of item.content) {
if (isRecord(nested)) {
appendConversationFileSummary(summary, nested);
}
}
}
}
function createEmptySummary(): ConversationFileInputSummary {
return {
hasImage: false,
hasAudio: false,
hasDocument: false,
hasRemoteDocumentUrl: false,
};
}
export function summarizeConversationFileInputsInOpenAiBody(
body: Record<string, unknown>,
): ConversationFileInputSummary {
const summary = createEmptySummary();
const messages = Array.isArray(body.messages) ? body.messages : [];
for (const message of messages) {
if (!isRecord(message)) continue;
const content = message.content;
if (Array.isArray(content)) {
for (const item of content) {
if (isRecord(item)) appendConversationFileSummary(summary, item);
}
continue;
}
if (isRecord(content)) {
appendConversationFileSummary(summary, content);
}
}
return summary;
}
export function summarizeConversationFileInputsInResponsesBody(
body: Record<string, unknown>,
): ConversationFileInputSummary {
const summary = createEmptySummary();
const input = body.input;
if (Array.isArray(input)) {
for (const item of input) {
if (isRecord(item)) appendConversationFileSummary(summary, item);
}
return summary;
}
if (isRecord(input)) {
appendConversationFileSummary(summary, input);
}
return summary;
}
export function resolveConversationFileEndpointCapability(input: {
sitePlatform?: string;
endpoint: ConversationFileEndpoint;
}): ConversationFileEndpointCapability {
const platform = asTrimmedString(input.sitePlatform).toLowerCase();
const endpoint = input.endpoint;
if (platform === 'codex') {
if (endpoint === 'responses') {
return {
image: 'native',
audio: 'native',
document: 'native',
preservesRemoteDocumentUrl: true,
};
}
return {
image: 'unsupported',
audio: 'unsupported',
document: 'unsupported',
preservesRemoteDocumentUrl: false,
};
}
if (platform === 'claude') {
if (endpoint === 'messages') {
return {
image: 'native',
audio: 'unsupported',
document: 'inline_only',
preservesRemoteDocumentUrl: false,
};
}
return {
image: 'unsupported',
audio: 'unsupported',
document: 'unsupported',
preservesRemoteDocumentUrl: false,
};
}
if (platform === 'gemini-cli' || platform === 'antigravity') {
if (endpoint === 'chat') {
return {
image: 'native',
audio: 'native',
document: 'inline_only',
preservesRemoteDocumentUrl: false,
};
}
return {
image: 'unsupported',
audio: 'unsupported',
document: 'unsupported',
preservesRemoteDocumentUrl: false,
};
}
if (platform === 'gemini') {
if (endpoint === 'responses') {
return {
image: 'native',
audio: 'native',
document: 'native',
preservesRemoteDocumentUrl: true,
};
}
if (endpoint === 'chat') {
return {
image: 'native',
audio: 'native',
document: 'inline_only',
preservesRemoteDocumentUrl: false,
};
}
return {
image: 'unsupported',
audio: 'unsupported',
document: 'unsupported',
preservesRemoteDocumentUrl: false,
};
}
if (endpoint === 'responses') {
return {
image: 'native',
audio: 'native',
document: 'native',
preservesRemoteDocumentUrl: true,
};
}
if (endpoint === 'messages') {
return {
image: 'native',
audio: 'unsupported',
document: 'inline_only',
preservesRemoteDocumentUrl: false,
};
}
return {
image: 'native',
audio: 'native',
document: 'inline_only',
preservesRemoteDocumentUrl: false,
};
}
export function rankConversationFileEndpoints(input: {
sitePlatform?: string;
requestedOrder: ConversationFileEndpoint[];
summary: ConversationFileInputSummary;
preferMessagesForClaudeModel?: boolean;
}): ConversationFileEndpoint[] {
if (!input.summary.hasDocument) {
return [...input.requestedOrder];
}
const preferredDocumentOrder = input.preferMessagesForClaudeModel === true
? ['messages', 'responses', 'chat']
: ['responses', 'messages', 'chat'];
const supportedDocumentEndpoints = preferredDocumentOrder.filter((endpoint) => {
if (!input.requestedOrder.includes(endpoint as ConversationFileEndpoint)) return false;
const capability = resolveConversationFileEndpointCapability({
sitePlatform: input.sitePlatform,
endpoint: endpoint as ConversationFileEndpoint,
});
if (capability.document === 'unsupported') return false;
if (input.summary.hasRemoteDocumentUrl && !capability.preservesRemoteDocumentUrl) return false;
return true;
}) as ConversationFileEndpoint[];
if (supportedDocumentEndpoints.length <= 0) {
return [...input.requestedOrder];
}
return [
...supportedDocumentEndpoints,
...input.requestedOrder.filter((endpoint) => !supportedDocumentEndpoints.includes(endpoint)),
];
}
@@ -1,8 +1,10 @@
import { createHash, randomUUID } from 'node:crypto';
import type { PreparedProviderRequest, PrepareProviderRequestInput, ProviderProfile } from './types.js';
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() : '';
@@ -53,6 +55,7 @@ function uuidFromSeed(seed: string): string {
function buildCodexRuntimeHeaders(input: {
baseHeaders: Record<string, string>;
providerHeaders?: Record<string, string>;
oauthProvider?: string;
explicitSessionId?: string | null;
continuityKey?: string | null;
}): Record<string, string> {
@@ -64,7 +67,21 @@ function buildCodexRuntimeHeaders(input: {
const originator = getInputHeader(input.providerHeaders, 'originator') || 'codex_cli_rs';
const accountId = getInputHeader(input.providerHeaders, 'chatgpt-account-id');
const version = getInputHeader(input.baseHeaders, 'version') || CODEX_CLIENT_VERSION;
const userAgent = getInputHeader(input.baseHeaders, 'user-agent') || CODEX_DEFAULT_USER_AGENT;
const isCodexOauth = asTrimmedString(input.oauthProvider).toLowerCase() === 'codex';
const configuredUserAgent = isCodexOauth ? asTrimmedString(config.codexHeaderDefaults.userAgent) : '';
const websocketTransport = getInputHeader(input.baseHeaders, 'x-metapi-responses-websocket-transport') === '1';
const configuredBetaFeatures = (
isCodexOauth && websocketTransport
? asTrimmedString(config.codexHeaderDefaults.betaFeatures)
: ''
);
const userAgent = configuredUserAgent || getInputHeader(input.baseHeaders, 'user-agent') || CODEX_DEFAULT_USER_AGENT;
const codexBetaFeatures = getInputHeader(input.baseHeaders, 'x-codex-beta-features') || configuredBetaFeatures;
const codexTurnState = getInputHeader(input.baseHeaders, 'x-codex-turn-state');
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);
const explicitSessionId = asTrimmedString(input.explicitSessionId);
const continuityKey = asTrimmedString(input.continuityKey);
const sessionId = (
@@ -87,6 +104,11 @@ function buildCodexRuntimeHeaders(input: {
...(accountId ? { 'Chatgpt-Account-Id': accountId } : {}),
Originator: originator,
Version: version,
...(codexBetaFeatures ? { 'x-codex-beta-features': codexBetaFeatures } : {}),
...(codexTurnState ? { 'x-codex-turn-state': codexTurnState } : {}),
...(codexTurnMetadata ? { 'x-codex-turn-metadata': codexTurnMetadata } : {}),
...(timingMetrics ? { 'x-responsesapi-include-timing-metrics': timingMetrics } : {}),
...(openAiBeta ? { 'OpenAI-Beta': openAiBeta } : {}),
Session_id: sessionId,
...(conversationId ? { Conversation_id: conversationId } : {}),
'User-Agent': userAgent,
@@ -101,6 +123,7 @@ export const codexProviderProfile: ProviderProfile = {
const headers = buildCodexRuntimeHeaders({
baseHeaders: input.baseHeaders,
providerHeaders: input.providerHeaders,
oauthProvider: input.oauthProvider,
explicitSessionId: asTrimmedString(input.codexExplicitSessionId) || null,
continuityKey: asTrimmedString(input.codexSessionCacheKey) || null,
});
@@ -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()];
},
};
}
+26
View File
@@ -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[];
};
@@ -30,7 +30,6 @@ import { anthropicMessagesTransformer } from '../../transformers/anthropic/messa
import { getProxyAuthContext, getProxyResourceOwner } from '../../middleware/auth.js';
import {
ProxyInputFileResolutionError,
hasNonImageFileInputInOpenAiBody,
resolveOpenAiBodyInputFiles,
} from '../../services/proxyInputFileResolver.js';
import {
@@ -52,6 +51,7 @@ import {
import { dispatchRuntimeRequest } from '../../routes/proxy/runtimeExecutor.js';
import { detectCliProfile } from '../cliProfiles/registry.js';
import type { CliProfileId } from '../cliProfiles/types.js';
import { summarizeConversationFileInputsInOpenAiBody } from '../capabilities/conversationFileCapabilities.js';
const MAX_RETRIES = 2;
@@ -122,7 +122,8 @@ export async function handleChatSurfaceRequest(
throw error;
}
}
const hasNonImageFileInput = hasNonImageFileInputInOpenAiBody(resolvedOpenAiBody);
const conversationFileSummary = summarizeConversationFileInputsInOpenAiBody(resolvedOpenAiBody);
const hasNonImageFileInput = conversationFileSummary.hasDocument;
const codexSessionCacheKey = deriveCodexSessionCacheKey({
downstreamFormat,
body: downstreamFormat === 'claude' ? claudeOriginalBody : request.body,
@@ -172,6 +173,7 @@ export async function handleChatSurfaceRequest(
requestedModel,
{
hasNonImageFileInput,
conversationFileSummary,
},
),
];
@@ -25,7 +25,6 @@ import { dispatchRuntimeRequest } from '../../routes/proxy/runtimeExecutor.js';
import { normalizeInputFileBlock } from '../../transformers/shared/inputFile.js';
import {
ProxyInputFileResolutionError,
hasNonImageFileInputInOpenAiBody,
resolveResponsesBodyInputFiles,
} from '../../services/proxyInputFileResolver.js';
import {
@@ -47,6 +46,10 @@ import {
import { isCodexResponsesSurface } from '../cliProfiles/codexProfile.js';
import { detectCliProfile } from '../cliProfiles/registry.js';
import type { CliProfileId } from '../cliProfiles/types.js';
import {
summarizeConversationFileInputsInOpenAiBody,
summarizeConversationFileInputsInResponsesBody,
} from '../capabilities/conversationFileCapabilities.js';
const MAX_RETRIES = 2;
@@ -248,9 +251,12 @@ export async function handleOpenAiResponsesSurfaceRequest(
isStream,
{ defaultEncryptedReasoningInclude },
);
const hasNonImageFileInput = hasNonImageFileInputInOpenAiBody(openAiBody);
const conversationFileSummary = summarizeConversationFileInputsInOpenAiBody(openAiBody);
const hasNonImageFileInput = conversationFileSummary.hasDocument;
const prefersNativeResponsesReasoning = wantsNativeResponsesReasoning(normalizedResponsesBody);
const requiresNativeResponsesFileUrl = carriesResponsesFileUrlInput(normalizedResponsesBody.input);
const responsesConversationFileSummary = summarizeConversationFileInputsInResponsesBody(normalizedResponsesBody);
const requiresNativeResponsesFileUrl = responsesConversationFileSummary.hasRemoteDocumentUrl
|| carriesResponsesFileUrlInput(normalizedResponsesBody.input);
if (requiresNativeResponsesFileUrl && String(selected.site.platform || '').trim().toLowerCase() === 'claude') {
return reply.code(400).send({
error: {
@@ -269,6 +275,7 @@ export async function handleOpenAiResponsesSurfaceRequest(
requestedModel,
{
hasNonImageFileInput,
conversationFileSummary,
wantsNativeResponsesReasoning: prefersNativeResponsesReasoning,
},
);