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`. Probes run
165    /// sequentially and nothing here is contended; what the atomic buys is the
166    /// ability to `await` this engine from a multi-threaded runtime — an
167    /// embedder's request handler cannot hold a `!Send` future.
168    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            // Redirects would let a relay bounce our probe to a different
178            // host without us noticing which one actually answered.
179            .redirect(reqwest::redirect::Policy::none())
180            .build()
181            .context("failed to build HTTP client")?;
182        Ok(Self::with_http(endpoint, http))
183    }
184
185    /// Build on a caller-supplied HTTP client.
186    ///
187    /// An embedder generally already has one, configured for its own network:
188    /// a proxy it must egress through, a connection pool it wants shared, a
189    /// root store it pins. Rebuilding that here would either duplicate the
190    /// configuration or quietly ignore it.
191    ///
192    /// The caller owns the timeout and the redirect policy that come with the
193    /// client it passes. Both matter to what the probes mean — a client that
194    /// follows redirects can be bounced to a different host mid-run without
195    /// the report saying so — so a caller that has no strong opinion should
196    /// use [`Client::new`].
197    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    /// Requests issued so far.
206    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                    // Many Anthropic-compatible relays only accept Bearer.
224                    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    /// POST a JSON body and return the raw response without interpreting it.
248    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    /// Send a chat request and parse it. Returns both the parsed view and the
306    /// raw response, because several probes assert on headers and status.
307    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    /// Stream a chat request, timing the first content-bearing event.
338    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            // Events are separated by a blank line; keep the trailing partial.
376            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        // Flush a final event that arrived without a trailing blank line.
386        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                // Later usage frames supersede earlier ones; Anthropic sends a
412                // partial at message_start and the real totals at message_delta.
413                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    /// Anthropic's authoritative token counter. `None` when the protocol has
440    /// no such route; `Err` when the route exists but the endpoint refused.
441    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        // count_tokens rejects generation-only fields.
445        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
489// ── SSE parsing ────────────────────────────────────────────────────────────
490
491fn 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
501/// Index just past the first `\n\n` (or `\r\n\r\n`) in the buffer.
502fn 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
531/// The incremental text carried by one streamed frame, if any.
532fn 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        // A path segment that merely starts with "v" is not a version.
616        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        // message_start carries no content and must not start the TTFT clock.
667        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        // A role-only opening frame must not count as first content either.
676        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        // OpenAI sends `"usage": null` on every non-final frame.
691        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}