Skip to main content

forge_system_one/
client.rs

1//! The HTTP client: one POST per question, everything failed mapped to
2//! a typed error, no retries - the caller decides what a failure means.
3
4use std::collections::BTreeMap;
5use std::time::Duration;
6
7use crate::config::{SystemOneConfig, systemone_url};
8use crate::error::SystemOneError;
9use crate::wire::{Answer, AskOutcome, Question, Usage, validate_answer};
10
11/// The question name on the wire. The name is echoed back, never sent to
12/// the model, so one fixed key is enough.
13const QUESTION_KEY: &str = "q";
14
15/// How much of a provider error body the error text carries.
16const ERROR_BODY_LIMIT: usize = 500;
17
18/// Calls one System One endpoint with one configured model.
19pub struct SystemOneClient {
20    http: reqwest::Client,
21    endpoint: String,
22    api_key: Option<String>,
23    model: String,
24    timeout: Duration,
25}
26
27#[derive(serde::Deserialize)]
28struct ResponseEnvelope {
29    model: String,
30    answers: BTreeMap<String, Answer>,
31    usage: Option<Usage>,
32}
33
34impl SystemOneClient {
35    /// The caller builds the `reqwest::Client`, so a boot site can pass
36    /// the `NODE_EXTRA_CA_CERTS` trust client every outbound call uses;
37    /// the config's timeout is applied per request so a client shared
38    /// with another leg cannot widen it.
39    pub fn new(config: &SystemOneConfig, http: reqwest::Client) -> Self {
40        Self {
41            http,
42            endpoint: systemone_url(&config.base_url),
43            api_key: config.api_key.clone(),
44            model: config.model.clone(),
45            timeout: Duration::from_millis(config.timeout_ms),
46        }
47    }
48
49    /// Ask one question about one state; the answer must validate against
50    /// the question it answers.
51    pub async fn ask(
52        &self,
53        state: &serde_json::Value,
54        question: &Question,
55    ) -> Result<AskOutcome, SystemOneError> {
56        let body = serde_json::json!({
57            "model": self.model,
58            "state": state,
59            "questions": { QUESTION_KEY: question },
60        });
61        let mut request = self.http.post(&self.endpoint).json(&body).timeout(self.timeout);
62        if let Some(key) = &self.api_key {
63            request = request.bearer_auth(key);
64        }
65        let response = request.send().await.map_err(|err| transport_or_timeout(&err))?;
66        let status = response.status();
67        let text = response.text().await.map_err(|err| transport_or_timeout(&err))?;
68        if !status.is_success() {
69            return Err(SystemOneError::Http {
70                status: status.as_u16(),
71                body: truncate(&self.redact(&text), ERROR_BODY_LIMIT),
72            });
73        }
74        let envelope: ResponseEnvelope = serde_json::from_str(&text).map_err(|err| {
75            // A serde message quotes the body it was reading, so the same
76            // mask covers it.
77            SystemOneError::InvalidResponse(self.redact(&format!("response did not parse: {err}")))
78        })?;
79        let ResponseEnvelope { model, mut answers, usage } = envelope;
80        let answer = match (answers.len(), answers.remove(QUESTION_KEY)) {
81            (1, Some(answer)) => answer,
82            (1, None) => {
83                return Err(SystemOneError::InvalidResponse(format!(
84                    "response answers are not keyed `{QUESTION_KEY}`, the one question requested"
85                )));
86            }
87            (count, _) => {
88                return Err(SystemOneError::InvalidResponse(format!(
89                    "response carries {count} answers where exactly one was requested"
90                )));
91            }
92        };
93        // The detail quotes provider values - the choice, the legend and
94        // probability keys - so it is masked like the body.
95        validate_answer(question, &answer)
96            .map_err(|detail| SystemOneError::InvalidResponse(self.redact(&detail)))?;
97        Ok(AskOutcome { model, answer, usage })
98    }
99
100    /// The provider's text with the configured key's copies masked; no key
101    /// configured means nothing to mask.
102    fn redact(&self, text: &str) -> String {
103        match self.api_key.as_deref() {
104            Some(key) => redact_key(text, key),
105            None => text.to_owned(),
106        }
107    }
108}
109
110/// A request failure reads as a timeout wherever it struck - the send
111/// or the body read - so a stalled connection never surfaces as a
112/// decode error.
113fn transport_or_timeout(err: &reqwest::Error) -> SystemOneError {
114    if err.is_timeout() {
115        SystemOneError::Timeout
116    } else {
117        SystemOneError::Transport(err.to_string())
118    }
119}
120
121/// The width of the key piece a mask matches on: long enough that it is
122/// key material rather than a scheme, short enough to survive a truncation.
123const TAG: usize = 8;
124
125/// Text with the credential's copies masked, before the body is cut - a
126/// key straddling the cut would otherwise survive as a fragment of itself.
127/// A text carrying no key material comes back untouched, its own formatting
128/// intact.
129///
130/// The error text reaches tool output and the transcript JSONL, neither of
131/// which rotates, so a host reflecting the request (the key rides its auth)
132/// must not put key material there. Two masks cover the shapes a copy
133/// arrives in: the key itself wherever it appears, and any word carrying a
134/// window of it - a reflected copy can be truncated or wrapped across
135/// words, so a word may carry only a piece of the key.
136fn redact_key(text: &str, key: &str) -> String {
137    // Below the tag's own width the key is a placeholder rather than key
138    // material, so only a word that IS the key is masked: masking the
139    // words that merely CONTAIN it eats the diagnostics the body exists for.
140    let tag: String = key.chars().take(TAG).collect();
141    if tag.chars().count() < TAG {
142        if key.is_empty() {
143            return text.to_owned();
144        }
145        if !text.split_whitespace().any(|word| bare_word(word) == key) {
146            return text.to_owned();
147        }
148        return text
149            .split_whitespace()
150            .map(|word| if bare_word(word) == key { "[redacted]" } else { word })
151            .collect::<Vec<_>>()
152            .join(" ");
153    }
154    // Every window of the key, not just its opening: a copy can be
155    // truncated or wrapped, so the piece that survives still carries a
156    // window of it.
157    let windows: Vec<String> =
158        key.chars().collect::<Vec<_>>().windows(TAG).map(|w| w.iter().collect()).collect();
159    if !windows.iter().any(|window| text.contains(window)) {
160        return text.to_owned();
161    }
162    // Whitespace runs normalize in a text that carried key material.
163    text.replace(key, "[redacted]")
164        .split_whitespace()
165        .map(|word| if windows.iter().any(|w| word.contains(w)) { "[redacted]" } else { word })
166        .collect::<Vec<_>>()
167        .join(" ")
168}
169
170/// A word with the punctuation a key cannot hold trimmed off its ends, the
171/// way a JSON body carries one (`"hunter2",` reads as the key).
172fn bare_word(word: &str) -> &str {
173    word.trim_matches(|c: char| !c.is_alphanumeric() && c != '-' && c != '_')
174}
175
176/// A bounded prefix of a provider error body, cut on a char boundary.
177fn truncate(text: &str, limit: usize) -> String {
178    if text.len() <= limit {
179        return text.to_owned();
180    }
181    let mut cut = limit;
182    while !text.is_char_boundary(cut) {
183        cut -= 1;
184    }
185    format!("{}...", &text[..cut])
186}
187
188#[cfg(test)]
189mod tests {
190    use std::sync::Arc;
191    use std::time::Duration;
192
193    use tokio::sync::Mutex;
194
195    use super::*;
196    use crate::wire::NoulCriteria;
197
198    /// One request the test server saw.
199    #[derive(Debug)]
200    struct Recorded {
201        body: serde_json::Value,
202        authorization: Option<String>,
203    }
204
205    /// Spawn a one-route server that records what it was sent and answers
206    /// with the canned (status, body), optionally after a delay.
207    async fn spawn_server(
208        status: u16,
209        response_body: &str,
210        delay: Option<Duration>,
211    ) -> (String, Arc<Mutex<Vec<Recorded>>>) {
212        let requests: Arc<Mutex<Vec<Recorded>>> = Arc::new(Mutex::new(Vec::new()));
213        let recorder = Arc::clone(&requests);
214        let body_for_handler: Arc<String> = Arc::new(response_body.to_owned());
215        let app = axum::Router::new().route(
216            "/v1/systemone",
217            axum::routing::post(move |headers: axum::http::HeaderMap, body: axum::body::Bytes| {
218                let recorder = Arc::clone(&recorder);
219                let body_for_handler = Arc::clone(&body_for_handler);
220                async move {
221                    let parsed = serde_json::from_slice(&body).unwrap_or(serde_json::Value::Null);
222                    let authorization = headers
223                        .get(axum::http::header::AUTHORIZATION)
224                        .and_then(|value| value.to_str().ok())
225                        .map(str::to_owned);
226                    recorder.lock().await.push(Recorded { body: parsed, authorization });
227                    if let Some(delay) = delay {
228                        tokio::time::sleep(delay).await;
229                    }
230                    (
231                        axum::http::StatusCode::from_u16(status).expect("test status"),
232                        (*body_for_handler).clone(),
233                    )
234                }
235            }),
236        );
237        let listener =
238            tokio::net::TcpListener::bind("127.0.0.1:0").await.expect("test listener binds");
239        let addr = listener.local_addr().expect("bound address");
240        tokio::spawn(async move {
241            axum::serve(listener, app).await.expect("test server serves");
242        });
243        (format!("http://127.0.0.1:{}", addr.port()), requests)
244    }
245
246    fn config_for(base: &str, api_key: Option<&str>) -> SystemOneConfig {
247        SystemOneConfig {
248            base_url: base.to_owned(),
249            api_key: api_key.map(str::to_owned),
250            model: "test-model".to_owned(),
251            timeout_ms: 5_000,
252        }
253    }
254
255    fn client_for(base: &str, api_key: Option<&str>) -> SystemOneClient {
256        SystemOneClient::new(&config_for(base, api_key), reqwest::Client::new())
257    }
258
259    fn noul_question() -> Question {
260        Question::Noul { instructions: serde_json::json!("Is this a bug?"), criteria: None }
261    }
262
263    fn choice_question() -> Question {
264        Question::Choice {
265            instructions: serde_json::json!("Which team?"),
266            criteria: [
267                ("billing".to_owned(), serde_json::Value::Null),
268                ("technical".to_owned(), serde_json::Value::Null),
269            ]
270            .into_iter()
271            .collect(),
272        }
273    }
274
275    /// Ok(()) means the answer validated; the canned Noul answers 0.5.
276    const NOUL_OK_BODY: &str = r#"{"model":"test-model","answers":{"q":{"type":"noul","noul":0.83}},"usage":{"input_tokens":10,"output_tokens":3}}"#;
277
278    fn http_error(err: SystemOneError) -> (u16, String) {
279        match err {
280            SystemOneError::Http { status, body } => (status, body),
281            other => panic!("expected an Http error, got {other:?}"),
282        }
283    }
284
285    fn invalid_response(err: SystemOneError) -> String {
286        match err {
287            SystemOneError::InvalidResponse(detail) => detail,
288            other => panic!("expected an InvalidResponse, got {other:?}"),
289        }
290    }
291
292    #[tokio::test]
293    async fn noul_round_trips_and_sends_the_wire_body() {
294        let (base, requests) = spawn_server(200, NOUL_OK_BODY, None).await;
295        let client = client_for(&base, Some("k"));
296
297        let outcome = client
298            .ask(&serde_json::json!({"ticket": "x"}), &noul_question())
299            .await
300            .expect("request succeeds");
301
302        assert_eq!(
303            outcome,
304            AskOutcome {
305                model: "test-model".to_owned(),
306                answer: Answer::Noul { noul: 0.83 },
307                usage: Some(Usage { input_tokens: 10, output_tokens: 3, cost: None }),
308            }
309        );
310        let seen = requests.lock().await;
311        assert_eq!(
312            seen[0].body,
313            serde_json::json!({"model":"test-model","state":{"ticket":"x"},"questions":{"q":{"type":"noul","instructions":"Is this a bug?"}}})
314        );
315        assert_eq!(seen[0].authorization.as_deref(), Some("Bearer k"));
316    }
317
318    /// The API's own typing carried end to end: structured `instructions`
319    /// and criteria values reach the request body verbatim.
320    #[tokio::test]
321    async fn structured_question_reaches_the_request_body() {
322        let (base, requests) = spawn_server(200, NOUL_OK_BODY, None).await;
323        let client = client_for(&base, Some("k"));
324
325        let noul = Question::Noul {
326            instructions: serde_json::json!({
327                "question": "Is the claim `just check` green?",
328                "evidence": {"command": "just check", "verdict": "all green"}
329            }),
330            criteria: Some(NoulCriteria {
331                r#true: serde_json::json!({"rule": "the verdict line says all green"}),
332                r#false: serde_json::json!("anything else"),
333            }),
334        };
335        client
336            .ask(&serde_json::json!({"ticket": "x"}), &noul)
337            .await
338            .expect("the structured noul request succeeds");
339
340        let choice = Question::Choice {
341            instructions: serde_json::json!("Which team owns `auth.rs`?"),
342            criteria: [
343                ("billing".to_owned(), serde_json::json!({"files": ["a.rs"]})),
344                ("frontend".to_owned(), serde_json::Value::Null),
345            ]
346            .into_iter()
347            .collect(),
348        };
349        let choice_body = r#"{"model":"test-model","answers":{"q":{"type":"choice","choice":"billing"}},"usage":{"input_tokens":10,"output_tokens":3}}"#;
350        let (choice_base, choice_requests) = spawn_server(200, choice_body, None).await;
351        client_for(&choice_base, Some("k"))
352            .ask(&serde_json::json!("x"), &choice)
353            .await
354            .expect("the structured choice request succeeds");
355
356        let seen = requests.lock().await;
357        assert_eq!(
358            seen[0].body,
359            serde_json::json!({
360                "model": "test-model",
361                "state": {"ticket": "x"},
362                "questions": {"q": {
363                    "type": "noul",
364                    "instructions": {
365                        "question": "Is the claim `just check` green?",
366                        "evidence": {"command": "just check", "verdict": "all green"}
367                    },
368                    "criteria": {
369                        "true": {"rule": "the verdict line says all green"},
370                        "false": "anything else"
371                    }
372                }}
373            }),
374            "structured noul values reach the request body verbatim"
375        );
376        let choice_seen = choice_requests.lock().await;
377        assert_eq!(
378            choice_seen[0].body,
379            serde_json::json!({
380                "model": "test-model",
381                "state": "x",
382                "questions": {"q": {
383                    "type": "choice",
384                    "instructions": "Which team owns `auth.rs`?",
385                    "criteria": {"billing": {"files": ["a.rs"]}, "frontend": null}
386                }}
387            }),
388            "structured choice option values reach the request body verbatim"
389        );
390    }
391
392    #[tokio::test]
393    async fn no_key_sends_no_authorization_header() {
394        let (base, requests) = spawn_server(200, NOUL_OK_BODY, None).await;
395        let client = client_for(&base, None);
396
397        client.ask(&serde_json::json!("x"), &noul_question()).await.expect("request succeeds");
398
399        let seen = requests.lock().await;
400        assert_eq!(seen[0].authorization, None);
401    }
402
403    #[tokio::test]
404    async fn typesafe_detail_body_is_carried() {
405        let body =
406            r#"{"detail":[{"type":"missing","loc":["body","model"],"msg":"Field required"}]}"#;
407        let (base, _) = spawn_server(422, body, None).await;
408        let client = client_for(&base, Some("k"));
409
410        let err = client
411            .ask(&serde_json::json!("x"), &noul_question())
412            .await
413            .expect_err("422 is an error");
414
415        let (status, body) = http_error(err);
416        assert_eq!(status, 422);
417        assert!(body.contains("\"missing\""), "{body}");
418    }
419
420    #[tokio::test]
421    async fn openrouter_error_envelope_is_carried() {
422        let body = r#"{"error":{"code":429,"message":"Rate limit exceeded"}}"#;
423        let (base, _) = spawn_server(429, body, None).await;
424        let client = client_for(&base, Some("k"));
425
426        let err = client
427            .ask(&serde_json::json!("x"), &noul_question())
428            .await
429            .expect_err("429 is an error");
430
431        let (status, body) = http_error(err);
432        assert_eq!(status, 429);
433        assert!(body.contains("Rate limit exceeded"), "{body}");
434    }
435
436    #[tokio::test]
437    async fn html_error_body_is_carried() {
438        let (base, _) = spawn_server(504, "<html><body>gateway timeout</body></html>", None).await;
439        let client = client_for(&base, Some("k"));
440
441        let err = client
442            .ask(&serde_json::json!("x"), &noul_question())
443            .await
444            .expect_err("504 is an error");
445
446        let (status, body) = http_error(err);
447        assert_eq!(status, 504);
448        assert!(body.starts_with("<html>"), "{body}");
449    }
450
451    #[tokio::test]
452    async fn a_dead_port_is_a_transport_error() {
453        let listener =
454            tokio::net::TcpListener::bind("127.0.0.1:0").await.expect("test listener binds");
455        let addr = listener.local_addr().expect("bound address");
456        drop(listener);
457        let client = client_for(&format!("http://127.0.0.1:{}", addr.port()), Some("k"));
458
459        let err = client
460            .ask(&serde_json::json!("x"), &noul_question())
461            .await
462            .expect_err("refused connection is an error");
463
464        assert!(matches!(err, SystemOneError::Transport(_)), "{err:?}");
465    }
466
467    /// A server that sends headers and half a body, then stalls: the
468    /// stall sits inside the body read, and it must still read as a
469    /// timeout - not as a transport failure.
470    #[tokio::test]
471    async fn a_stalled_body_read_times_out() {
472        let listener =
473            tokio::net::TcpListener::bind("127.0.0.1:0").await.expect("test listener binds");
474        let addr = listener.local_addr().expect("bound address");
475        let hold = tokio::spawn(async move {
476            use tokio::io::{AsyncReadExt, AsyncWriteExt};
477            let (mut stream, _) = listener.accept().await.expect("accept");
478            // **A real server reads the request before it answers.**
479            // Writing first let the response overtake the client's own
480            // write, and hyper then reported a transport error instead of
481            // the body stall this test is about.
482            let mut request = Vec::new();
483            let mut chunk = [0_u8; 1024];
484            while !request.windows(4).any(|window| window == b"\r\n\r\n") {
485                let read = stream.read(&mut chunk).await.expect("read the request head");
486                assert!(read > 0, "the client's request ended before its head");
487                request.extend_from_slice(&chunk[..read]);
488            }
489            let head_end =
490                request.windows(4).position(|window| window == b"\r\n\r\n").expect("the head ends")
491                    + 4;
492            let body_len = String::from_utf8_lossy(&request[..head_end])
493                .lines()
494                .find_map(|line| {
495                    let (name, value) = line.split_once(':')?;
496                    name.eq_ignore_ascii_case("content-length")
497                        .then(|| value.trim().parse::<usize>().ok())
498                        .flatten()
499                })
500                .unwrap_or(0);
501            while request.len() < head_end + body_len {
502                let read = stream.read(&mut chunk).await.expect("read the request body");
503                assert!(read > 0, "the client's request ended before its body");
504                request.extend_from_slice(&chunk[..read]);
505            }
506            stream
507                .write_all(b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 1000\r\n\r\n{\"model\":")
508                .await
509                .expect("write headers and a partial body");
510            tokio::time::sleep(Duration::from_secs(10)).await;
511        });
512        let mut config = config_for(&format!("http://127.0.0.1:{}", addr.port()), Some("k"));
513        config.timeout_ms = 100;
514        let client = SystemOneClient::new(&config, reqwest::Client::new());
515
516        let err = client
517            .ask(&serde_json::json!("x"), &noul_question())
518            .await
519            .expect_err("a stalled body is an error");
520
521        assert!(matches!(err, SystemOneError::Timeout), "{err:?}");
522        hold.abort();
523    }
524
525    #[tokio::test]
526    async fn a_slow_server_times_out() {
527        let (base, _) = spawn_server(200, NOUL_OK_BODY, Some(Duration::from_millis(200))).await;
528        let mut config = config_for(&base, Some("k"));
529        config.timeout_ms = 50;
530        let client = SystemOneClient::new(&config, reqwest::Client::new());
531
532        let err = client
533            .ask(&serde_json::json!("x"), &noul_question())
534            .await
535            .expect_err("a slow response is an error");
536
537        assert!(matches!(err, SystemOneError::Timeout), "{err:?}");
538    }
539
540    #[tokio::test]
541    async fn choice_key_drift_is_an_invalid_response() {
542        let body = r#"{"model":"m","answers":{"q":{"type":"choice","choice":"billing","probabilities":{"billing":0.9,"sales":0.1}}},"usage":{"input_tokens":1,"output_tokens":1}}"#;
543        let (base, _) = spawn_server(200, body, None).await;
544        let client = client_for(&base, Some("k"));
545
546        let err = client
547            .ask(&serde_json::json!("x"), &choice_question())
548            .await
549            .expect_err("drifted keys are an error");
550
551        assert!(invalid_response(err).starts_with("probability keys"));
552    }
553
554    #[tokio::test]
555    async fn extra_response_fields_are_ignored() {
556        let body = r#"{"id":"gen-1","provider":"TypeSafe","model":"m","answers":{"q":{"type":"noul","noul":0.5}},"usage":{"input_tokens":1,"output_tokens":1,"cost":0.0001}}"#;
557        let (base, _) = spawn_server(200, body, None).await;
558        let client = client_for(&base, Some("k"));
559
560        let outcome = client
561            .ask(&serde_json::json!("x"), &noul_question())
562            .await
563            .expect("extra fields do not break parsing");
564
565        assert_eq!(outcome.usage.expect("usage present").cost, Some(0.0001));
566    }
567
568    #[tokio::test]
569    async fn missing_usage_leaves_the_answer_standing() {
570        let body = r#"{"model":"m","answers":{"q":{"type":"noul","noul":0.5}}}"#;
571        let (base, _) = spawn_server(200, body, None).await;
572        let client = client_for(&base, Some("k"));
573
574        let outcome = client
575            .ask(&serde_json::json!("x"), &noul_question())
576            .await
577            .expect("a delivered decision stands without usage");
578
579        assert_eq!(outcome.usage, None, "an absent usage is None, never invented zeros");
580        assert_eq!(outcome.answer, Answer::Noul { noul: 0.5 });
581    }
582
583    #[tokio::test]
584    async fn a_long_error_body_is_bounded_and_marked() {
585        let body = format!("START {} END", "x".repeat(10_000));
586        let (base, _) = spawn_server(500, &body, None).await;
587        let client = client_for(&base, Some("k"));
588
589        let err = client
590            .ask(&serde_json::json!("x"), &noul_question())
591            .await
592            .expect_err("500 is an error");
593
594        let (_, text) = http_error(err);
595        assert!(text.starts_with("START "), "{text}");
596        assert!(text.ends_with("..."), "a bounded snippet says it was cut: {text}");
597        assert!(text.len() <= 503, "the snippet stays bounded: {}", text.len());
598    }
599
600    /// The cut walks back to a char boundary; a boundary-unaware slice
601    /// would panic right here on the multi-byte run the limit lands in,
602    /// which is the property this test rests on.
603    #[tokio::test]
604    async fn a_multibyte_error_body_cuts_on_a_char_boundary() {
605        let body = format!("A{}Z", "é".repeat(1000));
606        let (base, _) = spawn_server(500, &body, None).await;
607        let client = client_for(&base, Some("k"));
608
609        let err = client
610            .ask(&serde_json::json!("x"), &noul_question())
611            .await
612            .expect_err("500 is an error");
613
614        let (_, text) = http_error(err);
615        assert!(text.starts_with('A'), "{text}");
616        assert!(text.ends_with("..."), "{text}");
617    }
618
619    /// The error body reaches tool output and the transcript JSONL, which
620    /// do not rotate: a host reflecting the request (the key rides its
621    /// auth) must never print key material there - the key itself, and a
622    /// copy wrapped across words, each piece carrying a run of the key.
623    #[tokio::test]
624    async fn a_reflected_key_in_an_error_body_never_prints() {
625        let key = "sk-live-EXAMPLEKEYVALUE";
626        let body = format!(
627            "origin echoed the request: {key} and the header \"Authorization: Bearer {key}\" \
628             and a wrapped echo: {} {}",
629            &key[..12],
630            &key[12..]
631        );
632        let (base, _) = spawn_server(502, &body, None).await;
633        let client = client_for(&base, Some(key));
634
635        let err = client
636            .ask(&serde_json::json!("x"), &noul_question())
637            .await
638            .expect_err("502 is an error");
639
640        let (status, text) = http_error(err);
641        assert_eq!(status, 502);
642        assert!(!text.contains(key), "the key itself never prints: {text}");
643        assert!(!text.contains(&key[..12]), "a wrapped copy's head never prints: {text}");
644        assert!(!text.contains(&key[12..]), "a wrapped copy's tail never prints: {text}");
645        assert!(
646            text.contains("\"Authorization: Bearer [redacted]\""),
647            "the verbatim echo is masked in place, its header kept: {text}"
648        );
649        assert!(
650            text.contains("origin echoed the request"),
651            "the body is masked, not dropped: {text}"
652        );
653    }
654
655    /// The parse-failure detail quotes the body serde was reading, and it
656    /// lands in the same tool output: the key is masked there too.
657    #[tokio::test]
658    async fn a_reflected_key_in_a_parse_failure_never_prints() {
659        let key = "sk-live-EXAMPLEKEYVALUE";
660        let (base, _) = spawn_server(200, &format!("\"upstream echoed: {key}\""), None).await;
661        let client = client_for(&base, Some(key));
662
663        let err = client
664            .ask(&serde_json::json!("x"), &noul_question())
665            .await
666            .expect_err("a body that is not an envelope is an error");
667
668        let detail = invalid_response(err);
669        assert!(
670            detail.starts_with("response did not parse"),
671            "a parse failure is still named as one"
672        );
673        assert!(!detail.contains(key), "the parse-failure detail never prints the key");
674    }
675
676    /// The validation detail is built from provider strings - the choice it
677    /// was given, legend and probability keys - and it lands in the same
678    /// tool output: the key is masked there too.
679    #[tokio::test]
680    async fn a_reflected_key_in_a_validation_detail_never_prints() {
681        let key = "sk-live-EXAMPLEKEYVALUE";
682        let body = format!(
683            r#"{{"model":"m","answers":{{"q":{{"type":"choice","choice":"{key}"}}}},"usage":null}}"#
684        );
685        let (base, _) = spawn_server(200, &body, None).await;
686        let client = client_for(&base, Some(key));
687
688        let err = client
689            .ask(&serde_json::json!("x"), &choice_question())
690            .await
691            .expect_err("a choice outside the criteria is an error");
692
693        let detail = invalid_response(err);
694        assert!(
695            detail.contains("not one of the criteria keys"),
696            "the validation failure keeps its own wording"
697        );
698        assert!(!detail.contains(key), "the validation detail never prints the key");
699    }
700
701    /// A body carrying no key material comes back byte-identical: the mask
702    /// is a no-op there, so a clean body's own newlines and indentation
703    /// survive. Each mask path guards its no-op separately - a key at or
704    /// over the tag width, and one below it - so both keys run.
705    #[tokio::test]
706    async fn a_body_without_the_key_is_untouched() {
707        let body = "<html>\n  <body>gateway timeout</body>\n</html>";
708        for key in ["sk-live-EXAMPLEKEYVALUE", "hunter2"] {
709            let (base, _) = spawn_server(504, body, None).await;
710            let client = client_for(&base, Some(key));
711
712            let err = client
713                .ask(&serde_json::json!("x"), &noul_question())
714                .await
715                .expect_err("504 is an error");
716
717            let (_, text) = http_error(err);
718            assert_eq!(text, body, "a keyless body is not reflowed (key `{key}`)");
719        }
720    }
721
722    /// A key below the tag width is masked only where a word IS it, so a
723    /// placeholder does not eat the diagnostics around it. The masked word
724    /// goes whole, punctuation and all (`"hunter2",` reads as the key). The
725    /// glued compact-JSON shape (`{"token":"hunter2"}`) stays unmasked by
726    /// design: its word is not the key, and masking every word containing
727    /// the placeholder is what the width rule avoids.
728    #[tokio::test]
729    async fn a_short_key_masks_the_word_that_is_it() {
730        let (base, _) = spawn_server(500, "upstream said: \"hunter2\", apparently", None).await;
731        let client = client_for(&base, Some("hunter2"));
732
733        let err = client
734            .ask(&serde_json::json!("x"), &noul_question())
735            .await
736            .expect_err("500 is an error");
737
738        let (_, text) = http_error(err);
739        assert_eq!(
740            text, "upstream said: [redacted] apparently",
741            "the word that carries the key is masked whole, its neighbours kept"
742        );
743    }
744
745    /// The mask runs over the whole body, before the cut: the cut can land
746    /// a few characters into the key, below the width the windowed masks
747    /// match on, so a key in its reach would otherwise survive as a
748    /// fragment of itself.
749    #[tokio::test]
750    async fn the_key_is_masked_before_the_body_is_cut() {
751        let key = "sk-live-EXAMPLEKEYVALUE";
752        let body = format!("{}{key} tail", "x".repeat(496));
753        let (base, _) = spawn_server(500, &body, None).await;
754        let client = client_for(&base, Some(key));
755
756        let err = client
757            .ask(&serde_json::json!("x"), &noul_question())
758            .await
759            .expect_err("500 is an error");
760
761        let (_, text) = http_error(err);
762        assert!(text.ends_with("..."), "the body reached the cut this pins: {text}");
763        assert!(!text.contains(&key[..4]), "no fragment of the key survives the cut: {text}");
764    }
765
766    #[tokio::test]
767    async fn answer_key_mismatch_is_an_invalid_response() {
768        let body = r#"{"model":"m","answers":{"x":{"type":"noul","noul":0.5}},"usage":{"input_tokens":1,"output_tokens":1}}"#;
769        let (base, _) = spawn_server(200, body, None).await;
770        let client = client_for(&base, Some("k"));
771
772        let err = client
773            .ask(&serde_json::json!("x"), &noul_question())
774            .await
775            .expect_err("a mismatched answer key is an error");
776
777        let detail = invalid_response(err);
778        assert!(detail.contains("not keyed `q`"), "the wording names what came instead: {detail}");
779        assert!(detail.contains("requested"), "and what was asked for: {detail}");
780    }
781
782    /// Extra answers beside the requested one: the wording must name the
783    /// count, not dress up the present key as the problem.
784    #[tokio::test]
785    async fn multiple_answers_are_an_invalid_response() {
786        let body = r#"{"model":"m","answers":{"q":{"type":"noul","noul":0.5},"extra":{"type":"noul","noul":0.1}},"usage":{"input_tokens":1,"output_tokens":1}}"#;
787        let (base, _) = spawn_server(200, body, None).await;
788        let client = client_for(&base, Some("k"));
789
790        let err = client
791            .ask(&serde_json::json!("x"), &noul_question())
792            .await
793            .expect_err("extra answers are an error");
794
795        let detail = invalid_response(err);
796        assert!(detail.contains("carries 2 answers"), "the wording names the count: {detail}");
797        assert!(detail.contains("exactly one was requested"), "{detail}");
798    }
799}