Skip to main content

typesafe_system_one/
answers.rs

1//! Response types: answers, usage, models, and hand-rolled decoding with
2//! dotted-path validation errors.
3//!
4//! Answers are decoded by hand from [`serde_json::Value`] (not via derive) so
5//! that every validation failure can carry a dotted field path such as
6//! `answers.tone.confidence`, per the SPEC. An answer with an unrecognized
7//! `type` is not an error: it is kept as an opaque [`Answer::Unknown`] that
8//! carries its raw JSON.
9
10use std::collections::BTreeMap;
11
12use serde_json::Value;
13
14use crate::errors::Error;
15
16/// The answer to a yes/no question: the probability of yes (0 to 1).
17///
18/// There is no confidence value for noul answers.
19#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
20pub struct NoulAnswer {
21    /// Probability of a yes answer, from 0 to 1. Near 0.5 indicates uncertainty.
22    pub noul: f64,
23}
24
25/// The answer to a choice question.
26#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
27pub struct ChoiceAnswer {
28    /// The name of the option with the highest probability.
29    pub choice: String,
30    /// Probability of each option, from 0 to 1; the values sum to ~1.
31    pub probabilities: BTreeMap<String, f64>,
32    /// Confidence in the selected option, from 0 to 1.
33    pub confidence: f64,
34}
35
36impl ChoiceAnswer {
37    /// Returns `(option, probability)` pairs sorted by probability,
38    /// descending.
39    pub fn ranked(&self) -> Vec<(&str, f64)> {
40        let mut ranked: Vec<(&str, f64)> = self
41            .probabilities
42            .iter()
43            .map(|(option, probability)| (option.as_str(), *probability))
44            .collect();
45        ranked.sort_by(|a, b| {
46            b.1.partial_cmp(&a.1)
47                .unwrap_or(std::cmp::Ordering::Equal)
48                .then_with(|| a.0.cmp(b.0))
49        });
50        ranked
51    }
52}
53
54/// The answer to a score question: a probability-weighted average of the
55/// rubric levels.
56#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
57pub struct ScoreAnswer {
58    /// The expected score. It may fall between integer levels.
59    pub score: f64,
60    /// Confidence in the score, from 0 to 1.
61    pub confidence: f64,
62    /// The requested criteria mapped to their score levels, keyed by
63    /// integer level index.
64    pub legend: BTreeMap<u32, Value>,
65    /// Probability of each score level, keyed by integer level index.
66    pub probabilities: BTreeMap<u32, f64>,
67}
68
69/// Any answer, discriminated by its `type` field.
70///
71/// Unknown future types are kept losslessly as [`Answer::Unknown`].
72#[derive(Debug, Clone, PartialEq)]
73#[non_exhaustive]
74pub enum Answer {
75    /// A yes/no answer.
76    Noul(NoulAnswer),
77    /// A one-of-many selection.
78    Choice(ChoiceAnswer),
79    /// A rubric rating.
80    Score(ScoreAnswer),
81    /// An answer whose `type` this client does not know, carrying its raw
82    /// JSON and the type name.
83    Unknown {
84        /// The unrecognized `type` value.
85        kind: String,
86        /// The raw JSON of the answer.
87        raw: Value,
88    },
89}
90
91impl From<NoulAnswer> for Answer {
92    fn from(answer: NoulAnswer) -> Self {
93        Self::Noul(answer)
94    }
95}
96
97impl From<ChoiceAnswer> for Answer {
98    fn from(answer: ChoiceAnswer) -> Self {
99        Self::Choice(answer)
100    }
101}
102
103impl From<ScoreAnswer> for Answer {
104    fn from(answer: ScoreAnswer) -> Self {
105        Self::Score(answer)
106    }
107}
108
109/// Token usage for a request. Both fields default to 0 when the server
110/// omits them, because some gateways omit usage.
111#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
112pub struct Usage {
113    /// Number of billable input tokens used to evaluate the request.
114    pub input_tokens: u64,
115    /// Number of output tokens used to answer the questions. Currently free.
116    pub output_tokens: u64,
117}
118
119/// The response to a [`SystemOneRequest`](crate::SystemOneRequest).
120#[derive(Debug, Clone, PartialEq)]
121pub struct SystemOneResponse {
122    /// The versioned ID of the model that actually answered, which may
123    /// differ from the alias supplied in the request.
124    pub model: String,
125    /// Answers keyed by the question IDs supplied in the request.
126    pub answers: BTreeMap<String, Answer>,
127    /// Token usage, defaulting to 0 per field when the server omits it.
128    pub usage: Usage,
129    /// The `x-typesafe-request-id` response header, when present.
130    pub request_id: Option<String>,
131}
132
133impl SystemOneResponse {
134    /// Returns the noul answer for `id`, when that question was answered as
135    /// a noul.
136    pub fn noul(&self, id: &str) -> Option<&NoulAnswer> {
137        match self.answers.get(id)? {
138            Answer::Noul(answer) => Some(answer),
139            _ => None,
140        }
141    }
142
143    /// Returns the choice answer for `id`, when that question was answered
144    /// as a choice.
145    pub fn choice(&self, id: &str) -> Option<&ChoiceAnswer> {
146        match self.answers.get(id)? {
147            Answer::Choice(answer) => Some(answer),
148            _ => None,
149        }
150    }
151
152    /// Returns the score answer for `id`, when that question was answered as
153    /// a score.
154    pub fn score(&self, id: &str) -> Option<&ScoreAnswer> {
155        match self.answers.get(id)? {
156            Answer::Score(answer) => Some(answer),
157            _ => None,
158        }
159    }
160
161    /// Iterates every `(id, noul answer)` pair.
162    pub fn nouls(&self) -> impl Iterator<Item = (&str, &NoulAnswer)> {
163        self.answers.iter().filter_map(|(id, answer)| match answer {
164            Answer::Noul(answer) => Some((id.as_str(), answer)),
165            _ => None,
166        })
167    }
168
169    /// Iterates every `(id, choice answer)` pair.
170    pub fn choices(&self) -> impl Iterator<Item = (&str, &ChoiceAnswer)> {
171        self.answers.iter().filter_map(|(id, answer)| match answer {
172            Answer::Choice(answer) => Some((id.as_str(), answer)),
173            _ => None,
174        })
175    }
176
177    /// Iterates every `(id, score answer)` pair.
178    pub fn scores(&self) -> impl Iterator<Item = (&str, &ScoreAnswer)> {
179        self.answers.iter().filter_map(|(id, answer)| match answer {
180            Answer::Score(answer) => Some((id.as_str(), answer)),
181            _ => None,
182        })
183    }
184}
185
186/// Metadata describing one available model or alias.
187#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
188pub struct ModelMetadata {
189    /// Model name or alias accepted by a request's `model` field.
190    pub name: String,
191    /// Human-readable description of the model and its capabilities.
192    pub description: String,
193    /// Model release date, formatted as `YYYY-MM-DD`.
194    pub release_date: String,
195}
196
197/// A response validation failure with its dotted field path.
198fn validation_error(
199    status: u16,
200    field_path: &str,
201    request_id: Option<String>,
202    endpoint: &str,
203) -> Error {
204    Error::ResponseValidation {
205        status,
206        field_path: field_path.to_owned(),
207        request_id,
208        endpoint: endpoint.to_owned(),
209    }
210}
211
212/// Builds the validation error for a failed 2xx decode, including the
213/// response's request ID.
214fn decode_error(response: &crate::client::RawResponse, field_path: &str) -> Error {
215    validation_error(
216        response.status,
217        field_path,
218        response.request_id.clone(),
219        &response.endpoint,
220    )
221}
222
223/// Decodes a number field, reporting `path` when missing or not a number.
224fn number(value: &Value, path: &str) -> Result<f64, String> {
225    match value {
226        Value::Number(number) => number.as_f64().ok_or_else(|| path.to_owned()),
227        _ => Err(path.to_owned()),
228    }
229}
230
231/// Decodes a string field, reporting `path` when missing or not a string.
232fn string(value: &Value, path: &str) -> Result<String, String> {
233    match value {
234        Value::String(string) => Ok(string.clone()),
235        _ => Err(path.to_owned()),
236    }
237}
238
239/// Decodes a `{key: number}` map.
240fn number_map(value: &Value, path: &str) -> Result<BTreeMap<String, f64>, String> {
241    let Value::Object(map) = value else {
242        return Err(path.to_owned());
243    };
244    let mut result = BTreeMap::new();
245    for (key, value) in map {
246        let key_path = format!("{path}.{key}");
247        result.insert(key.clone(), number(value, &key_path)?);
248    }
249    Ok(result)
250}
251
252/// Decodes a `{"<level>": number}` map with decimal level-index keys into
253/// integer keys.
254fn level_number_map(value: &Value, path: &str) -> Result<BTreeMap<u32, f64>, String> {
255    let Value::Object(map) = value else {
256        return Err(path.to_owned());
257    };
258    let mut result = BTreeMap::new();
259    for (key, value) in map {
260        let key_path = format!("{path}.{key}");
261        let level: u32 = key.parse().map_err(|_| key_path.clone())?;
262        result.insert(level, number(value, &key_path)?);
263    }
264    Ok(result)
265}
266
267/// Decodes a `{"<level>": string|object|array}` legend map into integer keys.
268fn level_value_map(value: &Value, path: &str) -> Result<BTreeMap<u32, Value>, String> {
269    let Value::Object(map) = value else {
270        return Err(path.to_owned());
271    };
272    let mut result = BTreeMap::new();
273    for (key, value) in map {
274        let key_path = format!("{path}.{key}");
275        // Legend values are string, object, or array (OpenAPI
276        // `ScoreAnswer.legend`); null, booleans, and numbers are invalid.
277        if !matches!(value, Value::String(_) | Value::Object(_) | Value::Array(_)) {
278            return Err(key_path);
279        }
280        let level: u32 = key.parse().map_err(|_| key_path.clone())?;
281        result.insert(level, value.clone());
282    }
283    Ok(result)
284}
285
286/// Decodes one answer, reporting `answers.<id>.<field>` paths on failure.
287fn decode_answer(id: &str, value: &Value) -> Result<Answer, String> {
288    let Value::Object(answer) = value else {
289        return Err(format!("answers.{id}"));
290    };
291    let answer_type = match answer.get("type") {
292        Some(Value::String(answer_type)) => answer_type.as_str(),
293        _ => return Err(format!("answers.{id}.type")),
294    };
295    let field = |name: &str| answer.get(name);
296    match answer_type {
297        "noul" => {
298            let noul = field("noul").ok_or_else(|| format!("answers.{id}.noul"))?;
299            Ok(Answer::Noul(NoulAnswer {
300                noul: number(noul, &format!("answers.{id}.noul"))?,
301            }))
302        }
303        "choice" => {
304            let choice = field("choice").ok_or_else(|| format!("answers.{id}.choice"))?;
305            let probabilities =
306                field("probabilities").ok_or_else(|| format!("answers.{id}.probabilities"))?;
307            let confidence =
308                field("confidence").ok_or_else(|| format!("answers.{id}.confidence"))?;
309            Ok(Answer::Choice(ChoiceAnswer {
310                choice: string(choice, &format!("answers.{id}.choice"))?,
311                probabilities: number_map(probabilities, &format!("answers.{id}.probabilities"))?,
312                confidence: number(confidence, &format!("answers.{id}.confidence"))?,
313            }))
314        }
315        "score" => {
316            let score = field("score").ok_or_else(|| format!("answers.{id}.score"))?;
317            let confidence =
318                field("confidence").ok_or_else(|| format!("answers.{id}.confidence"))?;
319            let legend = field("legend").ok_or_else(|| format!("answers.{id}.legend"))?;
320            let probabilities =
321                field("probabilities").ok_or_else(|| format!("answers.{id}.probabilities"))?;
322            Ok(Answer::Score(ScoreAnswer {
323                score: number(score, &format!("answers.{id}.score"))?,
324                confidence: number(confidence, &format!("answers.{id}.confidence"))?,
325                legend: level_value_map(legend, &format!("answers.{id}.legend"))?,
326                probabilities: level_number_map(
327                    probabilities,
328                    &format!("answers.{id}.probabilities"),
329                )?,
330            }))
331        }
332        other => {
333            tracing::warn!(
334                answer_id = %id,
335                answer_type = %other,
336                "keeping answer with unrecognized type as Unknown"
337            );
338            Ok(Answer::Unknown {
339                kind: other.to_owned(),
340                raw: value.clone(),
341            })
342        }
343    }
344}
345
346use serde::{Deserialize, Serialize};
347
348impl SystemOneResponse {
349    /// Decodes a 2xx response body, validating its structure.
350    ///
351    /// The body must be a JSON object with `model` (string) and `answers`
352    /// (object). Each answer must be an object with a string `type` and
353    /// every field its known `type` requires. Failures produce a
354    /// [`Error::ResponseValidation`] with a dotted field path and the HTTP
355    /// status. Unrecognized answer types are kept as [`Answer::Unknown`].
356    pub(super) fn decode(response: &crate::client::RawResponse) -> Result<Self, Error> {
357        let body: Value =
358            serde_json::from_str(&response.body).map_err(|_| decode_error(response, ""))?;
359        let Value::Object(object) = &body else {
360            return Err(decode_error(response, ""));
361        };
362        let model = string(
363            object
364                .get("model")
365                .ok_or_else(|| decode_error(response, "model"))?,
366            "model",
367        )
368        .map_err(|_| decode_error(response, "model"))?;
369        let answers_value = object
370            .get("answers")
371            .ok_or_else(|| decode_error(response, "answers"))?;
372        let answers_object = match answers_value {
373            Value::Object(map) => map,
374            _ => return Err(decode_error(response, "answers")),
375        };
376        let mut answers: BTreeMap<String, Answer> = BTreeMap::new();
377        for (id, answer_value) in answers_object {
378            let decoded = decode_answer(id.as_str(), answer_value)
379                .map_err(|field_path| decode_error(response, &field_path))?;
380            answers.insert(id.clone(), decoded);
381        }
382        // Usage may be absent (some gateways omit it) and so may either
383        // field, defaulting to 0; a present value of the wrong type is a
384        // validation error, as in the Go client and the Python SDK.
385        let usage = match object.get("usage") {
386            None | Some(Value::Null) => Usage::default(),
387            Some(Value::Object(usage)) => {
388                let tokens = |key: &str| -> Result<u64, Error> {
389                    match usage.get(key) {
390                        None | Some(Value::Null) => Ok(0),
391                        Some(value) => value
392                            .as_u64()
393                            .ok_or_else(|| decode_error(response, &format!("usage.{key}"))),
394                    }
395                };
396                Usage {
397                    input_tokens: tokens("input_tokens")?,
398                    output_tokens: tokens("output_tokens")?,
399                }
400            }
401            Some(_) => return Err(decode_error(response, "usage")),
402        };
403        Ok(Self {
404            model,
405            answers,
406            usage,
407            request_id: response.request_id.clone(),
408        })
409    }
410}
411
412/// Decodes a `GET /v1/models` response.
413/// Requires an answer for every requested question, and that an answer of a
414/// known type matches its question's type. Unknown (newer) answer types and
415/// extra answer IDs pass through. Without this, a typed accessor on a
416/// missing answer returns `None`, which is easy to mistake for a real "no".
417pub(super) fn check_complete(
418    response: &crate::client::RawResponse,
419    answers: &BTreeMap<String, Answer>,
420    questions: &BTreeMap<String, crate::questions::Question>,
421) -> Result<(), Error> {
422    use crate::questions::Question;
423    // BTreeMap iteration is sorted, so the first problem reported is
424    // deterministic.
425    for (id, question) in questions {
426        let Some(answer) = answers.get(id) else {
427            return Err(decode_error(response, &format!("answers.{id}")));
428        };
429        let matches = matches!(
430            (question, answer),
431            (Question::Noul(_), Answer::Noul(_))
432                | (Question::Choice(_), Answer::Choice(_))
433                | (Question::Score(_), Answer::Score(_))
434                | (_, Answer::Unknown { .. })
435        );
436        if !matches {
437            return Err(decode_error(response, &format!("answers.{id}.type")));
438        }
439    }
440    Ok(())
441}
442
443pub(super) fn decode_models(
444    response: &crate::client::RawResponse,
445) -> Result<Vec<ModelMetadata>, Error> {
446    let body: Value =
447        serde_json::from_str(&response.body).map_err(|_| decode_error(response, ""))?;
448    let Value::Object(object) = &body else {
449        return Err(decode_error(response, ""));
450    };
451    let Value::Array(models) = object
452        .get("models")
453        .ok_or_else(|| decode_error(response, "models"))?
454    else {
455        return Err(decode_error(response, "models"));
456    };
457    let mut result = Vec::with_capacity(models.len());
458    for (index, model) in models.iter().enumerate() {
459        // Same path format as the Go client and the Python SDK:
460        // `models[0].release_date`.
461        let path = format!("models[{index}]");
462        let Value::Object(model) = model else {
463            return Err(decode_error(response, &path));
464        };
465        let field = |key: &str| -> Result<String, Error> {
466            let field_path = format!("{path}.{key}");
467            let value = model
468                .get(key)
469                .ok_or_else(|| decode_error(response, &field_path))?;
470            string(value, &field_path).map_err(|p| decode_error(response, &p))
471        };
472        result.push(ModelMetadata {
473            name: field("name")?,
474            description: field("description")?,
475            release_date: field("release_date")?,
476        });
477    }
478    Ok(result)
479}
480
481#[cfg(test)]
482mod tests {
483    use super::*;
484
485    fn raw(body: &str) -> crate::client::RawResponse {
486        crate::client::RawResponse {
487            status: 200,
488            body: body.to_owned(),
489            request_id: None,
490            endpoint: "POST https://api.typesafe.ai/v1/systemone".into(),
491        }
492    }
493
494    #[test]
495    fn decodes_all_three_answer_kinds() {
496        let body = r#"{
497            "model": "jev-1.13.0",
498            "answers": {
499                "spam": {"type": "noul", "noul": 0.98},
500                "tone": {"type": "choice", "choice": "angry", "probabilities": {"angry": 0.8, "calm": 0.1, "excited": 0.1}, "confidence": 0.9},
501                "urgency": {"type": "score", "score": 1.7, "confidence": 0.9, "legend": {"0": "Can wait", "1": "This week", "2": "Today"}, "probabilities": {"0": 0.1, "1": 0.1, "2": 0.8}}
502            },
503            "usage": {"input_tokens": 120, "output_tokens": 12}
504        }"#;
505        let response = SystemOneResponse::decode(&raw(body)).unwrap();
506        assert_eq!(response.model, "jev-1.13.0");
507        assert_eq!(response.noul("spam").unwrap().noul, 0.98);
508        let tone = response.choice("tone").unwrap();
509        assert_eq!(tone.choice, "angry");
510        assert_eq!(tone.confidence, 0.9);
511        assert_eq!(tone.probabilities["angry"], 0.8);
512        let urgency = response.score("urgency").unwrap();
513        assert_eq!(urgency.score, 1.7);
514        assert_eq!(urgency.legend[&2], "Today");
515        assert_eq!(urgency.probabilities[&2], 0.8);
516        assert_eq!(response.usage.input_tokens, 120);
517        assert_eq!(response.usage.output_tokens, 12);
518        assert_eq!(response.nouls().count(), 1);
519        assert_eq!(response.choices().count(), 1);
520        assert_eq!(response.scores().count(), 1);
521        assert!(response.noul("tone").is_none());
522    }
523
524    #[test]
525    fn ranked_sorts_descending() {
526        let tone = ChoiceAnswer {
527            choice: "angry".into(),
528            probabilities: [("calm", 0.1), ("angry", 0.8), ("excited", 0.1)]
529                .into_iter()
530                .map(|(key, value)| (key.to_owned(), value))
531                .collect(),
532            confidence: 0.9,
533        };
534        let ranked = tone.ranked();
535        assert_eq!(ranked[0], ("angry", 0.8));
536        // Ties are broken alphabetically.
537        assert_eq!(ranked[1].0, "calm");
538        assert_eq!(ranked[2].0, "excited");
539    }
540
541    #[test]
542    fn unknown_answer_type_is_kept() {
543        let body = r#"{
544            "model": "jev-1.13.0",
545            "answers": {
546                "spam": {"type": "noul", "noul": 0.98},
547                "future": {"type": "vibes", "vibe": "immaculate", "confidence": 0.5}
548            },
549            "usage": {"input_tokens": 1, "output_tokens": 2}
550        }"#;
551        let response = SystemOneResponse::decode(&raw(body)).unwrap();
552        match response.answers.get("future").unwrap() {
553            Answer::Unknown { kind, raw } => {
554                assert_eq!(kind, "vibes");
555                assert_eq!(raw["vibe"], "immaculate");
556            }
557            other => panic!("expected Unknown, got {other:?}"),
558        }
559        // Typed accessors skip it.
560        assert!(response.noul("future").is_none());
561        assert_eq!(response.nouls().count(), 1);
562    }
563
564    #[test]
565    fn missing_usage_defaults_to_zero() {
566        let body = r#"{"model": "jev-1.13.0", "answers": {"a": {"type": "noul", "noul": 1.0}}}"#;
567        let response = SystemOneResponse::decode(&raw(body)).unwrap();
568        assert_eq!(response.usage, Usage::default());
569    }
570
571    #[test]
572    fn validation_error_field_paths() {
573        // Missing confidence on a choice answer.
574        let body = r#"{"model": "m", "answers": {"tone": {"type": "choice", "choice": "calm", "probabilities": {"calm": 1.0}}}, "usage": {}}"#;
575        let error = SystemOneResponse::decode(&raw(body)).unwrap_err();
576        match error {
577            Error::ResponseValidation {
578                field_path, status, ..
579            } => {
580                assert_eq!(field_path, "answers.tone.confidence");
581                assert_eq!(status, 200);
582            }
583            other => panic!("expected ResponseValidation, got {other:?}"),
584        }
585        // Non-object body.
586        let error = SystemOneResponse::decode(&raw("nope")).unwrap_err();
587        match error {
588            Error::ResponseValidation { field_path, .. } => {
589                assert_eq!(field_path, "");
590            }
591            other => panic!("expected ResponseValidation, got {other:?}"),
592        }
593        // Missing model.
594        let error = SystemOneResponse::decode(&raw(r#"{"answers": {}}"#)).unwrap_err();
595        match error {
596            Error::ResponseValidation { field_path, .. } => {
597                assert_eq!(field_path, "model");
598            }
599            other => panic!("expected ResponseValidation, got {other:?}"),
600        }
601        // Missing answers.
602        let error = SystemOneResponse::decode(&raw(r#"{"model": "m"}"#)).unwrap_err();
603        match error {
604            Error::ResponseValidation { field_path, .. } => {
605                assert_eq!(field_path, "answers");
606            }
607            other => panic!("expected ResponseValidation, got {other:?}"),
608        }
609        // Bad answer type field.
610        let body = r#"{"model": "m", "answers": {"a": {"noul": 1.0}}}"#;
611        let error = SystemOneResponse::decode(&raw(body)).unwrap_err();
612        match error {
613            Error::ResponseValidation { field_path, .. } => {
614                assert_eq!(field_path, "answers.a.type");
615            }
616            other => panic!("expected ResponseValidation, got {other:?}"),
617        }
618        // Non-numeric noul.
619        let body = r#"{"model": "m", "answers": {"a": {"type": "noul", "noul": "yes"}}}"#;
620        let error = SystemOneResponse::decode(&raw(body)).unwrap_err();
621        match error {
622            Error::ResponseValidation { field_path, .. } => {
623                assert_eq!(field_path, "answers.a.noul");
624            }
625            other => panic!("expected ResponseValidation, got {other:?}"),
626        }
627    }
628
629    #[test]
630    fn decodes_models() {
631        let body = r#"{"models": [
632            {"name": "jev-latest", "description": "General-purpose system one model.", "release_date": "2026-09-15"}
633        ]}"#;
634        let models = decode_models(&raw(body)).unwrap();
635        assert_eq!(models.len(), 1);
636        assert_eq!(models[0].name, "jev-latest");
637        assert_eq!(models[0].release_date, "2026-09-15");
638        let error = decode_models(&raw(r#"{}"#)).unwrap_err();
639        match error {
640            Error::ResponseValidation { field_path, .. } => {
641                assert_eq!(field_path, "models");
642            }
643            other => panic!("expected ResponseValidation, got {other:?}"),
644        }
645    }
646}