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 +3 -3
- package/bin/cli.mjs +24 -5
- package/package.json +5 -1
- package/src/adapter.mjs +35 -6
- package/src/anthropic.mjs +3 -14
- package/src/copilot.mjs +32 -20
- package/src/models.mjs +17 -0
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
|
|
97
|
-
| `CCDX_IMG_QUALITY` | `85` | WebP quality used when
|
|
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
|
-
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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.
|
|
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
|
-
//
|
|
14
|
-
|
|
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
|
|
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 (
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
|
|
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 : [];
|