diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index fdcde3e7fb..5acb4c091d 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -36,6 +36,12 @@ const OPENAI_HEADER_TIMEOUT_DEFAULT = 10_000 // Custom fetch adapters can opt out when they apply route-specific header timing internally. const HEADER_TIMEOUT = Symbol.for("opencode.provider.header-timeout") +export function headerTimeoutForFetch(fetch: unknown, timeout: number | false | undefined) { + if (timeout === false) return undefined + if (typeof fetch === "function" && fetch[HEADER_TIMEOUT] === false) return undefined + return timeout +} + function wrapSSE(res: Response, ms: number, ctl: AbortController) { if (typeof ms !== "number" || ms <= 0) return res if (!res.body) return res @@ -1605,8 +1611,7 @@ export const layer = Layer.effect( const fetchFn = customFetch ?? fetch const opts = init ?? {} const chunkAbortCtl = typeof chunkTimeout === "number" && chunkTimeout > 0 ? new AbortController() : undefined - const headerTimeoutMs = - headerTimeout === false || customFetch?.[HEADER_TIMEOUT] === false ? undefined : headerTimeout + const headerTimeoutMs = headerTimeoutForFetch(customFetch, headerTimeout) const headerTimeoutCtl = typeof headerTimeoutMs === "number" ? timeoutController(headerTimeoutMs) : undefined const signals: AbortSignal[] = [] diff --git a/packages/opencode/test/plugin/codex.test.ts b/packages/opencode/test/plugin/codex.test.ts index 3169258e60..dd20e1b744 100644 --- a/packages/opencode/test/plugin/codex.test.ts +++ b/packages/opencode/test/plugin/codex.test.ts @@ -176,14 +176,24 @@ describe("plugin.codex", () => { }) test("can disable websocket HTTP fallback header timeout", async () => { - const response = await fetchWithHeaderTimeout( - async () => new Response("http"), - "https://example.com/v1/responses", - undefined, - false, - ) + using server = Bun.serve({ + port: 0, + async fetch() { + await Bun.sleep(30) + return new Response("http") + }, + }) + const hooks = await CodexAuthPlugin({} as never, { experimentalWebSockets: true }) + await hooks.config!({ provider: { openai: { options: { headerTimeout: false } } } } as never) + const loaded = await hooks.auth!.loader!(async () => ({ type: "api", key: "sk-test" }) as never, {} as never) + + const response = await loaded.fetch!(new URL("/v1/responses", server.url), { + method: "POST", + body: JSON.stringify({ stream: true }), + }) expect(await response.text()).toBe("http") + await hooks.dispose?.() }) test("deduplicates concurrent Codex token refreshes", async () => { diff --git a/packages/opencode/test/provider/header-timeout.test.ts b/packages/opencode/test/provider/header-timeout.test.ts index a3caf4fd8a..ad470cd1d4 100644 --- a/packages/opencode/test/provider/header-timeout.test.ts +++ b/packages/opencode/test/provider/header-timeout.test.ts @@ -1,4 +1,4 @@ -import { afterEach, expect } from "bun:test" +import { afterEach, expect, test } from "bun:test" import { createServer, type Server } from "node:http" import { streamText } from "ai" import { Effect, Layer } from "effect" @@ -20,6 +20,16 @@ const it = testEffect( Layer.mergeAll(Provider.defaultLayer, Env.defaultLayer, Plugin.defaultLayer, CrossSpawnSpawner.defaultLayer), ) +test("marked custom fetch adapters opt out of outer header timeout", () => { + const fetch = Object.assign(() => Promise.resolve(new Response()), { + [Symbol.for("opencode.provider.header-timeout")]: false, + }) + + expect(Provider.headerTimeoutForFetch(fetch, 10_000)).toBeUndefined() + expect(Provider.headerTimeoutForFetch(() => Promise.resolve(new Response()), 10_000)).toBe(10_000) + expect(Provider.headerTimeoutForFetch(fetch, false)).toBeUndefined() +}) + it.live("headerTimeout does not abort delayed SSE body after headers arrive", () => Effect.gen(function* () { const server = yield* Effect.acquireRelease(