Skip to main content

firstpass_proxy/
config.rs

1//! Proxy configuration, loaded from the environment (SPEC §7.4: agent-first, zero-config
2//! by default — every knob has a sane local default and can be overridden by env var).
3
4use std::env;
5use std::num::NonZeroU32;
6
7use firstpass_core::{Config as RoutingConfig, Mode, PriceTable};
8
9/// The built-in prompt salt used when `FIRSTPASS_PROMPT_SALT` is unset. Fine for local
10/// development; an operator should set their own before handling real traffic, since a
11/// shared default salt makes `prompt_hash` comparable/guessable across installs.
12const DEFAULT_PROMPT_SALT: &str = "firstpass-dev-salt";
13
14/// Controls what happens to audit traces when the background writer channel is full.
15///
16/// - [`BestEffort`](ReceiptsMode::BestEffort) — `try_send` drops the trace and increments
17///   `firstpass_traces_dropped_total`; bounded memory, zero added latency. The hash chain over
18///   persisted traces stays valid; a dropped trace is simply absent.
19/// - [`Durable`](ReceiptsMode::Durable) — on `TrySendError::Full` the trace is serialised as a
20///   JSON line and appended (with `sync_data`) to `<db_path>.spill.jsonl`. The background writer
21///   drains the spill file at startup and whenever the channel empties, inserting spilled traces
22///   BEFORE new channel arrivals so the hash chain remains append-only and valid.
23///   The blocking fs append on the hot path is the DELIBERATE tradeoff of durable mode —
24///   it only fires under sustained backpressure; switch to `best_effort` if disk latency
25///   under load is unacceptable.
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
27pub enum ReceiptsMode {
28    /// Drop traces when the writer channel is full (default — bounded memory, zero latency impact).
29    #[default]
30    BestEffort,
31    /// Spill traces to disk when the writer channel is full; drain and recover on the next boot
32    /// or whenever the channel empties.
33    Durable,
34}
35
36/// Runtime configuration for the proxy.
37#[derive(Debug, Clone)]
38pub struct ProxyConfig {
39    /// Address to bind the HTTP server to, e.g. `127.0.0.1:8080`.
40    pub bind: String,
41    /// Base URL of the upstream Anthropic-compatible API.
42    pub upstream_anthropic: String,
43    /// Base URL of the upstream OpenAI-compatible API.
44    pub upstream_openai: String,
45    /// Path to the SQLite trace database.
46    pub db_path: String,
47    /// Tenant identifier stamped on every trace when `require_auth` is off (single-operator
48    /// default). When `require_auth` is on, the per-request tenant comes from the verified API
49    /// key instead, and this is only the fallback for operator-scoped tooling.
50    pub tenant_id: String,
51    /// Multi-tenant auth gate (ADR 0004 §D1). Default `false` — single-operator behavior is
52    /// byte-identical: no auth checks, tenant is the static [`Self::tenant_id`]. Set via
53    /// `FIRSTPASS_REQUIRE_AUTH`. **Experimental / pre-external-review (ADR 0004 §D7).**
54    pub require_auth: bool,
55    /// Per-tenant Argon2id API-key hashes (ADR 0004 §D1). Empty unless configured via
56    /// `FIRSTPASS_TENANT_KEYS` (path to a JSON `{ tenant_id: hash }`) or `FIRSTPASS_TENANT_KEYS_JSON`
57    /// (inline JSON). Only consulted when `require_auth` is on.
58    ///
59    /// The stored `hash` is the Argon2id hash of a tenant's **secret** (make one with
60    /// `TenantKeys::hash_key`). A tenant then authenticates with the key `<tenant_id>.<secret>` —
61    /// the tenant id names which hash to verify, so a request does exactly one Argon2 check. Tenant
62    /// ids must not contain a `.` (the first `.` splits id from secret).
63    pub tenant_keys: crate::tenant_auth::TenantKeys,
64    /// Salt mixed into the prompt hash so raw prompt text never touches storage.
65    pub prompt_salt: String,
66    /// Default mode when no routing config matches (`observe` unless overridden).
67    pub mode: Mode,
68    /// Optional declarative routing config (§8.4). When present, its routes decide the mode,
69    /// ladder, and gates per request; enforce routes activate the escalation engine.
70    pub routing: Option<RoutingConfig>,
71    /// Model pricing table used to cost each attempt.
72    pub prices: PriceTable,
73    /// Max in-flight requests before new ones queue behind the concurrency limiter
74    /// (`FIRSTPASS_MAX_CONCURRENCY`, default 512). A load-shed valve, not a timeout — it never
75    /// severs an in-flight SSE stream.
76    pub max_concurrency: usize,
77    /// Per-tenant request rate limit, requests/sec (ADR 0004 §D6). `None` (the default) means
78    /// unlimited — single-operator and existing deployments are unaffected. Set via
79    /// `FIRSTPASS_TENANT_RATE_PER_SEC`; only enforced when configured.
80    pub tenant_rate_per_sec: Option<NonZeroU32>,
81    /// Whether to guarantee audit-trace durability under backpressure (`FIRSTPASS_RECEIPTS`).
82    /// Default [`ReceiptsMode::BestEffort`] is byte-identical to the previous behavior.
83    pub receipts_mode: ReceiptsMode,
84}
85
86/// Default for [`ProxyConfig::max_concurrency`] when `FIRSTPASS_MAX_CONCURRENCY` is unset.
87const DEFAULT_MAX_CONCURRENCY: usize = 512;
88
89/// Errors that prevent the proxy from starting.
90#[derive(Debug, thiserror::Error)]
91pub enum ConfigError {
92    /// `FIRSTPASS_MODE` named an unknown mode (valid: `observe`, `enforce`).
93    #[error(
94        "FIRSTPASS_MODE={0:?} is not a known mode; set `observe` or `enforce`, or leave it unset"
95    )]
96    UnsupportedMode(String),
97
98    /// `FIRSTPASS_RECEIPTS` named an unknown mode (valid: `best_effort`, `durable`).
99    #[error("FIRSTPASS_RECEIPTS={0:?} is not valid; set `best_effort` (default) or `durable`")]
100    UnsupportedReceiptsMode(String),
101
102    /// The routing config file could not be read or parsed.
103    #[error("routing config error: {0}")]
104    Config(String),
105
106    /// The multi-tenant auth config (`FIRSTPASS_TENANT_KEYS`) is invalid (ADR 0004 §D1). The
107    /// message never echoes hash material — only the structural problem.
108    #[error("tenant auth config error: {0}")]
109    Auth(#[from] crate::tenant_auth::AuthConfigError),
110}
111
112impl ProxyConfig {
113    /// Load configuration from environment variables, falling back to local defaults.
114    ///
115    /// # Errors
116    /// [`ConfigError::UnsupportedMode`] for an unknown `FIRSTPASS_MODE`; [`ConfigError::Config`]
117    /// if the `FIRSTPASS_CONFIG` routing file cannot be read or parsed.
118    pub fn from_env() -> Result<Self, ConfigError> {
119        // Read the routing config file (if any) here, then hand its *content* to the pure
120        // `from_lookup` seam via the synthetic `FIRSTPASS_CONFIG_TOML` key — keeping file I/O
121        // out of the unit-testable path.
122        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        // Read the tenant-keys file (if any) here and hand its *content* to `from_lookup` via the
130        // synthetic `FIRSTPASS_TENANT_KEYS_JSON` key, keeping file I/O out of the testable path.
131        // An inline `FIRSTPASS_TENANT_KEYS_JSON` in the real env takes precedence if both are set.
132        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    /// Load configuration from an arbitrary key lookup — the seam `from_env` uses, exposed
147    /// so tests can exercise defaulting/validation without touching real process env vars.
148    /// The routing config is supplied inline via the `FIRSTPASS_CONFIG_TOML` key.
149    ///
150    /// # Errors
151    /// [`ConfigError::UnsupportedMode`] for an unknown `FIRSTPASS_MODE`; [`ConfigError::Config`]
152    /// if the inline routing TOML fails to parse.
153    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        // Multi-tenant auth (ADR 0004 §D1) — default OFF. Anything other than an explicit truthy
188        // value leaves single-operator behavior byte-identical.
189        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        // Per-tenant rate limiting (ADR 0004 §D6) — default OFF (unlimited).
209        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        // Receipts durability mode — default BestEffort (byte-identical to pre-Phase-3 behavior).
217        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                // Operator [[price]] overrides pin THIS deployment's real prices onto the
237                // built-in defaults — savings math tracks the contract, not a stale list.
238                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        // 1000 in + 1000 out at 2.0/10.0 per Mtok = 0.002 + 0.010.
390        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}