codex-copilot-dx 0.4.22 → 0.4.25

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
@@ -37,8 +37,12 @@ function isResponsesOnlyModel(model) {
37
37
 
38
38
  // Direct Copilot Responses API proxy.
39
39
 
40
- const RESPONSE_HISTORY_LIMIT = 64;
41
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;
42
46
  const DEFAULT_MAX_BODY_BYTES = 128 * 1024 * 1024;
43
47
  const DEFAULT_MAX_DECODED_BODY_BYTES = 256 * 1024 * 1024;
44
48
  const DEFAULT_UPSTREAM_TIMEOUT_MS = 120 * 1000;
@@ -55,6 +59,8 @@ function positiveInt(value, fallback) {
55
59
  const MAX_BODY_BYTES = positiveInt(process.env.CCDX_MAX_BODY_BYTES, DEFAULT_MAX_BODY_BYTES);
56
60
  const MAX_DECODED_BODY_BYTES = positiveInt(process.env.CCDX_MAX_DECODED_BODY_BYTES, DEFAULT_MAX_DECODED_BODY_BYTES);
57
61
  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);
58
64
 
59
65
  export function requestPath(reqUrl) {
60
66
  return new URL(reqUrl || "/", "http://localhost").pathname;
@@ -64,6 +70,65 @@ function cloneJson(value) {
64
70
  return value === undefined ? undefined : JSON.parse(JSON.stringify(value));
65
71
  }
66
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
+
67
132
  function httpError(message, statusCode) {
68
133
  const err = new Error(message);
69
134
  err.statusCode = statusCode;
@@ -258,7 +323,7 @@ export function stripInternalResponsesInputFields(inputItems) {
258
323
 
259
324
  function responsesOutputItems(output) {
260
325
  if (!Array.isArray(output)) return [];
261
- return cloneJson(output.filter((item) => item?.type === "message" || item?.type === "function_call"));
326
+ return output.filter((item) => item?.type === "message" || item?.type === "function_call");
262
327
  }
263
328
 
264
329
  function stripEncryptedReasoningValue(value, state) {
@@ -303,10 +368,16 @@ export function sanitizeEncryptedReasoningRequest(reqContext) {
303
368
  body = stripEncryptedReasoningValue(body, state);
304
369
  }
305
370
  if (!state.changed) return null;
371
+ const historyInputItems = Array.isArray(reqContext.historyInputItems)
372
+ ? reqContext.historyInputItems
373
+ .filter((item) => !isEncryptedReasoningInputItem(item))
374
+ .map((item) => stripEncryptedReasoningValue(item, { changed: false }))
375
+ : reqContext.historyInputItems;
306
376
  return {
307
377
  ...reqContext,
308
378
  body,
309
379
  inputItems: Array.isArray(body.input) ? body.input : reqContext.inputItems,
380
+ historyInputItems,
310
381
  };
311
382
  }
312
383
 
@@ -318,39 +389,163 @@ export function isEncryptedContentVerificationError(statusCode, text) {
318
389
  && (lower.includes("could not be decrypted") || lower.includes("could not be parsed"));
319
390
  }
320
391
 
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 };
392
+ export function isImageNamespaceCollisionError(statusCode, text) {
393
+ if (statusCode < 400 || !text) return false;
394
+ const lower = String(text).toLowerCase();
395
+ return lower.includes("namespace")
396
+ && lower.includes("image_gen")
397
+ && lower.includes("collid");
398
+ }
324
399
 
325
- const errorText = await resp.text();
326
- if (!isEncryptedContentVerificationError(resp.status, errorText)) {
327
- return { resp, reqContext, errorText };
328
- }
400
+ function isImageNamespaceTool(tool, { collisionFallback = false } = {}) {
401
+ if (!tool || typeof tool !== "object") return false;
402
+ const type = String(tool.type || "").toLowerCase();
403
+ const name = String(tool.name || tool.function?.name || "").toLowerCase();
404
+ const namespace = String(tool.namespace || "").toLowerCase();
405
+ if (["image_gen", "image_generation"].includes(type)) return true;
406
+ if (["image_gen", "image_generation"].includes(name)) return true;
407
+ if (namespace === "image_gen" || namespace === "image_generation") return true;
408
+ return collisionFallback && [type, name, namespace].some((value) => value.startsWith("image_gen"));
409
+ }
329
410
 
330
- const retryContext = sanitizeEncryptedReasoningRequest(reqContext);
331
- if (!retryContext) return { resp, reqContext, errorText };
411
+ export function sanitizeImageNamespaceCollisionRequest(reqContext) {
412
+ if (!Array.isArray(reqContext?.body?.tools)) return null;
413
+ const body = cloneJson(reqContext.body);
414
+ const filtered = body.tools.filter((tool) => !isImageNamespaceTool(tool, { collisionFallback: true }));
415
+ if (filtered.length === body.tools.length) return null;
416
+ if (filtered.length) body.tools = filtered;
417
+ else delete body.tools;
418
+ return { ...reqContext, body };
419
+ }
332
420
 
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() };
421
+ export async function openCopilotResponse(reqContext, upstream = copilotResponses, options = {}) {
422
+ let encryptedRetried = false;
423
+ let imageNamespaceRetried = false;
424
+ for (let attempt = 0; attempt < 3; attempt += 1) {
425
+ const resp = await upstream(reqContext.body, { signal: options.signal });
426
+ if (resp.ok) return { resp, reqContext };
427
+
428
+ const errorText = await resp.text();
429
+ if (!imageNamespaceRetried && isImageNamespaceCollisionError(resp.status, errorText)) {
430
+ const retryContext = sanitizeImageNamespaceCollisionRequest(reqContext);
431
+ if (retryContext) {
432
+ imageNamespaceRetried = true;
433
+ reqContext = retryContext;
434
+ console.warn(status("warn", "image_gen namespace rejected by upstream; retrying without the conflicting image tool"));
435
+ continue;
436
+ }
437
+ }
438
+ if (!encryptedRetried && isEncryptedContentVerificationError(resp.status, errorText)) {
439
+ const retryContext = sanitizeEncryptedReasoningRequest(reqContext);
440
+ if (retryContext) {
441
+ encryptedRetried = true;
442
+ reqContext = retryContext;
443
+ console.warn(status("warn", "encrypted reasoning rejected by upstream; retrying without encrypted reasoning"));
444
+ continue;
445
+ }
446
+ }
447
+ return { resp, reqContext, errorText };
448
+ }
449
+ throw httpError("Responses compatibility retry limit exceeded", 502);
337
450
  }
338
451
 
339
452
  export function clearResponseHistoryForTests() {
340
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);
341
540
  }
342
541
 
343
542
  // The GPT (gpt-5.6) Responses backend registers a built-in `image_gen` tool
344
543
  // namespace. When the client also sends an image generation tool, the upstream
345
544
  // rejects the request with "User-defined namespace 'image_gen' collides ...".
346
- // Strip any built-in image tool (matched by type/name/namespace) before proxying.
545
+ // Strip exact built-in image tools before proxying. Broader namespace variants
546
+ // are removed only after the upstream explicitly reports a collision.
347
547
  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
- });
548
+ return isImageNamespaceTool(tool);
354
549
  }
