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, RoutingMode};
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    /// Global default routing-mode preset (`FIRSTPASS_MODE_PROFILE`). Overridden by per-route
85    /// `routing_mode` and the per-request `x-firstpass-mode` header. Default `Balanced` is
86    /// byte-identical to existing behaviour (no knob changes at all).
87    pub default_routing_mode: RoutingMode,
88}
89
90/// Default for [`ProxyConfig::max_concurrency`] when `FIRSTPASS_MAX_CONCURRENCY` is unset.
91const DEFAULT_MAX_CONCURRENCY: usize = 512;
92
93/// Errors that prevent the proxy from starting.
94#[derive(Debug, thiserror::Error)]
95pub enum ConfigError {
96    /// `FIRSTPASS_MODE` named an unknown mode (valid: `observe`, `enforce`).
97    #[error(
98        "FIRSTPASS_MODE={0:?} is not a known mode; set `observe` or `enforce`, or leave it unset"
99    )]
100    UnsupportedMode(String),
101
102    /// `FIRSTPASS_RECEIPTS` named an unknown mode (valid: `best_effort`, `durable`).
103    #[error("FIRSTPASS_RECEIPTS={0:?} is not valid; set `best_effort` (default) or `durable`")]
104    UnsupportedReceiptsMode(String),
105
106    /// `FIRSTPASS_MODE_PROFILE` named an unknown routing mode.
107    #[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    /// The routing config file could not be read or parsed.
114    #[error("routing config error: {0}")]
115    Config(String),
116
117    /// The multi-tenant auth config (`FIRSTPASS_TENANT_KEYS`) is invalid (ADR 0004 §D1). The
118    /// message never echoes hash material — only the structural problem.
119    #[error("tenant auth config error: {0}")]
120    Auth(#[from] crate::tenant_auth::AuthConfigError),
121}
122
123impl ProxyConfig {
124    /// Load configuration from environment variables, falling back to local defaults.
125    ///
126    /// # Errors
127    /// [`ConfigError::UnsupportedMode`] for an unknown `FIRSTPASS_MODE`; [`ConfigError::Config`]
128    /// if the `FIRSTPASS_CONFIG` routing file cannot be read or parsed.
129    pub fn from_env() -> Result<Self, ConfigError> {
130        // Read the routing config file (if any) here, then hand its *content* to the pure
131        // `from_lookup` seam via the synthetic `FIRSTPASS_CONFIG_TOML` key — keeping file I/O
132        // out of the unit-testable path.
133        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        // Read the tenant-keys file (if any) here and hand its *content* to `from_lookup` via the
141        // synthetic `FIRSTPASS_TENANT_KEYS_JSON` key, keeping file I/O out of the testable path.
142        // An inline `FIRSTPASS_TENANT_KEYS_JSON` in the real env takes precedence if both are set.
143        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    /// Load configuration from an arbitrary key lookup — the seam `from_env` uses, exposed
158    /// so tests can exercise defaulting/validation without touching real process env vars.
159    /// The routing config is supplied inline via the `FIRSTPASS_CONFIG_TOML` key.
160    ///
161    /// # Errors
162    /// [`ConfigError::UnsupportedMode`] for an unknown `FIRSTPASS_MODE`; [`ConfigError::Config`]
163    /// if the inline routing TOML fails to parse.
164    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        // Multi-tenant auth (ADR 0004 §D1) — default OFF. Anything other than an explicit truthy
199        // value leaves single-operator behavior byte-identical.
200        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        // Per-tenant rate limiting (ADR 0004 §D6) — default OFF (unlimited).
220        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        // Receipts durability mode — default BestEffort (byte-identical to pre-Phase-3 behavior).
228        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        // Global routing-mode profile — default Balanced (byte-identical to existing behaviour).
237        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                // Operator [[price]] overrides pin THIS deployment's real prices onto the
261                // built-in defaults — savings math tracks the contract, not a stale list.
262                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        // 1000 in + 1000 out at 2.0/10.0 per Mtok = 0.002 + 0.010.
438        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}