codex-copilot-dx 0.4.23 → 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,118 +66,25 @@ function isResponsesOnlyModel(model) {
37
66
 
38
67
  // Direct Copilot Responses API proxy.
39
68
 
40
- const RESPONSE_HISTORY_LIMIT = 64;
41
- const responseHistories = new Map();
42
- const DEFAULT_MAX_BODY_BYTES = 128 * 1024 * 1024;
43
- const DEFAULT_MAX_DECODED_BODY_BYTES = 256 * 1024 * 1024;
44
69
  const DEFAULT_UPSTREAM_TIMEOUT_MS = 120 * 1000;
45
- const gunzipAsync = promisify(zlib.gunzip);
46
- const inflateAsync = promisify(zlib.inflate);
47
- const brotliDecompressAsync = promisify(zlib.brotliDecompress);
48
- 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;
49
72
 
50
73
  function positiveInt(value, fallback) {
51
74
  const n = Number.parseInt(value, 10);
52
75
  return Number.isFinite(n) && n > 0 ? n : fallback;
53
76
  }
54
77
 
55
- const MAX_BODY_BYTES = positiveInt(process.env.CCDX_MAX_BODY_BYTES, DEFAULT_MAX_BODY_BYTES);
56
- const MAX_DECODED_BODY_BYTES = positiveInt(process.env.CCDX_MAX_DECODED_BODY_BYTES, DEFAULT_MAX_DECODED_BODY_BYTES);
57
78
  const UPSTREAM_TIMEOUT_MS = positiveInt(process.env.CCDX_UPSTREAM_TIMEOUT_MS, DEFAULT_UPSTREAM_TIMEOUT_MS);
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);
58
81
 
59
82
  export function requestPath(reqUrl) {
60
83
  return new URL(reqUrl || "/", "http://localhost").pathname;
61
84
  }
62
85
 
63
86
  function cloneJson(value) {
64
- return value === undefined ? undefined : JSON.parse(JSON.stringify(value));
65
- }
66
-
67
- function httpError(message, statusCode) {
68
- const err = new Error(message);
69
- err.statusCode = statusCode;
70
- return err;
71
- }
72
-
73
- function payloadTooLarge(kind, maxBytes) {
74
- return httpError(`${kind} request body exceeds ${maxBytes} bytes`, 413);
75
- }
76
-
77
- async function readRequestBuffer(req, maxBytes) {
78
- const contentLength = Number.parseInt(req.headers?.["content-length"] || "", 10);
79
- if (Number.isFinite(contentLength) && contentLength > maxBytes) {
80
- throw payloadTooLarge("Raw", maxBytes);
81
- }
82
-
83
- const chunks = [];
84
- let total = 0;
85
- for await (const chunk of req) {
86
- const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
87
- total += buf.length;
88
- if (total > maxBytes) throw payloadTooLarge("Raw", maxBytes);
89
- chunks.push(buf);
90
- }
91
- return Buffer.concat(chunks);
92
- }
93
-
94
- async function decompressBody(decompress, buffer, maxBytes) {
95
- try {
96
- return await decompress(buffer, { maxOutputLength: maxBytes });
97
- } catch (e) {
98
- if (e?.code === "ERR_BUFFER_TOO_LARGE" || /maxOutputLength/i.test(e?.message || "")) {
99
- throw payloadTooLarge("Decoded", maxBytes);
100
- }
101
- throw e;
102
- }
103
- }
104
-
105
- async function decodeRequestBuffer(buffer, contentEncoding, maxBytes) {
106
- const encodings = String(contentEncoding || "identity")
107
- .split(",")
108
- .map((v) => v.trim().toLowerCase())
109
- .filter(Boolean);
110
-
111
- let decoded = buffer;
112
- for (const encoding of encodings.reverse()) {
113
- if (encoding === "identity") continue;
114
- if (encoding === "gzip" || encoding === "x-gzip") {
115
- decoded = await decompressBody(gunzipAsync, decoded, maxBytes);
116
- } else if (encoding === "deflate") {
117
- decoded = await decompressBody(inflateAsync, decoded, maxBytes);
118
- } else if (encoding === "br") {
119
- decoded = await decompressBody(brotliDecompressAsync, decoded, maxBytes);
120
- } else if (encoding === "zstd") {
121
- if (!zstdDecompressAsync) throw new Error("Unsupported Content-Encoding: zstd");
122
- decoded = await decompressBody(zstdDecompressAsync, decoded, maxBytes);
123
- } else {
124
- throw new Error(`Unsupported Content-Encoding: ${encoding}`);
125
- }
126
- if (decoded.length > maxBytes) throw payloadTooLarge("Decoded", maxBytes);
127
- }
128
- if (decoded.length > maxBytes) throw payloadTooLarge("Decoded", maxBytes);
129
- return decoded;
130
- }
131
-
132
- export async function readJsonBody(req, {
133
- maxBodyBytes = MAX_BODY_BYTES,
134
- maxDecodedBodyBytes = MAX_DECODED_BODY_BYTES,
135
- } = {}) {
136
- const buffer = await readRequestBuffer(req, maxBodyBytes);
137
- const decoded = await decodeRequestBuffer(buffer, req.headers?.["content-encoding"], maxDecodedBodyBytes);
138
- return JSON.parse(decoded.toString("utf8"));
139
- }
140
-
141
- function sendJsonError(res, err, fallbackStatus = 400) {
142
- if (res.destroyed || res.writableEnded) return;
143
- if (!res.headersSent) res.writeHead(err?.statusCode || fallbackStatus, { "Content-Type": "application/json" });
144
- res.end(JSON.stringify({ error: err?.message || "Request failed" }));
145
- }
146
-
147
- function sendUpstreamError(res, resp, text) {
148
- if (!res.headersSent) {
149
- res.writeHead(resp.status || 502, { "Content-Type": resp.headers?.get("content-type") || "application/json" });
150
- }
151
- res.end(text || JSON.stringify({ error: "Upstream request failed" }));
87
+ return value === undefined ? undefined : structuredClone(value);
152
88
  }
