Skip to main content

typesafe_rs/types/
response.rs

1use http::{HeaderMap, StatusCode};
2use indexmap::IndexMap;
3use serde::Deserialize;
4
5/// Token usage reported by the API.
6#[derive(Clone, Debug, PartialEq, Eq, Deserialize)]
7pub struct Usage {
8    /// Prompt / input tokens.
9    pub input_tokens: u64,
10    /// Completion / output tokens.
11    pub output_tokens: u64,
12}
13
14/// Metadata captured from the HTTP response (not part of the JSON body).
15#[derive(Clone, Debug, Default)]
16pub struct ResponseMeta {
17    /// Value of `x-typesafe-request-id` when present.
18    pub request_id: Option<String>,
19    /// HTTP status of the final attempt.
20    pub status: Option<StatusCode>,
21    /// Response headers of the final attempt.
22    pub headers: HeaderMap,
23    /// Total HTTP attempts including the original request.
24    pub attempts: u32,
25}
26
27/// A typed answer, or [`Answer::Unknown`] for forward-compatible types.
28#[derive(Clone, Debug, PartialEq, Deserialize)]
29#[non_exhaustive]
30#[serde(tag = "type", rename_all = "lowercase")]
31pub enum Answer {
32    /// Yes/no probability.
33    Noul {
34        /// Probability of yes, in `[0, 1]`.
35        noul: f64,
36    },
37    /// Selected label plus the full distribution.
38    Choice {
39        /// Highest-probability option.
40        choice: String,
41        /// Probability of each option.
42        probabilities: IndexMap<String, f64>,
43        /// Model confidence in the selected label.
44        confidence: f64,
45    },
46    /// Weighted score across ordered levels.
47    Score {
48        /// Expected score; may fall between integer levels.
49        score: f64,
50        /// Level index (as a string) mapped to its description.
51        legend: IndexMap<String, serde_json::Value>,
52        /// Optional per-level probabilities.
53        #[serde(default)]
54        probabilities: Option<IndexMap<String, f64>>,
55        /// Model confidence in the score.
56        confidence: f64,
57    },
58    /// An answer `type` this SDK version does not model.
59    #[serde(other)]
60    Unknown,
61}
62
63/// Borrowed view of a Noul answer.
64#[derive(Clone, Copy, Debug, PartialEq)]
65pub struct NoulView {
66    /// Probability of yes, in `[0, 1]`.
67    pub noul: f64,
68}
69
70/// Borrowed view of a Choice answer.
71#[derive(Clone, Copy, Debug, PartialEq)]
72pub struct ChoiceView<'a> {
73    /// Selected option.
74    pub choice: &'a str,
75    /// Probability of each option.
76    pub probabilities: &'a IndexMap<String, f64>,
77    /// Model confidence.
78    pub confidence: f64,
79}
80
81/// Borrowed view of a Score answer.
82#[derive(Clone, Copy, Debug, PartialEq)]
83pub struct ScoreView<'a> {
84    /// Expected score.
85    pub score: f64,
86    /// Level descriptions keyed by index.
87    pub legend: &'a IndexMap<String, serde_json::Value>,
88    /// Per-level probabilities, when the API included them.
89    pub probabilities: Option<&'a IndexMap<String, f64>>,
90    /// Model confidence.
91    pub confidence: f64,
92}
93
94/// Parsed `POST /v1/systemone` response.
95///
96/// Look up typed answers with [`noul`](Self::noul), [`choice`](Self::choice),
97/// and [`score`](Self::score). Unknown `type` values are [`Answer::Unknown`].
98#[derive(Clone, Debug, Deserialize)]
99pub struct SystemOneResponse {
100    /// Model that produced the answers.
101    pub model: String,
102    /// Answers keyed as in the request.
103    pub answers: IndexMap<String, Answer>,
104    /// Token usage, when present.
105    #[serde(default)]
106    pub usage: Option<Usage>,
107    /// HTTP metadata filled in by the client after deserialize.
108    #[serde(skip)]
109    pub meta: ResponseMeta,
110}
111
112impl SystemOneResponse {
113    /// Iterate Noul answers.
114    pub fn nouls(&self) -> impl Iterator<Item = (&str, NoulView)> + '_ {
115        self.answers
116            .iter()
117            .filter_map(|(key, answer)| match answer {
118                Answer::Noul { noul } => Some((key.as_str(), NoulView { noul: *noul })),
119                _ => None,
120            })
121    }
122
123    /// Iterate Choice answers.
124    pub fn choices(&self) -> impl Iterator<Item = (&str, ChoiceView<'_>)> + '_ {
125        self.answers
126            .iter()
127            .filter_map(|(key, answer)| match answer {
128                Answer::Choice {
129                    choice,
130                    probabilities,
131                    confidence,
132                } => Some((
133                    key.as_str(),
134                    ChoiceView {
135                        choice,
136                        probabilities,
137                        confidence: *confidence,
138                    },
139                )),
140                _ => None,
141            })
142    }
143
144    /// Iterate Score answers.
145    pub fn scores(&self) -> impl Iterator<Item = (&str, ScoreView<'_>)> + '_ {
146        self.answers
147            .iter()
148            .filter_map(|(key, answer)| match answer {
149                Answer::Score {
150                    score,
151                    legend,
152                    probabilities,
153                    confidence,
154                } => Some((
155                    key.as_str(),
156                    ScoreView {
157                        score: *score,
158                        legend,
159                        probabilities: probabilities.as_ref(),
160                        confidence: *confidence,
161                    },
162                )),
163                _ => None,
164            })
165    }
166
167    /// Noul probability for `key`, if that answer is a Noul.
168    #[must_use]
169    pub fn noul(&self, key: &str) -> Option<f64> {
170        match self.answers.get(key) {
171            Some(Answer::Noul { noul }) => Some(*noul),
172            _ => None,
173        }
174    }
175
176    /// Choice view for `key`, if that answer is a Choice.
177    #[must_use]
178    pub fn choice(&self, key: &str) -> Option<ChoiceView<'_>> {
179        match self.answers.get(key) {
180            Some(Answer::Choice {
181                choice,
182                probabilities,
183                confidence,
184            }) => Some(ChoiceView {
185                choice,
186                probabilities,
187                confidence: *confidence,
188            }),
189            _ => None,
190        }
191    }
192
193    /// Score view for `key`, if that answer is a Score.
194    #[must_use]
195    pub fn score(&self, key: &str) -> Option<ScoreView<'_>> {
196        match self.answers.get(key) {
197            Some(Answer::Score {
198                score,
199                legend,
200                probabilities,
201                confidence,
202            }) => Some(ScoreView {
203                score: *score,
204                legend,
205                probabilities: probabilities.as_ref(),
206                confidence: *confidence,
207            }),
208            _ => None,
209        }
210    }
211
212    /// Borrow the answer for `key`.
213    #[must_use]
214    pub fn answer(&self, key: &str) -> Option<&Answer> {
215        self.answers.get(key)
216    }
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222    use serde_json::json;
223
224    #[test]
225    fn deserializes_noul_choice_score() {
226        let body = json!({
227            "model": "jev-latest",
228            "answers": {
229                "urgent": { "type": "noul", "noul": 0.92 },
230                "dept": {
231                    "type": "choice",
232                    "choice": "technical",
233                    "probabilities": { "billing": 0.08, "technical": 0.85, "sales": 0.07 },
234                    "confidence": 0.82
235                },
236                "frustration": {
237                    "type": "score",
238                    "score": 1.6,
239                    "legend": { "0": "Calm", "1": "Frustrated", "2": "Very angry" },
240                    "probabilities": { "0": 0.05, "1": 0.3, "2": 0.65 },
241                    "confidence": 0.78
242                }
243            },
244            "usage": { "input_tokens": 312, "output_tokens": 48 }
245        });
246        let resp: SystemOneResponse = serde_json::from_value(body).unwrap();
247        assert_eq!(resp.noul("urgent"), Some(0.92));
248        assert_eq!(resp.choice("dept").unwrap().choice, "technical");
249        assert_eq!(resp.score("frustration").unwrap().score, 1.6);
250        assert_eq!(resp.usage.as_ref().unwrap().input_tokens, 312);
251        assert_eq!(resp.nouls().count(), 1);
252        assert_eq!(resp.choices().count(), 1);
253        assert_eq!(resp.scores().count(), 1);
254    }
255
256    #[test]
257    fn unknown_answer_type_does_not_fail() {
258        let body = json!({
259            "model": "jev-latest",
260            "answers": {
261                "future": { "type": "spectrum", "value": 1 },
262                "urgent": { "type": "noul", "noul": 0.5 }
263            }
264        });
265        let resp: SystemOneResponse = serde_json::from_value(body).unwrap();
266        assert!(matches!(resp.answer("future"), Some(Answer::Unknown)));
267        assert_eq!(resp.noul("urgent"), Some(0.5));
268    }
269
270    #[test]
271    fn extra_fields_on_known_answers_are_ignored() {
272        let body = json!({
273            "model": "jev-latest",
274            "answers": {
275                "urgent": { "type": "noul", "noul": 0.1, "extra": true }
276            },
277            "bonus": 1
278        });
279        let resp: SystemOneResponse = serde_json::from_value(body).unwrap();
280        assert_eq!(resp.noul("urgent"), Some(0.1));
281    }
282
283    #[test]
284    fn score_without_probabilities_is_ok() {
285        let body = json!({
286            "model": "jev-latest",
287            "answers": {
288                "s": {
289                    "type": "score",
290                    "score": 0.0,
291                    "legend": { "0": "a", "1": "b" },
292                    "confidence": 0.5
293                }
294            }
295        });
296        let resp: SystemOneResponse = serde_json::from_value(body).unwrap();
297        assert!(resp.score("s").unwrap().probabilities.is_none());
298    }
299}