use std::time::Duration;
use crate::brain::provider::OpenAIProvider;
use crate::brain::provider::{
ContentBlock, ContentDelta, LLMRequest, Message, Provider, StreamEvent,
};
use crate::utils::retry::RetryConfig;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
use tokio::time::timeout;
async fn write_frame(sock: &mut tokio::net::TcpStream, data: &str) {
let payload = format!("data: {data}\n\n");
let head = format!("{:X}\r\n", payload.len());
sock.write_all(head.as_bytes()).await.expect("chunk head");
sock.write_all(payload.as_bytes())
.await
.expect("chunk body");
sock.write_all(b"\r\n").await.expect("chunk crlf");
sock.flush().await.ok();
}
async fn serve_slow_sse(listener: TcpListener, frames: Vec<String>, gap: Duration) {
let (mut sock, _) = listener.accept().await.expect("accept");
let mut buf = [0u8; 8192];
let _ = timeout(Duration::from_secs(5), sock.read(&mut buf)).await;
let head = "HTTP/1.1 200 OK\r\n\
Content-Type: text/event-stream\r\n\
Transfer-Encoding: chunked\r\n\r\n";
sock.write_all(head.as_bytes()).await.expect("head");
sock.flush().await.ok();
for frame in &frames {
write_frame(&mut sock, frame).await;
tokio::time::sleep(gap).await;
}
sock.write_all(b"0\r\n\r\n").await.expect("last chunk");
sock.flush().await.ok();
}
async fn serve_delayed_json(listener: TcpListener, body: String, delay: Duration) {
let (mut sock, _) = listener.accept().await.expect("accept");
let mut buf = [0u8; 8192];
let _ = timeout(Duration::from_secs(5), sock.read(&mut buf)).await;
tokio::time::sleep(delay).await;
let resp = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\
Connection: close\r\n\r\n{}",
body.len(),
body
);
sock.write_all(resp.as_bytes()).await.expect("body");
sock.flush().await.ok();
}
fn role_frame(id: &str) -> String {
format!(
r#"{{"id":"{id}","object":"chat.completion.chunk","model":"m","choices":[{{"index":0,"delta":{{"role":"assistant"}},"finish_reason":null}}]}}"#
)
}
fn content_frame(id: &str, text: &str) -> String {
format!(
r#"{{"id":"{id}","object":"chat.completion.chunk","model":"m","choices":[{{"index":0,"delta":{{"content":"{text}"}},"finish_reason":null}}]}}"#
)
}
fn terminal_frame(id: &str) -> String {
format!(
r#"{{"id":"{id}","object":"chat.completion.chunk","model":"m","choices":[{{"index":0,"delta":{{}},"finish_reason":"stop"}}]}}"#
)
}
async fn collect_stream_text(provider: &OpenAIProvider) -> Result<String, String> {
let req = LLMRequest::new("test-model", vec![Message::user("hello?")]);
let mut stream = provider.stream(req).await.map_err(|e| e.to_string())?;
let mut text = String::new();
while let Some(ev) = futures::StreamExt::next(&mut stream).await {
let ev = ev.map_err(|e| e.to_string())?;
match ev {
StreamEvent::ContentBlockDelta {
delta: ContentDelta::TextDelta { text: chunk },
..
} => text.push_str(&chunk),
StreamEvent::ContentBlockStart {
content_block: ContentBlock::Text { text: initial },
..
} => text.push_str(&initial),
StreamEvent::MessageStop => break,
_ => {}
}
}
Ok(text)
}
#[tokio::test]
async fn a_healthy_stream_outliving_the_request_timeout_is_not_cut() {
let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind");
let port = listener.local_addr().unwrap().port();
let id = "chatcmpl-1687";
let mut frames = vec![role_frame(id)];
for i in 0..12 {
frames.push(content_frame(id, &format!("frag{i} ")));
}
frames.push(terminal_frame(id));
frames.push("[DONE]".to_string());
tokio::spawn(serve_slow_sse(listener, frames, Duration::from_millis(120)));
let provider = OpenAIProvider::local(format!("http://127.0.0.1:{port}/chat/completions"))
.with_timeout(Duration::from_millis(250));
let text = timeout(Duration::from_secs(15), collect_stream_text(&provider))
.await
.expect("stream settles within 15s")
.unwrap_or_else(|err| panic!("stream was cut: {err}"));
for i in 0..12 {
assert!(
text.contains(&format!("frag{i}")),
"fragment {i} lost. assembled: {text:?}"
);
}
}
#[tokio::test]
async fn the_request_timeout_still_bounds_non_streaming_calls() {
let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind");
let port = listener.local_addr().unwrap().port();
let body = r#"{"id":"cmpl-1687","model":"m","choices":[{"index":0,"message":{"role":"assistant","content":"late"},"finish_reason":"stop"}],"usage":{}}"#.to_string();
tokio::spawn(serve_delayed_json(
listener,
body,
Duration::from_millis(900),
));
let provider = OpenAIProvider::local(format!("http://127.0.0.1:{port}/chat/completions"))
.with_timeout(Duration::from_millis(150))
.with_retry_config(RetryConfig {
max_attempts: 0,
..Default::default()
});
let req = LLMRequest::new("test-model", vec![Message::user("hello?")]);
let started = std::time::Instant::now();
let err = provider
.complete(req)
.await
.expect_err("a 900ms body must not survive a 150ms request timeout");
let elapsed = started.elapsed();
assert!(
elapsed < Duration::from_millis(600),
"complete() took {elapsed:?} to fail; the 150ms ceiling never fired (error: {err})"
);
}
#[tokio::test]
async fn a_non_streaming_call_inside_the_ceiling_still_succeeds() {
let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind");
let port = listener.local_addr().unwrap().port();
let body = r#"{"id":"cmpl-1687","model":"m","choices":[{"index":0,"message":{"role":"assistant","content":"on time"},"finish_reason":"stop"}],"usage":{}}"#.to_string();
tokio::spawn(serve_delayed_json(
listener,
body,
Duration::from_millis(30),
));
let provider = OpenAIProvider::local(format!("http://127.0.0.1:{port}/chat/completions"))
.with_timeout(Duration::from_secs(5));
let req = LLMRequest::new("test-model", vec![Message::user("hello?")]);
let resp = provider
.complete(req)
.await
.expect("a 30ms body fits inside a 5s ceiling");
let assembled: String = resp
.content
.iter()
.filter_map(|b| match b {
ContentBlock::Text { text } => Some(text.as_str()),
_ => None,
})
.collect();
assert_eq!(assembled, "on time", "body parsed: {resp:?}");
}