551 lines
18 KiB
Rust
551 lines
18 KiB
Rust
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<String>,
|
|
pub model: String,
|
|
pub api_path: Option<String>,
|
|
pub wire_api: Option<String>,
|
|
pub requires_openai_auth: bool,
|
|
}
|
|
|
|
impl ProviderSettings {
|
|
fn from_legacy_deepseek(
|
|
api_key: String,
|
|
base_url: String,
|
|
model: String,
|
|
) -> Result<Self, ConfigError> {
|
|
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<Self, ConfigError> {
|
|
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<PathBuf>,
|
|
}
|
|
|
|
impl DeepSeekSettings {
|
|
pub fn from_env() -> Result<Self, ConfigError> {
|
|
Ok(Self::from(&SgClawSettings::from_env()?))
|
|
}
|
|
|
|
pub fn load(config_path: Option<&Path>) -> Result<Option<Self>, 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<PathBuf>,
|
|
pub skills_prompt_mode: SkillsPromptMode,
|
|
pub runtime_profile: RuntimeProfile,
|
|
pub planner_mode: PlannerMode,
|
|
pub providers: Vec<ProviderSettings>,
|
|
pub active_provider: String,
|
|
pub browser_backend: BrowserBackend,
|
|
pub office_backend: OfficeBackend,
|
|
}
|
|
|
|
impl SgClawSettings {
|
|
pub fn from_env() -> Result<Self, ConfigError> {
|
|
Self::maybe_from_env()?.ok_or(ConfigError::MissingEnv("DEEPSEEK_API_KEY"))
|
|
}
|
|
|
|
pub fn load(config_path: Option<&Path>) -> Result<Option<Self>, 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<PathBuf>,
|
|
) -> Result<Self, ConfigError> {
|
|
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<Option<Self>, 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<Self, ConfigError> {
|
|
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::<Result<Vec<_>, _>>()
|
|
.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<PathBuf>,
|
|
skills_prompt_mode: Option<SkillsPromptMode>,
|
|
runtime_profile: Option<RuntimeProfile>,
|
|
planner_mode: Option<PlannerMode>,
|
|
providers: Vec<ProviderSettings>,
|
|
active_provider: Option<String>,
|
|
browser_backend: Option<BrowserBackend>,
|
|
office_backend: Option<OfficeBackend>,
|
|
) -> Result<Self, ConfigError> {
|
|
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<RuntimeProfile, String> {
|
|
let normalized = raw
|
|
.trim()
|
|
.chars()
|
|
.filter(|ch| *ch != '_' && *ch != '-')
|
|
.collect::<String>()
|
|
.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<SkillsPromptMode, String> {
|
|
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<PlannerMode, String> {
|
|
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<BrowserBackend, String> {
|
|
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<OfficeBackend, String> {
|
|
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<String>, config_dir: &Path) -> Option<PathBuf> {
|
|
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<String, ConfigError> {
|
|
let trimmed = raw.trim().to_string();
|
|
if trimmed.is_empty() {
|
|
return Err(ConfigError::EmptyValue(field));
|
|
}
|
|
Ok(trimmed)
|
|
}
|
|
|
|
fn normalize_optional_value(raw: Option<String>) -> Option<String> {
|
|
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::<String>()
|
|
.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<String>,
|
|
#[serde(rename = "skillsPromptMode", alias = "skills_prompt_mode", default)]
|
|
skills_prompt_mode: Option<String>,
|
|
#[serde(rename = "runtimeProfile", alias = "runtime_profile", default)]
|
|
runtime_profile: Option<String>,
|
|
#[serde(rename = "plannerMode", alias = "planner_mode", default)]
|
|
planner_mode: Option<String>,
|
|
#[serde(rename = "activeProvider", alias = "active_provider", default)]
|
|
active_provider: Option<String>,
|
|
#[serde(rename = "browserBackend", alias = "browser_backend", default)]
|
|
browser_backend: Option<String>,
|
|
#[serde(rename = "officeBackend", alias = "office_backend", default)]
|
|
office_backend: Option<String>,
|
|
#[serde(default)]
|
|
providers: Vec<RawProviderSettings>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct RawProviderSettings {
|
|
#[serde(default)]
|
|
id: String,
|
|
#[serde(default)]
|
|
provider: Option<String>,
|
|
#[serde(rename = "apiKey", default)]
|
|
api_key: String,
|
|
#[serde(rename = "baseUrl", default)]
|
|
base_url: Option<String>,
|
|
#[serde(default)]
|
|
model: String,
|
|
#[serde(rename = "apiPath", alias = "api_path", default)]
|
|
api_path: Option<String>,
|
|
#[serde(rename = "wireApi", alias = "wire_api", default)]
|
|
wire_api: Option<String>,
|
|
#[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"),
|
|
}
|
|
}
|
|
}
|