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
2 changes: 2 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/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
"solid-js": "catalog:",
"tree-sitter-bash": "0.25.0",
"tree-sitter-powershell": "0.25.10",
"undici": "8.11.2",
"uqr": "0.1.3",
"web-tree-sitter": "0.25.10",
"ws": "8.21.0"
Expand Down
5 changes: 5 additions & 0 deletions packages/cli/vite.node.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,10 +109,15 @@ const resolve = {
alias: [
{ find: /^solid-js\/store$/, replacement: "solid-js/store/dist/store.js" },
{ find: /^solid-js$/, replacement: "solid-js/dist/solid.js" },
// Bun, which runs this build, lists ws and undici as builtins, so Vite would leave them external.
{
find: /^ws$/,
replacement: path.join(path.dirname(createRequire(import.meta.url).resolve("ws/package.json")), "wrapper.mjs"),
},
{
find: /^undici$/,
replacement: path.join(path.dirname(createRequire(import.meta.url).resolve("undici/package.json")), "index.js"),
},
],
conditions: ["node"],
}
Expand Down
7 changes: 7 additions & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,12 @@
"node": "./src/image/photon-wasm.node.ts",
"default": "./src/image/photon-wasm.bun.ts"
},
"#runtime-fetch": {
"workerd": "./src/effect/runtime-fetch.workerd.ts",
"bun": "./src/effect/runtime-fetch.bun.ts",
"node": "./src/effect/runtime-fetch.node.ts",
"default": "./src/effect/runtime-fetch.bun.ts"
},
"#shell-parser-wasm": {
"workerd": "./src/shell/parser-wasm.workerd.ts",
"bun": "./src/shell/parser-wasm.bun.ts",
Expand Down Expand Up @@ -132,6 +138,7 @@
"mime-types": "3.0.2",
"tree-sitter-bash": "0.25.0",
"tree-sitter-powershell": "0.25.10",
"undici": "8.11.2",
"web-tree-sitter": "0.25.10",
"which": "6.0.1",
"zod": "catalog:"
Expand Down
1 change: 1 addition & 0 deletions packages/core/script/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ const result = await Bun.build({
"#persistent-pty-binary",
"#fff",
"#photon-wasm",
"#runtime-fetch",
"#shell-parser-wasm",
"#v1-migration",
],
Expand Down
17 changes: 7 additions & 10 deletions packages/core/src/aisdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import { Cause, Context, Effect, Layer, Option, Schema, Scope, Stream } from "ef
import { makeParser } from "effect/encoding/Sse"
import { HttpClientRequest, HttpClientResponse } from "effect/http"
import { AsyncLocalStorage } from "node:async_hooks"
import { withoutRuntimeTimeouts } from "#runtime-fetch"
import type { ID, RuntimeInfo } from "./model.js"
import { Provider } from "./provider.js"
import { State } from "./state.js"
Expand Down Expand Up @@ -159,31 +160,27 @@ function prepareOptions(model: RuntimeInfo, pkg: string) {
}
}

const send: Fetch = typeof customFetch === "function" ? customFetch : fetch
const send = withoutRuntimeTimeouts(typeof customFetch === "function" ? customFetch : fetch)
const middleware = httpMiddleware.getStore()
const res = await (
middleware
? throughMiddleware(middleware, send, input, { ...opts, timeout: false })
: send(input, { ...opts, timeout: false })
).finally(() => clearTimeout(headerTimer))
const res = await (middleware ? throughMiddleware(middleware, send, input, opts) : send(input, opts)).finally(() =>
clearTimeout(headerTimer),
)
if (timeouts.chunkTimeout === false) return res
return wrapSSE(res, timeouts.chunkTimeout, chunks)
}

return options
}

type Fetch = (input: Parameters<typeof fetch>[0], init?: BunFetchRequestInit) => Promise<Response>

// HTTP hook middleware is scoped to one model request, but the SDK's fetch is baked into the
// cached language model, so the active middleware rides along in async context instead.
const httpMiddleware = new AsyncLocalStorage<{ http: HttpMiddleware; context: Context.Context<never> }>()

