Skip to main content

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}