1use std::collections::HashMap;
4use std::time::Duration;
5
6#[derive(Debug, Clone)]
7pub struct Config {
8 pub addr: String,
9 pub metrics_addr: String,
10 pub routes_path: String,
11 pub pricing_path: String,
12 pub guardrails_path: String,
13 pub a2a_path: String,
14 pub ai_task_types_path: String,
15 pub ledger_backends: Vec<LedgerBackend>,
16 pub default_tenant: String,
17 pub request_timeout: Duration,
18 pub stream_idle_timeout: Duration,
19 pub embed_default_input_per_mtok: f64,
20 pub env: HashMap<String, String>,
22 pub provider_validation: ProviderValidation,
24}
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum LedgerBackend {
28 Sqlite,
29 Postgres,
30 Pubsub,
31 Sns,
32}
33
34impl LedgerBackend {
35 pub fn label(self) -> &'static str {
36 match self {
37 LedgerBackend::Sqlite => "sqlite",
38 LedgerBackend::Postgres => "postgres",
39 LedgerBackend::Pubsub => "pubsub",
40 LedgerBackend::Sns => "sns",
41 }
42 }
43}
44
45fn parse_ledger_backends(list: &str) -> anyhow::Result<Vec<LedgerBackend>> {
46 let mut out: Vec<LedgerBackend> = Vec::new();
47 for raw in list.split(',') {
48 let name = raw.trim().to_ascii_lowercase();
49 if name.is_empty() {
50 continue;
51 }
52 let b = match name.as_str() {
53 "sqlite" => LedgerBackend::Sqlite,
54 "postgres" => LedgerBackend::Postgres,
55 "pubsub" => LedgerBackend::Pubsub,
56 "sns" => LedgerBackend::Sns,
57 other => anyhow::bail!("unknown ledger backend '{other}' (sqlite|postgres|pubsub|sns)"),
58 };
59 if out.contains(&b) {
60 anyhow::bail!("duplicate ledger backend '{name}' in SYNAPSE_LEDGER_BACKENDS");
61 }
62 out.push(b);
63 }
64 if out.is_empty() {
65 anyhow::bail!("SYNAPSE_LEDGER_BACKENDS resolved to an empty backend list");
66 }
67 Ok(out)
68}
69
70#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
83pub enum ProviderValidation {
84 #[default]
85 Strict,
86 Lenient,
87}
88
89impl ProviderValidation {
90 pub fn from_env(env: &HashMap<String, String>) -> Self {
92 match env
93 .get("SYNAPSE_PROVIDER_VALIDATION")
94 .map(|s| s.trim().to_ascii_lowercase())
95 .as_deref()
96 {
97 Some("lenient") => Self::Lenient,
98 _ => Self::Strict,
99 }
100 }
101}
102
103pub fn vertex_project_from_env(env: &HashMap<String, String>) -> Option<String> {
106 ["VERTEX_PROJECT_ID", "VERTEX_PROJECT"]
107 .into_iter()
108 .find_map(|key| {
109 env.get(key)
110 .map(|s| s.trim())
111 .filter(|s| !s.is_empty())
112 .map(str::to_string)
113 })
114}
115
116impl Config {
117 pub fn from_env_map(env: &HashMap<String, String>) -> anyhow::Result<Self> {
118 let get = |k: &str| env.get(k).cloned().filter(|s| !s.trim().is_empty());
119 let get_or = |k: &str, d: &str| get(k).unwrap_or_else(|| d.to_string());
120 let backends_raw = get("SYNAPSE_LEDGER_BACKENDS")
121 .or_else(|| get("SYNAPSE_LEDGER_BACKEND"))
122 .unwrap_or_else(|| "sqlite".to_string());
123 let ledger_backends = parse_ledger_backends(&backends_raw)?;
124 Ok(Self {
125 addr: get_or("SYNAPSE_ADDR", "0.0.0.0:8080"),
126 metrics_addr: get_or("SYNAPSE_METRICS_ADDR", "0.0.0.0:9090"),
127 routes_path: get_or("SYNAPSE_ROUTES_PATH", "config/routes.toml"),
128 pricing_path: get_or("SYNAPSE_PRICING_PATH", "config/pricing.toml"),
129 guardrails_path: get_or("SYNAPSE_GUARDRAILS_PATH", "config/guardrails.toml"),
130 a2a_path: get_or("SYNAPSE_A2A_PATH", "config/a2a.toml"),
131 ai_task_types_path: get_or("SYNAPSE_AI_TASK_TYPES_PATH", "config/ai_task_types.toml"),
132 ledger_backends,
133 default_tenant: get_or("SYNAPSE_DEFAULT_TENANT", "unattributed"),
134 request_timeout: Duration::from_secs(
135 get_or("SYNAPSE_REQUEST_TIMEOUT_SECS", "120")
136 .parse()
137 .map_err(|e| anyhow::anyhow!("SYNAPSE_REQUEST_TIMEOUT_SECS: {e}"))?,
138 ),
139 stream_idle_timeout: Duration::from_secs(
140 get_or("SYNAPSE_STREAM_IDLE_TIMEOUT_SECS", "60")
141 .parse()
142 .map_err(|e| anyhow::anyhow!("SYNAPSE_STREAM_IDLE_TIMEOUT_SECS: {e}"))?,
143 ),
144 embed_default_input_per_mtok: get_or(
145 "SYNAPSE_EMBED_DEFAULT_INPUT_PRICE_PER_MTOK",
146 "0.10",
147 )
148 .parse()
149 .unwrap_or(0.10),
150 env: env.clone(),
151 provider_validation: ProviderValidation::from_env(env),
152 })
153 }
154}
155
156#[cfg(test)]
157mod tests {
158 use super::*;
159
160 #[test]
161 fn provider_validation_defaults_to_strict_and_opts_in_on_lenient() {
162 let cases = [
163 (vec![], ProviderValidation::Strict),
164 (
165 vec![("SYNAPSE_PROVIDER_VALIDATION", "lenient")],
166 ProviderValidation::Lenient,
167 ),
168 (
169 vec![("SYNAPSE_PROVIDER_VALIDATION", " LENIENT ")],
170 ProviderValidation::Lenient,
171 ),
172 (
173 vec![("SYNAPSE_PROVIDER_VALIDATION", "strict")],
174 ProviderValidation::Strict,
175 ),
176 (
178 vec![("SYNAPSE_PROVIDER_VALIDATION", "yes")],
179 ProviderValidation::Strict,
180 ),
181 ];
182 for (pairs, want) in cases {
183 let e = env(&pairs);
184 assert_eq!(ProviderValidation::from_env(&e), want, "for {pairs:?}");
185 assert_eq!(Config::from_env_map(&e).unwrap().provider_validation, want);
186 }
187 }
188
189 fn env(pairs: &[(&str, &str)]) -> HashMap<String, String> {
190 pairs
191 .iter()
192 .map(|(k, v)| (k.to_string(), v.to_string()))
193 .collect()
194 }
195
196 #[test]
197 fn vertex_project_from_env_prefers_project_id() {
198 let env = env(&[
199 ("VERTEX_PROJECT_ID", "from-id"),
200 ("VERTEX_PROJECT", "from-legacy"),
201 ]);
202 assert_eq!(vertex_project_from_env(&env).as_deref(), Some("from-id"));
203 }
204
205 #[test]
206 fn vertex_project_from_env_falls_back_to_vertex_project() {
207 let env = env(&[("VERTEX_PROJECT", "legacy-only")]);
208 assert_eq!(
209 vertex_project_from_env(&env).as_deref(),
210 Some("legacy-only")
211 );
212 }
213
214 #[test]
215 fn vertex_project_from_env_ignores_blank_values() {
216 let env = env(&[("VERTEX_PROJECT_ID", " "), ("VERTEX_PROJECT", "ok")]);
217 assert_eq!(vertex_project_from_env(&env).as_deref(), Some("ok"));
218 }
219
220 #[test]
221 fn defaults_apply_when_env_empty() {
222 let c = Config::from_env_map(&env(&[])).unwrap();
223 assert_eq!(c.addr, "0.0.0.0:8080");
224 assert_eq!(c.ledger_backends, vec![LedgerBackend::Sqlite]);
225 assert_eq!(c.default_tenant, "unattributed");
226 }
227
228 #[test]
229 fn env_overrides_and_validates_backend() {
230 let c = Config::from_env_map(&env(&[("SYNAPSE_LEDGER_BACKEND", "postgres")])).unwrap();
231 assert_eq!(c.ledger_backends, vec![LedgerBackend::Postgres]);
232 let err = Config::from_env_map(&env(&[("SYNAPSE_LEDGER_BACKEND", "mysql")])).unwrap_err();
233 assert!(err.to_string().contains("sqlite|postgres"));
234 }
235
236 #[test]
237 fn parses_stream_timeouts() {
238 let c = Config::from_env_map(&env(&[
239 ("SYNAPSE_REQUEST_TIMEOUT_SECS", "30"),
240 ("SYNAPSE_STREAM_IDLE_TIMEOUT_SECS", "45"),
241 ]))
242 .unwrap();
243 assert_eq!(c.request_timeout, std::time::Duration::from_secs(30));
244 assert_eq!(c.stream_idle_timeout, std::time::Duration::from_secs(45));
245 }
246
247 #[test]
248 fn parses_backend_list() {
249 let c =
250 Config::from_env_map(&env(&[("SYNAPSE_LEDGER_BACKENDS", "postgres, pubsub")])).unwrap();
251 assert_eq!(
252 c.ledger_backends,
253 vec![LedgerBackend::Postgres, LedgerBackend::Pubsub]
254 );
255 }
256
257 #[test]
258 fn singular_backend_is_back_compat() {
259 let c = Config::from_env_map(&env(&[("SYNAPSE_LEDGER_BACKEND", "sns")])).unwrap();
260 assert_eq!(c.ledger_backends, vec![LedgerBackend::Sns]);
261 }
262
263 #[test]
264 fn defaults_to_sqlite_list() {
265 let c = Config::from_env_map(&env(&[])).unwrap();
266 assert_eq!(c.ledger_backends, vec![LedgerBackend::Sqlite]);
267 }
268
269 #[test]
270 fn rejects_unknown_and_duplicate_backends() {
271 assert!(Config::from_env_map(&env(&[("SYNAPSE_LEDGER_BACKENDS", "mysql")])).is_err());
272 assert!(
273 Config::from_env_map(&env(&[("SYNAPSE_LEDGER_BACKENDS", "pubsub,pubsub")])).is_err()
274 );
275 }
276
277 #[test]
278 fn guardrails_path_defaults_and_overrides() {
279 let c = Config::from_env_map(&env(&[])).unwrap();
280 assert_eq!(c.guardrails_path, "config/guardrails.toml");
281 let c = Config::from_env_map(&env(&[("SYNAPSE_GUARDRAILS_PATH", "/etc/g.toml")])).unwrap();
282 assert_eq!(c.guardrails_path, "/etc/g.toml");
283 }
284
285 #[test]
286 fn a2a_path_defaults_and_overrides() {
287 let c = Config::from_env_map(&env(&[])).unwrap();
288 assert_eq!(c.a2a_path, "config/a2a.toml");
289 let c = Config::from_env_map(&env(&[("SYNAPSE_A2A_PATH", "/etc/a2a.toml")])).unwrap();
290 assert_eq!(c.a2a_path, "/etc/a2a.toml");
291 }
292
293 #[test]
294 fn ai_task_types_path_defaults_and_overrides() {
295 let c = Config::from_env_map(&env(&[])).unwrap();
296 assert_eq!(c.ai_task_types_path, "config/ai_task_types.toml");
297 let c =
298 Config::from_env_map(&env(&[("SYNAPSE_AI_TASK_TYPES_PATH", "/etc/ai.toml")])).unwrap();
299 assert_eq!(c.ai_task_types_path, "/etc/ai.toml");
300 }
301
302 #[test]
303 fn shipped_ai_task_types_sample_parses_and_covers_known_aliases() {
304 let content = std::fs::read_to_string("config/ai_task_types.toml")
305 .expect("config/ai_task_types.toml should exist");
306 let table = crate::ai_task_type::AiTaskTypeTable::from_toml_str(&content).unwrap();
307 assert_eq!(table.resolve("conversation"), "conversation");
308 assert_eq!(table.resolve("no-such-alias"), "simple");
310 }
311
312 #[test]
313 fn shipped_guardrails_sample_builds_an_engine() {
314 let content = std::fs::read_to_string("config/guardrails.toml")
315 .expect("config/guardrails.toml should exist");
316 let cfg = crate::guard::GuardrailsConfig::from_toml_str(&content).unwrap();
317 crate::guard::GuardEngine::from_config(&cfg).expect("sample policies must compile");
318 }
319}