Skip to main content

maple_proxy/
config.rs

1use clap::Parser;
2use opensecret::Pcr0Environment;
3use serde::Serialize;
4use std::{net::SocketAddr, time::Duration};
5
6pub const DEFAULT_REQUEST_TIMEOUT_SECS: u64 = 300;
7pub const DEFAULT_STREAM_IDLE_TIMEOUT_SECS: u64 = 300;
8
9#[derive(Parser, Debug, Clone)]
10#[command(name = "maple-proxy")]
11#[command(about = "Lightweight OpenAI-compatible proxy server for Maple/OpenSecret")]
12pub struct Config {
13    /// Host to bind the server to
14    #[arg(long, env = "MAPLE_HOST", default_value = "127.0.0.1")]
15    pub host: String,
16
17    /// Port to bind the server to
18    #[arg(short, long, env = "MAPLE_PORT", default_value = "8080")]
19    pub port: u16,
20
21    /// OpenSecret/Maple backend URL
22    #[arg(
23        long,
24        env = "MAPLE_BACKEND_URL",
25        default_value = "https://enclave.trymaple.ai"
26    )]
27    pub backend_url: String,
28
29    /// PCR0 trust-root environment for backend attestation
30    #[arg(
31        long,
32        env = "MAPLE_PCR0_ENVIRONMENT",
33        default_value = "production",
34        value_parser = parse_pcr0_environment
35    )]
36    pub pcr0_environment: Pcr0Environment,
37
38    /// Default API key for Maple/OpenSecret (can be overridden by client Authorization header)
39    #[arg(long, env = "MAPLE_API_KEY")]
40    pub default_api_key: Option<String>,
41
42    /// Enable debug logging
43    #[arg(short, long, env = "MAPLE_DEBUG")]
44    pub debug: bool,
45
46    /// Enable CORS for all origins (useful for web clients)
47    #[arg(long, env = "MAPLE_ENABLE_CORS")]
48    pub enable_cors: bool,
49
50    /// Timeout for backend request setup and non-streaming responses, in seconds
51    #[arg(
52        long,
53        env = "MAPLE_REQUEST_TIMEOUT_SECS",
54        default_value_t = DEFAULT_REQUEST_TIMEOUT_SECS,
55        value_parser = clap::value_parser!(u64).range(1..)
56    )]
57    pub request_timeout_secs: u64,
58
59    /// Maximum time to wait between streaming response chunks, in seconds
60    #[arg(
61        long,
62        env = "MAPLE_STREAM_IDLE_TIMEOUT_SECS",
63        default_value_t = DEFAULT_STREAM_IDLE_TIMEOUT_SECS,
64        value_parser = clap::value_parser!(u64).range(1..)
65    )]
66    pub stream_idle_timeout_secs: u64,
67}
68
69impl Config {
70    pub fn socket_addr(&self) -> anyhow::Result<SocketAddr> {
71        let addr = format!("{}:{}", self.host, self.port);
72        addr.parse()
73            .map_err(|e| anyhow::anyhow!("Invalid socket address '{}': {}", addr, e))
74    }
75
76    pub fn load() -> Self {
77        // Load from .env file if it exists
78        let _ = dotenvy::dotenv();
79
80        Config::parse()
81    }
82
83    /// Create a new Config programmatically (for library usage)
84    pub fn new(host: String, port: u16, backend_url: String) -> Self {
85        Self {
86            host,
87            port,
88            backend_url,
89            pcr0_environment: Pcr0Environment::Production,
90            default_api_key: None,
91            debug: false,
92            enable_cors: false,
93            request_timeout_secs: DEFAULT_REQUEST_TIMEOUT_SECS,
94            stream_idle_timeout_secs: DEFAULT_STREAM_IDLE_TIMEOUT_SECS,
95        }
96    }
97
98    pub fn request_timeout(&self) -> Duration {
99        Duration::from_secs(self.request_timeout_secs)
100    }
101
102    pub fn stream_idle_timeout(&self) -> Duration {
103        Duration::from_secs(self.stream_idle_timeout_secs)
104    }
105
106    /// Builder-style method to select the backend PCR0 trust-root environment
107    pub fn with_pcr0_environment(mut self, pcr0_environment: Pcr0Environment) -> Self {
108        self.pcr0_environment = pcr0_environment;
109        self
110    }
111
112    /// Builder-style method to set the API key
113    pub fn with_api_key(mut self, api_key: String) -> Self {
114        self.default_api_key = Some(api_key);
115        self
116    }
117
118    /// Builder-style method to enable debug mode
119    pub fn with_debug(mut self, debug: bool) -> Self {
120        self.debug = debug;
121        self
122    }
123
124    /// Builder-style method to enable CORS
125    pub fn with_cors(mut self, enable_cors: bool) -> Self {
126        self.enable_cors = enable_cors;
127        self
128    }
129
130    /// Builder-style method to set the backend request timeout
131    pub fn with_request_timeout_secs(mut self, request_timeout_secs: u64) -> Self {
132        self.request_timeout_secs = request_timeout_secs;
133        self
134    }
135
136    /// Builder-style method to set the streaming idle timeout
137    pub fn with_stream_idle_timeout_secs(mut self, stream_idle_timeout_secs: u64) -> Self {
138        self.stream_idle_timeout_secs = stream_idle_timeout_secs;
139        self
140    }
141}
142
143fn parse_pcr0_environment(value: &str) -> Result<Pcr0Environment, String> {
144    match value {
145        "production" => Ok(Pcr0Environment::Production),
146        "development" => Ok(Pcr0Environment::Development),
147        _ => Err("PCR0 environment must be 'production' or 'development'".to_string()),
148    }
149}
150
151#[derive(Debug, Serialize)]
152pub(crate) struct OpenAIError {
153    error: OpenAIErrorDetails,
154}
155
156#[derive(Debug, Serialize)]
157struct OpenAIErrorDetails {
158    message: String,
159    #[serde(rename = "type")]
160    error_type: String,
161    param: Option<String>,
162    code: Option<String>,
163}
164
165impl OpenAIError {
166    fn new(message: impl Into<String>, error_type: impl Into<String>) -> Self {
167        Self {
168            error: OpenAIErrorDetails {
169                message: message.into(),
170                error_type: error_type.into(),
171                param: None,
172                code: None,
173            },
174        }
175    }
176
177    pub(crate) fn authentication_error(message: impl Into<String>) -> Self {
178        Self::new(message, "invalid_request_error")
179    }
180
181    pub(crate) fn server_error(message: impl Into<String>) -> Self {
182        Self::new(message, "server_error")
183    }
184}
185
186#[cfg(test)]
187mod tests {
188    use super::*;
189    use clap::{error::ErrorKind, Parser};
190    use std::sync::Mutex;
191
192    static PCR0_ENVIRONMENT_LOCK: Mutex<()> = Mutex::new(());
193
194    fn with_pcr0_environment_env<T>(value: Option<&str>, run: impl FnOnce() -> T) -> T {
195        let _guard = PCR0_ENVIRONMENT_LOCK.lock().unwrap();
196        let previous = std::env::var_os("MAPLE_PCR0_ENVIRONMENT");
197
198        match value {
199            Some(value) => std::env::set_var("MAPLE_PCR0_ENVIRONMENT", value),
200            None => std::env::remove_var("MAPLE_PCR0_ENVIRONMENT"),
201        }
202
203        let result = run();
204
205        match previous {
206            Some(previous) => std::env::set_var("MAPLE_PCR0_ENVIRONMENT", previous),
207            None => std::env::remove_var("MAPLE_PCR0_ENVIRONMENT"),
208        }
209
210        result
211    }
212
213    #[test]
214    fn config_new_uses_timeout_defaults() {
215        let config = Config::new(
216            "127.0.0.1".to_string(),
217            8080,
218            "https://enclave.trymaple.ai".to_string(),
219        );
220
221        assert_eq!(config.request_timeout_secs, DEFAULT_REQUEST_TIMEOUT_SECS);
222        assert_eq!(config.pcr0_environment, Pcr0Environment::Production);
223        assert_eq!(
224            config.stream_idle_timeout_secs,
225            DEFAULT_STREAM_IDLE_TIMEOUT_SECS
226        );
227        assert_eq!(
228            config.request_timeout(),
229            Duration::from_secs(DEFAULT_REQUEST_TIMEOUT_SECS)
230        );
231        assert_eq!(
232            config.stream_idle_timeout(),
233            Duration::from_secs(DEFAULT_STREAM_IDLE_TIMEOUT_SECS)
234        );
235    }
236
237    #[test]
238    fn pcr0_environment_defaults_to_production_for_cli() {
239        let config =
240            with_pcr0_environment_env(None, || Config::try_parse_from(["maple-proxy"]).unwrap());
241
242        assert_eq!(config.pcr0_environment, Pcr0Environment::Production);
243    }
244
245    #[test]
246    fn pcr0_environment_accepts_explicit_development_cli_value() {
247        let config =
248            Config::try_parse_from(["maple-proxy", "--pcr0-environment", "development"]).unwrap();
249
250        assert_eq!(config.pcr0_environment, Pcr0Environment::Development);
251    }
252
253    #[test]
254    fn pcr0_environment_builder_selects_development() {
255        let config = Config::new(
256            "127.0.0.1".to_string(),
257            8080,
258            "https://enclave.secretgpt.ai".to_string(),
259        )
260        .with_pcr0_environment(Pcr0Environment::Development);
261
262        assert_eq!(config.pcr0_environment, Pcr0Environment::Development);
263    }
264
265    #[test]
266    fn pcr0_environment_accepts_explicit_development_env_value() {
267        let config = with_pcr0_environment_env(Some("development"), || {
268            Config::try_parse_from(["maple-proxy"]).unwrap()
269        });
270
271        assert_eq!(config.pcr0_environment, Pcr0Environment::Development);
272    }
273
274    #[test]
275    fn pcr0_environment_rejects_unknown_values() {
276        let error =
277            Config::try_parse_from(["maple-proxy", "--pcr0-environment", "staging"]).unwrap_err();
278
279        assert_eq!(error.kind(), ErrorKind::ValueValidation);
280    }
281
282    #[test]
283    fn timeout_builder_methods_override_defaults() {
284        let config = Config::new(
285            "127.0.0.1".to_string(),
286            8080,
287            "https://enclave.trymaple.ai".to_string(),
288        )
289        .with_request_timeout_secs(45)
290        .with_stream_idle_timeout_secs(15);
291
292        assert_eq!(config.request_timeout(), Duration::from_secs(45));
293        assert_eq!(config.stream_idle_timeout(), Duration::from_secs(15));
294    }
295
296    #[test]
297    fn timeout_cli_values_must_be_positive() {
298        let request_timeout_error =
299            Config::try_parse_from(["maple-proxy", "--request-timeout-secs", "0"]).unwrap_err();
300        assert_eq!(request_timeout_error.kind(), ErrorKind::ValueValidation);
301
302        let stream_idle_timeout_error =
303            Config::try_parse_from(["maple-proxy", "--stream-idle-timeout-secs", "0"]).unwrap_err();
304        assert_eq!(stream_idle_timeout_error.kind(), ErrorKind::ValueValidation);
305    }
306}