@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.
- package/README.md +4 -6
- package/dist/main.mjs +200 -157
- 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
|
|
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`,
|
|
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
|
|
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) =>
|
|
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
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
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
|
-
*
|
|
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
|
-
*
|
|
663
|
-
*
|
|
664
|
-
*
|
|
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
|
|
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
|
-
|
|
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.
|
|
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, {
|
|
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
|
|
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
|
});
|