Skip to content

Commit 77ae84c

Browse files
committed
fix(opencode): bound codex stream stalls
1 parent 848d763 commit 77ae84c

4 files changed

Lines changed: 35 additions & 5 deletions

File tree

packages/opencode/src/plugin/codex.ts

Lines changed: 7 additions & 2 deletions
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([
@@ -140,9 +142,10 @@ async function exchangeCodeForTokens(code: string, redirectUri: string, pkce: Pk
140142
return response.json()
141143
}
142144

143-
async function refreshAccessToken(refreshToken: string, issuer = ISSUER): Promise<TokenResponse> {
145+
async function refreshAccessToken(refreshToken: string, issuer = ISSUER, signal?: AbortSignal): Promise<TokenResponse> {
144146
const response = await fetch(`${issuer}/oauth/token`, {
145147
method: "POST",
148+
signal,
146149
headers: { "Content-Type": "application/x-www-form-urlencoded" },
147150
body: new URLSearchParams({
148151
grant_type: "refresh_token",
@@ -421,6 +424,8 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug
421424

422425
return {
423426
apiKey: OAUTH_DUMMY_KEY,
427+
timeout: CODEX_REQUEST_TIMEOUT,
428+
chunkTimeout: CODEX_CHUNK_TIMEOUT,
424429
async fetch(requestInput: RequestInfo | URL, init?: RequestInit) {
425430
// Remove dummy API key authorization header
426431
if (init?.headers) {
@@ -445,7 +450,7 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug
445450
if (!currentAuth.access || currentAuth.expires < Date.now()) {
446451
if (!refreshPromise) {
447452
log.info("refreshing codex access token")
448-
refreshPromise = refreshAccessToken(currentAuth.refresh, issuer)
453+
refreshPromise = refreshAccessToken(currentAuth.refresh, issuer, init?.signal ?? undefined)
449454
.then(async (tokens) => {
450455
const accountId = extractAccountId(tokens) || authWithAccount.accountId
451456
await input.client.auth.set({

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)