diff --git a/packages/opencode/src/plugin/codex.ts b/packages/opencode/src/plugin/codex.ts index df4b4d0d5c05..57008200a235 100644 --- a/packages/opencode/src/plugin/codex.ts +++ b/packages/opencode/src/plugin/codex.ts @@ -11,6 +11,8 @@ const log = Log.create({ service: "plugin.codex" }) const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann" const ISSUER = "https://auth.openai.com" const CODEX_API_ENDPOINT = "https://chatgpt.com/backend-api/codex/responses" +const CODEX_REQUEST_TIMEOUT = 30_000 +const CODEX_CHUNK_TIMEOUT = 120_000 const OAUTH_PORT = 1455 const OAUTH_POLLING_SAFETY_MARGIN_MS = 3000 const ALLOWED_MODELS = new Set([ @@ -141,21 +143,35 @@ async function exchangeCodeForTokens(code: string, redirectUri: string, pkce: Pk } async function refreshAccessToken(refreshToken: string, issuer = ISSUER): Promise { + const timeout = timeoutController(CODEX_REQUEST_TIMEOUT) const response = await fetch(`${issuer}/oauth/token`, { method: "POST", + signal: timeout.signal, headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ grant_type: "refresh_token", refresh_token: refreshToken, client_id: CLIENT_ID, }).toString(), - }) + }).finally(() => timeout.clear()) if (!response.ok) { throw new Error(`Token refresh failed: ${response.status}`) } return response.json() } +function timeoutController(ms: number) { + const ctl = new AbortController() + const id = setTimeout( + () => ctl.abort(new DOMException(`Codex request timed out after ${ms}ms`, "TimeoutError")), + ms, + ) + return { + signal: ctl.signal, + clear: () => clearTimeout(id), + } +} + const HTML_SUCCESS = ` @@ -421,6 +437,8 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug return { apiKey: OAUTH_DUMMY_KEY, + timeout: CODEX_REQUEST_TIMEOUT, + chunkTimeout: CODEX_CHUNK_TIMEOUT, async fetch(requestInput: RequestInfo | URL, init?: RequestInit) { // Remove dummy API key authorization header if (init?.headers) { diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 496a2f6d2d3b..7ce8712009df 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -30,6 +30,7 @@ import { ModelStatus } from "./model-status" import { RuntimeFlags } from "@/effect/runtime-flags" const log = Log.create({ service: "provider" }) +const PROVIDER_TIMEOUT_DEFAULT = 300_000 function shouldUseCopilotResponsesApi(modelID: string): boolean { const match = /^gpt-(\d+)/.exec(modelID) @@ -85,6 +86,18 @@ function wrapSSE(res: Response, ms: number, ctl: AbortController) { }) } +function timeoutController(ms: number) { + const ctl = new AbortController() + const id = setTimeout( + () => ctl.abort(new DOMException(`Provider request timed out after ${ms}ms`, "TimeoutError")), + ms, + ) + return { + signal: ctl.signal, + clear: () => clearTimeout(id), + } +} + function googleVertexAnthropicBaseURL(project: string | undefined, location: string | undefined) { if (!project) return if (location !== "eu" && location !== "us") return @@ -1607,12 +1620,13 @@ export const layer = Layer.effect( const fetchFn = customFetch ?? fetch const opts = init ?? {} const chunkAbortCtl = typeof chunkTimeout === "number" && chunkTimeout > 0 ? new AbortController() : undefined + const requestTimeout = options["timeout"] === false ? undefined : (options["timeout"] ?? PROVIDER_TIMEOUT_DEFAULT) + const requestTimeoutCtl = typeof requestTimeout === "number" ? timeoutController(requestTimeout) : undefined const signals: AbortSignal[] = [] if (opts.signal) signals.push(opts.signal) if (chunkAbortCtl) signals.push(chunkAbortCtl.signal) - if (options["timeout"] !== undefined && options["timeout"] !== null && options["timeout"] !== false) - signals.push(AbortSignal.timeout(options["timeout"])) + if (requestTimeoutCtl) signals.push(requestTimeoutCtl.signal) const combined = signals.length === 0 ? null : signals.length === 1 ? signals[0] : AbortSignal.any(signals) if (combined) opts.signal = combined @@ -1639,7 +1653,7 @@ export const layer = Layer.effect( ...opts, // @ts-ignore see here: https://github.com/oven-sh/bun/issues/16682 timeout: false, - }) + }).finally(() => requestTimeoutCtl?.clear()) if (!chunkAbortCtl) return res return wrapSSE(res, chunkTimeout, chunkAbortCtl) diff --git a/packages/opencode/src/session/retry.ts b/packages/opencode/src/session/retry.ts index 463bc27a95db..86e6a14e4c3b 100644 --- a/packages/opencode/src/session/retry.ts +++ b/packages/opencode/src/session/retry.ts @@ -125,6 +125,9 @@ export function retryable(error: Err, provider: string) { const msg = isRecord(error.data) ? error.data.message : undefined if (typeof msg === "string") { const lower = msg.toLowerCase() + if (lower.includes("sse read timed out") || lower.includes("provider request timed out")) { + return { message: msg } + } if ( lower.includes("rate increased too quickly") || lower.includes("rate limit") || diff --git a/packages/opencode/test/session/retry.test.ts b/packages/opencode/test/session/retry.test.ts index 22ff6cde811d..23cd3c1cb88b 100644 --- a/packages/opencode/test/session/retry.test.ts +++ b/packages/opencode/test/session/retry.test.ts @@ -163,6 +163,14 @@ describe("session.retry.retryable", () => { expect(SessionRetry.retryable(error, retryProvider)).toEqual({ message: msg }) }) + test("retries transport timeout errors", () => { + const sse = wrap("SSE read timed out") + expect(SessionRetry.retryable(sse, retryProvider)).toEqual({ message: "SSE read timed out" }) + + const request = wrap("Provider request timed out after 30000ms") + expect(SessionRetry.retryable(request, retryProvider)).toEqual({ message: "Provider request timed out after 30000ms" }) + }) + test("does not retry context overflow errors", () => { const error = new MessageV2.ContextOverflowError({ message: "Input exceeds context window of this model",