From 0f8dc025f755e59b848f8e6a676c018f610c65b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yuniel=20Acosta=20P=C3=A9rez?= <33158051+yacosta738@users.noreply.github.com> Date: Sun, 22 Feb 2026 22:17:24 +0100 Subject: [PATCH 1/3] feat(onboard): enable live model discovery for Copilot --- clients/agent-runtime/src/onboard/wizard.rs | 79 +++++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/clients/agent-runtime/src/onboard/wizard.rs b/clients/agent-runtime/src/onboard/wizard.rs index 455c43fc5..19b423693 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(Debug, 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,6 +1023,51 @@ fn fetch_ollama_models() -> Result> { Ok(parse_ollama_model_ids(&payload)) } +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() { @@ -1065,6 +1132,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(), }; @@ -4998,10 +5066,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!( From c4dfef14606590cb47ae073de4ad9951692ef003 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yuniel=20Acosta=20P=C3=A9rez?= <33158051+yacosta738@users.noreply.github.com> Date: Sun, 22 Feb 2026 22:49:15 +0100 Subject: [PATCH 2/3] fix(onboard): harden Copilot model discovery token handling --- clients/agent-runtime/src/onboard/wizard.rs | 73 ++++++++++++++++----- 1 file changed, 56 insertions(+), 17 deletions(-) diff --git a/clients/agent-runtime/src/onboard/wizard.rs b/clients/agent-runtime/src/onboard/wizard.rs index 19b423693..199f5465b 100755 --- a/clients/agent-runtime/src/onboard/wizard.rs +++ b/clients/agent-runtime/src/onboard/wizard.rs @@ -72,7 +72,7 @@ struct CopilotApiEndpoints { api: Option, } -#[derive(Debug, Deserialize)] +#[derive(Deserialize)] struct CopilotApiKeyInfo { token: String, #[serde(default)] @@ -1023,6 +1023,28 @@ 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) @@ -1070,22 +1092,7 @@ fn fetch_copilot_models(api_key: Option<&str>) -> Result> { 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())?, @@ -4583,8 +4590,14 @@ 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(())) + } + // ── ProjectContext defaults ────────────────────────────────── #[test] @@ -5294,6 +5307,32 @@ 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(); + + let previous_github_token = std::env::var("GITHUB_TOKEN").ok(); + let previous_gh_token = std::env::var("GH_TOKEN").ok(); + + 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")); + + if let Some(value) = previous_github_token { + std::env::set_var("GITHUB_TOKEN", value); + } else { + std::env::remove_var("GITHUB_TOKEN"); + } + + if let Some(value) = previous_gh_token { + std::env::set_var("GH_TOKEN", value); + } else { + std::env::remove_var("GH_TOKEN"); + } + } + #[test] fn backend_key_from_choice_maps_supported_backends() { assert_eq!(backend_key_from_choice(0), "sqlite"); From f053bd0699276a41f5770e489437fafbfd001de2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yuniel=20Acosta=20P=C3=A9rez?= <33158051+yacosta738@users.noreply.github.com> Date: Sun, 22 Feb 2026 23:02:24 +0100 Subject: [PATCH 3/3] fix(onboard): align GH_TOKEN detection across wizard flow --- clients/agent-runtime/src/onboard/wizard.rs | 48 ++++++++++++--------- 1 file changed, 28 insertions(+), 20 deletions(-) diff --git a/clients/agent-runtime/src/onboard/wizard.rs b/clients/agent-runtime/src/onboard/wizard.rs index 199f5465b..2ba03e0cc 100755 --- a/clients/agent-runtime/src/onboard/wizard.rs +++ b/clients/agent-runtime/src/onboard/wizard.rs @@ -1989,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) = @@ -4598,6 +4595,30 @@ mod tests { 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] @@ -5309,28 +5330,15 @@ mod tests { #[test] fn resolve_live_model_api_key_uses_gh_token_for_copilot() { - let _guard = env_lock().lock().unwrap(); - - let previous_github_token = std::env::var("GITHUB_TOKEN").ok(); - let previous_gh_token = std::env::var("GH_TOKEN").ok(); + 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")); - - if let Some(value) = previous_github_token { - std::env::set_var("GITHUB_TOKEN", value); - } else { - std::env::remove_var("GITHUB_TOKEN"); - } - - if let Some(value) = previous_gh_token { - std::env::set_var("GH_TOKEN", value); - } else { - std::env::remove_var("GH_TOKEN"); - } } #[test]