@dianshuv/copilot-api 0.15.0 → 0.16.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 +4 -6
  2. package/dist/main.mjs +200 -157
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # Copilot API Proxy (Fork)
2
2
 
3
3
  > [!NOTE]
4
- > This is a fork of [@hsupu/copilot-api](https://www.npmjs.com/package/@hsupu/copilot-api), which itself is a fork of [ericc-ch/copilot-api](https://github.com/ericc-ch/copilot-api), with additional improvements and bug fixes.
4
+ > This is a fork of [@hsupu/copilot-api](https://www.npmjs.com/package/@hsupu/copilot-api), which itself is a fork of `ericc-ch/copilot-api`, with additional improvements and bug fixes.
5
5
 
6
6
  > [!WARNING]
7
7
  > This is a reverse-engineered proxy of GitHub Copilot API. It is not supported by GitHub, and may break unexpectedly. Use at your own risk.
@@ -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 |
@@ -275,6 +276,3 @@ Create `.claude/settings.json` in your project:
275
276
  }
276
277
  ```
277
278
 
278
- ## Upstream Project
279
-
280
- For the original project documentation, features, and updates, see: [ericc-ch/copilot-api](https://github.com/ericc-ch/copilot-api)
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.0";
1013
+ var version = "0.16.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();
@@ -3215,6 +3137,43 @@ const awaitApproval = async () => {
3215
3137
  if (!await consola.prompt(`Accept incoming request?`, { type: "confirm" })) throw new HTTPError("Request rejected", 403, JSON.stringify({ message: "Request rejected" }));
3216
3138
  };
3217
3139
 
3140
+ //#endregion
3141
+ //#region src/lib/client-abort.ts
3142
+ /**
3143
+ * Build a per-request AbortController that fires when the downstream client
3144
+ * goes away, for forwarding into the upstream Copilot `fetch`.
3145
+ *
3146
+ * Why a controller instead of passing `c.req.raw.signal` straight through:
3147
+ * the client-disconnect signal is delivered differently per runtime. Verified
3148
+ * on the runtime the published package runs under (Node/srvx): BOTH
3149
+ * `c.req.raw.signal` and Hono's `stream.onAbort` fire on disconnect. Rather
3150
+ * than trust a single source (a wrong bet here silently leaks), callers wire
3151
+ * `c.req.raw.signal` (here) AND `stream.onAbort` (in the streaming branch) into
3152
+ * the one controller, then pass `controller.signal` to the upstream fetch.
3153
+ *
3154
+ * Without this, an abandoned request keeps draining the upstream response to
3155
+ * completion — holding one of the account's scarce concurrent-request slots
3156
+ * until the proxy process dies (which is why restarting the proxy "fixes" a
3157
+ * pile-up of slow requests).
3158
+ */
3159
+ function clientAbortController(c) {
3160
+ const controller = new AbortController();
3161
+ const clientSignal = c.req.raw.signal;
3162
+ if (clientSignal.aborted) controller.abort();
3163
+ else clientSignal.addEventListener("abort", () => controller.abort(), { once: true });
3164
+ return controller;
3165
+ }
3166
+ /**
3167
+ * True when an error originates from an AbortSignal firing (client disconnect)
3168
+ * rather than a genuine upstream failure, so callers can treat a cancelled
3169
+ * request as a clean stop instead of recording a spurious error. Matches both
3170
+ * `Error` and `DOMException` (undici's fetch rejects with the latter, which is
3171
+ * not always an `instanceof Error`), keying only on the `name`.
3172
+ */
3173
+ function isAbortError(error) {
3174
+ return typeof error === "object" && error !== null && "name" in error && error.name === "AbortError";
3175
+ }
3176
+
3218
3177
  //#endregion
3219
3178
  //#region src/lib/message-sanitizer.ts
3220
3179
  const startPattern = /^\s*<system-reminder>[\s\S]*?<\/system-reminder>\n*/;
@@ -3911,7 +3870,8 @@ const createChatCompletions = async (payload, options) => {
3911
3870
  const response = await copilotFetch("/chat/completions", {
3912
3871
  method: "POST",
3913
3872
  headers,
3914
- body: JSON.stringify(wire)
3873
+ body: JSON.stringify(wire),
3874
+ signal: options?.signal
3915
3875
  });
3916
3876
  if (!response.ok) {
3917
3877
  consola.error("Failed to create chat completions", response);
@@ -4346,13 +4306,18 @@ async function handleCompletion$1(c) {
4346
4306
  */
4347
4307
  async function executeRequest(opts) {
4348
4308
  const { c, payload, selectedModel, ctx } = opts;
4309
+ const abort = clientAbortController(c);
4349
4310
  try {
4350
- const { result: response, queueWaitMs } = await executeWithAdaptiveRateLimit(() => createChatCompletions(payload, { resolvedModel: selectedModel }));
4311
+ const { result: response, queueWaitMs } = await executeWithAdaptiveRateLimit(() => createChatCompletions(payload, {
4312
+ resolvedModel: selectedModel,
4313
+ signal: abort.signal
4314
+ }));
4351
4315
  ctx.queueWaitMs = queueWaitMs;
4352
4316
  if (isNonStreaming(response)) return handleNonStreamingResponse$1(c, response, ctx, payload);
4353
4317
  consola.debug("Streaming response");
4354
4318
  updateTrackerStatus(ctx.trackingId, "streaming");
4355
4319
  return streamSSE(c, async (stream) => {
4320
+ stream.onAbort(() => abort.abort());
4356
4321
  await handleStreamingResponse$1({
4357
4322
  stream,
4358
4323
  response,
@@ -4361,6 +4326,11 @@ async function executeRequest(opts) {
4361
4326
  });
4362
4327
  });
4363
4328
  } catch (error) {
4329
+ if (isAbortError(error)) {
4330
+ consola.debug("[ChatCompletions] client disconnected before response; upstream aborted");
4331
+ failTracking(ctx.trackingId, "client disconnected");
4332
+ return new Response(null, { status: 499 });
4333
+ }
4364
4334
  if (error instanceof HTTPError && error.status === 413) await logPayloadSizeInfo(payload, selectedModel);
4365
4335
  recordErrorResponse(ctx, payload.model, error, "chat_completions", payload.stream ?? false);
4366
4336
  throw error;
@@ -4499,6 +4469,11 @@ async function handleStreamingResponse$1(opts) {
4499
4469
  toolCount: payload.tools?.length ?? 0
4500
4470
  }, ctx.timings);
4501
4471
  } catch (error) {
4472
+ if (isAbortError(error)) {
4473
+ consola.debug("[ChatCompletions] client disconnected mid-stream; upstream aborted");
4474
+ failTracking(ctx.trackingId, "client disconnected");
4475
+ return;
4476
+ }
4502
4477
  recordStreamError({
4503
4478
  acc,
4504
4479
  fallbackModel: payload.model,
@@ -6837,7 +6812,8 @@ async function createAnthropicMessages(payload, options) {
6837
6812
  const response = await copilotFetch("/v1/messages", {
6838
6813
  method: "POST",
6839
6814
  headers,
6840
- body: JSON.stringify(filteredPayload)
6815
+ body: JSON.stringify(filteredPayload),
6816
+ signal: options?.signal
6841
6817
  });
6842
6818
  if (!response.ok) {
6843
6819
  consola.debug("Request failed:", {
@@ -8295,13 +8271,15 @@ async function handleDirectAnthropicCompletion(c, anthropicPayload, ctx, initiat
8295
8271
  } else if (state.autoTruncate && !selectedModel) consola.debug(`[Anthropic] Model '${anthropicPayload.model}' not found, skipping auto-truncate`);
8296
8272
  if (state.manualApprove) await awaitApproval();
8297
8273
  const clientAnthropicBetaHeader = c.req.header("anthropic-beta");
8274
+ const abort = clientAbortController(c);
8298
8275
  const isStreaming = anthropicPayload.stream === true;
8299
8276
  try {
8300
8277
  const settled = settle(executeWithAdaptiveRateLimit(() => createAnthropicMessages(effectivePayload, {
8301
8278
  initiator: initiatorOverride,
8302
8279
  injectContext1mBeta: needsContext1mBeta,
8303
8280
  errorModelIdOverride: needsContext1mBeta ? originalModelId : void 0,
8304
- clientAnthropicBetaHeader
8281
+ clientAnthropicBetaHeader,
8282
+ signal: abort.signal
8305
8283
  })));
8306
8284
  const raced = isStreaming ? await raceWithGrace(settled, RATE_LIMIT_GRACE_MS) : await settled;
8307
8285
  if (raced.kind === "error") throw raced.error;
@@ -8312,6 +8290,7 @@ async function handleDirectAnthropicCompletion(c, anthropicPayload, ctx, initiat
8312
8290
  consola.debug("Streaming response from Copilot (direct Anthropic)");
8313
8291
  updateTrackerStatus(ctx.trackingId, "streaming");
8314
8292
  return streamSSE(c, async (stream) => {
8293
+ stream.onAbort(() => abort.abort());
8315
8294
  await handleDirectAnthropicStreamingResponse({
8316
8295
  stream,
8317
8296
  response,
@@ -8325,6 +8304,7 @@ async function handleDirectAnthropicCompletion(c, anthropicPayload, ctx, initiat
8325
8304
  consola.debug("[RateLimiter] Request queued past grace; opening keepalive stream (direct Anthropic)");
8326
8305
  updateTrackerStatus(ctx.trackingId, "streaming");
8327
8306
  return streamSSE(c, async (stream) => {
8307
+ stream.onAbort(() => abort.abort());
8328
8308
  await runStreamWithKeepalive({
8329
8309
  stream,
8330
8310
  settled,
@@ -8339,6 +8319,11 @@ async function handleDirectAnthropicCompletion(c, anthropicPayload, ctx, initiat
8339
8319
  });
8340
8320
  },
8341
8321
  onError: async (error) => {
8322
+ if (isAbortError(error)) {
8323
+ consola.debug("[Anthropic] client disconnected during keepalive; upstream aborted");
8324
+ failTracking(ctx.trackingId, "client disconnected");
8325
+ return;
8326
+ }
8342
8327
  recordStreamError({
8343
8328
  acc: createAnthropicStreamAccumulator(),
8344
8329
  fallbackModel: anthropicPayload.model,
@@ -8356,6 +8341,11 @@ async function handleDirectAnthropicCompletion(c, anthropicPayload, ctx, initiat
8356
8341
  });
8357
8342
  });
8358
8343
  } catch (error) {
8344
+ if (isAbortError(error)) {
8345
+ consola.debug("[Anthropic] client disconnected before response; upstream aborted");
8346
+ failTracking(ctx.trackingId, "client disconnected");
8347
+ return new Response(null, { status: 499 });
8348
+ }
8359
8349
  if (error instanceof HTTPError && error.status === 413) logPayloadSizeInfoAnthropic(effectivePayload, selectedModel);
8360
8350
  recordErrorResponse(ctx, anthropicPayload.model, error, "messages", anthropicPayload.stream ?? false);
8361
8351
  throw error;
@@ -8510,6 +8500,11 @@ async function handleDirectAnthropicStreamingResponse(opts) {
8510
8500
  toolCount: anthropicPayload.tools?.length ?? 0
8511
8501
  }, ctx.timings);
8512
8502
  } catch (error) {
8503
+ if (isAbortError(error)) {
8504
+ consola.debug("[Anthropic] client disconnected mid-stream; upstream aborted");
8505
+ failTracking(ctx.trackingId, "client disconnected");
8506
+ return;
8507
+ }
8513
8508
  consola.error("Direct Anthropic stream error:", formatError(error));
8514
8509
  recordStreamError({
8515
8510
  acc,
@@ -8599,13 +8594,15 @@ async function handleTranslatedCompletion(c, anthropicPayload, ctx, initiatorOve
8599
8594
  if (autoTruncateConfig.tokenLimitCacheKeyOverride !== void 0) errorModelIdOverride = autoTruncateConfig.tokenLimitCacheKeyOverride;
8600
8595
  else if (needsContext1mBeta && selectedModel) errorModelIdOverride = selectedModel.id;
8601
8596
  else if (hasOneMillionSuffix) errorModelIdOverride = originalModelId;
8597
+ const abort = clientAbortController(c);
8602
8598
  const isStreaming = anthropicPayload.stream === true;
8603
8599
  try {
8604
8600
  const settled = settle(executeWithAdaptiveRateLimit(() => createChatCompletions(openAIPayload, {
8605
8601
  initiator: initiatorOverride,
8606
8602
  resolvedModel: selectedModel,
8607
8603
  anthropicBeta,
8608
- errorModelIdOverride
8604
+ errorModelIdOverride,
8605
+ signal: abort.signal
8609
8606
  })));
8610
8607
  const raced = isStreaming ? await raceWithGrace(settled, RATE_LIMIT_GRACE_MS) : await settled;
8611
8608
  if (raced.kind === "error") throw raced.error;
@@ -8622,6 +8619,7 @@ async function handleTranslatedCompletion(c, anthropicPayload, ctx, initiatorOve
8622
8619
  consola.debug("Streaming response from Copilot");
8623
8620
  updateTrackerStatus(ctx.trackingId, "streaming");
8624
8621
  return streamSSE(c, async (stream) => {
8622
+ stream.onAbort(() => abort.abort());
8625
8623
  await handleStreamingResponse({
8626
8624
  stream,
8627
8625
  response,
@@ -8634,6 +8632,7 @@ async function handleTranslatedCompletion(c, anthropicPayload, ctx, initiatorOve
8634
8632
  consola.debug("[RateLimiter] Request queued past grace; opening keepalive stream (translated)");
8635
8633
  updateTrackerStatus(ctx.trackingId, "streaming");
8636
8634
  return streamSSE(c, async (stream) => {
8635
+ stream.onAbort(() => abort.abort());
8637
8636
  await runStreamWithKeepalive({
8638
8637
  stream,
8639
8638
  settled,
@@ -8650,6 +8649,11 @@ async function handleTranslatedCompletion(c, anthropicPayload, ctx, initiatorOve
8650
8649
  });
8651
8650
  },
8652
8651
  onError: async (error) => {
8652
+ if (isAbortError(error)) {
8653
+ consola.debug("[Translated] client disconnected during keepalive; upstream aborted");
8654
+ failTracking(ctx.trackingId, "client disconnected");
8655
+ return;
8656
+ }
8653
8657
  recordStreamError({
8654
8658
  acc: createAnthropicStreamAccumulator(),
8655
8659
  fallbackModel: anthropicPayload.model,
@@ -8667,6 +8671,11 @@ async function handleTranslatedCompletion(c, anthropicPayload, ctx, initiatorOve
8667
8671
  });
8668
8672
  });
8669
8673
  } catch (error) {
8674
+ if (isAbortError(error)) {
8675
+ consola.debug("[Translated] client disconnected before response; upstream aborted");
8676
+ failTracking(ctx.trackingId, "client disconnected");
8677
+ return new Response(null, { status: 499 });
8678
+ }
8670
8679
  if (error instanceof HTTPError && error.status === 413) await logPayloadSizeInfo(openAIPayload, selectedModel);
8671
8680
  recordErrorResponse(ctx, anthropicPayload.model, error, "messages", anthropicPayload.stream ?? false);
8672
8681
  throw error;
@@ -8755,6 +8764,11 @@ async function handleStreamingResponse(opts) {
8755
8764
  toolCount: anthropicPayload.tools?.length ?? 0
8756
8765
  }, ctx.timings);
8757
8766
  } catch (error) {
8767
+ if (isAbortError(error)) {
8768
+ consola.debug("[Translated] client disconnected mid-stream; upstream aborted");
8769
+ failTracking(ctx.trackingId, "client disconnected");
8770
+ return;
8771
+ }
8758
8772
  consola.error("Stream error:", formatError(error));
8759
8773
  recordStreamError({
8760
8774
  acc,
@@ -9037,7 +9051,7 @@ function injectPromptCacheKey(payload, clientName) {
9037
9051
 
9038
9052
  //#endregion
9039
9053
  //#region src/services/copilot/create-responses.ts
9040
- const createResponses = async (payload, { vision, initiator, resolvedModel }) => {
9054
+ const createResponses = async (payload, { vision, initiator, resolvedModel, signal }) => {
9041
9055
  if (!state.copilotToken) throw new Error("Copilot token not found");
9042
9056
  const modelSupportsVision = resolvedModel?.capabilities?.supports?.vision !== false;
9043
9057
  const headers = {
@@ -9051,7 +9065,8 @@ const createResponses = async (payload, { vision, initiator, resolvedModel }) =>
9051
9065
  const response = await copilotFetch("/responses", {
9052
9066
  method: "POST",
9053
9067
  headers,
9054
- body: JSON.stringify(payload)
9068
+ body: JSON.stringify(payload),
9069
+ signal
9055
9070
  });
9056
9071
  if (!response.ok) {
9057
9072
  consola.error("Failed to create responses", response);
@@ -9333,17 +9348,20 @@ const handleResponses = async (c) => {
9333
9348
  }
9334
9349
  const { vision, initiator } = getResponsesRequestOptions(payload);
9335
9350
  if (state.manualApprove) await awaitApproval();
9351
+ const abort = clientAbortController(c);
9336
9352
  try {
9337
9353
  const { result: response, queueWaitMs } = await executeWithAdaptiveRateLimit(() => createResponses(payload, {
9338
9354
  vision,
9339
9355
  initiator,
9340
- resolvedModel: selectedModel
9356
+ resolvedModel: selectedModel,
9357
+ signal: abort.signal
9341
9358
  }));
9342
9359
  ctx.queueWaitMs = queueWaitMs;
9343
9360
  if (isStreamingRequested(payload) && isAsyncIterable(response)) {
9344
9361
  consola.debug("Forwarding native Responses stream");
9345
9362
  updateTrackerStatus(trackingId, "streaming");
9346
9363
  return streamSSE(c, async (stream) => {
9364
+ stream.onAbort(() => abort.abort());
9347
9365
  const idTracker = createStreamIdTracker();
9348
9366
  let finalResult;
9349
9367
  let streamErrorMessage;
@@ -9392,6 +9410,11 @@ const handleResponses = async (c) => {
9392
9410
  completeTracking(trackingId, 0, 0, queueWaitMs, void 0, void 0, ctx.timings);
9393
9411
  } else completeTracking(trackingId, 0, 0, queueWaitMs, void 0, void 0, ctx.timings);
9394
9412
  } catch (error) {
9413
+ if (isAbortError(error)) {
9414
+ consola.debug("[Responses] client disconnected mid-stream; upstream aborted");
9415
+ failTracking(trackingId, "client disconnected");
9416
+ return;
9417
+ }
9395
9418
  recordStreamError({
9396
9419
  acc: { model: finalResult?.model || model },
9397
9420
  fallbackModel: model,
@@ -9427,6 +9450,11 @@ const handleResponses = async (c) => {
9427
9450
  consola.debug("Forwarding native Responses result:", JSON.stringify(result).slice(-400));
9428
9451
  return c.json(echoResponseBody(result, ctx));
9429
9452
  } catch (error) {
9453
+ if (isAbortError(error)) {
9454
+ consola.debug("[Responses] client disconnected before response; upstream aborted");
9455
+ failTracking(trackingId, "client disconnected");
9456
+ return new Response(null, { status: 499 });
9457
+ }
9430
9458
  recordErrorResponse(ctx, model, error, "responses", stream);
9431
9459
  failTracking(trackingId, error);
9432
9460
  throw error;
@@ -9498,6 +9526,8 @@ responsesRoutes.post("/", async (c) => {
9498
9526
  const tokenRoute = new Hono();
9499
9527
  tokenRoute.get("/", (c) => {
9500
9528
  try {
9529
+ 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);
9530
+ 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);
9501
9531
  return c.json({ token: state.copilotToken });
9502
9532
  } catch (error) {
9503
9533
  return forwardError(c, error);
@@ -9520,6 +9550,11 @@ usageRoute.get("/", async (c) => {
9520
9550
  //#region src/server.ts
9521
9551
  const server = new Hono();
9522
9552
  server.use(tuiLogger());
9553
+ server.use("/token", async (c, next) => {
9554
+ await next();
9555
+ c.header("Access-Control-Allow-Origin", "null");
9556
+ c.header("Access-Control-Allow-Credentials", "false");
9557
+ });
9523
9558
  server.use(cors());
9524
9559
  server.use(authGate());
9525
9560
  server.get("/", (c) => c.text("Server running"));
@@ -9578,6 +9613,8 @@ function formatModelInfo(model) {
9578
9613
  async function runServer(options) {
9579
9614
  consola.info(`copilot-api v${version}`);
9580
9615
  configureProxyApiKey(options.apiKey);
9616
+ state.allowTokenEndpoint = options.allowTokenEndpoint;
9617
+ 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.");
9581
9618
  if (options.verbose) {
9582
9619
  consola.level = 5;
9583
9620
  consola.info("Verbose logging enabled");
@@ -9609,7 +9646,7 @@ async function runServer(options) {
9609
9646
  state.githubToken = options.githubToken;
9610
9647
  consola.info("Using provided GitHub token");
9611
9648
  } else await setupGitHubToken();
9612
- await setupCopilotToken();
9649
+ await bootstrapCopilotSession();
9613
9650
  try {
9614
9651
  await cacheModels();
9615
9652
  } catch (error) {
@@ -9694,6 +9731,11 @@ const start = defineCommand({
9694
9731
  "api-key": {
9695
9732
  type: "string",
9696
9733
  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)."
9734
+ },
9735
+ "allow-token-endpoint": {
9736
+ type: "boolean",
9737
+ default: false,
9738
+ 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."
9697
9739
  }
9698
9740
  },
9699
9741
  run({ args }) {
@@ -9710,7 +9752,8 @@ const start = defineCommand({
9710
9752
  githubToken: args["github-token"] || process.env.GH_TOKEN,
9711
9753
  posthogKey: args["posthog-key"],
9712
9754
  apiKey: resolvedApiKey.key,
9713
- apiKeySource: resolvedApiKey.source
9755
+ apiKeySource: resolvedApiKey.source,
9756
+ allowTokenEndpoint: args["allow-token-endpoint"] || process.env.COPILOT_ALLOW_TOKEN_ENDPOINT === "1" || process.env.COPILOT_ALLOW_TOKEN_ENDPOINT === "true"
9714
9757
  });
9715
9758
  }
9716
9759
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dianshuv/copilot-api",
3
- "version": "0.15.0",
3
+ "version": "0.16.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",