1use std::env;
5use std::num::NonZeroU32;
6
7use firstpass_core::{Config as RoutingConfig, Mode, PriceTable};
8
9const DEFAULT_PROMPT_SALT: &str = "firstpass-dev-salt";
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
27pub enum ReceiptsMode {
28 #[default]
30 BestEffort,
31 Durable,
34}
35
36#[derive(Debug, Clone)]
38pub struct ProxyConfig {
39 pub bind: String,
41 pub upstream_anthropic: String,
43 pub upstream_openai: String,
45 pub db_path: String,
47 pub tenant_id: String,
51 pub require_auth: bool,
55 pub tenant_keys: crate::tenant_auth::TenantKeys,
64 pub prompt_salt: String,
66 pub mode: Mode,
68 pub routing: Option<RoutingConfig>,
71 pub prices: PriceTable,
73 pub max_concurrency: usize,
77 pub tenant_rate_per_sec: Option<NonZeroU32>,
81 pub receipts_mode: ReceiptsMode,
84}
85
86const DEFAULT_MAX_CONCURRENCY: usize = 512;
88
89#[derive(Debug, thiserror::Error)]
91pub enum ConfigError {
92 #[error(
94 "FIRSTPASS_MODE={0:?} is not a known mode; set `observe` or `enforce`, or leave it unset"
95 )]
96 UnsupportedMode(String),
97
98 #[error("FIRSTPASS_RECEIPTS={0:?} is not valid; set `best_effort` (default) or `durable`")]
100 UnsupportedReceiptsMode(String),
101
102 #[error("routing config error: {0}")]
104 Config(String),
105
106 #[error("tenant auth config error: {0}")]
109 Auth(#[from] crate::tenant_auth::AuthConfigError),
110}
111
112impl ProxyConfig {
113 pub fn from_env() -> Result<Self, ConfigError> {
119 let routing_toml = match env::var("FIRSTPASS_CONFIG").ok() {
123 Some(path) => Some(
124 std::fs::read_to_string(&path)
125 .map_err(|e| ConfigError::Config(format!("reading {path}: {e}")))?,
126 ),
127 None => None,
128 };
129 let tenant_keys_json = match env::var("FIRSTPASS_TENANT_KEYS").ok() {
133 Some(path) => Some(
134 std::fs::read_to_string(&path)
135 .map_err(|e| ConfigError::Config(format!("reading {path}: {e}")))?,
136 ),
137 None => None,
138 };
139 Self::from_lookup(|key| match key {
140 "FIRSTPASS_CONFIG_TOML" => routing_toml.clone(),
141 "FIRSTPASS_TENANT_KEYS_JSON" => env::var(key).ok().or_else(|| tenant_keys_json.clone()),
142 other => env::var(other).ok(),
143 })
144 }
145
146 pub fn from_lookup(lookup: impl Fn(&str) -> Option<String>) -> Result<Self, ConfigError> {
154 let bind = lookup("FIRSTPASS_BIND").unwrap_or_else(|| "127.0.0.1:8080".to_owned());
155 let upstream_anthropic = lookup("FIRSTPASS_UPSTREAM_ANTHROPIC")
156 .unwrap_or_else(|| "https://api.anthropic.com".to_owned());
157 let upstream_openai = lookup("FIRSTPASS_UPSTREAM_OPENAI")
158 .unwrap_or_else(|| "https://api.openai.com".to_owned());
159 let db_path = lookup("FIRSTPASS_DB").unwrap_or_else(|| "firstpass.db".to_owned());
160 let tenant_id = lookup("FIRSTPASS_TENANT").unwrap_or_else(|| "default".to_owned());
161 let prompt_salt = lookup("FIRSTPASS_PROMPT_SALT").unwrap_or_else(|| {
162 tracing::warn!(
163 "FIRSTPASS_PROMPT_SALT is unset — using the built-in dev default; \
164 set a real secret before handling production traffic"
165 );
166 DEFAULT_PROMPT_SALT.to_owned()
167 });
168 let mode_str = lookup("FIRSTPASS_MODE").unwrap_or_else(|| "observe".to_owned());
169 let mode = match mode_str.as_str() {
170 "observe" => Mode::Observe,
171 "enforce" => Mode::Enforce,
172 other => return Err(ConfigError::UnsupportedMode(other.to_owned())),
173 };
174 let routing = match lookup("FIRSTPASS_CONFIG_TOML") {
175 Some(toml) => {
176 Some(RoutingConfig::parse(&toml).map_err(|e| ConfigError::Config(e.to_string()))?)
177 }
178 None => None,
179 };
180 let max_concurrency = match lookup("FIRSTPASS_MAX_CONCURRENCY") {
181 Some(s) => s.parse().map_err(|e| {
182 ConfigError::Config(format!("FIRSTPASS_MAX_CONCURRENCY={s:?}: {e}"))
183 })?,
184 None => DEFAULT_MAX_CONCURRENCY,
185 };
186
187 let require_auth = lookup("FIRSTPASS_REQUIRE_AUTH")
190 .map(|s| {
191 matches!(
192 s.trim().to_ascii_lowercase().as_str(),
193 "1" | "true" | "yes" | "on"
194 )
195 })
196 .unwrap_or(false);
197 let tenant_keys = match lookup("FIRSTPASS_TENANT_KEYS_JSON") {
198 Some(json) => crate::tenant_auth::TenantKeys::from_json(&json)?,
199 None => crate::tenant_auth::TenantKeys::default(),
200 };
201 if require_auth && tenant_keys.is_empty() {
202 tracing::warn!(
203 "FIRSTPASS_REQUIRE_AUTH is on but no tenant keys are configured — every request \
204 will be rejected with 401 until FIRSTPASS_TENANT_KEYS is set"
205 );
206 }
207
208 let tenant_rate_per_sec = match lookup("FIRSTPASS_TENANT_RATE_PER_SEC") {
210 Some(s) => Some(s.trim().parse::<NonZeroU32>().map_err(|e| {
211 ConfigError::Config(format!("FIRSTPASS_TENANT_RATE_PER_SEC={s:?}: {e}"))
212 })?),
213 None => None,
214 };
215
216 let receipts_mode = match lookup("FIRSTPASS_RECEIPTS").as_deref() {
218 None | Some("best_effort") => ReceiptsMode::BestEffort,
219 Some("durable") => ReceiptsMode::Durable,
220 Some(other) => {
221 return Err(ConfigError::UnsupportedReceiptsMode(other.to_owned()));
222 }
223 };
224
225 Ok(Self {
226 bind,
227 upstream_anthropic,
228 upstream_openai,
229 db_path,
230 tenant_id,
231 require_auth,
232 tenant_keys,
233 prompt_salt,
234 mode,
235 prices: {
236 let mut prices = PriceTable::defaults();
239 if let Some(cfg) = routing.as_ref() {
240 for p in &cfg.price_defs {
241 prices = prices.with_override(
242 p.model.clone(),
243 firstpass_core::ModelPrice {
244 input_per_mtok: p.input_per_mtok,
245 output_per_mtok: p.output_per_mtok,
246 },
247 );
248 }
249 }
250 prices
251 },
252 routing,
253 max_concurrency,
254 tenant_rate_per_sec,
255 receipts_mode,
256 })
257 }
258}
259
260#[cfg(test)]
261mod tests {
262 use super::*;
263
264 #[test]
265 fn defaults_are_sane_when_unset() {
266 let cfg = ProxyConfig::from_lookup(|_| None).unwrap();
267 assert_eq!(cfg.bind, "127.0.0.1:8080");
268 assert_eq!(cfg.upstream_anthropic, "https://api.anthropic.com");
269 assert_eq!(cfg.db_path, "firstpass.db");
270 assert_eq!(cfg.tenant_id, "default");
271 assert_eq!(cfg.prompt_salt, DEFAULT_PROMPT_SALT);
272 assert_eq!(cfg.mode, Mode::Observe);
273 assert_eq!(cfg.max_concurrency, DEFAULT_MAX_CONCURRENCY);
274 }
275
276 #[test]
277 fn max_concurrency_is_parsed_from_env() {
278 let cfg = ProxyConfig::from_lookup(|key| {
279 (key == "FIRSTPASS_MAX_CONCURRENCY").then(|| "64".to_owned())
280 })
281 .unwrap();
282 assert_eq!(cfg.max_concurrency, 64);
283 }
284
285 #[test]
286 fn bad_max_concurrency_is_an_error() {
287 let result = ProxyConfig::from_lookup(|key| {
288 (key == "FIRSTPASS_MAX_CONCURRENCY").then(|| "not-a-number".to_owned())
289 });
290 assert!(matches!(result, Err(ConfigError::Config(_))));
291 }
292
293 #[test]
294 fn overrides_are_applied() {
295 let cfg = ProxyConfig::from_lookup(|key| match key {
296 "FIRSTPASS_BIND" => Some("0.0.0.0:9090".to_owned()),
297 "FIRSTPASS_TENANT" => Some("acme".to_owned()),
298 _ => None,
299 })
300 .unwrap();
301 assert_eq!(cfg.bind, "0.0.0.0:9090");
302 assert_eq!(cfg.tenant_id, "acme");
303 }
304
305 #[test]
306 fn enforce_mode_is_accepted() {
307 let cfg =
308 ProxyConfig::from_lookup(|key| (key == "FIRSTPASS_MODE").then(|| "enforce".to_owned()))
309 .unwrap();
310 assert_eq!(cfg.mode, Mode::Enforce);
311 }
312
313 #[test]
314 fn unknown_mode_is_rejected() {
315 let result =
316 ProxyConfig::from_lookup(|key| (key == "FIRSTPASS_MODE").then(|| "banana".to_owned()));
317 assert!(matches!(result, Err(ConfigError::UnsupportedMode(m)) if m == "banana"));
318 }
319
320 #[test]
321 fn routing_config_parses_inline() {
322 let toml = r#"
323[[route]]
324match = { task_kind = "code_edit" }
325mode = "enforce"
326ladder = ["anthropic/claude-haiku-4-5", "anthropic/claude-sonnet-5"]
327gates = ["non-empty"]
328"#;
329 let cfg = ProxyConfig::from_lookup(|key| match key {
330 "FIRSTPASS_CONFIG_TOML" => Some(toml.to_owned()),
331 _ => None,
332 })
333 .unwrap();
334 let routing = cfg.routing.expect("routing config present");
335 assert_eq!(routing.routes.len(), 1);
336 assert_eq!(routing.routes[0].mode, Mode::Enforce);
337 }
338
339 #[test]
340 fn bad_routing_config_is_an_error() {
341 let result = ProxyConfig::from_lookup(|key| match key {
342 "FIRSTPASS_CONFIG_TOML" => Some("this is not valid = = toml".to_owned()),
343 _ => None,
344 });
345 assert!(matches!(result, Err(ConfigError::Config(_))));
346 }
347
348 #[test]
349 fn receipts_mode_defaults_to_best_effort() {
350 let cfg = ProxyConfig::from_lookup(|_| None).unwrap();
351 assert_eq!(cfg.receipts_mode, ReceiptsMode::BestEffort);
352 }
353
354 #[test]
355 fn receipts_mode_durable_is_accepted() {
356 let cfg =
357 ProxyConfig::from_lookup(|k| (k == "FIRSTPASS_RECEIPTS").then(|| "durable".to_owned()))
358 .unwrap();
359 assert_eq!(cfg.receipts_mode, ReceiptsMode::Durable);
360 }
361
362 #[test]
363 fn receipts_mode_best_effort_explicit_is_accepted() {
364 let cfg = ProxyConfig::from_lookup(|k| {
365 (k == "FIRSTPASS_RECEIPTS").then(|| "best_effort".to_owned())
366 })
367 .unwrap();
368 assert_eq!(cfg.receipts_mode, ReceiptsMode::BestEffort);
369 }
370
371 #[test]
372 fn receipts_mode_unknown_is_rejected() {
373 let result = ProxyConfig::from_lookup(|k| {
374 (k == "FIRSTPASS_RECEIPTS").then(|| "never_drop".to_owned())
375 });
376 assert!(
377 matches!(result, Err(ConfigError::UnsupportedReceiptsMode(m)) if m == "never_drop")
378 );
379 }
380
381 #[test]
382 fn price_overrides_reach_the_price_table() {
383 let toml = "[[route]]\nmatch = {}\nmode = \"observe\"\nladder = [\"anthropic/claude-haiku-4-5\"]\n\n[[price]]\nmodel = \"anthropic/claude-haiku-4-5\"\ninput_per_mtok = 2.0\noutput_per_mtok = 10.0\n";
384 let cfg = ProxyConfig::from_lookup(|k| match k {
385 "FIRSTPASS_CONFIG_TOML" => Some(toml.to_owned()),
386 _ => None,
387 })
388 .unwrap();
389 let cost = cfg
391 .prices
392 .cost_usd("anthropic/claude-haiku-4-5", 1000, 1000)
393 .unwrap();
394 assert!((cost - 0.012).abs() < 1e-9, "override must win: {cost}");
395 }
396}