use serde::{Deserialize, Serialize};
use std::collections::HashMap;
pub const DEFAULT_API_BIND: &str = "127.0.0.1:3456";
pub const API_URL_ENV: &str = "KOWALSKI_API";
pub const API_TOKEN_ENV: &str = "KOWALSKI_API_TOKEN";
pub const RUN_DB_ENV: &str = "KOWALSKI_RUN_DB";
pub const CLI_BIN_ENV: &str = "KOWALSKI_CLI_BIN";
pub const CONFIG_ENV: &str = "KOWALSKI_CONFIG";
pub fn default_config_path() -> std::path::PathBuf {
use std::path::PathBuf;
if let Ok(p) = std::env::var(CONFIG_ENV)
&& !p.trim().is_empty()
{
return PathBuf::from(p.trim());
}
let local = PathBuf::from("config.toml");
if local.is_file() {
return local;
}
if let Some(user) = user_config_path()
&& user.is_file()
{
return user;
}
local
}
pub fn user_config_path() -> Option<std::path::PathBuf> {
use std::path::PathBuf;
let base = std::env::var("XDG_CONFIG_HOME")
.ok()
.filter(|v| !v.trim().is_empty())
.map(PathBuf::from)
.or_else(|| std::env::var("HOME").ok().map(|h| PathBuf::from(h).join(".config")))?;
Some(base.join("kowalski").join("config.toml"))
}
pub const HORDES_DIR_ENV: &str = "KOWALSKI_HORDES_DIR";
pub fn default_horde_roots(config_dir: Option<&std::path::Path>) -> Vec<std::path::PathBuf> {
use std::path::PathBuf;
let mut roots = Vec::new();
if let Ok(env) = std::env::var(HORDES_DIR_ENV) {
for piece in env.split(':') {
if !piece.trim().is_empty() {
roots.push(PathBuf::from(piece.trim()));
}
}
}
if let Some(c) = config_dir {
roots.push(c.join("hordes"));
if let Some(parent) = c.parent() {
roots.push(parent.join("examples"));
}
}
if let Ok(cwd) = std::env::current_dir() {
roots.push(cwd.join("examples"));
}
let mut seen = std::collections::HashSet::new();
roots.retain(|p| seen.insert(p.clone()));
roots
}
pub fn user_hordes_root(config_dir: Option<&std::path::Path>) -> Option<std::path::PathBuf> {
if let Ok(env) = std::env::var(HORDES_DIR_ENV) {
for piece in env.split(':') {
if !piece.trim().is_empty() {
return Some(std::path::PathBuf::from(piece.trim()));
}
}
}
config_dir.map(|c| c.join("hordes"))
}
pub fn ollama_base_url(config: &Config) -> String {
format!("http://{}:{}", config.ollama.host, config.ollama.port)
}
pub fn default_model(config: &Config) -> String {
if config.llm.provider == "openai"
&& let Some(ref model) = config.llm.model
{
return model.clone();
}
config.ollama.model.clone()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct Config {
pub ollama: OllamaConfig,
pub chat: ChatConfig,
pub memory: MemoryConfig,
pub working_memory_retrieval_limit: usize,
pub episodic_memory_retrieval_limit: usize,
pub semantic_memory_retrieval_limit: usize,
#[serde(default)]
pub llm: LLMConfig,
#[serde(default)]
pub mcp: McpConfig,
#[serde(flatten)]
pub additional: HashMap<String, serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LLMConfig {
pub provider: String,
#[serde(default = "openai_key_from_env")]
pub openai_api_key: Option<String>,
#[serde(default)]
pub openai_api_base: Option<String>,
#[serde(default)]
pub model: Option<String>,
#[serde(default = "default_embeddings_provider")]
pub embeddings_provider: String,
#[serde(default)]
pub native_tools: bool,
#[serde(default)]
pub tool_calling: ToolCallingMode,
#[serde(default)]
pub structured_output: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum ToolCallingMode {
#[default]
Auto,
Native,
React,
}
fn openai_key_from_env() -> Option<String> {
std::env::var("OPENAI_API_KEY").ok().filter(|k| !k.is_empty())
}
fn default_embeddings_provider() -> String {
"llm".to_string()
}
impl Default for LLMConfig {
fn default() -> Self {
Self {
provider: "ollama".to_string(),
openai_api_key: openai_key_from_env(),
openai_api_base: None,
model: None,
embeddings_provider: "llm".to_string(),
native_tools: false,
tool_calling: ToolCallingMode::default(),
structured_output: false,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct OllamaConfig {
pub host: String,
pub port: u16,
pub model: String,
#[serde(flatten)]
pub additional: HashMap<String, serde_json::Value>,
}
impl Default for OllamaConfig {
fn default() -> Self {
Self {
host: "localhost".to_string(),
port: 11434,
model: "llama3.2".to_string(), additional: HashMap::new(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct ChatConfig {
pub max_history: usize,
#[serde(alias = "stream")]
pub enable_streaming: bool,
pub temperature: f32,
pub max_tokens: u32,
#[serde(flatten)]
pub additional: HashMap<String, serde_json::Value>,
}
impl Default for ChatConfig {
fn default() -> Self {
Self {
max_history: 100,
enable_streaming: true,
temperature: 0.7,
max_tokens: 2048,
additional: HashMap::new(),
}
}
}
fn default_embedding_vector_dimensions() -> usize {
768
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct MemoryConfig {
pub episodic_path: String,
#[serde(default)]
pub database_url: Option<String>,
#[serde(default = "default_embedding_vector_dimensions")]
pub embedding_vector_dimensions: usize,
#[serde(flatten)]
pub additional: HashMap<String, serde_json::Value>,
}
impl Default for MemoryConfig {
fn default() -> Self {
Self {
episodic_path: "../target/episodic_db".to_string(), database_url: None,
embedding_vector_dimensions: default_embedding_vector_dimensions(),
additional: HashMap::new(),
}
}
}
pub fn memory_uses_postgres(memory: &MemoryConfig) -> bool {
memory
.database_url
.as_ref()
.is_some_and(|u| u.starts_with("postgres://") || u.starts_with("postgresql://"))
}
#[cfg(test)]
mod postgres_flag_tests {
use super::{MemoryConfig, memory_uses_postgres};
#[test]
fn memory_uses_postgres_detects_url() {
let mut m = MemoryConfig::default();
assert!(!memory_uses_postgres(&m));
m.database_url = Some("postgres://localhost/db".to_string());
assert!(memory_uses_postgres(&m));
m.database_url = Some("postgresql://localhost/db".to_string());
assert!(memory_uses_postgres(&m));
}
}
pub fn postgres_feature_required_error() -> crate::error::KowalskiError {
crate::error::KowalskiError::Configuration(
"PostgreSQL support requires building with `--features postgres` (e.g. `cargo build -p kowalski-core --features postgres` or `cargo build -p kowalski-cli --features postgres`).".to_string(),
)
}
pub trait ConfigExt {
fn core(&self) -> &Config;
fn core_mut(&mut self) -> &mut Config;
fn get_additional<T: serde::de::DeserializeOwned>(&self, key: &str) -> Option<T> {
self.core()
.additional
.get(key)
.and_then(|v| serde_json::from_value(v.clone()).ok())
}
fn set_additional<T: serde::Serialize>(&mut self, key: &str, value: T) {
if let Ok(json) = serde_json::to_value(value) {
self.core_mut().additional.insert(key.to_string(), json);
}
}
}
impl Default for Config {
fn default() -> Self {
Self {
ollama: OllamaConfig::default(),
llm: LLMConfig::default(),
mcp: McpConfig::default(),
chat: ChatConfig::default(),
memory: MemoryConfig::default(),
working_memory_retrieval_limit: 3,
episodic_memory_retrieval_limit: 3,
semantic_memory_retrieval_limit: 3,
additional: HashMap::new(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct McpConfig {
#[serde(default)]
pub servers: Vec<McpServerConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpServerConfig {
pub name: String,
#[serde(default)]
pub url: String,
#[serde(default)]
pub transport: McpTransport,
#[serde(default)]
pub headers: HashMap<String, String>,
#[serde(default)]
pub command: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub oauth: Option<McpOAuthConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct McpOAuthConfig {
pub token_file: std::path::PathBuf,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[derive(Default)]
pub enum McpTransport {
#[default]
Sse,
Http,
Stdio,
}