From d22e6ca08872c606fe2d747351cfcd43b971107e Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Thu, 30 Jul 2026 18:05:53 -0500 Subject: [PATCH 1/5] feat(plugin): add session request hook --- packages/ai/src/route/transport/http.ts | 19 +++++--- packages/ai/src/schema/options.ts | 22 +++++++-- packages/ai/test/compile.test.ts | 32 +++++++++++++ packages/core/src/aisdk.ts | 29 ++++++++++-- packages/core/src/session/model-request.ts | 17 +++++++ packages/core/test/aisdk.test.ts | 54 ++++++++++++++++++++++ packages/plugin/src/effect/session.ts | 11 +++++ packages/plugin/src/promise/session.ts | 11 +++++ 8 files changed, 182 insertions(+), 13 deletions(-) diff --git a/packages/ai/src/route/transport/http.ts b/packages/ai/src/route/transport/http.ts index 785e450a5e20..783a255d8e5f 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.request.http?.transform?.(request) ?? Effect.void) + return { + request: ProviderShared.jsonPost({ + url: parts.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/schema/options.ts b/packages/ai/src/schema/options.ts index df7c6544aa4f..0f2c0281cff5 100644 --- a/packages/ai/src/schema/options.ts +++ b/packages/ai/src/schema/options.ts @@ -1,4 +1,4 @@ -import { Schema } from "effect" +import { Effect, Schema } from "effect" import { JsonSchema, ModelID, ProviderID } from "./ids" import type { AnyRoute } from "../route/client" import { isRecord } from "../utils/record" @@ -50,10 +50,25 @@ export const mergeProviderOptions = ( return Object.keys(result).length === 0 ? undefined : result } +export interface HttpRequest { + readonly url: string + readonly method: string + headers: Record + body: string | undefined +} + +export type HttpRequestTransform = (request: HttpRequest) => Effect.Effect + +const HttpRequestTransform = Schema.declare( + (value): value is HttpRequestTransform => typeof value === "function", + { expected: "LLM.HttpRequestTransform" }, +) + export class HttpOptions extends Schema.Class("LLM.HttpOptions")({ body: Schema.optional(JsonSchema), headers: Schema.optional(Schema.Record(Schema.String, Schema.String)), query: Schema.optional(Schema.Record(Schema.String, Schema.String)), + transform: Schema.optional(HttpRequestTransform), }) {} export namespace HttpOptions { @@ -67,8 +82,9 @@ export const mergeHttpOptions = (...items: ReadonlyArray item?.body)) const headers = mergeStringRecords(...items.map((item) => item?.headers)) const query = mergeStringRecords(...items.map((item) => item?.query)) - if (!body && !headers && !query) return undefined - return new HttpOptions({ body, headers, query }) + const transform = items.findLast((item) => item?.transform !== undefined)?.transform + if (!body && !headers && !query && !transform) return undefined + return new HttpOptions({ body, headers, query, transform }) } export class GenerationOptions extends Schema.Class("LLM.GenerationOptions")({ diff --git a/packages/ai/test/compile.test.ts b/packages/ai/test/compile.test.ts index b64b173b0ea5..005554b38f9b 100644 --- a/packages/ai/test/compile.test.ts +++ b/packages/ai/test/compile.test.ts @@ -137,6 +137,38 @@ 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.", + http: { + transform: (request) => + Effect.sync(() => { + expect(request.headers.authorization).toBe("Bearer fresh-key") + 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.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/aisdk.ts b/packages/core/src/aisdk.ts index 7aaa5ec6c951..daef8a855e99 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 "@opencode-ai/util/effect/app-node" import type { JSONSchema7, @@ -25,6 +26,7 @@ import { ToolResultValue, UnknownProviderReason, type ContentPart, + type HttpRequestTransform, type LLMRequest, type ToolDefinition, type UsageInput, @@ -40,6 +42,7 @@ type SDK = any type UserContent = Extract["content"] type AssistantContent = Extract["content"] type ToolResultContent = Extract +const requestTransform = new AsyncLocalStorage() export interface SDKEvent { readonly model: Info @@ -150,6 +153,20 @@ function prepareOptions(model: Info, pkg: string) { } } + const transform = requestTransform.getStore() + if (transform) { + const source = input instanceof Request ? new Request(input, opts) : new Request(input.toString(), opts) + const request = { + url: source.url, + method: source.method, + headers: Object.fromEntries(source.headers.entries()), + body: source.body ? await source.clone().text() : undefined, + } + await Effect.runPromise(transform(request)) + opts.headers = request.headers + opts.body = request.body + } + const res = await (typeof customFetch === "function" ? customFetch : fetch)(input, { ...opts, timeout: false, @@ -342,7 +359,8 @@ function modelFromLanguage(info: 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) => + streamLanguage(language, prepared as LanguageModelV3CallOptions, request.http?.transform), } return Model.make({ id: info.modelID ?? info.id, @@ -531,13 +549,18 @@ 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: HttpRequestTransform | undefined, +) { 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: () => + transform ? requestTransform.run(transform, () => language.doStream(options)) : language.doStream(options), catch: (error) => llmError("doStream", error), }).pipe( Effect.map((result) => diff --git a/packages/core/src/session/model-request.ts b/packages/core/src/session/model-request.ts index 3cd0a2642033..3c77723052c4 100644 --- a/packages/core/src/session/model-request.ts +++ b/packages/core/src/session/model-request.ts @@ -155,6 +155,23 @@ export const layer = Layer.effect( model, http: { headers: SessionModelHeaders.make(session, app), + transform: (request) => + hooks + .trigger("session", "request", { + sessionID: session.id, + agent: agent.id, + model: resolved.ref, + ...request, + }) + .pipe( + Effect.tap((event) => + Effect.sync(() => { + request.headers = event.headers + request.body = event.body + }), + ), + Effect.asVoid, + ), }, providerOptions: { openai: { promptCacheKey } }, system: contextEvent.system, diff --git a/packages/core/test/aisdk.test.ts b/packages/core/test/aisdk.test.ts index e3dfbe8d8035..322b89cf1a17 100644 --- a/packages/core/test/aisdk.test.ts +++ b/packages/core/test/aisdk.test.ts @@ -98,6 +98,60 @@ it.effect("projects request settings, headers, and body overlays", () => }), ) +it.effect("transforms the final AI SDK fetch request", () => + Effect.gen(function* () { + const aisdk = yield* AISDK.Service + const outgoing: Array<{ headers: Headers; body: string | undefined }> = [] + yield* aisdk.hook.sdk((event) => { + const result = streamModel([{ type: "finish", finishReason: { unified: "stop", raw: "stop" }, usage }]) + event.sdk = { + languageModel: () => ({ + ...result, + doStream: async () => { + await event.options.fetch("https://provider.test/v1/messages", { + method: "POST", + headers: { authorization: "Bearer secret" }, + body: JSON.stringify({ original: true }), + }) + return result.doStream({} as never) + }, + }), + } + }) + + const resolved = yield* aisdk.model( + model("test-request-hook", { + fetch: async (_input: Parameters[0], init?: RequestInit) => { + outgoing.push({ + headers: new Headers(init?.headers), + body: typeof init?.body === "string" ? init.body : undefined, + }) + return new Response() + }, + }), + ) + yield* LLMClient.generate( + LLM.request({ + model: resolved, + prompt: "Hello", + http: { + transform: (request) => + Effect.sync(() => { + expect(request.url).toBe("https://provider.test/v1/messages") + expect(request.headers.authorization).toBe("Bearer secret") + request.headers["x-plugin"] = "transformed" + request.body = JSON.stringify({ transformed: true }) + }), + }, + }), + ).pipe(Effect.provide(client)) + + expect(outgoing).toHaveLength(1) + expect(outgoing[0].headers.get("x-plugin")).toBe("transformed") + expect(outgoing[0].body).toBe(JSON.stringify({ transformed: true })) + }), +) + it.effect("maps pro reasoning bodies to AI SDK provider options", () => Effect.gen(function* () { const aisdk = yield* AISDK.Service diff --git a/packages/plugin/src/effect/session.ts b/packages/plugin/src/effect/session.ts index 73b87fb3d11c..00037231c23f 100644 --- a/packages/plugin/src/effect/session.ts +++ b/packages/plugin/src/effect/session.ts @@ -15,8 +15,19 @@ export interface SessionContext { tools: Record } +export interface SessionRequest { + readonly sessionID: Session.ID + readonly agent: Agent.ID + readonly model: Model.Ref + readonly url: string + readonly method: string + headers: Record + body: string | undefined +} + 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..761adc692954 100644 --- a/packages/plugin/src/promise/session.ts +++ b/packages/plugin/src/promise/session.ts @@ -15,8 +15,19 @@ export interface SessionContext { tools: Record } +export interface SessionRequest { + readonly sessionID: Session.ID + readonly agent: Agent.ID + readonly model: Model.Ref + readonly url: string + readonly method: string + headers: Record + body: string | undefined +} + export interface SessionHooks { readonly context: SessionContext + readonly request: SessionRequest } export type SessionDomain = Pick< From 23ef1fa04cabdc608744b0fca6cabfdef2812c84 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Thu, 30 Jul 2026 20:36:55 -0500 Subject: [PATCH 2/5] refactor(plugin): scope request hook to native --- packages/core/src/aisdk.ts | 29 ++--------------- packages/core/test/aisdk.test.ts | 54 -------------------------------- 2 files changed, 3 insertions(+), 80 deletions(-) diff --git a/packages/core/src/aisdk.ts b/packages/core/src/aisdk.ts index daef8a855e99..7aaa5ec6c951 100644 --- a/packages/core/src/aisdk.ts +++ b/packages/core/src/aisdk.ts @@ -1,6 +1,5 @@ export * as AISDK from "./aisdk" -import { AsyncLocalStorage } from "node:async_hooks" import { makeLocationNode } from "@opencode-ai/util/effect/app-node" import type { JSONSchema7, @@ -26,7 +25,6 @@ import { ToolResultValue, UnknownProviderReason, type ContentPart, - type HttpRequestTransform, type LLMRequest, type ToolDefinition, type UsageInput, @@ -42,7 +40,6 @@ type SDK = any type UserContent = Extract["content"] type AssistantContent = Extract["content"] type ToolResultContent = Extract -const requestTransform = new AsyncLocalStorage() export interface SDKEvent { readonly model: Info @@ -153,20 +150,6 @@ function prepareOptions(model: Info, pkg: string) { } } - const transform = requestTransform.getStore() - if (transform) { - const source = input instanceof Request ? new Request(input, opts) : new Request(input.toString(), opts) - const request = { - url: source.url, - method: source.method, - headers: Object.fromEntries(source.headers.entries()), - body: source.body ? await source.clone().text() : undefined, - } - await Effect.runPromise(transform(request)) - opts.headers = request.headers - opts.body = request.body - } - const res = await (typeof customFetch === "function" ? customFetch : fetch)(input, { ...opts, timeout: false, @@ -359,8 +342,7 @@ function modelFromLanguage(info: 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, request) => - streamLanguage(language, prepared as LanguageModelV3CallOptions, request.http?.transform), + streamPrepared: (prepared) => streamLanguage(language, prepared as LanguageModelV3CallOptions), } return Model.make({ id: info.modelID ?? info.id, @@ -549,18 +531,13 @@ function providerOptions(input: LLMRequest["providerOptions"]): SharedV3Provider return Object.fromEntries(Object.entries(input).map(([key, value]) => [key, jsonObject(value)])) } -function streamLanguage( - language: LanguageModelV3, - options: LanguageModelV3CallOptions, - transform: HttpRequestTransform | undefined, -) { +function streamLanguage(language: LanguageModelV3, options: LanguageModelV3CallOptions) { const state = { step: 0, toolNames: {} as Record } return Stream.concat( Stream.make(LLMEvent.stepStart({ index: state.step })), Stream.unwrap( Effect.tryPromise({ - try: () => - transform ? requestTransform.run(transform, () => language.doStream(options)) : language.doStream(options), + try: () => language.doStream(options), catch: (error) => llmError("doStream", error), }).pipe( Effect.map((result) => diff --git a/packages/core/test/aisdk.test.ts b/packages/core/test/aisdk.test.ts index 322b89cf1a17..e3dfbe8d8035 100644 --- a/packages/core/test/aisdk.test.ts +++ b/packages/core/test/aisdk.test.ts @@ -98,60 +98,6 @@ it.effect("projects request settings, headers, and body overlays", () => }), ) -it.effect("transforms the final AI SDK fetch request", () => - Effect.gen(function* () { - const aisdk = yield* AISDK.Service - const outgoing: Array<{ headers: Headers; body: string | undefined }> = [] - yield* aisdk.hook.sdk((event) => { - const result = streamModel([{ type: "finish", finishReason: { unified: "stop", raw: "stop" }, usage }]) - event.sdk = { - languageModel: () => ({ - ...result, - doStream: async () => { - await event.options.fetch("https://provider.test/v1/messages", { - method: "POST", - headers: { authorization: "Bearer secret" }, - body: JSON.stringify({ original: true }), - }) - return result.doStream({} as never) - }, - }), - } - }) - - const resolved = yield* aisdk.model( - model("test-request-hook", { - fetch: async (_input: Parameters[0], init?: RequestInit) => { - outgoing.push({ - headers: new Headers(init?.headers), - body: typeof init?.body === "string" ? init.body : undefined, - }) - return new Response() - }, - }), - ) - yield* LLMClient.generate( - LLM.request({ - model: resolved, - prompt: "Hello", - http: { - transform: (request) => - Effect.sync(() => { - expect(request.url).toBe("https://provider.test/v1/messages") - expect(request.headers.authorization).toBe("Bearer secret") - request.headers["x-plugin"] = "transformed" - request.body = JSON.stringify({ transformed: true }) - }), - }, - }), - ).pipe(Effect.provide(client)) - - expect(outgoing).toHaveLength(1) - expect(outgoing[0].headers.get("x-plugin")).toBe("transformed") - expect(outgoing[0].body).toBe(JSON.stringify({ transformed: true })) - }), -) - it.effect("maps pro reasoning bodies to AI SDK provider options", () => Effect.gen(function* () { const aisdk = yield* AISDK.Service From 6c1b3238bcb6c84fd6c87ae912b0154a678e4d36 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Thu, 30 Jul 2026 20:48:14 -0500 Subject: [PATCH 3/5] feat(plugin): allow request URL transforms --- packages/ai/src/route/transport/http.ts | 2 +- packages/ai/src/schema/options.ts | 2 +- packages/ai/test/compile.test.ts | 2 ++ packages/core/src/session/model-request.ts | 1 + packages/plugin/src/effect/session.ts | 2 +- packages/plugin/src/promise/session.ts | 2 +- 6 files changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/ai/src/route/transport/http.ts b/packages/ai/src/route/transport/http.ts index 783a255d8e5f..d95a22c8aa1a 100644 --- a/packages/ai/src/route/transport/http.ts +++ b/packages/ai/src/route/transport/http.ts @@ -126,7 +126,7 @@ export const httpJson = (input: HttpJsonInput): HttpJs yield* (prepareInput.request.http?.transform?.(request) ?? Effect.void) return { request: ProviderShared.jsonPost({ - url: parts.url, + url: request.url, body: request.body ?? "", headers: Headers.fromInput(request.headers), }), diff --git a/packages/ai/src/schema/options.ts b/packages/ai/src/schema/options.ts index 0f2c0281cff5..63f01c856195 100644 --- a/packages/ai/src/schema/options.ts +++ b/packages/ai/src/schema/options.ts @@ -51,7 +51,7 @@ export const mergeProviderOptions = ( } export interface HttpRequest { - readonly url: string + url: string readonly method: string headers: Record body: string | undefined diff --git a/packages/ai/test/compile.test.ts b/packages/ai/test/compile.test.ts index 005554b38f9b..8734f8234cd8 100644 --- a/packages/ai/test/compile.test.ts +++ b/packages/ai/test/compile.test.ts @@ -148,6 +148,7 @@ describe("request option precedence", () => { 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 }) }), @@ -158,6 +159,7 @@ describe("request option precedence", () => { 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")), { diff --git a/packages/core/src/session/model-request.ts b/packages/core/src/session/model-request.ts index 3c77723052c4..b748b771a300 100644 --- a/packages/core/src/session/model-request.ts +++ b/packages/core/src/session/model-request.ts @@ -166,6 +166,7 @@ export const layer = Layer.effect( .pipe( Effect.tap((event) => Effect.sync(() => { + request.url = event.url request.headers = event.headers request.body = event.body }), diff --git a/packages/plugin/src/effect/session.ts b/packages/plugin/src/effect/session.ts index 00037231c23f..5b6298e92d59 100644 --- a/packages/plugin/src/effect/session.ts +++ b/packages/plugin/src/effect/session.ts @@ -19,7 +19,7 @@ export interface SessionRequest { readonly sessionID: Session.ID readonly agent: Agent.ID readonly model: Model.Ref - readonly url: string + url: string readonly method: string headers: Record body: string | undefined diff --git a/packages/plugin/src/promise/session.ts b/packages/plugin/src/promise/session.ts index 761adc692954..64de8451afeb 100644 --- a/packages/plugin/src/promise/session.ts +++ b/packages/plugin/src/promise/session.ts @@ -19,7 +19,7 @@ export interface SessionRequest { readonly sessionID: Session.ID readonly agent: Agent.ID readonly model: Model.Ref - readonly url: string + url: string readonly method: string headers: Record body: string | undefined From f3971ebf74f7ea95aaac348279d3ffae31bf5e54 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Thu, 30 Jul 2026 20:56:41 -0500 Subject: [PATCH 4/5] refactor(ai): separate request execution options --- packages/ai/src/route/client.ts | 43 +++++++++++++--------- packages/ai/src/route/index.ts | 3 +- packages/ai/src/route/transport/http.ts | 2 +- packages/ai/src/route/transport/index.ts | 10 +++++ packages/ai/src/schema/options.ts | 22 ++--------- packages/ai/test/compile.test.ts | 18 ++++----- packages/core/src/session/model-request.ts | 41 ++++++++++++--------- packages/core/src/session/runner/llm.ts | 2 +- packages/plugin/src/effect/session.ts | 7 +--- packages/plugin/src/promise/session.ts | 7 +--- 10 files changed, 79 insertions(+), 76 deletions(-) diff --git a/packages/ai/src/route/client.ts b/packages/ai/src/route/client.ts index b935699f90a4..1eb56763117a 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 transformRequest?: 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, + transformRequest: options?.transformRequest, }), 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 d95a22c8aa1a..7998e0c76572 100644 --- a/packages/ai/src/route/transport/http.ts +++ b/packages/ai/src/route/transport/http.ts @@ -123,7 +123,7 @@ export const httpJson = (input: HttpJsonInput): HttpJs Effect.gen(function* () { const parts = yield* jsonRequestParts({ ...prepareInput }) const request = { url: parts.url, method: "POST", headers: { ...parts.headers }, body: parts.bodyText } - yield* (prepareInput.request.http?.transform?.(request) ?? Effect.void) + yield* (prepareInput.transformRequest?.(request) ?? Effect.void) return { request: ProviderShared.jsonPost({ url: request.url, diff --git a/packages/ai/src/route/transport/index.ts b/packages/ai/src/route/transport/index.ts index cf8fef1d08c6..da884bb8ac08 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 transformRequest?: HttpRequestTransform } export * as HttpTransport from "./http" diff --git a/packages/ai/src/schema/options.ts b/packages/ai/src/schema/options.ts index 63f01c856195..df7c6544aa4f 100644 --- a/packages/ai/src/schema/options.ts +++ b/packages/ai/src/schema/options.ts @@ -1,4 +1,4 @@ -import { Effect, Schema } from "effect" +import { Schema } from "effect" import { JsonSchema, ModelID, ProviderID } from "./ids" import type { AnyRoute } from "../route/client" import { isRecord } from "../utils/record" @@ -50,25 +50,10 @@ export const mergeProviderOptions = ( return Object.keys(result).length === 0 ? undefined : result } -export interface HttpRequest { - url: string - readonly method: string - headers: Record - body: string | undefined -} - -export type HttpRequestTransform = (request: HttpRequest) => Effect.Effect - -const HttpRequestTransform = Schema.declare( - (value): value is HttpRequestTransform => typeof value === "function", - { expected: "LLM.HttpRequestTransform" }, -) - export class HttpOptions extends Schema.Class("LLM.HttpOptions")({ body: Schema.optional(JsonSchema), headers: Schema.optional(Schema.Record(Schema.String, Schema.String)), query: Schema.optional(Schema.Record(Schema.String, Schema.String)), - transform: Schema.optional(HttpRequestTransform), }) {} export namespace HttpOptions { @@ -82,9 +67,8 @@ export const mergeHttpOptions = (...items: ReadonlyArray item?.body)) const headers = mergeStringRecords(...items.map((item) => item?.headers)) const query = mergeStringRecords(...items.map((item) => item?.query)) - const transform = items.findLast((item) => item?.transform !== undefined)?.transform - if (!body && !headers && !query && !transform) return undefined - return new HttpOptions({ body, headers, query, transform }) + if (!body && !headers && !query) return undefined + return new HttpOptions({ body, headers, query }) } export class GenerationOptions extends Schema.Class("LLM.GenerationOptions")({ diff --git a/packages/ai/test/compile.test.ts b/packages/ai/test/compile.test.ts index 8734f8234cd8..a15e9d5260df 100644 --- a/packages/ai/test/compile.test.ts +++ b/packages/ai/test/compile.test.ts @@ -144,16 +144,16 @@ describe("request option precedence", () => { .with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("fresh-key") }) .model({ id: "gpt-4o-mini" }), prompt: "Say hello.", - http: { - 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 }) - }), - }, }), + { + transformRequest: (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) => diff --git a/packages/core/src/session/model-request.ts b/packages/core/src/session/model-request.ts index b748b771a300..1682ffdcd046 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. @@ -155,24 +157,6 @@ export const layer = Layer.effect( model, http: { headers: SessionModelHeaders.make(session, app), - 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, - ), }, providerOptions: { openai: { promptCacheKey } }, system: contextEvent.system, @@ -180,6 +164,26 @@ export const layer = Layer.effect( tools: hookedTools, toolChoice: stepLimitReached ? "none" : undefined, }) + const options: StreamOptions = { + transformRequest: (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) @@ -208,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 5b6298e92d59..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,14 +16,10 @@ export interface SessionContext { tools: Record } -export interface SessionRequest { +export interface SessionRequest extends HttpRequest { readonly sessionID: Session.ID readonly agent: Agent.ID readonly model: Model.Ref - url: string - readonly method: string - headers: Record - body: string | undefined } export interface SessionHooks { diff --git a/packages/plugin/src/promise/session.ts b/packages/plugin/src/promise/session.ts index 64de8451afeb..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,14 +16,10 @@ export interface SessionContext { tools: Record } -export interface SessionRequest { +export interface SessionRequest extends HttpRequest { readonly sessionID: Session.ID readonly agent: Agent.ID readonly model: Model.Ref - url: string - readonly method: string - headers: Record - body: string | undefined } export interface SessionHooks { From 6df4a9fa4307eea82b77babc792ce46a3c9f9b2d Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Thu, 30 Jul 2026 21:06:05 -0500 Subject: [PATCH 5/5] refactor(ai): simplify request transform naming --- packages/ai/src/route/client.ts | 4 ++-- packages/ai/src/route/transport/http.ts | 2 +- packages/ai/src/route/transport/index.ts | 2 +- packages/ai/test/compile.test.ts | 2 +- packages/core/src/session/model-request.ts | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/ai/src/route/client.ts b/packages/ai/src/route/client.ts index 1eb56763117a..5021ae958f4f 100644 --- a/packages/ai/src/route/client.ts +++ b/packages/ai/src/route/client.ts @@ -150,7 +150,7 @@ export interface Interface { } export interface StreamOptions { - readonly transformRequest?: HttpRequestTransform + readonly transform?: HttpRequestTransform } export interface StreamMethod { @@ -302,7 +302,7 @@ function makeFromTransport( auth: routeInput.auth ?? Auth.none, encodeBody, headers: routeInput.headers, - transformRequest: options?.transformRequest, + transform: options?.transform, }), streamPrepared: (prepared: Prepared, request: LLMRequest, runtime: TransportRuntime) => { const route = `${request.model.provider}/${request.model.route.id}` diff --git a/packages/ai/src/route/transport/http.ts b/packages/ai/src/route/transport/http.ts index 7998e0c76572..ae8b5e088d67 100644 --- a/packages/ai/src/route/transport/http.ts +++ b/packages/ai/src/route/transport/http.ts @@ -123,7 +123,7 @@ export const httpJson = (input: HttpJsonInput): HttpJs Effect.gen(function* () { const parts = yield* jsonRequestParts({ ...prepareInput }) const request = { url: parts.url, method: "POST", headers: { ...parts.headers }, body: parts.bodyText } - yield* (prepareInput.transformRequest?.(request) ?? Effect.void) + yield* (prepareInput.transform?.(request) ?? Effect.void) return { request: ProviderShared.jsonPost({ url: request.url, diff --git a/packages/ai/src/route/transport/index.ts b/packages/ai/src/route/transport/index.ts index da884bb8ac08..588ea9c8969b 100644 --- a/packages/ai/src/route/transport/index.ts +++ b/packages/ai/src/route/transport/index.ts @@ -36,7 +36,7 @@ export interface TransportPrepareInput { readonly auth: Auth.Definition readonly encodeBody: (body: Body) => string readonly headers?: (input: { readonly request: LLMRequest }) => Record - readonly transformRequest?: HttpRequestTransform + 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 a15e9d5260df..9c8dbbdfac9f 100644 --- a/packages/ai/test/compile.test.ts +++ b/packages/ai/test/compile.test.ts @@ -146,7 +146,7 @@ describe("request option precedence", () => { prompt: "Say hello.", }), { - transformRequest: (request) => + transform: (request) => Effect.sync(() => { expect(request.headers.authorization).toBe("Bearer fresh-key") request.url = "https://proxy.test/v1/chat/completions" diff --git a/packages/core/src/session/model-request.ts b/packages/core/src/session/model-request.ts index 1682ffdcd046..18ed3a193316 100644 --- a/packages/core/src/session/model-request.ts +++ b/packages/core/src/session/model-request.ts @@ -165,7 +165,7 @@ export const layer = Layer.effect( toolChoice: stepLimitReached ? "none" : undefined, }) const options: StreamOptions = { - transformRequest: (request) => + transform: (request) => hooks .trigger("session", "request", { sessionID: session.id,