Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 87 additions & 0 deletions src/api/providers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<String> = 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)?;
Expand Down Expand Up @@ -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")
);
}
}
8 changes: 8 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::*;
Expand Down Expand Up @@ -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 {
Expand Down
17 changes: 16 additions & 1 deletion src/config/providers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <https://openrouter.ai/docs/app-attribution>.
Expand Down Expand Up @@ -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<String>) -> 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
Expand Down
Loading