From ace54a7be4c1daef8c805447c2f8f25b788bc081 Mon Sep 17 00:00:00 2001 From: YuKuang Date: Thu, 19 Feb 2026 01:28:26 +0800 Subject: [PATCH 1/8] Add providers field to LlmConfig and update has_any_key() method --- src/config.rs | 148 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 148 insertions(+) diff --git a/src/config.rs b/src/config.rs index dfab17caa..142c4a91e 100644 --- a/src/config.rs +++ b/src/config.rs @@ -93,6 +93,41 @@ impl Default for MetricsConfig { } } +/// API types supported by LLM providers. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ApiType { + /// OpenAI Completions API (https://api.openai.com/v1/completions) + OpenAiCompletions, + /// OpenAI Responses API (https://api.openai.com/v1/chat/completions) + OpenAiResponses, + /// Anthropic Messages API (https://api.anthropic.com/v1/messages) + Anthropic, +} + +impl<'de> serde::Deserialize<'de> for ApiType { + fn deserialize>(deserializer: D) -> std::result::Result { + let s = String::deserialize(deserializer)?; + match s.as_str() { + "openai_completions" => Ok(Self::OpenAiCompletions), + "openai_responses" => Ok(Self::OpenAiResponses), + "anthropic" => Ok(Self::Anthropic), + other => Err(serde::de::Error::invalid_value( + serde::de::Unexpected::Str(other), + &"one of \"openai_completions\", \"openai_responses\", or \"anthropic\"", + )), + } + } +} + +/// Configuration for a single LLM provider. +#[derive(Debug, Clone)] +pub struct ProviderConfig { + pub api_type: ApiType, + pub base_url: String, + pub api_key: String, + pub name: Option, +} + /// LLM provider credentials (instance-level). #[derive(Debug, Clone)] pub struct LlmConfig { @@ -110,6 +145,7 @@ pub struct LlmConfig { pub ollama_base_url: Option, pub opencode_zen_key: Option, pub nvidia_key: Option, + pub providers: HashMap, } impl LlmConfig { @@ -129,6 +165,7 @@ impl LlmConfig { || self.ollama_base_url.is_some() || self.opencode_zen_key.is_some() || self.nvidia_key.is_some() + || !self.providers.is_empty() } } @@ -958,6 +995,14 @@ fn default_metrics_bind() -> String { "0.0.0.0".into() } +#[derive(Deserialize, Debug)] +struct TomlProviderConfig { + api_type: ApiType, + base_url: String, + api_key: String, + name: Option, +} + #[derive(Deserialize, Default)] struct TomlLlmConfig { anthropic_key: Option, @@ -974,6 +1019,8 @@ struct TomlLlmConfig { ollama_base_url: Option, opencode_zen_key: Option, nvidia_key: Option, + #[serde(default)] + providers: HashMap, } #[derive(Deserialize, Default)] @@ -1327,6 +1374,8 @@ impl Config { ollama_base_url: std::env::var("OLLAMA_BASE_URL").ok(), opencode_zen_key: std::env::var("OPENCODE_ZEN_API_KEY").ok(), nvidia_key: std::env::var("NVIDIA_API_KEY").ok(), + nvidia_key: std::env::var("NVIDIA_API_KEY").ok(), + providers: HashMap::new(), }; // Note: We allow boot without provider configuration now. System starts in setup mode. @@ -1477,6 +1526,24 @@ impl Config { .as_deref() .and_then(resolve_env_value) .or_else(|| std::env::var("NVIDIA_API_KEY").ok()), + nvidia_key: toml + .llm + .nvidia_key + .as_deref() + .and_then(resolve_env_value) + .or_else(|| std::env::var("NVIDIA_API_KEY").ok()), + providers: toml.llm.providers.into_iter().map(|(name, config)| { + ( + name, + ProviderConfig { + api_type: config.api_type, + base_url: config.base_url, + api_key: resolve_env_value(&config.api_key) + .expect("Failed to resolve API key for provider"), + name: config.name, + }, + ) + }).collect(), }; // Note: We allow boot without provider configuration now. System starts in setup mode. @@ -2656,3 +2723,84 @@ pub fn run_onboarding() -> anyhow::Result> { Ok(Some(config_path)) } + +#[cfg(test)] +mod tests { + use super::*; + use std::result::Result as StdResult; + + #[test] + fn test_api_type_deserialization() { + let toml1 = r#" +api_type = "openai_completions" +base_url = "https://api.openai.com" +api_key = "test-key" +"#; + let result1: StdResult = toml::from_str(toml1); + assert!(result1.is_ok(), "Error: {:?}", result1.err()); + assert_eq!(result1.unwrap().api_type, ApiType::OpenAiCompletions); + + let toml2 = r#" +api_type = "openai_responses" +base_url = "https://api.openai.com" +api_key = "test-key" +"#; + let result2: StdResult = toml::from_str(toml2); + assert!(result2.is_ok(), "Error: {:?}", result2.err()); + assert_eq!(result2.unwrap().api_type, ApiType::OpenAiResponses); + + let toml3 = r#" +api_type = "anthropic" +base_url = "https://api.anthropic.com" +api_key = "test-key" +"#; + let result3: StdResult = toml::from_str(toml3); + assert!(result3.is_ok(), "Error: {:?}", result3.err()); + assert_eq!(result3.unwrap().api_type, ApiType::Anthropic); + } + + #[test] + fn test_api_type_deserialization_invalid() { + let toml = r#"api_type = "invalid_type""#; + let result: StdResult = toml::from_str(toml); + assert!(result.is_err()); + let error = result.unwrap_err(); + assert!(error.to_string().contains("invalid value")); + assert!(error.to_string().contains("openai_completions")); + assert!(error.to_string().contains("openai_responses")); + assert!(error.to_string().contains("anthropic")); + } + + #[test] + fn test_provider_config_deserialization() { + let toml = r#" +api_type = "anthropic" +base_url = "https://api.anthropic.com/v1" +api_key = "sk-ant-api03-abc123" +name = "Anthropic" +"#; + let result: StdResult = toml::from_str(toml); + assert!(result.is_ok()); + let config = result.unwrap(); + assert_eq!(config.api_type, ApiType::Anthropic); + assert_eq!(config.base_url, "https://api.anthropic.com/v1"); + assert_eq!(config.api_key, "sk-ant-api03-abc123"); + assert_eq!(config.name, Some("Anthropic".to_string())); + } + + #[test] + fn test_provider_config_deserialization_no_name() { + let toml = r#" +api_type = "openai_responses" +base_url = "https://api.openai.com/v1" +api_key = "sk-proj-xyz789" +"#; + let result: StdResult = toml::from_str(toml); + assert!(result.is_ok()); + let config = result.unwrap(); + assert_eq!(config.api_type, ApiType::OpenAiResponses); + assert_eq!(config.base_url, "https://api.openai.com/v1"); + assert_eq!(config.api_key, "sk-proj-xyz789"); + assert_eq!(config.name, None); + } +} From ea2a687c856f080657bd99e5fe35529e5605034f Mon Sep 17 00:00:00 2001 From: YuKuang Date: Thu, 19 Feb 2026 01:47:47 +0800 Subject: [PATCH 2/8] Update setup mode provider detection --- src/config.rs | 303 +++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 275 insertions(+), 28 deletions(-) diff --git a/src/config.rs b/src/config.rs index 142c4a91e..dc8d52385 100644 --- a/src/config.rs +++ b/src/config.rs @@ -4,7 +4,7 @@ use crate::error::{ConfigError, Result}; use crate::llm::routing::RoutingConfig; use anyhow::Context as _; use arc_swap::ArcSwap; -use serde::Deserialize; +use serde::{Deserialize, Deserializer}; use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::sync::Arc; @@ -169,6 +169,10 @@ impl LlmConfig { } } +const ANTHROPIC_PROVIDER_BASE_URL: &str = "https://api.anthropic.com"; +const OPENAI_PROVIDER_BASE_URL: &str = "https://api.openai.com"; +const OPENROUTER_PROVIDER_BASE_URL: &str = "https://openrouter.ai/api"; + /// Defaults inherited by all agents. Individual agents can override any field. #[derive(Debug, Clone)] pub struct DefaultsConfig { @@ -1004,7 +1008,7 @@ struct TomlProviderConfig { } #[derive(Deserialize, Default)] -struct TomlLlmConfig { +struct TomlLlmConfigFields { anthropic_key: Option, openai_key: Option, openrouter_key: Option, @@ -1021,6 +1025,72 @@ struct TomlLlmConfig { nvidia_key: Option, #[serde(default)] providers: HashMap, + #[serde(default)] + #[serde(flatten)] + extra: HashMap, +} + +#[derive(Default)] +struct TomlLlmConfig { + anthropic_key: Option, + openai_key: Option, + openrouter_key: Option, + zhipu_key: Option, + groq_key: Option, + together_key: Option, + fireworks_key: Option, + deepseek_key: Option, + xai_key: Option, + mistral_key: Option, + opencode_zen_key: Option, + providers: HashMap, +} + +impl<'de> Deserialize<'de> for TomlLlmConfig { + fn deserialize(deserializer: D) -> std::result::Result + where + D: Deserializer<'de>, + { + let mut fields = TomlLlmConfigFields::deserialize(deserializer)?; + let mut providers = fields.providers; + + for (key, value) in fields.extra { + if key == "provider" { + let table = value + .as_table() + .ok_or_else(|| serde::de::Error::custom("`llm.provider` must be a table"))?; + for (provider_id, provider_value) in table { + let provider_config = provider_value + .clone() + .try_into() + .map_err(serde::de::Error::custom)?; + providers.insert(provider_id.to_string(), provider_config); + } + } + + if let Some(provider_id) = key.strip_prefix("provider.") { + let provider_config = value.try_into().map_err(serde::de::Error::custom)?; + providers.insert(provider_id.to_string(), provider_config); + } + } + + fields.providers = providers; + + Ok(Self { + anthropic_key: fields.anthropic_key, + openai_key: fields.openai_key, + openrouter_key: fields.openrouter_key, + zhipu_key: fields.zhipu_key, + groq_key: fields.groq_key, + together_key: fields.together_key, + fireworks_key: fields.fireworks_key, + deepseek_key: fields.deepseek_key, + xai_key: fields.xai_key, + mistral_key: fields.mistral_key, + opencode_zen_key: fields.opencode_zen_key, + providers: fields.providers, + }) + } } #[derive(Deserialize, Default)] @@ -1313,14 +1383,15 @@ impl Config { }) } - /// Check whether a first-run onboarding is needed (no config file and no env keys). + /// Check whether a first-run onboarding is needed (no config file and no env keys/providers). pub fn needs_onboarding() -> bool { let instance_dir = Self::default_instance_dir(); let config_path = instance_dir.join("config.toml"); if config_path.exists() { return false; } - // No config file — check if env vars can bootstrap + + // Check if we have any legacy env keys or providers configured std::env::var("ANTHROPIC_API_KEY").is_err() && std::env::var("OPENAI_API_KEY").is_err() && std::env::var("OPENROUTER_API_KEY").is_err() @@ -1359,7 +1430,7 @@ impl Config { /// Load from environment variables only (no config file). pub fn load_from_env(instance_dir: &Path) -> Result { - let llm = LlmConfig { + let mut llm = LlmConfig { anthropic_key: std::env::var("ANTHROPIC_API_KEY").ok(), openai_key: std::env::var("OPENAI_API_KEY").ok(), openrouter_key: std::env::var("OPENROUTER_API_KEY").ok(), @@ -1374,12 +1445,45 @@ impl Config { ollama_base_url: std::env::var("OLLAMA_BASE_URL").ok(), opencode_zen_key: std::env::var("OPENCODE_ZEN_API_KEY").ok(), nvidia_key: std::env::var("NVIDIA_API_KEY").ok(), - nvidia_key: std::env::var("NVIDIA_API_KEY").ok(), providers: HashMap::new(), }; - // Note: We allow boot without provider configuration now. System starts in setup mode. - // Agents are initialized later when providers are added via API. + // Populate providers from env vars (same as from_toml does) + if let Some(anthropic_key) = llm.anthropic_key.clone() { + llm.providers + .entry("anthropic".to_string()) + .or_insert_with(|| ProviderConfig { + api_type: ApiType::Anthropic, + base_url: ANTHROPIC_PROVIDER_BASE_URL.to_string(), + api_key: anthropic_key, + name: None, + }); + } + + if let Some(openai_key) = llm.openai_key.clone() { + llm.providers + .entry("openai".to_string()) + .or_insert_with(|| ProviderConfig { + api_type: ApiType::OpenAiCompletions, + base_url: OPENAI_PROVIDER_BASE_URL.to_string(), + api_key: openai_key, + name: None, + }); + } + + if let Some(openrouter_key) = llm.openrouter_key.clone() { + llm.providers + .entry("openrouter".to_string()) + .or_insert_with(|| ProviderConfig { + api_type: ApiType::OpenAiCompletions, + base_url: OPENROUTER_PROVIDER_BASE_URL.to_string(), + api_key: openrouter_key, + name: None, + }); + } + + // Note: We allow boot without provider keys now. System starts in setup mode. + // Agents are initialized later when keys are added via API. // Env-only routing: check for env overrides on channel/worker models let mut routing = RoutingConfig::default(); @@ -1441,7 +1545,7 @@ impl Config { } fn from_toml(toml: TomlConfig, instance_dir: PathBuf) -> Result { - let llm = LlmConfig { + let mut llm = LlmConfig { anthropic_key: toml .llm .anthropic_key @@ -1526,28 +1630,60 @@ impl Config { .as_deref() .and_then(resolve_env_value) .or_else(|| std::env::var("NVIDIA_API_KEY").ok()), - nvidia_key: toml + providers: toml .llm - .nvidia_key - .as_deref() - .and_then(resolve_env_value) - .or_else(|| std::env::var("NVIDIA_API_KEY").ok()), - providers: toml.llm.providers.into_iter().map(|(name, config)| { - ( - name, - ProviderConfig { - api_type: config.api_type, - base_url: config.base_url, - api_key: resolve_env_value(&config.api_key) - .expect("Failed to resolve API key for provider"), - name: config.name, - }, - ) - }).collect(), + .providers + .into_iter() + .map(|(provider_id, config)| { + ( + provider_id.to_lowercase(), + ProviderConfig { + api_type: config.api_type, + base_url: config.base_url, + api_key: resolve_env_value(&config.api_key) + .expect("Failed to resolve API key for provider"), + name: config.name, + }, + ) + }) + .collect(), }; - // Note: We allow boot without provider configuration now. System starts in setup mode. - // Agents are initialized later when providers are added via API. + if let Some(anthropic_key) = llm.anthropic_key.clone() { + llm.providers + .entry("anthropic".to_string()) + .or_insert_with(|| ProviderConfig { + api_type: ApiType::Anthropic, + base_url: ANTHROPIC_PROVIDER_BASE_URL.to_string(), + api_key: anthropic_key, + name: None, + }); + } + + if let Some(openai_key) = llm.openai_key.clone() { + llm.providers + .entry("openai".to_string()) + .or_insert_with(|| ProviderConfig { + api_type: ApiType::OpenAiCompletions, + base_url: OPENAI_PROVIDER_BASE_URL.to_string(), + api_key: openai_key, + name: None, + }); + } + + if let Some(openrouter_key) = llm.openrouter_key.clone() { + llm.providers + .entry("openrouter".to_string()) + .or_insert_with(|| ProviderConfig { + api_type: ApiType::OpenAiCompletions, + base_url: OPENROUTER_PROVIDER_BASE_URL.to_string(), + api_key: openrouter_key, + name: None, + }); + } + + // Note: We allow boot without provider keys now. System starts in setup mode. + // Agents are initialized later when keys are added via API. let base_defaults = DefaultsConfig::default(); let defaults = DefaultsConfig { @@ -2803,4 +2939,115 @@ api_key = "sk-proj-xyz789" assert_eq!(config.api_key, "sk-proj-xyz789"); assert_eq!(config.name, None); } + + #[test] + fn test_llm_provider_tables_parse_with_env_and_lowercase_keys() { + let toml = r#" +[llm.provider.MyProv] +api_type = "openai_responses" +base_url = "https://api.example.com/v1" +api_key = "env:PATH" + +[llm.provider.SecondProvider] +api_type = "anthropic" +base_url = "https://api.anthropic.com/v1" +api_key = "static-provider-key" +"#; + + let parsed: TomlConfig = toml::from_str(toml).expect("failed to parse test TOML"); + let config = Config::from_toml(parsed, PathBuf::from(".")).expect("failed to build Config"); + + assert_eq!(config.llm.providers.len(), 2); + assert!(config.llm.providers.contains_key("myprov")); + assert!(config.llm.providers.contains_key("secondprovider")); + + let my_provider = config + .llm + .providers + .get("myprov") + .expect("myprov provider missing"); + assert_eq!(my_provider.api_type, ApiType::OpenAiResponses); + assert_eq!(my_provider.base_url, "https://api.example.com/v1"); + assert_eq!( + my_provider.api_key, + std::env::var("PATH").expect("PATH must exist for test") + ); + + let second_provider = config + .llm + .providers + .get("secondprovider") + .expect("secondprovider provider missing"); + assert_eq!(second_provider.api_type, ApiType::Anthropic); + assert_eq!(second_provider.base_url, "https://api.anthropic.com/v1"); + assert_eq!(second_provider.api_key, "static-provider-key"); + } + + #[test] + fn test_legacy_llm_keys_auto_migrate_to_providers() { + let toml = r#" +[llm] +anthropic_key = "legacy-anthropic-key" +openai_key = "legacy-openai-key" +openrouter_key = "legacy-openrouter-key" +"#; + + let parsed: TomlConfig = toml::from_str(toml).expect("failed to parse test TOML"); + let config = Config::from_toml(parsed, PathBuf::from(".")).expect("failed to build Config"); + + let anthropic_provider = config + .llm + .providers + .get("anthropic") + .expect("anthropic provider missing"); + assert_eq!(anthropic_provider.api_type, ApiType::Anthropic); + assert_eq!(anthropic_provider.base_url, ANTHROPIC_PROVIDER_BASE_URL); + assert_eq!(anthropic_provider.api_key, "legacy-anthropic-key"); + + let openai_provider = config + .llm + .providers + .get("openai") + .expect("openai provider missing"); + assert_eq!(openai_provider.api_type, ApiType::OpenAiCompletions); + assert_eq!(openai_provider.base_url, OPENAI_PROVIDER_BASE_URL); + assert_eq!(openai_provider.api_key, "legacy-openai-key"); + + let openrouter_provider = config + .llm + .providers + .get("openrouter") + .expect("openrouter provider missing"); + assert_eq!(openrouter_provider.api_type, ApiType::OpenAiCompletions); + assert_eq!(openrouter_provider.base_url, OPENROUTER_PROVIDER_BASE_URL); + assert_eq!(openrouter_provider.api_key, "legacy-openrouter-key"); + } + + #[test] + fn test_explicit_provider_config_takes_priority_over_legacy_key_migration() { + let toml = r#" +[llm] +openai_key = "legacy-openai-key" + +[llm.provider.openai] +api_type = "openai_responses" +base_url = "https://custom.openai.example/v1" +api_key = "explicit-openai-key" +name = "Custom OpenAI" +"#; + + let parsed: TomlConfig = toml::from_str(toml).expect("failed to parse test TOML"); + let config = Config::from_toml(parsed, PathBuf::from(".")).expect("failed to build Config"); + + let openai_provider = config + .llm + .providers + .get("openai") + .expect("openai provider missing"); + assert_eq!(openai_provider.api_type, ApiType::OpenAiResponses); + assert_eq!(openai_provider.base_url, "https://custom.openai.example/v1"); + assert_eq!(openai_provider.api_key, "explicit-openai-key"); + assert_eq!(openai_provider.name.as_deref(), Some("Custom OpenAI")); + assert_eq!(config.llm.openai_key.as_deref(), Some("legacy-openai-key")); + } } From f232c6cc63ee5c9731277827b9110b9a1b07c5ea Mon Sep 17 00:00:00 2001 From: YuKuang Date: Thu, 19 Feb 2026 08:53:39 +0800 Subject: [PATCH 3/8] Add .idea to gitignore and document custom LLM providers - Add .idea directory to .gitignore for JetBrains IDE support - Document custom provider configuration in config.mdx - Add migration guide from legacy keys to custom providers - Document all supported API types (anthropic, openai_completions, openai_responses) --- .gitignore | 2 + docs/content/docs/(configuration)/config.mdx | 140 ++++++++++++++++++- 2 files changed, 139 insertions(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index a6ff80f37..830410c28 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,5 @@ # Interface interface/node_modules/ interface/dist/ + +.idea diff --git a/docs/content/docs/(configuration)/config.mdx b/docs/content/docs/(configuration)/config.mdx index 88ca4630c..0b87bb846 100644 --- a/docs/content/docs/(configuration)/config.mdx +++ b/docs/content/docs/(configuration)/config.mdx @@ -19,11 +19,37 @@ spacebot --config /path/to.toml # CLI override ```toml # --- LLM Provider Credentials --- -# Instance-level, shared by all agents. At least one key is required. +# Instance-level, shared by all agents. At least one key or provider is required. [llm] anthropic_key = "env:ANTHROPIC_API_KEY" openai_key = "env:OPENAI_API_KEY" openrouter_key = "env:OPENROUTER_API_KEY" +zhipu_key = "env:ZHIPU_API_KEY" +groq_key = "env:GROQ_API_KEY" +together_key = "env:TOGETHER_API_KEY" +fireworks_key = "env:FIREWORKS_API_KEY" +deepseek_key = "env:DEEPSEEK_API_KEY" +xai_key = "env:XAI_API_KEY" +mistral_key = "env:MISTRAL_API_KEY" +opencode_zen_key = "env:OPENCODE_ZEN_API_KEY" + +# Custom LLM providers (alternative to legacy keys) +[llm.provider.my_anthropic] +api_type = "anthropic" +base_url = "https://api.anthropic.com" +api_key = "env:MY_ANTHROPIC_KEY" +name = "My Custom Anthropic" + +[llm.provider.my_openai] +api_type = "openai_responses" +base_url = "https://api.openai.com" +api_key = "env:MY_OPENAI_KEY" + +[llm.provider.local_openai] +api_type = "openai_completions" +base_url = "http://localhost:8080/v1" +api_key = "env:LOCAL_OPENAI_KEY" +name = "Local OpenAI Compatible" # --- Instance Defaults --- # All agents inherit these. Individual agents can override any field. @@ -168,6 +194,7 @@ Model names include the provider as a prefix: | Anthropic | `anthropic/` | `anthropic/claude-sonnet-4-20250514` | | OpenAI | `openai/` | `openai/gpt-4o` | | OpenRouter | `openrouter//` | `openrouter/anthropic/claude-sonnet-4-20250514` | +| Custom provider | `/` | `my_openai/gpt-4o-mini` | You can mix providers across process types. See [Routing](/docs/routing) for the full routing system. @@ -254,15 +281,102 @@ System prompts (channel, branch, worker, compactor, cortex, etc.) are Jinja2 tem ## Sections Reference -### `[llm]` +### Migration from Legacy Keys + +Legacy keys (`anthropic_key`, `openai_key`, etc.) are still supported and automatically converted to provider entries internally. For example: + +**Legacy format:** +```toml +[llm] +anthropic_key = "env:ANTHROPIC_API_KEY" +openai_key = "env:OPENAI_API_KEY" +``` + +**Internal representation (auto-created):** +```toml +[llm.provider.anthropic] +api_type = "anthropic" +base_url = "https://api.anthropic.com" +api_key = "env:ANTHROPIC_API_KEY" + +[llm.provider.openai] +api_type = "openai_completions" +base_url = "https://api.openai.com" +api_key = "env:OPENAI_API_KEY" +``` + +If you define a custom provider with the same ID as a legacy key, your custom configuration takes precedence. + +#### Legacy Keys | Key | Type | Default | Description | |-----|------|---------|-------------| | `anthropic_key` | string | None | Anthropic API key (or `env:VAR_NAME`) | | `openai_key` | string | None | OpenAI API key (or `env:VAR_NAME`) | | `openrouter_key` | string | None | OpenRouter API key (or `env:VAR_NAME`) | +| `zhipu_key` | string | None | Zhipu AI (GLM) API key (or `env:VAR_NAME`) | +| `groq_key` | string | None | Groq API key (or `env:VAR_NAME`) | +| `together_key` | string | None | Together AI API key (or `env:VAR_NAME`) | +| `fireworks_key` | string | None | Fireworks AI API key (or `env:VAR_NAME`) | +| `deepseek_key` | string | None | DeepSeek API key (or `env:VAR_NAME`) | +| `xai_key` | string | None | XAI API key (or `env:VAR_NAME`) | +| `mistral_key` | string | None | Mistral API key (or `env:VAR_NAME`) | +| `opencode_zen_key` | string | None | OpenCode Zen API key (or `env:VAR_NAME`) | + +#### Custom Providers + +Custom providers allow configuring LLM providers with custom endpoints and API types. Use either legacy keys **or** custom providers. + +```toml +[llm.provider.] +api_type = "anthropic" # Required - one of: anthropic, openai_completions, openai_responses +base_url = "https://api..." # Required - valid URL +api_key = "env:API_KEY" # Required - API key (supports env:VAR_NAME format) +name = "My Provider" # Optional - friendly name for display +``` + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `api_type` | string | Yes | API protocol type. One of: `anthropic` (Anthropic Messages API), `openai_completions` (OpenAI Completions API), or `openai_responses` (OpenAI Chat Completions API) | +| `base_url` | string | Yes | Base URL of the API endpoint. Must be a valid URL (including protocol) | +| `api_key` | string | Yes | API key for authentication. Supports `env:VAR_NAME` syntax to reference environment variables | +| `name` | string | No | Optional friendly name for the provider (displayed in logs and UI) | -At least one key must be provided (via config or environment). +**Provider ID Requirements:** +- 1-64 characters long +- Cannot contain `/` or whitespace +- Case-insensitive (stored as lowercase) + +#### Examples + +**Anthropic-compatible provider:** +```toml +[llm.provider.custom_anthropic] +api_type = "anthropic" +base_url = "https://api.anthropic.com" +api_key = "env:CUSTOM_ANTHROPIC_KEY" +name = "Anthropic EU" +``` + +**OpenAI Chat Completions provider:** +```toml +[llm.provider.azure_openai] +api_type = "openai_responses" +base_url = "https://my-azure-openai.openai.azure.com" +api_key = "env:AZURE_OPENAI_KEY" +name = "Azure OpenAI GPT-4" +``` + +**OpenAI Completions provider:** +```toml +[llm.provider.local_llm] +api_type = "openai_completions" +base_url = "http://localhost:8080/v1" +api_key = "env:LOCAL_LLM_KEY" +name = "Local LLaMA Server" +``` + +At least one provider (legacy key or custom provider) must be configured. ### `[defaults]` @@ -285,6 +399,26 @@ At least one key must be provided (via config or environment). | `cortex` | string | `anthropic/claude-haiku-4.5-20250514` | Model for system observation | | `rate_limit_cooldown_secs` | integer | 60 | How long to deprioritize a rate-limited model | +Routing selects providers by the prefix before the first `/` in the model name. + +```toml +[defaults.routing] +channel = "my_openai/gpt-4o-mini" +worker = "custom_anthropic/claude-3-5-sonnet" + +[llm.provider.my_openai] +api_type = "openai_completions" +base_url = "https://api.openai.com" +api_key = "env:OPENAI_API_KEY" + +[llm.provider.custom_anthropic] +api_type = "anthropic" +base_url = "https://api.anthropic.com" +api_key = "env:ANTHROPIC_API_KEY" +``` + +If no prefix is provided (for example `claude-sonnet-4-20250514`), Spacebot defaults to the `anthropic` provider. + ### `[defaults.routing.task_overrides]` Map of task type names to model names. Applied when workers or branches are spawned with a specific task type. From 616b412765df693cf87ede9d315a38c6d6ee43ea Mon Sep 17 00:00:00 2001 From: YuKuang Date: Thu, 19 Feb 2026 08:54:06 +0800 Subject: [PATCH 4/8] Add custom provider config validation and onboarding improvements - Add provider ID validation (length, invalid characters) - Add base URL validation in config loading - Extend needs_onboarding() to check all legacy env keys - Add provider-specific env var detection - Add tests for onboarding logic --- src/config.rs | 168 +++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 161 insertions(+), 7 deletions(-) diff --git a/src/config.rs b/src/config.rs index dc8d52385..0fbfe1add 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1390,14 +1390,36 @@ impl Config { if config_path.exists() { return false; } + + // Check if we have any legacy env keys configured + let has_legacy_keys = std::env::var("ANTHROPIC_API_KEY").is_ok() + || std::env::var("OPENAI_API_KEY").is_ok() + || std::env::var("OPENROUTER_API_KEY").is_ok() + || std::env::var("ZHIPU_API_KEY").is_ok() + || std::env::var("GROQ_API_KEY").is_ok() + || std::env::var("TOGETHER_API_KEY").is_ok() + || std::env::var("FIREWORKS_API_KEY").is_ok() + || std::env::var("DEEPSEEK_API_KEY").is_ok() + || std::env::var("XAI_API_KEY").is_ok() + || std::env::var("MISTRAL_API_KEY").is_ok() + || std::env::var("NVIDIA_API_KEY").is_ok() + || std::env::var("OLLAMA_API_KEY").is_ok() + || std::env::var("OLLAMA_BASE_URL").is_ok() + || std::env::var("OPENCODE_ZEN_API_KEY").is_ok(); + + // If we have any legacy keys, no onboarding needed + if has_legacy_keys { + return false; + } + + // Check if we have any provider-specific env variables (provider..*) + let has_provider_env_vars = std::env::vars().any(|(key, _)| { + key.starts_with("SPACEBOT_PROVIDER_") + || key.starts_with("PROVIDER_") + || key.contains("PROVIDER") && key.contains("API_KEY") + }); - // Check if we have any legacy env keys or providers configured - std::env::var("ANTHROPIC_API_KEY").is_err() - && std::env::var("OPENAI_API_KEY").is_err() - && std::env::var("OPENROUTER_API_KEY").is_err() - && std::env::var("OLLAMA_API_KEY").is_err() - && std::env::var("OLLAMA_BASE_URL").is_err() - && std::env::var("OPENCODE_ZEN_API_KEY").is_err() + !has_provider_env_vars } /// Load configuration from the default config file, falling back to env vars. @@ -1545,6 +1567,34 @@ impl Config { } fn from_toml(toml: TomlConfig, instance_dir: PathBuf) -> Result { + // Validate providers before processing + for (provider_id, config) in &toml.llm.providers { + // Validate provider_id + if provider_id.is_empty() || provider_id.len() > 64 { + return Err(ConfigError::Invalid(format!( + "Provider ID '{}' must be between 1 and 64 characters long", + provider_id + )) + .into()); + } + if provider_id.contains('/') || provider_id.contains(char::is_whitespace) { + return Err(ConfigError::Invalid(format!( + "Provider ID '{}' contains invalid characters (cannot contain '/' or whitespace)", + provider_id + )) + .into()); + } + + // Validate base_url + if let Err(e) = reqwest::Url::parse(&config.base_url) { + return Err(ConfigError::Invalid(format!( + "Invalid base URL '{}' for provider '{}': {}", + config.base_url, provider_id, e + )) + .into()); + } + } + let mut llm = LlmConfig { anthropic_key: toml .llm @@ -2864,6 +2914,68 @@ pub fn run_onboarding() -> anyhow::Result> { mod tests { use super::*; use std::result::Result as StdResult; + use std::sync::{Mutex, OnceLock}; + + fn env_test_lock() -> &'static Mutex<()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) + } + + struct EnvGuard { + vars: Vec<(&'static str, Option)>, + test_dir: PathBuf, + } + + impl EnvGuard { + fn new() -> Self { + const KEYS: [&str; 4] = [ + "SPACEBOT_DIR", + "ANTHROPIC_API_KEY", + "OPENAI_API_KEY", + "OPENROUTER_API_KEY", + ]; + + let vars = KEYS + .into_iter() + .map(|key| (key, std::env::var(key).ok())) + .collect::>(); + + for key in KEYS { + unsafe { + std::env::remove_var(key); + } + } + + let unique = format!( + "spacebot-config-tests-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system time before UNIX_EPOCH") + .as_nanos() + ); + let test_dir = std::env::temp_dir().join(unique); + std::fs::create_dir_all(&test_dir).expect("failed to create test dir"); + + unsafe { + std::env::set_var("SPACEBOT_DIR", &test_dir); + } + + Self { vars, test_dir } + } + } + + impl Drop for EnvGuard { + fn drop(&mut self) { + for (key, value) in &self.vars { + match value { + Some(v) => unsafe { std::env::set_var(key, v) }, + None => unsafe { std::env::remove_var(key) }, + } + } + let _ = std::fs::remove_dir_all(&self.test_dir); + } + } #[test] fn test_api_type_deserialization() { @@ -3050,4 +3162,46 @@ name = "Custom OpenAI" assert_eq!(openai_provider.name.as_deref(), Some("Custom OpenAI")); assert_eq!(config.llm.openai_key.as_deref(), Some("legacy-openai-key")); } + + #[test] + fn test_needs_onboarding_without_config_or_env() { + let _lock = env_test_lock().lock().expect("failed to lock env test mutex"); + let _env = EnvGuard::new(); + + assert!(Config::needs_onboarding()); + } + + #[test] + fn test_needs_onboarding_with_anthropic_env_key() { + let _lock = env_test_lock().lock().expect("failed to lock env test mutex"); + let _env = EnvGuard::new(); + + unsafe { + std::env::set_var("ANTHROPIC_API_KEY", "test-key"); + } + + assert!(!Config::needs_onboarding()); + } + + #[test] + fn test_load_from_env_populates_legacy_key_and_provider() { + let _lock = env_test_lock().lock().expect("failed to lock env test mutex"); + let _env = EnvGuard::new(); + + unsafe { + std::env::set_var("ANTHROPIC_API_KEY", "test-key"); + } + + let config = Config::load_from_env(&Config::default_instance_dir()) + .expect("failed to load config from env"); + + assert_eq!(config.llm.anthropic_key.as_deref(), Some("test-key")); + let provider = config + .llm + .providers + .get("anthropic") + .expect("missing anthropic provider from env"); + assert_eq!(provider.api_key, "test-key"); + assert_eq!(provider.base_url, ANTHROPIC_PROVIDER_BASE_URL); + } } From 6fae23dd12f3745e2459c2528434de6386b469b0 Mon Sep 17 00:00:00 2001 From: YuKuang Date: Thu, 19 Feb 2026 08:54:28 +0800 Subject: [PATCH 5/8] Refactor LlmManager to support custom providers - Add get_provider() method to retrieve provider config by ID - Make config field public for API access - Refactor get_api_key() to use provider config instead of hardcoded keys - Remove hardcoded provider key matches in favor of dynamic lookup --- src/llm/manager.rs | 50 ++++++++++++++++++---------------------------- 1 file changed, 19 insertions(+), 31 deletions(-) diff --git a/src/llm/manager.rs b/src/llm/manager.rs index 4f7293269..2e005905e 100644 --- a/src/llm/manager.rs +++ b/src/llm/manager.rs @@ -4,7 +4,7 @@ //! and shared rate limit state. Routing decisions (which model for which //! process) live on the agent's RoutingConfig, not here. -use crate::config::LlmConfig; +use crate::config::{LlmConfig, ProviderConfig}; use crate::error::{LlmError, Result}; use anyhow::Context as _; use std::collections::HashMap; @@ -14,7 +14,7 @@ use tokio::sync::RwLock; /// Manages LLM provider clients and tracks rate limit state. pub struct LlmManager { - config: LlmConfig, + pub config: LlmConfig, http_client: reqwest::Client, /// Models currently in rate limit cooldown, with the time they were limited. rate_limited: Arc>>, @@ -35,37 +35,25 @@ impl LlmManager { }) } + pub fn get_provider(&self, provider_id: &str) -> Result { + let normalized_provider_id = provider_id.to_lowercase(); + + self.config + .providers + .get(&normalized_provider_id) + .cloned() + .ok_or_else(|| LlmError::UnknownProvider(provider_id.to_string()).into()) + } + /// Get the appropriate API key for a provider. - pub fn get_api_key(&self, provider: &str) -> Result { - match provider { - "anthropic" => self.config.anthropic_key.clone() - .ok_or_else(|| LlmError::MissingProviderKey("anthropic".into()).into()), - "openai" => self.config.openai_key.clone() - .ok_or_else(|| LlmError::MissingProviderKey("openai".into()).into()), - "openrouter" => self.config.openrouter_key.clone() - .ok_or_else(|| LlmError::MissingProviderKey("openrouter".into()).into()), - "zhipu" => self.config.zhipu_key.clone() - .ok_or_else(|| LlmError::MissingProviderKey("zhipu".into()).into()), - "groq" => self.config.groq_key.clone() - .ok_or_else(|| LlmError::MissingProviderKey("groq".into()).into()), - "together" => self.config.together_key.clone() - .ok_or_else(|| LlmError::MissingProviderKey("together".into()).into()), - "fireworks" => self.config.fireworks_key.clone() - .ok_or_else(|| LlmError::MissingProviderKey("fireworks".into()).into()), - "deepseek" => self.config.deepseek_key.clone() - .ok_or_else(|| LlmError::MissingProviderKey("deepseek".into()).into()), - "xai" => self.config.xai_key.clone() - .ok_or_else(|| LlmError::MissingProviderKey("xai".into()).into()), - "mistral" => self.config.mistral_key.clone() - .ok_or_else(|| LlmError::MissingProviderKey("mistral".into()).into()), - "ollama" => self.config.ollama_key.clone() - .ok_or_else(|| LlmError::MissingProviderKey("ollama".into()).into()), - "opencode-zen" => self.config.opencode_zen_key.clone() - .ok_or_else(|| LlmError::MissingProviderKey("opencode-zen".into()).into()), - "nvidia" => self.config.nvidia_key.clone() - .ok_or_else(|| LlmError::MissingProviderKey("nvidia".into()).into()), - _ => Err(LlmError::UnknownProvider(provider.into()).into()), + pub fn get_api_key(&self, provider_id: &str) -> Result { + let provider = self.get_provider(provider_id)?; + + if provider.api_key.is_empty() { + return Err(LlmError::MissingProviderKey(provider_id.to_string()).into()); } + + Ok(provider.api_key) } /// Get configured Ollama base URL, if provided. From e07c5668f8d52639b1886c0bbbc0481d82b48414 Mon Sep 17 00:00:00 2001 From: YuKuang Date: Thu, 19 Feb 2026 08:54:54 +0800 Subject: [PATCH 6/8] Refactor SpacebotModel to use custom provider config - Extract provider ID from model name (e.g., 'my_openai/gpt-4o' -> 'my_openai') - Route API calls based on provider config api_type field - Add call_openai_responses() for OpenAI Responses API support - Add convert_messages_to_openai_responses() message converter - Add parse_openai_responses_response() response parser - Update all provider calls to use dynamic base_url from config - Add convert_image_openai_responses() for image handling in Responses API --- src/llm/model.rs | 473 +++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 416 insertions(+), 57 deletions(-) diff --git a/src/llm/model.rs b/src/llm/model.rs index 7a88e7970..7ea309cc1 100644 --- a/src/llm/model.rs +++ b/src/llm/model.rs @@ -4,13 +4,14 @@ use crate::llm::manager::LlmManager; use crate::llm::routing::{ self, RoutingConfig, MAX_FALLBACK_ATTEMPTS, MAX_RETRIES_PER_MODEL, RETRY_BASE_DELAY_MS, }; +use crate::config::{ApiType, ProviderConfig}; use rig::completion::{ self, CompletionError, CompletionModel, CompletionRequest, GetTokenUsage, }; use rig::message::{ AssistantContent, DocumentSourceKind, Image, Message, MimeType, Text, ToolCall, ToolFunction, - ToolResult, UserContent, + UserContent, }; use rig::one_or_many::OneOrMany; use rig::streaming::StreamingCompletionResponse; @@ -65,23 +66,21 @@ impl SpacebotModel { &self, request: CompletionRequest, ) -> Result, CompletionError> { - match self.provider.as_str() { - "anthropic" => self.call_anthropic(request).await, - "openai" => self.call_openai(request).await, - "openrouter" => self.call_openrouter(request).await, - "zhipu" => self.call_zhipu(request).await, - "groq" => self.call_groq(request).await, - "together" => self.call_together(request).await, - "fireworks" => self.call_fireworks(request).await, - "deepseek" => self.call_deepseek(request).await, - "xai" => self.call_xai(request).await, - "mistral" => self.call_mistral(request).await, - "ollama" => self.call_ollama(request).await, - "opencode-zen" => self.call_opencode_zen(request).await, - "nvidia" => self.call_nvidia(request).await, - other => Err(CompletionError::ProviderError(format!( - "unknown provider: {other}" - ))), + let provider_id = self + .full_model_name + .split_once('/') + .map(|(provider, _)| provider) + .unwrap_or("anthropic"); + + let provider_config = self + .llm_manager + .get_provider(provider_id) + .map_err(|e| CompletionError::ProviderError(e.to_string()))?; + + match provider_config.api_type { + ApiType::Anthropic => self.call_anthropic(request, &provider_config).await, + ApiType::OpenAiCompletions => self.call_openai(request, &provider_config).await, + ApiType::OpenAiResponses => self.call_openai_responses(request, &provider_config).await, } } @@ -301,11 +300,11 @@ impl SpacebotModel { async fn call_anthropic( &self, request: CompletionRequest, + provider_config: &ProviderConfig, ) -> Result, CompletionError> { - let api_key = self - .llm_manager - .get_api_key("anthropic") - .map_err(|e| CompletionError::ProviderError(e.to_string()))?; + let base_url = provider_config.base_url.trim_end_matches('/'); + let messages_url = format!("{base_url}/v1/messages"); + let api_key = provider_config.api_key.as_str(); let messages = convert_messages_to_anthropic(&request.chat_history); @@ -341,8 +340,8 @@ impl SpacebotModel { let response = self .llm_manager .http_client() - .post("https://api.anthropic.com/v1/messages") - .header("x-api-key", &api_key) + .post(&messages_url) + .header("x-api-key", api_key) .header("anthropic-version", "2023-06-01") .header("content-type", "application/json") .json(&body) @@ -376,11 +375,9 @@ impl SpacebotModel { async fn call_openai( &self, request: CompletionRequest, + provider_config: &ProviderConfig, ) -> Result, CompletionError> { - let api_key = self - .llm_manager - .get_api_key("openai") - .map_err(|e| CompletionError::ProviderError(e.to_string()))?; + let api_key = provider_config.api_key.as_str(); let mut messages = Vec::new(); @@ -424,10 +421,15 @@ impl SpacebotModel { body["tools"] = serde_json::json!(tools); } + let chat_completions_url = format!( + "{}/v1/chat/completions", + provider_config.base_url.trim_end_matches('/') + ); + let response = self .llm_manager .http_client() - .post("https://api.openai.com/v1/chat/completions") + .post(&chat_completions_url) .header("authorization", format!("Bearer {api_key}")) .header("content-type", "application/json") .json(&body) @@ -458,6 +460,85 @@ impl SpacebotModel { parse_openai_response(response_body, "OpenAI") } + async fn call_openai_responses( + &self, + request: CompletionRequest, + provider_config: &ProviderConfig, + ) -> Result, CompletionError> { + let base_url = provider_config.base_url.trim_end_matches('/'); + let responses_url = format!("{base_url}/v1/responses"); + let api_key = provider_config.api_key.as_str(); + + let input = convert_messages_to_openai_responses(&request.chat_history); + + let mut body = serde_json::json!({ + "model": self.model_name, + "input": input, + }); + + if let Some(preamble) = &request.preamble { + body["instructions"] = serde_json::json!(preamble); + } + + if let Some(max_tokens) = request.max_tokens { + body["max_output_tokens"] = serde_json::json!(max_tokens); + } + + if let Some(temperature) = request.temperature { + body["temperature"] = serde_json::json!(temperature); + } + + if !request.tools.is_empty() { + let tools: Vec = request + .tools + .iter() + .map(|tool_definition| { + serde_json::json!({ + "type": "function", + "name": tool_definition.name, + "description": tool_definition.description, + "parameters": tool_definition.parameters, + }) + }) + .collect(); + body["tools"] = serde_json::json!(tools); + } + + let response = self + .llm_manager + .http_client() + .post(&responses_url) + .header("authorization", format!("Bearer {api_key}")) + .header("content-type", "application/json") + .json(&body) + .send() + .await + .map_err(|e| CompletionError::ProviderError(e.to_string()))?; + + let status = response.status(); + let response_text = response + .text() + .await + .map_err(|e| CompletionError::ProviderError(format!("failed to read response body: {e}")))?; + + let response_body: serde_json::Value = serde_json::from_str(&response_text) + .map_err(|e| CompletionError::ProviderError(format!( + "OpenAI Responses API response ({status}) is not valid JSON: {e}\nBody: {}", + truncate_body(&response_text) + )))?; + + if !status.is_success() { + let message = response_body["error"]["message"] + .as_str() + .unwrap_or("unknown error"); + return Err(CompletionError::ProviderError(format!( + "OpenAI Responses API error ({status}): {message}" + ))); + } + + parse_openai_responses_response(response_body) + } + async fn call_openrouter( &self, request: CompletionRequest, @@ -636,20 +717,95 @@ impl SpacebotModel { async fn call_openai_compatible( &self, request: CompletionRequest, - provider_id: &str, provider_display_name: &str, - endpoint: &str, + provider_config: &ProviderConfig, ) -> Result, CompletionError> { - let api_key = self + let base_url = provider_config.base_url.trim_end_matches('/'); + let endpoint_path = match provider_config.api_type { + ApiType::OpenAiCompletions | ApiType::OpenAiResponses => "/v1/chat/completions", + ApiType::Anthropic => { + return Err(CompletionError::ProviderError(format!( + "{provider_display_name} is configured with anthropic API type, but this call expects an OpenAI-compatible API" + ))); + } + }; + let endpoint = format!("{base_url}{endpoint_path}"); + let api_key = provider_config.api_key.as_str(); + + let mut messages = Vec::new(); + + if let Some(preamble) = &request.preamble { + messages.push(serde_json::json!({ + "role": "system", + "content": preamble, + })); + } + + messages.extend(convert_messages_to_openai(&request.chat_history)); + + let mut body = serde_json::json!({ + "model": self.model_name, + "messages": messages, + }); + + if let Some(max_tokens) = request.max_tokens { + body["max_tokens"] = serde_json::json!(max_tokens); + } + + if let Some(temperature) = request.temperature { + body["temperature"] = serde_json::json!(temperature); + } + + if !request.tools.is_empty() { + let tools: Vec = request + .tools + .iter() + .map(|t| { + serde_json::json!({ + "type": "function", + "function": { + "name": t.name, + "description": t.description, + "parameters": t.parameters, + } + }) + }) + .collect(); + body["tools"] = serde_json::json!(tools); + } + + let response = self .llm_manager - .get_api_key(provider_id) + .http_client() + .post(&endpoint) + .header("authorization", format!("Bearer {api_key}")) + .header("content-type", "application/json") + .json(&body) + .send() + .await .map_err(|e| CompletionError::ProviderError(e.to_string()))?; - self.call_openai_compatible_with_optional_auth( - request, - provider_display_name, - endpoint, - Some(api_key), - ).await + + let status = response.status(); + let response_text = response + .text() + .await + .map_err(|e| CompletionError::ProviderError(format!("failed to read response body: {e}")))?; + + let response_body: serde_json::Value = serde_json::from_str(&response_text) + .map_err(|e| CompletionError::ProviderError(format!( + "{provider_display_name} response ({status}) is not valid JSON: {e}\nBody: {}", truncate_body(&response_text) + )))?; + + if !status.is_success() { + let message = response_body["error"]["message"] + .as_str() + .unwrap_or("unknown error"); + return Err(CompletionError::ProviderError(format!( + "{provider_display_name} API error ({status}): {message}" + ))); + } + + parse_openai_response(response_body, provider_display_name) } /// Generic OpenAI-compatible API call with optional bearer auth. @@ -660,7 +816,6 @@ impl SpacebotModel { endpoint: &str, api_key: Option, ) -> Result, CompletionError> { - let mut messages = Vec::new(); if let Some(preamble) = &request.preamble { @@ -707,11 +862,13 @@ impl SpacebotModel { .llm_manager .http_client() .post(endpoint); + let response = if let Some(api_key) = api_key { response.header("authorization", format!("Bearer {api_key}")) } else { response }; + let response = response .header("content-type", "application/json") .json(&body) @@ -727,7 +884,8 @@ impl SpacebotModel { let response_body: serde_json::Value = serde_json::from_str(&response_text) .map_err(|e| CompletionError::ProviderError(format!( - "{provider_display_name} response ({status}) is not valid JSON: {e}\nBody: {}", truncate_body(&response_text) + "{provider_display_name} response ({status}) is not valid JSON: {e}\nBody: {}", + truncate_body(&response_text) )))?; if !status.is_success() { @@ -746,11 +904,15 @@ impl SpacebotModel { &self, request: CompletionRequest, ) -> Result, CompletionError> { + let provider_config = self + .llm_manager + .get_provider("groq") + .map_err(|e| CompletionError::ProviderError(e.to_string()))?; + self.call_openai_compatible( request, - "groq", "Groq", - "https://api.groq.com/openai/v1/chat/completions", + &provider_config, ).await } @@ -758,11 +920,15 @@ impl SpacebotModel { &self, request: CompletionRequest, ) -> Result, CompletionError> { + let provider_config = self + .llm_manager + .get_provider("together") + .map_err(|e| CompletionError::ProviderError(e.to_string()))?; + self.call_openai_compatible( request, - "together", "Together AI", - "https://api.together.xyz/v1/chat/completions", + &provider_config, ).await } @@ -770,11 +936,15 @@ impl SpacebotModel { &self, request: CompletionRequest, ) -> Result, CompletionError> { + let provider_config = self + .llm_manager + .get_provider("fireworks") + .map_err(|e| CompletionError::ProviderError(e.to_string()))?; + self.call_openai_compatible( request, - "fireworks", "Fireworks AI", - "https://api.fireworks.ai/inference/v1/chat/completions", + &provider_config, ).await } @@ -782,11 +952,15 @@ impl SpacebotModel { &self, request: CompletionRequest, ) -> Result, CompletionError> { + let provider_config = self + .llm_manager + .get_provider("deepseek") + .map_err(|e| CompletionError::ProviderError(e.to_string()))?; + self.call_openai_compatible( request, - "deepseek", "DeepSeek", - "https://api.deepseek.com/v1/chat/completions", + &provider_config, ).await } @@ -794,11 +968,15 @@ impl SpacebotModel { &self, request: CompletionRequest, ) -> Result, CompletionError> { + let provider_config = self + .llm_manager + .get_provider("xai") + .map_err(|e| CompletionError::ProviderError(e.to_string()))?; + self.call_openai_compatible( request, - "xai", "xAI", - "https://api.x.ai/v1/chat/completions", + &provider_config, ).await } @@ -806,11 +984,15 @@ impl SpacebotModel { &self, request: CompletionRequest, ) -> Result, CompletionError> { + let provider_config = self + .llm_manager + .get_provider("mistral") + .map_err(|e| CompletionError::ProviderError(e.to_string()))?; + self.call_openai_compatible( request, - "mistral", "Mistral AI", - "https://api.mistral.ai/v1/chat/completions", + &provider_config, ).await } @@ -834,11 +1016,15 @@ impl SpacebotModel { &self, request: CompletionRequest, ) -> Result, CompletionError> { + let provider_config = self + .llm_manager + .get_provider("opencode-zen") + .map_err(|e| CompletionError::ProviderError(e.to_string()))?; + self.call_openai_compatible( request, - "opencode-zen", "OpenCode Zen", - "https://opencode.ai/zen/v1/chat/completions", + &provider_config, ).await } @@ -846,11 +1032,15 @@ impl SpacebotModel { &self, request: CompletionRequest, ) -> Result, CompletionError> { + let provider_config = self + .llm_manager + .get_provider("nvidia") + .map_err(|e| CompletionError::ProviderError(e.to_string()))?; + self.call_openai_compatible( request, - "nvidia", "NVIDIA NIM", - "https://integrate.api.nvidia.com/v1/chat/completions", + &provider_config, ).await } } @@ -1023,6 +1213,83 @@ fn convert_messages_to_openai(messages: &OneOrMany) -> Vec) -> Vec { + let mut result = Vec::new(); + + for message in messages.iter() { + match message { + Message::User { content } => { + let mut content_parts = Vec::new(); + + for item in content.iter() { + match item { + UserContent::Text(text) => { + content_parts.push(serde_json::json!({ + "type": "input_text", + "text": text.text, + })); + } + UserContent::Image(image) => { + if let Some(part) = convert_image_openai_responses(image) { + content_parts.push(part); + } + } + UserContent::ToolResult(tool_result) => { + result.push(serde_json::json!({ + "type": "function_call_output", + "call_id": tool_result.id, + "output": tool_result_content_to_string(&tool_result.content), + })); + } + _ => {} + } + } + + if !content_parts.is_empty() { + result.push(serde_json::json!({ + "role": "user", + "content": content_parts, + })); + } + } + Message::Assistant { content, .. } => { + let mut text_parts = Vec::new(); + + for item in content.iter() { + match item { + AssistantContent::Text(text) => { + text_parts.push(serde_json::json!({ + "type": "output_text", + "text": text.text, + })); + } + AssistantContent::ToolCall(tool_call) => { + let arguments = serde_json::to_string(&tool_call.function.arguments) + .unwrap_or_else(|_| "{}".to_string()); + result.push(serde_json::json!({ + "type": "function_call", + "name": tool_call.function.name, + "arguments": arguments, + "call_id": tool_call.id, + })); + } + _ => {} + } + } + + if !text_parts.is_empty() { + result.push(serde_json::json!({ + "role": "assistant", + "content": text_parts, + })); + } + } + } + } + + result +} + // --- Image conversion helpers --- /// Convert a rig Image to an Anthropic image content block. @@ -1079,6 +1346,29 @@ fn convert_image_openai(image: &Image) -> Option { } } +fn convert_image_openai_responses(image: &Image) -> Option { + let media_type = image + .media_type + .as_ref() + .map(|mime_type| mime_type.to_mime_type()) + .unwrap_or("image/jpeg"); + + match &image.data { + DocumentSourceKind::Base64(data) => { + let data_url = format!("data:{media_type};base64,{data}"); + Some(serde_json::json!({ + "type": "input_image", + "image_url": data_url, + })) + } + DocumentSourceKind::Url(url) => Some(serde_json::json!({ + "type": "input_image", + "image_url": url, + })), + _ => None, + } +} + /// Truncate a response body for error messages to avoid dumping megabytes of HTML. fn truncate_body(body: &str) -> &str { let limit = 500; @@ -1223,3 +1513,72 @@ fn parse_openai_response( raw_response: RawResponse { body }, }) } + +fn parse_openai_responses_response( + body: serde_json::Value, +) -> Result, CompletionError> { + let output_items = body["output"] + .as_array() + .ok_or_else(|| CompletionError::ResponseError("missing output array".into()))?; + + let mut assistant_content = Vec::new(); + + for output_item in output_items { + match output_item["type"].as_str() { + Some("message") => { + if let Some(content_items) = output_item["content"].as_array() { + for content_item in content_items { + if content_item["type"].as_str() == Some("output_text") { + if let Some(text) = content_item["text"].as_str() { + if !text.is_empty() { + assistant_content.push(AssistantContent::Text(Text { + text: text.to_string(), + })); + } + } + } + } + } + } + Some("function_call") => { + let call_id = output_item["call_id"] + .as_str() + .or_else(|| output_item["id"].as_str()) + .unwrap_or("") + .to_string(); + let name = output_item["name"].as_str().unwrap_or("").to_string(); + let arguments = output_item["arguments"] + .as_str() + .and_then(|arguments| serde_json::from_str(arguments).ok()) + .unwrap_or(serde_json::json!({})); + + assistant_content.push(AssistantContent::ToolCall(make_tool_call( + call_id, + name, + arguments, + ))); + } + _ => {} + } + } + + let choice = OneOrMany::many(assistant_content) + .map_err(|_| CompletionError::ResponseError("empty response from OpenAI Responses API".into()))?; + + let input_tokens = body["usage"]["input_tokens"].as_u64().unwrap_or(0); + let output_tokens = body["usage"]["output_tokens"].as_u64().unwrap_or(0); + let cached = body["usage"]["input_tokens_details"]["cached_tokens"] + .as_u64() + .unwrap_or(0); + + Ok(completion::CompletionResponse { + choice, + usage: completion::Usage { + input_tokens, + output_tokens, + total_tokens: input_tokens + output_tokens, + cached_input_tokens: cached, + }, + raw_response: RawResponse { body }, + }) +} From 4343d8686d635fb8b491cd6854b1fc8bf47c5dd1 Mon Sep 17 00:00:00 2001 From: YuKuang Date: Thu, 19 Feb 2026 10:11:43 +0800 Subject: [PATCH 7/8] docs(config): Add commit for openai completions api base_url --- docs/content/docs/(configuration)/config.mdx | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/docs/content/docs/(configuration)/config.mdx b/docs/content/docs/(configuration)/config.mdx index 0b87bb846..39a360217 100644 --- a/docs/content/docs/(configuration)/config.mdx +++ b/docs/content/docs/(configuration)/config.mdx @@ -47,7 +47,7 @@ api_key = "env:MY_OPENAI_KEY" [llm.provider.local_openai] api_type = "openai_completions" -base_url = "http://localhost:8080/v1" +base_url = "http://localhost:8080" # do not include /v1; Spacebot appends endpoint paths api_key = "env:LOCAL_OPENAI_KEY" name = "Local OpenAI Compatible" @@ -337,11 +337,18 @@ name = "My Provider" # Optional - friendly name for display | Field | Type | Required | Description | |-------|------|----------|-------------| -| `api_type` | string | Yes | API protocol type. One of: `anthropic` (Anthropic Messages API), `openai_completions` (OpenAI Completions API), or `openai_responses` (OpenAI Chat Completions API) | +| `api_type` | string | Yes | API protocol type. One of: `anthropic` (Anthropic Messages API), `openai_completions` (OpenAI Chat Completions-compatible API), or `openai_responses` (OpenAI Responses API-compatible) | | `base_url` | string | Yes | Base URL of the API endpoint. Must be a valid URL (including protocol) | | `api_key` | string | Yes | API key for authentication. Supports `env:VAR_NAME` syntax to reference environment variables | | `name` | string | No | Optional friendly name for the provider (displayed in logs and UI) | +> Note: +> - For `openai_completions` and `openai_responses`, configure `base_url` as the provider root URL (usually without a trailing `/v1`). +> - Spacebot appends the endpoint path automatically: +> - `openai_completions` -> `/v1/chat/completions` +> - `openai_responses` -> `/v1/responses` +> - If you include `/v1` in `base_url`, requests can end up with duplicated paths such as `/v1/v1/...`. + **Provider ID Requirements:** - 1-64 characters long - Cannot contain `/` or whitespace @@ -371,7 +378,7 @@ name = "Azure OpenAI GPT-4" ```toml [llm.provider.local_llm] api_type = "openai_completions" -base_url = "http://localhost:8080/v1" +base_url = "http://localhost:8080" # no /v1 in base_url api_key = "env:LOCAL_LLM_KEY" name = "Local LLaMA Server" ``` From 921c0b726fbadbe8fd6687803c96d0674a162446 Mon Sep 17 00:00:00 2001 From: YuKuang Date: Thu, 19 Feb 2026 11:18:13 +0800 Subject: [PATCH 8/8] Add list/ directory to .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 830410c28..8c9fe9dde 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ interface/node_modules/ interface/dist/ .idea +list/