1use 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#[derive(Debug)]
22pub struct JevClient {
23 client: Client,
24 base_url: String,
25 api_key: Secret,
26}
27
28impl JevClient {
29 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 pub fn from_config(config: &JevConfig) -> Result<Self> {
55 Self::new(&config.base_url, config.api_key.expose_secret())
56 }
57
58 #[must_use]
60 pub fn base_url(&self) -> &str {
61 &self.base_url
62 }
63
64 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
108fn 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}