codex-copilot-dx 0.4.20 → 0.4.22

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
@@ -93,8 +93,8 @@ Environment variables:
93
93
  | `CCDX_UPSTREAM_RETRY_DELAY_MS` | `300` | Initial upstream retry backoff in milliseconds; capped at `5000` |
94
94
  | `CCDX_LOG_PATH` | unset | Mirror terminal logs to a file; set to `1` for `~/.local/share/codex-copilot-dx/debug.log` |
95
95
  | `CCDX_LOG_LEVEL` | `info` | Set to `debug` to include upstream request attempts, status codes, retry causes, and timings |
96
- | `CCDX_IMG_MAX_DIM` | `2048` | Max long edge in pixels for optional image downscaling |
97
- | `CCDX_IMG_QUALITY` | `85` | WebP quality used when optional image re-encoding is available |
96
+ | `CCDX_IMG_MAX_DIM` | `2048` | Max long edge in pixels for image downscaling |
97
+ | `CCDX_IMG_QUALITY` | `85` | WebP quality used when re-encoding images |
98
98
  | `CCDX_IMG_MIN_BYTES` | `100000` | Images smaller than this are left untouched |
99
99
  | `CCDX_IMG_CONCURRENCY` | `4` | Concurrent image optimization tasks; values above `12` are capped at `12` |
100
100
  | `CCDX_DISABLE_IMG_OPT` | unset | Set to `1` to disable image optimization |
@@ -127,7 +127,7 @@ codex-copilot-dx usage
127
127
 
128
128
  Long computer-use sessions can accumulate screenshots inside the conversation history. Each screenshot is shipped on later turns, and GitHub Copilot's `/responses` endpoint can reject oversized requests with `413 Payload Too Large`.
129
129
 
130
- When the optional `sharp` package is available, the adapter downsamples embedded screenshots to long-edge <= 2048 px and re-encodes them as WebP before forwarding `/v1/responses`. `sharp` is not installed by default so the CLI stays lightweight and avoids native package install stalls; install it alongside `codex-copilot-dx` if you want this optimization.
130
+ The adapter automatically downsamples embedded screenshots to long-edge <= 2048 px and re-encodes them as WebP before forwarding `/v1/responses`.
131
131
 
132
132
  ## License
133
133
 
package/bin/cli.mjs CHANGED
@@ -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);
@@ -172,6 +181,16 @@ try {
172
181
  // Launch Codex when available.
173
182
  openCodex();
174
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
+ }
193
+
175
194
  console.log(`
176
195
  ${status("ok", "Ready, Claude Code, Claude App and Codex App are now ready to use")}
177
196
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-copilot-dx",
3
- "version": "0.4.20",
3
+ "version": "0.4.22",
4
4
  "description": "Use Codex Desktop, Claude Code, and Claude App with GitHub Copilot",
5
5
  "type": "module",
6
6
  "bin": {
@@ -27,5 +27,9 @@
27
27
  "license": "MIT",
28
28
  "scripts": {
29
29
  "test": "node --test"
30
+ },
31
+ "dependencies": {
32
+ "gpt-tokenizer": "^3.4.0",
33
+ "sharp": "^0.34.5"
30
34
  }
31
35
  }
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/anthropic.mjs CHANGED
@@ -1,4 +1,5 @@
1
1
  import { randomUUID } from "node:crypto";
2
+ import { encode } from "gpt-tokenizer";
2
3
 
3
4
  // Anthropic Messages API to OpenAI chat/completions translation.
4
5
  // Pure functions only; network calls stay in adapter.mjs and copilot.mjs.
@@ -237,19 +238,7 @@ export async function streamAnthropicFromLines(lineIterator, emit, model, option
237
238
  await emit("message_stop", { type: "message_stop" });
238
239
  }
239
240
 
240
- function estimateTokens(text) {
241
- if (!text) return 0;
242
- let asciiChars = 0;
243
- let nonAsciiChars = 0;
244
- for (const ch of text) {
245
- if (/\s/.test(ch)) continue;
246
- if (ch.codePointAt(0) <= 0x7f) asciiChars += 1;
247
- else nonAsciiChars += 1;
248
- }
249
- return Math.max(1, Math.ceil(asciiChars / 4) + nonAsciiChars);
250
- }
251
-
252
- // Copilot has no count_tokens endpoint; estimate locally without install-time dependencies.
241
+ // Copilot has no count_tokens endpoint; estimate locally with gpt-tokenizer.
253
242
  export function countTokens(body) {
254
243
  const parts = [];
255
244
  const sys = systemToText(body.system);
@@ -274,5 +263,5 @@ export function countTokens(body) {
274
263
  }
275
264
 
276
265
  const text = parts.join("\n");
277
- return { input_tokens: estimateTokens(text) };
266
+ return { input_tokens: encode(text).length };
278
267
  }
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);
@@ -21,8 +45,6 @@ const IMG_CONCURRENCY = parseImageConcurrency(process.env.CCDX_IMG_CONCURRENCY);
21
45
  const UPSTREAM_RETRIES = parseUpstreamRetries(process.env.CCDX_UPSTREAM_RETRIES);
22
46
  const UPSTREAM_RETRY_DELAY_MS = parseUpstreamRetryDelayMs(process.env.CCDX_UPSTREAM_RETRY_DELAY_MS);
23
47
  let sharpImport = null;
24
- let sharpUnavailable = false;
25
- let sharpUnavailableLogged = false;
26
48
 
27
49
  export function parseImageConcurrency(value) {
28
50
  const n = Number.parseInt(value, 10);
@@ -175,16 +197,9 @@ export async function fetchCopilotUpstream(
175
197
  }
176
198
 
177
199
  async function sharp() {
178
- if (sharpUnavailable) return null;
179
- try {
180
- sharpImport ||= import("sharp");
181
- const mod = await sharpImport;
182
- return mod.default || mod;
183
- } catch {
184
- sharpUnavailable = true;
185
- sharpImport = null;
186
- return null;
187
- }
200
+ sharpImport ||= import("sharp");
201
+ const mod = await sharpImport;
202
+ return mod.default || mod;
188
203
  }
189
204
 
190
205
  export async function optimizeImageDataUrl(dataUrl) {
@@ -198,13 +213,6 @@ export async function optimizeImageDataUrl(dataUrl) {
198
213
 
199
214
  try {
200
215
  const resize = await sharp();
201
- if (!resize) {
202
- if (!sharpUnavailableLogged) {
203
- sharpUnavailableLogged = true;
204
- console.warn(status("warn", "image optimization skipped because optional dependency sharp is not installed"));
205
- }
206
- return dataUrl;
207
- }
208
216
  const out = await resize(raw, { failOn: "none" })
209
217
  .rotate()
210
218
  .resize(IMG_MAX_DIM, IMG_MAX_DIM, { fit: "inside", withoutEnlargement: true })
@@ -502,7 +510,11 @@ export async function listModels({ signal, fetchImpl, retryOptions } = {}) {
502
510
  token, version: getVSCodeVersion(), initiator: "user", vision: false,
503
511
  });
504
512
  const resp = await fetchCopilotUpstream(`${getApiBase()}/models`, { headers, signal }, { fetchImpl, ...retryOptions });
505
- 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 };
506
518
  }
507
519
 
508
520
  // Responses-only models go directly to Copilot's /responses endpoint.
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 : [];