From f965db9e13e1e7d96b8cbca55667f3bf1a47b7b7 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Tue, 26 May 2026 21:22:24 -0500 Subject: [PATCH] feat: add headerTimeout cfg option, default it on only for openai w/ default of 10s (#29484) --- packages/opencode/src/config/provider.ts | 12 +- packages/opencode/src/provider/error.ts | 8 + packages/opencode/src/provider/provider.ts | 21 +- packages/opencode/src/session/message-v2.ts | 12 ++ .../test/provider/header-timeout.test.ts | 192 ++++++++++++++++++ .../opencode/test/provider/provider.test.ts | 3 +- packages/opencode/test/session/retry.test.ts | 9 + packages/sdk/js/src/v2/gen/types.gen.ts | 8 +- 8 files changed, 257 insertions(+), 8 deletions(-) create mode 100644 packages/opencode/test/provider/header-timeout.test.ts diff --git a/packages/opencode/src/config/provider.ts b/packages/opencode/src/config/provider.ts index 43565ddc61..fde4a5467f 100644 --- a/packages/opencode/src/config/provider.ts +++ b/packages/opencode/src/config/provider.ts @@ -92,11 +92,19 @@ export const Info = Schema.Struct({ timeout: Schema.optional( Schema.Union([PositiveInt, Schema.Literal(false)]).annotate({ description: - "Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout.", + "Timeout in milliseconds for full requests to this provider. Set to false to disable timeout.", + }), + ).annotate({ + description: "Timeout in milliseconds for full requests to this provider. Set to false to disable timeout.", + }), + headerTimeout: Schema.optional( + Schema.Union([PositiveInt, Schema.Literal(false)]).annotate({ + description: + "Timeout in milliseconds to wait for response headers. Provider integrations may set defaults. Set to false to disable timeout.", }), ).annotate({ description: - "Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout.", + "Timeout in milliseconds to wait for response headers. Provider integrations may set defaults. Set to false to disable timeout.", }), chunkTimeout: Schema.optional(PositiveInt).annotate({ description: diff --git a/packages/opencode/src/provider/error.ts b/packages/opencode/src/provider/error.ts index 7363b5ce59..4233839171 100644 --- a/packages/opencode/src/provider/error.ts +++ b/packages/opencode/src/provider/error.ts @@ -3,6 +3,14 @@ import { STATUS_CODES } from "http" import { iife } from "@/util/iife" import type { ProviderID } from "./schema" +export class HeaderTimeoutError extends Error { + public override readonly name = "ProviderHeaderTimeoutError" + + constructor(public readonly ms: number) { + super(`Provider response headers timed out after ${ms}ms`) + } +} + // Adapted from overflow detection patterns in: // https://github.com/badlogic/pi-mono/blob/main/packages/ai/src/utils/overflow.ts const OVERFLOW_PATTERNS = [ diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 496a2f6d2d..6f3cfccc51 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -28,9 +28,10 @@ import * as ProviderTransform from "./transform" import { ModelID, ProviderID } from "./schema" import { ModelStatus } from "./model-status" import { RuntimeFlags } from "@/effect/runtime-flags" +import { ProviderError } from "./error" const log = Log.create({ service: "provider" }) - +const OPENAI_HEADER_TIMEOUT_DEFAULT = 10_000 function shouldUseCopilotResponsesApi(modelID: string): boolean { const match = /^gpt-(\d+)/.exec(modelID) if (!match) return false @@ -85,6 +86,15 @@ function wrapSSE(res: Response, ms: number, ctl: AbortController) { }) } +function timeoutController(ms: number) { + const ctl = new AbortController() + const id = setTimeout(() => ctl.abort(new ProviderError.HeaderTimeoutError(ms)), 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 @@ -194,7 +204,7 @@ function custom(dep: CustomDep): Record { async getModel(sdk: any, modelID: string, _options?: Record) { return sdk.responses(modelID) }, - options: {}, + options: { headerTimeout: OPENAI_HEADER_TIMEOUT_DEFAULT }, }), xai: () => Effect.succeed({ @@ -1601,16 +1611,21 @@ export const layer = Layer.effect( const customFetch = options["fetch"] const chunkTimeout = options["chunkTimeout"] + const headerTimeout = options["headerTimeout"] delete options["chunkTimeout"] + delete options["headerTimeout"] options["fetch"] = async (input: any, init?: BunFetchRequestInit) => { const fetchFn = customFetch ?? fetch const opts = init ?? {} const chunkAbortCtl = typeof chunkTimeout === "number" && chunkTimeout > 0 ? new AbortController() : undefined + const headerTimeoutMs = headerTimeout === false ? undefined : headerTimeout + const headerTimeoutCtl = typeof headerTimeoutMs === "number" ? timeoutController(headerTimeoutMs) : undefined const signals: AbortSignal[] = [] if (opts.signal) signals.push(opts.signal) if (chunkAbortCtl) signals.push(chunkAbortCtl.signal) + if (headerTimeoutCtl) signals.push(headerTimeoutCtl.signal) if (options["timeout"] !== undefined && options["timeout"] !== null && options["timeout"] !== false) signals.push(AbortSignal.timeout(options["timeout"])) @@ -1639,7 +1654,7 @@ export const layer = Layer.effect( ...opts, // @ts-ignore see here: https://github.com/oven-sh/bun/issues/16682 timeout: false, - }) + }).finally(() => headerTimeoutCtl?.clear()) if (!chunkAbortCtl) return res return wrapSSE(res, chunkTimeout, chunkAbortCtl) diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 2745ff4f45..29cc83a1eb 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -1143,6 +1143,18 @@ export function fromError( }, { cause: e }, ).toObject() + case e instanceof ProviderError.HeaderTimeoutError: + return new APIError( + { + message: e.message, + isRetryable: true, + metadata: { + code: e.name, + timeoutMs: String(e.ms), + }, + }, + { cause: e }, + ).toObject() case APICallError.isInstance(e): const parsed = ProviderError.parseAPICallError({ providerID: ctx.providerID, diff --git a/packages/opencode/test/provider/header-timeout.test.ts b/packages/opencode/test/provider/header-timeout.test.ts new file mode 100644 index 0000000000..c6967d4ce4 --- /dev/null +++ b/packages/opencode/test/provider/header-timeout.test.ts @@ -0,0 +1,192 @@ +import { afterEach, expect } from "bun:test" +import { createServer, type Server } from "node:http" +import { streamText } from "ai" +import { Effect, Layer } from "effect" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { disposeAllInstances, provideTmpdirInstance, provideTmpdirServer } from "../fixture/fixture" +import { testEffect } from "../lib/effect" +import { reply, TestLLMServer } from "../lib/llm-server" +import { testProviderConfig } from "../lib/test-provider" +import { Env } from "@/env" +import { Plugin } from "@/plugin" +import { Provider } from "@/provider/provider" +import { ModelID, ProviderID } from "@/provider/schema" + +afterEach(async () => { + await disposeAllInstances() +}) + +const it = testEffect( + Layer.mergeAll(Provider.defaultLayer, Env.defaultLayer, Plugin.defaultLayer, TestLLMServer.layer, CrossSpawnSpawner.defaultLayer), +) + +it.live("headerTimeout does not abort delayed SSE body after headers arrive", () => + provideTmpdirServer( + ({ llm }) => + Effect.gen(function* () { + yield* llm.push(reply().wait(Bun.sleep(250)).text("late").stop()) + + const provider = yield* Provider.Service + const model = yield* provider.getModel(ProviderID.make("test"), ModelID.make("test-model")) + const result = streamText({ + model: yield* provider.getLanguage(model), + messages: [{ role: "user", content: "hello" }], + }) + + expect(yield* Effect.promise(() => result.text)).toBe("late") + }), + { + config: (url) => { + const config = testProviderConfig(url) + return { + ...config, + provider: { + test: { + ...config.provider.test, + options: { ...config.provider.test.options, headerTimeout: 50 }, + }, + }, + } + }, + }, + ), +) + +it.live("headerTimeout aborts when response headers do not arrive", () => + Effect.gen(function* () { + const server = yield* Effect.acquireRelease( + Effect.promise(() => delayedHeaderServer(250)), + (server) => Effect.sync(() => server.server.close()), + ) + + yield* provideTmpdirInstance( + () => + Effect.gen(function* () { + const provider = yield* Provider.Service + const model = yield* provider.getModel(ProviderID.make("test"), ModelID.make("test-model")) + const result = streamText({ + model: yield* provider.getLanguage(model), + onError() {}, + messages: [{ role: "user", content: "hello" }], + }) + + const errors = yield* Effect.promise(async () => { + const errors: string[] = [] + for await (const part of result.fullStream) { + if (part.type === "error") errors.push(String(part.error)) + } + return errors + }) + expect(errors.join("\n")).toContain("response headers timed out") + }), + { config: providerConfig(server.url, { headerTimeout: 50 }) }, + ) + }), +) + +it.live("headerTimeout is opt-in for non-OpenAI providers", () => + Effect.gen(function* () { + const server = yield* Effect.acquireRelease( + Effect.promise(() => delayedHeaderServer(100)), + (server) => Effect.sync(() => server.server.close()), + ) + + yield* provideTmpdirInstance( + () => + Effect.gen(function* () { + const provider = yield* Provider.Service + const model = yield* provider.getModel(ProviderID.make("test"), ModelID.make("test-model")) + const result = streamText({ + model: yield* provider.getLanguage(model), + messages: [{ role: "user", content: "hello" }], + }) + + expect(yield* Effect.promise(() => result.text)).toBe("ok") + }), + { config: providerConfig(server.url) }, + ) + }), +) + +it.live("OpenAI Codex headerTimeout default can be disabled by config", () => + Effect.gen(function* () { + yield* withAuthContent( + Effect.gen(function* () { + yield* provideTmpdirInstance( + () => + Effect.gen(function* () { + const provider = yield* Provider.Service + const openai = yield* provider.getProvider(ProviderID.openai) + expect(openai.options.headerTimeout).toBe(false) + }), + { config: { provider: { openai: { options: { headerTimeout: false } } } } }, + ) + }), + ) + }), +) + +it.live("OpenAI API auth gets 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(ProviderID.openai) + expect(openai.options.headerTimeout).toBe(10_000) + }), + ) + }), + { openai: { type: "api", key: "sk-test" } }, + ) + }), +) + +function providerConfig(url: string, options: Record = {}) { + const config = testProviderConfig(url) + return { + ...config, + provider: { + test: { + ...config.provider.test, + options: { ...config.provider.test.options, ...options }, + }, + }, + } +} + +async function delayedHeaderServer(delay: number): Promise<{ server: Server; url: string }> { + const server = createServer((_, res) => { + setTimeout(() => { + res.writeHead(200, { "content-type": "text/event-stream" }) + res.end('data: {"choices":[{"delta":{"content":"ok"}}]}\n\ndata: [DONE]\n\n') + }, delay) + }) + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)) + const address = server.address() + if (!address || typeof address === "string") throw new Error("server did not bind to a TCP port") + return { server, url: `http://127.0.0.1:${address.port}` } +} + +function withAuthContent(self: Effect.Effect, value: Record = defaultAuthContent()) { + return Effect.acquireUseRelease( + Effect.sync(() => { + const previous = process.env.OPENCODE_AUTH_CONTENT + process.env.OPENCODE_AUTH_CONTENT = JSON.stringify(value) + return previous + }), + () => self, + (previous) => + Effect.sync(() => { + if (previous === undefined) delete process.env.OPENCODE_AUTH_CONTENT + else process.env.OPENCODE_AUTH_CONTENT = previous + }), + ) +} + +function defaultAuthContent() { + return { + openai: { type: "oauth", refresh: "refresh", access: "access", expires: Date.now() + 60_000 }, + } +} diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index 8cf93e22d6..1ea2c82424 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -282,9 +282,10 @@ it.instance( expect(providers[ProviderID.anthropic]).toBeDefined() // Config options should be merged expect(providers[ProviderID.anthropic].options.timeout).toBe(60000) + expect(providers[ProviderID.anthropic].options.headerTimeout).toBe(10000) expect(providers[ProviderID.anthropic].options.chunkTimeout).toBe(15000) }), - { config: { provider: { anthropic: { options: { timeout: 60000, chunkTimeout: 15000 } } } } }, + { config: { provider: { anthropic: { options: { timeout: 60000, headerTimeout: 10000, chunkTimeout: 15000 } } } } }, ) it.instance("getModel returns model for valid provider/model", () => diff --git a/packages/opencode/test/session/retry.test.ts b/packages/opencode/test/session/retry.test.ts index 22ff6cde81..26c55bde71 100644 --- a/packages/opencode/test/session/retry.test.ts +++ b/packages/opencode/test/session/retry.test.ts @@ -7,6 +7,7 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { SessionRetry } from "../../src/session/retry" import { MessageV2 } from "../../src/session/message-v2" import { ProviderID } from "../../src/provider/schema" +import { ProviderError } from "../../src/provider/error" import { SessionID } from "../../src/session/schema" import { SessionStatus } from "../../src/session/status" import { provideTmpdirInstance } from "../fixture/fixture" @@ -163,6 +164,14 @@ describe("session.retry.retryable", () => { expect(SessionRetry.retryable(error, retryProvider)).toEqual({ message: msg }) }) + test("retries transport timeout errors", () => { + const request = MessageV2.fromError(new ProviderError.HeaderTimeoutError(10000), { providerID }) + expect(MessageV2.APIError.isInstance(request)).toBe(true) + expect(SessionRetry.retryable(request, retryProvider)).toEqual({ + message: "Provider response headers timed out after 10000ms", + }) + }) + test("does not retry context overflow errors", () => { const error = new MessageV2.ContextOverflowError({ message: "Input exceeds context window of this model", diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 89cfc65590..26b99e25e8 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1049,11 +1049,15 @@ export type ProviderConfig = { enterpriseUrl?: string setCacheKey?: boolean /** - * Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout. + * Timeout in milliseconds for full requests to this provider. Set to false to disable timeout. */ timeout?: number | false + /** + * Timeout in milliseconds to wait for response headers. Provider integrations may set defaults. Set to false to disable timeout. + */ + headerTimeout?: number | false chunkTimeout?: number - [key: string]: unknown | string | boolean | number | false | number | undefined + [key: string]: unknown | string | boolean | number | false | number | false | number | undefined } models?: { [key: string]: {