codex-copilot-dx 0.6.2 → 0.6.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 CHANGED
@@ -29,11 +29,11 @@ npm install -g codex-copilot-dx@latest
29
29
  ccdx
30
30
  ```
31
31
 
32
- The shorter `ccdx` command is the primary launcher. The existing `codex-copilot-dx`
33
- command remains an equivalent compatibility alias. For a one-off run without a
34
- global install, use `npx codex-copilot-dx@latest`.
35
- Both installed commands expose the same options and subcommands; help, version,
36
- doctor, status, and argument-error output use the command name that was invoked.
32
+ `ccdx` is the only documented command and primary launcher. The old
33
+ `codex-copilot-dx` executable remains temporarily as a compatibility shim: it
34
+ prints a deprecation warning and runs `ccdx`, and will be removed in a future
35
+ breaking release. For a one-off run without a global install, use
36
+ `npm exec --yes --package=codex-copilot-dx@latest -- ccdx`.
37
37
  Run `ccdx <command> --help` (including `auth login claude --help` and
38
38
  `pms status --help`) for command-specific behavior and side-effect details.
39
39
 
@@ -49,8 +49,7 @@ starting the adapter, opening device login, reading the local model cache, or
49
49
  consuming an inference request. It lists model IDs by provider with their
50
50
  advertised API capabilities and reports the live Claude/Anthropic count. A model
51
51
  being advertised does not guarantee that a later inference request will avoid
52
- quota, rate-limit, or policy enforcement. `codex-copilot-dx models` is fully
53
- equivalent.
52
+ quota, rate-limit, or policy enforcement.
54
53
 
55
54
  ### Separate GitHub account for Claude
56
55
 
@@ -193,7 +192,7 @@ It saves the selection in `~/.config/codex-copilot-dx/config.json` (or under
193
192
  `XDG_CONFIG_HOME` when set); choosing `gpt-5.5` clears the override and restores
194
193
  the package default. A running 0.5.1+
195
194
  adapter reads the setting on the next Auto-review request, so it does not need to
196
- be restarted. `codex-copilot-dx auto-review-model` is fully equivalent.
195
+ be restarted.
197
196
 
198
197
  To update the globally installed package, choose a source interactively:
199
198
 
@@ -216,8 +215,7 @@ latest commit from `DaleXiao/codex-copilot-dx` `main` and opts in to Git fetchin
216
215
  for that command, as required by npm 12. It does not change npm's persistent
217
216
  `allow-git` setting. Both paths use npm's global installer without shell
218
217
  interpolation. A currently running adapter keeps its loaded version until it is
219
- stopped and started again. The
220
- `codex-copilot-dx update` compatibility command behaves identically.
218
+ stopped and started again.
221
219
  If a configured npm mirror has not synchronized the current release yet, use
222
220
  the GitHub source to install the current `main` revision.
223
221
 
@@ -278,15 +276,13 @@ For a read-only summary of the running adapter, use:
278
276
  ccdx status
279
277
  ```
280
278
 
281
- The compatibility alias supports the same command as
282
- `codex-copilot-dx status`. For the complete machine-readable payload, query the
283
- loopback endpoint directly:
279
+ For the complete machine-readable payload, query the loopback endpoint directly:
284
280
 
285
281
  ```bash
286
282
  curl -s http://127.0.0.1:2026/_ccdx/status | jq
287
283
  ```
288
284
 
289
- The status endpoint is restricted to the socket's real loopback address, even when LAN binding is explicitly enabled. It reports fixed-size request counters, bounded TTFT/TPOT histograms for streaming routes, admission pressure, memory use, response-history size, image queue/cache state, profile model cache counts, PM relay route totals, and token expiry state. It never retains metric samples or includes prompts, completions, tool arguments, image content, account names, or token values. API responses always include a safe `X-Request-Id` for correlation. If the adapter is not running, the CLI prints the exact start-and-retry command. To include the same ID as `request_id` in related terminal and file log lines, start the adapter with:
285
+ The status endpoint is restricted to the socket's real loopback address, even when LAN binding is explicitly enabled. It reports fixed-size request counters, bounded TTFT/TPOT histograms for streaming routes, admission pressure, memory use, response-history size, image queue/cache and adaptive-history state, profile model cache counts, PM relay route totals, and token expiry state. It never retains metric samples or includes prompts, completions, tool arguments, image content, account names, or token values. API responses always include a safe `X-Request-Id` for correlation. If the adapter is not running, the CLI prints the exact start-and-retry command. To include the same ID as `request_id` in related terminal and file log lines, start the adapter with:
290
286
 
291
287
  ```bash
292
288
  ccdx --show-request-id
@@ -306,7 +302,7 @@ ccdx
306
302
  Claude App support is opt-in so the default Codex Desktop and Claude Code setup stays unchanged:
307
303
 
308
304
  ```bash
309
- npx codex-copilot-dx@latest start --configure-claude-app
305
+ npm exec --yes --package=codex-copilot-dx@latest -- ccdx start --configure-claude-app
310
306
  ```
311
307
 
312
308
  The previous `--configure-claude-desktop` spelling remains a compatibility
@@ -316,7 +312,7 @@ key is restored or the profile is configured in that run.
316
312
  Or set:
317
313
 
318
314
  ```bash
319
- CCDX_CONFIGURE_CLAUDE_DESKTOP=1 npx codex-copilot-dx@latest
315
+ CCDX_CONFIGURE_CLAUDE_DESKTOP=1 npm exec --yes --package=codex-copilot-dx@latest -- ccdx
320
316
  ```
321
317
 
322
318
  This writes a local Claude App 3P gateway profile that points to the adapter root URL, such as `http://127.0.0.1:2026`. The profile uses a generated local bearer key unless `CCDX_CLAUDE_DESKTOP_API_KEY` is set. Later starts restore that key only from the active managed profile when its gateway URL matches the adapter. Once that managed profile is active, successful Copilot model refreshes automatically keep its `inferenceModels` list current without changing the gateway key or other profile settings. Restart Claude App after the initial setup. Later model changes require no profile reconfiguration, although a running Claude App may need to be reopened if it has not reloaded the updated profile yet.
