From 4eb29a64f0054672950acf789f2b09487ebfbb20 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Wed, 2 Sep 2026 15:33:15 -0500 Subject: [PATCH] fix(opencode): default chunk timeout to five minutes (#46890) --- packages/core/src/v1/config/provider.ts | 9 ++- packages/opencode/src/provider/provider.ts | 2 +- .../test/provider/header-timeout.test.ts | 73 ++++++++++++++++++- packages/sdk/js/src/v2/gen/types.gen.ts | 7 +- packages/sdk/openapi.json | 13 +++- packages/web/src/content/docs/config.mdx | 2 +- 6 files changed, 95 insertions(+), 11 deletions(-) diff --git a/packages/core/src/v1/config/provider.ts b/packages/core/src/v1/config/provider.ts index f860a2b4ac..2421e13de4 100644 --- a/packages/core/src/v1/config/provider.ts +++ b/packages/core/src/v1/config/provider.ts @@ -114,9 +114,14 @@ export const Info = Schema.Struct({ description: "Timeout in milliseconds to wait for response headers. Provider integrations may set defaults. Set to false to disable timeout.", }), - chunkTimeout: Schema.optional(PositiveInt).annotate({ + chunkTimeout: Schema.optional( + Schema.Union([PositiveInt, Schema.Literal(false)]).annotate({ + description: + "Timeout in milliseconds between streamed SSE chunks for this provider (default: 300000). If no chunk arrives within this window, the request is aborted. Set to false to disable timeout.", + }), + ).annotate({ description: - "Timeout in milliseconds between streamed SSE chunks for this provider. If no chunk arrives within this window, the request is aborted.", + "Timeout in milliseconds between streamed SSE chunks for this provider (default: 300000). If no chunk arrives within this window, the request is aborted. Set to false to disable timeout.", }), }), [Schema.Record(Schema.String, Schema.Any)], diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 2c69d8fba9..dc43fbfbdd 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -1792,7 +1792,7 @@ const layer = Layer.effect( if (existing) return existing const customFetch = options["fetch"] - const chunkTimeout = options["chunkTimeout"] + const chunkTimeout = options["chunkTimeout"] ?? 300_000 const headerTimeout = options["headerTimeout"] delete options["chunkTimeout"] delete options["headerTimeout"] diff --git a/packages/opencode/test/provider/header-timeout.test.ts b/packages/opencode/test/provider/header-timeout.test.ts index fc5ab04e10..e0dd19e471 100644 --- a/packages/opencode/test/provider/header-timeout.test.ts +++ b/packages/opencode/test/provider/header-timeout.test.ts @@ -13,6 +13,8 @@ import { Env } from "@/env" import { Plugin } from "@/plugin" import { Provider } from "@/provider/provider" import { ProviderError } from "@/provider/error" +import { MessageV2 } from "@/session/message-v2" +import { SessionRetry } from "@/session/retry" afterEach(async () => { await disposeAllInstances() @@ -46,7 +48,42 @@ it.live("headerTimeout does not abort delayed SSE body after headers arrive", () }), ) -it.live("chunkTimeout raises a response stream error when SSE body stalls", () => +it.live("default chunkTimeout is applied at fetch without changing provider options", () => + Effect.gen(function* () { + const server = yield* Effect.acquireRelease( + Effect.promise(() => delayedBodyServer(250)), + (server) => Effect.sync(() => server.server.close()), + ) + + yield* provideTmpdirInstance( + () => + Effect.gen(function* () { + const provider = yield* Provider.Service + const configured = yield* provider.getProvider(ProviderV2.ID.make("test")) + const signals: (AbortSignal | null | undefined)[] = [] + configured.options.fetch = (input: RequestInfo | URL, init?: RequestInit) => { + signals.push(init?.signal) + return fetch(input, init) + } + const model = yield* provider.getModel(ProviderV2.ID.make("test"), ModelV2.ID.make("test-model")) + const language = yield* provider.getLanguage(model) + yield* Effect.acquireRelease( + Effect.promise(() => + language.doStream({ prompt: [{ role: "user", content: [{ type: "text", text: "hello" }] }] }), + ), + (result) => Effect.promise(() => result.stream.cancel()), + ) + + expect(signals).toHaveLength(1) + expect(signals[0]).toBeInstanceOf(AbortSignal) + expect(configured.options.chunkTimeout).toBeUndefined() + }), + { config: providerConfig(server.url) }, + ) + }), +) + +it.live("configured chunkTimeout raises a retryable response stream error when SSE body stalls", () => Effect.gen(function* () { const server = yield* Effect.acquireRelease( Effect.promise(() => delayedBodyServer(250)), @@ -74,12 +111,41 @@ it.live("chunkTimeout raises a response stream error when SSE body stalls", () = } }) expect(error).toBeInstanceOf(ProviderError.ResponseStreamError) + expect( + SessionRetry.retryable(MessageV2.fromError(error, { providerID: model.providerID }), model.providerID), + ).toEqual({ message: "SSE read timed out" }) }), { config: providerConfig(server.url, { chunkTimeout: 50 }) }, ) }), ) +it.live("chunkTimeout can be disabled with false", () => + Effect.gen(function* () { + const server = yield* Effect.acquireRelease( + Effect.promise(() => delayedBodyServer(250)), + (server) => Effect.sync(() => server.server.close()), + ) + + yield* provideTmpdirInstance( + () => + Effect.gen(function* () { + const provider = yield* Provider.Service + const configured = yield* provider.getProvider(ProviderV2.ID.make("test")) + expect(configured.options.chunkTimeout).toBe(false) + const model = yield* provider.getModel(ProviderV2.ID.make("test"), ModelV2.ID.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: providerConfig(server.url, { chunkTimeout: false }) }, + ) + }), +) + it.live("headerTimeout aborts when response headers do not arrive", () => Effect.gen(function* () { const server = yield* Effect.acquireRelease( @@ -136,7 +202,7 @@ it.live("headerTimeout is opt-in for non-OpenAI providers", () => }), ) -it.live("OpenAI Codex headerTimeout default can be disabled by config", () => +it.live("OpenAI Codex header and chunk timeout defaults can be disabled by config", () => Effect.gen(function* () { yield* withAuthContent( Effect.gen(function* () { @@ -146,8 +212,9 @@ it.live("OpenAI Codex headerTimeout default can be disabled by config", () => const provider = yield* Provider.Service const openai = yield* provider.getProvider(ProviderV2.ID.openai) expect(openai.options.headerTimeout).toBe(false) + expect(openai.options.chunkTimeout).toBe(false) }), - { config: { provider: { openai: { options: { headerTimeout: false } } } } }, + { config: { provider: { openai: { options: { headerTimeout: false, chunkTimeout: false } } } } }, ) }), ) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 72b5e6f30a..23d1b19649 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1754,8 +1754,11 @@ export type ProviderConfig = { * 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 | false | number | undefined + /** + * Timeout in milliseconds between streamed SSE chunks for this provider (default: 300000). If no chunk arrives within this window, the request is aborted. Set to false to disable timeout. + */ + chunkTimeout?: number | false + [key: string]: unknown | string | boolean | number | false | number | false | number | false | undefined } models?: { [key: string]: { diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 5e372b6fb6..d9f7579936 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -20783,8 +20783,17 @@ "description": "Timeout in milliseconds to wait for response headers. Provider integrations may set defaults. Set to false to disable timeout." }, "chunkTimeout": { - "type": "integer", - "exclusiveMinimum": 0 + "anyOf": [ + { + "type": "integer", + "exclusiveMinimum": 0 + }, + { + "type": "boolean", + "enum": [false] + } + ], + "description": "Timeout in milliseconds between streamed SSE chunks for this provider (default: 300000). If no chunk arrives within this window, the request is aborted. Set to false to disable timeout." } }, "additionalProperties": {} diff --git a/packages/web/src/content/docs/config.mdx b/packages/web/src/content/docs/config.mdx index 318f013b41..70dc8851d3 100644 --- a/packages/web/src/content/docs/config.mdx +++ b/packages/web/src/content/docs/config.mdx @@ -392,7 +392,7 @@ Provider options can include `timeout`, `chunkTimeout`, and `setCacheKey`: ``` - `timeout` - Request timeout in milliseconds (default: 300000). Set to `false` to disable. -- `chunkTimeout` - Timeout in milliseconds between streamed response chunks. If no chunk arrives in time, the request is aborted. +- `chunkTimeout` - Timeout in milliseconds between streamed response chunks (default: 300000, or 5 minutes). If no chunk arrives in time, the request is aborted. Set to `false` to disable. - `setCacheKey` - Ensure a cache key is always set for designated provider. You can also configure [local models](/docs/models#local). [Learn more](/docs/models).