handle openai websocket previous_response_not_found error

fixes #6931
This commit is contained in:
David Brailovsky
2026-07-22 10:26:24 +00:00
parent a5afc3f171
commit c5dcb26000
2 changed files with 238 additions and 6 deletions
+19 -5
View File
@@ -67,6 +67,7 @@ const REQUEST_COMPRESSION_ZSTD_LEVEL = 3;
const CODEX_TOOL_CALL_PROVIDERS = new Set(["openai", "openai-codex", "opencode"]); const CODEX_TOOL_CALL_PROVIDERS = new Set(["openai", "openai-codex", "opencode"]);
const WEBSOCKET_MESSAGE_TOO_BIG_CLOSE_CODE = 1009; const WEBSOCKET_MESSAGE_TOO_BIG_CLOSE_CODE = 1009;
const WEBSOCKET_CONNECTION_LIMIT_REACHED_CODE = "websocket_connection_limit_reached"; const WEBSOCKET_CONNECTION_LIMIT_REACHED_CODE = "websocket_connection_limit_reached";
const PREVIOUS_RESPONSE_NOT_FOUND_CODE = "previous_response_not_found";
const CODEX_RESPONSE_STATUSES = new Set<CodexResponseStatus>([ const CODEX_RESPONSE_STATUSES = new Set<CodexResponseStatus>([
"completed", "completed",
@@ -273,6 +274,7 @@ export const stream: StreamFunction<"openai-codex-responses", OpenAICodexRespons
const httpTimeoutMs = normalizeTimeoutMs(options?.timeoutMs); const httpTimeoutMs = normalizeTimeoutMs(options?.timeoutMs);
const websocketConnectTimeoutMs = normalizeTimeoutMs(options?.websocketConnectTimeoutMs); const websocketConnectTimeoutMs = normalizeTimeoutMs(options?.websocketConnectTimeoutMs);
const transport = options?.transport || "auto"; const transport = options?.transport || "auto";
let startEmitted = false;
const websocketDisabledForSession = transport !== "sse" && isWebSocketSseFallbackActive(options?.sessionId); const websocketDisabledForSession = transport !== "sse" && isWebSocketSseFallbackActive(options?.sessionId);
if (websocketDisabledForSession) { if (websocketDisabledForSession) {
recordWebSocketSseFallback(options?.sessionId); recordWebSocketSseFallback(options?.sessionId);
@@ -281,6 +283,7 @@ export const stream: StreamFunction<"openai-codex-responses", OpenAICodexRespons
if (transport !== "sse" && !websocketDisabledForSession) { if (transport !== "sse" && !websocketDisabledForSession) {
let websocketStarted = false; let websocketStarted = false;
let retriedWebSocketConnectionLimit = false; let retriedWebSocketConnectionLimit = false;
let retriedMissingWebSocketContinuation = false;
while (true) { while (true) {
websocketStarted = false; websocketStarted = false;
try { try {
@@ -293,6 +296,10 @@ export const stream: StreamFunction<"openai-codex-responses", OpenAICodexRespons
model, model,
() => { () => {
websocketStarted = true; websocketStarted = true;
if (!startEmitted) {
startEmitted = true;
stream.push({ type: "start", partial: output });
}
}, },
httpTimeoutMs, httpTimeoutMs,
websocketConnectTimeoutMs, websocketConnectTimeoutMs,
@@ -312,6 +319,11 @@ export const stream: StreamFunction<"openai-codex-responses", OpenAICodexRespons
} catch (error) { } catch (error) {
const aborted = options?.signal?.aborted; const aborted = options?.signal?.aborted;
const connectionLimitBeforeStart = !websocketStarted && isWebSocketConnectionLimitReachedError(error); const connectionLimitBeforeStart = !websocketStarted && isWebSocketConnectionLimitReachedError(error);
const previousResponseNotFound = isPreviousResponseNotFoundError(error);
if (!aborted && previousResponseNotFound && !retriedMissingWebSocketContinuation) {
retriedMissingWebSocketContinuation = true;
continue;
}
if (!aborted && connectionLimitBeforeStart && !retriedWebSocketConnectionLimit) { if (!aborted && connectionLimitBeforeStart && !retriedWebSocketConnectionLimit) {
retriedWebSocketConnectionLimit = true; retriedWebSocketConnectionLimit = true;
continue; continue;
@@ -432,7 +444,10 @@ export const stream: StreamFunction<"openai-codex-responses", OpenAICodexRespons
throw new Error("No response body"); throw new Error("No response body");
} }
if (!startEmitted) {
startEmitted = true;
stream.push({ type: "start", partial: output }); stream.push({ type: "start", partial: output });
}
await processStream(response, output, stream, model, options); await processStream(response, output, stream, model, options);
if (options?.signal?.aborted) { if (options?.signal?.aborted) {
@@ -636,6 +651,10 @@ function isWebSocketConnectionLimitReachedError(error: unknown): boolean {
return error instanceof CodexApiError && error.code === WEBSOCKET_CONNECTION_LIMIT_REACHED_CODE; return error instanceof CodexApiError && error.code === WEBSOCKET_CONNECTION_LIMIT_REACHED_CODE;
} }
function isPreviousResponseNotFoundError(error: unknown): boolean {
return error instanceof CodexApiError && error.code === PREVIOUS_RESPONSE_NOT_FOUND_CODE;
}
function extractCodexEventError(event: Record<string, unknown>): { code?: string; message?: string } { function extractCodexEventError(event: Record<string, unknown>): { code?: string; message?: string } {
const nested = event.error && typeof event.error === "object" ? (event.error as Record<string, unknown>) : undefined; const nested = event.error && typeof event.error === "object" ? (event.error as Record<string, unknown>) : undefined;
return { return {
@@ -1358,8 +1377,6 @@ function buildCachedWebSocketRequestBody(entry: CachedWebSocketConnection, body:
async function* startWebSocketOutputOnFirstEvent( async function* startWebSocketOutputOnFirstEvent(
events: AsyncIterable<ResponseStreamEvent>, events: AsyncIterable<ResponseStreamEvent>,
output: AssistantMessage,
stream: AssistantMessageEventStream,
onStart: () => void, onStart: () => void,
): AsyncGenerator<ResponseStreamEvent> { ): AsyncGenerator<ResponseStreamEvent> {
let started = false; let started = false;
@@ -1367,7 +1384,6 @@ async function* startWebSocketOutputOnFirstEvent(
if (!started) { if (!started) {
started = true; started = true;
onStart(); onStart();
stream.push({ type: "start", partial: output });
} }
yield event; yield event;
} }
@@ -1422,8 +1438,6 @@ async function processWebSocketStream(
await processResponsesStream( await processResponsesStream(
startWebSocketOutputOnFirstEvent( startWebSocketOutputOnFirstEvent(
mapCodexEvents(parseWebSocket(socket, options?.signal, idleTimeoutMs)), mapCodexEvents(parseWebSocket(socket, options?.signal, idleTimeoutMs)),
output,
stream,
onStart, onStart,
), ),
output, output,
@@ -1802,6 +1802,224 @@ describe("openai-codex streaming", () => {
}); });
}); });
it.each(["websocket", "sse"] as const)(
"recovers a missing cached websocket continuation via %s",
async (recoveryTransport) => {
const token = mockToken();
const sessionId = `missing-continuation-${recoveryTransport}`;
const encoder = new TextEncoder();
const fetchMock = vi.fn(
async () =>
new Response(
new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(encoder.encode(buildSSEPayload({ status: "completed" })));
controller.close();
},
}),
{ status: 200, headers: { "content-type": "text/event-stream" } },
),
);
vi.stubGlobal("fetch", fetchMock);
const sentBodies: Array<{
connectionId: number;
input: unknown[];
previous_response_id?: string;
}> = [];
let connections = 0;
class MockWebSocket {
static OPEN = 1;
static CLOSED = 3;
readyState = MockWebSocket.OPEN;
private readonly connectionId = ++connections;
private listeners = new Map<string, Set<(event: unknown) => void>>();
constructor(_url: string, _protocols?: string | string[] | { headers?: Record<string, string> }) {
queueMicrotask(() => this.dispatch("open", {}));
}
addEventListener(type: string, listener: (event: unknown) => void): void {
let listeners = this.listeners.get(type);
if (!listeners) {
listeners = new Set();
this.listeners.set(type, listeners);
}
listeners.add(listener);
}
removeEventListener(type: string, listener: (event: unknown) => void): void {
this.listeners.get(type)?.delete(listener);
}
send(data: string): void {
const body = JSON.parse(data) as { input: unknown[]; previous_response_id?: string };
sentBodies.push({ ...body, connectionId: this.connectionId });
if (sentBodies.length === 2) {
this.dispatchEvents([
{
type: "codex.rate_limits",
plan_type: "plus",
rate_limits: {
allowed: true,
limit_reached: false,
primary: {
used_percent: 7,
window_minutes: 10080,
reset_after_seconds: 556112,
reset_at: 1785269351,
},
secondary: null,
},
code_review_rate_limits: null,
additional_rate_limits: null,
credits: { has_credits: false, unlimited: false, balance: "0" },
promo: null,
},
{
type: "error",
status: 400,
error: {
code: "previous_response_not_found",
message: "Previous response with id 'resp_1' not found.",
param: "previous_response_id",
},
},
]);
return;
}
if (sentBodies.length === 3 && recoveryTransport === "sse") {
queueMicrotask(() => this.dispatch("error", { message: "retry websocket failed" }));
return;
}
const response =
sentBodies.length === 1
? { responseId: "resp_1", messageId: "msg_1", text: "Hello" }
: { responseId: "resp_2", messageId: "msg_2", text: "Recovered" };
this.dispatchEvents([
{ type: "response.created", response: { id: response.responseId } },
{
type: "response.output_item.added",
output_index: 0,
item: {
type: "message",
id: response.messageId,
role: "assistant",
status: "in_progress",
content: [],
},
},
{
type: "response.output_item.done",
output_index: 0,
item: {
type: "message",
id: response.messageId,
role: "assistant",
status: "completed",
content: [{ type: "output_text", text: response.text }],
},
},
{
type: "response.completed",
response: {
id: response.responseId,
status: "completed",
usage: { input_tokens: 5, output_tokens: 3, total_tokens: 8 },
},
},
]);
}
close(): void {
this.readyState = MockWebSocket.CLOSED;
}
private dispatchEvents(events: unknown[]): void {
queueMicrotask(() => {
for (const event of events) {
this.dispatch("message", { data: JSON.stringify(event) });
}
});
}
private dispatch(type: string, event: unknown): void {
for (const listener of this.listeners.get(type) ?? []) {
listener(event);
}
}
}
vi.stubGlobal("WebSocket", MockWebSocket);
const model: Model<"openai-codex-responses"> = {
id: "gpt-5.1-codex",
name: "GPT-5.1 Codex",
api: "openai-codex-responses",
provider: "openai-codex",
baseUrl: "https://chatgpt.com/backend-api",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 400000,
maxTokens: 128000,
};
const firstContext: Context = {
systemPrompt: "You are a helpful assistant.",
messages: [{ role: "user", content: "Say hello", timestamp: 1 }],
};
const first = await streamOpenAICodexResponses(model, firstContext, {
apiKey: token,
sessionId,
transport: "websocket-cached",
}).result();
const secondContext: Context = {
systemPrompt: "You are a helpful assistant.",
messages: [...firstContext.messages, first, { role: "user", content: "Now finish", timestamp: 2 }],
};
const eventTypes: string[] = [];
const secondStream = streamOpenAICodexResponses(model, secondContext, {
apiKey: token,
sessionId,
transport: "websocket-cached",
});
for await (const event of secondStream) {
eventTypes.push(event.type);
}
const second = await secondStream.result();
expect(second.stopReason).toBe("stop");
expect(second.content.find((content) => content.type === "text")?.text).toBe(
recoveryTransport === "sse" ? "Hello" : "Recovered",
);
expect(eventTypes.filter((type) => type === "start")).toHaveLength(1);
expect(eventTypes).not.toContain("error");
expect(connections).toBe(2);
expect(sentBodies).toHaveLength(3);
expect(sentBodies.map((body) => body.connectionId)).toEqual([1, 1, 2]);
expect(sentBodies[1].previous_response_id).toBe("resp_1");
expect(sentBodies[1].input).toEqual([{ role: "user", content: [{ type: "input_text", text: "Now finish" }] }]);
expect(sentBodies[2].previous_response_id).toBeUndefined();
expect(sentBodies[2].input).toHaveLength(3);
expect(sentBodies[2].input.at(-1)).toEqual({
role: "user",
content: [{ type: "input_text", text: "Now finish" }],
});
expect(fetchMock).toHaveBeenCalledTimes(recoveryTransport === "sse" ? 1 : 0);
expect(getOpenAICodexWebSocketDebugStats(sessionId)).toMatchObject({
requests: 3,
connectionsCreated: 2,
connectionsReused: 1,
fullContextRequests: 2,
deltaRequests: 1,
websocketFailures: recoveryTransport === "sse" ? 1 : 0,
sseFallbacks: recoveryTransport === "sse" ? 1 : 0,
});
},
);
it.each([ it.each([
["retry-after-ms", () => ({ "content-type": "application/json", "retry-after-ms": "1500" }), 1500], ["retry-after-ms", () => ({ "content-type": "application/json", "retry-after-ms": "1500" }), 1500],
["retry-after seconds", () => ({ "content-type": "application/json", "retry-after": "60" }), 60_000], ["retry-after seconds", () => ({ "content-type": "application/json", "retry-after": "60" }), 60_000],