copilot-relay 0.4.5 → 0.4.7

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