355
550
 
356
551
  export function prepareResponsesRequest(reqBody) {
@@ -359,13 +554,7 @@ export function prepareResponsesRequest(reqBody) {
359
554
  const previousId = body.previous_response_id;
360
555
 
361
556
  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];
557
+ body.input = [...materializeResponseHistory(previousId), ...currentInputItems];
369
558
  } else {
370
559
  body.input = currentInputItems;
371
560
  }
@@ -377,17 +566,44 @@ export function prepareResponsesRequest(reqBody) {
377
566
  if (!body.tools.length) delete body.tools;
378
567
  }
379
568
  stripInternalResponsesInputFields(body.input);
569
+ stripInternalResponsesInputFields(currentInputItems);
380
570
 
381
- return { body, inputItems: body.input };
571
+ return {
572
+ body,
573
+ inputItems: body.input,
574
+ historyParentId: previousId ?? null,
575
+ historyInputItems: currentInputItems,
576
+ };
382
577
  }
383
578
 
384
579
  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);
580
+ if (!responseJson?.id || !Array.isArray(reqContext?.historyInputItems || reqContext?.inputItems)) return;
581
+ const sourceInputItems = reqContext.historyInputItems || reqContext.inputItems;
582
+ 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);
390
601
  }
602
+ responseHistories.set(responseJson.id, entry);
603
+ linkHistoryChild(entry.parentId, responseJson.id);
604
+ responseHistoryBytes += entry.bytes;
605
+ evictedResponseIds.delete(responseJson.id);
606
+ enforceResponseHistoryLimits();
391
607
  }
