Skip to main content

jev/
lib.rs

1//! A client for TypeSafe's Jev, a System One model, on OpenRouter's decisions endpoint. Jev reads a
2//! state (a string, a JSON object or an array) and answers typed questions about it: a Choice
3//! between options, a Score along levels, or a Noul, the probability that something is true.
4//!
5//! ```no_run
6//! # async fn run() -> jev::Result<()> {
7//! use jev::{Client, Question};
8//!
9//! let client = Client::new(&std::env::var("OPENROUTER_API_KEY").unwrap_or_default());
10//! let reply = client
11//!     .decide(
12//!         "Help! My payouts have been failing for 3 days.",
13//!         [
14//!             ("is_urgent", Question::noul("Does this message convey urgency?")),
15//!             ("department", Question::choice("Which team should handle this?", [("billing", "Payments"), ("technical", "Bugs")])),
16//!             ("frustration", Question::score("How frustrated is the customer?", ["Calm", "Frustrated", "Very angry"])),
17//!         ],
18//!     )
19//!     .await?;
20//! if reply.noul("is_urgent")? > 0.8 && reply.choice("department")?.confidence > 0.6 {
21//!     println!("page {}", reply.choice("department")?.choice);
22//! }
23//! # Ok(())
24//! # }
25//! ```
26//!
27//! Works natively and on wasm32, where requests go through the host's `fetch`.
28
29mod error;
30#[cfg(feature = "command")]
31pub mod print;
32#[cfg(feature = "command")]
33pub mod rules;
34#[cfg(feature = "command")]
35pub mod skill;
36#[cfg(feature = "command")]
37pub mod spec;
38mod types;
39
40use std::collections::BTreeMap;
41use std::time::Duration;
42
43use serde::Serialize;
44
45pub use error::{Error, Result};
46pub use types::{Answer, ChoiceAnswer, DecisionRequest, DecisionResponse, NoulAnswer, NoulCriteria, Options, Question, ScoreAnswer, Usage};
47
48/// The model requests go to unless the client says otherwise.
49pub const DEFAULT_MODEL: &str = "typesafe/jev-1.13";
50
51/// OpenRouter's decisions endpoint.
52pub const DECISIONS_URL: &str = "https://openrouter.ai/api/alpha/decisions";
53
54/// How long a request may take, from sending it to reading the whole reply, unless the client says
55/// otherwise ([`Client::with_timeout`]). A reply usually takes a second or two.
56pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(60);
57
58/// Sends decision requests. `Debug` isn't derived, to keep the key out of logs.
59#[derive(Clone)]
60pub struct Client {
61    http: reqwest::Client,
62    key: String,
63    url: String,
64    model: String,
65    timeout: Duration,
66}
67
68impl Client {
69    /// A client for OpenRouter with an API key. An empty key sends no `Authorization` header, for an
70    /// endpoint (see [`Client::with_url`]) that adds the key itself.
71    pub fn new(key: &str) -> Client {
72        Client {
73            http: reqwest::Client::new(),
74            key: key.trim().to_owned(),
75            url: DECISIONS_URL.to_owned(),
76            model: DEFAULT_MODEL.to_owned(),
77            timeout: DEFAULT_TIMEOUT,
78        }
79    }
80
81    /// Gives up on a request that takes longer than `timeout` in all, instead of
82    /// [`DEFAULT_TIMEOUT`]. A request that timed out is not sent again: it may have been answered,
83    /// and billed, all the same.
84    pub fn with_timeout(mut self, timeout: Duration) -> Client {
85        self.timeout = timeout;
86        self
87    }
88
89    /// Sends requests through `http`, for a proxy, other TLS roots or a shared connection pool.
90    /// The timeout is still this client's own ([`Client::with_timeout`]).
91    pub fn with_http(mut self, http: reqwest::Client) -> Client {
92        self.http = http;
93        self
94    }
95
96    /// Sends requests to `url` instead of OpenRouter's decisions endpoint.
97    pub fn with_url(mut self, url: &str) -> Client {
98        url.clone_into(&mut self.url);
99        self
100    }
101
102    /// Asks `model` instead of [`DEFAULT_MODEL`].
103    pub fn with_model(mut self, model: &str) -> Client {
104        model.clone_into(&mut self.model);
105        self
106    }
107
108    /// Asks `questions`, under their ids, about `state`.
109    pub async fn decide<K: Into<String>>(
110        &self,
111        state: impl Serialize,
112        questions: impl IntoIterator<Item = (K, Question)>,
113    ) -> Result<DecisionResponse> {
114        self.send(&self.request(state, questions)?).await
115    }
116
117    /// The request [`Client::decide`] sends.
118    pub fn request<K: Into<String>>(
119        &self,
120        state: impl Serialize,
121        questions: impl IntoIterator<Item = (K, Question)>,
122    ) -> Result<DecisionRequest> {
123        let mut asked = BTreeMap::new();
124        for (id, question) in questions {
125            let id = id.into();
126            question.check().map_err(|why| Error::Invalid { id: id.clone(), why })?;
127            // Collecting into the map would keep the last of two questions under one id and drop
128            // the other, leaving the caller an answer short with nothing to say why.
129            if asked.insert(id.clone(), question).is_some() {
130                return Err(Error::DuplicateQuestion(id));
131            }
132        }
133        Ok(DecisionRequest {
134            model: self.model.clone(),
135            state: serde_json::to_value(state).map_err(|e| Error::Decode(format!("state: {e}")))?,
136            questions: asked,
137        })
138    }
139
140    /// Sends `request` as it is, whatever model it names.
141    pub async fn send(&self, request: &DecisionRequest) -> Result<DecisionResponse> {
142        let body = serde_json::to_vec(request).map_err(|e| Error::Decode(e.to_string()))?;
143        let mut post = self.http.post(&self.url).header("content-type", "application/json").timeout(self.timeout).body(body);
144        if !self.key.is_empty() {
145            post = post.bearer_auth(&self.key);
146        }
147        // reqwest's errors hold JavaScript values on wasm32, so they're turned into messages.
148        let reply = post.send().await.map_err(|e| Error::Http(e.to_string()))?;
149        let status = reply.status();
150        let body = reply.bytes().await.map_err(|e| Error::Http(e.to_string()))?;
151        if !status.is_success() {
152            return Err(Error::Status { status: status.as_u16(), message: error_message(&body) });
153        }
154        serde_json::from_slice(&body).map_err(|e| Error::Decode(e.to_string()))
155    }
156}
157
158/// What an error reply says: OpenRouter's `{"error": {"message": …}}`, or the start of the body.
159fn error_message(body: &[u8]) -> String {
160    #[derive(serde::Deserialize)]
161    struct Reply {
162        error: Message,
163    }
164    #[derive(serde::Deserialize)]
165    struct Message {
166        message: String,
167    }
168    match serde_json::from_slice::<Reply>(body) {
169        Ok(reply) => reply.error.message,
170        Err(_) => String::from_utf8_lossy(&body[..body.len().min(300)]).into_owned(),
171    }
172}
173
174#[cfg(test)]
175mod tests {
176    use serde_json::{json, Value};
177
178    use super::*;
179
180    fn triage() -> Vec<(&'static str, Question)> {
181        vec![
182            (
183                "is_urgent",
184                Question::noul_with_criteria("Does this message convey urgency?", "Explicitly time-sensitive", "No urgency expressed"),
185            ),
186            (
187                "department",
188                Question::choice(
189                    "Which team should handle this?",
190                    [
191                        ("billing", "Payments, invoicing, refunds"),
192                        ("technical", "Bugs, outages, integrations"),
193                        ("sales", "Pricing, upgrades, new accounts"),
194                    ],
195                ),
196            ),
197            ("frustration", Question::score("How frustrated is the customer?", ["Calm", "Frustrated", "Very angry"])),
198        ]
199    }
200
201    #[test]
202    fn builds_the_documented_request() {
203        let request = Client::new("key").request("Help! My payouts have been failing for 3 days.", triage()).unwrap();
204        let expected = json!({
205            "model": "typesafe/jev-1.13",
206            "state": "Help! My payouts have been failing for 3 days.",
207            "questions": {
208                "is_urgent": {
209                    "type": "noul",
210                    "instructions": "Does this message convey urgency?",
211                    "criteria": {"true": "Explicitly time-sensitive", "false": "No urgency expressed"}
212                },
213                "department": {
214                    "type": "choice",
215                    "instructions": "Which team should handle this?",
216                    "criteria": {
217                        "billing": "Payments, invoicing, refunds",
218                        "technical": "Bugs, outages, integrations",
219                        "sales": "Pricing, upgrades, new accounts"
220                    }
221                },
222                "frustration": {
223                    "type": "score",
224                    "instructions": "How frustrated is the customer?",
225                    "criteria": ["Calm", "Frustrated", "Very angry"]
226                }
227            }
228        });
229        assert_eq!(serde_json::to_value(&request).unwrap(), expected);
230    }
231
232    #[test]
233    fn keeps_choice_options_in_order() {
234        let question = Question::choice("?", [("zeta", "last letter"), ("alpha", "first letter")]);
235        assert_eq!(
236            serde_json::to_string(&question).unwrap(),
237            r#"{"type":"choice","instructions":"?","criteria":{"zeta":"last letter","alpha":"first letter"}}"#
238        );
239    }
240
241    #[test]
242    fn leaves_out_missing_noul_criteria() {
243        assert_eq!(serde_json::to_value(Question::noul("Is it?")).unwrap(), json!({"type": "noul", "instructions": "Is it?"}));
244    }
245
246    #[test]
247    fn takes_structured_state_and_criteria() {
248        let question = Question::choice(json!({"question": "Which?", "focus": "the primary request"}), [("a", Value::Null)]);
249        let request = Client::new("").with_model("typesafe/jev-latest").request(json!({"message": "hi"}), [("q", question)]).unwrap();
250        assert_eq!(
251            serde_json::to_value(&request).unwrap(),
252            json!({
253                "model": "typesafe/jev-latest",
254                "state": {"message": "hi"},
255                "questions": {"q": {"type": "choice", "instructions": {"question": "Which?", "focus": "the primary request"}, "criteria": {"a": null}}}
256            })
257        );
258    }
259
260    #[test]
261    fn reads_a_reply() {
262        let reply: DecisionResponse = serde_json::from_str(include_str!("../tests/fixtures/decision.json")).unwrap();
263        assert_eq!(reply.model, "typesafe/jev-1.13-20260917");
264        assert_eq!(reply.noul("is_urgent").unwrap(), 0.95);
265
266        let department = reply.choice("department").unwrap();
267        assert_eq!(department.choice, "billing");
268        assert_eq!(department.confidence, 0.82);
269        assert_eq!(department.probabilities["technical"], 0.12);
270
271        let frustration = reply.score("frustration").unwrap();
272        assert_eq!(frustration.score, 1.04);
273        assert_eq!(frustration.probabilities[&1], 0.96);
274        assert_eq!(frustration.legend[&2], json!("Very angry"));
275
276        assert_eq!(reply.usage.input_tokens, 427);
277        assert_eq!(reply.usage.cost, Some(0.000017934));
278        assert_eq!(reply.provider.as_deref(), Some("TypeSafe"));
279    }
280
281    #[test]
282    fn says_which_answer_is_missing_or_of_another_type() {
283        let reply: DecisionResponse = serde_json::from_str(include_str!("../tests/fixtures/decision.json")).unwrap();
284        assert_eq!(reply.noul("nope"), Err(Error::MissingAnswer("nope".into())));
285        assert_eq!(reply.score("department").unwrap_err().to_string(), "question `department` is a choice, not a score");
286    }
287
288    #[test]
289    fn reads_an_unknown_answer_type() {
290        let reply: DecisionResponse = serde_json::from_value(json!({
291            "model": "m",
292            "answers": {"q": {"type": "ranking", "order": [1, 2]}},
293            "usage": {"input_tokens": 1, "output_tokens": 1}
294        }))
295        .unwrap();
296        // Nothing the endpoint sent is dropped: --json hands back what arrived.
297        assert_eq!(reply.answers["q"], Answer::Other(json!({"type": "ranking", "order": [1, 2]})));
298        assert_eq!(serde_json::to_value(&reply.answers["q"]).unwrap(), json!({"type": "ranking", "order": [1, 2]}));
299        assert_eq!(reply.answers["q"].kind(), "ranking");
300        assert_eq!(reply.usage.cost, None);
301    }
302
303    #[test]
304    fn refuses_a_question_asked_twice() {
305        let client = Client::new("key");
306        let twice = [("a", Question::noul("Is it?")), ("a", Question::noul("Is it really?"))];
307        assert_eq!(client.request("state", twice), Err(Error::DuplicateQuestion("a".into())));
308    }
309
310    #[test]
311    fn refuses_a_question_the_endpoint_cannot_answer() {
312        let client = Client::new("key");
313        let invalid = |question| client.request("state", [("q", question)]).unwrap_err().to_string();
314        let options: Vec<(String, &str)> = (0..256).map(|option| (format!("option{option}"), "")).collect();
315        assert!(invalid(Question::choice("Which?", options)).contains("up to 255 options"));
316        assert!(invalid(Question::choice("Which?", [("a", ""), ("a", "")])).contains("`a` is there twice"));
317        assert!(invalid(Question::choice::<String, &str>("Which?", [])).contains("needs options"));
318        assert!(invalid(Question::score("How much?", ["level"; 11])).contains("up to 10 levels"));
319        assert!(invalid(Question::score::<&str>("How much?", [])).contains("needs levels"));
320        // The docs ask for two levels, but the endpoint answers a single one, so the client allows it.
321        assert!(client.request("state", [("q", Question::score("How much?", ["the only level"]))]).is_ok());
322    }
323
324    #[test]
325    fn reads_error_replies() {
326        assert_eq!(error_message(br#"{"error":{"message":"Missing Authentication header","code":401}}"#), "Missing Authentication header");
327        assert_eq!(error_message(b"Bad Gateway"), "Bad Gateway");
328    }
329}