//! Kernel configuration //! //! Design principles: //! - Model ID is passed directly to the API without any transformation //! - No provider prefix or alias mapping //! - Simple, unified configuration structure use std::path::PathBuf; use std::sync::Arc; use serde::{Deserialize, Serialize}; use secrecy::SecretString; use zclaw_types::Result; use zclaw_runtime::{LlmDriver, AnthropicDriver, OpenAiDriver}; /// API protocol type #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum ApiProtocol { OpenAI, Anthropic, } impl Default for ApiProtocol { fn default() -> Self { Self::OpenAI } } /// LLM configuration - unified config for all providers /// /// This is the single source of truth for LLM configuration. /// Model ID is passed directly to the API without any transformation. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LlmConfig { /// API base URL (e.g., "https://api.openai.com/v1") pub base_url: String, /// API key #[serde(skip_serializing)] pub api_key: String, /// Model identifier - passed directly to the API /// Examples: "gpt-4o", "glm-4-flash", "glm-4-plus", "claude-3-opus-20240229" pub model: String, /// API protocol (OpenAI-compatible or Anthropic) #[serde(default)] pub api_protocol: ApiProtocol, /// Maximum tokens per response #[serde(default = "default_max_tokens")] pub max_tokens: u32, /// Temperature #[serde(default = "default_temperature")] pub temperature: f32, /// Context window size in tokens (default: 128000) /// Used to calculate dynamic compaction threshold. #[serde(default = "default_context_window")] pub context_window: u32, } impl LlmConfig { /// Create a new LLM config pub fn new(base_url: impl Into, api_key: impl Into, model: impl Into) -> Self { Self { base_url: base_url.into(), api_key: api_key.into(), model: model.into(), api_protocol: ApiProtocol::OpenAI, max_tokens: default_max_tokens(), temperature: default_temperature(), context_window: default_context_window(), } } /// Set API protocol pub fn with_protocol(mut self, protocol: ApiProtocol) -> Self { self.api_protocol = protocol; self } /// Set max tokens pub fn with_max_tokens(mut self, max_tokens: u32) -> Self { self.max_tokens = max_tokens; self } /// Set temperature pub fn with_temperature(mut self, temperature: f32) -> Self { self.temperature = temperature; self } /// Create driver from this config pub fn create_driver(&self) -> Result> { match self.api_protocol { ApiProtocol::Anthropic => { if self.base_url.is_empty() { Ok(Arc::new(AnthropicDriver::new(SecretString::new(self.api_key.clone())))) } else { Ok(Arc::new(AnthropicDriver::with_base_url( SecretString::new(self.api_key.clone()), self.base_url.clone(), ))) } } ApiProtocol::OpenAI => { Ok(Arc::new(OpenAiDriver::with_base_url( SecretString::new(self.api_key.clone()), self.base_url.clone(), ))) } } } } /// Kernel configuration #[derive(Debug, Clone, Serialize, Deserialize)] pub struct KernelConfig { /// Database URL (SQLite) #[serde(default = "default_database_url")] pub database_url: String, /// LLM configuration #[serde(flatten)] pub llm: LlmConfig, /// Skills directory path (optional, defaults to ./skills) #[serde(default)] pub skills_dir: Option, } fn default_database_url() -> String { let home = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from(".")); let dir = home.join(".zclaw"); format!("sqlite:{}/data.db?mode=rwc", dir.display()) } fn default_max_tokens() -> u32 { 4096 } fn default_temperature() -> f32 { 0.7 } fn default_context_window() -> u32 { 128000 } impl Default for KernelConfig { fn default() -> Self { Self { database_url: default_database_url(), llm: LlmConfig { base_url: "https://api.openai.com/v1".to_string(), api_key: String::new(), model: "gpt-4o-mini".to_string(), api_protocol: ApiProtocol::OpenAI, max_tokens: default_max_tokens(), temperature: default_temperature(), context_window: default_context_window(), }, skills_dir: default_skills_dir(), } } } /// Default skills directory /// /// Discovery order: /// 1. ZCLAW_SKILLS_DIR environment variable (if set) /// 2. Compile-time known workspace path (CARGO_WORKSPACE_DIR or relative from manifest dir) /// 3. Current working directory/skills (for development) /// 4. Executable directory and multiple levels up (for packaged apps) fn default_skills_dir() -> Option { // 1. Check environment variable override if let Ok(dir) = std::env::var("ZCLAW_SKILLS_DIR") { let path = std::path::PathBuf::from(&dir); tracing::debug!(target: "kernel_config", "ZCLAW_SKILLS_DIR env: {} (exists: {})", path.display(), path.exists()); if path.exists() { return Some(path); } // Even if it doesn't exist, respect the env var return Some(path); } // 2. Try compile-time known paths (works for cargo build/test) // CARGO_MANIFEST_DIR is the crate directory (crates/zclaw-kernel) // We need to go up to find the workspace root let manifest_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")); tracing::debug!(target: "kernel_config", "CARGO_MANIFEST_DIR: {}", manifest_dir.display()); // Go up from crates/zclaw-kernel to workspace root if let Some(workspace_root) = manifest_dir.parent().and_then(|p| p.parent()) { let workspace_skills = workspace_root.join("skills"); tracing::debug!(target: "kernel_config", "Workspace skills: {} (exists: {})", workspace_skills.display(), workspace_skills.exists()); if workspace_skills.exists() { return Some(workspace_skills); } } // 3. Try current working directory first (for development) if let Ok(cwd) = std::env::current_dir() { let cwd_skills = cwd.join("skills"); tracing::debug!(target: "kernel_config", "Checking cwd: {} (exists: {})", cwd_skills.display(), cwd_skills.exists()); if cwd_skills.exists() { return Some(cwd_skills); } // Also try going up from cwd (might be in desktop/src-tauri) let mut current = cwd.as_path(); for i in 0..6 { if let Some(parent) = current.parent() { let parent_skills = parent.join("skills"); tracing::debug!(target: "kernel_config", "CWD Level {}: {} (exists: {})", i, parent_skills.display(), parent_skills.exists()); if parent_skills.exists() { return Some(parent_skills); } current = parent; } else { break; } } } // 4. Try executable's directory and multiple levels up if let Ok(exe) = std::env::current_exe() { tracing::debug!(target: "kernel_config", "Current exe: {}", exe.display()); if let Some(exe_dir) = exe.parent().map(|p| p.to_path_buf()) { // Same directory as exe let exe_skills = exe_dir.join("skills"); tracing::debug!(target: "kernel_config", "Checking exe dir: {} (exists: {})", exe_skills.display(), exe_skills.exists()); if exe_skills.exists() { return Some(exe_skills); } // Go up multiple levels to handle Tauri dev builds let mut current = exe_dir.as_path(); for i in 0..6 { if let Some(parent) = current.parent() { let parent_skills = parent.join("skills"); tracing::debug!(target: "kernel_config", "EXE Level {}: {} (exists: {})", i, parent_skills.display(), parent_skills.exists()); if parent_skills.exists() { return Some(parent_skills); } current = parent; } else { break; } } } } // 5. Fallback to current working directory/skills (may not exist) let fallback = std::env::current_dir() .ok() .map(|cwd| cwd.join("skills")); tracing::debug!(target: "kernel_config", "Fallback to: {:?}", fallback); fallback } impl KernelConfig { /// Load configuration from file. /// /// Search order: /// 1. Path from `ZCLAW_CONFIG` environment variable /// 2. `~/.zclaw/config.toml` /// 3. Fallback to `Self::default()` /// /// Supports `${VAR_NAME}` environment variable interpolation in string values. pub async fn load() -> Result { let config_path = Self::find_config_path(); match config_path { Some(path) => { if !path.exists() { tracing::debug!(target: "kernel_config", "Config file not found: {:?}, using defaults", path); return Ok(Self::default()); } tracing::info!(target: "kernel_config", "Loading config from: {:?}", path); let content = std::fs::read_to_string(&path).map_err(|e| { zclaw_types::ZclawError::Internal(format!("Failed to read config {}: {}", path.display(), e)) })?; let interpolated = interpolate_env_vars(&content); let mut config: KernelConfig = toml::from_str(&interpolated).map_err(|e| { zclaw_types::ZclawError::Internal(format!("Failed to parse config {}: {}", path.display(), e)) })?; // Resolve skills_dir if not explicitly set if config.skills_dir.is_none() { config.skills_dir = default_skills_dir(); } tracing::info!( target: "kernel_config", model = %config.llm.model, base_url = %config.llm.base_url, has_api_key = !config.llm.api_key.is_empty(), "Config loaded successfully" ); Ok(config) } None => Ok(Self::default()), } } /// Find the config file path. pub fn find_config_path() -> Option { // 1. Environment variable override if let Ok(path) = std::env::var("ZCLAW_CONFIG") { return Some(PathBuf::from(path)); } // 2. ~/.zclaw/config.toml if let Some(home) = dirs::home_dir() { let path = home.join(".zclaw").join("config.toml"); if path.exists() { return Some(path); } } // 3. Project root config/config.toml (for development) let project_config = std::env::current_dir() .ok() .map(|cwd| cwd.join("config").join("config.toml"))?; if project_config.exists() { return Some(project_config); } None } /// Create the LLM driver pub fn create_driver(&self) -> Result> { self.llm.create_driver() } /// Get the model ID (passed directly to API) pub fn model(&self) -> &str { &self.llm.model } /// Get max tokens pub fn max_tokens(&self) -> u32 { self.llm.max_tokens } /// Get temperature pub fn temperature(&self) -> f32 { self.llm.temperature } /// Get context window size in tokens pub fn context_window(&self) -> u32 { self.llm.context_window } /// Dynamic compaction threshold = context_window * 0.6 /// Leaves 40% headroom for system prompt + response tokens pub fn compaction_threshold(&self) -> usize { (self.llm.context_window as f64 * 0.6) as usize } } // === Preset configurations for common providers === impl LlmConfig { /// OpenAI GPT-4 pub fn openai(api_key: impl Into) -> Self { Self::new("https://api.openai.com/v1", api_key, "gpt-4o") } /// Anthropic Claude pub fn anthropic(api_key: impl Into) -> Self { Self::new("https://api.anthropic.com", api_key, "claude-sonnet-4-20250514") .with_protocol(ApiProtocol::Anthropic) } /// 智谱 GLM pub fn zhipu(api_key: impl Into, model: impl Into) -> Self { Self::new("https://open.bigmodel.cn/api/paas/v4", api_key, model) } /// 智谱 GLM Coding Plan pub fn zhipu_coding(api_key: impl Into, model: impl Into) -> Self { Self::new("https://open.bigmodel.cn/api/coding/paas/v4", api_key, model) } /// Kimi (Moonshot) pub fn kimi(api_key: impl Into, model: impl Into) -> Self { Self::new("https://api.moonshot.cn/v1", api_key, model) } /// Kimi Coding Plan pub fn kimi_coding(api_key: impl Into, model: impl Into) -> Self { Self::new("https://api.kimi.com/coding/v1", api_key, model) } /// 阿里云百炼 (Qwen) pub fn qwen(api_key: impl Into, model: impl Into) -> Self { Self::new("https://dashscope.aliyuncs.com/compatible-mode/v1", api_key, model) } /// 阿里云百炼 Coding Plan pub fn qwen_coding(api_key: impl Into, model: impl Into) -> Self { Self::new("https://coding.dashscope.aliyuncs.com/v1", api_key, model) } /// DeepSeek pub fn deepseek(api_key: impl Into, model: impl Into) -> Self { Self::new("https://api.deepseek.com/v1", api_key, model) } /// Ollama / Local pub fn local(base_url: impl Into, model: impl Into) -> Self { Self::new(base_url, "", model) } } // === Backward compatibility === /// Provider type for backward compatibility #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Provider { OpenAI, Anthropic, Gemini, Zhipu, Kimi, Qwen, DeepSeek, Local, Custom, } impl KernelConfig { /// Create config from provider type (for backward compatibility with Tauri commands) pub fn from_provider( provider: &str, api_key: &str, model: &str, base_url: Option<&str>, api_protocol: &str, ) -> Self { let llm = match provider { "anthropic" => LlmConfig::anthropic(api_key).with_model(model), "openai" => { if let Some(url) = base_url.filter(|u| !u.is_empty()) { LlmConfig::new(url, api_key, model) } else { LlmConfig::openai(api_key).with_model(model) } } "gemini" => LlmConfig::new( base_url.unwrap_or("https://generativelanguage.googleapis.com/v1beta"), api_key, model, ), "zhipu" => { let url = base_url.unwrap_or("https://open.bigmodel.cn/api/paas/v4"); LlmConfig::zhipu(api_key, model).with_base_url(url) } "zhipu-coding" => { let url = base_url.unwrap_or("https://open.bigmodel.cn/api/coding/paas/v4"); LlmConfig::zhipu_coding(api_key, model).with_base_url(url) } "kimi" => { let url = base_url.unwrap_or("https://api.moonshot.cn/v1"); LlmConfig::kimi(api_key, model).with_base_url(url) } "kimi-coding" => { let url = base_url.unwrap_or("https://api.kimi.com/coding/v1"); LlmConfig::kimi_coding(api_key, model).with_base_url(url) } "qwen" => { let url = base_url.unwrap_or("https://dashscope.aliyuncs.com/compatible-mode/v1"); LlmConfig::qwen(api_key, model).with_base_url(url) } "qwen-coding" => { let url = base_url.unwrap_or("https://coding.dashscope.aliyuncs.com/v1"); LlmConfig::qwen_coding(api_key, model).with_base_url(url) } "deepseek" => LlmConfig::deepseek(api_key, model), "local" | "ollama" => { let url = base_url.unwrap_or("http://localhost:11434/v1"); LlmConfig::local(url, model) } _ => { // Custom provider let protocol = if api_protocol == "anthropic" { ApiProtocol::Anthropic } else { ApiProtocol::OpenAI }; LlmConfig::new( base_url.unwrap_or("https://api.openai.com/v1"), api_key, model, ) .with_protocol(protocol) } }; Self { database_url: default_database_url(), llm, skills_dir: default_skills_dir(), } } } impl LlmConfig { /// Set model pub fn with_model(mut self, model: impl Into) -> Self { self.model = model.into(); self } /// Set base URL pub fn with_base_url(mut self, base_url: impl Into) -> Self { self.base_url = base_url.into(); self } } // === Environment variable interpolation === /// Replace `${VAR_NAME}` patterns in a string with environment variable values. /// If the variable is not set, the pattern is left as-is. fn interpolate_env_vars(content: &str) -> String { let mut result = String::with_capacity(content.len()); let mut chars = content.char_indices().peekable(); while let Some((_, ch)) = chars.next() { if ch == '$' && chars.peek().map(|(_, c)| *c == '{').unwrap_or(false) { chars.next(); // consume '{' let mut var_name = String::new(); while let Some((_, c)) = chars.peek() { match c { '}' => { chars.next(); // consume '}' if let Ok(value) = std::env::var(&var_name) { result.push_str(&value); } else { result.push_str("${"); result.push_str(&var_name); result.push('}'); } break; } _ => { var_name.push(*c); chars.next(); } } } // Handle unclosed ${... at end of string if !content[result.len()..].contains('}') && var_name.is_empty() { // Already consumed, nothing to do } } else { result.push(ch); } } result } #[cfg(test)] mod tests { use super::*; #[test] fn test_interpolate_env_vars_basic() { std::env::set_var("ZCLAW_TEST_VAR", "hello"); let result = interpolate_env_vars("prefix ${ZCLAW_TEST_VAR} suffix"); assert_eq!(result, "prefix hello suffix"); } #[test] fn test_interpolate_env_vars_missing() { let result = interpolate_env_vars("${ZCLAW_NONEXISTENT_VAR_12345}"); assert_eq!(result, "${ZCLAW_NONEXISTENT_VAR_12345}"); } #[test] fn test_interpolate_env_vars_no_vars() { let result = interpolate_env_vars("no variables here"); assert_eq!(result, "no variables here"); } #[test] fn test_interpolate_env_vars_multiple() { std::env::set_var("ZCLAW_TEST_A", "alpha"); std::env::set_var("ZCLAW_TEST_B", "beta"); let result = interpolate_env_vars("${ZCLAW_TEST_A}-${ZCLAW_TEST_B}"); assert_eq!(result, "alpha-beta"); } }