diff --git a/packages/ai/src/route/client.ts b/packages/ai/src/route/client.ts
index 067292329b..22b46e0286 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 { RequestTransform, Transport, TransportRuntime } from "./transport"
import { WebSocketExecutor } from "./transport"
import type { Protocol } from "./protocol"
import { applyCachePolicy } from "../cache-policy"
@@ -155,6 +155,7 @@ export interface Interface {
readonly prepare:
(request: LLMRequest) => Effect.Effect, LLMError>
readonly stream: StreamMethod
readonly generate: GenerateMethod
+ readonly withRequestTransform: (transform: RequestTransform) => Interface
}
export interface StreamMethod {
@@ -411,6 +412,22 @@ const streamRequestWith = (runtime: TransportRuntime) => (request: LLMRequest) =
}),
)
+const makeClient = (runtime: TransportRuntime): Interface => {
+ const stream = streamRequestWith(runtime)
+ return {
+ prepare: prepareWith as Interface["prepare"],
+ stream,
+ generate: generateWith(stream),
+ withRequestTransform: (transform) =>
+ makeClient({
+ ...runtime,
+ transformRequest: runtime.transformRequest
+ ? (request) => runtime.transformRequest!(request).pipe(Effect.flatMap(transform))
+ : transform,
+ }),
+ }
+}
+
const generateWith = (stream: Interface["stream"]) =>
Effect.fn("LLM.generate")(function* (request: LLMRequest) {
const state = yield* stream(request).pipe(Stream.runFold(LLMResponse.empty, LLMResponse.reduce))
@@ -449,11 +466,12 @@ export const streamRequest = (request: LLMRequest) =>
export const layer: Layer.Layer = Layer.effect(
Service,
Effect.gen(function* () {
- const stream = streamRequestWith({
- http: yield* RequestExecutor.Service,
- webSocket: Option.getOrUndefined(yield* Effect.serviceOption(WebSocketExecutor.Service)),
- })
- return Service.of({ prepare: prepareWith as Interface["prepare"], stream, generate: generateWith(stream) })
+ return Service.of(
+ makeClient({
+ http: yield* RequestExecutor.Service,
+ webSocket: Option.getOrUndefined(yield* Effect.serviceOption(WebSocketExecutor.Service)),
+ }),
+ )
}),
)
diff --git a/packages/ai/src/route/index.ts b/packages/ai/src/route/index.ts
index 70db881ea4..80d6f6edac 100644
--- a/packages/ai/src/route/index.ts
+++ b/packages/ai/src/route/index.ts
@@ -22,4 +22,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 { RequestTransform, 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 785e450a5e..eead28635c 100644
--- a/packages/ai/src/route/transport/http.ts
+++ b/packages/ai/src/route/transport/http.ts
@@ -5,7 +5,7 @@ import { render as renderEndpoint } from "../endpoint"
import { Framing } from "../framing"
import type { Transport, TransportPrepareInput } from "./index"
import * as ProviderShared from "../../protocols/shared"
-import { mergeJsonRecords, type LLMRequest } from "../../schema"
+import { InvalidRequestReason, LLMError, mergeJsonRecords, type LLMRequest } from "../../schema"
export type JsonRequestInput = TransportPrepareInput
@@ -130,23 +130,36 @@ export const httpJson = (input: HttpJsonInput): HttpJs
),
frames: (prepared, request, runtime) =>
Stream.unwrap(
- runtime.http
- .execute(prepared.request)
- .pipe(
- Effect.map((response) =>
- prepared.framing.frame(
- response.stream.pipe(
- Stream.mapError((error) =>
- ProviderShared.eventError(
- `${request.model.provider}/${request.model.route.id}`,
- `Failed to read ${request.model.provider}/${request.model.route.id} stream`,
- ProviderShared.errorText(error),
- ),
+ (runtime.transformRequest
+ ? HttpClientRequest.toWeb(prepared.request).pipe(
+ Effect.mapError(
+ (error) =>
+ new LLMError({
+ module: "HttpTransport",
+ method: "frames",
+ reason: new InvalidRequestReason({ message: error.message }),
+ }),
+ ),
+ Effect.flatMap(runtime.transformRequest),
+ Effect.map((request) => HttpClientRequest.fromWeb(request.clone() as Request)),
+ Effect.flatMap(runtime.http.execute),
+ )
+ : runtime.http.execute(prepared.request)
+ ).pipe(
+ Effect.map((response) =>
+ prepared.framing.frame(
+ response.stream.pipe(
+ Stream.mapError((error) =>
+ ProviderShared.eventError(
+ `${request.model.provider}/${request.model.route.id}`,
+ `Failed to read ${request.model.provider}/${request.model.route.id} stream`,
+ ProviderShared.errorText(error),
),
),
),
),
),
+ ),
),
})
diff --git a/packages/ai/src/route/transport/index.ts b/packages/ai/src/route/transport/index.ts
index cf8fef1d08..febb3f1152 100644
--- a/packages/ai/src/route/transport/index.ts
+++ b/packages/ai/src/route/transport/index.ts
@@ -8,8 +8,11 @@ import type { LLMError, LLMRequest } from "../../schema"
export interface TransportRuntime {
readonly http: RequestExecutorInterface
readonly webSocket?: WebSocketExecutorInterface
+ readonly transformRequest?: RequestTransform
}
+export type RequestTransform = (request: Request) => Effect.Effect
+
export interface Transport {
readonly id: string
readonly prepare: (input: TransportPrepareInput) => Effect.Effect
diff --git a/packages/ai/test/provider/openai-chat.test.ts b/packages/ai/test/provider/openai-chat.test.ts
index a08a22b037..a332589214 100644
--- a/packages/ai/test/provider/openai-chat.test.ts
+++ b/packages/ai/test/provider/openai-chat.test.ts
@@ -690,4 +690,33 @@ describe("OpenAI Chat route", () => {
expect(events.map((event) => event.type)).toEqual(["step-start"])
}),
)
+
+ it.effect("transforms the serialized HTTP request before dispatch", () =>
+ Effect.gen(function* () {
+ const llm = yield* LLMClient.Service
+ const seen: string[] = []
+ yield* llm
+ .withRequestTransform((request) =>
+ Effect.sync(() => {
+ seen.push(request.url)
+ const headers = new Headers(request.headers)
+ headers.set("x-hook", "enabled")
+ return new Request(request, { headers })
+ }),
+ )
+ .stream(request)
+ .pipe(Stream.runDrain)
+
+ expect(seen).toEqual(["https://api.openai.test/v1/chat/completions"])
+ }).pipe(
+ Effect.provide(
+ dynamicResponse((input) => {
+ expect(input.request.headers["x-hook"]).toBe("enabled")
+ return Effect.succeed(
+ input.respond(sseEvents(deltaChunk({}, "stop")), { headers: { "content-type": "text/event-stream" } }),
+ )
+ }),
+ ),
+ ),
+ )
})
diff --git a/packages/core/src/aisdk.ts b/packages/core/src/aisdk.ts
index 4ecda81b9e..f9ca64d188 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 TransportRuntime } 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,7 @@ type SDK = any
type UserContent = Extract["content"]
type AssistantContent = Extract["content"]
type ToolResultContent = Extract
+const requestTransform = new AsyncLocalStorage()
export interface SDKEvent {
readonly model: ModelV2.Info
@@ -149,10 +151,20 @@ function prepareOptions(model: ModelV2.Info, pkg: string) {
}
}
- const res = await (typeof customFetch === "function" ? customFetch : fetch)(input, {
- ...opts,
- timeout: false,
- })
+ const request = new Request(input as Request, opts)
+ const transform = requestTransform.getStore()
+ const transformed = transform ? await Effect.runPromise(transform(request)) : request
+ const upstream = typeof customFetch === "function" ? customFetch : fetch
+ const res = transform
+ ? await upstream(transformed.url, {
+ ...opts,
+ method: transformed.method,
+ headers: transformed.headers,
+ body: transformed === request || transformed.body === null ? opts.body : await transformed.clone().text(),
+ signal: transformed.signal,
+ timeout: false,
+ })
+ : await upstream(input, { ...opts, timeout: false })
if (!chunkAbortCtl || typeof chunkTimeout !== "number") return res
return wrapSSE(res, chunkTimeout, chunkAbortCtl)
}
@@ -341,7 +353,8 @@ function modelFromLanguage(info: ModelV2.Info, language: LanguageModelV3) {
with: () => route,
model: (input) => Model.make({ ...input, provider: "provider" in input ? input.provider : info.providerID, route }),
prepareTransport: (body) => Effect.succeed(body),
- streamPrepared: (prepared) => streamLanguage(language, prepared as LanguageModelV3CallOptions),
+ streamPrepared: (prepared, _request, runtime) =>
+ streamLanguage(language, prepared as LanguageModelV3CallOptions, runtime.transformRequest),
}
return Model.make({ id: info.modelID ?? info.id, provider: info.providerID, route })
}
@@ -532,13 +545,17 @@ function providerOptions(input: LLMRequest["providerOptions"]): SharedV3Provider
return Object.fromEntries(Object.entries(input).map(([key, value]) => [key, jsonObject(value)]))
}
-function streamLanguage(language: LanguageModelV3, options: LanguageModelV3CallOptions) {
+function streamLanguage(
+ language: LanguageModelV3,
+ options: LanguageModelV3CallOptions,
+ transform: TransportRuntime["transformRequest"],
+) {
const state = { step: 0, toolNames: {} as Record }
return Stream.concat(
Stream.make(LLMEvent.stepStart({ index: state.step })),
Stream.unwrap(
Effect.tryPromise({
- try: () => language.doStream(options),
+ try: () => requestTransform.run(transform, () => language.doStream(options)),
catch: (error) => llmError("doStream", error),
}).pipe(
Effect.map((result) =>
diff --git a/packages/core/src/plugin/hooks.ts b/packages/core/src/plugin/hooks.ts
index 4ac437d392..8d3d73fbd9 100644
--- a/packages/core/src/plugin/hooks.ts
+++ b/packages/core/src/plugin/hooks.ts
@@ -16,6 +16,10 @@ export interface Domains {
type Callback = (event: Event) => Effect.Effect
export interface Interface {
+ readonly has: (
+ domain: Domain,
+ name: Name,
+ ) => boolean
readonly register: (
domain: Domain,
name: Name,
@@ -60,7 +64,7 @@ const layer = Layer.effect(
return event
})
- return Service.of({ register, trigger })
+ return Service.of({ has: (domain, name) => callbacks.has(key(domain, name)), register, trigger })
}),
)
diff --git a/packages/core/src/session/compaction.ts b/packages/core/src/session/compaction.ts
index 547d5797bd..540fb7ebff 100644
--- a/packages/core/src/session/compaction.ts
+++ b/packages/core/src/session/compaction.ts
@@ -1,15 +1,18 @@
export * as SessionCompaction from "./compaction"
-import { LLM, LLMClient, LLMError, LLMEvent, Message, type LLMRequest, type Model } from "@opencode-ai/ai"
+import { LLM, LLMClient, LLMEvent, Message, type Model } from "@opencode-ai/ai"
+import type { LLMClientShape } from "@opencode-ai/ai/route"
import { SessionError } from "@opencode-ai/schema/session-error"
import { Context, Effect, Layer, Stream } from "effect"
import { Config } from "../config"
import { EventV2 } from "../event"
+import { PluginHooks } from "../plugin/hooks"
import { makeLocationNode } from "../effect/app-node"
import { llmClient } from "../effect/app-node-platform"
import { SessionEvent } from "./event"
import type { SessionMessage } from "./message"
import { SessionModelHeaders } from "./model-headers"
+import { SessionRequestHook } from "./request-hook"
import { SessionRunnerModel } from "./runner/model"
import { SessionSchema } from "./schema"
import { toSessionError } from "./to-session-error"
@@ -59,11 +62,10 @@ type Settings = {
type Dependencies = {
readonly events: EventV2.Interface
- readonly llm: {
- readonly stream: (request: LLMRequest) => Stream.Stream
- }
+ readonly llm: LLMClientShape
readonly models: SessionRunnerModel.Interface
readonly config: Settings
+ readonly hooks: PluginHooks.Interface
}
export type AutoInput = {
@@ -240,7 +242,7 @@ const make = (dependencies: Dependencies) => {
const chunks: string[] = []
let failure: SessionError.Error | undefined
- yield* dependencies.llm
+ yield* SessionRequestHook.client(dependencies.llm, dependencies.hooks, plan.session.id)
.stream(
LLM.request({
model: plan.model,
@@ -372,12 +374,13 @@ export const layer = Layer.effect(
const llm = yield* LLMClient.Service
const config = yield* Config.Service
const models = yield* SessionRunnerModel.Service
- return make({ events, llm, models, config: settings(yield* config.entries()) })
+ const hooks = yield* PluginHooks.Service
+ return make({ events, llm, models, config: settings(yield* config.entries()), hooks })
}),
)
export const node = makeLocationNode({
service: Service,
layer,
- deps: [EventV2.node, llmClient, Config.node, SessionRunnerModel.node],
+ deps: [EventV2.node, llmClient, Config.node, SessionRunnerModel.node, PluginHooks.node],
})
diff --git a/packages/core/src/session/request-hook.ts b/packages/core/src/session/request-hook.ts
new file mode 100644
index 0000000000..762ceb1c1b
--- /dev/null
+++ b/packages/core/src/session/request-hook.ts
@@ -0,0 +1,13 @@
+export * as SessionRequestHook from "./request-hook"
+
+import type { LLMClientShape } from "@opencode-ai/ai/route"
+import { Effect } from "effect"
+import { PluginHooks } from "../plugin/hooks"
+import { SessionSchema } from "./schema"
+
+export const client = (llm: LLMClientShape, hooks: PluginHooks.Interface, sessionID: SessionSchema.ID) =>
+ hooks.has("session", "request")
+ ? llm.withRequestTransform((request) =>
+ hooks.trigger("session", "request", { sessionID, request }).pipe(Effect.map((event) => event.request)),
+ )
+ : llm
diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts
index 63dc59dbdd..92a4e90c40 100644
--- a/packages/core/src/session/runner/llm.ts
+++ b/packages/core/src/session/runner/llm.ts
@@ -8,6 +8,7 @@ import { Database } from "../../database/database"
import { EventV2 } from "../../event"
import { ModelV2 } from "../../model"
import { PermissionV2 } from "../../permission"
+import { PluginHooks } from "../../plugin/hooks"
import { QuestionTool } from "../../tool/question"
import { ToolOutputStore } from "../../tool-output-store"
import { InstructionState } from "../instruction-state"
@@ -15,6 +16,7 @@ import { SessionCompaction } from "../compaction"
import { SessionContext } from "../context"
import { SessionEvent } from "../event"
import { SessionPending } from "../pending"
+import { SessionRequestHook } from "../request-hook"
import { SessionModelRequest } from "../model-request"
import { SessionMessage } from "../message"
import { SessionSchema } from "../schema"
@@ -65,6 +67,7 @@ const layer = Layer.effect(
const db = (yield* Database.Service).db
const compaction = yield* SessionCompaction.Service
const title = yield* SessionTitle.Service
+ const hooks = yield* PluginHooks.Service
// Title generation is a side effect of the first step; it must not delay step continuation.
// Tracked per process so repeated wakes before the second user message arrives don't
// re-fire a redundant LLM call; `SessionTitle` itself is idempotent based on durable history.
@@ -157,7 +160,7 @@ const layer = Layer.effect(
const serialized = (effect: Effect.Effect) => publication.withPermit(effect)
const publish = (event: LLMEvent, error?: SessionError.Error) => serialized(publisher.publish(event, error))
let overflowFailure: ProviderErrorEvent | undefined
- const providerStream = llm.stream(prepared.request).pipe(
+ const providerStream = SessionRequestHook.client(llm, hooks, session.id).stream(prepared.request).pipe(
Stream.runForEach((event) =>
Effect.gen(function* () {
if (overflowFailure || publisher.hasProviderError()) return
@@ -506,5 +509,6 @@ export const node = makeLocationNode({
SessionTitle.node,
Snapshot.node,
Database.node,
+ PluginHooks.node,
],
})
diff --git a/packages/core/src/session/title.ts b/packages/core/src/session/title.ts
index ac7b7c27e1..a49017943d 100644
--- a/packages/core/src/session/title.ts
+++ b/packages/core/src/session/title.ts
@@ -1,15 +1,18 @@
export * as SessionTitle from "./title"
-import { LLM, LLMClient, LLMError, LLMEvent, Message, type LLMRequest } from "@opencode-ai/ai"
+import { LLM, LLMClient, LLMEvent, Message } from "@opencode-ai/ai"
+import type { LLMClientShape } from "@opencode-ai/ai/route"
import { Context, DateTime, Effect, Layer, Stream } from "effect"
import { AgentV2 } from "../agent"
import { Database } from "../database/database"
import { EventV2 } from "../event"
+import { PluginHooks } from "../plugin/hooks"
import { makeLocationNode } from "../effect/app-node"
import { llmClient } from "../effect/app-node-platform"
import { SessionEvent } from "./event"
import { SessionHistory } from "./history"
import { SessionModelHeaders } from "./model-headers"
+import { SessionRequestHook } from "./request-hook"
import { SessionRunnerModel } from "./runner/model"
import { SessionSchema } from "./schema"
@@ -17,11 +20,10 @@ const MAX_LENGTH = 100
type Dependencies = {
readonly events: EventV2.Interface
- readonly llm: {
- readonly stream: (request: LLMRequest) => Stream.Stream
- }
+ readonly llm: LLMClientShape
readonly agents: AgentV2.Interface
readonly models: SessionRunnerModel.Interface
+ readonly hooks: PluginHooks.Interface
}
export interface Interface {
@@ -51,7 +53,7 @@ const make = (dependencies: Dependencies) => {
if (!resolved) return
const chunks: string[] = []
let failed = false
- const streamed = yield* dependencies.llm
+ const streamed = yield* SessionRequestHook.client(dependencies.llm, dependencies.hooks, session.id)
.stream(
LLM.request({
model: resolved.model,
@@ -93,7 +95,8 @@ export const layer = Layer.effect(
const agents = yield* AgentV2.Service
const models = yield* SessionRunnerModel.Service
const database = yield* Database.Service
- const title = make({ events, llm, agents, models })
+ const hooks = yield* PluginHooks.Service
+ const title = make({ events, llm, agents, models, hooks })
return Service.of({
generateForFirstPrompt: (session) => title.generateForFirstPrompt(database.db, session),
})
@@ -103,5 +106,5 @@ export const layer = Layer.effect(
export const node = makeLocationNode({
service: Service,
layer,
- deps: [EventV2.node, llmClient, AgentV2.node, SessionRunnerModel.node, Database.node],
+ deps: [EventV2.node, llmClient, AgentV2.node, SessionRunnerModel.node, Database.node, PluginHooks.node],
})
diff --git a/packages/core/test/aisdk.test.ts b/packages/core/test/aisdk.test.ts
index 59c8f8e8d3..dba183f999 100644
--- a/packages/core/test/aisdk.test.ts
+++ b/packages/core/test/aisdk.test.ts
@@ -5,7 +5,7 @@ import { ProviderV2 } from "@opencode-ai/core/provider"
import { LLM, Message } from "@opencode-ai/ai"
import { LLMClient } from "@opencode-ai/ai/route"
import { expect } from "bun:test"
-import { Effect } from "effect"
+import { Effect, Stream } from "effect"
import { testEffect } from "./lib/effect"
const it = testEffect(AISDK.locationLayer)
@@ -19,6 +19,64 @@ const model = (packageName: string, settings: Record = {}) =>
limit: { context: 100, output: 20 },
})
+it.effect("applies request transforms to AI SDK fetch calls", () =>
+ Effect.gen(function* () {
+ const aisdk = yield* AISDK.Service
+ let received: Request | undefined
+ const input = model("test-sdk")
+ Object.defineProperty(input, "settings", {
+ value: {
+ fetch: async (input: Parameters[0], init?: RequestInit) => {
+ received = new Request(input as Request, init)
+ return new Response()
+ },
+ },
+ })
+ yield* aisdk.hook.sdk((event) => {
+ event.sdk = {
+ languageModel: () => ({
+ doStream: async () => {
+ await event.options.fetch("https://provider.test/generate", { method: "POST", body: "{}" })
+ return {
+ stream: new ReadableStream({
+ start(controller) {
+ controller.enqueue({
+ type: "finish",
+ finishReason: { unified: "stop" },
+ usage: {
+ inputTokens: { total: 0, noCache: 0, cacheRead: 0, cacheWrite: 0 },
+ outputTokens: { total: 0, text: 0, reasoning: 0 },
+ },
+ })
+ controller.close()
+ },
+ }),
+ }
+ },
+ }),
+ }
+ })
+ const resolved = yield* aisdk.model(input)
+ const request = LLM.request({ model: resolved, prompt: "Hello" })
+ const body = yield* resolved.route.body.from(request)
+ const prepared = yield* resolved.route.prepareTransport(body, request)
+ yield* resolved.route
+ .streamPrepared(prepared, request, {
+ http: { execute: () => Effect.die("unused") },
+ transformRequest: (request) =>
+ Effect.sync(() => {
+ const headers = new Headers(request.headers)
+ headers.set("x-hook", "enabled")
+ return new Request(request, { headers })
+ }),
+ })
+ .pipe(Stream.runDrain)
+
+ expect(received?.url).toBe("https://provider.test/generate")
+ expect(received?.headers.get("x-hook")).toBe("enabled")
+ }),
+)
+
it.effect("keys language models by package and flattened overlays", () =>
Effect.gen(function* () {
const aisdk = yield* AISDK.Service
diff --git a/packages/core/test/plugin-hooks.test.ts b/packages/core/test/plugin-hooks.test.ts
index 4fdf9a9c54..866b8a724d 100644
--- a/packages/core/test/plugin-hooks.test.ts
+++ b/packages/core/test/plugin-hooks.test.ts
@@ -42,4 +42,24 @@ describe("PluginHooks", () => {
expect(event.messages).toEqual([Message.user("changed")])
}),
)
+
+ it.effect("allows session request hooks to replace the raw request", () =>
+ Effect.gen(function* () {
+ const hooks = yield* PluginHooks.Service
+ expect(hooks.has("session", "request")).toBe(false)
+ yield* hooks.register("session", "request", (event) =>
+ Effect.sync(() => {
+ event.request = new Request(event.request, { headers: { "x-hook": "enabled" } })
+ }),
+ )
+ expect(hooks.has("session", "request")).toBe(true)
+ const event = {
+ sessionID: Session.ID.make("ses_request_hook"),
+ request: new Request("https://example.com"),
+ }
+
+ expect(yield* hooks.trigger("session", "request", event)).toBe(event)
+ expect(event.request.headers.get("x-hook")).toBe("enabled")
+ }),
+ )
})
diff --git a/packages/core/test/session-compact.test.ts b/packages/core/test/session-compact.test.ts
index da90068ea4..5c8c44a318 100644
--- a/packages/core/test/session-compact.test.ts
+++ b/packages/core/test/session-compact.test.ts
@@ -1,5 +1,6 @@
import { describe, expect } from "bun:test"
import { LLMClient, LLMEvent, Model, type LLMRequest } from "@opencode-ai/ai"
+import type { LLMClientShape } from "@opencode-ai/ai/route"
import { OpenAIChat } from "@opencode-ai/ai/protocols"
import { Config } from "@opencode-ai/core/config"
import { Database } from "@opencode-ai/core/database/database"
@@ -40,14 +41,16 @@ const projects = Layer.succeed(
}),
)
let requests: LLMRequest[] = []
-const client = Layer.mock(LLMClient.Service)({
+const clientShape: LLMClientShape = {
prepare: () => Effect.die("unused"),
stream: (request: LLMRequest) => {
requests.push(request)
return Stream.make(LLMEvent.textDelta({ id: "summary", text: "manual session summary" }))
},
generate: () => Effect.die("unused"),
-})
+ withRequestTransform: () => clientShape,
+}
+const client = Layer.mock(LLMClient.Service)(clientShape)
const config = Layer.mock(Config.Service)({ entries: () => Effect.succeed([]) })
const models = SessionRunnerModel.layerWith(() => Effect.succeed(SessionRunnerModel.resolved(model)))
const locations = Layer.effect(
diff --git a/packages/core/test/session-compaction.test.ts b/packages/core/test/session-compaction.test.ts
index f9cf2e4c69..a5646f08c2 100644
--- a/packages/core/test/session-compaction.test.ts
+++ b/packages/core/test/session-compaction.test.ts
@@ -1,5 +1,6 @@
import { expect, test } from "bun:test"
import { LLMClient, LLMEvent, Model, type LLMRequest } from "@opencode-ai/ai"
+import type { LLMClientShape } from "@opencode-ai/ai/route"
import { OpenAIChat } from "@opencode-ai/ai/protocols"
import { Config } from "@opencode-ai/core/config"
import { Database } from "@opencode-ai/core/database/database"
@@ -31,14 +32,16 @@ const model = Model.make({
provider: "test",
route: OpenAIChat.route.with({ limits: { context: 10_000, output: 1_000 } }),
})
-const client = Layer.mock(LLMClient.Service)({
+const clientShape: LLMClientShape = {
prepare: () => Effect.die("unused"),
stream: (request: LLMRequest) => {
requests.push(request)
return Stream.make(LLMEvent.textDelta({ id: "summary", text: "manual summary" }))
},
generate: () => Effect.die("unused"),
-})
+ withRequestTransform: () => clientShape,
+}
+const client = Layer.mock(LLMClient.Service)(clientShape)
const config = Layer.mock(Config.Service)({ entries: () => Effect.succeed([]) })
const models = Layer.mock(SessionRunnerModel.Service)({
resolve: () => Effect.succeed(SessionRunnerModel.resolved(model)),
diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts
index 0bb14b020f..e1c9733c32 100644
--- a/packages/core/test/session-runner.test.ts
+++ b/packages/core/test/session-runner.test.ts
@@ -89,9 +89,7 @@ let toolExecutionsStarted: Deferred.Deferred | undefined
let toolExecutionsReady = 5
let activeToolExecutions = 0
let maxActiveToolExecutions = 0
-const client = Layer.succeed(
- LLMClient.Service,
- LLMClient.Service.of({
+const clientValue: LLMClientShape = LLMClient.Service.of({
prepare: () => Effect.die("unused"),
stream: ((request: LLMRequest) => {
requests.push(request)
@@ -113,8 +111,9 @@ const client = Layer.succeed(
)
}) as unknown as LLMClientShape["stream"],
generate: () => Effect.die("unused"),
- }),
-)
+ withRequestTransform: () => clientValue,
+ })
+const client = Layer.succeed(LLMClient.Service, clientValue)
const reply = {
stop: () => [
LLMEvent.stepStart({ index: 0 }),
diff --git a/packages/core/test/session-title.test.ts b/packages/core/test/session-title.test.ts
index dccfaf63df..acc316e821 100644
--- a/packages/core/test/session-title.test.ts
+++ b/packages/core/test/session-title.test.ts
@@ -1,5 +1,6 @@
import { expect } from "bun:test"
import { LLMClient, LLMEvent, Model, type LLMRequest } from "@opencode-ai/ai"
+import type { LLMClientShape } from "@opencode-ai/ai/route"
import { OpenAIChat } from "@opencode-ai/ai/protocols"
import { AgentV2 } from "@opencode-ai/core/agent"
import { Database } from "@opencode-ai/core/database/database"
@@ -29,14 +30,16 @@ const model = Model.make({
provider: "test",
route: OpenAIChat.route.with({ limits: { context: 10_000, output: 1_000 } }),
})
-const client = Layer.mock(LLMClient.Service)({
+const clientShape: LLMClientShape = {
prepare: () => Effect.die("unused"),
stream: (request: LLMRequest) => {
requests.push(request)
return Stream.make(LLMEvent.textDelta({ id: "title", text: "Generated Title\n" }))
},
generate: () => Effect.die("unused"),
-})
+ withRequestTransform: () => clientShape,
+}
+const client = Layer.mock(LLMClient.Service)(clientShape)
const models = Layer.mock(SessionRunnerModel.Service)({
resolve: () => Effect.succeed(SessionRunnerModel.resolved(model)),
})
diff --git a/packages/plugin/src/v2/effect/README.md b/packages/plugin/src/v2/effect/README.md
index 8ce7ddb414..2ca55f0eab 100644
--- a/packages/plugin/src/v2/effect/README.md
+++ b/packages/plugin/src/v2/effect/README.md
@@ -92,6 +92,19 @@ yield *
)
```
+The serialized provider request is also mutable before it is sent:
+
+```ts
+yield *
+ ctx.session.hook("request", (event) =>
+ Effect.sync(() => {
+ event.request = new Request(event.request, {
+ headers: new Headers([...event.request.headers, ["x-plugin", "enabled"]]),
+ })
+ }),
+ )
+```
+
## Reloading A Domain
When data captured by a transform changes, reload the affected domain:
diff --git a/packages/plugin/src/v2/effect/session.ts b/packages/plugin/src/v2/effect/session.ts
index 836f4431c6..309af23c86 100644
--- a/packages/plugin/src/v2/effect/session.ts
+++ b/packages/plugin/src/v2/effect/session.ts
@@ -15,8 +15,14 @@ export interface SessionContext {
tools: Record
}
+export interface SessionRequest {
+ readonly sessionID: Session.ID
+ request: Request
+}
+
export interface SessionHooks {
readonly context: SessionContext
+ readonly request: SessionRequest
}
export type SessionDomain = Pick<
diff --git a/packages/plugin/src/v2/promise/README.md b/packages/plugin/src/v2/promise/README.md
index e23a231e4f..bbdc0a20a1 100644
--- a/packages/plugin/src/v2/promise/README.md
+++ b/packages/plugin/src/v2/promise/README.md
@@ -94,6 +94,16 @@ await ctx.session.hook("context", (event) => {
})
```
+The serialized provider request is also mutable before it is sent:
+
+```ts
+await ctx.session.hook("request", (event) => {
+ event.request = new Request(event.request, {
+ headers: new Headers([...event.request.headers, ["x-plugin", "enabled"]]),
+ })
+})
+```
+
Promise tools use plain object declarations with async executors:
```ts
diff --git a/packages/plugin/src/v2/promise/session.ts b/packages/plugin/src/v2/promise/session.ts
index 387d6fe007..fdc165bf8d 100644
--- a/packages/plugin/src/v2/promise/session.ts
+++ b/packages/plugin/src/v2/promise/session.ts
@@ -15,8 +15,14 @@ export interface SessionContext {
tools: Record
}
+export interface SessionRequest {
+ readonly sessionID: Session.ID
+ request: Request
+}
+
export interface SessionHooks {
readonly context: SessionContext
+ readonly request: SessionRequest
}
export type SessionDomain = Pick & {