1use crate::protocol::{ChatRequest, ChatResponse, Protocol};
8use crate::util::now_ms;
9use anyhow::{anyhow, Context, Result};
10use futures_util::StreamExt;
11use serde_json::Value;
12use std::collections::BTreeMap;
13use std::time::Duration;
14
15const UA: &str = concat!("llm-verify/", env!("CARGO_PKG_VERSION"));
17
18#[derive(Debug, Clone)]
19pub struct Endpoint {
20 pub base_url: String,
21 pub api_key: String,
22 pub protocol: Protocol,
23 pub model: String,
24 pub anthropic_version: String,
25 pub timeout: Duration,
26 pub headers: Vec<(String, String)>,
34}
35
36impl Default for Endpoint {
37 fn default() -> Self {
38 Endpoint {
39 base_url: String::new(),
40 api_key: String::new(),
41 protocol: Protocol::OpenAI,
42 model: String::new(),
43 anthropic_version: "2023-06-01".to_string(),
44 timeout: Duration::from_secs(120),
45 headers: Vec::new(),
46 }
47 }
48}
49
50impl Endpoint {
51 pub fn url(&self, path: &str) -> String {
56 let base = self.base_url.trim_end_matches('/');
57 let last = base.rsplit('/').next().unwrap_or("");
58 let versioned = last.len() >= 2
59 && last.starts_with('v')
60 && last[1..2].chars().all(|c| c.is_ascii_digit());
61 if versioned {
62 format!("{base}{path}")
63 } else {
64 format!("{base}/v1{path}")
65 }
66 }
67
68 pub fn host(&self) -> String {
69 self.base_url
70 .split("://")
71 .nth(1)
72 .unwrap_or(&self.base_url)
73 .split('/')
74 .next()
75 .unwrap_or_default()
76 .to_string()
77 }
78}
79
80#[derive(Debug, Clone, Default)]
82pub struct RequestOpts {
83 pub omit_auth: bool,
84 pub omit_version: bool,
85 pub raw_body: Option<Vec<u8>>,
87 pub extra_headers: Vec<(String, String)>,
88}
89
90#[derive(Debug, Clone)]
91pub struct RawResponse {
92 pub status: u16,
93 pub headers: BTreeMap<String, String>,
94 pub body: String,
95 pub duration_ms: u64,
96}
97
98impl RawResponse {
99 pub fn json(&self) -> Option<Value> {
100 serde_json::from_str(&self.body).ok()
101 }
102
103 pub fn header(&self, name: &str) -> Option<&str> {
104 self.headers
105 .get(&name.to_ascii_lowercase())
106 .map(|s| s.as_str())
107 }
108
109 pub fn ok(&self) -> bool {
110 (200..300).contains(&self.status)
111 }
112}
113
114#[derive(Debug, Clone)]
116pub struct SseEvent {
117 pub name: String,
119 pub data: String,
120 pub at_ms: u64,
122}
123
124#[derive(Debug, Clone, Default)]
125pub struct StreamResult {
126 pub status: u16,
127 pub headers: BTreeMap<String, String>,
128 pub events: Vec<SseEvent>,
129 pub ttft_ms: Option<u64>,
133 pub total_ms: u64,
134 pub text: String,
135 pub saw_done_sentinel: bool,
136 pub content_type: String,
137 pub bytes: usize,
140 pub usage: Option<crate::protocol::Usage>,
141 pub error: Option<String>,
142}
143
144impl StreamResult {
145 pub fn event_names(&self) -> Vec<String> {
146 self.events
147 .iter()
148 .map(|e| {
149 if e.name.is_empty() {
150 "data".to_string()
151 } else {
152 e.name.clone()
153 }
154 })
155 .collect()
156 }
157}
158
159pub struct Client {
160 http: reqwest::Client,
161 pub endpoint: Endpoint,
162 pub request_count: std::sync::atomic::AtomicU32,
169}
170
171impl Client {
172 pub fn new(endpoint: Endpoint) -> Result<Self> {
173 let http = reqwest::Client::builder()
174 .timeout(endpoint.timeout)
175 .connect_timeout(Duration::from_secs(15))
176 .user_agent(UA)
177 .redirect(reqwest::redirect::Policy::none())
180 .build()
181 .context("failed to build HTTP client")?;
182 Ok(Self::with_http(endpoint, http))
183 }
184
185 pub fn with_http(endpoint: Endpoint, http: reqwest::Client) -> Self {
198 Self {
199 http,
200 endpoint,
201 request_count: std::sync::atomic::AtomicU32::new(0),
202 }
203 }
204
205 pub fn requests(&self) -> u32 {
207 self.request_count
208 .load(std::sync::atomic::Ordering::Relaxed)
209 }
210
211 fn count_request(&self) {
212 self.request_count
213 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
214 }
215
216 fn auth_headers(&self, opts: &RequestOpts) -> Vec<(String, String)> {
217 let mut h = vec![("content-type".to_string(), "application/json".to_string())];
218 h.extend(self.endpoint.headers.iter().cloned());
219 if !opts.omit_auth {
220 match self.endpoint.protocol {
221 Protocol::Anthropic => {
222 h.push(("x-api-key".into(), self.endpoint.api_key.clone()));
223 h.push((
225 "authorization".into(),
226 format!("Bearer {}", self.endpoint.api_key),
227 ));
228 }
229 Protocol::OpenAI => {
230 h.push((
231 "authorization".into(),
232 format!("Bearer {}", self.endpoint.api_key),
233 ));
234 }
235 }
236 }
237 if self.endpoint.protocol == Protocol::Anthropic && !opts.omit_version {
238 h.push((
239 "anthropic-version".into(),
240 self.endpoint.anthropic_version.clone(),
241 ));
242 }
243 h.extend(opts.extra_headers.iter().cloned());
244 h
245 }
246
247 pub async fn post_raw(
249 &self,
250 path: &str,
251 body: &Value,
252 opts: &RequestOpts,
253 ) -> Result<RawResponse> {
254 self.count_request();
255 let url = self.endpoint.url(path);
256 let payload = match &opts.raw_body {
257 Some(b) => b.clone(),
258 None => serde_json::to_vec(body)?,
259 };
260
261 let mut req = self.http.post(&url).body(payload);
262 for (k, v) in self.auth_headers(opts) {
263 req = req.header(k, v);
264 }
265
266 let started = now_ms();
267 let resp = req
268 .send()
269 .await
270 .with_context(|| format!("POST {url} failed"))?;
271 let status = resp.status().as_u16();
272 let headers = collect_headers(resp.headers());
273 let body = resp.text().await.unwrap_or_default();
274 Ok(RawResponse {
275 status,
276 headers,
277 body,
278 duration_ms: (now_ms() - started) as u64,
279 })
280 }
281
282 pub async fn get_raw(&self, path: &str, opts: &RequestOpts) -> Result<RawResponse> {
283 self.count_request();
284 let url = self.endpoint.url(path);
285 let mut req = self.http.get(&url);
286 for (k, v) in self.auth_headers(opts) {
287 req = req.header(k, v);
288 }
289 let started = now_ms();
290 let resp = req
291 .send()
292 .await
293 .with_context(|| format!("GET {url} failed"))?;
294 let status = resp.status().as_u16();
295 let headers = collect_headers(resp.headers());
296 let body = resp.text().await.unwrap_or_default();
297 Ok(RawResponse {
298 status,
299 headers,
300 body,
301 duration_ms: (now_ms() - started) as u64,
302 })
303 }
304
305 pub async fn chat(&self, req: &ChatRequest) -> Result<(ChatResponse, RawResponse)> {
308 self.chat_with(req, &RequestOpts::default()).await
309 }
310
311 pub async fn chat_with(
312 &self,
313 req: &ChatRequest,
314 opts: &RequestOpts,
315 ) -> Result<(ChatResponse, RawResponse)> {
316 let proto = self.endpoint.protocol;
317 let raw = self
318 .post_raw(proto.chat_path(), &req.to_body(proto), opts)
319 .await?;
320 if !raw.ok() {
321 return Err(anyhow!(
322 "HTTP {} from {}: {}",
323 raw.status,
324 self.endpoint.host(),
325 crate::util::truncate(raw.body.trim(), 240)
326 ));
327 }
328 let v = raw.json().ok_or_else(|| {
329 anyhow!(
330 "response body was not JSON: {}",
331 crate::util::truncate(&raw.body, 200)
332 )
333 })?;
334 Ok((ChatResponse::parse(proto, &v), raw))
335 }
336
337 pub async fn stream(&self, req: &ChatRequest) -> Result<StreamResult> {
339 self.count_request();
340 let proto = self.endpoint.protocol;
341 let body = req.clone().stream(true).to_body(proto);
342 let url = self.endpoint.url(proto.chat_path());
343
344 let mut http_req = self.http.post(&url).body(serde_json::to_vec(&body)?);
345 for (k, v) in self.auth_headers(&RequestOpts::default()) {
346 http_req = http_req.header(k, v);
347 }
348 http_req = http_req.header("accept", "text/event-stream");
349
350 let started = now_ms();
351 let resp = http_req
352 .send()
353 .await
354 .with_context(|| format!("POST {url} (stream) failed"))?;
355
356 let mut out = StreamResult {
357 status: resp.status().as_u16(),
358 headers: collect_headers(resp.headers()),
359 ..Default::default()
360 };
361 out.content_type = out.headers.get("content-type").cloned().unwrap_or_default();
362
363 let mut stream = resp.bytes_stream();
364 let mut buf = String::new();
365 while let Some(chunk) = stream.next().await {
366 let chunk = match chunk {
367 Ok(c) => c,
368 Err(e) => {
369 out.error = Some(format!("stream aborted: {e}"));
370 break;
371 }
372 };
373 out.bytes += chunk.len();
374 buf.push_str(&String::from_utf8_lossy(&chunk));
375 while let Some(idx) = find_event_boundary(&buf) {
377 let (raw_event, rest) = buf.split_at(idx);
378 let raw_event = raw_event.to_string();
379 buf = rest.trim_start_matches(['\r', '\n']).to_string();
380 if let Some(ev) = parse_sse_block(&raw_event, (now_ms() - started) as u64) {
381 self.absorb_event(proto, ev, &mut out);
382 }
383 }
384 }
385 if !buf.trim().is_empty() {
387 if let Some(ev) = parse_sse_block(&buf, (now_ms() - started) as u64) {
388 self.absorb_event(proto, ev, &mut out);
389 }
390 }
391 out.total_ms = (now_ms() - started) as u64;
392 Ok(out)
393 }
394
395 fn absorb_event(&self, proto: Protocol, ev: SseEvent, out: &mut StreamResult) {
396 if ev.data.trim() == "[DONE]" {
397 out.saw_done_sentinel = true;
398 out.events.push(ev);
399 return;
400 }
401 if let Ok(v) = serde_json::from_str::<Value>(&ev.data) {
402 if let Some(delta) = extract_delta_text(proto, &v) {
403 if !delta.is_empty() {
404 if out.ttft_ms.is_none() {
405 out.ttft_ms = Some(ev.at_ms);
406 }
407 out.text.push_str(&delta);
408 }
409 }
410 if let Some(u) = extract_stream_usage(proto, &v) {
411 out.usage = Some(match out.usage.take() {
414 Some(prev) => crate::protocol::Usage {
415 input_tokens: if u.input_tokens > 0 {
416 u.input_tokens
417 } else {
418 prev.input_tokens
419 },
420 output_tokens: if u.output_tokens > 0 {
421 u.output_tokens
422 } else {
423 prev.output_tokens
424 },
425 cache_create_tokens: u.cache_create_tokens.max(prev.cache_create_tokens),
426 cache_read_tokens: u.cache_read_tokens.max(prev.cache_read_tokens),
427 present: true,
428 },
429 None => u,
430 });
431 }
432 if let Some(err) = v.get("error") {
433 out.error = Some(crate::util::truncate(&err.to_string(), 200));
434 }
435 }
436 out.events.push(ev);
437 }
438
439 pub async fn count_tokens(&self, req: &ChatRequest) -> Option<Result<u32>> {
442 let path = self.endpoint.protocol.count_tokens_path()?;
443 let mut body = req.to_body(self.endpoint.protocol);
444 for k in ["max_tokens", "temperature", "stream", "stop_sequences"] {
446 if let Some(o) = body.as_object_mut() {
447 o.remove(k);
448 }
449 }
450 Some(
451 match self.post_raw(path, &body, &RequestOpts::default()).await {
452 Err(e) => Err(e),
453 Ok(raw) if !raw.ok() => Err(anyhow!(
454 "count_tokens returned HTTP {}: {}",
455 raw.status,
456 crate::util::truncate(raw.body.trim(), 160)
457 )),
458 Ok(raw) => raw
459 .json()
460 .and_then(|v| v.get("input_tokens").and_then(|t| t.as_u64()))
461 .map(|t| t as u32)
462 .ok_or_else(|| anyhow!("count_tokens response had no input_tokens field")),
463 },
464 )
465 }
466
467 pub async fn list_models(&self) -> Result<Vec<String>> {
468 let raw = self
469 .get_raw(
470 self.endpoint.protocol.models_path(),
471 &RequestOpts::default(),
472 )
473 .await?;
474 if !raw.ok() {
475 return Err(anyhow!("HTTP {} from /models", raw.status));
476 }
477 let v = raw.json().ok_or_else(|| anyhow!("/models was not JSON"))?;
478 let arr = v
479 .get("data")
480 .and_then(|d| d.as_array())
481 .ok_or_else(|| anyhow!("/models had no data array"))?;
482 Ok(arr
483 .iter()
484 .filter_map(|m| m.get("id").and_then(|i| i.as_str()).map(String::from))
485 .collect())
486 }
487}
488
489fn collect_headers(h: &reqwest::header::HeaderMap) -> BTreeMap<String, String> {
492 h.iter()
493 .filter_map(|(k, v)| {
494 v.to_str()
495 .ok()
496 .map(|v| (k.as_str().to_ascii_lowercase(), v.to_string()))
497 })
498 .collect()
499}
500
501fn find_event_boundary(buf: &str) -> Option<usize> {
503 let a = buf.find("\n\n").map(|i| i + 2);
504 let b = buf.find("\r\n\r\n").map(|i| i + 4);
505 match (a, b) {
506 (Some(x), Some(y)) => Some(x.min(y)),
507 (x, y) => x.or(y),
508 }
509}
510
511fn parse_sse_block(block: &str, at_ms: u64) -> Option<SseEvent> {
512 let mut name = String::new();
513 let mut data = String::new();
514 for line in block.lines() {
515 let line = line.trim_end_matches('\r');
516 if let Some(rest) = line.strip_prefix("event:") {
517 name = rest.trim().to_string();
518 } else if let Some(rest) = line.strip_prefix("data:") {
519 if !data.is_empty() {
520 data.push('\n');
521 }
522 data.push_str(rest.strip_prefix(' ').unwrap_or(rest));
523 }
524 }
525 if name.is_empty() && data.is_empty() {
526 return None;
527 }
528 Some(SseEvent { name, data, at_ms })
529}
530
531fn extract_delta_text(proto: Protocol, v: &Value) -> Option<String> {
533 match proto {
534 Protocol::Anthropic => {
535 if v.get("type").and_then(|t| t.as_str()) != Some("content_block_delta") {
536 return None;
537 }
538 v.get("delta")
539 .and_then(|d| d.get("text"))
540 .and_then(|t| t.as_str())
541 .map(String::from)
542 }
543 Protocol::OpenAI => v
544 .get("choices")
545 .and_then(|c| c.as_array())
546 .and_then(|a| a.first())
547 .and_then(|c| c.get("delta"))
548 .and_then(|d| d.get("content"))
549 .and_then(|t| t.as_str())
550 .map(String::from),
551 }
552}
553
554fn extract_stream_usage(proto: Protocol, v: &Value) -> Option<crate::protocol::Usage> {
555 let u = match proto {
556 Protocol::Anthropic => v
557 .get("usage")
558 .or_else(|| v.get("message").and_then(|m| m.get("usage")))?,
559 Protocol::OpenAI => v.get("usage").filter(|u| !u.is_null())?,
560 };
561 let get = |k: &str| u.get(k).and_then(|x| x.as_u64()).unwrap_or(0) as u32;
562 Some(match proto {
563 Protocol::Anthropic => crate::protocol::Usage {
564 input_tokens: get("input_tokens"),
565 output_tokens: get("output_tokens"),
566 cache_create_tokens: get("cache_creation_input_tokens"),
567 cache_read_tokens: get("cache_read_input_tokens"),
568 present: true,
569 },
570 Protocol::OpenAI => crate::protocol::Usage {
571 input_tokens: get("prompt_tokens"),
572 output_tokens: get("completion_tokens"),
573 cache_create_tokens: 0,
574 cache_read_tokens: u
575 .get("prompt_tokens_details")
576 .and_then(|d| d.get("cached_tokens"))
577 .and_then(|c| c.as_u64())
578 .unwrap_or(0) as u32,
579 present: true,
580 },
581 })
582}
583
584#[cfg(test)]
585mod tests {
586 use super::*;
587 use serde_json::json;
588
589 fn ep(base: &str) -> Endpoint {
590 Endpoint {
591 base_url: base.into(),
592 api_key: "k".into(),
593 protocol: Protocol::Anthropic,
594 model: "m".into(),
595 anthropic_version: "2023-06-01".into(),
596 timeout: Duration::from_secs(1),
597 headers: Vec::new(),
598 }
599 }
600
601 #[test]
602 fn url_inserts_v1_only_when_absent() {
603 assert_eq!(
604 ep("https://api.anthropic.com").url("/messages"),
605 "https://api.anthropic.com/v1/messages"
606 );
607 assert_eq!(
608 ep("https://relay.example/api/v1").url("/messages"),
609 "https://relay.example/api/v1/messages"
610 );
611 assert_eq!(
612 ep("https://relay.example/api/v1/").url("/messages"),
613 "https://relay.example/api/v1/messages"
614 );
615 assert_eq!(
617 ep("https://relay.example/vendor").url("/messages"),
618 "https://relay.example/vendor/v1/messages"
619 );
620 assert_eq!(
621 ep("https://x.dev/v1beta").url("/messages"),
622 "https://x.dev/v1beta/messages"
623 );
624 }
625
626 #[test]
627 fn host_extracts_authority() {
628 assert_eq!(ep("https://api.example.com/v1").host(), "api.example.com");
629 assert_eq!(ep("http://localhost:8080").host(), "localhost:8080");
630 }
631
632 #[test]
633 fn event_boundary_prefers_the_earliest_terminator() {
634 assert_eq!(find_event_boundary("a\n\nb"), Some(3));
635 assert_eq!(find_event_boundary("a\r\n\r\nb"), Some(5));
636 assert_eq!(find_event_boundary("no terminator"), None);
637 }
638
639 #[test]
640 fn parses_named_and_data_only_events() {
641 let named = parse_sse_block("event: message_start\ndata: {\"a\":1}\n", 5).unwrap();
642 assert_eq!(named.name, "message_start");
643 assert_eq!(named.data, "{\"a\":1}");
644
645 let data_only = parse_sse_block("data: [DONE]\n", 9).unwrap();
646 assert!(data_only.name.is_empty());
647 assert_eq!(data_only.data, "[DONE]");
648
649 assert!(parse_sse_block(": keep-alive comment\n", 0).is_none());
650 }
651
652 #[test]
653 fn multiline_data_fields_are_joined() {
654 let ev = parse_sse_block("data: line1\ndata: line2\n", 0).unwrap();
655 assert_eq!(ev.data, "line1\nline2");
656 }
657
658 #[test]
659 fn delta_text_extracted_per_protocol() {
660 let a =
661 json!({"type": "content_block_delta", "delta": {"type": "text_delta", "text": "hi"}});
662 assert_eq!(
663 extract_delta_text(Protocol::Anthropic, &a).as_deref(),
664 Some("hi")
665 );
666 let start = json!({"type": "message_start", "message": {"usage": {"input_tokens": 4}}});
668 assert!(extract_delta_text(Protocol::Anthropic, &start).is_none());
669
670 let o = json!({"choices": [{"delta": {"content": "yo"}}]});
671 assert_eq!(
672 extract_delta_text(Protocol::OpenAI, &o).as_deref(),
673 Some("yo")
674 );
675 let role = json!({"choices": [{"delta": {"role": "assistant"}}]});
677 assert!(extract_delta_text(Protocol::OpenAI, &role).is_none());
678 }
679
680 #[test]
681 fn stream_usage_read_from_both_shapes() {
682 let start = json!({"type": "message_start", "message": {"usage": {"input_tokens": 7}}});
683 let u = extract_stream_usage(Protocol::Anthropic, &start).unwrap();
684 assert_eq!(u.input_tokens, 7);
685
686 let oai = json!({"usage": {"prompt_tokens": 3, "completion_tokens": 11}});
687 let u = extract_stream_usage(Protocol::OpenAI, &oai).unwrap();
688 assert_eq!(u.output_tokens, 11);
689
690 assert!(extract_stream_usage(Protocol::OpenAI, &json!({"usage": null})).is_none());
692 }
693
694 #[test]
695 fn raw_response_header_lookup_is_case_insensitive() {
696 let r = RawResponse {
697 status: 200,
698 headers: [("request-id".to_string(), "req_1".to_string())].into(),
699 body: String::new(),
700 duration_ms: 0,
701 };
702 assert_eq!(r.header("Request-Id"), Some("req_1"));
703 assert!(r.ok());
704 }
705}