1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3use std::time::Duration;
4
5use crate::error::Error;
6use crate::tool::McpServerEntry;
7
8pub const AGENTIC_API_HOME_ENV: &str = "AGENTIC_API_HOME";
9pub const CONFIG_FILE_NAME: &str = "config.toml";
10pub const DATABASE_FILE_NAME: &str = "agentic_api.db";
11
12pub const DEFAULT_POSTGRES_MAX_CONNECTIONS: u32 = 10;
13pub const DEFAULT_POSTGRES_ACQUIRE_TIMEOUT_SECONDS: u64 = 30;
14pub const DEFAULT_POSTGRES_IDLE_TIMEOUT_SECONDS: u64 = 600;
15pub const DEFAULT_POSTGRES_LOCK_TIMEOUT_SECONDS: u64 = 5;
16pub const DEFAULT_POSTGRES_MAX_LIFETIME_SECONDS: u64 = 1_800;
17pub const DEFAULT_POSTGRES_MIGRATION_TIMEOUT_SECONDS: u64 = 300;
18pub const DEFAULT_POSTGRES_STATEMENT_TIMEOUT_SECONDS: u64 = 30;
19pub const DEFAULT_SQLITE_MAX_CONNECTIONS: u32 = 4;
20pub const DEFAULT_SQLITE_JOURNAL_SIZE_LIMIT_BYTES: u64 = 6_144_000;
21pub const DEFAULT_SQLITE_MMAP_SIZE_BYTES: u64 = 268_435_456;
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub struct PostgresConfig {
25 pub max_connections: u32,
26 pub acquire_timeout: Duration,
27 pub lock_timeout: Duration,
28 pub migration_timeout: Duration,
29 pub statement_timeout: Duration,
30 pub idle_timeout: Option<Duration>,
31 pub max_lifetime: Option<Duration>,
32}
33
34impl Default for PostgresConfig {
35 fn default() -> Self {
36 Self {
37 max_connections: DEFAULT_POSTGRES_MAX_CONNECTIONS,
38 acquire_timeout: Duration::from_secs(DEFAULT_POSTGRES_ACQUIRE_TIMEOUT_SECONDS),
39 lock_timeout: Duration::from_secs(DEFAULT_POSTGRES_LOCK_TIMEOUT_SECONDS),
40 migration_timeout: Duration::from_secs(DEFAULT_POSTGRES_MIGRATION_TIMEOUT_SECONDS),
41 statement_timeout: Duration::from_secs(DEFAULT_POSTGRES_STATEMENT_TIMEOUT_SECONDS),
42 idle_timeout: Some(Duration::from_secs(DEFAULT_POSTGRES_IDLE_TIMEOUT_SECONDS)),
43 max_lifetime: Some(Duration::from_secs(DEFAULT_POSTGRES_MAX_LIFETIME_SECONDS)),
44 }
45 }
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
49pub enum SqliteTempStore {
50 Default,
51 File,
52 #[default]
53 Memory,
54}
55
56impl SqliteTempStore {
57 #[must_use]
58 pub fn as_pragma_value(self) -> &'static str {
59 match self {
60 Self::Default => "DEFAULT",
61 Self::File => "FILE",
62 Self::Memory => "MEMORY",
63 }
64 }
65}
66
67#[derive(Debug, Clone, Copy, PartialEq, Eq)]
68pub struct SqliteConfig {
69 pub max_connections: u32,
70 pub journal_size_limit_bytes: u64,
71 pub temp_store: SqliteTempStore,
72 pub mmap_size_bytes: u64,
73}
74
75impl Default for SqliteConfig {
76 fn default() -> Self {
77 Self {
78 max_connections: DEFAULT_SQLITE_MAX_CONNECTIONS,
79 journal_size_limit_bytes: DEFAULT_SQLITE_JOURNAL_SIZE_LIMIT_BYTES,
80 temp_store: SqliteTempStore::default(),
81 mmap_size_bytes: DEFAULT_SQLITE_MMAP_SIZE_BYTES,
82 }
83 }
84}
85
86#[derive(Debug, Clone, Default)]
87pub struct WebSearchProviderConfig {
88 pub api_key: Option<String>,
89 pub base_url: Option<String>,
90}
91
92#[derive(Debug, Clone, Default)]
93pub struct ToolRuntimeConfig {
94 pub web_search: WebSearchProviderConfig,
95 pub mcp_servers: HashMap<String, McpServerEntry>,
96 pub mcp_allowed_hosts: Vec<String>,
97 pub messages_gateway_tool_aliases: Option<String>,
98}
99
100#[derive(Debug, Clone)]
101pub struct Config {
102 pub llm_api_base: String,
103 pub openai_api_key: Option<String>,
104 pub llm_ready_timeout_s: f64,
105 pub llm_ready_interval_s: f64,
106 pub skip_llm_ready_check: bool,
107 pub db_url: Option<String>,
110 pub postgres: PostgresConfig,
111 pub sqlite: SqliteConfig,
112 pub tools: ToolRuntimeConfig,
113}
114
115pub fn agentic_api_home() -> Result<PathBuf, Error> {
125 let configured = std::env::var_os(AGENTIC_API_HOME_ENV).filter(|value| !value.is_empty());
126 resolve_agentic_api_home(configured.map(PathBuf::from), dirs::home_dir())
127}
128
129fn resolve_agentic_api_home(configured: Option<PathBuf>, user_home: Option<PathBuf>) -> Result<PathBuf, Error> {
130 if let Some(path) = configured {
131 if !path.is_absolute() {
132 return Err(Error::Config(format!(
133 "{AGENTIC_API_HOME_ENV} must be an absolute path: {}",
134 path.display()
135 )));
136 }
137 return Ok(path);
138 }
139
140 let user_home = user_home.ok_or_else(|| Error::Config("could not determine the user home directory".to_owned()))?;
141 if !user_home.is_absolute() {
142 return Err(Error::Config(format!(
143 "user home directory must be an absolute path: {}",
144 user_home.display()
145 )));
146 }
147 Ok(user_home.join(".agentic-api"))
148}
149
150pub fn ensure_agentic_api_home() -> Result<PathBuf, Error> {
157 let path = agentic_api_home()?;
158 std::fs::create_dir_all(&path).map_err(|error| {
159 Error::Config(format!(
160 "failed to create Agentic API home directory {}: {error}",
161 path.display()
162 ))
163 })?;
164 if !path.is_dir() {
165 return Err(Error::Config(format!(
166 "Agentic API home path is not a directory: {}",
167 path.display()
168 )));
169 }
170 Ok(path)
171}
172
173pub fn default_database_url() -> Result<String, Error> {
179 default_database_url_in(&ensure_agentic_api_home()?)
180}
181
182fn default_database_url_in(home: &Path) -> Result<String, Error> {
183 const SQLITE_PATH_ENCODE_SET: &percent_encoding::AsciiSet = &percent_encoding::CONTROLS
184 .add(b' ')
185 .add(b'"')
186 .add(b'#')
187 .add(b'<')
188 .add(b'>')
189 .add(b'?')
190 .add(b'%')
191 .add(b'`')
192 .add(b'{')
193 .add(b'}');
194
195 let path = home.join(DATABASE_FILE_NAME);
196 let path = path
197 .to_str()
198 .ok_or_else(|| Error::Config(format!("default database path is not valid UTF-8: {}", path.display())))?;
199 #[cfg(windows)]
200 let path = path.replace('\\', "/");
201 let encoded = percent_encoding::utf8_percent_encode(path, SQLITE_PATH_ENCODE_SET);
202 Ok(format!("sqlite://{encoded}"))
203}
204
205#[must_use]
206pub fn normalize_base_url(url: &str) -> String {
207 let mut s = url.trim_end_matches('/').to_owned();
208 if s.ends_with("/v1") {
209 s.truncate(s.len() - 3);
210 s = s.trim_end_matches('/').to_owned();
211 }
212 s
213}
214
215#[cfg(test)]
216mod tests {
217 use super::*;
218 use std::path::PathBuf;
219
220 #[test]
221 fn strip_trailing_v1() {
222 assert_eq!(normalize_base_url("http://host:8000/v1"), "http://host:8000");
223 assert_eq!(normalize_base_url("http://host:8000/v1/"), "http://host:8000");
224 }
225
226 #[test]
227 fn no_v1_unchanged() {
228 assert_eq!(normalize_base_url("http://host:8000"), "http://host:8000");
229 assert_eq!(normalize_base_url("http://host:8000/"), "http://host:8000");
230 }
231
232 #[test]
233 fn home_override_takes_precedence() {
234 let configured = if cfg!(windows) {
235 PathBuf::from(r"C:\agentic-home")
236 } else {
237 PathBuf::from("/tmp/agentic-home")
238 };
239 let resolved = resolve_agentic_api_home(Some(configured.clone()), Some(PathBuf::from("/ignored")))
240 .expect("absolute configured home");
241 assert_eq!(resolved, configured);
242 }
243
244 #[test]
245 fn default_home_is_hidden_directory() {
246 let user_home = if cfg!(windows) {
247 PathBuf::from(r"C:\Users\agentic")
248 } else {
249 PathBuf::from("/home/agentic")
250 };
251 let resolved = resolve_agentic_api_home(None, Some(user_home.clone())).expect("user home");
252 assert_eq!(resolved, user_home.join(".agentic-api"));
253 }
254
255 #[test]
256 fn relative_home_override_is_rejected() {
257 let error = resolve_agentic_api_home(Some(PathBuf::from("relative")), Some(PathBuf::from("/home/agentic")))
258 .expect_err("relative override must fail");
259 assert!(error.to_string().contains("must be an absolute path"));
260 }
261
262 #[test]
263 fn database_url_uses_home_directory() {
264 let home = if cfg!(windows) {
265 PathBuf::from(r"C:\Users\agentic\.agentic-api")
266 } else {
267 PathBuf::from("/home/agentic/.agentic-api")
268 };
269 let url = default_database_url_in(&home).expect("database URL");
270 assert!(url.starts_with("sqlite://"));
271 assert!(url.ends_with("/.agentic-api/agentic_api.db"));
272 }
273
274 #[test]
275 fn database_url_encodes_url_delimiters_in_home_path() {
276 let home = if cfg!(windows) {
277 PathBuf::from(r"C:\Users\agentic api\state?#%")
278 } else {
279 PathBuf::from("/home/agentic api/state?#%")
280 };
281 let url = default_database_url_in(&home).expect("database URL");
282 assert!(url.contains("agentic%20api"));
283 assert!(url.contains("state%3F%23%25"));
284 }
285}