diff --git a/packages/ai/src/route/executor.ts b/packages/ai/src/route/executor.ts index 73433d05ed4..9b90bf1aa4f 100644 --- a/packages/ai/src/route/executor.ts +++ b/packages/ai/src/route/executor.ts @@ -270,7 +270,7 @@ const toHttpError = (redactedNames: ReadonlyArray) => (error: u return transportError({ message: error.message, kind: "Timeout" }) } if (!HttpClientError.isHttpClientError(error)) { - return transportError({ message: "HTTP transport failed" }) + return transportError({ message: error instanceof Error ? error.message : "HTTP transport failed" }) } const request = "request" in error ? error.request : undefined if (error.reason._tag === "TransportError") { diff --git a/packages/ai/test/executor.test.ts b/packages/ai/test/executor.test.ts index 91417c8f08f..b4e5fe62b8e 100644 --- a/packages/ai/test/executor.test.ts +++ b/packages/ai/test/executor.test.ts @@ -67,6 +67,18 @@ const expectAIError = (error: unknown) => { const errorHttp = (error: AIError) => ("http" in error.reason ? error.reason.http : undefined) describe("RequestExecutor", () => { + it.effect("preserves middleware error messages", () => + Effect.gen(function* () { + const executor = yield* RequestExecutor.Service + const error = yield* executor + .execute(request, () => Effect.fail(new Error("plugin rejected request"))) + .pipe(Effect.flip) + + expectAIError(error) + expect(error.reason.message).toBe("plugin rejected request") + }).pipe(Effect.provide(responsesLayer([]))), + ) + it.effect("classifies context overflow responses", () => Effect.gen(function* () { const executor = yield* RequestExecutor.Service diff --git a/packages/core/src/plugin/hooks.ts b/packages/core/src/plugin/hooks.ts index 1f02bbed97a..6d0cbfdfe21 100644 --- a/packages/core/src/plugin/hooks.ts +++ b/packages/core/src/plugin/hooks.ts @@ -1,7 +1,7 @@ export * as PluginHooks from "./hooks" import type { AISDKHooks } from "@opencode-ai/plugin/effect/aisdk" -import type { SessionHooks } from "@opencode-ai/plugin/effect/session" +import type { SessionHooks, SessionHttpContext } from "@opencode-ai/plugin/effect/session" import type { ShellHooks } from "@opencode-ai/plugin/effect/shell" import type { ToolHooks } from "@opencode-ai/plugin/effect/tool" import { Context, Effect, Layer, Scope } from "effect" @@ -10,7 +10,11 @@ import { State } from "../state" export interface Domains { readonly aisdk: AISDKHooks - readonly session: SessionHooks + readonly session: SessionHooks & { + readonly http: SessionHttpContext & { + request: (input: Request) => Effect.Effect + } + } readonly shell: ShellHooks readonly tool: ToolHooks } diff --git a/packages/core/src/plugin/host.ts b/packages/core/src/plugin/host.ts index 9f525cab991..17eda7b4645 100644 --- a/packages/core/src/plugin/host.ts +++ b/packages/core/src/plugin/host.ts @@ -338,6 +338,13 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: import("../p }, session: { hook: (name, callback) => hooks.register("session", name, callback), + http: (middleware) => + hooks.register("session", "http", (event) => + Effect.sync(() => { + const next = event.request + event.request = (request) => middleware(event, request, next) + }), + ), create: (input) => runtime.session.create({ id: input?.id, diff --git a/packages/core/src/plugin/promise.ts b/packages/core/src/plugin/promise.ts index a4575b2072a..ec03ad57555 100644 --- a/packages/core/src/plugin/promise.ts +++ b/packages/core/src/plugin/promise.ts @@ -2,7 +2,7 @@ export * as PluginPromise from "./promise" import { define } from "@opencode-ai/plugin/effect/plugin" import type { Context, Plugin } from "@opencode-ai/plugin/promise/plugin" -import type { SessionContext, SessionHttp } from "@opencode-ai/plugin/promise/session" +import type { SessionHttpMiddleware } from "@opencode-ai/plugin/promise/session" import type { Info } from "@opencode-ai/plugin/promise/tool" import { Agent } from "@opencode-ai/schema/agent" import { Integration } from "@opencode-ai/schema/integration" @@ -58,26 +58,19 @@ export function fromPromise(plugin: Plugin) { }), ) - const sessionHttp = (callback: (event: SessionHttp) => Promise | void) => + const sessionHttp = (middleware: SessionHttpMiddleware) => register( - host.session.hook("http", (event) => { - const request = event.request - const output: SessionHttp = { - ...event, - request: (input) => Effect.runPromiseWith(context)(request(input), { signal: input.signal }), - } - return Effect.promise(() => Promise.resolve(callback(output))).pipe( - Effect.tap(() => - Effect.sync(() => { - event.request = (input) => - Effect.tryPromise({ - try: (signal) => output.request(new Request(input, { signal })), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }) - }), - ), - ) - }), + host.session.http((event, input, next) => + Effect.tryPromise({ + try: (signal) => + Promise.resolve( + middleware(event, new Request(input, { signal }), (request) => + Effect.runPromiseWith(context)(next(new Request(request, { signal })), { signal }), + ), + ), + catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), + }), + ), ) const context2: Context = { @@ -288,13 +281,9 @@ export function fromPromise(plugin: Plugin) { ), }, session: { - hook: (name, callback) => { - if (name === "http") return sessionHttp(callback as (event: SessionHttp) => Promise | void) - const sessionContext = callback as (event: SessionContext) => Promise | void - return register( - host.session.hook("context", (event) => Effect.promise(() => Promise.resolve(sessionContext(event)))), - ) - }, + hook: (name, callback) => + register(host.session.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))), + http: sessionHttp, 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 b57cdf5486f..50f21581021 100644 --- a/packages/core/src/plugin/provider/openai.ts +++ b/packages/core/src/plugin/provider/openai.ts @@ -225,27 +225,14 @@ export const OpenAIPlugin = define({ }) } }) - yield* ctx.session.hook("http", (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, - }), - ) - } - }), - ) + yield* ctx.session.http((evt, request, next) => { + if (!chatgpt || evt.model.providerID !== Provider.ID.openai) return next(request) + const url = new URL(request.url) + request.headers.set("originator", "opencode") + request.headers.set("session-id", evt.sessionID) + if (url.origin !== "https://api.openai.com") return next(request) + return next(new Request(`${codexBaseURL}${url.pathname.replace(/^\/v1/, "")}${url.search}`, request)) + }) const refresh = () => loading.withPermit(load().pipe(Effect.andThen(ctx.catalog.reload()))) yield* bus.subscribe(Integration.Event.ConnectionUpdated).pipe( diff --git a/packages/core/test/plugin/host.ts b/packages/core/test/plugin/host.ts index f45364ebdfe..1b1b1b82a1f 100644 --- a/packages/core/test/plugin/host.ts +++ b/packages/core/test/plugin/host.ts @@ -101,6 +101,7 @@ export function host(overrides: Overrides = {}): Plugin.Context { }, session: { hook: overrides.session?.hook ?? (() => Effect.die("unused session.hook")), + http: overrides.session?.http ?? (() => Effect.die("unused session.http")), create: overrides.session?.create ?? (() => Effect.die("unused session.create")), get: overrides.session?.get ?? (() => Effect.die("unused session.get")), prompt: overrides.session?.prompt ?? (() => Effect.die("unused session.prompt")), diff --git a/packages/core/test/plugin/promise.test.ts b/packages/core/test/plugin/promise.test.ts index b0c01fe44f6..2e1e6a27783 100644 --- a/packages/core/test/plugin/promise.test.ts +++ b/packages/core/test/plugin/promise.test.ts @@ -232,17 +232,19 @@ describe("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`) - } + await ctx.session.http(async (_event, request, next) => { + request.headers.set("x-hook", "promise") + const response = await next(request) + return new Response(`${await response.text()}-response`) + }) + await ctx.session.http(async (_event, request, next) => { + const response = await next(request) + return new Response(`${await response.text()}-outer`) }) }, }), ).effect(host) - const event: SessionHooks["http"] = { + const event: PluginHooks.Domains["session"]["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") }), @@ -252,7 +254,7 @@ describe("fromPromise", () => { 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") + expect(yield* Effect.promise(() => response.text())).toBe("promise-response-outer") }), ) @@ -265,16 +267,13 @@ describe("fromPromise", () => { define({ id: "promise-session-http-interrupt", setup: async (ctx) => { - await ctx.session.hook("http", (event) => { - const request = event.request - event.request = (input) => request(input) - }) + await ctx.session.http((_event, request, next) => next(request)) }, }), ).effect(host) const started = yield* Deferred.make() const interrupted = yield* Deferred.make() - const event: SessionHooks["http"] = { + const event: PluginHooks.Domains["session"]["http"] = { sessionID: Session.ID.make("ses_promise_session_http_interrupt"), agent: Agent.ID.make("build"), model: Model.Ref.make({ providerID: Provider.ID.make("test"), id: Model.ID.make("model") }), diff --git a/packages/plugin/src/effect/session.ts b/packages/plugin/src/effect/session.ts index c2bf4b0ce83..407ee33e5e9 100644 --- a/packages/plugin/src/effect/session.ts +++ b/packages/plugin/src/effect/session.ts @@ -3,8 +3,8 @@ import type { Message, SystemPart } from "@opencode-ai/ai" 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 { Hooks } from "./registration.js" +import type { Effect, JsonSchema, Scope } from "effect" +import type { Hooks, Registration } from "./registration.js" export interface SessionContext { readonly sessionID: Session.ID @@ -15,16 +15,20 @@ export interface SessionContext { tools: Record } -export interface SessionHttp { +export interface SessionHttpContext { readonly sessionID: Session.ID readonly agent: Agent.ID readonly model: Model.Ref - request: (input: Request) => Effect.Effect } +export type SessionHttpMiddleware = ( + context: SessionHttpContext, + request: Request, + next: (request: Request) => Effect.Effect, +) => Effect.Effect + export interface SessionHooks { readonly context: SessionContext - readonly http: SessionHttp } export type SessionDomain = Pick< @@ -32,4 +36,5 @@ export type SessionDomain = Pick< "create" | "get" | "prompt" | "generate" | "command" | "synthetic" | "interrupt" | "rename" | "wait" > & { readonly hook: Hooks + readonly http: (middleware: SessionHttpMiddleware) => Effect.Effect } diff --git a/packages/plugin/src/promise/session.ts b/packages/plugin/src/promise/session.ts index b94121d4812..d5587274129 100644 --- a/packages/plugin/src/promise/session.ts +++ b/packages/plugin/src/promise/session.ts @@ -4,7 +4,7 @@ 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 { Hooks } from "./registration.js" +import type { Hooks, Registration } from "./registration.js" export interface SessionContext { readonly sessionID: Session.ID @@ -15,16 +15,20 @@ export interface SessionContext { tools: Record } -export interface SessionHttp { +export interface SessionHttpContext { readonly sessionID: Session.ID readonly agent: Agent.ID readonly model: Model.Ref - request: (input: Request) => Promise } +export type SessionHttpMiddleware = ( + context: SessionHttpContext, + request: Request, + next: (request: Request) => Promise, +) => Promise | Response + export interface SessionHooks { readonly context: SessionContext - readonly http: SessionHttp } export type SessionDomain = Pick< @@ -32,4 +36,5 @@ export type SessionDomain = Pick< "create" | "get" | "prompt" | "generate" | "command" | "synthetic" | "interrupt" > & { readonly hook: Hooks + readonly http: (middleware: SessionHttpMiddleware) => Promise } diff --git a/packages/www/content/docs/build/plugins.mdx b/packages/www/content/docs/build/plugins.mdx index f8e1c5cbc50..5e6f2430e74 100644 --- a/packages/www/content/docs/build/plugins.mdx +++ b/packages/www/content/docs/build/plugins.mdx @@ -177,7 +177,7 @@ and plugin options. | `ctx.integration` | `list`, `get`, `connect`, `attempt`, `transform`, `reload`, and connection lookup/resolution | | `ctx.plugin` | `list` currently active plugin IDs | | `ctx.reference` | `list`, `transform`, `reload` | -| `ctx.session` | `create`, `get`, `prompt`, `command`, `rename`, `synthetic`, `interrupt`, `wait`, and `hook` | +| `ctx.session` | `create`, `get`, `prompt`, `command`, `rename`, `synthetic`, `interrupt`, `wait`, `hook`, and `http` | | `ctx.skill` | `list`, `transform`, `reload` | | `ctx.tool` | `transform` and `hook` | | `ctx.aisdk` | `hook` | @@ -239,17 +239,27 @@ without restarting OpenCode. ### Runtime hooks -Runtime hooks intercept live operations. Their event objects expose specific -mutable fields: +Runtime hooks intercept live operations: | Hook | 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.http(middleware)` | 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 | +| `ctx.tool.hook("execute.after", callback)` | Terminal `result` on success or `error` on failure | + +HTTP middleware can modify requests, inspect responses, retry, or return a +response without calling the provider: + +```ts +await ctx.session.http(async (event, request, next) => { + request.headers.set("x-session-id", event.sessionID) + const response = await next(request) + return response +}) +``` For example, remove a tool from selected model requests and normalize another tool's input: