feat(plugin): add experimental WebSocket handshake hook - #49131
Merged
Merged
Conversation
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Azure never used WebSockets. Two blockers, both fixed here:
http.request/http.responsehook registered. The Azure plugin always registers one (for Entra token injection), sohasHttpHookswas 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.api-keyintoAuthorization: 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.handshakehook (@opencode/plugin, botheffect/andpromise/):{ sessionID, agent, model, kind, url, headers }, mutableurl/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 instart()before affinity is computed. Transport stays free of plugin types.model-request.ts— drop the!hasHttpHooksgate.httpmiddleware and thewebSocketexecutor 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 throughprepared.http.middleware).bearer(url)helper;http.requestandexperimental.ws.handshakeboth inject the Entra token. Only thehandshakeregistration is new.AI package — remove the Azure
api-key → Bearerhandshake header rewrite. The handshake now carries whatever header the route'sAuthproduced:api-keyfor keys,Authorization: BearerforAuth.bearer(entraToken).Resolves a latent trap
Previously "offer WebSocket" and "run HTTP hooks" were an XOR in core. Registering an
http.requesthook for a provider silently disabled its WebSocket path (and vice versa: opting a model intotransport: "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-keyhandshake 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 existingAuth.bearerassertion in the ai package, but no Entra socket has been opened live from this branch — there was noaz loginavailable. Azure documents Entra for Responses as a standard bearer on the same endpoint, so this is expected to work, and a reviewer withaz logincan 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(orCLOSED 1000) means the handshake accepted the Entra bearer.Not included (deliberately)
ws.send/ws.receiveframe 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.model.request— separate PR.Tests
session-model-request-hooks: HTTP hooks + WebSocket executor coexist; handshake hook fires withkindand 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 stripsapi-key.openai-responses(ai): Azure WebSocket handshake carriesapi-keyfor key auth andBearerfor explicitAuth.bearer, matching the live probe.ai,plugin,core;packages/coretest/plugin/+ transport + request-hooks suites: 310 passed.