codex-copilot-dx 0.5.0 → 0.5.3

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
 
@@ -33,6 +33,42 @@ global install, use `npx codex-copilot-dx@latest`.
33
33
  Both installed commands expose the same options and subcommands; help, version,
34
34
  doctor, status, and argument-error output use the command name that was invoked.
35
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` and opts in to Git fetching
66
+ for that command, as required by npm 12. It does not change npm's persistent
67
+ `allow-git` setting. Both paths use npm's global installer without shell
68
+ interpolation. A currently running adapter keeps its loaded version until it is
69
+ stopped and started again. The
70
+ `codex-copilot-dx update` compatibility command behaves identically.
71
+
36
72
  On first run, it will:
37
73
  1. Authenticate with GitHub via device flow (if needed), after first trying compatible local Copilot token sources
38
74
  2. Print the local package version and check for a newer npm release in the background
@@ -145,7 +181,7 @@ Environment variables:
145
181
  | `CCDX_STREAM_IDLE_TIMEOUT_MS` | `120000` | Maximum idle time between upstream streaming body chunks |
146
182
  | `CCDX_UPSTREAM_RETRIES` | `2` | Retries for safe requests and clearly pre-connect POST failures; capped at `5` |
147
183
  | `CCDX_UPSTREAM_RETRY_DELAY_MS` | `300` | Initial upstream retry backoff in milliseconds; capped at `5000` |
148
- | `CCDX_AUTO_REVIEW_MODEL` | `gpt-5.5` | Copilot Responses model used for Codex Auto-review requests |
184
+ | `CCDX_AUTO_REVIEW_MODEL` | saved selection or `gpt-5.5` | Copilot Responses model used for Codex Auto-review requests; overrides the interactive selection |
149
185
  | `CCDX_LOG_PATH` | unset | Mirror terminal logs to a file; set to `1` for `~/.local/share/codex-copilot-dx/debug.log` |
150
186
  | `CCDX_LOG_LEVEL` | `info` | Set to `debug` to include upstream request attempts, status codes, retry causes, and timings |
151
187
  | `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
@@ -19,6 +19,8 @@ import { initializeModelRegistry, runInBackground } from "../src/startup.mjs";
19
19
  import { cliCommandName, cliHelp, parseAdapterProbeOptions, parseCliArgs, parseRuntimeOptions } from "../src/cli-options.mjs";
20
20
  import { formatAdapterStatus, readAdapterStatus } from "../src/cli-status.mjs";
21
21
  import { closeHttpServer } from "../src/shutdown.mjs";
22
+ import { runAutoReviewModelCommand } from "../src/auto-review-model.mjs";
23
+ import { autoReviewModelPreference } from "../src/user-settings.mjs";
22
24
 
23
25
  const LOCAL_VERSION = localPackageVersion();
24
26
  const CLI_NAME = cliCommandName();
@@ -66,6 +68,25 @@ if (CLI.command === "status") {
66
68
  process.exit(1);
67
69
  }
68
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
+ }
69
90
 
70
91
  let RUNTIME;
