codex-copilot-dx 0.4.18 → 0.4.19

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,6 +89,10 @@ 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_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 |
92
96
  | `CCDX_IMG_MAX_DIM` | `2048` | Max long edge in pixels for image downscaling |
93
97
  | `CCDX_IMG_QUALITY` | `85` | WebP quality used when re-encoding images |
94
98
  | `CCDX_IMG_MIN_BYTES` | `100000` | Images smaller than this are left untouched |
@@ -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
  ```
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.19",
4
4
  "description": "Use Codex Desktop, Claude Code, and Claude App with GitHub Copilot",
5
5
  "type": "module",
6
6
  "bin": {
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,8 +12,14 @@ 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;
17
24
 
18
25
  export function parseImageConcurrency(value) {
@@ -20,6 +27,16 @@ export function parseImageConcurrency(value) {
20
27
  return Number.isFinite(n) && n > 0 ? Math.min(n, IMG_MAX_CONCURRENCY) : DEFAULT_IMG_CONCURRENCY;
21
28
  }
22
29
 
30
+ export function parseUpstreamRetries(value) {
31
+ const n = Number.parseInt(value, 10);
32
+ return Number.isFinite(n) && n >= 0 ? Math.min(n, MAX_UPSTREAM_RETRIES) : DEFAULT_UPSTREAM_RETRIES;
33
+ }
34
+
35
+ export function parseUpstreamRetryDelayMs(value) {
36
+ const n = Number.parseInt(value, 10);
37
+ return Number.isFinite(n) && n > 0 ? Math.min(n, MAX_UPSTREAM_RETRY_DELAY_MS) : DEFAULT_UPSTREAM_RETRY_DELAY_MS;
38
+ }
39
+
23
40
  export function parseApiBase(data) {
24
41
  return (data && data.endpoints && typeof data.endpoints.api === "string" && data.endpoints.api)
25
42
  ? data.endpoints.api
@@ -34,6 +51,126 @@ export function responsesEndpointPath() {
34
51
  return "/responses";
35
52
  }
36
53
  const GITHUB_API = "https://api.github.com";
54
+ const RETRYABLE_UPSTREAM_ERROR_CODES = new Set([
55
+ "UND_ERR_CONNECT_TIMEOUT",
56
+ "UND_ERR_HEADERS_TIMEOUT",
57
+ "UND_ERR_BODY_TIMEOUT",
58
+ "ETIMEDOUT",
59
+ "ECONNRESET",
60
+ "ECONNREFUSED",
61
+ "EPIPE",
62
+ "EAI_AGAIN",
63
+ "ENOTFOUND",
64
+ ]);
65
+ const RETRYABLE_UPSTREAM_STATUSES = new Set([408, 502, 503, 504]);
66
+
67
+ function upstreamTarget(url) {
68
+ try {
69
+ const parsed = new URL(url);
70
+ return `${parsed.hostname}${parsed.pathname}`;
71
+ } catch {
72
+ return String(url);
73
+ }
74
+ }
75
+
76
+ function upstreamErrorCode(err) {
77
+ return err?.cause?.code || err?.code || "";
78
+ }
79
+
80
+ function isAbortError(err, signal) {
81
+ return signal?.aborted || err?.name === "AbortError" || err?.code === "ABORT_ERR";
82
+ }
83
+
84
+ function abortError(signal) {
85
+ if (signal?.reason instanceof Error) return signal.reason;
86
+ const err = new Error("The operation was aborted");
87
+ err.name = "AbortError";
88
+ return err;
89
+ }
90
+
91
+ function sleep(ms, { signal } = {}) {
92
+ if (signal?.aborted) return Promise.reject(abortError(signal));
93
+ return new Promise((resolve, reject) => {
94
+ let timer;
95
+ const cleanup = () => {
96
+ clearTimeout(timer);
97
+ signal?.removeEventListener("abort", onAbort);
98
+ };
99
+ const onAbort = () => {
100
+ cleanup();
101
+ reject(abortError(signal));
102
+ };
103
+ timer = setTimeout(() => {
104
+ cleanup();
105
+ resolve();
106
+ }, ms);
107
+ if (!signal) return;
108
+ signal.addEventListener("abort", onAbort, { once: true });
109
+ });
110
+ }
111
+
112
+ function requestMethod(init = {}) {
113
+ return String(init.method || "GET").toUpperCase();
114
+ }
115
+
116
+ export function isRetryableUpstreamError(err, { signal } = {}) {
117
+ if (isAbortError(err, signal)) return false;
118
+ return RETRYABLE_UPSTREAM_ERROR_CODES.has(upstreamErrorCode(err));
119
+ }
120
+
121
+ export function isRetryableUpstreamStatus(status, method = "GET") {
122
+ const safeMethod = ["GET", "HEAD", "OPTIONS"].includes(String(method || "GET").toUpperCase());
123
+ return safeMethod && RETRYABLE_UPSTREAM_STATUSES.has(status);
124
+ }
125
+
126
+ function upstreamRetryDelay(attempt, baseDelayMs) {
127
+ return Math.min(baseDelayMs * (2 ** attempt), MAX_UPSTREAM_RETRY_DELAY_MS);
128
+ }
129
+
130
+ function describeUpstreamError(err) {
131
+ const code = upstreamErrorCode(err);
132
+ return [code, err?.cause?.message || err?.message].filter(Boolean).join(": ") || "network error";
133
+ }
134
+
135
+ export async function fetchCopilotUpstream(
136
+ url,
137
+ init = {},
138
+ {
139
+ fetchImpl = fetch,
140
+ retries = UPSTREAM_RETRIES,
141
+ retryDelayMs = UPSTREAM_RETRY_DELAY_MS,
142
+ } = {},
143
+ ) {
144
+ const retryCount = parseUpstreamRetries(retries);
145
+ const baseDelay = parseUpstreamRetryDelayMs(retryDelayMs);
146
+ const method = requestMethod(init);
147
+ const signal = init.signal;
148
+ const target = upstreamTarget(url);
149
+ const totalStart = Date.now();
150
+
151
+ for (let attempt = 0; ; attempt += 1) {
152
+ const attemptStart = Date.now();
153
+ debugLog(`upstream ${method} ${target} attempt=${attempt + 1}/${retryCount + 1}`);
154
+ try {
155
+ const resp = await fetchImpl(url, init);
156
+ debugLog(`upstream ${method} ${target} status=${resp.status} attempt=${attempt + 1}/${retryCount + 1} attempt_ms=${Date.now() - attemptStart} total_ms=${Date.now() - totalStart}`);
157
+ if (attempt < retryCount && isRetryableUpstreamStatus(resp.status, method)) {
158
+ await resp.arrayBuffer?.().catch(() => {});
159
+ const delay = upstreamRetryDelay(attempt, baseDelay);
160
+ console.warn(status("warn", `upstream ${target} returned ${resp.status}; retry ${attempt + 1}/${retryCount} in ${delay}ms`));
161
+ await sleep(delay, { signal });
162
+ continue;
163
+ }
164
+ return resp;
165
+ } catch (e) {
166
+ debugLog(`upstream ${method} ${target} error=${describeUpstreamError(e)} attempt=${attempt + 1}/${retryCount + 1} attempt_ms=${Date.now() - attemptStart} total_ms=${Date.now() - totalStart}`);
167
+ if (attempt >= retryCount || !isRetryableUpstreamError(e, { signal })) throw e;
168
+ const delay = upstreamRetryDelay(attempt, baseDelay);
169
+ console.warn(status("warn", `upstream ${target} ${describeUpstreamError(e)}; retry ${attempt + 1}/${retryCount} in ${delay}ms`));
170
+ await sleep(delay, { signal });
171
+ }
172
+ }
173
+ }
37
174
 
38
175
  async function sharp() {
39
176
  sharpImport ||= import("sharp");
@@ -326,7 +463,7 @@ export function resetCopilotTokenForTests() {
326
463
  }
327
464
 
328
465
  // chatReq is already converted by the adapter. The caller parses the raw Response.
329
- export async function chatCompletions(chatReq, { signal } = {}) {
466
+ export async function chatCompletions(chatReq, { signal, fetchImpl, retryOptions } = {}) {
330
467
  const token = await getCopilotToken({ signal });
331
468
  const messages = chatReq.messages || [];
332
469
  const headers = buildHeaders({
@@ -335,25 +472,25 @@ export async function chatCompletions(chatReq, { signal } = {}) {
335
472
  initiator: computeInitiator(messages),
336
473
  vision: computeVision(messages),
337
474
  });
338
- return fetch(`${getApiBase()}/chat/completions`, {
475
+ return fetchCopilotUpstream(`${getApiBase()}/chat/completions`, {
339
476
  method: "POST",
340
477
  headers,
341
478
  body: JSON.stringify(chatReq),
342
479
  signal,
343
- });
480
+ }, { fetchImpl, ...retryOptions });
344
481
  }
345
482
 
346
- export async function listModels({ signal } = {}) {
483
+ export async function listModels({ signal, fetchImpl, retryOptions } = {}) {
347
484
  const token = await getCopilotToken({ signal });
348
485
  const headers = buildHeaders({
349
486
  token, version: getVSCodeVersion(), initiator: "user", vision: false,
350
487
  });
351
- const resp = await fetch(`${getApiBase()}/models`, { headers, signal });
488
+ const resp = await fetchCopilotUpstream(`${getApiBase()}/models`, { headers, signal }, { fetchImpl, ...retryOptions });
352
489
  return { status: resp.status, body: await resp.text() };
353
490
  }
354
491
 
355
492
  // Responses-only models go directly to Copilot's /responses endpoint.
356
- export async function responses(reqBody, { signal } = {}) {
493
+ export async function responses(reqBody, { signal, fetchImpl, retryOptions } = {}) {
357
494
  const token = await getCopilotToken({ signal });
358
495
  const beforeBytes = Buffer.byteLength(JSON.stringify(reqBody));
359
496
  await optimizeImagesInBody(reqBody);
@@ -371,12 +508,12 @@ export async function responses(reqBody, { signal } = {}) {
371
508
  headers["Accept"] = reqBody.stream ? "text/event-stream" : "application/json";
372
509
 
373
510
  try {
374
- return await fetch(`${getApiBase()}${responsesEndpointPath()}`, {
511
+ return await fetchCopilotUpstream(`${getApiBase()}${responsesEndpointPath()}`, {
375
512
  method: "POST",
376
513
  headers,
377
514
  body: bodyBuf,
378
515
  signal,
379
- });
516
+ }, { fetchImpl, ...retryOptions });
380
517
  } catch (e) {
381
518
  const cause = e?.cause;
382
519
  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) {