codex-copilot-dx 0.4.32 → 0.4.34
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 +11 -2
- package/bin/cli.mjs +1 -0
- package/package.json +4 -1
- package/scripts/benchmark.mjs +258 -0
- package/src/adapter.mjs +1 -1
- package/src/anthropic.mjs +9 -2
- package/src/cli-options.mjs +17 -9
- package/src/copilot.mjs +203 -19
- package/src/doctor.mjs +164 -3
- package/src/image-optimization.mjs +95 -20
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 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
|
|
|
@@ -167,6 +175,7 @@ MIT
|
|
|
167
175
|
```bash
|
|
168
176
|
npm ci
|
|
169
177
|
npm run verify
|
|
178
|
+
npm run bench:payload
|
|
170
179
|
```
|
|
171
180
|
|
|
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
|
|
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.
|
package/bin/cli.mjs
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "codex-copilot-dx",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.34",
|
|
4
4
|
"description": "Use Codex Desktop, Claude Code, and Claude App with GitHub Copilot",
|
|
5
5
|
"author": "Dale Xiao",
|
|
6
6
|
"type": "module",
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
"files": [
|
|
11
11
|
"bin/",
|
|
12
12
|
"src/",
|
|
13
|
+
"scripts/benchmark.mjs",
|
|
13
14
|
"README.md"
|
|
14
15
|
],
|
|
15
16
|
"keywords": [
|
|
@@ -41,6 +42,8 @@
|
|
|
41
42
|
"test": "node --test",
|
|
42
43
|
"test:smoke": "node scripts/offline-smoke.mjs",
|
|
43
44
|
"test:e2e": "bash test/e2e.sh",
|
|
45
|
+
"bench": "node scripts/benchmark.mjs",
|
|
46
|
+
"bench:payload": "node scripts/benchmark.mjs --large-payload",
|
|
44
47
|
"pack:check": "npm pack --dry-run --json",
|
|
45
48
|
"verify": "npm test && npm run test:smoke && npm run pack:check",
|
|
46
49
|
"prepublishOnly": "npm run verify"
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
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";
|
|
7
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
import sharp from "sharp";
|
|
10
|
+
import { optimizeImagesInBody, prepareResponsesPayload } from "../src/image-optimization.mjs";
|
|
11
|
+
|
|
12
|
+
const packageRoot = fileURLToPath(new URL("../", import.meta.url));
|
|
13
|
+
|
|
14
|
+
function runProbe(source) {
|
|
15
|
+
const result = spawnSync(process.execPath, ["--expose-gc", "--input-type=module", "-e", source], {
|
|
16
|
+
cwd: packageRoot,
|
|
17
|
+
encoding: "utf8",
|
|
18
|
+
});
|
|
19
|
+
if (result.status !== 0) {
|
|
20
|
+
throw new Error(result.stderr.trim() || `benchmark probe exited with ${result.status}`);
|
|
21
|
+
}
|
|
22
|
+
return JSON.parse(result.stdout.trim());
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function deterministicPixels(byteLength, initialSeed) {
|
|
26
|
+
const pixels = Buffer.alloc(byteLength);
|
|
27
|
+
let seed = initialSeed;
|
|
28
|
+
for (let index = 0; index < pixels.length; index += 1) {
|
|
29
|
+
seed = ((seed * 1664525) + 1013904223) >>> 0;
|
|
30
|
+
pixels[index] = seed >>> 24;
|
|
31
|
+
}
|
|
32
|
+
return pixels;
|
|
33
|
+
}
|
|
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 { readJsonBody } from "./src/http-transport.mjs";
|
|
76
|
+
import { prepareResponsesPayload } from "./src/image-optimization.mjs";
|
|
77
|
+
|
|
78
|
+
const fixtureFiles = ${JSON.stringify(fixtureFiles)};
|
|
79
|
+
const memoryFields = ["rss", "heapUsed", "external", "arrayBuffers"];
|
|
80
|
+
const peak = {};
|
|
81
|
+
const sample = () => {
|
|
82
|
+
const memory = process.memoryUsage();
|
|
83
|
+
for (const field of memoryFields) peak[field] = Math.max(peak[field] || 0, memory[field] || 0);
|
|
84
|
+
};
|
|
85
|
+
globalThis.gc?.();
|
|
86
|
+
const idleMemory = process.memoryUsage();
|
|
87
|
+
for (const field of memoryFields) peak[field] = idleMemory[field] || 0;
|
|
88
|
+
const timer = setInterval(sample, 5);
|
|
89
|
+
timer.unref?.();
|
|
90
|
+
|
|
91
|
+
async function processRequest(filePath) {
|
|
92
|
+
const inputBytes = fs.statSync(filePath).size;
|
|
93
|
+
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 };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const originalLog = console.log;
|
|
105
|
+
const originalWarn = console.warn;
|
|
106
|
+
console.log = () => {};
|
|
107
|
+
console.warn = () => {};
|
|
108
|
+
const started = performance.now();
|
|
109
|
+
let results;
|
|
110
|
+
try {
|
|
111
|
+
results = await Promise.all(fixtureFiles.map(processRequest));
|
|
112
|
+
} finally {
|
|
113
|
+
clearInterval(timer);
|
|
114
|
+
console.log = originalLog;
|
|
115
|
+
console.warn = originalWarn;
|
|
116
|
+
}
|
|
117
|
+
sample();
|
|
118
|
+
const finalMemory = process.memoryUsage();
|
|
119
|
+
const mib = (bytes) => +(bytes / 1048576).toFixed(1);
|
|
120
|
+
const memory = Object.fromEntries(memoryFields.map((field) => [field.replace("Used", "_used").replace("Buffers", "_buffers").toLowerCase(), {
|
|
121
|
+
idle_mib: mib(idleMemory[field] || 0),
|
|
122
|
+
peak_mib: mib(peak[field] || 0),
|
|
123
|
+
peak_delta_mib: mib((peak[field] || 0) - (idleMemory[field] || 0)),
|
|
124
|
+
final_mib: mib(finalMemory[field] || 0),
|
|
125
|
+
}]));
|
|
126
|
+
process.stdout.write(JSON.stringify({
|
|
127
|
+
target_request_mib: ${targetMiB},
|
|
128
|
+
concurrency: fixtureFiles.length,
|
|
129
|
+
elapsed_ms: +(performance.now() - started).toFixed(1),
|
|
130
|
+
input_body_mib: results.map((entry) => mib(entry.inputBytes)),
|
|
131
|
+
output_body_mib: results.map((entry) => mib(entry.outputBytes)),
|
|
132
|
+
output_body_kib: results.map((entry) => +(entry.outputBytes / 1024).toFixed(1)),
|
|
133
|
+
memory,
|
|
134
|
+
}));
|
|
135
|
+
`);
|
|
136
|
+
result.fixture_prepare_ms = +fixturePrepareMs.toFixed(1);
|
|
137
|
+
return result;
|
|
138
|
+
} finally {
|
|
139
|
+
fs.rmSync(fixtureDir, { recursive: true, force: true });
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const adapterImport = runProbe(`
|
|
144
|
+
const started = performance.now();
|
|
145
|
+
await import("./src/adapter.mjs");
|
|
146
|
+
globalThis.gc?.();
|
|
147
|
+
const memory = process.memoryUsage();
|
|
148
|
+
console.log(JSON.stringify({
|
|
149
|
+
elapsed_ms: +(performance.now() - started).toFixed(1),
|
|
150
|
+
rss_mib: +(memory.rss / 1048576).toFixed(1),
|
|
151
|
+
heap_used_mib: +(memory.heapUsed / 1048576).toFixed(1),
|
|
152
|
+
}));
|
|
153
|
+
`);
|
|
154
|
+
|
|
155
|
+
const tokenCount = runProbe(`
|
|
156
|
+
const importStarted = performance.now();
|
|
157
|
+
const { countTokens } = await import("./src/anthropic.mjs");
|
|
158
|
+
const importMs = performance.now() - importStarted;
|
|
159
|
+
const body = { messages: [{ role: "user", content: "a ".repeat(50000) }] };
|
|
160
|
+
const firstStarted = performance.now();
|
|
161
|
+
const first = await countTokens(body);
|
|
162
|
+
const firstMs = performance.now() - firstStarted;
|
|
163
|
+
const warmStarted = performance.now();
|
|
164
|
+
const warm = await countTokens(body);
|
|
165
|
+
const warmMs = performance.now() - warmStarted;
|
|
166
|
+
globalThis.gc?.();
|
|
167
|
+
const memory = process.memoryUsage();
|
|
168
|
+
console.log(JSON.stringify({
|
|
169
|
+
module_import_ms: +importMs.toFixed(1),
|
|
170
|
+
first_call_ms: +firstMs.toFixed(1),
|
|
171
|
+
warm_call_ms: +warmMs.toFixed(1),
|
|
172
|
+
input_tokens: first.input_tokens,
|
|
173
|
+
deterministic: first.input_tokens === warm.input_tokens,
|
|
174
|
+
rss_mib: +(memory.rss / 1048576).toFixed(1),
|
|
175
|
+
}));
|
|
176
|
+
`);
|
|
177
|
+
|
|
178
|
+
const repeatedImage = "data:image/png;base64,QUJDRA==";
|
|
179
|
+
const duplicateBody = {
|
|
180
|
+
input: [{
|
|
181
|
+
type: "message",
|
|
182
|
+
role: "user",
|
|
183
|
+
content: Array.from({ length: 8 }, () => ({ type: "input_image", image_url: repeatedImage })),
|
|
184
|
+
}],
|
|
185
|
+
};
|
|
186
|
+
let duplicateCalls = 0;
|
|
187
|
+
const duplicateStarted = performance.now();
|
|
188
|
+
await optimizeImagesInBody(duplicateBody, {
|
|
189
|
+
concurrency: 2,
|
|
190
|
+
optimizeImage: async (value) => {
|
|
191
|
+
duplicateCalls += 1;
|
|
192
|
+
await delay(5);
|
|
193
|
+
return value;
|
|
194
|
+
},
|
|
195
|
+
});
|
|
196
|
+
const duplicateImages = {
|
|
197
|
+
elapsed_ms: +(performance.now() - duplicateStarted).toFixed(1),
|
|
198
|
+
image_occurrences: 8,
|
|
199
|
+
optimizer_calls: duplicateCalls,
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
const pixels = deterministicPixels(1024 * 1024 * 3, 0x87654321);
|
|
203
|
+
const webp = await sharp(pixels, { raw: { width: 1024, height: 1024, channels: 3 } })
|
|
204
|
+
.webp({ quality: 100 })
|
|
205
|
+
.toBuffer();
|
|
206
|
+
const payloadBody = {
|
|
207
|
+
input: [{
|
|
208
|
+
type: "message",
|
|
209
|
+
role: "user",
|
|
210
|
+
content: [{ type: "input_image", image_url: `data:image/webp;base64,${webp.toString("base64")}` }],
|
|
211
|
+
}],
|
|
212
|
+
};
|
|
213
|
+
const originalLog = console.log;
|
|
214
|
+
const originalWarn = console.warn;
|
|
215
|
+
let oversizedPayload;
|
|
216
|
+
let profilePasses = 0;
|
|
217
|
+
try {
|
|
218
|
+
console.log = () => {};
|
|
219
|
+
console.warn = () => { profilePasses += 1; };
|
|
220
|
+
const started = performance.now();
|
|
221
|
+
const result = await prepareResponsesPayload(payloadBody, {
|
|
222
|
+
maxBytes: 300000,
|
|
223
|
+
profiles: [
|
|
224
|
+
{ maxDim: 800, quality: 75 },
|
|
225
|
+
{ maxDim: 640, quality: 65 },
|
|
226
|
+
],
|
|
227
|
+
});
|
|
228
|
+
oversizedPayload = {
|
|
229
|
+
elapsed_ms: +(performance.now() - started).toFixed(1),
|
|
230
|
+
original_image_bytes: webp.length,
|
|
231
|
+
output_body_bytes: result.bodyBytes,
|
|
232
|
+
profile_passes: profilePasses,
|
|
233
|
+
adapted: result.adapted,
|
|
234
|
+
};
|
|
235
|
+
} finally {
|
|
236
|
+
console.log = originalLog;
|
|
237
|
+
console.warn = originalWarn;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const report = {
|
|
241
|
+
note: "Report-only benchmark; timings vary by machine and are not pass/fail thresholds.",
|
|
242
|
+
adapter_import: adapterImport,
|
|
243
|
+
token_count_100kb: tokenCount,
|
|
244
|
+
duplicate_images: duplicateImages,
|
|
245
|
+
oversized_payload: oversizedPayload,
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
if (process.argv.includes("--large-payload")) {
|
|
249
|
+
report.large_payload_peak = [
|
|
250
|
+
largePayloadProbe(5, 1),
|
|
251
|
+
largePayloadProbe(5, 4),
|
|
252
|
+
largePayloadProbe(30, 1),
|
|
253
|
+
largePayloadProbe(30, 2),
|
|
254
|
+
largePayloadProbe(60, 1),
|
|
255
|
+
];
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
console.log(JSON.stringify(report, null, 2));
|
package/src/adapter.mjs
CHANGED
|
@@ -890,7 +890,7 @@ export function createAdapterHandler(options = {}) {
|
|
|
890
890
|
try {
|
|
891
891
|
const parsed = await readJsonBody(req);
|
|
892
892
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
893
|
-
res.end(JSON.stringify(countTokens(parsed)));
|
|
893
|
+
res.end(JSON.stringify(await countTokens(parsed)));
|
|
894
894
|
} catch (e) {
|
|
895
895
|
sendJsonError(res, e);
|
|
896
896
|
} finally {
|
package/src/anthropic.mjs
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
-
|
|
2
|
+
|
|
3
|
+
let tokenizerModulePromise = null;
|
|
4
|
+
|
|
5
|
+
async function tokenEncoder() {
|
|
6
|
+
tokenizerModulePromise ||= import("gpt-tokenizer");
|
|
7
|
+
return (await tokenizerModulePromise).encode;
|
|
8
|
+
}
|
|
3
9
|
|
|
4
10
|
// Anthropic Messages API to OpenAI chat/completions translation.
|
|
5
11
|
// Pure functions only; network calls stay in adapter.mjs and copilot.mjs.
|
|
@@ -252,7 +258,7 @@ export async function streamAnthropicFromLines(lineIterator, emit, model, option
|
|
|
252
258
|
}
|
|
253
259
|
|
|
254
260
|
// Copilot has no count_tokens endpoint; estimate locally with gpt-tokenizer.
|
|
255
|
-
export function countTokens(body) {
|
|
261
|
+
export async function countTokens(body) {
|
|
256
262
|
const parts = [];
|
|
257
263
|
const sys = systemToText(body.system);
|
|
258
264
|
if (sys) parts.push(sys);
|
|
@@ -276,5 +282,6 @@ export function countTokens(body) {
|
|
|
276
282
|
}
|
|
277
283
|
|
|
278
284
|
const text = parts.join("\n");
|
|
285
|
+
const encode = await tokenEncoder();
|
|
279
286
|
return { input_tokens: encode(text).length };
|
|
280
287
|
}
|
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`;
|
package/src/copilot.mjs
CHANGED
|
@@ -1,14 +1,22 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import os from "node:os";
|
|
3
|
-
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
4
4
|
import { status } from "./status.mjs";
|
|
5
5
|
import { debugLog } from "./log.mjs";
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
fetchGithubIdentity,
|
|
8
|
+
githubReauthMessage,
|
|
9
|
+
githubTokenPath,
|
|
10
|
+
importDiscoveredGithubToken,
|
|
11
|
+
readGithubTokenMetadata,
|
|
12
|
+
writeGithubTokenMetadata,
|
|
13
|
+
} from "./auth.mjs";
|
|
7
14
|
import { prepareResponsesPayload } from "./image-optimization.mjs";
|
|
8
15
|
|
|
9
16
|
export {
|
|
10
17
|
optimizeImageDataUrl,
|
|
11
18
|
optimizeImagesInBody,
|
|
19
|
+
createTaskLimiter,
|
|
12
20
|
parseImageConcurrency,
|
|
13
21
|
prepareResponsesPayload,
|
|
14
22
|
runWithConcurrency,
|
|
@@ -266,6 +274,11 @@ export async function refreshVSCodeVersion() {
|
|
|
266
274
|
let copilotToken = null;
|
|
267
275
|
let copilotTokenExpiry = 0;
|
|
268
276
|
let copilotTokenRefresh = null;
|
|
277
|
+
let copilotTokenSourceKey = null;
|
|
278
|
+
let copilotTokenGithubFingerprint = "";
|
|
279
|
+
let copilotTokenGithubIdentity = null;
|
|
280
|
+
let copilotTokenRefreshRetryAt = 0;
|
|
281
|
+
const modelListFlights = new Map();
|
|
269
282
|
|
|
270
283
|
function getGithubToken({ home = os.homedir() } = {}) {
|
|
271
284
|
const GITHUB_TOKEN_PATH = githubTokenPath(home);
|
|
@@ -284,17 +297,76 @@ function requestCopilotToken(ghToken, { fetchImpl = fetch, signal } = {}) {
|
|
|
284
297
|
});
|
|
285
298
|
}
|
|
286
299
|
|
|
287
|
-
function
|
|
300
|
+
function githubTokenFingerprint(token) {
|
|
301
|
+
return createHash("sha256").update(String(token || "")).digest("hex").slice(0, 24);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function normalizeGithubIdentity(identity) {
|
|
305
|
+
if (!identity || typeof identity !== "object") return null;
|
|
306
|
+
const login = typeof identity.login === "string" ? identity.login.trim() : "";
|
|
307
|
+
const id = identity.id === undefined || identity.id === null ? "" : String(identity.id).trim();
|
|
308
|
+
return login || id ? { login, id } : null;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function githubIdentitiesMatch(first, second) {
|
|
312
|
+
const a = normalizeGithubIdentity(first);
|
|
313
|
+
const b = normalizeGithubIdentity(second);
|
|
314
|
+
if (!a || !b) return false;
|
|
315
|
+
if (a.id && b.id) return a.id === b.id;
|
|
316
|
+
return Boolean(a.login && b.login && a.login.toLowerCase() === b.login.toLowerCase());
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function githubIdentityLabel(identity) {
|
|
320
|
+
const normalized = normalizeGithubIdentity(identity);
|
|
321
|
+
return normalized?.login || normalized?.id || "unknown";
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function cacheCopilotTokenData(data, {
|
|
325
|
+
sourceKey,
|
|
326
|
+
githubToken,
|
|
327
|
+
githubIdentity,
|
|
328
|
+
allowAccountSwitch = false,
|
|
329
|
+
} = {}) {
|
|
288
330
|
if (!data.token) throw new Error("Copilot token response missing token field");
|
|
331
|
+
const fingerprint = githubTokenFingerprint(githubToken);
|
|
332
|
+
const identity = normalizeGithubIdentity(githubIdentity);
|
|
333
|
+
const tokenChanged = Boolean(copilotTokenGithubFingerprint && fingerprint !== copilotTokenGithubFingerprint);
|
|
334
|
+
if (!allowAccountSwitch && copilotTokenGithubIdentity && identity
|
|
335
|
+
&& !githubIdentitiesMatch(copilotTokenGithubIdentity, identity)) {
|
|
336
|
+
throw new Error(`Refusing to switch GitHub Copilot account from ${githubIdentityLabel(copilotTokenGithubIdentity)} to ${githubIdentityLabel(identity)} while the adapter is running. Restart codex-copilot-dx to switch accounts intentionally.`);
|
|
337
|
+
}
|
|
338
|
+
if (!allowAccountSwitch && copilotTokenGithubIdentity && tokenChanged && !identity) {
|
|
339
|
+
const error = new Error("The saved GitHub token changed, but its account could not be verified. Keeping the running adapter bound to the existing GitHub account.");
|
|
340
|
+
error.transient = true;
|
|
341
|
+
throw error;
|
|
342
|
+
}
|
|
289
343
|
copilotToken = data.token;
|
|
290
344
|
apiBase = parseApiBase(data);
|
|
291
345
|
copilotTokenExpiry = typeof data.expires_at === "number"
|
|
292
346
|
? data.expires_at * 1000
|
|
293
347
|
: Date.now() + 25 * 60 * 1000; // fallback if expires_at absent: refresh in ~25min
|
|
348
|
+
copilotTokenSourceKey = sourceKey || copilotTokenSourceKey;
|
|
349
|
+
copilotTokenGithubFingerprint = fingerprint;
|
|
350
|
+
if (identity) copilotTokenGithubIdentity = identity;
|
|
351
|
+
copilotTokenRefreshRetryAt = 0;
|
|
294
352
|
console.log(status("ok", "Copilot token refreshed"));
|
|
295
353
|
return copilotToken;
|
|
296
354
|
}
|
|
297
355
|
|
|
356
|
+
async function githubIdentityForToken(token, { home, fetchImpl, signal }) {
|
|
357
|
+
const cached = readGithubTokenMetadata(home, token);
|
|
358
|
+
if (cached) return cached;
|
|
359
|
+
const identity = await fetchGithubIdentity(token, { fetchImpl, signal });
|
|
360
|
+
return identity.ok ? identity : null;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function tokenRefreshError(message, { statusCode, transient = false } = {}) {
|
|
364
|
+
const error = new Error(message);
|
|
365
|
+
if (statusCode) error.statusCode = statusCode;
|
|
366
|
+
if (transient) error.transient = true;
|
|
367
|
+
return error;
|
|
368
|
+
}
|
|
369
|
+
|
|
298
370
|
async function refreshCopilotToken({
|
|
299
371
|
signal,
|
|
300
372
|
home = os.homedir(),
|
|
@@ -302,7 +374,14 @@ async function refreshCopilotToken({
|
|
|
302
374
|
fetchImpl = fetch,
|
|
303
375
|
} = {}) {
|
|
304
376
|
const ghToken = getGithubToken({ home });
|
|
305
|
-
const
|
|
377
|
+
const sourceKey = githubTokenPath(home);
|
|
378
|
+
let resp;
|
|
379
|
+
try {
|
|
380
|
+
resp = await requestCopilotToken(ghToken, { fetchImpl, signal });
|
|
381
|
+
} catch (e) {
|
|
382
|
+
if (!isAbortError(e, signal)) e.transient = true;
|
|
383
|
+
throw e;
|
|
384
|
+
}
|
|
306
385
|
if (!resp.ok) {
|
|
307
386
|
if (resp.status === 401 || resp.status === 403) {
|
|
308
387
|
const imported = await importDiscoveredGithubToken({
|
|
@@ -314,35 +393,89 @@ async function refreshCopilotToken({
|
|
|
314
393
|
validateSavedToken: true,
|
|
315
394
|
});
|
|
316
395
|
if (imported?.validation?.copilotTokenData?.token) {
|
|
317
|
-
|
|
396
|
+
const explicitSource = imported.source?.type === "env" || imported.source?.type === "token-file";
|
|
397
|
+
return cacheCopilotTokenData(imported.validation.copilotTokenData, {
|
|
398
|
+
sourceKey,
|
|
399
|
+
githubToken: imported.token,
|
|
400
|
+
githubIdentity: imported.validation,
|
|
401
|
+
allowAccountSwitch: explicitSource,
|
|
402
|
+
});
|
|
318
403
|
}
|
|
319
404
|
throw new Error(githubReauthMessage(`Failed to get Copilot token: ${resp.status}. The saved GitHub token may be expired, revoked, or missing Copilot access.`));
|
|
320
405
|
}
|
|
321
|
-
throw
|
|
406
|
+
throw tokenRefreshError(`Failed to get Copilot token: ${resp.status}`, {
|
|
407
|
+
statusCode: resp.status,
|
|
408
|
+
transient: resp.status === 408 || resp.status === 425 || resp.status === 429 || resp.status >= 500,
|
|
409
|
+
});
|
|
322
410
|
}
|
|
323
411
|
const data = await resp.json();
|
|
324
|
-
await
|
|
325
|
-
|
|
412
|
+
const identity = await githubIdentityForToken(ghToken, { home, fetchImpl, signal });
|
|
413
|
+
const token = cacheCopilotTokenData(data, {
|
|
414
|
+
sourceKey,
|
|
415
|
+
githubToken: ghToken,
|
|
416
|
+
githubIdentity: identity,
|
|
417
|
+
});
|
|
418
|
+
if (identity) writeGithubTokenMetadata(identity, home, ghToken);
|
|
419
|
+
return token;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function canUseCachedToken(sourceKey, now = Date.now(), allowCurrentProcessToken = false) {
|
|
423
|
+
const sameSource = copilotTokenSourceKey === sourceKey || allowCurrentProcessToken;
|
|
424
|
+
return Boolean(copilotToken && sameSource && now < copilotTokenExpiry);
|
|
326
425
|
}
|
|
327
426
|
|
|
328
|
-
export
|
|
329
|
-
if (copilotToken && Date.now() < copilotTokenExpiry - 60000) return copilotToken;
|
|
427
|
+
export function getCopilotToken(options = {}) {
|
|
330
428
|
const home = options.home || os.homedir();
|
|
331
|
-
const
|
|
332
|
-
|
|
429
|
+
const sourceKey = githubTokenPath(home);
|
|
430
|
+
const now = Date.now();
|
|
431
|
+
const allowCurrentProcessToken = options.home === undefined;
|
|
432
|
+
if (canUseCachedToken(sourceKey, now, allowCurrentProcessToken) && now < copilotTokenExpiry - 60000) {
|
|
433
|
+
return Promise.resolve(copilotToken);
|
|
434
|
+
}
|
|
435
|
+
if (canUseCachedToken(sourceKey, now, allowCurrentProcessToken) && now < copilotTokenRefreshRetryAt) {
|
|
436
|
+
return Promise.resolve(copilotToken);
|
|
437
|
+
}
|
|
438
|
+
if (options.signal?.aborted) return Promise.reject(abortError(options.signal));
|
|
333
439
|
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
440
|
+
let flight = copilotTokenRefresh?.key === sourceKey ? copilotTokenRefresh : null;
|
|
441
|
+
if (flight?.controller.signal.aborted) {
|
|
442
|
+
if (copilotTokenRefresh === flight) copilotTokenRefresh = null;
|
|
443
|
+
flight = null;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
if (!flight) {
|
|
447
|
+
const controller = new AbortController();
|
|
448
|
+
flight = { key: sourceKey, controller, waiters: 0, settled: false, promise: null };
|
|
449
|
+
flight.promise = refreshCopilotToken({ ...options, home, signal: controller.signal })
|
|
450
|
+
.catch((error) => {
|
|
451
|
+
const fallbackNow = Date.now();
|
|
452
|
+
if (error?.transient && canUseCachedToken(sourceKey, fallbackNow)) {
|
|
453
|
+
copilotTokenRefreshRetryAt = Math.min(copilotTokenExpiry, fallbackNow + 5000);
|
|
454
|
+
console.warn(status("warn", `Copilot token refresh failed temporarily; using the existing token until ${new Date(copilotTokenExpiry).toISOString()} (${error.message})`));
|
|
455
|
+
return copilotToken;
|
|
456
|
+
}
|
|
457
|
+
throw error;
|
|
458
|
+
})
|
|
459
|
+
.finally(() => {
|
|
460
|
+
flight.settled = true;
|
|
461
|
+
if (copilotTokenRefresh === flight) copilotTokenRefresh = null;
|
|
462
|
+
});
|
|
463
|
+
copilotTokenRefresh = flight;
|
|
464
|
+
}
|
|
465
|
+
return waitForSingleflight(flight, options.signal);
|
|
339
466
|
}
|
|
340
467
|
|
|
341
468
|
export function resetCopilotTokenForTests() {
|
|
469
|
+
copilotTokenRefresh?.controller.abort();
|
|
342
470
|
copilotToken = null;
|
|
343
471
|
copilotTokenExpiry = 0;
|
|
344
472
|
copilotTokenRefresh = null;
|
|
473
|
+
copilotTokenSourceKey = null;
|
|
474
|
+
copilotTokenGithubFingerprint = "";
|
|
475
|
+
copilotTokenGithubIdentity = null;
|
|
476
|
+
copilotTokenRefreshRetryAt = 0;
|
|
345
477
|
apiBase = DEFAULT_API_BASE;
|
|
478
|
+
resetModelListSingleflightForTests();
|
|
346
479
|
}
|
|
347
480
|
|
|
348
481
|
// chatReq is already converted by the adapter. The caller parses the raw Response.
|
|
@@ -363,7 +496,7 @@ export async function chatCompletions(chatReq, { signal, fetchImpl, retryOptions
|
|
|
363
496
|
}, { fetchImpl, ...retryOptions });
|
|
364
497
|
}
|
|
365
498
|
|
|
366
|
-
|
|
499
|
+
async function fetchModels({ signal, fetchImpl, retryOptions }) {
|
|
367
500
|
const token = await getCopilotToken({ signal });
|
|
368
501
|
const headers = buildHeaders({
|
|
369
502
|
token, version: getVSCodeVersion(), initiator: "user", vision: false,
|
|
@@ -376,10 +509,61 @@ export async function listModels({ signal, fetchImpl, retryOptions } = {}) {
|
|
|
376
509
|
return { status: resp.status, body };
|
|
377
510
|
}
|
|
378
511
|
|
|
512
|
+
function waitForSingleflight(flight, signal) {
|
|
513
|
+
if (signal?.aborted) return Promise.reject(abortError(signal));
|
|
514
|
+
flight.waiters += 1;
|
|
515
|
+
|
|
516
|
+
return new Promise((resolve, reject) => {
|
|
517
|
+
let finished = false;
|
|
518
|
+
const finish = (handler, value) => {
|
|
519
|
+
if (finished) return;
|
|
520
|
+
finished = true;
|
|
521
|
+
signal?.removeEventListener("abort", onAbort);
|
|
522
|
+
flight.waiters -= 1;
|
|
523
|
+
if (!flight.settled && flight.waiters === 0) flight.controller.abort();
|
|
524
|
+
handler(value);
|
|
525
|
+
};
|
|
526
|
+
const onAbort = () => finish(reject, abortError(signal));
|
|
527
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
528
|
+
if (signal?.aborted) onAbort();
|
|
529
|
+
flight.promise.then(
|
|
530
|
+
(value) => finish(resolve, value),
|
|
531
|
+
(error) => finish(reject, error),
|
|
532
|
+
);
|
|
533
|
+
});
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
export function listModels({ signal, fetchImpl = fetch, retryOptions } = {}) {
|
|
537
|
+
if (signal?.aborted) return Promise.reject(abortError(signal));
|
|
538
|
+
let flight = modelListFlights.get(fetchImpl);
|
|
539
|
+
if (flight?.controller.signal.aborted) {
|
|
540
|
+
modelListFlights.delete(fetchImpl);
|
|
541
|
+
flight = null;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
if (!flight) {
|
|
545
|
+
const controller = new AbortController();
|
|
546
|
+
flight = { controller, waiters: 0, settled: false, promise: null };
|
|
547
|
+
flight.promise = fetchModels({ signal: controller.signal, fetchImpl, retryOptions })
|
|
548
|
+
.finally(() => {
|
|
549
|
+
flight.settled = true;
|
|
550
|
+
if (modelListFlights.get(fetchImpl) === flight) modelListFlights.delete(fetchImpl);
|
|
551
|
+
});
|
|
552
|
+
modelListFlights.set(fetchImpl, flight);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
return waitForSingleflight(flight, signal);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
export function resetModelListSingleflightForTests() {
|
|
559
|
+
for (const flight of modelListFlights.values()) flight.controller.abort();
|
|
560
|
+
modelListFlights.clear();
|
|
561
|
+
}
|
|
562
|
+
|
|
379
563
|
// Responses-only models go directly to Copilot's /responses endpoint.
|
|
380
564
|
export async function responses(reqBody, { signal, fetchImpl, retryOptions } = {}) {
|
|
381
565
|
const token = await getCopilotToken({ signal });
|
|
382
|
-
const { bodyText, bodyBytes, summary } = await prepareResponsesPayload(reqBody);
|
|
566
|
+
const { bodyText, bodyBytes, summary } = await prepareResponsesPayload(reqBody, { signal });
|
|
383
567
|
console.log(status("info", `responses payload bytes=${bodyBytes} input_items=${summary.items} images=${summary.images}`));
|
|
384
568
|
const headers = buildHeaders({
|
|
385
569
|
token,
|
package/src/doctor.mjs
CHANGED
|
@@ -48,6 +48,152 @@ function valueLabel(value) {
|
|
|
48
48
|
return value ? `"${value}"` : "missing";
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
function copilotModelData(models) {
|
|
52
|
+
const data = Array.isArray(models) ? models : models?.data;
|
|
53
|
+
return Array.isArray(data) ? data : [];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function modelEndpoints(model) {
|
|
57
|
+
return Array.isArray(model?.supported_endpoints) ? model.supported_endpoints : [];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function selectCompatibilityModels(models) {
|
|
61
|
+
const data = copilotModelData(models).filter((model) => model?.model_picker_enabled !== false);
|
|
62
|
+
const responsesCandidates = data.filter((model) => {
|
|
63
|
+
const id = String(model?.id || "");
|
|
64
|
+
const endpoints = modelEndpoints(model);
|
|
65
|
+
return id.startsWith("gpt-") && (endpoints.includes("/responses") || endpoints.includes("/v1/responses"));
|
|
66
|
+
});
|
|
67
|
+
const responsesOnly = [...responsesCandidates].reverse()
|
|
68
|
+
.find((model) => !modelEndpoints(model).includes("/chat/completions"));
|
|
69
|
+
const claude = data.find((model) => {
|
|
70
|
+
const id = String(model?.id || "");
|
|
71
|
+
const vendor = String(model?.vendor || "").toLowerCase();
|
|
72
|
+
return (id.startsWith("claude-") || vendor === "anthropic")
|
|
73
|
+
&& modelEndpoints(model).includes("/chat/completions");
|
|
74
|
+
});
|
|
75
|
+
return {
|
|
76
|
+
responsesModel: String((responsesOnly || responsesCandidates[0])?.id || ""),
|
|
77
|
+
claudeModel: String(claude?.id || ""),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function fetchTextWithTimeout(fetchImpl, url, init, timeoutMs) {
|
|
82
|
+
const controller = new AbortController();
|
|
83
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
84
|
+
try {
|
|
85
|
+
const resp = await fetchImpl(url, { ...init, signal: controller.signal });
|
|
86
|
+
return { resp, text: await resp.text() };
|
|
87
|
+
} finally {
|
|
88
|
+
clearTimeout(timer);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function responseFailure(resp, text) {
|
|
93
|
+
const detail = String(text || "").replace(/\s+/g, " ").trim().slice(0, 240);
|
|
94
|
+
return new Error(`HTTP ${resp.status}${detail ? `: ${detail}` : ""}`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function compatibilityRequest(fetchImpl, url, body, timeoutMs) {
|
|
98
|
+
const { resp, text } = await fetchTextWithTimeout(fetchImpl, url, {
|
|
99
|
+
method: "POST",
|
|
100
|
+
headers: { "Content-Type": "application/json", Accept: body.stream ? "text/event-stream" : "application/json" },
|
|
101
|
+
body: JSON.stringify(body),
|
|
102
|
+
}, timeoutMs);
|
|
103
|
+
if (!resp.ok) throw responseFailure(resp, text);
|
|
104
|
+
return text;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function runCompatibilityCheck(checks, label, task) {
|
|
108
|
+
const started = Date.now();
|
|
109
|
+
try {
|
|
110
|
+
const value = await task();
|
|
111
|
+
checks.push({ kind: "ok", message: `${label} passed (${Date.now() - started}ms)` });
|
|
112
|
+
return value;
|
|
113
|
+
} catch (e) {
|
|
114
|
+
const reason = e?.name === "AbortError" ? "timed out" : e?.message || "unknown error";
|
|
115
|
+
checks.push({ kind: "err", message: `${label} failed: ${reason}` });
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function parseResponseObject(text) {
|
|
121
|
+
const response = JSON.parse(text);
|
|
122
|
+
if (!response?.id || !Array.isArray(response.output)) throw new Error("response body is missing id or output");
|
|
123
|
+
return response;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export async function inspectAdapterCompatibility({
|
|
127
|
+
host = "127.0.0.1",
|
|
128
|
+
port = 2026,
|
|
129
|
+
fetchImpl = fetch,
|
|
130
|
+
timeoutMs = 120000,
|
|
131
|
+
} = {}) {
|
|
132
|
+
const baseUrl = localGatewayBaseUrl(connectHost(host), port);
|
|
133
|
+
const checks = [];
|
|
134
|
+
const models = await runCompatibilityCheck(checks, "Compatibility model discovery", async () => {
|
|
135
|
+
const { resp, text } = await fetchTextWithTimeout(fetchImpl, `${baseUrl}/v1/models`, { headers: { Accept: "application/json" } }, timeoutMs);
|
|
136
|
+
if (!resp.ok) throw responseFailure(resp, text);
|
|
137
|
+
const parsed = JSON.parse(text);
|
|
138
|
+
if (!copilotModelData(parsed).length) throw new Error("model list is empty");
|
|
139
|
+
return parsed;
|
|
140
|
+
});
|
|
141
|
+
if (!models) return checks;
|
|
142
|
+
|
|
143
|
+
const { responsesModel, claudeModel } = selectCompatibilityModels(models);
|
|
144
|
+
if (!responsesModel) {
|
|
145
|
+
checks.push({ kind: "err", message: "Compatibility Responses check failed: no GPT model advertises /responses" });
|
|
146
|
+
return checks;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const firstResponse = await runCompatibilityCheck(checks, `Native Responses (${responsesModel})`, async () => {
|
|
150
|
+
const text = await compatibilityRequest(fetchImpl, `${baseUrl}/v1/responses`, {
|
|
151
|
+
model: responsesModel,
|
|
152
|
+
stream: false,
|
|
153
|
+
input: "Reply with OK only.",
|
|
154
|
+
}, timeoutMs);
|
|
155
|
+
return parseResponseObject(text);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
if (firstResponse) {
|
|
159
|
+
await runCompatibilityCheck(checks, "Responses stream, history, and image tool compatibility", async () => {
|
|
160
|
+
const text = await compatibilityRequest(fetchImpl, `${baseUrl}/v1/responses`, {
|
|
161
|
+
model: responsesModel,
|
|
162
|
+
stream: true,
|
|
163
|
+
previous_response_id: firstResponse.id,
|
|
164
|
+
input: "Reply with OK again.",
|
|
165
|
+
tools: [{ type: "image_generation" }],
|
|
166
|
+
}, timeoutMs);
|
|
167
|
+
if (!/^event:\s*response\.completed\s*$/m.test(text)) throw new Error("stream did not contain response.completed");
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
await runCompatibilityCheck(checks, "Responses compact", async () => {
|
|
172
|
+
const text = await compatibilityRequest(fetchImpl, `${baseUrl}/v1/responses/compact`, {
|
|
173
|
+
model: responsesModel,
|
|
174
|
+
stream: false,
|
|
175
|
+
input: "Compact this short context.",
|
|
176
|
+
}, timeoutMs);
|
|
177
|
+
parseResponseObject(text);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
if (!claudeModel) {
|
|
181
|
+
checks.push({ kind: "warn", message: "Anthropic stream compatibility skipped because no Claude chat model was advertised" });
|
|
182
|
+
return checks;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
await runCompatibilityCheck(checks, `Anthropic Messages stream (${claudeModel})`, async () => {
|
|
186
|
+
const text = await compatibilityRequest(fetchImpl, `${baseUrl}/v1/messages`, {
|
|
187
|
+
model: claudeModel,
|
|
188
|
+
max_tokens: 16,
|
|
189
|
+
stream: true,
|
|
190
|
+
messages: [{ role: "user", content: "Reply with OK only." }],
|
|
191
|
+
}, timeoutMs);
|
|
192
|
+
if (!/^event:\s*message_stop\s*$/m.test(text)) throw new Error("stream did not contain message_stop");
|
|
193
|
+
});
|
|
194
|
+
return checks;
|
|
195
|
+
}
|
|
196
|
+
|
|
51
197
|
export function inspectGitHubToken({ home = os.homedir() } = {}) {
|
|
52
198
|
const filePath = githubTokenPath(home);
|
|
53
199
|
const token = readText(filePath);
|
|
@@ -237,8 +383,11 @@ export async function collectDoctorChecks({
|
|
|
237
383
|
checkAdapterListeningFn = checkAdapterListening,
|
|
238
384
|
checkRunningAdapterFn = checkRunningAdapter,
|
|
239
385
|
online = false,
|
|
386
|
+
compat = false,
|
|
240
387
|
fetchImpl = fetch,
|
|
241
388
|
onlineTimeoutMs = 10000,
|
|
389
|
+
compatTimeoutMs = 120000,
|
|
390
|
+
inspectAdapterCompatibilityFn = inspectAdapterCompatibility,
|
|
242
391
|
} = {}) {
|
|
243
392
|
const checks = [
|
|
244
393
|
...inspectGitHubToken({ home }),
|
|
@@ -251,13 +400,16 @@ export async function collectDoctorChecks({
|
|
|
251
400
|
checks.push(...await inspectGitHubTokenOnline({ home, fetchImpl, timeoutMs: onlineTimeoutMs }));
|
|
252
401
|
}
|
|
253
402
|
|
|
254
|
-
|
|
255
|
-
|
|
403
|
+
let running = null;
|
|
404
|
+
if (checkAdapter || compat) {
|
|
256
405
|
try {
|
|
257
406
|
running = await checkRunningAdapterFn({ host, port, fetchImpl });
|
|
258
407
|
} catch {
|
|
259
408
|
running = null;
|
|
260
409
|
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
if (checkAdapter) {
|
|
261
413
|
if (running?.ok) {
|
|
262
414
|
checks.push({ kind: "ok", message: `Adapter ${running.data.version} is listening on ${running.baseUrl}` });
|
|
263
415
|
} else if (running?.incompatible) {
|
|
@@ -270,12 +422,21 @@ export async function collectDoctorChecks({
|
|
|
270
422
|
}
|
|
271
423
|
}
|
|
272
424
|
|
|
425
|
+
if (compat) {
|
|
426
|
+
if (!running?.ok) {
|
|
427
|
+
checks.push({ kind: "err", message: "Compatibility checks require a running, version-compatible codex-copilot-dx adapter" });
|
|
428
|
+
} else {
|
|
429
|
+
checks.push(...await inspectAdapterCompatibilityFn({ host, port, fetchImpl, timeoutMs: compatTimeoutMs }));
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
273
433
|
return checks;
|
|
274
434
|
}
|
|
275
435
|
|
|
276
436
|
export async function runDoctor(options = {}) {
|
|
277
437
|
const log = options.log || console.log;
|
|
278
|
-
|
|
438
|
+
const flags = [options.online ? "--online" : "", options.compat ? "--compat" : ""].filter(Boolean);
|
|
439
|
+
log(`codex-copilot-dx doctor${flags.length ? ` ${flags.join(" ")}` : ""}`);
|
|
279
440
|
const checks = await collectDoctorChecks(options);
|
|
280
441
|
for (const check of checks) log(status(check.kind, check.message));
|
|
281
442
|
return checks;
|
|
@@ -35,6 +35,66 @@ export function parseImageConcurrency(value) {
|
|
|
35
35
|
return Number.isFinite(n) && n > 0 ? Math.min(n, IMG_MAX_CONCURRENCY) : DEFAULT_IMG_CONCURRENCY;
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
function abortError(signal) {
|
|
39
|
+
if (signal?.reason instanceof Error) return signal.reason;
|
|
40
|
+
const error = new Error("The operation was aborted");
|
|
41
|
+
error.name = "AbortError";
|
|
42
|
+
return error;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function createTaskLimiter(concurrency) {
|
|
46
|
+
const limit = parseImageConcurrency(concurrency);
|
|
47
|
+
const queue = [];
|
|
48
|
+
let active = 0;
|
|
49
|
+
|
|
50
|
+
const drain = () => {
|
|
51
|
+
while (active < limit && queue.length) {
|
|
52
|
+
const entry = queue.shift();
|
|
53
|
+
if (entry.cancelled) continue;
|
|
54
|
+
entry.cleanup();
|
|
55
|
+
if (entry.signal?.aborted) {
|
|
56
|
+
entry.reject(abortError(entry.signal));
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
active += 1;
|
|
60
|
+
entry.resolve();
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
return async function runLimited(task, { signal } = {}) {
|
|
65
|
+
if (signal?.aborted) throw abortError(signal);
|
|
66
|
+
await new Promise((resolve, reject) => {
|
|
67
|
+
const entry = {
|
|
68
|
+
cancelled: false,
|
|
69
|
+
cleanup: () => signal?.removeEventListener("abort", onAbort),
|
|
70
|
+
reject,
|
|
71
|
+
resolve,
|
|
72
|
+
signal,
|
|
73
|
+
};
|
|
74
|
+
const onAbort = () => {
|
|
75
|
+
entry.cancelled = true;
|
|
76
|
+
entry.cleanup();
|
|
77
|
+
reject(abortError(signal));
|
|
78
|
+
drain();
|
|
79
|
+
};
|
|
80
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
81
|
+
queue.push(entry);
|
|
82
|
+
if (signal?.aborted) onAbort();
|
|
83
|
+
else drain();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
try {
|
|
87
|
+
if (signal?.aborted) throw abortError(signal);
|
|
88
|
+
return await task();
|
|
89
|
+
} finally {
|
|
90
|
+
active -= 1;
|
|
91
|
+
drain();
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const runGlobalImageTask = createTaskLimiter(IMG_CONCURRENCY);
|
|
97
|
+
|
|
38
98
|
async function sharp() {
|
|
39
99
|
sharpImport ||= import("sharp");
|
|
40
100
|
const mod = await sharpImport;
|
|
@@ -63,6 +123,7 @@ export async function optimizeImageDataUrl(dataUrl, {
|
|
|
63
123
|
maxDim = IMG_MAX_DIM,
|
|
64
124
|
quality = IMG_QUALITY,
|
|
65
125
|
force = false,
|
|
126
|
+
signal,
|
|
66
127
|
} = {}) {
|
|
67
128
|
if (IMG_OPT_DISABLED) return dataUrl;
|
|
68
129
|
if (typeof dataUrl !== "string" || !dataUrl.startsWith("data:")) return dataUrl;
|
|
@@ -75,22 +136,26 @@ export async function optimizeImageDataUrl(dataUrl, {
|
|
|
75
136
|
if (!force && touchOptimizedImage(digest)) return dataUrl;
|
|
76
137
|
|
|
77
138
|
try {
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
139
|
+
return await runGlobalImageTask(async () => {
|
|
140
|
+
const resize = await sharp();
|
|
141
|
+
const image = resize(raw, { failOn: "none", limitInputPixels: IMG_MAX_INPUT_PIXELS });
|
|
142
|
+
const out = await image
|
|
143
|
+
.rotate()
|
|
144
|
+
.resize(positiveInt(maxDim, IMG_MAX_DIM), positiveInt(maxDim, IMG_MAX_DIM), { fit: "inside", withoutEnlargement: true })
|
|
145
|
+
.webp({ quality: positiveInt(quality, IMG_QUALITY, 100), effort: 4 })
|
|
146
|
+
.toBuffer();
|
|
147
|
+
if (signal?.aborted) throw abortError(signal);
|
|
148
|
+
if (out.length >= raw.length) {
|
|
149
|
+
rememberOptimizedImage(digest);
|
|
150
|
+
return dataUrl;
|
|
151
|
+
}
|
|
152
|
+
rememberOptimizedImage(imageDigest(out));
|
|
153
|
+
const ratio = ((out.length / raw.length) * 100).toFixed(1);
|
|
154
|
+
console.log(status("info", `image ${(raw.length / 1024).toFixed(0)}KB ${mime} -> ${(out.length / 1024).toFixed(0)}KB webp (${ratio}%)`));
|
|
155
|
+
return `data:image/webp;base64,${out.toString("base64")}`;
|
|
156
|
+
}, { signal });
|
|
93
157
|
} catch (e) {
|
|
158
|
+
if (signal?.aborted || e?.name === "AbortError") throw e;
|
|
94
159
|
console.warn(status("warn", `image optimize failed (${mime}, ${raw.length}b): ${e.message}`));
|
|
95
160
|
return dataUrl;
|
|
96
161
|
}
|
|
@@ -131,19 +196,27 @@ function visitImageParts(parts, tasks, optimizeImage) {
|
|
|
131
196
|
export async function optimizeImagesInBody(reqBody, {
|
|
132
197
|
concurrency = IMG_CONCURRENCY,
|
|
133
198
|
optimizeImage = optimizeImageDataUrl,
|
|
199
|
+
signal,
|
|
134
200
|
} = {}) {
|
|
135
201
|
if (IMG_OPT_DISABLED || !Array.isArray(reqBody.input)) return reqBody;
|
|
136
202
|
const tasks = [];
|
|
137
203
|
const finalize = [];
|
|
204
|
+
const optimizedByDataUrl = new Map();
|
|
205
|
+
const optimizeOnce = (dataUrl) => {
|
|
206
|
+
if (!optimizedByDataUrl.has(dataUrl)) {
|
|
207
|
+
optimizedByDataUrl.set(dataUrl, Promise.resolve().then(() => optimizeImage(dataUrl, { signal })));
|
|
208
|
+
}
|
|
209
|
+
return optimizedByDataUrl.get(dataUrl);
|
|
210
|
+
};
|
|
138
211
|
|
|
139
212
|
for (const item of reqBody.input) {
|
|
140
213
|
if (!item) continue;
|
|
141
214
|
if (item.type === "message" && Array.isArray(item.content)) {
|
|
142
|
-
visitImageParts(item.content, tasks,
|
|
215
|
+
visitImageParts(item.content, tasks, optimizeOnce);
|
|
143
216
|
}
|
|
144
217
|
if (item.type === "function_call_output") {
|
|
145
218
|
if (Array.isArray(item.output)) {
|
|
146
|
-
visitImageParts(item.output, tasks,
|
|
219
|
+
visitImageParts(item.output, tasks, optimizeOnce);
|
|
147
220
|
} else if (typeof item.output === "string") {
|
|
148
221
|
const trimmed = item.output.trim();
|
|
149
222
|
if (trimmed.startsWith("[")) {
|
|
@@ -151,7 +224,7 @@ export async function optimizeImagesInBody(reqBody, {
|
|
|
151
224
|
const parsed = JSON.parse(trimmed);
|
|
152
225
|
if (Array.isArray(parsed)) {
|
|
153
226
|
const taskCount = tasks.length;
|
|
154
|
-
visitImageParts(parsed, tasks,
|
|
227
|
+
visitImageParts(parsed, tasks, optimizeOnce);
|
|
155
228
|
if (tasks.length > taskCount) finalize.push(() => { item.output = JSON.stringify(parsed); });
|
|
156
229
|
}
|
|
157
230
|
} catch {
|
|
@@ -170,8 +243,9 @@ export async function optimizeImagesInBody(reqBody, {
|
|
|
170
243
|
export async function prepareResponsesPayload(reqBody, {
|
|
171
244
|
maxBytes = MAX_UPSTREAM_BODY_BYTES,
|
|
172
245
|
profiles = OVERSIZE_IMAGE_PROFILES,
|
|
246
|
+
signal,
|
|
173
247
|
} = {}) {
|
|
174
|
-
await optimizeImagesInBody(reqBody);
|
|
248
|
+
await optimizeImagesInBody(reqBody, { signal });
|
|
175
249
|
let bodyText = JSON.stringify(reqBody);
|
|
176
250
|
let bodyBytes = Buffer.byteLength(bodyText);
|
|
177
251
|
const summary = summarizeReqBody(reqBody);
|
|
@@ -183,7 +257,8 @@ export async function prepareResponsesPayload(reqBody, {
|
|
|
183
257
|
const beforeBytes = bodyBytes;
|
|
184
258
|
bodyText = "";
|
|
185
259
|
await optimizeImagesInBody(reqBody, {
|
|
186
|
-
optimizeImage: (dataUrl) => optimizeImageDataUrl(dataUrl, { ...profile, force: true }),
|
|
260
|
+
optimizeImage: (dataUrl) => optimizeImageDataUrl(dataUrl, { ...profile, force: true, signal }),
|
|
261
|
+
signal,
|
|
187
262
|
});
|
|
188
263
|
bodyText = JSON.stringify(reqBody);
|
|
189
264
|
bodyBytes = Buffer.byteLength(bodyText);
|