codex-copilot-dx 0.4.35 → 0.4.37
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 +12 -0
- package/bin/cli.mjs +1 -0
- package/package.json +1 -1
- package/src/adapter.mjs +116 -866
- package/src/anthropic-handler.mjs +128 -0
- package/src/anthropic.mjs +1 -1
- package/src/auth.mjs +12 -35
- package/src/cli-options.mjs +21 -8
- package/src/copilot.mjs +23 -26
- package/src/github-identity.mjs +30 -0
- package/src/http-transport.mjs +58 -22
- package/src/image-optimization.mjs +16 -1
- 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 +6 -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
|
@@ -8,7 +8,7 @@ async function tokenCounter() {
|
|
|
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) {
|
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/cli-options.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
const HELP_COMMANDS = new Set(["help", "--help", "-h"]);
|
|
2
2
|
const VERSION_COMMANDS = new Set(["version", "--version", "-v"]);
|
|
3
|
+
const START_OPTIONS = new Set(["--configure-claude-desktop", "--show-request-id"]);
|
|
3
4
|
|
|
4
5
|
function unexpectedArgs(args) {
|
|
5
6
|
throw new Error(`Unexpected argument${args.length === 1 ? "" : "s"}: ${args.join(" ")}`);
|
|
@@ -7,18 +8,18 @@ function unexpectedArgs(args) {
|
|
|
7
8
|
|
|
8
9
|
export function parseCliArgs(args = []) {
|
|
9
10
|
const [command, ...rest] = args;
|
|
10
|
-
if (!command) return { command: "start", configureClaudeDesktop: false, online: false, compat: false };
|
|
11
|
+
if (!command) return { command: "start", configureClaudeDesktop: false, showRequestId: false, online: false, compat: false };
|
|
11
12
|
if (HELP_COMMANDS.has(command)) {
|
|
12
13
|
if (rest.length) unexpectedArgs(rest);
|
|
13
|
-
return { command: "help", configureClaudeDesktop: false, online: false, compat: false };
|
|
14
|
+
return { command: "help", configureClaudeDesktop: false, showRequestId: false, online: false, compat: false };
|
|
14
15
|
}
|
|
15
16
|
if (VERSION_COMMANDS.has(command)) {
|
|
16
17
|
if (rest.length) unexpectedArgs(rest);
|
|
17
|
-
return { command: "version", configureClaudeDesktop: false, online: false, compat: false };
|
|
18
|
+
return { command: "version", configureClaudeDesktop: false, showRequestId: false, online: false, compat: false };
|
|
18
19
|
}
|
|
19
20
|
if (command === "usage") {
|
|
20
21
|
if (rest.length) unexpectedArgs(rest);
|
|
21
|
-
return { command: "usage", configureClaudeDesktop: false, online: false, compat: false };
|
|
22
|
+
return { command: "usage", configureClaudeDesktop: false, showRequestId: false, online: false, compat: false };
|
|
22
23
|
}
|
|
23
24
|
if (command === "doctor" || command === "--doctor") {
|
|
24
25
|
const supported = new Set(["--online", "--compat"]);
|
|
@@ -30,13 +31,25 @@ export function parseCliArgs(args = []) {
|
|
|
30
31
|
return {
|
|
31
32
|
command: "doctor",
|
|
32
33
|
configureClaudeDesktop: false,
|
|
34
|
+
showRequestId: false,
|
|
33
35
|
online: rest.includes("--online"),
|
|
34
36
|
compat: rest.includes("--compat"),
|
|
35
37
|
};
|
|
36
38
|
}
|
|
37
|
-
if (command
|
|
38
|
-
|
|
39
|
-
|
|
39
|
+
if (START_OPTIONS.has(command)) {
|
|
40
|
+
const options = [command, ...rest];
|
|
41
|
+
const invalid = options.filter((arg) => !START_OPTIONS.has(arg));
|
|
42
|
+
if (invalid.length) unexpectedArgs(invalid);
|
|
43
|
+
for (const option of START_OPTIONS) {
|
|
44
|
+
if (options.filter((arg) => arg === option).length > 1) unexpectedArgs([option]);
|
|
45
|
+
}
|
|
46
|
+
return {
|
|
47
|
+
command: "start",
|
|
48
|
+
configureClaudeDesktop: options.includes("--configure-claude-desktop"),
|
|
49
|
+
showRequestId: options.includes("--show-request-id"),
|
|
50
|
+
online: false,
|
|
51
|
+
compat: false,
|
|
52
|
+
};
|
|
40
53
|
}
|
|
41
54
|
throw new Error(`Unknown command or option: ${command}`);
|
|
42
55
|
}
|
|
@@ -69,7 +82,7 @@ export function parseRuntimeOptions(env = process.env) {
|
|
|
69
82
|
|
|
70
83
|
export function cliHelp() {
|
|
71
84
|
return `Usage:
|
|
72
|
-
codex-copilot-dx [--configure-claude-desktop]
|
|
85
|
+
codex-copilot-dx [--configure-claude-desktop] [--show-request-id]
|
|
73
86
|
codex-copilot-dx doctor [--online] [--compat]
|
|
74
87
|
codex-copilot-dx usage
|
|
75
88
|
codex-copilot-dx --version
|
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 {
|
|
@@ -292,6 +298,21 @@ let copilotTokenGithubIdentity = null;
|
|
|
292
298
|
let copilotTokenRefreshRetryAt = 0;
|
|
293
299
|
const modelListFlights = new Map();
|
|
294
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
|
+
|
|
295
316
|
function getGithubToken({ home = os.homedir() } = {}) {
|
|
296
317
|
const GITHUB_TOKEN_PATH = githubTokenPath(home);
|
|
297
318
|
if (!fs.existsSync(GITHUB_TOKEN_PATH)) {
|
|
@@ -309,30 +330,6 @@ function requestCopilotToken(ghToken, { fetchImpl = fetch, signal } = {}) {
|
|
|
309
330
|
});
|
|
310
331
|
}
|
|
311
332
|
|
|
312
|
-
function githubTokenFingerprint(token) {
|
|
313
|
-
return createHash("sha256").update(String(token || "")).digest("hex").slice(0, 24);
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
function normalizeGithubIdentity(identity) {
|
|
317
|
-
if (!identity || typeof identity !== "object") return null;
|
|
318
|
-
const login = typeof identity.login === "string" ? identity.login.trim() : "";
|
|
319
|
-
const id = identity.id === undefined || identity.id === null ? "" : String(identity.id).trim();
|
|
320
|
-
return login || id ? { login, id } : null;
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
function githubIdentitiesMatch(first, second) {
|
|
324
|
-
const a = normalizeGithubIdentity(first);
|
|
325
|
-
const b = normalizeGithubIdentity(second);
|
|
326
|
-
if (!a || !b) return false;
|
|
327
|
-
if (a.id && b.id) return a.id === b.id;
|
|
328
|
-
return Boolean(a.login && b.login && a.login.toLowerCase() === b.login.toLowerCase());
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
function githubIdentityLabel(identity) {
|
|
332
|
-
const normalized = normalizeGithubIdentity(identity);
|
|
333
|
-
return normalized?.login || normalized?.id || "unknown";
|
|
334
|
-
}
|
|
335
|
-
|
|
336
333
|
function cacheCopilotTokenData(data, {
|
|
337
334
|
sourceKey,
|
|
338
335
|
githubToken,
|
|
@@ -344,7 +341,7 @@ function cacheCopilotTokenData(data, {
|
|
|
344
341
|
const identity = normalizeGithubIdentity(githubIdentity);
|
|
345
342
|
const tokenChanged = Boolean(copilotTokenGithubFingerprint && fingerprint !== copilotTokenGithubFingerprint);
|
|
346
343
|
if (!allowAccountSwitch && copilotTokenGithubIdentity && identity
|
|
347
|
-
&& !
|
|
344
|
+
&& !githubIdentitiesEqual(copilotTokenGithubIdentity, identity)) {
|
|
348
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.`);
|
|
349
346
|
}
|
|
350
347
|
if (!allowAccountSwitch && copilotTokenGithubIdentity && tokenChanged && !identity) {
|
|
@@ -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
|
+
}
|
package/src/http-transport.mjs
CHANGED
|
@@ -1,28 +1,20 @@
|
|
|
1
1
|
import { promisify } from "node:util";
|
|
2
2
|
import * as zlib from "node:zlib";
|
|
3
|
+
import { loadRuntimeConfig, parsePositiveInteger } from "./runtime-config.mjs";
|
|
3
4
|
import { status } from "./status.mjs";
|
|
4
5
|
|
|
5
|
-
const DEFAULT_MAX_BODY_BYTES = 64 * 1024 * 1024;
|
|
6
|
-
const DEFAULT_MAX_DECODED_BODY_BYTES = 128 * 1024 * 1024;
|
|
7
|
-
const DEFAULT_MAX_INFLIGHT_BODY_BYTES = 32 * 1024 * 1024;
|
|
8
|
-
const DEFAULT_MAX_QUEUED_REQUESTS = 16;
|
|
9
|
-
const DEFAULT_REQUEST_QUEUE_TIMEOUT_MS = 120 * 1000;
|
|
10
6
|
const COMPRESSED_BODY_WEIGHT_MULTIPLIER = 4;
|
|
11
7
|
const gunzipAsync = promisify(zlib.gunzip);
|
|
12
8
|
const inflateAsync = promisify(zlib.inflate);
|
|
13
9
|
const brotliDecompressAsync = promisify(zlib.brotliDecompress);
|
|
14
10
|
const zstdDecompressAsync = zlib.zstdDecompress ? promisify(zlib.zstdDecompress) : null;
|
|
15
11
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
const
|
|
22
|
-
const MAX_DECODED_BODY_BYTES = positiveInt(process.env.CCDX_MAX_DECODED_BODY_BYTES, DEFAULT_MAX_DECODED_BODY_BYTES);
|
|
23
|
-
const MAX_INFLIGHT_BODY_BYTES = positiveInt(process.env.CCDX_MAX_INFLIGHT_BODY_BYTES, DEFAULT_MAX_INFLIGHT_BODY_BYTES);
|
|
24
|
-
const MAX_QUEUED_REQUESTS = positiveInt(process.env.CCDX_MAX_QUEUED_REQUESTS, DEFAULT_MAX_QUEUED_REQUESTS);
|
|
25
|
-
const REQUEST_QUEUE_TIMEOUT_MS = positiveInt(process.env.CCDX_REQUEST_QUEUE_TIMEOUT_MS, DEFAULT_REQUEST_QUEUE_TIMEOUT_MS);
|
|
12
|
+
const HTTP_RUNTIME_CONFIG = loadRuntimeConfig();
|
|
13
|
+
const MAX_BODY_BYTES = HTTP_RUNTIME_CONFIG.maxBodyBytes;
|
|
14
|
+
const MAX_DECODED_BODY_BYTES = HTTP_RUNTIME_CONFIG.maxDecodedBodyBytes;
|
|
15
|
+
const MAX_INFLIGHT_BODY_BYTES = HTTP_RUNTIME_CONFIG.maxInflightBodyBytes;
|
|
16
|
+
const MAX_QUEUED_REQUESTS = HTTP_RUNTIME_CONFIG.maxQueuedRequests;
|
|
17
|
+
const REQUEST_QUEUE_TIMEOUT_MS = HTTP_RUNTIME_CONFIG.requestQueueTimeoutMs;
|
|
26
18
|
|
|
27
19
|
export function httpError(message, statusCode) {
|
|
28
20
|
const err = new Error(message);
|
|
@@ -59,11 +51,22 @@ export function createRequestAdmission({
|
|
|
59
51
|
maxQueued = MAX_QUEUED_REQUESTS,
|
|
60
52
|
waitTimeoutMs = REQUEST_QUEUE_TIMEOUT_MS,
|
|
61
53
|
} = {}) {
|
|
62
|
-
const byteLimit =
|
|
63
|
-
const queueLimit =
|
|
64
|
-
const timeoutMs =
|
|
54
|
+
const byteLimit = parsePositiveInteger(maxBytes, MAX_INFLIGHT_BODY_BYTES);
|
|
55
|
+
const queueLimit = parsePositiveInteger(maxQueued, MAX_QUEUED_REQUESTS);
|
|
56
|
+
const timeoutMs = parsePositiveInteger(waitTimeoutMs, REQUEST_QUEUE_TIMEOUT_MS);
|
|
65
57
|
const queue = [];
|
|
66
58
|
let activeBytes = 0;
|
|
59
|
+
let activeRequests = 0;
|
|
60
|
+
const counters = {
|
|
61
|
+
total: 0,
|
|
62
|
+
activated: 0,
|
|
63
|
+
queued: 0,
|
|
64
|
+
rejected: 0,
|
|
65
|
+
timedOut: 0,
|
|
66
|
+
aborted: 0,
|
|
67
|
+
waitMsTotal: 0,
|
|
68
|
+
waitMsMax: 0,
|
|
69
|
+
};
|
|
67
70
|
|
|
68
71
|
const remove = (entry) => {
|
|
69
72
|
const index = queue.indexOf(entry);
|
|
@@ -73,11 +76,17 @@ export function createRequestAdmission({
|
|
|
73
76
|
const activate = (entry) => {
|
|
74
77
|
entry.cleanup();
|
|
75
78
|
activeBytes += entry.weight;
|
|
79
|
+
activeRequests += 1;
|
|
80
|
+
counters.activated += 1;
|
|
81
|
+
const waitMs = Math.max(0, Date.now() - entry.startedAt);
|
|
82
|
+
counters.waitMsTotal += waitMs;
|
|
83
|
+
counters.waitMsMax = Math.max(counters.waitMsMax, waitMs);
|
|
76
84
|
let released = false;
|
|
77
85
|
entry.resolve(() => {
|
|
78
86
|
if (released) return;
|
|
79
87
|
released = true;
|
|
80
88
|
activeBytes = Math.max(0, activeBytes - entry.weight);
|
|
89
|
+
activeRequests = Math.max(0, activeRequests - 1);
|
|
81
90
|
drain();
|
|
82
91
|
});
|
|
83
92
|
};
|
|
@@ -99,16 +108,23 @@ export function createRequestAdmission({
|
|
|
99
108
|
};
|
|
100
109
|
|
|
101
110
|
const acquire = (req, { signal } = {}) => {
|
|
102
|
-
|
|
111
|
+
counters.total += 1;
|
|
112
|
+
if (signal?.aborted) {
|
|
113
|
+
counters.aborted += 1;
|
|
114
|
+
return Promise.reject(admissionAbortError(signal));
|
|
115
|
+
}
|
|
103
116
|
const weight = requestAdmissionWeight(req, byteLimit);
|
|
104
117
|
if (queue.length >= queueLimit && activeBytes + weight > byteLimit) {
|
|
118
|
+
counters.rejected += 1;
|
|
105
119
|
return Promise.reject(httpError(`Request queue is full (${queueLimit} waiting)`, 503));
|
|
106
120
|
}
|
|
121
|
+
if (activeBytes + weight > byteLimit) counters.queued += 1;
|
|
107
122
|
|
|
108
123
|
return new Promise((resolve, reject) => {
|
|
109
124
|
let timer;
|
|
110
125
|
const entry = {
|
|
111
126
|
cancelled: false,
|
|
127
|
+
startedAt: Date.now(),
|
|
112
128
|
weight,
|
|
113
129
|
resolve,
|
|
114
130
|
cleanup: () => {
|
|
@@ -116,18 +132,20 @@ export function createRequestAdmission({
|
|
|
116
132
|
signal?.removeEventListener("abort", onAbort);
|
|
117
133
|
},
|
|
118
134
|
};
|
|
119
|
-
const cancel = (error) => {
|
|
135
|
+
const cancel = (error, reason) => {
|
|
120
136
|
if (entry.cancelled) return;
|
|
121
137
|
entry.cancelled = true;
|
|
138
|
+
if (reason === "aborted") counters.aborted += 1;
|
|
139
|
+
if (reason === "timed_out") counters.timedOut += 1;
|
|
122
140
|
entry.cleanup();
|
|
123
141
|
remove(entry);
|
|
124
142
|
reject(error);
|
|
125
143
|
drain();
|
|
126
144
|
};
|
|
127
|
-
const onAbort = () => cancel(admissionAbortError(signal));
|
|
145
|
+
const onAbort = () => cancel(admissionAbortError(signal), "aborted");
|
|
128
146
|
signal?.addEventListener("abort", onAbort, { once: true });
|
|
129
147
|
timer = setTimeout(() => {
|
|
130
|
-
cancel(httpError(`Request admission timed out after ${timeoutMs}ms`, 503));
|
|
148
|
+
cancel(httpError(`Request admission timed out after ${timeoutMs}ms`, 503), "timed_out");
|
|
131
149
|
}, timeoutMs);
|
|
132
150
|
timer.unref?.();
|
|
133
151
|
queue.push(entry);
|
|
@@ -137,6 +155,24 @@ export function createRequestAdmission({
|
|
|
137
155
|
};
|
|
138
156
|
|
|
139
157
|
acquire.stats = () => ({ activeBytes, queued: queue.length, maxBytes: byteLimit });
|
|
158
|
+
acquire.diagnostics = () => ({
|
|
159
|
+
activeBytes,
|
|
160
|
+
activeRequests,
|
|
161
|
+
queued: queue.length,
|
|
162
|
+
maxBytes: byteLimit,
|
|
163
|
+
maxQueued: queueLimit,
|
|
164
|
+
waitTimeoutMs: timeoutMs,
|
|
165
|
+
total: counters.total,
|
|
166
|
+
activated: counters.activated,
|
|
167
|
+
queuedTotal: counters.queued,
|
|
168
|
+
rejected: counters.rejected,
|
|
169
|
+
timedOut: counters.timedOut,
|
|
170
|
+
aborted: counters.aborted,
|
|
171
|
+
waitMsAvg: counters.activated > 0
|
|
172
|
+
? Number((counters.waitMsTotal / counters.activated).toFixed(1))
|
|
173
|
+
: 0,
|
|
174
|
+
waitMsMax: counters.waitMsMax,
|
|
175
|
+
});
|
|
140
176
|
return acquire;
|
|
141
177
|
}
|
|
142
178
|
|
|
@@ -61,7 +61,7 @@ export function createTaskLimiter(concurrency) {
|
|
|
61
61
|
}
|
|
62
62
|
};
|
|
63
63
|
|
|
64
|
-
|
|
64
|
+
const runLimited = async function runLimited(task, { signal } = {}) {
|
|
65
65
|
if (signal?.aborted) throw abortError(signal);
|
|
66
66
|
await new Promise((resolve, reject) => {
|
|
67
67
|
const entry = {
|
|
@@ -91,10 +91,25 @@ export function createTaskLimiter(concurrency) {
|
|
|
91
91
|
drain();
|
|
92
92
|
}
|
|
93
93
|
};
|
|
94
|
+
runLimited.stats = () => ({
|
|
95
|
+
active,
|
|
96
|
+
queued: queue.reduce((count, entry) => count + (entry.cancelled ? 0 : 1), 0),
|
|
97
|
+
limit,
|
|
98
|
+
});
|
|
99
|
+
return runLimited;
|
|
94
100
|
}
|
|
95
101
|
|
|
96
102
|
const runGlobalImageTask = createTaskLimiter(IMG_CONCURRENCY);
|
|
97
103
|
|
|
104
|
+
export function imageOptimizationStats() {
|
|
105
|
+
return {
|
|
106
|
+
...runGlobalImageTask.stats(),
|
|
107
|
+
disabled: IMG_OPT_DISABLED,
|
|
108
|
+
cache_entries: optimizedImageDigests.size,
|
|
109
|
+
sharp_loaded: sharpImport !== null,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
98
113
|
async function sharp() {
|
|
99
114
|
sharpImport ||= import("sharp");
|
|
100
115
|
const mod = await sharpImport;
|