From b1f86ee72b4cc0c45bdba741026949bf8adbd37c Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Sat, 1 Aug 2026 11:44:08 -0500 Subject: [PATCH] feat(plugin): wrap session HTTP requests --- packages/ai/src/route/client.ts | 11 +-- packages/ai/src/route/index.ts | 2 +- packages/ai/src/route/transport/http.ts | 67 +++++++++++++++---- packages/ai/src/route/transport/index.ts | 12 +--- packages/ai/test/compile.test.ts | 54 +++++++++++++-- packages/core/src/aisdk.ts | 49 ++++++++++---- packages/core/src/plugin/promise.ts | 34 +++++++++- packages/core/src/plugin/provider/openai.ts | 22 ++++-- packages/core/src/session/model-request.ts | 17 ++--- packages/core/test/aisdk.test.ts | 44 ++++++++++++ packages/core/test/plugin/promise.test.ts | 33 +++++++++ .../core/test/plugin/provider-openai.test.ts | 59 ++++++---------- packages/plugin/src/effect/session.ts | 8 +-- packages/plugin/src/promise/session.ts | 6 +- packages/www/content/docs/build/plugins.mdx | 5 +- 15 files changed, 309 insertions(+), 114 deletions(-) diff --git a/packages/ai/src/route/client.ts b/packages/ai/src/route/client.ts index 5021ae958f4..4ce9dcc1860 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 { HttpRequestTransform, Transport, TransportRuntime } from "./transport" +import type { HttpMiddleware, Transport, TransportRuntime } from "./transport" import { WebSocketExecutor } from "./transport" import type { Protocol } from "./protocol" import { applyCachePolicy } from "../cache-policy" @@ -96,7 +96,10 @@ export interface RoutePatch extends RouteDefaultsInput { type RouteMappedModelInput = RouteModelInput | RouteRoutedModelInput -const makeRouteModel = (route: AnyRoute, mapped: RouteMappedModelInput) => { +const makeRouteModel = ( + route: AnyRoute, + mapped: RouteMappedModelInput, +) => { const provider = route.provider ?? ("provider" in mapped ? mapped.provider : undefined) if (!provider) throw new Error(`Route.model(${route.id}) requires a provider`) if (!endpointBaseURL(route.endpoint)) @@ -150,7 +153,7 @@ export interface Interface { } export interface StreamOptions { - readonly transform?: HttpRequestTransform + readonly http?: HttpMiddleware } export interface StreamMethod { @@ -302,7 +305,7 @@ function makeFromTransport( auth: routeInput.auth ?? Auth.none, encodeBody, headers: routeInput.headers, - transform: options?.transform, + middleware: options?.http, }), streamPrepared: (prepared: Prepared, request: LLMRequest, runtime: TransportRuntime) => { const route = `${request.model.provider}/${request.model.route.id}` diff --git a/packages/ai/src/route/index.ts b/packages/ai/src/route/index.ts index 0f382326180..7ef778800fd 100644 --- a/packages/ai/src/route/index.ts +++ b/packages/ai/src/route/index.ts @@ -23,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 { HttpRequest, HttpRequestTransform, Transport as TransportDef, TransportRuntime } from "./transport" +export type { HttpHandler, HttpMiddleware, 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 a1de9ec5735..a1204c47f15 100644 --- a/packages/ai/src/route/transport/http.ts +++ b/packages/ai/src/route/transport/http.ts @@ -1,11 +1,11 @@ import { Effect, Stream } from "effect" -import { Headers, HttpClientRequest } from "effect/unstable/http" +import { Headers, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" import { Auth } from "../auth" import { render as renderEndpoint } from "../endpoint" import { Framing } from "../framing" -import type { Transport, TransportPrepareInput } from "./index" +import type { HttpMiddleware, Transport, TransportPrepareInput } from "./index" import * as ProviderShared from "../../protocols/shared" -import { mergeJsonRecords, type LLMRequest } from "../../schema" +import { LLMError, mergeJsonRecords, type LLMRequest } from "../../schema" export type JsonRequestInput = TransportPrepareInput @@ -18,7 +18,9 @@ export interface JsonRequestParts { export interface HttpPrepared { readonly request: HttpClientRequest.HttpClientRequest + readonly web: Request readonly framing: Framing.Definition + readonly middleware?: HttpMiddleware } const applyQuery = (url: string, query: Record | undefined) => { @@ -74,21 +76,62 @@ export const httpJson = (input: HttpJsonInput): HttpJs prepare: (prepareInput) => 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) + const request = ProviderShared.jsonPost({ + url: parts.url, + body: parts.bodyText, + headers: parts.headers, + }) return { - request: ProviderShared.jsonPost({ - url: request.url, - body: request.body ?? "", - headers: Headers.fromInput(request.headers), - }), + request, + web: new Request(parts.url, { method: "POST", headers: parts.headers, body: parts.bodyText }), framing: input.framing, + middleware: prepareInput.middleware, } }), frames: (prepared, request, runtime) => Stream.unwrap( - runtime.http - .execute(prepared.request) + Effect.gen(function* () { + const request = prepared.web + const execute = (input: Request) => + Effect.tryPromise({ + try: () => input.text(), + catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), + }).pipe( + Effect.flatMap((body) => + runtime.http.execute( + ProviderShared.jsonPost({ + url: input.url, + body, + headers: Headers.fromInput(input.headers), + }), + ), + ), + Effect.flatMap((response) => + Stream.toReadableStreamEffect(response.stream).pipe( + Effect.map( + (body) => + new Response(body, { + status: response.status, + headers: response.headers, + }), + ), + ), + ), + ) + return yield* prepared.middleware ? prepared.middleware(request, execute) : execute(request) + }) + .pipe( + Effect.mapError((error) => + error instanceof LLMError + ? error + : ProviderShared.eventError( + `${request.model.provider}/${request.model.route.id}`, + `Failed to execute ${request.model.provider}/${request.model.route.id} request`, + ProviderShared.errorText(error), + ), + ), + Effect.map((response) => HttpClientResponse.fromWeb(prepared.request, response)), + ) .pipe( Effect.map((response) => prepared.framing.frame( diff --git a/packages/ai/src/route/transport/index.ts b/packages/ai/src/route/transport/index.ts index 588ea9c8969..275f64c06c5 100644 --- a/packages/ai/src/route/transport/index.ts +++ b/packages/ai/src/route/transport/index.ts @@ -10,14 +10,8 @@ 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 type HttpHandler = (request: Request) => Effect.Effect +export type HttpMiddleware = (request: Request, handler: HttpHandler) => Effect.Effect export interface Transport { readonly id: string @@ -36,7 +30,7 @@ export interface TransportPrepareInput { readonly auth: Auth.Definition readonly encodeBody: (body: Body) => string readonly headers?: (input: { readonly request: LLMRequest }) => Record - readonly transform?: HttpRequestTransform + readonly middleware?: HttpMiddleware } export * as HttpTransport from "./http" diff --git a/packages/ai/test/compile.test.ts b/packages/ai/test/compile.test.ts index 27971bf5c8e..741b1f7ddab 100644 --- a/packages/ai/test/compile.test.ts +++ b/packages/ai/test/compile.test.ts @@ -146,12 +146,18 @@ describe("request option precedence", () => { 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 }) + http: (request, handler) => + Effect.gen(function* () { + expect(request.headers.get("authorization")).toBe("Bearer fresh-key") + const headers = new Headers(request.headers) + headers.set("x-plugin", "transformed") + return yield* handler( + new Request("https://proxy.test/v1/chat/completions", { + method: request.method, + headers, + body: JSON.stringify({ transformed: true }), + }), + ) }), }, ).pipe( @@ -171,6 +177,42 @@ describe("request option precedence", () => { ), ) + it.effect("transforms the HTTP response before protocol decoding", () => + Effect.gen(function* () { + const response = yield* LLMClient.generate( + LLM.request({ + model: OpenAIChat.route + .with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") }) + .model({ id: "gpt-4o-mini" }), + prompt: "Say hello.", + }), + { + http: (request, handler) => + Effect.gen(function* () { + const response = yield* handler(request) + const body = yield* Effect.promise(() => response.text()) + return new Response(body.replace("network", "hooked"), { + status: response.status, + headers: response.headers, + }) + }), + }, + ).pipe( + Effect.provide( + dynamicResponse((input) => + Effect.succeed( + input.respond(sseEvents(deltaChunk({ content: "network" }, "stop")), { + headers: { "content-type": "text/event-stream" }, + }), + ), + ), + ), + ) + + expect(response.text).toBe("hooked") + }), + ) + it.effect("applies raw body overlays after protocol lowering", () => LLMClient.generate( LLM.request({ diff --git a/packages/core/src/aisdk.ts b/packages/core/src/aisdk.ts index 4114f4aaea4..5eb64c96c12 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, @@ -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 HttpHandler, type HttpMiddleware } from "@opencode-ai/ai/route" import { ProviderShared } from "@opencode-ai/ai/protocols/shared" import { Cause, Context, Effect, Layer, Option, Schema, Scope, Stream } from "effect" import type { ID, Info } from "./model" @@ -103,7 +104,7 @@ function wrapSSE(res: Response, ms: number, ctl: AbortController) { }) } -function prepareOptions(model: Info, pkg: string) { +function prepareOptions(model: Info, pkg: string, requests: AsyncLocalStorage) { const projected = mapBodyToProviderOptions(model, pkg) const options: Record = { name: model.providerID, @@ -150,10 +151,20 @@ function prepareOptions(model: Info, pkg: string) { } } - const res = await (typeof customFetch === "function" ? customFetch : fetch)(input, { - ...opts, - timeout: false, - }) + const requestInit: RequestInit = opts + const request = + input instanceof Request + ? new Request(input, requestInit) + : input instanceof URL + ? new Request(input.href, requestInit) + : new Request(input, requestInit) + const handler: HttpHandler = (input) => + Effect.tryPromise({ + try: () => (typeof customFetch === "function" ? customFetch(input) : fetch(input)), + catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), + }) + const middleware = requests.getStore() + const res = await Effect.runPromise(middleware ? middleware(request, handler) : handler(request)) if (!chunkAbortCtl || typeof chunkTimeout !== "number") return res return wrapSSE(res, chunkTimeout, chunkAbortCtl) } @@ -194,6 +205,7 @@ export const locationLayer = Layer.effect( let languageHooks: ((event: LanguageEvent) => Effect.Effect | void)[] = [] const languages = new Map() const sdks = new Map() + const requests = new AsyncLocalStorage() const functionIDs = new WeakMap() let nextFunctionID = 0 const cacheKey = (input: unknown) => @@ -267,7 +279,7 @@ export const locationLayer = Layer.effect( }) const packageName = Provider.packageName(model.package) - const options = prepareOptions(model, packageName) + const options = prepareOptions(model, packageName, requests) const sdkKey = cacheKey({ providerID: model.providerID, package: packageName, @@ -292,14 +304,14 @@ export const locationLayer = Layer.effect( return language }), model: Effect.fn("AISDK.model")(function* (model) { - return modelFromLanguage(model, yield* service.language(model)) + return modelFromLanguage(model, yield* service.language(model), requests) }), }) return service }), ) -function modelFromLanguage(info: Info, language: LanguageModelV3) { +function modelFromLanguage(info: Info, language: LanguageModelV3, requests: AsyncLocalStorage) { const packageName = Provider.packageName(info.package!) const projected = mapBodyToProviderOptions(info, packageName) const optionKey = providerOptionKey(packageName, info.providerID) @@ -339,8 +351,11 @@ 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), + prepareTransport: (body, _request, options) => Effect.succeed({ body, middleware: options?.http }), + streamPrepared: (prepared) => { + const input = prepared as { body: LanguageModelV3CallOptions; middleware?: HttpMiddleware } + return streamLanguage(language, input.body, requests, input.middleware) + }, } return Model.make({ id: info.modelID ?? info.id, @@ -529,13 +544,21 @@ 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, + requests?: AsyncLocalStorage, + middleware?: HttpMiddleware, +) { 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: () => + requests && middleware + ? requests.run(middleware, () => language.doStream(options)) + : language.doStream(options), catch: (error) => llmError("doStream", error), }).pipe( Effect.map((result) => diff --git a/packages/core/src/plugin/promise.ts b/packages/core/src/plugin/promise.ts index 651bc7c8ba5..36d903b9ee9 100644 --- a/packages/core/src/plugin/promise.ts +++ b/packages/core/src/plugin/promise.ts @@ -194,7 +194,9 @@ export function fromPromise(plugin: Plugin) { ), ), refresh: - refresh === undefined ? undefined : (credential) => Effect.promise(() => refresh(credential)), + refresh === undefined + ? undefined + : (credential) => Effect.promise(() => refresh(credential)), }) }, remove: draft.method.remove, @@ -263,8 +265,34 @@ export function fromPromise(plugin: Plugin) { ), }, session: { - hook: (name, callback) => - register(host.session.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))), + hook: (name, callback) => { + if (name !== "http") + return register( + host.session.hook(name, (event) => + Effect.promise(() => Promise.resolve(Reflect.apply(callback, undefined, [event]))), + ), + ) + return register( + host.session.hook("http", (event) => { + const request = event.request + const output = { + ...event, + request: (input: Request) => Effect.runPromiseWith(context)(request(input)), + } + return Effect.promise(() => Promise.resolve(Reflect.apply(callback, undefined, [output]))).pipe( + Effect.tap(() => + Effect.sync(() => { + event.request = (input) => + Effect.tryPromise({ + try: () => output.request(input), + catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), + }) + }), + ), + ) + }), + ) + }, create: (input) => run( host.session.create( diff --git a/packages/core/src/plugin/provider/openai.ts b/packages/core/src/plugin/provider/openai.ts index cbd101eacaf..b57cdf5486f 100644 --- a/packages/core/src/plugin/provider/openai.ts +++ b/packages/core/src/plugin/provider/openai.ts @@ -225,15 +225,25 @@ export const OpenAIPlugin = define({ }) } }) - yield* ctx.session.hook("request", (evt) => + yield* ctx.session.hook("http", (evt) => Effect.sync(() => { if (!chatgpt || evt.model.providerID !== Provider.ID.openai) return - const url = new URL(evt.url) - if (url.origin === "https://api.openai.com") { - evt.url = `${codexBaseURL}${url.pathname.replace(/^\/v1/, "")}${url.search}` + const request = evt.request + evt.request = (input) => { + const url = new URL(input.url) + const headers = new Headers(input.headers) + headers.set("originator", "opencode") + headers.set("session-id", evt.sessionID) + if (url.origin !== "https://api.openai.com") return request(new Request(input, { headers })) + return request( + new Request(`${codexBaseURL}${url.pathname.replace(/^\/v1/, "")}${url.search}`, { + method: input.method, + headers, + body: input.body, + signal: input.signal, + }), + ) } - evt.headers.originator = "opencode" - evt.headers["session-id"] = evt.sessionID }), ) diff --git a/packages/core/src/session/model-request.ts b/packages/core/src/session/model-request.ts index fab9e4c0e46..86ff5f3a24c 100644 --- a/packages/core/src/session/model-request.ts +++ b/packages/core/src/session/model-request.ts @@ -220,24 +220,15 @@ export const layer = Layer.effect( toolChoice: stepLimitReached ? "none" : undefined, }) const options: StreamOptions = { - transform: (request) => + http: (request, handler) => hooks - .trigger("session", "request", { + .trigger("session", "http", { sessionID: session.id, agent: agent.id, model: resolved.ref, - ...request, + request: handler, }) - .pipe( - Effect.tap((event) => - Effect.sync(() => { - request.url = event.url - request.headers = event.headers - request.body = event.body - }), - ), - Effect.asVoid, - ), + .pipe(Effect.flatMap((event) => event.request(request))), } if (promptCacheSnapshots) { const current = PromptCacheDiagnostics.snapshot(request) diff --git a/packages/core/test/aisdk.test.ts b/packages/core/test/aisdk.test.ts index e3dfbe8d803..fe411517d0e 100644 --- a/packages/core/test/aisdk.test.ts +++ b/packages/core/test/aisdk.test.ts @@ -51,6 +51,50 @@ const client = LLMClient.layer.pipe( ), ) +it.effect("applies HTTP middleware to AI SDK requests and responses", () => + Effect.gen(function* () { + const aisdk = yield* AISDK.Service + yield* aisdk.hook.sdk((event) => { + const request = event.options.fetch + event.sdk = { + languageModel: () => ({ + ...streamModel([]), + doStream: async () => { + const response = await request("https://provider.test/v1/chat", { method: "POST", body: "before" }) + const text = await response.text() + return { + stream: new ReadableStream({ + start(controller) { + controller.enqueue({ type: "text-start", id: "text" }) + controller.enqueue({ type: "text-delta", id: "text", delta: text }) + controller.enqueue({ type: "text-end", id: "text" }) + controller.enqueue({ type: "finish", finishReason: { unified: "stop", raw: "stop" }, usage }) + controller.close() + }, + }), + } + }, + }), + } + }) + + const resolved = yield* aisdk.model( + model("middleware-test", { + fetch: async (request: Request) => new Response(await request.text()), + }), + ) + const response = yield* LLMClient.generate(LLM.request({ model: resolved, prompt: "test" }), { + http: (request, handler) => + Effect.gen(function* () { + const response = yield* handler(new Request(request, { method: request.method, body: "request-hooked" })) + return new Response(`${yield* Effect.promise(() => response.text())}-response-hooked`) + }), + }).pipe(Effect.provide(client)) + + expect(response.text).toBe("request-hooked-response-hooked") + }), +) + 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/promise.test.ts b/packages/core/test/plugin/promise.test.ts index f63c7f3eca8..a0956307792 100644 --- a/packages/core/test/plugin/promise.test.ts +++ b/packages/core/test/plugin/promise.test.ts @@ -221,6 +221,39 @@ describe("fromPromise", () => { }), ) + it.effect("adapts promise session HTTP hooks", () => + Effect.gen(function* () { + const plugin = yield* Plugin.Service + const hooks = yield* PluginHooks.Service + const host = yield* PluginHost.make(plugin) + yield* PluginPromise.fromPromise( + define({ + id: "promise-session-http", + setup: async (ctx) => { + await ctx.session.hook("http", (event) => { + const request = event.request + event.request = async (input) => { + const response = await request(new Request(input, { headers: { "x-hook": "promise" } })) + return new Response(`${await response.text()}-response`) + } + }) + }, + }), + ).effect(host) + const event: SessionHooks["http"] = { + sessionID: Session.ID.make("ses_promise_session_http"), + agent: Agent.ID.make("build"), + model: Model.Ref.make({ providerID: Provider.ID.make("test"), id: Model.ID.make("model") }), + request: (input) => Effect.succeed(new Response(input.headers.get("x-hook") ?? "missing")), + } + + yield* hooks.trigger("session", "http", event) + const response = yield* event.request(new Request("https://provider.test")) + + expect(yield* Effect.promise(() => response.text())).toBe("promise-response") + }), + ) + it.effect("disposes a hook registration on request", () => Effect.gen(function* () { const agents = yield* Agent.Service diff --git a/packages/core/test/plugin/provider-openai.test.ts b/packages/core/test/plugin/provider-openai.test.ts index 9a73f5af939..de5f87e09e2 100644 --- a/packages/core/test/plugin/provider-openai.test.ts +++ b/packages/core/test/plugin/provider-openai.test.ts @@ -29,6 +29,21 @@ function required(value: T | undefined): T { return value } +const http = Effect.fn(function* (providerID: Provider.ID, url: string) { + const event = yield* (yield* PluginHooks.Service).trigger("session", "http", { + sessionID: Session.ID.make("ses_test"), + agent: Agent.ID.make("build"), + model: Model.Ref.make({ providerID, id: Model.ID.make("gpt-5.5") }), + request: (input) => { + const headers = new Headers(input.headers) + headers.set("x-seen-url", input.url) + return Effect.succeed(new Response(null, { headers })) + }, + }) + const response = yield* event.request(new Request(url, { method: "POST", body: "{}" })) + return { url: response.headers.get("x-seen-url"), headers: Object.fromEntries(response.headers.entries()) } +}) + describe("OpenAIPlugin", () => { it.effect("registers browser and headless ChatGPT OAuth methods", () => Effect.gen(function* () { @@ -100,33 +115,9 @@ describe("OpenAIPlugin", () => { }) yield* addPlugin() - const request = yield* (yield* PluginHooks.Service).trigger("session", "request", { - sessionID: Session.ID.make("ses_test"), - agent: Agent.ID.make("build"), - model: Model.Ref.make({ providerID: Provider.ID.openai, id: Model.ID.make("gpt-5.5") }), - url: "https://api.openai.com/v1/responses", - method: "POST", - headers: {}, - body: "{}", - }) - const custom = yield* (yield* PluginHooks.Service).trigger("session", "request", { - sessionID: Session.ID.make("ses_test"), - agent: Agent.ID.make("build"), - model: Model.Ref.make({ providerID: Provider.ID.make("custom-openai"), id: Model.ID.make("gpt-5.5") }), - url: "https://custom.example/v1/responses", - method: "POST", - headers: {}, - body: "{}", - }) - const proxy = yield* (yield* PluginHooks.Service).trigger("session", "request", { - sessionID: Session.ID.make("ses_test"), - agent: Agent.ID.make("build"), - model: Model.Ref.make({ providerID: Provider.ID.openai, id: Model.ID.make("gpt-5.5") }), - url: "https://proxy.example/v1/responses?region=us", - method: "POST", - headers: {}, - body: "{}", - }) + const request = yield* http(Provider.ID.openai, "https://api.openai.com/v1/responses") + const custom = yield* http(Provider.ID.make("custom-openai"), "https://custom.example/v1/responses") + const proxy = yield* http(Provider.ID.openai, "https://proxy.example/v1/responses?region=us") const provider = required(yield* catalog.provider.get(Provider.ID.openai)) expect(provider.package).toBe("@opencode-ai/ai/providers/openai") @@ -134,7 +125,7 @@ describe("OpenAIPlugin", () => { expect(provider.headers).toMatchObject({ "chatgpt-account-id": "acct_123" }) expect(request.url).toBe("https://chatgpt.com/backend-api/codex/responses") expect(request.headers).toMatchObject({ originator: "opencode", "session-id": "ses_test" }) - expect(custom.headers).toEqual({}) + expect(custom.headers).not.toHaveProperty("originator") expect(proxy.url).toBe("https://proxy.example/v1/responses?region=us") expect(proxy.headers).toMatchObject({ originator: "opencode", "session-id": "ses_test" }) const eligible = required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.5"))) @@ -184,21 +175,13 @@ describe("OpenAIPlugin", () => { }) yield* addPlugin() - const request = yield* (yield* PluginHooks.Service).trigger("session", "request", { - sessionID: Session.ID.make("ses_test"), - agent: Agent.ID.make("build"), - model: Model.Ref.make({ providerID: Provider.ID.openai, id: Model.ID.make("gpt-5.5") }), - url: "https://api.openai.com/v1/responses", - method: "POST", - headers: {}, - body: "{}", - }) + const request = yield* http(Provider.ID.openai, "https://api.openai.com/v1/responses") const model = required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.5"))) expect(model.package).toBe("@opencode-ai/ai/providers/openai") expect(model.enabled).toBe(true) expect(model.limit).toEqual({ context: 1_050_000, input: 922_000, output: 128_000 }) - expect(request.headers).toEqual({}) + expect(request.headers).not.toHaveProperty("originator") expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-4.1"))).enabled).toBe(true) }), ) diff --git a/packages/plugin/src/effect/session.ts b/packages/plugin/src/effect/session.ts index e4206b6df25..c2bf4b0ce83 100644 --- a/packages/plugin/src/effect/session.ts +++ b/packages/plugin/src/effect/session.ts @@ -1,10 +1,9 @@ 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" -import type { JsonSchema } from "effect" +import type { Effect, JsonSchema } from "effect" import type { Hooks } from "./registration.js" export interface SessionContext { @@ -16,15 +15,16 @@ export interface SessionContext { tools: Record } -export interface SessionRequest extends HttpRequest { +export interface SessionHttp { readonly sessionID: Session.ID readonly agent: Agent.ID readonly model: Model.Ref + request: (input: Request) => Effect.Effect } export interface SessionHooks { readonly context: SessionContext - readonly request: SessionRequest + readonly http: SessionHttp } export type SessionDomain = Pick< diff --git a/packages/plugin/src/promise/session.ts b/packages/plugin/src/promise/session.ts index adc371b9510..b94121d4812 100644 --- a/packages/plugin/src/promise/session.ts +++ b/packages/plugin/src/promise/session.ts @@ -1,6 +1,5 @@ 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" @@ -16,15 +15,16 @@ export interface SessionContext { tools: Record } -export interface SessionRequest extends HttpRequest { +export interface SessionHttp { readonly sessionID: Session.ID readonly agent: Agent.ID readonly model: Model.Ref + request: (input: Request) => Promise } export interface SessionHooks { readonly context: SessionContext - readonly request: SessionRequest + readonly http: SessionHttp } export type SessionDomain = Pick< diff --git a/packages/www/content/docs/build/plugins.mdx b/packages/www/content/docs/build/plugins.mdx index fd9331c8805..f8e1c5cbc50 100644 --- a/packages/www/content/docs/build/plugins.mdx +++ b/packages/www/content/docs/build/plugins.mdx @@ -246,7 +246,8 @@ mutable fields: | ------------------------------------------- | ------------------------------------------------------------------------------ | | `ctx.aisdk.hook("sdk", callback)` | `sdk`, after inspecting `model`, `package`, and `options` | | `ctx.aisdk.hook("language", callback)` | `language`, after inspecting `model`, `sdk`, and `options` | -| `ctx.session.hook("request", callback)` | `system`, `messages`, and the `tools` record immediately before model dispatch | +| `ctx.session.hook("context", callback)` | `system`, `messages`, and the `tools` record immediately before model dispatch | +| `ctx.session.hook("http", callback)` | `request`, wrapping the model's HTTP request and response | | `ctx.tool.hook("execute.before", callback)` | `input`, before the selected tool executes | | `ctx.tool.hook("execute.after", callback)` | Terminal `result` on success or `error` on failure | @@ -259,7 +260,7 @@ import { Plugin } from "@opencode-ai/plugin" export default Plugin.define({ id: "acme.guards", setup: async (ctx) => { - await ctx.session.hook("request", (event) => { + await ctx.session.hook("context", (event) => { delete event.tools.write })