metrale_bench/
http.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! 2026-09-26: A minimal OpenAI-compatible client over raw HTTP/1.1 on
4//! `tokio::net::TcpStream`, with no TLS: one streaming chat call, measured
5//! ([`chat_stream`]), plus the blocking, GET, gap and failure helpers in the
6//! submodules. Chunked transfer-encoding is decoded by the private `reader`
7//! module before SSE lines are split, so a chunk boundary inside a `data:`
8//! line does not lose a token.
9//!
10//! Owner: bench (HTTP client).
11//! Invariants:
12//! - A 200 stream that ends with neither `[DONE]` nor a `finish_reason` is an
13//!   error, never a success.
14//! - An in-band SSE error frame is an error, never a success.
15
16use std::time::{Duration, Instant};
17
18use anyhow::{Context, Result, anyhow, bail};
19use serde_json::Value;
20use tokio::io::{AsyncReadExt, AsyncWriteExt};
21use tokio::net::TcpStream;
22
23use crate::plugin::TargetEndpoint;
24
25/// 2026-09-26: A tool call assembled from streamed deltas.
26#[derive(Clone, Debug, Default, PartialEq)]
27pub struct ToolCall {
28    pub id: String,
29    pub name: String,
30    /// 2026-09-26: The streamed argument text, concatenated and not parsed.
31    pub arguments: String,
32}
33
34/// 2026-09-26: Everything one request produced, plus its timings.
35#[derive(Clone, Debug, Default)]
36pub struct ChatOutcome {
37    pub text: String,
38    /// 2026-09-26: The streamed `reasoning_content`, kept apart from `text`.
39    /// Each non-empty reasoning delta still counts toward `completion_tokens`
40    /// and can start the TTFT clock.
41    pub reasoning: String,
42    pub tool_calls: Vec<ToolCall>,
43    pub finish_reason: Option<String>,
44    /// 2026-09-26: Client clock: request write → first chunk that carried a
45    /// reasoning, content or tool-call delta.
46    pub ttft_ms: Option<f64>,
47    /// 2026-09-26: Client-clock inter-token latency, called TPOT here: first
48    /// token-carrying chunk → end of stream (`[DONE]` or EOF), over
49    /// `completion_tokens − 1` ([`itl_ms`]). The window ends at the final
50    /// chunk, not the last token. `None` below two tokens.
51    pub tpot_ms: Option<f64>,
52    /// 2026-09-26: Client clock: request write → end of stream, the same end
53    /// instant as `tpot_ms`.
54    pub e2e_ms: f64,
55    /// 2026-09-26: Gaps between socket reads that carried a token ([`gaps`]).
56    pub arrival_gaps: GapSample,
57    /// 2026-09-26: One per token-carrying reasoning or content delta; a
58    /// `usage.completion_tokens` replaces the running count, and later deltas
59    /// add to it.
60    pub completion_tokens: usize,
61    pub prompt_tokens: usize,
62    pub cached_prompt_tokens: usize,
63    /// 2026-09-26: `usage.time_to_first_token_ms`, on the server's clock.
64    pub server_ttft_ms: Option<f64>,
65    /// 2026-09-26: `usage."response_token/s"`, which this engine's server
66    /// computes as `(completion_tokens − 1) / decode seconds`
67    /// (`ir::Usage::decode_rate_tok_s` in the server crate).
68    pub server_tps: Option<f64>,
69    /// 2026-09-26: Accepted speculative draft tokens
70    /// (`usage.completion_tokens_details.accepted_prediction_tokens`). `None`
71    /// when the field is absent, so it differs from `Some(0)`.
72    pub accepted_prediction_tokens: Option<usize>,
73    /// 2026-09-26: `usage.decode_time_ms`, the numerator of
74    /// [`Self::server_tpot_ms`]. `None` when absent.
75    pub server_decode_time_ms: Option<f64>,
76    /// 2026-09-26: `usage.total_time_ms`. `None` when absent.
77    pub server_total_time_ms: Option<f64>,
78}
79
80impl ChatOutcome {
81    /// 2026-09-26: Server-clock inter-token latency: `decode_time_ms /
82    /// (completion_tokens − 1)`, the same [`itl_ms`] rule as
83    /// [`Self::tpot_ms`]. `None` without the window or below two tokens.
84    pub fn server_tpot_ms(&self) -> Option<f64> {
85        itl_ms(self.server_decode_time_ms?, self.completion_tokens)
86    }
87}
88
89/// 2026-09-26: POST `body` to `/v1/chat/completions` as an SSE request and
90/// measure it. A request that exceeds `timeout` fails as
91/// `FailureKind::Timeout`.
92pub async fn chat_stream(
93    target: &TargetEndpoint,
94    body: &Value,
95    timeout: Duration,
96) -> Result<ChatOutcome> {
97    chat_stream_signalled(target, body, timeout, None).await
98}
99
100/// 2026-10-04: [`chat_stream`], plus a signal: `first_token`, when given, receives the
101/// instant the first token-carrying chunk arrived (the instant `ttft_ms` is measured to)
102/// while the stream is still running. A request that ends before any token drops the
103/// sender, so the receiver sees an error instead of waiting.
104pub async fn chat_stream_signalled(
105    target: &TargetEndpoint,
106    body: &Value,
107    timeout: Duration,
108    first_token: Option<tokio::sync::oneshot::Sender<Instant>>,
109) -> Result<ChatOutcome> {
110    tokio::time::timeout(timeout, chat_stream_inner(target, body, first_token))
111        .await
112        .map_err(|_| {
113            RequestFailure::new(
114                FailureKind::Timeout,
115                format!("request exceeded {:.0}s", timeout.as_secs_f64()),
116            )
117        })?
118}
119
120async fn chat_stream_inner(
121    target: &TargetEndpoint,
122    body: &Value,
123    mut first_token: Option<tokio::sync::oneshot::Sender<Instant>>,
124) -> Result<ChatOutcome> {
125    let (host, port) = target.host_port()?;
126    let payload = serde_json::to_string(body)?;
127    let mut sock = TcpStream::connect((host.as_str(), port))
128        .await
129        .with_context(|| format!("connecting to {}", target.base_url))?;
130    let _ = sock.set_nodelay(true);
131    let request = format!(
132        "POST /v1/chat/completions HTTP/1.1\r\nHost: {host}:{port}\r\n\
133         Content-Type: application/json\r\nAccept: text/event-stream\r\n\
134         Connection: close\r\nContent-Length: {}\r\n\r\n{payload}",
135        payload.len()
136    );
137
138    let started = Instant::now();
139    sock.write_all(request.as_bytes()).await.context("write")?;
140
141    let mut reader = Reader::default();
142    let mut out = ChatOutcome::default();
143    let mut first_delta: Option<Instant> = None;
144    // 2026-09-26: The previous socket read that carried a token; gaps are per
145    // read, not per delta.
146    let mut last_arrival: Option<Instant> = None;
147    // 2026-09-26: The first `data:` frame that was not JSON, kept so a stream
148    // that then ends without a terminal frame reports as malformed.
149    let mut undecodable: Option<String> = None;
150    let mut buf = [0u8; 16 * 1024];
151    loop {
152        let n = sock.read(&mut buf).await.context("read")?;
153        if n == 0 {
154            // 2026-09-26: EOF. A non-200 response still being collected is
155            // reported now with whatever body arrived.
156            reader.finish()?;
157            // 2026-09-26: A 200 stream that ends with neither `[DONE]` nor a
158            // `finish_reason` was cut off: `Truncated`, or `Malformed` when a
159            // `data:` frame did not parse.
160            if out.finish_reason.is_none() {
161                let message = format!(
162                    "stream ended without a terminal frame (no [DONE], no finish_reason) after \
163                     {} token(s): truncated, not completed",
164                    out.completion_tokens
165                );
166                return Err(match &undecodable {
167                    Some(frame) => RequestFailure::new(FailureKind::Malformed, message)
168                        .with_body(frame.as_bytes()),
169                    None => RequestFailure::new(FailureKind::Truncated, message),
170                }
171                .into());
172            }
173            break;
174        }
175        let arrived = Instant::now();
176        let mut carried = false;
177        let mut done = false;
178        for line in reader.push(&buf[..n])? {
179            let Some(data) = line.strip_prefix("data:") else {
180                continue;
181            };
182            let data = data.trim();
183            if data == "[DONE]" {
184                done = true;
185                break;
186            }
187            let Ok(chunk) = serde_json::from_str::<Value>(data) else {
188                undecodable.get_or_insert_with(|| data.to_string());
189                continue;
190            };
191            // 2026-09-26: An in-band error frame fails the request even though
192            // the status was 200.
193            if let Some(msg) = stream_error(&chunk) {
194                return Err(RequestFailure::new(
195                    FailureKind::ServerError,
196                    format!("server reported an error mid-stream: {msg}"),
197                )
198                .with_body(data.as_bytes())
199                .with_finish_reason(out.finish_reason.clone())
200                .into());
201            }
202            if apply_chunk(&chunk, &mut out) {
203                let first = *first_delta.get_or_insert_with(Instant::now);
204                if let Some(signal) = first_token.take() {
205                    // 2026-10-04: A receiver that is gone no longer wants the signal; the
206                    // stream itself is unaffected.
207                    let _ = signal.send(first);
208                }
209                carried = true;
210            }
211        }
212        // 2026-09-26: Book the arrival before honouring `[DONE]`, which can
213        // share a read with the last token.
214        if carried {
215            if let Some(prev) = last_arrival {
216                out.arrival_gaps
217                    .push(arrived.duration_since(prev).as_secs_f64() * 1000.0);
218            }
219            last_arrival = Some(arrived);
220        }
221        if done {
222            break;
223        }
224    }
225    // 2026-09-26: One end instant for `e2e_ms` and the ITL window, so
226    // `(e2e − ttft) / (n − 1)` equals `tpot_ms`.
227    let ended = Instant::now();
228    out.e2e_ms = ended.duration_since(started).as_secs_f64() * 1000.0;
229    out.ttft_ms = first_delta.map(|t| t.duration_since(started).as_secs_f64() * 1000.0);
230    out.tpot_ms = first_delta.and_then(|f| {
231        itl_ms(
232            ended.duration_since(f).as_secs_f64() * 1000.0,
233            out.completion_tokens,
234        )
235    });
236    Ok(out)
237}
238
239/// 2026-09-26: The message of an in-band SSE error frame, if `chunk` is one:
240/// a non-null `error` that is a string, an object (its `message`, else the
241/// whole object) or any other value. The server's frame shapes are pinned in
242/// its `api/tests/error_frames.rs`.
243pub(crate) fn stream_error(chunk: &Value) -> Option<String> {
244    let err = chunk.get("error")?;
245    if err.is_null() {
246        return None;
247    }
248    Some(match err {
249        Value::String(m) => m.clone(),
250        Value::Object(o) => o
251            .get("message")
252            .and_then(Value::as_str)
253            .map_or_else(|| err.to_string(), str::to_string),
254        other => other.to_string(),
255    })
256}
257
258/// 2026-09-26: Fold one SSE chunk into the outcome. Returns true when it
259/// carried a non-empty reasoning or content delta, or any tool-call delta.
260fn apply_chunk(chunk: &Value, out: &mut ChatOutcome) -> bool {
261    if let Some(usage) = chunk.get("usage").filter(|u| u.is_object()) {
262        if let Some(c) = usage.get("completion_tokens").and_then(Value::as_u64) {
263            out.completion_tokens = c as usize;
264        }
265        if let Some(p) = usage.get("prompt_tokens").and_then(Value::as_u64) {
266            out.prompt_tokens = p as usize;
267        }
268        if let Some(c) = usage
269            .get("prompt_tokens_details")
270            .and_then(|d| d.get("cached_tokens"))
271            .and_then(Value::as_u64)
272        {
273            out.cached_prompt_tokens = c as usize;
274        }
275        if let Some(v) = usage.get("time_to_first_token_ms").and_then(Value::as_f64) {
276            out.server_ttft_ms = Some(v);
277        }
278        if let Some(v) = usage.get("response_token/s").and_then(Value::as_f64) {
279            out.server_tps = Some(v);
280        }
281        if let Some(v) = usage.get("decode_time_ms").and_then(Value::as_f64) {
282            out.server_decode_time_ms = Some(v);
283        }
284        if let Some(v) = usage.get("total_time_ms").and_then(Value::as_f64) {
285            out.server_total_time_ms = Some(v);
286        }
287        if let Some(v) = usage
288            .get("completion_tokens_details")
289            .and_then(|d| d.get("accepted_prediction_tokens"))
290            .and_then(Value::as_u64)
291        {
292            out.accepted_prediction_tokens = Some(v as usize);
293        }
294    }
295    let Some(choice) = chunk.get("choices").and_then(|c| c.get(0)) else {
296        return false;
297    };
298    if let Some(r) = choice.get("finish_reason").and_then(Value::as_str) {
299        out.finish_reason = Some(r.to_string());
300    }
301    let Some(delta) = choice.get("delta") else {
302        return false;
303    };
304    let mut carried = false;
305    // 2026-09-26: A reasoning delta is a token: it counts and it can start the
306    // TTFT clock, or a thinking model's TTFT would include its whole
307    // reasoning block.
308    if let Some(reasoning) = delta.get("reasoning_content").and_then(Value::as_str)
309        && !reasoning.is_empty()
310    {
311        out.reasoning.push_str(reasoning);
312        out.completion_tokens += 1;
313        carried = true;
314    }
315    if let Some(content) = delta.get("content").and_then(Value::as_str)
316        && !content.is_empty()
317    {
318        out.text.push_str(content);
319        out.completion_tokens += 1;
320        carried = true;
321    }
322    if let Some(calls) = delta.get("tool_calls").and_then(Value::as_array) {
323        for call in calls {
324            let idx = call.get("index").and_then(Value::as_u64).unwrap_or(0) as usize;
325            if out.tool_calls.len() <= idx {
326                out.tool_calls.resize(idx + 1, ToolCall::default());
327            }
328            let slot = &mut out.tool_calls[idx];
329            if let Some(id) = call.get("id").and_then(Value::as_str) {
330                slot.id.push_str(id);
331            }
332            if let Some(f) = call.get("function") {
333                if let Some(name) = f.get("name").and_then(Value::as_str) {
334                    slot.name.push_str(name);
335                }
336                if let Some(args) = f.get("arguments").and_then(Value::as_str) {
337                    slot.arguments.push_str(args);
338                }
339            }
340            carried = true;
341        }
342    }
343    carried
344}
345
346/// 2026-09-26: The trimmed `error.message` of an OpenAI-shaped error body.
347/// `None` when the body is not JSON with a non-empty string there, so callers
348/// can fall back to the status line.
349pub fn message_from_body(body: &str) -> Option<String> {
350    let v: serde_json::Value = serde_json::from_str(body).ok()?;
351    let msg = v.get("error")?.get("message")?.as_str()?.trim();
352    (!msg.is_empty()).then(|| msg.to_string())
353}
354
355/// 2026-09-26: The message from a complete non-200 HTTP response (status
356/// line, headers and body), decoded by the same private reader as streams,
357/// chunked bodies included. `None` unless the response is a non-200 whose
358/// body [`message_from_body`] can read.
359pub fn error_message_from_response(raw: &[u8]) -> Option<String> {
360    let mut r = Reader::default();
361    // 2026-09-26: Both return the error being reported; only the decoded body
362    // is needed.
363    let _ = r.push(raw);
364    let _ = r.finish();
365    r.error_status.as_ref()?;
366    message_from_body(String::from_utf8_lossy(&r.body).trim())
367}
368
369/// 2026-09-26: Cap on how much of a non-200 body to buffer before reporting
370/// it.
371pub(super) const MAX_ERROR_BODY: usize = 64 * 1024;
372
373pub(super) fn is_chunked(head: &str) -> bool {
374    head.lines().any(|line| {
375        let line = line.to_ascii_lowercase();
376        line.starts_with("transfer-encoding:") && line.contains("chunked")
377    })
378}
379
380pub(super) fn status_is_success(status_line: &str) -> bool {
381    status_line.split_whitespace().nth(1) == Some("200")
382}
383
384/// 2026-09-26: `Content-Length` from a header block, if declared and
385/// parseable.
386pub(super) fn content_length(head: &str) -> Option<usize> {
387    head.lines()
388        .find(|l| l.to_ascii_lowercase().starts_with("content-length:"))
389        .and_then(|l| l.split_once(':'))
390        .and_then(|(_, v)| v.trim().parse().ok())
391}
392
393mod reader;
394use reader::Reader;
395
396mod blocking;
397pub use blocking::{BlockingOutcome, chat_blocking, responses_blocking};
398
399mod get;
400pub use get::{fetch_hardware, get_json, get_text, list_models, probe};
401
402pub mod gaps;
403pub use gaps::{GapSample, GapStats, itl_ms};
404
405pub mod failure;
406pub use failure::{FailureKind, RequestFailure, classify};
407
408pub(super) fn find(hay: &[u8], needle: &[u8]) -> Option<usize> {
409    hay.windows(needle.len()).position(|w| w == needle)
410}
411
412#[cfg(test)]
413#[path = "http_tests.rs"]
414mod tests;