Skip to main content

omni_dev/jev/
client.rs

1//! Jev "System One" REST API client.
2//!
3//! Thin `reqwest` wrapper around the single `POST /v1/systemone` endpoint.
4//! Retries `429`/`529` responses via the shared `utils::http::retry_if`
5//! driver (honouring `Retry-After` / `X-RateLimit-Reset`) — `529` is Jev's
6//! own "Overloaded" status, so the literal-429 `utils::http::retry_429`
7//! driver would not retry it.
8
9use anyhow::{Context, Result};
10use reqwest::Client;
11
12use crate::jev::config::JevConfig;
13use crate::jev::error::JevError;
14use crate::jev::protocol::SystemOneRequest;
15use crate::jev::protocol::SystemOneResponse;
16use crate::request_log;
17use crate::utils::http::{connect_timeout, read_timeout, retry_if};
18use crate::utils::secret::Secret;
19
20/// HTTP client for the Jev System One REST API.
21#[derive(Debug)]
22pub struct JevClient {
23    client: Client,
24    base_url: String,
25    api_key: Secret,
26}
27
28impl JevClient {
29    /// Creates a new Jev API client.
30    ///
31    /// `base_url` should be the full API host, e.g. `https://api.typesafe.ai`.
32    /// For production use, construct via [`Self::from_config`]; tests pass a
33    /// wiremock URL directly.
34    ///
35    /// Uses the shared REST-client connect/read timeout split
36    /// (`connect_timeout`, `read_timeout`) rather than the AI backends'
37    /// much longer `OMNI_DEV_AI_TIMEOUT_SECS` budget — Jev is a low-latency
38    /// model, not a chat completion.
39    pub fn new(base_url: &str, api_key: &str) -> Result<Self> {
40        let client = Client::builder()
41            .connect_timeout(connect_timeout())
42            .read_timeout(read_timeout())
43            .build()
44            .context("Failed to build HTTP client")?;
45
46        Ok(Self {
47            client,
48            base_url: base_url.trim_end_matches('/').to_string(),
49            api_key: api_key.into(),
50        })
51    }
52
53    /// Creates a client from resolved [`JevConfig`].
54    pub fn from_config(config: &JevConfig) -> Result<Self> {
55        Self::new(&config.base_url, config.api_key.expose_secret())
56    }
57
58    /// Returns the API base URL (without trailing slash).
59    #[must_use]
60    pub fn base_url(&self) -> &str {
61        &self.base_url
62    }
63
64    /// Sends a `POST /v1/systemone` request and returns the parsed response.
65    ///
66    /// `.json(req)` sets `Content-Type: application/json` itself.
67    ///
68    /// Retries on `429` (rate limited) and `529` (overloaded); any other
69    /// non-2xx status becomes [`JevError::ApiRequestFailed`]. A `2xx` body
70    /// that fails to parse names each answer it could not read and that
71    /// answer's `type`, since the likeliest cause is a primitive added by a
72    /// newer Jev API version.
73    pub async fn system_one(&self, req: &SystemOneRequest) -> Result<SystemOneResponse> {
74        let url = format!("{}/v1/systemone", self.base_url);
75        let response = retry_if(
76            || {
77                self.client
78                    .post(&url)
79                    .header(
80                        "Authorization",
81                        format!("Bearer {}", self.api_key.expose_secret()),
82                    )
83                    .json(req)
84            },
85            |started, result| {
86                request_log::record_http_result("jev", "POST", &url, started, result);
87            },
88            |status, _body| status == 429 || status == 529,
89            None,
90        )
91        .await
92        .context("Failed to send request to Jev API")?;
93
94        if !response.status().is_success() {
95            let status = response.status().as_u16();
96            let body = response.text().await.unwrap_or_default();
97            return Err(JevError::ApiRequestFailed { status, body }.into());
98        }
99
100        let body = response
101            .bytes()
102            .await
103            .context("Failed to read Jev API response")?;
104        serde_json::from_slice(&body).map_err(|err| parse_error(&body, err))
105    }
106}
107
108/// Builds the error for a `2xx` body that failed to parse.
109///
110/// A typed-response failure alone doesn't say *which* answer broke, so this
111/// re-reads the body loosely and names each answer that doesn't fit
112/// [`Answer`](crate::jev::protocol::Answer), with its `type`. Trying each
113/// answer against the enum itself, rather than a list of known type names,
114/// keeps this from drifting when a variant is added.
115fn parse_error(body: &[u8], err: serde_json::Error) -> anyhow::Error {
116    let bad_answers: Vec<String> = serde_json::from_slice::<serde_json::Value>(body)
117        .ok()
118        .as_ref()
119        .and_then(|value| value.get("answers"))
120        .and_then(serde_json::Value::as_object)
121        .map(|answers| {
122            answers
123                .iter()
124                .filter_map(|(name, answer)| {
125                    serde_json::from_value::<crate::jev::protocol::Answer>(answer.clone())
126                        .err()
127                        .map(|e| {
128                            let ty = answer
129                                .get("type")
130                                .and_then(serde_json::Value::as_str)
131                                .unwrap_or("<missing>");
132                            format!("{name:?} (type {ty:?}: {e})")
133                        })
134                })
135                .collect()
136        })
137        .unwrap_or_default();
138
139    if bad_answers.is_empty() {
140        anyhow::Error::new(err).context("Failed to parse Jev API response")
141    } else {
142        anyhow::anyhow!(
143            "Jev API returned answers this version of omni-dev cannot read: {}; \
144             if the type is new, try upgrading omni-dev",
145            bad_answers.join(", ")
146        )
147    }
148}
149
150#[cfg(test)]
151#[allow(clippy::unwrap_used, clippy::expect_used)]
152mod tests {
153    use super::*;
154    use crate::jev::protocol::Question;
155    use std::collections::BTreeMap;
156
157    fn sample_request() -> SystemOneRequest {
158        SystemOneRequest {
159            state: serde_json::json!("payouts failing 3 days"),
160            model: "jev-latest".to_string(),
161            questions: BTreeMap::from([(
162                "refund".to_string(),
163                Question::Noul {
164                    instructions: "Urgent?".to_string(),
165                    criteria: None,
166                },
167            )]),
168        }
169    }
170
171    #[test]
172    fn new_client_strips_trailing_slash() {
173        let client = JevClient::new("https://api.typesafe.ai/", "key").unwrap();
174        assert_eq!(client.base_url(), "https://api.typesafe.ai");
175    }
176
177    #[test]
178    fn client_debug_redacts_api_key() {
179        let client = JevClient::new("https://api.typesafe.ai", "sekret-key").unwrap();
180        let debug = format!("{client:?}");
181        assert!(!debug.contains("sekret-key"));
182        assert!(debug.contains("api_key: <redacted>"));
183    }
184
185    #[test]
186    fn from_config_uses_resolved_values() {
187        let config = JevConfig {
188            api_key: "sekret-key".into(),
189            base_url: "https://api.typesafe.ai".to_string(),
190            model: "jev-latest".to_string(),
191        };
192        let client = JevClient::from_config(&config).unwrap();
193        assert_eq!(client.base_url(), "https://api.typesafe.ai");
194    }
195
196    async fn system_one_with_200_body(body: serde_json::Value) -> anyhow::Error {
197        let server = wiremock::MockServer::start().await;
198        wiremock::Mock::given(wiremock::matchers::method("POST"))
199            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(body))
200            .mount(&server)
201            .await;
202        let client = JevClient::new(&server.uri(), "my-key").unwrap();
203        client.system_one(&sample_request()).await.unwrap_err()
204    }
205
206    #[tokio::test]
207    async fn unknown_answer_type_is_named_in_the_error() {
208        let err = system_one_with_200_body(serde_json::json!({
209            "model": "jev-2.0.0",
210            "answers": {
211                "refund": {"type": "noul", "noul": 0.5},
212                "sentiment": {"type": "range", "low": 0.1, "high": 0.4}
213            },
214            "usage": {"input_tokens": 1, "output_tokens": 1}
215        }))
216        .await;
217        let msg = format!("{err:#}");
218        assert!(
219            msg.contains(
220                "\"sentiment\" (type \"range\": unknown variant `range`, \
221                 expected one of `choice`, `score`, `noul`)"
222            ),
223            "{msg}"
224        );
225        assert!(!msg.contains("\"refund\""), "{msg}");
226        assert!(msg.contains("try upgrading omni-dev"), "{msg}");
227    }
228
229    #[tokio::test]
230    async fn malformed_body_without_bad_answers_keeps_the_parse_reason() {
231        let err = system_one_with_200_body(serde_json::json!({"unexpected": true})).await;
232        let msg = format!("{err:#}");
233        assert!(msg.starts_with("Failed to parse Jev API response"), "{msg}");
234        assert!(msg.contains("missing field"), "{msg}");
235        assert!(!msg.contains("upgrading"), "{msg}");
236    }
237
238    #[tokio::test]
239    async fn system_one_sends_bearer_header_and_body() {
240        let server = wiremock::MockServer::start().await;
241        wiremock::Mock::given(wiremock::matchers::method("POST"))
242            .and(wiremock::matchers::path("/v1/systemone"))
243            .and(wiremock::matchers::header("Authorization", "Bearer my-key"))
244            .and(wiremock::matchers::header(
245                "Content-Type",
246                "application/json",
247            ))
248            .and(wiremock::matchers::body_json(serde_json::json!({
249                "state": "payouts failing 3 days",
250                "model": "jev-latest",
251                "questions": {
252                    "refund": {"type": "noul", "instructions": "Urgent?"}
253                }
254            })))
255            .respond_with(
256                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
257                    "model": "jev-1.13.0",
258                    "answers": {"refund": {"type": "noul", "noul": 0.5}},
259                    "usage": {"input_tokens": 1, "output_tokens": 1}
260                })),
261            )
262            .expect(1)
263            .mount(&server)
264            .await;
265
266        let client = JevClient::new(&server.uri(), "my-key").unwrap();
267        let response = client.system_one(&sample_request()).await.unwrap();
268        assert_eq!(response.model, "jev-1.13.0");
269    }
270
271    #[tokio::test]
272    async fn system_one_200_parses() {
273        let server = wiremock::MockServer::start().await;
274        wiremock::Mock::given(wiremock::matchers::method("POST"))
275            .and(wiremock::matchers::path("/v1/systemone"))
276            .respond_with(
277                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
278                    "model": "jev-1.13.0",
279                    "answers": {},
280                    "usage": {"input_tokens": 0, "output_tokens": 0}
281                })),
282            )
283            .mount(&server)
284            .await;
285
286        let client = JevClient::new(&server.uri(), "my-key").unwrap();
287        let response = client.system_one(&sample_request()).await.unwrap();
288        assert_eq!(response.model, "jev-1.13.0");
289        assert!(response.answers.is_empty());
290    }
291
292    #[tokio::test]
293    async fn system_one_401_maps_to_api_request_failed() {
294        let server = wiremock::MockServer::start().await;
295        wiremock::Mock::given(wiremock::matchers::method("POST"))
296            .and(wiremock::matchers::path("/v1/systemone"))
297            .respond_with(wiremock::ResponseTemplate::new(401).set_body_string("Unauthorized"))
298            .expect(1)
299            .mount(&server)
300            .await;
301
302        let client = JevClient::new(&server.uri(), "my-key").unwrap();
303        let err = client.system_one(&sample_request()).await.unwrap_err();
304        match err.downcast_ref::<JevError>() {
305            Some(JevError::ApiRequestFailed { status, body }) => {
306                assert_eq!(*status, 401);
307                assert_eq!(body, "Unauthorized");
308            }
309            other => panic!("expected ApiRequestFailed, got {other:?}"),
310        }
311    }
312
313    #[tokio::test]
314    async fn system_one_422_maps_to_api_request_failed_and_is_not_retried() {
315        let server = wiremock::MockServer::start().await;
316        wiremock::Mock::given(wiremock::matchers::method("POST"))
317            .and(wiremock::matchers::path("/v1/systemone"))
318            .respond_with(wiremock::ResponseTemplate::new(422).set_body_string("invalid criteria"))
319            .expect(1)
320            .mount(&server)
321            .await;
322
323        let client = JevClient::new(&server.uri(), "my-key").unwrap();
324        let err = client.system_one(&sample_request()).await.unwrap_err();
325        match err.downcast_ref::<JevError>() {
326            Some(JevError::ApiRequestFailed { status, body }) => {
327                assert_eq!(*status, 422);
328                assert_eq!(body, "invalid criteria");
329            }
330            other => panic!("expected ApiRequestFailed, got {other:?}"),
331        }
332    }
333
334    #[tokio::test]
335    async fn system_one_retries_529_then_succeeds() {
336        let server = wiremock::MockServer::start().await;
337        wiremock::Mock::given(wiremock::matchers::method("POST"))
338            .and(wiremock::matchers::path("/v1/systemone"))
339            .respond_with(wiremock::ResponseTemplate::new(529).append_header("Retry-After", "0"))
340            .up_to_n_times(1)
341            .with_priority(1)
342            .mount(&server)
343            .await;
344        wiremock::Mock::given(wiremock::matchers::method("POST"))
345            .and(wiremock::matchers::path("/v1/systemone"))
346            .respond_with(
347                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
348                    "model": "jev-1.13.0",
349                    "answers": {},
350                    "usage": {"input_tokens": 0, "output_tokens": 0}
351                })),
352            )
353            .with_priority(2)
354            .mount(&server)
355            .await;
356
357        let client = JevClient::new(&server.uri(), "my-key").unwrap();
358        let response = client.system_one(&sample_request()).await.unwrap();
359        assert_eq!(response.model, "jev-1.13.0");
360        assert_eq!(server.received_requests().await.unwrap().len(), 2);
361    }
362
363    #[tokio::test]
364    async fn system_one_retries_429_then_succeeds() {
365        let server = wiremock::MockServer::start().await;
366        wiremock::Mock::given(wiremock::matchers::method("POST"))
367            .and(wiremock::matchers::path("/v1/systemone"))
368            .respond_with(wiremock::ResponseTemplate::new(429).append_header("Retry-After", "0"))
369            .up_to_n_times(1)
370            .with_priority(1)
371            .mount(&server)
372            .await;
373        wiremock::Mock::given(wiremock::matchers::method("POST"))
374            .and(wiremock::matchers::path("/v1/systemone"))
375            .respond_with(
376                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
377                    "model": "jev-1.13.0",
378                    "answers": {},
379                    "usage": {"input_tokens": 0, "output_tokens": 0}
380                })),
381            )
382            .with_priority(2)
383            .mount(&server)
384            .await;
385
386        let client = JevClient::new(&server.uri(), "my-key").unwrap();
387        let response = client.system_one(&sample_request()).await.unwrap();
388        assert_eq!(response.model, "jev-1.13.0");
389        assert_eq!(server.received_requests().await.unwrap().len(), 2);
390    }
391}