codex-copilot-dx 0.6.3 → 0.6.5

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
@@ -31,7 +31,8 @@ ccdx
31
31
 
32
32
  `ccdx` is the only documented command and primary launcher. The old
33
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
34
+ prints a deprecation warning at most once every 7 days in interactive terminals,
35
+ runs `ccdx`, and remains silent in scripts. It will be removed in a future
35
36
  breaking release. For a one-off run without a global install, use
36
37
  `npm exec --yes --package=codex-copilot-dx@latest -- ccdx`.
37
38
  Run `ccdx <command> --help` (including `auth login claude --help` and
@@ -110,7 +111,7 @@ active account or profile. GPT requests continue to use the bearer supplied by
110
111
  the current PM profile; exact enabled Anthropic model IDs use the isolated
111
112
  CCDX Claude profile. Neither credential is copied into the other application.
112
113
 
113
- The first recipe supports only an exact, unmodified PM Studio 2.9.7 bundle on
114
+ The current patch recipe supports only an exact, unmodified PM Studio 2.9.7 bundle on
114
115
  macOS. Authorize the isolated Claude profile first, quit PM Studio and its
115
116
  updater, then run the one-time setup command:
116
117
 
@@ -126,7 +127,7 @@ and relay without changing files:
126
127
  ccdx pms status
127
128
  ```
128
129
 
129
- `pm-studio status` and `pm-studio setup` are equivalent long-form aliases.
130
+ `ccdx pm-studio status` and `ccdx pm-studio setup` are equivalent long-form aliases.
130
131
 
131
132
  `ccdx pms setup` performs only local preflight, backup, staging, integrity,
132
133
  signing, and replacement work. It never starts Device Flow, the adapter,
@@ -186,13 +187,13 @@ To change the model used by Codex Auto-review, run:
186
187
  ccdx auto-review-model
187
188
  ```
188
189
 
189
- The selector reads the current adapter model list, falling back to the fresh local
190
- model cache, and offers only enabled models that advertise a Responses endpoint.
190
+ The selector first queries the running adapter, including its last-known-good
191
+ model list when live refresh is unavailable, then falls back to the non-expired
192
+ local model cache. It offers only enabled models that advertise a Responses endpoint.
191
193
  It saves the selection in `~/.config/codex-copilot-dx/config.json` (or under
192
194
  `XDG_CONFIG_HOME` when set); choosing `gpt-5.5` clears the override and restores
193
- the package default. A running 0.5.1+
194
- adapter reads the setting on the next Auto-review request, so it does not need to
195
- be restarted.
195
+ the package default. A running adapter reads the setting on the next Auto-review
196
+ request, so it does not need to be restarted.
196
197
 
197
198
  To update the globally installed package, choose a source interactively:
198
199
 
@@ -212,26 +213,26 @@ ccdx update github
212
213
  The npm source installs `codex-copilot-dx@latest` through the registry already
213
214
  configured for npm, including a company mirror. The GitHub source installs the
214
215
  latest commit from `DaleXiao/codex-copilot-dx` `main` and opts in to Git fetching
215
- for that command, as required by npm 12. It does not change npm's persistent
216
- `allow-git` setting. Both paths use npm's global installer without shell
216
+ for that command. It does not change npm's persistent `allow-git` setting. Both
217
+ paths use npm's global installer without shell
217
218
  interpolation. A currently running adapter keeps its loaded version until it is
218
219
  stopped and started again.
219
220
  If a configured npm mirror has not synchronized the current release yet, use
220
221
  the GitHub source to install the current `main` revision.
221
222
 
222
- On first run, it will:
223
- 1. Authenticate with GitHub via device flow (if needed), after first trying compatible local Copilot token sources
224
- 2. Print the local package version and check for a newer npm release in the background
225
- 3. Start the adapter on loopback (`127.0.0.1:2026`)
223
+ On a normal launch, it will:
224
+ 1. Print the local package version and check for a newer npm release in the background
225
+ 2. Reuse a compatible running adapter when available; otherwise authenticate with GitHub if needed, after first trying compatible local Copilot token sources
226
+ 3. When starting a new adapter, refresh Copilot model metadata and listen on loopback (`127.0.0.1:2026`)
226
227
  4. Configure Codex (`~/.codex/config.toml`) to use the adapter, including stale shell env base URLs if present
227
228
  5. Configure Claude Code (`~/.claude/settings.json`) to use the adapter; it creates the file when missing, otherwise backs up `settings.json.bak` before updating the local API env keys
228
- 6. Launch Codex Desktop
229
+ 6. On macOS, attempt to launch Codex or ChatGPT unless `CCDX_AUTO_LAUNCH` disables it
229
230
 
230
231
  Claude Code picks up the new `ANTHROPIC_BASE_URL` on its next launch.
231
232
 
232
- If an existing `codex-copilot-dx` adapter is already running on the configured host and port, a second launch reuses it instead of starting another proxy. The second launch still refreshes Codex and Claude Code config, then exits.
233
+ If a compatible CCDX adapter is already running on the configured host and port, a second launch reuses it instead of starting another proxy. The second launch still refreshes Codex and Claude Code config, then exits.
233
234
 
234
- The running adapter reports its package and protocol versions. After upgrading `codex-copilot-dx`, stop the old process before starting the new version; the new CLI refuses to silently reuse an incompatible adapter.
235
+ The running adapter reports its package and protocol versions. After upgrading the package, stop the old process before starting the new version; the new CLI refuses to silently reuse an incompatible adapter.
235
236
 
236
237
  Do not set Claude Code by manually exporting `ANTHROPIC_BASE_URL` or `ANTHROPIC_AUTH_TOKEN` in your shell. Let `ccdx` write the local config files instead. If you previously exported those variables, remove them from shell startup files and restart the terminal before launching Claude Code.
237
238
 
@@ -330,12 +331,13 @@ Environment variables:
330
331
  | `CCDX_ALLOW_LAN` | unset | Set to `1` to allow non-loopback `ADAPTER_HOST` values such as `0.0.0.0`; exposes your Copilot-backed adapter beyond this machine |
331
332
  | `CCDX_MAX_BODY_BYTES` | `67108864` | Maximum compressed/raw request body size |
332
333
  | `CCDX_MAX_DECODED_BODY_BYTES` | `134217728` | Maximum decoded request body size after decompression |
333
- | `CCDX_MAX_INFLIGHT_BODY_BYTES` | `33554432` | Shared byte budget for admitted request bodies; a larger single request runs exclusively |
334
+ | `CCDX_MAX_INFLIGHT_BODY_BYTES` | `33554432` | Per-pool byte budget for raw bodies, decoded bodies, and materialized response history; a larger item runs exclusively in its pool |
334
335
  | `CCDX_MAX_QUEUED_REQUESTS` | `16` | Maximum body requests waiting for the shared byte budget |
335
336
  | `CCDX_REQUEST_QUEUE_TIMEOUT_MS` | `120000` | Maximum wait for request-body admission before returning `503` |
337
+ | `CCDX_REQUEST_BODY_TIMEOUT_MS` | `120000` | Maximum time to receive and decode one request body before returning `408` |
336
338
  | `CCDX_MAX_UPSTREAM_BODY_BYTES` | `31457280` | Strict maximum forwarded Responses body size; larger payloads are adapted locally or rejected with `413` |
337
339
  | `CCDX_MAX_SSE_BUFFER_BYTES` | `8388608` | Maximum buffered bytes for one unterminated upstream SSE line/event |
338
- | `CCDX_UPSTREAM_TIMEOUT_MS` | `120000` | Timeout for non-streaming upstream Copilot requests |
340
+ | `CCDX_UPSTREAM_TIMEOUT_MS` | `120000` | Per-phase timeout for Responses preparation and non-streaming upstream Copilot requests |
339
341
  | `CCDX_STREAM_HANDSHAKE_TIMEOUT_MS` | `120000` | Timeout while waiting for upstream streaming response headers |
340
342
  | `CCDX_STREAM_IDLE_TIMEOUT_MS` | `120000` | Maximum idle time between upstream streaming body chunks |
341
343
  | `CCDX_UPSTREAM_RETRIES` | `2` | Retries for safe requests and clearly pre-connect POST failures; capped at `5` |
@@ -396,7 +398,7 @@ The final serialized UTF-8 request body is measured before forwarding. Above the
396
398
 
397
399
  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.
398
400
 
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.
401
+ 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, a streaming handshake, or a non-streaming upstream request, or if the upstream returns HTTP 408, 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
402
 
401
403
  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.
402
404
 
@@ -1,3 +1,10 @@
1
1
  #!/usr/bin/env node
2
- console.warn("codex-copilot-dx is deprecated; use ccdx instead. The compatibility alias will be removed in a future breaking release.");
2
+
3
+ if (process.stderr.isTTY === true) {
4
+ const {
5
+ LEGACY_COMMAND_WARNING,
6
+ shouldShowLegacyCommandWarning,
7
+ } = await import("../src/legacy-command-warning.mjs");
8
+ if (shouldShowLegacyCommandWarning({ interactive: true })) console.warn(LEGACY_COMMAND_WARNING);
9
+ }
3
10
  await import("./cli.mjs");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-copilot-dx",
3
- "version": "0.6.3",
3
+ "version": "0.6.5",
4
4
  "description": "Use Codex Desktop, Claude Code, and Claude App with GitHub Copilot",
5
5
  "author": "Dale Xiao",
6
6
  "type": "module",
package/src/adapter.mjs CHANGED
@@ -136,6 +136,7 @@ export function createAdapterHandler(options = {}) {
136
136
  const upstreamTimeoutMs = parsePositiveInteger(options.upstreamTimeoutMs, ADAPTER_RUNTIME_CONFIG.upstreamTimeoutMs);
137
137
  const streamHandshakeTimeoutMs = parsePositiveInteger(options.streamHandshakeTimeoutMs, ADAPTER_RUNTIME_CONFIG.streamHandshakeTimeoutMs);
138
138
  const streamIdleTimeoutMs = parsePositiveInteger(options.streamIdleTimeoutMs, ADAPTER_RUNTIME_CONFIG.streamIdleTimeoutMs);
139
+ const requestBodyTimeoutMs = parsePositiveInteger(options.requestBodyTimeoutMs, ADAPTER_RUNTIME_CONFIG.requestBodyTimeoutMs);
139
140
  const acquireRequest = options.acquireRequest || createRequestAdmission();
140
141
  const requestMetrics = options.requestMetrics || createRequestMetrics();
141
142
  const streamPerformanceMetrics = options.streamPerformanceMetrics || createStreamPerformanceMetrics();
@@ -150,9 +151,11 @@ export function createAdapterHandler(options = {}) {
150
151
  chatCompletionsFn: codexChatCompletionsFn,
151
152
  getCachedModelEndpointsFn,
152
153
  imagePressure,
154
+ now: options.now,
153
155
  openAIModelEnv,
154
156
  responsesPayloadOptions: options.responsesPayloadOptions,
155
157
  responsesFn,
158
+ requestBodyTimeoutMs,
156
159
  streamHandshakeTimeoutMs,
157
160
  streamIdleTimeoutMs,
158
161
  upstreamTimeoutMs,
@@ -161,18 +164,21 @@ export function createAdapterHandler(options = {}) {
161
164
  acquireRequest,
162
165
  autoReviewModelResolver: options.autoReviewModelResolver,
163
166
  imagePressure,
167
+ now: options.now,
164
168
  openAIModelEnv,
169
+ requestBodyTimeoutMs,
165
170
  responsesCompactFn,
166
171
  streamHandshakeTimeoutMs,
167
172
  streamIdleTimeoutMs,
168
173
  upstreamTimeoutMs,
169
174
  });
170
- const anthropicCountTokensHandler = createAnthropicCountTokensHandler({ acquireRequest });
175
+ const anthropicCountTokensHandler = createAnthropicCountTokensHandler({ acquireRequest, requestBodyTimeoutMs });
171
176
  const anthropicMessagesHandler = createAnthropicMessagesHandler({
172
177
  acquireRequest,
173
178
  chatCompletionsFn: claudeChatCompletionsFn,
174
179
  environment: process.env,
175
180
  modelOptions: claudeDesktopModelOptions,
181
+ requestBodyTimeoutMs,
176
182
  streamHandshakeTimeoutMs,
177
183
  streamIdleTimeoutMs,
178
184
  upstreamTimeoutMs,
@@ -184,6 +190,7 @@ export function createAdapterHandler(options = {}) {
184
190
  claudeProfile: options.claudeProfile,
185
191
  claudeModelRegistry,
186
192
  fetchImpl: options.pmStudioFetchImpl,
193
+ requestBodyTimeoutMs,
187
194
  streamHandshakeTimeoutMs,
188
195
  streamIdleTimeoutMs,
189
196
  upstreamTimeoutMs,
@@ -284,13 +291,20 @@ export function createAdapterHandler(options = {}) {
284
291
  };
285
292
 
286
293
  return (req, res) => {
287
- const pathname = requestPath(req.url);
288
294
  const requestId = createRequestId();
289
295
  if (typeof res.setHeader === "function") {
290
296
  res.setHeader("X-Request-Id", requestId);
291
297
  } else if (res.headers && typeof res.headers === "object") {
292
298
  res.headers["X-Request-Id"] = requestId;
293
299
  }
300
+ let pathname;
301
+ try {
302
+ pathname = requestPath(req.url);
303
+ } catch {
304
+ res.writeHead(400, { "Content-Type": "application/json", Connection: "close" });
305
+ res.end(JSON.stringify({ error: "Invalid request target" }));
306
+ return;
307
+ }
294
308
 
295
309
  const trackRequest = pathname !== ADAPTER_HEALTH_PATH && pathname !== ADAPTER_STATUS_PATH;
296
310
  const routeName = classifyAdapterRoute(req.method, pathname);
@@ -352,11 +366,9 @@ export function startAdapter(port = 2026, host = "127.0.0.1", options = {}) {
352
366
  server.once("close", cleanupTerminalActivity);
353
367
 
354
368
  server.on("upgrade", (req, socket) => {
355
- // Codex Desktop 0.130+ negotiates a "responses_websockets" server-push
356
- // protocol on WS upgrade. We don't implement that protocol; accepting the
357
- // upgrade and waiting for a client request just hangs and triggers a
358
- // 5-attempt reconnect storm. Refuse the upgrade so Codex falls back to
359
- // plain HTTP SSE, which this adapter handles correctly.
369
+ // The responses_websockets upgrade expects a server-push protocol that this
370
+ // adapter does not implement. Refuse it so the client can fall back to the
371
+ // supported HTTP SSE transport instead of waiting on an unusable socket.
360
372
  socket.write("HTTP/1.1 426 Upgrade Required\r\nConnection: close\r\nContent-Length: 0\r\n\r\n");
361
373
  socket.destroy();
362
374
  });
@@ -21,20 +21,22 @@ import { safeUpstreamResponseHeaders } from "./upstream-headers.mjs";
21
21
  import { recordAnthropicUsage } from "./usage.mjs";
22
22
 
23
23
  export function createAnthropicCountTokensHandler(options) {
24
- const { acquireRequest } = options;
24
+ const { acquireRequest, requestBodyTimeoutMs } = options;
25
25
 
26
26
  return async function handleAnthropicCountTokens(req, res) {
27
27
  const abort = createRequestAbort(req, res);
28
28
  let releaseRequest = () => {};
29
29
  try {
30
30
  releaseRequest = await acquireRequest(req, { signal: abort.signal });
31
- const parsed = await readJsonBody(req);
31
+ abort.setTimeout(requestBodyTimeoutMs, "request_body_timeout");
32
+ const parsed = await readJsonBody(req, { admission: releaseRequest, signal: abort.signal });
33
+ abort.clearTimeout();
32
34
  const result = await countTokens(parsed);
33
35
  releaseRequest();
34
36
  res.writeHead(200, { "Content-Type": "application/json" });
35
37
  res.end(JSON.stringify(result));
36
38
  } catch (error) {
37
- sendJsonError(res, error);
39
+ sendJsonError(res, error, abort.reason === "request_body_timeout" ? 408 : 400);
38
40
  } finally {
39
41
  releaseRequest();
40
42
  abort.cleanup();
@@ -48,6 +50,7 @@ export function createAnthropicMessagesHandler(options) {
48
50
  chatCompletionsFn,
49
51
  environment,
50
52
  modelOptions,
53
+ requestBodyTimeoutMs,
51
54
  streamHandshakeTimeoutMs,
52
55
  streamIdleTimeoutMs,
53
56
  upstreamTimeoutMs,
@@ -58,7 +61,8 @@ export function createAnthropicMessagesHandler(options) {
58
61
  let releaseRequest = () => {};
59
62
  try {
60
63
  releaseRequest = await acquireRequest(req, { signal: abort.signal });
61
- const parsed = await readJsonBody(req);
64
+ abort.setTimeout(requestBodyTimeoutMs, "request_body_timeout");
65
+ const parsed = await readJsonBody(req, { admission: releaseRequest, signal: abort.signal });
62
66
  abort.setTimeout(
63
67
  parsed.stream ? streamHandshakeTimeoutMs : upstreamTimeoutMs,
64
68
  parsed.stream ? "stream_handshake_timeout" : "upstream_timeout",
@@ -135,7 +135,7 @@ export async function runAutoReviewModelCommand({
135
135
  if (String(env.CCDX_AUTO_REVIEW_MODEL || "").trim()) {
136
136
  output.write(`CCDX_AUTO_REVIEW_MODEL=${current.model} remains the effective override until it is unset.\n`);
137
137
  } else {
138
- output.write("The running 0.5.1+ adapter will use this model on the next Auto Review request.\n");
138
+ output.write("The running adapter will use this model on the next Auto Review request.\n");
139
139
  }
140
140
  return { ...result, cancelled: false, model: selectedModel };
141
141
  }
@@ -258,7 +258,7 @@ export function cliCommandName() {
258
258
 
259
259
  function topicHelp(name, topic) {
260
260
  const sections = {
261
- start: `Usage:\n ${name} [start] [--configure-claude-app] [--show-request-id]\n\nStarts or reuses the local adapter, updates Codex and Claude Code configuration, and opens Codex.\n--configure-claude-app also creates or updates the managed Claude App gateway profile.`,
261
+ start: `Usage:\n ${name} [start] [--configure-claude-app] [--show-request-id]\n\nStarts or reuses the local adapter, updates Codex and Claude Code configuration, and attempts to open Codex or ChatGPT when auto-launch is enabled and supported.\n--configure-claude-app also creates or updates the managed Claude App gateway profile.`,
262
262
  auth: `Usage:\n ${name} auth status [--online]\n ${name} auth login claude [--github-login <login>] [--reauth]\n\nShows the two account profiles or configures the isolated Claude account. Device login starts only when no reusable local Copilot credential is available or --reauth is used.`,
263
263
  "auth status": `Usage:\n ${name} auth status [--online]\n\nShows account routing without exposing credentials. --online also verifies both configured Copilot entitlements and model catalogs.`,
264
264
  "auth login": `Usage:\n ${name} auth login claude [--github-login <login>] [--reauth]\n\nReuses a compatible local Copilot credential when possible. --reauth forces GitHub Device Flow.`,
@@ -47,6 +47,83 @@ function admissionAbortError(signal) {
47
47
  return error;
48
48
  }
49
49
 
50
+ function createSupplementalAdmission({ maxWeight, maxQueued, waitTimeoutMs, label }) {
51
+ const queue = [];
52
+ let activeWeight = 0;
53
+ let activeRequests = 0;
54
+
55
+ const activate = (entry) => {
56
+ entry.cleanup();
57
+ activeWeight += entry.weight;
58
+ activeRequests += 1;
59
+ let released = false;
60
+ entry.resolve(() => {
61
+ if (released) return;
62
+ released = true;
63
+ activeWeight = Math.max(0, activeWeight - entry.weight);
64
+ activeRequests = Math.max(0, activeRequests - 1);
65
+ drain();
66
+ });
67
+ };
68
+
69
+ const drain = () => {
70
+ for (let index = 0; index < queue.length;) {
71
+ const entry = queue[index];
72
+ if (activeWeight + entry.weight > maxWeight) {
73
+ index += 1;
74
+ continue;
75
+ }
76
+ queue.splice(index, 1);
77
+ activate(entry);
78
+ }
79
+ };
80
+
81
+ const acquire = (requestedWeight, { signal } = {}) => {
82
+ if (signal?.aborted) return Promise.reject(admissionAbortError(signal));
83
+ const parsedWeight = Number.isFinite(requestedWeight) && requestedWeight > 0
84
+ ? Math.ceil(requestedWeight)
85
+ : maxWeight;
86
+ const weight = Math.max(1, Math.min(parsedWeight, maxWeight));
87
+ if (queue.length >= maxQueued && activeWeight + weight > maxWeight) {
88
+ return Promise.reject(httpError(`${label} queue is full (${maxQueued} waiting)`, 503));
89
+ }
90
+
91
+ return new Promise((resolve, reject) => {
92
+ let timer;
93
+ const entry = {
94
+ weight,
95
+ resolve,
96
+ cleanup: () => {
97
+ if (timer) clearTimeout(timer);
98
+ signal?.removeEventListener("abort", onAbort);
99
+ },
100
+ };
101
+ const remove = () => {
102
+ const index = queue.indexOf(entry);
103
+ if (index >= 0) queue.splice(index, 1);
104
+ };
105
+ const fail = (error) => {
106
+ entry.cleanup();
107
+ remove();
108
+ reject(error);
109
+ drain();
110
+ };
111
+ const onAbort = () => fail(admissionAbortError(signal));
112
+ signal?.addEventListener("abort", onAbort, { once: true });
113
+ timer = setTimeout(() => {
114
+ fail(httpError(`${label} admission timed out after ${waitTimeoutMs}ms`, 503));
115
+ }, waitTimeoutMs);
116
+ timer.unref?.();
117
+ queue.push(entry);
118
+ if (signal?.aborted) onAbort();
119
+ else drain();
120
+ });
121
+ };
122
+
123
+ acquire.stats = () => ({ activeWeight, activeRequests, queued: queue.length, maxWeight });
124
+ return acquire;
125
+ }
126
+
50
127
  export function createRequestAdmission({
51
128
  maxBytes = MAX_INFLIGHT_BODY_BYTES,
52
129
  maxQueued = MAX_QUEUED_REQUESTS,
@@ -55,6 +132,24 @@ export function createRequestAdmission({
55
132
  const byteLimit = parsePositiveInteger(maxBytes, MAX_INFLIGHT_BODY_BYTES);
56
133
  const queueLimit = parsePositiveInteger(maxQueued, MAX_QUEUED_REQUESTS);
57
134
  const timeoutMs = parsePositiveInteger(waitTimeoutMs, REQUEST_QUEUE_TIMEOUT_MS);
135
+ const acquireDecompression = createSupplementalAdmission({
136
+ maxWeight: 1,
137
+ maxQueued: queueLimit,
138
+ waitTimeoutMs: timeoutMs,
139
+ label: "Request decompression",
140
+ });
141
+ const acquireDecodedBody = createSupplementalAdmission({
142
+ maxWeight: byteLimit,
143
+ maxQueued: queueLimit,
144
+ waitTimeoutMs: timeoutMs,
145
+ label: "Decoded request body",
146
+ });
147
+ const acquireResponseHistory = createSupplementalAdmission({
148
+ maxWeight: byteLimit,
149
+ maxQueued: queueLimit,
150
+ waitTimeoutMs: timeoutMs,
151
+ label: "Response history",
152
+ });
58
153
  const queue = [];
59
154
  let activeBytes = 0;
60
155
  let activeRequests = 0;
@@ -83,13 +178,28 @@ export function createRequestAdmission({
83
178
  counters.waitMsTotal += waitMs;
84
179
  counters.waitMsMax = Math.max(counters.waitMsMax, waitMs);
85
180
  let released = false;
86
- entry.resolve(() => {
181
+ const supplementalReleases = new Set();
182
+ const release = () => {
87
183
  if (released) return;
88
184
  released = true;
185
+ for (const releaseSupplemental of supplementalReleases) releaseSupplemental();
186
+ supplementalReleases.clear();
89
187
  activeBytes = Math.max(0, activeBytes - entry.weight);
90
188
  activeRequests = Math.max(0, activeRequests - 1);
91
189
  drain();
92
- });
190
+ };
191
+ const reserve = async (gate, weight, options) => {
192
+ const releaseSupplemental = await gate(weight, options);
193
+ if (released) {
194
+ releaseSupplemental();
195
+ throw admissionAbortError(options?.signal);
196
+ }
197
+ supplementalReleases.add(releaseSupplemental);
198
+ };
199
+ release.acquireDecompression = (options) => acquireDecompression(1, options);
200
+ release.reserveDecodedBody = (bytes, options) => reserve(acquireDecodedBody, bytes, options);
201
+ release.reserveResponseHistory = (bytes, options) => reserve(acquireResponseHistory, bytes, options);
202
+ entry.resolve(release);
93
203
  };
94
204
 
95
205
  const drain = () => {
@@ -173,11 +283,64 @@ export function createRequestAdmission({
173
283
  ? Number((counters.waitMsTotal / counters.activated).toFixed(1))
174
284
  : 0,
175
285
  waitMsMax: counters.waitMsMax,
286
+ decompressionsActive: acquireDecompression.stats().activeRequests,
287
+ decompressionsQueued: acquireDecompression.stats().queued,
288
+ decodedBodyBytes: acquireDecodedBody.stats().activeWeight,
289
+ decodedBodiesActive: acquireDecodedBody.stats().activeRequests,
290
+ decodedBodiesQueued: acquireDecodedBody.stats().queued,
291
+ responseHistoryBytes: acquireResponseHistory.stats().activeWeight,
292
+ responseHistoriesActive: acquireResponseHistory.stats().activeRequests,
293
+ responseHistoriesQueued: acquireResponseHistory.stats().queued,
176
294
  });
177
295
  return acquire;
178
296
  }
179
297
 
180
- async function readRequestBuffer(req, maxBytes) {
298
+ function consumeRequestChunks(req, { signal, onChunk }) {
299
+ return new Promise((resolve, reject) => {
300
+ let settled = false;
301
+ const cleanup = () => {
302
+ req.off("data", handleData);
303
+ req.off("end", handleEnd);
304
+ req.off("error", handleError);
305
+ req.off("aborted", handleAborted);
306
+ signal?.removeEventListener("abort", handleSignalAbort);
307
+ };
308
+ const finish = (error) => {
309
+ if (settled) return;
310
+ settled = true;
311
+ cleanup();
312
+ if (error) reject(error);
313
+ else resolve();
314
+ };
315
+ const stopReading = (error) => {
316
+ req.pause?.();
317
+ finish(error);
318
+ };
319
+ const handleData = (chunk) => {
320
+ try {
321
+ onChunk(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
322
+ } catch (error) {
323
+ stopReading(error);
324
+ }
325
+ };
326
+ const handleEnd = () => finish();
327
+ const handleError = (error) => finish(error);
328
+ const handleAborted = () => finish(admissionAbortError(signal));
329
+ const handleSignalAbort = () => stopReading(admissionAbortError(signal));
330
+
331
+ req.once("end", handleEnd);
332
+ req.once("error", handleError);
333
+ req.once("aborted", handleAborted);
334
+ signal?.addEventListener("abort", handleSignalAbort, { once: true });
335
+ if (signal?.aborted) handleSignalAbort();
336
+ else {
337
+ req.on("data", handleData);
338
+ req.resume?.();
339
+ }
340
+ });
341
+ }
342
+
343
+ async function readRequestBuffer(req, maxBytes, signal) {
181
344
  const contentLength = requestContentLength(req);
182
345
  if (Number.isFinite(contentLength) && contentLength > maxBytes) {
183
346
  throw payloadTooLarge("Raw", maxBytes);
@@ -185,16 +348,18 @@ async function readRequestBuffer(req, maxBytes) {
185
348
 
186
349
  const chunks = [];
187
350
  let total = 0;
188
- for await (const chunk of req) {
189
- const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
190
- total += buffer.length;
191
- if (total > maxBytes) throw payloadTooLarge("Raw", maxBytes);
192
- chunks.push(buffer);
193
- }
351
+ await consumeRequestChunks(req, {
352
+ signal,
353
+ onChunk(buffer) {
354
+ total += buffer.length;
355
+ if (total > maxBytes) throw payloadTooLarge("Raw", maxBytes);
356
+ chunks.push(buffer);
357
+ },
358
+ });
194
359
  return Buffer.concat(chunks);
195
360
  }
196
361
 
197
- async function readIdentityText(req, maxBodyBytes, maxDecodedBodyBytes) {
362
+ async function readIdentityText(req, maxBodyBytes, maxDecodedBodyBytes, signal) {
198
363
  const contentLength = requestContentLength(req);
199
364
  if (Number.isFinite(contentLength) && contentLength > maxBodyBytes) {
200
365
  throw payloadTooLarge("Raw", maxBodyBytes);
@@ -206,13 +371,15 @@ async function readIdentityText(req, maxBodyBytes, maxDecodedBodyBytes) {
206
371
  const decoder = new TextDecoder();
207
372
  let text = "";
208
373
  let total = 0;
209
- for await (const chunk of req) {
210
- const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
211
- total += buffer.length;
212
- if (total > maxBodyBytes) throw payloadTooLarge("Raw", maxBodyBytes);
213
- if (total > maxDecodedBodyBytes) throw payloadTooLarge("Decoded", maxDecodedBodyBytes);
214
- text += decoder.decode(buffer, { stream: true });
215
- }
374
+ await consumeRequestChunks(req, {
375
+ signal,
376
+ onChunk(buffer) {
377
+ total += buffer.length;
378
+ if (total > maxBodyBytes) throw payloadTooLarge("Raw", maxBodyBytes);
379
+ if (total > maxDecodedBodyBytes) throw payloadTooLarge("Decoded", maxDecodedBodyBytes);
380
+ text += decoder.decode(buffer, { stream: true });
381
+ },
382
+ });
216
383
  return text + decoder.decode();
217
384
  }
218
385
 
@@ -267,22 +434,34 @@ function parseRequestJson(text) {
267
434
  }
268
435
 
269
436
  export async function readJsonBody(req, {
437
+ admission,
270
438
  maxBodyBytes = MAX_BODY_BYTES,
271
439
  maxDecodedBodyBytes = MAX_DECODED_BODY_BYTES,
440
+ signal,
272
441
  } = {}) {
442
+ if (signal?.aborted) throw admissionAbortError(signal);
273
443
  const encodings = contentEncodings(req.headers?.["content-encoding"]);
274
444
  if (encodings.every((encoding) => encoding === "identity")) {
275
- return parseRequestJson(await readIdentityText(req, maxBodyBytes, maxDecodedBodyBytes));
445
+ return parseRequestJson(await readIdentityText(req, maxBodyBytes, maxDecodedBodyBytes, signal));
276
446
  }
277
- const buffer = await readRequestBuffer(req, maxBodyBytes);
278
- let decoded;
447
+ const buffer = await readRequestBuffer(req, maxBodyBytes, signal);
448
+ const releaseDecompression = await admission?.acquireDecompression?.({ signal }) || (() => {});
279
449
  try {
280
- decoded = await decodeRequestBuffer(buffer, req.headers?.["content-encoding"], maxDecodedBodyBytes);
281
- } catch (e) {
282
- if (e?.statusCode) throw e;
283
- throw httpError(`Invalid compressed request body: ${e.message}`, 400);
450
+ let decoded;
451
+ try {
452
+ decoded = await decodeRequestBuffer(buffer, req.headers?.["content-encoding"], maxDecodedBodyBytes);
453
+ } catch (e) {
454
+ if (e?.statusCode) throw e;
455
+ throw httpError(`Invalid compressed request body: ${e.message}`, 400);
456
+ }
457
+ if (signal?.aborted) throw admissionAbortError(signal);
458
+ if (decoded.length > 0) {
459
+ await admission?.reserveDecodedBody?.(decoded.length, { signal });
460
+ }
461
+ return parseRequestJson(decoded.toString("utf8"));
462
+ } finally {
463
+ releaseDecompression();
284
464
  }
285
- return parseRequestJson(decoded.toString("utf8"));
286
465
  }
287
466
 
288
467
  export function sendJsonError(res, err, fallbackStatus = 400) {
@@ -291,7 +470,10 @@ export function sendJsonError(res, err, fallbackStatus = 400) {
291
470
  res.end();
292
471
  return;
293
472
  }
294
- res.writeHead(err?.statusCode || fallbackStatus, { "Content-Type": "application/json" });
473
+ const statusCode = err?.statusCode || fallbackStatus;
474
+ const headers = { "Content-Type": "application/json" };
475
+ if (statusCode === 408 || statusCode === 413) headers.Connection = "close";
476
+ res.writeHead(statusCode, headers);
295
477
  res.end(JSON.stringify(err?.jsonBody || { error: err?.message || "Request failed" }));
296
478
  }
297
479
 
@@ -327,6 +509,7 @@ export function isAbortLikeError(err) {
327
509
  }
328
510
 
329
511
  export function abortErrorStatusCode(reason) {
512
+ if (reason === "request_body_timeout") return 408;
330
513
  if (["responses_prepare_timeout", "upstream_timeout", "stream_handshake_timeout", "stream_idle_timeout"].includes(reason)) return 504;
331
514
  if (reason === "client_aborted" || reason === "client_closed") return 499;
332
515
  return 502;
@@ -352,6 +535,7 @@ export function createRequestAbort(req, res) {
352
535
  return {
353
536
  signal: controller.signal,
354
537
  get reason() { return reason; },
538
+ abort,
355
539
  setTimeout(ms, nextReason = "upstream_timeout") {
356
540
  if (timer) clearTimeout(timer);
357
541
  timer = null;
package/src/launcher.mjs CHANGED
@@ -3,9 +3,8 @@ import { status } from "./status.mjs";
3
3
 
4
4
  export { ensureAuth } from "./auth.mjs";
5
5
 
6
- // The Codex desktop app was folded into the ChatGPT app, but it keeps the
7
- // original bundle identifier. Launching by bundle id works for both the old
8
- // Codex.app and the new ChatGPT.app regardless of where they are installed.
6
+ // Prefer the Codex bundle identifier, then fall back to the known app paths.
7
+ // This supports both Codex.app and ChatGPT.app regardless of installation path.
9
8
  const CODEX_BUNDLE_ID = "com.openai.codex";
10
9
  const APP_PATH_CANDIDATES = [
11
10
  "/Applications/Codex.app",
@@ -0,0 +1,37 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { atomicWriteFileSync } from "./atomic-file.mjs";
5
+
6
+ export const LEGACY_COMMAND_WARNING_INTERVAL_MS = 7 * 24 * 60 * 60 * 1000;
7
+ export const LEGACY_COMMAND_WARNING = "codex-copilot-dx is deprecated; use ccdx instead. The compatibility alias will be removed in a future breaking release.";
8
+
9
+ export function legacyCommandWarningPath({ env = process.env, home = os.homedir() } = {}) {
10
+ const xdgCacheHome = String(env.XDG_CACHE_HOME || "").trim();
11
+ const cacheRoot = xdgCacheHome || path.join(home, ".cache");
12
+ return path.join(cacheRoot, "codex-copilot-dx", "legacy-command-warning");
13
+ }
14
+
15
+ export function shouldShowLegacyCommandWarning({
16
+ env = process.env,
17
+ home = os.homedir(),
18
+ interactive = process.stderr.isTTY === true,
19
+ now = Date.now(),
20
+ } = {}) {
21
+ if (!interactive) return false;
22
+ const filePath = legacyCommandWarningPath({ env, home });
23
+ try {
24
+ const previous = Number.parseInt(fs.readFileSync(filePath, "utf8"), 10);
25
+ if (Number.isFinite(previous) && Math.abs(now - previous) < LEGACY_COMMAND_WARNING_INTERVAL_MS) {
26
+ return false;
27
+ }
28
+ } catch {
29
+ // A missing or unreadable cache must not prevent the compatibility command.
30
+ }
31
+ try {
32
+ atomicWriteFileSync(filePath, `${now}\n`, { mode: 0o600 });
33
+ } catch {
34
+ // The warning remains best effort when the cache directory is read-only.
35
+ }
36
+ return true;
37
+ }
@@ -252,6 +252,9 @@ async function relayUpstreamResponse(response, reqBody, res, abort, streamIdleTi
252
252
  function safeFailure(error, abort) {
253
253
  if (error?.statusCode && !isAbortLikeError(error)) return error;
254
254
  if (abort?.signal.aborted || isAbortLikeError(error)) {
255
+ if (abort?.reason === "request_body_timeout") {
256
+ return localError(408, "request_timeout", "PM Studio relay request body timed out");
257
+ }
255
258
  return localError(
256
259
  abortErrorStatusCode(abort?.reason),
257
260
  "upstream_timeout",
@@ -277,6 +280,7 @@ export function createPmStudioRelayHandler(options = {}) {
277
280
  const upstreamTimeoutMs = parsePositiveInteger(options.upstreamTimeoutMs, runtime.upstreamTimeoutMs);
278
281
  const streamHandshakeTimeoutMs = parsePositiveInteger(options.streamHandshakeTimeoutMs, runtime.streamHandshakeTimeoutMs);
279
282
  const streamIdleTimeoutMs = parsePositiveInteger(options.streamIdleTimeoutMs, runtime.streamIdleTimeoutMs);
283
+ const requestBodyTimeoutMs = parsePositiveInteger(options.requestBodyTimeoutMs, runtime.requestBodyTimeoutMs);
280
284
  const validationTtlMs = parsePositiveInteger(options.validationTtlMs, DEFAULT_VALIDATION_TTL_MS);
281
285
  const validationCacheSize = parsePositiveInteger(options.validationCacheSize, DEFAULT_VALIDATION_CACHE_SIZE, 4096);
282
286
  const validationConcurrency = parsePositiveInteger(
@@ -430,8 +434,10 @@ export function createPmStudioRelayHandler(options = {}) {
430
434
  async function handlePost(req, res, pathname, token, abort) {
431
435
  let releaseRequest = releaseOnce();
432
436
  try {
433
- releaseRequest = releaseOnce(await acquireRequest(req, { signal: abort.signal }));
434
- const body = await readJsonBody(req);
437
+ const admission = await acquireRequest(req, { signal: abort.signal });
438
+ releaseRequest = releaseOnce(admission);
439
+ abort.setTimeout(requestBodyTimeoutMs, "request_body_timeout");
440
+ const body = await readJsonBody(req, { admission, signal: abort.signal });
435
441
  const supportsStream = pathname === "/chat/completions" || pathname === "/responses";
436
442
  const streaming = supportsStream && body?.stream === true;
437
443
  const requestTimeoutMs = streaming ? streamHandshakeTimeoutMs : upstreamTimeoutMs;
@@ -178,7 +178,7 @@ export function responseHistoryRootId(responseId) {
178
178
  return histories.get(responseId)?.rootId || null;
179
179
  }
180
180
 
181
- export function materializeResponseHistory(responseId) {
181
+ function responseHistoryChain(responseId) {
182
182
  const chain = [];
183
183
  const seen = new Set();
184
184
  let currentId = responseId;
@@ -193,7 +193,16 @@ export function materializeResponseHistory(responseId) {
193
193
  chain.push(entry);
194
194
  currentId = entry.parentId;
195
195
  }
196
- const rootId = chain[0]?.rootId;
196
+ return { chain, rootId: chain[0]?.rootId };
197
+ }
198
+
199
+ export function responseHistoryMaterializedBytes(responseId) {
200
+ const { chain } = responseHistoryChain(responseId);
201
+ return chain.reduce((bytes, entry) => bytes + entry.bytes, 0);
202
+ }
203
+
204
+ export function materializeResponseHistory(responseId) {
205
+ const { chain, rootId } = responseHistoryChain(responseId);
197
206
  const items = [];
198
207
  for (const entry of chain.reverse()) items.push(...entry.inputItems, ...entry.outputItems);
199
208
  const materialized = cloneJson(items);
@@ -143,23 +143,38 @@ export function chatToResponses(chatResp, model) {
143
143
 
144
144
  export async function forwardToChat(chatReq, emitEvent, onDone, onError, options = {}) {
145
145
  delete chatReq.max_tokens;
146
- const upstreamReq = withChatStreamUsage(chatReq);
147
- const bodyText = upstreamReq === chatReq ? options.bodyText : undefined;
146
+ const requestModel = chatReq.model || "unknown";
147
+ let upstreamReq = withChatStreamUsage(chatReq);
148
+ let {
149
+ abort,
150
+ bodyText,
151
+ chatCompletionsFn: configuredChatCompletionsFn,
152
+ onUpstreamResponse,
153
+ onUpstreamStart,
154
+ releaseRequest,
155
+ signal,
156
+ streamIdleTimeoutMs,
157
+ } = options;
158
+ options = undefined;
159
+ if (upstreamReq !== chatReq) bodyText = undefined;
148
160
  let resp;
149
161
  try {
150
- const chatCompletionsFn = options.chatCompletionsFn || chatCompletions;
162
+ const chatCompletionsFn = configuredChatCompletionsFn || chatCompletions;
151
163
  try {
152
- options.onUpstreamStart?.();
164
+ onUpstreamStart?.();
153
165
  resp = await chatCompletionsFn(upstreamReq, {
154
- signal: options.signal,
166
+ signal,
155
167
  bodyText,
156
- onUpstreamStart: options.onUpstreamStart,
168
+ onUpstreamStart,
157
169
  });
158
170
  } finally {
159
- options.releaseRequest?.();
171
+ bodyText = undefined;
172
+ chatReq = undefined;
173
+ upstreamReq = undefined;
174
+ releaseRequest?.();
160
175
  }
161
176
  } catch (e) {
162
- const statusCode = isAbortLikeError(e) ? abortErrorStatusCode(options.abort?.reason) : 502;
177
+ const statusCode = isAbortLikeError(e) ? abortErrorStatusCode(abort?.reason) : 502;
163
178
  await onError(statusCode, e.message);
164
179
  return false;
165
180
  }
@@ -173,10 +188,10 @@ export async function forwardToChat(chatReq, emitEvent, onDone, onError, options
173
188
  await onError(error.statusCode, error.message, error);
174
189
  return false;
175
190
  }
176
- options.onUpstreamResponse?.(resp);
177
- options.abort?.setTimeout(options.streamIdleTimeoutMs, "stream_idle_timeout");
191
+ onUpstreamResponse?.(resp);
192
+ abort?.setTimeout(streamIdleTimeoutMs, "stream_idle_timeout");
178
193
  const respId = `resp_${uid()}`;
179
- let actualModel = chatReq.model || "unknown";
194
+ let actualModel = requestModel;
180
195
  let fullText = "";
181
196
  let messageItem = null;
182
197
  let nextOutputIndex = 0;
@@ -280,7 +295,7 @@ export async function forwardToChat(chatReq, emitEvent, onDone, onError, options
280
295
 
281
296
  try {
282
297
  for await (const line of webStreamLines(resp, {
283
- onChunk: () => options.abort?.setTimeout(options.streamIdleTimeoutMs, "stream_idle_timeout"),
298
+ onChunk: () => abort?.setTimeout(streamIdleTimeoutMs, "stream_idle_timeout"),
284
299
  })) {
285
300
  if (!line.startsWith("data: ")) continue;
286
301
  const data = line.slice(6).trim();
@@ -334,7 +349,7 @@ export async function forwardToChat(chatReq, emitEvent, onDone, onError, options
334
349
  }
335
350
  }
336
351
  } catch (e) {
337
- const statusCode = isAbortLikeError(e) ? abortErrorStatusCode(options.abort?.reason) : (e?.statusCode || 502);
352
+ const statusCode = isAbortLikeError(e) ? abortErrorStatusCode(abort?.reason) : (e?.statusCode || 502);
338
353
  await onError(statusCode, e?.message || "upstream stream error", e);
339
354
  return false;
340
355
  }
@@ -17,7 +17,12 @@ import { chatToResponses, forwardToChat, responsesBodyUsesCustomTools } from "./
17
17
  import { prepareResponsesChatPayload } from "./responses-chat-payload.mjs";
18
18
  import { prepareResponsesCompactionRequest } from "./responses-compaction.mjs";
19
19
  import { proxyCopilotResponses } from "./responses-proxy.mjs";
20
- import { prepareResponsesRequest, rememberResponseHistory } from "./responses-request.mjs";
20
+ import { responseHistoryMaterializedBytes } from "./response-history.mjs";
21
+ import {
22
+ dropMaterializedResponseHistory,
23
+ prepareResponsesRequest,
24
+ rememberResponseHistory,
25
+ } from "./responses-request.mjs";
21
26
  import { status } from "./status.mjs";
22
27
  import { endStreamWithError } from "./stream-errors.mjs";
23
28
  import { safeUpstreamResponseHeaders } from "./upstream-headers.mjs";
@@ -81,12 +86,14 @@ export function createResponsesHandler(options) {
81
86
  getCachedModelEndpointsFn = getCachedModelEndpoints,
82
87
  imagePressure,
83
88
  openAIModelEnv,
89
+ requestBodyTimeoutMs,
84
90
  responsesPayloadOptions,
85
91
  responsesFn,
86
92
  streamHandshakeTimeoutMs,
87
93
  streamIdleTimeoutMs,
88
94
  upstreamTimeoutMs,
89
95
  } = options;
96
+ const now = options.now || Date.now;
90
97
 
91
98
  return async function handleResponses(req, res) {
92
99
  const abort = createRequestAbort(req, res);
@@ -94,10 +101,26 @@ export function createResponsesHandler(options) {
94
101
  let prepared = null;
95
102
  let imagePressureResult = null;
96
103
  let parsed = null;
97
- const startUpstreamTimeout = () => abort.setTimeout(
98
- parsed?.stream ? streamHandshakeTimeoutMs : upstreamTimeoutMs,
99
- parsed?.stream ? "stream_handshake_timeout" : "upstream_timeout",
100
- );
104
+ let upstreamPayloadReleased = false;
105
+ let prepareDeadline = Number.POSITIVE_INFINITY;
106
+ let upstreamStarted = false;
107
+ const assertPrepareActive = () => {
108
+ if (abort.signal.aborted) throw abort.signal.reason;
109
+ if (now() < prepareDeadline) return;
110
+ abort.abort("responses_prepare_timeout");
111
+ throw abort.signal.reason;
112
+ };
113
+ const startUpstreamTimeout = () => {
114
+ if (!upstreamStarted) {
115
+ assertPrepareActive();
116
+ upstreamStarted = true;
117
+ }
118
+ const streaming = prepared?.body?.stream === true;
119
+ abort.setTimeout(
120
+ streaming ? streamHandshakeTimeoutMs : upstreamTimeoutMs,
121
+ streaming ? "stream_handshake_timeout" : "upstream_timeout",
122
+ );
123
+ };
101
124
  const applyAdaptiveTimeout = (error) => {
102
125
  if (!["responses_prepare_timeout", "stream_handshake_timeout", "upstream_timeout"].includes(abort.reason)) {
103
126
  return error;
@@ -118,21 +141,47 @@ export function createResponsesHandler(options) {
118
141
  };
119
142
  return error;
120
143
  };
144
+ const markAdaptiveHttpTimeout = (statusCode) => {
145
+ if (statusCode !== 408) return false;
146
+ return imagePressure?.markTimeout?.(prepared?.historyRootId, {
147
+ eligible: imagePressureResult?.pressureEligible === true,
148
+ }) || false;
149
+ };
150
+ const releaseUpstreamPayload = (finalContext) => {
151
+ if (upstreamPayloadReleased) return;
152
+ upstreamPayloadReleased = true;
153
+ if (finalContext && finalContext !== prepared) dropMaterializedResponseHistory(finalContext);
154
+ dropMaterializedResponseHistory(prepared);
155
+ releaseRequest();
156
+ };
121
157
  try {
122
158
  releaseRequest = await acquireRequest(req, { signal: abort.signal });
123
- parsed = await readJsonBody(req);
159
+ abort.setTimeout(requestBodyTimeoutMs, "request_body_timeout");
160
+ parsed = await readJsonBody(req, { admission: releaseRequest, signal: abort.signal });
161
+ prepareDeadline = now() + upstreamTimeoutMs;
124
162
  abort.setTimeout(upstreamTimeoutMs, "responses_prepare_timeout");
163
+ if (parsed.previous_response_id !== undefined && parsed.previous_response_id !== null) {
164
+ const historyBytes = responseHistoryMaterializedBytes(parsed.previous_response_id);
165
+ if (historyBytes > 0) {
166
+ await releaseRequest.reserveResponseHistory?.(historyBytes, { signal: abort.signal });
167
+ }
168
+ assertPrepareActive();
169
+ }
125
170
  prepared = prepareResponsesRequest(parsed, { mutate: true });
171
+ parsed = null;
172
+ assertPrepareActive();
126
173
  imagePressureResult = imagePressure?.apply?.(prepared) || null;
174
+ assertPrepareActive();
127
175
  if (imagePressureResult?.adapted) {
128
176
  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
177
  }
130
178
  prepared.surface = "responses";
131
- const model = parsed.model || "unknown";
179
+ const model = prepared.body.model || "unknown";
180
+ const streaming = prepared.body.stream === true;
132
181
  const { requestedModel, upstreamModel } = resolveRequestModel(model, openAIModelEnv, autoReviewModelResolver);
133
182
  if (upstreamModel !== requestedModel) prepared.body.model = upstreamModel;
134
183
  const upstreamLog = upstreamModel === requestedModel ? "" : ` upstream_model=${upstreamModel}`;
135
- console.log(status("info", `responses model=${requestedModel}${upstreamLog} stream=${!!parsed.stream}`));
184
+ console.log(status("info", `responses model=${requestedModel}${upstreamLog} stream=${streaming}`));
136
185
  const usesCustomTools = responsesBodyUsesCustomTools(prepared.body);
137
186
  const useNativeResponses = requestedModel === CODEX_AUTO_REVIEW_MODEL
138
187
  || isResponsesOnlyModel(upstreamModel, getCachedModelEndpointsFn)
@@ -143,18 +192,23 @@ export function createResponsesHandler(options) {
143
192
  signal: abort.signal,
144
193
  abort,
145
194
  onUpstreamStart: startUpstreamTimeout,
146
- releaseRequest,
195
+ releaseRequest: releaseUpstreamPayload,
147
196
  streamIdleTimeoutMs,
148
197
  });
149
198
  if (result?.successful) imagePressure?.markSuccess?.(prepared.historyRootId);
199
+ else markAdaptiveHttpTimeout(result?.upstreamStatus);
150
200
  } else {
151
- const chatPayload = await prepareResponsesChatPayload(prepared, {
201
+ let { chatReq, bodyText } = await prepareResponsesChatPayload(prepared, {
152
202
  payloadOptions: responsesPayloadOptions,
153
203
  signal: abort.signal,
154
- stream: parsed.stream,
204
+ stream: streaming,
155
205
  });
156
- const { chatReq, bodyText } = chatPayload;
157
- if (parsed.stream) {
206
+ const releaseChatPayload = () => {
207
+ chatReq = undefined;
208
+ bodyText = undefined;
209
+ releaseUpstreamPayload();
210
+ };
211
+ if (streaming) {
158
212
  let streamResponseHeaders = null;
159
213
  const successful = await forwardToChat(chatReq, async (event, data) => {
160
214
  if (!res.headersSent) res.writeHead(200, {
@@ -170,6 +224,7 @@ export function createResponsesHandler(options) {
170
224
  }, () => { if (!res.writableEnded) res.end(); }, async (statusCode, errMsg, error, upstreamResponse) => {
171
225
  if (!res.headersSent) {
172
226
  if (upstreamResponse) {
227
+ markAdaptiveHttpTimeout(upstreamResponse.status);
173
228
  sendUpstreamError(res, upstreamResponse, errMsg);
174
229
  return;
175
230
  }
@@ -184,7 +239,7 @@ export function createResponsesHandler(options) {
184
239
  onUpstreamStart: startUpstreamTimeout,
185
240
  streamIdleTimeoutMs,
186
241
  chatCompletionsFn,
187
- releaseRequest,
242
+ releaseRequest: releaseChatPayload,
188
243
  bodyText,
189
244
  onUpstreamResponse: (upstreamResponse) => {
190
245
  streamResponseHeaders = safeUpstreamResponseHeaders(upstreamResponse.headers, {
@@ -201,9 +256,10 @@ export function createResponsesHandler(options) {
201
256
  bodyText,
202
257
  onUpstreamStart: startUpstreamTimeout,
203
258
  });
204
- releaseRequest();
259
+ releaseChatPayload();
205
260
  const data = await upstream.text();
206
261
  if (!upstream.ok) {
262
+ markAdaptiveHttpTimeout(upstream.status);
207
263
  sendUpstreamError(res, upstream, data);
208
264
  return;
209
265
  }
@@ -227,7 +283,7 @@ export function createResponsesHandler(options) {
227
283
  logRequestFailure("Responses", responseError, abort);
228
284
  sendJsonError(res, responseError, 502);
229
285
  } finally {
230
- releaseRequest();
286
+ releaseUpstreamPayload();
231
287
  abort.cleanup();
232
288
  }
233
289
  };
@@ -239,20 +295,46 @@ export function createResponsesCompactHandler(options) {
239
295
  autoReviewModelResolver,
240
296
  imagePressure,
241
297
  openAIModelEnv,
298
+ requestBodyTimeoutMs,
242
299
  responsesCompactFn,
243
300
  upstreamTimeoutMs,
244
301
  } = options;
302
+ const now = options.now || Date.now;
245
303
 
246
304
  return async function handleResponsesCompact(req, res) {
247
305
  const abort = createRequestAbort(req, res);
248
306
  let releaseRequest = () => {};
249
307
  try {
250
308
  releaseRequest = await acquireRequest(req, { signal: abort.signal });
251
- const parsed = await readJsonBody(req);
309
+ abort.setTimeout(requestBodyTimeoutMs, "request_body_timeout");
310
+ const parsed = await readJsonBody(req, { admission: releaseRequest, signal: abort.signal });
311
+ const prepareDeadline = now() + upstreamTimeoutMs;
312
+ const assertPrepareActive = () => {
313
+ if (abort.signal.aborted) throw abort.signal.reason;
314
+ if (now() < prepareDeadline) return;
315
+ abort.abort("responses_prepare_timeout");
316
+ throw abort.signal.reason;
317
+ };
318
+ let upstreamStarted = false;
319
+ const startUpstreamTimeout = () => {
320
+ if (!upstreamStarted) {
321
+ assertPrepareActive();
322
+ upstreamStarted = true;
323
+ }
324
+ abort.setTimeout(upstreamTimeoutMs, "upstream_timeout");
325
+ };
252
326
  abort.setTimeout(upstreamTimeoutMs, "responses_prepare_timeout");
327
+ if (parsed.previous_response_id !== undefined && parsed.previous_response_id !== null) {
328
+ const historyBytes = responseHistoryMaterializedBytes(parsed.previous_response_id);
329
+ if (historyBytes > 0) {
330
+ await releaseRequest.reserveResponseHistory?.(historyBytes, { signal: abort.signal });
331
+ }
332
+ assertPrepareActive();
333
+ }
253
334
  const prepared = prepareResponsesCompactionRequest(
254
335
  prepareResponsesRequest(parsed, { mutate: true }),
255
336
  );
337
+ assertPrepareActive();
256
338
  prepared.surface = "responses_compact";
257
339
  const model = parsed.model || "unknown";
258
340
  const { requestedModel, upstreamModel } = resolveRequestModel(model, openAIModelEnv, autoReviewModelResolver);
@@ -262,7 +344,7 @@ export function createResponsesCompactHandler(options) {
262
344
  const result = await proxyCopilotResponses(prepared, req, res, responsesCompactFn, {
263
345
  signal: abort.signal,
264
346
  abort,
265
- onUpstreamStart: () => abort.setTimeout(upstreamTimeoutMs, "upstream_timeout"),
347
+ onUpstreamStart: startUpstreamTimeout,
266
348
  releaseRequest,
267
349
  });
268
350
  if (result?.compacted) imagePressure?.clear?.(prepared.historyRootId);
@@ -32,8 +32,24 @@ export function applyResponsesImagePressure(reqContext, {
32
32
  const currentInputStart = Number.isFinite(reqContext?.currentInputStart)
33
33
  ? reqContext.currentInputStart
34
34
  : 0;
35
- const initial = bodyPayload(body);
36
35
  const initialImages = responsesHistoricalImageStats(body?.input, currentInputStart);
36
+ if (initialImages.historicalImages === 0) {
37
+ return {
38
+ mode: "normal",
39
+ pressureEligible: false,
40
+ adapted: false,
41
+ imagesOmitted: 0,
42
+ initialBodyBytes: null,
43
+ bodyBytes: null,
44
+ initialHistoricalImages: 0,
45
+ historicalImages: 0,
46
+ currentImages: initialImages.currentImages,
47
+ overBudget: false,
48
+ overImageBudget: false,
49
+ };
50
+ }
51
+
52
+ const initial = bodyPayload(body);
37
53
  const pressureEligible = initialImages.historicalImages > 0 && (
38
54
  initialImages.historicalImages > policy.triggerHistoricalImages
39
55
  || initial.bodyBytes > policy.triggerBodyBytes
@@ -70,12 +86,13 @@ export function applyResponsesImagePressure(reqContext, {
70
86
  initialBodyText: initial.bodyText,
71
87
  initialBodyBytes: initial.bodyBytes,
72
88
  });
89
+ const { bodyText: _bodyText, ...trimmedResult } = trimmed;
73
90
  return {
74
91
  mode: recovery ? "recovery" : "pressure",
75
92
  pressureEligible: pressureEligible || recovery,
76
93
  initialBodyBytes: initial.bodyBytes,
77
94
  initialHistoricalImages: initialImages.historicalImages,
78
- ...trimmed,
95
+ ...trimmedResult,
79
96
  };
80
97
  }
81
98
 
@@ -120,13 +120,13 @@ export async function proxyCopilotResponses(reqContext, req, res, upstream = cop
120
120
  try {
121
121
  opened = await openCopilotResponse(reqContext, upstream, options);
122
122
  } finally {
123
- options.releaseRequest?.();
123
+ options.releaseRequest?.(opened?.reqContext);
124
124
  }
125
125
  const { resp, errorText } = opened;
126
126
  reqContext = opened.reqContext;
127
127
  if (errorText !== undefined) {
128
128
  sendUpstreamError(res, resp, errorText);
129
- return { successful: false, compacted: false };
129
+ return { successful: false, compacted: false, upstreamStatus: resp.status };
130
130
  }
131
131
 
132
132
  if (reqContext.body.stream) {
@@ -245,6 +245,14 @@ export function prepareResponsesRequest(reqBody, { mutate = false } = {}) {
245
245
  };
246
246
  }
247
247
 
248
+ export function dropMaterializedResponseHistory(reqContext) {
249
+ if (!reqContext?.historyParentId || !Array.isArray(reqContext.historyInputItems)) return false;
250
+ reqContext.body = { ...reqContext.body, input: reqContext.historyInputItems };
251
+ reqContext.inputItems = reqContext.historyInputItems;
252
+ reqContext.currentInputStart = 0;
253
+ return true;
254
+ }
255
+
248
256
  export function rememberResponseHistory(reqContext, responseJson) {
249
257
  if (!responseJson?.id || !Array.isArray(reqContext?.historyInputItems || reqContext?.inputItems)) return;
250
258
  const compacted = isSuccessfulCompactionResponse(responseJson);
@@ -2,6 +2,7 @@ export const RUNTIME_DEFAULTS = Object.freeze({
2
2
  upstreamTimeoutMs: 120 * 1000,
3
3
  streamHandshakeTimeoutMs: 120 * 1000,
4
4
  streamIdleTimeoutMs: 120 * 1000,
5
+ requestBodyTimeoutMs: 120 * 1000,
5
6
  maxSseBufferBytes: 8 * 1024 * 1024,
6
7
  maxBodyBytes: 64 * 1024 * 1024,
7
8
  maxDecodedBodyBytes: 128 * 1024 * 1024,
@@ -24,6 +25,7 @@ export function loadRuntimeConfig(env = process.env) {
24
25
  upstreamTimeoutMs: parsePositiveInteger(env.CCDX_UPSTREAM_TIMEOUT_MS, RUNTIME_DEFAULTS.upstreamTimeoutMs),
25
26
  streamHandshakeTimeoutMs: parsePositiveInteger(env.CCDX_STREAM_HANDSHAKE_TIMEOUT_MS, RUNTIME_DEFAULTS.streamHandshakeTimeoutMs),
26
27
  streamIdleTimeoutMs: parsePositiveInteger(env.CCDX_STREAM_IDLE_TIMEOUT_MS, RUNTIME_DEFAULTS.streamIdleTimeoutMs),
28
+ requestBodyTimeoutMs: parsePositiveInteger(env.CCDX_REQUEST_BODY_TIMEOUT_MS, RUNTIME_DEFAULTS.requestBodyTimeoutMs),
27
29
  maxSseBufferBytes: parsePositiveInteger(env.CCDX_MAX_SSE_BUFFER_BYTES, RUNTIME_DEFAULTS.maxSseBufferBytes),
28
30
  maxBodyBytes: parsePositiveInteger(env.CCDX_MAX_BODY_BYTES, RUNTIME_DEFAULTS.maxBodyBytes),
29
31
  maxDecodedBodyBytes: parsePositiveInteger(env.CCDX_MAX_DECODED_BODY_BYTES, RUNTIME_DEFAULTS.maxDecodedBodyBytes),