codex-copilot-dx 0.4.43 → 0.4.44

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/doctor.mjs CHANGED
@@ -124,6 +124,15 @@ function parseResponseObject(text) {
124
124
  return response;
125
125
  }
126
126
 
127
+ function parseCompactionResponse(text) {
128
+ const response = parseResponseObject(text);
129
+ if (response.object !== "response.compaction"
130
+ || !response.output.some((item) => item?.type === "compaction")) {
131
+ throw new Error("response body is missing valid compaction state");
132
+ }
133
+ return response;
134
+ }
135
+
127
136
  export async function inspectAdapterCompatibility({
128
137
  host = "127.0.0.1",
129
138
  port = 2026,
@@ -184,7 +193,7 @@ export async function inspectAdapterCompatibility({
184
193
  stream: false,
185
194
  input: "Compact this short context.",
186
195
  }, timeoutMs);
187
- parseResponseObject(text);
196
+ parseCompactionResponse(text);
188
197
  });
189
198
 
190
199
  if (!claudeModel) {
@@ -446,7 +455,7 @@ export async function collectDoctorChecks({
446
455
  export async function runDoctor(options = {}) {
447
456
  const log = options.log || console.log;
448
457
  const flags = [options.online ? "--online" : "", options.compat ? "--compat" : ""].filter(Boolean);
449
- log(`codex-copilot-dx doctor${flags.length ? ` ${flags.join(" ")}` : ""}`);
458
+ log(`ccdx doctor${flags.length ? ` ${flags.join(" ")}` : ""}`);
450
459
  const checks = await collectDoctorChecks(options);
451
460
  for (const check of checks) log(status(check.kind, check.message));
452
461
  return checks;
@@ -2,6 +2,7 @@ import { promisify } from "node:util";
2
2
  import * as zlib from "node:zlib";
3
3
  import { loadRuntimeConfig, parsePositiveInteger } from "./runtime-config.mjs";
4
4
  import { status } from "./status.mjs";
5
+ import { safeUpstreamResponseHeaders } from "./upstream-headers.mjs";
5
6
 
6
7
  const COMPRESSED_BODY_WEIGHT_MULTIPLIER = 4;
7
8
  const gunzipAsync = promisify(zlib.gunzip);
@@ -296,7 +297,9 @@ export function sendJsonError(res, err, fallbackStatus = 400) {
296
297
 
297
298
  export function sendUpstreamError(res, response, text) {
298
299
  if (!res.headersSent) {
299
- res.writeHead(response.status || 502, { "Content-Type": response.headers?.get("content-type") || "application/json" });
300
+ res.writeHead(response.status || 502, safeUpstreamResponseHeaders(response.headers, {
301
+ defaultContentType: "application/json",
302
+ }));
300
303
  }
301
304
  res.end(text || JSON.stringify({ error: "Upstream request failed" }));
302
305
  }
@@ -4,17 +4,19 @@ import {
4
4
  readResponsesImagePart,
5
5
  readResponsesToolOutputParts,
6
6
  } from "./responses-content.mjs";
7
+ import { createBoundedResultCache } from "./bounded-result-cache.mjs";
7
8
  import { status } from "./status.mjs";
8
9
 
9
10
  const DEFAULT_IMG_CONCURRENCY = 2;
10
11
  const IMG_MAX_CONCURRENCY = 12;
11
12
  const DEFAULT_IMG_MAX_INPUT_PIXELS = 40 * 1000 * 1000;
13
+ const DEFAULT_IMG_CACHE_MAX_BYTES = 64 * 1024 * 1024;
12
14
  const DEFAULT_MAX_UPSTREAM_BODY_BYTES = 30 * 1024 * 1024;
13
- const MAX_OPTIMIZED_IMAGE_DIGESTS = 4096;
14
15
  const IMG_MAX_DIM = positiveInt(process.env.CCDX_IMG_MAX_DIM, 2048);
15
16
  const IMG_QUALITY = positiveInt(process.env.CCDX_IMG_QUALITY, 82, 100);
16
17
  const IMG_MIN_BYTES = nonNegativeInt(process.env.CCDX_IMG_MIN_BYTES, 100000);
17
18
  const IMG_MAX_INPUT_PIXELS = positiveInt(process.env.CCDX_IMG_MAX_INPUT_PIXELS, DEFAULT_IMG_MAX_INPUT_PIXELS);
19
+ const IMG_CACHE_MAX_BYTES = nonNegativeInt(process.env.CCDX_IMG_CACHE_MAX_BYTES, DEFAULT_IMG_CACHE_MAX_BYTES);
18
20
  const MAX_UPSTREAM_BODY_BYTES = positiveInt(process.env.CCDX_MAX_UPSTREAM_BODY_BYTES, DEFAULT_MAX_UPSTREAM_BODY_BYTES);
19
21
  const IMG_OPT_DISABLED = process.env.CCDX_DISABLE_IMG_OPT === "1";
20
22
  const IMG_CONCURRENCY = parseImageConcurrency(process.env.CCDX_IMG_CONCURRENCY);
@@ -22,7 +24,8 @@ const OVERSIZE_IMAGE_PROFILES = [
22
24
  { maxDim: Math.min(IMG_MAX_DIM, 1600), quality: Math.min(IMG_QUALITY, 75) },
23
25
  { maxDim: Math.min(IMG_MAX_DIM, 1280), quality: Math.min(IMG_QUALITY, 65) },
24
26
  ];
25
- const optimizedImageDigests = new Set();
27
+ const IMAGE_ENCODER_CONFIG = "webp:v1:effort=4:autorotate=1:fit=inside:without_enlargement=1";
28
+ const imageResultCache = createBoundedResultCache({ maxBytes: IMG_CACHE_MAX_BYTES });
26
29
  let sharpImport = null;
27
30
 
28
31
  function positiveInt(value, fallback, max = Number.MAX_SAFE_INTEGER) {
@@ -140,52 +143,50 @@ export function createTaskLimiter(concurrency) {
140
143
  const runGlobalImageTask = createTaskLimiter(IMG_CONCURRENCY);
141
144
 
142
145
  export function imageOptimizationStats() {
146
+ const cache = imageResultCache.stats();
143
147
  return {
144
148
  ...runGlobalImageTask.stats(),
145
149
  disabled: IMG_OPT_DISABLED,
146
- cache_entries: optimizedImageDigests.size,
150
+ cache_entries: cache.entries,
151
+ cache_bytes: cache.bytes,
152
+ cache_max_bytes: cache.max_bytes,
153
+ cache_hits: cache.hits,
154
+ cache_misses: cache.misses,
155
+ cache_evictions: cache.evictions,
156
+ cache_inflight: cache.inflight,
147
157
  sharp_loaded: sharpImport !== null,
148
158
  };
149
159
  }
150
160
 
161
+ export function resetImageOptimizationCacheForTests() {
162
+ imageResultCache.clear();
163
+ }
164
+
151
165
  async function sharp() {
152
166
  sharpImport ||= import("sharp");
153
167
  const mod = await sharpImport;
154
168
  return mod.default || mod;
155
169
  }
156
170
 
157
- function imageDigest(raw) {
158
- return createHash("sha256").update(raw).digest("base64url");
171
+ function imageDigest(value, encoding) {
172
+ return createHash("sha256").update(value, encoding).digest("base64url");
159
173
  }
160
174
 
161
- function imageOptimizationKey(digest, constraints, quality) {
175
+ function imageOptimizationKey(digest, mime, constraints, quality) {
162
176
  return [
163
177
  digest,
178
+ mime,
164
179
  constraints.maxLongEdge || "",
165
180
  constraints.maxShortEdge || "",
166
181
  constraints.maxArea || "",
167
182
  quality,
183
+ IMAGE_ENCODER_CONFIG,
168
184
  ].join(":");
169
185
  }
170
186
 
171
- function touchOptimizedImage(digest) {
172
- if (!optimizedImageDigests.delete(digest)) return false;
173
- optimizedImageDigests.add(digest);
174
- return true;
175
- }
176
-
177
- function rememberOptimizedImage(digest) {
178
- optimizedImageDigests.delete(digest);
179
- optimizedImageDigests.add(digest);
180
- if (optimizedImageDigests.size > MAX_OPTIMIZED_IMAGE_DIGESTS) {
181
- optimizedImageDigests.delete(optimizedImageDigests.values().next().value);
182
- }
183
- }
184
-
185
187
  export async function optimizeImageDataUrl(dataUrl, {
186
188
  maxDim = IMG_MAX_DIM,
187
189
  quality = IMG_QUALITY,
188
- force = false,
189
190
  model,
190
191
  signal,
191
192
  } = {}) {
@@ -197,52 +198,60 @@ export async function optimizeImageDataUrl(dataUrl, {
197
198
  const encoded = match[2];
198
199
  const inputBytes = Buffer.byteLength(encoded, "base64");
199
200
  if (mime.includes("gif")) return dataUrl;
201
+ if (signal?.aborted) throw abortError(signal);
202
+ const constraints = imageConstraintsForModel(model, { maxDim });
203
+ const outputQuality = positiveInt(quality, IMG_QUALITY, 100);
204
+ // Hash decoded source bytes without retaining a decoded Buffer. Cache hits
205
+ // therefore bypass both Sharp and the global image work queue.
206
+ const digest = imageDigest(encoded, "base64");
207
+ const optimizationKey = imageOptimizationKey(digest, mime, constraints, outputQuality);
200
208
 
201
209
  try {
202
- return await runGlobalImageTask(async () => {
203
- const raw = Buffer.from(encoded, "base64");
204
- const digest = imageDigest(raw);
205
- const constraints = imageConstraintsForModel(model, { maxDim });
206
- const outputQuality = positiveInt(quality, IMG_QUALITY, 100);
207
- const optimizationKey = imageOptimizationKey(digest, constraints, outputQuality);
208
- if (!force && touchOptimizedImage(optimizationKey)) return dataUrl;
209
- const resize = await sharp();
210
- const image = resize(raw, { failOn: "none", limitInputPixels: IMG_MAX_INPUT_PIXELS });
211
- const metadata = await image.metadata();
212
- const orientation = Number(metadata.orientation);
213
- const rotated = orientation >= 5 && orientation <= 8;
214
- const sourceWidth = rotated ? metadata.height : metadata.width;
215
- const sourceHeight = rotated ? metadata.width : metadata.height;
216
- const target = fitImageDimensions(sourceWidth, sourceHeight, constraints);
217
- const resizeRequired = target
218
- && (target.width < sourceWidth || target.height < sourceHeight);
219
- if (raw.length < IMG_MIN_BYTES && !resizeRequired) {
220
- rememberOptimizedImage(optimizationKey);
221
- return dataUrl;
222
- }
223
- const resizeOptions = target
224
- ? { ...target, fit: "inside", withoutEnlargement: true }
225
- : {
226
- width: constraints.maxLongEdge,
227
- height: constraints.maxLongEdge,
228
- fit: "inside",
229
- withoutEnlargement: true,
230
- };
231
- const out = await image
232
- .rotate()
233
- .resize(resizeOptions)
234
- .webp({ quality: outputQuality, effort: 4 })
235
- .toBuffer();
236
- if (signal?.aborted) throw abortError(signal);
237
- if (out.length >= raw.length) {
238
- rememberOptimizedImage(optimizationKey);
239
- return dataUrl;
240
- }
241
- rememberOptimizedImage(imageOptimizationKey(imageDigest(out), constraints, outputQuality));
242
- const ratio = ((out.length / raw.length) * 100).toFixed(1);
243
- console.log(status("info", `image ${(raw.length / 1024).toFixed(0)}KB ${mime} -> ${(out.length / 1024).toFixed(0)}KB webp (${ratio}%)`));
244
- return `data:image/webp;base64,${out.toString("base64")}`;
245
- }, { signal });
210
+ return await imageResultCache.getOrCreate(optimizationKey, (sharedSignal) => (
211
+ runGlobalImageTask(async () => {
212
+ const raw = Buffer.from(encoded, "base64");
213
+ const resize = await sharp();
214
+ const image = resize(raw, { failOn: "none", limitInputPixels: IMG_MAX_INPUT_PIXELS });
215
+ const metadata = await image.metadata();
216
+ if (sharedSignal.aborted) throw abortError(sharedSignal);
217
+ const orientation = Number(metadata.orientation);
218
+ const rotated = orientation >= 5 && orientation <= 8;
219
+ const sourceWidth = rotated ? metadata.height : metadata.width;
220
+ const sourceHeight = rotated ? metadata.width : metadata.height;
221
+ const target = fitImageDimensions(sourceWidth, sourceHeight, constraints);
222
+ const resizeRequired = target
223
+ && (target.width < sourceWidth || target.height < sourceHeight);
224
+ if (raw.length < IMG_MIN_BYTES && !resizeRequired) return dataUrl;
225
+ const resizeOptions = target
226
+ ? { ...target, fit: "inside", withoutEnlargement: true }
227
+ : {
228
+ width: constraints.maxLongEdge,
229
+ height: constraints.maxLongEdge,
230
+ fit: "inside",
231
+ withoutEnlargement: true,
232
+ };
233
+ const out = await image
234
+ .rotate()
235
+ .resize(resizeOptions)
236
+ .webp({ quality: outputQuality, effort: 4 })
237
+ .toBuffer();
238
+ if (sharedSignal.aborted) throw abortError(sharedSignal);
239
+ if (out.length >= raw.length) return dataUrl;
240
+ const outputBase64 = out.toString("base64");
241
+ const outputDataUrl = `data:image/webp;base64,${outputBase64}`;
242
+ const outputKey = imageOptimizationKey(
243
+ imageDigest(outputBase64, "base64"),
244
+ "image/webp",
245
+ constraints,
246
+ outputQuality,
247
+ );
248
+ // Preserve idempotence when optimized history is submitted again.
249
+ imageResultCache.set(outputKey, outputDataUrl);
250
+ const ratio = ((out.length / raw.length) * 100).toFixed(1);
251
+ console.log(status("info", `image ${(raw.length / 1024).toFixed(0)}KB ${mime} -> ${(out.length / 1024).toFixed(0)}KB webp (${ratio}%)`));
252
+ return outputDataUrl;
253
+ }, { signal: sharedSignal })
254
+ ), { signal });
246
255
  } catch (e) {
247
256
  if (signal?.aborted || e?.name === "AbortError") throw e;
248
257
  if (inputBytes >= IMG_MIN_BYTES) {
@@ -118,7 +118,7 @@ function modelRegistryStatus(modelRegistry) {
118
118
  };
119
119
  }
120
120
 
121
- export function runtimeStatusPayload({ metrics, admission, modelRegistry } = {}) {
121
+ export function runtimeStatusPayload({ metrics, streamPerformance, admission, modelRegistry } = {}) {
122
122
  const memory = process.memoryUsage();
123
123
  return {
124
124
  ...adapterHealthPayload(),
@@ -131,6 +131,7 @@ export function runtimeStatusPayload({ metrics, admission, modelRegistry } = {})
131
131
  array_buffers_bytes: memory.arrayBuffers,
132
132
  },
133
133
  requests: metrics?.snapshot?.() || createRequestMetrics().snapshot(),
134
+ stream_performance: streamPerformance?.snapshot?.() || null,
134
135
  admission: admission?.diagnostics?.() || admission?.stats?.() || null,
135
136
  response_history: responseHistoryStats(),
136
137
  image_optimization: imageOptimizationStats(),
@@ -2,6 +2,15 @@ import { randomUUID } from "node:crypto";
2
2
  import { chatCompletions } from "./copilot.mjs";
3
3
  import { webStreamLines } from "./stream.mjs";
4
4
  import { abortErrorStatusCode, isAbortLikeError } from "./http-transport.mjs";
5
+ import {
6
+ incompleteUpstreamStream,
7
+ invalidUpstreamStream,
8
+ requireUpstreamEventStream,
9
+ ToolArgumentDeltaGuard,
10
+ upstreamChatStreamError,
11
+ withChatStreamUsage,
12
+ } from "./stream-contract.mjs";
13
+ import { isChatOutputDelta, markFirstOutput, markOutputTokens } from "./stream-performance.mjs";
5
14
 
6
15
  function cloneJson(value) {
7
16
  return value === undefined ? undefined : structuredClone(value);
@@ -134,7 +143,7 @@ export function chatToResponses(chatResp, model) {
134
143
 
135
144
  export async function forwardToChat(chatReq, emitEvent, onDone, onError, options = {}) {
136
145
  delete chatReq.max_tokens;
137
- const upstreamReq = chatReq.stream === true ? chatReq : { ...chatReq, stream: true };
146
+ const upstreamReq = withChatStreamUsage(chatReq);
138
147
  const bodyText = upstreamReq === chatReq ? options.bodyText : undefined;
139
148
  let resp;
140
149
  try {
@@ -150,9 +159,16 @@ export async function forwardToChat(chatReq, emitEvent, onDone, onError, options
150
159
  return;
151
160
  }
152
161
  if (!resp.ok) {
153
- await onError(resp.status, await resp.text());
162
+ await onError(resp.status, await resp.text(), undefined, resp);
154
163
  return;
155
164
  }
165
+ try {
166
+ await requireUpstreamEventStream(resp);
167
+ } catch (error) {
168
+ await onError(error.statusCode, error.message, error);
169
+ return;
170
+ }
171
+ options.onUpstreamResponse?.(resp);
156
172
  options.abort?.setTimeout(options.streamIdleTimeoutMs, "stream_idle_timeout");
157
173
  const respId = `resp_${uid()}`;
158
174
  let actualModel = chatReq.model || "unknown";
@@ -161,6 +177,13 @@ export async function forwardToChat(chatReq, emitEvent, onDone, onError, options
161
177
  let nextOutputIndex = 0;
162
178
  let usage = { input_tokens: 0, output_tokens: 0, total_tokens: 0 };
163
179
  const toolCalls = new Map();
180
+ const toolArgumentGuard = new ToolArgumentDeltaGuard();
181
+ let performanceOutputSeen = false;
182
+ const markPerformanceOutput = () => {
183
+ if (performanceOutputSeen) return;
184
+ performanceOutputSeen = true;
185
+ markFirstOutput();
186
+ };
164
187
 
165
188
  await emitEvent("response.created", { response: { id: respId, object: "response", status: "in_progress", model: actualModel, output: [] } });
166
189
 
@@ -258,7 +281,16 @@ export async function forwardToChat(chatReq, emitEvent, onDone, onError, options
258
281
  const data = line.slice(6).trim();
259
282
  if (data === "[DONE]") { await emitCompleted(); onDone(); return; }
260
283
  let parsed;
261
- try { parsed = JSON.parse(data); } catch { continue; }
284
+ try {
285
+ parsed = JSON.parse(data);
286
+ } catch {
287
+ throw invalidUpstreamStream(
288
+ "Upstream Chat Completions SSE event contained invalid JSON",
289
+ "upstream_stream_json_invalid",
290
+ );
291
+ }
292
+ const upstreamError = upstreamChatStreamError(parsed);
293
+ if (upstreamError) throw upstreamError;
262
294
  if (parsed.model) actualModel = parsed.model;
263
295
  if (parsed.usage) {
264
296
  usage = {
@@ -268,9 +300,11 @@ export async function forwardToChat(chatReq, emitEvent, onDone, onError, options
268
300
  };
269
301
  const cached = parsed.usage.prompt_tokens_details?.cached_tokens;
270
302
  if (cached) usage.input_tokens_details = { cached_tokens: cached };
303
+ markOutputTokens(usage.output_tokens);
271
304
  }
272
305
  const delta = parsed.choices?.[0]?.delta;
273
306
  if (!delta) continue;
307
+ if (isChatOutputDelta(delta)) markPerformanceOutput();
274
308
  if (delta.content) {
275
309
  const message = await ensureMessageItem();
276
310
  fullText += delta.content;
@@ -281,22 +315,24 @@ export async function forwardToChat(chatReq, emitEvent, onDone, onError, options
281
315
  const { tool, created } = await ensureToolCall(tc);
282
316
  if (tc.id) tool.callId = tc.id;
283
317
  if (!created && tc.function?.name) tool.name += tc.function.name;
284
- if (tc.function?.arguments) {
285
- tool.arguments += tc.function.arguments;
318
+ const argumentDelta = tc.function?.arguments;
319
+ toolArgumentGuard.observe(tool.outputIndex, argumentDelta);
320
+ if (argumentDelta) {
321
+ tool.arguments += argumentDelta;
286
322
  await emitEvent("response.function_call_arguments.delta", {
287
323
  output_index: tool.outputIndex,
288
324
  item_id: tool.id,
289
- delta: tc.function.arguments,
325
+ delta: argumentDelta,
290
326
  });
291
327
  }
292
328
  }
293
329
  }
294
330
  }
295
331
  } catch (e) {
296
- const statusCode = isAbortLikeError(e) ? abortErrorStatusCode(options.abort?.reason) : 502;
297
- await onError(statusCode, e?.message || "upstream stream error");
332
+ const statusCode = isAbortLikeError(e) ? abortErrorStatusCode(options.abort?.reason) : (e?.statusCode || 502);
333
+ await onError(statusCode, e?.message || "upstream stream error", e);
298
334
  return;
299
335
  }
300
- await emitCompleted();
301
- onDone();
336
+ const error = incompleteUpstreamStream("[DONE]");
337
+ await onError(error.statusCode, error.message, error);
302
338
  }
@@ -5,6 +5,7 @@ import {
5
5
  } from "./responses-byte-budget.mjs";
6
6
  import { responsesToChat } from "./responses-bridge.mjs";
7
7
  import { readResponsesImagePart, readResponsesToolOutputParts } from "./responses-content.mjs";
8
+ import { withChatStreamUsage } from "./stream-contract.mjs";
8
9
 
9
10
  const DEFAULT_MAX_UPSTREAM_BODY_BYTES = 30 * 1024 * 1024;
10
11
 
@@ -30,7 +31,7 @@ function chatRequest(responsesBody, stream) {
30
31
  const chatReq = responsesToChat(responsesBody);
31
32
  chatReq.stream = Boolean(stream);
32
33
  delete chatReq.max_tokens;
33
- return chatReq;
34
+ return stream ? withChatStreamUsage(chatReq) : chatReq;
34
35
  }
35
36
 
36
37
  function adjustedResponsesTarget(limit, responsesBytes, chatBytes) {
@@ -0,0 +1,113 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { httpError } from "./http-transport.mjs";
3
+
4
+ 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
+
9
+ function compactionError(message) {
10
+ const error = httpError(message, 502);
11
+ error.code = "ccdx_invalid_compaction_response";
12
+ error.jsonBody = {
13
+ error: {
14
+ message,
15
+ type: "upstream_error",
16
+ code: error.code,
17
+ },
18
+ };
19
+ return error;
20
+ }
21
+
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
+ export function prepareResponsesCompactionRequest(reqContext) {
64
+ const input = Array.isArray(reqContext?.body?.input) ? reqContext.body.input : [];
65
+ const compactionInputItems = compactionInputWithoutTrigger(input);
66
+ reqContext.body.input = [...compactionInputItems, { type: COMPACTION_TRIGGER_TYPE }];
67
+ reqContext.body.stream = false;
68
+ return reqContext;
69
+ }
70
+
71
+ export function compactionInputWithoutTrigger(input) {
72
+ return Array.isArray(input)
73
+ ? input.filter((item) => item?.type !== COMPACTION_TRIGGER_TYPE)
74
+ : [];
75
+ }
76
+
77
+ export function createResponsesCompactionResult(inputItems, generated) {
78
+ if (!generated || typeof generated !== "object" || !generated.id || !Array.isArray(generated.output)) {
79
+ throw compactionError("Copilot compaction returned an invalid response envelope");
80
+ }
81
+ if (Object.prototype.hasOwnProperty.call(generated, "status") && generated.status !== "completed") {
82
+ throw compactionError(`Copilot compaction returned status ${generated.status}`);
83
+ }
84
+
85
+ const compactionItems = generated.output.filter((item) => item?.type === "compaction");
86
+ if (!compactionItems.length) {
87
+ throw compactionError("Copilot compaction returned no compaction output item");
88
+ }
89
+ if (compactionItems.some((item) => (
90
+ typeof item.encrypted_content !== "string" || item.encrypted_content.length === 0
91
+ ))) {
92
+ throw compactionError("Copilot compaction returned an invalid compaction output item");
93
+ }
94
+
95
+ return {
96
+ ...generated,
97
+ object: "response.compaction",
98
+ output: [
99
+ ...retainedMessages(Array.isArray(inputItems) ? inputItems : []),
100
+ ...compactionItems,
101
+ ],
102
+ };
103
+ }
104
+
105
+ export function parseResponsesCompactionResult(inputItems, text) {
106
+ let generated;
107
+ try {
108
+ generated = JSON.parse(text);
109
+ } catch {
110
+ throw compactionError("Copilot compaction returned invalid JSON");
111
+ }
112
+ return createResponsesCompactionResult(inputItems, generated);
113
+ }
@@ -15,10 +15,12 @@ import {
15
15
  } from "./models.mjs";
16
16
  import { chatToResponses, forwardToChat, responsesBodyUsesCustomTools } from "./responses-bridge.mjs";
17
17
  import { prepareResponsesChatPayload } from "./responses-chat-payload.mjs";
18
+ import { prepareResponsesCompactionRequest } from "./responses-compaction.mjs";
18
19
  import { proxyCopilotResponses } from "./responses-proxy.mjs";
19
20
  import { prepareResponsesRequest, rememberResponseHistory } from "./responses-request.mjs";
20
21
  import { status } from "./status.mjs";
21
22
  import { endStreamWithError } from "./stream-errors.mjs";
23
+ import { safeUpstreamResponseHeaders } from "./upstream-headers.mjs";
22
24
  import { recordResponsesUsage } from "./usage.mjs";
23
25
 
24
26
  const RESPONSES_ONLY_FALLBACK = new Set([
@@ -113,20 +115,29 @@ export function createResponsesHandler(options) {
113
115
  });
114
116
  const { chatReq, bodyText } = chatPayload;
115
117
  if (parsed.stream) {
118
+ let streamResponseHeaders = null;
116
119
  await forwardToChat(chatReq, async (event, data) => {
117
- if (!res.headersSent) res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive" });
120
+ if (!res.headersSent) res.writeHead(200, {
121
+ ...streamResponseHeaders,
122
+ "Cache-Control": "no-cache",
123
+ Connection: "keep-alive",
124
+ });
118
125
  await writeOrDrain(res, `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
119
126
  if (event === "response.completed") {
120
127
  rememberResponseHistory(prepared, data.response);
121
128
  recordResponsesUsage({ surface: prepared.surface, mode: "stream", model, response: data.response, event: data });
122
129
  }
123
- }, () => { if (!res.writableEnded) res.end(); }, async (statusCode, errMsg) => {
130
+ }, () => { if (!res.writableEnded) res.end(); }, async (statusCode, errMsg, error, upstreamResponse) => {
124
131
  if (!res.headersSent) {
125
- res.writeHead(statusCode || 500, { "Content-Type": "text/plain; charset=utf-8" });
126
- res.end(errMsg);
132
+ if (upstreamResponse) {
133
+ sendUpstreamError(res, upstreamResponse, errMsg);
134
+ return;
135
+ }
136
+ const responseError = error || Object.assign(new Error(errMsg), { statusCode });
137
+ sendJsonError(res, responseError, statusCode || 500);
127
138
  return;
128
139
  }
129
- await endStreamWithError(res, "responses", new Error(errMsg), abort);
140
+ await endStreamWithError(res, "responses", error || new Error(errMsg), abort);
130
141
  }, {
131
142
  signal: abort.signal,
132
143
  abort,
@@ -134,6 +145,11 @@ export function createResponsesHandler(options) {
134
145
  chatCompletionsFn,
135
146
  releaseRequest,
136
147
  bodyText,
148
+ onUpstreamResponse: (upstreamResponse) => {
149
+ streamResponseHeaders = safeUpstreamResponseHeaders(upstreamResponse.headers, {
150
+ contentType: "text/event-stream",
151
+ });
152
+ },
137
153
  });
138
154
  } else {
139
155
  try {
@@ -147,7 +163,9 @@ export function createResponsesHandler(options) {
147
163
  const response = chatToResponses(JSON.parse(data), model);
148
164
  rememberResponseHistory(prepared, response);
149
165
  recordResponsesUsage({ surface: prepared.surface, mode: "json", model, response, event: response });
150
- res.writeHead(200, { "Content-Type": "application/json" });
166
+ res.writeHead(200, safeUpstreamResponseHeaders(upstream.headers, {
167
+ contentType: "application/json",
168
+ }));
151
169
  res.end(JSON.stringify(response));
152
170
  } catch (error) {
153
171
  logRequestFailure("Responses", error, abort);
@@ -170,8 +188,6 @@ export function createResponsesCompactHandler(options) {
170
188
  acquireRequest,
171
189
  openAIModelEnv,
172
190
  responsesCompactFn,
173
- streamHandshakeTimeoutMs,
174
- streamIdleTimeoutMs,
175
191
  upstreamTimeoutMs,
176
192
  } = options;
177
193
 
@@ -181,22 +197,20 @@ export function createResponsesCompactHandler(options) {
181
197
  try {
182
198
  releaseRequest = await acquireRequest(req, { signal: abort.signal });
183
199
  const parsed = await readJsonBody(req);
184
- abort.setTimeout(
185
- parsed.stream ? streamHandshakeTimeoutMs : upstreamTimeoutMs,
186
- parsed.stream ? "stream_handshake_timeout" : "upstream_timeout",
200
+ abort.setTimeout(upstreamTimeoutMs, "upstream_timeout");
201
+ const prepared = prepareResponsesCompactionRequest(
202
+ prepareResponsesRequest(parsed, { mutate: true }),
187
203
  );
188
- const prepared = prepareResponsesRequest(parsed, { mutate: true });
189
204
  prepared.surface = "responses_compact";
190
205
  const model = parsed.model || "unknown";
191
206
  const { requestedModel, upstreamModel } = resolveOpenAIModel(model, openAIModelEnv);
192
207
  if (upstreamModel !== requestedModel) prepared.body.model = upstreamModel;
193
208
  const upstreamLog = upstreamModel === requestedModel ? "" : ` upstream_model=${upstreamModel}`;
194
- console.log(status("info", `responses compact model=${requestedModel}${upstreamLog} stream=${!!parsed.stream}`));
209
+ console.log(status("info", `responses compact model=${requestedModel}${upstreamLog} stream=false`));
195
210
  await proxyCopilotResponses(prepared, req, res, responsesCompactFn, {
196
211
  signal: abort.signal,
197
212
  abort,
198
213
  releaseRequest,
199
- streamIdleTimeoutMs,
200
214
  });
201
215
  } catch (error) {
202
216
  logRequestFailure("Responses compact", error, abort);