1use std::collections::HashSet;
6use std::time::Duration;
7
8use serde_json::{Map, Value};
9use tokio_util::sync::CancellationToken;
10use yapi_types::auth::OAuthCredential;
11
12use super::device::{Poll, poll_device_code};
13use super::{
14 AuthError, AuthEvent, AuthPrompt, BoxFuture, Interaction, LoginOptions, OAuthAuth,
15 OAuthProvider, json_body, post_form, send,
16};
17
18const CLIENT_ID: &str = "Iv1.b507a08c87ecfe98";
20const USER_AGENT: &str = "GitHubCopilotChat/0.35.0";
21const HEADERS: [(&str, &str); 4] = [
22 ("User-Agent", USER_AGENT),
23 ("Editor-Version", "vscode/1.107.0"),
24 ("Editor-Plugin-Version", "copilot-chat/0.35.0"),
25 ("Copilot-Integration-Id", "vscode-chat"),
26];
27const API_VERSION: &str = "2026-06-01";
28const DEFAULT_BASE_URL: &str = "https://api.individual.githubcopilot.com";
29const EXPIRY_MARGIN_MS: u64 = 5 * 60 * 1000;
30
31#[derive(Clone, Debug, Default)]
34pub struct CopilotOAuth {
35 pub github_url: Option<String>,
37 pub api_url: Option<String>,
39}
40
41fn normalize_domain(input: &str) -> Option<String> {
43 let trimmed = input.trim();
44 if trimmed.is_empty() {
45 return None;
46 }
47 let candidate = if trimmed.contains("://") {
48 trimmed.to_owned()
49 } else {
50 format!("https://{trimmed}")
51 };
52 url::Url::parse(&candidate)
53 .ok()?
54 .host_str()
55 .map(str::to_owned)
56}
57
58fn base_url_from_token(token: &str) -> Option<String> {
60 let start = token.find("proxy-ep=")? + "proxy-ep=".len();
61 let host = token[start..].split(';').next()?;
62 if host.is_empty() {
63 return None;
64 }
65 let host = host
66 .strip_prefix("proxy.")
67 .map_or_else(|| host.to_owned(), |rest| format!("api.{rest}"));
68 Some(format!("https://{host}"))
69}
70
71fn enterprise_domain(credential: &OAuthCredential) -> Option<String> {
72 credential
73 .extra
74 .get("enterpriseUrl")
75 .and_then(Value::as_str)
76 .filter(|url| !url.is_empty())
77 .and_then(normalize_domain)
78}
79
80#[derive(Debug, Default, PartialEq)]
82struct Catalog {
83 available: Vec<String>,
84 policy: Vec<String>,
85}
86
87fn parse_catalog(raw: &Value, allow_policy_fallback: bool) -> Result<Catalog, AuthError> {
88 let data = raw
89 .get("data")
90 .and_then(Value::as_array)
91 .ok_or_else(|| AuthError::failed("Invalid Copilot models response"))?;
92 struct Entry {
93 id: String,
94 picker: bool,
95 policy: Option<String>,
96 }
97 let entries: Vec<Entry> = data
98 .iter()
99 .filter_map(|item| {
100 let id = item.get("id")?.as_str()?.to_owned();
101 let tool_calls = item.pointer("/capabilities/supports/tool_calls");
102 if tool_calls == Some(&Value::Bool(false)) {
103 return None;
104 }
105 Some(Entry {
106 id,
107 picker: item.get("model_picker_enabled") == Some(&Value::Bool(true)),
108 policy: item
109 .pointer("/policy/state")
110 .and_then(Value::as_str)
111 .map(str::to_owned),
112 })
113 })
114 .collect();
115 let picker: Vec<String> = entries
116 .iter()
117 .filter(|entry| entry.picker && entry.policy.as_deref() != Some("disabled"))
118 .map(|entry| entry.id.clone())
119 .collect();
120 let use_policy_fallback = allow_policy_fallback && picker.is_empty();
121 let available = if !picker.is_empty() || !allow_policy_fallback {
122 picker
123 } else {
124 entries
125 .iter()
126 .filter(|entry| entry.policy.as_deref() == Some("enabled"))
127 .map(|entry| entry.id.clone())
128 .collect()
129 };
130 let known: HashSet<String> = crate::catalog::builtin_models("github-copilot")
131 .into_iter()
132 .map(|model| model.id)
133 .collect();
134 let policy = entries
135 .iter()
136 .filter(|entry| {
137 entry.policy.as_deref() == Some("unconfigured")
138 && known.contains(&entry.id)
139 && (entry.picker || use_policy_fallback)
140 })
141 .map(|entry| entry.id.clone())
142 .collect();
143 Ok(Catalog { available, policy })
144}
145
146async fn fetch_json(
147 request: reqwest::RequestBuilder,
148 cancel: &CancellationToken,
149) -> Result<Value, AuthError> {
150 let response = send(request, cancel).await?;
151 if !response.status().is_success() {
152 let status = response.status();
153 let text = response.text().await.unwrap_or_default();
154 return Err(AuthError::Failed(format!(
155 "{} {}: {text}",
156 status.as_u16(),
157 status.canonical_reason().unwrap_or_default()
158 )));
159 }
160 Ok(json_body(response).await)
161}
162
163async fn send_with_rate_limit_retry(
166 build: impl Fn() -> reqwest::RequestBuilder,
167 cancel: &CancellationToken,
168 max_retries: u32,
169 budget: Duration,
170) -> Result<reqwest::Response, AuthError> {
171 let deadline = tokio::time::Instant::now() + budget;
172 let mut retry = 0;
173 loop {
174 let response = send(build().timeout(Duration::from_secs(5)), cancel).await?;
175 if response.status().as_u16() != 429 || retry == max_retries {
176 return Ok(response);
177 }
178 let delay = match response
179 .headers()
180 .get("retry-after")
181 .and_then(|value| value.to_str().ok())
182 {
183 Some(value) => match value.trim().parse::<f64>() {
184 Ok(seconds) => {
185 Duration::try_from_secs_f64(seconds.max(0.0)).unwrap_or(Duration::MAX)
186 }
187 Err(_) => return Ok(response),
188 },
189 None => Duration::from_millis(500 * 2u64.pow(retry)),
190 };
191 if delay >= deadline.saturating_duration_since(tokio::time::Instant::now()) {
192 return Ok(response);
193 }
194 retry += 1;
195 cancel
196 .run_until_cancelled(tokio::time::sleep(delay))
197 .await
198 .ok_or(AuthError::Cancelled)?;
199 }
200}
201
202impl CopilotOAuth {
203 fn github(&self, domain: &str) -> String {
204 self.github_url
205 .clone()
206 .unwrap_or_else(|| format!("https://{domain}"))
207 }
208
209 fn github_api(&self, domain: &str) -> String {
210 self.github_url
211 .clone()
212 .unwrap_or_else(|| format!("https://api.{domain}"))
213 }
214
215 fn base_url(&self, token: &str, enterprise: Option<&str>) -> String {
216 if let Some(url) = &self.api_url {
217 return url.clone();
218 }
219 base_url_from_token(token)
220 .or_else(|| enterprise.map(|domain| format!("https://copilot-api.{domain}")))
221 .unwrap_or_else(|| DEFAULT_BASE_URL.to_owned())
222 }
223
224 async fn copilot_token(
225 &self,
226 github_token: &str,
227 enterprise: Option<&str>,
228 cancel: &CancellationToken,
229 ) -> Result<OAuthCredential, AuthError> {
230 let domain = enterprise.unwrap_or("github.com");
231 let mut request = crate::http::client()
232 .get(format!(
233 "{}/copilot_internal/v2/token",
234 self.github_api(domain)
235 ))
236 .header("Accept", "application/json")
237 .header("Authorization", format!("Bearer {github_token}"));
238 for (name, value) in HEADERS {
239 request = request.header(name, value);
240 }
241 let raw = fetch_json(request, cancel).await?;
242 if !raw.is_object() {
243 return Err(AuthError::failed("Invalid Copilot token response"));
244 }
245 let (Some(token), Some(expires_at)) = (
246 raw.get("token").and_then(Value::as_str),
247 raw.get("expires_at").and_then(Value::as_f64),
248 ) else {
249 return Err(AuthError::failed("Invalid Copilot token response fields"));
250 };
251 let mut extra = Map::new();
252 if let Some(domain) = enterprise {
253 extra.insert("enterpriseUrl".into(), Value::String(domain.to_owned()));
254 }
255 let expires = (expires_at * 1000.0).max(0.0) as u64;
256 Ok(OAuthCredential {
257 access: token.to_owned(),
258 refresh: github_token.to_owned(),
259 expires: expires.saturating_sub(EXPIRY_MARGIN_MS) as f64,
260 extra,
261 })
262 }
263
264 async fn models(
265 &self,
266 token: &str,
267 enterprise: Option<&str>,
268 cancel: &CancellationToken,
269 max_retries: u32,
270 budget: Duration,
271 ) -> Result<Catalog, AuthError> {
272 let base = self.base_url(token, enterprise);
273 let allow_policy_fallback = base == DEFAULT_BASE_URL;
276 let build = || {
277 let mut request = crate::http::client()
278 .get(format!("{base}/models"))
279 .header("Accept", "application/json")
280 .header("Authorization", format!("Bearer {token}"));
281 for (name, value) in HEADERS {
282 request = request.header(name, value);
283 }
284 request.header("X-GitHub-Api-Version", API_VERSION)
285 };
286 let response = send_with_rate_limit_retry(build, cancel, max_retries, budget).await?;
287 if !response.status().is_success() {
288 let status = response.status();
289 let text = response.text().await.unwrap_or_default();
290 return Err(AuthError::Failed(format!(
291 "{} {}: {text}",
292 status.as_u16(),
293 status.canonical_reason().unwrap_or_default()
294 )));
295 }
296 parse_catalog(&json_body(response).await, allow_policy_fallback)
297 }
298
299 async fn enable_models(
302 &self,
303 token: &str,
304 ids: &[String],
305 enterprise: Option<&str>,
306 cancel: &CancellationToken,
307 ) -> Result<Vec<String>, AuthError> {
308 let base = self.base_url(token, enterprise);
309 let mut enabled = Vec::new();
310 for id in ids {
311 let build = || {
312 let mut request = crate::http::client()
313 .post(format!("{base}/models/{id}/policy"))
314 .header("Content-Type", "application/json")
315 .header("Authorization", format!("Bearer {token}"));
316 for (name, value) in HEADERS {
317 request = request.header(name, value);
318 }
319 request
320 .header("openai-intent", "chat-policy")
321 .header("x-interaction-type", "chat-policy")
322 .body("{\"state\":\"enabled\"}")
323 };
324 match send_with_rate_limit_retry(build, cancel, 2, Duration::from_secs(5)).await {
325 Ok(response) if response.status().as_u16() == 429 => break,
326 Ok(response) if response.status().is_success() => enabled.push(id.clone()),
327 Ok(_) => {}
328 Err(AuthError::Cancelled) => return Err(AuthError::Cancelled),
329 Err(_) => {}
330 }
331 }
332 Ok(enabled)
333 }
334
335 async fn login_copilot(&self, interaction: &Interaction) -> Result<OAuthCredential, AuthError> {
336 let input = interaction
337 .prompt(AuthPrompt::Text {
338 message: "GitHub Enterprise URL/domain (blank for github.com)".into(),
339 placeholder: Some("company.ghe.com".into()),
340 })
341 .await?;
342 interaction.check()?;
343 let enterprise = normalize_domain(&input);
344 if !input.trim().is_empty() && enterprise.is_none() {
345 return Err(AuthError::failed("Invalid GitHub Enterprise URL/domain"));
346 }
347 let domain = enterprise.clone().unwrap_or_else(|| "github.com".into());
348 let cancel = interaction.cancel();
349
350 let request = post_form(
351 format!("{}/login/device/code", self.github(&domain)),
352 &[("client_id", CLIENT_ID), ("scope", "read:user")],
353 )
354 .header("User-Agent", USER_AGENT);
355 let device = fetch_json(request, cancel).await?;
356 if !device.is_object() {
357 return Err(AuthError::failed("Invalid device code response"));
358 }
359 let interval = device.get("interval");
360 let (Some(device_code), Some(user_code), Some(verification_uri), Some(expires_in)) = (
361 device.get("device_code").and_then(Value::as_str),
362 device.get("user_code").and_then(Value::as_str),
363 device.get("verification_uri").and_then(Value::as_str),
364 device.get("expires_in").and_then(Value::as_f64),
365 ) else {
366 return Err(AuthError::failed("Invalid device code response fields"));
367 };
368 if interval.is_some_and(|interval| !interval.is_number()) {
369 return Err(AuthError::failed("Invalid device code response fields"));
370 }
371 let interval = interval.and_then(Value::as_f64);
372 let verification_uri = url::Url::parse(verification_uri)
374 .ok()
375 .filter(|url| matches!(url.scheme(), "https" | "http"))
376 .ok_or_else(|| {
377 AuthError::failed("Untrusted verification_uri in device code response")
378 })?;
379 interaction.notify(AuthEvent::DeviceCode {
380 user_code: user_code.to_owned(),
381 verification_uri: verification_uri.to_string(),
382 interval_seconds: interval,
383 expires_in_seconds: Some(expires_in),
384 });
385
386 let token_url = format!("{}/login/oauth/access_token", self.github(&domain));
387 let github_token = poll_device_code(interval, Some(expires_in), true, cancel, || {
388 let request = post_form(
389 &token_url,
390 &[
391 ("client_id", CLIENT_ID),
392 ("device_code", device_code),
393 ("grant_type", super::device::DEVICE_CODE_GRANT),
394 ],
395 )
396 .header("User-Agent", USER_AGENT);
397 async move {
398 let raw = fetch_json(request, cancel).await?;
399 if let Some(token) = raw.get("access_token").and_then(Value::as_str) {
400 return Ok(Poll::Complete(token.to_owned()));
401 }
402 let Some(error) = raw.get("error").and_then(Value::as_str) else {
403 return Ok(Poll::Failed("Invalid device token response".into()));
404 };
405 Ok(match error {
406 "authorization_pending" => Poll::Pending,
407 "slow_down" => Poll::SlowDown(raw.get("interval").and_then(Value::as_f64)),
408 _ => {
409 let suffix = raw
410 .get("error_description")
411 .and_then(Value::as_str)
412 .map_or_else(String::new, |text| format!(": {text}"));
413 Poll::Failed(format!("Device flow failed: {error}{suffix}"))
414 }
415 })
416 }
417 })
418 .await?;
419
420 let mut credential = self
421 .copilot_token(&github_token, enterprise.as_deref(), cancel)
422 .await?;
423 let catalog = self
424 .models(
425 &credential.access,
426 enterprise.as_deref(),
427 cancel,
428 2,
429 Duration::from_secs(5),
430 )
431 .await?;
432 let mut enabled = Vec::new();
433 if !catalog.policy.is_empty() {
434 interaction.notify(AuthEvent::Progress {
435 message: "Enabling models...".into(),
436 });
437 enabled = self
438 .enable_models(
439 &credential.access,
440 &catalog.policy,
441 enterprise.as_deref(),
442 cancel,
443 )
444 .await?;
445 }
446 let mut ids = catalog.available;
447 for id in enabled {
448 if !ids.contains(&id) {
449 ids.push(id);
450 }
451 }
452 credential.extra.insert(
453 "availableModelIds".into(),
454 Value::Array(ids.into_iter().map(Value::String).collect()),
455 );
456 Ok(credential)
457 }
458}
459
460impl OAuthProvider for CopilotOAuth {
461 fn name(&self) -> &str {
462 "GitHub Copilot"
463 }
464
465 fn login<'a>(
466 &'a self,
467 interaction: &'a Interaction,
468 _options: &'a LoginOptions,
469 ) -> BoxFuture<'a, Result<OAuthCredential, AuthError>> {
470 Box::pin(self.login_copilot(interaction))
471 }
472
473 fn refresh<'a>(
474 &'a self,
475 credential: &'a OAuthCredential,
476 cancel: &'a CancellationToken,
477 ) -> BoxFuture<'a, Result<OAuthCredential, AuthError>> {
478 Box::pin(async move {
479 let enterprise = enterprise_domain(credential);
480 let mut refreshed = self
481 .copilot_token(&credential.refresh, enterprise.as_deref(), cancel)
482 .await?;
483 let catalog = self
484 .models(
485 &refreshed.access,
486 enterprise.as_deref(),
487 cancel,
488 0,
489 Duration::ZERO,
490 )
491 .await?;
492 refreshed.extra.insert(
493 "availableModelIds".into(),
494 Value::Array(catalog.available.into_iter().map(Value::String).collect()),
495 );
496 Ok(refreshed)
497 })
498 }
499
500 fn to_auth<'a>(
501 &'a self,
502 credential: &'a OAuthCredential,
503 ) -> BoxFuture<'a, Result<OAuthAuth, AuthError>> {
504 let base_url = self.base_url(&credential.access, enterprise_domain(credential).as_deref());
505 Box::pin(async move {
506 Ok(OAuthAuth {
507 api_key: Some(credential.access.clone()),
508 base_url: Some(base_url),
509 ..OAuthAuth::default()
510 })
511 })
512 }
513}
514
515pub fn available_model_ids(credential: &OAuthCredential) -> Option<HashSet<String>> {
517 let ids = credential.extra.get("availableModelIds")?.as_array()?;
518 ids.iter()
519 .map(|id| id.as_str().map(str::to_owned))
520 .collect()
521}
522
523#[cfg(test)]
524mod tests {
525 use serde_json::json;
526
527 use super::*;
528
529 #[test]
530 fn derives_endpoints_like_pi() {
531 assert_eq!(
532 base_url_from_token("tid=1;exp=2;proxy-ep=proxy.business.githubcopilot.com;st=x"),
533 Some("https://api.business.githubcopilot.com".into())
534 );
535 assert_eq!(base_url_from_token("tid=1"), None);
536 assert_eq!(
537 normalize_domain("https://ghe.example.com/x"),
538 Some("ghe.example.com".into())
539 );
540 assert_eq!(
541 normalize_domain("company.ghe.com"),
542 Some("company.ghe.com".into())
543 );
544 assert_eq!(normalize_domain(" "), None);
545 let oauth = CopilotOAuth::default();
546 assert_eq!(
547 oauth.base_url("tid=1", Some("ghe.example.com")),
548 "https://copilot-api.ghe.example.com"
549 );
550 assert_eq!(oauth.base_url("tid=1", None), DEFAULT_BASE_URL);
551 }
552
553 #[test]
554 fn reads_model_catalog() {
555 let known = crate::catalog::builtin_models("github-copilot")[0]
556 .id
557 .clone();
558 let raw = json!({"data": [
559 {"id": "a", "model_picker_enabled": true, "policy": {"state": "enabled"}},
560 {"id": "b", "model_picker_enabled": true, "policy": {"state": "disabled"}},
561 {"id": "c", "model_picker_enabled": true, "capabilities": {"supports": {"tool_calls": false}}},
562 {"id": known, "model_picker_enabled": true, "policy": {"state": "unconfigured"}},
563 ]});
564 let catalog = parse_catalog(&raw, true).unwrap();
565 assert_eq!(catalog.available, ["a".to_owned(), known.clone()]);
566 assert_eq!(catalog.policy, [known]);
567 let fallback = json!({"data": [
568 {"id": "a", "model_picker_enabled": false, "policy": {"state": "enabled"}},
569 ]});
570 assert_eq!(parse_catalog(&fallback, true).unwrap().available, ["a"]);
571 assert!(
572 parse_catalog(&fallback, false)
573 .unwrap()
574 .available
575 .is_empty()
576 );
577 }
578}