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}