1use 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
11const QUESTION_KEY: &str = "q";
14
15const ERROR_BODY_LIMIT: usize = 500;
17
18pub 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 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 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 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 validate_answer(question, &answer)
96 .map_err(|detail| SystemOneError::InvalidResponse(self.redact(&detail)))?;
97 Ok(AskOutcome { model, answer, usage })
98 }
99
100 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
110fn 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
121const TAG: usize = 8;
124
125fn redact_key(text: &str, key: &str) -> String {
137 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 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 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
170fn bare_word(word: &str) -> &str {
173 word.trim_matches(|c: char| !c.is_alphanumeric() && c != '-' && c != '_')
174}
175
176fn 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 #[derive(Debug)]
200 struct Recorded {
201 body: serde_json::Value,
202 authorization: Option<String>,
203 }
204
205 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 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 #[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 #[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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}