From 98229d466d14157725946b27a4032d7a3d599bc8 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Thu, 30 Jul 2026 21:11:22 -0500 Subject: [PATCH] feat(plugin): add session request hook (#39764) --- packages/ai/src/route/client.ts | 43 +++++++++++++--------- packages/ai/src/route/index.ts | 3 +- packages/ai/src/route/transport/http.ts | 19 ++++++---- packages/ai/src/route/transport/index.ts | 10 +++++ packages/ai/test/compile.test.ts | 34 +++++++++++++++++ packages/core/src/session/model-request.ts | 23 ++++++++++++ packages/core/src/session/runner/llm.ts | 2 +- packages/plugin/src/effect/session.ts | 8 ++++ packages/plugin/src/promise/session.ts | 8 ++++ 9 files changed, 124 insertions(+), 26 deletions(-) diff --git a/packages/ai/src/route/client.ts b/packages/ai/src/route/client.ts index b935699f90..5021ae958f 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 70db881ea4..0f38232618 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 785e450a5e..ae8b5e088d 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 cf8fef1d08..588ea9c896 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 b64b173b0e..9c8dbbdfac 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 3cd0a26420..18ed3a1933 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 5fb61e2635..2879bab487 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -278,7 +278,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 73b87fb3d1..886490060a 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 99346de3ea..adc371b951 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<