use std::path::{Path, PathBuf}; use serde::Deserialize; use thiserror::Error; use crate::runtime::RuntimeProfile; pub use zeroclaw::config::SkillsPromptInjectionMode as SkillsPromptMode; const DEFAULT_DEEPSEEK_BASE_URL: &str = "https://api.deepseek.com"; const DEFAULT_DEEPSEEK_MODEL: &str = "deepseek-chat"; const DEFAULT_PROVIDER_ID: &str = "deepseek"; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum PlannerMode { ZeroclawPlanFirst, LegacyDeterministic, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum BrowserBackend { SuperRpa, AgentBrowser, RustNative, ComputerUse, Auto, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum OfficeBackend { OpenXml, Disabled, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct ProviderSettings { pub id: String, pub provider: String, pub api_key: String, pub base_url: Option, pub model: String, pub api_path: Option, pub wire_api: Option, pub requires_openai_auth: bool, } impl ProviderSettings { fn from_legacy_deepseek( api_key: String, base_url: String, model: String, ) -> Result { let api_key = normalize_required_value("DEEPSEEK_API_KEY", api_key)?; let base_url = normalize_base_url(base_url); let model = normalize_model(model); Ok(Self { id: DEFAULT_PROVIDER_ID.to_string(), provider: DEFAULT_PROVIDER_ID.to_string(), api_key, base_url: Some(base_url), model, api_path: None, wire_api: None, requires_openai_auth: false, }) } fn from_raw(raw: RawProviderSettings) -> Result { let id = raw.id.trim().to_string(); if id.is_empty() { return Err(ConfigError::InvalidValue( "providers[].id", "must not be empty".to_string(), )); } let api_key = normalize_required_value("providers[].apiKey", raw.api_key)?; let model = normalize_required_value("providers[].model", raw.model)?; let base_url = normalize_optional_value(raw.base_url); let provider = normalize_optional_value(raw.provider) .or_else(|| base_url.as_ref().map(|url| format!("custom:{url}"))) .ok_or_else(|| { ConfigError::InvalidValue( "providers[].provider", format!("provider {} must define provider or baseUrl", id), ) })?; Ok(Self { id, provider, api_key, base_url, model, api_path: normalize_optional_value(raw.api_path), wire_api: normalize_optional_value(raw.wire_api), requires_openai_auth: raw.requires_openai_auth, }) } } #[derive(Debug, Clone, PartialEq, Eq)] pub struct DeepSeekSettings { pub api_key: String, pub base_url: String, pub model: String, pub skills_dir: Option, } impl DeepSeekSettings { pub fn from_env() -> Result { Ok(Self::from(&SgClawSettings::from_env()?)) } pub fn load(config_path: Option<&Path>) -> Result, ConfigError> { SgClawSettings::load(config_path).map(|settings| settings.map(|settings| Self::from(&settings))) } } #[derive(Debug, Clone, PartialEq, Eq)] pub struct SgClawSettings { pub provider_api_key: String, pub provider_base_url: String, pub provider_model: String, pub skills_dir: Option, pub skills_prompt_mode: SkillsPromptMode, pub runtime_profile: RuntimeProfile, pub planner_mode: PlannerMode, pub providers: Vec, pub active_provider: String, pub browser_backend: BrowserBackend, pub office_backend: OfficeBackend, } impl SgClawSettings { pub fn from_env() -> Result { Self::maybe_from_env()?.ok_or(ConfigError::MissingEnv("DEEPSEEK_API_KEY")) } pub fn load(config_path: Option<&Path>) -> Result, ConfigError> { if let Some(path) = config_path { if path.exists() { return Self::from_config_path(path).map(Some); } } Self::maybe_from_env() } pub fn from_legacy_deepseek_fields( api_key: String, base_url: String, model: String, skills_dir: Option, ) -> Result { Self::new( api_key, base_url, model, skills_dir, None, None, None, Vec::new(), None, None, None, ) } pub fn active_provider_settings(&self) -> &ProviderSettings { self.providers .iter() .find(|provider| provider.id == self.active_provider) .expect("active_provider should always resolve to a configured provider") } fn maybe_from_env() -> Result, ConfigError> { let api_key = match std::env::var("DEEPSEEK_API_KEY") { Ok(value) => value, Err(std::env::VarError::NotPresent) => return Ok(None), Err(std::env::VarError::NotUnicode(_)) => { return Err(ConfigError::InvalidEnv("DEEPSEEK_API_KEY")) } }; let base_url = std::env::var("DEEPSEEK_BASE_URL") .unwrap_or_else(|_| DEFAULT_DEEPSEEK_BASE_URL.to_string()); let model = std::env::var("DEEPSEEK_MODEL").unwrap_or_else(|_| DEFAULT_DEEPSEEK_MODEL.to_string()); Ok(Some(Self::new( api_key, base_url, model, None, None, None, None, Vec::new(), None, None, None, )?)) } fn from_config_path(path: &Path) -> Result { let raw = std::fs::read_to_string(path) .map_err(|err| ConfigError::ConfigRead(path.to_path_buf(), err.to_string()))?; let config: RawSgClawSettings = serde_json::from_str(&raw) .map_err(|err| ConfigError::ConfigParse(path.to_path_buf(), err.to_string()))?; let config_dir = path.parent().unwrap_or_else(|| Path::new(".")); let runtime_profile = config .runtime_profile .as_deref() .map(parse_runtime_profile) .transpose() .map_err(|value| { ConfigError::ConfigParse(path.to_path_buf(), format!("invalid runtimeProfile: {value}")) })?; let skills_prompt_mode = config .skills_prompt_mode .as_deref() .map(parse_skills_prompt_mode) .transpose() .map_err(|value| { ConfigError::ConfigParse( path.to_path_buf(), format!("invalid skillsPromptMode: {value}"), ) })?; let planner_mode = config .planner_mode .as_deref() .map(parse_planner_mode) .transpose() .map_err(|value| { ConfigError::ConfigParse(path.to_path_buf(), format!("invalid plannerMode: {value}")) })?; let browser_backend = config .browser_backend .as_deref() .map(parse_browser_backend) .transpose() .map_err(|value| { ConfigError::ConfigParse(path.to_path_buf(), format!("invalid browserBackend: {value}")) })?; let office_backend = config .office_backend .as_deref() .map(parse_office_backend) .transpose() .map_err(|value| { ConfigError::ConfigParse(path.to_path_buf(), format!("invalid officeBackend: {value}")) })?; let providers = config .providers .into_iter() .map(ProviderSettings::from_raw) .collect::, _>>() .map_err(|err| err.with_path(path))?; Self::new( config.api_key, config.base_url, config.model, resolve_configured_skills_dir(config.skills_dir, config_dir), skills_prompt_mode, runtime_profile, planner_mode, providers, config.active_provider, browser_backend, office_backend, ) .map_err(|err| err.with_path(path)) } fn new( api_key: String, base_url: String, model: String, skills_dir: Option, skills_prompt_mode: Option, runtime_profile: Option, planner_mode: Option, providers: Vec, active_provider: Option, browser_backend: Option, office_backend: Option, ) -> Result { let providers = if providers.is_empty() { vec![ProviderSettings::from_legacy_deepseek(api_key, base_url, model)?] } else { providers }; let active_provider = normalize_optional_value(active_provider) .unwrap_or_else(|| providers[0].id.clone()); let active_provider_settings = providers .iter() .find(|provider| provider.id == active_provider) .ok_or_else(|| { ConfigError::InvalidValue( "activeProvider", format!("unknown provider id: {active_provider}"), ) })?; Ok(Self { provider_api_key: active_provider_settings.api_key.clone(), provider_base_url: active_provider_settings.base_url.clone().unwrap_or_default(), provider_model: active_provider_settings.model.clone(), skills_dir, skills_prompt_mode: skills_prompt_mode.unwrap_or(SkillsPromptMode::Compact), runtime_profile: runtime_profile.unwrap_or(RuntimeProfile::BrowserAttached), planner_mode: planner_mode.unwrap_or(PlannerMode::ZeroclawPlanFirst), providers, active_provider, browser_backend: browser_backend.unwrap_or(BrowserBackend::SuperRpa), office_backend: office_backend.unwrap_or(OfficeBackend::OpenXml), }) } } impl From<&SgClawSettings> for DeepSeekSettings { fn from(value: &SgClawSettings) -> Self { Self { api_key: value.provider_api_key.clone(), base_url: value.provider_base_url.clone(), model: value.provider_model.clone(), skills_dir: value.skills_dir.clone(), } } } impl From<&DeepSeekSettings> for SgClawSettings { fn from(value: &DeepSeekSettings) -> Self { Self::from_legacy_deepseek_fields( value.api_key.clone(), value.base_url.clone(), value.model.clone(), value.skills_dir.clone(), ) .expect("DeepSeekSettings should already be validated") } } fn parse_runtime_profile(raw: &str) -> Result { let normalized = raw .trim() .chars() .filter(|ch| *ch != '_' && *ch != '-') .collect::() .to_ascii_lowercase(); match normalized.as_str() { "browserattached" => Ok(RuntimeProfile::BrowserAttached), "browserheavy" => Ok(RuntimeProfile::BrowserHeavy), "generalassistant" => Ok(RuntimeProfile::GeneralAssistant), _ => Err(raw.to_string()), } } fn parse_skills_prompt_mode(raw: &str) -> Result { match raw.trim().to_ascii_lowercase().as_str() { "compact" => Ok(SkillsPromptMode::Compact), "full" => Ok(SkillsPromptMode::Full), _ => Err(raw.to_string()), } } fn parse_planner_mode(raw: &str) -> Result { let normalized = normalize_enum_token(raw); match normalized.as_str() { "zeroclawplanfirst" | "planfirst" | "plannerfirst" => Ok(PlannerMode::ZeroclawPlanFirst), "legacy" | "legacydeterministic" | "deterministic" => Ok(PlannerMode::LegacyDeterministic), _ => Err(raw.to_string()), } } fn parse_browser_backend(raw: &str) -> Result { let normalized = normalize_enum_token(raw); match normalized.as_str() { "superrpa" | "superrpapipe" | "host" | "hostpipe" => Ok(BrowserBackend::SuperRpa), "agentbrowser" => Ok(BrowserBackend::AgentBrowser), "rustnative" => Ok(BrowserBackend::RustNative), "computeruse" => Ok(BrowserBackend::ComputerUse), "auto" => Ok(BrowserBackend::Auto), _ => Err(raw.to_string()), } } fn parse_office_backend(raw: &str) -> Result { let normalized = normalize_enum_token(raw); match normalized.as_str() { "openxml" => Ok(OfficeBackend::OpenXml), "disabled" | "none" => Ok(OfficeBackend::Disabled), _ => Err(raw.to_string()), } } fn resolve_configured_skills_dir(raw: Option, config_dir: &Path) -> Option { let trimmed = raw .as_deref() .map(str::trim) .filter(|value| !value.is_empty())?; let path = PathBuf::from(trimmed); if path.is_absolute() { Some(path) } else { Some(config_dir.join(path)) } } fn normalize_required_value(field: &'static str, raw: String) -> Result { let trimmed = raw.trim().to_string(); if trimmed.is_empty() { return Err(ConfigError::EmptyValue(field)); } Ok(trimmed) } fn normalize_optional_value(raw: Option) -> Option { raw.map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()) } fn normalize_base_url(raw: String) -> String { let trimmed = raw.trim(); if trimmed.is_empty() { DEFAULT_DEEPSEEK_BASE_URL.to_string() } else { trimmed.to_string() } } fn normalize_model(raw: String) -> String { let trimmed = raw.trim(); if trimmed.is_empty() { DEFAULT_DEEPSEEK_MODEL.to_string() } else { trimmed.to_string() } } fn normalize_enum_token(raw: &str) -> String { raw.trim() .chars() .filter(|ch| *ch != '_' && *ch != '-') .collect::() .to_ascii_lowercase() } #[derive(Debug, Deserialize)] struct RawSgClawSettings { #[serde(rename = "apiKey", default)] api_key: String, #[serde(rename = "baseUrl", default)] base_url: String, #[serde(default)] model: String, #[serde(rename = "skillsDir", alias = "skills_dir", default)] skills_dir: Option, #[serde(rename = "skillsPromptMode", alias = "skills_prompt_mode", default)] skills_prompt_mode: Option, #[serde(rename = "runtimeProfile", alias = "runtime_profile", default)] runtime_profile: Option, #[serde(rename = "plannerMode", alias = "planner_mode", default)] planner_mode: Option, #[serde(rename = "activeProvider", alias = "active_provider", default)] active_provider: Option, #[serde(rename = "browserBackend", alias = "browser_backend", default)] browser_backend: Option, #[serde(rename = "officeBackend", alias = "office_backend", default)] office_backend: Option, #[serde(default)] providers: Vec, } #[derive(Debug, Deserialize)] struct RawProviderSettings { #[serde(default)] id: String, #[serde(default)] provider: Option, #[serde(rename = "apiKey", default)] api_key: String, #[serde(rename = "baseUrl", default)] base_url: Option, #[serde(default)] model: String, #[serde(rename = "apiPath", alias = "api_path", default)] api_path: Option, #[serde(rename = "wireApi", alias = "wire_api", default)] wire_api: Option, #[serde( rename = "requiresOpenaiAuth", alias = "requires_openai_auth", default )] requires_openai_auth: bool, } #[derive(Debug, Error, Clone, PartialEq, Eq)] pub enum ConfigError { #[error("missing environment variable: {0}")] MissingEnv(&'static str), #[error("environment variable must not be empty: {0}")] EmptyValue(&'static str), #[error("invalid non-utf8 environment variable: {0}")] InvalidEnv(&'static str), #[error("failed to read DeepSeek config file {0}: {1}")] ConfigRead(PathBuf, String), #[error("invalid DeepSeek config JSON in {0}: {1}")] ConfigParse(PathBuf, String), #[error("DeepSeek config value must not be empty: {0} ({1})")] ConfigValueEmpty(&'static str, PathBuf), #[error("invalid config value for {0}: {1}")] InvalidValue(&'static str, String), #[error("invalid DeepSeek config value for {0}: {2} ({1})")] ConfigInvalidValue(&'static str, PathBuf, String), } impl ConfigError { fn with_path(self, path: &Path) -> Self { match self { Self::EmptyValue(field) => Self::ConfigValueEmpty(field, path.to_path_buf()), Self::InvalidValue(field, detail) => { Self::ConfigInvalidValue(field, path.to_path_buf(), detail) } other => other, } } } impl BrowserBackend { pub fn zeroclaw_backend(self) -> Option<&'static str> { match self { Self::SuperRpa => None, Self::AgentBrowser => Some("agent_browser"), Self::RustNative => Some("rust_native"), Self::ComputerUse => Some("computer_use"), Self::Auto => Some("auto"), } } }