codex-copilot-dx 0.4.44 → 0.5.2

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
@@ -12,7 +12,7 @@ A single in-process adapter (port `2026`) exposes both APIs over your Copilot su
12
12
 
13
13
  Supports both HTTP SSE streaming and non-streaming.
14
14
 
15
- Codex Auto-review requests use the hidden `codex-auto-review` model ID. The adapter maps it to Copilot's `gpt-5.5` Responses model by default and logs both model IDs when the mapping is used.
15
+ Codex Auto-review requests use the hidden `codex-auto-review` model ID. The adapter maps it to Copilot's `gpt-5.5` Responses model by default and logs both model IDs when the mapping is used. The target can be changed interactively without reinstalling the package.
16
16
 
17
17
  ## Prerequisites
18
18
 
@@ -30,6 +30,42 @@ ccdx
30
30
  The shorter `ccdx` command is the primary launcher. The existing `codex-copilot-dx`
31
31
  command remains an equivalent compatibility alias. For a one-off run without a
32
32
  global install, use `npx codex-copilot-dx@latest`.
33
+ Both installed commands expose the same options and subcommands; help, version,
34
+ doctor, status, and argument-error output use the command name that was invoked.
35
+
36
+ To change the model used by Codex Auto-review, run:
37
+
38
+ ```bash
39
+ ccdx auto-review-model
40
+ ```
41
+
42
+ The selector reads the current adapter model list, falling back to the fresh local
43
+ model cache, and offers only enabled models that advertise a Responses endpoint.
44
+ It saves the selection in `~/.config/codex-copilot-dx/config.json` (or under
45
+ `XDG_CONFIG_HOME` when set); choosing `gpt-5.5` clears the override and restores
46
+ the package default. A running 0.5.1+
47
+ adapter reads the setting on the next Auto-review request, so it does not need to
48
+ be restarted. `codex-copilot-dx auto-review-model` is fully equivalent.
49
+
50
+ To update the globally installed package, choose a source interactively:
51
+
52
+ ```bash
53
+ ccdx update
54
+ ```
55
+
56
+ For scripts or a direct choice, pass the source explicitly:
57
+
58
+ ```bash
59
+ ccdx update npm
60
+ ccdx update github
61
+ ```
62
+
63
+ The npm source installs `codex-copilot-dx@latest` through the registry already
64
+ configured for npm, including a company mirror. The GitHub source installs the
65
+ latest commit from `DaleXiao/codex-copilot-dx` `main`. Both paths use npm's
66
+ global installer without shell interpolation. A currently running adapter keeps
67
+ its loaded version until it is stopped and started again. The
68
+ `codex-copilot-dx update` compatibility command behaves identically.
33
69
 
34
70
  On first run, it will:
35
71
  1. Authenticate with GitHub via device flow (if needed), after first trying compatible local Copilot token sources
@@ -75,7 +111,15 @@ ccdx doctor --compat
75
111
 
76
112
  The compatibility doctor sends a few minimal Copilot requests to check Codex Auto-review, 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.
77
113
 
78
- For live runtime diagnostics, query the running adapter from the same machine:
114
+ For a read-only summary of the running adapter, use:
115
+
116
+ ```bash
117
+ ccdx status
118
+ ```
119
+
120
+ The compatibility alias supports the same command as
121
+ `codex-copilot-dx status`. For the complete machine-readable payload, query the
122
+ loopback endpoint directly:
79
123
 
