Skip to main content

llm_verify/
client.rs

1// SPDX-License-Identifier: Apache-2.0
2//! HTTP transport. Deliberately low-level: several probes work by *removing*
3//! things a well-behaved client would always send (the auth header, the API
4//! version header) or by sending a body that is not valid JSON, so nothing
5//! here may quietly normalise a request on our behalf.
6
7use 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
15/// Sent on every request so operators can identify the traffic in their logs.
16const 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    /// Sent on every request, ahead of any per-probe `extra_headers`.
27    ///
28    /// The CLI leaves this empty. An embedder uses it for whatever its own
29    /// front door requires — routing headers, a tenant id, a marker that says
30    /// which of its callers this run belongs to. It is deliberately *not*
31    /// merged into `auth_headers`' omit logic: a probe that removes the API key
32    /// to see how the endpoint answers must still be routed to it.
33    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    /// Join the base URL with a protocol path, inserting `/v1` only when the
52    /// base does not already carry a version segment. Both
53    /// `https://api.anthropic.com` and `https://relay.example/api/v1` are
54    /// common in the wild and must both resolve correctly.
55    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/// Knobs that let a probe deviate from a well-formed request on purpose.
81#[derive(Debug, Clone, Default)]
82pub struct RequestOpts {
83    pub omit_auth: bool,
84    pub omit_version: bool,
85    /// Replaces the serialised body verbatim — used to send malformed JSON.
86    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/// One parsed Server-Sent Event.
115#[derive(Debug, Clone)]
116pub struct SseEvent {
117    /// `event:` field, empty for data-only streams such as OpenAI's.
118    pub name: String,
119    pub data: String,
120    /// Milliseconds from request send to this event arriving.
121    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    /// Time to the first event carrying actual content text — not role
130    /// assignments, not `message_start`, not pings. This is what a user feels
131    /// as lag before the answer starts appearing.
132    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    /// Bytes received, so an empty keep-alive stream is distinguishable from
138    /// a stream that failed to open at all.
139    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    /// Every request the run has made, for the report's request ledger.
163    ///
164    /// Atomic rather than `Cell` so a probe future stays `Send`. What the
165    /// atomic buys is the ability to `await` this engine from a multi-threaded
166    /// runtime — an embedder's request handler cannot hold a `!Send` future —
167    /// and, since [`RunConfig::concurrency`](crate::engine::RunConfig::concurrency),
168    /// correctness under genuinely simultaneous probes.
169    pub request_count: std::sync::atomic::AtomicU32,
170    /// How many requests this client may have in flight at once.
171    ///
172    /// `None` is unlimited, which is the right answer for a sequential run and
173    /// the wrong one for a concurrent one. See [`Client::with_limit`].
174    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            // Redirects would let a relay bounce our probe to a different
184            // host without us noticing which one actually answered.
185            .redirect(reqwest::redirect::Policy::none())
186            .build()
187            .context("failed to build HTTP client")?;
188        Ok(Self::with_http(endpoint, http))
189    }
190
191    /// Build on a caller-supplied HTTP client.
192    ///
193    /// An embedder generally already has one, configured for its own network:
194    /// a proxy it must egress through, a connection pool it wants shared, a
195    /// root store it pins. Rebuilding that here would either duplicate the
196    /// configuration or quietly ignore it.
197    ///
198    /// The caller owns the timeout and the redirect policy that come with the
199    /// client it passes. Both matter to what the probes mean — a client that
200    /// follows redirects can be bounced to a different host mid-run without
201    /// the report saying so — so a caller that has no strong opinion should
202    /// use [`Client::new`].
203    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    /// Cap how many requests this client may have in flight at once.
213    ///
214    /// # Why the cap lives here and not in the scheduler
215    ///
216    /// Concurrency has two units in this crate and only one of them is worth
217    /// bounding. The scheduler's unit is the *step*, and steps are wildly
218    /// uneven — the capability battery is a dozen requests, `stop_sequence` is
219    /// one — so "four steps at a time" says almost nothing about how much
220    /// traffic is actually in the air. The unit that matters to whoever is
221    /// answering is the *request*, and every request in the suite passes
222    /// through this type.
223    ///
224    /// That matters most to the caller this exists for: one probing somebody
225    /// else's endpoint, where the far end has a concurrency budget of its own
226    /// that it did not agree to spend on being examined. Overrun it and the
227    /// endpoint starts refusing — and a refusal is indistinguishable, from
228    /// here, from the endpoint being broken. A run that saturates its subject
229    /// measures the saturation.
230    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    /// Hold one of the concurrency permits, if there are any, for as long as
237    /// the returned guard lives.
238    ///
239    /// The semaphore is never closed, so the acquire cannot fail; `ok()` is
240    /// there to keep an unreachable error out of every call site rather than
241    /// to handle it.
242    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    /// Requests issued so far.
250    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                    // Many Anthropic-compatible relays only accept Bearer.
268                    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    /// POST a JSON body and return the raw response without interpreting it.
292    pub async fn post_raw(
293        &self,
294        path: &str,
295        body: &Value,
296        opts: &RequestOpts,
297    ) -> Result<RawResponse> {
298        // Held until the body has been read, not merely until the headers
299        // arrive: a generation that is still streaming is still occupying the
300        // far end, and releasing early would let the cap be exceeded by exactly
301        // the requests that take longest.
302        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    /// Send a chat request and parse it. Returns both the parsed view and the
356    /// raw response, because several probes assert on headers and status.
357    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    /// Stream a chat request, timing the first content-bearing event.
388    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            // Events are separated by a blank line; keep the trailing partial.
427            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        // Flush a final event that arrived without a trailing blank line.
437        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                // Later usage frames supersede earlier ones; Anthropic sends a
463                // partial at message_start and the real totals at message_delta.
464                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    /// Anthropic's authoritative token counter. `None` when the protocol has
491    /// no such route; `Err` when the route exists but the endpoint refused.
492    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        // count_tokens rejects generation-only fields.
496        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
540// ── SSE parsing ────────────────────────────────────────────────────────────
541
542fn 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
552/// Index just past the first `\n\n` (or `\r\n\r\n`) in the buffer.
553fn 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
582/// The incremental text carried by one streamed frame, if any.
583fn 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        // A path segment that merely starts with "v" is not a version.
667        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        // message_start carries no content and must not start the TTFT clock.
718        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        // A role-only opening frame must not count as first content either.
727        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        // OpenAI sends `"usage": null` on every non-final frame.
742        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}