@@ -392,7 +388,7 @@ Set `CCDX_LOG_PATH=1` to mirror terminal logs to `~/.local/share/codex-copilot-d
392
388
 
393
389
  ### Image optimization
394
390
 
395
- Long computer-use sessions can accumulate screenshots inside the conversation history. Each screenshot is shipped on later turns, and GitHub Copilot's `/responses` endpoint can reject oversized requests with `408 Request Timeout` while reading the body or `413 Payload Too Large`.
391
+ Long computer-use sessions can accumulate screenshots inside the conversation history. Each screenshot is shipped on later turns, which can increase local preparation time, upstream handshake latency, and the chance of a `413 Payload Too Large` response.
396
392
 
397
393
  The adapter automatically downsamples embedded screenshots to model-appropriate pixel bounds (with a conservative 2048 px long-edge fallback) and initially re-encodes them as WebP quality 82 before forwarding `/v1/responses`. It never replaces an image with a larger encoding and applies one global concurrency limit across direct, function-tool, and custom-tool output images.
398
394
 
@@ -400,6 +396,8 @@ The final serialized UTF-8 request body is measured before forwarding. Above the
400
396
 
401
397
  Completed image transforms are reused from a byte-bounded process-local LRU cache, and concurrent requests for the same transform share one encode. The cache key includes source content, MIME type, model-aware dimensions, WebP quality, and encoder settings; failures are not cached and request cancellation remains isolated.
402
398
 
399
+ Before image encoding, CCDX leaves ordinary visual history byte-for-byte unchanged while it has at most 24 historical images and the expanded body is at most 18 MiB. Above either threshold, the temporary upstream view keeps every current-turn image and at most 16 recent historical images, targeting 16 MiB. If that history tree then times out during local preparation or upstream handshake, the next retry automatically uses an 8-image, 10 MiB recovery window for ten minutes. Two successful requests or a successful compaction clear recovery mode. CCDX never transparently repeats the ambiguous timed-out POST, and no local history is deleted; retrying does not require restarting the service.
400
+
403
401
  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.
404
402
 
405
403
  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.
@@ -1,2 +1,3 @@
1
1
  #!/usr/bin/env node
2
- import "./cli.mjs";
2
+ console.warn("codex-copilot-dx is deprecated; use ccdx instead. The compatibility alias will be removed in a future breaking release.");
3
+ await import("./cli.mjs");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-copilot-dx",
3
- "version": "0.6.2",
3
+ "version": "0.6.3",
4
4
  "description": "Use Codex Desktop, Claude Code, and Claude App with GitHub Copilot",
5
5
  "author": "Dale Xiao",
6
6
  "type": "module",
@@ -177,6 +177,60 @@ function largeTokenCountProbe(mode) {
177
177
  `);
178
178
  }
179
179
 
180
+ function visualHistoryPressureProbe() {
181
+ return runProbe(`
182
+ import { applyResponsesImagePressure } from "./src/responses-image-pressure.mjs";
183
+
184
+ const concurrency = 4;
185
+ const historicalImages = 36;
186
+ const imageBytes = 256 * 1024;
187
+ const dataUrls = Array.from({ length: historicalImages }, (_, index) => (
188
+ \`data:image/png;base64,\${Buffer.alloc(imageBytes, index + 1).toString("base64")}\`
189
+ ));
190
+ const contexts = Array.from({ length: concurrency }, (_, requestIndex) => ({
191
+ historyRootId: \`resp_benchmark_\${requestIndex}\`,
192
+ currentInputStart: historicalImages,
193
+ body: {
194
+ model: "gpt-5.6-sol",
195
+ stream: true,
196
+ input: [
197
+ ...dataUrls.map((imageUrl) => ({
198
+ type: "message",
199
+ role: "user",
200
+ content: [{ type: "input_image", image_url: imageUrl }],
201
+ })),
202
+ { type: "message", role: "user", content: "continue" },
203
+ ],
204
+ },
205
+ }));
206
+ globalThis.gc?.();
207
+ const before = process.memoryUsage();
208
+ let peakRss = before.rss;
209
+ let peakHeap = before.heapUsed;
210
+ const started = performance.now();
211
+ const results = await Promise.all(contexts.map(async (context) => {
212
+ await Promise.resolve();
213
+ const result = applyResponsesImagePressure(context);
214
+ const memory = process.memoryUsage();
215
+ peakRss = Math.max(peakRss, memory.rss);
216
+ peakHeap = Math.max(peakHeap, memory.heapUsed);
217
+ return result;
218
+ }));
219
+ const mib = (bytes) => +(bytes / 1048576).toFixed(1);
220
+ process.stdout.write(JSON.stringify({
221
+ concurrency,
222
+ historical_images_per_request: historicalImages,
223
+ elapsed_ms: +(performance.now() - started).toFixed(1),
224
+ omitted_images: results.map((result) => result.imagesOmitted),
225
+ retained_historical_images: results.map((result) => result.historicalImages),
226
+ input_body_mib: results.map((result) => mib(result.initialBodyBytes)),
227
+ output_body_mib: results.map((result) => mib(result.bodyBytes)),
228
+ rss_peak_delta_mib: mib(peakRss - before.rss),
229
+ heap_peak_delta_mib: mib(peakHeap - before.heapUsed),
230
+ }));
231
+ `);
232
+ }
233
+
180
234
  const adapterImport = runProbe(`
181
235
  const started = performance.now();
182
236
  await import("./src/adapter.mjs");
@@ -324,6 +378,7 @@ if (checkMode) {
324
378
  largePayloadProbe(30, 1),
325
379
  largePayloadProbe(30, 2),
326
380
  ];
381
+ report.visual_history_pressure_36x4 = visualHistoryPressureProbe();
327
382
  } else if (process.argv.includes("--large-payload")) {
328
383
  report.large_payload_peak = [
329
384
  largePayloadProbe(5, 1),
@@ -359,6 +414,12 @@ if (checkMode) {
359
414
  failures.push(`request admission exceeded its budget at ${payload.target_request_mib}MiB x${payload.concurrency}`);
360
415
  }
361
416
  }
417
+ const visualHistory = report.visual_history_pressure_36x4;
418
+ if (!visualHistory.omitted_images.every((count) => count === 20)
419
+ || !visualHistory.retained_historical_images.every((count) => count === 16)
420
+ || !visualHistory.output_body_mib.every((size, index) => size < visualHistory.input_body_mib[index])) {
421
+ failures.push("36-image x4 visual history pressure did not preserve the expected 16-image upstream window");
422
+ }
362
423
  if (failures.length) {
363
424
  for (const failure of failures) console.error(`[FAIL] ${failure}`);
364
425
  process.exitCode = 1;
package/src/adapter.mjs CHANGED
@@ -17,6 +17,7 @@ import { status } from "./status.mjs";
17
17
  import { createTerminalActivityIndicator } from "./terminal-activity.mjs";
18
18
  import { ADAPTER_HEALTH_PATH, adapterHealthPayload } from "./running-adapter.mjs";
19
19
  import { createResponsesCompactHandler, createResponsesHandler } from "./responses-handler.mjs";
20
+ import { createResponsesImagePressureController } from "./responses-image-pressure.mjs";
20
21
  import { loadRuntimeConfig, parsePositiveInteger } from "./runtime-config.mjs";
21
22
  import {
22
23
  createRequestAdmission,
@@ -138,6 +139,7 @@ export function createAdapterHandler(options = {}) {
138
139
  const acquireRequest = options.acquireRequest || createRequestAdmission();
139
140
  const requestMetrics = options.requestMetrics || createRequestMetrics();
140
141
  const streamPerformanceMetrics = options.streamPerformanceMetrics || createStreamPerformanceMetrics();
142
+ const imagePressure = options.imagePressure || createResponsesImagePressureController();
141
143
  const claudeDesktopModelOptions = () => {
142
144
  const modelDefs = claudeModelRegistry?.modelDefs || options.claudeDesktopModelDefs;
143
145
  return Array.isArray(modelDefs) ? { modelDefs } : {};
@@ -147,6 +149,7 @@ export function createAdapterHandler(options = {}) {
147
149
  autoReviewModelResolver: options.autoReviewModelResolver,
148
150
  chatCompletionsFn: codexChatCompletionsFn,
149
151
  getCachedModelEndpointsFn,
152
+ imagePressure,
150
153
  openAIModelEnv,
151
154
  responsesPayloadOptions: options.responsesPayloadOptions,
152
155
  responsesFn,
@@ -157,6 +160,7 @@ export function createAdapterHandler(options = {}) {
157
160
  const responsesCompactHandler = createResponsesCompactHandler({
158
161
  acquireRequest,
159
162
  autoReviewModelResolver: options.autoReviewModelResolver,
163
+ imagePressure,
160
164
  openAIModelEnv,
161
165
  responsesCompactFn,
162
166
  streamHandshakeTimeoutMs,
@@ -197,6 +201,7 @@ export function createAdapterHandler(options = {}) {
197
201
  metrics: requestMetrics,
198
202
  streamPerformance: streamPerformanceMetrics,
199
203
  admission: acquireRequest,
204
+ imagePressure,
200
205
  modelRegistry: codexModelRegistry,
201
206
  codexClient,
202
207
  claudeClient,
@@ -5,7 +5,6 @@ const START_OPTIONS = new Map([
5
5
  ["--configure-claude-app", "configure-claude-app"],
6
6
  ["--show-request-id", "show-request-id"],
7
7
  ]);
8
- const CLI_COMMANDS = new Set(["ccdx", "codex-copilot-dx"]);
9
8
  const PM_COMMANDS = new Set(["pms", "pm-studio"]);
10
9
  const HELP_TOPICS = new Set([
11
10
  "",
@@ -253,10 +252,8 @@ export function parseAdapterProbeOptions(env = process.env) {
253
252
  };
254
253
  }
255
254
 
256
- export function cliCommandName(executable = process.argv[1]) {
257
- const name = String(executable || "").split(/[\\/]/).pop();
258
- if (name === "codex-copilot-dx.mjs") return "codex-copilot-dx";
259
- return CLI_COMMANDS.has(name) ? name : "ccdx";
255
+ export function cliCommandName() {
256
+ return "ccdx";
260
257
  }
261
258
 
262
259
  function topicHelp(name, topic) {
@@ -280,10 +277,9 @@ function topicHelp(name, topic) {
280
277
  }
281
278
 
282
279
  export function cliHelp(commandName = "ccdx", topic = "") {
283
- const name = CLI_COMMANDS.has(commandName) ? commandName : "ccdx";
284
- const alias = name === "ccdx" ? "codex-copilot-dx" : "ccdx";
280
+ const name = "ccdx";
285
281
  const normalizedTopic = checkedHelpTopic(topic ? topic.split(" ") : []);
286
- if (normalizedTopic) return `${topicHelp(name, normalizedTopic)}\n\nEquivalent command: ${alias}`;
282
+ if (normalizedTopic) return topicHelp(name, normalizedTopic);
287
283
  return `Usage:
288
284
  ${name} [start] [--configure-claude-app] [--show-request-id]
289
285
  ${name} auth status [--online]
@@ -310,7 +306,5 @@ Commands:
310
306
  auto-review-model Select the Codex Auto-review Responses model
311
307
  update Update the global package from npm or GitHub
312
308
 
313
- Run ${name} <command> --help for command details.
314
-
315
- Equivalent command: ${alias}`;
309
+ Run ${name} <command> --help for command details.`;
316
310
  }
