Explorar o código

refactor(ai): move websocket transport to open responses

Shoubhit Dash hai 1 día
pai
achega
5baceaf4e6

+ 3 - 2
packages/ai/AGENTS.md

@@ -80,7 +80,7 @@ Route defaults are request-shaping defaults such as `headers`, `limits`, `genera
 
 The four-axis decomposition is the reason DeepSeek, TogetherAI, Cerebras, Baseten, Fireworks, and DeepInfra all reuse `OpenAIChat.protocol` verbatim — each provider deployment is a 5-15 line `Route.make(...)` call instead of a 300-400 line route clone. Bug fixes in one protocol propagate to every consumer of that protocol in a single commit.
 
-When a provider supports multiple physical transports, selection remains execution policy below its semantic route. OpenAI Responses uses a purpose-built hybrid transport that prepares one final request, executes HTTP by default, and passes a generic channel exchange to a per-call `WebSocketChannelExecutor` when supplied. `Route.streamPrepared` owns decoding and acknowledges channel completion only after successful full consumption.
+When a provider supports multiple physical transports, selection remains execution policy below its semantic route. `OpenResponsesChannel.transport(...)` owns the provider-neutral Responses WebSocket concept: it prepares one final request, executes HTTP by default, strips WebSocket-disallowed fields, and passes a generic channel exchange to a per-call `WebSocketChannelExecutor` when supplied. Provider-specific Responses routes opt in with handshake and connection-age policy. `Route.streamPrepared` owns decoding and acknowledges channel completion only after successful full consumption.
 
 ### URL Construction
 
@@ -161,7 +161,8 @@ packages/ai/src/
     shared.ts               ProviderShared toolkit used inside protocol impls
     openai-chat.ts          protocol + route (compose OpenAIChat.protocol)
     open-responses.ts         provider-neutral Responses protocol baseline
-    openai-responses.ts       OpenAI tools/events/transports composed over OpenResponses
+    open-responses-channel.ts provider-neutral Responses WebSocket transport factory
+    openai-responses.ts       OpenAI tools/events and channel policy composed over OpenResponses
     anthropic-messages.ts
     gemini.ts
     bedrock-converse.ts

+ 1 - 1
packages/ai/README.md

@@ -331,7 +331,7 @@ OpenAI Chat and OpenAI Responses are separate semantic entrypoints:
 - `@opencode-ai/ai/providers/google-vertex/responses`
 - `@opencode-ai/ai/providers/google-vertex/messages`
 
-OpenAI Responses has one semantic route and uses HTTP by default. Advanced callers may supply a per-call WebSocket channel executor through `StreamOptions`; transport policy does not change provider settings, model identity, or route identity. Azure follows the same Chat/Responses split at `providers/azure/chat` and `providers/azure/responses`. Generic OpenAI-compatible Chat remains at `providers/openai-compatible`; the Responses adapter at `providers/openai-compatible/responses` uses the provider-neutral Open Responses protocol. OpenAI Responses extends that baseline with OpenAI tools, event variants, metadata, and defaults. Generic Anthropic Messages-compatible providers use `providers/anthropic-compatible`, which the named Anthropic provider composes. Google Gemini and Amazon Bedrock expose their single native API through their existing provider paths.
+OpenAI Responses has one semantic route and uses HTTP by default. Advanced callers may supply a per-call WebSocket channel executor through `StreamOptions`; transport policy does not change provider settings, model identity, or route identity. The provider-neutral Open Responses implementation owns the reusable WebSocket request and event contract, while each provider opts in with its own handshake and connection policy. Azure follows the same Chat/Responses split at `providers/azure/chat` and `providers/azure/responses`. Generic OpenAI-compatible Chat remains at `providers/openai-compatible`; the Responses adapter at `providers/openai-compatible/responses` uses the provider-neutral Open Responses protocol. OpenAI Responses extends that baseline with OpenAI tools, event variants, metadata, and defaults. Generic Anthropic Messages-compatible providers use `providers/anthropic-compatible`, which the named Anthropic provider composes. Google Gemini and Amazon Bedrock expose their single native API through their existing provider paths.
 
 Vertex Gemini, Vertex Chat, Vertex Responses, and Vertex Messages are separate API entrypoints. All accept `project`, `location`, and an optional `accessToken`; when no explicit token or auth override is supplied they lazily use Google Application Default Credentials. Vertex Gemini instead selects express mode when `apiKey` or `GOOGLE_VERTEX_API_KEY` is present. Vertex Chat targets MaaS models through the OpenAI-compatible Chat Completions endpoint, while Vertex Responses targets Grok models and defaults `store` to `false` as required by Vertex. `providers/google-vertex` remains the default alias for `providers/google-vertex/gemini`.
 

+ 18 - 18
packages/ai/STATUS.md

@@ -77,24 +77,24 @@ Other `aisdk:` packages, including Google Vertex, Azure, and Bedrock, currently
 
 These are implementation/API slices, not separate npm packages.
 
-| API slice                     | Package-like entrypoint                                     | Purpose                                                                      |
-| ----------------------------- | ----------------------------------------------------------- | ---------------------------------------------------------------------------- |
-| OpenAI Chat                   | `@opencode-ai/ai/providers/openai/chat`                     | OpenAI `/chat/completions` semantics.                                        |
-| OpenAI Responses              | `@opencode-ai/ai/providers/openai/responses`                | OpenAI `/responses` semantics with HTTP/WebSocket selected through settings. |
-| OpenAI-compatible Chat        | `@opencode-ai/ai/providers/openai-compatible`               | Generic OpenAI-compatible `/chat/completions`.                               |
-| Open Responses-compatible     | `@opencode-ai/ai/providers/openai-compatible/responses`     | Generic provider-neutral `/responses`.                                       |
-| Anthropic-compatible Messages | `@opencode-ai/ai/providers/anthropic-compatible`            | Generic Anthropic-compatible `/messages`.                                    |
-| Anthropic Messages            | `@opencode-ai/ai/providers/anthropic`                       | Anthropic Messages API.                                                      |
-| Gemini Developer API          | `@opencode-ai/ai/providers/google`                          | Google AI Studio Gemini API.                                                 |
-| Vertex Gemini                 | `@opencode-ai/ai/providers/google-vertex/gemini`            | Vertex Gemini API; `providers/google-vertex` is the default alias.           |
-| Vertex Chat                   | `@opencode-ai/ai/providers/google-vertex/chat`              | Vertex OpenAI-compatible Chat Completions for MaaS models.                   |
-| Vertex Responses              | `@opencode-ai/ai/providers/google-vertex/responses`         | Vertex Open Responses for Grok models.                                       |
-| Vertex Messages               | `@opencode-ai/ai/providers/google-vertex/messages`          | Vertex-hosted Anthropic Messages API.                                        |
-| Bedrock Converse              | `@opencode-ai/ai/providers/amazon-bedrock`                  | AWS Bedrock Converse API.                                                    |
-| Bedrock Mantle Chat           | `@opencode-ai/ai/providers/amazon-bedrock/mantle/chat`      | AWS Bedrock Mantle OpenAI-compatible Chat API.                               |
-| Bedrock Mantle Responses      | `@opencode-ai/ai/providers/amazon-bedrock/mantle/responses` | AWS Bedrock Mantle OpenAI-compatible Responses API.                          |
-| Azure OpenAI Chat             | `@opencode-ai/ai/providers/azure/chat`                      | Azure specialization of OpenAI Chat.                                         |
-| Azure OpenAI Responses        | `@opencode-ai/ai/providers/azure/responses`                 | Azure specialization of OpenAI Responses.                                    |
+| API slice                     | Package-like entrypoint                                     | Purpose                                                                                    |
+| ----------------------------- | ----------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
+| OpenAI Chat                   | `@opencode-ai/ai/providers/openai/chat`                     | OpenAI `/chat/completions` semantics.                                                      |
+| OpenAI Responses              | `@opencode-ai/ai/providers/openai/responses`                | OpenAI `/responses` semantics with HTTP default and optional per-call WebSocket execution. |
+| OpenAI-compatible Chat        | `@opencode-ai/ai/providers/openai-compatible`               | Generic OpenAI-compatible `/chat/completions`.                                             |
+| Open Responses-compatible     | `@opencode-ai/ai/providers/openai-compatible/responses`     | Generic provider-neutral `/responses`.                                                     |
+| Anthropic-compatible Messages | `@opencode-ai/ai/providers/anthropic-compatible`            | Generic Anthropic-compatible `/messages`.                                                  |
+| Anthropic Messages            | `@opencode-ai/ai/providers/anthropic`                       | Anthropic Messages API.                                                                    |
+| Gemini Developer API          | `@opencode-ai/ai/providers/google`                          | Google AI Studio Gemini API.                                                               |
+| Vertex Gemini                 | `@opencode-ai/ai/providers/google-vertex/gemini`            | Vertex Gemini API; `providers/google-vertex` is the default alias.                         |
+| Vertex Chat                   | `@opencode-ai/ai/providers/google-vertex/chat`              | Vertex OpenAI-compatible Chat Completions for MaaS models.                                 |
+| Vertex Responses              | `@opencode-ai/ai/providers/google-vertex/responses`         | Vertex Open Responses for Grok models.                                                     |
+| Vertex Messages               | `@opencode-ai/ai/providers/google-vertex/messages`          | Vertex-hosted Anthropic Messages API.                                                      |
+| Bedrock Converse              | `@opencode-ai/ai/providers/amazon-bedrock`                  | AWS Bedrock Converse API.                                                                  |
+| Bedrock Mantle Chat           | `@opencode-ai/ai/providers/amazon-bedrock/mantle/chat`      | AWS Bedrock Mantle OpenAI-compatible Chat API.                                             |
+| Bedrock Mantle Responses      | `@opencode-ai/ai/providers/amazon-bedrock/mantle/responses` | AWS Bedrock Mantle OpenAI-compatible Responses API.                                        |
+| Azure OpenAI Chat             | `@opencode-ai/ai/providers/azure/chat`                      | Azure specialization of OpenAI Chat.                                                       |
+| Azure OpenAI Responses        | `@opencode-ai/ai/providers/azure/responses`                 | Azure specialization of OpenAI Responses.                                                  |
 
 ## Suggested Next Work Slices
 

+ 5 - 9
packages/ai/example/call-sites.md

@@ -376,11 +376,9 @@ import { model } from "@opencode-ai/ai/providers/google-vertex/messages"
 model("claude-sonnet-4-6", { project, location: "global" })
 ```
 
