Skip to main content

llm_manager/
serve_api.rs

1use std::net::SocketAddr;
2use std::sync::{Arc, Mutex, RwLock};
3use std::time::Instant;
4
5use axum::Json;
6use axum::Router;
7use axum::body::{Body, Bytes, to_bytes};
8use axum::extract::State;
9use axum::http::StatusCode;
10use axum::response::IntoResponse;
11use axum::routing::{get, post};
12use futures_util::StreamExt;
13use tower_http::trace::TraceLayer;
14use tracing::info;
15
16use crate::models::clean_host;
17
18use reqwest::Client;
19
20use crate::backend::web_context;
21
22/// HTTP hop-by-hop headers to strip when proxying.
23const HOP_BY_HOP: &[&str] = &[
24    "connection",
25    "keep-alive",
26    "proxy-authenticate",
27    "proxy-authorization",
28    "te",
29    "trailer",
30    "transfer-encoding",
31    "upgrade",
32    "host",
33    "content-length",
34];
35
36pub struct StatusCache {
37    pub models: usize,
38    pub cached_at: Instant,
39}
40
41#[derive(Clone)]
42pub struct ApiState {
43    pub server_url: String,
44    pub api_key: Option<String>,
45    pub model_name: String,
46    pub pid: u32,
47    pub start_time: Instant,
48    pub port: u16,
49    pub client: reqwest::Client,
50    pub status_cache: Arc<RwLock<StatusCache>>,
51    pub system_prompt_preset_name: String,
52    pub web_search_engine: String,
53    pub web_search_engine_url: String,
54    pub web_search_enabled: bool,
55    pub web_search_api_key: Option<String>,
56    pub log_callback: Arc<Mutex<Option<Box<dyn Fn(String) + Send + Sync>>>>,
57}
58
59fn extract_api_key(headers: &axum::http::HeaderMap) -> Option<String> {
60    headers
61        .get("Authorization")
62        .and_then(|v| v.to_str().ok())
63        .and_then(|v| v.strip_prefix("Bearer "))
64        .map(|s| s.to_string())
65}
66
67async fn auth_middleware(
68    State(state): State<ApiState>,
69    req: axum::extract::Request,
70    next: axum::middleware::Next,
71) -> axum::response::Response {
72    tracing::debug!("auth_middleware: api_key={:?}", state.api_key.is_some());
73    if let Some(expected) = &state.api_key {
74        let provided = extract_api_key(req.headers());
75        let expected_bytes = expected.as_bytes();
76        let not_equal = if let Some(provided_str) = provided {
77            constant_time_not_eq(provided_str.as_bytes(), expected_bytes)
78        } else {
79            true
80        };
81        if not_equal {
82            tracing::debug!(
83                "auth_middleware: rejecting request, not_equal={}",
84                not_equal
85            );
86            return (
87                StatusCode::UNAUTHORIZED,
88                Json(serde_json::json!({"error": "Unauthorized"})),
89            )
90                .into_response();
91        }
92    }
93    next.run(req).await
94}
95
96/// Constant-time byte comparison: returns true if a != b.
97/// Always processes all bytes regardless of where the first difference occurs.
98fn constant_time_not_eq(a: &[u8], b: &[u8]) -> bool {
99    if a.len() != b.len() {
100        return true;
101    }
102    let mut result: u8 = 0;
103    for (x, y) in a.iter().zip(b.iter()) {
104        result |= x ^ y;
105    }
106    result != 0
107}
108
109/// Proxy a request to the llama-server backend with SSE streaming support.
110/// Checks Content-Type: if text/event-stream, streams the body; otherwise buffers.
111async fn proxy_streaming(
112    State(state): State<ApiState>,
113    req: axum::extract::Request,
114) -> impl IntoResponse {
115    let path = req.uri().path().to_string();
116    let method = req.method().clone();
117    let headers = req.headers().clone();
118
119    let url = format!("{}{}", state.server_url, path);
120
121    // For chat completions and completions, drain body and optionally inject web search
122    if (path == "/v1/chat/completions" || path == "/v1/completions")
123        && method == axum::http::Method::POST
124    {
125        info!("API: proxying {} {}", method, path);
126        {
127            let cb = state.log_callback.lock().unwrap();
128            if let Some(c) = cb.as_ref() {
129                c(format!("API: proxying {} {}", method, path));
130            }
131        }
132        let body_bytes = match to_bytes(req.into_body(), 10 * 1024 * 1024).await {
133            Ok(b) => b,
134            Err(e) => {
135                info!("Failed to collect request body for {}: {}", path, e);
136                return (
137                    StatusCode::BAD_GATEWAY,
138                    Json(
139                        serde_json::json!({"error": format!("Failed to read request body: {}", e)}),
140                    ),
141                )
142                    .into_response();
143            }
144        };
145
146        let body_bytes = body_bytes;
147        let mut request_json: serde_json::Value = match serde_json::from_slice(&body_bytes) {
148            Ok(j) => j,
149            Err(e) => {
150                info!("Failed to parse request JSON for {}: {}", path, e);
151                return (
152                    StatusCode::BAD_REQUEST,
153                    Json(serde_json::json!({"error": format!("Invalid JSON: {}", e)})),
154                )
155                    .into_response();
156            }
157        };
158
159        info!(
160            "API: web_search_enabled={}, preset='{}', engine='{}'",
161            state.web_search_enabled, state.system_prompt_preset_name, state.web_search_engine
162        );
163        {
164            let cb = state.log_callback.lock().unwrap();
165            if let Some(c) = cb.as_ref() {
166                c(format!(
167                    "API: web_search_enabled={}, preset='{}', engine='{}'",
168                    state.web_search_enabled,
169                    state.system_prompt_preset_name,
170                    state.web_search_engine
171                ));
172            }
173        }
174
175        let result = web_context::build_injected_prompt(
176            &state.system_prompt_preset_name,
177            &request_json,
178            state.web_search_enabled,
179            &state.web_search_engine,
180            &state.web_search_engine_url,
181            state.web_search_api_key.as_deref().unwrap_or(""),
182            &state.log_callback,
183        )
184        .await;
185
186        info!(
187            "API: web search performed={}, content_len={}",
188            result.performed,
189            result.content.len()
190        );
191        {
192            let cb = state.log_callback.lock().unwrap();
193            if let Some(c) = cb.as_ref() {
194                c(format!(
195                    "API: web search performed={}, content_len={}",
196                    result.performed,
197                    result.content.len()
198                ));
199            }
200        }
201        if result.performed
202            && !result.content.is_empty()
203            && let Some(obj) = request_json.as_object_mut()
204            && let Some(messages) = obj.get_mut("messages").and_then(|m| m.as_array_mut())
205            && let Some(last) = messages.last_mut()
206            && let Some(content_val) = last.get_mut("content")
207        {
208            *content_val = serde_json::Value::String(result.content);
209        }
210
211        let modified_body = request_json.clone();
212
213        if let Some(messages) = modified_body.get("messages").and_then(|m| m.as_array()) {
214            let last_content = messages
215                .last()
216                .and_then(|m| m.get("content").and_then(|c| c.as_str()))
217                .unwrap_or("");
218            info!(
219                "Prompt to llama-server: {} messages, last content ({} chars):\n{}",
220                messages.len(),
221                last_content.len(),
222                last_content
223            );
224        }
225
226        let body_stream = futures_util::stream::once(async move {
227            Ok::<Bytes, std::convert::Infallible>(Bytes::from(
228                serde_json::to_vec(&modified_body).unwrap_or(body_bytes.to_vec()),
229            ))
230        });
231
232        let mut request_builder = state.client.post(&url);
233
234        let mut filtered = axum::http::HeaderMap::new();
235        for (name, value) in headers.iter() {
236            let n = name.as_str();
237            if !HOP_BY_HOP.contains(&n) && n != "authorization" {
238                filtered.insert(name, value.clone());
239            }
240        }
241        request_builder = request_builder.headers(filtered);
242
243        let response = request_builder
244            .body(reqwest::Body::wrap_stream(body_stream))
245            .send()
246            .await;
247
248        let response = handle_response(response, &path).await;
249        return response.into_response();
250    }
251
252    // Stream request body directly to backend (no drain to memory)
253    let body_stream = req
254        .into_body()
255        .into_data_stream()
256        .map(|r| r.map_err(|e| std::io::Error::other(format!("{}", e))));
257
258    let mut request_builder = match method {
259        axum::http::Method::GET => state.client.get(&url),
260        axum::http::Method::POST => state.client.post(&url),
261        axum::http::Method::PUT => state.client.put(&url),
262        axum::http::Method::DELETE => state.client.delete(&url),
263        _ => {
264            return (
265                StatusCode::METHOD_NOT_ALLOWED,
266                Json(serde_json::json!({"error": "Method not supported"})),
267            )
268                .into_response();
269        }
270    };
271
272    let mut filtered = axum::http::HeaderMap::new();
273    for (name, value) in headers.iter() {
274        let n = name.as_str();
275        if !HOP_BY_HOP.contains(&n) && n != "authorization" {
276            filtered.insert(name, value.clone());
277        }
278    }
279    request_builder = request_builder.headers(filtered);
280
281    let response = request_builder
282        .body(reqwest::Body::wrap_stream(body_stream))
283        .send()
284        .await;
285
286    let response = handle_response(response, &path).await;
287    response.into_response()
288}
289
290async fn handle_response(
291    response: Result<reqwest::Response, reqwest::Error>,
292    path: &str,
293) -> impl IntoResponse {
294    match response {
295        Ok(resp) => {
296            let status = resp.status();
297            let headers = resp.headers().clone();
298            let is_sse = resp
299                .headers()
300                .get(axum::http::header::CONTENT_TYPE)
301                .and_then(|v| v.to_str().ok())
302                .map(|v| v.contains("text/event-stream"))
303                .unwrap_or(false);
304
305            if is_sse {
306                let mut response = axum::response::Response::new(Body::from_stream(
307                    resp.bytes_stream()
308                        .map(|result| result.map_err(std::io::Error::other)),
309                ));
310                *response.status_mut() = status;
311                for (name, value) in headers.iter() {
312                    response.headers_mut().insert(name, value.clone());
313                }
314                response
315            } else {
316                let bytes = match resp.bytes().await {
317                    Ok(b) => b,
318                    Err(e) => {
319                        info!("Failed to read response body for {}: {}", path, e);
320                        return (
321                            StatusCode::BAD_GATEWAY,
322                            Json(serde_json::json!({"error": format!("Failed to read backend response: {}", e)})),
323                        )
324                            .into_response();
325                    }
326                };
327                (status, headers, bytes).into_response()
328            }
329        }
330        Err(e) => {
331            info!("Proxy error for {}: {}", path, e);
332            (
333                StatusCode::BAD_GATEWAY,
334                Json(serde_json::json!({"error": format!("Backend unavailable: {}", e)})),
335            )
336                .into_response()
337        }
338    }
339}
340
341/// Simple health check endpoint - no auth, verifies backend
342async fn security_headers(
343    req: axum::extract::Request,
344    next: axum::middleware::Next,
345) -> impl IntoResponse {
346    let mut resp = next.run(req).await;
347    resp.headers_mut()
348        .entry(axum::http::header::X_CONTENT_TYPE_OPTIONS)
349        .or_insert("nosniff".parse().unwrap());
350    resp.headers_mut()
351        .entry(axum::http::header::X_FRAME_OPTIONS)
352        .or_insert("DENY".parse().unwrap());
353    resp.headers_mut()
354        .entry(axum::http::header::CONTENT_SECURITY_POLICY)
355        .or_insert("default-src 'self'".parse().unwrap());
356    resp
357}
358
359/// Dynamic CORS middleware: validates Origin header against allowed hosts.
360/// Allows localhost, 127.0.0.1, and the configured bind host.
361async fn cors_middleware(
362    State(allowed_origins): State<Arc<Vec<String>>>,
363    req: axum::extract::Request,
364    next: axum::middleware::Next,
365) -> impl IntoResponse {
366    let origin = req
367        .headers()
368        .get(axum::http::header::ORIGIN)
369        .and_then(|v| v.to_str().ok())
370        .map(|s| s.to_string());
371
372    let allowed = origin
373        .as_ref()
374        .map(|o| {
375            let origin_host = o
376                .strip_prefix("http://")
377                .or_else(|| o.strip_prefix("https://"))
378                .and_then(|u| u.split('/').next())
379                .map(|h| h.strip_suffix(':').unwrap_or(h))
380                .unwrap_or("");
381            allowed_origins.iter().any(|a| a == origin_host)
382        })
383        .unwrap_or(false);
384
385    if req.method() == axum::http::Method::OPTIONS {
386        if allowed {
387            let mut resp = axum::response::Response::new(Body::empty());
388            resp.headers_mut().insert(
389                axum::http::header::ACCESS_CONTROL_ALLOW_ORIGIN,
390                origin.unwrap().parse().unwrap(),
391            );
392            resp.headers_mut().insert(
393                axum::http::header::ACCESS_CONTROL_ALLOW_METHODS,
394                "GET, POST, PUT, DELETE, OPTIONS".parse().unwrap(),
395            );
396            resp.headers_mut().insert(
397                axum::http::header::ACCESS_CONTROL_ALLOW_HEADERS,
398                "Content-Type, Authorization".parse().unwrap(),
399            );
400            resp
401        } else {
402            StatusCode::METHOD_NOT_ALLOWED.into_response()
403        }
404    } else {
405        let mut resp = next.run(req).await;
406        if allowed {
407            if let Some(o) = origin {
408                resp.headers_mut().insert(
409                    axum::http::header::ACCESS_CONTROL_ALLOW_ORIGIN,
410                    o.parse().unwrap(),
411                );
412                resp.headers_mut().insert(
413                    axum::http::header::ACCESS_CONTROL_ALLOW_HEADERS,
414                    "Content-Type, Authorization".parse().unwrap(),
415                );
416            }
417        }
418        resp
419    }
420}
421
422/// Simple health check endpoint - no auth, verifies backend
423async fn health(State(state): State<ApiState>) -> impl IntoResponse {
424    let resp = state
425        .client
426        .get(format!("{}/health", state.server_url))
427        .send()
428        .await;
429
430    match resp {
431        Ok(response) if response.status().is_success() => Json(serde_json::json!({
432            "status": "ok",
433            "backend": "healthy"
434        })),
435        Ok(_) => Json(serde_json::json!({
436            "status": "degraded",
437            "backend": "unreachable"
438        })),
439        Err(_) => Json(serde_json::json!({
440            "status": "degraded",
441            "backend": "unreachable"
442        })),
443    }
444}
445
446/// Custom status endpoint.
447const STATUS_CACHE_TTL: std::time::Duration = std::time::Duration::from_secs(5);
448
449#[axum::debug_handler]
450async fn status(State(state): State<ApiState>) -> impl IntoResponse {
451    let uptime = state.start_time.elapsed();
452    let uptime_secs = uptime.as_secs();
453
454    let loaded_models = {
455        let (is_stale, cached_models) = {
456            let cache = state.status_cache.read().unwrap();
457            (cache.cached_at.elapsed() >= STATUS_CACHE_TTL, cache.models)
458        };
459        if !is_stale {
460            cached_models
461        } else {
462            match state
463                .client
464                .get(format!("{}/models", state.server_url))
465                .send()
466                .await
467            {
468                Ok(resp) if resp.status().is_success() => {
469                    let val: Option<serde_json::Value> = resp.json().await.ok();
470                    let data = val
471                        .as_ref()
472                        .and_then(|v| v.get("data"))
473                        .and_then(|d| d.as_array());
474                    let c = data.map(|a| a.len()).unwrap_or(0);
475                    let mut cache = state.status_cache.write().unwrap();
476                    cache.models = c;
477                    cache.cached_at = Instant::now();
478                    c
479                }
480                _ => {
481                    let mut cache = state.status_cache.write().unwrap();
482                    cache.models = 0;
483                    cache.cached_at = Instant::now();
484                    0
485                }
486            }
487        }
488    };
489
490    Json(serde_json::json!({
491        "status": "running",
492        "pid": state.pid,
493        "port": state.port,
494        "model": state.model_name,
495        "uptime_seconds": uptime_secs,
496        "loaded_models": loaded_models,
497    }))
498}
499
500pub async fn start_api_server(
501    addr: SocketAddr,
502    api_key: Option<String>,
503    server_port: u16,
504    model_name: String,
505    pid: u32,
506    mut shutdown_rx: tokio::sync::watch::Receiver<bool>,
507    host: String,
508    tls_config: Option<axum_server::tls_rustls::RustlsConfig>,
509    system_prompt_preset_name: String,
510    web_search_engine: String,
511    web_search_engine_url: String,
512    web_search_enabled: bool,
513    web_search_api_key: Option<String>,
514    log_callback: Arc<Mutex<Option<Box<dyn Fn(String) + Send + Sync>>>>,
515) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
516    let bind = addr;
517    let start_time = Instant::now();
518    let client = Client::builder()
519        .pool_max_idle_per_host(20)
520        .timeout(std::time::Duration::from_secs(60))
521        .build()?;
522    let state = ApiState {
523        server_url: format!("http://{}:{}", clean_host(&host), server_port),
524        api_key,
525        model_name,
526        pid,
527        start_time,
528        port: bind.port(),
529        client,
530        status_cache: Arc::new(RwLock::new(StatusCache {
531            models: 0,
532            cached_at: Instant::now() - std::time::Duration::from_secs(10),
533        })),
534        system_prompt_preset_name,
535        web_search_engine,
536        web_search_engine_url,
537        web_search_enabled,
538        web_search_api_key,
539        log_callback,
540    };
541
542    let allowed_origins: Arc<Vec<String>> = Arc::new({
543        let mut origins = vec!["127.0.0.1".into(), "localhost".into()];
544        if host != "127.0.0.1" && host != "localhost" && host != "0.0.0.0" {
545            origins.push(host.clone());
546        }
547        origins
548    });
549
550    let api_key_clone = state.api_key.clone();
551    let protocol = if tls_config.is_some() {
552        "https"
553    } else {
554        "http"
555    };
556    info!(
557        "API server starting on {protocol}://{} (proxying to http://127.0.0.1:{})",
558        host, server_port
559    );
560    if api_key_clone.is_some() {
561        info!("API key authentication is ENABLED");
562    }
563
564    let app = Router::new()
565        .route("/health", get(health))
566        .route("/metrics", get(proxy_streaming))
567        .merge(
568            Router::new()
569                .route("/v1/chat/completions", post(proxy_streaming))
570                .route("/v1/completions", post(proxy_streaming))
571                .route("/v1/embeddings", post(proxy_streaming))
572                .route("/v1/models", get(proxy_streaming))
573                .route("/api/status", get(status))
574                .fallback(proxy_streaming)
575                .layer(axum::middleware::from_fn_with_state(
576                    allowed_origins.clone(),
577                    cors_middleware,
578                ))
579                .layer(TraceLayer::new_for_http()),
580        )
581        .layer(axum::middleware::from_fn(security_headers))
582        .layer(axum::middleware::from_fn_with_state(
583            state.clone(),
584            auth_middleware,
585        ))
586        .with_state(state);
587
588    match tls_config {
589        Some(tls_cfg) => {
590            let tls_listener = axum_server::bind_rustls(bind, tls_cfg);
591            let shutdown_fut = async {
592                let _ = shutdown_rx.wait_for(|v| *v).await;
593            };
594            tokio::select! {
595                result = tls_listener.serve(app.into_make_service()) => result?,
596                _ = shutdown_fut => {},
597            };
598        }
599        None => {
600            axum::serve(tokio::net::TcpListener::bind(bind).await?, app)
601                .with_graceful_shutdown(async move {
602                    let _ = shutdown_rx.wait_for(|v| *v).await;
603                })
604                .await?;
605        }
606    }
607    Ok(())
608}