Skip to main content

typesafe_ai_rs/
types.rs

1//! Question builders, request bodies, and typed API responses.
2
3use 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
16/// JSON content accepted by the API, including text, objects, arrays, and null.
17pub type Entry = Value;
18/// Labels mapped to descriptions; use [`Value::Null`] for an undescribed label.
19pub type ChoiceCriteria = BTreeMap<String, Value>;
20/// Descriptions for the `true` and `false` outcomes of a noul.
21pub type NoulCriteria = BTreeMap<String, Value>;
22/// An ordered rubric whose positions are the integer score levels.
23pub type ScoreCriteria = Vec<Value>;
24/// Questions keyed by the names used to identify their answers.
25pub type Questions = BTreeMap<String, Question>;
26
27/// A yes/no question. Its answer is a probability between zero and one.
28///
29/// `Noul::default()` omits instructions; `Noul::new(Value::Null)` sends explicit null.
30#[derive(Clone, Debug, Default, Serialize)]
31#[serde(tag = "type", rename = "noul")]
32pub struct Noul {
33    /// Optional question text or structured JSON; `Some(Value::Null)` sends null.
34    #[serde(skip_serializing_if = "Option::is_none")]
35    pub instructions: Option<Value>,
36    /// Optional descriptions of the `true` and `false` outcomes.
37    #[serde(skip_serializing_if = "Option::is_none")]
38    pub criteria: Option<Value>,
39}
40
41impl Noul {
42    /// Create a yes/no question with text or structured instructions.
43    pub fn new(instructions: impl Into<Value>) -> Self {
44        Self::default().instructions(instructions)
45    }
46
47    /// Set instructions, preserving an explicitly supplied JSON null.
48    pub fn instructions(mut self, instructions: impl Into<Value>) -> Self {
49        self.instructions = Some(instructions.into());
50        self
51    }
52
53    /// Describe the `true` and/or `false` outcomes. Nested JSON is preserved.
54    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    /// Send an explicit null criteria field instead of omitting it.
64    pub fn null_criteria(mut self) -> Self {
65        self.criteria = Some(Value::Null);
66        self
67    }
68}
69
70/// A question that selects among named alternatives.
71#[derive(Clone, Debug, Serialize)]
72#[serde(tag = "type", rename = "choice")]
73pub struct Choice {
74    /// Optional question text or structured JSON.
75    #[serde(skip_serializing_if = "Option::is_none")]
76    pub instructions: Option<Value>,
77    /// Named alternatives and their descriptions.
78    pub criteria: ChoiceCriteria,
79}
80
81impl Choice {
82    /// Create a choice from label-description pairs, such as `[("yes", "Accept"), ("no", "Reject")]`.
83    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    /// Set instructions, preserving an explicitly supplied JSON null.
98    pub fn instructions(mut self, instructions: impl Into<Value>) -> Self {
99        self.instructions = Some(instructions.into());
100        self
101    }
102}
103
104/// A question that assigns an expected score using an ordered rubric.
105///
106/// The rubric must contain at least one entry. This follows the Python SDK;
107/// the JavaScript SDK currently requires at least two entries.
108#[derive(Clone, Debug, Serialize)]
109#[serde(tag = "type", rename = "score")]
110pub struct Score {
111    /// Optional question text or structured JSON.
112    #[serde(skip_serializing_if = "Option::is_none")]
113    pub instructions: Option<Value>,
114    /// Descriptions in score order, starting with level zero.
115    pub criteria: ScoreCriteria,
116}
117
118impl Score {
119    /// Create a score question from an ordered rubric, such as `["low", "high"]`.
120    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    /// Set instructions, preserving an explicitly supplied JSON null.
128    pub fn instructions(mut self, instructions: impl Into<Value>) -> Self {
129        self.instructions = Some(instructions.into());
130        self
131    }
132}
133
134/// A typed question, or a raw JSON object for additional and future API fields.
135#[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/// State and named questions to evaluate with System One.
169#[derive(Clone, Debug, Serialize)]
170pub struct SystemOneRequest {
171    /// Text or structured JSON to evaluate.
172    pub state: Value,
173    /// Nonempty questions keyed by answer name.
174    pub questions: Questions,
175    /// Optional model override; omitted values use the client's configured model.
176    #[serde(skip_serializing_if = "Option::is_none")]
177    pub model: Option<String>,
178    /// Additional JSON fields, merged after the standard fields as in the Python SDK.
179    #[serde(flatten)]
180    pub extra_body: Map<String, Value>,
181}
182
183impl SystemOneRequest {
184    /// Create a request from state and named questions.
185    ///
186    /// Arrays of pairs and maps are accepted. Convert mixed question types to
187    /// [`Question`] using `.into()` or `Question::from(...)`.
188    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    /// Override the client's model for this request.
205    pub fn model(mut self, model: impl Into<String>) -> Self {
206        self.model = Some(model.into());
207        self
208    }
209
210    /// Add fields that are merged last, including explicit nulls and overrides.
211    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    /// Validate the questions and resolve the model before an HTTP request is sent.
221    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/// The complete response before typed parsing, including unrecognized API fields.
293#[derive(Clone)]
294pub struct RawResponse {
295    /// HTTP response status.
296    pub status: StatusCode,
297    /// Original response headers.
298    pub headers: HeaderMap,
299    /// Original response bytes, retained without rewriting the JSON.
300    pub body: Bytes,
301}
302
303impl RawResponse {
304    /// The `x-typesafe-request-id` header, when present and valid UTF-8.
305    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    /// Decode the complete response body, including unknown API fields.
312    pub fn json(&self) -> Result<Value, serde_json::Error> {
313        serde_json::from_slice(&self.body)
314    }
315
316    /// Read the body as UTF-8, replacing invalid byte sequences.
317    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/// A yes/no answer, expressed as the probability of yes.
334#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
335pub struct NoulAnswer {
336    pub noul: f64,
337}
338
339/// The selected label, its confidence, and the probability of each label.
340#[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/// An expected score, which may fall between integer rubric levels.
348#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
349pub struct ScoreAnswer {
350    pub score: f64,
351    pub confidence: f64,
352    /// JSON string keys are converted to integer score levels on parsing.
353    pub legend: BTreeMap<i64, Value>,
354    pub probabilities: BTreeMap<i64, f64>,
355}
356
357/// An answer identified by its `type` discriminator in the API response.
358#[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/// Token counts, when reported by the API.
390#[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/// Answers keyed by question name, together with model and token usage metadata.
397#[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    /// Parse an HTTP response, skipping future answer types while retaining the raw body.
408    /// Non-2xx statuses produce an API error before response validation.
409    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    /// Borrow all yes/no answers without cloning their contents.
492    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/// Metadata for an available model.
515#[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
522/// The Python SDK calls model cards `ModelMetadata`.
523pub type ModelMetadata = ModelCard;
524
525/// Models available to the account, with raw HTTP metadata.
526#[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    /// Parse a model list and retain HTTP metadata. Non-2xx statuses are errors.
535    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}