codex-copilot-dx 0.6.4 → 0.6.6

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
@@ -111,7 +111,7 @@ active account or profile. GPT requests continue to use the bearer supplied by
111
111
  the current PM profile; exact enabled Anthropic model IDs use the isolated
112
112
  CCDX Claude profile. Neither credential is copied into the other application.
113
113
 
114
- 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
115
115
  macOS. Authorize the isolated Claude profile first, quit PM Studio and its
116
116
  updater, then run the one-time setup command:
117
117
 
@@ -120,14 +120,14 @@ ccdx auth login claude --reauth --github-login <personal-github-login>
120
120
  ccdx pms setup
121
121
  ```
122
122
 
123
- Inspect the installed version, exact patch integrity, isolated Claude profile,
124
- and relay without changing files:
123
+ Inspect the installed version, exact patch manifest plus executable/signing
124
+ integrity, isolated Claude profile, and live relay routing without changing files:
125
125
 
126
126
  ```bash
127
127
  ccdx pms status
128
128
  ```
129
129
 
130
- `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.
131
131
 
132
132
  `ccdx pms setup` performs only local preflight, backup, staging, integrity,
133
133
  signing, and replacement work. It never starts Device Flow, the adapter,
@@ -187,13 +187,13 @@ To change the model used by Codex Auto-review, run:
187
187
  ccdx auto-review-model
188
188
  ```
189
189
 
190
- The selector reads the current adapter model list, falling back to the fresh local
191
- 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.
192
193
  It saves the selection in `~/.config/codex-copilot-dx/config.json` (or under
193
194
  `XDG_CONFIG_HOME` when set); choosing `gpt-5.5` clears the override and restores
194
- the package default. A running 0.5.1+
195
- adapter reads the setting on the next Auto-review request, so it does not need to
196
- 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.
197
197
 
198
198
  To update the globally installed package, choose a source interactively:
199
199
 
@@ -213,26 +213,26 @@ ccdx update github
213
213
  The npm source installs `codex-copilot-dx@latest` through the registry already
214
214
  configured for npm, including a company mirror. The GitHub source installs the
215
215
  latest commit from `DaleXiao/codex-copilot-dx` `main` and opts in to Git fetching
216
- for that command, as required by npm 12. It does not change npm's persistent
217
- `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
218
218
  interpolation. A currently running adapter keeps its loaded version until it is
219
219
  stopped and started again.
220
220
  If a configured npm mirror has not synchronized the current release yet, use
221
221
  the GitHub source to install the current `main` revision.
222
222
 
223
- On first run, it will:
224
- 1. Authenticate with GitHub via device flow (if needed), after first trying compatible local Copilot token sources
225
- 2. Print the local package version and check for a newer npm release in the background
226
- 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`)
227
227
  4. Configure Codex (`~/.codex/config.toml`) to use the adapter, including stale shell env base URLs if present
228
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
229
- 6. Launch Codex Desktop
229
+ 6. On macOS, attempt to launch Codex or ChatGPT unless `CCDX_AUTO_LAUNCH` disables it
230
230
 
231
231
  Claude Code picks up the new `ANTHROPIC_BASE_URL` on its next launch.
232
232
 
233
- 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.
234
234
 
235
- 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.
236
236
 
237
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.
238
238
 
@@ -337,7 +337,7 @@ Environment variables:
337
337
  | `CCDX_REQUEST_BODY_TIMEOUT_MS` | `120000` | Maximum time to receive and decode one request body before returning `408` |
338
338
  | `CCDX_MAX_UPSTREAM_BODY_BYTES` | `31457280` | Strict maximum forwarded Responses body size; larger payloads are adapted locally or rejected with `413` |
339
339
  | `CCDX_MAX_SSE_BUFFER_BYTES` | `8388608` | Maximum buffered bytes for one unterminated upstream SSE line/event |
340
- | `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 |
341
341
  | `CCDX_STREAM_HANDSHAKE_TIMEOUT_MS` | `120000` | Timeout while waiting for upstream streaming response headers |
