1use 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
25const COPILOT_USAGE_PATH: &str = "/copilot_internal/user";
28const COPILOT_FALLBACK_VERSION: &str = "1.0.68";
31const COPILOT_INTEGRATION_ID: &str = "copilot-cli";
33const COPILOT_API_VERSION: &str = "2025-04-01";
35pub const COPILOT_LOGIN_HINT: &str = "run: copilot login";
37
38fn 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
54fn 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 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
105struct CopilotCreds {
107 api_url: String,
108 token: String,
109}
110
111fn 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 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
151fn 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
164pub 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 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 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
234enum FetchResult {
236 Ok(CopilotQuotaSnapshot),
237 Unauthorized,
239 Transient,
241}
242
243fn 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 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
284pub fn fetch_copilot_raw(client: &Client) -> Result<(u16, String)> {
294 fetch_copilot_raw_from(client, &get_copilot_config_path()?)
295}
296
297pub(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#[derive(Default)]
336pub struct CopilotState;
337
338impl CopilotState {
339 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 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 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 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 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 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 #[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}