Skip to main content

maple_proxy/
proxy.rs

1use crate::config::{Config, OpenAIError};
2use axum::{
3    extract::State,
4    http::{HeaderMap, StatusCode},
5    response::{IntoResponse, Response, Sse},
6    Json,
7};
8use dashmap::DashMap;
9use futures::Stream;
10use opensecret::{
11    ChatCompletionChunk, ChatCompletionRequest, EmbeddingRequest, EmbeddingResponse,
12    ModelsResponse, OpenSecretClient, Result as OpenSecretResult,
13};
14use std::{
15    convert::Infallible,
16    sync::Arc,
17    time::{Duration, Instant},
18};
19use tokio::sync::OnceCell;
20use tracing::{debug, error};
21
22const CLIENT_CACHE_MAX_ENTRIES: usize = 1024;
23const CLIENT_CACHE_ENTRY_TTL: Duration = Duration::from_secs(60 * 60);
24
25type ProxyError = (StatusCode, Json<OpenAIError>);
26
27struct CachedClientEntry {
28    cell: OnceCell<Arc<OpenSecretClient>>,
29    created_at: Instant,
30}
31
32impl CachedClientEntry {
33    fn new(created_at: Instant) -> Self {
34        Self {
35            cell: OnceCell::new(),
36            created_at,
37        }
38    }
39
40    fn is_expired(&self, now: Instant) -> bool {
41        now.saturating_duration_since(self.created_at) >= CLIENT_CACHE_ENTRY_TTL
42    }
43}
44
45#[derive(Clone)]
46pub struct ProxyState {
47    pub config: Config,
48    clients: DashMap<String, Arc<CachedClientEntry>>,
49}
50
51impl ProxyState {
52    pub fn new(config: Config) -> Self {
53        Self {
54            config,
55            clients: DashMap::new(),
56        }
57    }
58
59    fn client_entry_for_api_key(&self, api_key: &str) -> Arc<CachedClientEntry> {
60        let now = Instant::now();
61
62        if let Some(entry) = self.clients.get(api_key) {
63            if !entry.is_expired(now) {
64                return Arc::clone(entry.value());
65            }
66        }
67
68        self.clients
69            .remove_if(api_key, |_, entry| entry.is_expired(now));
70        self.evict_expired_clients(now);
71        self.evict_oldest_client_if_needed();
72
73        self.clients
74            .entry(api_key.to_string())
75            .or_insert_with(|| Arc::new(CachedClientEntry::new(now)))
76            .clone()
77    }
78
79    async fn client_for_api_key(&self, api_key: &str) -> Result<Arc<OpenSecretClient>, ProxyError> {
80        let cache_key = api_key.to_string();
81        let client_entry = self.client_entry_for_api_key(&cache_key);
82        let backend_url = self.config.backend_url.clone();
83        let request_timeout = self.config.request_timeout();
84        let init_api_key = cache_key.clone();
85
86        let client = client_entry
87            .cell
88            .get_or_try_init(|| async move {
89                debug!(
90                    "Creating OpenSecret client for API key: {}...",
91                    &init_api_key[..8.min(init_api_key.len())]
92                );
93                create_client_with_auth(&backend_url, &init_api_key, request_timeout)
94                    .await
95                    .map(Arc::new)
96            })
97            .await;
98
99        match client {
100            Ok(client) => Ok(Arc::clone(client)),
101            Err(error) => {
102                self.remove_client_entry_if_same(&cache_key, &client_entry);
103                Err(error)
104            }
105        }
106    }
107
108    fn remove_client_entry_if_same(&self, api_key: &str, client_entry: &Arc<CachedClientEntry>) {
109        self.clients
110            .remove_if(api_key, |_, entry| Arc::ptr_eq(entry, client_entry));
111    }
112
113    fn evict_expired_clients(&self, now: Instant) {
114        self.clients.retain(|_, entry| !entry.is_expired(now));
115    }
116
117    fn evict_oldest_client_if_needed(&self) {
118        while self.clients.len() >= CLIENT_CACHE_MAX_ENTRIES {
119            let oldest_key = self
120                .clients
121                .iter()
122                .min_by_key(|entry| entry.value().created_at)
123                .map(|entry| entry.key().clone());
124
125            let Some(oldest_key) = oldest_key else {
126                break;
127            };
128
129            self.clients.remove(&oldest_key);
130        }
131    }
132}
133
134pub async fn health_check() -> impl IntoResponse {
135    Json(serde_json::json!({
136        "status": "ok",
137        "service": "maple-proxy",
138        "version": env!("CARGO_PKG_VERSION")
139    }))
140}
141
142fn extract_api_key(
143    headers: &HeaderMap,
144    default_key: &Option<String>,
145) -> Result<String, OpenAIError> {
146    // Try to get API key from Authorization header first
147    if let Some(auth_header) = headers.get("authorization") {
148        let auth_str = auth_header.to_str().map_err(|_| {
149            OpenAIError::authentication_error("Invalid Authorization header format")
150        })?;
151
152        if let Some(key) = auth_str.strip_prefix("Bearer ") {
153            return Ok(key.to_string());
154        }
155    }
156
157    // Fall back to default API key from config
158    default_key
159        .as_ref()
160        .cloned()
161        .ok_or_else(|| OpenAIError::authentication_error("No API key provided. Set MAPLE_API_KEY environment variable or provide Authorization header"))
162}
163
164async fn create_client_with_auth(
165    backend_url: &str,
166    api_key: &str,
167    request_timeout: Duration,
168) -> Result<OpenSecretClient, ProxyError> {
169    let client =
170        OpenSecretClient::new_with_api_key(backend_url, api_key.to_string()).map_err(|e| {
171            (
172                StatusCode::INTERNAL_SERVER_ERROR,
173                Json(OpenAIError::server_error(format!(
174                    "Failed to create client: {}",
175                    e
176                ))),
177            )
178        })?;
179
180    // Perform attestation handshake
181    tokio::time::timeout(request_timeout, client.perform_attestation_handshake())
182        .await
183        .map_err(|_| timeout_response("Attestation handshake", request_timeout))?
184        .map_err(|e| {
185            error!("Attestation handshake failed: {}", e);
186            (
187                StatusCode::INTERNAL_SERVER_ERROR,
188                Json(OpenAIError::server_error(
189                    "Failed to establish secure connection with Maple backend",
190                )),
191            )
192        })?;
193
194    Ok(client)
195}
196
197fn timeout_response(operation: &str, timeout: Duration) -> ProxyError {
198    error!(
199        "{} timed out after {} seconds",
200        operation,
201        timeout.as_secs()
202    );
203    (
204        StatusCode::GATEWAY_TIMEOUT,
205        Json(OpenAIError::server_error(format!(
206            "{} timed out after {} seconds",
207            operation,
208            timeout.as_secs()
209        ))),
210    )
211}
212
213pub async fn list_models(
214    State(state): State<Arc<ProxyState>>,
215    headers: HeaderMap,
216) -> Result<Json<ModelsResponse>, (StatusCode, Json<OpenAIError>)> {
217    let api_key = extract_api_key(&headers, &state.config.default_api_key)
218        .map_err(|e| (StatusCode::UNAUTHORIZED, Json(e)))?;
219
220    debug!(
221        "Listing models for API key: {}...",
222        &api_key[..8.min(api_key.len())]
223    );
224
225    let client = state.client_for_api_key(&api_key).await?;
226
227    let request_timeout = state.config.request_timeout();
228    let models = tokio::time::timeout(request_timeout, client.get_models())
229        .await
230        .map_err(|_| timeout_response("List models request", request_timeout))?
231        .map_err(|e| {
232            error!("Failed to get models: {}", e);
233            (
234                StatusCode::INTERNAL_SERVER_ERROR,
235                Json(OpenAIError::server_error(format!(
236                    "Failed to retrieve models: {}",
237                    e
238                ))),
239            )
240        })?;
241
242    debug!("Successfully retrieved {} models", models.data.len());
243    Ok(Json(models))
244}
245
246pub async fn create_chat_completion(
247    State(state): State<Arc<ProxyState>>,
248    headers: HeaderMap,
249    Json(mut request): Json<ChatCompletionRequest>,
250) -> Result<Response, (StatusCode, Json<OpenAIError>)> {
251    let api_key = extract_api_key(&headers, &state.config.default_api_key)
252        .map_err(|e| (StatusCode::UNAUTHORIZED, Json(e)))?;
253
254    debug!(
255        "Chat completion request for model: {}, stream: {:?}",
256        request.model,
257        request.stream.unwrap_or(false)
258    );
259
260    let client = state.client_for_api_key(&api_key).await?;
261
262    // Check if streaming is requested
263    if request.stream.unwrap_or(false) {
264        // Handle streaming response
265        let request_timeout = state.config.request_timeout();
266        let stream = tokio::time::timeout(
267            request_timeout,
268            client.create_chat_completion_stream(request),
269        )
270        .await
271        .map_err(|_| timeout_response("Streaming chat completion request", request_timeout))?
272        .map_err(|e| {
273            error!("Failed to create streaming chat completion: {}", e);
274            (
275                StatusCode::INTERNAL_SERVER_ERROR,
276                Json(OpenAIError::server_error(format!(
277                    "Failed to create streaming completion: {}",
278                    e
279                ))),
280            )
281        })?;
282
283        let sse_stream = create_sse_stream(stream, state.config.stream_idle_timeout());
284        Ok(Sse::new(sse_stream).into_response())
285    } else {
286        // Handle non-streaming response
287        request.stream = Some(false); // Ensure it's explicitly false
288
289        let request_timeout = state.config.request_timeout();
290        let response =
291            tokio::time::timeout(request_timeout, client.create_chat_completion(request))
292                .await
293                .map_err(|_| timeout_response("Chat completion request", request_timeout))?
294                .map_err(|e| {
295                    error!("Failed to create chat completion: {}", e);
296                    (
297                        StatusCode::INTERNAL_SERVER_ERROR,
298                        Json(OpenAIError::server_error(format!(
299                            "Failed to create completion: {}",
300                            e
301                        ))),
302                    )
303                })?;
304
305        debug!("Successfully created chat completion: {}", response.id);
306        Ok(Json(response).into_response())
307    }
308}
309
310fn create_sse_stream(
311    mut stream: std::pin::Pin<Box<dyn Stream<Item = OpenSecretResult<ChatCompletionChunk>> + Send>>,
312    stream_idle_timeout: Duration,
313) -> impl Stream<Item = Result<axum::response::sse::Event, Infallible>> {
314    async_stream::stream! {
315        use futures::StreamExt;
316        let mut completed_normally = false;
317
318        loop {
319            let chunk_result = match tokio::time::timeout(stream_idle_timeout, stream.next()).await {
320                Ok(Some(chunk_result)) => chunk_result,
321                Ok(None) => {
322                    completed_normally = true;
323                    break;
324                }
325                Err(_) => {
326                    error!(
327                        "Streaming chat completion idle timed out after {} seconds",
328                        stream_idle_timeout.as_secs()
329                    );
330                    let error_event = axum::response::sse::Event::default()
331                        .data(format!(
332                            r#"{{"error": "Stream idle timeout after {} seconds"}}"#,
333                            stream_idle_timeout.as_secs()
334                        ));
335                    yield Ok(error_event);
336                    break;
337                }
338            };
339
340            match chunk_result {
341                Ok(chunk) => {
342                    match serde_json::to_string(&chunk) {
343                        Ok(json) => {
344                            let event = axum::response::sse::Event::default()
345                                .data(json);
346                            yield Ok(event);
347                        }
348                        Err(e) => {
349                            error!("Failed to serialize chunk: {}", e);
350                            let error_event = axum::response::sse::Event::default()
351                                .data(format!(r#"{{"error": "Failed to serialize chunk: {}"}}"#, e));
352                            yield Ok(error_event);
353                            break;
354                        }
355                    }
356                }
357                Err(e) => {
358                    error!("Stream error: {}", e);
359                    let error_event = axum::response::sse::Event::default()
360                        .data(format!(r#"{{"error": "Stream error: {}"}}"#, e));
361                    yield Ok(error_event);
362                    break;
363                }
364            }
365        }
366
367        if completed_normally {
368            let done_event = axum::response::sse::Event::default()
369                .data("[DONE]");
370            yield Ok(done_event);
371        }
372    }
373}
374
375pub async fn create_embeddings(
376    State(state): State<Arc<ProxyState>>,
377    headers: HeaderMap,
378    Json(request): Json<EmbeddingRequest>,
379) -> Result<Json<EmbeddingResponse>, (StatusCode, Json<OpenAIError>)> {
380    let api_key = extract_api_key(&headers, &state.config.default_api_key)
381        .map_err(|e| (StatusCode::UNAUTHORIZED, Json(e)))?;
382
383    debug!("Embeddings request for model: {}", request.model);
384
385    let client = state.client_for_api_key(&api_key).await?;
386
387    let request_timeout = state.config.request_timeout();
388    let response = tokio::time::timeout(request_timeout, client.create_embeddings(request))
389        .await
390        .map_err(|_| timeout_response("Embeddings request", request_timeout))?
391        .map_err(|e| {
392            error!("Failed to create embeddings: {}", e);
393            (
394                StatusCode::INTERNAL_SERVER_ERROR,
395                Json(OpenAIError::server_error(format!(
396                    "Failed to create embeddings: {}",
397                    e
398                ))),
399            )
400        })?;
401
402    debug!(
403        "Successfully created embeddings with {} vectors",
404        response.data.len()
405    );
406    Ok(Json(response))
407}
408
409#[cfg(test)]
410mod tests {
411    use super::*;
412
413    fn test_config() -> Config {
414        Config {
415            host: "127.0.0.1".to_string(),
416            port: 0,
417            backend_url: "http://localhost:3000".to_string(),
418            default_api_key: None,
419            debug: false,
420            enable_cors: false,
421            request_timeout_secs: 300,
422            stream_idle_timeout_secs: 300,
423        }
424    }
425
426    #[test]
427    fn reuses_client_cell_for_same_api_key() {
428        let state = ProxyState::new(test_config());
429
430        let first = state.client_entry_for_api_key("key-a");
431        let second = state.client_entry_for_api_key("key-a");
432
433        assert!(Arc::ptr_eq(&first, &second));
434        assert_eq!(state.clients.len(), 1);
435    }
436
437    #[test]
438    fn keeps_client_cells_separate_by_api_key() {
439        let state = ProxyState::new(test_config());
440
441        let first = state.client_entry_for_api_key("key-a");
442        let second = state.client_entry_for_api_key("key-b");
443
444        assert!(!Arc::ptr_eq(&first, &second));
445        assert_eq!(state.clients.len(), 2);
446    }
447
448    #[test]
449    fn evicts_old_client_cell_at_capacity() {
450        let state = ProxyState::new(test_config());
451
452        for index in 0..CLIENT_CACHE_MAX_ENTRIES {
453            state.client_entry_for_api_key(&format!("key-{}", index));
454        }
455
456        state.client_entry_for_api_key("new-key");
457
458        assert!(state.clients.contains_key("new-key"));
459        assert_eq!(state.clients.len(), CLIENT_CACHE_MAX_ENTRIES);
460    }
461
462    #[test]
463    fn replaces_expired_client_cell() {
464        let state = ProxyState::new(test_config());
465        let expired_at = Instant::now()
466            .checked_sub(CLIENT_CACHE_ENTRY_TTL + Duration::from_secs(1))
467            .unwrap();
468        let expired = Arc::new(CachedClientEntry::new(expired_at));
469
470        state
471            .clients
472            .insert("key-a".to_string(), Arc::clone(&expired));
473
474        let fresh = state.client_entry_for_api_key("key-a");
475
476        assert!(!Arc::ptr_eq(&expired, &fresh));
477        assert_eq!(state.clients.len(), 1);
478    }
479
480    #[test]
481    fn removes_failed_initialization_cell() {
482        let state = ProxyState::new(test_config());
483        let entry = state.client_entry_for_api_key("key-a");
484
485        state.remove_client_entry_if_same("key-a", &entry);
486
487        assert!(!state.clients.contains_key("key-a"));
488    }
489
490    #[tokio::test]
491    async fn sse_stream_emits_error_when_idle_timeout_expires() {
492        use futures::{stream, StreamExt};
493
494        let backend_stream = Box::pin(stream::pending::<OpenSecretResult<ChatCompletionChunk>>());
495        let sse_stream = create_sse_stream(backend_stream, Duration::from_millis(1));
496        futures::pin_mut!(sse_stream);
497
498        assert!(
499            tokio::time::timeout(Duration::from_secs(1), sse_stream.next())
500                .await
501                .unwrap()
502                .is_some()
503        );
504        assert!(
505            tokio::time::timeout(Duration::from_secs(1), sse_stream.next())
506                .await
507                .unwrap()
508                .is_none()
509        );
510    }
511}