#[cfg(test)]
use std::cell::RefCell;
use std::pin::Pin;
use std::task::{Context, Poll};
use bytes::Bytes;
#[cfg(test)]
use bytes::BytesMut;
use futures_core::Stream;
use memchr::memchr;
use pin_project_lite::pin_project;
use crate::error::{LiterLlmError, Result};
use crate::http::request::{ResponseReadOptions, with_retry_bounded};
#[cfg(test)]
use crate::types::ChatCompletionChunk;
const MAX_BUFFER_BYTES: usize = 1024 * 1024;
const TRUNCATION_PREVIEW_CHARS: usize = 64;
#[cfg(test)]
const MAX_POOL_BUFFER_CAPACITY: usize = 64 * 1024;
#[cfg(test)]
thread_local! {
static BYTES_POOL: RefCell<Option<BytesMut>> = const { RefCell::new(None) };
}
#[cfg(test)]
fn pool_acquire() -> BytesMut {
BYTES_POOL.with(|cell| {
cell.borrow_mut()
.take()
.map(|mut buf| {
buf.clear();
buf
})
.unwrap_or_else(|| BytesMut::with_capacity(4096))
})
}
#[cfg(test)]
fn pool_release(buf: BytesMut) {
if buf.capacity() <= MAX_POOL_BUFFER_CAPACITY {
BYTES_POOL.with(|cell| {
*cell.borrow_mut() = Some(buf);
});
}
}
#[cfg(feature = "native-http")]
pub use tokio_util::sync::CancellationToken;
#[allow(dead_code, reason = "retain the unconfigured raw request entry point")]
pub async fn post_stream<P, T>(
client: &reqwest::Client,
url: &str,
auth_header: Option<(&str, &str)>,
extra_headers: &[(&str, &str)],
body: Bytes,
max_retries: u32,
parse_event: P,
) -> Result<crate::client::BoxStream<'static, Result<T>>>
where
P: Fn(&str) -> Result<Option<T>> + Send + 'static,
T: Send + 'static,
{
post_stream_bounded(
client,
url,
auth_header,
extra_headers,
body,
parse_event,
ResponseReadOptions {
max_retries,
max_response_bytes: None,
},
)
.await
}
#[tracing::instrument(
name = "post_stream",
level = "debug",
skip_all,
fields(
http.method = "POST",
http.url = %url,
http.status_code = tracing::field::Empty,
http.retry_count = tracing::field::Empty,
)
)]
pub(crate) async fn post_stream_bounded<P, T>(
client: &reqwest::Client,
url: &str,
auth_header: Option<(&str, &str)>,
extra_headers: &[(&str, &str)],
body: Bytes,
parse_event: P,
options: ResponseReadOptions,
) -> Result<crate::client::BoxStream<'static, Result<T>>>
where
P: Fn(&str) -> Result<Option<T>> + Send + 'static,
T: Send + 'static,
{
let ResponseReadOptions {
max_retries,
max_response_bytes,
} = options;
let mut retry_count = 0u32;
let resp = with_retry_bounded(url, max_retries, max_response_bytes, || {
let mut builder = client
.post(url)
.header(reqwest::header::CONTENT_TYPE, "application/json")
.body(body.clone());
if let Some((name, value)) = auth_header {
builder = builder.header(name, value);
}
for (name, value) in extra_headers {
builder = builder.header(*name, *value);
}
retry_count += 1;
builder.send()
})
.await?;
{
let span = tracing::Span::current();
span.record("http.status_code", resp.status().as_u16());
span.record("http.retry_count", retry_count.saturating_sub(1));
}
let byte_stream = resp.bytes_stream();
let stream = SseParser::new(byte_stream, parse_event, None);
Ok(Box::pin(stream))
}
#[cfg(feature = "native-http")]
#[allow(dead_code)]
#[allow(clippy::too_many_arguments)]
pub async fn post_stream_with_cancel<P, T>(
client: &reqwest::Client,
url: &str,
auth_header: Option<(&str, &str)>,
extra_headers: &[(&str, &str)],
body: Bytes,
max_retries: u32,
parse_event: P,
cancel: CancellationToken,
) -> Result<crate::client::BoxStream<'static, Result<T>>>
where
P: Fn(&str) -> Result<Option<T>> + Send + 'static,
T: Send + 'static,
{
post_stream_with_cancel_bounded(
client,
url,
auth_header,
extra_headers,
body,
parse_event,
cancel,
ResponseReadOptions {
max_retries,
max_response_bytes: None,
},
)
.await
}
#[tracing::instrument(
name = "post_stream_with_cancel",
level = "debug",
skip_all,
fields(
http.method = "POST",
http.url = %url,
http.status_code = tracing::field::Empty,
http.retry_count = tracing::field::Empty,
)
)]
#[cfg(feature = "native-http")]
#[allow(dead_code)]
#[allow(clippy::too_many_arguments)]
pub(crate) async fn post_stream_with_cancel_bounded<P, T>(
client: &reqwest::Client,
url: &str,
auth_header: Option<(&str, &str)>,
extra_headers: &[(&str, &str)],
body: Bytes,
parse_event: P,
cancel: CancellationToken,
options: ResponseReadOptions,
) -> Result<crate::client::BoxStream<'static, Result<T>>>
where
P: Fn(&str) -> Result<Option<T>> + Send + 'static,
T: Send + 'static,
{
let ResponseReadOptions {
max_retries,
max_response_bytes,
} = options;
let mut retry_count = 0u32;
let resp = with_retry_bounded(url, max_retries, max_response_bytes, || {
let mut builder = client
.post(url)
.header(reqwest::header::CONTENT_TYPE, "application/json")
.body(body.clone());
if let Some((name, value)) = auth_header {
builder = builder.header(name, value);
}
for (name, value) in extra_headers {
builder = builder.header(*name, *value);
}
retry_count += 1;
builder.send()
})
.await?;
{
let span = tracing::Span::current();
span.record("http.status_code", resp.status().as_u16());
span.record("http.retry_count", retry_count.saturating_sub(1));
}
let byte_stream = resp.bytes_stream();
let stream = SseParser::new(byte_stream, parse_event, Some(cancel));
Ok(Box::pin(stream))
}
#[cfg(feature = "native-http")]
type CancelField = Option<CancellationToken>;
#[cfg(not(feature = "native-http"))]
type CancelField = Option<std::convert::Infallible>;
pin_project! {
struct SseParser<S, P, T> {
#[pin]
inner: S,
buffer: String,
pending: Vec<u8>,
cursor: usize,
done: bool,
parse_event: P,
cancel: CancelField,
_marker: std::marker::PhantomData<fn() -> T>,
}
impl<S, P, T> PinnedDrop for SseParser<S, P, T> {
fn drop(this: Pin<&mut Self>) {
let _ = this;
}
}
}
impl<S, P, T> SseParser<S, P, T>
where
P: Fn(&str) -> Result<Option<T>>,
{
fn new(inner: S, parse_event: P, cancel: CancelField) -> Self {
Self {
inner,
buffer: String::with_capacity(4096),
pending: Vec::new(),
cursor: 0,
done: false,
parse_event,
cancel,
_marker: std::marker::PhantomData,
}
}
}
impl<S, P, T> Stream for SseParser<S, P, T>
where
S: Stream<Item = std::result::Result<Bytes, reqwest::Error>>,
P: Fn(&str) -> Result<Option<T>>,
{
type Item = Result<T>;
fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
let mut this = self.project();
#[cfg(feature = "native-http")]
if this.cancel.as_ref().is_some_and(|t| t.is_cancelled()) {
tracing::debug!("SSE stream cancelled by downstream disconnect");
*this.done = true;
return Poll::Ready(None);
}
loop {
if let Some(offset) = memchr(b'\n', &this.buffer.as_bytes()[*this.cursor..]) {
let newline_pos = *this.cursor + offset;
let line = this.buffer[*this.cursor..newline_pos].trim_end_matches('\r').trim();
if line.is_empty() || line.starts_with(':') {
*this.cursor = newline_pos + 1;
compact_if_needed(this.buffer, this.cursor);
continue;
}
if let Some(raw) = line.strip_prefix("data:") {
let data = raw.strip_prefix(' ').unwrap_or(raw).trim();
if data == "[DONE]" {
*this.cursor = newline_pos + 1;
compact_if_needed(this.buffer, this.cursor);
return Poll::Ready(None);
}
let result = (this.parse_event)(data);
*this.cursor = newline_pos + 1;
compact_if_needed(this.buffer, this.cursor);
match result {
Ok(None) => continue,
Ok(Some(chunk)) => return Poll::Ready(Some(Ok(chunk))),
Err(e) => return Poll::Ready(Some(Err(e))),
}
}
*this.cursor = newline_pos + 1;
compact_if_needed(this.buffer, this.cursor);
continue;
}
if *this.done {
let remaining = this.buffer.len() - *this.cursor;
if remaining > 0 {
let leftover = this.buffer[*this.cursor..].trim();
if !leftover.is_empty() {
let preview: String = leftover.chars().take(TRUNCATION_PREVIEW_CHARS).collect();
tracing::error!(
leftover_bytes = remaining,
preview = %preview,
"SSE stream ended with unterminated data in buffer; stream was truncated"
);
this.buffer.clear();
*this.cursor = 0;
this.pending.clear();
return Poll::Ready(Some(Err(LiterLlmError::Streaming {
message: format!(
"SSE stream truncated: {remaining} bytes of incomplete data at end of stream \
(starts with: {preview:?})"
),
})));
}
this.buffer.clear();
*this.cursor = 0;
}
if !this.pending.is_empty() {
let pending_len = this.pending.len();
this.pending.clear();
tracing::error!(
pending_bytes = pending_len,
"SSE stream ended mid-codepoint; stream was truncated"
);
return Poll::Ready(Some(Err(LiterLlmError::Streaming {
message: format!(
"SSE stream truncated: {pending_len} bytes of incomplete UTF-8 at end of stream"
),
})));
}
return Poll::Ready(None);
}
#[cfg(feature = "native-http")]
if this.cancel.as_ref().is_some_and(|t| t.is_cancelled()) {
tracing::debug!("SSE stream cancelled while waiting for next chunk");
*this.done = true;
return Poll::Ready(None);
}
match this.inner.as_mut().poll_next(cx) {
Poll::Ready(Some(Ok(bytes))) => {
if this.buffer.len() + this.pending.len() + bytes.len() > MAX_BUFFER_BYTES {
*this.done = true;
return Poll::Ready(Some(Err(LiterLlmError::Streaming {
message: format!("SSE buffer exceeded {MAX_BUFFER_BYTES} bytes; stream aborted"),
})));
}
this.pending.extend_from_slice(&bytes);
match std::str::from_utf8(this.pending) {
Ok(s) => {
this.buffer.push_str(s);
this.pending.clear();
}
Err(e) => {
let valid = e.valid_up_to();
let complete_error = e.error_len().is_some();
this.buffer
.push_str(unsafe { std::str::from_utf8_unchecked(&this.pending[..valid]) });
if complete_error {
*this.done = true;
return Poll::Ready(Some(Err(LiterLlmError::Streaming {
message: format!("invalid UTF-8 in SSE stream: {e}"),
})));
}
this.pending.drain(..valid);
}
}
}
Poll::Ready(Some(Err(e))) => {
return Poll::Ready(Some(Err(LiterLlmError::from(e))));
}
Poll::Ready(None) => {
*this.done = true;
continue;
}
Poll::Pending => {
return Poll::Pending;
}
}
}
}
}
fn compact_if_needed(buffer: &mut String, cursor: &mut usize) {
if *cursor > buffer.len() / 2 {
buffer.drain(..*cursor);
*cursor = 0;
}
}
#[cfg(test)]
pub(crate) fn parse_sse_line(line: &str) -> Option<Result<ChatCompletionChunk>> {
let raw = line.strip_prefix("data:")?;
let data = raw.strip_prefix(' ').unwrap_or(raw).trim();
if data == "[DONE]" {
return None;
}
Some(serde_json::from_str(data).map_err(|e| LiterLlmError::Streaming {
message: format!("failed to parse SSE data: {e}"),
}))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bytes_pool_reuses_buffer() {
let buf = pool_acquire();
let ptr_before = buf.as_ptr();
pool_release(buf);
let buf2 = pool_acquire();
let ptr_after = buf2.as_ptr();
assert_eq!(ptr_before, ptr_after, "pool should reuse the same BytesMut allocation");
pool_release(buf2);
}
#[test]
fn bytes_pool_discards_oversized_buffers() {
let mut big = BytesMut::with_capacity(MAX_POOL_BUFFER_CAPACITY + 1);
big.resize(MAX_POOL_BUFFER_CAPACITY + 1, 0u8);
pool_release(big);
let acquired = pool_acquire();
assert!(
acquired.capacity() <= 4096,
"oversized buffer should have been discarded; got capacity {}",
acquired.capacity()
);
pool_release(acquired);
}
#[test]
fn sse_parser_does_not_hold_idle_buffer_when_stream_idle() {
use std::pin::Pin;
use std::task::{Context, Poll};
use bytes::Bytes;
use futures_core::Stream;
struct NeverStream;
impl Stream for NeverStream {
type Item = std::result::Result<Bytes, reqwest::Error>;
fn poll_next(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
Poll::Pending
}
}
let sentinel = pool_acquire();
let sentinel_ptr = sentinel.as_ptr();
pool_release(sentinel);
let parser = SseParser::new(
NeverStream,
|_data: &str| -> Result<Option<crate::types::ChatCompletionChunk>> { Ok(None) },
None,
);
let reclaimed = pool_acquire();
assert_eq!(
reclaimed.as_ptr(),
sentinel_ptr,
"SseParser constructor must not acquire a BytesMut from the pool"
);
pool_release(reclaimed);
drop(parser);
let after_drop = pool_acquire();
assert_eq!(
after_drop.as_ptr(),
sentinel_ptr,
"SseParser Drop must not corrupt the pool slot"
);
pool_release(after_drop);
}
#[cfg(feature = "native-http")]
#[tokio::test]
async fn cancellation_aborts_upstream() {
use std::pin::Pin;
use std::task::{Context, Poll};
use bytes::Bytes;
use futures_core::Stream;
use crate::types::ChatCompletionChunk;
struct InfiniteStream;
impl Stream for InfiniteStream {
type Item = std::result::Result<Bytes, reqwest::Error>;
fn poll_next(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
Poll::Pending
}
}
let token = CancellationToken::new();
let token_clone = token.clone();
let mut parser: Pin<Box<SseParser<_, _, _>>> = Box::pin(SseParser::new(
InfiniteStream,
|_data: &str| -> Result<Option<ChatCompletionChunk>> { Ok(None) },
Some(token_clone),
));
token.cancel();
let result = futures_util::StreamExt::next(&mut parser).await;
assert!(result.is_none(), "cancelled stream should return None immediately");
}
#[cfg(test)]
fn json_parse(data: &str) -> Result<Option<ChatCompletionChunk>> {
serde_json::from_str(data)
.map(Some)
.map_err(|e| LiterLlmError::Streaming { message: e.to_string() })
}
#[tokio::test]
async fn should_reassemble_multibyte_char_split_across_chunks() {
use crate::types::chat::{StreamChoice, StreamDelta};
let content = "你好,世界 🌍 café";
let chunk = ChatCompletionChunk {
id: "test-id".to_string(),
object: "chat.completion.chunk".to_string(),
created: 0,
model: "test-model".to_string(),
choices: vec![StreamChoice {
index: 0,
delta: StreamDelta {
content: Some(content.to_string()),
..Default::default()
},
finish_reason: None,
}],
usage: None,
system_fingerprint: None,
service_tier: None,
};
let full = format!("data: {}\n\ndata: [DONE]\n\n", serde_json::to_string(&chunk).unwrap());
let bytes = full.into_bytes();
let split = (1..bytes.len())
.find(|&i| std::str::from_utf8(&bytes[..i]).is_err())
.expect("input contains multi-byte codepoints");
let inner = futures_util::stream::iter(vec![
Ok::<_, reqwest::Error>(Bytes::from(bytes[..split].to_vec())),
Ok::<_, reqwest::Error>(Bytes::from(bytes[split..].to_vec())),
]);
let mut parser: Pin<Box<SseParser<_, _, _>>> = Box::pin(SseParser::new(inner, json_parse, None));
let result = futures_util::StreamExt::next(&mut parser)
.await
.expect("should yield one chunk")
.expect("split codepoint must not abort the stream");
assert_eq!(result.choices[0].delta.content.as_deref(), Some(content));
assert!(futures_util::StreamExt::next(&mut parser).await.is_none());
}
#[tokio::test]
async fn should_error_on_genuinely_invalid_utf8() {
let inner = futures_util::stream::iter(vec![
Ok::<_, reqwest::Error>(Bytes::from_static(b"data: ")),
Ok::<_, reqwest::Error>(Bytes::from_static(&[0xFF])),
Ok::<_, reqwest::Error>(Bytes::from_static(b"\n\n")),
]);
let mut parser: Pin<Box<SseParser<_, _, _>>> = Box::pin(SseParser::new(inner, json_parse, None));
match futures_util::StreamExt::next(&mut parser).await {
Some(Err(LiterLlmError::Streaming { message })) => {
assert!(message.contains("invalid UTF-8"), "unexpected message: {message}");
}
other => panic!("expected a Streaming UTF-8 error, got {other:?}"),
}
}
#[tokio::test]
async fn truncated_stream_surfaces_as_error_not_clean_eof() {
let inner = futures_util::stream::iter(vec![Ok::<_, reqwest::Error>(Bytes::from_static(
br#"data: {"id":"trunc","choices":[{"delta":{"content":"partial"#,
))]);
let mut parser: Pin<Box<SseParser<_, _, _>>> = Box::pin(SseParser::new(inner, json_parse, None));
match futures_util::StreamExt::next(&mut parser).await {
Some(Err(LiterLlmError::Streaming { message })) => {
assert!(message.contains("truncated"), "unexpected message: {message}");
}
other => panic!("truncated stream must yield an error, not {other:?}"),
}
assert!(futures_util::StreamExt::next(&mut parser).await.is_none());
}
#[tokio::test]
async fn truncated_stream_mid_codepoint_surfaces_as_error() {
let content = "café"; let full = format!(r#"data: {{"text":"{content}"#);
let bytes = full.into_bytes();
let split = bytes.len() - 1; assert!(
std::str::from_utf8(&bytes[..split]).is_err(),
"split must land inside a codepoint"
);
let inner = futures_util::stream::iter(vec![Ok::<_, reqwest::Error>(Bytes::from(bytes[..split].to_vec()))]);
let mut parser: Pin<Box<SseParser<_, _, _>>> = Box::pin(SseParser::new(inner, json_parse, None));
match futures_util::StreamExt::next(&mut parser).await {
Some(Err(LiterLlmError::Streaming { message })) => {
assert!(message.contains("truncated"), "unexpected message: {message}");
}
other => panic!("mid-codepoint truncation must yield an error, not {other:?}"),
}
}
}