Skip to main content

jev/
types.rs

1//! The decisions endpoint's request and reply, as TypeSafe documents them
2//! (https://docs.typesafe.ai/primitives). Instructions and every criterion are JSON values: a
3//! string usually, but an object, an array or `null` works too (the docs' "Advanced: structure").
4
5use std::borrow::Cow;
6use std::collections::BTreeMap;
7
8use serde::de::Error as _;
9use serde::ser::SerializeMap;
10use serde::{Deserialize, Deserializer, Serialize, Serializer};
11use serde_json::Value;
12
13use crate::error::{Error, Result};
14
15/// One request: a state, and questions about it under ids of the caller's choosing. Every question
16/// sees the same state and is answered independently of the others.
17#[derive(Debug, Clone, PartialEq, Serialize)]
18pub struct DecisionRequest {
19    pub model: String,
20    pub state: Value,
21    pub questions: BTreeMap<String, Question>,
22}
23
24/// A question. The id it is sent under is never shown to the model, so `instructions` should say
25/// everything. It reads from JSON too, in the endpoint's own shape.
26#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
27#[serde(tag = "type", rename_all = "lowercase")]
28pub enum Question {
29    /// Which one of these options? `criteria` maps each option to its description.
30    Choice { instructions: Value, criteria: Options },
31    /// Which level, from the lowest to the highest? Two to ten levels.
32    Score { instructions: Value, criteria: Vec<Value> },
33    /// Is this true? The answer is the probability of yes.
34    Noul {
35        instructions: Value,
36        #[serde(skip_serializing_if = "Option::is_none")]
37        criteria: Option<NoulCriteria>,
38    },
39}
40
41impl Question {
42    /// A Choice between `options`: (name, description) pairs, kept in the order given.
43    pub fn choice<K: Into<String>, V: Into<Value>>(instructions: impl Into<Value>, options: impl IntoIterator<Item = (K, V)>) -> Question {
44        let options = options.into_iter().map(|(name, description)| (name.into(), description.into())).collect();
45        Question::Choice { instructions: instructions.into(), criteria: Options(options) }
46    }
47
48    /// A Score over `levels`, from the lowest (level 0) to the highest.
49    pub fn score<V: Into<Value>>(instructions: impl Into<Value>, levels: impl IntoIterator<Item = V>) -> Question {
50        Question::Score { instructions: instructions.into(), criteria: levels.into_iter().map(Into::into).collect() }
51    }
52
53    /// A Noul: the yes/no question alone.
54    pub fn noul(instructions: impl Into<Value>) -> Question {
55        Question::Noul { instructions: instructions.into(), criteria: None }
56    }
57
58    /// A Noul with what a yes and a no mean, for when the boundary is subtle.
59    pub fn noul_with_criteria(instructions: impl Into<Value>, yes: impl Into<Value>, no: impl Into<Value>) -> Question {
60        Question::Noul { instructions: instructions.into(), criteria: Some(NoulCriteria { yes: yes.into(), no: no.into() }) }
61    }
62}
63
64impl Question {
65    /// Whether the endpoint's documented limits hold, so a request that cannot be answered fails
66    /// here rather than after a round trip. The endpoint is the authority on the rest.
67    pub fn check(&self) -> std::result::Result<(), String> {
68        match self {
69            Question::Choice { criteria: Options(options), .. } => {
70                if options.is_empty() {
71                    return Err("a choice needs options to pick from".to_owned());
72                }
73                // The options are sent as a JSON object, where a repeated name would be one key.
74                for (at, (name, _)) in options.iter().enumerate() {
75                    if options[..at].iter().any(|(earlier, _)| earlier == name) {
76                        return Err(format!("option `{name}` is there twice"));
77                    }
78                }
79                if options.len() > CHOICE_OPTIONS {
80                    return Err(format!("a choice takes up to {CHOICE_OPTIONS} options, not {}", options.len()));
81                }
82            }
83            Question::Score { criteria: levels, .. } => {
84                if levels.is_empty() {
85                    return Err("a score needs levels to place the state on".to_owned());
86                }
87                if levels.len() > SCORE_LEVELS {
88                    return Err(format!("a score takes up to {SCORE_LEVELS} levels, not {}", levels.len()));
89                }
90            }
91            Question::Noul { .. } => {}
92        }
93        Ok(())
94    }
95}
96
97/// How many options a Choice takes, and how many levels a Score takes, as the docs give them. No
98/// minimum of two levels is enforced: the docs ask for two, but the endpoint answers a single one.
99const CHOICE_OPTIONS: usize = 255;
100const SCORE_LEVELS: usize = 10;
101
102/// A Choice's options, sent as a JSON object in the order they were given.
103#[derive(Debug, Clone, PartialEq, Default)]
104pub struct Options(pub Vec<(String, Value)>);
105
106impl Serialize for Options {
107    fn serialize<S: Serializer>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error> {
108        let mut map = serializer.serialize_map(Some(self.0.len()))?;
109        for (name, description) in &self.0 {
110            map.serialize_entry(name, description)?;
111        }
112        map.end()
113    }
114}
115
116impl<'de> Deserialize<'de> for Options {
117    /// Keeps the options in the order the JSON has them.
118    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> std::result::Result<Options, D::Error> {
119        struct Visitor;
120        impl<'de> serde::de::Visitor<'de> for Visitor {
121            type Value = Options;
122
123            fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
124                f.write_str("an object of options and their descriptions")
125            }
126
127            fn visit_map<A: serde::de::MapAccess<'de>>(self, mut map: A) -> std::result::Result<Options, A::Error> {
128                let mut options = Vec::new();
129                while let Some(option) = map.next_entry()? {
130                    options.push(option);
131                }
132                Ok(Options(options))
133            }
134        }
135        deserializer.deserialize_map(Visitor)
136    }
137}
138
139/// What a Noul's yes and no mean.
140#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
141pub struct NoulCriteria {
142    #[serde(rename = "true")]
143    pub yes: Value,
144    #[serde(rename = "false")]
145    pub no: Value,
146}
147
148/// The reply: one answer per question, under the ids from the request.
149#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
150pub struct DecisionResponse {
151    /// The model that answered, with its date, such as `typesafe/jev-1.13-20260917`.
152    pub model: String,
153    pub answers: BTreeMap<String, Answer>,
154    pub usage: Usage,
155    /// OpenRouter's id for the call.
156    #[serde(default, skip_serializing_if = "Option::is_none")]
157    pub id: Option<String>,
158    #[serde(default, skip_serializing_if = "Option::is_none")]
159    pub provider: Option<String>,
160}
161
162impl DecisionResponse {
163    /// The answer to question `id`.
164    pub fn answer(&self, id: &str) -> Result<&Answer> {
165        self.answers.get(id).ok_or_else(|| Error::MissingAnswer(id.to_owned()))
166    }
167
168    /// The Choice answer to question `id`.
169    pub fn choice(&self, id: &str) -> Result<&ChoiceAnswer> {
170        match self.answer(id)? {
171            Answer::Choice(answer) => Ok(answer),
172            other => Err(wrong_type(id, "choice", other)),
173        }
174    }
175
176    /// The Score answer to question `id`.
177    pub fn score(&self, id: &str) -> Result<&ScoreAnswer> {
178        match self.answer(id)? {
179            Answer::Score(answer) => Ok(answer),
180            other => Err(wrong_type(id, "score", other)),
181        }
182    }
183
184    /// The probability of yes for Noul question `id`.
185    pub fn noul(&self, id: &str) -> Result<f64> {
186        match self.answer(id)? {
187            Answer::Noul(answer) => Ok(answer.noul),
188            other => Err(wrong_type(id, "noul", other)),
189        }
190    }
191}
192
193fn wrong_type(id: &str, expected: &'static str, found: &Answer) -> Error {
194    Error::WrongType { id: id.to_owned(), expected, found: found.kind().to_owned() }
195}
196
197/// One question's answer.
198#[derive(Debug, Clone, PartialEq)]
199pub enum Answer {
200    Choice(ChoiceAnswer),
201    Score(ScoreAnswer),
202    Noul(NoulAnswer),
203    /// An answer of a type this crate doesn't know yet, kept as it arrived so that nothing the
204    /// endpoint sent is lost on the way back out.
205    Other(Value),
206}
207
208impl Answer {
209    /// The question type, as the endpoint names it.
210    pub fn kind(&self) -> &str {
211        match self {
212            Answer::Choice(_) => "choice",
213            Answer::Score(_) => "score",
214            Answer::Noul(_) => "noul",
215            Answer::Other(answer) => answer.get("type").and_then(Value::as_str).unwrap_or("an answer of another type"),
216        }
217    }
218}
219
220/// The three types this crate knows, tagged as the endpoint tags them. [`Answer`] hands anything
221/// else to `Value`, which no derived enum can do: `#[serde(other)]` takes no payload.
222#[derive(Serialize, Deserialize)]
223#[serde(tag = "type", rename_all = "lowercase")]
224enum Known<'a> {
225    Choice(Cow<'a, ChoiceAnswer>),
226    Score(Cow<'a, ScoreAnswer>),
227    Noul(Cow<'a, NoulAnswer>),
228}
229
230impl Serialize for Answer {
231    fn serialize<S: Serializer>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error> {
232        match self {
233            Answer::Choice(answer) => Known::Choice(Cow::Borrowed(answer)).serialize(serializer),
234            Answer::Score(answer) => Known::Score(Cow::Borrowed(answer)).serialize(serializer),
235            Answer::Noul(answer) => Known::Noul(Cow::Borrowed(answer)).serialize(serializer),
236            Answer::Other(answer) => answer.serialize(serializer),
237        }
238    }
239}
240
241impl<'de> Deserialize<'de> for Answer {
242    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> std::result::Result<Answer, D::Error> {
243        let answer = Value::deserialize(deserializer)?;
244        let known = match answer.get("type").and_then(Value::as_str) {
245            Some("choice" | "score" | "noul") => serde_json::from_value::<Known>(answer.clone()).map_err(D::Error::custom)?,
246            _ => return Ok(Answer::Other(answer)),
247        };
248        Ok(match known {
249            Known::Choice(answer) => Answer::Choice(answer.into_owned()),
250            Known::Score(answer) => Answer::Score(answer.into_owned()),
251            Known::Noul(answer) => Answer::Noul(answer.into_owned()),
252        })
253    }
254}
255
256#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
257pub struct ChoiceAnswer {
258    /// The most probable option.
259    pub choice: String,
260    /// From 0 to 1: how peaked `probabilities` is.
261    pub confidence: f64,
262    /// Every option's probability; they add up to 1.
263    pub probabilities: BTreeMap<String, f64>,
264}
265
266#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
267pub struct ScoreAnswer {
268    /// The expected level, from 0 to the top level; it can fall between two levels.
269    pub score: f64,
270    /// From 0 to 1: how peaked `probabilities` is.
271    pub confidence: f64,
272    /// Each level's probability, by level number; they add up to 1.
273    #[serde(deserialize_with = "by_level")]
274    pub probabilities: BTreeMap<u8, f64>,
275    /// Each level's description, by level number.
276    #[serde(default, deserialize_with = "by_level")]
277    pub legend: BTreeMap<u8, Value>,
278}
279
280/// A map keyed by level numbers, which arrive as strings (`"0"`, `"1"`, …). serde_json reads those
281/// into numbers on its own, but not inside a tagged enum such as [`Answer`], which buffers them first.
282fn by_level<'de, D: Deserializer<'de>, T: Deserialize<'de>>(deserializer: D) -> std::result::Result<BTreeMap<u8, T>, D::Error> {
283    BTreeMap::<String, T>::deserialize(deserializer)?
284        .into_iter()
285        .map(|(level, value)| {
286            level.parse().map(|level| (level, value)).map_err(|_| D::Error::custom(format!("level {level:?} is not a number")))
287        })
288        .collect()
289}
290
291#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
292pub struct NoulAnswer {
293    /// The probability that the answer is yes.
294    pub noul: f64,
295}
296
297#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
298pub struct Usage {
299    pub input_tokens: u64,
300    pub output_tokens: u64,
301    /// What the call cost, in US dollars, when OpenRouter says.
302    #[serde(default, skip_serializing_if = "Option::is_none")]
303    pub cost: Option<f64>,
304}