80
124
  ```bash
81
125
  curl -s http://127.0.0.1:2026/_ccdx/status | jq
@@ -135,7 +179,7 @@ Environment variables:
135
179
  | `CCDX_STREAM_IDLE_TIMEOUT_MS` | `120000` | Maximum idle time between upstream streaming body chunks |
136
180
  | `CCDX_UPSTREAM_RETRIES` | `2` | Retries for safe requests and clearly pre-connect POST failures; capped at `5` |
137
181
  | `CCDX_UPSTREAM_RETRY_DELAY_MS` | `300` | Initial upstream retry backoff in milliseconds; capped at `5000` |
138
- | `CCDX_AUTO_REVIEW_MODEL` | `gpt-5.5` | Copilot Responses model used for Codex Auto-review requests |
182
+ | `CCDX_AUTO_REVIEW_MODEL` | saved selection or `gpt-5.5` | Copilot Responses model used for Codex Auto-review requests; overrides the interactive selection |
139
183
  | `CCDX_LOG_PATH` | unset | Mirror terminal logs to a file; set to `1` for `~/.local/share/codex-copilot-dx/debug.log` |
140
184
  | `CCDX_LOG_LEVEL` | `info` | Set to `debug` to include upstream request attempts, status codes, retry causes, and timings |
141
185
  | `CCDX_LOG_MAX_BYTES` | `16777216` | Rotate the debug log at this size, retaining one `.1` backup; set to `0` to disable rotation |
package/bin/cli.mjs CHANGED
@@ -16,23 +16,27 @@ import { adapterBaseUrl, checkRunningAdapter } from "../src/running-adapter.mjs"
16
16
  import { assertSafeAdapterHost, isLoopbackHost } from "../src/security.mjs";
17
17
  import { isValidModelList, loadModelCache, saveModelCache } from "../src/model-cache.mjs";
18
18
  import { initializeModelRegistry, runInBackground } from "../src/startup.mjs";
19
- import { cliHelp, parseCliArgs, parseRuntimeOptions } from "../src/cli-options.mjs";
19
+ import { cliCommandName, cliHelp, parseAdapterProbeOptions, parseCliArgs, parseRuntimeOptions } from "../src/cli-options.mjs";
20
+ import { formatAdapterStatus, readAdapterStatus } from "../src/cli-status.mjs";
20
21
  import { closeHttpServer } from "../src/shutdown.mjs";
22
+ import { runAutoReviewModelCommand } from "../src/auto-review-model.mjs";
23
+ import { autoReviewModelPreference } from "../src/user-settings.mjs";
21
24
 
22
25
  const LOCAL_VERSION = localPackageVersion();
23
- const CLI_BANNER = `codex-copilot-dx v${LOCAL_VERSION} by Dale Xiao`;
26
+ const CLI_NAME = cliCommandName();
27
+ const CLI_BANNER = `${CLI_NAME} v${LOCAL_VERSION} by Dale Xiao`;
24
28
 
25
29
  let CLI;
26
30
  try {
27
31
  CLI = parseCliArgs(process.argv.slice(2));
28
32
  } catch (e) {
29
33
  console.error(e.message);
30
- console.error("Run ccdx --help for usage.");
34
+ console.error(`Run ${CLI_NAME} --help for usage.`);
31
35
  process.exit(2);
32
36
  }
33
37
 
34
38
  if (CLI.command === "help") {
35
- console.log(cliHelp());
39
+ console.log(cliHelp(CLI_NAME));
36
40
  process.exit(0);
37
41
  }
38
42
  if (CLI.command === "version") {
@@ -43,6 +47,46 @@ if (CLI.command === "usage") {
43
47
  await printUsageSummary();
44
48
  process.exit(0);
45
49
  }
50
+ if (CLI.command === "status") {
51
+ let probe;
52
+ try {
53
+ probe = parseAdapterProbeOptions(process.env);
54
+ } catch (e) {
55
+ console.error(e.message);
56
+ process.exit(2);
57
+ }
58
+ try {
59
+ const snapshot = await readAdapterStatus({
60
+ host: probe.adapterHost,
61
+ port: probe.adapterPort,
62
+ timeoutMs: probe.existingAdapterTimeoutMs,
63
+ });
64
+ console.log(formatAdapterStatus(snapshot, { commandName: CLI_NAME, cliVersion: LOCAL_VERSION }));
65
+ process.exit(0);
66
+ } catch (e) {
67
+ console.error(status("err", e.message));
68
+ process.exit(1);
69
+ }
70
+ }
71
+ if (CLI.command === "auto-review-model") {
72
+ try {
73
+ await runAutoReviewModelCommand({ commandName: CLI_NAME });
74
+ process.exit(0);
75
+ } catch (e) {
76
+ console.error(status("err", e.message));
77
+ process.exit(1);
78
+ }
79
+ }
80
+ if (CLI.command === "update") {
81
+ try {
82
+ const { runPackageUpdateCommand } = await import("../src/package-update.mjs");
83
+ await runPackageUpdateCommand({ commandName: CLI_NAME, source: CLI.updateSource });
84
+ process.exit(0);
85
+ } catch (e) {
86
+ console.error(status("err", e.message));
87
+ process.exit(1);
88
+ }
89
+ }
46
90
 
47
91
  let RUNTIME;
48
92
  try {
@@ -105,9 +149,11 @@ async function refreshClaudeDesktopModelDefs() {
105
149
  throw new Error(`Copilot models returned HTTP ${httpStatus}`);
106
150
  }
107
151
  const models = JSON.parse(body);
108
- const autoReview = codexAutoReviewModelStatus(models);
152
+ const autoReview = codexAutoReviewModelStatus(models, process.env, {
153
+ autoReviewModel: autoReviewModelPreference().model,
154
+ });
109
155
  if (!autoReview.available) {
110
- console.log(status("warn", `Auto-review target ${autoReview.upstreamModel} is unavailable: ${autoReview.reason}. Run ccdx doctor --compat to verify the live path.`));
156
+ console.log(status("warn", `Auto-review target ${autoReview.upstreamModel} is unavailable: ${autoReview.reason}. Run ${CLI_NAME} doctor --compat to verify the live path.`));
111
157
  }
112
158
  const gptModelIds = gptModelIdsFromCopilotModels(models);
113
159
  if (gptModelIds.length) {
@@ -169,7 +215,7 @@ async function reuseRunningAdapterIfAvailable() {
169
215
  });
170
216
  if (running.incompatible) {
171
217
  const found = running.data?.version || "unknown";
172
- throw new Error(`Adapter ${found} is already running at ${running.baseUrl}, but this CLI is ${LOCAL_VERSION}. Stop the existing process and run ccdx again.`);
218
+ throw new Error(`Adapter ${found} is already running at ${running.baseUrl}, but this CLI is ${LOCAL_VERSION}. Stop the existing process and run ${CLI_NAME} again.`);
173
219
  }
174
220
  if (!running.ok) return false;
175
221
 
@@ -206,6 +252,7 @@ async function reuseRunningAdapterIfAvailable() {
206
252
 
207
253
  if (CLI.command === "doctor") {
208
254
  const checks = await runDoctor({
255
+ commandName: CLI_NAME,
209
256
  port: ADAPTER_PORT,
210
257
  host: ADAPTER_HOST,
211
258
  online: CLI.online,
@@ -268,6 +315,7 @@ try {
268
315
  activeServer = await startAdapter(ADAPTER_PORT, ADAPTER_HOST, {
269
316
  claudeDesktopApiKey,
270
317
  claudeDesktopModelDefs,
318
+ autoReviewModelResolver: () => autoReviewModelPreference().model,
271
319
  modelRegistry: MODEL_REGISTRY,
272
320
  showRequestId: CLI.showRequestId,
273
321
  upstreamTimeoutMs: RUNTIME.upstreamTimeoutMs,
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ import "./cli.mjs";
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "codex-copilot-dx",
3
- "version": "0.4.44",
3
+ "version": "0.5.2",
4
4
  "description": "Use Codex Desktop, Claude Code, and Claude App with GitHub Copilot",
5
5
  "author": "Dale Xiao",
6
6
  "type": "module",
7
7
  "bin": {
8
8
  "ccdx": "bin/cli.mjs",
9
- "codex-copilot-dx": "bin/cli.mjs"
9
+ "codex-copilot-dx": "bin/codex-copilot-dx.mjs"
10
10
  },
11
11
  "files": [
12
12
  "bin/",
package/src/adapter.mjs CHANGED
@@ -106,6 +106,7 @@ export function createAdapterHandler(options = {}) {
106
106
  };
107
107
  const responsesHandler = createResponsesHandler({
108
108
  acquireRequest,
109
+ autoReviewModelResolver: options.autoReviewModelResolver,
109
110
  chatCompletionsFn,
110
111
  openAIModelEnv,
111
112
  responsesPayloadOptions: options.responsesPayloadOptions,
@@ -116,6 +117,7 @@ export function createAdapterHandler(options = {}) {
116
117
  });
117
118
  const responsesCompactHandler = createResponsesCompactHandler({
118
119
  acquireRequest,
120
+ autoReviewModelResolver: options.autoReviewModelResolver,
119
121
  openAIModelEnv,
120
122
  responsesCompactFn,
121
123
  streamHandshakeTimeoutMs,
@@ -0,0 +1,141 @@
1
+ import os from "node:os";
2
+ import { createInterface } from "node:readline/promises";
3
+ import { loadModelCache } from "./model-cache.mjs";
4
+ import {
5
+ DEFAULT_CODEX_AUTO_REVIEW_MODEL,
6
+ responsesModelIdsFromCopilotModels,
7
+ } from "./models.mjs";
8
+ import { parseAdapterProbeOptions } from "./cli-options.mjs";
9
+ import { adapterBaseUrl } from "./running-adapter.mjs";
10
+ import {
11
+ autoReviewModelPreference,
12
+ readUserSettings,
13
+ writeAutoReviewModel,
14
+ } from "./user-settings.mjs";
15
+
16
+ const DEFAULT_MODEL_FETCH_TIMEOUT_MS = 5000;
17
+
18
+ function modelFetchTimeout(env) {
19
+ const value = Number(env.CCDX_MODEL_REFRESH_TIMEOUT_MS);
20
+ return Number.isSafeInteger(value) && value > 0 ? value : DEFAULT_MODEL_FETCH_TIMEOUT_MS;
21
+ }
22
+
23
+ function sortedModelIds(modelIds, currentModel) {
24
+ return [...modelIds].sort((left, right) => {
25
+ const leftRank = left === currentModel ? 0 : left === DEFAULT_CODEX_AUTO_REVIEW_MODEL ? 1 : 2;
26
+ const rightRank = right === currentModel ? 0 : right === DEFAULT_CODEX_AUTO_REVIEW_MODEL ? 1 : 2;
27
+ return leftRank - rightRank || left.localeCompare(right);
28
+ });
29
+ }
30
+
31
+ export async function loadAutoReviewModelCatalog({
32
+ env = process.env,
33
+ fetchImpl = fetch,
34
+ home = os.homedir(),
35
+ loadModelCacheFn = loadModelCache,
36
+ } = {}) {
37
+ const { adapterHost, adapterPort } = parseAdapterProbeOptions(env);
38
+ const url = `${adapterBaseUrl(adapterHost, adapterPort)}/v1/models`;
39
+ const ctrl = new AbortController();
40
+ const timer = setTimeout(() => ctrl.abort(), modelFetchTimeout(env));
41
+ let liveError = "adapter is not running";
42
+ try {
43
+ const response = await fetchImpl(url, {
44
+ headers: { Accept: "application/json" },
45
+ redirect: "error",
46
+ signal: ctrl.signal,
47
+ });
48
+ if (!response.ok) {
49
+ liveError = `adapter returned HTTP ${response.status}`;
50
+ } else {
51
+ const models = await response.json();
52
+ const modelIds = responsesModelIdsFromCopilotModels(models);
53
+ if (modelIds.length) {
54
+ const fallback = response.headers.get("X-CCDX-Model-Source") === "last-known-good";
55
+ return { modelIds, source: fallback ? "running adapter cache" : "running adapter" };
56
+ }
57
+ liveError = "adapter returned no selectable Responses models";
58
+ }
59
+ } catch (error) {
60
+ liveError = ctrl.signal.aborted
61
+ ? `adapter model lookup timed out after ${modelFetchTimeout(env)}ms`
62
+ : error?.message || String(error);
63
+ } finally {
64
+ clearTimeout(timer);
65
+ }
66
+
67
+ const cached = loadModelCacheFn({ home });
68
+ const modelIds = responsesModelIdsFromCopilotModels(cached);
69
+ if (modelIds.length) return { modelIds, source: "local model cache" };
70
+ throw new Error(`No selectable Responses models are available (${liveError}). Start ccdx once to refresh the model list.`);
71
+ }
72
+
73
+ export async function runAutoReviewModelCommand({
74
+ commandName = "ccdx",
75
+ env = process.env,
76
+ fetchImpl = fetch,
77
+ home = os.homedir(),
78
+ input = process.stdin,
79
+ loadCatalog = loadAutoReviewModelCatalog,
80
+ output = process.stdout,
81
+ prompt,
82
+ } = {}) {
83
+ if (!prompt && (!input.isTTY || !output.isTTY)) {
84
+ throw new Error(`${commandName} auto-review-model requires an interactive terminal`);
85
+ }
86
+
87
+ readUserSettings({ env, home, strict: true });
88
+ const current = autoReviewModelPreference({ env, home });
89
+ const catalog = await loadCatalog({ env, fetchImpl, home });
90
+ const modelIds = sortedModelIds(catalog.modelIds, current.model);
91
+ if (!modelIds.length) throw new Error("No selectable Responses models are available");
92
+
93
+ output.write(`${commandName} auto-review-model\n`);
94
+ output.write(`Current: ${current.model} (${current.source})\n`);
95
+ output.write(`Responses models from ${catalog.source}:\n`);
96
+ modelIds.forEach((model, index) => {
97
+ const markers = [];
98
+ if (model === current.model) markers.push("current");
99
+ if (model === DEFAULT_CODEX_AUTO_REVIEW_MODEL) markers.push("default");
100
+ output.write(` ${index + 1}. ${model}${markers.length ? ` [${markers.join(", ")}]` : ""}\n`);
101
+ });
102
+
103
+ let defaultIndex = modelIds.indexOf(current.model);
104
+ if (defaultIndex < 0) defaultIndex = modelIds.indexOf(DEFAULT_CODEX_AUTO_REVIEW_MODEL);
105
+ if (defaultIndex < 0) defaultIndex = 0;
106
+
107
+ let readline;
108
+ const ask = prompt || (async (question) => {
109
+ readline ||= createInterface({ input, output });
110
+ return readline.question(question);
111
+ });
112
+
113
+ let selectedModel;
114
+ try {
115
+ while (!selectedModel) {
116
+ const answer = String(await ask(`Select a model [${defaultIndex + 1}], or q to cancel: `) || "").trim();
117
+ if (answer.toLowerCase() === "q" || answer.toLowerCase() === "quit") {
118
+ output.write("No changes made.\n");
119
+ return { changed: false, cancelled: true, model: current.model };
120
+ }
121
+ const selectedIndex = answer === "" ? defaultIndex : Number(answer) - 1;
122
+ if (Number.isInteger(selectedIndex) && selectedIndex >= 0 && selectedIndex < modelIds.length) {
123
+ selectedModel = modelIds[selectedIndex];
124
+ } else {
125
+ output.write(`Enter a number from 1 to ${modelIds.length}, or q to cancel.\n`);
126
+ }
127
+ }
128
+ } finally {
129
+ readline?.close();
130
+ }
131
+
132
+ const savedModel = selectedModel === DEFAULT_CODEX_AUTO_REVIEW_MODEL ? "" : selectedModel;
133
+ const result = writeAutoReviewModel(savedModel, { env, home });
134
+ output.write(`${result.changed ? "Saved" : "Kept"} Auto Review model: ${selectedModel}\n`);
135
+ if (String(env.CCDX_AUTO_REVIEW_MODEL || "").trim()) {
136
+ output.write(`CCDX_AUTO_REVIEW_MODEL=${current.model} remains the effective override until it is unset.\n`);
137
+ } else {
138
+ output.write("The running 0.5.1+ adapter will use this model on the next Auto Review request.\n");
139
+ }
140
+ return { ...result, cancelled: false, model: selectedModel };
141
+ }
@@ -1,6 +1,7 @@
1
1
  const HELP_COMMANDS = new Set(["help", "--help", "-h"]);
2
2
  const VERSION_COMMANDS = new Set(["version", "--version", "-v"]);
3
3
  const START_OPTIONS = new Set(["--configure-claude-desktop", "--show-request-id"]);
4
+ const CLI_COMMANDS = new Set(["ccdx", "codex-copilot-dx"]);
4
5
 
5
6
  function unexpectedArgs(args) {
6
7
  throw new Error(`Unexpected argument${args.length === 1 ? "" : "s"}: ${args.join(" ")}`);
@@ -17,10 +18,33 @@ export function parseCliArgs(args = []) {
17
18
  if (rest.length) unexpectedArgs(rest);
18
19
  return { command: "version", configureClaudeDesktop: false, showRequestId: false, online: false, compat: false };
19
20
  }
21
+ if (command === "status" || command === "--status") {
22
+ if (rest.length) unexpectedArgs(rest);
23
+ return { command: "status", configureClaudeDesktop: false, showRequestId: false, online: false, compat: false };
24
+ }
20
25
  if (command === "usage") {
21
26
  if (rest.length) unexpectedArgs(rest);
22
27
  return { command: "usage", configureClaudeDesktop: false, showRequestId: false, online: false, compat: false };
23
28
  }
29
+ if (command === "auto-review-model") {
30
+ if (rest.length) unexpectedArgs(rest);
31
+ return { command: "auto-review-model", configureClaudeDesktop: false, showRequestId: false, online: false, compat: false };
32
+ }
33
+ if (command === "update") {
34
+ if (rest.length > 1) unexpectedArgs(rest.slice(1));
35
+ const source = rest[0];
36
+ if (source && !new Set(["npm", "github", "gh"]).has(source)) {
37
+ throw new Error(`Update source must be npm or github: ${source}`);
38
+ }
39
+ return {
40
+ command: "update",
41
+ configureClaudeDesktop: false,
42
+ showRequestId: false,
43
+ online: false,
44
+ compat: false,
45
+ updateSource: source === "gh" ? "github" : source,
46
+ };
47
+ }
24
48
  if (command === "doctor" || command === "--doctor") {
25
49
  const supported = new Set(["--online", "--compat"]);
26
50
  const invalid = rest.filter((arg) => !supported.has(arg));
@@ -80,13 +104,32 @@ export function parseRuntimeOptions(env = process.env) {
80
104
  };
81
105
  }
82
106
 
83
- export function cliHelp() {
107
+ export function parseAdapterProbeOptions(env = process.env) {
108
+ return {
109
+ adapterPort: integerEnv(env, "ADAPTER_PORT", 2026, { min: 1, max: 65535 }),
110
+ adapterHost: String(env.ADAPTER_HOST || "127.0.0.1").trim() || "127.0.0.1",
111
+ existingAdapterTimeoutMs: integerEnv(env, "CCDX_EXISTING_ADAPTER_TIMEOUT_MS", 500, { min: 1 }),
112
+ };
113
+ }
114
+
115
+ export function cliCommandName(executable = process.argv[1]) {
116
+ const name = String(executable || "").split(/[\\/]/).pop();
117
+ if (name === "codex-copilot-dx.mjs") return "codex-copilot-dx";
118
+ return CLI_COMMANDS.has(name) ? name : "ccdx";
119
+ }
120
+
121
+ export function cliHelp(commandName = "ccdx") {
122
+ const name = CLI_COMMANDS.has(commandName) ? commandName : "ccdx";
123
+ const alias = name === "ccdx" ? "codex-copilot-dx" : "ccdx";
84
124
  return `Usage:
