codex-copilot-dx 0.4.33 → 0.4.35
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -3
- package/bin/cli.mjs +12 -7
- package/package.json +4 -2
- package/scripts/benchmark.mjs +202 -3
- package/src/adapter.mjs +38 -6
- package/src/anthropic.mjs +4 -4
- package/src/claude-config.mjs +7 -6
- package/src/cli-options.mjs +17 -9
- package/src/config.mjs +11 -10
- package/src/copilot.mjs +164 -22
- package/src/doctor.mjs +182 -11
- package/src/http-transport.mjs +109 -0
- package/src/image-optimization.mjs +8 -5
- package/src/models.mjs +13 -0
package/src/config.mjs
CHANGED
|
@@ -3,6 +3,7 @@ import path from "node:path";
|
|
|
3
3
|
import os from "node:os";
|
|
4
4
|
import { status } from "./status.mjs";
|
|
5
5
|
import { atomicWriteFileIfChangedSync } from "./atomic-file.mjs";
|
|
6
|
+
import { adapterBaseUrl } from "./running-adapter.mjs";
|
|
6
7
|
|
|
7
8
|
const CONFIG_PATH = path.join(os.homedir(), ".codex", "config.toml");
|
|
8
9
|
|
|
@@ -33,9 +34,9 @@ function setTomlKey(lines, sectionName, key, value) {
|
|
|
33
34
|
return true;
|
|
34
35
|
}
|
|
35
36
|
|
|
36
|
-
export function computeUpdatedCodexConfig(content, adapterPort = 2026) {
|
|
37
|
-
const
|
|
38
|
-
const
|
|
37
|
+
export function computeUpdatedCodexConfig(content, adapterPort = 2026, adapterHost = "127.0.0.1") {
|
|
38
|
+
const anthropicBaseUrl = adapterBaseUrl(adapterHost, adapterPort);
|
|
39
|
+
const baseUrl = `${anthropicBaseUrl}/v1`;
|
|
39
40
|
const hadTrailingNewline = content.endsWith("\n");
|
|
40
41
|
const lines = content.split("\n");
|
|
41
42
|
if (hadTrailingNewline) lines.pop();
|
|
@@ -59,9 +60,9 @@ export function computeUpdatedCodexConfig(content, adapterPort = 2026) {
|
|
|
59
60
|
return { content: lines.join("\n") + (hadTrailingNewline ? "\n" : ""), changed };
|
|
60
61
|
}
|
|
61
62
|
|
|
62
|
-
function initialCodexConfig(adapterPort) {
|
|
63
|
-
const
|
|
64
|
-
const
|
|
63
|
+
function initialCodexConfig(adapterPort, adapterHost) {
|
|
64
|
+
const anthropicBaseUrl = adapterBaseUrl(adapterHost, adapterPort);
|
|
65
|
+
const baseUrl = `${anthropicBaseUrl}/v1`;
|
|
65
66
|
return `openai_base_url = "${baseUrl}"
|
|
66
67
|
|
|
67
68
|
[shell_environment_policy]
|
|
@@ -75,18 +76,18 @@ OPENAI_API_KEY = "dummy"
|
|
|
75
76
|
`;
|
|
76
77
|
}
|
|
77
78
|
|
|
78
|
-
export function ensureCodexConfig(adapterPort = 2026, { filePath = CONFIG_PATH } = {}) {
|
|
79
|
-
const baseUrl =
|
|
79
|
+
export function ensureCodexConfig(adapterPort = 2026, { filePath = CONFIG_PATH, host = "127.0.0.1" } = {}) {
|
|
80
|
+
const baseUrl = `${adapterBaseUrl(host, adapterPort)}/v1`;
|
|
80
81
|
|
|
81
82
|
if (!fs.existsSync(filePath)) {
|
|
82
83
|
// Codex config does not exist yet; create the local proxy defaults.
|
|
83
|
-
atomicWriteFileIfChangedSync(filePath, initialCodexConfig(adapterPort));
|
|
84
|
+
atomicWriteFileIfChangedSync(filePath, initialCodexConfig(adapterPort, host));
|
|
84
85
|
console.log(status("ok", "Created ~/.codex/config.toml"));
|
|
85
86
|
return;
|
|
86
87
|
}
|
|
87
88
|
|
|
88
89
|
const content = fs.readFileSync(filePath, "utf-8");
|
|
89
|
-
const updated = computeUpdatedCodexConfig(content, adapterPort);
|
|
90
|
+
const updated = computeUpdatedCodexConfig(content, adapterPort, host);
|
|
90
91
|
|
|
91
92
|
if (!updated.changed) {
|
|
92
93
|
console.log(status("ok", `Codex already points to ${baseUrl}`));
|
package/src/copilot.mjs
CHANGED
|
@@ -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 {
|
|
@@ -88,6 +95,12 @@ const RETRYABLE_UPSTREAM_ERROR_CODES = new Set([
|
|
|
88
95
|
"EAI_AGAIN",
|
|
89
96
|
"ENOTFOUND",
|
|
90
97
|
]);
|
|
98
|
+
const RETRYABLE_POST_CONNECT_ERROR_CODES = new Set([
|
|
99
|
+
"UND_ERR_CONNECT_TIMEOUT",
|
|
100
|
+
"ECONNREFUSED",
|
|
101
|
+
"EAI_AGAIN",
|
|
102
|
+
"ENOTFOUND",
|
|
103
|
+
]);
|
|
91
104
|
const RETRYABLE_UPSTREAM_STATUSES = new Set([408, 502, 503, 504]);
|
|
92
105
|
|
|
93
106
|
function upstreamTarget(url) {
|
|
@@ -139,9 +152,15 @@ function requestMethod(init = {}) {
|
|
|
139
152
|
return String(init.method || "GET").toUpperCase();
|
|
140
153
|
}
|
|
141
154
|
|
|
142
|
-
export function isRetryableUpstreamError(err, { signal } = {}) {
|
|
155
|
+
export function isRetryableUpstreamError(err, { signal, method = "GET" } = {}) {
|
|
143
156
|
if (isAbortError(err, signal)) return false;
|
|
144
|
-
|
|
157
|
+
const code = upstreamErrorCode(err);
|
|
158
|
+
if (!["GET", "HEAD", "OPTIONS"].includes(String(method || "GET").toUpperCase())) {
|
|
159
|
+
const syscall = err?.cause?.syscall || err?.syscall || "";
|
|
160
|
+
return RETRYABLE_POST_CONNECT_ERROR_CODES.has(code)
|
|
161
|
+
|| (syscall === "connect" && ["ETIMEDOUT", "ECONNRESET"].includes(code));
|
|
162
|
+
}
|
|
163
|
+
return RETRYABLE_UPSTREAM_ERROR_CODES.has(code);
|
|
145
164
|
}
|
|
146
165
|
|
|
147
166
|
export function isRetryableUpstreamStatus(status, method = "GET") {
|
|
@@ -190,7 +209,7 @@ export async function fetchCopilotUpstream(
|
|
|
190
209
|
return resp;
|
|
191
210
|
} catch (e) {
|
|
192
211
|
debugLog(`upstream ${method} ${target} error=${describeUpstreamError(e)} attempt=${attempt + 1}/${retryCount + 1} attempt_ms=${Date.now() - attemptStart} total_ms=${Date.now() - totalStart}`);
|
|
193
|
-
if (attempt >= retryCount || !isRetryableUpstreamError(e, { signal })) throw e;
|
|
212
|
+
if (attempt >= retryCount || !isRetryableUpstreamError(e, { signal, method })) throw e;
|
|
194
213
|
const delay = upstreamRetryDelay(attempt, baseDelay);
|
|
195
214
|
console.warn(status("warn", `upstream ${target} ${describeUpstreamError(e)}; retry ${attempt + 1}/${retryCount} in ${delay}ms`));
|
|
196
215
|
await sleep(delay, { signal });
|
|
@@ -267,6 +286,10 @@ export async function refreshVSCodeVersion() {
|
|
|
267
286
|
let copilotToken = null;
|
|
268
287
|
let copilotTokenExpiry = 0;
|
|
269
288
|
let copilotTokenRefresh = null;
|
|
289
|
+
let copilotTokenSourceKey = null;
|
|
290
|
+
let copilotTokenGithubFingerprint = "";
|
|
291
|
+
let copilotTokenGithubIdentity = null;
|
|
292
|
+
let copilotTokenRefreshRetryAt = 0;
|
|
270
293
|
const modelListFlights = new Map();
|
|
271
294
|
|
|
272
295
|
function getGithubToken({ home = os.homedir() } = {}) {
|
|
@@ -286,17 +309,76 @@ function requestCopilotToken(ghToken, { fetchImpl = fetch, signal } = {}) {
|
|
|
286
309
|
});
|
|
287
310
|
}
|
|
288
311
|
|
|
289
|
-
function
|
|
312
|
+
function githubTokenFingerprint(token) {
|
|
313
|
+
return createHash("sha256").update(String(token || "")).digest("hex").slice(0, 24);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function normalizeGithubIdentity(identity) {
|
|
317
|
+
if (!identity || typeof identity !== "object") return null;
|
|
318
|
+
const login = typeof identity.login === "string" ? identity.login.trim() : "";
|
|
319
|
+
const id = identity.id === undefined || identity.id === null ? "" : String(identity.id).trim();
|
|
320
|
+
return login || id ? { login, id } : null;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function githubIdentitiesMatch(first, second) {
|
|
324
|
+
const a = normalizeGithubIdentity(first);
|
|
325
|
+
const b = normalizeGithubIdentity(second);
|
|
326
|
+
if (!a || !b) return false;
|
|
327
|
+
if (a.id && b.id) return a.id === b.id;
|
|
328
|
+
return Boolean(a.login && b.login && a.login.toLowerCase() === b.login.toLowerCase());
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function githubIdentityLabel(identity) {
|
|
332
|
+
const normalized = normalizeGithubIdentity(identity);
|
|
333
|
+
return normalized?.login || normalized?.id || "unknown";
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function cacheCopilotTokenData(data, {
|
|
337
|
+
sourceKey,
|
|
338
|
+
githubToken,
|
|
339
|
+
githubIdentity,
|
|
340
|
+
allowAccountSwitch = false,
|
|
341
|
+
} = {}) {
|
|
290
342
|
if (!data.token) throw new Error("Copilot token response missing token field");
|
|
343
|
+
const fingerprint = githubTokenFingerprint(githubToken);
|
|
344
|
+
const identity = normalizeGithubIdentity(githubIdentity);
|
|
345
|
+
const tokenChanged = Boolean(copilotTokenGithubFingerprint && fingerprint !== copilotTokenGithubFingerprint);
|
|
346
|
+
if (!allowAccountSwitch && copilotTokenGithubIdentity && identity
|
|
347
|
+
&& !githubIdentitiesMatch(copilotTokenGithubIdentity, identity)) {
|
|
348
|
+
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.`);
|
|
349
|
+
}
|
|
350
|
+
if (!allowAccountSwitch && copilotTokenGithubIdentity && tokenChanged && !identity) {
|
|
351
|
+
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.");
|
|
352
|
+
error.transient = true;
|
|
353
|
+
throw error;
|
|
354
|
+
}
|
|
291
355
|
copilotToken = data.token;
|
|
292
356
|
apiBase = parseApiBase(data);
|
|
293
357
|
copilotTokenExpiry = typeof data.expires_at === "number"
|
|
294
358
|
? data.expires_at * 1000
|
|
295
359
|
: Date.now() + 25 * 60 * 1000; // fallback if expires_at absent: refresh in ~25min
|
|
360
|
+
copilotTokenSourceKey = sourceKey || copilotTokenSourceKey;
|
|
361
|
+
copilotTokenGithubFingerprint = fingerprint;
|
|
362
|
+
if (identity) copilotTokenGithubIdentity = identity;
|
|
363
|
+
copilotTokenRefreshRetryAt = 0;
|
|
296
364
|
console.log(status("ok", "Copilot token refreshed"));
|
|
297
365
|
return copilotToken;
|
|
298
366
|
}
|
|
299
367
|
|
|
368
|
+
async function githubIdentityForToken(token, { home, fetchImpl, signal }) {
|
|
369
|
+
const cached = readGithubTokenMetadata(home, token);
|
|
370
|
+
if (cached) return cached;
|
|
371
|
+
const identity = await fetchGithubIdentity(token, { fetchImpl, signal });
|
|
372
|
+
return identity.ok ? identity : null;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function tokenRefreshError(message, { statusCode, transient = false } = {}) {
|
|
376
|
+
const error = new Error(message);
|
|
377
|
+
if (statusCode) error.statusCode = statusCode;
|
|
378
|
+
if (transient) error.transient = true;
|
|
379
|
+
return error;
|
|
380
|
+
}
|
|
381
|
+
|
|
300
382
|
async function refreshCopilotToken({
|
|
301
383
|
signal,
|
|
302
384
|
home = os.homedir(),
|
|
@@ -304,7 +386,14 @@ async function refreshCopilotToken({
|
|
|
304
386
|
fetchImpl = fetch,
|
|
305
387
|
} = {}) {
|
|
306
388
|
const ghToken = getGithubToken({ home });
|
|
307
|
-
const
|
|
389
|
+
const sourceKey = githubTokenPath(home);
|
|
390
|
+
let resp;
|
|
391
|
+
try {
|
|
392
|
+
resp = await requestCopilotToken(ghToken, { fetchImpl, signal });
|
|
393
|
+
} catch (e) {
|
|
394
|
+
if (!isAbortError(e, signal)) e.transient = true;
|
|
395
|
+
throw e;
|
|
396
|
+
}
|
|
308
397
|
if (!resp.ok) {
|
|
309
398
|
if (resp.status === 401 || resp.status === 403) {
|
|
310
399
|
const imported = await importDiscoveredGithubToken({
|
|
@@ -316,34 +405,87 @@ async function refreshCopilotToken({
|
|
|
316
405
|
validateSavedToken: true,
|
|
317
406
|
});
|
|
318
407
|
if (imported?.validation?.copilotTokenData?.token) {
|
|
319
|
-
|
|
408
|
+
const explicitSource = imported.source?.type === "env" || imported.source?.type === "token-file";
|
|
409
|
+
return cacheCopilotTokenData(imported.validation.copilotTokenData, {
|
|
410
|
+
sourceKey,
|
|
411
|
+
githubToken: imported.token,
|
|
412
|
+
githubIdentity: imported.validation,
|
|
413
|
+
allowAccountSwitch: explicitSource,
|
|
414
|
+
});
|
|
320
415
|
}
|
|
321
416
|
throw new Error(githubReauthMessage(`Failed to get Copilot token: ${resp.status}. The saved GitHub token may be expired, revoked, or missing Copilot access.`));
|
|
322
417
|
}
|
|
323
|
-
throw
|
|
418
|
+
throw tokenRefreshError(`Failed to get Copilot token: ${resp.status}`, {
|
|
419
|
+
statusCode: resp.status,
|
|
420
|
+
transient: resp.status === 408 || resp.status === 425 || resp.status === 429 || resp.status >= 500,
|
|
421
|
+
});
|
|
324
422
|
}
|
|
325
423
|
const data = await resp.json();
|
|
326
|
-
await
|
|
327
|
-
|
|
424
|
+
const identity = await githubIdentityForToken(ghToken, { home, fetchImpl, signal });
|
|
425
|
+
const token = cacheCopilotTokenData(data, {
|
|
426
|
+
sourceKey,
|
|
427
|
+
githubToken: ghToken,
|
|
428
|
+
githubIdentity: identity,
|
|
429
|
+
});
|
|
430
|
+
if (identity) writeGithubTokenMetadata(identity, home, ghToken);
|
|
431
|
+
return token;
|
|
328
432
|
}
|
|
329
433
|
|
|
330
|
-
|
|
331
|
-
|
|
434
|
+
function canUseCachedToken(sourceKey, now = Date.now(), allowCurrentProcessToken = false) {
|
|
435
|
+
const sameSource = copilotTokenSourceKey === sourceKey || allowCurrentProcessToken;
|
|
436
|
+
return Boolean(copilotToken && sameSource && now < copilotTokenExpiry);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
export function getCopilotToken(options = {}) {
|
|
332
440
|
const home = options.home || os.homedir();
|
|
333
|
-
const
|
|
334
|
-
|
|
441
|
+
const sourceKey = githubTokenPath(home);
|
|
442
|
+
const now = Date.now();
|
|
443
|
+
const allowCurrentProcessToken = options.home === undefined;
|
|
444
|
+
if (canUseCachedToken(sourceKey, now, allowCurrentProcessToken) && now < copilotTokenExpiry - 60000) {
|
|
445
|
+
return Promise.resolve(copilotToken);
|
|
446
|
+
}
|
|
447
|
+
if (canUseCachedToken(sourceKey, now, allowCurrentProcessToken) && now < copilotTokenRefreshRetryAt) {
|
|
448
|
+
return Promise.resolve(copilotToken);
|
|
449
|
+
}
|
|
450
|
+
if (options.signal?.aborted) return Promise.reject(abortError(options.signal));
|
|
335
451
|
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
452
|
+
let flight = copilotTokenRefresh?.key === sourceKey ? copilotTokenRefresh : null;
|
|
453
|
+
if (flight?.controller.signal.aborted) {
|
|
454
|
+
if (copilotTokenRefresh === flight) copilotTokenRefresh = null;
|
|
455
|
+
flight = null;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
if (!flight) {
|
|
459
|
+
const controller = new AbortController();
|
|
460
|
+
flight = { key: sourceKey, controller, waiters: 0, settled: false, promise: null };
|
|
461
|
+
flight.promise = refreshCopilotToken({ ...options, home, signal: controller.signal })
|
|
462
|
+
.catch((error) => {
|
|
463
|
+
const fallbackNow = Date.now();
|
|
464
|
+
if (error?.transient && canUseCachedToken(sourceKey, fallbackNow)) {
|
|
465
|
+
copilotTokenRefreshRetryAt = Math.min(copilotTokenExpiry, fallbackNow + 5000);
|
|
466
|
+
console.warn(status("warn", `Copilot token refresh failed temporarily; using the existing token until ${new Date(copilotTokenExpiry).toISOString()} (${error.message})`));
|
|
467
|
+
return copilotToken;
|
|
468
|
+
}
|
|
469
|
+
throw error;
|
|
470
|
+
})
|
|
471
|
+
.finally(() => {
|
|
472
|
+
flight.settled = true;
|
|
473
|
+
if (copilotTokenRefresh === flight) copilotTokenRefresh = null;
|
|
474
|
+
});
|
|
475
|
+
copilotTokenRefresh = flight;
|
|
476
|
+
}
|
|
477
|
+
return waitForSingleflight(flight, options.signal);
|
|
341
478
|
}
|
|
342
479
|
|
|
343
480
|
export function resetCopilotTokenForTests() {
|
|
481
|
+
copilotTokenRefresh?.controller.abort();
|
|
344
482
|
copilotToken = null;
|
|
345
483
|
copilotTokenExpiry = 0;
|
|
346
484
|
copilotTokenRefresh = null;
|
|
485
|
+
copilotTokenSourceKey = null;
|
|
486
|
+
copilotTokenGithubFingerprint = "";
|
|
487
|
+
copilotTokenGithubIdentity = null;
|
|
488
|
+
copilotTokenRefreshRetryAt = 0;
|
|
347
489
|
apiBase = DEFAULT_API_BASE;
|
|
348
490
|
resetModelListSingleflightForTests();
|
|
349
491
|
}
|
|
@@ -379,7 +521,7 @@ async function fetchModels({ signal, fetchImpl, retryOptions }) {
|
|
|
379
521
|
return { status: resp.status, body };
|
|
380
522
|
}
|
|
381
523
|
|
|
382
|
-
function
|
|
524
|
+
function waitForSingleflight(flight, signal) {
|
|
383
525
|
if (signal?.aborted) return Promise.reject(abortError(signal));
|
|
384
526
|
flight.waiters += 1;
|
|
385
527
|
|
|
@@ -422,7 +564,7 @@ export function listModels({ signal, fetchImpl = fetch, retryOptions } = {}) {
|
|
|
422
564
|
modelListFlights.set(fetchImpl, flight);
|
|
423
565
|
}
|
|
424
566
|
|
|
425
|
-
return
|
|
567
|
+
return waitForSingleflight(flight, signal);
|
|
426
568
|
}
|
|
427
569
|
|
|
428
570
|
export function resetModelListSingleflightForTests() {
|
package/src/doctor.mjs
CHANGED
|
@@ -6,7 +6,8 @@ import { githubTokenPath, readGithubTokenMetadata, validateGithubToken } from ".
|
|
|
6
6
|
import { claudeDesktopPaths } from "./claude-desktop-config.mjs";
|
|
7
7
|
import { status } from "./status.mjs";
|
|
8
8
|
import { buildHeaders, DEFAULT_API_BASE, FALLBACK_VSCODE_VERSION } from "./copilot.mjs";
|
|
9
|
-
import { checkRunningAdapter } from "./running-adapter.mjs";
|
|
9
|
+
import { adapterBaseUrl, checkRunningAdapter } from "./running-adapter.mjs";
|
|
10
|
+
import { CODEX_AUTO_REVIEW_MODEL } from "./models.mjs";
|
|
10
11
|
|
|
11
12
|
function localGatewayBaseUrl(host, port) {
|
|
12
13
|
const safeHost = String(host || "127.0.0.1");
|
|
@@ -48,6 +49,161 @@ function valueLabel(value) {
|
|
|
48
49
|
return value ? `"${value}"` : "missing";
|
|
49
50
|
}
|
|
50
51
|
|
|
52
|
+
function copilotModelData(models) {
|
|
53
|
+
const data = Array.isArray(models) ? models : models?.data;
|
|
54
|
+
return Array.isArray(data) ? data : [];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function modelEndpoints(model) {
|
|
58
|
+
return Array.isArray(model?.supported_endpoints) ? model.supported_endpoints : [];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function selectCompatibilityModels(models) {
|
|
62
|
+
const data = copilotModelData(models).filter((model) => model?.model_picker_enabled !== false);
|
|
63
|
+
const responsesCandidates = data.filter((model) => {
|
|
64
|
+
const id = String(model?.id || "");
|
|
65
|
+
const endpoints = modelEndpoints(model);
|
|
66
|
+
return id.startsWith("gpt-") && (endpoints.includes("/responses") || endpoints.includes("/v1/responses"));
|
|
67
|
+
});
|
|
68
|
+
const responsesOnly = [...responsesCandidates].reverse()
|
|
69
|
+
.find((model) => !modelEndpoints(model).includes("/chat/completions"));
|
|
70
|
+
const claude = data.find((model) => {
|
|
71
|
+
const id = String(model?.id || "");
|
|
72
|
+
const vendor = String(model?.vendor || "").toLowerCase();
|
|
73
|
+
return (id.startsWith("claude-") || vendor === "anthropic")
|
|
74
|
+
&& modelEndpoints(model).includes("/chat/completions");
|
|
75
|
+
});
|
|
76
|
+
return {
|
|
77
|
+
responsesModel: String((responsesOnly || responsesCandidates[0])?.id || ""),
|
|
78
|
+
claudeModel: String(claude?.id || ""),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function fetchTextWithTimeout(fetchImpl, url, init, timeoutMs) {
|
|
83
|
+
const controller = new AbortController();
|
|
84
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
85
|
+
try {
|
|
86
|
+
const resp = await fetchImpl(url, { ...init, signal: controller.signal });
|
|
87
|
+
return { resp, text: await resp.text() };
|
|
88
|
+
} finally {
|
|
89
|
+
clearTimeout(timer);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function responseFailure(resp, text) {
|
|
94
|
+
const detail = String(text || "").replace(/\s+/g, " ").trim().slice(0, 240);
|
|
95
|
+
return new Error(`HTTP ${resp.status}${detail ? `: ${detail}` : ""}`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function compatibilityRequest(fetchImpl, url, body, timeoutMs) {
|
|
99
|
+
const { resp, text } = await fetchTextWithTimeout(fetchImpl, url, {
|
|
100
|
+
method: "POST",
|
|
101
|
+
headers: { "Content-Type": "application/json", Accept: body.stream ? "text/event-stream" : "application/json" },
|
|
102
|
+
body: JSON.stringify(body),
|
|
103
|
+
}, timeoutMs);
|
|
104
|
+
if (!resp.ok) throw responseFailure(resp, text);
|
|
105
|
+
return text;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function runCompatibilityCheck(checks, label, task) {
|
|
109
|
+
const started = Date.now();
|
|
110
|
+
try {
|
|
111
|
+
const value = await task();
|
|
112
|
+
checks.push({ kind: "ok", message: `${label} passed (${Date.now() - started}ms)` });
|
|
113
|
+
return value;
|
|
114
|
+
} catch (e) {
|
|
115
|
+
const reason = e?.name === "AbortError" ? "timed out" : e?.message || "unknown error";
|
|
116
|
+
checks.push({ kind: "err", message: `${label} failed: ${reason}` });
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function parseResponseObject(text) {
|
|
122
|
+
const response = JSON.parse(text);
|
|
123
|
+
if (!response?.id || !Array.isArray(response.output)) throw new Error("response body is missing id or output");
|
|
124
|
+
return response;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export async function inspectAdapterCompatibility({
|
|
128
|
+
host = "127.0.0.1",
|
|
129
|
+
port = 2026,
|
|
130
|
+
fetchImpl = fetch,
|
|
131
|
+
timeoutMs = 120000,
|
|
132
|
+
} = {}) {
|
|
133
|
+
const baseUrl = localGatewayBaseUrl(connectHost(host), port);
|
|
134
|
+
const checks = [];
|
|
135
|
+
const models = await runCompatibilityCheck(checks, "Compatibility model discovery", async () => {
|
|
136
|
+
const { resp, text } = await fetchTextWithTimeout(fetchImpl, `${baseUrl}/v1/models`, { headers: { Accept: "application/json" } }, timeoutMs);
|
|
137
|
+
if (!resp.ok) throw responseFailure(resp, text);
|
|
138
|
+
const parsed = JSON.parse(text);
|
|
139
|
+
if (!copilotModelData(parsed).length) throw new Error("model list is empty");
|
|
140
|
+
return parsed;
|
|
141
|
+
});
|
|
142
|
+
if (!models) return checks;
|
|
143
|
+
|
|
144
|
+
const { responsesModel, claudeModel } = selectCompatibilityModels(models);
|
|
145
|
+
if (!responsesModel) {
|
|
146
|
+
checks.push({ kind: "err", message: "Compatibility Responses check failed: no GPT model advertises /responses" });
|
|
147
|
+
return checks;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
await runCompatibilityCheck(checks, "Codex Auto-review", async () => {
|
|
151
|
+
const text = await compatibilityRequest(fetchImpl, `${baseUrl}/v1/responses`, {
|
|
152
|
+
model: CODEX_AUTO_REVIEW_MODEL,
|
|
153
|
+
stream: false,
|
|
154
|
+
input: "Reply with OK only.",
|
|
155
|
+
}, timeoutMs);
|
|
156
|
+
parseResponseObject(text);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
const firstResponse = await runCompatibilityCheck(checks, `Native Responses (${responsesModel})`, async () => {
|
|
160
|
+
const text = await compatibilityRequest(fetchImpl, `${baseUrl}/v1/responses`, {
|
|
161
|
+
model: responsesModel,
|
|
162
|
+
stream: false,
|
|
163
|
+
input: "Reply with OK only.",
|
|
164
|
+
}, timeoutMs);
|
|
165
|
+
return parseResponseObject(text);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
if (firstResponse) {
|
|
169
|
+
await runCompatibilityCheck(checks, "Responses stream, history, and image tool compatibility", async () => {
|
|
170
|
+
const text = await compatibilityRequest(fetchImpl, `${baseUrl}/v1/responses`, {
|
|
171
|
+
model: responsesModel,
|
|
172
|
+
stream: true,
|
|
173
|
+
previous_response_id: firstResponse.id,
|
|
174
|
+
input: "Reply with OK again.",
|
|
175
|
+
tools: [{ type: "image_generation" }],
|
|
176
|
+
}, timeoutMs);
|
|
177
|
+
if (!/^event:\s*response\.completed\s*$/m.test(text)) throw new Error("stream did not contain response.completed");
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
await runCompatibilityCheck(checks, "Responses compact", async () => {
|
|
182
|
+
const text = await compatibilityRequest(fetchImpl, `${baseUrl}/v1/responses/compact`, {
|
|
183
|
+
model: responsesModel,
|
|
184
|
+
stream: false,
|
|
185
|
+
input: "Compact this short context.",
|
|
186
|
+
}, timeoutMs);
|
|
187
|
+
parseResponseObject(text);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
if (!claudeModel) {
|
|
191
|
+
checks.push({ kind: "warn", message: "Anthropic stream compatibility skipped because no Claude chat model was advertised" });
|
|
192
|
+
return checks;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
await runCompatibilityCheck(checks, `Anthropic Messages stream (${claudeModel})`, async () => {
|
|
196
|
+
const text = await compatibilityRequest(fetchImpl, `${baseUrl}/v1/messages`, {
|
|
197
|
+
model: claudeModel,
|
|
198
|
+
max_tokens: 16,
|
|
199
|
+
stream: true,
|
|
200
|
+
messages: [{ role: "user", content: "Reply with OK only." }],
|
|
201
|
+
}, timeoutMs);
|
|
202
|
+
if (!/^event:\s*message_stop\s*$/m.test(text)) throw new Error("stream did not contain message_stop");
|
|
203
|
+
});
|
|
204
|
+
return checks;
|
|
205
|
+
}
|
|
206
|
+
|
|
51
207
|
export function inspectGitHubToken({ home = os.homedir() } = {}) {
|
|
52
208
|
const filePath = githubTokenPath(home);
|
|
53
209
|
const token = readText(filePath);
|
|
@@ -113,10 +269,10 @@ export async function inspectGitHubTokenOnline({
|
|
|
113
269
|
}
|
|
114
270
|
}
|
|
115
271
|
|
|
116
|
-
export function inspectCodexConfig({ home = os.homedir(), port = 2026 } = {}) {
|
|
272
|
+
export function inspectCodexConfig({ home = os.homedir(), host = "127.0.0.1", port = 2026 } = {}) {
|
|
117
273
|
const filePath = path.join(home, ".codex", "config.toml");
|
|
118
|
-
const
|
|
119
|
-
const
|
|
274
|
+
const expectedAnthropicBaseUrl = adapterBaseUrl(host, port);
|
|
275
|
+
const expectedBaseUrl = `${expectedAnthropicBaseUrl}/v1`;
|
|
120
276
|
const config = readText(filePath);
|
|
121
277
|
if (!config.ok) {
|
|
122
278
|
return [{ kind: "warn", message: `Codex config not found at ${displayPath(home, filePath)}` }];
|
|
@@ -139,9 +295,9 @@ export function inspectCodexConfig({ home = os.homedir(), port = 2026 } = {}) {
|
|
|
139
295
|
return checks;
|
|
140
296
|
}
|
|
141
297
|
|
|
142
|
-
export function inspectClaudeCodeConfig({ home = os.homedir(), port = 2026 } = {}) {
|
|
298
|
+
export function inspectClaudeCodeConfig({ home = os.homedir(), host = "127.0.0.1", port = 2026 } = {}) {
|
|
143
299
|
const filePath = path.join(home, ".claude", "settings.json");
|
|
144
|
-
const expectedBaseUrl =
|
|
300
|
+
const expectedBaseUrl = adapterBaseUrl(host, port);
|
|
145
301
|
const settings = readJson(filePath);
|
|
146
302
|
if (!settings.ok) {
|
|
147
303
|
const reason = settings.parseError ? `could not parse: ${settings.error.message}` : `not found at ${displayPath(home, filePath)}`;
|
|
@@ -237,13 +393,16 @@ export async function collectDoctorChecks({
|
|
|
237
393
|
checkAdapterListeningFn = checkAdapterListening,
|
|
238
394
|
checkRunningAdapterFn = checkRunningAdapter,
|
|
239
395
|
online = false,
|
|
396
|
+
compat = false,
|
|
240
397
|
fetchImpl = fetch,
|
|
241
398
|
onlineTimeoutMs = 10000,
|
|
399
|
+
compatTimeoutMs = 120000,
|
|
400
|
+
inspectAdapterCompatibilityFn = inspectAdapterCompatibility,
|
|
242
401
|
} = {}) {
|
|
243
402
|
const checks = [
|
|
244
403
|
...inspectGitHubToken({ home }),
|
|
245
|
-
...inspectCodexConfig({ home, port }),
|
|
246
|
-
...inspectClaudeCodeConfig({ home, port }),
|
|
404
|
+
...inspectCodexConfig({ home, host, port }),
|
|
405
|
+
...inspectClaudeCodeConfig({ home, host, port }),
|
|
247
406
|
...inspectClaudeAppConfig({ home, platform, env, host, port }),
|
|
248
407
|
];
|
|
249
408
|
|
|
@@ -251,13 +410,16 @@ export async function collectDoctorChecks({
|
|
|
251
410
|
checks.push(...await inspectGitHubTokenOnline({ home, fetchImpl, timeoutMs: onlineTimeoutMs }));
|
|
252
411
|
}
|
|
253
412
|
|
|
254
|
-
|
|
255
|
-
|
|
413
|
+
let running = null;
|
|
414
|
+
if (checkAdapter || compat) {
|
|
256
415
|
try {
|
|
257
416
|
running = await checkRunningAdapterFn({ host, port, fetchImpl });
|
|
258
417
|
} catch {
|
|
259
418
|
running = null;
|
|
260
419
|
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
if (checkAdapter) {
|
|
261
423
|
if (running?.ok) {
|
|
262
424
|
checks.push({ kind: "ok", message: `Adapter ${running.data.version} is listening on ${running.baseUrl}` });
|
|
263
425
|
} else if (running?.incompatible) {
|
|
@@ -270,12 +432,21 @@ export async function collectDoctorChecks({
|
|
|
270
432
|
}
|
|
271
433
|
}
|
|
272
434
|
|
|
435
|
+
if (compat) {
|
|
436
|
+
if (!running?.ok) {
|
|
437
|
+
checks.push({ kind: "err", message: "Compatibility checks require a running, version-compatible codex-copilot-dx adapter" });
|
|
438
|
+
} else {
|
|
439
|
+
checks.push(...await inspectAdapterCompatibilityFn({ host, port, fetchImpl, timeoutMs: compatTimeoutMs }));
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
273
443
|
return checks;
|
|
274
444
|
}
|
|
275
445
|
|
|
276
446
|
export async function runDoctor(options = {}) {
|
|
277
447
|
const log = options.log || console.log;
|
|
278
|
-
|
|
448
|
+
const flags = [options.online ? "--online" : "", options.compat ? "--compat" : ""].filter(Boolean);
|
|
449
|
+
log(`codex-copilot-dx doctor${flags.length ? ` ${flags.join(" ")}` : ""}`);
|
|
279
450
|
const checks = await collectDoctorChecks(options);
|
|
280
451
|
for (const check of checks) log(status(check.kind, check.message));
|
|
281
452
|
return checks;
|