1use std::env;
5use std::num::NonZeroU32;
6
7use firstpass_core::{Config as RoutingConfig, Mode, PriceTable, RoutingMode};
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 pub default_routing_mode: RoutingMode,
88}
89
90const DEFAULT_MAX_CONCURRENCY: usize = 512;
92
93#[derive(Debug, thiserror::Error)]
95pub enum ConfigError {
96 #[error(
98 "FIRSTPASS_MODE={0:?} is not a known mode; set `observe` or `enforce`, or leave it unset"
99 )]
100 UnsupportedMode(String),
101
102 #[error("FIRSTPASS_RECEIPTS={0:?} is not valid; set `best_effort` (default) or `durable`")]
104 UnsupportedReceiptsMode(String),
105
106 #[error(
108 "FIRSTPASS_MODE_PROFILE={0:?} is not a known routing mode; \
109 valid: observe|cost|balanced|quality|latency|max"
110 )]
111 UnsupportedModeProfile(String),
112
113 #[error("routing config error: {0}")]
115 Config(String),
116
117 #[error("tenant auth config error: {0}")]
120 Auth(#[from] crate::tenant_auth::AuthConfigError),
121}
122
123impl ProxyConfig {
124 pub fn from_env() -> Result<Self, ConfigError> {
130 let routing_toml = match env::var("FIRSTPASS_CONFIG").ok() {
134 Some(path) => Some(
135 std::fs::read_to_string(&path)
136 .map_err(|e| ConfigError::Config(format!("reading {path}: {e}")))?,
137 ),
138 None => None,
139 };
140 let tenant_keys_json = match env::var("FIRSTPASS_TENANT_KEYS").ok() {
144 Some(path) => Some(
145 std::fs::read_to_string(&path)
146 .map_err(|e| ConfigError::Config(format!("reading {path}: {e}")))?,
147 ),
148 None => None,
149 };
150 Self::from_lookup(|key| match key {
151 "FIRSTPASS_CONFIG_TOML" => routing_toml.clone(),
152 "FIRSTPASS_TENANT_KEYS_JSON" => env::var(key).ok().or_else(|| tenant_keys_json.clone()),
153 other => env::var(other).ok(),
154 })
155 }
156
157 pub fn from_lookup(lookup: impl Fn(&str) -> Option<String>) -> Result<Self, ConfigError> {
165 let bind = lookup("FIRSTPASS_BIND").unwrap_or_else(|| "127.0.0.1:8080".to_owned());
166 let upstream_anthropic = lookup("FIRSTPASS_UPSTREAM_ANTHROPIC")
167 .unwrap_or_else(|| "https://api.anthropic.com".to_owned());
168 let upstream_openai = lookup("FIRSTPASS_UPSTREAM_OPENAI")
169 .unwrap_or_else(|| "https://api.openai.com".to_owned());
170 let db_path = lookup("FIRSTPASS_DB").unwrap_or_else(|| "firstpass.db".to_owned());
171 let tenant_id = lookup("FIRSTPASS_TENANT").unwrap_or_else(|| "default".to_owned());
172 let prompt_salt = lookup("FIRSTPASS_PROMPT_SALT").unwrap_or_else(|| {
173 tracing::warn!(
174 "FIRSTPASS_PROMPT_SALT is unset — using the built-in dev default; \
175 set a real secret before handling production traffic"
176 );
177 DEFAULT_PROMPT_SALT.to_owned()
178 });
179 let mode_str = lookup("FIRSTPASS_MODE").unwrap_or_else(|| "observe".to_owned());
180 let mode = match mode_str.as_str() {
181 "observe" => Mode::Observe,
182 "enforce" => Mode::Enforce,
183 other => return Err(ConfigError::UnsupportedMode(other.to_owned())),
184 };
185 let routing = match lookup("FIRSTPASS_CONFIG_TOML") {
186 Some(toml) => {
187 Some(RoutingConfig::parse(&toml).map_err(|e| ConfigError::Config(e.to_string()))?)
188 }
189 None => None,
190 };
191 let max_concurrency = match lookup("FIRSTPASS_MAX_CONCURRENCY") {
192 Some(s) => s.parse().map_err(|e| {
193 ConfigError::Config(format!("FIRSTPASS_MAX_CONCURRENCY={s:?}: {e}"))
194 })?,
195 None => DEFAULT_MAX_CONCURRENCY,
196 };
197
198 let require_auth = lookup("FIRSTPASS_REQUIRE_AUTH")
201 .map(|s| {
202 matches!(
203 s.trim().to_ascii_lowercase().as_str(),
204 "1" | "true" | "yes" | "on"
205 )
206 })
207 .unwrap_or(false);
208 let tenant_keys = match lookup("FIRSTPASS_TENANT_KEYS_JSON") {
209 Some(json) => crate::tenant_auth::TenantKeys::from_json(&json)?,
210 None => crate::tenant_auth::TenantKeys::default(),
211 };
212 if require_auth && tenant_keys.is_empty() {
213 tracing::warn!(
214 "FIRSTPASS_REQUIRE_AUTH is on but no tenant keys are configured — every request \
215 will be rejected with 401 until FIRSTPASS_TENANT_KEYS is set"
216 );
217 }
218
219 let tenant_rate_per_sec = match lookup("FIRSTPASS_TENANT_RATE_PER_SEC") {
221 Some(s) => Some(s.trim().parse::<NonZeroU32>().map_err(|e| {
222 ConfigError::Config(format!("FIRSTPASS_TENANT_RATE_PER_SEC={s:?}: {e}"))
223 })?),
224 None => None,
225 };
226
227 let receipts_mode = match lookup("FIRSTPASS_RECEIPTS").as_deref() {
229 None | Some("best_effort") => ReceiptsMode::BestEffort,
230 Some("durable") => ReceiptsMode::Durable,
231 Some(other) => {
232 return Err(ConfigError::UnsupportedReceiptsMode(other.to_owned()));
233 }
234 };
235
236 let default_routing_mode = match lookup("FIRSTPASS_MODE_PROFILE").as_deref() {
238 None | Some("balanced") => RoutingMode::Balanced,
239 Some("observe") => RoutingMode::Observe,
240 Some("cost") => RoutingMode::Cost,
241 Some("quality") => RoutingMode::Quality,
242 Some("latency") => RoutingMode::Latency,
243 Some("max") => RoutingMode::Max,
244 Some(other) => {
245 return Err(ConfigError::UnsupportedModeProfile(other.to_owned()));
246 }
247 };
248
249 Ok(Self {
250 bind,
251 upstream_anthropic,
252 upstream_openai,
253 db_path,
254 tenant_id,
255 require_auth,
256 tenant_keys,
257 prompt_salt,
258 mode,
259 prices: {
260 let mut prices = PriceTable::defaults();
263 if let Some(cfg) = routing.as_ref() {
264 for p in &cfg.price_defs {
265 prices = prices.with_override(
266 p.model.clone(),
267 firstpass_core::ModelPrice {
268 input_per_mtok: p.input_per_mtok,
269 output_per_mtok: p.output_per_mtok,
270 },
271 );
272 }
273 }
274 prices
275 },
276 routing,
277 max_concurrency,
278 tenant_rate_per_sec,
279 receipts_mode,
280 default_routing_mode,
281 })
282 }
283}
284
285#[cfg(test)]
286mod tests {
287 use super::*;
288
289 #[test]
290 fn defaults_are_sane_when_unset() {
291 let cfg = ProxyConfig::from_lookup(|_| None).unwrap();
292 assert_eq!(cfg.bind, "127.0.0.1:8080");
293 assert_eq!(cfg.upstream_anthropic, "https://api.anthropic.com");
294 assert_eq!(cfg.db_path, "firstpass.db");
295 assert_eq!(cfg.tenant_id, "default");
296 assert_eq!(cfg.prompt_salt, DEFAULT_PROMPT_SALT);
297 assert_eq!(cfg.mode, Mode::Observe);
298 assert_eq!(cfg.max_concurrency, DEFAULT_MAX_CONCURRENCY);
299 }
300
301 #[test]
302 fn max_concurrency_is_parsed_from_env() {
303 let cfg = ProxyConfig::from_lookup(|key| {
304 (key == "FIRSTPASS_MAX_CONCURRENCY").then(|| "64".to_owned())
305 })
306 .unwrap();
307 assert_eq!(cfg.max_concurrency, 64);
308 }
309
310 #[test]
311 fn bad_max_concurrency_is_an_error() {
312 let result = ProxyConfig::from_lookup(|key| {
313 (key == "FIRSTPASS_MAX_CONCURRENCY").then(|| "not-a-number".to_owned())
314 });
315 assert!(matches!(result, Err(ConfigError::Config(_))));
316 }
317
318 #[test]
319 fn overrides_are_applied() {
320 let cfg = ProxyConfig::from_lookup(|key| match key {
321 "FIRSTPASS_BIND" => Some("0.0.0.0:9090".to_owned()),
322 "FIRSTPASS_TENANT" => Some("acme".to_owned()),
323 _ => None,
324 })
325 .unwrap();
326 assert_eq!(cfg.bind, "0.0.0.0:9090");
327 assert_eq!(cfg.tenant_id, "acme");
328 }
329
330 #[test]
331 fn enforce_mode_is_accepted() {
332 let cfg =
333 ProxyConfig::from_lookup(|key| (key == "FIRSTPASS_MODE").then(|| "enforce".to_owned()))
334 .unwrap();
335 assert_eq!(cfg.mode, Mode::Enforce);
336 }
337
338 #[test]
339 fn unknown_mode_is_rejected() {
340 let result =
341 ProxyConfig::from_lookup(|key| (key == "FIRSTPASS_MODE").then(|| "banana".to_owned()));
342 assert!(matches!(result, Err(ConfigError::UnsupportedMode(m)) if m == "banana"));
343 }
344
345 #[test]
346 fn routing_config_parses_inline() {
347 let toml = r#"
348[[route]]
349match = { task_kind = "code_edit" }
350mode = "enforce"
351ladder = ["anthropic/claude-haiku-4-5", "anthropic/claude-sonnet-5"]
352gates = ["non-empty"]
353"#;
354 let cfg = ProxyConfig::from_lookup(|key| match key {
355 "FIRSTPASS_CONFIG_TOML" => Some(toml.to_owned()),
356 _ => None,
357 })
358 .unwrap();
359 let routing = cfg.routing.expect("routing config present");
360 assert_eq!(routing.routes.len(), 1);
361 assert_eq!(routing.routes[0].mode, Mode::Enforce);
362 }
363
364 #[test]
365 fn bad_routing_config_is_an_error() {
366 let result = ProxyConfig::from_lookup(|key| match key {
367 "FIRSTPASS_CONFIG_TOML" => Some("this is not valid = = toml".to_owned()),
368 _ => None,
369 });
370 assert!(matches!(result, Err(ConfigError::Config(_))));
371 }
372
373 #[test]
374 fn receipts_mode_defaults_to_best_effort() {
375 let cfg = ProxyConfig::from_lookup(|_| None).unwrap();
376 assert_eq!(cfg.receipts_mode, ReceiptsMode::BestEffort);
377 }
378
379 #[test]
380 fn receipts_mode_durable_is_accepted() {
381 let cfg =
382 ProxyConfig::from_lookup(|k| (k == "FIRSTPASS_RECEIPTS").then(|| "durable".to_owned()))
383 .unwrap();
384 assert_eq!(cfg.receipts_mode, ReceiptsMode::Durable);
385 }
386
387 #[test]
388 fn receipts_mode_best_effort_explicit_is_accepted() {
389 let cfg = ProxyConfig::from_lookup(|k| {
390 (k == "FIRSTPASS_RECEIPTS").then(|| "best_effort".to_owned())
391 })
392 .unwrap();
393 assert_eq!(cfg.receipts_mode, ReceiptsMode::BestEffort);
394 }
395
396 #[test]
397 fn receipts_mode_unknown_is_rejected() {
398 let result = ProxyConfig::from_lookup(|k| {
399 (k == "FIRSTPASS_RECEIPTS").then(|| "never_drop".to_owned())
400 });
401 assert!(
402 matches!(result, Err(ConfigError::UnsupportedReceiptsMode(m)) if m == "never_drop")
403 );
404 }
405
406 #[test]
407 fn default_routing_mode_is_balanced() {
408 let cfg = ProxyConfig::from_lookup(|_| None).unwrap();
409 assert_eq!(cfg.default_routing_mode, RoutingMode::Balanced);
410 }
411
412 #[test]
413 fn firstpass_mode_profile_is_parsed() {
414 let cfg = ProxyConfig::from_lookup(|k| {
415 (k == "FIRSTPASS_MODE_PROFILE").then(|| "quality".to_owned())
416 })
417 .unwrap();
418 assert_eq!(cfg.default_routing_mode, RoutingMode::Quality);
419 }
420
421 #[test]
422 fn unknown_mode_profile_is_rejected() {
423 let result = ProxyConfig::from_lookup(|k| {
424 (k == "FIRSTPASS_MODE_PROFILE").then(|| "turbo".to_owned())
425 });
426 assert!(matches!(result, Err(ConfigError::UnsupportedModeProfile(m)) if m == "turbo"));
427 }
428
429 #[test]
430 fn price_overrides_reach_the_price_table() {
431 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";
432 let cfg = ProxyConfig::from_lookup(|k| match k {
433 "FIRSTPASS_CONFIG_TOML" => Some(toml.to_owned()),
434 _ => None,
435 })
436 .unwrap();
437 let cost = cfg
439 .prices
440 .cost_usd("anthropic/claude-haiku-4-5", 1000, 1000)
441 .unwrap();
442 assert!((cost - 0.012).abs() < 1e-9, "override must win: {cost}");
443 }
444}