codex-copilot-dx 0.6.13 → 0.6.14
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 +5 -5
- package/package.json +1 -1
- package/scripts/benchmark.mjs +74 -0
- package/src/anthropic.mjs +22 -2
- package/src/config.mjs +38 -3
- package/src/doctor.mjs +1 -2
- package/src/responses-bridge.mjs +44 -7
- package/src/responses-compaction.mjs +1 -50
- package/src/responses-proxy.mjs +116 -35
- package/src/responses-request.mjs +2 -3
- package/src/stream.mjs +27 -13
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@ Use [Codex Desktop](https://openai.com/codex), [Claude Code](https://claude.com/
|
|
|
7
7
|
A single in-process adapter (port `2026`) exposes both APIs over one Copilot
|
|
8
8
|
account by default, or over isolated Codex and Claude accounts when configured:
|
|
9
9
|
|
|
10
|
-
- **Codex** -> OpenAI Responses API (`/v1/responses`, `/v1/responses/compact`); Responses-only models and compaction proxy directly, chat models convert
|
|
10
|
+
- **Codex** -> OpenAI Responses API (`/v1/responses`, `/v1/responses/compact`); Responses-only models and compaction proxy directly, while chat models convert the text, image, and function-tool subset and explicitly reject incompatible top-level items, tools, and tool choices.
|
|
11
11
|
- **Claude Code** -> Anthropic Messages API (`/v1/messages`, `/v1/messages/count_tokens`), translated to/from Chat Completions.
|
|
12
12
|
- **Claude App** -> optional Claude Desktop App gateway profile using the same local Messages API plus local model discovery for the configured gateway key.
|
|
13
13
|
- **PM Studio** -> optional macOS app patch that keeps the active PM profile for GPT models while routing enabled Claude models through an isolated CCDX Claude account.
|
|
@@ -301,7 +301,7 @@ To actively verify the protocol path through an already-running adapter, run:
|
|
|
301
301
|
ccdx doctor --compat
|
|
302
302
|
```
|
|
303
303
|
|
|
304
|
-
The compatibility doctor sends a few minimal Copilot requests to check Codex Auto-review, native Responses, streaming history, compaction,
|
|
304
|
+
The compatibility doctor sends a few minimal Copilot requests to check Codex Auto-review, native Responses, streaming history, compaction, and Anthropic streaming. It consumes a small amount of Copilot usage, never starts the adapter or device flow, and does not change client configuration. Combine it with `--online` when both the saved-token entitlement check and the adapter protocol checks are needed.
|
|
305
305
|
|
|
306
306
|
For a read-only summary of the running adapter, use:
|
|
307
307
|
|
|
@@ -438,9 +438,9 @@ Before image encoding, CCDX leaves ordinary visual history byte-for-byte unchang
|
|
|
438
438
|
|
|
439
439
|
When expanded Responses history exceeds Copilot's 50-image request limit, the adapter removes older duplicate image occurrences first, then keeps the 50 most recent images. This applies only to the forwarded request: local history remains complete, current images are preferred over older history, and requests at or below the limit are unchanged.
|
|
440
440
|
|
|
441
|
-
For `/v1/responses/compact`, the adapter sends one terminal `compaction_trigger` in unary mode, validates that Copilot returned completed compaction state, and
|
|
441
|
+
For `/v1/responses/compact`, the adapter sends one terminal `compaction_trigger` in unary mode, validates that Copilot returned completed compaction state, and preserves the complete upstream compacted window as the canonical next context. Only a validated window becomes a new local history root. Later turns continue from it without re-expanding pre-compaction history; existing older response branches remain available until normal history eviction.
|
|
442
442
|
|
|
443
|
-
Successful streaming responses are accepted only when the upstream body is SSE and reaches its protocol terminal event. Unexpected EOF becomes a protocol-native stream error, and repeated blank tool-argument deltas are stopped per tool call. Inference responses forward safe quota, retry, model, trace, and upstream request-ID metadata; cookies, authorization, body-length, and encoding headers are not forwarded.
|
|
443
|
+
Successful streaming responses are accepted only when the upstream body is SSE and reaches its protocol terminal event; bytes after that terminal are not forwarded. Unexpected EOF becomes a protocol-native stream error, and repeated blank tool-argument deltas are stopped per tool call. Inference responses forward safe quota, retry, model, trace, and upstream request-ID metadata; cookies, authorization, body-length, and encoding headers are not forwarded.
|
|
444
444
|
|
|
445
445
|
Newer ChatGPT/Codex clients can advertise an `image_gen` namespace that already exists upstream. The adapter removes that exact conflicting client tool before forwarding and retries once only when Copilot explicitly reports an image namespace collision. Image inputs and screenshot optimization remain enabled.
|
|
446
446
|
|
|
@@ -456,4 +456,4 @@ npm run verify
|
|
|
456
456
|
npm run bench:payload
|
|
457
457
|
```
|
|
458
458
|
|
|
459
|
-
`npm test` runs the unit and handler-level suite. `npm run test:smoke` starts a real local HTTP adapter with fully injected offline upstreams. `npm run bench:check` enforces relative token-counting performance and request-admission resource limits. `npm run pack:check` verifies the npm tarball contents without publishing. `npm run bench:payload` remains a report-only, isolated-process benchmark for 5-60 MiB image payloads and does not contact Copilot. The CI workflow runs the verification checks on supported Node.js release lines.
|
|
459
|
+
`npm test` runs the unit and handler-level suite. `npm run test:smoke` starts a real local HTTP adapter with fully injected offline upstreams. `npm run bench:check` enforces relative token-counting performance, linear SSE scanning, and request-admission resource limits. `npm run pack:check` verifies the npm tarball contents without publishing. `npm run bench:payload` remains a report-only, isolated-process benchmark for 5-60 MiB image payloads and does not contact Copilot. The CI workflow runs the verification checks on supported Node.js release lines.
|
package/package.json
CHANGED
package/scripts/benchmark.mjs
CHANGED
|
@@ -31,6 +31,65 @@ function median(values) {
|
|
|
31
31
|
: ordered[middle];
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
function sseLinearityProbe() {
|
|
35
|
+
return runProbe(`
|
|
36
|
+
import { webStreamLines } from "./src/stream.mjs";
|
|
37
|
+
|
|
38
|
+
const chunkBytes = 16 * 1024;
|
|
39
|
+
const maxBufferBytes = 8 * 1024 * 1024;
|
|
40
|
+
const chunk = new TextEncoder().encode("x".repeat(chunkBytes));
|
|
41
|
+
const results = [];
|
|
42
|
+
for (const inputMiB of [1, 2, 4, 8]) {
|
|
43
|
+
const inputBytes = inputMiB * 1024 * 1024;
|
|
44
|
+
let remaining = inputBytes;
|
|
45
|
+
const response = {
|
|
46
|
+
body: new ReadableStream({
|
|
47
|
+
pull(controller) {
|
|
48
|
+
if (remaining === 0) {
|
|
49
|
+
controller.close();
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
const nextBytes = Math.min(chunk.byteLength, remaining);
|
|
53
|
+
controller.enqueue(nextBytes === chunk.byteLength ? chunk : chunk.subarray(0, nextBytes));
|
|
54
|
+
remaining -= nextBytes;
|
|
55
|
+
},
|
|
56
|
+
}, { highWaterMark: 0 }),
|
|
57
|
+
};
|
|
58
|
+
const originalByteLength = Buffer.byteLength;
|
|
59
|
+
let byteLengthCalls = 0;
|
|
60
|
+
let scannedBytes = 0;
|
|
61
|
+
Buffer.byteLength = function trackedByteLength(value, ...args) {
|
|
62
|
+
const bytes = originalByteLength(value, ...args);
|
|
63
|
+
byteLengthCalls += 1;
|
|
64
|
+
scannedBytes += bytes;
|
|
65
|
+
return bytes;
|
|
66
|
+
};
|
|
67
|
+
let lines = 0;
|
|
68
|
+
let outputBytes = 0;
|
|
69
|
+
const started = performance.now();
|
|
70
|
+
try {
|
|
71
|
+
for await (const line of webStreamLines(response, { maxBufferBytes })) {
|
|
72
|
+
lines += 1;
|
|
73
|
+
outputBytes += originalByteLength(line);
|
|
74
|
+
}
|
|
75
|
+
} finally {
|
|
76
|
+
Buffer.byteLength = originalByteLength;
|
|
77
|
+
}
|
|
78
|
+
results.push({
|
|
79
|
+
input_mib: inputMiB,
|
|
80
|
+
chunks: Math.ceil(inputBytes / chunkBytes),
|
|
81
|
+
lines,
|
|
82
|
+
output_bytes: outputBytes,
|
|
83
|
+
byte_length_calls: byteLengthCalls,
|
|
84
|
+
scanned_bytes: scannedBytes,
|
|
85
|
+
scan_ratio: +(scannedBytes / inputBytes).toFixed(3),
|
|
86
|
+
elapsed_ms: +(performance.now() - started).toFixed(1),
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
process.stdout.write(JSON.stringify({ chunk_kib: chunkBytes / 1024, max_buffer_mib: 8, results }));
|
|
90
|
+
`);
|
|
91
|
+
}
|
|
92
|
+
|
|
34
93
|
function deterministicPixels(byteLength, initialSeed) {
|
|
35
94
|
const pixels = Buffer.alloc(byteLength);
|
|
36
95
|
let seed = initialSeed;
|
|
@@ -422,6 +481,7 @@ const report = {
|
|
|
422
481
|
};
|
|
423
482
|
|
|
424
483
|
if (checkMode) {
|
|
484
|
+
report.sse_linearity = sseLinearityProbe();
|
|
425
485
|
report.large_token_count = {
|
|
426
486
|
proxy: largeTokenCountProbe("proxy"),
|
|
427
487
|
materialized_array: largeTokenCountProbe("array"),
|
|
@@ -466,6 +526,20 @@ if (checkMode) {
|
|
|
466
526
|
if (report.tool_output_parse_cache.parse_calls !== 1) {
|
|
467
527
|
failures.push(`stringified tool output parsed ${report.tool_output_parse_cache.parse_calls} times instead of once`);
|
|
468
528
|
}
|
|
529
|
+
const expectedSseSizes = [1, 2, 4, 8];
|
|
530
|
+
if (report.sse_linearity.results.length !== expectedSseSizes.length
|
|
531
|
+
|| report.sse_linearity.results.some((sample, index) => sample.input_mib !== expectedSseSizes[index])) {
|
|
532
|
+
failures.push("SSE linearity probe did not cover 1/2/4/8 MiB inputs");
|
|
533
|
+
}
|
|
534
|
+
for (const sample of report.sse_linearity.results) {
|
|
535
|
+
const inputBytes = sample.input_mib * 1024 * 1024;
|
|
536
|
+
if (sample.lines !== 1 || sample.output_bytes !== inputBytes) {
|
|
537
|
+
failures.push(`SSE parser changed the ${sample.input_mib}MiB fragmented line`);
|
|
538
|
+
}
|
|
539
|
+
if (sample.scanned_bytes > inputBytes * 1.01 || sample.byte_length_calls > sample.chunks + 1) {
|
|
540
|
+
failures.push(`SSE parser scan complexity is not linear at ${sample.input_mib}MiB`);
|
|
541
|
+
}
|
|
542
|
+
}
|
|
469
543
|
for (const payload of report.large_payload_peak) {
|
|
470
544
|
if (payload.admission_max_active_mib > payload.admission_budget_mib) {
|
|
471
545
|
failures.push(`request admission exceeded its budget at ${payload.target_request_mib}MiB x${payload.concurrency}`);
|
package/src/anthropic.mjs
CHANGED
|
@@ -130,6 +130,14 @@ export function anthropicToChat(body, options = {}) {
|
|
|
130
130
|
|
|
131
131
|
function uid() { return randomUUID().replace(/-/g, ""); }
|
|
132
132
|
|
|
133
|
+
function invalidToolArguments(toolName) {
|
|
134
|
+
const name = String(toolName || "unknown").slice(0, 100);
|
|
135
|
+
return invalidUpstreamStream(
|
|
136
|
+
`Upstream Chat Completions tool "${name}" did not return valid JSON object arguments`,
|
|
137
|
+
"upstream_tool_arguments_json_invalid",
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
133
141
|
export function chatToAnthropic(openaiResp, model, options = {}) {
|
|
134
142
|
const choice = openaiResp.choices?.[0];
|
|
135
143
|
const msg = choice?.message || {};
|
|
@@ -138,8 +146,20 @@ export function chatToAnthropic(openaiResp, model, options = {}) {
|
|
|
138
146
|
if (msg.content) content.push({ type: "text", text: msg.content });
|
|
139
147
|
if (Array.isArray(msg.tool_calls)) {
|
|
140
148
|
for (const tc of msg.tool_calls) {
|
|
141
|
-
|
|
142
|
-
|
|
149
|
+
const rawArguments = tc.function?.arguments;
|
|
150
|
+
let input;
|
|
151
|
+
if (rawArguments === undefined || rawArguments === null || rawArguments === "") {
|
|
152
|
+
input = {};
|
|
153
|
+
} else {
|
|
154
|
+
try {
|
|
155
|
+
input = JSON.parse(rawArguments);
|
|
156
|
+
} catch {
|
|
157
|
+
throw invalidToolArguments(tc.function?.name);
|
|
158
|
+
}
|
|
159
|
+
if (!input || typeof input !== "object" || Array.isArray(input)) {
|
|
160
|
+
throw invalidToolArguments(tc.function?.name);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
143
163
|
content.push({ type: "tool_use", id: tc.id, name: tc.function.name, input });
|
|
144
164
|
}
|
|
145
165
|
}
|
package/src/config.mjs
CHANGED
|
@@ -9,8 +9,39 @@ const CONFIG_PATH = path.join(os.homedir(), ".codex", "config.toml");
|
|
|
9
9
|
const MODEL_CONTEXT_WINDOW = 1_000_000;
|
|
10
10
|
const MODEL_AUTO_COMPACT_TOKEN_LIMIT = 900_000;
|
|
11
11
|
|
|
12
|
+
function isTomlTableHeader(line) {
|
|
13
|
+
const source = line.trimStart();
|
|
14
|
+
const openingBrackets = source.startsWith("[[") ? 2 : source.startsWith("[") ? 1 : 0;
|
|
15
|
+
if (!openingBrackets) return false;
|
|
16
|
+
|
|
17
|
+
let quote = "";
|
|
18
|
+
let escaped = false;
|
|
19
|
+
for (let index = openingBrackets; index < source.length; index += 1) {
|
|
20
|
+
const char = source[index];
|
|
21
|
+
if (quote) {
|
|
22
|
+
if (quote === '"' && char === "\\" && !escaped) {
|
|
23
|
+
escaped = true;
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
if (char === quote && !escaped) quote = "";
|
|
27
|
+
escaped = false;
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
if (char === '"' || char === "'") {
|
|
31
|
+
quote = char;
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
if (char !== "]" || (openingBrackets === 2 && source[index + 1] !== "]")) continue;
|
|
35
|
+
|
|
36
|
+
if (!source.slice(openingBrackets, index).trim()) return false;
|
|
37
|
+
const tail = source.slice(index + openingBrackets).trim();
|
|
38
|
+
return tail === "" || tail.startsWith("#");
|
|
39
|
+
}
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
|
|
12
43
|
function setTopLevelTomlDefault(lines, key, value) {
|
|
13
|
-
let end = lines.findIndex(
|
|
44
|
+
let end = lines.findIndex(isTomlTableHeader);
|
|
14
45
|
if (end === -1) end = lines.length;
|
|
15
46
|
|
|
16
47
|
const keyRe = new RegExp(`^\\s*${key}\\s*=`);
|
|
@@ -28,7 +59,7 @@ function setTomlKey(lines, sectionName, key, value) {
|
|
|
28
59
|
|
|
29
60
|
let end = lines.length;
|
|
30
61
|
for (let i = start + 1; i < lines.length; i++) {
|
|
31
|
-
if (
|
|
62
|
+
if (isTomlTableHeader(lines[i])) {
|
|
32
63
|
end = i;
|
|
33
64
|
break;
|
|
34
65
|
}
|
|
@@ -57,7 +88,11 @@ export function computeUpdatedCodexConfig(content, adapterPort = 2026, adapterHo
|
|
|
57
88
|
|
|
58
89
|
let changed = false;
|
|
59
90
|
const openaiLine = `openai_base_url = "${baseUrl}"`;
|
|
60
|
-
const
|
|
91
|
+
const firstSection = lines.findIndex(isTomlTableHeader);
|
|
92
|
+
const topLevelEnd = firstSection === -1 ? lines.length : firstSection;
|
|
93
|
+
const openaiIndex = lines.findIndex((line, index) => (
|
|
94
|
+
index < topLevelEnd && /^\s*openai_base_url\s*=/.test(line)
|
|
95
|
+
));
|
|
61
96
|
if (openaiIndex === -1) {
|
|
62
97
|
lines.unshift(openaiLine);
|
|
63
98
|
changed = true;
|
package/src/doctor.mjs
CHANGED
|
@@ -181,13 +181,12 @@ export async function inspectAdapterCompatibility({
|
|
|
181
181
|
});
|
|
182
182
|
|
|
183
183
|
if (firstResponse) {
|
|
184
|
-
await runCompatibilityCheck(checks, "Responses stream
|
|
184
|
+
await runCompatibilityCheck(checks, "Responses stream and history compatibility", async () => {
|
|
185
185
|
const text = await compatibilityRequest(fetchImpl, `${baseUrl}/v1/responses`, {
|
|
186
186
|
model: responsesModel,
|
|
187
187
|
stream: true,
|
|
188
188
|
previous_response_id: firstResponse.id,
|
|
189
189
|
input: "Reply with OK again.",
|
|
190
|
-
tools: [{ type: "image_generation" }],
|
|
191
190
|
}, timeoutMs);
|
|
192
191
|
if (!/^event:\s*response\.completed\s*$/m.test(text)) throw new Error("stream did not contain response.completed");
|
|
193
192
|
});
|
package/src/responses-bridge.mjs
CHANGED
|
@@ -38,6 +38,23 @@ export function responsesBodyUsesCustomTools(body) {
|
|
|
38
38
|
&& body.input.some((item) => item?.type === "custom_tool_call" || item?.type === "custom_tool_call_output");
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
function chatCompatibilityError(kind, itemType) {
|
|
42
|
+
const type = typeof itemType === "string" && itemType ? itemType : "unknown";
|
|
43
|
+
const message = `Responses ${kind} type "${type}" cannot be represented by Chat Completions`;
|
|
44
|
+
const error = new Error(message);
|
|
45
|
+
error.statusCode = 400;
|
|
46
|
+
error.code = "ccdx_responses_chat_incompatible";
|
|
47
|
+
error.jsonBody = {
|
|
48
|
+
error: {
|
|
49
|
+
message,
|
|
50
|
+
type: "invalid_request_error",
|
|
51
|
+
code: error.code,
|
|
52
|
+
item_type: type,
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
return error;
|
|
56
|
+
}
|
|
57
|
+
|
|
41
58
|
export function responsesToChat(body) {
|
|
42
59
|
const messages = [];
|
|
43
60
|
if (body.instructions) messages.push({ role: "system", content: body.instructions });
|
|
@@ -65,19 +82,36 @@ export function responsesToChat(body) {
|
|
|
65
82
|
if (typeof body.input === "string") {
|
|
66
83
|
messages.push({ role: "user", content: body.input });
|
|
67
84
|
} else if (Array.isArray(body.input)) {
|
|
68
|
-
for (
|
|
69
|
-
|
|
85
|
+
for (let index = 0; index < body.input.length; index += 1) {
|
|
86
|
+
const item = body.input[index];
|
|
87
|
+
const isEasyMessage = item?.type === undefined
|
|
88
|
+
&& ["user", "assistant", "system", "developer"].includes(item?.role)
|
|
89
|
+
&& Object.prototype.hasOwnProperty.call(item, "content");
|
|
90
|
+
if (item?.type === "message" || isEasyMessage) {
|
|
70
91
|
messages.push({ role: item.role, content: messageContent(item.content) });
|
|
71
|
-
} else if (["input_image", "image_url", "image"].includes(item
|
|
92
|
+
} else if (["input_image", "image_url", "image"].includes(item?.type)) {
|
|
72
93
|
messages.push({ role: "user", content: messageContent([item]) });
|
|
73
|
-
} else if (item
|
|
94
|
+
} else if (item?.type === "function_call") {
|
|
95
|
+
const toolCalls = [];
|
|
96
|
+
while (index < body.input.length && body.input[index]?.type === "function_call") {
|
|
97
|
+
const call = body.input[index];
|
|
98
|
+
toolCalls.push({
|
|
99
|
+
id: call.call_id || randomUUID(),
|
|
100
|
+
type: "function",
|
|
101
|
+
function: { name: call.name, arguments: call.arguments },
|
|
102
|
+
});
|
|
103
|
+
index += 1;
|
|
104
|
+
}
|
|
105
|
+
index -= 1;
|
|
74
106
|
messages.push({
|
|
75
107
|
role: "assistant",
|
|
76
108
|
content: null,
|
|
77
|
-
tool_calls:
|
|
109
|
+
tool_calls: toolCalls,
|
|
78
110
|
});
|
|
79
|
-
} else if (item
|
|
111
|
+
} else if (item?.type === "function_call_output") {
|
|
80
112
|
messages.push({ role: "tool", tool_call_id: item.call_id, content: typeof item.output === "string" ? item.output : JSON.stringify(item.output) });
|
|
113
|
+
} else {
|
|
114
|
+
throw chatCompatibilityError("input item", item?.type);
|
|
81
115
|
}
|
|
82
116
|
}
|
|
83
117
|
}
|
|
@@ -90,9 +124,10 @@ export function responsesToChat(body) {
|
|
|
90
124
|
if (maxTok !== undefined) chatReq.max_completion_tokens = maxTok;
|
|
91
125
|
|
|
92
126
|
if (body.tools?.length) {
|
|
127
|
+
const unsupportedTool = body.tools.find((tool) => tool?.type !== "function");
|
|
128
|
+
if (unsupportedTool) throw chatCompatibilityError("tool", unsupportedTool?.type);
|
|
93
129
|
chatReq.tools = body.tools
|
|
94
130
|
.map((tool) => {
|
|
95
|
-
if (tool?.type !== "function") return null;
|
|
96
131
|
if (tool.function?.name) return cloneJson(tool);
|
|
97
132
|
const fn = {
|
|
98
133
|
name: tool.name,
|
|
@@ -112,6 +147,8 @@ export function responsesToChat(body) {
|
|
|
112
147
|
chatReq.tool_choice = body.tool_choice;
|
|
113
148
|
} else if (body.tool_choice?.type === "function" && body.tool_choice.name) {
|
|
114
149
|
chatReq.tool_choice = { type: "function", function: { name: body.tool_choice.name } };
|
|
150
|
+
} else {
|
|
151
|
+
throw chatCompatibilityError("tool choice", body.tool_choice?.type);
|
|
115
152
|
}
|
|
116
153
|
}
|
|
117
154
|
if (body.parallel_tool_calls !== undefined) chatReq.parallel_tool_calls = body.parallel_tool_calls;
|
|
@@ -1,10 +1,6 @@
|
|
|
1
|
-
import { randomUUID } from "node:crypto";
|
|
2
1
|
import { httpError } from "./http-transport.mjs";
|
|
3
2
|
|
|
4
3
|
const COMPACTION_TRIGGER_TYPE = "compaction_trigger";
|
|
5
|
-
const RETAINED_MESSAGE_ROLES = new Set(["user", "assistant", "developer", "system"]);
|
|
6
|
-
const RETAINED_MESSAGE_TOKEN_BUDGET = 64_000;
|
|
7
|
-
const APPROX_BYTES_PER_TOKEN = 4;
|
|
8
4
|
|
|
9
5
|
function compactionError(message) {
|
|
10
6
|
const error = httpError(message, 502);
|
|
@@ -19,47 +15,6 @@ function compactionError(message) {
|
|
|
19
15
|
return error;
|
|
20
16
|
}
|
|
21
17
|
|
|
22
|
-
function normalizeMessageContent(content) {
|
|
23
|
-
if (typeof content === "string") return [{ type: "input_text", text: content }];
|
|
24
|
-
if (!Array.isArray(content)) return [];
|
|
25
|
-
return content.map((part) => {
|
|
26
|
-
if (!part || typeof part !== "object") return part;
|
|
27
|
-
return part.type === "output_text"
|
|
28
|
-
? { ...part, type: "input_text" }
|
|
29
|
-
: structuredClone(part);
|
|
30
|
-
});
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
function approximateTextTokens(content) {
|
|
34
|
-
return content.reduce((total, part) => {
|
|
35
|
-
if (part?.type !== "input_text" || typeof part.text !== "string") return total;
|
|
36
|
-
return total + Math.ceil(Buffer.byteLength(part.text) / APPROX_BYTES_PER_TOKEN);
|
|
37
|
-
}, 0);
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
function retainedMessages(inputItems) {
|
|
41
|
-
const kept = [];
|
|
42
|
-
let usedTokens = 0;
|
|
43
|
-
for (let index = inputItems.length - 1; index >= 0; index -= 1) {
|
|
44
|
-
const item = inputItems[index];
|
|
45
|
-
if (item?.type !== "message" || !RETAINED_MESSAGE_ROLES.has(item.role)) continue;
|
|
46
|
-
|
|
47
|
-
const content = normalizeMessageContent(item.content);
|
|
48
|
-
usedTokens += Math.max(approximateTextTokens(content), 1);
|
|
49
|
-
if (usedTokens > RETAINED_MESSAGE_TOKEN_BUDGET && kept.length > 0) break;
|
|
50
|
-
|
|
51
|
-
kept.push({
|
|
52
|
-
type: "message",
|
|
53
|
-
id: `msg_${randomUUID().replaceAll("-", "")}`,
|
|
54
|
-
status: item.status ?? "completed",
|
|
55
|
-
role: item.role,
|
|
56
|
-
content,
|
|
57
|
-
...(item.phase !== undefined ? { phase: structuredClone(item.phase) } : {}),
|
|
58
|
-
});
|
|
59
|
-
}
|
|
60
|
-
return kept.reverse();
|
|
61
|
-
}
|
|
62
|
-
|
|
63
18
|
export function prepareResponsesCompactionRequest(reqContext) {
|
|
64
19
|
const input = Array.isArray(reqContext?.body?.input) ? reqContext.body.input : [];
|
|
65
20
|
const compactionInputItems = compactionInputWithoutTrigger(input);
|
|
@@ -74,7 +29,7 @@ export function compactionInputWithoutTrigger(input) {
|
|
|
74
29
|
: [];
|
|
75
30
|
}
|
|
76
31
|
|
|
77
|
-
export function createResponsesCompactionResult(
|
|
32
|
+
export function createResponsesCompactionResult(_inputItems, generated) {
|
|
78
33
|
if (!generated || typeof generated !== "object" || !generated.id || !Array.isArray(generated.output)) {
|
|
79
34
|
throw compactionError("Copilot compaction returned an invalid response envelope");
|
|
80
35
|
}
|
|
@@ -95,10 +50,6 @@ export function createResponsesCompactionResult(inputItems, generated) {
|
|
|
95
50
|
return {
|
|
96
51
|
...generated,
|
|
97
52
|
object: "response.compaction",
|
|
98
|
-
output: [
|
|
99
|
-
...retainedMessages(Array.isArray(inputItems) ? inputItems : []),
|
|
100
|
-
...compactionItems,
|
|
101
|
-
],
|
|
102
53
|
};
|
|
103
54
|
}
|
|
104
55
|
|
package/src/responses-proxy.mjs
CHANGED
|
@@ -81,15 +81,38 @@ function inspectResponseSseEvent(state, eventName, data) {
|
|
|
81
81
|
state.completed = { response, event };
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
-
function
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
84
|
+
function createResponseSseInspector(state) {
|
|
85
|
+
let eventBuffer = Buffer.allocUnsafe(Math.min(4096, MAX_SSE_BUFFER_BYTES + 4));
|
|
86
|
+
let eventLength = 0;
|
|
87
|
+
let previous1 = -1;
|
|
88
|
+
let previous2 = -1;
|
|
89
|
+
let previous3 = -1;
|
|
90
|
+
|
|
91
|
+
const ensureCapacity = (required) => {
|
|
92
|
+
if (required > MAX_SSE_BUFFER_BYTES + 4) {
|
|
93
|
+
throw httpError(`Upstream SSE buffer exceeds ${MAX_SSE_BUFFER_BYTES} bytes`, 502);
|
|
94
|
+
}
|
|
95
|
+
if (required <= eventBuffer.length) return;
|
|
96
|
+
let capacity = eventBuffer.length;
|
|
97
|
+
while (capacity < required) capacity = Math.min(MAX_SSE_BUFFER_BYTES + 4, Math.max(capacity * 2, required));
|
|
98
|
+
const next = Buffer.allocUnsafe(capacity);
|
|
99
|
+
eventBuffer.copy(next, 0, 0, eventLength);
|
|
100
|
+
eventBuffer = next;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const append = (bytes) => {
|
|
104
|
+
if (!bytes.byteLength) return;
|
|
105
|
+
ensureCapacity(eventLength + bytes.byteLength);
|
|
106
|
+
eventBuffer.set(bytes, eventLength);
|
|
107
|
+
eventLength += bytes.byteLength;
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const inspectEvent = (delimiterLength) => {
|
|
111
|
+
const contentLength = eventLength - delimiterLength;
|
|
112
|
+
if (contentLength > MAX_SSE_BUFFER_BYTES) {
|
|
90
113
|
throw httpError(`Upstream SSE buffer exceeds ${MAX_SSE_BUFFER_BYTES} bytes`, 502);
|
|
91
114
|
}
|
|
92
|
-
|
|
115
|
+
const chunk = eventBuffer.subarray(0, contentLength).toString("utf8");
|
|
93
116
|
const lines = chunk.split(/\r?\n/);
|
|
94
117
|
const eventName = lines
|
|
95
118
|
.find((line) => line.startsWith("event:"))
|
|
@@ -99,8 +122,73 @@ function readSseEvents(buffer, onEvent) {
|
|
|
99
122
|
.filter((line) => line.startsWith("data:"))
|
|
100
123
|
.map((line) => line.slice(5).trimStart())
|
|
101
124
|
.join("\n");
|
|
102
|
-
if (data)
|
|
125
|
+
if (data) inspectResponseSseEvent(state, eventName, data);
|
|
126
|
+
eventLength = 0;
|
|
127
|
+
previous1 = -1;
|
|
128
|
+
previous2 = -1;
|
|
129
|
+
previous3 = -1;
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
push(value) {
|
|
134
|
+
let segmentStart = 0;
|
|
135
|
+
for (let index = 0; index < value.byteLength; index += 1) {
|
|
136
|
+
const byte = value[index];
|
|
137
|
+
let delimiterLength = 0;
|
|
138
|
+
if (byte === 0x0a && previous1 === 0x0a) {
|
|
139
|
+
delimiterLength = previous2 === 0x0d ? 3 : 2;
|
|
140
|
+
} else if (byte === 0x0a && previous1 === 0x0d && previous2 === 0x0a) {
|
|
141
|
+
delimiterLength = previous3 === 0x0d ? 4 : 3;
|
|
142
|
+
}
|
|
143
|
+
if (delimiterLength) {
|
|
144
|
+
append(value.subarray(segmentStart, index + 1));
|
|
145
|
+
inspectEvent(delimiterLength);
|
|
146
|
+
segmentStart = index + 1;
|
|
147
|
+
if (state.sawTerminal) return index + 1;
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
previous3 = previous2;
|
|
151
|
+
previous2 = previous1;
|
|
152
|
+
previous1 = byte;
|
|
153
|
+
}
|
|
154
|
+
append(value.subarray(segmentStart));
|
|
155
|
+
if (eventLength > MAX_SSE_BUFFER_BYTES + 3) {
|
|
156
|
+
throw httpError(`Upstream SSE buffer exceeds ${MAX_SSE_BUFFER_BYTES} bytes`, 502);
|
|
157
|
+
}
|
|
158
|
+
return null;
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function invalidResponsesResponse(message) {
|
|
164
|
+
const error = httpError(message, 502);
|
|
165
|
+
error.code = "ccdx_invalid_responses_response";
|
|
166
|
+
error.jsonBody = {
|
|
167
|
+
error: {
|
|
168
|
+
message,
|
|
169
|
+
type: "upstream_error",
|
|
170
|
+
code: error.code,
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
return error;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function parseSuccessfulResponsesResult(text) {
|
|
177
|
+
let response;
|
|
178
|
+
try {
|
|
179
|
+
response = JSON.parse(text);
|
|
180
|
+
} catch {
|
|
181
|
+
throw invalidResponsesResponse("Copilot Responses returned invalid JSON");
|
|
182
|
+
}
|
|
183
|
+
if (!response
|
|
184
|
+
|| typeof response !== "object"
|
|
185
|
+
|| Array.isArray(response)
|
|
186
|
+
|| typeof response.id !== "string"
|
|
187
|
+
|| response.id.length === 0
|
|
188
|
+
|| !Array.isArray(response.output)) {
|
|
189
|
+
throw invalidResponsesResponse("Copilot Responses returned an invalid response envelope");
|
|
103
190
|
}
|
|
191
|
+
return response;
|
|
104
192
|
}
|
|
105
193
|
|
|
106
194
|
function storeCompletedResponse(reqContext, completed) {
|
|
@@ -138,35 +226,26 @@ export async function proxyCopilotResponses(reqContext, req, res, upstream = cop
|
|
|
138
226
|
Connection: "keep-alive",
|
|
139
227
|
});
|
|
140
228
|
const reader = resp.body.getReader();
|
|
141
|
-
const decoder = new TextDecoder();
|
|
142
|
-
let buffer = "";
|
|
143
229
|
const streamState = {
|
|
144
230
|
completed: null,
|
|
145
231
|
sawTerminal: false,
|
|
146
232
|
sawOutput: false,
|
|
147
233
|
toolArgumentGuard: new ToolArgumentDeltaGuard(),
|
|
148
234
|
};
|
|
235
|
+
const inspector = createResponseSseInspector(streamState);
|
|
149
236
|
try {
|
|
150
237
|
while (true) {
|
|
151
238
|
const { done, value } = await reader.read();
|
|
152
239
|
if (done) {
|
|
153
|
-
buffer += decoder.decode();
|
|
154
|
-
if (Buffer.byteLength(buffer) > MAX_SSE_BUFFER_BYTES) {
|
|
155
|
-
throw httpError(`Upstream SSE buffer exceeds ${MAX_SSE_BUFFER_BYTES} bytes`, 502);
|
|
156
|
-
}
|
|
157
|
-
readSseEvents(buffer, (eventName, data) => inspectResponseSseEvent(streamState, eventName, data));
|
|
158
240
|
if (!streamState.sawTerminal) throw incompleteUpstreamStream("a terminal Responses event");
|
|
159
241
|
storeCompletedResponse(reqContext, streamState.completed);
|
|
160
242
|
res.end();
|
|
161
243
|
return { successful: Boolean(streamState.completed), compacted: false };
|
|
162
244
|
}
|
|
163
245
|
options.abort?.setTimeout(options.streamIdleTimeoutMs, "stream_idle_timeout");
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
if (
|
|
167
|
-
throw httpError(`Upstream SSE buffer exceeds ${MAX_SSE_BUFFER_BYTES} bytes`, 502);
|
|
168
|
-
}
|
|
169
|
-
if (!await writeOrDrain(res, value)) return;
|
|
246
|
+
const terminalOffset = inspector.push(value);
|
|
247
|
+
const forwarded = terminalOffset === null ? value : value.subarray(0, terminalOffset);
|
|
248
|
+
if (forwarded.byteLength > 0 && !await writeOrDrain(res, forwarded)) return;
|
|
170
249
|
if (streamState.sawTerminal) {
|
|
171
250
|
storeCompletedResponse(reqContext, streamState.completed);
|
|
172
251
|
res.end();
|
|
@@ -202,24 +281,26 @@ export async function proxyCopilotResponses(reqContext, req, res, upstream = cop
|
|
|
202
281
|
res.end(JSON.stringify(response));
|
|
203
282
|
return { successful: true, compacted: true };
|
|
204
283
|
}
|
|
284
|
+
if (resp.ok) {
|
|
285
|
+
const response = parseSuccessfulResponsesResult(data);
|
|
286
|
+
rememberResponseHistory(reqContext, response);
|
|
287
|
+
recordResponsesUsage({
|
|
288
|
+
surface: reqContext.surface,
|
|
289
|
+
mode: "json",
|
|
290
|
+
model: reqContext.body?.model,
|
|
291
|
+
response,
|
|
292
|
+
event: response,
|
|
293
|
+
});
|
|
294
|
+
res.writeHead(resp.status, safeUpstreamResponseHeaders(resp.headers, {
|
|
295
|
+
contentType: "application/json",
|
|
296
|
+
}));
|
|
297
|
+
res.end(data);
|
|
298
|
+
return { successful: true, compacted: false };
|
|
299
|
+
}
|
|
205
300
|
res.writeHead(resp.status, safeUpstreamResponseHeaders(resp.headers, {
|
|
206
301
|
contentType: "application/json",
|
|
207
302
|
}));
|
|
208
303
|
res.end(data);
|
|
209
|
-
if (resp.ok) {
|
|
210
|
-
try {
|
|
211
|
-
const response = JSON.parse(data);
|
|
212
|
-
rememberResponseHistory(reqContext, response);
|
|
213
|
-
recordResponsesUsage({
|
|
214
|
-
surface: reqContext.surface,
|
|
215
|
-
mode: "json",
|
|
216
|
-
model: reqContext.body?.model,
|
|
217
|
-
response,
|
|
218
|
-
event: response,
|
|
219
|
-
});
|
|
220
|
-
return { successful: true, compacted: false };
|
|
221
|
-
} catch {}
|
|
222
|
-
}
|
|
223
304
|
return { successful: false, compacted: false };
|
|
224
305
|
}
|
|
225
306
|
}
|
|
@@ -46,7 +46,7 @@ export function stripInternalResponsesInputFields(inputItems, { assertActive } =
|
|
|
46
46
|
|
|
47
47
|
function responsesOutputItems(output) {
|
|
48
48
|
if (!Array.isArray(output)) return [];
|
|
49
|
-
return output.filter((item) =>
|
|
49
|
+
return output.filter((item) => item && typeof item === "object");
|
|
50
50
|
}
|
|
51
51
|
|
|
52
52
|
function isCompactedResponsesOutput(output) {
|
|
@@ -62,8 +62,7 @@ function isSuccessfulCompactionResponse(response) {
|
|
|
62
62
|
}
|
|
63
63
|
|
|
64
64
|
function replayableResponsesOutputItems(output) {
|
|
65
|
-
|
|
66
|
-
return output.filter((item) => item && typeof item === "object");
|
|
65
|
+
return responsesOutputItems(output);
|
|
67
66
|
}
|
|
68
67
|
|
|
69
68
|
function isEncryptedContentPart(value) {
|
package/src/stream.mjs
CHANGED
|
@@ -4,33 +4,47 @@ import { loadRuntimeConfig } from "./runtime-config.mjs";
|
|
|
4
4
|
|
|
5
5
|
const MAX_SSE_BUFFER_BYTES = loadRuntimeConfig().maxSseBufferBytes;
|
|
6
6
|
|
|
7
|
-
function
|
|
8
|
-
if (
|
|
7
|
+
function appendLineFragment(parts, fragment, currentBytes, maxBufferBytes) {
|
|
8
|
+
if (!fragment) return currentBytes;
|
|
9
|
+
const nextBytes = currentBytes + Buffer.byteLength(fragment);
|
|
10
|
+
if (nextBytes > maxBufferBytes) {
|
|
9
11
|
throw new Error(`Upstream SSE buffer exceeds ${maxBufferBytes} bytes`);
|
|
10
12
|
}
|
|
13
|
+
parts.push(fragment);
|
|
14
|
+
return nextBytes;
|
|
11
15
|
}
|
|
12
16
|
|
|
13
17
|
export async function* webStreamLines(response, { onChunk, maxBufferBytes = MAX_SSE_BUFFER_BYTES } = {}) {
|
|
14
18
|
const reader = response.body.getReader();
|
|
15
19
|
const decoder = new TextDecoder();
|
|
16
|
-
let
|
|
20
|
+
let lineParts = [];
|
|
21
|
+
let lineBytes = 0;
|
|
17
22
|
try {
|
|
18
23
|
while (true) {
|
|
19
24
|
const { done, value } = await reader.read();
|
|
20
25
|
if (done) break;
|
|
21
26
|
onChunk?.(value);
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
27
|
+
const decoded = decoder.decode(value, { stream: true });
|
|
28
|
+
let start = 0;
|
|
29
|
+
while (start < decoded.length) {
|
|
30
|
+
const newline = decoded.indexOf("\n", start);
|
|
31
|
+
if (newline === -1) {
|
|
32
|
+
lineBytes = appendLineFragment(lineParts, decoded.slice(start), lineBytes, maxBufferBytes);
|
|
33
|
+
break;
|
|
34
|
+
}
|
|
35
|
+
lineBytes = appendLineFragment(lineParts, decoded.slice(start, newline), lineBytes, maxBufferBytes);
|
|
36
|
+
const line = lineParts.length > 1 ? lineParts.join("") : (lineParts[0] || "");
|
|
37
|
+
yield line.endsWith("\r") ? line.slice(0, -1) : line;
|
|
38
|
+
lineParts = [];
|
|
39
|
+
lineBytes = 0;
|
|
40
|
+
start = newline + 1;
|
|
29
41
|
}
|
|
30
42
|
}
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
43
|
+
lineBytes = appendLineFragment(lineParts, decoder.decode(), lineBytes, maxBufferBytes);
|
|
44
|
+
if (lineParts.length > 0) {
|
|
45
|
+
const line = lineParts.length > 1 ? lineParts.join("") : lineParts[0];
|
|
46
|
+
yield line.endsWith("\r") ? line.slice(0, -1) : line;
|
|
47
|
+
}
|
|
34
48
|
} finally {
|
|
35
49
|
await reader.cancel().catch(() => {});
|
|
36
50
|
reader.releaseLock();
|