@dianshuv/copilot-api 0.15.1 → 0.17.0

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.
Files changed (3) hide show
  1. package/README.md +3 -2
  2. package/dist/main.mjs +174 -168
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -16,7 +16,7 @@
16
16
  - **Stream repetition detection**: Detects when models get stuck in repetitive output loops using KMP-based pattern matching and logs a warning.
17
17
  - **Stale request reaping**: Automatically force-fails requests that exceed a configurable maximum age (default 600s) to prevent resource leaks.
18
18
  - **PostHog analytics**: Optional PostHog Cloud integration (`--posthog-key`) sends per-request token usage events for long-term trend analysis. Free tier (1M events/month) is more than sufficient for individual use.
19
- - **GitHub Copilot CLI emulation**: All upstream requests to GitHub — device-flow `login`, Copilot token exchange, and model calls — carry the official GitHub Copilot CLI's (`@github/copilot`) identity: its `copilot-integration-id` (`copilot-developer-cli`), `editor-version`/`user-agent` (`copilot/<version>`), `x-github-api-version`, and a persistent `x-client-machine-id` (stored at `~/.local/share/copilot-api/machine_id`). The `login` flow uses the CLI's own OAuth app, so **new** logins request the `read:user`, `read:org`, `repo`, and `gist` scopes; existing tokens keep working without re-authentication.
19
+ - **GitHub Copilot CLI emulation**: All upstream requests to GitHub — device-flow `login`, `/copilot_internal/user` bootstrap, and CAPI model/chat calls — carry the official GitHub Copilot CLI's (`@github/copilot`) identity: its `copilot-integration-id` (`copilot-developer-cli`), `editor-version`/`user-agent` (`copilot/<version>`), `x-github-api-version`, and a persistent `x-client-machine-id` (stored at `~/.local/share/copilot-api/machine_id`). The CAPI Bearer is the GitHub OAuth token itself — the CLI does not perform a separate token exchange. The `login` flow uses the CLI's own OAuth app, so **new** logins request the `read:user`, `read:org`, `repo`, and `gist` scopes; existing tokens keep working without re-authentication.
20
20
 
21
21
  ## Quick Start
22
22
 
@@ -63,6 +63,7 @@ make down
63
63
  | `--github-token`, `-g` | Provide GitHub token directly (or `GH_TOKEN` env) | none |
64
64
  | `--posthog-key` | PostHog API key for token usage analytics (opt-in) | none |
