codex-copilot-dx 0.4.34 → 0.4.36

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.
@@ -0,0 +1,128 @@
1
+ import {
2
+ anthropicToChat,
3
+ chatToAnthropic,
4
+ countTokens,
5
+ streamAnthropicFromLines,
6
+ } from "./anthropic.mjs";
7
+ import {
8
+ createRequestAbort,
9
+ logRequestFailure,
10
+ readJsonBody,
11
+ sendJsonError,
12
+ sendUpstreamError,
13
+ writeOrDrain,
14
+ } from "./http-transport.mjs";
15
+ import { resolveAnthropicModel } from "./models.mjs";
16
+ import { status } from "./status.mjs";
17
+ import { webStreamLines } from "./stream.mjs";
18
+ import { endStreamWithError } from "./stream-errors.mjs";
19
+ import { recordAnthropicUsage } from "./usage.mjs";
20
+
21
+ export function createAnthropicCountTokensHandler(options) {
22
+ const { acquireRequest } = options;
23
+
24
+ return async function handleAnthropicCountTokens(req, res) {
25
+ const abort = createRequestAbort(req, res);
26
+ let releaseRequest = () => {};
27
+ try {
28
+ releaseRequest = await acquireRequest(req, { signal: abort.signal });
29
+ const parsed = await readJsonBody(req);
30
+ const result = await countTokens(parsed);
31
+ releaseRequest();
32
+ res.writeHead(200, { "Content-Type": "application/json" });
33
+ res.end(JSON.stringify(result));
34
+ } catch (error) {
35
+ sendJsonError(res, error);
36
+ } finally {
37
+ releaseRequest();
38
+ abort.cleanup();
39
+ }
40
+ };
41
+ }
42
+
43
+ export function createAnthropicMessagesHandler(options) {
44
+ const {
45
+ acquireRequest,
46
+ chatCompletionsFn,
47
+ environment,
48
+ modelOptions,
49
+ streamHandshakeTimeoutMs,
50
+ streamIdleTimeoutMs,
51
+ upstreamTimeoutMs,
52
+ } = options;
53
+
54
+ return async function handleAnthropicMessages(req, res) {
55
+ const abort = createRequestAbort(req, res);
56
+ let releaseRequest = () => {};
57
+ try {
58
+ releaseRequest = await acquireRequest(req, { signal: abort.signal });
59
+ const parsed = await readJsonBody(req);
60
+ abort.setTimeout(
61
+ parsed.stream ? streamHandshakeTimeoutMs : upstreamTimeoutMs,
62
+ parsed.stream ? "stream_handshake_timeout" : "upstream_timeout",
63
+ );
64
+ const { requestedModel, upstreamModel } = resolveAnthropicModel(parsed.model || "unknown", environment, modelOptions());
65
+ const modelNote = upstreamModel === requestedModel ? requestedModel : `${requestedModel} -> ${upstreamModel}`;
66
+ console.log(status("info", `messages model=${modelNote} stream=${!!parsed.stream}`));
67
+ const chatReq = anthropicToChat(parsed, { upstreamModel });
68
+ const forceRequestedModel = upstreamModel !== requestedModel;
69
+ if (parsed.stream) {
70
+ const upstream = await chatCompletionsFn({ ...chatReq, stream: true }, { signal: abort.signal });
71
+ releaseRequest();
72
+ if (!upstream.ok) {
73
+ if (!res.headersSent) res.writeHead(upstream.status);
74
+ res.end(await upstream.text());
75
+ return;
76
+ }
77
+ abort.setTimeout(streamIdleTimeoutMs, "stream_idle_timeout");
78
+ res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive" });
79
+ let messageId;
80
+ let usage;
81
+ try {
82
+ await streamAnthropicFromLines(
83
+ webStreamLines(upstream, {
84
+ onChunk: () => abort.setTimeout(streamIdleTimeoutMs, "stream_idle_timeout"),
85
+ }),
86
+ async (event, data) => {
87
+ if (event === "message_start") messageId = data.message?.id;
88
+ if (event === "message_delta") usage = data.usage;
89
+ await writeOrDrain(res, `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
90
+ },
91
+ requestedModel,
92
+ { forceModel: forceRequestedModel },
93
+ );
94
+ } catch (error) {
95
+ logRequestFailure("Messages", error, abort);
96
+ await endStreamWithError(res, "anthropic", error, abort);
97
+ return;
98
+ }
99
+ recordAnthropicUsage({ surface: "messages", mode: "stream", model: requestedModel, responseId: messageId, usage });
100
+ if (!res.writableEnded) res.end();
101
+ } else {
102
+ const upstream = await chatCompletionsFn({ ...chatReq, stream: false }, { signal: abort.signal });
103
+ releaseRequest();
104
+ const data = await upstream.text();
105
+ if (!upstream.ok) {
106
+ sendUpstreamError(res, upstream, data);
107
+ return;
108
+ }
109
+ const anthropicMessage = chatToAnthropic(JSON.parse(data), requestedModel, { forceModel: forceRequestedModel });
110
+ recordAnthropicUsage({
111
+ surface: "messages",
112
+ mode: "json",
113
+ model: anthropicMessage.model,
114
+ responseId: anthropicMessage.id,
115
+ usage: anthropicMessage.usage,
116
+ });
117
+ res.writeHead(200, { "Content-Type": "application/json" });
118
+ res.end(JSON.stringify(anthropicMessage));
119
+ }
120
+ } catch (error) {
121
+ logRequestFailure("Messages", error, abort);
122
+ sendJsonError(res, error, 502);
123
+ } finally {
124
+ releaseRequest();
125
+ abort.cleanup();
126
+ }
127
+ };
128
+ }
package/src/anthropic.mjs CHANGED
@@ -2,13 +2,13 @@ import { randomUUID } from "node:crypto";
2
2
 
3
3
  let tokenizerModulePromise = null;
4
4
 
5
- async function tokenEncoder() {
5
+ async function tokenCounter() {
6
6
  tokenizerModulePromise ||= import("gpt-tokenizer");
7
- return (await tokenizerModulePromise).encode;
7
+ return (await tokenizerModulePromise).countTokens;
8
8
  }
9
9
 
10
10
  // Anthropic Messages API to OpenAI chat/completions translation.
11
- // Pure functions only; network calls stay in adapter.mjs and copilot.mjs.
11
+ // Pure functions only; network calls stay in the protocol handler and copilot.mjs.
12
12
 
13
13
  export function mapStopReason(finishReason) {
14
14
  switch (finishReason) {
@@ -282,6 +282,6 @@ export async function countTokens(body) {
282
282
  }
283
283
 
284
284
  const text = parts.join("\n");
285
- const encode = await tokenEncoder();
286
- return { input_tokens: encode(text).length };
285
+ const count = await tokenCounter();
286
+ return { input_tokens: count(text) };
287
287
  }
package/src/auth.mjs CHANGED
@@ -2,7 +2,12 @@ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import os from "node:os";
4
4
  import { spawn } from "node:child_process";
5
- import { createHash } from "node:crypto";
5
+ import {
6
+ githubIdentityMatchesExpected,
7
+ githubTokenFingerprint,
8
+ normalizeGithubIdentity,
9
+ } from "./github-identity.mjs";
10
+ import { parsePositiveInteger, RUNTIME_DEFAULTS } from "./runtime-config.mjs";
6
11
  import { status } from "./status.mjs";
7
12
  import { withFileLock } from "./lock.mjs";
8
13
 
@@ -12,8 +17,6 @@ const GITHUB_API = "https://api.github.com";
12
17
  const COPILOT_TOKEN_URL = `${GITHUB_API}/copilot_internal/v2/token`;
13
18
  const DISABLE_TOKEN_DISCOVERY_VALUES = new Set(["1", "true", "yes"]);
14
19
  const MAX_AUTH_JSON_BYTES = 1024 * 1024;
15
- const DEFAULT_TOKEN_LOCK_TIMEOUT_MS = 10 * 60 * 1000;
16
- const DEFAULT_TOKEN_LOCK_STALE_MS = 15 * 60 * 1000;
17
20
 
18
21
  export function githubTokenPath(home = os.homedir()) {
19
22
  return path.join(home, ".local", "share", "copilot-api", "github_token");
@@ -58,15 +61,10 @@ function isTokenDiscoveryDisabled(env = process.env) {
58
61
  return DISABLE_TOKEN_DISCOVERY_VALUES.has(String(env.CCDX_DISABLE_TOKEN_DISCOVERY || "").toLowerCase());
59
62
  }
60
63
 
61
- function positiveInt(value, fallback) {
62
- const n = Number.parseInt(value, 10);
63
- return Number.isFinite(n) && n > 0 ? n : fallback;
64
- }
65
-
66
64
  function tokenLockOptions(env = process.env) {
67
65
  return {
68
- timeoutMs: positiveInt(env.CCDX_TOKEN_LOCK_TIMEOUT_MS, DEFAULT_TOKEN_LOCK_TIMEOUT_MS),
69
- staleMs: positiveInt(env.CCDX_TOKEN_LOCK_STALE_MS, DEFAULT_TOKEN_LOCK_STALE_MS),
66
+ timeoutMs: parsePositiveInteger(env.CCDX_TOKEN_LOCK_TIMEOUT_MS, RUNTIME_DEFAULTS.tokenLockTimeoutMs),
67
+ staleMs: parsePositiveInteger(env.CCDX_TOKEN_LOCK_STALE_MS, RUNTIME_DEFAULTS.tokenLockStaleMs),
70
68
  };
71
69
  }
72
70
 
@@ -215,22 +213,10 @@ function readSavedGithubToken(home = os.homedir()) {
215
213
  return fs.readFileSync(filePath, "utf8").trim();
216
214
  }
217
215
 
218
- function normalizeGithubIdentity(identity) {
219
- if (!identity || typeof identity !== "object") return null;
220
- const login = typeof identity.login === "string" ? identity.login.trim() : "";
221
- const id = identity.id === undefined || identity.id === null ? "" : String(identity.id).trim();
222
- if (!login && !id) return null;
223
- return { login, id };
224
- }
225
-
226
- function tokenFingerprint(token) {
227
- return createHash("sha256").update(String(token || "")).digest("hex").slice(0, 24);
228
- }
229
-
230
216
  export function readGithubTokenMetadata(home = os.homedir(), token = null) {
231
217
  try {
232
218
  const parsed = JSON.parse(fs.readFileSync(githubTokenMetadataPath(home), "utf8"));
233
- if (token && parsed.token_fingerprint !== tokenFingerprint(token)) return null;
219
+ if (token && parsed.token_fingerprint !== githubTokenFingerprint(token)) return null;
234
220
  const identity = normalizeGithubIdentity(parsed);
235
221
  return identity ? { ...identity, token_fingerprint: parsed.token_fingerprint || "" } : null;
236
222
  } catch {
@@ -238,15 +224,6 @@ export function readGithubTokenMetadata(home = os.homedir(), token = null) {
238
224
  }
239
225
  }
240
226
 
241
- function githubIdentityMatches(identity, expected) {
242
- const actual = normalizeGithubIdentity(identity);
243
- const wanted = normalizeGithubIdentity(expected);
244
- if (!wanted) return true;
245
- if (!actual) return false;
246
- if (wanted.id && actual.id) return wanted.id === actual.id;
247
- return Boolean(wanted.login && actual.login && wanted.login.toLowerCase() === actual.login.toLowerCase());
248
- }
249
-
250
227
  function expectedGithubIdentity(home, env, token) {
251
228
  const configuredLogin = String(env.CCDX_GITHUB_LOGIN || "").trim();
252
229
  if (configuredLogin) return { login: configuredLogin };
@@ -374,7 +351,7 @@ export async function discoverGithubToken({
374
351
  const candidate = { ok: true, token, source, validation };
375
352
  const explicitSource = source.type === "env" || source.type === "token-file";
376
353
  if (expectedIdentity && (strictExpectedIdentity || !explicitSource)) {
377
- if (githubIdentityMatches(validation, expectedIdentity)) return candidate;
354
+ if (githubIdentityMatchesExpected(validation, expectedIdentity)) return candidate;
378
355
  failures.push({ source, validation, reason: "github_account_mismatch" });
379
356
  continue;
380
357
  }
@@ -427,7 +404,7 @@ export async function importDiscoveredGithubToken({
427
404
  return { ok: true, token: savedToken, source: { type: "saved-token" }, imported: false };
428
405
  }
429
406
  const validation = await validateGithubToken(savedToken, { fetchImpl, signal });
430
- if (validation.ok && githubIdentityMatches(validation, expectedIdentity)) {
407
+ if (validation.ok && githubIdentityMatchesExpected(validation, expectedIdentity)) {
431
408
  writeGithubTokenMetadata(validation, home, savedToken);
432
409
  return { ok: true, token: savedToken, source: { type: "saved-token" }, validation, imported: false };
433
410
  }
@@ -561,7 +538,7 @@ export function writeGithubTokenMetadata(identity, home = os.homedir(), token =
561
538
  fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
562
539
  fs.writeFileSync(filePath, `${JSON.stringify({
563
540
  ...normalized,
564
- token_fingerprint: tokenFingerprint(token),
541
+ token_fingerprint: githubTokenFingerprint(token),
565
542
  updated_at: new Date().toISOString(),
566
543
  }, null, 2)}\n`, { mode: 0o600 });
567
544
  fs.chmodSync(filePath, 0o600);
@@ -3,12 +3,13 @@ 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 SETTINGS_PATH = path.join(os.homedir(), ".claude", "settings.json");
8
9
 
9
10
  // Return an updated settings object without mutating the input.
10
- export function computeUpdatedSettings(settings, port) {
11
- const target = `http://127.0.0.1:${port}`;
11
+ export function computeUpdatedSettings(settings, port, host = "127.0.0.1") {
12
+ const target = adapterBaseUrl(host, port);
12
13
  const current = settings?.env?.ANTHROPIC_BASE_URL;
13
14
  const currentToken = settings?.env?.ANTHROPIC_AUTH_TOKEN;
14
15
  const changed = current !== target || currentToken !== "dummy";
@@ -24,11 +25,11 @@ export function computeUpdatedSettings(settings, port) {
24
25
  }
25
26
 
26
27
  // Point Claude Code at the adapter by updating ~/.claude/settings.json.
27
- export function ensureClaudeConfig(port = 2026, { filePath = SETTINGS_PATH } = {}) {
28
- const target = `http://127.0.0.1:${port}`;
28
+ export function ensureClaudeConfig(port = 2026, { filePath = SETTINGS_PATH, host = "127.0.0.1" } = {}) {
29
+ const target = adapterBaseUrl(host, port);
29
30
 
30
31
  if (!fs.existsSync(filePath)) {
31
- const { json } = computeUpdatedSettings({}, port);
32
+ const { json } = computeUpdatedSettings({}, port, host);
32
33
  atomicWriteFileIfChangedSync(filePath, JSON.stringify(json, null, 2) + "\n");
33
34
  console.log(status("ok", `Created ~/.claude/settings.json for Claude Code at ${target}`));
34
35
  return;
@@ -44,7 +45,7 @@ export function ensureClaudeConfig(port = 2026, { filePath = SETTINGS_PATH } = {
44
45
  return;
45
46
  }
46
47
 
47
- const { json, changed } = computeUpdatedSettings(settings, port);
48
+ const { json, changed } = computeUpdatedSettings(settings, port, host);
48
49
  if (!changed) {
49
50
  console.log(status("ok", `Claude Code already points to ${target}`));
50
51
  return;
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,6 +1,6 @@
1
1
  import fs from "node:fs";
2
2
  import os from "node:os";
3
- import { createHash, randomUUID } from "node:crypto";
3
+ import { randomUUID } from "node:crypto";
4
4
  import { status } from "./status.mjs";
5
5
  import { debugLog } from "./log.mjs";
6
6
  import {
@@ -11,6 +11,12 @@ import {
11
11
  readGithubTokenMetadata,
12
12
  writeGithubTokenMetadata,
13
13
  } from "./auth.mjs";
14
+ import {
15
+ githubIdentitiesEqual,
16
+ githubIdentityLabel,
17
+ githubTokenFingerprint,
18
+ normalizeGithubIdentity,
19
+ } from "./github-identity.mjs";
14
20
  import { prepareResponsesPayload } from "./image-optimization.mjs";
15
21
 
16
22
  export {
@@ -95,6 +101,12 @@ const RETRYABLE_UPSTREAM_ERROR_CODES = new Set([
95
101
  "EAI_AGAIN",
96
102
  "ENOTFOUND",
97
103
  ]);
104
+ const RETRYABLE_POST_CONNECT_ERROR_CODES = new Set([
105
+ "UND_ERR_CONNECT_TIMEOUT",
106
+ "ECONNREFUSED",
107
+ "EAI_AGAIN",
108
+ "ENOTFOUND",
109
+ ]);
98
110
  const RETRYABLE_UPSTREAM_STATUSES = new Set([408, 502, 503, 504]);
99
111
 
100
112
  function upstreamTarget(url) {
@@ -146,9 +158,15 @@ function requestMethod(init = {}) {
146
158
  return String(init.method || "GET").toUpperCase();
147
159
  }
148
160
 
149
- export function isRetryableUpstreamError(err, { signal } = {}) {
161
+ export function isRetryableUpstreamError(err, { signal, method = "GET" } = {}) {
150
162
  if (isAbortError(err, signal)) return false;
151
- return RETRYABLE_UPSTREAM_ERROR_CODES.has(upstreamErrorCode(err));
163
+ const code = upstreamErrorCode(err);
164
+ if (!["GET", "HEAD", "OPTIONS"].includes(String(method || "GET").toUpperCase())) {
165
+ const syscall = err?.cause?.syscall || err?.syscall || "";
166
+ return RETRYABLE_POST_CONNECT_ERROR_CODES.has(code)
167
+ || (syscall === "connect" && ["ETIMEDOUT", "ECONNRESET"].includes(code));
168
+ }
169
+ return RETRYABLE_UPSTREAM_ERROR_CODES.has(code);
152
170
  }
153
171
 
154
172
  export function isRetryableUpstreamStatus(status, method = "GET") {
@@ -197,7 +215,7 @@ export async function fetchCopilotUpstream(
197
215
  return resp;
198
216
  } catch (e) {
199
217
  debugLog(`upstream ${method} ${target} error=${describeUpstreamError(e)} attempt=${attempt + 1}/${retryCount + 1} attempt_ms=${Date.now() - attemptStart} total_ms=${Date.now() - totalStart}`);
200
- if (attempt >= retryCount || !isRetryableUpstreamError(e, { signal })) throw e;
218
+ if (attempt >= retryCount || !isRetryableUpstreamError(e, { signal, method })) throw e;
201
219
  const delay = upstreamRetryDelay(attempt, baseDelay);
202
220
  console.warn(status("warn", `upstream ${target} ${describeUpstreamError(e)}; retry ${attempt + 1}/${retryCount} in ${delay}ms`));
203
221
  await sleep(delay, { signal });
@@ -280,6 +298,21 @@ let copilotTokenGithubIdentity = null;
280
298
  let copilotTokenRefreshRetryAt = 0;
281
299
  const modelListFlights = new Map();
282
300
 
301
+ export function copilotRuntimeStatus(now = Date.now()) {
302
+ let upstreamHost = "unknown";
303
+ try { upstreamHost = new URL(apiBase).hostname; } catch {}
304
+ return {
305
+ token_cached: Boolean(copilotToken),
306
+ token_expires_in_ms: copilotToken ? Math.max(0, copilotTokenExpiry - now) : null,
307
+ token_refresh_in_flight: Boolean(copilotTokenRefresh),
308
+ token_refresh_backoff_ms: Math.max(0, copilotTokenRefreshRetryAt - now),
309
+ account_bound: Boolean(copilotTokenGithubIdentity),
310
+ upstream_host: upstreamHost,
311
+ model_endpoint_cache_entries: modelEndpointCache.size,
312
+ model_list_flights: modelListFlights.size,
313
+ };
314
+ }
315
+
283
316
  function getGithubToken({ home = os.homedir() } = {}) {
284
317
  const GITHUB_TOKEN_PATH = githubTokenPath(home);
285
318
  if (!fs.existsSync(GITHUB_TOKEN_PATH)) {
@@ -297,30 +330,6 @@ function requestCopilotToken(ghToken, { fetchImpl = fetch, signal } = {}) {
297
330
  });
298
331
  }
299
332
 
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
333
  function cacheCopilotTokenData(data, {
325
334
  sourceKey,
326
335
  githubToken,
@@ -332,7 +341,7 @@ function cacheCopilotTokenData(data, {
332
341
  const identity = normalizeGithubIdentity(githubIdentity);
333
342
  const tokenChanged = Boolean(copilotTokenGithubFingerprint && fingerprint !== copilotTokenGithubFingerprint);
334
343
  if (!allowAccountSwitch && copilotTokenGithubIdentity && identity
335
- && !githubIdentitiesMatch(copilotTokenGithubIdentity, identity)) {
344
+ && !githubIdentitiesEqual(copilotTokenGithubIdentity, identity)) {
336
345
  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
346
  }
338
347
  if (!allowAccountSwitch && copilotTokenGithubIdentity && tokenChanged && !identity) {
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");
@@ -146,6 +147,15 @@ export async function inspectAdapterCompatibility({
146
147
  return checks;
147
148
  }
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
+
149
159
  const firstResponse = await runCompatibilityCheck(checks, `Native Responses (${responsesModel})`, async () => {
150
160
  const text = await compatibilityRequest(fetchImpl, `${baseUrl}/v1/responses`, {
151
161
  model: responsesModel,
@@ -259,10 +269,10 @@ export async function inspectGitHubTokenOnline({
259
269
  }
260
270
  }
261
271
 
262
- export function inspectCodexConfig({ home = os.homedir(), port = 2026 } = {}) {
272
+ export function inspectCodexConfig({ home = os.homedir(), host = "127.0.0.1", port = 2026 } = {}) {
263
273
  const filePath = path.join(home, ".codex", "config.toml");
264
- const expectedBaseUrl = `http://127.0.0.1:${port}/v1`;
265
- const expectedAnthropicBaseUrl = `http://127.0.0.1:${port}`;
274
+ const expectedAnthropicBaseUrl = adapterBaseUrl(host, port);
275
+ const expectedBaseUrl = `${expectedAnthropicBaseUrl}/v1`;
266
276
  const config = readText(filePath);
267
277
  if (!config.ok) {
268
278
  return [{ kind: "warn", message: `Codex config not found at ${displayPath(home, filePath)}` }];
@@ -285,9 +295,9 @@ export function inspectCodexConfig({ home = os.homedir(), port = 2026 } = {}) {
285
295
  return checks;
286
296
  }
287
297
 
288
- export function inspectClaudeCodeConfig({ home = os.homedir(), port = 2026 } = {}) {
298
+ export function inspectClaudeCodeConfig({ home = os.homedir(), host = "127.0.0.1", port = 2026 } = {}) {
289
299
  const filePath = path.join(home, ".claude", "settings.json");
290
- const expectedBaseUrl = `http://127.0.0.1:${port}`;
300
+ const expectedBaseUrl = adapterBaseUrl(host, port);
291
301
  const settings = readJson(filePath);
292
302
  if (!settings.ok) {
293
303
  const reason = settings.parseError ? `could not parse: ${settings.error.message}` : `not found at ${displayPath(home, filePath)}`;
@@ -391,8 +401,8 @@ export async function collectDoctorChecks({
391
401
  } = {}) {
392
402
  const checks = [
393
403
  ...inspectGitHubToken({ home }),
394
- ...inspectCodexConfig({ home, port }),
395
- ...inspectClaudeCodeConfig({ home, port }),
404
+ ...inspectCodexConfig({ home, host, port }),
405
+ ...inspectClaudeCodeConfig({ home, host, port }),
396
406
  ...inspectClaudeAppConfig({ home, platform, env, host, port }),
397
407
  ];
398
408
 
@@ -0,0 +1,30 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ export function normalizeGithubIdentity(identity) {
4
+ if (!identity || typeof identity !== "object") return null;
5
+ const login = typeof identity.login === "string" ? identity.login.trim() : "";
6
+ const id = identity.id === undefined || identity.id === null ? "" : String(identity.id).trim();
7
+ return login || id ? { login, id } : null;
8
+ }
9
+
10
+ export function githubTokenFingerprint(token) {
11
+ return createHash("sha256").update(String(token || "")).digest("hex").slice(0, 24);
12
+ }
13
+
14
+ export function githubIdentitiesEqual(first, second) {
15
+ const a = normalizeGithubIdentity(first);
16
+ const b = normalizeGithubIdentity(second);
17
+ if (!a || !b) return false;
18
+ if (a.id && b.id) return a.id === b.id;
19
+ return Boolean(a.login && b.login && a.login.toLowerCase() === b.login.toLowerCase());
20
+ }
21
+
22
+ export function githubIdentityMatchesExpected(identity, expected) {
23
+ if (!normalizeGithubIdentity(expected)) return true;
24
+ return githubIdentitiesEqual(identity, expected);
25
+ }
26
+
27
+ export function githubIdentityLabel(identity) {
28
+ const normalized = normalizeGithubIdentity(identity);
29
+ return normalized?.login || normalized?.id || "unknown";
30
+ }