Skip to main content

synapse/
config.rs

1//! Layered runtime config (env + file paths). Env takes precedence.
2
3use 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    /// Provider credentials/base-urls, read straight from the env map.
21    pub env: HashMap<String, String>,
22    /// How to treat a route whose provider this process cannot satisfy.
23    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/// What to do when a route table references a provider this process cannot build —
71/// a credential absent from the env, or an id this binary does not recognise.
72///
73/// `Strict` (the default) refuses to start. That is the right default for a
74/// dedicated gateway: a route table it cannot fully serve is a deploy mistake,
75/// and failing at boot surfaces it immediately.
76///
77/// `Lenient` drops the legs it cannot serve, keeps the rest of each route, and
78/// starts. It exists because one route table is commonly shared by several
79/// processes — a gateway plus in-process consumers — and a leg added for one of
80/// them should not take the others down. A route left with no legs disappears
81/// from the table, so calling it is a 404 rather than a crash.
82#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
83pub enum ProviderValidation {
84    #[default]
85    Strict,
86    Lenient,
87}
88
89impl ProviderValidation {
90    /// `SYNAPSE_PROVIDER_VALIDATION=lenient` opts in; anything else is strict.
91    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
103/// Resolve the GCP project id for Vertex from an env map.
104/// `VERTEX_PROJECT_ID` is preferred; `VERTEX_PROJECT` is accepted for compatibility.
105pub 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            // An unrecognised value must not silently loosen validation.
177            (
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        // An alias absent from the shipped taxonomy still resolves, to the default.
309        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}