From 2b72a955028b6a29073cc75d54d0b3b341819cde Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Wed, 2 Sep 2026 17:55:42 -0500 Subject: [PATCH 1/2] fix(core): default provider header and chunk timeouts to five minutes --- packages/ai/src/route/transport/http.ts | 66 ++++++++- packages/ai/src/schema/options.ts | 17 ++- packages/ai/test/http-timeout.test.ts | 144 ++++++++++++++++++++ packages/core/src/aisdk.ts | 34 +++-- packages/core/src/model-resolver.ts | 16 ++- packages/core/src/request-timeouts.ts | 17 +++ packages/core/src/session/model-request.ts | 3 +- packages/core/test/aisdk.test.ts | 62 +++++++++ packages/core/test/model-resolver.test.ts | 16 +++ packages/www/src/docs/content/providers.mdx | 27 ++++ 10 files changed, 380 insertions(+), 22 deletions(-) create mode 100644 packages/ai/test/http-timeout.test.ts create mode 100644 packages/core/src/request-timeouts.ts diff --git a/packages/ai/src/route/transport/http.ts b/packages/ai/src/route/transport/http.ts index c8f94d1a7a1a..70f7adf7dd89 100644 --- a/packages/ai/src/route/transport/http.ts +++ b/packages/ai/src/route/transport/http.ts @@ -1,11 +1,20 @@ -import { Effect } from "effect" +import { Duration, Effect, Stream } from "effect" import { Headers, HttpClientRequest } from "effect/unstable/http" import { Auth } from "../auth.js" import { render as renderEndpoint } from "../endpoint.js" import { Framing } from "../framing.js" import type { HttpMiddleware, Transport, TransportPrepareInput } from "./index.js" import * as ProviderShared from "../../protocols/shared.js" -import { mergeJsonRecords, type LLMRequest } from "../../schema/index.js" +import { + AIError, + DEFAULT_HTTP_TIMEOUT_MS, + mergeJsonRecords, + TransportError, + type HttpContext, + type HttpTimeout, + type LLMRequest, + type TransportOperation, +} from "../../schema/index.js" import { RequestExecutor } from "../executor.js" export type JsonRequestInput = TransportPrepareInput @@ -87,17 +96,62 @@ export const httpJson = (input: HttpJsonInput): HttpJs middleware: prepareInput.middleware, } }), - execute: (prepared, _request, runtime) => + execute: (prepared, request, runtime) => Effect.gen(function* () { - const response = yield* runtime.http.execute(prepared.request, prepared.middleware) + const headerTimeout = timeoutDuration(request.http?.headerTimeout) + const chunkTimeout = timeoutDuration(request.http?.chunkTimeout) + const execute = runtime.http.execute(prepared.request, prepared.middleware) + const response = yield* headerTimeout + ? execute.pipe( + Effect.timeoutOrElse({ + duration: headerTimeout, + orElse: () => + Effect.fail( + timeoutError( + prepared.request.url, + "request", + `Timed out waiting for response headers after ${Duration.format(headerTimeout)}`, + ), + ), + }), + ) + : execute + const http = RequestExecutor.responseHttp(response) + const bytes = RequestExecutor.responseStream(response) return { - frames: prepared.framing.frame(RequestExecutor.responseStream(response)), - http: RequestExecutor.responseHttp(response), + frames: prepared.framing.frame( + chunkTimeout + ? bytes.pipe( + Stream.timeoutOrElse({ + duration: chunkTimeout, + orElse: () => + Stream.fail( + timeoutError( + prepared.request.url, + "read", + `Timed out waiting for response data after ${Duration.format(chunkTimeout)}`, + http, + ), + ), + }), + ) + : bytes, + ), + http, body: prepared.framing.body, } }), }) +// `false` disables a timer; an unset value uses the shared default. +const timeoutDuration = (value: HttpTimeout | undefined) => + value === false ? undefined : Duration.millis(value ?? DEFAULT_HTTP_TIMEOUT_MS) + +const timeoutError = (url: string, operation: TransportOperation, message: string, http?: HttpContext) => + new AIError({ + reason: new TransportError({ message, transport: "http", operation, code: "Timeout", url, http }), + }) + export const sseJson = { id: "http-json/sse", with: () => httpJson({ framing: Framing.sse }), diff --git a/packages/ai/src/schema/options.ts b/packages/ai/src/schema/options.ts index 3cf9f6a3ee11..7c98a3f2c8a6 100644 --- a/packages/ai/src/schema/options.ts +++ b/packages/ai/src/schema/options.ts @@ -43,10 +43,21 @@ export const mergeProviderOptions = ( ...items: ReadonlyArray ): ProviderOptions | undefined => mergeJsonRecords(...items) +/** Milliseconds for an HTTP timeout, or `false` to disable it. */ +export const HttpTimeout = Schema.Union([Schema.Number.check(Schema.isGreaterThan(0)), Schema.Literal(false)]) +export type HttpTimeout = Schema.Schema.Type + +/** Default for `headerTimeout` and `chunkTimeout` when a request leaves them unset. */ +export const DEFAULT_HTTP_TIMEOUT_MS = 300_000 + export class HttpOptions extends Schema.Class("AI.HttpOptions")({ body: Schema.optional(JsonSchema), headers: Schema.optional(Schema.Record(Schema.String, Schema.String)), query: Schema.optional(Schema.Record(Schema.String, Schema.String)), + /** Time allowed for response headers to arrive. Defaults to five minutes. */ + headerTimeout: Schema.optional(HttpTimeout), + /** Time allowed between streamed response chunks once headers have arrived. Defaults to five minutes. */ + chunkTimeout: Schema.optional(HttpTimeout), }) {} export namespace HttpOptions { @@ -60,8 +71,10 @@ export const mergeHttpOptions = (...items: ReadonlyArray item?.body)) const headers = mergeStringRecords(...items.map((item) => item?.headers)) const query = mergeStringRecords(...items.map((item) => item?.query)) - if (!body && !headers && !query) return undefined - return new HttpOptions({ body, headers, query }) + const headerTimeout = items.findLast((item) => item?.headerTimeout !== undefined)?.headerTimeout + const chunkTimeout = items.findLast((item) => item?.chunkTimeout !== undefined)?.chunkTimeout + if (!body && !headers && !query && headerTimeout === undefined && chunkTimeout === undefined) return undefined + return new HttpOptions({ body, headers, query, headerTimeout, chunkTimeout }) } export class GenerationOptions extends Schema.Class("LLM.GenerationOptions")({ diff --git a/packages/ai/test/http-timeout.test.ts b/packages/ai/test/http-timeout.test.ts new file mode 100644 index 000000000000..4088ba6dc56d --- /dev/null +++ b/packages/ai/test/http-timeout.test.ts @@ -0,0 +1,144 @@ +import { describe, expect } from "bun:test" +import { Deferred, Effect, Fiber } from "effect" +import * as TestClock from "effect/testing/TestClock" +import { HttpOptions, LLM, mergeHttpOptions } from "../src/index.js" +import { LLMClient } from "../src/route.js" +import { configure } from "../src/providers/openai.js" +import { dynamicResponse } from "./lib/http.js" +import { deltaChunk, finishChunk } from "./lib/openai-chunks.js" +import { sseEvents } from "./lib/sse.js" +import { it } from "./lib/effect.js" + +const model = configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).chat("gpt-4.1-mini") +const body = sseEvents(deltaChunk({ role: "assistant", content: "Hi" }), finishChunk("stop")) +const SSE = { headers: { "content-type": "text/event-stream" } } + +// Never produces headers; the header timer is the only way out. +const silentServer = dynamicResponse(() => Effect.never) + +// Sends one chunk, then stalls until `resume` releases the rest of the body. +const stalledServer = Effect.gen(function* () { + const stalled = yield* Deferred.make() + let resume = () => {} + const released = new Promise((resolve) => { + resume = resolve + }) + const encoder = new TextEncoder() + const layer = dynamicResponse((input) => + Effect.sync(() => + input.respond( + new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode(`data: ${JSON.stringify(deltaChunk({ content: "Hi" }))}\n\n`)) + }, + async pull(controller) { + Deferred.doneUnsafe(stalled, Effect.void) + await released + controller.enqueue(encoder.encode(`data: ${JSON.stringify(finishChunk("stop"))}\n\ndata: [DONE]\n\n`)) + controller.close() + }, + }), + SSE, + ), + ), + ) + return { layer, stalled, resume: () => resume() } +}) + +const slowHeadersServer = dynamicResponse((input) => + Effect.sleep("10 minutes").pipe(Effect.as(input.respond(body, SSE))), +) + +describe("HTTP transport timeouts", () => { + it.effect("fails when response headers take longer than five minutes", () => + Effect.gen(function* () { + const fiber = yield* LLMClient.generate(LLM.request({ model, prompt: "Hello" })).pipe( + Effect.provide(silentServer), + Effect.flip, + Effect.forkChild({ startImmediately: true }), + ) + yield* TestClock.adjust("5 minutes") + const error = yield* Fiber.join(fiber) + + expect(error.reason).toMatchObject({ + _tag: "Transport", + transport: "http", + operation: "request", + code: "Timeout", + }) + expect(error.reason.message).toContain("response headers") + }), + ) + + it.effect("fails when the response body stalls for five minutes", () => + Effect.gen(function* () { + const server = yield* stalledServer + const fiber = yield* LLMClient.generate(LLM.request({ model, prompt: "Hello" })).pipe( + Effect.provide(server.layer), + Effect.flip, + Effect.forkChild({ startImmediately: true }), + ) + yield* Deferred.await(server.stalled) + yield* Effect.yieldNow + yield* TestClock.adjust("5 minutes") + const error = yield* Fiber.join(fiber) + + expect(error.reason).toMatchObject({ _tag: "Transport", transport: "http", operation: "read", code: "Timeout" }) + expect(error.reason.http).toMatchObject({ status: 200 }) + }), + ) + + it.effect("applies a configured header timeout", () => + Effect.gen(function* () { + const fiber = yield* LLMClient.generate( + LLM.request({ model, prompt: "Hello", http: { headerTimeout: 1_000 } }), + ).pipe(Effect.provide(silentServer), Effect.flip, Effect.forkChild({ startImmediately: true })) + yield* TestClock.adjust("1 second") + const error = yield* Fiber.join(fiber) + + expect(error.reason).toMatchObject({ _tag: "Transport", operation: "request", code: "Timeout" }) + }), + ) + + it.effect("disables the header timeout with false", () => + Effect.gen(function* () { + const fiber = yield* LLMClient.generate( + LLM.request({ model, prompt: "Hello", http: { headerTimeout: false } }), + ).pipe(Effect.provide(slowHeadersServer), Effect.forkChild({ startImmediately: true })) + yield* TestClock.adjust("10 minutes") + const response = yield* Fiber.join(fiber) + + expect(response.text).toBe("Hi") + }), + ) + + it.effect("disables the chunk timeout with false", () => + Effect.gen(function* () { + const server = yield* stalledServer + const fiber = yield* LLMClient.generate( + LLM.request({ model, prompt: "Hello", http: { chunkTimeout: false } }), + ).pipe(Effect.provide(server.layer), Effect.forkChild({ startImmediately: true })) + yield* Deferred.await(server.stalled) + yield* Effect.yieldNow + yield* TestClock.adjust("10 minutes") + server.resume() + const response = yield* Fiber.join(fiber) + + expect(response.text).toBe("Hi") + }), + ) + + it.effect("merges timeouts with later values winning", () => + Effect.sync(() => { + const merged = mergeHttpOptions( + new HttpOptions({ headerTimeout: 1_000, chunkTimeout: 2_000 }), + new HttpOptions({ headers: { a: "b" } }), + new HttpOptions({ chunkTimeout: false }), + ) + + expect(merged).toMatchObject({ headers: { a: "b" }, headerTimeout: 1_000, chunkTimeout: false }) + expect(mergeHttpOptions(new HttpOptions({}), undefined)).toBeUndefined() + expect(mergeHttpOptions(new HttpOptions({ headerTimeout: false }))?.headerTimeout).toBe(false) + }), + ) +}) diff --git a/packages/core/src/aisdk.ts b/packages/core/src/aisdk.ts index 215d6dcdea8d..aedb3d781e5b 100644 --- a/packages/core/src/aisdk.ts +++ b/packages/core/src/aisdk.ts @@ -16,6 +16,7 @@ import type { SharedV3ProviderOptions, } from "@ai-sdk/provider" import { + DEFAULT_HTTP_TIMEOUT_MS, FinishReason, LLMEvent, AIError, @@ -36,6 +37,7 @@ import { Cause, Context, Effect, Layer, Option, Schema, Scope, Stream } from "ef import { makeParser } from "effect/unstable/encoding/Sse" import type { ID, Info } from "./model.js" import { Provider } from "./provider.js" +import { RequestTimeouts } from "./request-timeouts.js" import { State } from "./state.js" type SDK = any @@ -126,19 +128,28 @@ function prepareOptions(model: Info, pkg: string) { } const customFetch = options.fetch - const chunkTimeout = options.chunkTimeout + const timeouts = RequestTimeouts.from(options) + const chunkTimeout = timeouts.chunkTimeout ?? DEFAULT_HTTP_TIMEOUT_MS + const headerTimeout = timeouts.headerTimeout ?? DEFAULT_HTTP_TIMEOUT_MS delete options.chunkTimeout + delete options.headerTimeout options.fetch = async (input: Parameters[0], init?: RequestInit) => { const opts = { ...(init ?? {}) } - const signals = [ + const chunkAbortCtl = chunkTimeout === false ? undefined : new AbortController() + const headerAbortCtl = headerTimeout === false ? undefined : new AbortController() + // Only covers the wait for response headers; the chunk timer takes over once the body streams. + const headerTimer = + headerTimeout === false + ? undefined + : setTimeout(() => headerAbortCtl?.abort(new Error(HEADER_TIMEOUT_MESSAGE)), headerTimeout) + const abortSignals = [ opts.signal, - typeof chunkTimeout === "number" && chunkTimeout > 0 ? new AbortController() : undefined, + chunkAbortCtl?.signal, + headerAbortCtl?.signal, options.timeout !== undefined && options.timeout !== null && options.timeout !== false ? AbortSignal.timeout(options.timeout) : undefined, - ].filter((item): item is AbortSignal | AbortController => item !== undefined && item !== null) - const chunkAbortCtl = signals.find((item): item is AbortController => item instanceof AbortController) - const abortSignals = signals.map((item) => (item instanceof AbortController ? item.signal : item)) + ].filter((item): item is AbortSignal => item !== undefined && item !== null) if (abortSignals.length === 1) opts.signal = abortSignals[0] if (abortSignals.length > 1) opts.signal = AbortSignal.any(abortSignals) @@ -152,14 +163,16 @@ function prepareOptions(model: Info, pkg: string) { const res = await (typeof customFetch === "function" ? customFetch : fetch)(input, { ...opts, timeout: false, - }) - if (!chunkAbortCtl || typeof chunkTimeout !== "number") return res + }).finally(() => clearTimeout(headerTimer)) + if (!chunkAbortCtl || chunkTimeout === false) return res return wrapSSE(res, chunkTimeout, chunkAbortCtl) } return options } +const HEADER_TIMEOUT_MESSAGE = "Response headers timed out" + export class InitError extends Schema.TaggedError()("AISDK.InitError", { providerID: Provider.ID, cause: Schema.Defect(), @@ -388,7 +401,7 @@ function requestSettings(settings: Readonly> | undefined if (settings === undefined) return undefined const result = Object.fromEntries( Object.entries(settings).filter( - ([key]) => !["apiKey", "authToken", "baseURL", "chunkTimeout", "fetch", "timeout"].includes(key), + ([key]) => !["apiKey", "authToken", "baseURL", "chunkTimeout", "fetch", "headerTimeout", "timeout"].includes(key), ), ) return Object.keys(result).length === 0 ? undefined : result @@ -838,7 +851,7 @@ function llmError(error: unknown, operation: "request" | "read") { // Runtime-generated network failure shapes. The codes mirror the AI SDK's own // Bun network error list in handleFetchError; the messages are undici's fetch -// TypeError and stream termination strings plus our SSE chunk timeout error. +// TypeError and stream termination strings plus our header and chunk timeout errors. // Unrecognized shapes still retry via the UnknownProvider default; this match // only adds transport semantics (continuation eligibility, display). const NETWORK_ERROR_CODES = new Set([ @@ -856,6 +869,7 @@ const NETWORK_ERROR_MESSAGES = new Set([ "terminated", "other side closed", "sse read timed out", + HEADER_TIMEOUT_MESSAGE.toLowerCase(), ]) const NativeErrorShape = Schema.Struct({ diff --git a/packages/core/src/model-resolver.ts b/packages/core/src/model-resolver.ts index 94abbc5129e2..df4395946a66 100644 --- a/packages/core/src/model-resolver.ts +++ b/packages/core/src/model-resolver.ts @@ -1,7 +1,7 @@ export * as ModelResolver from "./model-resolver.js" import { makeLocationNode } from "@opencode-ai/util/effect/app-node" -import { LanguageModel } from "@opencode-ai/ai" +import { HttpOptions, LanguageModel, mergeHttpOptions } from "@opencode-ai/ai" import { Auth } from "@opencode-ai/ai/route" import { Context, Effect, Layer, Schema, Struct } from "effect" import { AISDK } from "./aisdk.js" @@ -12,6 +12,7 @@ import { Integration } from "./integration.js" import { Capabilities, ID, Info, Ref, VariantID } from "./model.js" import { Npm } from "@opencode-ai/util/npm" import { Provider } from "./provider.js" +import { RequestTimeouts } from "./request-timeouts.js" export class VariantUnavailableError extends Schema.TaggedError()( "SessionRunnerModel.VariantUnavailableError", @@ -128,7 +129,10 @@ const resolveCatalogModel = Effect.fn("ModelResolver.resolveCatalogModel")(funct const resolved = prepareRuntimeModel(model, credential) const packageName = Provider.packageName(resolved.package) const configuration = credential?.type === "key" ? credential.configuration : undefined - const configured = { ...resolved.settings, ...credential?.metadata, ...configuration } + const settingsWithTimeouts = { ...resolved.settings, ...credential?.metadata, ...configuration } + // Timeouts are transport policy, so they become route HTTP defaults instead of provider package settings. + const timeouts = RequestTimeouts.from(settingsWithTimeouts) + const configured = RequestTimeouts.strip(settingsWithTimeouts) const mapping = Provider.isAISDK(resolved.package) ? AISDKNative.map({ packageName, @@ -173,6 +177,14 @@ const resolveCatalogModel = Effect.fn("ModelResolver.resolveCatalogModel")(funct compatibility: resolved.compatibility ? Object.assign({}, runtime.compatibility, resolved.compatibility) : runtime.compatibility, + ...(timeouts.headerTimeout === undefined && timeouts.chunkTimeout === undefined + ? {} + : { + defaults: { + ...runtime.defaults, + http: mergeHttpOptions(runtime.defaults?.http, new HttpOptions(timeouts)), + }, + }), }) }, catch: () => unsupported(resolved), diff --git a/packages/core/src/request-timeouts.ts b/packages/core/src/request-timeouts.ts new file mode 100644 index 000000000000..bdbeae9730dc --- /dev/null +++ b/packages/core/src/request-timeouts.ts @@ -0,0 +1,17 @@ +export * as RequestTimeouts from "./request-timeouts.js" + +import { HttpTimeout } from "@opencode-ai/ai" +import { Option, Schema, Struct } from "effect" + +/** Provider `settings` keys that configure request timeouts rather than the provider package. */ +export const KEYS = ["headerTimeout", "chunkTimeout"] as const + +const decode = Schema.decodeUnknownOption(HttpTimeout) + +/** Read timeout settings; values that are not positive milliseconds or `false` are ignored so the default applies. */ +export const from = (settings: Readonly> | undefined) => ({ + headerTimeout: Option.getOrUndefined(decode(settings?.headerTimeout)), + chunkTimeout: Option.getOrUndefined(decode(settings?.chunkTimeout)), +}) + +export const strip = >>(settings: T) => Struct.omit(settings, KEYS) diff --git a/packages/core/src/session/model-request.ts b/packages/core/src/session/model-request.ts index cbd27f531d2d..028180824c20 100644 --- a/packages/core/src/session/model-request.ts +++ b/packages/core/src/session/model-request.ts @@ -226,9 +226,8 @@ const applyModelHooks = (hooks: PluginHooks.Interface, scope: HookScope, request return LLMRequest.update(request, { model: route === request.model.route ? request.model : LanguageModel.update(request.model, { route }), http: new HttpOptions({ - body: request.http?.body, + ...request.http, headers: Object.keys(event.headers).length === 0 ? undefined : event.headers, - query: request.http?.query, }), }) }) diff --git a/packages/core/test/aisdk.test.ts b/packages/core/test/aisdk.test.ts index c59e4e7e8a64..cf217114379d 100644 --- a/packages/core/test/aisdk.test.ts +++ b/packages/core/test/aisdk.test.ts @@ -701,6 +701,68 @@ it.effect("does not treat SSE comment heartbeats as model progress", () => }), ) +it.effect("fails with a retryable transport error when response headers time out", () => + Effect.gen(function* () { + const aisdk = yield* AISDK.Service + const customFetch = Object.assign( + (_input: Parameters[0], init?: RequestInit) => + new Promise((_, reject) => { + init?.signal?.addEventListener("abort", () => reject(init.signal?.reason), { once: true }) + }), + { preconnect: fetch.preconnect }, + ) + yield* aisdk.hook.sdk((event) => { + event.sdk = createOpenAICompatible({ + ...event.options, + name: String(event.options.name), + baseURL: String(event.options.baseURL), + }) + }) + const resolved = yield* aisdk.model( + model("@ai-sdk/openai-compatible", { + apiKey: "test", + baseURL: "https://example.test/v1", + headerTimeout: 25, + fetch: customFetch, + }), + ) + const error = yield* LLMClient.generate(LLM.request({ model: resolved, prompt: "Hello" })).pipe( + Effect.provide(client), + Effect.flip, + ) + + expect(error.reason).toMatchObject({ + _tag: "Transport", + operation: "request", + message: "Response headers timed out", + }) + expect(SessionRunnerRetry.isRetryable(error)).toBe(true) + }), +) + +it.effect("strips timeout settings from provider options", () => + Effect.gen(function* () { + const aisdk = yield* AISDK.Service + let options: Record | undefined + yield* aisdk.hook.sdk((event) => { + options = event.options + event.sdk = { languageModel: () => streamModel([]) } + }) + yield* aisdk.model( + model("@ai-sdk/openai-compatible", { + apiKey: "test", + baseURL: "https://example.test/v1", + headerTimeout: false, + chunkTimeout: 60_000, + }), + ) + + expect(options).toBeDefined() + expect(options).not.toHaveProperty("headerTimeout") + expect(options).not.toHaveProperty("chunkTimeout") + }), +) + it.effect("emits malformed AI SDK tool input without executing it", () => Effect.gen(function* () { const aisdk = yield* AISDK.Service diff --git a/packages/core/test/model-resolver.test.ts b/packages/core/test/model-resolver.test.ts index 6d67215eef27..4d010f08cee7 100644 --- a/packages/core/test/model-resolver.test.ts +++ b/packages/core/test/model-resolver.test.ts @@ -247,6 +247,22 @@ describe("ModelResolver", () => { }), ) + it.effect("lifts timeout settings onto native route HTTP defaults", () => + Effect.gen(function* () { + const resolved = yield* ModelResolver.fromCatalogModel( + model(Provider.aisdk("@ai-sdk/anthropic"), { + settings: { baseURL: "https://anthropic.example/v1", headerTimeout: false, chunkTimeout: 60_000 }, + }), + Credential.Key.make({ type: "key", key: "secret" }), + ) + const prepared = yield* compileRequest(LLM.request({ model: resolved, prompt: "Hello" })) + + expect(resolved.defaults?.http).toMatchObject({ headerTimeout: false, chunkTimeout: 60_000 }) + expect(resolved.defaults?.providerOptions).toBeUndefined() + expect(JSON.stringify(prepared.body)).not.toContain("Timeout") + }), + ) + it.effect("keeps catalog apiKey credentials out of provider JSON", () => Effect.gen(function* () { const resolved = yield* ModelResolver.fromCatalogModel( diff --git a/packages/www/src/docs/content/providers.mdx b/packages/www/src/docs/content/providers.mdx index 845dd3ad9bd6..b9fac1bb72ea 100644 --- a/packages/www/src/docs/content/providers.mdx +++ b/packages/www/src/docs/content/providers.mdx @@ -138,6 +138,33 @@ body: } ``` +## Timeouts + +Provider requests wait up to five minutes for response headers and up to five minutes between streamed response chunks. +Set `settings.headerTimeout` or `settings.chunkTimeout` to a number of milliseconds to change a limit, or to `false` to +disable it: + +```jsonc title="opencode.jsonc" +{ + "$schema": "https://opencode.ai/config.json", + "providers": { + "anthropic": { + "settings": { + "headerTimeout": 600000, + "chunkTimeout": false, + }, + }, + }, +} +``` + +| Setting | Default | Purpose | +| --------------- | -------- | ---------------------------------------------------------------------------- | +| `headerTimeout` | `300000` | Time to wait for response headers. Stops once headers arrive. | +| `chunkTimeout` | `300000` | Time allowed between streamed response chunks. Resets whenever data arrives. | + +A request that times out fails with a transport error and follows the normal retry policy. + ## Package The `package` field selects the runtime used to communicate with a provider. For an OpenAI-compatible API, use the From b85cf3d67fe3d36a8d04f4247fc781045d5b3dee Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Wed, 2 Sep 2026 18:23:14 -0500 Subject: [PATCH 2/2] refactor(core): simplify provider timeout wiring --- packages/ai/src/route/transport/http.ts | 65 +++++++++---------------- packages/core/src/aisdk.ts | 33 ++++++------- packages/core/src/model-resolver.ts | 18 ++----- packages/core/src/provider.ts | 14 +++++- packages/core/src/request-timeouts.ts | 17 ------- packages/core/test/aisdk.test.ts | 23 --------- 6 files changed, 56 insertions(+), 114 deletions(-) delete mode 100644 packages/core/src/request-timeouts.ts diff --git a/packages/ai/src/route/transport/http.ts b/packages/ai/src/route/transport/http.ts index 70f7adf7dd89..960a1d54e117 100644 --- a/packages/ai/src/route/transport/http.ts +++ b/packages/ai/src/route/transport/http.ts @@ -98,44 +98,32 @@ export const httpJson = (input: HttpJsonInput): HttpJs }), execute: (prepared, request, runtime) => Effect.gen(function* () { - const headerTimeout = timeoutDuration(request.http?.headerTimeout) - const chunkTimeout = timeoutDuration(request.http?.chunkTimeout) - const execute = runtime.http.execute(prepared.request, prepared.middleware) - const response = yield* headerTimeout - ? execute.pipe( - Effect.timeoutOrElse({ - duration: headerTimeout, - orElse: () => - Effect.fail( - timeoutError( - prepared.request.url, - "request", - `Timed out waiting for response headers after ${Duration.format(headerTimeout)}`, - ), - ), - }), - ) - : execute + const timeout = (operation: TransportOperation, message: string, http?: HttpContext) => + new AIError({ + reason: new TransportError({ + message, + transport: "http", + operation, + code: "Timeout", + url: prepared.request.url, + http, + }), + }) + const response = yield* runtime.http.execute(prepared.request, prepared.middleware).pipe( + Effect.timeoutOrElse({ + duration: timeoutDuration(request.http?.headerTimeout), + orElse: () => Effect.fail(timeout("request", "Timed out waiting for response headers")), + }), + ) const http = RequestExecutor.responseHttp(response) - const bytes = RequestExecutor.responseStream(response) return { frames: prepared.framing.frame( - chunkTimeout - ? bytes.pipe( - Stream.timeoutOrElse({ - duration: chunkTimeout, - orElse: () => - Stream.fail( - timeoutError( - prepared.request.url, - "read", - `Timed out waiting for response data after ${Duration.format(chunkTimeout)}`, - http, - ), - ), - }), - ) - : bytes, + RequestExecutor.responseStream(response).pipe( + Stream.timeoutOrElse({ + duration: timeoutDuration(request.http?.chunkTimeout), + orElse: () => Stream.fail(timeout("read", "Timed out waiting for response data", http)), + }), + ), ), http, body: prepared.framing.body, @@ -145,12 +133,7 @@ export const httpJson = (input: HttpJsonInput): HttpJs // `false` disables a timer; an unset value uses the shared default. const timeoutDuration = (value: HttpTimeout | undefined) => - value === false ? undefined : Duration.millis(value ?? DEFAULT_HTTP_TIMEOUT_MS) - -const timeoutError = (url: string, operation: TransportOperation, message: string, http?: HttpContext) => - new AIError({ - reason: new TransportError({ message, transport: "http", operation, code: "Timeout", url, http }), - }) + value === false ? Duration.infinity : Duration.millis(value ?? DEFAULT_HTTP_TIMEOUT_MS) export const sseJson = { id: "http-json/sse", diff --git a/packages/core/src/aisdk.ts b/packages/core/src/aisdk.ts index aedb3d781e5b..a69c9d33e7c5 100644 --- a/packages/core/src/aisdk.ts +++ b/packages/core/src/aisdk.ts @@ -37,7 +37,6 @@ import { Cause, Context, Effect, Layer, Option, Schema, Scope, Stream } from "ef import { makeParser } from "effect/unstable/encoding/Sse" import type { ID, Info } from "./model.js" import { Provider } from "./provider.js" -import { RequestTimeouts } from "./request-timeouts.js" import { State } from "./state.js" type SDK = any @@ -128,30 +127,28 @@ function prepareOptions(model: Info, pkg: string) { } const customFetch = options.fetch - const timeouts = RequestTimeouts.from(options) + const timeouts = Provider.timeouts(options) const chunkTimeout = timeouts.chunkTimeout ?? DEFAULT_HTTP_TIMEOUT_MS const headerTimeout = timeouts.headerTimeout ?? DEFAULT_HTTP_TIMEOUT_MS delete options.chunkTimeout delete options.headerTimeout options.fetch = async (input: Parameters[0], init?: RequestInit) => { const opts = { ...(init ?? {}) } - const chunkAbortCtl = chunkTimeout === false ? undefined : new AbortController() - const headerAbortCtl = headerTimeout === false ? undefined : new AbortController() - // Only covers the wait for response headers; the chunk timer takes over once the body streams. + const ctl = new AbortController() + // Only covers the wait for response headers; wrapSSE takes over once the body streams. const headerTimer = headerTimeout === false ? undefined - : setTimeout(() => headerAbortCtl?.abort(new Error(HEADER_TIMEOUT_MESSAGE)), headerTimeout) - const abortSignals = [ - opts.signal, - chunkAbortCtl?.signal, - headerAbortCtl?.signal, - options.timeout !== undefined && options.timeout !== null && options.timeout !== false - ? AbortSignal.timeout(options.timeout) - : undefined, - ].filter((item): item is AbortSignal => item !== undefined && item !== null) - if (abortSignals.length === 1) opts.signal = abortSignals[0] - if (abortSignals.length > 1) opts.signal = AbortSignal.any(abortSignals) + : setTimeout(() => ctl.abort(new Error(HEADER_TIMEOUT_MESSAGE)), headerTimeout) + opts.signal = AbortSignal.any( + [ + opts.signal, + ctl.signal, + options.timeout !== undefined && options.timeout !== null && options.timeout !== false + ? AbortSignal.timeout(options.timeout) + : undefined, + ].filter((item): item is AbortSignal => item !== undefined && item !== null), + ) if (typeof opts.body === "string" && model.body !== undefined) { const decoded = Option.getOrUndefined(decodeJson(opts.body)) @@ -164,8 +161,8 @@ function prepareOptions(model: Info, pkg: string) { ...opts, timeout: false, }).finally(() => clearTimeout(headerTimer)) - if (!chunkAbortCtl || chunkTimeout === false) return res - return wrapSSE(res, chunkTimeout, chunkAbortCtl) + if (chunkTimeout === false) return res + return wrapSSE(res, chunkTimeout, ctl) } return options diff --git a/packages/core/src/model-resolver.ts b/packages/core/src/model-resolver.ts index df4395946a66..9fc3934b9e13 100644 --- a/packages/core/src/model-resolver.ts +++ b/packages/core/src/model-resolver.ts @@ -12,7 +12,6 @@ import { Integration } from "./integration.js" import { Capabilities, ID, Info, Ref, VariantID } from "./model.js" import { Npm } from "@opencode-ai/util/npm" import { Provider } from "./provider.js" -import { RequestTimeouts } from "./request-timeouts.js" export class VariantUnavailableError extends Schema.TaggedError()( "SessionRunnerModel.VariantUnavailableError", @@ -129,10 +128,10 @@ const resolveCatalogModel = Effect.fn("ModelResolver.resolveCatalogModel")(funct const resolved = prepareRuntimeModel(model, credential) const packageName = Provider.packageName(resolved.package) const configuration = credential?.type === "key" ? credential.configuration : undefined - const settingsWithTimeouts = { ...resolved.settings, ...credential?.metadata, ...configuration } - // Timeouts are transport policy, so they become route HTTP defaults instead of provider package settings. - const timeouts = RequestTimeouts.from(settingsWithTimeouts) - const configured = RequestTimeouts.strip(settingsWithTimeouts) + const merged = { ...resolved.settings, ...credential?.metadata, ...configuration } + // Timeouts are transport policy: they become route HTTP defaults rather than provider package settings. + const timeouts = Provider.timeouts(merged) + const configured = Struct.omit(merged, ["headerTimeout", "chunkTimeout"]) const mapping = Provider.isAISDK(resolved.package) ? AISDKNative.map({ packageName, @@ -177,14 +176,7 @@ const resolveCatalogModel = Effect.fn("ModelResolver.resolveCatalogModel")(funct compatibility: resolved.compatibility ? Object.assign({}, runtime.compatibility, resolved.compatibility) : runtime.compatibility, - ...(timeouts.headerTimeout === undefined && timeouts.chunkTimeout === undefined - ? {} - : { - defaults: { - ...runtime.defaults, - http: mergeHttpOptions(runtime.defaults?.http, new HttpOptions(timeouts)), - }, - }), + defaults: { ...runtime.defaults, http: mergeHttpOptions(runtime.defaults?.http, new HttpOptions(timeouts)) }, }) }, catch: () => unsupported(resolved), diff --git a/packages/core/src/provider.ts b/packages/core/src/provider.ts index a6852178d06c..e2e2ba5acde8 100644 --- a/packages/core/src/provider.ts +++ b/packages/core/src/provider.ts @@ -1,8 +1,8 @@ export * as Provider from "./provider.js" -import { Effect, Schema } from "effect" +import { Effect, Option, Schema } from "effect" import { Provider } from "@opencode-ai/schema/provider" -import type { ProviderPackageDefinition } from "@opencode-ai/ai" +import { HttpTimeout, type ProviderPackageDefinition } from "@opencode-ai/ai" import { isRecord } from "@opencode-ai/ai/utils/record" import { Npm } from "@opencode-ai/util/npm" import type { DeepMutable } from "./schema.js" @@ -136,6 +136,16 @@ export function mergeHeaders( ) } +const decodeTimeout = Schema.decodeUnknownOption(HttpTimeout) + +/** Read request timeouts from provider settings; invalid values are ignored so the default applies. */ +export function timeouts(settings: Readonly>) { + return { + headerTimeout: Option.getOrUndefined(decodeTimeout(settings.headerTimeout)), + chunkTimeout: Option.getOrUndefined(decodeTimeout(settings.chunkTimeout)), + } +} + export const Request = Provider.Request export type Request = Provider.Request diff --git a/packages/core/src/request-timeouts.ts b/packages/core/src/request-timeouts.ts deleted file mode 100644 index bdbeae9730dc..000000000000 --- a/packages/core/src/request-timeouts.ts +++ /dev/null @@ -1,17 +0,0 @@ -export * as RequestTimeouts from "./request-timeouts.js" - -import { HttpTimeout } from "@opencode-ai/ai" -import { Option, Schema, Struct } from "effect" - -/** Provider `settings` keys that configure request timeouts rather than the provider package. */ -export const KEYS = ["headerTimeout", "chunkTimeout"] as const - -const decode = Schema.decodeUnknownOption(HttpTimeout) - -/** Read timeout settings; values that are not positive milliseconds or `false` are ignored so the default applies. */ -export const from = (settings: Readonly> | undefined) => ({ - headerTimeout: Option.getOrUndefined(decode(settings?.headerTimeout)), - chunkTimeout: Option.getOrUndefined(decode(settings?.chunkTimeout)), -}) - -export const strip = >>(settings: T) => Struct.omit(settings, KEYS) diff --git a/packages/core/test/aisdk.test.ts b/packages/core/test/aisdk.test.ts index cf217114379d..9a262189a8e5 100644 --- a/packages/core/test/aisdk.test.ts +++ b/packages/core/test/aisdk.test.ts @@ -740,29 +740,6 @@ it.effect("fails with a retryable transport error when response headers time out }), ) -it.effect("strips timeout settings from provider options", () => - Effect.gen(function* () { - const aisdk = yield* AISDK.Service - let options: Record | undefined - yield* aisdk.hook.sdk((event) => { - options = event.options - event.sdk = { languageModel: () => streamModel([]) } - }) - yield* aisdk.model( - model("@ai-sdk/openai-compatible", { - apiKey: "test", - baseURL: "https://example.test/v1", - headerTimeout: false, - chunkTimeout: 60_000, - }), - ) - - expect(options).toBeDefined() - expect(options).not.toHaveProperty("headerTimeout") - expect(options).not.toHaveProperty("chunkTimeout") - }), -) - it.effect("emits malformed AI SDK tool input without executing it", () => Effect.gen(function* () { const aisdk = yield* AISDK.Service