Compare commits

...
1 Commits
Author SHA1 Message Date
Aiden Cline 71d7a84685 feat(plugin): add experimental WebSocket session hooks 2026-09-10 00:41:18 -05:00
10 changed files with 300 additions and 24 deletions
+33 -12
View File
@@ -178,29 +178,50 @@ export const AzurePlugin = define({
Effect.forkScoped({ startImmediately: true }),
)
// Entra bearer tokens are minted per request from the target URL's scope, so they are injected at the
// transport hooks rather than stored as a credential.
const bearer = Effect.fn(function* (url: string) {
const connection = yield* ctx.integration.connection.active(Provider.ID.azure)
const credential = connection
? yield* ctx.integration.connection.resolve(connection).pipe(Effect.orElseSucceed(() => undefined))
: undefined
if (credential?.type !== "oauth" || credential.methodID !== methodID) return
const target = new URL(url)
const scope =
target.hostname.endsWith(".services.ai.azure.com") && !target.pathname.startsWith("/models")
? foundryScope
: cognitiveScope
const current = yield* token(scope).pipe(Effect.orDie)
return `Bearer ${current.access}`
})
yield* ctx.session.hook(
"http.request",
(evt) =>
Effect.gen(function* () {
if (evt.model.providerID !== Provider.ID.azure) return
const connection = yield* ctx.integration.connection.active(Provider.ID.azure)
const credential = connection
? yield* ctx.integration.connection.resolve(connection).pipe(Effect.orElseSucceed(() => undefined))
: undefined
if (credential?.type !== "oauth" || credential.methodID !== methodID) return
const url = new URL(evt.request.url)
const scope =
url.hostname.endsWith(".services.ai.azure.com") && !url.pathname.startsWith("/models")
? foundryScope
: cognitiveScope
const current = yield* token(scope).pipe(Effect.orDie)
const authorization = yield* bearer(evt.request.url)
if (!authorization) return
evt.request.headers.delete("api-key")
evt.request.headers.delete("x-api-key")
evt.request.headers.set("authorization", `Bearer ${current.access}`)
evt.request.headers.set("authorization", authorization)
evt.request.headers.set("user-agent", App.useragent(ctx.app))
}),
{ providerID: Provider.ID.azure },
)
yield* ctx.session.hook(
"experimental.ws.handshake",
(evt) =>
Effect.gen(function* () {
if (evt.model.providerID !== Provider.ID.azure) return
const authorization = yield* bearer(evt.url)
if (!authorization) return
delete evt.headers["api-key"]
delete evt.headers["x-api-key"]
evt.headers.authorization = authorization
evt.headers["user-agent"] = App.useragent(ctx.app)
}),
{ providerID: Provider.ID.azure },
)
yield* ctx.aisdk.hook(
"sdk",
+17 -6
View File
@@ -316,17 +316,28 @@ export const layer = Layer.effect(
return HttpClientResponse.fromWeb(sent, after.response)
}).pipe(Effect.mapError((cause) => (cause instanceof Error ? cause : new Error(String(cause)))))
: undefined
// HTTP hooks must observe every request, so they keep the provider on HTTP.
const webSocket =
input.webSocket === "session" &&
!hasHttpHooks &&
model.capabilities.responsesWebsockets === true &&
model.websocket
input.webSocket === "session" && model.capabilities.responsesWebsockets === true && model.websocket
const interceptor: SessionModelTransport.Interceptor = {
handshake: (connect) =>
hooks.trigger("session", "experimental.ws.handshake", {
...scope,
url: connect.url,
headers: connect.headers,
}),
send: (frame, mode) =>
hooks.trigger("session", "experimental.ws.send", { ...scope, mode, frame }).pipe(Effect.map((e) => e.frame)),
receive: (frame) =>
hooks.trigger("session", "experimental.ws.receive", { ...scope, frame }).pipe(Effect.map((e) => e.frame)),
}
return {
event: shaped,
request,
options: { ...(http ? { http } : {}), ...(webSocket ? { webSocket: transport.bind(session.id) } : {}) },
options: {
...(http ? { http } : {}),
...(webSocket ? { webSocket: transport.bind(session.id, interceptor) } : {}),
},
retry: (event: Parameters<Prepared["retry"]>[0]) =>
hooks.trigger("session", "retry", event).pipe(Effect.asVoid),
// Permission.assert and the question tool throw declines as defects so tools cannot
+30 -6
View File
@@ -2,6 +2,7 @@ export * as SessionModelTransport from "./model-transport.js"
import {
WebSocketTransport,
type ChannelCreate,
type ChannelObservation,
type ChannelCheckpoint,
type WebSocketChannelExchange,
@@ -13,6 +14,7 @@ import {
import { AIError, AIErrorReason, TransportError, type TransportOperation } from "@opencode/ai"
import { Hash } from "@opencode/util/hash"
import { Cause, Clock, Context, Effect, Fiber, Layer, Metric, Queue, Scope, Semaphore, Stream } from "effect"
import { Headers } from "effect/unstable/http"
import { Socket } from "effect/unstable/socket"
import { makeGlobalNode } from "@opencode/util/effect/app-node"
import { SessionSchema } from "./schema.js"
@@ -52,8 +54,18 @@ interface State {
channel?: Channel
}
/** Per-exchange plugin hooks. `handshake` output selects the connection; frames are what crosses the wire. */
export interface Interceptor {
readonly handshake: (connect: {
readonly url: string
readonly headers: Record<string, string>
}) => Effect.Effect<{ readonly url: string; readonly headers: Record<string, string> }>
readonly send: (frame: string, mode: ChannelCreate["mode"]) => Effect.Effect<string>
readonly receive: (frame: string) => Effect.Effect<string>
}
export interface Interface {
readonly bind: (sessionID: SessionSchema.ID) => WebSocketChannelExecutor
readonly bind: (sessionID: SessionSchema.ID, interceptor?: Interceptor) => WebSocketChannelExecutor
readonly close: (sessionID: SessionSchema.ID) => Effect.Effect<void>
readonly closeAll: Effect.Effect<void>
}
@@ -267,7 +279,8 @@ export const makeLayer = (connector: WebSocketConnector) =>
const start = Effect.fn("SessionModelTransport.start")(function* (
owner: State,
exchange: WebSocketChannelExchange,
input: WebSocketChannelExchange,
interceptor?: Interceptor,
) {
if (owner.closed)
return yield* transportError("Session WebSocket owner is closed", {
@@ -276,7 +289,16 @@ export const makeLayer = (connector: WebSocketConnector) =>
phase: "queue",
delivery: "not-sent",
})
if (owner.httpFallback) return fallback(exchange)
if (owner.httpFallback) return fallback(input)
const handshake = interceptor
? yield* interceptor.handshake({ url: input.connect.url, headers: { ...input.connect.headers } })
: undefined
const exchange: WebSocketChannelExchange = handshake
? {
...input,
connect: { ...input.connect, url: handshake.url, headers: Headers.fromInput(handshake.headers) },
}
: input
const key = affinity(exchange)
const now = yield* Clock.currentTimeMillis
const current = owner.channel
@@ -337,6 +359,7 @@ export const makeLayer = (connector: WebSocketConnector) =>
Effect.onInterrupt(() => closeChannel(owner, channel)),
)
if (create.mode === "full") channel.checkpoint = undefined
const message = interceptor ? yield* interceptor.send(create.message, create.mode) : create.message
yield* Effect.logDebug("session websocket sending", {
sessionTransport: "websocket",
phase: "send",
@@ -347,7 +370,7 @@ export const makeLayer = (connector: WebSocketConnector) =>
delivery: "send-attempted",
}
channel.active = active
const sent = yield* channel.connection.sendText(create.message).pipe(
const sent = yield* channel.connection.sendText(message).pipe(
Effect.withSpan("SessionModelTransport.send"),
Effect.onInterrupt(() => closeChannel(owner, channel)),
Effect.result,
@@ -388,6 +411,7 @@ export const makeLayer = (connector: WebSocketConnector) =>
}),
),
}),
Stream.mapEffect((frame) => (interceptor ? interceptor.receive(frame) : Effect.succeed(frame))),
Stream.mapEffect((frame) => exchange.driver.observe(create, frame)),
Stream.tap((observation) =>
Effect.sync(() => {
@@ -465,7 +489,7 @@ export const makeLayer = (connector: WebSocketConnector) =>
return { frames, complete, http: channel.connection.http }
})
const bind = (sessionID: SessionSchema.ID): WebSocketChannelExecutor => ({
const bind = (sessionID: SessionSchema.ID, interceptor?: Interceptor): WebSocketChannelExecutor => ({
execute: (exchange) => {
const owner = state(sessionID)
let execution: WebSocketChannelExecution | undefined
@@ -475,7 +499,7 @@ export const makeLayer = (connector: WebSocketConnector) =>
},
frames: Stream.unwrap(
Effect.acquireRelease(owner.lock.take(1), () => owner.lock.release(1), { interruptible: true }).pipe(
Effect.andThen(start(owner, exchange)),
Effect.andThen(start(owner, exchange, interceptor)),
Effect.tap((started) =>
Effect.sync(() => {
execution = started
@@ -303,6 +303,20 @@ describe("AzurePlugin", () => {
})
expect(foundry.request.headers.get("authorization")).toBe("Bearer https://ai.azure.com/.default-token")
expect(foundry.request.headers.has("x-api-key")).toBe(false)
const handshake = yield* hooks.trigger("session", "experimental.ws.handshake", {
sessionID: Session.ID.make("ses_azure_ws"),
agent: Agent.ID.make("build"),
model,
kind: "primary",
url: "wss://test-resource.openai.azure.com/openai/v1/responses",
headers: { "api-key": "stored-token", "x-keep": "yes" },
})
expect(handshake.headers).toMatchObject({
authorization: "Bearer https://cognitiveservices.azure.com/.default-token",
"x-keep": "yes",
})
expect(handshake.headers["api-key"]).toBeUndefined()
}),
),
)
@@ -79,4 +79,68 @@ describe("SessionModelRequest HTTP hooks", () => {
)
}).pipe(Effect.provideService(SessionModelTransport.Service, transport)),
)
it.effect("runs experimental.ws hooks through the transport interceptor alongside http hooks", () =>
Effect.gen(function* () {
const hooks = yield* PluginHooks.Service
const seen: Array<string> = []
yield* hooks.register("session", "http.request", () => Effect.void)
yield* hooks.register("session", "experimental.ws.handshake", (event) =>
Effect.sync(() => {
seen.push(`handshake:${event.kind}:${event.agent}`)
event.url = `${event.url}?hooked`
event.headers.authorization = "Bearer hooked"
}),
)
yield* hooks.register("session", "experimental.ws.send", (event) =>
Effect.sync(() => {
seen.push(`send:${event.kind}:${event.mode}`)
event.frame = `${event.frame}:sent`
}),
)
yield* hooks.register("session", "experimental.ws.receive", (event) =>
Effect.sync(() => {
seen.push(`receive:${event.kind}`)
event.frame = `${event.frame}:received`
}),
)
let interceptor: SessionModelTransport.Interceptor | undefined
const capturing = SessionModelTransport.Service.of({
bind: (_sessionID, bound) => {
interceptor = bound
return { execute: () => Effect.die("unused WebSocket execution") }
},
close: () => Effect.void,
closeAll: Effect.void,
})
const requests = yield* SessionModelRequest.Service.pipe(
Effect.provide(SessionModelRequest.layer),
Effect.provideService(SessionModelTransport.Service, capturing),
)
const prepared = yield* requests.compaction({
session,
agent: Agent.ID.make("build"),
model: SessionRunnerModel.resolved(OpenAIChat.route.model({ id: "gpt-5.5", provider: "test" }), {
capabilities: { tools: true, input: ["text"], output: ["text"], responsesWebsockets: true },
cost: [],
limit: { context: 200_000, output: 32_000 },
websocket: true,
}),
system: [],
messages: [],
webSocket: "session",
})
expect(prepared.options.http).toBeDefined()
expect(prepared.options.webSocket).toBeDefined()
if (!interceptor) throw new Error("Expected the transport to receive an interceptor")
expect(yield* interceptor.handshake({ url: "wss://example.test/v1/responses", headers: {} })).toMatchObject({
url: "wss://example.test/v1/responses?hooked",
headers: { authorization: "Bearer hooked" },
})
expect(yield* interceptor.send("frame", "incremental")).toBe("frame:sent")
expect(yield* interceptor.receive("frame")).toBe("frame:received")
expect(seen).toEqual(["handshake:compaction:build", "send:compaction:incremental", "receive:compaction"])
}),
)
})
@@ -822,6 +822,36 @@ describe("SessionModelTransport", () => {
)
})
test("runs interceptors on the handshake and both frame directions", async () => {
const fixture = automatic()
const seen: Array<string> = []
let authorization = "one"
await run(
fixture.connector,
Effect.gen(function* () {
const transport = yield* SessionModelTransport.Service
const executor = transport.bind(session, {
handshake: (connect) =>
Effect.succeed({ url: `${connect.url}?hooked`, headers: { ...connect.headers, authorization } }),
send: (frame, mode) => Effect.succeed(`${frame}:${mode}`),
receive: (frame) =>
Effect.sync(() => {
seen.push(frame)
return frame.toUpperCase()
}),
})
expect(yield* collect(executor, exchange("first"))).toEqual(["COMPLETED:FIRST:FULL"])
authorization = "two"
expect(yield* collect(executor, exchange("second"))).toEqual(["COMPLETED:SECOND:FULL"])
expect(seen).toEqual(["completed:first:full", "completed:second:full"])
expect(fixture.connections).toHaveLength(2)
expect(fixture.connections.map((item) => item.headers.authorization)).toEqual(["one", "two"])
expect(fixture.connections.map((item) => item.sent)).toEqual([["first:full"], ["second:full"]])
}),
)
})
test("rotates when the connection exceeds its requested age limit", async () => {
const fixture = automatic()
+31
View File
@@ -86,6 +86,34 @@ export interface SessionHttpResponse {
response: Response
}
/** Connection a WebSocket request needs. Changing `url` or `headers` reopens the Session's socket. */
export interface SessionWebSocketHandshake {
readonly sessionID: Session.ID
readonly agent: Agent.ID
readonly model: Model.Ref
readonly kind: SessionRequestKind
url: string
headers: Record<string, string>
}
export interface SessionWebSocketSend {
readonly sessionID: Session.ID
readonly agent: Agent.ID
readonly model: Model.Ref
readonly kind: SessionRequestKind
/** Incremental frames carry only what changed since the provider's last checkpoint. */
readonly mode: "full" | "incremental"
frame: string
}
export interface SessionWebSocketReceive {
readonly sessionID: Session.ID
readonly agent: Agent.ID
readonly model: Model.Ref
readonly kind: SessionRequestKind
frame: string
}
export type SessionRetryDecision = { retry: false } | { retry: true; delay: number }
export interface SessionRetry {
@@ -106,6 +134,9 @@ export interface SessionHooks {
readonly "model.request": SessionModelRequest
readonly "http.request": SessionHttpRequest
readonly "http.response": SessionHttpResponse
readonly "experimental.ws.handshake": SessionWebSocketHandshake
readonly "experimental.ws.send": SessionWebSocketSend
readonly "experimental.ws.receive": SessionWebSocketReceive
readonly retry: SessionRetry
}
+31
View File
@@ -86,6 +86,34 @@ export interface SessionHttpResponse {
response: Response
}
/** Connection a WebSocket request needs. Changing `url` or `headers` reopens the Session's socket. */
export interface SessionWebSocketHandshake {
readonly sessionID: Session.ID
readonly agent: Agent.ID
readonly model: Model.Ref
readonly kind: SessionRequestKind
url: string
headers: Record<string, string>
}
export interface SessionWebSocketSend {
readonly sessionID: Session.ID
readonly agent: Agent.ID
readonly model: Model.Ref
readonly kind: SessionRequestKind
/** Incremental frames carry only what changed since the provider's last checkpoint. */
readonly mode: "full" | "incremental"
frame: string
}
export interface SessionWebSocketReceive {
readonly sessionID: Session.ID
readonly agent: Agent.ID
readonly model: Model.Ref
readonly kind: SessionRequestKind
frame: string
}
export type SessionRetryDecision = { retry: false } | { retry: true; delay: number }
export interface SessionRetry {
@@ -106,6 +134,9 @@ export interface SessionHooks {
readonly "model.request": SessionModelRequest
readonly "http.request": SessionHttpRequest
readonly "http.response": SessionHttpResponse
readonly "experimental.ws.handshake": SessionWebSocketHandshake
readonly "experimental.ws.send": SessionWebSocketSend
readonly "experimental.ws.receive": SessionWebSocketReceive
readonly retry: SessionRetry
}
@@ -1151,6 +1151,24 @@ effect: (ctx) =>
}),
```
WebSocket providers do not issue one HTTP request per model call, so the HTTP hooks never see that traffic. Three
experimental hooks cover it: `experimental.ws.handshake` runs once per model call with the URL and headers the connection
needs (changing either reopens the session's socket), `experimental.ws.send` runs on the outbound frame, and
`experimental.ws.receive` on every inbound frame. Incremental `send` frames carry only what changed since the provider's
last checkpoint; rewriting them changes what the provider sees without changing what OpenCode believes it sent.
```ts
effect: (ctx) =>
Effect.gen(function* () {
yield* ctx.session.hook("experimental.ws.handshake", (event) =>
Effect.sync(() => {
event.headers.authorization = `Bearer ${token}`
}),
)
yield* ctx.session.hook("experimental.ws.receive", (event) => Effect.log(event.frame))
}),
```
Override the retry decision for a provider failure or replace its delay in milliseconds. The hook runs after OpenCode
classifies the failure and proposes its policy, but before any retry is scheduled. It does not expose how OpenCode
internally performs the next attempt.
@@ -1191,6 +1209,9 @@ interface SessionHooks {
readonly "model.request": SessionModelRequest
readonly "http.request": SessionHttpRequest
readonly "http.response": SessionHttpResponse
readonly "experimental.ws.handshake": SessionWebSocketHandshake
readonly "experimental.ws.send": SessionWebSocketSend
readonly "experimental.ws.receive": SessionWebSocketReceive
readonly retry: SessionRetry
}
@@ -1294,6 +1294,32 @@ await ctx.session.hook("http.response", (event) => {
})
```
#### WebSocket (experimental)
Providers that stream over a WebSocket do not issue one HTTP request per model call, so `http.request` and
`http.response` never see that traffic. Three experimental hooks cover it instead. `experimental.ws.handshake` runs
once per model call with the URL and headers the connection needs; changing either reopens the session's socket.
`experimental.ws.send` runs on the outbound frame, and `experimental.ws.receive` on every inbound frame. All three carry
the same `sessionID`, `agent`, `model`, and `kind` as the HTTP hooks.
```ts
await ctx.session.hook("experimental.ws.handshake", (event) => {
event.headers.authorization = `Bearer ${token}`
})
await ctx.session.hook("experimental.ws.send", (event) => {
if (event.mode === "full") event.frame = redact(event.frame)
})
await ctx.session.hook("experimental.ws.receive", (event) => {
log(event.frame)
})
```
`send` frames in `"incremental"` mode carry only what changed since the provider's last checkpoint. Rewriting them
changes what the provider sees without changing what OpenCode believes it sent, so treat them as read-only unless you
also handle the resulting drift.
#### Retry policy
Override the retry decision for a provider failure or replace its delay in milliseconds. The hook runs after OpenCode
@@ -1339,6 +1365,9 @@ interface SessionHooks {
"model.request": SessionModelRequestHook
"http.request": SessionHttpRequestHook
"http.response": SessionHttpResponseHook
"experimental.ws.handshake": SessionWebSocketHandshakeHook
"experimental.ws.send": SessionWebSocketSendHook
"experimental.ws.receive": SessionWebSocketReceiveHook
retry: SessionRetryHook
}