codex-copilot-dx 0.4.34 → 0.4.36
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 +14 -3
- package/bin/cli.mjs +11 -7
- package/package.json +3 -2
- package/scripts/benchmark.mjs +87 -11
- package/src/adapter.mjs +115 -837
- package/src/anthropic-handler.mjs +128 -0
- package/src/anthropic.mjs +5 -5
- package/src/auth.mjs +12 -35
- package/src/claude-config.mjs +7 -6
- package/src/config.mjs +11 -10
- package/src/copilot.mjs +38 -29
- package/src/doctor.mjs +18 -8
- package/src/github-identity.mjs +30 -0
- package/src/http-transport.mjs +154 -9
- package/src/image-optimization.mjs +24 -6
- package/src/models.mjs +13 -0
- package/src/observability.mjs +147 -0
- package/src/request-context.mjs +16 -0
- package/src/response-history.mjs +8 -11
- package/src/responses-bridge.mjs +282 -0
- package/src/responses-handler.mjs +176 -0
- package/src/responses-proxy.mjs +122 -0
- package/src/responses-request.mjs +202 -0
- package/src/runtime-config.mjs +38 -0
- package/src/status.mjs +5 -1
- package/src/stream-errors.mjs +17 -0
- package/src/stream.mjs +2 -7
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import {
|
|
2
|
+
anthropicToChat,
|
|
3
|
+
chatToAnthropic,
|
|
4
|
+
countTokens,
|
|
5
|
+
streamAnthropicFromLines,
|
|
6
|
+
} from "./anthropic.mjs";
|
|
7
|
+
import {
|
|
8
|
+
createRequestAbort,
|
|
9
|
+
logRequestFailure,
|
|
10
|
+
readJsonBody,
|
|
11
|
+
sendJsonError,
|
|
12
|
+
sendUpstreamError,
|
|
13
|
+
writeOrDrain,
|
|
14
|
+
} from "./http-transport.mjs";
|
|
15
|
+
import { resolveAnthropicModel } from "./models.mjs";
|
|
16
|
+
import { status } from "./status.mjs";
|
|
17
|
+
import { webStreamLines } from "./stream.mjs";
|
|
18
|
+
import { endStreamWithError } from "./stream-errors.mjs";
|
|
19
|
+
import { recordAnthropicUsage } from "./usage.mjs";
|
|
20
|
+
|
|
21
|
+
export function createAnthropicCountTokensHandler(options) {
|
|
22
|
+
const { acquireRequest } = options;
|
|
23
|
+
|
|
24
|
+
return async function handleAnthropicCountTokens(req, res) {
|
|
25
|
+
const abort = createRequestAbort(req, res);
|
|
26
|
+
let releaseRequest = () => {};
|
|
27
|
+
try {
|
|
28
|
+
releaseRequest = await acquireRequest(req, { signal: abort.signal });
|
|
29
|
+
const parsed = await readJsonBody(req);
|
|
30
|
+
const result = await countTokens(parsed);
|
|
31
|
+
releaseRequest();
|
|
32
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
33
|
+
res.end(JSON.stringify(result));
|
|
34
|
+
} catch (error) {
|
|
35
|
+
sendJsonError(res, error);
|
|
36
|
+
} finally {
|
|
37
|
+
releaseRequest();
|
|
38
|
+
abort.cleanup();
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function createAnthropicMessagesHandler(options) {
|
|
44
|
+
const {
|
|
45
|
+
acquireRequest,
|
|
46
|
+
chatCompletionsFn,
|
|
47
|
+
environment,
|
|
48
|
+
modelOptions,
|
|
49
|
+
streamHandshakeTimeoutMs,
|
|
50
|
+
streamIdleTimeoutMs,
|
|
51
|
+
upstreamTimeoutMs,
|
|
52
|
+
} = options;
|
|
53
|
+
|
|
54
|
+
return async function handleAnthropicMessages(req, res) {
|
|
55
|
+
const abort = createRequestAbort(req, res);
|
|
56
|
+
let releaseRequest = () => {};
|
|
57
|
+
try {
|
|
58
|
+
releaseRequest = await acquireRequest(req, { signal: abort.signal });
|
|
59
|
+
const parsed = await readJsonBody(req);
|
|
60
|
+
abort.setTimeout(
|
|
61
|
+
parsed.stream ? streamHandshakeTimeoutMs : upstreamTimeoutMs,
|
|
62
|
+
parsed.stream ? "stream_handshake_timeout" : "upstream_timeout",
|
|
63
|
+
);
|
|
64
|
+
const { requestedModel, upstreamModel } = resolveAnthropicModel(parsed.model || "unknown", environment, modelOptions());
|
|
65
|
+
const modelNote = upstreamModel === requestedModel ? requestedModel : `${requestedModel} -> ${upstreamModel}`;
|
|
66
|
+
console.log(status("info", `messages model=${modelNote} stream=${!!parsed.stream}`));
|
|
67
|
+
const chatReq = anthropicToChat(parsed, { upstreamModel });
|
|
68
|
+
const forceRequestedModel = upstreamModel !== requestedModel;
|
|
69
|
+
if (parsed.stream) {
|
|
70
|
+
const upstream = await chatCompletionsFn({ ...chatReq, stream: true }, { signal: abort.signal });
|
|
71
|
+
releaseRequest();
|
|
72
|
+
if (!upstream.ok) {
|
|
73
|
+
if (!res.headersSent) res.writeHead(upstream.status);
|
|
74
|
+
res.end(await upstream.text());
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
abort.setTimeout(streamIdleTimeoutMs, "stream_idle_timeout");
|
|
78
|
+
res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive" });
|
|
79
|
+
let messageId;
|
|
80
|
+
let usage;
|
|
81
|
+
try {
|
|
82
|
+
await streamAnthropicFromLines(
|
|
83
|
+
webStreamLines(upstream, {
|
|
84
|
+
onChunk: () => abort.setTimeout(streamIdleTimeoutMs, "stream_idle_timeout"),
|
|
85
|
+
}),
|
|
86
|
+
async (event, data) => {
|
|
87
|
+
if (event === "message_start") messageId = data.message?.id;
|
|
88
|
+
if (event === "message_delta") usage = data.usage;
|
|
89
|
+
await writeOrDrain(res, `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
|
90
|
+
},
|
|
91
|
+
requestedModel,
|
|
92
|
+
{ forceModel: forceRequestedModel },
|
|
93
|
+
);
|
|
94
|
+
} catch (error) {
|
|
95
|
+
logRequestFailure("Messages", error, abort);
|
|
96
|
+
await endStreamWithError(res, "anthropic", error, abort);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
recordAnthropicUsage({ surface: "messages", mode: "stream", model: requestedModel, responseId: messageId, usage });
|
|
100
|
+
if (!res.writableEnded) res.end();
|
|
101
|
+
} else {
|
|
102
|
+
const upstream = await chatCompletionsFn({ ...chatReq, stream: false }, { signal: abort.signal });
|
|
103
|
+
releaseRequest();
|
|
104
|
+
const data = await upstream.text();
|
|
105
|
+
if (!upstream.ok) {
|
|
106
|
+
sendUpstreamError(res, upstream, data);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
const anthropicMessage = chatToAnthropic(JSON.parse(data), requestedModel, { forceModel: forceRequestedModel });
|
|
110
|
+
recordAnthropicUsage({
|
|
111
|
+
surface: "messages",
|
|
112
|
+
mode: "json",
|
|
113
|
+
model: anthropicMessage.model,
|
|
114
|
+
responseId: anthropicMessage.id,
|
|
115
|
+
usage: anthropicMessage.usage,
|
|
116
|
+
});
|
|
117
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
118
|
+
res.end(JSON.stringify(anthropicMessage));
|
|
119
|
+
}
|
|
120
|
+
} catch (error) {
|
|
121
|
+
logRequestFailure("Messages", error, abort);
|
|
122
|
+
sendJsonError(res, error, 502);
|
|
123
|
+
} finally {
|
|
124
|
+
releaseRequest();
|
|
125
|
+
abort.cleanup();
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
}
|
package/src/anthropic.mjs
CHANGED
|
@@ -2,13 +2,13 @@ import { randomUUID } from "node:crypto";
|
|
|
2
2
|
|
|
3
3
|
let tokenizerModulePromise = null;
|
|
4
4
|
|
|
5
|
-
async function
|
|
5
|
+
async function tokenCounter() {
|
|
6
6
|
tokenizerModulePromise ||= import("gpt-tokenizer");
|
|
7
|
-
return (await tokenizerModulePromise).
|
|
7
|
+
return (await tokenizerModulePromise).countTokens;
|
|
8
8
|
}
|
|
9
9
|
|
|
10
10
|
// Anthropic Messages API to OpenAI chat/completions translation.
|
|
11
|
-
// Pure functions only; network calls stay in
|
|
11
|
+
// Pure functions only; network calls stay in the protocol handler and copilot.mjs.
|
|
12
12
|
|
|
13
13
|
export function mapStopReason(finishReason) {
|
|
14
14
|
switch (finishReason) {
|
|
@@ -282,6 +282,6 @@ export async function countTokens(body) {
|
|
|
282
282
|
}
|
|
283
283
|
|
|
284
284
|
const text = parts.join("\n");
|
|
285
|
-
const
|
|
286
|
-
return { input_tokens:
|
|
285
|
+
const count = await tokenCounter();
|
|
286
|
+
return { input_tokens: count(text) };
|
|
287
287
|
}
|
package/src/auth.mjs
CHANGED
|
@@ -2,7 +2,12 @@ import fs from "node:fs";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import os from "node:os";
|
|
4
4
|
import { spawn } from "node:child_process";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
githubIdentityMatchesExpected,
|
|
7
|
+
githubTokenFingerprint,
|
|
8
|
+
normalizeGithubIdentity,
|
|
9
|
+
} from "./github-identity.mjs";
|
|
10
|
+
import { parsePositiveInteger, RUNTIME_DEFAULTS } from "./runtime-config.mjs";
|
|
6
11
|
import { status } from "./status.mjs";
|
|
7
12
|
import { withFileLock } from "./lock.mjs";
|
|
8
13
|
|
|
@@ -12,8 +17,6 @@ const GITHUB_API = "https://api.github.com";
|
|
|
12
17
|
const COPILOT_TOKEN_URL = `${GITHUB_API}/copilot_internal/v2/token`;
|
|
13
18
|
const DISABLE_TOKEN_DISCOVERY_VALUES = new Set(["1", "true", "yes"]);
|
|
14
19
|
const MAX_AUTH_JSON_BYTES = 1024 * 1024;
|
|
15
|
-
const DEFAULT_TOKEN_LOCK_TIMEOUT_MS = 10 * 60 * 1000;
|
|
16
|
-
const DEFAULT_TOKEN_LOCK_STALE_MS = 15 * 60 * 1000;
|
|
17
20
|
|
|
18
21
|
export function githubTokenPath(home = os.homedir()) {
|
|
19
22
|
return path.join(home, ".local", "share", "copilot-api", "github_token");
|
|
@@ -58,15 +61,10 @@ function isTokenDiscoveryDisabled(env = process.env) {
|
|
|
58
61
|
return DISABLE_TOKEN_DISCOVERY_VALUES.has(String(env.CCDX_DISABLE_TOKEN_DISCOVERY || "").toLowerCase());
|
|
59
62
|
}
|
|
60
63
|
|
|
61
|
-
function positiveInt(value, fallback) {
|
|
62
|
-
const n = Number.parseInt(value, 10);
|
|
63
|
-
return Number.isFinite(n) && n > 0 ? n : fallback;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
64
|
function tokenLockOptions(env = process.env) {
|
|
67
65
|
return {
|
|
68
|
-
timeoutMs:
|
|
69
|
-
staleMs:
|
|
66
|
+
timeoutMs: parsePositiveInteger(env.CCDX_TOKEN_LOCK_TIMEOUT_MS, RUNTIME_DEFAULTS.tokenLockTimeoutMs),
|
|
67
|
+
staleMs: parsePositiveInteger(env.CCDX_TOKEN_LOCK_STALE_MS, RUNTIME_DEFAULTS.tokenLockStaleMs),
|
|
70
68
|
};
|
|
71
69
|
}
|
|
72
70
|
|
|
@@ -215,22 +213,10 @@ function readSavedGithubToken(home = os.homedir()) {
|
|
|
215
213
|
return fs.readFileSync(filePath, "utf8").trim();
|
|
216
214
|
}
|
|
217
215
|
|
|
218
|
-
function normalizeGithubIdentity(identity) {
|
|
219
|
-
if (!identity || typeof identity !== "object") return null;
|
|
220
|
-
const login = typeof identity.login === "string" ? identity.login.trim() : "";
|
|
221
|
-
const id = identity.id === undefined || identity.id === null ? "" : String(identity.id).trim();
|
|
222
|
-
if (!login && !id) return null;
|
|
223
|
-
return { login, id };
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
function tokenFingerprint(token) {
|
|
227
|
-
return createHash("sha256").update(String(token || "")).digest("hex").slice(0, 24);
|
|
228
|
-
}
|
|
229
|
-
|
|
230
216
|
export function readGithubTokenMetadata(home = os.homedir(), token = null) {
|
|
231
217
|
try {
|
|
232
218
|
const parsed = JSON.parse(fs.readFileSync(githubTokenMetadataPath(home), "utf8"));
|
|
233
|
-
if (token && parsed.token_fingerprint !==
|
|
219
|
+
if (token && parsed.token_fingerprint !== githubTokenFingerprint(token)) return null;
|
|
234
220
|
const identity = normalizeGithubIdentity(parsed);
|
|
235
221
|
return identity ? { ...identity, token_fingerprint: parsed.token_fingerprint || "" } : null;
|
|
236
222
|
} catch {
|
|
@@ -238,15 +224,6 @@ export function readGithubTokenMetadata(home = os.homedir(), token = null) {
|
|
|
238
224
|
}
|
|
239
225
|
}
|
|
240
226
|
|
|
241
|
-
function githubIdentityMatches(identity, expected) {
|
|
242
|
-
const actual = normalizeGithubIdentity(identity);
|
|
243
|
-
const wanted = normalizeGithubIdentity(expected);
|
|
244
|
-
if (!wanted) return true;
|
|
245
|
-
if (!actual) return false;
|
|
246
|
-
if (wanted.id && actual.id) return wanted.id === actual.id;
|
|
247
|
-
return Boolean(wanted.login && actual.login && wanted.login.toLowerCase() === actual.login.toLowerCase());
|
|
248
|
-
}
|
|
249
|
-
|
|
250
227
|
function expectedGithubIdentity(home, env, token) {
|
|
251
228
|
const configuredLogin = String(env.CCDX_GITHUB_LOGIN || "").trim();
|
|
252
229
|
if (configuredLogin) return { login: configuredLogin };
|
|
@@ -374,7 +351,7 @@ export async function discoverGithubToken({
|
|
|
374
351
|
const candidate = { ok: true, token, source, validation };
|
|
375
352
|
const explicitSource = source.type === "env" || source.type === "token-file";
|
|
376
353
|
if (expectedIdentity && (strictExpectedIdentity || !explicitSource)) {
|
|
377
|
-
if (
|
|
354
|
+
if (githubIdentityMatchesExpected(validation, expectedIdentity)) return candidate;
|
|
378
355
|
failures.push({ source, validation, reason: "github_account_mismatch" });
|
|
379
356
|
continue;
|
|
380
357
|
}
|
|
@@ -427,7 +404,7 @@ export async function importDiscoveredGithubToken({
|
|
|
427
404
|
return { ok: true, token: savedToken, source: { type: "saved-token" }, imported: false };
|
|
428
405
|
}
|
|
429
406
|
const validation = await validateGithubToken(savedToken, { fetchImpl, signal });
|
|
430
|
-
if (validation.ok &&
|
|
407
|
+
if (validation.ok && githubIdentityMatchesExpected(validation, expectedIdentity)) {
|
|
431
408
|
writeGithubTokenMetadata(validation, home, savedToken);
|
|
432
409
|
return { ok: true, token: savedToken, source: { type: "saved-token" }, validation, imported: false };
|
|
433
410
|
}
|
|
@@ -561,7 +538,7 @@ export function writeGithubTokenMetadata(identity, home = os.homedir(), token =
|
|
|
561
538
|
fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
|
|
562
539
|
fs.writeFileSync(filePath, `${JSON.stringify({
|
|
563
540
|
...normalized,
|
|
564
|
-
token_fingerprint:
|
|
541
|
+
token_fingerprint: githubTokenFingerprint(token),
|
|
565
542
|
updated_at: new Date().toISOString(),
|
|
566
543
|
}, null, 2)}\n`, { mode: 0o600 });
|
|
567
544
|
fs.chmodSync(filePath, 0o600);
|
package/src/claude-config.mjs
CHANGED
|
@@ -3,12 +3,13 @@ import path from "node:path";
|
|
|
3
3
|
import os from "node:os";
|
|
4
4
|
import { status } from "./status.mjs";
|
|
5
5
|
import { atomicWriteFileIfChangedSync } from "./atomic-file.mjs";
|
|
6
|
+
import { adapterBaseUrl } from "./running-adapter.mjs";
|
|
6
7
|
|
|
7
8
|
const SETTINGS_PATH = path.join(os.homedir(), ".claude", "settings.json");
|
|
8
9
|
|
|
9
10
|
// Return an updated settings object without mutating the input.
|
|
10
|
-
export function computeUpdatedSettings(settings, port) {
|
|
11
|
-
const target =
|
|
11
|
+
export function computeUpdatedSettings(settings, port, host = "127.0.0.1") {
|
|
12
|
+
const target = adapterBaseUrl(host, port);
|
|
12
13
|
const current = settings?.env?.ANTHROPIC_BASE_URL;
|
|
13
14
|
const currentToken = settings?.env?.ANTHROPIC_AUTH_TOKEN;
|
|
14
15
|
const changed = current !== target || currentToken !== "dummy";
|
|
@@ -24,11 +25,11 @@ export function computeUpdatedSettings(settings, port) {
|
|
|
24
25
|
}
|
|
25
26
|
|
|
26
27
|
// Point Claude Code at the adapter by updating ~/.claude/settings.json.
|
|
27
|
-
export function ensureClaudeConfig(port = 2026, { filePath = SETTINGS_PATH } = {}) {
|
|
28
|
-
const target =
|
|
28
|
+
export function ensureClaudeConfig(port = 2026, { filePath = SETTINGS_PATH, host = "127.0.0.1" } = {}) {
|
|
29
|
+
const target = adapterBaseUrl(host, port);
|
|
29
30
|
|
|
30
31
|
if (!fs.existsSync(filePath)) {
|
|
31
|
-
const { json } = computeUpdatedSettings({}, port);
|
|
32
|
+
const { json } = computeUpdatedSettings({}, port, host);
|
|
32
33
|
atomicWriteFileIfChangedSync(filePath, JSON.stringify(json, null, 2) + "\n");
|
|
33
34
|
console.log(status("ok", `Created ~/.claude/settings.json for Claude Code at ${target}`));
|
|
34
35
|
return;
|
|
@@ -44,7 +45,7 @@ export function ensureClaudeConfig(port = 2026, { filePath = SETTINGS_PATH } = {
|
|
|
44
45
|
return;
|
|
45
46
|
}
|
|
46
47
|
|
|
47
|
-
const { json, changed } = computeUpdatedSettings(settings, port);
|
|
48
|
+
const { json, changed } = computeUpdatedSettings(settings, port, host);
|
|
48
49
|
if (!changed) {
|
|
49
50
|
console.log(status("ok", `Claude Code already points to ${target}`));
|
|
50
51
|
return;
|
package/src/config.mjs
CHANGED
|
@@ -3,6 +3,7 @@ import path from "node:path";
|
|
|
3
3
|
import os from "node:os";
|
|
4
4
|
import { status } from "./status.mjs";
|
|
5
5
|
import { atomicWriteFileIfChangedSync } from "./atomic-file.mjs";
|
|
6
|
+
import { adapterBaseUrl } from "./running-adapter.mjs";
|
|
6
7
|
|
|
7
8
|
const CONFIG_PATH = path.join(os.homedir(), ".codex", "config.toml");
|
|
8
9
|
|
|
@@ -33,9 +34,9 @@ function setTomlKey(lines, sectionName, key, value) {
|
|
|
33
34
|
return true;
|
|
34
35
|
}
|
|
35
36
|
|
|
36
|
-
export function computeUpdatedCodexConfig(content, adapterPort = 2026) {
|
|
37
|
-
const
|
|
38
|
-
const
|
|
37
|
+
export function computeUpdatedCodexConfig(content, adapterPort = 2026, adapterHost = "127.0.0.1") {
|
|
38
|
+
const anthropicBaseUrl = adapterBaseUrl(adapterHost, adapterPort);
|
|
39
|
+
const baseUrl = `${anthropicBaseUrl}/v1`;
|
|
39
40
|
const hadTrailingNewline = content.endsWith("\n");
|
|
40
41
|
const lines = content.split("\n");
|
|
41
42
|
if (hadTrailingNewline) lines.pop();
|
|
@@ -59,9 +60,9 @@ export function computeUpdatedCodexConfig(content, adapterPort = 2026) {
|
|
|
59
60
|
return { content: lines.join("\n") + (hadTrailingNewline ? "\n" : ""), changed };
|
|
60
61
|
}
|
|
61
62
|
|
|
62
|
-
function initialCodexConfig(adapterPort) {
|
|
63
|
-
const
|
|
64
|
-
const
|
|
63
|
+
function initialCodexConfig(adapterPort, adapterHost) {
|
|
64
|
+
const anthropicBaseUrl = adapterBaseUrl(adapterHost, adapterPort);
|
|
65
|
+
const baseUrl = `${anthropicBaseUrl}/v1`;
|
|
65
66
|
return `openai_base_url = "${baseUrl}"
|
|
66
67
|
|
|
67
68
|
[shell_environment_policy]
|
|
@@ -75,18 +76,18 @@ OPENAI_API_KEY = "dummy"
|
|
|
75
76
|
`;
|
|
76
77
|
}
|
|
77
78
|
|
|
78
|
-
export function ensureCodexConfig(adapterPort = 2026, { filePath = CONFIG_PATH } = {}) {
|
|
79
|
-
const baseUrl =
|
|
79
|
+
export function ensureCodexConfig(adapterPort = 2026, { filePath = CONFIG_PATH, host = "127.0.0.1" } = {}) {
|
|
80
|
+
const baseUrl = `${adapterBaseUrl(host, adapterPort)}/v1`;
|
|
80
81
|
|
|
81
82
|
if (!fs.existsSync(filePath)) {
|
|
82
83
|
// Codex config does not exist yet; create the local proxy defaults.
|
|
83
|
-
atomicWriteFileIfChangedSync(filePath, initialCodexConfig(adapterPort));
|
|
84
|
+
atomicWriteFileIfChangedSync(filePath, initialCodexConfig(adapterPort, host));
|
|
84
85
|
console.log(status("ok", "Created ~/.codex/config.toml"));
|
|
85
86
|
return;
|
|
86
87
|
}
|
|
87
88
|
|
|
88
89
|
const content = fs.readFileSync(filePath, "utf-8");
|
|
89
|
-
const updated = computeUpdatedCodexConfig(content, adapterPort);
|
|
90
|
+
const updated = computeUpdatedCodexConfig(content, adapterPort, host);
|
|
90
91
|
|
|
91
92
|
if (!updated.changed) {
|
|
92
93
|
console.log(status("ok", `Codex already points to ${baseUrl}`));
|
package/src/copilot.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import os from "node:os";
|
|
3
|
-
import {
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
4
|
import { status } from "./status.mjs";
|
|
5
5
|
import { debugLog } from "./log.mjs";
|
|
6
6
|
import {
|
|
@@ -11,6 +11,12 @@ import {
|
|
|
11
11
|
readGithubTokenMetadata,
|
|
12
12
|
writeGithubTokenMetadata,
|
|
13
13
|
} from "./auth.mjs";
|
|
14
|
+
import {
|
|
15
|
+
githubIdentitiesEqual,
|
|
16
|
+
githubIdentityLabel,
|
|
17
|
+
githubTokenFingerprint,
|
|
18
|
+
normalizeGithubIdentity,
|
|
19
|
+
} from "./github-identity.mjs";
|
|
14
20
|
import { prepareResponsesPayload } from "./image-optimization.mjs";
|
|
15
21
|
|
|
16
22
|
export {
|
|
@@ -95,6 +101,12 @@ const RETRYABLE_UPSTREAM_ERROR_CODES = new Set([
|
|
|
95
101
|
"EAI_AGAIN",
|
|
96
102
|
"ENOTFOUND",
|
|
97
103
|
]);
|
|
104
|
+
const RETRYABLE_POST_CONNECT_ERROR_CODES = new Set([
|
|
105
|
+
"UND_ERR_CONNECT_TIMEOUT",
|
|
106
|
+
"ECONNREFUSED",
|
|
107
|
+
"EAI_AGAIN",
|
|
108
|
+
"ENOTFOUND",
|
|
109
|
+
]);
|
|
98
110
|
const RETRYABLE_UPSTREAM_STATUSES = new Set([408, 502, 503, 504]);
|
|
99
111
|
|
|
100
112
|
function upstreamTarget(url) {
|
|
@@ -146,9 +158,15 @@ function requestMethod(init = {}) {
|
|
|
146
158
|
return String(init.method || "GET").toUpperCase();
|
|
147
159
|
}
|
|
148
160
|
|
|
149
|
-
export function isRetryableUpstreamError(err, { signal } = {}) {
|
|
161
|
+
export function isRetryableUpstreamError(err, { signal, method = "GET" } = {}) {
|
|
150
162
|
if (isAbortError(err, signal)) return false;
|
|
151
|
-
|
|
163
|
+
const code = upstreamErrorCode(err);
|
|
164
|
+
if (!["GET", "HEAD", "OPTIONS"].includes(String(method || "GET").toUpperCase())) {
|
|
165
|
+
const syscall = err?.cause?.syscall || err?.syscall || "";
|
|
166
|
+
return RETRYABLE_POST_CONNECT_ERROR_CODES.has(code)
|
|
167
|
+
|| (syscall === "connect" && ["ETIMEDOUT", "ECONNRESET"].includes(code));
|
|
168
|
+
}
|
|
169
|
+
return RETRYABLE_UPSTREAM_ERROR_CODES.has(code);
|
|
152
170
|
}
|
|
153
171
|
|
|
154
172
|
export function isRetryableUpstreamStatus(status, method = "GET") {
|
|
@@ -197,7 +215,7 @@ export async function fetchCopilotUpstream(
|
|
|
197
215
|
return resp;
|
|
198
216
|
} catch (e) {
|
|
199
217
|
debugLog(`upstream ${method} ${target} error=${describeUpstreamError(e)} attempt=${attempt + 1}/${retryCount + 1} attempt_ms=${Date.now() - attemptStart} total_ms=${Date.now() - totalStart}`);
|
|
200
|
-
if (attempt >= retryCount || !isRetryableUpstreamError(e, { signal })) throw e;
|
|
218
|
+
if (attempt >= retryCount || !isRetryableUpstreamError(e, { signal, method })) throw e;
|
|
201
219
|
const delay = upstreamRetryDelay(attempt, baseDelay);
|
|
202
220
|
console.warn(status("warn", `upstream ${target} ${describeUpstreamError(e)}; retry ${attempt + 1}/${retryCount} in ${delay}ms`));
|
|
203
221
|
await sleep(delay, { signal });
|
|
@@ -280,6 +298,21 @@ let copilotTokenGithubIdentity = null;
|
|
|
280
298
|
let copilotTokenRefreshRetryAt = 0;
|
|
281
299
|
const modelListFlights = new Map();
|
|
282
300
|
|
|
301
|
+
export function copilotRuntimeStatus(now = Date.now()) {
|
|
302
|
+
let upstreamHost = "unknown";
|
|
303
|
+
try { upstreamHost = new URL(apiBase).hostname; } catch {}
|
|
304
|
+
return {
|
|
305
|
+
token_cached: Boolean(copilotToken),
|
|
306
|
+
token_expires_in_ms: copilotToken ? Math.max(0, copilotTokenExpiry - now) : null,
|
|
307
|
+
token_refresh_in_flight: Boolean(copilotTokenRefresh),
|
|
308
|
+
token_refresh_backoff_ms: Math.max(0, copilotTokenRefreshRetryAt - now),
|
|
309
|
+
account_bound: Boolean(copilotTokenGithubIdentity),
|
|
310
|
+
upstream_host: upstreamHost,
|
|
311
|
+
model_endpoint_cache_entries: modelEndpointCache.size,
|
|
312
|
+
model_list_flights: modelListFlights.size,
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
|
|
283
316
|
function getGithubToken({ home = os.homedir() } = {}) {
|
|
284
317
|
const GITHUB_TOKEN_PATH = githubTokenPath(home);
|
|
285
318
|
if (!fs.existsSync(GITHUB_TOKEN_PATH)) {
|
|
@@ -297,30 +330,6 @@ function requestCopilotToken(ghToken, { fetchImpl = fetch, signal } = {}) {
|
|
|
297
330
|
});
|
|
298
331
|
}
|
|
299
332
|
|
|
300
|
-
function githubTokenFingerprint(token) {
|
|
301
|
-
return createHash("sha256").update(String(token || "")).digest("hex").slice(0, 24);
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
function normalizeGithubIdentity(identity) {
|
|
305
|
-
if (!identity || typeof identity !== "object") return null;
|
|
306
|
-
const login = typeof identity.login === "string" ? identity.login.trim() : "";
|
|
307
|
-
const id = identity.id === undefined || identity.id === null ? "" : String(identity.id).trim();
|
|
308
|
-
return login || id ? { login, id } : null;
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
function githubIdentitiesMatch(first, second) {
|
|
312
|
-
const a = normalizeGithubIdentity(first);
|
|
313
|
-
const b = normalizeGithubIdentity(second);
|
|
314
|
-
if (!a || !b) return false;
|
|
315
|
-
if (a.id && b.id) return a.id === b.id;
|
|
316
|
-
return Boolean(a.login && b.login && a.login.toLowerCase() === b.login.toLowerCase());
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
function githubIdentityLabel(identity) {
|
|
320
|
-
const normalized = normalizeGithubIdentity(identity);
|
|
321
|
-
return normalized?.login || normalized?.id || "unknown";
|
|
322
|
-
}
|
|
323
|
-
|
|
324
333
|
function cacheCopilotTokenData(data, {
|
|
325
334
|
sourceKey,
|
|
326
335
|
githubToken,
|
|
@@ -332,7 +341,7 @@ function cacheCopilotTokenData(data, {
|
|
|
332
341
|
const identity = normalizeGithubIdentity(githubIdentity);
|
|
333
342
|
const tokenChanged = Boolean(copilotTokenGithubFingerprint && fingerprint !== copilotTokenGithubFingerprint);
|
|
334
343
|
if (!allowAccountSwitch && copilotTokenGithubIdentity && identity
|
|
335
|
-
&& !
|
|
344
|
+
&& !githubIdentitiesEqual(copilotTokenGithubIdentity, identity)) {
|
|
336
345
|
throw new Error(`Refusing to switch GitHub Copilot account from ${githubIdentityLabel(copilotTokenGithubIdentity)} to ${githubIdentityLabel(identity)} while the adapter is running. Restart codex-copilot-dx to switch accounts intentionally.`);
|
|
337
346
|
}
|
|
338
347
|
if (!allowAccountSwitch && copilotTokenGithubIdentity && tokenChanged && !identity) {
|
package/src/doctor.mjs
CHANGED
|
@@ -6,7 +6,8 @@ import { githubTokenPath, readGithubTokenMetadata, validateGithubToken } from ".
|
|
|
6
6
|
import { claudeDesktopPaths } from "./claude-desktop-config.mjs";
|
|
7
7
|
import { status } from "./status.mjs";
|
|
8
8
|
import { buildHeaders, DEFAULT_API_BASE, FALLBACK_VSCODE_VERSION } from "./copilot.mjs";
|
|
9
|
-
import { checkRunningAdapter } from "./running-adapter.mjs";
|
|
9
|
+
import { adapterBaseUrl, checkRunningAdapter } from "./running-adapter.mjs";
|
|
10
|
+
import { CODEX_AUTO_REVIEW_MODEL } from "./models.mjs";
|
|
10
11
|
|
|
11
12
|
function localGatewayBaseUrl(host, port) {
|
|
12
13
|
const safeHost = String(host || "127.0.0.1");
|
|
@@ -146,6 +147,15 @@ export async function inspectAdapterCompatibility({
|
|
|
146
147
|
return checks;
|
|
147
148
|
}
|
|
148
149
|
|
|
150
|
+
await runCompatibilityCheck(checks, "Codex Auto-review", async () => {
|
|
151
|
+
const text = await compatibilityRequest(fetchImpl, `${baseUrl}/v1/responses`, {
|
|
152
|
+
model: CODEX_AUTO_REVIEW_MODEL,
|
|
153
|
+
stream: false,
|
|
154
|
+
input: "Reply with OK only.",
|
|
155
|
+
}, timeoutMs);
|
|
156
|
+
parseResponseObject(text);
|
|
157
|
+
});
|
|
158
|
+
|
|
149
159
|
const firstResponse = await runCompatibilityCheck(checks, `Native Responses (${responsesModel})`, async () => {
|
|
150
160
|
const text = await compatibilityRequest(fetchImpl, `${baseUrl}/v1/responses`, {
|
|
151
161
|
model: responsesModel,
|
|
@@ -259,10 +269,10 @@ export async function inspectGitHubTokenOnline({
|
|
|
259
269
|
}
|
|
260
270
|
}
|
|
261
271
|
|
|
262
|
-
export function inspectCodexConfig({ home = os.homedir(), port = 2026 } = {}) {
|
|
272
|
+
export function inspectCodexConfig({ home = os.homedir(), host = "127.0.0.1", port = 2026 } = {}) {
|
|
263
273
|
const filePath = path.join(home, ".codex", "config.toml");
|
|
264
|
-
const
|
|
265
|
-
const
|
|
274
|
+
const expectedAnthropicBaseUrl = adapterBaseUrl(host, port);
|
|
275
|
+
const expectedBaseUrl = `${expectedAnthropicBaseUrl}/v1`;
|
|
266
276
|
const config = readText(filePath);
|
|
267
277
|
if (!config.ok) {
|
|
268
278
|
return [{ kind: "warn", message: `Codex config not found at ${displayPath(home, filePath)}` }];
|
|
@@ -285,9 +295,9 @@ export function inspectCodexConfig({ home = os.homedir(), port = 2026 } = {}) {
|
|
|
285
295
|
return checks;
|
|
286
296
|
}
|
|
287
297
|
|
|
288
|
-
export function inspectClaudeCodeConfig({ home = os.homedir(), port = 2026 } = {}) {
|
|
298
|
+
export function inspectClaudeCodeConfig({ home = os.homedir(), host = "127.0.0.1", port = 2026 } = {}) {
|
|
289
299
|
const filePath = path.join(home, ".claude", "settings.json");
|
|
290
|
-
const expectedBaseUrl =
|
|
300
|
+
const expectedBaseUrl = adapterBaseUrl(host, port);
|
|
291
301
|
const settings = readJson(filePath);
|
|
292
302
|
if (!settings.ok) {
|
|
293
303
|
const reason = settings.parseError ? `could not parse: ${settings.error.message}` : `not found at ${displayPath(home, filePath)}`;
|
|
@@ -391,8 +401,8 @@ export async function collectDoctorChecks({
|
|
|
391
401
|
} = {}) {
|
|
392
402
|
const checks = [
|
|
393
403
|
...inspectGitHubToken({ home }),
|
|
394
|
-
...inspectCodexConfig({ home, port }),
|
|
395
|
-
...inspectClaudeCodeConfig({ home, port }),
|
|
404
|
+
...inspectCodexConfig({ home, host, port }),
|
|
405
|
+
...inspectClaudeCodeConfig({ home, host, port }),
|
|
396
406
|
...inspectClaudeAppConfig({ home, platform, env, host, port }),
|
|
397
407
|
];
|
|
398
408
|
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
export function normalizeGithubIdentity(identity) {
|
|
4
|
+
if (!identity || typeof identity !== "object") return null;
|
|
5
|
+
const login = typeof identity.login === "string" ? identity.login.trim() : "";
|
|
6
|
+
const id = identity.id === undefined || identity.id === null ? "" : String(identity.id).trim();
|
|
7
|
+
return login || id ? { login, id } : null;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function githubTokenFingerprint(token) {
|
|
11
|
+
return createHash("sha256").update(String(token || "")).digest("hex").slice(0, 24);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function githubIdentitiesEqual(first, second) {
|
|
15
|
+
const a = normalizeGithubIdentity(first);
|
|
16
|
+
const b = normalizeGithubIdentity(second);
|
|
17
|
+
if (!a || !b) return false;
|
|
18
|
+
if (a.id && b.id) return a.id === b.id;
|
|
19
|
+
return Boolean(a.login && b.login && a.login.toLowerCase() === b.login.toLowerCase());
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function githubIdentityMatchesExpected(identity, expected) {
|
|
23
|
+
if (!normalizeGithubIdentity(expected)) return true;
|
|
24
|
+
return githubIdentitiesEqual(identity, expected);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function githubIdentityLabel(identity) {
|
|
28
|
+
const normalized = normalizeGithubIdentity(identity);
|
|
29
|
+
return normalized?.login || normalized?.id || "unknown";
|
|
30
|
+
}
|