1use std::borrow::Cow;
4use std::collections::BTreeMap;
5use std::fmt;
6
7use bytes::Bytes;
8use reqwest::header::HeaderMap;
9use reqwest::StatusCode;
10use serde::de::DeserializeOwned;
11use serde::{Deserialize, Serialize};
12use serde_json::{Map, Value};
13
14use crate::Error;
15
16pub type Entry = Value;
18pub type ChoiceCriteria = BTreeMap<String, Value>;
20pub type NoulCriteria = BTreeMap<String, Value>;
22pub type ScoreCriteria = Vec<Value>;
24pub type Questions = BTreeMap<String, Question>;
26
27#[derive(Clone, Debug, Default, Serialize)]
31#[serde(tag = "type", rename = "noul")]
32pub struct Noul {
33 #[serde(skip_serializing_if = "Option::is_none")]
35 pub instructions: Option<Value>,
36 #[serde(skip_serializing_if = "Option::is_none")]
38 pub criteria: Option<Value>,
39}
40
41impl Noul {
42 pub fn new(instructions: impl Into<Value>) -> Self {
44 Self::default().instructions(instructions)
45 }
46
47 pub fn instructions(mut self, instructions: impl Into<Value>) -> Self {
49 self.instructions = Some(instructions.into());
50 self
51 }
52
53 pub fn criteria<K, V>(mut self, criteria: impl IntoIterator<Item = (K, V)>) -> Self
55 where
56 K: Into<String>,
57 V: Into<Value>,
58 {
59 self.criteria = Some(Value::Object(json_map(criteria)));
60 self
61 }
62
63 pub fn null_criteria(mut self) -> Self {
65 self.criteria = Some(Value::Null);
66 self
67 }
68}
69
70#[derive(Clone, Debug, Serialize)]
72#[serde(tag = "type", rename = "choice")]
73pub struct Choice {
74 #[serde(skip_serializing_if = "Option::is_none")]
76 pub instructions: Option<Value>,
77 pub criteria: ChoiceCriteria,
79}
80
81impl Choice {
82 pub fn new<K, V>(criteria: impl IntoIterator<Item = (K, V)>) -> Self
84 where
85 K: Into<String>,
86 V: Into<Value>,
87 {
88 Self {
89 instructions: None,
90 criteria: criteria
91 .into_iter()
92 .map(|(key, value)| (key.into(), value.into()))
93 .collect(),
94 }
95 }
96
97 pub fn instructions(mut self, instructions: impl Into<Value>) -> Self {
99 self.instructions = Some(instructions.into());
100 self
101 }
102}
103
104#[derive(Clone, Debug, Serialize)]
109#[serde(tag = "type", rename = "score")]
110pub struct Score {
111 #[serde(skip_serializing_if = "Option::is_none")]
113 pub instructions: Option<Value>,
114 pub criteria: ScoreCriteria,
116}
117
118impl Score {
119 pub fn new<V: Into<Value>>(criteria: impl IntoIterator<Item = V>) -> Self {
121 Self {
122 instructions: None,
123 criteria: criteria.into_iter().map(Into::into).collect(),
124 }
125 }
126
127 pub fn instructions(mut self, instructions: impl Into<Value>) -> Self {
129 self.instructions = Some(instructions.into());
130 self
131 }
132}
133
134#[derive(Clone, Debug, Serialize)]
136#[serde(untagged)]
137pub enum Question {
138 Noul(Noul),
139 Choice(Choice),
140 Score(Score),
141 Raw(Value),
142}
143
144impl From<Noul> for Question {
145 fn from(question: Noul) -> Self {
146 Self::Noul(question)
147 }
148}
149
150impl From<Choice> for Question {
151 fn from(question: Choice) -> Self {
152 Self::Choice(question)
153 }
154}
155
156impl From<Score> for Question {
157 fn from(question: Score) -> Self {
158 Self::Score(question)
159 }
160}
161
162impl From<Value> for Question {
163 fn from(question: Value) -> Self {
164 Self::Raw(question)
165 }
166}
167
168#[derive(Clone, Debug, Serialize)]
170pub struct SystemOneRequest {
171 pub state: Value,
173 pub questions: Questions,
175 #[serde(skip_serializing_if = "Option::is_none")]
177 pub model: Option<String>,
178 #[serde(flatten)]
180 pub extra_body: Map<String, Value>,
181}
182
183impl SystemOneRequest {
184 pub fn new<K, Q>(state: impl Into<Value>, questions: impl IntoIterator<Item = (K, Q)>) -> Self
189 where
190 K: Into<String>,
191 Q: Into<Question>,
192 {
193 Self {
194 state: state.into(),
195 questions: questions
196 .into_iter()
197 .map(|(key, question)| (key.into(), question.into()))
198 .collect(),
199 model: None,
200 extra_body: Map::new(),
201 }
202 }
203
204 pub fn model(mut self, model: impl Into<String>) -> Self {
206 self.model = Some(model.into());
207 self
208 }
209
210 pub fn extra_body<K, V>(mut self, fields: impl IntoIterator<Item = (K, V)>) -> Self
212 where
213 K: Into<String>,
214 V: Into<Value>,
215 {
216 self.extra_body.extend(json_map(fields));
217 self
218 }
219
220 pub fn prepare(&self, default_model: &str) -> Result<Value, Error> {
222 if self.questions.is_empty() {
223 return Err(Error::InvalidRequest(
224 "At least one question is required.".into(),
225 ));
226 }
227 let mut questions = Map::new();
228 for (name, question) in &self.questions {
229 let question = serde_json::to_value(question)
230 .map_err(|error| Error::InvalidRequest(error.to_string()))?;
231 validate_question(name, &question)?;
232 questions.insert(name.clone(), question);
233 }
234 let mut body = Map::new();
235 body.insert("state".into(), self.state.clone());
236 body.insert("questions".into(), Value::Object(questions));
237 body.insert(
238 "model".into(),
239 Value::String(self.model.as_deref().unwrap_or(default_model).into()),
240 );
241 body.extend(self.extra_body.clone());
242 Ok(Value::Object(body))
243 }
244}
245
246fn json_map<K, V>(entries: impl IntoIterator<Item = (K, V)>) -> Map<String, Value>
247where
248 K: Into<String>,
249 V: Into<Value>,
250{
251 entries
252 .into_iter()
253 .map(|(key, value)| (key.into(), value.into()))
254 .collect()
255}
256
257fn validate_question(name: &str, question: &Value) -> Result<(), Error> {
258 let kind = question
259 .get("type")
260 .and_then(Value::as_str)
261 .filter(|kind| !kind.is_empty());
262 let Some(kind) = kind else {
263 return Err(Error::InvalidRequest(format!(
264 "Question {name:?} must be an object with a nonempty string \"type\"."
265 )));
266 };
267 if matches!(kind, "choice" | "score") && question.get("criteria").is_none() {
268 return Err(Error::InvalidRequest(format!(
269 "Question {name:?} requires \"criteria\"."
270 )));
271 }
272 if kind == "choice" && !question["criteria"].is_object() {
273 return Err(Error::InvalidRequest(format!(
274 "Choice question {name:?} requires a map of labels to descriptions."
275 )));
276 }
277 if kind == "score" {
278 let criteria = question["criteria"].as_array().ok_or_else(|| {
279 Error::InvalidRequest(format!(
280 "Score question {name:?} requires a list of descriptions indexed by score from zero."
281 ))
282 })?;
283 if criteria.is_empty() {
284 return Err(Error::InvalidRequest(format!(
285 "Score question {name:?} has no criteria; at least one score is required."
286 )));
287 }
288 }
289 Ok(())
290}
291
292#[derive(Clone)]
294pub struct RawResponse {
295 pub status: StatusCode,
297 pub headers: HeaderMap,
299 pub body: Bytes,
301}
302
303impl RawResponse {
304 pub fn request_id(&self) -> Option<&str> {
306 self.headers
307 .get("x-typesafe-request-id")
308 .and_then(|value| value.to_str().ok())
309 }
310
311 pub fn json(&self) -> Result<Value, serde_json::Error> {
313 serde_json::from_slice(&self.body)
314 }
315
316 pub fn text(&self) -> Cow<'_, str> {
318 String::from_utf8_lossy(&self.body)
319 }
320}
321
322impl fmt::Debug for RawResponse {
323 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
324 formatter
325 .debug_struct("RawResponse")
326 .field("status", &self.status)
327 .field("request_id", &self.request_id())
328 .field("body_bytes", &self.body.len())
329 .finish_non_exhaustive()
330 }
331}
332
333#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
335pub struct NoulAnswer {
336 pub noul: f64,
337}
338
339#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
341pub struct ChoiceAnswer {
342 pub choice: String,
343 pub confidence: f64,
344 pub probabilities: BTreeMap<String, f64>,
345}
346
347#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
349pub struct ScoreAnswer {
350 pub score: f64,
351 pub confidence: f64,
352 pub legend: BTreeMap<i64, Value>,
354 pub probabilities: BTreeMap<i64, f64>,
355}
356
357#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
359#[serde(tag = "type", rename_all = "lowercase")]
360pub enum Answer {
361 Noul(NoulAnswer),
362 Choice(ChoiceAnswer),
363 Score(ScoreAnswer),
364}
365
366impl Answer {
367 pub fn as_noul(&self) -> Option<&NoulAnswer> {
368 match self {
369 Self::Noul(answer) => Some(answer),
370 _ => None,
371 }
372 }
373
374 pub fn as_choice(&self) -> Option<&ChoiceAnswer> {
375 match self {
376 Self::Choice(answer) => Some(answer),
377 _ => None,
378 }
379 }
380
381 pub fn as_score(&self) -> Option<&ScoreAnswer> {
382 match self {
383 Self::Score(answer) => Some(answer),
384 _ => None,
385 }
386 }
387}
388
389#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
391pub struct Usage {
392 pub input_tokens: Option<u64>,
393 pub output_tokens: Option<u64>,
394}
395
396#[derive(Clone, Debug, Serialize)]
398pub struct SystemOneResponse {
399 pub model: String,
400 pub usage: Usage,
401 pub answers: BTreeMap<String, Answer>,
402 #[serde(skip)]
403 pub raw_http_response: RawResponse,
404}
405
406impl SystemOneResponse {
407 pub fn from_raw(raw: RawResponse) -> Result<Self, Error> {
410 Self::from_raw_with_log_level(raw, log::LevelFilter::Warn)
411 }
412
413 pub(crate) fn from_raw_with_log_level(
414 raw: RawResponse,
415 log_level: log::LevelFilter,
416 ) -> Result<Self, Error> {
417 let body = parse_response_body(&raw)?;
418 let model = required(&body, "model", "model", &raw)?;
419 let usage_body = body
420 .get("usage")
421 .filter(|value| value.is_object())
422 .ok_or_else(|| invalid_response(&raw, "usage"))?;
423 let usage = Usage {
424 input_tokens: optional(usage_body, "input_tokens", "usage.input_tokens", &raw)?,
425 output_tokens: optional(usage_body, "output_tokens", "usage.output_tokens", &raw)?,
426 };
427 let answer_bodies = body
428 .get("answers")
429 .and_then(Value::as_object)
430 .ok_or_else(|| invalid_response(&raw, "answers"))?;
431 let mut answers = BTreeMap::new();
432 for (name, value) in answer_bodies {
433 let prefix = format!("answers.{name}");
434 let kind: String = required(value, "type", &format!("{prefix}.type"), &raw)?;
435 let answer = match kind.as_str() {
436 "noul" => Answer::Noul(NoulAnswer {
437 noul: required(value, "noul", &format!("{prefix}.noul"), &raw)?,
438 }),
439 "choice" => Answer::Choice(ChoiceAnswer {
440 choice: required(value, "choice", &format!("{prefix}.choice"), &raw)?,
441 confidence: required(
442 value,
443 "confidence",
444 &format!("{prefix}.confidence"),
445 &raw,
446 )?,
447 probabilities: required(
448 value,
449 "probabilities",
450 &format!("{prefix}.probabilities"),
451 &raw,
452 )?,
453 }),
454 "score" => Answer::Score(ScoreAnswer {
455 score: required(value, "score", &format!("{prefix}.score"), &raw)?,
456 confidence: required(
457 value,
458 "confidence",
459 &format!("{prefix}.confidence"),
460 &raw,
461 )?,
462 legend: required(value, "legend", &format!("{prefix}.legend"), &raw)?,
463 probabilities: required(
464 value,
465 "probabilities",
466 &format!("{prefix}.probabilities"),
467 &raw,
468 )?,
469 }),
470 _ => {
471 if log_level >= log::LevelFilter::Warn {
472 log::warn!(target: "typesafe_ai_rs", "Ignoring answer {name:?} with unrecognized type {kind:?}");
473 }
474 continue;
475 }
476 };
477 answers.insert(name.clone(), answer);
478 }
479 Ok(Self {
480 model,
481 usage,
482 answers,
483 raw_http_response: raw,
484 })
485 }
486
487 pub fn request_id(&self) -> Option<&str> {
488 self.raw_http_response.request_id()
489 }
490
491 pub fn nouls(&self) -> BTreeMap<&str, &NoulAnswer> {
493 self.answers
494 .iter()
495 .filter_map(|(name, answer)| answer.as_noul().map(|answer| (name.as_str(), answer)))
496 .collect()
497 }
498
499 pub fn choices(&self) -> BTreeMap<&str, &ChoiceAnswer> {
500 self.answers
501 .iter()
502 .filter_map(|(name, answer)| answer.as_choice().map(|answer| (name.as_str(), answer)))
503 .collect()
504 }
505
506 pub fn scores(&self) -> BTreeMap<&str, &ScoreAnswer> {
507 self.answers
508 .iter()
509 .filter_map(|(name, answer)| answer.as_score().map(|answer| (name.as_str(), answer)))
510 .collect()
511 }
512}
513
514#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
516pub struct ModelCard {
517 pub name: String,
518 pub description: String,
519 pub release_date: String,
520}
521
522pub type ModelMetadata = ModelCard;
524
525#[derive(Clone, Debug, Serialize)]
527pub struct ListModelsResponse {
528 pub models: Vec<ModelCard>,
529 #[serde(skip)]
530 pub raw_http_response: RawResponse,
531}
532
533impl ListModelsResponse {
534 pub fn from_raw(raw: RawResponse) -> Result<Self, Error> {
536 let body = parse_response_body(&raw)?;
537 let model_bodies = body
538 .get("models")
539 .and_then(Value::as_array)
540 .ok_or_else(|| invalid_response(&raw, "models"))?;
541 let mut models = Vec::with_capacity(model_bodies.len());
542 for (index, value) in model_bodies.iter().enumerate() {
543 models.push(ModelCard {
544 name: required(value, "name", &format!("models[{index}].name"), &raw)?,
545 description: required(
546 value,
547 "description",
548 &format!("models[{index}].description"),
549 &raw,
550 )?,
551 release_date: required(
552 value,
553 "release_date",
554 &format!("models[{index}].release_date"),
555 &raw,
556 )?,
557 });
558 }
559 Ok(Self {
560 models,
561 raw_http_response: raw,
562 })
563 }
564
565 pub fn request_id(&self) -> Option<&str> {
566 self.raw_http_response.request_id()
567 }
568}
569
570fn parse_response_body(raw: &RawResponse) -> Result<Value, Error> {
571 if !raw.status.is_success() {
572 let body = if raw.body.is_empty() {
573 Value::Null
574 } else {
575 raw.json()
576 .unwrap_or_else(|_| Value::String(raw.text().into_owned()))
577 };
578 return Err(crate::ApiError::new(raw.status, body, raw.headers.clone(), None).into());
579 }
580 raw.json().map_err(|_| invalid_response(raw, "$"))
581}
582
583fn required<T: DeserializeOwned>(
584 body: &Value,
585 key: &str,
586 path: &str,
587 raw: &RawResponse,
588) -> Result<T, Error> {
589 let value = body.get(key).ok_or_else(|| invalid_response(raw, path))?;
590 serde_json::from_value(value.clone()).map_err(|_| invalid_response(raw, path))
591}
592
593fn optional<T: DeserializeOwned>(
594 body: &Value,
595 key: &str,
596 path: &str,
597 raw: &RawResponse,
598) -> Result<Option<T>, Error> {
599 match body.get(key) {
600 None | Some(Value::Null) => Ok(None),
601 Some(value) => serde_json::from_value(value.clone())
602 .map(Some)
603 .map_err(|_| invalid_response(raw, path)),
604 }
605}
606
607fn invalid_response(raw: &RawResponse, field_path: &str) -> Error {
608 Error::ResponseValidation {
609 field_path: field_path.into(),
610 response: Box::new(raw.clone()),
611 }
612}
613
614#[cfg(test)]
615mod tests {
616 use super::*;
617 use serde_json::json;
618
619 fn raw(body: Value) -> RawResponse {
620 let mut headers = HeaderMap::new();
621 headers.insert("x-typesafe-request-id", "req-42".parse().unwrap());
622 RawResponse {
623 status: StatusCode::OK,
624 headers,
625 body: serde_json::to_vec(&body).unwrap().into(),
626 }
627 }
628
629 #[test]
630 fn questions_preserve_omitted_null_and_nested_fields() {
631 let request = SystemOneRequest::new(
632 Value::Null,
633 [
634 ("omitted", Question::from(Noul::default())),
635 ("explicit", Noul::new(Value::Null).null_criteria().into()),
636 (
637 "choice",
638 Choice::new([("yes", json!({"example": [null, true]}))]).into(),
639 ),
640 (
641 "score",
642 Score::new([Value::Null, json!(["high", {"note": null}])]).into(),
643 ),
644 ("future", json!({"type": "future", "custom": null}).into()),
645 ],
646 )
647 .extra_body([("custom", Value::Null)]);
648 assert_eq!(
649 request.prepare("jev-latest").unwrap(),
650 json!({
651 "state": null,
652 "model": "jev-latest",
653 "custom": null,
654 "questions": {
655 "omitted": {"type": "noul"},
656 "explicit": {"type": "noul", "instructions": null, "criteria": null},
657 "choice": {"type": "choice", "criteria": {"yes": {"example": [null, true]}}},
658 "score": {"type": "score", "criteria": [null, ["high", {"note": null}]]},
659 "future": {"type": "future", "custom": null}
660 }
661 })
662 );
663 }
664
665 #[test]
666 fn invalid_question_structure_is_rejected_before_transport() {
667 for question in [
668 json!({}),
669 json!({"type": ""}),
670 json!({"type": 2}),
671 json!({"type": "choice"}),
672 json!({"type": "choice", "criteria": []}),
673 json!({"type": "score", "criteria": []}),
674 json!({"type": "score", "criteria": {"0": "bad"}}),
675 Value::Null,
676 ] {
677 assert!(matches!(
678 SystemOneRequest::new("state", [("q", question)]).prepare("model"),
679 Err(Error::InvalidRequest(_))
680 ));
681 }
682 assert!(SystemOneRequest::new("state", Questions::new())
683 .prepare("model")
684 .is_err());
685 assert!(
686 SystemOneRequest::new("state", [("q", Score::new(["only"]))])
687 .prepare("model")
688 .is_ok()
689 );
690 }
691
692 #[test]
693 fn model_override_and_extra_body_follow_python_merge_semantics() {
694 let request = SystemOneRequest::new("state", [("q", Noul::new("question"))])
695 .model("override")
696 .extra_body([("model", json!("extra")), ("state", Value::Null)]);
697 let body = request.prepare("default").unwrap();
698 assert_eq!(body["model"], "extra");
699 assert!(body["state"].is_null());
700 }
701
702 #[test]
703 fn typed_answers_preserve_score_levels_and_raw_unknown_answers() {
704 let body = json!({"model": "test", "usage": {"input_tokens": 2, "billing_units": 7}, "answers": {
705 "spam": {"type": "noul", "noul": 0.9},
706 "tone": {"type": "choice", "choice": "calm", "confidence": 0.8, "probabilities": {"calm": 0.8}},
707 "quality": {"type": "score", "score": 0.4, "confidence": 0.8,
708 "legend": {"0": {"examples": ["low", null]}, "1": null}, "probabilities": {"0": 0.6, "1": 0.4}},
709 "future": {"type": "aurora", "value": 3}
710 }});
711 let response = SystemOneResponse::from_raw(raw(body.clone())).unwrap();
712 assert_eq!(response.request_id(), Some("req-42"));
713 assert_eq!(response.nouls()["spam"].noul, 0.9);
714 assert_eq!(response.choices()["tone"].choice, "calm");
715 assert_eq!(response.scores()["quality"].probabilities[&1], 0.4);
716 assert_eq!(
717 response.scores()["quality"].legend[&0],
718 json!({"examples": ["low", null]})
719 );
720 assert_eq!(response.usage.output_tokens, None);
721 assert!(!response.answers.contains_key("future"));
722 assert_eq!(response.raw_http_response.json().unwrap(), body);
723 let exported = serde_json::to_value(&response).unwrap();
724 assert!(exported.get("raw_http_response").is_none());
725 assert_eq!(exported["answers"]["spam"]["type"], "noul");
726 assert!(exported["usage"].get("billing_units").is_none());
727 }
728
729 #[test]
730 fn malformed_responses_report_field_paths_and_keep_http_metadata() {
731 for (answers, path) in [
732 (json!({"n": {"type": "noul"}}), "answers.n.noul"),
733 (
734 json!({"c": {"type": "choice", "choice": "a", "probabilities": {}}}),
735 "answers.c.confidence",
736 ),
737 (
738 json!({"s": {"type": "score", "score": 1, "confidence": 1, "legend": {"x": "bad"}, "probabilities": {}}}),
739 "answers.s.legend",
740 ),
741 (json!({"n": "not an object"}), "answers.n.type"),
742 ] {
743 let error = SystemOneResponse::from_raw(raw(
744 json!({"model": "test", "usage": {}, "answers": answers}),
745 ))
746 .unwrap_err();
747 match error {
748 Error::ResponseValidation {
749 field_path,
750 response,
751 } => {
752 assert_eq!(field_path, path);
753 assert_eq!(response.request_id(), Some("req-42"));
754 }
755 error => panic!("unexpected error: {error}"),
756 }
757 }
758 }
759
760 #[test]
761 fn model_list_errors_identify_the_nested_missing_field() {
762 let error = ListModelsResponse::from_raw(raw(json!({"models": [
763 {"name": "test", "description": "Test", "release_date": "2026-09-14"},
764 {"name": "test", "release_date": "2026-09-14"}
765 ]})))
766 .unwrap_err();
767 match error {
768 Error::ResponseValidation { field_path, .. } => {
769 assert_eq!(field_path, "models[1].description")
770 }
771 error => panic!("unexpected error: {error}"),
772 }
773 }
774
775 #[test]
776 fn public_parsers_reject_error_statuses_even_with_success_shaped_bodies() {
777 let mut response = raw(json!({"model": "test", "usage": {}, "answers": {}, "models": []}));
778 response.status = StatusCode::BAD_REQUEST;
779 for error in [
780 SystemOneResponse::from_raw(response.clone()).unwrap_err(),
781 ListModelsResponse::from_raw(response).unwrap_err(),
782 ] {
783 assert!(matches!(error, Error::Api(_)));
784 assert_eq!(error.status(), Some(StatusCode::BAD_REQUEST));
785 assert_eq!(error.request_id(), Some("req-42"));
786 }
787 }
788}