mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-04 13:40:30 +00:00
refactor(plugin): expose HTTP middleware
This commit is contained in:
parent
46295dc33d
commit
95ad8ffe59
11 changed files with 97 additions and 78 deletions
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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")),
|
||||
|
|
|
|||
|
|
@ -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") }),
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
|
|
|
|||
20
packages/www/content/docs/build/plugins.mdx
vendored
20
packages/www/content/docs/build/plugins.mdx
vendored
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue