From 5df0c6e3c364f9ef50fab202738ad3384f66b142 Mon Sep 17 00:00:00 2001 From: James Long Date: Fri, 6 Mar 2026 14:06:41 -0500 Subject: [PATCH 1/2] feat(core): add `chunkTimeout` option for sse timeouts in providers --- packages/opencode/src/config/config.ts | 8 ++ packages/opencode/src/provider/provider.ts | 75 ++++++++++++++++--- .../opencode/test/provider/provider.test.ts | 2 + packages/web/src/content/docs/config.mdx | 4 +- 4 files changed, 79 insertions(+), 10 deletions(-) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 6b4242a225a..ef2821727b5 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -972,6 +972,14 @@ export namespace Config { .describe( "Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout.", ), + chunkTimeout: z + .number() + .int() + .positive() + .optional() + .describe( + "Timeout in milliseconds between streamed SSE chunks for this provider. If no chunk arrives within this window, the request is aborted.", + ), }) .catchall(z.any()) .optional(), diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index b4836ae047d..85e9dea24f8 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -46,6 +46,8 @@ import { GoogleAuth } from "google-auth-library" import { ProviderTransform } from "./transform" import { Installation } from "../installation" +const DEFAULT_CHUNK_TIMEOUT = 120_000 + export namespace Provider { const log = Log.create({ service: "provider" }) @@ -85,6 +87,54 @@ export namespace Provider { }) } + function wrapSSE(res: Response, ms: number, ctl: AbortController) { + if (typeof ms !== "number" || ms <= 0) return res + if (!res.body) return res + if (!res.headers.get("content-type")?.includes("text/event-stream")) return res + + const reader = res.body.getReader() + const body = new ReadableStream({ + async pull(ctrl) { + const part = await new Promise>((resolve, reject) => { + const id = setTimeout(() => { + const err = new Error("SSE read timed out") + ctl.abort(err) + void reader.cancel(err) + reject(err) + }, ms) + + reader.read().then( + (part) => { + clearTimeout(id) + resolve(part) + }, + (err) => { + clearTimeout(id) + reject(err) + }, + ) + }) + + if (part.done) { + ctrl.close() + return + } + + ctrl.enqueue(part.value) + }, + async cancel(reason) { + ctl.abort(reason) + await reader.cancel(reason) + }, + }) + + return new Response(body, { + headers: new Headers(res.headers), + status: res.status, + statusText: res.statusText, + }) + } + const BUNDLED_PROVIDERS: Record SDK> = { "@ai-sdk/amazon-bedrock": createAmazonBedrock, "@ai-sdk/anthropic": createAnthropic, @@ -1091,21 +1141,25 @@ export namespace Provider { if (existing) return existing const customFetch = options["fetch"] + const chunkTimeout = options["chunkTimeout"] || DEFAULT_CHUNK_TIMEOUT + const timeout = options["timeout"] + delete options["chunkTimeout"] options["fetch"] = async (input: any, init?: BunFetchRequestInit) => { // Preserve custom fetch if it exists, wrap it with timeout logic const fetchFn = customFetch ?? fetch const opts = init ?? {} + const chunkAbortCtl = + typeof chunkTimeout === "number" && chunkTimeout > 0 ? new AbortController() : undefined + const signals: AbortSignal[] = [] - if (options["timeout"] !== undefined && options["timeout"] !== null) { - const signals: AbortSignal[] = [] - if (opts.signal) signals.push(opts.signal) - if (options["timeout"] !== false) signals.push(AbortSignal.timeout(options["timeout"])) - - const combined = signals.length > 1 ? AbortSignal.any(signals) : signals[0] + if (opts.signal) signals.push(opts.signal) + if (chunkAbortCtl) signals.push(chunkAbortCtl.signal) + if (options["timeout"] !== undefined && options["timeout"] !== null && options["timeout"] !== false) + signals.push(AbortSignal.timeout(options["timeout"])) - opts.signal = combined - } + const combined = signals.length === 0 ? null : signals.length === 1 ? signals[0] : AbortSignal.any(signals) + if (combined) opts.signal = combined // Strip openai itemId metadata following what codex does // Codex uses #[serde(skip_serializing)] on id fields for all item types: @@ -1125,11 +1179,14 @@ export namespace Provider { } } - return fetchFn(input, { + const res = await fetchFn(input, { ...opts, // @ts-ignore see here: https://github.com/oven-sh/bun/issues/16682 timeout: false, }) + + if (!chunkAbortCtl) return res + return wrapSSE(res, chunkTimeout, chunkAbortCtl) } const bundledFn = BUNDLED_PROVIDERS[model.api.npm] diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index 11c943db6f8..4c6eaf8b227 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -260,6 +260,7 @@ test("env variable takes precedence, config merges options", async () => { anthropic: { options: { timeout: 60000, + chunkTimeout: 15000, }, }, }, @@ -277,6 +278,7 @@ test("env variable takes precedence, config merges options", async () => { expect(providers["anthropic"]).toBeDefined() // Config options should be merged expect(providers["anthropic"].options.timeout).toBe(60000) + expect(providers["anthropic"].options.chunkTimeout).toBe(15000) }, }) }) diff --git a/packages/web/src/content/docs/config.mdx b/packages/web/src/content/docs/config.mdx index 038f253274e..d2770ee2094 100644 --- a/packages/web/src/content/docs/config.mdx +++ b/packages/web/src/content/docs/config.mdx @@ -244,7 +244,7 @@ You can configure the providers and models you want to use in your OpenCode conf The `small_model` option configures a separate model for lightweight tasks like title generation. By default, OpenCode tries to use a cheaper model if one is available from your provider, otherwise it falls back to your main model. -Provider options can include `timeout` and `setCacheKey`: +Provider options can include `timeout`, `chunkTimeout`, and `setCacheKey`: ```json title="opencode.json" { @@ -253,6 +253,7 @@ Provider options can include `timeout` and `setCacheKey`: "anthropic": { "options": { "timeout": 600000, + "chunkTimeout": 30000, "setCacheKey": true } } @@ -261,6 +262,7 @@ Provider options can include `timeout` 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. - `setCacheKey` - Ensure a cache key is always set for designated provider. You can also configure [local models](/docs/models#local). [Learn more](/docs/models). From f5dde52cc4361f3bdb6dca61731d7436b18168c9 Mon Sep 17 00:00:00 2001 From: James Long Date: Fri, 6 Mar 2026 14:22:10 -0500 Subject: [PATCH 2/2] Fix types --- packages/opencode/src/provider/provider.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 85e9dea24f8..1df358ccd8d 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -95,7 +95,7 @@ export namespace Provider { const reader = res.body.getReader() const body = new ReadableStream({ async pull(ctrl) { - const part = await new Promise>((resolve, reject) => { + const part = await new Promise>>((resolve, reject) => { const id = setTimeout(() => { const err = new Error("SSE read timed out") ctl.abort(err) @@ -1142,15 +1142,13 @@ export namespace Provider { const customFetch = options["fetch"] const chunkTimeout = options["chunkTimeout"] || DEFAULT_CHUNK_TIMEOUT - const timeout = options["timeout"] delete options["chunkTimeout"] options["fetch"] = async (input: any, init?: BunFetchRequestInit) => { // Preserve custom fetch if it exists, wrap it with timeout logic const fetchFn = customFetch ?? fetch const opts = init ?? {} - const chunkAbortCtl = - typeof chunkTimeout === "number" && chunkTimeout > 0 ? new AbortController() : undefined + const chunkAbortCtl = typeof chunkTimeout === "number" && chunkTimeout > 0 ? new AbortController() : undefined const signals: AbortSignal[] = [] if (opts.signal) signals.push(opts.signal)