codex-copilot-dx 0.4.33 → 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 +2 -1
- package/scripts/benchmark.mjs +125 -2
- package/src/cli-options.mjs +17 -9
- package/src/copilot.mjs +149 -19
- package/src/doctor.mjs +164 -3
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",
|
|
@@ -43,6 +43,7 @@
|
|
|
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",
|
|
46
47
|
"pack:check": "npm pack --dry-run --json",
|
|
47
48
|
"verify": "npm test && npm run test:smoke && npm run pack:check",
|
|
48
49
|
"prepublishOnly": "npm run verify"
|
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,114 @@ 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 { 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
|
+
|
|
32
143
|
const adapterImport = runProbe(`
|
|
33
144
|
const started = performance.now();
|
|
34
145
|
await import("./src/adapter.mjs");
|
|
@@ -126,10 +237,22 @@ try {
|
|
|
126
237
|
console.warn = originalWarn;
|
|
127
238
|
}
|
|
128
239
|
|
|
129
|
-
|
|
240
|
+
const report = {
|
|
130
241
|
note: "Report-only benchmark; timings vary by machine and are not pass/fail thresholds.",
|
|
131
242
|
adapter_import: adapterImport,
|
|
132
243
|
token_count_100kb: tokenCount,
|
|
133
244
|
duplicate_images: duplicateImages,
|
|
134
245
|
oversized_payload: oversizedPayload,
|
|
135
|
-
}
|
|
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/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,9 +1,16 @@
|
|
|
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 {
|
|
@@ -267,6 +274,10 @@ export async function refreshVSCodeVersion() {
|
|
|
267
274
|
let copilotToken = null;
|
|
268
275
|
let copilotTokenExpiry = 0;
|
|
269
276
|
let copilotTokenRefresh = null;
|
|
277
|
+
let copilotTokenSourceKey = null;
|
|
278
|
+
let copilotTokenGithubFingerprint = "";
|
|
279
|
+
let copilotTokenGithubIdentity = null;
|
|
280
|
+
let copilotTokenRefreshRetryAt = 0;
|
|
270
281
|
const modelListFlights = new Map();
|
|
271
282
|
|
|
272
283
|
function getGithubToken({ home = os.homedir() } = {}) {
|
|
@@ -286,17 +297,76 @@ function requestCopilotToken(ghToken, { fetchImpl = fetch, signal } = {}) {
|
|
|
286
297
|
});
|
|
287
298
|
}
|
|
288
299
|
|
|
289
|
-
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
|
+
} = {}) {
|
|
290
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
|
+
}
|
|
291
343
|
copilotToken = data.token;
|
|
292
344
|
apiBase = parseApiBase(data);
|
|
293
345
|
copilotTokenExpiry = typeof data.expires_at === "number"
|
|
294
346
|
? data.expires_at * 1000
|
|
295
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;
|
|
296
352
|
console.log(status("ok", "Copilot token refreshed"));
|
|
297
353
|
return copilotToken;
|
|
298
354
|
}
|
|
299
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
|
+
|
|
300
370
|
async function refreshCopilotToken({
|
|
301
371
|
signal,
|
|
302
372
|
home = os.homedir(),
|
|
@@ -304,7 +374,14 @@ async function refreshCopilotToken({
|
|
|
304
374
|
fetchImpl = fetch,
|
|
305
375
|
} = {}) {
|
|
306
376
|
const ghToken = getGithubToken({ home });
|
|
307
|
-
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
|
+
}
|
|
308
385
|
if (!resp.ok) {
|
|
309
386
|
if (resp.status === 401 || resp.status === 403) {
|
|
310
387
|
const imported = await importDiscoveredGithubToken({
|
|
@@ -316,34 +393,87 @@ async function refreshCopilotToken({
|
|
|
316
393
|
validateSavedToken: true,
|
|
317
394
|
});
|
|
318
395
|
if (imported?.validation?.copilotTokenData?.token) {
|
|
319
|
-
|
|
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
|
+
});
|
|
320
403
|
}
|
|
321
404
|
throw new Error(githubReauthMessage(`Failed to get Copilot token: ${resp.status}. The saved GitHub token may be expired, revoked, or missing Copilot access.`));
|
|
322
405
|
}
|
|
323
|
-
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
|
+
});
|
|
324
410
|
}
|
|
325
411
|
const data = await resp.json();
|
|
326
|
-
await
|
|
327
|
-
|
|
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;
|
|
328
420
|
}
|
|
329
421
|
|
|
330
|
-
|
|
331
|
-
|
|
422
|
+
function canUseCachedToken(sourceKey, now = Date.now(), allowCurrentProcessToken = false) {
|
|
423
|
+
const sameSource = copilotTokenSourceKey === sourceKey || allowCurrentProcessToken;
|
|
424
|
+
return Boolean(copilotToken && sameSource && now < copilotTokenExpiry);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
export function getCopilotToken(options = {}) {
|
|
332
428
|
const home = options.home || os.homedir();
|
|
333
|
-
const
|
|
334
|
-
|
|
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));
|
|
335
439
|
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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);
|
|
341
466
|
}
|
|
342
467
|
|
|
343
468
|
export function resetCopilotTokenForTests() {
|
|
469
|
+
copilotTokenRefresh?.controller.abort();
|
|
344
470
|
copilotToken = null;
|
|
345
471
|
copilotTokenExpiry = 0;
|
|
346
472
|
copilotTokenRefresh = null;
|
|
473
|
+
copilotTokenSourceKey = null;
|
|
474
|
+
copilotTokenGithubFingerprint = "";
|
|
475
|
+
copilotTokenGithubIdentity = null;
|
|
476
|
+
copilotTokenRefreshRetryAt = 0;
|
|
347
477
|
apiBase = DEFAULT_API_BASE;
|
|
348
478
|
resetModelListSingleflightForTests();
|
|
349
479
|
}
|
|
@@ -379,7 +509,7 @@ async function fetchModels({ signal, fetchImpl, retryOptions }) {
|
|
|
379
509
|
return { status: resp.status, body };
|
|
380
510
|
}
|
|
381
511
|
|
|
382
|
-
function
|
|
512
|
+
function waitForSingleflight(flight, signal) {
|
|
383
513
|
if (signal?.aborted) return Promise.reject(abortError(signal));
|
|
384
514
|
flight.waiters += 1;
|
|
385
515
|
|
|
@@ -422,7 +552,7 @@ export function listModels({ signal, fetchImpl = fetch, retryOptions } = {}) {
|
|
|
422
552
|
modelListFlights.set(fetchImpl, flight);
|
|
423
553
|
}
|
|
424
554
|
|
|
425
|
-
return
|
|
555
|
+
return waitForSingleflight(flight, signal);
|
|
426
556
|
}
|
|
427
557
|
|
|
428
558
|
export function resetModelListSingleflightForTests() {
|
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;
|