use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use crate::auth::types::{AuthEvent, AuthLoginCallbacks, AuthPrompt, ModelAuth, OAuthAuth, OAuthCredential};
use crate::auth::{OAuthLoader, lazy_oauth};
use crate::models::catalog::GITHUB_COPILOT_MODELS;
use super::device_code::{DeviceCodePollOptions, DeviceCodePollResult, poll_oauth_device_code_flow};
const CLIENT_ID: &str = "Iv1.b507a08c87ecfe98";
const COPILOT_API_VERSION: &str = "2026-06-01";
const COPILOT_HEADERS: &[(&str, &str)] = &[
("User-Agent", "GitHubCopilotChat/0.35.0"),
("Editor-Version", "vscode/1.107.0"),
("Editor-Plugin-Version", "copilot-chat/0.35.0"),
("Copilot-Integration-Id", "vscode-chat"),
];
pub fn github_copilot_oauth() -> OAuthAuth {
lazy_oauth("GitHub Copilot", github_copilot_oauth_loader())
}
pub fn github_copilot_oauth_loader() -> OAuthLoader {
Arc::new(|| Box::pin(async { github_copilot_oauth_impl() }))
}
fn github_copilot_oauth_impl() -> OAuthAuth {
OAuthAuth {
name: "GitHub Copilot".to_string(),
login: Arc::new(|callbacks: Arc<dyn AuthLoginCallbacks>| {
Box::pin(async move {
let creds = login_github_copilot(&callbacks).await?;
Ok(to_oauth_credential(creds))
})
}),
refresh: Arc::new(|credential| {
Box::pin(async move {
let creds =
refresh_github_copilot_token(&credential.refresh, credential.enterprise_url.as_deref()).await?;
Ok(to_oauth_credential(creds))
})
}),
to_auth: Arc::new(|credential| {
Box::pin(async move {
let enterprise_domain = copilot_enterprise_domain(&credential);
Ok(ModelAuth {
api_key: Some(credential.access.clone()),
headers: None,
base_url: Some(get_github_copilot_base_url(
Some(&credential.access),
enterprise_domain.as_deref(),
)),
})
})
}),
}
}
#[derive(Debug, Clone)]
pub struct CopilotOAuthTokens {
access: String,
refresh: String,
expires: i64,
enterprise_url: Option<String>,
available_model_ids: Vec<String>,
}
fn to_oauth_credential(creds: CopilotOAuthTokens) -> OAuthCredential {
OAuthCredential {
kind: "oauth".to_string(),
access: creds.access,
refresh: creds.refresh,
expires: creds.expires,
account_id: None,
enterprise_url: creds.enterprise_url,
available_model_ids: Some(creds.available_model_ids),
}
}
pub fn normalize_domain(input: &str) -> Option<String> {
let trimmed = input.trim();
if trimmed.is_empty() {
return None;
}
if let Ok(url) = url::Url::parse(trimmed) {
return url.host_str().map(|s| s.to_string());
}
if let Ok(url) = url::Url::parse(&format!("https://{trimmed}")) {
return url.host_str().map(|s| s.to_string());
}
None
}
fn copilot_enterprise_domain(credential: &OAuthCredential) -> Option<String> {
credential.enterprise_url.as_deref().and_then(normalize_domain)
}
pub fn get_github_copilot_base_url(token: Option<&str>, enterprise_domain: Option<&str>) -> String {
if let Some(token) = token
&& let Some(url) = base_url_from_token(token)
{
return url;
}
if let Some(domain) = enterprise_domain {
return format!("https://copilot-api.{domain}");
}
"https://api.individual.githubcopilot.com".to_string()
}
fn base_url_from_token(token: &str) -> Option<String> {
let proxy = token.split(';').find_map(|part| part.strip_prefix("proxy-ep="))?;
let api_host = proxy.strip_prefix("proxy.").unwrap_or(proxy);
Some(format!("https://api.{api_host}"))
}
struct DeviceCodeResponse {
device_code: String,
user_code: String,
verification_uri: String,
interval: Option<u64>,
expires_in: u64,
}
fn copilot_urls(domain: &str) -> (String, String, String) {
(
format!("https://{domain}/login/device/code"),
format!("https://{domain}/login/oauth/access_token"),
format!("https://api.{domain}/copilot_internal/v2/token"),
)
}
pub async fn login_github_copilot(callbacks: &Arc<dyn AuthLoginCallbacks>) -> anyhow::Result<CopilotOAuthTokens> {
let input = callbacks
.prompt(AuthPrompt::Text {
message: "GitHub Enterprise URL/domain (blank for github.com)".to_string(),
placeholder: Some("company.ghe.com".to_string()),
})
.await?;
let trimmed = input.trim();
let enterprise_domain = normalize_domain(&input);
if !trimmed.is_empty() && enterprise_domain.is_none() {
return Err(anyhow::anyhow!("Invalid GitHub Enterprise URL/domain"));
}
let domain = enterprise_domain.as_deref().unwrap_or("github.com");
let device = start_device_flow(domain).await?;
callbacks.notify(AuthEvent::DeviceCode {
user_code: device.user_code.clone(),
verification_uri: device.verification_uri.clone(),
interval_seconds: device.interval.map(|i| i as u32),
expires_in_seconds: Some(device.expires_in as u32),
});
let github_access = poll_github_access_token(domain, &device).await?;
let mut creds = refresh_copilot_access_token(&github_access, enterprise_domain.as_deref()).await?;
callbacks.notify(AuthEvent::Progress {
message: "Enabling models...".to_string(),
});
enable_all_copilot_models(&creds.access, enterprise_domain.as_deref()).await;
creds.available_model_ids = fetch_available_model_ids(&creds.access, enterprise_domain.as_deref()).await?;
Ok(creds)
}
pub async fn refresh_github_copilot_token(
refresh_token: &str,
enterprise_domain: Option<&str>,
) -> anyhow::Result<CopilotOAuthTokens> {
let mut creds = refresh_copilot_access_token(refresh_token, enterprise_domain).await?;
creds.available_model_ids = fetch_available_model_ids(&creds.access, enterprise_domain).await?;
Ok(creds)
}
async fn start_device_flow(domain: &str) -> anyhow::Result<DeviceCodeResponse> {
let (device_code_url, _, _) = copilot_urls(domain);
let client = reqwest::Client::new();
let response = client
.post(&device_code_url)
.header("Accept", "application/json")
.header("Content-Type", "application/x-www-form-urlencoded")
.header("User-Agent", "GitHubCopilotChat/0.35.0")
.body(format!("client_id={CLIENT_ID}&scope=read:user"))
.send()
.await?;
let data: serde_json::Value = response.json().await?;
Ok(DeviceCodeResponse {
device_code: data["device_code"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("invalid device_code"))?
.to_string(),
user_code: data["user_code"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("invalid user_code"))?
.to_string(),
verification_uri: data["verification_uri"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("invalid verification_uri"))?
.to_string(),
interval: data["interval"].as_u64(),
expires_in: data["expires_in"]
.as_u64()
.ok_or_else(|| anyhow::anyhow!("invalid expires_in"))?,
})
}
async fn poll_github_access_token(domain: &str, device: &DeviceCodeResponse) -> anyhow::Result<String> {
let (_, access_token_url, _) = copilot_urls(domain);
poll_oauth_device_code_flow(DeviceCodePollOptions {
interval_seconds: device.interval,
expires_in_seconds: Some(device.expires_in),
wait_before_first_poll: true,
poll: Box::new({
let device_code = device.device_code.clone();
let access_token_url = access_token_url.clone();
move || {
let device_code = device_code.clone();
let access_token_url = access_token_url.clone();
Box::pin(async move {
let client = reqwest::Client::new();
let response = client
.post(&access_token_url)
.header("Accept", "application/json")
.header("Content-Type", "application/x-www-form-urlencoded")
.header("User-Agent", "GitHubCopilotChat/0.35.0")
.body(format!(
"client_id={CLIENT_ID}&device_code={device_code}&grant_type=urn:ietf:params:oauth:grant-type:device_code"
))
.send()
.await;
let response = match response {
Ok(r) => r,
Err(e) => return DeviceCodePollResult::Failed { message: e.to_string() },
};
let data: serde_json::Value = match response.json().await {
Ok(v) => v,
Err(e) => return DeviceCodePollResult::Failed { message: e.to_string() },
};
if let Some(token) = data["access_token"].as_str() {
return DeviceCodePollResult::Complete(token.to_string());
}
if let Some(error) = data["error"].as_str() {
return match error {
"authorization_pending" => DeviceCodePollResult::Pending,
"slow_down" => DeviceCodePollResult::SlowDown {
interval_seconds: data["interval"].as_u64(),
},
_ => DeviceCodePollResult::Failed {
message: format!("Device flow failed: {error}"),
},
};
}
DeviceCodePollResult::Failed {
message: "Invalid device token response".to_string(),
}
}) as Pin<Box<dyn Future<Output = DeviceCodePollResult<String>> + Send>>
}
}),
})
.await
}
async fn refresh_copilot_access_token(
refresh_token: &str,
enterprise_domain: Option<&str>,
) -> anyhow::Result<CopilotOAuthTokens> {
let domain = enterprise_domain.unwrap_or("github.com");
let (_, _, copilot_token_url) = copilot_urls(domain);
let client = reqwest::Client::new();
let mut req = client
.get(&copilot_token_url)
.header("Accept", "application/json")
.header("Authorization", format!("Bearer {refresh_token}"));
for (k, v) in COPILOT_HEADERS {
req = req.header(*k, *v);
}
let data: serde_json::Value = req.send().await?.json().await?;
let token = data["token"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("invalid copilot token"))?;
let expires_at = data["expires_at"]
.as_i64()
.ok_or_else(|| anyhow::anyhow!("invalid expires_at"))?;
Ok(CopilotOAuthTokens {
access: token.to_string(),
refresh: refresh_token.to_string(),
expires: expires_at * 1000 - 5 * 60 * 1000,
enterprise_url: enterprise_domain.map(|s| s.to_string()),
available_model_ids: vec![],
})
}
async fn fetch_available_model_ids(token: &str, enterprise_domain: Option<&str>) -> anyhow::Result<Vec<String>> {
let base_url = get_github_copilot_base_url(Some(token), enterprise_domain);
let client = reqwest::Client::new();
let mut req = client
.get(format!("{base_url}/models"))
.header("Accept", "application/json")
.header("Authorization", format!("Bearer {token}"))
.header("X-GitHub-Api-Version", COPILOT_API_VERSION);
for (k, v) in COPILOT_HEADERS {
req = req.header(*k, *v);
}
let data: serde_json::Value = req
.timeout(std::time::Duration::from_secs(5))
.send()
.await?
.json()
.await?;
let ids = data["data"]
.as_array()
.ok_or_else(|| anyhow::anyhow!("Invalid Copilot models response"))?
.iter()
.filter_map(|item| {
let id = item["id"].as_str()?;
let enabled = item["model_picker_enabled"].as_bool().unwrap_or(false);
let disabled = item.pointer("/policy/state").and_then(|v| v.as_str()) == Some("disabled");
let no_tools = item
.pointer("/capabilities/supports/tool_calls")
.and_then(|v| v.as_bool())
== Some(false);
if enabled && !disabled && !no_tools {
Some(id.to_string())
} else {
None
}
})
.collect();
Ok(ids)
}
async fn enable_all_copilot_models(token: &str, enterprise_domain: Option<&str>) {
let base_url = get_github_copilot_base_url(Some(token), enterprise_domain);
let client = reqwest::Client::new();
for model in GITHUB_COPILOT_MODELS.iter() {
let mut req = client
.post(format!("{base_url}/models/{}/policy", model.id))
.header("Content-Type", "application/json")
.header("Authorization", format!("Bearer {token}"))
.header("openai-intent", "chat-policy")
.header("x-interaction-type", "chat-policy");
for (k, v) in COPILOT_HEADERS {
req = req.header(*k, *v);
}
let _ = req.body(r#"{"state":"enabled"}"#).send().await;
}
}