diff --git a/packages/ai/src/route/client.ts b/packages/ai/src/route/client.ts index 4ce9dcc186..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 { HttpMiddleware, 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" @@ -96,10 +96,7 @@ 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)) @@ -153,7 +150,7 @@ export interface Interface { } export interface StreamOptions { - readonly http?: HttpMiddleware + readonly transform?: HttpRequestTransform } export interface StreamMethod { @@ -305,7 +302,7 @@ function makeFromTransport( auth: routeInput.auth ?? Auth.none, encodeBody, headers: routeInput.headers, - middleware: options?.http, + 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/index.ts b/packages/ai/src/route/index.ts index 7ef778800f..0f38232618 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 { HttpHandler, HttpMiddleware, 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 a1204c47f1..a1de9ec573 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, HttpClientResponse } from "effect/unstable/http" +import { Headers, HttpClientRequest } from "effect/unstable/http" import { Auth } from "../auth" import { render as renderEndpoint } from "../endpoint" import { Framing } from "../framing" -import type { HttpMiddleware, Transport, TransportPrepareInput } from "./index" +import type { Transport, TransportPrepareInput } from "./index" import * as ProviderShared from "../../protocols/shared" -import { LLMError, mergeJsonRecords, type LLMRequest } from "../../schema" +import { mergeJsonRecords, type LLMRequest } from "../../schema" export type JsonRequestInput = TransportPrepareInput @@ -18,9 +18,7 @@ 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) => { @@ -76,62 +74,21 @@ export const httpJson = (input: HttpJsonInput): HttpJs prepare: (prepareInput) => Effect.gen(function* () { const parts = yield* jsonRequestParts({ ...prepareInput }) - const request = ProviderShared.jsonPost({ - url: parts.url, - body: parts.bodyText, - headers: parts.headers, - }) + const request = { url: parts.url, method: "POST", headers: { ...parts.headers }, body: parts.bodyText } + yield* (prepareInput.transform?.(request) ?? Effect.void) return { - request, - web: new Request(parts.url, { method: "POST", headers: parts.headers, body: parts.bodyText }), + request: ProviderShared.jsonPost({ + url: request.url, + body: request.body ?? "", + headers: Headers.fromInput(request.headers), + }), framing: input.framing, - middleware: prepareInput.middleware, } }), frames: (prepared, request, runtime) => Stream.unwrap( - 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)), - ) + runtime.http + .execute(prepared.request) .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 275f64c06c..588ea9c896 100644 --- a/packages/ai/src/route/transport/index.ts +++ b/packages/ai/src/route/transport/index.ts @@ -10,8 +10,14 @@ export interface TransportRuntime { readonly webSocket?: WebSocketExecutorInterface } -export type HttpHandler = (request: Request) => Effect.Effect -export type HttpMiddleware = (request: Request, handler: HttpHandler) => Effect.Effect +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 @@ -30,7 +36,7 @@ export interface TransportPrepareInput { readonly auth: Auth.Definition readonly encodeBody: (body: Body) => string readonly headers?: (input: { readonly request: LLMRequest }) => Record - readonly middleware?: HttpMiddleware + 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 741b1f7dda..27971bf5c8 100644 --- a/packages/ai/test/compile.test.ts +++ b/packages/ai/test/compile.test.ts @@ -146,18 +146,12 @@ describe("request option precedence", () => { prompt: "Say hello.", }), { - 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 }), - }), - ) + 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( @@ -177,42 +171,6 @@ 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 5eb64c96c1..4114f4aaea 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, @@ -30,7 +29,7 @@ import { type ToolDefinition, type UsageInput, } from "@opencode-ai/ai" -import { Auth, Endpoint, type AnyRoute, type HttpHandler, type HttpMiddleware } from "@opencode-ai/ai/route" +import { Auth, Endpoint, type AnyRoute } 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" @@ -104,7 +103,7 @@ function wrapSSE(res: Response, ms: number, ctl: AbortController) { }) } -function prepareOptions(model: Info, pkg: string, requests: AsyncLocalStorage) { +function prepareOptions(model: Info, pkg: string) { const projected = mapBodyToProviderOptions(model, pkg) const options: Record = { name: model.providerID, @@ -151,20 +150,10 @@ function prepareOptions(model: Info, pkg: string, requests: AsyncLocalStorage - 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)) + const res = await (typeof customFetch === "function" ? customFetch : fetch)(input, { + ...opts, + timeout: false, + }) if (!chunkAbortCtl || typeof chunkTimeout !== "number") return res return wrapSSE(res, chunkTimeout, chunkAbortCtl) } @@ -205,7 +194,6 @@ 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) => @@ -279,7 +267,7 @@ export const locationLayer = Layer.effect( }) const packageName = Provider.packageName(model.package) - const options = prepareOptions(model, packageName, requests) + const options = prepareOptions(model, packageName) const sdkKey = cacheKey({ providerID: model.providerID, package: packageName, @@ -304,14 +292,14 @@ export const locationLayer = Layer.effect( return language }), model: Effect.fn("AISDK.model")(function* (model) { - return modelFromLanguage(model, yield* service.language(model), requests) + return modelFromLanguage(model, yield* service.language(model)) }), }) return service }), ) -function modelFromLanguage(info: Info, language: LanguageModelV3, requests: AsyncLocalStorage) { +function modelFromLanguage(info: Info, language: LanguageModelV3) { const packageName = Provider.packageName(info.package!) const projected = mapBodyToProviderOptions(info, packageName) const optionKey = providerOptionKey(packageName, info.providerID) @@ -351,11 +339,8 @@ function modelFromLanguage(info: Info, language: LanguageModelV3, requests: Asyn }, with: () => route, model: (input) => Model.make({ ...input, provider: "provider" in input ? input.provider : info.providerID, route }), - 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) - }, + prepareTransport: (body) => Effect.succeed(body), + streamPrepared: (prepared) => streamLanguage(language, prepared as LanguageModelV3CallOptions), } return Model.make({ id: info.modelID ?? info.id, @@ -544,21 +529,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, - requests?: AsyncLocalStorage, - middleware?: HttpMiddleware, -) { +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: () => - requests && middleware - ? requests.run(middleware, () => language.doStream(options)) - : language.doStream(options), + try: () => 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 36d903b9ee..651bc7c8ba 100644 --- a/packages/core/src/plugin/promise.ts +++ b/packages/core/src/plugin/promise.ts @@ -194,9 +194,7 @@ 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, @@ -265,34 +263,8 @@ export function fromPromise(plugin: Plugin) { ), }, session: { - 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))), - }) - }), - ), - ) - }), - ) - }, + hook: (name, callback) => + register(host.session.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))), 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 b57cdf5486..cbd101eaca 100644 --- a/packages/core/src/plugin/provider/openai.ts +++ b/packages/core/src/plugin/provider/openai.ts @@ -225,25 +225,15 @@ export const OpenAIPlugin = define({ }) } }) - yield* ctx.session.hook("http", (evt) => + yield* ctx.session.hook("request", (evt) => Effect.sync(() => { if (!chatgpt || evt.model.providerID !== Provider.ID.openai) return - 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, - }), - ) + const url = new URL(evt.url) + if (url.origin === "https://api.openai.com") { + evt.url = `${codexBaseURL}${url.pathname.replace(/^\/v1/, "")}${url.search}` } + 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 86ff5f3a24..fab9e4c0e4 100644 --- a/packages/core/src/session/model-request.ts +++ b/packages/core/src/session/model-request.ts @@ -220,15 +220,24 @@ export const layer = Layer.effect( toolChoice: stepLimitReached ? "none" : undefined, }) const options: StreamOptions = { - http: (request, handler) => + transform: (request) => hooks - .trigger("session", "http", { + .trigger("session", "request", { sessionID: session.id, agent: agent.id, model: resolved.ref, - request: handler, + ...request, }) - .pipe(Effect.flatMap((event) => event.request(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) diff --git a/packages/core/test/aisdk.test.ts b/packages/core/test/aisdk.test.ts index fe411517d0..e3dfbe8d80 100644 --- a/packages/core/test/aisdk.test.ts +++ b/packages/core/test/aisdk.test.ts @@ -51,50 +51,6 @@ 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 a095630779..f63c7f3eca 100644 --- a/packages/core/test/plugin/promise.test.ts +++ b/packages/core/test/plugin/promise.test.ts @@ -221,39 +221,6 @@ 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 de5f87e09e..9a73f5af93 100644 --- a/packages/core/test/plugin/provider-openai.test.ts +++ b/packages/core/test/plugin/provider-openai.test.ts @@ -29,21 +29,6 @@ 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* () { @@ -115,9 +100,33 @@ describe("OpenAIPlugin", () => { }) yield* addPlugin() - 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 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 provider = required(yield* catalog.provider.get(Provider.ID.openai)) expect(provider.package).toBe("@opencode-ai/ai/providers/openai") @@ -125,7 +134,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).not.toHaveProperty("originator") + expect(custom.headers).toEqual({}) 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"))) @@ -175,13 +184,21 @@ describe("OpenAIPlugin", () => { }) yield* addPlugin() - const request = yield* http(Provider.ID.openai, "https://api.openai.com/v1/responses") + 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 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).not.toHaveProperty("originator") + expect(request.headers).toEqual({}) 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 c2bf4b0ce8..e4206b6df2 100644 --- a/packages/plugin/src/effect/session.ts +++ b/packages/plugin/src/effect/session.ts @@ -1,9 +1,10 @@ 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 { Effect, JsonSchema } from "effect" +import type { JsonSchema } from "effect" import type { Hooks } from "./registration.js" export interface SessionContext { @@ -15,16 +16,15 @@ export interface SessionContext { tools: Record } -export interface SessionHttp { +export interface SessionRequest extends HttpRequest { readonly sessionID: Session.ID readonly agent: Agent.ID readonly model: Model.Ref - request: (input: Request) => Effect.Effect } export interface SessionHooks { readonly context: SessionContext - readonly http: SessionHttp + readonly request: SessionRequest } export type SessionDomain = Pick< diff --git a/packages/plugin/src/promise/session.ts b/packages/plugin/src/promise/session.ts index b94121d481..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,16 +16,15 @@ export interface SessionContext { tools: Record } -export interface SessionHttp { +export interface SessionRequest extends HttpRequest { readonly sessionID: Session.ID readonly agent: Agent.ID readonly model: Model.Ref - request: (input: Request) => Promise } export interface SessionHooks { readonly context: SessionContext - readonly http: SessionHttp + readonly request: SessionRequest } export type SessionDomain = Pick< diff --git a/packages/www/content/docs/build/plugins.mdx b/packages/www/content/docs/build/plugins.mdx index f8e1c5cbc5..fd9331c880 100644 --- a/packages/www/content/docs/build/plugins.mdx +++ b/packages/www/content/docs/build/plugins.mdx @@ -246,8 +246,7 @@ 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("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.session.hook("request", callback)` | `system`, `messages`, and the `tools` record immediately before model dispatch | | `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 | @@ -260,7 +259,7 @@ import { Plugin } from "@opencode-ai/plugin" export default Plugin.define({ id: "acme.guards", setup: async (ctx) => { - await ctx.session.hook("context", (event) => { + await ctx.session.hook("request", (event) => { delete event.tools.write })