use crate::config::paths::Paths; use anyhow::{anyhow, Context, Result}; use async_trait::async_trait; use axum::http; use chrono::{DateTime, Utc}; use reqwest::Client; use serde::{Deserialize, Serialize}; use serde_json::Value; use std::cell::RefCell; use std::collections::HashMap; use std::path::PathBuf; use std::time::Duration; use super::base::{Provider, ProviderMetadata, ProviderUsage, Usage}; use super::errors::ProviderError; use super::formats::openai::{create_request, get_usage, response_to_message}; use super::retry::ProviderRetry; use super::utils::{get_model, handle_response_openai_compat, ImageFormat, RequestLog}; use crate::config::{Config, ConfigError}; use crate::conversation::message::Message; use crate::model::ModelConfig; use crate::providers::base::ConfigKey; use rmcp::model::Tool; pub const GITHUB_COPILOT_DEFAULT_MODEL: &str = "gpt-4.1"; pub const GITHUB_COPILOT_KNOWN_MODELS: &[&str] = &[ "gpt-4.1", "gpt-5-mini", "gpt-5", "gpt-4o", "grok-code-fast-1", "gpt-5-codex", "claude-sonnet-4", "claude-sonnet-4.5", "claude-haiku-4.5", "gemini-2.5-pro", ]; pub const GITHUB_COPILOT_STREAM_MODELS: &[&str] = &[ "gpt-4.1", "gpt-5", "gpt-5-mini", "gpt-5-codex", "claude-sonnet-4", "claude-sonnet-4.5", "claude-haiku-4.5", "gemini-2.5-pro", "grok-code-fast-1", ]; const GITHUB_COPILOT_DOC_URL: &str = "https://docs.github.com/en/copilot/using-github-copilot/ai-models"; const GITHUB_COPILOT_CLIENT_ID: &str = "Iv1.b507a08c87ecfe98"; const GITHUB_COPILOT_DEVICE_CODE_URL: &str = "https://github.com/login/device/code"; const GITHUB_COPILOT_ACCESS_TOKEN_URL: &str = "https://github.com/login/oauth/access_token"; const GITHUB_COPILOT_API_KEY_URL: &str = "https://api.github.com/copilot_internal/v2/token"; #[derive(Debug, Deserialize)] struct DeviceCodeInfo { device_code: String, user_code: String, verification_uri: String, } #[derive(Debug, Serialize, Deserialize, Clone)] struct CopilotTokenEndpoints { api: String, #[serde(flatten)] _extra: HashMap, } #[derive(Debug, Serialize, Deserialize, Clone)] #[allow(dead_code)] // useful for debugging struct CopilotTokenInfo { token: String, expires_at: i64, refresh_in: i64, endpoints: CopilotTokenEndpoints, #[serde(flatten)] _extra: HashMap, } #[derive(Debug, Serialize, Deserialize, Clone)] struct CopilotState { expires_at: DateTime, info: CopilotTokenInfo, } #[derive(Debug)] struct DiskCache { cache_path: PathBuf, } impl DiskCache { fn new() -> Self { let cache_path = Paths::in_config_dir("githubcopilot/info.json"); Self { cache_path } } async fn load(&self) -> Option { if let Ok(contents) = tokio::fs::read_to_string(&self.cache_path).await { if let Ok(info) = serde_json::from_str::(&contents) { return Some(info); } } None } async fn save(&self, info: &CopilotState) -> Result<()> { if let Some(parent) = self.cache_path.parent() { tokio::fs::create_dir_all(parent).await?; } let contents = serde_json::to_string(info)?; tokio::fs::write(&self.cache_path, contents).await?; Ok(()) } } #[derive(Debug, serde::Serialize)] pub struct GithubCopilotProvider { #[serde(skip)] client: Client, #[serde(skip)] cache: DiskCache, #[serde(skip)] mu: tokio::sync::Mutex>>, model: ModelConfig, #[serde(skip)] name: String, } impl GithubCopilotProvider { fn payload_contains_image(payload: &Value) -> bool { payload .get("messages") .and_then(|m| m.as_array()) .is_some_and(|messages| { messages.iter().any(|msg| { msg.get("content").is_some_and(|content| { content .as_array() .map(|arr| arr.iter().collect::>()) .unwrap_or_else(|| vec![content]) .iter() .any(|item| { matches!( item.get("type").and_then(|v| v.as_str()), Some("image_url") | Some("image") ) }) }) }) }) } pub async fn from_env(model: ModelConfig) -> Result { let client = Client::builder() .timeout(Duration::from_secs(600)) .build()?; let cache = DiskCache::new(); let mu = tokio::sync::Mutex::new(RefCell::new(None)); Ok(Self { client, cache, mu, model, name: Self::metadata().name, }) } async fn post(&self, payload: &mut Value) -> Result { use crate::providers::utils_universal_openai_stream::{OAIStreamChunk, OAIStreamCollector}; use futures::StreamExt; // Detect gpt-4.1 and stream let model_name = payload.get("model").and_then(|v| v.as_str()).unwrap_or(""); let stream_only_model = GITHUB_COPILOT_STREAM_MODELS .iter() .any(|prefix| model_name.starts_with(prefix)); if stream_only_model { payload .as_object_mut() .unwrap() .insert("stream".to_string(), serde_json::Value::Bool(true)); } let (endpoint, token) = self.get_api_info().await?; let url = url::Url::parse(&format!("{}/chat/completions", endpoint)) .map_err(|e| ProviderError::RequestFailed(format!("Invalid base URL: {e}")))?; let headers = self.get_github_headers(); let mut request = self .client .post(url) .headers(headers) .header("Authorization", format!("Bearer {}", token)); if Self::payload_contains_image(payload) { request = request.header("Copilot-Vision-Request", "true"); } let response = request.json(payload).send().await?; if stream_only_model { let mut collector = OAIStreamCollector::new(); let mut stream = response.bytes_stream(); while let Some(chunk) = stream.next().await { let chunk = chunk.map_err(|e| ProviderError::RequestFailed(e.to_string()))?; let text = String::from_utf8_lossy(&chunk); for line in text.lines() { let tline = line.trim(); if !tline.starts_with("data: ") { continue; } let Some(payload) = tline.get(6..) else { continue; }; if payload == "[DONE]" { break; } match serde_json::from_str::(payload) { Ok(ch) => collector.add_chunk(&ch), Err(_) => continue, } } } let final_response = collector.build_response(); let value = serde_json::to_value(final_response) .map_err(|e| ProviderError::RequestFailed(e.to_string()))?; Ok(value) } else { handle_response_openai_compat(response).await } } async fn get_api_info(&self) -> Result<(String, String)> { let guard = self.mu.lock().await; if let Some(state) = guard.borrow().as_ref() { if state.expires_at > Utc::now() { return Ok((state.info.endpoints.api.clone(), state.info.token.clone())); } } if let Some(state) = self.cache.load().await { if guard.borrow().is_none() { guard.replace(Some(state.clone())); } if state.expires_at > Utc::now() { return Ok((state.info.endpoints.api, state.info.token)); } } const MAX_ATTEMPTS: i32 = 3; for attempt in 0..MAX_ATTEMPTS { tracing::trace!("attempt {} to refresh api info", attempt + 1); let info = match self.refresh_api_info().await { Ok(data) => data, Err(err) => { tracing::warn!("failed to refresh api info: {}", err); continue; } }; let expires_at = Utc::now() + chrono::Duration::seconds(info.refresh_in); let new_state = CopilotState { info, expires_at }; self.cache.save(&new_state).await?; guard.replace(Some(new_state.clone())); return Ok((new_state.info.endpoints.api, new_state.info.token)); } Err(anyhow!("failed to get api info after 3 attempts")) } async fn refresh_api_info(&self) -> Result { let config = Config::global(); let token = match config.get_secret::("GITHUB_COPILOT_TOKEN") { Ok(token) => token, Err(err) => match err { ConfigError::NotFound(_) => { let token = self .get_access_token() .await .context("unable to login into github")?; config.set_secret("GITHUB_COPILOT_TOKEN", &token)?; token } _ => return Err(err.into()), }, }; let resp = self .client .get(GITHUB_COPILOT_API_KEY_URL) .headers(self.get_github_headers()) .header(http::header::AUTHORIZATION, format!("bearer {}", &token)) .send() .await? .error_for_status()? .text() .await?; tracing::trace!("copilot token response: {}", resp); let info: CopilotTokenInfo = serde_json::from_str(&resp)?; Ok(info) } async fn get_access_token(&self) -> Result { for attempt in 0..3 { tracing::trace!("attempt {} to get access token", attempt + 1); match self.login().await { Ok(token) => return Ok(token), Err(err) => tracing::warn!("failed to get access token: {}", err), } } Err(anyhow!("failed to get access token after 3 attempts")) } async fn login(&self) -> Result { let device_code_info = self.get_device_code().await?; println!( "Please visit {} and enter code {}", device_code_info.verification_uri, device_code_info.user_code ); self.poll_for_access_token(&device_code_info.device_code) .await } async fn get_device_code(&self) -> Result { #[derive(Serialize)] struct DeviceCodeRequest { client_id: String, scope: String, } self.client .post(GITHUB_COPILOT_DEVICE_CODE_URL) .headers(self.get_github_headers()) .json(&DeviceCodeRequest { client_id: GITHUB_COPILOT_CLIENT_ID.to_string(), scope: "read:user".to_string(), }) .send() .await .context("failed to send request to get device code")? .error_for_status() .context("failed to get device code")? .json::() .await .context("failed to parse device code response") } async fn poll_for_access_token(&self, device_code: &str) -> Result { #[derive(Serialize)] struct AccessTokenRequest { client_id: String, device_code: String, grant_type: String, } #[derive(Debug, Deserialize)] struct AccessTokenResponse { access_token: Option, error: Option, #[serde(flatten)] _extra: HashMap, } const MAX_ATTEMPTS: i32 = 36; for attempt in 0..MAX_ATTEMPTS { let resp = self .client .post(GITHUB_COPILOT_ACCESS_TOKEN_URL) .headers(self.get_github_headers()) .json(&AccessTokenRequest { client_id: GITHUB_COPILOT_CLIENT_ID.to_string(), device_code: device_code.to_string(), grant_type: "urn:ietf:params:oauth:grant-type:device_code".to_string(), }) .send() .await .context("failed to make request while polling for access token")? .error_for_status() .context("error polling for access token")? .json::() .await .context("failed to parse response while polling for access token")?; if resp.access_token.is_some() { tracing::trace!("successful authorization: {:#?}", resp,); } if let Some(access_token) = resp.access_token { return Ok(access_token); } else if resp .error .as_ref() .is_some_and(|err| err == "authorization_pending") { tracing::debug!( "authorization pending (attempt {}/{})", attempt + 1, MAX_ATTEMPTS ); } else { tracing::debug!("unexpected response: {:#?}", resp); } tokio::time::sleep(tokio::time::Duration::from_secs(5)).await; } Err(anyhow!("failed to get access token")) } fn get_github_headers(&self) -> http::HeaderMap { let mut headers = http::HeaderMap::new(); headers.insert(http::header::ACCEPT, "application/json".parse().unwrap()); headers.insert( http::header::CONTENT_TYPE, "application/json".parse().unwrap(), ); headers.insert( http::header::USER_AGENT, "GithubCopilot/1.155.0".parse().unwrap(), ); headers.insert("editor-version", "vscode/1.85.1".parse().unwrap()); headers.insert("editor-plugin-version", "copilot/1.155.0".parse().unwrap()); headers } } #[async_trait] impl Provider for GithubCopilotProvider { fn metadata() -> ProviderMetadata { ProviderMetadata::new( "github_copilot", "GitHub Copilot", "GitHub Copilot. Run `goose configure` and select copilot to set up.", GITHUB_COPILOT_DEFAULT_MODEL, GITHUB_COPILOT_KNOWN_MODELS.to_vec(), GITHUB_COPILOT_DOC_URL, vec![ConfigKey::new_oauth( "GITHUB_COPILOT_TOKEN", true, true, None, )], ) } fn get_name(&self) -> &str { &self.name } fn get_model_config(&self) -> ModelConfig { self.model.clone() } #[tracing::instrument( skip(self, model_config, system, messages, tools), fields(model_config, input, output, input_tokens, output_tokens, total_tokens) )] async fn complete_with_model( &self, model_config: &ModelConfig, system: &str, messages: &[Message], tools: &[Tool], ) -> Result<(Message, ProviderUsage), ProviderError> { let payload = create_request( model_config, system, messages, tools, &ImageFormat::OpenAi, false, )?; let mut log = RequestLog::start(model_config, &payload)?; // Make request with retry let response = self .with_retry(|| async { let mut payload_clone = payload.clone(); self.post(&mut payload_clone).await }) .await?; // Parse response let message = response_to_message(&response)?; let usage = response.get("usage").map(get_usage).unwrap_or_else(|| { tracing::debug!("Failed to get usage data"); Usage::default() }); let response_model = get_model(&response); log.write(&response, Some(&usage))?; Ok((message, ProviderUsage::new(response_model, usage))) } /// Fetch supported models from GitHub Copliot; returns Err on failure, Ok(None) if not present async fn fetch_supported_models(&self) -> Result>, ProviderError> { let (endpoint, token) = self.get_api_info().await?; let url = format!("{}/models", endpoint); let mut headers = http::HeaderMap::new(); headers.insert(http::header::ACCEPT, "application/json".parse().unwrap()); headers.insert( http::header::CONTENT_TYPE, "application/json".parse().unwrap(), ); headers.insert("Copilot-Integration-Id", "vscode-chat".parse().unwrap()); headers.insert( http::header::AUTHORIZATION, format!("Bearer {}", token).parse().unwrap(), ); let response = self.client.get(url).headers(headers).send().await?; let json: serde_json::Value = response.json().await?; let arr = match json.get("data").and_then(|v| v.as_array()) { Some(arr) => arr, None => return Ok(None), }; let mut models: Vec = arr .iter() .filter_map(|m| { if let Some(s) = m.as_str() { Some(s.to_string()) } else if let Some(obj) = m.as_object() { obj.get("id").and_then(|v| v.as_str()).map(str::to_string) } else { None } }) .collect(); models.sort(); Ok(Some(models)) } async fn configure_oauth(&self) -> Result<(), ProviderError> { let config = Config::global(); // Check if token already exists and is valid if config.get_secret::("GITHUB_COPILOT_TOKEN").is_ok() { // Try to refresh API info to validate the token match self.refresh_api_info().await { Ok(_) => return Ok(()), // Token is valid Err(_) => { // Token is invalid, continue with OAuth flow tracing::debug!("Existing token is invalid, starting OAuth flow"); } } } // Start OAuth device code flow let token = self .get_access_token() .await .map_err(|e| ProviderError::Authentication(format!("OAuth flow failed: {}", e)))?; // Save the token config .set_secret("GITHUB_COPILOT_TOKEN", &token) .map_err(|e| ProviderError::ExecutionError(format!("Failed to save token: {}", e)))?; Ok(()) } }