codex-copilot-dx 0.4.18 → 0.4.20

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
@@ -89,8 +89,12 @@ Environment variables:
89
89
  | `CCDX_MAX_BODY_BYTES` | `134217728` | Maximum compressed/raw request body size |
90
90
  | `CCDX_MAX_DECODED_BODY_BYTES` | `268435456` | Maximum decoded request body size after decompression |
91
91
  | `CCDX_UPSTREAM_TIMEOUT_MS` | `120000` | Timeout for non-streaming upstream Copilot requests |
92
- | `CCDX_IMG_MAX_DIM` | `2048` | Max long edge in pixels for image downscaling |
93
- | `CCDX_IMG_QUALITY` | `85` | WebP quality used when re-encoding images |
92
+ | `CCDX_UPSTREAM_RETRIES` | `2` | Retries for transient Copilot upstream network errors; capped at `5` |
93
+ | `CCDX_UPSTREAM_RETRY_DELAY_MS` | `300` | Initial upstream retry backoff in milliseconds; capped at `5000` |
94
+ | `CCDX_LOG_PATH` | unset | Mirror terminal logs to a file; set to `1` for `~/.local/share/codex-copilot-dx/debug.log` |
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 |
94
98
  | `CCDX_IMG_MIN_BYTES` | `100000` | Images smaller than this are left untouched |
95
99
  | `CCDX_IMG_CONCURRENCY` | `4` | Concurrent image optimization tasks; values above `12` are capped at `12` |
96
100
  | `CCDX_DISABLE_IMG_OPT` | unset | Set to `1` to disable image optimization |
@@ -111,6 +115,10 @@ Environment variables:
111
115
 
112
116
  The adapter records token usage metadata to `~/.local/share/codex-copilot-dx/usage.jsonl` when upstream responses include usage fields. It logs counts, model names, API surface, and response IDs only; it does not log prompts, completions, tool arguments, or image content.
113
117
 
118
+ ### Debug logging
119
+
120
+ Set `CCDX_LOG_PATH=1` to mirror terminal logs to `~/.local/share/codex-copilot-dx/debug.log`, or set `CCDX_LOG_PATH` to a custom file. Add `CCDX_LOG_LEVEL=debug` to include upstream request attempts, retry causes, status codes, and timings. Debug logs do not include prompts, completions, request bodies, or authorization tokens.
121
+
114
122
  ```bash
115
123
  codex-copilot-dx usage
116
124
  ```
@@ -119,7 +127,7 @@ codex-copilot-dx usage
119
127
 
