openai-ws.test.ts 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884
  1. import { describe, expect, test } from "bun:test"
  2. import { EventEmitter } from "node:events"
  3. import { createServer, type IncomingMessage, type Server as HttpServer } from "node:http"
  4. import net, { type AddressInfo, type Socket } from "node:net"
  5. import WebSocket, { WebSocketServer } from "ws"
  6. import { APICallError } from "ai"
  7. import { ProviderError } from "../../src/provider/error"
  8. import { OpenAIWebSocket } from "../../src/plugin/openai/ws"
  9. import { OpenAIWebSocketPool, TITLE_HEADER } from "../../src/plugin/openai/ws-pool"
  10. describe("plugin.openai.ws", () => {
  11. test("derives websocket URLs and sends auth plus protocol headers", async () => {
  12. let headers: IncomingMessage["headers"] | undefined
  13. await using server = await createWebSocketServer((_socket, request) => {
  14. headers = request.headers
  15. })
  16. const socket = await OpenAIWebSocket.connectResponsesWebSocket({
  17. url: server.wsUrl,
  18. headers: { authorization: "Bearer test", "content-length": "123" },
  19. })
  20. expect(OpenAIWebSocket.toWebSocketUrl("http://example.com/v1/responses")).toBe("ws://example.com/v1/responses")
  21. expect(OpenAIWebSocket.toWebSocketUrl("https://example.com/v1/responses")).toBe("wss://example.com/v1/responses")
  22. expect(headers?.authorization).toBe("Bearer test")
  23. expect(headers?.["openai-beta"]).toBe(OpenAIWebSocket.PROTOCOL_HEADER)
  24. expect(headers?.["content-length"]).toBeUndefined()
  25. socket.terminate()
  26. })
  27. test("enforces websocket connect timeout", async () => {
  28. await using server = await createHangingTcpServer()
  29. await expect(
  30. OpenAIWebSocket.connectResponsesWebSocket({
  31. url: server.wsUrl,
  32. headers: {},
  33. timeout: 20,
  34. }),
  35. ).rejects.toThrow("WebSocket connect timed out")
  36. })
  37. test("surfaces websocket upgrade rejection messages", async () => {
  38. await using server = await createRejectingWebSocketServer(() => {})
  39. await expect(
  40. OpenAIWebSocket.connectResponsesWebSocket({
  41. url: server.wsUrl,
  42. headers: {},
  43. }),
  44. ).rejects.toThrow("Expected 101 status code")
  45. })
  46. test("enforces websocket send idle timeout", async () => {
  47. const socket = new (class extends EventEmitter {
  48. send(_data: string, _callback: (error?: Error) => void) {}
  49. })() as unknown as WebSocket
  50. const invalid: string[] = []
  51. const response = OpenAIWebSocket.streamResponsesWebSocket({
  52. socket,
  53. body: { stream: true, input: "hi" },
  54. idleTimeout: 20,
  55. onConnectionInvalid: (error) => invalid.push(error.message),
  56. })
  57. expect((await readTextError(response.text())).message).toContain("idle timeout sending websocket request")
  58. expect(invalid).toEqual(["idle timeout sending websocket request"])
  59. })
  60. test("streams websocket events as SSE and handles response.done", async () => {
  61. let requestBody: unknown
  62. await using server = await createWebSocketServer((socket) => {
  63. socket.once("message", (data) => {
  64. requestBody = JSON.parse(data.toString())
  65. socket.send(JSON.stringify({ type: "response.output_text.delta", delta: "hello" }))
  66. socket.send(JSON.stringify({ type: "response.done", response: { id: "resp_123" } }))
  67. socket.close(1000, "done")
  68. })
  69. })
  70. const socket = await OpenAIWebSocket.connectResponsesWebSocket({
  71. url: server.wsUrl,
  72. headers: { authorization: "Bearer test", "content-length": "123" },
  73. })
  74. const completed: Record<string, unknown>[] = []
  75. const response = OpenAIWebSocket.streamResponsesWebSocket({
  76. socket,
  77. body: { stream: true, background: true, input: "hi" },
  78. onComplete: (event) => completed.push(event),
  79. })
  80. expect(await response.text()).toBe(
  81. 'data: {"type":"response.output_text.delta","delta":"hello"}\n\ndata: {"type":"response.done","response":{"id":"resp_123"}}\n\ndata: [DONE]\n\n',
  82. )
  83. expect(requestBody).toEqual({ type: "response.create", input: "hi" })
  84. expect(completed).toHaveLength(1)
  85. expect(completed[0]?.type).toBe("response.done")
  86. })
  87. test("errors the SSE stream when the server closes before a terminal event", async () => {
  88. const invalid: Error[] = []
  89. await using server = await createWebSocketServer((socket) => {
  90. socket.once("message", () => {
  91. socket.close(1009, "payload too large")
  92. })
  93. })
  94. const socket = await OpenAIWebSocket.connectResponsesWebSocket({ url: server.wsUrl, headers: {} })
  95. const response = OpenAIWebSocket.streamResponsesWebSocket({
  96. socket,
  97. body: { stream: true, input: "hi" },
  98. onConnectionInvalid: (error) => invalid.push(error),
  99. })
  100. expect((await readTextError(response.text())).message).toContain(
  101. "WebSocket closed before response.completed (code 1009: message too big: payload too large)",
  102. )
  103. expect(invalid[0]).toBeInstanceOf(ProviderError.ResponseStreamError)
  104. expect(invalid.map((error) => error.message)).toEqual([
  105. "WebSocket closed before response.completed (code 1009: message too big: payload too large)",
  106. ])
  107. })
  108. test("rejects unexpected binary websocket frames", async () => {
  109. const invalid: string[] = []
  110. await using server = await createWebSocketServer((socket) => {
  111. socket.once("message", () => {
  112. socket.send(Buffer.from("not json text"))
  113. })
  114. })
  115. const socket = await OpenAIWebSocket.connectResponsesWebSocket({ url: server.wsUrl, headers: {} })
  116. const response = OpenAIWebSocket.streamResponsesWebSocket({
  117. socket,
  118. body: { stream: true, input: "hi" },
  119. onConnectionInvalid: (error) => invalid.push(error.message),
  120. })
  121. expect((await readTextError(response.text())).message).toContain("Unexpected binary WebSocket frame")
  122. expect(invalid).toEqual(["Unexpected binary WebSocket frame"])
  123. })
  124. })
  125. describe("plugin.openai.ws-pool", () => {
  126. test("reuses one healthy websocket for sequential requests", async () => {
  127. let connections = 0
  128. let messages = 0
  129. await using server = await createWebSocketServer((socket) => {
  130. connections += 1
  131. socket.on("message", () => {
  132. messages += 1
  133. socket.send(JSON.stringify({ type: "response.completed", response: { id: `resp_${messages}` } }))
  134. })
  135. })
  136. const fetch = OpenAIWebSocketPool.createWebSocketFetch({
  137. url: server.url,
  138. })
  139. const first = await fetch(server.url, streamRequest())
  140. expect(await first.text()).toContain("data: [DONE]")
  141. const second = await fetch(server.url, streamRequest())
  142. expect(await second.text()).toContain("data: [DONE]")
  143. expect(connections).toBe(1)
  144. expect(messages).toBe(2)
  145. fetch.close()
  146. })
  147. test("rotates a socket that exceeds max connection age", async () => {
  148. let connections = 0
  149. await using server = await createWebSocketServer((socket) => {
  150. connections += 1
  151. socket.on("message", () => {
  152. socket.send(JSON.stringify({ type: "response.completed", response: { id: `resp_${connections}` } }))
  153. })
  154. })
  155. const fetch = OpenAIWebSocketPool.createWebSocketFetch({
  156. url: server.url,
  157. maxConnectionAge: 0,
  158. })
  159. const first = await fetch(server.url, streamRequest())
  160. expect(await first.text()).toContain("data: [DONE]")
  161. const second = await fetch(server.url, streamRequest())
  162. expect(await second.text()).toContain("data: [DONE]")
  163. expect(connections).toBe(2)
  164. fetch.close()
  165. })
  166. test("falls back to HTTP after websocket setup retries are exhausted", async () => {
  167. const attempts: string[] = []
  168. await using server = await createRejectingWebSocketServer(() => attempts.push("websocket"))
  169. const fetch = OpenAIWebSocketPool.createWebSocketFetch({
  170. url: server.url,
  171. connectTimeout: 100,
  172. streamRetries: 1,
  173. })
  174. const first = await fetch(server.url, streamRequest({ [TITLE_HEADER]: "false" }))
  175. expect(await readTextError(first.text())).toBeInstanceOf(ProviderError.ResponseStreamError)
  176. const second = await fetch(server.url, streamRequest({ [TITLE_HEADER]: "false" }))
  177. const third = await fetch(server.url, streamRequest({ [TITLE_HEADER]: "false" }))
  178. expect(await second.text()).toBe("http")
  179. expect(await third.text()).toBe("http")
  180. expect(attempts).toEqual(["websocket", "websocket"])
  181. expect(server.httpRequests).toHaveLength(2)
  182. expect(server.httpRequests[0]?.headers[TITLE_HEADER]).toBeUndefined()
  183. expect(server.httpRequests[1]?.headers[TITLE_HEADER]).toBeUndefined()
  184. fetch.close()
  185. })
  186. test("keeps HTTP fallback active after its idle timeout", async () => {
  187. let websocketAttempts = 0
  188. await using server = await createRejectingWebSocketServer(() => websocketAttempts++)
  189. const fetch = OpenAIWebSocketPool.createWebSocketFetch({
  190. url: server.url,
  191. connectTimeout: 100,
  192. idleTimeout: 20,
  193. streamRetries: 0,
  194. })
  195. const first = await fetch(server.url, streamRequest())
  196. expect(await first.text()).toBe("http")
  197. await new Promise((resolve) => setTimeout(resolve, 50))
  198. const second = await fetch(server.url, streamRequest())
  199. expect(await second.text()).toBe("http")
  200. expect(websocketAttempts).toBe(1)
  201. expect(server.httpRequests).toHaveLength(2)
  202. fetch.close()
  203. })
  204. test("removes HTTP fallback when its session is deleted", async () => {
  205. let websocketAttempts = 0
  206. await using server = await createRejectingWebSocketServer(() => websocketAttempts++)
  207. const fetch = OpenAIWebSocketPool.createWebSocketFetch({
  208. url: server.url,
  209. connectTimeout: 100,
  210. streamRetries: 0,
  211. })
  212. const first = await fetch(server.url, streamRequest())
  213. expect(await first.text()).toBe("http")
  214. fetch.remove("session-1")
  215. const second = await fetch(server.url, streamRequest())
  216. expect(await second.text()).toBe("http")
  217. expect(websocketAttempts).toBe(2)
  218. expect(server.httpRequests).toHaveLength(2)
  219. fetch.close()
  220. })
  221. test("terminates active websocket connections when their session is deleted", async () => {
  222. let connections = 0
  223. await using server = await createWebSocketServer((socket) => {
  224. connections += 1
  225. socket.once("message", () => {
  226. if (connections === 1) {
  227. socket.send(JSON.stringify({ type: "response.output_text.delta", delta: "started" }))
  228. return
  229. }
  230. socket.send(JSON.stringify({ type: "response.completed", response: { id: "resp_after_remove" } }))
  231. })
  232. })
  233. const fetch = OpenAIWebSocketPool.createWebSocketFetch({
  234. url: server.url,
  235. })
  236. const first = await fetch(server.url, streamRequest())
  237. const firstText = first.text()
  238. fetch.remove("session-1")
  239. expect((await readTextError(firstText)).message).toContain("WebSocket closed before response.completed")
  240. const second = await fetch(server.url, streamRequest())
  241. expect(await second.text()).toContain("data: [DONE]")
  242. expect(connections).toBe(2)
  243. fetch.close()
  244. })
  245. test("prunes idle websocket connections after completed responses", async () => {
  246. let connections = 0
  247. let closed = 0
  248. await using server = await createWebSocketServer((socket) => {
  249. connections += 1
  250. socket.once("close", () => closed++)
  251. socket.once("message", () => {
  252. socket.send(JSON.stringify({ type: "response.completed", response: { id: `resp_${connections}` } }))
  253. })
  254. })
  255. const fetch = OpenAIWebSocketPool.createWebSocketFetch({
  256. url: server.url,
  257. idleTimeout: 20,
  258. })
  259. const first = await fetch(server.url, streamRequest())
  260. expect(await first.text()).toContain("data: [DONE]")
  261. await waitFor(() => closed === 1, "idle websocket was not pruned")
  262. const second = await fetch(server.url, streamRequest())
  263. expect(await second.text()).toContain("data: [DONE]")
  264. expect(connections).toBe(2)
  265. fetch.close()
  266. })
  267. test("invalidates but does not reuse a socket after terminal failure frames", async () => {
  268. let connections = 0
  269. await using server = await createWebSocketServer((socket) => {
  270. connections += 1
  271. socket.once("message", () => {
  272. socket.send(JSON.stringify({ type: connections === 1 ? "response.failed" : "response.completed" }))
  273. })
  274. })
  275. const fetch = OpenAIWebSocketPool.createWebSocketFetch({
  276. url: server.url,
  277. })
  278. const first = await fetch(server.url, streamRequest())
  279. expect(await first.text()).toContain('data: {"type":"response.failed"}')
  280. const second = await fetch(server.url, streamRequest())
  281. expect(await second.text()).toContain('data: {"type":"response.completed"}')
  282. expect(connections).toBe(2)
  283. expect(server.httpRequests).toHaveLength(0)
  284. fetch.close()
  285. })
  286. test("returns initial websocket error frames as HTTP-style API errors", async () => {
  287. const error = {
  288. type: "invalid_request_error",
  289. message: "The model is not supported when using Codex with a ChatGPT account.",
  290. }
  291. const event = {
  292. type: "error",
  293. status: 400,
  294. error,
  295. headers: {
  296. "x-codex-primary-window-minutes": 15,
  297. ignored: { nested: true },
  298. },
  299. }
  300. await using server = await createWebSocketServer((socket) => {
  301. socket.once("message", () => {
  302. socket.send(JSON.stringify(event))
  303. })
  304. })
  305. const fetch = OpenAIWebSocketPool.createWebSocketFetch({
  306. url: server.url,
  307. })
  308. const response = await fetch(server.url, streamRequest())
  309. expect(response.status).toBe(400)
  310. expect(response.headers.get("content-type")).toContain("application/json")
  311. expect(response.headers.get("x-codex-primary-window-minutes")).toBe("15")
  312. expect(response.headers.get("ignored")).toBeNull()
  313. expect(await response.json()).toEqual(event)
  314. fetch.close()
  315. })
  316. test("fails mid-stream wrapped websocket errors as HTTP-style API errors", async () => {
  317. const event = {
  318. type: "error",
  319. status_code: 429,
  320. error: {
  321. type: "usage_limit_reached",
  322. message: "The usage limit has been reached",
  323. },
  324. headers: {
  325. "x-codex-primary-used-percent": "100.0",
  326. },
  327. }
  328. await using server = await createWebSocketServer((socket) => {
  329. socket.once("message", () => {
  330. socket.send(JSON.stringify({ type: "response.output_text.delta", delta: "started" }))
  331. socket.send(JSON.stringify(event))
  332. })
  333. })
  334. const fetch = OpenAIWebSocketPool.createWebSocketFetch({
  335. url: server.url,
  336. })
  337. const response = await fetch(server.url, streamRequest())
  338. const error = await readTextError(response.text())
  339. expect(APICallError.isInstance(error)).toBe(true)
  340. if (!APICallError.isInstance(error)) throw new Error("Expected APICallError")
  341. expect(error.statusCode).toBe(429)
  342. expect(error.responseHeaders).toEqual({ "x-codex-primary-used-percent": "100.0" })
  343. expect(error.responseBody).toBe(JSON.stringify(event))
  344. fetch.close()
  345. })
  346. test("retries websocket connection limit errors on the next stream attempt", async () => {
  347. let connections = 0
  348. let messages = 0
  349. await using server = await createWebSocketServer((socket) => {
  350. connections += 1
  351. socket.once("message", () => {
  352. messages += 1
  353. if (connections === 1) {
  354. socket.send(
  355. JSON.stringify({
  356. type: "error",
  357. status: 400,
  358. error: {
  359. type: "invalid_request_error",
  360. code: "websocket_connection_limit_reached",
  361. message: "Responses websocket connection limit reached",
  362. },
  363. }),
  364. )
  365. return
  366. }
  367. socket.send(JSON.stringify({ type: "response.completed", response: { id: "resp_retry" } }))
  368. })
  369. })
  370. const fetch = OpenAIWebSocketPool.createWebSocketFetch({
  371. url: server.url,
  372. })
  373. const first = await fetch(server.url, streamRequest())
  374. expect((await readTextError(first.text())).message).toContain("Responses websocket connection limit reached")
  375. const second = await fetch(server.url, streamRequest())
  376. const text = await second.text()
  377. expect(text).not.toContain("websocket_connection_limit_reached")
  378. expect(text).toContain('data: {"type":"response.completed","response":{"id":"resp_retry"}}')
  379. expect(text).toContain("data: [DONE]")
  380. expect(connections).toBe(2)
  381. expect(messages).toBe(2)
  382. expect(server.httpRequests).toHaveLength(0)
  383. fetch.close()
  384. })
  385. test("falls back to HTTP after websocket connection limit retries are exhausted", async () => {
  386. let connections = 0
  387. await using server = await createWebSocketServer((socket) => {
  388. connections += 1
  389. socket.once("message", () => {
  390. socket.send(
  391. JSON.stringify({
  392. type: "error",
  393. status: 400,
  394. error: {
  395. type: "invalid_request_error",
  396. code: "websocket_connection_limit_reached",
  397. message: "Responses websocket connection limit reached",
  398. },
  399. }),
  400. )
  401. })
  402. })
  403. const fetch = OpenAIWebSocketPool.createWebSocketFetch({
  404. url: server.url,
  405. streamRetries: 2,
  406. })
  407. const first = await fetch(server.url, streamRequest())
  408. expect((await readTextError(first.text())).message).toContain("Responses websocket connection limit reached")
  409. const second = await fetch(server.url, streamRequest())
  410. expect((await readTextError(second.text())).message).toContain("Responses websocket connection limit reached")
  411. const third = await fetch(server.url, streamRequest())
  412. const fourth = await fetch(server.url, streamRequest())
  413. expect(await third.text()).toBe("http")
  414. expect(await fourth.text()).toBe("http")
  415. expect(connections).toBe(3)
  416. expect(server.httpRequests).toHaveLength(2)
  417. fetch.close()
  418. })
  419. test("shares the websocket retry budget across stream and connection limit failures", async () => {
  420. let connections = 0
  421. await using server = await createWebSocketServer((socket) => {
  422. connections += 1
  423. socket.once("message", () => {
  424. if (connections === 1) {
  425. socket.send(JSON.stringify({ type: "response.output_text.delta", delta: "started" }))
  426. socket.terminate()
  427. return
  428. }
  429. socket.send(
  430. JSON.stringify({
  431. type: "error",
  432. error: {
  433. code: "websocket_connection_limit_reached",
  434. message: "Responses websocket connection limit reached",
  435. },
  436. }),
  437. )
  438. })
  439. })
  440. const fetch = OpenAIWebSocketPool.createWebSocketFetch({
  441. url: server.url,
  442. streamRetries: 1,
  443. })
  444. const first = await fetch(server.url, streamRequest())
  445. expect((await readTextError(first.text())).message).toContain("WebSocket closed before response.completed")
  446. const second = await fetch(server.url, streamRequest())
  447. expect(await second.text()).toBe("http")
  448. expect(connections).toBe(2)
  449. expect(server.httpRequests).toHaveLength(1)
  450. fetch.close()
  451. })
  452. test("retries websocket idle failures before first event then falls back to HTTP", async () => {
  453. let connections = 0
  454. await using server = await createWebSocketServer((socket) => {
  455. connections += 1
  456. socket.once("message", () => {})
  457. })
  458. const fetch = OpenAIWebSocketPool.createWebSocketFetch({
  459. url: server.url,
  460. idleTimeout: 20,
  461. streamRetries: 1,
  462. })
  463. const first = await fetch(server.url, streamRequest())
  464. expect((await readTextError(first.text())).message).toContain("idle timeout waiting for websocket")
  465. const second = await fetch(server.url, streamRequest())
  466. const third = await fetch(server.url, streamRequest())
  467. expect(await second.text()).toBe("http")
  468. expect(await third.text()).toBe("http")
  469. expect(connections).toBe(2)
  470. expect(server.httpRequests).toHaveLength(2)
  471. fetch.close()
  472. })
  473. test("keeps websocket retry state until the failed stream becomes idle", async () => {
  474. let connections = 0
  475. await using server = await createWebSocketServer((socket) => {
  476. connections += 1
  477. socket.once("message", () => {})
  478. })
  479. const fetch = OpenAIWebSocketPool.createWebSocketFetch({
  480. url: server.url,
  481. idleTimeout: 500,
  482. streamRetries: 1,
  483. })
  484. await new Promise((resolve) => setTimeout(resolve, 250))
  485. const first = await fetch(server.url, streamRequest())
  486. expect((await readTextError(first.text())).message).toContain("idle timeout waiting for websocket")
  487. await new Promise((resolve) => setTimeout(resolve, 300))
  488. const second = await fetch(server.url, streamRequest())
  489. expect(await second.text()).toBe("http")
  490. expect(connections).toBe(2)
  491. expect(server.httpRequests).toHaveLength(1)
  492. fetch.close()
  493. })
  494. test("retries failed websocket streams before using HTTP fallback", async () => {
  495. const attempts: Array<(socket: WebSocket) => void> = []
  496. await using server = await createWebSocketServer((socket) => {
  497. socket.once("message", () => {
  498. socket.send(JSON.stringify({ type: "response.output_text.delta", delta: "started" }))
  499. attempts.shift()?.(socket)
  500. })
  501. })
  502. const fetch = OpenAIWebSocketPool.createWebSocketFetch({
  503. url: server.url,
  504. streamRetries: 1,
  505. })
  506. const firstAttempt = new Promise<WebSocket>((resolve) => attempts.push(resolve))
  507. const first = await fetch(server.url, streamRequest())
  508. const firstSocket = await firstAttempt
  509. firstSocket.terminate()
  510. expect((await readTextError(first.text())).message).toContain("WebSocket closed before response.completed")
  511. const secondAttempt = new Promise<WebSocket>((resolve) => attempts.push(resolve))
  512. const second = await fetch(server.url, streamRequest())
  513. const secondSocket = await secondAttempt
  514. secondSocket.terminate()
  515. expect((await readTextError(second.text())).message).toContain("WebSocket closed before response.completed")
  516. const third = await fetch(server.url, streamRequest())
  517. expect(await third.text()).toBe("http")
  518. expect(server.httpRequests).toHaveLength(1)
  519. fetch.close()
  520. })
  521. test("resets websocket stream failures after a completed response", async () => {
  522. let connections = 0
  523. let requests = 0
  524. await using server = await createWebSocketServer((socket) => {
  525. connections += 1
  526. socket.on("message", () => {
  527. requests += 1
  528. if (requests === 1 || requests === 3) {
  529. socket.send(JSON.stringify({ type: "response.output_text.delta", delta: "started" }))
  530. socket.terminate()
  531. return
  532. }
  533. socket.send(JSON.stringify({ type: "response.completed", response: { id: `resp_${requests}` } }))
  534. })
  535. })
  536. const fetch = OpenAIWebSocketPool.createWebSocketFetch({
  537. url: server.url,
  538. streamRetries: 1,
  539. })
  540. const first = await fetch(server.url, streamRequest())
  541. expect((await readTextError(first.text())).message).toContain("WebSocket closed before response.completed")
  542. const second = await fetch(server.url, streamRequest())
  543. expect(await second.text()).toContain("data: [DONE]")
  544. const third = await fetch(server.url, streamRequest())
  545. expect((await readTextError(third.text())).message).toContain("WebSocket closed before response.completed")
  546. const fourth = await fetch(server.url, streamRequest())
  547. expect(await fourth.text()).toContain("data: [DONE]")
  548. expect(connections).toBe(3)
  549. expect(requests).toBe(4)
  550. expect(server.httpRequests).toHaveLength(0)
  551. fetch.close()
  552. })
  553. test("falls back to HTTP for missing session and title requests", async () => {
  554. await using server = await createWebSocketServer(() => {})
  555. const fetch = OpenAIWebSocketPool.createWebSocketFetch()
  556. const missingSession = await fetch(server.url, {
  557. method: "POST",
  558. headers: { [TITLE_HEADER]: "false" },
  559. body: JSON.stringify({ stream: true }),
  560. })
  561. const title = await fetch(server.url, streamRequest({ [TITLE_HEADER]: "true" }))
  562. expect(await missingSession.text()).toBe("http")
  563. expect(await title.text()).toBe("http")
  564. expect(server.httpRequests).toHaveLength(2)
  565. expect(server.httpRequests[0]?.headers[TITLE_HEADER]).toBeUndefined()
  566. expect(server.httpRequests[1]?.headers[TITLE_HEADER]).toBeUndefined()
  567. fetch.close()
  568. })
  569. test("falls back to HTTP while a websocket lane is busy", async () => {
  570. let connections = 0
  571. await using server = await createWebSocketServer((socket) => {
  572. connections += 1
  573. socket.once("message", () => {
  574. socket.send(JSON.stringify({ type: "response.output_text.delta", delta: "started" }))
  575. })
  576. })
  577. const abort = new AbortController()
  578. const fetch = OpenAIWebSocketPool.createWebSocketFetch({
  579. url: server.url,
  580. })
  581. const first = await fetch(server.url, streamRequest({}, abort.signal))
  582. const firstText = first.text()
  583. await waitFor(() => connections === 1, "websocket did not connect")
  584. const second = await fetch(server.url, streamRequest())
  585. expect(await second.text()).toBe("http")
  586. expect(server.httpRequests).toHaveLength(1)
  587. expect(connections).toBe(1)
  588. abort.abort(new Error("stop"))
  589. expect((await readTextError(firstText)).message).toContain("stop")
  590. fetch.close()
  591. })
  592. test("reserves a websocket lane while its socket is connecting", async () => {
  593. await using server = await createHangingTcpServer()
  594. await using fallback = await createHttpServer()
  595. const fetch = OpenAIWebSocketPool.createWebSocketFetch({
  596. url: server.url,
  597. connectTimeout: 20,
  598. streamRetries: 0,
  599. })
  600. const first = fetch(fallback.url, streamRequest())
  601. await waitFor(() => server.connections() === 1, "first websocket did not begin connecting")
  602. const second = fetch(fallback.url, streamRequest())
  603. expect(await (await second).text()).toBe("http")
  604. expect(await (await first).text()).toBe("http")
  605. expect(server.connections()).toBe(1)
  606. expect(fallback.httpRequests).toHaveLength(2)
  607. fetch.close()
  608. })
  609. test("retries unexpected closes before first event then falls back to HTTP", async () => {
  610. let connections = 0
  611. await using server = await createWebSocketServer((socket) => {
  612. connections += 1
  613. socket.once("message", () => {
  614. socket.close(1001, "server shutdown")
  615. })
  616. })
  617. const fetch = OpenAIWebSocketPool.createWebSocketFetch({
  618. url: server.url,
  619. streamRetries: 1,
  620. })
  621. const first = await fetch(server.url, streamRequest())
  622. expect((await readTextError(first.text())).message).toContain("WebSocket closed before response.completed")
  623. const second = await fetch(server.url, streamRequest())
  624. const third = await fetch(server.url, streamRequest())
  625. expect(await second.text()).toBe("http")
  626. expect(await third.text()).toBe("http")
  627. expect(connections).toBe(2)
  628. expect(server.httpRequests).toHaveLength(2)
  629. fetch.close()
  630. })
  631. test("does not keep HTTP fallback active after aborting a websocket response", async () => {
  632. let connections = 0
  633. await using server = await createWebSocketServer((socket) => {
  634. connections += 1
  635. socket.once("message", () => {
  636. if (connections === 1) {
  637. socket.send(JSON.stringify({ type: "response.output_text.delta", delta: "started" }))
  638. return
  639. }
  640. socket.send(JSON.stringify({ type: "response.completed", response: { id: "resp_456" } }))
  641. })
  642. })
  643. const abort = new AbortController()
  644. const fetch = OpenAIWebSocketPool.createWebSocketFetch({
  645. url: server.url,
  646. })
  647. const first = await fetch(server.url, streamRequest({}, abort.signal))
  648. const firstText = first.text()
  649. await waitFor(() => connections === 1, "first websocket did not connect")
  650. abort.abort(new Error("stop"))
  651. expect((await readTextError(firstText)).message).toContain("stop")
  652. const second = await fetch(server.url, streamRequest())
  653. expect(await second.text()).toContain("data: [DONE]")
  654. expect(connections).toBe(2)
  655. expect(server.httpRequests).toHaveLength(0)
  656. fetch.close()
  657. })
  658. test("releases the websocket lane when the response body is cancelled", async () => {
  659. let connections = 0
  660. await using server = await createWebSocketServer((socket) => {
  661. connections += 1
  662. socket.once("message", () => {
  663. if (connections === 1) {
  664. socket.send(JSON.stringify({ type: "response.output_text.delta", delta: "started" }))
  665. return
  666. }
  667. socket.send(JSON.stringify({ type: "response.completed", response: { id: "resp_after_cancel" } }))
  668. })
  669. })
  670. const fetch = OpenAIWebSocketPool.createWebSocketFetch({
  671. url: server.url,
  672. })
  673. const first = await fetch(server.url, streamRequest())
  674. await waitFor(() => connections === 1, "first websocket did not connect")
  675. await first.body!.cancel("stop")
  676. const second = await fetch(server.url, streamRequest())
  677. expect(await second.text()).toContain("data: [DONE]")
  678. expect(connections).toBe(2)
  679. expect(server.httpRequests).toHaveLength(0)
  680. fetch.close()
  681. })
  682. })
  683. function streamRequest(headers?: Record<string, string>, signal?: AbortSignal): RequestInit {
  684. return {
  685. method: "POST",
  686. headers: {
  687. "session-id": "session-1",
  688. authorization: "Bearer test",
  689. ...headers,
  690. },
  691. body: JSON.stringify({ stream: true, input: "hi" }),
  692. signal,
  693. }
  694. }
  695. async function readTextError(promise: Promise<string>) {
  696. // Bun 1.3.14 hangs on expect(response.text()).rejects for streams errored from ws callbacks.
  697. return promise.then(
  698. () => {
  699. throw new Error("Expected response text to reject")
  700. },
  701. (error) => {
  702. expect(error).toBeInstanceOf(Error)
  703. return error as Error
  704. },
  705. )
  706. }
  707. async function createWebSocketServer(onConnection: (socket: WebSocket, request: IncomingMessage) => void) {
  708. const http = await createHttpServer()
  709. const server = new WebSocketServer({ server: http.server })
  710. server.on("connection", onConnection)
  711. return websocketServerHandle(server, http)
  712. }
  713. async function createHangingTcpServer() {
  714. const sockets = new Set<Socket>()
  715. let connections = 0
  716. const server = net.createServer((socket) => {
  717. connections += 1
  718. sockets.add(socket)
  719. socket.on("close", () => sockets.delete(socket))
  720. })
  721. await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve))
  722. const address = server.address() as AddressInfo
  723. return {
  724. url: `http://127.0.0.1:${address.port}/v1/responses`,
  725. wsUrl: `ws://127.0.0.1:${address.port}/v1/responses`,
  726. connections: () => connections,
  727. async [Symbol.asyncDispose]() {
  728. for (const socket of sockets) socket.destroy()
  729. server.close()
  730. },
  731. }
  732. }
  733. async function createRejectingWebSocketServer(onAttempt: () => void) {
  734. const http = await createHttpServer()
  735. const server = new WebSocketServer({
  736. server: http.server,
  737. verifyClient(_info, callback) {
  738. onAttempt()
  739. callback(false, 401, "denied")
  740. },
  741. })
  742. return websocketServerHandle(server, http)
  743. }
  744. async function createHttpServer() {
  745. const httpRequests: IncomingMessage[] = []
  746. const server = createServer((request, response) => {
  747. httpRequests.push(request)
  748. response.writeHead(200, { "content-type": "text/plain" })
  749. response.end("http")
  750. })
  751. await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve))
  752. const address = server.address() as AddressInfo
  753. return {
  754. server,
  755. httpRequests,
  756. url: `http://127.0.0.1:${address.port}/v1/responses`,
  757. async [Symbol.asyncDispose]() {
  758. await closeHttpServer(server)
  759. },
  760. }
  761. }
  762. function websocketServerHandle(server: WebSocketServer, http: Awaited<ReturnType<typeof createHttpServer>>) {
  763. return {
  764. url: http.url,
  765. wsUrl: http.url.replace(/^http/, "ws"),
  766. httpRequests: http.httpRequests,
  767. async [Symbol.asyncDispose]() {
  768. for (const socket of server.clients) socket.terminate()
  769. server.close()
  770. http.server.close()
  771. },
  772. }
  773. }
  774. function closeHttpServer(server: HttpServer) {
  775. return new Promise<void>((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())))
  776. }
  777. async function waitFor(predicate: () => boolean, message: string) {
  778. const started = Date.now()
  779. while (!predicate()) {
  780. if (Date.now() - started > 1_000) throw new Error(message)
  781. await new Promise((resolve) => setTimeout(resolve, 1))
  782. }
  783. }