153
89
 
154
90
  function bearerToken(headers = {}) {
@@ -164,85 +100,13 @@ export function shouldServeClaudeDesktopModels(req, claudeDesktopApiKey) {
164
100
  return token === claudeDesktopApiKey || xApiKey === claudeDesktopApiKey;
165
101
  }
166
102
 
167
- export function writeOrDrain(res, chunk) {
168
- if (res.destroyed || res.writableEnded) return Promise.resolve(false);
169
- if (res.write(chunk)) return Promise.resolve(true);
170
- return new Promise((resolve, reject) => {
171
- const cleanup = () => {
172
- res.off("drain", onDrain);
173
- res.off("error", onError);
174
- res.off("close", onClose);
175
- };
176
- const onDrain = () => { cleanup(); resolve(true); };
177
- const onClose = () => { cleanup(); resolve(false); };
178
- const onError = (err) => { cleanup(); reject(err); };
179
- res.once("drain", onDrain);
180
- res.once("close", onClose);
181
- res.once("error", onError);
182
- });
183
- }
184
-
185
- export function isAbortLikeError(err) {
186
- return err?.name === "AbortError" || /operation was aborted/i.test(String(err?.message || ""));
187
- }
188
-
189
- export function abortErrorStatusCode(reason) {
190
- if (reason === "upstream_timeout") return 504;
191
- if (reason === "client_aborted" || reason === "client_closed") return 499;
192
- return 400;
193
- }
194
-
195
- export function createRequestAbort(req, res) {
196
- const controller = new AbortController();
197
- let timer = null;
198
- let cleaned = false;
199
- let reason = null;
200
- const abort = (nextReason = "aborted") => {
201
- if (!cleaned && !controller.signal.aborted) {
202
- reason = nextReason;
203
- controller.abort();
204
- }
205
- };
206
- const onReqAborted = () => abort("client_aborted");
207
- const onResClose = () => {
208
- if (!res.writableEnded) abort("client_closed");
209
- };
210
- req.on("aborted", onReqAborted);
211
- res.on("close", onResClose);
212
- return {
213
- signal: controller.signal,
214
- get reason() { return reason; },
215
- setTimeout(ms) {
216
- if (timer) clearTimeout(timer);
217
- if (ms > 0) timer = setTimeout(() => abort("upstream_timeout"), ms);
218
- },
219
- cleanup() {
220
- cleaned = true;
221
- if (timer) clearTimeout(timer);
222
- req.off("aborted", onReqAborted);
223
- res.off("close", onResClose);
224
- },
225
- };
226
- }
227
-
228
- function logRequestFailure(label, err, abort) {
229
- if (!isAbortLikeError(err)) {
230
- console.error(status("err", `${label} request failed: ${err.message}`));
231
- return;
232
- }
233
-
234
- const reason = abort?.reason || "aborted";
235
- err.statusCode ||= abortErrorStatusCode(reason);
236
- const detail = reason === "upstream_timeout" ? `${reason} after ${UPSTREAM_TIMEOUT_MS}ms` : reason;
237
- console.warn(status("warn", `${label} request aborted: ${detail}`));
238
- }
239
-
240
- function responsesInputItems(input) {
103
+ function responsesInputItems(input, { clone = true } = {}) {
241
104
  if (input === undefined || input === null) return [];
242
105
  if (typeof input === "string") {
243
106
  return [{ type: "message", role: "user", content: [{ type: "input_text", text: input }] }];
244
107
  }
245
- return Array.isArray(input) ? cloneJson(input) : [cloneJson(input)];
108
+ if (Array.isArray(input)) return clone ? cloneJson(input) : input;
109
+ return [clone ? cloneJson(input) : input];
246
110
  }
247
111
 
248
112
  export function stripInternalResponsesInputFields(inputItems) {
@@ -258,7 +122,7 @@ export function stripInternalResponsesInputFields(inputItems) {
258
122
 
259
123
  function responsesOutputItems(output) {
260
124
  if (!Array.isArray(output)) return [];
261
- return cloneJson(output.filter((item) => item?.type === "message" || item?.type === "function_call"));
125
+ return output.filter((item) => item?.type === "message" || item?.type === "function_call");
262
126
  }
263
127
 
264
128
  function stripEncryptedReasoningValue(value, state) {
@@ -303,10 +167,16 @@ export function sanitizeEncryptedReasoningRequest(reqContext) {
303
167
  body = stripEncryptedReasoningValue(body, state);
304
168
  }
305
169
  if (!state.changed) return null;
170
+ const historyInputItems = Array.isArray(reqContext.historyInputItems)
171
+ ? reqContext.historyInputItems
172
+ .filter((item) => !isEncryptedReasoningInputItem(item))
173
+ .map((item) => stripEncryptedReasoningValue(item, { changed: false }))
174
+ : reqContext.historyInputItems;
306
175
  return {
307
176
  ...reqContext,
308
177
  body,
309
178
  inputItems: Array.isArray(body.input) ? body.input : reqContext.inputItems,
179
+ historyInputItems,
310
180
  };
311
181
  }
312
182
 
@@ -318,54 +188,82 @@ export function isEncryptedContentVerificationError(statusCode, text) {
318
188
  && (lower.includes("could not be decrypted") || lower.includes("could not be parsed"));
319
189
  }
320
190
 
321
- export async function openCopilotResponse(reqContext, upstream = copilotResponses, options = {}) {
322
- let resp = await upstream(reqContext.body, { signal: options.signal });
323
- if (resp.ok) return { resp, reqContext };
324
-
325
- const errorText = await resp.text();
326
- if (!isEncryptedContentVerificationError(resp.status, errorText)) {
327
- return { resp, reqContext, errorText };
328
- }
191
+ export function isImageNamespaceCollisionError(statusCode, text) {
192
+ if (statusCode < 400 || !text) return false;
193
+ const lower = String(text).toLowerCase();
194
+ return lower.includes("namespace")
195
+ && lower.includes("image_gen")
196
+ && lower.includes("collid");
197
+ }
329
198
 
330
- const retryContext = sanitizeEncryptedReasoningRequest(reqContext);
331
- if (!retryContext) return { resp, reqContext, errorText };
199
+ function isImageNamespaceTool(tool, { collisionFallback = false } = {}) {
200
+ if (!tool || typeof tool !== "object") return false;
201
+ const type = String(tool.type || "").toLowerCase();
202
+ const name = String(tool.name || tool.function?.name || "").toLowerCase();
203
+ const namespace = String(tool.namespace || "").toLowerCase();
204
+ if (["image_gen", "image_generation"].includes(type)) return true;
205
+ if (["image_gen", "image_generation"].includes(name)) return true;
206
+ if (namespace === "image_gen" || namespace === "image_generation") return true;
207
+ return collisionFallback && [type, name, namespace].some((value) => value.startsWith("image_gen"));
208
+ }
332
209
 
333
- console.warn(status("warn", "encrypted reasoning rejected by upstream; retrying without encrypted reasoning"));
334
- resp = await upstream(retryContext.body, { signal: options.signal });
335
- if (resp.ok) return { resp, reqContext: retryContext };
336
- return { resp, reqContext: retryContext, errorText: await resp.text() };
210
+ export function sanitizeImageNamespaceCollisionRequest(reqContext) {
211
+ if (!Array.isArray(reqContext?.body?.tools)) return null;
212
+ const body = cloneJson(reqContext.body);
213
+ const filtered = body.tools.filter((tool) => !isImageNamespaceTool(tool, { collisionFallback: true }));
214
+ if (filtered.length === body.tools.length) return null;
215
+ if (filtered.length) body.tools = filtered;
216
+ else delete body.tools;
217
+ return { ...reqContext, body };
337
218
  }
338
219
 
339
- export function clearResponseHistoryForTests() {
340
- responseHistories.clear();
220
+ export async function openCopilotResponse(reqContext, upstream = copilotResponses, options = {}) {
221
+ let encryptedRetried = false;
222
+ let imageNamespaceRetried = false;
223
+ for (let attempt = 0; attempt < 3; attempt += 1) {
224
+ const resp = await upstream(reqContext.body, { signal: options.signal });
225
+ if (resp.ok) return { resp, reqContext };
226
+
227
+ const errorText = await resp.text();
228
+ if (!imageNamespaceRetried && isImageNamespaceCollisionError(resp.status, errorText)) {
229
+ const retryContext = sanitizeImageNamespaceCollisionRequest(reqContext);
230
+ if (retryContext) {
231
+ imageNamespaceRetried = true;
232
+ reqContext = retryContext;
233
+ console.warn(status("warn", "image_gen namespace rejected by upstream; retrying without the conflicting image tool"));
234
+ continue;
235
+ }
236
+ }
237
+ if (!encryptedRetried && isEncryptedContentVerificationError(resp.status, errorText)) {
238
+ const retryContext = sanitizeEncryptedReasoningRequest(reqContext);
239
+ if (retryContext) {
240
+ encryptedRetried = true;
241
+ reqContext = retryContext;
242
+ console.warn(status("warn", "encrypted reasoning rejected by upstream; retrying without encrypted reasoning"));
243
+ continue;
244
+ }
245
+ }
246
+ return { resp, reqContext, errorText };
247
+ }
248
+ throw httpError("Responses compatibility retry limit exceeded", 502);
341
249
  }
342
250
 
343
251
  // The GPT (gpt-5.6) Responses backend registers a built-in `image_gen` tool
344
252
  // namespace. When the client also sends an image generation tool, the upstream
345
253
  // rejects the request with "User-defined namespace 'image_gen' collides ...".
346
- // Strip any built-in image tool (matched by type/name/namespace) before proxying.
254
+ // Strip exact built-in image tools before proxying. Broader namespace variants
255
+ // are removed only after the upstream explicitly reports a collision.
347
256
  function isBuiltinImageTool(tool) {
348
- if (!tool || typeof tool !== "object") return false;
349
- const candidates = [tool.type, tool.name, tool.namespace];
350
- return candidates.some((v) => {
351
- const s = String(v || "").toLowerCase();
352
- return s === "image_gen" || s === "image_generation" || s.startsWith("image_gen");
353
- });
257
+ return isImageNamespaceTool(tool);
354
258
  }
355
259
 
356
- export function prepareResponsesRequest(reqBody) {
357
- const body = cloneJson(reqBody);
358
- 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 });
359
263
  const previousId = body.previous_response_id;
360
264
 
361
265
  if (previousId !== undefined && previousId !== null) {
362
- const previousItems = responseHistories.get(previousId);
363
- if (!previousItems) {
364
- const err = new Error(`previous_response_id is not available in local adapter history: ${previousId}`);
365
- err.statusCode = 400;
366
- throw err;
367
- }
368
- body.input = [...cloneJson(previousItems), ...currentInputItems];
266
+ body.input = [...materializeResponseHistory(previousId), ...currentInputItems];
369
267
  } else {
370
268
  body.input = currentInputItems;
371
269
  }
@@ -377,17 +275,28 @@ export function prepareResponsesRequest(reqBody) {
377
275
  if (!body.tools.length) delete body.tools;
378
276
  }
379
277
  stripInternalResponsesInputFields(body.input);
278
+ stripInternalResponsesInputFields(currentInputItems);
380
279
 
381
- return { body, inputItems: body.input };
280
+ return {
281
+ body,
282
+ inputItems: body.input,
283
+ historyParentId: previousId ?? null,
284
+ historyInputItems: currentInputItems,
285
+ takeHistoryOwnership: mutate,
286
+ };
382
287
  }
383
288
 
384
289
  export function rememberResponseHistory(reqContext, responseJson) {
385
- if (!responseJson?.id || !Array.isArray(reqContext?.inputItems)) return;
386
- const items = [...cloneJson(reqContext.inputItems), ...responsesOutputItems(responseJson.output)];
387
- responseHistories.set(responseJson.id, items);
388
- while (responseHistories.size > RESPONSE_HISTORY_LIMIT) {
389
- responseHistories.delete(responseHistories.keys().next().value);
390
- }
290
+ if (!responseJson?.id || !Array.isArray(reqContext?.historyInputItems || reqContext?.inputItems)) return;
291
+ const sourceInputItems = reqContext.historyInputItems || reqContext.inputItems;
292
+ const sourceOutputItems = responsesOutputItems(responseJson.output);
293
+ rememberResponseHistoryNode({
294
+ id: responseJson.id,
295
+ parentId: reqContext.historyParentId,
296
+ inputItems: sourceInputItems,
297
+ outputItems: sourceOutputItems,
298
+ takeOwnership: reqContext.takeHistoryOwnership,
299
+ });
391
300
  }
392
301
 
393
302
  function storeCompletedResponseFromSse(reqContext, data) {
@@ -435,6 +344,7 @@ async function proxyCopilotResponses(reqContext, req, res, upstream = copilotRes
435
344
  }
436
345
 
437
346
  if (reqContext.body.stream) {
347
+ options.abort?.setTimeout(options.streamIdleTimeoutMs, "stream_idle_timeout");
438
348
  res.writeHead(resp.status, {
439
349
  "Content-Type": resp.headers.get("content-type") || "text/event-stream",
440
350
  "Cache-Control": "no-cache",
@@ -443,17 +353,23 @@ async function proxyCopilotResponses(reqContext, req, res, upstream = copilotRes
443
353
  const reader = resp.body.getReader();
444
354
  const decoder = new TextDecoder();
445
355
  let buffer = "";
446
- while (true) {
447
- const { done, value } = await reader.read();
448
- if (done) {
449
- buffer += decoder.decode();
450
- readSseEvents(buffer, (data) => storeCompletedResponseFromSse(reqContext, data));
451
- res.end();
452
- 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;
453
369
  }
454
- buffer += decoder.decode(value, { stream: true });
455
- buffer = readSseEvents(buffer, (data) => storeCompletedResponseFromSse(reqContext, data));
456
- await writeOrDrain(res, value);
370
+ } finally {
371
+ await reader.cancel().catch(() => {});
372
+ reader.releaseLock();
457
373
  }
458
374
  } else {
459
375
  const data = await resp.text();
@@ -477,22 +393,39 @@ async function proxyCopilotResponses(reqContext, req, res, upstream = copilotRes
477
393
 
478
394
  // Chat Completions conversion for older models.
479
395
 
480
- function responsesToChat(body) {
396
+ export function responsesToChat(body) {
481
397
  const messages = [];
482
398
  if (body.instructions) messages.push({ role: "system", content: body.instructions });
483
399
 
400
+ const messageContent = (content) => {
401
+ if (typeof content === "string") return content;
402
+ if (!Array.isArray(content)) return JSON.stringify(content);
403
+ const parts = [];
404
+ for (const part of content) {
405
+ if (!part || typeof part !== "object") continue;
406
+ if (["input_text", "output_text", "text"].includes(part.type)) {
407
+ parts.push({ type: "text", text: String(part.text || "") });
408
+ } else if (part.type === "input_image" || part.type === "image_url") {
409
+ const raw = part.image_url ?? part.url;
410
+ const imageUrl = typeof raw === "string"
411
+ ? { url: raw }
412
+ : raw && typeof raw === "object" ? cloneJson(raw) : null;
413
+ if (imageUrl && part.detail !== undefined && imageUrl.detail === undefined) imageUrl.detail = part.detail;
414
+ if (imageUrl?.url) parts.push({ type: "image_url", image_url: imageUrl });
415
+ } else {
416
+ parts.push({ type: "text", text: JSON.stringify(part) });
417
+ }
418
+ }
419
+ if (parts.length === 1 && parts[0].type === "text") return parts[0].text;
420
+ return parts;
421
+ };
422
+
484
423
  if (typeof body.input === "string") {
485
424
  messages.push({ role: "user", content: body.input });
486
425
  } else if (Array.isArray(body.input)) {
487
426
  for (const item of body.input) {
488
427
  if (item.type === "message") {
489
- const content =
490
- typeof item.content === "string"
491
- ? item.content
492
- : Array.isArray(item.content)
493
- ? item.content.map((p) => (p.type === "input_text" || p.type === "text" ? p.text : JSON.stringify(p))).join("")
494
- : JSON.stringify(item.content);
495
- messages.push({ role: item.role, content });
428
+ messages.push({ role: item.role, content: messageContent(item.content) });
496
429
  } else if (item.type === "function_call") {
497
430
  messages.push({
498
431
  role: "assistant",
@@ -514,10 +447,44 @@ function responsesToChat(body) {
514
447
 
515
448
  if (body.tools?.length) {
516
449
  chatReq.tools = body.tools
517
- .map((t) => (t.type === "function" ? t : { type: "function", function: t }))
450
+ .map((tool) => {
451
+ if (tool?.type !== "function") return null;
452
+ if (tool.function?.name) return cloneJson(tool);
453
+ const fn = {
454
+ name: tool.name,
455
+ description: tool.description,
456
+ parameters: tool.parameters,
457
+ strict: tool.strict,
458
+ };
459
+ for (const key of Object.keys(fn)) if (fn[key] === undefined) delete fn[key];
460
+ return { type: "function", function: fn };
461
+ })
462
+ .filter(Boolean)
518
463
  .filter((t) => t.function?.name);
519
464
  if (!chatReq.tools.length) delete chatReq.tools;
520
465
  }
466
+ if (body.tool_choice !== undefined) {
467
+ if (typeof body.tool_choice === "string") {
468
+ chatReq.tool_choice = body.tool_choice;
469
+ } else if (body.tool_choice?.type === "function" && body.tool_choice.name) {
470
+ chatReq.tool_choice = { type: "function", function: { name: body.tool_choice.name } };
471
+ }
472
+ }
473
+ if (body.parallel_tool_calls !== undefined) chatReq.parallel_tool_calls = body.parallel_tool_calls;
474
+ const textFormat = body.text?.format;
475
+ if (textFormat?.type === "json_schema") {
476
+ chatReq.response_format = {
477
+ type: "json_schema",
478
+ json_schema: Object.fromEntries(Object.entries({
479
+ name: textFormat.name,
480
+ description: textFormat.description,
481
+ schema: textFormat.schema,
482
+ strict: textFormat.strict,
483
+ }).filter(([, value]) => value !== undefined)),
484
+ };
485
+ } else if (textFormat?.type === "json_object") {
486
+ chatReq.response_format = { type: "json_object" };
487
+ }
521
488
  return chatReq;
522
489
  }
523
490
 
@@ -530,55 +497,160 @@ function chatToResponses(chatResp, model) {
530
497
  return { id, object: "response", status: "completed", model: chatResp.model || model, output, usage: chatResp.usage ? { input_tokens: chatResp.usage.prompt_tokens || 0, output_tokens: chatResp.usage.completion_tokens || 0, total_tokens: chatResp.usage.total_tokens || 0 } : undefined };
531
498
  }
532
499
 
533
- async function forwardToChat(chatReq, emitEvent, onDone, onError, options = {}) {
500
+ export async function forwardToChat(chatReq, emitEvent, onDone, onError, options = {}) {
534
501
  delete chatReq.max_tokens;
535
502
  let resp;
536
503
  try {
537
- resp = await chatCompletions({ ...chatReq, stream: true }, { signal: options.signal });
504
+ const chatCompletionsFn = options.chatCompletionsFn || chatCompletions;
505
+ resp = await chatCompletionsFn({
506
+ ...chatReq,
507
+ stream: true,
508
+ }, { signal: options.signal });
538
509
  } catch (e) {
539
- onError(502, e.message);
510
+ const statusCode = isAbortLikeError(e) ? abortErrorStatusCode(options.abort?.reason) : 502;
511
+ onError(statusCode, e.message);
540
512
  return;
541
513
  }
542
514
  if (!resp.ok) {
543
515
  onError(resp.status, await resp.text());
544
516
  return;
545
517
  }
546
- const respId = `resp_${uid()}`, itemId = `item_${uid()}`;
547
- let actualModel = "unknown", fullText = "", toolCalls = {}, hasToolCalls = false;
518
+ options.abort?.setTimeout(options.streamIdleTimeoutMs, "stream_idle_timeout");
519
+ const respId = `resp_${uid()}`;
520
+ let actualModel = chatReq.model || "unknown";
521
+ let fullText = "";
522
+ let messageItem = null;
523
+ let nextOutputIndex = 0;
524
+ let usage = { input_tokens: 0, output_tokens: 0, total_tokens: 0 };
525
+ const toolCalls = new Map();
548
526
 
549
527
  await emitEvent("response.created", { response: { id: respId, object: "response", status: "in_progress", model: actualModel, output: [] } });
550
- await emitEvent("response.output_item.added", { output_index: 0, item: { type: "message", id: itemId, role: "assistant", status: "in_progress", content: [] } });
551
- await emitEvent("response.content_part.added", { output_index: 0, content_index: 0, part: { type: "output_text", text: "" } });
528
+
529
+ const ensureMessageItem = async () => {
530
+ if (messageItem) return messageItem;
531
+ messageItem = { id: `msg_${uid()}`, outputIndex: nextOutputIndex++ };
532
+ await emitEvent("response.output_item.added", {
533
+ output_index: messageItem.outputIndex,
534
+ item: { type: "message", id: messageItem.id, role: "assistant", status: "in_progress", content: [] },
535
+ });
536
+ await emitEvent("response.content_part.added", {
537
+ output_index: messageItem.outputIndex,
538
+ content_index: 0,
539
+ part: { type: "output_text", text: "" },
540
+ });
541
+ return messageItem;
542
+ };
543
+
544
+ const ensureToolCall = async (chunk) => {
545
+ const key = Number.isInteger(chunk.index) ? `index:${chunk.index}` : `id:${chunk.id || toolCalls.size}`;
546
+ if (toolCalls.has(key)) return { tool: toolCalls.get(key), created: false };
547
+ const id = chunk.id || `call_${uid()}`;
548
+ const tool = {
549
+ id,
550
+ callId: id,
551
+ name: chunk.function?.name || "",
552
+ arguments: "",
553
+ outputIndex: nextOutputIndex++,
554
+ };
555
+ toolCalls.set(key, tool);
556
+ await emitEvent("response.output_item.added", {
557
+ output_index: tool.outputIndex,
558
+ item: {
559
+ type: "function_call",
560
+ id: tool.id,
561
+ call_id: tool.callId,
562
+ name: tool.name,
563
+ arguments: "",
564
+ status: "in_progress",
565
+ },
566
+ });
567
+ return { tool, created: true };
568
+ };
552
569
 
553
570
  const emitCompleted = async () => {
554
- if (!hasToolCalls) {
555
- await emitEvent("response.output_text.done", { output_index: 0, content_index: 0, text: fullText });
556
- await emitEvent("response.output_item.done", { output_index: 0, item: { type: "message", id: itemId, role: "assistant", status: "completed", content: [{ type: "output_text", text: fullText }] } });
571
+ if (!messageItem && toolCalls.size === 0) await ensureMessageItem();
572
+ const output = [];
573
+ if (messageItem) {
574
+ const item = {
575
+ type: "message",
576
+ id: messageItem.id,
577
+ role: "assistant",
578
+ status: "completed",
579
+ content: [{ type: "output_text", text: fullText }],
580
+ };
581
+ await emitEvent("response.output_text.done", { output_index: messageItem.outputIndex, content_index: 0, text: fullText });
582
+ await emitEvent("response.content_part.done", { output_index: messageItem.outputIndex, content_index: 0, part: item.content[0] });
583
+ await emitEvent("response.output_item.done", { output_index: messageItem.outputIndex, item });
584
+ output[messageItem.outputIndex] = item;
585
+ }
586
+ for (const tool of toolCalls.values()) {
587
+ const item = {
588
+ type: "function_call",
589
+ id: tool.id,
590
+ call_id: tool.callId,
591
+ name: tool.name,
592
+ arguments: tool.arguments,
593
+ status: "completed",
594
+ };
595
+ await emitEvent("response.function_call_arguments.done", {
596
+ output_index: tool.outputIndex,
597
+ item_id: tool.id,
598
+ arguments: tool.arguments,
599
+ });
600
+ await emitEvent("response.output_item.done", { output_index: tool.outputIndex, item });
601
+ output[tool.outputIndex] = item;
557
602
  }
558
- const output = hasToolCalls
559
- ? Object.entries(toolCalls).map(([id, tc]) => ({ type: "function_call", id, call_id: id, name: tc.name, arguments: tc.arguments, status: "completed" }))
560
- : [{ type: "message", id: itemId, role: "assistant", status: "completed", content: [{ type: "output_text", text: fullText }] }];
561
- await emitEvent("response.completed", { response: { id: respId, object: "response", status: "completed", model: actualModel, output, usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 } } });
603
+ await emitEvent("response.completed", {
604
+ response: {
605
+ id: respId,
606
+ object: "response",
607
+ status: "completed",
608
+ model: actualModel,
609
+ output: output.filter(Boolean),
610
+ usage,
611
+ },
612
+ });
562
613
  };
563
614
 
564
615
  try {
565
- 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
+ })) {
566
619
  if (!line.startsWith("data: ")) continue;
567
620
  const data = line.slice(6).trim();
568
621
  if (data === "[DONE]") { await emitCompleted(); onDone(); return; }
569
622
  let parsed;
570
623
  try { parsed = JSON.parse(data); } catch { continue; }
571
624
  if (parsed.model) actualModel = parsed.model;
625
+ if (parsed.usage) {
626
+ usage = {
627
+ input_tokens: parsed.usage.prompt_tokens || 0,
628
+ output_tokens: parsed.usage.completion_tokens || 0,
629
+ total_tokens: parsed.usage.total_tokens || 0,
630
+ };
631
+ const cached = parsed.usage.prompt_tokens_details?.cached_tokens;
632
+ if (cached) usage.input_tokens_details = { cached_tokens: cached };
633
+ }
572
634
  const delta = parsed.choices?.[0]?.delta;
573
635
  if (!delta) continue;
574
- if (delta.content) { fullText += delta.content; await emitEvent("response.output_text.delta", { output_index: 0, content_index: 0, delta: delta.content }); }
636
+ if (delta.content) {
637
+ const message = await ensureMessageItem();
638
+ fullText += delta.content;
639
+ await emitEvent("response.output_text.delta", { output_index: message.outputIndex, content_index: 0, delta: delta.content });
640
+ }
575
641
  if (delta.tool_calls) {
576
- hasToolCalls = true;
577
642
  for (const tc of delta.tool_calls) {
578
- const id = tc.id || Object.keys(toolCalls)[tc.index] || `call_${tc.index}`;
579
- if (!toolCalls[id]) { toolCalls[id] = { name: "", arguments: "" }; await emitEvent("response.output_item.added", { output_index: Object.keys(toolCalls).length - 1, item: { type: "function_call", id, call_id: id, name: tc.function?.name || "", arguments: "", status: "in_progress" } }); }
580
- if (tc.function?.name) toolCalls[id].name += tc.function.name;
581
- if (tc.function?.arguments) toolCalls[id].arguments += tc.function.arguments;
643
+ const { tool, created } = await ensureToolCall(tc);
644
+ if (tc.id) tool.callId = tc.id;
645
+ if (!created && tc.function?.name) tool.name += tc.function.name;
646
+ if (tc.function?.arguments) {
647
+ tool.arguments += tc.function.arguments;
648
+ await emitEvent("response.function_call_arguments.delta", {
649
+ output_index: tool.outputIndex,
650
+ item_id: tool.id,
651
+ delta: tc.function.arguments,
652
+ });
653
+ }
582
654
  }
583
655
  }
584
656
  }
@@ -590,18 +662,26 @@ async function forwardToChat(chatReq, emitEvent, onDone, onError, options = {})
590
662
  onDone();
591
663
  }
592
664
 
593
- // Public server entry point.
594
-
595
- export function startAdapter(port = 2026, host = "127.0.0.1", options = {}) {
665
+ // Shared request handler. Keeping this separate from the listener makes the
666
+ // complete HTTP routing layer testable without opening a local port.
667
+ export function createAdapterHandler(options = {}) {
596
668
  const claudeDesktopApiKey = options.claudeDesktopApiKey
597
669
  || process.env.CCDX_CLAUDE_DESKTOP_API_KEY
598
670
  || process.env.CCDX_PROXY_API_KEY
599
671
  || "";
600
- const claudeDesktopModelOptions = Array.isArray(options.claudeDesktopModelDefs)
601
- ? { modelDefs: options.claudeDesktopModelDefs }
602
- : {};
672
+ const chatCompletionsFn = options.chatCompletionsFn || chatCompletions;
673
+ const responsesFn = options.responsesFn || copilotResponses;
674
+ const responsesCompactFn = options.responsesCompactFn || copilotResponsesCompact;
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);
679
+ const claudeDesktopModelOptions = () => {
680
+ const modelDefs = options.modelRegistry?.modelDefs || options.claudeDesktopModelDefs;
681
+ return Array.isArray(modelDefs) ? { modelDefs } : {};
682
+ };
603
683
 
604
- const server = http.createServer((req, res) => {
684
+ return (req, res) => {
605
685
  const pathname = requestPath(req.url);
606
686
 
607
687
  if (req.method === "GET" && pathname === ADAPTER_HEALTH_PATH) {
@@ -611,17 +691,24 @@ export function startAdapter(port = 2026, host = "127.0.0.1", options = {}) {
611
691
  }
612
692
 
613
693
  if (req.method === "POST" && pathname === "/v1/responses") {
614
- (async () => {
694
+ return (async () => {
615
695
  const abort = createRequestAbort(req, res);
616
696
  try {
617
697
  const parsed = await readJsonBody(req);
618
- if (!parsed.stream) abort.setTimeout(UPSTREAM_TIMEOUT_MS);
619
- 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 });
620
703
  prepared.surface = "responses";
621
704
  const model = parsed.model || "unknown";
622
705
  console.log(status("info", `responses model=${model} stream=${!!parsed.stream}`));
623
706
  if (isResponsesOnlyModel(model)) {
624
- await proxyCopilotResponses(prepared, req, res, copilotResponses, { signal: abort.signal });
707
+ await proxyCopilotResponses(prepared, req, res, responsesFn, {
708
+ signal: abort.signal,
709
+ abort,
710
+ streamIdleTimeoutMs,
711
+ });
625
712
  } else {
626
713
  const chatReq = responsesToChat(prepared.body);
627
714
  if (parsed.stream) {
@@ -632,13 +719,22 @@ export function startAdapter(port = 2026, host = "127.0.0.1", options = {}) {
632
719
  rememberResponseHistory(prepared, data.response);
633
720
  recordResponsesUsage({ surface: prepared.surface, mode: "stream", model, response: data.response, event: data });
634
721
  }
635
- }, () => { if (!res.writableEnded) res.end(); }, (status, errMsg) => { if (!res.headersSent) res.writeHead(status || 500); res.end(errMsg); }, { signal: abort.signal });
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
+ });
636
728
  } else {
637
729
  chatReq.stream = false;
638
730
  delete chatReq.max_tokens;
639
731
  try {
640
- const upstream = await chatCompletions({ ...chatReq, stream: false }, { signal: abort.signal });
732
+ const upstream = await chatCompletionsFn({ ...chatReq, stream: false }, { signal: abort.signal });
641
733
  const data = await upstream.text();
734
+ if (!upstream.ok) {
735
+ sendUpstreamError(res, upstream, data);
736
+ return;
737
+ }
642
738
  const resp = chatToResponses(JSON.parse(data), model);
643
739
  rememberResponseHistory(prepared, resp);
644
740
  recordResponsesUsage({ surface: prepared.surface, mode: "json", model, response: resp, event: resp });
@@ -660,7 +756,6 @@ export function startAdapter(port = 2026, host = "127.0.0.1", options = {}) {
660
756
  abort.cleanup();
661
757
  }
662
758
  })();
663
- return;
664
759
  }
665
760
 
666
761
  if (req.method === "POST" && pathname === "/v1/responses/compact") {
@@ -668,12 +763,19 @@ export function startAdapter(port = 2026, host = "127.0.0.1", options = {}) {
668
763
  const abort = createRequestAbort(req, res);
669
764
  try {
670
765
  const parsed = await readJsonBody(req);
671
- if (!parsed.stream) abort.setTimeout(UPSTREAM_TIMEOUT_MS);
672
- 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 });
673
771
  prepared.surface = "responses_compact";
674
772
  const model = parsed.model || "unknown";
675
773
  console.log(status("info", `responses compact model=${model} stream=${!!parsed.stream}`));
676
- await proxyCopilotResponses(prepared, req, res, copilotResponsesCompact, { signal: abort.signal });
774
+ await proxyCopilotResponses(prepared, req, res, responsesCompactFn, {
775
+ signal: abort.signal,
776
+ abort,
777
+ streamIdleTimeoutMs,
778
+ });
677
779
  } catch (e) {
678
780
  logRequestFailure("Responses compact", e, abort);
679
781
  sendJsonError(res, e);
@@ -687,12 +789,12 @@ export function startAdapter(port = 2026, host = "127.0.0.1", options = {}) {
687
789
  if (req.method === "GET" && pathname === "/v1/models") {
688
790
  if (shouldServeClaudeDesktopModels(req, claudeDesktopApiKey)) {
689
791
  res.writeHead(200, { "Content-Type": "application/json" });
690
- res.end(JSON.stringify(claudeDesktopModelsResponse(process.env, claudeDesktopModelOptions)));
792
+ res.end(JSON.stringify(claudeDesktopModelsResponse(process.env, claudeDesktopModelOptions())));
691
793
  return;
692
794
  }
693
795
  const abort = createRequestAbort(req, res);
694
- abort.setTimeout(UPSTREAM_TIMEOUT_MS);
695
- listModels({ signal: abort.signal })
796
+ abort.setTimeout(upstreamTimeoutMs);
797
+ listModelsFn({ signal: abort.signal })
696
798
  .then(({ status, body }) => { res.writeHead(status, { "Content-Type": "application/json" }); res.end(body); })
697
799
  .catch((e) => {
698
800
  logRequestFailure("Models", e, abort);
@@ -726,24 +828,30 @@ export function startAdapter(port = 2026, host = "127.0.0.1", options = {}) {
726
828
  const abort = createRequestAbort(req, res);
727
829
  try {
728
830
  const parsed = await readJsonBody(req);
729
- if (!parsed.stream) abort.setTimeout(UPSTREAM_TIMEOUT_MS);
730
- const { requestedModel, upstreamModel } = resolveAnthropicModel(parsed.model || "unknown", process.env, claudeDesktopModelOptions);
831
+ abort.setTimeout(
832
+ parsed.stream ? streamHandshakeTimeoutMs : upstreamTimeoutMs,
833
+ parsed.stream ? "stream_handshake_timeout" : "upstream_timeout",
834
+ );
835
+ const { requestedModel, upstreamModel } = resolveAnthropicModel(parsed.model || "unknown", process.env, claudeDesktopModelOptions());
731
836
  const modelNote = upstreamModel === requestedModel ? requestedModel : `${requestedModel} -> ${upstreamModel}`;
732
837
  console.log(status("info", `messages model=${modelNote} stream=${!!parsed.stream}`));
733
838
  const chatReq = anthropicToChat(parsed, { upstreamModel });
734
839
  const forceRequestedModel = upstreamModel !== requestedModel;
735
840
  if (parsed.stream) {
736
- const upstream = await chatCompletions({ ...chatReq, stream: true }, { signal: abort.signal });
841
+ const upstream = await chatCompletionsFn({ ...chatReq, stream: true }, { signal: abort.signal });
737
842
  if (!upstream.ok) {
738
843
  if (!res.headersSent) res.writeHead(upstream.status);
739
844
  res.end(await upstream.text());
740
845
  return;
741
846
  }
847
+ abort.setTimeout(streamIdleTimeoutMs, "stream_idle_timeout");
742
848
  res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive" });
743
849
  let messageId;
744
850
  let usage;
745
851
  await streamAnthropicFromLines(
746
- webStreamLines(upstream),
852
+ webStreamLines(upstream, {
853
+ onChunk: () => abort.setTimeout(streamIdleTimeoutMs, "stream_idle_timeout"),
854
+ }),
747
855
  async (event, data) => {
748
856
  if (event === "message_start") messageId = data.message?.id;
749
857
  if (event === "message_delta") usage = data.usage;
@@ -755,8 +863,12 @@ export function startAdapter(port = 2026, host = "127.0.0.1", options = {}) {
755
863
  recordAnthropicUsage({ surface: "messages", mode: "stream", model: requestedModel, responseId: messageId, usage });
756
864
  if (!res.writableEnded) res.end();
757
865
  } else {
758
- const upstream = await chatCompletions({ ...chatReq, stream: false }, { signal: abort.signal });
866
+ const upstream = await chatCompletionsFn({ ...chatReq, stream: false }, { signal: abort.signal });
759
867
  const data = await upstream.text();
868
+ if (!upstream.ok) {
869
+ sendUpstreamError(res, upstream, data);
870
+ return;
871
+ }
760
872
  const anthropicMsg = chatToAnthropic(JSON.parse(data), requestedModel, { forceModel: forceRequestedModel });
761
873
  recordAnthropicUsage({ surface: "messages", mode: "json", model: anthropicMsg.model, responseId: anthropicMsg.id, usage: anthropicMsg.usage });
762
874
  res.writeHead(200, { "Content-Type": "application/json" });
@@ -774,7 +886,13 @@ export function startAdapter(port = 2026, host = "127.0.0.1", options = {}) {
774
886
 
775
887
  res.writeHead(404, { "Content-Type": "application/json" });
776
888
  res.end(JSON.stringify({ error: "Not found" }));
777
- });
889
+ };
890
+ }
891
+
892
+ // Public server entry point.
893
+
894
+ export function startAdapter(port = 2026, host = "127.0.0.1", options = {}) {
895
+ const server = http.createServer(createAdapterHandler(options));
778
896
 
779
897
  server.on("upgrade", (req, socket) => {
780
898
  // Codex Desktop 0.130+ negotiates a "responses_websockets" server-push