Skip to content

Commit 8a781ca

Browse files
committed
fix(opencode): bound codex stream stalls
1 parent 0448a30 commit 8a781ca

4 files changed

Lines changed: 47 additions & 4 deletions

File tree

packages/opencode/src/plugin/codex.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ const log = Log.create({ service: "plugin.codex" })
1111
const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
1212
const ISSUER = "https://auth.openai.com"
1313
const CODEX_API_ENDPOINT = "https://chatgpt.com/backend-api/codex/responses"
14+
const CODEX_REQUEST_TIMEOUT = 30_000
15+
const CODEX_CHUNK_TIMEOUT = 120_000
1416
const OAUTH_PORT = 1455
1517
const OAUTH_POLLING_SAFETY_MARGIN_MS = 3000
1618
const ALLOWED_MODELS = new Set([
@@ -141,21 +143,35 @@ async function exchangeCodeForTokens(code: string, redirectUri: string, pkce: Pk
141143
}
142144

143145
async function refreshAccessToken(refreshToken: string, issuer = ISSUER): Promise<TokenResponse> {
146+
const timeout = timeoutController(CODEX_REQUEST_TIMEOUT)
144147
const response = await fetch(`${issuer}/oauth/token`, {
145148
method: "POST",
149+
signal: timeout.signal,
146150
headers: { "Content-Type": "application/x-www-form-urlencoded" },
147151
body: new URLSearchParams({
148152
grant_type: "refresh_token",
149153
refresh_token: refreshToken,
150154
client_id: CLIENT_ID,
151155
}).toString(),
152-
})
156+
}).finally(() => timeout.clear())
153157
if (!response.ok) {
154158
throw new Error(`Token refresh failed: ${response.status}`)
155159
}
156160
return response.json()
157161
}
158162

163+
function timeoutController(ms: number) {
164+
const ctl = new AbortController()
165+
const id = setTimeout(
166+
() => ctl.abort(new DOMException(`Codex request timed out after ${ms}ms`, "TimeoutError")),
167+
ms,
168+
)
169+
return {
170+
signal: ctl.signal,
171+
clear: () => clearTimeout(id),
172+
}
173+
}
174+
159175
const HTML_SUCCESS = `<!doctype html>
160176
<html>
161177
<head>
@@ -421,6 +437,8 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug
421437

422438
return {
423439
apiKey: OAUTH_DUMMY_KEY,
440+
timeout: CODEX_REQUEST_TIMEOUT,
441+
chunkTimeout: CODEX_CHUNK_TIMEOUT,
424442
async fetch(requestInput: RequestInfo | URL, init?: RequestInit) {
425443
// Remove dummy API key authorization header
426444
if (init?.headers) {

packages/opencode/src/provider/provider.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import { ModelStatus } from "./model-status"
3030
import { RuntimeFlags } from "@/effect/runtime-flags"
3131

3232
const log = Log.create({ service: "provider" })
33+
const PROVIDER_TIMEOUT_DEFAULT = 300_000
3334

3435
function shouldUseCopilotResponsesApi(modelID: string): boolean {
3536
const match = /^gpt-(\d+)/.exec(modelID)
@@ -85,6 +86,18 @@ function wrapSSE(res: Response, ms: number, ctl: AbortController) {
8586
})
8687
}
8788

89+
function timeoutController(ms: number) {
90+
const ctl = new AbortController()
91+
const id = setTimeout(
92+
() => ctl.abort(new DOMException(`Provider request timed out after ${ms}ms`, "TimeoutError")),
93+
ms,
94+
)
95+
return {
96+
signal: ctl.signal,
97+
clear: () => clearTimeout(id),
98+
}
99+
}
100+
88101
function googleVertexAnthropicBaseURL(project: string | undefined, location: string | undefined) {
89102
if (!project) return
90103
if (location !== "eu" && location !== "us") return
@@ -1607,12 +1620,13 @@ export const layer = Layer.effect(
16071620
const fetchFn = customFetch ?? fetch
16081621
const opts = init ?? {}
16091622
const chunkAbortCtl = typeof chunkTimeout === "number" && chunkTimeout > 0 ? new AbortController() : undefined
1623+
const requestTimeout = options["timeout"] === false ? undefined : (options["timeout"] ?? PROVIDER_TIMEOUT_DEFAULT)
1624+
const requestTimeoutCtl = typeof requestTimeout === "number" ? timeoutController(requestTimeout) : undefined
16101625
const signals: AbortSignal[] = []
16111626

16121627
if (opts.signal) signals.push(opts.signal)
16131628
if (chunkAbortCtl) signals.push(chunkAbortCtl.signal)
1614-
if (options["timeout"] !== undefined && options["timeout"] !== null && options["timeout"] !== false)
1615-
signals.push(AbortSignal.timeout(options["timeout"]))
1629+
if (requestTimeoutCtl) signals.push(requestTimeoutCtl.signal)
16161630

16171631
const combined = signals.length === 0 ? null : signals.length === 1 ? signals[0] : AbortSignal.any(signals)
16181632
if (combined) opts.signal = combined
@@ -1639,7 +1653,7 @@ export const layer = Layer.effect(
16391653
...opts,
16401654
// @ts-ignore see here: https://github.com/oven-sh/bun/issues/16682
16411655
timeout: false,
1642-
})
1656+
}).finally(() => requestTimeoutCtl?.clear())
16431657

16441658
if (!chunkAbortCtl) return res
16451659
return wrapSSE(res, chunkTimeout, chunkAbortCtl)

packages/opencode/src/session/retry.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,9 @@ export function retryable(error: Err, provider: string) {
125125
const msg = isRecord(error.data) ? error.data.message : undefined
126126
if (typeof msg === "string") {
127127
const lower = msg.toLowerCase()
128+
if (lower.includes("sse read timed out") || lower.includes("provider request timed out")) {
129+
return { message: msg }
130+
}
128131
if (
129132
lower.includes("rate increased too quickly") ||
130133
lower.includes("rate limit") ||

packages/opencode/test/session/retry.test.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,14 @@ describe("session.retry.retryable", () => {
163163
expect(SessionRetry.retryable(error, retryProvider)).toEqual({ message: msg })
164164
})
165165

166+
test("retries transport timeout errors", () => {
167+
const sse = wrap("SSE read timed out")
168+
expect(SessionRetry.retryable(sse, retryProvider)).toEqual({ message: "SSE read timed out" })
169+
170+
const request = wrap("Provider request timed out after 30000ms")
171+
expect(SessionRetry.retryable(request, retryProvider)).toEqual({ message: "Provider request timed out after 30000ms" })
172+
})
173+
166174
test("does not retry context overflow errors", () => {
167175
const error = new MessageV2.ContextOverflowError({
168176
message: "Input exceeds context window of this model",

0 commit comments

Comments
 (0)