-The client should not require a different public layer just because a selected
-route uses WebSocket. Use one `LLMClient.layer` with HTTP and WebSocket runtime
-capabilities available; routes that do not need WebSocket simply never touch it.
-If a WebSocket route is selected in an environment without WebSocket support,
-fail with a typed transport configuration error.
+The client does not require a different public layer for WebSocket execution.
+Responses routes use HTTP by default, and callers may pass a channel executor per
+call. Routes without channel support simply ignore that execution capability.
 
 Azure is a route specialization with auth/path/default changes plus input
 mapping. The public API configures the Azure resource once, then selects
@@ -582,10 +580,8 @@ App boundary = explicit durable-config -> typed-provider call
 - [ ] Decide whether a tiny `Provider.define(...)` helper is warranted after two
       or three provider conversions; start with plain objects if duplication is not
       yet painful.
-- [x] Update `packages/opencode/src/session/llm/native-request.ts` to construct
-      executable models at the session boundary with explicit provider facade
-      calls, mapping catalog metadata such as `endpoint.websocket` to the correct
-      named route selector.
+- [x] Keep executable model construction transport-neutral at the Session boundary;
+      Session-scoped execution policy supplies channel capability separately.
 - [ ] Update tests so direct route/provider tests assert route values are carried
       by executable models, and opencode/native tests assert boundary-based route
       selection.

