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 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 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 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 if request.stream.unwrap_or(false) {
264 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 request.stream = Some(false); 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}