codex-copilot-dx 0.4.34 → 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
@@ -70,7 +70,7 @@ To actively verify the protocol path through an already-running adapter, run:
70
70
  codex-copilot-dx doctor --compat
71
71
  ```
72
72
 
73
- The compatibility doctor sends a few minimal Copilot requests to check 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.
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
74
 
75
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.
76
76
 
@@ -110,12 +110,15 @@ Environment variables:
110
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 |
111
111
  | `CCDX_MAX_BODY_BYTES` | `67108864` | Maximum compressed/raw request body size |
112
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` |
113
116
  | `CCDX_MAX_UPSTREAM_BODY_BYTES` | `31457280` | Responses body target; larger image payloads are compressed more aggressively before forwarding |
114
117
  | `CCDX_MAX_SSE_BUFFER_BYTES` | `8388608` | Maximum buffered bytes for one unterminated upstream SSE line/event |
115
118
  | `CCDX_UPSTREAM_TIMEOUT_MS` | `120000` | Timeout for non-streaming upstream Copilot requests |
116
119
  | `CCDX_STREAM_HANDSHAKE_TIMEOUT_MS` | `120000` | Timeout while waiting for upstream streaming response headers |
117
120
  | `CCDX_STREAM_IDLE_TIMEOUT_MS` | `120000` | Maximum idle time between upstream streaming body chunks |
118
- | `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` |
119
122
  | `CCDX_UPSTREAM_RETRY_DELAY_MS` | `300` | Initial upstream retry backoff in milliseconds; capped at `5000` |
120
123
  | `CCDX_AUTO_REVIEW_MODEL` | `gpt-5.5` | Copilot Responses model used for Codex Auto-review requests |
121
124
  | `CCDX_LOG_PATH` | unset | Mirror terminal logs to a file; set to `1` for `~/.local/share/codex-copilot-dx/debug.log` |
@@ -178,4 +181,4 @@ npm run verify
178
181
  npm run bench:payload
179
182
  ```
180
183
 
181
- `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. `npm run bench:payload` is 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.
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();
@@ -252,8 +256,8 @@ try {
252
256
  });
253
257
 
254
258
  // Point Codex and Claude Code at the adapter.
