diff --git a/packages/ai/src/route/client.ts b/packages/ai/src/route/client.ts
index 5021ae958f..4ce9dcc186 100644
--- a/packages/ai/src/route/client.ts
+++ b/packages/ai/src/route/client.ts
@@ -5,7 +5,7 @@ import { Endpoint, type EndpointPatch } from "./endpoint"
import { RequestExecutor } from "./executor"
import { Framing } from "./framing"
import { HttpTransport } from "./transport"
-import type { HttpRequestTransform, Transport, TransportRuntime } from "./transport"
+import type { HttpMiddleware, Transport, TransportRuntime } from "./transport"
import { WebSocketExecutor } from "./transport"
import type { Protocol } from "./protocol"
import { applyCachePolicy } from "../cache-policy"
@@ -96,7 +96,10 @@ export interface RoutePatch
extends RouteDefaultsInput {
type RouteMappedModelInput = RouteModelInput | RouteRoutedModelInput
-const makeRouteModel = (route: AnyRoute, mapped: RouteMappedModelInput) => {
+const makeRouteModel = (
+ route: AnyRoute,
+ mapped: RouteMappedModelInput,
+) => {
const provider = route.provider ?? ("provider" in mapped ? mapped.provider : undefined)
if (!provider) throw new Error(`Route.model(${route.id}) requires a provider`)
if (!endpointBaseURL(route.endpoint))
@@ -150,7 +153,7 @@ export interface Interface {
}
export interface StreamOptions {
- readonly transform?: HttpRequestTransform
+ readonly http?: HttpMiddleware
}
export interface StreamMethod {
@@ -302,7 +305,7 @@ function makeFromTransport(
auth: routeInput.auth ?? Auth.none,
encodeBody,
headers: routeInput.headers,
- transform: options?.transform,
+ middleware: options?.http,
}),
streamPrepared: (prepared: Prepared, request: LLMRequest, runtime: TransportRuntime) => {
const route = `${request.model.provider}/${request.model.route.id}`
diff --git a/packages/ai/src/route/index.ts b/packages/ai/src/route/index.ts
index 0f38232618..7ef778800f 100644
--- a/packages/ai/src/route/index.ts
+++ b/packages/ai/src/route/index.ts
@@ -23,4 +23,4 @@ export type { ApiKeyMode, AuthOverride, ProviderAuthOption } from "./auth-option
export type { Definition as EndpointFn, EndpointInput } from "./endpoint"
export type { Definition as FramingDef } from "./framing"
export type { Protocol as ProtocolDef } from "./protocol"
-export type { HttpRequest, HttpRequestTransform, Transport as TransportDef, TransportRuntime } from "./transport"
+export type { HttpHandler, HttpMiddleware, Transport as TransportDef, TransportRuntime } from "./transport"
diff --git a/packages/ai/src/route/transport/http.ts b/packages/ai/src/route/transport/http.ts
index a1de9ec573..a1204c47f1 100644
--- a/packages/ai/src/route/transport/http.ts
+++ b/packages/ai/src/route/transport/http.ts
@@ -1,11 +1,11 @@
import { Effect, Stream } from "effect"
-import { Headers, HttpClientRequest } from "effect/unstable/http"
+import { Headers, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { Auth } from "../auth"
import { render as renderEndpoint } from "../endpoint"
import { Framing } from "../framing"
-import type { Transport, TransportPrepareInput } from "./index"
+import type { HttpMiddleware, Transport, TransportPrepareInput } from "./index"
import * as ProviderShared from "../../protocols/shared"
-import { mergeJsonRecords, type LLMRequest } from "../../schema"
+import { LLMError, mergeJsonRecords, type LLMRequest } from "../../schema"
export type JsonRequestInput = TransportPrepareInput
@@ -18,7 +18,9 @@ export interface JsonRequestParts {
export interface HttpPrepared {
readonly request: HttpClientRequest.HttpClientRequest
+ readonly web: Request
readonly framing: Framing.Definition
+ readonly middleware?: HttpMiddleware
}
const applyQuery = (url: string, query: Record | undefined) => {
@@ -74,21 +76,62 @@ export const httpJson = (input: HttpJsonInput): HttpJs
prepare: (prepareInput) =>
Effect.gen(function* () {
const parts = yield* jsonRequestParts({ ...prepareInput })
- const request = { url: parts.url, method: "POST", headers: { ...parts.headers }, body: parts.bodyText }
- yield* (prepareInput.transform?.(request) ?? Effect.void)
+ const request = ProviderShared.jsonPost({
+ url: parts.url,
+ body: parts.bodyText,
+ headers: parts.headers,
+ })
return {
- request: ProviderShared.jsonPost({
- url: request.url,
- body: request.body ?? "",
- headers: Headers.fromInput(request.headers),
- }),
+ request,
+ web: new Request(parts.url, { method: "POST", headers: parts.headers, body: parts.bodyText }),
framing: input.framing,
+ middleware: prepareInput.middleware,
}
}),
frames: (prepared, request, runtime) =>
Stream.unwrap(
- runtime.http
- .execute(prepared.request)
+ Effect.gen(function* () {
+ const request = prepared.web
+ const execute = (input: Request) =>
+ Effect.tryPromise({
+ try: () => input.text(),
+ catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))),
+ }).pipe(
+ Effect.flatMap((body) =>
+ runtime.http.execute(
+ ProviderShared.jsonPost({
+ url: input.url,
+ body,
+ headers: Headers.fromInput(input.headers),
+ }),
+ ),
+ ),
+ Effect.flatMap((response) =>
+ Stream.toReadableStreamEffect(response.stream).pipe(
+ Effect.map(
+ (body) =>
+ new Response(body, {
+ status: response.status,
+ headers: response.headers,
+ }),
+ ),
+ ),
+ ),
+ )
+ return yield* prepared.middleware ? prepared.middleware(request, execute) : execute(request)
+ })
+ .pipe(
+ Effect.mapError((error) =>
+ error instanceof LLMError
+ ? error
+ : ProviderShared.eventError(
+ `${request.model.provider}/${request.model.route.id}`,
+ `Failed to execute ${request.model.provider}/${request.model.route.id} request`,
+ ProviderShared.errorText(error),
+ ),
+ ),
+ Effect.map((response) => HttpClientResponse.fromWeb(prepared.request, response)),
+ )
.pipe(
Effect.map((response) =>
prepared.framing.frame(
diff --git a/packages/ai/src/route/transport/index.ts b/packages/ai/src/route/transport/index.ts
index 588ea9c896..275f64c06c 100644
--- a/packages/ai/src/route/transport/index.ts
+++ b/packages/ai/src/route/transport/index.ts
@@ -10,14 +10,8 @@ export interface TransportRuntime {
readonly webSocket?: WebSocketExecutorInterface
}
-export interface HttpRequest {
- url: string
- readonly method: string
- headers: Record
- body: string | undefined
-}
-
-export type HttpRequestTransform = (request: HttpRequest) => Effect.Effect
+export type HttpHandler = (request: Request) => Effect.Effect
+export type HttpMiddleware = (request: Request, handler: HttpHandler) => Effect.Effect
export interface Transport {
readonly id: string
@@ -36,7 +30,7 @@ export interface TransportPrepareInput {
readonly auth: Auth.Definition
readonly encodeBody: (body: Body) => string
readonly headers?: (input: { readonly request: LLMRequest }) => Record
- readonly transform?: HttpRequestTransform
+ readonly middleware?: HttpMiddleware
}
export * as HttpTransport from "./http"
diff --git a/packages/ai/test/compile.test.ts b/packages/ai/test/compile.test.ts
index 27971bf5c8..741b1f7dda 100644
--- a/packages/ai/test/compile.test.ts
+++ b/packages/ai/test/compile.test.ts
@@ -146,12 +146,18 @@ describe("request option precedence", () => {
prompt: "Say hello.",
}),
{
- transform: (request) =>
- Effect.sync(() => {
- expect(request.headers.authorization).toBe("Bearer fresh-key")
- request.url = "https://proxy.test/v1/chat/completions"
- request.headers["x-plugin"] = "transformed"
- request.body = JSON.stringify({ transformed: true })
+ http: (request, handler) =>
+ Effect.gen(function* () {
+ expect(request.headers.get("authorization")).toBe("Bearer fresh-key")
+ const headers = new Headers(request.headers)
+ headers.set("x-plugin", "transformed")
+ return yield* handler(
+ new Request("https://proxy.test/v1/chat/completions", {
+ method: request.method,
+ headers,
+ body: JSON.stringify({ transformed: true }),
+ }),
+ )
}),
},
).pipe(
@@ -171,6 +177,42 @@ describe("request option precedence", () => {
),
)
+ it.effect("transforms the HTTP response before protocol decoding", () =>
+ Effect.gen(function* () {
+ const response = yield* LLMClient.generate(
+ LLM.request({
+ model: OpenAIChat.route
+ .with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") })
+ .model({ id: "gpt-4o-mini" }),
+ prompt: "Say hello.",
+ }),
+ {
+ http: (request, handler) =>
+ Effect.gen(function* () {
+ const response = yield* handler(request)
+ const body = yield* Effect.promise(() => response.text())
+ return new Response(body.replace("network", "hooked"), {
+ status: response.status,
+ headers: response.headers,
+ })
+ }),
+ },
+ ).pipe(
+ Effect.provide(
+ dynamicResponse((input) =>
+ Effect.succeed(
+ input.respond(sseEvents(deltaChunk({ content: "network" }, "stop")), {
+ headers: { "content-type": "text/event-stream" },
+ }),
+ ),
+ ),
+ ),
+ )
+
+ expect(response.text).toBe("hooked")
+ }),
+ )
+
it.effect("applies raw body overlays after protocol lowering", () =>
LLMClient.generate(
LLM.request({
diff --git a/packages/core/src/plugin/promise.ts b/packages/core/src/plugin/promise.ts
index 651bc7c8ba..36d903b9ee 100644
--- a/packages/core/src/plugin/promise.ts
+++ b/packages/core/src/plugin/promise.ts
@@ -194,7 +194,9 @@ export function fromPromise(plugin: Plugin) {
),
),
refresh:
- refresh === undefined ? undefined : (credential) => Effect.promise(() => refresh(credential)),
+ refresh === undefined
+ ? undefined
+ : (credential) => Effect.promise(() => refresh(credential)),
})
},
remove: draft.method.remove,
@@ -263,8 +265,34 @@ export function fromPromise(plugin: Plugin) {
),
},
session: {
- hook: (name, callback) =>
- register(host.session.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))),
+ hook: (name, callback) => {
+ if (name !== "http")
+ return register(
+ host.session.hook(name, (event) =>
+ Effect.promise(() => Promise.resolve(Reflect.apply(callback, undefined, [event]))),
+ ),
+ )
+ return register(
+ host.session.hook("http", (event) => {
+ const request = event.request
+ const output = {
+ ...event,
+ request: (input: Request) => Effect.runPromiseWith(context)(request(input)),
+ }
+ return Effect.promise(() => Promise.resolve(Reflect.apply(callback, undefined, [output]))).pipe(
+ Effect.tap(() =>
+ Effect.sync(() => {
+ event.request = (input) =>
+ Effect.tryPromise({
+ try: () => output.request(input),
+ catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))),
+ })
+ }),
+ ),
+ )
+ }),
+ )
+ },
create: (input) =>
run(
host.session.create(
diff --git a/packages/core/src/plugin/provider/openai.ts b/packages/core/src/plugin/provider/openai.ts
index 166cfb78b7..b57cdf5486 100644
--- a/packages/core/src/plugin/provider/openai.ts
+++ b/packages/core/src/plugin/provider/openai.ts
@@ -228,12 +228,22 @@ export const OpenAIPlugin = define({
yield* ctx.session.hook("http", (evt) =>
Effect.sync(() => {
if (!chatgpt || evt.model.providerID !== Provider.ID.openai) return
- const url = new URL(evt.url)
- if (url.origin === "https://api.openai.com") {
- evt.url = `${codexBaseURL}${url.pathname.replace(/^\/v1/, "")}${url.search}`
+ const request = evt.request
+ evt.request = (input) => {
+ const url = new URL(input.url)
+ const headers = new Headers(input.headers)
+ headers.set("originator", "opencode")
+ headers.set("session-id", evt.sessionID)
+ if (url.origin !== "https://api.openai.com") return request(new Request(input, { headers }))
+ return request(
+ new Request(`${codexBaseURL}${url.pathname.replace(/^\/v1/, "")}${url.search}`, {
+ method: input.method,
+ headers,
+ body: input.body,
+ signal: input.signal,
+ }),
+ )
}
- evt.headers.originator = "opencode"
- evt.headers["session-id"] = evt.sessionID
}),
)
diff --git a/packages/core/src/session/model-request.ts b/packages/core/src/session/model-request.ts
index 829371e25f..86ff5f3a24 100644
--- a/packages/core/src/session/model-request.ts
+++ b/packages/core/src/session/model-request.ts
@@ -220,24 +220,15 @@ export const layer = Layer.effect(
toolChoice: stepLimitReached ? "none" : undefined,
})
const options: StreamOptions = {
- transform: (request) =>
+ http: (request, handler) =>
hooks
.trigger("session", "http", {
sessionID: session.id,
agent: agent.id,
model: resolved.ref,
- ...request,
+ request: handler,
})
- .pipe(
- Effect.tap((event) =>
- Effect.sync(() => {
- request.url = event.url
- request.headers = event.headers
- request.body = event.body
- }),
- ),
- Effect.asVoid,
- ),
+ .pipe(Effect.flatMap((event) => event.request(request))),
}
if (promptCacheSnapshots) {
const current = PromptCacheDiagnostics.snapshot(request)
diff --git a/packages/core/test/plugin/promise.test.ts b/packages/core/test/plugin/promise.test.ts
index f63c7f3eca..a095630779 100644
--- a/packages/core/test/plugin/promise.test.ts
+++ b/packages/core/test/plugin/promise.test.ts
@@ -221,6 +221,39 @@ describe("fromPromise", () => {
}),
)
+ it.effect("adapts promise session HTTP hooks", () =>
+ Effect.gen(function* () {
+ const plugin = yield* Plugin.Service
+ const hooks = yield* PluginHooks.Service
+ const host = yield* PluginHost.make(plugin)
+ yield* PluginPromise.fromPromise(
+ define({
+ id: "promise-session-http",
+ setup: async (ctx) => {
+ await ctx.session.hook("http", (event) => {
+ const request = event.request
+ event.request = async (input) => {
+ const response = await request(new Request(input, { headers: { "x-hook": "promise" } }))
+ return new Response(`${await response.text()}-response`)
+ }
+ })
+ },
+ }),
+ ).effect(host)
+ const event: SessionHooks["http"] = {
+ sessionID: Session.ID.make("ses_promise_session_http"),
+ agent: Agent.ID.make("build"),
+ model: Model.Ref.make({ providerID: Provider.ID.make("test"), id: Model.ID.make("model") }),
+ request: (input) => Effect.succeed(new Response(input.headers.get("x-hook") ?? "missing")),
+ }
+
+ yield* hooks.trigger("session", "http", event)
+ const response = yield* event.request(new Request("https://provider.test"))
+
+ expect(yield* Effect.promise(() => response.text())).toBe("promise-response")
+ }),
+ )
+
it.effect("disposes a hook registration on request", () =>
Effect.gen(function* () {
const agents = yield* Agent.Service
diff --git a/packages/core/test/plugin/provider-openai.test.ts b/packages/core/test/plugin/provider-openai.test.ts
index 7ecaeb4cb0..de5f87e09e 100644
--- a/packages/core/test/plugin/provider-openai.test.ts
+++ b/packages/core/test/plugin/provider-openai.test.ts
@@ -29,6 +29,21 @@ function required(value: T | undefined): T {
return value
}
+const http = Effect.fn(function* (providerID: Provider.ID, url: string) {
+ const event = yield* (yield* PluginHooks.Service).trigger("session", "http", {
+ sessionID: Session.ID.make("ses_test"),
+ agent: Agent.ID.make("build"),
+ model: Model.Ref.make({ providerID, id: Model.ID.make("gpt-5.5") }),
+ request: (input) => {
+ const headers = new Headers(input.headers)
+ headers.set("x-seen-url", input.url)
+ return Effect.succeed(new Response(null, { headers }))
+ },
+ })
+ const response = yield* event.request(new Request(url, { method: "POST", body: "{}" }))
+ return { url: response.headers.get("x-seen-url"), headers: Object.fromEntries(response.headers.entries()) }
+})
+
describe("OpenAIPlugin", () => {
it.effect("registers browser and headless ChatGPT OAuth methods", () =>
Effect.gen(function* () {
@@ -100,33 +115,9 @@ describe("OpenAIPlugin", () => {
})
yield* addPlugin()
- const request = yield* (yield* PluginHooks.Service).trigger("session", "http", {
- sessionID: Session.ID.make("ses_test"),
- agent: Agent.ID.make("build"),
- model: Model.Ref.make({ providerID: Provider.ID.openai, id: Model.ID.make("gpt-5.5") }),
- url: "https://api.openai.com/v1/responses",
- method: "POST",
- headers: {},
- body: "{}",
- })
- const custom = yield* (yield* PluginHooks.Service).trigger("session", "http", {
- sessionID: Session.ID.make("ses_test"),
- agent: Agent.ID.make("build"),
- model: Model.Ref.make({ providerID: Provider.ID.make("custom-openai"), id: Model.ID.make("gpt-5.5") }),
- url: "https://custom.example/v1/responses",
- method: "POST",
- headers: {},
- body: "{}",
- })
- const proxy = yield* (yield* PluginHooks.Service).trigger("session", "http", {
- sessionID: Session.ID.make("ses_test"),
- agent: Agent.ID.make("build"),
- model: Model.Ref.make({ providerID: Provider.ID.openai, id: Model.ID.make("gpt-5.5") }),
- url: "https://proxy.example/v1/responses?region=us",
- method: "POST",
- headers: {},
- body: "{}",
- })
+ const request = yield* http(Provider.ID.openai, "https://api.openai.com/v1/responses")
+ const custom = yield* http(Provider.ID.make("custom-openai"), "https://custom.example/v1/responses")
+ const proxy = yield* http(Provider.ID.openai, "https://proxy.example/v1/responses?region=us")
const provider = required(yield* catalog.provider.get(Provider.ID.openai))
expect(provider.package).toBe("@opencode-ai/ai/providers/openai")
@@ -134,7 +125,7 @@ describe("OpenAIPlugin", () => {
expect(provider.headers).toMatchObject({ "chatgpt-account-id": "acct_123" })
expect(request.url).toBe("https://chatgpt.com/backend-api/codex/responses")
expect(request.headers).toMatchObject({ originator: "opencode", "session-id": "ses_test" })
- expect(custom.headers).toEqual({})
+ expect(custom.headers).not.toHaveProperty("originator")
expect(proxy.url).toBe("https://proxy.example/v1/responses?region=us")
expect(proxy.headers).toMatchObject({ originator: "opencode", "session-id": "ses_test" })
const eligible = required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.5")))
@@ -184,21 +175,13 @@ describe("OpenAIPlugin", () => {
})
yield* addPlugin()
- const request = yield* (yield* PluginHooks.Service).trigger("session", "http", {
- sessionID: Session.ID.make("ses_test"),
- agent: Agent.ID.make("build"),
- model: Model.Ref.make({ providerID: Provider.ID.openai, id: Model.ID.make("gpt-5.5") }),
- url: "https://api.openai.com/v1/responses",
- method: "POST",
- headers: {},
- body: "{}",
- })
+ const request = yield* http(Provider.ID.openai, "https://api.openai.com/v1/responses")
const model = required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.5")))
expect(model.package).toBe("@opencode-ai/ai/providers/openai")
expect(model.enabled).toBe(true)
expect(model.limit).toEqual({ context: 1_050_000, input: 922_000, output: 128_000 })
- expect(request.headers).toEqual({})
+ expect(request.headers).not.toHaveProperty("originator")
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-4.1"))).enabled).toBe(true)
}),
)
diff --git a/packages/plugin/src/effect/session.ts b/packages/plugin/src/effect/session.ts
index 170e6317ea..c2bf4b0ce8 100644
--- a/packages/plugin/src/effect/session.ts
+++ b/packages/plugin/src/effect/session.ts
@@ -1,10 +1,9 @@
import type { SessionApi } from "@opencode-ai/client/effect/api"
import type { Message, SystemPart } from "@opencode-ai/ai"
-import type { HttpRequest } from "@opencode-ai/ai/route"
import type { Agent } from "@opencode-ai/schema/agent"
import type { Model } from "@opencode-ai/schema/model"
import type { Session } from "@opencode-ai/schema/session"
-import type { JsonSchema } from "effect"
+import type { Effect, JsonSchema } from "effect"
import type { Hooks } from "./registration.js"
export interface SessionContext {
@@ -16,10 +15,11 @@ export interface SessionContext {
tools: Record
}
-export interface SessionHttp extends HttpRequest {
+export interface SessionHttp {
readonly sessionID: Session.ID
readonly agent: Agent.ID
readonly model: Model.Ref
+ request: (input: Request) => Effect.Effect
}
export interface SessionHooks {
diff --git a/packages/plugin/src/promise/session.ts b/packages/plugin/src/promise/session.ts
index 7eaf9994ec..b94121d481 100644
--- a/packages/plugin/src/promise/session.ts
+++ b/packages/plugin/src/promise/session.ts
@@ -1,6 +1,5 @@
import type { SessionApi } from "@opencode-ai/client/promise/api"
import type { Message, SystemPart } from "@opencode-ai/ai"
-import type { HttpRequest } from "@opencode-ai/ai/route"
import type { Agent } from "@opencode-ai/schema/agent"
import type { Model } from "@opencode-ai/schema/model"
import type { Session } from "@opencode-ai/schema/session"
@@ -16,10 +15,11 @@ export interface SessionContext {
tools: Record
}
-export interface SessionHttp extends HttpRequest {
+export interface SessionHttp {
readonly sessionID: Session.ID
readonly agent: Agent.ID
readonly model: Model.Ref
+ request: (input: Request) => Promise
}
export interface SessionHooks {
diff --git a/packages/www/content/docs/build/plugins.mdx b/packages/www/content/docs/build/plugins.mdx
index eb545ddde5..f8e1c5cbc5 100644
--- a/packages/www/content/docs/build/plugins.mdx
+++ b/packages/www/content/docs/build/plugins.mdx
@@ -247,7 +247,7 @@ mutable fields:
| `ctx.aisdk.hook("sdk", callback)` | `sdk`, after inspecting `model`, `package`, and `options` |
| `ctx.aisdk.hook("language", callback)` | `language`, after inspecting `model`, `sdk`, and `options` |
| `ctx.session.hook("context", callback)` | `system`, `messages`, and the `tools` record immediately before model dispatch |
-| `ctx.session.hook("http", callback)` | `url`, `headers`, and `body` before the model request is sent |
+| `ctx.session.hook("http", callback)` | `request`, wrapping the model's HTTP request and response |
| `ctx.tool.hook("execute.before", callback)` | `input`, before the selected tool executes |
| `ctx.tool.hook("execute.after", callback)` | Terminal `result` on success or `error` on failure |