+ 1 - 0
packages/ai/src/protocols/index.ts

@@ -7,3 +7,4 @@ export * as OpenAICompatibleChat from "./openai-compatible-chat"
 export * as OpenAICompatibleResponses from "./openai-compatible-responses"
 export * as OpenAIResponses from "./openai-responses"
 export * as OpenResponses from "./open-responses"
+export * as OpenResponsesChannel from "./open-responses-channel"

+ 119 - 0
packages/ai/src/protocols/open-responses-channel.ts

@@ -0,0 +1,119 @@
+import { Effect, Schema, Stream } from "effect"
+import { Headers } from "effect/unstable/http"
+import { Framing } from "../route/framing"
+import {
+  HttpTransport,
+  WebSocketTransport,
+  type Transport,
+  type WebSocketChannelDriver,
+  type WebSocketChannelExchange,
+} from "../route/transport"
+import * as ProviderShared from "./shared"
+import { OpenResponses } from "./open-responses"
+
+const WebSocketResponseCreate = Schema.StructWithRest(Schema.Struct({ type: Schema.tag("response.create") }), [
+  Schema.Record(Schema.String, Schema.Unknown),
+])
+const decodeMessage = ProviderShared.validateWith(Schema.decodeUnknownEffect(WebSocketResponseCreate))
+const encodeMessage = Schema.encodeSync(Schema.fromJsonString(WebSocketResponseCreate))
+const decodeEvent = Schema.decodeUnknownEffect(OpenResponses.protocol.stream.event)
+
+export interface Options {
+  readonly id: string
+  readonly name: string
+  readonly rotateAfterMs?: number
+  readonly headers?: (headers: Headers.Headers) => Headers.Headers
+}
+
+export interface Prepared {
+  readonly http: HttpTransport.HttpPrepared<string>
+  readonly channel?: {
+    readonly url: string
+    readonly headers: Headers.Headers
+    readonly rotateAfterMs?: number
+    readonly driver: WebSocketChannelDriver
+  }
+}
+
+const message = (body: unknown) =>
+  Effect.gen(function* () {
+    if (!ProviderShared.isRecord(body))
+      return yield* ProviderShared.invalidRequest("Open Responses WebSocket body must be a JSON object")
+    const { stream: _stream, stream_options: _streamOptions, background: _background, ...request } = body
+    return encodeMessage(yield* decodeMessage({ ...request, type: "response.create" }))
+  })
+
+const driver = (options: Options, body: string): WebSocketChannelDriver => ({
+  create: () => Effect.succeed({ message: body, mode: "full" }),
+  observe: (_create, frame) =>
+    Effect.gen(function* () {
+      const event = yield* decodeEvent(frame).pipe(
+        Effect.mapError(() => ProviderShared.eventError(options.id, `Invalid ${options.name} WebSocket event`, frame)),
+      )
+      if (event.type === "response.completed") return { type: "completed", frame }
+      if (event.type === "response.incomplete") return { type: "incomplete", frame }
+      if (event.type === "response.failed")
+        return {
+          type: "provider-failure",
+          error: OpenResponses.providerFailure(options.id, event, `${options.name} response failed`),
+        }
+      if (event.type === "error") {
+        yield* OpenResponses.decodeKnownErrorEvent(event).pipe(
+          Effect.mapError(() =>
+            ProviderShared.eventError(options.id, `${options.name} returned a malformed error event`, frame),
+          ),
+        )
+        return {
+          type: "provider-failure",
+          error: OpenResponses.providerFailure(options.id, event, `${options.name} stream error`),
+        }
+      }
+      return { type: "frame", frame }
+    }),
+})
+
+export const transport = <Body>(options: Options): Transport<Body, Prepared, string> => {
+  const http = HttpTransport.sseJson.with<Body>()
+  return {
+    id: http.id,
+    prepare: (input) =>
+      Effect.gen(function* () {
+        const parts = yield* HttpTransport.jsonRequestParts(input)
+        const headers = Headers.remove(options.headers?.(parts.headers) ?? parts.headers, "content-length")
+        return {
+          http: {
+            request: ProviderShared.jsonPost({ url: parts.url, body: parts.bodyText, headers: parts.headers }),
+            framing: Framing.sse,
+            middleware: input.middleware,
+          },
+          channel: input.webSocket
+            ? {
+                url: yield* WebSocketTransport.toWebSocketUrl(parts.url),
+                headers,
+                rotateAfterMs: options.rotateAfterMs,
+                driver: driver(options, yield* message(parts.jsonBody)),
+              }
+            : undefined,
+        }
+      }),
+    execute: (prepared, request, runtime, executeOptions) => {
+      if (!executeOptions?.webSocket || !prepared.channel) return http.execute(prepared.http, request, runtime)
+      const exchange: WebSocketChannelExchange = {
+        id: request.id ?? "request",
+        connect: {
+          url: prepared.channel.url,
+          headers: prepared.channel.headers,
+          rotateAfterMs: prepared.channel.rotateAfterMs,
+        },
+        fallback: () =>
+          Stream.unwrap(
+            http.execute(prepared.http, request, runtime).pipe(Effect.map((execution) => execution.frames)),
+          ),
+        driver: prepared.channel.driver,
+      }
+      return executeOptions.webSocket.execute(exchange)
+    },
+  }
+}
+
+export const OpenResponsesChannel = { transport } as const

