248 lines
8.2 KiB
TypeScript
248 lines
8.2 KiB
TypeScript
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"
|