71
92
  try {
@@ -128,7 +149,9 @@ async function refreshClaudeDesktopModelDefs() {
128
149
  throw new Error(`Copilot models returned HTTP ${httpStatus}`);
129
150
  }
130
151
  const models = JSON.parse(body);
131
- const autoReview = codexAutoReviewModelStatus(models);
152
+ const autoReview = codexAutoReviewModelStatus(models, process.env, {
153
+ autoReviewModel: autoReviewModelPreference().model,
154
+ });
132
155
  if (!autoReview.available) {
133
156
  console.log(status("warn", `Auto-review target ${autoReview.upstreamModel} is unavailable: ${autoReview.reason}. Run ${CLI_NAME} doctor --compat to verify the live path.`));
134
157
  }
@@ -292,6 +315,7 @@ try {
292
315
  activeServer = await startAdapter(ADAPTER_PORT, ADAPTER_HOST, {
293
316
  claudeDesktopApiKey,
294
317
  claudeDesktopModelDefs,
318
+ autoReviewModelResolver: () => autoReviewModelPreference().model,
295
319
  modelRegistry: MODEL_REGISTRY,
296
320
  showRequestId: CLI.showRequestId,
297
321
  upstreamTimeoutMs: RUNTIME.upstreamTimeoutMs,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-copilot-dx",
3
- "version": "0.5.0",
3
+ "version": "0.5.3",
4
4
  "description": "Use Codex Desktop, Claude Code, and Claude App with GitHub Copilot",
5
5
  "author": "Dale Xiao",
6
6
  "type": "module",
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
+ }
@@ -26,6 +26,25 @@ export function parseCliArgs(args = []) {
26
26
  if (rest.length) unexpectedArgs(rest);
27
27
  return { command: "usage", configureClaudeDesktop: false, showRequestId: false, online: false, compat: false };
28
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
+ }
29
48
  if (command === "doctor" || command === "--doctor") {
30
49
  const supported = new Set(["--online", "--compat"]);
31
50
  const invalid = rest.filter((arg) => !supported.has(arg));
@@ -107,6 +126,8 @@ export function cliHelp(commandName = "ccdx") {
107
126
  ${name} doctor [--online] [--compat]
108
127
  ${name} status
109
128
  ${name} usage
129
+ ${name} auto-review-model
130
+ ${name} update [npm|github]
110
131
  ${name} --version
111
132
  ${name} --help
112
133
 
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);
@@ -0,0 +1,100 @@
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
+ const args = ["install", "--global"];
20
+ if (normalized === "github") args.push("--allow-git=all");
21
+ args.push(UPDATE_SPECS[normalized]);
22
+ return {
23
+ command: platform === "win32" ? "npm.cmd" : "npm",
24
+ args,
25
+ source: normalized,
26
+ };
27
+ }
28
+
29
+ function waitForExit(child) {
30
+ return new Promise((resolve, reject) => {
31
+ let settled = false;
32
+ const finish = (handler, value) => {
33
+ if (settled) return;
34
+ settled = true;
35
+ handler(value);
36
+ };
37
+ child.once("error", (error) => finish(reject, error));
38
+ child.once("exit", (code, signal) => {
39
+ if (signal) finish(reject, new Error(`npm update was interrupted by ${signal}`));
40
+ else if (code !== 0) finish(reject, new Error(`npm update exited with status ${code}`));
41
+ else finish(resolve);
42
+ });
43
+ });
44
+ }
45
+
46
+ export async function runPackageUpdateCommand({
47
+ commandName = "ccdx",
48
+ env = process.env,
49
+ input = process.stdin,
50
+ output = process.stdout,
51
+ platform = process.platform,
52
+ prompt,
53
+ source,
54
+ spawnImpl = spawn,
55
+ } = {}) {
56
+ let selectedSource = normalizeUpdateSource(source);
57
+ if (source && !selectedSource) throw new Error(`Update source must be npm or github: ${source}`);
58
+
59
+ if (!selectedSource) {
60
+ if (!prompt && (!input.isTTY || !output.isTTY)) {
61
+ throw new Error(`Choose a source with ${commandName} update npm or ${commandName} update github`);
62
+ }
63
+
64
+ output.write(`${commandName} update\n`);
65
+ output.write(" 1. npm registry [default]\n");
66
+ output.write(" 2. GitHub main\n");
67
+
68
+ let readline;
69
+ const ask = prompt || (async (question) => {
70
+ readline ||= createInterface({ input, output });
71
+ return readline.question(question);
72
+ });
73
+ try {
74
+ while (!selectedSource) {
75
+ const answer = String(await ask("Select update source [1], or q to cancel: ") || "").trim().toLowerCase();
76
+ if (answer === "q" || answer === "quit") {
77
+ output.write("No changes made.\n");
78
+ return { cancelled: true, source: "" };
79
+ }
80
+ if (answer === "" || answer === "1" || answer === "npm") selectedSource = "npm";
81
+ else if (answer === "2" || answer === "github" || answer === "gh") selectedSource = "github";
82
+ else output.write("Enter 1 for npm, 2 for GitHub, or q to cancel.\n");
83
+ }
84
+ } finally {
85
+ readline?.close();
86
+ }
87
+ }
88
+
89
+ const update = globalUpdateCommand(selectedSource, { platform });
90
+ const sourceLabel = selectedSource === "npm" ? "npm registry" : "GitHub main";
91
+ output.write(`Updating ${PACKAGE_NAME} from ${sourceLabel}...\n`);
92
+ const child = spawnImpl(update.command, update.args, {
93
+ env,
94
+ shell: false,
95
+ stdio: "inherit",
96
+ });
97
+ await waitForExit(child);
98
+ output.write(`Updated ${PACKAGE_NAME} from ${sourceLabel}. Restart the running adapter to use the new version.\n`);
99
+ return { cancelled: false, source: selectedSource };
100
+ }
@@ -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`));
@@ -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
+ }