From cb39ea11362b688409dbd309edfc7fc3ba32a6fe Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Wed, 19 Aug 2026 02:20:57 +0530 Subject: [PATCH] fix(core): prefer websocket with request hooks (#43234) --- .changeset/websocket-request-hooks.md | 6 + packages/core/src/plugin/hooks.ts | 64 ++++++---- packages/core/src/plugin/host.ts | 6 +- .../src/plugin/provider/github-copilot.ts | 29 ++--- packages/core/src/plugin/provider/openai.ts | 21 ++-- packages/core/src/session/compaction.ts | 33 +++--- packages/core/src/session/generate-node.ts | 19 +-- packages/core/src/session/model-hook.ts | 34 ++++++ packages/core/src/session/model-request.ts | 34 +++--- packages/core/src/session/title.ts | 33 +++--- packages/core/test/plugin/promise.test.ts | 20 +++- .../core/test/plugin/provider-openai.test.ts | 110 +++++++++++++++--- packages/core/test/session-generate.test.ts | 7 ++ packages/core/test/session-runner.test.ts | 30 +++++ packages/plugin/src/effect/aisdk.ts | 4 +- packages/plugin/src/effect/registration.ts | 13 +++ packages/plugin/src/effect/session.ts | 13 ++- packages/plugin/src/promise/adapter.ts | 12 +- packages/plugin/src/promise/aisdk.ts | 4 +- packages/plugin/src/promise/registration.ts | 11 ++ packages/plugin/src/promise/session.ts | 13 ++- 21 files changed, 389 insertions(+), 127 deletions(-) create mode 100644 .changeset/websocket-request-hooks.md create mode 100644 packages/core/src/session/model-hook.ts diff --git a/.changeset/websocket-request-hooks.md b/.changeset/websocket-request-hooks.md new file mode 100644 index 00000000000..6901987769f --- /dev/null +++ b/.changeset/websocket-request-hooks.md @@ -0,0 +1,6 @@ +--- +"@opencode-ai/plugin": patch +"@opencode-ai/core": patch +--- + +Add transport-neutral Session model request hooks and provider-scoped hook registration so eligible OpenAI Responses requests can prefer WebSocket without bypassing HTTP-only middleware. diff --git a/packages/core/src/plugin/hooks.ts b/packages/core/src/plugin/hooks.ts index b701975dde9..c9b5abba369 100644 --- a/packages/core/src/plugin/hooks.ts +++ b/packages/core/src/plugin/hooks.ts @@ -4,6 +4,7 @@ import type { AISDKHooks } from "@opencode-ai/plugin/effect/aisdk" import type { SessionHooks } from "@opencode-ai/plugin/effect/session" import type { ShellHooks } from "@opencode-ai/plugin/effect/shell" import type { ToolFailures, ToolHooks } from "@opencode-ai/plugin/effect/tool" +import type { ModelHookOptions } from "@opencode-ai/plugin/effect/registration" import { Context, Effect, Layer, Scope } from "effect" import { makeLocationNode } from "@opencode-ai/util/effect/app-node" import { State } from "../state.js" @@ -26,16 +27,26 @@ interface Failures extends Record { } type Callback = (event: Event) => Effect.Effect +type Entry = { readonly callback: Function; readonly options?: ModelHookOptions } + +const eventProviderID = (event: unknown) => { + if (typeof event !== "object" || event === null || !("model" in event)) return undefined + const model = event.model + if (typeof model !== "object" || model === null || !("providerID" in model)) return undefined + return typeof model.providerID === "string" ? model.providerID : undefined +} export interface Interface { readonly has: ( domain: Domain, name: keyof Domains[Domain] & keyof Failures[Domain], + providerID?: string, ) => Effect.Effect readonly register: ( domain: Domain, name: Name, callback: Callback, + options?: ModelHookOptions, ) => Effect.Effect readonly trigger: ( domain: Domain, @@ -49,36 +60,47 @@ export class Service extends Context.Service()("@opencode/Pl const layer = Layer.effect( Service, Effect.gen(function* () { - const callbacks = new Map() + const callbacks = new Map() const key = (domain: keyof Domains, name: PropertyKey) => `${domain}.${String(name)}` - const register: Interface["register"] = Effect.fn("PluginHooks.register")(function* (domain, name, callback) { - const scope = yield* Scope.Scope - const id = key(domain, name) - let active = true - callbacks.set(id, [...(callbacks.get(id) ?? []), callback]) - const dispose = Effect.sync(() => { - if (!active) return - active = false - const next = (callbacks.get(id) ?? []).filter((item) => item !== callback) - if (next.length === 0) callbacks.delete(id) - else callbacks.set(id, next) - }) - yield* Scope.addFinalizer(scope, dispose) - return { dispose } - }) + const register: Interface["register"] = Effect.fn("PluginHooks.register")( + function* (domain, name, callback, options) { + const scope = yield* Scope.Scope + const id = key(domain, name) + let active = true + const entry = { callback, options } + callbacks.set(id, [...(callbacks.get(id) ?? []), entry]) + const dispose = Effect.sync(() => { + if (!active) return + active = false + const next = (callbacks.get(id) ?? []).filter((item) => item !== entry) + if (next.length === 0) callbacks.delete(id) + else callbacks.set(id, next) + }) + yield* Scope.addFinalizer(scope, dispose) + return { dispose } + }, + ) const trigger: Interface["trigger"] = Effect.fnUntraced(function* (domain, name, event) { - for (const callback of callbacks.get(key(domain, name)) ?? []) { - const result: Effect.Effect = Reflect.apply(callback, undefined, [ - event, - ]) + for (const entry of callbacks.get(key(domain, name)) ?? []) { + if (entry.options?.providerID !== undefined && entry.options.providerID !== eventProviderID(event)) continue + const result: Effect.Effect = Reflect.apply( + entry.callback, + undefined, + [event], + ) yield* result } return event }) - const has: Interface["has"] = (domain, name) => Effect.sync(() => callbacks.has(key(domain, name))) + const has: Interface["has"] = (domain, name, providerID) => + Effect.sync(() => + (callbacks.get(key(domain, name)) ?? []).some( + (entry) => entry.options?.providerID === undefined || entry.options.providerID === providerID, + ), + ) return Service.of({ has, register, trigger }) }), diff --git a/packages/core/src/plugin/host.ts b/packages/core/src/plugin/host.ts index 288a7d2909f..0e9f780cc66 100644 --- a/packages/core/src/plugin/host.ts +++ b/packages/core/src/plugin/host.ts @@ -104,9 +104,10 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: import("../p }), }, aisdk: { - hook: (name, callback) => { + hook: (name, callback, options) => { if (name === "sdk") { return aisdk.hook.sdk((event) => { + if (options?.providerID !== undefined && options.providerID !== event.model.providerID) return Effect.void const output = { model: mutable(event.model), package: event.package, @@ -119,6 +120,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: import("../p }) } return aisdk.hook.language((event) => { + if (options?.providerID !== undefined && options.providerID !== event.model.providerID) return Effect.void const output = { model: mutable(event.model), options: event.options, @@ -382,7 +384,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: import("../p }), }, session: { - hook: (name, callback) => hooks.register("session", name, callback), + hook: (name, callback, options) => hooks.register("session", name, callback, options), create: (input) => runtime.session.create({ id: input?.id, diff --git a/packages/core/src/plugin/provider/github-copilot.ts b/packages/core/src/plugin/provider/github-copilot.ts index 9b7a0ae88c9..927bd82ba3b 100644 --- a/packages/core/src/plugin/provider/github-copilot.ts +++ b/packages/core/src/plugin/provider/github-copilot.ts @@ -241,19 +241,22 @@ export const GithubCopilotPlugin = define({ evt.sdk = mod.createOpenaiCompatible(evt.options) }), ) - yield* ctx.session.hook("http.request", (evt) => - Effect.gen(function* () { - if (evt.model.providerID !== Provider.ID.githubCopilot) return - if (evt.agent === Agent.ID.make("title")) - evt.request.headers.set("X-Interaction-Type", "conversation-background") - if (evt.agent === Agent.ID.make("compaction")) - evt.request.headers.set("X-Interaction-Type", "conversation-compaction") - const token = evt.request.headers.get("x-api-key") - if (!token) return - const text = yield* Effect.promise(() => evt.request.clone().text()) - const body = Option.getOrUndefined(decodeBody(text)) - applyHeaders(evt.request.headers, token, ctx.app, requestMetadata(evt.request.url, body), true) - }), + yield* ctx.session.hook( + "http.request", + (evt) => + Effect.gen(function* () { + if (evt.model.providerID !== Provider.ID.githubCopilot) return + if (evt.agent === Agent.ID.make("title")) + evt.request.headers.set("X-Interaction-Type", "conversation-background") + if (evt.agent === Agent.ID.make("compaction")) + evt.request.headers.set("X-Interaction-Type", "conversation-compaction") + const token = evt.request.headers.get("x-api-key") + if (!token) return + const text = yield* Effect.promise(() => evt.request.clone().text()) + const body = Option.getOrUndefined(decodeBody(text)) + applyHeaders(evt.request.headers, token, ctx.app, requestMetadata(evt.request.url, body), true) + }), + { providerID: Provider.ID.githubCopilot }, ) yield* ctx.aisdk.hook( "language", diff --git a/packages/core/src/plugin/provider/openai.ts b/packages/core/src/plugin/provider/openai.ts index 63d1f72509b..8d492350d66 100644 --- a/packages/core/src/plugin/provider/openai.ts +++ b/packages/core/src/plugin/provider/openai.ts @@ -5,7 +5,6 @@ import { App } from "../../app.js" import { Credential } from "../../credential.js" import { Bus } from "../../bus.js" import { Integration } from "../../integration.js" -import { Model } from "../../model.js" import { OauthCallbackPage } from "../../oauth/page.js" import { Provider } from "../../provider.js" import type { PluginInternal } from "../internal.js" @@ -230,15 +229,17 @@ export const OpenAIPlugin = define({ }) } }) - yield* ctx.session.hook("http.request", (evt) => - Effect.sync(() => { - if (!chatgpt || evt.model.providerID !== Provider.ID.openai) return - const url = new URL(evt.request.url) - evt.request.headers.set("originator", "opencode") - evt.request.headers.set("session-id", evt.sessionID) - if (url.origin !== "https://api.openai.com") return - evt.request = new Request(`${codexBaseURL}${url.pathname.replace(/^\/v1/, "")}${url.search}`, evt.request) - }), + yield* ctx.session.hook( + "model.request", + (evt) => + Effect.sync(() => { + if (!chatgpt) return + if (evt.baseURL && URL.canParse(evt.baseURL) && new URL(evt.baseURL).origin === "https://api.openai.com") + evt.baseURL = codexBaseURL + evt.headers.originator = "opencode" + evt.headers["session-id"] = evt.sessionID + }), + { providerID: Provider.ID.openai }, ) const refresh = () => loading.withPermit(load().pipe(Effect.andThen(ctx.catalog.reload()))) yield* bus.subscribe(Integration.Event.ConnectionUpdated).pipe( diff --git a/packages/core/src/session/compaction.ts b/packages/core/src/session/compaction.ts index dbdc5db9515..ed3fb859bde 100644 --- a/packages/core/src/session/compaction.ts +++ b/packages/core/src/session/compaction.ts @@ -12,6 +12,7 @@ import { llmClient } from "../effect/app-node-platform.js" import { SessionEvent } from "./event.js" import type { SessionMessage } from "./message.js" import { SessionModelHeaders } from "./model-headers.js" +import { SessionModelHook } from "./model-hook.js" import { SessionModelHttp } from "./model-http.js" import { SessionPromptCacheKey } from "./prompt-cache-key.js" import { App } from "../app.js" @@ -270,23 +271,25 @@ const make = (dependencies: Dependencies) => { }) : Effect.void, ) + const request = yield* SessionModelHook.apply( + dependencies.hooks, + { sessionID: plan.session.id, agent: Agent.ID.make("compaction"), model: plan.ref }, + LLM.request({ + model: plan.model, + promptCacheKey: SessionPromptCacheKey.make(plan.session.id), + http: { headers: SessionModelHeaders.make(plan.session, dependencies.app) }, + messages: [Message.user(plan.prompt)], + tools: [], + }), + ) yield* dependencies.llm - .stream( - LLM.request({ - model: plan.model, - promptCacheKey: SessionPromptCacheKey.make(plan.session.id), - http: { headers: SessionModelHeaders.make(plan.session, dependencies.app) }, - messages: [Message.user(plan.prompt)], - tools: [], + .stream(request, { + http: SessionModelHttp.middleware(dependencies.hooks, { + sessionID: plan.session.id, + agent: Agent.ID.make("compaction"), + model: plan.ref, }), - { - http: SessionModelHttp.middleware(dependencies.hooks, { - sessionID: plan.session.id, - agent: Agent.ID.make("compaction"), - model: plan.ref, - }), - }, - ) + }) .pipe( Stream.runForEach((event) => { if (LLMEvent.is.providerError(event)) diff --git a/packages/core/src/session/generate-node.ts b/packages/core/src/session/generate-node.ts index 6025bfc1bec..b5fd873b9c2 100644 --- a/packages/core/src/session/generate-node.ts +++ b/packages/core/src/session/generate-node.ts @@ -11,6 +11,7 @@ import { SessionContext } from "./context.js" import { SessionGenerate } from "./generate.js" import { SessionHistory } from "./history.js" import { SessionModelHeaders } from "./model-headers.js" +import { SessionModelHook } from "./model-hook.js" import { SessionModelHttp } from "./model-http.js" import { SessionPromptCacheKey } from "./prompt-cache-key.js" import { SessionRunnerModel } from "./runner/model.js" @@ -71,7 +72,9 @@ export const layer = Layer.effect( providerID: model.ref.providerID, modelID: model.ref.id, }) - const response = yield* llm.generate( + const request = yield* SessionModelHook.apply( + hooks, + { sessionID: selection.session.id, agent: selection.agent.id, model: model.ref }, LLM.request({ model: model.model, http: { headers: SessionModelHeaders.make(selection.session, app) }, @@ -80,14 +83,14 @@ export const layer = Layer.effect( messages: contextEvent.messages, tools: hookedTools, }), - { - http: SessionModelHttp.middleware(hooks, { - sessionID: selection.session.id, - agent: selection.agent.id, - model: model.ref, - }), - }, ) + const response = yield* llm.generate(request, { + http: SessionModelHttp.middleware(hooks, { + sessionID: selection.session.id, + agent: selection.agent.id, + model: model.ref, + }), + }) yield* Effect.logInfo("session generation usage diagnostic", { usage: response.usage }) return response.text }), diff --git a/packages/core/src/session/model-hook.ts b/packages/core/src/session/model-hook.ts new file mode 100644 index 00000000000..c75e8ced728 --- /dev/null +++ b/packages/core/src/session/model-hook.ts @@ -0,0 +1,34 @@ +export * as SessionModelHook from "./model-hook.js" + +import { HttpOptions, LanguageModel, LLMRequest } 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 { Effect } from "effect" +import { PluginHooks } from "../plugin/hooks.js" + +export const apply = ( + hooks: PluginHooks.Interface, + input: { readonly sessionID: Session.ID; readonly agent: Agent.ID; readonly model: Model.Ref }, + request: LLMRequest, +) => + Effect.gen(function* () { + const currentBaseURL = request.model.route.endpoint.baseURL + const event = yield* hooks.trigger("session", "model.request", { + ...input, + baseURL: typeof currentBaseURL === "string" ? currentBaseURL : undefined, + headers: { ...request.http?.headers }, + }) + const route = + event.baseURL !== undefined && event.baseURL !== currentBaseURL + ? request.model.route.with({ endpoint: { baseURL: event.baseURL } }) + : request.model.route + return LLMRequest.update(request, { + model: route === request.model.route ? request.model : LanguageModel.update(request.model, { route }), + http: new HttpOptions({ + body: request.http?.body, + headers: Object.keys(event.headers).length === 0 ? undefined : event.headers, + query: request.http?.query, + }), + }) + }) diff --git a/packages/core/src/session/model-request.ts b/packages/core/src/session/model-request.ts index 5d8254740d6..14ebaf6f1b2 100644 --- a/packages/core/src/session/model-request.ts +++ b/packages/core/src/session/model-request.ts @@ -14,6 +14,7 @@ import { QuestionTool } from "../tool/plugin/question.js" import { Tool } from "../tool.js" import { SessionContext } from "./context.js" import { SessionModelHeaders } from "./model-headers.js" +import { SessionModelHook } from "./model-hook.js" import { SessionModelHttp } from "./model-http.js" import { SessionModelTransport } from "./model-transport.js" import { SessionPromptCacheKey } from "./prompt-cache-key.js" @@ -226,20 +227,25 @@ export const layer = Layer.effect( return [[name, { ...tool, description: definition.description, inputSchema: definition.input }] as const] }), ) - const request = LLM.request({ - model, - http: { - headers: SessionModelHeaders.make(session, app), - }, - // TODO: Persist cache lineage so nested forks reuse the root session's cache key. - promptCacheKey: SessionPromptCacheKey.make(session.fork?.sessionID ?? session.id), - system: context.system, - messages: boundImages(unsupportedParts(context.messages, resolved.capabilities)), - tools: Array.from(hooked, ([name, tool]) => ({ ...tool, name })), - toolChoice: stepLimitReached ? "none" : undefined, - }) + const request = yield* SessionModelHook.apply( + hooks, + { sessionID: session.id, agent: agent.id, model: resolved.ref }, + LLM.request({ + model, + http: { + headers: SessionModelHeaders.make(session, app), + }, + // TODO: Persist cache lineage so nested forks reuse the root session's cache key. + promptCacheKey: SessionPromptCacheKey.make(session.fork?.sessionID ?? session.id), + system: context.system, + messages: boundImages(unsupportedParts(context.messages, resolved.capabilities)), + tools: Array.from(hooked, ([name, tool]) => ({ ...tool, name })), + toolChoice: stepLimitReached ? "none" : undefined, + }), + ) const webSocketEligible = - !(yield* hooks.has("session", "http.request")) && !(yield* hooks.has("session", "http.response")) + !(yield* hooks.has("session", "http.request", resolved.ref.providerID)) && + !(yield* hooks.has("session", "http.response", resolved.ref.providerID)) const http = webSocketEligible ? undefined : SessionModelHttp.middleware(hooks, { @@ -252,7 +258,7 @@ export const layer = Layer.effect( ...(webSocket && webSocketEligible && resolved.ref.providerID === Provider.ID.openai && - model.route.id === "openai-responses" + request.model.route.id === "openai-responses" ? { webSocket: transport.bind(session.id) } : {}), } diff --git a/packages/core/src/session/title.ts b/packages/core/src/session/title.ts index ea31303550e..5524538c0da 100644 --- a/packages/core/src/session/title.ts +++ b/packages/core/src/session/title.ts @@ -14,6 +14,7 @@ import { PluginHooks } from "../plugin/hooks.js" import { SessionEvent } from "./event.js" import { SessionHistory } from "./history.js" import { SessionModelHeaders } from "./model-headers.js" +import { SessionModelHook } from "./model-hook.js" import { SessionModelHttp } from "./model-http.js" import { SessionRunnerModel } from "./runner/model.js" import { SessionSchema } from "./schema.js" @@ -80,23 +81,25 @@ const make = (dependencies: Dependencies) => { }) : Effect.void, ) + const request = yield* SessionModelHook.apply( + dependencies.hooks, + { sessionID: session.id, agent: agent.id, model: resolved.ref }, + LLM.request({ + model: resolved.model, + http: { headers: SessionModelHeaders.make(session, dependencies.app) }, + system: agent.system, + messages: [Message.user(firstUser.text)], + tools: [], + }), + ) const streamed = yield* dependencies.llm - .stream( - LLM.request({ - model: resolved.model, - http: { headers: SessionModelHeaders.make(session, dependencies.app) }, - system: agent.system, - messages: [Message.user(firstUser.text)], - tools: [], + .stream(request, { + http: SessionModelHttp.middleware(dependencies.hooks, { + sessionID: session.id, + agent: agent.id, + model: resolved.ref, }), - { - http: SessionModelHttp.middleware(dependencies.hooks, { - sessionID: session.id, - agent: agent.id, - model: resolved.ref, - }), - }, - ) + }) .pipe( Stream.runForEach((event) => { if (LLMEvent.is.providerError(event)) failed = true diff --git a/packages/core/test/plugin/promise.test.ts b/packages/core/test/plugin/promise.test.ts index 056b823d6aa..d80fd7968bd 100644 --- a/packages/core/test/plugin/promise.test.ts +++ b/packages/core/test/plugin/promise.test.ts @@ -320,10 +320,14 @@ describe("fromPromise", () => { define({ id: "promise-session-http", setup: async (ctx) => { - await ctx.session.hook("http.request", (event) => { - event.request = new Request("https://provider.test/changed", event.request) - event.request.headers.set("x-hook", "promise") - }) + await ctx.session.hook( + "http.request", + (event) => { + event.request = new Request("https://provider.test/changed", event.request) + event.request.headers.set("x-hook", "promise") + }, + { providerID: "test" }, + ) await ctx.session.hook("http.response", async (event) => { event.response = new Response(`${await event.response.text()}-response`, { status: event.response.status, @@ -342,6 +346,11 @@ describe("fromPromise", () => { ...context, request: new Request("https://provider.test", { method: "POST", body: "payload" }), }) + const ignored = yield* hooks.trigger("session", "http.request", { + ...context, + model: Model.Ref.make({ providerID: Provider.ID.make("other"), id: Model.ID.make("model") }), + request: new Request("https://other.test"), + }) const response = yield* hooks.trigger("session", "http.response", { ...context, request: request.request, @@ -349,6 +358,9 @@ describe("fromPromise", () => { }) expect(request.request.url).toBe("https://provider.test/changed") + expect(ignored.request.url).toBe("https://other.test/") + expect(yield* hooks.has("session", "http.request", Provider.ID.make("test"))).toBe(true) + expect(yield* hooks.has("session", "http.request", Provider.ID.make("other"))).toBe(false) expect(yield* Effect.promise(() => response.response.text())).toBe("promise-response") }), ) diff --git a/packages/core/test/plugin/provider-openai.test.ts b/packages/core/test/plugin/provider-openai.test.ts index 150d33a38e6..18c0c8e36fe 100644 --- a/packages/core/test/plugin/provider-openai.test.ts +++ b/packages/core/test/plugin/provider-openai.test.ts @@ -1,17 +1,25 @@ import { Money } from "@opencode-ai/schema/money" import { Agent } from "@opencode-ai/schema/agent" import { Session } from "@opencode-ai/schema/session" +import { OpenAIResponses } from "@opencode-ai/ai/protocols/openai-responses" import { describe, expect } from "bun:test" -import { Effect } from "effect" +import { ConfigProvider, DateTime, Effect } from "effect" import { Catalog } from "@opencode-ai/core/catalog" import { Credential } from "@opencode-ai/core/credential" import { Integration } from "@opencode-ai/core/integration" +import { Location } from "@opencode-ai/core/location" import { Model } from "@opencode-ai/core/model" import { Plugin } from "@opencode-ai/core/plugin" import { PluginHost } from "@opencode-ai/core/plugin/host" import { PluginHooks } from "@opencode-ai/core/plugin/hooks" +import { GithubCopilotPlugin } from "@opencode-ai/core/plugin/provider/github-copilot" import { OpenAIPlugin } from "@opencode-ai/core/plugin/provider/openai" +import { Project } from "@opencode-ai/core/project" import { Provider } from "@opencode-ai/core/provider" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { SessionModelRequest } from "@opencode-ai/core/session/model-request" +import { SessionModelTransport } from "@opencode-ai/core/session/model-transport" +import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model" import { testEffect } from "../lib/effect" import { PluginTestLayer } from "./fixture" @@ -24,19 +32,33 @@ const addPlugin = Effect.fn(function* () { yield* OpenAIPlugin.effect(host).pipe(Effect.provideService(Integration.Service, integrations)) }) +const addGithubCopilotPlugin = Effect.fn(function* () { + const plugin = yield* Plugin.Service + const host = yield* PluginHost.make(plugin) + yield* GithubCopilotPlugin.effect(host) +}) + function required(value: T | undefined): T { if (value === undefined) throw new Error("Expected value") return value } -const http = Effect.fn(function* (providerID: Provider.ID, url: string) { - const event = yield* (yield* PluginHooks.Service).trigger("session", "http.request", { +const request = Effect.fn(function* (providerID: Provider.ID, baseURL: string) { + const hooks = yield* PluginHooks.Service + const event = yield* hooks.trigger("session", "model.request", { sessionID: Session.ID.make("ses_test"), agent: Agent.ID.make("build"), model: Model.Ref.make({ providerID, id: Model.ID.make("gpt-5.5") }), - request: new Request(url, { method: "POST", body: "{}" }), + baseURL, + headers: {}, }) - return { url: event.request.url, headers: Object.fromEntries(event.request.headers.entries()) } + return { + baseURL: event.baseURL, + headers: event.headers, + hasHttpHooks: + (yield* hooks.has("session", "http.request", providerID)) || + (yield* hooks.has("session", "http.response", providerID)), + } }) describe("OpenAIPlugin", () => { @@ -110,18 +132,19 @@ 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 direct = yield* request(Provider.ID.openai, "https://api.openai.com/v1") + const custom = yield* request(Provider.ID.make("custom-openai"), "https://custom.example/v1") + const proxy = yield* request(Provider.ID.openai, "https://proxy.example/v1?region=us") const provider = required(yield* catalog.provider.get(Provider.ID.openai)) expect(provider.package).toBe("@opencode-ai/ai/providers/openai") expect(provider.settings).toMatchObject({ baseURL: "https://chatgpt.com/backend-api/codex" }) expect(provider.headers).toMatchObject({ originator: "opencode", "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(direct.baseURL).toBe("https://chatgpt.com/backend-api/codex") + expect(direct.headers).toMatchObject({ originator: "opencode", "session-id": "ses_test" }) + expect(direct.hasHttpHooks).toBe(false) expect(custom.headers).not.toHaveProperty("originator") - expect(proxy.url).toBe("https://proxy.example/v1/responses?region=us") + expect(proxy.baseURL).toBe("https://proxy.example/v1?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"))) expect(eligible.package).toBe("@opencode-ai/ai/providers/openai") @@ -167,16 +190,77 @@ describe("OpenAIPlugin", () => { }) yield* addPlugin() - const request = yield* http(Provider.ID.openai, "https://api.openai.com/v1/responses") + const direct = yield* request(Provider.ID.openai, "https://api.openai.com/v1") const provider = required(yield* catalog.provider.get(Provider.ID.openai)) 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(direct.headers).not.toHaveProperty("originator") + expect(direct.hasHttpHooks).toBe(false) expect(provider.headers).not.toHaveProperty("originator") expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-4.1"))).enabled).toBe(true) }), ) + + it.effect("selects WebSocket with the built-in provider hooks enabled", () => + Effect.gen(function* () { + const credentials = yield* Credential.Service + yield* credentials.create({ + integrationID: Integration.ID.make("openai"), + value: Credential.Key.make({ type: "key", key: "sk-test" }), + }) + yield* addPlugin() + yield* addGithubCopilotPlugin() + const executor = { execute: () => Effect.die("unused WebSocket execution") } + const transport = SessionModelTransport.Service.of({ + bind: () => executor, + close: () => Effect.void, + closeAll: Effect.void, + }) + const sessionID = Session.ID.make("ses_websocket_hooks") + const agentID = Agent.ID.make("build") + const agent = Agent.Info.make(Agent.Info.default(agentID)) + const model = SessionRunnerModel.resolved(OpenAIResponses.route.model({ id: "gpt-5.5" }), { + capabilities: { tools: true, input: ["text"], output: ["text"] }, + cost: [], + }) + const program = Effect.gen(function* () { + const requests = yield* SessionModelRequest.Service + return yield* requests.prepare({ + context: { + session: Session.Info.make({ + id: sessionID, + projectID: Project.ID.global, + cost: Money.USD.zero, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) }, + location: Location.Ref.make({ directory: AbsolutePath.make("/project") }), + }), + agent: { id: agentID, info: agent }, + model, + initial: "", + messages: [], + tools: { definitions: [], execute: () => Effect.die("unused tool execution") }, + }, + step: 1, + }) + }).pipe( + Effect.provide(SessionModelRequest.layer), + Effect.provideService(SessionModelTransport.Service, transport), + Effect.provide( + ConfigProvider.layer( + ConfigProvider.fromEnv({ env: { OPENCODE_EXPERIMENTAL_OPENAI_RESPONSES_WEBSOCKET: "true" } }), + ), + ), + ) + + const prepared = yield* program + + expect(prepared.webSocketEligible).toBe(true) + expect(prepared.options.webSocket).toBe(executor) + expect(prepared.options.http).toBeUndefined() + }), + ) }) diff --git a/packages/core/test/session-generate.test.ts b/packages/core/test/session-generate.test.ts index 10dfd3bca70..0389f2b45b4 100644 --- a/packages/core/test/session-generate.test.ts +++ b/packages/core/test/session-generate.test.ts @@ -291,18 +291,25 @@ it.effect( instruction = "Changed context" const before = yield* durableState(db, sessionID) const hooks = yield* PluginHooks.Service + let modelRequestHook = false yield* hooks.register("session", "context", (event) => Effect.sync(() => { event.system = [SystemPart.make("Hooked system"), ...event.system] if (event.tools.lookup) event.tools.lookup.description = "Hooked lookup" }), ) + yield* hooks.register("session", "model.request", () => + Effect.sync(() => { + modelRequestHook = true + }), + ) const generate = yield* SessionGenerate.Service const result = yield* generate.generate({ sessionID, prompt: "Summarize privately" }) expect(result).toBe("Transient answer") expect(requests).toHaveLength(1) + expect(modelRequestHook).toBe(true) expect(hasHttpMiddleware).toBe(true) expect(requests[0]?.model).toBe(model) expect(requests[0]?.system[0]?.text).toBe("Hooked system") diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index d0167a6cec7..c4d31dac420 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -1020,6 +1020,36 @@ describe("SessionRunnerLLM", () => { }), ) + it.effect("keeps WebSocket eligibility after model request hooks", () => + Effect.gen(function* () { + yield* setup + const hooks = yield* PluginHooks.Service + yield* hooks.register("session", "model.request", (event) => + Effect.sync(() => { + event.headers["x-model-request-hook"] = "active" + }), + ) + yield* hooks.register("session", "http.request", () => Effect.die("Other-provider HTTP hook should not apply"), { + providerID: Provider.ID.githubCopilot, + }) + const context = yield* SessionContext.Service + const modelRequests = yield* SessionModelRequest.Service + const selected = yield* context.select(sessionID) + const database = yield* Database.Service + const bus = yield* Bus.Service + yield* InstructionState.prepare(database.db, bus, selected.instructions, sessionID) + + const prepared = yield* modelRequests.prepare({ + context: yield* context.load(selected), + step: 1, + }) + + expect(prepared.request.http?.headers?.["x-model-request-hook"]).toBe("active") + expect(prepared.webSocketEligible).toBe(true) + expect(prepared.options.http).toBeUndefined() + }), + ) + it.effect("forces HTTP and triggers active request and response hooks once", () => Effect.gen(function* () { yield* setup diff --git a/packages/plugin/src/effect/aisdk.ts b/packages/plugin/src/effect/aisdk.ts index 9539e7b9876..37d68179cd2 100644 --- a/packages/plugin/src/effect/aisdk.ts +++ b/packages/plugin/src/effect/aisdk.ts @@ -1,6 +1,6 @@ import type { LanguageModelV3 } from "@ai-sdk/provider" import type { Model } from "@opencode-ai/schema/model" -import type { Hooks } from "./registration.js" +import type { ModelHooks } from "./registration.js" export interface AISDKHooks { sdk: { @@ -18,5 +18,5 @@ export interface AISDKHooks { } export interface AISDKDomain { - readonly hook: Hooks + readonly hook: ModelHooks } diff --git a/packages/plugin/src/effect/registration.ts b/packages/plugin/src/effect/registration.ts index b7cc13328ab..174d2d49d31 100644 --- a/packages/plugin/src/effect/registration.ts +++ b/packages/plugin/src/effect/registration.ts @@ -4,6 +4,11 @@ export interface Registration { readonly dispose: Effect.Effect } +export interface ModelHookOptions { + /** Limits the hook to one provider. Unscoped hooks apply to every provider. */ + readonly providerID?: string +} + export type Hooks = Record> = < Name extends keyof Spec, >( @@ -11,4 +16,12 @@ export type Hooks = Record Effect.Effect, ) => Effect.Effect +export type ModelHooks = Record> = < + Name extends keyof Spec, +>( + name: Name, + callback: (input: Spec[Name]) => Effect.Effect, + options?: ModelHookOptions, +) => Effect.Effect + export type Transform = (callback: (input: Input) => void) => Effect.Effect diff --git a/packages/plugin/src/effect/session.ts b/packages/plugin/src/effect/session.ts index c4564c2cd61..41ea2918903 100644 --- a/packages/plugin/src/effect/session.ts +++ b/packages/plugin/src/effect/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 { ModelHooks } from "./registration.js" export interface SessionContext { readonly sessionID: Session.ID @@ -15,6 +15,14 @@ export interface SessionContext { tools: Record } +export interface SessionModelRequest { + readonly sessionID: Session.ID + readonly agent: Agent.ID + readonly model: Model.Ref + baseURL?: string + headers: Record +} + export interface SessionHttpRequest { readonly sessionID: Session.ID readonly agent: Agent.ID @@ -32,6 +40,7 @@ export interface SessionHttpResponse { export interface SessionHooks { readonly context: SessionContext + readonly "model.request": SessionModelRequest readonly "http.request": SessionHttpRequest readonly "http.response": SessionHttpResponse } @@ -40,5 +49,5 @@ export type SessionDomain = Pick< SessionApi, "create" | "get" | "prompt" | "generate" | "command" | "synthetic" | "interrupt" | "rename" | "wait" > & { - readonly hook: Hooks + readonly hook: ModelHooks } diff --git a/packages/plugin/src/promise/adapter.ts b/packages/plugin/src/promise/adapter.ts index 13164d42ac9..4a8c86a1659 100644 --- a/packages/plugin/src/promise/adapter.ts +++ b/packages/plugin/src/promise/adapter.ts @@ -130,8 +130,10 @@ export function fromPromise(plugin: Plugin) { reload: () => run(host.agent.reload()), }, aisdk: { - hook: (name, callback) => - register(host.aisdk.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))), + hook: (name, callback, options) => + register( + host.aisdk.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))), options), + ), }, catalog: { provider: { @@ -295,8 +297,10 @@ export function fromPromise(plugin: Plugin) { ), }, session: { - hook: (name, callback) => - register(host.session.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))), + hook: (name, callback, options) => + register( + host.session.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))), options), + ), create: adaptApiMethod(SessionEndpoints["session.create"], host.session.create), get: adaptApiMethod(SessionEndpoints["session.get"], host.session.get), prompt: adaptApiMethod(SessionEndpoints["session.prompt"], host.session.prompt), diff --git a/packages/plugin/src/promise/aisdk.ts b/packages/plugin/src/promise/aisdk.ts index 9539e7b9876..37d68179cd2 100644 --- a/packages/plugin/src/promise/aisdk.ts +++ b/packages/plugin/src/promise/aisdk.ts @@ -1,6 +1,6 @@ import type { LanguageModelV3 } from "@ai-sdk/provider" import type { Model } from "@opencode-ai/schema/model" -import type { Hooks } from "./registration.js" +import type { ModelHooks } from "./registration.js" export interface AISDKHooks { sdk: { @@ -18,5 +18,5 @@ export interface AISDKHooks { } export interface AISDKDomain { - readonly hook: Hooks + readonly hook: ModelHooks } diff --git a/packages/plugin/src/promise/registration.ts b/packages/plugin/src/promise/registration.ts index 0537c1b98ee..1886496b8da 100644 --- a/packages/plugin/src/promise/registration.ts +++ b/packages/plugin/src/promise/registration.ts @@ -2,9 +2,20 @@ export interface Registration { readonly dispose: () => Promise } +export interface ModelHookOptions { + /** Limits the hook to one provider. Unscoped hooks apply to every provider. */ + readonly providerID?: string +} + export type Hooks = ( name: Name, callback: (input: Spec[Name]) => Promise | void, ) => Promise +export type ModelHooks = ( + name: Name, + callback: (input: Spec[Name]) => Promise | void, + options?: ModelHookOptions, +) => Promise + export type Transform = (callback: (input: Input) => void) => Promise diff --git a/packages/plugin/src/promise/session.ts b/packages/plugin/src/promise/session.ts index 0a59c75c399..d4ea141154a 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 { ModelHooks } from "./registration.js" export interface SessionContext { readonly sessionID: Session.ID @@ -15,6 +15,14 @@ export interface SessionContext { tools: Record } +export interface SessionModelRequest { + readonly sessionID: Session.ID + readonly agent: Agent.ID + readonly model: Model.Ref + baseURL?: string + headers: Record +} + export interface SessionHttpRequest { readonly sessionID: Session.ID readonly agent: Agent.ID @@ -32,6 +40,7 @@ export interface SessionHttpResponse { export interface SessionHooks { readonly context: SessionContext + readonly "model.request": SessionModelRequest readonly "http.request": SessionHttpRequest readonly "http.response": SessionHttpResponse } @@ -40,5 +49,5 @@ export type SessionDomain = Pick< SessionApi, "create" | "get" | "prompt" | "generate" | "command" | "synthetic" | "interrupt" | "rename" | "wait" > & { - readonly hook: Hooks + readonly hook: ModelHooks }