392
608
 
393
609
  function storeCompletedResponseFromSse(reqContext, data) {
@@ -477,22 +693,39 @@ async function proxyCopilotResponses(reqContext, req, res, upstream = copilotRes
477
693
 
478
694
  // Chat Completions conversion for older models.
479
695
 
480
- function responsesToChat(body) {
696
+ export function responsesToChat(body) {
481
697
  const messages = [];
482
698
  if (body.instructions) messages.push({ role: "system", content: body.instructions });
483
699
 
700
+ const messageContent = (content) => {
701
+ if (typeof content === "string") return content;
702
+ if (!Array.isArray(content)) return JSON.stringify(content);
703
+ const parts = [];
704
+ for (const part of content) {
705
+ if (!part || typeof part !== "object") continue;
706
+ if (["input_text", "output_text", "text"].includes(part.type)) {
707
+ parts.push({ type: "text", text: String(part.text || "") });
708
+ } else if (part.type === "input_image" || part.type === "image_url") {
709
+ const raw = part.image_url ?? part.url;
710
+ const imageUrl = typeof raw === "string"
711
+ ? { url: raw }
712
+ : raw && typeof raw === "object" ? cloneJson(raw) : null;
713
+ if (imageUrl && part.detail !== undefined && imageUrl.detail === undefined) imageUrl.detail = part.detail;
714
+ if (imageUrl?.url) parts.push({ type: "image_url", image_url: imageUrl });
715
+ } else {
716
+ parts.push({ type: "text", text: JSON.stringify(part) });
717
+ }
718
+ }
719
+ if (parts.length === 1 && parts[0].type === "text") return parts[0].text;
720
+ return parts;
721
+ };
722
+
484
723
  if (typeof body.input === "string") {
485
724
  messages.push({ role: "user", content: body.input });
486
725
  } else if (Array.isArray(body.input)) {
487
726
  for (const item of body.input) {
488
727
  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 });
728
+ messages.push({ role: item.role, content: messageContent(item.content) });
496
729
  } else if (item.type === "function_call") {
497
730
  messages.push({
498
731
  role: "assistant",
@@ -514,10 +747,44 @@ function responsesToChat(body) {
514
747
 
515
748
  if (body.tools?.length) {
516
749
  chatReq.tools = body.tools
517
- .map((t) => (t.type === "function" ? t : { type: "function", function: t }))
750
+ .map((tool) => {
751
+ if (tool?.type !== "function") return null;
752
+ if (tool.function?.name) return cloneJson(tool);
753
+ const fn = {
754
+ name: tool.name,
755
+ description: tool.description,
756
+ parameters: tool.parameters,
757
+ strict: tool.strict,
758
+ };
759
+ for (const key of Object.keys(fn)) if (fn[key] === undefined) delete fn[key];
760
+ return { type: "function", function: fn };
761
+ })
762
+ .filter(Boolean)
518
763
  .filter((t) => t.function?.name);
519
764
  if (!chatReq.tools.length) delete chatReq.tools;
520
765
  }
766
+ if (body.tool_choice !== undefined) {
767
+ if (typeof body.tool_choice === "string") {
768
+ chatReq.tool_choice = body.tool_choice;
769
+ } else if (body.tool_choice?.type === "function" && body.tool_choice.name) {
770
+ chatReq.tool_choice = { type: "function", function: { name: body.tool_choice.name } };
771
+ }
772
+ }
773
+ if (body.parallel_tool_calls !== undefined) chatReq.parallel_tool_calls = body.parallel_tool_calls;
774
+ const textFormat = body.text?.format;
775
+ if (textFormat?.type === "json_schema") {
776
+ chatReq.response_format = {
777
+ type: "json_schema",
778
+ json_schema: Object.fromEntries(Object.entries({
779
+ name: textFormat.name,
780
+ description: textFormat.description,
781
+ schema: textFormat.schema,
782
+ strict: textFormat.strict,
783
+ }).filter(([, value]) => value !== undefined)),
784
+ };
785
+ } else if (textFormat?.type === "json_object") {
786
+ chatReq.response_format = { type: "json_object" };
787
+ }
521
788
  return chatReq;
522
789
  }
523
790
 
@@ -530,11 +797,15 @@ function chatToResponses(chatResp, model) {
530
797
  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
798
  }
532
799
 
533
- async function forwardToChat(chatReq, emitEvent, onDone, onError, options = {}) {
800
+ export async function forwardToChat(chatReq, emitEvent, onDone, onError, options = {}) {
534
801
  delete chatReq.max_tokens;
535
802
  let resp;
536
803
  try {
537
- resp = await chatCompletions({ ...chatReq, stream: true }, { signal: options.signal });
804
+ const chatCompletionsFn = options.chatCompletionsFn || chatCompletions;
805
+ resp = await chatCompletionsFn({
806
+ ...chatReq,
807
+ stream: true,
808
+ }, { signal: options.signal });
538
809
  } catch (e) {
539
810
  onError(502, e.message);
540
811
  return;
@@ -543,22 +814,100 @@ async function forwardToChat(chatReq, emitEvent, onDone, onError, options = {})
543
814
  onError(resp.status, await resp.text());
544
815
  return;
545
816
  }
546
- const respId = `resp_${uid()}`, itemId = `item_${uid()}`;
547
- let actualModel = "unknown", fullText = "", toolCalls = {}, hasToolCalls = false;
817
+ const respId = `resp_${uid()}`;
818
+ let actualModel = chatReq.model || "unknown";
819
+ let fullText = "";
820
+ let messageItem = null;
821
+ let nextOutputIndex = 0;
822
+ let usage = { input_tokens: 0, output_tokens: 0, total_tokens: 0 };
823
+ const toolCalls = new Map();
548
824
 
549
825
  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: "" } });
826
+
827
+ const ensureMessageItem = async () => {
828
+ if (messageItem) return messageItem;
829
+ messageItem = { id: `msg_${uid()}`, outputIndex: nextOutputIndex++ };
830
+ await emitEvent("response.output_item.added", {
831
+ output_index: messageItem.outputIndex,
832
+ item: { type: "message", id: messageItem.id, role: "assistant", status: "in_progress", content: [] },
833
+ });
834
+ await emitEvent("response.content_part.added", {
835
+ output_index: messageItem.outputIndex,
836
+ content_index: 0,
837
+ part: { type: "output_text", text: "" },
838
+ });
839
+ return messageItem;
840
+ };
841
+
842
+ const ensureToolCall = async (chunk) => {
843
+ const key = Number.isInteger(chunk.index) ? `index:${chunk.index}` : `id:${chunk.id || toolCalls.size}`;
844
+ if (toolCalls.has(key)) return { tool: toolCalls.get(key), created: false };
845
+ const id = chunk.id || `call_${uid()}`;
846
+ const tool = {
847
+ id,
848
+ callId: id,
849
+ name: chunk.function?.name || "",
850
+ arguments: "",
851
+ outputIndex: nextOutputIndex++,
852
+ };
853
+ toolCalls.set(key, tool);
854
+ await emitEvent("response.output_item.added", {
855
+ output_index: tool.outputIndex,
856
+ item: {
857
+ type: "function_call",
858
+ id: tool.id,
859
+ call_id: tool.callId,
860
+ name: tool.name,
861
+ arguments: "",
862
+ status: "in_progress",
863
+ },
864
+ });
865
+ return { tool, created: true };
866
+ };
552
867
 
553
868
  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 }] } });
