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;
5
6use firstpass_core::{Config as RoutingConfig, Mode, PriceTable};
7
8/// The built-in prompt salt used when `FIRSTPASS_PROMPT_SALT` is unset. Fine for local
9/// development; an operator should set their own before handling real traffic, since a
10/// shared default salt makes `prompt_hash` comparable/guessable across installs.
11const DEFAULT_PROMPT_SALT: &str = "firstpass-dev-salt";
12
13/// Runtime configuration for the proxy.
14#[derive(Debug, Clone)]
15pub struct ProxyConfig {
16    /// Address to bind the HTTP server to, e.g. `127.0.0.1:8080`.
17    pub bind: String,
18    /// Base URL of the upstream Anthropic-compatible API.
19    pub upstream_anthropic: String,
20    /// Base URL of the upstream OpenAI-compatible API.
21    pub upstream_openai: String,
22    /// Path to the SQLite trace database.
23    pub db_path: String,
24    /// Tenant identifier stamped on every trace.
25    pub tenant_id: String,
26    /// Salt mixed into the prompt hash so raw prompt text never touches storage.
27    pub prompt_salt: String,
28    /// Default mode when no routing config matches (`observe` unless overridden).
29    pub mode: Mode,
30    /// Optional declarative routing config (§8.4). When present, its routes decide the mode,
31    /// ladder, and gates per request; enforce routes activate the escalation engine.
32    pub routing: Option<RoutingConfig>,
33    /// Model pricing table used to cost each attempt.
34    pub prices: PriceTable,
35    /// Max in-flight requests before new ones queue behind the concurrency limiter
36    /// (`FIRSTPASS_MAX_CONCURRENCY`, default 512). A load-shed valve, not a timeout — it never
37    /// severs an in-flight SSE stream.
38    pub max_concurrency: usize,
39}
40
41/// Default for [`ProxyConfig::max_concurrency`] when `FIRSTPASS_MAX_CONCURRENCY` is unset.
42const DEFAULT_MAX_CONCURRENCY: usize = 512;
43
44/// Errors that prevent the proxy from starting.
45#[derive(Debug, thiserror::Error)]
46pub enum ConfigError {
47    /// `FIRSTPASS_MODE` named an unknown mode (valid: `observe`, `enforce`).
48    #[error(
49        "FIRSTPASS_MODE={0:?} is not a known mode; set `observe` or `enforce`, or leave it unset"
50    )]
51    UnsupportedMode(String),
52
53    /// The routing config file could not be read or parsed.
54    #[error("routing config error: {0}")]
55    Config(String),
56}
57
58impl ProxyConfig {
59    /// Load configuration from environment variables, falling back to local defaults.
60    ///
61    /// # Errors
62    /// [`ConfigError::UnsupportedMode`] for an unknown `FIRSTPASS_MODE`; [`ConfigError::Config`]
63    /// if the `FIRSTPASS_CONFIG` routing file cannot be read or parsed.
64    pub fn from_env() -> Result<Self, ConfigError> {
65        // Read the routing config file (if any) here, then hand its *content* to the pure
66        // `from_lookup` seam via the synthetic `FIRSTPASS_CONFIG_TOML` key — keeping file I/O
67        // out of the unit-testable path.
68        let routing_toml = match env::var("FIRSTPASS_CONFIG").ok() {
69            Some(path) => Some(
70                std::fs::read_to_string(&path)
71                    .map_err(|e| ConfigError::Config(format!("reading {path}: {e}")))?,
72            ),
73            None => None,
74        };
75        Self::from_lookup(|key| match key {
76            "FIRSTPASS_CONFIG_TOML" => routing_toml.clone(),
77            other => env::var(other).ok(),
78        })
79    }
80
81    /// Load configuration from an arbitrary key lookup — the seam `from_env` uses, exposed
82    /// so tests can exercise defaulting/validation without touching real process env vars.
83    /// The routing config is supplied inline via the `FIRSTPASS_CONFIG_TOML` key.
84    ///
85    /// # Errors
86    /// [`ConfigError::UnsupportedMode`] for an unknown `FIRSTPASS_MODE`; [`ConfigError::Config`]
87    /// if the inline routing TOML fails to parse.
88    pub fn from_lookup(lookup: impl Fn(&str) -> Option<String>) -> Result<Self, ConfigError> {
89        let bind = lookup("FIRSTPASS_BIND").unwrap_or_else(|| "127.0.0.1:8080".to_owned());
90        let upstream_anthropic = lookup("FIRSTPASS_UPSTREAM_ANTHROPIC")
91            .unwrap_or_else(|| "https://api.anthropic.com".to_owned());
92        let upstream_openai = lookup("FIRSTPASS_UPSTREAM_OPENAI")
93            .unwrap_or_else(|| "https://api.openai.com".to_owned());
94        let db_path = lookup("FIRSTPASS_DB").unwrap_or_else(|| "firstpass.db".to_owned());
95        let tenant_id = lookup("FIRSTPASS_TENANT").unwrap_or_else(|| "default".to_owned());
96        let prompt_salt = lookup("FIRSTPASS_PROMPT_SALT").unwrap_or_else(|| {
97            tracing::warn!(
98                "FIRSTPASS_PROMPT_SALT is unset — using the built-in dev default; \
99                 set a real secret before handling production traffic"
100            );
101            DEFAULT_PROMPT_SALT.to_owned()
102        });
103        let mode_str = lookup("FIRSTPASS_MODE").unwrap_or_else(|| "observe".to_owned());
104        let mode = match mode_str.as_str() {
105            "observe" => Mode::Observe,
106            "enforce" => Mode::Enforce,
107            other => return Err(ConfigError::UnsupportedMode(other.to_owned())),
108        };
109        let routing = match lookup("FIRSTPASS_CONFIG_TOML") {
110            Some(toml) => {
111                Some(RoutingConfig::parse(&toml).map_err(|e| ConfigError::Config(e.to_string()))?)
112            }
113            None => None,
114        };
115        let max_concurrency = match lookup("FIRSTPASS_MAX_CONCURRENCY") {
116            Some(s) => s.parse().map_err(|e| {
117                ConfigError::Config(format!("FIRSTPASS_MAX_CONCURRENCY={s:?}: {e}"))
118            })?,
119            None => DEFAULT_MAX_CONCURRENCY,
120        };
121
122        Ok(Self {
123            bind,
124            upstream_anthropic,
125            upstream_openai,
126            db_path,
127            tenant_id,
128            prompt_salt,
129            mode,
130            routing,
131            prices: PriceTable::defaults(),
132            max_concurrency,
133        })
134    }
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140
141    #[test]
142    fn defaults_are_sane_when_unset() {
143        let cfg = ProxyConfig::from_lookup(|_| None).unwrap();
144        assert_eq!(cfg.bind, "127.0.0.1:8080");
145        assert_eq!(cfg.upstream_anthropic, "https://api.anthropic.com");
146        assert_eq!(cfg.db_path, "firstpass.db");
147        assert_eq!(cfg.tenant_id, "default");
148        assert_eq!(cfg.prompt_salt, DEFAULT_PROMPT_SALT);
149        assert_eq!(cfg.mode, Mode::Observe);
150        assert_eq!(cfg.max_concurrency, DEFAULT_MAX_CONCURRENCY);
151    }
152
153    #[test]
154    fn max_concurrency_is_parsed_from_env() {
155        let cfg = ProxyConfig::from_lookup(|key| {
156            (key == "FIRSTPASS_MAX_CONCURRENCY").then(|| "64".to_owned())
157        })
158        .unwrap();
159        assert_eq!(cfg.max_concurrency, 64);
160    }
161
162    #[test]
163    fn bad_max_concurrency_is_an_error() {
164        let result = ProxyConfig::from_lookup(|key| {
165            (key == "FIRSTPASS_MAX_CONCURRENCY").then(|| "not-a-number".to_owned())
166        });
167        assert!(matches!(result, Err(ConfigError::Config(_))));
168    }
169
170    #[test]
171    fn overrides_are_applied() {
172        let cfg = ProxyConfig::from_lookup(|key| match key {
173            "FIRSTPASS_BIND" => Some("0.0.0.0:9090".to_owned()),
174            "FIRSTPASS_TENANT" => Some("acme".to_owned()),
175            _ => None,
176        })
177        .unwrap();
178        assert_eq!(cfg.bind, "0.0.0.0:9090");
179        assert_eq!(cfg.tenant_id, "acme");
180    }
181
182    #[test]
183    fn enforce_mode_is_accepted() {
184        let cfg =
185            ProxyConfig::from_lookup(|key| (key == "FIRSTPASS_MODE").then(|| "enforce".to_owned()))
186                .unwrap();
187        assert_eq!(cfg.mode, Mode::Enforce);
188    }
189
190    #[test]
191    fn unknown_mode_is_rejected() {
192        let result =
193            ProxyConfig::from_lookup(|key| (key == "FIRSTPASS_MODE").then(|| "banana".to_owned()));
194        assert!(matches!(result, Err(ConfigError::UnsupportedMode(m)) if m == "banana"));
195    }
196
197    #[test]
198    fn routing_config_parses_inline() {
199        let toml = r#"
200[[route]]
201match = { task_kind = "code_edit" }
202mode = "enforce"
203ladder = ["anthropic/claude-haiku-4-5", "anthropic/claude-sonnet-5"]
204gates = ["non-empty"]
205"#;
206        let cfg = ProxyConfig::from_lookup(|key| match key {
207            "FIRSTPASS_CONFIG_TOML" => Some(toml.to_owned()),
208            _ => None,
209        })
210        .unwrap();
211        let routing = cfg.routing.expect("routing config present");
212        assert_eq!(routing.routes.len(), 1);
213        assert_eq!(routing.routes[0].mode, Mode::Enforce);
214    }
215
216    #[test]
217    fn bad_routing_config_is_an_error() {
218        let result = ProxyConfig::from_lookup(|key| match key {
219            "FIRSTPASS_CONFIG_TOML" => Some("this is not valid = = toml".to_owned()),
220            _ => None,
221        });
222        assert!(matches!(result, Err(ConfigError::Config(_))));
223    }
224}