use std::path::PathBuf; use secrecy::SecretString; use crate::config::helpers::{optional_env, parse_optional_env}; use crate::error::ConfigError; use crate::settings::Settings; /// Which LLM backend to use. /// /// Defaults to `NearAi` to keep IronClaw close to the NEAR ecosystem. /// Users can override with `LLM_BACKEND` env var to use their own API keys. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum LlmBackend { /// NEAR AI proxy (default) -- session or API key auth #[default] NearAi, /// Direct OpenAI API OpenAi, /// Direct Anthropic API Anthropic, /// Local Ollama instance Ollama, /// Any OpenAI-compatible endpoint (e.g. vLLM, LiteLLM, Together) OpenAiCompatible, /// Tinfoil private inference Tinfoil, } impl std::str::FromStr for LlmBackend { type Err = String; fn from_str(s: &str) -> Result { match s.to_lowercase().as_str() { "nearai" | "near_ai" | "near" => Ok(Self::NearAi), "openai" | "open_ai" => Ok(Self::OpenAi), "anthropic" | "claude" => Ok(Self::Anthropic), "ollama" => Ok(Self::Ollama), "openai_compatible" | "openai-compatible" | "compatible" => Ok(Self::OpenAiCompatible), "tinfoil" => Ok(Self::Tinfoil), _ => Err(format!( "invalid LLM backend '{}', expected one of: nearai, openai, anthropic, ollama, openai_compatible, tinfoil", s )), } } } impl std::fmt::Display for LlmBackend { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::NearAi => write!(f, "nearai"), Self::OpenAi => write!(f, "openai"), Self::Anthropic => write!(f, "anthropic"), Self::Ollama => write!(f, "ollama"), Self::OpenAiCompatible => write!(f, "openai_compatible"), Self::Tinfoil => write!(f, "tinfoil"), } } } /// Configuration for direct OpenAI API access. #[derive(Debug, Clone)] pub struct OpenAiDirectConfig { pub api_key: SecretString, pub model: String, /// Optional base URL override (e.g. for proxies like VibeProxy). pub base_url: Option, } /// Configuration for direct Anthropic API access. #[derive(Debug, Clone)] pub struct AnthropicDirectConfig { pub api_key: SecretString, pub model: String, /// Optional base URL override (e.g. for proxies like VibeProxy). pub base_url: Option, } /// Configuration for local Ollama. #[derive(Debug, Clone)] pub struct OllamaConfig { pub base_url: String, pub model: String, } /// Configuration for any OpenAI-compatible endpoint. #[derive(Debug, Clone)] pub struct OpenAiCompatibleConfig { pub base_url: String, pub api_key: Option, pub model: String, /// Extra HTTP headers injected into every LLM request. /// Parsed from `LLM_EXTRA_HEADERS` env var (format: `Key:Value,Key2:Value2`). pub extra_headers: Vec<(String, String)>, } /// Configuration for Tinfoil private inference. #[derive(Debug, Clone)] pub struct TinfoilConfig { pub api_key: SecretString, pub model: String, } /// LLM provider configuration. /// /// NEAR AI remains the default backend. Users can switch to other providers /// by setting `LLM_BACKEND` (e.g. `openai`, `anthropic`, `ollama`). #[derive(Debug, Clone)] pub struct LlmConfig { /// Which backend to use (default: NearAi) pub backend: LlmBackend, /// NEAR AI config (always populated for NEAR AI embeddings, etc.) pub nearai: NearAiConfig, /// Direct OpenAI config (populated when backend=openai) pub openai: Option, /// Direct Anthropic config (populated when backend=anthropic) pub anthropic: Option, /// Ollama config (populated when backend=ollama) pub ollama: Option, /// OpenAI-compatible config (populated when backend=openai_compatible) pub openai_compatible: Option, /// Tinfoil config (populated when backend=tinfoil) pub tinfoil: Option, } /// NEAR AI configuration. #[derive(Debug, Clone)] pub struct NearAiConfig { /// Model to use (e.g., "claude-3-5-sonnet-20241022", "gpt-4o") pub model: String, /// Cheap/fast model for lightweight tasks (heartbeat, routing, evaluation). /// Falls back to the main model if not set. pub cheap_model: Option, /// Base URL for the NEAR AI API. /// Default: `https://private.near.ai` (session token) or `https://cloud-api.near.ai` (API key) pub base_url: String, /// Base URL for auth/refresh endpoints (default: https://private.near.ai) pub auth_base_url: String, /// Path to session file (default: ~/.ironclaw/session.json) pub session_path: PathBuf, /// API key for NEAR AI Cloud. When set, uses API key auth; otherwise uses session token auth. pub api_key: Option, /// Optional fallback model for failover (default: None). /// When set, a secondary provider is created with this model and wrapped /// in a `FailoverProvider` so transient errors on the primary model /// automatically fall through to the fallback. pub fallback_model: Option, /// Maximum number of retries for transient errors (default: 3). /// With the default of 3, the provider makes up to 4 total attempts /// (1 initial + 3 retries) before giving up. pub max_retries: u32, /// Consecutive transient failures before the circuit breaker opens. /// None = disabled (default). E.g. 5 means after 5 consecutive failures /// all requests are rejected until recovery timeout elapses. pub circuit_breaker_threshold: Option, /// How long (seconds) the circuit stays open before allowing a probe (default: 30). pub circuit_breaker_recovery_secs: u64, /// Enable in-memory response caching for `complete()` calls. /// Saves tokens on repeated prompts within a session. Default: false. pub response_cache_enabled: bool, /// TTL in seconds for cached responses (default: 3600 = 1 hour). pub response_cache_ttl_secs: u64, /// Max cached responses before LRU eviction (default: 1000). pub response_cache_max_entries: usize, /// Cooldown duration in seconds for the failover provider (default: 300). /// When a provider accumulates enough consecutive failures it is skipped /// for this many seconds. pub failover_cooldown_secs: u64, /// Number of consecutive retryable failures before a provider enters /// cooldown (default: 3). pub failover_cooldown_threshold: u32, /// Enable cascade mode for smart routing: when a moderate-complexity task /// gets an uncertain response from the cheap model, re-send to primary. /// Default: true. pub smart_routing_cascade: bool, } impl LlmConfig { pub(crate) fn resolve(settings: &Settings) -> Result { // Determine backend: env var > settings > default (NearAi) let backend: LlmBackend = if let Some(b) = optional_env("LLM_BACKEND")? { b.parse().map_err(|e| ConfigError::InvalidValue { key: "LLM_BACKEND".to_string(), message: e, })? } else if let Some(ref b) = settings.llm_backend { match b.parse() { Ok(backend) => backend, Err(e) => { tracing::warn!( "Invalid llm_backend '{}' in settings: {}. Using default NearAi.", b, e ); LlmBackend::NearAi } } } else { LlmBackend::NearAi }; // Resolve NEAR AI config only when backend is NearAi (or when explicitly configured) let nearai_api_key = optional_env("NEARAI_API_KEY")?.map(SecretString::from); let nearai = NearAiConfig { model: optional_env("NEARAI_MODEL")? .or_else(|| settings.selected_model.clone()) .unwrap_or_else(|| { "fireworks::accounts/fireworks/models/llama4-maverick-instruct-basic" .to_string() }), cheap_model: optional_env("NEARAI_CHEAP_MODEL")?, base_url: optional_env("NEARAI_BASE_URL")?.unwrap_or_else(|| { if nearai_api_key.is_some() { "https://cloud-api.near.ai".to_string() } else { "https://private.near.ai".to_string() } }), auth_base_url: optional_env("NEARAI_AUTH_URL")? .unwrap_or_else(|| "https://private.near.ai".to_string()), session_path: optional_env("NEARAI_SESSION_PATH")? .map(PathBuf::from) .unwrap_or_else(default_session_path), api_key: nearai_api_key, fallback_model: optional_env("NEARAI_FALLBACK_MODEL")?, max_retries: parse_optional_env("NEARAI_MAX_RETRIES", 3)?, circuit_breaker_threshold: optional_env("CIRCUIT_BREAKER_THRESHOLD")? .map(|s| s.parse()) .transpose() .map_err(|e| ConfigError::InvalidValue { key: "CIRCUIT_BREAKER_THRESHOLD".to_string(), message: format!("must be a positive integer: {e}"), })?, circuit_breaker_recovery_secs: parse_optional_env("CIRCUIT_BREAKER_RECOVERY_SECS", 30)?, response_cache_enabled: parse_optional_env("RESPONSE_CACHE_ENABLED", false)?, response_cache_ttl_secs: parse_optional_env("RESPONSE_CACHE_TTL_SECS", 3600)?, response_cache_max_entries: parse_optional_env("RESPONSE_CACHE_MAX_ENTRIES", 1000)?, failover_cooldown_secs: parse_optional_env("LLM_FAILOVER_COOLDOWN_SECS", 300)?, failover_cooldown_threshold: parse_optional_env("LLM_FAILOVER_THRESHOLD", 3)?, smart_routing_cascade: parse_optional_env("SMART_ROUTING_CASCADE", true)?, }; // Resolve provider-specific configs based on backend let openai = if backend == LlmBackend::OpenAi { let api_key = optional_env("OPENAI_API_KEY")? .map(SecretString::from) .ok_or_else(|| ConfigError::MissingRequired { key: "OPENAI_API_KEY".to_string(), hint: "Set OPENAI_API_KEY when LLM_BACKEND=openai".to_string(), })?; let model = optional_env("OPENAI_MODEL")?.unwrap_or_else(|| "gpt-4o".to_string()); let base_url = optional_env("OPENAI_BASE_URL")?; Some(OpenAiDirectConfig { api_key, model, base_url, }) } else { None }; let anthropic = if backend == LlmBackend::Anthropic { let api_key = optional_env("ANTHROPIC_API_KEY")? .map(SecretString::from) .ok_or_else(|| ConfigError::MissingRequired { key: "ANTHROPIC_API_KEY".to_string(), hint: "Set ANTHROPIC_API_KEY when LLM_BACKEND=anthropic".to_string(), })?; let model = optional_env("ANTHROPIC_MODEL")? .unwrap_or_else(|| "claude-sonnet-4-20250514".to_string()); let base_url = optional_env("ANTHROPIC_BASE_URL")?; Some(AnthropicDirectConfig { api_key, model, base_url, }) } else { None }; let ollama = if backend == LlmBackend::Ollama { let base_url = optional_env("OLLAMA_BASE_URL")? .or_else(|| settings.ollama_base_url.clone()) .unwrap_or_else(|| "http://localhost:11434".to_string()); let model = optional_env("OLLAMA_MODEL")?.unwrap_or_else(|| "llama3".to_string()); Some(OllamaConfig { base_url, model }) } else { None }; let openai_compatible = if backend == LlmBackend::OpenAiCompatible { let base_url = optional_env("LLM_BASE_URL")? .or_else(|| settings.openai_compatible_base_url.clone()) .ok_or_else(|| ConfigError::MissingRequired { key: "LLM_BASE_URL".to_string(), hint: "Set LLM_BASE_URL when LLM_BACKEND=openai_compatible".to_string(), })?; let api_key = optional_env("LLM_API_KEY")?.map(SecretString::from); let model = optional_env("LLM_MODEL")? .or_else(|| settings.selected_model.clone()) .unwrap_or_else(|| "default".to_string()); let extra_headers = optional_env("LLM_EXTRA_HEADERS")? .map(|val| parse_extra_headers(&val)) .transpose()? .unwrap_or_default(); Some(OpenAiCompatibleConfig { base_url, api_key, model, extra_headers, }) } else { None }; let tinfoil = if backend == LlmBackend::Tinfoil { let api_key = optional_env("TINFOIL_API_KEY")? .map(SecretString::from) .ok_or_else(|| ConfigError::MissingRequired { key: "TINFOIL_API_KEY".to_string(), hint: "Set TINFOIL_API_KEY when LLM_BACKEND=tinfoil".to_string(), })?; let model = optional_env("TINFOIL_MODEL")?.unwrap_or_else(|| "kimi-k2-5".to_string()); Some(TinfoilConfig { api_key, model }) } else { None }; Ok(Self { backend, nearai, openai, anthropic, ollama, openai_compatible, tinfoil, }) } } /// Parse `LLM_EXTRA_HEADERS` value into a list of (key, value) pairs. /// /// Format: `Key1:Value1,Key2:Value2` — colon-separated key:value, comma-separated pairs. /// Colon is used as the separator (not `=`) because header values often contain `=` /// (e.g., base64 tokens). fn parse_extra_headers(val: &str) -> Result, ConfigError> { if val.trim().is_empty() { return Ok(Vec::new()); } let mut headers = Vec::new(); for pair in val.split(',') { let pair = pair.trim(); if pair.is_empty() { continue; } let Some((key, value)) = pair.split_once(':') else { return Err(ConfigError::InvalidValue { key: "LLM_EXTRA_HEADERS".to_string(), message: format!("malformed header entry '{}', expected Key:Value", pair), }); }; let key = key.trim(); if key.is_empty() { return Err(ConfigError::InvalidValue { key: "LLM_EXTRA_HEADERS".to_string(), message: format!("empty header name in entry '{}'", pair), }); } headers.push((key.to_string(), value.trim().to_string())); } Ok(headers) } /// Get the default session file path (~/.ironclaw/session.json). fn default_session_path() -> PathBuf { dirs::home_dir() .unwrap_or_else(|| PathBuf::from(".")) .join(".ironclaw") .join("session.json") } #[cfg(test)] mod tests { use super::*; use crate::config::helpers::ENV_MUTEX; use crate::settings::Settings; /// Clear all openai-compatible-related env vars. fn clear_openai_compatible_env() { // SAFETY: Only called under ENV_MUTEX in tests. unsafe { std::env::remove_var("LLM_BACKEND"); std::env::remove_var("LLM_BASE_URL"); std::env::remove_var("LLM_MODEL"); } } #[test] fn openai_compatible_uses_selected_model_when_llm_model_unset() { let _guard = ENV_MUTEX.lock().expect("env mutex poisoned"); clear_openai_compatible_env(); let settings = Settings { llm_backend: Some("openai_compatible".to_string()), openai_compatible_base_url: Some("https://openrouter.ai/api/v1".to_string()), selected_model: Some("openai/gpt-5.1-codex".to_string()), ..Default::default() }; let cfg = LlmConfig::resolve(&settings).expect("resolve should succeed"); let compat = cfg .openai_compatible .expect("openai-compatible config should be present"); assert_eq!(compat.model, "openai/gpt-5.1-codex"); } #[test] fn openai_compatible_llm_model_env_overrides_selected_model() { let _guard = ENV_MUTEX.lock().expect("env mutex poisoned"); clear_openai_compatible_env(); // SAFETY: Under ENV_MUTEX. unsafe { std::env::set_var("LLM_MODEL", "openai/gpt-5-codex"); } let settings = Settings { llm_backend: Some("openai_compatible".to_string()), openai_compatible_base_url: Some("https://openrouter.ai/api/v1".to_string()), selected_model: Some("openai/gpt-5.1-codex".to_string()), ..Default::default() }; let cfg = LlmConfig::resolve(&settings).expect("resolve should succeed"); let compat = cfg .openai_compatible .expect("openai-compatible config should be present"); assert_eq!(compat.model, "openai/gpt-5-codex"); // SAFETY: Under ENV_MUTEX. unsafe { std::env::remove_var("LLM_MODEL"); } } #[test] fn test_extra_headers_parsed() { let result = parse_extra_headers("HTTP-Referer:https://myapp.com,X-Title:MyApp").unwrap(); assert_eq!( result, vec![ ("HTTP-Referer".to_string(), "https://myapp.com".to_string()), ("X-Title".to_string(), "MyApp".to_string()), ] ); } #[test] fn test_extra_headers_empty_string() { let result = parse_extra_headers("").unwrap(); assert!(result.is_empty()); } #[test] fn test_extra_headers_whitespace_only() { let result = parse_extra_headers(" ").unwrap(); assert!(result.is_empty()); } #[test] fn test_extra_headers_malformed() { let result = parse_extra_headers("NoColonHere"); assert!(result.is_err()); } #[test] fn test_extra_headers_empty_key() { let result = parse_extra_headers(":value"); assert!(result.is_err()); } #[test] fn test_extra_headers_value_with_colons() { // Values can contain colons (e.g., URLs) let result = parse_extra_headers("Authorization:Bearer abc:def").unwrap(); assert_eq!( result, vec![("Authorization".to_string(), "Bearer abc:def".to_string())] ); } #[test] fn test_extra_headers_trailing_comma() { let result = parse_extra_headers("X-Title:MyApp,").unwrap(); assert_eq!(result, vec![("X-Title".to_string(), "MyApp".to_string())]); } #[test] fn test_extra_headers_with_spaces() { let result = parse_extra_headers(" HTTP-Referer : https://myapp.com , X-Title : MyApp ").unwrap(); assert_eq!( result, vec![ ("HTTP-Referer".to_string(), "https://myapp.com".to_string()), ("X-Title".to_string(), "MyApp".to_string()), ] ); } }