869
+ if (!messageItem && toolCalls.size === 0) await ensureMessageItem();
870
+ const output = [];
871
+ if (messageItem) {
872
+ const item = {
873
+ type: "message",
874
+ id: messageItem.id,
875
+ role: "assistant",
876
+ status: "completed",
877
+ content: [{ type: "output_text", text: fullText }],
878
+ };
879
+ await emitEvent("response.output_text.done", { output_index: messageItem.outputIndex, content_index: 0, text: fullText });
880
+ await emitEvent("response.content_part.done", { output_index: messageItem.outputIndex, content_index: 0, part: item.content[0] });
881
+ await emitEvent("response.output_item.done", { output_index: messageItem.outputIndex, item });
882
+ output[messageItem.outputIndex] = item;
883
+ }
884
+ for (const tool of toolCalls.values()) {
885
+ const item = {
886
+ type: "function_call",
887
+ id: tool.id,
888
+ call_id: tool.callId,
889
+ name: tool.name,
890
+ arguments: tool.arguments,
891
+ status: "completed",
892
+ };
893
+ await emitEvent("response.function_call_arguments.done", {
894
+ output_index: tool.outputIndex,
895
+ item_id: tool.id,
896
+ arguments: tool.arguments,
897
+ });
898
+ await emitEvent("response.output_item.done", { output_index: tool.outputIndex, item });
899
+ output[tool.outputIndex] = item;
557
900
  }
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 } } });
901
+ await emitEvent("response.completed", {
902
+ response: {
903
+ id: respId,
904
+ object: "response",
905
+ status: "completed",
906
+ model: actualModel,
907
+ output: output.filter(Boolean),
908
+ usage,
909
+ },
910
+ });
562
911
  };
