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 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 to Chat Completions.
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, image tool namespace handling, 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.
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 rebuilds a replayable snapshot from recent system, developer, user, and assistant messages plus every compaction item. Only a validated snapshot 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.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-copilot-dx",
3
- "version": "0.6.13",
3
+ "version": "0.6.14",
4
4
  "description": "Use Codex Desktop, Claude Code, and Claude App with GitHub Copilot",
5
5
  "author": "Dale Xiao",
6
6
  "type": "module",
@@ -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
- let input = {};
142
- try { input = JSON.parse(tc.function.arguments || "{}"); } catch { input = {}; }
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((line) => /^\s*\[[^\]]+\]\s*$/.test(line));
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 (/^\s*\[[^\]]+\]\s*$/.test(lines[i])) {
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 openaiIndex = lines.findIndex((line) => /^openai_base_url\s*=/.test(line));
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, history, and image tool compatibility", async () => {
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
  });
@@ -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 (const item of body.input) {
69
- if (item.type === "message") {
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.type)) {
92
+ } else if (["input_image", "image_url", "image"].includes(item?.type)) {
72
93
  messages.push({ role: "user", content: messageContent([item]) });
73
- } else if (item.type === "function_call") {
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: [{ id: item.call_id || randomUUID(), type: "function", function: { name: item.name, arguments: item.arguments } }],
109
+ tool_calls: toolCalls,
78
110
  });
79
- } else if (item.type === "function_call_output") {
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(inputItems, generated) {
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
 
@@ -81,15 +81,38 @@ function inspectResponseSseEvent(state, eventName, data) {
81
81
  state.completed = { response, event };
82
82
  }
83
83
 
84
- function readSseEvents(buffer, onEvent) {
85
- while (true) {
86
- const match = buffer.match(/\r?\n\r?\n/);
87
- if (!match) return buffer;
88
- const chunk = buffer.slice(0, match.index);
89
- if (Buffer.byteLength(chunk) > MAX_SSE_BUFFER_BYTES) {
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
- buffer = buffer.slice(match.index + match[0].length);
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) onEvent(eventName, 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
- buffer += decoder.decode(value, { stream: true });
165
- buffer = readSseEvents(buffer, (eventName, data) => inspectResponseSseEvent(streamState, eventName, data));
166
- if (Buffer.byteLength(buffer) > MAX_SSE_BUFFER_BYTES) {
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) => ["message", "function_call", "custom_tool_call"].includes(item?.type));
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
- if (!Array.isArray(output)) return [];
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 assertSseBufferLimit(buffer, maxBufferBytes) {
8
- if (Buffer.byteLength(buffer) > maxBufferBytes) {
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 buf = "";
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
- buf += decoder.decode(value, { stream: true });
23
- const lines = buf.split("\n");
24
- buf = lines.pop();
25
- assertSseBufferLimit(buf, maxBufferBytes);
26
- for (const line of lines) {
27
- assertSseBufferLimit(line, maxBufferBytes);
28
- yield line.replace(/\r$/, "");
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
- buf += decoder.decode();
32
- assertSseBufferLimit(buf, maxBufferBytes);
33
- if (buf.length > 0) yield buf.replace(/\r$/, "");
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();