use anyhow::{Context, Result};
use sapphire_workspace::SyncConfig;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Config {
#[serde(default)]
pub matrix: Option<MatrixConfig>,
#[serde(default)]
pub discord: Option<DiscordConfig>,
pub anthropic: AnthropicConfig,
#[serde(default)]
pub compression: CompressionConfig,
#[serde(default)]
pub tools: ToolsConfig,
#[serde(default)]
pub serve: Option<ServeConfig>,
#[serde(default)]
pub a2a: Option<A2aConfig>,
#[serde(default)]
pub image_cache: ImageCacheConfig,
pub workspace_dir: Option<String>,
pub sessions_dir: Option<String>,
#[serde(default)]
pub day_boundary_hour: u8,
#[serde(default)]
pub session_policy: SessionPolicy,
#[serde(default)]
pub providers: HashMap<String, ProviderConfig>,
#[serde(default)]
pub profiles: HashMap<String, ProfileConfig>,
#[serde(default, rename = "room_profile")]
pub room_profiles: HashMap<String, RoomProfileConfig>,
#[serde(default, rename = "memory_namespace")]
pub memory_namespaces: HashMap<String, MemoryNamespaceConfig>,
#[serde(default = "default_true")]
pub daily_log_enabled: bool,
#[serde(default = "default_true")]
pub memory_compaction_enabled: bool,
#[serde(default = "default_true")]
pub heartbeat_enabled: bool,
#[serde(default)]
pub standby_mode: bool,
#[serde(default)]
pub sync: Option<SyncConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sync_interval_minutes: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub intraday_idle_minutes: Option<u32>,
#[serde(default)]
pub digest: DigestConfig,
#[serde(default, rename = "voice_pipeline")]
pub voice_pipelines: HashMap<String, VoicePipelineConfig>,
#[serde(default, rename = "stt_provider")]
pub stt_providers: HashMap<String, SttProviderConfig>,
#[serde(default, rename = "tts_provider")]
pub tts_providers: HashMap<String, TtsProviderConfig>,
#[serde(default)]
pub voice: VoiceConfig,
#[serde(default)]
pub timer: TimerConfig,
}
fn default_true() -> bool {
true
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum SessionPolicy {
#[default]
Reset,
Compact,
None,
}
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
pub struct RoomProfileConfig {
pub profile: String,
#[serde(default)]
pub memory_namespace: Option<String>,
#[serde(default)]
pub session_policy: Option<SessionPolicy>,
#[serde(default)]
pub rooms: Vec<String>,
#[serde(default)]
pub voice_pipeline: Option<String>,
#[serde(default)]
pub api_keys: Vec<String>,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct A2aConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub public_url: Option<String>,
#[serde(default)]
pub agent_name: Option<String>,
#[serde(default)]
pub agent_description: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ImageCacheConfig {
#[serde(default = "default_image_cache_enabled")]
pub enabled: bool,
#[serde(default)]
pub dir: Option<PathBuf>,
}
impl Default for ImageCacheConfig {
fn default() -> Self {
Self {
enabled: true,
dir: None,
}
}
}
fn default_image_cache_enabled() -> bool {
true
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct VoiceConfig {
#[serde(default)]
pub wake_word_model: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(tag = "type")]
pub enum ProviderConfig {
#[serde(rename = "openai_compatible")]
OpenAiCompatible(crate::provider::openai_compatible::OpenAICompatibleConfig),
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ProfileConfig {
pub provider: String,
#[serde(default)]
pub fallback_provider: Option<String>,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct MemoryNamespaceConfig {
#[serde(default)]
pub include: Vec<String>,
#[serde(default)]
pub background_profile: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct VoicePipelineConfig {
pub stt_provider: String,
pub tts_provider: String,
#[serde(default)]
pub language: Option<String>,
#[serde(default = "default_capture_max_ms")]
pub capture_max_ms: u32,
}
fn default_capture_max_ms() -> u32 {
30_000
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(tag = "type")]
pub enum SttProviderConfig {
#[serde(rename = "sherpa_onnx")]
SherpaOnnx(SherpaSttConfig),
#[serde(rename = "openai_whisper_api")]
OpenAiWhisperApi {
api_key_env: String,
#[serde(default)]
base_url: Option<String>,
#[serde(default)]
model: Option<String>,
},
#[serde(rename = "mock")]
Mock {
#[serde(default = "default_mock_transcript")]
transcript: String,
},
}
fn default_mock_transcript() -> String {
"test transcript".to_string()
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct SherpaSttConfig {
pub kind: SherpaSttKind,
#[serde(default)]
pub model: Option<String>,
#[serde(default)]
pub model_dir: Option<String>,
#[serde(default)]
pub language: Option<String>,
#[serde(default = "default_sherpa_num_threads")]
pub num_threads: i32,
#[serde(default = "default_sherpa_provider")]
pub provider: String,
}
fn default_sherpa_num_threads() -> i32 {
2
}
fn default_sherpa_provider() -> String {
"cpu".to_string()
}
#[derive(Debug, Clone, Copy, Deserialize, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum SherpaSttKind {
SenseVoice,
Whisper,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(tag = "type")]
pub enum TtsProviderConfig {
#[serde(rename = "openai_tts")]
OpenAiTts {
#[serde(default)]
api_key_env: Option<String>,
#[serde(default)]
base_url: Option<String>,
#[serde(default)]
model: Option<String>,
#[serde(default)]
voice: Option<String>,
},
#[serde(rename = "mock")]
Mock {
#[serde(default = "default_mock_duration_ms")]
duration_ms: u32,
#[serde(default = "default_mock_freq_hz")]
frequency_hz: u32,
},
#[serde(rename = "sherpa_onnx")]
SherpaOnnx(SherpaTtsConfig),
}
fn default_mock_duration_ms() -> u32 {
200
}
fn default_mock_freq_hz() -> u32 {
440
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct SherpaTtsConfig {
pub kind: SherpaTtsKind,
#[serde(default)]
pub model: Option<String>,
#[serde(default)]
pub model_dir: Option<String>,
#[serde(default)]
pub speaker_id: i32,
#[serde(default = "default_tts_speed")]
pub speed: f32,
#[serde(default = "default_sherpa_num_threads")]
pub num_threads: i32,
#[serde(default = "default_sherpa_provider")]
pub provider: String,
}
fn default_tts_speed() -> f32 {
1.0
}
#[derive(Debug, Clone, Copy, Deserialize, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum SherpaTtsKind {
Vits,
Matcha,
Kokoro,
}
pub const ANTHROPIC_PROVIDER_NAME: &str = "anthropic";
pub const DEFAULT_PROFILE_NAME: &str = "default";
pub const BACKGROUND_PROFILE_NAME: &str = "background";
pub const DEFAULT_NAMESPACE_NAME: &str = "default";
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
pub struct ServeConfig {
#[serde(default = "default_serve_host")]
pub host: String,
#[serde(default = "default_serve_port")]
pub port: u16,
}
fn default_serve_host() -> String {
"127.0.0.1".to_string()
}
fn default_serve_port() -> u16 {
9000
}
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
pub struct ToolsConfig {
pub tavily_api_key: Option<String>,
#[serde(default)]
pub mcp_servers: Vec<McpServerConfig>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct McpServerConfig {
pub name: String,
#[serde(flatten)]
pub transport: McpTransportConfig,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(tag = "type")]
pub enum McpTransportConfig {
#[serde(rename = "http")]
Http {
url: String,
#[serde(default)]
api_key: Option<String>,
},
#[serde(rename = "stdio")]
Stdio {
command: String,
#[serde(default)]
args: Vec<String>,
#[serde(default)]
env: std::collections::HashMap<String, String>,
},
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct MatrixConfig {
pub homeserver: String,
pub access_token: String,
pub user_id: String,
pub device_id: String,
#[serde(default, alias = "room_id", deserialize_with = "deserialize_room_ids")]
pub room_ids: Vec<String>,
#[serde(default)]
pub allowed_users: Vec<String>,
pub recovery_key: Option<String>,
pub state_dir: Option<String>,
}
fn deserialize_room_ids<'de, D>(deserializer: D) -> std::result::Result<Vec<String>, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum OneOrMany {
One(String),
Many(Vec<String>),
}
match OneOrMany::deserialize(deserializer)? {
OneOrMany::One(s) => Ok(vec![s]),
OneOrMany::Many(v) => Ok(v),
}
}
impl MatrixConfig {
pub fn primary_room_id(&self) -> Option<&str> {
self.room_ids.first().map(|s| s.as_str())
}
pub fn resolved_state_dir(&self) -> PathBuf {
if let Some(dir) = &self.state_dir {
PathBuf::from(shellexpand::tilde(dir).as_ref())
} else if let Some(dirs) = directories::ProjectDirs::from("", "", "sapphire-agent") {
dirs.data_local_dir().join("matrix")
} else {
PathBuf::from(".sapphire-agent/matrix")
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct DiscordConfig {
pub bot_token: String,
#[serde(default)]
pub channel_ids: Vec<String>,
#[serde(default)]
pub allowed_users: Vec<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct AnthropicConfig {
#[serde(default)]
pub api_key: Option<String>,
#[serde(default = "default_model")]
pub model: String,
pub light_model: Option<String>,
#[serde(default = "default_max_tokens")]
pub max_tokens: u32,
pub system_prompt: Option<String>,
}
pub const ANTHROPIC_API_KEY_ENV: &str = "ANTHROPIC_API_KEY";
impl AnthropicConfig {
pub fn resolve_api_key(&self) -> Result<String> {
let from_config = self
.api_key
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty());
if let Some(key) = from_config {
return Ok(key.to_string());
}
match std::env::var(ANTHROPIC_API_KEY_ENV) {
Ok(v) if !v.trim().is_empty() => Ok(v),
_ => Err(anyhow::anyhow!(
"no Anthropic API key found: set [anthropic].api_key in config or \
the {ANTHROPIC_API_KEY_ENV} environment variable"
)),
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct CompressionConfig {
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default = "default_context_window")]
pub context_window: usize,
#[serde(default = "default_compression_threshold")]
pub threshold: f64,
#[serde(default = "default_preserve_recent")]
pub preserve_recent: usize,
}
impl Default for CompressionConfig {
fn default() -> Self {
Self {
enabled: true,
context_window: default_context_window(),
threshold: default_compression_threshold(),
preserve_recent: default_preserve_recent(),
}
}
}
fn default_model() -> String {
"claude-opus-4-6".to_string()
}
fn default_max_tokens() -> u32 {
8192
}
fn default_context_window() -> usize {
200_000
}
fn default_compression_threshold() -> f64 {
0.80
}
fn default_preserve_recent() -> usize {
20
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct TimerConfig {
#[serde(default, rename = "preset")]
pub presets: Vec<TimerPreset>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct TimerPreset {
pub name: String,
#[serde(default = "default_timer_cycles")]
pub cycles: u32,
pub steps: Vec<TimerStep>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct TimerStep {
pub label: String,
pub minutes: f64,
}
fn default_timer_cycles() -> u32 {
1
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct DigestConfig {
#[serde(default = "default_digest_daily_items")]
pub daily_items: usize,
#[serde(default = "default_digest_weekly_items")]
pub weekly_items: usize,
#[serde(default = "default_digest_monthly_items")]
pub monthly_items: usize,
#[serde(default = "default_digest_yearly_items")]
pub yearly_items: usize,
#[serde(default = "default_true")]
pub weekly_enabled: bool,
#[serde(default = "default_true")]
pub monthly_enabled: bool,
#[serde(default = "default_true")]
pub yearly_enabled: bool,
}
impl Default for DigestConfig {
fn default() -> Self {
Self {
daily_items: default_digest_daily_items(),
weekly_items: default_digest_weekly_items(),
monthly_items: default_digest_monthly_items(),
yearly_items: default_digest_yearly_items(),
weekly_enabled: true,
monthly_enabled: true,
yearly_enabled: true,
}
}
}
fn default_digest_daily_items() -> usize {
3
}
fn default_digest_weekly_items() -> usize {
3
}
fn default_digest_monthly_items() -> usize {
5
}
fn default_digest_yearly_items() -> usize {
5
}
impl Config {
pub fn load(path: &Path) -> Result<Self> {
let content = std::fs::read_to_string(path)
.with_context(|| format!("Failed to read config file: {}", path.display()))?;
let config: Config =
toml::from_str(&content).with_context(|| "Failed to parse config file")?;
Ok(config)
}
pub fn resolved_workspace_dir(&self, config_path: &Path) -> PathBuf {
if let Some(dir) = &self.workspace_dir {
PathBuf::from(shellexpand::tilde(dir).as_ref())
} else {
config_path
.parent()
.unwrap_or_else(|| Path::new("."))
.to_path_buf()
}
}
pub fn resolved_sessions_dir(&self, workspace_dir: &Path) -> PathBuf {
if let Some(dir) = &self.sessions_dir {
PathBuf::from(shellexpand::tilde(dir).as_ref())
} else {
workspace_dir.join("sessions")
}
}
pub fn room_profile_for(&self, room_id: &str) -> Option<(&str, &RoomProfileConfig)> {
for (name, rp) in &self.room_profiles {
if rp.rooms.iter().any(|r| r == room_id) {
return Some((name.as_str(), rp));
}
}
self.room_profiles
.get_key_value(DEFAULT_PROFILE_NAME)
.map(|(k, v)| (k.as_str(), v))
}
pub fn room_profile(&self, name: &str) -> Option<&RoomProfileConfig> {
self.room_profiles.get(name)
}
pub fn resolve_a2a_token(&self, token: &str) -> Option<&str> {
if token.is_empty() {
return None;
}
for (name, rp) in &self.room_profiles {
if rp.api_keys.iter().any(|k| k == token) {
return Some(name.as_str());
}
}
None
}
pub fn session_policy_for(&self, room_id: &str) -> SessionPolicy {
self.room_profile_for(room_id)
.and_then(|(_, rp)| rp.session_policy)
.unwrap_or(self.session_policy)
}
pub fn intraday_idle_threshold_minutes(&self) -> Option<u32> {
match self.intraday_idle_minutes {
Some(0) => None,
Some(n) => Some(n),
None => Some(30),
}
}
pub fn profile_for(&self, room_id: &str) -> Option<&str> {
if let Some((_, rp)) = self.room_profile_for(room_id) {
return Some(rp.profile.as_str());
}
if self.profiles.contains_key(DEFAULT_PROFILE_NAME) {
return Some(DEFAULT_PROFILE_NAME);
}
None
}
#[allow(dead_code)]
pub fn provider_for_profile(&self, profile_name: &str) -> Option<&str> {
self.profiles.get(profile_name).map(|p| p.provider.as_str())
}
pub fn validate_profiles(&self) -> Vec<String> {
let mut errors = Vec::new();
let known_provider = |name: &str| -> bool {
name == ANTHROPIC_PROVIDER_NAME || self.providers.contains_key(name)
};
for (pname, prof) in &self.profiles {
if !known_provider(&prof.provider) {
errors.push(format!(
"profile '{pname}' references unknown provider '{}'",
prof.provider
));
}
if let Some(fb) = &prof.fallback_provider
&& !known_provider(fb)
{
errors.push(format!(
"profile '{pname}' references unknown fallback_provider '{fb}'"
));
}
}
let mut seen_rooms: HashMap<String, String> = HashMap::new();
let mut seen_api_keys: HashMap<String, String> = HashMap::new();
for (rp_name, rp) in &self.room_profiles {
if !self.profiles.contains_key(&rp.profile) {
errors.push(format!(
"room_profile '{rp_name}' references unknown profile '{}'",
rp.profile
));
}
if let Some(ns) = &rp.memory_namespace
&& !self.namespace_is_defined(ns)
{
errors.push(format!(
"room_profile '{rp_name}' references unknown memory_namespace '{ns}'"
));
}
for room in &rp.rooms {
if let Some(prev) = seen_rooms.get(room) {
errors.push(format!(
"room '{room}' appears in multiple room_profiles: '{prev}' and '{rp_name}'"
));
} else {
seen_rooms.insert(room.clone(), rp_name.clone());
}
}
for key in &rp.api_keys {
if key.is_empty() {
errors.push(format!(
"room_profile '{rp_name}' has an empty api_keys entry"
));
continue;
}
if let Some(prev) = seen_api_keys.get(key) {
errors.push(format!(
"api_keys token reused across room_profiles '{prev}' and '{rp_name}'"
));
} else {
seen_api_keys.insert(key.clone(), rp_name.clone());
}
}
}
for (ns_name, ns_cfg) in &self.memory_namespaces {
for parent in &ns_cfg.include {
if !self.namespace_is_defined(parent) {
errors.push(format!(
"memory_namespace '{ns_name}' includes unknown namespace '{parent}'"
));
}
}
if let Some(prof) = &ns_cfg.background_profile
&& !self.profiles.contains_key(prof)
{
errors.push(format!(
"memory_namespace '{ns_name}' references unknown background_profile '{prof}'"
));
}
}
for ns_name in self.memory_namespaces.keys() {
if let Some(cycle) = self.namespace_cycle_starting_at(ns_name) {
errors.push(format!(
"memory_namespace cycle detected: {}",
cycle.join(" -> ")
));
}
}
for (rp_name, rp) in &self.room_profiles {
if let Some(vp) = &rp.voice_pipeline
&& !self.voice_pipelines.contains_key(vp)
{
errors.push(format!(
"room_profile '{rp_name}' references unknown voice_pipeline '{vp}'"
));
}
}
if let Some(path) = &self.voice.wake_word_model {
let expanded = shellexpand::tilde(path);
if !std::path::Path::new(expanded.as_ref()).is_file() {
errors.push(format!(
"voice.wake_word_model = '{path}' is not an existing file"
));
}
}
for (vp_name, vp) in &self.voice_pipelines {
if !self.stt_providers.contains_key(&vp.stt_provider) {
errors.push(format!(
"voice_pipeline '{vp_name}' references unknown stt_provider '{}'",
vp.stt_provider
));
}
if !self.tts_providers.contains_key(&vp.tts_provider) {
errors.push(format!(
"voice_pipeline '{vp_name}' references unknown tts_provider '{}'",
vp.tts_provider
));
}
}
errors
}
fn namespace_is_defined(&self, name: &str) -> bool {
name == DEFAULT_NAMESPACE_NAME || self.memory_namespaces.contains_key(name)
}
fn namespace_cycle_starting_at(&self, start: &str) -> Option<Vec<String>> {
let mut stack: Vec<String> = vec![start.to_string()];
let mut on_stack = std::collections::HashSet::new();
on_stack.insert(start.to_string());
fn dfs(
cfg: &Config,
node: &str,
stack: &mut Vec<String>,
on_stack: &mut std::collections::HashSet<String>,
) -> Option<Vec<String>> {
let parents: Vec<String> = cfg
.memory_namespaces
.get(node)
.map(|c| c.include.clone())
.unwrap_or_default();
for parent in parents {
if on_stack.contains(&parent) {
let mut cycle: Vec<String> = stack.to_vec();
cycle.push(parent);
return Some(cycle);
}
stack.push(parent.clone());
on_stack.insert(parent.clone());
if let Some(c) = dfs(cfg, &parent, stack, on_stack) {
return Some(c);
}
stack.pop();
on_stack.remove(&parent);
}
None
}
dfs(self, start, &mut stack, &mut on_stack)
}
pub fn resolve_namespace_chain(&self, name: &str) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
self.namespace_chain_walk(name, &mut out, &mut seen);
out
}
fn namespace_chain_walk(
&self,
name: &str,
out: &mut Vec<String>,
seen: &mut std::collections::HashSet<String>,
) {
if !seen.insert(name.to_string()) {
return;
}
out.push(name.to_string());
if let Some(cfg) = self.memory_namespaces.get(name) {
for parent in &cfg.include {
self.namespace_chain_walk(parent, out, seen);
}
}
}
pub fn background_profile_for_namespace(&self, namespace: &str) -> Option<&str> {
if let Some(name) = self
.memory_namespaces
.get(namespace)
.and_then(|c| c.background_profile.as_deref())
{
return Some(name);
}
if self.profiles.contains_key(BACKGROUND_PROFILE_NAME) {
return Some(BACKGROUND_PROFILE_NAME);
}
None
}
pub fn namespace_for_room_profile(&self, name: &str) -> &str {
self.room_profiles
.get(name)
.and_then(|rp| rp.memory_namespace.as_deref())
.unwrap_or(DEFAULT_NAMESPACE_NAME)
}
pub fn namespace_for_room(&self, room_id: &str) -> &str {
self.room_profile_for(room_id)
.and_then(|(_, rp)| rp.memory_namespace.as_deref())
.unwrap_or(DEFAULT_NAMESPACE_NAME)
}
pub fn all_memory_namespaces(&self) -> Vec<String> {
let mut out: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
out.insert(DEFAULT_NAMESPACE_NAME.to_string());
out.extend(self.memory_namespaces.keys().cloned());
for rp in self.room_profiles.values() {
if let Some(ns) = &rp.memory_namespace {
out.insert(ns.clone());
}
}
out.into_iter().collect()
}
pub fn voice_pipeline_for_room_profile(&self, name: &str) -> Option<&VoicePipelineConfig> {
self.room_profiles
.get(name)
.and_then(|rp| rp.voice_pipeline.as_ref())
.and_then(|vp_name| self.voice_pipelines.get(vp_name))
}
pub fn default_path() -> PathBuf {
if let Some(dirs) = directories::ProjectDirs::from("", "", "sapphire-agent") {
dirs.config_dir().join("config.toml")
} else {
PathBuf::from("config.toml")
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn parse(s: &str) -> Config {
toml::from_str(s).expect("config should parse")
}
const MINIMAL: &str = r#"
[anthropic]
api_key = "test"
"#;
#[test]
fn no_profiles_means_no_resolution() {
let cfg = parse(MINIMAL);
assert!(cfg.profile_for("!any:srv").is_none());
assert!(cfg.validate_profiles().is_empty());
}
#[test]
fn default_profile_is_used_when_room_unspecified() {
let cfg = parse(
r#"
[anthropic]
api_key = "test"
[profiles.default]
provider = "anthropic"
"#,
);
assert_eq!(cfg.profile_for("!some:srv"), Some("default"));
}
#[test]
fn room_profile_assigns_profile_to_listed_rooms() {
let cfg = parse(
r#"
[anthropic]
api_key = "test"
[providers.local]
type = "openai_compatible"
base_url = "http://127.0.0.1:8080/v1"
model = "gemma-4-31b-it"
[profiles.default]
provider = "anthropic"
[profiles.nsfw]
provider = "local"
[room_profile.private_nsfw]
profile = "nsfw"
rooms = ["!nsfw:srv"]
"#,
);
assert_eq!(cfg.profile_for("!nsfw:srv"), Some("nsfw"));
assert_eq!(cfg.profile_for("!other:srv"), Some("default"));
assert_eq!(cfg.provider_for_profile("nsfw"), Some("local"));
assert_eq!(cfg.provider_for_profile("default"), Some("anthropic"));
assert!(cfg.validate_profiles().is_empty());
}
#[test]
fn default_room_profile_catches_unmatched_rooms() {
let cfg = parse(
r#"
[anthropic]
api_key = "test"
[profiles.casual]
provider = "anthropic"
[profiles.opus]
provider = "anthropic"
[room_profile.default]
profile = "casual"
rooms = []
[room_profile.dev]
profile = "opus"
rooms = ["!dev:srv"]
"#,
);
assert_eq!(cfg.profile_for("!dev:srv"), Some("opus"));
assert_eq!(cfg.profile_for("!chat:srv"), Some("casual"));
}
#[test]
fn validate_rejects_room_listed_in_two_profiles() {
let cfg = parse(
r#"
[anthropic]
api_key = "test"
[profiles.a]
provider = "anthropic"
[profiles.b]
provider = "anthropic"
[room_profile.first]
profile = "a"
rooms = ["!shared:srv"]
[room_profile.second]
profile = "b"
rooms = ["!shared:srv"]
"#,
);
let errors = cfg.validate_profiles();
assert!(
errors.iter().any(|e| e.contains("multiple room_profiles")),
"expected duplicate-room error, got: {errors:?}"
);
}
#[test]
fn validate_flags_unknown_provider_in_profile() {
let cfg = parse(
r#"
[anthropic]
api_key = "test"
[profiles.default]
provider = "ghost"
"#,
);
let errors = cfg.validate_profiles();
assert_eq!(errors.len(), 1, "got: {errors:?}");
assert!(errors[0].contains("ghost"));
}
#[test]
fn validate_flags_unknown_fallback_provider() {
let cfg = parse(
r#"
[anthropic]
api_key = "test"
[profiles.default]
provider = "anthropic"
fallback_provider = "ghost"
"#,
);
let errors = cfg.validate_profiles();
assert_eq!(errors.len(), 1);
assert!(errors[0].contains("fallback"));
assert!(errors[0].contains("ghost"));
}
#[test]
fn validate_flags_unknown_profile_in_room_profile() {
let cfg = parse(
r#"
[anthropic]
api_key = "test"
[room_profile.x]
profile = "missing"
rooms = ["!x:srv"]
"#,
);
let errors = cfg.validate_profiles();
assert!(
errors.iter().any(|e| e.contains("missing")),
"got: {errors:?}"
);
}
#[test]
fn validate_rejects_duplicate_api_keys_across_profiles() {
let cfg = parse(
r#"
[anthropic]
api_key = "test"
[profiles.a]
provider = "anthropic"
[profiles.b]
provider = "anthropic"
[room_profile.alpha]
profile = "a"
rooms = []
api_keys = ["sa-a2a-shared"]
[room_profile.beta]
profile = "b"
rooms = []
api_keys = ["sa-a2a-shared"]
"#,
);
let errors = cfg.validate_profiles();
assert!(
errors.iter().any(|e| e.contains("token reused")),
"expected duplicate-api_keys error, got: {errors:?}"
);
}
#[test]
fn validate_rejects_empty_api_key() {
let cfg = parse(
r#"
[anthropic]
api_key = "test"
[profiles.a]
provider = "anthropic"
[room_profile.alpha]
profile = "a"
rooms = []
api_keys = [""]
"#,
);
let errors = cfg.validate_profiles();
assert!(
errors.iter().any(|e| e.contains("empty api_keys")),
"expected empty-token error, got: {errors:?}"
);
}
#[test]
fn resolve_a2a_token_finds_owning_profile() {
let cfg = parse(
r#"
[anthropic]
api_key = "test"
[profiles.world]
provider = "anthropic"
[profiles.dev]
provider = "anthropic"
[room_profile.sapphire_world]
profile = "world"
rooms = []
api_keys = ["sa-a2a-world-1", "sa-a2a-world-2"]
[room_profile.developer]
profile = "dev"
rooms = []
api_keys = ["sa-a2a-dev"]
"#,
);
assert!(cfg.validate_profiles().is_empty());
assert_eq!(
cfg.resolve_a2a_token("sa-a2a-world-1"),
Some("sapphire_world")
);
assert_eq!(
cfg.resolve_a2a_token("sa-a2a-world-2"),
Some("sapphire_world")
);
assert_eq!(cfg.resolve_a2a_token("sa-a2a-dev"), Some("developer"));
assert_eq!(cfg.resolve_a2a_token("nope"), None);
assert_eq!(cfg.resolve_a2a_token(""), None);
}
#[test]
fn shipped_example_parses() {
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("config.example.toml");
let cfg = Config::load(&path).expect("config.example.toml should parse");
assert!(
cfg.validate_profiles().is_empty(),
"validation errors: {:?}",
cfg.validate_profiles()
);
}
#[test]
fn namespace_default_resolves_when_unconfigured() {
let cfg = parse(MINIMAL);
assert_eq!(
cfg.resolve_namespace_chain(DEFAULT_NAMESPACE_NAME),
vec!["default".to_string()]
);
assert_eq!(cfg.namespace_for_room("!any:srv"), "default");
assert!(cfg.validate_profiles().is_empty());
}
#[test]
fn namespace_chain_includes_parents_in_dfs_preorder() {
let cfg = parse(
r#"
[anthropic]
api_key = "test"
[memory_namespace.user]
include = ["default"]
[memory_namespace.user_nsfw]
include = ["user"]
"#,
);
assert_eq!(
cfg.resolve_namespace_chain("user_nsfw"),
vec![
"user_nsfw".to_string(),
"user".to_string(),
"default".to_string()
]
);
assert_eq!(
cfg.resolve_namespace_chain("user"),
vec!["user".to_string(), "default".to_string()]
);
}
#[test]
fn namespace_chain_dedupes_diamond() {
let cfg = parse(
r#"
[anthropic]
api_key = "test"
[memory_namespace.b]
include = ["d"]
[memory_namespace.c]
include = ["d"]
[memory_namespace.d]
[memory_namespace.a]
include = ["b", "c"]
"#,
);
let chain = cfg.resolve_namespace_chain("a");
assert_eq!(chain.iter().filter(|n| *n == "d").count(), 1);
assert_eq!(chain[0], "a");
}
#[test]
fn namespace_cycle_is_rejected() {
let cfg = parse(
r#"
[anthropic]
api_key = "test"
[memory_namespace.a]
include = ["b"]
[memory_namespace.b]
include = ["a"]
"#,
);
let errors = cfg.validate_profiles();
assert!(
errors.iter().any(|e| e.contains("cycle")),
"expected cycle error, got: {errors:?}"
);
}
#[test]
fn namespace_unknown_include_is_rejected() {
let cfg = parse(
r#"
[anthropic]
api_key = "test"
[memory_namespace.user]
include = ["ghost"]
"#,
);
let errors = cfg.validate_profiles();
assert!(
errors.iter().any(|e| e.contains("ghost")),
"expected unknown-namespace error, got: {errors:?}"
);
}
#[test]
fn room_profile_assigns_memory_namespace() {
let cfg = parse(
r#"
[anthropic]
api_key = "test"
[memory_namespace.user_nsfw]
include = ["default"]
[profiles.nsfw]
provider = "anthropic"
[room_profile.private_nsfw]
profile = "nsfw"
memory_namespace = "user_nsfw"
rooms = ["!nsfw:srv"]
"#,
);
assert!(cfg.validate_profiles().is_empty());
assert_eq!(cfg.namespace_for_room("!nsfw:srv"), "user_nsfw");
assert_eq!(cfg.namespace_for_room("!other:srv"), "default");
assert_eq!(cfg.namespace_for_room_profile("private_nsfw"), "user_nsfw");
}
#[test]
fn room_profile_unknown_memory_namespace_is_rejected() {
let cfg = parse(
r#"
[anthropic]
api_key = "test"
[profiles.x]
provider = "anthropic"
[room_profile.bad]
profile = "x"
memory_namespace = "ghost"
rooms = ["!x:srv"]
"#,
);
let errors = cfg.validate_profiles();
assert!(
errors.iter().any(|e| e.contains("ghost")),
"expected unknown-namespace error, got: {errors:?}"
);
}
#[test]
fn background_profile_resolves_from_namespace_first() {
let cfg = parse(
r#"
[anthropic]
api_key = "test"
[profiles.bg_global]
provider = "anthropic"
[profiles.bg_nsfw]
provider = "anthropic"
[profiles.background]
provider = "anthropic"
[memory_namespace.user_nsfw]
include = ["default"]
background_profile = "bg_nsfw"
"#,
);
assert!(cfg.validate_profiles().is_empty());
assert_eq!(
cfg.background_profile_for_namespace("user_nsfw"),
Some("bg_nsfw")
);
assert_eq!(
cfg.background_profile_for_namespace("default"),
Some("background")
);
}
#[test]
fn background_profile_is_none_when_unconfigured() {
let cfg = parse(MINIMAL);
assert!(cfg.background_profile_for_namespace("default").is_none());
}
#[test]
fn background_profile_falls_back_to_global() {
let cfg = parse(
r#"
[anthropic]
api_key = "test"
[profiles.background]
provider = "anthropic"
"#,
);
assert_eq!(
cfg.background_profile_for_namespace("anything"),
Some("background")
);
}
#[test]
fn unknown_background_profile_is_rejected() {
let cfg = parse(
r#"
[anthropic]
api_key = "test"
[memory_namespace.user]
background_profile = "ghost"
"#,
);
let errors = cfg.validate_profiles();
assert!(
errors.iter().any(|e| e.contains("ghost")),
"expected ghost error, got: {errors:?}"
);
}
#[test]
fn all_memory_namespaces_unions_sources() {
let cfg = parse(
r#"
[anthropic]
api_key = "test"
[memory_namespace.user]
include = ["default"]
[profiles.nsfw]
provider = "anthropic"
memory_namespace = "user_nsfw"
[memory_namespace.user_nsfw]
include = ["user"]
"#,
);
let all = cfg.all_memory_namespaces();
assert!(all.contains(&"default".to_string()));
assert!(all.contains(&"user".to_string()));
assert!(all.contains(&"user_nsfw".to_string()));
}
#[test]
fn voice_pipeline_config_parses_and_validates() {
let cfg = parse(
r#"
[anthropic]
api_key = "test"
[profiles.casual]
provider = "anthropic"
[voice_pipeline.default]
stt_provider = "sense_voice"
tts_provider = "irodori"
language = "ja"
[stt_provider.sense_voice]
type = "sherpa_onnx"
kind = "sense_voice"
model = "sherpa-onnx-sense-voice-zh-en-ja-ko-yue-2024-07-17"
[tts_provider.irodori]
type = "openai_tts"
base_url = "https://irodori-tts-api.home.fireturtle.net"
model = "tts-1"
voice = "alloy"
[room_profile.home]
profile = "casual"
voice_pipeline = "default"
rooms = []
"#,
);
assert!(
cfg.validate_profiles().is_empty(),
"errors: {:?}",
cfg.validate_profiles()
);
let vp = cfg
.voice_pipeline_for_room_profile("home")
.expect("voice pipeline resolved");
assert_eq!(vp.stt_provider, "sense_voice");
assert_eq!(vp.tts_provider, "irodori");
assert_eq!(vp.language.as_deref(), Some("ja"));
assert_eq!(vp.capture_max_ms, 30_000); }
#[test]
fn sherpa_stt_config_round_trips_with_defaults() {
let cfg = parse(
r#"
[anthropic]
api_key = "test"
[stt_provider.sense_voice]
type = "sherpa_onnx"
kind = "sense_voice"
model = "sherpa-onnx-sense-voice-zh-en-ja-ko-yue-2024-07-17"
"#,
);
let stt = cfg
.stt_providers
.get("sense_voice")
.expect("provider parses");
match stt {
SttProviderConfig::SherpaOnnx(s) => {
assert!(matches!(s.kind, SherpaSttKind::SenseVoice));
assert_eq!(
s.model.as_deref(),
Some("sherpa-onnx-sense-voice-zh-en-ja-ko-yue-2024-07-17")
);
assert_eq!(s.num_threads, 2);
assert_eq!(s.provider, "cpu");
assert!(s.language.is_none());
}
_ => panic!("expected SherpaOnnx variant"),
}
}
#[test]
fn sherpa_tts_config_round_trips_with_defaults() {
let cfg = parse(
r#"
[anthropic]
api_key = "test"
[tts_provider.vits_ja]
type = "sherpa_onnx"
kind = "vits"
model = "vits-someone-2024"
speaker_id = 3
speed = 1.2
"#,
);
let tts = cfg.tts_providers.get("vits_ja").expect("provider parses");
match tts {
TtsProviderConfig::SherpaOnnx(s) => {
assert!(matches!(s.kind, SherpaTtsKind::Vits));
assert_eq!(s.speaker_id, 3);
assert_eq!(s.speed, 1.2);
assert_eq!(s.num_threads, 2);
assert_eq!(s.provider, "cpu");
}
_ => panic!("expected SherpaOnnx variant"),
}
}
#[test]
fn voice_pipeline_rejects_unknown_stt() {
let cfg = parse(
r#"
[anthropic]
api_key = "test"
[voice_pipeline.default]
stt_provider = "ghost"
tts_provider = "irodori"
[tts_provider.irodori]
type = "openai_tts"
base_url = "http://localhost:8000"
"#,
);
let errors = cfg.validate_profiles();
assert!(
errors.iter().any(|e| e.contains("ghost")),
"got: {errors:?}"
);
}
#[test]
fn room_profile_voice_pipeline_must_exist() {
let cfg = parse(
r#"
[anthropic]
api_key = "test"
[profiles.casual]
provider = "anthropic"
[room_profile.home]
profile = "casual"
voice_pipeline = "ghost"
rooms = []
"#,
);
let errors = cfg.validate_profiles();
assert!(
errors.iter().any(|e| e.contains("ghost")),
"got: {errors:?}"
);
}
#[test]
fn voice_wake_word_model_defaults_to_none() {
let cfg = parse(
r#"
[anthropic]
api_key = "test"
"#,
);
assert!(cfg.voice.wake_word_model.is_none());
}
#[test]
fn voice_wake_word_model_rejects_missing_file() {
let cfg = parse(
r#"
[anthropic]
api_key = "test"
[voice]
wake_word_model = "/nonexistent/saphina.onnx"
"#,
);
let errors = cfg.validate_profiles();
assert!(
errors
.iter()
.any(|e| e.contains("/nonexistent/saphina.onnx")),
"got: {errors:?}"
);
}
#[test]
fn provider_config_parses_openai_compatible() {
let cfg = parse(
r#"
[anthropic]
api_key = "test"
[providers.local]
type = "openai_compatible"
base_url = "http://127.0.0.1:8080/v1"
model = "gemma-4-31b-it"
"#,
);
let local = cfg.providers.get("local").expect("local provider present");
match local {
ProviderConfig::OpenAiCompatible(c) => {
assert_eq!(c.base_url, "http://127.0.0.1:8080/v1");
assert_eq!(c.model, "gemma-4-31b-it");
assert!(c.api_key.is_none());
}
}
}
}