copilot-relay 0.4.6 → 0.4.8
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 -2
- package/dist/main.js +194 -84
- package/dist/main.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -55,7 +55,7 @@ running daemon. Add `--details` for safe failure evidence and private replay hin
|
|
|
55
55
|
Use `copilot-relay status --deep` for daemon health, and `copilot-relay stop` when finished.
|
|
56
56
|
|
|
57
57
|
Check how well prompt caching works per model and upstream route. The report reads
|
|
58
|
-
only local logs and
|
|
58
|
+
only local logs and shows a hit rate below the goal, 95% by default, in red:
|
|
59
59
|
|
|
60
60
|
```sh
|
|
61
61
|
copilot-relay cache # last 24 hours
|
|
@@ -72,7 +72,7 @@ scripts. It asks GitHub with the stored token, so no relay needs to run.
|
|
|
72
72
|
|
|
73
73
|
- [Configure models and deep checks](wiki/EN-Configuration.md) · [中文](wiki/ZH-Configuration.md)
|
|
74
74
|
- Run at login: [macOS](wiki/EN-macOS-LaunchAgent.md) · [Windows](wiki/EN-Windows-Service.md) · [Linux](wiki/EN-Linux-systemd.md)
|
|
75
|
-
- [Troubleshoot](wiki/EN-Logging-Troubleshooting.md) · [Understand the architecture](wiki/EN-Architecture.md) · [Contribute](wiki/EN-Development.md)
|
|
75
|
+
- [Commands](wiki/EN-Commands.md) · [Prompt caching](wiki/EN-Prompt-Caching.md) · [Troubleshoot](wiki/EN-Logging-Troubleshooting.md) · [Understand the architecture](wiki/EN-Architecture.md) · [Contribute](wiki/EN-Development.md)
|
|
76
76
|
|
|
77
77
|
Unofficial research project; not affiliated with GitHub or Anthropic. Upstream
|
|
78
78
|
services and compatibility can change. Model and tool support are not guaranteed;
|
package/dist/main.js
CHANGED
|
@@ -49,6 +49,24 @@ var PromptTooLongError = class extends HTTPError {
|
|
|
49
49
|
}
|
|
50
50
|
};
|
|
51
51
|
|
|
52
|
+
//#endregion
|
|
53
|
+
//#region src/lib/file-identity.ts
|
|
54
|
+
/** True when two stats of a path name the same file: the same device and file index. */
|
|
55
|
+
const sameFile$1 = (left, right) => left.dev === right.dev && left.ino === right.ino;
|
|
56
|
+
/**
|
|
57
|
+
* True when a stat of an open handle and a stat of its path name the same file.
|
|
58
|
+
*
|
|
59
|
+
* On Windows the two can disagree on `dev` for the same file: on Node 22.13.1 a path stat
|
|
60
|
+
* reports `dev` 0 while a handle stat reports the volume serial number. There a `dev` of 0 on
|
|
61
|
+
* either side counts as unreported and the file index (`ino`) alone decides, which is weaker
|
|
62
|
+
* evidence than both. Two nonzero `dev` values must still match, and other platforms compare both.
|
|
63
|
+
*/
|
|
64
|
+
const sameOpenedFile = (left, right, platform = process.platform) => {
|
|
65
|
+
if (left.ino !== right.ino) return false;
|
|
66
|
+
if (platform === "win32" && (left.dev === 0 || right.dev === 0)) return true;
|
|
67
|
+
return left.dev === right.dev;
|
|
68
|
+
};
|
|
69
|
+
|
|
52
70
|
//#endregion
|
|
53
71
|
//#region src/lib/paths.ts
|
|
54
72
|
const homeDir = os.homedir();
|
|
@@ -220,7 +238,7 @@ const formatUpstreamProxyForDisplay = (upstreamProxy) => {
|
|
|
220
238
|
* `status` prints config values directly to stdout. A config string carrying
|
|
221
239
|
* raw control bytes could clear the line and paint status rows the relay never
|
|
222
240
|
* produced. The config file is user-owned, so this is hardening rather than a
|
|
223
|
-
* vulnerability — but it costs nothing to make the output non-forgeable.
|
|
241
|
+
* vulnerability — but it costs nothing to make the output non-forgeable.
|
|
224
242
|
*/
|
|
225
243
|
const sanitizeTerminalString = (text$1) => text$1.replace(ansiEscapePattern, "").replace(controlBytePattern, "");
|
|
226
244
|
/**
|
|
@@ -371,7 +389,6 @@ const formatLogValue = (value) => typeof value === "string" ? value : inspect(va
|
|
|
371
389
|
});
|
|
372
390
|
const escapeLogLineSeparators = (value) => value.replaceAll("\r", "\\r").replaceAll("\n", "\\n").replaceAll(String.fromCodePoint(8232), "\\u2028").replaceAll(String.fromCodePoint(8233), "\\u2029");
|
|
373
391
|
const isMissing$2 = (error) => error.code === "ENOENT";
|
|
374
|
-
const sameFile$2 = (left, right) => left.dev === right.dev && left.ino === right.ino;
|
|
375
392
|
const ensurePrivateDirectory = async (directory) => {
|
|
376
393
|
await fs.mkdir(directory, { mode: 448 }).catch((error) => {
|
|
377
394
|
if (error.code !== "EEXIST") throw error;
|
|
@@ -382,7 +399,7 @@ const ensurePrivateDirectory = async (directory) => {
|
|
|
382
399
|
const handle = await fs.open(directory, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
|
|
383
400
|
try {
|
|
384
401
|
const opened = await handle.stat();
|
|
385
|
-
if (!opened.isDirectory() || !
|
|
402
|
+
if (!opened.isDirectory() || !sameOpenedFile(observed, opened)) throw new Error("Log directory changed while opening");
|
|
386
403
|
await handle.chmod(448);
|
|
387
404
|
} finally {
|
|
388
405
|
await handle.close();
|
|
@@ -401,10 +418,10 @@ let activeLog;
|
|
|
401
418
|
let logIdleClose;
|
|
402
419
|
const maxLogWriteBytes = 256 * 1024;
|
|
403
420
|
const hasMode = (stats, mode) => process.platform === "win32" || (stats.mode & 511) === mode;
|
|
404
|
-
const isSameDirectory = (current, checked) => current !== void 0 && current.isDirectory() && sameFile$
|
|
421
|
+
const isSameDirectory = (current, checked) => current !== void 0 && current.isDirectory() && sameFile$1(current, checked) && hasMode(current, 448);
|
|
405
422
|
const isStillActive = async (active) => {
|
|
406
423
|
const [current, ...directories] = await Promise.all([active.filePath, ...logDirectories].map((target) => fs.lstat(target).catch(() => void 0)));
|
|
407
|
-
return current !== void 0 && current.isFile() && current.nlink === 1 &&
|
|
424
|
+
return current !== void 0 && current.isFile() && current.nlink === 1 && sameOpenedFile(current, active.identity) && hasMode(current, 384) && active.directories.every((checked, index) => isSameDirectory(directories[index], checked));
|
|
408
425
|
};
|
|
409
426
|
const openPrivateLog = async (filePath, directories) => {
|
|
410
427
|
const observed = await fs.lstat(filePath).catch((error) => {
|
|
@@ -416,7 +433,7 @@ const openPrivateLog = async (filePath, directories) => {
|
|
|
416
433
|
try {
|
|
417
434
|
const opened = await handle.stat();
|
|
418
435
|
const current = await fs.lstat(filePath);
|
|
419
|
-
if (!opened.isFile() || opened.nlink !== 1 || current.isSymbolicLink() || !
|
|
436
|
+
if (!opened.isFile() || opened.nlink !== 1 || current.isSymbolicLink() || !sameOpenedFile(opened, current) || observed && !sameOpenedFile(observed, opened)) throw new Error("Log file changed while opening");
|
|
420
437
|
if (process.platform !== "win32") await handle.chmod(384);
|
|
421
438
|
return {
|
|
422
439
|
filePath,
|
|
@@ -513,7 +530,7 @@ const cleanupLogsIfDue = async () => {
|
|
|
513
530
|
* Each argument is rendered exactly once and scrubbed once, and the identical
|
|
514
531
|
* array of strings goes to both the file and the console. Scrubbing only on the
|
|
515
532
|
* way to disk would leave the original object on the console - the screen a
|
|
516
|
-
* user screenshots into an issue - so the two sinks must not diverge.
|
|
533
|
+
* user screenshots into an issue - so the two sinks must not diverge.
|
|
517
534
|
*
|
|
518
535
|
* Rendering is skipped only when neither sink would emit, so the cost matches
|
|
519
536
|
* the old behavior at info level. The console gate is read from consola rather
|
|
@@ -595,7 +612,7 @@ var InvalidProxyEnvironmentError = class extends Error {
|
|
|
595
612
|
/**
|
|
596
613
|
* Builds the dispatcher for one resolved upstreamProxy value.
|
|
597
614
|
*
|
|
598
|
-
* Empty connects directly
|
|
615
|
+
* Empty connects directly. "env" hands HTTPS_PROXY, HTTP_PROXY
|
|
599
616
|
* and NO_PROXY, in upper or lower case, to EnvHttpProxyAgent. Anything else is a proxy URL that
|
|
600
617
|
* normalizeUpstreamProxy has already validated.
|
|
601
618
|
*/
|
|
@@ -1373,7 +1390,7 @@ const normalizeApiKey = (value) => {
|
|
|
1373
1390
|
* through. None of them can be found again in arbitrary log text: practical URL
|
|
1374
1391
|
* detection anchors on a literal "://", and loosening that to chase a bare
|
|
1375
1392
|
* "https:" would start matching ordinary prose. Requiring the authority prefix
|
|
1376
|
-
* is the one boundary where this has a definite answer.
|
|
1393
|
+
* is the one boundary where this has a definite answer.
|
|
1377
1394
|
*/
|
|
1378
1395
|
const conventionalHttpUrlPattern = /^https?:\/\//i;
|
|
1379
1396
|
/**
|
|
@@ -1390,7 +1407,7 @@ const conventionalHttpUrlPattern = /^https?:\/\//i;
|
|
|
1390
1407
|
*
|
|
1391
1408
|
* Rejecting the raw byte costs nothing: every one of these has a
|
|
1392
1409
|
* percent-encoded spelling that is unambiguous in log text and is accepted
|
|
1393
|
-
* here unchanged.
|
|
1410
|
+
* here unchanged.
|
|
1394
1411
|
*/
|
|
1395
1412
|
const unsafeRawDelimiterPattern = /['"`<>\s\u0000-\u001F\u007F]/;
|
|
1396
1413
|
/**
|
|
@@ -1404,7 +1421,7 @@ const copilotBaseUrlDelimiterMessage = "Invalid copilotBaseUrl: quotes, backtick
|
|
|
1404
1421
|
/**
|
|
1405
1422
|
* Validates copilotBaseUrl as a conventional HTTP(S) URL without credentials.
|
|
1406
1423
|
*
|
|
1407
|
-
* Four rules
|
|
1424
|
+
* Four rules:
|
|
1408
1425
|
*
|
|
1409
1426
|
* The value is concatenated as `${base}${path}` for every upstream call, so it
|
|
1410
1427
|
* must be something Undici will accept. A relative or non-HTTP value fails at
|
|
@@ -2146,36 +2163,6 @@ const dataColumns = [
|
|
|
2146
2163
|
alignRight: true,
|
|
2147
2164
|
cell: (row) => formatCount(row.requests)
|
|
2148
2165
|
},
|
|
2149
|
-
{
|
|
2150
|
-
header: "UNKNOWN",
|
|
2151
|
-
alignRight: true,
|
|
2152
|
-
cell: (row) => formatCount(row.unknownCacheRequests)
|
|
2153
|
-
},
|
|
2154
|
-
{
|
|
2155
|
-
header: "0-READ",
|
|
2156
|
-
alignRight: true,
|
|
2157
|
-
cell: (row) => formatCount(row.zeroCacheReadRequests)
|
|
2158
|
-
},
|
|
2159
|
-
{
|
|
2160
|
-
header: "INPUT",
|
|
2161
|
-
alignRight: true,
|
|
2162
|
-
cell: (row) => formatCount(row.totalInputTokens)
|
|
2163
|
-
},
|
|
2164
|
-
{
|
|
2165
|
-
header: "CACHE READ",
|
|
2166
|
-
alignRight: true,
|
|
2167
|
-
cell: (row) => formatCount(row.cacheReadTokens)
|
|
2168
|
-
},
|
|
2169
|
-
{
|
|
2170
|
-
header: "UNCACHED",
|
|
2171
|
-
alignRight: true,
|
|
2172
|
-
cell: (row) => formatCount(row.uncachedInputTokens)
|
|
2173
|
-
},
|
|
2174
|
-
{
|
|
2175
|
-
header: "CACHE WRITE",
|
|
2176
|
-
alignRight: true,
|
|
2177
|
-
cell: (row) => row.cacheWriteTokens === null ? "-" : formatCount(row.cacheWriteTokens)
|
|
2178
|
-
},
|
|
2179
2166
|
{
|
|
2180
2167
|
header: "HIT RATE",
|
|
2181
2168
|
alignRight: true,
|
|
@@ -2190,12 +2177,10 @@ const renderTable = (rows, view, color) => {
|
|
|
2190
2177
|
const pad = (text$1, index) => columns[index].alignRight ? text$1.padStart(widths[index]) : text$1.padEnd(widths[index]);
|
|
2191
2178
|
const header = columns.map((column, index) => pad(column.header, index)).join(" ");
|
|
2192
2179
|
const body = rows.map((row, rowIndex) => {
|
|
2193
|
-
|
|
2180
|
+
return ` ${columns.map((column, index) => {
|
|
2194
2181
|
const text$1 = pad(cells[rowIndex][index], index);
|
|
2195
2182
|
return column.tone === void 0 ? text$1 : colorText(text$1, column.tone(row), color);
|
|
2196
|
-
})
|
|
2197
|
-
const marker = row.belowGoal ? ` ${colorText("below goal", "bad", color)}` : "";
|
|
2198
|
-
return ` ${rendered.join(" ")}${marker}`;
|
|
2183
|
+
}).join(" ")}`;
|
|
2199
2184
|
});
|
|
2200
2185
|
return [` ${header}`, ...body];
|
|
2201
2186
|
};
|
|
@@ -2208,7 +2193,7 @@ const formatLocalTime = (date) => `${formatLogDate(date)} ${twoDigits(date.getHo
|
|
|
2208
2193
|
const describeScope = (options) => {
|
|
2209
2194
|
return `${options.since === void 0 ? "across all retained logs" : `since ${formatLocalTime(options.since)} local time`}${options.model === void 0 ? "" : `, for models matching "${terminalText(options.model)}"`}`;
|
|
2210
2195
|
};
|
|
2211
|
-
const unknownNote =
|
|
2196
|
+
const unknownNote = (calls) => `HIT RATE leaves out ${formatCount(calls)} ${calls === 1 ? "call" : "calls"} that logged no cache_read_input_tokens.`;
|
|
2212
2197
|
/** The report as text lines. Kept free of IO, so it is tested without a terminal. */
|
|
2213
2198
|
const renderCacheReport = (rows, options, color = false) => {
|
|
2214
2199
|
if (rows.length === 0) return [
|
|
@@ -2221,7 +2206,8 @@ const renderCacheReport = (rows, options, color = false) => {
|
|
|
2221
2206
|
"",
|
|
2222
2207
|
...renderTable(rows, options.view, color)
|
|
2223
2208
|
];
|
|
2224
|
-
|
|
2209
|
+
const unknownCalls = rows.reduce((total, row) => total + row.unknownCacheRequests, 0);
|
|
2210
|
+
if (unknownCalls > 0) lines.push("", unknownNote(unknownCalls));
|
|
2225
2211
|
return lines;
|
|
2226
2212
|
};
|
|
2227
2213
|
|
|
@@ -2255,7 +2241,7 @@ const cache = defineCommand({
|
|
|
2255
2241
|
},
|
|
2256
2242
|
goal: {
|
|
2257
2243
|
default: String(defaultGoal),
|
|
2258
|
-
description: "Hit-rate goal in percent, with at most two decimals.
|
|
2244
|
+
description: "Hit-rate goal in percent, with at most two decimals. A hit rate below it is shown in red.",
|
|
2259
2245
|
type: "string"
|
|
2260
2246
|
}
|
|
2261
2247
|
},
|
|
@@ -2327,7 +2313,6 @@ const markDiscardedResponse = (response) => {
|
|
|
2327
2313
|
};
|
|
2328
2314
|
const credentialPattern = /(?:gh[pousr]_|github_pat_|sk-|eyJ)[A-Za-z0-9_-]+/;
|
|
2329
2315
|
const uuidPattern = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/;
|
|
2330
|
-
const sameFile$1 = (left, right) => left.dev === right.dev && left.ino === right.ino;
|
|
2331
2316
|
const noFollow = process.platform === "win32" ? 0 : fs.constants.O_NOFOLLOW;
|
|
2332
2317
|
const safeValue = (value) => typeof value === "string" && /^[A-Za-z0-9_.:/[\]-]{1,160}$/.test(value) && !/(?:gh[pousr]_|github_pat_|sk-|eyJ)[A-Za-z0-9_-]+/.test(value) ? value : void 0;
|
|
2333
2318
|
const errorName = (error) => error instanceof Error && [
|
|
@@ -2475,7 +2460,7 @@ const privateDirectory = async (directory) => {
|
|
|
2475
2460
|
const handle = await fs.open(directory, fs.constants.O_RDONLY | noFollow);
|
|
2476
2461
|
try {
|
|
2477
2462
|
const opened = await handle.stat();
|
|
2478
|
-
if (!opened.isDirectory() || !
|
|
2463
|
+
if (!opened.isDirectory() || !sameOpenedFile(observed, opened)) throw new Error("Capture directory changed.");
|
|
2479
2464
|
await handle.chmod(448);
|
|
2480
2465
|
return opened;
|
|
2481
2466
|
} finally {
|
|
@@ -2487,7 +2472,7 @@ const createPrivateFile = async (file) => {
|
|
|
2487
2472
|
try {
|
|
2488
2473
|
const opened = await handle.stat();
|
|
2489
2474
|
const current = await fs.lstat(file);
|
|
2490
|
-
if (!opened.isFile() || opened.nlink !== 1 || current.isSymbolicLink() || !
|
|
2475
|
+
if (!opened.isFile() || opened.nlink !== 1 || current.isSymbolicLink() || !sameOpenedFile(opened, current)) throw new Error("Unsafe capture file.");
|
|
2491
2476
|
return handle;
|
|
2492
2477
|
} catch (error) {
|
|
2493
2478
|
await handle.close();
|
|
@@ -2945,7 +2930,7 @@ var RequestTrace = class RequestTrace {
|
|
|
2945
2930
|
}
|
|
2946
2931
|
const opened = await handle.stat();
|
|
2947
2932
|
const current = await fs.lstat(file);
|
|
2948
|
-
if (!current.isFile() || current.isSymbolicLink() || opened.nlink !== 1 || !
|
|
2933
|
+
if (!current.isFile() || current.isSymbolicLink() || opened.nlink !== 1 || !sameOpenedFile(opened, current)) throw new Error("Capture file changed.");
|
|
2949
2934
|
await handle.writeFile(buffer);
|
|
2950
2935
|
}).catch(() => {
|
|
2951
2936
|
this.manifest.captureError = "capture_write_failed";
|
|
@@ -2998,7 +2983,7 @@ var RequestTrace = class RequestTrace {
|
|
|
2998
2983
|
await this.checkDirectories();
|
|
2999
2984
|
const manifest = path.join(this.captureDirectory, "meta.json");
|
|
3000
2985
|
const current = await fs.lstat(manifest);
|
|
3001
|
-
if (!current.isFile() || current.isSymbolicLink() || current.nlink !== 1 || !this.metadataStat || !
|
|
2986
|
+
if (!current.isFile() || current.isSymbolicLink() || current.nlink !== 1 || !this.metadataStat || !sameOpenedFile(current, this.metadataStat) || current.size !== this.metadataStat.size || current.mtimeMs !== this.metadataStat.mtimeMs || current.ctimeMs !== this.metadataStat.ctimeMs) throw new Error("Unsafe capture metadata.");
|
|
3002
2987
|
await fs.rename(path.join(this.captureDirectory, "meta.next.json"), manifest);
|
|
3003
2988
|
}
|
|
3004
2989
|
}).catch(() => {
|
|
@@ -3036,7 +3021,7 @@ const readRetentionManifest = async (file) => {
|
|
|
3036
3021
|
const handle = await fs.open(file, fs.constants.O_RDONLY | noFollow);
|
|
3037
3022
|
try {
|
|
3038
3023
|
const opened = await handle.stat();
|
|
3039
|
-
if (!
|
|
3024
|
+
if (!sameOpenedFile(observed, opened) || opened.nlink !== 1 || opened.size !== observed.size) throw new Error("Capture metadata changed.");
|
|
3040
3025
|
const bytes = Buffer.alloc(opened.size);
|
|
3041
3026
|
let offset = 0;
|
|
3042
3027
|
while (offset < bytes.length) {
|
|
@@ -3045,7 +3030,7 @@ const readRetentionManifest = async (file) => {
|
|
|
3045
3030
|
offset += bytesRead;
|
|
3046
3031
|
}
|
|
3047
3032
|
const current = await fs.lstat(file);
|
|
3048
|
-
if (!
|
|
3033
|
+
if (!sameOpenedFile(opened, current) || current.size !== opened.size || current.mtimeMs !== opened.mtimeMs) throw new Error("Capture metadata changed.");
|
|
3049
3034
|
return JSON.parse(bytes.toString("utf8"));
|
|
3050
3035
|
} finally {
|
|
3051
3036
|
await handle.close();
|
|
@@ -3358,10 +3343,10 @@ const pinCopilotModelCatalog = (config) => {
|
|
|
3358
3343
|
pinnedCatalogs.set(config, config.modelCatalog);
|
|
3359
3344
|
};
|
|
3360
3345
|
const pendingCatalogs = /* @__PURE__ */ new WeakMap();
|
|
3361
|
-
const isRecord$
|
|
3346
|
+
const isRecord$5 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
3362
3347
|
const isPositiveInteger = (value) => typeof value === "number" && Number.isSafeInteger(value) && value > 0;
|
|
3363
3348
|
function parseModelTokenLimits(value) {
|
|
3364
|
-
if (!isRecord$
|
|
3349
|
+
if (!isRecord$5(value)) return;
|
|
3365
3350
|
const context = value.max_context_window_tokens;
|
|
3366
3351
|
const prompt = value.max_prompt_tokens;
|
|
3367
3352
|
const output = value.max_output_tokens;
|
|
@@ -3417,13 +3402,13 @@ async function loadCopilotModelCatalog(config) {
|
|
|
3417
3402
|
});
|
|
3418
3403
|
if (!response.ok) throw new HTTPError("Failed to validate upstream models", response);
|
|
3419
3404
|
const payload = await readCopilotJson(response, signal, config.upstreamTimeoutMs);
|
|
3420
|
-
if (!isRecord$
|
|
3405
|
+
if (!isRecord$5(payload) || !Array.isArray(payload.data)) throw new Error("Copilot /models returned an invalid model catalog.");
|
|
3421
3406
|
const models$1 = /* @__PURE__ */ new Map();
|
|
3422
3407
|
for (const model of payload.data) {
|
|
3423
|
-
if (!isRecord$
|
|
3424
|
-
const capabilities = isRecord$
|
|
3408
|
+
if (!isRecord$5(model) || typeof model.id !== "string" || !model.id) continue;
|
|
3409
|
+
const capabilities = isRecord$5(model.capabilities) ? model.capabilities : void 0;
|
|
3425
3410
|
const limits = parseModelTokenLimits(capabilities?.limits);
|
|
3426
|
-
const supports = isRecord$
|
|
3411
|
+
const supports = isRecord$5(capabilities?.supports) ? capabilities.supports : void 0;
|
|
3427
3412
|
const supportedEndpoints = parseStringArray(model.supported_endpoints);
|
|
3428
3413
|
const reasoningEfforts = parseReasoningEfforts(supports);
|
|
3429
3414
|
models$1.set(model.id, {
|
|
@@ -3671,11 +3656,15 @@ const probeHint = (code) => {
|
|
|
3671
3656
|
//#endregion
|
|
3672
3657
|
//#region src/claude/billing-line.ts
|
|
3673
3658
|
const billingLinePrefix = "x-anthropic-billing-header:";
|
|
3674
|
-
const
|
|
3659
|
+
const leadingBillingLine = (text$1) => {
|
|
3675
3660
|
if (!text$1.startsWith(billingLinePrefix)) return;
|
|
3676
3661
|
const lineEnd = text$1.indexOf("\n");
|
|
3677
|
-
|
|
3678
|
-
|
|
3662
|
+
return lineEnd === -1 ? text$1 : text$1.slice(0, lineEnd);
|
|
3663
|
+
};
|
|
3664
|
+
const afterBillingLine = (text$1) => {
|
|
3665
|
+
const line = leadingBillingLine(text$1);
|
|
3666
|
+
if (line === void 0) return;
|
|
3667
|
+
return text$1.slice(line.length).replace(/^(?:\r?\n)+/, "");
|
|
3679
3668
|
};
|
|
3680
3669
|
const isTextBlock = (block) => typeof block === "object" && block !== null && block.type === "text" && typeof block.text === "string";
|
|
3681
3670
|
const withSystem = (payload, system) => {
|
|
@@ -3711,6 +3700,120 @@ const removeBillingLine = (payload) => {
|
|
|
3711
3700
|
if (!changed) return payload;
|
|
3712
3701
|
return withSystem(payload, kept.length > 0 ? kept : void 0);
|
|
3713
3702
|
};
|
|
3703
|
+
const systemTexts$1 = (system) => {
|
|
3704
|
+
if (typeof system === "string") return [system];
|
|
3705
|
+
return Array.isArray(system) ? system.filter(isTextBlock).map((block) => block.text) : [];
|
|
3706
|
+
};
|
|
3707
|
+
const readBillingLineField = (payload, name) => {
|
|
3708
|
+
if (typeof payload !== "object" || payload === null) return;
|
|
3709
|
+
const line = systemTexts$1(payload.system).map((text$1) => leadingBillingLine(text$1)).find((text$1) => text$1 !== void 0);
|
|
3710
|
+
if (line === void 0) return;
|
|
3711
|
+
for (const field of line.slice(27).split(";")) {
|
|
3712
|
+
const separator = field.indexOf("=");
|
|
3713
|
+
if (separator !== -1 && field.slice(0, separator).trim() === name) return field.slice(separator + 1).trim();
|
|
3714
|
+
}
|
|
3715
|
+
};
|
|
3716
|
+
|
|
3717
|
+
//#endregion
|
|
3718
|
+
//#region src/claude/initiator.ts
|
|
3719
|
+
const ownRequestSystemPrompts = ["You are a helpful AI assistant tasked with summarizing conversations.", "You are naming a coding session"];
|
|
3720
|
+
const compactionGuard = "CRITICAL: Respond with TEXT ONLY.";
|
|
3721
|
+
const compactionInstruction = "Your task is to create a detailed summary of the conversation so far";
|
|
3722
|
+
const personDeliveries = [
|
|
3723
|
+
"The user sent a new message while you were working:",
|
|
3724
|
+
"A message arrived in the bound thread while you were working:",
|
|
3725
|
+
"Messages arrived in the bound thread while you were working:"
|
|
3726
|
+
];
|
|
3727
|
+
const claudeCodeTriggers = [
|
|
3728
|
+
"This session is being continued from a previous conversation",
|
|
3729
|
+
"The summarized conversation included Artifact content",
|
|
3730
|
+
"The previous response failed to produce a valid tool call.",
|
|
3731
|
+
"Your tool call was malformed and could not be parsed.",
|
|
3732
|
+
"[Your previous response had no visible output.",
|
|
3733
|
+
"[structured-output-enforce]",
|
|
3734
|
+
"[projects-reply-gate]",
|
|
3735
|
+
"Output token limit hit",
|
|
3736
|
+
"Your response above was cut off mid-stream",
|
|
3737
|
+
"Your response above was stopped by a safety classifier",
|
|
3738
|
+
"Activity in the bound conversation",
|
|
3739
|
+
"Another Claude session sent a message",
|
|
3740
|
+
"Your background observer",
|
|
3741
|
+
"[MESSAGE FROM NON-USER SOURCE",
|
|
3742
|
+
"[SCHEDULED TASK",
|
|
3743
|
+
"<channel source=",
|
|
3744
|
+
"<teammate-message"
|
|
3745
|
+
];
|
|
3746
|
+
const claudeCodeTriggerPatterns = [/^\S+ hook (?:feedback|blocking error from command):/, /^The \S+ plugin sent a message/];
|
|
3747
|
+
const claudeCodeContext = [
|
|
3748
|
+
"[Request interrupted by user",
|
|
3749
|
+
"[Tool call ",
|
|
3750
|
+
"The user doesn't want to take this action right now.",
|
|
3751
|
+
"Base directory for this skill:"
|
|
3752
|
+
];
|
|
3753
|
+
const claudeCodeContextPatterns = [/^\S+ hook additional context:/];
|
|
3754
|
+
const statusProbePrompt = "Reply with the single word: ok";
|
|
3755
|
+
const statusProbeMaxTokens = 16;
|
|
3756
|
+
const isRecord$4 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
3757
|
+
const innermostReminder = /<system-reminder>(?:(?!<system-reminder>)[\s\S])*?<\/system-reminder>/g;
|
|
3758
|
+
const withoutReminders = (text$1) => {
|
|
3759
|
+
let previous = text$1;
|
|
3760
|
+
let remaining = text$1.replace(innermostReminder, "");
|
|
3761
|
+
while (remaining !== previous) {
|
|
3762
|
+
previous = remaining;
|
|
3763
|
+
remaining = remaining.replace(innermostReminder, "");
|
|
3764
|
+
}
|
|
3765
|
+
return remaining.trim();
|
|
3766
|
+
};
|
|
3767
|
+
const startsWithAny = (text$1, prefixes) => prefixes.some((prefix) => text$1.startsWith(prefix));
|
|
3768
|
+
const matchesAny = (text$1, prefixes, patterns) => startsWithAny(text$1, prefixes) || patterns.some((pattern) => pattern.test(text$1));
|
|
3769
|
+
const contentBlocks = (content) => {
|
|
3770
|
+
if (typeof content === "string") return [{
|
|
3771
|
+
type: "text",
|
|
3772
|
+
text: content
|
|
3773
|
+
}];
|
|
3774
|
+
return Array.isArray(content) ? content.filter(isRecord$4) : [];
|
|
3775
|
+
};
|
|
3776
|
+
const originatingTurn = (messages) => {
|
|
3777
|
+
const blocks = [];
|
|
3778
|
+
let sawUser = false;
|
|
3779
|
+
for (let index = messages.length - 1; index >= 0; index--) {
|
|
3780
|
+
const message = messages[index];
|
|
3781
|
+
if (!isRecord$4(message) || message.role === "system") continue;
|
|
3782
|
+
if (message.role !== "user") break;
|
|
3783
|
+
sawUser = true;
|
|
3784
|
+
blocks.unshift(...contentBlocks(message.content));
|
|
3785
|
+
}
|
|
3786
|
+
return sawUser ? blocks : void 0;
|
|
3787
|
+
};
|
|
3788
|
+
const blockOrigin = (block) => {
|
|
3789
|
+
if (block.type === "tool_result") return "agent";
|
|
3790
|
+
if (block.type === "image" || block.type === "document") return "user";
|
|
3791
|
+
if (block.type !== "text" || typeof block.text !== "string") return;
|
|
3792
|
+
const text$1 = withoutReminders(block.text);
|
|
3793
|
+
if (text$1 === "" || matchesAny(text$1, claudeCodeContext, claudeCodeContextPatterns)) return;
|
|
3794
|
+
if (startsWithAny(text$1, personDeliveries)) return "user";
|
|
3795
|
+
if (text$1.includes(compactionGuard) && text$1.includes(compactionInstruction) || matchesAny(text$1, claudeCodeTriggers, claudeCodeTriggerPatterns)) return "agent";
|
|
3796
|
+
return "user";
|
|
3797
|
+
};
|
|
3798
|
+
const isStatusProbe = (body) => {
|
|
3799
|
+
if (Object.keys(body).sort().join(",") !== "max_tokens,messages,model" || body.max_tokens !== statusProbeMaxTokens) return false;
|
|
3800
|
+
const messages = body.messages;
|
|
3801
|
+
return Array.isArray(messages) && messages.length === 1 && isRecord$4(messages[0]) && messages[0].role === "user" && messages[0].content === statusProbePrompt;
|
|
3802
|
+
};
|
|
3803
|
+
const systemTexts = (system) => contentBlocks(system).flatMap((block) => block.type === "text" && typeof block.text === "string" ? [block.text] : []);
|
|
3804
|
+
const getClaudeRequestInitiator = (body) => {
|
|
3805
|
+
if (!isRecord$4(body) || !Array.isArray(body.messages) || isStatusProbe(body)) return "agent";
|
|
3806
|
+
const payload = body;
|
|
3807
|
+
if (readBillingLineField(payload, "cc_is_subagent") === "true" || readBillingLineField(payload, "cc_workload") === "cron") return "agent";
|
|
3808
|
+
if (systemTexts(removeBillingLine(payload).system).some((text$1) => startsWithAny(text$1.trimStart(), ownRequestSystemPrompts))) return "agent";
|
|
3809
|
+
const turn = originatingTurn(body.messages);
|
|
3810
|
+
if (turn === void 0) return "agent";
|
|
3811
|
+
for (let index = turn.length - 1; index >= 0; index--) {
|
|
3812
|
+
const origin = blockOrigin(turn[index]);
|
|
3813
|
+
if (origin !== void 0) return origin;
|
|
3814
|
+
}
|
|
3815
|
+
return "agent";
|
|
3816
|
+
};
|
|
3714
3817
|
|
|
3715
3818
|
//#endregion
|
|
3716
3819
|
//#region src/claude/utils.ts
|
|
@@ -3741,7 +3844,7 @@ function mapOpenAIStopReasonToClaude(finishReason) {
|
|
|
3741
3844
|
}
|
|
3742
3845
|
/**
|
|
3743
3846
|
* Upstream sends an empty `arguments` string for tools that take no
|
|
3744
|
-
* parameters
|
|
3847
|
+
* parameters. Blank text is the empty object; anything else must be a
|
|
3745
3848
|
* JSON object, and a failure names the tool rather than surfacing a bare
|
|
3746
3849
|
* SyntaxError. Argument text is never echoed: it can carry user data.
|
|
3747
3850
|
*/
|
|
@@ -5690,7 +5793,6 @@ const normalizeFinalAssistantPrefill = (payload) => {
|
|
|
5690
5793
|
messages
|
|
5691
5794
|
};
|
|
5692
5795
|
};
|
|
5693
|
-
const isAgentInitiator = (messages) => messages.some((message) => message.role === "assistant" || message.role === "tool") ? "agent" : "user";
|
|
5694
5796
|
const holdsImage = (message) => Array.isArray(message.content) && message.content.some((part) => part.type === "image_url");
|
|
5695
5797
|
const messagesIncludeImage = (messages) => messages.some(holdsImage);
|
|
5696
5798
|
const isToolImageMessage = (message) => message.role === "tool" && holdsImage(message);
|
|
@@ -5767,7 +5869,7 @@ const createChatCompletions = async (config, payload, options = {}) => {
|
|
|
5767
5869
|
const compatiblePayload = useResponsesApi ? upstreamPayload : normalizeFinalAssistantPrefill(moveToolImagesToUserMessages(upstreamPayload));
|
|
5768
5870
|
const provider = getCopilotProviderContext(config);
|
|
5769
5871
|
const enableVision = messagesIncludeImage(compatiblePayload.messages);
|
|
5770
|
-
const initiator =
|
|
5872
|
+
const initiator = options.initiator ?? "agent";
|
|
5771
5873
|
const requestPayload = buildRequestPayload(compatiblePayload);
|
|
5772
5874
|
const effectiveEffortLabel = requestPayload.reasoning_effort ?? "omitted";
|
|
5773
5875
|
log.info(sanitizeTerminalString([
|
|
@@ -5778,7 +5880,8 @@ const createChatCompletions = async (config, payload, options = {}) => {
|
|
|
5778
5880
|
`upstream_model=${compatiblePayload.model}`,
|
|
5779
5881
|
`requested_think_effort=${requestedThinkEffort}`,
|
|
5780
5882
|
`requested_thinking=${requestedThinking}`,
|
|
5781
|
-
`effective_think_effort=${effectiveEffortLabel}
|
|
5883
|
+
`effective_think_effort=${effectiveEffortLabel}`,
|
|
5884
|
+
`initiator=${initiator}`
|
|
5782
5885
|
].join(" ")));
|
|
5783
5886
|
if (useResponsesApi) return completeResponse(await createResponses(provider, compatiblePayload, {
|
|
5784
5887
|
vision: enableVision,
|
|
@@ -6012,7 +6115,8 @@ async function createNativeMessages(config, payload, options) {
|
|
|
6012
6115
|
const beta = options.headers?.get("anthropic-beta");
|
|
6013
6116
|
if (beta) headers["anthropic-beta"] = beta;
|
|
6014
6117
|
const effortLabel = effort === void 0 ? "omitted" : getClaudeTurnEffort(body).effective;
|
|
6015
|
-
|
|
6118
|
+
const initiator = options.initiator ?? "agent";
|
|
6119
|
+
log.info(sanitizeTerminalString(`request_id=${options.requestId} Model request client=claude requested_model=${payload.model} upstream_model=${payload.model} upstream_api=messages effective_think_effort=${effortLabel} initiator=${initiator}`));
|
|
6016
6120
|
const response = await fetchCopilot(getCopilotProviderContext(config), "/v1/messages", {
|
|
6017
6121
|
method: "POST",
|
|
6018
6122
|
headers,
|
|
@@ -6021,7 +6125,7 @@ async function createNativeMessages(config, payload, options) {
|
|
|
6021
6125
|
requestId: options.requestId,
|
|
6022
6126
|
signal: options.signal,
|
|
6023
6127
|
timeoutMs: config.upstreamTimeoutMs,
|
|
6024
|
-
initiator
|
|
6128
|
+
initiator
|
|
6025
6129
|
});
|
|
6026
6130
|
if (!response.ok) {
|
|
6027
6131
|
const detail = await readCopilotText(response, options.signal, config.upstreamTimeoutMs);
|
|
@@ -6294,7 +6398,8 @@ async function handleNativeMessages(config, payload, options, write) {
|
|
|
6294
6398
|
]
|
|
6295
6399
|
}, {
|
|
6296
6400
|
...options,
|
|
6297
|
-
signal
|
|
6401
|
+
signal,
|
|
6402
|
+
initiator: "agent"
|
|
6298
6403
|
});
|
|
6299
6404
|
const offset = combined.length;
|
|
6300
6405
|
let finalHeldFrom;
|
|
@@ -6497,13 +6602,14 @@ const translationEndpoint = (config, model) => {
|
|
|
6497
6602
|
const selection = selectCopilotEndpoint(config, normalizeCopilotModelId(model));
|
|
6498
6603
|
return selection.endpoint && !selection.responsesFallback ? selection.endpoint : void 0;
|
|
6499
6604
|
};
|
|
6500
|
-
const handleClaudeMessageRequest = async (config, claudePayload, requestSignal, requestId, writeEvent, requestHeaders) => {
|
|
6605
|
+
const handleClaudeMessageRequest = async (config, claudePayload, initiator, requestSignal, requestId, writeEvent, requestHeaders) => {
|
|
6501
6606
|
const upstreamModel = translateModelName(claudePayload.model);
|
|
6502
6607
|
if (shouldUseNativeMessages(config, upstreamModel)) return handleNativeMessages(config, {
|
|
6503
6608
|
...claudePayload,
|
|
6504
6609
|
model: upstreamModel
|
|
6505
6610
|
}, {
|
|
6506
6611
|
requestId,
|
|
6612
|
+
initiator,
|
|
6507
6613
|
signal: requestSignal,
|
|
6508
6614
|
headers: requestHeaders
|
|
6509
6615
|
}, writeEvent);
|
|
@@ -6515,6 +6621,7 @@ const handleClaudeMessageRequest = async (config, claudePayload, requestSignal,
|
|
|
6515
6621
|
if (shouldLetModelDecideWebSearch && !canStreamWebSearchDecision) openAIPayload.stream = false;
|
|
6516
6622
|
const response = await createChatCompletions(config, openAIPayload, {
|
|
6517
6623
|
client: "claude",
|
|
6624
|
+
initiator,
|
|
6518
6625
|
requestedModel: claudePayload.model,
|
|
6519
6626
|
requestedThinkEffort: getClaudeRequestedThinkEffort(claudePayload),
|
|
6520
6627
|
requestedThinking: getClaudeRequestedThinking(claudePayload),
|
|
@@ -6588,6 +6695,7 @@ const handleClaudeMessageRequest = async (config, claudePayload, requestSignal,
|
|
|
6588
6695
|
else {
|
|
6589
6696
|
const finalResponse = await createChatCompletions(config, createFinalWebSearchPayload(openAIPayload, search, toolNameMapper), {
|
|
6590
6697
|
client: "claude",
|
|
6698
|
+
initiator: "agent",
|
|
6591
6699
|
requestedModel: claudePayload.model,
|
|
6592
6700
|
requestedThinkEffort: getClaudeRequestedThinkEffort(claudePayload),
|
|
6593
6701
|
requestedThinking: getClaudeRequestedThinking(claudePayload),
|
|
@@ -6665,7 +6773,9 @@ claudeRoutes.get("/models", (c) => c.json({
|
|
|
6665
6773
|
claudeRoutes.post("/messages", async (c) => {
|
|
6666
6774
|
const config = c.get("config");
|
|
6667
6775
|
const requestId = c.get("requestId");
|
|
6668
|
-
const
|
|
6776
|
+
const receivedPayload = await c.req.json();
|
|
6777
|
+
const initiator = c.env?.relayProbe === true ? "agent" : getClaudeRequestInitiator(receivedPayload);
|
|
6778
|
+
const claudePayload = removeBillingLine(receivedPayload);
|
|
6669
6779
|
const requestSignal = createCopilotRequestSignal(c.req.raw.signal, config.upstreamTimeoutMs);
|
|
6670
6780
|
try {
|
|
6671
6781
|
getRequestReasoningEffort(claudePayload);
|
|
@@ -6716,7 +6826,7 @@ claudeRoutes.post("/messages", async (c) => {
|
|
|
6716
6826
|
data: JSON.stringify(event)
|
|
6717
6827
|
}));
|
|
6718
6828
|
try {
|
|
6719
|
-
await handleClaudeMessageRequest(config, claudePayload, requestSignal, requestId, writeEvent, c.req.raw.headers);
|
|
6829
|
+
await handleClaudeMessageRequest(config, claudePayload, initiator, requestSignal, requestId, writeEvent, c.req.raw.headers);
|
|
6720
6830
|
} catch (error) {
|
|
6721
6831
|
log.error(`request_id=${requestId} Error during Claude stream request:`, error);
|
|
6722
6832
|
if (error instanceof UpstreamToolInputError) trace?.recordFailure("invalid-tool-input");
|
|
@@ -6730,7 +6840,7 @@ claudeRoutes.post("/messages", async (c) => {
|
|
|
6730
6840
|
});
|
|
6731
6841
|
}
|
|
6732
6842
|
try {
|
|
6733
|
-
return c.json(await handleClaudeMessageRequest(config, claudePayload, requestSignal, requestId, void 0, c.req.raw.headers));
|
|
6843
|
+
return c.json(await handleClaudeMessageRequest(config, claudePayload, initiator, requestSignal, requestId, void 0, c.req.raw.headers));
|
|
6734
6844
|
} catch (error) {
|
|
6735
6845
|
if (error instanceof ProxyNotImplementedError) {
|
|
6736
6846
|
c.set("requestErrorMessage", error.message);
|
|
@@ -7140,7 +7250,7 @@ async function probeModels(config, entries, options) {
|
|
|
7140
7250
|
content: "Reply with OK only."
|
|
7141
7251
|
}]
|
|
7142
7252
|
})
|
|
7143
|
-
}));
|
|
7253
|
+
}), { relayProbe: true });
|
|
7144
7254
|
return {
|
|
7145
7255
|
response,
|
|
7146
7256
|
body: await response.json().catch(() => void 0)
|
|
@@ -7647,7 +7757,7 @@ const readCaptureFile = async (directory, file, maximum, optional = false) => {
|
|
|
7647
7757
|
const handle = await fs.open(filePath, fs.constants.O_RDONLY | (process.platform === "win32" ? 0 : fs.constants.O_NOFOLLOW));
|
|
7648
7758
|
try {
|
|
7649
7759
|
const opened = await handle.stat();
|
|
7650
|
-
requireValid(opened.isFile() && opened
|
|
7760
|
+
requireValid(opened.isFile() && sameOpenedFile(opened, before) && opened.size === before.size);
|
|
7651
7761
|
const bytes = Buffer.alloc(opened.size);
|
|
7652
7762
|
let offset = 0;
|
|
7653
7763
|
while (offset < bytes.length) {
|
|
@@ -7656,7 +7766,7 @@ const readCaptureFile = async (directory, file, maximum, optional = false) => {
|
|
|
7656
7766
|
offset += read.bytesRead;
|
|
7657
7767
|
}
|
|
7658
7768
|
const after = await fs.lstat(filePath);
|
|
7659
|
-
requireValid(!after.isSymbolicLink() && after
|
|
7769
|
+
requireValid(!after.isSymbolicLink() && sameOpenedFile(after, opened) && after.size === opened.size && after.mtimeMs === opened.mtimeMs);
|
|
7660
7770
|
return bytes;
|
|
7661
7771
|
} finally {
|
|
7662
7772
|
await handle.close();
|
|
@@ -8820,7 +8930,7 @@ const restart = defineCommand({
|
|
|
8820
8930
|
* for different responses - restart the service versus re-authenticate.
|
|
8821
8931
|
*
|
|
8822
8932
|
* A failed health probe is a 2, not a 0. Printing FAILED while exiting 0 would
|
|
8823
|
-
* make every scripted caller treat an unreachable relay as fine.
|
|
8933
|
+
* make every scripted caller treat an unreachable relay as fine.
|
|
8824
8934
|
*/
|
|
8825
8935
|
const exitCodes = {
|
|
8826
8936
|
notRunning: 1,
|
|
@@ -8852,9 +8962,9 @@ const hasVersionMismatch = (status$1) => status$1.running && status$1.daemonVers
|
|
|
8852
8962
|
*
|
|
8853
8963
|
* 0 requires a live process *and* a passing health probe. Printing FAILED while
|
|
8854
8964
|
* exiting 0 would make every scripted caller treat an unreachable relay as
|
|
8855
|
-
* fine.
|
|
8965
|
+
* fine.
|
|
8856
8966
|
*
|
|
8857
|
-
* A version mismatch stays 0 on purpose
|
|
8967
|
+
* A version mismatch stays 0 on purpose. The relay works; it is just not
|
|
8858
8968
|
* the build that was installed. Mapping it to 2 would overstate it and break
|
|
8859
8969
|
* every scripted caller that treats non-zero as broken. It is surfaced in the
|
|
8860
8970
|
* text instead.
|
|
@@ -8994,7 +9104,7 @@ const describeError = (error) => {
|
|
|
8994
9104
|
* The version rides along on a probe `status` already makes, so asking what is
|
|
8995
9105
|
* running costs nothing extra. Absent for a daemon older than v0.3.1, which is
|
|
8996
9106
|
* itself informative, so it stays optional rather than defaulting to the
|
|
8997
|
-
* caller's version - that substitution is the bug.
|
|
9107
|
+
* caller's version - that substitution is the bug.
|
|
8998
9108
|
*/
|
|
8999
9109
|
const checkHealth = async (baseUrl) => {
|
|
9000
9110
|
try {
|
|
@@ -9037,9 +9147,9 @@ const checkDeep = async (baseUrl, model, apiKey) => {
|
|
|
9037
9147
|
try {
|
|
9038
9148
|
const result = await probe(`${baseUrl}/v1/messages`, {
|
|
9039
9149
|
body: JSON.stringify({
|
|
9040
|
-
max_tokens:
|
|
9150
|
+
max_tokens: statusProbeMaxTokens,
|
|
9041
9151
|
messages: [{
|
|
9042
|
-
content:
|
|
9152
|
+
content: statusProbePrompt,
|
|
9043
9153
|
role: "user"
|
|
9044
9154
|
}],
|
|
9045
9155
|
model
|