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 #[arg(long, env = "MAPLE_HOST", default_value = "127.0.0.1")]
15 pub host: String,
16
17 #[arg(short, long, env = "MAPLE_PORT", default_value = "8080")]
19 pub port: u16,
20
21 #[arg(
23 long,
24 env = "MAPLE_BACKEND_URL",
25 default_value = "https://enclave.trymaple.ai"
26 )]
27 pub backend_url: String,
28
29 #[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 #[arg(long, env = "MAPLE_API_KEY")]
40 pub default_api_key: Option<String>,
41
42 #[arg(short, long, env = "MAPLE_DEBUG")]
44 pub debug: bool,
45
46 #[arg(long, env = "MAPLE_ENABLE_CORS")]
48 pub enable_cors: bool,
49
50 #[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 #[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 let _ = dotenvy::dotenv();
79
80 Config::parse()
81 }
82
83 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 pub fn with_pcr0_environment(mut self, pcr0_environment: Pcr0Environment) -> Self {
108 self.pcr0_environment = pcr0_environment;
109 self
110 }
111
112 pub fn with_api_key(mut self, api_key: String) -> Self {
114 self.default_api_key = Some(api_key);
115 self
116 }
117
118 pub fn with_debug(mut self, debug: bool) -> Self {
120 self.debug = debug;
121 self
122 }
123
124 pub fn with_cors(mut self, enable_cors: bool) -> Self {
126 self.enable_cors = enable_cors;
127 self
128 }
129
130 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 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}