copilot-relay 0.4.5 → 0.4.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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,6 +201,20 @@ const formatUrlForDisplay = (raw) => {
188
201
  return isOriginOnly(parsed) ? raw : `${parsed.origin} (path/query/fragment hidden)`;
189
202
  };
190
203
  /**
204
+ * Renders an upstreamProxy value for display: "env" as written, a proxy URL as
205
+ * its origin.
206
+ *
207
+ * A proxy URL may carry a user name and password. Neither is shown; a marker
208
+ * says they were there, so a reader can still tell the proxy authenticates.
209
+ * Never throws.
210
+ */
211
+ const formatUpstreamProxyForDisplay = (upstreamProxy) => {
212
+ if (upstreamProxy === "env") return upstreamProxy;
213
+ const parsed = parseHttpUrl(upstreamProxy);
214
+ if (!parsed) return "(invalid url)";
215
+ return parsed.username !== "" || parsed.password !== "" ? `${parsed.origin} (credentials hidden)` : parsed.origin;
216
+ };
217
+ /**
191
218
  * Strips ANSI escapes and control bytes from a string bound for the terminal.
192
219
  *
193
220
  * `status` prints config values directly to stdout. A config string carrying
@@ -253,7 +280,7 @@ const scrubSensitiveUrls = (text$1) => {
253
280
  const logCleanupCheckIntervalMs = 3600 * 1e3;
254
281
  let logRetentionDays = 3;
255
282
  let nextLogCleanupCheckAt = 0;
256
- const datedLogFilePattern = /* @__PURE__ */ new RegExp(`^${paths.logFileBaseName}\\.(\\d{4})-(\\d{2})-(\\d{2})\\.log$`);
283
+ const datedLogFilePattern$1 = /* @__PURE__ */ new RegExp(`^${paths.logFileBaseName}\\.(\\d{4})-(\\d{2})-(\\d{2})\\.log$`);
257
284
  const consolaLevelByName = {
258
285
  error: 0,
259
286
  info: 3,
@@ -268,6 +295,7 @@ const loggingSuppressed = new AsyncLocalStorage();
268
295
  const consoleSuppressed = new AsyncLocalStorage();
269
296
  const withoutLogging = (run) => loggingSuppressed.run(true, run);
270
297
  const withoutConsoleLogging = (run) => consoleSuppressed.run(true, run);
298
+ const isLoggingSuppressed = () => loggingSuppressed.getStore() === true;
271
299
  let currentLogLevel = consolaLevelByName.info;
272
300
  const registeredLogSecrets = /* @__PURE__ */ new Set();
273
301
  const logSecretForms = /* @__PURE__ */ new Set();
@@ -342,7 +370,7 @@ const formatLogValue = (value) => typeof value === "string" ? value : inspect(va
342
370
  maxStringLength: 4e3
343
371
  });
344
372
  const escapeLogLineSeparators = (value) => value.replaceAll("\r", "\\r").replaceAll("\n", "\\n").replaceAll(String.fromCodePoint(8232), "\\u2028").replaceAll(String.fromCodePoint(8233), "\\u2029");
345
- const isMissing$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);
@@ -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, as every release before #153 did. "env" hands HTTPS_PROXY, HTTP_PROXY
599
+ * and NO_PROXY, in upper or lower case, to EnvHttpProxyAgent. Anything else is a proxy URL that
600
+ * normalizeUpstreamProxy has already validated.
601
+ */
602
+ const buildUpstreamDispatcher = (upstreamProxy) => {
603
+ if (upstreamProxy === void 0) return new Agent(connectionOptions);
604
+ if (upstreamProxy === "env") try {
605
+ return new EnvHttpProxyAgent(connectionOptions);
606
+ } catch {
607
+ throw new InvalidProxyEnvironmentError();
608
+ }
609
+ return new ProxyAgent({
610
+ ...connectionOptions,
611
+ uri: upstreamProxy
612
+ });
613
+ };
614
+ let upstreamDispatcher = buildUpstreamDispatcher(void 0);
615
+ let configuredUpstreamProxy;
616
+ /**
617
+ * The proxy URLs a dispatcher for this upstreamProxy can send through: none when it is empty, the
618
+ * URL itself, or with "env" each proxy variable EnvHttpProxyAgent reads, the lower-case spelling
619
+ * first, as undici does.
620
+ */
621
+ const proxyUrlsFor = (upstreamProxy) => {
622
+ if (upstreamProxy === void 0) return [];
623
+ if (upstreamProxy !== "env") return [upstreamProxy];
624
+ return [process.env.http_proxy ?? process.env.HTTP_PROXY, process.env.https_proxy ?? process.env.HTTPS_PROXY].filter((value) => value !== void 0 && value !== "");
625
+ };
626
+ const decodeOrKeep = (value) => {
627
+ try {
628
+ return decodeURIComponent(value);
629
+ } catch {
630
+ return value;
631
+ }
632
+ };
633
+ const parseProxyUrl = (proxyUrl) => {
634
+ try {
635
+ return new URL(proxyUrl);
636
+ } catch {
637
+ return;
638
+ }
639
+ };
640
+ /**
641
+ * The forms a proxy's credentials take together: user:password as written in the URL and decoded,
642
+ * and the Basic value undici sends in Proxy-Authorization. None when the URL lacks either part,
643
+ * because undici then sends no Proxy-Authorization at all.
644
+ */
645
+ const credentialPairForms = (proxyUrl) => {
646
+ const url = parseProxyUrl(proxyUrl);
647
+ if (url === void 0 || url.username === "" || url.password === "") return [];
648
+ const decoded$1 = `${decodeOrKeep(url.username)}:${decodeOrKeep(url.password)}`;
649
+ return [
650
+ `${url.username}:${url.password}`,
651
+ decoded$1,
652
+ Buffer.from(decoded$1).toString("base64")
653
+ ];
654
+ };
655
+ const credentialForms = (proxyUrl) => {
656
+ const url = parseProxyUrl(proxyUrl);
657
+ if (url === void 0) return [];
658
+ return [
659
+ ...credentialPairForms(proxyUrl),
660
+ url.username,
661
+ decodeOrKeep(url.username),
662
+ url.password,
663
+ decodeOrKeep(url.password)
664
+ ].filter((form) => form !== "");
665
+ };
666
+ const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
667
+ let activeCredentialPattern;
668
+ const minimumRegisteredLength = 8;
669
+ /**
670
+ * Builds this process's upstream dispatcher from the resolved upstreamProxy, and registers the
671
+ * credentials of each proxy it can use with the log redaction.
672
+ *
673
+ * start, auth, models and usage call it once, after reading the config and before their first
674
+ * upstream call. Until one does, upstream calls connect directly, as they did before the key
675
+ * existed. A config reload never calls it, which is why upstreamProxy, like host and port, takes
676
+ * effect on restart, and why a reload registers no credentials. It is never undici's global
677
+ * dispatcher, and calls to the relay itself never use it.
678
+ *
679
+ * Only the user:password pair and the Basic value are registered, never a user name or password
680
+ * alone: registerLogSecret replaces a value in every log line, and a short or common user name such
681
+ * as "copilot" would also cut it out of "copilot-relay".
682
+ */
683
+ const configureUpstreamDispatcher = (upstreamProxy) => {
684
+ const previous = upstreamDispatcher;
685
+ upstreamDispatcher = buildUpstreamDispatcher(upstreamProxy);
686
+ configuredUpstreamProxy = upstreamProxy;
687
+ const proxyUrls = proxyUrlsFor(upstreamProxy);
688
+ for (const form of proxyUrls.flatMap((proxyUrl) => credentialPairForms(proxyUrl))) if (form.length >= minimumRegisteredLength) registerLogSecret(form);
689
+ const forms = [...new Set(proxyUrls.flatMap((proxyUrl) => credentialForms(proxyUrl)))].sort((left, right) => right.length - left.length);
690
+ activeCredentialPattern = forms.length === 0 ? void 0 : new RegExp(forms.map((form) => escapeRegExp(form)).join("|"), "g");
691
+ previous.close().catch(() => void 0);
692
+ };
693
+ const proxyVariables = [
694
+ "HTTPS_PROXY",
695
+ "https_proxy",
696
+ "HTTP_PROXY",
697
+ "http_proxy"
698
+ ];
699
+ let proxyHintLogged = false;
700
+ /**
701
+ * Logs, once per process, that a proxy the environment names was not used.
702
+ *
703
+ * Only for a request that failed without an HTTP response while upstreamProxy is empty: the relay
704
+ * ignored HTTPS_PROXY or HTTP_PROXY and went direct, and those variables suggest the network
705
+ * expects a proxy. A cancelled request or a passed deadline is not that kind of failure.
706
+ *
707
+ * The hint counts as logged only once it is written. Under withoutLogging, where models --deep
708
+ * runs its probes, log.error writes nothing, so a failure there leaves the hint for a later one.
709
+ */
710
+ const noteConnectionFailure = (signal) => {
711
+ if (proxyHintLogged || configuredUpstreamProxy !== void 0 || signal?.aborted || isLoggingSuppressed()) return;
712
+ if (!proxyVariables.some((name) => process.env[name])) return;
713
+ proxyHintLogged = true;
714
+ log.error(`Upstream request failed without a response. HTTPS_PROXY or HTTP_PROXY is set, but upstreamProxy is empty, so copilot-relay connected directly. To use that proxy, set upstreamProxy: env in ${paths.configPath}, then run the command again or restart the relay.`);
715
+ };
716
+ const redacted = "[redacted]";
717
+ const rawPayloadKeys = ["data", "body"];
718
+ const replaceText = (error, key, value) => {
719
+ try {
720
+ Object.defineProperty(error, key, {
721
+ configurable: true,
722
+ enumerable: false,
723
+ value,
724
+ writable: true
725
+ });
726
+ } catch {}
727
+ };
728
+ const scrubStackHead = (stack, pattern) => {
729
+ const frames = stack.search(/\n\s+at /);
730
+ if (frames === -1) return stack.replace(pattern, redacted);
731
+ return stack.slice(0, frames).replace(pattern, redacted) + stack.slice(frames);
732
+ };
733
+ /**
734
+ * Cleans the error of an upstream request that failed without a response, in place, so it keeps
735
+ * its type, code and cause, which callers read.
736
+ *
737
+ * It drops the raw payload undici can keep from the peer anywhere in the cause chain: a proxy that
738
+ * answers CONNECT with a reply undici cannot parse leaves the rest of that reply in
739
+ * HTTPParserError.data, and a reply that echoes Proxy-Authorization would carry the password into
740
+ * every log line that prints the error. It also replaces every form of the active proxy's
741
+ * credentials in each message, short ones and a user name or password alone included: unlike the
742
+ * log redaction, only this error changes.
743
+ */
744
+ const sanitizeTransportError = (error, seen = /* @__PURE__ */ new Set()) => {
745
+ if (!(error instanceof Error) || seen.has(error)) return;
746
+ seen.add(error);
747
+ for (const key of rawPayloadKeys) Reflect.deleteProperty(error, key);
748
+ if (activeCredentialPattern !== void 0) {
749
+ if (typeof error.message === "string") {
750
+ const message = error.message.replace(activeCredentialPattern, redacted);
751
+ if (message !== error.message) replaceText(error, "message", message);
752
+ }
753
+ if (typeof error.stack === "string") {
754
+ const stack = scrubStackHead(error.stack, activeCredentialPattern);
755
+ if (stack !== error.stack) replaceText(error, "stack", stack);
756
+ }
757
+ }
758
+ sanitizeTransportError(error.cause, seen);
759
+ if (error instanceof AggregateError) for (const inner of error.errors) sanitizeTransportError(inner, seen);
760
+ };
761
+ /**
762
+ * fetch for an upstream call, through this process's upstream dispatcher.
763
+ *
764
+ * A request that fails without a response rejects with its error cleaned by
765
+ * sanitizeTransportError, before any caller can log it. The undici response is rewrapped as a
766
+ * global Response, so callers, HTTPError included, keep the Response type the rest of the relay
767
+ * uses.
768
+ */
769
+ const fetchUpstream = async (url, init) => {
770
+ const response = await fetch(url, {
771
+ ...init,
772
+ dispatcher: upstreamDispatcher
773
+ }).catch((error) => {
774
+ sanitizeTransportError(error);
775
+ noteConnectionFailure(init.signal);
776
+ throw error;
777
+ });
778
+ const wrappedResponse = new Response(response.body, {
779
+ status: response.status,
780
+ statusText: response.statusText,
781
+ headers: response.headers
782
+ });
783
+ Object.defineProperty(wrappedResponse, "url", { value: response.url });
784
+ return wrappedResponse;
785
+ };
786
+
549
787
  //#endregion
550
788
  //#region src/lib/auth.ts
551
789
  const copilotVersion$1 = "0.26.7";
@@ -570,6 +808,18 @@ const readGitHubToken = async () => {
570
808
  registerLogSecret(token.trim());
571
809
  return token.trim();
572
810
  };
