use smooth_operator_core::llm::{ApiFormat, RetryPolicy};
use smooth_operator_core::LlmConfig;
pub const DEFAULT_BIND: &str = "127.0.0.1";
pub const DEFAULT_PORT: u16 = 8787;
pub const DEFAULT_GATEWAY_URL: &str = "https://llm.smoo.ai/v1";
pub const DEFAULT_MODEL: &str = "claude-haiku-4-5";
pub const DEFAULT_MAX_ITERATIONS: u32 = 6;
pub const DEFAULT_MAX_TOKENS: u32 = 512;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StorageBackend {
Memory,
Postgres,
Dynamodb,
}
impl StorageBackend {
#[must_use]
pub fn parse(value: &str) -> Self {
match value.trim().to_ascii_lowercase().as_str() {
"postgres" | "pg" | "postgresql" => Self::Postgres,
"dynamodb" | "ddb" | "dynamo" => Self::Dynamodb,
_ => Self::Memory,
}
}
}
#[derive(Debug, Clone)]
pub struct ServerConfig {
pub bind: String,
pub port: u16,
pub gateway_url: String,
pub gateway_key: Option<String>,
pub model: String,
pub seed_kb: bool,
pub max_iterations: u32,
pub max_tokens: u32,
pub storage: StorageBackend,
pub widget_auth_strict: bool,
pub confirm_tools: Vec<String>,
}
impl ServerConfig {
#[must_use]
pub fn from_env() -> Self {
let bind = std::env::var("SMOOTH_AGENT_BIND")
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.unwrap_or_else(|| DEFAULT_BIND.to_string());
let port = std::env::var("SMOOTH_AGENT_PORT")
.ok()
.and_then(|s| s.trim().parse::<u16>().ok())
.unwrap_or(DEFAULT_PORT);
let gateway_url = std::env::var("SMOOAI_GATEWAY_URL")
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.unwrap_or_else(|| DEFAULT_GATEWAY_URL.to_string());
let gateway_key = std::env::var("SMOOAI_GATEWAY_KEY")
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
let model = std::env::var("SMOOTH_AGENT_MODEL")
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.unwrap_or_else(|| DEFAULT_MODEL.to_string());
let seed_kb = std::env::var("SMOOTH_AGENT_SEED_KB").as_deref() == Ok("1");
let max_iterations = std::env::var("SMOOTH_AGENT_MAX_ITERATIONS")
.ok()
.and_then(|s| s.trim().parse::<u32>().ok())
.filter(|n| *n > 0)
.unwrap_or(DEFAULT_MAX_ITERATIONS);
let max_tokens = std::env::var("SMOOTH_AGENT_MAX_TOKENS")
.ok()
.and_then(|s| s.trim().parse::<u32>().ok())
.filter(|n| *n > 0)
.unwrap_or(DEFAULT_MAX_TOKENS);
let storage = std::env::var("SMOOTH_AGENT_STORAGE")
.ok()
.map(|s| StorageBackend::parse(&s))
.unwrap_or(StorageBackend::Memory);
let widget_auth_strict = std::env::var("WIDGET_AUTH_STRICT")
.ok()
.map(|s| {
let s = s.trim().to_ascii_lowercase();
s == "1" || s == "true" || s == "yes"
})
.unwrap_or(false);
let confirm_tools = std::env::var("SMOOTH_AGENT_CONFIRM_TOOLS")
.ok()
.map(|s| parse_confirm_tools(&s))
.unwrap_or_default();
Self {
bind,
port,
gateway_url,
gateway_key,
model,
seed_kb,
max_iterations,
max_tokens,
storage,
widget_auth_strict,
confirm_tools,
}
}
#[must_use]
pub fn confirmation_tool_patterns(&self) -> Option<Vec<String>> {
if self.confirm_tools.is_empty() {
None
} else {
Some(self.confirm_tools.clone())
}
}
#[must_use]
pub fn has_llm(&self) -> bool {
self.gateway_key.is_some()
}
#[must_use]
pub fn llm_config(&self) -> Option<LlmConfig> {
let key = self.gateway_key.clone()?;
Some(self.llm_config_with_key(key))
}
#[must_use]
pub fn llm_config_with_key(&self, key: String) -> LlmConfig {
LlmConfig {
api_url: self.gateway_url.clone(),
api_key: key,
model: self.model.clone(),
max_tokens: self.max_tokens,
temperature: 0.0,
retry_policy: RetryPolicy::default(),
api_format: ApiFormat::OpenAiCompat,
}
}
#[must_use]
pub fn placeholder_llm_config(&self) -> LlmConfig {
LlmConfig {
api_url: self.gateway_url.clone(),
api_key: "mock-no-network".to_string(),
model: self.model.clone(),
max_tokens: self.max_tokens,
temperature: 0.0,
retry_policy: RetryPolicy::default(),
api_format: ApiFormat::OpenAiCompat,
}
}
}
fn parse_confirm_tools(raw: &str) -> Vec<String> {
raw.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn defaults_apply_when_env_absent() {
let cfg = ServerConfig {
bind: DEFAULT_BIND.to_string(),
port: DEFAULT_PORT,
gateway_url: DEFAULT_GATEWAY_URL.to_string(),
gateway_key: None,
model: DEFAULT_MODEL.to_string(),
seed_kb: false,
max_iterations: DEFAULT_MAX_ITERATIONS,
max_tokens: DEFAULT_MAX_TOKENS,
storage: StorageBackend::Memory,
widget_auth_strict: false,
confirm_tools: Vec::new(),
};
assert_eq!(cfg.port, 8787);
assert_eq!(cfg.storage, StorageBackend::Memory);
assert_eq!(cfg.gateway_url, "https://llm.smoo.ai/v1");
assert_eq!(cfg.model, "claude-haiku-4-5");
assert!(!cfg.has_llm());
assert!(cfg.llm_config().is_none());
}
#[test]
fn llm_config_built_when_key_present() {
let cfg = ServerConfig {
bind: DEFAULT_BIND.to_string(),
port: 1,
gateway_url: "https://example.test/v1".into(),
gateway_key: Some("sk-test".into()),
model: "m".into(),
seed_kb: false,
max_iterations: 4,
max_tokens: 128,
storage: StorageBackend::Memory,
widget_auth_strict: false,
confirm_tools: Vec::new(),
};
assert!(cfg.has_llm());
let llm = cfg.llm_config().expect("llm config");
assert_eq!(llm.api_url, "https://example.test/v1");
assert_eq!(llm.model, "m");
assert_eq!(llm.max_tokens, 128);
assert!(matches!(llm.api_format, ApiFormat::OpenAiCompat));
}
#[test]
fn storage_backend_parse_maps_aliases_and_defaults_memory() {
assert_eq!(StorageBackend::parse("postgres"), StorageBackend::Postgres);
assert_eq!(StorageBackend::parse(" PG "), StorageBackend::Postgres);
assert_eq!(
StorageBackend::parse("PostgreSQL"),
StorageBackend::Postgres
);
assert_eq!(StorageBackend::parse("dynamodb"), StorageBackend::Dynamodb);
assert_eq!(StorageBackend::parse("ddb"), StorageBackend::Dynamodb);
assert_eq!(StorageBackend::parse("Dynamo"), StorageBackend::Dynamodb);
assert_eq!(StorageBackend::parse("memory"), StorageBackend::Memory);
assert_eq!(StorageBackend::parse("sqlite"), StorageBackend::Memory);
assert_eq!(StorageBackend::parse(""), StorageBackend::Memory);
}
}