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 CHANGED
@@ -64,7 +64,15 @@ codex-copilot-dx doctor --online
64
64
 
65
65
  The online doctor never starts device flow, scans for replacement tokens, or changes the saved token.
66
66
 
67
- When the saved token is missing, `codex-copilot-dx` first looks for compatible local Copilot GitHub tokens, validates them with GitHub and Copilot, and imports a valid one before starting device login. It checks explicit token sources (`CCDX_GITHUB_TOKEN`, `CCDX_GITHUB_TOKEN_PATH`, `CCDX_GITHUB_TOKEN_PATHS`) plus common local `auth.json` layouts under application config directories. It does not rely on a specific app name. Generic discovery refuses to choose silently when valid tokens for multiple GitHub accounts are found. After an account is selected, automatic `401`/`403` recovery accepts only the same GitHub account. Explicit token variables remain the intentional way to switch accounts.
67
+ To actively verify the protocol path through an already-running adapter, run:
68
+
69
+ ```bash
70
+ codex-copilot-dx doctor --compat
71
+ ```
72
+
73
+ The compatibility doctor sends a few minimal Copilot requests to check 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 all three checks on supported Node.js release lines.
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
@@ -187,6 +187,7 @@ if (CLI.command === "doctor") {
187
187
  port: ADAPTER_PORT,
188
188
  host: ADAPTER_HOST,
189
189
  online: CLI.online,
190
+ compat: CLI.compat,
190
191
  });
191
192
  process.exit(checks.some((check) => check.kind === "err") ? 1 : 0);
192
193
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-copilot-dx",
3
- "version": "0.4.33",
3
+ "version": "0.4.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"
@@ -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
- console.log(JSON.stringify({
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
- }, null, 2));
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));
@@ -7,28 +7,36 @@ function unexpectedArgs(args) {
7
7
 
8
8
  export function parseCliArgs(args = []) {
9
9
  const [command, ...rest] = args;
10
- if (!command) return { command: "start", configureClaudeDesktop: false, online: false };
10
+ if (!command) return { command: "start", configureClaudeDesktop: false, online: false, compat: false };
11
11
  if (HELP_COMMANDS.has(command)) {
12
12
  if (rest.length) unexpectedArgs(rest);
13
- return { command: "help", configureClaudeDesktop: false, online: false };
13
+ return { command: "help", configureClaudeDesktop: false, online: false, compat: false };
14
14
  }
15
15
  if (VERSION_COMMANDS.has(command)) {
16
16
  if (rest.length) unexpectedArgs(rest);
17
- return { command: "version", configureClaudeDesktop: false, online: false };
17
+ return { command: "version", configureClaudeDesktop: false, online: false, compat: false };
18
18
  }
19
19
  if (command === "usage") {
20
20
  if (rest.length) unexpectedArgs(rest);
21
- return { command: "usage", configureClaudeDesktop: false, online: false };
21
+ return { command: "usage", configureClaudeDesktop: false, online: false, compat: false };
22
22
  }
23
23
  if (command === "doctor" || command === "--doctor") {
24
- const invalid = rest.filter((arg) => arg !== "--online");
24
+ const supported = new Set(["--online", "--compat"]);
25
+ const invalid = rest.filter((arg) => !supported.has(arg));
25
26
  if (invalid.length) unexpectedArgs(invalid);
26
- if (rest.filter((arg) => arg === "--online").length > 1) unexpectedArgs(["--online"]);
27
- return { command: "doctor", configureClaudeDesktop: false, online: rest.includes("--online") };
27
+ for (const option of supported) {
28
+ if (rest.filter((arg) => arg === option).length > 1) unexpectedArgs([option]);
29
+ }
30
+ return {
31
+ command: "doctor",
32
+ configureClaudeDesktop: false,
33
+ online: rest.includes("--online"),
34
+ compat: rest.includes("--compat"),
35
+ };
28
36
  }
29
37
  if (command === "--configure-claude-desktop") {
30
38
  if (rest.length) unexpectedArgs(rest);
31
- return { command: "start", configureClaudeDesktop: true, online: false };
39
+ return { command: "start", configureClaudeDesktop: true, online: false, compat: false };
32
40
  }
33
41
  throw new Error(`Unknown command or option: ${command}`);
34
42
  }
@@ -62,7 +70,7 @@ export function parseRuntimeOptions(env = process.env) {
62
70
  export function cliHelp() {
63
71
  return `Usage:
64
72
  codex-copilot-dx [--configure-claude-desktop]
65
- codex-copilot-dx doctor [--online]
73
+ codex-copilot-dx doctor [--online] [--compat]
66
74
  codex-copilot-dx usage
67
75
  codex-copilot-dx --version
68
76
  codex-copilot-dx --help`;
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 { ensureGithubTokenMetadata, githubReauthMessage, githubTokenPath, importDiscoveredGithubToken } from "./auth.mjs";
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 cacheCopilotTokenData(data) {
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 resp = await requestCopilotToken(ghToken, { fetchImpl, signal });
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
- return cacheCopilotTokenData(imported.validation.copilotTokenData);
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 new Error(`Failed to get Copilot token: ${resp.status}`);
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 ensureGithubTokenMetadata(ghToken, { home, fetchImpl, signal });
327
- return cacheCopilotTokenData(data);
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
- export async function getCopilotToken(options = {}) {
331
- if (copilotToken && Date.now() < copilotTokenExpiry - 60000) return copilotToken;
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 refreshKey = githubTokenPath(home);
334
- if (copilotTokenRefresh?.key === refreshKey) return copilotTokenRefresh.promise;
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
- const promise = refreshCopilotToken(options).finally(() => {
337
- if (copilotTokenRefresh?.promise === promise) copilotTokenRefresh = null;
338
- });
339
- copilotTokenRefresh = { key: refreshKey, promise };
340
- return promise;
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 waitForModelListFlight(flight, signal) {
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 waitForModelListFlight(flight, signal);
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
- if (checkAdapter) {
255
- let running;
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
- log(`codex-copilot-dx doctor${options.online ? " --online" : ""}`);
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;