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 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 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
66pub 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}