feat(openai): add responses websocket transport (#29477)
This commit is contained in:
247
packages/opencode/src/plugin/openai/ws-pool.ts
Normal file
247
packages/opencode/src/plugin/openai/ws-pool.ts
Normal file
@@ -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"
|
||||
Reference in New Issue
Block a user