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 +8 -0
- package/bin/cli.mjs +7 -0
- package/package.json +1 -1
- package/src/copilot.mjs +145 -8
- package/src/log.mjs +85 -0
- package/src/status.mjs +1 -0
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
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
|
|
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
|
|
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
|
|
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
|
+
}
|