811
+ const readStoredGitHubToken = async () => {
812
+ let content;
813
+ try {
814
+ content = await fs.readFile(paths.githubTokenPath, "utf8");
815
+ } catch (error) {
816
+ if (error.code === "ENOENT") return;
817
+ throw error;
818
+ }
819
+ const token = content.trim();
820
+ registerLogSecret(token);
821
+ return token === "" ? void 0 : token;
822
+ };
573
823
  const writeGitHubToken = async (token) => {
574
824
  registerLogSecret(token.trim());
575
825
  await ensurePaths();
@@ -632,7 +882,7 @@ const githubHeaders = (githubToken, vsCodeVersion) => ({
632
882
  "x-vscode-user-agent-library-version": "electron-fetch"
633
883
  });
634
884
  const getDeviceCode = async () => {
635
- const response = await 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
  *
@@ -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,499 @@ const auth = defineCommand({
1382
1783
  }
1383
1784
  });
1384
1785
 
1786
+ //#endregion
1787
+ //#region src/lib/cache-report.ts
1788
+ const completionRoutes = [
1789
+ "/chat/completions",
1790
+ "/responses",
1791
+ "/v1/messages"
1792
+ ];
1793
+ /** A flag the command cannot use. The message is shown to the user as it is. */
1794
+ var CacheUsageError = class extends Error {};
1795
+ const defaultGoal = 95;
1796
+ const timestampPattern = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
1797
+ const fieldPattern = /^([A-Za-z0-9_]+)=(\S+)$/;
1798
+ const countPattern = /^\d+$/;
1799
+ const countFields = [
1800
+ "input_tokens",
1801
+ "cache_read_input_tokens",
1802
+ "cache_creation_input_tokens"
1803
+ ];
1804
+ const isCompletionRoute = (value) => completionRoutes.some((route) => route === value);
1805
+ const parseTimestamp = (text$1) => {
1806
+ if (!timestampPattern.test(text$1)) return;
1807
+ const date = new Date(text$1);
1808
+ if (Number.isNaN(date.getTime()) || date.toISOString() !== text$1) return;
1809
+ return date;
1810
+ };
1811
+ const isCount = (value) => value !== void 0 && countPattern.test(value) && Number.isSafeInteger(Number(value));
1812
+ const countField = (fields, key) => {
1813
+ const value = fields.get(key);
1814
+ return value === void 0 ? void 0 : Number(value);
1815
+ };
1816
+ /**
1817
+ * The completion entry on a log line, or undefined for any other line.
1818
+ *
1819
+ * Only a successful call is returned: an info entry for HTTP 200 on one of the three routes, with
1820
+ * a numeric input_tokens. A line that is not exactly what the relay writes is skipped rather than
1821
+ * guessed at, since one bad count would skew a whole row.
1822
+ */
1823
+ const parseCompletionLine = (line) => {
1824
+ if (!line.includes(" completion path=")) return;
1825
+ const tokens = line.split(" ");
1826
+ const timestamp = parseTimestamp(tokens[0]);
1827
+ const marker = tokens.indexOf("completion", 2);
1828
+ if (timestamp === void 0 || tokens[1] !== "info" || marker === -1) return;
1829
+ if (!tokens.slice(2, marker).every((token) => fieldPattern.test(token))) return;
1830
+ const fields = /* @__PURE__ */ new Map();
1831
+ for (const token of tokens.slice(marker + 1)) {
1832
+ const field = fieldPattern.exec(token);
1833
+ if (field === null || fields.has(field[1])) return;
1834
+ fields.set(field[1], field[2]);
1835
+ }
1836
+ if (countFields.some((key) => fields.has(key) && !isCount(fields.get(key)))) return;
1837
+ const route = fields.get("path");
1838
+ const inputTokens = countField(fields, "input_tokens");
1839
+ const cacheReadTokens = countField(fields, "cache_read_input_tokens");
1840
+ const cacheWriteTokens = countField(fields, "cache_creation_input_tokens");
1841
+ if (!isCompletionRoute(route) || fields.get("http_status") !== "200" || inputTokens === void 0) return;
1842
+ if (route !== "/v1/messages" && cacheReadTokens !== void 0 && cacheReadTokens > inputTokens) return;
1843
+ return {
1844
+ timestamp,
1845
+ model: terminalText(fields.get("model") ?? "") || "unknown",
1846
+ route,
1847
+ inputTokens,
1848
+ ...cacheReadTokens !== void 0 && { cacheReadTokens },
1849
+ ...cacheWriteTokens !== void 0 && { cacheWriteTokens }
1850
+ };
1851
+ };
1852
+ const datedLogFilePattern = /* @__PURE__ */ new RegExp(`^${paths.logFileBaseName}\\.(\\d{4})-(\\d{2})-(\\d{2})\\.log$`);
1853
+ const dayNumber = (year, month, day) => year * 1e4 + month * 100 + day;
1854
+ const maxLineLength = 1024 * 1024;
1855
+ const isMissing = (error) => error.code === "ENOENT";
1856
+ const readLogDirectory = async () => {
1857
+ try {
1858
+ return await fs.readdir(paths.logsDir, { withFileTypes: true });
1859
+ } catch (error) {
1860
+ if (isMissing(error)) return [];
1861
+ throw error;
1862
+ }
1863
+ };
1864
+ /**
1865
+ * The dated log files that can hold entries at or after `since`; all of them without it.
1866
+ *
1867
+ * A file is named for the local date its entries were written on. UTC offsets span 26 hours, from
1868
+ * UTC-12 to UTC+14, so after a time-zone change an entry can sit in a file dated up to two days
1869
+ * before the date the current zone gives `since`. Those days are read too, and every entry is then
1870
+ * checked against `since` itself.
1871
+ */
1872
+ const listLogFiles = async (since) => {
1873
+ let earliestDay = Number.NEGATIVE_INFINITY;
1874
+ if (since !== void 0) {
1875
+ const twoDaysBefore = new Date(since.getTime());
1876
+ twoDaysBefore.setDate(twoDaysBefore.getDate() - 2);
1877
+ if (!Number.isNaN(twoDaysBefore.getTime())) earliestDay = dayNumber(twoDaysBefore.getFullYear(), twoDaysBefore.getMonth() + 1, twoDaysBefore.getDate());
1878
+ }
1879
+ return (await readLogDirectory()).filter((entry) => {
1880
+ const date = datedLogFilePattern.exec(entry.name);
1881
+ if (!entry.isFile() || date === null) return false;
1882
+ return dayNumber(Number(date[1]), Number(date[2]), Number(date[3])) >= earliestDay;
1883
+ }).map((entry) => path.join(paths.logsDir, entry.name));
1884
+ };
1885
+ const withoutCarriageReturn = (line) => line.endsWith("\r") ? line.slice(0, -1) : line;
1886
+ /**
1887
+ * The complete lines of a file. A last line without its line break is dropped: the relay may still
1888
+ * be appending it, and half an entry can hold a cut-off count that still parses as a number.
1889
+ */
1890
+ async function* readLines(filePath) {
1891
+ const stream = createReadStream(filePath, { encoding: "utf8" });
1892
+ let partial = "";
1893
+ let oversized = false;
1894
+ for await (const chunk of stream) {
1895
+ const pieces = chunk.split("\n");
1896
+ const unfinished = pieces.pop() ?? "";
1897
+ for (const piece of pieces) {
1898
+ if (!oversized) yield withoutCarriageReturn(partial + piece);
1899
+ partial = "";
1900
+ oversized = false;
1901
+ }
1902
+ if (!oversized) partial += unfinished;
1903
+ if (partial.length > maxLineLength) {
1904
+ partial = "";
1905
+ oversized = true;
1906
+ }
1907
+ }
1908
+ }
1909
+ const isInWindow = (record$3, since) => since === void 0 || record$3.timestamp.getTime() >= since.getTime();
1910
+ async function* readCompletionRecords(since) {
1911
+ for (const filePath of await listLogFiles(since)) try {
1912
+ for await (const line of readLines(filePath)) {
1913
+ const record$3 = parseCompletionLine(line);
1914
+ if (record$3 !== void 0 && isInWindow(record$3, since)) yield record$3;
1915
+ }
1916
+ } catch (error) {
1917
+ if (!isMissing(error)) throw error;
1918
+ }
1919
+ }
1920
+ const emptyTotals = (bucket, record$3) => ({
1921
+ bucket: bucket.label,
1922
+ ...bucket.utcOffset !== void 0 && { utcOffset: bucket.utcOffset },
1923
+ firstCall: record$3.timestamp.getTime(),
1924
+ model: record$3.model,
1925
+ route: record$3.route,
1926
+ requests: 0,
1927
+ unknownCacheRequests: 0,
1928
+ zeroCacheReadRequests: 0,
1929
+ totalInputTokens: 0,
1930
+ cacheReadTokens: 0,
1931
+ cacheWriteTokens: null
1932
+ });
1933
+ const addRecord = (totals, record$3) => {
1934
+ totals.requests += 1;
1935
+ totals.firstCall = Math.min(totals.firstCall, record$3.timestamp.getTime());
1936
+ if (record$3.cacheReadTokens === void 0) {
1937
+ totals.unknownCacheRequests += 1;
1938
+ return;
1939
+ }
1940
+ const totalInput = record$3.route === "/v1/messages" ? record$3.inputTokens + record$3.cacheReadTokens + (record$3.cacheWriteTokens ?? 0) : record$3.inputTokens;
1941
+ totals.totalInputTokens += totalInput;
1942
+ totals.cacheReadTokens += record$3.cacheReadTokens;
1943
+ if (record$3.cacheReadTokens === 0) totals.zeroCacheReadRequests += 1;
1944
+ if (record$3.cacheWriteTokens !== void 0) totals.cacheWriteTokens = (totals.cacheWriteTokens ?? 0) + record$3.cacheWriteTokens;
1945
+ };
1946
+ const hitBasisPoints = (cacheReadTokens, totalInputTokens) => Number(BigInt(cacheReadTokens) * 10000n / BigInt(totalInputTokens));
1947
+ const toRow = (totals, goal) => {
1948
+ const hasInput = totals.totalInputTokens > 0;
1949
+ return {
1950
+ bucket: totals.bucket,
1951
+ model: totals.model,
1952
+ route: totals.route,
1953
+ requests: totals.requests,
1954
+ unknownCacheRequests: totals.unknownCacheRequests,
1955
+ zeroCacheReadRequests: totals.zeroCacheReadRequests,
1956
+ totalInputTokens: totals.totalInputTokens,
1957
+ cacheReadTokens: totals.cacheReadTokens,
1958
+ uncachedInputTokens: totals.totalInputTokens - totals.cacheReadTokens,
1959
+ cacheWriteTokens: totals.cacheWriteTokens,
1960
+ hitRate: hasInput ? totals.cacheReadTokens / totals.totalInputTokens : null,
1961
+ belowGoal: hasInput && hitBasisPoints(totals.cacheReadTokens, totals.totalInputTokens) < Math.round(goal * 100)
1962
+ };
1963
+ };
1964
+ const twoDigits = (value) => String(value).padStart(2, "0");
1965
+ const formatUtcOffset = (timestamp) => {
1966
+ const minutes = -timestamp.getTimezoneOffset();
1967
+ const sign = minutes < 0 ? "-" : "+";
1968
+ const absolute = Math.abs(minutes);
1969
+ return `UTC${sign}${twoDigits(Math.floor(absolute / 60))}:${twoDigits(absolute % 60)}`;
1970
+ };
1971
+ const bucketOf = (timestamp, view) => {
1972
+ if (view === "daily") return { label: formatLogDate(timestamp) };
1973
+ if (view === "hourly") return {
1974
+ label: `${formatLogDate(timestamp)} ${twoDigits(timestamp.getHours())}:00`,
1975
+ utcOffset: formatUtcOffset(timestamp)
1976
+ };
1977
+ return { label: null };
1978
+ };
1979
+ const compareText = (left, right) => {
1980
+ if (left < right) return -1;
1981
+ return left > right ? 1 : 0;
1982
+ };
1983
+ const segmentOf = (totals) => JSON.stringify([totals.bucket, totals.utcOffset ?? null]);
1984
+ const sortTotals = (groups) => {
1985
+ const firstCalls = /* @__PURE__ */ new Map();
1986
+ for (const totals of groups) {
1987
+ const segment = segmentOf(totals);
1988
+ firstCalls.set(segment, Math.min(firstCalls.get(segment) ?? totals.firstCall, totals.firstCall));
1989
+ }
1990
+ const firstCallOf = (totals) => firstCalls.get(segmentOf(totals)) ?? totals.firstCall;
1991
+ return [...groups].sort((left, right) => firstCallOf(left) - firstCallOf(right) || compareText(left.model, right.model) || compareText(left.route, right.route));
1992
+ };
1993
+ const labelRepeatedHours = (sorted) => {
1994
+ const offsetsByLabel = /* @__PURE__ */ new Map();
1995
+ for (const totals of sorted) if (totals.utcOffset !== void 0 && totals.bucket !== null) {
1996
+ const offsets = offsetsByLabel.get(totals.bucket) ?? /* @__PURE__ */ new Set();
1997
+ offsets.add(totals.utcOffset);
1998
+ offsetsByLabel.set(totals.bucket, offsets);
1999
+ }
2000
+ for (const totals of sorted) {
2001
+ const offsets = totals.bucket === null ? void 0 : offsetsByLabel.get(totals.bucket);
2002
+ if (totals.utcOffset !== void 0 && offsets !== void 0 && offsets.size > 1) totals.bucket = `${totals.bucket} ${totals.utcOffset}`;
2003
+ }
2004
+ };
2005
+ /** Reads the retained logs and returns one row per bucket, model and route. */
2006
+ const buildCacheReport = async (options) => {
2007
+ parseGoal(String(options.goal));
2008
+ const modelFilter = options.model?.toLowerCase();
2009
+ const groups = /* @__PURE__ */ new Map();
2010
+ for await (const record$3 of readCompletionRecords(options.since)) {
2011
+ if (modelFilter !== void 0 && !record$3.model.toLowerCase().includes(modelFilter)) continue;
2012
+ const bucket = bucketOf(record$3.timestamp, options.view);
2013
+ const key = JSON.stringify([
2014
+ bucket.label,
2015
+ bucket.utcOffset ?? null,
2016
+ record$3.model,
2017
+ record$3.route
2018
+ ]);
2019
+ let totals = groups.get(key);
2020
+ if (totals === void 0) {
2021
+ totals = emptyTotals(bucket, record$3);
2022
+ groups.set(key, totals);
2023
+ }
2024
+ addRecord(totals, record$3);
2025
+ }
2026
+ const sorted = sortTotals([...groups.values()]);
2027
+ labelRepeatedHours(sorted);
2028
+ return sorted.map((totals) => toRow(totals, options.goal));
2029
+ };
2030
+ const minuteMs = 60 * 1e3;
2031
+ const hourMs = 60 * minuteMs;
2032
+ const dayMs = 24 * hourMs;
2033
+ const durationUnits = {
2034
+ m: minuteMs,
2035
+ h: hourMs,
2036
+ d: dayMs
2037
+ };
2038
+ const durationPattern = /^(\d+)([mhd])$/i;
2039
+ const isoTimePattern = /^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2})(?:\.\d+)?)?(?:Z|[+-]\d{2}:\d{2})?)?$/i;
2040
+ const sinceUsage = "--since needs a duration such as 30m, 6h or 2d, or an ISO date or time such as 2026-10-03 or 2026-10-03T09:00.";
2041
+ const goalPattern = /^(\d+(?:\.\d{1,2})?)%?$/;
2042
+ const goalUsage = "--goal needs a percentage from 0 to 100 with at most two decimals, such as --goal 95 or --goal 97.5.";
2043
+ const isCalendarDate = (year, month, day) => {
2044
+ const date = new Date(year, month - 1, day);
2045
+ return date.getFullYear() === year && date.getMonth() === month - 1 && date.getDate() === day;
2046
+ };
2047
+ const parseIsoTime = (text$1) => {
2048
+ const match = isoTimePattern.exec(text$1);
2049
+ if (match === null) return;
2050
+ const [, year, month, day, hour, minute, second = "00"] = match;
2051
+ if (!isCalendarDate(Number(year), Number(month), Number(day))) return;
2052
+ if (hour === void 0) return new Date(Number(year), Number(month) - 1, Number(day));
2053
+ if (Number(hour) > 23 || Number(minute) > 59 || Number(second) > 59) return;
2054
+ const time = Date.parse(text$1.toUpperCase().replace(" ", "T"));
2055
+ return Number.isNaN(time) ? void 0 : new Date(time);
2056
+ };
2057
+ /**
2058
+ * The start of the window for --since: a duration counted back from `now`, or an ISO date or
2059
+ * time. A date, or a time without Z or an offset, is local, like every other time in the report.
2060
+ */
2061
+ const parseSince = (value, now) => {
2062
+ const text$1 = value.trim();
2063
+ const duration = durationPattern.exec(text$1);
2064
+ if (duration !== null) {
2065
+ const unit = durationUnits[duration[2].toLowerCase()];
2066
+ const start$1 = new Date(now.getTime() - Number(duration[1]) * unit);
2067
+ if (Number.isNaN(start$1.getTime())) throw new CacheUsageError(sinceUsage);
2068
+ return start$1;
2069
+ }
2070
+ const time = parseIsoTime(text$1);
2071
+ if (time === void 0) throw new CacheUsageError(sinceUsage);
2072
+ return time;
2073
+ };
2074
+ /** The hit-rate goal for --goal, in percent with at most two decimals, with or without a trailing "%". */
2075
+ const parseGoal = (value) => {
2076
+ const match = goalPattern.exec(value.trim());
2077
+ if (match === null || Number(match[1]) > 100) throw new CacheUsageError(goalUsage);
2078
+ return Number(match[1]);
2079
+ };
2080
+ const singleValue = (value, flag) => {
2081
+ if (value === void 0) return;
2082
+ if (typeof value !== "string") throw new CacheUsageError(`Give ${flag} once.`);
2083
+ return value;
2084
+ };
2085
+ /**
2086
+ * The report options for the command's flags.
2087
+ *
2088
+ * The summary and the hourly trend cover the last 24 hours by default, and the daily trend every
2089
+ * retained day. --since replaces either default.
2090
+ */
2091
+ const resolveCacheOptions = (args, now) => {
2092
+ if (args.hourly && args.daily) throw new CacheUsageError("Choose one of --hourly and --daily.");
2093
+ let view = "summary";
2094
+ if (args.hourly) view = "hourly";
2095
+ else if (args.daily) view = "daily";
2096
+ const sinceText = singleValue(args.since, "--since");
2097
+ const goalText = singleValue(args.goal, "--goal");
2098
+ const model = singleValue(args.model, "--model")?.trim();
2099
+ let since;
2100
+ if (sinceText !== void 0) since = parseSince(sinceText, now);
2101
+ else if (view !== "daily") since = new Date(now.getTime() - dayMs);
2102
+ if (model === "") throw new CacheUsageError("--model needs part of a model name, such as --model opus.");
2103
+ return {
2104
+ view,
2105
+ goal: goalText === void 0 ? defaultGoal : parseGoal(goalText),
2106
+ ...since !== void 0 && { since },
2107
+ ...model !== void 0 && { model }
2108
+ };
2109
+ };
2110
+ const formatCount = (value) => value.toLocaleString("en-US");
2111
+ const formatHitRate = (row) => {
2112
+ if (row.hitRate === null) return "-";
2113
+ const basisPoints = hitBasisPoints(row.cacheReadTokens, row.totalInputTokens);
2114
+ return `${Math.floor(basisPoints / 100)}.${twoDigits(basisPoints % 100)}%`;
2115
+ };
2116
+ const hitRateTone = (row) => {
2117
+ if (row.hitRate === null) return "muted";
2118
+ return row.belowGoal ? "bad" : "good";
2119
+ };
2120
+ const bucketColumns = {
2121
+ summary: [],
2122
+ hourly: [{
2123
+ header: "HOUR",
2124
+ alignRight: false,
2125
+ cell: (row) => row.bucket ?? ""
2126
+ }],
2127
+ daily: [{
2128
+ header: "DAY",
2129
+ alignRight: false,
2130
+ cell: (row) => row.bucket ?? ""
2131
+ }]
2132
+ };
2133
+ const dataColumns = [
2134
+ {
2135
+ header: "MODEL",
2136
+ alignRight: false,
2137
+ cell: (row) => row.model
2138
+ },
2139
+ {
2140
+ header: "ROUTE",
2141
+ alignRight: false,
2142
+ cell: (row) => row.route
2143
+ },
2144
+ {
2145
+ header: "REQUESTS",
2146
+ alignRight: true,
2147
+ cell: (row) => formatCount(row.requests)
2148
+ },
2149
+ {
2150
+ header: "UNKNOWN",
2151
+ alignRight: true,
2152
+ cell: (row) => formatCount(row.unknownCacheRequests)
2153
+ },
2154
+ {
2155
+ header: "0-READ",
2156
+ alignRight: true,
2157
+ cell: (row) => formatCount(row.zeroCacheReadRequests)
2158
+ },
2159
+ {
2160
+ header: "INPUT",
2161
+ alignRight: true,
2162
+ cell: (row) => formatCount(row.totalInputTokens)
2163
+ },
2164
+ {
2165
+ header: "CACHE READ",
2166
+ alignRight: true,
2167
+ cell: (row) => formatCount(row.cacheReadTokens)
2168
+ },
2169
+ {
2170
+ header: "UNCACHED",
2171
+ alignRight: true,
2172
+ cell: (row) => formatCount(row.uncachedInputTokens)
2173
+ },
2174
+ {
2175
+ header: "CACHE WRITE",
2176
+ alignRight: true,
2177
+ cell: (row) => row.cacheWriteTokens === null ? "-" : formatCount(row.cacheWriteTokens)
2178
+ },
2179
+ {
2180
+ header: "HIT RATE",
2181
+ alignRight: true,
2182
+ cell: formatHitRate,
2183
+ tone: hitRateTone
2184
+ }
2185
+ ];
2186
+ const renderTable = (rows, view, color) => {
2187
+ const columns = [...bucketColumns[view], ...dataColumns];
2188
+ const cells = rows.map((row) => columns.map((column) => column.cell(row)));
2189
+ const widths = columns.map((column, index) => Math.max(column.header.length, ...cells.map((rowCells) => rowCells[index].length)));
2190
+ const pad = (text$1, index) => columns[index].alignRight ? text$1.padStart(widths[index]) : text$1.padEnd(widths[index]);
2191
+ const header = columns.map((column, index) => pad(column.header, index)).join(" ");
2192
+ const body = rows.map((row, rowIndex) => {
2193
+ const rendered = columns.map((column, index) => {
2194
+ const text$1 = pad(cells[rowIndex][index], index);
2195
+ return column.tone === void 0 ? text$1 : colorText(text$1, column.tone(row), color);
2196
+ });
2197
+ const marker = row.belowGoal ? ` ${colorText("below goal", "bad", color)}` : "";
2198
+ return ` ${rendered.join(" ")}${marker}`;
2199
+ });
2200
+ return [` ${header}`, ...body];
2201
+ };
2202
+ const viewTitles = {
2203
+ summary: "Prompt-cache hit rate",
2204
+ hourly: "Prompt-cache hit rate by local hour",
2205
+ daily: "Prompt-cache hit rate by local day"
2206
+ };
2207
+ const formatLocalTime = (date) => `${formatLogDate(date)} ${twoDigits(date.getHours())}:${twoDigits(date.getMinutes())}`;
2208
+ const describeScope = (options) => {
2209
+ return `${options.since === void 0 ? "across all retained logs" : `since ${formatLocalTime(options.since)} local time`}${options.model === void 0 ? "" : `, for models matching "${terminalText(options.model)}"`}`;
2210
+ };
2211
+ const unknownNote = "UNKNOWN calls logged no cache_read_input_tokens; they are left out of the token columns and HIT RATE.";
2212
+ /** The report as text lines. Kept free of IO, so it is tested without a terminal. */
2213
+ const renderCacheReport = (rows, options, color = false) => {
2214
+ if (rows.length === 0) return [
2215
+ `No prompt-cache data ${describeScope(options)}.`,
2216
+ `The report reads the upstream completion entries in ${paths.logsDir}.`,
2217
+ "They are written at logLevel info or debug, not error, and kept for logRetentionDays."
2218
+ ];
2219
+ const lines = [
2220
+ `${viewTitles[options.view]} ${describeScope(options)}, goal ${options.goal}%`,
2221
+ "",
2222
+ ...renderTable(rows, options.view, color)
2223
+ ];
2224
+ if (rows.some((row) => row.unknownCacheRequests > 0)) lines.push("", unknownNote);
2225
+ return lines;
2226
+ };
2227
+
2228
+ //#endregion
2229
+ //#region src/cache.ts
2230
+ const cache = defineCommand({
2231
+ meta: {
2232
+ name: "cache",
2233
+ description: "Show prompt-cache hit rates per model and upstream route, read from local logs."
2234
+ },
2235
+ args: {
2236
+ hourly: {
2237
+ description: "Show the trend by local hour. Covers the last 24 hours unless --since is given.",
2238
+ type: "boolean"
2239
+ },
2240
+ daily: {
2241
+ description: "Show the trend by local day. Covers every retained day unless --since is given.",
2242
+ type: "boolean"
2243
+ },
2244
+ since: {
2245
+ description: "Start of the window: a duration such as 6h or 2d, or an ISO date or time. Without it, the summary covers the last 24 hours.",
2246
+ type: "string"
2247
+ },
2248
+ model: {
2249
+ description: "Only count models whose name contains this text, ignoring case.",
2250
+ type: "string"
2251
+ },
2252
+ json: {
2253
+ description: "Print the rows as a JSON array.",
2254
+ type: "boolean"
2255
+ },
2256
+ goal: {
2257
+ default: String(defaultGoal),
2258
+ description: "Hit-rate goal in percent, with at most two decimals. Rows below it are flagged.",
2259
+ type: "string"
2260
+ }
2261
+ },
2262
+ async run({ args }) {
2263
+ try {
2264
+ const options = resolveCacheOptions(args, /* @__PURE__ */ new Date());
2265
+ const rows = await buildCacheReport(options);
2266
+ if (args.json) console.log(JSON.stringify(rows, null, 2));
2267
+ else console.log(renderCacheReport(rows, options, colorEnabled()).join("\n"));
2268
+ } catch (error) {
2269
+ if (error instanceof CacheUsageError) console.error(error.message);
2270
+ else {
2271
+ const detail = error instanceof Error ? error.message : String(error);
2272
+ console.error(terminalText(`Could not build the cache report from ${paths.logsDir}: ${detail}`));
2273
+ }
2274
+ process.exitCode = 1;
2275
+ }
2276
+ }
2277
+ });
2278
+
1385
2279
  //#endregion
