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}
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum LedgerBackend {
26    Sqlite,
27    Postgres,
28    Pubsub,
29    Sns,
30}
31
32impl LedgerBackend {
33    pub fn label(self) -> &'static str {
34        match self {
35            LedgerBackend::Sqlite => "sqlite",
36            LedgerBackend::Postgres => "postgres",
37            LedgerBackend::Pubsub => "pubsub",
38            LedgerBackend::Sns => "sns",
39        }
40    }
41}
42
43fn parse_ledger_backends(list: &str) -> anyhow::Result<Vec<LedgerBackend>> {
44    let mut out: Vec<LedgerBackend> = Vec::new();
45    for raw in list.split(',') {
46        let name = raw.trim().to_ascii_lowercase();
47        if name.is_empty() {
48            continue;
49        }
50        let b = match name.as_str() {
51            "sqlite" => LedgerBackend::Sqlite,
52            "postgres" => LedgerBackend::Postgres,
53            "pubsub" => LedgerBackend::Pubsub,
54            "sns" => LedgerBackend::Sns,
55            other => anyhow::bail!("unknown ledger backend '{other}' (sqlite|postgres|pubsub|sns)"),
56        };
57        if out.contains(&b) {
58            anyhow::bail!("duplicate ledger backend '{name}' in SYNAPSE_LEDGER_BACKENDS");
59        }
60        out.push(b);
61    }
62    if out.is_empty() {
63        anyhow::bail!("SYNAPSE_LEDGER_BACKENDS resolved to an empty backend list");
64    }
65    Ok(out)
66}
67
68/// Resolve the GCP project id for Vertex from an env map.
69/// `VERTEX_PROJECT_ID` is preferred; `VERTEX_PROJECT` is accepted for compatibility.
70pub fn vertex_project_from_env(env: &HashMap<String, String>) -> Option<String> {
71    ["VERTEX_PROJECT_ID", "VERTEX_PROJECT"]
72        .into_iter()
73        .find_map(|key| {
74            env.get(key)
75                .map(|s| s.trim())
76                .filter(|s| !s.is_empty())
77                .map(str::to_string)
78        })
79}
80
81impl Config {
82    pub fn from_env_map(env: &HashMap<String, String>) -> anyhow::Result<Self> {
83        let get = |k: &str| env.get(k).cloned().filter(|s| !s.trim().is_empty());
84        let get_or = |k: &str, d: &str| get(k).unwrap_or_else(|| d.to_string());
85        let backends_raw = get("SYNAPSE_LEDGER_BACKENDS")
86            .or_else(|| get("SYNAPSE_LEDGER_BACKEND"))
87            .unwrap_or_else(|| "sqlite".to_string());
88        let ledger_backends = parse_ledger_backends(&backends_raw)?;
89        Ok(Self {
90            addr: get_or("SYNAPSE_ADDR", "0.0.0.0:8080"),
91            metrics_addr: get_or("SYNAPSE_METRICS_ADDR", "0.0.0.0:9090"),
92            routes_path: get_or("SYNAPSE_ROUTES_PATH", "config/routes.toml"),
93            pricing_path: get_or("SYNAPSE_PRICING_PATH", "config/pricing.toml"),
94            guardrails_path: get_or("SYNAPSE_GUARDRAILS_PATH", "config/guardrails.toml"),
95            a2a_path: get_or("SYNAPSE_A2A_PATH", "config/a2a.toml"),
96            ai_task_types_path: get_or("SYNAPSE_AI_TASK_TYPES_PATH", "config/ai_task_types.toml"),
97            ledger_backends,
98            default_tenant: get_or("SYNAPSE_DEFAULT_TENANT", "unattributed"),
99            request_timeout: Duration::from_secs(
100                get_or("SYNAPSE_REQUEST_TIMEOUT_SECS", "120")
101                    .parse()
102                    .map_err(|e| anyhow::anyhow!("SYNAPSE_REQUEST_TIMEOUT_SECS: {e}"))?,
103            ),
104            stream_idle_timeout: Duration::from_secs(
105                get_or("SYNAPSE_STREAM_IDLE_TIMEOUT_SECS", "60")
106                    .parse()
107                    .map_err(|e| anyhow::anyhow!("SYNAPSE_STREAM_IDLE_TIMEOUT_SECS: {e}"))?,
108            ),
109            embed_default_input_per_mtok: get_or(
110                "SYNAPSE_EMBED_DEFAULT_INPUT_PRICE_PER_MTOK",
111                "0.10",
112            )
113            .parse()
114            .unwrap_or(0.10),
115            env: env.clone(),
116        })
117    }
118}
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123
124    fn env(pairs: &[(&str, &str)]) -> HashMap<String, String> {
125        pairs
126            .iter()
127            .map(|(k, v)| (k.to_string(), v.to_string()))
128            .collect()
129    }
130
131    #[test]
132    fn vertex_project_from_env_prefers_project_id() {
133        let env = env(&[
134            ("VERTEX_PROJECT_ID", "from-id"),
135            ("VERTEX_PROJECT", "from-legacy"),
136        ]);
137        assert_eq!(vertex_project_from_env(&env).as_deref(), Some("from-id"));
138    }
139
140    #[test]
141    fn vertex_project_from_env_falls_back_to_vertex_project() {
142        let env = env(&[("VERTEX_PROJECT", "legacy-only")]);
143        assert_eq!(
144            vertex_project_from_env(&env).as_deref(),
145            Some("legacy-only")
146        );
147    }
148
149    #[test]
150    fn vertex_project_from_env_ignores_blank_values() {
151        let env = env(&[("VERTEX_PROJECT_ID", "  "), ("VERTEX_PROJECT", "ok")]);
152        assert_eq!(vertex_project_from_env(&env).as_deref(), Some("ok"));
153    }
154
155    #[test]
156    fn defaults_apply_when_env_empty() {
157        let c = Config::from_env_map(&env(&[])).unwrap();
158        assert_eq!(c.addr, "0.0.0.0:8080");
159        assert_eq!(c.ledger_backends, vec![LedgerBackend::Sqlite]);
160        assert_eq!(c.default_tenant, "unattributed");
161    }
162
163    #[test]
164    fn env_overrides_and_validates_backend() {
165        let c = Config::from_env_map(&env(&[("SYNAPSE_LEDGER_BACKEND", "postgres")])).unwrap();
166        assert_eq!(c.ledger_backends, vec![LedgerBackend::Postgres]);
167        let err = Config::from_env_map(&env(&[("SYNAPSE_LEDGER_BACKEND", "mysql")])).unwrap_err();
168        assert!(err.to_string().contains("sqlite|postgres"));
169    }
170
171    #[test]
172    fn parses_stream_timeouts() {
173        let c = Config::from_env_map(&env(&[
174            ("SYNAPSE_REQUEST_TIMEOUT_SECS", "30"),
175            ("SYNAPSE_STREAM_IDLE_TIMEOUT_SECS", "45"),
176        ]))
177        .unwrap();
178        assert_eq!(c.request_timeout, std::time::Duration::from_secs(30));
179        assert_eq!(c.stream_idle_timeout, std::time::Duration::from_secs(45));
180    }
181
182    #[test]
183    fn parses_backend_list() {
184        let c =
185            Config::from_env_map(&env(&[("SYNAPSE_LEDGER_BACKENDS", "postgres, pubsub")])).unwrap();
186        assert_eq!(
187            c.ledger_backends,
188            vec![LedgerBackend::Postgres, LedgerBackend::Pubsub]
189        );
190    }
191
192    #[test]
193    fn singular_backend_is_back_compat() {
194        let c = Config::from_env_map(&env(&[("SYNAPSE_LEDGER_BACKEND", "sns")])).unwrap();
195        assert_eq!(c.ledger_backends, vec![LedgerBackend::Sns]);
196    }
197
198    #[test]
199    fn defaults_to_sqlite_list() {
200        let c = Config::from_env_map(&env(&[])).unwrap();
201        assert_eq!(c.ledger_backends, vec![LedgerBackend::Sqlite]);
202    }
203
204    #[test]
205    fn rejects_unknown_and_duplicate_backends() {
206        assert!(Config::from_env_map(&env(&[("SYNAPSE_LEDGER_BACKENDS", "mysql")])).is_err());
207        assert!(
208            Config::from_env_map(&env(&[("SYNAPSE_LEDGER_BACKENDS", "pubsub,pubsub")])).is_err()
209        );
210    }
211
212    #[test]
213    fn guardrails_path_defaults_and_overrides() {
214        let c = Config::from_env_map(&env(&[])).unwrap();
215        assert_eq!(c.guardrails_path, "config/guardrails.toml");
216        let c = Config::from_env_map(&env(&[("SYNAPSE_GUARDRAILS_PATH", "/etc/g.toml")])).unwrap();
217        assert_eq!(c.guardrails_path, "/etc/g.toml");
218    }
219
220    #[test]
221    fn a2a_path_defaults_and_overrides() {
222        let c = Config::from_env_map(&env(&[])).unwrap();
223        assert_eq!(c.a2a_path, "config/a2a.toml");
224        let c = Config::from_env_map(&env(&[("SYNAPSE_A2A_PATH", "/etc/a2a.toml")])).unwrap();
225        assert_eq!(c.a2a_path, "/etc/a2a.toml");
226    }
227
228    #[test]
229    fn ai_task_types_path_defaults_and_overrides() {
230        let c = Config::from_env_map(&env(&[])).unwrap();
231        assert_eq!(c.ai_task_types_path, "config/ai_task_types.toml");
232        let c =
233            Config::from_env_map(&env(&[("SYNAPSE_AI_TASK_TYPES_PATH", "/etc/ai.toml")])).unwrap();
234        assert_eq!(c.ai_task_types_path, "/etc/ai.toml");
235    }
236
237    #[test]
238    fn shipped_ai_task_types_sample_parses_and_covers_known_aliases() {
239        let content = std::fs::read_to_string("config/ai_task_types.toml")
240            .expect("config/ai_task_types.toml should exist");
241        let table = crate::ai_task_type::AiTaskTypeTable::from_toml_str(&content).unwrap();
242        assert_eq!(table.resolve("conversation"), "conversation");
243        // An alias absent from the shipped taxonomy still resolves, to the default.
244        assert_eq!(table.resolve("no-such-alias"), "simple");
245    }
246
247    #[test]
248    fn shipped_guardrails_sample_builds_an_engine() {
249        let content = std::fs::read_to_string("config/guardrails.toml")
250            .expect("config/guardrails.toml should exist");
251        let cfg = crate::guard::GuardrailsConfig::from_toml_str(&content).unwrap();
252        crate::guard::GuardEngine::from_config(&cfg).expect("sample policies must compile");
253    }
254}