@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.
- package/README.md +3 -2
- package/dist/main.mjs +174 -168
- 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`,
|
|
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 |
|
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.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)
|
|
8482
|
-
|
|
8483
|
-
|
|
8484
|
-
|
|
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)
|
|
8795
|
-
|
|
8796
|
-
|
|
8797
|
-
|
|
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
|
|
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
|
});
|