netget/server/openai/mod.rs
1//! OpenAI-compatible API server implementation
2//!
3//! OpenAI API runs over HTTP. The LLM uses Ollama to generate chat completions
4//! and return them in OpenAI-compatible format.
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, StatusCode};
18use hyper_util::rt::TokioIo;
19use serde_json::json;
20use tokio::sync::mpsc;
21use tracing::{debug, error};
22
23use crate::llm::action_helper::call_llm;
24use crate::llm::actions::protocol_trait::ActionResult;
25use crate::llm::ollama_client::OllamaClient;
26use crate::logging::emit::Log;
27use crate::protocol::Event;
28use crate::server::connection::ConnectionId;
29use crate::server::openai::actions::{OpenAiProtocol, OPENAI_REQUEST_EVENT};
30use crate::state::app_state::AppState;
31
32/// How long a peer that has connected and sent nothing at all may hold this connection.
33///
34/// **NetGet's own OpenAI client cannot be this peer**, which is what decides the number. The
35/// `[ + openai client ]` button creates `src/client/openai/`, and that client is `async-openai`
36/// over a `reqwest` client: `connect()` stores the endpoint, registers a command channel and
37/// returns, opening no socket at all. The first TCP connection it makes is inside the request
38/// that carries a body — the "lazy" case, not the "connected and silent" one that made `tcp`,
39/// `telnet`, `ldap`, `whois` and `redis` settle on 300 seconds. Nothing here can sit connected
40/// waiting for a person to type into `[ send message ]`, because until they do there is no
41/// connection.
42///
43/// So every peer this listener can have is an HTTP client, and HTTP is client-speaks-first: the
44/// request line is the first thing on the wire. Thirty seconds sits between nginx's
45/// `client_header_timeout` default of 60s and Apache's `RequestReadTimeout header=20`, which
46/// bound exactly this, and it is what `s3` and `etcd` already use here for the same reason.
47///
48/// Enforced with `TcpStream::peek` **before** hyper sees the socket. hyper owns every read once
49/// `serve_connection` starts and keeps polling for frames while a request is being answered, so
50/// a deadline on its reads would fire in the middle of a model round-trip. `peek` waits for
51/// data without consuming it, so the request line is still there for hyper afterwards.
52const FIRST_BYTE_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
53
54/// How long an established connection may sit **silent** between requests.
55///
56/// This bounds silence and never a request. A request being answered holds
57/// [`ConnectionActivity`](crate::server::accept_bounded::ConnectionActivity) busy for the whole
58/// of it — the model round-trip included, and a `manual` rule parking the event for a human
59/// (`src/state/intercepts.rs`, 300s by default) — and `watch_idle` reports a busy connection as
60/// not idle at all, so the clock only ever runs on a connection with nothing in flight. That
61/// matters more here than for most servers: this server's answers *are* model output, so a
62/// perfectly healthy request can take minutes.
63///
64/// Five minutes, and the number is chosen against the clients rather than against the service:
65/// every OpenAI SDK in existence pools connections, and `reqwest` — which `async-openai`, the
66/// Python SDK's `httpx` and most others behave like — keeps an idle pooled connection for 90
67/// seconds before evicting it. An idle bound below that races the pool: the client hands a
68/// request to a connection the server has just closed, and a `POST /v1/chat/completions` is not
69/// idempotent, so it surfaces as a failed completion rather than a retry. At 300 seconds the
70/// pool always evicts first and the race cannot happen.
71const IDLE_BETWEEN_REQUESTS_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(300);
72
73/// Concurrent connections this server admits.
74///
75/// [`accept_bounded::DEFAULT_MAX_CONNECTIONS`](crate::server::accept_bounded::DEFAULT_MAX_CONNECTIONS).
76/// Each admitted connection may buffer a request of up to [`MAX_REQUEST_BODY_BYTES`], so the
77/// cap is what turns that 8 MiB per-connection bound into a total one. It is far above the pool
78/// any OpenAI SDK opens against one endpoint and far below what socket exhaustion needs.
79const MAX_CONNECTIONS: usize = crate::server::accept_bounded::DEFAULT_MAX_CONNECTIONS;
80
81/// What a peer over [`MAX_CONNECTIONS`] is told before the socket closes.
82///
83/// `503 Service Unavailable` with a `Retry-After`, which is HTTP's own word for this and which
84/// every OpenAI SDK already understands as a retryable server-side refusal — `openai-python`
85/// and `async-openai` both back off on a 503 rather than reporting a transport fault. A silent
86/// close would be indistinguishable from a crash, and the client would retry immediately and
87/// forever.
88pub const CONNECTION_CAP_REFUSAL: &[u8] = b"HTTP/1.1 503 Service Unavailable\r\n\
89 Content-Type: application/json\r\nContent-Length: 83\r\nRetry-After: 5\r\n\
90 Connection: close\r\n\r\n\
91 {\"error\":{\"message\":\"server at connection limit\",\"type\":\"server_error\",\"code\":503}}";
92
93/// OpenAI-compatible API server that delegates to LLM/Ollama
94pub struct OpenAiServer;
95
96impl OpenAiServer {
97 /// Spawn the OpenAI API server with integrated LLM actions
98 pub async fn spawn_with_llm_actions(
99 listen_addr: SocketAddr,
100 llm_client: OllamaClient,
101 app_state: Arc<AppState>,
102 status_tx: mpsc::UnboundedSender<String>,
103 _send_first: bool,
104 server_id: crate::state::ServerId,
105 first_byte_timeout_secs: Option<u64>,
106 idle_timeout_secs: Option<u64>,
107 ) -> anyhow::Result<SocketAddr> {
108 // Both bounds are tunable because their right value is a property of who is on the
109 // other end, which only the operator knows. The defaults are argued beside the
110 // constants.
111 let first_byte_timeout = first_byte_timeout_secs
112 .map(std::time::Duration::from_secs)
113 .unwrap_or(FIRST_BYTE_READ_TIMEOUT);
114 let idle_timeout = idle_timeout_secs
115 .map(std::time::Duration::from_secs)
116 .unwrap_or(IDLE_BETWEEN_REQUESTS_TIMEOUT);
117 let listener =
118 crate::server::socket_helpers::create_reusable_tcp_listener(listen_addr).await?;
119 let local_addr = listener.local_addr()?;
120 Log::new(Some(&status_tx)).info(format!("OpenAI API server listening on {}", local_addr));
121
122 let protocol = Arc::new(OpenAiProtocol::new());
123
124 // Spawn server loop
125 let task_registrar = app_state.clone();
126 let limiter = crate::server::accept_bounded::ConnectionLimiter::new(MAX_CONNECTIONS);
127 let accept_handle = tokio::spawn(async move {
128 loop {
129 match crate::server::accept_bounded::accept_bounded(
130 &listener,
131 &limiter,
132 CONNECTION_CAP_REFUSAL,
133 "OpenAI",
134 Some(&status_tx),
135 )
136 .await
137 {
138 Ok((stream, remote_addr, permit)) => {
139 let connection_id =
140 ConnectionId::new(app_state.get_next_unified_id().await);
141 let local_addr_conn = stream.local_addr().unwrap_or(local_addr);
142 Log::new(Some(&status_tx)).info(format!(
143 "OpenAI API connection {} from {}",
144 connection_id, remote_addr
145 ));
146
147 // Add connection to ServerInstance
148 use crate::state::server::{
149 ConnectionState as ServerConnectionState, ConnectionStatus,
150 ProtocolConnectionInfo,
151 };
152 let now = crate::utils::clock::Instant::now();
153 let conn_state = ServerConnectionState {
154 id: connection_id,
155 remote_addr,
156 local_addr: local_addr_conn,
157 bytes_sent: 0,
158 bytes_received: 0,
159 packets_sent: 0,
160 packets_received: 0,
161 last_activity: now,
162 status: ConnectionStatus::Active,
163 status_changed_at: now,
164 protocol_info: ProtocolConnectionInfo::empty(),
165 };
166 app_state
167 .add_connection_to_server(server_id, conn_state)
168 .await;
169 let _ = status_tx.send("__UPDATE_UI__".to_string());
170
171 let llm_client_clone = llm_client.clone();
172 let app_state_clone = app_state.clone();
173 let status_tx_clone = status_tx.clone();
174 let protocol_clone = protocol.clone();
175
176 // Spawn a task to handle this connection
177 // Tracked, not detached: stop_server must abort this task too.
178 let task_owner = app_state.clone();
179 task_owner
180 .spawn_server_task(server_id, async move {
181 // The permit is released when this task ends, which is what
182 // makes MAX_CONNECTIONS a cap on live connections rather than
183 // on the accept rate.
184 let _permit = permit;
185
186 // First-byte bound, before hyper sees the socket. `peek` waits
187 // for data without consuming it, so the request line is still
188 // there for hyper afterwards — see FIRST_BYTE_READ_TIMEOUT for
189 // why a deadline inside hyper's own reads would be wrong.
190 let spoke = matches!(
191 tokio::time::timeout(
192 first_byte_timeout,
193 stream.peek(&mut [0u8; 1])
194 )
195 .await,
196 Ok(Ok(n)) if n > 0
197 );
198
199 if spoke {
200 let io = TokioIo::new(stream);
201
202 // Clone for service closure
203 let status_for_service = status_tx_clone.clone();
204 let app_state_for_service = app_state_clone.clone();
205
206 // Whether this connection is answering anything. The
207 // watchdog below reads it, so only genuine silence — never
208 // a model round-trip or an event parked for a human — can
209 // close a connection.
210 let activity = Arc::new(
211 crate::server::accept_bounded::ConnectionActivity::new(),
212 );
213 let activity_for_service = Arc::clone(&activity);
214
215 // Create a service that handles OpenAI API requests with LLM
216 let service = service_fn(move |req: Request<Incoming>| {
217 let llm_clone = llm_client_clone.clone();
218 let state_clone = app_state_for_service.clone();
219 let status_clone = status_for_service.clone();
220 let protocol_clone = protocol_clone.clone();
221 let activity = Arc::clone(&activity_for_service);
222 async move {
223 let _busy = activity.busy();
224 handle_openai_request(
225 req,
226 connection_id,
227 llm_clone,
228 state_clone,
229 status_clone,
230 protocol_clone,
231 server_id,
232 )
233 .await
234 }
235 });
236
237 // Serve HTTP/1 on this connection
238 let conn = http1::Builder::new().serve_connection(io, service);
239 tokio::pin!(conn);
240 tokio::select! {
241 result = &mut conn => {
242 if let Err(err) = result {
243 error!(
244 "Error serving OpenAI API connection: {:?}",
245 err
246 );
247 }
248 }
249 // The idle bound, over `ConnectionActivity` rather than
250 // over a read: hyper owns every read once
251 // `serve_connection` starts and keeps polling for frames
252 // while a request is being answered, so a deadline on
253 // those reads would fire mid-answer.
254 _ = crate::server::accept_bounded::watch_idle(
255 Arc::clone(&activity),
256 idle_timeout,
257 ) => {
258 Log::new(Some(&status_tx_clone)).debug(format!(
259 "OpenAI API connection {} idle for {}s; closing \
260 decision=fail_closed_idle_timeout",
261 connection_id,
262 idle_timeout.as_secs()
263 ));
264 }
265 }
266 } else {
267 Log::new(Some(&status_tx_clone)).debug(format!(
268 "OpenAI API peer {} sent no request within {}s; closing \
269 decision=fail_closed_first_byte_timeout",
270 remote_addr,
271 first_byte_timeout.as_secs()
272 ));
273 }
274
275 // Mark connection as closed
276 app_state_clone
277 .close_connection_on_server(server_id, connection_id)
278 .await;
279 Log::new(Some(&status_tx_clone)).info(format!(
280 "OpenAI API connection {} closed",
281 connection_id
282 ));
283 let _ = status_tx_clone.send("__UPDATE_UI__".to_string());
284 })
285 .await;
286 }
287 Err(e) => {
288 Log::new(Some(&status_tx))
289 .error(format!("Failed to accept OpenAI API connection: {}", e));
290 break;
291 }
292 }
293 }
294 });
295
296 task_registrar
297 .register_server_task(server_id, accept_handle)
298 .await;
299
300 Ok(local_addr)
301 }
302}
303
304/// How much of a request body is read before the request is refused with 413.
305///
306/// Same value and same reasoning as `http_common::handler::MAX_REQUEST_BODY_BYTES`, declared
307/// here rather than imported because the `openai` feature does not pull in `http`, so that
308/// module is configured out of an `--features openai` build.
309pub const MAX_REQUEST_BODY_BYTES: usize = 8 * 1024 * 1024;
310
311/// An OpenAI-shaped `{"error": {...}}` body at the given status.
312///
313/// `message` is always a fixed string or a `WireFailure` category — never an error's own
314/// text. A peer gets the category; the log gets the error.
315fn error_response(
316 status: StatusCode,
317 message: &str,
318 error_type: &str,
319 code: &str,
320) -> Response<Full<Bytes>> {
321 let body = json!({
322 "error": { "message": message, "type": error_type, "code": code }
323 })
324 .to_string();
325
326 Response::builder()
327 .status(status)
328 .header("Content-Type", "application/json")
329 .body(Full::new(Bytes::from(body.clone())))
330 .unwrap_or_else(|_| {
331 // Never fall back to `Response::new`, which is a 200: that would turn a refusal
332 // into a success.
333 let mut response = Response::new(Full::new(Bytes::from(body)));
334 *response.status_mut() = StatusCode::INTERNAL_SERVER_ERROR;
335 response
336 })
337}
338
339/// Handle a single OpenAI API request with LLM actions
340async fn handle_openai_request(
341 req: Request<Incoming>,
342 connection_id: ConnectionId,
343 llm_client: OllamaClient,
344 app_state: Arc<AppState>,
345 status_tx: mpsc::UnboundedSender<String>,
346 protocol: Arc<OpenAiProtocol>,
347 server_id: crate::state::ServerId,
348) -> Result<Response<Full<Bytes>>, Infallible> {
349 let method = req.method().to_string();
350 let uri = req.uri().clone();
351 let path = uri.path().to_string();
352
353 Log::new(Some(&status_tx)).debug(format!("OpenAI API request: {} {}", method, path));
354
355 // Read the request body, bounded. `Incoming` has no default limit, so `req.collect()`
356 // buffered whatever the peer chose to send — and this endpoint is unauthenticated, so a
357 // single `POST /v1/chat/completions` with an endless chunked body was enough to walk the
358 // process out of memory. `Limited` errors as soon as the cap is passed, so an oversized
359 // upload costs at most the cap rather than all of it.
360 //
361 // The cap is small because the body is handed to the model as prompt text: a model
362 // cannot read 8 MiB, so every byte past that is cost without benefit. 413 rather than an
363 // empty body, because a truncated body looks complete to the model and it would answer a
364 // request it never saw.
365 let limited = http_body_util::Limited::new(req.into_body(), MAX_REQUEST_BODY_BYTES);
366 let body_bytes = match limited.collect().await {
367 Ok(collected) => collected.to_bytes(),
368 Err(e) => {
369 Log::new(Some(&status_tx)).warn(format!(
370 "OpenAI {} {} decision=fail_closed_body_too_large: {} (limit {} bytes)",
371 method, path, e, MAX_REQUEST_BODY_BYTES
372 ));
373 return Ok(error_response(
374 StatusCode::PAYLOAD_TOO_LARGE,
375 "request body too large",
376 "invalid_request_error",
377 "request_too_large",
378 ));
379 }
380 };
381
382 let body_text = String::from_utf8_lossy(&body_bytes);
383
384 // Create OpenAI request event
385 let event = Event::new(
386 &OPENAI_REQUEST_EVENT,
387 json!({
388 "method": method,
389 "path": path,
390 "body": if body_text.is_empty() { "" } else { body_text.as_ref() }
391 }),
392 );
393
394 // Call LLM to generate OpenAI response
395 match call_llm(
396 &llm_client,
397 &app_state,
398 server_id,
399 Some(connection_id),
400 &event,
401 protocol.as_ref(),
402 )
403 .await
404 {
405 Ok(execution_result) => {
406 debug!("LLM OpenAI response received");
407
408 // Display messages
409 for msg in execution_result.messages {
410 let _ = status_tx.send(msg);
411 }
412
413 // Build HTTP response from action results
414 build_openai_response(
415 execution_result.protocol_results,
416 &method,
417 &path,
418 &status_tx,
419 )
420 }
421 Err(e) => {
422 // The peer gets a category and the log gets the error. `Overloaded` is
423 // transient, so it is a 503 and a client backs off; anything else is a 500 so it
424 // is not retried forever. The connection itself continues.
425 let failure = crate::utils::WireFailure::classify(&e);
426 let status = if failure.is_overloaded() {
427 StatusCode::SERVICE_UNAVAILABLE
428 } else {
429 StatusCode::INTERNAL_SERVER_ERROR
430 };
431 Log::new(Some(&status_tx)).warn(format!(
432 "OpenAI {} {} decision=fail_closed_llm_error category={}: {}",
433 method,
434 path,
435 if failure.is_overloaded() {
436 "overloaded"
437 } else {
438 "unavailable"
439 },
440 e
441 ));
442
443 Ok(error_response(
444 status,
445 failure.text(),
446 "server_error",
447 "internal_error",
448 ))
449 }
450 }
451}
452
453/// Build HTTP response from OpenAI action results
454fn build_openai_response(
455 protocol_results: Vec<ActionResult>,
456 method: &str,
457 path: &str,
458 status_tx: &mpsc::UnboundedSender<String>,
459) -> Result<Response<Full<Bytes>>, Infallible> {
460 // Find openai_response result
461 for result in &protocol_results {
462 if let ActionResult::Custom { name, data } = result {
463 if name == "openai_response" {
464 // `as u16` wrapped. The executors bound `status` to 100-599 before it gets
465 // here, so nothing can reach this with 65736 today — but the narrowing sat
466 // one careless executor away from turning a refusal into a 200, which is the
467 // defect this protocol's `status_range_test` exists for. Read it without
468 // narrowing, and treat anything unusable as a server error rather than as
469 // the 200 the old default supplied.
470 let status = match data.get("status").and_then(|v| v.as_u64()) {
471 None => 200,
472 Some(code) => u16::try_from(code)
473 .ok()
474 .filter(|code| (100..=599).contains(code))
475 .unwrap_or_else(|| {
476 error!(
477 "OpenAI executor produced status {} which is not an HTTP \
478 status; answering 500",
479 code
480 );
481 500
482 }),
483 };
484
485 let headers = data
486 .get("headers")
487 .and_then(|v| v.as_array())
488 .cloned()
489 .unwrap_or_default();
490
491 let body = data.get("body").and_then(|v| v.as_str()).unwrap_or("{}");
492
493 Log::new(Some(status_tx))
494 .debug(format!("OpenAI {} {} -> {}", method, path, status));
495
496 // Every header name and value here came out of an action's data, so this
497 // must not end in `.unwrap()`: a name with a space in it, or a value
498 // carrying CR/LF (a response-splitting attempt), makes the builder return
499 // Err and would panic the connection task. Drop the individual bad header
500 // and keep the response.
501 let mut response_builder = Response::builder().status(status);
502 for header in headers {
503 if let (Some(name_val), Some(value_val)) = (
504 header.get(0).and_then(|v| v.as_str()),
505 header.get(1).and_then(|v| v.as_str()),
506 ) {
507 let candidate = Response::builder().header(name_val, value_val);
508 if candidate.headers_ref().is_some() {
509 response_builder = response_builder.header(name_val, value_val);
510 } else {
511 error!("OpenAI dropping unusable response header {:?}", name_val);
512 }
513 }
514 }
515
516 return Ok(response_builder
517 .body(Full::new(Bytes::from(body.to_string())))
518 .unwrap_or_else(|e| {
519 error!("OpenAI could not build the response ({e}); answering 500");
520 error_response(
521 StatusCode::INTERNAL_SERVER_ERROR,
522 crate::utils::WireFailure::Unavailable.text(),
523 "server_error",
524 "internal_error",
525 )
526 }));
527 }
528 }
529 }
530
531 // The model was consulted and produced no response action. "LLM did not return valid
532 // response" named netget's own internals on a stranger's terminal; the peer gets a
533 // category and the reason stays in the log.
534 Log::new(Some(status_tx)).warn(format!(
535 "OpenAI {} {} decision=fail_closed_no_action: no openai_response action in the result",
536 method, path
537 ));
538
539 Ok(error_response(
540 StatusCode::INTERNAL_SERVER_ERROR,
541 crate::utils::WireFailure::Unavailable.text(),
542 "server_error",
543 "internal_error",
544 ))
545}