feat(openai): add responses websocket transport (#29477)

This commit is contained in:
Aiden Cline
2026-05-27 15:02:37 -05:00
committed by GitHub
parent 28a06e52fc
commit 62da1e7682
12 changed files with 1326 additions and 45 deletions
@@ -0,0 +1,31 @@
# OpenAI Responses WebSocket
Enabled by default on `local`, `dev`, and `beta`. On `latest` and `prod`, set `OPENCODE_EXPERIMENTAL_WEBSOCKETS=true`.
## Flow
1. A streamed `POST /responses` request arrives.
2. If it has no `session-id` or `x-session-affinity` header, use HTTP.
3. Title requests use HTTP.
4. If that session's socket is busy or already in fallback mode, use HTTP.
5. Otherwise, reuse its open socket or open a new one.
6. Send `response.create` and return WebSocket events as SSE.
## Lifetime
- Connect timeout: 15 seconds.
- Idle timeout: 5 minutes.
- After a completed response, keep the socket for reuse.
- Reuse a socket for up to 55 minutes, then replace it on the next request.
## Retries
- If WebSocket setup fails or it fails before its first event, replay over HTTP and keep that session on HTTP until idle-pruned.
- If the server returns `websocket_connection_limit_reached` before output, reconnect up to 5 times, then follow the same HTTP fallback.
- If a WebSocket fails after its first event, fail the stream. Do not replay partial output.
- Abort or cancel closes the socket.
## Next Steps
- `previous_response_id` continuation.
- Optional second WebSocket for concurrent requests in one session. Currently these use HTTP.
@@ -0,0 +1,650 @@
import type { Hooks, PluginInput } from "@opencode-ai/plugin"
import * as Log from "@opencode-ai/core/util/log"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { OAUTH_DUMMY_KEY } from "../../auth"
import os from "os"
import { setTimeout as sleep } from "node:timers/promises"
import { createServer } from "http"
import { OpenAIWebSocketPool } from "./ws-pool"
const log = Log.create({ service: "plugin.codex" })
const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
const ISSUER = "https://auth.openai.com"
const CODEX_API_ENDPOINT = "https://chatgpt.com/backend-api/codex/responses"
const OAUTH_PORT = 1455
const OAUTH_POLLING_SAFETY_MARGIN_MS = 3000
const ALLOWED_MODELS = new Set([
"gpt-5.5",
"gpt-5.2",
"gpt-5.3-codex",
"gpt-5.3-codex-spark",
"gpt-5.4",
"gpt-5.4-mini",
])
interface PkceCodes {
verifier: string
challenge: string
}
async function generatePKCE(): Promise<PkceCodes> {
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
const verifier = Array.from(crypto.getRandomValues(new Uint8Array(43)))
.map((b) => chars[b % chars.length])
.join("")
const challenge = base64UrlEncode(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier)))
return { verifier, challenge }
}
function base64UrlEncode(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer)
const binary = String.fromCharCode(...bytes)
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "")
}
export interface IdTokenClaims {
chatgpt_account_id?: string
organizations?: Array<{ id: string }>
email?: string
"https://api.openai.com/auth"?: {
chatgpt_account_id?: string
}
}
export function parseJwtClaims(token: string): IdTokenClaims | undefined {
const parts = token.split(".")
if (parts.length !== 3) return undefined
try {
return JSON.parse(Buffer.from(parts[1], "base64url").toString())
} catch {
return undefined
}
}
export function extractAccountIdFromClaims(claims: IdTokenClaims): string | undefined {
return (
claims.chatgpt_account_id ||
claims["https://api.openai.com/auth"]?.chatgpt_account_id ||
claims.organizations?.[0]?.id
)
}
export function extractAccountId(tokens: TokenResponse): string | undefined {
if (tokens.id_token) {
const claims = parseJwtClaims(tokens.id_token)
const accountId = claims && extractAccountIdFromClaims(claims)
if (accountId) return accountId
}
if (tokens.access_token) {
const claims = parseJwtClaims(tokens.access_token)
return claims ? extractAccountIdFromClaims(claims) : undefined
}
return undefined
}
function buildAuthorizeUrl(redirectUri: string, pkce: PkceCodes, state: string): string {
const params = new URLSearchParams({
response_type: "code",
client_id: CLIENT_ID,
redirect_uri: redirectUri,
scope: "openid profile email offline_access",
code_challenge: pkce.challenge,
code_challenge_method: "S256",
id_token_add_organizations: "true",
codex_cli_simplified_flow: "true",
state,
originator: "opencode",
})
return `${ISSUER}/oauth/authorize?${params.toString()}`
}
interface TokenResponse {
id_token: string
access_token: string
refresh_token: string
expires_in?: number
}
interface CodexAuthPluginOptions {
issuer?: string
codexApiEndpoint?: string
experimentalWebSockets?: boolean
}
async function exchangeCodeForTokens(code: string, redirectUri: string, pkce: PkceCodes): Promise<TokenResponse> {
const response = await fetch(`${ISSUER}/oauth/token`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code",
code,
redirect_uri: redirectUri,
client_id: CLIENT_ID,
code_verifier: pkce.verifier,
}).toString(),
})
if (!response.ok) {
throw new Error(`Token exchange failed: ${response.status}`)
}
return response.json()
}
async function refreshAccessToken(refreshToken: string, issuer = ISSUER): Promise<TokenResponse> {
const response = await fetch(`${issuer}/oauth/token`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "refresh_token",
refresh_token: refreshToken,
client_id: CLIENT_ID,
}).toString(),
})
if (!response.ok) {
throw new Error(`Token refresh failed: ${response.status}`)
}
return response.json()
}
const HTML_SUCCESS = `<!doctype html>
<html>
<head>
<title>OpenCode - Codex Authorization Successful</title>
<style>
body {
font-family:
system-ui,
-apple-system,
sans-serif;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
margin: 0;
background: #131010;
color: #f1ecec;
}
.container {
text-align: center;
padding: 2rem;
}
h1 {
color: #f1ecec;
margin-bottom: 1rem;
}
p {
color: #b7b1b1;
}
</style>
</head>
<body>
<div class="container">
<h1>Authorization Successful</h1>
<p>You can close this window and return to OpenCode.</p>
</div>
<script>
setTimeout(() => window.close(), 2000)
</script>
</body>
</html>`
const HTML_ERROR = (error: string) => `<!doctype html>
<html>
<head>
<title>OpenCode - Codex Authorization Failed</title>
<style>
body {
font-family:
system-ui,
-apple-system,
sans-serif;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
margin: 0;
background: #131010;
color: #f1ecec;
}
.container {
text-align: center;
padding: 2rem;
}
h1 {
color: #fc533a;
margin-bottom: 1rem;
}
p {
color: #b7b1b1;
}
.error {
color: #ff917b;
font-family: monospace;
margin-top: 1rem;
padding: 1rem;
background: #3c140d;
border-radius: 0.5rem;
}
</style>
</head>
<body>
<div class="container">
<h1>Authorization Failed</h1>
<p>An error occurred during authorization.</p>
<div class="error">${error}</div>
</div>
</body>
</html>`
interface PendingOAuth {
pkce: PkceCodes
state: string
resolve: (tokens: TokenResponse) => void
reject: (error: Error) => void
}
let oauthServer: ReturnType<typeof createServer> | undefined
let pendingOAuth: PendingOAuth | undefined
async function startOAuthServer(): Promise<{ port: number; redirectUri: string }> {
if (oauthServer) {
return { port: OAUTH_PORT, redirectUri: `http://localhost:${OAUTH_PORT}/auth/callback` }
}
oauthServer = createServer((req, res) => {
const url = new URL(req.url || "/", `http://localhost:${OAUTH_PORT}`)
if (url.pathname === "/auth/callback") {
const code = url.searchParams.get("code")
const state = url.searchParams.get("state")
const error = url.searchParams.get("error")
const errorDescription = url.searchParams.get("error_description")
if (error) {
const errorMsg = errorDescription || error
pendingOAuth?.reject(new Error(errorMsg))
pendingOAuth = undefined
res.writeHead(200, { "Content-Type": "text/html" })
res.end(HTML_ERROR(errorMsg))
return
}
if (!code) {
const errorMsg = "Missing authorization code"
pendingOAuth?.reject(new Error(errorMsg))
pendingOAuth = undefined
res.writeHead(400, { "Content-Type": "text/html" })
res.end(HTML_ERROR(errorMsg))
return
}
if (!pendingOAuth || state !== pendingOAuth.state) {
const errorMsg = "Invalid state - potential CSRF attack"
pendingOAuth?.reject(new Error(errorMsg))
pendingOAuth = undefined
res.writeHead(400, { "Content-Type": "text/html" })
res.end(HTML_ERROR(errorMsg))
return
}
const current = pendingOAuth
pendingOAuth = undefined
exchangeCodeForTokens(code, `http://localhost:${OAUTH_PORT}/auth/callback`, current.pkce)
.then((tokens) => current.resolve(tokens))
.catch((err) => current.reject(err))
res.writeHead(200, { "Content-Type": "text/html" })
res.end(HTML_SUCCESS)
return
}
if (url.pathname === "/cancel") {
pendingOAuth?.reject(new Error("Login cancelled"))
pendingOAuth = undefined
res.writeHead(200)
res.end("Login cancelled")
return
}
res.writeHead(404)
res.end("Not found")
})
await new Promise<void>((resolve, reject) => {
oauthServer!.listen(OAUTH_PORT, () => {
log.info("codex oauth server started", { port: OAUTH_PORT })
resolve()
})
oauthServer!.on("error", reject)
})
return { port: OAUTH_PORT, redirectUri: `http://localhost:${OAUTH_PORT}/auth/callback` }
}
function stopOAuthServer() {
if (oauthServer) {
oauthServer.close(() => {
log.info("codex oauth server stopped")
})
oauthServer = undefined
}
}
function waitForOAuthCallback(pkce: PkceCodes, state: string): Promise<TokenResponse> {
return new Promise((resolve, reject) => {
const timeout = setTimeout(
() => {
if (pendingOAuth) {
pendingOAuth = undefined
reject(new Error("OAuth callback timeout - authorization took too long"))
}
},
5 * 60 * 1000,
) // 5 minute timeout
pendingOAuth = {
pkce,
state,
resolve: (tokens) => {
clearTimeout(timeout)
resolve(tokens)
},
reject: (error) => {
clearTimeout(timeout)
reject(error)
},
}
})
}
export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPluginOptions = {}): Promise<Hooks> {
const issuer = options.issuer ?? ISSUER
const codexApiEndpoint = options.codexApiEndpoint ?? CODEX_API_ENDPOINT
let websocketFetchInstalled = false
const websocketFetches: Array<ReturnType<typeof OpenAIWebSocketPool.createWebSocketFetch>> = []
return {
async dispose() {
for (const websocketFetch of websocketFetches) websocketFetch.close()
websocketFetches.length = 0
},
provider: {
id: "openai",
async models(provider, ctx) {
if (ctx.auth?.type !== "oauth") return provider.models
return Object.fromEntries(
Object.entries(provider.models)
.filter(([, model]) => {
if (ALLOWED_MODELS.has(model.api.id)) return true
const match = model.api.id.match(/^gpt-(\d+\.\d+)/)
return match ? parseFloat(match[1]) > 5.4 : false
})
.map(([modelID, model]) => [
modelID,
{
...model,
cost: {
input: 0,
output: 0,
cache: { read: 0, write: 0 },
},
limit: model.id.includes("gpt-5.5")
? {
context: 400_000,
input: 272_000,
output: 128_000,
}
: model.limit,
},
]),
)
},
},
auth: {
provider: "openai",
async loader(getAuth) {
const auth = await getAuth()
const websocketFetch = options.experimentalWebSockets
? OpenAIWebSocketPool.createWebSocketFetch({ httpFetch: fetch })
: undefined
if (websocketFetch) {
websocketFetches.push(websocketFetch)
websocketFetchInstalled = true
}
if (auth.type !== "oauth") return websocketFetch ? { fetch: websocketFetch } : {}
let refreshPromise:
| Promise<{
access: string
accountId: string | undefined
}>
| undefined
return {
apiKey: OAUTH_DUMMY_KEY,
async fetch(requestInput: RequestInfo | URL, init?: RequestInit) {
if (init?.headers) {
if (init.headers instanceof Headers) {
init.headers.delete("authorization")
init.headers.delete("Authorization")
} else if (Array.isArray(init.headers)) {
init.headers = init.headers.filter(([key]) => key.toLowerCase() !== "authorization")
} else {
delete init.headers["authorization"]
delete init.headers["Authorization"]
}
}
const currentAuth = await getAuth()
if (currentAuth.type !== "oauth")
return websocketFetch ? websocketFetch(requestInput, init) : fetch(requestInput, init)
const authWithAccount = currentAuth as typeof currentAuth & { accountId?: string }
if (!currentAuth.access || currentAuth.expires < Date.now()) {
if (!refreshPromise) {
log.info("refreshing codex access token")
refreshPromise = refreshAccessToken(currentAuth.refresh, issuer)
.then(async (tokens) => {
const accountId = extractAccountId(tokens) || authWithAccount.accountId
await input.client.auth.set({
path: { id: "openai" },
body: {
type: "oauth",
refresh: tokens.refresh_token,
access: tokens.access_token,
expires: Date.now() + (tokens.expires_in ?? 3600) * 1000,
...(accountId && { accountId }),
},
})
return {
access: tokens.access_token,
accountId,
}
})
.finally(() => {
refreshPromise = undefined
})
}
const refreshed = await refreshPromise
currentAuth.access = refreshed.access
authWithAccount.accountId = refreshed.accountId
}
const headers = new Headers()
if (init?.headers) {
if (init.headers instanceof Headers) {
init.headers.forEach((value, key) => headers.set(key, value))
} else if (Array.isArray(init.headers)) {
for (const [key, value] of init.headers) {
if (value !== undefined) headers.set(key, String(value))
}
} else {
for (const [key, value] of Object.entries(init.headers)) {
if (value !== undefined) headers.set(key, String(value))
}
}
}
headers.set("authorization", `Bearer ${currentAuth.access}`)
if (authWithAccount.accountId) {
headers.set("ChatGPT-Account-Id", authWithAccount.accountId)
}
const parsed =
requestInput instanceof URL
? requestInput
: new URL(typeof requestInput === "string" ? requestInput : requestInput.url)
const url =
parsed.pathname.includes("/v1/responses") || parsed.pathname.includes("/chat/completions")
? new URL(codexApiEndpoint)
: parsed
const requestInit = {
...init,
headers,
}
if (websocketFetch && parsed.pathname.includes("/v1/responses")) return websocketFetch(url, requestInit)
return fetch(url, OpenAIWebSocketPool.withoutInternalHeaders(requestInit))
},
}
},
methods: [
{
label: "ChatGPT Pro/Plus (browser)",
type: "oauth",
authorize: async () => {
const { redirectUri } = await startOAuthServer()
const pkce = await generatePKCE()
const state = base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)).buffer)
const authUrl = buildAuthorizeUrl(redirectUri, pkce, state)
const callbackPromise = waitForOAuthCallback(pkce, state)
return {
url: authUrl,
instructions: "Complete authorization in your browser. This window will close automatically.",
method: "auto" as const,
callback: async () => {
const tokens = await callbackPromise
stopOAuthServer()
const accountId = extractAccountId(tokens)
return {
type: "success" as const,
refresh: tokens.refresh_token,
access: tokens.access_token,
expires: Date.now() + (tokens.expires_in ?? 3600) * 1000,
accountId,
}
},
}
},
},
{
label: "ChatGPT Pro/Plus (headless)",
type: "oauth",
authorize: async () => {
const deviceResponse = await fetch(`${ISSUER}/api/accounts/deviceauth/usercode`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"User-Agent": `opencode/${InstallationVersion}`,
},
body: JSON.stringify({ client_id: CLIENT_ID }),
})
if (!deviceResponse.ok) throw new Error("Failed to initiate device authorization")
const deviceData = (await deviceResponse.json()) as {
device_auth_id: string
user_code: string
interval: string
}
const interval = Math.max(parseInt(deviceData.interval) || 5, 1) * 1000
return {
url: `${ISSUER}/codex/device`,
instructions: `Enter code: ${deviceData.user_code}`,
method: "auto" as const,
async callback() {
while (true) {
const response = await fetch(`${ISSUER}/api/accounts/deviceauth/token`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"User-Agent": `opencode/${InstallationVersion}`,
},
body: JSON.stringify({
device_auth_id: deviceData.device_auth_id,
user_code: deviceData.user_code,
}),
})
if (response.ok) {
const data = (await response.json()) as {
authorization_code: string
code_verifier: string
}
const tokenResponse = await fetch(`${ISSUER}/oauth/token`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code",
code: data.authorization_code,
redirect_uri: `${ISSUER}/deviceauth/callback`,
client_id: CLIENT_ID,
code_verifier: data.code_verifier,
}).toString(),
})
if (!tokenResponse.ok) {
throw new Error(`Token exchange failed: ${tokenResponse.status}`)
}
const tokens: TokenResponse = await tokenResponse.json()
return {
type: "success" as const,
refresh: tokens.refresh_token,
access: tokens.access_token,
expires: Date.now() + (tokens.expires_in ?? 3600) * 1000,
accountId: extractAccountId(tokens),
}
}
if (response.status !== 403 && response.status !== 404) {
return { type: "failed" as const }
}
await sleep(interval + OAUTH_POLLING_SAFETY_MARGIN_MS)
}
},
}
},
},
{
label: "Manually enter API Key",
type: "api",
},
],
},
"chat.headers": async (input, output) => {
if (input.model.providerID !== "openai") return
output.headers.originator = "opencode"
output.headers["User-Agent"] = `opencode/${InstallationVersion} (${os.platform()} ${os.release()}; ${os.arch()})`
output.headers["session-id"] = input.sessionID
// Temporary fetch-layer hack: title generation currently shares the conversation
// session ID, so the OpenAI plugin marks it for HTTP fallback until transport
// context can be passed directly instead of smuggled through headers.
if (websocketFetchInstalled && input.agent === "title") output.headers[OpenAIWebSocketPool.TITLE_HEADER] = "true"
},
"chat.params": async (input, output) => {
if (input.model.providerID !== "openai") return
// Match codex cli
output.maxOutputTokens = undefined
},
}
}
@@ -0,0 +1,247 @@
import WebSocket from "ws"
import * as Log from "@opencode-ai/core/util/log"
import { isRecord } from "@/util/record"
import { OpenAIWebSocket } from "./ws"
export const TITLE_HEADER = "x-opencode-title"
const log = Log.create({ service: "plugin.openai.ws" })
export interface CreateWebSocketFetchOptions {
httpFetch?: typeof globalThis.fetch
url?: string
connectTimeout?: number
idleTimeout?: number
maxConnectionAge?: number
connectionLimitRetries?: number
}
interface PoolEntry {
socket?: WebSocket
connectedAt?: number
lastUsedAt: number
busy: boolean
fallback: boolean
}
const DEFAULT_CONNECT_TIMEOUT = 15_000
const DEFAULT_IDLE_TIMEOUT = 5 * 60 * 1000
const DEFAULT_MAX_CONNECTION_AGE = 55 * 60 * 1000
const CONNECTION_LIMIT_REACHED_CODE = "websocket_connection_limit_reached"
export function createWebSocketFetch(options?: CreateWebSocketFetchOptions) {
const httpFetch = options?.httpFetch ?? globalThis.fetch
const pool = new Map<string, PoolEntry>()
const connectTimeout = options?.connectTimeout ?? DEFAULT_CONNECT_TIMEOUT
const idleTimeout = options?.idleTimeout ?? DEFAULT_IDLE_TIMEOUT
const maxConnectionAge = options?.maxConnectionAge ?? DEFAULT_MAX_CONNECTION_AGE
const connectionLimitRetries = options?.connectionLimitRetries ?? 5
const pruneTimer = setInterval(() => prune(), Math.min(idleTimeout, 60_000))
if (typeof pruneTimer === "object" && "unref" in pruneTimer && typeof pruneTimer.unref === "function") {
pruneTimer.unref()
}
async function websocketFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
const url = input instanceof URL ? input.toString() : typeof input === "string" ? input : input.url
const internalHeaders = OpenAIWebSocket.normalizeHeaders(init?.headers)
const httpInit = withoutInternalHeaders(init)
if (init?.method !== "POST" || !new URL(url).pathname.endsWith("/responses")) {
return httpFetch(input, httpInit)
}
const body = (() => {
try {
if (typeof init?.body !== "string") return undefined
const parsed = JSON.parse(init.body)
return typeof parsed === "object" && parsed !== null ? parsed : undefined
} catch {
return undefined
}
})()
if (!body?.stream) return httpFetch(input, httpInit)
if (internalHeaders[TITLE_HEADER] === "true") {
log.debug("http fallback", { reason: "title" })
return httpFetch(input, httpInit)
}
const sessionID = internalHeaders["x-session-affinity"] ?? internalHeaders["session-id"]
if (!sessionID) {
log.debug("http fallback", { reason: "missing_session" })
return httpFetch(input, httpInit)
}
const key = `${sessionID}:conversation`
const entry = pool.get(key) ?? { lastUsedAt: Date.now(), busy: false, fallback: false }
pool.set(key, entry)
if (entry.fallback) {
log.debug("http fallback", { key, reason: "fallback_active" })
return httpFetch(input, httpInit)
}
if (entry.busy) {
log.debug("http fallback", { key, reason: "busy" })
return httpFetch(input, httpInit)
}
entry.busy = true
entry.lastUsedAt = Date.now()
try {
let connectionLimitAttempts = 0
entry.socket = await socket(
entry,
options?.url ?? url,
OpenAIWebSocket.normalizeHeaders(httpInit?.headers),
connectTimeout,
maxConnectionAge,
init?.signal,
)
let resolveFirstEvent: (started: boolean) => void = () => {}
let rejectFirstEvent: (error: Error) => void = () => {}
const firstEvent = new Promise<boolean>((resolve, reject) => {
resolveFirstEvent = resolve
rejectFirstEvent = reject
})
const response = OpenAIWebSocket.streamResponsesWebSocket({
socket: entry.socket,
body,
idleTimeout,
signal: init?.signal ?? undefined,
onFirstEvent: () => resolveFirstEvent(true),
onTerminal: (event) => {
entry.busy = false
entry.lastUsedAt = Date.now()
if (event.type !== "response.completed" && event.type !== "response.done") {
log.warn("websocket terminal failure", { key, type: event.type })
invalidate(entry)
}
},
onConnectionInvalid: (error) => {
log.warn("websocket invalidated", { key, error: error instanceof Error ? error.message : String(error) })
entry.busy = false
entry.fallback = true
invalidate(entry)
resolveFirstEvent(false)
},
onAbort: (error) => {
log.debug("websocket aborted", { key })
entry.busy = false
entry.lastUsedAt = Date.now()
invalidate(entry)
rejectFirstEvent(error)
},
onRetryableTerminal: async (event) => {
const error = connectionLimitError(event)
if (!error) return undefined
if (connectionLimitAttempts >= connectionLimitRetries) throw error
connectionLimitAttempts++
log.warn("websocket connection limit reached", { key, attempt: connectionLimitAttempts })
invalidate(entry)
entry.socket = await socket(
entry,
options?.url ?? url,
OpenAIWebSocket.normalizeHeaders(httpInit?.headers),
connectTimeout,
maxConnectionAge,
init?.signal,
)
entry.lastUsedAt = Date.now()
return entry.socket
},
})
if (await firstEvent) return response
log.debug("http fallback", { key, reason: "websocket_failed_before_first_event" })
return httpFetch(input, httpInit)
} catch (error) {
entry.busy = false
entry.lastUsedAt = Date.now()
if (OpenAIWebSocket.isAbortError(error)) {
invalidate(entry)
throw error
}
entry.fallback = true
log.warn("websocket setup failed", { key, error: error instanceof Error ? error.message : String(error), fallback: "http" })
invalidate(entry)
return httpFetch(input, httpInit)
}
}
function prune() {
const now = Date.now()
for (const [key, entry] of pool) {
if (entry.busy) continue
if (now - entry.lastUsedAt < idleTimeout) continue
log.debug("websocket idle prune", { key })
invalidate(entry)
pool.delete(key)
}
}
function close() {
log.debug("websocket pool close", { count: pool.size })
clearInterval(pruneTimer)
for (const entry of pool.values()) invalidate(entry)
pool.clear()
}
return Object.assign(websocketFetch, { close })
}
function connectionLimitError(event: Record<string, unknown>) {
if (event.type !== "error" || !isRecord(event.error) || event.error.code !== CONNECTION_LIMIT_REACHED_CODE) return
return new Error(typeof event.error.message === "string" ? event.error.message : CONNECTION_LIMIT_REACHED_CODE)
}
async function socket(
entry: PoolEntry,
url: string,
headers: Record<string, string>,
connectTimeout: number,
maxConnectionAge: number,
signal?: AbortSignal | null,
) {
if (entry.socket?.readyState === WebSocket.OPEN && entry.connectedAt && Date.now() - entry.connectedAt < maxConnectionAge) {
return entry.socket
}
invalidate(entry)
const next = await OpenAIWebSocket.connectResponsesWebSocket({
url: OpenAIWebSocket.toWebSocketUrl(url),
headers,
timeout: connectTimeout,
signal: signal ?? undefined,
})
entry.connectedAt = Date.now()
return next
}
function invalidate(entry: PoolEntry) {
if (entry.socket) {
entry.socket.on("error", () => {})
entry.socket.terminate()
entry.socket = undefined
}
entry.connectedAt = undefined
}
export function withoutInternalHeaders<T extends { headers?: HeadersInit }>(init: T | undefined): T | undefined {
if (!init?.headers) return init
if (init.headers instanceof Headers) {
const headers = new Headers(init.headers)
headers.delete(TITLE_HEADER)
return { ...init, headers }
}
if (Array.isArray(init.headers)) {
return { ...init, headers: init.headers.filter((item) => item[0].toLowerCase() !== TITLE_HEADER) }
}
return {
...init,
headers: Object.fromEntries(Object.entries(init.headers).filter(([key]) => key.toLowerCase() !== TITLE_HEADER)),
}
}
export * as OpenAIWebSocketPool from "./ws-pool"
+315
View File
@@ -0,0 +1,315 @@
// Low-level OpenAI Responses WebSocket protocol helpers. Session pooling,
// fallback, and continuation state intentionally live above this file.
import WebSocket from "ws"
export const PROTOCOL_HEADER = "responses_websockets=2026-02-06"
export interface ConnectResponsesWebSocketOptions {
url: string
headers: Record<string, string>
timeout?: number
signal?: AbortSignal
}
export interface StreamResponsesWebSocketOptions {
socket: WebSocket
body: Record<string, unknown>
idleTimeout?: number
signal?: AbortSignal
onFirstEvent?: () => void
onComplete?: (event: Record<string, unknown>) => void
onTerminal?: (event: Record<string, unknown>) => void
onRetryableTerminal?: (event: Record<string, unknown>) => Promise<WebSocket | undefined>
onConnectionInvalid?: (error: Error) => void
onAbort?: (error: Error) => void
}
export function toWebSocketUrl(url: string) {
return url.replace(/^http/, "ws")
}
export function normalizeHeaders(headers: HeadersInit | undefined): Record<string, string> {
const result: Record<string, string> = {}
if (!headers) return result
if (headers instanceof Headers) {
headers.forEach((value, key) => {
result[key.toLowerCase()] = value
})
return result
}
if (Array.isArray(headers)) {
for (const [key, value] of headers) {
result[key.toLowerCase()] = value
}
return result
}
for (const [key, value] of Object.entries(headers)) {
if (value != null) result[key.toLowerCase()] = value
}
return result
}
export function isAbortError(error: unknown): error is DOMException {
return error instanceof DOMException && error.name === "AbortError"
}
export function connectResponsesWebSocket(options: ConnectResponsesWebSocketOptions) {
return new Promise<WebSocket>((resolve, reject) => {
if (options.signal?.aborted) {
reject(abortError(options.signal))
return
}
const headers: Record<string, string> = {
...options.headers,
"openai-beta": options.headers["openai-beta"] ?? PROTOCOL_HEADER,
}
delete headers["content-length"]
const socket = new WebSocket(options.url, { headers })
const timeout = options.timeout
? setTimeout(() => {
cleanup()
socket.on("error", () => {})
socket.terminate()
reject(new Error("WebSocket connect timed out"))
}, options.timeout)
: undefined
function cleanup() {
if (timeout) clearTimeout(timeout)
socket.off("open", onOpen)
socket.off("error", onError)
socket.off("close", onClose)
options.signal?.removeEventListener("abort", onAbort)
}
function onOpen() {
cleanup()
resolve(socket)
}
function onError(error: Error) {
socket.on("error", () => {})
cleanup()
reject(error)
}
function onClose(code: number, reason: Buffer) {
cleanup()
reject(closeError("WebSocket closed before open", code, reason))
}
function onAbort() {
cleanup()
socket.on("error", () => {})
socket.terminate()
reject(abortError(options.signal))
}
socket.once("open", onOpen)
socket.once("error", onError)
socket.once("close", onClose)
options.signal?.addEventListener("abort", onAbort, { once: true })
})
}
export function streamResponsesWebSocket(options: StreamResponsesWebSocketOptions) {
const encoder = new TextEncoder()
let socket = options.socket
let controller: ReadableStreamDefaultController<Uint8Array> | undefined
let cleanupSocket = () => {}
let completed = false
let emitted = false
let idleTimer: ReturnType<typeof setTimeout> | undefined
function cleanup() {
if (idleTimer) clearTimeout(idleTimer)
cleanupSocket()
options.signal?.removeEventListener("abort", onAbort)
}
function terminateSocket(target = socket) {
target.on("error", () => {})
target.terminate()
}
function closeCompleted() {
cleanup()
controller?.enqueue(encoder.encode("data: [DONE]\n\n"))
controller?.close()
}
function invalidate(error: Error) {
if (completed) return
completed = true
cleanup()
options.onConnectionInvalid?.(error)
controller?.error(error)
}
function resetIdleTimeout(message: string) {
if (completed) return
if (!options.idleTimeout) return
if (idleTimer) clearTimeout(idleTimer)
idleTimer = setTimeout(() => invalidate(new Error(message)), options.idleTimeout)
if (typeof idleTimer === "object" && "unref" in idleTimer && typeof idleTimer.unref === "function") {
idleTimer.unref()
}
}
async function onMessage(data: WebSocket.RawData, isBinary: boolean) {
if (completed) return
if (isBinary) {
invalidate(new Error("Unexpected binary WebSocket frame"))
return
}
const text = data.toString()
const event = (() => {
try {
const parsed = JSON.parse(text)
return typeof parsed === "object" && parsed !== null ? parsed : undefined
} catch {
return undefined
}
})()
if (event?.type === "error" && !emitted && options.onRetryableTerminal) {
cleanupSocket()
if (idleTimer) clearTimeout(idleTimer)
idleTimer = undefined
try {
const next = await options.onRetryableTerminal(event)
if (completed) {
if (next) terminateSocket(next)
return
}
if (next) {
attach(next)
return
}
} catch (error) {
invalidate(error instanceof Error ? error : new Error(String(error)))
return
}
}
if (!emitted) options.onFirstEvent?.()
controller?.enqueue(encoder.encode(`${text.split(/\r?\n/).map((line) => `data: ${line}`).join("\n")}\n\n`))
emitted = true
resetIdleTimeout("idle timeout waiting for websocket")
if (!event) return
if (event.type === "response.completed" || event.type === "response.done") {
completed = true
options.onComplete?.(event)
options.onTerminal?.(event)
closeCompleted()
return
}
if (event.type === "response.failed" || event.type === "response.incomplete" || event.type === "error") {
completed = true
options.onTerminal?.(event)
closeCompleted()
}
}
function onError(error: Error) {
invalidate(error)
}
function onClose(code: number, reason: Buffer) {
if (completed) return
invalidate(closeError("WebSocket closed before response.completed", code, reason))
}
function onAbort() {
const error = abortError(options.signal)
if (completed) return
completed = true
cleanup()
terminateSocket()
options.onAbort?.(error)
controller?.error(error)
}
function onCancel(reason: unknown) {
if (completed) return
completed = true
cleanup()
terminateSocket()
options.onAbort?.(cancelError(reason))
}
function attach(next: WebSocket) {
cleanupSocket()
socket = next
socket.on("message", onMessage)
socket.once("error", onError)
socket.once("close", onClose)
cleanupSocket = () => {
socket.off("message", onMessage)
socket.off("error", onError)
socket.off("close", onClose)
}
const { stream: _stream, background: _background, ...payload } = options.body
resetIdleTimeout("idle timeout sending websocket request")
socket.send(JSON.stringify({ type: "response.create", ...payload }), (error) => {
if (completed) return
resetIdleTimeout("idle timeout waiting for websocket")
if (error) invalidate(error)
})
}
return new Response(
new ReadableStream<Uint8Array>({
start(next) {
controller = next
options.signal?.addEventListener("abort", onAbort, { once: true })
if (options.signal?.aborted) {
onAbort()
return
}
attach(socket)
},
cancel(reason) {
onCancel(reason)
},
}),
{
status: 200,
headers: { "content-type": "text/event-stream" },
},
)
}
function cancelError(reason: unknown) {
if (isAbortError(reason)) return reason
if (reason instanceof Error) return reason
return new DOMException(typeof reason === "string" ? reason : "Aborted", "AbortError")
}
function abortError(signal: AbortSignal | undefined) {
const reason = signal?.reason
if (isAbortError(reason)) return reason
return new DOMException(reason instanceof Error ? reason.message : "Aborted", "AbortError")
}
function closeError(message: string, code: number, reason: Buffer) {
const details = [`code ${code}`]
if (code === 1009) details.push("message too big")
if (reason.length > 0) details.push(reason.toString())
return new Error(`${message} (${details.join(": ")})`)
}
export * as OpenAIWebSocket from "./ws"