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 +15 -3
- package/bin/cli.mjs +12 -7
- package/package.json +4 -2
- package/scripts/benchmark.mjs +202 -3
- package/src/adapter.mjs +38 -6
- package/src/anthropic.mjs +4 -4
- package/src/claude-config.mjs +7 -6
- package/src/cli-options.mjs +17 -9
- package/src/config.mjs +11 -10
- package/src/copilot.mjs +164 -22
- package/src/doctor.mjs +182 -11
- package/src/http-transport.mjs +109 -0
- package/src/image-optimization.mjs +8 -5
- package/src/models.mjs +13 -0
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
|
-
|
|
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
|
|
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
|
|
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:
|
|
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.
|
|
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": {
|
package/scripts/benchmark.mjs
CHANGED
|
@@ -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
|
-
|
|
130
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
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
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
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(
|
|
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
|
|
5
|
+
async function tokenCounter() {
|
|
6
6
|
tokenizerModulePromise ||= import("gpt-tokenizer");
|
|
7
|
-
return (await tokenizerModulePromise).
|
|
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
|
|
286
|
-
return { input_tokens:
|
|
285
|
+
const count = await tokenCounter();
|
|
286
|
+
return { input_tokens: count(text) };
|
|
287
287
|
}
|
package/src/claude-config.mjs
CHANGED
|
@@ -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 =
|
|
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 =
|
|
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/cli-options.mjs
CHANGED
|
@@ -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
|
|
24
|
+
const supported = new Set(["--online", "--compat"]);
|
|
25
|
+
const invalid = rest.filter((arg) => !supported.has(arg));
|
|
25
26
|
if (invalid.length) unexpectedArgs(invalid);
|
|
26
|
-
|
|
27
|
-
|
|
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`;
|