Skip to main content

vct_core/quota/
copilot.rs

1//! GitHub Copilot quota fetcher.
2//!
3//! Reads the long-lived GitHub OAuth token (`gho_...`) from the Copilot CLI's
4//! `~/.copilot/config.json`, calls the internal usage API
5//! (`GET /copilot_internal/user`), and maps the premium-interactions quota into
6//! the shared snapshot shape. The token is long-lived and the file carries no
7//! refresh token, so there is **no** token refresh here: a 401/403 means the
8//! account is logged out and surfaces a `copilot login` hint.
9//!
10//! The request impersonates the Copilot CLI (the client the token belongs to):
11//! a `GitHubCopilotCLI/<version>` User-Agent (version detected from
12//! `copilot --version`, cached for the day) plus `Copilot-Integration-Id:
13//! copilot-cli`. These identity headers are camouflage; the endpoint answers a
14//! bare bearer token.
15
16use crate::models::{CopilotQuotaSnapshot, CopilotUserResponse, QuotaSource, QuotaWindow};
17use crate::quota::http::{detect_cli_version, iso_to_unix_secs};
18use crate::quota::provider::QuotaOutcome;
19use crate::utils::get_copilot_config_path;
20use anyhow::{Context, Result};
21use reqwest::blocking::Client;
22use std::path::Path;
23use std::sync::OnceLock;
24
25/// Path of the Copilot internal usage endpoint (the host is derived per account
26/// so GHE data-residency logins reach `api.<host>` instead of api.github.com).
27const COPILOT_USAGE_PATH: &str = "/copilot_internal/user";
28/// Fallback Copilot CLI version for the User-Agent when `copilot --version`
29/// cannot be resolved (CLI absent or unreadable). Bump occasionally.
30const COPILOT_FALLBACK_VERSION: &str = "1.0.68";
31/// The Copilot CLI's integration id (confirmed from the CLI bundle).
32const COPILOT_INTEGRATION_ID: &str = "copilot-cli";
33/// GitHub API version pinned by the request.
34const COPILOT_API_VERSION: &str = "2025-04-01";
35/// Login hint shown when the Copilot token is rejected.
36pub const COPILOT_LOGIN_HINT: &str = "run: copilot login";
37
38/// Builds the Copilot CLI's request User-Agent, e.g. `GitHubCopilotCLI/1.0.68`.
39///
40/// The version is detected from the installed CLI (see
41/// [`crate::quota::http::detect_cli_version`]) so the UA tracks the real client
42/// rather than drifting from a hardcoded constant.
43fn copilot_ua() -> &'static str {
44    static UA: OnceLock<String> = OnceLock::new();
45    UA.get_or_init(|| {
46        format!(
47            "GitHubCopilotCLI/{}",
48            detect_cli_version("copilot", "copilot_version.json", COPILOT_FALLBACK_VERSION)
49        )
50    })
51    .as_str()
52}
53
54/// Strips `//` line comments and `/* */` block comments from a JSONC string,
55/// respecting string context so a `//` inside a value (e.g. a
56/// `"https://github.com:login"` key) is never removed.
57fn strip_jsonc_comments(input: &str) -> String {
58    let mut out = String::with_capacity(input.len());
59    let mut chars = input.chars().peekable();
60    let mut in_string = false;
61    let mut escaped = false;
62    while let Some(c) = chars.next() {
63        if in_string {
64            out.push(c);
65            if escaped {
66                escaped = false;
67            } else if c == '\\' {
68                escaped = true;
69            } else if c == '"' {
70                in_string = false;
71            }
72            continue;
73        }
74        match c {
75            '"' => {
76                in_string = true;
77                out.push(c);
78            }
79            '/' if chars.peek() == Some(&'/') => {
80                chars.next();
81                // Skip to end of line, preserving the newline for line counting.
82                for nc in chars.by_ref() {
83                    if nc == '\n' {
84                        out.push('\n');
85                        break;
86                    }
87                }
88            }
89            '/' if chars.peek() == Some(&'*') => {
90                chars.next();
91                let mut prev = '\0';
92                for nc in chars.by_ref() {
93                    if prev == '*' && nc == '/' {
94                        break;
95                    }
96                    prev = nc;
97                }
98            }
99            _ => out.push(c),
100        }
101    }
102    out
103}
104
105/// A resolved Copilot credential: the entitlement API URL + the OAuth token.
106struct CopilotCreds {
107    api_url: String,
108    token: String,
109}
110
111/// Reads the `gho_...` GitHub token from the (JSONC) Copilot config and derives
112/// the entitlement API host from the account's login host.
113///
114/// Prefers the token for `lastLoggedInUser` (`<host>:<login>`) so a config that
115/// still holds several accounts queries the one the user is actually on, then
116/// falls back to the first `https://github.com` entry.
117fn read_copilot_creds(body: &str) -> Option<CopilotCreds> {
118    let stripped = strip_jsonc_comments(body);
119    let root: serde_json::Value = serde_json::from_str(&stripped).ok()?;
120    let tokens = root.get("copilotTokens")?.as_object()?;
121
122    let entry_token =
123        |v: &serde_json::Value| v.as_str().filter(|s| !s.is_empty()).map(str::to_string);
124
125    // The `copilotTokens` keys are `<host>:<login>`; match the last-logged-in
126    // account first, then fall back to the first GitHub entry.
127    let preferred = root.get("lastLoggedInUser").and_then(|user| {
128        let host = user.get("host")?.as_str()?;
129        let login = user.get("login")?.as_str()?;
130        let key = format!("{host}:{login}");
131        let token = tokens.get(&key).and_then(entry_token)?;
132        Some((key, token))
133    });
134
135    let (key, token) = match preferred {
136        Some(pair) => pair,
137        None => {
138            let (k, v) = tokens
139                .iter()
140                .find(|(k, _)| k.starts_with("https://github.com"))?;
141            (k.clone(), entry_token(v)?)
142        }
143    };
144
145    Some(CopilotCreds {
146        api_url: copilot_api_url(&key),
147        token,
148    })
149}
150
151/// Derives the entitlement API URL from a `copilotTokens` key (`<host>:<login>`).
152///
153/// `https://github.com:me` -> `https://api.github.com/copilot_internal/user`; a
154/// GHE data-residency host (`https://acme.ghe.com:me`) maps to
155/// `https://api.acme.ghe.com/...` so those tokens reach the correct host.
156fn copilot_api_url(key: &str) -> String {
157    let host = key.rsplit_once(':').map(|(h, _)| h).unwrap_or(key);
158    let domain = host
159        .trim_start_matches("https://")
160        .trim_start_matches("http://");
161    format!("https://api.{domain}{COPILOT_USAGE_PATH}")
162}
163
164/// Maps a `/copilot_internal/user` body into a [`CopilotQuotaSnapshot`] (pure).
165///
166/// # Errors
167///
168/// Returns an error if the body is not valid JSON in the expected shape.
169pub fn map_copilot_user(body: &str, now: i64) -> Result<CopilotQuotaSnapshot> {
170    let resp: CopilotUserResponse =
171        serde_json::from_str(body).context("Failed to parse Copilot user response")?;
172
173    // Prefer the UTC instant; fall back to the date-only field (midnight UTC)
174    // so the gauge still shows a reset countdown when only that is returned.
175    let reset = resp
176        .quota_reset_date_utc
177        .as_deref()
178        .and_then(iso_to_unix_secs)
179        .or_else(|| {
180            resp.quota_reset_date
181                .as_deref()
182                .and_then(|d| iso_to_unix_secs(&format!("{d}T00:00:00Z")))
183        });
184
185    let snaps = resp.quota_snapshots.as_ref();
186    let premium_entry = snaps.and_then(|s| s.premium_interactions.as_ref());
187
188    let premium_unlimited = premium_entry.and_then(|e| e.unlimited).unwrap_or(false);
189    let premium_remaining = premium_entry.and_then(|e| e.remaining).map(|v| v as i64);
190    let premium_entitlement = premium_entry.and_then(|e| e.entitlement).map(|v| v as i64);
191
192    // Derive the used-percent from `percent_remaining` (preferred) or the
193    // remaining/entitlement ratio. A zero-entitlement placeholder (common for
194    // token-based / business seats) carries no real gauge, so drop it rather
195    // than render a misleading empty bar.
196    let placeholder = matches!((premium_remaining, premium_entitlement), (Some(0), Some(0)));
197    let premium = if premium_unlimited || placeholder {
198        None
199    } else {
200        premium_entry.and_then(|e| {
201            let used = match e.percent_remaining {
202                Some(pr) => 100.0 - pr,
203                None => match (e.remaining, e.entitlement) {
204                    (Some(r), Some(t)) if t > 0.0 => (1.0 - r / t) * 100.0,
205                    _ => return None,
206                },
207            };
208            Some(QuotaWindow {
209                used_percent: used.clamp(0.0, 100.0),
210                resets_at_unix: reset,
211            })
212        })
213    };
214
215    let limit_reached = !premium_unlimited
216        && premium
217            .as_ref()
218            .map(|w| w.used_percent >= 100.0)
219            .unwrap_or(false);
220
221    Ok(CopilotQuotaSnapshot {
222        source: QuotaSource::Api,
223        fetched_at: now,
224        plan_type: resp.copilot_plan.filter(|s| !s.is_empty()),
225        premium,
226        premium_remaining,
227        premium_entitlement,
228        premium_unlimited,
229        limit_reached,
230        needs_login: false,
231    })
232}
233
234/// Outcome of a single usage request.
235enum FetchResult {
236    Ok(CopilotQuotaSnapshot),
237    /// 401/403 → the token is rejected; there is no refresh, so re-login.
238    Unauthorized,
239    /// Network / other non-success error → keep last-known-good.
240    Transient,
241}
242
243/// Calls the Copilot usage API with `token`, impersonating the Copilot CLI.
244fn fetch_copilot_user(client: &Client, api_url: &str, token: &str, now: i64) -> FetchResult {
245    let resp = match client
246        .get(api_url)
247        .header(reqwest::header::AUTHORIZATION, format!("token {token}"))
248        .header(reqwest::header::ACCEPT, "application/json")
249        .header(reqwest::header::USER_AGENT, copilot_ua())
250        .header("Copilot-Integration-Id", COPILOT_INTEGRATION_ID)
251        .header("X-GitHub-Api-Version", COPILOT_API_VERSION)
252        .send()
253    {
254        Ok(r) => r,
255        Err(e) => {
256            log::warn!("copilot quota fetch: request failed: {e}");
257            return FetchResult::Transient;
258        }
259    };
260    let status = resp.status();
261    // Copilot has no refresh; a logged-out account answers 401 or 403.
262    if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
263        return FetchResult::Unauthorized;
264    }
265    if !status.is_success() {
266        log::warn!("copilot quota fetch: HTTP {status}");
267        return FetchResult::Transient;
268    }
269    match resp.text() {
270        Ok(text) => match map_copilot_user(&text, now) {
271            Ok(snap) => FetchResult::Ok(snap),
272            Err(e) => {
273                log::warn!("copilot quota fetch: failed to parse user response: {e}");
274                FetchResult::Transient
275            }
276        },
277        Err(e) => {
278            log::warn!("copilot quota fetch: failed to read response body: {e}");
279            FetchResult::Transient
280        }
281    }
282}
283
284/// Fetches the raw `/copilot_internal/user` response for `vct fetch copilot`.
285///
286/// Uses the stored `gho_` token verbatim (Copilot has no refresh) and returns
287/// `(status_code, body)`. A non-2xx status is left for the caller to surface.
288///
289/// # Errors
290///
291/// Returns an error if the config is missing, has no usable token, or the
292/// request cannot be sent.
293pub fn fetch_copilot_raw(client: &Client) -> Result<(u16, String)> {
294    fetch_copilot_raw_from(client, &get_copilot_config_path()?)
295}
296
297/// The injectable core of [`fetch_copilot_raw`]: reads the token from an explicit
298/// config path (the API host is derived from the token key). Production passes
299/// `~/.copilot/config.json`; tests pass a temp config file.
300pub(crate) fn fetch_copilot_raw_from(client: &Client, config_path: &Path) -> Result<(u16, String)> {
301    let path = config_path;
302    let body = std::fs::read_to_string(path).with_context(|| {
303        format!(
304            "no Copilot credentials at {} ({COPILOT_LOGIN_HINT})",
305            path.display()
306        )
307    })?;
308    let creds = read_copilot_creds(&body).with_context(|| {
309        format!(
310            "no usable Copilot token in {} ({COPILOT_LOGIN_HINT})",
311            path.display()
312        )
313    })?;
314    let resp = client
315        .get(creds.api_url.as_str())
316        .header(
317            reqwest::header::AUTHORIZATION,
318            format!("token {}", creds.token),
319        )
320        .header(reqwest::header::ACCEPT, "application/json")
321        .header(reqwest::header::USER_AGENT, copilot_ua())
322        .header("Copilot-Integration-Id", COPILOT_INTEGRATION_ID)
323        .header("X-GitHub-Api-Version", COPILOT_API_VERSION)
324        .send()
325        .context("Failed to send Copilot usage request")?;
326    let status = resp.status().as_u16();
327    let text = resp
328        .text()
329        .context("Failed to read Copilot usage response body")?;
330    Ok((status, text))
331}
332
333/// Per-worker Copilot state. Copilot's token is long-lived with no refresh, so
334/// there is no in-memory token cache or refresh backoff to keep.
335#[derive(Default)]
336pub struct CopilotState;
337
338impl CopilotState {
339    /// One worker tick: read the token, call the usage API.
340    pub fn resolve(&mut self, client: &Client) -> QuotaOutcome<CopilotQuotaSnapshot> {
341        let now = chrono::Local::now().timestamp();
342        let path = match get_copilot_config_path() {
343            Ok(p) => p,
344            Err(_) => return QuotaOutcome::Transient,
345        };
346        let body = match std::fs::read_to_string(&path) {
347            Ok(b) => b,
348            Err(_) => return QuotaOutcome::Transient,
349        };
350        let creds = match read_copilot_creds(&body) {
351            Some(c) => c,
352            None => return QuotaOutcome::NeedsLogin,
353        };
354        match fetch_copilot_user(client, &creds.api_url, &creds.token, now) {
355            FetchResult::Ok(snap) => QuotaOutcome::Data(snap),
356            FetchResult::Unauthorized => QuotaOutcome::NeedsLogin,
357            FetchResult::Transient => QuotaOutcome::Transient,
358        }
359    }
360}
361
362#[cfg(test)]
363mod tests {
364    use super::*;
365
366    // A real (redacted) config.json shape: JSONC with leading comments and a
367    // URL key whose value contains `//`.
368    const CONFIG: &str = r#"// User settings belong in settings.json.
369// This file is managed automatically.
370{
371  "firstLaunchAt": "2026-04-27T16:16:13.673Z",
372  "copilotTokens": {
373    "https://github.com:octocat": "gho_EXAMPLETOKEN"
374  }
375}"#;
376
377    #[test]
378    fn strips_comments_without_eating_url_slashes() {
379        let out = strip_jsonc_comments(CONFIG);
380        assert!(!out.contains("// User settings"));
381        // The `//` inside the string value must survive.
382        assert!(out.contains("https://github.com:octocat"));
383        let creds = read_copilot_creds(CONFIG).unwrap();
384        assert_eq!(creds.token, "gho_EXAMPLETOKEN");
385        assert_eq!(
386            creds.api_url,
387            "https://api.github.com/copilot_internal/user"
388        );
389    }
390
391    #[test]
392    fn block_comments_are_stripped_outside_strings() {
393        let src = r#"{ /* c */ "a": "x /* not a comment */ y" }"#;
394        let out = strip_jsonc_comments(src);
395        let v: serde_json::Value = serde_json::from_str(&out).unwrap();
396        assert_eq!(
397            v.get("a").unwrap().as_str().unwrap(),
398            "x /* not a comment */ y"
399        );
400    }
401
402    #[test]
403    fn no_token_returns_none() {
404        assert!(read_copilot_creds(r#"{ "copilotTokens": {} }"#).is_none());
405        assert!(read_copilot_creds(r#"{}"#).is_none());
406    }
407
408    #[test]
409    fn prefers_last_logged_in_account_token() {
410        let cfg = r#"{
411            "copilotTokens": {
412                "https://github.com:alice": "gho_ALICE",
413                "https://github.com:bob": "gho_BOB"
414            },
415            "lastLoggedInUser": { "host": "https://github.com", "login": "bob" }
416        }"#;
417        assert_eq!(read_copilot_creds(cfg).unwrap().token, "gho_BOB");
418    }
419
420    #[test]
421    fn falls_back_to_a_github_token_without_last_user() {
422        let cfg = r#"{ "copilotTokens": { "https://github.com:alice": "gho_ALICE" } }"#;
423        assert_eq!(read_copilot_creds(cfg).unwrap().token, "gho_ALICE");
424    }
425
426    #[test]
427    fn derives_api_host_from_login_host() {
428        assert_eq!(
429            copilot_api_url("https://github.com:me"),
430            "https://api.github.com/copilot_internal/user"
431        );
432        // GHE data-residency host keeps its subdomain.
433        assert_eq!(
434            copilot_api_url("https://acme.ghe.com:me"),
435            "https://api.acme.ghe.com/copilot_internal/user"
436        );
437    }
438
439    #[test]
440    fn ghe_host_creds_target_the_ghe_api() {
441        let cfg = r#"{
442            "copilotTokens": { "https://acme.ghe.com:me": "gho_GHE" },
443            "lastLoggedInUser": { "host": "https://acme.ghe.com", "login": "me" }
444        }"#;
445        let creds = read_copilot_creds(cfg).unwrap();
446        assert_eq!(creds.token, "gho_GHE");
447        assert_eq!(
448            creds.api_url,
449            "https://api.acme.ghe.com/copilot_internal/user"
450        );
451    }
452
453    const USER: &str = r#"{
454      "copilot_plan": "individual",
455      "quota_reset_date": "2026-08-01",
456      "quota_reset_date_utc": "2026-08-01T00:00:00.000Z",
457      "quota_snapshots": {
458        "premium_interactions": { "percent_remaining": 97.6, "remaining": 1464, "entitlement": 1500, "unlimited": false },
459        "chat": { "unlimited": true },
460        "completions": { "unlimited": true }
461      }
462    }"#;
463
464    #[test]
465    fn maps_copilot_user() {
466        let snap = map_copilot_user(USER, 1_000_000).unwrap();
467        assert_eq!(snap.source, QuotaSource::Api);
468        assert_eq!(snap.plan_type.as_deref(), Some("individual"));
469        let prem = snap.premium.as_ref().unwrap();
470        // 100 - 97.6 = 2.4 used.
471        assert!((prem.used_percent - 2.4).abs() < 1e-6);
472        assert!(prem.resets_at_unix.unwrap() > 0);
473        assert_eq!(snap.premium_remaining, Some(1464));
474        assert_eq!(snap.premium_entitlement, Some(1500));
475        assert!(!snap.limit_reached);
476        assert!(!snap.needs_login);
477    }
478
479    #[test]
480    fn derives_used_from_ratio_when_percent_absent() {
481        let body = r#"{ "quota_snapshots": { "premium_interactions": { "remaining": 750, "entitlement": 1500 } } }"#;
482        let snap = map_copilot_user(body, 1).unwrap();
483        assert!((snap.premium.unwrap().used_percent - 50.0).abs() < 1e-6);
484    }
485
486    #[test]
487    fn drops_zero_entitlement_placeholder() {
488        let body = r#"{ "quota_snapshots": { "premium_interactions": { "remaining": 0, "entitlement": 0, "percent_remaining": 100 } } }"#;
489        let snap = map_copilot_user(body, 1).unwrap();
490        assert!(
491            snap.premium.is_none(),
492            "0/0 placeholder is not a real gauge"
493        );
494        assert!(!snap.limit_reached);
495    }
496
497    #[test]
498    fn flags_limit_when_exhausted() {
499        let body = r#"{ "quota_snapshots": { "premium_interactions": { "percent_remaining": 0, "remaining": 0, "entitlement": 1500 } } }"#;
500        let snap = map_copilot_user(body, 1).unwrap();
501        assert_eq!(snap.premium.as_ref().unwrap().used_percent, 100.0);
502        assert!(snap.limit_reached);
503    }
504
505    #[test]
506    fn missing_snapshots_is_tolerated() {
507        let snap = map_copilot_user(r#"{ "copilot_plan": "business" }"#, 1).unwrap();
508        assert!(snap.premium.is_none());
509        assert_eq!(snap.plan_type.as_deref(), Some("business"));
510        assert!(!snap.limit_reached);
511    }
512
513    #[test]
514    fn reset_falls_back_to_date_only_field() {
515        // Only the date-only field is present (no UTC instant).
516        let body = r#"{
517          "quota_reset_date": "2026-08-01",
518          "quota_snapshots": { "premium_interactions": { "percent_remaining": 50, "remaining": 750, "entitlement": 1500 } }
519        }"#;
520        let snap = map_copilot_user(body, 1).unwrap();
521        assert!(
522            snap.premium.as_ref().unwrap().resets_at_unix.unwrap() > 0,
523            "date-only reset should still yield a timestamp"
524        );
525    }
526
527    // ---- HTTP-layer tests against a local mock server (no real API) ----
528    //
529    // `fetch_copilot_user` takes the API URL as a parameter, so it can be pointed
530    // at a local mock. (`fetch_copilot_raw` derives an `https://api.<host>` URL
531    // from the config and is intentionally not driven here to avoid any real
532    // request; its send path is identical to the one exercised below.)
533
534    #[test]
535    fn fetch_copilot_user_maps_200_and_401() {
536        use crate::quota::http::build_client;
537        use httpmock::prelude::*;
538
539        let server = MockServer::start();
540        let ok = server.mock(|when, then| {
541            when.method(GET).path("/user");
542            then.status(200).body(USER);
543        });
544        server.mock(|when, then| {
545            when.method(GET).path("/denied");
546            then.status(401);
547        });
548        let client = build_client().unwrap();
549
550        match fetch_copilot_user(&client, &server.url("/user"), "gho_x", 1_000_000) {
551            FetchResult::Ok(snap) => assert!(snap.premium.is_some()),
552            _ => panic!("expected Ok"),
553        }
554        ok.assert();
555        assert!(matches!(
556            fetch_copilot_user(&client, &server.url("/denied"), "gho_x", 0),
557            FetchResult::Unauthorized
558        ));
559    }
560}