AkurAI Build
Menu

Public repository / master

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

Latest change a81af83654d182e3becb8e2beaed6d61a3bd8e6b - feat(llm): streaming OpenAI-compatible client + /api/assistant SSE endpoint by Ólafur Búi Ólafsson

//! The plain-HTTP/1.1 socket round-trip that streams the SSE response.
//!
//! [`stream_lines`] connects, writes the request, reads the response head
//! (handling `Transfer-Encoding: chunked`, which streaming OpenAI servers use),
//! and invokes a callback for every complete line of the body as it arrives.
//!
//! The socket loop itself is not unit-tested (tests never touch the network).
//! The line-reassembly [`LineBuffer`] — the one tricky pure part, since chunk
//! boundaries don't align with lines — is unit-tested directly.

use std::io::{BufRead, BufReader, Read, Write};
use std::net::TcpStream;
use std::time::Duration;

use crate::error::LlmError;
use crate::request::Endpoint;

/// Per-read timeout. Generous enough for a cold local model's first token and
/// for inter-token gaps; it resets on every read, so total generation time is
/// unbounded as long as tokens keep flowing.
const TIMEOUT: Duration = Duration::from_secs(120);

/// Connect, send `request`, and call `on_line` for each complete body line of
/// the streamed response (newline stripped). Returns when the stream ends
/// (final chunk or EOF) or `on_line` returns an error.
pub fn stream_lines<F>(ep: &Endpoint, request: &str, mut on_line: F) -> Result<(), LlmError>
where
    F: FnMut(&str) -> Result<(), LlmError>,
{
    let addr = format!("{}:{}", ep.host, ep.port);
    let stream = TcpStream::connect(&addr).map_err(|e| LlmError::Connect(e.to_string()))?;
    stream.set_read_timeout(Some(TIMEOUT)).map_err(map_io)?;
    stream.set_write_timeout(Some(TIMEOUT)).map_err(map_io)?;

    {
        let mut w = &stream;
        w.write_all(request.as_bytes()).map_err(map_io)?;
        w.flush().map_err(map_io)?;
    }

    let mut reader = BufReader::new(stream);
    let head = read_head(&mut reader)?;
    if !(200..300).contains(&head.status) {
        let mut buf = [0u8; 512];
        let snippet = match reader.read(&mut buf) {
            Ok(n) => String::from_utf8_lossy(&buf[..n]).into_owned(),
            Err(_) => String::new(),
        };
        return Err(LlmError::HttpStatus {
            code: head.status,
            snippet,
        });
    }

    if head.chunked {
        read_chunked(&mut reader, &mut on_line)
    } else {
        read_plain(&mut reader, &mut on_line)
    }
}

/// Parsed response head: status code and whether the body is chunked.
struct Head {
    status: u16,
    chunked: bool,
}

/// Read the status line and headers up to the blank line.
fn read_head<R: BufRead>(reader: &mut R) -> Result<Head, LlmError> {
    let mut status_line = String::new();
    if reader.read_line(&mut status_line).map_err(map_io)? == 0 {
        return Err(LlmError::ShortResponse);
    }
    let status = status_line
        .split_whitespace()
        .nth(1)
        .and_then(|c| c.parse().ok())
        .ok_or(LlmError::ShortResponse)?;

    let mut chunked = false;
    loop {
        let mut line = String::new();
        if reader.read_line(&mut line).map_err(map_io)? == 0 {
            break; // EOF before blank line
        }
        let trimmed = line.trim_end_matches(['\r', '\n']);
        if trimmed.is_empty() {
            break; // end of headers
        }
        if let Some((name, value)) = trimmed.split_once(':') {
            if name.trim().eq_ignore_ascii_case("transfer-encoding")
                && value.to_ascii_lowercase().contains("chunked")
            {
                chunked = true;
            }
        }
    }
    Ok(Head { status, chunked })
}

/// Read an unchunked body line by line until EOF.
fn read_plain<R, F>(reader: &mut R, on_line: &mut F) -> Result<(), LlmError>
where
    R: BufRead,
    F: FnMut(&str) -> Result<(), LlmError>,
{
    loop {
        let mut line = String::new();
        if reader.read_line(&mut line).map_err(map_io)? == 0 {
            return Ok(());
        }
        on_line(line.trim_end_matches(['\r', '\n']))?;
    }
}

