diff --git a/packages/ai/src/route/client.ts b/packages/ai/src/route/client.ts
index b935699f90a4..5021ae958f4f 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 { Transport, TransportRuntime } from "./transport"
+import type { HttpRequestTransform, Transport, TransportRuntime } from "./transport"
import { WebSocketExecutor } from "./transport"
import type { Protocol } from "./protocol"
import { applyCachePolicy } from "../cache-policy"
@@ -46,7 +46,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?: StreamOptions,
+ ) => Effect.Effect
readonly streamPrepared: (
prepared: Prepared,
request: LLMRequest,
@@ -145,12 +149,16 @@ export interface Interface {
readonly generate: GenerateMethod
}
+export interface StreamOptions {
+ readonly transform?: HttpRequestTransform
+}
+
export interface StreamMethod {
- (request: LLMRequest): Stream.Stream
+ (request: LLMRequest, options?: StreamOptions): Stream.Stream
}
export interface GenerateMethod {
- (request: LLMRequest): Effect.Effect
+ (request: LLMRequest, options?: StreamOptions): Effect.Effect
}
export class Service extends Context.Service()("@opencode/LLMClient") {}
@@ -286,7 +294,7 @@ function makeFromTransport(
},
model: (input: RouteMappedModelInput) =>
makeRouteModel(route, input),
- prepareTransport: (body, request) =>
+ prepareTransport: (body, request, options) =>
routeInput.transport.prepare({
body,
request,
@@ -294,6 +302,7 @@ function makeFromTransport(
auth: routeInput.auth ?? Auth.none,
encodeBody,
headers: routeInput.headers,
+ transform: options?.transform,
}),
streamPrepared: (prepared: Prepared, request: LLMRequest, runtime: TransportRuntime) => {
const route = `${request.model.provider}/${request.model.route.id}`
@@ -359,14 +368,14 @@ export function make(
})
}
-const compile = Effect.fn("LLM.compile")(function* (request: LLMRequest) {
+const compile = Effect.fn("LLM.compile")(function* (request: LLMRequest, options?: StreamOptions) {
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,
@@ -389,17 +398,17 @@ export const compileRequest = Effect.fn("LLM.compileRequest")(function* (request
}
})
-const streamRequestWith = (runtime: TransportRuntime) => (request: LLMRequest) =>
+const streamRequestWith = (runtime: TransportRuntime) => (request: LLMRequest, options?: StreamOptions) =>
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?: StreamOptions) {
+ 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(
@@ -408,24 +417,24 @@ const generateWith = (stream: Interface["stream"]) =>
)
})
-export function stream(request: LLMRequest): Stream.Stream {
+export function stream(request: LLMRequest, options?: StreamOptions): 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?: StreamOptions): Effect.Effect {
return Effect.gen(function* () {
- return yield* (yield* Service).generate(request)
+ return yield* (yield* Service).generate(request, options)
}) as Effect.Effect
}
-export const streamRequest = (request: LLMRequest) =>
+export const streamRequest = (request: LLMRequest, options?: StreamOptions) =>
Stream.unwrap(
Effect.gen(function* () {
- return (yield* Service).stream(request)
+ return (yield* Service).stream(request, options)
}),
)
diff --git a/packages/ai/src/route/index.ts b/packages/ai/src/route/index.ts
index 70db881ea462..0f3823261802 100644
--- a/packages/ai/src/route/index.ts
+++ b/packages/ai/src/route/index.ts
@@ -8,6 +8,7 @@ export type {
AnyRoute,
Interface as LLMClientShape,
Service as LLMClientService,
+ StreamOptions,
} from "./client"
export * from "./executor"
export { Auth } from "./auth"
@@ -22,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 { Transport as TransportDef, TransportRuntime } from "./transport"
+export type { HttpRequest, HttpRequestTransform, 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 785e450a5e20..ae8b5e088d67 100644
--- a/packages/ai/src/route/transport/http.ts
+++ b/packages/ai/src/route/transport/http.ts
@@ -120,14 +120,19 @@ export const httpJson = (input: HttpJsonInput): HttpJs
id: "http-json",
with: (patch) => httpJson({ ...input, ...patch }),
prepare: (prepareInput) =>
- jsonRequestParts({
- ...prepareInput,
- }).pipe(
- Effect.map((parts) => ({
- request: ProviderShared.jsonPost({ url: parts.url, body: parts.bodyText, headers: parts.headers }),
+ 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)
+ return {
+ request: ProviderShared.jsonPost({
+ url: request.url,
+ body: request.body ?? "",
+ headers: Headers.fromInput(request.headers),
+ }),
framing: input.framing,
- })),
- ),
+ }
+ }),
frames: (prepared, request, runtime) =>
Stream.unwrap(
runtime.http
diff --git a/packages/ai/src/route/transport/index.ts b/packages/ai/src/route/transport/index.ts
index cf8fef1d08c6..588ea9c8969b 100644
--- a/packages/ai/src/route/transport/index.ts
+++ b/packages/ai/src/route/transport/index.ts
@@ -10,6 +10,15 @@ 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 interface Transport {
readonly id: string
readonly prepare: (input: TransportPrepareInput) => Effect.Effect
@@ -27,6 +36,7 @@ export interface TransportPrepareInput {
readonly auth: Auth.Definition
readonly encodeBody: (body: Body) => string
readonly headers?: (input: { readonly request: LLMRequest }) => Record
+ readonly transform?: HttpRequestTransform
}
export * as HttpTransport from "./http"
diff --git a/packages/ai/test/compile.test.ts b/packages/ai/test/compile.test.ts
index b64b173b0ea5..9c8dbbdfac9f 100644
--- a/packages/ai/test/compile.test.ts
+++ b/packages/ai/test/compile.test.ts
@@ -137,6 +137,40 @@ describe("request option precedence", () => {
),
)
+ it.effect("transforms the final HTTP request after serialization and authentication", () =>
+ LLMClient.generate(
+ LLM.request({
+ model: OpenAIChat.route
+ .with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("fresh-key") })
+ .model({ id: "gpt-4o-mini" }),
+ 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 })
+ }),
+ },
+ ).pipe(
+ Effect.provide(
+ dynamicResponse((input) =>
+ Effect.gen(function* () {
+ const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
+ expect(web.url).toBe("https://proxy.test/v1/chat/completions")
+ expect(web.headers.get("x-plugin")).toBe("transformed")
+ expect(decodeJson(input.text)).toEqual({ transformed: true })
+ 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/core/src/session/model-request.ts b/packages/core/src/session/model-request.ts
index 3cd0a2642033..18ed3a193316 100644
--- a/packages/core/src/session/model-request.ts
+++ b/packages/core/src/session/model-request.ts
@@ -1,6 +1,7 @@
export * as SessionModelRequest from "./model-request"
import { LLM, Message, SystemPart, type LLMRequest } from "@opencode-ai/ai"
+import type { StreamOptions } from "@opencode-ai/ai/route"
import type { Content } from "@opencode-ai/schema/tool"
import { SessionError } from "@opencode-ai/schema/session-error"
import { Cause, Config, Context, Effect, Layer, Result } from "effect"
@@ -37,6 +38,7 @@ const declineDefect = (cause: Cause.Cause) => {
interface Prepared {
readonly request: LLMRequest
+ readonly options: StreamOptions
/**
* One request-scoped execution operation. Unknown, hook-removed, and
* step-limit-violating calls fail individually through the same seam.
@@ -162,6 +164,26 @@ export const layer = Layer.effect(
tools: hookedTools,
toolChoice: stepLimitReached ? "none" : undefined,
})
+ const options: StreamOptions = {
+ transform: (request) =>
+ hooks
+ .trigger("session", "request", {
+ sessionID: session.id,
+ agent: agent.id,
+ model: resolved.ref,
+ ...request,
+ })
+ .pipe(
+ Effect.tap((event) =>
+ Effect.sync(() => {
+ request.url = event.url
+ request.headers = event.headers
+ request.body = event.body
+ }),
+ ),
+ Effect.asVoid,
+ ),
+ }
if (promptCacheSnapshots) {
const current = PromptCacheDiagnostics.snapshot(request)
const comparison = PromptCacheDiagnostics.compare(promptCacheSnapshots.get(session.id), current)
@@ -190,6 +212,7 @@ export const layer = Layer.effect(
}
return {
request,
+ options,
executeTool,
stepLimitReached,
}
diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts
index 42f871e8f704..ae9e2cb120ed 100644
--- a/packages/core/src/session/runner/llm.ts
+++ b/packages/core/src/session/runner/llm.ts
@@ -279,7 +279,7 @@ const layer = Layer.effect(
// event durably, fork one fiber per local tool call, and hold back a virgin
// context-overflow provider error so settlement may recover it via compaction.
let overflowFailure: ProviderErrorEvent | undefined
- const providerStream = llm.stream(prepared.request).pipe(
+ const providerStream = llm.stream(prepared.request, prepared.options).pipe(
Stream.runForEach((event) =>
Effect.gen(function* () {
if (overflowFailure || publisher.hasProviderError()) return
diff --git a/packages/plugin/src/effect/session.ts b/packages/plugin/src/effect/session.ts
index 73b87fb3d11c..886490060a3a 100644
--- a/packages/plugin/src/effect/session.ts
+++ b/packages/plugin/src/effect/session.ts
@@ -1,5 +1,6 @@
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"
@@ -15,8 +16,15 @@ export interface SessionContext {
tools: Record
}
+export interface SessionRequest extends HttpRequest {
+ readonly sessionID: Session.ID
+ readonly agent: Agent.ID
+ readonly model: Model.Ref
+}
+
export interface SessionHooks {
readonly context: SessionContext
+ readonly request: SessionRequest
}
export type SessionDomain = Pick<
diff --git a/packages/plugin/src/promise/session.ts b/packages/plugin/src/promise/session.ts
index 99346de3eab1..adc371b9510f 100644
--- a/packages/plugin/src/promise/session.ts
+++ b/packages/plugin/src/promise/session.ts
@@ -1,5 +1,6 @@
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"
@@ -15,8 +16,15 @@ export interface SessionContext {
tools: Record
}
+export interface SessionRequest extends HttpRequest {
+ readonly sessionID: Session.ID
+ readonly agent: Agent.ID
+ readonly model: Model.Ref
+}
+
export interface SessionHooks {
readonly context: SessionContext
+ readonly request: SessionRequest
}
export type SessionDomain = Pick<