copilot-relay 0.4.4 → 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 +1618 -219
- 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();
|
|
@@ -831,6 +1089,7 @@ const normalizeClaudeModelId = (model, contextWindowTokens) => {
|
|
|
831
1089
|
return contextWindowTokens !== void 0 && contextWindowTokens !== 1e6 ? normalized : `${normalized}[1m]`;
|
|
832
1090
|
};
|
|
833
1091
|
const normalizeCopilotModelId = (model) => normalizeOneMillionContextModel(model) ?? model;
|
|
1092
|
+
const isClaudeModelId = (model) => model.startsWith("claude-");
|
|
834
1093
|
const getConfiguredModelRouting = () => getRuntimeState().modelRouting ?? defaultModelRouting;
|
|
835
1094
|
const getModelRouting = () => {
|
|
836
1095
|
const routing = getConfiguredModelRouting();
|
|
@@ -860,7 +1119,13 @@ var FileConflictError = class extends Error {
|
|
|
860
1119
|
this.name = "FileConflictError";
|
|
861
1120
|
}
|
|
862
1121
|
};
|
|
863
|
-
|
|
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";
|
|
864
1129
|
const identityOf = (stat) => ({
|
|
865
1130
|
dev: stat.dev,
|
|
866
1131
|
ino: stat.ino,
|
|
@@ -873,9 +1138,9 @@ const resolveTarget = async (filePath, remainingLinks = 40) => {
|
|
|
873
1138
|
try {
|
|
874
1139
|
return await fs.realpath(filePath);
|
|
875
1140
|
} catch (error) {
|
|
876
|
-
if (!isMissing(error)) throw error;
|
|
1141
|
+
if (!isMissing$1(error)) throw error;
|
|
877
1142
|
if ((await fs.lstat(filePath).catch((cause) => {
|
|
878
|
-
if (!isMissing(cause)) throw cause;
|
|
1143
|
+
if (!isMissing$1(cause)) throw cause;
|
|
879
1144
|
}))?.isSymbolicLink()) {
|
|
880
1145
|
if (remainingLinks === 0) throw new Error("Too many symbolic links");
|
|
881
1146
|
return resolveTarget(path.resolve(path.dirname(filePath), await fs.readlink(filePath)), remainingLinks - 1);
|
|
@@ -889,7 +1154,7 @@ const readStableSnapshot = async (filePath) => {
|
|
|
889
1154
|
const requestedPath = path.resolve(filePath);
|
|
890
1155
|
const resolvedPath = await resolveTarget(requestedPath);
|
|
891
1156
|
const before = await fs.lstat(resolvedPath).catch((error) => {
|
|
892
|
-
if (!isMissing(error)) throw error;
|
|
1157
|
+
if (!isMissing$1(error)) throw error;
|
|
893
1158
|
});
|
|
894
1159
|
if (!before) return {
|
|
895
1160
|
requestedPath,
|
|
@@ -898,14 +1163,14 @@ const readStableSnapshot = async (filePath) => {
|
|
|
898
1163
|
mode: 384,
|
|
899
1164
|
identity: null
|
|
900
1165
|
};
|
|
901
|
-
if (!before.isFile()) throw new
|
|
1166
|
+
if (!before.isFile()) throw new NotRegularFileError(resolvedPath);
|
|
902
1167
|
let raw;
|
|
903
1168
|
let after;
|
|
904
1169
|
try {
|
|
905
1170
|
raw = await fs.readFile(resolvedPath, "utf8");
|
|
906
1171
|
after = await fs.lstat(resolvedPath);
|
|
907
1172
|
} catch (error) {
|
|
908
|
-
if (isMissing(error)) throw new FileConflictError();
|
|
1173
|
+
if (isMissing$1(error)) throw new FileConflictError();
|
|
909
1174
|
throw error;
|
|
910
1175
|
}
|
|
911
1176
|
const snapshot = {
|
|
@@ -954,7 +1219,7 @@ const assertUnchanged = async (expected) => {
|
|
|
954
1219
|
if (current.resolvedPath !== expected.resolvedPath || current.raw !== expected.raw || current.mode !== expected.mode || !sameIdentity(current.identity, expected.identity)) throw new FileConflictError();
|
|
955
1220
|
};
|
|
956
1221
|
const pendingWrites = /* @__PURE__ */ new Map();
|
|
957
|
-
const publish = async (snapshot, content) => {
|
|
1222
|
+
const publish = async (snapshot, content, options) => {
|
|
958
1223
|
await assertUnchanged(snapshot);
|
|
959
1224
|
const directory = path.dirname(snapshot.resolvedPath);
|
|
960
1225
|
await fs.mkdir(directory, {
|
|
@@ -967,7 +1232,7 @@ const publish = async (snapshot, content) => {
|
|
|
967
1232
|
try {
|
|
968
1233
|
try {
|
|
969
1234
|
await handle.writeFile(content, "utf8");
|
|
970
|
-
if (process.platform !== "win32") await handle.chmod(snapshot.mode);
|
|
1235
|
+
if (process.platform !== "win32") await handle.chmod(options.ownerOnly ? 384 : snapshot.mode);
|
|
971
1236
|
await handle.sync();
|
|
972
1237
|
} finally {
|
|
973
1238
|
await handle.close();
|
|
@@ -984,8 +1249,8 @@ const publish = async (snapshot, content) => {
|
|
|
984
1249
|
await fs.rm(temporaryPath, { force: true });
|
|
985
1250
|
}
|
|
986
1251
|
};
|
|
987
|
-
const writeFileSnapshot = async (snapshot, content) => {
|
|
988
|
-
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));
|
|
989
1254
|
pendingWrites.set(snapshot.resolvedPath, current);
|
|
990
1255
|
try {
|
|
991
1256
|
await current;
|
|
@@ -994,6 +1259,29 @@ const writeFileSnapshot = async (snapshot, content) => {
|
|
|
994
1259
|
}
|
|
995
1260
|
};
|
|
996
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
|
+
|
|
997
1285
|
//#endregion
|
|
998
1286
|
//#region src/lib/app-config.ts
|
|
999
1287
|
const logLevels = [
|
|
@@ -1010,6 +1298,7 @@ const logLevels = [
|
|
|
1010
1298
|
* rewrite a value the user's config already holds.
|
|
1011
1299
|
*/
|
|
1012
1300
|
const defaultConfig = {
|
|
1301
|
+
apiKey: "",
|
|
1013
1302
|
claudeSetup: true,
|
|
1014
1303
|
copilotBaseUrl: "https://api.githubcopilot.com",
|
|
1015
1304
|
gptModel: "gpt-6-astra",
|
|
@@ -1021,6 +1310,7 @@ const defaultConfig = {
|
|
|
1021
1310
|
thinkEffort: defaultReasoningEffort,
|
|
1022
1311
|
upstreamTimeoutSeconds: 180,
|
|
1023
1312
|
webSearchBackend: void 0,
|
|
1313
|
+
upstreamProxy: void 0,
|
|
1024
1314
|
claudeUpstreamApi: "chat-completions"
|
|
1025
1315
|
};
|
|
1026
1316
|
const isLogLevelName = (value) => typeof value === "string" && logLevels.includes(value.toLowerCase());
|
|
@@ -1028,7 +1318,7 @@ const normalizeLogLevel = (value) => {
|
|
|
1028
1318
|
if (value === void 0) return;
|
|
1029
1319
|
if (typeof value !== "string") throw new Error(`Invalid logLevel: expected one of ${logLevels.join(", ")}`);
|
|
1030
1320
|
const normalized = value.toLowerCase();
|
|
1031
|
-
if (!isLogLevelName(normalized)) throw new Error(`Invalid logLevel
|
|
1321
|
+
if (!isLogLevelName(normalized)) throw new Error(`Invalid logLevel: expected one of ${logLevels.join(", ")}`);
|
|
1032
1322
|
return normalized;
|
|
1033
1323
|
};
|
|
1034
1324
|
const normalizeClaudeUpstreamApi = (value) => {
|
|
@@ -1058,6 +1348,22 @@ const normalizeRequiredString = (value, key) => {
|
|
|
1058
1348
|
if (normalized === void 0) throw new Error(`Invalid ${key}: expected a non-empty string`);
|
|
1059
1349
|
return normalized;
|
|
1060
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
|
+
};
|
|
1061
1367
|
/**
|
|
1062
1368
|
* A conventional http(s) URL: scheme followed by a literal "//" authority.
|
|
1063
1369
|
*
|
|
@@ -1139,6 +1445,49 @@ const normalizeCopilotBaseUrl = (value) => {
|
|
|
1139
1445
|
if (parsed.username || parsed.password) throw new Error(copilotBaseUrlCredentialsMessage);
|
|
1140
1446
|
return trimmed;
|
|
1141
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
|
+
};
|
|
1142
1491
|
var InvalidThinkEffortError = class extends Error {
|
|
1143
1492
|
constructor() {
|
|
1144
1493
|
super(`Invalid thinkEffort. Valid values: ${configurableReasoningEfforts.join(", ")}. "none" is not allowed as a configured default.`);
|
|
@@ -1179,6 +1528,7 @@ const readDefaultConfigTemplate = async () => {
|
|
|
1179
1528
|
}
|
|
1180
1529
|
};
|
|
1181
1530
|
const configAliases = {
|
|
1531
|
+
api_key: "apiKey",
|
|
1182
1532
|
claude_setup: "claudeSetup",
|
|
1183
1533
|
copilot_base_url: "copilotBaseUrl",
|
|
1184
1534
|
gpt_model: "gptModel",
|
|
@@ -1186,6 +1536,7 @@ const configAliases = {
|
|
|
1186
1536
|
log_retention_days: "logRetentionDays",
|
|
1187
1537
|
opus_model: "opusModel",
|
|
1188
1538
|
think_effort: "thinkEffort",
|
|
1539
|
+
upstream_proxy: "upstreamProxy",
|
|
1189
1540
|
upstream_timeout_seconds: "upstreamTimeoutSeconds",
|
|
1190
1541
|
web_search_backend: "webSearchBackend",
|
|
1191
1542
|
claude_upstream_api: "claudeUpstreamApi"
|
|
@@ -1237,7 +1588,7 @@ const serializeConfig = (config) => [
|
|
|
1237
1588
|
"# copilot-relay configuration",
|
|
1238
1589
|
"#",
|
|
1239
1590
|
"# Valid complete edits hot-reload without rewriting this file.",
|
|
1240
|
-
"# host, port, and
|
|
1591
|
+
"# host, port, claudeSetup, and upstreamProxy require restart.",
|
|
1241
1592
|
"",
|
|
1242
1593
|
"# Local host for the Claude Code-compatible HTTP server.",
|
|
1243
1594
|
`host: ${config.host}`,
|
|
@@ -1245,9 +1596,17 @@ const serializeConfig = (config) => [
|
|
|
1245
1596
|
"# Local port for the HTTP server.",
|
|
1246
1597
|
`port: ${config.port}`,
|
|
1247
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
|
+
"",
|
|
1248
1603
|
"# GitHub Copilot API base URL.",
|
|
1249
1604
|
`copilotBaseUrl: ${config.copilotBaseUrl}`,
|
|
1250
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
|
+
"",
|
|
1251
1610
|
"# Update ~/.claude/settings.json on start.",
|
|
1252
1611
|
`claudeSetup: ${config.claudeSetup}`,
|
|
1253
1612
|
"",
|
|
@@ -1282,6 +1641,7 @@ const serializeConfig = (config) => [
|
|
|
1282
1641
|
""
|
|
1283
1642
|
].join("\n");
|
|
1284
1643
|
const resolveConfig = (raw) => ({
|
|
1644
|
+
apiKey: normalizeApiKey(raw.apiKey) ?? defaultConfig.apiKey,
|
|
1285
1645
|
claudeSetup: normalizeBoolean(raw.claudeSetup) ?? defaultConfig.claudeSetup,
|
|
1286
1646
|
copilotBaseUrl: normalizeCopilotBaseUrl(normalizeRequiredString(raw.copilotBaseUrl, "copilotBaseUrl")) ?? defaultConfig.copilotBaseUrl,
|
|
1287
1647
|
gptModel: normalizeRequiredString(raw.gptModel, "gptModel") ?? defaultConfig.gptModel,
|
|
@@ -1293,6 +1653,7 @@ const resolveConfig = (raw) => ({
|
|
|
1293
1653
|
thinkEffort: normalizeThinkEffort(raw.thinkEffort) ?? defaultConfig.thinkEffort,
|
|
1294
1654
|
upstreamTimeoutSeconds: normalizeUpstreamTimeoutSeconds(raw.upstreamTimeoutSeconds) ?? defaultConfig.upstreamTimeoutSeconds,
|
|
1295
1655
|
webSearchBackend: normalizeString(raw.webSearchBackend),
|
|
1656
|
+
upstreamProxy: normalizeUpstreamProxy(raw.upstreamProxy),
|
|
1296
1657
|
claudeUpstreamApi: normalizeClaudeUpstreamApi(raw.claudeUpstreamApi) ?? defaultConfig.claudeUpstreamApi
|
|
1297
1658
|
});
|
|
1298
1659
|
async function readAppConfig() {
|
|
@@ -1304,26 +1665,64 @@ async function readAppConfig() {
|
|
|
1304
1665
|
if (content !== snapshot.raw) await writeFileSnapshot(snapshot, content);
|
|
1305
1666
|
return config;
|
|
1306
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
|
+
};
|
|
1307
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
|
+
};
|
|
1308
1693
|
const watchAppConfig = (onReload) => {
|
|
1309
1694
|
let lastSnapshot;
|
|
1695
|
+
let reportedFailure;
|
|
1310
1696
|
let reloading = false;
|
|
1311
1697
|
const timer = setInterval(async () => {
|
|
1312
1698
|
if (reloading) return;
|
|
1313
1699
|
reloading = true;
|
|
1700
|
+
let snapshot;
|
|
1314
1701
|
try {
|
|
1315
|
-
|
|
1316
|
-
if (
|
|
1702
|
+
snapshot = await readConfigSnapshot();
|
|
1703
|
+
if (!snapshot) return;
|
|
1704
|
+
if (lastSnapshot && sameSnapshot(lastSnapshot, snapshot)) {
|
|
1705
|
+
reportedFailure = void 0;
|
|
1706
|
+
return;
|
|
1707
|
+
}
|
|
1317
1708
|
if (snapshot.raw === null) throw new Error("Config file is missing");
|
|
1318
1709
|
const raw = parseConfigYaml(snapshot.raw);
|
|
1319
1710
|
if (configKeys.some((key) => !Object.hasOwn(raw, key))) throw new Error("Config reload requires every materialized key");
|
|
1320
1711
|
const config = resolveConfig(raw);
|
|
1321
|
-
|
|
1712
|
+
const current = await readConfigSnapshot();
|
|
1713
|
+
if (!current || !sameSnapshot(snapshot, current)) return;
|
|
1322
1714
|
onReload(config);
|
|
1323
1715
|
lastSnapshot = snapshot;
|
|
1716
|
+
reportedFailure = void 0;
|
|
1324
1717
|
} catch (error) {
|
|
1325
|
-
|
|
1326
|
-
|
|
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.`));
|
|
1327
1726
|
} finally {
|
|
1328
1727
|
reloading = false;
|
|
1329
1728
|
}
|
|
@@ -1362,7 +1761,8 @@ const readProxyConfig = (config) => ({
|
|
|
1362
1761
|
upstreamTimeoutMs: config.upstreamTimeoutSeconds * 1e3,
|
|
1363
1762
|
vsCodeVersion: vscodeVersion,
|
|
1364
1763
|
webSearchBackend: config.webSearchBackend,
|
|
1365
|
-
claudeUpstreamApi: config.claudeUpstreamApi
|
|
1764
|
+
claudeUpstreamApi: config.claudeUpstreamApi,
|
|
1765
|
+
apiKey: config.apiKey
|
|
1366
1766
|
});
|
|
1367
1767
|
|
|
1368
1768
|
//#endregion
|
|
@@ -1373,7 +1773,9 @@ const auth = defineCommand({
|
|
|
1373
1773
|
description: "Run GitHub device auth and cache credentials for copilot-relay."
|
|
1374
1774
|
},
|
|
1375
1775
|
async run() {
|
|
1376
|
-
const
|
|
1776
|
+
const appConfig = await readAppConfig();
|
|
1777
|
+
configureUpstreamDispatcher(appConfig.upstreamProxy);
|
|
1778
|
+
const config = readProxyConfig(appConfig);
|
|
1377
1779
|
config.copilotToken = void 0;
|
|
1378
1780
|
const authSession = await setupProxyAuth(config, { force: true });
|
|
1379
1781
|
if (authSession.githubLogin) log.info(`Logged in as ${authSession.githubLogin}`);
|
|
@@ -1381,6 +1783,499 @@ const auth = defineCommand({
|
|
|
1381
1783
|
}
|
|
1382
1784
|
});
|
|
1383
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
|
+
|
|
1384
2279
|
//#endregion
|
|
1385
2280
|
//#region src/lib/version.ts
|
|
1386
2281
|
const packageName = /^(?:@[a-z0-9][a-z0-9._-]*\/)?copilot-relay$/;
|
|
@@ -1549,13 +2444,13 @@ var OutcomeObserver = class {
|
|
|
1549
2444
|
const incomplete = this.metadataValue(value.incomplete_details?.reason);
|
|
1550
2445
|
if (incomplete) this.fields.incomplete_reason = ["max_output_tokens", "content_filter"].includes(incomplete) ? incomplete : "unknown";
|
|
1551
2446
|
if (payload.type === "error" || record$2(payload.error)) this.fields.error = "upstream_error";
|
|
1552
|
-
const usage = findUsage(payload, value);
|
|
1553
|
-
if (usage) {
|
|
2447
|
+
const usage$1 = findUsage(payload, value);
|
|
2448
|
+
if (usage$1) {
|
|
1554
2449
|
for (const [target, input] of [
|
|
1555
|
-
["input_tokens", usage.input_tokens ?? usage.prompt_tokens],
|
|
1556
|
-
["output_tokens", usage.output_tokens ?? usage.completion_tokens],
|
|
1557
|
-
["cache_read_input_tokens", usage.cache_read_input_tokens ?? usage.prompt_tokens_details?.cached_tokens ?? usage.input_tokens_details?.cached_tokens],
|
|
1558
|
-
["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]
|
|
1559
2454
|
]) if (typeof input === "number" && Number.isSafeInteger(input) && input >= 0) this.fields[target] = input;
|
|
1560
2455
|
}
|
|
1561
2456
|
}
|
|
@@ -1771,6 +2666,7 @@ var RequestTrace = class RequestTrace {
|
|
|
1771
2666
|
constructor(requestId, request, config, runtime) {
|
|
1772
2667
|
this.requestId = requestId;
|
|
1773
2668
|
this.protectCredential(config.copilotToken);
|
|
2669
|
+
this.protectCredential(config.apiKey);
|
|
1774
2670
|
this.protectCredential(request.headers.get("authorization")?.replace(/^Bearer\s+/i, ""));
|
|
1775
2671
|
this.protectCredential(request.headers.get("x-api-key") ?? void 0);
|
|
1776
2672
|
this.signal = AbortSignal.any([request.signal, this.controller.signal]);
|
|
@@ -2233,11 +3129,6 @@ const userAgent = `GitHubCopilotChat/${copilotVersion}`;
|
|
|
2233
3129
|
const apiVersion = "2025-04-01";
|
|
2234
3130
|
const maxFetchAttempts = 2;
|
|
2235
3131
|
const copilotRequestTimeoutMs = 18e4;
|
|
2236
|
-
const copilotDispatcher = new Agent({
|
|
2237
|
-
allowH2: false,
|
|
2238
|
-
connect: { allowH2: false },
|
|
2239
|
-
keepAliveTimeout: 5e4
|
|
2240
|
-
});
|
|
2241
3132
|
const getCopilotProviderContext = (config) => ({
|
|
2242
3133
|
baseUrl: config.copilotBaseUrl,
|
|
2243
3134
|
get token() {
|
|
@@ -2277,6 +3168,33 @@ const toCopilotAbortHTTPError = (error, signal, timeoutMs = copilotRequestTimeou
|
|
|
2277
3168
|
headers: { "content-type": "application/json" }
|
|
2278
3169
|
}), message);
|
|
2279
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
|
+
};
|
|
2280
3198
|
const readCopilotJson = async (response, signal, timeoutMs = copilotRequestTimeoutMs) => {
|
|
2281
3199
|
try {
|
|
2282
3200
|
return await response.json();
|
|
@@ -2379,21 +3297,12 @@ const fetchCopilot = async (provider, path$1, init, options = {}) => {
|
|
|
2379
3297
|
headers,
|
|
2380
3298
|
upstreamRequestId,
|
|
2381
3299
|
signal
|
|
2382
|
-
},
|
|
2383
|
-
|
|
2384
|
-
|
|
2385
|
-
|
|
2386
|
-
|
|
2387
|
-
|
|
2388
|
-
});
|
|
2389
|
-
const wrappedResponse = new Response(undiciResponse.body, {
|
|
2390
|
-
status: undiciResponse.status,
|
|
2391
|
-
statusText: undiciResponse.statusText,
|
|
2392
|
-
headers: undiciResponse.headers
|
|
2393
|
-
});
|
|
2394
|
-
Object.defineProperty(wrappedResponse, "url", { value: undiciResponse.url });
|
|
2395
|
-
return wrappedResponse;
|
|
2396
|
-
});
|
|
3300
|
+
}, () => fetchUpstream(`${provider.baseUrl}${path$1}`, {
|
|
3301
|
+
body: init.body,
|
|
3302
|
+
headers,
|
|
3303
|
+
method: init.method,
|
|
3304
|
+
signal
|
|
3305
|
+
}));
|
|
2397
3306
|
const ms = Math.round(performance.now() - started);
|
|
2398
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}`);
|
|
2399
3308
|
log.debug(`${formatRequestId(options.requestId)}Copilot ${init.method ?? "GET"} ${path$1} -> ${response.status} ${ms}ms (attempt ${attempt}) upstream_request_id=${upstreamRequestId}`);
|
|
@@ -2449,10 +3358,10 @@ const pinCopilotModelCatalog = (config) => {
|
|
|
2449
3358
|
pinnedCatalogs.set(config, config.modelCatalog);
|
|
2450
3359
|
};
|
|
2451
3360
|
const pendingCatalogs = /* @__PURE__ */ new WeakMap();
|
|
2452
|
-
const isRecord$
|
|
3361
|
+
const isRecord$4 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2453
3362
|
const isPositiveInteger = (value) => typeof value === "number" && Number.isSafeInteger(value) && value > 0;
|
|
2454
3363
|
function parseModelTokenLimits(value) {
|
|
2455
|
-
if (!isRecord$
|
|
3364
|
+
if (!isRecord$4(value)) return;
|
|
2456
3365
|
const context = value.max_context_window_tokens;
|
|
2457
3366
|
const prompt = value.max_prompt_tokens;
|
|
2458
3367
|
const output = value.max_output_tokens;
|
|
@@ -2508,13 +3417,13 @@ async function loadCopilotModelCatalog(config) {
|
|
|
2508
3417
|
});
|
|
2509
3418
|
if (!response.ok) throw new HTTPError("Failed to validate upstream models", response);
|
|
2510
3419
|
const payload = await readCopilotJson(response, signal, config.upstreamTimeoutMs);
|
|
2511
|
-
if (!isRecord$
|
|
3420
|
+
if (!isRecord$4(payload) || !Array.isArray(payload.data)) throw new Error("Copilot /models returned an invalid model catalog.");
|
|
2512
3421
|
const models$1 = /* @__PURE__ */ new Map();
|
|
2513
3422
|
for (const model of payload.data) {
|
|
2514
|
-
if (!isRecord$
|
|
2515
|
-
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;
|
|
2516
3425
|
const limits = parseModelTokenLimits(capabilities?.limits);
|
|
2517
|
-
const supports = isRecord$
|
|
3426
|
+
const supports = isRecord$4(capabilities?.supports) ? capabilities.supports : void 0;
|
|
2518
3427
|
const supportedEndpoints = parseStringArray(model.supported_endpoints);
|
|
2519
3428
|
const reasoningEfforts = parseReasoningEfforts(supports);
|
|
2520
3429
|
models$1.set(model.id, {
|
|
@@ -2570,12 +3479,12 @@ const copilotEndpoints = [
|
|
|
2570
3479
|
"/responses",
|
|
2571
3480
|
"/v1/messages"
|
|
2572
3481
|
];
|
|
2573
|
-
const legacyResponsesPattern = /^(?:gpt-5\.5|gpt-5\.6|gpt-6-astra)(?:-|$)/i;
|
|
3482
|
+
const legacyResponsesPattern = /^(?:gpt-5\.4(?!-mini)|gpt-5\.5|gpt-5\.6|gpt-6-astra)(?:-|$)/i;
|
|
2574
3483
|
function selectCopilotEndpoint(config, model) {
|
|
2575
3484
|
const capabilities = getCachedCopilotModel(config, model);
|
|
2576
3485
|
const advertisedEndpoints = capabilities?.supportedEndpoints;
|
|
2577
3486
|
if (capabilities?.type !== void 0 && capabilities.type !== "chat") return { reason: "unsupported-model-type" };
|
|
2578
|
-
const isClaude = model
|
|
3487
|
+
const isClaude = isClaudeModelId(model);
|
|
2579
3488
|
const protocolMode = config.claudeUpstreamApi ?? "chat-completions";
|
|
2580
3489
|
if (isClaude && protocolMode === "messages") return {
|
|
2581
3490
|
endpoint: "/v1/messages",
|
|
@@ -2632,27 +3541,6 @@ function requireCopilotEndpoint(config, model) {
|
|
|
2632
3541
|
}, { status: 400 }), message);
|
|
2633
3542
|
}
|
|
2634
3543
|
|
|
2635
|
-
//#endregion
|
|
2636
|
-
//#region src/lib/terminal.ts
|
|
2637
|
-
const colorEnabled = (env = process.env, isTTY = Boolean(process.stdout.isTTY)) => {
|
|
2638
|
-
if (env.NO_COLOR !== void 0 || env.FORCE_COLOR === "0") return false;
|
|
2639
|
-
if (env.FORCE_COLOR !== void 0) return env.FORCE_COLOR === "" || [
|
|
2640
|
-
"1",
|
|
2641
|
-
"2",
|
|
2642
|
-
"3",
|
|
2643
|
-
"true"
|
|
2644
|
-
].includes(env.FORCE_COLOR);
|
|
2645
|
-
return isTTY && env.TERM !== "dumb";
|
|
2646
|
-
};
|
|
2647
|
-
const ansiColorCodes = {
|
|
2648
|
-
good: 32,
|
|
2649
|
-
bad: 31,
|
|
2650
|
-
warning: 33,
|
|
2651
|
-
muted: 90
|
|
2652
|
-
};
|
|
2653
|
-
const terminalText = (value) => sanitizeTerminalString(value).replace(/[\x80-\x9f]|\p{Cf}|\p{Zl}|\p{Zp}/gu, "");
|
|
2654
|
-
const colorText = (text$1, tone, enabled) => enabled ? `\u001b[${ansiColorCodes[tone]}m${text$1}\u001b[0m` : text$1;
|
|
2655
|
-
|
|
2656
3544
|
//#endregion
|
|
2657
3545
|
//#region src/lib/model-probe-output.ts
|
|
2658
3546
|
const tones = {
|
|
@@ -2780,6 +3668,50 @@ const probeHint = (code) => {
|
|
|
2780
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.";
|
|
2781
3669
|
};
|
|
2782
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
|
+
|
|
2783
3715
|
//#endregion
|
|
2784
3716
|
//#region src/claude/utils.ts
|
|
2785
3717
|
const isEffortOnlyControl = (control) => control !== null && typeof control === "object" && !Array.isArray(control) && Object.keys(control).length === 1 && "effort" in control && isConfiguredReasoningEffort(control.effort);
|
|
@@ -2911,7 +3843,7 @@ const createClaudeToolNameMapper = (tools, options = {}) => {
|
|
|
2911
3843
|
function translateModelName(model) {
|
|
2912
3844
|
return routeModelId(model);
|
|
2913
3845
|
}
|
|
2914
|
-
function translateToOpenAI(payload, _settings, toolNameMapper) {
|
|
3846
|
+
function translateToOpenAI(payload, _settings, toolNameMapper, options = {}) {
|
|
2915
3847
|
const model = translateModelName(payload.model);
|
|
2916
3848
|
const mapper = toolNameMapper ?? createClaudeToolNameMapper(payload.tools, { ...getToolNameMapperOptionsForModel(model) });
|
|
2917
3849
|
const tools = translateClaudeToolsToOpenAI(payload.tools, mapper);
|
|
@@ -2919,7 +3851,7 @@ function translateToOpenAI(payload, _settings, toolNameMapper) {
|
|
|
2919
3851
|
validateClaudeMessages(payload.messages);
|
|
2920
3852
|
return {
|
|
2921
3853
|
model,
|
|
2922
|
-
messages: normalizeFinalAssistantPrefill$1(translateClaudeMessagesToOpenAI(payload.messages, payload.system, mapper)),
|
|
3854
|
+
messages: normalizeFinalAssistantPrefill$1(translateClaudeMessagesToOpenAI(payload.messages, payload.system, mapper, options.endpoint === "/chat/completions" && isClaudeModelId(model))),
|
|
2923
3855
|
max_tokens: payload.max_tokens,
|
|
2924
3856
|
stop: payload.stop_sequences,
|
|
2925
3857
|
stream: payload.stream,
|
|
@@ -2945,20 +3877,50 @@ function validateClaudeMessages(messages, native = false) {
|
|
|
2945
3877
|
if (typeof message.content !== "string" && (!Array.isArray(message.content) || message.content.some((block) => !block || block.type !== "text" || typeof block.text !== "string"))) throw invalidMessage("System message content must contain only text.");
|
|
2946
3878
|
}
|
|
2947
3879
|
}
|
|
2948
|
-
function translateClaudeMessagesToOpenAI(claudeMessages, system, toolNameMapper) {
|
|
2949
|
-
const
|
|
2950
|
-
|
|
2951
|
-
|
|
2952
|
-
|
|
2953
|
-
|
|
2954
|
-
|
|
2955
|
-
|
|
2956
|
-
|
|
2957
|
-
|
|
2958
|
-
|
|
2959
|
-
|
|
2960
|
-
|
|
3880
|
+
function translateClaudeMessagesToOpenAI(claudeMessages, system, toolNameMapper, claudeChatRoute) {
|
|
3881
|
+
const translated = handleSystemPrompt(system);
|
|
3882
|
+
if (claudeChatRoute && hasCacheBreakpoint(system)) markCacheBreakpoint(translated.at(-1));
|
|
3883
|
+
for (const message of claudeMessages) {
|
|
3884
|
+
const start$1 = translated.length;
|
|
3885
|
+
translated.push(...translateClaudeMessage(message, toolNameMapper, claudeChatRoute));
|
|
3886
|
+
if (claudeChatRoute) markMessageBreakpoints(message, translated, start$1);
|
|
3887
|
+
}
|
|
3888
|
+
return translated;
|
|
3889
|
+
}
|
|
3890
|
+
function markMessageBreakpoints(message, translated, start$1) {
|
|
3891
|
+
if (message.role !== "user" || !Array.isArray(message.content)) {
|
|
3892
|
+
if (hasCacheBreakpoint(message.content)) markCacheBreakpoint(translated.at(-1));
|
|
3893
|
+
return;
|
|
3894
|
+
}
|
|
3895
|
+
let toolIndex = start$1;
|
|
3896
|
+
for (const block of message.content) if (block.type === "tool_result") {
|
|
3897
|
+
if (isCacheBreakpoint(block)) markCacheBreakpoint(translated[toolIndex]);
|
|
3898
|
+
toolIndex += 1;
|
|
3899
|
+
} else if (isCacheBreakpoint(block)) markCacheBreakpoint(translated.at(-1));
|
|
3900
|
+
}
|
|
3901
|
+
function translateClaudeMessage(message, toolNameMapper, claudeChatRoute) {
|
|
3902
|
+
switch (message.role) {
|
|
3903
|
+
case "user": return handleUserMessage(message);
|
|
3904
|
+
case "assistant": return handleAssistantMessage(message, toolNameMapper);
|
|
3905
|
+
case "system":
|
|
3906
|
+
if (message.output_config !== void 0 && (typeof message.content === "string" ? message.content.length === 0 : message.content.every((block) => block.text.length === 0))) return [];
|
|
3907
|
+
return claudeChatRoute ? toSystemReminder(message.content) : handleSystemPrompt(message.content);
|
|
3908
|
+
default: throw invalidMessage("Unsupported message role.");
|
|
3909
|
+
}
|
|
3910
|
+
}
|
|
3911
|
+
function toSystemReminder(content) {
|
|
3912
|
+
const text$1 = typeof content === "string" ? content : content.map((block) => block.text).join("\n\n");
|
|
3913
|
+
if (text$1.trim().length === 0) return [];
|
|
3914
|
+
return [{
|
|
3915
|
+
role: "user",
|
|
3916
|
+
content: `<system-reminder>\n${text$1}\n</system-reminder>`
|
|
3917
|
+
}];
|
|
2961
3918
|
}
|
|
3919
|
+
const isCacheBreakpoint = (block) => "cache_control" in block && Boolean(block.cache_control);
|
|
3920
|
+
const hasCacheBreakpoint = (content) => Array.isArray(content) && content.some(isCacheBreakpoint);
|
|
3921
|
+
const markCacheBreakpoint = (message) => {
|
|
3922
|
+
if (message) message.copilot_cache_control = { type: "ephemeral" };
|
|
3923
|
+
};
|
|
2962
3924
|
const invalidMessage = (message) => new HTTPError(message, new Response(JSON.stringify({
|
|
2963
3925
|
type: "error",
|
|
2964
3926
|
error: {
|
|
@@ -3311,6 +4273,13 @@ function translateChunkToClaudeEvents(chunk, state, toolNameMapper = createClaud
|
|
|
3311
4273
|
return events$1;
|
|
3312
4274
|
}
|
|
3313
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
|
+
};
|
|
3314
4283
|
return {
|
|
3315
4284
|
type: "error",
|
|
3316
4285
|
error: {
|
|
@@ -3334,7 +4303,7 @@ const webSearchInputSchema = {
|
|
|
3334
4303
|
required: ["query"],
|
|
3335
4304
|
additionalProperties: false
|
|
3336
4305
|
};
|
|
3337
|
-
const isRecord$
|
|
4306
|
+
const isRecord$3 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
3338
4307
|
const isAnthropicNativeWebSearchTool = (tool) => tool.name === "web_search" && typeof tool.type === "string" && anthropicWebSearchToolPattern.test(tool.type);
|
|
3339
4308
|
const isClaudeCodeWebSearchTool = (tool) => tool.name === claudeCodeWebSearchToolName;
|
|
3340
4309
|
const isClaudeWebSearchTool = (tool) => isAnthropicNativeWebSearchTool(tool) || isClaudeCodeWebSearchTool(tool);
|
|
@@ -3351,11 +4320,16 @@ const prepareClaudeWebSearchDecisionPayload = (payload) => {
|
|
|
3351
4320
|
} : tool)
|
|
3352
4321
|
};
|
|
3353
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
|
+
};
|
|
3354
4328
|
const textFromMessageContent = (content) => {
|
|
3355
4329
|
if (typeof content === "string") return content;
|
|
3356
4330
|
return content.flatMap((block) => {
|
|
3357
4331
|
if (block.type === "text") return [block.text];
|
|
3358
|
-
if (block.type === "tool_result") return [block.content];
|
|
4332
|
+
if (block.type === "tool_result") return [toolResultText(block.content)];
|
|
3359
4333
|
return [];
|
|
3360
4334
|
}).join("\n\n");
|
|
3361
4335
|
};
|
|
@@ -3369,7 +4343,7 @@ const getQueryFromToolArguments = (value) => {
|
|
|
3369
4343
|
if (!trimmed) return;
|
|
3370
4344
|
try {
|
|
3371
4345
|
const parsed = JSON.parse(trimmed);
|
|
3372
|
-
if (isRecord$
|
|
4346
|
+
if (isRecord$3(parsed) && typeof parsed.query === "string" && parsed.query.trim()) return parsed.query.trim();
|
|
3373
4347
|
} catch {
|
|
3374
4348
|
return trimmed;
|
|
3375
4349
|
}
|
|
@@ -3426,7 +4400,7 @@ const getReportedQuery = (action, queries) => {
|
|
|
3426
4400
|
const getSearchQuery = (response, requestedQuery) => {
|
|
3427
4401
|
for (const item of response.output ?? []) {
|
|
3428
4402
|
if (item.type !== "web_search_call") continue;
|
|
3429
|
-
const action = isRecord$
|
|
4403
|
+
const action = isRecord$3(item.action) ? item.action : void 0;
|
|
3430
4404
|
const query = getReportedQuery(action, Array.isArray(action?.queries) ? action.queries : []);
|
|
3431
4405
|
if (query) return query;
|
|
3432
4406
|
}
|
|
@@ -3434,7 +4408,7 @@ const getSearchQuery = (response, requestedQuery) => {
|
|
|
3434
4408
|
};
|
|
3435
4409
|
const getResponseText = (response) => (response.output ?? []).flatMap((item) => {
|
|
3436
4410
|
if (item.type !== "message") return [];
|
|
3437
|
-
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] : []);
|
|
3438
4412
|
}).join("\n").trim();
|
|
3439
4413
|
const cleanTitle = (line, url) => {
|
|
3440
4414
|
return line.slice(0, line.indexOf(url)).replace(/^\s*(?:[-*]|\d+[.)])\s*/, "").replace(/\s*(?:[-:|])\s*$/, "").trim() || new URL(url).hostname;
|
|
@@ -3458,15 +4432,15 @@ const parseSearchResults = (text$1) => {
|
|
|
3458
4432
|
const getStructuredSearchResults = (response) => {
|
|
3459
4433
|
const sources = [];
|
|
3460
4434
|
for (const item of response.output ?? []) {
|
|
3461
|
-
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);
|
|
3462
4436
|
if (item.type === "message" && Array.isArray(item.content)) {
|
|
3463
|
-
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"));
|
|
3464
4438
|
}
|
|
3465
4439
|
}
|
|
3466
4440
|
const results = [];
|
|
3467
4441
|
const seen = /* @__PURE__ */ new Set();
|
|
3468
4442
|
for (const source of sources) {
|
|
3469
|
-
if (!isRecord$
|
|
4443
|
+
if (!isRecord$3(source) || typeof source.url !== "string") continue;
|
|
3470
4444
|
try {
|
|
3471
4445
|
const url = new URL(source.url);
|
|
3472
4446
|
if (!/^https?:$/.test(url.protocol) || url.username || url.password || seen.has(url.href)) continue;
|
|
@@ -3518,21 +4492,21 @@ const getResultSource = (structured, results) => {
|
|
|
3518
4492
|
return results.length ? "text" : "none";
|
|
3519
4493
|
};
|
|
3520
4494
|
const interpretSearchResponse = (raw, config, requestedQuery, request, requestedEffort, requestId) => {
|
|
3521
|
-
const malformed = !isRecord$
|
|
3522
|
-
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) ? {
|
|
3523
4497
|
...raw,
|
|
3524
|
-
output: Array.isArray(raw.output) ? raw.output.filter(isRecord$
|
|
4498
|
+
output: Array.isArray(raw.output) ? raw.output.filter(isRecord$3) : []
|
|
3525
4499
|
} : {};
|
|
3526
4500
|
const status$1 = recognizedValue(upstream.status, responseStates);
|
|
3527
|
-
const reason = recognizedValue(isRecord$
|
|
4501
|
+
const reason = recognizedValue(isRecord$3(upstream.incomplete_details) ? upstream.incomplete_details.reason : void 0, incompleteReasons);
|
|
3528
4502
|
const items = upstream.output ?? [];
|
|
3529
4503
|
const calls = items.filter((item) => item.type === "web_search_call");
|
|
3530
4504
|
const callStates = calls.map((item) => recognizedValue(item.status, searchStates));
|
|
3531
|
-
const usage = isRecord$
|
|
3532
|
-
const inputTokens = reportedTokens(usage.input_tokens);
|
|
3533
|
-
const cachedInputTokens = reportedTokens(isRecord$
|
|
3534
|
-
const outputTokens = reportedTokens(usage.output_tokens);
|
|
3535
|
-
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);
|
|
3536
4510
|
const upstreamResponseId = safeResponseId(upstream.id, config.copilotToken);
|
|
3537
4511
|
const safeRequestId = typeof requestId === "string" && /^[a-f0-9-]{36}$/i.test(requestId) ? requestId : "unreported";
|
|
3538
4512
|
const text$1 = getResponseText(upstream);
|
|
@@ -3620,7 +4594,7 @@ const getSearchFailureDetail = (body, token) => {
|
|
|
3620
4594
|
let detail = body.trim();
|
|
3621
4595
|
try {
|
|
3622
4596
|
const parsed = JSON.parse(detail);
|
|
3623
|
-
detail = getErrorMessageOrCode(isRecord$
|
|
4597
|
+
detail = getErrorMessageOrCode(isRecord$3(parsed) && isRecord$3(parsed.error) ? parsed.error : void 0);
|
|
3624
4598
|
} catch {
|
|
3625
4599
|
if (/^[{[<]/.test(detail)) return "";
|
|
3626
4600
|
}
|
|
@@ -3789,9 +4763,9 @@ const accumulateChunks = (chunks) => {
|
|
|
3789
4763
|
let reasoning = "";
|
|
3790
4764
|
let refusal = "";
|
|
3791
4765
|
let finishReason = null;
|
|
3792
|
-
let usage;
|
|
4766
|
+
let usage$1;
|
|
3793
4767
|
for (const chunk of chunks) {
|
|
3794
|
-
if (chunk.usage) usage = chunk.usage;
|
|
4768
|
+
if (chunk.usage) usage$1 = chunk.usage;
|
|
3795
4769
|
const choice = chunk.choices[0];
|
|
3796
4770
|
if (!choice) continue;
|
|
3797
4771
|
if (typeof choice.delta?.content === "string") content += choice.delta.content;
|
|
@@ -3832,7 +4806,7 @@ const accumulateChunks = (chunks) => {
|
|
|
3832
4806
|
},
|
|
3833
4807
|
finish_reason: refusal ? "content_filter" : finishReason ?? "stop"
|
|
3834
4808
|
}],
|
|
3835
|
-
...usage ? { usage } : {}
|
|
4809
|
+
...usage$1 ? { usage: usage$1 } : {}
|
|
3836
4810
|
};
|
|
3837
4811
|
};
|
|
3838
4812
|
const incompleteStreamError = () => {
|
|
@@ -3848,7 +4822,7 @@ const incompleteStreamError = () => {
|
|
|
3848
4822
|
async function* normalizeChatCompletionStream(stream) {
|
|
3849
4823
|
let terminal;
|
|
3850
4824
|
let refusal = false;
|
|
3851
|
-
let usage;
|
|
4825
|
+
let usage$1;
|
|
3852
4826
|
for await (const event of stream) {
|
|
3853
4827
|
if (event.event === "error") throw incompleteStreamError();
|
|
3854
4828
|
if (!event.data) continue;
|
|
@@ -3860,7 +4834,7 @@ async function* normalizeChatCompletionStream(stream) {
|
|
|
3860
4834
|
throw incompleteStreamError();
|
|
3861
4835
|
}
|
|
3862
4836
|
if (!chunk || !Array.isArray(chunk.choices)) throw incompleteStreamError();
|
|
3863
|
-
if (chunk.usage) usage = chunk.usage;
|
|
4837
|
+
if (chunk.usage) usage$1 = chunk.usage;
|
|
3864
4838
|
if (chunk.choices.length === 0) continue;
|
|
3865
4839
|
const choice = chunk.choices[0];
|
|
3866
4840
|
if (!choice || !choice.delta || typeof choice.delta !== "object" || terminal) throw incompleteStreamError();
|
|
@@ -3892,7 +4866,7 @@ async function* normalizeChatCompletionStream(stream) {
|
|
|
3892
4866
|
if (!terminal) throw incompleteStreamError();
|
|
3893
4867
|
yield { data: JSON.stringify({
|
|
3894
4868
|
...terminal,
|
|
3895
|
-
...usage && { usage }
|
|
4869
|
+
...usage$1 && { usage: usage$1 }
|
|
3896
4870
|
}) };
|
|
3897
4871
|
yield { data: "[DONE]" };
|
|
3898
4872
|
}
|
|
@@ -4192,7 +5166,7 @@ const lookaroundPrefixes = [
|
|
|
4192
5166
|
"(?<=",
|
|
4193
5167
|
"(?<!"
|
|
4194
5168
|
];
|
|
4195
|
-
const isRecord$
|
|
5169
|
+
const isRecord$2 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
4196
5170
|
function hasUnsupportedPatternFeatures(pattern) {
|
|
4197
5171
|
let inCharacterClass = false;
|
|
4198
5172
|
for (let index = 0; index < pattern.length; index++) {
|
|
@@ -4238,12 +5212,12 @@ function mapArray(values) {
|
|
|
4238
5212
|
}
|
|
4239
5213
|
function normalizeSchemaValue(value) {
|
|
4240
5214
|
if (Array.isArray(value)) return mapArray(value);
|
|
4241
|
-
return isRecord$
|
|
5215
|
+
return isRecord$2(value) ? normalizeResponsesToolSchema(value) : value;
|
|
4242
5216
|
}
|
|
4243
5217
|
function normalizeResponsesToolSchema(schema) {
|
|
4244
5218
|
return mapRecord(schema, (key, value) => {
|
|
4245
5219
|
if (key === "pattern" && typeof value === "string" && hasUnsupportedPatternFeatures(value)) return removed;
|
|
4246
|
-
if (schemaMapKeywords.has(key) && isRecord$
|
|
5220
|
+
if (schemaMapKeywords.has(key) && isRecord$2(value)) return mapRecord(value, (_name, nested) => normalizeSchemaValue(nested));
|
|
4247
5221
|
if (nestedSchemaKeywords.has(key)) return normalizeSchemaValue(value);
|
|
4248
5222
|
return value;
|
|
4249
5223
|
});
|
|
@@ -4522,7 +5496,7 @@ function createRoleChunk(state) {
|
|
|
4522
5496
|
state.started = true;
|
|
4523
5497
|
return createChunk(state, { delta: { role: "assistant" } });
|
|
4524
5498
|
}
|
|
4525
|
-
function createChunk(state, { delta, finishReason = null, usage }) {
|
|
5499
|
+
function createChunk(state, { delta, finishReason = null, usage: usage$1 }) {
|
|
4526
5500
|
return { data: JSON.stringify({
|
|
4527
5501
|
id: state.responseId,
|
|
4528
5502
|
object: "chat.completion.chunk",
|
|
@@ -4534,7 +5508,7 @@ function createChunk(state, { delta, finishReason = null, usage }) {
|
|
|
4534
5508
|
finish_reason: finishReason,
|
|
4535
5509
|
logprobs: null
|
|
4536
5510
|
}],
|
|
4537
|
-
usage
|
|
5511
|
+
usage: usage$1
|
|
4538
5512
|
}) };
|
|
4539
5513
|
}
|
|
4540
5514
|
function translateMessagesToResponsesInput(messages) {
|
|
@@ -4546,7 +5520,7 @@ function translateMessage(message) {
|
|
|
4546
5520
|
if (message.role === "tool") return [{
|
|
4547
5521
|
type: "function_call_output",
|
|
4548
5522
|
call_id: message.tool_call_id ?? "",
|
|
4549
|
-
output:
|
|
5523
|
+
output: translateToolOutput(message.content)
|
|
4550
5524
|
}];
|
|
4551
5525
|
const translated = [];
|
|
4552
5526
|
if (hasContent(message.content)) translated.push({
|
|
@@ -4582,6 +5556,21 @@ function translateContentPart(part) {
|
|
|
4582
5556
|
detail: part.image_url.detail ?? "auto"
|
|
4583
5557
|
};
|
|
4584
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
|
+
}
|
|
4585
5574
|
function stringifyToolOutput(content) {
|
|
4586
5575
|
if (typeof content === "string") return content;
|
|
4587
5576
|
if (!content) return "";
|
|
@@ -4605,13 +5594,13 @@ function translateToolChoice(toolChoice) {
|
|
|
4605
5594
|
name: toolChoice.function.name
|
|
4606
5595
|
};
|
|
4607
5596
|
}
|
|
4608
|
-
function translateUsage(usage) {
|
|
4609
|
-
if (!usage) return;
|
|
5597
|
+
function translateUsage(usage$1) {
|
|
5598
|
+
if (!usage$1) return;
|
|
4610
5599
|
return {
|
|
4611
|
-
prompt_tokens: usage.input_tokens,
|
|
4612
|
-
completion_tokens: usage.output_tokens,
|
|
4613
|
-
total_tokens: usage.total_tokens,
|
|
4614
|
-
...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 } }
|
|
4615
5604
|
};
|
|
4616
5605
|
}
|
|
4617
5606
|
function getMessageOutputText(content) {
|
|
@@ -4702,7 +5691,57 @@ const normalizeFinalAssistantPrefill = (payload) => {
|
|
|
4702
5691
|
};
|
|
4703
5692
|
};
|
|
4704
5693
|
const isAgentInitiator = (messages) => messages.some((message) => message.role === "assistant" || message.role === "tool") ? "agent" : "user";
|
|
4705
|
-
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
|
+
};
|
|
4706
5745
|
const createChatCompletions = async (config, payload, options = {}) => {
|
|
4707
5746
|
const client = options.client ?? "generic";
|
|
4708
5747
|
const requestedModel = options.requestedModel ?? payload.model;
|
|
@@ -4725,7 +5764,7 @@ const createChatCompletions = async (config, payload, options = {}) => {
|
|
|
4725
5764
|
stream: bufferResponse ? true : payload.stream
|
|
4726
5765
|
};
|
|
4727
5766
|
const useResponsesApi = selection.endpoint === "/responses";
|
|
4728
|
-
const compatiblePayload = useResponsesApi ? upstreamPayload : normalizeFinalAssistantPrefill(upstreamPayload);
|
|
5767
|
+
const compatiblePayload = useResponsesApi ? upstreamPayload : normalizeFinalAssistantPrefill(moveToolImagesToUserMessages(upstreamPayload));
|
|
4729
5768
|
const provider = getCopilotProviderContext(config);
|
|
4730
5769
|
const enableVision = messagesIncludeImage(compatiblePayload.messages);
|
|
4731
5770
|
const initiator = isAgentInitiator(compatiblePayload.messages);
|
|
@@ -4767,19 +5806,19 @@ const createChatCompletions = async (config, payload, options = {}) => {
|
|
|
4767
5806
|
markDiscardedResponse(response);
|
|
4768
5807
|
await response.body?.cancel();
|
|
4769
5808
|
return completeResponse(await createResponses(provider, upstreamPayload, {
|
|
4770
|
-
vision:
|
|
5809
|
+
vision: messagesIncludeImage(upstreamPayload.messages),
|
|
4771
5810
|
initiator,
|
|
4772
5811
|
requestId: options.requestId,
|
|
4773
5812
|
signal,
|
|
4774
5813
|
timeoutMs: options.timeoutMs
|
|
4775
5814
|
}));
|
|
4776
5815
|
}
|
|
4777
|
-
throw
|
|
5816
|
+
throw await logUpstreamError("Failed to create chat completions", response, {
|
|
4778
5817
|
model: payload.model,
|
|
4779
5818
|
request: requestPayload,
|
|
4780
5819
|
route: "/chat/completions",
|
|
4781
5820
|
requestId: options.requestId
|
|
4782
|
-
}, signal, options.timeoutMs)
|
|
5821
|
+
}, signal, options.timeoutMs);
|
|
4783
5822
|
}
|
|
4784
5823
|
if (compatiblePayload.stream) return completeResponse(normalizeChatCompletionStream(events(response)));
|
|
4785
5824
|
return readCopilotJson(response, signal, options.timeoutMs);
|
|
@@ -4801,12 +5840,12 @@ async function createResponses(provider, payload, options) {
|
|
|
4801
5840
|
signal: options.signal,
|
|
4802
5841
|
timeoutMs: options.timeoutMs
|
|
4803
5842
|
});
|
|
4804
|
-
if (!response.ok) throw
|
|
5843
|
+
if (!response.ok) throw await logUpstreamError("Failed to create responses", response, {
|
|
4805
5844
|
model: payload.model,
|
|
4806
5845
|
request: requestPayload,
|
|
4807
5846
|
route: "/responses",
|
|
4808
5847
|
requestId: options.requestId
|
|
4809
|
-
}, options.signal, options.timeoutMs)
|
|
5848
|
+
}, options.signal, options.timeoutMs);
|
|
4810
5849
|
if (payload.stream) return normalizeChatCompletionStream(translateResponsesStreamToChatCompletionStream(events(response)));
|
|
4811
5850
|
return translateResponsesToChatCompletion(await readCopilotJson(response, options.signal, options.timeoutMs));
|
|
4812
5851
|
}
|
|
@@ -4818,16 +5857,16 @@ async function logUpstreamError(message, response, context, signal, timeoutMs) {
|
|
|
4818
5857
|
route: context.route,
|
|
4819
5858
|
model: context.model,
|
|
4820
5859
|
requestId: context.requestId,
|
|
4821
|
-
request: context.request,
|
|
4822
5860
|
response: {
|
|
4823
5861
|
status: response.status,
|
|
4824
5862
|
statusText: response.statusText || void 0,
|
|
4825
5863
|
url: response.url || void 0,
|
|
4826
5864
|
headers: Object.fromEntries(response.headers.entries()),
|
|
4827
5865
|
body: errorBody || void 0
|
|
4828
|
-
}
|
|
5866
|
+
},
|
|
5867
|
+
request: context.request
|
|
4829
5868
|
});
|
|
4830
|
-
return detail;
|
|
5869
|
+
return toPromptTooLongError(response.status, errorBody, detail) ?? new HTTPError(message, response, detail);
|
|
4831
5870
|
}
|
|
4832
5871
|
function getUpstreamErrorDetail(response, body) {
|
|
4833
5872
|
if (!body) return response.statusText || void 0;
|
|
@@ -4987,6 +6026,8 @@ async function createNativeMessages(config, payload, options) {
|
|
|
4987
6026
|
if (!response.ok) {
|
|
4988
6027
|
const detail = await readCopilotText(response, options.signal, config.upstreamTimeoutMs);
|
|
4989
6028
|
const contentType = response.headers.get("content-type") ?? "application/json";
|
|
6029
|
+
const promptTooLong = toPromptTooLongError(response.status, detail);
|
|
6030
|
+
if (promptTooLong) throw promptTooLong;
|
|
4990
6031
|
throw new HTTPError("Native upstream request failed", new Response(detail, {
|
|
4991
6032
|
status: response.status,
|
|
4992
6033
|
headers: { "content-type": contentType }
|
|
@@ -5216,7 +6257,7 @@ async function handleNativeMessages(config, payload, options, write) {
|
|
|
5216
6257
|
};
|
|
5217
6258
|
const upstreamToolResult = toolResult(searchCall.id, resultBlock.content);
|
|
5218
6259
|
const hasSiblingTools = message.content.some((block) => block.type === "tool_use" && block !== searchCall);
|
|
5219
|
-
let usage = mergeUsage(message.usage, searchMessage.usage);
|
|
6260
|
+
let usage$1 = mergeUsage(message.usage, searchMessage.usage);
|
|
5220
6261
|
const combined = [...displayDecision, resultBlock];
|
|
5221
6262
|
if (write) for (const [index, block] of combined.entries()) {
|
|
5222
6263
|
if (index < (heldFrom ?? message.content.length)) continue;
|
|
@@ -5227,13 +6268,13 @@ async function handleNativeMessages(config, payload, options, write) {
|
|
|
5227
6268
|
...message,
|
|
5228
6269
|
content: combined,
|
|
5229
6270
|
stop_reason: hasSiblingTools ? "tool_use" : "end_turn",
|
|
5230
|
-
usage
|
|
6271
|
+
usage: usage$1
|
|
5231
6272
|
};
|
|
5232
6273
|
if (!write) return complete;
|
|
5233
6274
|
await write({
|
|
5234
6275
|
type: "message_delta",
|
|
5235
6276
|
delta: { stop_reason: complete.stop_reason },
|
|
5236
|
-
usage
|
|
6277
|
+
usage: usage$1
|
|
5237
6278
|
});
|
|
5238
6279
|
await write({ type: "message_stop" });
|
|
5239
6280
|
return;
|
|
@@ -5266,11 +6307,11 @@ async function handleNativeMessages(config, payload, options, write) {
|
|
|
5266
6307
|
});
|
|
5267
6308
|
} : void 0);
|
|
5268
6309
|
if (finalMessage.content.some((block) => block.type === "tool_use" && isClaudeWebSearchToolName(block.name ?? ""))) throw new Error("Repeated bridge-managed search is unsupported.");
|
|
5269
|
-
usage = mergeUsage(usage, finalMessage.usage);
|
|
6310
|
+
usage$1 = mergeUsage(usage$1, finalMessage.usage);
|
|
5270
6311
|
if (!write) return {
|
|
5271
6312
|
...finalMessage,
|
|
5272
6313
|
content: [...combined, ...finalMessage.content],
|
|
5273
|
-
usage
|
|
6314
|
+
usage: usage$1
|
|
5274
6315
|
};
|
|
5275
6316
|
if (finalHeldFrom !== void 0) for (const [index, block] of finalMessage.content.entries()) {
|
|
5276
6317
|
if (index < finalHeldFrom) continue;
|
|
@@ -5283,7 +6324,7 @@ async function handleNativeMessages(config, payload, options, write) {
|
|
|
5283
6324
|
stop_sequence: finalMessage.stop_sequence,
|
|
5284
6325
|
..."stop_details" in finalMessage && { stop_details: finalMessage.stop_details }
|
|
5285
6326
|
},
|
|
5286
|
-
usage
|
|
6327
|
+
usage: usage$1
|
|
5287
6328
|
});
|
|
5288
6329
|
await write({ type: "message_stop" });
|
|
5289
6330
|
}
|
|
@@ -5452,6 +6493,10 @@ const createQueuedClaudeStreamWriter = (write) => {
|
|
|
5452
6493
|
const writeClaudeStreamEvents = async (events$1, writeEvent) => {
|
|
5453
6494
|
for (const event of events$1) await writeEvent(event);
|
|
5454
6495
|
};
|
|
6496
|
+
const translationEndpoint = (config, model) => {
|
|
6497
|
+
const selection = selectCopilotEndpoint(config, normalizeCopilotModelId(model));
|
|
6498
|
+
return selection.endpoint && !selection.responsesFallback ? selection.endpoint : void 0;
|
|
6499
|
+
};
|
|
5455
6500
|
const handleClaudeMessageRequest = async (config, claudePayload, requestSignal, requestId, writeEvent, requestHeaders) => {
|
|
5456
6501
|
const upstreamModel = translateModelName(claudePayload.model);
|
|
5457
6502
|
if (shouldUseNativeMessages(config, upstreamModel)) return handleNativeMessages(config, {
|
|
@@ -5465,7 +6510,7 @@ const handleClaudeMessageRequest = async (config, claudePayload, requestSignal,
|
|
|
5465
6510
|
const shouldLetModelDecideWebSearch = hasClaudeWebSearch(claudePayload);
|
|
5466
6511
|
const decisionPayload = shouldLetModelDecideWebSearch ? prepareClaudeWebSearchDecisionPayload(claudePayload) : claudePayload;
|
|
5467
6512
|
const toolNameMapper = createClaudeToolNameMapper(decisionPayload.tools, { ...getToolNameMapperOptionsForModel(upstreamModel) });
|
|
5468
|
-
const openAIPayload = translateToOpenAI(decisionPayload, void 0, toolNameMapper);
|
|
6513
|
+
const openAIPayload = translateToOpenAI(decisionPayload, void 0, toolNameMapper, { endpoint: translationEndpoint(config, upstreamModel) });
|
|
5469
6514
|
const canStreamWebSearchDecision = shouldLetModelDecideWebSearch && !!writeEvent;
|
|
5470
6515
|
if (shouldLetModelDecideWebSearch && !canStreamWebSearchDecision) openAIPayload.stream = false;
|
|
5471
6516
|
const response = await createChatCompletions(config, openAIPayload, {
|
|
@@ -5620,7 +6665,7 @@ claudeRoutes.get("/models", (c) => c.json({
|
|
|
5620
6665
|
claudeRoutes.post("/messages", async (c) => {
|
|
5621
6666
|
const config = c.get("config");
|
|
5622
6667
|
const requestId = c.get("requestId");
|
|
5623
|
-
const claudePayload = await c.req.json();
|
|
6668
|
+
const claudePayload = removeBillingLine(await c.req.json());
|
|
5624
6669
|
const requestSignal = createCopilotRequestSignal(c.req.raw.signal, config.upstreamTimeoutMs);
|
|
5625
6670
|
try {
|
|
5626
6671
|
getRequestReasoningEffort(claudePayload);
|
|
@@ -5709,7 +6754,7 @@ claudeRoutes.post("/messages", async (c) => {
|
|
|
5709
6754
|
claudeRoutes.post("/messages/count_tokens", async (c) => {
|
|
5710
6755
|
try {
|
|
5711
6756
|
const claudeBeta = c.req.header("claude-beta");
|
|
5712
|
-
const claudePayload = await c.req.json();
|
|
6757
|
+
const claudePayload = removeBillingLine(await c.req.json());
|
|
5713
6758
|
const openAIPayload = translateToOpenAI(shouldUseNativeMessages(c.get("config"), translateModelName(claudePayload.model)) ? {
|
|
5714
6759
|
...claudePayload,
|
|
5715
6760
|
messages: claudePayload.messages.map((message) => {
|
|
@@ -5717,7 +6762,7 @@ claudeRoutes.post("/messages/count_tokens", async (c) => {
|
|
|
5717
6762
|
const { output_config: _outputConfig, clear_at: _clearAt,...textMessage } = message;
|
|
5718
6763
|
return textMessage;
|
|
5719
6764
|
})
|
|
5720
|
-
} : claudePayload);
|
|
6765
|
+
} : claudePayload, void 0, void 0, { endpoint: translationEndpoint(c.get("config"), translateModelName(claudePayload.model)) });
|
|
5721
6766
|
const exposedModels = getExposedModelIds();
|
|
5722
6767
|
const upstreamModel = getCachedCopilotModel(c.get("config"), openAIPayload.model);
|
|
5723
6768
|
const hasDiscoveredTokenizer = upstreamModel?.tokenizer !== void 0 && isSupportedTokenizer(upstreamModel.tokenizer);
|
|
@@ -5781,6 +6826,22 @@ const parseAuthority = (authority, protocol) => {
|
|
|
5781
6826
|
};
|
|
5782
6827
|
const effectivePort = (url) => Number(url.port || (url.protocol === "https:" ? 443 : 80));
|
|
5783
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";
|
|
5784
6845
|
const createServer = (config) => {
|
|
5785
6846
|
const app = new Hono();
|
|
5786
6847
|
const { host, port } = config;
|
|
@@ -5824,6 +6885,18 @@ const createServer = (config) => {
|
|
|
5824
6885
|
return c.json({ error: { message } }, 403);
|
|
5825
6886
|
}
|
|
5826
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
|
+
}
|
|
5827
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") {
|
|
5828
6901
|
const message = "Content-Type must be application/json";
|
|
5829
6902
|
c.set("requestErrorMessage", message);
|
|
@@ -5904,7 +6977,14 @@ const startServer = (config) => new Promise((resolve$1, reject) => {
|
|
|
5904
6977
|
//#endregion
|
|
5905
6978
|
//#region src/lib/model-probe.ts
|
|
5906
6979
|
const record$1 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
5907
|
-
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
|
+
};
|
|
5908
6988
|
const httpCategory = (status$1) => {
|
|
5909
6989
|
if (status$1 === 401) return "authentication-rejected";
|
|
5910
6990
|
if (status$1 === 403) return "access-denied";
|
|
@@ -5961,9 +7041,9 @@ const failureFromEvidence = (row, diagnostic) => {
|
|
|
5961
7041
|
return row.detail;
|
|
5962
7042
|
};
|
|
5963
7043
|
async function probeModels(config, entries, options) {
|
|
5964
|
-
|
|
5965
|
-
|
|
5966
|
-
|
|
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`);
|
|
5967
7047
|
const controller = new AbortController();
|
|
5968
7048
|
const interrupt = () => controller.abort();
|
|
5969
7049
|
process.on("SIGINT", interrupt);
|
|
@@ -5977,9 +7057,9 @@ async function probeModels(config, entries, options) {
|
|
|
5977
7057
|
const results = [];
|
|
5978
7058
|
const app = createServer(config);
|
|
5979
7059
|
const columns = Math.max(40, process.stdout.columns || 80);
|
|
5980
|
-
const width = probeColumnWidth(entries.map(([id]) => safeId(id, config
|
|
7060
|
+
const width = probeColumnWidth(entries.map(([id]) => safeId(id, config) ? id : "[unsupported ID]"), columns);
|
|
5981
7061
|
const color = colorEnabled();
|
|
5982
|
-
if (entries.length)
|
|
7062
|
+
if (entries.length) printLine(renderProbeHeader(width));
|
|
5983
7063
|
try {
|
|
5984
7064
|
runtimeState.modelCatalog = config.modelCatalog;
|
|
5985
7065
|
runtimeState.upstreamBaseUrl = config.copilotBaseUrl;
|
|
@@ -6001,7 +7081,7 @@ async function probeModels(config, entries, options) {
|
|
|
6001
7081
|
const endpoint = selection.endpoint ?? "none";
|
|
6002
7082
|
const { advertisedEndpoints, unknownEndpoints } = summarizeAdvertisedEndpoints(model.supportedEndpoints);
|
|
6003
7083
|
const row = {
|
|
6004
|
-
id: safeId(id, config
|
|
7084
|
+
id: safeId(id, config) ? id : "[unsupported ID]",
|
|
6005
7085
|
status: "NOT_TESTED",
|
|
6006
7086
|
reported: "-",
|
|
6007
7087
|
sent: false,
|
|
@@ -6016,7 +7096,7 @@ async function probeModels(config, entries, options) {
|
|
|
6016
7096
|
unverified: model.reasoningEfforts === void 0 || model.supportedEndpoints === void 0
|
|
6017
7097
|
};
|
|
6018
7098
|
if (controller.signal.aborted || totalDeadline.aborted) row.detail = controller.signal.aborted ? "cancelled" : "total-deadline";
|
|
6019
|
-
else if (!safeId(id, config
|
|
7099
|
+
else if (!safeId(id, config) || normalizeCopilotModelId(id) !== id) {
|
|
6020
7100
|
row.status = "SKIPPED";
|
|
6021
7101
|
row.detail = "unsafe-or-noncanonical-id";
|
|
6022
7102
|
} else if (!selection.endpoint) {
|
|
@@ -6045,7 +7125,10 @@ async function probeModels(config, entries, options) {
|
|
|
6045
7125
|
const sendProbe = async () => {
|
|
6046
7126
|
const response = await app.fetch(new Request(`http://localhost${config.port ? `:${config.port}` : ""}/v1/messages`, {
|
|
6047
7127
|
method: "POST",
|
|
6048
|
-
headers: {
|
|
7128
|
+
headers: {
|
|
7129
|
+
"content-type": "application/json",
|
|
7130
|
+
...config.apiKey ? { "x-api-key": config.apiKey } : {}
|
|
7131
|
+
},
|
|
6049
7132
|
signal,
|
|
6050
7133
|
body: JSON.stringify({
|
|
6051
7134
|
model: id,
|
|
@@ -6086,7 +7169,7 @@ async function probeModels(config, entries, options) {
|
|
|
6086
7169
|
if (code === "model_not_supported") row.detail = "model-not-supported";
|
|
6087
7170
|
} else if (!record$1(body) || !Array.isArray(body.content)) row.detail = "malformed-response";
|
|
6088
7171
|
else {
|
|
6089
|
-
row.reported = safeId(body.model, config
|
|
7172
|
+
row.reported = safeId(body.model, config) ? body.model : "unreported";
|
|
6090
7173
|
if (!reportsSelectedModel(endpoint, id, row.reported)) row.detail = "model-mismatch";
|
|
6091
7174
|
else if (body.stop_reason === "max_tokens") {
|
|
6092
7175
|
row.status = "INCOMPLETE";
|
|
@@ -6118,11 +7201,11 @@ async function probeModels(config, entries, options) {
|
|
|
6118
7201
|
if (row.status === "FAIL") row.detail = failureFromEvidence(row, row.diagnostic);
|
|
6119
7202
|
}
|
|
6120
7203
|
}
|
|
6121
|
-
if (!safeId(id, config
|
|
7204
|
+
if (!safeId(id, config)) row.id = "[unsupported ID]";
|
|
6122
7205
|
results.push(row);
|
|
6123
|
-
for (const line of renderProbeRow(row, width, color, columns))
|
|
6124
|
-
if (options.details) for (const line of renderProbeDetails(row, row.diagnostic))
|
|
6125
|
-
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}`);
|
|
6126
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}`));
|
|
6127
7210
|
}
|
|
6128
7211
|
} finally {
|
|
@@ -6137,11 +7220,11 @@ async function probeModels(config, entries, options) {
|
|
|
6137
7220
|
process.off("SIGINT", interrupt);
|
|
6138
7221
|
process.off("SIGTERM", interrupt);
|
|
6139
7222
|
}
|
|
6140
|
-
|
|
6141
|
-
if (results.some((row) => row.unverified))
|
|
6142
|
-
const hints = new Set(results.filter((row) => row.status !== "PASS").map((row) => probeHint(row.detail)).filter(
|
|
6143
|
-
for (const hint of hints)
|
|
6144
|
-
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.");
|
|
6145
7228
|
if (controller.signal.aborted) return 130;
|
|
6146
7229
|
return results.length > 0 && results.every((row) => row.status === "PASS") ? 0 : 2;
|
|
6147
7230
|
}
|
|
@@ -6234,8 +7317,9 @@ const renderConfigGuide = (configPath$1, configured, chosen) => {
|
|
|
6234
7317
|
//#endregion
|
|
6235
7318
|
//#region src/models.ts
|
|
6236
7319
|
var ModelsUsageError = class extends Error {};
|
|
7320
|
+
const printable = (text$1) => scrubLogSecrets(terminalText(text$1));
|
|
6237
7321
|
const displayName = (name) => {
|
|
6238
|
-
const text$1 =
|
|
7322
|
+
const text$1 = printable(name ?? "").trim();
|
|
6239
7323
|
if (text$1 === "") return;
|
|
6240
7324
|
return text$1.length > 80 ? `${text$1.slice(0, 77)}...` : text$1;
|
|
6241
7325
|
};
|
|
@@ -6243,7 +7327,7 @@ const listedModels = (config, catalog) => [...catalog.models.keys()].sort().map(
|
|
|
6243
7327
|
const selection = selectCopilotEndpoint(config, id);
|
|
6244
7328
|
const name = displayName(catalog.models.get(id)?.name);
|
|
6245
7329
|
return {
|
|
6246
|
-
id:
|
|
7330
|
+
id: printable(id),
|
|
6247
7331
|
...name !== void 0 && { name },
|
|
6248
7332
|
...!selection.endpoint && { unusable: probeReason(selection.reason) }
|
|
6249
7333
|
};
|
|
@@ -6253,7 +7337,7 @@ const configuredModels = (appConfig, config, catalog) => ["gptModel", "opusModel
|
|
|
6253
7337
|
const selection = selectCopilotEndpoint(config, id);
|
|
6254
7338
|
return {
|
|
6255
7339
|
key,
|
|
6256
|
-
value:
|
|
7340
|
+
value: printable(appConfig[key]),
|
|
6257
7341
|
advertised: catalog.models.has(id),
|
|
6258
7342
|
...!selection.endpoint && { unusable: probeReason(selection.reason) }
|
|
6259
7343
|
};
|
|
@@ -6263,7 +7347,7 @@ const listingHeading = (search, result) => {
|
|
|
6263
7347
|
if (result.models.length === 0) return "No models advertised by upstream.";
|
|
6264
7348
|
return `Upstream-advertised models (${result.models.length}):`;
|
|
6265
7349
|
}
|
|
6266
|
-
const label =
|
|
7350
|
+
const label = printable(search);
|
|
6267
7351
|
if (result.found) return `Upstream models matching "${label}" (${result.models.length}):`;
|
|
6268
7352
|
if (result.models.length === 0) return `No upstream model matches "${label}". List them all with copilot-relay models.`;
|
|
6269
7353
|
return `No upstream model matches "${label}". Closest IDs (${result.models.length}):`;
|
|
@@ -6340,6 +7424,8 @@ const models = defineCommand({
|
|
|
6340
7424
|
const appConfig$1 = await readAppConfig();
|
|
6341
7425
|
setLogLevel(appConfig$1.logLevel);
|
|
6342
7426
|
registerSensitiveOrigin(appConfig$1.copilotBaseUrl);
|
|
7427
|
+
registerLogSecret(appConfig$1.apiKey);
|
|
7428
|
+
configureUpstreamDispatcher(appConfig$1.upstreamProxy);
|
|
6343
7429
|
const config$1 = readProxyConfig(appConfig$1);
|
|
6344
7430
|
failure = "Could not authenticate with GitHub Copilot";
|
|
6345
7431
|
await setupProxyAuth(config$1, args.deep ? { onDeviceCode: (url, code) => console.error(`Sign in: open ${terminalText(url)} and enter ${terminalText(code)}.`) } : void 0);
|
|
@@ -6370,7 +7456,7 @@ const models = defineCommand({
|
|
|
6370
7456
|
const lines = [listingHeading(search, result), ...renderModelRows(result.models)];
|
|
6371
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.`);
|
|
6372
7458
|
lines.push("", ...renderConfigGuide(paths.configPath, configuredModels(appConfig, config, catalog), result.chosen));
|
|
6373
|
-
console.log(scrubSensitiveUrls(
|
|
7459
|
+
console.log(lines.map((line) => scrubLogSecrets(scrubSensitiveUrls(terminalText(line)))).join("\n"));
|
|
6374
7460
|
if (!result.found) process.exitCode = 1;
|
|
6375
7461
|
} catch (error) {
|
|
6376
7462
|
let detail = "Check configuration, authentication, and upstream connectivity.";
|
|
@@ -6935,8 +8021,8 @@ const replay = defineCommand({
|
|
|
6935
8021
|
console.log(`${method} ${path$1} status=${status$1 ?? "unknown"} outcome=${outcome}`);
|
|
6936
8022
|
console.log(`upstream=${routes$1.join(" -> ") || "none"}`);
|
|
6937
8023
|
for (const exchange of result.summary.exchanges) {
|
|
6938
|
-
const usage = Object.entries(exchange.usage).map(([key, value]) => `${key}=${value}`).join(" ");
|
|
6939
|
-
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}` : ""}`);
|
|
6940
8026
|
}
|
|
6941
8027
|
if (result.summary.contentBlocks) console.log(`client_blocks=${result.summary.contentBlocks.join(",") || "none"} tool_calls=${result.summary.toolCalls ?? 0}`);
|
|
6942
8028
|
}
|
|
@@ -6953,6 +8039,22 @@ const getRelayBaseUrl = (host, port) => {
|
|
|
6953
8039
|
const hostname = new URL(`http://${authority}`).hostname;
|
|
6954
8040
|
return `http://${loopbackForWildcard.get(hostname) ?? hostname}:${port}`;
|
|
6955
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
|
+
};
|
|
6956
8058
|
|
|
6957
8059
|
//#endregion
|
|
6958
8060
|
//#region src/lib/lifecycle.ts
|
|
@@ -7389,18 +8491,20 @@ const knownModelEnvKeys = [
|
|
|
7389
8491
|
"ANTHROPIC_SMALL_FAST_MODEL",
|
|
7390
8492
|
"CLAUDE_CODE_SUBAGENT_MODEL"
|
|
7391
8493
|
];
|
|
7392
|
-
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
8494
|
+
const isRecord$1 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
7393
8495
|
const hasMalformedModelOverride = (parsed) => {
|
|
7394
8496
|
if (parsed.model !== void 0 && typeof parsed.model !== "string") return true;
|
|
7395
8497
|
const env = parsed.env;
|
|
7396
|
-
if (!isRecord(env)) return false;
|
|
8498
|
+
if (!isRecord$1(env)) return false;
|
|
7397
8499
|
return knownModelEnvKeys.some((key) => env[key] !== void 0 && typeof env[key] !== "string");
|
|
7398
8500
|
};
|
|
7399
8501
|
const hasPrimaryModelOverride = (parsed, env) => typeof parsed.model === "string" || typeof env.ANTHROPIC_MODEL === "string";
|
|
7400
8502
|
/**
|
|
7401
8503
|
* Update `~/.claude/settings.json` so its env block points Claude Code at the
|
|
7402
8504
|
* running proxy. Preserves unrelated keys and model choices while normalizing
|
|
7403
|
-
* 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.
|
|
7404
8508
|
*/
|
|
7405
8509
|
async function applyClaudeConfig(input) {
|
|
7406
8510
|
const { configPath: configPath$1, baseUrl, gptModel } = input;
|
|
@@ -7415,7 +8519,7 @@ async function applyClaudeConfig(input) {
|
|
|
7415
8519
|
};
|
|
7416
8520
|
if (raw.trim().length > 0) try {
|
|
7417
8521
|
const value = JSON.parse(raw);
|
|
7418
|
-
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 {
|
|
7419
8523
|
configPath: configPath$1,
|
|
7420
8524
|
changed: false,
|
|
7421
8525
|
created: false
|
|
@@ -7428,7 +8532,7 @@ async function applyClaudeConfig(input) {
|
|
|
7428
8532
|
created: false
|
|
7429
8533
|
};
|
|
7430
8534
|
}
|
|
7431
|
-
const env = isRecord(parsed.env) ? { ...parsed.env } : {};
|
|
8535
|
+
const env = isRecord$1(parsed.env) ? { ...parsed.env } : {};
|
|
7432
8536
|
const previousBaseUrl = typeof env.ANTHROPIC_BASE_URL === "string" ? env.ANTHROPIC_BASE_URL : void 0;
|
|
7433
8537
|
const hadPrimaryModelOverride = hasPrimaryModelOverride(parsed, env);
|
|
7434
8538
|
const contextWindow = input.gptLimits?.max_context_window_tokens;
|
|
@@ -7437,9 +8541,11 @@ async function applyClaudeConfig(input) {
|
|
|
7437
8541
|
const nextModel = typeof parsed.model === "string" ? normalizeClaudeModelId(parsed.model, contextWindow) : void 0;
|
|
7438
8542
|
for (const key of knownModelEnvKeys) if (typeof env[key] === "string") env[key] = normalizeClaudeModelId(env[key], contextWindow);
|
|
7439
8543
|
env.ANTHROPIC_BASE_URL = baseUrl;
|
|
7440
|
-
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";
|
|
7441
8546
|
if (contextWindow !== void 0 && env.CLAUDE_CODE_MAX_CONTEXT_TOKENS === void 0) env.CLAUDE_CODE_MAX_CONTEXT_TOKENS = String(contextWindow);
|
|
7442
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";
|
|
7443
8549
|
const next = {
|
|
7444
8550
|
...parsed,
|
|
7445
8551
|
...nextModel !== void 0 && { model: nextModel },
|
|
@@ -7447,13 +8553,15 @@ async function applyClaudeConfig(input) {
|
|
|
7447
8553
|
env
|
|
7448
8554
|
};
|
|
7449
8555
|
const serialized = `${JSON.stringify(next, null, 2)}\n`;
|
|
7450
|
-
|
|
8556
|
+
const writesKey = Boolean(input.apiKey);
|
|
8557
|
+
const keyExposed = writesKey && process.platform !== "win32" && (snapshot.mode & 63) !== 0;
|
|
8558
|
+
if (serialized === raw && !keyExposed) return {
|
|
7451
8559
|
configPath: configPath$1,
|
|
7452
8560
|
changed: false,
|
|
7453
8561
|
created: false,
|
|
7454
8562
|
previousBaseUrl
|
|
7455
8563
|
};
|
|
7456
|
-
await writeFileSnapshot(snapshot, serialized);
|
|
8564
|
+
await writeFileSnapshot(snapshot, serialized, { ownerOnly: writesKey });
|
|
7457
8565
|
return {
|
|
7458
8566
|
configPath: configPath$1,
|
|
7459
8567
|
changed: true,
|
|
@@ -7524,6 +8632,34 @@ const validateUpstream = async (config, thinkEffort) => {
|
|
|
7524
8632
|
//#endregion
|
|
7525
8633
|
//#region src/start.ts
|
|
7526
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
|
+
};
|
|
7527
8663
|
async function startRelay(appConfig) {
|
|
7528
8664
|
appConfig ??= await readAppConfig();
|
|
7529
8665
|
appConfig = {
|
|
@@ -7533,10 +8669,12 @@ async function startRelay(appConfig) {
|
|
|
7533
8669
|
setLogLevel(appConfig.logLevel);
|
|
7534
8670
|
await cleanupLogs(appConfig.logRetentionDays);
|
|
7535
8671
|
await cleanupCaptures(appConfig.logRetentionDays);
|
|
8672
|
+
configureUpstreamDispatcher(appConfig.upstreamProxy);
|
|
7536
8673
|
const claudeConfigPath$1 = claudeConfigPath;
|
|
7537
8674
|
const config = readProxyConfig(appConfig);
|
|
7538
8675
|
const applyRuntimeConfig = (nextConfig) => {
|
|
7539
8676
|
registerSensitiveOrigin(nextConfig.copilotBaseUrl);
|
|
8677
|
+
registerLogSecret(nextConfig.apiKey);
|
|
7540
8678
|
setLogLevel(nextConfig.logLevel);
|
|
7541
8679
|
cleanupLogs(nextConfig.logRetentionDays).catch(() => log.error("Log retention failed; existing logs retained."));
|
|
7542
8680
|
cleanupCaptures(nextConfig.logRetentionDays);
|
|
@@ -7550,6 +8688,7 @@ async function startRelay(appConfig) {
|
|
|
7550
8688
|
config.upstreamTimeoutMs = nextConfig.upstreamTimeoutSeconds * 1e3;
|
|
7551
8689
|
config.webSearchBackend = nextConfig.webSearchBackend;
|
|
7552
8690
|
config.claudeUpstreamApi = nextConfig.claudeUpstreamApi;
|
|
8691
|
+
config.apiKey = nextConfig.apiKey;
|
|
7553
8692
|
runtimeState.modelRouting = {
|
|
7554
8693
|
gptModel: nextConfig.gptModel,
|
|
7555
8694
|
opusModel: nextConfig.opusModel
|
|
@@ -7559,6 +8698,11 @@ async function startRelay(appConfig) {
|
|
|
7559
8698
|
log.info(`Log level: ${appConfig.logLevel}`);
|
|
7560
8699
|
log.info(`Default think effort: ${appConfig.thinkEffort}`);
|
|
7561
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);
|
|
7562
8706
|
const authSession = await setupProxyAuth(config);
|
|
7563
8707
|
try {
|
|
7564
8708
|
await validateUpstream(config, appConfig.thinkEffort);
|
|
@@ -7585,6 +8729,7 @@ async function startRelay(appConfig) {
|
|
|
7585
8729
|
const gptLimits = getCachedCopilotModel(config, appConfig.gptModel)?.limits;
|
|
7586
8730
|
const opusLimits = getCachedCopilotModel(config, appConfig.opusModel)?.limits;
|
|
7587
8731
|
const claudeResult = await applyClaudeConfig({
|
|
8732
|
+
apiKey: appConfig.apiKey,
|
|
7588
8733
|
baseUrl,
|
|
7589
8734
|
configPath: claudeConfigPath$1,
|
|
7590
8735
|
gptModel: appConfig.gptModel,
|
|
@@ -7599,8 +8744,11 @@ async function startRelay(appConfig) {
|
|
|
7599
8744
|
log.error(`Could not update claude settings (${claudeConfigPath$1}):`, error);
|
|
7600
8745
|
}
|
|
7601
8746
|
const configWatcher = watchAppConfig((nextConfig) => {
|
|
8747
|
+
const previousApiKey = config.apiKey;
|
|
7602
8748
|
applyRuntimeConfig(nextConfig);
|
|
7603
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);
|
|
7604
8752
|
});
|
|
7605
8753
|
const shutdownGraceMs = 2e3;
|
|
7606
8754
|
const shutdown = (signal) => {
|
|
@@ -7683,7 +8831,9 @@ const localProbeTimeoutMs = 5e3;
|
|
|
7683
8831
|
const deepProbeTimeoutMs = 6e4;
|
|
7684
8832
|
const toStatusConfig = (config) => ({
|
|
7685
8833
|
...config,
|
|
8834
|
+
apiKey: config.apiKey ? "[redacted]" : "",
|
|
7686
8835
|
copilotBaseUrl: formatUrlForDisplay(config.copilotBaseUrl),
|
|
8836
|
+
upstreamProxy: config.upstreamProxy === void 0 ? null : formatUpstreamProxyForDisplay(config.upstreamProxy),
|
|
7687
8837
|
webSearchBackend: config.webSearchBackend ?? null,
|
|
7688
8838
|
claudeUpstreamApi: config.claudeUpstreamApi ?? "chat-completions"
|
|
7689
8839
|
});
|
|
@@ -7737,27 +8887,35 @@ const formatUptime = (startedAt) => {
|
|
|
7737
8887
|
const configRowOrder = {
|
|
7738
8888
|
host: 0,
|
|
7739
8889
|
port: 1,
|
|
7740
|
-
|
|
7741
|
-
|
|
7742
|
-
|
|
7743
|
-
|
|
7744
|
-
|
|
7745
|
-
|
|
7746
|
-
|
|
7747
|
-
|
|
7748
|
-
|
|
7749
|
-
|
|
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
|
|
7750
8902
|
};
|
|
7751
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
|
+
};
|
|
7752
8909
|
/**
|
|
7753
8910
|
* The resolved config, one key per line.
|
|
7754
8911
|
*
|
|
7755
8912
|
* These are the values on disk. That is not the same question as "what is the
|
|
7756
8913
|
* running daemon honouring", and the footnote says so rather than leaving it
|
|
7757
|
-
* implied: applyRuntimeConfig() in start.ts never reads claudeSetup and
|
|
7758
|
-
* deliberately
|
|
7759
|
-
*
|
|
7760
|
-
*
|
|
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.
|
|
7761
8919
|
*
|
|
7762
8920
|
* Only shown when running, because with nothing up every value applies at the
|
|
7763
8921
|
* next start and the note would be noise.
|
|
@@ -7765,10 +8923,10 @@ const configRowKeys = Object.keys(configRowOrder).sort((a, b) => configRowOrder[
|
|
|
7765
8923
|
const renderConfig = (config, running) => {
|
|
7766
8924
|
const lines = configRowKeys.map((key) => {
|
|
7767
8925
|
const value = config[key];
|
|
7768
|
-
const rendered =
|
|
8926
|
+
const rendered = (value === null || value === "" ? unsetConfigText[key] : void 0) ?? String(value);
|
|
7769
8927
|
return ` ${key.padEnd(24)}${sanitizeTerminalString(rendered)}`;
|
|
7770
8928
|
});
|
|
7771
|
-
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.");
|
|
7772
8930
|
return lines;
|
|
7773
8931
|
};
|
|
7774
8932
|
/**
|
|
@@ -7796,9 +8954,26 @@ const renderStatus = (status$1, color = false) => {
|
|
|
7796
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");
|
|
7797
8955
|
return lines;
|
|
7798
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
|
+
} };
|
|
7799
8974
|
const probe = async (url, init, timeoutMs) => {
|
|
7800
8975
|
const started = performance.now();
|
|
7801
|
-
const response = await fetch(url, {
|
|
8976
|
+
const response = await relayListener.fetch(url, {
|
|
7802
8977
|
...init,
|
|
7803
8978
|
signal: AbortSignal.timeout(timeoutMs)
|
|
7804
8979
|
});
|
|
@@ -7843,9 +9018,10 @@ const checkHealth = async (baseUrl) => {
|
|
|
7843
9018
|
} };
|
|
7844
9019
|
}
|
|
7845
9020
|
};
|
|
7846
|
-
const
|
|
9021
|
+
const relayKeyHeaders = (apiKey) => apiKey ? { "x-api-key": apiKey } : {};
|
|
9022
|
+
const readModels = async (baseUrl, apiKey) => {
|
|
7847
9023
|
try {
|
|
7848
|
-
const data = (await probe(`${baseUrl}/v1/models`, {}, localProbeTimeoutMs)).body?.data;
|
|
9024
|
+
const data = (await probe(`${baseUrl}/v1/models`, { headers: relayKeyHeaders(apiKey) }, localProbeTimeoutMs)).body?.data;
|
|
7849
9025
|
return Array.isArray(data) ? data.flatMap((model) => typeof model.id === "string" ? [model.id] : []) : [];
|
|
7850
9026
|
} catch {
|
|
7851
9027
|
return [];
|
|
@@ -7857,7 +9033,7 @@ const readModels = async (baseUrl) => {
|
|
|
7857
9033
|
* Opt-in because it spends tokens. It is the only check that exercises token
|
|
7858
9034
|
* refresh, the upstream call, and translation back to Claude shape.
|
|
7859
9035
|
*/
|
|
7860
|
-
const checkDeep = async (baseUrl, model) => {
|
|
9036
|
+
const checkDeep = async (baseUrl, model, apiKey) => {
|
|
7861
9037
|
try {
|
|
7862
9038
|
const result = await probe(`${baseUrl}/v1/messages`, {
|
|
7863
9039
|
body: JSON.stringify({
|
|
@@ -7870,7 +9046,8 @@ const checkDeep = async (baseUrl, model) => {
|
|
|
7870
9046
|
}),
|
|
7871
9047
|
headers: {
|
|
7872
9048
|
"anthropic-version": "2023-06-01",
|
|
7873
|
-
"content-type": "application/json"
|
|
9049
|
+
"content-type": "application/json",
|
|
9050
|
+
...relayKeyHeaders(apiKey)
|
|
7874
9051
|
},
|
|
7875
9052
|
method: "POST"
|
|
7876
9053
|
}, deepProbeTimeoutMs);
|
|
@@ -7913,6 +9090,7 @@ const collectStatus = async (options) => {
|
|
|
7913
9090
|
});
|
|
7914
9091
|
setLogLevel(appConfig.logLevel);
|
|
7915
9092
|
registerSensitiveOrigin(appConfig.copilotBaseUrl);
|
|
9093
|
+
registerLogSecret(appConfig.apiKey);
|
|
7916
9094
|
const base = {
|
|
7917
9095
|
config: toStatusConfig(appConfig),
|
|
7918
9096
|
configPath: paths.configPath,
|
|
@@ -7928,8 +9106,8 @@ const collectStatus = async (options) => {
|
|
|
7928
9106
|
};
|
|
7929
9107
|
const baseUrl = getRelayBaseUrl(relay.host, relay.port);
|
|
7930
9108
|
const health = await checkHealth(baseUrl);
|
|
7931
|
-
const models$1 = health.result.ok ? await readModels(baseUrl) : [];
|
|
7932
|
-
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;
|
|
7933
9111
|
const daemonVersion = health.version ?? relay.version;
|
|
7934
9112
|
return {
|
|
7935
9113
|
...base,
|
|
@@ -7974,8 +9152,8 @@ const status = defineCommand({
|
|
|
7974
9152
|
process.exitCode = exitCodes.notUsable;
|
|
7975
9153
|
return;
|
|
7976
9154
|
}
|
|
7977
|
-
if (args.json) console.log(scrubSensitiveUrls(JSON.stringify(result, null, 2)));
|
|
7978
|
-
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")))));
|
|
7979
9157
|
process.exitCode = resolveExitCode(result);
|
|
7980
9158
|
}
|
|
7981
9159
|
});
|
|
@@ -8002,6 +9180,225 @@ const stop = defineCommand({
|
|
|
8002
9180
|
}
|
|
8003
9181
|
});
|
|
8004
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
|
+
|
|
8005
9402
|
//#endregion
|
|
8006
9403
|
//#region src/main.ts
|
|
8007
9404
|
await runMain(defineCommand({
|
|
@@ -8011,12 +9408,14 @@ await runMain(defineCommand({
|
|
|
8011
9408
|
},
|
|
8012
9409
|
subCommands: {
|
|
8013
9410
|
auth,
|
|
9411
|
+
cache,
|
|
8014
9412
|
models,
|
|
8015
9413
|
replay,
|
|
8016
9414
|
restart,
|
|
8017
9415
|
start,
|
|
8018
9416
|
status,
|
|
8019
|
-
stop
|
|
9417
|
+
stop,
|
|
9418
|
+
usage
|
|
8020
9419
|
}
|
|
8021
9420
|
}));
|
|
8022
9421
|
|