563
912
 
564
913
  try {
@@ -569,16 +918,35 @@ async function forwardToChat(chatReq, emitEvent, onDone, onError, options = {})
569
918
  let parsed;
570
919
  try { parsed = JSON.parse(data); } catch { continue; }
571
920
  if (parsed.model) actualModel = parsed.model;
921
+ if (parsed.usage) {
922
+ usage = {
923
+ input_tokens: parsed.usage.prompt_tokens || 0,
924
+ output_tokens: parsed.usage.completion_tokens || 0,
925
+ total_tokens: parsed.usage.total_tokens || 0,
926
+ };
927
+ const cached = parsed.usage.prompt_tokens_details?.cached_tokens;
928
+ if (cached) usage.input_tokens_details = { cached_tokens: cached };
929
+ }
572
930
  const delta = parsed.choices?.[0]?.delta;
573
931
  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 }); }
932
+ if (delta.content) {
933
+ const message = await ensureMessageItem();
934
+ fullText += delta.content;
935
+ await emitEvent("response.output_text.delta", { output_index: message.outputIndex, content_index: 0, delta: delta.content });
936
+ }
575
937
  if (delta.tool_calls) {
576
- hasToolCalls = true;
577
938
  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;
939
+ const { tool, created } = await ensureToolCall(tc);
940
+ if (tc.id) tool.callId = tc.id;
941
+ if (!created && tc.function?.name) tool.name += tc.function.name;
942
+ if (tc.function?.arguments) {
943
+ tool.arguments += tc.function.arguments;
944
+ await emitEvent("response.function_call_arguments.delta", {
945
+ output_index: tool.outputIndex,
946
+ item_id: tool.id,
947
+ delta: tc.function.arguments,
948
+ });
949
+ }
582
950
  }
583
951
  }
584
952
  }
@@ -590,18 +958,23 @@ async function forwardToChat(chatReq, emitEvent, onDone, onError, options = {})
590
958
  onDone();
591
959
  }
592
960
 
