codex-copilot-dx 0.4.33 → 0.4.35

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
@@ -64,7 +64,15 @@ codex-copilot-dx doctor --online
64
64
 
65
65
  The online doctor never starts device flow, scans for replacement tokens, or changes the saved token.
66
66
 
67
- When the saved token is missing, `codex-copilot-dx` first looks for compatible local Copilot GitHub tokens, validates them with GitHub and Copilot, and imports a valid one before starting device login. It checks explicit token sources (`CCDX_GITHUB_TOKEN`, `CCDX_GITHUB_TOKEN_PATH`, `CCDX_GITHUB_TOKEN_PATHS`) plus common local `auth.json` layouts under application config directories. It does not rely on a specific app name. Generic discovery refuses to choose silently when valid tokens for multiple GitHub accounts are found. After an account is selected, automatic `401`/`403` recovery accepts only the same GitHub account. Explicit token variables remain the intentional way to switch accounts.
67
+ To actively verify the protocol path through an already-running adapter, run:
68
+
69
+ ```bash
70
+ codex-copilot-dx doctor --compat
71
+ ```
72
+
73
+ The compatibility doctor sends a few minimal Copilot requests to check Codex Auto-review, native Responses, streaming history, compaction, image tool namespace handling, and Anthropic streaming. It consumes a small amount of Copilot usage, never starts the adapter or device flow, and does not change client configuration. Combine it with `--online` when both the saved-token entitlement check and the adapter protocol checks are needed.
74
+
75
+ When the saved token is missing, `codex-copilot-dx` first looks for compatible local Copilot GitHub tokens, validates them with GitHub and Copilot, and imports a valid one before starting device login. It checks explicit token sources (`CCDX_GITHUB_TOKEN`, `CCDX_GITHUB_TOKEN_PATH`, `CCDX_GITHUB_TOKEN_PATHS`) plus common local `auth.json` layouts under application config directories. It does not rely on a specific app name. Generic discovery refuses to choose silently when valid tokens for multiple GitHub accounts are found. After an account is selected, automatic `401`/`403` recovery and in-process token rotation accept only the same GitHub account. Concurrent callers share token refresh work without sharing cancellation, and a still-valid Copilot token may be used briefly after a transient refresh failure. Explicit token variables remain the intentional way to switch accounts.
68
76
 
69
77
  If Copilot token refresh still fails with `401` or `403`, the saved GitHub token may be expired, revoked, or missing Copilot access. Delete the saved token and start the tool again to trigger GitHub device login:
70
78
 
@@ -102,12 +110,15 @@ Environment variables:
102
110
  | `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 |
103
111
  | `CCDX_MAX_BODY_BYTES` | `67108864` | Maximum compressed/raw request body size |
104
112
  | `CCDX_MAX_DECODED_BODY_BYTES` | `134217728` | Maximum decoded request body size after decompression |
113
+ | `CCDX_MAX_INFLIGHT_BODY_BYTES` | `33554432` | Shared byte budget for admitted request bodies; a larger single request runs exclusively |
114
+ | `CCDX_MAX_QUEUED_REQUESTS` | `16` | Maximum body requests waiting for the shared byte budget |
115
+ | `CCDX_REQUEST_QUEUE_TIMEOUT_MS` | `120000` | Maximum wait for request-body admission before returning `503` |
105
116
  | `CCDX_MAX_UPSTREAM_BODY_BYTES` | `31457280` | Responses body target; larger image payloads are compressed more aggressively before forwarding |
106
117
  | `CCDX_MAX_SSE_BUFFER_BYTES` | `8388608` | Maximum buffered bytes for one unterminated upstream SSE line/event |
107
118
  | `CCDX_UPSTREAM_TIMEOUT_MS` | `120000` | Timeout for non-streaming upstream Copilot requests |
108
119
  | `CCDX_STREAM_HANDSHAKE_TIMEOUT_MS` | `120000` | Timeout while waiting for upstream streaming response headers |
109
120
  | `CCDX_STREAM_IDLE_TIMEOUT_MS` | `120000` | Maximum idle time between upstream streaming body chunks |
110
- | `CCDX_UPSTREAM_RETRIES` | `2` | Retries for transient Copilot upstream network errors; capped at `5` |
121
+ | `CCDX_UPSTREAM_RETRIES` | `2` | Retries for safe requests and clearly pre-connect POST failures; capped at `5` |
111
122
  | `CCDX_UPSTREAM_RETRY_DELAY_MS` | `300` | Initial upstream retry backoff in milliseconds; capped at `5000` |
112
123
  | `CCDX_AUTO_REVIEW_MODEL` | `gpt-5.5` | Copilot Responses model used for Codex Auto-review requests |
113
124
  | `CCDX_LOG_PATH` | unset | Mirror terminal logs to a file; set to `1` for `~/.local/share/codex-copilot-dx/debug.log` |
@@ -167,6 +178,7 @@ MIT
167
178
  ```bash
