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,
170 limit: Option<std::sync::Arc<tokio::sync::Semaphore>>,
175}
176
177impl Client {
178 pub fn new(endpoint: Endpoint) -> Result<Self> {
179 let http = reqwest::Client::builder()
180 .timeout(endpoint.timeout)
181 .connect_timeout(Duration::from_secs(15))
182 .user_agent(UA)
183 .redirect(reqwest::redirect::Policy::none())
186 .build()
187 .context("failed to build HTTP client")?;
188 Ok(Self::with_http(endpoint, http))
189 }
190
191 pub fn with_http(endpoint: Endpoint, http: reqwest::Client) -> Self {
204 Self {
205 http,
206 endpoint,
207 request_count: std::sync::atomic::AtomicU32::new(0),
208 limit: None,
209 }
210 }
211
212 pub fn with_limit(mut self, permits: usize) -> Self {
231 self.limit =
232 (permits > 0).then(|| std::sync::Arc::new(tokio::sync::Semaphore::new(permits)));
233 self
234 }
235
236 async fn permit(&self) -> Option<tokio::sync::OwnedSemaphorePermit> {
243 match &self.limit {
244 Some(s) => s.clone().acquire_owned().await.ok(),
245 None => None,
246 }
247 }
248
249 pub fn requests(&self) -> u32 {
251 self.request_count
252 .load(std::sync::atomic::Ordering::Relaxed)
253 }
254
255 fn count_request(&self) {
256 self.request_count
257 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
258 }
259
260 fn auth_headers(&self, opts: &RequestOpts) -> Vec<(String, String)> {
261 let mut h = vec![("content-type".to_string(), "application/json".to_string())];
262 h.extend(self.endpoint.headers.iter().cloned());
263 if !opts.omit_auth {
264 match self.endpoint.protocol {
265 Protocol::Anthropic => {
266 h.push(("x-api-key".into(), self.endpoint.api_key.clone()));
267 h.push((
269 "authorization".into(),
270 format!("Bearer {}", self.endpoint.api_key),
271 ));
272 }
273 Protocol::OpenAI => {
274 h.push((
275 "authorization".into(),
276 format!("Bearer {}", self.endpoint.api_key),
277 ));
278 }
279 }
280 }
281 if self.endpoint.protocol == Protocol::Anthropic && !opts.omit_version {
282 h.push((
283 "anthropic-version".into(),
284 self.endpoint.anthropic_version.clone(),
285 ));
286 }
287 h.extend(opts.extra_headers.iter().cloned());
288 h
289 }
290
291 pub async fn post_raw(
293 &self,
294 path: &str,
295 body: &Value,
296 opts: &RequestOpts,
297 ) -> Result<RawResponse> {
298 let _permit = self.permit().await;
303 self.count_request();
304 let url = self.endpoint.url(path);
305 let payload = match &opts.raw_body {
306 Some(b) => b.clone(),
307 None => serde_json::to_vec(body)?,
308 };
309
310 let mut req = self.http.post(&url).body(payload);
311 for (k, v) in self.auth_headers(opts) {
312 req = req.header(k, v);
313 }
314
315 let started = now_ms();
316 let resp = req
317 .send()
318 .await
319 .with_context(|| format!("POST {url} failed"))?;
320 let status = resp.status().as_u16();
321 let headers = collect_headers(resp.headers());
322 let body = resp.text().await.unwrap_or_default();
323 Ok(RawResponse {
324 status,
325 headers,
326 body,
327 duration_ms: (now_ms() - started) as u64,
328 })
329 }
330
331 pub async fn get_raw(&self, path: &str, opts: &RequestOpts) -> Result<RawResponse> {
332 let _permit = self.permit().await;
333 self.count_request();
334 let url = self.endpoint.url(path);
335 let mut req = self.http.get(&url);
336 for (k, v) in self.auth_headers(opts) {
337 req = req.header(k, v);
338 }
339 let started = now_ms();
340 let resp = req
341 .send()
342 .await
343 .with_context(|| format!("GET {url} failed"))?;
344 let status = resp.status().as_u16();
345 let headers = collect_headers(resp.headers());
346 let body = resp.text().await.unwrap_or_default();
347 Ok(RawResponse {
348 status,
349 headers,
350 body,
351 duration_ms: (now_ms() - started) as u64,
352 })
353 }
354
355 pub async fn chat(&self, req: &ChatRequest) -> Result<(ChatResponse, RawResponse)> {
358 self.chat_with(req, &RequestOpts::default()).await
359 }
360
361 pub async fn chat_with(
362 &self,
363 req: &ChatRequest,
364 opts: &RequestOpts,
365 ) -> Result<(ChatResponse, RawResponse)> {
366 let proto = self.endpoint.protocol;
367 let raw = self
368 .post_raw(proto.chat_path(), &req.to_body(proto), opts)
369 .await?;
370 if !raw.ok() {
371 return Err(anyhow!(
372 "HTTP {} from {}: {}",
373 raw.status,
374 self.endpoint.host(),
375 crate::util::truncate(raw.body.trim(), 240)
376 ));
377 }
378 let v = raw.json().ok_or_else(|| {
379 anyhow!(
380 "response body was not JSON: {}",
381 crate::util::truncate(&raw.body, 200)
382 )
383 })?;
384 Ok((ChatResponse::parse(proto, &v), raw))
385 }
386
387 pub async fn stream(&self, req: &ChatRequest) -> Result<StreamResult> {
389 let _permit = self.permit().await;
390 self.count_request();
391 let proto = self.endpoint.protocol;
392 let body = req.clone().stream(true).to_body(proto);
393 let url = self.endpoint.url(proto.chat_path());
394
395 let mut http_req = self.http.post(&url).body(serde_json::to_vec(&body)?);
396 for (k, v) in self.auth_headers(&RequestOpts::default()) {
397 http_req = http_req.header(k, v);
398 }
399 http_req = http_req.header("accept", "text/event-stream");
400
401 let started = now_ms();
402 let resp = http_req
403 .send()
404 .await
405 .with_context(|| format!("POST {url} (stream) failed"))?;
406
407 let mut out = StreamResult {
408 status: resp.status().as_u16(),
409 headers: collect_headers(resp.headers()),
410 ..Default::default()
411 };
412 out.content_type = out.headers.get("content-type").cloned().unwrap_or_default();
413
414 let mut stream = resp.bytes_stream();
415 let mut buf = String::new();
416 while let Some(chunk) = stream.next().await {
417 let chunk = match chunk {
418 Ok(c) => c,
419 Err(e) => {
420 out.error = Some(format!("stream aborted: {e}"));
421 break;
422 }
423 };
424 out.bytes += chunk.len();
425 buf.push_str(&String::from_utf8_lossy(&chunk));
426 while let Some(idx) = find_event_boundary(&buf) {
428 let (raw_event, rest) = buf.split_at(idx);
429 let raw_event = raw_event.to_string();
430 buf = rest.trim_start_matches(['\r', '\n']).to_string();
431 if let Some(ev) = parse_sse_block(&raw_event, (now_ms() - started) as u64) {
432 self.absorb_event(proto, ev, &mut out);
433 }
434 }
435 }
436 if !buf.trim().is_empty() {
438 if let Some(ev) = parse_sse_block(&buf, (now_ms() - started) as u64) {
439 self.absorb_event(proto, ev, &mut out);
440 }
441 }
442 out.total_ms = (now_ms() - started) as u64;
443 Ok(out)
444 }
445
446 fn absorb_event(&self, proto: Protocol, ev: SseEvent, out: &mut StreamResult) {
447 if ev.data.trim() == "[DONE]" {
448 out.saw_done_sentinel = true;
449 out.events.push(ev);
450 return;
451 }
452 if let Ok(v) = serde_json::from_str::<Value>(&ev.data) {
453 if let Some(delta) = extract_delta_text(proto, &v) {
454 if !delta.is_empty() {
455 if out.ttft_ms.is_none() {
456 out.ttft_ms = Some(ev.at_ms);
457 }
458 out.text.push_str(&delta);
459 }
460 }
461 if let Some(u) = extract_stream_usage(proto, &v) {
462 out.usage = Some(match out.usage.take() {
465 Some(prev) => crate::protocol::Usage {
466 input_tokens: if u.input_tokens > 0 {
467 u.input_tokens
468 } else {
469 prev.input_tokens
470 },
471 output_tokens: if u.output_tokens > 0 {
472 u.output_tokens
473 } else {
474 prev.output_tokens
475 },
476 cache_create_tokens: u.cache_create_tokens.max(prev.cache_create_tokens),
477 cache_read_tokens: u.cache_read_tokens.max(prev.cache_read_tokens),
478 present: true,
479 },
480 None => u,
481 });
482 }
483 if let Some(err) = v.get("error") {
484 out.error = Some(crate::util::truncate(&err.to_string(), 200));
485 }
486 }
487 out.events.push(ev);
488 }
489
490 pub async fn count_tokens(&self, req: &ChatRequest) -> Option<Result<u32>> {
493 let path = self.endpoint.protocol.count_tokens_path()?;
494 let mut body = req.to_body(self.endpoint.protocol);
495 for k in ["max_tokens", "temperature", "stream", "stop_sequences"] {
497 if let Some(o) = body.as_object_mut() {
498 o.remove(k);
499 }
500 }
501 Some(
502 match self.post_raw(path, &body, &RequestOpts::default()).await {
503 Err(e) => Err(e),
504 Ok(raw) if !raw.ok() => Err(anyhow!(
505 "count_tokens returned HTTP {}: {}",
506 raw.status,
507 crate::util::truncate(raw.body.trim(), 160)
508 )),
509 Ok(raw) => raw
510 .json()
511 .and_then(|v| v.get("input_tokens").and_then(|t| t.as_u64()))
512 .map(|t| t as u32)
513 .ok_or_else(|| anyhow!("count_tokens response had no input_tokens field")),
514 },
515 )
516 }
517
518 pub async fn list_models(&self) -> Result<Vec<String>> {
519 let raw = self
520 .get_raw(
521 self.endpoint.protocol.models_path(),
522 &RequestOpts::default(),
523 )
524 .await?;
525 if !raw.ok() {
526 return Err(anyhow!("HTTP {} from /models", raw.status));
527 }
528 let v = raw.json().ok_or_else(|| anyhow!("/models was not JSON"))?;
529 let arr = v
530 .get("data")
531 .and_then(|d| d.as_array())
532 .ok_or_else(|| anyhow!("/models had no data array"))?;
533 Ok(arr
534 .iter()
535 .filter_map(|m| m.get("id").and_then(|i| i.as_str()).map(String::from))
536 .collect())
537 }
538}
539
540fn collect_headers(h: &reqwest::header::HeaderMap) -> BTreeMap<String, String> {
543 h.iter()
544 .filter_map(|(k, v)| {
545 v.to_str()
546 .ok()
547 .map(|v| (k.as_str().to_ascii_lowercase(), v.to_string()))
548 })
549 .collect()
550}
551
552fn find_event_boundary(buf: &str) -> Option<usize> {
554 let a = buf.find("\n\n").map(|i| i + 2);
555 let b = buf.find("\r\n\r\n").map(|i| i + 4);
556 match (a, b) {
557 (Some(x), Some(y)) => Some(x.min(y)),
558 (x, y) => x.or(y),
559 }
560}
561
562fn parse_sse_block(block: &str, at_ms: u64) -> Option<SseEvent> {
563 let mut name = String::new();
564 let mut data = String::new();
565 for line in block.lines() {
566 let line = line.trim_end_matches('\r');
567 if let Some(rest) = line.strip_prefix("event:") {
568 name = rest.trim().to_string();
569 } else if let Some(rest) = line.strip_prefix("data:") {
570 if !data.is_empty() {
571 data.push('\n');
572 }
573 data.push_str(rest.strip_prefix(' ').unwrap_or(rest));
574 }
575 }
576 if name.is_empty() && data.is_empty() {
577 return None;
578 }
579 Some(SseEvent { name, data, at_ms })
580}
581
582fn extract_delta_text(proto: Protocol, v: &Value) -> Option<String> {
584 match proto {
585 Protocol::Anthropic => {
586 if v.get("type").and_then(|t| t.as_str()) != Some("content_block_delta") {
587 return None;
588 }
589 v.get("delta")
590 .and_then(|d| d.get("text"))
591 .and_then(|t| t.as_str())
592 .map(String::from)
593 }
594 Protocol::OpenAI => v
595 .get("choices")
596 .and_then(|c| c.as_array())
597 .and_then(|a| a.first())
598 .and_then(|c| c.get("delta"))
599 .and_then(|d| d.get("content"))
600 .and_then(|t| t.as_str())
601 .map(String::from),
602 }
603}
604
605fn extract_stream_usage(proto: Protocol, v: &Value) -> Option<crate::protocol::Usage> {
606 let u = match proto {
607 Protocol::Anthropic => v
608 .get("usage")
609 .or_else(|| v.get("message").and_then(|m| m.get("usage")))?,
610 Protocol::OpenAI => v.get("usage").filter(|u| !u.is_null())?,
611 };
612 let get = |k: &str| u.get(k).and_then(|x| x.as_u64()).unwrap_or(0) as u32;
613 Some(match proto {
614 Protocol::Anthropic => crate::protocol::Usage {
615 input_tokens: get("input_tokens"),
616 output_tokens: get("output_tokens"),
617 cache_create_tokens: get("cache_creation_input_tokens"),
618 cache_read_tokens: get("cache_read_input_tokens"),
619 present: true,
620 },
621 Protocol::OpenAI => crate::protocol::Usage {
622 input_tokens: get("prompt_tokens"),
623 output_tokens: get("completion_tokens"),
624 cache_create_tokens: 0,
625 cache_read_tokens: u
626 .get("prompt_tokens_details")
627 .and_then(|d| d.get("cached_tokens"))
628 .and_then(|c| c.as_u64())
629 .unwrap_or(0) as u32,
630 present: true,
631 },
632 })
633}
634
635#[cfg(test)]
636mod tests {
637 use super::*;
638 use serde_json::json;
639
640 fn ep(base: &str) -> Endpoint {
641 Endpoint {
642 base_url: base.into(),
643 api_key: "k".into(),
644 protocol: Protocol::Anthropic,
645 model: "m".into(),
646 anthropic_version: "2023-06-01".into(),
647 timeout: Duration::from_secs(1),
648 headers: Vec::new(),
649 }
650 }
651
652 #[test]
653 fn url_inserts_v1_only_when_absent() {
654 assert_eq!(
655 ep("https://api.anthropic.com").url("/messages"),
656 "https://api.anthropic.com/v1/messages"
657 );
658 assert_eq!(
659 ep("https://relay.example/api/v1").url("/messages"),
660 "https://relay.example/api/v1/messages"
661 );
662 assert_eq!(
663 ep("https://relay.example/api/v1/").url("/messages"),
664 "https://relay.example/api/v1/messages"
665 );
666 assert_eq!(
668 ep("https://relay.example/vendor").url("/messages"),
669 "https://relay.example/vendor/v1/messages"
670 );
671 assert_eq!(
672 ep("https://x.dev/v1beta").url("/messages"),
673 "https://x.dev/v1beta/messages"
674 );
675 }
676
677 #[test]
678 fn host_extracts_authority() {
679 assert_eq!(ep("https://api.example.com/v1").host(), "api.example.com");
680 assert_eq!(ep("http://localhost:8080").host(), "localhost:8080");
681 }
682
683 #[test]
684 fn event_boundary_prefers_the_earliest_terminator() {
685 assert_eq!(find_event_boundary("a\n\nb"), Some(3));
686 assert_eq!(find_event_boundary("a\r\n\r\nb"), Some(5));
687 assert_eq!(find_event_boundary("no terminator"), None);
688 }
689
690 #[test]
691 fn parses_named_and_data_only_events() {
692 let named = parse_sse_block("event: message_start\ndata: {\"a\":1}\n", 5).unwrap();
693 assert_eq!(named.name, "message_start");
694 assert_eq!(named.data, "{\"a\":1}");
695
696 let data_only = parse_sse_block("data: [DONE]\n", 9).unwrap();
697 assert!(data_only.name.is_empty());
698 assert_eq!(data_only.data, "[DONE]");
699
700 assert!(parse_sse_block(": keep-alive comment\n", 0).is_none());
701 }
702
703 #[test]
704 fn multiline_data_fields_are_joined() {
705 let ev = parse_sse_block("data: line1\ndata: line2\n", 0).unwrap();
706 assert_eq!(ev.data, "line1\nline2");
707 }
708
709 #[test]
710 fn delta_text_extracted_per_protocol() {
711 let a =
712 json!({"type": "content_block_delta", "delta": {"type": "text_delta", "text": "hi"}});
713 assert_eq!(
714 extract_delta_text(Protocol::Anthropic, &a).as_deref(),
715 Some("hi")
716 );
717 let start = json!({"type": "message_start", "message": {"usage": {"input_tokens": 4}}});
719 assert!(extract_delta_text(Protocol::Anthropic, &start).is_none());
720
721 let o = json!({"choices": [{"delta": {"content": "yo"}}]});
722 assert_eq!(
723 extract_delta_text(Protocol::OpenAI, &o).as_deref(),
724 Some("yo")
725 );
726 let role = json!({"choices": [{"delta": {"role": "assistant"}}]});
728 assert!(extract_delta_text(Protocol::OpenAI, &role).is_none());
729 }
730
731 #[test]
732 fn stream_usage_read_from_both_shapes() {
733 let start = json!({"type": "message_start", "message": {"usage": {"input_tokens": 7}}});
734 let u = extract_stream_usage(Protocol::Anthropic, &start).unwrap();
735 assert_eq!(u.input_tokens, 7);
736
737 let oai = json!({"usage": {"prompt_tokens": 3, "completion_tokens": 11}});
738 let u = extract_stream_usage(Protocol::OpenAI, &oai).unwrap();
739 assert_eq!(u.output_tokens, 11);
740
741 assert!(extract_stream_usage(Protocol::OpenAI, &json!({"usage": null})).is_none());
743 }
744
745 #[test]
746 fn raw_response_header_lookup_is_case_insensitive() {
747 let r = RawResponse {
748 status: 200,
749 headers: [("request-id".to_string(), "req_1".to_string())].into(),
750 body: String::new(),
751 duration_ms: 0,
752 };
753 assert_eq!(r.header("Request-Id"), Some("req_1"));
754 assert!(r.ok());
755 }
756}