diff --git a/src/api/providers.rs b/src/api/providers.rs index 894f8c2e9..c90437694 100644 --- a/src/api/providers.rs +++ b/src/api/providers.rs @@ -207,6 +207,14 @@ fn build_test_llm_config(provider: &str, credential: &str) -> crate::config::Llm let mut providers = HashMap::new(); if let Some(provider_config) = crate::config::default_provider_config(provider, credential) { providers.insert(provider.to_string(), provider_config); + } else if provider == "github-copilot" { + // GitHub Copilot uses token exchange, so default_provider_config returns None. + // For testing, add a provider entry with the PAT as the api_key — + // LlmManager::get_copilot_token() will exchange it for a real Copilot token. + providers.insert( + provider.to_string(), + crate::config::copilot_default_provider_config(credential), + ); } crate::config::LlmConfig { @@ -1572,6 +1580,65 @@ pub(super) async fn delete_provider( .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let doc: toml_edit::DocumentMut = content + .parse() + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + // Check if the provider has a key in TOML + let has_toml_key = doc + .get("llm") + .and_then(|llm| llm.get(key_name)) + .and_then(|val| val.as_str()) + .is_some_and(|s| { + if let Some(alias) = s.strip_prefix("secret:") { + // Check if secret exists in secrets store + state + .secrets_store + .load() + .as_ref() + .as_ref() + .and_then(|store| store.get(alias).ok()) + .is_some() + } else if let Some(var_name) = s.strip_prefix("env:") { + // Check if env var exists and is non-empty + std::env::var(var_name) + .ok() + .is_some_and(|v| !v.trim().is_empty()) + } else { + // Direct value + !s.trim().is_empty() + } + }); + + // If no TOML key exists, check if configured via env var + if !has_toml_key { + let env_vars: Vec = match provider.as_str() { + "ollama" => vec!["OLLAMA_BASE_URL".to_string(), "OLLAMA_API_KEY".to_string()], + _ => vec![format!( + "{}_API_KEY", + provider.to_uppercase().replace("-", "_") + )], + }; + + let configured_env_var = env_vars.iter().find(|name| { + std::env::var(name.as_str()) + .ok() + .is_some_and(|v| !v.trim().is_empty()) + }); + + if let Some(env_var) = configured_env_var { + return Ok(Json(ProviderUpdateResponse { + success: false, + message: format!( + "Provider '{}' is configured via the {} environment variable. \ + To remove this provider, unset the environment variable and restart Spacebot.", + provider, env_var + ), + })); + } + } + + // Now remove from TOML let mut doc: toml_edit::DocumentMut = content .parse() .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; @@ -1607,4 +1674,24 @@ mod tests { assert_eq!(provider.base_url, "http://remote-ollama.local:11434"); assert_eq!(provider.api_key, ""); } + + #[test] + fn build_test_llm_config_registers_github_copilot_provider() { + let config = build_test_llm_config("github-copilot", "ghp_test_pat_token"); + let provider = config + .providers + .get("github-copilot") + .expect("github-copilot provider should be registered"); + + assert_eq!( + provider.base_url, + crate::config::GITHUB_COPILOT_DEFAULT_BASE_URL + ); + assert_eq!(provider.api_key, "ghp_test_pat_token"); + assert!(provider.use_bearer_auth); + assert_eq!( + config.github_copilot_key.as_deref(), + Some("ghp_test_pat_token") + ); + } } diff --git a/src/config.rs b/src/config.rs index eb0891a9a..a4d529d0c 100644 --- a/src/config.rs +++ b/src/config.rs @@ -18,6 +18,10 @@ pub use permissions::{ DiscordPermissions, MattermostPermissions, SignalPermissions, SlackPermissions, TelegramPermissions, TwitchPermissions, }; +// Only used in tests; #[allow(unused_imports)] prevents warnings during non-test builds. +#[allow(unused_imports)] +pub(crate) use providers::GITHUB_COPILOT_DEFAULT_BASE_URL; +pub(crate) use providers::copilot_default_provider_config; pub(crate) use providers::default_provider_config; pub use runtime::RuntimeConfig; pub use types::*; @@ -1306,6 +1310,10 @@ maintenance_merge_similarity_threshold = 1.1 "ollama", "localhost:11434", ), + // Note: github_copilot_key is intentionally excluded — GitHub Copilot requires + // special token exchange handling and is registered via + // LlmManager::get_github_copilot_provider(), not via the standard shorthand + // provider registration mechanism used by other providers. ]; for (toml_key, toml_value, provider_name, url_substr) in cases { diff --git a/src/config/providers.rs b/src/config/providers.rs index 575b9cb2b..fc4d7191a 100644 --- a/src/config/providers.rs +++ b/src/config/providers.rs @@ -26,7 +26,7 @@ pub(super) const NVIDIA_PROVIDER_BASE_URL: &str = "https://integrate.api.nvidia. pub(super) const FIREWORKS_PROVIDER_BASE_URL: &str = "https://api.fireworks.ai/inference"; pub(crate) const GEMINI_PROVIDER_BASE_URL: &str = "https://generativelanguage.googleapis.com/v1beta/openai"; -pub(super) const GITHUB_COPILOT_DEFAULT_BASE_URL: &str = "https://api.individual.githubcopilot.com"; +pub(crate) const GITHUB_COPILOT_DEFAULT_BASE_URL: &str = "https://api.individual.githubcopilot.com"; /// App attribution headers sent with every OpenRouter API request. /// See . @@ -286,6 +286,21 @@ pub(super) fn add_shorthand_provider( } } +/// Returns the default ProviderConfig for GitHub Copilot with the given API key. +/// Used by API tests and other code that needs Copilot provider configs. +pub fn copilot_default_provider_config(api_key: impl Into) -> super::ProviderConfig { + super::ProviderConfig { + api_type: super::ApiType::OpenAiChatCompletions, + base_url: GITHUB_COPILOT_DEFAULT_BASE_URL.to_string(), + api_key: api_key.into(), + name: Some("GitHub Copilot".to_string()), + use_bearer_auth: true, + extra_headers: vec![], + api_version: None, + deployment: None, + } +} + /// When `[defaults.routing]` is absent from the config file, pick routing /// defaults based on which provider the user actually has configured. This /// avoids the common pitfall where a user sets up OpenRouter (or another