Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
5 changes: 4 additions & 1 deletion packages/core/src/aisdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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<typeof fetch>[0], init?: RequestInit) => {
const opts = { ...(init ?? {}) }
const signals = [
Expand Down Expand Up @@ -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)
}
Expand Down
30 changes: 30 additions & 0 deletions packages/core/src/util/undici-dispatcher.ts
Original file line number Diff line number Diff line change
@@ -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)
// <number> >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<string, string | undefined>).bun) return undefined
const ms = resolveTimeoutMs(timeout)
if (ms === undefined) return undefined
return new Agent({ headersTimeout: ms, bodyTimeout: ms })
}
75 changes: 75 additions & 0 deletions packages/core/test/util/undici-dispatcher.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string | undefined>).bun
;(process.versions as Record<string, string | undefined>).bun = "1.0.0"
try {
expect(createUndiciDispatcher(600_000)).toBeUndefined()
expect(createUndiciDispatcher(false)).toBeUndefined()
} finally {
if (original === undefined) delete (process.versions as Record<string, string | undefined>).bun
else (process.versions as Record<string, string | undefined>).bun = original
}
})

test("returns undefined when no bun version and timeout resolves to undefined", () => {
const original = (process.versions as Record<string, string | undefined>).bun
;(process.versions as Record<string, string | undefined>).bun = undefined
try {
expect(createUndiciDispatcher(undefined)).toBeUndefined()
expect(createUndiciDispatcher(0)).toBeUndefined()
} finally {
if (original === undefined) delete (process.versions as Record<string, string | undefined>).bun
else (process.versions as Record<string, string | undefined>).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<string, string | undefined>).bun
;(process.versions as Record<string, string | undefined>).bun = undefined
try {
const agent = createUndiciDispatcher(600_000)
expect(agent).toBeInstanceOf(Agent)
} finally {
if (original === undefined) delete (process.versions as Record<string, string | undefined>).bun
else (process.versions as Record<string, string | undefined>).bun = original
}
})
})
1 change: 1 addition & 0 deletions packages/opencode/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
5 changes: 4 additions & 1 deletion packages/opencode/src/provider/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down
3 changes: 2 additions & 1 deletion packages/sdk/js/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"typescript": "catalog:"
},
"dependencies": {
"cross-spawn": "catalog:"
"cross-spawn": "catalog:",
"undici": "8.3.0"
}
}
7 changes: 2 additions & 5 deletions packages/sdk/js/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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,
Expand Down
24 changes: 24 additions & 0 deletions packages/sdk/js/src/node-fetch.ts
Original file line number Diff line number Diff line change
@@ -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<typeof import("undici").Agent>

const nodeDispatcher: Promise<UndiciAgent | null> =
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)
}
7 changes: 2 additions & 5 deletions packages/sdk/js/src/v2/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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,
Expand Down
Loading