diff --git a/crates/goose/src/providers/factory.rs b/crates/goose/src/providers/factory.rs index e4edf19b..45e0b0a0 100644 --- a/crates/goose/src/providers/factory.rs +++ b/crates/goose/src/providers/factory.rs @@ -7,6 +7,7 @@ use super::{ bedrock::BedrockProvider, databricks::DatabricksProvider, gcpvertexai::GcpVertexAIProvider, + githubcopilot::GithubCopilotProvider, google::GoogleProvider, groq::GroqProvider, ollama::OllamaProvider, @@ -23,6 +24,7 @@ pub fn providers() -> Vec { BedrockProvider::metadata(), DatabricksProvider::metadata(), GcpVertexAIProvider::metadata(), + GithubCopilotProvider::metadata(), GoogleProvider::metadata(), GroqProvider::metadata(), OllamaProvider::metadata(), @@ -44,6 +46,7 @@ pub fn create(name: &str, model: ModelConfig) -> Result> { "openrouter" => Ok(Arc::new(OpenRouterProvider::from_env(model)?)), "gcp_vertex_ai" => Ok(Arc::new(GcpVertexAIProvider::from_env(model)?)), "google" => Ok(Arc::new(GoogleProvider::from_env(model)?)), + "github_copilot" => Ok(Arc::new(GithubCopilotProvider::from_env(model)?)), _ => Err(anyhow::anyhow!("Unknown provider: {}", name)), } } diff --git a/crates/goose/src/providers/githubcopilot.rs b/crates/goose/src/providers/githubcopilot.rs new file mode 100644 index 00000000..2d8b36ff --- /dev/null +++ b/crates/goose/src/providers/githubcopilot.rs @@ -0,0 +1,385 @@ +use anyhow::{anyhow, Context, Result}; +use async_trait::async_trait; +use axum::http; +use chrono::{DateTime, Utc}; +use etcetera::{choose_app_strategy, AppStrategy}; +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::utils::{emit_debug_trace, get_model, handle_response_openai_compat, ImageFormat}; +use crate::config::{Config, ConfigError}; +use crate::message::Message; +use crate::model::ModelConfig; +use mcp_core::tool::Tool; + +pub const GITHUB_COPILOT_DEFAULT_MODEL: &str = "gpt-4o"; +pub const GITHUB_COPILOT_KNOWN_MODELS: &[&str] = &[ + "gpt-4o", + "o1", + "o3-mini", + "claude-3-7-sonnet", + "claude-3-5-sonnet", +]; + +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 = choose_app_strategy(crate::config::APP_STRATEGY.clone()) + .expect("goose requires a home dir") + .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, +} + +impl Default for GithubCopilotProvider { + fn default() -> Self { + let model = ModelConfig::new(GithubCopilotProvider::metadata().default_model); + GithubCopilotProvider::from_env(model).expect("Failed to initialize GithubCopilot provider") + } +} + +impl GithubCopilotProvider { + pub 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, + }) + } + + async fn post(&self, payload: Value) -> Result { + 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 response = self + .client + .post(url) + .headers(self.get_github_headers()) + .header("Authorization", format!("Bearer {}", token)) + .json(&payload) + .send() + .await?; + 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_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_TOKEN", Value::String(token.clone()))?; + 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 and associated models", + GITHUB_COPILOT_DEFAULT_MODEL, + GITHUB_COPILOT_KNOWN_MODELS.to_vec(), + GITHUB_COPILOT_DOC_URL, + vec![], + ) + } + + fn get_model_config(&self) -> ModelConfig { + self.model.clone() + } + + #[tracing::instrument( + skip(self, system, messages, tools), + fields(model_config, input, output, input_tokens, output_tokens, total_tokens) + )] + async fn complete( + &self, + system: &str, + messages: &[Message], + tools: &[Tool], + ) -> Result<(Message, ProviderUsage), ProviderError> { + let payload = create_request(&self.model, system, messages, tools, &ImageFormat::OpenAi)?; + + // Make request + let response = self.post(payload.clone()).await?; + + // Parse response + let message = response_to_message(response.clone())?; + let usage = match get_usage(&response) { + Ok(usage) => usage, + Err(ProviderError::UsageError(e)) => { + tracing::debug!("Failed to get usage data: {}", e); + Usage::default() + } + Err(e) => return Err(e), + }; + let model = get_model(&response); + emit_debug_trace(&self.model, &payload, &response, &usage); + Ok((message, ProviderUsage::new(model, usage))) + } +} diff --git a/crates/goose/src/providers/mod.rs b/crates/goose/src/providers/mod.rs index 82dfdfe3..b5299d1c 100644 --- a/crates/goose/src/providers/mod.rs +++ b/crates/goose/src/providers/mod.rs @@ -9,6 +9,7 @@ mod factory; pub mod formats; mod gcpauth; pub mod gcpvertexai; +pub mod githubcopilot; pub mod google; pub mod groq; pub mod oauth;