/// Decode `Transfer-Encoding: chunked` framing and feed the payload bytes
/// through a [`LineBuffer`], so SSE lines that span chunk boundaries are
/// reassembled correctly.
fn read_chunked<R, F>(reader: &mut R, on_line: &mut F) -> Result<(), LlmError>
where
    R: BufRead,
    F: FnMut(&str) -> Result<(), LlmError>,
{
    let mut lines = LineBuffer::new();
    loop {
        let mut size_line = String::new();
        if reader.read_line(&mut size_line).map_err(map_io)? == 0 {
            break; // EOF without a terminating zero-chunk; flush what we have.
        }
        let hex = size_line.trim().split(';').next().unwrap_or("").trim();
        if hex.is_empty() {
            continue;
        }
        let size = usize::from_str_radix(hex, 16).map_err(|_| LlmError::ShortResponse)?;
        if size == 0 {
            break; // final chunk
        }
        let mut buf = vec![0u8; size];
        reader.read_exact(&mut buf).map_err(map_io)?;
        lines.push(&buf, on_line)?;
        // Consume the CRLF that terminates the chunk data.
        let mut crlf = [0u8; 2];
        let _ = reader.read_exact(&mut crlf);
    }
    lines.flush(on_line)
}

/// Map an I/O error to a timeout or generic I/O variant.
fn map_io(e: std::io::Error) -> LlmError {
    match e.kind() {
        std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut => LlmError::Timeout,
        _ => LlmError::Io(e.to_string()),
    }
}

/// Reassembles a byte stream (whose boundaries are arbitrary) into complete
/// `\n`-terminated lines, emitting each via a callback. A trailing partial line
/// is held until more bytes arrive or [`flush`](LineBuffer::flush) is called.
#[derive(Default)]
pub struct LineBuffer {
    buf: Vec<u8>,
}

impl LineBuffer {
    pub fn new() -> LineBuffer {
        LineBuffer::default()
    }

    /// Append `data` and emit every complete line it completes.
    pub fn push<F>(&mut self, data: &[u8], on_line: &mut F) -> Result<(), LlmError>
    where
        F: FnMut(&str) -> Result<(), LlmError>,
    {
        self.buf.extend_from_slice(data);
        while let Some(pos) = self.buf.iter().position(|&b| b == b'\n') {
            let line: Vec<u8> = self.buf.drain(..=pos).collect();
            let text = String::from_utf8_lossy(&line);
            on_line(text.trim_end_matches(['\r', '\n']))?;
        }
        Ok(())
    }

    /// Emit any held-back final line that had no trailing newline.
    pub fn flush<F>(&mut self, on_line: &mut F) -> Result<(), LlmError>
    where
        F: FnMut(&str) -> Result<(), LlmError>,
    {
        if !self.buf.is_empty() {
            let text = String::from_utf8_lossy(&self.buf);
            let trimmed = text.trim_end_matches(['\r', '\n']);
            if !trimmed.is_empty() {
                on_line(trimmed)?;
            }
            self.buf.clear();
        }
        Ok(())
    }
}

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

    fn collect(fragments: &[&[u8]]) -> Vec<String> {
        let mut out = Vec::new();
        let mut lb = LineBuffer::new();
        let mut sink = |line: &str| {
            out.push(line.to_string());
            Ok(())
        };
        for f in fragments {
            lb.push(f, &mut sink).unwrap();
        }
        lb.flush(&mut sink).unwrap();
        out
    }

    #[test]
    fn splits_whole_lines() {
        assert_eq!(
            collect(&[b"data: a\ndata: b\n"]),
            vec!["data: a".to_string(), "data: b".to_string()]
        );
    }

    #[test]
    fn reassembles_a_line_split_across_fragments() {
        // The "data: hello" line is delivered in three byte-fragments.
        assert_eq!(
            collect(&[b"data: hel", b"lo\nda", b"ta: x\n"]),
            vec!["data: hello".to_string(), "data: x".to_string()]
        );
    }

    #[test]
    fn strips_crlf_and_keeps_blank_lines() {
        assert_eq!(
            collect(&[b"a\r\n\r\nb\r\n"]),
            vec!["a".to_string(), String::new(), "b".to_string()]
        );
    }

    #[test]
    fn flush_emits_unterminated_tail() {
        assert_eq!(collect(&[b"data: tail"]), vec!["data: tail".to_string()]);
    }

    #[test]
    fn flush_drops_empty_tail() {
        assert_eq!(collect(&[b"only\n"]), vec!["only".to_string()]);
    }
}