85
- ccdx [--configure-claude-desktop] [--show-request-id]
86
- ccdx doctor [--online] [--compat]
87
- ccdx usage
88
- ccdx --version
89
- ccdx --help
125
+ ${name} [--configure-claude-desktop] [--show-request-id]
126
+ ${name} doctor [--online] [--compat]
127
+ ${name} status
128
+ ${name} usage
129
+ ${name} auto-review-model
130
+ ${name} update [npm|github]
131
+ ${name} --version
132
+ ${name} --help
90
133
 
91
- Compatibility alias: codex-copilot-dx`;
134
+ Equivalent command: ${alias}`;
92
135
  }
@@ -0,0 +1,122 @@
1
+ import { ADAPTER_STATUS_PATH, adapterBaseUrl } from "./running-adapter.mjs";
2
+ import { status } from "./status.mjs";
3
+
4
+ function finiteNumber(value) {
5
+ const number = Number(value);
6
+ return Number.isFinite(number) ? number : null;
7
+ }
8
+
9
+ function count(value) {
10
+ const number = finiteNumber(value);
11
+ return number === null ? "n/a" : String(Math.max(0, Math.round(number)));
12
+ }
13
+
14
+ function duration(value) {
15
+ const milliseconds = finiteNumber(value);
16
+ if (milliseconds === null) return "n/a";
17
+ const seconds = Math.max(0, Math.round(milliseconds / 1000));
18
+ const days = Math.floor(seconds / 86400);
19
+ const hours = Math.floor((seconds % 86400) / 3600);
20
+ const minutes = Math.floor((seconds % 3600) / 60);
21
+ const remainder = seconds % 60;
22
+ if (days) return `${days}d ${hours}h`;
23
+ if (hours) return `${hours}h ${minutes}m`;
24
+ if (minutes) return `${minutes}m ${remainder}s`;
25
+ return `${remainder}s`;
26
+ }
27
+
28
+ function latency(value) {
29
+ const milliseconds = finiteNumber(value);
30
+ if (milliseconds === null) return "n/a";
31
+ if (milliseconds >= 1000) return `${(milliseconds / 1000).toFixed(2)}s`;
32
+ return `${milliseconds.toFixed(1)}ms`;
33
+ }
34
+
35
+ function mebibytes(value) {
36
+ const bytes = finiteNumber(value);
37
+ return bytes === null ? "n/a" : `${(bytes / (1024 * 1024)).toFixed(1)}MiB`;
38
+ }
39
+
40
+ function tpot(metric) {
41
+ const average = finiteNumber(metric?.avg);
42
+ if (average === null) return "n/a";
43
+ const milliseconds = metric?.unit === "us" ? average / 1000 : average;
44
+ return `${milliseconds.toFixed(2)}ms/token`;
45
+ }
46
+
47
+ function cacheHitRate(cache = {}) {
48
+ const hits = finiteNumber(cache.cache_hits);
49
+ const misses = finiteNumber(cache.cache_misses);
50
+ if (hits === null || misses === null || hits + misses <= 0) return "n/a";
51
+ return `${((hits / (hits + misses)) * 100).toFixed(1)}%`;
52
+ }
53
+
54
+ export async function readAdapterStatus({
55
+ host = "127.0.0.1",
56
+ port = 2026,
57
+ timeoutMs = 500,
58
+ fetchImpl = fetch,
59
+ } = {}) {
60
+ const baseUrl = adapterBaseUrl(host, port);
61
+ const ctrl = new AbortController();
62
+ const timer = setTimeout(() => ctrl.abort(), timeoutMs);
63
+ try {
64
+ const response = await fetchImpl(`${baseUrl}${ADAPTER_STATUS_PATH}`, {
65
+ headers: { Accept: "application/json" },
66
+ redirect: "error",
67
+ signal: ctrl.signal,
68
+ });
69
+ if (!response.ok) throw new Error(`returned HTTP ${response.status}`);
70
+
71
+ let data;
72
+ try {
73
+ data = await response.json();
74
+ } catch {
75
+ throw new Error("returned invalid JSON");
76
+ }
77
+ if (data?.ok !== true
78
+ || data?.name !== "codex-copilot-dx"
79
+ || typeof data.version !== "string"
80
+ || !Number.isInteger(data.pid)) {
81
+ throw new Error("returned an incompatible status payload");
82
+ }
83
+ return { baseUrl, data };
84
+ } catch (error) {
85
+ const reason = ctrl.signal.aborted
86
+ ? `timed out after ${timeoutMs}ms`
87
+ : error?.cause?.code === "ECONNREFUSED"
88
+ ? "adapter is not running"
89
+ : error?.message || String(error);
90
+ throw new Error(`Could not read adapter status at ${baseUrl}: ${reason}`);
91
+ } finally {
92
+ clearTimeout(timer);
93
+ }
94
+ }
95
+
96
+ export function formatAdapterStatus({ baseUrl, data }, { commandName = "ccdx", cliVersion = data.version } = {}) {
97
+ const requests = data.requests || {};
98
+ const performance = data.stream_performance?.by_route?.responses || {};
99
+ const admission = data.admission || {};
100
+ const history = data.response_history || {};
101
+ const images = data.image_optimization || {};
102
+ const models = data.models || {};
103
+ const copilot = data.copilot || {};
104
+ const limits = data.limits || {};
105
+ const processStats = data.process || {};
106
+ const adapterLine = data.version === cliVersion
107
+ ? status("ok", `Adapter ${data.version} is running at ${baseUrl} (PID ${data.pid}, uptime ${duration(data.uptime_ms)})`)
108
+ : status("warn", `Adapter ${data.version} is running at ${baseUrl}, but this CLI is ${cliVersion}; stop the running adapter before switching versions`);
109
+
110
+ return [
111
+ `${commandName} status`,
112
+ adapterLine,
113
+ status("info", `Requests: ${count(requests.completed)}/${count(requests.total)} completed, ${count(requests.active)} active, ${count(requests.status_4xx)} 4xx, ${count(requests.status_5xx)} 5xx, ${count(requests.aborted)} aborted`),
114
+ status("info", `Responses stream: TTFT avg ${latency(performance.ttft_ms?.avg)} (${count(performance.ttft_ms?.samples)} samples), TPOT avg ${tpot(performance.tpot_us)}`),
115
+ status("info", `Admission: ${count(admission.activeRequests)} active, ${count(admission.queued)} queued, ${count(admission.rejected)} rejected, ${count(admission.timedOut)} timed out, wait avg ${latency(admission.waitMsAvg)}`),
116
+ status("info", `Memory: RSS ${mebibytes(processStats.rss_bytes)}, heap ${mebibytes(processStats.heap_used_bytes)}`),
117
+ status("info", `History: ${mebibytes(history.bytes)} / ${mebibytes(limits.response_history_max_bytes)}, ${count(history.entries)} entries, ${count(history.evicted)} evicted`),
118
+ status("info", `Image cache: ${count(images.cache_entries)} entries, ${cacheHitRate(images)} hits, ${mebibytes(images.cache_bytes)} / ${mebibytes(images.cache_max_bytes)}`),
119
+ status("info", `Models: ${count(models.models)} total, ${count(models.claude_models)} Claude; Copilot token ${copilot.token_cached ? `cached (${duration(copilot.token_expires_in_ms)} remaining)` : "not cached"}`),
120
+ status("info", `Limits: request body ${mebibytes(limits.max_body_bytes)}, decoded body ${mebibytes(limits.max_decoded_body_bytes)}`),
121
+ ].join("\n");
122
+ }
package/src/doctor.mjs CHANGED
@@ -455,7 +455,7 @@ export async function collectDoctorChecks({
455
455
  export async function runDoctor(options = {}) {
456
456
  const log = options.log || console.log;
457
457
  const flags = [options.online ? "--online" : "", options.compat ? "--compat" : ""].filter(Boolean);
458
- log(`ccdx doctor${flags.length ? ` ${flags.join(" ")}` : ""}`);
458
+ log(`${options.commandName || "ccdx"} doctor${flags.length ? ` ${flags.join(" ")}` : ""}`);
459
459
  const checks = await collectDoctorChecks(options);
460
460
  for (const check of checks) log(status(check.kind, check.message));
461
461
  return checks;
package/src/models.mjs CHANGED
@@ -158,6 +158,17 @@ export function gptModelIdsFromCopilotModels(models) {
158
158
  });
159
159
  }
160
160
 
161
+ export function responsesModelIdsFromCopilotModels(models) {
162
+ return uniqueIds(copilotModelData(models), (model) => {
163
+ const id = String(model?.id || "").trim();
164
+ const endpoints = modelEndpoints(model);
165
+ return id !== CODEX_AUTO_REVIEW_MODEL
166
+ && /^[A-Za-z0-9][A-Za-z0-9._:/-]*$/.test(id)
167
+ && model?.model_picker_enabled !== false
168
+ && (endpoints.includes("/responses") || endpoints.includes("/v1/responses"));
169
+ });
170
+ }
171
+
161
172
  export function claudeDesktopModelDefsFromCopilotModels(models) {
162
173
  const data = copilotModelData(models);
163
174
 
@@ -212,21 +223,21 @@ export function resolveAnthropicModel(model, env = process.env, options = {}) {
212
223
  };
213
224
  }
214
225
 
215
- export function resolveOpenAIModel(model, env = process.env) {
226
+ export function resolveOpenAIModel(model, env = process.env, options = {}) {
216
227
  const requestedModel = String(model || "");
217
228
  if (requestedModel !== CODEX_AUTO_REVIEW_MODEL) {
218
229
  return { requestedModel, upstreamModel: requestedModel };
219
230
  }
220
231
 
221
- const configuredModel = String(env.CCDX_AUTO_REVIEW_MODEL || "").trim();
232
+ const configuredModel = String(env.CCDX_AUTO_REVIEW_MODEL || options.autoReviewModel || "").trim();
222
233
  return {
223
234
  requestedModel,
224
235
  upstreamModel: configuredModel || DEFAULT_CODEX_AUTO_REVIEW_MODEL,
225
236
  };
226
237
  }
227
238
 
228
- export function codexAutoReviewModelStatus(models, env = process.env) {
229
- const { upstreamModel } = resolveOpenAIModel(CODEX_AUTO_REVIEW_MODEL, env);
239
+ export function codexAutoReviewModelStatus(models, env = process.env, options = {}) {
240
+ const { upstreamModel } = resolveOpenAIModel(CODEX_AUTO_REVIEW_MODEL, env, options);
230
241
  const model = copilotModelData(models).find((entry) => String(entry?.id || "").trim() === upstreamModel);
231
242
  if (!model) return { available: false, upstreamModel, reason: "model is not advertised" };
232
243
  const endpoints = modelEndpoints(model);
@@ -1,11 +1,11 @@
1
1
  import { isIP } from "node:net";
2
- import { adapterHealthPayload } from "./running-adapter.mjs";
2
+ import { ADAPTER_STATUS_PATH, adapterHealthPayload } from "./running-adapter.mjs";
3
3
  import { copilotRuntimeStatus } from "./copilot.mjs";
4
4
  import { imageOptimizationStats } from "./image-optimization.mjs";
5
5
  import { responseHistoryStats } from "./response-history.mjs";
6
6
  import { loadRuntimeConfig } from "./runtime-config.mjs";
7
7
 
8
- export const ADAPTER_STATUS_PATH = "/_ccdx/status";
8
+ export { ADAPTER_STATUS_PATH };
9
9
  const OBSERVABILITY_RUNTIME_CONFIG = loadRuntimeConfig();
10
10
 
11
11
  const ROUTE_NAMES = Object.freeze([
@@ -0,0 +1,97 @@
1
+ import { spawn } from "node:child_process";
2
+ import { createInterface } from "node:readline/promises";
3
+
4
+ const PACKAGE_NAME = "codex-copilot-dx";
5
+ const UPDATE_SPECS = {
6
+ npm: `${PACKAGE_NAME}@latest`,
7
+ github: "github:DaleXiao/codex-copilot-dx#main",
8
+ };
9
+
10
+ export function normalizeUpdateSource(value) {
11
+ const source = String(value || "").trim().toLowerCase();
12
+ if (source === "gh") return "github";
13
+ return Object.hasOwn(UPDATE_SPECS, source) ? source : "";
14
+ }
15
+
16
+ export function globalUpdateCommand(source, { platform = process.platform } = {}) {
17
+ const normalized = normalizeUpdateSource(source);
18
+ if (!normalized) throw new Error(`Update source must be npm or github: ${source}`);
19
+ return {
20
+ command: platform === "win32" ? "npm.cmd" : "npm",
21
+ args: ["install", "--global", UPDATE_SPECS[normalized]],
22
+ source: normalized,
23
+ };
24
+ }
25
+
26
+ function waitForExit(child) {
27
+ return new Promise((resolve, reject) => {
28
+ let settled = false;
29
+ const finish = (handler, value) => {
30
+ if (settled) return;
31
+ settled = true;
32
+ handler(value);
33
+ };
34
+ child.once("error", (error) => finish(reject, error));
35
+ child.once("exit", (code, signal) => {
36
+ if (signal) finish(reject, new Error(`npm update was interrupted by ${signal}`));
37
+ else if (code !== 0) finish(reject, new Error(`npm update exited with status ${code}`));
38
+ else finish(resolve);
39
+ });
40
+ });
41
+ }
42
+
43
+ export async function runPackageUpdateCommand({
44
+ commandName = "ccdx",
45
+ env = process.env,
46
+ input = process.stdin,
47
+ output = process.stdout,
48
+ platform = process.platform,
49
+ prompt,
50
+ source,
51
+ spawnImpl = spawn,
52
+ } = {}) {
53
+ let selectedSource = normalizeUpdateSource(source);
54
+ if (source && !selectedSource) throw new Error(`Update source must be npm or github: ${source}`);
55
+
56
+ if (!selectedSource) {
57
+ if (!prompt && (!input.isTTY || !output.isTTY)) {
58
+ throw new Error(`Choose a source with ${commandName} update npm or ${commandName} update github`);
59
+ }
60
+
61
+ output.write(`${commandName} update\n`);
62
+ output.write(" 1. npm registry [default]\n");
63
+ output.write(" 2. GitHub main\n");
64
+
65
+ let readline;
66
+ const ask = prompt || (async (question) => {
67
+ readline ||= createInterface({ input, output });
68
+ return readline.question(question);
69
+ });
70
+ try {
71
+ while (!selectedSource) {
72
+ const answer = String(await ask("Select update source [1], or q to cancel: ") || "").trim().toLowerCase();
73
+ if (answer === "q" || answer === "quit") {
74
+ output.write("No changes made.\n");
75
+ return { cancelled: true, source: "" };
76
+ }
77
+ if (answer === "" || answer === "1" || answer === "npm") selectedSource = "npm";
78
+ else if (answer === "2" || answer === "github" || answer === "gh") selectedSource = "github";
79
+ else output.write("Enter 1 for npm, 2 for GitHub, or q to cancel.\n");
80
+ }
81
+ } finally {
82
+ readline?.close();
83
+ }
84
+ }
85
+
86
+ const update = globalUpdateCommand(selectedSource, { platform });
87
+ const sourceLabel = selectedSource === "npm" ? "npm registry" : "GitHub main";
88
+ output.write(`Updating ${PACKAGE_NAME} from ${sourceLabel}...\n`);
89
+ const child = spawnImpl(update.command, update.args, {
90
+ env,
91
+ shell: false,
92
+ stdio: "inherit",
93
+ });
94
+ await waitForExit(child);
95
+ output.write(`Updated ${PACKAGE_NAME} from ${sourceLabel}. Restart the running adapter to use the new version.\n`);
96
+ return { cancelled: false, source: selectedSource };
97
+ }
@@ -66,9 +66,17 @@ function unsupportedCustomToolsError(model) {
66
66
  return error;
67
67
  }
68
68
 
69
+ function resolveRequestModel(model, openAIModelEnv, autoReviewModelResolver) {
70
+ const options = model === CODEX_AUTO_REVIEW_MODEL && typeof autoReviewModelResolver === "function"
71
+ ? { autoReviewModel: autoReviewModelResolver() }
72
+ : {};
73
+ return resolveOpenAIModel(model, openAIModelEnv, options);
74
+ }
75
+
69
76
  export function createResponsesHandler(options) {
70
77
  const {
71
78
  acquireRequest,
79
+ autoReviewModelResolver,
72
80
  chatCompletionsFn,
73
81
  openAIModelEnv,
74
82
  responsesPayloadOptions,
@@ -91,7 +99,7 @@ export function createResponsesHandler(options) {
91
99
  const prepared = prepareResponsesRequest(parsed, { mutate: true });
92
100
  prepared.surface = "responses";
93
101
  const model = parsed.model || "unknown";
94
- const { requestedModel, upstreamModel } = resolveOpenAIModel(model, openAIModelEnv);
102
+ const { requestedModel, upstreamModel } = resolveRequestModel(model, openAIModelEnv, autoReviewModelResolver);
95
103
  if (upstreamModel !== requestedModel) prepared.body.model = upstreamModel;
96
104
  const upstreamLog = upstreamModel === requestedModel ? "" : ` upstream_model=${upstreamModel}`;
97
105
  console.log(status("info", `responses model=${requestedModel}${upstreamLog} stream=${!!parsed.stream}`));
@@ -186,6 +194,7 @@ export function createResponsesHandler(options) {
186
194
  export function createResponsesCompactHandler(options) {
187
195
  const {
188
196
  acquireRequest,
197
+ autoReviewModelResolver,
189
198
  openAIModelEnv,
190
199
  responsesCompactFn,
191
200
  upstreamTimeoutMs,
@@ -203,7 +212,7 @@ export function createResponsesCompactHandler(options) {
203
212
  );
204
213
  prepared.surface = "responses_compact";
205
214
  const model = parsed.model || "unknown";
206
- const { requestedModel, upstreamModel } = resolveOpenAIModel(model, openAIModelEnv);
215
+ const { requestedModel, upstreamModel } = resolveRequestModel(model, openAIModelEnv, autoReviewModelResolver);
207
216
  if (upstreamModel !== requestedModel) prepared.body.model = upstreamModel;
208
217
  const upstreamLog = upstreamModel === requestedModel ? "" : ` upstream_model=${upstreamModel}`;
209
218
  console.log(status("info", `responses compact model=${requestedModel}${upstreamLog} stream=false`));
@@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
2
2
  import { localPackageVersion } from "./version.mjs";
3
3
 
4
4
  export const ADAPTER_HEALTH_PATH = "/_ccdx/health";
5
+ export const ADAPTER_STATUS_PATH = "/_ccdx/status";
5
6
  export const ADAPTER_PROTOCOL_VERSION = 2;
6
7
  export const ADAPTER_VERSION = localPackageVersion();
7
8
  const ADAPTER_INSTANCE_ID = randomUUID();
@@ -0,0 +1,67 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { atomicWriteFileIfChangedSync } from "./atomic-file.mjs";
5
+ import { DEFAULT_CODEX_AUTO_REVIEW_MODEL } from "./models.mjs";
6
+
7
+ const AUTO_REVIEW_MODEL_KEY = "auto_review_model";
8
+
9
+ export function userSettingsPath({ env = process.env, home = os.homedir() } = {}) {
10
+ const xdgConfigHome = String(env.XDG_CONFIG_HOME || "").trim();
11
+ const configRoot = xdgConfigHome || path.join(home, ".config");
12
+ return path.join(configRoot, "codex-copilot-dx", "config.json");
13
+ }
14
+
15
+ function invalidSettings(filePath, reason) {
16
+ return new Error(`Invalid ccdx settings at ${filePath}: ${reason}`);
17
+ }
18
+
19
+ export function readUserSettings({ env = process.env, home = os.homedir(), strict = false } = {}) {
20
+ const filePath = userSettingsPath({ env, home });
21
+ try {
22
+ const parsed = JSON.parse(fs.readFileSync(filePath, "utf8"));
23
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
24
+ throw invalidSettings(filePath, "expected a JSON object");
25
+ }
26
+ if (Object.hasOwn(parsed, AUTO_REVIEW_MODEL_KEY)) {
27
+ const value = parsed[AUTO_REVIEW_MODEL_KEY];
28
+ if (typeof value !== "string" || !value.trim()) {
29
+ throw invalidSettings(filePath, `${AUTO_REVIEW_MODEL_KEY} must be a non-empty string`);
30
+ }
31
+ }
32
+ return parsed;
33
+ } catch (error) {
34
+ if (error?.code === "ENOENT") return {};
35
+ if (strict) {
36
+ if (String(error?.message || "").startsWith("Invalid ccdx settings")) throw error;
37
+ throw invalidSettings(filePath, error?.message || String(error));
38
+ }
39
+ return {};
40
+ }
41
+ }
42
+
43
+ export function savedAutoReviewModel(options = {}) {
44
+ const settings = readUserSettings(options);
45
+ return String(settings[AUTO_REVIEW_MODEL_KEY] || "").trim();
46
+ }
47
+
48
+ export function autoReviewModelPreference({ env = process.env, home = os.homedir() } = {}) {
49
+ const environmentModel = String(env.CCDX_AUTO_REVIEW_MODEL || "").trim();
50
+ if (environmentModel) return { model: environmentModel, source: "environment" };
51
+
52
+ const savedModel = savedAutoReviewModel({ env, home });
53
+ if (savedModel) return { model: savedModel, source: "settings" };
54
+ return { model: DEFAULT_CODEX_AUTO_REVIEW_MODEL, source: "default" };
55
+ }
56
+
57
+ export function writeAutoReviewModel(model, { env = process.env, home = os.homedir() } = {}) {
58
+ const filePath = userSettingsPath({ env, home });
59
+ const settings = readUserSettings({ env, home, strict: true });
60
+ const value = String(model || "").trim();
61
+ const next = { ...settings };
62
+ if (value) next[AUTO_REVIEW_MODEL_KEY] = value;
63
+ else delete next[AUTO_REVIEW_MODEL_KEY];
64
+
65
+ const changed = atomicWriteFileIfChangedSync(filePath, `${JSON.stringify(next, null, 2)}\n`, { mode: 0o600 });
66
+ return { changed, filePath, model: value || DEFAULT_CODEX_AUTO_REVIEW_MODEL };
67
+ }