@@ -149,6 +149,7 @@ export function formatAdapterStatus({ baseUrl, data }, { commandName = "ccdx", c
149
149
  const admission = data.admission || {};
150
150
  const history = data.response_history || {};
151
151
  const images = data.image_optimization || {};
152
+ const imageHistory = data.image_history_pressure;
152
153
  const models = data.models || {};
153
154
  const copilot = data.copilot || {};
154
155
  const limits = data.limits || {};
@@ -166,8 +167,11 @@ export function formatAdapterStatus({ baseUrl, data }, { commandName = "ccdx", c
166
167
  status("info", `Memory: RSS ${mebibytes(processStats.rss_bytes)}, heap ${mebibytes(processStats.heap_used_bytes)}`),
167
168
  status("info", `History: ${mebibytes(history.bytes)} / ${mebibytes(limits.response_history_max_bytes)}, ${count(history.entries)} entries, ${count(history.evicted)} evicted`),
168
169
  status("info", `Image cache: ${count(images.cache_entries)} entries, ${cacheHitRate(images)} hits, ${mebibytes(images.cache_bytes)} / ${mebibytes(images.cache_max_bytes)}`),
169
- status("info", `Models: ${count(models.models)} total, ${count(models.claude_models)} Claude; Copilot token ${copilot.token_cached ? `cached (${duration(copilot.token_expires_in_ms)} remaining)` : "not cached"}`),
170
170
  ];
171
+ if (imageHistory && typeof imageHistory === "object") {
172
+ lines.push(status("info", `Visual history: ${count(imageHistory.active_recovery_trees)} recovery trees, ${count(imageHistory.adapted_requests)} adapted requests, ${count(imageHistory.historical_images_omitted)} older images omitted, ${count(imageHistory.timeouts_recorded)} timeouts`));
173
+ }
174
+ lines.push(status("info", `Models: ${count(models.models)} total, ${count(models.claude_models)} Claude; Copilot token ${copilot.token_cached ? `cached (${duration(copilot.token_expires_in_ms)} remaining)` : "not cached"}`));
171
175
  if (data.profiles?.codex || data.profiles?.claude) {
172
176
  lines.push(status("info", `Profiles: ${profileSummary("Codex", data.profiles?.codex)}; ${profileSummary("Claude", data.profiles?.claude)}`));
173
177
  }
@@ -85,6 +85,7 @@ export function createCopilotClientRuntime({
85
85
  fetchImpl,
86
86
  retryOptions,
87
87
  bodyText,
88
+ onUpstreamStart,
88
89
  } = {}) {
89
90
  const token = await tokenSession.getToken({ signal });
90
91
  const upstreamReq = chatReq.stream === true ? withChatStreamUsage(chatReq) : chatReq;
@@ -99,6 +100,7 @@ export function createCopilotClientRuntime({
99
100
  vision: computeVision(messages),
100
101
  });
101
102
  headers["Content-Length"] = String(Buffer.byteLength(serializedBody));
103
+ onUpstreamStart?.();
102
104
  if (upstreamReq.stream === true) markUpstreamStarted();
103
105
  return fetchCopilotUpstream(`${tokenSession.getApiBase()}/chat/completions`, {
104
106
  method: "POST",
@@ -152,6 +154,7 @@ export function createCopilotClientRuntime({
152
154
  fetchImpl,
153
155
  retryOptions,
154
156
  currentInputStart = 0,
157
+ onUpstreamStart,
155
158
  payloadPrepared = false,
156
159
  payloadOptions = {},
157
160
  } = {}) {
@@ -175,6 +178,7 @@ export function createCopilotClientRuntime({
175
178
  headers["Content-Length"] = String(bodyBytes);
176
179
  headers.Accept = reqBody.stream ? "text/event-stream" : "application/json";
177
180
  try {
181
+ onUpstreamStart?.();
178
182
  if (reqBody.stream === true) markUpstreamStarted();
179
183
  return await fetchCopilotUpstream(`${tokenSession.getApiBase()}${responsesEndpointPath()}`, {
180
184
  method: "POST",
@@ -183,6 +187,7 @@ export function createCopilotClientRuntime({
183
187
  signal,
184
188
  }, { fetchImpl, ...retryOptions });
185
189
  } catch (error) {
190
+ if (signal?.aborted) throw abortError(signal);
186
191
  const cause = error?.cause;
187
192
  const causeText = cause ? ` (${[cause.code, cause.message].filter(Boolean).join(": ")})` : "";
188
193
  throw new Error(`Copilot responses fetch failed: ${error.message}${causeText}`);
@@ -327,7 +327,7 @@ export function isAbortLikeError(err) {
327
327
  }
328
328
 
329
329
  export function abortErrorStatusCode(reason) {
330
- if (["upstream_timeout", "stream_handshake_timeout", "stream_idle_timeout"].includes(reason)) return 504;
330
+ if (["responses_prepare_timeout", "upstream_timeout", "stream_handshake_timeout", "stream_idle_timeout"].includes(reason)) return 504;
331
331
  if (reason === "client_aborted" || reason === "client_closed") return 499;
332
332
  return 502;
333
333
  }
@@ -154,6 +154,7 @@ export function runtimeStatusPayload({
154
154
  metrics,
155
155
  streamPerformance,
156
156
  admission,
157
+ imagePressure,
157
158
  modelRegistry,
158
159
  codexClient,
159
160
  claudeClient,
@@ -186,6 +187,7 @@ export function runtimeStatusPayload({
186
187
  admission: admission?.diagnostics?.() || admission?.stats?.() || null,
187
188
  response_history: responseHistoryStats(),
188
189
  image_optimization: imageOptimizationStats(),
190
+ image_history_pressure: imagePressure?.snapshot?.() || null,
189
191
  copilot: codexRuntime,
190
192
  models: codexModels,
191
193
  profiles: {
@@ -174,6 +174,10 @@ export function responseHistoryStats() {
174
174
  return { entries: histories.size, bytes: totalBytes, evicted: evictedIds.size };
175
175
  }
176
176
 
177
+ export function responseHistoryRootId(responseId) {
178
+ return histories.get(responseId)?.rootId || null;
179
+ }
180
+
177
181
  export function materializeResponseHistory(responseId) {
178
182
  const chain = [];
179
183
  const seen = new Set();
@@ -149,24 +149,29 @@ export async function forwardToChat(chatReq, emitEvent, onDone, onError, options
149
149
  try {
150
150
  const chatCompletionsFn = options.chatCompletionsFn || chatCompletions;
151
151
  try {
152
- resp = await chatCompletionsFn(upstreamReq, { signal: options.signal, bodyText });
152
+ options.onUpstreamStart?.();
153
+ resp = await chatCompletionsFn(upstreamReq, {
154
+ signal: options.signal,
155
+ bodyText,
156
+ onUpstreamStart: options.onUpstreamStart,
157
+ });
153
158
  } finally {
154
159
  options.releaseRequest?.();
155
160
  }
156
161
  } catch (e) {
157
162
  const statusCode = isAbortLikeError(e) ? abortErrorStatusCode(options.abort?.reason) : 502;
158
163
  await onError(statusCode, e.message);
159
- return;
164
+ return false;
160
165
  }
161
166
  if (!resp.ok) {
162
167
  await onError(resp.status, await resp.text(), undefined, resp);
163
- return;
168
+ return false;
164
169
  }
165
170
  try {
166
171
  await requireUpstreamEventStream(resp);
167
172
  } catch (error) {
168
173
  await onError(error.statusCode, error.message, error);
169
- return;
174
+ return false;
170
175
  }
171
176
  options.onUpstreamResponse?.(resp);
172
177
  options.abort?.setTimeout(options.streamIdleTimeoutMs, "stream_idle_timeout");
@@ -279,7 +284,7 @@ export async function forwardToChat(chatReq, emitEvent, onDone, onError, options
279
284
  })) {
280
285
  if (!line.startsWith("data: ")) continue;
281
286
  const data = line.slice(6).trim();
282
- if (data === "[DONE]") { await emitCompleted(); onDone(); return; }
287
+ if (data === "[DONE]") { await emitCompleted(); onDone(); return true; }
283
288
  let parsed;
284
289
  try {
285
290
  parsed = JSON.parse(data);
@@ -331,8 +336,9 @@ export async function forwardToChat(chatReq, emitEvent, onDone, onError, options
331
336
  } catch (e) {
332
337
  const statusCode = isAbortLikeError(e) ? abortErrorStatusCode(options.abort?.reason) : (e?.statusCode || 502);
333
338
  await onError(statusCode, e?.message || "upstream stream error", e);
334
- return;
339
+ return false;
335
340
  }
336
341
  const error = incompleteUpstreamStream("[DONE]");
337
342
  await onError(error.statusCode, error.message, error);
343
+ return false;
338
344
  }
@@ -159,12 +159,14 @@ function recordBytesAfterDrop(record, state, partIndex) {
159
159
  return state.baseBytes + remainingElementBytes + remainingCount - 1;
160
160
  }
161
161
 
162
- function selectImageDrops(refs, bodyBytes, targetBytes) {
162
+ function selectImageDrops(refs, bodyBytes, targetBytes, maxHistoricalImages = Number.POSITIVE_INFINITY) {
163
163
  const candidates = orderedHistoricalImageCandidates(refs);
164
164
  const selected = new Set();
165
165
  const byRecord = new Map();
166
166
  let projectedBytes = bodyBytes;
167
+ let historicalImages = refs.reduce((count, ref) => count + (ref.historical ? 1 : 0), 0);
167
168
  for (const index of candidates) {
169
+ if (projectedBytes <= targetBytes && historicalImages <= maxHistoricalImages) break;
168
170
  const ref = refs[index];
169
171
  let state = byRecord.get(ref.record);
170
172
  if (!state) {
@@ -173,7 +175,7 @@ function selectImageDrops(refs, bodyBytes, targetBytes) {
173
175
  }
174
176
  const afterBytes = recordBytesAfterDrop(ref.record, state, ref.partIndex);
175
177
  const savings = state.currentBytes - afterBytes;
176
- if (savings <= 0) continue;
178
+ if (savings <= 0 && historicalImages <= maxHistoricalImages) continue;
177
179
  state.currentBytes = afterBytes;
178
180
  if (ref.record.kind !== "top-level") {
179
181
  state.remainingCount -= 1;
@@ -181,9 +183,9 @@ function selectImageDrops(refs, bodyBytes, targetBytes) {
181
183
  }
182
184
  selected.add(index);
183
185
  projectedBytes -= savings;
184
- if (projectedBytes <= targetBytes) break;
186
+ historicalImages -= 1;
185
187
  }
186
- return selected;
188
+ return { selected, historicalImages, projectedBytes };
187
189
  }
188
190
 
189
191
  function applyImageDrops(records, refs, selected) {
@@ -253,7 +255,7 @@ export function trimResponsesHistoryToByteBudget(reqBody, {
253
255
  while (bodyBytes > limit) {
254
256
  const { records, refs } = collectResponseImages(inputItems, currentInputStart);
255
257
  if (!refs.length) break;
256
- const selected = selectImageDrops(refs, bodyBytes, limit);
258
+ const { selected } = selectImageDrops(refs, bodyBytes, limit);
257
259
  if (!selected.size) break;
258
260
  applyImageDrops(records, refs, selected);
259
261
  imagesOmitted += selected.size;
@@ -278,6 +280,62 @@ export function trimResponsesHistoryToByteBudget(reqBody, {
278
280
  };
279
281
  }
280
282
 
283
+ export function responsesHistoricalImageStats(inputItems, currentInputStart = 0) {
284
+ const items = Array.isArray(inputItems) ? inputItems : [];
285
+ const { refs } = collectResponseImages(items, currentInputStart);
286
+ const historicalImages = refs.reduce((count, ref) => count + (ref.historical ? 1 : 0), 0);
287
+ return {
288
+ totalImages: refs.length,
289
+ historicalImages,
290
+ currentImages: refs.length - historicalImages,
291
+ };
292
+ }
293
+
294
+ export function trimResponsesHistoricalImages(reqBody, {
295
+ currentInputStart = 0,
296
+ maxHistoricalImages = Number.POSITIVE_INFINITY,
297
+ targetBytes = Number.POSITIVE_INFINITY,
298
+ initialBodyText,
299
+ initialBodyBytes,
300
+ } = {}) {
301
+ let bodyText = initialBodyText ?? JSON.stringify(reqBody);
302
+ let bodyBytes = initialBodyBytes ?? Buffer.byteLength(bodyText);
303
+ const byteLimit = Number.isFinite(targetBytes) && targetBytes > 0
304
+ ? Math.floor(targetBytes)
305
+ : Number.POSITIVE_INFINITY;
306
+ const imageLimit = Number.isFinite(maxHistoricalImages) && maxHistoricalImages >= 0
307
+ ? Math.floor(maxHistoricalImages)
308
+ : Number.POSITIVE_INFINITY;
309
+ const inputItems = Array.isArray(reqBody?.input) ? reqBody.input : [];
310
+ let imagesOmitted = 0;
311
+
312
+ while (true) {
313
+ const { records, refs } = collectResponseImages(inputItems, currentInputStart);
314
+ const historicalImages = refs.reduce((count, ref) => count + (ref.historical ? 1 : 0), 0);
315
+ if (bodyBytes <= byteLimit && historicalImages <= imageLimit) break;
316
+ if (!historicalImages) break;
317
+ const { selected } = selectImageDrops(refs, bodyBytes, byteLimit, imageLimit);
318
+ if (!selected.size) break;
319
+ applyImageDrops(records, refs, selected);
320
+ imagesOmitted += selected.size;
321
+ ({ bodyText, bodyBytes } = bodyPayload(reqBody));
322
+ }
323
+
324
+ const stats = responsesHistoricalImageStats(inputItems, currentInputStart);
325
+ return {
326
+ bodyText,
327
+ bodyBytes,
328
+ targetBytes: byteLimit,
329
+ maxHistoricalImages: imageLimit,
330
+ historicalImages: stats.historicalImages,
331
+ currentImages: stats.currentImages,
332
+ imagesOmitted,
333
+ overBudget: bodyBytes > byteLimit,
334
+ overImageBudget: stats.historicalImages > imageLimit,
335
+ adapted: imagesOmitted > 0,
336
+ };
337
+ }
338
+
281
339
  export function enforceResponsesPayloadByteBudget(reqBody, payload) {
282
340
  let finalized = payload;
283
341
  if (payload?.overBudget) {
@@ -79,6 +79,7 @@ export function createResponsesHandler(options) {
79
79
  autoReviewModelResolver,
80
80
  chatCompletionsFn,
81
81
  getCachedModelEndpointsFn = getCachedModelEndpoints,
82
+ imagePressure,
82
83
  openAIModelEnv,
83
84
  responsesPayloadOptions,
84
85
  responsesFn,
@@ -90,14 +91,42 @@ export function createResponsesHandler(options) {
90
91
  return async function handleResponses(req, res) {
91
92
  const abort = createRequestAbort(req, res);
92
93
  let releaseRequest = () => {};
94
+ let prepared = null;
95
+ let imagePressureResult = null;
96
+ let parsed = null;
97
+ const startUpstreamTimeout = () => abort.setTimeout(
98
+ parsed?.stream ? streamHandshakeTimeoutMs : upstreamTimeoutMs,
99
+ parsed?.stream ? "stream_handshake_timeout" : "upstream_timeout",
100
+ );
101
+ const applyAdaptiveTimeout = (error) => {
102
+ if (!["responses_prepare_timeout", "stream_handshake_timeout", "upstream_timeout"].includes(abort.reason)) {
103
+ return error;
104
+ }
105
+ const activated = imagePressure?.markTimeout?.(prepared?.historyRootId, {
106
+ eligible: imagePressureResult?.pressureEligible === true,
107
+ });
108
+ if (!activated) return error;
109
+ const message = "Responses timed out while processing a large visual history. Retry the request; ccdx will keep fewer earlier images automatically. Restart is not required.";
110
+ error.statusCode = 504;
111
+ error.jsonBody = {
112
+ error: {
113
+ message,
114
+ type: "timeout_error",
115
+ code: "ccdx_visual_history_timeout",
116
+ retryable: true,
117
+ },
118
+ };
119
+ return error;
120
+ };
93
121
  try {
94
122
  releaseRequest = await acquireRequest(req, { signal: abort.signal });
95
- const parsed = await readJsonBody(req);
96
- abort.setTimeout(
97
- parsed.stream ? streamHandshakeTimeoutMs : upstreamTimeoutMs,
98
- parsed.stream ? "stream_handshake_timeout" : "upstream_timeout",
99
- );
100
- const prepared = prepareResponsesRequest(parsed, { mutate: true });
123
+ parsed = await readJsonBody(req);
124
+ abort.setTimeout(upstreamTimeoutMs, "responses_prepare_timeout");
125
+ prepared = prepareResponsesRequest(parsed, { mutate: true });
126
+ imagePressureResult = imagePressure?.apply?.(prepared) || null;
127
+ if (imagePressureResult?.adapted) {
128
+ console.warn(status("warn", `responses visual history mode=${imagePressureResult.mode} historical_images=${imagePressureResult.initialHistoricalImages}->${imagePressureResult.historicalImages} omitted=${imagePressureResult.imagesOmitted} bytes=${imagePressureResult.initialBodyBytes}->${imagePressureResult.bodyBytes}`));
129
+ }
101
130
  prepared.surface = "responses";
102
131
  const model = parsed.model || "unknown";
103
132
  const { requestedModel, upstreamModel } = resolveRequestModel(model, openAIModelEnv, autoReviewModelResolver);
@@ -110,12 +139,14 @@ export function createResponsesHandler(options) {
110
139
  || (usesCustomTools && cachedModelSupportsResponses(upstreamModel, getCachedModelEndpointsFn));
111
140
  if (usesCustomTools && !useNativeResponses) throw unsupportedCustomToolsError(upstreamModel);
112
141
  if (useNativeResponses) {
113
- await proxyCopilotResponses(prepared, req, res, responsesFn, {
142
+ const result = await proxyCopilotResponses(prepared, req, res, responsesFn, {
114
143
  signal: abort.signal,
115
144
  abort,
145
+ onUpstreamStart: startUpstreamTimeout,
116
146
  releaseRequest,
117
147
  streamIdleTimeoutMs,
118
148
  });
149
+ if (result?.successful) imagePressure?.markSuccess?.(prepared.historyRootId);
119
150
  } else {
120
151
  const chatPayload = await prepareResponsesChatPayload(prepared, {
121
152
  payloadOptions: responsesPayloadOptions,
@@ -125,7 +156,7 @@ export function createResponsesHandler(options) {
125
156
  const { chatReq, bodyText } = chatPayload;
126
157
  if (parsed.stream) {
127
158
  let streamResponseHeaders = null;
128
- await forwardToChat(chatReq, async (event, data) => {
159
+ const successful = await forwardToChat(chatReq, async (event, data) => {
129
160
  if (!res.headersSent) res.writeHead(200, {
130
161
  ...streamResponseHeaders,
131
162
  "Cache-Control": "no-cache",
@@ -142,7 +173,7 @@ export function createResponsesHandler(options) {
142
173
  sendUpstreamError(res, upstreamResponse, errMsg);
143
174
  return;
144
175
  }
145
- const responseError = error || Object.assign(new Error(errMsg), { statusCode });
176
+ const responseError = applyAdaptiveTimeout(error || Object.assign(new Error(errMsg), { statusCode }));
146
177
  sendJsonError(res, responseError, statusCode || 500);
147
178
  return;
148
179
  }
@@ -150,6 +181,7 @@ export function createResponsesHandler(options) {
150
181
  }, {
151
182
  signal: abort.signal,
152
183
  abort,
184
+ onUpstreamStart: startUpstreamTimeout,
153
185
  streamIdleTimeoutMs,
154
186
  chatCompletionsFn,
155
187
  releaseRequest,
@@ -160,9 +192,15 @@ export function createResponsesHandler(options) {
160
192
  });
161
193
  },
162
194
  });
195
+ if (successful) imagePressure?.markSuccess?.(prepared.historyRootId);
163
196
  } else {
164
197
  try {
165
- const upstream = await chatCompletionsFn(chatReq, { signal: abort.signal, bodyText });
198
+ startUpstreamTimeout();
199
+ const upstream = await chatCompletionsFn(chatReq, {
200
+ signal: abort.signal,
201
+ bodyText,
202
+ onUpstreamStart: startUpstreamTimeout,
203
+ });
166
204
  releaseRequest();
167
205
  const data = await upstream.text();
168
206
  if (!upstream.ok) {
@@ -171,20 +209,23 @@ export function createResponsesHandler(options) {
171
209
  }
172
210
  const response = chatToResponses(JSON.parse(data), model);
173
211
  rememberResponseHistory(prepared, response);
212
+ imagePressure?.markSuccess?.(prepared.historyRootId);
174
213
  recordResponsesUsage({ surface: prepared.surface, mode: "json", model, response, event: response });
175
214
  res.writeHead(200, safeUpstreamResponseHeaders(upstream.headers, {
176
215
  contentType: "application/json",
177
216
  }));
178
217
  res.end(JSON.stringify(response));
179
218
  } catch (error) {
180
- logRequestFailure("Responses", error, abort);
181
- sendJsonError(res, error, 502);
219
+ const responseError = applyAdaptiveTimeout(error);
220
+ logRequestFailure("Responses", responseError, abort);
221
+ sendJsonError(res, responseError, 502);
182
222
  }
183
223
  }
184
224
  }
185
225
  } catch (error) {
186
- logRequestFailure("Responses", error, abort);
187
- sendJsonError(res, error, 502);
226
+ const responseError = applyAdaptiveTimeout(error);
227
+ logRequestFailure("Responses", responseError, abort);
228
+ sendJsonError(res, responseError, 502);
188
229
  } finally {
189
230
  releaseRequest();
190
231
  abort.cleanup();
@@ -196,6 +237,7 @@ export function createResponsesCompactHandler(options) {
196
237
  const {
197
238
  acquireRequest,
198
239
  autoReviewModelResolver,
240
+ imagePressure,
199
241
  openAIModelEnv,
200
242
  responsesCompactFn,
201
243
  upstreamTimeoutMs,
@@ -207,7 +249,7 @@ export function createResponsesCompactHandler(options) {
207
249
  try {
208
250
  releaseRequest = await acquireRequest(req, { signal: abort.signal });
209
251
  const parsed = await readJsonBody(req);
210
- abort.setTimeout(upstreamTimeoutMs, "upstream_timeout");
252
+ abort.setTimeout(upstreamTimeoutMs, "responses_prepare_timeout");
211
253
  const prepared = prepareResponsesCompactionRequest(
212
254
  prepareResponsesRequest(parsed, { mutate: true }),
213
255
  );
@@ -217,11 +259,13 @@ export function createResponsesCompactHandler(options) {
217
259
  if (upstreamModel !== requestedModel) prepared.body.model = upstreamModel;
218
260
  const upstreamLog = upstreamModel === requestedModel ? "" : ` upstream_model=${upstreamModel}`;
219
261
  console.log(status("info", `responses compact model=${requestedModel}${upstreamLog} stream=false`));
220
- await proxyCopilotResponses(prepared, req, res, responsesCompactFn, {
262
+ const result = await proxyCopilotResponses(prepared, req, res, responsesCompactFn, {
221
263
  signal: abort.signal,
222
264
  abort,
265
+ onUpstreamStart: () => abort.setTimeout(upstreamTimeoutMs, "upstream_timeout"),
223
266
  releaseRequest,
224
267
  });
268
+ if (result?.compacted) imagePressure?.clear?.(prepared.historyRootId);
225
269
  } catch (error) {
226
270
  logRequestFailure("Responses compact", error, abort);
227
271
  sendJsonError(res, error, 502);
@@ -0,0 +1,163 @@
1
+ import {
2
+ responsesHistoricalImageStats,
3
+ trimResponsesHistoricalImages,
4
+ } from "./responses-byte-budget.mjs";
5
+
6
+ const MIB = 1024 * 1024;
7
+
8
+ export const DEFAULT_RESPONSES_IMAGE_PRESSURE_POLICY = Object.freeze({
9
+ triggerHistoricalImages: 24,
10
+ triggerBodyBytes: 18 * MIB,
11
+ pressureHistoricalImages: 16,
12
+ pressureBodyBytes: 16 * MIB,
13
+ recoveryHistoricalImages: 8,
14
+ recoveryBodyBytes: 10 * MIB,
15
+ });
16
+
17
+ function normalizedPolicy(policy = {}) {
18
+ return { ...DEFAULT_RESPONSES_IMAGE_PRESSURE_POLICY, ...policy };
19
+ }
20
+
21
+ function bodyPayload(body) {
22
+ const bodyText = JSON.stringify(body);
23
+ return { bodyText, bodyBytes: Buffer.byteLength(bodyText) };
24
+ }
25
+
26
+ export function applyResponsesImagePressure(reqContext, {
27
+ mode = "normal",
28
+ policy: policyOverrides,
29
+ } = {}) {
30
+ const policy = normalizedPolicy(policyOverrides);
31
+ const body = reqContext?.body;
32
+ const currentInputStart = Number.isFinite(reqContext?.currentInputStart)
33
+ ? reqContext.currentInputStart
34
+ : 0;
35
+ const initial = bodyPayload(body);
36
+ const initialImages = responsesHistoricalImageStats(body?.input, currentInputStart);
37
+ const pressureEligible = initialImages.historicalImages > 0 && (
38
+ initialImages.historicalImages > policy.triggerHistoricalImages
39
+ || initial.bodyBytes > policy.triggerBodyBytes
40
+ );
41
+ const recovery = mode === "recovery";
42
+ const maxHistoricalImages = recovery
43
+ ? policy.recoveryHistoricalImages
44
+ : policy.pressureHistoricalImages;
45
+ const targetBytes = recovery ? policy.recoveryBodyBytes : policy.pressureBodyBytes;
46
+ const shouldAdapt = initialImages.historicalImages > 0 && (recovery
47
+ ? initialImages.historicalImages > maxHistoricalImages || initial.bodyBytes > targetBytes
48
+ : pressureEligible);
49
+
50
+ if (!shouldAdapt) {
51
+ return {
52
+ mode: "normal",
53
+ pressureEligible,
54
+ adapted: false,
55
+ imagesOmitted: 0,
56
+ initialBodyBytes: initial.bodyBytes,
57
+ bodyBytes: initial.bodyBytes,
58
+ initialHistoricalImages: initialImages.historicalImages,
59
+ historicalImages: initialImages.historicalImages,
60
+ currentImages: initialImages.currentImages,
61
+ overBudget: false,
62
+ overImageBudget: false,
63
+ };
64
+ }
65
+
66
+ const trimmed = trimResponsesHistoricalImages(body, {
67
+ currentInputStart,
68
+ maxHistoricalImages,
69
+ targetBytes,
70
+ initialBodyText: initial.bodyText,
71
+ initialBodyBytes: initial.bodyBytes,
72
+ });
73
+ return {
74
+ mode: recovery ? "recovery" : "pressure",
75
+ pressureEligible: pressureEligible || recovery,
76
+ initialBodyBytes: initial.bodyBytes,
77
+ initialHistoricalImages: initialImages.historicalImages,
78
+ ...trimmed,
79
+ };
80
+ }
81
+
82
+ export function createResponsesImagePressureController({
83
+ now = Date.now,
84
+ maxEntries = 256,
85
+ recoveryTtlMs = 10 * 60 * 1000,
86
+ successesToClear = 2,
87
+ policy: policyOverrides,
88
+ } = {}) {
89
+ const policy = normalizedPolicy(policyOverrides);
90
+ const recovery = new Map();
91
+ const counters = {
92
+ adaptedRequests: 0,
93
+ imagesOmitted: 0,
94
+ recoveryActivations: 0,
95
+ timeoutsRecorded: 0,
96
+ };
97
+
98
+ const prune = () => {
99
+ const timestamp = now();
100
+ for (const [rootId, state] of recovery) {
101
+ if (state.expiresAt <= timestamp) recovery.delete(rootId);
102
+ }
103
+ while (recovery.size > maxEntries) recovery.delete(recovery.keys().next().value);
104
+ };
105
+
106
+ const modeFor = (rootId) => {
107
+ prune();
108
+ return rootId && recovery.has(rootId) ? "recovery" : "normal";
109
+ };
110
+
111
+ return Object.freeze({
112
+ apply(reqContext) {
113
+ const result = applyResponsesImagePressure(reqContext, {
114
+ mode: modeFor(reqContext?.historyRootId),
115
+ policy,
116
+ });
117
+ if (result.adapted) {
118
+ counters.adaptedRequests += 1;
119
+ counters.imagesOmitted += result.imagesOmitted;
120
+ }
121
+ return result;
122
+ },
123
+ markTimeout(rootId, { eligible = false } = {}) {
124
+ if (!rootId || !eligible) return false;
125
+ prune();
126
+ const existing = recovery.get(rootId);
127
+ recovery.delete(rootId);
128
+ recovery.set(rootId, { expiresAt: now() + recoveryTtlMs, successes: 0 });
129
+ counters.timeoutsRecorded += 1;
130
+ if (!existing) counters.recoveryActivations += 1;
131
+ prune();
132
+ return true;
133
+ },
134
+ markSuccess(rootId) {
135
+ if (!rootId) return false;
136
+ prune();
137
+ const state = recovery.get(rootId);
138
+ if (!state) return false;
139
+ state.successes += 1;
140
+ if (state.successes >= successesToClear) {
141
+ recovery.delete(rootId);
142
+ return true;
143
+ }
144
+ return false;
145
+ },
146
+ clear(rootId) {
147
+ return rootId ? recovery.delete(rootId) : false;
148
+ },
149
+ snapshot() {
150
+ prune();
151
+ return {
152
+ active_recovery_trees: recovery.size,
153
+ adapted_requests: counters.adaptedRequests,
154
+ historical_images_omitted: counters.imagesOmitted,
155
+ recovery_activations: counters.recoveryActivations,
156
+ timeouts_recorded: counters.timeoutsRecorded,
157
+ recovery_ttl_ms: recoveryTtlMs,
158
+ pressure_image_limit: policy.pressureHistoricalImages,
159
+ recovery_image_limit: policy.recoveryHistoricalImages,
160
+ };
161
+ },
162
+ });
163
+ }
@@ -126,7 +126,7 @@ export async function proxyCopilotResponses(reqContext, req, res, upstream = cop
126
126
  reqContext = opened.reqContext;
127
127
  if (errorText !== undefined) {
128
128
  sendUpstreamError(res, resp, errorText);
129
- return;
129
+ return { successful: false, compacted: false };
130
130
  }
131
131
 
132
132
  if (reqContext.body.stream) {
@@ -158,7 +158,7 @@ export async function proxyCopilotResponses(reqContext, req, res, upstream = cop
158
158
  if (!streamState.sawTerminal) throw incompleteUpstreamStream("a terminal Responses event");
159
159
  storeCompletedResponse(reqContext, streamState.completed);
160
160
  res.end();
161
- return;
161
+ return { successful: Boolean(streamState.completed), compacted: false };
162
162
  }
163
163
  options.abort?.setTimeout(options.streamIdleTimeoutMs, "stream_idle_timeout");
164
164
  buffer += decoder.decode(value, { stream: true });
@@ -170,12 +170,13 @@ export async function proxyCopilotResponses(reqContext, req, res, upstream = cop
170
170
  if (streamState.sawTerminal) {
171
171
  storeCompletedResponse(reqContext, streamState.completed);
172
172
  res.end();
173
- return;
173
+ return { successful: Boolean(streamState.completed), compacted: false };
174
174
  }
175
175
  }
176
176
  } catch (e) {
177
177
  logRequestFailure("Responses", e, options.abort);
178
178
  await endStreamWithError(res, "responses", e, options.abort);
179
+ return { successful: false, compacted: false };
179
180
  } finally {
180
181
  await reader.cancel().catch(() => {});
181
182
  reader.releaseLock();
@@ -199,7 +200,7 @@ export async function proxyCopilotResponses(reqContext, req, res, upstream = cop
199
200
  contentType: "application/json",
200
201
  }));
201
202
  res.end(JSON.stringify(response));
202
- return;
203
+ return { successful: true, compacted: true };
203
204
  }
204
205
  res.writeHead(resp.status, safeUpstreamResponseHeaders(resp.headers, {
205
206
  contentType: "application/json",
@@ -216,7 +217,9 @@ export async function proxyCopilotResponses(reqContext, req, res, upstream = cop
216
217
  response,
217
218
  event: response,
218
219
  });
220
+ return { successful: true, compacted: false };
219
221
  } catch {}
220
222
  }
223
+ return { successful: false, compacted: false };
221
224
  }
222
225
  }
@@ -1,7 +1,11 @@
1
1
  import { responses as copilotResponses } from "./copilot.mjs";
2
2
  import { httpError } from "./http-transport.mjs";
3
3
  import { debugLog } from "./log.mjs";
4
- import { materializeResponseHistory, rememberResponseHistoryNode } from "./response-history.mjs";
4
+ import {
5
+ materializeResponseHistory,
6
+ rememberResponseHistoryNode,
7
+ responseHistoryRootId,
8
+ } from "./response-history.mjs";
5
9
  import { enforceResponsesImageLimit } from "./responses-image-limit.mjs";
6
10
  import { status } from "./status.mjs";
7
11
 
@@ -157,6 +161,7 @@ export async function openCopilotResponse(reqContext, upstream = copilotResponse
157
161
  const resp = await upstream(reqContext.body, {
158
162
  signal: options.signal,
159
163
  currentInputStart: reqContext.currentInputStart,
164
+ onUpstreamStart: options.onUpstreamStart,
160
165
  payloadPrepared,
161
166
  });
162
167
  payloadPrepared = true;
@@ -198,9 +203,11 @@ export function prepareResponsesRequest(reqBody, { mutate = false } = {}) {
198
203
  );
199
204
  const previousId = body.previous_response_id;
200
205
  let historyItems = [];
206
+ let historyRootId = null;
201
207
 
202
208
  if (previousId !== undefined && previousId !== null) {
203
209
  historyItems = materializeResponseHistory(previousId);
210
+ historyRootId = responseHistoryRootId(previousId);
204
211
  body.input = [...historyItems, ...currentInputItems];
205
212
  } else {
206
213
  body.input = currentInputItems;
@@ -232,6 +239,7 @@ export function prepareResponsesRequest(reqBody, { mutate = false } = {}) {
232
239
  inputItems: body.input,
233
240
  currentInputStart: retainedCurrentInputStart < 0 ? body.input.length : retainedCurrentInputStart,
234
241
  historyParentId: previousId ?? null,
242
+ historyRootId,
235
243
  historyInputItems,
236
244
  takeHistoryOwnership: mutate,
237
245
  };