Skip to main content

agentic_core/executor/
inference.rs

1//! HTTP transport layer for LLM backend communication.
2//!
3//! Handles sending requests, reading streaming chunks, and mapping network
4//! and HTTP errors to [`ExecutorError`].
5
6use std::sync::Arc;
7use std::time::Duration;
8
9use async_stream::stream;
10use futures::{Stream, StreamExt};
11
12use crate::executor::error::{ExecutorError, ExecutorResult};
13
14/// SSE stream of raw lines sent to the client (`data: …\n\n` per event).
15pub type BoxStream = std::pin::Pin<Box<dyn Stream<Item = String> + Send>>;
16
17/// Wire-format marker signalling end-of-stream to the client.
18pub(super) const DONE_MARKER: &str = "data: [DONE]\n\n";
19
20/// Fetch the next raw bytes chunk from a streaming response.
21///
22/// Returns `Ok(Some(bytes))` on data, `Ok(None)` when the stream ends cleanly,
23/// and `Err` on a network failure or chunk timeout.
24pub(super) async fn next_chunk<S>(stream: &mut S, timeout: Duration) -> ExecutorResult<Option<bytes::Bytes>>
25where
26    S: futures::Stream<Item = Result<bytes::Bytes, reqwest::Error>> + Unpin,
27{
28    let item = if timeout.is_zero() {
29        stream.next().await
30    } else {
31        tokio::time::timeout(timeout, stream.next()).await.map_err(|_| {
32            ExecutorError::StreamError("chunk timeout: no data received within the configured window".into())
33        })?
34    };
35    item.transpose().map_err(ExecutorError::NetworkError)
36}
37
38fn drain_complete_utf8_lines(buffer: &mut Vec<u8>) -> Vec<String> {
39    let mut lines = Vec::new();
40    while let Some(pos) = buffer.iter().position(|byte| *byte == b'\n') {
41        let line = buffer.drain(..=pos).collect::<Vec<_>>();
42        let line_end = if pos > 0 && line.get(pos - 1) == Some(&b'\r') {
43            pos - 1
44        } else {
45            pos
46        };
47        if let Ok(line) = std::str::from_utf8(&line[..line_end]) {
48            lines.push(line.to_string());
49        }
50    }
51    lines
52}
53
54/// Build, send, and validate an HTTP POST to the LLM backend.
55///
56/// Shared by both the blocking path (caller reads `.text()`) and the streaming
57/// path (caller reads `.bytes_stream()`). Maps connect/timeout failures and
58/// non-2xx status codes to [`ExecutorError::LLMRequest`].
59pub(super) async fn send_request(
60    client: &reqwest::Client,
61    url: &str,
62    body: String,
63    auth: Option<&str>,
64) -> ExecutorResult<reqwest::Response> {
65    let mut req = client.post(url).header("Content-Type", "application/json").body(body);
66    if let Some(key) = auth {
67        req = req.bearer_auth(key);
68    }
69
70    let resp = req.send().await.map_err(|e| ExecutorError::LLMRequest {
71        status: if e.is_timeout() {
72            http::StatusCode::GATEWAY_TIMEOUT
73        } else {
74            http::StatusCode::BAD_GATEWAY
75        },
76        body: if e.is_timeout() {
77            "upstream timeout".into()
78        } else {
79            "upstream unavailable".into()
80        },
81    })?;
82
83    if !resp.status().is_success() {
84        let status = resp.status().as_u16();
85        // Log and discard any error reading the error body — the status code
86        // is the primary signal; an empty body is acceptable here.
87        let body = resp
88            .text()
89            .await
90            .inspect_err(|e| tracing::debug!("failed to read error response body: {e}"))
91            .unwrap_or_default();
92        return Err(ExecutorError::LLMRequest {
93            status: http::StatusCode::from_u16(status).unwrap_or(http::StatusCode::INTERNAL_SERVER_ERROR),
94            body,
95        });
96    }
97
98    Ok(resp)
99}
100
101/// Makes a non-streaming HTTP POST to the LLM backend and returns the full JSON body.
102///
103/// Used by `run_blocking` so it can pass the result to [`ResponseAccumulator::from_json`](crate::executor::accumulator::ResponseAccumulator::from_json).
104pub(super) async fn fetch_response_json(
105    upstream_json: String,
106    url: &str,
107    client: &reqwest::Client,
108    auth: Option<&str>,
109) -> ExecutorResult<String> {
110    let resp = send_request(client, url, upstream_json, auth).await?;
111    // Preserve the reqwest::Error as the typed source (NetworkError).
112    resp.text().await.map_err(ExecutorError::NetworkError)
113}
114
115/// Step 2 — Call the LLM inference backend; yields raw SSE lines (`data: …`).
116///
117/// Always requests `stream=true` upstream. Stops on `[DONE]`.
118///
119/// # Errors
120/// Each stream item is `Result<String, ExecutorError>`. The stream yields `Err` on:
121/// - [`ExecutorError::LLMRequest`] — connect timeout (504), connection failure (502),
122///   or non-2xx HTTP status from the backend
123/// - [`ExecutorError::NetworkError`] — network failure while reading the response body
124pub fn call_inference(
125    upstream_json: String,
126    url: String,
127    client: Arc<reqwest::Client>,
128    auth: Option<String>,
129    chunk_timeout: Duration,
130) -> impl Stream<Item = Result<String, ExecutorError>> + Send + 'static {
131    stream! {
132        let resp = match send_request(&client, &url, upstream_json, auth.as_deref()).await {
133            Ok(r) => r,
134            Err(e) => { yield Err(e); return; }
135        };
136
137        let mut bytes = resp.bytes_stream();
138        let mut buf = Vec::with_capacity(8192);
139
140        loop {
141            let chunk = match next_chunk(&mut bytes, chunk_timeout).await {
142                Ok(Some(c)) => c,
143                Ok(None) => break,
144                Err(e) => { yield Err(e); return; }
145            };
146
147            buf.extend_from_slice(&chunk);
148
149            for line in drain_complete_utf8_lines(&mut buf) {
150                match line.as_str() {
151                    "data: [DONE]" => return,
152                    l if l.starts_with("data: ") => yield Ok(line),
153                    _ => {}
154                }
155            }
156        }
157    }
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163
164    #[test]
165    fn utf8_line_reader_preserves_split_multibyte_characters() {
166        let snowman = "\u{2603}";
167        let line = format!(r#"data: {{"delta":"snow {snowman}"}}"#);
168        let bytes = format!("{line}\n").into_bytes();
169        let split_at = bytes
170            .windows(snowman.len())
171            .position(|window| window == snowman.as_bytes())
172            .expect("snowman bytes present")
173            + 1;
174        let mut buffer = bytes[..split_at].to_vec();
175
176        assert!(drain_complete_utf8_lines(&mut buffer).is_empty());
177
178        buffer.extend_from_slice(&bytes[split_at..]);
179        let lines = drain_complete_utf8_lines(&mut buffer);
180
181        assert!(buffer.is_empty());
182        assert_eq!(lines, vec![line]);
183        assert!(!lines[0].contains('\u{FFFD}'));
184    }
185}