codex-copilot-dx 0.4.25 → 0.4.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/adapter.mjs CHANGED
@@ -1,14 +1,43 @@
1
1
  import http from "node:http";
2
2
  import { randomUUID } from "node:crypto";
3
- import { promisify } from "node:util";
4
- import * as zlib from "node:zlib";
5
3
  import { chatCompletions, listModels, responses as copilotResponses, responsesCompact as copilotResponsesCompact, getCachedModelEndpoints } from "./copilot.mjs";
6
4
  import { webStreamLines } from "./stream.mjs";
7
5
  import { anthropicToChat, chatToAnthropic, streamAnthropicFromLines, countTokens } from "./anthropic.mjs";
8
- import { claudeDesktopModelsResponse, resolveAnthropicModel, modelIsResponsesOnly, modelSupportsChatCompletions } from "./models.mjs";
6
+ import { CODEX_AUTO_REVIEW_MODEL, claudeDesktopModelsResponse, resolveAnthropicModel, resolveOpenAIModel, modelIsResponsesOnly, modelSupportsChatCompletions } from "./models.mjs";
9
7
  import { status } from "./status.mjs";
10
8
  import { recordAnthropicUsage, recordResponsesUsage } from "./usage.mjs";
11
9
  import { ADAPTER_HEALTH_PATH, adapterHealthPayload } from "./running-adapter.mjs";
10
+ import {
11
+ abortErrorStatusCode,
12
+ createRequestAbort,
13
+ httpError,
14
+ isAbortLikeError,
15
+ logRequestFailure,
16
+ readJsonBody,
17
+ sendJsonError,
18
+ sendUpstreamError,
19
+ writeOrDrain,
20
+ } from "./http-transport.mjs";
21
+ import {
22
+ clearResponseHistoryForTests,
23
+ configureResponseHistoryForTests,
24
+ materializeResponseHistory,
25
+ rememberResponseHistoryNode,
26
+ responseHistoryStats,
27
+ } from "./response-history.mjs";
28
+
29
+ export {
30
+ abortErrorStatusCode,
31
+ createRequestAbort,
32
+ isAbortLikeError,
33
+ readJsonBody,
34
+ writeOrDrain,
35
+ } from "./http-transport.mjs";
36
+ export {
37
+ clearResponseHistoryForTests,
38
+ configureResponseHistoryForTests,
39
+ responseHistoryStats,
40
+ } from "./response-history.mjs";
12
41
 
13
42
  // Fallback list for models known to only support the Responses API, used when
14
43
  // live model metadata (supported_endpoints) has not been cached yet.
