Skip to main content

yapi_ai/auth/
copilot.rs

1//! GitHub Copilot sign-in: a GitHub device flow, then a Copilot token whose
2//! `proxy-ep` names the account's API endpoint. Port of
3//! `oauth/github-copilot.ts`.
4
5use 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
18/// Public client id, base64 in pi's source.
19const 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/// The Copilot sign-in. Endpoints derive from the GitHub domain unless
32/// overridden.
33#[derive(Clone, Debug, Default)]
34pub struct CopilotOAuth {
35    /// Replaces `https://<domain>` and `https://api.<domain>` for GitHub calls.
36    pub github_url: Option<String>,
37    /// Replaces the Copilot API base URL.
38    pub api_url: Option<String>,
39}
40
41/// The host of a GitHub Enterprise URL or domain.
42fn 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
58/// The API base URL from a Copilot token's `proxy-ep` field.
59fn 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/// What the account's model catalog allows.
81#[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
163/// Sends with a five-second timeout per attempt, retrying 429 responses
164/// within `max_retries` and `budget`.
165async 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        // Some Individual accounts disable every picker flag despite enabled
274        // policies. Only that endpoint falls back to the policies.
275        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    /// Enables models for the account, best effort. Exhausted rate limiting
300    /// stops the batch.
301    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        // The URI is opened in a browser. Only web URLs are trusted.
373        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
515/// The models a Copilot credential allows, when it lists them.
516pub 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}