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