120
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`.
121
129
 
122
- The adapter automatically downsamples embedded screenshots to long-edge <= 2048 px and re-encodes them as WebP before forwarding `/v1/responses`.
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.
123
131
 
124
132
  ## License
125
133
 
package/bin/cli.mjs CHANGED
@@ -8,6 +8,7 @@ import { startAdapter } from "../src/adapter.mjs";
8
8
  import { listModels, refreshVSCodeVersion } from "../src/copilot.mjs";
9
9
  import { claudeDesktopModelDefsFromCopilotModels, claudeDesktopModelIds, gptModelIdsFromCopilotModels, parseModelAliasEnv } from "../src/models.mjs";
10
10
  import { status } from "../src/status.mjs";
11
+ import { configureLogging } from "../src/log.mjs";
11
12
  import { printUsageSummary } from "../src/usage.mjs";
12
13
  import { checkForUpdate, localPackageVersion } from "../src/version.mjs";
13
14
  import { runDoctor } from "../src/doctor.mjs";
@@ -21,6 +22,12 @@ const EXISTING_ADAPTER_TIMEOUT_MS = parseInt(process.env.CCDX_EXISTING_ADAPTER_T
21
22
  const LOCAL_VERSION = localPackageVersion();
22
23
  const command = process.argv[2];
23
24
  const CONFIGURE_CLAUDE_DESKTOP = command === "--configure-claude-desktop" || process.env.CCDX_CONFIGURE_CLAUDE_DESKTOP === "1";
25
+ const LOGGING = configureLogging();
26
+
27
+ if (LOGGING.filePath) {
28
+ console.log(status("info", `Debug log: ${LOGGING.filePath}`));
29
+ if (LOGGING.level === "debug") console.log(status("debug", "Debug logging enabled"));
30
+ }
24
31
 
25
32
  async function refreshClaudeDesktopModelDefs() {
26
33
  if (parseModelAliasEnv(process.env.CCDX_CLAUDE_MODEL_ALIASES).length) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-copilot-dx",
3
- "version": "0.4.18",
3
+ "version": "0.4.20",
4
4
  "description": "Use Codex Desktop, Claude Code, and Claude App with GitHub Copilot",
5
5
  "type": "module",
6
6
  "bin": {
@@ -27,9 +27,5 @@
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"
34
30
  }
35
31
  }
package/src/anthropic.mjs CHANGED
@@ -1,5 +1,4 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { encode } from "gpt-tokenizer";
3
2
 
4
3
  // Anthropic Messages API to OpenAI chat/completions translation.
5
4
  // Pure functions only; network calls stay in adapter.mjs and copilot.mjs.
@@ -238,7 +237,19 @@ export async function streamAnthropicFromLines(lineIterator, emit, model, option
238
237
  await emit("message_stop", { type: "message_stop" });
239
238
  }
240
239
 
241
- // Copilot has no count_tokens endpoint; estimate locally with gpt-tokenizer.
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.
242
253
  export function countTokens(body) {
243
254
  const parts = [];
244
255
  const sys = systemToText(body.system);
@@ -263,5 +274,5 @@ export function countTokens(body) {
263
274
  }
264
275
 
265
276
  const text = parts.join("\n");
266
- return { input_tokens: encode(text).length };
277
+ return { input_tokens: estimateTokens(text) };
267
278
  }
package/src/copilot.mjs CHANGED
@@ -2,6 +2,7 @@ import fs from "node:fs";
2
2
  import os from "node:os";
3
3
  import { randomUUID } from "node:crypto";
4
4
  import { status } from "./status.mjs";
5
+ import { debugLog } from "./log.mjs";
5
6
  import { githubReauthMessage, githubTokenPath, importDiscoveredGithubToken } from "./auth.mjs";
6
7
 
7
8
  export const DEFAULT_API_BASE = "https://api.githubcopilot.com";
@@ -11,15 +12,33 @@ const IMG_QUALITY = parseInt(process.env.CCDX_IMG_QUALITY || "85", 10);
11
12
  const IMG_MIN_BYTES = parseInt(process.env.CCDX_IMG_MIN_BYTES || "100000", 10);
12
13
  const DEFAULT_IMG_CONCURRENCY = 4;
13
14
  const IMG_MAX_CONCURRENCY = 12;
15
+ const DEFAULT_UPSTREAM_RETRIES = 2;
16
+ const MAX_UPSTREAM_RETRIES = 5;
17
+ const DEFAULT_UPSTREAM_RETRY_DELAY_MS = 300;
18
+ const MAX_UPSTREAM_RETRY_DELAY_MS = 5000;
14
19
  const IMG_OPT_DISABLED = process.env.CCDX_DISABLE_IMG_OPT === "1";
15
20
  const IMG_CONCURRENCY = parseImageConcurrency(process.env.CCDX_IMG_CONCURRENCY);
21
+ const UPSTREAM_RETRIES = parseUpstreamRetries(process.env.CCDX_UPSTREAM_RETRIES);
22
+ const UPSTREAM_RETRY_DELAY_MS = parseUpstreamRetryDelayMs(process.env.CCDX_UPSTREAM_RETRY_DELAY_MS);
16
23
  let sharpImport = null;
24
+ let sharpUnavailable = false;
25
+ let sharpUnavailableLogged = false;
17
26
 
18
27
  export function parseImageConcurrency(value) {
19
28
  const n = Number.parseInt(value, 10);
20
29
  return Number.isFinite(n) && n > 0 ? Math.min(n, IMG_MAX_CONCURRENCY) : DEFAULT_IMG_CONCURRENCY;
21
30
  }
22
31
 
32
+ export function parseUpstreamRetries(value) {
33
+ const n = Number.parseInt(value, 10);
34
+ return Number.isFinite(n) && n >= 0 ? Math.min(n, MAX_UPSTREAM_RETRIES) : DEFAULT_UPSTREAM_RETRIES;
35
+ }
36
+
37
+ export function parseUpstreamRetryDelayMs(value) {
38
+ const n = Number.parseInt(value, 10);
39
+ return Number.isFinite(n) && n > 0 ? Math.min(n, MAX_UPSTREAM_RETRY_DELAY_MS) : DEFAULT_UPSTREAM_RETRY_DELAY_MS;
40
+ }
41
+
23
42
  export function parseApiBase(data) {
24
43
  return (data && data.endpoints && typeof data.endpoints.api === "string" && data.endpoints.api)
25
44
  ? data.endpoints.api
@@ -34,11 +53,138 @@ export function responsesEndpointPath() {
34
53
  return "/responses";
35
54
  }
36
55
  const GITHUB_API = "https://api.github.com";
56
+ const RETRYABLE_UPSTREAM_ERROR_CODES = new Set([
57
+ "UND_ERR_CONNECT_TIMEOUT",
58
+ "UND_ERR_HEADERS_TIMEOUT",
59
+ "UND_ERR_BODY_TIMEOUT",
60
+ "ETIMEDOUT",
61
+ "ECONNRESET",
62
+ "ECONNREFUSED",
63
+ "EPIPE",
64
+ "EAI_AGAIN",
65
+ "ENOTFOUND",
66
+ ]);
67
+ const RETRYABLE_UPSTREAM_STATUSES = new Set([408, 502, 503, 504]);
68
+
69
+ function upstreamTarget(url) {
70
+ try {
71
+ const parsed = new URL(url);
72
+ return `${parsed.hostname}${parsed.pathname}`;
73
+ } catch {
74
+ return String(url);
75
+ }
76
+ }
77
+
78
+ function upstreamErrorCode(err) {
79
+ return err?.cause?.code || err?.code || "";
80
+ }
81
+
82
+ function isAbortError(err, signal) {
83
+ return signal?.aborted || err?.name === "AbortError" || err?.code === "ABORT_ERR";
84
+ }
85
+
86
+ function abortError(signal) {
87
+ if (signal?.reason instanceof Error) return signal.reason;
88
+ const err = new Error("The operation was aborted");
89
+ err.name = "AbortError";
90
+ return err;
91
+ }
92
+
93
+ function sleep(ms, { signal } = {}) {
94
+ if (signal?.aborted) return Promise.reject(abortError(signal));
95
+ return new Promise((resolve, reject) => {
96
+ let timer;
97
+ const cleanup = () => {
98
+ clearTimeout(timer);
99
+ signal?.removeEventListener("abort", onAbort);
100
+ };
101
+ const onAbort = () => {
102
+ cleanup();
103
+ reject(abortError(signal));
104
+ };
105
+ timer = setTimeout(() => {
106
+ cleanup();
107
+ resolve();
108
+ }, ms);
109
+ if (!signal) return;
110
+ signal.addEventListener("abort", onAbort, { once: true });
111
+ });
112
+ }
113
+
114
+ function requestMethod(init = {}) {
115
+ return String(init.method || "GET").toUpperCase();
116
+ }
117
+
118
+ export function isRetryableUpstreamError(err, { signal } = {}) {
119
+ if (isAbortError(err, signal)) return false;
120
+ return RETRYABLE_UPSTREAM_ERROR_CODES.has(upstreamErrorCode(err));
121
+ }
122
+
123
+ export function isRetryableUpstreamStatus(status, method = "GET") {
124
+ const safeMethod = ["GET", "HEAD", "OPTIONS"].includes(String(method || "GET").toUpperCase());
125
+ return safeMethod && RETRYABLE_UPSTREAM_STATUSES.has(status);
126
+ }
127
+
128
+ function upstreamRetryDelay(attempt, baseDelayMs) {
129
+ return Math.min(baseDelayMs * (2 ** attempt), MAX_UPSTREAM_RETRY_DELAY_MS);
130
+ }
131
+
132
+ function describeUpstreamError(err) {
133
+ const code = upstreamErrorCode(err);
134
+ return [code, err?.cause?.message || err?.message].filter(Boolean).join(": ") || "network error";
135
+ }
136
+
137
+ export async function fetchCopilotUpstream(
138
+ url,
139
+ init = {},
140
+ {
141
+ fetchImpl = fetch,
142
+ retries = UPSTREAM_RETRIES,
143
+ retryDelayMs = UPSTREAM_RETRY_DELAY_MS,
144
+ } = {},
145
+ ) {
146
+ const retryCount = parseUpstreamRetries(retries);
147
+ const baseDelay = parseUpstreamRetryDelayMs(retryDelayMs);
148
+ const method = requestMethod(init);
149
+ const signal = init.signal;
150
+ const target = upstreamTarget(url);
151
+ const totalStart = Date.now();
152
+
153
+ for (let attempt = 0; ; attempt += 1) {
154
+ const attemptStart = Date.now();
155
+ debugLog(`upstream ${method} ${target} attempt=${attempt + 1}/${retryCount + 1}`);
156
+ try {
157
+ const resp = await fetchImpl(url, init);
158
+ debugLog(`upstream ${method} ${target} status=${resp.status} attempt=${attempt + 1}/${retryCount + 1} attempt_ms=${Date.now() - attemptStart} total_ms=${Date.now() - totalStart}`);
159
+ if (attempt < retryCount && isRetryableUpstreamStatus(resp.status, method)) {
160
+ await resp.arrayBuffer?.().catch(() => {});
161
+ const delay = upstreamRetryDelay(attempt, baseDelay);
162
+ console.warn(status("warn", `upstream ${target} returned ${resp.status}; retry ${attempt + 1}/${retryCount} in ${delay}ms`));
163
+ await sleep(delay, { signal });
164
+ continue;
165
+ }
166
+ return resp;
167
+ } catch (e) {
168
+ debugLog(`upstream ${method} ${target} error=${describeUpstreamError(e)} attempt=${attempt + 1}/${retryCount + 1} attempt_ms=${Date.now() - attemptStart} total_ms=${Date.now() - totalStart}`);
169
+ if (attempt >= retryCount || !isRetryableUpstreamError(e, { signal })) throw e;
170
+ const delay = upstreamRetryDelay(attempt, baseDelay);
171
+ console.warn(status("warn", `upstream ${target} ${describeUpstreamError(e)}; retry ${attempt + 1}/${retryCount} in ${delay}ms`));
172
+ await sleep(delay, { signal });
173
+ }
174
+ }
175
+ }
37
176
 
38
177
  async function sharp() {
39
- sharpImport ||= import("sharp");
40
- const mod = await sharpImport;
41
- return mod.default || mod;
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
+ }
42
188
  }
43
189
 
44
190
  export async function optimizeImageDataUrl(dataUrl) {
@@ -52,6 +198,13 @@ export async function optimizeImageDataUrl(dataUrl) {
52
198
 
53
199
  try {
54
200
  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
+ }
55
208
  const out = await resize(raw, { failOn: "none" })
56
209
  .rotate()
57
210
  .resize(IMG_MAX_DIM, IMG_MAX_DIM, { fit: "inside", withoutEnlargement: true })
@@ -326,7 +479,7 @@ export function resetCopilotTokenForTests() {
326
479
  }
327
480
 
328
481
  // chatReq is already converted by the adapter. The caller parses the raw Response.
329
- export async function chatCompletions(chatReq, { signal } = {}) {
482
+ export async function chatCompletions(chatReq, { signal, fetchImpl, retryOptions } = {}) {
330
483
  const token = await getCopilotToken({ signal });
331
484
  const messages = chatReq.messages || [];
332
485
  const headers = buildHeaders({
@@ -335,25 +488,25 @@ export async function chatCompletions(chatReq, { signal } = {}) {
335
488
  initiator: computeInitiator(messages),
336
489
  vision: computeVision(messages),
337
490
  });
338
- return fetch(`${getApiBase()}/chat/completions`, {
491
+ return fetchCopilotUpstream(`${getApiBase()}/chat/completions`, {
339
492
  method: "POST",
340
493
  headers,
341
494
  body: JSON.stringify(chatReq),
342
495
  signal,
343
- });
496
+ }, { fetchImpl, ...retryOptions });
344
497
  }
345
498
 
346
- export async function listModels({ signal } = {}) {
499
+ export async function listModels({ signal, fetchImpl, retryOptions } = {}) {
347
500
  const token = await getCopilotToken({ signal });
348
501
  const headers = buildHeaders({
349
502
  token, version: getVSCodeVersion(), initiator: "user", vision: false,
350
503
  });
351
- const resp = await fetch(`${getApiBase()}/models`, { headers, signal });
504
+ const resp = await fetchCopilotUpstream(`${getApiBase()}/models`, { headers, signal }, { fetchImpl, ...retryOptions });
352
505
  return { status: resp.status, body: await resp.text() };
353
506
  }
354
507
 
355
508
  // Responses-only models go directly to Copilot's /responses endpoint.
356
- export async function responses(reqBody, { signal } = {}) {
509
+ export async function responses(reqBody, { signal, fetchImpl, retryOptions } = {}) {
357
510
  const token = await getCopilotToken({ signal });
358
511
  const beforeBytes = Buffer.byteLength(JSON.stringify(reqBody));
359
512
  await optimizeImagesInBody(reqBody);
@@ -371,12 +524,12 @@ export async function responses(reqBody, { signal } = {}) {
371
524
  headers["Accept"] = reqBody.stream ? "text/event-stream" : "application/json";
372
525
 
373
526
  try {
374
- return await fetch(`${getApiBase()}${responsesEndpointPath()}`, {
527
+ return await fetchCopilotUpstream(`${getApiBase()}${responsesEndpointPath()}`, {
375
528
  method: "POST",
376
529
  headers,
377
530
  body: bodyBuf,
378
531
  signal,
379
- });
532
+ }, { fetchImpl, ...retryOptions });
380
533
  } catch (e) {
381
534
  const cause = e?.cause;
382
535
  const causeText = cause ? ` (${[cause.code, cause.message].filter(Boolean).join(": ")})` : "";
package/src/log.mjs ADDED
@@ -0,0 +1,85 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { format } from "node:util";
5
+ import { status } from "./status.mjs";
6
+
7
+ let globalInstall = null;
8
+
9
+ export function normalizeLogLevel(value) {
10
+ return String(value || "info").trim().toLowerCase() === "debug" ? "debug" : "info";
11
+ }
12
+
13
+ export function defaultLogPath({ home = os.homedir() } = {}) {
14
+ return path.join(home, ".local", "share", "codex-copilot-dx", "debug.log");
15
+ }
16
+
17
+ export function resolveLogPath(value, { home = os.homedir() } = {}) {
18
+ const raw = String(value || "").trim();
19
+ if (!raw) return null;
20
+ if (["1", "true", "yes"].includes(raw.toLowerCase())) return defaultLogPath({ home });
21
+ if (raw === "~") return home;
22
+ if (raw.startsWith("~/")) return path.join(home, raw.slice(2));
23
+ return raw;
24
+ }
25
+
26
+ export function isDebugLoggingEnabled(env = process.env) {
27
+ return normalizeLogLevel(env.CCDX_LOG_LEVEL) === "debug";
28
+ }
29
+
30
+ function renderLogPayload(args) {
31
+ const stamp = new Date().toISOString();
32
+ const text = format(...args);
33
+ return `${text.split(/\r?\n/).map((line) => `${stamp} ${line}`).join("\n")}\n`;
34
+ }
35
+
36
+ export function configureLogging({
37
+ env = process.env,
38
+ consoleObj = console,
39
+ home = os.homedir(),
40
+ } = {}) {
41
+ const level = normalizeLogLevel(env.CCDX_LOG_LEVEL);
42
+ const filePath = resolveLogPath(env.CCDX_LOG_PATH, { home });
43
+ if (!filePath) return { level, filePath: null, cleanup: () => {} };
44
+ if (consoleObj === console && globalInstall) {
45
+ return { level, filePath: globalInstall.filePath, cleanup: globalInstall.cleanup };
46
+ }
47
+
48
+ fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
49
+
50
+ const methods = ["log", "warn", "error", "debug"];
51
+ const originals = Object.fromEntries(methods.map((method) => [
52
+ method,
53
+ typeof consoleObj[method] === "function" ? consoleObj[method].bind(consoleObj) : () => {},
54
+ ]));
55
+ let writeFailed = false;
56
+
57
+ function append(args) {
58
+ try {
59
+ fs.appendFileSync(filePath, renderLogPayload(args), { encoding: "utf8", mode: 0o600 });
60
+ } catch (e) {
61
+ if (writeFailed) return;
62
+ writeFailed = true;
63
+ originals.error(`codex-copilot-dx log write failed: ${e.message}`);
64
+ }
65
+ }
66
+
67
+ for (const method of methods) {
68
+ consoleObj[method] = (...args) => {
69
+ originals[method](...args);
70
+ append(args);
71
+ };
72
+ }
73
+
74
+ const cleanup = () => {
75
+ for (const method of methods) consoleObj[method] = originals[method];
76
+ if (consoleObj === console && globalInstall?.cleanup === cleanup) globalInstall = null;
77
+ };
78
+
79
+ if (consoleObj === console) globalInstall = { filePath, cleanup };
80
+ return { level, filePath, cleanup };
81
+ }
82
+
83
+ export function debugLog(message, env = process.env) {
84
+ if (isDebugLoggingEnabled(env)) console.log(status("debug", message));
85
+ }
package/src/status.mjs CHANGED
@@ -4,6 +4,7 @@ const LABELS = {
4
4
  wait: "[WAIT]",
5
5
  warn: "[WARN]",
6
6
  err: "[ERR]",
7
+ debug: "[DEBUG]",
7
8
  };
8
9
 
9
10
  export function status(kind, message) {