function throughMiddleware(
store: { http: HttpMiddleware; context: Context.Context<never> },
send: Fetch,
send: typeof fetch,
input: Parameters<typeof fetch>[0],
init: BunFetchRequestInit,
init: RequestInit,
) {
const toError = (cause: unknown) => (cause instanceof Error ? cause : new Error(String(cause)))
const request = input instanceof Request ? new Request(input, init) : new Request(String(input), init)
Expand Down
14 changes: 8 additions & 6 deletions packages/core/src/effect/app-node-platform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,23 +4,25 @@ import { FetchHttpClient, HttpClient } from "effect/http"
import { Socket } from "effect/socket"
import { makeGlobalNode } from "@opencode/util/effect/app-node"
import { httpClient } from "@opencode/util/effect/app-node-platform"
import { withoutRuntimeTimeouts } from "#runtime-fetch"
import { WebSocketConstructor } from "./websocket-constructor.js"

// Bun's fetch ends a request after five quiet minutes, before provider routes' own header and chunk timeouts.
const withoutBunTimeout = Layer.effect(
// Provider routes enforce their own header, chunk, and total timeouts; the runtime fetch must not end requests first.
const withoutFetchTimeouts = Layer.effect(
HttpClient.HttpClient,
Effect.gen(function* () {
const client = yield* HttpClient.HttpClient
return HttpClient.transformResponse(
client,
Effect.provideService(FetchHttpClient.RequestInit, { timeout: false } as BunFetchRequestInit),
return HttpClient.transformResponse(client, (effect) =>
Effect.flatMap(FetchHttpClient.Fetch, (fetch) =>
Effect.provideService(effect, FetchHttpClient.Fetch, withoutRuntimeTimeouts(fetch)),
),
)
}),
)

export const requestExecutor = makeGlobalNode({
service: RequestExecutor.Service,
layer: RequestExecutor.layer.pipe(Layer.provide(withoutBunTimeout)),
layer: RequestExecutor.layer.pipe(Layer.provide(withoutFetchTimeouts)),
deps: [httpClient],
})

Expand Down
10 changes: 10 additions & 0 deletions packages/core/src/effect/runtime-fetch.bun.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
type Fetch = typeof globalThis.fetch

// Bun's fetch ends a request after five quiet minutes, before provider routes' own header, chunk, and total timeouts.
export function withoutRuntimeTimeouts(runtime: Fetch): Fetch {
return Object.assign(
(input: Parameters<Fetch>[0], init?: RequestInit) =>
runtime(input, { ...init, timeout: false } as BunFetchRequestInit),
{ preconnect: runtime.preconnect },
)
}
22 changes: 22 additions & 0 deletions packages/core/src/effect/runtime-fetch.node.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { EnvHttpProxyAgent, fetch } from "undici"

type Fetch = typeof globalThis.fetch

// Node's fetch applies undici's default 300s headersTimeout and bodyTimeout, which end a request to a model that
// thinks for five minutes before provider routes' own header, chunk, and total timeouts apply. Like Bun's fetch,
// the agent honors HTTP_PROXY, HTTPS_PROXY, and NO_PROXY.
const dispatcher = new EnvHttpProxyAgent({ headersTimeout: 0, bodyTimeout: 0 })

export function withoutRuntimeTimeouts(runtime: Fetch): Fetch {
// A fetch supplied by a host, plugin, or test stays in charge.
if (runtime !== globalThis.fetch) return runtime
return Object.assign(
(input: Parameters<Fetch>[0], init?: RequestInit) =>
// Node's built-in fetch rejects an agent from a different undici release, and undici's fetch cannot read the
// built-in Request and FormData classes, so those requests keep the built-in fetch and its timeouts.
input instanceof Request || init?.body instanceof FormData
? runtime(input, init)
: (fetch(input, { ...init, dispatcher } as Parameters<typeof fetch>[1]) as unknown as Promise<Response>),
{ preconnect: runtime.preconnect },
)
}
6 changes: 6 additions & 0 deletions packages/core/src/effect/runtime-fetch.workerd.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
type Fetch = typeof globalThis.fetch

// Provider routes enforce their own header, chunk, and total timeouts; workerd keeps its platform fetch unchanged.
export function withoutRuntimeTimeouts(runtime: Fetch): Fetch {
return runtime
}
34 changes: 32 additions & 2 deletions packages/core/test/effect-app-node-platform.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { describe, expect, test } from "bun:test"
import { WebSocketTransport } from "@opencode/ai/route"
import { RequestExecutor, WebSocketTransport } from "@opencode/ai/route"
import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder"
import { LayerNodePlatform } from "@opencode/core/effect/app-node-platform"
import { WebSocketConstructor } from "@opencode/core/effect/websocket-constructor"
import { Effect } from "effect"
import { Headers } from "effect/http"
import { FetchHttpClient, Headers, HttpClientRequest } from "effect/http"
import { Socket } from "effect/socket"

const makeServer = (fetch: (request: Request, server: Bun.Server<undefined>) => Response | undefined) =>
Expand All @@ -18,6 +20,34 @@ const makeServer = (fetch: (request: Request, server: Bun.Server<undefined>) =>
(server) => Effect.promise(() => server.stop(true)),
)

describe("provider fetch policy", () => {
// Bun's fetch otherwise ends a request after five quiet minutes, before provider routes' own timeouts.
test("sends provider requests through the ambient fetch without Bun's idle deadline", async () => {
const sent: Array<{ url: string; timeout?: boolean }> = []
const ambient = Object.assign(
async (input: string | URL | Request, init?: RequestInit & { readonly timeout?: boolean }) => {
sent.push({ url: String(input), timeout: init?.timeout })
return new Response("ok")
},
{ preconnect: fetch.preconnect },
)

const status = await Effect.runPromise(
Effect.gen(function* () {
const executor = yield* RequestExecutor.Service
const response = yield* executor.execute(HttpClientRequest.post("https://provider.test/v1/chat/completions"))
return response.status
}).pipe(
Effect.provide(AppNodeBuilder.build(LayerNodePlatform.requestExecutor)),
Effect.provideService(FetchHttpClient.Fetch, ambient),
),
)

expect(status).toBe(200)
expect(sent).toEqual([{ url: "https://provider.test/v1/chat/completions", timeout: false }])
})
})

describe("WebSocket network policy", () => {
test("uses protocol-specific and standard proxy variables", () => {
expect(
Expand Down
Loading