1386
2280
  //#region src/lib/version.ts
1387
2281
  const packageName = /^(?:@[a-z0-9][a-z0-9._-]*\/)?copilot-relay$/;
@@ -1550,13 +2444,13 @@ var OutcomeObserver = class {
1550
2444
  const incomplete = this.metadataValue(value.incomplete_details?.reason);
1551
2445
  if (incomplete) this.fields.incomplete_reason = ["max_output_tokens", "content_filter"].includes(incomplete) ? incomplete : "unknown";
1552
2446
  if (payload.type === "error" || record$2(payload.error)) this.fields.error = "upstream_error";
1553
- const usage = findUsage(payload, value);
1554
- if (usage) {
2447
+ const usage$1 = findUsage(payload, value);
2448
+ if (usage$1) {
1555
2449
  for (const [target, input] of [
1556
- ["input_tokens", usage.input_tokens ?? usage.prompt_tokens],
1557
- ["output_tokens", usage.output_tokens ?? usage.completion_tokens],
1558
- ["cache_read_input_tokens", usage.cache_read_input_tokens ?? usage.prompt_tokens_details?.cached_tokens ?? usage.input_tokens_details?.cached_tokens],
1559
- ["cache_creation_input_tokens", usage.cache_creation_input_tokens]
2450
+ ["input_tokens", usage$1.input_tokens ?? usage$1.prompt_tokens],
2451
+ ["output_tokens", usage$1.output_tokens ?? usage$1.completion_tokens],
2452
+ ["cache_read_input_tokens", usage$1.cache_read_input_tokens ?? usage$1.prompt_tokens_details?.cached_tokens ?? usage$1.input_tokens_details?.cached_tokens],
2453
+ ["cache_creation_input_tokens", usage$1.cache_creation_input_tokens]
1560
2454
  ]) if (typeof input === "number" && Number.isSafeInteger(input) && input >= 0) this.fields[target] = input;
1561
2455
  }
1562
2456
  }
@@ -1772,6 +2666,7 @@ var RequestTrace = class RequestTrace {
1772
2666
  constructor(requestId, request, config, runtime) {
1773
2667
  this.requestId = requestId;
1774
2668
  this.protectCredential(config.copilotToken);
2669
+ this.protectCredential(config.apiKey);
1775
2670
  this.protectCredential(request.headers.get("authorization")?.replace(/^Bearer\s+/i, ""));
1776
2671
  this.protectCredential(request.headers.get("x-api-key") ?? void 0);
1777
2672
  this.signal = AbortSignal.any([request.signal, this.controller.signal]);
@@ -2234,11 +3129,6 @@ const userAgent = `GitHubCopilotChat/${copilotVersion}`;
2234
3129
  const apiVersion = "2025-04-01";
2235
3130
  const maxFetchAttempts = 2;
2236
3131
  const copilotRequestTimeoutMs = 18e4;
2237
- const copilotDispatcher = new Agent({
2238
- allowH2: false,
2239
- connect: { allowH2: false },
2240
- keepAliveTimeout: 5e4
2241
- });
2242
3132
  const getCopilotProviderContext = (config) => ({
2243
3133
  baseUrl: config.copilotBaseUrl,
2244
3134
  get token() {
@@ -2278,6 +3168,33 @@ const toCopilotAbortHTTPError = (error, signal, timeoutMs = copilotRequestTimeou
2278
3168
  headers: { "content-type": "application/json" }
2279
3169
  }), message);
2280
3170
  };
3171
+ const copilotPromptTooLongPattern = /^prompt token count of (\d+) exceeds the limit of (\d+)$/;
3172
+ const anthropicPromptTooLongPattern = /^prompt is too long: (\d+) tokens > (\d+) maximum$/;
3173
+ const parseJsonBody = (body) => {
3174
+ try {
3175
+ return JSON.parse(body);
3176
+ } catch {
3177
+ return;
3178
+ }
3179
+ };
3180
+ const isJsonObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
3181
+ const parseTokenCount = (digits) => {
3182
+ const count = Number(digits);
3183
+ return Number.isSafeInteger(count) ? count : void 0;
3184
+ };
3185
+ const toPromptTooLongError = (status$1, body, detail) => {
3186
+ if (status$1 !== 400) return;
3187
+ const payload = parseJsonBody(body);
3188
+ const upstreamError = isJsonObject(payload) ? payload.error : void 0;
3189
+ if (!isJsonObject(payload) || !isJsonObject(upstreamError)) return;
3190
+ const { code, message, type: errorType } = upstreamError;
3191
+ const upstreamMessage = typeof message === "string" ? message : "";
3192
+ const isCopilotOverflow = code === "model_max_prompt_tokens_exceeded";
3193
+ const isAnthropicOverflow = payload.type === "error" && errorType === "invalid_request_error" && upstreamMessage.startsWith("prompt is too long");
3194
+ if (!isCopilotOverflow && !isAnthropicOverflow) return;
3195
+ const counts = copilotPromptTooLongPattern.exec(upstreamMessage) ?? anthropicPromptTooLongPattern.exec(upstreamMessage);
3196
+ return new PromptTooLongError(parseTokenCount(counts?.[1]), parseTokenCount(counts?.[2]), detail ?? (upstreamMessage || void 0));
3197
+ };
2281
3198
  const readCopilotJson = async (response, signal, timeoutMs = copilotRequestTimeoutMs) => {
2282
3199
  try {
2283
3200
  return await response.json();
@@ -2380,21 +3297,12 @@ const fetchCopilot = async (provider, path$1, init, options = {}) => {
2380
3297
  headers,
2381
3298
  upstreamRequestId,
2382
3299
  signal
2383
- }, 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
- });
3300
+ }, () => fetchUpstream(`${provider.baseUrl}${path$1}`, {
3301
+ body: init.body,
3302
+ headers,
3303
+ method: init.method,
3304
+ signal
3305
+ }));
2398
3306
  const ms = Math.round(performance.now() - started);
2399
3307
  logUpstreamLifecycle(options.requestId, `return from upstream method=${init.method ?? "GET"} path=${path$1} status=${response.status} ms=${ms} attempt=${attempt} upstream_request_id=${upstreamRequestId}`);
2400
3308
  log.debug(`${formatRequestId(options.requestId)}Copilot ${init.method ?? "GET"} ${path$1} -> ${response.status} ${ms}ms (attempt ${attempt}) upstream_request_id=${upstreamRequestId}`);
@@ -2450,10 +3358,10 @@ const pinCopilotModelCatalog = (config) => {
2450
3358
  pinnedCatalogs.set(config, config.modelCatalog);
2451
3359
  };
2452
3360
  const pendingCatalogs = /* @__PURE__ */ new WeakMap();
2453
- const isRecord$3 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
3361
+ const isRecord$4 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
2454
3362
  const isPositiveInteger = (value) => typeof value === "number" && Number.isSafeInteger(value) && value > 0;
2455
3363
  function parseModelTokenLimits(value) {
2456
- if (!isRecord$3(value)) return;
3364
+ if (!isRecord$4(value)) return;
2457
3365
  const context = value.max_context_window_tokens;
2458
3366
  const prompt = value.max_prompt_tokens;
2459
3367
  const output = value.max_output_tokens;
@@ -2509,13 +3417,13 @@ async function loadCopilotModelCatalog(config) {
2509
3417
  });
2510
3418
  if (!response.ok) throw new HTTPError("Failed to validate upstream models", response);
2511
3419
  const payload = await readCopilotJson(response, signal, config.upstreamTimeoutMs);
2512
- if (!isRecord$3(payload) || !Array.isArray(payload.data)) throw new Error("Copilot /models returned an invalid model catalog.");
3420
+ if (!isRecord$4(payload) || !Array.isArray(payload.data)) throw new Error("Copilot /models returned an invalid model catalog.");
2513
3421
  const models$1 = /* @__PURE__ */ new Map();
2514
3422
  for (const model of payload.data) {
2515
- if (!isRecord$3(model) || typeof model.id !== "string" || !model.id) continue;
2516
- const capabilities = isRecord$3(model.capabilities) ? model.capabilities : void 0;
3423
+ if (!isRecord$4(model) || typeof model.id !== "string" || !model.id) continue;
3424
+ const capabilities = isRecord$4(model.capabilities) ? model.capabilities : void 0;
2517
3425
  const limits = parseModelTokenLimits(capabilities?.limits);
2518
- const supports = isRecord$3(capabilities?.supports) ? capabilities.supports : void 0;
3426
+ const supports = isRecord$4(capabilities?.supports) ? capabilities.supports : void 0;
2519
3427
  const supportedEndpoints = parseStringArray(model.supported_endpoints);
2520
3428
  const reasoningEfforts = parseReasoningEfforts(supports);
2521
3429
  models$1.set(model.id, {
@@ -2633,27 +3541,6 @@ function requireCopilotEndpoint(config, model) {
2633
3541
  }, { status: 400 }), message);
2634
3542
  }
2635
3543
 
2636
- //#endregion
2637
- //#region src/lib/terminal.ts
2638
- const colorEnabled = (env = process.env, isTTY = Boolean(process.stdout.isTTY)) => {
2639
- if (env.NO_COLOR !== void 0 || env.FORCE_COLOR === "0") return false;
2640
- if (env.FORCE_COLOR !== void 0) return env.FORCE_COLOR === "" || [
2641
- "1",
2642
- "2",
2643
- "3",
2644
- "true"
2645
- ].includes(env.FORCE_COLOR);
2646
- return isTTY && env.TERM !== "dumb";
2647
- };
2648
- const ansiColorCodes = {
2649
- good: 32,
2650
- bad: 31,
2651
- warning: 33,
2652
- muted: 90
2653
- };
2654
- const terminalText = (value) => sanitizeTerminalString(value).replace(/[\x80-\x9f]|\p{Cf}|\p{Zl}|\p{Zp}/gu, "");
2655
- const colorText = (text$1, tone, enabled) => enabled ? `\u001b[${ansiColorCodes[tone]}m${text$1}\u001b[0m` : text$1;
2656
-
2657
3544
  //#endregion
2658
3545
  //#region src/lib/model-probe-output.ts
2659
3546
  const tones = {
@@ -2781,6 +3668,50 @@ const probeHint = (code) => {
2781
3668
  if (apiFailureCodes.includes(code) || /^HTTP-5\d\d$/.test(code)) return "API failure: the request ID locates the local summary; --details shows evidence on a new probe.";
2782
3669
  };
2783
3670
 
3671
+ //#endregion
3672
+ //#region src/claude/billing-line.ts
3673
+ const billingLinePrefix = "x-anthropic-billing-header:";
3674
+ const afterBillingLine = (text$1) => {
3675
+ if (!text$1.startsWith(billingLinePrefix)) return;
3676
+ const lineEnd = text$1.indexOf("\n");
3677
+ if (lineEnd === -1) return "";
3678
+ return text$1.slice(lineEnd + 1).replace(/^(?:\r?\n)+/, "");
3679
+ };
3680
+ const isTextBlock = (block) => typeof block === "object" && block !== null && block.type === "text" && typeof block.text === "string";
3681
+ const withSystem = (payload, system) => {
3682
+ const { system: _removed,...rest } = payload;
3683
+ return system === void 0 ? rest : {
3684
+ ...rest,
3685
+ system
3686
+ };
3687
+ };
3688
+ const removeBillingLine = (payload) => {
3689
+ if (typeof payload !== "object" || payload === null) return payload;
3690
+ const { system } = payload;
3691
+ if (typeof system === "string") {
3692
+ const rest = afterBillingLine(system);
3693
+ if (rest === void 0) return payload;
3694
+ return withSystem(payload, rest.trim() === "" ? void 0 : rest);
3695
+ }
3696
+ if (!Array.isArray(system)) return payload;
3697
+ let changed = false;
3698
+ const kept = [];
3699
+ for (const block of system) {
3700
+ const rest = isTextBlock(block) ? afterBillingLine(block.text) : void 0;
3701
+ if (rest === void 0) {
3702
+ kept.push(block);
3703
+ continue;
3704
+ }
3705
+ changed = true;
3706
+ if (rest.trim() !== "") kept.push({
3707
+ ...block,
3708
+ text: rest
3709
+ });
3710
+ }
3711
+ if (!changed) return payload;
3712
+ return withSystem(payload, kept.length > 0 ? kept : void 0);
3713
+ };
3714
+
2784
3715
  //#endregion
2785
3716
  //#region src/claude/utils.ts
2786
3717
  const isEffortOnlyControl = (control) => control !== null && typeof control === "object" && !Array.isArray(control) && Object.keys(control).length === 1 && "effort" in control && isConfiguredReasoningEffort(control.effort);
@@ -3342,6 +4273,13 @@ function translateChunkToClaudeEvents(chunk, state, toolNameMapper = createClaud
3342
4273
  return events$1;
3343
4274
  }
3344
4275
  function translateErrorToClaudeErrorEvent(error) {
4276
+ if (error instanceof PromptTooLongError) return {
4277
+ type: "error",
4278
+ error: {
4279
+ type: "invalid_request_error",
4280
+ message: error.message
4281
+ }
4282
+ };
3345
4283
  return {
3346
4284
  type: "error",
3347
4285
  error: {
@@ -3365,7 +4303,7 @@ const webSearchInputSchema = {
3365
4303
  required: ["query"],
3366
4304
  additionalProperties: false
3367
4305
  };
3368
- const isRecord$2 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
4306
+ const isRecord$3 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
3369
4307
  const isAnthropicNativeWebSearchTool = (tool) => tool.name === "web_search" && typeof tool.type === "string" && anthropicWebSearchToolPattern.test(tool.type);
3370
4308
  const isClaudeCodeWebSearchTool = (tool) => tool.name === claudeCodeWebSearchToolName;
3371
4309
  const isClaudeWebSearchTool = (tool) => isAnthropicNativeWebSearchTool(tool) || isClaudeCodeWebSearchTool(tool);
@@ -3382,11 +4320,16 @@ const prepareClaudeWebSearchDecisionPayload = (payload) => {
3382
4320
  } : tool)
3383
4321
  };
3384
4322
  };
4323
+ const toolResultText = (content) => {
4324
+ if (typeof content === "string") return content;
4325
+ if (!Array.isArray(content)) return "";
4326
+ return content.map((block) => block.type === "text" ? block.text : `[${block.type}]`).join("\n\n");
4327
+ };
3385
4328
  const textFromMessageContent = (content) => {
3386
4329
  if (typeof content === "string") return content;
3387
4330
  return content.flatMap((block) => {
3388
4331
  if (block.type === "text") return [block.text];
3389
- if (block.type === "tool_result") return [block.content];
4332
+ if (block.type === "tool_result") return [toolResultText(block.content)];
3390
4333
  return [];
3391
4334
  }).join("\n\n");
3392
4335
  };
@@ -3400,7 +4343,7 @@ const getQueryFromToolArguments = (value) => {
3400
4343
  if (!trimmed) return;
3401
4344
  try {
3402
4345
  const parsed = JSON.parse(trimmed);
3403
- if (isRecord$2(parsed) && typeof parsed.query === "string" && parsed.query.trim()) return parsed.query.trim();
4346
+ if (isRecord$3(parsed) && typeof parsed.query === "string" && parsed.query.trim()) return parsed.query.trim();
3404
4347
  } catch {
3405
4348
  return trimmed;
3406
4349
  }
@@ -3457,7 +4400,7 @@ const getReportedQuery = (action, queries) => {
3457
4400
  const getSearchQuery = (response, requestedQuery) => {
3458
4401
  for (const item of response.output ?? []) {
3459
4402
  if (item.type !== "web_search_call") continue;
3460
- const action = isRecord$2(item.action) ? item.action : void 0;
4403
+ const action = isRecord$3(item.action) ? item.action : void 0;
3461
4404
  const query = getReportedQuery(action, Array.isArray(action?.queries) ? action.queries : []);
3462
4405
  if (query) return query;
3463
4406
  }
@@ -3465,7 +4408,7 @@ const getSearchQuery = (response, requestedQuery) => {
3465
4408
  };
3466
4409
  const getResponseText = (response) => (response.output ?? []).flatMap((item) => {
3467
4410
  if (item.type !== "message") return [];
3468
- return (Array.isArray(item.content) ? item.content : []).flatMap((part) => isRecord$2(part) && part.type === "output_text" && typeof part.text === "string" ? [part.text] : []);
4411
+ return (Array.isArray(item.content) ? item.content : []).flatMap((part) => isRecord$3(part) && part.type === "output_text" && typeof part.text === "string" ? [part.text] : []);
3469
4412
  }).join("\n").trim();
3470
4413
  const cleanTitle = (line, url) => {
3471
4414
  return line.slice(0, line.indexOf(url)).replace(/^\s*(?:[-*]|\d+[.)])\s*/, "").replace(/\s*(?:[-:|])\s*$/, "").trim() || new URL(url).hostname;
@@ -3489,15 +4432,15 @@ const parseSearchResults = (text$1) => {
3489
4432
  const getStructuredSearchResults = (response) => {
3490
4433
  const sources = [];
3491
4434
  for (const item of response.output ?? []) {
3492
- if (item.type === "web_search_call" && isRecord$2(item.action) && Array.isArray(item.action.sources)) sources.push(...item.action.sources);
4435
+ if (item.type === "web_search_call" && isRecord$3(item.action) && Array.isArray(item.action.sources)) sources.push(...item.action.sources);
3493
4436
  if (item.type === "message" && Array.isArray(item.content)) {
3494
- for (const part of item.content) if (isRecord$2(part) && Array.isArray(part.annotations)) sources.push(...part.annotations.filter((annotation) => isRecord$2(annotation) && annotation.type === "url_citation"));
4437
+ for (const part of item.content) if (isRecord$3(part) && Array.isArray(part.annotations)) sources.push(...part.annotations.filter((annotation) => isRecord$3(annotation) && annotation.type === "url_citation"));
3495
4438
  }
3496
4439
  }
3497
4440
  const results = [];
3498
4441
  const seen = /* @__PURE__ */ new Set();
3499
4442
  for (const source of sources) {
3500
- if (!isRecord$2(source) || typeof source.url !== "string") continue;
4443
+ if (!isRecord$3(source) || typeof source.url !== "string") continue;
3501
4444
  try {
3502
4445
  const url = new URL(source.url);
3503
4446
  if (!/^https?:$/.test(url.protocol) || url.username || url.password || seen.has(url.href)) continue;
@@ -3549,21 +4492,21 @@ const getResultSource = (structured, results) => {
3549
4492
  return results.length ? "text" : "none";
3550
4493
  };
3551
4494
  const interpretSearchResponse = (raw, config, requestedQuery, request, requestedEffort, requestId) => {
3552
- const malformed = !isRecord$2(raw) || raw.output !== void 0 && (!Array.isArray(raw.output) || !raw.output.every(isRecord$2));
3553
- const upstream = isRecord$2(raw) ? {
4495
+ const malformed = !isRecord$3(raw) || raw.output !== void 0 && (!Array.isArray(raw.output) || !raw.output.every(isRecord$3));
4496
+ const upstream = isRecord$3(raw) ? {
3554
4497
  ...raw,
3555
- output: Array.isArray(raw.output) ? raw.output.filter(isRecord$2) : []
4498
+ output: Array.isArray(raw.output) ? raw.output.filter(isRecord$3) : []
3556
4499
  } : {};
3557
4500
  const status$1 = recognizedValue(upstream.status, responseStates);
3558
- const reason = recognizedValue(isRecord$2(upstream.incomplete_details) ? upstream.incomplete_details.reason : void 0, incompleteReasons);
4501
+ const reason = recognizedValue(isRecord$3(upstream.incomplete_details) ? upstream.incomplete_details.reason : void 0, incompleteReasons);
3559
4502
  const items = upstream.output ?? [];
3560
4503
  const calls = items.filter((item) => item.type === "web_search_call");
3561
4504
  const callStates = calls.map((item) => recognizedValue(item.status, searchStates));
3562
- const usage = isRecord$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);
4505
+ const usage$1 = isRecord$3(upstream.usage) ? upstream.usage : {};
4506
+ const inputTokens = reportedTokens(usage$1.input_tokens);
4507
+ const cachedInputTokens = reportedTokens(isRecord$3(usage$1.input_tokens_details) ? usage$1.input_tokens_details.cached_tokens : void 0);
4508
+ const outputTokens = reportedTokens(usage$1.output_tokens);
4509
+ const reasoningTokens = reportedTokens(isRecord$3(usage$1.output_tokens_details) ? usage$1.output_tokens_details.reasoning_tokens : void 0);
3567
4510
  const upstreamResponseId = safeResponseId(upstream.id, config.copilotToken);
3568
4511
  const safeRequestId = typeof requestId === "string" && /^[a-f0-9-]{36}$/i.test(requestId) ? requestId : "unreported";
3569
4512
  const text$1 = getResponseText(upstream);
@@ -3651,7 +4594,7 @@ const getSearchFailureDetail = (body, token) => {
3651
4594
  let detail = body.trim();
3652
4595
  try {
3653
4596
  const parsed = JSON.parse(detail);
3654
- detail = getErrorMessageOrCode(isRecord$2(parsed) && isRecord$2(parsed.error) ? parsed.error : void 0);
4597
+ detail = getErrorMessageOrCode(isRecord$3(parsed) && isRecord$3(parsed.error) ? parsed.error : void 0);
3655
4598
  } catch {
3656
4599
  if (/^[{[<]/.test(detail)) return "";
3657
4600
  }
@@ -3820,9 +4763,9 @@ const accumulateChunks = (chunks) => {
3820
4763
  let reasoning = "";
3821
4764
  let refusal = "";
3822
4765
  let finishReason = null;
3823
- let usage;
4766
+ let usage$1;
3824
4767
  for (const chunk of chunks) {
3825
- if (chunk.usage) usage = chunk.usage;
4768
+ if (chunk.usage) usage$1 = chunk.usage;
3826
4769
  const choice = chunk.choices[0];
3827
4770
  if (!choice) continue;
3828
4771
  if (typeof choice.delta?.content === "string") content += choice.delta.content;
@@ -3863,7 +4806,7 @@ const accumulateChunks = (chunks) => {
3863
4806
  },
3864
4807
  finish_reason: refusal ? "content_filter" : finishReason ?? "stop"
3865
4808
  }],
3866
- ...usage ? { usage } : {}
4809
+ ...usage$1 ? { usage: usage$1 } : {}
3867
4810
  };
3868
4811
  };
3869
4812
  const incompleteStreamError = () => {
@@ -3879,7 +4822,7 @@ const incompleteStreamError = () => {
3879
4822
  async function* normalizeChatCompletionStream(stream) {
3880
4823
  let terminal;
3881
4824
  let refusal = false;
3882
- let usage;
4825
+ let usage$1;
3883
4826
  for await (const event of stream) {
3884
4827
  if (event.event === "error") throw incompleteStreamError();
3885
4828
  if (!event.data) continue;
@@ -3891,7 +4834,7 @@ async function* normalizeChatCompletionStream(stream) {
3891
4834
  throw incompleteStreamError();
3892
4835
  }
3893
4836
  if (!chunk || !Array.isArray(chunk.choices)) throw incompleteStreamError();
3894
- if (chunk.usage) usage = chunk.usage;
4837
+ if (chunk.usage) usage$1 = chunk.usage;
3895
4838
  if (chunk.choices.length === 0) continue;
3896
4839
  const choice = chunk.choices[0];
3897
4840
  if (!choice || !choice.delta || typeof choice.delta !== "object" || terminal) throw incompleteStreamError();
@@ -3923,7 +4866,7 @@ async function* normalizeChatCompletionStream(stream) {
3923
4866
  if (!terminal) throw incompleteStreamError();
3924
4867
  yield { data: JSON.stringify({
3925
4868
  ...terminal,
3926
- ...usage && { usage }
4869
+ ...usage$1 && { usage: usage$1 }
3927
4870
  }) };
3928
4871
  yield { data: "[DONE]" };
3929
4872
  }
@@ -4223,7 +5166,7 @@ const lookaroundPrefixes = [
4223
5166
  "(?<=",
4224
5167
  "(?<!"
4225
5168
  ];
4226
- const isRecord$1 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
5169
+ const isRecord$2 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
4227
5170
  function hasUnsupportedPatternFeatures(pattern) {
4228
5171
  let inCharacterClass = false;
4229
5172
  for (let index = 0; index < pattern.length; index++) {
@@ -4269,12 +5212,12 @@ function mapArray(values) {
4269
5212
  }
4270
5213
  function normalizeSchemaValue(value) {
4271
5214
  if (Array.isArray(value)) return mapArray(value);
4272
- return isRecord$1(value) ? normalizeResponsesToolSchema(value) : value;
5215
+ return isRecord$2(value) ? normalizeResponsesToolSchema(value) : value;
4273
5216
  }
4274
5217
  function normalizeResponsesToolSchema(schema) {
4275
5218
  return mapRecord(schema, (key, value) => {
4276
5219
  if (key === "pattern" && typeof value === "string" && hasUnsupportedPatternFeatures(value)) return removed;
4277
- if (schemaMapKeywords.has(key) && isRecord$1(value)) return mapRecord(value, (_name, nested) => normalizeSchemaValue(nested));
5220
+ if (schemaMapKeywords.has(key) && isRecord$2(value)) return mapRecord(value, (_name, nested) => normalizeSchemaValue(nested));
4278
5221
  if (nestedSchemaKeywords.has(key)) return normalizeSchemaValue(value);
4279
5222
  return value;
4280
5223
  });
@@ -4553,7 +5496,7 @@ function createRoleChunk(state) {
4553
5496
  state.started = true;
4554
5497
  return createChunk(state, { delta: { role: "assistant" } });
4555
5498
  }
4556
- function createChunk(state, { delta, finishReason = null, usage }) {
5499
+ function createChunk(state, { delta, finishReason = null, usage: usage$1 }) {
4557
5500
  return { data: JSON.stringify({
4558
5501
  id: state.responseId,
4559
5502
  object: "chat.completion.chunk",
@@ -4565,7 +5508,7 @@ function createChunk(state, { delta, finishReason = null, usage }) {
4565
5508
  finish_reason: finishReason,
4566
5509
  logprobs: null
4567
5510
  }],
4568
- usage
5511
+ usage: usage$1
4569
5512
  }) };
4570
5513
  }
4571
5514
  function translateMessagesToResponsesInput(messages) {
@@ -4577,7 +5520,7 @@ function translateMessage(message) {
4577
5520
  if (message.role === "tool") return [{
4578
5521
  type: "function_call_output",
4579
5522
  call_id: message.tool_call_id ?? "",
4580
- output: stringifyToolOutput(message.content)
5523
+ output: translateToolOutput(message.content)
4581
5524
  }];
4582
5525
  const translated = [];
4583
5526
  if (hasContent(message.content)) translated.push({
@@ -4613,6 +5556,21 @@ function translateContentPart(part) {
4613
5556
  detail: part.image_url.detail ?? "auto"
4614
5557
  };
4615
5558
  }
5559
+ function translateToolOutput(content) {
5560
+ if (Array.isArray(content) && content.some((part) => part.type === "image_url")) return content.flatMap((part) => translateToolOutputPart(part));
5561
+ return stringifyToolOutput(content);
5562
+ }
5563
+ function translateToolOutputPart(part) {
5564
+ if (part.type === "image_url") return [{
5565
+ type: "input_image",
5566
+ image_url: part.image_url.url
5567
+ }];
5568
+ if (part.text === "") return [];
5569
+ return [{
5570
+ type: "input_text",
5571
+ text: part.text
5572
+ }];
5573
+ }
4616
5574
  function stringifyToolOutput(content) {
4617
5575
  if (typeof content === "string") return content;
4618
5576
  if (!content) return "";
@@ -4636,13 +5594,13 @@ function translateToolChoice(toolChoice) {
4636
5594
  name: toolChoice.function.name
4637
5595
  };
4638
5596
  }
4639
- function translateUsage(usage) {
4640
- if (!usage) return;
5597
+ function translateUsage(usage$1) {
5598
+ if (!usage$1) return;
4641
5599
  return {
4642
- prompt_tokens: usage.input_tokens,
4643
- completion_tokens: usage.output_tokens,
4644
- total_tokens: usage.total_tokens,
4645
- ...usage.input_tokens_details?.cached_tokens !== void 0 && { prompt_tokens_details: { cached_tokens: usage.input_tokens_details.cached_tokens } }
5600
+ prompt_tokens: usage$1.input_tokens,
5601
+ completion_tokens: usage$1.output_tokens,
5602
+ total_tokens: usage$1.total_tokens,
5603
+ ...usage$1.input_tokens_details?.cached_tokens !== void 0 && { prompt_tokens_details: { cached_tokens: usage$1.input_tokens_details.cached_tokens } }
4646
5604
  };
4647
5605
  }
4648
5606
  function getMessageOutputText(content) {
@@ -4733,7 +5691,57 @@ const normalizeFinalAssistantPrefill = (payload) => {
4733
5691
  };
4734
5692
  };
4735
5693
  const isAgentInitiator = (messages) => messages.some((message) => message.role === "assistant" || message.role === "tool") ? "agent" : "user";
4736
- const messagesIncludeImage = (messages) => messages.some((message) => typeof message.content !== "string" && message.content?.some((part) => part.type === "image_url"));
5694
+ const holdsImage = (message) => Array.isArray(message.content) && message.content.some((part) => part.type === "image_url");
5695
+ const messagesIncludeImage = (messages) => messages.some(holdsImage);
5696
+ const isToolImageMessage = (message) => message.role === "tool" && holdsImage(message);
5697
+ const joinTextParts = (parts) => parts.filter((part) => part.type === "text").map((part) => part.text).join("\n\n");
5698
+ const toContentParts = (content) => {
5699
+ if (typeof content === "string") return [{
5700
+ type: "text",
5701
+ text: content
5702
+ }];
5703
+ return content ?? [];
5704
+ };
5705
+ const toolImageNote = "Image output follows in the next user message.";
5706
+ const moveToolImagesToUserMessages = (payload) => {
5707
+ if (isClaudeModelId(payload.model) || !payload.messages.some(isToolImageMessage)) return payload;
5708
+ const messages = [];
5709
+ let movedImages = [];
5710
+ for (const message of payload.messages) {
5711
+ if (isToolImageMessage(message)) {
5712
+ messages.push({
5713
+ ...message,
5714
+ content: joinTextParts(message.content) || toolImageNote
5715
+ });
5716
+ movedImages.push({
5717
+ type: "text",
5718
+ text: `Image output of tool call ${message.tool_call_id ?? ""}:`
5719
+ }, ...message.content.filter((part) => part.type === "image_url"));
5720
+ continue;
5721
+ }
5722
+ if (message.role === "tool" || movedImages.length === 0) {
5723
+ messages.push(message);
5724
+ continue;
5725
+ }
5726
+ if (message.role === "user") messages.push({
5727
+ ...message,
5728
+ content: [...movedImages, ...toContentParts(message.content)]
5729
+ });
5730
+ else messages.push({
5731
+ role: "user",
5732
+ content: movedImages
5733
+ }, message);
5734
+ movedImages = [];
5735
+ }
5736
+ if (movedImages.length > 0) messages.push({
5737
+ role: "user",
5738
+ content: movedImages
5739
+ });
5740
+ return {
5741
+ ...payload,
5742
+ messages
5743
+ };
5744
+ };
4737
5745
  const createChatCompletions = async (config, payload, options = {}) => {
4738
5746
  const client = options.client ?? "generic";
4739
5747
  const requestedModel = options.requestedModel ?? payload.model;
@@ -4756,7 +5764,7 @@ const createChatCompletions = async (config, payload, options = {}) => {
4756
5764
  stream: bufferResponse ? true : payload.stream
4757
5765
  };
4758
5766
  const useResponsesApi = selection.endpoint === "/responses";
4759
- const compatiblePayload = useResponsesApi ? upstreamPayload : normalizeFinalAssistantPrefill(upstreamPayload);
5767
+ const compatiblePayload = useResponsesApi ? upstreamPayload : normalizeFinalAssistantPrefill(moveToolImagesToUserMessages(upstreamPayload));
4760
5768
  const provider = getCopilotProviderContext(config);
4761
5769
  const enableVision = messagesIncludeImage(compatiblePayload.messages);
4762
5770
  const initiator = isAgentInitiator(compatiblePayload.messages);
@@ -4798,19 +5806,19 @@ const createChatCompletions = async (config, payload, options = {}) => {
4798
5806
  markDiscardedResponse(response);
4799
5807
  await response.body?.cancel();
4800
5808
  return completeResponse(await createResponses(provider, upstreamPayload, {
4801
- vision: enableVision,
5809
+ vision: messagesIncludeImage(upstreamPayload.messages),
4802
5810
  initiator,
4803
5811
  requestId: options.requestId,
4804
5812
  signal,
4805
5813
  timeoutMs: options.timeoutMs
4806
5814
  }));
4807
5815
  }
4808
- throw new HTTPError("Failed to create chat completions", response, await logUpstreamError("Failed to create chat completions", response, {
5816
+ throw await logUpstreamError("Failed to create chat completions", response, {
4809
5817
  model: payload.model,
4810
5818
  request: requestPayload,
4811
5819
  route: "/chat/completions",
4812
5820
  requestId: options.requestId
4813
- }, signal, options.timeoutMs));
5821
+ }, signal, options.timeoutMs);
4814
5822
  }
4815
5823
  if (compatiblePayload.stream) return completeResponse(normalizeChatCompletionStream(events(response)));
4816
5824
  return readCopilotJson(response, signal, options.timeoutMs);
@@ -4832,12 +5840,12 @@ async function createResponses(provider, payload, options) {
4832
5840
  signal: options.signal,
4833
5841
  timeoutMs: options.timeoutMs
4834
5842
  });
4835
- if (!response.ok) throw new HTTPError("Failed to create responses", response, await logUpstreamError("Failed to create responses", response, {
5843
+ if (!response.ok) throw await logUpstreamError("Failed to create responses", response, {
4836
5844
  model: payload.model,
4837
5845
  request: requestPayload,
4838
5846
  route: "/responses",
4839
5847
  requestId: options.requestId
4840
- }, options.signal, options.timeoutMs));
5848
+ }, options.signal, options.timeoutMs);
4841
5849
  if (payload.stream) return normalizeChatCompletionStream(translateResponsesStreamToChatCompletionStream(events(response)));
4842
5850
  return translateResponsesToChatCompletion(await readCopilotJson(response, options.signal, options.timeoutMs));
4843
5851
  }
@@ -4858,7 +5866,7 @@ async function logUpstreamError(message, response, context, signal, timeoutMs) {
4858
5866
  },
4859
5867
  request: context.request
4860
5868
  });
4861
- return detail;
5869
+ return toPromptTooLongError(response.status, errorBody, detail) ?? new HTTPError(message, response, detail);
4862
5870
  }
4863
5871
  function getUpstreamErrorDetail(response, body) {
4864
5872
  if (!body) return response.statusText || void 0;
@@ -5018,6 +6026,8 @@ async function createNativeMessages(config, payload, options) {
5018
6026
  if (!response.ok) {
5019
6027
  const detail = await readCopilotText(response, options.signal, config.upstreamTimeoutMs);
5020
6028
  const contentType = response.headers.get("content-type") ?? "application/json";
6029
+ const promptTooLong = toPromptTooLongError(response.status, detail);
6030
+ if (promptTooLong) throw promptTooLong;
5021
6031
  throw new HTTPError("Native upstream request failed", new Response(detail, {
5022
6032
  status: response.status,
5023
6033
  headers: { "content-type": contentType }
@@ -5247,7 +6257,7 @@ async function handleNativeMessages(config, payload, options, write) {
5247
6257
  };
5248
6258
  const upstreamToolResult = toolResult(searchCall.id, resultBlock.content);
5249
6259
  const hasSiblingTools = message.content.some((block) => block.type === "tool_use" && block !== searchCall);
5250
- let usage = mergeUsage(message.usage, searchMessage.usage);
6260
+ let usage$1 = mergeUsage(message.usage, searchMessage.usage);
5251
6261
  const combined = [...displayDecision, resultBlock];
5252
6262
  if (write) for (const [index, block] of combined.entries()) {
5253
6263
  if (index < (heldFrom ?? message.content.length)) continue;
@@ -5258,13 +6268,13 @@ async function handleNativeMessages(config, payload, options, write) {
5258
6268
  ...message,
5259
6269
  content: combined,
5260
6270
  stop_reason: hasSiblingTools ? "tool_use" : "end_turn",
5261
- usage
6271
+ usage: usage$1
5262
6272
  };
5263
6273
  if (!write) return complete;
5264
6274
  await write({
5265
6275
  type: "message_delta",
5266
6276
  delta: { stop_reason: complete.stop_reason },
5267
- usage
6277
+ usage: usage$1
5268
6278
  });
5269
6279
  await write({ type: "message_stop" });
5270
6280
  return;
@@ -5297,11 +6307,11 @@ async function handleNativeMessages(config, payload, options, write) {
5297
6307
  });
5298
6308
  } : void 0);
5299
6309
  if (finalMessage.content.some((block) => block.type === "tool_use" && isClaudeWebSearchToolName(block.name ?? ""))) throw new Error("Repeated bridge-managed search is unsupported.");
5300
- usage = mergeUsage(usage, finalMessage.usage);
6310
+ usage$1 = mergeUsage(usage$1, finalMessage.usage);
5301
6311
  if (!write) return {
5302
6312
  ...finalMessage,
5303
6313
  content: [...combined, ...finalMessage.content],
5304
- usage
6314
+ usage: usage$1
5305
6315
  };
5306
6316
  if (finalHeldFrom !== void 0) for (const [index, block] of finalMessage.content.entries()) {
5307
6317
  if (index < finalHeldFrom) continue;
@@ -5314,7 +6324,7 @@ async function handleNativeMessages(config, payload, options, write) {
5314
6324
  stop_sequence: finalMessage.stop_sequence,
5315
6325
  ..."stop_details" in finalMessage && { stop_details: finalMessage.stop_details }
5316
6326
  },
5317
- usage
6327
+ usage: usage$1
5318
6328
  });
5319
6329
  await write({ type: "message_stop" });
5320
6330
  }
@@ -5655,7 +6665,7 @@ claudeRoutes.get("/models", (c) => c.json({
5655
6665
  claudeRoutes.post("/messages", async (c) => {
5656
6666
  const config = c.get("config");
5657
6667
  const requestId = c.get("requestId");
5658
- const claudePayload = await c.req.json();
6668
+ const claudePayload = removeBillingLine(await c.req.json());
5659
6669
  const requestSignal = createCopilotRequestSignal(c.req.raw.signal, config.upstreamTimeoutMs);
5660
6670
  try {
5661
6671
  getRequestReasoningEffort(claudePayload);
@@ -5744,7 +6754,7 @@ claudeRoutes.post("/messages", async (c) => {
5744
6754
  claudeRoutes.post("/messages/count_tokens", async (c) => {
5745
6755
  try {
5746
6756
  const claudeBeta = c.req.header("claude-beta");
5747
- const claudePayload = await c.req.json();
6757
+ const claudePayload = removeBillingLine(await c.req.json());
5748
6758
  const openAIPayload = translateToOpenAI(shouldUseNativeMessages(c.get("config"), translateModelName(claudePayload.model)) ? {
5749
6759
  ...claudePayload,
5750
6760
  messages: claudePayload.messages.map((message) => {
@@ -5816,6 +6826,22 @@ const parseAuthority = (authority, protocol) => {
5816
6826
  };
5817
6827
  const effectivePort = (url) => Number(url.port || (url.protocol === "https:" ? 443 : 80));
5818
6828
  const isAllowedHostname = (hostname, configuredHost) => hostname === "localhost" || hostname === "[::1]" || isIPv4(hostname) && hostname.startsWith("127.") || hostname !== "0.0.0.0" && hostname !== "[::]" && hostname === configuredHost;
6829
+ const sha256 = (value) => createHash("sha256").update(value, "utf8").digest();
6830
+ /**
6831
+ * True when the request carries the relay's apiKey as x-api-key or Authorization: Bearer.
6832
+ *
6833
+ * The configured key and each presented value are hashed with SHA-256, and timingSafeEqual compares
6834
+ * the fixed-length digests, so the comparison never stops at the first byte that differs. Both
6835
+ * headers are compared even after one matches.
6836
+ */
6837
+ const presentsApiKey = (headers, apiKey) => {
6838
+ const expected = sha256(apiKey);
6839
+ const bearer = /^Bearer\s+(.+)$/i.exec(headers.get("authorization") ?? "")?.[1];
6840
+ let matched = false;
6841
+ for (const presented of [headers.get("x-api-key"), bearer]) if (typeof presented === "string" && timingSafeEqual(sha256(presented), expected)) matched = true;
6842
+ return matched;
6843
+ };
6844
+ const isOpenProbe = (method, path$1) => method === "GET" && path$1 === "/healthz" || (method === "GET" || method === "HEAD") && path$1 === "/api/hello";
5819
6845
  const createServer = (config) => {
5820
6846
  const app = new Hono();
5821
6847
  const { host, port } = config;
@@ -5859,6 +6885,18 @@ const createServer = (config) => {
5859
6885
  return c.json({ error: { message } }, 403);
5860
6886
  }
5861
6887
  }
6888
+ const apiKey = config.apiKey;
6889
+ if (apiKey && !isOpenProbe(c.req.method, c.req.path) && !presentsApiKey(c.req.raw.headers, apiKey)) {
6890
+ const message = "Missing or invalid API key: send the relay apiKey as x-api-key or Authorization: Bearer";
6891
+ c.set("requestErrorMessage", message);
6892
+ return c.json({
6893
+ type: "error",
6894
+ error: {
6895
+ type: "authentication_error",
6896
+ message
6897
+ }
6898
+ }, 401, { "www-authenticate": "Bearer" });
6899
+ }
5862
6900
  if (c.req.method === "POST" && (c.req.path === "/v1/messages" || c.req.path === "/v1/messages/count_tokens") && (incoming ? Boolean(incoming.headers["transfer-encoding"]) || Number(incoming.headers["content-length"] ?? 0) > 0 : c.req.raw.body !== null) && c.req.header("content-type")?.split(";", 1)[0].trim().toLowerCase() !== "application/json") {
5863
6901
  const message = "Content-Type must be application/json";
5864
6902
  c.set("requestErrorMessage", message);
@@ -5939,7 +6977,14 @@ const startServer = (config) => new Promise((resolve$1, reject) => {
5939
6977
  //#endregion
5940
6978
  //#region src/lib/model-probe.ts
5941
6979
  const record$1 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
5942
- const 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);
6980
+ const holdsCredential = (text$1, config) => {
6981
+ for (const secret of [config.copilotToken, config.apiKey]) if (secret && text$1.includes(secret)) return true;
6982
+ return false;
6983
+ };
6984
+ const safeId = (id, config) => typeof id === "string" && /^[A-Za-z0-9._\[\]-]{1,128}$/.test(id) && !holdsCredential(id, config) && !/(?:gh[pousr]_|github_pat_|sk-|eyJ)/.test(id);
6985
+ const printLine = (line) => {
6986
+ console.log(scrubLogSecrets(scrubSensitiveUrls(line)));
6987
+ };
5943
6988
  const httpCategory = (status$1) => {
5944
6989
  if (status$1 === 401) return "authentication-rejected";
5945
6990
  if (status$1 === 403) return "access-denied";
@@ -5996,9 +7041,9 @@ const failureFromEvidence = (row, diagnostic) => {
5996
7041
  return row.detail;
5997
7042
  };
5998
7043
  async function probeModels(config, entries, options) {
5999
- 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`);
7044
+ printLine(`\nModel check · ${entries.length} ${entries.length === 1 ? "model" : "models"} · real Copilot usage`);
7045
+ printLine("Isolated relay pipeline; not running-daemon health.");
7046
+ printLine(`Up to ${options.maxTokens} output tokens/probe; existing retries may add calls.\n`);
6002
7047
  const controller = new AbortController();
6003
7048
  const interrupt = () => controller.abort();
6004
7049
  process.on("SIGINT", interrupt);
@@ -6012,9 +7057,9 @@ async function probeModels(config, entries, options) {
6012
7057
  const results = [];
6013
7058
  const app = createServer(config);
6014
7059
  const columns = Math.max(40, process.stdout.columns || 80);
6015
- const width = probeColumnWidth(entries.map(([id]) => safeId(id, config.copilotToken) ? id : "[unsupported ID]"), columns);
7060
+ const width = probeColumnWidth(entries.map(([id]) => safeId(id, config) ? id : "[unsupported ID]"), columns);
6016
7061
  const color = colorEnabled();
6017
- if (entries.length) console.log(renderProbeHeader(width));
7062
+ if (entries.length) printLine(renderProbeHeader(width));
6018
7063
  try {
6019
7064
  runtimeState.modelCatalog = config.modelCatalog;
6020
7065
  runtimeState.upstreamBaseUrl = config.copilotBaseUrl;
@@ -6036,7 +7081,7 @@ async function probeModels(config, entries, options) {
6036
7081
  const endpoint = selection.endpoint ?? "none";
6037
7082
  const { advertisedEndpoints, unknownEndpoints } = summarizeAdvertisedEndpoints(model.supportedEndpoints);
6038
7083
  const row = {
6039
- id: safeId(id, config.copilotToken) ? id : "[unsupported ID]",
7084
+ id: safeId(id, config) ? id : "[unsupported ID]",
6040
7085
  status: "NOT_TESTED",
6041
7086
  reported: "-",
6042
7087
  sent: false,
@@ -6051,7 +7096,7 @@ async function probeModels(config, entries, options) {
6051
7096
  unverified: model.reasoningEfforts === void 0 || model.supportedEndpoints === void 0
6052
7097
  };
6053
7098
  if (controller.signal.aborted || totalDeadline.aborted) row.detail = controller.signal.aborted ? "cancelled" : "total-deadline";
6054
- else if (!safeId(id, config.copilotToken) || normalizeCopilotModelId(id) !== id) {
7099
+ else if (!safeId(id, config) || normalizeCopilotModelId(id) !== id) {
6055
7100
  row.status = "SKIPPED";
6056
7101
  row.detail = "unsafe-or-noncanonical-id";
6057
7102
  } else if (!selection.endpoint) {
@@ -6080,7 +7125,10 @@ async function probeModels(config, entries, options) {
6080
7125
  const sendProbe = async () => {
6081
7126
  const response = await app.fetch(new Request(`http://localhost${config.port ? `:${config.port}` : ""}/v1/messages`, {
6082
7127
  method: "POST",
6083
- headers: { "content-type": "application/json" },
7128
+ headers: {
7129
+ "content-type": "application/json",
7130
+ ...config.apiKey ? { "x-api-key": config.apiKey } : {}
7131
+ },
6084
7132
  signal,
6085
7133
  body: JSON.stringify({
6086
7134
  model: id,
@@ -6121,7 +7169,7 @@ async function probeModels(config, entries, options) {
6121
7169
  if (code === "model_not_supported") row.detail = "model-not-supported";
6122
7170
  } else if (!record$1(body) || !Array.isArray(body.content)) row.detail = "malformed-response";
6123
7171
  else {
6124
- row.reported = safeId(body.model, config.copilotToken) ? body.model : "unreported";
7172
+ row.reported = safeId(body.model, config) ? body.model : "unreported";
6125
7173
  if (!reportsSelectedModel(endpoint, id, row.reported)) row.detail = "model-mismatch";
6126
7174
  else if (body.stop_reason === "max_tokens") {
6127
7175
  row.status = "INCOMPLETE";
@@ -6153,11 +7201,11 @@ async function probeModels(config, entries, options) {
6153
7201
  if (row.status === "FAIL") row.detail = failureFromEvidence(row, row.diagnostic);
6154
7202
  }
6155
7203
  }
6156
- if (!safeId(id, config.copilotToken)) row.id = "[unsupported ID]";
7204
+ if (!safeId(id, config)) row.id = "[unsupported ID]";
6157
7205
  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}`);
7206
+ for (const line of renderProbeRow(row, width, color, columns)) printLine(line);
7207
+ if (options.details) for (const line of renderProbeDetails(row, row.diagnostic)) printLine(line);
7208
+ else if (row.status !== "PASS" && row.diagnostic) printLine(` request_id=${row.diagnostic.requestId}`);
6161
7209
  if (row.status !== "PASS" && row.sent) withoutConsoleLogging(() => log.info(`request_id=${row.diagnostic?.requestId ?? "unknown"} model_probe model=${row.id} status=${row.status} reason=${row.detail}`));
6162
7210
  }
6163
7211
  } finally {
@@ -6172,11 +7220,11 @@ async function probeModels(config, entries, options) {
6172
7220
  process.off("SIGINT", interrupt);
6173
7221
  process.off("SIGTERM", interrupt);
6174
7222
  }
6175
- 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.");
7223
+ printLine(`\n${renderProbeSummary(results)}`);
7224
+ if (results.some((row) => row.unverified)) printLine("* Effort or endpoint metadata is unverified.");
7225
+ const hints = new Set(results.filter((row) => row.status !== "PASS").map((row) => probeHint(row.detail)).filter((hint) => hint !== void 0));
7226
+ for (const hint of hints) printLine(hint);
7227
+ if (!options.details && results.some((row) => row.status !== "PASS" && row.sent)) printLine("Use --details for request evidence; another deep run consumes usage.");
6180
7228
  if (controller.signal.aborted) return 130;
6181
7229
  return results.length > 0 && results.every((row) => row.status === "PASS") ? 0 : 2;
6182
7230
  }
@@ -6269,8 +7317,9 @@ const renderConfigGuide = (configPath$1, configured, chosen) => {
6269
7317
  //#endregion
6270
7318
  //#region src/models.ts
6271
7319
  var ModelsUsageError = class extends Error {};
7320
+ const printable = (text$1) => scrubLogSecrets(terminalText(text$1));
6272
7321
  const displayName = (name) => {
6273
- const text$1 = sanitizeTerminalString(name ?? "").trim();
7322
+ const text$1 = printable(name ?? "").trim();
6274
7323
  if (text$1 === "") return;
6275
7324
  return text$1.length > 80 ? `${text$1.slice(0, 77)}...` : text$1;
6276
7325
  };
@@ -6278,7 +7327,7 @@ const listedModels = (config, catalog) => [...catalog.models.keys()].sort().map(
6278
7327
  const selection = selectCopilotEndpoint(config, id);
6279
7328
  const name = displayName(catalog.models.get(id)?.name);
6280
7329
  return {
6281
- id: sanitizeTerminalString(id),
7330
+ id: printable(id),
6282
7331
  ...name !== void 0 && { name },
6283
7332
  ...!selection.endpoint && { unusable: probeReason(selection.reason) }
6284
7333
  };
@@ -6288,7 +7337,7 @@ const configuredModels = (appConfig, config, catalog) => ["gptModel", "opusModel
6288
7337
  const selection = selectCopilotEndpoint(config, id);
6289
7338
  return {
6290
7339
  key,
6291
- value: sanitizeTerminalString(appConfig[key]),
7340
+ value: printable(appConfig[key]),
6292
7341
  advertised: catalog.models.has(id),
6293
7342
  ...!selection.endpoint && { unusable: probeReason(selection.reason) }
6294
7343
  };
@@ -6298,7 +7347,7 @@ const listingHeading = (search, result) => {
6298
7347
  if (result.models.length === 0) return "No models advertised by upstream.";
6299
7348
  return `Upstream-advertised models (${result.models.length}):`;
6300
7349
  }
6301
- const label = sanitizeTerminalString(search);
7350
+ const label = printable(search);
6302
7351
  if (result.found) return `Upstream models matching "${label}" (${result.models.length}):`;
6303
7352
  if (result.models.length === 0) return `No upstream model matches "${label}". List them all with copilot-relay models.`;
6304
7353
  return `No upstream model matches "${label}". Closest IDs (${result.models.length}):`;
@@ -6375,6 +7424,8 @@ const models = defineCommand({
6375
7424
  const appConfig$1 = await readAppConfig();
6376
7425
  setLogLevel(appConfig$1.logLevel);
6377
7426
  registerSensitiveOrigin(appConfig$1.copilotBaseUrl);
7427
+ registerLogSecret(appConfig$1.apiKey);
7428
+ configureUpstreamDispatcher(appConfig$1.upstreamProxy);
6378
7429
  const config$1 = readProxyConfig(appConfig$1);
6379
7430
  failure = "Could not authenticate with GitHub Copilot";
6380
7431
  await setupProxyAuth(config$1, args.deep ? { onDeviceCode: (url, code) => console.error(`Sign in: open ${terminalText(url)} and enter ${terminalText(code)}.`) } : void 0);
@@ -6405,7 +7456,7 @@ const models = defineCommand({
6405
7456
  const lines = [listingHeading(search, result), ...renderModelRows(result.models)];
6406
7457
  if (result.models.length > 0) lines.push("Inference, tool, and effort compatibility are not verified by this listing.", `Test one with copilot-relay models --deep --model ${result.chosen?.id ?? "<id>"}; it consumes Copilot usage.`);
6407
7458
  lines.push("", ...renderConfigGuide(paths.configPath, configuredModels(appConfig, config, catalog), result.chosen));
6408
- console.log(scrubSensitiveUrls(lines.join("\n")));
7459
+ console.log(lines.map((line) => scrubLogSecrets(scrubSensitiveUrls(terminalText(line)))).join("\n"));
6409
7460
  if (!result.found) process.exitCode = 1;
6410
7461
  } catch (error) {
6411
7462
  let detail = "Check configuration, authentication, and upstream connectivity.";
@@ -6970,8 +8021,8 @@ const replay = defineCommand({
6970
8021
  console.log(`${method} ${path$1} status=${status$1 ?? "unknown"} outcome=${outcome}`);
6971
8022
  console.log(`upstream=${routes$1.join(" -> ") || "none"}`);
6972
8023
  for (const exchange of result.summary.exchanges) {
6973
- const usage = Object.entries(exchange.usage).map(([key, value]) => `${key}=${value}`).join(" ");
6974
- console.log(`exchange=${exchange.order} path=${exchange.path} status=${exchange.status ?? "unknown"} outcome=${exchange.outcome}${usage ? ` ${usage}` : ""}${exchange.refusalCategory ? ` refusal_category=${exchange.refusalCategory}` : ""}`);
8024
+ const usage$1 = Object.entries(exchange.usage).map(([key, value]) => `${key}=${value}`).join(" ");
8025
+ console.log(`exchange=${exchange.order} path=${exchange.path} status=${exchange.status ?? "unknown"} outcome=${exchange.outcome}${usage$1 ? ` ${usage$1}` : ""}${exchange.refusalCategory ? ` refusal_category=${exchange.refusalCategory}` : ""}`);
6975
8026
  }
6976
8027
  if (result.summary.contentBlocks) console.log(`client_blocks=${result.summary.contentBlocks.join(",") || "none"} tool_calls=${result.summary.toolCalls ?? 0}`);
6977
8028
  }
@@ -6988,6 +8039,22 @@ const getRelayBaseUrl = (host, port) => {
6988
8039
  const hostname = new URL(`http://${authority}`).hostname;
6989
8040
  return `http://${loopbackForWildcard.get(hostname) ?? hostname}:${port}`;
6990
8041
  };
8042
+ const loopbackAddresses = new BlockList();
8043
+ loopbackAddresses.addSubnet("127.0.0.0", 8, "ipv4");
8044
+ loopbackAddresses.addAddress("::1", "ipv6");
8045
+ /**
8046
+ * True when a bind host accepts connections from this machine only.
8047
+ *
8048
+ * Literal loopback addresses and the name localhost qualify. Wildcards, other addresses and other
8049
+ * names do not: a name can resolve to any address, so it is treated as reachable from the network.
8050
+ */
8051
+ const isLoopbackHost = (host) => {
8052
+ const bare = host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host;
8053
+ if (bare.toLowerCase() === "localhost") return true;
8054
+ const family = isIP(bare);
8055
+ if (family === 0) return false;
8056
+ return loopbackAddresses.check(bare, family === 4 ? "ipv4" : "ipv6");
8057
+ };
6991
8058
 
6992
8059
  //#endregion
6993
8060
  //#region src/lib/lifecycle.ts
@@ -7424,18 +8491,20 @@ const knownModelEnvKeys = [
7424
8491
  "ANTHROPIC_SMALL_FAST_MODEL",
7425
8492
  "CLAUDE_CODE_SUBAGENT_MODEL"
7426
8493
  ];
7427
- const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
8494
+ const isRecord$1 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
7428
8495
  const hasMalformedModelOverride = (parsed) => {
7429
8496
  if (parsed.model !== void 0 && typeof parsed.model !== "string") return true;
7430
8497
  const env = parsed.env;
7431
- if (!isRecord(env)) return false;
8498
+ if (!isRecord$1(env)) return false;
7432
8499
  return knownModelEnvKeys.some((key) => env[key] !== void 0 && typeof env[key] !== "string");
7433
8500
  };
7434
8501
  const hasPrimaryModelOverride = (parsed, env) => typeof parsed.model === "string" || typeof env.ANTHROPIC_MODEL === "string";
7435
8502
  /**
7436
8503
  * Update `~/.claude/settings.json` so its env block points Claude Code at the
7437
8504
  * running proxy. Preserves unrelated keys and model choices while normalizing
7438
- * managed one-million-context GPT overrides. Sets a dummy auth token only if absent.
8505
+ * managed one-million-context GPT overrides. Sets the auth token to the relay's
8506
+ * apiKey when one is configured and then publishes the file owner-only;
8507
+ * otherwise keeps an existing token and writes a dummy value only if none is set.
7439
8508
  */
7440
8509
  async function applyClaudeConfig(input) {
7441
8510
  const { configPath: configPath$1, baseUrl, gptModel } = input;
@@ -7450,7 +8519,7 @@ async function applyClaudeConfig(input) {
7450
8519
  };
7451
8520
  if (raw.trim().length > 0) try {
7452
8521
  const value = JSON.parse(raw);
7453
- if (!isRecord(value) || value.env !== void 0 && !isRecord(value.env) || hasMalformedModelOverride(value)) return {
8522
+ if (!isRecord$1(value) || value.env !== void 0 && !isRecord$1(value.env) || hasMalformedModelOverride(value)) return {
7454
8523
  configPath: configPath$1,
7455
8524
  changed: false,
7456
8525
  created: false
@@ -7463,7 +8532,7 @@ async function applyClaudeConfig(input) {
7463
8532
  created: false
7464
8533
  };
7465
8534
  }
7466
- const env = isRecord(parsed.env) ? { ...parsed.env } : {};
8535
+ const env = isRecord$1(parsed.env) ? { ...parsed.env } : {};
7467
8536
  const previousBaseUrl = typeof env.ANTHROPIC_BASE_URL === "string" ? env.ANTHROPIC_BASE_URL : void 0;
7468
8537
  const hadPrimaryModelOverride = hasPrimaryModelOverride(parsed, env);
7469
8538
  const contextWindow = input.gptLimits?.max_context_window_tokens;
@@ -7472,9 +8541,11 @@ async function applyClaudeConfig(input) {
7472
8541
  const nextModel = typeof parsed.model === "string" ? normalizeClaudeModelId(parsed.model, contextWindow) : void 0;
7473
8542
  for (const key of knownModelEnvKeys) if (typeof env[key] === "string") env[key] = normalizeClaudeModelId(env[key], contextWindow);
7474
8543
  env.ANTHROPIC_BASE_URL = baseUrl;
7475
- if (typeof env.ANTHROPIC_AUTH_TOKEN !== "string" || !env.ANTHROPIC_AUTH_TOKEN) env.ANTHROPIC_AUTH_TOKEN = "dummy";
8544
+ if (input.apiKey) env.ANTHROPIC_AUTH_TOKEN = input.apiKey;
8545
+ else if (typeof env.ANTHROPIC_AUTH_TOKEN !== "string" || !env.ANTHROPIC_AUTH_TOKEN) env.ANTHROPIC_AUTH_TOKEN = "dummy";
7476
8546
  if (contextWindow !== void 0 && env.CLAUDE_CODE_MAX_CONTEXT_TOKENS === void 0) env.CLAUDE_CODE_MAX_CONTEXT_TOKENS = String(contextWindow);
7477
8547
  if (input.maxOutputTokens !== void 0 && env.CLAUDE_CODE_MAX_OUTPUT_TOKENS === void 0) env.CLAUDE_CODE_MAX_OUTPUT_TOKENS = String(input.maxOutputTokens);
8548
+ if (env.CLAUDE_CODE_AUTO_MODE_SERVER === void 0) env.CLAUDE_CODE_AUTO_MODE_SERVER = "0";
7478
8549
  const next = {
7479
8550
  ...parsed,
7480
8551
  ...nextModel !== void 0 && { model: nextModel },
@@ -7482,13 +8553,15 @@ async function applyClaudeConfig(input) {
7482
8553
  env
7483
8554
  };
7484
8555
  const serialized = `${JSON.stringify(next, null, 2)}\n`;
7485
- if (serialized === raw) return {
8556
+ const writesKey = Boolean(input.apiKey);
8557
+ const keyExposed = writesKey && process.platform !== "win32" && (snapshot.mode & 63) !== 0;
8558
+ if (serialized === raw && !keyExposed) return {
7486
8559
  configPath: configPath$1,
7487
8560
  changed: false,
7488
8561
  created: false,
7489
8562
  previousBaseUrl
7490
8563
  };
7491
- await writeFileSnapshot(snapshot, serialized);
8564
+ await writeFileSnapshot(snapshot, serialized, { ownerOnly: writesKey });
7492
8565
  return {
7493
8566
  configPath: configPath$1,
7494
8567
  changed: true,
@@ -7559,6 +8632,34 @@ const validateUpstream = async (config, thinkEffort) => {
7559
8632
  //#endregion
7560
8633
  //#region src/start.ts
7561
8634
  const canCloseConnections = (server) => typeof server.closeIdleConnections === "function" && typeof server.closeAllConnections === "function";
8635
+ /**
8636
+ * The startup warning for a listener that other machines may reach without a key.
8637
+ *
8638
+ * A warning rather than a refusal, so an existing `host: 0.0.0.0` setup keeps
8639
+ * working after an upgrade. It names the apiKey setting and never prints a key.
8640
+ */
8641
+ const unauthenticatedListenerWarning = (config) => {
8642
+ if (config.apiKey || isLoopbackHost(config.host)) return;
8643
+ return terminalText(`apiKey is empty and host ${config.host} is not a loopback address: any client that can reach port ${config.port} can consume your Copilot usage. Set apiKey in ${paths.configPath}, or bind host to 127.0.0.1.`);
8644
+ };
8645
+ /**
8646
+ * The warning for a config.yaml that other local users can access while it holds the apiKey.
8647
+ *
8648
+ * A new config.yaml is created 0600, but an existing one keeps its mode, and the relay never
8649
+ * changes the mode of the user's own file. stat follows a symlink, so the mode checked is the
8650
+ * target's. Windows has no POSIX mode bits. It never prints a key.
8651
+ */
8652
+ const exposedConfigWarning = (config, configPath$1 = paths.configPath) => {
8653
+ if (!config.apiKey || process.platform === "win32") return;
8654
+ let mode;
8655
+ try {
8656
+ mode = statSync(configPath$1).mode & 511;
8657
+ } catch {
8658
+ return;
8659
+ }
8660
+ if ((mode & 63) === 0) return;
8661
+ return terminalText(`${configPath$1} holds the apiKey and has mode ${mode.toString(8).padStart(4, "0")}, which lets other local users access it. Run chmod 600 ${configPath$1}.`);
8662
+ };
7562
8663
  async function startRelay(appConfig) {
7563
8664
  appConfig ??= await readAppConfig();
7564
8665
  appConfig = {
@@ -7568,10 +8669,12 @@ async function startRelay(appConfig) {
7568
8669
  setLogLevel(appConfig.logLevel);
7569
8670
  await cleanupLogs(appConfig.logRetentionDays);
7570
8671
  await cleanupCaptures(appConfig.logRetentionDays);
8672
+ configureUpstreamDispatcher(appConfig.upstreamProxy);
7571
8673
  const claudeConfigPath$1 = claudeConfigPath;
7572
8674
  const config = readProxyConfig(appConfig);
7573
8675
  const applyRuntimeConfig = (nextConfig) => {
7574
8676
  registerSensitiveOrigin(nextConfig.copilotBaseUrl);
8677
+ registerLogSecret(nextConfig.apiKey);
7575
8678
  setLogLevel(nextConfig.logLevel);
7576
8679
  cleanupLogs(nextConfig.logRetentionDays).catch(() => log.error("Log retention failed; existing logs retained."));
7577
8680
  cleanupCaptures(nextConfig.logRetentionDays);
@@ -7585,6 +8688,7 @@ async function startRelay(appConfig) {
7585
8688
  config.upstreamTimeoutMs = nextConfig.upstreamTimeoutSeconds * 1e3;
7586
8689
  config.webSearchBackend = nextConfig.webSearchBackend;
7587
8690
  config.claudeUpstreamApi = nextConfig.claudeUpstreamApi;
8691
+ config.apiKey = nextConfig.apiKey;
7588
8692
  runtimeState.modelRouting = {
7589
8693
  gptModel: nextConfig.gptModel,
7590
8694
  opusModel: nextConfig.opusModel
@@ -7594,6 +8698,11 @@ async function startRelay(appConfig) {
7594
8698
  log.info(`Log level: ${appConfig.logLevel}`);
7595
8699
  log.info(`Default think effort: ${appConfig.thinkEffort}`);
7596
8700
  log.info(`Upstream timeout: ${appConfig.upstreamTimeoutSeconds}s`);
8701
+ if (appConfig.upstreamProxy !== void 0) log.info(`Upstream proxy: ${formatUpstreamProxyForDisplay(appConfig.upstreamProxy)}`);
8702
+ const listenerWarning = unauthenticatedListenerWarning(appConfig);
8703
+ if (listenerWarning) log.error(listenerWarning);
8704
+ const configWarning = exposedConfigWarning(appConfig);
8705
+ if (configWarning) log.error(configWarning);
7597
8706
  const authSession = await setupProxyAuth(config);
7598
8707
  try {
7599
8708
  await validateUpstream(config, appConfig.thinkEffort);
@@ -7620,6 +8729,7 @@ async function startRelay(appConfig) {
7620
8729
  const gptLimits = getCachedCopilotModel(config, appConfig.gptModel)?.limits;
7621
8730
  const opusLimits = getCachedCopilotModel(config, appConfig.opusModel)?.limits;
7622
8731
  const claudeResult = await applyClaudeConfig({
8732
+ apiKey: appConfig.apiKey,
7623
8733
  baseUrl,
7624
8734
  configPath: claudeConfigPath$1,
7625
8735
  gptModel: appConfig.gptModel,
@@ -7634,8 +8744,11 @@ async function startRelay(appConfig) {
7634
8744
  log.error(`Could not update claude settings (${claudeConfigPath$1}):`, error);
7635
8745
  }
7636
8746
  const configWatcher = watchAppConfig((nextConfig) => {
8747
+ const previousApiKey = config.apiKey;
7637
8748
  applyRuntimeConfig(nextConfig);
7638
8749
  log.info(`Config reloaded: logLevel=${nextConfig.logLevel} thinkEffort=${nextConfig.thinkEffort} upstreamTimeoutSeconds=${nextConfig.upstreamTimeoutSeconds}`);
8750
+ const reloadedConfigWarning = nextConfig.apiKey === previousApiKey ? void 0 : exposedConfigWarning(nextConfig);
8751
+ if (reloadedConfigWarning) log.error(reloadedConfigWarning);
7639
8752
  });
7640
8753
  const shutdownGraceMs = 2e3;
7641
8754
  const shutdown = (signal) => {
@@ -7718,7 +8831,9 @@ const localProbeTimeoutMs = 5e3;
7718
8831
  const deepProbeTimeoutMs = 6e4;
7719
8832
  const toStatusConfig = (config) => ({
7720
8833
  ...config,
8834
+ apiKey: config.apiKey ? "[redacted]" : "",
7721
8835
  copilotBaseUrl: formatUrlForDisplay(config.copilotBaseUrl),
8836
+ upstreamProxy: config.upstreamProxy === void 0 ? null : formatUpstreamProxyForDisplay(config.upstreamProxy),
7722
8837
  webSearchBackend: config.webSearchBackend ?? null,
7723
8838
  claudeUpstreamApi: config.claudeUpstreamApi ?? "chat-completions"
7724
8839
  });
@@ -7772,27 +8887,35 @@ const formatUptime = (startedAt) => {
7772
8887
  const configRowOrder = {
7773
8888
  host: 0,
7774
8889
  port: 1,
7775
- 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
8890
+ apiKey: 2,
8891
+ copilotBaseUrl: 3,
8892
+ upstreamProxy: 4,
8893
+ claudeSetup: 5,
8894
+ logLevel: 6,
8895
+ logRetentionDays: 7,
8896
+ thinkEffort: 8,
8897
+ upstreamTimeoutSeconds: 9,
8898
+ webSearchBackend: 10,
8899
+ gptModel: 11,
8900
+ opusModel: 12,
8901
+ claudeUpstreamApi: 13
7785
8902
  };
7786
8903
  const configRowKeys = Object.keys(configRowOrder).sort((a, b) => configRowOrder[a] - configRowOrder[b]);
8904
+ const unsetConfigText = {
8905
+ apiKey: "(unset — clients need no key)",
8906
+ upstreamProxy: "(unset — connects directly)",
8907
+ webSearchBackend: "(unset — uses gptModel)"
8908
+ };
7787
8909
  /**
7788
8910
  * The resolved config, one key per line.
7789
8911
  *
7790
8912
  * These are the values on disk. That is not the same question as "what is the
7791
8913
  * running daemon honouring", and the footnote says so rather than leaving it
7792
- * implied: applyRuntimeConfig() in start.ts never reads claudeSetup and
7793
- * deliberately 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.
8914
+ * implied: applyRuntimeConfig() in start.ts never reads claudeSetup, and
8915
+ * deliberately neither rebinds the listening socket when host or port changes
8916
+ * nor rebuilds the upstream dispatcher when upstreamProxy changes; it
8917
+ * hot-reloads the rest. Printing every key without that line would imply a
8918
+ * live daemon had read values it has not.
7796
8919
  *
7797
8920
  * Only shown when running, because with nothing up every value applies at the
7798
8921
  * next start and the note would be noise.
@@ -7800,10 +8923,10 @@ const configRowKeys = Object.keys(configRowOrder).sort((a, b) => configRowOrder[
7800
8923
  const renderConfig = (config, running) => {
7801
8924
  const lines = configRowKeys.map((key) => {
7802
8925
  const value = config[key];
7803
- const rendered = key === "webSearchBackend" && value === null ? "(unset — uses gptModel)" : String(value);
8926
+ const rendered = (value === null || value === "" ? unsetConfigText[key] : void 0) ?? String(value);
7804
8927
  return ` ${key.padEnd(24)}${sanitizeTerminalString(rendered)}`;
7805
8928
  });
7806
- if (running) lines.push(" host, port and claudeSetup take effect on restart; the rest hot-reload.");
8929
+ if (running) lines.push(" host, port, claudeSetup and upstreamProxy take effect on restart; the rest hot-reload.");
7807
8930
  return lines;
7808
8931
  };
7809
8932
  /**
@@ -7831,9 +8954,26 @@ const renderStatus = (status$1, color = false) => {
7831
8954
  if (hasVersionMismatch(status$1)) lines.push("", ` The running relay is ${status$1.daemonVersion}; ${status$1.version} is installed.`, " Restart it to serve the installed version: copilot-relay restart");
7832
8955
  return lines;
7833
8956
  };
8957
+ let relayAgent;
8958
+ /**
8959
+ * How the probes reach the relay's own listener: undici's fetch with a direct Agent of their own,
8960
+ * never the global dispatcher and never the upstream one. With NODE_USE_ENV_PROXY=1, Node's fetch
8961
+ * sends even a request to localhost through HTTP_PROXY unless NO_PROXY exempts it, so through a
8962
+ * proxy that refuses, status would report a healthy relay as unusable. upstreamProxy is for
8963
+ * Copilot and GitHub, not for this machine.
8964
+ *
8965
+ * An object, so a test can replace fetch.
8966
+ */
8967
+ const relayListener = { fetch: (url, init) => {
8968
+ relayAgent ??= new Agent();
8969
+ return fetch(url, {
8970
+ ...init,
8971
+ dispatcher: relayAgent
8972
+ });
8973
+ } };
7834
8974
  const probe = async (url, init, timeoutMs) => {
7835
8975
  const started = performance.now();
7836
- const response = await fetch(url, {
8976
+ const response = await relayListener.fetch(url, {
7837
8977
  ...init,
7838
8978
  signal: AbortSignal.timeout(timeoutMs)
7839
8979
  });
@@ -7878,9 +9018,10 @@ const checkHealth = async (baseUrl) => {
7878
9018
  } };
7879
9019
  }
7880
9020
  };
7881
- const readModels = async (baseUrl) => {
9021
+ const relayKeyHeaders = (apiKey) => apiKey ? { "x-api-key": apiKey } : {};
9022
+ const readModels = async (baseUrl, apiKey) => {
7882
9023
  try {
7883
- const data = (await probe(`${baseUrl}/v1/models`, {}, localProbeTimeoutMs)).body?.data;
9024
+ const data = (await probe(`${baseUrl}/v1/models`, { headers: relayKeyHeaders(apiKey) }, localProbeTimeoutMs)).body?.data;
7884
9025
  return Array.isArray(data) ? data.flatMap((model) => typeof model.id === "string" ? [model.id] : []) : [];
7885
9026
  } catch {
7886
9027
  return [];
@@ -7892,7 +9033,7 @@ const readModels = async (baseUrl) => {
7892
9033
  * Opt-in because it spends tokens. It is the only check that exercises token
7893
9034
  * refresh, the upstream call, and translation back to Claude shape.
7894
9035
  */
7895
- const checkDeep = async (baseUrl, model) => {
9036
+ const checkDeep = async (baseUrl, model, apiKey) => {
7896
9037
  try {
7897
9038
  const result = await probe(`${baseUrl}/v1/messages`, {
7898
9039
  body: JSON.stringify({
@@ -7905,7 +9046,8 @@ const checkDeep = async (baseUrl, model) => {
7905
9046
  }),
7906
9047
  headers: {
7907
9048
  "anthropic-version": "2023-06-01",
7908
- "content-type": "application/json"
9049
+ "content-type": "application/json",
9050
+ ...relayKeyHeaders(apiKey)
7909
9051
  },
7910
9052
  method: "POST"
7911
9053
  }, deepProbeTimeoutMs);
@@ -7948,6 +9090,7 @@ const collectStatus = async (options) => {
7948
9090
  });
7949
9091
  setLogLevel(appConfig.logLevel);
7950
9092
  registerSensitiveOrigin(appConfig.copilotBaseUrl);
9093
+ registerLogSecret(appConfig.apiKey);
7951
9094
  const base = {
7952
9095
  config: toStatusConfig(appConfig),
7953
9096
  configPath: paths.configPath,
@@ -7963,8 +9106,8 @@ const collectStatus = async (options) => {
7963
9106
  };
7964
9107
  const baseUrl = getRelayBaseUrl(relay.host, relay.port);
7965
9108
  const health = await checkHealth(baseUrl);
7966
- const models$1 = health.result.ok ? await readModels(baseUrl) : [];
7967
- const deep = options.deep && health.result.ok ? await checkDeep(baseUrl, models$1[0] ?? appConfig.gptModel) : void 0;
9109
+ const models$1 = health.result.ok ? await readModels(baseUrl, appConfig.apiKey) : [];
9110
+ const deep = options.deep && health.result.ok ? await checkDeep(baseUrl, models$1[0] ?? appConfig.gptModel, appConfig.apiKey) : void 0;
7968
9111
  const daemonVersion = health.version ?? relay.version;
7969
9112
  return {
7970
9113
  ...base,
@@ -8009,8 +9152,8 @@ const status = defineCommand({
8009
9152
  process.exitCode = exitCodes.notUsable;
8010
9153
  return;
8011
9154
  }
8012
- if (args.json) console.log(scrubSensitiveUrls(JSON.stringify(result, null, 2)));
8013
- else console.log(scrubSensitiveUrls(renderStatus(result, colorEnabled()).join("\n")));
9155
+ if (args.json) console.log(scrubLogSecrets(scrubSensitiveUrls(withoutHiddenCharacters(JSON.stringify(result, null, 2)))));
9156
+ else console.log(scrubLogSecrets(scrubSensitiveUrls(withoutHiddenCharacters(renderStatus(result, colorEnabled()).join("\n")))));
8014
9157
  process.exitCode = resolveExitCode(result);
8015
9158
  }
8016
9159
  });
@@ -8037,6 +9180,225 @@ const stop = defineCommand({
8037
9180
  }
8038
9181
  });
8039
9182
 
9183
+ //#endregion
9184
+ //#region src/lib/usage.ts
9185
+ const knownQuotaIds = [
9186
+ "chat",
9187
+ "completions",
9188
+ "premium_interactions"
9189
+ ];
9190
+ /** A failure the command reports. The message is one printable line and never holds the token. */
9191
+ var CopilotUsageError = class extends Error {};
9192
+ const notAnObject = "GitHub's answer to the usage request was not a JSON object.";
9193
+ const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
9194
+ const stringField = (value) => typeof value === "string" ? terminalText(value) : null;
9195
+ const numberField = (value) => typeof value === "number" && Number.isFinite(value) ? value : null;
9196
+ const booleanField = (value) => typeof value === "boolean" ? value : null;
9197
+ const parseSnapshot = (value) => {
9198
+ if (!isRecord(value)) return null;
9199
+ return {
9200
+ unlimited: booleanField(value.unlimited),
9201
+ entitlement: numberField(value.entitlement),
9202
+ remaining: numberField(value.remaining),
9203
+ percent_remaining: numberField(value.percent_remaining),
9204
+ overage_permitted: booleanField(value.overage_permitted),
9205
+ overage_count: numberField(value.overage_count),
9206
+ token_based_billing: booleanField(value.token_based_billing),
9207
+ credits_used: numberField(value.credits_used)
9208
+ };
9209
+ };
9210
+ /**
9211
+ * The plan and quota fields of a copilot_internal/user answer, each checked on its own.
9212
+ *
9213
+ * Throws CopilotUsageError when the answer is not a JSON object at all.
9214
+ */
9215
+ const parseCopilotUsage = (body) => {
9216
+ if (!isRecord(body)) throw new CopilotUsageError(notAnObject);
9217
+ const quota = body.quota_snapshots;
9218
+ const reported = isRecord(quota) ? quota : {};
9219
+ const snapshots = /* @__PURE__ */ new Map();
9220
+ for (const id of knownQuotaIds) snapshots.set(id, parseSnapshot(reported[id]));
9221
+ for (const [rawId, value] of Object.entries(reported)) {
9222
+ const id = terminalText(rawId);
9223
+ if (id === "" || snapshots.has(id)) continue;
9224
+ snapshots.set(id, parseSnapshot(value));
9225
+ }
9226
+ return {
9227
+ copilot_plan: stringField(body.copilot_plan),
9228
+ access_type_sku: stringField(body.access_type_sku),
9229
+ quota_reset_date: stringField(body.quota_reset_date),
9230
+ quota_snapshots: Object.fromEntries(snapshots)
9231
+ };
9232
+ };
9233
+ const notReported = "not reported";
9234
+ const formatNumber = (value) => value === null ? "?" : String(value);
9235
+ const formatAllowance = (snapshot) => {
9236
+ if (snapshot.unlimited === true) return "unlimited";
9237
+ return `${`${formatNumber(snapshot.remaining)} of ${formatNumber(snapshot.entitlement)} remaining`} (${formatNumber(snapshot.percent_remaining)}%)`;
9238
+ };
9239
+ const formatOverage = (snapshot) => {
9240
+ let permitted = "overage ?";
9241
+ if (snapshot.overage_permitted === true) permitted = "overage permitted";
9242
+ else if (snapshot.overage_permitted === false) permitted = "overage not permitted";
9243
+ return `${permitted}, overage count ${formatNumber(snapshot.overage_count)}`;
9244
+ };
9245
+ const formatQuota = (snapshot) => {
9246
+ const parts = [formatAllowance(snapshot), formatOverage(snapshot)];
9247
+ if (snapshot.credits_used !== null) parts.push(`credits used ${formatNumber(snapshot.credits_used)}`);
9248
+ return parts.join("; ");
9249
+ };
9250
+ /** The report as text lines. Kept free of IO, so it is tested without a terminal. */
9251
+ const renderCopilotUsage = (usage$1, color = false) => {
9252
+ const shown = (value) => value ?? colorText(notReported, "muted", color);
9253
+ const headings = [
9254
+ ["Plan", usage$1.copilot_plan],
9255
+ ["SKU", usage$1.access_type_sku],
9256
+ ["Quota reset", usage$1.quota_reset_date]
9257
+ ];
9258
+ const labelWidth = Math.max(...headings.map(([label]) => label.length));
9259
+ const known = [...knownQuotaIds];
9260
+ const ids = [...known, ...Object.keys(usage$1.quota_snapshots).filter((id) => !known.includes(id))];
9261
+ const idWidth = Math.max(...ids.map((id) => id.length));
9262
+ const quotaLines = ids.map((id) => {
9263
+ const snapshot = usage$1.quota_snapshots[id] ?? null;
9264
+ const text$1 = snapshot === null ? colorText(notReported, "muted", color) : formatQuota(snapshot);
9265
+ return `${id.padEnd(idWidth)} ${text$1}`;
9266
+ });
9267
+ return [
9268
+ ...headings.map(([label, value]) => `${label.padEnd(labelWidth)} ${shown(value)}`),
9269
+ "",
9270
+ ...quotaLines
9271
+ ];
9272
+ };
9273
+ const requestTimeoutSeconds = 30;
9274
+ const tokenInAnswer = "GitHub's answer to the usage request contains the stored token, so none of it is printed.";
9275
+ const visibleToken = (token) => terminalText(token);
9276
+ const withoutToken = (line, token) => {
9277
+ const visible = visibleToken(token);
9278
+ return visible === "" ? line : line.replaceAll(visible, "[redacted]");
9279
+ };
9280
+ const lettersAndDigits = (text$1) => text$1.replace(/[^A-Za-z0-9]/g, "");
9281
+ const minimumSpelledLength = 16;
9282
+ const spellsToken = (text$1, token) => {
9283
+ const spelled = lettersAndDigits(visibleToken(token));
9284
+ return spelled.length >= minimumSpelledLength && lettersAndDigits(text$1).includes(spelled);
9285
+ };
9286
+ const showsToken = (usage$1, token) => {
9287
+ const visible = visibleToken(token);
9288
+ const printed = [
9289
+ usage$1.copilot_plan,
9290
+ usage$1.access_type_sku,
9291
+ usage$1.quota_reset_date,
9292
+ ...Object.keys(usage$1.quota_snapshots)
9293
+ ].filter((text$1) => text$1 !== null);
9294
+ if (visible !== "" && printed.some((text$1) => text$1.includes(visible))) return true;
9295
+ return spellsToken(printed.join(""), token);
9296
+ };
9297
+ const failureReason = (error) => {
9298
+ const cause = error instanceof Error ? error.cause : void 0;
9299
+ if (cause instanceof Error && cause.message !== "") return cause.message;
9300
+ const code = cause?.code;
9301
+ if (typeof code === "string") return code;
9302
+ return error instanceof Error && error.message !== "" ? error.message : String(error);
9303
+ };
9304
+ const isTimeout = (error) => typeof error === "object" && error !== null && "name" in error && error.name === "TimeoutError";
9305
+ const describeRequestFailure = async (error, token) => {
9306
+ if (error instanceof HTTPError) {
9307
+ await error.response.body?.cancel().catch(() => {});
9308
+ const status$1 = error.response.status;
9309
+ if (status$1 === 401 || status$1 === 403) return new CopilotUsageError(`GitHub rejected the stored token (HTTP ${status$1}). Sign in again with copilot-relay auth.`);
9310
+ return new CopilotUsageError(`GitHub answered the usage request with HTTP ${status$1}.`);
9311
+ }
9312
+ if (isTimeout(error)) return new CopilotUsageError(`GitHub did not answer within ${requestTimeoutSeconds} seconds.`);
9313
+ if (error instanceof SyntaxError) return new CopilotUsageError(notAnObject);
9314
+ const line = withoutToken(terminalText(`Could not reach GitHub: ${failureReason(error)}`), token);
9315
+ return new CopilotUsageError(spellsToken(line, token) ? "Could not reach GitHub." : line);
9316
+ };
9317
+ const readToken = async () => {
9318
+ let token;
9319
+ try {
9320
+ token = await readStoredGitHubToken();
9321
+ } catch (error) {
9322
+ const code = error?.code;
9323
+ const reason = typeof code === "string" ? `: ${code}` : "";
9324
+ throw new CopilotUsageError(terminalText(`Could not read the GitHub token at ${paths.githubTokenPath}${reason}.`));
9325
+ }
9326
+ if (token === void 0) throw new CopilotUsageError(terminalText(`No GitHub token is stored at ${paths.githubTokenPath}. Sign in with copilot-relay auth.`));
9327
+ return token;
9328
+ };
9329
+ /**
9330
+ * Builds this process's upstream dispatcher from upstreamProxy, as start, auth and models do, but
9331
+ * reads config.yaml without creating, completing or rewriting it. Without a config.yaml the
9332
+ * request connects directly.
9333
+ *
9334
+ * A config.yaml that cannot be read or is invalid gets one fixed line, whatever the reason, so no
9335
+ * text from that file can reach the terminal: it can hold credentials, such as an upstreamProxy
9336
+ * password.
9337
+ */
9338
+ const applyUpstreamProxy = async () => {
9339
+ let upstreamProxy;
9340
+ try {
9341
+ upstreamProxy = (await readExistingAppConfig())?.upstreamProxy;
9342
+ } catch {
9343
+ throw new CopilotUsageError(terminalText(`Could not read the config at ${paths.configPath}; fix it, then run copilot-relay usage again.`));
9344
+ }
9345
+ try {
9346
+ configureUpstreamDispatcher(upstreamProxy);
9347
+ } catch (error) {
9348
+ if (error instanceof InvalidProxyEnvironmentError) throw new CopilotUsageError(error.message);
9349
+ throw error;
9350
+ }
9351
+ };
9352
+ const requestCopilotUsage = async () => {
9353
+ const token = await readToken();
9354
+ await applyUpstreamProxy();
9355
+ let body;
9356
+ try {
9357
+ body = await getCopilotUsage(token, vscodeVersion, AbortSignal.timeout(requestTimeoutSeconds * 1e3));
9358
+ } catch (error) {
9359
+ throw await describeRequestFailure(error, token);
9360
+ }
9361
+ const usage$1 = parseCopilotUsage(body);
9362
+ if (showsToken(usage$1, token)) throw new CopilotUsageError(tokenInAnswer);
9363
+ return usage$1;
9364
+ };
9365
+ /**
9366
+ * The Copilot plan and quota of the stored GitHub token's account.
9367
+ *
9368
+ * Reads the token file and config.yaml, and asks GitHub through upstreamProxy; writes nothing.
9369
+ * Logging is off throughout, so a line the upstream request would log, such as the hint about an
9370
+ * ignored proxy variable, never reaches the log file. Every failure becomes a CopilotUsageError
9371
+ * whose message is the line to print.
9372
+ */
9373
+ const loadCopilotUsage = () => withoutLogging(requestCopilotUsage);
9374
+
9375
+ //#endregion
9376
+ //#region src/usage.ts
9377
+ const usage = defineCommand({
9378
+ meta: {
9379
+ name: "usage",
9380
+ description: "Show the Copilot plan and quota GitHub reports for the stored GitHub token."
9381
+ },
9382
+ args: { json: {
9383
+ description: "Print the plan and quota fields as a JSON object.",
9384
+ type: "boolean"
9385
+ } },
9386
+ async run({ args }) {
9387
+ try {
9388
+ const report = await loadCopilotUsage();
9389
+ if (args.json) console.log(JSON.stringify(report, null, 2));
9390
+ else console.log(renderCopilotUsage(report, colorEnabled()).join("\n"));
9391
+ } catch (error) {
9392
+ if (error instanceof CopilotUsageError) console.error(error.message);
9393
+ else {
9394
+ const name = error instanceof Error ? error.name : typeof error;
9395
+ console.error(terminalText(`Could not show the Copilot usage: unexpected ${name}.`));
9396
+ }
9397
+ process.exitCode = 1;
9398
+ }
9399
+ }
9400
+ });
9401
+
8040
9402
  //#endregion
8041
9403
  //#region src/main.ts
8042
9404
  await runMain(defineCommand({
@@ -8046,12 +9408,14 @@ await runMain(defineCommand({
8046
9408
  },
8047
9409
  subCommands: {
8048
9410
  auth,
9411
+ cache,
8049
9412
  models,
8050
9413
  replay,
8051
9414
  restart,
8052
9415
  start,
8053
9416
  status,
8054
- stop
9417
+ stop,
9418
+ usage
8055
9419
  }
8056
9420
  }));
8057
9421