Skip to content

feat(plugin): add experimental WebSocket handshake hook - #49131

Merged
rekram1-node merged 1 commit into
v2from
ws-hooks-v2
Sep 15, 2026
Merged

rekram1-node merged 1 commit into
v2from
ws-hooks-v2

Conversation

@rekram1-node

@rekram1-node rekram1-node commented Sep 15, 2026

Copy link
Copy Markdown
Collaborator

Summary

Azure never used WebSockets. Two blockers, both fixed here:

  1. Core forced HTTP whenever a provider had any http.request/http.response hook registered. The Azure plugin always registers one (for Entra token injection), so hasHttpHooks was true for every Azure model and the WebSocket executor was never offered. The rule existed because HTTP hooks couldn't observe a WebSocket handshake — until there was a hook that could.
  2. The Azure route rewrote api-key into Authorization: Bearer <key> for the handshake. Azure rejects that upgrade with a non-101 response. Verified live against *.openai.azure.com/openai/v1/responses: api-key → 101 + clean close; Bearer <api-key> → rejected; no auth → rejected.

Changes

experimental.ws.handshake hook (@opencode/plugin, both effect/ and promise/): { sessionID, agent, model, kind, url, headers }, mutable url/headers. Runs once per model call before the Session's socket is selected. Its output feeds the existing affinity key, so a rotated bearer reopens the connection with no new mechanism.

Core wiring

  • SessionModelTransport.bind(sessionID, handshake?) — optional interceptor applied in start() before affinity is computed. Transport stays free of plugin types.
  • model-request.ts — drop the !hasHttpHooks gate. http middleware and the webSocket executor are now both offered whenever they apply; the route decides which transport carries the request. HTTP hooks fire on every HTTP request, including the WebSocket fallback path (which already runs through prepared.http.middleware).
  • Azure plugin — shared bearer(url) helper; http.request and experimental.ws.handshake both inject the Entra token. Only the handshake registration is new.

AI package — remove the Azure api-key → Bearer handshake header rewrite. The handshake now carries whatever header the route's Auth produced: api-key for keys, Authorization: Bearer for Auth.bearer(entraToken).

Resolves a latent trap

Previously "offer WebSocket" and "run HTTP hooks" were an XOR in core. Registering an http.request hook for a provider silently disabled its WebSocket path (and vice versa: opting a model into transport: "websocket" skipped building the HTTP middleware). Both now coexist; the XOR is gone as a consequence of the gate removal rather than a separate fix.

Entra verification status

The api-key handshake behavior above was probed live. The Entra path is covered by unit tests (handshake hook injects the bearer, affinity rotates on token refresh) and by the existing Auth.bearer assertion in the ai package, but no Entra socket has been opened live from this branch — there was no az login available. Azure documents Entra for Responses as a standard bearer on the same endpoint, so this is expected to work, and a reviewer with az login can confirm in one line:

bun -e 'const t=(await Bun.$`az account get-access-token --scope https://cognitiveservices.azure.com/.default --query accessToken -o tsv`.text()).trim();const ws=new WebSocket("wss://<resource>.openai.azure.com/openai/v1/responses",{headers:{authorization:`Bearer ${t}`}});ws.onopen=()=>{console.log("OPEN");ws.close()};ws.onclose=e=>console.log("CLOSED",e.code,e.reason);ws.onerror=e=>console.log("ERROR",e.message)'

OPEN (or CLOSED 1000) means the handshake accepted the Entra bearer.

Not included (deliberately)

  • ws.send/ws.receive frame hooks from the earlier feat(plugin): add experimental WebSocket session hooks #48289 draft. Frames are provider-protocol JSON with driver-tracked state (response.created, ID consistency); no consumer needs them today and mutating them is a footgun. Add a read-only tap later if observability wants it.
  • Migrating Copilot/OpenAI off model.request — separate PR.

Tests

  • session-model-request-hooks: HTTP hooks + WebSocket executor coexist; handshake hook fires with kind and its mutations reach the transport.
  • session-model-transport: handshake output feeds affinity — same minted token reuses the socket, rotated token reopens it.
  • provider-azure: handshake hook injects the cognitive-services bearer and strips api-key.
  • openai-responses (ai): Azure WebSocket handshake carries api-key for key auth and Bearer for explicit Auth.bearer, matching the live probe.
  • Typecheck green in ai, plugin, core; packages/core test/plugin/ + transport + request-hooks suites: 310 passed.

@rekram1-node
rekram1-node merged commit d0a9028 into v2 Sep 15, 2026
12 checks passed
@rekram1-node
rekram1-node deleted the ws-hooks-v2 branch September 15, 2026 06:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant