diff --git a/packages/ai/src/providers/openai-chatgpt-responses.retry.test.ts b/packages/ai/src/providers/openai-chatgpt-responses.retry.test.ts new file mode 100644 index 000000000000..48acf2dde03d --- /dev/null +++ b/packages/ai/src/providers/openai-chatgpt-responses.retry.test.ts @@ -0,0 +1,118 @@ +// Covers which ChatGPT Responses failures the SSE transport retries. +import { afterEach, describe, expect, it, vi } from "vitest"; +import { configureAiTransportHost } from "../host.js"; +import type { Context, Model } from "../types.js"; +import { + closeOpenAICodexWebSocketSessions, + resetOpenAICodexWebSocketStateForTest, + streamOpenAICodexResponses, +} from "./openai-chatgpt-responses.js"; + +function createTestJwt(payload: Record): string { + const header = Buffer.from(JSON.stringify({ alg: "none", typ: "JWT" })).toString("base64url"); + const body = Buffer.from(JSON.stringify(payload)).toString("base64url"); + return `${header}.${body}.signature`; +} + +describe("streamOpenAICodexResponses retry classification", () => { + afterEach(() => { + closeOpenAICodexWebSocketSessions(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + vi.useRealTimers(); + resetOpenAICodexWebSocketStateForTest(); + configureAiTransportHost({}); + }); + + const model = { + id: "gpt-5.5", + name: "GPT-5.5", + api: "openai-chatgpt-responses", + provider: "openai", + baseUrl: "https://chatgpt.test/backend-api", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128_000, + maxTokens: 16_000, + } satisfies Model<"openai-chatgpt-responses">; + + const context = { + messages: [{ role: "user", content: "hi", timestamp: 1 }], + } satisfies Context; + + const jwt = createTestJwt({ + "https://api.openai.com/auth": { chatgpt_account_id: "acct-1" }, + }); + + it.each([ + { status: 401, statusText: "Unauthorized", message: "Invalid credentials" }, + { status: 403, statusText: "Forbidden", message: "Account is not authorized" }, + { status: 400, statusText: "Bad Request", message: "Unsupported parameter" }, + ])( + "does not retry non-retryable ChatGPT responses: $status", + async ({ status, statusText, message }) => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ error: { message } }), { + status, + statusText, + }), + ); + vi.stubGlobal("fetch", fetchMock); + const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout"); + + const result = await streamOpenAICodexResponses(model, context, { + apiKey: jwt, + transport: "sse", + }).result(); + + expect(result.stopReason).toBe("error"); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(setTimeoutSpy).not.toHaveBeenCalled(); + }, + ); + + it("still retries retryable ChatGPT responses", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(new Response("overloaded", { status: 503 })) + .mockResolvedValueOnce( + new Response(JSON.stringify({ error: { message: "Invalid credentials" } }), { + status: 401, + }), + ); + vi.stubGlobal("fetch", fetchMock); + vi.spyOn(globalThis, "setTimeout").mockImplementation((callback: TimerHandler) => { + if (typeof callback === "function") { + callback(); + } + return 0 as unknown as ReturnType; + }); + + const result = await streamOpenAICodexResponses(model, context, { + apiKey: jwt, + transport: "sse", + }).result(); + + expect(result.stopReason).toBe("error"); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it("does not retry a bodyless 304 response", async () => { + const fetchMock = vi + .fn() + .mockResolvedValue(new Response(null, { status: 304, statusText: "Not Modified" })); + vi.stubGlobal("fetch", fetchMock); + const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout"); + + const result = await streamOpenAICodexResponses(model, context, { + apiKey: jwt, + transport: "sse", + }).result(); + + expect(result.stopReason).toBe("error"); + expect(result.errorMessage).toBe("Not Modified"); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(setTimeoutSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/ai/src/providers/openai-chatgpt-responses.ts b/packages/ai/src/providers/openai-chatgpt-responses.ts index 2a3b486dd4c7..f2ea78210a9a 100644 --- a/packages/ai/src/providers/openai-chatgpt-responses.ts +++ b/packages/ai/src/providers/openai-chatgpt-responses.ts @@ -144,6 +144,32 @@ function isRetryableError(status: number, errorText: string): boolean { ); } +function resolveHttpRetryDelayMs(response: Response, attempt: number): number { + const fallbackMs = BASE_DELAY_MS * 2 ** attempt; + const retryAfterMs = response.headers.get("retry-after-ms"); + if (retryAfterMs !== null) { + const trimmed = retryAfterMs.trim(); + const millis = Number(trimmed); + return /^\d+(?:\.\d+)?$/.test(trimmed) && Number.isFinite(millis) + ? (clampTimerTimeoutMs(millis, 0) ?? fallbackMs) + : fallbackMs; + } + + const retryAfter = response.headers.get("retry-after"); + if (!retryAfter) { + return fallbackMs; + } + const trimmed = retryAfter.trim(); + const seconds = Number(trimmed); + if (/^\d+$/.test(trimmed) && Number.isFinite(seconds)) { + return clampTimerTimeoutMs(seconds * 1000, 0) ?? fallbackMs; + } + const retryAt = parseRetryAfterHttpDateMs(trimmed); + return retryAt === undefined + ? fallbackMs + : (clampTimerTimeoutMs(retryAt - Date.now(), 0) ?? fallbackMs); +} + function resolveRequestTimeoutMs(options?: OpenAICodexResponsesOptions): number | undefined { const timeoutMs = options?.timeoutMs; return typeof timeoutMs === "number" && Number.isFinite(timeoutMs) && timeoutMs > 0 @@ -373,60 +399,25 @@ export const streamOpenAICodexResponses: StreamFunction< throw new Error("Request was aborted"); } + let attemptResponse: Response; + let errorText: string; try { - response = await fetch(resolveCodexUrl(model.baseUrl), { + attemptResponse = await fetch(resolveCodexUrl(model.baseUrl), { method: "POST", headers: sseHeaders, body: sseBody, signal: activeSignal, }); + response = attemptResponse; await options?.onResponse?.( - { status: response.status, headers: headersToRecord(response.headers) }, + { status: attemptResponse.status, headers: headersToRecord(attemptResponse.headers) }, model, ); - if (response.ok) { + if (attemptResponse.ok) { break; } - - const errorText = await readChatGptResponsesErrorTextLimited(response); - if (attempt < maxRetries && isRetryableError(response.status, errorText)) { - let delayMs = BASE_DELAY_MS * 2 ** attempt; - - const retryAfterMs = response.headers.get("retry-after-ms"); - if (retryAfterMs !== null) { - const trimmedRetryAfterMs = retryAfterMs.trim(); - const millis = Number(trimmedRetryAfterMs); - if (/^\d+(?:\.\d+)?$/.test(trimmedRetryAfterMs) && Number.isFinite(millis)) { - delayMs = clampTimerTimeoutMs(millis, 0) ?? delayMs; - } - } else { - const retryAfter = response.headers.get("retry-after"); - if (retryAfter) { - const trimmedRetryAfter = retryAfter.trim(); - const seconds = Number(trimmedRetryAfter); - if (/^\d+$/.test(trimmedRetryAfter) && Number.isFinite(seconds)) { - delayMs = clampTimerTimeoutMs(seconds * 1000, 0) ?? delayMs; - } else { - const retryAt = parseRetryAfterHttpDateMs(trimmedRetryAfter); - if (retryAt !== undefined) { - delayMs = clampTimerTimeoutMs(retryAt - Date.now(), 0) ?? delayMs; - } - } - } - } - - await sleepWithAbort(delayMs, activeSignal); - continue; - } - - // Parse error for friendly message on final attempt or non-retryable error - const fakeResponse = new Response(errorText, { - status: response.status, - statusText: response.statusText, - }); - const info = await parseErrorResponse(fakeResponse); - throw new Error(info.friendlyMessage || info.message); + errorText = await readChatGptResponsesErrorTextLimited(attemptResponse); } catch (error) { if (error instanceof Error) { if ( @@ -456,6 +447,18 @@ export const streamOpenAICodexResponses: StreamFunction< } throw lastError; } + + if (attempt < maxRetries && isRetryableError(attemptResponse.status, errorText)) { + await sleepWithAbort(resolveHttpRetryDelayMs(attemptResponse, attempt), activeSignal); + continue; + } + + const info = parseErrorResponseText( + errorText, + attemptResponse.status, + attemptResponse.statusText, + ); + throw new Error(info.friendlyMessage || info.message); } if (!response?.ok) { @@ -1579,11 +1582,12 @@ async function readChatGptResponsesErrorTextLimited(response: Response): Promise return text; } -async function parseErrorResponse( - response: Response, -): Promise<{ message: string; friendlyMessage?: string }> { - const raw = await readChatGptResponsesErrorTextLimited(response); - let message = raw || response.statusText || "Request failed"; +function parseErrorResponseText( + raw: string, + status: number, + statusText: string, +): { message: string; friendlyMessage?: string } { + let message = raw || statusText || "Request failed"; let friendlyMessage: string | undefined; try { @@ -1601,7 +1605,7 @@ async function parseErrorResponse( const code = err.code || err.type || ""; if ( /usage_limit_reached|usage_not_included|rate_limit_exceeded/i.test(code) || - response.status === 429 + status === 429 ) { const plan = err.plan_type ? ` (${err.plan_type.toLowerCase()} plan)` : ""; const mins = err.resets_at