codex-copilot-dx 0.7.2 → 0.7.3
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 +4 -0
- package/package.json +1 -1
- package/src/responses-proxy.mjs +110 -21
package/README.md
CHANGED
|
@@ -283,6 +283,8 @@ For `/v1/responses/compact`, CCDX sends one terminal `compaction_trigger`, valid
|
|
|
283
283
|
|
|
284
284
|
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. Safe quota, retry, model, trace, and upstream request-ID metadata are forwarded; cookies, authorization, body-length, and encoding headers are not.
|
|
285
285
|
|
|
286
|
+
CCDX 0.7.3 keeps each native Responses message on its first upstream ID when Copilot changes that ID during streaming. Message deltas, completion events, and cached history stay consistent, preventing duplicate live messages in Codex App. Tool identities, reasoning/encrypted content, message phases, and speed-tier routing are unchanged. Stable events retain their original SSE bytes; changed events are rewritten with bounded, backpressured streaming.
|
|
287
|
+
|
|
286
288
|
Newer Codex App builds can advertise an `image_gen` namespace that already exists upstream. CCDX removes that exact conflicting client tool before forwarding and retries once only when Copilot explicitly reports an image namespace collision. Image input and screenshot optimization remain enabled.
|
|
287
289
|
|
|
288
290
|
## Development
|
|
@@ -295,6 +297,8 @@ npm run bench:payload
|
|
|
295
297
|
|
|
296
298
|
`npm test` runs unit and handler-level tests. `npm run test:smoke` starts a real local adapter with fully injected offline upstreams. `npm run bench:check` enforces linear SSE scanning, image/tool processing, and request-admission resource limits. `npm run pack:check` verifies npm tarball contents without publishing. `npm run bench:payload` is a report-only isolated-process benchmark for 5–60 MiB image payloads and does not contact Copilot. CI runs verification on the supported Node.js release lines.
|
|
297
299
|
|
|
300
|
+
From a source checkout, `node scripts/codex-message-id-replay.mjs --codex /absolute/path/to/codex` checks message identity against an installed Codex runtime using only synthetic SSE, a local mock server, and automatically cleaned temporary Codex directories. It does not use the real client configuration or call Copilot.
|
|
301
|
+
|
|
298
302
|
## License
|
|
299
303
|
|
|
300
304
|
MIT
|
package/package.json
CHANGED
package/src/responses-proxy.mjs
CHANGED
|
@@ -32,12 +32,56 @@ import { safeUpstreamResponseHeaders } from "./upstream-headers.mjs";
|
|
|
32
32
|
import { recordResponsesUsage } from "./usage.mjs";
|
|
33
33
|
|
|
34
34
|
const MAX_SSE_BUFFER_BYTES = loadRuntimeConfig().maxSseBufferBytes;
|
|
35
|
+
const SSE_WRITE_BATCH_BYTES = 64 * 1024;
|
|
35
36
|
const RESPONSES_TERMINAL_EVENT_TYPES = new Set([
|
|
36
37
|
"response.completed",
|
|
37
38
|
"response.incomplete",
|
|
38
39
|
"response.failed",
|
|
39
40
|
"error",
|
|
40
41
|
]);
|
|
42
|
+
const MESSAGE_CONTENT_EVENT_TYPES = new Set([
|
|
43
|
+
"response.content_part.added",
|
|
44
|
+
"response.content_part.done",
|
|
45
|
+
"response.output_text.delta",
|
|
46
|
+
"response.output_text.done",
|
|
47
|
+
"response.output_text.annotation.added",
|
|
48
|
+
"response.refusal.delta",
|
|
49
|
+
"response.refusal.done",
|
|
50
|
+
]);
|
|
51
|
+
|
|
52
|
+
function normalizeMessageIds(state, event, eventType) {
|
|
53
|
+
let changed = false;
|
|
54
|
+
const replaceId = (object, field, id) => {
|
|
55
|
+
if (id && typeof object?.[field] === "string" && object[field] !== id) {
|
|
56
|
+
object[field] = id;
|
|
57
|
+
changed = true;
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
const index = event.output_index;
|
|
61
|
+
if (Number.isSafeInteger(index) && index >= 0) {
|
|
62
|
+
// Bind only confirmed message items, using the full output index (including tools).
|
|
63
|
+
// IDs are opaque: retain the first upstream ID without decoding or synthesizing one.
|
|
64
|
+
if (eventType === "response.output_item.added" && event.item?.type === "message"
|
|
65
|
+
&& typeof event.item.id === "string" && event.item.id.length > 0
|
|
66
|
+
&& !state.messageIds.has(index)) {
|
|
67
|
+
state.messageIds.set(index, event.item.id);
|
|
68
|
+
}
|
|
69
|
+
const id = state.messageIds.get(index);
|
|
70
|
+
if (["response.output_item.added", "response.output_item.done"].includes(eventType)
|
|
71
|
+
&& event.item?.type === "message") {
|
|
72
|
+
replaceId(event.item, "id", id);
|
|
73
|
+
} else if (MESSAGE_CONTENT_EVENT_TYPES.has(eventType)) {
|
|
74
|
+
replaceId(event, "item_id", id);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (RESPONSES_TERMINAL_EVENT_TYPES.has(eventType) && Array.isArray(event.response?.output)) {
|
|
78
|
+
for (let index = 0; index < event.response.output.length; index += 1) {
|
|
79
|
+
const item = event.response.output[index];
|
|
80
|
+
if (item?.type === "message") replaceId(item, "id", state.messageIds.get(index));
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return changed;
|
|
84
|
+
}
|
|
41
85
|
|
|
42
86
|
function inspectResponseSseEvent(state, eventName, data) {
|
|
43
87
|
if (!data || state.sawTerminal) return;
|
|
@@ -57,6 +101,7 @@ function inspectResponseSseEvent(state, eventName, data) {
|
|
|
57
101
|
);
|
|
58
102
|
}
|
|
59
103
|
const eventType = event.type || eventName;
|
|
104
|
+
const changed = normalizeMessageIds(state, event, eventType);
|
|
60
105
|
const outputTokens = event.response?.usage?.output_tokens ?? event.usage?.output_tokens;
|
|
61
106
|
if (outputTokens !== undefined) markOutputTokens(outputTokens);
|
|
62
107
|
if (!state.sawOutput && isResponsesOutputEvent(event, eventType)) {
|
|
@@ -66,7 +111,7 @@ function inspectResponseSseEvent(state, eventName, data) {
|
|
|
66
111
|
if (["response.function_call_arguments.delta", "response.custom_tool_call_input.delta"].includes(eventType)) {
|
|
67
112
|
state.toolArgumentGuard.observe(event.output_index ?? event.item_id ?? event.call_id, event.delta);
|
|
68
113
|
}
|
|
69
|
-
if (!RESPONSES_TERMINAL_EVENT_TYPES.has(eventType)) return;
|
|
114
|
+
if (!RESPONSES_TERMINAL_EVENT_TYPES.has(eventType)) return changed ? event : null;
|
|
70
115
|
const expectedStatus = {
|
|
71
116
|
"response.completed": "completed",
|
|
72
117
|
"response.incomplete": "incomplete",
|
|
@@ -80,12 +125,14 @@ function inspectResponseSseEvent(state, eventName, data) {
|
|
|
80
125
|
}
|
|
81
126
|
state.sawTerminal = true;
|
|
82
127
|
if (eventType === "response.failed" || eventType === "error") markStreamFailure();
|
|
83
|
-
if (eventType
|
|
84
|
-
|
|
85
|
-
|
|
128
|
+
if (eventType === "response.completed") {
|
|
129
|
+
const response = event.response;
|
|
130
|
+
state.completed = { response, event };
|
|
131
|
+
}
|
|
132
|
+
return changed ? event : null;
|
|
86
133
|
}
|
|
87
134
|
|
|
88
|
-
function
|
|
135
|
+
function createResponseSseTransformer(state) {
|
|
89
136
|
let eventBuffer = Buffer.allocUnsafe(Math.min(4096, MAX_SSE_BUFFER_BYTES + 4));
|
|
90
137
|
let eventLength = 0;
|
|
91
138
|
let previous1 = -1;
|
|
@@ -111,12 +158,12 @@ function createResponseSseInspector(state) {
|
|
|
111
158
|
eventLength += bytes.byteLength;
|
|
112
159
|
};
|
|
113
160
|
|
|
114
|
-
const inspectEvent = (delimiterLength) => {
|
|
115
|
-
const contentLength =
|
|
161
|
+
const inspectEvent = (frame, delimiterLength) => {
|
|
162
|
+
const contentLength = frame.byteLength - delimiterLength;
|
|
116
163
|
if (contentLength > MAX_SSE_BUFFER_BYTES) {
|
|
117
164
|
throw httpError(`Upstream SSE buffer exceeds ${MAX_SSE_BUFFER_BYTES} bytes`, 502);
|
|
118
165
|
}
|
|
119
|
-
const chunk =
|
|
166
|
+
const chunk = frame.subarray(0, contentLength).toString("utf8");
|
|
120
167
|
const lines = chunk.split(/\r?\n/);
|
|
121
168
|
const eventName = lines
|
|
122
169
|
.find((line) => line.startsWith("event:"))
|
|
@@ -126,15 +173,31 @@ function createResponseSseInspector(state) {
|
|
|
126
173
|
.filter((line) => line.startsWith("data:"))
|
|
127
174
|
.map((line) => line.slice(5).trimStart())
|
|
128
175
|
.join("\n");
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
176
|
+
const changed = data && inspectResponseSseEvent(state, eventName, data);
|
|
177
|
+
if (!changed) return frame;
|
|
178
|
+
let wroteData = false;
|
|
179
|
+
const rewritten = lines.filter((line) => {
|
|
180
|
+
if (!line.startsWith("data:")) return true;
|
|
181
|
+
if (wroteData) return false;
|
|
182
|
+
wroteData = true;
|
|
183
|
+
return true;
|
|
184
|
+
}).map((line) => line.startsWith("data:") ? `data: ${JSON.stringify(changed)}` : line);
|
|
185
|
+
return Buffer.from(rewritten.join(chunk.includes("\r\n") ? "\r\n" : "\n")
|
|
186
|
+
+ frame.subarray(contentLength).toString("ascii"));
|
|
134
187
|
};
|
|
135
188
|
|
|
136
189
|
return {
|
|
137
190
|
push(value) {
|
|
191
|
+
// Fetch exposes Uint8Array chunks; use a zero-copy Buffer view for UTF-8 decoding.
|
|
192
|
+
if (!Buffer.isBuffer(value)) value = Buffer.from(value.buffer, value.byteOffset, value.byteLength);
|
|
193
|
+
let pending = [];
|
|
194
|
+
let pendingBytes = 0;
|
|
195
|
+
const flush = () => {
|
|
196
|
+
const output = pending.length === 1 ? pending[0] : Buffer.concat(pending, pendingBytes);
|
|
197
|
+
pending = [];
|
|
198
|
+
pendingBytes = 0;
|
|
199
|
+
return output;
|
|
200
|
+
};
|
|
138
201
|
let segmentStart = 0;
|
|
139
202
|
for (let index = 0; index < value.byteLength; index += 1) {
|
|
140
203
|
const byte = value[index];
|
|
@@ -145,10 +208,32 @@ function createResponseSseInspector(state) {
|
|
|
145
208
|
delimiterLength = previous3 === 0x0d ? 4 : 3;
|
|
146
209
|
}
|
|
147
210
|
if (delimiterLength) {
|
|
148
|
-
|
|
149
|
-
|
|
211
|
+
let frame = value.subarray(segmentStart, index + 1);
|
|
212
|
+
if (eventLength > 0) {
|
|
213
|
+
append(frame);
|
|
214
|
+
// Writes may retain a Buffer after returning; never expose the reusable buffer.
|
|
215
|
+
if (eventLength > SSE_WRITE_BATCH_BYTES) {
|
|
216
|
+
frame = eventBuffer.subarray(0, eventLength);
|
|
217
|
+
eventBuffer = Buffer.allocUnsafe(Math.min(4096, MAX_SSE_BUFFER_BYTES + 4));
|
|
218
|
+
} else {
|
|
219
|
+
frame = Buffer.from(eventBuffer.subarray(0, eventLength));
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
eventLength = 0;
|
|
223
|
+
previous1 = -1;
|
|
224
|
+
previous2 = -1;
|
|
225
|
+
previous3 = -1;
|
|
150
226
|
segmentStart = index + 1;
|
|
151
|
-
|
|
227
|
+
const forwarded = inspectEvent(frame, delimiterLength);
|
|
228
|
+
const last = pending.at(-1);
|
|
229
|
+
if (last && last.buffer === forwarded.buffer
|
|
230
|
+
&& last.byteOffset + last.byteLength === forwarded.byteOffset) {
|
|
231
|
+
pending[pending.length - 1] = Buffer.from(last.buffer, last.byteOffset, last.byteLength + forwarded.byteLength);
|
|
232
|
+
} else {
|
|
233
|
+
pending.push(forwarded);
|
|
234
|
+
}
|
|
235
|
+
pendingBytes += forwarded.byteLength;
|
|
236
|
+
if (state.sawTerminal) return flush();
|
|
152
237
|
continue;
|
|
153
238
|
}
|
|
154
239
|
previous3 = previous2;
|
|
@@ -159,7 +244,7 @@ function createResponseSseInspector(state) {
|
|
|
159
244
|
if (eventLength > MAX_SSE_BUFFER_BYTES + 3) {
|
|
160
245
|
throw httpError(`Upstream SSE buffer exceeds ${MAX_SSE_BUFFER_BYTES} bytes`, 502);
|
|
161
246
|
}
|
|
162
|
-
return null;
|
|
247
|
+
return pendingBytes > 0 ? flush() : null;
|
|
163
248
|
},
|
|
164
249
|
};
|
|
165
250
|
}
|
|
@@ -235,8 +320,9 @@ export async function proxyCopilotResponses(reqContext, req, res, upstream = cop
|
|
|
235
320
|
sawTerminal: false,
|
|
236
321
|
sawOutput: false,
|
|
237
322
|
toolArgumentGuard: new ToolArgumentDeltaGuard(),
|
|
323
|
+
messageIds: new Map(),
|
|
238
324
|
};
|
|
239
|
-
const
|
|
325
|
+
const transformer = createResponseSseTransformer(streamState);
|
|
240
326
|
try {
|
|
241
327
|
while (true) {
|
|
242
328
|
const { done, value } = await reader.read();
|
|
@@ -247,9 +333,12 @@ export async function proxyCopilotResponses(reqContext, req, res, upstream = cop
|
|
|
247
333
|
return { successful: Boolean(streamState.completed), compacted: false };
|
|
248
334
|
}
|
|
249
335
|
options.abort?.setTimeout(options.streamIdleTimeoutMs, "stream_idle_timeout");
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
336
|
+
// Batch within this read, not across reads, and await drain before processing more.
|
|
337
|
+
for (let offset = 0; offset < value.byteLength; offset += SSE_WRITE_BATCH_BYTES) {
|
|
338
|
+
const frame = transformer.push(value.subarray(offset, offset + SSE_WRITE_BATCH_BYTES));
|
|
339
|
+
if (frame && !await writeOrDrain(res, frame)) return;
|
|
340
|
+
if (streamState.sawTerminal) break;
|
|
341
|
+
}
|
|
253
342
|
if (streamState.sawTerminal) {
|
|
254
343
|
storeCompletedResponse(reqContext, streamState.completed);
|
|
255
344
|
res.end();
|