copilot-relay 0.4.4 → 0.4.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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();
@@ -831,6 +1089,7 @@ const normalizeClaudeModelId = (model, contextWindowTokens) => {
831
1089
  return contextWindowTokens !== void 0 && contextWindowTokens !== 1e6 ? normalized : `${normalized}[1m]`;
832
1090
  };
833
1091
  const normalizeCopilotModelId = (model) => normalizeOneMillionContextModel(model) ?? model;
1092
+ const isClaudeModelId = (model) => model.startsWith("claude-");
834
1093
  const getConfiguredModelRouting = () => getRuntimeState().modelRouting ?? defaultModelRouting;
835
1094
  const getModelRouting = () => {
836
1095
  const routing = getConfiguredModelRouting();
@@ -860,7 +1119,13 @@ var FileConflictError = class extends Error {
860
1119
  this.name = "FileConflictError";
861
1120
  }
862
1121
  };
863
- 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";
864
1129
  const identityOf = (stat) => ({
865
1130
  dev: stat.dev,
866
1131
  ino: stat.ino,
@@ -873,9 +1138,9 @@ const resolveTarget = async (filePath, remainingLinks = 40) => {
873
1138
  try {
874
1139
  return await fs.realpath(filePath);
875
1140
  } catch (error) {
876
- if (!isMissing(error)) throw error;
1141
+ if (!isMissing$1(error)) throw error;
877
1142
  if ((await fs.lstat(filePath).catch((cause) => {
878
- if (!isMissing(cause)) throw cause;
1143
+ if (!isMissing$1(cause)) throw cause;
879
1144
  }))?.isSymbolicLink()) {
880
1145
  if (remainingLinks === 0) throw new Error("Too many symbolic links");
881
1146
  return resolveTarget(path.resolve(path.dirname(filePath), await fs.readlink(filePath)), remainingLinks - 1);
@@ -889,7 +1154,7 @@ const readStableSnapshot = async (filePath) => {
889
1154
  const requestedPath = path.resolve(filePath);
890
1155
  const resolvedPath = await resolveTarget(requestedPath);
891
1156
  const before = await fs.lstat(resolvedPath).catch((error) => {
892
- if (!isMissing(error)) throw error;
1157
+ if (!isMissing$1(error)) throw error;
893
1158
  });
894
1159
  if (!before) return {
895
1160
  requestedPath,
@@ -898,14 +1163,14 @@ const readStableSnapshot = async (filePath) => {
898
1163
  mode: 384,
899
1164
  identity: null
900
1165
  };
901
- if (!before.isFile()) throw new FileConflictError();
1166
+ if (!before.isFile()) throw new NotRegularFileError(resolvedPath);
902
1167
  let raw;
903
1168
  let after;
904
1169
  try {
905
1170
  raw = await fs.readFile(resolvedPath, "utf8");
906
1171
  after = await fs.lstat(resolvedPath);
907
1172
  } catch (error) {
908
- if (isMissing(error)) throw new FileConflictError();
1173
+ if (isMissing$1(error)) throw new FileConflictError();
909
1174
  throw error;
910
1175
  }
911
1176
  const snapshot = {
@@ -954,7 +1219,7 @@ const assertUnchanged = async (expected) => {
954
1219
  if (current.resolvedPath !== expected.resolvedPath || current.raw !== expected.raw || current.mode !== expected.mode || !sameIdentity(current.identity, expected.identity)) throw new FileConflictError();
955
1220
  };
956
1221
  const pendingWrites = /* @__PURE__ */ new Map();
957
- const publish = async (snapshot, content) => {
1222
+ const publish = async (snapshot, content, options) => {
958
1223
  await assertUnchanged(snapshot);
959
1224
  const directory = path.dirname(snapshot.resolvedPath);
960
1225
  await fs.mkdir(directory, {
@@ -967,7 +1232,7 @@ const publish = async (snapshot, content) => {
967
1232
  try {
968
1233
  try {
969
1234
  await handle.writeFile(content, "utf8");
970
- if (process.platform !== "win32") await handle.chmod(snapshot.mode);
1235
+ if (process.platform !== "win32") await handle.chmod(options.ownerOnly ? 384 : snapshot.mode);
971
1236
  await handle.sync();
972
1237
  } finally {
973
1238
  await handle.close();
@@ -984,8 +1249,8 @@ const publish = async (snapshot, content) => {
984
1249
  await fs.rm(temporaryPath, { force: true });
985
1250
  }
986
1251
  };
987
- const writeFileSnapshot = async (snapshot, content) => {
988
- const current = (pendingWrites.get(snapshot.resolvedPath) ?? Promise.resolve()).catch(() => void 0).then(() => publish(snapshot, content));
1252
+ const writeFileSnapshot = async (snapshot, content, options = {}) => {
1253
+ const current = (pendingWrites.get(snapshot.resolvedPath) ?? Promise.resolve()).catch(() => void 0).then(() => publish(snapshot, content, options));
989
1254
  pendingWrites.set(snapshot.resolvedPath, current);
990
1255
  try {
991
1256
  await current;
@@ -994,6 +1259,29 @@ const writeFileSnapshot = async (snapshot, content) => {
994
1259
  }
995
1260
  };
996
1261
 
1262
+ //#endregion
1263
+ //#region src/lib/terminal.ts
1264
+ const colorEnabled = (env = process.env, isTTY = Boolean(process.stdout.isTTY)) => {
1265
+ if (env.NO_COLOR !== void 0 || env.FORCE_COLOR === "0") return false;
1266
+ if (env.FORCE_COLOR !== void 0) return env.FORCE_COLOR === "" || [
1267
+ "1",
1268
+ "2",
1269
+ "3",
1270
+ "true"
1271
+ ].includes(env.FORCE_COLOR);
1272
+ return isTTY && env.TERM !== "dumb";
1273
+ };
1274
+ const ansiColorCodes = {
1275
+ good: 32,
1276
+ bad: 31,
1277
+ warning: 33,
1278
+ muted: 90
1279
+ };
1280
+ const hiddenCharacters = /[\x80-\x9f]|\p{Cf}|\p{Zl}|\p{Zp}/gu;
1281
+ const withoutHiddenCharacters = (value) => value.replace(hiddenCharacters, "");
1282
+ const terminalText = (value) => withoutHiddenCharacters(sanitizeTerminalString(value));
1283
+ const colorText = (text$1, tone, enabled) => enabled ? `\u001b[${ansiColorCodes[tone]}m${text$1}\u001b[0m` : text$1;
1284
+
997
1285
  //#endregion
998
1286
  //#region src/lib/app-config.ts
999
1287
  const logLevels = [
@@ -1010,6 +1298,7 @@ const logLevels = [
1010
1298
  * rewrite a value the user's config already holds.
1011
1299
  */
1012
1300
  const defaultConfig = {
1301
+ apiKey: "",
1013
1302
  claudeSetup: true,
1014
1303
  copilotBaseUrl: "https://api.githubcopilot.com",
1015
1304
  gptModel: "gpt-6-astra",
@@ -1021,6 +1310,7 @@ const defaultConfig = {
1021
1310
  thinkEffort: defaultReasoningEffort,
1022
1311
  upstreamTimeoutSeconds: 180,
1023
1312
  webSearchBackend: void 0,
1313
+ upstreamProxy: void 0,
1024
1314
  claudeUpstreamApi: "chat-completions"
1025
1315
  };
1026
1316
  const isLogLevelName = (value) => typeof value === "string" && logLevels.includes(value.toLowerCase());
@@ -1028,7 +1318,7 @@ const normalizeLogLevel = (value) => {
1028
1318
  if (value === void 0) return;
1029
1319
  if (typeof value !== "string") throw new Error(`Invalid logLevel: expected one of ${logLevels.join(", ")}`);
1030
1320
  const normalized = value.toLowerCase();
1031
- if (!isLogLevelName(normalized)) throw new Error(`Invalid logLevel "${value}": expected one of ${logLevels.join(", ")}`);
1321
+ if (!isLogLevelName(normalized)) throw new Error(`Invalid logLevel: expected one of ${logLevels.join(", ")}`);
1032
1322
  return normalized;
1033
1323
  };
1034
1324
  const normalizeClaudeUpstreamApi = (value) => {
@@ -1058,6 +1348,22 @@ const normalizeRequiredString = (value, key) => {
1058
1348
  if (normalized === void 0) throw new Error(`Invalid ${key}: expected a non-empty string`);
1059
1349
  return normalized;
1060
1350
  };
1351
+ const invalidApiKeyMessage = "Invalid apiKey: expected empty, or at least 16 visible ASCII characters";
1352
+ /**
1353
+ * Validates the optional inbound apiKey. Empty disables it.
1354
+ *
1355
+ * A set key is at least 16 visible ASCII characters. It travels in an HTTP
1356
+ * header, which cannot carry a space, a control byte or a non-ASCII character
1357
+ * reliably. Every logged copy of it is redacted as text, which would also hide
1358
+ * any ordinary text that a very short key happened to match.
1359
+ */
1360
+ const normalizeApiKey = (value) => {
1361
+ if (value === void 0) return;
1362
+ if (typeof value !== "string") throw new Error(invalidApiKeyMessage);
1363
+ const trimmed = value.trim();
1364
+ if (trimmed !== "" && !/^[\x21-\x7E]{16,}$/.test(trimmed)) throw new Error(invalidApiKeyMessage);
1365
+ return trimmed;
1366
+ };
1061
1367
  /**
1062
1368
  * A conventional http(s) URL: scheme followed by a literal "//" authority.
1063
1369
  *
@@ -1139,6 +1445,49 @@ const normalizeCopilotBaseUrl = (value) => {
1139
1445
  if (parsed.username || parsed.password) throw new Error(copilotBaseUrlCredentialsMessage);
1140
1446
  return trimmed;
1141
1447
  };
1448
+ /**
1449
+ * Fixed strings, like the copilotBaseUrl messages: a proxy URL can carry a password, and these
1450
+ * messages reach the terminal and the log, so the rejected value is never repeated.
1451
+ */
1452
+ const invalidUpstreamProxyMessage = "Invalid upstreamProxy: expected empty, env, or an absolute http(s) proxy URL such as http://proxy.example:3128";
1453
+ const upstreamProxyPathMessage = "Invalid upstreamProxy: a proxy URL takes no path, query or fragment";
1454
+ const upstreamProxyCredentialsMessage = "Invalid upstreamProxy: credentials need both a username and a password, with reserved characters percent-encoded";
1455
+ const canDecodeURIComponent = (value) => {
1456
+ try {
1457
+ return true;
1458
+ } catch {
1459
+ return false;
1460
+ }
1461
+ };
1462
+ /**
1463
+ * Validates upstreamProxy: empty, the literal "env", or an absolute http(s) proxy URL.
1464
+ *
1465
+ * Each rule stops a value that undici's ProxyAgent would turn into a confusing failure. A path,
1466
+ * query or fragment makes it throw "invalid url" when the relay builds its dispatcher. A username
1467
+ * without a password, or the reverse, makes it send no Proxy-Authorization at all. A malformed
1468
+ * percent-escape in the credentials makes its decodeURIComponent throw. The explicit "http://" or
1469
+ * "https://" prefix is required for the reason copilotBaseUrl requires it: the shorthand forms
1470
+ * WHATWG also accepts are not URLs anyone would recognize in a log line.
1471
+ *
1472
+ * Userinfo is accepted, unlike in copilotBaseUrl: it is how a proxy takes Basic credentials, and
1473
+ * configureUpstreamDispatcher registers it with the log redaction when it builds the dispatcher.
1474
+ *
1475
+ * The accepted value is the trimmed original, which is what the dispatcher is built from.
1476
+ */
1477
+ const normalizeUpstreamProxy = (value) => {
1478
+ const trimmed = normalizeString(value);
1479
+ if (trimmed === void 0 || trimmed === "env") return trimmed;
1480
+ if (!conventionalHttpUrlPattern.test(trimmed)) throw new Error(invalidUpstreamProxyMessage);
1481
+ let parsed;
1482
+ try {
1483
+ parsed = new URL(trimmed);
1484
+ } catch {
1485
+ throw new Error(invalidUpstreamProxyMessage);
1486
+ }
1487
+ if (parsed.pathname !== "/" || parsed.search || parsed.hash) throw new Error(upstreamProxyPathMessage);
1488
+ if (parsed.username !== "" !== (parsed.password !== "") || !canDecodeURIComponent(parsed.username) || !canDecodeURIComponent(parsed.password)) throw new Error(upstreamProxyCredentialsMessage);
1489
+ return trimmed;
1490
+ };
1142
1491
  var InvalidThinkEffortError = class extends Error {
1143
1492
  constructor() {
1144
1493
  super(`Invalid thinkEffort. Valid values: ${configurableReasoningEfforts.join(", ")}. "none" is not allowed as a configured default.`);
@@ -1179,6 +1528,7 @@ const readDefaultConfigTemplate = async () => {
1179
1528
  }
1180
1529
  };
1181
1530
  const configAliases = {
1531
+ api_key: "apiKey",
1182
1532
  claude_setup: "claudeSetup",
1183
1533
  copilot_base_url: "copilotBaseUrl",
1184
1534
  gpt_model: "gptModel",
@@ -1186,6 +1536,7 @@ const configAliases = {
1186
1536
  log_retention_days: "logRetentionDays",
1187
1537
  opus_model: "opusModel",
1188
1538
  think_effort: "thinkEffort",
1539
+ upstream_proxy: "upstreamProxy",
1189
1540
  upstream_timeout_seconds: "upstreamTimeoutSeconds",
1190
1541
  web_search_backend: "webSearchBackend",
1191
1542
  claude_upstream_api: "claudeUpstreamApi"
@@ -1237,7 +1588,7 @@ const serializeConfig = (config) => [
1237
1588
  "# copilot-relay configuration",
1238
1589
  "#",
1239
1590
  "# Valid complete edits hot-reload without rewriting this file.",
1240
- "# host, port, and claudeSetup require restart.",
1591
+ "# host, port, claudeSetup, and upstreamProxy require restart.",
1241
1592
  "",
1242
1593
  "# Local host for the Claude Code-compatible HTTP server.",
1243
1594
  `host: ${config.host}`,
@@ -1245,9 +1596,17 @@ const serializeConfig = (config) => [
1245
1596
  "# Local port for the HTTP server.",
1246
1597
  `port: ${config.port}`,
1247
1598
  "",
1599
+ "# Key clients must send as x-api-key or Authorization: Bearer; empty disables it.",
1600
+ "# Set one before binding host beyond loopback.",
1601
+ `apiKey: ${config.apiKey}`,
1602
+ "",
1248
1603
  "# GitHub Copilot API base URL.",
1249
1604
  `copilotBaseUrl: ${config.copilotBaseUrl}`,
1250
1605
  "",
1606
+ "# Outbound proxy for Copilot and GitHub calls. Empty connects directly; env uses",
1607
+ "# HTTPS_PROXY, HTTP_PROXY and NO_PROXY; or a URL such as http://proxy.example:3128.",
1608
+ `upstreamProxy: ${config.upstreamProxy ?? ""}`,
1609
+ "",
1251
1610
  "# Update ~/.claude/settings.json on start.",
1252
1611
  `claudeSetup: ${config.claudeSetup}`,
1253
1612
  "",
@@ -1282,6 +1641,7 @@ const serializeConfig = (config) => [
1282
1641
  ""
1283
1642
  ].join("\n");
1284
1643
  const resolveConfig = (raw) => ({
1644
+ apiKey: normalizeApiKey(raw.apiKey) ?? defaultConfig.apiKey,
1285
1645
  claudeSetup: normalizeBoolean(raw.claudeSetup) ?? defaultConfig.claudeSetup,
1286
1646
  copilotBaseUrl: normalizeCopilotBaseUrl(normalizeRequiredString(raw.copilotBaseUrl, "copilotBaseUrl")) ?? defaultConfig.copilotBaseUrl,
1287
1647
  gptModel: normalizeRequiredString(raw.gptModel, "gptModel") ?? defaultConfig.gptModel,
@@ -1293,6 +1653,7 @@ const resolveConfig = (raw) => ({
1293
1653
  thinkEffort: normalizeThinkEffort(raw.thinkEffort) ?? defaultConfig.thinkEffort,
1294
1654
  upstreamTimeoutSeconds: normalizeUpstreamTimeoutSeconds(raw.upstreamTimeoutSeconds) ?? defaultConfig.upstreamTimeoutSeconds,
1295
1655
  webSearchBackend: normalizeString(raw.webSearchBackend),
1656
+ upstreamProxy: normalizeUpstreamProxy(raw.upstreamProxy),
1296
1657
  claudeUpstreamApi: normalizeClaudeUpstreamApi(raw.claudeUpstreamApi) ?? defaultConfig.claudeUpstreamApi
1297
1658
  });
1298
1659
  async function readAppConfig() {
@@ -1304,26 +1665,64 @@ async function readAppConfig() {
1304
1665
  if (content !== snapshot.raw) await writeFileSnapshot(snapshot, content);
1305
1666
  return config;
1306
1667
  }
1668
+ /**
1669
+ * Reads and resolves config.yaml without creating, completing or rewriting it, for a command that
1670
+ * writes no file, such as `usage`. Returns undefined when there is no config.yaml; unlike
1671
+ * readAppConfig, it consults neither a legacy config file nor the default template. An invalid
1672
+ * file throws the error readAppConfig would.
1673
+ */
1674
+ const readExistingAppConfig = async () => {
1675
+ const snapshot = await readFileSnapshot(paths.configPath);
1676
+ if (snapshot.raw === null) return;
1677
+ return resolveConfig(parseConfigYaml(snapshot.raw));
1678
+ };
1307
1679
  const sameSnapshot = (left, right) => left.resolvedPath === right.resolvedPath && left.raw === right.raw && left.mode === right.mode && JSON.stringify(left.identity) === JSON.stringify(right.identity);
1680
+ const isSameFailure = (reported, reason, snapshot) => {
1681
+ if (!reported || reported.reason !== reason) return false;
1682
+ if (!reported.snapshot || !snapshot) return reported.snapshot === snapshot;
1683
+ return sameSnapshot(reported.snapshot, snapshot);
1684
+ };
1685
+ const readConfigSnapshot = async () => {
1686
+ try {
1687
+ return await readFileSnapshot(paths.configPath);
1688
+ } catch (error) {
1689
+ if (error instanceof FileConflictError) return;
1690
+ throw error;
1691
+ }
1692
+ };
1308
1693
  const watchAppConfig = (onReload) => {
1309
1694
  let lastSnapshot;
1695
+ let reportedFailure;
1310
1696
  let reloading = false;
1311
1697
  const timer = setInterval(async () => {
1312
1698
  if (reloading) return;
1313
1699
  reloading = true;
1700
+ let snapshot;
1314
1701
  try {
1315
- const snapshot = await readFileSnapshot(paths.configPath);
1316
- 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
+ }
1317
1708
  if (snapshot.raw === null) throw new Error("Config file is missing");
1318
1709
  const raw = parseConfigYaml(snapshot.raw);
1319
1710
  if (configKeys.some((key) => !Object.hasOwn(raw, key))) throw new Error("Config reload requires every materialized key");
1320
1711
  const config = resolveConfig(raw);
1321
- if (!sameSnapshot(snapshot, await readFileSnapshot(paths.configPath))) throw new FileConflictError();
1712
+ const current = await readConfigSnapshot();
1713
+ if (!current || !sameSnapshot(snapshot, current)) return;
1322
1714
  onReload(config);
1323
1715
  lastSnapshot = snapshot;
1716
+ reportedFailure = void 0;
1324
1717
  } catch (error) {
1325
- if (error instanceof InvalidThinkEffortError) log.error(`${error.message} Keeping the previous runtime settings.`);
1326
- 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.`));
1327
1726
  } finally {
1328
1727
  reloading = false;
1329
1728
  }
@@ -1362,7 +1761,8 @@ const readProxyConfig = (config) => ({
1362
1761
  upstreamTimeoutMs: config.upstreamTimeoutSeconds * 1e3,
1363
1762
  vsCodeVersion: vscodeVersion,
1364
1763
  webSearchBackend: config.webSearchBackend,
1365
- claudeUpstreamApi: config.claudeUpstreamApi
1764
+ claudeUpstreamApi: config.claudeUpstreamApi,
1765
+ apiKey: config.apiKey
1366
1766
  });
1367
1767
 
1368
1768
  //#endregion
@@ -1373,7 +1773,9 @@ const auth = defineCommand({
1373
1773
  description: "Run GitHub device auth and cache credentials for copilot-relay."
1374
1774
  },
1375
1775
  async run() {
1376
- const config = readProxyConfig(await readAppConfig());
1776
+ const appConfig = await readAppConfig();
1777
+ configureUpstreamDispatcher(appConfig.upstreamProxy);
1778
+ const config = readProxyConfig(appConfig);
1377
1779
  config.copilotToken = void 0;
1378
1780
  const authSession = await setupProxyAuth(config, { force: true });
1379
1781
  if (authSession.githubLogin) log.info(`Logged in as ${authSession.githubLogin}`);
@@ -1381,6 +1783,499 @@ const auth = defineCommand({
1381
1783
  }
1382
1784
  });
1383
1785
 
1786
+ //#endregion
1787
+ //#region src/lib/cache-report.ts
1788
+ const completionRoutes = [
1789
+ "/chat/completions",
1790
+ "/responses",
1791
+ "/v1/messages"
1792
+ ];
1793
+ /** A flag the command cannot use. The message is shown to the user as it is. */
1794
+ var CacheUsageError = class extends Error {};
1795
+ const defaultGoal = 95;
1796
+ const timestampPattern = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
1797
+ const fieldPattern = /^([A-Za-z0-9_]+)=(\S+)$/;
1798
+ const countPattern = /^\d+$/;
1799
+ const countFields = [
1800
+ "input_tokens",
1801
+ "cache_read_input_tokens",
1802
+ "cache_creation_input_tokens"
1803
+ ];
1804
+ const isCompletionRoute = (value) => completionRoutes.some((route) => route === value);
1805
+ const parseTimestamp = (text$1) => {
1806
+ if (!timestampPattern.test(text$1)) return;
1807
+ const date = new Date(text$1);
1808
+ if (Number.isNaN(date.getTime()) || date.toISOString() !== text$1) return;
1809
+ return date;
1810
+ };
1811
+ const isCount = (value) => value !== void 0 && countPattern.test(value) && Number.isSafeInteger(Number(value));
1812
+ const countField = (fields, key) => {
1813
+ const value = fields.get(key);
1814
+ return value === void 0 ? void 0 : Number(value);
1815
+ };
1816
+ /**
1817
+ * The completion entry on a log line, or undefined for any other line.
1818
+ *
1819
+ * Only a successful call is returned: an info entry for HTTP 200 on one of the three routes, with
1820
+ * a numeric input_tokens. A line that is not exactly what the relay writes is skipped rather than
1821
+ * guessed at, since one bad count would skew a whole row.
1822
+ */
1823
+ const parseCompletionLine = (line) => {
1824
+ if (!line.includes(" completion path=")) return;
1825
+ const tokens = line.split(" ");
1826
+ const timestamp = parseTimestamp(tokens[0]);
1827
+ const marker = tokens.indexOf("completion", 2);
1828
+ if (timestamp === void 0 || tokens[1] !== "info" || marker === -1) return;
1829
+ if (!tokens.slice(2, marker).every((token) => fieldPattern.test(token))) return;
1830
+ const fields = /* @__PURE__ */ new Map();
1831
+ for (const token of tokens.slice(marker + 1)) {
1832
+ const field = fieldPattern.exec(token);
1833
+ if (field === null || fields.has(field[1])) return;
1834
+ fields.set(field[1], field[2]);
1835
+ }
1836
+ if (countFields.some((key) => fields.has(key) && !isCount(fields.get(key)))) return;
1837
+ const route = fields.get("path");
1838
+ const inputTokens = countField(fields, "input_tokens");
1839
+ const cacheReadTokens = countField(fields, "cache_read_input_tokens");
1840
+ const cacheWriteTokens = countField(fields, "cache_creation_input_tokens");
1841
+ if (!isCompletionRoute(route) || fields.get("http_status") !== "200" || inputTokens === void 0) return;
1842
+ if (route !== "/v1/messages" && cacheReadTokens !== void 0 && cacheReadTokens > inputTokens) return;
1843
+ return {
1844
+ timestamp,
1845
+ model: terminalText(fields.get("model") ?? "") || "unknown",
1846
+ route,
1847
+ inputTokens,
1848
+ ...cacheReadTokens !== void 0 && { cacheReadTokens },
1849
+ ...cacheWriteTokens !== void 0 && { cacheWriteTokens }
1850
+ };
1851
+ };
1852
+ const datedLogFilePattern = /* @__PURE__ */ new RegExp(`^${paths.logFileBaseName}\\.(\\d{4})-(\\d{2})-(\\d{2})\\.log$`);
1853
+ const dayNumber = (year, month, day) => year * 1e4 + month * 100 + day;
1854
+ const maxLineLength = 1024 * 1024;
1855
+ const isMissing = (error) => error.code === "ENOENT";
1856
+ const readLogDirectory = async () => {
1857
+ try {
1858
+ return await fs.readdir(paths.logsDir, { withFileTypes: true });
1859
+ } catch (error) {
1860
+ if (isMissing(error)) return [];
1861
+ throw error;
1862
+ }
1863
+ };
1864
+ /**
1865
+ * The dated log files that can hold entries at or after `since`; all of them without it.
1866
+ *
1867
+ * A file is named for the local date its entries were written on. UTC offsets span 26 hours, from
1868
+ * UTC-12 to UTC+14, so after a time-zone change an entry can sit in a file dated up to two days
1869
+ * before the date the current zone gives `since`. Those days are read too, and every entry is then
1870
+ * checked against `since` itself.
1871
+ */
1872
+ const listLogFiles = async (since) => {
1873
+ let earliestDay = Number.NEGATIVE_INFINITY;
1874
+ if (since !== void 0) {
1875
+ const twoDaysBefore = new Date(since.getTime());
1876
+ twoDaysBefore.setDate(twoDaysBefore.getDate() - 2);
1877
+ if (!Number.isNaN(twoDaysBefore.getTime())) earliestDay = dayNumber(twoDaysBefore.getFullYear(), twoDaysBefore.getMonth() + 1, twoDaysBefore.getDate());
1878
+ }
1879
+ return (await readLogDirectory()).filter((entry) => {
1880
+ const date = datedLogFilePattern.exec(entry.name);
1881
+ if (!entry.isFile() || date === null) return false;
1882
+ return dayNumber(Number(date[1]), Number(date[2]), Number(date[3])) >= earliestDay;
1883
+ }).map((entry) => path.join(paths.logsDir, entry.name));
1884
+ };
1885
+ const withoutCarriageReturn = (line) => line.endsWith("\r") ? line.slice(0, -1) : line;
1886
+ /**
1887
+ * The complete lines of a file. A last line without its line break is dropped: the relay may still
1888
+ * be appending it, and half an entry can hold a cut-off count that still parses as a number.
1889
+ */
1890
+ async function* readLines(filePath) {
1891
+ const stream = createReadStream(filePath, { encoding: "utf8" });
1892
+ let partial = "";
1893
+ let oversized = false;
1894
+ for await (const chunk of stream) {
1895
+ const pieces = chunk.split("\n");
1896
+ const unfinished = pieces.pop() ?? "";
1897
+ for (const piece of pieces) {
1898
+ if (!oversized) yield withoutCarriageReturn(partial + piece);
1899
+ partial = "";
1900
+ oversized = false;
1901
+ }
1902
+ if (!oversized) partial += unfinished;
1903
+ if (partial.length > maxLineLength) {
1904
+ partial = "";
1905
+ oversized = true;
1906
+ }
1907
+ }
1908
+ }
1909
+ const isInWindow = (record$3, since) => since === void 0 || record$3.timestamp.getTime() >= since.getTime();
1910
+ async function* readCompletionRecords(since) {
1911
+ for (const filePath of await listLogFiles(since)) try {
1912
+ for await (const line of readLines(filePath)) {
1913
+ const record$3 = parseCompletionLine(line);
1914
+ if (record$3 !== void 0 && isInWindow(record$3, since)) yield record$3;
1915
+ }
1916
+ } catch (error) {
1917
+ if (!isMissing(error)) throw error;
1918
+ }
1919
+ }
1920
+ const emptyTotals = (bucket, record$3) => ({
1921
+ bucket: bucket.label,
1922
+ ...bucket.utcOffset !== void 0 && { utcOffset: bucket.utcOffset },
1923
+ firstCall: record$3.timestamp.getTime(),
1924
+ model: record$3.model,
1925
+ route: record$3.route,
1926
+ requests: 0,
1927
+ unknownCacheRequests: 0,
1928
+ zeroCacheReadRequests: 0,
1929
+ totalInputTokens: 0,
1930
+ cacheReadTokens: 0,
1931
+ cacheWriteTokens: null
1932
+ });
1933
+ const addRecord = (totals, record$3) => {
1934
+ totals.requests += 1;
1935
+ totals.firstCall = Math.min(totals.firstCall, record$3.timestamp.getTime());
1936
+ if (record$3.cacheReadTokens === void 0) {
1937
+ totals.unknownCacheRequests += 1;
1938
+ return;
1939
+ }
1940
+ const totalInput = record$3.route === "/v1/messages" ? record$3.inputTokens + record$3.cacheReadTokens + (record$3.cacheWriteTokens ?? 0) : record$3.inputTokens;
1941
+ totals.totalInputTokens += totalInput;
1942
+ totals.cacheReadTokens += record$3.cacheReadTokens;
1943
+ if (record$3.cacheReadTokens === 0) totals.zeroCacheReadRequests += 1;
1944
+ if (record$3.cacheWriteTokens !== void 0) totals.cacheWriteTokens = (totals.cacheWriteTokens ?? 0) + record$3.cacheWriteTokens;
1945
+ };
1946
+ const hitBasisPoints = (cacheReadTokens, totalInputTokens) => Number(BigInt(cacheReadTokens) * 10000n / BigInt(totalInputTokens));
1947
+ const toRow = (totals, goal) => {
1948
+ const hasInput = totals.totalInputTokens > 0;
1949
+ return {
1950
+ bucket: totals.bucket,
1951
+ model: totals.model,
1952
+ route: totals.route,
1953
+ requests: totals.requests,
1954
+ unknownCacheRequests: totals.unknownCacheRequests,
1955
+ zeroCacheReadRequests: totals.zeroCacheReadRequests,
1956
+ totalInputTokens: totals.totalInputTokens,
1957
+ cacheReadTokens: totals.cacheReadTokens,
1958
+ uncachedInputTokens: totals.totalInputTokens - totals.cacheReadTokens,
1959
+ cacheWriteTokens: totals.cacheWriteTokens,
1960
+ hitRate: hasInput ? totals.cacheReadTokens / totals.totalInputTokens : null,
1961
+ belowGoal: hasInput && hitBasisPoints(totals.cacheReadTokens, totals.totalInputTokens) < Math.round(goal * 100)
1962
+ };
1963
+ };
1964
+ const twoDigits = (value) => String(value).padStart(2, "0");
1965
+ const formatUtcOffset = (timestamp) => {
1966
+ const minutes = -timestamp.getTimezoneOffset();
1967
+ const sign = minutes < 0 ? "-" : "+";
1968
+ const absolute = Math.abs(minutes);
1969
+ return `UTC${sign}${twoDigits(Math.floor(absolute / 60))}:${twoDigits(absolute % 60)}`;
1970
+ };
1971
+ const bucketOf = (timestamp, view) => {
1972
+ if (view === "daily") return { label: formatLogDate(timestamp) };
1973
+ if (view === "hourly") return {
1974
+ label: `${formatLogDate(timestamp)} ${twoDigits(timestamp.getHours())}:00`,
1975
+ utcOffset: formatUtcOffset(timestamp)
1976
+ };
1977
+ return { label: null };
1978
+ };
1979
+ const compareText = (left, right) => {
1980
+ if (left < right) return -1;
1981
+ return left > right ? 1 : 0;
1982
+ };
1983
+ const segmentOf = (totals) => JSON.stringify([totals.bucket, totals.utcOffset ?? null]);
1984
+ const sortTotals = (groups) => {
1985
+ const firstCalls = /* @__PURE__ */ new Map();
1986
+ for (const totals of groups) {
1987
+ const segment = segmentOf(totals);
1988
+ firstCalls.set(segment, Math.min(firstCalls.get(segment) ?? totals.firstCall, totals.firstCall));
1989
+ }
1990
+ const firstCallOf = (totals) => firstCalls.get(segmentOf(totals)) ?? totals.firstCall;
1991
+ return [...groups].sort((left, right) => firstCallOf(left) - firstCallOf(right) || compareText(left.model, right.model) || compareText(left.route, right.route));
1992
+ };
1993
+ const labelRepeatedHours = (sorted) => {
1994
+ const offsetsByLabel = /* @__PURE__ */ new Map();
1995
+ for (const totals of sorted) if (totals.utcOffset !== void 0 && totals.bucket !== null) {
1996
+ const offsets = offsetsByLabel.get(totals.bucket) ?? /* @__PURE__ */ new Set();
1997
+ offsets.add(totals.utcOffset);
1998
+ offsetsByLabel.set(totals.bucket, offsets);
1999
+ }
2000
+ for (const totals of sorted) {
2001
+ const offsets = totals.bucket === null ? void 0 : offsetsByLabel.get(totals.bucket);
2002
+ if (totals.utcOffset !== void 0 && offsets !== void 0 && offsets.size > 1) totals.bucket = `${totals.bucket} ${totals.utcOffset}`;
2003
+ }
2004
+ };
2005
+ /** Reads the retained logs and returns one row per bucket, model and route. */
2006
+ const buildCacheReport = async (options) => {
2007
+ parseGoal(String(options.goal));
2008
+ const modelFilter = options.model?.toLowerCase();
2009
+ const groups = /* @__PURE__ */ new Map();
2010
+ for await (const record$3 of readCompletionRecords(options.since)) {
2011
+ if (modelFilter !== void 0 && !record$3.model.toLowerCase().includes(modelFilter)) continue;
2012
+ const bucket = bucketOf(record$3.timestamp, options.view);
2013
+ const key = JSON.stringify([
2014
+ bucket.label,
2015
+ bucket.utcOffset ?? null,
2016
+ record$3.model,
2017
+ record$3.route
2018
+ ]);
2019
+ let totals = groups.get(key);
2020
+ if (totals === void 0) {
2021
+ totals = emptyTotals(bucket, record$3);
2022
+ groups.set(key, totals);
2023
+ }
2024
+ addRecord(totals, record$3);
2025
+ }
2026
+ const sorted = sortTotals([...groups.values()]);
2027
+ labelRepeatedHours(sorted);
2028
+ return sorted.map((totals) => toRow(totals, options.goal));
2029
+ };
2030
+ const minuteMs = 60 * 1e3;
2031
+ const hourMs = 60 * minuteMs;
2032
+ const dayMs = 24 * hourMs;
2033
+ const durationUnits = {
2034
+ m: minuteMs,
2035
+ h: hourMs,
2036
+ d: dayMs
2037
+ };
2038
+ const durationPattern = /^(\d+)([mhd])$/i;
2039
+ const isoTimePattern = /^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2})(?:\.\d+)?)?(?:Z|[+-]\d{2}:\d{2})?)?$/i;
2040
+ const sinceUsage = "--since needs a duration such as 30m, 6h or 2d, or an ISO date or time such as 2026-10-03 or 2026-10-03T09:00.";
2041
+ const goalPattern = /^(\d+(?:\.\d{1,2})?)%?$/;
2042
+ const goalUsage = "--goal needs a percentage from 0 to 100 with at most two decimals, such as --goal 95 or --goal 97.5.";
2043
+ const isCalendarDate = (year, month, day) => {
2044
+ const date = new Date(year, month - 1, day);
2045
+ return date.getFullYear() === year && date.getMonth() === month - 1 && date.getDate() === day;
2046
+ };
2047
+ const parseIsoTime = (text$1) => {
2048
+ const match = isoTimePattern.exec(text$1);
2049
+ if (match === null) return;
2050
+ const [, year, month, day, hour, minute, second = "00"] = match;
2051
+ if (!isCalendarDate(Number(year), Number(month), Number(day))) return;
2052
+ if (hour === void 0) return new Date(Number(year), Number(month) - 1, Number(day));
2053
+ if (Number(hour) > 23 || Number(minute) > 59 || Number(second) > 59) return;
2054
+ const time = Date.parse(text$1.toUpperCase().replace(" ", "T"));
2055
+ return Number.isNaN(time) ? void 0 : new Date(time);
2056
+ };
2057
+ /**
2058
+ * The start of the window for --since: a duration counted back from `now`, or an ISO date or
2059
+ * time. A date, or a time without Z or an offset, is local, like every other time in the report.
2060
+ */
2061
+ const parseSince = (value, now) => {
2062
+ const text$1 = value.trim();
2063
+ const duration = durationPattern.exec(text$1);
2064
+ if (duration !== null) {
2065
+ const unit = durationUnits[duration[2].toLowerCase()];
2066
+ const start$1 = new Date(now.getTime() - Number(duration[1]) * unit);
2067
+ if (Number.isNaN(start$1.getTime())) throw new CacheUsageError(sinceUsage);
2068
+ return start$1;
2069
+ }
2070
+ const time = parseIsoTime(text$1);
2071
+ if (time === void 0) throw new CacheUsageError(sinceUsage);
2072
+ return time;
2073
+ };
2074
+ /** The hit-rate goal for --goal, in percent with at most two decimals, with or without a trailing "%". */
2075
+ const parseGoal = (value) => {
2076
+ const match = goalPattern.exec(value.trim());
2077
+ if (match === null || Number(match[1]) > 100) throw new CacheUsageError(goalUsage);
2078
+ return Number(match[1]);
2079
+ };
2080
+ const singleValue = (value, flag) => {
2081
+ if (value === void 0) return;
2082
+ if (typeof value !== "string") throw new CacheUsageError(`Give ${flag} once.`);
2083
+ return value;
2084
+ };
2085
+ /**
2086
+ * The report options for the command's flags.
2087
+ *
2088
+ * The summary and the hourly trend cover the last 24 hours by default, and the daily trend every
2089
+ * retained day. --since replaces either default.
2090
+ */
2091
+ const resolveCacheOptions = (args, now) => {
2092
+ if (args.hourly && args.daily) throw new CacheUsageError("Choose one of --hourly and --daily.");
2093
+ let view = "summary";
2094
+ if (args.hourly) view = "hourly";
2095
+ else if (args.daily) view = "daily";
2096
+ const sinceText = singleValue(args.since, "--since");
2097
+ const goalText = singleValue(args.goal, "--goal");
2098
+ const model = singleValue(args.model, "--model")?.trim();
2099
+ let since;
2100
+ if (sinceText !== void 0) since = parseSince(sinceText, now);
2101
+ else if (view !== "daily") since = new Date(now.getTime() - dayMs);
2102
+ if (model === "") throw new CacheUsageError("--model needs part of a model name, such as --model opus.");
2103
+ return {
2104
+ view,
2105
+ goal: goalText === void 0 ? defaultGoal : parseGoal(goalText),
2106
+ ...since !== void 0 && { since },
2107
+ ...model !== void 0 && { model }
2108
+ };
2109
+ };
2110
+ const formatCount = (value) => value.toLocaleString("en-US");
2111
+ const formatHitRate = (row) => {
2112
+ if (row.hitRate === null) return "-";
2113
+ const basisPoints = hitBasisPoints(row.cacheReadTokens, row.totalInputTokens);
2114
+ return `${Math.floor(basisPoints / 100)}.${twoDigits(basisPoints % 100)}%`;
2115
+ };
2116
+ const hitRateTone = (row) => {
2117
+ if (row.hitRate === null) return "muted";
2118
+ return row.belowGoal ? "bad" : "good";
2119
+ };
2120
+ const bucketColumns = {
2121
+ summary: [],
2122
+ hourly: [{
2123
+ header: "HOUR",
2124
+ alignRight: false,
2125
+ cell: (row) => row.bucket ?? ""
2126
+ }],
2127
+ daily: [{
2128
+ header: "DAY",
2129
+ alignRight: false,
2130
+ cell: (row) => row.bucket ?? ""
2131
+ }]
2132
+ };
2133
+ const dataColumns = [
2134
+ {
2135
+ header: "MODEL",
2136
+ alignRight: false,
2137
+ cell: (row) => row.model
2138
+ },
2139
+ {
2140
+ header: "ROUTE",
2141
+ alignRight: false,
2142
+ cell: (row) => row.route
2143
+ },
2144
+ {
2145
+ header: "REQUESTS",
2146
+ alignRight: true,
2147
+ cell: (row) => formatCount(row.requests)
2148
+ },
2149
+ {
2150
+ header: "UNKNOWN",
2151
+ alignRight: true,
2152
+ cell: (row) => formatCount(row.unknownCacheRequests)
2153
+ },
2154
+ {
2155
+ header: "0-READ",
2156
+ alignRight: true,
2157
+ cell: (row) => formatCount(row.zeroCacheReadRequests)
2158
+ },
2159
+ {
2160
+ header: "INPUT",
2161
+ alignRight: true,
2162
+ cell: (row) => formatCount(row.totalInputTokens)
2163
+ },
2164
+ {
2165
+ header: "CACHE READ",
2166
+ alignRight: true,
2167
+ cell: (row) => formatCount(row.cacheReadTokens)
2168
+ },
2169
+ {
2170
+ header: "UNCACHED",
2171
+ alignRight: true,
2172
+ cell: (row) => formatCount(row.uncachedInputTokens)
2173
+ },
2174
+ {
2175
+ header: "CACHE WRITE",
2176
+ alignRight: true,
2177
+ cell: (row) => row.cacheWriteTokens === null ? "-" : formatCount(row.cacheWriteTokens)
2178
+ },
2179
+ {
2180
+ header: "HIT RATE",
2181
+ alignRight: true,
2182
+ cell: formatHitRate,
2183
+ tone: hitRateTone
2184
+ }
2185
+ ];
2186
+ const renderTable = (rows, view, color) => {
2187
+ const columns = [...bucketColumns[view], ...dataColumns];
2188
+ const cells = rows.map((row) => columns.map((column) => column.cell(row)));
2189
+ const widths = columns.map((column, index) => Math.max(column.header.length, ...cells.map((rowCells) => rowCells[index].length)));
2190
+ const pad = (text$1, index) => columns[index].alignRight ? text$1.padStart(widths[index]) : text$1.padEnd(widths[index]);
2191
+ const header = columns.map((column, index) => pad(column.header, index)).join(" ");
2192
+ const body = rows.map((row, rowIndex) => {
2193
+ const rendered = columns.map((column, index) => {
2194
+ const text$1 = pad(cells[rowIndex][index], index);
2195
+ return column.tone === void 0 ? text$1 : colorText(text$1, column.tone(row), color);
2196
+ });
2197
+ const marker = row.belowGoal ? ` ${colorText("below goal", "bad", color)}` : "";
2198
+ return ` ${rendered.join(" ")}${marker}`;
2199
+ });
2200
+ return [` ${header}`, ...body];
2201
+ };
2202
+ const viewTitles = {
2203
+ summary: "Prompt-cache hit rate",
2204
+ hourly: "Prompt-cache hit rate by local hour",
2205
+ daily: "Prompt-cache hit rate by local day"
2206
+ };
2207
+ const formatLocalTime = (date) => `${formatLogDate(date)} ${twoDigits(date.getHours())}:${twoDigits(date.getMinutes())}`;
2208
+ const describeScope = (options) => {
2209
+ return `${options.since === void 0 ? "across all retained logs" : `since ${formatLocalTime(options.since)} local time`}${options.model === void 0 ? "" : `, for models matching "${terminalText(options.model)}"`}`;
2210
+ };
2211
+ const unknownNote = "UNKNOWN calls logged no cache_read_input_tokens; they are left out of the token columns and HIT RATE.";
2212
+ /** The report as text lines. Kept free of IO, so it is tested without a terminal. */
2213
+ const renderCacheReport = (rows, options, color = false) => {
2214
+ if (rows.length === 0) return [
2215
+ `No prompt-cache data ${describeScope(options)}.`,
2216
+ `The report reads the upstream completion entries in ${paths.logsDir}.`,
2217
+ "They are written at logLevel info or debug, not error, and kept for logRetentionDays."
2218
+ ];
2219
+ const lines = [
2220
+ `${viewTitles[options.view]} ${describeScope(options)}, goal ${options.goal}%`,
2221
+ "",
2222
+ ...renderTable(rows, options.view, color)
2223
+ ];
2224
+ if (rows.some((row) => row.unknownCacheRequests > 0)) lines.push("", unknownNote);
2225
+ return lines;
2226
+ };
2227
+
2228
+ //#endregion
2229
+ //#region src/cache.ts
2230
+ const cache = defineCommand({
2231
+ meta: {
2232
+ name: "cache",
2233
+ description: "Show prompt-cache hit rates per model and upstream route, read from local logs."
2234
+ },
2235
+ args: {
2236
+ hourly: {
2237
+ description: "Show the trend by local hour. Covers the last 24 hours unless --since is given.",
2238
+ type: "boolean"
2239
+ },
2240
+ daily: {
2241
+ description: "Show the trend by local day. Covers every retained day unless --since is given.",
2242
+ type: "boolean"
2243
+ },
2244
+ since: {
2245
+ description: "Start of the window: a duration such as 6h or 2d, or an ISO date or time. Without it, the summary covers the last 24 hours.",
2246
+ type: "string"
2247
+ },
2248
+ model: {
2249
+ description: "Only count models whose name contains this text, ignoring case.",
2250
+ type: "string"
2251
+ },
2252
+ json: {
2253
+ description: "Print the rows as a JSON array.",
2254
+ type: "boolean"
2255
+ },
2256
+ goal: {
2257
+ default: String(defaultGoal),
2258
+ description: "Hit-rate goal in percent, with at most two decimals. Rows below it are flagged.",
2259
+ type: "string"
2260
+ }
2261
+ },
2262
+ async run({ args }) {
2263
+ try {
2264
+ const options = resolveCacheOptions(args, /* @__PURE__ */ new Date());
2265
+ const rows = await buildCacheReport(options);
2266
+ if (args.json) console.log(JSON.stringify(rows, null, 2));
2267
+ else console.log(renderCacheReport(rows, options, colorEnabled()).join("\n"));
2268
+ } catch (error) {
2269
+ if (error instanceof CacheUsageError) console.error(error.message);
2270
+ else {
2271
+ const detail = error instanceof Error ? error.message : String(error);
2272
+ console.error(terminalText(`Could not build the cache report from ${paths.logsDir}: ${detail}`));
2273
+ }
2274
+ process.exitCode = 1;
2275
+ }
2276
+ }
2277
+ });
2278
+
1384
2279
  //#endregion
1385
2280
  //#region src/lib/version.ts
1386
2281
  const packageName = /^(?:@[a-z0-9][a-z0-9._-]*\/)?copilot-relay$/;
@@ -1549,13 +2444,13 @@ var OutcomeObserver = class {
1549
2444
  const incomplete = this.metadataValue(value.incomplete_details?.reason);
1550
2445
  if (incomplete) this.fields.incomplete_reason = ["max_output_tokens", "content_filter"].includes(incomplete) ? incomplete : "unknown";
1551
2446
  if (payload.type === "error" || record$2(payload.error)) this.fields.error = "upstream_error";
1552
- const usage = findUsage(payload, value);
1553
- if (usage) {
2447
+ const usage$1 = findUsage(payload, value);
2448
+ if (usage$1) {
1554
2449
  for (const [target, input] of [
1555
- ["input_tokens", usage.input_tokens ?? usage.prompt_tokens],
1556
- ["output_tokens", usage.output_tokens ?? usage.completion_tokens],
1557
- ["cache_read_input_tokens", usage.cache_read_input_tokens ?? usage.prompt_tokens_details?.cached_tokens ?? usage.input_tokens_details?.cached_tokens],
1558
- ["cache_creation_input_tokens", usage.cache_creation_input_tokens]
2450
+ ["input_tokens", usage$1.input_tokens ?? usage$1.prompt_tokens],
2451
+ ["output_tokens", usage$1.output_tokens ?? usage$1.completion_tokens],
2452
+ ["cache_read_input_tokens", usage$1.cache_read_input_tokens ?? usage$1.prompt_tokens_details?.cached_tokens ?? usage$1.input_tokens_details?.cached_tokens],
2453
+ ["cache_creation_input_tokens", usage$1.cache_creation_input_tokens]
1559
2454
  ]) if (typeof input === "number" && Number.isSafeInteger(input) && input >= 0) this.fields[target] = input;
1560
2455
  }
1561
2456
  }
@@ -1771,6 +2666,7 @@ var RequestTrace = class RequestTrace {
1771
2666
  constructor(requestId, request, config, runtime) {
1772
2667
  this.requestId = requestId;
1773
2668
  this.protectCredential(config.copilotToken);
2669
+ this.protectCredential(config.apiKey);
1774
2670
  this.protectCredential(request.headers.get("authorization")?.replace(/^Bearer\s+/i, ""));
1775
2671
  this.protectCredential(request.headers.get("x-api-key") ?? void 0);
1776
2672
  this.signal = AbortSignal.any([request.signal, this.controller.signal]);
@@ -2233,11 +3129,6 @@ const userAgent = `GitHubCopilotChat/${copilotVersion}`;
2233
3129
  const apiVersion = "2025-04-01";
2234
3130
  const maxFetchAttempts = 2;
2235
3131
  const copilotRequestTimeoutMs = 18e4;
2236
- const copilotDispatcher = new Agent({
2237
- allowH2: false,
2238
- connect: { allowH2: false },
2239
- keepAliveTimeout: 5e4
2240
- });
2241
3132
  const getCopilotProviderContext = (config) => ({
2242
3133
  baseUrl: config.copilotBaseUrl,
2243
3134
  get token() {
@@ -2277,6 +3168,33 @@ const toCopilotAbortHTTPError = (error, signal, timeoutMs = copilotRequestTimeou
2277
3168
  headers: { "content-type": "application/json" }
2278
3169
  }), message);
2279
3170
  };
3171
+ const copilotPromptTooLongPattern = /^prompt token count of (\d+) exceeds the limit of (\d+)$/;
3172
+ const anthropicPromptTooLongPattern = /^prompt is too long: (\d+) tokens > (\d+) maximum$/;
3173
+ const parseJsonBody = (body) => {
3174
+ try {
3175
+ return JSON.parse(body);
3176
+ } catch {
3177
+ return;
3178
+ }
3179
+ };
3180
+ const isJsonObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
3181
+ const parseTokenCount = (digits) => {
3182
+ const count = Number(digits);
3183
+ return Number.isSafeInteger(count) ? count : void 0;
3184
+ };
3185
+ const toPromptTooLongError = (status$1, body, detail) => {
3186
+ if (status$1 !== 400) return;
3187
+ const payload = parseJsonBody(body);
3188
+ const upstreamError = isJsonObject(payload) ? payload.error : void 0;
3189
+ if (!isJsonObject(payload) || !isJsonObject(upstreamError)) return;
3190
+ const { code, message, type: errorType } = upstreamError;
3191
+ const upstreamMessage = typeof message === "string" ? message : "";
3192
+ const isCopilotOverflow = code === "model_max_prompt_tokens_exceeded";
3193
+ const isAnthropicOverflow = payload.type === "error" && errorType === "invalid_request_error" && upstreamMessage.startsWith("prompt is too long");
3194
+ if (!isCopilotOverflow && !isAnthropicOverflow) return;
3195
+ const counts = copilotPromptTooLongPattern.exec(upstreamMessage) ?? anthropicPromptTooLongPattern.exec(upstreamMessage);
3196
+ return new PromptTooLongError(parseTokenCount(counts?.[1]), parseTokenCount(counts?.[2]), detail ?? (upstreamMessage || void 0));
3197
+ };
2280
3198
  const readCopilotJson = async (response, signal, timeoutMs = copilotRequestTimeoutMs) => {
2281
3199
  try {
2282
3200
  return await response.json();
@@ -2379,21 +3297,12 @@ const fetchCopilot = async (provider, path$1, init, options = {}) => {
2379
3297
  headers,
2380
3298
  upstreamRequestId,
2381
3299
  signal
2382
- }, async () => {
2383
- const undiciResponse = await fetch$1(`${provider.baseUrl}${path$1}`, {
2384
- ...init,
2385
- headers,
2386
- dispatcher: copilotDispatcher,
2387
- signal
2388
- });
2389
- const wrappedResponse = new Response(undiciResponse.body, {
2390
- status: undiciResponse.status,
2391
- statusText: undiciResponse.statusText,
2392
- headers: undiciResponse.headers
2393
- });
2394
- Object.defineProperty(wrappedResponse, "url", { value: undiciResponse.url });
2395
- return wrappedResponse;
2396
- });
3300
+ }, () => fetchUpstream(`${provider.baseUrl}${path$1}`, {
3301
+ body: init.body,
3302
+ headers,
3303
+ method: init.method,
3304
+ signal
3305
+ }));
2397
3306
  const ms = Math.round(performance.now() - started);
2398
3307
  logUpstreamLifecycle(options.requestId, `return from upstream method=${init.method ?? "GET"} path=${path$1} status=${response.status} ms=${ms} attempt=${attempt} upstream_request_id=${upstreamRequestId}`);
2399
3308
  log.debug(`${formatRequestId(options.requestId)}Copilot ${init.method ?? "GET"} ${path$1} -> ${response.status} ${ms}ms (attempt ${attempt}) upstream_request_id=${upstreamRequestId}`);
@@ -2449,10 +3358,10 @@ const pinCopilotModelCatalog = (config) => {
2449
3358
  pinnedCatalogs.set(config, config.modelCatalog);
2450
3359
  };
2451
3360
  const pendingCatalogs = /* @__PURE__ */ new WeakMap();
2452
- const isRecord$3 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
3361
+ const isRecord$4 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
2453
3362
  const isPositiveInteger = (value) => typeof value === "number" && Number.isSafeInteger(value) && value > 0;
2454
3363
  function parseModelTokenLimits(value) {
2455
- if (!isRecord$3(value)) return;
3364
+ if (!isRecord$4(value)) return;
2456
3365
  const context = value.max_context_window_tokens;
2457
3366
  const prompt = value.max_prompt_tokens;
2458
3367
  const output = value.max_output_tokens;
@@ -2508,13 +3417,13 @@ async function loadCopilotModelCatalog(config) {
2508
3417
  });
2509
3418
  if (!response.ok) throw new HTTPError("Failed to validate upstream models", response);
2510
3419
  const payload = await readCopilotJson(response, signal, config.upstreamTimeoutMs);
2511
- if (!isRecord$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.");
2512
3421
  const models$1 = /* @__PURE__ */ new Map();
2513
3422
  for (const model of payload.data) {
2514
- if (!isRecord$3(model) || typeof model.id !== "string" || !model.id) continue;
2515
- 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;
2516
3425
  const limits = parseModelTokenLimits(capabilities?.limits);
2517
- const supports = isRecord$3(capabilities?.supports) ? capabilities.supports : void 0;
3426
+ const supports = isRecord$4(capabilities?.supports) ? capabilities.supports : void 0;
2518
3427
  const supportedEndpoints = parseStringArray(model.supported_endpoints);
2519
3428
  const reasoningEfforts = parseReasoningEfforts(supports);
2520
3429
  models$1.set(model.id, {
@@ -2570,12 +3479,12 @@ const copilotEndpoints = [
2570
3479
  "/responses",
2571
3480
  "/v1/messages"
2572
3481
  ];
2573
- const legacyResponsesPattern = /^(?:gpt-5\.5|gpt-5\.6|gpt-6-astra)(?:-|$)/i;
3482
+ const legacyResponsesPattern = /^(?:gpt-5\.4(?!-mini)|gpt-5\.5|gpt-5\.6|gpt-6-astra)(?:-|$)/i;
2574
3483
  function selectCopilotEndpoint(config, model) {
2575
3484
  const capabilities = getCachedCopilotModel(config, model);
2576
3485
  const advertisedEndpoints = capabilities?.supportedEndpoints;
2577
3486
  if (capabilities?.type !== void 0 && capabilities.type !== "chat") return { reason: "unsupported-model-type" };
2578
- const isClaude = model.startsWith("claude-");
3487
+ const isClaude = isClaudeModelId(model);
2579
3488
  const protocolMode = config.claudeUpstreamApi ?? "chat-completions";
2580
3489
  if (isClaude && protocolMode === "messages") return {
2581
3490
  endpoint: "/v1/messages",
@@ -2632,27 +3541,6 @@ function requireCopilotEndpoint(config, model) {
2632
3541
  }, { status: 400 }), message);
2633
3542
  }
2634
3543
 
2635
- //#endregion
2636
- //#region src/lib/terminal.ts
2637
- const colorEnabled = (env = process.env, isTTY = Boolean(process.stdout.isTTY)) => {
2638
- if (env.NO_COLOR !== void 0 || env.FORCE_COLOR === "0") return false;
2639
- if (env.FORCE_COLOR !== void 0) return env.FORCE_COLOR === "" || [
2640
- "1",
2641
- "2",
2642
- "3",
2643
- "true"
2644
- ].includes(env.FORCE_COLOR);
2645
- return isTTY && env.TERM !== "dumb";
2646
- };
2647
- const ansiColorCodes = {
2648
- good: 32,
2649
- bad: 31,
2650
- warning: 33,
2651
- muted: 90
2652
- };
2653
- const terminalText = (value) => sanitizeTerminalString(value).replace(/[\x80-\x9f]|\p{Cf}|\p{Zl}|\p{Zp}/gu, "");
2654
- const colorText = (text$1, tone, enabled) => enabled ? `\u001b[${ansiColorCodes[tone]}m${text$1}\u001b[0m` : text$1;
2655
-
2656
3544
  //#endregion
2657
3545
  //#region src/lib/model-probe-output.ts
2658
3546
  const tones = {
@@ -2780,6 +3668,50 @@ const probeHint = (code) => {
2780
3668
  if (apiFailureCodes.includes(code) || /^HTTP-5\d\d$/.test(code)) return "API failure: the request ID locates the local summary; --details shows evidence on a new probe.";
2781
3669
  };
2782
3670
 
3671
+ //#endregion
3672
+ //#region src/claude/billing-line.ts
3673
+ const billingLinePrefix = "x-anthropic-billing-header:";
3674
+ const afterBillingLine = (text$1) => {
3675
+ if (!text$1.startsWith(billingLinePrefix)) return;
3676
+ const lineEnd = text$1.indexOf("\n");
3677
+ if (lineEnd === -1) return "";
3678
+ return text$1.slice(lineEnd + 1).replace(/^(?:\r?\n)+/, "");
3679
+ };
3680
+ const isTextBlock = (block) => typeof block === "object" && block !== null && block.type === "text" && typeof block.text === "string";
3681
+ const withSystem = (payload, system) => {
3682
+ const { system: _removed,...rest } = payload;
3683
+ return system === void 0 ? rest : {
3684
+ ...rest,
3685
+ system
3686
+ };
3687
+ };
3688
+ const removeBillingLine = (payload) => {
3689
+ if (typeof payload !== "object" || payload === null) return payload;
3690
+ const { system } = payload;
3691
+ if (typeof system === "string") {
3692
+ const rest = afterBillingLine(system);
3693
+ if (rest === void 0) return payload;
3694
+ return withSystem(payload, rest.trim() === "" ? void 0 : rest);
3695
+ }
3696
+ if (!Array.isArray(system)) return payload;
3697
+ let changed = false;
3698
+ const kept = [];
3699
+ for (const block of system) {
3700
+ const rest = isTextBlock(block) ? afterBillingLine(block.text) : void 0;
3701
+ if (rest === void 0) {
3702
+ kept.push(block);
3703
+ continue;
3704
+ }
3705
+ changed = true;
3706
+ if (rest.trim() !== "") kept.push({
3707
+ ...block,
3708
+ text: rest
3709
+ });
3710
+ }
3711
+ if (!changed) return payload;
3712
+ return withSystem(payload, kept.length > 0 ? kept : void 0);
3713
+ };
3714
+
2783
3715
  //#endregion
2784
3716
  //#region src/claude/utils.ts
2785
3717
  const isEffortOnlyControl = (control) => control !== null && typeof control === "object" && !Array.isArray(control) && Object.keys(control).length === 1 && "effort" in control && isConfiguredReasoningEffort(control.effort);
@@ -2911,7 +3843,7 @@ const createClaudeToolNameMapper = (tools, options = {}) => {
2911
3843
  function translateModelName(model) {
2912
3844
  return routeModelId(model);
2913
3845
  }
2914
- function translateToOpenAI(payload, _settings, toolNameMapper) {
3846
+ function translateToOpenAI(payload, _settings, toolNameMapper, options = {}) {
2915
3847
  const model = translateModelName(payload.model);
2916
3848
  const mapper = toolNameMapper ?? createClaudeToolNameMapper(payload.tools, { ...getToolNameMapperOptionsForModel(model) });
2917
3849
  const tools = translateClaudeToolsToOpenAI(payload.tools, mapper);
@@ -2919,7 +3851,7 @@ function translateToOpenAI(payload, _settings, toolNameMapper) {
2919
3851
  validateClaudeMessages(payload.messages);
2920
3852
  return {
2921
3853
  model,
2922
- messages: normalizeFinalAssistantPrefill$1(translateClaudeMessagesToOpenAI(payload.messages, payload.system, mapper)),
3854
+ messages: normalizeFinalAssistantPrefill$1(translateClaudeMessagesToOpenAI(payload.messages, payload.system, mapper, options.endpoint === "/chat/completions" && isClaudeModelId(model))),
2923
3855
  max_tokens: payload.max_tokens,
2924
3856
  stop: payload.stop_sequences,
2925
3857
  stream: payload.stream,
@@ -2945,20 +3877,50 @@ function validateClaudeMessages(messages, native = false) {
2945
3877
  if (typeof message.content !== "string" && (!Array.isArray(message.content) || message.content.some((block) => !block || block.type !== "text" || typeof block.text !== "string"))) throw invalidMessage("System message content must contain only text.");
2946
3878
  }
2947
3879
  }
2948
- function translateClaudeMessagesToOpenAI(claudeMessages, system, toolNameMapper) {
2949
- const systemMessages = handleSystemPrompt(system);
2950
- const otherMessages = claudeMessages.flatMap((message) => {
2951
- switch (message.role) {
2952
- case "user": return handleUserMessage(message);
2953
- case "assistant": return handleAssistantMessage(message, toolNameMapper);
2954
- case "system":
2955
- if (message.output_config !== void 0 && (typeof message.content === "string" ? message.content.length === 0 : message.content.every((block) => block.text.length === 0))) return [];
2956
- return handleSystemPrompt(message.content);
2957
- default: throw invalidMessage("Unsupported message role.");
2958
- }
2959
- });
2960
- return [...systemMessages, ...otherMessages];
3880
+ function translateClaudeMessagesToOpenAI(claudeMessages, system, toolNameMapper, claudeChatRoute) {
3881
+ const translated = handleSystemPrompt(system);
3882
+ if (claudeChatRoute && hasCacheBreakpoint(system)) markCacheBreakpoint(translated.at(-1));
3883
+ for (const message of claudeMessages) {
3884
+ const start$1 = translated.length;
3885
+ translated.push(...translateClaudeMessage(message, toolNameMapper, claudeChatRoute));
3886
+ if (claudeChatRoute) markMessageBreakpoints(message, translated, start$1);
3887
+ }
3888
+ return translated;
3889
+ }
3890
+ function markMessageBreakpoints(message, translated, start$1) {
3891
+ if (message.role !== "user" || !Array.isArray(message.content)) {
3892
+ if (hasCacheBreakpoint(message.content)) markCacheBreakpoint(translated.at(-1));
3893
+ return;
3894
+ }
3895
+ let toolIndex = start$1;
3896
+ for (const block of message.content) if (block.type === "tool_result") {
3897
+ if (isCacheBreakpoint(block)) markCacheBreakpoint(translated[toolIndex]);
3898
+ toolIndex += 1;
3899
+ } else if (isCacheBreakpoint(block)) markCacheBreakpoint(translated.at(-1));
3900
+ }
3901
+ function translateClaudeMessage(message, toolNameMapper, claudeChatRoute) {
3902
+ switch (message.role) {
3903
+ case "user": return handleUserMessage(message);
3904
+ case "assistant": return handleAssistantMessage(message, toolNameMapper);
3905
+ case "system":
3906
+ if (message.output_config !== void 0 && (typeof message.content === "string" ? message.content.length === 0 : message.content.every((block) => block.text.length === 0))) return [];
3907
+ return claudeChatRoute ? toSystemReminder(message.content) : handleSystemPrompt(message.content);
3908
+ default: throw invalidMessage("Unsupported message role.");
3909
+ }
3910
+ }
3911
+ function toSystemReminder(content) {
3912
+ const text$1 = typeof content === "string" ? content : content.map((block) => block.text).join("\n\n");
3913
+ if (text$1.trim().length === 0) return [];
3914
+ return [{
3915
+ role: "user",
3916
+ content: `<system-reminder>\n${text$1}\n</system-reminder>`
3917
+ }];
2961
3918
  }
3919
+ const isCacheBreakpoint = (block) => "cache_control" in block && Boolean(block.cache_control);
3920
+ const hasCacheBreakpoint = (content) => Array.isArray(content) && content.some(isCacheBreakpoint);
3921
+ const markCacheBreakpoint = (message) => {
3922
+ if (message) message.copilot_cache_control = { type: "ephemeral" };
3923
+ };
2962
3924
  const invalidMessage = (message) => new HTTPError(message, new Response(JSON.stringify({
2963
3925
  type: "error",
2964
3926
  error: {
@@ -3311,6 +4273,13 @@ function translateChunkToClaudeEvents(chunk, state, toolNameMapper = createClaud
3311
4273
  return events$1;
3312
4274
  }
3313
4275
  function translateErrorToClaudeErrorEvent(error) {
4276
+ if (error instanceof PromptTooLongError) return {
4277
+ type: "error",
4278
+ error: {
4279
+ type: "invalid_request_error",
4280
+ message: error.message
4281
+ }
4282
+ };
3314
4283
  return {
3315
4284
  type: "error",
3316
4285
  error: {
@@ -3334,7 +4303,7 @@ const webSearchInputSchema = {
3334
4303
  required: ["query"],
3335
4304
  additionalProperties: false
3336
4305
  };
3337
- const isRecord$2 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
4306
+ const isRecord$3 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
3338
4307
  const isAnthropicNativeWebSearchTool = (tool) => tool.name === "web_search" && typeof tool.type === "string" && anthropicWebSearchToolPattern.test(tool.type);
3339
4308
  const isClaudeCodeWebSearchTool = (tool) => tool.name === claudeCodeWebSearchToolName;
3340
4309
  const isClaudeWebSearchTool = (tool) => isAnthropicNativeWebSearchTool(tool) || isClaudeCodeWebSearchTool(tool);
@@ -3351,11 +4320,16 @@ const prepareClaudeWebSearchDecisionPayload = (payload) => {
3351
4320
  } : tool)
3352
4321
  };
3353
4322
  };
4323
+ const toolResultText = (content) => {
4324
+ if (typeof content === "string") return content;
4325
+ if (!Array.isArray(content)) return "";
4326
+ return content.map((block) => block.type === "text" ? block.text : `[${block.type}]`).join("\n\n");
4327
+ };
3354
4328
  const textFromMessageContent = (content) => {
3355
4329
  if (typeof content === "string") return content;
3356
4330
  return content.flatMap((block) => {
3357
4331
  if (block.type === "text") return [block.text];
3358
- if (block.type === "tool_result") return [block.content];
4332
+ if (block.type === "tool_result") return [toolResultText(block.content)];
3359
4333
  return [];
3360
4334
  }).join("\n\n");
3361
4335
  };
@@ -3369,7 +4343,7 @@ const getQueryFromToolArguments = (value) => {
3369
4343
  if (!trimmed) return;
3370
4344
  try {
3371
4345
  const parsed = JSON.parse(trimmed);
3372
- if (isRecord$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();
3373
4347
  } catch {
3374
4348
  return trimmed;
3375
4349
  }
@@ -3426,7 +4400,7 @@ const getReportedQuery = (action, queries) => {
3426
4400
  const getSearchQuery = (response, requestedQuery) => {
3427
4401
  for (const item of response.output ?? []) {
3428
4402
  if (item.type !== "web_search_call") continue;
3429
- const action = isRecord$2(item.action) ? item.action : void 0;
4403
+ const action = isRecord$3(item.action) ? item.action : void 0;
3430
4404
  const query = getReportedQuery(action, Array.isArray(action?.queries) ? action.queries : []);
3431
4405
  if (query) return query;
3432
4406
  }
@@ -3434,7 +4408,7 @@ const getSearchQuery = (response, requestedQuery) => {
3434
4408
  };
3435
4409
  const getResponseText = (response) => (response.output ?? []).flatMap((item) => {
3436
4410
  if (item.type !== "message") return [];
3437
- return (Array.isArray(item.content) ? item.content : []).flatMap((part) => isRecord$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] : []);
3438
4412
  }).join("\n").trim();
3439
4413
  const cleanTitle = (line, url) => {
3440
4414
  return line.slice(0, line.indexOf(url)).replace(/^\s*(?:[-*]|\d+[.)])\s*/, "").replace(/\s*(?:[-:|])\s*$/, "").trim() || new URL(url).hostname;
@@ -3458,15 +4432,15 @@ const parseSearchResults = (text$1) => {
3458
4432
  const getStructuredSearchResults = (response) => {
3459
4433
  const sources = [];
3460
4434
  for (const item of response.output ?? []) {
3461
- if (item.type === "web_search_call" && isRecord$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);
3462
4436
  if (item.type === "message" && Array.isArray(item.content)) {
3463
- 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"));
3464
4438
  }
3465
4439
  }
3466
4440
  const results = [];
3467
4441
  const seen = /* @__PURE__ */ new Set();
3468
4442
  for (const source of sources) {
3469
- if (!isRecord$2(source) || typeof source.url !== "string") continue;
4443
+ if (!isRecord$3(source) || typeof source.url !== "string") continue;
3470
4444
  try {
3471
4445
  const url = new URL(source.url);
3472
4446
  if (!/^https?:$/.test(url.protocol) || url.username || url.password || seen.has(url.href)) continue;
@@ -3518,21 +4492,21 @@ const getResultSource = (structured, results) => {
3518
4492
  return results.length ? "text" : "none";
3519
4493
  };
3520
4494
  const interpretSearchResponse = (raw, config, requestedQuery, request, requestedEffort, requestId) => {
3521
- const malformed = !isRecord$2(raw) || raw.output !== void 0 && (!Array.isArray(raw.output) || !raw.output.every(isRecord$2));
3522
- 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) ? {
3523
4497
  ...raw,
3524
- output: Array.isArray(raw.output) ? raw.output.filter(isRecord$2) : []
4498
+ output: Array.isArray(raw.output) ? raw.output.filter(isRecord$3) : []
3525
4499
  } : {};
3526
4500
  const status$1 = recognizedValue(upstream.status, responseStates);
3527
- const reason = recognizedValue(isRecord$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);
3528
4502
  const items = upstream.output ?? [];
3529
4503
  const calls = items.filter((item) => item.type === "web_search_call");
3530
4504
  const callStates = calls.map((item) => recognizedValue(item.status, searchStates));
3531
- const usage = isRecord$2(upstream.usage) ? upstream.usage : {};
3532
- const inputTokens = reportedTokens(usage.input_tokens);
3533
- const cachedInputTokens = reportedTokens(isRecord$2(usage.input_tokens_details) ? usage.input_tokens_details.cached_tokens : void 0);
3534
- const outputTokens = reportedTokens(usage.output_tokens);
3535
- 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);
3536
4510
  const upstreamResponseId = safeResponseId(upstream.id, config.copilotToken);
3537
4511
  const safeRequestId = typeof requestId === "string" && /^[a-f0-9-]{36}$/i.test(requestId) ? requestId : "unreported";
3538
4512
  const text$1 = getResponseText(upstream);
@@ -3620,7 +4594,7 @@ const getSearchFailureDetail = (body, token) => {
3620
4594
  let detail = body.trim();
3621
4595
  try {
3622
4596
  const parsed = JSON.parse(detail);
3623
- detail = getErrorMessageOrCode(isRecord$2(parsed) && isRecord$2(parsed.error) ? parsed.error : void 0);
4597
+ detail = getErrorMessageOrCode(isRecord$3(parsed) && isRecord$3(parsed.error) ? parsed.error : void 0);
3624
4598
  } catch {
3625
4599
  if (/^[{[<]/.test(detail)) return "";
3626
4600
  }
@@ -3789,9 +4763,9 @@ const accumulateChunks = (chunks) => {
3789
4763
  let reasoning = "";
3790
4764
  let refusal = "";
3791
4765
  let finishReason = null;
3792
- let usage;
4766
+ let usage$1;
3793
4767
  for (const chunk of chunks) {
3794
- if (chunk.usage) usage = chunk.usage;
4768
+ if (chunk.usage) usage$1 = chunk.usage;
3795
4769
  const choice = chunk.choices[0];
3796
4770
  if (!choice) continue;
3797
4771
  if (typeof choice.delta?.content === "string") content += choice.delta.content;
@@ -3832,7 +4806,7 @@ const accumulateChunks = (chunks) => {
3832
4806
  },
3833
4807
  finish_reason: refusal ? "content_filter" : finishReason ?? "stop"
3834
4808
  }],
3835
- ...usage ? { usage } : {}
4809
+ ...usage$1 ? { usage: usage$1 } : {}
3836
4810
  };
3837
4811
  };
3838
4812
  const incompleteStreamError = () => {
@@ -3848,7 +4822,7 @@ const incompleteStreamError = () => {
3848
4822
  async function* normalizeChatCompletionStream(stream) {
3849
4823
  let terminal;
3850
4824
  let refusal = false;
3851
- let usage;
4825
+ let usage$1;
3852
4826
  for await (const event of stream) {
3853
4827
  if (event.event === "error") throw incompleteStreamError();
3854
4828
  if (!event.data) continue;
@@ -3860,7 +4834,7 @@ async function* normalizeChatCompletionStream(stream) {
3860
4834
  throw incompleteStreamError();
3861
4835
  }
3862
4836
  if (!chunk || !Array.isArray(chunk.choices)) throw incompleteStreamError();
3863
- if (chunk.usage) usage = chunk.usage;
4837
+ if (chunk.usage) usage$1 = chunk.usage;
3864
4838
  if (chunk.choices.length === 0) continue;
3865
4839
  const choice = chunk.choices[0];
3866
4840
  if (!choice || !choice.delta || typeof choice.delta !== "object" || terminal) throw incompleteStreamError();
@@ -3892,7 +4866,7 @@ async function* normalizeChatCompletionStream(stream) {
3892
4866
  if (!terminal) throw incompleteStreamError();
3893
4867
  yield { data: JSON.stringify({
3894
4868
  ...terminal,
3895
- ...usage && { usage }
4869
+ ...usage$1 && { usage: usage$1 }
3896
4870
  }) };
3897
4871
  yield { data: "[DONE]" };
3898
4872
  }
@@ -4192,7 +5166,7 @@ const lookaroundPrefixes = [
4192
5166
  "(?<=",
4193
5167
  "(?<!"
4194
5168
  ];
4195
- const isRecord$1 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
5169
+ const isRecord$2 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
4196
5170
  function hasUnsupportedPatternFeatures(pattern) {
4197
5171
  let inCharacterClass = false;
4198
5172
  for (let index = 0; index < pattern.length; index++) {
@@ -4238,12 +5212,12 @@ function mapArray(values) {
4238
5212
  }
4239
5213
  function normalizeSchemaValue(value) {
4240
5214
  if (Array.isArray(value)) return mapArray(value);
4241
- return isRecord$1(value) ? normalizeResponsesToolSchema(value) : value;
5215
+ return isRecord$2(value) ? normalizeResponsesToolSchema(value) : value;
4242
5216
  }
4243
5217
  function normalizeResponsesToolSchema(schema) {
4244
5218
  return mapRecord(schema, (key, value) => {
4245
5219
  if (key === "pattern" && typeof value === "string" && hasUnsupportedPatternFeatures(value)) return removed;
4246
- if (schemaMapKeywords.has(key) && isRecord$1(value)) return mapRecord(value, (_name, nested) => normalizeSchemaValue(nested));
5220
+ if (schemaMapKeywords.has(key) && isRecord$2(value)) return mapRecord(value, (_name, nested) => normalizeSchemaValue(nested));
4247
5221
  if (nestedSchemaKeywords.has(key)) return normalizeSchemaValue(value);
4248
5222
  return value;
4249
5223
  });
@@ -4522,7 +5496,7 @@ function createRoleChunk(state) {
4522
5496
  state.started = true;
4523
5497
  return createChunk(state, { delta: { role: "assistant" } });
4524
5498
  }
4525
- function createChunk(state, { delta, finishReason = null, usage }) {
5499
+ function createChunk(state, { delta, finishReason = null, usage: usage$1 }) {
4526
5500
  return { data: JSON.stringify({
4527
5501
  id: state.responseId,
4528
5502
  object: "chat.completion.chunk",
@@ -4534,7 +5508,7 @@ function createChunk(state, { delta, finishReason = null, usage }) {
4534
5508
  finish_reason: finishReason,
4535
5509
  logprobs: null
4536
5510
  }],
4537
- usage
5511
+ usage: usage$1
4538
5512
  }) };
4539
5513
  }
4540
5514
  function translateMessagesToResponsesInput(messages) {
@@ -4546,7 +5520,7 @@ function translateMessage(message) {
4546
5520
  if (message.role === "tool") return [{
4547
5521
  type: "function_call_output",
4548
5522
  call_id: message.tool_call_id ?? "",
4549
- output: stringifyToolOutput(message.content)
5523
+ output: translateToolOutput(message.content)
4550
5524
  }];
4551
5525
  const translated = [];
4552
5526
  if (hasContent(message.content)) translated.push({
@@ -4582,6 +5556,21 @@ function translateContentPart(part) {
4582
5556
  detail: part.image_url.detail ?? "auto"
4583
5557
  };
4584
5558
  }
5559
+ function translateToolOutput(content) {
5560
+ if (Array.isArray(content) && content.some((part) => part.type === "image_url")) return content.flatMap((part) => translateToolOutputPart(part));
5561
+ return stringifyToolOutput(content);
5562
+ }
5563
+ function translateToolOutputPart(part) {
5564
+ if (part.type === "image_url") return [{
5565
+ type: "input_image",
5566
+ image_url: part.image_url.url
5567
+ }];
5568
+ if (part.text === "") return [];
5569
+ return [{
5570
+ type: "input_text",
5571
+ text: part.text
5572
+ }];
5573
+ }
4585
5574
  function stringifyToolOutput(content) {
4586
5575
  if (typeof content === "string") return content;
4587
5576
  if (!content) return "";
@@ -4605,13 +5594,13 @@ function translateToolChoice(toolChoice) {
4605
5594
  name: toolChoice.function.name
4606
5595
  };
4607
5596
  }
4608
- function translateUsage(usage) {
4609
- if (!usage) return;
5597
+ function translateUsage(usage$1) {
5598
+ if (!usage$1) return;
4610
5599
  return {
4611
- prompt_tokens: usage.input_tokens,
4612
- completion_tokens: usage.output_tokens,
4613
- total_tokens: usage.total_tokens,
4614
- ...usage.input_tokens_details?.cached_tokens !== void 0 && { prompt_tokens_details: { cached_tokens: usage.input_tokens_details.cached_tokens } }
5600
+ prompt_tokens: usage$1.input_tokens,
5601
+ completion_tokens: usage$1.output_tokens,
5602
+ total_tokens: usage$1.total_tokens,
5603
+ ...usage$1.input_tokens_details?.cached_tokens !== void 0 && { prompt_tokens_details: { cached_tokens: usage$1.input_tokens_details.cached_tokens } }
4615
5604
  };
4616
5605
  }
4617
5606
  function getMessageOutputText(content) {
@@ -4702,7 +5691,57 @@ const normalizeFinalAssistantPrefill = (payload) => {
4702
5691
  };
4703
5692
  };
4704
5693
  const isAgentInitiator = (messages) => messages.some((message) => message.role === "assistant" || message.role === "tool") ? "agent" : "user";
4705
- const 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
+ };
4706
5745
  const createChatCompletions = async (config, payload, options = {}) => {
4707
5746
  const client = options.client ?? "generic";
4708
5747
  const requestedModel = options.requestedModel ?? payload.model;
@@ -4725,7 +5764,7 @@ const createChatCompletions = async (config, payload, options = {}) => {
4725
5764
  stream: bufferResponse ? true : payload.stream
4726
5765
  };
4727
5766
  const useResponsesApi = selection.endpoint === "/responses";
4728
- const compatiblePayload = useResponsesApi ? upstreamPayload : normalizeFinalAssistantPrefill(upstreamPayload);
5767
+ const compatiblePayload = useResponsesApi ? upstreamPayload : normalizeFinalAssistantPrefill(moveToolImagesToUserMessages(upstreamPayload));
4729
5768
  const provider = getCopilotProviderContext(config);
4730
5769
  const enableVision = messagesIncludeImage(compatiblePayload.messages);
4731
5770
  const initiator = isAgentInitiator(compatiblePayload.messages);
@@ -4767,19 +5806,19 @@ const createChatCompletions = async (config, payload, options = {}) => {
4767
5806
  markDiscardedResponse(response);
4768
5807
  await response.body?.cancel();
4769
5808
  return completeResponse(await createResponses(provider, upstreamPayload, {
4770
- vision: enableVision,
5809
+ vision: messagesIncludeImage(upstreamPayload.messages),
4771
5810
  initiator,
4772
5811
  requestId: options.requestId,
4773
5812
  signal,
4774
5813
  timeoutMs: options.timeoutMs
4775
5814
  }));
4776
5815
  }
4777
- throw 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, {
4778
5817
  model: payload.model,
4779
5818
  request: requestPayload,
4780
5819
  route: "/chat/completions",
4781
5820
  requestId: options.requestId
4782
- }, signal, options.timeoutMs));
5821
+ }, signal, options.timeoutMs);
4783
5822
  }
4784
5823
  if (compatiblePayload.stream) return completeResponse(normalizeChatCompletionStream(events(response)));
4785
5824
  return readCopilotJson(response, signal, options.timeoutMs);
@@ -4801,12 +5840,12 @@ async function createResponses(provider, payload, options) {
4801
5840
  signal: options.signal,
4802
5841
  timeoutMs: options.timeoutMs
4803
5842
  });
4804
- if (!response.ok) throw 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, {
4805
5844
  model: payload.model,
4806
5845
  request: requestPayload,
4807
5846
  route: "/responses",
4808
5847
  requestId: options.requestId
4809
- }, options.signal, options.timeoutMs));
5848
+ }, options.signal, options.timeoutMs);
4810
5849
  if (payload.stream) return normalizeChatCompletionStream(translateResponsesStreamToChatCompletionStream(events(response)));
4811
5850
  return translateResponsesToChatCompletion(await readCopilotJson(response, options.signal, options.timeoutMs));
4812
5851
  }
@@ -4818,16 +5857,16 @@ async function logUpstreamError(message, response, context, signal, timeoutMs) {
4818
5857
  route: context.route,
4819
5858
  model: context.model,
4820
5859
  requestId: context.requestId,
4821
- request: context.request,
4822
5860
  response: {
4823
5861
  status: response.status,
4824
5862
  statusText: response.statusText || void 0,
4825
5863
  url: response.url || void 0,
4826
5864
  headers: Object.fromEntries(response.headers.entries()),
4827
5865
  body: errorBody || void 0
4828
- }
5866
+ },
5867
+ request: context.request
4829
5868
  });
4830
- return detail;
5869
+ return toPromptTooLongError(response.status, errorBody, detail) ?? new HTTPError(message, response, detail);
4831
5870
  }
4832
5871
  function getUpstreamErrorDetail(response, body) {
4833
5872
  if (!body) return response.statusText || void 0;
@@ -4987,6 +6026,8 @@ async function createNativeMessages(config, payload, options) {
4987
6026
  if (!response.ok) {
4988
6027
  const detail = await readCopilotText(response, options.signal, config.upstreamTimeoutMs);
4989
6028
  const contentType = response.headers.get("content-type") ?? "application/json";
6029
+ const promptTooLong = toPromptTooLongError(response.status, detail);
6030
+ if (promptTooLong) throw promptTooLong;
4990
6031
  throw new HTTPError("Native upstream request failed", new Response(detail, {
4991
6032
  status: response.status,
4992
6033
  headers: { "content-type": contentType }
@@ -5216,7 +6257,7 @@ async function handleNativeMessages(config, payload, options, write) {
5216
6257
  };
5217
6258
  const upstreamToolResult = toolResult(searchCall.id, resultBlock.content);
5218
6259
  const hasSiblingTools = message.content.some((block) => block.type === "tool_use" && block !== searchCall);
5219
- let usage = mergeUsage(message.usage, searchMessage.usage);
6260
+ let usage$1 = mergeUsage(message.usage, searchMessage.usage);
5220
6261
  const combined = [...displayDecision, resultBlock];
5221
6262
  if (write) for (const [index, block] of combined.entries()) {
5222
6263
  if (index < (heldFrom ?? message.content.length)) continue;
@@ -5227,13 +6268,13 @@ async function handleNativeMessages(config, payload, options, write) {
5227
6268
  ...message,
5228
6269
  content: combined,
5229
6270
  stop_reason: hasSiblingTools ? "tool_use" : "end_turn",
5230
- usage
6271
+ usage: usage$1
5231
6272
  };
5232
6273
  if (!write) return complete;
5233
6274
  await write({
5234
6275
  type: "message_delta",
5235
6276
  delta: { stop_reason: complete.stop_reason },
5236
- usage
6277
+ usage: usage$1
5237
6278
  });
5238
6279
  await write({ type: "message_stop" });
5239
6280
  return;
@@ -5266,11 +6307,11 @@ async function handleNativeMessages(config, payload, options, write) {
5266
6307
  });
5267
6308
  } : void 0);
5268
6309
  if (finalMessage.content.some((block) => block.type === "tool_use" && isClaudeWebSearchToolName(block.name ?? ""))) throw new Error("Repeated bridge-managed search is unsupported.");
5269
- usage = mergeUsage(usage, finalMessage.usage);
6310
+ usage$1 = mergeUsage(usage$1, finalMessage.usage);
5270
6311
  if (!write) return {
5271
6312
  ...finalMessage,
5272
6313
  content: [...combined, ...finalMessage.content],
5273
- usage
6314
+ usage: usage$1
5274
6315
  };
5275
6316
  if (finalHeldFrom !== void 0) for (const [index, block] of finalMessage.content.entries()) {
5276
6317
  if (index < finalHeldFrom) continue;
@@ -5283,7 +6324,7 @@ async function handleNativeMessages(config, payload, options, write) {
5283
6324
  stop_sequence: finalMessage.stop_sequence,
5284
6325
  ..."stop_details" in finalMessage && { stop_details: finalMessage.stop_details }
5285
6326
  },
5286
- usage
6327
+ usage: usage$1
5287
6328
  });
5288
6329
  await write({ type: "message_stop" });
5289
6330
  }
@@ -5452,6 +6493,10 @@ const createQueuedClaudeStreamWriter = (write) => {
5452
6493
  const writeClaudeStreamEvents = async (events$1, writeEvent) => {
5453
6494
  for (const event of events$1) await writeEvent(event);
5454
6495
  };
6496
+ const translationEndpoint = (config, model) => {
6497
+ const selection = selectCopilotEndpoint(config, normalizeCopilotModelId(model));
6498
+ return selection.endpoint && !selection.responsesFallback ? selection.endpoint : void 0;
6499
+ };
5455
6500
  const handleClaudeMessageRequest = async (config, claudePayload, requestSignal, requestId, writeEvent, requestHeaders) => {
5456
6501
  const upstreamModel = translateModelName(claudePayload.model);
5457
6502
  if (shouldUseNativeMessages(config, upstreamModel)) return handleNativeMessages(config, {
@@ -5465,7 +6510,7 @@ const handleClaudeMessageRequest = async (config, claudePayload, requestSignal,
5465
6510
  const shouldLetModelDecideWebSearch = hasClaudeWebSearch(claudePayload);
5466
6511
  const decisionPayload = shouldLetModelDecideWebSearch ? prepareClaudeWebSearchDecisionPayload(claudePayload) : claudePayload;
5467
6512
  const toolNameMapper = createClaudeToolNameMapper(decisionPayload.tools, { ...getToolNameMapperOptionsForModel(upstreamModel) });
5468
- const openAIPayload = translateToOpenAI(decisionPayload, void 0, toolNameMapper);
6513
+ const openAIPayload = translateToOpenAI(decisionPayload, void 0, toolNameMapper, { endpoint: translationEndpoint(config, upstreamModel) });
5469
6514
  const canStreamWebSearchDecision = shouldLetModelDecideWebSearch && !!writeEvent;
5470
6515
  if (shouldLetModelDecideWebSearch && !canStreamWebSearchDecision) openAIPayload.stream = false;
5471
6516
  const response = await createChatCompletions(config, openAIPayload, {
@@ -5620,7 +6665,7 @@ claudeRoutes.get("/models", (c) => c.json({
5620
6665
  claudeRoutes.post("/messages", async (c) => {
5621
6666
  const config = c.get("config");
5622
6667
  const requestId = c.get("requestId");
5623
- const claudePayload = await c.req.json();
6668
+ const claudePayload = removeBillingLine(await c.req.json());
5624
6669
  const requestSignal = createCopilotRequestSignal(c.req.raw.signal, config.upstreamTimeoutMs);
5625
6670
  try {
5626
6671
  getRequestReasoningEffort(claudePayload);
@@ -5709,7 +6754,7 @@ claudeRoutes.post("/messages", async (c) => {
5709
6754
  claudeRoutes.post("/messages/count_tokens", async (c) => {
5710
6755
  try {
5711
6756
  const claudeBeta = c.req.header("claude-beta");
5712
- const claudePayload = await c.req.json();
6757
+ const claudePayload = removeBillingLine(await c.req.json());
5713
6758
  const openAIPayload = translateToOpenAI(shouldUseNativeMessages(c.get("config"), translateModelName(claudePayload.model)) ? {
5714
6759
  ...claudePayload,
5715
6760
  messages: claudePayload.messages.map((message) => {
@@ -5717,7 +6762,7 @@ claudeRoutes.post("/messages/count_tokens", async (c) => {
5717
6762
  const { output_config: _outputConfig, clear_at: _clearAt,...textMessage } = message;
5718
6763
  return textMessage;
5719
6764
  })
5720
- } : claudePayload);
6765
+ } : claudePayload, void 0, void 0, { endpoint: translationEndpoint(c.get("config"), translateModelName(claudePayload.model)) });
5721
6766
  const exposedModels = getExposedModelIds();
5722
6767
  const upstreamModel = getCachedCopilotModel(c.get("config"), openAIPayload.model);
5723
6768
  const hasDiscoveredTokenizer = upstreamModel?.tokenizer !== void 0 && isSupportedTokenizer(upstreamModel.tokenizer);
@@ -5781,6 +6826,22 @@ const parseAuthority = (authority, protocol) => {
5781
6826
  };
5782
6827
  const effectivePort = (url) => Number(url.port || (url.protocol === "https:" ? 443 : 80));
5783
6828
  const isAllowedHostname = (hostname, configuredHost) => hostname === "localhost" || hostname === "[::1]" || isIPv4(hostname) && hostname.startsWith("127.") || hostname !== "0.0.0.0" && hostname !== "[::]" && hostname === configuredHost;
6829
+ const sha256 = (value) => createHash("sha256").update(value, "utf8").digest();
6830
+ /**
6831
+ * True when the request carries the relay's apiKey as x-api-key or Authorization: Bearer.
6832
+ *
6833
+ * The configured key and each presented value are hashed with SHA-256, and timingSafeEqual compares
6834
+ * the fixed-length digests, so the comparison never stops at the first byte that differs. Both
6835
+ * headers are compared even after one matches.
6836
+ */
6837
+ const presentsApiKey = (headers, apiKey) => {
6838
+ const expected = sha256(apiKey);
6839
+ const bearer = /^Bearer\s+(.+)$/i.exec(headers.get("authorization") ?? "")?.[1];
6840
+ let matched = false;
6841
+ for (const presented of [headers.get("x-api-key"), bearer]) if (typeof presented === "string" && timingSafeEqual(sha256(presented), expected)) matched = true;
6842
+ return matched;
6843
+ };
6844
+ const isOpenProbe = (method, path$1) => method === "GET" && path$1 === "/healthz" || (method === "GET" || method === "HEAD") && path$1 === "/api/hello";
5784
6845
  const createServer = (config) => {
5785
6846
  const app = new Hono();
5786
6847
  const { host, port } = config;
@@ -5824,6 +6885,18 @@ const createServer = (config) => {
5824
6885
  return c.json({ error: { message } }, 403);
5825
6886
  }
5826
6887
  }
6888
+ const apiKey = config.apiKey;
6889
+ if (apiKey && !isOpenProbe(c.req.method, c.req.path) && !presentsApiKey(c.req.raw.headers, apiKey)) {
6890
+ const message = "Missing or invalid API key: send the relay apiKey as x-api-key or Authorization: Bearer";
6891
+ c.set("requestErrorMessage", message);
6892
+ return c.json({
6893
+ type: "error",
6894
+ error: {
6895
+ type: "authentication_error",
6896
+ message
6897
+ }
6898
+ }, 401, { "www-authenticate": "Bearer" });
6899
+ }
5827
6900
  if (c.req.method === "POST" && (c.req.path === "/v1/messages" || c.req.path === "/v1/messages/count_tokens") && (incoming ? Boolean(incoming.headers["transfer-encoding"]) || Number(incoming.headers["content-length"] ?? 0) > 0 : c.req.raw.body !== null) && c.req.header("content-type")?.split(";", 1)[0].trim().toLowerCase() !== "application/json") {
5828
6901
  const message = "Content-Type must be application/json";
5829
6902
  c.set("requestErrorMessage", message);
@@ -5904,7 +6977,14 @@ const startServer = (config) => new Promise((resolve$1, reject) => {
5904
6977
  //#endregion
5905
6978
  //#region src/lib/model-probe.ts
5906
6979
  const record$1 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
5907
- const 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
+ };
5908
6988
  const httpCategory = (status$1) => {
5909
6989
  if (status$1 === 401) return "authentication-rejected";
5910
6990
  if (status$1 === 403) return "access-denied";
@@ -5961,9 +7041,9 @@ const failureFromEvidence = (row, diagnostic) => {
5961
7041
  return row.detail;
5962
7042
  };
5963
7043
  async function probeModels(config, entries, options) {
5964
- console.log(`\nModel check · ${entries.length} ${entries.length === 1 ? "model" : "models"} · real Copilot usage`);
5965
- console.log("Isolated relay pipeline; not running-daemon health.");
5966
- 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`);
5967
7047
  const controller = new AbortController();
5968
7048
  const interrupt = () => controller.abort();
5969
7049
  process.on("SIGINT", interrupt);
@@ -5977,9 +7057,9 @@ async function probeModels(config, entries, options) {
5977
7057
  const results = [];
5978
7058
  const app = createServer(config);
5979
7059
  const columns = Math.max(40, process.stdout.columns || 80);
5980
- const width = probeColumnWidth(entries.map(([id]) => safeId(id, config.copilotToken) ? id : "[unsupported ID]"), columns);
7060
+ const width = probeColumnWidth(entries.map(([id]) => safeId(id, config) ? id : "[unsupported ID]"), columns);
5981
7061
  const color = colorEnabled();
5982
- if (entries.length) console.log(renderProbeHeader(width));
7062
+ if (entries.length) printLine(renderProbeHeader(width));
5983
7063
  try {
5984
7064
  runtimeState.modelCatalog = config.modelCatalog;
5985
7065
  runtimeState.upstreamBaseUrl = config.copilotBaseUrl;
@@ -6001,7 +7081,7 @@ async function probeModels(config, entries, options) {
6001
7081
  const endpoint = selection.endpoint ?? "none";
6002
7082
  const { advertisedEndpoints, unknownEndpoints } = summarizeAdvertisedEndpoints(model.supportedEndpoints);
6003
7083
  const row = {
6004
- id: safeId(id, config.copilotToken) ? id : "[unsupported ID]",
7084
+ id: safeId(id, config) ? id : "[unsupported ID]",
6005
7085
  status: "NOT_TESTED",
6006
7086
  reported: "-",
6007
7087
  sent: false,
@@ -6016,7 +7096,7 @@ async function probeModels(config, entries, options) {
6016
7096
  unverified: model.reasoningEfforts === void 0 || model.supportedEndpoints === void 0
6017
7097
  };
6018
7098
  if (controller.signal.aborted || totalDeadline.aborted) row.detail = controller.signal.aborted ? "cancelled" : "total-deadline";
6019
- else if (!safeId(id, config.copilotToken) || normalizeCopilotModelId(id) !== id) {
7099
+ else if (!safeId(id, config) || normalizeCopilotModelId(id) !== id) {
6020
7100
  row.status = "SKIPPED";
6021
7101
  row.detail = "unsafe-or-noncanonical-id";
6022
7102
  } else if (!selection.endpoint) {
@@ -6045,7 +7125,10 @@ async function probeModels(config, entries, options) {
6045
7125
  const sendProbe = async () => {
6046
7126
  const response = await app.fetch(new Request(`http://localhost${config.port ? `:${config.port}` : ""}/v1/messages`, {
6047
7127
  method: "POST",
6048
- headers: { "content-type": "application/json" },
7128
+ headers: {
7129
+ "content-type": "application/json",
7130
+ ...config.apiKey ? { "x-api-key": config.apiKey } : {}
7131
+ },
6049
7132
  signal,
6050
7133
  body: JSON.stringify({
6051
7134
  model: id,
@@ -6086,7 +7169,7 @@ async function probeModels(config, entries, options) {
6086
7169
  if (code === "model_not_supported") row.detail = "model-not-supported";
6087
7170
  } else if (!record$1(body) || !Array.isArray(body.content)) row.detail = "malformed-response";
6088
7171
  else {
6089
- row.reported = safeId(body.model, config.copilotToken) ? body.model : "unreported";
7172
+ row.reported = safeId(body.model, config) ? body.model : "unreported";
6090
7173
  if (!reportsSelectedModel(endpoint, id, row.reported)) row.detail = "model-mismatch";
6091
7174
  else if (body.stop_reason === "max_tokens") {
6092
7175
  row.status = "INCOMPLETE";
@@ -6118,11 +7201,11 @@ async function probeModels(config, entries, options) {
6118
7201
  if (row.status === "FAIL") row.detail = failureFromEvidence(row, row.diagnostic);
6119
7202
  }
6120
7203
  }
6121
- if (!safeId(id, config.copilotToken)) row.id = "[unsupported ID]";
7204
+ if (!safeId(id, config)) row.id = "[unsupported ID]";
6122
7205
  results.push(row);
6123
- for (const line of renderProbeRow(row, width, color, columns)) console.log(scrubSensitiveUrls(line));
6124
- if (options.details) for (const line of renderProbeDetails(row, row.diagnostic)) console.log(line);
6125
- 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}`);
6126
7209
  if (row.status !== "PASS" && row.sent) withoutConsoleLogging(() => log.info(`request_id=${row.diagnostic?.requestId ?? "unknown"} model_probe model=${row.id} status=${row.status} reason=${row.detail}`));
6127
7210
  }
6128
7211
  } finally {
@@ -6137,11 +7220,11 @@ async function probeModels(config, entries, options) {
6137
7220
  process.off("SIGINT", interrupt);
6138
7221
  process.off("SIGTERM", interrupt);
6139
7222
  }
6140
- console.log(`\n${renderProbeSummary(results)}`);
6141
- if (results.some((row) => row.unverified)) console.log("* Effort or endpoint metadata is unverified.");
6142
- const hints = new Set(results.filter((row) => row.status !== "PASS").map((row) => probeHint(row.detail)).filter(Boolean));
6143
- for (const hint of hints) console.log(hint);
6144
- 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.");
6145
7228
  if (controller.signal.aborted) return 130;
6146
7229
  return results.length > 0 && results.every((row) => row.status === "PASS") ? 0 : 2;
6147
7230
  }
@@ -6234,8 +7317,9 @@ const renderConfigGuide = (configPath$1, configured, chosen) => {
6234
7317
  //#endregion
6235
7318
  //#region src/models.ts
6236
7319
  var ModelsUsageError = class extends Error {};
7320
+ const printable = (text$1) => scrubLogSecrets(terminalText(text$1));
6237
7321
  const displayName = (name) => {
6238
- const text$1 = sanitizeTerminalString(name ?? "").trim();
7322
+ const text$1 = printable(name ?? "").trim();
6239
7323
  if (text$1 === "") return;
6240
7324
  return text$1.length > 80 ? `${text$1.slice(0, 77)}...` : text$1;
6241
7325
  };
@@ -6243,7 +7327,7 @@ const listedModels = (config, catalog) => [...catalog.models.keys()].sort().map(
6243
7327
  const selection = selectCopilotEndpoint(config, id);
6244
7328
  const name = displayName(catalog.models.get(id)?.name);
6245
7329
  return {
6246
- id: sanitizeTerminalString(id),
7330
+ id: printable(id),
6247
7331
  ...name !== void 0 && { name },
6248
7332
  ...!selection.endpoint && { unusable: probeReason(selection.reason) }
6249
7333
  };
@@ -6253,7 +7337,7 @@ const configuredModels = (appConfig, config, catalog) => ["gptModel", "opusModel
6253
7337
  const selection = selectCopilotEndpoint(config, id);
6254
7338
  return {
6255
7339
  key,
6256
- value: sanitizeTerminalString(appConfig[key]),
7340
+ value: printable(appConfig[key]),
6257
7341
  advertised: catalog.models.has(id),
6258
7342
  ...!selection.endpoint && { unusable: probeReason(selection.reason) }
6259
7343
  };
@@ -6263,7 +7347,7 @@ const listingHeading = (search, result) => {
6263
7347
  if (result.models.length === 0) return "No models advertised by upstream.";
6264
7348
  return `Upstream-advertised models (${result.models.length}):`;
6265
7349
  }
6266
- const label = sanitizeTerminalString(search);
7350
+ const label = printable(search);
6267
7351
  if (result.found) return `Upstream models matching "${label}" (${result.models.length}):`;
6268
7352
  if (result.models.length === 0) return `No upstream model matches "${label}". List them all with copilot-relay models.`;
6269
7353
  return `No upstream model matches "${label}". Closest IDs (${result.models.length}):`;
@@ -6340,6 +7424,8 @@ const models = defineCommand({
6340
7424
  const appConfig$1 = await readAppConfig();
6341
7425
  setLogLevel(appConfig$1.logLevel);
6342
7426
  registerSensitiveOrigin(appConfig$1.copilotBaseUrl);
7427
+ registerLogSecret(appConfig$1.apiKey);
7428
+ configureUpstreamDispatcher(appConfig$1.upstreamProxy);
6343
7429
  const config$1 = readProxyConfig(appConfig$1);
6344
7430
  failure = "Could not authenticate with GitHub Copilot";
6345
7431
  await setupProxyAuth(config$1, args.deep ? { onDeviceCode: (url, code) => console.error(`Sign in: open ${terminalText(url)} and enter ${terminalText(code)}.`) } : void 0);
@@ -6370,7 +7456,7 @@ const models = defineCommand({
6370
7456
  const lines = [listingHeading(search, result), ...renderModelRows(result.models)];
6371
7457
  if (result.models.length > 0) lines.push("Inference, tool, and effort compatibility are not verified by this listing.", `Test one with copilot-relay models --deep --model ${result.chosen?.id ?? "<id>"}; it consumes Copilot usage.`);
6372
7458
  lines.push("", ...renderConfigGuide(paths.configPath, configuredModels(appConfig, config, catalog), result.chosen));
6373
- console.log(scrubSensitiveUrls(lines.join("\n")));
7459
+ console.log(lines.map((line) => scrubLogSecrets(scrubSensitiveUrls(terminalText(line)))).join("\n"));
6374
7460
  if (!result.found) process.exitCode = 1;
6375
7461
  } catch (error) {
6376
7462
  let detail = "Check configuration, authentication, and upstream connectivity.";
@@ -6935,8 +8021,8 @@ const replay = defineCommand({
6935
8021
  console.log(`${method} ${path$1} status=${status$1 ?? "unknown"} outcome=${outcome}`);
6936
8022
  console.log(`upstream=${routes$1.join(" -> ") || "none"}`);
6937
8023
  for (const exchange of result.summary.exchanges) {
6938
- const usage = Object.entries(exchange.usage).map(([key, value]) => `${key}=${value}`).join(" ");
6939
- console.log(`exchange=${exchange.order} path=${exchange.path} status=${exchange.status ?? "unknown"} outcome=${exchange.outcome}${usage ? ` ${usage}` : ""}${exchange.refusalCategory ? ` refusal_category=${exchange.refusalCategory}` : ""}`);
8024
+ const usage$1 = Object.entries(exchange.usage).map(([key, value]) => `${key}=${value}`).join(" ");
8025
+ console.log(`exchange=${exchange.order} path=${exchange.path} status=${exchange.status ?? "unknown"} outcome=${exchange.outcome}${usage$1 ? ` ${usage$1}` : ""}${exchange.refusalCategory ? ` refusal_category=${exchange.refusalCategory}` : ""}`);
6940
8026
  }
6941
8027
  if (result.summary.contentBlocks) console.log(`client_blocks=${result.summary.contentBlocks.join(",") || "none"} tool_calls=${result.summary.toolCalls ?? 0}`);
6942
8028
  }
@@ -6953,6 +8039,22 @@ const getRelayBaseUrl = (host, port) => {
6953
8039
  const hostname = new URL(`http://${authority}`).hostname;
6954
8040
  return `http://${loopbackForWildcard.get(hostname) ?? hostname}:${port}`;
6955
8041
  };
8042
+ const loopbackAddresses = new BlockList();
8043
+ loopbackAddresses.addSubnet("127.0.0.0", 8, "ipv4");
8044
+ loopbackAddresses.addAddress("::1", "ipv6");
8045
+ /**
8046
+ * True when a bind host accepts connections from this machine only.
8047
+ *
8048
+ * Literal loopback addresses and the name localhost qualify. Wildcards, other addresses and other
8049
+ * names do not: a name can resolve to any address, so it is treated as reachable from the network.
8050
+ */
8051
+ const isLoopbackHost = (host) => {
8052
+ const bare = host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host;
8053
+ if (bare.toLowerCase() === "localhost") return true;
8054
+ const family = isIP(bare);
8055
+ if (family === 0) return false;
8056
+ return loopbackAddresses.check(bare, family === 4 ? "ipv4" : "ipv6");
8057
+ };
6956
8058
 
6957
8059
  //#endregion
6958
8060
  //#region src/lib/lifecycle.ts
@@ -7389,18 +8491,20 @@ const knownModelEnvKeys = [
7389
8491
  "ANTHROPIC_SMALL_FAST_MODEL",
7390
8492
  "CLAUDE_CODE_SUBAGENT_MODEL"
7391
8493
  ];
7392
- const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
8494
+ const isRecord$1 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
7393
8495
  const hasMalformedModelOverride = (parsed) => {
7394
8496
  if (parsed.model !== void 0 && typeof parsed.model !== "string") return true;
7395
8497
  const env = parsed.env;
7396
- if (!isRecord(env)) return false;
8498
+ if (!isRecord$1(env)) return false;
7397
8499
  return knownModelEnvKeys.some((key) => env[key] !== void 0 && typeof env[key] !== "string");
7398
8500
  };
7399
8501
  const hasPrimaryModelOverride = (parsed, env) => typeof parsed.model === "string" || typeof env.ANTHROPIC_MODEL === "string";
7400
8502
  /**
7401
8503
  * Update `~/.claude/settings.json` so its env block points Claude Code at the
7402
8504
  * running proxy. Preserves unrelated keys and model choices while normalizing
7403
- * managed one-million-context GPT overrides. Sets 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.
7404
8508
  */
7405
8509
  async function applyClaudeConfig(input) {
7406
8510
  const { configPath: configPath$1, baseUrl, gptModel } = input;
@@ -7415,7 +8519,7 @@ async function applyClaudeConfig(input) {
7415
8519
  };
7416
8520
  if (raw.trim().length > 0) try {
7417
8521
  const value = JSON.parse(raw);
7418
- if (!isRecord(value) || value.env !== void 0 && !isRecord(value.env) || hasMalformedModelOverride(value)) return {
8522
+ if (!isRecord$1(value) || value.env !== void 0 && !isRecord$1(value.env) || hasMalformedModelOverride(value)) return {
7419
8523
  configPath: configPath$1,
7420
8524
  changed: false,
7421
8525
  created: false
@@ -7428,7 +8532,7 @@ async function applyClaudeConfig(input) {
7428
8532
  created: false
7429
8533
  };
7430
8534
  }
7431
- const env = isRecord(parsed.env) ? { ...parsed.env } : {};
8535
+ const env = isRecord$1(parsed.env) ? { ...parsed.env } : {};
7432
8536
  const previousBaseUrl = typeof env.ANTHROPIC_BASE_URL === "string" ? env.ANTHROPIC_BASE_URL : void 0;
7433
8537
  const hadPrimaryModelOverride = hasPrimaryModelOverride(parsed, env);
7434
8538
  const contextWindow = input.gptLimits?.max_context_window_tokens;
@@ -7437,9 +8541,11 @@ async function applyClaudeConfig(input) {
7437
8541
  const nextModel = typeof parsed.model === "string" ? normalizeClaudeModelId(parsed.model, contextWindow) : void 0;
7438
8542
  for (const key of knownModelEnvKeys) if (typeof env[key] === "string") env[key] = normalizeClaudeModelId(env[key], contextWindow);
7439
8543
  env.ANTHROPIC_BASE_URL = baseUrl;
7440
- if (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";
7441
8546
  if (contextWindow !== void 0 && env.CLAUDE_CODE_MAX_CONTEXT_TOKENS === void 0) env.CLAUDE_CODE_MAX_CONTEXT_TOKENS = String(contextWindow);
7442
8547
  if (input.maxOutputTokens !== void 0 && env.CLAUDE_CODE_MAX_OUTPUT_TOKENS === void 0) env.CLAUDE_CODE_MAX_OUTPUT_TOKENS = String(input.maxOutputTokens);
8548
+ if (env.CLAUDE_CODE_AUTO_MODE_SERVER === void 0) env.CLAUDE_CODE_AUTO_MODE_SERVER = "0";
7443
8549
  const next = {
7444
8550
  ...parsed,
7445
8551
  ...nextModel !== void 0 && { model: nextModel },
@@ -7447,13 +8553,15 @@ async function applyClaudeConfig(input) {
7447
8553
  env
7448
8554
  };
7449
8555
  const serialized = `${JSON.stringify(next, null, 2)}\n`;
7450
- 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 {
7451
8559
  configPath: configPath$1,
7452
8560
  changed: false,
7453
8561
  created: false,
7454
8562
  previousBaseUrl
7455
8563
  };
7456
- await writeFileSnapshot(snapshot, serialized);
8564
+ await writeFileSnapshot(snapshot, serialized, { ownerOnly: writesKey });
7457
8565
  return {
7458
8566
  configPath: configPath$1,
7459
8567
  changed: true,
@@ -7524,6 +8632,34 @@ const validateUpstream = async (config, thinkEffort) => {
7524
8632
  //#endregion
7525
8633
  //#region src/start.ts
7526
8634
  const canCloseConnections = (server) => typeof server.closeIdleConnections === "function" && typeof server.closeAllConnections === "function";
8635
+ /**
8636
+ * The startup warning for a listener that other machines may reach without a key.
8637
+ *
8638
+ * A warning rather than a refusal, so an existing `host: 0.0.0.0` setup keeps
8639
+ * working after an upgrade. It names the apiKey setting and never prints a key.
8640
+ */
8641
+ const unauthenticatedListenerWarning = (config) => {
8642
+ if (config.apiKey || isLoopbackHost(config.host)) return;
8643
+ return terminalText(`apiKey is empty and host ${config.host} is not a loopback address: any client that can reach port ${config.port} can consume your Copilot usage. Set apiKey in ${paths.configPath}, or bind host to 127.0.0.1.`);
8644
+ };
8645
+ /**
8646
+ * The warning for a config.yaml that other local users can access while it holds the apiKey.
8647
+ *
8648
+ * A new config.yaml is created 0600, but an existing one keeps its mode, and the relay never
8649
+ * changes the mode of the user's own file. stat follows a symlink, so the mode checked is the
8650
+ * target's. Windows has no POSIX mode bits. It never prints a key.
8651
+ */
8652
+ const exposedConfigWarning = (config, configPath$1 = paths.configPath) => {
8653
+ if (!config.apiKey || process.platform === "win32") return;
8654
+ let mode;
8655
+ try {
8656
+ mode = statSync(configPath$1).mode & 511;
8657
+ } catch {
8658
+ return;
8659
+ }
8660
+ if ((mode & 63) === 0) return;
8661
+ return terminalText(`${configPath$1} holds the apiKey and has mode ${mode.toString(8).padStart(4, "0")}, which lets other local users access it. Run chmod 600 ${configPath$1}.`);
8662
+ };
7527
8663
  async function startRelay(appConfig) {
7528
8664
  appConfig ??= await readAppConfig();
7529
8665
  appConfig = {
@@ -7533,10 +8669,12 @@ async function startRelay(appConfig) {
7533
8669
  setLogLevel(appConfig.logLevel);
7534
8670
  await cleanupLogs(appConfig.logRetentionDays);
7535
8671
  await cleanupCaptures(appConfig.logRetentionDays);
8672
+ configureUpstreamDispatcher(appConfig.upstreamProxy);
7536
8673
  const claudeConfigPath$1 = claudeConfigPath;
7537
8674
  const config = readProxyConfig(appConfig);
7538
8675
  const applyRuntimeConfig = (nextConfig) => {
7539
8676
  registerSensitiveOrigin(nextConfig.copilotBaseUrl);
8677
+ registerLogSecret(nextConfig.apiKey);
7540
8678
  setLogLevel(nextConfig.logLevel);
7541
8679
  cleanupLogs(nextConfig.logRetentionDays).catch(() => log.error("Log retention failed; existing logs retained."));
7542
8680
  cleanupCaptures(nextConfig.logRetentionDays);
@@ -7550,6 +8688,7 @@ async function startRelay(appConfig) {
7550
8688
  config.upstreamTimeoutMs = nextConfig.upstreamTimeoutSeconds * 1e3;
7551
8689
  config.webSearchBackend = nextConfig.webSearchBackend;
7552
8690
  config.claudeUpstreamApi = nextConfig.claudeUpstreamApi;
8691
+ config.apiKey = nextConfig.apiKey;
7553
8692
  runtimeState.modelRouting = {
7554
8693
  gptModel: nextConfig.gptModel,
7555
8694
  opusModel: nextConfig.opusModel
@@ -7559,6 +8698,11 @@ async function startRelay(appConfig) {
7559
8698
  log.info(`Log level: ${appConfig.logLevel}`);
7560
8699
  log.info(`Default think effort: ${appConfig.thinkEffort}`);
7561
8700
  log.info(`Upstream timeout: ${appConfig.upstreamTimeoutSeconds}s`);
8701
+ if (appConfig.upstreamProxy !== void 0) log.info(`Upstream proxy: ${formatUpstreamProxyForDisplay(appConfig.upstreamProxy)}`);
8702
+ const listenerWarning = unauthenticatedListenerWarning(appConfig);
8703
+ if (listenerWarning) log.error(listenerWarning);
8704
+ const configWarning = exposedConfigWarning(appConfig);
8705
+ if (configWarning) log.error(configWarning);
7562
8706
  const authSession = await setupProxyAuth(config);
7563
8707
  try {
7564
8708
  await validateUpstream(config, appConfig.thinkEffort);
@@ -7585,6 +8729,7 @@ async function startRelay(appConfig) {
7585
8729
  const gptLimits = getCachedCopilotModel(config, appConfig.gptModel)?.limits;
7586
8730
  const opusLimits = getCachedCopilotModel(config, appConfig.opusModel)?.limits;
7587
8731
  const claudeResult = await applyClaudeConfig({
8732
+ apiKey: appConfig.apiKey,
7588
8733
  baseUrl,
7589
8734
  configPath: claudeConfigPath$1,
7590
8735
  gptModel: appConfig.gptModel,
@@ -7599,8 +8744,11 @@ async function startRelay(appConfig) {
7599
8744
  log.error(`Could not update claude settings (${claudeConfigPath$1}):`, error);
7600
8745
  }
7601
8746
  const configWatcher = watchAppConfig((nextConfig) => {
8747
+ const previousApiKey = config.apiKey;
7602
8748
  applyRuntimeConfig(nextConfig);
7603
8749
  log.info(`Config reloaded: logLevel=${nextConfig.logLevel} thinkEffort=${nextConfig.thinkEffort} upstreamTimeoutSeconds=${nextConfig.upstreamTimeoutSeconds}`);
8750
+ const reloadedConfigWarning = nextConfig.apiKey === previousApiKey ? void 0 : exposedConfigWarning(nextConfig);
8751
+ if (reloadedConfigWarning) log.error(reloadedConfigWarning);
7604
8752
  });
7605
8753
  const shutdownGraceMs = 2e3;
7606
8754
  const shutdown = (signal) => {
@@ -7683,7 +8831,9 @@ const localProbeTimeoutMs = 5e3;
7683
8831
  const deepProbeTimeoutMs = 6e4;
7684
8832
  const toStatusConfig = (config) => ({
7685
8833
  ...config,
8834
+ apiKey: config.apiKey ? "[redacted]" : "",
7686
8835
  copilotBaseUrl: formatUrlForDisplay(config.copilotBaseUrl),
8836
+ upstreamProxy: config.upstreamProxy === void 0 ? null : formatUpstreamProxyForDisplay(config.upstreamProxy),
7687
8837
  webSearchBackend: config.webSearchBackend ?? null,
7688
8838
  claudeUpstreamApi: config.claudeUpstreamApi ?? "chat-completions"
7689
8839
  });
@@ -7737,27 +8887,35 @@ const formatUptime = (startedAt) => {
7737
8887
  const configRowOrder = {
7738
8888
  host: 0,
7739
8889
  port: 1,
7740
- copilotBaseUrl: 2,
7741
- claudeSetup: 3,
7742
- logLevel: 4,
7743
- logRetentionDays: 5,
7744
- thinkEffort: 6,
7745
- upstreamTimeoutSeconds: 7,
7746
- webSearchBackend: 8,
7747
- gptModel: 9,
7748
- opusModel: 10,
7749
- 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
7750
8902
  };
7751
8903
  const configRowKeys = Object.keys(configRowOrder).sort((a, b) => configRowOrder[a] - configRowOrder[b]);
8904
+ const unsetConfigText = {
8905
+ apiKey: "(unset — clients need no key)",
8906
+ upstreamProxy: "(unset — connects directly)",
8907
+ webSearchBackend: "(unset — uses gptModel)"
8908
+ };
7752
8909
  /**
7753
8910
  * The resolved config, one key per line.
7754
8911
  *
7755
8912
  * These are the values on disk. That is not the same question as "what is the
7756
8913
  * running daemon honouring", and the footnote says so rather than leaving it
7757
- * implied: applyRuntimeConfig() in start.ts never reads claudeSetup and
7758
- * deliberately does not rebind the listening socket when host or port
7759
- * changes; it hot-reloads the rest. Printing every key without that line
7760
- * 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.
7761
8919
  *
7762
8920
  * Only shown when running, because with nothing up every value applies at the
7763
8921
  * next start and the note would be noise.
@@ -7765,10 +8923,10 @@ const configRowKeys = Object.keys(configRowOrder).sort((a, b) => configRowOrder[
7765
8923
  const renderConfig = (config, running) => {
7766
8924
  const lines = configRowKeys.map((key) => {
7767
8925
  const value = config[key];
7768
- const rendered = key === "webSearchBackend" && value === null ? "(unset — uses gptModel)" : String(value);
8926
+ const rendered = (value === null || value === "" ? unsetConfigText[key] : void 0) ?? String(value);
7769
8927
  return ` ${key.padEnd(24)}${sanitizeTerminalString(rendered)}`;
7770
8928
  });
7771
- if (running) lines.push(" host, port and 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.");
7772
8930
  return lines;
7773
8931
  };
7774
8932
  /**
@@ -7796,9 +8954,26 @@ const renderStatus = (status$1, color = false) => {
7796
8954
  if (hasVersionMismatch(status$1)) lines.push("", ` The running relay is ${status$1.daemonVersion}; ${status$1.version} is installed.`, " Restart it to serve the installed version: copilot-relay restart");
7797
8955
  return lines;
7798
8956
  };
8957
+ let relayAgent;
8958
+ /**
8959
+ * How the probes reach the relay's own listener: undici's fetch with a direct Agent of their own,
8960
+ * never the global dispatcher and never the upstream one. With NODE_USE_ENV_PROXY=1, Node's fetch
8961
+ * sends even a request to localhost through HTTP_PROXY unless NO_PROXY exempts it, so through a
8962
+ * proxy that refuses, status would report a healthy relay as unusable. upstreamProxy is for
8963
+ * Copilot and GitHub, not for this machine.
8964
+ *
8965
+ * An object, so a test can replace fetch.
8966
+ */
8967
+ const relayListener = { fetch: (url, init) => {
8968
+ relayAgent ??= new Agent();
8969
+ return fetch(url, {
8970
+ ...init,
8971
+ dispatcher: relayAgent
8972
+ });
8973
+ } };
7799
8974
  const probe = async (url, init, timeoutMs) => {
7800
8975
  const started = performance.now();
7801
- const response = await fetch(url, {
8976
+ const response = await relayListener.fetch(url, {
7802
8977
  ...init,
7803
8978
  signal: AbortSignal.timeout(timeoutMs)
7804
8979
  });
@@ -7843,9 +9018,10 @@ const checkHealth = async (baseUrl) => {
7843
9018
  } };
7844
9019
  }
7845
9020
  };
7846
- const readModels = async (baseUrl) => {
9021
+ const relayKeyHeaders = (apiKey) => apiKey ? { "x-api-key": apiKey } : {};
9022
+ const readModels = async (baseUrl, apiKey) => {
7847
9023
  try {
7848
- const data = (await probe(`${baseUrl}/v1/models`, {}, localProbeTimeoutMs)).body?.data;
9024
+ const data = (await probe(`${baseUrl}/v1/models`, { headers: relayKeyHeaders(apiKey) }, localProbeTimeoutMs)).body?.data;
7849
9025
  return Array.isArray(data) ? data.flatMap((model) => typeof model.id === "string" ? [model.id] : []) : [];
7850
9026
  } catch {
7851
9027
  return [];
@@ -7857,7 +9033,7 @@ const readModels = async (baseUrl) => {
7857
9033
  * Opt-in because it spends tokens. It is the only check that exercises token
7858
9034
  * refresh, the upstream call, and translation back to Claude shape.
7859
9035
  */
7860
- const checkDeep = async (baseUrl, model) => {
9036
+ const checkDeep = async (baseUrl, model, apiKey) => {
7861
9037
  try {
7862
9038
  const result = await probe(`${baseUrl}/v1/messages`, {
7863
9039
  body: JSON.stringify({
@@ -7870,7 +9046,8 @@ const checkDeep = async (baseUrl, model) => {
7870
9046
  }),
7871
9047
  headers: {
7872
9048
  "anthropic-version": "2023-06-01",
7873
- "content-type": "application/json"
9049
+ "content-type": "application/json",
9050
+ ...relayKeyHeaders(apiKey)
7874
9051
  },
7875
9052
  method: "POST"
7876
9053
  }, deepProbeTimeoutMs);
@@ -7913,6 +9090,7 @@ const collectStatus = async (options) => {
7913
9090
  });
7914
9091
  setLogLevel(appConfig.logLevel);
7915
9092
  registerSensitiveOrigin(appConfig.copilotBaseUrl);
9093
+ registerLogSecret(appConfig.apiKey);
7916
9094
  const base = {
7917
9095
  config: toStatusConfig(appConfig),
7918
9096
  configPath: paths.configPath,
@@ -7928,8 +9106,8 @@ const collectStatus = async (options) => {
7928
9106
  };
7929
9107
  const baseUrl = getRelayBaseUrl(relay.host, relay.port);
7930
9108
  const health = await checkHealth(baseUrl);
7931
- const models$1 = health.result.ok ? await readModels(baseUrl) : [];
7932
- const deep = options.deep && health.result.ok ? await checkDeep(baseUrl, models$1[0] ?? appConfig.gptModel) : void 0;
9109
+ const models$1 = health.result.ok ? await readModels(baseUrl, appConfig.apiKey) : [];
9110
+ const deep = options.deep && health.result.ok ? await checkDeep(baseUrl, models$1[0] ?? appConfig.gptModel, appConfig.apiKey) : void 0;
7933
9111
  const daemonVersion = health.version ?? relay.version;
7934
9112
  return {
7935
9113
  ...base,
@@ -7974,8 +9152,8 @@ const status = defineCommand({
7974
9152
  process.exitCode = exitCodes.notUsable;
7975
9153
  return;
7976
9154
  }
7977
- if (args.json) console.log(scrubSensitiveUrls(JSON.stringify(result, null, 2)));
7978
- else console.log(scrubSensitiveUrls(renderStatus(result, colorEnabled()).join("\n")));
9155
+ if (args.json) console.log(scrubLogSecrets(scrubSensitiveUrls(withoutHiddenCharacters(JSON.stringify(result, null, 2)))));
9156
+ else console.log(scrubLogSecrets(scrubSensitiveUrls(withoutHiddenCharacters(renderStatus(result, colorEnabled()).join("\n")))));
7979
9157
  process.exitCode = resolveExitCode(result);
7980
9158
  }
7981
9159
  });
@@ -8002,6 +9180,225 @@ const stop = defineCommand({
8002
9180
  }
8003
9181
  });
8004
9182
 
9183
+ //#endregion
9184
+ //#region src/lib/usage.ts
9185
+ const knownQuotaIds = [
9186
+ "chat",
9187
+ "completions",
9188
+ "premium_interactions"
9189
+ ];
9190
+ /** A failure the command reports. The message is one printable line and never holds the token. */
9191
+ var CopilotUsageError = class extends Error {};
9192
+ const notAnObject = "GitHub's answer to the usage request was not a JSON object.";
9193
+ const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
9194
+ const stringField = (value) => typeof value === "string" ? terminalText(value) : null;
9195
+ const numberField = (value) => typeof value === "number" && Number.isFinite(value) ? value : null;
9196
+ const booleanField = (value) => typeof value === "boolean" ? value : null;
9197
+ const parseSnapshot = (value) => {
9198
+ if (!isRecord(value)) return null;
9199
+ return {
9200
+ unlimited: booleanField(value.unlimited),
9201
+ entitlement: numberField(value.entitlement),
9202
+ remaining: numberField(value.remaining),
9203
+ percent_remaining: numberField(value.percent_remaining),
9204
+ overage_permitted: booleanField(value.overage_permitted),
9205
+ overage_count: numberField(value.overage_count),
9206
+ token_based_billing: booleanField(value.token_based_billing),
9207
+ credits_used: numberField(value.credits_used)
9208
+ };
9209
+ };
9210
+ /**
9211
+ * The plan and quota fields of a copilot_internal/user answer, each checked on its own.
9212
+ *
9213
+ * Throws CopilotUsageError when the answer is not a JSON object at all.
9214
+ */
9215
+ const parseCopilotUsage = (body) => {
9216
+ if (!isRecord(body)) throw new CopilotUsageError(notAnObject);
9217
+ const quota = body.quota_snapshots;
9218
+ const reported = isRecord(quota) ? quota : {};
9219
+ const snapshots = /* @__PURE__ */ new Map();
9220
+ for (const id of knownQuotaIds) snapshots.set(id, parseSnapshot(reported[id]));
9221
+ for (const [rawId, value] of Object.entries(reported)) {
9222
+ const id = terminalText(rawId);
9223
+ if (id === "" || snapshots.has(id)) continue;
9224
+ snapshots.set(id, parseSnapshot(value));
9225
+ }
9226
+ return {
9227
+ copilot_plan: stringField(body.copilot_plan),
9228
+ access_type_sku: stringField(body.access_type_sku),
9229
+ quota_reset_date: stringField(body.quota_reset_date),
9230
+ quota_snapshots: Object.fromEntries(snapshots)
9231
+ };
9232
+ };
9233
+ const notReported = "not reported";
9234
+ const formatNumber = (value) => value === null ? "?" : String(value);
9235
+ const formatAllowance = (snapshot) => {
9236
+ if (snapshot.unlimited === true) return "unlimited";
9237
+ return `${`${formatNumber(snapshot.remaining)} of ${formatNumber(snapshot.entitlement)} remaining`} (${formatNumber(snapshot.percent_remaining)}%)`;
9238
+ };
9239
+ const formatOverage = (snapshot) => {
9240
+ let permitted = "overage ?";
9241
+ if (snapshot.overage_permitted === true) permitted = "overage permitted";
9242
+ else if (snapshot.overage_permitted === false) permitted = "overage not permitted";
9243
+ return `${permitted}, overage count ${formatNumber(snapshot.overage_count)}`;
9244
+ };
9245
+ const formatQuota = (snapshot) => {
9246
+ const parts = [formatAllowance(snapshot), formatOverage(snapshot)];
9247
+ if (snapshot.credits_used !== null) parts.push(`credits used ${formatNumber(snapshot.credits_used)}`);
9248
+ return parts.join("; ");
9249
+ };
9250
+ /** The report as text lines. Kept free of IO, so it is tested without a terminal. */
9251
+ const renderCopilotUsage = (usage$1, color = false) => {
9252
+ const shown = (value) => value ?? colorText(notReported, "muted", color);
9253
+ const headings = [
9254
+ ["Plan", usage$1.copilot_plan],
9255
+ ["SKU", usage$1.access_type_sku],
9256
+ ["Quota reset", usage$1.quota_reset_date]
9257
+ ];
9258
+ const labelWidth = Math.max(...headings.map(([label]) => label.length));
9259
+ const known = [...knownQuotaIds];
9260
+ const ids = [...known, ...Object.keys(usage$1.quota_snapshots).filter((id) => !known.includes(id))];
9261
+ const idWidth = Math.max(...ids.map((id) => id.length));
9262
+ const quotaLines = ids.map((id) => {
9263
+ const snapshot = usage$1.quota_snapshots[id] ?? null;
9264
+ const text$1 = snapshot === null ? colorText(notReported, "muted", color) : formatQuota(snapshot);
9265
+ return `${id.padEnd(idWidth)} ${text$1}`;
9266
+ });
9267
+ return [
9268
+ ...headings.map(([label, value]) => `${label.padEnd(labelWidth)} ${shown(value)}`),
9269
+ "",
9270
+ ...quotaLines
9271
+ ];
9272
+ };
9273
+ const requestTimeoutSeconds = 30;
9274
+ const tokenInAnswer = "GitHub's answer to the usage request contains the stored token, so none of it is printed.";
9275
+ const visibleToken = (token) => terminalText(token);
9276
+ const withoutToken = (line, token) => {
9277
+ const visible = visibleToken(token);
9278
+ return visible === "" ? line : line.replaceAll(visible, "[redacted]");
9279
+ };
9280
+ const lettersAndDigits = (text$1) => text$1.replace(/[^A-Za-z0-9]/g, "");
9281
+ const minimumSpelledLength = 16;
9282
+ const spellsToken = (text$1, token) => {
9283
+ const spelled = lettersAndDigits(visibleToken(token));
9284
+ return spelled.length >= minimumSpelledLength && lettersAndDigits(text$1).includes(spelled);
9285
+ };
9286
+ const showsToken = (usage$1, token) => {
9287
+ const visible = visibleToken(token);
9288
+ const printed = [
9289
+ usage$1.copilot_plan,
9290
+ usage$1.access_type_sku,
9291
+ usage$1.quota_reset_date,
9292
+ ...Object.keys(usage$1.quota_snapshots)
9293
+ ].filter((text$1) => text$1 !== null);
9294
+ if (visible !== "" && printed.some((text$1) => text$1.includes(visible))) return true;
9295
+ return spellsToken(printed.join(""), token);
9296
+ };
9297
+ const failureReason = (error) => {
9298
+ const cause = error instanceof Error ? error.cause : void 0;
9299
+ if (cause instanceof Error && cause.message !== "") return cause.message;
9300
+ const code = cause?.code;
9301
+ if (typeof code === "string") return code;
9302
+ return error instanceof Error && error.message !== "" ? error.message : String(error);
9303
+ };
9304
+ const isTimeout = (error) => typeof error === "object" && error !== null && "name" in error && error.name === "TimeoutError";
9305
+ const describeRequestFailure = async (error, token) => {
9306
+ if (error instanceof HTTPError) {
9307
+ await error.response.body?.cancel().catch(() => {});
9308
+ const status$1 = error.response.status;
9309
+ if (status$1 === 401 || status$1 === 403) return new CopilotUsageError(`GitHub rejected the stored token (HTTP ${status$1}). Sign in again with copilot-relay auth.`);
9310
+ return new CopilotUsageError(`GitHub answered the usage request with HTTP ${status$1}.`);
9311
+ }
9312
+ if (isTimeout(error)) return new CopilotUsageError(`GitHub did not answer within ${requestTimeoutSeconds} seconds.`);
9313
+ if (error instanceof SyntaxError) return new CopilotUsageError(notAnObject);
9314
+ const line = withoutToken(terminalText(`Could not reach GitHub: ${failureReason(error)}`), token);
9315
+ return new CopilotUsageError(spellsToken(line, token) ? "Could not reach GitHub." : line);
9316
+ };
9317
+ const readToken = async () => {
9318
+ let token;
9319
+ try {
9320
+ token = await readStoredGitHubToken();
9321
+ } catch (error) {
9322
+ const code = error?.code;
9323
+ const reason = typeof code === "string" ? `: ${code}` : "";
9324
+ throw new CopilotUsageError(terminalText(`Could not read the GitHub token at ${paths.githubTokenPath}${reason}.`));
9325
+ }
9326
+ if (token === void 0) throw new CopilotUsageError(terminalText(`No GitHub token is stored at ${paths.githubTokenPath}. Sign in with copilot-relay auth.`));
9327
+ return token;
9328
+ };
9329
+ /**
9330
+ * Builds this process's upstream dispatcher from upstreamProxy, as start, auth and models do, but
9331
+ * reads config.yaml without creating, completing or rewriting it. Without a config.yaml the
9332
+ * request connects directly.
9333
+ *
9334
+ * A config.yaml that cannot be read or is invalid gets one fixed line, whatever the reason, so no
9335
+ * text from that file can reach the terminal: it can hold credentials, such as an upstreamProxy
9336
+ * password.
9337
+ */
9338
+ const applyUpstreamProxy = async () => {
9339
+ let upstreamProxy;
9340
+ try {
9341
+ upstreamProxy = (await readExistingAppConfig())?.upstreamProxy;
9342
+ } catch {
9343
+ throw new CopilotUsageError(terminalText(`Could not read the config at ${paths.configPath}; fix it, then run copilot-relay usage again.`));
9344
+ }
9345
+ try {
9346
+ configureUpstreamDispatcher(upstreamProxy);
9347
+ } catch (error) {
9348
+ if (error instanceof InvalidProxyEnvironmentError) throw new CopilotUsageError(error.message);
9349
+ throw error;
9350
+ }
9351
+ };
9352
+ const requestCopilotUsage = async () => {
9353
+ const token = await readToken();
9354
+ await applyUpstreamProxy();
9355
+ let body;
9356
+ try {
9357
+ body = await getCopilotUsage(token, vscodeVersion, AbortSignal.timeout(requestTimeoutSeconds * 1e3));
9358
+ } catch (error) {
9359
+ throw await describeRequestFailure(error, token);
9360
+ }
9361
+ const usage$1 = parseCopilotUsage(body);
9362
+ if (showsToken(usage$1, token)) throw new CopilotUsageError(tokenInAnswer);
9363
+ return usage$1;
9364
+ };
9365
+ /**
9366
+ * The Copilot plan and quota of the stored GitHub token's account.
9367
+ *
9368
+ * Reads the token file and config.yaml, and asks GitHub through upstreamProxy; writes nothing.
9369
+ * Logging is off throughout, so a line the upstream request would log, such as the hint about an
9370
+ * ignored proxy variable, never reaches the log file. Every failure becomes a CopilotUsageError
9371
+ * whose message is the line to print.
9372
+ */
9373
+ const loadCopilotUsage = () => withoutLogging(requestCopilotUsage);
9374
+
9375
+ //#endregion
9376
+ //#region src/usage.ts
9377
+ const usage = defineCommand({
9378
+ meta: {
9379
+ name: "usage",
9380
+ description: "Show the Copilot plan and quota GitHub reports for the stored GitHub token."
9381
+ },
9382
+ args: { json: {
9383
+ description: "Print the plan and quota fields as a JSON object.",
9384
+ type: "boolean"
9385
+ } },
9386
+ async run({ args }) {
9387
+ try {
9388
+ const report = await loadCopilotUsage();
9389
+ if (args.json) console.log(JSON.stringify(report, null, 2));
9390
+ else console.log(renderCopilotUsage(report, colorEnabled()).join("\n"));
9391
+ } catch (error) {
9392
+ if (error instanceof CopilotUsageError) console.error(error.message);
9393
+ else {
9394
+ const name = error instanceof Error ? error.name : typeof error;
9395
+ console.error(terminalText(`Could not show the Copilot usage: unexpected ${name}.`));
9396
+ }
9397
+ process.exitCode = 1;
9398
+ }
9399
+ }
9400
+ });
9401
+
8005
9402
  //#endregion
8006
9403
  //#region src/main.ts
8007
9404
  await runMain(defineCommand({
@@ -8011,12 +9408,14 @@ await runMain(defineCommand({
8011
9408
  },
8012
9409
  subCommands: {
8013
9410
  auth,
9411
+ cache,
8014
9412
  models,
8015
9413
  replay,
8016
9414
  restart,
8017
9415
  start,
8018
9416
  status,
8019
- stop
9417
+ stop,
9418
+ usage
8020
9419
  }
8021
9420
  }));
8022
9421