Skip to main content

typesafe/
response.rs

1//! Answers and response metadata.
2//!
3//! The answer types serialize to the JSON the API sends, so a response can be stored and read
4//! back: an [`Answer`] carries its wire `type` tag, and a [`SystemOneResponse`] serializes to its
5//! `{"model", "answers", "usage"}` body.
6
7use std::collections::BTreeMap;
8use std::fmt;
9use std::str::FromStr;
10
11use http::StatusCode;
12use http::header::HeaderMap;
13use indexmap::IndexMap;
14use serde::de::{self, DeserializeOwned, Deserializer};
15use serde::ser::{SerializeMap, SerializeStruct, Serializer};
16use serde::{Deserialize, Serialize};
17use serde_json::Value;
18use serde_json::value::RawValue;
19
20use crate::constants::REQUEST_ID_HEADER;
21
22/// A yes/no answer.
23#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
24#[non_exhaustive]
25pub struct NoulAnswer {
26    /// Probability of "yes", from 0 to 1.
27    pub noul: f64,
28}
29
30impl NoulAnswer {
31    /// `noul >= threshold`.
32    pub fn is_yes(&self, threshold: f64) -> bool {
33        self.noul >= threshold
34    }
35}
36
37/// A selected option with its distribution.
38#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
39#[non_exhaustive]
40pub struct ChoiceAnswer {
41    /// The highest-probability option.
42    pub choice: String,
43    /// Every option mapped to its probability, in server order.
44    pub probabilities: IndexMap<String, f64>,
45    /// Certainty derived from the distribution, 0 to 1.
46    pub confidence: f64,
47}
48
49impl ChoiceAnswer {
50    /// Parse the selected label into your own type (e.g. an enum implementing `FromStr`).
51    ///
52    /// # Errors
53    ///
54    /// Whatever `T::from_str` returns for the label.
55    pub fn parse<T: FromStr>(&self) -> Result<T, T::Err> {
56        self.choice.parse()
57    }
58
59    /// Probability of a given label.
60    pub fn probability(&self, label: &str) -> Option<f64> {
61        self.probabilities.get(label).copied()
62    }
63
64    /// Labels ordered by descending probability.
65    pub fn ranked(&self) -> Vec<(&str, f64)> {
66        let mut v: Vec<_> = self
67            .probabilities
68            .iter()
69            .map(|(k, p)| (k.as_str(), *p))
70            .collect();
71        v.sort_by(|a, b| b.1.total_cmp(&a.1));
72        v
73    }
74}
75
76/// An expected score with its rubric and distribution.
77#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
78#[non_exhaustive]
79pub struct ScoreAnswer {
80    /// Probability-weighted level; may fall between levels.
81    pub score: f64,
82    /// Certainty derived from the distribution, 0 to 1.
83    pub confidence: f64,
84    /// Level index → the description you supplied.
85    pub legend: BTreeMap<u32, Value>,
86    /// Level index → probability.
87    pub probabilities: BTreeMap<u32, f64>,
88}
89
90impl ScoreAnswer {
91    /// The single most likely level (argmax of `probabilities`), as opposed to the weighted `score`.
92    pub fn most_likely_level(&self) -> Option<u32> {
93        self.probabilities
94            .iter()
95            .max_by(|a, b| a.1.total_cmp(b.1))
96            .map(|(level, _)| *level)
97    }
98
99    /// `score` rounded to the nearest level.
100    pub fn rounded_level(&self) -> u32 {
101        self.score.round().max(0.0) as u32
102    }
103}
104
105/// An answer to one question.
106#[derive(Debug, Clone, PartialEq)]
107#[non_exhaustive]
108pub enum Answer {
109    /// See [`NoulAnswer`].
110    Noul(NoulAnswer),
111    /// See [`ChoiceAnswer`].
112    Choice(ChoiceAnswer),
113    /// See [`ScoreAnswer`].
114    Score(ScoreAnswer),
115}
116
117/// The type of an [`Answer`]; `Display` gives its wire `type` tag.
118#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
119#[non_exhaustive]
120pub enum AnswerKind {
121    /// A [`NoulAnswer`], `"noul"`.
122    Noul,
123    /// A [`ChoiceAnswer`], `"choice"`.
124    Choice,
125    /// A [`ScoreAnswer`], `"score"`.
126    Score,
127}
128
129impl AnswerKind {
130    /// The wire `type` tag: `"noul"`, `"choice"` or `"score"`.
131    pub const fn as_str(self) -> &'static str {
132        match self {
133            AnswerKind::Noul => "noul",
134            AnswerKind::Choice => "choice",
135            AnswerKind::Score => "score",
136        }
137    }
138}
139
140impl fmt::Display for AnswerKind {
141    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
142        f.write_str(self.as_str())
143    }
144}
145
146impl Answer {
147    /// The answer's type.
148    pub fn kind(&self) -> AnswerKind {
149        match self {
150            Answer::Noul(_) => AnswerKind::Noul,
151            Answer::Choice(_) => AnswerKind::Choice,
152            Answer::Score(_) => AnswerKind::Score,
153        }
154    }
155
156    /// Confidence, for answer types that report it.
157    pub fn confidence(&self) -> Option<f64> {
158        match self {
159            Answer::Noul(_) => None,
160            Answer::Choice(a) => Some(a.confidence),
161            Answer::Score(a) => Some(a.confidence),
162        }
163    }
164}
165
166/// Serialized with its wire `type` tag: `{"type": "noul", "noul": 0.97}`.
167impl Serialize for Answer {
168    fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
169        let mut m = s.serialize_map(None)?;
170        m.serialize_entry("type", self.kind().as_str())?;
171        match self {
172            Answer::Noul(a) => m.serialize_entry("noul", &a.noul)?,
173            Answer::Choice(a) => {
174                m.serialize_entry("choice", &a.choice)?;
175                m.serialize_entry("probabilities", &a.probabilities)?;
176                m.serialize_entry("confidence", &a.confidence)?;
177            }
178            Answer::Score(a) => {
179                m.serialize_entry("score", &a.score)?;
180                m.serialize_entry("legend", &a.legend)?;
181                m.serialize_entry("probabilities", &a.probabilities)?;
182                m.serialize_entry("confidence", &a.confidence)?;
183            }
184        }
185        m.end()
186    }
187}
188
189/// Reads the wire shape, dispatching on `type`; an unknown type is an error.
190impl<'de> Deserialize<'de> for Answer {
191    fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
192        // Every field any answer type has, so the body is read once, in order, by any
193        // deserializer. Choice and score probabilities differ in key type, so they are read as
194        // strings and the score's parsed afterwards.
195        #[derive(Deserialize)]
196        struct Wire {
197            #[serde(rename = "type")]
198            kind: String,
199            noul: Option<f64>,
200            choice: Option<String>,
201            score: Option<f64>,
202            confidence: Option<f64>,
203            legend: Option<BTreeMap<u32, Value>>,
204            probabilities: Option<IndexMap<String, f64>>,
205        }
206
207        fn need<T, E: de::Error>(v: Option<T>, field: &'static str) -> Result<T, E> {
208            v.ok_or_else(|| E::missing_field(field))
209        }
210
211        let w = Wire::deserialize(d)?;
212        match w.kind.as_str() {
213            "noul" => Ok(Answer::Noul(NoulAnswer {
214                noul: need(w.noul, "noul")?,
215            })),
216            "choice" => Ok(Answer::Choice(ChoiceAnswer {
217                choice: need(w.choice, "choice")?,
218                probabilities: need(w.probabilities, "probabilities")?,
219                confidence: need(w.confidence, "confidence")?,
220            })),
221            "score" => Ok(Answer::Score(ScoreAnswer {
222                score: need(w.score, "score")?,
223                confidence: need(w.confidence, "confidence")?,
224                legend: need(w.legend, "legend")?,
225                probabilities: need(w.probabilities, "probabilities")?
226                    .into_iter()
227                    .map(|(k, p)| match k.parse() {
228                        Ok(level) => Ok((level, p)),
229                        Err(_) => Err(de::Error::custom(format!("invalid level {k:?}"))),
230                    })
231                    .collect::<Result<_, D::Error>>()?,
232            })),
233            other => Err(de::Error::unknown_variant(
234                other,
235                &["noul", "choice", "score"],
236            )),
237        }
238    }
239}
240
241/// Token usage. The API reports these when available; absent counts are `None`.
242#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
243#[non_exhaustive]
244pub struct Usage {
245    /// Input tokens, when reported.
246    #[serde(default, skip_serializing_if = "Option::is_none")]
247    pub input_tokens: Option<u64>,
248    /// Output tokens, when reported.
249    #[serde(default, skip_serializing_if = "Option::is_none")]
250    pub output_tokens: Option<u64>,
251}
252
253/// Metadata of the HTTP exchange that produced a response.
254#[derive(Debug, Clone)]
255#[non_exhaustive]
256pub struct ResponseMeta {
257    /// HTTP status.
258    pub status: StatusCode,
259    /// Response headers.
260    pub headers: HeaderMap,
261    /// Number of attempts made, including the successful one; `0` for a replayed response.
262    pub attempts: u32,
263}
264
265impl ResponseMeta {
266    /// The `x-typesafe-request-id` header.
267    pub fn request_id(&self) -> Option<&str> {
268        self.headers
269            .get(REQUEST_ID_HEADER)
270            .and_then(|v| v.to_str().ok())
271    }
272}
273
274/// The result of a System One call.
275#[derive(Debug, Clone)]
276#[non_exhaustive]
277pub struct SystemOneResponse {
278    /// The model that answered.
279    pub model: String,
280    /// Token usage.
281    pub usage: Usage,
282    /// Answers keyed by question name, in server order. Answer types unknown to this SDK version
283    /// are skipped (with a `tracing` warning) and remain visible in [`SystemOneResponse::raw`].
284    pub answers: IndexMap<String, Answer>,
285    /// The full decoded body. Object keys follow `serde_json::Map` ordering (sorted unless your
286    /// build enables serde_json's `preserve_order`); the typed fields above keep server order.
287    pub raw: Value,
288    /// HTTP metadata.
289    pub meta: ResponseMeta,
290}
291
292impl SystemOneResponse {
293    /// The `x-typesafe-request-id` header.
294    pub fn request_id(&self) -> Option<&str> {
295        self.meta.request_id()
296    }
297
298    /// The answer to `name`, if it is a Noul.
299    pub fn noul(&self, name: &str) -> Option<&NoulAnswer> {
300        match self.answers.get(name)? {
301            Answer::Noul(a) => Some(a),
302            _ => None,
303        }
304    }
305
306    /// The answer to `name`, if it is a Choice.
307    pub fn choice(&self, name: &str) -> Option<&ChoiceAnswer> {
308        match self.answers.get(name)? {
309            Answer::Choice(a) => Some(a),
310            _ => None,
311        }
312    }
313
314    /// The answer to `name`, if it is a Score.
315    pub fn score(&self, name: &str) -> Option<&ScoreAnswer> {
316        match self.answers.get(name)? {
317            Answer::Score(a) => Some(a),
318            _ => None,
319        }
320    }
321
322    /// All Noul answers.
323    pub fn nouls(&self) -> impl Iterator<Item = (&str, &NoulAnswer)> {
324        self.answers.iter().filter_map(|(k, a)| match a {
325            Answer::Noul(n) => Some((k.as_str(), n)),
326            _ => None,
327        })
328    }
329
330    /// All Choice answers.
331    pub fn choices(&self) -> impl Iterator<Item = (&str, &ChoiceAnswer)> {
332        self.answers.iter().filter_map(|(k, a)| match a {
333            Answer::Choice(c) => Some((k.as_str(), c)),
334            _ => None,
335        })
336    }
337
338    /// All Score answers.
339    pub fn scores(&self) -> impl Iterator<Item = (&str, &ScoreAnswer)> {
340        self.answers.iter().filter_map(|(k, a)| match a {
341            Answer::Score(s) => Some((k.as_str(), s)),
342            _ => None,
343        })
344    }
345}
346
347/// Serialized as the body it was decoded from: `{"model", "answers", "usage"}`, answers in server
348/// order. HTTP metadata is left out, as are answers of types this SDK version does not know (see
349/// `raw` for those).
350impl Serialize for SystemOneResponse {
351    fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
352        let mut st = s.serialize_struct("SystemOneResponse", 3)?;
353        st.serialize_field("model", &self.model)?;
354        st.serialize_field("answers", &self.answers)?;
355        st.serialize_field("usage", &self.usage)?;
356        st.end()
357    }
358}
359
360/// One available model.
361#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
362#[non_exhaustive]
363pub struct ModelMetadata {
364    /// Model name, e.g. `jev-latest`.
365    pub name: String,
366    /// Description.
367    pub description: String,
368    /// Release date as reported by the API.
369    pub release_date: String,
370}
371
372/// Serialized as the body it was decoded from: `{"models": [...]}`, without HTTP metadata.
373impl Serialize for ListModelsResponse {
374    fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
375        let mut st = s.serialize_struct("ListModelsResponse", 1)?;
376        st.serialize_field("models", &self.models)?;
377        st.end()
378    }
379}
380
381/// The models available to the account.
382#[derive(Debug, Clone)]
383#[non_exhaustive]
384pub struct ListModelsResponse {
385    /// Available models.
386    pub models: Vec<ModelMetadata>,
387    /// The full decoded body.
388    pub raw: Value,
389    /// HTTP metadata.
390    pub meta: ResponseMeta,
391}
392
393// ---- decoding ------------------------------------------------------------------------------
394
395/// A decode failure with a dotted path to the offending field.
396pub(crate) struct DecodeFailure {
397    pub path: String,
398    pub detail: String,
399}
400
401/// Deserialize `T` from JSON text, reporting the dotted path of the failing field under `prefix`.
402///
403/// Decoding from text (not from a [`Value`]) keeps object key order for `IndexMap` fields
404/// regardless of serde_json's `preserve_order` feature.
405fn typed<T: DeserializeOwned>(prefix: &str, json: &[u8]) -> Result<T, DecodeFailure> {
406    let mut de = serde_json::Deserializer::from_slice(json);
407    let value = serde_path_to_error::deserialize(&mut de).map_err(|e| {
408        let inner = e.path().to_string();
409        let path = match (prefix.is_empty(), inner.as_str()) {
410            (true, ".") => String::new(),
411            (true, _) => inner.clone(),
412            (false, ".") => prefix.to_owned(),
413            (false, _) => format!("{prefix}.{inner}"),
414        };
415        // serde_json reports a missing field on the parent path; append it for precision.
416        let msg = e.inner().to_string();
417        let path = match msg
418            .strip_prefix("missing field `")
419            .and_then(|r| r.split('`').next())
420        {
421            Some(field) if path.is_empty() => field.to_owned(),
422            Some(field) => format!("{path}.{field}"),
423            None => path,
424        };
425        DecodeFailure { path, detail: msg }
426    })?;
427    de.end().map_err(|e| DecodeFailure {
428        path: prefix.to_owned(),
429        detail: e.to_string(),
430    })?;
431    Ok(value)
432}
433
434#[derive(Deserialize)]
435struct Envelope {
436    model: String,
437    #[serde(default)]
438    usage: Usage,
439    answers: IndexMap<String, Box<RawValue>>,
440}
441
442/// A successfully decoded System One body.
443pub(crate) struct DecodedSystemOne {
444    pub model: String,
445    pub usage: Usage,
446    pub answers: IndexMap<String, Answer>,
447    pub raw: Value,
448}
449
450pub(crate) fn decode_system_one(body: &[u8]) -> Result<DecodedSystemOne, DecodeFailure> {
451    let env: Envelope = typed("", body)?;
452    let mut answers = IndexMap::with_capacity(env.answers.len());
453    for (name, value) in env.answers {
454        let prefix = format!("answers.{name}");
455        let json = value.get();
456        let tag = serde_json::from_str::<Value>(json)
457            .ok()
458            .and_then(|v| v.get("type").and_then(Value::as_str).map(str::to_owned));
459        let Some(tag) = tag else {
460            return Err(DecodeFailure {
461                path: format!("{prefix}.type"),
462                detail: "missing or non-string answer type".into(),
463            });
464        };
465        let answer = match tag.as_str() {
466            "noul" => Answer::Noul(typed(&prefix, json.as_bytes())?),
467            "choice" => Answer::Choice(typed(&prefix, json.as_bytes())?),
468            "score" => Answer::Score(typed(&prefix, json.as_bytes())?),
469            other => {
470                tracing::warn!(answer = %name, r#type = %other, "ignoring answer with unrecognized type");
471                continue;
472            }
473        };
474        answers.insert(name, answer);
475    }
476    // The envelope parsed, so the body is valid JSON.
477    let raw = serde_json::from_slice(body).unwrap_or(Value::Null);
478    Ok(DecodedSystemOne {
479        model: env.model,
480        usage: env.usage,
481        answers,
482        raw,
483    })
484}
485
486#[derive(Deserialize)]
487struct ModelList {
488    models: Vec<ModelMetadata>,
489}
490
491pub(crate) fn decode_models(body: &[u8]) -> Result<(Vec<ModelMetadata>, Value), DecodeFailure> {
492    let list: ModelList = typed("", body)?;
493    let raw = serde_json::from_slice(body).unwrap_or(Value::Null);
494    Ok((list.models, raw))
495}
496
497#[cfg(test)]
498mod tests {
499    use super::*;
500    use serde_json::json;
501
502    fn sample() -> Value {
503        json!({
504            "model": "jev-latest",
505            "answers": {
506                "department": {"type": "choice", "choice": "technical",
507                    "probabilities": {"billing": 0.159, "technical": 0.84, "sales": 0.001}, "confidence": 0.596},
508                "frustration": {"type": "score", "score": 1.6,
509                    "legend": {"0": "Calm", "1": "Frustrated", "2": "Very angry"},
510                    "probabilities": {"0": 0.05, "1": 0.3, "2": 0.65}, "confidence": 0.78},
511                "is_urgent": {"type": "noul", "noul": 0.999},
512                "future": {"type": "span", "start": 3}
513            },
514            "usage": {"input_tokens": 312, "output_tokens": 48, "extra": true}
515        })
516    }
517
518    fn decode(v: Value) -> Result<DecodedSystemOne, DecodeFailure> {
519        decode_system_one(&serde_json::to_vec(&v).unwrap())
520    }
521
522    #[test]
523    fn decodes_all_types_and_skips_unknown() {
524        let DecodedSystemOne {
525            model,
526            usage,
527            answers,
528            raw,
529        } = decode(sample()).ok().unwrap();
530        assert_eq!(model, "jev-latest");
531        assert_eq!(usage.input_tokens, Some(312));
532        assert_eq!(answers.len(), 3);
533        assert_eq!(raw["answers"]["future"]["start"], json!(3));
534        let Answer::Score(s) = &answers["frustration"] else {
535            panic!()
536        };
537        assert_eq!(s.legend[&2], json!("Very angry"));
538        assert_eq!(s.most_likely_level(), Some(2));
539        assert_eq!(s.rounded_level(), 2);
540        let Answer::Choice(c) = &answers["department"] else {
541            panic!()
542        };
543        assert_eq!(c.ranked()[0], ("technical", 0.84));
544    }
545
546    #[test]
547    fn serializes_back_to_the_wire_shape() {
548        let mut wire = sample();
549        wire["answers"].as_object_mut().unwrap().remove("future");
550        wire["usage"].as_object_mut().unwrap().remove("extra");
551        let d = decode(wire.clone()).ok().unwrap();
552        let res = SystemOneResponse {
553            model: d.model,
554            usage: d.usage,
555            answers: d.answers,
556            raw: d.raw,
557            meta: ResponseMeta {
558                status: StatusCode::OK,
559                headers: HeaderMap::new(),
560                attempts: 1,
561            },
562        };
563        // The JSON the API sent, and it decodes to the same answers.
564        let body = serde_json::to_vec(&res).unwrap();
565        assert_eq!(serde_json::from_slice::<Value>(&body).unwrap(), wire);
566        let again = decode_system_one(&body).ok().unwrap();
567        assert_eq!(again.model, res.model);
568        assert_eq!(again.usage, res.usage);
569        assert_eq!(again.answers, res.answers);
570
571        for (name, answer) in &res.answers {
572            assert_eq!(answer.kind().to_string(), wire["answers"][name]["type"]);
573            let text = serde_json::to_string(answer).unwrap();
574            assert_eq!(serde_json::from_str::<Answer>(&text).unwrap(), *answer);
575            assert_eq!(
576                serde_json::from_str::<Value>(&text).unwrap(),
577                wire["answers"][name]
578            );
579        }
580        // Server order survives the round trip, byte for byte.
581        let text = r#"{"type":"choice","choice":"z","probabilities":{"z":0.5,"a":0.3,"m":0.2},"confidence":0.1}"#;
582        let answer: Answer = serde_json::from_str(text).unwrap();
583        assert_eq!(serde_json::to_string(&answer).unwrap(), text);
584
585        assert_eq!(serde_json::to_value(Usage::default()).unwrap(), json!({}));
586        assert!(serde_json::from_value::<Answer>(json!({"type": "span"})).is_err());
587        assert!(serde_json::from_value::<Answer>(json!({"type": "noul"})).is_err());
588
589        let body =
590            br#"{"models":[{"name":"jev-latest","description":"d","release_date":"2025-01-01"}]}"#;
591        let (models, raw) = decode_models(body).ok().unwrap();
592        let list = ListModelsResponse {
593            models,
594            raw: raw.clone(),
595            meta: res.meta.clone(),
596        };
597        assert_eq!(serde_json::to_value(&list).unwrap(), raw);
598    }
599
600    #[test]
601    fn preserves_server_order_of_probabilities() {
602        let body = br#"{"model":"m","usage":{},"answers":{"c":{"type":"choice","choice":"z",
603            "probabilities":{"z":0.5,"a":0.3,"m":0.2},"confidence":0.1}}}"#;
604        let d = decode_system_one(body).ok().unwrap();
605        let Answer::Choice(c) = &d.answers["c"] else {
606            panic!()
607        };
608        let keys: Vec<_> = c.probabilities.keys().map(String::as_str).collect();
609        assert_eq!(keys, ["z", "a", "m"]);
610    }
611
612    #[test]
613    fn usage_may_be_empty_or_absent() {
614        let d = decode(json!({"model": "m", "answers": {}, "usage": {}}))
615            .ok()
616            .unwrap();
617        assert_eq!(d.usage, Usage::default());
618        let d = decode(json!({"model": "m", "answers": {}})).ok().unwrap();
619        assert_eq!(d.usage, Usage::default());
620    }
621
622    #[test]
623    fn rejects_non_json_and_trailing_content() {
624        assert_eq!(decode_system_one(b"<html>").err().unwrap().path, "");
625        let mut body = serde_json::to_vec(&sample()).unwrap();
626        body.extend_from_slice(b" trailing");
627        assert!(decode_system_one(&body).is_err());
628    }
629
630    #[test]
631    fn reports_precise_paths() {
632        let mut v = sample();
633        v["answers"]["department"]
634            .as_object_mut()
635            .unwrap()
636            .remove("confidence");
637        assert_eq!(
638            decode(v).err().unwrap().path,
639            "answers.department.confidence"
640        );
641
642        let mut v = sample();
643        v["answers"]["frustration"]["probabilities"]["1"] = json!("high");
644        assert_eq!(
645            decode(v).err().unwrap().path,
646            "answers.frustration.probabilities.1"
647        );
648
649        let mut v = sample();
650        v["answers"]["is_urgent"]["type"] = json!(7);
651        assert_eq!(decode(v).err().unwrap().path, "answers.is_urgent.type");
652
653        let mut v = sample();
654        v.as_object_mut().unwrap().remove("model");
655        assert_eq!(decode(v).err().unwrap().path, "model");
656    }
657}