use serde::{Deserialize, Serialize};
use solo_core::{Error, Result};
use std::path::Path;
use crate::key_material::SALT_LEN;
pub const CONFIG_SCHEMA_VERSION: u32 = 1;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "mode", rename_all = "snake_case")]
pub enum AuthSettings {
Bearer {
token: String,
},
Oidc {
discovery_url: String,
audience: String,
#[serde(default = "default_tenant_claim_name")]
tenant_claim_name: String,
},
}
fn default_tenant_claim_name() -> String {
"solo_tenant".to_string()
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "mode", rename_all = "snake_case")]
pub enum LlmSettings {
#[default]
None,
Anthropic {
#[serde(default = "default_anthropic_api_key_env")]
api_key_env: String,
#[serde(default = "default_anthropic_model")]
model: String,
},
Openai {
#[serde(default = "default_openai_api_key_env")]
api_key_env: String,
#[serde(default = "default_openai_model")]
model: String,
},
Ollama {
#[serde(default = "default_ollama_base_url")]
base_url: String,
#[serde(default = "default_ollama_model")]
model: String,
},
McpSampling,
}
fn default_anthropic_api_key_env() -> String {
"ANTHROPIC_API_KEY".to_string()
}
fn default_anthropic_model() -> String {
"claude-sonnet-4-6".to_string()
}
fn default_openai_api_key_env() -> String {
"OPENAI_API_KEY".to_string()
}
fn default_openai_model() -> String {
"gpt-5o".to_string()
}
fn default_ollama_base_url() -> String {
"http://localhost:11434".to_string()
}
fn default_ollama_model() -> String {
"qwen3-coder:30b".to_string()
}
impl LlmSettings {
pub fn is_real_llm(&self) -> bool {
!matches!(self, LlmSettings::None)
}
pub fn mode_str(&self) -> &'static str {
match self {
LlmSettings::None => "none",
LlmSettings::Anthropic { .. } => "anthropic",
LlmSettings::Openai { .. } => "openai",
LlmSettings::Ollama { .. } => "ollama",
LlmSettings::McpSampling => "mcp_sampling",
}
}
pub fn requires_mcp_peer(&self) -> bool {
matches!(self, LlmSettings::McpSampling)
}
pub fn validate_against_transport(
&self,
mcp_transport_available: bool,
) -> Result<()> {
if self.requires_mcp_peer() && !mcp_transport_available {
return Err(Error::storage(
"LLM backend `mcp_sampling` requires a connected MCP \
client that advertises the `sampling` capability at \
initialize. This process is running without an MCP \
transport (daemon-only or HTTP-only), so no peer can \
be reached. Configure `[llm] mode` to one of \
`anthropic`, `openai`, `ollama`, or `none` in \
`solo.config.toml`."
.to_string(),
));
}
Ok(())
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct TriplesConfig {
#[serde(default = "default_triples_trigger_interval_secs")]
pub trigger_interval_secs: u64,
#[serde(default = "default_triples_trigger_episode_count")]
pub trigger_episode_count: u32,
#[serde(default = "default_triples_consolidate_interval_secs")]
pub consolidate_interval_secs: u64,
#[serde(default = "default_triples_cluster_timeout_secs")]
pub cluster_timeout_secs: u64,
}
fn default_triples_trigger_interval_secs() -> u64 {
3600
}
fn default_triples_trigger_episode_count() -> u32 {
50
}
fn default_triples_consolidate_interval_secs() -> u64 {
3600
}
fn default_triples_cluster_timeout_secs() -> u64 {
60
}
impl Default for TriplesConfig {
fn default() -> Self {
Self {
trigger_interval_secs: default_triples_trigger_interval_secs(),
trigger_episode_count: default_triples_trigger_episode_count(),
consolidate_interval_secs: default_triples_consolidate_interval_secs(),
cluster_timeout_secs: default_triples_cluster_timeout_secs(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SamplingConfig {
#[serde(default = "default_sampling_coalesce_window_ms")]
pub coalesce_window_ms: u64,
#[serde(default = "default_sampling_coalesce_max_requests")]
pub coalesce_max_requests: u32,
}
fn default_sampling_coalesce_window_ms() -> u64 {
5000
}
fn default_sampling_coalesce_max_requests() -> u32 {
10
}
impl Default for SamplingConfig {
fn default() -> Self {
Self {
coalesce_window_ms: default_sampling_coalesce_window_ms(),
coalesce_max_requests: default_sampling_coalesce_max_requests(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SamplingConfigDiagnostic {
Ok,
Info,
Warn,
}
impl SamplingConfig {
pub fn diagnostic(&self) -> SamplingConfigDiagnostic {
if self.coalesce_window_ms == 0 && self.coalesce_max_requests <= 1 {
SamplingConfigDiagnostic::Warn
} else if self.coalesce_window_ms == 0
|| self.coalesce_max_requests == 0
{
SamplingConfigDiagnostic::Info
} else {
SamplingConfigDiagnostic::Ok
}
}
pub fn warn_on_edge_values(&self) {
match self.diagnostic() {
SamplingConfigDiagnostic::Warn => {
tracing::warn!(
coalesce_window_ms = self.coalesce_window_ms,
coalesce_max_requests = self.coalesce_max_requests,
"sampling coalescing disabled by config \
(window=0ms, max_requests<=1); each LLM call \
goes through the MCP client uncoalesced"
);
}
SamplingConfigDiagnostic::Info => {
tracing::info!(
coalesce_window_ms = self.coalesce_window_ms,
coalesce_max_requests = self.coalesce_max_requests,
"sampling config has a zero-valued bound; \
resolved settings logged for operator \
visibility (coordinator clamps max_requests to \
max(1) internally)"
);
}
SamplingConfigDiagnostic::Ok => {}
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct AuditSettings {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub retention_days: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub purge_interval_secs: Option<u64>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct RedactionConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub exclude_builtin: Vec<String>,
#[serde(default)]
pub custom: Vec<CustomRedactionPattern>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct CustomRedactionPattern {
pub name: String,
pub regex: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub replacement: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SoloConfig {
pub schema_version: u32,
pub salt_hex: String,
pub embedder: EmbedderConfig,
#[serde(default)]
pub identity: IdentityConfig,
#[serde(default)]
pub documents: DocumentConfig,
#[serde(default)]
pub auth: Option<AuthSettings>,
#[serde(default)]
pub audit: AuditSettings,
#[serde(default)]
pub redaction: RedactionConfig,
#[serde(default)]
pub llm: Option<LlmSettings>,
#[serde(default)]
pub triples: TriplesConfig,
#[serde(default)]
pub sampling: SamplingConfig,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct IdentityConfig {
#[serde(default)]
pub user_aliases: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct DocumentConfig {
#[serde(default = "default_chunk_token_target")]
pub chunk_token_target: u32,
#[serde(default = "default_chunk_overlap_tokens")]
pub chunk_overlap_tokens: u32,
#[serde(default = "default_allowed_extensions")]
pub allowed_extensions: Vec<String>,
}
fn default_chunk_token_target() -> u32 {
500
}
fn default_chunk_overlap_tokens() -> u32 {
50
}
fn default_allowed_extensions() -> Vec<String> {
vec![
"md", "markdown", "txt", "rs", "py", "toml", "yaml", "yml", "json", "pdf", "html", "htm",
]
.into_iter()
.map(String::from)
.collect()
}
impl Default for DocumentConfig {
fn default() -> Self {
Self {
chunk_token_target: default_chunk_token_target(),
chunk_overlap_tokens: default_chunk_overlap_tokens(),
allowed_extensions: default_allowed_extensions(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct EmbedderConfig {
pub name: String,
pub version: String,
pub dim: u32,
pub dtype: String,
}
impl SoloConfig {
pub fn new(salt: [u8; SALT_LEN], embedder: EmbedderConfig) -> Self {
Self {
schema_version: CONFIG_SCHEMA_VERSION,
salt_hex: hex::encode(salt),
embedder,
identity: IdentityConfig::default(),
documents: DocumentConfig::default(),
auth: None,
audit: AuditSettings::default(),
redaction: RedactionConfig::default(),
llm: None,
triples: TriplesConfig::default(),
sampling: SamplingConfig::default(),
}
}
pub fn salt_bytes(&self) -> Result<[u8; SALT_LEN]> {
let bytes = hex::decode(&self.salt_hex)
.map_err(|e| Error::storage(format!("config salt_hex is not valid hex: {e}")))?;
if bytes.len() != SALT_LEN {
return Err(Error::storage(format!(
"config salt_hex must decode to {} bytes, got {}",
SALT_LEN,
bytes.len()
)));
}
let mut out = [0u8; SALT_LEN];
out.copy_from_slice(&bytes);
Ok(out)
}
pub fn write(&self, path: &Path) -> Result<()> {
if path.exists() {
return Err(Error::conflict(format!(
"config already exists: {}",
path.display()
)));
}
let tmp_path = path.with_extension("toml.tmp");
let body = toml::to_string_pretty(self)
.map_err(|e| Error::storage(format!("toml serialize: {e}")))?;
{
let mut tmp_file = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&tmp_path)
.map_err(|e| Error::storage(format!("open tmp {}: {e}", tmp_path.display())))?;
std::io::Write::write_all(&mut tmp_file, body.as_bytes())
.map_err(|e| Error::storage(format!("write {}: {e}", tmp_path.display())))?;
tmp_file
.sync_all()
.map_err(|e| Error::storage(format!("fsync tmp {}: {e}", tmp_path.display())))?;
}
std::fs::rename(&tmp_path, path)
.map_err(|e| Error::storage(format!("rename to {}: {e}", path.display())))?;
#[cfg(unix)]
{
if let Some(parent) = path.parent() {
if let Ok(d) = std::fs::OpenOptions::new().read(true).open(parent) {
let _ = d.sync_all();
}
}
}
Ok(())
}
pub fn read(path: &Path) -> Result<Self> {
let body = std::fs::read_to_string(path)
.map_err(|e| Error::storage(format!("read {}: {e}", path.display())))?;
let cfg: Self = toml::from_str(&body)
.map_err(|e| Error::storage(format!("toml parse {}: {e}", path.display())))?;
if cfg.schema_version != CONFIG_SCHEMA_VERSION {
return Err(Error::storage(format!(
"config schema_version mismatch: file is v{}, this binary expects v{}",
cfg.schema_version, CONFIG_SCHEMA_VERSION
)));
}
let _ = cfg.salt_bytes()?;
cfg.sampling.warn_on_edge_values();
Ok(cfg)
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn fixture_embedder() -> EmbedderConfig {
EmbedderConfig {
name: "bge-m3".into(),
version: "v1.0".into(),
dim: 1024,
dtype: "f32".into(),
}
}
#[test]
fn roundtrip_via_disk() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("solo.config.toml");
let salt = [7u8; SALT_LEN];
let cfg = SoloConfig::new(salt, fixture_embedder());
cfg.write(&path).unwrap();
let read_back = SoloConfig::read(&path).unwrap();
assert_eq!(cfg, read_back);
assert_eq!(read_back.salt_bytes().unwrap(), salt);
}
#[test]
fn write_refuses_overwrite() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("solo.config.toml");
let cfg = SoloConfig::new([0; SALT_LEN], fixture_embedder());
cfg.write(&path).unwrap();
let err = cfg.write(&path).unwrap_err();
assert!(err.to_string().contains("already exists"), "got: {err}");
}
#[test]
fn read_rejects_wrong_schema_version() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("solo.config.toml");
std::fs::write(
&path,
r#"
schema_version = 99
salt_hex = "00000000000000000000000000000000"
[embedder]
name = "bge-m3"
version = "v1.0"
dim = 1024
dtype = "f32"
"#,
)
.unwrap();
let err = SoloConfig::read(&path).unwrap_err();
assert!(err.to_string().contains("schema_version mismatch"), "got: {err}");
}
#[test]
fn read_rejects_non_hex_salt() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("solo.config.toml");
std::fs::write(
&path,
format!(
r#"
schema_version = {CONFIG_SCHEMA_VERSION}
salt_hex = "ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ"
[embedder]
name = "bge-m3"
version = "v1.0"
dim = 1024
dtype = "f32"
"#
),
)
.unwrap();
let err = SoloConfig::read(&path).unwrap_err();
assert!(err.to_string().contains("salt_hex"), "got: {err}");
}
#[test]
fn read_rejects_missing_embedder_block() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("solo.config.toml");
std::fs::write(
&path,
format!(
r#"
schema_version = {CONFIG_SCHEMA_VERSION}
salt_hex = "00000000000000000000000000000000"
"#
),
)
.unwrap();
let err = SoloConfig::read(&path).unwrap_err();
assert!(err.to_string().to_lowercase().contains("embedder") || err.to_string().contains("missing"), "got: {err}");
}
#[test]
fn read_loads_user_aliases_from_identity_block() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("solo.config.toml");
std::fs::write(
&path,
format!(
r#"
schema_version = {CONFIG_SCHEMA_VERSION}
salt_hex = "00000000000000000000000000000000"
[embedder]
name = "bge-m3"
version = "v1.0"
dim = 1024
dtype = "f32"
[identity]
user_aliases = ["alex", "alice"]
"#
),
)
.unwrap();
let cfg = SoloConfig::read(&path).expect("read ok");
assert_eq!(cfg.identity.user_aliases, vec!["alex".to_string(), "alice".to_string()]);
}
#[test]
fn read_defaults_identity_when_block_absent() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("solo.config.toml");
std::fs::write(
&path,
format!(
r#"
schema_version = {CONFIG_SCHEMA_VERSION}
salt_hex = "00000000000000000000000000000000"
[embedder]
name = "bge-m3"
version = "v1.0"
dim = 1024
dtype = "f32"
"#
),
)
.unwrap();
let cfg = SoloConfig::read(&path).expect("read ok");
assert!(cfg.identity.user_aliases.is_empty());
}
#[test]
fn read_defaults_user_aliases_when_identity_block_empty() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("solo.config.toml");
std::fs::write(
&path,
format!(
r#"
schema_version = {CONFIG_SCHEMA_VERSION}
salt_hex = "00000000000000000000000000000000"
[embedder]
name = "bge-m3"
version = "v1.0"
dim = 1024
dtype = "f32"
[identity]
"#
),
)
.unwrap();
let cfg = SoloConfig::read(&path).expect("read ok");
assert!(cfg.identity.user_aliases.is_empty());
}
#[test]
fn read_defaults_documents_when_block_absent() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("solo.config.toml");
std::fs::write(
&path,
format!(
r#"
schema_version = {CONFIG_SCHEMA_VERSION}
salt_hex = "00000000000000000000000000000000"
[embedder]
name = "bge-m3"
version = "v1.0"
dim = 1024
dtype = "f32"
"#
),
)
.unwrap();
let cfg = SoloConfig::read(&path).expect("read ok");
assert_eq!(cfg.documents.chunk_token_target, 500);
assert_eq!(cfg.documents.chunk_overlap_tokens, 50);
assert!(cfg.documents.allowed_extensions.contains(&"md".to_string()));
assert!(cfg.documents.allowed_extensions.contains(&"pdf".to_string()));
}
#[test]
fn read_loads_custom_documents_block() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("solo.config.toml");
std::fs::write(
&path,
format!(
r#"
schema_version = {CONFIG_SCHEMA_VERSION}
salt_hex = "00000000000000000000000000000000"
[embedder]
name = "bge-m3"
version = "v1.0"
dim = 1024
dtype = "f32"
[documents]
chunk_token_target = 250
chunk_overlap_tokens = 25
allowed_extensions = ["md", "txt"]
"#
),
)
.unwrap();
let cfg = SoloConfig::read(&path).expect("read ok");
assert_eq!(cfg.documents.chunk_token_target, 250);
assert_eq!(cfg.documents.chunk_overlap_tokens, 25);
assert_eq!(cfg.documents.allowed_extensions, vec!["md".to_string(), "txt".to_string()]);
}
#[test]
fn document_config_default_matches_plan() {
let d = DocumentConfig::default();
assert_eq!(d.chunk_token_target, 500);
assert_eq!(d.chunk_overlap_tokens, 50);
for ext in &["md", "markdown", "txt", "rs", "py", "toml", "yaml", "yml", "json", "pdf", "html", "htm"] {
assert!(
d.allowed_extensions.iter().any(|e| e == ext),
"default allowed_extensions missing {ext}"
);
}
}
#[test]
fn read_defaults_auth_when_block_absent() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("solo.config.toml");
std::fs::write(
&path,
format!(
r#"
schema_version = {CONFIG_SCHEMA_VERSION}
salt_hex = "00000000000000000000000000000000"
[embedder]
name = "bge-m3"
version = "v1.0"
dim = 1024
dtype = "f32"
"#
),
)
.unwrap();
let cfg = SoloConfig::read(&path).expect("read ok");
assert!(cfg.auth.is_none());
}
#[test]
fn read_loads_bearer_auth_block() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("solo.config.toml");
std::fs::write(
&path,
format!(
r#"
schema_version = {CONFIG_SCHEMA_VERSION}
salt_hex = "00000000000000000000000000000000"
[embedder]
name = "bge-m3"
version = "v1.0"
dim = 1024
dtype = "f32"
[auth]
mode = "bearer"
token = "s3cr3t"
"#
),
)
.unwrap();
let cfg = SoloConfig::read(&path).expect("read ok");
match cfg.auth {
Some(AuthSettings::Bearer { token }) => assert_eq!(token, "s3cr3t"),
other => panic!("expected bearer, got {other:?}"),
}
}
#[test]
fn read_loads_oidc_auth_block_with_default_tenant_claim() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("solo.config.toml");
std::fs::write(
&path,
format!(
r#"
schema_version = {CONFIG_SCHEMA_VERSION}
salt_hex = "00000000000000000000000000000000"
[embedder]
name = "bge-m3"
version = "v1.0"
dim = 1024
dtype = "f32"
[auth]
mode = "oidc"
discovery_url = "https://idp.example.com/.well-known/openid-configuration"
audience = "solo-prod"
"#
),
)
.unwrap();
let cfg = SoloConfig::read(&path).expect("read ok");
match cfg.auth {
Some(AuthSettings::Oidc {
discovery_url,
audience,
tenant_claim_name,
}) => {
assert_eq!(
discovery_url,
"https://idp.example.com/.well-known/openid-configuration"
);
assert_eq!(audience, "solo-prod");
assert_eq!(tenant_claim_name, "solo_tenant");
}
other => panic!("expected oidc, got {other:?}"),
}
}
#[test]
fn read_loads_oidc_auth_block_with_custom_tenant_claim() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("solo.config.toml");
std::fs::write(
&path,
format!(
r#"
schema_version = {CONFIG_SCHEMA_VERSION}
salt_hex = "00000000000000000000000000000000"
[embedder]
name = "bge-m3"
version = "v1.0"
dim = 1024
dtype = "f32"
[auth]
mode = "oidc"
discovery_url = "https://idp.example.com/.well-known/openid-configuration"
audience = "solo-prod"
tenant_claim_name = "org_id"
"#
),
)
.unwrap();
let cfg = SoloConfig::read(&path).expect("read ok");
match cfg.auth {
Some(AuthSettings::Oidc {
tenant_claim_name, ..
}) => assert_eq!(tenant_claim_name, "org_id"),
other => panic!("expected oidc, got {other:?}"),
}
}
#[test]
fn read_defaults_audit_when_block_absent() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("solo.config.toml");
std::fs::write(
&path,
format!(
r#"
schema_version = {CONFIG_SCHEMA_VERSION}
salt_hex = "00000000000000000000000000000000"
[embedder]
name = "bge-m3"
version = "v1.0"
dim = 1024
dtype = "f32"
"#
),
)
.unwrap();
let cfg = SoloConfig::read(&path).expect("read ok");
assert!(cfg.audit.retention_days.is_none());
assert!(cfg.audit.purge_interval_secs.is_none());
}
#[test]
fn read_loads_audit_block_with_retention_only() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("solo.config.toml");
std::fs::write(
&path,
format!(
r#"
schema_version = {CONFIG_SCHEMA_VERSION}
salt_hex = "00000000000000000000000000000000"
[embedder]
name = "bge-m3"
version = "v1.0"
dim = 1024
dtype = "f32"
[audit]
retention_days = 30
"#
),
)
.unwrap();
let cfg = SoloConfig::read(&path).expect("read ok");
assert_eq!(cfg.audit.retention_days, Some(30));
assert!(cfg.audit.purge_interval_secs.is_none());
}
#[test]
fn read_loads_audit_block_with_purge_interval() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("solo.config.toml");
std::fs::write(
&path,
format!(
r#"
schema_version = {CONFIG_SCHEMA_VERSION}
salt_hex = "00000000000000000000000000000000"
[embedder]
name = "bge-m3"
version = "v1.0"
dim = 1024
dtype = "f32"
[audit]
retention_days = 7
purge_interval_secs = 3600
"#
),
)
.unwrap();
let cfg = SoloConfig::read(&path).expect("read ok");
assert_eq!(cfg.audit.retention_days, Some(7));
assert_eq!(cfg.audit.purge_interval_secs, Some(3600));
}
#[test]
fn read_rejects_short_salt_hex() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("solo.config.toml");
std::fs::write(
&path,
format!(
r#"
schema_version = {CONFIG_SCHEMA_VERSION}
salt_hex = "deadbeef"
[embedder]
name = "bge-m3"
version = "v1.0"
dim = 1024
dtype = "f32"
"#
),
)
.unwrap();
let err = SoloConfig::read(&path).unwrap_err();
assert!(err.to_string().contains("salt_hex"), "got: {err}");
}
#[test]
fn llm_settings_anthropic_round_trip_with_defaults() {
let toml_in = r#"mode = "anthropic""#;
let parsed: LlmSettings = toml::from_str(toml_in).expect("parse");
match parsed {
LlmSettings::Anthropic {
ref api_key_env,
ref model,
} => {
assert_eq!(api_key_env, "ANTHROPIC_API_KEY");
assert_eq!(model, "claude-sonnet-4-6");
}
other => panic!("expected Anthropic, got {other:?}"),
}
let serialized = toml::to_string(&parsed).expect("serialize");
let reparsed: LlmSettings = toml::from_str(&serialized).expect("reparse");
assert_eq!(parsed, reparsed);
}
#[test]
fn llm_settings_openai_with_custom_model_and_env() {
let toml_in = r#"
mode = "openai"
api_key_env = "MY_OAI_KEY"
model = "gpt-5o-mini"
"#;
let parsed: LlmSettings = toml::from_str(toml_in).expect("parse");
assert_eq!(
parsed,
LlmSettings::Openai {
api_key_env: "MY_OAI_KEY".into(),
model: "gpt-5o-mini".into(),
}
);
assert_eq!(parsed.mode_str(), "openai");
assert!(parsed.is_real_llm());
assert!(!parsed.requires_mcp_peer());
}
#[test]
fn llm_settings_ollama_round_trip_with_defaults() {
let toml_in = r#"mode = "ollama""#;
let parsed: LlmSettings = toml::from_str(toml_in).expect("parse");
match parsed {
LlmSettings::Ollama {
ref base_url,
ref model,
} => {
assert_eq!(base_url, "http://localhost:11434");
assert_eq!(model, "qwen3-coder:30b");
}
other => panic!("expected Ollama, got {other:?}"),
}
}
#[test]
fn llm_settings_none_round_trips_and_short_circuits() {
let toml_in = r#"mode = "none""#;
let parsed: LlmSettings = toml::from_str(toml_in).expect("parse");
assert_eq!(parsed, LlmSettings::None);
assert!(!parsed.is_real_llm());
assert!(!parsed.requires_mcp_peer());
assert_eq!(parsed.mode_str(), "none");
assert_eq!(LlmSettings::default(), LlmSettings::None);
}
#[test]
fn llm_settings_mcp_sampling_parses_and_requires_peer() {
let toml_in = r#"mode = "mcp_sampling""#;
let parsed: LlmSettings = toml::from_str(toml_in).expect("parse");
assert_eq!(parsed, LlmSettings::McpSampling);
assert!(parsed.is_real_llm());
assert!(parsed.requires_mcp_peer());
assert_eq!(parsed.mode_str(), "mcp_sampling");
}
#[test]
fn llm_settings_unknown_mode_rejects() {
let toml_in = r#"mode = "qwerty""#;
let err = toml::from_str::<LlmSettings>(toml_in).unwrap_err();
let s = err.to_string();
assert!(
s.contains("unknown variant") || s.contains("qwerty"),
"expected unknown-variant error; got: {s}"
);
}
#[test]
fn llm_settings_validate_against_transport_rejects_sampling_without_mcp() {
let cfg = LlmSettings::McpSampling;
let err = cfg
.validate_against_transport(false)
.expect_err("mcp_sampling without MCP transport must reject");
let msg = err.to_string();
assert!(
msg.contains("mcp_sampling"),
"error must name the offending mode; got: {msg}"
);
assert!(
msg.contains("anthropic") && msg.contains("openai") && msg.contains("ollama") && msg.contains("none"),
"error must list all 4 alternative modes for actionable recovery; got: {msg}"
);
}
#[test]
fn llm_settings_validate_against_transport_allows_sampling_when_mcp_available() {
let cfg = LlmSettings::McpSampling;
cfg.validate_against_transport(true)
.expect("mcp_sampling with MCP transport must validate");
}
#[test]
fn llm_settings_validate_against_transport_no_op_for_static_backends() {
for cfg in [
LlmSettings::None,
LlmSettings::Anthropic {
api_key_env: "X".into(),
model: "y".into(),
},
LlmSettings::Openai {
api_key_env: "X".into(),
model: "y".into(),
},
LlmSettings::Ollama {
base_url: "http://x".into(),
model: "y".into(),
},
] {
cfg.validate_against_transport(false)
.expect("static backend must validate without MCP transport");
cfg.validate_against_transport(true)
.expect("static backend must validate with MCP transport too");
}
}
#[test]
fn solo_config_round_trips_with_llm_block() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("solo.config.toml");
std::fs::write(
&path,
format!(
r#"
schema_version = {CONFIG_SCHEMA_VERSION}
salt_hex = "00000000000000000000000000000000"
[embedder]
name = "bge-m3"
version = "v1.0"
dim = 1024
dtype = "f32"
[llm]
mode = "anthropic"
api_key_env = "ANTHROPIC_API_KEY"
model = "claude-sonnet-4-6"
"#
),
)
.unwrap();
let cfg = SoloConfig::read(&path).expect("read ok");
assert_eq!(
cfg.llm,
Some(LlmSettings::Anthropic {
api_key_env: "ANTHROPIC_API_KEY".into(),
model: "claude-sonnet-4-6".into(),
})
);
}
#[test]
fn solo_config_defaults_llm_to_none_when_block_absent() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("solo.config.toml");
std::fs::write(
&path,
format!(
r#"
schema_version = {CONFIG_SCHEMA_VERSION}
salt_hex = "00000000000000000000000000000000"
[embedder]
name = "bge-m3"
version = "v1.0"
dim = 1024
dtype = "f32"
"#
),
)
.unwrap();
let cfg = SoloConfig::read(&path).expect("read ok");
assert!(
cfg.llm.is_none(),
"missing [llm] block must deserialize as None (env-var fallback path)"
);
}
#[test]
fn triples_config_default_matches_plan_defaults() {
let t = TriplesConfig::default();
assert_eq!(t.trigger_interval_secs, 3600, "MINOR 1: hourly cadence");
assert_eq!(t.trigger_episode_count, 50);
assert_eq!(
t.consolidate_interval_secs, 3600,
"NEW finding #7: TOML-level default flipped to 3600 for new installs"
);
assert_eq!(
t.cluster_timeout_secs, 60,
"v0.10.1 m5: per-cluster LLM call inside extract_triples_batch \
gets a 60-second default ceiling"
);
}
#[test]
fn solo_config_loads_custom_cluster_timeout_secs() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("solo.config.toml");
std::fs::write(
&path,
format!(
r#"
schema_version = {CONFIG_SCHEMA_VERSION}
salt_hex = "00000000000000000000000000000000"
[embedder]
name = "bge-m3"
version = "v1.0"
dim = 1024
dtype = "f32"
[triples]
cluster_timeout_secs = 10
"#
),
)
.unwrap();
let cfg = SoloConfig::read(&path).expect("read ok");
assert_eq!(cfg.triples.cluster_timeout_secs, 10);
assert_eq!(cfg.triples.trigger_interval_secs, 3600);
assert_eq!(cfg.triples.trigger_episode_count, 50);
}
#[test]
fn solo_config_defaults_triples_block_when_absent() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("solo.config.toml");
std::fs::write(
&path,
format!(
r#"
schema_version = {CONFIG_SCHEMA_VERSION}
salt_hex = "00000000000000000000000000000000"
[embedder]
name = "bge-m3"
version = "v1.0"
dim = 1024
dtype = "f32"
"#
),
)
.unwrap();
let cfg = SoloConfig::read(&path).expect("read ok");
assert_eq!(cfg.triples, TriplesConfig::default());
}
#[test]
fn solo_config_loads_custom_triples_block() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("solo.config.toml");
std::fs::write(
&path,
format!(
r#"
schema_version = {CONFIG_SCHEMA_VERSION}
salt_hex = "00000000000000000000000000000000"
[embedder]
name = "bge-m3"
version = "v1.0"
dim = 1024
dtype = "f32"
[triples]
trigger_interval_secs = 900
trigger_episode_count = 25
consolidate_interval_secs = 1800
cluster_timeout_secs = 45
"#
),
)
.unwrap();
let cfg = SoloConfig::read(&path).expect("read ok");
assert_eq!(cfg.triples.trigger_interval_secs, 900);
assert_eq!(cfg.triples.trigger_episode_count, 25);
assert_eq!(cfg.triples.consolidate_interval_secs, 1800);
assert_eq!(cfg.triples.cluster_timeout_secs, 45);
}
#[test]
fn solo_config_triples_partial_keeps_other_defaults() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("solo.config.toml");
std::fs::write(
&path,
format!(
r#"
schema_version = {CONFIG_SCHEMA_VERSION}
salt_hex = "00000000000000000000000000000000"
[embedder]
name = "bge-m3"
version = "v1.0"
dim = 1024
dtype = "f32"
[triples]
trigger_episode_count = 10
"#
),
)
.unwrap();
let cfg = SoloConfig::read(&path).expect("read ok");
assert_eq!(cfg.triples.trigger_episode_count, 10);
assert_eq!(cfg.triples.trigger_interval_secs, 3600);
assert_eq!(cfg.triples.consolidate_interval_secs, 3600);
assert_eq!(cfg.triples.cluster_timeout_secs, 60);
}
#[test]
fn sampling_config_default_matches_plan_defaults() {
let s = SamplingConfig::default();
assert_eq!(s.coalesce_window_ms, 5000);
assert_eq!(s.coalesce_max_requests, 10);
}
#[test]
fn solo_config_defaults_sampling_block_when_absent() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("solo.config.toml");
std::fs::write(
&path,
format!(
r#"
schema_version = {CONFIG_SCHEMA_VERSION}
salt_hex = "00000000000000000000000000000000"
[embedder]
name = "bge-m3"
version = "v1.0"
dim = 1024
dtype = "f32"
"#
),
)
.unwrap();
let cfg = SoloConfig::read(&path).expect("read ok");
assert_eq!(cfg.sampling, SamplingConfig::default());
}
#[test]
fn solo_config_loads_custom_sampling_block() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("solo.config.toml");
std::fs::write(
&path,
format!(
r#"
schema_version = {CONFIG_SCHEMA_VERSION}
salt_hex = "00000000000000000000000000000000"
[embedder]
name = "bge-m3"
version = "v1.0"
dim = 1024
dtype = "f32"
[sampling]
coalesce_window_ms = 1500
coalesce_max_requests = 5
"#
),
)
.unwrap();
let cfg = SoloConfig::read(&path).expect("read ok");
assert_eq!(cfg.sampling.coalesce_window_ms, 1500);
assert_eq!(cfg.sampling.coalesce_max_requests, 5);
}
#[test]
fn solo_config_sampling_partial_keeps_other_defaults() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("solo.config.toml");
std::fs::write(
&path,
format!(
r#"
schema_version = {CONFIG_SCHEMA_VERSION}
salt_hex = "00000000000000000000000000000000"
[embedder]
name = "bge-m3"
version = "v1.0"
dim = 1024
dtype = "f32"
[sampling]
coalesce_max_requests = 25
"#
),
)
.unwrap();
let cfg = SoloConfig::read(&path).expect("read ok");
assert_eq!(cfg.sampling.coalesce_max_requests, 25);
assert_eq!(
cfg.sampling.coalesce_window_ms, 5000,
"unset knob falls back to default"
);
}
#[test]
fn sampling_config_diagnostic_classifies_edge_values() {
assert_eq!(
SamplingConfig::default().diagnostic(),
SamplingConfigDiagnostic::Ok
);
let disabled = SamplingConfig {
coalesce_window_ms: 0,
coalesce_max_requests: 0,
};
assert_eq!(disabled.diagnostic(), SamplingConfigDiagnostic::Warn);
let disabled_clamped = SamplingConfig {
coalesce_window_ms: 0,
coalesce_max_requests: 1,
};
assert_eq!(
disabled_clamped.diagnostic(),
SamplingConfigDiagnostic::Warn
);
let info_window = SamplingConfig {
coalesce_window_ms: 0,
coalesce_max_requests: 10,
};
assert_eq!(info_window.diagnostic(), SamplingConfigDiagnostic::Info);
let info_max = SamplingConfig {
coalesce_window_ms: 5000,
coalesce_max_requests: 0,
};
assert_eq!(info_max.diagnostic(), SamplingConfigDiagnostic::Info);
let custom = SamplingConfig {
coalesce_window_ms: 250,
coalesce_max_requests: 3,
};
assert_eq!(custom.diagnostic(), SamplingConfigDiagnostic::Ok);
}
#[test]
fn sampling_config_warn_on_edge_values_does_not_panic() {
SamplingConfig::default().warn_on_edge_values();
SamplingConfig {
coalesce_window_ms: 0,
coalesce_max_requests: 0,
}
.warn_on_edge_values();
SamplingConfig {
coalesce_window_ms: 0,
coalesce_max_requests: 10,
}
.warn_on_edge_values();
SamplingConfig {
coalesce_window_ms: 5000,
coalesce_max_requests: 0,
}
.warn_on_edge_values();
}
#[test]
fn solo_config_read_accepts_disabled_coalescing_block() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("solo.config.toml");
std::fs::write(
&path,
format!(
r#"
schema_version = {CONFIG_SCHEMA_VERSION}
salt_hex = "00000000000000000000000000000000"
[embedder]
name = "bge-m3"
version = "v1.0"
dim = 1024
dtype = "f32"
[sampling]
coalesce_window_ms = 0
coalesce_max_requests = 0
"#
),
)
.unwrap();
let cfg = SoloConfig::read(&path).expect("read ok");
assert_eq!(cfg.sampling.coalesce_window_ms, 0);
assert_eq!(cfg.sampling.coalesce_max_requests, 0);
assert_eq!(
cfg.sampling.diagnostic(),
SamplingConfigDiagnostic::Warn,
"0/0 must classify Warn — the warning gets emitted to \
tracing during read()"
);
}
}