Skip to main content

netget/server/openapi/
mod.rs

1//! OpenAPI 3.1 spec-driven HTTP server implementation
2//!
3//! The LLM provides an OpenAPI specification and generates responses based on validated requests.
4//! Supports both spec-compliant and intentionally non-compliant responses for testing/honeypot purposes.
5
6pub mod actions;
7
8use std::convert::Infallible;
9use std::net::SocketAddr;
10use std::sync::Arc;
11
12use bytes::Bytes;
13use http_body_util::{BodyExt, Full};
14use hyper::body::Incoming;
15use hyper::server::conn::http1;
16use hyper::service::service_fn;
17use hyper::{Request, Response};
18use hyper_util::rt::TokioIo;
19use serde_json::json;
20use std::collections::HashMap;
21use tokio::sync::mpsc;
22use tokio::sync::RwLock;
23use tracing::{debug, error};
24
25use crate::llm::action_helper::call_llm;
26use crate::llm::actions::protocol_trait::ActionResult;
27use crate::llm::ollama_client::OllamaClient;
28use crate::logging::emit::Log;
29use crate::protocol::Event;
30use crate::server::connection::ConnectionId;
31use crate::server::openapi::actions::OpenApiProtocol;
32use crate::state::app_state::AppState;
33
34use matchit::Router;
35use openapi_rs::model::parse::OpenAPI;
36
37/// Metadata for a matched route
38#[derive(Clone, Debug)]
39pub struct RouteMetadata {
40    pub operation_id: Option<String>,
41    pub method: String,
42    pub path_template: String,
43    pub operation_json: serde_json::Value, // Pre-serialized operation for LLM
44}
45
46/// Result of route matching
47#[derive(Debug)]
48pub enum MatchResult {
49    /// Route found and matched
50    Found {
51        metadata: RouteMetadata,
52        params: HashMap<String, String>,
53    },
54    /// Path exists but method not allowed
55    MethodNotAllowed { allowed_methods: Vec<String> },
56    /// Path not found in spec
57    NotFound,
58}
59
60/// OpenAPI server state
61pub struct OpenApiState {
62    /// Raw OpenAPI specification (YAML or JSON)
63    pub spec: Option<String>,
64    /// Whether the spec has been successfully parsed
65    pub spec_valid: bool,
66    /// Parsed OpenAPI specification
67    pub parsed_spec: Option<OpenAPI>,
68    /// Route matcher for fast path matching
69    pub router: Option<Router<RouteMetadata>>,
70    /// Whether to ask LLM for invalid requests (404/405/400)
71    pub llm_on_invalid: bool,
72}
73
74impl OpenApiState {
75    pub fn new() -> Self {
76        Self {
77            spec: None,
78            spec_valid: false,
79            parsed_spec: None,
80            router: None,
81            llm_on_invalid: false, // Default: bypass LLM for errors
82        }
83    }
84}
85
86/// Build matchit router from OpenAPI specification
87fn build_router(spec: &OpenAPI) -> anyhow::Result<Router<RouteMetadata>> {
88    let mut router = Router::new();
89
90    for (path_template, path_item) in &spec.paths {
91        // Iterate over operations HashMap (keys: "get", "post", etc.)
92        for (method_lower, operation) in &path_item.operations {
93            let method = method_lower.to_uppercase();
94
95            // Serialize operation to JSON for LLM
96            let operation_json = serde_json::to_value(operation)?;
97
98            let metadata = RouteMetadata {
99                operation_id: operation.operation_id.clone(),
100                method: method.clone(),
101                path_template: path_template.clone(),
102                operation_json,
103            };
104
105            // Use composite key: METHOD:PATH (e.g., "GET:/users/{id}")
106            let route_key = format!("{}:{}", method, path_template);
107            router.insert(&route_key, metadata)?;
108
109            debug!("Registered OpenAPI route: {} {}", method, path_template);
110        }
111    }
112
113    Ok(router)
114}
115
116/// Match incoming request against router
117fn match_route(router: &Router<RouteMetadata>, method: &str, path: &str) -> MatchResult {
118    // Try exact method:path match
119    let route_key = format!("{}:{}", method, path);
120
121    match router.at(&route_key) {
122        Ok(matched) => {
123            // Extract path parameters
124            let params: HashMap<String, String> = matched
125                .params
126                .iter()
127                .map(|(k, v)| (k.to_string(), v.to_string()))
128                .collect();
129
130            MatchResult::Found {
131                metadata: matched.value.clone(),
132                params,
133            }
134        }
135        Err(_) => {
136            // Check if path exists with different method (for 405)
137            let allowed_methods = find_allowed_methods(router, path);
138
139            if !allowed_methods.is_empty() {
140                MatchResult::MethodNotAllowed { allowed_methods }
141            } else {
142                MatchResult::NotFound
143            }
144        }
145    }
146}
147
148/// Find which HTTP methods are allowed for a given path
149fn find_allowed_methods(router: &Router<RouteMetadata>, path: &str) -> Vec<String> {
150    let methods = [
151        "GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS", "TRACE",
152    ];
153    let mut allowed = Vec::new();
154
155    for method in &methods {
156        let route_key = format!("{}:{}", method, path);
157        if router.at(&route_key).is_ok() {
158            allowed.push(method.to_string());
159        }
160    }
161
162    allowed
163}
164
165/// Validate request against OpenAPI operation schema
166/// Returns Ok(()) if valid, Err(error_message) if invalid
167fn validate_request(
168    _operation_json: &serde_json::Value,
169    _method: &str,
170    _path: &str,
171    _headers: &HashMap<String, String>,
172    _body: &str,
173) -> anyhow::Result<()> {
174    // TODO: Implement schema validation
175    // For now, just return Ok - validation can be added later
176    // This would involve:
177    // 1. Validate required parameters are present
178    // 2. Validate parameter types match schema
179    // 3. Validate request body against schema if present
180    // 4. Validate Content-Type header
181
182    Ok(())
183}
184
185/// How much of a request body is read before the request is refused with 413.
186///
187/// The body is buffered whole and then embedded in an LLM prompt, so there is no legitimate
188/// use for a large one. Matches `http_common::handler::MAX_REQUEST_BODY_BYTES`.
189pub const MAX_REQUEST_BODY_BYTES: usize = 8 * 1024 * 1024;
190
191/// How long a peer that has connected and produced nothing may hold a slot.
192///
193/// HTTP is client-speaks-first: the server says nothing until a request line arrives, so a peer
194/// that has completed the TCP handshake and sent no byte has asked nothing and negotiated
195/// nothing. That state carries no protocol yet, which is why this number is the same across
196/// netget's HTTP-shaped servers while the idle bound below is not. Apache's `mod_reqtimeout`
197/// gives the request header 20s and nginx's `client_header_timeout` 60s; 30s sits between the
198/// two deployed norms.
199///
200/// hyper's own `header_read_timeout` is **not** this bound: its 30-second default is inert
201/// unless `http1::Builder::timer` is also set, which nothing here does — hyper downgrades a
202/// defaulted duration to `None` when no timer is present and applies no deadline at all.
203const FIRST_BYTE_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
204
205/// How long a connection with nothing outstanding may sit idle between requests.
206///
207/// nginx's `keepalive_timeout` default, which is the number the deployed web is tuned against
208/// and the right one here because an OpenAPI server's clients are whatever the specification
209/// describes — a browser, `curl`, a generated SDK — with no pooling discipline of their own to
210/// argue for more. Apache's `KeepAliveTimeout` of 5s is the other end of the range and is tuned
211/// for a server fronting far more connections than this one admits. A request still being
212/// answered is not silence: the watchdog reads
213/// [`ConnectionActivity`](crate::server::accept_bounded::ConnectionActivity), which reports a
214/// connection with work in flight — a model round-trip, or an event a `manual` rule parked for a
215/// human — as not idle at all.
216const IDLE_BETWEEN_REQUESTS_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(75);
217
218/// Concurrent connections this server admits.
219///
220/// Below the shared `DEFAULT_MAX_CONNECTIONS` of 256 on purpose: each admitted connection may
221/// buffer one body of up to [`MAX_REQUEST_BODY_BYTES`] (8 MiB), which is the largest
222/// per-connection cost in netget's HTTP family, and the cap is what turns that per-connection
223/// bound into a total one. 128 holds the worst case to the same ~1 GiB ceiling the 4 MiB and
224/// 64 KiB servers reach at 256, so the number that varies is the one that has to.
225const MAX_CONNECTIONS: usize = 128;
226
227/// What a peer over [`MAX_CONNECTIONS`] is told before the socket closes.
228///
229/// `503 Service Unavailable` with a `Retry-After`, written directly onto the socket because the
230/// peer has not sent a request line for hyper to answer. Fixed bytes: nothing derived from an
231/// error reaches the wire (see `crate::utils::wire_failure`).
232const CONNECTION_CAP_REFUSAL: &[u8] = b"HTTP/1.1 503 Service Unavailable\r\n\
233    Content-Length: 0\r\nRetry-After: 5\r\nConnection: close\r\n\r\n";
234
235/// Create immediate 413 response for a body past [`MAX_REQUEST_BODY_BYTES`].
236fn payload_too_large() -> Response<Full<Bytes>> {
237    Response::builder()
238        .status(413)
239        .header("Content-Type", "application/json")
240        .body(Full::new(Bytes::from(
241            json!({
242                "error": "Payload Too Large",
243                "message": "The request body exceeds this server's limit"
244            })
245            .to_string(),
246        )))
247        .unwrap_or_else(|_| Response::new(Full::new(Bytes::new())))
248}
249
250/// Create immediate 404 Not Found response
251fn immediate_404() -> Response<Full<Bytes>> {
252    Response::builder()
253        .status(404)
254        .header("Content-Type", "application/json")
255        .body(Full::new(Bytes::from(
256            json!({
257                "error": "Not Found",
258                "message": "The requested path does not exist in the OpenAPI specification"
259            })
260            .to_string(),
261        )))
262        .unwrap()
263}
264
265/// Create immediate 405 Method Not Allowed response with Allow header
266fn immediate_405(allowed_methods: Vec<String>) -> Response<Full<Bytes>> {
267    let allow_header = allowed_methods.join(", ");
268
269    Response::builder()
270        .status(405)
271        .header("Content-Type", "application/json")
272        .header("Allow", allow_header.clone())
273        .body(Full::new(Bytes::from(
274            json!({
275                "error": "Method Not Allowed",
276                "message": format!("This path does not support the requested method"),
277                "allowed_methods": allowed_methods
278            })
279            .to_string(),
280        )))
281        .unwrap()
282}
283
284/// Create immediate 400 Bad Request response for validation errors
285fn immediate_400(error_message: String) -> Response<Full<Bytes>> {
286    Response::builder()
287        .status(400)
288        .header("Content-Type", "application/json")
289        .body(Full::new(Bytes::from(
290            json!({
291                "error": "Bad Request",
292                "message": error_message
293            })
294            .to_string(),
295        )))
296        .unwrap()
297}
298
299/// Build the peer-visible failure response for a request netget could not answer.
300///
301/// The peer gets a *category*, never the error: no backend URL, no model name, no `anyhow`
302/// chain. `Overloaded` is transient, so it is reported as 503 + `Retry-After` and a client
303/// backs off; anything else is a 500 so it is not retried forever. See
304/// `crate::utils::wire_failure`.
305fn failure_response(failure: crate::utils::WireFailure) -> Response<Full<Bytes>> {
306    let (status, reason) = if failure.is_overloaded() {
307        (503, "Service Unavailable")
308    } else {
309        (500, "Internal Server Error")
310    };
311
312    let body = json!({
313        "error": reason,
314        "message": failure.text(),
315    })
316    .to_string();
317
318    let mut builder = Response::builder()
319        .status(status)
320        .header("Content-Type", "application/json");
321    if failure.is_overloaded() {
322        builder = builder.header(hyper::header::RETRY_AFTER, "1");
323    }
324
325    builder
326        .body(Full::new(Bytes::from(body)))
327        .unwrap_or_else(|_| Response::new(Full::new(Bytes::new())))
328}
329
330/// The LLM call itself failed. Log the whole error, answer the peer with a category only.
331fn llm_error_response(
332    error: anyhow::Error,
333    method: &str,
334    path: &str,
335    status_tx: &mpsc::UnboundedSender<String>,
336) -> Response<Full<Bytes>> {
337    let failure = crate::utils::WireFailure::classify(&error);
338    let category = if failure.is_overloaded() {
339        "overloaded"
340    } else {
341        "unavailable"
342    };
343
344    // `Log` writes the file log and the TUI status stream from one call, so the whole error
345    // is recorded exactly once — where an operator looks, and nowhere the peer can read.
346    Log::new(Some(status_tx)).error(format!(
347        "OpenAPI {} {} decision=fail_closed_llm_error category={}: {}",
348        method, path, category, error
349    ));
350
351    failure_response(failure)
352}
353
354/// Handle LLM response and process actions
355async fn handle_llm_response(
356    execution_result: crate::llm::actions::executor::ExecutionResult,
357    status_tx: mpsc::UnboundedSender<String>,
358    openapi_state: Arc<RwLock<OpenApiState>>,
359    method: String,
360    path: String,
361) -> Result<Response<Full<Bytes>>, Infallible> {
362    debug!("LLM OpenAPI response received");
363
364    // Display messages
365    for msg in execution_result.messages {
366        let _ = status_tx.send(msg);
367    }
368
369    // Default response. `produced_response` stays false until the model actually answers:
370    // a silent model must not read on the wire like a successful 200 (see the fail-open
371    // rule in CLAUDE.md).
372    let mut status_code = 200;
373    let mut response_headers = HashMap::new();
374    let mut response_body = String::new();
375    let mut spec_to_load: Option<String> = None;
376    let mut produced_response = false;
377    let mut decision = "fail_closed_no_action";
378
379    // Process protocol results
380    for protocol_result in execution_result.protocol_results {
381        match protocol_result {
382            ActionResult::Custom { name, data } => {
383                match name.as_str() {
384                    "send_openapi_response" => {
385                        produced_response = true;
386                        decision = "model_response";
387                        // Extract response details
388                        if let Some(status) = data.get("status_code").and_then(|v| v.as_u64()) {
389                            status_code = status as u16;
390                        }
391                        if let Some(headers_obj) = data.get("headers").and_then(|v| v.as_object()) {
392                            for (k, v) in headers_obj {
393                                if let Some(v_str) = v.as_str() {
394                                    response_headers.insert(k.clone(), v_str.to_string());
395                                }
396                            }
397                        }
398                        if let Some(body) = data.get("body").and_then(|v| v.as_str()) {
399                            response_body = body.to_string();
400                        }
401                    }
402                    "send_validation_error" => {
403                        produced_response = true;
404                        decision = "model_reject";
405                        // A validation error the model did not give a status for is a 400,
406                        // not the 200 the default would otherwise leave in place.
407                        status_code = 400;
408                        // Extract error details
409                        if let Some(status) = data.get("status_code").and_then(|v| v.as_u64()) {
410                            status_code = status as u16;
411                        }
412                        if let Some(message) = data.get("message").and_then(|v| v.as_str()) {
413                            response_body = json!({
414                                "error": message
415                            })
416                            .to_string();
417                            response_headers
418                                .insert("content-type".to_string(), "application/json".to_string());
419                        }
420                    }
421                    "load_openapi_spec" | "reload_spec" => {
422                        // LLM provided OpenAPI spec
423                        if let Some(spec) = data.get("spec").and_then(|v| v.as_str()) {
424                            spec_to_load = Some(spec.to_string());
425                        }
426                    }
427                    "configure_error_handling" => {
428                        // LLM configured error handling
429                        if let Some(llm_on_invalid) =
430                            data.get("llm_on_invalid").and_then(|v| v.as_bool())
431                        {
432                            let mut state = openapi_state.write().await;
433                            state.llm_on_invalid = llm_on_invalid;
434                            Log::new(Some(&status_tx))
435                                .info(format!("OpenAPI llm_on_invalid set to: {}", llm_on_invalid));
436                        }
437                    }
438                    _ => {
439                        debug!("Unknown custom action: {}", name);
440                    }
441                }
442            }
443            ActionResult::Output(output_data) => {
444                // Legacy fallback for non-action responses
445                if let Ok(json_value) = serde_json::from_slice::<serde_json::Value>(&output_data) {
446                    produced_response = true;
447                    decision = "model_output_legacy";
448                    // `as u16` wraps, and this path does not go through the executors that
449                    // bound `status_code` to 100-599 — so it was the one remaining route by
450                    // which `65736` could reach `build_safe_response` as a perfectly valid
451                    // 200. Anything unusable stays at the 500 set below rather than becoming
452                    // the success the old default supplied.
453                    status_code = 500;
454                    match json_value.get("status").and_then(|v| v.as_u64()) {
455                        Some(status) => match u16::try_from(status)
456                            .ok()
457                            .filter(|code| (100..=599).contains(code))
458                        {
459                            Some(code) => status_code = code,
460                            None => error!(
461                                "OpenAPI legacy output carried status {} which is not an HTTP \
462                                 status; answering 500",
463                                status
464                            ),
465                        },
466                        None => status_code = 200,
467                    }
468                    if let Some(headers_obj) = json_value.get("headers").and_then(|v| v.as_object())
469                    {
470                        for (k, v) in headers_obj {
471                            if let Some(v_str) = v.as_str() {
472                                response_headers.insert(k.clone(), v_str.to_string());
473                            }
474                        }
475                    }
476                    if let Some(body) = json_value.get("body").and_then(|v| v.as_str()) {
477                        response_body = body.to_string();
478                    }
479                }
480            }
481            _ => {}
482        }
483    }
484
485    // Load spec if provided
486    if let Some(spec) = spec_to_load {
487        let mut state = openapi_state.write().await;
488        // Try to parse spec
489        match openapi_rs::model::parse::OpenAPI::yaml(&spec) {
490            Ok(parsed_spec) => {
491                Log::new(Some(&status_tx)).info(format!(
492                    "OpenAPI spec loaded successfully: {} bytes",
493                    spec.len()
494                ));
495
496                // Build router from parsed spec
497                match build_router(&parsed_spec) {
498                    Ok(router) => {
499                        let route_count = parsed_spec.paths.len();
500                        Log::new(Some(&status_tx))
501                            .info(format!("Built OpenAPI router with {} routes", route_count));
502
503                        state.spec = Some(spec);
504                        state.spec_valid = true;
505                        state.parsed_spec = Some(parsed_spec);
506                        state.router = Some(router);
507                    }
508                    Err(e) => {
509                        Log::new(Some(&status_tx))
510                            .error(format!("Failed to build OpenAPI router: {}", e));
511                        state.spec = Some(spec);
512                        state.spec_valid = false;
513                    }
514                }
515            }
516            Err(e) => {
517                Log::new(Some(&status_tx)).error(format!("Failed to parse OpenAPI spec: {}", e));
518                state.spec = Some(spec);
519                state.spec_valid = false;
520            }
521        }
522    }
523
524    // The model was consulted and produced no response action. That is not a success: a
525    // silent model used to be answered with 200 and a body naming netget's own internals,
526    // so a caller could not tell an answered request from an unanswered one. Fail closed
527    // with a category, and keep the reason in the log rather than on the wire. A spec
528    // reload or an error-handling change on its own is configuration, not an answer.
529    if !produced_response {
530        Log::new(Some(&status_tx)).warn(format!(
531            "OpenAPI {} {} decision=fail_closed_no_action -> 500 (no response action returned)",
532            method, path
533        ));
534        return Ok(failure_response(crate::utils::WireFailure::Unavailable));
535    }
536
537    Log::new(Some(&status_tx)).info(format!(
538        "OpenAPI {} {} -> {} ({} bytes, decision={})",
539        method,
540        path,
541        status_code,
542        response_body.len(),
543        decision
544    ));
545
546    // `status_code` and every header name/value here are model output, so this must not be
547    // `.body(..).unwrap()`: a `status_code` of 1000, or a header value containing CR/LF (a
548    // response-splitting attempt), made the builder return Err and panicked the connection
549    // task. `build_safe_response` clamps the status and drops individual bad headers.
550    Ok(crate::server::http_common::handler::build_safe_response(
551        status_code,
552        response_headers,
553        response_body,
554        "OpenAPI",
555    ))
556}
557
558/// OpenAPI server that uses LLM to handle spec-driven requests
559pub struct OpenApiServer;
560
561impl OpenApiServer {
562    /// Spawn OpenAPI server with LLM actions
563    ///
564    /// ## Startup Parameters
565    ///
566    /// The LLM can provide OpenAPI specification during server initialization via `startup_params`:
567    ///
568    /// **Option 1: Inline spec (string)**
569    /// ```json
570    /// {
571    ///   "spec": "openapi: 3.1.0\ninfo:\n  title: My API\n..."
572    /// }
573    /// ```
574    ///
575    /// There is no `spec_file` option. It was declared in `get_startup_parameters()` and
576    /// documented here, but no code ever read it — a caller passing only `spec_file` hit the
577    /// "requires 'spec' parameter" error below. Read the file yourself and pass its contents
578    /// as `spec`.
579    ///
580    /// When a spec is provided via startup_params:
581    /// - The spec is immediately parsed and validated
582    /// - Route matching is configured automatically
583    /// - Invalid requests (404/405) are rejected without asking LLM (unless `llm_on_invalid` is enabled)
584    /// - Matched requests receive only the relevant operation spec, not the full spec
585    ///
586    /// If no spec is provided, the server starts in "dynamic mode" where the LLM can load the spec
587    /// later using the `reload_spec` action.
588    pub async fn spawn_with_llm_actions(
589        listen_addr: SocketAddr,
590        llm_client: OllamaClient,
591        app_state: Arc<AppState>,
592        status_tx: mpsc::UnboundedSender<String>,
593        server_id: crate::state::ServerId,
594        startup_params: Option<crate::protocol::StartupParams>,
595    ) -> anyhow::Result<SocketAddr> {
596        // Create shared OpenAPI state
597        let openapi_state = Arc::new(RwLock::new(OpenApiState::new()));
598        let protocol = Arc::new(OpenApiProtocol::new());
599
600        // Check if spec is provided via startup_params (REQUIRED)
601        let spec_loaded = if let Some(ref params) = startup_params {
602            // Extract required spec parameter
603            let spec_content = if let Some(spec_str) = params.get_optional_string("spec")? {
604                // Spec provided (LLM must read file and provide content)
605                Log::new(Some(&status_tx)).info(format!(
606                    "OpenAPI spec provided via startup_params ({} bytes)",
607                    spec_str.len()
608                ));
609                Some(spec_str)
610            } else {
611                // spec parameter is required
612                let msg = "OpenAPI server requires 'spec' parameter in startup_params. LLM must read the spec file and provide content.";
613                Log::new(Some(&status_tx)).error(msg);
614                return Err(anyhow::anyhow!(msg));
615            };
616
617            // If we have spec content, parse and build router
618            if let Some(spec) = spec_content {
619                let mut state = openapi_state.write().await;
620                state.spec = Some(spec.clone());
621
622                match openapi_rs::model::parse::OpenAPI::yaml(&spec) {
623                    Ok(parsed) => match build_router(&parsed) {
624                        Ok(router) => {
625                            let route_count = parsed.paths.len();
626                            Log::new(Some(&status_tx)).info(format!(
627                                "Successfully built OpenAPI router with {} routes",
628                                route_count
629                            ));
630                            state.parsed_spec = Some(parsed);
631                            state.router = Some(router);
632                            state.spec_valid = true;
633                            true
634                        }
635                        Err(e) => {
636                            let msg = format!("Failed to build router: {}", e);
637                            Log::new(Some(&status_tx)).error(&msg);
638                            state.spec_valid = false;
639                            return Err(anyhow::anyhow!(msg));
640                        }
641                    },
642                    Err(e) => {
643                        let msg = format!("Failed to parse OpenAPI spec: {}", e);
644                        Log::new(Some(&status_tx)).error(&msg);
645                        state.spec_valid = false;
646                        return Err(anyhow::anyhow!(msg));
647                    }
648                }
649            } else {
650                false
651            }
652        } else {
653            false
654        };
655
656        if !spec_loaded {
657            Log::new(Some(&status_tx))
658                .info("Starting OpenAPI server (spec will be provided by LLM on first request)...");
659        } else {
660            Log::new(Some(&status_tx)).info("Starting OpenAPI server with pre-loaded spec...");
661        }
662
663        // Start HTTP server
664        let listener =
665            crate::server::socket_helpers::create_reusable_tcp_listener(listen_addr).await?;
666        let local_addr = listener.local_addr()?;
667        Log::new(Some(&status_tx)).info(format!("OpenAPI server listening on {}", local_addr));
668
669        // Spawn server loop
670        let task_registrar = app_state.clone();
671        let limiter = crate::server::accept_bounded::ConnectionLimiter::new(MAX_CONNECTIONS);
672        let accept_handle = tokio::spawn(async move {
673            loop {
674                match crate::server::accept_bounded::accept_bounded(
675                    &listener,
676                    &limiter,
677                    CONNECTION_CAP_REFUSAL,
678                    "OpenAPI",
679                    Some(&status_tx),
680                )
681                .await
682                {
683                    Ok((stream, remote_addr, permit)) => {
684                        let connection_id =
685                            ConnectionId::new(app_state.get_next_unified_id().await);
686                        let local_addr_conn = stream.local_addr().unwrap_or(local_addr);
687                        Log::new(Some(&status_tx)).info(format!(
688                            "OpenAPI connection {} from {}",
689                            connection_id, remote_addr
690                        ));
691
692                        // Add connection to ServerInstance
693                        use crate::state::server::{
694                            ConnectionState as ServerConnectionState, ConnectionStatus,
695                            ProtocolConnectionInfo,
696                        };
697                        let now = crate::utils::clock::Instant::now();
698                        let conn_state = ServerConnectionState {
699                            id: connection_id,
700                            remote_addr,
701                            local_addr: local_addr_conn,
702                            bytes_sent: 0,
703                            bytes_received: 0,
704                            packets_sent: 0,
705                            packets_received: 0,
706                            last_activity: now,
707                            status: ConnectionStatus::Active,
708                            status_changed_at: now,
709                            protocol_info: ProtocolConnectionInfo::empty(),
710                        };
711                        app_state
712                            .add_connection_to_server(server_id, conn_state)
713                            .await;
714                        let _ = status_tx.send("__UPDATE_UI__".to_string());
715
716                        let llm_client_clone = llm_client.clone();
717                        let app_state_clone = app_state.clone();
718                        let status_tx_clone = status_tx.clone();
719                        let protocol_clone = protocol.clone();
720                        let openapi_state_clone = openapi_state.clone();
721
722                        // Spawn a task to handle this connection
723                        // Tracked, not detached: stop_server must abort this task too.
724                        let task_owner = app_state.clone();
725                        task_owner
726                            .spawn_server_task(server_id, async move {
727                                // Held for the life of the connection, so the cap counts live
728                                // clients rather than accepts.
729                                let _permit = permit;
730
731                                // First-byte bound, before hyper sees the socket.
732                                //
733                                // hyper owns every read once `serve_connection` starts, and a
734                                // deadline on those reads would be wrong here rather than merely
735                                // awkward: hyper keeps polling the connection for more input
736                                // while a request is being answered, so such a deadline would
737                                // fire in the middle of a model round-trip. `peek` waits for data
738                                // without consuming it, so the request line is still there for
739                                // hyper afterwards, and it bounds exactly the case that needs
740                                // bounding - a peer that has connected and sent nothing at all.
741                                let spoke = matches!(
742                                    tokio::time::timeout(
743                                        FIRST_BYTE_READ_TIMEOUT,
744                                        stream.peek(&mut [0u8; 1]),
745                                    )
746                                    .await,
747                                    Ok(Ok(n)) if n > 0
748                                );
749
750                                let io = TokioIo::new(stream);
751
752                                // Clone for service closure
753                                let status_for_service = status_tx_clone.clone();
754                                let app_state_for_service = app_state_clone.clone();
755                                let openapi_state_for_service = openapi_state_clone.clone();
756
757                                // Tracks whether this connection is answering anything. A
758                                // request waiting on the model, or parked for a human by a
759                                // `manual` rule, holds the count above zero, so the idle watchdog
760                                // below cannot close the connection the answer belongs to however
761                                // long it takes - only genuine silence counts.
762                                let activity = std::sync::Arc::new(
763                                    crate::server::accept_bounded::ConnectionActivity::new(),
764                                );
765                                let activity_for_service = std::sync::Arc::clone(&activity);
766
767                                // Create a service that handles OpenAPI requests with LLM
768                                let service = service_fn(move |req: Request<Incoming>| {
769                                    let llm_clone = llm_client_clone.clone();
770                                    let state_clone = app_state_for_service.clone();
771                                    let status_clone = status_for_service.clone();
772                                    let protocol_clone = protocol_clone.clone();
773                                    let openapi_state_clone = openapi_state_for_service.clone();
774                                    let activity = std::sync::Arc::clone(&activity_for_service);
775                                    async move {
776                                        let _busy = activity.busy();
777                                        handle_openapi_request(
778                                            req,
779                                            connection_id,
780                                            llm_clone,
781                                            state_clone,
782                                            status_clone,
783                                            protocol_clone,
784                                            openapi_state_clone,
785                                            server_id,
786                                        )
787                                        .await
788                                    }
789                                });
790
791                                // Serve HTTP/1 on this connection, bounded at both ends.
792                                if !spoke {
793                                    Log::new(Some(&status_tx_clone)).debug(format!(
794                                        "OpenAPI peer {} sent nothing for {}s; closing before \
795                                         any request",
796                                        remote_addr,
797                                        FIRST_BYTE_READ_TIMEOUT.as_secs()
798                                    ));
799                                } else {
800                                    let conn = http1::Builder::new().serve_connection(io, service);
801                                    tokio::pin!(conn);
802                                    tokio::select! {
803                                        result = &mut conn => {
804                                            if let Err(err) = result {
805                                                error!(
806                                                    "Error serving OpenAPI connection: \
807                                                     {:?}",
808                                                    err
809                                                );
810                                            }
811                                        }
812                                        _ = crate::server::accept_bounded::watch_idle(
813                                            std::sync::Arc::clone(&activity),
814                                            IDLE_BETWEEN_REQUESTS_TIMEOUT,
815                                        ) => {
816                                            Log::new(Some(&status_tx_clone)).debug(format!(
817                                                "OpenAPI connection {} idle for {}s; closing",
818                                                connection_id,
819                                                IDLE_BETWEEN_REQUESTS_TIMEOUT.as_secs()
820                                            ));
821                                        }
822                                    }
823                                }
824
825                                // Mark connection as closed
826                                app_state_clone
827                                    .close_connection_on_server(server_id, connection_id)
828                                    .await;
829                                Log::new(Some(&status_tx_clone))
830                                    .info(format!("OpenAPI connection {} closed", connection_id));
831                                let _ = status_tx_clone.send("__UPDATE_UI__".to_string());
832                            })
833                            .await;
834                    }
835                    Err(e) => {
836                        Log::new(Some(&status_tx))
837                            .error(format!("Failed to accept OpenAPI connection: {}", e));
838                        break;
839                    }
840                }
841            }
842        });
843
844        task_registrar
845            .register_server_task(server_id, accept_handle)
846            .await;
847
848        Ok(local_addr)
849    }
850}
851
852/// Handle a single OpenAPI request
853async fn handle_openapi_request(
854    req: Request<Incoming>,
855    connection_id: ConnectionId,
856    llm_client: OllamaClient,
857    app_state: Arc<AppState>,
858    status_tx: mpsc::UnboundedSender<String>,
859    protocol: Arc<OpenApiProtocol>,
860    openapi_state: Arc<RwLock<OpenApiState>>,
861    server_id: crate::state::ServerId,
862) -> Result<Response<Full<Bytes>>, Infallible> {
863    // Extract request details
864    let method = req.method().to_string();
865    let uri = req.uri().to_string();
866    let path = req.uri().path().to_string();
867
868    // Extract headers
869    let mut headers = HashMap::new();
870    for (name, value) in req.headers() {
871        if let Ok(value_str) = value.to_str() {
872            headers.insert(name.to_string(), value_str.to_string());
873        }
874    }
875
876    // Read the body, bounded. `Incoming` has no default limit, so this buffered whatever the
877    // peer chose to send — and an OpenAPI server is unauthenticated by construction, so a
878    // single POST with an endless chunked body was enough to walk the process out of memory.
879    // `Limited` errors as soon as the cap is passed, so an oversized upload costs at most the
880    // cap rather than all of it.
881    //
882    // The old arm was worse than unbounded: it swallowed the read failure into an empty body
883    // and carried on, so the model was shown a request with no body and answered it as if
884    // the peer had sent none. That is the truncated-body trap — the model answers a request
885    // it never saw. 413 says what happened, in a form a client can act on.
886    let limited = http_body_util::Limited::new(req.into_body(), MAX_REQUEST_BODY_BYTES);
887    let body_bytes = match limited.collect().await {
888        Ok(collected) => collected.to_bytes(),
889        Err(e) => {
890            Log::new(Some(&status_tx)).warn(format!(
891                "OpenAPI {} {} decision=fail_closed_body_too_large: {} (limit {} bytes)",
892                method, path, e, MAX_REQUEST_BODY_BYTES
893            ));
894            return Ok(payload_too_large());
895        }
896    };
897
898    // DEBUG: Log request summary
899    Log::new(Some(&status_tx)).debug(format!(
900        "OpenAPI {} {} ({} bytes)",
901        method,
902        path,
903        body_bytes.len()
904    ));
905
906    // TRACE: Log full request details (FileOnly — full payloads are not streamed to the TUI)
907    let log = Log::new(Some(&status_tx));
908    log.trace("OpenAPI request headers:");
909    for (name, value) in &headers {
910        log.trace(format!("OpenAPI header: {}: {}", name, value));
911    }
912    if !body_bytes.is_empty() {
913        if let Ok(body_str) = std::str::from_utf8(&body_bytes) {
914            // Try to pretty-print if it's JSON
915            if let Ok(json) = serde_json::from_str::<serde_json::Value>(body_str) {
916                let pretty = serde_json::to_string_pretty(&json).unwrap_or(body_str.to_string());
917                log.trace(format!("OpenAPI request body (JSON):\n{}", pretty));
918            } else {
919                log.trace(format!("OpenAPI request body:\n{}", body_str));
920            }
921        } else {
922            log.trace(format!(
923                "OpenAPI request body (binary): {} bytes",
924                body_bytes.len()
925            ));
926        }
927    }
928
929    // Get current spec info and perform route matching
930    let (spec_info, match_result, llm_on_invalid) = {
931        let state = openapi_state.read().await;
932        let spec_info = json!({
933            "spec_loaded": state.spec.is_some(),
934            "spec_valid": state.spec_valid
935        });
936
937        {
938            let match_result = if let Some(router) = state.router.as_ref() {
939                Some(match_route(router, &method, &path))
940            } else {
941                None
942            };
943            (spec_info, match_result, state.llm_on_invalid)
944        }
945    };
946
947    // Handle route matching results
948    if let Some(match_result) = match_result {
949        match match_result {
950            MatchResult::Found { metadata, params } => {
951                // Validate request if llm_on_invalid is false
952                if !llm_on_invalid {
953                    let body_text = String::from_utf8_lossy(&body_bytes);
954                    if let Err(e) = validate_request(
955                        &metadata.operation_json,
956                        &method,
957                        &path,
958                        &headers,
959                        &body_text,
960                    ) {
961                        Log::new(Some(&status_tx)).warn(format!("OpenAPI validation error: {}", e));
962                        return Ok(immediate_400(e.to_string()));
963                    }
964                }
965
966                // Create event with matched route information
967                let body_text = String::from_utf8_lossy(&body_bytes);
968                let event = Event::new(
969                    &*crate::server::openapi::actions::OPENAPI_REQUEST_EVENT,
970                    serde_json::json!({
971                        "method": method,
972                        "path": path,
973                        "uri": uri,
974                        "headers": headers,
975                        "body": if body_text.is_empty() { "" } else { body_text.as_ref() },
976                        "spec_info": spec_info,
977                        "matched_route": {
978                            "operation_id": metadata.operation_id,
979                            "path_template": metadata.path_template,
980                            "path_params": params,
981                            "operation": metadata.operation_json,
982                        }
983                    }),
984                );
985
986                // Call LLM with matched route context
987                match call_llm(
988                    &llm_client,
989                    &app_state,
990                    server_id,
991                    Some(connection_id),
992                    &event,
993                    protocol.as_ref(),
994                )
995                .await
996                {
997                    Ok(execution_result) => {
998                        return handle_llm_response(
999                            execution_result,
1000                            status_tx,
1001                            openapi_state,
1002                            method,
1003                            path,
1004                        )
1005                        .await;
1006                    }
1007                    Err(e) => {
1008                        return Ok(llm_error_response(e, &method, &path, &status_tx));
1009                    }
1010                }
1011            }
1012            MatchResult::MethodNotAllowed { allowed_methods } => {
1013                if llm_on_invalid {
1014                    // Let LLM handle 405 error
1015                    debug!(
1016                        "OpenAPI 405 Method Not Allowed (LLM will handle): {} {}",
1017                        method, path
1018                    );
1019                } else {
1020                    // Immediate 405 response
1021                    Log::new(Some(&status_tx)).info(format!(
1022                        "OpenAPI 405 Method Not Allowed: {} {} (allowed: {})",
1023                        method,
1024                        path,
1025                        allowed_methods.join(", ")
1026                    ));
1027                    return Ok(immediate_405(allowed_methods));
1028                }
1029            }
1030            MatchResult::NotFound => {
1031                if llm_on_invalid {
1032                    // Let LLM handle 404 error
1033                    debug!(
1034                        "OpenAPI 404 Not Found (LLM will handle): {} {}",
1035                        method, path
1036                    );
1037                } else {
1038                    // Immediate 404 response
1039                    Log::new(Some(&status_tx))
1040                        .info(format!("OpenAPI 404 Not Found: {} {}", method, path));
1041                    return Ok(immediate_404());
1042                }
1043            }
1044        }
1045    }
1046
1047    // If no router or llm_on_invalid is true for errors, call LLM with basic info
1048    let body_text = String::from_utf8_lossy(&body_bytes);
1049    let event = Event::new(
1050        &*crate::server::openapi::actions::OPENAPI_REQUEST_EVENT,
1051        serde_json::json!({
1052            "method": method,
1053            "path": path,
1054            "uri": uri,
1055            "headers": headers,
1056            "body": if body_text.is_empty() { "" } else { body_text.as_ref() },
1057            "spec_info": spec_info
1058        }),
1059    );
1060
1061    // Call LLM to handle request
1062    match call_llm(
1063        &llm_client,
1064        &app_state,
1065        server_id,
1066        Some(connection_id),
1067        &event,
1068        protocol.as_ref(),
1069    )
1070    .await
1071    {
1072        Ok(execution_result) => {
1073            handle_llm_response(execution_result, status_tx, openapi_state, method, path).await
1074        }
1075        Err(e) => Ok(llm_error_response(e, &method, &path, &status_tx)),
1076    }
1077}