copilot-relay 0.4.5 → 0.4.7
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 +15 -2
- package/config.default.yaml +22 -3
- package/dist/main.js +1678 -220
- package/dist/main.js.map +1 -1
- package/package.json +1 -1
package/dist/main.js
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { defineCommand, runMain } from "citty";
|
|
3
3
|
import fs from "node:fs/promises";
|
|
4
|
-
import { createHash, randomUUID } from "node:crypto";
|
|
4
|
+
import { createHash, randomUUID, timingSafeEqual } from "node:crypto";
|
|
5
5
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
6
6
|
import timers from "node:timers";
|
|
7
7
|
import { inspect, isDeepStrictEqual, promisify } from "node:util";
|
|
8
8
|
import consola from "consola";
|
|
9
9
|
import os from "node:os";
|
|
10
10
|
import path, { dirname, posix, resolve } from "node:path";
|
|
11
|
+
import { Agent, EnvHttpProxyAgent, ProxyAgent, fetch } from "undici";
|
|
11
12
|
import { fileURLToPath } from "node:url";
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
import { isIPv4 } from "node:net";
|
|
13
|
+
import { createReadStream, readFileSync, statSync } from "node:fs";
|
|
14
|
+
import { BlockList, isIP, isIPv4 } from "node:net";
|
|
15
15
|
import { createAdaptorServer } from "@hono/node-server";
|
|
16
16
|
import { Hono } from "hono";
|
|
17
17
|
import { streamSSE } from "hono/streaming";
|
|
@@ -35,6 +35,19 @@ var HTTPError = class extends Error {
|
|
|
35
35
|
this.response = response;
|
|
36
36
|
}
|
|
37
37
|
};
|
|
38
|
+
var PromptTooLongError = class extends HTTPError {
|
|
39
|
+
constructor(tokens, limit, detail) {
|
|
40
|
+
const message = tokens !== void 0 && limit !== void 0 ? `prompt is too long: ${tokens} tokens > ${limit} maximum` : "prompt is too long";
|
|
41
|
+
super(message, Response.json({
|
|
42
|
+
type: "error",
|
|
43
|
+
error: {
|
|
44
|
+
type: "invalid_request_error",
|
|
45
|
+
message
|
|
46
|
+
}
|
|
47
|
+
}, { status: 400 }), detail);
|
|
48
|
+
this.name = "PromptTooLongError";
|
|
49
|
+
}
|
|
50
|
+
};
|
|
38
51
|
|
|
39
52
|
//#endregion
|
|
40
53
|
//#region src/lib/paths.ts
|
|
@@ -188,12 +201,26 @@ const formatUrlForDisplay = (raw) => {
|
|
|
188
201
|
return isOriginOnly(parsed) ? raw : `${parsed.origin} (path/query/fragment hidden)`;
|
|
189
202
|
};
|
|
190
203
|
/**
|
|
204
|
+
* Renders an upstreamProxy value for display: "env" as written, a proxy URL as
|
|
205
|
+
* its origin.
|
|
206
|
+
*
|
|
207
|
+
* A proxy URL may carry a user name and password. Neither is shown; a marker
|
|
208
|
+
* says they were there, so a reader can still tell the proxy authenticates.
|
|
209
|
+
* Never throws.
|
|
210
|
+
*/
|
|
211
|
+
const formatUpstreamProxyForDisplay = (upstreamProxy) => {
|
|
212
|
+
if (upstreamProxy === "env") return upstreamProxy;
|
|
213
|
+
const parsed = parseHttpUrl(upstreamProxy);
|
|
214
|
+
if (!parsed) return "(invalid url)";
|
|
215
|
+
return parsed.username !== "" || parsed.password !== "" ? `${parsed.origin} (credentials hidden)` : parsed.origin;
|
|
216
|
+
};
|
|
217
|
+
/**
|
|
191
218
|
* Strips ANSI escapes and control bytes from a string bound for the terminal.
|
|
192
219
|
*
|
|
193
220
|
* `status` prints config values directly to stdout. A config string carrying
|
|
194
221
|
* raw control bytes could clear the line and paint status rows the relay never
|
|
195
222
|
* produced. The config file is user-owned, so this is hardening rather than a
|
|
196
|
-
* vulnerability — but it costs nothing to make the output non-forgeable.
|
|
223
|
+
* vulnerability — but it costs nothing to make the output non-forgeable.
|
|
197
224
|
*/
|
|
198
225
|
const sanitizeTerminalString = (text$1) => text$1.replace(ansiEscapePattern, "").replace(controlBytePattern, "");
|
|
199
226
|
/**
|
|
@@ -253,7 +280,7 @@ const scrubSensitiveUrls = (text$1) => {
|
|
|
253
280
|
const logCleanupCheckIntervalMs = 3600 * 1e3;
|
|
254
281
|
let logRetentionDays = 3;
|
|
255
282
|
let nextLogCleanupCheckAt = 0;
|
|
256
|
-
const datedLogFilePattern = /* @__PURE__ */ new RegExp(`^${paths.logFileBaseName}\\.(\\d{4})-(\\d{2})-(\\d{2})\\.log$`);
|
|
283
|
+
const datedLogFilePattern$1 = /* @__PURE__ */ new RegExp(`^${paths.logFileBaseName}\\.(\\d{4})-(\\d{2})-(\\d{2})\\.log$`);
|
|
257
284
|
const consolaLevelByName = {
|
|
258
285
|
error: 0,
|
|
259
286
|
info: 3,
|
|
@@ -268,6 +295,7 @@ const loggingSuppressed = new AsyncLocalStorage();
|
|
|
268
295
|
const consoleSuppressed = new AsyncLocalStorage();
|
|
269
296
|
const withoutLogging = (run) => loggingSuppressed.run(true, run);
|
|
270
297
|
const withoutConsoleLogging = (run) => consoleSuppressed.run(true, run);
|
|
298
|
+
const isLoggingSuppressed = () => loggingSuppressed.getStore() === true;
|
|
271
299
|
let currentLogLevel = consolaLevelByName.info;
|
|
272
300
|
const registeredLogSecrets = /* @__PURE__ */ new Set();
|
|
273
301
|
const logSecretForms = /* @__PURE__ */ new Set();
|
|
@@ -342,7 +370,7 @@ const formatLogValue = (value) => typeof value === "string" ? value : inspect(va
|
|
|
342
370
|
maxStringLength: 4e3
|
|
343
371
|
});
|
|
344
372
|
const escapeLogLineSeparators = (value) => value.replaceAll("\r", "\\r").replaceAll("\n", "\\n").replaceAll(String.fromCodePoint(8232), "\\u2028").replaceAll(String.fromCodePoint(8233), "\\u2029");
|
|
345
|
-
const isMissing$
|
|
373
|
+
const isMissing$2 = (error) => error.code === "ENOENT";
|
|
346
374
|
const sameFile$2 = (left, right) => left.dev === right.dev && left.ino === right.ino;
|
|
347
375
|
const ensurePrivateDirectory = async (directory) => {
|
|
348
376
|
await fs.mkdir(directory, { mode: 448 }).catch((error) => {
|
|
@@ -380,7 +408,7 @@ const isStillActive = async (active) => {
|
|
|
380
408
|
};
|
|
381
409
|
const openPrivateLog = async (filePath, directories) => {
|
|
382
410
|
const observed = await fs.lstat(filePath).catch((error) => {
|
|
383
|
-
if (!isMissing$
|
|
411
|
+
if (!isMissing$2(error)) throw error;
|
|
384
412
|
});
|
|
385
413
|
if (observed && (!observed.isFile() || observed.isSymbolicLink() || observed.nlink !== 1)) throw new Error("Refusing an unsafe log file");
|
|
386
414
|
const flags = fs.constants.O_WRONLY | fs.constants.O_APPEND | fs.constants.O_CREAT | (process.platform === "win32" ? 0 : fs.constants.O_NOFOLLOW);
|
|
@@ -485,7 +513,7 @@ const cleanupLogsIfDue = async () => {
|
|
|
485
513
|
* Each argument is rendered exactly once and scrubbed once, and the identical
|
|
486
514
|
* array of strings goes to both the file and the console. Scrubbing only on the
|
|
487
515
|
* way to disk would leave the original object on the console - the screen a
|
|
488
|
-
* user screenshots into an issue - so the two sinks must not diverge.
|
|
516
|
+
* user screenshots into an issue - so the two sinks must not diverge.
|
|
489
517
|
*
|
|
490
518
|
* Rendering is skipped only when neither sink would emit, so the cost matches
|
|
491
519
|
* the old behavior at info level. The console gate is read from consola rather
|
|
@@ -521,7 +549,7 @@ consola.debug = wrapFileLog("debug", consola.debug.bind(consola));
|
|
|
521
549
|
* extend or shorten the retention window.
|
|
522
550
|
*/
|
|
523
551
|
const parseLogFileDate = (fileName) => {
|
|
524
|
-
const match = datedLogFilePattern.exec(fileName);
|
|
552
|
+
const match = datedLogFilePattern$1.exec(fileName);
|
|
525
553
|
if (!match) return;
|
|
526
554
|
const [, year, month, day] = match;
|
|
527
555
|
const date = new Date(Number(year), Number(month) - 1, Number(day));
|
|
@@ -535,10 +563,10 @@ const cleanupLogs = async (retentionDays) => {
|
|
|
535
563
|
oldestDay.setDate(oldestDay.getDate() - (retentionDays - 1));
|
|
536
564
|
const cutoff = oldestDay.getTime();
|
|
537
565
|
const entries = await fs.readdir(paths.logsDir, { withFileTypes: true });
|
|
538
|
-
await Promise.all(entries.filter((entry) => entry.isFile() && (entry.name === `${paths.logFileBaseName}.log` || datedLogFilePattern.test(entry.name))).map(async (entry) => {
|
|
566
|
+
await Promise.all(entries.filter((entry) => entry.isFile() && (entry.name === `${paths.logFileBaseName}.log` || datedLogFilePattern$1.test(entry.name))).map(async (entry) => {
|
|
539
567
|
const filePath = `${paths.logsDir}/${entry.name}`;
|
|
540
568
|
const current = await fs.lstat(filePath).catch((error) => {
|
|
541
|
-
if (!isMissing$
|
|
569
|
+
if (!isMissing$2(error)) throw error;
|
|
542
570
|
});
|
|
543
571
|
if (!current?.isFile() || current.nlink !== 1) return;
|
|
544
572
|
if ((parseLogFileDate(entry.name)?.getTime() ?? current.mtimeMs) < cutoff) await fs.rm(filePath, { force: true });
|
|
@@ -546,6 +574,216 @@ const cleanupLogs = async (retentionDays) => {
|
|
|
546
574
|
};
|
|
547
575
|
const log = consola;
|
|
548
576
|
|
|
577
|
+
//#endregion
|
|
578
|
+
//#region src/lib/upstream-dispatcher.ts
|
|
579
|
+
const connectionOptions = {
|
|
580
|
+
allowH2: false,
|
|
581
|
+
connect: { allowH2: false },
|
|
582
|
+
keepAliveTimeout: 5e4
|
|
583
|
+
};
|
|
584
|
+
/**
|
|
585
|
+
* A malformed HTTPS_PROXY or HTTP_PROXY with upstreamProxy: env. The message is fixed: the proxy
|
|
586
|
+
* URL comes from the environment, can carry a password, and undici's own error repeats it. This
|
|
587
|
+
* message reaches the terminal and the log.
|
|
588
|
+
*/
|
|
589
|
+
var InvalidProxyEnvironmentError = class extends Error {
|
|
590
|
+
constructor() {
|
|
591
|
+
super("Invalid HTTPS_PROXY or HTTP_PROXY: with upstreamProxy: env, each one that is set must be an absolute http(s) proxy URL");
|
|
592
|
+
this.name = "InvalidProxyEnvironmentError";
|
|
593
|
+
}
|
|
594
|
+
};
|
|
595
|
+
/**
|
|
596
|
+
* Builds the dispatcher for one resolved upstreamProxy value.
|
|
597
|
+
*
|
|
598
|
+
* Empty connects directly. "env" hands HTTPS_PROXY, HTTP_PROXY
|
|
599
|
+
* and NO_PROXY, in upper or lower case, to EnvHttpProxyAgent. Anything else is a proxy URL that
|
|
600
|
+
* normalizeUpstreamProxy has already validated.
|
|
601
|
+
*/
|
|
602
|
+
const buildUpstreamDispatcher = (upstreamProxy) => {
|
|
603
|
+
if (upstreamProxy === void 0) return new Agent(connectionOptions);
|
|
604
|
+
if (upstreamProxy === "env") try {
|
|
605
|
+
return new EnvHttpProxyAgent(connectionOptions);
|
|
606
|
+
} catch {
|
|
607
|
+
throw new InvalidProxyEnvironmentError();
|
|
608
|
+
}
|
|
609
|
+
return new ProxyAgent({
|
|
610
|
+
...connectionOptions,
|
|
611
|
+
uri: upstreamProxy
|
|
612
|
+
});
|
|
613
|
+
};
|
|
614
|
+
let upstreamDispatcher = buildUpstreamDispatcher(void 0);
|
|
615
|
+
let configuredUpstreamProxy;
|
|
616
|
+
/**
|
|
617
|
+
* The proxy URLs a dispatcher for this upstreamProxy can send through: none when it is empty, the
|
|
618
|
+
* URL itself, or with "env" each proxy variable EnvHttpProxyAgent reads, the lower-case spelling
|
|
619
|
+
* first, as undici does.
|
|
620
|
+
*/
|
|
621
|
+
const proxyUrlsFor = (upstreamProxy) => {
|
|
622
|
+
if (upstreamProxy === void 0) return [];
|
|
623
|
+
if (upstreamProxy !== "env") return [upstreamProxy];
|
|
624
|
+
return [process.env.http_proxy ?? process.env.HTTP_PROXY, process.env.https_proxy ?? process.env.HTTPS_PROXY].filter((value) => value !== void 0 && value !== "");
|
|
625
|
+
};
|
|
626
|
+
const decodeOrKeep = (value) => {
|
|
627
|
+
try {
|
|
628
|
+
return decodeURIComponent(value);
|
|
629
|
+
} catch {
|
|
630
|
+
return value;
|
|
631
|
+
}
|
|
632
|
+
};
|
|
633
|
+
const parseProxyUrl = (proxyUrl) => {
|
|
634
|
+
try {
|
|
635
|
+
return new URL(proxyUrl);
|
|
636
|
+
} catch {
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
639
|
+
};
|
|
640
|
+
/**
|
|
641
|
+
* The forms a proxy's credentials take together: user:password as written in the URL and decoded,
|
|
642
|
+
* and the Basic value undici sends in Proxy-Authorization. None when the URL lacks either part,
|
|
643
|
+
* because undici then sends no Proxy-Authorization at all.
|
|
644
|
+
*/
|
|
645
|
+
const credentialPairForms = (proxyUrl) => {
|
|
646
|
+
const url = parseProxyUrl(proxyUrl);
|
|
647
|
+
if (url === void 0 || url.username === "" || url.password === "") return [];
|
|
648
|
+
const decoded$1 = `${decodeOrKeep(url.username)}:${decodeOrKeep(url.password)}`;
|
|
649
|
+
return [
|
|
650
|
+
`${url.username}:${url.password}`,
|
|
651
|
+
decoded$1,
|
|
652
|
+
Buffer.from(decoded$1).toString("base64")
|
|
653
|
+
];
|
|
654
|
+
};
|
|
655
|
+
const credentialForms = (proxyUrl) => {
|
|
656
|
+
const url = parseProxyUrl(proxyUrl);
|
|
657
|
+
if (url === void 0) return [];
|
|
658
|
+
return [
|
|
659
|
+
...credentialPairForms(proxyUrl),
|
|
660
|
+
url.username,
|
|
661
|
+
decodeOrKeep(url.username),
|
|
662
|
+
url.password,
|
|
663
|
+
decodeOrKeep(url.password)
|
|
664
|
+
].filter((form) => form !== "");
|
|
665
|
+
};
|
|
666
|
+
const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
667
|
+
let activeCredentialPattern;
|
|
668
|
+
const minimumRegisteredLength = 8;
|
|
669
|
+
/**
|
|
670
|
+
* Builds this process's upstream dispatcher from the resolved upstreamProxy, and registers the
|
|
671
|
+
* credentials of each proxy it can use with the log redaction.
|
|
672
|
+
*
|
|
673
|
+
* start, auth, models and usage call it once, after reading the config and before their first
|
|
674
|
+
* upstream call. Until one does, upstream calls connect directly, as they did before the key
|
|
675
|
+
* existed. A config reload never calls it, which is why upstreamProxy, like host and port, takes
|
|
676
|
+
* effect on restart, and why a reload registers no credentials. It is never undici's global
|
|
677
|
+
* dispatcher, and calls to the relay itself never use it.
|
|
678
|
+
*
|
|
679
|
+
* Only the user:password pair and the Basic value are registered, never a user name or password
|
|
680
|
+
* alone: registerLogSecret replaces a value in every log line, and a short or common user name such
|
|
681
|
+
* as "copilot" would also cut it out of "copilot-relay".
|
|
682
|
+
*/
|
|
683
|
+
const configureUpstreamDispatcher = (upstreamProxy) => {
|
|
684
|
+
const previous = upstreamDispatcher;
|
|
685
|
+
upstreamDispatcher = buildUpstreamDispatcher(upstreamProxy);
|
|
686
|
+
configuredUpstreamProxy = upstreamProxy;
|
|
687
|
+
const proxyUrls = proxyUrlsFor(upstreamProxy);
|
|
688
|
+
for (const form of proxyUrls.flatMap((proxyUrl) => credentialPairForms(proxyUrl))) if (form.length >= minimumRegisteredLength) registerLogSecret(form);
|
|
689
|
+
const forms = [...new Set(proxyUrls.flatMap((proxyUrl) => credentialForms(proxyUrl)))].sort((left, right) => right.length - left.length);
|
|
690
|
+
activeCredentialPattern = forms.length === 0 ? void 0 : new RegExp(forms.map((form) => escapeRegExp(form)).join("|"), "g");
|
|
691
|
+
previous.close().catch(() => void 0);
|
|
692
|
+
};
|
|
693
|
+
const proxyVariables = [
|
|
694
|
+
"HTTPS_PROXY",
|
|
695
|
+
"https_proxy",
|
|
696
|
+
"HTTP_PROXY",
|
|
697
|
+
"http_proxy"
|
|
698
|
+
];
|
|
699
|
+
let proxyHintLogged = false;
|
|
700
|
+
/**
|
|
701
|
+
* Logs, once per process, that a proxy the environment names was not used.
|
|
702
|
+
*
|
|
703
|
+
* Only for a request that failed without an HTTP response while upstreamProxy is empty: the relay
|
|
704
|
+
* ignored HTTPS_PROXY or HTTP_PROXY and went direct, and those variables suggest the network
|
|
705
|
+
* expects a proxy. A cancelled request or a passed deadline is not that kind of failure.
|
|
706
|
+
*
|
|
707
|
+
* The hint counts as logged only once it is written. Under withoutLogging, where models --deep
|
|
708
|
+
* runs its probes, log.error writes nothing, so a failure there leaves the hint for a later one.
|
|
709
|
+
*/
|
|
710
|
+
const noteConnectionFailure = (signal) => {
|
|
711
|
+
if (proxyHintLogged || configuredUpstreamProxy !== void 0 || signal?.aborted || isLoggingSuppressed()) return;
|
|
712
|
+
if (!proxyVariables.some((name) => process.env[name])) return;
|
|
713
|
+
proxyHintLogged = true;
|
|
714
|
+
log.error(`Upstream request failed without a response. HTTPS_PROXY or HTTP_PROXY is set, but upstreamProxy is empty, so copilot-relay connected directly. To use that proxy, set upstreamProxy: env in ${paths.configPath}, then run the command again or restart the relay.`);
|
|
715
|
+
};
|
|
716
|
+
const redacted = "[redacted]";
|
|
717
|
+
const rawPayloadKeys = ["data", "body"];
|
|
718
|
+
const replaceText = (error, key, value) => {
|
|
719
|
+
try {
|
|
720
|
+
Object.defineProperty(error, key, {
|
|
721
|
+
configurable: true,
|
|
722
|
+
enumerable: false,
|
|
723
|
+
value,
|
|
724
|
+
writable: true
|
|
725
|
+
});
|
|
726
|
+
} catch {}
|
|
727
|
+
};
|
|
728
|
+
const scrubStackHead = (stack, pattern) => {
|
|
729
|
+
const frames = stack.search(/\n\s+at /);
|
|
730
|
+
if (frames === -1) return stack.replace(pattern, redacted);
|
|
731
|
+
return stack.slice(0, frames).replace(pattern, redacted) + stack.slice(frames);
|
|
732
|
+
};
|
|
733
|
+
/**
|
|
734
|
+
* Cleans the error of an upstream request that failed without a response, in place, so it keeps
|
|
735
|
+
* its type, code and cause, which callers read.
|
|
736
|
+
*
|
|
737
|
+
* It drops the raw payload undici can keep from the peer anywhere in the cause chain: a proxy that
|
|
738
|
+
* answers CONNECT with a reply undici cannot parse leaves the rest of that reply in
|
|
739
|
+
* HTTPParserError.data, and a reply that echoes Proxy-Authorization would carry the password into
|
|
740
|
+
* every log line that prints the error. It also replaces every form of the active proxy's
|
|
741
|
+
* credentials in each message, short ones and a user name or password alone included: unlike the
|
|
742
|
+
* log redaction, only this error changes.
|
|
743
|
+
*/
|
|
744
|
+
const sanitizeTransportError = (error, seen = /* @__PURE__ */ new Set()) => {
|
|
745
|
+
if (!(error instanceof Error) || seen.has(error)) return;
|
|
746
|
+
seen.add(error);
|
|
747
|
+
for (const key of rawPayloadKeys) Reflect.deleteProperty(error, key);
|
|
748
|
+
if (activeCredentialPattern !== void 0) {
|
|
749
|
+
if (typeof error.message === "string") {
|
|
750
|
+
const message = error.message.replace(activeCredentialPattern, redacted);
|
|
751
|
+
if (message !== error.message) replaceText(error, "message", message);
|
|
752
|
+
}
|
|
753
|
+
if (typeof error.stack === "string") {
|
|
754
|
+
const stack = scrubStackHead(error.stack, activeCredentialPattern);
|
|
755
|
+
if (stack !== error.stack) replaceText(error, "stack", stack);
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
sanitizeTransportError(error.cause, seen);
|
|
759
|
+
if (error instanceof AggregateError) for (const inner of error.errors) sanitizeTransportError(inner, seen);
|
|
760
|
+
};
|
|
761
|
+
/**
|
|
762
|
+
* fetch for an upstream call, through this process's upstream dispatcher.
|
|
763
|
+
*
|
|
764
|
+
* A request that fails without a response rejects with its error cleaned by
|
|
765
|
+
* sanitizeTransportError, before any caller can log it. The undici response is rewrapped as a
|
|
766
|
+
* global Response, so callers, HTTPError included, keep the Response type the rest of the relay
|
|
767
|
+
* uses.
|
|
768
|
+
*/
|
|
769
|
+
const fetchUpstream = async (url, init) => {
|
|
770
|
+
const response = await fetch(url, {
|
|
771
|
+
...init,
|
|
772
|
+
dispatcher: upstreamDispatcher
|
|
773
|
+
}).catch((error) => {
|
|
774
|
+
sanitizeTransportError(error);
|
|
775
|
+
noteConnectionFailure(init.signal);
|
|
776
|
+
throw error;
|
|
777
|
+
});
|
|
778
|
+
const wrappedResponse = new Response(response.body, {
|
|
779
|
+
status: response.status,
|
|
780
|
+
statusText: response.statusText,
|
|
781
|
+
headers: response.headers
|
|
782
|
+
});
|
|
783
|
+
Object.defineProperty(wrappedResponse, "url", { value: response.url });
|
|
784
|
+
return wrappedResponse;
|
|
785
|
+
};
|
|
786
|
+
|
|
549
787
|
//#endregion
|
|
550
788
|
//#region src/lib/auth.ts
|
|
551
789
|
const copilotVersion$1 = "0.26.7";
|
|
@@ -570,6 +808,18 @@ const readGitHubToken = async () => {
|
|
|
570
808
|
registerLogSecret(token.trim());
|
|
571
809
|
return token.trim();
|
|
572
810
|
};
|
|
811
|
+
const readStoredGitHubToken = async () => {
|
|
812
|
+
let content;
|
|
813
|
+
try {
|
|
814
|
+
content = await fs.readFile(paths.githubTokenPath, "utf8");
|
|
815
|
+
} catch (error) {
|
|
816
|
+
if (error.code === "ENOENT") return;
|
|
817
|
+
throw error;
|
|
818
|
+
}
|
|
819
|
+
const token = content.trim();
|
|
820
|
+
registerLogSecret(token);
|
|
821
|
+
return token === "" ? void 0 : token;
|
|
822
|
+
};
|
|
573
823
|
const writeGitHubToken = async (token) => {
|
|
574
824
|
registerLogSecret(token.trim());
|
|
575
825
|
await ensurePaths();
|
|
@@ -632,7 +882,7 @@ const githubHeaders = (githubToken, vsCodeVersion) => ({
|
|
|
632
882
|
"x-vscode-user-agent-library-version": "electron-fetch"
|
|
633
883
|
});
|
|
634
884
|
const getDeviceCode = async () => {
|
|
635
|
-
const response = await
|
|
885
|
+
const response = await fetchUpstream(`${githubBaseUrl}/login/device/code`, {
|
|
636
886
|
method: "POST",
|
|
637
887
|
headers: standardHeaders(),
|
|
638
888
|
body: JSON.stringify({
|
|
@@ -646,7 +896,7 @@ const getDeviceCode = async () => {
|
|
|
646
896
|
const pollAccessToken = async (deviceCode) => {
|
|
647
897
|
const sleepDuration = (deviceCode.interval + 1) * 1e3;
|
|
648
898
|
while (true) {
|
|
649
|
-
const response = await
|
|
899
|
+
const response = await fetchUpstream(`${githubBaseUrl}/login/oauth/access_token`, {
|
|
650
900
|
method: "POST",
|
|
651
901
|
headers: standardHeaders(),
|
|
652
902
|
body: JSON.stringify({
|
|
@@ -665,18 +915,26 @@ const pollAccessToken = async (deviceCode) => {
|
|
|
665
915
|
}
|
|
666
916
|
};
|
|
667
917
|
const getGitHubUser = async (githubToken, vsCodeVersion) => {
|
|
668
|
-
const response = await
|
|
918
|
+
const response = await fetchUpstream(`${githubApiBaseUrl}/user`, { headers: githubHeaders(githubToken, vsCodeVersion) });
|
|
669
919
|
if (!response.ok) throw new HTTPError("Failed to get GitHub user", response);
|
|
670
920
|
return await response.json();
|
|
671
921
|
};
|
|
672
922
|
const getCopilotToken = async (githubToken, vsCodeVersion, signal) => {
|
|
673
|
-
const response = await
|
|
923
|
+
const response = await fetchUpstream(`${githubApiBaseUrl}/copilot_internal/v2/token`, {
|
|
674
924
|
headers: githubHeaders(githubToken, vsCodeVersion),
|
|
675
925
|
signal
|
|
676
926
|
});
|
|
677
927
|
if (!response.ok) throw new HTTPError("Failed to get Copilot token", response);
|
|
678
928
|
return await response.json();
|
|
679
929
|
};
|
|
930
|
+
const getCopilotUsage = async (githubToken, vsCodeVersion, signal) => {
|
|
931
|
+
const response = await fetchUpstream(`${githubApiBaseUrl}/copilot_internal/user`, {
|
|
932
|
+
headers: githubHeaders(githubToken, vsCodeVersion),
|
|
933
|
+
signal
|
|
934
|
+
});
|
|
935
|
+
if (!response.ok) throw new HTTPError("Failed to get Copilot usage", response);
|
|
936
|
+
return response.json();
|
|
937
|
+
};
|
|
680
938
|
const isCopilotTokenError = (error) => error instanceof HTTPError && error.message === "Failed to get Copilot token";
|
|
681
939
|
const ensureGitHubToken = async (options = {}) => {
|
|
682
940
|
const existingToken = options.force ? "" : await readGitHubToken();
|
|
@@ -861,7 +1119,13 @@ var FileConflictError = class extends Error {
|
|
|
861
1119
|
this.name = "FileConflictError";
|
|
862
1120
|
}
|
|
863
1121
|
};
|
|
864
|
-
|
|
1122
|
+
var NotRegularFileError = class extends Error {
|
|
1123
|
+
constructor(filePath) {
|
|
1124
|
+
super(`${filePath} is not a regular file`);
|
|
1125
|
+
this.name = "NotRegularFileError";
|
|
1126
|
+
}
|
|
1127
|
+
};
|
|
1128
|
+
const isMissing$1 = (error) => error.code === "ENOENT";
|
|
865
1129
|
const identityOf = (stat) => ({
|
|
866
1130
|
dev: stat.dev,
|
|
867
1131
|
ino: stat.ino,
|
|
@@ -874,9 +1138,9 @@ const resolveTarget = async (filePath, remainingLinks = 40) => {
|
|
|
874
1138
|
try {
|
|
875
1139
|
return await fs.realpath(filePath);
|
|
876
1140
|
} catch (error) {
|
|
877
|
-
if (!isMissing(error)) throw error;
|
|
1141
|
+
if (!isMissing$1(error)) throw error;
|
|
878
1142
|
if ((await fs.lstat(filePath).catch((cause) => {
|
|
879
|
-
if (!isMissing(cause)) throw cause;
|
|
1143
|
+
if (!isMissing$1(cause)) throw cause;
|
|
880
1144
|
}))?.isSymbolicLink()) {
|
|
881
1145
|
if (remainingLinks === 0) throw new Error("Too many symbolic links");
|
|
882
1146
|
return resolveTarget(path.resolve(path.dirname(filePath), await fs.readlink(filePath)), remainingLinks - 1);
|
|
@@ -890,7 +1154,7 @@ const readStableSnapshot = async (filePath) => {
|
|
|
890
1154
|
const requestedPath = path.resolve(filePath);
|
|
891
1155
|
const resolvedPath = await resolveTarget(requestedPath);
|
|
892
1156
|
const before = await fs.lstat(resolvedPath).catch((error) => {
|
|
893
|
-
if (!isMissing(error)) throw error;
|
|
1157
|
+
if (!isMissing$1(error)) throw error;
|
|
894
1158
|
});
|
|
895
1159
|
if (!before) return {
|
|
896
1160
|
requestedPath,
|
|
@@ -899,14 +1163,14 @@ const readStableSnapshot = async (filePath) => {
|
|
|
899
1163
|
mode: 384,
|
|
900
1164
|
identity: null
|
|
901
1165
|
};
|
|
902
|
-
if (!before.isFile()) throw new
|
|
1166
|
+
if (!before.isFile()) throw new NotRegularFileError(resolvedPath);
|
|
903
1167
|
let raw;
|
|
904
1168
|
let after;
|
|
905
1169
|
try {
|
|
906
1170
|
raw = await fs.readFile(resolvedPath, "utf8");
|
|
907
1171
|
after = await fs.lstat(resolvedPath);
|
|
908
1172
|
} catch (error) {
|
|
909
|
-
if (isMissing(error)) throw new FileConflictError();
|
|
1173
|
+
if (isMissing$1(error)) throw new FileConflictError();
|
|
910
1174
|
throw error;
|
|
911
1175
|
}
|
|
912
1176
|
const snapshot = {
|
|
@@ -955,7 +1219,7 @@ const assertUnchanged = async (expected) => {
|
|
|
955
1219
|
if (current.resolvedPath !== expected.resolvedPath || current.raw !== expected.raw || current.mode !== expected.mode || !sameIdentity(current.identity, expected.identity)) throw new FileConflictError();
|
|
956
1220
|
};
|
|
957
1221
|
const pendingWrites = /* @__PURE__ */ new Map();
|
|
958
|
-
const publish = async (snapshot, content) => {
|
|
1222
|
+
const publish = async (snapshot, content, options) => {
|
|
959
1223
|
await assertUnchanged(snapshot);
|
|
960
1224
|
const directory = path.dirname(snapshot.resolvedPath);
|
|
961
1225
|
await fs.mkdir(directory, {
|
|
@@ -968,7 +1232,7 @@ const publish = async (snapshot, content) => {
|
|
|
968
1232
|
try {
|
|
969
1233
|
try {
|
|
970
1234
|
await handle.writeFile(content, "utf8");
|
|
971
|
-
if (process.platform !== "win32") await handle.chmod(snapshot.mode);
|
|
1235
|
+
if (process.platform !== "win32") await handle.chmod(options.ownerOnly ? 384 : snapshot.mode);
|
|
972
1236
|
await handle.sync();
|
|
973
1237
|
} finally {
|
|
974
1238
|
await handle.close();
|
|
@@ -985,8 +1249,8 @@ const publish = async (snapshot, content) => {
|
|
|
985
1249
|
await fs.rm(temporaryPath, { force: true });
|
|
986
1250
|
}
|
|
987
1251
|
};
|
|
988
|
-
const writeFileSnapshot = async (snapshot, content) => {
|
|
989
|
-
const current = (pendingWrites.get(snapshot.resolvedPath) ?? Promise.resolve()).catch(() => void 0).then(() => publish(snapshot, content));
|
|
1252
|
+
const writeFileSnapshot = async (snapshot, content, options = {}) => {
|
|
1253
|
+
const current = (pendingWrites.get(snapshot.resolvedPath) ?? Promise.resolve()).catch(() => void 0).then(() => publish(snapshot, content, options));
|
|
990
1254
|
pendingWrites.set(snapshot.resolvedPath, current);
|
|
991
1255
|
try {
|
|
992
1256
|
await current;
|
|
@@ -995,6 +1259,29 @@ const writeFileSnapshot = async (snapshot, content) => {
|
|
|
995
1259
|
}
|
|
996
1260
|
};
|
|
997
1261
|
|
|
1262
|
+
//#endregion
|
|
1263
|
+
//#region src/lib/terminal.ts
|
|
1264
|
+
const colorEnabled = (env = process.env, isTTY = Boolean(process.stdout.isTTY)) => {
|
|
1265
|
+
if (env.NO_COLOR !== void 0 || env.FORCE_COLOR === "0") return false;
|
|
1266
|
+
if (env.FORCE_COLOR !== void 0) return env.FORCE_COLOR === "" || [
|
|
1267
|
+
"1",
|
|
1268
|
+
"2",
|
|
1269
|
+
"3",
|
|
1270
|
+
"true"
|
|
1271
|
+
].includes(env.FORCE_COLOR);
|
|
1272
|
+
return isTTY && env.TERM !== "dumb";
|
|
1273
|
+
};
|
|
1274
|
+
const ansiColorCodes = {
|
|
1275
|
+
good: 32,
|
|
1276
|
+
bad: 31,
|
|
1277
|
+
warning: 33,
|
|
1278
|
+
muted: 90
|
|
1279
|
+
};
|
|
1280
|
+
const hiddenCharacters = /[\x80-\x9f]|\p{Cf}|\p{Zl}|\p{Zp}/gu;
|
|
1281
|
+
const withoutHiddenCharacters = (value) => value.replace(hiddenCharacters, "");
|
|
1282
|
+
const terminalText = (value) => withoutHiddenCharacters(sanitizeTerminalString(value));
|
|
1283
|
+
const colorText = (text$1, tone, enabled) => enabled ? `\u001b[${ansiColorCodes[tone]}m${text$1}\u001b[0m` : text$1;
|
|
1284
|
+
|
|
998
1285
|
//#endregion
|
|
999
1286
|
//#region src/lib/app-config.ts
|
|
1000
1287
|
const logLevels = [
|
|
@@ -1011,6 +1298,7 @@ const logLevels = [
|
|
|
1011
1298
|
* rewrite a value the user's config already holds.
|
|
1012
1299
|
*/
|
|
1013
1300
|
const defaultConfig = {
|
|
1301
|
+
apiKey: "",
|
|
1014
1302
|
claudeSetup: true,
|
|
1015
1303
|
copilotBaseUrl: "https://api.githubcopilot.com",
|
|
1016
1304
|
gptModel: "gpt-6-astra",
|
|
@@ -1022,6 +1310,7 @@ const defaultConfig = {
|
|
|
1022
1310
|
thinkEffort: defaultReasoningEffort,
|
|
1023
1311
|
upstreamTimeoutSeconds: 180,
|
|
1024
1312
|
webSearchBackend: void 0,
|
|
1313
|
+
upstreamProxy: void 0,
|
|
1025
1314
|
claudeUpstreamApi: "chat-completions"
|
|
1026
1315
|
};
|
|
1027
1316
|
const isLogLevelName = (value) => typeof value === "string" && logLevels.includes(value.toLowerCase());
|
|
@@ -1029,7 +1318,7 @@ const normalizeLogLevel = (value) => {
|
|
|
1029
1318
|
if (value === void 0) return;
|
|
1030
1319
|
if (typeof value !== "string") throw new Error(`Invalid logLevel: expected one of ${logLevels.join(", ")}`);
|
|
1031
1320
|
const normalized = value.toLowerCase();
|
|
1032
|
-
if (!isLogLevelName(normalized)) throw new Error(`Invalid logLevel
|
|
1321
|
+
if (!isLogLevelName(normalized)) throw new Error(`Invalid logLevel: expected one of ${logLevels.join(", ")}`);
|
|
1033
1322
|
return normalized;
|
|
1034
1323
|
};
|
|
1035
1324
|
const normalizeClaudeUpstreamApi = (value) => {
|
|
@@ -1059,6 +1348,22 @@ const normalizeRequiredString = (value, key) => {
|
|
|
1059
1348
|
if (normalized === void 0) throw new Error(`Invalid ${key}: expected a non-empty string`);
|
|
1060
1349
|
return normalized;
|
|
1061
1350
|
};
|
|
1351
|
+
const invalidApiKeyMessage = "Invalid apiKey: expected empty, or at least 16 visible ASCII characters";
|
|
1352
|
+
/**
|
|
1353
|
+
* Validates the optional inbound apiKey. Empty disables it.
|
|
1354
|
+
*
|
|
1355
|
+
* A set key is at least 16 visible ASCII characters. It travels in an HTTP
|
|
1356
|
+
* header, which cannot carry a space, a control byte or a non-ASCII character
|
|
1357
|
+
* reliably. Every logged copy of it is redacted as text, which would also hide
|
|
1358
|
+
* any ordinary text that a very short key happened to match.
|
|
1359
|
+
*/
|
|
1360
|
+
const normalizeApiKey = (value) => {
|
|
1361
|
+
if (value === void 0) return;
|
|
1362
|
+
if (typeof value !== "string") throw new Error(invalidApiKeyMessage);
|
|
1363
|
+
const trimmed = value.trim();
|
|
1364
|
+
if (trimmed !== "" && !/^[\x21-\x7E]{16,}$/.test(trimmed)) throw new Error(invalidApiKeyMessage);
|
|
1365
|
+
return trimmed;
|
|
1366
|
+
};
|
|
1062
1367
|
/**
|
|
1063
1368
|
* A conventional http(s) URL: scheme followed by a literal "//" authority.
|
|
1064
1369
|
*
|
|
@@ -1068,7 +1373,7 @@ const normalizeRequiredString = (value, key) => {
|
|
|
1068
1373
|
* through. None of them can be found again in arbitrary log text: practical URL
|
|
1069
1374
|
* detection anchors on a literal "://", and loosening that to chase a bare
|
|
1070
1375
|
* "https:" would start matching ordinary prose. Requiring the authority prefix
|
|
1071
|
-
* is the one boundary where this has a definite answer.
|
|
1376
|
+
* is the one boundary where this has a definite answer.
|
|
1072
1377
|
*/
|
|
1073
1378
|
const conventionalHttpUrlPattern = /^https?:\/\//i;
|
|
1074
1379
|
/**
|
|
@@ -1085,7 +1390,7 @@ const conventionalHttpUrlPattern = /^https?:\/\//i;
|
|
|
1085
1390
|
*
|
|
1086
1391
|
* Rejecting the raw byte costs nothing: every one of these has a
|
|
1087
1392
|
* percent-encoded spelling that is unambiguous in log text and is accepted
|
|
1088
|
-
* here unchanged.
|
|
1393
|
+
* here unchanged.
|
|
1089
1394
|
*/
|
|
1090
1395
|
const unsafeRawDelimiterPattern = /['"`<>\s\u0000-\u001F\u007F]/;
|
|
1091
1396
|
/**
|
|
@@ -1099,7 +1404,7 @@ const copilotBaseUrlDelimiterMessage = "Invalid copilotBaseUrl: quotes, backtick
|
|
|
1099
1404
|
/**
|
|
1100
1405
|
* Validates copilotBaseUrl as a conventional HTTP(S) URL without credentials.
|
|
1101
1406
|
*
|
|
1102
|
-
* Four rules
|
|
1407
|
+
* Four rules:
|
|
1103
1408
|
*
|
|
1104
1409
|
* The value is concatenated as `${base}${path}` for every upstream call, so it
|
|
1105
1410
|
* must be something Undici will accept. A relative or non-HTTP value fails at
|
|
@@ -1140,6 +1445,49 @@ const normalizeCopilotBaseUrl = (value) => {
|
|
|
1140
1445
|
if (parsed.username || parsed.password) throw new Error(copilotBaseUrlCredentialsMessage);
|
|
1141
1446
|
return trimmed;
|
|
1142
1447
|
};
|
|
1448
|
+
/**
|
|
1449
|
+
* Fixed strings, like the copilotBaseUrl messages: a proxy URL can carry a password, and these
|
|
1450
|
+
* messages reach the terminal and the log, so the rejected value is never repeated.
|
|
1451
|
+
*/
|
|
1452
|
+
const invalidUpstreamProxyMessage = "Invalid upstreamProxy: expected empty, env, or an absolute http(s) proxy URL such as http://proxy.example:3128";
|
|
1453
|
+
const upstreamProxyPathMessage = "Invalid upstreamProxy: a proxy URL takes no path, query or fragment";
|
|
1454
|
+
const upstreamProxyCredentialsMessage = "Invalid upstreamProxy: credentials need both a username and a password, with reserved characters percent-encoded";
|
|
1455
|
+
const canDecodeURIComponent = (value) => {
|
|
1456
|
+
try {
|
|
1457
|
+
return true;
|
|
1458
|
+
} catch {
|
|
1459
|
+
return false;
|
|
1460
|
+
}
|
|
1461
|
+
};
|
|
1462
|
+
/**
|
|
1463
|
+
* Validates upstreamProxy: empty, the literal "env", or an absolute http(s) proxy URL.
|
|
1464
|
+
*
|
|
1465
|
+
* Each rule stops a value that undici's ProxyAgent would turn into a confusing failure. A path,
|
|
1466
|
+
* query or fragment makes it throw "invalid url" when the relay builds its dispatcher. A username
|
|
1467
|
+
* without a password, or the reverse, makes it send no Proxy-Authorization at all. A malformed
|
|
1468
|
+
* percent-escape in the credentials makes its decodeURIComponent throw. The explicit "http://" or
|
|
1469
|
+
* "https://" prefix is required for the reason copilotBaseUrl requires it: the shorthand forms
|
|
1470
|
+
* WHATWG also accepts are not URLs anyone would recognize in a log line.
|
|
1471
|
+
*
|
|
1472
|
+
* Userinfo is accepted, unlike in copilotBaseUrl: it is how a proxy takes Basic credentials, and
|
|
1473
|
+
* configureUpstreamDispatcher registers it with the log redaction when it builds the dispatcher.
|
|
1474
|
+
*
|
|
1475
|
+
* The accepted value is the trimmed original, which is what the dispatcher is built from.
|
|
1476
|
+
*/
|
|
1477
|
+
const normalizeUpstreamProxy = (value) => {
|
|
1478
|
+
const trimmed = normalizeString(value);
|
|
1479
|
+
if (trimmed === void 0 || trimmed === "env") return trimmed;
|
|
1480
|
+
if (!conventionalHttpUrlPattern.test(trimmed)) throw new Error(invalidUpstreamProxyMessage);
|
|
1481
|
+
let parsed;
|
|
1482
|
+
try {
|
|
1483
|
+
parsed = new URL(trimmed);
|
|
1484
|
+
} catch {
|
|
1485
|
+
throw new Error(invalidUpstreamProxyMessage);
|
|
1486
|
+
}
|
|
1487
|
+
if (parsed.pathname !== "/" || parsed.search || parsed.hash) throw new Error(upstreamProxyPathMessage);
|
|
1488
|
+
if (parsed.username !== "" !== (parsed.password !== "") || !canDecodeURIComponent(parsed.username) || !canDecodeURIComponent(parsed.password)) throw new Error(upstreamProxyCredentialsMessage);
|
|
1489
|
+
return trimmed;
|
|
1490
|
+
};
|
|
1143
1491
|
var InvalidThinkEffortError = class extends Error {
|
|
1144
1492
|
constructor() {
|
|
1145
1493
|
super(`Invalid thinkEffort. Valid values: ${configurableReasoningEfforts.join(", ")}. "none" is not allowed as a configured default.`);
|
|
@@ -1180,6 +1528,7 @@ const readDefaultConfigTemplate = async () => {
|
|
|
1180
1528
|
}
|
|
1181
1529
|
};
|
|
1182
1530
|
const configAliases = {
|
|
1531
|
+
api_key: "apiKey",
|
|
1183
1532
|
claude_setup: "claudeSetup",
|
|
1184
1533
|
copilot_base_url: "copilotBaseUrl",
|
|
1185
1534
|
gpt_model: "gptModel",
|
|
@@ -1187,6 +1536,7 @@ const configAliases = {
|
|
|
1187
1536
|
log_retention_days: "logRetentionDays",
|
|
1188
1537
|
opus_model: "opusModel",
|
|
1189
1538
|
think_effort: "thinkEffort",
|
|
1539
|
+
upstream_proxy: "upstreamProxy",
|
|
1190
1540
|
upstream_timeout_seconds: "upstreamTimeoutSeconds",
|
|
1191
1541
|
web_search_backend: "webSearchBackend",
|
|
1192
1542
|
claude_upstream_api: "claudeUpstreamApi"
|
|
@@ -1238,7 +1588,7 @@ const serializeConfig = (config) => [
|
|
|
1238
1588
|
"# copilot-relay configuration",
|
|
1239
1589
|
"#",
|
|
1240
1590
|
"# Valid complete edits hot-reload without rewriting this file.",
|
|
1241
|
-
"# host, port, and
|
|
1591
|
+
"# host, port, claudeSetup, and upstreamProxy require restart.",
|
|
1242
1592
|
"",
|
|
1243
1593
|
"# Local host for the Claude Code-compatible HTTP server.",
|
|
1244
1594
|
`host: ${config.host}`,
|
|
@@ -1246,9 +1596,17 @@ const serializeConfig = (config) => [
|
|
|
1246
1596
|
"# Local port for the HTTP server.",
|
|
1247
1597
|
`port: ${config.port}`,
|
|
1248
1598
|
"",
|
|
1599
|
+
"# Key clients must send as x-api-key or Authorization: Bearer; empty disables it.",
|
|
1600
|
+
"# Set one before binding host beyond loopback.",
|
|
1601
|
+
`apiKey: ${config.apiKey}`,
|
|
1602
|
+
"",
|
|
1249
1603
|
"# GitHub Copilot API base URL.",
|
|
1250
1604
|
`copilotBaseUrl: ${config.copilotBaseUrl}`,
|
|
1251
1605
|
"",
|
|
1606
|
+
"# Outbound proxy for Copilot and GitHub calls. Empty connects directly; env uses",
|
|
1607
|
+
"# HTTPS_PROXY, HTTP_PROXY and NO_PROXY; or a URL such as http://proxy.example:3128.",
|
|
1608
|
+
`upstreamProxy: ${config.upstreamProxy ?? ""}`,
|
|
1609
|
+
"",
|
|
1252
1610
|
"# Update ~/.claude/settings.json on start.",
|
|
1253
1611
|
`claudeSetup: ${config.claudeSetup}`,
|
|
1254
1612
|
"",
|
|
@@ -1283,6 +1641,7 @@ const serializeConfig = (config) => [
|
|
|
1283
1641
|
""
|
|
1284
1642
|
].join("\n");
|
|
1285
1643
|
const resolveConfig = (raw) => ({
|
|
1644
|
+
apiKey: normalizeApiKey(raw.apiKey) ?? defaultConfig.apiKey,
|
|
1286
1645
|
claudeSetup: normalizeBoolean(raw.claudeSetup) ?? defaultConfig.claudeSetup,
|
|
1287
1646
|
copilotBaseUrl: normalizeCopilotBaseUrl(normalizeRequiredString(raw.copilotBaseUrl, "copilotBaseUrl")) ?? defaultConfig.copilotBaseUrl,
|
|
1288
1647
|
gptModel: normalizeRequiredString(raw.gptModel, "gptModel") ?? defaultConfig.gptModel,
|
|
@@ -1294,6 +1653,7 @@ const resolveConfig = (raw) => ({
|
|
|
1294
1653
|
thinkEffort: normalizeThinkEffort(raw.thinkEffort) ?? defaultConfig.thinkEffort,
|
|
1295
1654
|
upstreamTimeoutSeconds: normalizeUpstreamTimeoutSeconds(raw.upstreamTimeoutSeconds) ?? defaultConfig.upstreamTimeoutSeconds,
|
|
1296
1655
|
webSearchBackend: normalizeString(raw.webSearchBackend),
|
|
1656
|
+
upstreamProxy: normalizeUpstreamProxy(raw.upstreamProxy),
|
|
1297
1657
|
claudeUpstreamApi: normalizeClaudeUpstreamApi(raw.claudeUpstreamApi) ?? defaultConfig.claudeUpstreamApi
|
|
1298
1658
|
});
|
|
1299
1659
|
async function readAppConfig() {
|
|
@@ -1305,26 +1665,64 @@ async function readAppConfig() {
|
|
|
1305
1665
|
if (content !== snapshot.raw) await writeFileSnapshot(snapshot, content);
|
|
1306
1666
|
return config;
|
|
1307
1667
|
}
|
|
1668
|
+
/**
|
|
1669
|
+
* Reads and resolves config.yaml without creating, completing or rewriting it, for a command that
|
|
1670
|
+
* writes no file, such as `usage`. Returns undefined when there is no config.yaml; unlike
|
|
1671
|
+
* readAppConfig, it consults neither a legacy config file nor the default template. An invalid
|
|
1672
|
+
* file throws the error readAppConfig would.
|
|
1673
|
+
*/
|
|
1674
|
+
const readExistingAppConfig = async () => {
|
|
1675
|
+
const snapshot = await readFileSnapshot(paths.configPath);
|
|
1676
|
+
if (snapshot.raw === null) return;
|
|
1677
|
+
return resolveConfig(parseConfigYaml(snapshot.raw));
|
|
1678
|
+
};
|
|
1308
1679
|
const sameSnapshot = (left, right) => left.resolvedPath === right.resolvedPath && left.raw === right.raw && left.mode === right.mode && JSON.stringify(left.identity) === JSON.stringify(right.identity);
|
|
1680
|
+
const isSameFailure = (reported, reason, snapshot) => {
|
|
1681
|
+
if (!reported || reported.reason !== reason) return false;
|
|
1682
|
+
if (!reported.snapshot || !snapshot) return reported.snapshot === snapshot;
|
|
1683
|
+
return sameSnapshot(reported.snapshot, snapshot);
|
|
1684
|
+
};
|
|
1685
|
+
const readConfigSnapshot = async () => {
|
|
1686
|
+
try {
|
|
1687
|
+
return await readFileSnapshot(paths.configPath);
|
|
1688
|
+
} catch (error) {
|
|
1689
|
+
if (error instanceof FileConflictError) return;
|
|
1690
|
+
throw error;
|
|
1691
|
+
}
|
|
1692
|
+
};
|
|
1309
1693
|
const watchAppConfig = (onReload) => {
|
|
1310
1694
|
let lastSnapshot;
|
|
1695
|
+
let reportedFailure;
|
|
1311
1696
|
let reloading = false;
|
|
1312
1697
|
const timer = setInterval(async () => {
|
|
1313
1698
|
if (reloading) return;
|
|
1314
1699
|
reloading = true;
|
|
1700
|
+
let snapshot;
|
|
1315
1701
|
try {
|
|
1316
|
-
|
|
1317
|
-
if (
|
|
1702
|
+
snapshot = await readConfigSnapshot();
|
|
1703
|
+
if (!snapshot) return;
|
|
1704
|
+
if (lastSnapshot && sameSnapshot(lastSnapshot, snapshot)) {
|
|
1705
|
+
reportedFailure = void 0;
|
|
1706
|
+
return;
|
|
1707
|
+
}
|
|
1318
1708
|
if (snapshot.raw === null) throw new Error("Config file is missing");
|
|
1319
1709
|
const raw = parseConfigYaml(snapshot.raw);
|
|
1320
1710
|
if (configKeys.some((key) => !Object.hasOwn(raw, key))) throw new Error("Config reload requires every materialized key");
|
|
1321
1711
|
const config = resolveConfig(raw);
|
|
1322
|
-
|
|
1712
|
+
const current = await readConfigSnapshot();
|
|
1713
|
+
if (!current || !sameSnapshot(snapshot, current)) return;
|
|
1323
1714
|
onReload(config);
|
|
1324
1715
|
lastSnapshot = snapshot;
|
|
1716
|
+
reportedFailure = void 0;
|
|
1325
1717
|
} catch (error) {
|
|
1326
|
-
|
|
1327
|
-
|
|
1718
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
1719
|
+
if (isSameFailure(reportedFailure, reason, snapshot)) return;
|
|
1720
|
+
reportedFailure = {
|
|
1721
|
+
reason,
|
|
1722
|
+
snapshot
|
|
1723
|
+
};
|
|
1724
|
+
const sentence = /[.!?]$/.test(reason) ? reason : `${reason}.`;
|
|
1725
|
+
log.error(terminalText(`Could not reload config (${paths.configPath}): ${sentence} Keeping the previous runtime settings.`));
|
|
1328
1726
|
} finally {
|
|
1329
1727
|
reloading = false;
|
|
1330
1728
|
}
|
|
@@ -1363,7 +1761,8 @@ const readProxyConfig = (config) => ({
|
|
|
1363
1761
|
upstreamTimeoutMs: config.upstreamTimeoutSeconds * 1e3,
|
|
1364
1762
|
vsCodeVersion: vscodeVersion,
|
|
1365
1763
|
webSearchBackend: config.webSearchBackend,
|
|
1366
|
-
claudeUpstreamApi: config.claudeUpstreamApi
|
|
1764
|
+
claudeUpstreamApi: config.claudeUpstreamApi,
|
|
1765
|
+
apiKey: config.apiKey
|
|
1367
1766
|
});
|
|
1368
1767
|
|
|
1369
1768
|
//#endregion
|
|
@@ -1374,7 +1773,9 @@ const auth = defineCommand({
|
|
|
1374
1773
|
description: "Run GitHub device auth and cache credentials for copilot-relay."
|
|
1375
1774
|
},
|
|
1376
1775
|
async run() {
|
|
1377
|
-
const
|
|
1776
|
+
const appConfig = await readAppConfig();
|
|
1777
|
+
configureUpstreamDispatcher(appConfig.upstreamProxy);
|
|
1778
|
+
const config = readProxyConfig(appConfig);
|
|
1378
1779
|
config.copilotToken = void 0;
|
|
1379
1780
|
const authSession = await setupProxyAuth(config, { force: true });
|
|
1380
1781
|
if (authSession.githubLogin) log.info(`Logged in as ${authSession.githubLogin}`);
|
|
@@ -1382,6 +1783,468 @@ const auth = defineCommand({
|
|
|
1382
1783
|
}
|
|
1383
1784
|
});
|
|
1384
1785
|
|
|
1786
|
+
//#endregion
|
|
1787
|
+
//#region src/lib/cache-report.ts
|
|
1788
|
+
const completionRoutes = [
|
|
1789
|
+
"/chat/completions",
|
|
1790
|
+
"/responses",
|
|
1791
|
+
"/v1/messages"
|
|
1792
|
+
];
|
|
1793
|
+
/** A flag the command cannot use. The message is shown to the user as it is. */
|
|
1794
|
+
var CacheUsageError = class extends Error {};
|
|
1795
|
+
const defaultGoal = 95;
|
|
1796
|
+
const timestampPattern = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
|
|
1797
|
+
const fieldPattern = /^([A-Za-z0-9_]+)=(\S+)$/;
|
|
1798
|
+
const countPattern = /^\d+$/;
|
|
1799
|
+
const countFields = [
|
|
1800
|
+
"input_tokens",
|
|
1801
|
+
"cache_read_input_tokens",
|
|
1802
|
+
"cache_creation_input_tokens"
|
|
1803
|
+
];
|
|
1804
|
+
const isCompletionRoute = (value) => completionRoutes.some((route) => route === value);
|
|
1805
|
+
const parseTimestamp = (text$1) => {
|
|
1806
|
+
if (!timestampPattern.test(text$1)) return;
|
|
1807
|
+
const date = new Date(text$1);
|
|
1808
|
+
if (Number.isNaN(date.getTime()) || date.toISOString() !== text$1) return;
|
|
1809
|
+
return date;
|
|
1810
|
+
};
|
|
1811
|
+
const isCount = (value) => value !== void 0 && countPattern.test(value) && Number.isSafeInteger(Number(value));
|
|
1812
|
+
const countField = (fields, key) => {
|
|
1813
|
+
const value = fields.get(key);
|
|
1814
|
+
return value === void 0 ? void 0 : Number(value);
|
|
1815
|
+
};
|
|
1816
|
+
/**
|
|
1817
|
+
* The completion entry on a log line, or undefined for any other line.
|
|
1818
|
+
*
|
|
1819
|
+
* Only a successful call is returned: an info entry for HTTP 200 on one of the three routes, with
|
|
1820
|
+
* a numeric input_tokens. A line that is not exactly what the relay writes is skipped rather than
|
|
1821
|
+
* guessed at, since one bad count would skew a whole row.
|
|
1822
|
+
*/
|
|
1823
|
+
const parseCompletionLine = (line) => {
|
|
1824
|
+
if (!line.includes(" completion path=")) return;
|
|
1825
|
+
const tokens = line.split(" ");
|
|
1826
|
+
const timestamp = parseTimestamp(tokens[0]);
|
|
1827
|
+
const marker = tokens.indexOf("completion", 2);
|
|
1828
|
+
if (timestamp === void 0 || tokens[1] !== "info" || marker === -1) return;
|
|
1829
|
+
if (!tokens.slice(2, marker).every((token) => fieldPattern.test(token))) return;
|
|
1830
|
+
const fields = /* @__PURE__ */ new Map();
|
|
1831
|
+
for (const token of tokens.slice(marker + 1)) {
|
|
1832
|
+
const field = fieldPattern.exec(token);
|
|
1833
|
+
if (field === null || fields.has(field[1])) return;
|
|
1834
|
+
fields.set(field[1], field[2]);
|
|
1835
|
+
}
|
|
1836
|
+
if (countFields.some((key) => fields.has(key) && !isCount(fields.get(key)))) return;
|
|
1837
|
+
const route = fields.get("path");
|
|
1838
|
+
const inputTokens = countField(fields, "input_tokens");
|
|
1839
|
+
const cacheReadTokens = countField(fields, "cache_read_input_tokens");
|
|
1840
|
+
const cacheWriteTokens = countField(fields, "cache_creation_input_tokens");
|
|
1841
|
+
if (!isCompletionRoute(route) || fields.get("http_status") !== "200" || inputTokens === void 0) return;
|
|
1842
|
+
if (route !== "/v1/messages" && cacheReadTokens !== void 0 && cacheReadTokens > inputTokens) return;
|
|
1843
|
+
return {
|
|
1844
|
+
timestamp,
|
|
1845
|
+
model: terminalText(fields.get("model") ?? "") || "unknown",
|
|
1846
|
+
route,
|
|
1847
|
+
inputTokens,
|
|
1848
|
+
...cacheReadTokens !== void 0 && { cacheReadTokens },
|
|
1849
|
+
...cacheWriteTokens !== void 0 && { cacheWriteTokens }
|
|
1850
|
+
};
|
|
1851
|
+
};
|
|
1852
|
+
const datedLogFilePattern = /* @__PURE__ */ new RegExp(`^${paths.logFileBaseName}\\.(\\d{4})-(\\d{2})-(\\d{2})\\.log$`);
|
|
1853
|
+
const dayNumber = (year, month, day) => year * 1e4 + month * 100 + day;
|
|
1854
|
+
const maxLineLength = 1024 * 1024;
|
|
1855
|
+
const isMissing = (error) => error.code === "ENOENT";
|
|
1856
|
+
const readLogDirectory = async () => {
|
|
1857
|
+
try {
|
|
1858
|
+
return await fs.readdir(paths.logsDir, { withFileTypes: true });
|
|
1859
|
+
} catch (error) {
|
|
1860
|
+
if (isMissing(error)) return [];
|
|
1861
|
+
throw error;
|
|
1862
|
+
}
|
|
1863
|
+
};
|
|
1864
|
+
/**
|
|
1865
|
+
* The dated log files that can hold entries at or after `since`; all of them without it.
|
|
1866
|
+
*
|
|
1867
|
+
* A file is named for the local date its entries were written on. UTC offsets span 26 hours, from
|
|
1868
|
+
* UTC-12 to UTC+14, so after a time-zone change an entry can sit in a file dated up to two days
|
|
1869
|
+
* before the date the current zone gives `since`. Those days are read too, and every entry is then
|
|
1870
|
+
* checked against `since` itself.
|
|
1871
|
+
*/
|
|
1872
|
+
const listLogFiles = async (since) => {
|
|
1873
|
+
let earliestDay = Number.NEGATIVE_INFINITY;
|
|
1874
|
+
if (since !== void 0) {
|
|
1875
|
+
const twoDaysBefore = new Date(since.getTime());
|
|
1876
|
+
twoDaysBefore.setDate(twoDaysBefore.getDate() - 2);
|
|
1877
|
+
if (!Number.isNaN(twoDaysBefore.getTime())) earliestDay = dayNumber(twoDaysBefore.getFullYear(), twoDaysBefore.getMonth() + 1, twoDaysBefore.getDate());
|
|
1878
|
+
}
|
|
1879
|
+
return (await readLogDirectory()).filter((entry) => {
|
|
1880
|
+
const date = datedLogFilePattern.exec(entry.name);
|
|
1881
|
+
if (!entry.isFile() || date === null) return false;
|
|
1882
|
+
return dayNumber(Number(date[1]), Number(date[2]), Number(date[3])) >= earliestDay;
|
|
1883
|
+
}).map((entry) => path.join(paths.logsDir, entry.name));
|
|
1884
|
+
};
|
|
1885
|
+
const withoutCarriageReturn = (line) => line.endsWith("\r") ? line.slice(0, -1) : line;
|
|
1886
|
+
/**
|
|
1887
|
+
* The complete lines of a file. A last line without its line break is dropped: the relay may still
|
|
1888
|
+
* be appending it, and half an entry can hold a cut-off count that still parses as a number.
|
|
1889
|
+
*/
|
|
1890
|
+
async function* readLines(filePath) {
|
|
1891
|
+
const stream = createReadStream(filePath, { encoding: "utf8" });
|
|
1892
|
+
let partial = "";
|
|
1893
|
+
let oversized = false;
|
|
1894
|
+
for await (const chunk of stream) {
|
|
1895
|
+
const pieces = chunk.split("\n");
|
|
1896
|
+
const unfinished = pieces.pop() ?? "";
|
|
1897
|
+
for (const piece of pieces) {
|
|
1898
|
+
if (!oversized) yield withoutCarriageReturn(partial + piece);
|
|
1899
|
+
partial = "";
|
|
1900
|
+
oversized = false;
|
|
1901
|
+
}
|
|
1902
|
+
if (!oversized) partial += unfinished;
|
|
1903
|
+
if (partial.length > maxLineLength) {
|
|
1904
|
+
partial = "";
|
|
1905
|
+
oversized = true;
|
|
1906
|
+
}
|
|
1907
|
+
}
|
|
1908
|
+
}
|
|
1909
|
+
const isInWindow = (record$3, since) => since === void 0 || record$3.timestamp.getTime() >= since.getTime();
|
|
1910
|
+
async function* readCompletionRecords(since) {
|
|
1911
|
+
for (const filePath of await listLogFiles(since)) try {
|
|
1912
|
+
for await (const line of readLines(filePath)) {
|
|
1913
|
+
const record$3 = parseCompletionLine(line);
|
|
1914
|
+
if (record$3 !== void 0 && isInWindow(record$3, since)) yield record$3;
|
|
1915
|
+
}
|
|
1916
|
+
} catch (error) {
|
|
1917
|
+
if (!isMissing(error)) throw error;
|
|
1918
|
+
}
|
|
1919
|
+
}
|
|
1920
|
+
const emptyTotals = (bucket, record$3) => ({
|
|
1921
|
+
bucket: bucket.label,
|
|
1922
|
+
...bucket.utcOffset !== void 0 && { utcOffset: bucket.utcOffset },
|
|
1923
|
+
firstCall: record$3.timestamp.getTime(),
|
|
1924
|
+
model: record$3.model,
|
|
1925
|
+
route: record$3.route,
|
|
1926
|
+
requests: 0,
|
|
1927
|
+
unknownCacheRequests: 0,
|
|
1928
|
+
zeroCacheReadRequests: 0,
|
|
1929
|
+
totalInputTokens: 0,
|
|
1930
|
+
cacheReadTokens: 0,
|
|
1931
|
+
cacheWriteTokens: null
|
|
1932
|
+
});
|
|
1933
|
+
const addRecord = (totals, record$3) => {
|
|
1934
|
+
totals.requests += 1;
|
|
1935
|
+
totals.firstCall = Math.min(totals.firstCall, record$3.timestamp.getTime());
|
|
1936
|
+
if (record$3.cacheReadTokens === void 0) {
|
|
1937
|
+
totals.unknownCacheRequests += 1;
|
|
1938
|
+
return;
|
|
1939
|
+
}
|
|
1940
|
+
const totalInput = record$3.route === "/v1/messages" ? record$3.inputTokens + record$3.cacheReadTokens + (record$3.cacheWriteTokens ?? 0) : record$3.inputTokens;
|
|
1941
|
+
totals.totalInputTokens += totalInput;
|
|
1942
|
+
totals.cacheReadTokens += record$3.cacheReadTokens;
|
|
1943
|
+
if (record$3.cacheReadTokens === 0) totals.zeroCacheReadRequests += 1;
|
|
1944
|
+
if (record$3.cacheWriteTokens !== void 0) totals.cacheWriteTokens = (totals.cacheWriteTokens ?? 0) + record$3.cacheWriteTokens;
|
|
1945
|
+
};
|
|
1946
|
+
const hitBasisPoints = (cacheReadTokens, totalInputTokens) => Number(BigInt(cacheReadTokens) * 10000n / BigInt(totalInputTokens));
|
|
1947
|
+
const toRow = (totals, goal) => {
|
|
1948
|
+
const hasInput = totals.totalInputTokens > 0;
|
|
1949
|
+
return {
|
|
1950
|
+
bucket: totals.bucket,
|
|
1951
|
+
model: totals.model,
|
|
1952
|
+
route: totals.route,
|
|
1953
|
+
requests: totals.requests,
|
|
1954
|
+
unknownCacheRequests: totals.unknownCacheRequests,
|
|
1955
|
+
zeroCacheReadRequests: totals.zeroCacheReadRequests,
|
|
1956
|
+
totalInputTokens: totals.totalInputTokens,
|
|
1957
|
+
cacheReadTokens: totals.cacheReadTokens,
|
|
1958
|
+
uncachedInputTokens: totals.totalInputTokens - totals.cacheReadTokens,
|
|
1959
|
+
cacheWriteTokens: totals.cacheWriteTokens,
|
|
1960
|
+
hitRate: hasInput ? totals.cacheReadTokens / totals.totalInputTokens : null,
|
|
1961
|
+
belowGoal: hasInput && hitBasisPoints(totals.cacheReadTokens, totals.totalInputTokens) < Math.round(goal * 100)
|
|
1962
|
+
};
|
|
1963
|
+
};
|
|
1964
|
+
const twoDigits = (value) => String(value).padStart(2, "0");
|
|
1965
|
+
const formatUtcOffset = (timestamp) => {
|
|
1966
|
+
const minutes = -timestamp.getTimezoneOffset();
|
|
1967
|
+
const sign = minutes < 0 ? "-" : "+";
|
|
1968
|
+
const absolute = Math.abs(minutes);
|
|
1969
|
+
return `UTC${sign}${twoDigits(Math.floor(absolute / 60))}:${twoDigits(absolute % 60)}`;
|
|
1970
|
+
};
|
|
1971
|
+
const bucketOf = (timestamp, view) => {
|
|
1972
|
+
if (view === "daily") return { label: formatLogDate(timestamp) };
|
|
1973
|
+
if (view === "hourly") return {
|
|
1974
|
+
label: `${formatLogDate(timestamp)} ${twoDigits(timestamp.getHours())}:00`,
|
|
1975
|
+
utcOffset: formatUtcOffset(timestamp)
|
|
1976
|
+
};
|
|
1977
|
+
return { label: null };
|
|
1978
|
+
};
|
|
1979
|
+
const compareText = (left, right) => {
|
|
1980
|
+
if (left < right) return -1;
|
|
1981
|
+
return left > right ? 1 : 0;
|
|
1982
|
+
};
|
|
1983
|
+
const segmentOf = (totals) => JSON.stringify([totals.bucket, totals.utcOffset ?? null]);
|
|
1984
|
+
const sortTotals = (groups) => {
|
|
1985
|
+
const firstCalls = /* @__PURE__ */ new Map();
|
|
1986
|
+
for (const totals of groups) {
|
|
1987
|
+
const segment = segmentOf(totals);
|
|
1988
|
+
firstCalls.set(segment, Math.min(firstCalls.get(segment) ?? totals.firstCall, totals.firstCall));
|
|
1989
|
+
}
|
|
1990
|
+
const firstCallOf = (totals) => firstCalls.get(segmentOf(totals)) ?? totals.firstCall;
|
|
1991
|
+
return [...groups].sort((left, right) => firstCallOf(left) - firstCallOf(right) || compareText(left.model, right.model) || compareText(left.route, right.route));
|
|
1992
|
+
};
|
|
1993
|
+
const labelRepeatedHours = (sorted) => {
|
|
1994
|
+
const offsetsByLabel = /* @__PURE__ */ new Map();
|
|
1995
|
+
for (const totals of sorted) if (totals.utcOffset !== void 0 && totals.bucket !== null) {
|
|
1996
|
+
const offsets = offsetsByLabel.get(totals.bucket) ?? /* @__PURE__ */ new Set();
|
|
1997
|
+
offsets.add(totals.utcOffset);
|
|
1998
|
+
offsetsByLabel.set(totals.bucket, offsets);
|
|
1999
|
+
}
|
|
2000
|
+
for (const totals of sorted) {
|
|
2001
|
+
const offsets = totals.bucket === null ? void 0 : offsetsByLabel.get(totals.bucket);
|
|
2002
|
+
if (totals.utcOffset !== void 0 && offsets !== void 0 && offsets.size > 1) totals.bucket = `${totals.bucket} ${totals.utcOffset}`;
|
|
2003
|
+
}
|
|
2004
|
+
};
|
|
2005
|
+
/** Reads the retained logs and returns one row per bucket, model and route. */
|
|
2006
|
+
const buildCacheReport = async (options) => {
|
|
2007
|
+
parseGoal(String(options.goal));
|
|
2008
|
+
const modelFilter = options.model?.toLowerCase();
|
|
2009
|
+
const groups = /* @__PURE__ */ new Map();
|
|
2010
|
+
for await (const record$3 of readCompletionRecords(options.since)) {
|
|
2011
|
+
if (modelFilter !== void 0 && !record$3.model.toLowerCase().includes(modelFilter)) continue;
|
|
2012
|
+
const bucket = bucketOf(record$3.timestamp, options.view);
|
|
2013
|
+
const key = JSON.stringify([
|
|
2014
|
+
bucket.label,
|
|
2015
|
+
bucket.utcOffset ?? null,
|
|
2016
|
+
record$3.model,
|
|
2017
|
+
record$3.route
|
|
2018
|
+
]);
|
|
2019
|
+
let totals = groups.get(key);
|
|
2020
|
+
if (totals === void 0) {
|
|
2021
|
+
totals = emptyTotals(bucket, record$3);
|
|
2022
|
+
groups.set(key, totals);
|
|
2023
|
+
}
|
|
2024
|
+
addRecord(totals, record$3);
|
|
2025
|
+
}
|
|
2026
|
+
const sorted = sortTotals([...groups.values()]);
|
|
2027
|
+
labelRepeatedHours(sorted);
|
|
2028
|
+
return sorted.map((totals) => toRow(totals, options.goal));
|
|
2029
|
+
};
|
|
2030
|
+
const minuteMs = 60 * 1e3;
|
|
2031
|
+
const hourMs = 60 * minuteMs;
|
|
2032
|
+
const dayMs = 24 * hourMs;
|
|
2033
|
+
const durationUnits = {
|
|
2034
|
+
m: minuteMs,
|
|
2035
|
+
h: hourMs,
|
|
2036
|
+
d: dayMs
|
|
2037
|
+
};
|
|
2038
|
+
const durationPattern = /^(\d+)([mhd])$/i;
|
|
2039
|
+
const isoTimePattern = /^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2})(?:\.\d+)?)?(?:Z|[+-]\d{2}:\d{2})?)?$/i;
|
|
2040
|
+
const sinceUsage = "--since needs a duration such as 30m, 6h or 2d, or an ISO date or time such as 2026-10-03 or 2026-10-03T09:00.";
|
|
2041
|
+
const goalPattern = /^(\d+(?:\.\d{1,2})?)%?$/;
|
|
2042
|
+
const goalUsage = "--goal needs a percentage from 0 to 100 with at most two decimals, such as --goal 95 or --goal 97.5.";
|
|
2043
|
+
const isCalendarDate = (year, month, day) => {
|
|
2044
|
+
const date = new Date(year, month - 1, day);
|
|
2045
|
+
return date.getFullYear() === year && date.getMonth() === month - 1 && date.getDate() === day;
|
|
2046
|
+
};
|
|
2047
|
+
const parseIsoTime = (text$1) => {
|
|
2048
|
+
const match = isoTimePattern.exec(text$1);
|
|
2049
|
+
if (match === null) return;
|
|
2050
|
+
const [, year, month, day, hour, minute, second = "00"] = match;
|
|
2051
|
+
if (!isCalendarDate(Number(year), Number(month), Number(day))) return;
|
|
2052
|
+
if (hour === void 0) return new Date(Number(year), Number(month) - 1, Number(day));
|
|
2053
|
+
if (Number(hour) > 23 || Number(minute) > 59 || Number(second) > 59) return;
|
|
2054
|
+
const time = Date.parse(text$1.toUpperCase().replace(" ", "T"));
|
|
2055
|
+
return Number.isNaN(time) ? void 0 : new Date(time);
|
|
2056
|
+
};
|
|
2057
|
+
/**
|
|
2058
|
+
* The start of the window for --since: a duration counted back from `now`, or an ISO date or
|
|
2059
|
+
* time. A date, or a time without Z or an offset, is local, like every other time in the report.
|
|
2060
|
+
*/
|
|
2061
|
+
const parseSince = (value, now) => {
|
|
2062
|
+
const text$1 = value.trim();
|
|
2063
|
+
const duration = durationPattern.exec(text$1);
|
|
2064
|
+
if (duration !== null) {
|
|
2065
|
+
const unit = durationUnits[duration[2].toLowerCase()];
|
|
2066
|
+
const start$1 = new Date(now.getTime() - Number(duration[1]) * unit);
|
|
2067
|
+
if (Number.isNaN(start$1.getTime())) throw new CacheUsageError(sinceUsage);
|
|
2068
|
+
return start$1;
|
|
2069
|
+
}
|
|
2070
|
+
const time = parseIsoTime(text$1);
|
|
2071
|
+
if (time === void 0) throw new CacheUsageError(sinceUsage);
|
|
2072
|
+
return time;
|
|
2073
|
+
};
|
|
2074
|
+
/** The hit-rate goal for --goal, in percent with at most two decimals, with or without a trailing "%". */
|
|
2075
|
+
const parseGoal = (value) => {
|
|
2076
|
+
const match = goalPattern.exec(value.trim());
|
|
2077
|
+
if (match === null || Number(match[1]) > 100) throw new CacheUsageError(goalUsage);
|
|
2078
|
+
return Number(match[1]);
|
|
2079
|
+
};
|
|
2080
|
+
const singleValue = (value, flag) => {
|
|
2081
|
+
if (value === void 0) return;
|
|
2082
|
+
if (typeof value !== "string") throw new CacheUsageError(`Give ${flag} once.`);
|
|
2083
|
+
return value;
|
|
2084
|
+
};
|
|
2085
|
+
/**
|
|
2086
|
+
* The report options for the command's flags.
|
|
2087
|
+
*
|
|
2088
|
+
* The summary and the hourly trend cover the last 24 hours by default, and the daily trend every
|
|
2089
|
+
* retained day. --since replaces either default.
|
|
2090
|
+
*/
|
|
2091
|
+
const resolveCacheOptions = (args, now) => {
|
|
2092
|
+
if (args.hourly && args.daily) throw new CacheUsageError("Choose one of --hourly and --daily.");
|
|
2093
|
+
let view = "summary";
|
|
2094
|
+
if (args.hourly) view = "hourly";
|
|
2095
|
+
else if (args.daily) view = "daily";
|
|
2096
|
+
const sinceText = singleValue(args.since, "--since");
|
|
2097
|
+
const goalText = singleValue(args.goal, "--goal");
|
|
2098
|
+
const model = singleValue(args.model, "--model")?.trim();
|
|
2099
|
+
let since;
|
|
2100
|
+
if (sinceText !== void 0) since = parseSince(sinceText, now);
|
|
2101
|
+
else if (view !== "daily") since = new Date(now.getTime() - dayMs);
|
|
2102
|
+
if (model === "") throw new CacheUsageError("--model needs part of a model name, such as --model opus.");
|
|
2103
|
+
return {
|
|
2104
|
+
view,
|
|
2105
|
+
goal: goalText === void 0 ? defaultGoal : parseGoal(goalText),
|
|
2106
|
+
...since !== void 0 && { since },
|
|
2107
|
+
...model !== void 0 && { model }
|
|
2108
|
+
};
|
|
2109
|
+
};
|
|
2110
|
+
const formatCount = (value) => value.toLocaleString("en-US");
|
|
2111
|
+
const formatHitRate = (row) => {
|
|
2112
|
+
if (row.hitRate === null) return "-";
|
|
2113
|
+
const basisPoints = hitBasisPoints(row.cacheReadTokens, row.totalInputTokens);
|
|
2114
|
+
return `${Math.floor(basisPoints / 100)}.${twoDigits(basisPoints % 100)}%`;
|
|
2115
|
+
};
|
|
2116
|
+
const hitRateTone = (row) => {
|
|
2117
|
+
if (row.hitRate === null) return "muted";
|
|
2118
|
+
return row.belowGoal ? "bad" : "good";
|
|
2119
|
+
};
|
|
2120
|
+
const bucketColumns = {
|
|
2121
|
+
summary: [],
|
|
2122
|
+
hourly: [{
|
|
2123
|
+
header: "HOUR",
|
|
2124
|
+
alignRight: false,
|
|
2125
|
+
cell: (row) => row.bucket ?? ""
|
|
2126
|
+
}],
|
|
2127
|
+
daily: [{
|
|
2128
|
+
header: "DAY",
|
|
2129
|
+
alignRight: false,
|
|
2130
|
+
cell: (row) => row.bucket ?? ""
|
|
2131
|
+
}]
|
|
2132
|
+
};
|
|
2133
|
+
const dataColumns = [
|
|
2134
|
+
{
|
|
2135
|
+
header: "MODEL",
|
|
2136
|
+
alignRight: false,
|
|
2137
|
+
cell: (row) => row.model
|
|
2138
|
+
},
|
|
2139
|
+
{
|
|
2140
|
+
header: "ROUTE",
|
|
2141
|
+
alignRight: false,
|
|
2142
|
+
cell: (row) => row.route
|
|
2143
|
+
},
|
|
2144
|
+
{
|
|
2145
|
+
header: "REQUESTS",
|
|
2146
|
+
alignRight: true,
|
|
2147
|
+
cell: (row) => formatCount(row.requests)
|
|
2148
|
+
},
|
|
2149
|
+
{
|
|
2150
|
+
header: "HIT RATE",
|
|
2151
|
+
alignRight: true,
|
|
2152
|
+
cell: formatHitRate,
|
|
2153
|
+
tone: hitRateTone
|
|
2154
|
+
}
|
|
2155
|
+
];
|
|
2156
|
+
const renderTable = (rows, view, color) => {
|
|
2157
|
+
const columns = [...bucketColumns[view], ...dataColumns];
|
|
2158
|
+
const cells = rows.map((row) => columns.map((column) => column.cell(row)));
|
|
2159
|
+
const widths = columns.map((column, index) => Math.max(column.header.length, ...cells.map((rowCells) => rowCells[index].length)));
|
|
2160
|
+
const pad = (text$1, index) => columns[index].alignRight ? text$1.padStart(widths[index]) : text$1.padEnd(widths[index]);
|
|
2161
|
+
const header = columns.map((column, index) => pad(column.header, index)).join(" ");
|
|
2162
|
+
const body = rows.map((row, rowIndex) => {
|
|
2163
|
+
return ` ${columns.map((column, index) => {
|
|
2164
|
+
const text$1 = pad(cells[rowIndex][index], index);
|
|
2165
|
+
return column.tone === void 0 ? text$1 : colorText(text$1, column.tone(row), color);
|
|
2166
|
+
}).join(" ")}`;
|
|
2167
|
+
});
|
|
2168
|
+
return [` ${header}`, ...body];
|
|
2169
|
+
};
|
|
2170
|
+
const viewTitles = {
|
|
2171
|
+
summary: "Prompt-cache hit rate",
|
|
2172
|
+
hourly: "Prompt-cache hit rate by local hour",
|
|
2173
|
+
daily: "Prompt-cache hit rate by local day"
|
|
2174
|
+
};
|
|
2175
|
+
const formatLocalTime = (date) => `${formatLogDate(date)} ${twoDigits(date.getHours())}:${twoDigits(date.getMinutes())}`;
|
|
2176
|
+
const describeScope = (options) => {
|
|
2177
|
+
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)}"`}`;
|
|
2178
|
+
};
|
|
2179
|
+
const unknownNote = (calls) => `HIT RATE leaves out ${formatCount(calls)} ${calls === 1 ? "call" : "calls"} that logged no cache_read_input_tokens.`;
|
|
2180
|
+
/** The report as text lines. Kept free of IO, so it is tested without a terminal. */
|
|
2181
|
+
const renderCacheReport = (rows, options, color = false) => {
|
|
2182
|
+
if (rows.length === 0) return [
|
|
2183
|
+
`No prompt-cache data ${describeScope(options)}.`,
|
|
2184
|
+
`The report reads the upstream completion entries in ${paths.logsDir}.`,
|
|
2185
|
+
"They are written at logLevel info or debug, not error, and kept for logRetentionDays."
|
|
2186
|
+
];
|
|
2187
|
+
const lines = [
|
|
2188
|
+
`${viewTitles[options.view]} ${describeScope(options)}, goal ${options.goal}%`,
|
|
2189
|
+
"",
|
|
2190
|
+
...renderTable(rows, options.view, color)
|
|
2191
|
+
];
|
|
2192
|
+
const unknownCalls = rows.reduce((total, row) => total + row.unknownCacheRequests, 0);
|
|
2193
|
+
if (unknownCalls > 0) lines.push("", unknownNote(unknownCalls));
|
|
2194
|
+
return lines;
|
|
2195
|
+
};
|
|
2196
|
+
|
|
2197
|
+
//#endregion
|
|
2198
|
+
//#region src/cache.ts
|
|
2199
|
+
const cache = defineCommand({
|
|
2200
|
+
meta: {
|
|
2201
|
+
name: "cache",
|
|
2202
|
+
description: "Show prompt-cache hit rates per model and upstream route, read from local logs."
|
|
2203
|
+
},
|
|
2204
|
+
args: {
|
|
2205
|
+
hourly: {
|
|
2206
|
+
description: "Show the trend by local hour. Covers the last 24 hours unless --since is given.",
|
|
2207
|
+
type: "boolean"
|
|
2208
|
+
},
|
|
2209
|
+
daily: {
|
|
2210
|
+
description: "Show the trend by local day. Covers every retained day unless --since is given.",
|
|
2211
|
+
type: "boolean"
|
|
2212
|
+
},
|
|
2213
|
+
since: {
|
|
2214
|
+
description: "Start of the window: a duration such as 6h or 2d, or an ISO date or time. Without it, the summary covers the last 24 hours.",
|
|
2215
|
+
type: "string"
|
|
2216
|
+
},
|
|
2217
|
+
model: {
|
|
2218
|
+
description: "Only count models whose name contains this text, ignoring case.",
|
|
2219
|
+
type: "string"
|
|
2220
|
+
},
|
|
2221
|
+
json: {
|
|
2222
|
+
description: "Print the rows as a JSON array.",
|
|
2223
|
+
type: "boolean"
|
|
2224
|
+
},
|
|
2225
|
+
goal: {
|
|
2226
|
+
default: String(defaultGoal),
|
|
2227
|
+
description: "Hit-rate goal in percent, with at most two decimals. A hit rate below it is shown in red.",
|
|
2228
|
+
type: "string"
|
|
2229
|
+
}
|
|
2230
|
+
},
|
|
2231
|
+
async run({ args }) {
|
|
2232
|
+
try {
|
|
2233
|
+
const options = resolveCacheOptions(args, /* @__PURE__ */ new Date());
|
|
2234
|
+
const rows = await buildCacheReport(options);
|
|
2235
|
+
if (args.json) console.log(JSON.stringify(rows, null, 2));
|
|
2236
|
+
else console.log(renderCacheReport(rows, options, colorEnabled()).join("\n"));
|
|
2237
|
+
} catch (error) {
|
|
2238
|
+
if (error instanceof CacheUsageError) console.error(error.message);
|
|
2239
|
+
else {
|
|
2240
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
2241
|
+
console.error(terminalText(`Could not build the cache report from ${paths.logsDir}: ${detail}`));
|
|
2242
|
+
}
|
|
2243
|
+
process.exitCode = 1;
|
|
2244
|
+
}
|
|
2245
|
+
}
|
|
2246
|
+
});
|
|
2247
|
+
|
|
1385
2248
|
//#endregion
|
|
1386
2249
|
//#region src/lib/version.ts
|
|
1387
2250
|
const packageName = /^(?:@[a-z0-9][a-z0-9._-]*\/)?copilot-relay$/;
|
|
@@ -1550,13 +2413,13 @@ var OutcomeObserver = class {
|
|
|
1550
2413
|
const incomplete = this.metadataValue(value.incomplete_details?.reason);
|
|
1551
2414
|
if (incomplete) this.fields.incomplete_reason = ["max_output_tokens", "content_filter"].includes(incomplete) ? incomplete : "unknown";
|
|
1552
2415
|
if (payload.type === "error" || record$2(payload.error)) this.fields.error = "upstream_error";
|
|
1553
|
-
const usage = findUsage(payload, value);
|
|
1554
|
-
if (usage) {
|
|
2416
|
+
const usage$1 = findUsage(payload, value);
|
|
2417
|
+
if (usage$1) {
|
|
1555
2418
|
for (const [target, input] of [
|
|
1556
|
-
["input_tokens", usage.input_tokens ?? usage.prompt_tokens],
|
|
1557
|
-
["output_tokens", usage.output_tokens ?? usage.completion_tokens],
|
|
1558
|
-
["cache_read_input_tokens", usage.cache_read_input_tokens ?? usage.prompt_tokens_details?.cached_tokens ?? usage.input_tokens_details?.cached_tokens],
|
|
1559
|
-
["cache_creation_input_tokens", usage.cache_creation_input_tokens]
|
|
2419
|
+
["input_tokens", usage$1.input_tokens ?? usage$1.prompt_tokens],
|
|
2420
|
+
["output_tokens", usage$1.output_tokens ?? usage$1.completion_tokens],
|
|
2421
|
+
["cache_read_input_tokens", usage$1.cache_read_input_tokens ?? usage$1.prompt_tokens_details?.cached_tokens ?? usage$1.input_tokens_details?.cached_tokens],
|
|
2422
|
+
["cache_creation_input_tokens", usage$1.cache_creation_input_tokens]
|
|
1560
2423
|
]) if (typeof input === "number" && Number.isSafeInteger(input) && input >= 0) this.fields[target] = input;
|
|
1561
2424
|
}
|
|
1562
2425
|
}
|
|
@@ -1772,6 +2635,7 @@ var RequestTrace = class RequestTrace {
|
|
|
1772
2635
|
constructor(requestId, request, config, runtime) {
|
|
1773
2636
|
this.requestId = requestId;
|
|
1774
2637
|
this.protectCredential(config.copilotToken);
|
|
2638
|
+
this.protectCredential(config.apiKey);
|
|
1775
2639
|
this.protectCredential(request.headers.get("authorization")?.replace(/^Bearer\s+/i, ""));
|
|
1776
2640
|
this.protectCredential(request.headers.get("x-api-key") ?? void 0);
|
|
1777
2641
|
this.signal = AbortSignal.any([request.signal, this.controller.signal]);
|
|
@@ -2234,11 +3098,6 @@ const userAgent = `GitHubCopilotChat/${copilotVersion}`;
|
|
|
2234
3098
|
const apiVersion = "2025-04-01";
|
|
2235
3099
|
const maxFetchAttempts = 2;
|
|
2236
3100
|
const copilotRequestTimeoutMs = 18e4;
|
|
2237
|
-
const copilotDispatcher = new Agent({
|
|
2238
|
-
allowH2: false,
|
|
2239
|
-
connect: { allowH2: false },
|
|
2240
|
-
keepAliveTimeout: 5e4
|
|
2241
|
-
});
|
|
2242
3101
|
const getCopilotProviderContext = (config) => ({
|
|
2243
3102
|
baseUrl: config.copilotBaseUrl,
|
|
2244
3103
|
get token() {
|
|
@@ -2278,6 +3137,33 @@ const toCopilotAbortHTTPError = (error, signal, timeoutMs = copilotRequestTimeou
|
|
|
2278
3137
|
headers: { "content-type": "application/json" }
|
|
2279
3138
|
}), message);
|
|
2280
3139
|
};
|
|
3140
|
+
const copilotPromptTooLongPattern = /^prompt token count of (\d+) exceeds the limit of (\d+)$/;
|
|
3141
|
+
const anthropicPromptTooLongPattern = /^prompt is too long: (\d+) tokens > (\d+) maximum$/;
|
|
3142
|
+
const parseJsonBody = (body) => {
|
|
3143
|
+
try {
|
|
3144
|
+
return JSON.parse(body);
|
|
3145
|
+
} catch {
|
|
3146
|
+
return;
|
|
3147
|
+
}
|
|
3148
|
+
};
|
|
3149
|
+
const isJsonObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
3150
|
+
const parseTokenCount = (digits) => {
|
|
3151
|
+
const count = Number(digits);
|
|
3152
|
+
return Number.isSafeInteger(count) ? count : void 0;
|
|
3153
|
+
};
|
|
3154
|
+
const toPromptTooLongError = (status$1, body, detail) => {
|
|
3155
|
+
if (status$1 !== 400) return;
|
|
3156
|
+
const payload = parseJsonBody(body);
|
|
3157
|
+
const upstreamError = isJsonObject(payload) ? payload.error : void 0;
|
|
3158
|
+
if (!isJsonObject(payload) || !isJsonObject(upstreamError)) return;
|
|
3159
|
+
const { code, message, type: errorType } = upstreamError;
|
|
3160
|
+
const upstreamMessage = typeof message === "string" ? message : "";
|
|
3161
|
+
const isCopilotOverflow = code === "model_max_prompt_tokens_exceeded";
|
|
3162
|
+
const isAnthropicOverflow = payload.type === "error" && errorType === "invalid_request_error" && upstreamMessage.startsWith("prompt is too long");
|
|
3163
|
+
if (!isCopilotOverflow && !isAnthropicOverflow) return;
|
|
3164
|
+
const counts = copilotPromptTooLongPattern.exec(upstreamMessage) ?? anthropicPromptTooLongPattern.exec(upstreamMessage);
|
|
3165
|
+
return new PromptTooLongError(parseTokenCount(counts?.[1]), parseTokenCount(counts?.[2]), detail ?? (upstreamMessage || void 0));
|
|
3166
|
+
};
|
|
2281
3167
|
const readCopilotJson = async (response, signal, timeoutMs = copilotRequestTimeoutMs) => {
|
|
2282
3168
|
try {
|
|
2283
3169
|
return await response.json();
|
|
@@ -2380,21 +3266,12 @@ const fetchCopilot = async (provider, path$1, init, options = {}) => {
|
|
|
2380
3266
|
headers,
|
|
2381
3267
|
upstreamRequestId,
|
|
2382
3268
|
signal
|
|
2383
|
-
},
|
|
2384
|
-
|
|
2385
|
-
|
|
2386
|
-
|
|
2387
|
-
|
|
2388
|
-
|
|
2389
|
-
});
|
|
2390
|
-
const wrappedResponse = new Response(undiciResponse.body, {
|
|
2391
|
-
status: undiciResponse.status,
|
|
2392
|
-
statusText: undiciResponse.statusText,
|
|
2393
|
-
headers: undiciResponse.headers
|
|
2394
|
-
});
|
|
2395
|
-
Object.defineProperty(wrappedResponse, "url", { value: undiciResponse.url });
|
|
2396
|
-
return wrappedResponse;
|
|
2397
|
-
});
|
|
3269
|
+
}, () => fetchUpstream(`${provider.baseUrl}${path$1}`, {
|
|
3270
|
+
body: init.body,
|
|
3271
|
+
headers,
|
|
3272
|
+
method: init.method,
|
|
3273
|
+
signal
|
|
3274
|
+
}));
|
|
2398
3275
|
const ms = Math.round(performance.now() - started);
|
|
2399
3276
|
logUpstreamLifecycle(options.requestId, `return from upstream method=${init.method ?? "GET"} path=${path$1} status=${response.status} ms=${ms} attempt=${attempt} upstream_request_id=${upstreamRequestId}`);
|
|
2400
3277
|
log.debug(`${formatRequestId(options.requestId)}Copilot ${init.method ?? "GET"} ${path$1} -> ${response.status} ${ms}ms (attempt ${attempt}) upstream_request_id=${upstreamRequestId}`);
|
|
@@ -2450,10 +3327,10 @@ const pinCopilotModelCatalog = (config) => {
|
|
|
2450
3327
|
pinnedCatalogs.set(config, config.modelCatalog);
|
|
2451
3328
|
};
|
|
2452
3329
|
const pendingCatalogs = /* @__PURE__ */ new WeakMap();
|
|
2453
|
-
const isRecord$
|
|
3330
|
+
const isRecord$5 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2454
3331
|
const isPositiveInteger = (value) => typeof value === "number" && Number.isSafeInteger(value) && value > 0;
|
|
2455
3332
|
function parseModelTokenLimits(value) {
|
|
2456
|
-
if (!isRecord$
|
|
3333
|
+
if (!isRecord$5(value)) return;
|
|
2457
3334
|
const context = value.max_context_window_tokens;
|
|
2458
3335
|
const prompt = value.max_prompt_tokens;
|
|
2459
3336
|
const output = value.max_output_tokens;
|
|
@@ -2509,13 +3386,13 @@ async function loadCopilotModelCatalog(config) {
|
|
|
2509
3386
|
});
|
|
2510
3387
|
if (!response.ok) throw new HTTPError("Failed to validate upstream models", response);
|
|
2511
3388
|
const payload = await readCopilotJson(response, signal, config.upstreamTimeoutMs);
|
|
2512
|
-
if (!isRecord$
|
|
3389
|
+
if (!isRecord$5(payload) || !Array.isArray(payload.data)) throw new Error("Copilot /models returned an invalid model catalog.");
|
|
2513
3390
|
const models$1 = /* @__PURE__ */ new Map();
|
|
2514
3391
|
for (const model of payload.data) {
|
|
2515
|
-
if (!isRecord$
|
|
2516
|
-
const capabilities = isRecord$
|
|
3392
|
+
if (!isRecord$5(model) || typeof model.id !== "string" || !model.id) continue;
|
|
3393
|
+
const capabilities = isRecord$5(model.capabilities) ? model.capabilities : void 0;
|
|
2517
3394
|
const limits = parseModelTokenLimits(capabilities?.limits);
|
|
2518
|
-
const supports = isRecord$
|
|
3395
|
+
const supports = isRecord$5(capabilities?.supports) ? capabilities.supports : void 0;
|
|
2519
3396
|
const supportedEndpoints = parseStringArray(model.supported_endpoints);
|
|
2520
3397
|
const reasoningEfforts = parseReasoningEfforts(supports);
|
|
2521
3398
|
models$1.set(model.id, {
|
|
@@ -2633,27 +3510,6 @@ function requireCopilotEndpoint(config, model) {
|
|
|
2633
3510
|
}, { status: 400 }), message);
|
|
2634
3511
|
}
|
|
2635
3512
|
|
|
2636
|
-
//#endregion
|
|
2637
|
-
//#region src/lib/terminal.ts
|
|
2638
|
-
const colorEnabled = (env = process.env, isTTY = Boolean(process.stdout.isTTY)) => {
|
|
2639
|
-
if (env.NO_COLOR !== void 0 || env.FORCE_COLOR === "0") return false;
|
|
2640
|
-
if (env.FORCE_COLOR !== void 0) return env.FORCE_COLOR === "" || [
|
|
2641
|
-
"1",
|
|
2642
|
-
"2",
|
|
2643
|
-
"3",
|
|
2644
|
-
"true"
|
|
2645
|
-
].includes(env.FORCE_COLOR);
|
|
2646
|
-
return isTTY && env.TERM !== "dumb";
|
|
2647
|
-
};
|
|
2648
|
-
const ansiColorCodes = {
|
|
2649
|
-
good: 32,
|
|
2650
|
-
bad: 31,
|
|
2651
|
-
warning: 33,
|
|
2652
|
-
muted: 90
|
|
2653
|
-
};
|
|
2654
|
-
const terminalText = (value) => sanitizeTerminalString(value).replace(/[\x80-\x9f]|\p{Cf}|\p{Zl}|\p{Zp}/gu, "");
|
|
2655
|
-
const colorText = (text$1, tone, enabled) => enabled ? `\u001b[${ansiColorCodes[tone]}m${text$1}\u001b[0m` : text$1;
|
|
2656
|
-
|
|
2657
3513
|
//#endregion
|
|
2658
3514
|
//#region src/lib/model-probe-output.ts
|
|
2659
3515
|
const tones = {
|
|
@@ -2781,6 +3637,168 @@ const probeHint = (code) => {
|
|
|
2781
3637
|
if (apiFailureCodes.includes(code) || /^HTTP-5\d\d$/.test(code)) return "API failure: the request ID locates the local summary; --details shows evidence on a new probe.";
|
|
2782
3638
|
};
|
|
2783
3639
|
|
|
3640
|
+
//#endregion
|
|
3641
|
+
//#region src/claude/billing-line.ts
|
|
3642
|
+
const billingLinePrefix = "x-anthropic-billing-header:";
|
|
3643
|
+
const leadingBillingLine = (text$1) => {
|
|
3644
|
+
if (!text$1.startsWith(billingLinePrefix)) return;
|
|
3645
|
+
const lineEnd = text$1.indexOf("\n");
|
|
3646
|
+
return lineEnd === -1 ? text$1 : text$1.slice(0, lineEnd);
|
|
3647
|
+
};
|
|
3648
|
+
const afterBillingLine = (text$1) => {
|
|
3649
|
+
const line = leadingBillingLine(text$1);
|
|
3650
|
+
if (line === void 0) return;
|
|
3651
|
+
return text$1.slice(line.length).replace(/^(?:\r?\n)+/, "");
|
|
3652
|
+
};
|
|
3653
|
+
const isTextBlock = (block) => typeof block === "object" && block !== null && block.type === "text" && typeof block.text === "string";
|
|
3654
|
+
const withSystem = (payload, system) => {
|
|
3655
|
+
const { system: _removed,...rest } = payload;
|
|
3656
|
+
return system === void 0 ? rest : {
|
|
3657
|
+
...rest,
|
|
3658
|
+
system
|
|
3659
|
+
};
|
|
3660
|
+
};
|
|
3661
|
+
const removeBillingLine = (payload) => {
|
|
3662
|
+
if (typeof payload !== "object" || payload === null) return payload;
|
|
3663
|
+
const { system } = payload;
|
|
3664
|
+
if (typeof system === "string") {
|
|
3665
|
+
const rest = afterBillingLine(system);
|
|
3666
|
+
if (rest === void 0) return payload;
|
|
3667
|
+
return withSystem(payload, rest.trim() === "" ? void 0 : rest);
|
|
3668
|
+
}
|
|
3669
|
+
if (!Array.isArray(system)) return payload;
|
|
3670
|
+
let changed = false;
|
|
3671
|
+
const kept = [];
|
|
3672
|
+
for (const block of system) {
|
|
3673
|
+
const rest = isTextBlock(block) ? afterBillingLine(block.text) : void 0;
|
|
3674
|
+
if (rest === void 0) {
|
|
3675
|
+
kept.push(block);
|
|
3676
|
+
continue;
|
|
3677
|
+
}
|
|
3678
|
+
changed = true;
|
|
3679
|
+
if (rest.trim() !== "") kept.push({
|
|
3680
|
+
...block,
|
|
3681
|
+
text: rest
|
|
3682
|
+
});
|
|
3683
|
+
}
|
|
3684
|
+
if (!changed) return payload;
|
|
3685
|
+
return withSystem(payload, kept.length > 0 ? kept : void 0);
|
|
3686
|
+
};
|
|
3687
|
+
const systemTexts$1 = (system) => {
|
|
3688
|
+
if (typeof system === "string") return [system];
|
|
3689
|
+
return Array.isArray(system) ? system.filter(isTextBlock).map((block) => block.text) : [];
|
|
3690
|
+
};
|
|
3691
|
+
const readBillingLineField = (payload, name) => {
|
|
3692
|
+
if (typeof payload !== "object" || payload === null) return;
|
|
3693
|
+
const line = systemTexts$1(payload.system).map((text$1) => leadingBillingLine(text$1)).find((text$1) => text$1 !== void 0);
|
|
3694
|
+
if (line === void 0) return;
|
|
3695
|
+
for (const field of line.slice(27).split(";")) {
|
|
3696
|
+
const separator = field.indexOf("=");
|
|
3697
|
+
if (separator !== -1 && field.slice(0, separator).trim() === name) return field.slice(separator + 1).trim();
|
|
3698
|
+
}
|
|
3699
|
+
};
|
|
3700
|
+
|
|
3701
|
+
//#endregion
|
|
3702
|
+
//#region src/claude/initiator.ts
|
|
3703
|
+
const ownRequestSystemPrompts = ["You are a helpful AI assistant tasked with summarizing conversations.", "You are naming a coding session"];
|
|
3704
|
+
const compactionGuard = "CRITICAL: Respond with TEXT ONLY.";
|
|
3705
|
+
const compactionInstruction = "Your task is to create a detailed summary of the conversation so far";
|
|
3706
|
+
const personDeliveries = [
|
|
3707
|
+
"The user sent a new message while you were working:",
|
|
3708
|
+
"A message arrived in the bound thread while you were working:",
|
|
3709
|
+
"Messages arrived in the bound thread while you were working:"
|
|
3710
|
+
];
|
|
3711
|
+
const claudeCodeTriggers = [
|
|
3712
|
+
"This session is being continued from a previous conversation",
|
|
3713
|
+
"The summarized conversation included Artifact content",
|
|
3714
|
+
"The previous response failed to produce a valid tool call.",
|
|
3715
|
+
"Your tool call was malformed and could not be parsed.",
|
|
3716
|
+
"[Your previous response had no visible output.",
|
|
3717
|
+
"[structured-output-enforce]",
|
|
3718
|
+
"[projects-reply-gate]",
|
|
3719
|
+
"Output token limit hit",
|
|
3720
|
+
"Your response above was cut off mid-stream",
|
|
3721
|
+
"Your response above was stopped by a safety classifier",
|
|
3722
|
+
"Activity in the bound conversation",
|
|
3723
|
+
"Another Claude session sent a message",
|
|
3724
|
+
"Your background observer",
|
|
3725
|
+
"[MESSAGE FROM NON-USER SOURCE",
|
|
3726
|
+
"[SCHEDULED TASK",
|
|
3727
|
+
"<channel source=",
|
|
3728
|
+
"<teammate-message"
|
|
3729
|
+
];
|
|
3730
|
+
const claudeCodeTriggerPatterns = [/^\S+ hook (?:feedback|blocking error from command):/, /^The \S+ plugin sent a message/];
|
|
3731
|
+
const claudeCodeContext = [
|
|
3732
|
+
"[Request interrupted by user",
|
|
3733
|
+
"[Tool call ",
|
|
3734
|
+
"The user doesn't want to take this action right now.",
|
|
3735
|
+
"Base directory for this skill:"
|
|
3736
|
+
];
|
|
3737
|
+
const claudeCodeContextPatterns = [/^\S+ hook additional context:/];
|
|
3738
|
+
const statusProbePrompt = "Reply with the single word: ok";
|
|
3739
|
+
const statusProbeMaxTokens = 16;
|
|
3740
|
+
const isRecord$4 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
3741
|
+
const innermostReminder = /<system-reminder>(?:(?!<system-reminder>)[\s\S])*?<\/system-reminder>/g;
|
|
3742
|
+
const withoutReminders = (text$1) => {
|
|
3743
|
+
let previous = text$1;
|
|
3744
|
+
let remaining = text$1.replace(innermostReminder, "");
|
|
3745
|
+
while (remaining !== previous) {
|
|
3746
|
+
previous = remaining;
|
|
3747
|
+
remaining = remaining.replace(innermostReminder, "");
|
|
3748
|
+
}
|
|
3749
|
+
return remaining.trim();
|
|
3750
|
+
};
|
|
3751
|
+
const startsWithAny = (text$1, prefixes) => prefixes.some((prefix) => text$1.startsWith(prefix));
|
|
3752
|
+
const matchesAny = (text$1, prefixes, patterns) => startsWithAny(text$1, prefixes) || patterns.some((pattern) => pattern.test(text$1));
|
|
3753
|
+
const contentBlocks = (content) => {
|
|
3754
|
+
if (typeof content === "string") return [{
|
|
3755
|
+
type: "text",
|
|
3756
|
+
text: content
|
|
3757
|
+
}];
|
|
3758
|
+
return Array.isArray(content) ? content.filter(isRecord$4) : [];
|
|
3759
|
+
};
|
|
3760
|
+
const originatingTurn = (messages) => {
|
|
3761
|
+
const blocks = [];
|
|
3762
|
+
let sawUser = false;
|
|
3763
|
+
for (let index = messages.length - 1; index >= 0; index--) {
|
|
3764
|
+
const message = messages[index];
|
|
3765
|
+
if (!isRecord$4(message) || message.role === "system") continue;
|
|
3766
|
+
if (message.role !== "user") break;
|
|
3767
|
+
sawUser = true;
|
|
3768
|
+
blocks.unshift(...contentBlocks(message.content));
|
|
3769
|
+
}
|
|
3770
|
+
return sawUser ? blocks : void 0;
|
|
3771
|
+
};
|
|
3772
|
+
const blockOrigin = (block) => {
|
|
3773
|
+
if (block.type === "tool_result") return "agent";
|
|
3774
|
+
if (block.type === "image" || block.type === "document") return "user";
|
|
3775
|
+
if (block.type !== "text" || typeof block.text !== "string") return;
|
|
3776
|
+
const text$1 = withoutReminders(block.text);
|
|
3777
|
+
if (text$1 === "" || matchesAny(text$1, claudeCodeContext, claudeCodeContextPatterns)) return;
|
|
3778
|
+
if (startsWithAny(text$1, personDeliveries)) return "user";
|
|
3779
|
+
if (text$1.includes(compactionGuard) && text$1.includes(compactionInstruction) || matchesAny(text$1, claudeCodeTriggers, claudeCodeTriggerPatterns)) return "agent";
|
|
3780
|
+
return "user";
|
|
3781
|
+
};
|
|
3782
|
+
const isStatusProbe = (body) => {
|
|
3783
|
+
if (Object.keys(body).sort().join(",") !== "max_tokens,messages,model" || body.max_tokens !== statusProbeMaxTokens) return false;
|
|
3784
|
+
const messages = body.messages;
|
|
3785
|
+
return Array.isArray(messages) && messages.length === 1 && isRecord$4(messages[0]) && messages[0].role === "user" && messages[0].content === statusProbePrompt;
|
|
3786
|
+
};
|
|
3787
|
+
const systemTexts = (system) => contentBlocks(system).flatMap((block) => block.type === "text" && typeof block.text === "string" ? [block.text] : []);
|
|
3788
|
+
const getClaudeRequestInitiator = (body) => {
|
|
3789
|
+
if (!isRecord$4(body) || !Array.isArray(body.messages) || isStatusProbe(body)) return "agent";
|
|
3790
|
+
const payload = body;
|
|
3791
|
+
if (readBillingLineField(payload, "cc_is_subagent") === "true" || readBillingLineField(payload, "cc_workload") === "cron") return "agent";
|
|
3792
|
+
if (systemTexts(removeBillingLine(payload).system).some((text$1) => startsWithAny(text$1.trimStart(), ownRequestSystemPrompts))) return "agent";
|
|
3793
|
+
const turn = originatingTurn(body.messages);
|
|
3794
|
+
if (turn === void 0) return "agent";
|
|
3795
|
+
for (let index = turn.length - 1; index >= 0; index--) {
|
|
3796
|
+
const origin = blockOrigin(turn[index]);
|
|
3797
|
+
if (origin !== void 0) return origin;
|
|
3798
|
+
}
|
|
3799
|
+
return "agent";
|
|
3800
|
+
};
|
|
3801
|
+
|
|
2784
3802
|
//#endregion
|
|
2785
3803
|
//#region src/claude/utils.ts
|
|
2786
3804
|
const isEffortOnlyControl = (control) => control !== null && typeof control === "object" && !Array.isArray(control) && Object.keys(control).length === 1 && "effort" in control && isConfiguredReasoningEffort(control.effort);
|
|
@@ -2810,7 +3828,7 @@ function mapOpenAIStopReasonToClaude(finishReason) {
|
|
|
2810
3828
|
}
|
|
2811
3829
|
/**
|
|
2812
3830
|
* Upstream sends an empty `arguments` string for tools that take no
|
|
2813
|
-
* parameters
|
|
3831
|
+
* parameters. Blank text is the empty object; anything else must be a
|
|
2814
3832
|
* JSON object, and a failure names the tool rather than surfacing a bare
|
|
2815
3833
|
* SyntaxError. Argument text is never echoed: it can carry user data.
|
|
2816
3834
|
*/
|
|
@@ -3342,6 +4360,13 @@ function translateChunkToClaudeEvents(chunk, state, toolNameMapper = createClaud
|
|
|
3342
4360
|
return events$1;
|
|
3343
4361
|
}
|
|
3344
4362
|
function translateErrorToClaudeErrorEvent(error) {
|
|
4363
|
+
if (error instanceof PromptTooLongError) return {
|
|
4364
|
+
type: "error",
|
|
4365
|
+
error: {
|
|
4366
|
+
type: "invalid_request_error",
|
|
4367
|
+
message: error.message
|
|
4368
|
+
}
|
|
4369
|
+
};
|
|
3345
4370
|
return {
|
|
3346
4371
|
type: "error",
|
|
3347
4372
|
error: {
|
|
@@ -3365,7 +4390,7 @@ const webSearchInputSchema = {
|
|
|
3365
4390
|
required: ["query"],
|
|
3366
4391
|
additionalProperties: false
|
|
3367
4392
|
};
|
|
3368
|
-
const isRecord$
|
|
4393
|
+
const isRecord$3 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
3369
4394
|
const isAnthropicNativeWebSearchTool = (tool) => tool.name === "web_search" && typeof tool.type === "string" && anthropicWebSearchToolPattern.test(tool.type);
|
|
3370
4395
|
const isClaudeCodeWebSearchTool = (tool) => tool.name === claudeCodeWebSearchToolName;
|
|
3371
4396
|
const isClaudeWebSearchTool = (tool) => isAnthropicNativeWebSearchTool(tool) || isClaudeCodeWebSearchTool(tool);
|
|
@@ -3382,11 +4407,16 @@ const prepareClaudeWebSearchDecisionPayload = (payload) => {
|
|
|
3382
4407
|
} : tool)
|
|
3383
4408
|
};
|
|
3384
4409
|
};
|
|
4410
|
+
const toolResultText = (content) => {
|
|
4411
|
+
if (typeof content === "string") return content;
|
|
4412
|
+
if (!Array.isArray(content)) return "";
|
|
4413
|
+
return content.map((block) => block.type === "text" ? block.text : `[${block.type}]`).join("\n\n");
|
|
4414
|
+
};
|
|
3385
4415
|
const textFromMessageContent = (content) => {
|
|
3386
4416
|
if (typeof content === "string") return content;
|
|
3387
4417
|
return content.flatMap((block) => {
|
|
3388
4418
|
if (block.type === "text") return [block.text];
|
|
3389
|
-
if (block.type === "tool_result") return [block.content];
|
|
4419
|
+
if (block.type === "tool_result") return [toolResultText(block.content)];
|
|
3390
4420
|
return [];
|
|
3391
4421
|
}).join("\n\n");
|
|
3392
4422
|
};
|
|
@@ -3400,7 +4430,7 @@ const getQueryFromToolArguments = (value) => {
|
|
|
3400
4430
|
if (!trimmed) return;
|
|
3401
4431
|
try {
|
|
3402
4432
|
const parsed = JSON.parse(trimmed);
|
|
3403
|
-
if (isRecord$
|
|
4433
|
+
if (isRecord$3(parsed) && typeof parsed.query === "string" && parsed.query.trim()) return parsed.query.trim();
|
|
3404
4434
|
} catch {
|
|
3405
4435
|
return trimmed;
|
|
3406
4436
|
}
|
|
@@ -3457,7 +4487,7 @@ const getReportedQuery = (action, queries) => {
|
|
|
3457
4487
|
const getSearchQuery = (response, requestedQuery) => {
|
|
3458
4488
|
for (const item of response.output ?? []) {
|
|
3459
4489
|
if (item.type !== "web_search_call") continue;
|
|
3460
|
-
const action = isRecord$
|
|
4490
|
+
const action = isRecord$3(item.action) ? item.action : void 0;
|
|
3461
4491
|
const query = getReportedQuery(action, Array.isArray(action?.queries) ? action.queries : []);
|
|
3462
4492
|
if (query) return query;
|
|
3463
4493
|
}
|
|
@@ -3465,7 +4495,7 @@ const getSearchQuery = (response, requestedQuery) => {
|
|
|
3465
4495
|
};
|
|
3466
4496
|
const getResponseText = (response) => (response.output ?? []).flatMap((item) => {
|
|
3467
4497
|
if (item.type !== "message") return [];
|
|
3468
|
-
return (Array.isArray(item.content) ? item.content : []).flatMap((part) => isRecord$
|
|
4498
|
+
return (Array.isArray(item.content) ? item.content : []).flatMap((part) => isRecord$3(part) && part.type === "output_text" && typeof part.text === "string" ? [part.text] : []);
|
|
3469
4499
|
}).join("\n").trim();
|
|
3470
4500
|
const cleanTitle = (line, url) => {
|
|
3471
4501
|
return line.slice(0, line.indexOf(url)).replace(/^\s*(?:[-*]|\d+[.)])\s*/, "").replace(/\s*(?:[-:|])\s*$/, "").trim() || new URL(url).hostname;
|
|
@@ -3489,15 +4519,15 @@ const parseSearchResults = (text$1) => {
|
|
|
3489
4519
|
const getStructuredSearchResults = (response) => {
|
|
3490
4520
|
const sources = [];
|
|
3491
4521
|
for (const item of response.output ?? []) {
|
|
3492
|
-
if (item.type === "web_search_call" && isRecord$
|
|
4522
|
+
if (item.type === "web_search_call" && isRecord$3(item.action) && Array.isArray(item.action.sources)) sources.push(...item.action.sources);
|
|
3493
4523
|
if (item.type === "message" && Array.isArray(item.content)) {
|
|
3494
|
-
for (const part of item.content) if (isRecord$
|
|
4524
|
+
for (const part of item.content) if (isRecord$3(part) && Array.isArray(part.annotations)) sources.push(...part.annotations.filter((annotation) => isRecord$3(annotation) && annotation.type === "url_citation"));
|
|
3495
4525
|
}
|
|
3496
4526
|
}
|
|
3497
4527
|
const results = [];
|
|
3498
4528
|
const seen = /* @__PURE__ */ new Set();
|
|
3499
4529
|
for (const source of sources) {
|
|
3500
|
-
if (!isRecord$
|
|
4530
|
+
if (!isRecord$3(source) || typeof source.url !== "string") continue;
|
|
3501
4531
|
try {
|
|
3502
4532
|
const url = new URL(source.url);
|
|
3503
4533
|
if (!/^https?:$/.test(url.protocol) || url.username || url.password || seen.has(url.href)) continue;
|
|
@@ -3549,21 +4579,21 @@ const getResultSource = (structured, results) => {
|
|
|
3549
4579
|
return results.length ? "text" : "none";
|
|
3550
4580
|
};
|
|
3551
4581
|
const interpretSearchResponse = (raw, config, requestedQuery, request, requestedEffort, requestId) => {
|
|
3552
|
-
const malformed = !isRecord$
|
|
3553
|
-
const upstream = isRecord$
|
|
4582
|
+
const malformed = !isRecord$3(raw) || raw.output !== void 0 && (!Array.isArray(raw.output) || !raw.output.every(isRecord$3));
|
|
4583
|
+
const upstream = isRecord$3(raw) ? {
|
|
3554
4584
|
...raw,
|
|
3555
|
-
output: Array.isArray(raw.output) ? raw.output.filter(isRecord$
|
|
4585
|
+
output: Array.isArray(raw.output) ? raw.output.filter(isRecord$3) : []
|
|
3556
4586
|
} : {};
|
|
3557
4587
|
const status$1 = recognizedValue(upstream.status, responseStates);
|
|
3558
|
-
const reason = recognizedValue(isRecord$
|
|
4588
|
+
const reason = recognizedValue(isRecord$3(upstream.incomplete_details) ? upstream.incomplete_details.reason : void 0, incompleteReasons);
|
|
3559
4589
|
const items = upstream.output ?? [];
|
|
3560
4590
|
const calls = items.filter((item) => item.type === "web_search_call");
|
|
3561
4591
|
const callStates = calls.map((item) => recognizedValue(item.status, searchStates));
|
|
3562
|
-
const usage = isRecord$
|
|
3563
|
-
const inputTokens = reportedTokens(usage.input_tokens);
|
|
3564
|
-
const cachedInputTokens = reportedTokens(isRecord$
|
|
3565
|
-
const outputTokens = reportedTokens(usage.output_tokens);
|
|
3566
|
-
const reasoningTokens = reportedTokens(isRecord$
|
|
4592
|
+
const usage$1 = isRecord$3(upstream.usage) ? upstream.usage : {};
|
|
4593
|
+
const inputTokens = reportedTokens(usage$1.input_tokens);
|
|
4594
|
+
const cachedInputTokens = reportedTokens(isRecord$3(usage$1.input_tokens_details) ? usage$1.input_tokens_details.cached_tokens : void 0);
|
|
4595
|
+
const outputTokens = reportedTokens(usage$1.output_tokens);
|
|
4596
|
+
const reasoningTokens = reportedTokens(isRecord$3(usage$1.output_tokens_details) ? usage$1.output_tokens_details.reasoning_tokens : void 0);
|
|
3567
4597
|
const upstreamResponseId = safeResponseId(upstream.id, config.copilotToken);
|
|
3568
4598
|
const safeRequestId = typeof requestId === "string" && /^[a-f0-9-]{36}$/i.test(requestId) ? requestId : "unreported";
|
|
3569
4599
|
const text$1 = getResponseText(upstream);
|
|
@@ -3651,7 +4681,7 @@ const getSearchFailureDetail = (body, token) => {
|
|
|
3651
4681
|
let detail = body.trim();
|
|
3652
4682
|
try {
|
|
3653
4683
|
const parsed = JSON.parse(detail);
|
|
3654
|
-
detail = getErrorMessageOrCode(isRecord$
|
|
4684
|
+
detail = getErrorMessageOrCode(isRecord$3(parsed) && isRecord$3(parsed.error) ? parsed.error : void 0);
|
|
3655
4685
|
} catch {
|
|
3656
4686
|
if (/^[{[<]/.test(detail)) return "";
|
|
3657
4687
|
}
|
|
@@ -3820,9 +4850,9 @@ const accumulateChunks = (chunks) => {
|
|
|
3820
4850
|
let reasoning = "";
|
|
3821
4851
|
let refusal = "";
|
|
3822
4852
|
let finishReason = null;
|
|
3823
|
-
let usage;
|
|
4853
|
+
let usage$1;
|
|
3824
4854
|
for (const chunk of chunks) {
|
|
3825
|
-
if (chunk.usage) usage = chunk.usage;
|
|
4855
|
+
if (chunk.usage) usage$1 = chunk.usage;
|
|
3826
4856
|
const choice = chunk.choices[0];
|
|
3827
4857
|
if (!choice) continue;
|
|
3828
4858
|
if (typeof choice.delta?.content === "string") content += choice.delta.content;
|
|
@@ -3863,7 +4893,7 @@ const accumulateChunks = (chunks) => {
|
|
|
3863
4893
|
},
|
|
3864
4894
|
finish_reason: refusal ? "content_filter" : finishReason ?? "stop"
|
|
3865
4895
|
}],
|
|
3866
|
-
...usage ? { usage } : {}
|
|
4896
|
+
...usage$1 ? { usage: usage$1 } : {}
|
|
3867
4897
|
};
|
|
3868
4898
|
};
|
|
3869
4899
|
const incompleteStreamError = () => {
|
|
@@ -3879,7 +4909,7 @@ const incompleteStreamError = () => {
|
|
|
3879
4909
|
async function* normalizeChatCompletionStream(stream) {
|
|
3880
4910
|
let terminal;
|
|
3881
4911
|
let refusal = false;
|
|
3882
|
-
let usage;
|
|
4912
|
+
let usage$1;
|
|
3883
4913
|
for await (const event of stream) {
|
|
3884
4914
|
if (event.event === "error") throw incompleteStreamError();
|
|
3885
4915
|
if (!event.data) continue;
|
|
@@ -3891,7 +4921,7 @@ async function* normalizeChatCompletionStream(stream) {
|
|
|
3891
4921
|
throw incompleteStreamError();
|
|
3892
4922
|
}
|
|
3893
4923
|
if (!chunk || !Array.isArray(chunk.choices)) throw incompleteStreamError();
|
|
3894
|
-
if (chunk.usage) usage = chunk.usage;
|
|
4924
|
+
if (chunk.usage) usage$1 = chunk.usage;
|
|
3895
4925
|
if (chunk.choices.length === 0) continue;
|
|
3896
4926
|
const choice = chunk.choices[0];
|
|
3897
4927
|
if (!choice || !choice.delta || typeof choice.delta !== "object" || terminal) throw incompleteStreamError();
|
|
@@ -3923,7 +4953,7 @@ async function* normalizeChatCompletionStream(stream) {
|
|
|
3923
4953
|
if (!terminal) throw incompleteStreamError();
|
|
3924
4954
|
yield { data: JSON.stringify({
|
|
3925
4955
|
...terminal,
|
|
3926
|
-
...usage && { usage }
|
|
4956
|
+
...usage$1 && { usage: usage$1 }
|
|
3927
4957
|
}) };
|
|
3928
4958
|
yield { data: "[DONE]" };
|
|
3929
4959
|
}
|
|
@@ -4223,7 +5253,7 @@ const lookaroundPrefixes = [
|
|
|
4223
5253
|
"(?<=",
|
|
4224
5254
|
"(?<!"
|
|
4225
5255
|
];
|
|
4226
|
-
const isRecord$
|
|
5256
|
+
const isRecord$2 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
4227
5257
|
function hasUnsupportedPatternFeatures(pattern) {
|
|
4228
5258
|
let inCharacterClass = false;
|
|
4229
5259
|
for (let index = 0; index < pattern.length; index++) {
|
|
@@ -4269,12 +5299,12 @@ function mapArray(values) {
|
|
|
4269
5299
|
}
|
|
4270
5300
|
function normalizeSchemaValue(value) {
|
|
4271
5301
|
if (Array.isArray(value)) return mapArray(value);
|
|
4272
|
-
return isRecord$
|
|
5302
|
+
return isRecord$2(value) ? normalizeResponsesToolSchema(value) : value;
|
|
4273
5303
|
}
|
|
4274
5304
|
function normalizeResponsesToolSchema(schema) {
|
|
4275
5305
|
return mapRecord(schema, (key, value) => {
|
|
4276
5306
|
if (key === "pattern" && typeof value === "string" && hasUnsupportedPatternFeatures(value)) return removed;
|
|
4277
|
-
if (schemaMapKeywords.has(key) && isRecord$
|
|
5307
|
+
if (schemaMapKeywords.has(key) && isRecord$2(value)) return mapRecord(value, (_name, nested) => normalizeSchemaValue(nested));
|
|
4278
5308
|
if (nestedSchemaKeywords.has(key)) return normalizeSchemaValue(value);
|
|
4279
5309
|
return value;
|
|
4280
5310
|
});
|
|
@@ -4553,7 +5583,7 @@ function createRoleChunk(state) {
|
|
|
4553
5583
|
state.started = true;
|
|
4554
5584
|
return createChunk(state, { delta: { role: "assistant" } });
|
|
4555
5585
|
}
|
|
4556
|
-
function createChunk(state, { delta, finishReason = null, usage }) {
|
|
5586
|
+
function createChunk(state, { delta, finishReason = null, usage: usage$1 }) {
|
|
4557
5587
|
return { data: JSON.stringify({
|
|
4558
5588
|
id: state.responseId,
|
|
4559
5589
|
object: "chat.completion.chunk",
|
|
@@ -4565,7 +5595,7 @@ function createChunk(state, { delta, finishReason = null, usage }) {
|
|
|
4565
5595
|
finish_reason: finishReason,
|
|
4566
5596
|
logprobs: null
|
|
4567
5597
|
}],
|
|
4568
|
-
usage
|
|
5598
|
+
usage: usage$1
|
|
4569
5599
|
}) };
|
|
4570
5600
|
}
|
|
4571
5601
|
function translateMessagesToResponsesInput(messages) {
|
|
@@ -4577,7 +5607,7 @@ function translateMessage(message) {
|
|
|
4577
5607
|
if (message.role === "tool") return [{
|
|
4578
5608
|
type: "function_call_output",
|
|
4579
5609
|
call_id: message.tool_call_id ?? "",
|
|
4580
|
-
output:
|
|
5610
|
+
output: translateToolOutput(message.content)
|
|
4581
5611
|
}];
|
|
4582
5612
|
const translated = [];
|
|
4583
5613
|
if (hasContent(message.content)) translated.push({
|
|
@@ -4613,6 +5643,21 @@ function translateContentPart(part) {
|
|
|
4613
5643
|
detail: part.image_url.detail ?? "auto"
|
|
4614
5644
|
};
|
|
4615
5645
|
}
|
|
5646
|
+
function translateToolOutput(content) {
|
|
5647
|
+
if (Array.isArray(content) && content.some((part) => part.type === "image_url")) return content.flatMap((part) => translateToolOutputPart(part));
|
|
5648
|
+
return stringifyToolOutput(content);
|
|
5649
|
+
}
|
|
5650
|
+
function translateToolOutputPart(part) {
|
|
5651
|
+
if (part.type === "image_url") return [{
|
|
5652
|
+
type: "input_image",
|
|
5653
|
+
image_url: part.image_url.url
|
|
5654
|
+
}];
|
|
5655
|
+
if (part.text === "") return [];
|
|
5656
|
+
return [{
|
|
5657
|
+
type: "input_text",
|
|
5658
|
+
text: part.text
|
|
5659
|
+
}];
|
|
5660
|
+
}
|
|
4616
5661
|
function stringifyToolOutput(content) {
|
|
4617
5662
|
if (typeof content === "string") return content;
|
|
4618
5663
|
if (!content) return "";
|
|
@@ -4636,13 +5681,13 @@ function translateToolChoice(toolChoice) {
|
|
|
4636
5681
|
name: toolChoice.function.name
|
|
4637
5682
|
};
|
|
4638
5683
|
}
|
|
4639
|
-
function translateUsage(usage) {
|
|
4640
|
-
if (!usage) return;
|
|
5684
|
+
function translateUsage(usage$1) {
|
|
5685
|
+
if (!usage$1) return;
|
|
4641
5686
|
return {
|
|
4642
|
-
prompt_tokens: usage.input_tokens,
|
|
4643
|
-
completion_tokens: usage.output_tokens,
|
|
4644
|
-
total_tokens: usage.total_tokens,
|
|
4645
|
-
...usage.input_tokens_details?.cached_tokens !== void 0 && { prompt_tokens_details: { cached_tokens: usage.input_tokens_details.cached_tokens } }
|
|
5687
|
+
prompt_tokens: usage$1.input_tokens,
|
|
5688
|
+
completion_tokens: usage$1.output_tokens,
|
|
5689
|
+
total_tokens: usage$1.total_tokens,
|
|
5690
|
+
...usage$1.input_tokens_details?.cached_tokens !== void 0 && { prompt_tokens_details: { cached_tokens: usage$1.input_tokens_details.cached_tokens } }
|
|
4646
5691
|
};
|
|
4647
5692
|
}
|
|
4648
5693
|
function getMessageOutputText(content) {
|
|
@@ -4732,8 +5777,57 @@ const normalizeFinalAssistantPrefill = (payload) => {
|
|
|
4732
5777
|
messages
|
|
4733
5778
|
};
|
|
4734
5779
|
};
|
|
4735
|
-
const
|
|
4736
|
-
const messagesIncludeImage = (messages) => messages.some(
|
|
5780
|
+
const holdsImage = (message) => Array.isArray(message.content) && message.content.some((part) => part.type === "image_url");
|
|
5781
|
+
const messagesIncludeImage = (messages) => messages.some(holdsImage);
|
|
5782
|
+
const isToolImageMessage = (message) => message.role === "tool" && holdsImage(message);
|
|
5783
|
+
const joinTextParts = (parts) => parts.filter((part) => part.type === "text").map((part) => part.text).join("\n\n");
|
|
5784
|
+
const toContentParts = (content) => {
|
|
5785
|
+
if (typeof content === "string") return [{
|
|
5786
|
+
type: "text",
|
|
5787
|
+
text: content
|
|
5788
|
+
}];
|
|
5789
|
+
return content ?? [];
|
|
5790
|
+
};
|
|
5791
|
+
const toolImageNote = "Image output follows in the next user message.";
|
|
5792
|
+
const moveToolImagesToUserMessages = (payload) => {
|
|
5793
|
+
if (isClaudeModelId(payload.model) || !payload.messages.some(isToolImageMessage)) return payload;
|
|
5794
|
+
const messages = [];
|
|
5795
|
+
let movedImages = [];
|
|
5796
|
+
for (const message of payload.messages) {
|
|
5797
|
+
if (isToolImageMessage(message)) {
|
|
5798
|
+
messages.push({
|
|
5799
|
+
...message,
|
|
5800
|
+
content: joinTextParts(message.content) || toolImageNote
|
|
5801
|
+
});
|
|
5802
|
+
movedImages.push({
|
|
5803
|
+
type: "text",
|
|
5804
|
+
text: `Image output of tool call ${message.tool_call_id ?? ""}:`
|
|
5805
|
+
}, ...message.content.filter((part) => part.type === "image_url"));
|
|
5806
|
+
continue;
|
|
5807
|
+
}
|
|
5808
|
+
if (message.role === "tool" || movedImages.length === 0) {
|
|
5809
|
+
messages.push(message);
|
|
5810
|
+
continue;
|
|
5811
|
+
}
|
|
5812
|
+
if (message.role === "user") messages.push({
|
|
5813
|
+
...message,
|
|
5814
|
+
content: [...movedImages, ...toContentParts(message.content)]
|
|
5815
|
+
});
|
|
5816
|
+
else messages.push({
|
|
5817
|
+
role: "user",
|
|
5818
|
+
content: movedImages
|
|
5819
|
+
}, message);
|
|
5820
|
+
movedImages = [];
|
|
5821
|
+
}
|
|
5822
|
+
if (movedImages.length > 0) messages.push({
|
|
5823
|
+
role: "user",
|
|
5824
|
+
content: movedImages
|
|
5825
|
+
});
|
|
5826
|
+
return {
|
|
5827
|
+
...payload,
|
|
5828
|
+
messages
|
|
5829
|
+
};
|
|
5830
|
+
};
|
|
4737
5831
|
const createChatCompletions = async (config, payload, options = {}) => {
|
|
4738
5832
|
const client = options.client ?? "generic";
|
|
4739
5833
|
const requestedModel = options.requestedModel ?? payload.model;
|
|
@@ -4756,10 +5850,10 @@ const createChatCompletions = async (config, payload, options = {}) => {
|
|
|
4756
5850
|
stream: bufferResponse ? true : payload.stream
|
|
4757
5851
|
};
|
|
4758
5852
|
const useResponsesApi = selection.endpoint === "/responses";
|
|
4759
|
-
const compatiblePayload = useResponsesApi ? upstreamPayload : normalizeFinalAssistantPrefill(upstreamPayload);
|
|
5853
|
+
const compatiblePayload = useResponsesApi ? upstreamPayload : normalizeFinalAssistantPrefill(moveToolImagesToUserMessages(upstreamPayload));
|
|
4760
5854
|
const provider = getCopilotProviderContext(config);
|
|
4761
5855
|
const enableVision = messagesIncludeImage(compatiblePayload.messages);
|
|
4762
|
-
const initiator =
|
|
5856
|
+
const initiator = options.initiator ?? "agent";
|
|
4763
5857
|
const requestPayload = buildRequestPayload(compatiblePayload);
|
|
4764
5858
|
const effectiveEffortLabel = requestPayload.reasoning_effort ?? "omitted";
|
|
4765
5859
|
log.info(sanitizeTerminalString([
|
|
@@ -4770,7 +5864,8 @@ const createChatCompletions = async (config, payload, options = {}) => {
|
|
|
4770
5864
|
`upstream_model=${compatiblePayload.model}`,
|
|
4771
5865
|
`requested_think_effort=${requestedThinkEffort}`,
|
|
4772
5866
|
`requested_thinking=${requestedThinking}`,
|
|
4773
|
-
`effective_think_effort=${effectiveEffortLabel}
|
|
5867
|
+
`effective_think_effort=${effectiveEffortLabel}`,
|
|
5868
|
+
`initiator=${initiator}`
|
|
4774
5869
|
].join(" ")));
|
|
4775
5870
|
if (useResponsesApi) return completeResponse(await createResponses(provider, compatiblePayload, {
|
|
4776
5871
|
vision: enableVision,
|
|
@@ -4798,19 +5893,19 @@ const createChatCompletions = async (config, payload, options = {}) => {
|
|
|
4798
5893
|
markDiscardedResponse(response);
|
|
4799
5894
|
await response.body?.cancel();
|
|
4800
5895
|
return completeResponse(await createResponses(provider, upstreamPayload, {
|
|
4801
|
-
vision:
|
|
5896
|
+
vision: messagesIncludeImage(upstreamPayload.messages),
|
|
4802
5897
|
initiator,
|
|
4803
5898
|
requestId: options.requestId,
|
|
4804
5899
|
signal,
|
|
4805
5900
|
timeoutMs: options.timeoutMs
|
|
4806
5901
|
}));
|
|
4807
5902
|
}
|
|
4808
|
-
throw
|
|
5903
|
+
throw await logUpstreamError("Failed to create chat completions", response, {
|
|
4809
5904
|
model: payload.model,
|
|
4810
5905
|
request: requestPayload,
|
|
4811
5906
|
route: "/chat/completions",
|
|
4812
5907
|
requestId: options.requestId
|
|
4813
|
-
}, signal, options.timeoutMs)
|
|
5908
|
+
}, signal, options.timeoutMs);
|
|
4814
5909
|
}
|
|
4815
5910
|
if (compatiblePayload.stream) return completeResponse(normalizeChatCompletionStream(events(response)));
|
|
4816
5911
|
return readCopilotJson(response, signal, options.timeoutMs);
|
|
@@ -4832,12 +5927,12 @@ async function createResponses(provider, payload, options) {
|
|
|
4832
5927
|
signal: options.signal,
|
|
4833
5928
|
timeoutMs: options.timeoutMs
|
|
4834
5929
|
});
|
|
4835
|
-
if (!response.ok) throw
|
|
5930
|
+
if (!response.ok) throw await logUpstreamError("Failed to create responses", response, {
|
|
4836
5931
|
model: payload.model,
|
|
4837
5932
|
request: requestPayload,
|
|
4838
5933
|
route: "/responses",
|
|
4839
5934
|
requestId: options.requestId
|
|
4840
|
-
}, options.signal, options.timeoutMs)
|
|
5935
|
+
}, options.signal, options.timeoutMs);
|
|
4841
5936
|
if (payload.stream) return normalizeChatCompletionStream(translateResponsesStreamToChatCompletionStream(events(response)));
|
|
4842
5937
|
return translateResponsesToChatCompletion(await readCopilotJson(response, options.signal, options.timeoutMs));
|
|
4843
5938
|
}
|
|
@@ -4858,7 +5953,7 @@ async function logUpstreamError(message, response, context, signal, timeoutMs) {
|
|
|
4858
5953
|
},
|
|
4859
5954
|
request: context.request
|
|
4860
5955
|
});
|
|
4861
|
-
return detail;
|
|
5956
|
+
return toPromptTooLongError(response.status, errorBody, detail) ?? new HTTPError(message, response, detail);
|
|
4862
5957
|
}
|
|
4863
5958
|
function getUpstreamErrorDetail(response, body) {
|
|
4864
5959
|
if (!body) return response.statusText || void 0;
|
|
@@ -5004,7 +6099,8 @@ async function createNativeMessages(config, payload, options) {
|
|
|
5004
6099
|
const beta = options.headers?.get("anthropic-beta");
|
|
5005
6100
|
if (beta) headers["anthropic-beta"] = beta;
|
|
5006
6101
|
const effortLabel = effort === void 0 ? "omitted" : getClaudeTurnEffort(body).effective;
|
|
5007
|
-
|
|
6102
|
+
const initiator = options.initiator ?? "agent";
|
|
6103
|
+
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}`));
|
|
5008
6104
|
const response = await fetchCopilot(getCopilotProviderContext(config), "/v1/messages", {
|
|
5009
6105
|
method: "POST",
|
|
5010
6106
|
headers,
|
|
@@ -5013,11 +6109,13 @@ async function createNativeMessages(config, payload, options) {
|
|
|
5013
6109
|
requestId: options.requestId,
|
|
5014
6110
|
signal: options.signal,
|
|
5015
6111
|
timeoutMs: config.upstreamTimeoutMs,
|
|
5016
|
-
initiator
|
|
6112
|
+
initiator
|
|
5017
6113
|
});
|
|
5018
6114
|
if (!response.ok) {
|
|
5019
6115
|
const detail = await readCopilotText(response, options.signal, config.upstreamTimeoutMs);
|
|
5020
6116
|
const contentType = response.headers.get("content-type") ?? "application/json";
|
|
6117
|
+
const promptTooLong = toPromptTooLongError(response.status, detail);
|
|
6118
|
+
if (promptTooLong) throw promptTooLong;
|
|
5021
6119
|
throw new HTTPError("Native upstream request failed", new Response(detail, {
|
|
5022
6120
|
status: response.status,
|
|
5023
6121
|
headers: { "content-type": contentType }
|
|
@@ -5247,7 +6345,7 @@ async function handleNativeMessages(config, payload, options, write) {
|
|
|
5247
6345
|
};
|
|
5248
6346
|
const upstreamToolResult = toolResult(searchCall.id, resultBlock.content);
|
|
5249
6347
|
const hasSiblingTools = message.content.some((block) => block.type === "tool_use" && block !== searchCall);
|
|
5250
|
-
let usage = mergeUsage(message.usage, searchMessage.usage);
|
|
6348
|
+
let usage$1 = mergeUsage(message.usage, searchMessage.usage);
|
|
5251
6349
|
const combined = [...displayDecision, resultBlock];
|
|
5252
6350
|
if (write) for (const [index, block] of combined.entries()) {
|
|
5253
6351
|
if (index < (heldFrom ?? message.content.length)) continue;
|
|
@@ -5258,13 +6356,13 @@ async function handleNativeMessages(config, payload, options, write) {
|
|
|
5258
6356
|
...message,
|
|
5259
6357
|
content: combined,
|
|
5260
6358
|
stop_reason: hasSiblingTools ? "tool_use" : "end_turn",
|
|
5261
|
-
usage
|
|
6359
|
+
usage: usage$1
|
|
5262
6360
|
};
|
|
5263
6361
|
if (!write) return complete;
|
|
5264
6362
|
await write({
|
|
5265
6363
|
type: "message_delta",
|
|
5266
6364
|
delta: { stop_reason: complete.stop_reason },
|
|
5267
|
-
usage
|
|
6365
|
+
usage: usage$1
|
|
5268
6366
|
});
|
|
5269
6367
|
await write({ type: "message_stop" });
|
|
5270
6368
|
return;
|
|
@@ -5284,7 +6382,8 @@ async function handleNativeMessages(config, payload, options, write) {
|
|
|
5284
6382
|
]
|
|
5285
6383
|
}, {
|
|
5286
6384
|
...options,
|
|
5287
|
-
signal
|
|
6385
|
+
signal,
|
|
6386
|
+
initiator: "agent"
|
|
5288
6387
|
});
|
|
5289
6388
|
const offset = combined.length;
|
|
5290
6389
|
let finalHeldFrom;
|
|
@@ -5297,11 +6396,11 @@ async function handleNativeMessages(config, payload, options, write) {
|
|
|
5297
6396
|
});
|
|
5298
6397
|
} : void 0);
|
|
5299
6398
|
if (finalMessage.content.some((block) => block.type === "tool_use" && isClaudeWebSearchToolName(block.name ?? ""))) throw new Error("Repeated bridge-managed search is unsupported.");
|
|
5300
|
-
usage = mergeUsage(usage, finalMessage.usage);
|
|
6399
|
+
usage$1 = mergeUsage(usage$1, finalMessage.usage);
|
|
5301
6400
|
if (!write) return {
|
|
5302
6401
|
...finalMessage,
|
|
5303
6402
|
content: [...combined, ...finalMessage.content],
|
|
5304
|
-
usage
|
|
6403
|
+
usage: usage$1
|
|
5305
6404
|
};
|
|
5306
6405
|
if (finalHeldFrom !== void 0) for (const [index, block] of finalMessage.content.entries()) {
|
|
5307
6406
|
if (index < finalHeldFrom) continue;
|
|
@@ -5314,7 +6413,7 @@ async function handleNativeMessages(config, payload, options, write) {
|
|
|
5314
6413
|
stop_sequence: finalMessage.stop_sequence,
|
|
5315
6414
|
..."stop_details" in finalMessage && { stop_details: finalMessage.stop_details }
|
|
5316
6415
|
},
|
|
5317
|
-
usage
|
|
6416
|
+
usage: usage$1
|
|
5318
6417
|
});
|
|
5319
6418
|
await write({ type: "message_stop" });
|
|
5320
6419
|
}
|
|
@@ -5487,13 +6586,14 @@ const translationEndpoint = (config, model) => {
|
|
|
5487
6586
|
const selection = selectCopilotEndpoint(config, normalizeCopilotModelId(model));
|
|
5488
6587
|
return selection.endpoint && !selection.responsesFallback ? selection.endpoint : void 0;
|
|
5489
6588
|
};
|
|
5490
|
-
const handleClaudeMessageRequest = async (config, claudePayload, requestSignal, requestId, writeEvent, requestHeaders) => {
|
|
6589
|
+
const handleClaudeMessageRequest = async (config, claudePayload, initiator, requestSignal, requestId, writeEvent, requestHeaders) => {
|
|
5491
6590
|
const upstreamModel = translateModelName(claudePayload.model);
|
|
5492
6591
|
if (shouldUseNativeMessages(config, upstreamModel)) return handleNativeMessages(config, {
|
|
5493
6592
|
...claudePayload,
|
|
5494
6593
|
model: upstreamModel
|
|
5495
6594
|
}, {
|
|
5496
6595
|
requestId,
|
|
6596
|
+
initiator,
|
|
5497
6597
|
signal: requestSignal,
|
|
5498
6598
|
headers: requestHeaders
|
|
5499
6599
|
}, writeEvent);
|
|
@@ -5505,6 +6605,7 @@ const handleClaudeMessageRequest = async (config, claudePayload, requestSignal,
|
|
|
5505
6605
|
if (shouldLetModelDecideWebSearch && !canStreamWebSearchDecision) openAIPayload.stream = false;
|
|
5506
6606
|
const response = await createChatCompletions(config, openAIPayload, {
|
|
5507
6607
|
client: "claude",
|
|
6608
|
+
initiator,
|
|
5508
6609
|
requestedModel: claudePayload.model,
|
|
5509
6610
|
requestedThinkEffort: getClaudeRequestedThinkEffort(claudePayload),
|
|
5510
6611
|
requestedThinking: getClaudeRequestedThinking(claudePayload),
|
|
@@ -5578,6 +6679,7 @@ const handleClaudeMessageRequest = async (config, claudePayload, requestSignal,
|
|
|
5578
6679
|
else {
|
|
5579
6680
|
const finalResponse = await createChatCompletions(config, createFinalWebSearchPayload(openAIPayload, search, toolNameMapper), {
|
|
5580
6681
|
client: "claude",
|
|
6682
|
+
initiator: "agent",
|
|
5581
6683
|
requestedModel: claudePayload.model,
|
|
5582
6684
|
requestedThinkEffort: getClaudeRequestedThinkEffort(claudePayload),
|
|
5583
6685
|
requestedThinking: getClaudeRequestedThinking(claudePayload),
|
|
@@ -5655,7 +6757,9 @@ claudeRoutes.get("/models", (c) => c.json({
|
|
|
5655
6757
|
claudeRoutes.post("/messages", async (c) => {
|
|
5656
6758
|
const config = c.get("config");
|
|
5657
6759
|
const requestId = c.get("requestId");
|
|
5658
|
-
const
|
|
6760
|
+
const receivedPayload = await c.req.json();
|
|
6761
|
+
const initiator = c.env?.relayProbe === true ? "agent" : getClaudeRequestInitiator(receivedPayload);
|
|
6762
|
+
const claudePayload = removeBillingLine(receivedPayload);
|
|
5659
6763
|
const requestSignal = createCopilotRequestSignal(c.req.raw.signal, config.upstreamTimeoutMs);
|
|
5660
6764
|
try {
|
|
5661
6765
|
getRequestReasoningEffort(claudePayload);
|
|
@@ -5706,7 +6810,7 @@ claudeRoutes.post("/messages", async (c) => {
|
|
|
5706
6810
|
data: JSON.stringify(event)
|
|
5707
6811
|
}));
|
|
5708
6812
|
try {
|
|
5709
|
-
await handleClaudeMessageRequest(config, claudePayload, requestSignal, requestId, writeEvent, c.req.raw.headers);
|
|
6813
|
+
await handleClaudeMessageRequest(config, claudePayload, initiator, requestSignal, requestId, writeEvent, c.req.raw.headers);
|
|
5710
6814
|
} catch (error) {
|
|
5711
6815
|
log.error(`request_id=${requestId} Error during Claude stream request:`, error);
|
|
5712
6816
|
if (error instanceof UpstreamToolInputError) trace?.recordFailure("invalid-tool-input");
|
|
@@ -5720,7 +6824,7 @@ claudeRoutes.post("/messages", async (c) => {
|
|
|
5720
6824
|
});
|
|
5721
6825
|
}
|
|
5722
6826
|
try {
|
|
5723
|
-
return c.json(await handleClaudeMessageRequest(config, claudePayload, requestSignal, requestId, void 0, c.req.raw.headers));
|
|
6827
|
+
return c.json(await handleClaudeMessageRequest(config, claudePayload, initiator, requestSignal, requestId, void 0, c.req.raw.headers));
|
|
5724
6828
|
} catch (error) {
|
|
5725
6829
|
if (error instanceof ProxyNotImplementedError) {
|
|
5726
6830
|
c.set("requestErrorMessage", error.message);
|
|
@@ -5744,7 +6848,7 @@ claudeRoutes.post("/messages", async (c) => {
|
|
|
5744
6848
|
claudeRoutes.post("/messages/count_tokens", async (c) => {
|
|
5745
6849
|
try {
|
|
5746
6850
|
const claudeBeta = c.req.header("claude-beta");
|
|
5747
|
-
const claudePayload = await c.req.json();
|
|
6851
|
+
const claudePayload = removeBillingLine(await c.req.json());
|
|
5748
6852
|
const openAIPayload = translateToOpenAI(shouldUseNativeMessages(c.get("config"), translateModelName(claudePayload.model)) ? {
|
|
5749
6853
|
...claudePayload,
|
|
5750
6854
|
messages: claudePayload.messages.map((message) => {
|
|
@@ -5816,6 +6920,22 @@ const parseAuthority = (authority, protocol) => {
|
|
|
5816
6920
|
};
|
|
5817
6921
|
const effectivePort = (url) => Number(url.port || (url.protocol === "https:" ? 443 : 80));
|
|
5818
6922
|
const isAllowedHostname = (hostname, configuredHost) => hostname === "localhost" || hostname === "[::1]" || isIPv4(hostname) && hostname.startsWith("127.") || hostname !== "0.0.0.0" && hostname !== "[::]" && hostname === configuredHost;
|
|
6923
|
+
const sha256 = (value) => createHash("sha256").update(value, "utf8").digest();
|
|
6924
|
+
/**
|
|
6925
|
+
* True when the request carries the relay's apiKey as x-api-key or Authorization: Bearer.
|
|
6926
|
+
*
|
|
6927
|
+
* The configured key and each presented value are hashed with SHA-256, and timingSafeEqual compares
|
|
6928
|
+
* the fixed-length digests, so the comparison never stops at the first byte that differs. Both
|
|
6929
|
+
* headers are compared even after one matches.
|
|
6930
|
+
*/
|
|
6931
|
+
const presentsApiKey = (headers, apiKey) => {
|
|
6932
|
+
const expected = sha256(apiKey);
|
|
6933
|
+
const bearer = /^Bearer\s+(.+)$/i.exec(headers.get("authorization") ?? "")?.[1];
|
|
6934
|
+
let matched = false;
|
|
6935
|
+
for (const presented of [headers.get("x-api-key"), bearer]) if (typeof presented === "string" && timingSafeEqual(sha256(presented), expected)) matched = true;
|
|
6936
|
+
return matched;
|
|
6937
|
+
};
|
|
6938
|
+
const isOpenProbe = (method, path$1) => method === "GET" && path$1 === "/healthz" || (method === "GET" || method === "HEAD") && path$1 === "/api/hello";
|
|
5819
6939
|
const createServer = (config) => {
|
|
5820
6940
|
const app = new Hono();
|
|
5821
6941
|
const { host, port } = config;
|
|
@@ -5859,6 +6979,18 @@ const createServer = (config) => {
|
|
|
5859
6979
|
return c.json({ error: { message } }, 403);
|
|
5860
6980
|
}
|
|
5861
6981
|
}
|
|
6982
|
+
const apiKey = config.apiKey;
|
|
6983
|
+
if (apiKey && !isOpenProbe(c.req.method, c.req.path) && !presentsApiKey(c.req.raw.headers, apiKey)) {
|
|
6984
|
+
const message = "Missing or invalid API key: send the relay apiKey as x-api-key or Authorization: Bearer";
|
|
6985
|
+
c.set("requestErrorMessage", message);
|
|
6986
|
+
return c.json({
|
|
6987
|
+
type: "error",
|
|
6988
|
+
error: {
|
|
6989
|
+
type: "authentication_error",
|
|
6990
|
+
message
|
|
6991
|
+
}
|
|
6992
|
+
}, 401, { "www-authenticate": "Bearer" });
|
|
6993
|
+
}
|
|
5862
6994
|
if (c.req.method === "POST" && (c.req.path === "/v1/messages" || c.req.path === "/v1/messages/count_tokens") && (incoming ? Boolean(incoming.headers["transfer-encoding"]) || Number(incoming.headers["content-length"] ?? 0) > 0 : c.req.raw.body !== null) && c.req.header("content-type")?.split(";", 1)[0].trim().toLowerCase() !== "application/json") {
|
|
5863
6995
|
const message = "Content-Type must be application/json";
|
|
5864
6996
|
c.set("requestErrorMessage", message);
|
|
@@ -5939,7 +7071,14 @@ const startServer = (config) => new Promise((resolve$1, reject) => {
|
|
|
5939
7071
|
//#endregion
|
|
5940
7072
|
//#region src/lib/model-probe.ts
|
|
5941
7073
|
const record$1 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
5942
|
-
const
|
|
7074
|
+
const holdsCredential = (text$1, config) => {
|
|
7075
|
+
for (const secret of [config.copilotToken, config.apiKey]) if (secret && text$1.includes(secret)) return true;
|
|
7076
|
+
return false;
|
|
7077
|
+
};
|
|
7078
|
+
const safeId = (id, config) => typeof id === "string" && /^[A-Za-z0-9._\[\]-]{1,128}$/.test(id) && !holdsCredential(id, config) && !/(?:gh[pousr]_|github_pat_|sk-|eyJ)/.test(id);
|
|
7079
|
+
const printLine = (line) => {
|
|
7080
|
+
console.log(scrubLogSecrets(scrubSensitiveUrls(line)));
|
|
7081
|
+
};
|
|
5943
7082
|
const httpCategory = (status$1) => {
|
|
5944
7083
|
if (status$1 === 401) return "authentication-rejected";
|
|
5945
7084
|
if (status$1 === 403) return "access-denied";
|
|
@@ -5996,9 +7135,9 @@ const failureFromEvidence = (row, diagnostic) => {
|
|
|
5996
7135
|
return row.detail;
|
|
5997
7136
|
};
|
|
5998
7137
|
async function probeModels(config, entries, options) {
|
|
5999
|
-
|
|
6000
|
-
|
|
6001
|
-
|
|
7138
|
+
printLine(`\nModel check · ${entries.length} ${entries.length === 1 ? "model" : "models"} · real Copilot usage`);
|
|
7139
|
+
printLine("Isolated relay pipeline; not running-daemon health.");
|
|
7140
|
+
printLine(`Up to ${options.maxTokens} output tokens/probe; existing retries may add calls.\n`);
|
|
6002
7141
|
const controller = new AbortController();
|
|
6003
7142
|
const interrupt = () => controller.abort();
|
|
6004
7143
|
process.on("SIGINT", interrupt);
|
|
@@ -6012,9 +7151,9 @@ async function probeModels(config, entries, options) {
|
|
|
6012
7151
|
const results = [];
|
|
6013
7152
|
const app = createServer(config);
|
|
6014
7153
|
const columns = Math.max(40, process.stdout.columns || 80);
|
|
6015
|
-
const width = probeColumnWidth(entries.map(([id]) => safeId(id, config
|
|
7154
|
+
const width = probeColumnWidth(entries.map(([id]) => safeId(id, config) ? id : "[unsupported ID]"), columns);
|
|
6016
7155
|
const color = colorEnabled();
|
|
6017
|
-
if (entries.length)
|
|
7156
|
+
if (entries.length) printLine(renderProbeHeader(width));
|
|
6018
7157
|
try {
|
|
6019
7158
|
runtimeState.modelCatalog = config.modelCatalog;
|
|
6020
7159
|
runtimeState.upstreamBaseUrl = config.copilotBaseUrl;
|
|
@@ -6036,7 +7175,7 @@ async function probeModels(config, entries, options) {
|
|
|
6036
7175
|
const endpoint = selection.endpoint ?? "none";
|
|
6037
7176
|
const { advertisedEndpoints, unknownEndpoints } = summarizeAdvertisedEndpoints(model.supportedEndpoints);
|
|
6038
7177
|
const row = {
|
|
6039
|
-
id: safeId(id, config
|
|
7178
|
+
id: safeId(id, config) ? id : "[unsupported ID]",
|
|
6040
7179
|
status: "NOT_TESTED",
|
|
6041
7180
|
reported: "-",
|
|
6042
7181
|
sent: false,
|
|
@@ -6051,7 +7190,7 @@ async function probeModels(config, entries, options) {
|
|
|
6051
7190
|
unverified: model.reasoningEfforts === void 0 || model.supportedEndpoints === void 0
|
|
6052
7191
|
};
|
|
6053
7192
|
if (controller.signal.aborted || totalDeadline.aborted) row.detail = controller.signal.aborted ? "cancelled" : "total-deadline";
|
|
6054
|
-
else if (!safeId(id, config
|
|
7193
|
+
else if (!safeId(id, config) || normalizeCopilotModelId(id) !== id) {
|
|
6055
7194
|
row.status = "SKIPPED";
|
|
6056
7195
|
row.detail = "unsafe-or-noncanonical-id";
|
|
6057
7196
|
} else if (!selection.endpoint) {
|
|
@@ -6080,7 +7219,10 @@ async function probeModels(config, entries, options) {
|
|
|
6080
7219
|
const sendProbe = async () => {
|
|
6081
7220
|
const response = await app.fetch(new Request(`http://localhost${config.port ? `:${config.port}` : ""}/v1/messages`, {
|
|
6082
7221
|
method: "POST",
|
|
6083
|
-
headers: {
|
|
7222
|
+
headers: {
|
|
7223
|
+
"content-type": "application/json",
|
|
7224
|
+
...config.apiKey ? { "x-api-key": config.apiKey } : {}
|
|
7225
|
+
},
|
|
6084
7226
|
signal,
|
|
6085
7227
|
body: JSON.stringify({
|
|
6086
7228
|
model: id,
|
|
@@ -6092,7 +7234,7 @@ async function probeModels(config, entries, options) {
|
|
|
6092
7234
|
content: "Reply with OK only."
|
|
6093
7235
|
}]
|
|
6094
7236
|
})
|
|
6095
|
-
}));
|
|
7237
|
+
}), { relayProbe: true });
|
|
6096
7238
|
return {
|
|
6097
7239
|
response,
|
|
6098
7240
|
body: await response.json().catch(() => void 0)
|
|
@@ -6121,7 +7263,7 @@ async function probeModels(config, entries, options) {
|
|
|
6121
7263
|
if (code === "model_not_supported") row.detail = "model-not-supported";
|
|
6122
7264
|
} else if (!record$1(body) || !Array.isArray(body.content)) row.detail = "malformed-response";
|
|
6123
7265
|
else {
|
|
6124
|
-
row.reported = safeId(body.model, config
|
|
7266
|
+
row.reported = safeId(body.model, config) ? body.model : "unreported";
|
|
6125
7267
|
if (!reportsSelectedModel(endpoint, id, row.reported)) row.detail = "model-mismatch";
|
|
6126
7268
|
else if (body.stop_reason === "max_tokens") {
|
|
6127
7269
|
row.status = "INCOMPLETE";
|
|
@@ -6153,11 +7295,11 @@ async function probeModels(config, entries, options) {
|
|
|
6153
7295
|
if (row.status === "FAIL") row.detail = failureFromEvidence(row, row.diagnostic);
|
|
6154
7296
|
}
|
|
6155
7297
|
}
|
|
6156
|
-
if (!safeId(id, config
|
|
7298
|
+
if (!safeId(id, config)) row.id = "[unsupported ID]";
|
|
6157
7299
|
results.push(row);
|
|
6158
|
-
for (const line of renderProbeRow(row, width, color, columns))
|
|
6159
|
-
if (options.details) for (const line of renderProbeDetails(row, row.diagnostic))
|
|
6160
|
-
else if (row.status !== "PASS" && row.diagnostic)
|
|
7300
|
+
for (const line of renderProbeRow(row, width, color, columns)) printLine(line);
|
|
7301
|
+
if (options.details) for (const line of renderProbeDetails(row, row.diagnostic)) printLine(line);
|
|
7302
|
+
else if (row.status !== "PASS" && row.diagnostic) printLine(` request_id=${row.diagnostic.requestId}`);
|
|
6161
7303
|
if (row.status !== "PASS" && row.sent) withoutConsoleLogging(() => log.info(`request_id=${row.diagnostic?.requestId ?? "unknown"} model_probe model=${row.id} status=${row.status} reason=${row.detail}`));
|
|
6162
7304
|
}
|
|
6163
7305
|
} finally {
|
|
@@ -6172,11 +7314,11 @@ async function probeModels(config, entries, options) {
|
|
|
6172
7314
|
process.off("SIGINT", interrupt);
|
|
6173
7315
|
process.off("SIGTERM", interrupt);
|
|
6174
7316
|
}
|
|
6175
|
-
|
|
6176
|
-
if (results.some((row) => row.unverified))
|
|
6177
|
-
const hints = new Set(results.filter((row) => row.status !== "PASS").map((row) => probeHint(row.detail)).filter(
|
|
6178
|
-
for (const hint of hints)
|
|
6179
|
-
if (!options.details && results.some((row) => row.status !== "PASS" && row.sent))
|
|
7317
|
+
printLine(`\n${renderProbeSummary(results)}`);
|
|
7318
|
+
if (results.some((row) => row.unverified)) printLine("* Effort or endpoint metadata is unverified.");
|
|
7319
|
+
const hints = new Set(results.filter((row) => row.status !== "PASS").map((row) => probeHint(row.detail)).filter((hint) => hint !== void 0));
|
|
7320
|
+
for (const hint of hints) printLine(hint);
|
|
7321
|
+
if (!options.details && results.some((row) => row.status !== "PASS" && row.sent)) printLine("Use --details for request evidence; another deep run consumes usage.");
|
|
6180
7322
|
if (controller.signal.aborted) return 130;
|
|
6181
7323
|
return results.length > 0 && results.every((row) => row.status === "PASS") ? 0 : 2;
|
|
6182
7324
|
}
|
|
@@ -6269,8 +7411,9 @@ const renderConfigGuide = (configPath$1, configured, chosen) => {
|
|
|
6269
7411
|
//#endregion
|
|
6270
7412
|
//#region src/models.ts
|
|
6271
7413
|
var ModelsUsageError = class extends Error {};
|
|
7414
|
+
const printable = (text$1) => scrubLogSecrets(terminalText(text$1));
|
|
6272
7415
|
const displayName = (name) => {
|
|
6273
|
-
const text$1 =
|
|
7416
|
+
const text$1 = printable(name ?? "").trim();
|
|
6274
7417
|
if (text$1 === "") return;
|
|
6275
7418
|
return text$1.length > 80 ? `${text$1.slice(0, 77)}...` : text$1;
|
|
6276
7419
|
};
|
|
@@ -6278,7 +7421,7 @@ const listedModels = (config, catalog) => [...catalog.models.keys()].sort().map(
|
|
|
6278
7421
|
const selection = selectCopilotEndpoint(config, id);
|
|
6279
7422
|
const name = displayName(catalog.models.get(id)?.name);
|
|
6280
7423
|
return {
|
|
6281
|
-
id:
|
|
7424
|
+
id: printable(id),
|
|
6282
7425
|
...name !== void 0 && { name },
|
|
6283
7426
|
...!selection.endpoint && { unusable: probeReason(selection.reason) }
|
|
6284
7427
|
};
|
|
@@ -6288,7 +7431,7 @@ const configuredModels = (appConfig, config, catalog) => ["gptModel", "opusModel
|
|
|
6288
7431
|
const selection = selectCopilotEndpoint(config, id);
|
|
6289
7432
|
return {
|
|
6290
7433
|
key,
|
|
6291
|
-
value:
|
|
7434
|
+
value: printable(appConfig[key]),
|
|
6292
7435
|
advertised: catalog.models.has(id),
|
|
6293
7436
|
...!selection.endpoint && { unusable: probeReason(selection.reason) }
|
|
6294
7437
|
};
|
|
@@ -6298,7 +7441,7 @@ const listingHeading = (search, result) => {
|
|
|
6298
7441
|
if (result.models.length === 0) return "No models advertised by upstream.";
|
|
6299
7442
|
return `Upstream-advertised models (${result.models.length}):`;
|
|
6300
7443
|
}
|
|
6301
|
-
const label =
|
|
7444
|
+
const label = printable(search);
|
|
6302
7445
|
if (result.found) return `Upstream models matching "${label}" (${result.models.length}):`;
|
|
6303
7446
|
if (result.models.length === 0) return `No upstream model matches "${label}". List them all with copilot-relay models.`;
|
|
6304
7447
|
return `No upstream model matches "${label}". Closest IDs (${result.models.length}):`;
|
|
@@ -6375,6 +7518,8 @@ const models = defineCommand({
|
|
|
6375
7518
|
const appConfig$1 = await readAppConfig();
|
|
6376
7519
|
setLogLevel(appConfig$1.logLevel);
|
|
6377
7520
|
registerSensitiveOrigin(appConfig$1.copilotBaseUrl);
|
|
7521
|
+
registerLogSecret(appConfig$1.apiKey);
|
|
7522
|
+
configureUpstreamDispatcher(appConfig$1.upstreamProxy);
|
|
6378
7523
|
const config$1 = readProxyConfig(appConfig$1);
|
|
6379
7524
|
failure = "Could not authenticate with GitHub Copilot";
|
|
6380
7525
|
await setupProxyAuth(config$1, args.deep ? { onDeviceCode: (url, code) => console.error(`Sign in: open ${terminalText(url)} and enter ${terminalText(code)}.`) } : void 0);
|
|
@@ -6405,7 +7550,7 @@ const models = defineCommand({
|
|
|
6405
7550
|
const lines = [listingHeading(search, result), ...renderModelRows(result.models)];
|
|
6406
7551
|
if (result.models.length > 0) lines.push("Inference, tool, and effort compatibility are not verified by this listing.", `Test one with copilot-relay models --deep --model ${result.chosen?.id ?? "<id>"}; it consumes Copilot usage.`);
|
|
6407
7552
|
lines.push("", ...renderConfigGuide(paths.configPath, configuredModels(appConfig, config, catalog), result.chosen));
|
|
6408
|
-
console.log(scrubSensitiveUrls(
|
|
7553
|
+
console.log(lines.map((line) => scrubLogSecrets(scrubSensitiveUrls(terminalText(line)))).join("\n"));
|
|
6409
7554
|
if (!result.found) process.exitCode = 1;
|
|
6410
7555
|
} catch (error) {
|
|
6411
7556
|
let detail = "Check configuration, authentication, and upstream connectivity.";
|
|
@@ -6970,8 +8115,8 @@ const replay = defineCommand({
|
|
|
6970
8115
|
console.log(`${method} ${path$1} status=${status$1 ?? "unknown"} outcome=${outcome}`);
|
|
6971
8116
|
console.log(`upstream=${routes$1.join(" -> ") || "none"}`);
|
|
6972
8117
|
for (const exchange of result.summary.exchanges) {
|
|
6973
|
-
const usage = Object.entries(exchange.usage).map(([key, value]) => `${key}=${value}`).join(" ");
|
|
6974
|
-
console.log(`exchange=${exchange.order} path=${exchange.path} status=${exchange.status ?? "unknown"} outcome=${exchange.outcome}${usage ? ` ${usage}` : ""}${exchange.refusalCategory ? ` refusal_category=${exchange.refusalCategory}` : ""}`);
|
|
8118
|
+
const usage$1 = Object.entries(exchange.usage).map(([key, value]) => `${key}=${value}`).join(" ");
|
|
8119
|
+
console.log(`exchange=${exchange.order} path=${exchange.path} status=${exchange.status ?? "unknown"} outcome=${exchange.outcome}${usage$1 ? ` ${usage$1}` : ""}${exchange.refusalCategory ? ` refusal_category=${exchange.refusalCategory}` : ""}`);
|
|
6975
8120
|
}
|
|
6976
8121
|
if (result.summary.contentBlocks) console.log(`client_blocks=${result.summary.contentBlocks.join(",") || "none"} tool_calls=${result.summary.toolCalls ?? 0}`);
|
|
6977
8122
|
}
|
|
@@ -6988,6 +8133,22 @@ const getRelayBaseUrl = (host, port) => {
|
|
|
6988
8133
|
const hostname = new URL(`http://${authority}`).hostname;
|
|
6989
8134
|
return `http://${loopbackForWildcard.get(hostname) ?? hostname}:${port}`;
|
|
6990
8135
|
};
|
|
8136
|
+
const loopbackAddresses = new BlockList();
|
|
8137
|
+
loopbackAddresses.addSubnet("127.0.0.0", 8, "ipv4");
|
|
8138
|
+
loopbackAddresses.addAddress("::1", "ipv6");
|
|
8139
|
+
/**
|
|
8140
|
+
* True when a bind host accepts connections from this machine only.
|
|
8141
|
+
*
|
|
8142
|
+
* Literal loopback addresses and the name localhost qualify. Wildcards, other addresses and other
|
|
8143
|
+
* names do not: a name can resolve to any address, so it is treated as reachable from the network.
|
|
8144
|
+
*/
|
|
8145
|
+
const isLoopbackHost = (host) => {
|
|
8146
|
+
const bare = host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host;
|
|
8147
|
+
if (bare.toLowerCase() === "localhost") return true;
|
|
8148
|
+
const family = isIP(bare);
|
|
8149
|
+
if (family === 0) return false;
|
|
8150
|
+
return loopbackAddresses.check(bare, family === 4 ? "ipv4" : "ipv6");
|
|
8151
|
+
};
|
|
6991
8152
|
|
|
6992
8153
|
//#endregion
|
|
6993
8154
|
//#region src/lib/lifecycle.ts
|
|
@@ -7424,18 +8585,20 @@ const knownModelEnvKeys = [
|
|
|
7424
8585
|
"ANTHROPIC_SMALL_FAST_MODEL",
|
|
7425
8586
|
"CLAUDE_CODE_SUBAGENT_MODEL"
|
|
7426
8587
|
];
|
|
7427
|
-
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
8588
|
+
const isRecord$1 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
7428
8589
|
const hasMalformedModelOverride = (parsed) => {
|
|
7429
8590
|
if (parsed.model !== void 0 && typeof parsed.model !== "string") return true;
|
|
7430
8591
|
const env = parsed.env;
|
|
7431
|
-
if (!isRecord(env)) return false;
|
|
8592
|
+
if (!isRecord$1(env)) return false;
|
|
7432
8593
|
return knownModelEnvKeys.some((key) => env[key] !== void 0 && typeof env[key] !== "string");
|
|
7433
8594
|
};
|
|
7434
8595
|
const hasPrimaryModelOverride = (parsed, env) => typeof parsed.model === "string" || typeof env.ANTHROPIC_MODEL === "string";
|
|
7435
8596
|
/**
|
|
7436
8597
|
* Update `~/.claude/settings.json` so its env block points Claude Code at the
|
|
7437
8598
|
* running proxy. Preserves unrelated keys and model choices while normalizing
|
|
7438
|
-
* managed one-million-context GPT overrides. Sets
|
|
8599
|
+
* managed one-million-context GPT overrides. Sets the auth token to the relay's
|
|
8600
|
+
* apiKey when one is configured and then publishes the file owner-only;
|
|
8601
|
+
* otherwise keeps an existing token and writes a dummy value only if none is set.
|
|
7439
8602
|
*/
|
|
7440
8603
|
async function applyClaudeConfig(input) {
|
|
7441
8604
|
const { configPath: configPath$1, baseUrl, gptModel } = input;
|
|
@@ -7450,7 +8613,7 @@ async function applyClaudeConfig(input) {
|
|
|
7450
8613
|
};
|
|
7451
8614
|
if (raw.trim().length > 0) try {
|
|
7452
8615
|
const value = JSON.parse(raw);
|
|
7453
|
-
if (!isRecord(value) || value.env !== void 0 && !isRecord(value.env) || hasMalformedModelOverride(value)) return {
|
|
8616
|
+
if (!isRecord$1(value) || value.env !== void 0 && !isRecord$1(value.env) || hasMalformedModelOverride(value)) return {
|
|
7454
8617
|
configPath: configPath$1,
|
|
7455
8618
|
changed: false,
|
|
7456
8619
|
created: false
|
|
@@ -7463,7 +8626,7 @@ async function applyClaudeConfig(input) {
|
|
|
7463
8626
|
created: false
|
|
7464
8627
|
};
|
|
7465
8628
|
}
|
|
7466
|
-
const env = isRecord(parsed.env) ? { ...parsed.env } : {};
|
|
8629
|
+
const env = isRecord$1(parsed.env) ? { ...parsed.env } : {};
|
|
7467
8630
|
const previousBaseUrl = typeof env.ANTHROPIC_BASE_URL === "string" ? env.ANTHROPIC_BASE_URL : void 0;
|
|
7468
8631
|
const hadPrimaryModelOverride = hasPrimaryModelOverride(parsed, env);
|
|
7469
8632
|
const contextWindow = input.gptLimits?.max_context_window_tokens;
|
|
@@ -7472,9 +8635,11 @@ async function applyClaudeConfig(input) {
|
|
|
7472
8635
|
const nextModel = typeof parsed.model === "string" ? normalizeClaudeModelId(parsed.model, contextWindow) : void 0;
|
|
7473
8636
|
for (const key of knownModelEnvKeys) if (typeof env[key] === "string") env[key] = normalizeClaudeModelId(env[key], contextWindow);
|
|
7474
8637
|
env.ANTHROPIC_BASE_URL = baseUrl;
|
|
7475
|
-
if (
|
|
8638
|
+
if (input.apiKey) env.ANTHROPIC_AUTH_TOKEN = input.apiKey;
|
|
8639
|
+
else if (typeof env.ANTHROPIC_AUTH_TOKEN !== "string" || !env.ANTHROPIC_AUTH_TOKEN) env.ANTHROPIC_AUTH_TOKEN = "dummy";
|
|
7476
8640
|
if (contextWindow !== void 0 && env.CLAUDE_CODE_MAX_CONTEXT_TOKENS === void 0) env.CLAUDE_CODE_MAX_CONTEXT_TOKENS = String(contextWindow);
|
|
7477
8641
|
if (input.maxOutputTokens !== void 0 && env.CLAUDE_CODE_MAX_OUTPUT_TOKENS === void 0) env.CLAUDE_CODE_MAX_OUTPUT_TOKENS = String(input.maxOutputTokens);
|
|
8642
|
+
if (env.CLAUDE_CODE_AUTO_MODE_SERVER === void 0) env.CLAUDE_CODE_AUTO_MODE_SERVER = "0";
|
|
7478
8643
|
const next = {
|
|
7479
8644
|
...parsed,
|
|
7480
8645
|
...nextModel !== void 0 && { model: nextModel },
|
|
@@ -7482,13 +8647,15 @@ async function applyClaudeConfig(input) {
|
|
|
7482
8647
|
env
|
|
7483
8648
|
};
|
|
7484
8649
|
const serialized = `${JSON.stringify(next, null, 2)}\n`;
|
|
7485
|
-
|
|
8650
|
+
const writesKey = Boolean(input.apiKey);
|
|
8651
|
+
const keyExposed = writesKey && process.platform !== "win32" && (snapshot.mode & 63) !== 0;
|
|
8652
|
+
if (serialized === raw && !keyExposed) return {
|
|
7486
8653
|
configPath: configPath$1,
|
|
7487
8654
|
changed: false,
|
|
7488
8655
|
created: false,
|
|
7489
8656
|
previousBaseUrl
|
|
7490
8657
|
};
|
|
7491
|
-
await writeFileSnapshot(snapshot, serialized);
|
|
8658
|
+
await writeFileSnapshot(snapshot, serialized, { ownerOnly: writesKey });
|
|
7492
8659
|
return {
|
|
7493
8660
|
configPath: configPath$1,
|
|
7494
8661
|
changed: true,
|
|
@@ -7559,6 +8726,34 @@ const validateUpstream = async (config, thinkEffort) => {
|
|
|
7559
8726
|
//#endregion
|
|
7560
8727
|
//#region src/start.ts
|
|
7561
8728
|
const canCloseConnections = (server) => typeof server.closeIdleConnections === "function" && typeof server.closeAllConnections === "function";
|
|
8729
|
+
/**
|
|
8730
|
+
* The startup warning for a listener that other machines may reach without a key.
|
|
8731
|
+
*
|
|
8732
|
+
* A warning rather than a refusal, so an existing `host: 0.0.0.0` setup keeps
|
|
8733
|
+
* working after an upgrade. It names the apiKey setting and never prints a key.
|
|
8734
|
+
*/
|
|
8735
|
+
const unauthenticatedListenerWarning = (config) => {
|
|
8736
|
+
if (config.apiKey || isLoopbackHost(config.host)) return;
|
|
8737
|
+
return terminalText(`apiKey is empty and host ${config.host} is not a loopback address: any client that can reach port ${config.port} can consume your Copilot usage. Set apiKey in ${paths.configPath}, or bind host to 127.0.0.1.`);
|
|
8738
|
+
};
|
|
8739
|
+
/**
|
|
8740
|
+
* The warning for a config.yaml that other local users can access while it holds the apiKey.
|
|
8741
|
+
*
|
|
8742
|
+
* A new config.yaml is created 0600, but an existing one keeps its mode, and the relay never
|
|
8743
|
+
* changes the mode of the user's own file. stat follows a symlink, so the mode checked is the
|
|
8744
|
+
* target's. Windows has no POSIX mode bits. It never prints a key.
|
|
8745
|
+
*/
|
|
8746
|
+
const exposedConfigWarning = (config, configPath$1 = paths.configPath) => {
|
|
8747
|
+
if (!config.apiKey || process.platform === "win32") return;
|
|
8748
|
+
let mode;
|
|
8749
|
+
try {
|
|
8750
|
+
mode = statSync(configPath$1).mode & 511;
|
|
8751
|
+
} catch {
|
|
8752
|
+
return;
|
|
8753
|
+
}
|
|
8754
|
+
if ((mode & 63) === 0) return;
|
|
8755
|
+
return terminalText(`${configPath$1} holds the apiKey and has mode ${mode.toString(8).padStart(4, "0")}, which lets other local users access it. Run chmod 600 ${configPath$1}.`);
|
|
8756
|
+
};
|
|
7562
8757
|
async function startRelay(appConfig) {
|
|
7563
8758
|
appConfig ??= await readAppConfig();
|
|
7564
8759
|
appConfig = {
|
|
@@ -7568,10 +8763,12 @@ async function startRelay(appConfig) {
|
|
|
7568
8763
|
setLogLevel(appConfig.logLevel);
|
|
7569
8764
|
await cleanupLogs(appConfig.logRetentionDays);
|
|
7570
8765
|
await cleanupCaptures(appConfig.logRetentionDays);
|
|
8766
|
+
configureUpstreamDispatcher(appConfig.upstreamProxy);
|
|
7571
8767
|
const claudeConfigPath$1 = claudeConfigPath;
|
|
7572
8768
|
const config = readProxyConfig(appConfig);
|
|
7573
8769
|
const applyRuntimeConfig = (nextConfig) => {
|
|
7574
8770
|
registerSensitiveOrigin(nextConfig.copilotBaseUrl);
|
|
8771
|
+
registerLogSecret(nextConfig.apiKey);
|
|
7575
8772
|
setLogLevel(nextConfig.logLevel);
|
|
7576
8773
|
cleanupLogs(nextConfig.logRetentionDays).catch(() => log.error("Log retention failed; existing logs retained."));
|
|
7577
8774
|
cleanupCaptures(nextConfig.logRetentionDays);
|
|
@@ -7585,6 +8782,7 @@ async function startRelay(appConfig) {
|
|
|
7585
8782
|
config.upstreamTimeoutMs = nextConfig.upstreamTimeoutSeconds * 1e3;
|
|
7586
8783
|
config.webSearchBackend = nextConfig.webSearchBackend;
|
|
7587
8784
|
config.claudeUpstreamApi = nextConfig.claudeUpstreamApi;
|
|
8785
|
+
config.apiKey = nextConfig.apiKey;
|
|
7588
8786
|
runtimeState.modelRouting = {
|
|
7589
8787
|
gptModel: nextConfig.gptModel,
|
|
7590
8788
|
opusModel: nextConfig.opusModel
|
|
@@ -7594,6 +8792,11 @@ async function startRelay(appConfig) {
|
|
|
7594
8792
|
log.info(`Log level: ${appConfig.logLevel}`);
|
|
7595
8793
|
log.info(`Default think effort: ${appConfig.thinkEffort}`);
|
|
7596
8794
|
log.info(`Upstream timeout: ${appConfig.upstreamTimeoutSeconds}s`);
|
|
8795
|
+
if (appConfig.upstreamProxy !== void 0) log.info(`Upstream proxy: ${formatUpstreamProxyForDisplay(appConfig.upstreamProxy)}`);
|
|
8796
|
+
const listenerWarning = unauthenticatedListenerWarning(appConfig);
|
|
8797
|
+
if (listenerWarning) log.error(listenerWarning);
|
|
8798
|
+
const configWarning = exposedConfigWarning(appConfig);
|
|
8799
|
+
if (configWarning) log.error(configWarning);
|
|
7597
8800
|
const authSession = await setupProxyAuth(config);
|
|
7598
8801
|
try {
|
|
7599
8802
|
await validateUpstream(config, appConfig.thinkEffort);
|
|
@@ -7620,6 +8823,7 @@ async function startRelay(appConfig) {
|
|
|
7620
8823
|
const gptLimits = getCachedCopilotModel(config, appConfig.gptModel)?.limits;
|
|
7621
8824
|
const opusLimits = getCachedCopilotModel(config, appConfig.opusModel)?.limits;
|
|
7622
8825
|
const claudeResult = await applyClaudeConfig({
|
|
8826
|
+
apiKey: appConfig.apiKey,
|
|
7623
8827
|
baseUrl,
|
|
7624
8828
|
configPath: claudeConfigPath$1,
|
|
7625
8829
|
gptModel: appConfig.gptModel,
|
|
@@ -7634,8 +8838,11 @@ async function startRelay(appConfig) {
|
|
|
7634
8838
|
log.error(`Could not update claude settings (${claudeConfigPath$1}):`, error);
|
|
7635
8839
|
}
|
|
7636
8840
|
const configWatcher = watchAppConfig((nextConfig) => {
|
|
8841
|
+
const previousApiKey = config.apiKey;
|
|
7637
8842
|
applyRuntimeConfig(nextConfig);
|
|
7638
8843
|
log.info(`Config reloaded: logLevel=${nextConfig.logLevel} thinkEffort=${nextConfig.thinkEffort} upstreamTimeoutSeconds=${nextConfig.upstreamTimeoutSeconds}`);
|
|
8844
|
+
const reloadedConfigWarning = nextConfig.apiKey === previousApiKey ? void 0 : exposedConfigWarning(nextConfig);
|
|
8845
|
+
if (reloadedConfigWarning) log.error(reloadedConfigWarning);
|
|
7639
8846
|
});
|
|
7640
8847
|
const shutdownGraceMs = 2e3;
|
|
7641
8848
|
const shutdown = (signal) => {
|
|
@@ -7707,7 +8914,7 @@ const restart = defineCommand({
|
|
|
7707
8914
|
* for different responses - restart the service versus re-authenticate.
|
|
7708
8915
|
*
|
|
7709
8916
|
* A failed health probe is a 2, not a 0. Printing FAILED while exiting 0 would
|
|
7710
|
-
* make every scripted caller treat an unreachable relay as fine.
|
|
8917
|
+
* make every scripted caller treat an unreachable relay as fine.
|
|
7711
8918
|
*/
|
|
7712
8919
|
const exitCodes = {
|
|
7713
8920
|
notRunning: 1,
|
|
@@ -7718,7 +8925,9 @@ const localProbeTimeoutMs = 5e3;
|
|
|
7718
8925
|
const deepProbeTimeoutMs = 6e4;
|
|
7719
8926
|
const toStatusConfig = (config) => ({
|
|
7720
8927
|
...config,
|
|
8928
|
+
apiKey: config.apiKey ? "[redacted]" : "",
|
|
7721
8929
|
copilotBaseUrl: formatUrlForDisplay(config.copilotBaseUrl),
|
|
8930
|
+
upstreamProxy: config.upstreamProxy === void 0 ? null : formatUpstreamProxyForDisplay(config.upstreamProxy),
|
|
7722
8931
|
webSearchBackend: config.webSearchBackend ?? null,
|
|
7723
8932
|
claudeUpstreamApi: config.claudeUpstreamApi ?? "chat-completions"
|
|
7724
8933
|
});
|
|
@@ -7737,9 +8946,9 @@ const hasVersionMismatch = (status$1) => status$1.running && status$1.daemonVers
|
|
|
7737
8946
|
*
|
|
7738
8947
|
* 0 requires a live process *and* a passing health probe. Printing FAILED while
|
|
7739
8948
|
* exiting 0 would make every scripted caller treat an unreachable relay as
|
|
7740
|
-
* fine.
|
|
8949
|
+
* fine.
|
|
7741
8950
|
*
|
|
7742
|
-
* A version mismatch stays 0 on purpose
|
|
8951
|
+
* A version mismatch stays 0 on purpose. The relay works; it is just not
|
|
7743
8952
|
* the build that was installed. Mapping it to 2 would overstate it and break
|
|
7744
8953
|
* every scripted caller that treats non-zero as broken. It is surfaced in the
|
|
7745
8954
|
* text instead.
|
|
@@ -7772,27 +8981,35 @@ const formatUptime = (startedAt) => {
|
|
|
7772
8981
|
const configRowOrder = {
|
|
7773
8982
|
host: 0,
|
|
7774
8983
|
port: 1,
|
|
7775
|
-
|
|
7776
|
-
|
|
7777
|
-
|
|
7778
|
-
|
|
7779
|
-
|
|
7780
|
-
|
|
7781
|
-
|
|
7782
|
-
|
|
7783
|
-
|
|
7784
|
-
|
|
8984
|
+
apiKey: 2,
|
|
8985
|
+
copilotBaseUrl: 3,
|
|
8986
|
+
upstreamProxy: 4,
|
|
8987
|
+
claudeSetup: 5,
|
|
8988
|
+
logLevel: 6,
|
|
8989
|
+
logRetentionDays: 7,
|
|
8990
|
+
thinkEffort: 8,
|
|
8991
|
+
upstreamTimeoutSeconds: 9,
|
|
8992
|
+
webSearchBackend: 10,
|
|
8993
|
+
gptModel: 11,
|
|
8994
|
+
opusModel: 12,
|
|
8995
|
+
claudeUpstreamApi: 13
|
|
7785
8996
|
};
|
|
7786
8997
|
const configRowKeys = Object.keys(configRowOrder).sort((a, b) => configRowOrder[a] - configRowOrder[b]);
|
|
8998
|
+
const unsetConfigText = {
|
|
8999
|
+
apiKey: "(unset — clients need no key)",
|
|
9000
|
+
upstreamProxy: "(unset — connects directly)",
|
|
9001
|
+
webSearchBackend: "(unset — uses gptModel)"
|
|
9002
|
+
};
|
|
7787
9003
|
/**
|
|
7788
9004
|
* The resolved config, one key per line.
|
|
7789
9005
|
*
|
|
7790
9006
|
* These are the values on disk. That is not the same question as "what is the
|
|
7791
9007
|
* running daemon honouring", and the footnote says so rather than leaving it
|
|
7792
|
-
* implied: applyRuntimeConfig() in start.ts never reads claudeSetup and
|
|
7793
|
-
* deliberately
|
|
7794
|
-
*
|
|
7795
|
-
*
|
|
9008
|
+
* implied: applyRuntimeConfig() in start.ts never reads claudeSetup, and
|
|
9009
|
+
* deliberately neither rebinds the listening socket when host or port changes
|
|
9010
|
+
* nor rebuilds the upstream dispatcher when upstreamProxy changes; it
|
|
9011
|
+
* hot-reloads the rest. Printing every key without that line would imply a
|
|
9012
|
+
* live daemon had read values it has not.
|
|
7796
9013
|
*
|
|
7797
9014
|
* Only shown when running, because with nothing up every value applies at the
|
|
7798
9015
|
* next start and the note would be noise.
|
|
@@ -7800,10 +9017,10 @@ const configRowKeys = Object.keys(configRowOrder).sort((a, b) => configRowOrder[
|
|
|
7800
9017
|
const renderConfig = (config, running) => {
|
|
7801
9018
|
const lines = configRowKeys.map((key) => {
|
|
7802
9019
|
const value = config[key];
|
|
7803
|
-
const rendered =
|
|
9020
|
+
const rendered = (value === null || value === "" ? unsetConfigText[key] : void 0) ?? String(value);
|
|
7804
9021
|
return ` ${key.padEnd(24)}${sanitizeTerminalString(rendered)}`;
|
|
7805
9022
|
});
|
|
7806
|
-
if (running) lines.push(" host, port and
|
|
9023
|
+
if (running) lines.push(" host, port, claudeSetup and upstreamProxy take effect on restart; the rest hot-reload.");
|
|
7807
9024
|
return lines;
|
|
7808
9025
|
};
|
|
7809
9026
|
/**
|
|
@@ -7831,9 +9048,26 @@ const renderStatus = (status$1, color = false) => {
|
|
|
7831
9048
|
if (hasVersionMismatch(status$1)) lines.push("", ` The running relay is ${status$1.daemonVersion}; ${status$1.version} is installed.`, " Restart it to serve the installed version: copilot-relay restart");
|
|
7832
9049
|
return lines;
|
|
7833
9050
|
};
|
|
9051
|
+
let relayAgent;
|
|
9052
|
+
/**
|
|
9053
|
+
* How the probes reach the relay's own listener: undici's fetch with a direct Agent of their own,
|
|
9054
|
+
* never the global dispatcher and never the upstream one. With NODE_USE_ENV_PROXY=1, Node's fetch
|
|
9055
|
+
* sends even a request to localhost through HTTP_PROXY unless NO_PROXY exempts it, so through a
|
|
9056
|
+
* proxy that refuses, status would report a healthy relay as unusable. upstreamProxy is for
|
|
9057
|
+
* Copilot and GitHub, not for this machine.
|
|
9058
|
+
*
|
|
9059
|
+
* An object, so a test can replace fetch.
|
|
9060
|
+
*/
|
|
9061
|
+
const relayListener = { fetch: (url, init) => {
|
|
9062
|
+
relayAgent ??= new Agent();
|
|
9063
|
+
return fetch(url, {
|
|
9064
|
+
...init,
|
|
9065
|
+
dispatcher: relayAgent
|
|
9066
|
+
});
|
|
9067
|
+
} };
|
|
7834
9068
|
const probe = async (url, init, timeoutMs) => {
|
|
7835
9069
|
const started = performance.now();
|
|
7836
|
-
const response = await fetch(url, {
|
|
9070
|
+
const response = await relayListener.fetch(url, {
|
|
7837
9071
|
...init,
|
|
7838
9072
|
signal: AbortSignal.timeout(timeoutMs)
|
|
7839
9073
|
});
|
|
@@ -7854,7 +9088,7 @@ const describeError = (error) => {
|
|
|
7854
9088
|
* The version rides along on a probe `status` already makes, so asking what is
|
|
7855
9089
|
* running costs nothing extra. Absent for a daemon older than v0.3.1, which is
|
|
7856
9090
|
* itself informative, so it stays optional rather than defaulting to the
|
|
7857
|
-
* caller's version - that substitution is the bug.
|
|
9091
|
+
* caller's version - that substitution is the bug.
|
|
7858
9092
|
*/
|
|
7859
9093
|
const checkHealth = async (baseUrl) => {
|
|
7860
9094
|
try {
|
|
@@ -7878,9 +9112,10 @@ const checkHealth = async (baseUrl) => {
|
|
|
7878
9112
|
} };
|
|
7879
9113
|
}
|
|
7880
9114
|
};
|
|
7881
|
-
const
|
|
9115
|
+
const relayKeyHeaders = (apiKey) => apiKey ? { "x-api-key": apiKey } : {};
|
|
9116
|
+
const readModels = async (baseUrl, apiKey) => {
|
|
7882
9117
|
try {
|
|
7883
|
-
const data = (await probe(`${baseUrl}/v1/models`, {}, localProbeTimeoutMs)).body?.data;
|
|
9118
|
+
const data = (await probe(`${baseUrl}/v1/models`, { headers: relayKeyHeaders(apiKey) }, localProbeTimeoutMs)).body?.data;
|
|
7884
9119
|
return Array.isArray(data) ? data.flatMap((model) => typeof model.id === "string" ? [model.id] : []) : [];
|
|
7885
9120
|
} catch {
|
|
7886
9121
|
return [];
|
|
@@ -7892,20 +9127,21 @@ const readModels = async (baseUrl) => {
|
|
|
7892
9127
|
* Opt-in because it spends tokens. It is the only check that exercises token
|
|
7893
9128
|
* refresh, the upstream call, and translation back to Claude shape.
|
|
7894
9129
|
*/
|
|
7895
|
-
const checkDeep = async (baseUrl, model) => {
|
|
9130
|
+
const checkDeep = async (baseUrl, model, apiKey) => {
|
|
7896
9131
|
try {
|
|
7897
9132
|
const result = await probe(`${baseUrl}/v1/messages`, {
|
|
7898
9133
|
body: JSON.stringify({
|
|
7899
|
-
max_tokens:
|
|
9134
|
+
max_tokens: statusProbeMaxTokens,
|
|
7900
9135
|
messages: [{
|
|
7901
|
-
content:
|
|
9136
|
+
content: statusProbePrompt,
|
|
7902
9137
|
role: "user"
|
|
7903
9138
|
}],
|
|
7904
9139
|
model
|
|
7905
9140
|
}),
|
|
7906
9141
|
headers: {
|
|
7907
9142
|
"anthropic-version": "2023-06-01",
|
|
7908
|
-
"content-type": "application/json"
|
|
9143
|
+
"content-type": "application/json",
|
|
9144
|
+
...relayKeyHeaders(apiKey)
|
|
7909
9145
|
},
|
|
7910
9146
|
method: "POST"
|
|
7911
9147
|
}, deepProbeTimeoutMs);
|
|
@@ -7948,6 +9184,7 @@ const collectStatus = async (options) => {
|
|
|
7948
9184
|
});
|
|
7949
9185
|
setLogLevel(appConfig.logLevel);
|
|
7950
9186
|
registerSensitiveOrigin(appConfig.copilotBaseUrl);
|
|
9187
|
+
registerLogSecret(appConfig.apiKey);
|
|
7951
9188
|
const base = {
|
|
7952
9189
|
config: toStatusConfig(appConfig),
|
|
7953
9190
|
configPath: paths.configPath,
|
|
@@ -7963,8 +9200,8 @@ const collectStatus = async (options) => {
|
|
|
7963
9200
|
};
|
|
7964
9201
|
const baseUrl = getRelayBaseUrl(relay.host, relay.port);
|
|
7965
9202
|
const health = await checkHealth(baseUrl);
|
|
7966
|
-
const models$1 = health.result.ok ? await readModels(baseUrl) : [];
|
|
7967
|
-
const deep = options.deep && health.result.ok ? await checkDeep(baseUrl, models$1[0] ?? appConfig.gptModel) : void 0;
|
|
9203
|
+
const models$1 = health.result.ok ? await readModels(baseUrl, appConfig.apiKey) : [];
|
|
9204
|
+
const deep = options.deep && health.result.ok ? await checkDeep(baseUrl, models$1[0] ?? appConfig.gptModel, appConfig.apiKey) : void 0;
|
|
7968
9205
|
const daemonVersion = health.version ?? relay.version;
|
|
7969
9206
|
return {
|
|
7970
9207
|
...base,
|
|
@@ -8009,8 +9246,8 @@ const status = defineCommand({
|
|
|
8009
9246
|
process.exitCode = exitCodes.notUsable;
|
|
8010
9247
|
return;
|
|
8011
9248
|
}
|
|
8012
|
-
if (args.json) console.log(scrubSensitiveUrls(JSON.stringify(result, null, 2)));
|
|
8013
|
-
else console.log(scrubSensitiveUrls(renderStatus(result, colorEnabled()).join("\n")));
|
|
9249
|
+
if (args.json) console.log(scrubLogSecrets(scrubSensitiveUrls(withoutHiddenCharacters(JSON.stringify(result, null, 2)))));
|
|
9250
|
+
else console.log(scrubLogSecrets(scrubSensitiveUrls(withoutHiddenCharacters(renderStatus(result, colorEnabled()).join("\n")))));
|
|
8014
9251
|
process.exitCode = resolveExitCode(result);
|
|
8015
9252
|
}
|
|
8016
9253
|
});
|
|
@@ -8037,6 +9274,225 @@ const stop = defineCommand({
|
|
|
8037
9274
|
}
|
|
8038
9275
|
});
|
|
8039
9276
|
|
|
9277
|
+
//#endregion
|
|
9278
|
+
//#region src/lib/usage.ts
|
|
9279
|
+
const knownQuotaIds = [
|
|
9280
|
+
"chat",
|
|
9281
|
+
"completions",
|
|
9282
|
+
"premium_interactions"
|
|
9283
|
+
];
|
|
9284
|
+
/** A failure the command reports. The message is one printable line and never holds the token. */
|
|
9285
|
+
var CopilotUsageError = class extends Error {};
|
|
9286
|
+
const notAnObject = "GitHub's answer to the usage request was not a JSON object.";
|
|
9287
|
+
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
9288
|
+
const stringField = (value) => typeof value === "string" ? terminalText(value) : null;
|
|
9289
|
+
const numberField = (value) => typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
9290
|
+
const booleanField = (value) => typeof value === "boolean" ? value : null;
|
|
9291
|
+
const parseSnapshot = (value) => {
|
|
9292
|
+
if (!isRecord(value)) return null;
|
|
9293
|
+
return {
|
|
9294
|
+
unlimited: booleanField(value.unlimited),
|
|
9295
|
+
entitlement: numberField(value.entitlement),
|
|
9296
|
+
remaining: numberField(value.remaining),
|
|
9297
|
+
percent_remaining: numberField(value.percent_remaining),
|
|
9298
|
+
overage_permitted: booleanField(value.overage_permitted),
|
|
9299
|
+
overage_count: numberField(value.overage_count),
|
|
9300
|
+
token_based_billing: booleanField(value.token_based_billing),
|
|
9301
|
+
credits_used: numberField(value.credits_used)
|
|
9302
|
+
};
|
|
9303
|
+
};
|
|
9304
|
+
/**
|
|
9305
|
+
* The plan and quota fields of a copilot_internal/user answer, each checked on its own.
|
|
9306
|
+
*
|
|
9307
|
+
* Throws CopilotUsageError when the answer is not a JSON object at all.
|
|
9308
|
+
*/
|
|
9309
|
+
const parseCopilotUsage = (body) => {
|
|
9310
|
+
if (!isRecord(body)) throw new CopilotUsageError(notAnObject);
|
|
9311
|
+
const quota = body.quota_snapshots;
|
|
9312
|
+
const reported = isRecord(quota) ? quota : {};
|
|
9313
|
+
const snapshots = /* @__PURE__ */ new Map();
|
|
9314
|
+
for (const id of knownQuotaIds) snapshots.set(id, parseSnapshot(reported[id]));
|
|
9315
|
+
for (const [rawId, value] of Object.entries(reported)) {
|
|
9316
|
+
const id = terminalText(rawId);
|
|
9317
|
+
if (id === "" || snapshots.has(id)) continue;
|
|
9318
|
+
snapshots.set(id, parseSnapshot(value));
|
|
9319
|
+
}
|
|
9320
|
+
return {
|
|
9321
|
+
copilot_plan: stringField(body.copilot_plan),
|
|
9322
|
+
access_type_sku: stringField(body.access_type_sku),
|
|
9323
|
+
quota_reset_date: stringField(body.quota_reset_date),
|
|
9324
|
+
quota_snapshots: Object.fromEntries(snapshots)
|
|
9325
|
+
};
|
|
9326
|
+
};
|
|
9327
|
+
const notReported = "not reported";
|
|
9328
|
+
const formatNumber = (value) => value === null ? "?" : String(value);
|
|
9329
|
+
const formatAllowance = (snapshot) => {
|
|
9330
|
+
if (snapshot.unlimited === true) return "unlimited";
|
|
9331
|
+
return `${`${formatNumber(snapshot.remaining)} of ${formatNumber(snapshot.entitlement)} remaining`} (${formatNumber(snapshot.percent_remaining)}%)`;
|
|
9332
|
+
};
|
|
9333
|
+
const formatOverage = (snapshot) => {
|
|
9334
|
+
let permitted = "overage ?";
|
|
9335
|
+
if (snapshot.overage_permitted === true) permitted = "overage permitted";
|
|
9336
|
+
else if (snapshot.overage_permitted === false) permitted = "overage not permitted";
|
|
9337
|
+
return `${permitted}, overage count ${formatNumber(snapshot.overage_count)}`;
|
|
9338
|
+
};
|
|
9339
|
+
const formatQuota = (snapshot) => {
|
|
9340
|
+
const parts = [formatAllowance(snapshot), formatOverage(snapshot)];
|
|
9341
|
+
if (snapshot.credits_used !== null) parts.push(`credits used ${formatNumber(snapshot.credits_used)}`);
|
|
9342
|
+
return parts.join("; ");
|
|
9343
|
+
};
|
|
9344
|
+
/** The report as text lines. Kept free of IO, so it is tested without a terminal. */
|
|
9345
|
+
const renderCopilotUsage = (usage$1, color = false) => {
|
|
9346
|
+
const shown = (value) => value ?? colorText(notReported, "muted", color);
|
|
9347
|
+
const headings = [
|
|
9348
|
+
["Plan", usage$1.copilot_plan],
|
|
9349
|
+
["SKU", usage$1.access_type_sku],
|
|
9350
|
+
["Quota reset", usage$1.quota_reset_date]
|
|
9351
|
+
];
|
|
9352
|
+
const labelWidth = Math.max(...headings.map(([label]) => label.length));
|
|
9353
|
+
const known = [...knownQuotaIds];
|
|
9354
|
+
const ids = [...known, ...Object.keys(usage$1.quota_snapshots).filter((id) => !known.includes(id))];
|
|
9355
|
+
const idWidth = Math.max(...ids.map((id) => id.length));
|
|
9356
|
+
const quotaLines = ids.map((id) => {
|
|
9357
|
+
const snapshot = usage$1.quota_snapshots[id] ?? null;
|
|
9358
|
+
const text$1 = snapshot === null ? colorText(notReported, "muted", color) : formatQuota(snapshot);
|
|
9359
|
+
return `${id.padEnd(idWidth)} ${text$1}`;
|
|
9360
|
+
});
|
|
9361
|
+
return [
|
|
9362
|
+
...headings.map(([label, value]) => `${label.padEnd(labelWidth)} ${shown(value)}`),
|
|
9363
|
+
"",
|
|
9364
|
+
...quotaLines
|
|
9365
|
+
];
|
|
9366
|
+
};
|
|
9367
|
+
const requestTimeoutSeconds = 30;
|
|
9368
|
+
const tokenInAnswer = "GitHub's answer to the usage request contains the stored token, so none of it is printed.";
|
|
9369
|
+
const visibleToken = (token) => terminalText(token);
|
|
9370
|
+
const withoutToken = (line, token) => {
|
|
9371
|
+
const visible = visibleToken(token);
|
|
9372
|
+
return visible === "" ? line : line.replaceAll(visible, "[redacted]");
|
|
9373
|
+
};
|
|
9374
|
+
const lettersAndDigits = (text$1) => text$1.replace(/[^A-Za-z0-9]/g, "");
|
|
9375
|
+
const minimumSpelledLength = 16;
|
|
9376
|
+
const spellsToken = (text$1, token) => {
|
|
9377
|
+
const spelled = lettersAndDigits(visibleToken(token));
|
|
9378
|
+
return spelled.length >= minimumSpelledLength && lettersAndDigits(text$1).includes(spelled);
|
|
9379
|
+
};
|
|
9380
|
+
const showsToken = (usage$1, token) => {
|
|
9381
|
+
const visible = visibleToken(token);
|
|
9382
|
+
const printed = [
|
|
9383
|
+
usage$1.copilot_plan,
|
|
9384
|
+
usage$1.access_type_sku,
|
|
9385
|
+
usage$1.quota_reset_date,
|
|
9386
|
+
...Object.keys(usage$1.quota_snapshots)
|
|
9387
|
+
].filter((text$1) => text$1 !== null);
|
|
9388
|
+
if (visible !== "" && printed.some((text$1) => text$1.includes(visible))) return true;
|
|
9389
|
+
return spellsToken(printed.join(""), token);
|
|
9390
|
+
};
|
|
9391
|
+
const failureReason = (error) => {
|
|
9392
|
+
const cause = error instanceof Error ? error.cause : void 0;
|
|
9393
|
+
if (cause instanceof Error && cause.message !== "") return cause.message;
|
|
9394
|
+
const code = cause?.code;
|
|
9395
|
+
if (typeof code === "string") return code;
|
|
9396
|
+
return error instanceof Error && error.message !== "" ? error.message : String(error);
|
|
9397
|
+
};
|
|
9398
|
+
const isTimeout = (error) => typeof error === "object" && error !== null && "name" in error && error.name === "TimeoutError";
|
|
9399
|
+
const describeRequestFailure = async (error, token) => {
|
|
9400
|
+
if (error instanceof HTTPError) {
|
|
9401
|
+
await error.response.body?.cancel().catch(() => {});
|
|
9402
|
+
const status$1 = error.response.status;
|
|
9403
|
+
if (status$1 === 401 || status$1 === 403) return new CopilotUsageError(`GitHub rejected the stored token (HTTP ${status$1}). Sign in again with copilot-relay auth.`);
|
|
9404
|
+
return new CopilotUsageError(`GitHub answered the usage request with HTTP ${status$1}.`);
|
|
9405
|
+
}
|
|
9406
|
+
if (isTimeout(error)) return new CopilotUsageError(`GitHub did not answer within ${requestTimeoutSeconds} seconds.`);
|
|
9407
|
+
if (error instanceof SyntaxError) return new CopilotUsageError(notAnObject);
|
|
9408
|
+
const line = withoutToken(terminalText(`Could not reach GitHub: ${failureReason(error)}`), token);
|
|
9409
|
+
return new CopilotUsageError(spellsToken(line, token) ? "Could not reach GitHub." : line);
|
|
9410
|
+
};
|
|
9411
|
+
const readToken = async () => {
|
|
9412
|
+
let token;
|
|
9413
|
+
try {
|
|
9414
|
+
token = await readStoredGitHubToken();
|
|
9415
|
+
} catch (error) {
|
|
9416
|
+
const code = error?.code;
|
|
9417
|
+
const reason = typeof code === "string" ? `: ${code}` : "";
|
|
9418
|
+
throw new CopilotUsageError(terminalText(`Could not read the GitHub token at ${paths.githubTokenPath}${reason}.`));
|
|
9419
|
+
}
|
|
9420
|
+
if (token === void 0) throw new CopilotUsageError(terminalText(`No GitHub token is stored at ${paths.githubTokenPath}. Sign in with copilot-relay auth.`));
|
|
9421
|
+
return token;
|
|
9422
|
+
};
|
|
9423
|
+
/**
|
|
9424
|
+
* Builds this process's upstream dispatcher from upstreamProxy, as start, auth and models do, but
|
|
9425
|
+
* reads config.yaml without creating, completing or rewriting it. Without a config.yaml the
|
|
9426
|
+
* request connects directly.
|
|
9427
|
+
*
|
|
9428
|
+
* A config.yaml that cannot be read or is invalid gets one fixed line, whatever the reason, so no
|
|
9429
|
+
* text from that file can reach the terminal: it can hold credentials, such as an upstreamProxy
|
|
9430
|
+
* password.
|
|
9431
|
+
*/
|
|
9432
|
+
const applyUpstreamProxy = async () => {
|
|
9433
|
+
let upstreamProxy;
|
|
9434
|
+
try {
|
|
9435
|
+
upstreamProxy = (await readExistingAppConfig())?.upstreamProxy;
|
|
9436
|
+
} catch {
|
|
9437
|
+
throw new CopilotUsageError(terminalText(`Could not read the config at ${paths.configPath}; fix it, then run copilot-relay usage again.`));
|
|
9438
|
+
}
|
|
9439
|
+
try {
|
|
9440
|
+
configureUpstreamDispatcher(upstreamProxy);
|
|
9441
|
+
} catch (error) {
|
|
9442
|
+
if (error instanceof InvalidProxyEnvironmentError) throw new CopilotUsageError(error.message);
|
|
9443
|
+
throw error;
|
|
9444
|
+
}
|
|
9445
|
+
};
|
|
9446
|
+
const requestCopilotUsage = async () => {
|
|
9447
|
+
const token = await readToken();
|
|
9448
|
+
await applyUpstreamProxy();
|
|
9449
|
+
let body;
|
|
9450
|
+
try {
|
|
9451
|
+
body = await getCopilotUsage(token, vscodeVersion, AbortSignal.timeout(requestTimeoutSeconds * 1e3));
|
|
9452
|
+
} catch (error) {
|
|
9453
|
+
throw await describeRequestFailure(error, token);
|
|
9454
|
+
}
|
|
9455
|
+
const usage$1 = parseCopilotUsage(body);
|
|
9456
|
+
if (showsToken(usage$1, token)) throw new CopilotUsageError(tokenInAnswer);
|
|
9457
|
+
return usage$1;
|
|
9458
|
+
};
|
|
9459
|
+
/**
|
|
9460
|
+
* The Copilot plan and quota of the stored GitHub token's account.
|
|
9461
|
+
*
|
|
9462
|
+
* Reads the token file and config.yaml, and asks GitHub through upstreamProxy; writes nothing.
|
|
9463
|
+
* Logging is off throughout, so a line the upstream request would log, such as the hint about an
|
|
9464
|
+
* ignored proxy variable, never reaches the log file. Every failure becomes a CopilotUsageError
|
|
9465
|
+
* whose message is the line to print.
|
|
9466
|
+
*/
|
|
9467
|
+
const loadCopilotUsage = () => withoutLogging(requestCopilotUsage);
|
|
9468
|
+
|
|
9469
|
+
//#endregion
|
|
9470
|
+
//#region src/usage.ts
|
|
9471
|
+
const usage = defineCommand({
|
|
9472
|
+
meta: {
|
|
9473
|
+
name: "usage",
|
|
9474
|
+
description: "Show the Copilot plan and quota GitHub reports for the stored GitHub token."
|
|
9475
|
+
},
|
|
9476
|
+
args: { json: {
|
|
9477
|
+
description: "Print the plan and quota fields as a JSON object.",
|
|
9478
|
+
type: "boolean"
|
|
9479
|
+
} },
|
|
9480
|
+
async run({ args }) {
|
|
9481
|
+
try {
|
|
9482
|
+
const report = await loadCopilotUsage();
|
|
9483
|
+
if (args.json) console.log(JSON.stringify(report, null, 2));
|
|
9484
|
+
else console.log(renderCopilotUsage(report, colorEnabled()).join("\n"));
|
|
9485
|
+
} catch (error) {
|
|
9486
|
+
if (error instanceof CopilotUsageError) console.error(error.message);
|
|
9487
|
+
else {
|
|
9488
|
+
const name = error instanceof Error ? error.name : typeof error;
|
|
9489
|
+
console.error(terminalText(`Could not show the Copilot usage: unexpected ${name}.`));
|
|
9490
|
+
}
|
|
9491
|
+
process.exitCode = 1;
|
|
9492
|
+
}
|
|
9493
|
+
}
|
|
9494
|
+
});
|
|
9495
|
+
|
|
8040
9496
|
//#endregion
|
|
8041
9497
|
//#region src/main.ts
|
|
8042
9498
|
await runMain(defineCommand({
|
|
@@ -8046,12 +9502,14 @@ await runMain(defineCommand({
|
|
|
8046
9502
|
},
|
|
8047
9503
|
subCommands: {
|
|
8048
9504
|
auth,
|
|
9505
|
+
cache,
|
|
8049
9506
|
models,
|
|
8050
9507
|
replay,
|
|
8051
9508
|
restart,
|
|
8052
9509
|
start,
|
|
8053
9510
|
status,
|
|
8054
|
-
stop
|
|
9511
|
+
stop,
|
|
9512
|
+
usage
|
|
8055
9513
|
}
|
|
8056
9514
|
}));
|
|
8057
9515
|
|