codex-copilot-dx 0.4.25 → 0.4.26

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,7 +1,5 @@
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";
@@ -9,6 +7,37 @@ import { claudeDesktopModelsResponse, resolveAnthropicModel, modelIsResponsesOnl
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,9 @@ 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 upstreamTimeoutMs = positiveInt(options.upstreamTimeoutMs, UPSTREAM_TIMEOUT_MS);
677
+ const streamHandshakeTimeoutMs = positiveInt(options.streamHandshakeTimeoutMs, STREAM_HANDSHAKE_TIMEOUT_MS);
678
+ const streamIdleTimeoutMs = positiveInt(options.streamIdleTimeoutMs, STREAM_IDLE_TIMEOUT_MS);
972
679
  const claudeDesktopModelOptions = () => {
973
680
  const modelDefs = options.modelRegistry?.modelDefs || options.claudeDesktopModelDefs;
974
681
  return Array.isArray(modelDefs) ? { modelDefs } : {};
@@ -988,13 +695,20 @@ export function createAdapterHandler(options = {}) {
988
695
  const abort = createRequestAbort(req, res);
989
696
  try {
990
697
  const parsed = await readJsonBody(req);
991
- if (!parsed.stream) abort.setTimeout(UPSTREAM_TIMEOUT_MS);
992
- const prepared = prepareResponsesRequest(parsed);
698
+ abort.setTimeout(
699
+ parsed.stream ? streamHandshakeTimeoutMs : upstreamTimeoutMs,
700
+ parsed.stream ? "stream_handshake_timeout" : "upstream_timeout",
701
+ );
702
+ const prepared = prepareResponsesRequest(parsed, { mutate: true });
993
703
  prepared.surface = "responses";
994
704
  const model = parsed.model || "unknown";
995
705
  console.log(status("info", `responses model=${model} stream=${!!parsed.stream}`));
996
706
  if (isResponsesOnlyModel(model)) {
997
- await proxyCopilotResponses(prepared, req, res, responsesFn, { signal: abort.signal });
707
+ await proxyCopilotResponses(prepared, req, res, responsesFn, {
708
+ signal: abort.signal,
709
+ abort,
710
+ streamIdleTimeoutMs,
711
+ });
998
712
  } else {
999
713
  const chatReq = responsesToChat(prepared.body);
1000
714
  if (parsed.stream) {
@@ -1005,7 +719,12 @@ export function createAdapterHandler(options = {}) {
1005
719
  rememberResponseHistory(prepared, data.response);
1006
720
  recordResponsesUsage({ surface: prepared.surface, mode: "stream", model, response: data.response, event: data });
1007
721
  }
1008
- }, () => { if (!res.writableEnded) res.end(); }, (status, errMsg) => { if (!res.headersSent) res.writeHead(status || 500); res.end(errMsg); }, { signal: abort.signal, chatCompletionsFn });
722
+ }, () => { if (!res.writableEnded) res.end(); }, (status, errMsg) => { if (!res.headersSent) res.writeHead(status || 500); res.end(errMsg); }, {
723
+ signal: abort.signal,
724
+ abort,
725
+ streamIdleTimeoutMs,
726
+ chatCompletionsFn,
727
+ });
1009
728
  } else {
1010
729
  chatReq.stream = false;
1011
730
  delete chatReq.max_tokens;
@@ -1044,12 +763,19 @@ export function createAdapterHandler(options = {}) {
1044
763
  const abort = createRequestAbort(req, res);
1045
764
  try {
1046
765
  const parsed = await readJsonBody(req);
1047
- if (!parsed.stream) abort.setTimeout(UPSTREAM_TIMEOUT_MS);
1048
- const prepared = prepareResponsesRequest(parsed);
766
+ abort.setTimeout(
767
+ parsed.stream ? streamHandshakeTimeoutMs : upstreamTimeoutMs,
768
+ parsed.stream ? "stream_handshake_timeout" : "upstream_timeout",
769
+ );
770
+ const prepared = prepareResponsesRequest(parsed, { mutate: true });
1049
771
  prepared.surface = "responses_compact";
1050
772
  const model = parsed.model || "unknown";
1051
773
  console.log(status("info", `responses compact model=${model} stream=${!!parsed.stream}`));
1052
- await proxyCopilotResponses(prepared, req, res, responsesCompactFn, { signal: abort.signal });
774
+ await proxyCopilotResponses(prepared, req, res, responsesCompactFn, {
775
+ signal: abort.signal,
776
+ abort,
777
+ streamIdleTimeoutMs,
778
+ });
1053
779
  } catch (e) {
1054
780
  logRequestFailure("Responses compact", e, abort);
1055
781
  sendJsonError(res, e);
@@ -1067,7 +793,7 @@ export function createAdapterHandler(options = {}) {
1067
793
  return;
1068
794
  }
1069
795
  const abort = createRequestAbort(req, res);
1070
- abort.setTimeout(UPSTREAM_TIMEOUT_MS);
796
+ abort.setTimeout(upstreamTimeoutMs);
1071
797
  listModelsFn({ signal: abort.signal })
1072
798
  .then(({ status, body }) => { res.writeHead(status, { "Content-Type": "application/json" }); res.end(body); })
1073
799
  .catch((e) => {
@@ -1102,7 +828,10 @@ export function createAdapterHandler(options = {}) {
1102
828
  const abort = createRequestAbort(req, res);
1103
829
  try {
1104
830
  const parsed = await readJsonBody(req);
1105
- if (!parsed.stream) abort.setTimeout(UPSTREAM_TIMEOUT_MS);
831
+ abort.setTimeout(
832
+ parsed.stream ? streamHandshakeTimeoutMs : upstreamTimeoutMs,
833
+ parsed.stream ? "stream_handshake_timeout" : "upstream_timeout",
834
+ );
1106
835
  const { requestedModel, upstreamModel } = resolveAnthropicModel(parsed.model || "unknown", process.env, claudeDesktopModelOptions());
1107
836
  const modelNote = upstreamModel === requestedModel ? requestedModel : `${requestedModel} -> ${upstreamModel}`;
1108
837
  console.log(status("info", `messages model=${modelNote} stream=${!!parsed.stream}`));
@@ -1115,11 +844,14 @@ export function createAdapterHandler(options = {}) {
1115
844
  res.end(await upstream.text());
1116
845
  return;
1117
846
  }
847
+ abort.setTimeout(streamIdleTimeoutMs, "stream_idle_timeout");
1118
848
  res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive" });
1119
849
  let messageId;
1120
850
  let usage;
1121
851
  await streamAnthropicFromLines(
1122
- webStreamLines(upstream),
852
+ webStreamLines(upstream, {
853
+ onChunk: () => abort.setTimeout(streamIdleTimeoutMs, "stream_idle_timeout"),
854
+ }),
1123
855
  async (event, data) => {
1124
856
  if (event === "message_start") messageId = data.message?.id;
1125
857
  if (event === "message_delta") usage = data.usage;