Add Client::chat with the first-byte wait, liveness and error mapping

Implemented-By: OpenCode session (model recorded in docs/implementer-log.md)
This commit is contained in:
2026-09-18 13:37:01 -07:00
parent 3663a1e4d5
commit 60557a0634
4 changed files with 447 additions and 0 deletions
+151
View File
@@ -0,0 +1,151 @@
//! One chat request: the wait for the first byte, the liveness of the stream, and the errors.
//!
//! There is no total deadline anywhere. The only question while waiting for the head is whether
//! bytes are still arriving; silence before the first byte means the slot is busy or the server is
//! loading, and only `slots` can say which. Once bytes flow, the only question is whether they keep
//! arriving, measured by the read timeout, never a wall-clock total.
use std::time::Duration;
use super::assemble::Assembler;
use super::{ChatEvent, ChatRequest, Client, Completion, InferError};
use crate::http::{Connection, Head, HttpError, Request, read_capped};
use crate::llama::info::{MAX_BODY, error_text, map_http};
use crate::llama::request::build_body;
use crate::sse::{Events, SseError, SseItem};
pub const MAX_SSE_LINE: usize = 1024 * 1024;
const CHAT: &str = "/v1/chat/completions";
/// What the wait for the head is doing when it times out.
#[derive(Clone, Copy, PartialEq)]
enum WaitState {
/// The head has started to arrive; no poll is needed.
Idle,
/// The request's own slot is processing.
Busy,
/// The server could not answer `/slots`, so it is loading or restarting.
Unavailable,
}
impl Client {
pub fn chat(
&self,
req: &ChatRequest,
on_event: &mut dyn FnMut(&ChatEvent),
) -> Result<Completion, InferError> {
// 1. Send.
let body_text =
build_body(&self.cfg, req).map_err(|e| InferError::Protocol(e.to_string()))?;
let mut conn = Connection::open(&self.cfg.infer.socket).map_err(map_http)?;
conn.send(&Request {
method: "POST",
path: CHAT,
body: Some(body_text.as_bytes()),
})
.map_err(map_http)?;
// 2. Wait for the head.
let head = wait_for_head(&mut conn, self, req, on_event)?;
// 3. Status.
conn.set_read_timeout(Duration::from_millis(self.cfg.limits.liveness_ms))
.map_err(map_http)?;
if head.status != 200 {
let mut body = conn.body(&head).map_err(map_http)?;
let bytes = read_capped(&mut body, MAX_BODY).map_err(map_http)?;
return Err(InferError::Http {
status: head.status,
body: error_text(&bytes),
});
}
// 4. Stream.
let mut body = conn.body(&head).map_err(map_http)?;
let mut events = Events::new(&mut body, MAX_SSE_LINE);
let mut assembler = Assembler::new();
loop {
match events.next_item() {
Ok(Some(SseItem::Data(text))) => {
for event in assembler.push(&text)? {
on_event(&event);
}
}
Ok(Some(SseItem::Done)) | Ok(None) => break,
Err(SseError::Timeout) => return Err(InferError::Stalled),
Err(SseError::Truncated) => return Err(InferError::StreamClosedEarly),
Err(e) => return Err(InferError::Protocol(e.to_string())),
}
}
// 5. Finish. A stream that ended without a finish_reason is a server that died between two
// events.
assembler.finish(false)
}
}
/// Reads the head of the chat response, waiting out a busy, unavailable or idle slot as it goes.
fn wait_for_head(
conn: &mut Connection,
client: &Client,
req: &ChatRequest,
on_event: &mut dyn FnMut(&ChatEvent),
) -> Result<Head, InferError> {
let poll = Duration::from_millis(client.cfg.limits.poll_ms);
conn.set_read_timeout(poll).map_err(map_http)?;
let mut prev: Option<WaitState> = None;
let mut since: Duration = Duration::ZERO;
loop {
match conn.read_head() {
Ok(head) => return Ok(head),
Err(HttpError::Timeout) => {}
Err(e) => return Err(map_http(e)),
}
let received = conn.received_any();
let (state, busy) = if received {
(WaitState::Idle, false)
} else {
match client.slots() {
Ok(slots) => {
let busy = slots
.iter()
.find(|s| s.id == req.slot)
.map(|s| s.is_processing)
.unwrap_or(false);
(
if busy {
WaitState::Busy
} else {
WaitState::Idle
},
busy,
)
}
Err(_) => (WaitState::Unavailable, false),
}
};
// A poll was made exactly when the head had not started to arrive.
if !received {
on_event(&ChatEvent::Waiting { slot_busy: busy });
}
since = if prev == Some(state) {
since + poll
} else {
poll
};
prev = Some(state);
let wait_for = match state {
WaitState::Busy => client.cfg.limits.busy_wait_ms,
WaitState::Unavailable => client.cfg.limits.load_wait_ms,
WaitState::Idle => client.cfg.limits.idle_grace_ms,
};
if since >= Duration::from_millis(wait_for) {
return Err(match state {
WaitState::Busy => InferError::WaitTimeout,
WaitState::Unavailable => InferError::LoadTimeout,
WaitState::Idle => InferError::Stalled,
});
}
}
}
+1
View File
@@ -1,6 +1,7 @@
//! The llama inference server's chat-completions types and the request builder.
pub mod assemble;
pub mod chat;
pub mod info;
pub mod request;
+294
View File
@@ -0,0 +1,294 @@
//! Tests for one request: events, waits, liveness and errors. Do not edit.
//!
//! Limits come from `support::test_config`: poll 40 ms, busy wait 400 ms, load wait 300 ms,
//! idle grace 200 ms, liveness 150 ms.
mod support;
use loopd::llama::{ChatEvent, ChatMessage, ChatRequest, Client, FinishReason, InferError};
use std::time::{Duration, Instant};
use support::{FakeServer, Reply};
const CHAT: &str = "/v1/chat/completions";
const IDLE: &str = r#"[{"id":0,"is_processing":false},{"id":1,"is_processing":false}]"#;
const SLOT0_BUSY: &str = r#"[{"id":0,"is_processing":true},{"id":1,"is_processing":false}]"#;
const SLOT1_BUSY: &str = r#"[{"id":0,"is_processing":false},{"id":1,"is_processing":true}]"#;
fn request() -> ChatRequest {
ChatRequest {
slot: 0,
messages: vec![ChatMessage::User {
content: "hi".to_string(),
}],
tools: vec![],
thinking: false,
}
}
fn run(
server: &FakeServer,
) -> (
Result<loopd::llama::Completion, InferError>,
Vec<ChatEvent>,
Duration,
) {
let client = Client::new(support::test_config(&server.socket));
let mut events = Vec::new();
let started = Instant::now();
let result = client.chat(&request(), &mut |e| events.push(e.clone()));
(result, events, started.elapsed())
}
fn waiting(events: &[ChatEvent]) -> Vec<bool> {
events
.iter()
.filter_map(|e| match e {
ChatEvent::Waiting { slot_busy } => Some(*slot_busy),
_ => None,
})
.collect()
}
#[test]
fn a_recorded_completion_comes_back_whole() {
let server = FakeServer::start();
server.route(CHAT, vec![Reply::fixture("tool_call")]);
let (result, events, _) = run(&server);
let done = result.unwrap();
let want = support::expected("tool_call");
assert_eq!(done.finish_reason, FinishReason::ToolCalls);
assert_eq!(done.content.as_deref(), want["content"].as_str());
assert_eq!(done.tool_calls[0].arguments, r#"{"path":"/etc/hostname"}"#);
assert_eq!(done.timings.prompt_n, 312);
assert!(!done.thinking_capped);
assert!(
waiting(&events).is_empty(),
"the answer came at once, so nothing was polled"
);
assert!(matches!(
events.first(),
Some(ChatEvent::Progress { total: 312, .. })
));
let sent = &server.requests_to(CHAT)[0];
assert_eq!(sent.method, "POST");
assert_eq!(sent.json()["messages"][0]["content"], "hi");
assert_eq!(sent.json()["id_slot"], 0);
}
#[test]
fn the_result_does_not_depend_on_how_the_bytes_arrive() {
for piece in [1, 17, 4096] {
let server = FakeServer::start();
server.route(CHAT, vec![Reply::fixture("thinking").trickle(piece, 0)]);
let done = run(&server).0.unwrap();
let want = support::expected("thinking");
assert_eq!(
done.content.as_deref(),
want["content"].as_str(),
"pieces of {piece}"
);
assert_eq!(
done.reasoning_content.as_deref(),
want["reasoning_content"].as_str()
);
assert_eq!(done.reasoning_tokens, 49);
}
}
#[test]
fn a_busy_slot_is_waited_out() {
let server = FakeServer::start();
server.route(CHAT, vec![Reply::fixture("plain").head_delay(250)]);
server.route("/slots", vec![Reply::json(200, SLOT0_BUSY)]);
let (result, events, took) = run(&server);
assert!(result.is_ok(), "{result:?}");
let polls = waiting(&events);
assert!(polls.len() >= 4, "250 ms at 40 ms per poll: {polls:?}");
assert!(polls.iter().all(|busy| *busy));
assert!(took >= Duration::from_millis(250));
assert!(server.requests_to("/slots").len() >= 4);
assert_eq!(
server.requests_to("/slots")[0].target,
"/slots?model=test-model"
);
}
#[test]
fn a_slot_that_stays_busy_is_a_wait_timeout() {
let server = FakeServer::start();
server.route(CHAT, vec![Reply::fixture("plain").head_delay(5_000)]);
server.route("/slots", vec![Reply::json(200, SLOT0_BUSY)]);
let (result, events, took) = run(&server);
assert!(matches!(result, Err(InferError::WaitTimeout)), "{result:?}");
assert!(
took >= Duration::from_millis(400) && took < Duration::from_millis(1500),
"{took:?}"
);
assert!(waiting(&events).iter().all(|busy| *busy));
}
#[test]
fn only_the_requests_own_slot_counts_as_busy() {
// Slot 1 is busy, slot 0 (ours) is idle and silent: that is a stall, not a queue.
let server = FakeServer::start();
server.route(CHAT, vec![Reply::fixture("plain").head_delay(5_000)]);
server.route("/slots", vec![Reply::json(200, SLOT1_BUSY)]);
let (result, events, took) = run(&server);
assert!(matches!(result, Err(InferError::Stalled)), "{result:?}");
assert!(
took >= Duration::from_millis(200) && took < Duration::from_millis(1000),
"{took:?}"
);
assert!(waiting(&events).iter().all(|busy| !*busy));
}
#[test]
fn an_unavailable_server_is_a_load_timeout() {
for slots_reply in [
Reply::json(503, r#"{"error":"loading"}"#),
Reply::raw("").cut_after(0),
] {
let server = FakeServer::start();
server.route(CHAT, vec![Reply::fixture("plain").head_delay(5_000)]);
server.route("/slots", vec![slots_reply]);
let (result, _, took) = run(&server);
assert!(matches!(result, Err(InferError::LoadTimeout)), "{result:?}");
assert!(
took >= Duration::from_millis(300) && took < Duration::from_millis(1500),
"{took:?}"
);
}
}
#[test]
fn the_wait_clocks_restart_when_the_state_changes() {
// Busy for about 5 polls, then idle: the idle grace starts counting from there, so the
// request outlives 200 ms of busy plus most of the 200 ms grace and then completes.
let server = FakeServer::start();
server.route(CHAT, vec![Reply::fixture("plain").head_delay(330)]);
let busy = Reply::json(200, SLOT0_BUSY);
server.route(
"/slots",
vec![
busy.clone(),
busy.clone(),
busy.clone(),
busy.clone(),
busy,
Reply::json(200, IDLE),
],
);
let (result, events, _) = run(&server);
assert!(result.is_ok(), "{result:?}");
let polls = waiting(&events);
assert!(
polls.starts_with(&[true, true, true, true, true]),
"{polls:?}"
);
assert_eq!(polls.last(), Some(&false), "{polls:?}");
}
#[test]
fn silence_in_mid_stream_is_a_stall() {
let server = FakeServer::start();
let reply = Reply::fixture("thinking");
let cut = reply.offset_after_events(10);
server.route(CHAT, vec![reply.hang_after(cut, 5_000)]);
let (result, events, took) = run(&server);
assert!(matches!(result, Err(InferError::Stalled)), "{result:?}");
assert!(
took >= Duration::from_millis(150) && took < Duration::from_millis(1000),
"{took:?}"
);
assert!(
events.iter().any(|e| matches!(e, ChatEvent::Reasoning(_))),
"events before the stall were delivered"
);
}
#[test]
fn a_slow_but_steady_stream_is_not_a_stall() {
// 60 ms between pieces is well inside the 150 ms liveness limit, however long the whole takes.
let server = FakeServer::start();
let size = support::fixture_bytes("http", "turn1.http").len();
server.route(CHAT, vec![Reply::fixture("turn1").trickle(size / 8, 60)]);
let (result, _, took) = run(&server);
assert_eq!(result.unwrap().content.as_deref(), Some("Blue"));
assert!(
took >= Duration::from_millis(400),
"the stream took longer than the liveness limit: {took:?}"
);
}
#[test]
fn a_stream_that_is_cut_is_closed_early_wherever_the_cut_falls() {
let reply = Reply::fixture("plain");
let whole = support::fixture_bytes("http", "plain.http").len();
let after_five = reply.offset_after_events(5);
// After the head only; in the middle of an event; between events; just before [DONE].
for cut in [300, after_five + 20, after_five, whole - 30] {
let server = FakeServer::start();
server.route(CHAT, vec![reply.clone().cut_after(cut)]);
let (result, _, _) = run(&server);
assert!(
matches!(result, Err(InferError::StreamClosedEarly)),
"cut at {cut}: {result:?}"
);
}
// Cut before the head is complete.
let server = FakeServer::start();
server.route(CHAT, vec![reply.cut_after(40)]);
assert!(matches!(run(&server).0, Err(InferError::StreamClosedEarly)));
}
#[test]
fn error_statuses_keep_their_body() {
let server = FakeServer::start();
server.route(CHAT, vec![Reply::fixture("bad_request")]);
match run(&server).0 {
Err(InferError::Http { status: 400, body }) => {
assert!(body.contains("Expected 'messages'"), "{body}")
}
other => panic!("{other:?}"),
}
let server = FakeServer::start();
server.route(
CHAT,
vec![Reply::json(503, r#"{"error":{"message":"Loading model"}}"#)],
);
assert!(matches!(
run(&server).0,
Err(InferError::Http { status: 503, .. })
));
}
#[test]
fn garbage_in_the_stream_is_a_protocol_error() {
let server = FakeServer::start();
let raw = "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\n\r\ndata: {not json}\n\n";
server.route(CHAT, vec![Reply::raw(raw)]);
assert!(matches!(run(&server).0, Err(InferError::Protocol(_))));
let server = FakeServer::start();
let huge = format!(
"HTTP/1.1 200 OK\r\n\r\ndata: {}\n\n",
"x".repeat(2 * 1024 * 1024)
);
server.route(CHAT, vec![Reply::raw(huge)]);
assert!(
matches!(run(&server).0, Err(InferError::Protocol(_))),
"a line over 1 MiB"
);
}
#[test]
fn no_server_is_a_connect_error() {
let server = FakeServer::start();
let mut cfg = support::test_config(&server.socket);
cfg.infer.socket = std::env::temp_dir().join("loopd-no-such-socket.sock");
let result = Client::new(cfg).chat(&request(), &mut |_| {});
assert!(matches!(result, Err(InferError::Connect(_))), "{result:?}");
let e: Box<dyn std::error::Error> = Box::new(InferError::Stalled);
assert!(!e.to_string().is_empty());
}