use super::crabrace::CrabraceConfig;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
static CONFIG_RECOVERED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
static CONFIG_AUTOFIXED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
static CONFIG_TYPO_WARNINGS: std::sync::Mutex<Vec<String>> = std::sync::Mutex::new(Vec::new());
pub static CONFIG_FILE_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config {
#[serde(default)]
pub crabrace: CrabraceConfig,
#[serde(default)]
pub database: DatabaseConfig,
#[serde(default)]
pub logging: LoggingConfig,
#[serde(default)]
pub debug: DebugConfig,
#[serde(default)]
pub providers: ProviderConfigs,
#[serde(default)]
pub channels: ChannelsConfig,
#[serde(default)]
pub agent: AgentConfig,
#[serde(default)]
pub daemon: DaemonConfig,
#[serde(default, alias = "gateway")]
pub a2a: A2aConfig,
#[serde(default)]
pub image: ImageConfig,
#[serde(default)]
pub cron: CronConfig,
#[serde(default)]
pub memory: MemoryConfig,
#[serde(default)]
pub brain: BrainConfig,
#[serde(default)]
pub browser: BrowserConfig,
}
fn deser_caps_compat<'de, D>(d: D) -> std::result::Result<BTreeMap<String, usize>, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::Deserialize as _;
let value: toml::Value = toml::Value::deserialize(d)?;
let mut result = BTreeMap::new();
if let Some(table) = value.as_table() {
flatten_caps_table(table, String::new(), &mut result);
}
Ok(result)
}
fn flatten_caps_table(
table: &toml::map::Map<String, toml::Value>,
prefix: String,
out: &mut BTreeMap<String, usize>,
) {
for (key, value) in table {
let full_key = if prefix.is_empty() {
key.clone()
} else {
format!("{}.{}", prefix, key)
};
if let Some(n) = value.as_integer() {
out.insert(full_key, n as usize);
} else if let Some(sub_table) = value.as_table() {
flatten_caps_table(sub_table, full_key, out);
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BrainConfig {
#[serde(default = "default_strip_empty_sections")]
pub strip_empty_sections: bool,
#[serde(default, deserialize_with = "deser_caps_compat")]
pub caps: std::collections::BTreeMap<String, usize>,
#[serde(default = "default_brain_file_cap")]
pub default_cap: usize,
}
fn default_true() -> bool {
true
}
fn default_strip_empty_sections() -> bool {
true
}
fn default_brain_file_cap() -> usize {
500
}
impl Default for BrainConfig {
fn default() -> Self {
Self {
strip_empty_sections: default_strip_empty_sections(),
caps: std::collections::BTreeMap::new(),
default_cap: default_brain_file_cap(),
}
}
}
impl BrainConfig {
pub fn cap_for(&self, filename: &str) -> usize {
self.caps.get(filename).copied().unwrap_or(self.default_cap)
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct BrowserConfig {
#[serde(default)]
pub cdp_endpoint: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct DaemonConfig {
#[serde(default)]
pub health_port: Option<u16>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct A2aConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default = "default_a2a_bind")]
pub bind: String,
#[serde(default = "default_a2a_port")]
pub port: u16,
#[serde(default)]
pub allowed_origins: Vec<String>,
#[serde(default)]
pub api_key: Option<String>,
}
fn default_a2a_bind() -> String {
"127.0.0.1".to_string()
}
fn default_a2a_port() -> u16 {
18790
}
impl Default for A2aConfig {
fn default() -> Self {
Self {
enabled: false,
bind: default_a2a_bind(),
port: default_a2a_port(),
allowed_origins: vec![],
api_key: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ChannelsConfig {
#[serde(default)]
pub telegram: TelegramConfig,
#[serde(default)]
pub discord: DiscordConfig,
#[serde(default)]
pub whatsapp: WhatsAppConfig,
#[serde(default)]
pub slack: SlackConfig,
#[serde(default)]
pub trello: TrelloConfig,
#[serde(default)]
pub signal: SignalConfig,
#[serde(default)]
pub google_chat: GoogleChatConfig,
#[serde(default)]
pub imessage: IMessageConfig,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum RespondTo {
All,
DmOnly,
#[default]
Mention,
}
fn deser_users_compat<'de, D>(d: D) -> Result<Vec<String>, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::Deserialize;
#[derive(Deserialize)]
#[serde(untagged)]
enum NumOrStr {
Int(i64),
Str(String),
}
Vec::<NumOrStr>::deserialize(d).map(|v| {
v.into_iter()
.map(|x| match x {
NumOrStr::Int(n) => n.to_string(),
NumOrStr::Str(s) => s,
})
.collect()
})
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct TelegramConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub token: Option<String>,
#[serde(default, deserialize_with = "deser_users_compat")]
pub allowed_users: Vec<String>,
#[serde(default)]
pub allowed_channels: Vec<String>,
#[serde(default)]
pub respond_to: RespondTo,
#[serde(default)]
pub session_idle_hours: Option<f64>,
#[serde(default)]
pub rich_messages: bool,
#[serde(default = "default_true")]
pub silence_group_start: bool,
#[serde(default, deserialize_with = "deser_users_compat")]
pub bot_owner: Vec<String>,
}
impl TelegramConfig {
pub fn is_owner(&self, user_id: &str) -> bool {
if self.allowed_users.is_empty() {
return true;
}
if !self.bot_owner.is_empty() {
self.bot_owner.contains(&user_id.to_string())
} else {
self.allowed_users.first().is_some_and(|o| o == user_id)
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct DiscordConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub token: Option<String>,
#[serde(default, deserialize_with = "deser_users_compat")]
pub allowed_users: Vec<String>,
#[serde(default)]
pub allowed_channels: Vec<String>,
#[serde(default)]
pub respond_to: RespondTo,
#[serde(default)]
pub session_idle_hours: Option<f64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct SlackConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub token: Option<String>,
#[serde(default)]
pub app_token: Option<String>,
#[serde(default, deserialize_with = "deser_users_compat")]
pub allowed_users: Vec<String>,
#[serde(default)]
pub allowed_channels: Vec<String>,
#[serde(default)]
pub respond_to: RespondTo,
#[serde(default)]
pub session_idle_hours: Option<f64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct WhatsAppConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub allowed_phones: Vec<String>,
#[serde(default)]
pub session_idle_hours: Option<f64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct TrelloConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub token: Option<String>,
#[serde(default)]
pub app_token: Option<String>,
#[serde(default, deserialize_with = "deser_users_compat")]
pub allowed_users: Vec<String>,
#[serde(default, alias = "allowed_channels")]
pub board_ids: Vec<String>,
#[serde(default)]
pub poll_interval_secs: Option<u64>,
#[serde(default)]
pub session_idle_hours: Option<f64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct SignalConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub allowed_phones: Vec<String>,
#[serde(default)]
pub session_idle_hours: Option<f64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct GoogleChatConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub token: Option<String>,
#[serde(default, deserialize_with = "deser_users_compat")]
pub allowed_users: Vec<String>,
#[serde(default)]
pub session_idle_hours: Option<f64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct IMessageConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub allowed_phones: Vec<String>,
#[serde(default)]
pub session_idle_hours: Option<f64>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum SttMode {
#[default]
Api,
Local,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum TtsMode {
#[default]
Api,
Local,
}
#[derive(Debug, Clone)]
pub struct VoiceConfig {
pub stt_enabled: bool,
pub stt_mode: SttMode,
pub local_stt_model: String,
pub stt_base_url: Option<String>,
pub stt_model: Option<String>,
pub stt_api_key: Option<String>,
pub tts_enabled: bool,
pub tts_mode: TtsMode,
pub tts_voice: String,
pub tts_model: String,
pub tts_base_url: Option<String>,
pub tts_api_key: Option<String>,
pub local_tts_voice: String,
pub stt_provider: Option<ProviderConfig>,
pub tts_provider: Option<ProviderConfig>,
pub voicebox_stt_enabled: bool,
pub voicebox_stt_base_url: String,
pub voicebox_tts_enabled: bool,
pub voicebox_tts_base_url: String,
pub voicebox_tts_profile_id: String,
pub voicebox_tts_engine: String,
pub stt_fallback_chain: Vec<String>,
pub tts_fallback_chain: Vec<String>,
}
fn default_local_stt_model() -> String {
"local-tiny".to_string()
}
fn default_tts_voice() -> String {
"echo".to_string()
}
fn default_tts_model() -> String {
"gpt-4o-mini-tts".to_string()
}
fn default_local_tts_voice() -> String {
"ryan".to_string()
}
impl Default for VoiceConfig {
fn default() -> Self {
Self {
stt_enabled: false,
stt_mode: SttMode::default(),
local_stt_model: default_local_stt_model(),
stt_base_url: None,
stt_model: None,
stt_api_key: None,
tts_enabled: false,
tts_mode: TtsMode::default(),
tts_voice: default_tts_voice(),
tts_model: default_tts_model(),
tts_base_url: None,
tts_api_key: None,
local_tts_voice: default_local_tts_voice(),
stt_provider: None,
tts_provider: None,
voicebox_stt_enabled: false,
voicebox_stt_base_url: default_voicebox_url(),
voicebox_tts_enabled: false,
voicebox_tts_base_url: default_voicebox_url(),
voicebox_tts_profile_id: String::new(),
voicebox_tts_engine: String::new(),
stt_fallback_chain: Vec::new(),
tts_fallback_chain: Vec::new(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ImageConfig {
#[serde(default)]
pub generation: ImageGenerationConfig,
#[serde(default)]
pub vision: ImageVisionConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ImageGenerationConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default = "default_image_model")]
pub model: String,
#[serde(skip, default)]
pub api_key: Option<String>,
}
impl Default for ImageGenerationConfig {
fn default() -> Self {
Self {
enabled: false,
model: default_image_model(),
api_key: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ImageVisionConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default = "default_image_model")]
pub model: String,
#[serde(skip, default)]
pub api_key: Option<String>,
}
impl Default for ImageVisionConfig {
fn default() -> Self {
Self {
enabled: false,
model: default_image_model(),
api_key: None,
}
}
}
pub fn default_image_model() -> String {
"gemini-3.1-flash-image-preview".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentConfig {
#[serde(default = "default_approval_policy")]
pub approval_policy: String,
#[serde(default = "default_max_concurrent")]
pub max_concurrent: u32,
#[serde(default = "default_context_limit")]
pub context_limit: u32,
#[serde(default = "default_max_tokens")]
pub max_tokens: u32,
#[serde(default)]
pub subagent_provider: Option<String>,
#[serde(default)]
pub subagent_model: Option<String>,
#[serde(default = "default_auto_update")]
pub auto_update: bool,
#[serde(default)]
pub self_improvement_provider: Option<String>,
#[serde(default)]
pub self_improvement_model: Option<String>,
#[serde(default)]
pub silent_compaction: bool,
#[serde(default = "default_lazy_tools")]
pub lazy_tools: bool,
#[serde(default = "default_redact_sensitive_data")]
pub redact_sensitive_data: bool,
}
fn default_lazy_tools() -> bool {
true
}
fn default_redact_sensitive_data() -> bool {
true
}
fn default_approval_policy() -> String {
"auto-always".to_string()
}
fn default_max_concurrent() -> u32 {
4
}
fn default_context_limit() -> u32 {
200_000
}
fn default_max_tokens() -> u32 {
65536
}
fn default_auto_update() -> bool {
true
}
impl Default for AgentConfig {
fn default() -> Self {
Self {
approval_policy: default_approval_policy(),
max_concurrent: default_max_concurrent(),
context_limit: default_context_limit(),
max_tokens: default_max_tokens(),
subagent_provider: None,
subagent_model: None,
auto_update: default_auto_update(),
self_improvement_provider: None,
self_improvement_model: None,
silent_compaction: false,
lazy_tools: default_lazy_tools(),
redact_sensitive_data: default_redact_sensitive_data(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct CronConfig {
#[serde(default)]
pub default_provider: Option<String>,
#[serde(default)]
pub default_model: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct EmbeddingConfig {
#[serde(default)]
pub url: Option<String>,
#[serde(default)]
pub model: Option<String>,
#[serde(default)]
pub api_key: Option<String>,
#[serde(default)]
pub dimensions: Option<usize>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MemoryConfig {
#[serde(default = "default_vector_enabled")]
pub vector_enabled: bool,
#[serde(default)]
pub embedding: Option<EmbeddingConfig>,
}
const fn default_vector_enabled() -> bool {
true
}
impl Default for MemoryConfig {
fn default() -> Self {
Self {
vector_enabled: default_vector_enabled(),
embedding: None,
}
}
}
impl MemoryConfig {
fn is_vps() -> bool {
#[cfg(target_os = "linux")]
{
if let Ok(product) = std::fs::read_to_string("/sys/class/dmi/id/product_name") {
let product = product.to_lowercase();
let cloud_vendors = [
"droplet",
"digitalocean",
"ec2",
"amazon",
"gce",
"google compute",
"kvm",
"vultr",
"linode",
"akamai",
"azure",
"hyper-v",
"oracle",
"oci",
];
for vendor in &cloud_vendors {
if product.contains(vendor) {
return true;
}
}
}
if let Ok(cgroup) = std::fs::read_to_string("/proc/1/cgroup")
&& (cgroup.contains("docker")
|| cgroup.contains("containerd")
|| cgroup.contains("kubepods"))
{
return true;
}
if let Ok(meminfo) = std::fs::read_to_string("/proc/meminfo") {
for line in meminfo.lines() {
if line.starts_with("MemTotal:") {
if let Some(kb_str) = line.split_whitespace().nth(1)
&& let Ok(kb) = kb_str.parse::<u64>()
&& {
let gb = kb / 1_048_576; gb < 2
}
{
return true;
}
break;
}
}
}
let has_display =
std::env::var("DISPLAY").is_ok() || std::env::var("WAYLAND_DISPLAY").is_ok();
if !has_display {
return true;
}
}
#[cfg(not(target_os = "linux"))]
{
}
false
}
pub fn auto_apply_vps_defaults() -> bool {
if !Self::is_vps() {
return false;
}
let config_path = opencrabs_home().join("config.toml");
if let Ok(content) = std::fs::read_to_string(&config_path) {
if content.contains("[memory]") {
return false;
}
}
tracing::info!(
"VPS/cloud detected — disabling vector embeddings for memory search (FTS-only mode)"
);
let append = "\n# Auto-configured: VPS/cloud detected\n\
# Local vector embeddings disabled to save RAM (~2.9GB).\n\
# FTS5 keyword search still works. WIP: OpenAI-compatible\n\
# embedding through API coming soon.\n\
[memory]\n\
vector_enabled = false\n";
let _ = std::fs::OpenOptions::new()
.append(true)
.open(&config_path)
.and_then(|mut f| std::io::Write::write_all(&mut f, append.as_bytes()));
true
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct DebugConfig {
#[serde(default)]
pub debug_lsp: bool,
#[serde(default)]
pub profiling: bool,
}
pub fn xiaomi_provider_defaults() -> ProviderConfig {
ProviderConfig {
enabled: true,
default_model: Some("mimo-v2.5-pro".to_string()),
models: [
"mimo-v2.5-pro",
"mimo-v2-pro",
"mimo-v2.5",
"mimo-v2-omni",
"mimo-v2-flash",
]
.iter()
.map(|s| s.to_string())
.collect(),
vision_model: Some("mimo-v2.5-pro".to_string()),
context_window: Some(200_000),
..Default::default()
}
}
fn default_xiaomi_provider() -> Option<ProviderConfig> {
Some(xiaomi_provider_defaults())
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ProviderConfigs {
#[serde(default)]
pub anthropic: Option<ProviderConfig>,
#[serde(default)]
pub openai: Option<ProviderConfig>,
#[serde(default)]
pub openrouter: Option<ProviderConfig>,
#[serde(default)]
pub minimax: Option<ProviderConfig>,
#[serde(default)]
pub zhipu: Option<ProviderConfig>,
#[serde(default = "default_xiaomi_provider")]
pub xiaomi: Option<ProviderConfig>,
#[serde(default, deserialize_with = "deserialize_custom_providers")]
pub custom: Option<BTreeMap<String, ProviderConfig>>,
#[serde(default)]
pub github: Option<ProviderConfig>,
#[serde(default)]
pub gemini: Option<ProviderConfig>,
#[serde(default)]
pub claude_cli: Option<ProviderConfig>,
#[serde(default)]
pub opencode_cli: Option<ProviderConfig>,
#[serde(default)]
pub codex_cli: Option<ProviderConfig>,
#[serde(default)]
pub codex: Option<ProviderConfig>,
#[serde(default)]
pub opencode: Option<ProviderConfig>,
#[serde(default)]
pub qwen: Option<ProviderConfig>,
#[serde(default)]
pub ollama: Option<ProviderConfig>,
#[serde(default)]
pub bedrock: Option<ProviderConfig>,
#[serde(default)]
pub vertex: Option<ProviderConfig>,
#[serde(default)]
pub stt: Option<SttProviders>,
#[serde(default)]
pub tts: Option<TtsProviders>,
#[serde(default)]
pub web_search: Option<WebSearchProviders>,
#[serde(default)]
pub image: Option<ImageProviders>,
#[serde(default)]
pub fallback: Option<FallbackProviderConfig>,
}
impl ProviderConfigs {
pub fn active_custom(&self) -> Option<(&str, &ProviderConfig)> {
self.custom
.as_ref()?
.iter()
.find(|(_, cfg)| cfg.enabled)
.map(|(name, cfg)| (name.as_str(), cfg))
}
pub fn custom_by_name(&self, name: &str) -> Option<&ProviderConfig> {
let normalized = normalize_toml_key(name);
self.custom.as_ref()?.get(&normalized)
}
fn provider_registry(
&self,
) -> [(&'static str, &'static str, bool, Option<&ProviderConfig>); 17] {
[
("xiaomi", "Xiaomi", false, self.xiaomi.as_ref()),
("claude-cli", "Claude CLI", false, self.claude_cli.as_ref()),
(
"opencode-cli",
"OpenCode CLI",
false,
self.opencode_cli.as_ref(),
),
("codex-cli", "Codex CLI", false, self.codex_cli.as_ref()),
("codex", "Codex OAuth", false, self.codex.as_ref()),
("opencode", "OpenCode", false, self.opencode.as_ref()),
("qwen", "Qwen", true, self.qwen.as_ref()),
("minimax", "Minimax", true, self.minimax.as_ref()),
("zhipu", "z.ai GLM", true, self.zhipu.as_ref()),
("openrouter", "OpenRouter", true, self.openrouter.as_ref()),
("anthropic", "Anthropic", true, self.anthropic.as_ref()),
("openai", "OpenAI", true, self.openai.as_ref()),
("github", "GitHub Copilot", true, self.github.as_ref()),
("gemini", "Google Gemini", true, self.gemini.as_ref()),
("ollama", "Ollama", false, self.ollama.as_ref()),
("bedrock", "AWS Bedrock", true, self.bedrock.as_ref()),
("vertex", "Google Vertex", true, self.vertex.as_ref()),
]
}
pub fn active_provider_and_model(&self) -> (String, String) {
for (id, _display, requires_api_key, cfg) in self.provider_registry() {
if let Some(c) = cfg
&& c.enabled
&& (!requires_api_key || c.api_key.is_some())
{
let model = c
.default_model
.clone()
.unwrap_or_else(|| "(default)".to_string());
return (id.to_string(), model);
}
}
if let Some((name, cfg)) = self.active_custom() {
let model = cfg
.default_model
.clone()
.unwrap_or_else(|| "(default)".to_string());
return (format!("custom:{}", name), model);
}
("none".to_string(), "none".to_string())
}
}
fn deserialize_custom_providers<'de, D>(
deserializer: D,
) -> std::result::Result<Option<BTreeMap<String, ProviderConfig>>, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::de;
let value: Option<toml::Value> = Option::deserialize(deserializer)?;
let Some(value) = value else {
return Ok(None);
};
let table = match value.as_table() {
Some(t) => t,
None => return Ok(None),
};
let flat_keys = ["enabled", "api_key", "base_url", "default_model", "models"];
let has_flat = flat_keys.iter().any(|k| table.contains_key(*k));
let has_named = table.values().any(|v| v.is_table());
if has_flat && has_named {
let mut map = BTreeMap::new();
let mut flat_table = toml::map::Map::new();
for key in &flat_keys {
if let Some(v) = table.get(*key) {
flat_table.insert(key.to_string(), v.clone());
}
}
let default_cfg: ProviderConfig = toml::Value::Table(flat_table)
.try_into()
.map_err(de::Error::custom)?;
map.insert("default".to_string(), default_cfg);
for (name, val) in table {
if flat_keys.contains(&name.as_str()) {
continue;
}
if val.is_table() {
let cfg: ProviderConfig = val.clone().try_into().map_err(de::Error::custom)?;
map.insert(normalize_toml_key(name), cfg);
}
}
Ok(Some(map))
} else if has_flat {
let config: ProviderConfig = toml::Value::Table(table.clone())
.try_into()
.map_err(de::Error::custom)?;
let mut map = BTreeMap::new();
map.insert("default".to_string(), config);
Ok(Some(map))
} else {
let raw: BTreeMap<String, ProviderConfig> = toml::Value::Table(table.clone())
.try_into()
.map_err(de::Error::custom)?;
let map: BTreeMap<String, ProviderConfig> = raw
.into_iter()
.map(|(k, v)| (normalize_toml_key(&k), v))
.collect();
Ok(if map.is_empty() { None } else { Some(map) })
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct FallbackProviderConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub providers: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct SttProviders {
#[serde(default)]
pub groq: Option<ProviderConfig>,
#[serde(default)]
pub local: Option<LocalSttConfig>,
#[serde(default)]
pub openai_compatible: Option<OpenaiCompatibleSttConfig>,
#[serde(default)]
pub voicebox: Option<VoiceboxSttConfig>,
#[serde(default)]
pub fallback_chain: Option<Vec<String>>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct OpenaiCompatibleSttConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub base_url: Option<String>,
#[serde(default)]
pub model: Option<String>,
#[serde(default)]
pub api_key: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VoiceboxSttConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default = "default_voicebox_url")]
pub base_url: String,
}
impl Default for VoiceboxSttConfig {
fn default() -> Self {
Self {
enabled: false,
base_url: default_voicebox_url(),
}
}
}
fn default_voicebox_url() -> String {
"http://localhost:8000".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LocalSttConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default = "default_local_stt_model")]
pub model: String,
}
impl Default for LocalSttConfig {
fn default() -> Self {
Self {
enabled: false,
model: default_local_stt_model(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct TtsProviders {
#[serde(default)]
pub openai: Option<ProviderConfig>,
#[serde(default)]
pub local: Option<LocalTtsConfig>,
#[serde(default)]
pub openai_compatible: Option<OpenaiCompatibleTtsConfig>,
#[serde(default)]
pub voicebox: Option<VoiceboxTtsConfig>,
#[serde(default)]
pub fallback_chain: Option<Vec<String>>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct OpenaiCompatibleTtsConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub base_url: Option<String>,
#[serde(default)]
pub model: Option<String>,
#[serde(default)]
pub voice: Option<String>,
#[serde(default)]
pub api_key: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VoiceboxTtsConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default = "default_voicebox_url")]
pub base_url: String,
#[serde(default)]
pub profile_id: String,
#[serde(default)]
pub engine: String,
}
impl Default for VoiceboxTtsConfig {
fn default() -> Self {
Self {
enabled: false,
base_url: default_voicebox_url(),
profile_id: String::new(),
engine: String::new(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LocalTtsConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default = "default_local_tts_voice")]
pub voice: String,
}
impl Default for LocalTtsConfig {
fn default() -> Self {
Self {
enabled: false,
voice: default_local_tts_voice(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct WebSearchProviders {
#[serde(default)]
pub exa: Option<ProviderConfig>,
#[serde(default)]
pub brave: Option<ProviderConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ImageProviders {
#[serde(default)]
pub gemini: Option<ProviderConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ProviderConfig {
#[serde(default = "default_enabled")]
pub enabled: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub api_key: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub base_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub default_model: Option<String>,
#[serde(default)]
pub models: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub vision_model: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub generation_model: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub context_window: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub endpoint_type: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub voice: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub enable_thinking: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cache_enabled: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cache_ttl: Option<u32>,
}
fn default_enabled() -> bool {
true
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DatabaseConfig {
#[serde(default = "default_db_path")]
pub path: PathBuf,
}
impl Default for DatabaseConfig {
fn default() -> Self {
Self {
path: default_db_path(),
}
}
}
fn default_db_path() -> PathBuf {
opencrabs_home().join("opencrabs.db")
}
fn expand_tilde(p: &Path) -> PathBuf {
if let Ok(rest) = p.strip_prefix("~") {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(rest)
} else {
p.to_path_buf()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LoggingConfig {
#[serde(default = "default_log_level")]
pub level: String,
#[serde(default)]
pub file: Option<PathBuf>,
}
impl Default for LoggingConfig {
fn default() -> Self {
Self {
level: default_log_level(),
file: None,
}
}
}
fn default_log_level() -> String {
"info".to_string()
}
impl Default for Config {
fn default() -> Self {
Self {
crabrace: CrabraceConfig::default(),
database: DatabaseConfig {
path: default_db_path(),
},
logging: LoggingConfig {
level: default_log_level(),
file: None,
},
debug: DebugConfig::default(),
providers: ProviderConfigs::default(),
channels: ChannelsConfig::default(),
agent: AgentConfig::default(),
daemon: DaemonConfig::default(),
a2a: A2aConfig::default(),
image: ImageConfig::default(),
cron: CronConfig::default(),
memory: MemoryConfig::default(),
brain: BrainConfig::default(),
browser: BrowserConfig::default(),
}
}
}
mod io;
pub use io::*;
pub(crate) use io::{load_keys_from_file, merge_channel_keys};
mod loader;
pub use loader::*;