65
65
  | `--api-key` | Proxy API key for inbound authentication (see [Authentication](#authentication)). Empty = disabled | none |
66
+ | `--allow-token-endpoint` | Opt-in to expose `GET /token` (echoes the raw GitHub OAuth token; requires `--api-key` too). `COPILOT_ALLOW_TOKEN_ENDPOINT=1` env twin. | false |
66
67
 
67
68
  ### Hidden Models
68
69
 
@@ -114,7 +115,7 @@ Currently hidden (grouped):
114
115
  |----------|--------|-------------|
115
116
  | `/` | GET | Server status |
116
117
  | `/usage` | GET | Copilot usage stats |
117
- | `/token` | GET | Current Copilot token |
118
+ | `/token` | GET | Current CAPI Bearer (= raw GitHub OAuth token). Disabled by default; requires `--allow-token-endpoint` **and** `--api-key`, otherwise 403. |
118
119
  | `/health` | GET | Health check |
119
120
  | `/history` | GET | Request history Web UI with token analytics (enabled by default) |
120
121
  | `/history/api/*` | GET/DELETE | History API endpoints |
package/dist/main.mjs CHANGED
@@ -140,6 +140,7 @@ const state = {
140
140
  showToken: false,
141
141
  showAllModels: false,
142
142
  verbose: false,
143
+ allowTokenEndpoint: false,
143
144
  autoTruncate: true,
144
145
  compressToolResults: false,
145
146
  redirectAnthropic: false,
@@ -165,7 +166,10 @@ const COPILOT_INTEGRATION_ID = "copilot-developer-cli";
165
166
  const cliVersion = (state) => state.copilotCliVersion ?? CLI_VERSION_FALLBACK;
166
167
  const editorVersion = (state) => `copilot/${cliVersion(state)}`;
167
168
  const userAgent = (state) => `copilot/${cliVersion(state)} (${process.platform} ${process.version}) term/${process.env.TERM_PROGRAM ?? "unknown"}`;
168
- const copilotBaseUrl = (state) => state.accountType === "individual" ? "https://api.githubcopilot.com" : `https://api.${state.accountType}.githubcopilot.com`;
169
+ const copilotBaseUrl = (state) => {
170
+ if (state.copilotApiEndpoint) return state.copilotApiEndpoint;
171
+ return state.accountType === "individual" ? "https://api.githubcopilot.com" : `https://api.${state.accountType}.githubcopilot.com`;
172
+ };
169
173
  function hasHeaderKey(headers, key) {
170
174
  const lowerKey = key.toLowerCase();
171
175
  return Object.keys(headers).some((existingKey) => {
@@ -465,14 +469,6 @@ function forwardError(c, error) {
465
469
  } }, 500);
466
470
  }
467
471
 
468
- //#endregion
469
- //#region src/services/github/get-copilot-token.ts
470
- const getCopilotToken = async () => {
471
- const response = await fetch(`${GITHUB_API_BASE_URL}/copilot_internal/v2/token`, { headers: githubHeaders(state) });
472
- if (!response.ok) throw await HTTPError.fromResponse("Failed to get Copilot token", response);
473
- return await response.json();
474
- };
475
-
476
472
  //#endregion
477
473
  //#region src/services/github/get-device-code.ts
478
474
  async function getDeviceCode() {
@@ -567,48 +563,28 @@ function isRetryable(error) {
567
563
  return code !== void 0 && RETRYABLE_CAUSE_CODES.has(code);
568
564
  }
569
565
  const RETRY_ATTEMPTS_KEY = "__copilotRetryAttempts";
570
- const RETRY_AUTH_REFRESHED_KEY = "__copilotRetryAuthRefreshed";
571
566
  /**
572
567
  * Read retry metadata recorded on an error or response by fetchWithRetry.
573
568
  * Returns attempts=1 (no retry) if the value is missing or malformed.
574
569
  */
575
570
  function getRetryAttempts(target) {
576
- if (typeof target !== "object" || target === null) return {
577
- attempts: 1,
578
- authRefreshed: false
579
- };
571
+ if (typeof target !== "object" || target === null) return { attempts: 1 };
580
572
  const meta = target;
581
- return {
582
- attempts: typeof meta[RETRY_ATTEMPTS_KEY] === "number" ? meta[RETRY_ATTEMPTS_KEY] : 1,
583
- authRefreshed: meta[RETRY_AUTH_REFRESHED_KEY] === true
584
- };
573
+ return { attempts: typeof meta[RETRY_ATTEMPTS_KEY] === "number" ? meta[RETRY_ATTEMPTS_KEY] : 1 };
585
574
  }
586
- async function fetchWithRetry(input, init, options) {
587
- let currentInit = init;
588
- let authRefreshed = false;
575
+ async function fetchWithRetry(input, init) {
589
576
  let networkAttempts = 0;
590
577
  for (let attempt = 0; attempt <= RETRY_DELAYS_MS.length; attempt++) try {
591
578
  networkAttempts++;
592
579
  const fetchStart = performance.now();
593
- const response = await fetch(input, currentInit);
580
+ const response = await fetch(input, init);
594
581
  const ttfbMs = performance.now() - fetchStart;
595
- if (response.status === 401 && !authRefreshed && options?.onUnauthorized) {
596
- const refreshed = await tryRefreshAuth(options.onUnauthorized);
597
- if (refreshed) {
598
- await response.body?.cancel().catch(() => {});
599
- consola.warn("Got 401 from upstream; refreshed token and retrying");
600
- currentInit = refreshed;
601
- authRefreshed = true;
602
- attempt--;
603
- continue;
604
- }
605
- }
606
582
  addTiming(TIMING.UPSTREAM_TTFB, ttfbMs);
607
- annotateRetryMeta(response, networkAttempts, authRefreshed);
583
+ annotateRetryMeta(response, networkAttempts);
608
584
  return response;
609
585
  } catch (error) {
610
586
  if (attempt === RETRY_DELAYS_MS.length || !isRetryable(error)) {
611
- annotateRetryMeta(error, networkAttempts, authRefreshed);
587
+ annotateRetryMeta(error, networkAttempts);
612
588
  throw error;
613
589
  }
614
590
  const delay = RETRY_DELAYS_MS[attempt];
@@ -617,54 +593,31 @@ async function fetchWithRetry(input, init, options) {
617
593
  }
618
594
  throw new Error("fetchWithRetry exhausted attempts without resolution");
619
595
  }
620
- function annotateRetryMeta(target, attempts, authRefreshed) {
596
+ function annotateRetryMeta(target, attempts) {
621
597
  if (typeof target !== "object" || target === null) return;
622
598
  try {
623
599
  const meta = target;
624
600
  meta[RETRY_ATTEMPTS_KEY] = attempts;
625
- meta[RETRY_AUTH_REFRESHED_KEY] = authRefreshed;
626
601
  } catch {}
627
602
  }
628
- async function tryRefreshAuth(onUnauthorized) {
629
- try {
630
- return await onUnauthorized();
631
- } catch (error) {
632
- consola.warn("onUnauthorized callback failed:", error instanceof Error ? error.message : error);
633
- return null;
634
- }
635
- }
636
- /**
637
- * Build an onUnauthorized callback that refreshes the Copilot token and
638
- * returns a new RequestInit with an updated Authorization header.
639
- */
640
- function makeCopilotAuthRetry(refreshToken, init) {
641
- return async () => {
642
- const newToken = await refreshToken();
643
- if (!newToken) return null;
644
- const headers = new Headers(init.headers);
645
- headers.set("Authorization", `Bearer ${newToken}`);
646
- return {
647
- ...init,
648
- headers
649
- };
650
- };
651
- }
652
603
 
653
604
  //#endregion
654
605
  //#region src/services/copilot/copilot-fetch.ts
655
606
  /**
656
607
  * Single transport seam for all upstream Copilot API calls.
657
608
  *
658
- * Wraps every request with:
659
- * - Network-error retry (UND_ERR_SOCKET, ECONNRESET, ENOTFOUND, connect timeouts)
660
- * - 401 → forceRefreshCopilotToken → retry once with the new bearer token
609
+ * Wraps every request with network-error retry (UND_ERR_SOCKET, ECONNRESET,
610
+ * ENOTFOUND, connect timeouts).
661
611
  *
662
- * Callers supply the path (e.g. "/chat/completions") and the usual RequestInit.
663
- * The host prefix and resilience wiring are applied here so every endpoint
664
- * gets the same treatment and adding a new endpoint is one call site.
612
+ * Historically this seam also refreshed a short-lived CAPI session token on
613
+ * 401. That session-token flow was a VSCode/JetBrains-plugin idiom; the CLI
614
+ * we impersonate uses the GitHub OAuth token directly as the CAPI Bearer, so
615
+ * a 401 there means the OAuth token itself is invalid — no server-side
616
+ * refresh helps. If a future refresh_token grant is wired in, add it back
617
+ * here as an `onUnauthorized` callback.
665
618
  */
666
619
  function copilotFetch(path, init) {
667
- return fetchWithRetry(`${copilotBaseUrl(state)}${path}`, init, { onUnauthorized: makeCopilotAuthRetry(forceRefreshCopilotToken, init) });
620
+ return fetchWithRetry(`${copilotBaseUrl(state)}${path}`, init);
668
621
  }
669
622
 
670
623
  //#endregion
@@ -708,6 +661,14 @@ async function getCopilotCliVersion() {
708
661
  }
709
662
  }
710
663
 
664
+ //#endregion
665
+ //#region src/services/github/get-copilot-usage.ts
666
+ const getCopilotUsage = async () => {
667
+ const response = await fetch(`${GITHUB_API_BASE_URL}/copilot_internal/user`, { headers: githubHeaders(state) });
668
+ if (!response.ok) throw await HTTPError.fromResponse("Failed to get Copilot usage", response);
669
+ return await response.json();
670
+ };
671
+
711
672
  //#endregion
712
673
  //#region src/lib/utils.ts
713
674
  const sleep = (ms) => new Promise((resolve) => {
@@ -736,6 +697,44 @@ const initCopilotIdentity = async () => {
736
697
  state.machineId = await getOrCreateMachineId();
737
698
  await cacheCopilotCliVersion();
738
699
  };
700
+ /**
701
+ * Bootstraps the CAPI session after GitHub OAuth is settled. Mirrors what the
702
+ * real @github/copilot CLI does at startup:
703
+ *
704
+ * 1. GET /copilot_internal/user to learn the account's CAPI endpoint set
705
+ * (`endpoints.api` — the host varies by plan tier, e.g. enterprise gets
706
+ * `api.enterprise.githubcopilot.com`).
707
+ * 2. Use the GitHub OAuth token directly as the CAPI Bearer. The CLI does NOT
708
+ * exchange the OAuth token for a separate CAPI session token — that flow
709
+ * (`GET /copilot_internal/v2/token`) is a VSCode/JetBrains-plugin idiom the
710
+ * CLI's GitHub App is not authorized for (returns 404).
711
+ *
712
+ * Must run after setupGitHubToken() and initCopilotIdentity(). Failure surfaces
713
+ * to the caller; there is no CAPI to serve without endpoints or a Bearer, so
714
+ * a missing/tampered/non-Copilot `endpoints.api` throws at boot rather than
715
+ * silently degrading to a stale accountType-derived host or routing the raw
716
+ * GitHub OAuth token at an arbitrary origin.
717
+ */
718
+ const CAPI_HOST_PATTERN = /^api(?:\.[a-z0-9-]+)?\.githubcopilot\.com$/;
719
+ const bootstrapCopilotSession = async () => {
720
+ const usage = await getCopilotUsage();
721
+ if (usage === null || typeof usage !== "object") throw new Error("bootstrapCopilotSession: /copilot_internal/user body was not an object.");
722
+ const usageObj = usage;
723
+ if (typeof usageObj.endpoints !== "object" || usageObj.endpoints === null) throw new Error("bootstrapCopilotSession: /copilot_internal/user returned no endpoints object.");
724
+ const rawApi = usageObj.endpoints.api;
725
+ if (typeof rawApi !== "string") throw new TypeError(`bootstrapCopilotSession: endpoints.api must be a string (got ${typeof rawApi}).`);
726
+ let parsed;
727
+ try {
728
+ parsed = new URL(rawApi);
729
+ } catch {
730
+ throw new Error(`bootstrapCopilotSession: endpoints.api is not a valid URL (${JSON.stringify(rawApi)}).`);
731
+ }
732
+ if (parsed.protocol !== "https:") throw new Error(`bootstrapCopilotSession: endpoints.api must use https:// (got ${parsed.protocol}).`);
733
+ if (parsed.port !== "") throw new Error(`bootstrapCopilotSession: endpoints.api must use the default https port (got :${parsed.port}).`);
734
+ if (!CAPI_HOST_PATTERN.test(parsed.hostname)) throw new Error(`bootstrapCopilotSession: endpoints.api host ${parsed.hostname} is not an api[.tier].githubcopilot.com host.`);
735
+ state.copilotApiEndpoint = parsed.origin;
736
+ state.copilotToken = state.githubToken;
737
+ };
739
738
 
740
739
  //#endregion
741
740
  //#region src/services/github/poll-access-token.ts
@@ -771,72 +770,6 @@ async function pollAccessToken(deviceCode) {
771
770
  //#region src/lib/token.ts
772
771
  const readGithubToken = () => fs.readFile(PATHS.GITHUB_TOKEN_PATH, "utf8");
773
772
  const writeGithubToken = (token) => fs.writeFile(PATHS.GITHUB_TOKEN_PATH, token);
774
- let copilotTokenRefreshTimer = null;
775
- /**
776
- * Refresh the Copilot token with exponential backoff retry.
777
- * Returns the new token on success, or null if all retries fail.
778
- */
779
- async function refreshCopilotTokenWithRetry(maxRetries = 3) {
780
- let lastError = null;
781
- for (let attempt = 0; attempt < maxRetries; attempt++) try {
782
- const { token } = await getCopilotToken();
783
- return token;
784
- } catch (error) {
785
- lastError = error;
786
- const delay = Math.min(1e3 * 2 ** attempt, 3e4);
787
- consola.warn(`Token refresh attempt ${attempt + 1}/${maxRetries} failed, retrying in ${delay}ms`);
788
- await new Promise((resolve) => setTimeout(resolve, delay));
789
- }
790
- consola.error("All token refresh attempts failed:", lastError);
791
- return null;
792
- }
793
- /**
794
- * Force-refresh the Copilot token on demand (e.g. after a 401 response).
795
- * Updates state.copilotToken on success and returns the new token, or null
796
- * if refresh failed. Coalesces concurrent callers so multiple in-flight 401s
797
- * only trigger one refresh.
798
- */
799
- let refreshInFlight = null;
800
- async function forceRefreshCopilotToken() {
801
- if (refreshInFlight) return refreshInFlight;
802
- refreshInFlight = refreshCopilotTokenWithRetry().then((token) => {
803
- if (token) state.copilotToken = token;
804
- return token;
805
- }).finally(() => {
806
- refreshInFlight = null;
807
- });
808
- return refreshInFlight;
809
- }
810
- /**
811
- * Clear any existing token refresh timer.
812
- * Call this before setting up a new timer or during cleanup.
813
- */
814
- function clearCopilotTokenRefresh() {
815
- if (copilotTokenRefreshTimer) {
816
- clearInterval(copilotTokenRefreshTimer);
817
- copilotTokenRefreshTimer = null;
818
- }
819
- }
820
- const setupCopilotToken = async () => {
821
- const { token, refresh_in } = await getCopilotToken();
822
- state.copilotToken = token;
823
- consola.debug("GitHub Copilot Token fetched successfully!");
824
- if (state.showToken) consola.info("Copilot token:", token);
825
- const refreshInterval = Math.max((refresh_in - 60) * 1e3, 60 * 1e3);
826
- clearCopilotTokenRefresh();
827
- copilotTokenRefreshTimer = setInterval(() => {
828
- consola.debug("Refreshing Copilot token");
829
- refreshCopilotTokenWithRetry().then((newToken) => {
830
- if (newToken) {
831
- state.copilotToken = newToken;
832
- consola.debug("Copilot token refreshed");
833
- if (state.showToken) consola.info("Refreshed Copilot token:", newToken);
834
- } else consola.error("Failed to refresh Copilot token after retries, using existing token");
835
- }).catch((error) => {
836
- consola.error("Unexpected error during token refresh:", error);
837
- });
838
- }, refreshInterval);
839
- };
840
773
  async function setupGitHubToken(options) {
841
774
  try {
842
775
  const githubToken = await readGithubToken();
@@ -869,14 +802,6 @@ async function logUser() {
869
802
  consola.info(`Logged in as ${user.login}`);
870
803
  }
871
804
 
872
- //#endregion
873
- //#region src/services/github/get-copilot-usage.ts
874
- const getCopilotUsage = async () => {
875
- const response = await fetch(`${GITHUB_API_BASE_URL}/copilot_internal/user`, { headers: githubHeaders(state) });
876
- if (!response.ok) throw await HTTPError.fromResponse("Failed to get Copilot usage", response);
877
- return await response.json();
878
- };
879
-
880
805
  //#endregion
881
806
  //#region src/debug.ts
882
807
  async function getPackageVersion() {
@@ -1002,8 +927,7 @@ const debugModels = defineCommand({
1002
927
  state.githubToken = args["github-token"];
1003
928
  consola.info("Using provided GitHub token");
1004
929
  } else await setupGitHubToken();
1005
- const { token } = await getCopilotToken();
1006
- state.copilotToken = token;
930
+ await bootstrapCopilotSession();
1007
931
  consola.info("Fetching models from Copilot API...");
1008
932
  const models = await getModels();
1009
933
  console.log(JSON.stringify(models, null, 2));
@@ -1086,7 +1010,7 @@ const logout = defineCommand({
1086
1010
 
1087
1011
  //#endregion
1088
1012
  //#region package.json
1089
- var version = "0.15.1";
1013
+ var version = "0.17.0";
1090
1014
 
1091
1015
  //#endregion
1092
1016
  //#region src/lib/event-loop-lag.ts
@@ -1739,7 +1663,6 @@ async function gracefulShutdown(signal, deps) {
1739
1663
  const tracker = deps?.tracker;
1740
1664
  const server = deps?.server ?? serverInstance;
1741
1665
  const rateLimiter = deps?.rateLimiter !== void 0 ? deps.rateLimiter : getAdaptiveRateLimiter();
1742
- const stopRefresh = deps?.stopTokenRefreshFn ?? (() => {});
1743
1666
  const closeWsClients = deps?.closeAllClientsFn ?? closeAllClients;
1744
1667
  const getWsCount = deps?.getClientCountFn ?? getClientCount;
1745
1668
  const gracefulWaitMs = deps?.gracefulWaitMs ?? state.shutdownGracefulWait * 1e3;
@@ -1756,7 +1679,6 @@ async function gracefulShutdown(signal, deps) {
1756
1679
  } catch {}
1757
1680
  stopMemoryPressureMonitor();
1758
1681
  stopEventLoopLagMonitor();
1759
- stopRefresh();
1760
1682
  const wsClients = getWsCount();
1761
1683
  if (wsClients > 0) {
1762
1684
  closeWsClients();
@@ -2793,6 +2715,20 @@ function formatTokens(input, output) {
2793
2715
  return `${formatNumber(input)}/${formatNumber(output)}`;
2794
2716
  }
2795
2717
  /**
2718
+ * Cache-hit segment for the complete line, e.g. `cache=90%` (or
2719
+ * `cache=90% (1.6K/1.8K)` under --verbose). Returns undefined when there is no
2720
+ * usable denominator or the request had zero cache hits, so non-cached and
2721
+ * non-LLM requests stay terse (minimize-noise). `total` is the full prompt size
2722
+ * normalized across API families; see completeTracking / the per-path extraction.
2723
+ */
2724
+ function formatCacheHitRate(cached, total) {
2725
+ if (total === void 0 || total <= 0) return void 0;
2726
+ if (cached === void 0 || cached <= 0) return void 0;
2727
+ const pct = Math.round(cached / total * 100);
2728
+ if (consola.level >= 5) return `cache=${pct}% (${formatNumber(cached)}/${formatNumber(total)})`;
2729
+ return `cache=${pct}%`;
2730
+ }
2731
+ /**
2796
2732
  * Console renderer that shows request lifecycle with apt-get style footer
2797
2733
  *
2798
2734
  * Log format:
@@ -2894,7 +2830,7 @@ var ConsoleRenderer = class {
2894
2830
  * Format a complete log line with colored parts
2895
2831
  */
2896
2832
  formatLogLine(parts) {
2897
- const { prefix, time, method, path, model, status, duration, tokens, queueWait, phases, extra, isError, isDim } = parts;
2833
+ const { prefix, time, method, path, model, status, duration, tokens, cache, queueWait, phases, extra, isError, isDim } = parts;
2898
2834
  if (isDim) {
2899
2835
  const modelPart = model ? ` ${model}` : "";
2900
2836
  const extraPart = extra ? ` ${extra}` : "";
@@ -2908,6 +2844,7 @@ var ConsoleRenderer = class {
2908
2844
  if (duration) result += ` ${pc.yellow(duration)}`;
2909
2845
  if (queueWait) result += ` ${pc.dim(`(queued ${queueWait})`)}`;
2910
2846
  if (tokens) result += ` ${pc.blue(tokens)}`;
2847
+ if (cache) result += ` ${pc.dim(cache)}`;
2911
2848
  if (phases) result += ` ${pc.dim(phases)}`;
2912
2849
  if (extra) result += isError ? pc.red(extra) : extra;
2913
2850
  return result;
@@ -2979,6 +2916,7 @@ var ConsoleRenderer = class {
2979
2916
  const status = request.statusCode ?? 0;
2980
2917
  const isError = request.status === "error" || status >= 400;
2981
2918
  const tokens = request.model ? formatTokens(request.inputTokens, request.outputTokens) : void 0;
2919
+ const cache = formatCacheHitRate(request.cachedInputTokens, request.totalInputTokens);
2982
2920
  const queueWait = request.queueWaitMs && request.queueWaitMs > 100 ? formatDuration(request.queueWaitMs) : void 0;
2983
2921
  const message = this.formatLogLine({
2984
2922
  prefix: isError ? "[FAIL]" : "[ OK ]",
@@ -2990,6 +2928,7 @@ var ConsoleRenderer = class {
2990
2928
  duration: formatDuration(request.durationMs ?? 0),
2991
2929
  queueWait,
2992
2930
  tokens,
2931
+ cache,
2993
2932
  phases: this.formatPhases(request),
2994
2933
  extra: isError && request.error ? `: ${request.error}` : void 0,
2995
2934
  isError,
@@ -3058,6 +2997,8 @@ var RequestTracker = class {
3058
2997
  if (update.inputTokens !== void 0) request.inputTokens = update.inputTokens;
3059
2998
  if (update.outputTokens !== void 0) request.outputTokens = update.outputTokens;
3060
2999
  if (update.reasoningTokens !== void 0) request.reasoningTokens = update.reasoningTokens;
3000
+ if (update.cachedInputTokens !== void 0) request.cachedInputTokens = update.cachedInputTokens;
3001
+ if (update.totalInputTokens !== void 0) request.totalInputTokens = update.totalInputTokens;
3061
3002
  if (update.error !== void 0) request.error = update.error;
3062
3003
  if (update.queuePosition !== void 0) request.queuePosition = update.queuePosition;
3063
3004
  if (update.queueWaitMs !== void 0) request.queueWaitMs = update.queueWaitMs;
@@ -4225,13 +4166,15 @@ function updateTrackerStatus(trackingId, status) {
4225
4166
  requestTracker.updateRequest(trackingId, { status });
4226
4167
  }
4227
4168
  /** Complete TUI tracking and send PostHog analytics */
4228
- function completeTracking(trackingId, inputTokens, outputTokens, queueWaitMs, reasoningTokens, analytics, timings) {
4169
+ function completeTracking(trackingId, inputTokens, outputTokens, queueWaitMs, reasoningTokens, analytics, timings, cache) {
4229
4170
  if (!trackingId) return;
4230
4171
  requestTracker.updateRequest(trackingId, {
4231
4172
  inputTokens,
4232
4173
  outputTokens,
4233
4174
  queueWaitMs,
4234
4175
  reasoningTokens,
4176
+ cachedInputTokens: cache?.cachedInputTokens,
4177
+ totalInputTokens: cache?.totalInputTokens,
4235
4178
  ...timingsToUpdate(timings)
4236
4179
  });
4237
4180
  requestTracker.completeRequest(trackingId, 200, {
@@ -4343,6 +4286,9 @@ function formatClientTruncationMarker(result) {
4343
4286
  function getReasoningTokensFromOpenAIUsage(usage) {
4344
4287
  return usage?.completion_tokens_details?.reasoning_tokens;
4345
4288
  }
4289
+ function getCachedTokensFromOpenAIUsage(usage) {
4290
+ return usage?.prompt_tokens_details?.cached_tokens;
4291
+ }
4346
4292
  async function handleCompletion$1(c) {
4347
4293
  const rawPayload = await c.req.json();
4348
4294
  consola.debug("Request payload:", JSON.stringify(rawPayload).slice(-400));
@@ -4461,7 +4407,9 @@ function handleNonStreamingResponse$1(c, originalResponse, ctx, payload) {
4461
4407
  inputTokens: usage.prompt_tokens,
4462
4408
  outputTokens: usage.completion_tokens,
4463
4409
  queueWaitMs: ctx.queueWaitMs,
4464
- reasoningTokens
4410
+ reasoningTokens,
4411
+ cachedInputTokens: getCachedTokensFromOpenAIUsage(usage),
4412
+ totalInputTokens: usage.prompt_tokens
4465
4413
  });
4466
4414
  captureRequest({
4467
4415
  model: response.model,
@@ -4502,6 +4450,7 @@ function createStreamAccumulator() {
4502
4450
  model: "",
4503
4451
  inputTokens: 0,
4504
4452
  outputTokens: 0,
4453
+ cachedTokens: 0,
4505
4454
  reasoningTokens: 0,
4506
4455
  finishReason: "",
4507
4456
  content: "",
@@ -4545,7 +4494,10 @@ async function handleStreamingResponse$1(opts) {
4545
4494
  durationMs: Date.now() - ctx.startTime,
4546
4495
  stopReason: acc.finishReason || void 0,
4547
4496
  toolCount: payload.tools?.length ?? 0
4548
- }, ctx.timings);
4497
+ }, ctx.timings, {
4498
+ cachedInputTokens: acc.cachedTokens,
4499
+ totalInputTokens: acc.inputTokens
4500
+ });
4549
4501
  } catch (error) {
4550
4502
  if (isAbortError(error)) {
4551
4503
  consola.debug("[ChatCompletions] client disconnected mid-stream; upstream aborted");
@@ -4614,6 +4566,7 @@ function accumulateParsedChunk(parsed, acc, checkRepetition) {
4614
4566
  if (parsed.usage) {
4615
4567
  acc.inputTokens = parsed.usage.prompt_tokens;
4616
4568
  acc.outputTokens = parsed.usage.completion_tokens;
4569
+ acc.cachedTokens = parsed.usage.prompt_tokens_details?.cached_tokens ?? 0;
4617
4570
  acc.reasoningTokens = getReasoningTokensFromOpenAIUsage(parsed.usage) ?? 0;
4618
4571
  }
4619
4572
  const choice = parsed.choices?.[0];
@@ -7227,6 +7180,8 @@ function createAnthropicStreamAccumulator() {
7227
7180
  model: "",
7228
7181
  inputTokens: 0,
7229
7182
  outputTokens: 0,
7183
+ cacheReadInputTokens: 0,
7184
+ cacheCreationInputTokens: 0,
7230
7185
  stopReason: "",
7231
7186
  content: "",
7232
7187
  toolCalls: [],
@@ -7236,6 +7191,9 @@ function createAnthropicStreamAccumulator() {
7236
7191
  }
7237
7192
  function processAnthropicEvent(event, acc) {
7238
7193
  switch (event.type) {
7194
+ case "message_start":
7195
+ handleMessageStart(event.message.usage, acc);
7196
+ break;
7239
7197
  case "content_block_delta":
7240
7198
  handleContentBlockDelta(event.delta, acc);
7241
7199
  break;
@@ -7271,11 +7229,20 @@ function handleContentBlockStop(acc) {
7271
7229
  acc.currentToolCall = null;
7272
7230
  }
7273
7231
  }
7232
+ function applyPromptSideUsage(usage, acc) {
7233
+ if (usage.input_tokens !== void 0) acc.inputTokens = usage.input_tokens;
7234
+ if (usage.cache_read_input_tokens !== void 0) acc.cacheReadInputTokens = usage.cache_read_input_tokens;
7235
+ if (usage.cache_creation_input_tokens !== void 0) acc.cacheCreationInputTokens = usage.cache_creation_input_tokens;
7236
+ }
7237
+ function handleMessageStart(usage, acc) {
7238
+ if (!usage) return;
7239
+ applyPromptSideUsage(usage, acc);
7240
+ }
7274
7241
  function handleMessageDelta(delta, usage, acc) {
7275
7242
  if (delta.stop_reason) acc.stopReason = delta.stop_reason;
7276
7243
  if (usage) {
7277
- acc.inputTokens = usage.input_tokens ?? 0;
7278
7244
  acc.outputTokens = usage.output_tokens;
7245
+ applyPromptSideUsage(usage, acc);
7279
7246
  }
7280
7247
  }
7281
7248
  function recordAnthropicStreamingResponse(acc, fallbackModel, ctx) {
@@ -8478,11 +8445,17 @@ function handleDirectAnthropicNonStreamingResponse(c, response, ctx, truncateRes
8478
8445
  },
8479
8446
  toolCalls: extractToolCallsFromContent(response.content)
8480
8447
  }, Date.now() - ctx.startTime);
8481
- if (ctx.trackingId) requestTracker.updateRequest(ctx.trackingId, {
8482
- inputTokens: response.usage.input_tokens,
8483
- outputTokens: response.usage.output_tokens,
8484
- queueWaitMs: ctx.queueWaitMs
8485
- });
8448
+ if (ctx.trackingId) {
8449
+ const cacheRead = response.usage.cache_read_input_tokens ?? 0;
8450
+ const cacheCreation = response.usage.cache_creation_input_tokens ?? 0;
8451
+ requestTracker.updateRequest(ctx.trackingId, {
8452
+ inputTokens: response.usage.input_tokens,
8453
+ outputTokens: response.usage.output_tokens,
8454
+ queueWaitMs: ctx.queueWaitMs,
8455
+ cachedInputTokens: cacheRead,
8456
+ totalInputTokens: response.usage.input_tokens + cacheRead + cacheCreation
8457
+ });
8458
+ }
8486
8459
  captureRequest({
8487
8460
  model: response.model,
8488
8461
  inputTokens: response.usage.input_tokens,
@@ -8576,7 +8549,10 @@ async function handleDirectAnthropicStreamingResponse(opts) {
8576
8549
  durationMs: Date.now() - ctx.startTime,
8577
8550
  stopReason: acc.stopReason || void 0,
8578
8551
  toolCount: anthropicPayload.tools?.length ?? 0
8579
- }, ctx.timings);
8552
+ }, ctx.timings, {
8553
+ cachedInputTokens: acc.cacheReadInputTokens,
8554
+ totalInputTokens: acc.inputTokens + acc.cacheReadInputTokens + acc.cacheCreationInputTokens
8555
+ });
8580
8556
  } catch (error) {
8581
8557
  if (isAbortError(error)) {
8582
8558
  consola.debug("[Anthropic] client disconnected mid-stream; upstream aborted");
@@ -8791,11 +8767,17 @@ function handleNonStreamingResponse(opts) {
8791
8767
  },
8792
8768
  toolCalls: extractToolCallsFromContent(anthropicResponse.content)
8793
8769
  }, Date.now() - ctx.startTime);
8794
- if (ctx.trackingId) requestTracker.updateRequest(ctx.trackingId, {
8795
- inputTokens: anthropicResponse.usage.input_tokens,
8796
- outputTokens: anthropicResponse.usage.output_tokens,
8797
- queueWaitMs: ctx.queueWaitMs
8798
- });
8770
+ if (ctx.trackingId) {
8771
+ const cacheRead = anthropicResponse.usage.cache_read_input_tokens ?? 0;
8772
+ const cacheCreation = anthropicResponse.usage.cache_creation_input_tokens ?? 0;
8773
+ requestTracker.updateRequest(ctx.trackingId, {
8774
+ inputTokens: anthropicResponse.usage.input_tokens,
8775
+ outputTokens: anthropicResponse.usage.output_tokens,
8776
+ queueWaitMs: ctx.queueWaitMs,
8777
+ cachedInputTokens: cacheRead,
8778
+ totalInputTokens: anthropicResponse.usage.input_tokens + cacheRead + cacheCreation
8779
+ });
8780
+ }
8799
8781
  captureRequest({
8800
8782
  model: anthropicResponse.model,
8801
8783
  inputTokens: anthropicResponse.usage.input_tokens,
@@ -8840,7 +8822,10 @@ async function handleStreamingResponse(opts) {
8840
8822
  durationMs: Date.now() - ctx.startTime,
8841
8823
  stopReason: acc.stopReason || void 0,
8842
8824
  toolCount: anthropicPayload.tools?.length ?? 0
8843
- }, ctx.timings);
8825
+ }, ctx.timings, {
8826
+ cachedInputTokens: acc.cacheReadInputTokens,
8827
+ totalInputTokens: acc.inputTokens + acc.cacheReadInputTokens + acc.cacheCreationInputTokens
8828
+ });
8844
8829
  } catch (error) {
8845
8830
  if (isAbortError(error)) {
8846
8831
  consola.debug("[Translated] client disconnected mid-stream; upstream aborted");
@@ -9473,7 +9458,10 @@ const handleResponses = async (c) => {
9473
9458
  stream: true,
9474
9459
  durationMs: Date.now() - startTime,
9475
9460
  toolCount: tools.length
9476
- }, ctx.timings);
9461
+ }, ctx.timings, {
9462
+ cachedInputTokens: usage?.input_tokens_details?.cached_tokens ?? 0,
9463
+ totalInputTokens: usage?.input_tokens ?? 0
9464
+ });
9477
9465
  } else if (streamErrorMessage) {
9478
9466
  recordResponse(historyId, {
9479
9467
  success: false,
@@ -9524,7 +9512,10 @@ const handleResponses = async (c) => {
9524
9512
  stream: false,
9525
9513
  durationMs: Date.now() - startTime,
9526
9514
  toolCount: tools.length
9527
- }, ctx.timings);
9515
+ }, ctx.timings, {
9516
+ cachedInputTokens: usage?.input_tokens_details?.cached_tokens ?? 0,
9517
+ totalInputTokens: usage?.input_tokens ?? 0
9518
+ });
9528
9519
  consola.debug("Forwarding native Responses result:", JSON.stringify(result).slice(-400));
9529
9520
  return c.json(echoResponseBody(result, ctx));
9530
9521
  } catch (error) {
@@ -9604,6 +9595,8 @@ responsesRoutes.post("/", async (c) => {
9604
9595
  const tokenRoute = new Hono();
9605
9596
  tokenRoute.get("/", (c) => {
9606
9597
  try {
9598
+ if (!state.allowTokenEndpoint) return c.json({ error: "/token disabled. Pass --allow-token-endpoint (or COPILOT_ALLOW_TOKEN_ENDPOINT=1) to expose the raw GitHub OAuth token; the fallback is to read ~/.local/share/copilot-api/github_token directly." }, 403);
9599
+ if (!state.proxyApiKeyDigest) return c.json({ error: "/token requires --api-key even when --allow-token-endpoint is set: the endpoint returns the raw GitHub OAuth token (repo,gist,read:user,read:org). Set --api-key or COPILOT_API_KEY to enable." }, 403);
9607
9600
  return c.json({ token: state.copilotToken });
9608
9601
  } catch (error) {
9609
9602
  return forwardError(c, error);
@@ -9626,6 +9619,11 @@ usageRoute.get("/", async (c) => {
9626
9619
  //#region src/server.ts
9627
9620
  const server = new Hono();
9628
9621
  server.use(tuiLogger());
9622
+ server.use("/token", async (c, next) => {
9623
+ await next();
9624
+ c.header("Access-Control-Allow-Origin", "null");
9625
+ c.header("Access-Control-Allow-Credentials", "false");
9626
+ });
9629
9627
  server.use(cors());
9630
9628
  server.use(authGate());
9631
9629
  server.get("/", (c) => c.text("Server running"));
@@ -9684,6 +9682,8 @@ function formatModelInfo(model) {
9684
9682
  async function runServer(options) {
9685
9683
  consola.info(`copilot-api v${version}`);
9686
9684
  configureProxyApiKey(options.apiKey);
9685
+ state.allowTokenEndpoint = options.allowTokenEndpoint;
9686
+ if (options.allowTokenEndpoint) consola.warn("/token endpoint enabled — will echo the raw GitHub OAuth token (repo,gist,read:user,read:org) to callers presenting --api-key. Only use on trusted single-operator hosts.");
9687
9687
  if (options.verbose) {
9688
9688
  consola.level = 5;
9689
9689
  consola.info("Verbose logging enabled");
@@ -9715,7 +9715,7 @@ async function runServer(options) {
9715
9715
  state.githubToken = options.githubToken;
9716
9716
  consola.info("Using provided GitHub token");
9717
9717
  } else await setupGitHubToken();
9718
- await setupCopilotToken();
9718
+ await bootstrapCopilotSession();
9719
9719
  try {
9720
9720
  await cacheModels();
9721
9721
  } catch (error) {
@@ -9800,6 +9800,11 @@ const start = defineCommand({
9800
9800
  "api-key": {
9801
9801
  type: "string",
9802
9802
  description: "Proxy API key for inbound authentication. When set (non-empty after trimming), all endpoints except / and /health require this key via 'Authorization: Bearer <key>'. Omitted or empty = auth disabled (default, all requests pass through)."
9803
+ },
9804
+ "allow-token-endpoint": {
9805
+ type: "boolean",
9806
+ default: false,
9807
+ description: "Enable the /token endpoint (default: disabled). When enabled AND --api-key is set, /token echoes the raw GitHub OAuth token (repo,gist,read:user,read:org) to authenticated callers. Never combine with --host 0.0.0.0 on untrusted networks — the token has full repo/gist write access on the operator's GitHub account. Fallback: read ~/.local/share/copilot-api/github_token directly."
9803
9808
  }
9804
9809
  },
9805
9810
  run({ args }) {
@@ -9816,7 +9821,8 @@ const start = defineCommand({
9816
9821
  githubToken: args["github-token"] || process.env.GH_TOKEN,
9817
9822
  posthogKey: args["posthog-key"],
9818
9823
  apiKey: resolvedApiKey.key,
9819
- apiKeySource: resolvedApiKey.source
9824
+ apiKeySource: resolvedApiKey.source,
9825
+ allowTokenEndpoint: args["allow-token-endpoint"] || process.env.COPILOT_ALLOW_TOKEN_ENDPOINT === "1" || process.env.COPILOT_ALLOW_TOKEN_ENDPOINT === "true"
9820
9826
  });
9821
9827
  }
9822
9828
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dianshuv/copilot-api",
3
- "version": "0.15.1",
3
+ "version": "0.17.0",
4
4
  "description": "Turn GitHub Copilot into OpenAI/Anthropic API compatible server. Usable with Claude Code!",
5
5
  "author": "dianshuv",
6
6
  "type": "module",