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 & {