Skip to main content

runifold_provider_testkit/
lib.rs

1//! Offline HTTP cassette and fault-injection support for provider adapters.
2//!
3//! The server intentionally speaks HTTP/1.1 over a loopback socket. This keeps
4//! provider tests independent from a production HTTP stack while still
5//! exercising real request serialization, response streaming, delays, and
6//! mid-stream disconnects.
7
8mod benchmark;
9mod conformance;
10
11pub use benchmark::{
12    BenchmarkFailureCount, BenchmarkFailureKind, BenchmarkFuture, BenchmarkInvocation,
13    BenchmarkInvocationError, BenchmarkPlan, BenchmarkRegressionComparison,
14    BenchmarkRegressionMetric, BenchmarkRegressionPolicy, BenchmarkTarget, LatencyDistribution,
15    ModelBenchmarkTarget, ProviderBenchmarkError, ProviderBenchmarkReport, benchmark,
16    compare_benchmarks,
17};
18pub use conformance::{
19    ConformanceCheck, ErrorContract, ProviderConformanceError, ProviderConformanceReport,
20    SuccessContract, verify_error, verify_success,
21};
22
23use std::{
24    collections::BTreeMap,
25    io::{Read, Write},
26    net::{SocketAddr, TcpListener, TcpStream},
27    sync::{
28        Arc, Mutex,
29        atomic::{AtomicBool, AtomicUsize, Ordering},
30    },
31    thread::{self, JoinHandle},
32    time::Duration,
33};
34
35use serde::{Deserialize, Serialize};
36use serde_json::Value;
37use thiserror::Error;
38
39/// One expected request and its scripted response.
40#[derive(Clone, Debug)]
41pub struct HttpExchange {
42    /// Expected uppercase HTTP method.
43    pub method: String,
44    /// Expected path, including a query string when present.
45    pub path: String,
46    /// Expected JSON body. `None` accepts any request body.
47    pub json_body: Option<Value>,
48    /// Response returned after the request matches.
49    pub response: ScriptedResponse,
50}
51
52impl HttpExchange {
53    /// Creates an exchange that accepts any request body.
54    pub fn new(
55        method: impl Into<String>,
56        path: impl Into<String>,
57        response: ScriptedResponse,
58    ) -> Self {
59        Self {
60            method: method.into().to_ascii_uppercase(),
61            path: path.into(),
62            json_body: None,
63            response,
64        }
65    }
66
67    /// Requires an exact JSON request body.
68    #[must_use]
69    pub fn with_json_body(mut self, body: Value) -> Self {
70        self.json_body = Some(body);
71        self
72    }
73}
74
75/// One response body fragment and the delay before it is sent.
76#[derive(Clone, Debug, Default)]
77pub struct ResponseChunk {
78    /// Bytes written as one HTTP chunk.
79    pub body: Vec<u8>,
80    /// Artificial delay before writing the chunk.
81    pub delay: Duration,
82}
83
84impl ResponseChunk {
85    /// Creates an immediate UTF-8 response chunk.
86    pub fn text(body: impl Into<String>) -> Self {
87        Self {
88            body: body.into().into_bytes(),
89            delay: Duration::ZERO,
90        }
91    }
92
93    /// Delays this chunk.
94    #[must_use]
95    pub const fn after(mut self, delay: Duration) -> Self {
96        self.delay = delay;
97        self
98    }
99}
100
101/// A streamed HTTP response, optionally disconnected before its terminator.
102#[derive(Clone, Debug)]
103pub struct ScriptedResponse {
104    /// HTTP status code.
105    pub status: u16,
106    /// Response headers.
107    pub headers: BTreeMap<String, String>,
108    /// Ordered chunked-transfer body fragments.
109    pub chunks: Vec<ResponseChunk>,
110    /// Close the socket without a final zero-sized chunk.
111    pub disconnect: bool,
112}
113
114impl ScriptedResponse {
115    /// Creates a successful response.
116    pub fn ok(chunks: Vec<ResponseChunk>) -> Self {
117        Self {
118            status: 200,
119            headers: BTreeMap::new(),
120            chunks,
121            disconnect: false,
122        }
123    }
124
125    /// Creates a JSON response.
126    ///
127    /// # Errors
128    ///
129    /// Returns an error when the JSON body cannot be serialized.
130    pub fn json(status: u16, body: &Value) -> Result<Self, CassetteError> {
131        let encoded = serde_json::to_vec(body)?;
132        let mut headers = BTreeMap::new();
133        headers.insert("content-type".into(), "application/json".into());
134        Ok(Self {
135            status,
136            headers,
137            chunks: vec![ResponseChunk {
138                body: encoded,
139                delay: Duration::ZERO,
140            }],
141            disconnect: false,
142        })
143    }
144
145    /// Adds or replaces a response header.
146    #[must_use]
147    pub fn with_header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
148        self.headers.insert(name.into(), value.into());
149        self
150    }
151
152    /// Disconnects after the configured chunks, simulating a broken stream.
153    #[must_use]
154    pub const fn disconnect_after_chunks(mut self) -> Self {
155        self.disconnect = true;
156        self
157    }
158}
159
160/// A request captured by the cassette server.
161#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
162pub struct ObservedRequest {
163    /// HTTP method.
164    pub method: String,
165    /// Request target.
166    pub path: String,
167    /// Lowercase headers with credentials replaced by `[REDACTED]`.
168    pub headers: BTreeMap<String, String>,
169    /// Raw request body.
170    pub body: Vec<u8>,
171}
172
173impl ObservedRequest {
174    /// Parses the request body as JSON.
175    ///
176    /// # Errors
177    ///
178    /// Returns an error if the captured body is not valid JSON.
179    pub fn json_body(&self) -> Result<Value, CassetteError> {
180        Ok(serde_json::from_slice(&self.body)?)
181    }
182}
183
184/// Errors produced by cassette construction, serving, or verification.
185#[derive(Debug, Error)]
186#[non_exhaustive]
187pub enum CassetteError {
188    /// A loopback socket operation failed.
189    #[error("cassette I/O failed: {0}")]
190    Io(#[from] std::io::Error),
191    /// JSON serialization or validation failed.
192    #[error("cassette JSON failed: {0}")]
193    Json(#[from] serde_json::Error),
194    /// A request did not match its scripted exchange.
195    #[error("cassette request mismatch: {0}")]
196    RequestMismatch(String),
197    /// The server thread panicked.
198    #[error("cassette server thread panicked")]
199    ServerPanicked,
200    /// Not every scripted exchange was consumed.
201    #[error("cassette consumed {observed} of {expected} exchanges")]
202    Incomplete {
203        /// Number of captured requests.
204        observed: usize,
205        /// Number of scripted exchanges.
206        expected: usize,
207    },
208}
209
210/// A loopback server executing a fixed sequence of HTTP exchanges.
211#[derive(Debug)]
212pub struct CassetteServer {
213    address: SocketAddr,
214    expected: usize,
215    observed: Arc<Mutex<Vec<ObservedRequest>>>,
216    failure: Arc<Mutex<Option<String>>>,
217    stats: Arc<ServerCounters>,
218    shutdown: Arc<AtomicBool>,
219    thread: Option<JoinHandle<()>>,
220}
221
222/// Snapshot of cassette-server concurrency and completion counters.
223#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
224pub struct ServerStats {
225    /// Connections accepted for scripted exchanges.
226    pub accepted: usize,
227    /// Responses that completed without a server-side write error.
228    pub completed: usize,
229    /// Largest number of simultaneously active handlers.
230    pub max_in_flight: usize,
231}
232
233#[derive(Debug, Default)]
234struct ServerCounters {
235    accepted: AtomicUsize,
236    completed: AtomicUsize,
237    in_flight: AtomicUsize,
238    max_in_flight: AtomicUsize,
239}
240
241impl CassetteServer {
242    /// Starts an offline loopback server.
243    ///
244    /// # Errors
245    ///
246    /// Returns an error when the loopback listener cannot be created.
247    pub fn start(exchanges: Vec<HttpExchange>) -> Result<Self, CassetteError> {
248        let listener = TcpListener::bind("127.0.0.1:0")?;
249        let address = listener.local_addr()?;
250        let expected = exchanges.len();
251        let observed = Arc::new(Mutex::new(Vec::new()));
252        let failure = Arc::new(Mutex::new(None));
253        let stats = Arc::new(ServerCounters::default());
254        let shutdown = Arc::new(AtomicBool::new(false));
255
256        let thread_observed = Arc::clone(&observed);
257        let thread_failure = Arc::clone(&failure);
258        let thread_stats = Arc::clone(&stats);
259        let thread_shutdown = Arc::clone(&shutdown);
260        let thread = thread::spawn(move || {
261            serve(
262                &listener,
263                exchanges,
264                &thread_observed,
265                &thread_failure,
266                &thread_stats,
267                &thread_shutdown,
268            );
269        });
270
271        Ok(Self {
272            address,
273            expected,
274            observed,
275            failure,
276            stats,
277            shutdown,
278            thread: Some(thread),
279        })
280    }
281
282    /// Starts a server which accepts `request_count` concurrent copies of one
283    /// exchange.
284    ///
285    /// This mode is intended for connection-pool and request-isolation stress
286    /// tests where arrival order is deliberately nondeterministic.
287    ///
288    /// # Errors
289    ///
290    /// Returns an error when the loopback listener cannot be created.
291    pub fn start_repeating(
292        exchange: HttpExchange,
293        request_count: usize,
294    ) -> Result<Self, CassetteError> {
295        let listener = TcpListener::bind("127.0.0.1:0")?;
296        let address = listener.local_addr()?;
297        let observed = Arc::new(Mutex::new(Vec::new()));
298        let failure = Arc::new(Mutex::new(None));
299        let stats = Arc::new(ServerCounters::default());
300        let shutdown = Arc::new(AtomicBool::new(false));
301
302        let thread_observed = Arc::clone(&observed);
303        let thread_failure = Arc::clone(&failure);
304        let thread_stats = Arc::clone(&stats);
305        let thread_shutdown = Arc::clone(&shutdown);
306        let thread = thread::spawn(move || {
307            serve_repeating(
308                &listener,
309                &exchange,
310                request_count,
311                &thread_observed,
312                &thread_failure,
313                &thread_stats,
314                &thread_shutdown,
315            );
316        });
317
318        Ok(Self {
319            address,
320            expected: request_count,
321            observed,
322            failure,
323            stats,
324            shutdown,
325            thread: Some(thread),
326        })
327    }
328
329    /// Returns a base URL suitable for provider configuration.
330    pub fn base_url(&self) -> String {
331        format!("http://{}/", self.address)
332    }
333
334    /// Returns a snapshot of all captured requests.
335    pub fn observed_requests(&self) -> Vec<ObservedRequest> {
336        self.observed
337            .lock()
338            .unwrap_or_else(std::sync::PoisonError::into_inner)
339            .clone()
340    }
341
342    /// Returns current server counters.
343    pub fn stats(&self) -> ServerStats {
344        ServerStats {
345            accepted: self.stats.accepted.load(Ordering::Acquire),
346            completed: self.stats.completed.load(Ordering::Acquire),
347            max_in_flight: self.stats.max_in_flight.load(Ordering::Acquire),
348        }
349    }
350
351    /// Verifies that all exchanges matched and were consumed.
352    ///
353    /// # Errors
354    ///
355    /// Returns a mismatch, server failure, or incomplete-cassette error.
356    pub fn assert_finished(&self) -> Result<(), CassetteError> {
357        if let Some(message) = self
358            .failure
359            .lock()
360            .unwrap_or_else(std::sync::PoisonError::into_inner)
361            .clone()
362        {
363            return Err(CassetteError::RequestMismatch(message));
364        }
365        let observed = self
366            .observed
367            .lock()
368            .unwrap_or_else(std::sync::PoisonError::into_inner)
369            .len();
370        if observed != self.expected {
371            return Err(CassetteError::Incomplete {
372                observed,
373                expected: self.expected,
374            });
375        }
376        Ok(())
377    }
378}
379
380impl Drop for CassetteServer {
381    fn drop(&mut self) {
382        self.shutdown.store(true, Ordering::Release);
383        let _ = TcpStream::connect(self.address);
384        if let Some(thread) = self.thread.take() {
385            let _ = thread.join();
386        }
387    }
388}
389
390fn serve(
391    listener: &TcpListener,
392    exchanges: Vec<HttpExchange>,
393    observed: &Mutex<Vec<ObservedRequest>>,
394    failure: &Mutex<Option<String>>,
395    stats: &ServerCounters,
396    shutdown: &AtomicBool,
397) {
398    for exchange in exchanges {
399        if shutdown.load(Ordering::Acquire) {
400            return;
401        }
402        let Ok((mut stream, _)) = listener.accept() else {
403            set_failure(failure, "failed to accept a connection");
404            return;
405        };
406        if shutdown.load(Ordering::Acquire) {
407            return;
408        }
409        request_started(stats);
410        let request = match read_request(&mut stream) {
411            Ok(request) => request,
412            Err(error) => {
413                set_failure(failure, error.to_string());
414                request_finished(stats, false);
415                return;
416            }
417        };
418        let mismatch = validate_request(&request, &exchange);
419        observed
420            .lock()
421            .unwrap_or_else(std::sync::PoisonError::into_inner)
422            .push(redact(request));
423        if let Err(error) = mismatch {
424            set_failure(failure, error.to_string());
425            let _ = write_simple_error(&mut stream);
426            request_finished(stats, false);
427            return;
428        }
429        if write_response(&mut stream, &exchange.response).is_err() {
430            // A caller may deliberately cancel or time out after the request
431            // has matched. That closes its socket, but must not prevent later
432            // scripted exchanges from being accepted.
433            request_finished(stats, false);
434            continue;
435        }
436        request_finished(stats, true);
437    }
438}
439
440fn serve_repeating(
441    listener: &TcpListener,
442    exchange: &HttpExchange,
443    request_count: usize,
444    observed: &Arc<Mutex<Vec<ObservedRequest>>>,
445    failure: &Arc<Mutex<Option<String>>>,
446    stats: &Arc<ServerCounters>,
447    shutdown: &AtomicBool,
448) {
449    let mut workers = Vec::with_capacity(request_count);
450    for _ in 0..request_count {
451        if shutdown.load(Ordering::Acquire) {
452            break;
453        }
454        let Ok((stream, _)) = listener.accept() else {
455            set_failure(failure, "failed to accept a connection");
456            break;
457        };
458        if shutdown.load(Ordering::Acquire) {
459            break;
460        }
461        let exchange = exchange.clone();
462        let observed = Arc::clone(observed);
463        let failure = Arc::clone(failure);
464        let stats = Arc::clone(stats);
465        workers.push(thread::spawn(move || {
466            handle_repeated(stream, &exchange, &observed, &failure, &stats);
467        }));
468    }
469    for worker in workers {
470        if worker.join().is_err() {
471            set_failure(failure, "cassette connection handler panicked");
472        }
473    }
474}
475
476fn handle_repeated(
477    mut stream: TcpStream,
478    exchange: &HttpExchange,
479    observed: &Mutex<Vec<ObservedRequest>>,
480    failure: &Mutex<Option<String>>,
481    stats: &ServerCounters,
482) {
483    request_started(stats);
484    let request = match read_request(&mut stream) {
485        Ok(request) => request,
486        Err(error) => {
487            set_failure(failure, error.to_string());
488            request_finished(stats, false);
489            return;
490        }
491    };
492    let mismatch = validate_request(&request, exchange);
493    observed
494        .lock()
495        .unwrap_or_else(std::sync::PoisonError::into_inner)
496        .push(redact(request));
497    if let Err(error) = mismatch {
498        set_failure(failure, error.to_string());
499        let _ = write_simple_error(&mut stream);
500        request_finished(stats, false);
501        return;
502    }
503    match write_response(&mut stream, &exchange.response) {
504        Ok(()) => request_finished(stats, true),
505        Err(error) => {
506            set_failure(failure, error.to_string());
507            request_finished(stats, false);
508        }
509    }
510}
511
512fn request_started(stats: &ServerCounters) {
513    stats.accepted.fetch_add(1, Ordering::AcqRel);
514    let current = stats.in_flight.fetch_add(1, Ordering::AcqRel) + 1;
515    stats.max_in_flight.fetch_max(current, Ordering::AcqRel);
516}
517
518fn request_finished(stats: &ServerCounters, completed: bool) {
519    stats.in_flight.fetch_sub(1, Ordering::AcqRel);
520    if completed {
521        stats.completed.fetch_add(1, Ordering::AcqRel);
522    }
523}
524
525fn read_request(stream: &mut TcpStream) -> Result<ObservedRequest, CassetteError> {
526    stream.set_read_timeout(Some(Duration::from_secs(5)))?;
527    let mut bytes = Vec::new();
528    let header_end = loop {
529        let mut buffer = [0_u8; 4096];
530        let count = stream.read(&mut buffer)?;
531        if count == 0 {
532            return Err(CassetteError::RequestMismatch(
533                "connection closed before HTTP headers completed".into(),
534            ));
535        }
536        bytes.extend_from_slice(&buffer[..count]);
537        if let Some(position) = find_subslice(&bytes, b"\r\n\r\n") {
538            break position + 4;
539        }
540        if bytes.len() > 1024 * 1024 {
541            return Err(CassetteError::RequestMismatch(
542                "request headers exceeded 1 MiB".into(),
543            ));
544        }
545    };
546
547    let header_text = std::str::from_utf8(&bytes[..header_end]).map_err(|_| {
548        CassetteError::RequestMismatch("request headers were not valid UTF-8".into())
549    })?;
550    let mut lines = header_text.split("\r\n");
551    let request_line = lines
552        .next()
553        .ok_or_else(|| CassetteError::RequestMismatch("missing request line".into()))?;
554    let mut request_parts = request_line.split_whitespace();
555    let method = required_part(&mut request_parts, "method")?.to_owned();
556    let path = required_part(&mut request_parts, "request target")?.to_owned();
557    let mut headers = BTreeMap::new();
558    for line in lines.filter(|line| !line.is_empty()) {
559        let (name, value) = line.split_once(':').ok_or_else(|| {
560            CassetteError::RequestMismatch(format!("malformed request header `{line}`"))
561        })?;
562        headers.insert(name.trim().to_ascii_lowercase(), value.trim().to_owned());
563    }
564    let content_length = headers.get("content-length").map_or(Ok(0), |value| {
565        value
566            .parse::<usize>()
567            .map_err(|_| CassetteError::RequestMismatch("invalid content-length header".into()))
568    })?;
569    while bytes.len() - header_end < content_length {
570        let mut buffer = [0_u8; 4096];
571        let count = stream.read(&mut buffer)?;
572        if count == 0 {
573            return Err(CassetteError::RequestMismatch(
574                "connection closed before request body completed".into(),
575            ));
576        }
577        bytes.extend_from_slice(&buffer[..count]);
578    }
579    Ok(ObservedRequest {
580        method,
581        path,
582        headers,
583        body: bytes[header_end..header_end + content_length].to_vec(),
584    })
585}
586
587fn required_part<'a>(
588    parts: &mut impl Iterator<Item = &'a str>,
589    name: &str,
590) -> Result<&'a str, CassetteError> {
591    parts
592        .next()
593        .ok_or_else(|| CassetteError::RequestMismatch(format!("request line is missing {name}")))
594}
595
596fn validate_request(
597    request: &ObservedRequest,
598    exchange: &HttpExchange,
599) -> Result<(), CassetteError> {
600    if request.method != exchange.method {
601        return Err(CassetteError::RequestMismatch(format!(
602            "expected method {}, received {}",
603            exchange.method, request.method
604        )));
605    }
606    if request.path != exchange.path {
607        return Err(CassetteError::RequestMismatch(format!(
608            "expected path {}, received {}",
609            exchange.path, request.path
610        )));
611    }
612    if let Some(expected) = &exchange.json_body {
613        let actual = request.json_body()?;
614        if actual != *expected {
615            return Err(CassetteError::RequestMismatch(format!(
616                "JSON body differs: expected {expected}, received {actual}"
617            )));
618        }
619    }
620    Ok(())
621}
622
623fn redact(mut request: ObservedRequest) -> ObservedRequest {
624    for name in [
625        "authorization",
626        "x-api-key",
627        "x-goog-api-key",
628        "api-key",
629        "x-amz-security-token",
630    ] {
631        if let Some(value) = request.headers.get_mut(name) {
632            *value = "[REDACTED]".into();
633        }
634    }
635    request
636}
637
638fn write_response(
639    stream: &mut TcpStream,
640    response: &ScriptedResponse,
641) -> Result<(), CassetteError> {
642    let reason = reason_phrase(response.status);
643    write!(
644        stream,
645        "HTTP/1.1 {} {}\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n",
646        response.status, reason
647    )?;
648    for (name, value) in &response.headers {
649        write!(stream, "{name}: {value}\r\n")?;
650    }
651    write!(stream, "\r\n")?;
652    stream.flush()?;
653    for chunk in &response.chunks {
654        if !chunk.delay.is_zero() {
655            thread::sleep(chunk.delay);
656        }
657        write!(stream, "{:X}\r\n", chunk.body.len())?;
658        stream.write_all(&chunk.body)?;
659        write!(stream, "\r\n")?;
660        stream.flush()?;
661    }
662    if !response.disconnect {
663        match stream.write_all(b"0\r\n\r\n").and_then(|()| stream.flush()) {
664            Ok(()) => {}
665            Err(error) if peer_closed(&error) => {}
666            Err(error) => return Err(error.into()),
667        }
668    }
669    Ok(())
670}
671
672fn peer_closed(error: &std::io::Error) -> bool {
673    matches!(
674        error.kind(),
675        std::io::ErrorKind::BrokenPipe
676            | std::io::ErrorKind::ConnectionAborted
677            | std::io::ErrorKind::ConnectionReset
678    )
679}
680
681fn write_simple_error(stream: &mut TcpStream) -> Result<(), CassetteError> {
682    stream.write_all(
683        b"HTTP/1.1 500 Cassette Mismatch\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
684    )?;
685    Ok(())
686}
687
688fn reason_phrase(status: u16) -> &'static str {
689    match status {
690        200 => "OK",
691        400 => "Bad Request",
692        401 => "Unauthorized",
693        403 => "Forbidden",
694        404 => "Not Found",
695        408 => "Request Timeout",
696        429 => "Too Many Requests",
697        500 => "Internal Server Error",
698        502 => "Bad Gateway",
699        503 => "Service Unavailable",
700        _ => "Scripted",
701    }
702}
703
704fn set_failure(failure: &Mutex<Option<String>>, message: impl Into<String>) {
705    *failure
706        .lock()
707        .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(message.into());
708}
709
710fn find_subslice(haystack: &[u8], needle: &[u8]) -> Option<usize> {
711    haystack
712        .windows(needle.len())
713        .position(|window| window == needle)
714}
715
716#[cfg(test)]
717mod tests {
718    use std::{
719        io::{Read, Write},
720        net::TcpStream,
721    };
722
723    use serde_json::json;
724
725    use super::{CassetteServer, HttpExchange, ResponseChunk, ScriptedResponse, peer_closed};
726
727    #[test]
728    fn peer_close_is_tolerated_only_after_the_scripted_body() {
729        for kind in [
730            std::io::ErrorKind::BrokenPipe,
731            std::io::ErrorKind::ConnectionAborted,
732            std::io::ErrorKind::ConnectionReset,
733        ] {
734            assert!(peer_closed(&std::io::Error::from(kind)));
735        }
736        assert!(!peer_closed(&std::io::Error::from(
737            std::io::ErrorKind::TimedOut
738        )));
739    }
740
741    #[test]
742    fn captures_json_and_redacts_credentials() {
743        let server = CassetteServer::start(vec![
744            HttpExchange::new(
745                "POST",
746                "/messages",
747                ScriptedResponse::ok(vec![ResponseChunk::text("hello")]),
748            )
749            .with_json_body(json!({"prompt": "hi"})),
750        ])
751        .unwrap();
752        let mut stream = TcpStream::connect(server.address).unwrap();
753        stream
754            .write_all(
755                b"POST /messages HTTP/1.1\r\nHost: localhost\r\nX-Api-Key: secret\r\nX-Amz-Security-Token: temporary-secret\r\nContent-Length: 15\r\n\r\n{\"prompt\":\"hi\"}",
756            )
757            .unwrap();
758        let mut response = String::new();
759        stream.read_to_string(&mut response).unwrap();
760
761        assert!(response.contains("5\r\nhello\r\n"));
762        server.assert_finished().unwrap();
763        let observed = server.observed_requests();
764        assert_eq!(observed[0].headers["x-api-key"], "[REDACTED]");
765        assert_eq!(observed[0].headers["x-amz-security-token"], "[REDACTED]");
766        assert_eq!(observed[0].json_body().unwrap(), json!({"prompt": "hi"}));
767    }
768}