diff --git a/packages/ai/src/route/client.ts b/packages/ai/src/route/client.ts
index 067292329be1..2e38b96a862a 100644
--- a/packages/ai/src/route/client.ts
+++ b/packages/ai/src/route/client.ts
@@ -8,6 +8,7 @@ import { HttpTransport } from "./transport"
import type { Transport, TransportRuntime } from "./transport"
import { WebSocketExecutor } from "./transport"
import type { Protocol } from "./protocol"
+import type { CallOptions } from "./request-transform"
import { applyCachePolicy } from "../cache-policy"
import * as ProviderShared from "../protocols/shared"
import type { LLMError, PreparedRequestOf, ProtocolID, ProviderOptions } from "../schema"
@@ -47,7 +48,11 @@ export interface Route
{
readonly body: RouteBody
readonly with: (patch: RoutePatch) => Route
readonly model: (input: RouteMappedModelInput) => Model
- readonly prepareTransport: (body: Body, request: LLMRequest) => Effect.Effect
+ readonly prepareTransport: (
+ body: Body,
+ request: LLMRequest,
+ options?: CallOptions,
+ ) => Effect.Effect
readonly streamPrepared: (
prepared: Prepared,
request: LLMRequest,
@@ -158,11 +163,11 @@ export interface Interface {
}
export interface StreamMethod {
- (request: LLMRequest): Stream.Stream
+ (request: LLMRequest, options?: CallOptions): Stream.Stream
}
export interface GenerateMethod {
- (request: LLMRequest): Effect.Effect
+ (request: LLMRequest, options?: CallOptions): Effect.Effect
}
export class Service extends Context.Service()("@opencode/LLMClient") {}
@@ -297,7 +302,7 @@ function makeFromTransport(
})
},
model: (input) => makeRouteModel(route, input),
- prepareTransport: (body, request) =>
+ prepareTransport: (body, request, options) =>
routeInput.transport.prepare({
body,
request,
@@ -305,6 +310,7 @@ function makeFromTransport(
auth: routeInput.auth ?? Auth.none,
encodeBody,
headers: routeInput.headers,
+ transformRequest: options?.transformRequest,
}),
streamPrepared: (prepared: Prepared, request: LLMRequest, runtime: TransportRuntime) => {
const route = `${request.model.provider}/${request.model.route.id}`
@@ -373,14 +379,14 @@ export function make(
// `compile` is the important boundary: it turns a common `LLMRequest` into a
// validated provider body plus transport-private prepared data, but does not
// execute transport.
-const compile = Effect.fn("LLM.compile")(function* (request: LLMRequest) {
+const compile = Effect.fn("LLM.compile")(function* (request: LLMRequest, options?: CallOptions) {
const resolved = applyCachePolicy(resolveRequestOptions(request))
const route = resolved.model.route
const body = yield* route.body
.from(resolved)
.pipe(Effect.flatMap(ProviderShared.validateWith(Schema.decodeUnknownEffect(route.body.schema))))
- const prepared = yield* route.prepareTransport(body, resolved)
+ const prepared = yield* route.prepareTransport(body, resolved, options)
return {
request: resolved,
@@ -403,17 +409,17 @@ const prepareWith = Effect.fn("LLMClient.prepare")(function* (request: LLMReques
})
})
-const streamRequestWith = (runtime: TransportRuntime) => (request: LLMRequest) =>
+const streamRequestWith = (runtime: TransportRuntime) => (request: LLMRequest, options?: CallOptions) =>
Stream.unwrap(
Effect.gen(function* () {
- const compiled = yield* compile(request)
+ const compiled = yield* compile(request, options)
return compiled.route.streamPrepared(compiled.prepared, compiled.request, runtime)
}),
)
const generateWith = (stream: Interface["stream"]) =>
- Effect.fn("LLM.generate")(function* (request: LLMRequest) {
- const state = yield* stream(request).pipe(Stream.runFold(LLMResponse.empty, LLMResponse.reduce))
+ Effect.fn("LLM.generate")(function* (request: LLMRequest, options?: CallOptions) {
+ const state = yield* stream(request, options).pipe(Stream.runFold(LLMResponse.empty, LLMResponse.reduce))
const response = LLMResponse.complete(state)
if (response) return response
return yield* ProviderShared.eventError(
@@ -425,17 +431,17 @@ const generateWith = (stream: Interface["stream"]) =>
export const prepare = (request: LLMRequest) =>
prepareWith(request) as Effect.Effect, LLMError>
-export function stream(request: LLMRequest): Stream.Stream {
+export function stream(request: LLMRequest, options?: CallOptions): Stream.Stream {
return Stream.unwrap(
Effect.gen(function* () {
- return (yield* Service).stream(request)
+ return (yield* Service).stream(request, options)
}),
) as Stream.Stream
}
-export function generate(request: LLMRequest): Effect.Effect {
+export function generate(request: LLMRequest, options?: CallOptions): Effect.Effect {
return Effect.gen(function* () {
- return yield* (yield* Service).generate(request)
+ return yield* (yield* Service).generate(request, options)
}) as Effect.Effect
}
diff --git a/packages/ai/src/route/index.ts b/packages/ai/src/route/index.ts
index 70db881ea462..08427d9c7677 100644
--- a/packages/ai/src/route/index.ts
+++ b/packages/ai/src/route/index.ts
@@ -9,6 +9,7 @@ export type {
Interface as LLMClientShape,
Service as LLMClientService,
} from "./client"
+export type { CallOptions, RequestData, RequestTransform, RequestValue } from "./request-transform"
export * from "./executor"
export { Auth } from "./auth"
export { AuthOptions } from "./auth-options"
diff --git a/packages/ai/src/route/request-transform.ts b/packages/ai/src/route/request-transform.ts
new file mode 100644
index 000000000000..e278aa34e0df
--- /dev/null
+++ b/packages/ai/src/route/request-transform.ts
@@ -0,0 +1,14 @@
+import type { Effect } from "effect"
+
+export type RequestValue = null | boolean | number | string | RequestValue[] | { [key: string]: RequestValue }
+
+export interface RequestData {
+ readonly headers: Record
+ readonly body: Record
+}
+
+export type RequestTransform = (request: RequestData) => Effect.Effect
+
+export interface CallOptions {
+ readonly transformRequest?: RequestTransform
+}
diff --git a/packages/ai/src/route/transport/http.ts b/packages/ai/src/route/transport/http.ts
index 785e450a5e20..469eaca0cb39 100644
--- a/packages/ai/src/route/transport/http.ts
+++ b/packages/ai/src/route/transport/http.ts
@@ -1,4 +1,4 @@
-import { Effect, Stream } from "effect"
+import { Effect, Schema, Stream } from "effect"
import { Headers, HttpClientRequest } from "effect/unstable/http"
import { Auth } from "../auth"
import { render as renderEndpoint } from "../endpoint"
@@ -6,6 +6,7 @@ import { Framing } from "../framing"
import type { Transport, TransportPrepareInput } from "./index"
import * as ProviderShared from "../../protocols/shared"
import { mergeJsonRecords, type LLMRequest } from "../../schema"
+import type { RequestValue } from "../request-transform"
export type JsonRequestInput = TransportPrepareInput
@@ -86,24 +87,61 @@ const bodyWithOverlay = (body: Body, request: LLMRequest, encodeBody: (bod
return yield* ProviderShared.invalidRequest("http.body can only overlay JSON object request bodies")
})
-export const jsonRequestParts = (input: JsonRequestInput) =>
+const isRequestValue = (value: unknown): value is RequestValue => {
+ if (value === null || ["boolean", "number", "string"].includes(typeof value)) return true
+ if (Array.isArray(value)) return value.every(isRequestValue)
+ return ProviderShared.isRecord(value) && Object.values(value).every(isRequestValue)
+}
+
+export const isRequestBody = (value: unknown): value is Record =>
+ ProviderShared.isRecord(value) && Object.values(value).every(isRequestValue)
+
+const decodeJson = Schema.decodeUnknownEffect(Schema.UnknownFromJsonString)
+
+export const decodeRequestBody = (text: string) =>
+ decodeJson(text).pipe(
+ Effect.mapError(() => ProviderShared.invalidRequest("Request hooks require a JSON object body")),
+ Effect.flatMap((body) =>
+ isRequestBody(body)
+ ? Effect.succeed(body)
+ : Effect.fail(ProviderShared.invalidRequest("Request hooks require a JSON object body")),
+ ),
+ )
+
+export const jsonRequestBaseParts = (input: JsonRequestInput) =>
Effect.gen(function* () {
const url = applyQuery(
renderEndpoint(input.endpoint, { request: input.request, body: input.body }).toString(),
input.request.http?.query,
)
const body = yield* bodyWithOverlay(input.body, input.request, input.encodeBody)
- const headers = yield* Auth.toEffect(input.auth)({
- request: input.request,
- method: "POST",
+ return {
url,
- body: body.bodyText,
- headers: Headers.fromInput({
+ jsonBody: body.jsonBody,
+ bodyText: body.bodyText,
+ headers: {
...input.headers?.({ request: input.request }),
...input.request.http?.headers,
- }),
+ },
+ }
+ })
+
+export const jsonRequestParts = (input: JsonRequestInput) =>
+ Effect.gen(function* () {
+ const base = yield* jsonRequestBaseParts(input)
+ const transformRequest = input.transformRequest
+ const transformed = transformRequest
+ ? yield* transformRequest({ headers: base.headers, body: yield* decodeRequestBody(base.bodyText) })
+ : { headers: base.headers, body: base.jsonBody }
+ const bodyText = transformRequest ? ProviderShared.encodeJson(transformed.body) : base.bodyText
+ const headers = yield* Auth.toEffect(input.auth)({
+ request: input.request,
+ method: "POST",
+ url: base.url,
+ body: bodyText,
+ headers: Headers.fromInput(transformed.headers),
})
- return { url, jsonBody: body.jsonBody, bodyText: body.bodyText, headers }
+ return { url: base.url, jsonBody: transformed.body, bodyText, headers }
})
export interface HttpJsonInput<_Body, Frame> {
diff --git a/packages/ai/src/route/transport/index.ts b/packages/ai/src/route/transport/index.ts
index cf8fef1d08c6..33ab5dd59543 100644
--- a/packages/ai/src/route/transport/index.ts
+++ b/packages/ai/src/route/transport/index.ts
@@ -4,6 +4,7 @@ import { Auth } from "../auth"
import type { Interface as RequestExecutorInterface } from "../executor"
import type { Interface as WebSocketExecutorInterface } from "./websocket"
import type { LLMError, LLMRequest } from "../../schema"
+import type { RequestTransform } from "../request-transform"
export interface TransportRuntime {
readonly http: RequestExecutorInterface
@@ -27,6 +28,7 @@ export interface TransportPrepareInput {
readonly auth: Auth.Definition
readonly encodeBody: (body: Body) => string
readonly headers?: (input: { readonly request: LLMRequest }) => Record
+ readonly transformRequest?: RequestTransform
}
export * as HttpTransport from "./http"
diff --git a/packages/ai/src/route/transport/websocket.ts b/packages/ai/src/route/transport/websocket.ts
index 310121420c4f..fbe0e483e0c6 100644
--- a/packages/ai/src/route/transport/websocket.ts
+++ b/packages/ai/src/route/transport/websocket.ts
@@ -1,6 +1,8 @@
import { Cause, Context, Effect, Layer, Queue, Stream } from "effect"
import { Headers } from "effect/unstable/http"
+import { encodeJson } from "../../protocols/shared"
import { LLMError, TransportReason } from "../../schema"
+import { Auth } from "../auth"
import * as HttpTransport from "./http"
import type { Transport } from "./index"
@@ -228,13 +230,24 @@ export const json = (input: JsonInput): JsonTransp
with: (patch) => json({ ...input, ...patch }),
prepare: (prepareInput) =>
Effect.gen(function* () {
- const parts = yield* HttpTransport.jsonRequestParts({
- ...prepareInput,
+ const parts = yield* HttpTransport.jsonRequestBaseParts(prepareInput)
+ const message = input.encodeMessage(yield* input.toMessage(parts.jsonBody))
+ const transformRequest = prepareInput.transformRequest
+ const transformed = transformRequest
+ ? yield* transformRequest({ headers: parts.headers, body: yield* HttpTransport.decodeRequestBody(message) })
+ : undefined
+ const bodyText = transformed ? encodeJson(transformed.body) : message
+ const headers = yield* Auth.toEffect(prepareInput.auth)({
+ request: prepareInput.request,
+ method: "POST",
+ url: parts.url,
+ body: bodyText,
+ headers: Headers.fromInput(transformed?.headers ?? parts.headers),
})
return {
url: yield* webSocketUrl(parts.url),
- headers: parts.headers,
- message: input.encodeMessage(yield* input.toMessage(parts.jsonBody)),
+ headers,
+ message: bodyText,
}
}),
frames: (prepared, _request, runtime) => {
diff --git a/packages/ai/test/prepare.test.ts b/packages/ai/test/prepare.test.ts
index 6923c5a678ed..81de2617428c 100644
--- a/packages/ai/test/prepare.test.ts
+++ b/packages/ai/test/prepare.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
-import { Effect, Schema } from "effect"
+import { Effect, Schema, Stream } from "effect"
import { HttpClientRequest } from "effect/unstable/http"
import { LLM, mergeProviderOptions } from "../src"
import { AnthropicMessages, OpenAIChat } from "../src/protocols"
@@ -136,6 +136,41 @@ describe("request option precedence", () => {
),
)
+ it.effect("transforms provider-native headers and JSON", () =>
+ Effect.gen(function* () {
+ const model = OpenAIChat.route
+ .with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") })
+ .model({ id: "gpt-4o-mini" })
+ yield* LLMClient.stream(LLM.request({ model, prompt: "Say hello." }), {
+ transformRequest: (request) =>
+ Effect.sync(() => {
+ const body = { ...request.body, store: true }
+ delete body.stream_options
+ return {
+ headers: { ...request.headers, "x-plugin": "enabled" },
+ body,
+ }
+ }),
+ }).pipe(
+ Stream.runDrain,
+ Effect.provide(
+ dynamicResponse((input) =>
+ Effect.gen(function* () {
+ const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
+ expect(web.headers.get("authorization")).toBe("Bearer test")
+ expect(web.headers.get("x-plugin")).toBe("enabled")
+ expect(decodeJson(input.text)).toMatchObject({ store: true })
+ expect(decodeJson(input.text)).not.toHaveProperty("stream_options")
+ return input.respond(sseEvents(deltaChunk({}, "stop")), {
+ headers: { "content-type": "text/event-stream" },
+ })
+ }),
+ ),
+ ),
+ )
+ }),
+ )
+
it.effect("rejects raw body overlays for protocol-owned roots", () =>
Effect.gen(function* () {
const model = OpenAIChat.route
diff --git a/packages/ai/test/provider/openai-responses.test.ts b/packages/ai/test/provider/openai-responses.test.ts
index 75483406903f..3549d63760a9 100644
--- a/packages/ai/test/provider/openai-responses.test.ts
+++ b/packages/ai/test/provider/openai-responses.test.ts
@@ -1,7 +1,7 @@
import { describe, expect } from "bun:test"
import { ConfigProvider, Effect, Layer, Stream } from "effect"
import { Headers, HttpClientRequest } from "effect/unstable/http"
-import { LLM, LLMError, Message, Model, ToolCallPart, Usage } from "../../src"
+import { LLM, LLMError, LLMEvent, Message, Model, ToolCallPart, Usage } from "../../src"
import { Auth, LLMClient, RequestExecutor, WebSocketExecutor } from "../../src/route"
import * as Azure from "../../src/providers/azure"
import * as OpenAI from "../../src/providers/openai"
@@ -218,16 +218,34 @@ describe("OpenAI Responses route", () => {
}),
),
)
- const response = yield* LLMClient.generate(
+ const text: string[] = []
+ yield* LLMClient.stream(
LLM.request({
model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responsesWebSocket(
"gpt-4.1-mini",
),
prompt: "Say hello.",
}),
- ).pipe(Effect.provide(LLMClient.layer.pipe(Layer.provide(deps))))
+ {
+ transformRequest: (request) =>
+ Effect.sync(() => {
+ expect(request.body.type).toBe("response.create")
+ expect(request.body.stream).toBeUndefined()
+ const body = { ...request.body, plugin: true }
+ delete body.store
+ return { headers: { ...request.headers, "x-plugin": "enabled" }, body }
+ }),
+ },
+ ).pipe(
+ Stream.runForEach((event) =>
+ Effect.sync(() => {
+ if (LLMEvent.is.textDelta(event)) text.push(event.text)
+ }),
+ ),
+ Effect.provide(LLMClient.layer.pipe(Layer.provide(deps))),
+ )
- expect(response.text).toBe("Hi")
+ expect(text.join("")).toBe("Hi")
expect(opened).toEqual([{ url: "wss://api.openai.test/v1/responses", authorization: "Bearer test" }])
expect(closed).toBe(true)
expect(sent).toHaveLength(1)
@@ -235,7 +253,7 @@ describe("OpenAI Responses route", () => {
type: "response.create",
model: "gpt-4.1-mini",
input: [{ role: "user", content: [{ type: "input_text", text: "Say hello." }] }],
- store: false,
+ plugin: true,
})
}),
)
diff --git a/packages/core/src/aisdk.ts b/packages/core/src/aisdk.ts
index 4ecda81b9ec6..2fbf8090e346 100644
--- a/packages/core/src/aisdk.ts
+++ b/packages/core/src/aisdk.ts
@@ -1,5 +1,6 @@
export * as AISDK from "./aisdk"
+import { AsyncLocalStorage } from "node:async_hooks"
import { makeLocationNode } from "./effect/app-node"
import type {
JSONSchema7,
@@ -29,7 +30,7 @@ import {
type ToolDefinition,
type UsageInput,
} from "@opencode-ai/ai"
-import { Auth, Endpoint, type AnyRoute } from "@opencode-ai/ai/route"
+import { Auth, Endpoint, type AnyRoute, type CallOptions } from "@opencode-ai/ai/route"
import { Cause, Context, Effect, Layer, Option, Schema, Scope, Stream } from "effect"
import { ModelV2 } from "./model"
import { ProviderV2 } from "./provider"
@@ -39,6 +40,12 @@ type SDK = any
type UserContent = Extract["content"]
type AssistantContent = Extract["content"]
type ToolResultContent = Extract
+type TransformRequest = NonNullable
+
+interface AISDKPrepared {
+ readonly call: LanguageModelV3CallOptions
+ readonly transformRequest?: TransformRequest
+}
export interface SDKEvent {
readonly model: ModelV2.Info
@@ -102,7 +109,7 @@ function wrapSSE(res: Response, ms: number, ctl: AbortController) {
})
}
-function prepareOptions(model: ModelV2.Info, pkg: string) {
+function prepareOptions(model: ModelV2.Info, pkg: string, requests: AsyncLocalStorage) {
const projected = mapBodyToProviderOptions(model, pkg)
const options: Record = {
name: model.providerID,
@@ -149,6 +156,21 @@ function prepareOptions(model: ModelV2.Info, pkg: string) {
}
}
+ const headers = new Headers(opts.headers)
+ const transformRequest = requests.getStore()
+ if (transformRequest) {
+ if (typeof opts.body !== "string") throw new Error("Session request hooks require a JSON request body")
+ const prepared = await Effect.runPromise(
+ transformRequest({
+ headers: Object.fromEntries(headers.entries()),
+ body: JSON.parse(opts.body),
+ }),
+ )
+ opts.headers = prepared.headers
+ opts.body = JSON.stringify(prepared.body)
+ }
+ if (!transformRequest) opts.headers = headers
+
const res = await (typeof customFetch === "function" ? customFetch : fetch)(input, {
...opts,
timeout: false,
@@ -193,6 +215,7 @@ export const locationLayer = Layer.effect(
let languageHooks: ((event: LanguageEvent) => Effect.Effect | void)[] = []
const languages = new Map()
const sdks = new Map()
+ const requests = new AsyncLocalStorage()
const functionIDs = new WeakMap