icebear icebear0828 commited on
Commit
d07b5c0
·
unverified ·
1 Parent(s): 0fc4785

feat: WebSocket transport + previous_response_id for multi-turn (#84)

Browse files

* feat: WebSocket transport + previous_response_id for multi-turn (#83)

The Codex backend only supports `previous_response_id` over WebSocket,
not HTTP SSE. This adds a dual-track transport layer:

- /v1/responses now connects to the backend via WebSocket by default
- WebSocket omits the `store` field, letting the server persist responses
- Clients can send `previous_response_id` to reference earlier turns
- On WebSocket failure, automatically falls back to HTTP SSE

New files:
- src/proxy/ws-transport.ts — WebSocket-to-SSE bridge (ReadableStream)

Dependencies: ws, https-proxy-agent

* fix: move ws from devDependencies to dependencies

ws is imported at runtime by src/proxy/ws-transport.ts, so it must be
in dependencies for production installs (npm install --omit=dev).

---------

Co-authored-by: icebear0828 <icebear0828@users.noreply.github.com>

CHANGELOG.md CHANGED
@@ -16,6 +16,7 @@
16
 
17
  - 模型列表自动同步:后端动态 fetch 成功后自动回写 `config/models.yaml`,静态配置不再滞后;前端每 60s 轮询模型列表,新模型无需刷新页面即可选择
18
  - Tuple Schema 支持:`prefixItems`(JSON Schema 2020-12 tuple)自动转换为等价 object schema 发给上游,响应侧还原为数组;OpenAI / Gemini / Responses 三端点统一支持
 
19
 
20
  ### Fixed
21
 
 
16
 
17
  - 模型列表自动同步:后端动态 fetch 成功后自动回写 `config/models.yaml`,静态配置不再滞后;前端每 60s 轮询模型列表,新模型无需刷新页面即可选择
18
  - Tuple Schema 支持:`prefixItems`(JSON Schema 2020-12 tuple)自动转换为等价 object schema 发给上游,响应侧还原为数组;OpenAI / Gemini / Responses 三端点统一支持
19
+ - WebSocket 传输 + `previous_response_id` 多轮支持:`/v1/responses` 端点自动通过 WebSocket 连接上游,服务端持久化 response,客户端可通过 `previous_response_id` 引用前轮对话实现增量多轮;WebSocket 失败自动降级回 HTTP SSE (#83)
20
 
21
  ### Fixed
22
 
package-lock.json CHANGED
@@ -1,24 +1,27 @@
1
  {
2
  "name": "codex-proxy",
3
- "version": "1.0.45",
4
  "lockfileVersion": 3,
5
  "requires": true,
6
  "packages": {
7
  "": {
8
  "name": "codex-proxy",
9
- "version": "1.0.45",
10
  "hasInstallScript": true,
11
  "dependencies": {
12
  "@hono/node-server": "^1.0.0",
13
  "hono": "^4.0.0",
 
14
  "js-yaml": "^4.1.0",
15
  "undici": "^7.0.0",
 
16
  "zod": "^3.23.0"
17
  },
18
  "devDependencies": {
19
  "@electron/asar": "^3.2.0",
20
  "@types/js-yaml": "^4.0.0",
21
  "@types/node": "^22.0.0",
 
22
  "@vitest/coverage-v8": "^3.2.4",
23
  "electron-to-chromium": "^1.5.302",
24
  "js-beautify": "^1.15.0",
@@ -1053,6 +1056,16 @@
1053
  "undici-types": "~6.21.0"
1054
  }
1055
  },
 
 
 
 
 
 
 
 
 
 
1056
  "node_modules/@vitest/coverage-v8": {
1057
  "version": "3.2.4",
1058
  "resolved": "https://registry.npmmirror.com/@vitest/coverage-v8/-/coverage-v8-3.2.4.tgz",
@@ -1212,6 +1225,15 @@
1212
  "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
1213
  }
1214
  },
 
 
 
 
 
 
 
 
 
1215
  "node_modules/ansi-regex": {
1216
  "version": "6.2.2",
1217
  "resolved": "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-6.2.2.tgz",
@@ -1395,7 +1417,6 @@
1395
  "version": "4.4.3",
1396
  "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz",
1397
  "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
1398
- "dev": true,
1399
  "license": "MIT",
1400
  "dependencies": {
1401
  "ms": "^2.1.3"
@@ -1682,6 +1703,19 @@
1682
  "dev": true,
1683
  "license": "MIT"
1684
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
1685
  "node_modules/inflight": {
1686
  "version": "1.0.6",
1687
  "resolved": "https://registry.npmmirror.com/inflight/-/inflight-1.0.6.tgz",
@@ -1983,7 +2017,6 @@
1983
  "version": "2.1.3",
1984
  "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz",
1985
  "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
1986
- "dev": true,
1987
  "license": "MIT"
1988
  },
1989
  "node_modules/nanoid": {
@@ -2941,6 +2974,27 @@
2941
  "dev": true,
2942
  "license": "ISC"
2943
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2944
  "node_modules/zod": {
2945
  "version": "3.25.76",
2946
  "resolved": "https://registry.npmmirror.com/zod/-/zod-3.25.76.tgz",
 
1
  {
2
  "name": "codex-proxy",
3
+ "version": "1.0.50",
4
  "lockfileVersion": 3,
5
  "requires": true,
6
  "packages": {
7
  "": {
8
  "name": "codex-proxy",
9
+ "version": "1.0.50",
10
  "hasInstallScript": true,
11
  "dependencies": {
12
  "@hono/node-server": "^1.0.0",
13
  "hono": "^4.0.0",
14
+ "https-proxy-agent": "^8.0.0",
15
  "js-yaml": "^4.1.0",
16
  "undici": "^7.0.0",
17
+ "ws": "^8.19.0",
18
  "zod": "^3.23.0"
19
  },
20
  "devDependencies": {
21
  "@electron/asar": "^3.2.0",
22
  "@types/js-yaml": "^4.0.0",
23
  "@types/node": "^22.0.0",
24
+ "@types/ws": "^8.18.1",
25
  "@vitest/coverage-v8": "^3.2.4",
26
  "electron-to-chromium": "^1.5.302",
27
  "js-beautify": "^1.15.0",
 
1056
  "undici-types": "~6.21.0"
1057
  }
1058
  },
1059
+ "node_modules/@types/ws": {
1060
+ "version": "8.18.1",
1061
+ "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
1062
+ "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
1063
+ "dev": true,
1064
+ "license": "MIT",
1065
+ "dependencies": {
1066
+ "@types/node": "*"
1067
+ }
1068
+ },
1069
  "node_modules/@vitest/coverage-v8": {
1070
  "version": "3.2.4",
1071
  "resolved": "https://registry.npmmirror.com/@vitest/coverage-v8/-/coverage-v8-3.2.4.tgz",
 
1225
  "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
1226
  }
1227
  },
1228
+ "node_modules/agent-base": {
1229
+ "version": "8.0.0",
1230
+ "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-8.0.0.tgz",
1231
+ "integrity": "sha512-QT8i0hCz6C/KQ+KTAbSNwCHDGdmUJl2tp2ZpNlGSWCfhUNVbYG2WLE3MdZGBAgXPV4GAvjGMxo+C1hroyxmZEg==",
1232
+ "license": "MIT",
1233
+ "engines": {
1234
+ "node": ">= 14"
1235
+ }
1236
+ },
1237
  "node_modules/ansi-regex": {
1238
  "version": "6.2.2",
1239
  "resolved": "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-6.2.2.tgz",
 
1417
  "version": "4.4.3",
1418
  "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz",
1419
  "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
 
1420
  "license": "MIT",
1421
  "dependencies": {
1422
  "ms": "^2.1.3"
 
1703
  "dev": true,
1704
  "license": "MIT"
1705
  },
1706
+ "node_modules/https-proxy-agent": {
1707
+ "version": "8.0.0",
1708
+ "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-8.0.0.tgz",
1709
+ "integrity": "sha512-YYeW+iCnAS3xhvj2dvVoWgsbca3RfQy/IlaNHHOtDmU0jMqPI9euIq3Y9BJETdxk16h9NHHCKqp/KB9nIMStCQ==",
1710
+ "license": "MIT",
1711
+ "dependencies": {
1712
+ "agent-base": "8.0.0",
1713
+ "debug": "^4.3.4"
1714
+ },
1715
+ "engines": {
1716
+ "node": ">= 14"
1717
+ }
1718
+ },
1719
  "node_modules/inflight": {
1720
  "version": "1.0.6",
1721
  "resolved": "https://registry.npmmirror.com/inflight/-/inflight-1.0.6.tgz",
 
2017
  "version": "2.1.3",
2018
  "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz",
2019
  "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
 
2020
  "license": "MIT"
2021
  },
2022
  "node_modules/nanoid": {
 
2974
  "dev": true,
2975
  "license": "ISC"
2976
  },
2977
+ "node_modules/ws": {
2978
+ "version": "8.19.0",
2979
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
2980
+ "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
2981
+ "license": "MIT",
2982
+ "engines": {
2983
+ "node": ">=10.0.0"
2984
+ },
2985
+ "peerDependencies": {
2986
+ "bufferutil": "^4.0.1",
2987
+ "utf-8-validate": ">=5.0.2"
2988
+ },
2989
+ "peerDependenciesMeta": {
2990
+ "bufferutil": {
2991
+ "optional": true
2992
+ },
2993
+ "utf-8-validate": {
2994
+ "optional": true
2995
+ }
2996
+ }
2997
+ },
2998
  "node_modules/zod": {
2999
  "version": "3.25.76",
3000
  "resolved": "https://registry.npmmirror.com/zod/-/zod-3.25.76.tgz",
package.json CHANGED
@@ -23,8 +23,10 @@
23
  "dependencies": {
24
  "@hono/node-server": "^1.0.0",
25
  "hono": "^4.0.0",
 
26
  "js-yaml": "^4.1.0",
27
  "undici": "^7.0.0",
 
28
  "zod": "^3.23.0"
29
  },
30
  "optionalDependencies": {
@@ -34,6 +36,7 @@
34
  "@electron/asar": "^3.2.0",
35
  "@types/js-yaml": "^4.0.0",
36
  "@types/node": "^22.0.0",
 
37
  "@vitest/coverage-v8": "^3.2.4",
38
  "electron-to-chromium": "^1.5.302",
39
  "js-beautify": "^1.15.0",
 
23
  "dependencies": {
24
  "@hono/node-server": "^1.0.0",
25
  "hono": "^4.0.0",
26
+ "https-proxy-agent": "^8.0.0",
27
  "js-yaml": "^4.1.0",
28
  "undici": "^7.0.0",
29
+ "ws": "^8.19.0",
30
  "zod": "^3.23.0"
31
  },
32
  "optionalDependencies": {
 
36
  "@electron/asar": "^3.2.0",
37
  "@types/js-yaml": "^4.0.0",
38
  "@types/node": "^22.0.0",
39
+ "@types/ws": "^8.18.1",
40
  "@vitest/coverage-v8": "^3.2.4",
41
  "electron-to-chromium": "^1.5.302",
42
  "js-beautify": "^1.15.0",
src/proxy/codex-api.ts CHANGED
@@ -15,6 +15,7 @@ import {
15
  buildHeaders,
16
  buildHeadersWithContentType,
17
  } from "../fingerprint/manager.js";
 
18
  import type { CookieJar } from "./cookie-jar.js";
19
  import type { BackendModelEntry } from "../models/model-store.js";
20
 
@@ -43,6 +44,10 @@ export interface CodexResponsesRequest {
43
  strict?: boolean;
44
  };
45
  };
 
 
 
 
46
  }
47
 
48
  /** Structured content part for multimodal Codex input. */
@@ -244,11 +249,71 @@ export class CodexApi {
244
 
245
  /**
246
  * Create a response (streaming).
247
- * Returns the raw Response so the caller can process the SSE stream.
 
248
  */
249
  async createResponse(
250
  request: CodexResponsesRequest,
251
  signal?: AbortSignal,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
252
  ): Promise<Response> {
253
  const config = getConfig();
254
  const transport = getTransport();
@@ -262,10 +327,9 @@ export class CodexApi {
262
  // Codex Desktop sends this beta header to enable newer API features
263
  headers["OpenAI-Beta"] = "responses_websockets=2026-02-06";
264
 
265
- // Strip service_tier from body — Codex backend doesn't accept it as a body field.
266
- // Desktop app handles Fast mode internally (lighter reasoning), not via the API.
267
- const { service_tier: _st, ...bodyWithoutServiceTier } = request;
268
- const body = JSON.stringify(bodyWithoutServiceTier);
269
 
270
  // No wall-clock timeout for streaming SSE — header timeout + AbortSignal provide protection
271
  let transportRes;
 
15
  buildHeaders,
16
  buildHeadersWithContentType,
17
  } from "../fingerprint/manager.js";
18
+ import { createWebSocketResponse, type WsCreateRequest } from "./ws-transport.js";
19
  import type { CookieJar } from "./cookie-jar.js";
20
  import type { BackendModelEntry } from "../models/model-store.js";
21
 
 
44
  strict?: boolean;
45
  };
46
  };
47
+ /** Optional: reference a previous response for multi-turn (WebSocket only). */
48
+ previous_response_id?: string;
49
+ /** When true, use WebSocket transport (enables previous_response_id and server-side storage). */
50
+ useWebSocket?: boolean;
51
  }
52
 
53
  /** Structured content part for multimodal Codex input. */
 
249
 
250
  /**
251
  * Create a response (streaming).
252
+ * Routes to WebSocket when previous_response_id is present (HTTP SSE doesn't support it).
253
+ * Falls back to HTTP SSE if WebSocket fails.
254
  */
255
  async createResponse(
256
  request: CodexResponsesRequest,
257
  signal?: AbortSignal,
258
+ ): Promise<Response> {
259
+ if (request.useWebSocket) {
260
+ try {
261
+ return await this.createResponseViaWebSocket(request, signal);
262
+ } catch (err) {
263
+ const msg = err instanceof Error ? err.message : String(err);
264
+ console.warn(`[CodexApi] WebSocket failed (${msg}), falling back to HTTP SSE`);
265
+ // Fallback: strip previous_response_id and use HTTP
266
+ const { previous_response_id: _, useWebSocket: _ws, ...httpRequest } = request;
267
+ return this.createResponseViaHttp(httpRequest as CodexResponsesRequest, signal);
268
+ }
269
+ }
270
+ return this.createResponseViaHttp(request, signal);
271
+ }
272
+
273
+ /**
274
+ * Create a response via WebSocket (for previous_response_id support).
275
+ * Returns a Response with SSE-formatted body, compatible with parseStream().
276
+ */
277
+ private async createResponseViaWebSocket(
278
+ request: CodexResponsesRequest,
279
+ signal?: AbortSignal,
280
+ ): Promise<Response> {
281
+ const config = getConfig();
282
+ const baseUrl = config.api.base_url;
283
+ const wsUrl = baseUrl.replace(/^https?:/, "wss:") + "/codex/responses";
284
+
285
+ // Build headers — same auth but no Content-Type (WebSocket upgrade)
286
+ const headers = this.applyHeaders(
287
+ buildHeaders(this.token, this.accountId),
288
+ );
289
+ headers["OpenAI-Beta"] = "responses_websockets=2026-02-06";
290
+ headers["x-openai-internal-codex-residency"] = "us";
291
+
292
+ // Build flat WebSocket message — omit store, stream, service_tier
293
+ const wsRequest: WsCreateRequest = {
294
+ type: "response.create",
295
+ model: request.model,
296
+ instructions: request.instructions,
297
+ input: request.input,
298
+ };
299
+ if (request.previous_response_id) {
300
+ wsRequest.previous_response_id = request.previous_response_id;
301
+ }
302
+ if (request.reasoning) wsRequest.reasoning = request.reasoning;
303
+ if (request.tools?.length) wsRequest.tools = request.tools;
304
+ if (request.tool_choice) wsRequest.tool_choice = request.tool_choice;
305
+ if (request.text) wsRequest.text = request.text;
306
+
307
+ return createWebSocketResponse(wsUrl, headers, wsRequest, signal, this.proxyUrl);
308
+ }
309
+
310
+ /**
311
+ * Create a response via HTTP SSE (default transport).
312
+ * Uses curl-impersonate for TLS fingerprinting.
313
+ */
314
+ private async createResponseViaHttp(
315
+ request: CodexResponsesRequest,
316
+ signal?: AbortSignal,
317
  ): Promise<Response> {
318
  const config = getConfig();
319
  const transport = getTransport();
 
327
  // Codex Desktop sends this beta header to enable newer API features
328
  headers["OpenAI-Beta"] = "responses_websockets=2026-02-06";
329
 
330
+ // Strip non-API fields from body — not supported by HTTP SSE.
331
+ const { service_tier: _st, previous_response_id: _pid, useWebSocket: _ws, ...bodyFields } = request;
332
+ const body = JSON.stringify(bodyFields);
 
333
 
334
  // No wall-clock timeout for streaming SSE — header timeout + AbortSignal provide protection
335
  let transportRes;
src/proxy/ws-transport.ts ADDED
@@ -0,0 +1,150 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * WebSocket transport for the Codex Responses API.
3
+ *
4
+ * Opens a WebSocket to the backend, sends a `response.create` message,
5
+ * and wraps incoming JSON messages into an SSE-formatted ReadableStream.
6
+ * This lets parseStream() and all downstream consumers work identically
7
+ * regardless of whether HTTP SSE or WebSocket was used.
8
+ *
9
+ * Used when `previous_response_id` is present — HTTP SSE does not support it.
10
+ */
11
+
12
+ import WebSocket from "ws";
13
+ import { HttpsProxyAgent } from "https-proxy-agent";
14
+ import type { CodexInputItem } from "./codex-api.js";
15
+
16
+ /** Flat WebSocket message format expected by the Codex backend. */
17
+ export interface WsCreateRequest {
18
+ type: "response.create";
19
+ model: string;
20
+ instructions: string;
21
+ input: CodexInputItem[];
22
+ previous_response_id?: string;
23
+ reasoning?: { effort?: string; summary?: string };
24
+ tools?: unknown[];
25
+ tool_choice?: string | { type: string; name: string };
26
+ text?: {
27
+ format: {
28
+ type: "text" | "json_object" | "json_schema";
29
+ name?: string;
30
+ schema?: Record<string, unknown>;
31
+ strict?: boolean;
32
+ };
33
+ };
34
+ // NOTE: `store` and `stream` are intentionally omitted.
35
+ // The backend defaults to storing via WebSocket and always streams.
36
+ }
37
+
38
+ /**
39
+ * Open a WebSocket to the Codex backend, send `response.create`,
40
+ * and return a Response whose body is an SSE-formatted ReadableStream.
41
+ *
42
+ * The SSE format matches what parseStream() expects:
43
+ * event: <type>\ndata: <json>\n\n
44
+ */
45
+ export function createWebSocketResponse(
46
+ wsUrl: string,
47
+ headers: Record<string, string>,
48
+ request: WsCreateRequest,
49
+ signal?: AbortSignal,
50
+ proxyUrl?: string | null,
51
+ ): Promise<Response> {
52
+ return new Promise<Response>((resolve, reject) => {
53
+ if (signal?.aborted) {
54
+ reject(new Error("Aborted before WebSocket connect"));
55
+ return;
56
+ }
57
+
58
+ const wsOpts: WebSocket.ClientOptions = { headers };
59
+ if (proxyUrl) {
60
+ wsOpts.agent = new HttpsProxyAgent(proxyUrl);
61
+ }
62
+ const ws = new WebSocket(wsUrl, wsOpts);
63
+ const encoder = new TextEncoder();
64
+ let controller: ReadableStreamDefaultController<Uint8Array> | null = null;
65
+ let streamClosed = false;
66
+ let connected = false;
67
+
68
+ function closeStream() {
69
+ if (!streamClosed && controller) {
70
+ streamClosed = true;
71
+ try { controller.close(); } catch { /* already closed */ }
72
+ }
73
+ }
74
+
75
+ function errorStream(err: Error) {
76
+ if (!streamClosed && controller) {
77
+ streamClosed = true;
78
+ try { controller.error(err); } catch { /* already closed */ }
79
+ }
80
+ }
81
+
82
+ // Abort signal handling
83
+ const onAbort = () => {
84
+ ws.close(1000, "aborted");
85
+ if (!connected) {
86
+ reject(new Error("Aborted during WebSocket connect"));
87
+ }
88
+ };
89
+ signal?.addEventListener("abort", onAbort, { once: true });
90
+
91
+ const stream = new ReadableStream<Uint8Array>({
92
+ start(c) {
93
+ controller = c;
94
+ },
95
+ cancel() {
96
+ ws.close(1000, "stream cancelled");
97
+ },
98
+ });
99
+
100
+ ws.on("open", () => {
101
+ connected = true;
102
+ ws.send(JSON.stringify(request));
103
+
104
+ // Return the Response immediately — events will flow into the stream
105
+ const responseHeaders = new Headers({ "content-type": "text/event-stream" });
106
+ resolve(new Response(stream, { status: 200, headers: responseHeaders }));
107
+ });
108
+
109
+ ws.on("message", (data: Buffer | string) => {
110
+ if (streamClosed) return;
111
+ const raw = typeof data === "string" ? data : data.toString("utf-8");
112
+
113
+ try {
114
+ const msg = JSON.parse(raw) as Record<string, unknown>;
115
+ const type = (msg.type as string) ?? "unknown";
116
+
117
+ // Re-encode as SSE: event: <type>\ndata: <full json>\n\n
118
+ const sse = `event: ${type}\ndata: ${raw}\n\n`;
119
+ controller!.enqueue(encoder.encode(sse));
120
+
121
+ // Close stream after response.completed, response.failed, or error
122
+ if (type === "response.completed" || type === "response.failed" || type === "error") {
123
+ // Let the SSE chunk flush, then close
124
+ queueMicrotask(() => {
125
+ closeStream();
126
+ ws.close(1000);
127
+ });
128
+ }
129
+ } catch {
130
+ // Non-JSON message — emit as raw data
131
+ const sse = `data: ${raw}\n\n`;
132
+ controller!.enqueue(encoder.encode(sse));
133
+ }
134
+ });
135
+
136
+ ws.on("error", (err: Error) => {
137
+ signal?.removeEventListener("abort", onAbort);
138
+ if (!connected) {
139
+ reject(err);
140
+ } else {
141
+ errorStream(err);
142
+ }
143
+ });
144
+
145
+ ws.on("close", (_code: number, _reason: Buffer) => {
146
+ signal?.removeEventListener("abort", onAbort);
147
+ closeStream();
148
+ });
149
+ });
150
+ }
src/routes/responses.ts CHANGED
@@ -304,6 +304,13 @@ export function createResponsesRoutes(
304
  store: false,
305
  };
306
 
 
 
 
 
 
 
 
307
  // Reasoning effort: explicit body > suffix > model default > config default
308
  const effort =
309
  (isRecord(body.reasoning) && typeof body.reasoning.effort === "string"
 
304
  store: false,
305
  };
306
 
307
+ // Responses API always uses WebSocket transport — enables server-side storage
308
+ // and previous_response_id for multi-turn conversations.
309
+ codexRequest.useWebSocket = true;
310
+ if (typeof body.previous_response_id === "string") {
311
+ codexRequest.previous_response_id = body.previous_response_id;
312
+ }
313
+
314
  // Reasoning effort: explicit body > suffix > model default > config default
315
  const effort =
316
  (isRecord(body.reasoning) && typeof body.reasoning.effort === "string"