342
342
  | `CCDX_STREAM_IDLE_TIMEOUT_MS` | `120000` | Maximum idle time between upstream streaming body chunks |
343
343
  | `CCDX_UPSTREAM_RETRIES` | `2` | Retries for safe requests and clearly pre-connect POST failures; capped at `5` |
@@ -398,7 +398,7 @@ The final serialized UTF-8 request body is measured before forwarding. Above the
398
398
 
399
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.
400
400
 
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 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.
402
402
 
403
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.
404
404
 
package/bin/cli.mjs CHANGED
@@ -316,6 +316,7 @@ try {
316
316
  codexModelRegistry: profileModels.codexRegistry,
317
317
  claudeModelRegistry: profileModels.claudeRegistry,
318
318
  claudeMode: profileRuntime.claudeMode,
319
+ isClaudeProfileCurrent: profileRuntime.isClaudeProfileCurrent,
319
320
  showRequestId: CLI.showRequestId,
320
321
  upstreamTimeoutMs: RUNTIME.upstreamTimeoutMs,
321
322
  streamHandshakeTimeoutMs: RUNTIME.streamHandshakeTimeoutMs,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-copilot-dx",
3
- "version": "0.6.4",
3
+ "version": "0.6.6",
4
4
  "description": "Use Codex Desktop, Claude Code, and Claude App with GitHub Copilot",
5
5
  "author": "Dale Xiao",
6
6
  "type": "module",
@@ -23,6 +23,14 @@ function runProbe(source) {
23
23
  return JSON.parse(result.stdout.trim());
24
24
  }
25
25
 
26
+ function median(values) {
27
+ const ordered = [...values].sort((left, right) => left - right);
28
+ const middle = Math.floor(ordered.length / 2);
29
+ return ordered.length % 2 === 0
30
+ ? (ordered[middle - 1] + ordered[middle]) / 2
31
+ : ordered[middle];
32
+ }
33
+
26
34
  function deterministicPixels(byteLength, initialSeed) {
27
35
  const pixels = Buffer.alloc(byteLength);
28
36
  let seed = initialSeed;
@@ -154,7 +162,7 @@ function largePayloadProbe(targetMiB, concurrency) {
154
162
  }
155
163
 
156
164
  function largeTokenCountProbe(mode) {
157
- return runProbe(`
165
+ const samples = Array.from({ length: 3 }, () => runProbe(`
158
166
  const text = "a ".repeat(2 * 1024 * 1024);
159
167
  const started = performance.now();
160
168
  let tokens;
@@ -174,6 +182,51 @@ function largeTokenCountProbe(mode) {
174
182
  rss_mib: +(memory.rss / 1048576).toFixed(1),
175
183
  heap_used_mib: +(memory.heapUsed / 1048576).toFixed(1),
176
184
  }));
185
+ `));
186
+ return {
187
+ input_mib: samples[0].input_mib,
188
+ tokens: samples[0].tokens,
189
+ elapsed_ms: +median(samples.map((sample) => sample.elapsed_ms)).toFixed(1),
190
+ rss_mib: +median(samples.map((sample) => sample.rss_mib)).toFixed(1),
191
+ heap_used_mib: +median(samples.map((sample) => sample.heap_used_mib)).toFixed(1),
192
+ samples: samples.length,
193
+ };
194
+ }
195
+
196
+ function toolOutputParseCacheProbe() {
197
+ return runProbe(`
198
+ import { enforceResponsesImageLimit } from "./src/responses-image-limit.mjs";
199
+ import { responsesHistoricalImageStats } from "./src/responses-byte-budget.mjs";
200
+ import { prepareResponsesChatPayload } from "./src/responses-chat-payload.mjs";
201
+
202
+ const item = {
203
+ type: "function_call_output",
204
+ call_id: "call_benchmark",
205
+ output: JSON.stringify([{ type: "input_text", text: "x".repeat(8 * 1024 * 1024) }]),
206
+ };
207
+ const source = item.output;
208
+ const originalParse = JSON.parse;
209
+ let parseCalls = 0;
210
+ JSON.parse = function countedParse(value, ...args) {
211
+ if (value === source) parseCalls += 1;
212
+ return originalParse.call(this, value, ...args);
213
+ };
214
+ const started = performance.now();
215
+ try {
216
+ enforceResponsesImageLimit([item]);
217
+ responsesHistoricalImageStats([item], 1);
218
+ await prepareResponsesChatPayload({
219
+ body: { model: "gpt-4o", input: [item] },
220
+ currentInputStart: 1,
221
+ }, { payloadOptions: { maxBytes: 30 * 1024 * 1024 } });
222
+ } finally {
223
+ JSON.parse = originalParse;
224
+ }
225
+ process.stdout.write(JSON.stringify({
226
+ output_mib: +(Buffer.byteLength(source) / 1048576).toFixed(1),
227
+ parse_calls: parseCalls,
228
+ elapsed_ms: +(performance.now() - started).toFixed(1),
229
+ }));
177
230
  `);
178
231
  }
179
232
 
@@ -373,6 +426,7 @@ if (checkMode) {
373
426
  proxy: largeTokenCountProbe("proxy"),
374
427
  materialized_array: largeTokenCountProbe("array"),
375
428
  };
429
+ report.tool_output_parse_cache = toolOutputParseCacheProbe();
376
430
  report.large_payload_peak = [
377
431
  largePayloadProbe(5, 4),
378
432
  largePayloadProbe(30, 1),
@@ -409,6 +463,9 @@ if (checkMode) {
409
463
  if (proxy.heap_used_mib > array.heap_used_mib * 0.75) {
410
464
  failures.push(`token counter heap regression: proxy=${proxy.heap_used_mib}MiB array=${array.heap_used_mib}MiB`);
411
465
  }
466
+ if (report.tool_output_parse_cache.parse_calls !== 1) {
467
+ failures.push(`stringified tool output parsed ${report.tool_output_parse_cache.parse_calls} times instead of once`);
468
+ }
412
469
  for (const payload of report.large_payload_peak) {
413
470
  if (payload.admission_max_active_mib > payload.admission_budget_mib) {
414
471
  failures.push(`request admission exceeded its budget at ${payload.target_request_mib}MiB x${payload.concurrency}`);
package/src/adapter.mjs CHANGED
@@ -215,6 +215,7 @@ export function createAdapterHandler(options = {}) {
215
215
  codexModelRegistry,
216
216
  claudeModelRegistry,
217
217
  claudeMode,
218
+ isClaudeProfileCurrent: options.isClaudeProfileCurrent,
218
219
  })));
219
220
  return;
220
221
  }
@@ -366,11 +367,9 @@ export function startAdapter(port = 2026, host = "127.0.0.1", options = {}) {
366
367
  server.once("close", cleanupTerminalActivity);
367
368
 
368
369
  server.on("upgrade", (req, socket) => {
369
- // Codex Desktop 0.130+ negotiates a "responses_websockets" server-push
370
- // protocol on WS upgrade. We don't implement that protocol; accepting the
371
- // upgrade and waiting for a client request just hangs and triggers a
372
- // 5-attempt reconnect storm. Refuse the upgrade so Codex falls back to
373
- // plain HTTP SSE, which this adapter handles correctly.
370
+ // The responses_websockets upgrade expects a server-push protocol that this
371
+ // adapter does not implement. Refuse it so the client can fall back to the
372
+ // supported HTTP SSE transport instead of waiting on an unusable socket.
374
373
  socket.write("HTTP/1.1 426 Upgrade Required\r\nConnection: close\r\nContent-Length: 0\r\n\r\n");
375
374
  socket.destroy();
376
375
  });
@@ -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
  }
package/src/cli-auth.mjs CHANGED
@@ -21,6 +21,7 @@ import {
21
21
  normalizeGithubIdentity,
22
22
  } from "./github-identity.mjs";
23
23
  import { saveModelCache } from "./model-cache.mjs";
24
+ import { isClaudeCopilotModel } from "./models.mjs";
24
25
  import { profileRouting } from "./profile-routing.mjs";
25
26
  import { status } from "./status.mjs";
26
27
 
@@ -145,17 +146,7 @@ async function codexIdentity({ home, fetchImpl, signal }) {
145
146
  function claudeModels(payload) {
146
147
  const models = Array.isArray(payload) ? payload : payload?.data;
147
148
  if (!Array.isArray(models)) throw new Error("Claude account model catalog contained no model list");
148
- return models.filter((model) => {
149
- const id = String(model?.id || "").trim();
150
- const vendor = String(model?.vendor || model?.owned_by || "").trim().toLowerCase();
151
- const policy = String(model?.policy?.state || "").trim().toLowerCase();
152
- const endpoints = Array.isArray(model?.supported_endpoints) ? model.supported_endpoints : [];
153
- return Boolean(id)
154
- && (id.toLowerCase().startsWith("claude-") || vendor === "anthropic")
155
- && model?.model_picker_enabled === true
156
- && (!policy || policy === "enabled")
157
- && (endpoints.includes("/chat/completions") || endpoints.includes("/v1/messages"));
158
- });
149
+ return models.filter(isClaudeCopilotModel);
159
150
  }
160
151
 
161
152
  function catalogModels(payload) {
@@ -6,6 +6,10 @@ import {
6
6
  readAuthProfileCredentials,
7
7
  } from "./auth-profile.mjs";
8
8
  import { buildHeaders, DEFAULT_API_BASE, FALLBACK_VSCODE_VERSION } from "./copilot.mjs";
9
+ import {
10
+ isClaudeCopilotCatalogEntry,
11
+ isClaudeCopilotModel,
12
+ } from "./models.mjs";
9
13
  import { status } from "./status.mjs";
10
14
 
11
15
  const DEFAULT_MODELS_TIMEOUT_MS = 10000;
@@ -37,11 +41,15 @@ export function selectableCopilotModels(payload) {
37
41
  const models = [];
38
42
  const seen = new Set();
39
43
  for (const model of data) {
40
- if (model?.model_picker_enabled !== true) continue;
41
- const policyState = safeText(model?.policy?.state).toLowerCase();
42
- if (policyState && policyState !== "enabled") continue;
43
- const capabilityType = safeText(model?.capabilities?.type).toLowerCase();
44
- if (capabilityType && capabilityType !== "chat") continue;
44
+ if (isClaudeCopilotCatalogEntry(model)) {
45
+ if (!isClaudeCopilotModel(model)) continue;
46
+ } else {
47
+ if (model?.model_picker_enabled !== true) continue;
48
+ const policyState = safeText(model?.policy?.state).toLowerCase();
49
+ if (policyState && policyState !== "enabled") continue;
50
+ const capabilityType = safeText(model?.capabilities?.type).toLowerCase();
51
+ if (capabilityType && capabilityType !== "chat") continue;
52
+ }
45
53
 
46
54
  const id = safeText(model?.id);
47
55
  const endpoints = endpointLabels(model);
@@ -163,8 +171,7 @@ export function formatLiveCopilotModels(catalog, { commandName = "ccdx" } = {})
163
171
  const profile = catalog?.profile === AUTH_PROFILE_CLAUDE ? AUTH_PROFILE_CLAUDE : AUTH_PROFILE_CODEX;
164
172
  const responses = models.filter((model) => model.endpoints.includes("responses")).length;
165
173
  const chat = models.filter((model) => model.endpoints.includes("chat")).length;
166
- const claude = models.filter((model) => model.id.toLowerCase().startsWith("claude-")
167
- || model.vendor.toLowerCase() === "anthropic").length;
174
+ const claude = models.filter(isClaudeCopilotCatalogEntry).length;
168
175
  const lines = [
169
176
  `${commandName} models${profile === AUTH_PROFILE_CLAUDE ? " --profile claude" : ""}`,
170
177
  status("ok", `Live catalog from ${safeText(catalog?.upstreamHost, "GitHub Copilot")}: ${models.length} selectable of ${Number(catalog?.advertised) || 0} advertised`),
@@ -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.`,
@@ -150,6 +150,7 @@ export function createCopilotClientRuntime({
150
150
  }
151
151
 
152
152
  async function responses(reqBody, {
153
+ assertActive,
153
154
  signal,
154
155
  fetchImpl,
155
156
  retryOptions,
@@ -160,13 +161,14 @@ export function createCopilotClientRuntime({
160
161
  } = {}) {
161
162
  const token = await tokenSession.getToken({ signal });
162
163
  const preparedPayload = await prepareResponsesPayload(reqBody, {
164
+ assertActive,
163
165
  ...payloadOptions,
164
166
  currentInputStart,
165
167
  profiles: payloadPrepared ? [] : payloadOptions.profiles,
166
168
  skipInitialOptimization: payloadPrepared || payloadOptions.skipInitialOptimization,
167
169
  signal,
168
170
  });
169
- const finalizedPayload = enforceResponsesPayloadByteBudget(reqBody, preparedPayload);
171
+ const finalizedPayload = enforceResponsesPayloadByteBudget(reqBody, preparedPayload, { assertActive });
170
172
  const { bodyText, bodyBytes, stage, adapted } = finalizedPayload;
171
173
  const summary = finalizedPayload.stage === preparedPayload.stage
172
174
  ? preparedPayload.summary
package/src/doctor.mjs CHANGED
@@ -12,7 +12,7 @@ import { claudeDesktopPaths } from "./claude-desktop-config.mjs";
12
12
  import { status } from "./status.mjs";
13
13
  import { buildHeaders, DEFAULT_API_BASE, FALLBACK_VSCODE_VERSION } from "./copilot.mjs";
14
14
  import { adapterBaseUrl, checkRunningAdapter } from "./running-adapter.mjs";
15
- import { CODEX_AUTO_REVIEW_MODEL } from "./models.mjs";
15
+ import { CODEX_AUTO_REVIEW_MODEL, isClaudeCopilotModel } from "./models.mjs";
16
16
  import { loadModelCache } from "./model-cache.mjs";
17
17
 
18
18
  function localGatewayBaseUrl(host, port) {
@@ -66,7 +66,7 @@ function modelEndpoints(model) {
66
66
 
67
67
  export function selectCompatibilityModels(models, { claudeModels = models } = {}) {
68
68
  const data = copilotModelData(models).filter((model) => model?.model_picker_enabled !== false);
69
- const claudeData = copilotModelData(claudeModels).filter((model) => model?.model_picker_enabled !== false);
69
+ const claudeData = copilotModelData(claudeModels);
70
70
  const responsesCandidates = data.filter((model) => {
71
71
  const id = String(model?.id || "");
72
72
  const endpoints = modelEndpoints(model);
@@ -74,12 +74,7 @@ export function selectCompatibilityModels(models, { claudeModels = models } = {}
74
74
  });
75
75
  const responsesOnly = [...responsesCandidates].reverse()
76
76
  .find((model) => !modelEndpoints(model).includes("/chat/completions"));
77
- const claude = claudeData.find((model) => {
78
- const id = String(model?.id || "");
79
- const vendor = String(model?.vendor || "").toLowerCase();
80
- return (id.startsWith("claude-") || vendor === "anthropic")
81
- && modelEndpoints(model).includes("/chat/completions");
82
- });
77
+ const claude = claudeData.find(isClaudeCopilotModel);
83
78
  return {
84
79
  responsesModel: String((responsesOnly || responsesCandidates[0])?.id || ""),
85
80
  claudeModel: String(claude?.id || ""),
@@ -588,6 +583,12 @@ export async function collectDoctorChecks({
588
583
  }
589
584
  if (!pm.adapter?.ok) {
590
585
  checks.push({ kind: "warn", message: "PM Studio relay is not currently available", fix: "ccdx start" });
586
+ } else if (!pm.runtime?.ok) {
587
+ checks.push({
588
+ kind: "warn",
589
+ message: "PM Studio relay is running, but its isolated routing is not ready",
590
+ fix: "stop the running adapter, then run ccdx start",
591
+ });
591
592
  }
592
593
  } else if (pm.app.state === "clean") {
593
594
  checks.push({
@@ -47,29 +47,56 @@ function admissionAbortError(signal) {
47
47
  return error;
48
48
  }
49
49
 
50
- function createSupplementalAdmission({ maxWeight, maxQueued, waitTimeoutMs, label }) {
50
+ function createSupplementalAdmission({ maxWeight, maxQueued, waitTimeoutMs, label, blockBehindExclusive = false }) {
51
51
  const queue = [];
52
52
  let activeWeight = 0;
53
+ let activeRequestedWeight = 0;
53
54
  let activeRequests = 0;
54
55
 
55
56
  const activate = (entry) => {
56
57
  entry.cleanup();
57
58
  activeWeight += entry.weight;
59
+ activeRequestedWeight += entry.requestedWeight;
58
60
  activeRequests += 1;
59
61
  let released = false;
60
- entry.resolve(() => {
62
+ const release = () => {
61
63
  if (released) return;
62
64
  released = true;
63
65
  activeWeight = Math.max(0, activeWeight - entry.weight);
66
+ activeRequestedWeight = Math.max(0, activeRequestedWeight - entry.requestedWeight);
64
67
  activeRequests = Math.max(0, activeRequests - 1);
65
68
  drain();
69
+ };
70
+ release.resize = (requestedWeight) => {
71
+ if (released) return 0;
72
+ const parsedWeight = Number.isFinite(requestedWeight) && requestedWeight > 0
73
+ ? Math.ceil(requestedWeight)
74
+ : maxWeight;
75
+ const requested = Math.max(1, parsedWeight);
76
+ const weight = Math.min(requested, maxWeight);
77
+ if (weight > entry.weight) {
78
+ throw new RangeError(`${label} admission weight cannot grow`);
79
+ }
80
+ activeWeight = Math.max(0, activeWeight - (entry.weight - weight));
81
+ activeRequestedWeight = Math.max(0, activeRequestedWeight + requested - entry.requestedWeight);
82
+ entry.weight = weight;
83
+ entry.requestedWeight = requested;
84
+ drain();
85
+ return weight;
86
+ };
87
+ Object.defineProperties(release, {
88
+ weight: { get: () => released ? 0 : entry.weight },
89
+ requestedWeight: { get: () => released ? 0 : entry.requestedWeight },
90
+ maxWeight: { value: maxWeight },
66
91
  });
92
+ entry.resolve(release);
67
93
  };
68
94
 
69
95
  const drain = () => {
70
96
  for (let index = 0; index < queue.length;) {
71
97
  const entry = queue[index];
72
98
  if (activeWeight + entry.weight > maxWeight) {
99
+ if (blockBehindExclusive && entry.weight === maxWeight) break;
73
100
  index += 1;
74
101
  continue;
75
102
  }
@@ -83,8 +110,12 @@ function createSupplementalAdmission({ maxWeight, maxQueued, waitTimeoutMs, labe
83
110
  const parsedWeight = Number.isFinite(requestedWeight) && requestedWeight > 0
84
111
  ? Math.ceil(requestedWeight)
85
112
  : maxWeight;
86
- const weight = Math.max(1, Math.min(parsedWeight, maxWeight));
87
- if (queue.length >= maxQueued && activeWeight + weight > maxWeight) {
113
+ const requested = Math.max(1, parsedWeight);
114
+ const weight = Math.min(requested, maxWeight);
115
+ const blockedByExclusive = blockBehindExclusive
116
+ && queue.some((entry) => entry.weight === maxWeight);
117
+ const mustWait = blockedByExclusive || activeWeight + weight > maxWeight;
118
+ if (queue.length >= maxQueued && mustWait) {
88
119
  return Promise.reject(httpError(`${label} queue is full (${maxQueued} waiting)`, 503));
89
120
  }
90
121
 
@@ -92,6 +123,7 @@ function createSupplementalAdmission({ maxWeight, maxQueued, waitTimeoutMs, labe
92
123
  let timer;
93
124
  const entry = {
94
125
  weight,
126
+ requestedWeight: requested,
95
127
  resolve,
96
128
  cleanup: () => {
97
129
  if (timer) clearTimeout(timer);
@@ -120,7 +152,13 @@ function createSupplementalAdmission({ maxWeight, maxQueued, waitTimeoutMs, labe
120
152
  });
121
153
  };
122
154
 
123
- acquire.stats = () => ({ activeWeight, activeRequests, queued: queue.length, maxWeight });
155
+ acquire.stats = () => ({
156
+ activeWeight,
157
+ activeRequestedWeight,
158
+ activeRequests,
159
+ queued: queue.length,
160
+ maxWeight,
161
+ });
124
162
  return acquire;
125
163
  }
126
164
 
@@ -143,6 +181,7 @@ export function createRequestAdmission({
143
181
  maxQueued: queueLimit,
144
182
  waitTimeoutMs: timeoutMs,
145
183
  label: "Decoded request body",
184
+ blockBehindExclusive: true,
146
185
  });
147
186
  const acquireResponseHistory = createSupplementalAdmission({
148
187
  maxWeight: byteLimit,
@@ -194,10 +233,30 @@ export function createRequestAdmission({
194
233
  releaseSupplemental();
195
234
  throw admissionAbortError(options?.signal);
196
235
  }
197
- supplementalReleases.add(releaseSupplemental);
236
+ let supplementalReleased = false;
237
+ const releaseReservation = () => {
238
+ if (supplementalReleased) return;
239
+ supplementalReleased = true;
240
+ supplementalReleases.delete(releaseReservation);
241
+ releaseSupplemental();
242
+ };
243
+ releaseReservation.resize = (nextWeight) => {
244
+ if (supplementalReleased) return 0;
245
+ return releaseSupplemental.resize(nextWeight);
246
+ };
247
+ Object.defineProperties(releaseReservation, {
248
+ weight: { get: () => supplementalReleased ? 0 : releaseSupplemental.weight },
249
+ requestedWeight: {
250
+ get: () => supplementalReleased ? 0 : releaseSupplemental.requestedWeight,
251
+ },
252
+ maxWeight: { value: releaseSupplemental.maxWeight },
253
+ });
254
+ supplementalReleases.add(releaseReservation);
255
+ return releaseReservation;
198
256
  };
199
257
  release.acquireDecompression = (options) => acquireDecompression(1, options);
200
258
  release.reserveDecodedBody = (bytes, options) => reserve(acquireDecodedBody, bytes, options);
259
+ release.reserveDecodedBody.supportsResize = true;
201
260
  release.reserveResponseHistory = (bytes, options) => reserve(acquireResponseHistory, bytes, options);
202
261
  entry.resolve(release);
203
262
  };
@@ -285,7 +344,8 @@ export function createRequestAdmission({
285
344
  waitMsMax: counters.waitMsMax,
286
345
  decompressionsActive: acquireDecompression.stats().activeRequests,
287
346
  decompressionsQueued: acquireDecompression.stats().queued,
288
- decodedBodyBytes: acquireDecodedBody.stats().activeWeight,
347
+ decodedBodyBytes: acquireDecodedBody.stats().activeRequestedWeight,
348
+ decodedBodyAdmissionBytes: acquireDecodedBody.stats().activeWeight,
289
349
  decodedBodiesActive: acquireDecodedBody.stats().activeRequests,
290
350
  decodedBodiesQueued: acquireDecodedBody.stats().queued,
291
351
  responseHistoryBytes: acquireResponseHistory.stats().activeWeight,
@@ -445,21 +505,62 @@ export async function readJsonBody(req, {
445
505
  return parseRequestJson(await readIdentityText(req, maxBodyBytes, maxDecodedBodyBytes, signal));
446
506
  }
447
507
  const buffer = await readRequestBuffer(req, maxBodyBytes, signal);
448
- const releaseDecompression = await admission?.acquireDecompression?.({ signal }) || (() => {});
508
+ let releaseDecompression = () => {};
509
+ let decodedReservation;
510
+ let retainDecodedReservation = false;
449
511
  try {
450
512
  let decoded;
513
+ const supportsResizableReservation = admission?.reserveDecodedBody?.supportsResize === true;
514
+ // Most compressed bodies fit the existing 4x admission estimate. High-ratio bodies retry
515
+ // only after obtaining the pool's exclusive reservation, so a full decoded body never waits
516
+ // outside the decoded-body budget.
517
+ const estimatedDecodedBytes = Math.max(
518
+ 1,
519
+ Math.min(buffer.length * COMPRESSED_BODY_WEIGHT_MULTIPLIER, maxDecodedBodyBytes),
520
+ );
521
+ if (supportsResizableReservation) {
522
+ decodedReservation = await admission.reserveDecodedBody(estimatedDecodedBytes, { signal });
523
+ }
524
+ releaseDecompression = await admission?.acquireDecompression?.({ signal }) || (() => {});
525
+ const reservedBytes = decodedReservation?.requestedWeight;
526
+ const reservedAdmissionBytes = decodedReservation?.weight;
527
+ const decodedPoolBytes = decodedReservation?.maxWeight;
528
+ const tentativeDecodeLimit = Number.isFinite(reservedBytes)
529
+ && Number.isFinite(reservedAdmissionBytes)
530
+ && Number.isFinite(decodedPoolBytes)
531
+ && reservedAdmissionBytes < decodedPoolBytes
532
+ ? Math.min(reservedBytes, maxDecodedBodyBytes)
533
+ : maxDecodedBodyBytes;
451
534
  try {
452
- decoded = await decodeRequestBuffer(buffer, req.headers?.["content-encoding"], maxDecodedBodyBytes);
535
+ decoded = await decodeRequestBuffer(buffer, req.headers?.["content-encoding"], tentativeDecodeLimit);
453
536
  } catch (e) {
454
- if (e?.statusCode) throw e;
455
- throw httpError(`Invalid compressed request body: ${e.message}`, 400);
537
+ if (e?.statusCode === 413 && tentativeDecodeLimit < maxDecodedBodyBytes && supportsResizableReservation) {
538
+ releaseDecompression();
539
+ releaseDecompression = () => {};
540
+ decodedReservation();
541
+ decodedReservation = await admission.reserveDecodedBody(maxDecodedBodyBytes, { signal });
542
+ releaseDecompression = await admission.acquireDecompression?.({ signal }) || (() => {});
543
+ try {
544
+ decoded = await decodeRequestBuffer(buffer, req.headers?.["content-encoding"], maxDecodedBodyBytes);
545
+ } catch (retryError) {
546
+ if (retryError?.statusCode) throw retryError;
547
+ throw httpError(`Invalid compressed request body: ${retryError.message}`, 400);
548
+ }
549
+ } else {
550
+ if (e?.statusCode) throw e;
551
+ throw httpError(`Invalid compressed request body: ${e.message}`, 400);
552
+ }
456
553
  }
457
554
  if (signal?.aborted) throw admissionAbortError(signal);
458
555
  if (decoded.length > 0) {
459
- await admission?.reserveDecodedBody?.(decoded.length, { signal });
556
+ if (supportsResizableReservation) decodedReservation.resize(decoded.length);
557
+ else decodedReservation = await admission?.reserveDecodedBody?.(decoded.length, { signal });
460
558
  }
461
- return parseRequestJson(decoded.toString("utf8"));
559
+ const parsed = parseRequestJson(decoded.toString("utf8"));
560
+ retainDecodedReservation = true;
561
+ return parsed;
462
562
  } finally {
563
+ if (!retainDecodedReservation) decodedReservation?.();
463
564
  releaseDecompression();
464
565
  }
465
566
  }