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/README.md +17 -1
- package/bin/cli.mjs +54 -14
- package/package.json +1 -1
- package/src/adapter.mjs +468 -82
- package/src/auth.mjs +148 -7
- package/src/copilot.mjs +9 -4
- package/src/doctor.mjs +82 -7
- package/src/launcher.mjs +54 -9
- package/src/model-cache.mjs +45 -0
- package/src/running-adapter.mjs +22 -0
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
|
|
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
|
|
322
|
-
|
|
323
|
-
|
|
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
|
-
|
|
326
|
-
if (!
|
|
327
|
-
|
|
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
|
-
|
|
331
|
-
if (!
|
|
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
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
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
|
-
|
|
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((
|
|
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
|
-
|
|
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()}
|
|
547
|
-
let actualModel =
|
|
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
|
-
|
|
551
|
-
|
|
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 (!
|
|
555
|
-
|
|
556
|
-
|
|
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
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
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) {
|
|
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
|
|
579
|
-
if (
|
|
580
|
-
if (tc.function?.name)
|
|
581
|
-
if (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
|
-
//
|
|
594
|
-
|
|
595
|
-
export function
|
|
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
|
|
601
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|