diff --git a/clients/agent-runtime/src/onboard/wizard.rs b/clients/agent-runtime/src/onboard/wizard.rs index 455c43fc5..2ba03e0cc 100755 --- a/clients/agent-runtime/src/onboard/wizard.rs +++ b/clients/agent-runtime/src/onboard/wizard.rs @@ -57,6 +57,27 @@ const MODEL_PREVIEW_LIMIT: usize = 20; const MODEL_CACHE_FILE: &str = "models_cache.json"; const MODEL_CACHE_TTL_SECS: u64 = 12 * 60 * 60; const CUSTOM_MODEL_SENTINEL: &str = "__custom_model__"; +const COPILOT_TOKEN_URL: &str = "https://api.github.com/copilot_internal/v2/token"; +const COPILOT_DEFAULT_API: &str = "https://api.githubcopilot.com"; + +const COPILOT_DISCOVERY_HEADERS: [(&str, &str); 4] = [ + ("Editor-Version", "vscode/1.85.1"), + ("Editor-Plugin-Version", "copilot/1.155.0"), + ("User-Agent", "GithubCopilot/1.155.0"), + ("Accept", "application/json"), +]; + +#[derive(Debug, Deserialize)] +struct CopilotApiEndpoints { + api: Option, +} + +#[derive(Deserialize)] +struct CopilotApiKeyInfo { + token: String, + #[serde(default)] + endpoints: Option, +} // ── Main wizard entry point ────────────────────────────────────── @@ -814,6 +835,7 @@ fn supports_live_model_fetch(provider_name: &str) -> bool { | "together-ai" | "gemini" | "ollama" + | "copilot" | "astrai" ) } @@ -1001,24 +1023,76 @@ fn fetch_ollama_models() -> Result> { Ok(parse_ollama_model_ids(&payload)) } +fn resolve_live_model_api_key(provider_name: &str, api_key: &str) -> Option { + if !api_key.trim().is_empty() { + return Some(api_key.trim().to_string()); + } + + std::env::var(provider_env_var(provider_name)) + .ok() + .or_else(|| { + // Anthropic also accepts OAuth setup-tokens via ANTHROPIC_OAUTH_TOKEN + if provider_name == "anthropic" { + std::env::var("ANTHROPIC_OAUTH_TOKEN").ok() + } else if provider_name == "copilot" { + // GitHub CLI commonly exposes GH_TOKEN + std::env::var("GH_TOKEN").ok() + } else { + None + } + }) + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) +} + +fn fetch_copilot_models(api_key: Option<&str>) -> Result> { + let github_token = api_key + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| anyhow::anyhow!("GitHub token is required for Copilot model discovery"))?; + + let client = build_model_fetch_client()?; + + let mut token_request = client + .get(COPILOT_TOKEN_URL) + .header("Authorization", format!("token {github_token}")); + for (header, value) in COPILOT_DISCOVERY_HEADERS { + token_request = token_request.header(header, value); + } + + let api_key_info: CopilotApiKeyInfo = token_request + .send() + .and_then(reqwest::blocking::Response::error_for_status) + .context("model fetch failed: GET Copilot API token")? + .json() + .context("failed to parse Copilot API token response")?; + + let endpoint = api_key_info + .endpoints + .and_then(|endpoints| endpoints.api) + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| COPILOT_DEFAULT_API.to_string()); + + let mut model_request = client + .get(format!("{}/models", endpoint.trim_end_matches('/'))) + .header("Authorization", format!("Bearer {}", api_key_info.token)); + for (header, value) in COPILOT_DISCOVERY_HEADERS { + model_request = model_request.header(header, value); + } + + let payload: Value = model_request + .send() + .and_then(reqwest::blocking::Response::error_for_status) + .context("model fetch failed: GET Copilot models")? + .json() + .context("failed to parse Copilot model list response")?; + + Ok(parse_openai_compatible_model_ids(&payload)) +} + fn fetch_live_models_for_provider(provider_name: &str, api_key: &str) -> Result> { let provider_name = canonical_provider_name(provider_name); - let api_key = if api_key.trim().is_empty() { - std::env::var(provider_env_var(provider_name)) - .ok() - .or_else(|| { - // Anthropic also accepts OAuth setup-tokens via ANTHROPIC_OAUTH_TOKEN - if provider_name == "anthropic" { - std::env::var("ANTHROPIC_OAUTH_TOKEN").ok() - } else { - None - } - }) - .map(|value| value.trim().to_string()) - .filter(|value| !value.is_empty()) - } else { - Some(api_key.trim().to_string()) - }; + let api_key = resolve_live_model_api_key(provider_name, api_key); let models = match provider_name { "openrouter" => fetch_openrouter_models(api_key.as_deref())?, @@ -1065,6 +1139,7 @@ fn fetch_live_models_for_provider(provider_name: &str, api_key: &str) -> Result< "astrai" => { fetch_openai_compatible_models("https://as-trai.com/v1/models", api_key.as_deref())? } + "copilot" => fetch_copilot_models(api_key.as_deref())?, _ => Vec::new(), }; @@ -1914,10 +1989,7 @@ fn setup_provider(workspace_dir: &Path) -> Result<(String, String, String, Optio if supports_live_model_fetch(provider_name) { let can_fetch_without_key = matches!(provider_name, "openrouter" | "ollama"); - let has_api_key = !api_key.trim().is_empty() - || std::env::var(provider_env_var(provider_name)) - .ok() - .is_some_and(|value| !value.trim().is_empty()); + let has_api_key = resolve_live_model_api_key(provider_name, &api_key).is_some(); if can_fetch_without_key || has_api_key { if let Some(cached) = @@ -4515,8 +4587,38 @@ fn print_summary(config: &Config) { mod tests { use super::*; use serde_json::json; + use std::sync::{Mutex, OnceLock}; use tempfile::TempDir; + fn env_lock() -> &'static Mutex<()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) + } + + struct EnvRestore { + key: &'static str, + value: Option, + } + + impl EnvRestore { + fn capture(key: &'static str) -> Self { + Self { + key, + value: std::env::var(key).ok(), + } + } + } + + impl Drop for EnvRestore { + fn drop(&mut self) { + if let Some(value) = &self.value { + std::env::set_var(self.key, value); + } else { + std::env::remove_var(self.key); + } + } + } + // ── ProjectContext defaults ────────────────────────────────── #[test] @@ -4998,10 +5100,21 @@ mod tests { assert!(supports_live_model_fetch("grok")); assert!(supports_live_model_fetch("together")); assert!(supports_live_model_fetch("ollama")); + assert!(supports_live_model_fetch("copilot")); + assert!(supports_live_model_fetch("github-copilot")); assert!(supports_live_model_fetch("astrai")); assert!(!supports_live_model_fetch("venice")); } + #[test] + fn fetch_copilot_models_requires_non_empty_token() { + let missing = fetch_copilot_models(None).unwrap_err().to_string(); + assert!(missing.contains("GitHub token is required")); + + let empty = fetch_copilot_models(Some(" ")).unwrap_err().to_string(); + assert!(empty.contains("GitHub token is required")); + } + #[test] fn curated_models_provider_aliases_share_same_catalog() { assert_eq!( @@ -5215,6 +5328,19 @@ mod tests { assert_eq!(provider_env_var("some-new-provider"), "API_KEY"); } + #[test] + fn resolve_live_model_api_key_uses_gh_token_for_copilot() { + let _guard = env_lock().lock().unwrap_or_else(|error| error.into_inner()); + let _restore_github_token = EnvRestore::capture("GITHUB_TOKEN"); + let _restore_gh_token = EnvRestore::capture("GH_TOKEN"); + + std::env::remove_var("GITHUB_TOKEN"); + std::env::set_var("GH_TOKEN", " ghp-from-gh-cli "); + + let resolved = resolve_live_model_api_key("copilot", ""); + assert_eq!(resolved.as_deref(), Some("ghp-from-gh-cli")); + } + #[test] fn backend_key_from_choice_maps_supported_backends() { assert_eq!(backend_key_from_choice(0), "sqlite");