168
179
  npm ci
169
180
  npm run verify
181
+ npm run bench:payload
170
182
  ```
171
183
 
172
- `npm test` runs the unit and handler-level suite. `npm run test:smoke` starts a real local HTTP adapter with fully injected offline upstreams. `npm run pack:check` verifies the npm tarball contents without publishing. The CI workflow runs all three checks on supported Node.js release lines.
184
+ `npm test` runs the unit and handler-level suite. `npm run test:smoke` starts a real local HTTP adapter with fully injected offline upstreams. `npm run bench:check` enforces relative token-counting performance and request-admission resource limits. `npm run pack:check` verifies the npm tarball contents without publishing. `npm run bench:payload` remains a report-only, isolated-process benchmark for 5-60 MiB image payloads and does not contact Copilot. The CI workflow runs the verification checks on supported Node.js release lines.
package/bin/cli.mjs CHANGED
@@ -6,13 +6,13 @@ import { ensureClaudeConfig } from "../src/claude-config.mjs";
6
6
  import { applyClaudeDesktopConfig, formatClaudeDesktopApplyResult, generatedClaudeDesktopApiKey, loadManagedClaudeDesktopApiKey } from "../src/claude-desktop-config.mjs";
7
7
  import { startAdapter } from "../src/adapter.mjs";
8
8
  import { cacheModelEndpoints, listModels, refreshVSCodeVersion } from "../src/copilot.mjs";
9
- import { claudeDesktopModelDefsFromCopilotModels, claudeDesktopModelIds, gptModelIdsFromCopilotModels, parseModelAliasEnv } from "../src/models.mjs";
9
+ import { claudeDesktopModelDefsFromCopilotModels, claudeDesktopModelIds, codexAutoReviewModelStatus, gptModelIdsFromCopilotModels, parseModelAliasEnv } from "../src/models.mjs";
10
10
  import { status } from "../src/status.mjs";
11
11
  import { configureLogging } from "../src/log.mjs";
12
12
  import { flushUsageWrites, printUsageSummary } from "../src/usage.mjs";
13
13
  import { checkForUpdate, localPackageVersion } from "../src/version.mjs";
14
14
  import { runDoctor } from "../src/doctor.mjs";
15
- import { checkRunningAdapter } from "../src/running-adapter.mjs";
15
+ import { adapterBaseUrl, checkRunningAdapter } from "../src/running-adapter.mjs";
16
16
  import { assertSafeAdapterHost, isLoopbackHost } from "../src/security.mjs";
17
17
  import { isValidModelList, loadModelCache, saveModelCache } from "../src/model-cache.mjs";
18
18
  import { initializeModelRegistry, runInBackground } from "../src/startup.mjs";
@@ -105,6 +105,10 @@ async function refreshClaudeDesktopModelDefs() {
105
105
  throw new Error(`Copilot models returned HTTP ${httpStatus}`);
106
106
  }
107
107
  const models = JSON.parse(body);
108
+ const autoReview = codexAutoReviewModelStatus(models);
109
+ if (!autoReview.available) {
110
+ console.log(status("warn", `Auto-review target ${autoReview.upstreamModel} is unavailable: ${autoReview.reason}. Run codex-copilot-dx doctor --compat to verify the live path.`));
111
+ }
108
112
  const gptModelIds = gptModelIdsFromCopilotModels(models);
109
113
  if (gptModelIds.length) {
110
114
  console.log(status("ok", `Refreshed GPT models from GitHub Copilot: ${gptModelIds.join(", ")}`));
@@ -152,8 +156,8 @@ async function reuseRunningAdapterIfAvailable() {
152
156
  if (!running.ok) return false;
153
157
 
154
158
  console.log(status("ok", `Using existing adapter at ${running.baseUrl}`));
155
- ensureCodexConfig(ADAPTER_PORT);
156
- ensureClaudeConfig(ADAPTER_PORT);
159
+ ensureCodexConfig(ADAPTER_PORT, { host: ADAPTER_HOST });
160
+ ensureClaudeConfig(ADAPTER_PORT, { host: ADAPTER_HOST });
157
161
 
158
162
  if (CONFIGURE_CLAUDE_DESKTOP) {
159
163
  const claudeDesktopApiKey = currentClaudeDesktopApiKey();
@@ -187,6 +191,7 @@ if (CLI.command === "doctor") {
187
191
  port: ADAPTER_PORT,
188
192
  host: ADAPTER_HOST,
189
193
  online: CLI.online,
194
+ compat: CLI.compat,
190
195
  });
191
196
  process.exit(checks.some((check) => check.kind === "err") ? 1 : 0);
192
197
  }
@@ -251,8 +256,8 @@ try {
251
256
  });
252
257
 
253
258
  // Point Codex and Claude Code at the adapter.
254
- ensureCodexConfig(ADAPTER_PORT);
255
- ensureClaudeConfig(ADAPTER_PORT);
259
+ ensureCodexConfig(ADAPTER_PORT, { host: ADAPTER_HOST });
260
+ ensureClaudeConfig(ADAPTER_PORT, { host: ADAPTER_HOST });
256
261
  if (CONFIGURE_CLAUDE_DESKTOP) {
257
262
  const result = applyClaudeDesktopConfig({
258
263
  port: ADAPTER_PORT,
@@ -282,7 +287,7 @@ try {
282
287
  console.log(`
