refactor(plugin): expose HTTP middleware

This commit is contained in:
Aiden Cline 2026-08-03 22:13:01 -05:00
parent 46295dc33d
commit 95ad8ffe59
11 changed files with 97 additions and 78 deletions

View file

@ -270,7 +270,7 @@ const toHttpError = (redactedNames: ReadonlyArray<string | RegExp>) => (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") {

View file

@ -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

View file

@ -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<Response, Error>
}
}
readonly shell: ShellHooks
readonly tool: ToolHooks
}

View file

@ -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,

View file

@ -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> | 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> | void)
const sessionContext = callback as (event: SessionContext) => Promise<void> | 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(

View file

@ -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(

View file

@ -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")),

View file

@ -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<void>()
const interrupted = yield* Deferred.make<void>()
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") }),

View file

@ -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<string, { description: string; input: JsonSchema.JsonSchema }>
}
export interface SessionHttp {
export interface SessionHttpContext {
readonly sessionID: Session.ID
readonly agent: Agent.ID
readonly model: Model.Ref
request: (input: Request) => Effect.Effect<Response, Error>
}
export type SessionHttpMiddleware = (
context: SessionHttpContext,
request: Request,
next: (request: Request) => Effect.Effect<Response, Error>,
) => Effect.Effect<Response, Error>
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<SessionHooks>
readonly http: (middleware: SessionHttpMiddleware) => Effect.Effect<Registration, never, Scope.Scope>
}

View file

@ -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<string, { description: string; input: JsonSchema.JsonSchema }>
}
export interface SessionHttp {
export interface SessionHttpContext {
readonly sessionID: Session.ID
readonly agent: Agent.ID
readonly model: Model.Ref
request: (input: Request) => Promise<Response>
}
export type SessionHttpMiddleware = (
context: SessionHttpContext,
request: Request,
next: (request: Request) => Promise<Response>,
) => Promise<Response> | 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<SessionHooks>
readonly http: (middleware: SessionHttpMiddleware) => Promise<Registration>
}

View file

@ -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: