Skip to main content

typesafe/
response.rs

1//! Answers and response metadata.
2
3use std::collections::BTreeMap;
4use std::str::FromStr;
5
6use http::header::HeaderMap;
7use indexmap::IndexMap;
8use serde::Deserialize;
9use serde::de::DeserializeOwned;
10use serde_json::Value;
11use serde_json::value::RawValue;
12
13use crate::constants::REQUEST_ID_HEADER;
14
15/// A yes/no answer.
16#[derive(Debug, Clone, PartialEq, Deserialize)]
17#[non_exhaustive]
18pub struct NoulAnswer {
19    /// Probability of "yes", from 0 to 1.
20    pub noul: f64,
21}
22
23impl NoulAnswer {
24    /// `noul >= threshold`.
25    pub fn is_yes(&self, threshold: f64) -> bool {
26        self.noul >= threshold
27    }
28}
29
30/// A selected option with its distribution.
31#[derive(Debug, Clone, PartialEq, Deserialize)]
32#[non_exhaustive]
33pub struct ChoiceAnswer {
34    /// The highest-probability option.
35    pub choice: String,
36    /// Every option mapped to its probability, in server order.
37    pub probabilities: IndexMap<String, f64>,
38    /// Certainty derived from the distribution, 0 to 1.
39    pub confidence: f64,
40}
41
42impl ChoiceAnswer {
43    /// Parse the selected label into your own type (e.g. an enum implementing `FromStr`).
44    pub fn parse<T: FromStr>(&self) -> Result<T, T::Err> {
45        self.choice.parse()
46    }
47
48    /// Probability of a given label.
49    pub fn probability(&self, label: &str) -> Option<f64> {
50        self.probabilities.get(label).copied()
51    }
52
53    /// Labels ordered by descending probability.
54    pub fn ranked(&self) -> Vec<(&str, f64)> {
55        let mut v: Vec<_> = self
56            .probabilities
57            .iter()
58            .map(|(k, p)| (k.as_str(), *p))
59            .collect();
60        v.sort_by(|a, b| b.1.total_cmp(&a.1));
61        v
62    }
63}
64
65/// An expected score with its rubric and distribution.
66#[derive(Debug, Clone, PartialEq, Deserialize)]
67#[non_exhaustive]
68pub struct ScoreAnswer {
69    /// Probability-weighted level; may fall between levels.
70    pub score: f64,
71    /// Certainty derived from the distribution, 0 to 1.
72    pub confidence: f64,
73    /// Level index → the description you supplied.
74    pub legend: BTreeMap<u32, Value>,
75    /// Level index → probability.
76    pub probabilities: BTreeMap<u32, f64>,
77}
78
79impl ScoreAnswer {
80    /// The single most likely level (argmax of `probabilities`), as opposed to the weighted `score`.
81    pub fn most_likely_level(&self) -> Option<u32> {
82        self.probabilities
83            .iter()
84            .max_by(|a, b| a.1.total_cmp(b.1))
85            .map(|(level, _)| *level)
86    }
87
88    /// `score` rounded to the nearest level.
89    pub fn rounded_level(&self) -> u32 {
90        self.score.round().max(0.0) as u32
91    }
92}
93
94/// An answer to one question.
95#[derive(Debug, Clone, PartialEq)]
96#[non_exhaustive]
97pub enum Answer {
98    /// See [`NoulAnswer`].
99    Noul(NoulAnswer),
100    /// See [`ChoiceAnswer`].
101    Choice(ChoiceAnswer),
102    /// See [`ScoreAnswer`].
103    Score(ScoreAnswer),
104}
105
106impl Answer {
107    /// The wire `type` tag.
108    pub fn kind(&self) -> &'static str {
109        match self {
110            Answer::Noul(_) => "noul",
111            Answer::Choice(_) => "choice",
112            Answer::Score(_) => "score",
113        }
114    }
115
116    /// Confidence, for answer types that report it.
117    pub fn confidence(&self) -> Option<f64> {
118        match self {
119            Answer::Noul(_) => None,
120            Answer::Choice(a) => Some(a.confidence),
121            Answer::Score(a) => Some(a.confidence),
122        }
123    }
124}
125
126/// Token usage. The API reports these when available; absent counts are `None`.
127#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
128#[non_exhaustive]
129pub struct Usage {
130    /// Input tokens, when reported.
131    #[serde(default)]
132    pub input_tokens: Option<u64>,
133    /// Output tokens, when reported.
134    #[serde(default)]
135    pub output_tokens: Option<u64>,
136}
137
138/// Metadata of the HTTP exchange that produced a response.
139#[derive(Debug, Clone)]
140#[non_exhaustive]
141pub struct ResponseMeta {
142    /// HTTP status.
143    pub status: u16,
144    /// Response headers.
145    pub headers: HeaderMap,
146    /// Number of attempts made, including the successful one.
147    pub attempts: u32,
148}
149
150impl ResponseMeta {
151    /// The `x-typesafe-request-id` header.
152    pub fn request_id(&self) -> Option<&str> {
153        self.headers
154            .get(REQUEST_ID_HEADER)
155            .and_then(|v| v.to_str().ok())
156    }
157}
158
159/// The result of a System One call.
160#[derive(Debug, Clone)]
161#[non_exhaustive]
162pub struct SystemOneResponse {
163    /// The model that answered.
164    pub model: String,
165    /// Token usage.
166    pub usage: Usage,
167    /// Answers keyed by question name, in server order. Answer types unknown to this SDK version
168    /// are skipped (with a `tracing` warning) and remain visible in [`SystemOneResponse::raw`].
169    pub answers: IndexMap<String, Answer>,
170    /// The full decoded body. Object keys follow `serde_json::Map` ordering (sorted unless your
171    /// build enables serde_json's `preserve_order`); the typed fields above keep server order.
172    pub raw: Value,
173    /// HTTP metadata.
174    pub meta: ResponseMeta,
175}
176
177impl SystemOneResponse {
178    /// The `x-typesafe-request-id` header.
179    pub fn request_id(&self) -> Option<&str> {
180        self.meta.request_id()
181    }
182
183    /// The answer to `name`, if it is a Noul.
184    pub fn noul(&self, name: &str) -> Option<&NoulAnswer> {
185        match self.answers.get(name)? {
186            Answer::Noul(a) => Some(a),
187            _ => None,
188        }
189    }
190
191    /// The answer to `name`, if it is a Choice.
192    pub fn choice(&self, name: &str) -> Option<&ChoiceAnswer> {
193        match self.answers.get(name)? {
194            Answer::Choice(a) => Some(a),
195            _ => None,
196        }
197    }
198
199    /// The answer to `name`, if it is a Score.
200    pub fn score(&self, name: &str) -> Option<&ScoreAnswer> {
201        match self.answers.get(name)? {
202            Answer::Score(a) => Some(a),
203            _ => None,
204        }
205    }
206
207    /// All Noul answers.
208    pub fn nouls(&self) -> impl Iterator<Item = (&str, &NoulAnswer)> {
209        self.answers.iter().filter_map(|(k, a)| match a {
210            Answer::Noul(n) => Some((k.as_str(), n)),
211            _ => None,
212        })
213    }
214
215    /// All Choice answers.
216    pub fn choices(&self) -> impl Iterator<Item = (&str, &ChoiceAnswer)> {
217        self.answers.iter().filter_map(|(k, a)| match a {
218            Answer::Choice(c) => Some((k.as_str(), c)),
219            _ => None,
220        })
221    }
222
223    /// All Score answers.
224    pub fn scores(&self) -> impl Iterator<Item = (&str, &ScoreAnswer)> {
225        self.answers.iter().filter_map(|(k, a)| match a {
226            Answer::Score(s) => Some((k.as_str(), s)),
227            _ => None,
228        })
229    }
230}
231
232/// One available model.
233#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
234#[non_exhaustive]
235pub struct ModelMetadata {
236    /// Model name, e.g. `jev-latest`.
237    pub name: String,
238    /// Description.
239    pub description: String,
240    /// Release date as reported by the API.
241    pub release_date: String,
242}
243
244/// The models available to the account.
245#[derive(Debug, Clone)]
246#[non_exhaustive]
247pub struct ListModelsResponse {
248    /// Available models.
249    pub models: Vec<ModelMetadata>,
250    /// The full decoded body.
251    pub raw: Value,
252    /// HTTP metadata.
253    pub meta: ResponseMeta,
254}
255
256// ---- decoding ------------------------------------------------------------------------------
257
258/// A decode failure with a dotted path to the offending field.
259pub(crate) struct DecodeFailure {
260    pub path: String,
261    pub detail: String,
262}
263
264/// Deserialize `T` from JSON text, reporting the dotted path of the failing field under `prefix`.
265///
266/// Decoding from text (not from a [`Value`]) keeps object key order for `IndexMap` fields
267/// regardless of serde_json's `preserve_order` feature.
268fn typed<T: DeserializeOwned>(prefix: &str, json: &[u8]) -> Result<T, DecodeFailure> {
269    let mut de = serde_json::Deserializer::from_slice(json);
270    let value = serde_path_to_error::deserialize(&mut de).map_err(|e| {
271        let inner = e.path().to_string();
272        let path = match (prefix.is_empty(), inner.as_str()) {
273            (true, ".") => String::new(),
274            (true, _) => inner.clone(),
275            (false, ".") => prefix.to_owned(),
276            (false, _) => format!("{prefix}.{inner}"),
277        };
278        // serde_json reports a missing field on the parent path; append it for precision.
279        let msg = e.inner().to_string();
280        let path = match msg
281            .strip_prefix("missing field `")
282            .and_then(|r| r.split('`').next())
283        {
284            Some(field) if path.is_empty() => field.to_owned(),
285            Some(field) => format!("{path}.{field}"),
286            None => path,
287        };
288        DecodeFailure { path, detail: msg }
289    })?;
290    de.end().map_err(|e| DecodeFailure {
291        path: prefix.to_owned(),
292        detail: e.to_string(),
293    })?;
294    Ok(value)
295}
296
297#[derive(Deserialize)]
298struct Envelope {
299    model: String,
300    #[serde(default)]
301    usage: Usage,
302    answers: IndexMap<String, Box<RawValue>>,
303}
304
305/// A successfully decoded System One body.
306pub(crate) struct DecodedSystemOne {
307    pub model: String,
308    pub usage: Usage,
309    pub answers: IndexMap<String, Answer>,
310    pub raw: Value,
311}
312
313pub(crate) fn decode_system_one(body: &[u8]) -> Result<DecodedSystemOne, DecodeFailure> {
314    let env: Envelope = typed("", body)?;
315    let mut answers = IndexMap::with_capacity(env.answers.len());
316    for (name, value) in env.answers {
317        let prefix = format!("answers.{name}");
318        let json = value.get();
319        let tag = serde_json::from_str::<Value>(json)
320            .ok()
321            .and_then(|v| v.get("type").and_then(Value::as_str).map(str::to_owned));
322        let Some(tag) = tag else {
323            return Err(DecodeFailure {
324                path: format!("{prefix}.type"),
325                detail: "missing or non-string answer type".into(),
326            });
327        };
328        let answer = match tag.as_str() {
329            "noul" => Answer::Noul(typed(&prefix, json.as_bytes())?),
330            "choice" => Answer::Choice(typed(&prefix, json.as_bytes())?),
331            "score" => Answer::Score(typed(&prefix, json.as_bytes())?),
332            other => {
333                tracing::warn!(answer = %name, r#type = %other, "ignoring answer with unrecognized type");
334                continue;
335            }
336        };
337        answers.insert(name, answer);
338    }
339    // The envelope parsed, so the body is valid JSON.
340    let raw = serde_json::from_slice(body).unwrap_or(Value::Null);
341    Ok(DecodedSystemOne {
342        model: env.model,
343        usage: env.usage,
344        answers,
345        raw,
346    })
347}
348
349#[derive(Deserialize)]
350struct ModelList {
351    models: Vec<ModelMetadata>,
352}
353
354pub(crate) fn decode_models(body: &[u8]) -> Result<(Vec<ModelMetadata>, Value), DecodeFailure> {
355    let list: ModelList = typed("", body)?;
356    let raw = serde_json::from_slice(body).unwrap_or(Value::Null);
357    Ok((list.models, raw))
358}
359
360#[cfg(test)]
361mod tests {
362    use super::*;
363    use serde_json::json;
364
365    fn sample() -> Value {
366        json!({
367            "model": "jev-latest",
368            "answers": {
369                "department": {"type": "choice", "choice": "technical",
370                    "probabilities": {"billing": 0.159, "technical": 0.84, "sales": 0.001}, "confidence": 0.596},
371                "frustration": {"type": "score", "score": 1.6,
372                    "legend": {"0": "Calm", "1": "Frustrated", "2": "Very angry"},
373                    "probabilities": {"0": 0.05, "1": 0.3, "2": 0.65}, "confidence": 0.78},
374                "is_urgent": {"type": "noul", "noul": 0.999},
375                "future": {"type": "span", "start": 3}
376            },
377            "usage": {"input_tokens": 312, "output_tokens": 48, "extra": true}
378        })
379    }
380
381    fn decode(v: Value) -> Result<DecodedSystemOne, DecodeFailure> {
382        decode_system_one(&serde_json::to_vec(&v).unwrap())
383    }
384
385    #[test]
386    fn decodes_all_types_and_skips_unknown() {
387        let DecodedSystemOne {
388            model,
389            usage,
390            answers,
391            raw,
392        } = decode(sample()).ok().unwrap();
393        assert_eq!(model, "jev-latest");
394        assert_eq!(usage.input_tokens, Some(312));
395        assert_eq!(answers.len(), 3);
396        assert_eq!(raw["answers"]["future"]["start"], json!(3));
397        let Answer::Score(s) = &answers["frustration"] else {
398            panic!()
399        };
400        assert_eq!(s.legend[&2], json!("Very angry"));
401        assert_eq!(s.most_likely_level(), Some(2));
402        assert_eq!(s.rounded_level(), 2);
403        let Answer::Choice(c) = &answers["department"] else {
404            panic!()
405        };
406        assert_eq!(c.ranked()[0], ("technical", 0.84));
407    }
408
409    #[test]
410    fn preserves_server_order_of_probabilities() {
411        let body = br#"{"model":"m","usage":{},"answers":{"c":{"type":"choice","choice":"z",
412            "probabilities":{"z":0.5,"a":0.3,"m":0.2},"confidence":0.1}}}"#;
413        let d = decode_system_one(body).ok().unwrap();
414        let Answer::Choice(c) = &d.answers["c"] else {
415            panic!()
416        };
417        let keys: Vec<_> = c.probabilities.keys().map(String::as_str).collect();
418        assert_eq!(keys, ["z", "a", "m"]);
419    }
420
421    #[test]
422    fn usage_may_be_empty_or_absent() {
423        let d = decode(json!({"model": "m", "answers": {}, "usage": {}}))
424            .ok()
425            .unwrap();
426        assert_eq!(d.usage, Usage::default());
427        let d = decode(json!({"model": "m", "answers": {}})).ok().unwrap();
428        assert_eq!(d.usage, Usage::default());
429    }
430
431    #[test]
432    fn rejects_non_json_and_trailing_content() {
433        assert_eq!(decode_system_one(b"<html>").err().unwrap().path, "");
434        let mut body = serde_json::to_vec(&sample()).unwrap();
435        body.extend_from_slice(b" trailing");
436        assert!(decode_system_one(&body).is_err());
437    }
438
439    #[test]
440    fn reports_precise_paths() {
441        let mut v = sample();
442        v["answers"]["department"]
443            .as_object_mut()
444            .unwrap()
445            .remove("confidence");
446        assert_eq!(
447            decode(v).err().unwrap().path,
448            "answers.department.confidence"
449        );
450
451        let mut v = sample();
452        v["answers"]["frustration"]["probabilities"]["1"] = json!("high");
453        assert_eq!(
454            decode(v).err().unwrap().path,
455            "answers.frustration.probabilities.1"
456        );
457
458        let mut v = sample();
459        v["answers"]["is_urgent"]["type"] = json!(7);
460        assert_eq!(decode(v).err().unwrap().path, "answers.is_urgent.type");
461
462        let mut v = sample();
463        v.as_object_mut().unwrap().remove("model");
464        assert_eq!(decode(v).err().unwrap().path, "model");
465    }
466}