@@ -37,183 +66,25 @@ function isResponsesOnlyModel(model) {
37
66
 
38
67
  // Direct Copilot Responses API proxy.
39
68
 
40
- const responseHistories = new Map();
41
- const responseHistoryChildren = new Map();
42
- const evictedResponseIds = new Set();
43
- const DEFAULT_RESPONSE_HISTORY_MAX_BYTES = 64 * 1024 * 1024;
44
- const DEFAULT_RESPONSE_HISTORY_MAX_ENTRIES = 4096;
45
- let responseHistoryBytes = 0;
46
- const DEFAULT_MAX_BODY_BYTES = 128 * 1024 * 1024;
47
- const DEFAULT_MAX_DECODED_BODY_BYTES = 256 * 1024 * 1024;
48
69
  const DEFAULT_UPSTREAM_TIMEOUT_MS = 120 * 1000;
49
- const gunzipAsync = promisify(zlib.gunzip);
50
- const inflateAsync = promisify(zlib.inflate);
51
- const brotliDecompressAsync = promisify(zlib.brotliDecompress);
52
- const zstdDecompressAsync = zlib.zstdDecompress ? promisify(zlib.zstdDecompress) : null;
70
+ const DEFAULT_STREAM_HANDSHAKE_TIMEOUT_MS = 120 * 1000;
71
+ const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 120 * 1000;
53
72
 
54
73
  function positiveInt(value, fallback) {
55
74
  const n = Number.parseInt(value, 10);
56
75
  return Number.isFinite(n) && n > 0 ? n : fallback;
57
76
  }
58
77
 
59
- const MAX_BODY_BYTES = positiveInt(process.env.CCDX_MAX_BODY_BYTES, DEFAULT_MAX_BODY_BYTES);
60
- const MAX_DECODED_BODY_BYTES = positiveInt(process.env.CCDX_MAX_DECODED_BODY_BYTES, DEFAULT_MAX_DECODED_BODY_BYTES);
61
78
  const UPSTREAM_TIMEOUT_MS = positiveInt(process.env.CCDX_UPSTREAM_TIMEOUT_MS, DEFAULT_UPSTREAM_TIMEOUT_MS);
62
- let responseHistoryMaxBytes = positiveInt(process.env.CCDX_RESPONSE_HISTORY_MAX_BYTES, DEFAULT_RESPONSE_HISTORY_MAX_BYTES);
63
- let responseHistoryMaxEntries = positiveInt(process.env.CCDX_RESPONSE_HISTORY_MAX_ENTRIES, DEFAULT_RESPONSE_HISTORY_MAX_ENTRIES);
79
+ const STREAM_HANDSHAKE_TIMEOUT_MS = positiveInt(process.env.CCDX_STREAM_HANDSHAKE_TIMEOUT_MS, DEFAULT_STREAM_HANDSHAKE_TIMEOUT_MS);
80
+ const STREAM_IDLE_TIMEOUT_MS = positiveInt(process.env.CCDX_STREAM_IDLE_TIMEOUT_MS, DEFAULT_STREAM_IDLE_TIMEOUT_MS);
64
81
 
65
82
  export function requestPath(reqUrl) {
66
83
  return new URL(reqUrl || "/", "http://localhost").pathname;
67
84
  }
68
85
 
69
86
  function cloneJson(value) {
70
- return value === undefined ? undefined : JSON.parse(JSON.stringify(value));
71
- }
72
-
73
- function jsonStringByteLength(value) {
74
- let bytes = 2;
75
- for (let index = 0; index < value.length; index += 1) {
76
- const code = value.charCodeAt(index);
77
- if (code === 0x22 || code === 0x5c) {
78
- bytes += 2;
79
- } else if (code <= 0x1f) {
80
- bytes += [0x08, 0x09, 0x0a, 0x0c, 0x0d].includes(code) ? 2 : 6;
81
- } else if (code <= 0x7f) {
82
- bytes += 1;
83
- } else if (code <= 0x7ff) {
84
- bytes += 2;
85
- } else if (code >= 0xd800 && code <= 0xdbff) {
86
- const next = value.charCodeAt(index + 1);
87
- if (next >= 0xdc00 && next <= 0xdfff) {
88
- bytes += 4;
89
- index += 1;
90
- } else {
91
- bytes += 6;
92
- }
93
- } else if (code >= 0xdc00 && code <= 0xdfff) {
94
- bytes += 6;
95
- } else {
96
- bytes += 3;
97
- }
98
- }
99
- return bytes;
100
- }
101
-
102
- function jsonByteLength(value, seen = new Set()) {
103
- if (value === null) return 4;
104
- if (typeof value === "string") return jsonStringByteLength(value);
105
- if (typeof value === "number") return Buffer.byteLength(JSON.stringify(value));
106
- if (typeof value === "boolean") return value ? 4 : 5;
107
- if (Array.isArray(value)) {
108
- if (seen.has(value)) throw new TypeError("Circular response history value");
109
- seen.add(value);
110
- let bytes = 2 + Math.max(0, value.length - 1);
111
- for (const item of value) {
112
- bytes += item === undefined || typeof item === "function" || typeof item === "symbol"
113
- ? 4
114
- : jsonByteLength(item, seen);
115
- }
116
- seen.delete(value);
117
- return bytes;
118
- }
119
- if (value && typeof value === "object") {
120
- if (seen.has(value)) throw new TypeError("Circular response history value");
121
- seen.add(value);
122
- const entries = Object.entries(value)
123
- .filter(([, item]) => item !== undefined && typeof item !== "function" && typeof item !== "symbol");
124
- let bytes = 2 + Math.max(0, entries.length - 1);
125
- for (const [key, item] of entries) bytes += jsonStringByteLength(key) + 1 + jsonByteLength(item, seen);
126
- seen.delete(value);
127
- return bytes;
128
- }
129
- return 4;
130
- }
131
-
132
- function httpError(message, statusCode) {
133
- const err = new Error(message);
134
- err.statusCode = statusCode;
135
- return err;
136
- }
137
-
138
- function payloadTooLarge(kind, maxBytes) {
139
- return httpError(`${kind} request body exceeds ${maxBytes} bytes`, 413);
140
- }
141
-
142
- async function readRequestBuffer(req, maxBytes) {
143
- const contentLength = Number.parseInt(req.headers?.["content-length"] || "", 10);
144
- if (Number.isFinite(contentLength) && contentLength > maxBytes) {
145
- throw payloadTooLarge("Raw", maxBytes);
146
- }
147
-
148
- const chunks = [];
149
- let total = 0;
150
- for await (const chunk of req) {
151
- const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
152
- total += buf.length;
153
- if (total > maxBytes) throw payloadTooLarge("Raw", maxBytes);
154
- chunks.push(buf);
155
- }
156
- return Buffer.concat(chunks);
157
- }
158
-
159
- async function decompressBody(decompress, buffer, maxBytes) {
160
- try {
161
- return await decompress(buffer, { maxOutputLength: maxBytes });
162
- } catch (e) {
163
- if (e?.code === "ERR_BUFFER_TOO_LARGE" || /maxOutputLength/i.test(e?.message || "")) {
164
- throw payloadTooLarge("Decoded", maxBytes);
165
- }
166
- throw e;
167
- }
168
- }
169
-
170
- async function decodeRequestBuffer(buffer, contentEncoding, maxBytes) {
171
- const encodings = String(contentEncoding || "identity")
172
- .split(",")
173
- .map((v) => v.trim().toLowerCase())
174
- .filter(Boolean);
175
-
176
- let decoded = buffer;
177
- for (const encoding of encodings.reverse()) {
178
- if (encoding === "identity") continue;
179
- if (encoding === "gzip" || encoding === "x-gzip") {
180
- decoded = await decompressBody(gunzipAsync, decoded, maxBytes);
181
- } else if (encoding === "deflate") {
182
- decoded = await decompressBody(inflateAsync, decoded, maxBytes);
183
- } else if (encoding === "br") {
184
- decoded = await decompressBody(brotliDecompressAsync, decoded, maxBytes);
185
- } else if (encoding === "zstd") {
186
- if (!zstdDecompressAsync) throw new Error("Unsupported Content-Encoding: zstd");
187
- decoded = await decompressBody(zstdDecompressAsync, decoded, maxBytes);
188
- } else {
189
- throw new Error(`Unsupported Content-Encoding: ${encoding}`);
190
- }
191
- if (decoded.length > maxBytes) throw payloadTooLarge("Decoded", maxBytes);
192
- }
193
- if (decoded.length > maxBytes) throw payloadTooLarge("Decoded", maxBytes);
194
- return decoded;
195
- }
196
-
197
- export async function readJsonBody(req, {
198
- maxBodyBytes = MAX_BODY_BYTES,
199
- maxDecodedBodyBytes = MAX_DECODED_BODY_BYTES,
200
- } = {}) {
201
- const buffer = await readRequestBuffer(req, maxBodyBytes);
202
- const decoded = await decodeRequestBuffer(buffer, req.headers?.["content-encoding"], maxDecodedBodyBytes);
203
- return JSON.parse(decoded.toString("utf8"));
204
- }
205
-
206
- function sendJsonError(res, err, fallbackStatus = 400) {
207
- if (res.destroyed || res.writableEnded) return;
208
- if (!res.headersSent) res.writeHead(err?.statusCode || fallbackStatus, { "Content-Type": "application/json" });
209
- res.end(JSON.stringify({ error: err?.message || "Request failed" }));
210
- }
211
-
212
- function sendUpstreamError(res, resp, text) {
213
- if (!res.headersSent) {
214
- res.writeHead(resp.status || 502, { "Content-Type": resp.headers?.get("content-type") || "application/json" });
215
- }
216
- res.end(text || JSON.stringify({ error: "Upstream request failed" }));
87
+ return value === undefined ? undefined : structuredClone(value);
217
88
  }
218
89
 
219
90
  function bearerToken(headers = {}) {
@@ -229,85 +100,13 @@ export function shouldServeClaudeDesktopModels(req, claudeDesktopApiKey) {
229
100
  return token === claudeDesktopApiKey || xApiKey === claudeDesktopApiKey;
230
101
  }
231
102
 
232
- export function writeOrDrain(res, chunk) {
233
- if (res.destroyed || res.writableEnded) return Promise.resolve(false);
234
- if (res.write(chunk)) return Promise.resolve(true);
235
- return new Promise((resolve, reject) => {
236
- const cleanup = () => {
237
- res.off("drain", onDrain);
238
- res.off("error", onError);
239
- res.off("close", onClose);
240
- };
241
- const onDrain = () => { cleanup(); resolve(true); };
242
- const onClose = () => { cleanup(); resolve(false); };
243
- const onError = (err) => { cleanup(); reject(err); };
244
- res.once("drain", onDrain);
245
- res.once("close", onClose);
246
- res.once("error", onError);
247
- });
248
- }
249
-
250
- export function isAbortLikeError(err) {
251
- return err?.name === "AbortError" || /operation was aborted/i.test(String(err?.message || ""));
252
- }
253
-
254
- export function abortErrorStatusCode(reason) {
255
- if (reason === "upstream_timeout") return 504;
256
- if (reason === "client_aborted" || reason === "client_closed") return 499;
257
- return 400;
258
- }
259
-
260
- export function createRequestAbort(req, res) {
261
- const controller = new AbortController();
262
- let timer = null;
263
- let cleaned = false;
264
- let reason = null;
265
- const abort = (nextReason = "aborted") => {
266
- if (!cleaned && !controller.signal.aborted) {
267
- reason = nextReason;
268
- controller.abort();
269
- }
270
- };
271
- const onReqAborted = () => abort("client_aborted");
272
- const onResClose = () => {
273
- if (!res.writableEnded) abort("client_closed");
274
- };
275
- req.on("aborted", onReqAborted);
276
- res.on("close", onResClose);
277
- return {
278
- signal: controller.signal,
279
- get reason() { return reason; },
280
- setTimeout(ms) {
281
- if (timer) clearTimeout(timer);
282
- if (ms > 0) timer = setTimeout(() => abort("upstream_timeout"), ms);
283
- },
284
- cleanup() {
285
- cleaned = true;
286
- if (timer) clearTimeout(timer);
287
- req.off("aborted", onReqAborted);
288
- res.off("close", onResClose);
289
- },
290
- };
291
- }
292
-
293
- function logRequestFailure(label, err, abort) {
294
- if (!isAbortLikeError(err)) {
295
- console.error(status("err", `${label} request failed: ${err.message}`));
296
- return;
297
- }
298
-
299
- const reason = abort?.reason || "aborted";
300
- err.statusCode ||= abortErrorStatusCode(reason);
301
- const detail = reason === "upstream_timeout" ? `${reason} after ${UPSTREAM_TIMEOUT_MS}ms` : reason;
302
- console.warn(status("warn", `${label} request aborted: ${detail}`));
303
- }
304
-
305
- function responsesInputItems(input) {
103
+ function responsesInputItems(input, { clone = true } = {}) {
306
104
  if (input === undefined || input === null) return [];
307
105
  if (typeof input === "string") {
308
106
  return [{ type: "message", role: "user", content: [{ type: "input_text", text: input }] }];
309
107
  }
310
- return Array.isArray(input) ? cloneJson(input) : [cloneJson(input)];
108
+ if (Array.isArray(input)) return clone ? cloneJson(input) : input;
109
+ return [clone ? cloneJson(input) : input];
311
110
  }
312
111
 
313
112
  export function stripInternalResponsesInputFields(inputItems) {
@@ -449,96 +248,6 @@ export async function openCopilotResponse(reqContext, upstream = copilotResponse
449
248
  throw httpError("Responses compatibility retry limit exceeded", 502);
450
249
  }
451
250
 
452
- export function clearResponseHistoryForTests() {
453
- responseHistories.clear();
454
- responseHistoryChildren.clear();
455
- evictedResponseIds.clear();
456
- responseHistoryBytes = 0;
457
- responseHistoryMaxBytes = DEFAULT_RESPONSE_HISTORY_MAX_BYTES;
458
- responseHistoryMaxEntries = DEFAULT_RESPONSE_HISTORY_MAX_ENTRIES;
459
- }
460
-
461
- export function configureResponseHistoryForTests({ maxBytes, maxEntries } = {}) {
462
- if (maxBytes !== undefined) responseHistoryMaxBytes = positiveInt(maxBytes, DEFAULT_RESPONSE_HISTORY_MAX_BYTES);
463
- if (maxEntries !== undefined) responseHistoryMaxEntries = positiveInt(maxEntries, DEFAULT_RESPONSE_HISTORY_MAX_ENTRIES);
464
- }
465
-
466
- export function responseHistoryStats() {
467
- return { entries: responseHistories.size, bytes: responseHistoryBytes, evicted: evictedResponseIds.size };
468
- }
469
-
470
- function rememberEvictedId(id) {
471
- evictedResponseIds.add(id);
472
- while (evictedResponseIds.size > 256) evictedResponseIds.delete(evictedResponseIds.values().next().value);
473
- }
474
-
475
- function linkHistoryChild(parentId, id) {
476
- if (!parentId) return;
477
- let children = responseHistoryChildren.get(parentId);
478
- if (!children) {
479
- children = new Set();
480
- responseHistoryChildren.set(parentId, children);
481
- }
482
- children.add(id);
483
- }
484
-
485
- function unlinkHistoryChild(parentId, id) {
486
- if (!parentId) return;
487
- const children = responseHistoryChildren.get(parentId);
488
- if (!children) return;
489
- children.delete(id);
490
- if (!children.size) responseHistoryChildren.delete(parentId);
491
- }
492
-
493
- function removeHistorySubtree(rootId) {
494
- const pending = [rootId];
495
- const removed = new Set();
496
- while (pending.length) {
497
- const id = pending.pop();
498
- if (removed.has(id)) continue;
499
- removed.add(id);
500
- const children = responseHistoryChildren.get(id);
501
- if (children) pending.push(...children);
502
- }
503
- for (const id of removed) {
504
- const entry = responseHistories.get(id);
505
- if (!entry) continue;
506
- unlinkHistoryChild(entry.parentId, id);
507
- responseHistoryChildren.delete(id);
508
- responseHistoryBytes -= entry.bytes;
509
- responseHistories.delete(id);
510
- rememberEvictedId(id);
511
- }
512
- }
513
-
514
- function enforceResponseHistoryLimits() {
515
- while (responseHistories.size > responseHistoryMaxEntries || responseHistoryBytes > responseHistoryMaxBytes) {
516
- const oldestId = responseHistories.keys().next().value;
517
- if (!oldestId) break;
518
- removeHistorySubtree(oldestId);
519
- }
520
- }
521
-
522
- function materializeResponseHistory(responseId) {
523
- const chain = [];
524
- const seen = new Set();
525
- let currentId = responseId;
526
- while (currentId) {
527
- if (seen.has(currentId)) throw httpError(`Cycle detected in local response history: ${currentId}`, 500);
528
- seen.add(currentId);
529
- const entry = responseHistories.get(currentId);
530
- if (!entry) {
531
- const reason = evictedResponseIds.has(currentId) ? " was evicted after reaching the local history limit" : " is not available";
532
- throw httpError(`previous_response_id${reason}: ${currentId}`, 400);
533
- }
534
- chain.push(entry);
535
- currentId = entry.parentId;
536
- }
537
- const items = [];
538
- for (const entry of chain.reverse()) items.push(...entry.inputItems, ...entry.outputItems);
539
- return cloneJson(items);
540
- }
541
-
542
251
  // The GPT (gpt-5.6) Responses backend registers a built-in `image_gen` tool
543
252
  // namespace. When the client also sends an image generation tool, the upstream
544
253
  // rejects the request with "User-defined namespace 'image_gen' collides ...".
@@ -548,9 +257,9 @@ function isBuiltinImageTool(tool) {
548
257
  return isImageNamespaceTool(tool);
549
258
  }
550
259
 
551
- export function prepareResponsesRequest(reqBody) {
552
- const body = cloneJson(reqBody);
553
- const currentInputItems = responsesInputItems(body.input);
260
+ export function prepareResponsesRequest(reqBody, { mutate = false } = {}) {
261
+ const body = mutate ? reqBody : cloneJson(reqBody);
262
+ const currentInputItems = responsesInputItems(body.input, { clone: !mutate });
554
263
  const previousId = body.previous_response_id;
555
264
 
556
265
  if (previousId !== undefined && previousId !== null) {
@@ -573,6 +282,7 @@ export function prepareResponsesRequest(reqBody) {
573
282
  inputItems: body.input,
574
283
  historyParentId: previousId ?? null,
575
284
  historyInputItems: currentInputItems,
285
+ takeHistoryOwnership: mutate,
576
286
  };
577
287
  }
578
288
 
@@ -580,30 +290,13 @@ export function rememberResponseHistory(reqContext, responseJson) {
580
290
  if (!responseJson?.id || !Array.isArray(reqContext?.historyInputItems || reqContext?.inputItems)) return;
581
291
  const sourceInputItems = reqContext.historyInputItems || reqContext.inputItems;
582
292
  const sourceOutputItems = responsesOutputItems(responseJson.output);
583
- const bytes = jsonByteLength([sourceInputItems, sourceOutputItems]);
584
- if (bytes > responseHistoryMaxBytes) {
585
- if (responseHistories.has(responseJson.id)) removeHistorySubtree(responseJson.id);
586
- rememberEvictedId(responseJson.id);
587
- return;
588
- }
589
- const inputItems = cloneJson(sourceInputItems);
590
- const outputItems = cloneJson(sourceOutputItems);
591
- const entry = {
592
- parentId: reqContext.historyParentId || null,
593
- inputItems,
594
- outputItems,
595
- bytes,
596
- };
597
- const existing = responseHistories.get(responseJson.id);
598
- if (existing) {
599
- responseHistoryBytes -= existing.bytes;
600
- unlinkHistoryChild(existing.parentId, responseJson.id);
601
- }
602
- responseHistories.set(responseJson.id, entry);
603
- linkHistoryChild(entry.parentId, responseJson.id);
604
- responseHistoryBytes += entry.bytes;
605
- evictedResponseIds.delete(responseJson.id);
606
- enforceResponseHistoryLimits();
293
+ rememberResponseHistoryNode({
294
+ id: responseJson.id,
295
+ parentId: reqContext.historyParentId,
296
+ inputItems: sourceInputItems,
297
+ outputItems: sourceOutputItems,
298
+ takeOwnership: reqContext.takeHistoryOwnership,
299
+ });
607
300
  }
608
301
 
609
302
  function storeCompletedResponseFromSse(reqContext, data) {
@@ -651,6 +344,7 @@ async function proxyCopilotResponses(reqContext, req, res, upstream = copilotRes
651
344
  }
652
345
 
653
346
  if (reqContext.body.stream) {
347
+ options.abort?.setTimeout(options.streamIdleTimeoutMs, "stream_idle_timeout");
654
348
  res.writeHead(resp.status, {
655
349
  "Content-Type": resp.headers.get("content-type") || "text/event-stream",
656
350
  "Cache-Control": "no-cache",
@@ -659,17 +353,23 @@ async function proxyCopilotResponses(reqContext, req, res, upstream = copilotRes
659
353
  const reader = resp.body.getReader();
660
354
  const decoder = new TextDecoder();
661
355
  let buffer = "";
662
- while (true) {
663
- const { done, value } = await reader.read();
664
- if (done) {
665
- buffer += decoder.decode();
666
- readSseEvents(buffer, (data) => storeCompletedResponseFromSse(reqContext, data));
667
- res.end();
668
- return;
356
+ try {
357
+ while (true) {
358
+ const { done, value } = await reader.read();
359
+ if (done) {
360
+ buffer += decoder.decode();
361
+ readSseEvents(buffer, (data) => storeCompletedResponseFromSse(reqContext, data));
362
+ res.end();
363
+ return;
364
+ }
365
+ options.abort?.setTimeout(options.streamIdleTimeoutMs, "stream_idle_timeout");
366
+ buffer += decoder.decode(value, { stream: true });
367
+ buffer = readSseEvents(buffer, (data) => storeCompletedResponseFromSse(reqContext, data));
368
+ if (!await writeOrDrain(res, value)) return;
669
369
  }
670
- buffer += decoder.decode(value, { stream: true });
671
- buffer = readSseEvents(buffer, (data) => storeCompletedResponseFromSse(reqContext, data));
672
- await writeOrDrain(res, value);
370
+ } finally {
371
+ await reader.cancel().catch(() => {});
372
+ reader.releaseLock();
673
373
  }
674
374
  } else {
675
375
  const data = await resp.text();
@@ -807,13 +507,15 @@ export async function forwardToChat(chatReq, emitEvent, onDone, onError, options
807
507
  stream: true,
808
508
  }, { signal: options.signal });
809
509
  } catch (e) {
810
- onError(502, e.message);
510
+ const statusCode = isAbortLikeError(e) ? abortErrorStatusCode(options.abort?.reason) : 502;
511
+ onError(statusCode, e.message);
811
512
  return;
812
513
  }
813
514
  if (!resp.ok) {
814
515
  onError(resp.status, await resp.text());
815
516
  return;
816
517
  }
518
+ options.abort?.setTimeout(options.streamIdleTimeoutMs, "stream_idle_timeout");
817
519
  const respId = `resp_${uid()}`;
818
520
  let actualModel = chatReq.model || "unknown";
819
521
  let fullText = "";
@@ -911,7 +613,9 @@ export async function forwardToChat(chatReq, emitEvent, onDone, onError, options
911
613
  };
912
614
 
913
615
  try {
914
- for await (const line of webStreamLines(resp)) {
616
+ for await (const line of webStreamLines(resp, {
617
+ onChunk: () => options.abort?.setTimeout(options.streamIdleTimeoutMs, "stream_idle_timeout"),
618
+ })) {
915
619
  if (!line.startsWith("data: ")) continue;
916
620
  const data = line.slice(6).trim();
917
621
  if (data === "[DONE]") { await emitCompleted(); onDone(); return; }
@@ -969,6 +673,10 @@ export function createAdapterHandler(options = {}) {
969
673
  const responsesFn = options.responsesFn || copilotResponses;
970
674
  const responsesCompactFn = options.responsesCompactFn || copilotResponsesCompact;
971
675
  const listModelsFn = options.listModelsFn || listModels;
676
+ const openAIModelEnv = options.openAIModelEnv || process.env;
677
+ const upstreamTimeoutMs = positiveInt(options.upstreamTimeoutMs, UPSTREAM_TIMEOUT_MS);
678
+ const streamHandshakeTimeoutMs = positiveInt(options.streamHandshakeTimeoutMs, STREAM_HANDSHAKE_TIMEOUT_MS);
679
+ const streamIdleTimeoutMs = positiveInt(options.streamIdleTimeoutMs, STREAM_IDLE_TIMEOUT_MS);
972
680
  const claudeDesktopModelOptions = () => {
973
681
  const modelDefs = options.modelRegistry?.modelDefs || options.claudeDesktopModelDefs;
974
682
  return Array.isArray(modelDefs) ? { modelDefs } : {};
@@ -988,13 +696,23 @@ export function createAdapterHandler(options = {}) {
988
696
  const abort = createRequestAbort(req, res);
989
697
  try {
990
698
  const parsed = await readJsonBody(req);
991
- if (!parsed.stream) abort.setTimeout(UPSTREAM_TIMEOUT_MS);
992
- const prepared = prepareResponsesRequest(parsed);
699
+ abort.setTimeout(
700
+ parsed.stream ? streamHandshakeTimeoutMs : upstreamTimeoutMs,
701
+ parsed.stream ? "stream_handshake_timeout" : "upstream_timeout",
702
+ );
703
+ const prepared = prepareResponsesRequest(parsed, { mutate: true });
993
704
  prepared.surface = "responses";
994
705
  const model = parsed.model || "unknown";
995
- console.log(status("info", `responses model=${model} stream=${!!parsed.stream}`));
996
- if (isResponsesOnlyModel(model)) {
997
- await proxyCopilotResponses(prepared, req, res, responsesFn, { signal: abort.signal });
706
+ const { requestedModel, upstreamModel } = resolveOpenAIModel(model, openAIModelEnv);
707
+ if (upstreamModel !== requestedModel) prepared.body.model = upstreamModel;
708
+ const upstreamLog = upstreamModel === requestedModel ? "" : ` upstream_model=${upstreamModel}`;
709
+ console.log(status("info", `responses model=${requestedModel}${upstreamLog} stream=${!!parsed.stream}`));
710
+ if (requestedModel === CODEX_AUTO_REVIEW_MODEL || isResponsesOnlyModel(upstreamModel)) {
711
+ await proxyCopilotResponses(prepared, req, res, responsesFn, {
712
+ signal: abort.signal,
713
+ abort,
714
+ streamIdleTimeoutMs,
715
+ });
998
716
  } else {
999
717
  const chatReq = responsesToChat(prepared.body);
1000
718
  if (parsed.stream) {
@@ -1005,7 +723,12 @@ export function createAdapterHandler(options = {}) {
1005
723
  rememberResponseHistory(prepared, data.response);
1006
724
  recordResponsesUsage({ surface: prepared.surface, mode: "stream", model, response: data.response, event: data });
1007
725
  }
1008
- }, () => { if (!res.writableEnded) res.end(); }, (status, errMsg) => { if (!res.headersSent) res.writeHead(status || 500); res.end(errMsg); }, { signal: abort.signal, chatCompletionsFn });
726
+ }, () => { if (!res.writableEnded) res.end(); }, (status, errMsg) => { if (!res.headersSent) res.writeHead(status || 500); res.end(errMsg); }, {
727
+ signal: abort.signal,
728
+ abort,
729
+ streamIdleTimeoutMs,
730
+ chatCompletionsFn,
731
+ });
1009
732
  } else {
1010
733
  chatReq.stream = false;
1011
734
  delete chatReq.max_tokens;
@@ -1044,12 +767,22 @@ export function createAdapterHandler(options = {}) {
1044
767
  const abort = createRequestAbort(req, res);
1045
768
  try {
1046
769
  const parsed = await readJsonBody(req);
1047
- if (!parsed.stream) abort.setTimeout(UPSTREAM_TIMEOUT_MS);
1048
- const prepared = prepareResponsesRequest(parsed);
770
+ abort.setTimeout(
771
+ parsed.stream ? streamHandshakeTimeoutMs : upstreamTimeoutMs,
772
+ parsed.stream ? "stream_handshake_timeout" : "upstream_timeout",
773
+ );
774
+ const prepared = prepareResponsesRequest(parsed, { mutate: true });
1049
775
  prepared.surface = "responses_compact";
1050
776
  const model = parsed.model || "unknown";
1051
- console.log(status("info", `responses compact model=${model} stream=${!!parsed.stream}`));
1052
- await proxyCopilotResponses(prepared, req, res, responsesCompactFn, { signal: abort.signal });
777
+ const { requestedModel, upstreamModel } = resolveOpenAIModel(model, openAIModelEnv);
778
+ if (upstreamModel !== requestedModel) prepared.body.model = upstreamModel;
779
+ const upstreamLog = upstreamModel === requestedModel ? "" : ` upstream_model=${upstreamModel}`;
780
+ console.log(status("info", `responses compact model=${requestedModel}${upstreamLog} stream=${!!parsed.stream}`));
781
+ await proxyCopilotResponses(prepared, req, res, responsesCompactFn, {
782
+ signal: abort.signal,
783
+ abort,
784
+ streamIdleTimeoutMs,
785
+ });
1053
786
  } catch (e) {
1054
787
  logRequestFailure("Responses compact", e, abort);
1055
788
  sendJsonError(res, e);
@@ -1067,7 +800,7 @@ export function createAdapterHandler(options = {}) {
1067
800
  return;
1068
801
  }
1069
802
  const abort = createRequestAbort(req, res);
1070
- abort.setTimeout(UPSTREAM_TIMEOUT_MS);
803
+ abort.setTimeout(upstreamTimeoutMs);
1071
804
  listModelsFn({ signal: abort.signal })
1072
805
  .then(({ status, body }) => { res.writeHead(status, { "Content-Type": "application/json" }); res.end(body); })
1073
806
  .catch((e) => {
@@ -1102,7 +835,10 @@ export function createAdapterHandler(options = {}) {
1102
835
  const abort = createRequestAbort(req, res);
1103
836
  try {
1104
837
  const parsed = await readJsonBody(req);
1105
- if (!parsed.stream) abort.setTimeout(UPSTREAM_TIMEOUT_MS);
838
+ abort.setTimeout(
839
+ parsed.stream ? streamHandshakeTimeoutMs : upstreamTimeoutMs,
840
+ parsed.stream ? "stream_handshake_timeout" : "upstream_timeout",
841
+ );
1106
842
  const { requestedModel, upstreamModel } = resolveAnthropicModel(parsed.model || "unknown", process.env, claudeDesktopModelOptions());
1107
843
  const modelNote = upstreamModel === requestedModel ? requestedModel : `${requestedModel} -> ${upstreamModel}`;
1108
844
  console.log(status("info", `messages model=${modelNote} stream=${!!parsed.stream}`));
@@ -1115,11 +851,14 @@ export function createAdapterHandler(options = {}) {
1115
851
  res.end(await upstream.text());
1116
852
  return;
1117
853
  }
854
+ abort.setTimeout(streamIdleTimeoutMs, "stream_idle_timeout");
1118
855
  res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive" });
1119
856
  let messageId;
1120
857
  let usage;
1121
858
  await streamAnthropicFromLines(
1122
- webStreamLines(upstream),
859
+ webStreamLines(upstream, {
860
+ onChunk: () => abort.setTimeout(streamIdleTimeoutMs, "stream_idle_timeout"),
861
+ }),
1123
862
  async (event, data) => {
1124
863
  if (event === "message_start") messageId = data.message?.id;
1125
864
  if (event === "message_delta") usage = data.usage;