+ 0 - 37
packages/ai/src/protocols/openai-responses-channel.ts

@@ -1,37 +0,0 @@
-import { Effect, Schema } from "effect"
-import type { WebSocketChannelDriver } from "../route/transport"
-import * as ProviderShared from "./shared"
-import { OpenResponses } from "./open-responses"
-
-const ADAPTER = "openai-responses"
-const NAME = "OpenAI Responses"
-const decodeEvent = Schema.decodeUnknownEffect(OpenResponses.protocol.stream.event)
-
-export const make = (message: string): WebSocketChannelDriver => ({
-  create: () => Effect.succeed({ message, mode: "full" }),
-  observe: (_create, frame) =>
-    Effect.gen(function* () {
-      const event = yield* decodeEvent(frame).pipe(
-        Effect.mapError(() => ProviderShared.eventError(ADAPTER, "Invalid OpenAI Responses WebSocket event", frame)),
-      )
-      if (event.type === "response.completed") return { type: "completed", frame }
-      if (event.type === "response.incomplete") return { type: "incomplete", frame }
-      if (event.type === "response.failed")
-        return {
-          type: "provider-failure",
-          error: OpenResponses.providerFailure(ADAPTER, event, `${NAME} response failed`),
-        }
-      if (event.type === "error") {
-        yield* OpenResponses.decodeKnownErrorEvent(event).pipe(
-          Effect.mapError(() => ProviderShared.eventError(ADAPTER, `${NAME} returned a malformed error event`, frame)),
-        )
-        return {
-          type: "provider-failure",
-          error: OpenResponses.providerFailure(ADAPTER, event, `${NAME} stream error`),
-        }
-      }
-      return { type: "frame", frame }
-    }),
-})
-
-export const OpenAIResponsesChannel = { make } as const

