diff --git a/packages/opencode/src/plugin/openai/codex.ts b/packages/opencode/src/plugin/openai/codex.ts index a577e2a82a..b55fa3dda8 100644 --- a/packages/opencode/src/plugin/openai/codex.ts +++ b/packages/opencode/src/plugin/openai/codex.ts @@ -410,7 +410,7 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug websocketFetches.push(websocketFetch) websocketFetchInstalled = true } - if (auth.type !== "oauth") return websocketFetch ? { fetch: websocketFetch } : {} + if (auth.type !== "oauth") return websocketFetch ? { fetch: websocketFetch, headerTimeout: false } : {} let refreshPromise: | Promise<{ @@ -421,6 +421,7 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug return { apiKey: OAUTH_DUMMY_KEY, + ...(websocketFetch ? { headerTimeout: false } : {}), async fetch(requestInput: RequestInfo | URL, init?: RequestInit) { if (init?.headers) { if (init.headers instanceof Headers) { diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 2c50a9a60d..8a8ba63907 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -190,13 +190,13 @@ function custom(dep: CustomDep): Record { options: ok ? {} : { apiKey: "public" }, } }), - openai: () => + openai: (input) => Effect.succeed({ autoload: false, async getModel(sdk: any, modelID: string, _options?: Record) { return sdk.responses(modelID) }, - options: { headerTimeout: OPENAI_HEADER_TIMEOUT_DEFAULT }, + options: { headerTimeout: input.options.headerTimeout ?? OPENAI_HEADER_TIMEOUT_DEFAULT }, }), xai: () => Effect.succeed({ @@ -1425,7 +1425,7 @@ export const layer = Layer.effect( log.error("Provider does not exist in model list " + providerID) continue } - const result = yield* fn(data) + const result = yield* fn(providers[providerID] ?? data) if (result && (result.autoload || providers[providerID])) { if (result.getModel) modelLoaders[providerID] = result.getModel if (result.vars) varsLoaders[providerID] = result.vars diff --git a/packages/opencode/test/plugin/codex.test.ts b/packages/opencode/test/plugin/codex.test.ts index a375fe4ee1..02a83e8107 100644 --- a/packages/opencode/test/plugin/codex.test.ts +++ b/packages/opencode/test/plugin/codex.test.ts @@ -137,9 +137,23 @@ describe("plugin.codex", () => { expect(disabledOptions.fetch).toBeUndefined() expect(enabledOptions.fetch).toBeFunction() + expect(disabledOptions.headerTimeout).toBeUndefined() + expect(enabledOptions.headerTimeout).toBe(false) await enabled.dispose?.() }) + test("disables the HTTP header timeout for websocket OAuth transport", async () => { + const hooks = await CodexAuthPlugin({} as never, { experimentalWebSockets: true }) + const options = await hooks.auth!.loader!( + async () => ({ type: "oauth", refresh: "refresh", access: "access", expires: Date.now() + 60_000 }) as never, + {} as never, + ) + + expect(options.fetch).toBeFunction() + expect(options.headerTimeout).toBe(false) + await hooks.dispose?.() + }) + test("deduplicates concurrent Codex token refreshes", async () => { let auth = { type: "oauth" as const, diff --git a/packages/opencode/test/provider/header-timeout.test.ts b/packages/opencode/test/provider/header-timeout.test.ts index a3caf4fd8a..90008aac24 100644 --- a/packages/opencode/test/provider/header-timeout.test.ts +++ b/packages/opencode/test/provider/header-timeout.test.ts @@ -11,6 +11,12 @@ import { Env } from "@/env" import { Plugin } from "@/plugin" import { Provider } from "@/provider/provider" import { ProviderError } from "@/provider/error" +import { RuntimeFlags } from "@/effect/runtime-flags" +import { FSUtil } from "@opencode-ai/core/fs-util" +import { Config } from "@/config/config" +import { Auth } from "@/auth" +import { ModelsDev } from "@opencode-ai/core/models-dev" +import { EventV2Bridge } from "@/event-v2-bridge" afterEach(async () => { await disposeAllInstances() @@ -19,6 +25,8 @@ afterEach(async () => { const it = testEffect( Layer.mergeAll(Provider.defaultLayer, Env.defaultLayer, Plugin.defaultLayer, CrossSpawnSpawner.defaultLayer), ) +const httpOnly = testEffect(providerLayer({ disableDefaultPlugins: true })) +const websockets = testEffect(providerLayer({ experimentalWebSockets: true })) it.live("headerTimeout does not abort delayed SSE body after headers arrive", () => Effect.gen(function* () { @@ -152,7 +160,7 @@ it.live("OpenAI Codex headerTimeout default can be disabled by config", () => }), ) -it.live("OpenAI API auth gets default headerTimeout", () => +httpOnly.live("OpenAI API auth gets default headerTimeout", () => Effect.gen(function* () { yield* withAuthContent( Effect.gen(function* () { @@ -169,6 +177,65 @@ it.live("OpenAI API auth gets default headerTimeout", () => }), ) +websockets.live("OpenAI Codex websocket transport disables default headerTimeout", () => + Effect.gen(function* () { + yield* withAuthContent( + Effect.gen(function* () { + yield* provideTmpdirInstance(() => + Effect.gen(function* () { + const provider = yield* Provider.Service + const openai = yield* provider.getProvider(ProviderV2.ID.openai) + expect(openai.options.headerTimeout).toBe(false) + }), + ) + }), + { openai: { type: "api", key: "sk-test" } }, + ) + }), +) + +websockets.live("OpenAI Codex websocket transport preserves configured headerTimeout", () => + Effect.gen(function* () { + yield* withAuthContent( + Effect.gen(function* () { + yield* provideTmpdirInstance( + () => + Effect.gen(function* () { + const provider = yield* Provider.Service + const openai = yield* provider.getProvider(ProviderV2.ID.openai) + expect(openai.options.headerTimeout).toBe(30_000) + }), + { config: { provider: { openai: { options: { headerTimeout: 30_000 } } } } }, + ) + }), + { openai: { type: "api", key: "sk-test" } }, + ) + }), +) + +function providerLayer(flags: Partial) { + const runtime = RuntimeFlags.layer(flags) + const plugin = Plugin.layer.pipe( + Layer.provide(EventV2Bridge.defaultLayer), + Layer.provide(Config.defaultLayer), + Layer.provide(runtime), + ) + return Layer.mergeAll( + Provider.layer.pipe( + Layer.provide(FSUtil.defaultLayer), + Layer.provide(Env.defaultLayer), + Layer.provide(Config.defaultLayer), + Layer.provide(Auth.defaultLayer), + Layer.provide(plugin), + Layer.provide(ModelsDev.defaultLayer), + Layer.provide(runtime), + ), + Env.defaultLayer, + plugin, + CrossSpawnSpawner.defaultLayer, + ) +} + function providerConfig(url: string, options: Record = {}) { const config = testProviderConfig(url) return {