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/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 baseUrl = `http://127.0.0.1:${adapterPort}/v1`;
38
- const anthropicBaseUrl = `http://127.0.0.1:${adapterPort}`;
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 baseUrl = `http://127.0.0.1:${adapterPort}/v1`;
64
- const anthropicBaseUrl = `http://127.0.0.1:${adapterPort}`;
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 = `http://127.0.0.1:${adapterPort}/v1`;
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 { 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 {
@@ -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
- return RETRYABLE_UPSTREAM_ERROR_CODES.has(upstreamErrorCode(err));
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 cacheCopilotTokenData(data) {
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 resp = await requestCopilotToken(ghToken, { fetchImpl, signal });
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
- return cacheCopilotTokenData(imported.validation.copilotTokenData);
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 new Error(`Failed to get Copilot token: ${resp.status}`);
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 ensureGithubTokenMetadata(ghToken, { home, fetchImpl, signal });
327
- return cacheCopilotTokenData(data);
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
- export async function getCopilotToken(options = {}) {
331
- if (copilotToken && Date.now() < copilotTokenExpiry - 60000) return copilotToken;
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 refreshKey = githubTokenPath(home);
334
- if (copilotTokenRefresh?.key === refreshKey) return copilotTokenRefresh.promise;
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
- const promise = refreshCopilotToken(options).finally(() => {
337
- if (copilotTokenRefresh?.promise === promise) copilotTokenRefresh = null;
338
- });
339
- copilotTokenRefresh = { key: refreshKey, promise };
340
- return promise;
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 waitForModelListFlight(flight, signal) {
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 waitForModelListFlight(flight, signal);
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 expectedBaseUrl = `http://127.0.0.1:${port}/v1`;
119
- const expectedAnthropicBaseUrl = `http://127.0.0.1:${port}`;
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 = `http://127.0.0.1:${port}`;
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
- if (checkAdapter) {
255
- let running;
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
- log(`codex-copilot-dx doctor${options.online ? " --online" : ""}`);
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;