codex-copilot-dx 0.6.18 → 0.6.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/bin/cli.mjs +2 -1
- package/package.json +1 -1
- package/src/adapter.mjs +19 -3
- package/src/anthropic-handler.mjs +9 -4
- package/src/anthropic.mjs +10 -3
- package/src/auth-profile.mjs +3 -3
- package/src/auth.mjs +44 -10
- package/src/auto-review-model.mjs +2 -2
- package/src/cli-auth.mjs +98 -25
- package/src/cli-error.mjs +50 -0
- package/src/cli-models.mjs +2 -2
- package/src/cli-options.mjs +10 -8
- package/src/copilot-token-session.mjs +17 -2
- package/src/copilot.mjs +22 -3
- package/src/doctor.mjs +2 -2
- package/src/http-transport.mjs +56 -4
- package/src/lock.mjs +24 -4
- package/src/pm-studio-bundle-content.mjs +165 -0
- package/src/pm-studio-relay.mjs +26 -5
- package/src/pm-studio-setup.mjs +42 -162
- package/src/responses-handler.mjs +7 -2
- package/src/responses-proxy.mjs +6 -1
- package/src/runtime-config.mjs +33 -7
- package/src/token-counter-service.mjs +211 -0
- package/src/token-counter-worker.mjs +16 -0
- package/src/usage.mjs +80 -13
package/src/pm-studio-setup.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import os from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
|
-
import {
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
5
5
|
import { spawnSync } from "node:child_process";
|
|
6
6
|
import {
|
|
7
7
|
AUTH_PROFILE_CLAUDE,
|
|
@@ -20,12 +20,14 @@ import {
|
|
|
20
20
|
patchAsarBuffer,
|
|
21
21
|
sha256Hex,
|
|
22
22
|
} from "./pm-studio-asar.mjs";
|
|
23
|
+
import { inspectBundleContent } from "./pm-studio-bundle-content.mjs";
|
|
23
24
|
import { withPmStudioSetupLock } from "./pm-studio-setup-lock.mjs";
|
|
24
25
|
|
|
26
|
+
export { inspectBundleContent } from "./pm-studio-bundle-content.mjs";
|
|
27
|
+
|
|
25
28
|
export const DEFAULT_PM_STUDIO_APP_PATH = "/Applications/PM Studio.app";
|
|
26
29
|
export const PM_STUDIO_CLAUDE_AUTH_COMMAND = "ccdx auth login claude --reauth --github-login <personal-login>";
|
|
27
30
|
const MIN_FREE_MARGIN_BYTES = 16 * 1024 * 1024;
|
|
28
|
-
const BUNDLE_CONTENT_SCHEME = "ccdx-bundle-content-v2";
|
|
29
31
|
|
|
30
32
|
function setupError(code, message, details) {
|
|
31
33
|
const error = new Error(message);
|
|
@@ -60,158 +62,6 @@ function runChecked(processRunner, command, args, label) {
|
|
|
60
62
|
return result;
|
|
61
63
|
}
|
|
62
64
|
|
|
63
|
-
function hashRegularFile(filePath, expected, readBuffer) {
|
|
64
|
-
const hash = createHash("sha256");
|
|
65
|
-
const file = fs.openSync(filePath, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0));
|
|
66
|
-
const buffer = readBuffer || Buffer.allocUnsafe(1024 * 1024);
|
|
67
|
-
try {
|
|
68
|
-
const before = fs.fstatSync(file, { bigint: true });
|
|
69
|
-
if (before.dev !== BigInt(expected.dev)
|
|
70
|
-
|| before.ino !== BigInt(expected.ino)
|
|
71
|
-
|| before.size !== BigInt(expected.size)) {
|
|
72
|
-
throw setupError("PM_STUDIO_BUNDLE_CHANGED", `PM Studio bundle changed while reading ${filePath}`);
|
|
73
|
-
}
|
|
74
|
-
for (;;) {
|
|
75
|
-
const length = fs.readSync(file, buffer, 0, buffer.length, null);
|
|
76
|
-
if (length === 0) break;
|
|
77
|
-
hash.update(buffer.subarray(0, length));
|
|
78
|
-
}
|
|
79
|
-
const after = fs.fstatSync(file, { bigint: true });
|
|
80
|
-
if (before.dev !== after.dev
|
|
81
|
-
|| before.ino !== after.ino
|
|
82
|
-
|| before.size !== after.size
|
|
83
|
-
|| before.mtimeNs !== after.mtimeNs
|
|
84
|
-
|| before.ctimeNs !== after.ctimeNs) {
|
|
85
|
-
throw setupError("PM_STUDIO_BUNDLE_CHANGED", `PM Studio bundle changed while hashing ${filePath}`);
|
|
86
|
-
}
|
|
87
|
-
} finally {
|
|
88
|
-
fs.closeSync(file);
|
|
89
|
-
}
|
|
90
|
-
return hash.digest("hex");
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
function parseXattrHex(output, root, ignoredXattrs) {
|
|
94
|
-
const records = [];
|
|
95
|
-
const ignored = new Set(ignoredXattrs);
|
|
96
|
-
const seen = new Set();
|
|
97
|
-
let current = null;
|
|
98
|
-
const flush = () => {
|
|
99
|
-
if (!current) return;
|
|
100
|
-
if (!ignored.has(current.name)) {
|
|
101
|
-
const key = `${current.path}\0${current.name}`;
|
|
102
|
-
if (seen.has(key)) throw setupError("PM_STUDIO_XATTR_INVALID", `Duplicate extended attribute ${current.name}`);
|
|
103
|
-
seen.add(key);
|
|
104
|
-
const value = Buffer.from(current.hex.join(""), "hex");
|
|
105
|
-
records.push(["xattr", current.path, current.name, value.length, sha256Hex(value)]);
|
|
106
|
-
}
|
|
107
|
-
current = null;
|
|
108
|
-
};
|
|
109
|
-
|
|
110
|
-
for (const line of String(output || "").split(/\r?\n/)) {
|
|
111
|
-
if (!line) continue;
|
|
112
|
-
const separator = line.lastIndexOf(": ");
|
|
113
|
-
if ((line === `${root}:` || line.startsWith(`${root}: `) || line.startsWith(`${root}${path.sep}`))
|
|
114
|
-
&& separator >= root.length && line.endsWith(":")) {
|
|
115
|
-
flush();
|
|
116
|
-
const target = line.slice(0, separator);
|
|
117
|
-
const relative = target === root ? "." : path.relative(root, target).split(path.sep).join("/");
|
|
118
|
-
if (!relative || relative === ".." || relative.startsWith("../") || path.isAbsolute(relative)) {
|
|
119
|
-
throw setupError("PM_STUDIO_XATTR_INVALID", `Extended attribute path escapes PM Studio: ${target}`);
|
|
120
|
-
}
|
|
121
|
-
current = { path: relative, name: line.slice(separator + 2, -1), hex: [] };
|
|
122
|
-
continue;
|
|
123
|
-
}
|
|
124
|
-
if (!current) throw setupError("PM_STUDIO_XATTR_INVALID", "Could not parse PM Studio extended attributes");
|
|
125
|
-
const data = line.includes(" |") ? line.slice(0, line.indexOf(" |")) : line;
|
|
126
|
-
const tokens = data.trim().split(/\s+/);
|
|
127
|
-
if (!/^[0-9a-f]{8}$/i.test(tokens[0]) || tokens.slice(1).some((token) => !/^[0-9a-f]{2}$/i.test(token))) {
|
|
128
|
-
throw setupError("PM_STUDIO_XATTR_INVALID", "Could not parse PM Studio extended attribute bytes");
|
|
129
|
-
}
|
|
130
|
-
current.hex.push(...tokens.slice(1));
|
|
131
|
-
}
|
|
132
|
-
flush();
|
|
133
|
-
return records.sort((left, right) => Buffer.compare(
|
|
134
|
-
Buffer.from(`${left[1]}\0${left[2]}`),
|
|
135
|
-
Buffer.from(`${right[1]}\0${right[2]}`),
|
|
136
|
-
));
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
export function inspectBundleContent(appPath, {
|
|
140
|
-
xattrOutput = "",
|
|
141
|
-
ignoredXattrs = [],
|
|
142
|
-
readBuffer,
|
|
143
|
-
} = {}) {
|
|
144
|
-
const root = path.resolve(appPath);
|
|
145
|
-
const rootStat = fs.lstatSync(root);
|
|
146
|
-
if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) {
|
|
147
|
-
throw setupError("PM_STUDIO_APP_INVALID", `PM Studio path is not a regular app bundle: ${appPath}`);
|
|
148
|
-
}
|
|
149
|
-
const records = [];
|
|
150
|
-
const normalizedPaths = new Map();
|
|
151
|
-
let regularFileCount = 0;
|
|
152
|
-
let regularBytes = 0;
|
|
153
|
-
let symlinkCount = 0;
|
|
154
|
-
|
|
155
|
-
const rememberPath = (relative) => {
|
|
156
|
-
const normalized = relative.normalize("NFC");
|
|
157
|
-
const existing = normalizedPaths.get(normalized);
|
|
158
|
-
if (existing && existing !== relative) {
|
|
159
|
-
throw setupError("PM_STUDIO_BUNDLE_PATH_COLLISION", `PM Studio contains colliding paths: ${existing} and ${relative}`);
|
|
160
|
-
}
|
|
161
|
-
normalizedPaths.set(normalized, relative);
|
|
162
|
-
};
|
|
163
|
-
rememberPath(".");
|
|
164
|
-
records.push(["directory", ".", rootStat.mode & 0o7777]);
|
|
165
|
-
const visit = (directory) => {
|
|
166
|
-
const names = fs.readdirSync(directory).sort((left, right) =>
|
|
167
|
-
Buffer.compare(Buffer.from(left), Buffer.from(right)));
|
|
168
|
-
for (const name of names) {
|
|
169
|
-
const fullPath = path.join(directory, name);
|
|
170
|
-
const stat = fs.lstatSync(fullPath);
|
|
171
|
-
const relative = path.relative(root, fullPath).split(path.sep).join("/");
|
|
172
|
-
const mode = stat.mode & 0o7777;
|
|
173
|
-
rememberPath(relative);
|
|
174
|
-
if (stat.isDirectory() && !stat.isSymbolicLink()) {
|
|
175
|
-
records.push(["directory", relative, mode]);
|
|
176
|
-
visit(fullPath);
|
|
177
|
-
} else if (stat.isSymbolicLink()) {
|
|
178
|
-
const target = fs.readlinkSync(fullPath);
|
|
179
|
-
const resolved = path.resolve(path.dirname(fullPath), target);
|
|
180
|
-
if (path.isAbsolute(target)
|
|
181
|
-
|| (resolved !== root && !resolved.startsWith(`${root}${path.sep}`))
|
|
182
|
-
|| !fs.existsSync(resolved)) {
|
|
183
|
-
throw setupError("PM_STUDIO_BUNDLE_SYMLINK_INVALID", `PM Studio contains an unsafe symlink: ${relative}`);
|
|
184
|
-
}
|
|
185
|
-
symlinkCount += 1;
|
|
186
|
-
records.push(["symlink", relative, mode, target]);
|
|
187
|
-
} else if (stat.isFile()) {
|
|
188
|
-
if (stat.nlink !== 1) {
|
|
189
|
-
throw setupError("PM_STUDIO_BUNDLE_HARDLINK_INVALID", `PM Studio contains a hard-linked file: ${relative}`);
|
|
190
|
-
}
|
|
191
|
-
regularFileCount += 1;
|
|
192
|
-
regularBytes += stat.size;
|
|
193
|
-
records.push(["file", relative, mode, stat.size, hashRegularFile(fullPath, stat, readBuffer)]);
|
|
194
|
-
} else {
|
|
195
|
-
throw setupError("PM_STUDIO_BUNDLE_ENTRY_INVALID", `PM Studio contains an unsupported entry: ${relative}`);
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
|
-
};
|
|
199
|
-
visit(root);
|
|
200
|
-
|
|
201
|
-
const xattrRecords = parseXattrHex(xattrOutput, root, ignoredXattrs);
|
|
202
|
-
const hash = createHash("sha256");
|
|
203
|
-
for (const record of [...records, ...xattrRecords]) hash.update(`${JSON.stringify(record)}\n`);
|
|
204
|
-
return {
|
|
205
|
-
scheme: BUNDLE_CONTENT_SCHEME,
|
|
206
|
-
sha256: hash.digest("hex"),
|
|
207
|
-
entryCount: records.length,
|
|
208
|
-
regularFileCount,
|
|
209
|
-
regularBytes,
|
|
210
|
-
symlinkCount,
|
|
211
|
-
xattrCount: xattrRecords.length,
|
|
212
|
-
};
|
|
213
|
-
}
|
|
214
|
-
|
|
215
65
|
function defaultInspectBundleContent({ appPath, recipe, processRunner, readBuffer }) {
|
|
216
66
|
const xattrs = runChecked(processRunner, "/usr/bin/xattr", ["-r", "-s", "-x", "-l", appPath],
|
|
217
67
|
"Reading PM Studio extended attributes");
|
|
@@ -346,7 +196,7 @@ function statusInspectExecutableIntegrity({ appPath, infoPlistPath, recipe, proc
|
|
|
346
196
|
};
|
|
347
197
|
}
|
|
348
198
|
|
|
349
|
-
|
|
199
|
+
function createPmStudioFileInspectionOperationOverrides() {
|
|
350
200
|
const evidenceCache = new Map();
|
|
351
201
|
const bundleReadBuffer = Buffer.allocUnsafe(1024 * 1024);
|
|
352
202
|
return {
|
|
@@ -370,6 +220,10 @@ export function createPmStudioStatusOperationOverrides() {
|
|
|
370
220
|
};
|
|
371
221
|
}
|
|
372
222
|
|
|
223
|
+
export function createPmStudioStatusOperationOverrides() {
|
|
224
|
+
return createPmStudioFileInspectionOperationOverrides();
|
|
225
|
+
}
|
|
226
|
+
|
|
373
227
|
function defaultSignApp({ appPath, source, processRunner }) {
|
|
374
228
|
const preserve = [];
|
|
375
229
|
if (source?.codeSign?.displayValid === true) {
|
|
@@ -1043,7 +897,7 @@ function patchRecordForState(manifest, recipe, state) {
|
|
|
1043
897
|
return patchRecordMatches(manifest?.patched, recipe, state) ? manifest.patched : null;
|
|
1044
898
|
}
|
|
1045
899
|
|
|
1046
|
-
function validateBackup({ backupDir, recipe, operations }) {
|
|
900
|
+
function validateBackup({ backupDir, recipe, operations, evidence }) {
|
|
1047
901
|
const manifestPath = path.join(backupDir, "manifest.json");
|
|
1048
902
|
const backupAppPath = path.join(backupDir, "PM Studio.app");
|
|
1049
903
|
let manifest;
|
|
@@ -1079,6 +933,9 @@ function validateBackup({ backupDir, recipe, operations }) {
|
|
|
1079
933
|
if (inspection.state !== "clean") {
|
|
1080
934
|
throw setupError("PM_STUDIO_BACKUP_INVALID", "Existing PM Studio backup is not an exact clean bundle", inspection.issues);
|
|
1081
935
|
}
|
|
936
|
+
if (evidence) {
|
|
937
|
+
evidence.inspection = inspection;
|
|
938
|
+
}
|
|
1082
939
|
return { backupDir, backupAppPath, manifestPath, manifest, reused: true };
|
|
1083
940
|
}
|
|
1084
941
|
|
|
@@ -1316,6 +1173,10 @@ function restoreStateFingerprint(inspection, bundleContent) {
|
|
|
1316
1173
|
|
|
1317
1174
|
function inspectRestoreState({ appPath, recipe, operations }) {
|
|
1318
1175
|
const inspection = inspectPmStudioApp({ appPath, recipe, operations });
|
|
1176
|
+
return restoreStateFromInspection({ appPath, inspection, recipe, operations });
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
function restoreStateFromInspection({ appPath, inspection, recipe, operations }) {
|
|
1319
1180
|
const bundleContent = inspection.bundleContent || operations.inspectBundleContent({
|
|
1320
1181
|
...appPaths(appPath, recipe),
|
|
1321
1182
|
recipe,
|
|
@@ -1355,7 +1216,10 @@ export async function runPmStudioSetup({
|
|
|
1355
1216
|
operations: operationOverrides = {},
|
|
1356
1217
|
logger = (line) => console.log(line),
|
|
1357
1218
|
} = {}) {
|
|
1358
|
-
const operations = createPmStudioSetupOperations(
|
|
1219
|
+
const operations = createPmStudioSetupOperations({
|
|
1220
|
+
...createPmStudioFileInspectionOperationOverrides(),
|
|
1221
|
+
...operationOverrides,
|
|
1222
|
+
});
|
|
1359
1223
|
return operations.withSetupLock({ appPath }, async () => {
|
|
1360
1224
|
const messages = [];
|
|
1361
1225
|
const emit = (message) => {
|
|
@@ -1548,7 +1412,10 @@ export async function runPmStudioRestore({
|
|
|
1548
1412
|
operations: operationOverrides = {},
|
|
1549
1413
|
logger = (line) => console.log(line),
|
|
1550
1414
|
} = {}) {
|
|
1551
|
-
const operations = createPmStudioSetupOperations(
|
|
1415
|
+
const operations = createPmStudioSetupOperations({
|
|
1416
|
+
...createPmStudioFileInspectionOperationOverrides(),
|
|
1417
|
+
...operationOverrides,
|
|
1418
|
+
});
|
|
1552
1419
|
return operations.withSetupLock({ appPath }, async () => {
|
|
1553
1420
|
const messages = [];
|
|
1554
1421
|
const emit = (message) => {
|
|
@@ -1590,7 +1457,8 @@ export async function runPmStudioRestore({
|
|
|
1590
1457
|
|
|
1591
1458
|
const manifestPath = pmStudioPatchManifestPath({ home, backupRoot, recipe });
|
|
1592
1459
|
const backupDir = path.dirname(manifestPath);
|
|
1593
|
-
const
|
|
1460
|
+
const backupEvidence = {};
|
|
1461
|
+
const backup = validateBackup({ backupDir, recipe, operations, evidence: backupEvidence });
|
|
1594
1462
|
assertPatchedBinaryRecord({
|
|
1595
1463
|
inspection: source.inspection,
|
|
1596
1464
|
manifestPath: backup.manifestPath,
|
|
@@ -1604,7 +1472,12 @@ export async function runPmStudioRestore({
|
|
|
1604
1472
|
});
|
|
1605
1473
|
assertRestoreSpace({ appPath, backupAppPath: backup.backupAppPath, operations });
|
|
1606
1474
|
const manifestSnapshot = fs.readFileSync(backup.manifestPath);
|
|
1607
|
-
const backupSource =
|
|
1475
|
+
const backupSource = restoreStateFromInspection({
|
|
1476
|
+
appPath: backup.backupAppPath,
|
|
1477
|
+
inspection: backupEvidence.inspection,
|
|
1478
|
+
recipe,
|
|
1479
|
+
operations,
|
|
1480
|
+
});
|
|
1608
1481
|
if (backupSource.inspection.state !== "clean") {
|
|
1609
1482
|
throw setupError("PM_STUDIO_BACKUP_INVALID", "Verified PM Studio backup is not an exact clean bundle",
|
|
1610
1483
|
backupSource.inspection.issues);
|
|
@@ -1623,9 +1496,16 @@ export async function runPmStudioRestore({
|
|
|
1623
1496
|
throw setupError("PM_STUDIO_BACKUP_CHANGED",
|
|
1624
1497
|
"PM Studio backup manifest changed during restore; the verified staging bundle was not installed");
|
|
1625
1498
|
}
|
|
1626
|
-
const
|
|
1627
|
-
const
|
|
1499
|
+
const currentBackupEvidence = {};
|
|
1500
|
+
const currentBackup = validateBackup({
|
|
1501
|
+
backupDir,
|
|
1502
|
+
recipe,
|
|
1503
|
+
operations,
|
|
1504
|
+
evidence: currentBackupEvidence,
|
|
1505
|
+
});
|
|
1506
|
+
const unchangedBackup = restoreStateFromInspection({
|
|
1628
1507
|
appPath: currentBackup.backupAppPath,
|
|
1508
|
+
inspection: currentBackupEvidence.inspection,
|
|
1629
1509
|
recipe,
|
|
1630
1510
|
operations,
|
|
1631
1511
|
});
|
|
@@ -3,6 +3,7 @@ import { applyCopilotResponsesRequestPolicies } from "./copilot-responses-policy
|
|
|
3
3
|
import {
|
|
4
4
|
createRequestAbort,
|
|
5
5
|
logRequestFailure,
|
|
6
|
+
MAX_UPSTREAM_CHAT_SUCCESS_BODY_BYTES,
|
|
6
7
|
MAX_UPSTREAM_ERROR_BODY_BYTES,
|
|
7
8
|
readJsonBody,
|
|
8
9
|
readBoundedResponseText,
|
|
@@ -136,7 +137,7 @@ export function createResponsesHandler(options) {
|
|
|
136
137
|
if (!["responses_prepare_timeout", "stream_handshake_timeout", "upstream_timeout"].includes(abort.reason)) {
|
|
137
138
|
return error;
|
|
138
139
|
}
|
|
139
|
-
const activated = imagePressure?.markTimeout?.(prepared
|
|
140
|
+
const activated = imagePressure?.markTimeout?.(responseHistoryPressureRootId(prepared), {
|
|
140
141
|
eligible: imagePressureResult?.pressureEligible === true,
|
|
141
142
|
});
|
|
142
143
|
if (!activated) return error;
|
|
@@ -310,7 +311,11 @@ export function createResponsesHandler(options) {
|
|
|
310
311
|
});
|
|
311
312
|
releaseChatPayload();
|
|
312
313
|
const data = upstream.ok
|
|
313
|
-
? await upstream
|
|
314
|
+
? await readBoundedResponseText(upstream, {
|
|
315
|
+
maxBytes: MAX_UPSTREAM_CHAT_SUCCESS_BODY_BYTES,
|
|
316
|
+
label: "Copilot Chat success body",
|
|
317
|
+
signal: abort.signal,
|
|
318
|
+
})
|
|
314
319
|
: await readBoundedResponseText(upstream, {
|
|
315
320
|
maxBytes: MAX_UPSTREAM_ERROR_BODY_BYTES,
|
|
316
321
|
label: "Copilot Chat error body",
|
package/src/responses-proxy.mjs
CHANGED
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
httpError,
|
|
4
4
|
logRequestFailure,
|
|
5
5
|
MAX_UPSTREAM_ERROR_BODY_BYTES,
|
|
6
|
+
MAX_UPSTREAM_RESPONSES_SUCCESS_BODY_BYTES,
|
|
6
7
|
readBoundedResponseText,
|
|
7
8
|
sendUpstreamError,
|
|
8
9
|
writeOrDrain,
|
|
@@ -265,7 +266,11 @@ export async function proxyCopilotResponses(reqContext, req, res, upstream = cop
|
|
|
265
266
|
}
|
|
266
267
|
} else {
|
|
267
268
|
const data = resp.ok
|
|
268
|
-
? await resp
|
|
269
|
+
? await readBoundedResponseText(resp, {
|
|
270
|
+
maxBytes: MAX_UPSTREAM_RESPONSES_SUCCESS_BODY_BYTES,
|
|
271
|
+
label: "Copilot Responses success body",
|
|
272
|
+
signal: options.signal,
|
|
273
|
+
})
|
|
269
274
|
: await readBoundedResponseText(resp, {
|
|
270
275
|
maxBytes: MAX_UPSTREAM_ERROR_BODY_BYTES,
|
|
271
276
|
label: "Copilot Responses error body",
|
package/src/runtime-config.mjs
CHANGED
|
@@ -7,6 +7,8 @@ export const RUNTIME_DEFAULTS = Object.freeze({
|
|
|
7
7
|
maxBodyBytes: 64 * 1024 * 1024,
|
|
8
8
|
maxDecodedBodyBytes: 128 * 1024 * 1024,
|
|
9
9
|
maxInflightBodyBytes: 32 * 1024 * 1024,
|
|
10
|
+
maxUpstreamChatResponseBytes: 64 * 1024 * 1024,
|
|
11
|
+
maxUpstreamResponsesResponseBytes: 256 * 1024 * 1024,
|
|
10
12
|
maxQueuedRequests: 16,
|
|
11
13
|
requestQueueTimeoutMs: 120 * 1000,
|
|
12
14
|
responseHistoryMaxBytes: 64 * 1024 * 1024,
|
|
@@ -15,26 +17,50 @@ export const RUNTIME_DEFAULTS = Object.freeze({
|
|
|
15
17
|
tokenLockStaleMs: 15 * 60 * 1000,
|
|
16
18
|
});
|
|
17
19
|
|
|
20
|
+
// Node clamps larger timer delays to 1ms. Keep timer parsing separate from
|
|
21
|
+
// byte/count limits so valid large resource budgets remain configurable.
|
|
22
|
+
export const MAX_TIMER_DELAY_MS = 2_147_483_647;
|
|
23
|
+
|
|
18
24
|
export function parsePositiveInteger(value, fallback, max = Number.POSITIVE_INFINITY) {
|
|
19
25
|
const parsed = Number.parseInt(value, 10);
|
|
20
26
|
return Number.isFinite(parsed) && parsed > 0 ? Math.min(parsed, max) : fallback;
|
|
21
27
|
}
|
|
22
28
|
|
|
29
|
+
export function parseSafePositiveInteger(value, fallback) {
|
|
30
|
+
const text = typeof value === "number" ? String(value) : String(value ?? "").trim();
|
|
31
|
+
if (!/^\d+$/.test(text)) return fallback;
|
|
32
|
+
const parsed = Number(text);
|
|
33
|
+
if (!Number.isSafeInteger(parsed) || parsed <= 0) return fallback;
|
|
34
|
+
return parsed;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function parseTimerMs(value, fallback) {
|
|
38
|
+
return Math.min(parseSafePositiveInteger(value, fallback), MAX_TIMER_DELAY_MS);
|
|
39
|
+
}
|
|
40
|
+
|
|
23
41
|
export function loadRuntimeConfig(env = process.env) {
|
|
24
42
|
return Object.freeze({
|
|
25
|
-
upstreamTimeoutMs:
|
|
26
|
-
streamHandshakeTimeoutMs:
|
|
27
|
-
streamIdleTimeoutMs:
|
|
28
|
-
requestBodyTimeoutMs:
|
|
43
|
+
upstreamTimeoutMs: parseTimerMs(env.CCDX_UPSTREAM_TIMEOUT_MS, RUNTIME_DEFAULTS.upstreamTimeoutMs),
|
|
44
|
+
streamHandshakeTimeoutMs: parseTimerMs(env.CCDX_STREAM_HANDSHAKE_TIMEOUT_MS, RUNTIME_DEFAULTS.streamHandshakeTimeoutMs),
|
|
45
|
+
streamIdleTimeoutMs: parseTimerMs(env.CCDX_STREAM_IDLE_TIMEOUT_MS, RUNTIME_DEFAULTS.streamIdleTimeoutMs),
|
|
46
|
+
requestBodyTimeoutMs: parseTimerMs(env.CCDX_REQUEST_BODY_TIMEOUT_MS, RUNTIME_DEFAULTS.requestBodyTimeoutMs),
|
|
29
47
|
maxSseBufferBytes: parsePositiveInteger(env.CCDX_MAX_SSE_BUFFER_BYTES, RUNTIME_DEFAULTS.maxSseBufferBytes),
|
|
30
48
|
maxBodyBytes: parsePositiveInteger(env.CCDX_MAX_BODY_BYTES, RUNTIME_DEFAULTS.maxBodyBytes),
|
|
31
49
|
maxDecodedBodyBytes: parsePositiveInteger(env.CCDX_MAX_DECODED_BODY_BYTES, RUNTIME_DEFAULTS.maxDecodedBodyBytes),
|
|
32
50
|
maxInflightBodyBytes: parsePositiveInteger(env.CCDX_MAX_INFLIGHT_BODY_BYTES, RUNTIME_DEFAULTS.maxInflightBodyBytes),
|
|
51
|
+
maxUpstreamChatResponseBytes: parsePositiveInteger(
|
|
52
|
+
env.CCDX_MAX_UPSTREAM_CHAT_RESPONSE_BYTES,
|
|
53
|
+
RUNTIME_DEFAULTS.maxUpstreamChatResponseBytes,
|
|
54
|
+
),
|
|
55
|
+
maxUpstreamResponsesResponseBytes: parsePositiveInteger(
|
|
56
|
+
env.CCDX_MAX_UPSTREAM_RESPONSES_RESPONSE_BYTES,
|
|
57
|
+
RUNTIME_DEFAULTS.maxUpstreamResponsesResponseBytes,
|
|
58
|
+
),
|
|
33
59
|
maxQueuedRequests: parsePositiveInteger(env.CCDX_MAX_QUEUED_REQUESTS, RUNTIME_DEFAULTS.maxQueuedRequests),
|
|
34
|
-
requestQueueTimeoutMs:
|
|
60
|
+
requestQueueTimeoutMs: parseTimerMs(env.CCDX_REQUEST_QUEUE_TIMEOUT_MS, RUNTIME_DEFAULTS.requestQueueTimeoutMs),
|
|
35
61
|
responseHistoryMaxBytes: parsePositiveInteger(env.CCDX_RESPONSE_HISTORY_MAX_BYTES, RUNTIME_DEFAULTS.responseHistoryMaxBytes),
|
|
36
62
|
responseHistoryMaxEntries: parsePositiveInteger(env.CCDX_RESPONSE_HISTORY_MAX_ENTRIES, RUNTIME_DEFAULTS.responseHistoryMaxEntries),
|
|
37
|
-
tokenLockTimeoutMs:
|
|
38
|
-
tokenLockStaleMs:
|
|
63
|
+
tokenLockTimeoutMs: parseSafePositiveInteger(env.CCDX_TOKEN_LOCK_TIMEOUT_MS, RUNTIME_DEFAULTS.tokenLockTimeoutMs),
|
|
64
|
+
tokenLockStaleMs: parseSafePositiveInteger(env.CCDX_TOKEN_LOCK_STALE_MS, RUNTIME_DEFAULTS.tokenLockStaleMs),
|
|
39
65
|
});
|
|
40
66
|
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { Worker } from "node:worker_threads";
|
|
2
|
+
import { anthropicTokenText } from "./anthropic.mjs";
|
|
3
|
+
import { debugLog } from "./log.mjs";
|
|
4
|
+
|
|
5
|
+
export const DEFAULT_TOKEN_COUNTER_TIMEOUT_MS = 120 * 1000;
|
|
6
|
+
export const DEFAULT_TOKEN_COUNTER_MAX_QUEUED = 16;
|
|
7
|
+
|
|
8
|
+
function serviceError(message, statusCode) {
|
|
9
|
+
return Object.assign(new Error(message), { statusCode });
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const DIAGNOSTIC_PHASES = new Set(["create", "worker", "error", "exit", "postMessage"]);
|
|
13
|
+
const DIAGNOSTIC_ERROR_NAMES = new Set(["Error", "TypeError", "RangeError", "SyntaxError", "WorkerExit"]);
|
|
14
|
+
|
|
15
|
+
export function tokenCounterWorkerDiagnostic(phase, error) {
|
|
16
|
+
const safePhase = DIAGNOSTIC_PHASES.has(phase) ? phase : "unknown";
|
|
17
|
+
let rawName;
|
|
18
|
+
let rawCode;
|
|
19
|
+
try { rawName = error?.name; } catch {}
|
|
20
|
+
try { rawCode = error?.code; } catch {}
|
|
21
|
+
const safeName = DIAGNOSTIC_ERROR_NAMES.has(rawName) ? rawName : "Error";
|
|
22
|
+
if (typeof rawCode === "number") rawCode = String(rawCode);
|
|
23
|
+
const safeCode = typeof rawCode === "string" && /^(?:\d+|[A-Z][A-Z0-9_]{0,63})$/.test(rawCode)
|
|
24
|
+
? rawCode
|
|
25
|
+
: "unknown";
|
|
26
|
+
return `Token counter worker failure: phase=${safePhase} name=${safeName} code=${safeCode}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function positiveInteger(value, fallback) {
|
|
30
|
+
return Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function abortError(signal) {
|
|
34
|
+
if (signal?.reason instanceof Error) return signal.reason;
|
|
35
|
+
const error = new Error("The operation was aborted");
|
|
36
|
+
error.name = "AbortError";
|
|
37
|
+
return error;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function tokenCounterWorkerExecArgv(execArgv = process.execArgv) {
|
|
41
|
+
const filtered = [];
|
|
42
|
+
for (let index = 0; index < execArgv.length; index += 1) {
|
|
43
|
+
const value = execArgv[index];
|
|
44
|
+
if (value === "--input-type") {
|
|
45
|
+
index += 1;
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
if (value.startsWith("--input-type=")) continue;
|
|
49
|
+
filtered.push(value);
|
|
50
|
+
}
|
|
51
|
+
return filtered;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function createTokenCounterWorker() {
|
|
55
|
+
const execArgv = tokenCounterWorkerExecArgv();
|
|
56
|
+
const options = execArgv.length === process.execArgv.length ? undefined : { execArgv };
|
|
57
|
+
return new Worker(new URL("./token-counter-worker.mjs", import.meta.url), options);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function createTokenCounterService({
|
|
61
|
+
timeoutMs = DEFAULT_TOKEN_COUNTER_TIMEOUT_MS,
|
|
62
|
+
maxQueued = DEFAULT_TOKEN_COUNTER_MAX_QUEUED,
|
|
63
|
+
workerFactory = createTokenCounterWorker,
|
|
64
|
+
} = {}) {
|
|
65
|
+
timeoutMs = positiveInteger(timeoutMs, DEFAULT_TOKEN_COUNTER_TIMEOUT_MS);
|
|
66
|
+
maxQueued = positiveInteger(maxQueued, DEFAULT_TOKEN_COUNTER_MAX_QUEUED);
|
|
67
|
+
const queue = [];
|
|
68
|
+
let worker = null;
|
|
69
|
+
let active = null;
|
|
70
|
+
let nextId = 1;
|
|
71
|
+
let closed = false;
|
|
72
|
+
|
|
73
|
+
const finish = (job, callback, value) => {
|
|
74
|
+
if (job.finished) return;
|
|
75
|
+
job.finished = true;
|
|
76
|
+
if (job.timer) clearTimeout(job.timer);
|
|
77
|
+
job.signal?.removeEventListener("abort", job.onAbort);
|
|
78
|
+
callback(value);
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const retireWorker = () => {
|
|
82
|
+
const retired = worker;
|
|
83
|
+
worker = null;
|
|
84
|
+
if (!retired) return;
|
|
85
|
+
retired.removeAllListeners();
|
|
86
|
+
retired.on("error", () => {});
|
|
87
|
+
Promise.resolve(retired.terminate()).catch(() => {});
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const pump = () => {
|
|
91
|
+
if (closed || active || queue.length === 0) return;
|
|
92
|
+
if (!worker) {
|
|
93
|
+
try {
|
|
94
|
+
worker = workerFactory();
|
|
95
|
+
} catch (error) {
|
|
96
|
+
debugLog(tokenCounterWorkerDiagnostic("create", error));
|
|
97
|
+
const job = queue.shift();
|
|
98
|
+
if (job) finish(job, job.reject, serviceError("Token counter worker failed", 500));
|
|
99
|
+
queueMicrotask(pump);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
worker.on("message", (message) => {
|
|
103
|
+
if (!active || message?.id !== active.id) return;
|
|
104
|
+
const job = active;
|
|
105
|
+
active = null;
|
|
106
|
+
if (message.error) {
|
|
107
|
+
debugLog(tokenCounterWorkerDiagnostic("worker", message.error));
|
|
108
|
+
retireWorker();
|
|
109
|
+
finish(job, job.reject, serviceError("Token counter worker failed", 500));
|
|
110
|
+
} else {
|
|
111
|
+
finish(job, job.resolve, message.result);
|
|
112
|
+
}
|
|
113
|
+
pump();
|
|
114
|
+
});
|
|
115
|
+
worker.on("error", (error) => {
|
|
116
|
+
debugLog(tokenCounterWorkerDiagnostic("error", error));
|
|
117
|
+
const job = active;
|
|
118
|
+
active = null;
|
|
119
|
+
retireWorker();
|
|
120
|
+
if (job) finish(job, job.reject, serviceError("Token counter worker failed", 500));
|
|
121
|
+
pump();
|
|
122
|
+
});
|
|
123
|
+
worker.on("exit", (code) => {
|
|
124
|
+
if (active) debugLog(tokenCounterWorkerDiagnostic("exit", { name: "WorkerExit", code }));
|
|
125
|
+
const job = active;
|
|
126
|
+
active = null;
|
|
127
|
+
worker = null;
|
|
128
|
+
if (job) finish(job, job.reject, serviceError("Token counter worker failed", 500));
|
|
129
|
+
pump();
|
|
130
|
+
});
|
|
131
|
+
worker.unref?.();
|
|
132
|
+
}
|
|
133
|
+
const job = queue.shift();
|
|
134
|
+
if (!job || job.finished) return pump();
|
|
135
|
+
active = job;
|
|
136
|
+
try {
|
|
137
|
+
worker.postMessage({ id: job.id, text: job.text });
|
|
138
|
+
job.text = null;
|
|
139
|
+
} catch (error) {
|
|
140
|
+
debugLog(tokenCounterWorkerDiagnostic("postMessage", error));
|
|
141
|
+
active = null;
|
|
142
|
+
retireWorker();
|
|
143
|
+
finish(job, job.reject, serviceError("Token counter worker failed", 500));
|
|
144
|
+
pump();
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
const cancel = (job, error) => {
|
|
149
|
+
if (job.finished) return;
|
|
150
|
+
const queuedIndex = queue.indexOf(job);
|
|
151
|
+
if (queuedIndex >= 0) queue.splice(queuedIndex, 1);
|
|
152
|
+
if (active === job) {
|
|
153
|
+
active = null;
|
|
154
|
+
retireWorker();
|
|
155
|
+
}
|
|
156
|
+
finish(job, job.reject, error);
|
|
157
|
+
pump();
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
count(body, { signal, deadlineMs = timeoutMs } = {}) {
|
|
162
|
+
if (closed) return Promise.reject(serviceError("Token counter service is closed", 503));
|
|
163
|
+
if (signal?.aborted) return Promise.reject(abortError(signal));
|
|
164
|
+
let text;
|
|
165
|
+
try {
|
|
166
|
+
text = anthropicTokenText(body);
|
|
167
|
+
} catch {
|
|
168
|
+
return Promise.reject(serviceError("Invalid count_tokens request", 400));
|
|
169
|
+
}
|
|
170
|
+
if (active && queue.length >= maxQueued) {
|
|
171
|
+
return Promise.reject(serviceError(`Token counter queue is full (${maxQueued} waiting)`, 503));
|
|
172
|
+
}
|
|
173
|
+
return new Promise((resolve, reject) => {
|
|
174
|
+
deadlineMs = positiveInteger(deadlineMs, timeoutMs);
|
|
175
|
+
const job = {
|
|
176
|
+
id: nextId++,
|
|
177
|
+
text,
|
|
178
|
+
signal,
|
|
179
|
+
resolve,
|
|
180
|
+
reject,
|
|
181
|
+
finished: false,
|
|
182
|
+
timer: null,
|
|
183
|
+
onAbort: null,
|
|
184
|
+
};
|
|
185
|
+
job.onAbort = () => cancel(job, abortError(signal));
|
|
186
|
+
signal?.addEventListener("abort", job.onAbort, { once: true });
|
|
187
|
+
job.timer = setTimeout(() => {
|
|
188
|
+
cancel(job, serviceError(`Token counting timed out after ${deadlineMs}ms`, 504));
|
|
189
|
+
}, deadlineMs);
|
|
190
|
+
queue.push(job);
|
|
191
|
+
if (signal?.aborted) job.onAbort();
|
|
192
|
+
else pump();
|
|
193
|
+
});
|
|
194
|
+
},
|
|
195
|
+
close() {
|
|
196
|
+
if (closed) return;
|
|
197
|
+
closed = true;
|
|
198
|
+
const error = serviceError("Token counter service is closed", 503);
|
|
199
|
+
if (active) {
|
|
200
|
+
const job = active;
|
|
201
|
+
active = null;
|
|
202
|
+
finish(job, job.reject, error);
|
|
203
|
+
}
|
|
204
|
+
for (const job of queue.splice(0)) finish(job, job.reject, error);
|
|
205
|
+
retireWorker();
|
|
206
|
+
},
|
|
207
|
+
stats() {
|
|
208
|
+
return { active: active ? 1 : 0, queued: queue.length, worker: worker ? 1 : 0 };
|
|
209
|
+
},
|
|
210
|
+
};
|
|
211
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { parentPort } from "node:worker_threads";
|
|
2
|
+
import { countTokenText } from "./anthropic.mjs";
|
|
3
|
+
|
|
4
|
+
parentPort.on("message", async ({ id, text }) => {
|
|
5
|
+
try {
|
|
6
|
+
parentPort.postMessage({ id, result: await countTokenText(text) });
|
|
7
|
+
} catch (error) {
|
|
8
|
+
parentPort.postMessage({
|
|
9
|
+
id,
|
|
10
|
+
error: {
|
|
11
|
+
name: typeof error?.name === "string" ? error.name : "Error",
|
|
12
|
+
code: typeof error?.code === "string" ? error.code : "unknown",
|
|
13
|
+
},
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
});
|