255
- ensureCodexConfig(ADAPTER_PORT);
256
- ensureClaudeConfig(ADAPTER_PORT);
259
+ ensureCodexConfig(ADAPTER_PORT, { host: ADAPTER_HOST });
260
+ ensureClaudeConfig(ADAPTER_PORT, { host: ADAPTER_HOST });
257
261
  if (CONFIGURE_CLAUDE_DESKTOP) {
258
262
  const result = applyClaudeDesktopConfig({
259
263
  port: ADAPTER_PORT,
@@ -283,7 +287,7 @@ try {
283
287
  console.log(`
284
288
  ${status("ok", "Ready, Claude Code, Claude App and Codex App are now ready to use")}
285
289
 
286
- Adapter: http://${ADAPTER_HOST}:${ADAPTER_PORT}
290
+ Adapter: ${adapterBaseUrl(ADAPTER_HOST, ADAPTER_PORT)}
287
291
 
288
292
  Press Ctrl+C to stop.
289
293
  `);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-copilot-dx",
3
- "version": "0.4.34",
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",
@@ -44,8 +44,9 @@
44
44
  "test:e2e": "bash test/e2e.sh",
45
45
  "bench": "node scripts/benchmark.mjs",
46
46
  "bench:payload": "node scripts/benchmark.mjs --large-payload",
47
+ "bench:check": "node scripts/benchmark.mjs --check",
47
48
  "pack:check": "npm pack --dry-run --json",
48
- "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",
49
50
  "prepublishOnly": "npm run verify"
50
51
  },
51
52
  "dependencies": {
@@ -72,12 +72,14 @@ function largePayloadProbe(targetMiB, concurrency) {
72
72
  const result = runProbe(`
73
73
  import fs from "node:fs";
74
74
  import { prepareResponsesRequest } from "./src/adapter.mjs";
75
- import { readJsonBody } from "./src/http-transport.mjs";
75
+ import { createRequestAdmission, readJsonBody } from "./src/http-transport.mjs";
76
76
  import { prepareResponsesPayload } from "./src/image-optimization.mjs";
77
77
 
78
78
  const fixtureFiles = ${JSON.stringify(fixtureFiles)};
79
+ const acquireRequest = createRequestAdmission();
79
80
  const memoryFields = ["rss", "heapUsed", "external", "arrayBuffers"];
80
81
  const peak = {};
82
+ let admissionMaxActiveBytes = 0;
81
83
  const sample = () => {
82
84
  const memory = process.memoryUsage();
83
85
  for (const field of memoryFields) peak[field] = Math.max(peak[field] || 0, memory[field] || 0);
@@ -90,15 +92,23 @@ function largePayloadProbe(targetMiB, concurrency) {
90
92
 
91
93
  async function processRequest(filePath) {
92
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);
93
98
  const req = fs.createReadStream(filePath);
94
- req.headers = { "content-length": String(inputBytes), "content-encoding": "identity" };
95
- const parsed = await readJsonBody(req);
96
- req.destroy();
97
- sample();
98
- const prepared = prepareResponsesRequest(parsed, { mutate: true });
99
- const result = await prepareResponsesPayload(prepared.body);
100
- sample();
101
- return { inputBytes, outputBytes: result.bodyBytes };
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
+ }
102
112
  }
103
113
 
104
114
  const originalLog = console.log;
@@ -130,6 +140,8 @@ function largePayloadProbe(targetMiB, concurrency) {
130
140
  input_body_mib: results.map((entry) => mib(entry.inputBytes)),
131
141
  output_body_mib: results.map((entry) => mib(entry.outputBytes)),
132
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),
133
145
  memory,
134
146
  }));
135
147
  `);
@@ -140,6 +152,30 @@ function largePayloadProbe(targetMiB, concurrency) {
140
152
  }
141
153
  }
142
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
+
143
179
  const adapterImport = runProbe(`
144
180
  const started = performance.now();
145
181
  await import("./src/adapter.mjs");
@@ -237,15 +273,28 @@ try {
237
273
  console.warn = originalWarn;
238
274
  }
239
275
 
276
+ const checkMode = process.argv.includes("--check");
240
277
  const report = {
241
- note: "Report-only benchmark; timings vary by machine and are not pass/fail thresholds.",
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.",
242
281
  adapter_import: adapterImport,
243
282
  token_count_100kb: tokenCount,
244
283
  duplicate_images: duplicateImages,
245
284
  oversized_payload: oversizedPayload,
246
285
  };
247
286
 
248
- if (process.argv.includes("--large-payload")) {
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")) {
249
298
  report.large_payload_peak = [
250
299
  largePayloadProbe(5, 1),
251
300
  largePayloadProbe(5, 4),
@@ -256,3 +305,30 @@ if (process.argv.includes("--large-payload")) {
256
305
  }
257
306
 
258
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;
package/src/config.mjs CHANGED
@@ -3,6 +3,7 @@ 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 CONFIG_PATH = path.join(os.homedir(), ".codex", "config.toml");
8
9
 
@@ -33,9 +34,9 @@ function setTomlKey(lines, sectionName, key, value) {
33
34
  return true;
34
35
  }
35
36
 
36
- export function computeUpdatedCodexConfig(content, adapterPort = 2026) {
37
- const baseUrl = `http://127.0.0.1:${adapterPort}/v1`;
38
- const anthropicBaseUrl = `http://127.0.0.1:${adapterPort}`;
37
+ export function computeUpdatedCodexConfig(content, adapterPort = 2026, adapterHost = "127.0.0.1") {
38
+ const anthropicBaseUrl = adapterBaseUrl(adapterHost, adapterPort);
39
+ const baseUrl = `${anthropicBaseUrl}/v1`;
39
40
  const hadTrailingNewline = content.endsWith("\n");
40
41
  const lines = content.split("\n");
41
42
  if (hadTrailingNewline) lines.pop();
@@ -59,9 +60,9 @@ export function computeUpdatedCodexConfig(content, adapterPort = 2026) {
59
60
  return { content: lines.join("\n") + (hadTrailingNewline ? "\n" : ""), changed };
60
61
  }
61
62
 
62
- function initialCodexConfig(adapterPort) {
63
- const baseUrl = `http://127.0.0.1:${adapterPort}/v1`;
64
- const anthropicBaseUrl = `http://127.0.0.1:${adapterPort}`;
63
+ function initialCodexConfig(adapterPort, adapterHost) {
64
+ const anthropicBaseUrl = adapterBaseUrl(adapterHost, adapterPort);
65
+ const baseUrl = `${anthropicBaseUrl}/v1`;
65
66
  return `openai_base_url = "${baseUrl}"
66
67
 
67
68
  [shell_environment_policy]
@@ -75,18 +76,18 @@ OPENAI_API_KEY = "dummy"
75
76
  `;
76
77
  }
77
78
 
78
- export function ensureCodexConfig(adapterPort = 2026, { filePath = CONFIG_PATH } = {}) {
79
- const baseUrl = `http://127.0.0.1:${adapterPort}/v1`;
79
+ export function ensureCodexConfig(adapterPort = 2026, { filePath = CONFIG_PATH, host = "127.0.0.1" } = {}) {
80
+ const baseUrl = `${adapterBaseUrl(host, adapterPort)}/v1`;
80
81
 
81
82
  if (!fs.existsSync(filePath)) {
82
83
  // Codex config does not exist yet; create the local proxy defaults.
83
- atomicWriteFileIfChangedSync(filePath, initialCodexConfig(adapterPort));
84
+ atomicWriteFileIfChangedSync(filePath, initialCodexConfig(adapterPort, host));
84
85
  console.log(status("ok", "Created ~/.codex/config.toml"));
85
86
  return;
86
87
  }
87
88
 
88
89
  const content = fs.readFileSync(filePath, "utf-8");
89
- const updated = computeUpdatedCodexConfig(content, adapterPort);
90
+ const updated = computeUpdatedCodexConfig(content, adapterPort, host);
90
91
 
91
92
  if (!updated.changed) {
92
93
  console.log(status("ok", `Codex already points to ${baseUrl}`));
package/src/copilot.mjs CHANGED
@@ -95,6 +95,12 @@ const RETRYABLE_UPSTREAM_ERROR_CODES = new Set([
95
95
  "EAI_AGAIN",
96
96
  "ENOTFOUND",
97
97
  ]);
98
+ const RETRYABLE_POST_CONNECT_ERROR_CODES = new Set([
99
+ "UND_ERR_CONNECT_TIMEOUT",
100
+ "ECONNREFUSED",
101
+ "EAI_AGAIN",
102
+ "ENOTFOUND",
103
+ ]);
98
104
  const RETRYABLE_UPSTREAM_STATUSES = new Set([408, 502, 503, 504]);
99
105
 
100
106
  function upstreamTarget(url) {
@@ -146,9 +152,15 @@ function requestMethod(init = {}) {
146
152
  return String(init.method || "GET").toUpperCase();
147
153
  }
148
154
 
149
- export function isRetryableUpstreamError(err, { signal } = {}) {
155
+ export function isRetryableUpstreamError(err, { signal, method = "GET" } = {}) {
150
156
  if (isAbortError(err, signal)) return false;
151
- return RETRYABLE_UPSTREAM_ERROR_CODES.has(upstreamErrorCode(err));
157
+ const code = upstreamErrorCode(err);
158
+ if (!["GET", "HEAD", "OPTIONS"].includes(String(method || "GET").toUpperCase())) {
159
+ const syscall = err?.cause?.syscall || err?.syscall || "";
160
+ return RETRYABLE_POST_CONNECT_ERROR_CODES.has(code)
161
+ || (syscall === "connect" && ["ETIMEDOUT", "ECONNRESET"].includes(code));
162
+ }
163
+ return RETRYABLE_UPSTREAM_ERROR_CODES.has(code);
152
164
  }
153
165
 
154
166
  export function isRetryableUpstreamStatus(status, method = "GET") {
@@ -197,7 +209,7 @@ export async function fetchCopilotUpstream(
197
209
  return resp;
198
210
  } catch (e) {
199
211
  debugLog(`upstream ${method} ${target} error=${describeUpstreamError(e)} attempt=${attempt + 1}/${retryCount + 1} attempt_ms=${Date.now() - attemptStart} total_ms=${Date.now() - totalStart}`);
200
- if (attempt >= retryCount || !isRetryableUpstreamError(e, { signal })) throw e;
212
+ if (attempt >= retryCount || !isRetryableUpstreamError(e, { signal, method })) throw e;
201
213
  const delay = upstreamRetryDelay(attempt, baseDelay);
202
214
  console.warn(status("warn", `upstream ${target} ${describeUpstreamError(e)}; retry ${attempt + 1}/${retryCount} in ${delay}ms`));
203
215
  await sleep(delay, { signal });
package/src/doctor.mjs CHANGED
@@ -6,7 +6,8 @@ import { githubTokenPath, readGithubTokenMetadata, validateGithubToken } from ".
6
6
  import { claudeDesktopPaths } from "./claude-desktop-config.mjs";
7
7
  import { status } from "./status.mjs";
8
8
  import { buildHeaders, DEFAULT_API_BASE, FALLBACK_VSCODE_VERSION } from "./copilot.mjs";
9
- import { checkRunningAdapter } from "./running-adapter.mjs";
9
+ import { adapterBaseUrl, checkRunningAdapter } from "./running-adapter.mjs";
10
+ import { CODEX_AUTO_REVIEW_MODEL } from "./models.mjs";
10
11
 
11
12
  function localGatewayBaseUrl(host, port) {
12
13
  const safeHost = String(host || "127.0.0.1");
@@ -146,6 +147,15 @@ export async function inspectAdapterCompatibility({
146
147
  return checks;
147
148
  }
148
149
 
150
+ await runCompatibilityCheck(checks, "Codex Auto-review", async () => {
151
+ const text = await compatibilityRequest(fetchImpl, `${baseUrl}/v1/responses`, {
152
+ model: CODEX_AUTO_REVIEW_MODEL,
153
+ stream: false,
154
+ input: "Reply with OK only.",
155
+ }, timeoutMs);
156
+ parseResponseObject(text);
157
+ });
158
+
149
159
  const firstResponse = await runCompatibilityCheck(checks, `Native Responses (${responsesModel})`, async () => {
150
160
  const text = await compatibilityRequest(fetchImpl, `${baseUrl}/v1/responses`, {
151
161
  model: responsesModel,
@@ -259,10 +269,10 @@ export async function inspectGitHubTokenOnline({
259
269
  }
260
270
  }
261
271
 
262
- export function inspectCodexConfig({ home = os.homedir(), port = 2026 } = {}) {
272
+ export function inspectCodexConfig({ home = os.homedir(), host = "127.0.0.1", port = 2026 } = {}) {
263
273
  const filePath = path.join(home, ".codex", "config.toml");
264
- const expectedBaseUrl = `http://127.0.0.1:${port}/v1`;
265
- const expectedAnthropicBaseUrl = `http://127.0.0.1:${port}`;
274
+ const expectedAnthropicBaseUrl = adapterBaseUrl(host, port);
275
+ const expectedBaseUrl = `${expectedAnthropicBaseUrl}/v1`;
266
276
  const config = readText(filePath);
267
277
  if (!config.ok) {
268
278
  return [{ kind: "warn", message: `Codex config not found at ${displayPath(home, filePath)}` }];
@@ -285,9 +295,9 @@ export function inspectCodexConfig({ home = os.homedir(), port = 2026 } = {}) {
285
295
  return checks;
286
296
  }
287
297
 
288
- export function inspectClaudeCodeConfig({ home = os.homedir(), port = 2026 } = {}) {
298
+ export function inspectClaudeCodeConfig({ home = os.homedir(), host = "127.0.0.1", port = 2026 } = {}) {
289
299
  const filePath = path.join(home, ".claude", "settings.json");
290
- const expectedBaseUrl = `http://127.0.0.1:${port}`;
300
+ const expectedBaseUrl = adapterBaseUrl(host, port);
291
301
  const settings = readJson(filePath);
292
302
  if (!settings.ok) {
293
303
  const reason = settings.parseError ? `could not parse: ${settings.error.message}` : `not found at ${displayPath(home, filePath)}`;
@@ -391,8 +401,8 @@ export async function collectDoctorChecks({
391
401
  } = {}) {
392
402
  const checks = [
393
403
  ...inspectGitHubToken({ home }),
394
- ...inspectCodexConfig({ home, port }),
395
- ...inspectClaudeCodeConfig({ home, port }),
404
+ ...inspectCodexConfig({ home, host, port }),
405
+ ...inspectClaudeCodeConfig({ home, host, port }),
396
406
  ...inspectClaudeAppConfig({ home, platform, env, host, port }),
397
407
  ];
398
408
 
@@ -4,6 +4,10 @@ import { status } from "./status.mjs";
4
4
 
5
5
  const DEFAULT_MAX_BODY_BYTES = 64 * 1024 * 1024;
6
6
  const DEFAULT_MAX_DECODED_BODY_BYTES = 128 * 1024 * 1024;
7
+ const DEFAULT_MAX_INFLIGHT_BODY_BYTES = 32 * 1024 * 1024;
8
+ const DEFAULT_MAX_QUEUED_REQUESTS = 16;
9
+ const DEFAULT_REQUEST_QUEUE_TIMEOUT_MS = 120 * 1000;
10
+ const COMPRESSED_BODY_WEIGHT_MULTIPLIER = 4;
7
11
  const gunzipAsync = promisify(zlib.gunzip);
8
12
  const inflateAsync = promisify(zlib.inflate);
9
13
  const brotliDecompressAsync = promisify(zlib.brotliDecompress);
@@ -16,6 +20,9 @@ function positiveInt(value, fallback) {
16
20
 
17
21
  const MAX_BODY_BYTES = positiveInt(process.env.CCDX_MAX_BODY_BYTES, DEFAULT_MAX_BODY_BYTES);
18
22
  const MAX_DECODED_BODY_BYTES = positiveInt(process.env.CCDX_MAX_DECODED_BODY_BYTES, DEFAULT_MAX_DECODED_BODY_BYTES);
23
+ const MAX_INFLIGHT_BODY_BYTES = positiveInt(process.env.CCDX_MAX_INFLIGHT_BODY_BYTES, DEFAULT_MAX_INFLIGHT_BODY_BYTES);
24
+ const MAX_QUEUED_REQUESTS = positiveInt(process.env.CCDX_MAX_QUEUED_REQUESTS, DEFAULT_MAX_QUEUED_REQUESTS);
25
+ const REQUEST_QUEUE_TIMEOUT_MS = positiveInt(process.env.CCDX_REQUEST_QUEUE_TIMEOUT_MS, DEFAULT_REQUEST_QUEUE_TIMEOUT_MS);
19
26
 
20
27
  export function httpError(message, statusCode) {
21
28
  const err = new Error(message);
@@ -31,6 +38,108 @@ function requestContentLength(req) {
31
38
  return Number.parseInt(req.headers?.["content-length"] || "", 10);
32
39
  }
33
40
 
41
+ function requestAdmissionWeight(req, maxBytes) {
42
+ const encodings = contentEncodings(req.headers?.["content-encoding"]);
43
+ const contentLength = requestContentLength(req);
44
+ if (!Number.isFinite(contentLength) || contentLength < 0) return maxBytes;
45
+ const compressed = encodings.some((encoding) => encoding !== "identity");
46
+ const weightedLength = compressed ? contentLength * COMPRESSED_BODY_WEIGHT_MULTIPLIER : contentLength;
47
+ return Math.max(1, Math.min(weightedLength, maxBytes));
48
+ }
49
+
50
+ function admissionAbortError(signal) {
51
+ if (signal?.reason instanceof Error) return signal.reason;
52
+ const error = new Error("The operation was aborted");
53
+ error.name = "AbortError";
54
+ return error;
55
+ }
56
+
57
+ export function createRequestAdmission({
58
+ maxBytes = MAX_INFLIGHT_BODY_BYTES,
59
+ maxQueued = MAX_QUEUED_REQUESTS,
60
+ waitTimeoutMs = REQUEST_QUEUE_TIMEOUT_MS,
61
+ } = {}) {
62
+ const byteLimit = positiveInt(maxBytes, MAX_INFLIGHT_BODY_BYTES);
63
+ const queueLimit = positiveInt(maxQueued, MAX_QUEUED_REQUESTS);
64
+ const timeoutMs = positiveInt(waitTimeoutMs, REQUEST_QUEUE_TIMEOUT_MS);
65
+ const queue = [];
66
+ let activeBytes = 0;
67
+
68
+ const remove = (entry) => {
69
+ const index = queue.indexOf(entry);
70
+ if (index >= 0) queue.splice(index, 1);
71
+ };
72
+
73
+ const activate = (entry) => {
74
+ entry.cleanup();
75
+ activeBytes += entry.weight;
76
+ let released = false;
77
+ entry.resolve(() => {
78
+ if (released) return;
79
+ released = true;
80
+ activeBytes = Math.max(0, activeBytes - entry.weight);
81
+ drain();
82
+ });
83
+ };
84
+
85
+ const drain = () => {
86
+ for (let index = 0; index < queue.length;) {
87
+ const entry = queue[index];
88
+ if (entry.cancelled) {
89
+ queue.splice(index, 1);
90
+ continue;
91
+ }
92
+ if (activeBytes + entry.weight > byteLimit) {
93
+ index += 1;
94
+ continue;
95
+ }
96
+ queue.splice(index, 1);
97
+ activate(entry);
98
+ }
99
+ };
100
+
101
+ const acquire = (req, { signal } = {}) => {
102
+ if (signal?.aborted) return Promise.reject(admissionAbortError(signal));
103
+ const weight = requestAdmissionWeight(req, byteLimit);
104
+ if (queue.length >= queueLimit && activeBytes + weight > byteLimit) {
105
+ return Promise.reject(httpError(`Request queue is full (${queueLimit} waiting)`, 503));
106
+ }
107
+
108
+ return new Promise((resolve, reject) => {
109
+ let timer;
110
+ const entry = {
111
+ cancelled: false,
112
+ weight,
113
+ resolve,
114
+ cleanup: () => {
115
+ if (timer) clearTimeout(timer);
116
+ signal?.removeEventListener("abort", onAbort);
117
+ },
118
+ };
119
+ const cancel = (error) => {
120
+ if (entry.cancelled) return;
121
+ entry.cancelled = true;
122
+ entry.cleanup();
123
+ remove(entry);
124
+ reject(error);
125
+ drain();
126
+ };
127
+ const onAbort = () => cancel(admissionAbortError(signal));
128
+ signal?.addEventListener("abort", onAbort, { once: true });
129
+ timer = setTimeout(() => {
130
+ cancel(httpError(`Request admission timed out after ${timeoutMs}ms`, 503));
131
+ }, timeoutMs);
132
+ timer.unref?.();
133
+ queue.push(entry);
134
+ if (signal?.aborted) onAbort();
135
+ else drain();
136
+ });
137
+ };
138
+
139
+ acquire.stats = () => ({ activeBytes, queued: queue.length, maxBytes: byteLimit });
140
+ return acquire;
141
+ }
142
+
34
143
  async function readRequestBuffer(req, maxBytes) {
35
144
  const contentLength = requestContentLength(req);
36
145
  if (Number.isFinite(contentLength) && contentLength > maxBytes) {
@@ -130,13 +130,16 @@ export async function optimizeImageDataUrl(dataUrl, {
130
130
  const match = /^data:(image\/[a-z+.-]+);base64,(.+)$/i.exec(dataUrl);
131
131
  if (!match) return dataUrl;
132
132
  const mime = match[1].toLowerCase();
133
- const raw = Buffer.from(match[2], "base64");
134
- if (raw.length < IMG_MIN_BYTES || mime.includes("gif")) return dataUrl;
135
- const digest = imageDigest(raw);
136
- if (!force && touchOptimizedImage(digest)) return dataUrl;
133
+ const encoded = match[2];
134
+ const inputBytes = Buffer.byteLength(encoded, "base64");
135
+ if (inputBytes < IMG_MIN_BYTES || mime.includes("gif")) return dataUrl;
137
136
 
138
137
  try {
139
138
  return await runGlobalImageTask(async () => {
139
+ const raw = Buffer.from(encoded, "base64");
140
+ if (raw.length < IMG_MIN_BYTES) return dataUrl;
141
+ const digest = imageDigest(raw);
142
+ if (!force && touchOptimizedImage(digest)) return dataUrl;
140
143
  const resize = await sharp();
141
144
  const image = resize(raw, { failOn: "none", limitInputPixels: IMG_MAX_INPUT_PIXELS });
142
145
  const out = await image
@@ -156,7 +159,7 @@ export async function optimizeImageDataUrl(dataUrl, {
156
159
  }, { signal });
157
160
  } catch (e) {
158
161
  if (signal?.aborted || e?.name === "AbortError") throw e;
159
- console.warn(status("warn", `image optimize failed (${mime}, ${raw.length}b): ${e.message}`));
162
+ console.warn(status("warn", `image optimize failed (${mime}, ${inputBytes}b): ${e.message}`));
160
163
  return dataUrl;
161
164
  }
162
165
  }
package/src/models.mjs CHANGED
@@ -225,6 +225,19 @@ export function resolveOpenAIModel(model, env = process.env) {
225
225
  };
226
226
  }
227
227
 
228
+ export function codexAutoReviewModelStatus(models, env = process.env) {
229
+ const { upstreamModel } = resolveOpenAIModel(CODEX_AUTO_REVIEW_MODEL, env);
230
+ const model = copilotModelData(models).find((entry) => String(entry?.id || "").trim() === upstreamModel);
231
+ if (!model) return { available: false, upstreamModel, reason: "model is not advertised" };
232
+ const endpoints = modelEndpoints(model);
233
+ const available = endpoints.includes("/responses") || endpoints.includes("/v1/responses");
234
+ return {
235
+ available,
236
+ upstreamModel,
237
+ reason: available ? "" : "model does not advertise a Responses endpoint",
238
+ };
239
+ }
240
+
228
241
  export function anthropicModelInfo(model) {
229
242
  return {
230
243
  id: model.id,