codex-copilot-dx 0.4.21 → 0.4.23

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/bin/cli.mjs CHANGED
@@ -96,7 +96,7 @@ async function reuseRunningAdapterIfAvailable() {
96
96
  console.log(status("ok", "Claude App support available with --configure-claude-desktop"));
97
97
  }
98
98
 
99
- openCodex();
99
+ await openCodex();
100
100
  console.log(`
101
101
  ${status("ok", "Ready, using the existing codex-copilot-dx adapter")}
102
102
 
@@ -125,11 +125,20 @@ console.log(`
125
125
  Use Codex Desktop, Claude Code, and Claude App with GitHub Copilot
126
126
  `);
127
127
 
128
- checkForUpdate({ currentVersion: LOCAL_VERSION }).then(({ latestVersion, updateAvailable }) => {
129
- if (!updateAvailable) return;
130
- console.log(`\n ${status("warn", `Update available: ${LOCAL_VERSION} -> ${latestVersion}`)}`);
131
- console.log(" npm install -g codex-copilot-dx@latest\n");
132
- });
128
+ async function printUpdateNotice() {
129
+ try {
130
+ const { latestVersion, updateAvailable } = await checkForUpdate({ currentVersion: LOCAL_VERSION });
131
+ if (!updateAvailable) return;
132
+ console.log(`\n ${status("warn", `Update available: ${LOCAL_VERSION} -> ${latestVersion}`)}`);
133
+ console.log(" npm install -g codex-copilot-dx@latest\n");
134
+ } catch {
135
+ // Never block startup on the update check.
136
+ }
137
+ }
138
+
139
+ // Await the update check up front so the notice is shown even on the
140
+ // "reuse existing adapter" path, which exits early below.
141
+ await printUpdateNotice();
133
142
 
134
143
  try {
135
144
  assertSafeAdapterHost(ADAPTER_HOST, process.env);
@@ -170,7 +179,17 @@ try {
170
179
  }
171
180
 
172
181
  // Launch Codex when available.
173
- openCodex();
182
+ await openCodex();
183
+
184
+ // Periodically refresh the model list + endpoint cache so a long-running
185
+ // adapter picks up newly released models without a restart.
186
+ const refreshIntervalMs = parseInt(process.env.CCDX_MODEL_REFRESH_INTERVAL_MS || String(30 * 60 * 1000), 10);
187
+ if (Number.isFinite(refreshIntervalMs) && refreshIntervalMs > 0) {
188
+ const timer = setInterval(() => {
189
+ refreshClaudeDesktopModelDefs().catch(() => {});
190
+ }, refreshIntervalMs);
191
+ if (typeof timer.unref === "function") timer.unref();
192
+ }
174
193
 
175
194
  console.log(`
176
195
  ${status("ok", "Ready, Claude Code, Claude App and Codex App are now ready to use")}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-copilot-dx",
3
- "version": "0.4.21",
3
+ "version": "0.4.23",
4
4
  "description": "Use Codex Desktop, Claude Code, and Claude App with GitHub Copilot",
5
5
  "type": "module",
6
6
  "bin": {
package/src/adapter.mjs CHANGED
@@ -2,16 +2,20 @@ import http from "node:http";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { promisify } from "node:util";
4
4
  import * as zlib from "node:zlib";
5
- import { chatCompletions, listModels, responses as copilotResponses, responsesCompact as copilotResponsesCompact } from "./copilot.mjs";
5
+ import { chatCompletions, listModels, responses as copilotResponses, responsesCompact as copilotResponsesCompact, getCachedModelEndpoints } from "./copilot.mjs";
6
6
  import { webStreamLines } from "./stream.mjs";
7
7
  import { anthropicToChat, chatToAnthropic, streamAnthropicFromLines, countTokens } from "./anthropic.mjs";
8
- import { claudeDesktopModelsResponse, resolveAnthropicModel } from "./models.mjs";
8
+ import { claudeDesktopModelsResponse, resolveAnthropicModel, modelIsResponsesOnly, modelSupportsChatCompletions } from "./models.mjs";
9
9
  import { status } from "./status.mjs";
10
10
  import { recordAnthropicUsage, recordResponsesUsage } from "./usage.mjs";
11
11
  import { ADAPTER_HEALTH_PATH, adapterHealthPayload } from "./running-adapter.mjs";
12
12
 
13
- // Models that only support Responses API (not chat/completions)
14
- const RESPONSES_ONLY = new Set([
13
+ // Fallback list for models known to only support the Responses API, used when
14
+ // live model metadata (supported_endpoints) has not been cached yet.
15
+ const RESPONSES_ONLY_FALLBACK = new Set([
16
+ "gpt-5.6-luna",
17
+ "gpt-5.6-sol",
18
+ "gpt-5.6-terra",
15
19
  "gpt-5.5",
16
20
  "gpt-5.4",
17
21
  "gpt-5.4-mini",
@@ -19,6 +23,18 @@ const RESPONSES_ONLY = new Set([
19
23
  "gpt-5.2-codex",
20
24
  ]);
21
25
 
26
+ // Decide whether a model must be proxied straight to /responses (vs converted to
27
+ // /chat/completions). Prefer real endpoint metadata; fall back to the static list.
28
+ function isResponsesOnlyModel(model) {
29
+ const endpoints = getCachedModelEndpoints(model);
30
+ if (endpoints) {
31
+ const fakeModel = { supported_endpoints: endpoints };
32
+ if (modelSupportsChatCompletions(fakeModel)) return false;
33
+ if (modelIsResponsesOnly(fakeModel)) return true;
34
+ }
35
+ return RESPONSES_ONLY_FALLBACK.has(model);
36
+ }
37
+
22
38
  // Direct Copilot Responses API proxy.
23
39
 
24
40
  const RESPONSE_HISTORY_LIMIT = 64;
@@ -324,6 +340,19 @@ export function clearResponseHistoryForTests() {
324
340
  responseHistories.clear();
325
341
  }
326
342
 
343
+ // The GPT (gpt-5.6) Responses backend registers a built-in `image_gen` tool
344
+ // namespace. When the client also sends an image generation tool, the upstream
345
+ // rejects the request with "User-defined namespace 'image_gen' collides ...".
346
+ // Strip any built-in image tool (matched by type/name/namespace) before proxying.
347
+ function isBuiltinImageTool(tool) {
348
+ if (!tool || typeof tool !== "object") return false;
349
+ const candidates = [tool.type, tool.name, tool.namespace];
350
+ return candidates.some((v) => {
351
+ const s = String(v || "").toLowerCase();
352
+ return s === "image_gen" || s === "image_generation" || s.startsWith("image_gen");
353
+ });
354
+ }
355
+
327
356
  export function prepareResponsesRequest(reqBody) {
328
357
  const body = cloneJson(reqBody);
329
358
  const currentInputItems = responsesInputItems(body.input);
@@ -344,7 +373,7 @@ export function prepareResponsesRequest(reqBody) {
344
373
  delete body.previous_response_id;
345
374
  delete body.store;
346
375
  if (Array.isArray(body.tools)) {
347
- body.tools = body.tools.filter((tool) => tool?.type !== "image_generation");
376
+ body.tools = body.tools.filter((tool) => !isBuiltinImageTool(tool));
348
377
  if (!body.tools.length) delete body.tools;
349
378
  }
350
379
  stripInternalResponsesInputFields(body.input);
@@ -591,7 +620,7 @@ export function startAdapter(port = 2026, host = "127.0.0.1", options = {}) {
591
620
  prepared.surface = "responses";
592
621
  const model = parsed.model || "unknown";
593
622
  console.log(status("info", `responses model=${model} stream=${!!parsed.stream}`));
594
- if (RESPONSES_ONLY.has(model)) {
623
+ if (isResponsesOnlyModel(model)) {
595
624
  await proxyCopilotResponses(prepared, req, res, copilotResponses, { signal: abort.signal });
596
625
  } else {
597
626
  const chatReq = responsesToChat(prepared.body);
package/src/copilot.mjs CHANGED
@@ -7,6 +7,30 @@ import { githubReauthMessage, githubTokenPath, importDiscoveredGithubToken } fro
7
7
 
8
8
  export const DEFAULT_API_BASE = "https://api.githubcopilot.com";
9
9
  let apiBase = DEFAULT_API_BASE;
10
+
11
+ // Cache of model id -> supported_endpoints, populated from listModels(). Used to
12
+ // route requests to /responses vs /chat/completions based on real model metadata.
13
+ const modelEndpointCache = new Map();
14
+
15
+ export function cacheModelEndpoints(models) {
16
+ const data = Array.isArray(models) ? models : models?.data;
17
+ if (!Array.isArray(data)) return;
18
+ for (const model of data) {
19
+ const id = String(model?.id || "").trim();
20
+ if (id && Array.isArray(model?.supported_endpoints)) {
21
+ modelEndpointCache.set(id, model.supported_endpoints);
22
+ }
23
+ }
24
+ }
25
+
26
+ // Returns the cached supported_endpoints for a model id, or null if unknown.
27
+ export function getCachedModelEndpoints(modelId) {
28
+ return modelEndpointCache.get(String(modelId || "").trim()) || null;
29
+ }
30
+
31
+ export function resetModelEndpointCacheForTests() {
32
+ modelEndpointCache.clear();
33
+ }
10
34
  const IMG_MAX_DIM = parseInt(process.env.CCDX_IMG_MAX_DIM || "2048", 10);
11
35
  const IMG_QUALITY = parseInt(process.env.CCDX_IMG_QUALITY || "85", 10);
12
36
  const IMG_MIN_BYTES = parseInt(process.env.CCDX_IMG_MIN_BYTES || "100000", 10);
@@ -486,7 +510,11 @@ export async function listModels({ signal, fetchImpl, retryOptions } = {}) {
486
510
  token, version: getVSCodeVersion(), initiator: "user", vision: false,
487
511
  });
488
512
  const resp = await fetchCopilotUpstream(`${getApiBase()}/models`, { headers, signal }, { fetchImpl, ...retryOptions });
489
- return { status: resp.status, body: await resp.text() };
513
+ const body = await resp.text();
514
+ if (resp.ok) {
515
+ try { cacheModelEndpoints(JSON.parse(body)); } catch {}
516
+ }
517
+ return { status: resp.status, body };
490
518
  }
491
519
 
492
520
  // Responses-only models go directly to Copilot's /responses endpoint.
package/src/launcher.mjs CHANGED
@@ -3,17 +3,62 @@ import { status } from "./status.mjs";
3
3
 
4
4
  export { ensureAuth } from "./auth.mjs";
5
5
 
6
- export function openCodex() {
7
- if (process.platform === "darwin") {
6
+ // The Codex desktop app was folded into the ChatGPT app, but it keeps the
7
+ // original bundle identifier. Launching by bundle id works for both the old
8
+ // Codex.app and the new ChatGPT.app regardless of where they are installed.
9
+ const CODEX_BUNDLE_ID = "com.openai.codex";
10
+ const APP_PATH_CANDIDATES = [
11
+ "/Applications/Codex.app",
12
+ "/Applications/ChatGPT.app",
13
+ ];
14
+
15
+ export function autoLaunchEnabled(env = process.env) {
16
+ const raw = env.CCDX_AUTO_LAUNCH;
17
+ if (raw === undefined || raw === "") return true;
18
+ return !/^(0|false|no|off)$/i.test(String(raw).trim());
19
+ }
20
+
21
+ // Run `open` and resolve to true only when it exits successfully.
22
+ function tryOpen(args, { spawnImpl = spawn } = {}) {
23
+ return new Promise((resolve) => {
24
+ let child;
8
25
  try {
9
- spawn("open", ["/Applications/Codex.app"], { detached: true, stdio: "ignore" });
10
- console.log(status("ok", "Codex app launched"));
26
+ child = spawnImpl("open", args, { stdio: "ignore" });
11
27
  } catch {
12
- console.log(status("warn", "Codex app not found at /Applications/Codex.app"));
13
- console.log(status("info", "Download Codex from https://openai.com/codex"));
28
+ resolve(false);
29
+ return;
14
30
  }
15
- } else {
16
- console.log(status("warn", "Auto-launch is not supported on this platform"));
17
- console.log(status("info", "Open Codex manually; it will connect to the adapter."));
31
+ child.on("error", () => resolve(false));
32
+ child.on("exit", (code) => resolve(code === 0));
33
+ });
34
+ }
35
+
36
+ // Build the ordered list of launch attempts: bundle id first, then paths.
37
+ export function launchAttempts() {
38
+ return [["-b", CODEX_BUNDLE_ID], ...APP_PATH_CANDIDATES.map((path) => [path])];
39
+ }
40
+
41
+ export async function openCodex({ env = process.env, spawnImpl = spawn, platform = process.platform } = {}) {
42
+ if (platform !== "darwin") {
43
+ console.log(status("warn", "Auto-launch is only supported on macOS"));
44
+ console.log(status("info", "Open Codex or the ChatGPT app manually; it will connect to the adapter."));
45
+ return false;
18
46
  }
47
+
48
+ if (!autoLaunchEnabled(env)) {
49
+ console.log(status("info", "Auto-launch disabled (CCDX_AUTO_LAUNCH=0); open Codex or the ChatGPT app manually."));
50
+ return false;
51
+ }
52
+
53
+ for (const args of launchAttempts()) {
54
+ if (await tryOpen(args, { spawnImpl })) {
55
+ console.log(status("ok", "Codex app launched"));
56
+ return true;
57
+ }
58
+ }
59
+
60
+ console.log(status("warn", "Could not launch the Codex app (checked com.openai.codex, Codex.app, ChatGPT.app)"));
61
+ console.log(status("info", "Open Codex or the ChatGPT app manually; it will connect to the adapter."));
62
+ console.log(status("info", "Download it from https://openai.com/codex"));
63
+ return false;
19
64
  }
package/src/models.mjs CHANGED
@@ -102,6 +102,23 @@ function hasOpenAIEndpoint(model) {
102
102
  return endpoints.includes("/responses") || endpoints.includes("/v1/responses") || endpoints.includes("/chat/completions");
103
103
  }
104
104
 
105
+ // Endpoint-based routing helpers. A model is "responses-only" when it exposes a
106
+ // /responses endpoint but does NOT accept /chat/completions. These read the real
107
+ // model metadata so new models (e.g. gpt-5.6-*) route correctly without a hardcoded list.
108
+ export function modelEndpoints(model) {
109
+ return Array.isArray(model?.supported_endpoints) ? model.supported_endpoints : [];
110
+ }
111
+
112
+ export function modelSupportsChatCompletions(model) {
113
+ return modelEndpoints(model).includes("/chat/completions");
114
+ }
115
+
116
+ export function modelIsResponsesOnly(model) {
117
+ const endpoints = modelEndpoints(model);
118
+ const hasResponses = endpoints.includes("/responses") || endpoints.includes("/v1/responses");
119
+ return hasResponses && !endpoints.includes("/chat/completions");
120
+ }
121
+
105
122
  function copilotModelData(models) {
106
123
  const data = Array.isArray(models) ? models : models?.data;
107
124
  return Array.isArray(data) ? data : [];