1use 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#[derive(Clone, Debug, Default, PartialEq)]
27pub struct ToolCall {
28 pub id: String,
29 pub name: String,
30 pub arguments: String,
32}
33
34#[derive(Clone, Debug, Default)]
36pub struct ChatOutcome {
37 pub text: String,
38 pub reasoning: String,
42 pub tool_calls: Vec<ToolCall>,
43 pub finish_reason: Option<String>,
44 pub ttft_ms: Option<f64>,
47 pub tpot_ms: Option<f64>,
52 pub e2e_ms: f64,
55 pub arrival_gaps: GapSample,
57 pub completion_tokens: usize,
61 pub prompt_tokens: usize,
62 pub cached_prompt_tokens: usize,
63 pub server_ttft_ms: Option<f64>,
65 pub server_tps: Option<f64>,
69 pub accepted_prediction_tokens: Option<usize>,
73 pub server_decode_time_ms: Option<f64>,
76 pub server_total_time_ms: Option<f64>,
78}
79
80impl ChatOutcome {
81 pub fn server_tpot_ms(&self) -> Option<f64> {
85 itl_ms(self.server_decode_time_ms?, self.completion_tokens)
86 }
87}
88
89pub 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
100pub 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 let mut last_arrival: Option<Instant> = None;
147 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 reader.finish()?;
157 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 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 let _ = signal.send(first);
208 }
209 carried = true;
210 }
211 }
212 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 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
239pub(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
258fn 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 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
346pub 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
355pub fn error_message_from_response(raw: &[u8]) -> Option<String> {
360 let mut r = Reader::default();
361 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
369pub(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
384pub(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;