copilot-relay 0.4.5 → 0.4.6
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 +1562 -198
- 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,6 +201,20 @@ 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
|
|
@@ -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);
|
|
@@ -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, as every release before #153 did. "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
|
*
|
|
@@ -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,499 @@ 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: "UNKNOWN",
|
|
2151
|
+
alignRight: true,
|
|
2152
|
+
cell: (row) => formatCount(row.unknownCacheRequests)
|
|
2153
|
+
},
|
|
2154
|
+
{
|
|
2155
|
+
header: "0-READ",
|
|
2156
|
+
alignRight: true,
|
|
2157
|
+
cell: (row) => formatCount(row.zeroCacheReadRequests)
|
|
2158
|
+
},
|
|
2159
|
+
{
|
|
2160
|
+
header: "INPUT",
|
|
2161
|
+
alignRight: true,
|
|
2162
|
+
cell: (row) => formatCount(row.totalInputTokens)
|
|
2163
|
+
},
|
|
2164
|
+
{
|
|
2165
|
+
header: "CACHE READ",
|
|
2166
|
+
alignRight: true,
|
|
2167
|
+
cell: (row) => formatCount(row.cacheReadTokens)
|
|
2168
|
+
},
|
|
2169
|
+
{
|
|
2170
|
+
header: "UNCACHED",
|
|
2171
|
+
alignRight: true,
|
|
2172
|
+
cell: (row) => formatCount(row.uncachedInputTokens)
|
|
2173
|
+
},
|
|
2174
|
+
{
|
|
2175
|
+
header: "CACHE WRITE",
|
|
2176
|
+
alignRight: true,
|
|
2177
|
+
cell: (row) => row.cacheWriteTokens === null ? "-" : formatCount(row.cacheWriteTokens)
|
|
2178
|
+
},
|
|
2179
|
+
{
|
|
2180
|
+
header: "HIT RATE",
|
|
2181
|
+
alignRight: true,
|
|
2182
|
+
cell: formatHitRate,
|
|
2183
|
+
tone: hitRateTone
|
|
2184
|
+
}
|
|
2185
|
+
];
|
|
2186
|
+
const renderTable = (rows, view, color) => {
|
|
2187
|
+
const columns = [...bucketColumns[view], ...dataColumns];
|
|
2188
|
+
const cells = rows.map((row) => columns.map((column) => column.cell(row)));
|
|
2189
|
+
const widths = columns.map((column, index) => Math.max(column.header.length, ...cells.map((rowCells) => rowCells[index].length)));
|
|
2190
|
+
const pad = (text$1, index) => columns[index].alignRight ? text$1.padStart(widths[index]) : text$1.padEnd(widths[index]);
|
|
2191
|
+
const header = columns.map((column, index) => pad(column.header, index)).join(" ");
|
|
2192
|
+
const body = rows.map((row, rowIndex) => {
|
|
2193
|
+
const rendered = columns.map((column, index) => {
|
|
2194
|
+
const text$1 = pad(cells[rowIndex][index], index);
|
|
2195
|
+
return column.tone === void 0 ? text$1 : colorText(text$1, column.tone(row), color);
|
|
2196
|
+
});
|
|
2197
|
+
const marker = row.belowGoal ? ` ${colorText("below goal", "bad", color)}` : "";
|
|
2198
|
+
return ` ${rendered.join(" ")}${marker}`;
|
|
2199
|
+
});
|
|
2200
|
+
return [` ${header}`, ...body];
|
|
2201
|
+
};
|
|
2202
|
+
const viewTitles = {
|
|
2203
|
+
summary: "Prompt-cache hit rate",
|
|
2204
|
+
hourly: "Prompt-cache hit rate by local hour",
|
|
2205
|
+
daily: "Prompt-cache hit rate by local day"
|
|
2206
|
+
};
|
|
2207
|
+
const formatLocalTime = (date) => `${formatLogDate(date)} ${twoDigits(date.getHours())}:${twoDigits(date.getMinutes())}`;
|
|
2208
|
+
const describeScope = (options) => {
|
|
2209
|
+
return `${options.since === void 0 ? "across all retained logs" : `since ${formatLocalTime(options.since)} local time`}${options.model === void 0 ? "" : `, for models matching "${terminalText(options.model)}"`}`;
|
|
2210
|
+
};
|
|
2211
|
+
const unknownNote = "UNKNOWN calls logged no cache_read_input_tokens; they are left out of the token columns and HIT RATE.";
|
|
2212
|
+
/** The report as text lines. Kept free of IO, so it is tested without a terminal. */
|
|
2213
|
+
const renderCacheReport = (rows, options, color = false) => {
|
|
2214
|
+
if (rows.length === 0) return [
|
|
2215
|
+
`No prompt-cache data ${describeScope(options)}.`,
|
|
2216
|
+
`The report reads the upstream completion entries in ${paths.logsDir}.`,
|
|
2217
|
+
"They are written at logLevel info or debug, not error, and kept for logRetentionDays."
|
|
2218
|
+
];
|
|
2219
|
+
const lines = [
|
|
2220
|
+
`${viewTitles[options.view]} ${describeScope(options)}, goal ${options.goal}%`,
|
|
2221
|
+
"",
|
|
2222
|
+
...renderTable(rows, options.view, color)
|
|
2223
|
+
];
|
|
2224
|
+
if (rows.some((row) => row.unknownCacheRequests > 0)) lines.push("", unknownNote);
|
|
2225
|
+
return lines;
|
|
2226
|
+
};
|
|
2227
|
+
|
|
2228
|
+
//#endregion
|
|
2229
|
+
//#region src/cache.ts
|
|
2230
|
+
const cache = defineCommand({
|
|
2231
|
+
meta: {
|
|
2232
|
+
name: "cache",
|
|
2233
|
+
description: "Show prompt-cache hit rates per model and upstream route, read from local logs."
|
|
2234
|
+
},
|
|
2235
|
+
args: {
|
|
2236
|
+
hourly: {
|
|
2237
|
+
description: "Show the trend by local hour. Covers the last 24 hours unless --since is given.",
|
|
2238
|
+
type: "boolean"
|
|
2239
|
+
},
|
|
2240
|
+
daily: {
|
|
2241
|
+
description: "Show the trend by local day. Covers every retained day unless --since is given.",
|
|
2242
|
+
type: "boolean"
|
|
2243
|
+
},
|
|
2244
|
+
since: {
|
|
2245
|
+
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.",
|
|
2246
|
+
type: "string"
|
|
2247
|
+
},
|
|
2248
|
+
model: {
|
|
2249
|
+
description: "Only count models whose name contains this text, ignoring case.",
|
|
2250
|
+
type: "string"
|
|
2251
|
+
},
|
|
2252
|
+
json: {
|
|
2253
|
+
description: "Print the rows as a JSON array.",
|
|
2254
|
+
type: "boolean"
|
|
2255
|
+
},
|
|
2256
|
+
goal: {
|
|
2257
|
+
default: String(defaultGoal),
|
|
2258
|
+
description: "Hit-rate goal in percent, with at most two decimals. Rows below it are flagged.",
|
|
2259
|
+
type: "string"
|
|
2260
|
+
}
|
|
2261
|
+
},
|
|
2262
|
+
async run({ args }) {
|
|
2263
|
+
try {
|
|
2264
|
+
const options = resolveCacheOptions(args, /* @__PURE__ */ new Date());
|
|
2265
|
+
const rows = await buildCacheReport(options);
|
|
2266
|
+
if (args.json) console.log(JSON.stringify(rows, null, 2));
|
|
2267
|
+
else console.log(renderCacheReport(rows, options, colorEnabled()).join("\n"));
|
|
2268
|
+
} catch (error) {
|
|
2269
|
+
if (error instanceof CacheUsageError) console.error(error.message);
|
|
2270
|
+
else {
|
|
2271
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
2272
|
+
console.error(terminalText(`Could not build the cache report from ${paths.logsDir}: ${detail}`));
|
|
2273
|
+
}
|
|
2274
|
+
process.exitCode = 1;
|
|
2275
|
+
}
|
|
2276
|
+
}
|
|
2277
|
+
});
|
|
2278
|
+
|
|
1385
2279
|
//#endregion
|
|
1386
2280
|
//#region src/lib/version.ts
|
|
1387
2281
|
const packageName = /^(?:@[a-z0-9][a-z0-9._-]*\/)?copilot-relay$/;
|
|
@@ -1550,13 +2444,13 @@ var OutcomeObserver = class {
|
|
|
1550
2444
|
const incomplete = this.metadataValue(value.incomplete_details?.reason);
|
|
1551
2445
|
if (incomplete) this.fields.incomplete_reason = ["max_output_tokens", "content_filter"].includes(incomplete) ? incomplete : "unknown";
|
|
1552
2446
|
if (payload.type === "error" || record$2(payload.error)) this.fields.error = "upstream_error";
|
|
1553
|
-
const usage = findUsage(payload, value);
|
|
1554
|
-
if (usage) {
|
|
2447
|
+
const usage$1 = findUsage(payload, value);
|
|
2448
|
+
if (usage$1) {
|
|
1555
2449
|
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]
|
|
2450
|
+
["input_tokens", usage$1.input_tokens ?? usage$1.prompt_tokens],
|
|
2451
|
+
["output_tokens", usage$1.output_tokens ?? usage$1.completion_tokens],
|
|
2452
|
+
["cache_read_input_tokens", usage$1.cache_read_input_tokens ?? usage$1.prompt_tokens_details?.cached_tokens ?? usage$1.input_tokens_details?.cached_tokens],
|
|
2453
|
+
["cache_creation_input_tokens", usage$1.cache_creation_input_tokens]
|
|
1560
2454
|
]) if (typeof input === "number" && Number.isSafeInteger(input) && input >= 0) this.fields[target] = input;
|
|
1561
2455
|
}
|
|
1562
2456
|
}
|
|
@@ -1772,6 +2666,7 @@ var RequestTrace = class RequestTrace {
|
|
|
1772
2666
|
constructor(requestId, request, config, runtime) {
|
|
1773
2667
|
this.requestId = requestId;
|
|
1774
2668
|
this.protectCredential(config.copilotToken);
|
|
2669
|
+
this.protectCredential(config.apiKey);
|
|
1775
2670
|
this.protectCredential(request.headers.get("authorization")?.replace(/^Bearer\s+/i, ""));
|
|
1776
2671
|
this.protectCredential(request.headers.get("x-api-key") ?? void 0);
|
|
1777
2672
|
this.signal = AbortSignal.any([request.signal, this.controller.signal]);
|
|
@@ -2234,11 +3129,6 @@ const userAgent = `GitHubCopilotChat/${copilotVersion}`;
|
|
|
2234
3129
|
const apiVersion = "2025-04-01";
|
|
2235
3130
|
const maxFetchAttempts = 2;
|
|
2236
3131
|
const copilotRequestTimeoutMs = 18e4;
|
|
2237
|
-
const copilotDispatcher = new Agent({
|
|
2238
|
-
allowH2: false,
|
|
2239
|
-
connect: { allowH2: false },
|
|
2240
|
-
keepAliveTimeout: 5e4
|
|
2241
|
-
});
|
|
2242
3132
|
const getCopilotProviderContext = (config) => ({
|
|
2243
3133
|
baseUrl: config.copilotBaseUrl,
|
|
2244
3134
|
get token() {
|
|
@@ -2278,6 +3168,33 @@ const toCopilotAbortHTTPError = (error, signal, timeoutMs = copilotRequestTimeou
|
|
|
2278
3168
|
headers: { "content-type": "application/json" }
|
|
2279
3169
|
}), message);
|
|
2280
3170
|
};
|
|
3171
|
+
const copilotPromptTooLongPattern = /^prompt token count of (\d+) exceeds the limit of (\d+)$/;
|
|
3172
|
+
const anthropicPromptTooLongPattern = /^prompt is too long: (\d+) tokens > (\d+) maximum$/;
|
|
3173
|
+
const parseJsonBody = (body) => {
|
|
3174
|
+
try {
|
|
3175
|
+
return JSON.parse(body);
|
|
3176
|
+
} catch {
|
|
3177
|
+
return;
|
|
3178
|
+
}
|
|
3179
|
+
};
|
|
3180
|
+
const isJsonObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
3181
|
+
const parseTokenCount = (digits) => {
|
|
3182
|
+
const count = Number(digits);
|
|
3183
|
+
return Number.isSafeInteger(count) ? count : void 0;
|
|
3184
|
+
};
|
|
3185
|
+
const toPromptTooLongError = (status$1, body, detail) => {
|
|
3186
|
+
if (status$1 !== 400) return;
|
|
3187
|
+
const payload = parseJsonBody(body);
|
|
3188
|
+
const upstreamError = isJsonObject(payload) ? payload.error : void 0;
|
|
3189
|
+
if (!isJsonObject(payload) || !isJsonObject(upstreamError)) return;
|
|
3190
|
+
const { code, message, type: errorType } = upstreamError;
|
|
3191
|
+
const upstreamMessage = typeof message === "string" ? message : "";
|
|
3192
|
+
const isCopilotOverflow = code === "model_max_prompt_tokens_exceeded";
|
|
3193
|
+
const isAnthropicOverflow = payload.type === "error" && errorType === "invalid_request_error" && upstreamMessage.startsWith("prompt is too long");
|
|
3194
|
+
if (!isCopilotOverflow && !isAnthropicOverflow) return;
|
|
3195
|
+
const counts = copilotPromptTooLongPattern.exec(upstreamMessage) ?? anthropicPromptTooLongPattern.exec(upstreamMessage);
|
|
3196
|
+
return new PromptTooLongError(parseTokenCount(counts?.[1]), parseTokenCount(counts?.[2]), detail ?? (upstreamMessage || void 0));
|
|
3197
|
+
};
|
|
2281
3198
|
const readCopilotJson = async (response, signal, timeoutMs = copilotRequestTimeoutMs) => {
|
|
2282
3199
|
try {
|
|
2283
3200
|
return await response.json();
|
|
@@ -2380,21 +3297,12 @@ const fetchCopilot = async (provider, path$1, init, options = {}) => {
|
|
|
2380
3297
|
headers,
|
|
2381
3298
|
upstreamRequestId,
|
|
2382
3299
|
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
|
-
});
|
|
3300
|
+
}, () => fetchUpstream(`${provider.baseUrl}${path$1}`, {
|
|
3301
|
+
body: init.body,
|
|
3302
|
+
headers,
|
|
3303
|
+
method: init.method,
|
|
3304
|
+
signal
|
|
3305
|
+
}));
|
|
2398
3306
|
const ms = Math.round(performance.now() - started);
|
|
2399
3307
|
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
3308
|
log.debug(`${formatRequestId(options.requestId)}Copilot ${init.method ?? "GET"} ${path$1} -> ${response.status} ${ms}ms (attempt ${attempt}) upstream_request_id=${upstreamRequestId}`);
|
|
@@ -2450,10 +3358,10 @@ const pinCopilotModelCatalog = (config) => {
|
|
|
2450
3358
|
pinnedCatalogs.set(config, config.modelCatalog);
|
|
2451
3359
|
};
|
|
2452
3360
|
const pendingCatalogs = /* @__PURE__ */ new WeakMap();
|
|
2453
|
-
const isRecord$
|
|
3361
|
+
const isRecord$4 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2454
3362
|
const isPositiveInteger = (value) => typeof value === "number" && Number.isSafeInteger(value) && value > 0;
|
|
2455
3363
|
function parseModelTokenLimits(value) {
|
|
2456
|
-
if (!isRecord$
|
|
3364
|
+
if (!isRecord$4(value)) return;
|
|
2457
3365
|
const context = value.max_context_window_tokens;
|
|
2458
3366
|
const prompt = value.max_prompt_tokens;
|
|
2459
3367
|
const output = value.max_output_tokens;
|
|
@@ -2509,13 +3417,13 @@ async function loadCopilotModelCatalog(config) {
|
|
|
2509
3417
|
});
|
|
2510
3418
|
if (!response.ok) throw new HTTPError("Failed to validate upstream models", response);
|
|
2511
3419
|
const payload = await readCopilotJson(response, signal, config.upstreamTimeoutMs);
|
|
2512
|
-
if (!isRecord$
|
|
3420
|
+
if (!isRecord$4(payload) || !Array.isArray(payload.data)) throw new Error("Copilot /models returned an invalid model catalog.");
|
|
2513
3421
|
const models$1 = /* @__PURE__ */ new Map();
|
|
2514
3422
|
for (const model of payload.data) {
|
|
2515
|
-
if (!isRecord$
|
|
2516
|
-
const capabilities = isRecord$
|
|
3423
|
+
if (!isRecord$4(model) || typeof model.id !== "string" || !model.id) continue;
|
|
3424
|
+
const capabilities = isRecord$4(model.capabilities) ? model.capabilities : void 0;
|
|
2517
3425
|
const limits = parseModelTokenLimits(capabilities?.limits);
|
|
2518
|
-
const supports = isRecord$
|
|
3426
|
+
const supports = isRecord$4(capabilities?.supports) ? capabilities.supports : void 0;
|
|
2519
3427
|
const supportedEndpoints = parseStringArray(model.supported_endpoints);
|
|
2520
3428
|
const reasoningEfforts = parseReasoningEfforts(supports);
|
|
2521
3429
|
models$1.set(model.id, {
|
|
@@ -2633,27 +3541,6 @@ function requireCopilotEndpoint(config, model) {
|
|
|
2633
3541
|
}, { status: 400 }), message);
|
|
2634
3542
|
}
|
|
2635
3543
|
|
|
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
3544
|
//#endregion
|
|
2658
3545
|
//#region src/lib/model-probe-output.ts
|
|
2659
3546
|
const tones = {
|
|
@@ -2781,6 +3668,50 @@ const probeHint = (code) => {
|
|
|
2781
3668
|
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
3669
|
};
|
|
2783
3670
|
|
|
3671
|
+
//#endregion
|
|
3672
|
+
//#region src/claude/billing-line.ts
|
|
3673
|
+
const billingLinePrefix = "x-anthropic-billing-header:";
|
|
3674
|
+
const afterBillingLine = (text$1) => {
|
|
3675
|
+
if (!text$1.startsWith(billingLinePrefix)) return;
|
|
3676
|
+
const lineEnd = text$1.indexOf("\n");
|
|
3677
|
+
if (lineEnd === -1) return "";
|
|
3678
|
+
return text$1.slice(lineEnd + 1).replace(/^(?:\r?\n)+/, "");
|
|
3679
|
+
};
|
|
3680
|
+
const isTextBlock = (block) => typeof block === "object" && block !== null && block.type === "text" && typeof block.text === "string";
|
|
3681
|
+
const withSystem = (payload, system) => {
|
|
3682
|
+
const { system: _removed,...rest } = payload;
|
|
3683
|
+
return system === void 0 ? rest : {
|
|
3684
|
+
...rest,
|
|
3685
|
+
system
|
|
3686
|
+
};
|
|
3687
|
+
};
|
|
3688
|
+
const removeBillingLine = (payload) => {
|
|
3689
|
+
if (typeof payload !== "object" || payload === null) return payload;
|
|
3690
|
+
const { system } = payload;
|
|
3691
|
+
if (typeof system === "string") {
|
|
3692
|
+
const rest = afterBillingLine(system);
|
|
3693
|
+
if (rest === void 0) return payload;
|
|
3694
|
+
return withSystem(payload, rest.trim() === "" ? void 0 : rest);
|
|
3695
|
+
}
|
|
3696
|
+
if (!Array.isArray(system)) return payload;
|
|
3697
|
+
let changed = false;
|
|
3698
|
+
const kept = [];
|
|
3699
|
+
for (const block of system) {
|
|
3700
|
+
const rest = isTextBlock(block) ? afterBillingLine(block.text) : void 0;
|
|
3701
|
+
if (rest === void 0) {
|
|
3702
|
+
kept.push(block);
|
|
3703
|
+
continue;
|
|
3704
|
+
}
|
|
3705
|
+
changed = true;
|
|
3706
|
+
if (rest.trim() !== "") kept.push({
|
|
3707
|
+
...block,
|
|
3708
|
+
text: rest
|
|
3709
|
+
});
|
|
3710
|
+
}
|
|
3711
|
+
if (!changed) return payload;
|
|
3712
|
+
return withSystem(payload, kept.length > 0 ? kept : void 0);
|
|
3713
|
+
};
|
|
3714
|
+
|
|
2784
3715
|
//#endregion
|
|
2785
3716
|
//#region src/claude/utils.ts
|
|
2786
3717
|
const isEffortOnlyControl = (control) => control !== null && typeof control === "object" && !Array.isArray(control) && Object.keys(control).length === 1 && "effort" in control && isConfiguredReasoningEffort(control.effort);
|
|
@@ -3342,6 +4273,13 @@ function translateChunkToClaudeEvents(chunk, state, toolNameMapper = createClaud
|
|
|
3342
4273
|
return events$1;
|
|
3343
4274
|
}
|
|
3344
4275
|
function translateErrorToClaudeErrorEvent(error) {
|
|
4276
|
+
if (error instanceof PromptTooLongError) return {
|
|
4277
|
+
type: "error",
|
|
4278
|
+
error: {
|
|
4279
|
+
type: "invalid_request_error",
|
|
4280
|
+
message: error.message
|
|
4281
|
+
}
|
|
4282
|
+
};
|
|
3345
4283
|
return {
|
|
3346
4284
|
type: "error",
|
|
3347
4285
|
error: {
|
|
@@ -3365,7 +4303,7 @@ const webSearchInputSchema = {
|
|
|
3365
4303
|
required: ["query"],
|
|
3366
4304
|
additionalProperties: false
|
|
3367
4305
|
};
|
|
3368
|
-
const isRecord$
|
|
4306
|
+
const isRecord$3 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
3369
4307
|
const isAnthropicNativeWebSearchTool = (tool) => tool.name === "web_search" && typeof tool.type === "string" && anthropicWebSearchToolPattern.test(tool.type);
|
|
3370
4308
|
const isClaudeCodeWebSearchTool = (tool) => tool.name === claudeCodeWebSearchToolName;
|
|
3371
4309
|
const isClaudeWebSearchTool = (tool) => isAnthropicNativeWebSearchTool(tool) || isClaudeCodeWebSearchTool(tool);
|
|
@@ -3382,11 +4320,16 @@ const prepareClaudeWebSearchDecisionPayload = (payload) => {
|
|
|
3382
4320
|
} : tool)
|
|
3383
4321
|
};
|
|
3384
4322
|
};
|
|
4323
|
+
const toolResultText = (content) => {
|
|
4324
|
+
if (typeof content === "string") return content;
|
|
4325
|
+
if (!Array.isArray(content)) return "";
|
|
4326
|
+
return content.map((block) => block.type === "text" ? block.text : `[${block.type}]`).join("\n\n");
|
|
4327
|
+
};
|
|
3385
4328
|
const textFromMessageContent = (content) => {
|
|
3386
4329
|
if (typeof content === "string") return content;
|
|
3387
4330
|
return content.flatMap((block) => {
|
|
3388
4331
|
if (block.type === "text") return [block.text];
|
|
3389
|
-
if (block.type === "tool_result") return [block.content];
|
|
4332
|
+
if (block.type === "tool_result") return [toolResultText(block.content)];
|
|
3390
4333
|
return [];
|
|
3391
4334
|
}).join("\n\n");
|
|
3392
4335
|
};
|
|
@@ -3400,7 +4343,7 @@ const getQueryFromToolArguments = (value) => {
|
|
|
3400
4343
|
if (!trimmed) return;
|
|
3401
4344
|
try {
|
|
3402
4345
|
const parsed = JSON.parse(trimmed);
|
|
3403
|
-
if (isRecord$
|
|
4346
|
+
if (isRecord$3(parsed) && typeof parsed.query === "string" && parsed.query.trim()) return parsed.query.trim();
|
|
3404
4347
|
} catch {
|
|
3405
4348
|
return trimmed;
|
|
3406
4349
|
}
|
|
@@ -3457,7 +4400,7 @@ const getReportedQuery = (action, queries) => {
|
|
|
3457
4400
|
const getSearchQuery = (response, requestedQuery) => {
|
|
3458
4401
|
for (const item of response.output ?? []) {
|
|
3459
4402
|
if (item.type !== "web_search_call") continue;
|
|
3460
|
-
const action = isRecord$
|
|
4403
|
+
const action = isRecord$3(item.action) ? item.action : void 0;
|
|
3461
4404
|
const query = getReportedQuery(action, Array.isArray(action?.queries) ? action.queries : []);
|
|
3462
4405
|
if (query) return query;
|
|
3463
4406
|
}
|
|
@@ -3465,7 +4408,7 @@ const getSearchQuery = (response, requestedQuery) => {
|
|
|
3465
4408
|
};
|
|
3466
4409
|
const getResponseText = (response) => (response.output ?? []).flatMap((item) => {
|
|
3467
4410
|
if (item.type !== "message") return [];
|
|
3468
|
-
return (Array.isArray(item.content) ? item.content : []).flatMap((part) => isRecord$
|
|
4411
|
+
return (Array.isArray(item.content) ? item.content : []).flatMap((part) => isRecord$3(part) && part.type === "output_text" && typeof part.text === "string" ? [part.text] : []);
|
|
3469
4412
|
}).join("\n").trim();
|
|
3470
4413
|
const cleanTitle = (line, url) => {
|
|
3471
4414
|
return line.slice(0, line.indexOf(url)).replace(/^\s*(?:[-*]|\d+[.)])\s*/, "").replace(/\s*(?:[-:|])\s*$/, "").trim() || new URL(url).hostname;
|
|
@@ -3489,15 +4432,15 @@ const parseSearchResults = (text$1) => {
|
|
|
3489
4432
|
const getStructuredSearchResults = (response) => {
|
|
3490
4433
|
const sources = [];
|
|
3491
4434
|
for (const item of response.output ?? []) {
|
|
3492
|
-
if (item.type === "web_search_call" && isRecord$
|
|
4435
|
+
if (item.type === "web_search_call" && isRecord$3(item.action) && Array.isArray(item.action.sources)) sources.push(...item.action.sources);
|
|
3493
4436
|
if (item.type === "message" && Array.isArray(item.content)) {
|
|
3494
|
-
for (const part of item.content) if (isRecord$
|
|
4437
|
+
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
4438
|
}
|
|
3496
4439
|
}
|
|
3497
4440
|
const results = [];
|
|
3498
4441
|
const seen = /* @__PURE__ */ new Set();
|
|
3499
4442
|
for (const source of sources) {
|
|
3500
|
-
if (!isRecord$
|
|
4443
|
+
if (!isRecord$3(source) || typeof source.url !== "string") continue;
|
|
3501
4444
|
try {
|
|
3502
4445
|
const url = new URL(source.url);
|
|
3503
4446
|
if (!/^https?:$/.test(url.protocol) || url.username || url.password || seen.has(url.href)) continue;
|
|
@@ -3549,21 +4492,21 @@ const getResultSource = (structured, results) => {
|
|
|
3549
4492
|
return results.length ? "text" : "none";
|
|
3550
4493
|
};
|
|
3551
4494
|
const interpretSearchResponse = (raw, config, requestedQuery, request, requestedEffort, requestId) => {
|
|
3552
|
-
const malformed = !isRecord$
|
|
3553
|
-
const upstream = isRecord$
|
|
4495
|
+
const malformed = !isRecord$3(raw) || raw.output !== void 0 && (!Array.isArray(raw.output) || !raw.output.every(isRecord$3));
|
|
4496
|
+
const upstream = isRecord$3(raw) ? {
|
|
3554
4497
|
...raw,
|
|
3555
|
-
output: Array.isArray(raw.output) ? raw.output.filter(isRecord$
|
|
4498
|
+
output: Array.isArray(raw.output) ? raw.output.filter(isRecord$3) : []
|
|
3556
4499
|
} : {};
|
|
3557
4500
|
const status$1 = recognizedValue(upstream.status, responseStates);
|
|
3558
|
-
const reason = recognizedValue(isRecord$
|
|
4501
|
+
const reason = recognizedValue(isRecord$3(upstream.incomplete_details) ? upstream.incomplete_details.reason : void 0, incompleteReasons);
|
|
3559
4502
|
const items = upstream.output ?? [];
|
|
3560
4503
|
const calls = items.filter((item) => item.type === "web_search_call");
|
|
3561
4504
|
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$
|
|
4505
|
+
const usage$1 = isRecord$3(upstream.usage) ? upstream.usage : {};
|
|
4506
|
+
const inputTokens = reportedTokens(usage$1.input_tokens);
|
|
4507
|
+
const cachedInputTokens = reportedTokens(isRecord$3(usage$1.input_tokens_details) ? usage$1.input_tokens_details.cached_tokens : void 0);
|
|
4508
|
+
const outputTokens = reportedTokens(usage$1.output_tokens);
|
|
4509
|
+
const reasoningTokens = reportedTokens(isRecord$3(usage$1.output_tokens_details) ? usage$1.output_tokens_details.reasoning_tokens : void 0);
|
|
3567
4510
|
const upstreamResponseId = safeResponseId(upstream.id, config.copilotToken);
|
|
3568
4511
|
const safeRequestId = typeof requestId === "string" && /^[a-f0-9-]{36}$/i.test(requestId) ? requestId : "unreported";
|
|
3569
4512
|
const text$1 = getResponseText(upstream);
|
|
@@ -3651,7 +4594,7 @@ const getSearchFailureDetail = (body, token) => {
|
|
|
3651
4594
|
let detail = body.trim();
|
|
3652
4595
|
try {
|
|
3653
4596
|
const parsed = JSON.parse(detail);
|
|
3654
|
-
detail = getErrorMessageOrCode(isRecord$
|
|
4597
|
+
detail = getErrorMessageOrCode(isRecord$3(parsed) && isRecord$3(parsed.error) ? parsed.error : void 0);
|
|
3655
4598
|
} catch {
|
|
3656
4599
|
if (/^[{[<]/.test(detail)) return "";
|
|
3657
4600
|
}
|
|
@@ -3820,9 +4763,9 @@ const accumulateChunks = (chunks) => {
|
|
|
3820
4763
|
let reasoning = "";
|
|
3821
4764
|
let refusal = "";
|
|
3822
4765
|
let finishReason = null;
|
|
3823
|
-
let usage;
|
|
4766
|
+
let usage$1;
|
|
3824
4767
|
for (const chunk of chunks) {
|
|
3825
|
-
if (chunk.usage) usage = chunk.usage;
|
|
4768
|
+
if (chunk.usage) usage$1 = chunk.usage;
|
|
3826
4769
|
const choice = chunk.choices[0];
|
|
3827
4770
|
if (!choice) continue;
|
|
3828
4771
|
if (typeof choice.delta?.content === "string") content += choice.delta.content;
|
|
@@ -3863,7 +4806,7 @@ const accumulateChunks = (chunks) => {
|
|
|
3863
4806
|
},
|
|
3864
4807
|
finish_reason: refusal ? "content_filter" : finishReason ?? "stop"
|
|
3865
4808
|
}],
|
|
3866
|
-
...usage ? { usage } : {}
|
|
4809
|
+
...usage$1 ? { usage: usage$1 } : {}
|
|
3867
4810
|
};
|
|
3868
4811
|
};
|
|
3869
4812
|
const incompleteStreamError = () => {
|
|
@@ -3879,7 +4822,7 @@ const incompleteStreamError = () => {
|
|
|
3879
4822
|
async function* normalizeChatCompletionStream(stream) {
|
|
3880
4823
|
let terminal;
|
|
3881
4824
|
let refusal = false;
|
|
3882
|
-
let usage;
|
|
4825
|
+
let usage$1;
|
|
3883
4826
|
for await (const event of stream) {
|
|
3884
4827
|
if (event.event === "error") throw incompleteStreamError();
|
|
3885
4828
|
if (!event.data) continue;
|
|
@@ -3891,7 +4834,7 @@ async function* normalizeChatCompletionStream(stream) {
|
|
|
3891
4834
|
throw incompleteStreamError();
|
|
3892
4835
|
}
|
|
3893
4836
|
if (!chunk || !Array.isArray(chunk.choices)) throw incompleteStreamError();
|
|
3894
|
-
if (chunk.usage) usage = chunk.usage;
|
|
4837
|
+
if (chunk.usage) usage$1 = chunk.usage;
|
|
3895
4838
|
if (chunk.choices.length === 0) continue;
|
|
3896
4839
|
const choice = chunk.choices[0];
|
|
3897
4840
|
if (!choice || !choice.delta || typeof choice.delta !== "object" || terminal) throw incompleteStreamError();
|
|
@@ -3923,7 +4866,7 @@ async function* normalizeChatCompletionStream(stream) {
|
|
|
3923
4866
|
if (!terminal) throw incompleteStreamError();
|
|
3924
4867
|
yield { data: JSON.stringify({
|
|
3925
4868
|
...terminal,
|
|
3926
|
-
...usage && { usage }
|
|
4869
|
+
...usage$1 && { usage: usage$1 }
|
|
3927
4870
|
}) };
|
|
3928
4871
|
yield { data: "[DONE]" };
|
|
3929
4872
|
}
|
|
@@ -4223,7 +5166,7 @@ const lookaroundPrefixes = [
|
|
|
4223
5166
|
"(?<=",
|
|
4224
5167
|
"(?<!"
|
|
4225
5168
|
];
|
|
4226
|
-
const isRecord$
|
|
5169
|
+
const isRecord$2 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
4227
5170
|
function hasUnsupportedPatternFeatures(pattern) {
|
|
4228
5171
|
let inCharacterClass = false;
|
|
4229
5172
|
for (let index = 0; index < pattern.length; index++) {
|
|
@@ -4269,12 +5212,12 @@ function mapArray(values) {
|
|
|
4269
5212
|
}
|
|
4270
5213
|
function normalizeSchemaValue(value) {
|
|
4271
5214
|
if (Array.isArray(value)) return mapArray(value);
|
|
4272
|
-
return isRecord$
|
|
5215
|
+
return isRecord$2(value) ? normalizeResponsesToolSchema(value) : value;
|
|
4273
5216
|
}
|
|
4274
5217
|
function normalizeResponsesToolSchema(schema) {
|
|
4275
5218
|
return mapRecord(schema, (key, value) => {
|
|
4276
5219
|
if (key === "pattern" && typeof value === "string" && hasUnsupportedPatternFeatures(value)) return removed;
|
|
4277
|
-
if (schemaMapKeywords.has(key) && isRecord$
|
|
5220
|
+
if (schemaMapKeywords.has(key) && isRecord$2(value)) return mapRecord(value, (_name, nested) => normalizeSchemaValue(nested));
|
|
4278
5221
|
if (nestedSchemaKeywords.has(key)) return normalizeSchemaValue(value);
|
|
4279
5222
|
return value;
|
|
4280
5223
|
});
|
|
@@ -4553,7 +5496,7 @@ function createRoleChunk(state) {
|
|
|
4553
5496
|
state.started = true;
|
|
4554
5497
|
return createChunk(state, { delta: { role: "assistant" } });
|
|
4555
5498
|
}
|
|
4556
|
-
function createChunk(state, { delta, finishReason = null, usage }) {
|
|
5499
|
+
function createChunk(state, { delta, finishReason = null, usage: usage$1 }) {
|
|
4557
5500
|
return { data: JSON.stringify({
|
|
4558
5501
|
id: state.responseId,
|
|
4559
5502
|
object: "chat.completion.chunk",
|
|
@@ -4565,7 +5508,7 @@ function createChunk(state, { delta, finishReason = null, usage }) {
|
|
|
4565
5508
|
finish_reason: finishReason,
|
|
4566
5509
|
logprobs: null
|
|
4567
5510
|
}],
|
|
4568
|
-
usage
|
|
5511
|
+
usage: usage$1
|
|
4569
5512
|
}) };
|
|
4570
5513
|
}
|
|
4571
5514
|
function translateMessagesToResponsesInput(messages) {
|
|
@@ -4577,7 +5520,7 @@ function translateMessage(message) {
|
|
|
4577
5520
|
if (message.role === "tool") return [{
|
|
4578
5521
|
type: "function_call_output",
|
|
4579
5522
|
call_id: message.tool_call_id ?? "",
|
|
4580
|
-
output:
|
|
5523
|
+
output: translateToolOutput(message.content)
|
|
4581
5524
|
}];
|
|
4582
5525
|
const translated = [];
|
|
4583
5526
|
if (hasContent(message.content)) translated.push({
|
|
@@ -4613,6 +5556,21 @@ function translateContentPart(part) {
|
|
|
4613
5556
|
detail: part.image_url.detail ?? "auto"
|
|
4614
5557
|
};
|
|
4615
5558
|
}
|
|
5559
|
+
function translateToolOutput(content) {
|
|
5560
|
+
if (Array.isArray(content) && content.some((part) => part.type === "image_url")) return content.flatMap((part) => translateToolOutputPart(part));
|
|
5561
|
+
return stringifyToolOutput(content);
|
|
5562
|
+
}
|
|
5563
|
+
function translateToolOutputPart(part) {
|
|
5564
|
+
if (part.type === "image_url") return [{
|
|
5565
|
+
type: "input_image",
|
|
5566
|
+
image_url: part.image_url.url
|
|
5567
|
+
}];
|
|
5568
|
+
if (part.text === "") return [];
|
|
5569
|
+
return [{
|
|
5570
|
+
type: "input_text",
|
|
5571
|
+
text: part.text
|
|
5572
|
+
}];
|
|
5573
|
+
}
|
|
4616
5574
|
function stringifyToolOutput(content) {
|
|
4617
5575
|
if (typeof content === "string") return content;
|
|
4618
5576
|
if (!content) return "";
|
|
@@ -4636,13 +5594,13 @@ function translateToolChoice(toolChoice) {
|
|
|
4636
5594
|
name: toolChoice.function.name
|
|
4637
5595
|
};
|
|
4638
5596
|
}
|
|
4639
|
-
function translateUsage(usage) {
|
|
4640
|
-
if (!usage) return;
|
|
5597
|
+
function translateUsage(usage$1) {
|
|
5598
|
+
if (!usage$1) return;
|
|
4641
5599
|
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 } }
|
|
5600
|
+
prompt_tokens: usage$1.input_tokens,
|
|
5601
|
+
completion_tokens: usage$1.output_tokens,
|
|
5602
|
+
total_tokens: usage$1.total_tokens,
|
|
5603
|
+
...usage$1.input_tokens_details?.cached_tokens !== void 0 && { prompt_tokens_details: { cached_tokens: usage$1.input_tokens_details.cached_tokens } }
|
|
4646
5604
|
};
|
|
4647
5605
|
}
|
|
4648
5606
|
function getMessageOutputText(content) {
|
|
@@ -4733,7 +5691,57 @@ const normalizeFinalAssistantPrefill = (payload) => {
|
|
|
4733
5691
|
};
|
|
4734
5692
|
};
|
|
4735
5693
|
const isAgentInitiator = (messages) => messages.some((message) => message.role === "assistant" || message.role === "tool") ? "agent" : "user";
|
|
4736
|
-
const
|
|
5694
|
+
const holdsImage = (message) => Array.isArray(message.content) && message.content.some((part) => part.type === "image_url");
|
|
5695
|
+
const messagesIncludeImage = (messages) => messages.some(holdsImage);
|
|
5696
|
+
const isToolImageMessage = (message) => message.role === "tool" && holdsImage(message);
|
|
5697
|
+
const joinTextParts = (parts) => parts.filter((part) => part.type === "text").map((part) => part.text).join("\n\n");
|
|
5698
|
+
const toContentParts = (content) => {
|
|
5699
|
+
if (typeof content === "string") return [{
|
|
5700
|
+
type: "text",
|
|
5701
|
+
text: content
|
|
5702
|
+
}];
|
|
5703
|
+
return content ?? [];
|
|
5704
|
+
};
|
|
5705
|
+
const toolImageNote = "Image output follows in the next user message.";
|
|
5706
|
+
const moveToolImagesToUserMessages = (payload) => {
|
|
5707
|
+
if (isClaudeModelId(payload.model) || !payload.messages.some(isToolImageMessage)) return payload;
|
|
5708
|
+
const messages = [];
|
|
5709
|
+
let movedImages = [];
|
|
5710
|
+
for (const message of payload.messages) {
|
|
5711
|
+
if (isToolImageMessage(message)) {
|
|
5712
|
+
messages.push({
|
|
5713
|
+
...message,
|
|
5714
|
+
content: joinTextParts(message.content) || toolImageNote
|
|
5715
|
+
});
|
|
5716
|
+
movedImages.push({
|
|
5717
|
+
type: "text",
|
|
5718
|
+
text: `Image output of tool call ${message.tool_call_id ?? ""}:`
|
|
5719
|
+
}, ...message.content.filter((part) => part.type === "image_url"));
|
|
5720
|
+
continue;
|
|
5721
|
+
}
|
|
5722
|
+
if (message.role === "tool" || movedImages.length === 0) {
|
|
5723
|
+
messages.push(message);
|
|
5724
|
+
continue;
|
|
5725
|
+
}
|
|
5726
|
+
if (message.role === "user") messages.push({
|
|
5727
|
+
...message,
|
|
5728
|
+
content: [...movedImages, ...toContentParts(message.content)]
|
|
5729
|
+
});
|
|
5730
|
+
else messages.push({
|
|
5731
|
+
role: "user",
|
|
5732
|
+
content: movedImages
|
|
5733
|
+
}, message);
|
|
5734
|
+
movedImages = [];
|
|
5735
|
+
}
|
|
5736
|
+
if (movedImages.length > 0) messages.push({
|
|
5737
|
+
role: "user",
|
|
5738
|
+
content: movedImages
|
|
5739
|
+
});
|
|
5740
|
+
return {
|
|
5741
|
+
...payload,
|
|
5742
|
+
messages
|
|
5743
|
+
};
|
|
5744
|
+
};
|
|
4737
5745
|
const createChatCompletions = async (config, payload, options = {}) => {
|
|
4738
5746
|
const client = options.client ?? "generic";
|
|
4739
5747
|
const requestedModel = options.requestedModel ?? payload.model;
|
|
@@ -4756,7 +5764,7 @@ const createChatCompletions = async (config, payload, options = {}) => {
|
|
|
4756
5764
|
stream: bufferResponse ? true : payload.stream
|
|
4757
5765
|
};
|
|
4758
5766
|
const useResponsesApi = selection.endpoint === "/responses";
|
|
4759
|
-
const compatiblePayload = useResponsesApi ? upstreamPayload : normalizeFinalAssistantPrefill(upstreamPayload);
|
|
5767
|
+
const compatiblePayload = useResponsesApi ? upstreamPayload : normalizeFinalAssistantPrefill(moveToolImagesToUserMessages(upstreamPayload));
|
|
4760
5768
|
const provider = getCopilotProviderContext(config);
|
|
4761
5769
|
const enableVision = messagesIncludeImage(compatiblePayload.messages);
|
|
4762
5770
|
const initiator = isAgentInitiator(compatiblePayload.messages);
|
|
@@ -4798,19 +5806,19 @@ const createChatCompletions = async (config, payload, options = {}) => {
|
|
|
4798
5806
|
markDiscardedResponse(response);
|
|
4799
5807
|
await response.body?.cancel();
|
|
4800
5808
|
return completeResponse(await createResponses(provider, upstreamPayload, {
|
|
4801
|
-
vision:
|
|
5809
|
+
vision: messagesIncludeImage(upstreamPayload.messages),
|
|
4802
5810
|
initiator,
|
|
4803
5811
|
requestId: options.requestId,
|
|
4804
5812
|
signal,
|
|
4805
5813
|
timeoutMs: options.timeoutMs
|
|
4806
5814
|
}));
|
|
4807
5815
|
}
|
|
4808
|
-
throw
|
|
5816
|
+
throw await logUpstreamError("Failed to create chat completions", response, {
|
|
4809
5817
|
model: payload.model,
|
|
4810
5818
|
request: requestPayload,
|
|
4811
5819
|
route: "/chat/completions",
|
|
4812
5820
|
requestId: options.requestId
|
|
4813
|
-
}, signal, options.timeoutMs)
|
|
5821
|
+
}, signal, options.timeoutMs);
|
|
4814
5822
|
}
|
|
4815
5823
|
if (compatiblePayload.stream) return completeResponse(normalizeChatCompletionStream(events(response)));
|
|
4816
5824
|
return readCopilotJson(response, signal, options.timeoutMs);
|
|
@@ -4832,12 +5840,12 @@ async function createResponses(provider, payload, options) {
|
|
|
4832
5840
|
signal: options.signal,
|
|
4833
5841
|
timeoutMs: options.timeoutMs
|
|
4834
5842
|
});
|
|
4835
|
-
if (!response.ok) throw
|
|
5843
|
+
if (!response.ok) throw await logUpstreamError("Failed to create responses", response, {
|
|
4836
5844
|
model: payload.model,
|
|
4837
5845
|
request: requestPayload,
|
|
4838
5846
|
route: "/responses",
|
|
4839
5847
|
requestId: options.requestId
|
|
4840
|
-
}, options.signal, options.timeoutMs)
|
|
5848
|
+
}, options.signal, options.timeoutMs);
|
|
4841
5849
|
if (payload.stream) return normalizeChatCompletionStream(translateResponsesStreamToChatCompletionStream(events(response)));
|
|
4842
5850
|
return translateResponsesToChatCompletion(await readCopilotJson(response, options.signal, options.timeoutMs));
|
|
4843
5851
|
}
|
|
@@ -4858,7 +5866,7 @@ async function logUpstreamError(message, response, context, signal, timeoutMs) {
|
|
|
4858
5866
|
},
|
|
4859
5867
|
request: context.request
|
|
4860
5868
|
});
|
|
4861
|
-
return detail;
|
|
5869
|
+
return toPromptTooLongError(response.status, errorBody, detail) ?? new HTTPError(message, response, detail);
|
|
4862
5870
|
}
|
|
4863
5871
|
function getUpstreamErrorDetail(response, body) {
|
|
4864
5872
|
if (!body) return response.statusText || void 0;
|
|
@@ -5018,6 +6026,8 @@ async function createNativeMessages(config, payload, options) {
|
|
|
5018
6026
|
if (!response.ok) {
|
|
5019
6027
|
const detail = await readCopilotText(response, options.signal, config.upstreamTimeoutMs);
|
|
5020
6028
|
const contentType = response.headers.get("content-type") ?? "application/json";
|
|
6029
|
+
const promptTooLong = toPromptTooLongError(response.status, detail);
|
|
6030
|
+
if (promptTooLong) throw promptTooLong;
|
|
5021
6031
|
throw new HTTPError("Native upstream request failed", new Response(detail, {
|
|
5022
6032
|
status: response.status,
|
|
5023
6033
|
headers: { "content-type": contentType }
|
|
@@ -5247,7 +6257,7 @@ async function handleNativeMessages(config, payload, options, write) {
|
|
|
5247
6257
|
};
|
|
5248
6258
|
const upstreamToolResult = toolResult(searchCall.id, resultBlock.content);
|
|
5249
6259
|
const hasSiblingTools = message.content.some((block) => block.type === "tool_use" && block !== searchCall);
|
|
5250
|
-
let usage = mergeUsage(message.usage, searchMessage.usage);
|
|
6260
|
+
let usage$1 = mergeUsage(message.usage, searchMessage.usage);
|
|
5251
6261
|
const combined = [...displayDecision, resultBlock];
|
|
5252
6262
|
if (write) for (const [index, block] of combined.entries()) {
|
|
5253
6263
|
if (index < (heldFrom ?? message.content.length)) continue;
|
|
@@ -5258,13 +6268,13 @@ async function handleNativeMessages(config, payload, options, write) {
|
|
|
5258
6268
|
...message,
|
|
5259
6269
|
content: combined,
|
|
5260
6270
|
stop_reason: hasSiblingTools ? "tool_use" : "end_turn",
|
|
5261
|
-
usage
|
|
6271
|
+
usage: usage$1
|
|
5262
6272
|
};
|
|
5263
6273
|
if (!write) return complete;
|
|
5264
6274
|
await write({
|
|
5265
6275
|
type: "message_delta",
|
|
5266
6276
|
delta: { stop_reason: complete.stop_reason },
|
|
5267
|
-
usage
|
|
6277
|
+
usage: usage$1
|
|
5268
6278
|
});
|
|
5269
6279
|
await write({ type: "message_stop" });
|
|
5270
6280
|
return;
|
|
@@ -5297,11 +6307,11 @@ async function handleNativeMessages(config, payload, options, write) {
|
|
|
5297
6307
|
});
|
|
5298
6308
|
} : void 0);
|
|
5299
6309
|
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);
|
|
6310
|
+
usage$1 = mergeUsage(usage$1, finalMessage.usage);
|
|
5301
6311
|
if (!write) return {
|
|
5302
6312
|
...finalMessage,
|
|
5303
6313
|
content: [...combined, ...finalMessage.content],
|
|
5304
|
-
usage
|
|
6314
|
+
usage: usage$1
|
|
5305
6315
|
};
|
|
5306
6316
|
if (finalHeldFrom !== void 0) for (const [index, block] of finalMessage.content.entries()) {
|
|
5307
6317
|
if (index < finalHeldFrom) continue;
|
|
@@ -5314,7 +6324,7 @@ async function handleNativeMessages(config, payload, options, write) {
|
|
|
5314
6324
|
stop_sequence: finalMessage.stop_sequence,
|
|
5315
6325
|
..."stop_details" in finalMessage && { stop_details: finalMessage.stop_details }
|
|
5316
6326
|
},
|
|
5317
|
-
usage
|
|
6327
|
+
usage: usage$1
|
|
5318
6328
|
});
|
|
5319
6329
|
await write({ type: "message_stop" });
|
|
5320
6330
|
}
|
|
@@ -5655,7 +6665,7 @@ claudeRoutes.get("/models", (c) => c.json({
|
|
|
5655
6665
|
claudeRoutes.post("/messages", async (c) => {
|
|
5656
6666
|
const config = c.get("config");
|
|
5657
6667
|
const requestId = c.get("requestId");
|
|
5658
|
-
const claudePayload = await c.req.json();
|
|
6668
|
+
const claudePayload = removeBillingLine(await c.req.json());
|
|
5659
6669
|
const requestSignal = createCopilotRequestSignal(c.req.raw.signal, config.upstreamTimeoutMs);
|
|
5660
6670
|
try {
|
|
5661
6671
|
getRequestReasoningEffort(claudePayload);
|
|
@@ -5744,7 +6754,7 @@ claudeRoutes.post("/messages", async (c) => {
|
|
|
5744
6754
|
claudeRoutes.post("/messages/count_tokens", async (c) => {
|
|
5745
6755
|
try {
|
|
5746
6756
|
const claudeBeta = c.req.header("claude-beta");
|
|
5747
|
-
const claudePayload = await c.req.json();
|
|
6757
|
+
const claudePayload = removeBillingLine(await c.req.json());
|
|
5748
6758
|
const openAIPayload = translateToOpenAI(shouldUseNativeMessages(c.get("config"), translateModelName(claudePayload.model)) ? {
|
|
5749
6759
|
...claudePayload,
|
|
5750
6760
|
messages: claudePayload.messages.map((message) => {
|
|
@@ -5816,6 +6826,22 @@ const parseAuthority = (authority, protocol) => {
|
|
|
5816
6826
|
};
|
|
5817
6827
|
const effectivePort = (url) => Number(url.port || (url.protocol === "https:" ? 443 : 80));
|
|
5818
6828
|
const isAllowedHostname = (hostname, configuredHost) => hostname === "localhost" || hostname === "[::1]" || isIPv4(hostname) && hostname.startsWith("127.") || hostname !== "0.0.0.0" && hostname !== "[::]" && hostname === configuredHost;
|
|
6829
|
+
const sha256 = (value) => createHash("sha256").update(value, "utf8").digest();
|
|
6830
|
+
/**
|
|
6831
|
+
* True when the request carries the relay's apiKey as x-api-key or Authorization: Bearer.
|
|
6832
|
+
*
|
|
6833
|
+
* The configured key and each presented value are hashed with SHA-256, and timingSafeEqual compares
|
|
6834
|
+
* the fixed-length digests, so the comparison never stops at the first byte that differs. Both
|
|
6835
|
+
* headers are compared even after one matches.
|
|
6836
|
+
*/
|
|
6837
|
+
const presentsApiKey = (headers, apiKey) => {
|
|
6838
|
+
const expected = sha256(apiKey);
|
|
6839
|
+
const bearer = /^Bearer\s+(.+)$/i.exec(headers.get("authorization") ?? "")?.[1];
|
|
6840
|
+
let matched = false;
|
|
6841
|
+
for (const presented of [headers.get("x-api-key"), bearer]) if (typeof presented === "string" && timingSafeEqual(sha256(presented), expected)) matched = true;
|
|
6842
|
+
return matched;
|
|
6843
|
+
};
|
|
6844
|
+
const isOpenProbe = (method, path$1) => method === "GET" && path$1 === "/healthz" || (method === "GET" || method === "HEAD") && path$1 === "/api/hello";
|
|
5819
6845
|
const createServer = (config) => {
|
|
5820
6846
|
const app = new Hono();
|
|
5821
6847
|
const { host, port } = config;
|
|
@@ -5859,6 +6885,18 @@ const createServer = (config) => {
|
|
|
5859
6885
|
return c.json({ error: { message } }, 403);
|
|
5860
6886
|
}
|
|
5861
6887
|
}
|
|
6888
|
+
const apiKey = config.apiKey;
|
|
6889
|
+
if (apiKey && !isOpenProbe(c.req.method, c.req.path) && !presentsApiKey(c.req.raw.headers, apiKey)) {
|
|
6890
|
+
const message = "Missing or invalid API key: send the relay apiKey as x-api-key or Authorization: Bearer";
|
|
6891
|
+
c.set("requestErrorMessage", message);
|
|
6892
|
+
return c.json({
|
|
6893
|
+
type: "error",
|
|
6894
|
+
error: {
|
|
6895
|
+
type: "authentication_error",
|
|
6896
|
+
message
|
|
6897
|
+
}
|
|
6898
|
+
}, 401, { "www-authenticate": "Bearer" });
|
|
6899
|
+
}
|
|
5862
6900
|
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
6901
|
const message = "Content-Type must be application/json";
|
|
5864
6902
|
c.set("requestErrorMessage", message);
|
|
@@ -5939,7 +6977,14 @@ const startServer = (config) => new Promise((resolve$1, reject) => {
|
|
|
5939
6977
|
//#endregion
|
|
5940
6978
|
//#region src/lib/model-probe.ts
|
|
5941
6979
|
const record$1 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
5942
|
-
const
|
|
6980
|
+
const holdsCredential = (text$1, config) => {
|
|
6981
|
+
for (const secret of [config.copilotToken, config.apiKey]) if (secret && text$1.includes(secret)) return true;
|
|
6982
|
+
return false;
|
|
6983
|
+
};
|
|
6984
|
+
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);
|
|
6985
|
+
const printLine = (line) => {
|
|
6986
|
+
console.log(scrubLogSecrets(scrubSensitiveUrls(line)));
|
|
6987
|
+
};
|
|
5943
6988
|
const httpCategory = (status$1) => {
|
|
5944
6989
|
if (status$1 === 401) return "authentication-rejected";
|
|
5945
6990
|
if (status$1 === 403) return "access-denied";
|
|
@@ -5996,9 +7041,9 @@ const failureFromEvidence = (row, diagnostic) => {
|
|
|
5996
7041
|
return row.detail;
|
|
5997
7042
|
};
|
|
5998
7043
|
async function probeModels(config, entries, options) {
|
|
5999
|
-
|
|
6000
|
-
|
|
6001
|
-
|
|
7044
|
+
printLine(`\nModel check · ${entries.length} ${entries.length === 1 ? "model" : "models"} · real Copilot usage`);
|
|
7045
|
+
printLine("Isolated relay pipeline; not running-daemon health.");
|
|
7046
|
+
printLine(`Up to ${options.maxTokens} output tokens/probe; existing retries may add calls.\n`);
|
|
6002
7047
|
const controller = new AbortController();
|
|
6003
7048
|
const interrupt = () => controller.abort();
|
|
6004
7049
|
process.on("SIGINT", interrupt);
|
|
@@ -6012,9 +7057,9 @@ async function probeModels(config, entries, options) {
|
|
|
6012
7057
|
const results = [];
|
|
6013
7058
|
const app = createServer(config);
|
|
6014
7059
|
const columns = Math.max(40, process.stdout.columns || 80);
|
|
6015
|
-
const width = probeColumnWidth(entries.map(([id]) => safeId(id, config
|
|
7060
|
+
const width = probeColumnWidth(entries.map(([id]) => safeId(id, config) ? id : "[unsupported ID]"), columns);
|
|
6016
7061
|
const color = colorEnabled();
|
|
6017
|
-
if (entries.length)
|
|
7062
|
+
if (entries.length) printLine(renderProbeHeader(width));
|
|
6018
7063
|
try {
|
|
6019
7064
|
runtimeState.modelCatalog = config.modelCatalog;
|
|
6020
7065
|
runtimeState.upstreamBaseUrl = config.copilotBaseUrl;
|
|
@@ -6036,7 +7081,7 @@ async function probeModels(config, entries, options) {
|
|
|
6036
7081
|
const endpoint = selection.endpoint ?? "none";
|
|
6037
7082
|
const { advertisedEndpoints, unknownEndpoints } = summarizeAdvertisedEndpoints(model.supportedEndpoints);
|
|
6038
7083
|
const row = {
|
|
6039
|
-
id: safeId(id, config
|
|
7084
|
+
id: safeId(id, config) ? id : "[unsupported ID]",
|
|
6040
7085
|
status: "NOT_TESTED",
|
|
6041
7086
|
reported: "-",
|
|
6042
7087
|
sent: false,
|
|
@@ -6051,7 +7096,7 @@ async function probeModels(config, entries, options) {
|
|
|
6051
7096
|
unverified: model.reasoningEfforts === void 0 || model.supportedEndpoints === void 0
|
|
6052
7097
|
};
|
|
6053
7098
|
if (controller.signal.aborted || totalDeadline.aborted) row.detail = controller.signal.aborted ? "cancelled" : "total-deadline";
|
|
6054
|
-
else if (!safeId(id, config
|
|
7099
|
+
else if (!safeId(id, config) || normalizeCopilotModelId(id) !== id) {
|
|
6055
7100
|
row.status = "SKIPPED";
|
|
6056
7101
|
row.detail = "unsafe-or-noncanonical-id";
|
|
6057
7102
|
} else if (!selection.endpoint) {
|
|
@@ -6080,7 +7125,10 @@ async function probeModels(config, entries, options) {
|
|
|
6080
7125
|
const sendProbe = async () => {
|
|
6081
7126
|
const response = await app.fetch(new Request(`http://localhost${config.port ? `:${config.port}` : ""}/v1/messages`, {
|
|
6082
7127
|
method: "POST",
|
|
6083
|
-
headers: {
|
|
7128
|
+
headers: {
|
|
7129
|
+
"content-type": "application/json",
|
|
7130
|
+
...config.apiKey ? { "x-api-key": config.apiKey } : {}
|
|
7131
|
+
},
|
|
6084
7132
|
signal,
|
|
6085
7133
|
body: JSON.stringify({
|
|
6086
7134
|
model: id,
|
|
@@ -6121,7 +7169,7 @@ async function probeModels(config, entries, options) {
|
|
|
6121
7169
|
if (code === "model_not_supported") row.detail = "model-not-supported";
|
|
6122
7170
|
} else if (!record$1(body) || !Array.isArray(body.content)) row.detail = "malformed-response";
|
|
6123
7171
|
else {
|
|
6124
|
-
row.reported = safeId(body.model, config
|
|
7172
|
+
row.reported = safeId(body.model, config) ? body.model : "unreported";
|
|
6125
7173
|
if (!reportsSelectedModel(endpoint, id, row.reported)) row.detail = "model-mismatch";
|
|
6126
7174
|
else if (body.stop_reason === "max_tokens") {
|
|
6127
7175
|
row.status = "INCOMPLETE";
|
|
@@ -6153,11 +7201,11 @@ async function probeModels(config, entries, options) {
|
|
|
6153
7201
|
if (row.status === "FAIL") row.detail = failureFromEvidence(row, row.diagnostic);
|
|
6154
7202
|
}
|
|
6155
7203
|
}
|
|
6156
|
-
if (!safeId(id, config
|
|
7204
|
+
if (!safeId(id, config)) row.id = "[unsupported ID]";
|
|
6157
7205
|
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)
|
|
7206
|
+
for (const line of renderProbeRow(row, width, color, columns)) printLine(line);
|
|
7207
|
+
if (options.details) for (const line of renderProbeDetails(row, row.diagnostic)) printLine(line);
|
|
7208
|
+
else if (row.status !== "PASS" && row.diagnostic) printLine(` request_id=${row.diagnostic.requestId}`);
|
|
6161
7209
|
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
7210
|
}
|
|
6163
7211
|
} finally {
|
|
@@ -6172,11 +7220,11 @@ async function probeModels(config, entries, options) {
|
|
|
6172
7220
|
process.off("SIGINT", interrupt);
|
|
6173
7221
|
process.off("SIGTERM", interrupt);
|
|
6174
7222
|
}
|
|
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))
|
|
7223
|
+
printLine(`\n${renderProbeSummary(results)}`);
|
|
7224
|
+
if (results.some((row) => row.unverified)) printLine("* Effort or endpoint metadata is unverified.");
|
|
7225
|
+
const hints = new Set(results.filter((row) => row.status !== "PASS").map((row) => probeHint(row.detail)).filter((hint) => hint !== void 0));
|
|
7226
|
+
for (const hint of hints) printLine(hint);
|
|
7227
|
+
if (!options.details && results.some((row) => row.status !== "PASS" && row.sent)) printLine("Use --details for request evidence; another deep run consumes usage.");
|
|
6180
7228
|
if (controller.signal.aborted) return 130;
|
|
6181
7229
|
return results.length > 0 && results.every((row) => row.status === "PASS") ? 0 : 2;
|
|
6182
7230
|
}
|
|
@@ -6269,8 +7317,9 @@ const renderConfigGuide = (configPath$1, configured, chosen) => {
|
|
|
6269
7317
|
//#endregion
|
|
6270
7318
|
//#region src/models.ts
|
|
6271
7319
|
var ModelsUsageError = class extends Error {};
|
|
7320
|
+
const printable = (text$1) => scrubLogSecrets(terminalText(text$1));
|
|
6272
7321
|
const displayName = (name) => {
|
|
6273
|
-
const text$1 =
|
|
7322
|
+
const text$1 = printable(name ?? "").trim();
|
|
6274
7323
|
if (text$1 === "") return;
|
|
6275
7324
|
return text$1.length > 80 ? `${text$1.slice(0, 77)}...` : text$1;
|
|
6276
7325
|
};
|
|
@@ -6278,7 +7327,7 @@ const listedModels = (config, catalog) => [...catalog.models.keys()].sort().map(
|
|
|
6278
7327
|
const selection = selectCopilotEndpoint(config, id);
|
|
6279
7328
|
const name = displayName(catalog.models.get(id)?.name);
|
|
6280
7329
|
return {
|
|
6281
|
-
id:
|
|
7330
|
+
id: printable(id),
|
|
6282
7331
|
...name !== void 0 && { name },
|
|
6283
7332
|
...!selection.endpoint && { unusable: probeReason(selection.reason) }
|
|
6284
7333
|
};
|
|
@@ -6288,7 +7337,7 @@ const configuredModels = (appConfig, config, catalog) => ["gptModel", "opusModel
|
|
|
6288
7337
|
const selection = selectCopilotEndpoint(config, id);
|
|
6289
7338
|
return {
|
|
6290
7339
|
key,
|
|
6291
|
-
value:
|
|
7340
|
+
value: printable(appConfig[key]),
|
|
6292
7341
|
advertised: catalog.models.has(id),
|
|
6293
7342
|
...!selection.endpoint && { unusable: probeReason(selection.reason) }
|
|
6294
7343
|
};
|
|
@@ -6298,7 +7347,7 @@ const listingHeading = (search, result) => {
|
|
|
6298
7347
|
if (result.models.length === 0) return "No models advertised by upstream.";
|
|
6299
7348
|
return `Upstream-advertised models (${result.models.length}):`;
|
|
6300
7349
|
}
|
|
6301
|
-
const label =
|
|
7350
|
+
const label = printable(search);
|
|
6302
7351
|
if (result.found) return `Upstream models matching "${label}" (${result.models.length}):`;
|
|
6303
7352
|
if (result.models.length === 0) return `No upstream model matches "${label}". List them all with copilot-relay models.`;
|
|
6304
7353
|
return `No upstream model matches "${label}". Closest IDs (${result.models.length}):`;
|
|
@@ -6375,6 +7424,8 @@ const models = defineCommand({
|
|
|
6375
7424
|
const appConfig$1 = await readAppConfig();
|
|
6376
7425
|
setLogLevel(appConfig$1.logLevel);
|
|
6377
7426
|
registerSensitiveOrigin(appConfig$1.copilotBaseUrl);
|
|
7427
|
+
registerLogSecret(appConfig$1.apiKey);
|
|
7428
|
+
configureUpstreamDispatcher(appConfig$1.upstreamProxy);
|
|
6378
7429
|
const config$1 = readProxyConfig(appConfig$1);
|
|
6379
7430
|
failure = "Could not authenticate with GitHub Copilot";
|
|
6380
7431
|
await setupProxyAuth(config$1, args.deep ? { onDeviceCode: (url, code) => console.error(`Sign in: open ${terminalText(url)} and enter ${terminalText(code)}.`) } : void 0);
|
|
@@ -6405,7 +7456,7 @@ const models = defineCommand({
|
|
|
6405
7456
|
const lines = [listingHeading(search, result), ...renderModelRows(result.models)];
|
|
6406
7457
|
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
7458
|
lines.push("", ...renderConfigGuide(paths.configPath, configuredModels(appConfig, config, catalog), result.chosen));
|
|
6408
|
-
console.log(scrubSensitiveUrls(
|
|
7459
|
+
console.log(lines.map((line) => scrubLogSecrets(scrubSensitiveUrls(terminalText(line)))).join("\n"));
|
|
6409
7460
|
if (!result.found) process.exitCode = 1;
|
|
6410
7461
|
} catch (error) {
|
|
6411
7462
|
let detail = "Check configuration, authentication, and upstream connectivity.";
|
|
@@ -6970,8 +8021,8 @@ const replay = defineCommand({
|
|
|
6970
8021
|
console.log(`${method} ${path$1} status=${status$1 ?? "unknown"} outcome=${outcome}`);
|
|
6971
8022
|
console.log(`upstream=${routes$1.join(" -> ") || "none"}`);
|
|
6972
8023
|
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}` : ""}`);
|
|
8024
|
+
const usage$1 = Object.entries(exchange.usage).map(([key, value]) => `${key}=${value}`).join(" ");
|
|
8025
|
+
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
8026
|
}
|
|
6976
8027
|
if (result.summary.contentBlocks) console.log(`client_blocks=${result.summary.contentBlocks.join(",") || "none"} tool_calls=${result.summary.toolCalls ?? 0}`);
|
|
6977
8028
|
}
|
|
@@ -6988,6 +8039,22 @@ const getRelayBaseUrl = (host, port) => {
|
|
|
6988
8039
|
const hostname = new URL(`http://${authority}`).hostname;
|
|
6989
8040
|
return `http://${loopbackForWildcard.get(hostname) ?? hostname}:${port}`;
|
|
6990
8041
|
};
|
|
8042
|
+
const loopbackAddresses = new BlockList();
|
|
8043
|
+
loopbackAddresses.addSubnet("127.0.0.0", 8, "ipv4");
|
|
8044
|
+
loopbackAddresses.addAddress("::1", "ipv6");
|
|
8045
|
+
/**
|
|
8046
|
+
* True when a bind host accepts connections from this machine only.
|
|
8047
|
+
*
|
|
8048
|
+
* Literal loopback addresses and the name localhost qualify. Wildcards, other addresses and other
|
|
8049
|
+
* names do not: a name can resolve to any address, so it is treated as reachable from the network.
|
|
8050
|
+
*/
|
|
8051
|
+
const isLoopbackHost = (host) => {
|
|
8052
|
+
const bare = host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host;
|
|
8053
|
+
if (bare.toLowerCase() === "localhost") return true;
|
|
8054
|
+
const family = isIP(bare);
|
|
8055
|
+
if (family === 0) return false;
|
|
8056
|
+
return loopbackAddresses.check(bare, family === 4 ? "ipv4" : "ipv6");
|
|
8057
|
+
};
|
|
6991
8058
|
|
|
6992
8059
|
//#endregion
|
|
6993
8060
|
//#region src/lib/lifecycle.ts
|
|
@@ -7424,18 +8491,20 @@ const knownModelEnvKeys = [
|
|
|
7424
8491
|
"ANTHROPIC_SMALL_FAST_MODEL",
|
|
7425
8492
|
"CLAUDE_CODE_SUBAGENT_MODEL"
|
|
7426
8493
|
];
|
|
7427
|
-
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
8494
|
+
const isRecord$1 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
7428
8495
|
const hasMalformedModelOverride = (parsed) => {
|
|
7429
8496
|
if (parsed.model !== void 0 && typeof parsed.model !== "string") return true;
|
|
7430
8497
|
const env = parsed.env;
|
|
7431
|
-
if (!isRecord(env)) return false;
|
|
8498
|
+
if (!isRecord$1(env)) return false;
|
|
7432
8499
|
return knownModelEnvKeys.some((key) => env[key] !== void 0 && typeof env[key] !== "string");
|
|
7433
8500
|
};
|
|
7434
8501
|
const hasPrimaryModelOverride = (parsed, env) => typeof parsed.model === "string" || typeof env.ANTHROPIC_MODEL === "string";
|
|
7435
8502
|
/**
|
|
7436
8503
|
* Update `~/.claude/settings.json` so its env block points Claude Code at the
|
|
7437
8504
|
* running proxy. Preserves unrelated keys and model choices while normalizing
|
|
7438
|
-
* managed one-million-context GPT overrides. Sets
|
|
8505
|
+
* managed one-million-context GPT overrides. Sets the auth token to the relay's
|
|
8506
|
+
* apiKey when one is configured and then publishes the file owner-only;
|
|
8507
|
+
* otherwise keeps an existing token and writes a dummy value only if none is set.
|
|
7439
8508
|
*/
|
|
7440
8509
|
async function applyClaudeConfig(input) {
|
|
7441
8510
|
const { configPath: configPath$1, baseUrl, gptModel } = input;
|
|
@@ -7450,7 +8519,7 @@ async function applyClaudeConfig(input) {
|
|
|
7450
8519
|
};
|
|
7451
8520
|
if (raw.trim().length > 0) try {
|
|
7452
8521
|
const value = JSON.parse(raw);
|
|
7453
|
-
if (!isRecord(value) || value.env !== void 0 && !isRecord(value.env) || hasMalformedModelOverride(value)) return {
|
|
8522
|
+
if (!isRecord$1(value) || value.env !== void 0 && !isRecord$1(value.env) || hasMalformedModelOverride(value)) return {
|
|
7454
8523
|
configPath: configPath$1,
|
|
7455
8524
|
changed: false,
|
|
7456
8525
|
created: false
|
|
@@ -7463,7 +8532,7 @@ async function applyClaudeConfig(input) {
|
|
|
7463
8532
|
created: false
|
|
7464
8533
|
};
|
|
7465
8534
|
}
|
|
7466
|
-
const env = isRecord(parsed.env) ? { ...parsed.env } : {};
|
|
8535
|
+
const env = isRecord$1(parsed.env) ? { ...parsed.env } : {};
|
|
7467
8536
|
const previousBaseUrl = typeof env.ANTHROPIC_BASE_URL === "string" ? env.ANTHROPIC_BASE_URL : void 0;
|
|
7468
8537
|
const hadPrimaryModelOverride = hasPrimaryModelOverride(parsed, env);
|
|
7469
8538
|
const contextWindow = input.gptLimits?.max_context_window_tokens;
|
|
@@ -7472,9 +8541,11 @@ async function applyClaudeConfig(input) {
|
|
|
7472
8541
|
const nextModel = typeof parsed.model === "string" ? normalizeClaudeModelId(parsed.model, contextWindow) : void 0;
|
|
7473
8542
|
for (const key of knownModelEnvKeys) if (typeof env[key] === "string") env[key] = normalizeClaudeModelId(env[key], contextWindow);
|
|
7474
8543
|
env.ANTHROPIC_BASE_URL = baseUrl;
|
|
7475
|
-
if (
|
|
8544
|
+
if (input.apiKey) env.ANTHROPIC_AUTH_TOKEN = input.apiKey;
|
|
8545
|
+
else if (typeof env.ANTHROPIC_AUTH_TOKEN !== "string" || !env.ANTHROPIC_AUTH_TOKEN) env.ANTHROPIC_AUTH_TOKEN = "dummy";
|
|
7476
8546
|
if (contextWindow !== void 0 && env.CLAUDE_CODE_MAX_CONTEXT_TOKENS === void 0) env.CLAUDE_CODE_MAX_CONTEXT_TOKENS = String(contextWindow);
|
|
7477
8547
|
if (input.maxOutputTokens !== void 0 && env.CLAUDE_CODE_MAX_OUTPUT_TOKENS === void 0) env.CLAUDE_CODE_MAX_OUTPUT_TOKENS = String(input.maxOutputTokens);
|
|
8548
|
+
if (env.CLAUDE_CODE_AUTO_MODE_SERVER === void 0) env.CLAUDE_CODE_AUTO_MODE_SERVER = "0";
|
|
7478
8549
|
const next = {
|
|
7479
8550
|
...parsed,
|
|
7480
8551
|
...nextModel !== void 0 && { model: nextModel },
|
|
@@ -7482,13 +8553,15 @@ async function applyClaudeConfig(input) {
|
|
|
7482
8553
|
env
|
|
7483
8554
|
};
|
|
7484
8555
|
const serialized = `${JSON.stringify(next, null, 2)}\n`;
|
|
7485
|
-
|
|
8556
|
+
const writesKey = Boolean(input.apiKey);
|
|
8557
|
+
const keyExposed = writesKey && process.platform !== "win32" && (snapshot.mode & 63) !== 0;
|
|
8558
|
+
if (serialized === raw && !keyExposed) return {
|
|
7486
8559
|
configPath: configPath$1,
|
|
7487
8560
|
changed: false,
|
|
7488
8561
|
created: false,
|
|
7489
8562
|
previousBaseUrl
|
|
7490
8563
|
};
|
|
7491
|
-
await writeFileSnapshot(snapshot, serialized);
|
|
8564
|
+
await writeFileSnapshot(snapshot, serialized, { ownerOnly: writesKey });
|
|
7492
8565
|
return {
|
|
7493
8566
|
configPath: configPath$1,
|
|
7494
8567
|
changed: true,
|
|
@@ -7559,6 +8632,34 @@ const validateUpstream = async (config, thinkEffort) => {
|
|
|
7559
8632
|
//#endregion
|
|
7560
8633
|
//#region src/start.ts
|
|
7561
8634
|
const canCloseConnections = (server) => typeof server.closeIdleConnections === "function" && typeof server.closeAllConnections === "function";
|
|
8635
|
+
/**
|
|
8636
|
+
* The startup warning for a listener that other machines may reach without a key.
|
|
8637
|
+
*
|
|
8638
|
+
* A warning rather than a refusal, so an existing `host: 0.0.0.0` setup keeps
|
|
8639
|
+
* working after an upgrade. It names the apiKey setting and never prints a key.
|
|
8640
|
+
*/
|
|
8641
|
+
const unauthenticatedListenerWarning = (config) => {
|
|
8642
|
+
if (config.apiKey || isLoopbackHost(config.host)) return;
|
|
8643
|
+
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.`);
|
|
8644
|
+
};
|
|
8645
|
+
/**
|
|
8646
|
+
* The warning for a config.yaml that other local users can access while it holds the apiKey.
|
|
8647
|
+
*
|
|
8648
|
+
* A new config.yaml is created 0600, but an existing one keeps its mode, and the relay never
|
|
8649
|
+
* changes the mode of the user's own file. stat follows a symlink, so the mode checked is the
|
|
8650
|
+
* target's. Windows has no POSIX mode bits. It never prints a key.
|
|
8651
|
+
*/
|
|
8652
|
+
const exposedConfigWarning = (config, configPath$1 = paths.configPath) => {
|
|
8653
|
+
if (!config.apiKey || process.platform === "win32") return;
|
|
8654
|
+
let mode;
|
|
8655
|
+
try {
|
|
8656
|
+
mode = statSync(configPath$1).mode & 511;
|
|
8657
|
+
} catch {
|
|
8658
|
+
return;
|
|
8659
|
+
}
|
|
8660
|
+
if ((mode & 63) === 0) return;
|
|
8661
|
+
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}.`);
|
|
8662
|
+
};
|
|
7562
8663
|
async function startRelay(appConfig) {
|
|
7563
8664
|
appConfig ??= await readAppConfig();
|
|
7564
8665
|
appConfig = {
|
|
@@ -7568,10 +8669,12 @@ async function startRelay(appConfig) {
|
|
|
7568
8669
|
setLogLevel(appConfig.logLevel);
|
|
7569
8670
|
await cleanupLogs(appConfig.logRetentionDays);
|
|
7570
8671
|
await cleanupCaptures(appConfig.logRetentionDays);
|
|
8672
|
+
configureUpstreamDispatcher(appConfig.upstreamProxy);
|
|
7571
8673
|
const claudeConfigPath$1 = claudeConfigPath;
|
|
7572
8674
|
const config = readProxyConfig(appConfig);
|
|
7573
8675
|
const applyRuntimeConfig = (nextConfig) => {
|
|
7574
8676
|
registerSensitiveOrigin(nextConfig.copilotBaseUrl);
|
|
8677
|
+
registerLogSecret(nextConfig.apiKey);
|
|
7575
8678
|
setLogLevel(nextConfig.logLevel);
|
|
7576
8679
|
cleanupLogs(nextConfig.logRetentionDays).catch(() => log.error("Log retention failed; existing logs retained."));
|
|
7577
8680
|
cleanupCaptures(nextConfig.logRetentionDays);
|
|
@@ -7585,6 +8688,7 @@ async function startRelay(appConfig) {
|
|
|
7585
8688
|
config.upstreamTimeoutMs = nextConfig.upstreamTimeoutSeconds * 1e3;
|
|
7586
8689
|
config.webSearchBackend = nextConfig.webSearchBackend;
|
|
7587
8690
|
config.claudeUpstreamApi = nextConfig.claudeUpstreamApi;
|
|
8691
|
+
config.apiKey = nextConfig.apiKey;
|
|
7588
8692
|
runtimeState.modelRouting = {
|
|
7589
8693
|
gptModel: nextConfig.gptModel,
|
|
7590
8694
|
opusModel: nextConfig.opusModel
|
|
@@ -7594,6 +8698,11 @@ async function startRelay(appConfig) {
|
|
|
7594
8698
|
log.info(`Log level: ${appConfig.logLevel}`);
|
|
7595
8699
|
log.info(`Default think effort: ${appConfig.thinkEffort}`);
|
|
7596
8700
|
log.info(`Upstream timeout: ${appConfig.upstreamTimeoutSeconds}s`);
|
|
8701
|
+
if (appConfig.upstreamProxy !== void 0) log.info(`Upstream proxy: ${formatUpstreamProxyForDisplay(appConfig.upstreamProxy)}`);
|
|
8702
|
+
const listenerWarning = unauthenticatedListenerWarning(appConfig);
|
|
8703
|
+
if (listenerWarning) log.error(listenerWarning);
|
|
8704
|
+
const configWarning = exposedConfigWarning(appConfig);
|
|
8705
|
+
if (configWarning) log.error(configWarning);
|
|
7597
8706
|
const authSession = await setupProxyAuth(config);
|
|
7598
8707
|
try {
|
|
7599
8708
|
await validateUpstream(config, appConfig.thinkEffort);
|
|
@@ -7620,6 +8729,7 @@ async function startRelay(appConfig) {
|
|
|
7620
8729
|
const gptLimits = getCachedCopilotModel(config, appConfig.gptModel)?.limits;
|
|
7621
8730
|
const opusLimits = getCachedCopilotModel(config, appConfig.opusModel)?.limits;
|
|
7622
8731
|
const claudeResult = await applyClaudeConfig({
|
|
8732
|
+
apiKey: appConfig.apiKey,
|
|
7623
8733
|
baseUrl,
|
|
7624
8734
|
configPath: claudeConfigPath$1,
|
|
7625
8735
|
gptModel: appConfig.gptModel,
|
|
@@ -7634,8 +8744,11 @@ async function startRelay(appConfig) {
|
|
|
7634
8744
|
log.error(`Could not update claude settings (${claudeConfigPath$1}):`, error);
|
|
7635
8745
|
}
|
|
7636
8746
|
const configWatcher = watchAppConfig((nextConfig) => {
|
|
8747
|
+
const previousApiKey = config.apiKey;
|
|
7637
8748
|
applyRuntimeConfig(nextConfig);
|
|
7638
8749
|
log.info(`Config reloaded: logLevel=${nextConfig.logLevel} thinkEffort=${nextConfig.thinkEffort} upstreamTimeoutSeconds=${nextConfig.upstreamTimeoutSeconds}`);
|
|
8750
|
+
const reloadedConfigWarning = nextConfig.apiKey === previousApiKey ? void 0 : exposedConfigWarning(nextConfig);
|
|
8751
|
+
if (reloadedConfigWarning) log.error(reloadedConfigWarning);
|
|
7639
8752
|
});
|
|
7640
8753
|
const shutdownGraceMs = 2e3;
|
|
7641
8754
|
const shutdown = (signal) => {
|
|
@@ -7718,7 +8831,9 @@ const localProbeTimeoutMs = 5e3;
|
|
|
7718
8831
|
const deepProbeTimeoutMs = 6e4;
|
|
7719
8832
|
const toStatusConfig = (config) => ({
|
|
7720
8833
|
...config,
|
|
8834
|
+
apiKey: config.apiKey ? "[redacted]" : "",
|
|
7721
8835
|
copilotBaseUrl: formatUrlForDisplay(config.copilotBaseUrl),
|
|
8836
|
+
upstreamProxy: config.upstreamProxy === void 0 ? null : formatUpstreamProxyForDisplay(config.upstreamProxy),
|
|
7722
8837
|
webSearchBackend: config.webSearchBackend ?? null,
|
|
7723
8838
|
claudeUpstreamApi: config.claudeUpstreamApi ?? "chat-completions"
|
|
7724
8839
|
});
|
|
@@ -7772,27 +8887,35 @@ const formatUptime = (startedAt) => {
|
|
|
7772
8887
|
const configRowOrder = {
|
|
7773
8888
|
host: 0,
|
|
7774
8889
|
port: 1,
|
|
7775
|
-
|
|
7776
|
-
|
|
7777
|
-
|
|
7778
|
-
|
|
7779
|
-
|
|
7780
|
-
|
|
7781
|
-
|
|
7782
|
-
|
|
7783
|
-
|
|
7784
|
-
|
|
8890
|
+
apiKey: 2,
|
|
8891
|
+
copilotBaseUrl: 3,
|
|
8892
|
+
upstreamProxy: 4,
|
|
8893
|
+
claudeSetup: 5,
|
|
8894
|
+
logLevel: 6,
|
|
8895
|
+
logRetentionDays: 7,
|
|
8896
|
+
thinkEffort: 8,
|
|
8897
|
+
upstreamTimeoutSeconds: 9,
|
|
8898
|
+
webSearchBackend: 10,
|
|
8899
|
+
gptModel: 11,
|
|
8900
|
+
opusModel: 12,
|
|
8901
|
+
claudeUpstreamApi: 13
|
|
7785
8902
|
};
|
|
7786
8903
|
const configRowKeys = Object.keys(configRowOrder).sort((a, b) => configRowOrder[a] - configRowOrder[b]);
|
|
8904
|
+
const unsetConfigText = {
|
|
8905
|
+
apiKey: "(unset — clients need no key)",
|
|
8906
|
+
upstreamProxy: "(unset — connects directly)",
|
|
8907
|
+
webSearchBackend: "(unset — uses gptModel)"
|
|
8908
|
+
};
|
|
7787
8909
|
/**
|
|
7788
8910
|
* The resolved config, one key per line.
|
|
7789
8911
|
*
|
|
7790
8912
|
* These are the values on disk. That is not the same question as "what is the
|
|
7791
8913
|
* 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
|
-
*
|
|
8914
|
+
* implied: applyRuntimeConfig() in start.ts never reads claudeSetup, and
|
|
8915
|
+
* deliberately neither rebinds the listening socket when host or port changes
|
|
8916
|
+
* nor rebuilds the upstream dispatcher when upstreamProxy changes; it
|
|
8917
|
+
* hot-reloads the rest. Printing every key without that line would imply a
|
|
8918
|
+
* live daemon had read values it has not.
|
|
7796
8919
|
*
|
|
7797
8920
|
* Only shown when running, because with nothing up every value applies at the
|
|
7798
8921
|
* next start and the note would be noise.
|
|
@@ -7800,10 +8923,10 @@ const configRowKeys = Object.keys(configRowOrder).sort((a, b) => configRowOrder[
|
|
|
7800
8923
|
const renderConfig = (config, running) => {
|
|
7801
8924
|
const lines = configRowKeys.map((key) => {
|
|
7802
8925
|
const value = config[key];
|
|
7803
|
-
const rendered =
|
|
8926
|
+
const rendered = (value === null || value === "" ? unsetConfigText[key] : void 0) ?? String(value);
|
|
7804
8927
|
return ` ${key.padEnd(24)}${sanitizeTerminalString(rendered)}`;
|
|
7805
8928
|
});
|
|
7806
|
-
if (running) lines.push(" host, port and
|
|
8929
|
+
if (running) lines.push(" host, port, claudeSetup and upstreamProxy take effect on restart; the rest hot-reload.");
|
|
7807
8930
|
return lines;
|
|
7808
8931
|
};
|
|
7809
8932
|
/**
|
|
@@ -7831,9 +8954,26 @@ const renderStatus = (status$1, color = false) => {
|
|
|
7831
8954
|
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
8955
|
return lines;
|
|
7833
8956
|
};
|
|
8957
|
+
let relayAgent;
|
|
8958
|
+
/**
|
|
8959
|
+
* How the probes reach the relay's own listener: undici's fetch with a direct Agent of their own,
|
|
8960
|
+
* never the global dispatcher and never the upstream one. With NODE_USE_ENV_PROXY=1, Node's fetch
|
|
8961
|
+
* sends even a request to localhost through HTTP_PROXY unless NO_PROXY exempts it, so through a
|
|
8962
|
+
* proxy that refuses, status would report a healthy relay as unusable. upstreamProxy is for
|
|
8963
|
+
* Copilot and GitHub, not for this machine.
|
|
8964
|
+
*
|
|
8965
|
+
* An object, so a test can replace fetch.
|
|
8966
|
+
*/
|
|
8967
|
+
const relayListener = { fetch: (url, init) => {
|
|
8968
|
+
relayAgent ??= new Agent();
|
|
8969
|
+
return fetch(url, {
|
|
8970
|
+
...init,
|
|
8971
|
+
dispatcher: relayAgent
|
|
8972
|
+
});
|
|
8973
|
+
} };
|
|
7834
8974
|
const probe = async (url, init, timeoutMs) => {
|
|
7835
8975
|
const started = performance.now();
|
|
7836
|
-
const response = await fetch(url, {
|
|
8976
|
+
const response = await relayListener.fetch(url, {
|
|
7837
8977
|
...init,
|
|
7838
8978
|
signal: AbortSignal.timeout(timeoutMs)
|
|
7839
8979
|
});
|
|
@@ -7878,9 +9018,10 @@ const checkHealth = async (baseUrl) => {
|
|
|
7878
9018
|
} };
|
|
7879
9019
|
}
|
|
7880
9020
|
};
|
|
7881
|
-
const
|
|
9021
|
+
const relayKeyHeaders = (apiKey) => apiKey ? { "x-api-key": apiKey } : {};
|
|
9022
|
+
const readModels = async (baseUrl, apiKey) => {
|
|
7882
9023
|
try {
|
|
7883
|
-
const data = (await probe(`${baseUrl}/v1/models`, {}, localProbeTimeoutMs)).body?.data;
|
|
9024
|
+
const data = (await probe(`${baseUrl}/v1/models`, { headers: relayKeyHeaders(apiKey) }, localProbeTimeoutMs)).body?.data;
|
|
7884
9025
|
return Array.isArray(data) ? data.flatMap((model) => typeof model.id === "string" ? [model.id] : []) : [];
|
|
7885
9026
|
} catch {
|
|
7886
9027
|
return [];
|
|
@@ -7892,7 +9033,7 @@ const readModels = async (baseUrl) => {
|
|
|
7892
9033
|
* Opt-in because it spends tokens. It is the only check that exercises token
|
|
7893
9034
|
* refresh, the upstream call, and translation back to Claude shape.
|
|
7894
9035
|
*/
|
|
7895
|
-
const checkDeep = async (baseUrl, model) => {
|
|
9036
|
+
const checkDeep = async (baseUrl, model, apiKey) => {
|
|
7896
9037
|
try {
|
|
7897
9038
|
const result = await probe(`${baseUrl}/v1/messages`, {
|
|
7898
9039
|
body: JSON.stringify({
|
|
@@ -7905,7 +9046,8 @@ const checkDeep = async (baseUrl, model) => {
|
|
|
7905
9046
|
}),
|
|
7906
9047
|
headers: {
|
|
7907
9048
|
"anthropic-version": "2023-06-01",
|
|
7908
|
-
"content-type": "application/json"
|
|
9049
|
+
"content-type": "application/json",
|
|
9050
|
+
...relayKeyHeaders(apiKey)
|
|
7909
9051
|
},
|
|
7910
9052
|
method: "POST"
|
|
7911
9053
|
}, deepProbeTimeoutMs);
|
|
@@ -7948,6 +9090,7 @@ const collectStatus = async (options) => {
|
|
|
7948
9090
|
});
|
|
7949
9091
|
setLogLevel(appConfig.logLevel);
|
|
7950
9092
|
registerSensitiveOrigin(appConfig.copilotBaseUrl);
|
|
9093
|
+
registerLogSecret(appConfig.apiKey);
|
|
7951
9094
|
const base = {
|
|
7952
9095
|
config: toStatusConfig(appConfig),
|
|
7953
9096
|
configPath: paths.configPath,
|
|
@@ -7963,8 +9106,8 @@ const collectStatus = async (options) => {
|
|
|
7963
9106
|
};
|
|
7964
9107
|
const baseUrl = getRelayBaseUrl(relay.host, relay.port);
|
|
7965
9108
|
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;
|
|
9109
|
+
const models$1 = health.result.ok ? await readModels(baseUrl, appConfig.apiKey) : [];
|
|
9110
|
+
const deep = options.deep && health.result.ok ? await checkDeep(baseUrl, models$1[0] ?? appConfig.gptModel, appConfig.apiKey) : void 0;
|
|
7968
9111
|
const daemonVersion = health.version ?? relay.version;
|
|
7969
9112
|
return {
|
|
7970
9113
|
...base,
|
|
@@ -8009,8 +9152,8 @@ const status = defineCommand({
|
|
|
8009
9152
|
process.exitCode = exitCodes.notUsable;
|
|
8010
9153
|
return;
|
|
8011
9154
|
}
|
|
8012
|
-
if (args.json) console.log(scrubSensitiveUrls(JSON.stringify(result, null, 2)));
|
|
8013
|
-
else console.log(scrubSensitiveUrls(renderStatus(result, colorEnabled()).join("\n")));
|
|
9155
|
+
if (args.json) console.log(scrubLogSecrets(scrubSensitiveUrls(withoutHiddenCharacters(JSON.stringify(result, null, 2)))));
|
|
9156
|
+
else console.log(scrubLogSecrets(scrubSensitiveUrls(withoutHiddenCharacters(renderStatus(result, colorEnabled()).join("\n")))));
|
|
8014
9157
|
process.exitCode = resolveExitCode(result);
|
|
8015
9158
|
}
|
|
8016
9159
|
});
|
|
@@ -8037,6 +9180,225 @@ const stop = defineCommand({
|
|
|
8037
9180
|
}
|
|
8038
9181
|
});
|
|
8039
9182
|
|
|
9183
|
+
//#endregion
|
|
9184
|
+
//#region src/lib/usage.ts
|
|
9185
|
+
const knownQuotaIds = [
|
|
9186
|
+
"chat",
|
|
9187
|
+
"completions",
|
|
9188
|
+
"premium_interactions"
|
|
9189
|
+
];
|
|
9190
|
+
/** A failure the command reports. The message is one printable line and never holds the token. */
|
|
9191
|
+
var CopilotUsageError = class extends Error {};
|
|
9192
|
+
const notAnObject = "GitHub's answer to the usage request was not a JSON object.";
|
|
9193
|
+
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
9194
|
+
const stringField = (value) => typeof value === "string" ? terminalText(value) : null;
|
|
9195
|
+
const numberField = (value) => typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
9196
|
+
const booleanField = (value) => typeof value === "boolean" ? value : null;
|
|
9197
|
+
const parseSnapshot = (value) => {
|
|
9198
|
+
if (!isRecord(value)) return null;
|
|
9199
|
+
return {
|
|
9200
|
+
unlimited: booleanField(value.unlimited),
|
|
9201
|
+
entitlement: numberField(value.entitlement),
|
|
9202
|
+
remaining: numberField(value.remaining),
|
|
9203
|
+
percent_remaining: numberField(value.percent_remaining),
|
|
9204
|
+
overage_permitted: booleanField(value.overage_permitted),
|
|
9205
|
+
overage_count: numberField(value.overage_count),
|
|
9206
|
+
token_based_billing: booleanField(value.token_based_billing),
|
|
9207
|
+
credits_used: numberField(value.credits_used)
|
|
9208
|
+
};
|
|
9209
|
+
};
|
|
9210
|
+
/**
|
|
9211
|
+
* The plan and quota fields of a copilot_internal/user answer, each checked on its own.
|
|
9212
|
+
*
|
|
9213
|
+
* Throws CopilotUsageError when the answer is not a JSON object at all.
|
|
9214
|
+
*/
|
|
9215
|
+
const parseCopilotUsage = (body) => {
|
|
9216
|
+
if (!isRecord(body)) throw new CopilotUsageError(notAnObject);
|
|
9217
|
+
const quota = body.quota_snapshots;
|
|
9218
|
+
const reported = isRecord(quota) ? quota : {};
|
|
9219
|
+
const snapshots = /* @__PURE__ */ new Map();
|
|
9220
|
+
for (const id of knownQuotaIds) snapshots.set(id, parseSnapshot(reported[id]));
|
|
9221
|
+
for (const [rawId, value] of Object.entries(reported)) {
|
|
9222
|
+
const id = terminalText(rawId);
|
|
9223
|
+
if (id === "" || snapshots.has(id)) continue;
|
|
9224
|
+
snapshots.set(id, parseSnapshot(value));
|
|
9225
|
+
}
|
|
9226
|
+
return {
|
|
9227
|
+
copilot_plan: stringField(body.copilot_plan),
|
|
9228
|
+
access_type_sku: stringField(body.access_type_sku),
|
|
9229
|
+
quota_reset_date: stringField(body.quota_reset_date),
|
|
9230
|
+
quota_snapshots: Object.fromEntries(snapshots)
|
|
9231
|
+
};
|
|
9232
|
+
};
|
|
9233
|
+
const notReported = "not reported";
|
|
9234
|
+
const formatNumber = (value) => value === null ? "?" : String(value);
|
|
9235
|
+
const formatAllowance = (snapshot) => {
|
|
9236
|
+
if (snapshot.unlimited === true) return "unlimited";
|
|
9237
|
+
return `${`${formatNumber(snapshot.remaining)} of ${formatNumber(snapshot.entitlement)} remaining`} (${formatNumber(snapshot.percent_remaining)}%)`;
|
|
9238
|
+
};
|
|
9239
|
+
const formatOverage = (snapshot) => {
|
|
9240
|
+
let permitted = "overage ?";
|
|
9241
|
+
if (snapshot.overage_permitted === true) permitted = "overage permitted";
|
|
9242
|
+
else if (snapshot.overage_permitted === false) permitted = "overage not permitted";
|
|
9243
|
+
return `${permitted}, overage count ${formatNumber(snapshot.overage_count)}`;
|
|
9244
|
+
};
|
|
9245
|
+
const formatQuota = (snapshot) => {
|
|
9246
|
+
const parts = [formatAllowance(snapshot), formatOverage(snapshot)];
|
|
9247
|
+
if (snapshot.credits_used !== null) parts.push(`credits used ${formatNumber(snapshot.credits_used)}`);
|
|
9248
|
+
return parts.join("; ");
|
|
9249
|
+
};
|
|
9250
|
+
/** The report as text lines. Kept free of IO, so it is tested without a terminal. */
|
|
9251
|
+
const renderCopilotUsage = (usage$1, color = false) => {
|
|
9252
|
+
const shown = (value) => value ?? colorText(notReported, "muted", color);
|
|
9253
|
+
const headings = [
|
|
9254
|
+
["Plan", usage$1.copilot_plan],
|
|
9255
|
+
["SKU", usage$1.access_type_sku],
|
|
9256
|
+
["Quota reset", usage$1.quota_reset_date]
|
|
9257
|
+
];
|
|
9258
|
+
const labelWidth = Math.max(...headings.map(([label]) => label.length));
|
|
9259
|
+
const known = [...knownQuotaIds];
|
|
9260
|
+
const ids = [...known, ...Object.keys(usage$1.quota_snapshots).filter((id) => !known.includes(id))];
|
|
9261
|
+
const idWidth = Math.max(...ids.map((id) => id.length));
|
|
9262
|
+
const quotaLines = ids.map((id) => {
|
|
9263
|
+
const snapshot = usage$1.quota_snapshots[id] ?? null;
|
|
9264
|
+
const text$1 = snapshot === null ? colorText(notReported, "muted", color) : formatQuota(snapshot);
|
|
9265
|
+
return `${id.padEnd(idWidth)} ${text$1}`;
|
|
9266
|
+
});
|
|
9267
|
+
return [
|
|
9268
|
+
...headings.map(([label, value]) => `${label.padEnd(labelWidth)} ${shown(value)}`),
|
|
9269
|
+
"",
|
|
9270
|
+
...quotaLines
|
|
9271
|
+
];
|
|
9272
|
+
};
|
|
9273
|
+
const requestTimeoutSeconds = 30;
|
|
9274
|
+
const tokenInAnswer = "GitHub's answer to the usage request contains the stored token, so none of it is printed.";
|
|
9275
|
+
const visibleToken = (token) => terminalText(token);
|
|
9276
|
+
const withoutToken = (line, token) => {
|
|
9277
|
+
const visible = visibleToken(token);
|
|
9278
|
+
return visible === "" ? line : line.replaceAll(visible, "[redacted]");
|
|
9279
|
+
};
|
|
9280
|
+
const lettersAndDigits = (text$1) => text$1.replace(/[^A-Za-z0-9]/g, "");
|
|
9281
|
+
const minimumSpelledLength = 16;
|
|
9282
|
+
const spellsToken = (text$1, token) => {
|
|
9283
|
+
const spelled = lettersAndDigits(visibleToken(token));
|
|
9284
|
+
return spelled.length >= minimumSpelledLength && lettersAndDigits(text$1).includes(spelled);
|
|
9285
|
+
};
|
|
9286
|
+
const showsToken = (usage$1, token) => {
|
|
9287
|
+
const visible = visibleToken(token);
|
|
9288
|
+
const printed = [
|
|
9289
|
+
usage$1.copilot_plan,
|
|
9290
|
+
usage$1.access_type_sku,
|
|
9291
|
+
usage$1.quota_reset_date,
|
|
9292
|
+
...Object.keys(usage$1.quota_snapshots)
|
|
9293
|
+
].filter((text$1) => text$1 !== null);
|
|
9294
|
+
if (visible !== "" && printed.some((text$1) => text$1.includes(visible))) return true;
|
|
9295
|
+
return spellsToken(printed.join(""), token);
|
|
9296
|
+
};
|
|
9297
|
+
const failureReason = (error) => {
|
|
9298
|
+
const cause = error instanceof Error ? error.cause : void 0;
|
|
9299
|
+
if (cause instanceof Error && cause.message !== "") return cause.message;
|
|
9300
|
+
const code = cause?.code;
|
|
9301
|
+
if (typeof code === "string") return code;
|
|
9302
|
+
return error instanceof Error && error.message !== "" ? error.message : String(error);
|
|
9303
|
+
};
|
|
9304
|
+
const isTimeout = (error) => typeof error === "object" && error !== null && "name" in error && error.name === "TimeoutError";
|
|
9305
|
+
const describeRequestFailure = async (error, token) => {
|
|
9306
|
+
if (error instanceof HTTPError) {
|
|
9307
|
+
await error.response.body?.cancel().catch(() => {});
|
|
9308
|
+
const status$1 = error.response.status;
|
|
9309
|
+
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.`);
|
|
9310
|
+
return new CopilotUsageError(`GitHub answered the usage request with HTTP ${status$1}.`);
|
|
9311
|
+
}
|
|
9312
|
+
if (isTimeout(error)) return new CopilotUsageError(`GitHub did not answer within ${requestTimeoutSeconds} seconds.`);
|
|
9313
|
+
if (error instanceof SyntaxError) return new CopilotUsageError(notAnObject);
|
|
9314
|
+
const line = withoutToken(terminalText(`Could not reach GitHub: ${failureReason(error)}`), token);
|
|
9315
|
+
return new CopilotUsageError(spellsToken(line, token) ? "Could not reach GitHub." : line);
|
|
9316
|
+
};
|
|
9317
|
+
const readToken = async () => {
|
|
9318
|
+
let token;
|
|
9319
|
+
try {
|
|
9320
|
+
token = await readStoredGitHubToken();
|
|
9321
|
+
} catch (error) {
|
|
9322
|
+
const code = error?.code;
|
|
9323
|
+
const reason = typeof code === "string" ? `: ${code}` : "";
|
|
9324
|
+
throw new CopilotUsageError(terminalText(`Could not read the GitHub token at ${paths.githubTokenPath}${reason}.`));
|
|
9325
|
+
}
|
|
9326
|
+
if (token === void 0) throw new CopilotUsageError(terminalText(`No GitHub token is stored at ${paths.githubTokenPath}. Sign in with copilot-relay auth.`));
|
|
9327
|
+
return token;
|
|
9328
|
+
};
|
|
9329
|
+
/**
|
|
9330
|
+
* Builds this process's upstream dispatcher from upstreamProxy, as start, auth and models do, but
|
|
9331
|
+
* reads config.yaml without creating, completing or rewriting it. Without a config.yaml the
|
|
9332
|
+
* request connects directly.
|
|
9333
|
+
*
|
|
9334
|
+
* A config.yaml that cannot be read or is invalid gets one fixed line, whatever the reason, so no
|
|
9335
|
+
* text from that file can reach the terminal: it can hold credentials, such as an upstreamProxy
|
|
9336
|
+
* password.
|
|
9337
|
+
*/
|
|
9338
|
+
const applyUpstreamProxy = async () => {
|
|
9339
|
+
let upstreamProxy;
|
|
9340
|
+
try {
|
|
9341
|
+
upstreamProxy = (await readExistingAppConfig())?.upstreamProxy;
|
|
9342
|
+
} catch {
|
|
9343
|
+
throw new CopilotUsageError(terminalText(`Could not read the config at ${paths.configPath}; fix it, then run copilot-relay usage again.`));
|
|
9344
|
+
}
|
|
9345
|
+
try {
|
|
9346
|
+
configureUpstreamDispatcher(upstreamProxy);
|
|
9347
|
+
} catch (error) {
|
|
9348
|
+
if (error instanceof InvalidProxyEnvironmentError) throw new CopilotUsageError(error.message);
|
|
9349
|
+
throw error;
|
|
9350
|
+
}
|
|
9351
|
+
};
|
|
9352
|
+
const requestCopilotUsage = async () => {
|
|
9353
|
+
const token = await readToken();
|
|
9354
|
+
await applyUpstreamProxy();
|
|
9355
|
+
let body;
|
|
9356
|
+
try {
|
|
9357
|
+
body = await getCopilotUsage(token, vscodeVersion, AbortSignal.timeout(requestTimeoutSeconds * 1e3));
|
|
9358
|
+
} catch (error) {
|
|
9359
|
+
throw await describeRequestFailure(error, token);
|
|
9360
|
+
}
|
|
9361
|
+
const usage$1 = parseCopilotUsage(body);
|
|
9362
|
+
if (showsToken(usage$1, token)) throw new CopilotUsageError(tokenInAnswer);
|
|
9363
|
+
return usage$1;
|
|
9364
|
+
};
|
|
9365
|
+
/**
|
|
9366
|
+
* The Copilot plan and quota of the stored GitHub token's account.
|
|
9367
|
+
*
|
|
9368
|
+
* Reads the token file and config.yaml, and asks GitHub through upstreamProxy; writes nothing.
|
|
9369
|
+
* Logging is off throughout, so a line the upstream request would log, such as the hint about an
|
|
9370
|
+
* ignored proxy variable, never reaches the log file. Every failure becomes a CopilotUsageError
|
|
9371
|
+
* whose message is the line to print.
|
|
9372
|
+
*/
|
|
9373
|
+
const loadCopilotUsage = () => withoutLogging(requestCopilotUsage);
|
|
9374
|
+
|
|
9375
|
+
//#endregion
|
|
9376
|
+
//#region src/usage.ts
|
|
9377
|
+
const usage = defineCommand({
|
|
9378
|
+
meta: {
|
|
9379
|
+
name: "usage",
|
|
9380
|
+
description: "Show the Copilot plan and quota GitHub reports for the stored GitHub token."
|
|
9381
|
+
},
|
|
9382
|
+
args: { json: {
|
|
9383
|
+
description: "Print the plan and quota fields as a JSON object.",
|
|
9384
|
+
type: "boolean"
|
|
9385
|
+
} },
|
|
9386
|
+
async run({ args }) {
|
|
9387
|
+
try {
|
|
9388
|
+
const report = await loadCopilotUsage();
|
|
9389
|
+
if (args.json) console.log(JSON.stringify(report, null, 2));
|
|
9390
|
+
else console.log(renderCopilotUsage(report, colorEnabled()).join("\n"));
|
|
9391
|
+
} catch (error) {
|
|
9392
|
+
if (error instanceof CopilotUsageError) console.error(error.message);
|
|
9393
|
+
else {
|
|
9394
|
+
const name = error instanceof Error ? error.name : typeof error;
|
|
9395
|
+
console.error(terminalText(`Could not show the Copilot usage: unexpected ${name}.`));
|
|
9396
|
+
}
|
|
9397
|
+
process.exitCode = 1;
|
|
9398
|
+
}
|
|
9399
|
+
}
|
|
9400
|
+
});
|
|
9401
|
+
|
|
8040
9402
|
//#endregion
|
|
8041
9403
|
//#region src/main.ts
|
|
8042
9404
|
await runMain(defineCommand({
|
|
@@ -8046,12 +9408,14 @@ await runMain(defineCommand({
|
|
|
8046
9408
|
},
|
|
8047
9409
|
subCommands: {
|
|
8048
9410
|
auth,
|
|
9411
|
+
cache,
|
|
8049
9412
|
models,
|
|
8050
9413
|
replay,
|
|
8051
9414
|
restart,
|
|
8052
9415
|
start,
|
|
8053
9416
|
status,
|
|
8054
|
-
stop
|
|
9417
|
+
stop,
|
|
9418
|
+
usage
|
|
8055
9419
|
}
|
|
8056
9420
|
}));
|
|
8057
9421
|
|