use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use thiserror::Error;
#[derive(Error, Debug)]
pub enum ConfigError {
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("Parse error: {0}")]
Parse(#[from] json5::Error),
#[error("Validation error: {0}")]
Validation(String),
#[error("Missing required field: {0}")]
MissingField(String),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[derive(Default)]
pub struct Config {
#[serde(default)]
pub gateway: GatewayConfig,
#[serde(default)]
pub agents: HashMap<String, AgentConfig>,
#[serde(default)]
pub channels: ChannelsConfig,
#[serde(default)]
pub providers: ProvidersConfig,
#[serde(default)]
pub settings: GlobalSettings,
}
impl Config {
pub fn load_default() -> Result<Self, ConfigError> {
let path = Self::default_path();
if path.exists() {
Self::load(&path)
} else {
Ok(Self::default())
}
}
pub fn load(path: &Path) -> Result<Self, ConfigError> {
let content = std::fs::read_to_string(path)?;
let config: Self = json5::from_str(&content)?;
config.validate()?;
Ok(config)
}
pub fn save(&self, path: &Path) -> Result<(), ConfigError> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let content = serde_json::to_string_pretty(self)
.map_err(|e| ConfigError::Validation(e.to_string()))?;
std::fs::write(path, content)?;
Ok(())
}
#[must_use]
pub fn default_path() -> PathBuf {
Self::state_dir().join("openclaw.json")
}
#[must_use]
pub fn state_dir() -> PathBuf {
if let Ok(dir) = std::env::var("OPENCLAW_STATE_DIR") {
PathBuf::from(dir)
} else if let Some(home) = dirs::home_dir() {
home.join(".openclaw")
} else {
PathBuf::from(".openclaw")
}
}
#[must_use]
pub fn credentials_dir() -> PathBuf {
Self::state_dir().join("credentials")
}
#[must_use]
pub fn sessions_dir() -> PathBuf {
Self::state_dir().join("sessions")
}
#[must_use]
pub fn agents_dir() -> PathBuf {
Self::state_dir().join("agents")
}
fn validate(&self) -> Result<(), ConfigError> {
if self.gateway.port == 0 {
return Err(ConfigError::Validation(
"Gateway port cannot be 0".to_string(),
));
}
for (id, agent) in &self.agents {
if agent.model.is_empty() {
return Err(ConfigError::Validation(format!(
"Agent '{id}' has empty model"
)));
}
}
Ok(())
}
#[must_use]
pub fn get_agent(&self, id: &str) -> AgentConfig {
self.agents
.get(id)
.cloned()
.unwrap_or_else(AgentConfig::default)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GatewayConfig {
#[serde(default = "default_port")]
pub port: u16,
#[serde(default)]
pub mode: BindMode,
#[serde(default = "default_true")]
pub cors: bool,
#[serde(default = "default_timeout")]
pub timeout_secs: u64,
}
impl Default for GatewayConfig {
fn default() -> Self {
Self {
port: default_port(),
mode: BindMode::default(),
cors: true,
timeout_secs: default_timeout(),
}
}
}
const fn default_port() -> u16 {
18789
}
const fn default_timeout() -> u64 {
300
}
const fn default_true() -> bool {
true
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum BindMode {
#[default]
Local,
Public,
Custom(String),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AgentConfig {
#[serde(default = "default_model")]
pub model: String,
#[serde(default = "default_provider")]
pub provider: String,
#[serde(default)]
pub system_prompt: Option<String>,
#[serde(default = "default_max_tokens")]
pub max_tokens: u32,
#[serde(default = "default_temperature")]
pub temperature: f32,
#[serde(default)]
pub tools: Vec<String>,
#[serde(default)]
pub allowlist: Vec<AllowlistEntry>,
}
impl Default for AgentConfig {
fn default() -> Self {
Self {
model: default_model(),
provider: default_provider(),
system_prompt: None,
max_tokens: default_max_tokens(),
temperature: default_temperature(),
tools: vec![],
allowlist: vec![],
}
}
}
fn default_model() -> String {
"claude-3-5-sonnet-20241022".to_string()
}
fn default_provider() -> String {
"anthropic".to_string()
}
const fn default_max_tokens() -> u32 {
4096
}
const fn default_temperature() -> f32 {
0.7
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AllowlistEntry {
pub channel: String,
pub peer_id: String,
#[serde(default)]
pub label: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ChannelsConfig {
#[serde(default)]
pub telegram: Option<TelegramConfig>,
#[serde(default)]
pub discord: Option<DiscordConfig>,
#[serde(default)]
pub slack: Option<SlackConfig>,
#[serde(default)]
pub signal: Option<SignalConfig>,
#[serde(default)]
pub matrix: Option<MatrixConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TelegramConfig {
pub bot_token: Option<String>,
#[serde(default)]
pub webhook: bool,
#[serde(default)]
pub webhook_url: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DiscordConfig {
pub bot_token: Option<String>,
pub application_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SlackConfig {
pub bot_token: Option<String>,
pub app_token: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SignalConfig {
pub phone_number: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MatrixConfig {
pub homeserver: Option<String>,
pub user_id: Option<String>,
pub access_token: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ProvidersConfig {
#[serde(default)]
pub anthropic: Option<AnthropicConfig>,
#[serde(default)]
pub openai: Option<OpenAIConfig>,
#[serde(default)]
pub ollama: Option<OllamaConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AnthropicConfig {
pub api_key: Option<String>,
#[serde(default)]
pub base_url: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OpenAIConfig {
pub api_key: Option<String>,
#[serde(default)]
pub base_url: Option<String>,
#[serde(default)]
pub org_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OllamaConfig {
#[serde(default = "default_ollama_url")]
pub base_url: String,
}
fn default_ollama_url() -> String {
"http://localhost:11434".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[derive(Default)]
pub struct GlobalSettings {
#[serde(default)]
pub debug: bool,
#[serde(default)]
pub log_format: LogFormat,
#[serde(default)]
pub telemetry: bool,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum LogFormat {
#[default]
Pretty,
Json,
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn test_default_config() {
let config = Config::default();
assert_eq!(config.gateway.port, 18789);
}
#[test]
fn test_config_roundtrip() {
let temp = tempdir().unwrap();
let path = temp.path().join("config.json");
let mut config = Config::default();
config.agents.insert(
"test".to_string(),
AgentConfig {
model: "gpt-4".to_string(),
..Default::default()
},
);
config.save(&path).unwrap();
let loaded = Config::load(&path).unwrap();
assert_eq!(loaded.agents.get("test").unwrap().model, "gpt-4");
}
#[test]
fn test_json5_parsing() {
let json5_content = r#"{
// This is a comment
gateway: {
port: 8080,
},
agents: {
default: {
model: "claude-3-5-sonnet-20241022",
// trailing comma
},
},
}"#;
let config: Config = json5::from_str(json5_content).unwrap();
assert_eq!(config.gateway.port, 8080);
}
#[test]
fn test_config_validation() {
let mut config = Config::default();
config.gateway.port = 0;
let result = config.validate();
assert!(result.is_err());
}
#[test]
fn test_state_dir() {
let dir = Config::state_dir();
assert!(dir.to_str().unwrap().contains("openclaw"));
}
}