+ 11 - 74
packages/ai/src/protocols/openai-responses.ts

@@ -1,27 +1,22 @@
-import { Effect, Encoding, Schema, Stream } from "effect"
+import { Effect, Encoding, Schema } from "effect"
 import { Headers } from "effect/unstable/http"
 import { Route } from "../route/client"
 import { Auth } from "../route/auth"
 import { Endpoint } from "../route/endpoint"
-import { Framing } from "../route/framing"
 import { Protocol } from "../route/protocol"
-import {
-  HttpTransport,
-  WebSocketTransport,
-  type Transport,
-  type WebSocketChannelDriver,
-  type WebSocketChannelExchange,
-} from "../route/transport"
+import { HttpTransport } from "../route/transport"
 import { LLMEvent, LLMRequest, type JsonSchema, type ToolDefinition } from "../schema"
 import { OpenResponses } from "./open-responses"
 import { optionalArray, ProviderShared } from "./shared"
 import { Lifecycle } from "./utils/lifecycle"
 import { OpenAIImage } from "./utils/openai-image"
 import { ToolSchemaProjection } from "./utils/tool-schema"
-import { OpenAIResponsesChannel } from "./openai-responses-channel"
+import { OpenResponsesChannel } from "./open-responses-channel"
 
 const ADAPTER = "openai-responses"
 const NAME = "OpenAI Responses"
+const WEBSOCKET_PROTOCOL_HEADER = "responses_websockets=2026-02-06"
+const WEBSOCKET_ROTATE_AFTER_MS = 55 * 60 * 1000
 export const DEFAULT_BASE_URL = "https://api.openai.com/v1"
 export const PATH = OpenResponses.PATH
 
@@ -66,16 +61,6 @@ const OpenAIResponsesBody = Schema.Struct({
 })
 export type OpenAIResponsesBody = Schema.Schema.Type<typeof OpenAIResponsesBody>
 
