AkurAI Build
Menu

Public repository / master

AkurAI-Framework / crates/llm/src/request.rs

Latest change c6179dcf3abbf53272e4279e6740397ad2e75726 - AKURAI-FRAMEWORK-1..9 consolidate framework APIs by Ólafur Búi Ólafsson

//! Endpoint parsing and HTTP/1.1 request building for a streaming
//! `POST /v1/chat/completions`.
//!
//! The pure parts here — [`parse_endpoint`], [`build_body`], [`build_request`]
//! — are unit-tested against hand-written strings; no network is touched.

use akurai_json::Value;

use crate::error::LlmError;
use crate::types::Message;

/// A parsed plain-HTTP endpoint: `host` and `port`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Endpoint {
    pub host: String,
    pub port: u16,
}

/// Parse an endpoint like `http://host:port` (or `http://host`, defaulting to
/// port 80). A trailing path is ignored — we always POST to
/// `/v1/chat/completions`.
///
/// `https://` is rejected with [`LlmError::TlsUnsupported`].
pub fn parse_endpoint(endpoint: &str) -> Result<Endpoint, LlmError> {
    let trimmed = endpoint.trim();
    if akurai_http::endpoint::is_https_endpoint(trimmed) {
        return Err(LlmError::TlsUnsupported(trimmed.to_string()));
    }
    let (host, port) =
        akurai_http::endpoint::parse_plain_endpoint(trimmed).map_err(LlmError::InvalidEndpoint)?;
    Ok(Endpoint { host, port })
}

/// Serialize the chat-completions request body using `akurai_json` so strings
/// are escaped correctly:
/// `{"model": ..., "messages": [{"role","content"}, ...], "stream": true,
///  "tools": [...]?}`.
///
/// `stream` is always `true`. `tools` is omitted entirely when `None` or empty.
/// Keys are emitted in insertion order.
pub fn build_body(model: &str, messages: &[Message], tools: Option<&[Value]>) -> String {
    let msgs = Value::Array(
        messages
            .iter()
            .map(|m| {
                Value::Object(vec![
                    ("role".into(), Value::Str(m.role.clone())),
                    ("content".into(), Value::Str(m.content.clone())),
                ])
            })
            .collect(),
    );
    let mut fields = vec![
        ("model".into(), Value::Str(model.to_string())),
        ("messages".into(), msgs),
        ("stream".into(), Value::Bool(true)),
    ];
    if let Some(tools) = tools {
        if !tools.is_empty() {
            fields.push(("tools".into(), Value::Array(tools.to_vec())));
        }
    }
    Value::Object(fields).to_json()
}

/// Build a complete HTTP/1.1 request string for the streaming
/// `POST /v1/chat/completions`.
///
/// `Content-Length` is the exact UTF-8 byte length of the body. `Accept:
/// text/event-stream` requests the SSE response, and `Connection: close` lets
/// the client read the stream until EOF.
pub fn build_request(
    host: &str,
    port: u16,
    model: &str,
    messages: &[Message],
    tools: Option<&[Value]>,
) -> String {
    let body = build_body(model, messages, tools);
    let host_header = if port == 80 {
        host.to_string()
    } else {
        format!("{host}:{port}")
    };
    format!(
        "POST /v1/chat/completions HTTP/1.1\r\n\
         Host: {host_header}\r\n\
         Content-Type: application/json\r\n\
         Accept: text/event-stream\r\n\
         Content-Length: {len}\r\n\
         Connection: close\r\n\
         \r\n\
         {body}",
        host_header = host_header,
        len = body.len(),
        body = body,
    )
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parses_host_and_port() {
        let e = parse_endpoint("http://192.168.8.14:8080").unwrap();
        assert_eq!(e.host, "192.168.8.14");
        assert_eq!(e.port, 8080);
    }

    #[test]
    fn defaults_port_80() {
        let e = parse_endpoint("http://localhost").unwrap();
        assert_eq!(e.host, "localhost");
        assert_eq!(e.port, 80);
    }

    #[test]
    fn ignores_trailing_path() {
        let e = parse_endpoint("http://host:9000/v1/chat/completions").unwrap();
        assert_eq!(e.host, "host");
        assert_eq!(e.port, 9000);
    }

    #[test]
    fn rejects_https() {
        assert_eq!(
            parse_endpoint("https://host:443"),
            Err(LlmError::TlsUnsupported("https://host:443".into()))
        );
    }

    #[test]
    fn rejects_unknown_scheme_and_empty_host() {
        assert!(matches!(
            parse_endpoint("ftp://host"),
            Err(LlmError::InvalidEndpoint(_))
        ));
        assert!(matches!(
            parse_endpoint("http://"),
            Err(LlmError::InvalidEndpoint(_))
        ));
    }

    #[test]
    fn body_serializes_messages_in_order_with_stream_true() {
        let msgs = [
            Message::new("system", "be brief"),
            Message::new("user", "hi"),
        ];
        let body = build_body("gemma", &msgs, None);
        assert_eq!(
            body,
            r#"{"model":"gemma","messages":[{"role":"system","content":"be brief"},{"role":"user","content":"hi"}],"stream":true}"#
        );
    }

    #[test]
    fn body_escapes_content() {
        let msgs = [Message::new("user", "say \"hæ\"")];
        let body = build_body("m", &msgs, None);
        assert!(body.contains(r#""content":"say \"hæ\"""#));
    }

    #[test]
    fn body_includes_tools_when_present() {
        let tool = akurai_json::parse(r#"{"type":"function","function":{"name":"now"}}"#).unwrap();
        let body = build_body("m", &[Message::new("user", "x")], Some(&[tool]));
        assert!(body
            .contains(r#""stream":true,"tools":[{"type":"function","function":{"name":"now"}}]"#));
    }

    #[test]
    fn body_omits_empty_tools() {
        let body = build_body("m", &[Message::new("user", "x")], Some(&[]));
        assert!(!body.contains("tools"));
    }

    #[test]
    fn request_is_well_formed() {
        let msgs = [Message::new("user", "hi")];
        let req = build_request("titan", 8080, "gemma", &msgs, None);
        let body = build_body("gemma", &msgs, None);
        assert!(req.starts_with("POST /v1/chat/completions HTTP/1.1\r\n"));
        assert!(req.contains("Host: titan:8080\r\n"));
        assert!(req.contains("Content-Type: application/json\r\n"));
        assert!(req.contains("Accept: text/event-stream\r\n"));
        assert!(req.contains("Connection: close\r\n"));
        assert!(req.contains(&format!("Content-Length: {}\r\n", body.len())));
        assert!(req.ends_with(&format!("\r\n\r\n{body}")));
    }

    #[test]
    fn request_host_header_omits_port_80() {
        let req = build_request("localhost", 80, "m", &[Message::new("user", "x")], None);
        assert!(req.contains("Host: localhost\r\n"));
    }
}