593
- // Public server entry point.
594
-
595
- export function startAdapter(port = 2026, host = "127.0.0.1", options = {}) {
961
+ // Shared request handler. Keeping this separate from the listener makes the
962
+ // complete HTTP routing layer testable without opening a local port.
963
+ export function createAdapterHandler(options = {}) {
596
964
  const claudeDesktopApiKey = options.claudeDesktopApiKey
597
965
  || process.env.CCDX_CLAUDE_DESKTOP_API_KEY
598
966
  || process.env.CCDX_PROXY_API_KEY
599
967
  || "";
600
- const claudeDesktopModelOptions = Array.isArray(options.claudeDesktopModelDefs)
601
- ? { modelDefs: options.claudeDesktopModelDefs }
602
- : {};
968
+ const chatCompletionsFn = options.chatCompletionsFn || chatCompletions;
969
+ const responsesFn = options.responsesFn || copilotResponses;
970
+ const responsesCompactFn = options.responsesCompactFn || copilotResponsesCompact;
971
+ const listModelsFn = options.listModelsFn || listModels;
972
+ const claudeDesktopModelOptions = () => {
973
+ const modelDefs = options.modelRegistry?.modelDefs || options.claudeDesktopModelDefs;
974
+ return Array.isArray(modelDefs) ? { modelDefs } : {};
975
+ };
603
976
 
604
- const server = http.createServer((req, res) => {
977
+ return (req, res) => {
605
978
  const pathname = requestPath(req.url);
606
979
 
607
980
  if (req.method === "GET" && pathname === ADAPTER_HEALTH_PATH) {
@@ -611,7 +984,7 @@ export function startAdapter(port = 2026, host = "127.0.0.1", options = {}) {
611
984
  }
612
985
 
613
986
  if (req.method === "POST" && pathname === "/v1/responses") {
614
- (async () => {
987
+ return (async () => {
615
988
  const abort = createRequestAbort(req, res);
616
989
  try {
617
990
  const parsed = await readJsonBody(req);
@@ -621,7 +994,7 @@ export function startAdapter(port = 2026, host = "127.0.0.1", options = {}) {
621
994
  const model = parsed.model || "unknown";
622
995
  console.log(status("info", `responses model=${model} stream=${!!parsed.stream}`));
623
996
  if (isResponsesOnlyModel(model)) {
624
- await proxyCopilotResponses(prepared, req, res, copilotResponses, { signal: abort.signal });
997
+ await proxyCopilotResponses(prepared, req, res, responsesFn, { signal: abort.signal });
625
998
  } else {
626
999
  const chatReq = responsesToChat(prepared.body);
627
1000
  if (parsed.stream) {
@@ -632,13 +1005,17 @@ export function startAdapter(port = 2026, host = "127.0.0.1", options = {}) {
632
1005
  rememberResponseHistory(prepared, data.response);
633
1006
  recordResponsesUsage({ surface: prepared.surface, mode: "stream", model, response: data.response, event: data });
634
1007
  }
635
- }, () => { if (!res.writableEnded) res.end(); }, (status, errMsg) => { if (!res.headersSent) res.writeHead(status || 500); res.end(errMsg); }, { signal: abort.signal });
1008
+ }, () => { if (!res.writableEnded) res.end(); }, (status, errMsg) => { if (!res.headersSent) res.writeHead(status || 500); res.end(errMsg); }, { signal: abort.signal, chatCompletionsFn });
636
1009
  } else {
637
1010
  chatReq.stream = false;
638
1011
  delete chatReq.max_tokens;
639
1012
  try {
640
- const upstream = await chatCompletions({ ...chatReq, stream: false }, { signal: abort.signal });
1013
+ const upstream = await chatCompletionsFn({ ...chatReq, stream: false }, { signal: abort.signal });
641
1014
  const data = await upstream.text();
1015
+ if (!upstream.ok) {
1016
+ sendUpstreamError(res, upstream, data);
1017
+ return;
1018
+ }
642
1019
  const resp = chatToResponses(JSON.parse(data), model);
643
1020
  rememberResponseHistory(prepared, resp);
644
1021
  recordResponsesUsage({ surface: prepared.surface, mode: "json", model, response: resp, event: resp });
@@ -660,7 +1037,6 @@ export function startAdapter(port = 2026, host = "127.0.0.1", options = {}) {
660
1037
  abort.cleanup();
661
1038
  }
662
1039
  })();
663
- return;
664
1040
  }
665
1041
 
666
1042
  if (req.method === "POST" && pathname === "/v1/responses/compact") {
@@ -673,7 +1049,7 @@ export function startAdapter(port = 2026, host = "127.0.0.1", options = {}) {
673
1049
  prepared.surface = "responses_compact";
674
1050
  const model = parsed.model || "unknown";
675
1051
  console.log(status("info", `responses compact model=${model} stream=${!!parsed.stream}`));
676
- await proxyCopilotResponses(prepared, req, res, copilotResponsesCompact, { signal: abort.signal });
1052
+ await proxyCopilotResponses(prepared, req, res, responsesCompactFn, { signal: abort.signal });
677
1053
  } catch (e) {
678
1054
  logRequestFailure("Responses compact", e, abort);
679
1055
  sendJsonError(res, e);
@@ -687,12 +1063,12 @@ export function startAdapter(port = 2026, host = "127.0.0.1", options = {}) {
687
1063
  if (req.method === "GET" && pathname === "/v1/models") {
688
1064
  if (shouldServeClaudeDesktopModels(req, claudeDesktopApiKey)) {
689
1065
  res.writeHead(200, { "Content-Type": "application/json" });
690
- res.end(JSON.stringify(claudeDesktopModelsResponse(process.env, claudeDesktopModelOptions)));
1066
+ res.end(JSON.stringify(claudeDesktopModelsResponse(process.env, claudeDesktopModelOptions())));
691
1067
  return;
692
1068
  }
693
1069
  const abort = createRequestAbort(req, res);
694
1070
  abort.setTimeout(UPSTREAM_TIMEOUT_MS);
695
- listModels({ signal: abort.signal })
1071
+ listModelsFn({ signal: abort.signal })
696
1072
  .then(({ status, body }) => { res.writeHead(status, { "Content-Type": "application/json" }); res.end(body); })
697
1073
  .catch((e) => {
698
1074
  logRequestFailure("Models", e, abort);
@@ -727,13 +1103,13 @@ export function startAdapter(port = 2026, host = "127.0.0.1", options = {}) {
727
1103
  try {
728
1104
  const parsed = await readJsonBody(req);
729
1105
  if (!parsed.stream) abort.setTimeout(UPSTREAM_TIMEOUT_MS);
730
- const { requestedModel, upstreamModel } = resolveAnthropicModel(parsed.model || "unknown", process.env, claudeDesktopModelOptions);
1106
+ const { requestedModel, upstreamModel } = resolveAnthropicModel(parsed.model || "unknown", process.env, claudeDesktopModelOptions());
731
1107
  const modelNote = upstreamModel === requestedModel ? requestedModel : `${requestedModel} -> ${upstreamModel}`;
732
1108
  console.log(status("info", `messages model=${modelNote} stream=${!!parsed.stream}`));
733
1109
  const chatReq = anthropicToChat(parsed, { upstreamModel });
734
1110
  const forceRequestedModel = upstreamModel !== requestedModel;
735
1111
  if (parsed.stream) {
736
- const upstream = await chatCompletions({ ...chatReq, stream: true }, { signal: abort.signal });
1112
+ const upstream = await chatCompletionsFn({ ...chatReq, stream: true }, { signal: abort.signal });
737
1113
  if (!upstream.ok) {
738
1114
  if (!res.headersSent) res.writeHead(upstream.status);
739
1115
  res.end(await upstream.text());
@@ -755,8 +1131,12 @@ export function startAdapter(port = 2026, host = "127.0.0.1", options = {}) {
755
1131
  recordAnthropicUsage({ surface: "messages", mode: "stream", model: requestedModel, responseId: messageId, usage });
756
1132
  if (!res.writableEnded) res.end();
757
1133
  } else {
758
- const upstream = await chatCompletions({ ...chatReq, stream: false }, { signal: abort.signal });
1134
+ const upstream = await chatCompletionsFn({ ...chatReq, stream: false }, { signal: abort.signal });
759
1135
  const data = await upstream.text();
1136
+ if (!upstream.ok) {
1137
+ sendUpstreamError(res, upstream, data);
1138
+ return;
1139
+ }
760
1140
  const anthropicMsg = chatToAnthropic(JSON.parse(data), requestedModel, { forceModel: forceRequestedModel });
761
1141
  recordAnthropicUsage({ surface: "messages", mode: "json", model: anthropicMsg.model, responseId: anthropicMsg.id, usage: anthropicMsg.usage });
762
1142
  res.writeHead(200, { "Content-Type": "application/json" });
@@ -774,7 +1154,13 @@ export function startAdapter(port = 2026, host = "127.0.0.1", options = {}) {
774
1154
 
775
1155
  res.writeHead(404, { "Content-Type": "application/json" });
776
1156
  res.end(JSON.stringify({ error: "Not found" }));
777
- });
1157
+ };
1158
+ }
1159
+
1160
+ // Public server entry point.
1161
+
1162
+ export function startAdapter(port = 2026, host = "127.0.0.1", options = {}) {
1163
+ const server = http.createServer(createAdapterHandler(options));
778
1164
 
779
1165
  server.on("upgrade", (req, socket) => {
780
1166
  // Codex Desktop 0.130+ negotiates a "responses_websockets" server-push