Skip to main content

maple_proxy/
config.rs

1use clap::Parser;
2use serde::Serialize;
3use std::{net::SocketAddr, time::Duration};
4
5pub const DEFAULT_REQUEST_TIMEOUT_SECS: u64 = 300;
6pub const DEFAULT_STREAM_IDLE_TIMEOUT_SECS: u64 = 300;
7
8#[derive(Parser, Debug, Clone)]
9#[command(name = "maple-proxy")]
10#[command(about = "Lightweight OpenAI-compatible proxy server for Maple/OpenSecret")]
11pub struct Config {
12    /// Host to bind the server to
13    #[arg(long, env = "MAPLE_HOST", default_value = "127.0.0.1")]
14    pub host: String,
15
16    /// Port to bind the server to
17    #[arg(short, long, env = "MAPLE_PORT", default_value = "8080")]
18    pub port: u16,
19
20    /// OpenSecret/Maple backend URL
21    #[arg(
22        long,
23        env = "MAPLE_BACKEND_URL",
24        default_value = "https://enclave.trymaple.ai"
25    )]
26    pub backend_url: String,
27
28    /// Default API key for Maple/OpenSecret (can be overridden by client Authorization header)
29    #[arg(long, env = "MAPLE_API_KEY")]
30    pub default_api_key: Option<String>,
31
32    /// Enable debug logging
33    #[arg(short, long, env = "MAPLE_DEBUG")]
34    pub debug: bool,
35
36    /// Enable CORS for all origins (useful for web clients)
37    #[arg(long, env = "MAPLE_ENABLE_CORS")]
38    pub enable_cors: bool,
39
40    /// Timeout for backend request setup and non-streaming responses, in seconds
41    #[arg(
42        long,
43        env = "MAPLE_REQUEST_TIMEOUT_SECS",
44        default_value_t = DEFAULT_REQUEST_TIMEOUT_SECS,
45        value_parser = clap::value_parser!(u64).range(1..)
46    )]
47    pub request_timeout_secs: u64,
48
49    /// Maximum time to wait between streaming response chunks, in seconds
50    #[arg(
51        long,
52        env = "MAPLE_STREAM_IDLE_TIMEOUT_SECS",
53        default_value_t = DEFAULT_STREAM_IDLE_TIMEOUT_SECS,
54        value_parser = clap::value_parser!(u64).range(1..)
55    )]
56    pub stream_idle_timeout_secs: u64,
57}
58
59impl Config {
60    pub fn socket_addr(&self) -> anyhow::Result<SocketAddr> {
61        let addr = format!("{}:{}", self.host, self.port);
62        addr.parse()
63            .map_err(|e| anyhow::anyhow!("Invalid socket address '{}': {}", addr, e))
64    }
65
66    pub fn load() -> Self {
67        // Load from .env file if it exists
68        let _ = dotenvy::dotenv();
69
70        Config::parse()
71    }
72
73    /// Create a new Config programmatically (for library usage)
74    pub fn new(host: String, port: u16, backend_url: String) -> Self {
75        Self {
76            host,
77            port,
78            backend_url,
79            default_api_key: None,
80            debug: false,
81            enable_cors: false,
82            request_timeout_secs: DEFAULT_REQUEST_TIMEOUT_SECS,
83            stream_idle_timeout_secs: DEFAULT_STREAM_IDLE_TIMEOUT_SECS,
84        }
85    }
86
87    pub fn request_timeout(&self) -> Duration {
88        Duration::from_secs(self.request_timeout_secs)
89    }
90
91    pub fn stream_idle_timeout(&self) -> Duration {
92        Duration::from_secs(self.stream_idle_timeout_secs)
93    }
94
95    /// Builder-style method to set the API key
96    pub fn with_api_key(mut self, api_key: String) -> Self {
97        self.default_api_key = Some(api_key);
98        self
99    }
100
101    /// Builder-style method to enable debug mode
102    pub fn with_debug(mut self, debug: bool) -> Self {
103        self.debug = debug;
104        self
105    }
106
107    /// Builder-style method to enable CORS
108    pub fn with_cors(mut self, enable_cors: bool) -> Self {
109        self.enable_cors = enable_cors;
110        self
111    }
112
113    /// Builder-style method to set the backend request timeout
114    pub fn with_request_timeout_secs(mut self, request_timeout_secs: u64) -> Self {
115        self.request_timeout_secs = request_timeout_secs;
116        self
117    }
118
119    /// Builder-style method to set the streaming idle timeout
120    pub fn with_stream_idle_timeout_secs(mut self, stream_idle_timeout_secs: u64) -> Self {
121        self.stream_idle_timeout_secs = stream_idle_timeout_secs;
122        self
123    }
124}
125
126#[derive(Debug, Serialize)]
127pub(crate) struct OpenAIError {
128    error: OpenAIErrorDetails,
129}
130
131#[derive(Debug, Serialize)]
132struct OpenAIErrorDetails {
133    message: String,
134    #[serde(rename = "type")]
135    error_type: String,
136    param: Option<String>,
137    code: Option<String>,
138}
139
140impl OpenAIError {
141    fn new(message: impl Into<String>, error_type: impl Into<String>) -> Self {
142        Self {
143            error: OpenAIErrorDetails {
144                message: message.into(),
145                error_type: error_type.into(),
146                param: None,
147                code: None,
148            },
149        }
150    }
151
152    pub(crate) fn authentication_error(message: impl Into<String>) -> Self {
153        Self::new(message, "invalid_request_error")
154    }
155
156    pub(crate) fn server_error(message: impl Into<String>) -> Self {
157        Self::new(message, "server_error")
158    }
159}
160
161#[cfg(test)]
162mod tests {
163    use super::*;
164    use clap::{error::ErrorKind, Parser};
165
166    #[test]
167    fn config_new_uses_timeout_defaults() {
168        let config = Config::new(
169            "127.0.0.1".to_string(),
170            8080,
171            "https://enclave.trymaple.ai".to_string(),
172        );
173
174        assert_eq!(config.request_timeout_secs, DEFAULT_REQUEST_TIMEOUT_SECS);
175        assert_eq!(
176            config.stream_idle_timeout_secs,
177            DEFAULT_STREAM_IDLE_TIMEOUT_SECS
178        );
179        assert_eq!(
180            config.request_timeout(),
181            Duration::from_secs(DEFAULT_REQUEST_TIMEOUT_SECS)
182        );
183        assert_eq!(
184            config.stream_idle_timeout(),
185            Duration::from_secs(DEFAULT_STREAM_IDLE_TIMEOUT_SECS)
186        );
187    }
188
189    #[test]
190    fn timeout_builder_methods_override_defaults() {
191        let config = Config::new(
192            "127.0.0.1".to_string(),
193            8080,
194            "https://enclave.trymaple.ai".to_string(),
195        )
196        .with_request_timeout_secs(45)
197        .with_stream_idle_timeout_secs(15);
198
199        assert_eq!(config.request_timeout(), Duration::from_secs(45));
200        assert_eq!(config.stream_idle_timeout(), Duration::from_secs(15));
201    }
202
203    #[test]
204    fn timeout_cli_values_must_be_positive() {
205        let request_timeout_error =
206            Config::try_parse_from(["maple-proxy", "--request-timeout-secs", "0"]).unwrap_err();
207        assert_eq!(request_timeout_error.kind(), ErrorKind::ValueValidation);
208
209        let stream_idle_timeout_error =
210            Config::try_parse_from(["maple-proxy", "--stream-idle-timeout-secs", "0"]).unwrap_err();
211        assert_eq!(stream_idle_timeout_error.kind(), ErrorKind::ValueValidation);
212    }
213}