fix: harden codex runtime routing and oauth recovery

This commit is contained in:
Cita
2026-03-21 19:07:14 +08:00
parent 6a19d809f0
commit 1d8889001f
18 changed files with 775 additions and 195 deletions
@@ -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;