1use std::time::{Duration, Instant};
4
5use futures_util::StreamExt;
6use serde::Serialize;
7use serde_json::{Value, json};
8
9#[derive(Debug, Clone, Serialize)]
11pub struct Exchange {
12 pub request: Value,
13 pub status: u16,
14 #[serde(skip)]
16 pub raw: Vec<u8>,
17 pub response: Value,
20 pub stream: Option<StreamInfo>,
21 pub latency_ms: u64,
22 pub ttft_ms: Option<u64>,
24}
25
26#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
27pub struct StreamInfo {
28 pub chunks: usize,
29 pub done: bool,
31 pub bad_lines: usize,
33}
34
35#[derive(Clone)]
36pub struct Client {
37 http: reqwest::Client,
38 base: String,
39 api_key: Option<String>,
40}
41
42impl Client {
43 pub fn new(base: &str, timeout: Duration, api_key: Option<String>) -> Self {
44 Client {
45 http: reqwest::Client::builder()
46 .timeout(timeout)
47 .build()
48 .expect("default client"),
49 base: base
50 .trim_end_matches('/')
51 .trim_end_matches("/v1")
52 .to_owned(),
53 api_key,
54 }
55 }
56
57 pub fn base(&self) -> &str {
58 &self.base
59 }
60
61 pub async fn get(&self, path: &str) -> Option<(u16, reqwest::header::HeaderMap, Value)> {
62 let mut req = self.http.get(format!("{}{path}", self.base));
63 if let Some(k) = &self.api_key {
64 req = req.bearer_auth(k);
65 }
66 let r = req.timeout(Duration::from_secs(5)).send().await.ok()?;
67 let status = r.status().as_u16();
68 let headers = r.headers().clone();
69 let body = r.json().await.unwrap_or(Value::Null);
70 Some((status, headers, body))
71 }
72
73 pub async fn post(&self, path: &str, body: Value) -> (u16, Value) {
75 let mut req = self.http.post(format!("{}{path}", self.base)).json(&body);
76 if let Some(k) = &self.api_key {
77 req = req.bearer_auth(k);
78 }
79 match req.send().await {
80 Ok(r) => {
81 let status = r.status().as_u16();
82 (status, r.json().await.unwrap_or(Value::Null))
83 }
84 Err(e) => (0, json!({ "error": { "message": e.to_string() } })),
85 }
86 }
87
88 pub async fn chat(&self, body: Value) -> Exchange {
90 let stream = body["stream"].as_bool().unwrap_or(false);
91 let start = Instant::now();
92 let mut req = self
93 .http
94 .post(format!("{}/v1/chat/completions", self.base))
95 .json(&body);
96 if let Some(k) = &self.api_key {
97 req = req.bearer_auth(k);
98 }
99 let resp = match req.send().await {
100 Ok(r) => r,
101 Err(e) => {
102 return Exchange {
103 request: body,
104 status: 0,
105 raw: Vec::new(),
106 response: json!({ "error": { "message": format!("request failed: {e}") } }),
107 stream: None,
108 latency_ms: start.elapsed().as_millis() as u64,
109 ttft_ms: None,
110 };
111 }
112 };
113 let status = resp.status().as_u16();
114 let sse = resp
115 .headers()
116 .get(reqwest::header::CONTENT_TYPE)
117 .and_then(|v| v.to_str().ok())
118 .is_some_and(|v| v.starts_with("text/event-stream"));
119 let mut raw = Vec::new();
120 let mut ttft = None;
121 let mut bytes = resp.bytes_stream();
122 while let Some(chunk) = bytes.next().await {
123 match chunk {
124 Ok(c) => {
125 if ttft.is_none() {
126 ttft = Some(start.elapsed().as_millis() as u64);
127 }
128 raw.extend_from_slice(&c);
129 }
130 Err(_) => break, }
132 }
133 let latency_ms = start.elapsed().as_millis() as u64;
134 let (response, info) = if stream && sse {
135 let (r, i) = reassemble(&raw);
136 (r, Some(i))
137 } else {
138 (
139 serde_json::from_slice(&raw)
140 .unwrap_or_else(|_| json!({ "raw": String::from_utf8_lossy(&raw) })),
141 None,
142 )
143 };
144 Exchange {
145 request: body,
146 status,
147 raw,
148 response,
149 stream: info,
150 latency_ms,
151 ttft_ms: if stream { ttft } else { None },
152 }
153 }
154}
155
156pub fn reassemble(raw: &[u8]) -> (Value, StreamInfo) {
161 let text = String::from_utf8_lossy(raw);
162 let mut info = StreamInfo::default();
163 let mut content = String::new();
164 let mut reasoning = String::new();
165 let mut calls: Vec<Value> = Vec::new();
166 let mut finish = Value::Null;
167 let mut usage = Value::Null;
168 let mut model = Value::Null;
169 let mut error = Value::Null;
170 for line in text.lines() {
171 let Some(data) = line.strip_prefix("data:").map(str::trim) else {
172 continue;
173 };
174 if data == "[DONE]" {
175 info.done = true;
176 continue;
177 }
178 let Ok(chunk) = serde_json::from_str::<Value>(data) else {
179 info.bad_lines += 1;
180 continue;
181 };
182 info.chunks += 1;
183 if !chunk["error"].is_null() {
184 error = chunk["error"].clone();
185 }
186 if model.is_null() {
187 model = chunk["model"].clone();
188 }
189 if !chunk["usage"].is_null() {
190 usage = chunk["usage"].clone();
191 }
192 let choice = &chunk["choices"][0];
193 if !choice["finish_reason"].is_null() {
194 finish = choice["finish_reason"].clone();
195 }
196 let delta = &choice["delta"];
197 if let Some(c) = delta["content"].as_str() {
198 content.push_str(c);
199 }
200 for key in ["reasoning_content", "reasoning"] {
201 if let Some(r) = delta[key].as_str() {
202 reasoning.push_str(r);
203 }
204 }
205 for tc in delta["tool_calls"].as_array().into_iter().flatten() {
206 let idx = tc["index"].as_u64().unwrap_or(calls.len() as u64) as usize;
207 while calls.len() <= idx {
208 calls.push(json!({ "id": null, "type": "function", "function": { "name": null, "arguments": "" } }));
209 }
210 let slot = &mut calls[idx];
211 if !tc["id"].is_null() {
212 slot["id"] = tc["id"].clone();
213 }
214 if let Some(n) = tc["function"]["name"].as_str() {
215 slot["function"]["name"] = json!(n);
216 }
217 if let Some(a) = tc["function"]["arguments"].as_str() {
218 let prev = slot["function"]["arguments"]
219 .as_str()
220 .unwrap_or("")
221 .to_owned();
222 slot["function"]["arguments"] = json!(prev + a);
223 }
224 }
225 }
226 let mut message = json!({ "role": "assistant", "content": if content.is_empty() { Value::Null } else { json!(content) } });
227 if !reasoning.is_empty() {
228 message["reasoning_content"] = json!(reasoning);
229 }
230 if !calls.is_empty() {
231 message["tool_calls"] = Value::Array(calls);
232 }
233 let mut response = json!({ "object": "chat.completion", "model": model, "choices": [{ "index": 0, "message": message, "finish_reason": finish }], "usage": usage });
234 if !error.is_null() {
235 response["error"] = error;
236 }
237 (response, info)
238}
239
240#[cfg(test)]
241mod tests {
242 use super::*;
243
244 #[test]
245 fn reassembles_split_tool_calls() {
246 let raw = concat!(
247 "data: {\"choices\":[{\"delta\":{\"role\":\"assistant\"}}]}\n\n",
248 "data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"c0\",\"function\":{\"name\":\"get\",\"arguments\":\"\"}}]}}]}\n\n",
249 "data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"{\\\"a\\\"\"}}]}}]}\n\n",
250 "data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\":1}\"}}]}}]}\n\n",
251 "data: {\"choices\":[{\"delta\":{},\"finish_reason\":\"tool_calls\"}]}\n\n",
252 "data: [DONE]\n\n",
253 );
254 let (r, info) = reassemble(raw.as_bytes());
255 assert!(info.done);
256 assert_eq!(info.chunks, 5);
257 let call = &r["choices"][0]["message"]["tool_calls"][0];
258 assert_eq!(call["id"], "c0");
259 assert_eq!(call["function"]["arguments"], "{\"a\":1}");
260 assert_eq!(r["choices"][0]["finish_reason"], "tool_calls");
261 assert!(r.get("error").is_none());
262 }
263
264 #[test]
265 fn keeps_a_streamed_error_event() {
266 let raw = concat!(
268 "data: {\"choices\":[{\"finish_reason\":null,\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":null}}],\"object\":\"chat.completion.chunk\"}\n\n",
269 "data: {\"error\":{\"code\":500,\"message\":\"The model produced output that does not match the expected peg-native format\",\"type\":\"server_error\"}}\n\n",
270 );
271 let (r, info) = reassemble(raw.as_bytes());
272 assert!(!info.done);
273 assert_eq!(r["error"]["code"], 500);
274 assert!(
275 r["error"]["message"]
276 .as_str()
277 .unwrap()
278 .contains("peg-native format")
279 );
280 }
281}