283
288
  ${status("ok", "Ready, Claude Code, Claude App and Codex App are now ready to use")}
284
289
 
285
- Adapter: http://${ADAPTER_HOST}:${ADAPTER_PORT}
290
+ Adapter: ${adapterBaseUrl(ADAPTER_HOST, ADAPTER_PORT)}
286
291
 
287
292
  Press Ctrl+C to stop.
288
293
  `);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-copilot-dx",
3
- "version": "0.4.33",
3
+ "version": "0.4.35",
4
4
  "description": "Use Codex Desktop, Claude Code, and Claude App with GitHub Copilot",
5
5
  "author": "Dale Xiao",
6
6
  "type": "module",
@@ -43,8 +43,10 @@
43
43
  "test:smoke": "node scripts/offline-smoke.mjs",
44
44
  "test:e2e": "bash test/e2e.sh",
45
45
  "bench": "node scripts/benchmark.mjs",
46
+ "bench:payload": "node scripts/benchmark.mjs --large-payload",
47
+ "bench:check": "node scripts/benchmark.mjs --check",
46
48
  "pack:check": "npm pack --dry-run --json",
47
- "verify": "npm test && npm run test:smoke && npm run pack:check",
49
+ "verify": "npm test && npm run test:smoke && npm run bench:check && npm run pack:check",
48
50
  "prepublishOnly": "npm run verify"
49
51
  },
50
52
  "dependencies": {
@@ -1,6 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import { spawnSync } from "node:child_process";
4
+ import fs from "node:fs";
5
+ import os from "node:os";
6
+ import path from "node:path";
4
7
  import { setTimeout as delay } from "node:timers/promises";
5
8
  import { fileURLToPath } from "node:url";
6
9
  import sharp from "sharp";
@@ -29,6 +32,150 @@ function deterministicPixels(byteLength, initialSeed) {
29
32
  return pixels;
30
33
  }
31
34
 
35
+ function largePayloadProbe(targetMiB, concurrency) {
36
+ const fixtureDir = fs.mkdtempSync(path.join(os.tmpdir(), "ccdx-payload-bench-"));
37
+ const fixtureStarted = performance.now();
38
+ try {
39
+ const generator = spawnSync(process.execPath, ["--input-type=module", "-e", `
40
+ import fs from "node:fs";
41
+ import path from "node:path";
42
+ import sharp from "sharp";
43
+ const targetBytes = ${targetMiB} * 1024 * 1024;
44
+ const requestConcurrency = ${concurrency};
45
+ const imageCount = 8;
46
+ const fixtureDir = ${JSON.stringify(fixtureDir)};
47
+ const basePng = await sharp({
48
+ create: { width: 32, height: 32, channels: 3, background: { r: 40, g: 90, b: 140 } },
49
+ }).png().toBuffer();
50
+ for (let requestIndex = 0; requestIndex < requestConcurrency; requestIndex += 1) {
51
+ const approximateDataUrlBytes = Math.floor(targetBytes / imageCount);
52
+ const rawBytes = Math.max(basePng.length, Math.floor((approximateDataUrlBytes - 32) * 3 / 4));
53
+ const content = [];
54
+ for (let imageIndex = 0; imageIndex < imageCount; imageIndex += 1) {
55
+ const raw = Buffer.alloc(rawBytes, (requestIndex * imageCount + imageIndex + 1) % 251);
56
+ basePng.copy(raw, 0);
57
+ content.push({ type: "input_image", image_url: \`data:image/png;base64,\${raw.toString("base64")}\` });
58
+ }
59
+ const source = {
60
+ model: "gpt-benchmark",
61
+ stream: false,
62
+ input: [{ type: "message", role: "user", content }],
63
+ };
64
+ fs.writeFileSync(path.join(fixtureDir, \`request-\${requestIndex}.json\`), JSON.stringify(source));
65
+ }
66
+ `], { cwd: packageRoot, encoding: "utf8" });
67
+ if (generator.status !== 0) {
68
+ throw new Error(generator.stderr.trim() || `benchmark fixture generator exited with ${generator.status}`);
69
+ }
70
+ const fixturePrepareMs = performance.now() - fixtureStarted;
71
+ const fixtureFiles = Array.from({ length: concurrency }, (_, index) => path.join(fixtureDir, `request-${index}.json`));
72
+ const result = runProbe(`
73
+ import fs from "node:fs";
74
+ import { prepareResponsesRequest } from "./src/adapter.mjs";
75
+ import { createRequestAdmission, readJsonBody } from "./src/http-transport.mjs";
76
+ import { prepareResponsesPayload } from "./src/image-optimization.mjs";
77
+
78
+ const fixtureFiles = ${JSON.stringify(fixtureFiles)};
79
+ const acquireRequest = createRequestAdmission();
80
+ const memoryFields = ["rss", "heapUsed", "external", "arrayBuffers"];
81
+ const peak = {};
82
+ let admissionMaxActiveBytes = 0;
83
+ const sample = () => {
84
+ const memory = process.memoryUsage();
85
+ for (const field of memoryFields) peak[field] = Math.max(peak[field] || 0, memory[field] || 0);
86
+ };
87
+ globalThis.gc?.();
88
+ const idleMemory = process.memoryUsage();
89
+ for (const field of memoryFields) peak[field] = idleMemory[field] || 0;
90
+ const timer = setInterval(sample, 5);
91
+ timer.unref?.();
92
+
93
+ async function processRequest(filePath) {
94
+ const inputBytes = fs.statSync(filePath).size;
95
+ const headers = { "content-length": String(inputBytes), "content-encoding": "identity" };
96
+ const releaseRequest = await acquireRequest({ headers });
97
+ admissionMaxActiveBytes = Math.max(admissionMaxActiveBytes, acquireRequest.stats().activeBytes);
98
+ const req = fs.createReadStream(filePath);
99
+ req.headers = headers;
100
+ try {
101
+ const parsed = await readJsonBody(req);
102
+ req.destroy();
103
+ sample();
104
+ const prepared = prepareResponsesRequest(parsed, { mutate: true });
105
+ const result = await prepareResponsesPayload(prepared.body);
106
+ sample();
107
+ return { inputBytes, outputBytes: result.bodyBytes };
108
+ } finally {
109
+ req.destroy();
110
+ releaseRequest();
111
+ }
112
+ }
113
+
114
+ const originalLog = console.log;
115
+ const originalWarn = console.warn;
116
+ console.log = () => {};
117
+ console.warn = () => {};
118
+ const started = performance.now();
119
+ let results;
120
+ try {
121
+ results = await Promise.all(fixtureFiles.map(processRequest));
122
+ } finally {
123
+ clearInterval(timer);
124
+ console.log = originalLog;
125
+ console.warn = originalWarn;
126
+ }
127
+ sample();
128
+ const finalMemory = process.memoryUsage();
129
+ const mib = (bytes) => +(bytes / 1048576).toFixed(1);
130
+ const memory = Object.fromEntries(memoryFields.map((field) => [field.replace("Used", "_used").replace("Buffers", "_buffers").toLowerCase(), {
131
+ idle_mib: mib(idleMemory[field] || 0),
132
+ peak_mib: mib(peak[field] || 0),
133
+ peak_delta_mib: mib((peak[field] || 0) - (idleMemory[field] || 0)),
134
+ final_mib: mib(finalMemory[field] || 0),
135
+ }]));
136
+ process.stdout.write(JSON.stringify({
137
+ target_request_mib: ${targetMiB},
138
+ concurrency: fixtureFiles.length,
139
+ elapsed_ms: +(performance.now() - started).toFixed(1),
140
+ input_body_mib: results.map((entry) => mib(entry.inputBytes)),
141
+ output_body_mib: results.map((entry) => mib(entry.outputBytes)),
142
+ output_body_kib: results.map((entry) => +(entry.outputBytes / 1024).toFixed(1)),
143
+ admission_max_active_mib: mib(admissionMaxActiveBytes),
144
+ admission_budget_mib: mib(acquireRequest.stats().maxBytes),
145
+ memory,
146
+ }));
147
+ `);
148
+ result.fixture_prepare_ms = +fixturePrepareMs.toFixed(1);
149
+ return result;
150
+ } finally {
151
+ fs.rmSync(fixtureDir, { recursive: true, force: true });
152
+ }
153
+ }
154
+
155
+ function largeTokenCountProbe(mode) {
156
+ return runProbe(`
157
+ const text = "a ".repeat(2 * 1024 * 1024);
158
+ const started = performance.now();
159
+ let tokens;
160
+ if (${JSON.stringify(mode)} === "proxy") {
161
+ const { countTokens } = await import("./src/anthropic.mjs");
162
+ tokens = (await countTokens({ messages: [{ role: "user", content: text }] })).input_tokens;
163
+ } else {
164
+ const { encode } = await import("gpt-tokenizer");
165
+ const encoded = encode(text);
166
+ tokens = encoded.length;
167
+ }
168
+ const memory = process.memoryUsage();
169
+ process.stdout.write(JSON.stringify({
170
+ input_mib: +(Buffer.byteLength(text) / 1048576).toFixed(1),
171
+ tokens,
172
+ elapsed_ms: +(performance.now() - started).toFixed(1),
173
+ rss_mib: +(memory.rss / 1048576).toFixed(1),
174
+ heap_used_mib: +(memory.heapUsed / 1048576).toFixed(1),
175
+ }));
176
+ `);
177
+ }
178
+
32
179
  const adapterImport = runProbe(`
33
180
  const started = performance.now();
34
181
  await import("./src/adapter.mjs");
@@ -126,10 +273,62 @@ try {
126
273
  console.warn = originalWarn;
127
274
  }
128
275
 
129
- console.log(JSON.stringify({
130
- note: "Report-only benchmark; timings vary by machine and are not pass/fail thresholds.",
276
+ const checkMode = process.argv.includes("--check");
277
+ const report = {
278
+ note: checkMode
279
+ ? "Relative performance and resource checks; absolute timings vary by machine."
280
+ : "Report-only benchmark; timings vary by machine and are not pass/fail thresholds.",
131
281
  adapter_import: adapterImport,
132
282
  token_count_100kb: tokenCount,
133
283
  duplicate_images: duplicateImages,
134
284
  oversized_payload: oversizedPayload,
135
- }, null, 2));
285
+ };
286
+
287
+ if (checkMode) {
288
+ report.large_token_count = {
289
+ proxy: largeTokenCountProbe("proxy"),
290
+ materialized_array: largeTokenCountProbe("array"),
291
+ };
292
+ report.large_payload_peak = [
293
+ largePayloadProbe(5, 4),
294
+ largePayloadProbe(30, 1),
295
+ largePayloadProbe(30, 2),
296
+ ];
297
+ } else if (process.argv.includes("--large-payload")) {
298
+ report.large_payload_peak = [
299
+ largePayloadProbe(5, 1),
300
+ largePayloadProbe(5, 4),
301
+ largePayloadProbe(30, 1),
302
+ largePayloadProbe(30, 2),
303
+ largePayloadProbe(60, 1),
304
+ ];
305
+ }
306
+
307
+ console.log(JSON.stringify(report, null, 2));
308
+
309
+ if (checkMode) {
310
+ const failures = [];
311
+ const proxy = report.large_token_count.proxy;
312
+ const array = report.large_token_count.materialized_array;
313
+ if (proxy.tokens !== array.tokens) failures.push(`token counts differ: proxy=${proxy.tokens} array=${array.tokens}`);
314
+ if (proxy.elapsed_ms > array.elapsed_ms * 1.25) {
315
+ failures.push(`token counter is materially slower: proxy=${proxy.elapsed_ms}ms array=${array.elapsed_ms}ms`);
316
+ }
317
+ if (proxy.rss_mib > array.rss_mib * 0.9) {
318
+ failures.push(`token counter RSS regression: proxy=${proxy.rss_mib}MiB array=${array.rss_mib}MiB`);
319
+ }
320
+ if (proxy.heap_used_mib > array.heap_used_mib * 0.75) {
321
+ failures.push(`token counter heap regression: proxy=${proxy.heap_used_mib}MiB array=${array.heap_used_mib}MiB`);
322
+ }
323
+ for (const payload of report.large_payload_peak) {
324
+ if (payload.admission_max_active_mib > payload.admission_budget_mib) {
325
+ failures.push(`request admission exceeded its budget at ${payload.target_request_mib}MiB x${payload.concurrency}`);
326
+ }
327
+ }
328
+ if (failures.length) {
329
+ for (const failure of failures) console.error(`[FAIL] ${failure}`);
330
+ process.exitCode = 1;
331
+ } else {
332
+ console.error("[OK] Performance and resource checks passed");
333
+ }
334
+ }
package/src/adapter.mjs CHANGED
@@ -10,6 +10,7 @@ import { recordAnthropicUsage, recordResponsesUsage } from "./usage.mjs";
10
10
  import { ADAPTER_HEALTH_PATH, adapterHealthPayload } from "./running-adapter.mjs";
11
11
  import {
12
12
  abortErrorStatusCode,
13
+ createRequestAdmission,
13
14
  createRequestAbort,
14
15
  httpError,
15
16
  isAbortLikeError,
@@ -29,6 +30,7 @@ import {
29
30
 
30
31
  export {
31
32
  abortErrorStatusCode,
33
+ createRequestAdmission,
32
34
  createRequestAbort,
33
35
  isAbortLikeError,
34
36
  readJsonBody,
@@ -371,7 +373,12 @@ async function endStreamWithError(res, protocol, error, abort) {
371
373
  }
372
374
 
373
375
  async function proxyCopilotResponses(reqContext, req, res, upstream = copilotResponses, options = {}) {
374
- const opened = await openCopilotResponse(reqContext, upstream, options);
376
+ let opened;
377
+ try {
378
+ opened = await openCopilotResponse(reqContext, upstream, options);
379
+ } finally {
380
+ options.releaseRequest?.();
381
+ }
375
382
  const { resp, errorText } = opened;
376
383
  reqContext = opened.reqContext;
377
384
  if (errorText !== undefined) {
@@ -547,10 +554,14 @@ export async function forwardToChat(chatReq, emitEvent, onDone, onError, options
547
554
  let resp;
548
555
  try {
549
556
  const chatCompletionsFn = options.chatCompletionsFn || chatCompletions;
550
- resp = await chatCompletionsFn({
551
- ...chatReq,
552
- stream: true,
553
- }, { signal: options.signal });
557
+ try {
558
+ resp = await chatCompletionsFn({
559
+ ...chatReq,
560
+ stream: true,
561
+ }, { signal: options.signal });
562
+ } finally {
563
+ options.releaseRequest?.();
564
+ }
554
565
  } catch (e) {
555
566
  const statusCode = isAbortLikeError(e) ? abortErrorStatusCode(options.abort?.reason) : 502;
556
567
  await onError(statusCode, e.message);
@@ -723,6 +734,7 @@ export function createAdapterHandler(options = {}) {
723
734
  const upstreamTimeoutMs = positiveInt(options.upstreamTimeoutMs, UPSTREAM_TIMEOUT_MS);
724
735
  const streamHandshakeTimeoutMs = positiveInt(options.streamHandshakeTimeoutMs, STREAM_HANDSHAKE_TIMEOUT_MS);
725
736
  const streamIdleTimeoutMs = positiveInt(options.streamIdleTimeoutMs, STREAM_IDLE_TIMEOUT_MS);
737
+ const acquireRequest = options.acquireRequest || createRequestAdmission();
726
738
  const claudeDesktopModelOptions = () => {
727
739
  const modelDefs = options.modelRegistry?.modelDefs || options.claudeDesktopModelDefs;
728
740
  return Array.isArray(modelDefs) ? { modelDefs } : {};
@@ -740,7 +752,9 @@ export function createAdapterHandler(options = {}) {
740
752
  if (req.method === "POST" && pathname === "/v1/responses") {
741
753
  return (async () => {
742
754
  const abort = createRequestAbort(req, res);
755
+ let releaseRequest = () => {};
743
756
  try {
757
+ releaseRequest = await acquireRequest(req, { signal: abort.signal });
744
758
  const parsed = await readJsonBody(req);
745
759
  abort.setTimeout(
746
760
  parsed.stream ? streamHandshakeTimeoutMs : upstreamTimeoutMs,
@@ -757,6 +771,7 @@ export function createAdapterHandler(options = {}) {
757
771
  await proxyCopilotResponses(prepared, req, res, responsesFn, {
758
772
  signal: abort.signal,
759
773
  abort,
774
+ releaseRequest,
760
775
  streamIdleTimeoutMs,
761
776
  });
762
777
  } else {
@@ -781,12 +796,14 @@ export function createAdapterHandler(options = {}) {
781
796
  abort,
782
797
  streamIdleTimeoutMs,
783
798
  chatCompletionsFn,
799
+ releaseRequest,
784
800
  });
785
801
  } else {
786
802
  chatReq.stream = false;
787
803
  delete chatReq.max_tokens;
788
804
  try {
789
805
  const upstream = await chatCompletionsFn({ ...chatReq, stream: false }, { signal: abort.signal });
806
+ releaseRequest();
790
807
  const data = await upstream.text();
791
808
  if (!upstream.ok) {
792
809
  sendUpstreamError(res, upstream, data);
@@ -807,6 +824,7 @@ export function createAdapterHandler(options = {}) {
807
824
  logRequestFailure("Responses", e, abort);
808
825
  sendJsonError(res, e, 502);
809
826
  } finally {
827
+ releaseRequest();
810
828
  abort.cleanup();
811
829
  }
812
830
  })();
@@ -815,7 +833,9 @@ export function createAdapterHandler(options = {}) {
815
833
  if (req.method === "POST" && pathname === "/v1/responses/compact") {
816
834
  (async () => {
817
835
  const abort = createRequestAbort(req, res);
836
+ let releaseRequest = () => {};
818
837
  try {
838
+ releaseRequest = await acquireRequest(req, { signal: abort.signal });
819
839
  const parsed = await readJsonBody(req);
820
840
  abort.setTimeout(
821
841
  parsed.stream ? streamHandshakeTimeoutMs : upstreamTimeoutMs,
@@ -831,12 +851,14 @@ export function createAdapterHandler(options = {}) {
831
851
  await proxyCopilotResponses(prepared, req, res, responsesCompactFn, {
832
852
  signal: abort.signal,
833
853
  abort,
854
+ releaseRequest,
834
855
  streamIdleTimeoutMs,
835
856
  });
836
857
  } catch (e) {
837
858
  logRequestFailure("Responses compact", e, abort);
838
859
  sendJsonError(res, e, 502);
839
860
  } finally {
861
+ releaseRequest();
840
862
  abort.cleanup();
841
863
  }
842
864
  })();
@@ -887,13 +909,18 @@ export function createAdapterHandler(options = {}) {
887
909
  if (req.method === "POST" && pathname === "/v1/messages/count_tokens") {
888
910
  (async () => {
889
911
  const abort = createRequestAbort(req, res);
912
+ let releaseRequest = () => {};
890
913
  try {
914
+ releaseRequest = await acquireRequest(req, { signal: abort.signal });
891
915
  const parsed = await readJsonBody(req);
916
+ const result = await countTokens(parsed);
917
+ releaseRequest();
892
918
  res.writeHead(200, { "Content-Type": "application/json" });
893
- res.end(JSON.stringify(await countTokens(parsed)));
919
+ res.end(JSON.stringify(result));
894
920
  } catch (e) {
895
921
  sendJsonError(res, e);
896
922
  } finally {
923
+ releaseRequest();
897
924
  abort.cleanup();
898
925
  }
899
926
  })();
@@ -903,7 +930,9 @@ export function createAdapterHandler(options = {}) {
903
930
  if (req.method === "POST" && pathname === "/v1/messages") {
904
931
  (async () => {
905
932
  const abort = createRequestAbort(req, res);
933
+ let releaseRequest = () => {};
906
934
  try {
935
+ releaseRequest = await acquireRequest(req, { signal: abort.signal });
907
936
  const parsed = await readJsonBody(req);
908
937
  abort.setTimeout(
909
938
  parsed.stream ? streamHandshakeTimeoutMs : upstreamTimeoutMs,
@@ -916,6 +945,7 @@ export function createAdapterHandler(options = {}) {
916
945
  const forceRequestedModel = upstreamModel !== requestedModel;
917
946
  if (parsed.stream) {
918
947
  const upstream = await chatCompletionsFn({ ...chatReq, stream: true }, { signal: abort.signal });
948
+ releaseRequest();
919
949
  if (!upstream.ok) {
920
950
  if (!res.headersSent) res.writeHead(upstream.status);
921
951
  res.end(await upstream.text());
@@ -947,6 +977,7 @@ export function createAdapterHandler(options = {}) {
947
977
  if (!res.writableEnded) res.end();
948
978
  } else {
949
979
  const upstream = await chatCompletionsFn({ ...chatReq, stream: false }, { signal: abort.signal });
980
+ releaseRequest();
950
981
  const data = await upstream.text();
951
982
  if (!upstream.ok) {
952
983
  sendUpstreamError(res, upstream, data);
@@ -961,6 +992,7 @@ export function createAdapterHandler(options = {}) {
961
992
  logRequestFailure("Messages", e, abort);
962
993
  sendJsonError(res, e, 502);
963
994
  } finally {
995
+ releaseRequest();
964
996
  abort.cleanup();
965
997
  }
966
998
  })();
package/src/anthropic.mjs CHANGED
@@ -2,9 +2,9 @@ import { randomUUID } from "node:crypto";
2
2
 
3
3
  let tokenizerModulePromise = null;
4
4
 
5
- async function tokenEncoder() {
5
+ async function tokenCounter() {
6
6
  tokenizerModulePromise ||= import("gpt-tokenizer");
7
- return (await tokenizerModulePromise).encode;
7
+ return (await tokenizerModulePromise).countTokens;
8
8
  }
9
9
 
10
10
  // Anthropic Messages API to OpenAI chat/completions translation.
@@ -282,6 +282,6 @@ export async function countTokens(body) {
282
282
  }
283
283
 
284
284
  const text = parts.join("\n");
285
- const encode = await tokenEncoder();
286
- return { input_tokens: encode(text).length };
285
+ const count = await tokenCounter();
286
+ return { input_tokens: count(text) };
287
287
  }
@@ -3,12 +3,13 @@ import path from "node:path";
3
3
  import os from "node:os";
4
4
  import { status } from "./status.mjs";
5
5
  import { atomicWriteFileIfChangedSync } from "./atomic-file.mjs";
6
+ import { adapterBaseUrl } from "./running-adapter.mjs";
6
7
 
7
8
  const SETTINGS_PATH = path.join(os.homedir(), ".claude", "settings.json");
8
9
 
9
10
  // Return an updated settings object without mutating the input.
10
- export function computeUpdatedSettings(settings, port) {
11
- const target = `http://127.0.0.1:${port}`;
11
+ export function computeUpdatedSettings(settings, port, host = "127.0.0.1") {
12
+ const target = adapterBaseUrl(host, port);
12
13
  const current = settings?.env?.ANTHROPIC_BASE_URL;
13
14
  const currentToken = settings?.env?.ANTHROPIC_AUTH_TOKEN;
14
15
  const changed = current !== target || currentToken !== "dummy";
@@ -24,11 +25,11 @@ export function computeUpdatedSettings(settings, port) {
24
25
  }
25
26
 
26
27
  // Point Claude Code at the adapter by updating ~/.claude/settings.json.
27
- export function ensureClaudeConfig(port = 2026, { filePath = SETTINGS_PATH } = {}) {
28
- const target = `http://127.0.0.1:${port}`;
28
+ export function ensureClaudeConfig(port = 2026, { filePath = SETTINGS_PATH, host = "127.0.0.1" } = {}) {
29
+ const target = adapterBaseUrl(host, port);
29
30
 
30
31
  if (!fs.existsSync(filePath)) {
31
- const { json } = computeUpdatedSettings({}, port);
32
+ const { json } = computeUpdatedSettings({}, port, host);
32
33
  atomicWriteFileIfChangedSync(filePath, JSON.stringify(json, null, 2) + "\n");
33
34
  console.log(status("ok", `Created ~/.claude/settings.json for Claude Code at ${target}`));
34
35
  return;
@@ -44,7 +45,7 @@ export function ensureClaudeConfig(port = 2026, { filePath = SETTINGS_PATH } = {
44
45
  return;
45
46
  }
46
47
 
47
- const { json, changed } = computeUpdatedSettings(settings, port);
48
+ const { json, changed } = computeUpdatedSettings(settings, port, host);
48
49
  if (!changed) {
49
50
  console.log(status("ok", `Claude Code already points to ${target}`));
50
51
  return;
@@ -7,28 +7,36 @@ function unexpectedArgs(args) {
7
7
 
8
8
  export function parseCliArgs(args = []) {
9
9
  const [command, ...rest] = args;
10
- if (!command) return { command: "start", configureClaudeDesktop: false, online: false };
10
+ if (!command) return { command: "start", configureClaudeDesktop: false, online: false, compat: false };
11
11
  if (HELP_COMMANDS.has(command)) {
12
12
  if (rest.length) unexpectedArgs(rest);
13
- return { command: "help", configureClaudeDesktop: false, online: false };
13
+ return { command: "help", configureClaudeDesktop: false, online: false, compat: false };
14
14
  }
15
15
  if (VERSION_COMMANDS.has(command)) {
16
16
  if (rest.length) unexpectedArgs(rest);
17
- return { command: "version", configureClaudeDesktop: false, online: false };
17
+ return { command: "version", configureClaudeDesktop: false, online: false, compat: false };
18
18
  }
19
19
  if (command === "usage") {
20
20
  if (rest.length) unexpectedArgs(rest);
21
- return { command: "usage", configureClaudeDesktop: false, online: false };
21
+ return { command: "usage", configureClaudeDesktop: false, online: false, compat: false };
22
22
  }
23
23
  if (command === "doctor" || command === "--doctor") {
24
- const invalid = rest.filter((arg) => arg !== "--online");
24
+ const supported = new Set(["--online", "--compat"]);
25
+ const invalid = rest.filter((arg) => !supported.has(arg));
25
26
  if (invalid.length) unexpectedArgs(invalid);
26
- if (rest.filter((arg) => arg === "--online").length > 1) unexpectedArgs(["--online"]);
27
- return { command: "doctor", configureClaudeDesktop: false, online: rest.includes("--online") };
27
+ for (const option of supported) {
28
+ if (rest.filter((arg) => arg === option).length > 1) unexpectedArgs([option]);
29
+ }
30
+ return {
31
+ command: "doctor",
32
+ configureClaudeDesktop: false,
33
+ online: rest.includes("--online"),
34
+ compat: rest.includes("--compat"),
35
+ };
28
36
  }
29
37
  if (command === "--configure-claude-desktop") {
30
38
  if (rest.length) unexpectedArgs(rest);
31
- return { command: "start", configureClaudeDesktop: true, online: false };
39
+ return { command: "start", configureClaudeDesktop: true, online: false, compat: false };
32
40
  }
33
41
  throw new Error(`Unknown command or option: ${command}`);
34
42
  }
@@ -62,7 +70,7 @@ export function parseRuntimeOptions(env = process.env) {
62
70
  export function cliHelp() {
63
71
  return `Usage:
64
72
  codex-copilot-dx [--configure-claude-desktop]
65
- codex-copilot-dx doctor [--online]
73
+ codex-copilot-dx doctor [--online] [--compat]
66
74
  codex-copilot-dx usage
67
75
  codex-copilot-dx --version
68
76
  codex-copilot-dx --help`;