firstpass_proxy/
config.rs1use std::env;
5
6use firstpass_core::{Config as RoutingConfig, Mode, PriceTable};
7
8const DEFAULT_PROMPT_SALT: &str = "firstpass-dev-salt";
12
13#[derive(Debug, Clone)]
15pub struct ProxyConfig {
16 pub bind: String,
18 pub upstream_anthropic: String,
20 pub upstream_openai: String,
22 pub db_path: String,
24 pub tenant_id: String,
26 pub prompt_salt: String,
28 pub mode: Mode,
30 pub routing: Option<RoutingConfig>,
33 pub prices: PriceTable,
35 pub max_concurrency: usize,
39}
40
41const DEFAULT_MAX_CONCURRENCY: usize = 512;
43
44#[derive(Debug, thiserror::Error)]
46pub enum ConfigError {
47 #[error(
49 "FIRSTPASS_MODE={0:?} is not a known mode; set `observe` or `enforce`, or leave it unset"
50 )]
51 UnsupportedMode(String),
52
53 #[error("routing config error: {0}")]
55 Config(String),
56}
57
58impl ProxyConfig {
59 pub fn from_env() -> Result<Self, ConfigError> {
65 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 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}