-const OpenAIResponsesWebSocketMessage = Schema.StructWithRest(
-  Schema.Struct({
-    type: Schema.tag("response.create"),
-    ...OpenAIResponsesCoreFields,
-  }),
-  [Schema.Record(Schema.String, Schema.Unknown)],
-)
-type OpenAIResponsesWebSocketMessage = Schema.Schema.Type<typeof OpenAIResponsesWebSocketMessage>
-const encodeWebSocketMessage = Schema.encodeSync(Schema.fromJsonString(OpenAIResponsesWebSocketMessage))
-
 const extension = {
   id: ADAPTER,
   name: NAME,
@@ -258,60 +243,12 @@ const endpoint = Endpoint.path<OpenAIResponsesBody>(PATH, { baseURL: DEFAULT_BAS
 const auth = Auth.none
 
 export const httpTransport = HttpTransport.sseJson.with<OpenAIResponsesBody>()
-
-const decodeWebSocketMessage = ProviderShared.validateWith(Schema.decodeUnknownEffect(OpenAIResponsesWebSocketMessage))
-
-const webSocketMessage = (body: OpenAIResponsesBody | Record<string, unknown>) =>
-  Effect.gen(function* () {
-    if (!ProviderShared.isRecord(body))
-      return yield* ProviderShared.invalidRequest("OpenAI Responses WebSocket body must be a JSON object")
-    const { stream: _stream, ...message } = body
-    return yield* decodeWebSocketMessage({ ...message, type: "response.create" })
-  })
-
-export interface OpenAIResponsesPrepared {
-  readonly http: HttpTransport.HttpPrepared<string>
-  readonly channel?: {
-    readonly url: string
-    readonly headers: Headers.Headers
-    readonly driver: WebSocketChannelDriver
-  }
-}
-
-export const transport: Transport<OpenAIResponsesBody, OpenAIResponsesPrepared, string> = {
-  id: httpTransport.id,
-  prepare: (input) =>
-    Effect.gen(function* () {
-      const parts = yield* HttpTransport.jsonRequestParts(input)
-      return {
-        http: {
-          request: ProviderShared.jsonPost({ url: parts.url, body: parts.bodyText, headers: parts.headers }),
-          framing: Framing.sse,
-          middleware: input.middleware,
-        },
-        channel: input.webSocket
-          ? {
-              url: yield* WebSocketTransport.toWebSocketUrl(parts.url),
-              headers: parts.headers,
-              driver: OpenAIResponsesChannel.make(encodeWebSocketMessage(yield* webSocketMessage(parts.jsonBody))),
-            }
-          : undefined,
-      }
-    }),
-  execute: (prepared, request, runtime, options) => {
-    if (!options?.webSocket || !prepared.channel) return httpTransport.execute(prepared.http, request, runtime)
-    const exchange: WebSocketChannelExchange = {
-      id: request.id ?? "request",
-      connect: { url: prepared.channel.url, headers: prepared.channel.headers },
-      fallback: () =>
-        Stream.unwrap(
-          httpTransport.execute(prepared.http, request, runtime).pipe(Effect.map((execution) => execution.frames)),
-        ),
-      driver: prepared.channel.driver,
-    }
-    return options.webSocket.execute(exchange)
-  },
-}
+export const transport = OpenResponsesChannel.transport<OpenAIResponsesBody>({
+  id: ADAPTER,
+  name: NAME,
+  rotateAfterMs: WEBSOCKET_ROTATE_AFTER_MS,
+  headers: (headers) => Headers.set(headers, "openai-beta", headers["openai-beta"] ?? WEBSOCKET_PROTOCOL_HEADER),
+})
 
 export const route = Route.make({
   id: ADAPTER,

+ 2 - 0
packages/ai/src/route/transport/websocket-channel.ts

@@ -19,6 +19,8 @@ export interface WebSocketChannelExchange {
   readonly connect: {
     readonly url: string
     readonly headers: Headers.Headers
+    /** Provider-safe connection age after which Core should rotate before sending. */
+    readonly rotateAfterMs?: number
   }
   readonly fallback: () => Stream.Stream<string, AIError>
   readonly driver: WebSocketChannelDriver

+ 2 - 0
packages/ai/test/exports.test.ts

@@ -17,6 +17,7 @@ import {
   OpenAICompatibleResponses,
   OpenAIResponses,
   OpenResponses,
+  OpenResponsesChannel,
 } from "@opencode-ai/ai/protocols"
 import * as AnthropicMessages from "@opencode-ai/ai/protocols/anthropic-messages"
 import { TestLLM } from "@opencode-ai/ai/testing"
@@ -83,6 +84,7 @@ describe("public exports", () => {
     expect(OpenAIChat.route.id).toBe("openai-chat")
     expect(OpenAICompatibleChat.route.id).toBe("openai-compatible-chat")
     expect(OpenResponses.protocol.id).toBe("open-responses")
+    expect(OpenResponsesChannel.transport).toBeFunction()
     expect(OpenAICompatibleResponses.route.id).toBe("openai-compatible-responses")
     expect(OpenAICompatibleResponses.route.protocol).toBe("open-responses")
     expect(OpenAIResponses.route.id).toBe("openai-responses")

+ 34 - 6
packages/ai/test/provider/openai-responses.test.ts

@@ -238,7 +238,11 @@ describe("OpenAI Responses route", () => {
   it.effect("streams OpenAI Responses over WebSocket", () =>
     Effect.gen(function* () {
       const sent: string[] = []
-      const opened: Array<{ readonly url: string; readonly authorization: string | undefined }> = []
+      const opened: Array<{
+        readonly url: string
+        readonly authorization: string | undefined
+        readonly protocol: string | undefined
+      }> = []
       let closed = false
       const deps = Layer.succeed(
         RequestExecutor.Service,
@@ -251,7 +255,11 @@ describe("OpenAI Responses route", () => {
           Effect.succeed({
             sendText: (message) =>
               Effect.sync(() => {
-                opened.push({ url: input.url, authorization: input.headers.authorization })
+                opened.push({
+                  url: input.url,
+                  authorization: input.headers.authorization,
+                  protocol: input.headers["openai-beta"],
+                })
                 sent.push(message)
               }),
             messages: Stream.fromArray([
@@ -265,14 +273,24 @@ describe("OpenAI Responses route", () => {
       })
       const response = yield* LLMClient.generate(
         LLM.request({
-          model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responses("gpt-4.1-mini"),
+          model: OpenAI.configure({
+            baseURL: "https://api.openai.test/v1/",
+            apiKey: "test",
+            headers: { "openai-beta": "custom-protocol" },
+          }).responses("gpt-4.1-mini"),
           prompt: "Say hello.",
         }),
         { webSocket },
       ).pipe(Effect.provide(LLMClient.layer.pipe(Layer.provide(deps))))
 
       expect(response.text).toBe("Hi")
-      expect(opened).toEqual([{ url: "wss://api.openai.test/v1/responses", authorization: "Bearer test" }])
+      expect(opened).toEqual([
+        {
+          url: "wss://api.openai.test/v1/responses",
+          authorization: "Bearer test",
+          protocol: "custom-protocol",
+        },
+      ])
       expect(closed).toBe(true)
       expect(sent).toHaveLength(1)
       expect(JSON.parse(sent[0])).toEqual({
@@ -294,7 +312,12 @@ describe("OpenAI Responses route", () => {
           model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responses("gpt-4.1-mini"),
           prompt: "Say hello.",
           http: {
-            body: { model: "overlaid-model", metadata: { source: "overlay" } },
+            body: {
+              model: "overlaid-model",
+              metadata: { source: "overlay" },
+              stream_options: { include_usage: true },
+              background: true,
+            },
             headers: { "x-request": "request" },
             query: { mode: "test" },
           },
@@ -303,6 +326,9 @@ describe("OpenAI Responses route", () => {
           webSocket: {
             execute: (exchange) =>
               Effect.gen(function* () {
+                expect(exchange.connect.rotateAfterMs).toBe(55 * 60 * 1000)
+                expect(exchange.connect.headers["openai-beta"]).toBe("responses_websockets=2026-02-06")
+                expect(exchange.connect.headers["content-length"]).toBeUndefined()
                 yield* exchange.driver
                   .create(undefined)
                   .pipe(Effect.flatMap((create) => Ref.set(message, create.message)))
@@ -328,7 +354,7 @@ describe("OpenAI Responses route", () => {
       )
 
       const httpBody = JSON.parse(yield* Ref.get(body))
-      const { stream: _stream, ...shared } = httpBody
+      const { stream: _stream, stream_options: _streamOptions, background: _background, ...shared } = httpBody
       expect(response.finishReason?.normalized).toBe("stop")
       expect(yield* Ref.get(attempts)).toBe(1)
       expect(JSON.parse(yield* Ref.get(message))).toEqual({ type: "response.create", ...shared })
@@ -336,6 +362,8 @@ describe("OpenAI Responses route", () => {
         model: "overlaid-model",
         metadata: { source: "overlay" },
         stream: true,
+        stream_options: { include_usage: true },
+        background: true,
       })
     }),
   )