diff --git a/bun.lock b/bun.lock index 657424dfef19..53a2ff1516f1 100644 --- a/bun.lock +++ b/bun.lock @@ -303,6 +303,7 @@ "npm-package-arg": "13.0.2", "semver": "^7.6.3", "turndown": "7.2.0", + "undici": "8.3.0", "venice-ai-sdk-provider": "2.0.2", "which": "6.0.1", "xdg-basedir": "5.1.0", @@ -590,6 +591,7 @@ "tree-sitter-powershell": "0.25.10", "turndown": "7.2.0", "ulid": "catalog:", + "undici": "8.3.0", "venice-ai-sdk-provider": "2.0.2", "vscode-jsonrpc": "8.2.1", "web-tree-sitter": "0.25.10", @@ -666,6 +668,7 @@ "version": "1.17.9", "dependencies": { "cross-spawn": "catalog:", + "undici": "8.3.0", }, "devDependencies": { "@hey-api/openapi-ts": "0.90.10", diff --git a/packages/core/package.json b/packages/core/package.json index 2aec5ecc7e50..a3a4dc8b5d2b 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -118,6 +118,7 @@ "npm-package-arg": "13.0.2", "semver": "^7.6.3", "turndown": "7.2.0", + "undici": "8.3.0", "venice-ai-sdk-provider": "2.0.2", "which": "6.0.1", "xdg-basedir": "5.1.0", diff --git a/packages/core/src/aisdk.ts b/packages/core/src/aisdk.ts index 9ea79394f34a..6639d0502d4e 100644 --- a/packages/core/src/aisdk.ts +++ b/packages/core/src/aisdk.ts @@ -5,6 +5,7 @@ import { Cause, Context, Effect, Layer, Schema, Scope } from "effect" import { ModelV2 } from "./model" import { ProviderV2 } from "./provider" import { State } from "./state" +import { createUndiciDispatcher } from "./util/undici-dispatcher" type SDK = any @@ -81,6 +82,7 @@ function prepareOptions(model: ModelV2.Info, pkg: string) { const customFetch = options.fetch const chunkTimeout = options.chunkTimeout delete options.chunkTimeout + const dispatcher = typeof customFetch === "function" ? undefined : createUndiciDispatcher(options.timeout) options.fetch = async (input: Parameters[0], init?: RequestInit) => { const opts = { ...(init ?? {}) } const signals = [ @@ -111,8 +113,9 @@ function prepareOptions(model: ModelV2.Info, pkg: string) { const res = await (typeof customFetch === "function" ? customFetch : fetch)(input, { ...opts, + ...(dispatcher ? { dispatcher } : {}), timeout: false, - }) + } as RequestInit) if (!chunkAbortCtl || typeof chunkTimeout !== "number") return res return wrapSSE(res, chunkTimeout, chunkAbortCtl) } diff --git a/packages/core/src/util/undici-dispatcher.ts b/packages/core/src/util/undici-dispatcher.ts new file mode 100644 index 000000000000..9b6eb219fe95 --- /dev/null +++ b/packages/core/src/util/undici-dispatcher.ts @@ -0,0 +1,30 @@ +import { Agent } from "undici" + +// Node's undici fetch defaults both headersTimeout and bodyTimeout to 300s. +// This kills long-running provider requests — including SSE streams with gaps +// >300s between chunks — at the ~303s mark regardless of provider.timeout config. +// +// resolveTimeoutMs maps the provider `timeout` option to a millisecond value: +// false → 0 (disabled) +// >0 → that value (ms) +// anything else → undefined (no dispatcher, undici defaults apply) +// +// createUndiciDispatcher wraps resolveTimeoutMs with a Node-only guard and +// constructs the Agent. Bun's fetch has no such defaults, so the guard via +// process.versions.bun keeps Bun behavior unchanged. +// +// This does not conflict with the existing chunkTimeout / headerTimeout abort +// signals: those use AbortSignal/AbortController which are independent of the +// undici dispatcher's socket-level timeouts. +export function resolveTimeoutMs(timeout: unknown): number | undefined { + if (timeout === false) return 0 + if (typeof timeout === "number" && timeout > 0) return timeout + return undefined +} + +export function createUndiciDispatcher(timeout: unknown) { + if (typeof process !== "object" || (process.versions as Record).bun) return undefined + const ms = resolveTimeoutMs(timeout) + if (ms === undefined) return undefined + return new Agent({ headersTimeout: ms, bodyTimeout: ms }) +} diff --git a/packages/core/test/util/undici-dispatcher.test.ts b/packages/core/test/util/undici-dispatcher.test.ts new file mode 100644 index 000000000000..1efaa2d45ab8 --- /dev/null +++ b/packages/core/test/util/undici-dispatcher.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, test } from "bun:test" +import { Agent } from "undici" +import { resolveTimeoutMs, createUndiciDispatcher } from "@opencode-ai/core/util/undici-dispatcher" + +describe("resolveTimeoutMs", () => { + test("false → 0 (disabled)", () => { + expect(resolveTimeoutMs(false)).toBe(0) + }) + + test("positive number → that value in ms", () => { + expect(resolveTimeoutMs(600_000)).toBe(600_000) + }) + + test("undefined → undefined (undici defaults apply)", () => { + expect(resolveTimeoutMs(undefined)).toBeUndefined() + }) + + test("0 → undefined (not a positive number)", () => { + expect(resolveTimeoutMs(0)).toBeUndefined() + }) + + test("negative number → undefined", () => { + expect(resolveTimeoutMs(-1)).toBeUndefined() + }) + + test("null → undefined", () => { + expect(resolveTimeoutMs(null)).toBeUndefined() + }) + + test("string → undefined", () => { + expect(resolveTimeoutMs("300000")).toBeUndefined() + }) +}) + +describe("createUndiciDispatcher", () => { + test("Bun guard: returns undefined when process.versions.bun is set", () => { + const original = (process.versions as Record).bun + ;(process.versions as Record).bun = "1.0.0" + try { + expect(createUndiciDispatcher(600_000)).toBeUndefined() + expect(createUndiciDispatcher(false)).toBeUndefined() + } finally { + if (original === undefined) delete (process.versions as Record).bun + else (process.versions as Record).bun = original + } + }) + + test("returns undefined when no bun version and timeout resolves to undefined", () => { + const original = (process.versions as Record).bun + ;(process.versions as Record).bun = undefined + try { + expect(createUndiciDispatcher(undefined)).toBeUndefined() + expect(createUndiciDispatcher(0)).toBeUndefined() + } finally { + if (original === undefined) delete (process.versions as Record).bun + else (process.versions as Record).bun = original + } + }) + + // Under Bun, createUndiciDispatcher always returns undefined because of the + // guard. The Agent construction path only runs under Node. We verify it + // returns an Agent instance (not private fields) when the bun guard is + // removed and a valid timeout is provided. + test("returns an Agent instance under Node (bun guard bypassed)", () => { + const original = (process.versions as Record).bun + ;(process.versions as Record).bun = undefined + try { + const agent = createUndiciDispatcher(600_000) + expect(agent).toBeInstanceOf(Agent) + } finally { + if (original === undefined) delete (process.versions as Record).bun + else (process.versions as Record).bun = original + } + }) +}) diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 139217e348a4..4354042337ff 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -141,6 +141,7 @@ "tree-sitter-powershell": "0.25.10", "turndown": "7.2.0", "ulid": "catalog:", + "undici": "8.3.0", "venice-ai-sdk-provider": "2.0.2", "vscode-jsonrpc": "8.2.1", "web-tree-sitter": "0.25.10", diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index d7c85f2ee6ae..bfb604abc30b 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -7,6 +7,7 @@ import { mapValues, mergeDeep, omit, pickBy, sortBy } from "remeda" import { NoSuchModelError, type Provider as SDK } from "ai" import { Npm } from "@opencode-ai/core/npm" import { Hash } from "@opencode-ai/core/util/hash" +import { createUndiciDispatcher } from "@opencode-ai/core/util/undici-dispatcher" import { Plugin } from "../plugin" import { serviceUse } from "@opencode-ai/core/effect/service-use" import { type LanguageModelV3 } from "@ai-sdk/provider" @@ -1688,6 +1689,7 @@ export const layer = Layer.effect( const headerTimeout = options["headerTimeout"] delete options["chunkTimeout"] delete options["headerTimeout"] + const dispatcher = customFetch ? undefined : createUndiciDispatcher(options["timeout"]) options["fetch"] = async (input: any, init?: BunFetchRequestInit) => { const fetchFn = customFetch ?? fetch @@ -1708,9 +1710,10 @@ export const layer = Layer.effect( const res = await fetchFn(input, { ...opts, + ...(dispatcher ? { dispatcher } : {}), // @ts-ignore see here: https://github.com/oven-sh/bun/issues/16682 timeout: false, - }).finally(() => headerTimeoutCtl?.clear()) + } as BunFetchRequestInit).finally(() => headerTimeoutCtl?.clear()) if (!chunkAbortCtl) return res return wrapSSE(res, chunkTimeout, chunkAbortCtl) diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index 78643b6a1ef1..65625ec96bbd 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -30,6 +30,7 @@ "typescript": "catalog:" }, "dependencies": { - "cross-spawn": "catalog:" + "cross-spawn": "catalog:", + "undici": "8.3.0" } } diff --git a/packages/sdk/js/src/client.ts b/packages/sdk/js/src/client.ts index 5cf071e7b7ab..928cfb4b1807 100644 --- a/packages/sdk/js/src/client.ts +++ b/packages/sdk/js/src/client.ts @@ -4,6 +4,7 @@ import { createClient } from "./gen/client/client.gen.js" import { type Config } from "./gen/client/types.gen.js" import { OpencodeClient } from "./gen/sdk.gen.js" import { wrapClientError } from "./error-interceptor.js" +import { nodeFetchWithDispatcher } from "./node-fetch.js" export { type Config as OpencodeClientConfig, OpencodeClient } function pick(value: string | null, fallback?: string) { @@ -32,11 +33,7 @@ function rewrite(request: Request, directory?: string) { export function createOpencodeClient(config?: Config & { directory?: string }) { if (!config?.fetch) { - const customFetch: any = (req: any) => { - // @ts-ignore - req.timeout = false - return fetch(req) - } + const customFetch: any = (req: any) => nodeFetchWithDispatcher(req) config = { ...config, fetch: customFetch, diff --git a/packages/sdk/js/src/node-fetch.ts b/packages/sdk/js/src/node-fetch.ts new file mode 100644 index 000000000000..2e2b0a848f6d --- /dev/null +++ b/packages/sdk/js/src/node-fetch.ts @@ -0,0 +1,24 @@ +// undici's fetch defaults headersTimeout and bodyTimeout to 300s, which kills +// long-running sessions at the ~303s mark. The SDK client→server connection +// (blocking POST /session/:id/message) needs both disabled so the request can +// survive past 5 minutes. Bun is unaffected — its fetch has no such defaults. +// +// undici is a hard dependency, but we still import it lazily inside the guard +// so browser bundlers don't pull undici (and its node:* deps) into the bundle +// when the SDK is loaded in a non-Node environment. +// +// The promise is cached so concurrent first calls share a single Agent — +// without this, two parallel calls could each create and race to assign an +// Agent, leaving the loser orphaned (never closed). +type UndiciAgent = InstanceType + +const nodeDispatcher: Promise = + typeof process === "undefined" || !process.versions || process.versions.bun + ? Promise.resolve(null) + : import("undici").then(({ Agent }) => new Agent({ headersTimeout: 0, bodyTimeout: 0 })) + +export async function nodeFetchWithDispatcher(req: Request) { + const dispatcher = await nodeDispatcher + if (!dispatcher) return fetch(req) + return fetch(req, { dispatcher } as RequestInit) +} diff --git a/packages/sdk/js/src/v2/client.ts b/packages/sdk/js/src/v2/client.ts index c1956cffe037..f8c422f6794d 100644 --- a/packages/sdk/js/src/v2/client.ts +++ b/packages/sdk/js/src/v2/client.ts @@ -5,6 +5,7 @@ import { createClient } from "./gen/client/client.gen.js" import { type Config } from "./gen/client/types.gen.js" import { OpencodeClient } from "./gen/sdk.gen.js" import { wrapClientError } from "../error-interceptor.js" +import { nodeFetchWithDispatcher } from "../node-fetch.js" export { type Config as OpencodeClientConfig, OpencodeClient } function pick(value: string | null, fallback?: string, encode?: (value: string) => string) { @@ -49,11 +50,7 @@ function rewrite(request: Request, values: { directory?: string; workspace?: str export function createOpencodeClient(config?: Config & { directory?: string; experimental_workspaceID?: string }) { if (!config?.fetch) { - const customFetch: any = (req: any) => { - // @ts-ignore - req.timeout = false - return fetch(req) - } + const customFetch: any = (req: any) => nodeFetchWithDispatcher(req) config = { ...config, fetch: customFetch,