1use reqwest::header::{self, HeaderMap, HeaderValue};
4use serde::Serialize;
5use serde_json::{Map, Value};
6use std::fmt;
7use std::net::IpAddr;
8use std::time::Duration;
9
10use crate::error::{ApiError, Error};
11use crate::models::ListModelsResponse;
12use crate::question::Question;
13use crate::response::SystemOneResponse;
14use crate::retry::RetryPolicy;
15
16pub const DEFAULT_BASE_URL: &str = "https://api.typesafe.ai";
18pub const DEFAULT_MODEL: &str = "jev-latest";
20pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(60);
22
23pub const ENV_API_KEY: &str = "TYPESAFE_API_KEY";
25pub const ENV_DEFAULT_MODEL: &str = "TYPESAFE_DEFAULT_MODEL";
27pub const ENV_BASE_URL: &str = "TYPESAFE_BASE_URL";
29
30const USER_AGENT: &str = concat!("jev-sdk/", env!("CARGO_PKG_VERSION"));
31
32const MAX_SUCCESS_BODY: u64 = 16 * 1024 * 1024; const MAX_ERROR_BODY: usize = 64 * 1024; #[derive(Clone)]
59pub struct TypeSafeClient {
60 http: reqwest::Client,
61 auth: HeaderValue,
64 base_url: String,
65 model: String,
66 retry: RetryPolicy,
67}
68
69impl fmt::Debug for TypeSafeClient {
72 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
73 f.debug_struct("TypeSafeClient")
74 .field("auth", &"[redacted]")
75 .field("base_url", &self.base_url)
76 .field("model", &self.model)
77 .field("retry", &self.retry)
78 .finish_non_exhaustive()
79 }
80}
81
82impl TypeSafeClient {
83 pub fn new(api_key: impl Into<String>) -> Result<Self, Error> {
85 Self::builder().api_key(api_key).build()
86 }
87
88 pub fn from_env() -> Result<Self, Error> {
94 Self::builder().build()
95 }
96
97 pub fn builder() -> ClientBuilder {
99 ClientBuilder::default()
100 }
101
102 pub fn model(&self) -> &str {
104 &self.model
105 }
106
107 pub async fn system_one<S, I, K, Q>(
115 &self,
116 state: S,
117 questions: I,
118 ) -> Result<SystemOneResponse, Error>
119 where
120 S: Serialize,
121 I: IntoIterator<Item = (K, Q)>,
122 K: Into<String>,
123 Q: Into<Question>,
124 {
125 let model = self.model.clone();
126 self.system_one_with_model(&model, state, questions).await
127 }
128
129 pub async fn system_one_with_model<S, I, K, Q>(
131 &self,
132 model: &str,
133 state: S,
134 questions: I,
135 ) -> Result<SystemOneResponse, Error>
136 where
137 S: Serialize,
138 I: IntoIterator<Item = (K, Q)>,
139 K: Into<String>,
140 Q: Into<Question>,
141 {
142 let questions = build_questions_map(questions)?;
143 let state =
144 serde_json::to_value(state).map_err(|e| Error::InvalidRequest(e.to_string()))?;
145
146 let mut body = Map::new();
147 body.insert("state".to_owned(), state);
148 body.insert("model".to_owned(), Value::String(model.to_owned()));
149 body.insert("questions".to_owned(), Value::Object(questions));
150
151 let url = self.endpoint("/v1/systemone");
152 let body = Value::Object(body);
153 self.send_with_retry(|| self.http.post(&url).json(&body))
154 .await
155 }
156
157 pub async fn list_models(&self) -> Result<ListModelsResponse, Error> {
159 let url = self.endpoint("/v1/models");
160 self.send_with_retry(|| self.http.get(&url)).await
161 }
162
163 fn endpoint(&self, path: &str) -> String {
164 format!("{}{path}", self.base_url.trim_end_matches('/'))
165 }
166
167 async fn send_with_retry<T, F>(&self, make_request: F) -> Result<T, Error>
168 where
169 T: serde::de::DeserializeOwned,
170 F: Fn() -> reqwest::RequestBuilder,
171 {
172 let mut attempt = 0u32;
173 loop {
174 let request = make_request()
175 .header(header::AUTHORIZATION, self.auth.clone())
176 .header(header::ACCEPT, "application/json");
177 match request.send().await {
178 Ok(resp) => {
179 let status = resp.status();
180 if status.is_success() {
181 let body = read_body_limited(resp, MAX_SUCCESS_BODY).await?;
182 return serde_json::from_slice::<T>(&body)
183 .map_err(|e| Error::Decode(e.to_string()));
184 }
185 let headers = resp.headers().clone();
186 let body = read_body_prefix(resp, MAX_ERROR_BODY).await;
187 let raw = String::from_utf8_lossy(&body);
188 let err = ApiError::from_response(status, &raw);
189 if attempt < self.retry.max_retries && self.retry.should_retry_status(status) {
190 let wait = self.retry.delay_for(attempt, &headers);
191 tokio::time::sleep(wait).await;
192 attempt += 1;
193 continue;
194 }
195 return Err(Error::Api(err));
196 }
197 Err(e) => {
198 let transient = e.is_connect() || e.is_timeout();
199 if attempt < self.retry.max_retries && transient {
200 let wait = self.retry.backoff_for(attempt);
201 tokio::time::sleep(wait).await;
202 attempt += 1;
203 continue;
204 }
205 return Err(Error::Connection(e.to_string()));
206 }
207 }
208 }
209 }
210}
211
212#[derive(Default)]
218pub struct ClientBuilder {
219 api_key: Option<String>,
220 model: Option<String>,
221 base_url: Option<String>,
222 timeout: Option<Duration>,
223 retry: Option<RetryPolicy>,
224 default_headers: Option<HeaderMap>,
225 http_client: Option<reqwest::Client>,
226}
227
228impl fmt::Debug for ClientBuilder {
230 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
231 f.debug_struct("ClientBuilder")
232 .field("api_key", &self.api_key.as_ref().map(|_| "[redacted]"))
233 .field("model", &self.model)
234 .field("base_url", &self.base_url)
235 .field("timeout", &self.timeout)
236 .field("retry", &self.retry)
237 .field("default_headers", &self.default_headers)
238 .finish_non_exhaustive()
239 }
240}
241
242impl ClientBuilder {
243 pub fn api_key(mut self, api_key: impl Into<String>) -> Self {
245 self.api_key = Some(api_key.into());
246 self
247 }
248
249 pub fn model(mut self, model: impl Into<String>) -> Self {
251 self.model = Some(model.into());
252 self
253 }
254
255 pub fn base_url(mut self, base_url: impl Into<String>) -> Self {
257 self.base_url = Some(base_url.into());
258 self
259 }
260
261 pub fn timeout(mut self, timeout: Duration) -> Self {
263 self.timeout = Some(timeout);
264 self
265 }
266
267 pub fn retry(mut self, retry: RetryPolicy) -> Self {
269 self.retry = Some(retry);
270 self
271 }
272
273 pub fn default_headers(mut self, headers: HeaderMap) -> Self {
276 self.default_headers = Some(headers);
277 self
278 }
279
280 pub fn http_client(mut self, client: reqwest::Client) -> Self {
288 self.http_client = Some(client);
289 self
290 }
291
292 pub fn build(self) -> Result<TypeSafeClient, Error> {
294 let api_key = resolve(self.api_key, ENV_API_KEY).ok_or_else(|| {
295 Error::Config(format!(
296 "missing API key: set {ENV_API_KEY} or use ClientBuilder::api_key"
297 ))
298 })?;
299 if api_key.trim().is_empty() {
300 return Err(Error::Config("API key is empty".into()));
301 }
302
303 let base_url =
304 resolve(self.base_url, ENV_BASE_URL).unwrap_or_else(|| DEFAULT_BASE_URL.to_owned());
305 validate_base_url(&base_url)?;
306
307 let model =
308 resolve(self.model, ENV_DEFAULT_MODEL).unwrap_or_else(|| DEFAULT_MODEL.to_owned());
309
310 let mut auth = HeaderValue::from_str(&format!("Bearer {api_key}"))
311 .map_err(|_| Error::Config("API key contains invalid header characters".into()))?;
312 auth.set_sensitive(true);
313
314 let http = match self.http_client {
315 Some(client) => client,
316 None => {
317 let mut builder = reqwest::Client::builder()
318 .user_agent(USER_AGENT)
319 .timeout(self.timeout.unwrap_or(DEFAULT_TIMEOUT))
320 .redirect(reqwest::redirect::Policy::none());
324 if let Some(headers) = self.default_headers {
325 builder = builder.default_headers(headers);
326 }
327 builder
328 .build()
329 .map_err(|e| Error::Config(format!("failed to build HTTP client: {e}")))?
330 }
331 };
332
333 Ok(TypeSafeClient {
334 http,
335 auth,
336 base_url,
337 model,
338 retry: self.retry.unwrap_or_default(),
339 })
340 }
341}
342
343fn resolve(explicit: Option<String>, env: &str) -> Option<String> {
346 explicit.or_else(|| std::env::var(env).ok()).and_then(|v| {
347 let trimmed = v.trim();
348 (!trimmed.is_empty()).then_some(v)
349 })
350}
351
352fn validate_base_url(base_url: &str) -> Result<(), Error> {
356 let url = reqwest::Url::parse(base_url)
357 .map_err(|_| Error::Config(format!("invalid base URL: {base_url:?}")))?;
358 if url.scheme() == "https" {
359 return Ok(());
360 }
361 if url.scheme() == "http" && url.host_str().is_some_and(is_loopback_host) {
362 return Ok(());
363 }
364 Err(Error::Config(format!(
365 "base URL {base_url:?} must use https (http is only allowed for loopback hosts); \
366 refusing to send the API key over plaintext"
367 )))
368}
369
370fn is_loopback_host(host: &str) -> bool {
374 if host == "localhost" || host.ends_with(".localhost") {
375 return true;
376 }
377 let bare = host.strip_prefix('[').and_then(|h| h.strip_suffix(']'));
378 let candidate = bare.unwrap_or(host);
379 candidate.parse::<IpAddr>().is_ok_and(|ip| ip.is_loopback())
380}
381
382async fn read_body_limited(mut resp: reqwest::Response, limit: u64) -> Result<Vec<u8>, Error> {
385 if resp.content_length().is_some_and(|len| len > limit) {
386 return Err(Error::Decode(format!(
387 "response body too large (Content-Length exceeds {limit} bytes)"
388 )));
389 }
390 let mut buf = Vec::new();
391 while let Some(chunk) = resp
392 .chunk()
393 .await
394 .map_err(|e| Error::Connection(e.to_string()))?
395 {
396 if buf.len() as u64 + chunk.len() as u64 > limit {
397 return Err(Error::Decode(format!(
398 "response body exceeded the {limit}-byte limit"
399 )));
400 }
401 buf.extend_from_slice(&chunk);
402 }
403 Ok(buf)
404}
405
406async fn read_body_prefix(mut resp: reqwest::Response, limit: usize) -> Vec<u8> {
409 let mut buf = Vec::new();
410 loop {
411 let remaining = limit.saturating_sub(buf.len());
412 if remaining == 0 {
413 break;
414 }
415 match resp.chunk().await {
416 Ok(Some(chunk)) => buf.extend_from_slice(&chunk[..chunk.len().min(remaining)]),
417 Ok(None) | Err(_) => break,
418 }
419 }
420 buf
421}
422
423fn build_questions_map<I, K, Q>(questions: I) -> Result<Map<String, Value>, Error>
424where
425 I: IntoIterator<Item = (K, Q)>,
426 K: Into<String>,
427 Q: Into<Question>,
428{
429 let mut map = Map::new();
430 for (key, question) in questions {
431 let question = question.into();
432 if let Question::Score(score) = &question {
433 if score.criteria.is_empty() {
434 return Err(Error::InvalidRequest(format!(
435 "score question {:?} must have at least one criteria level",
436 key.into()
437 )));
438 }
439 }
440 let key = key.into();
441 let value = serde_json::to_value(&question)
442 .map_err(|e| Error::InvalidRequest(format!("question {key:?}: {e}")))?;
443 map.insert(key, value);
444 }
445 if map.is_empty() {
446 return Err(Error::InvalidRequest(
447 "questions must not be empty".to_owned(),
448 ));
449 }
450 Ok(map)
451}
452
453#[cfg(test)]
454mod tests {
455 use super::*;
456 use crate::question::{Choice, Noul, Score};
457
458 #[test]
459 fn builder_requires_api_key() {
460 let key_present = std::env::var(ENV_API_KEY).is_ok();
462 let result = TypeSafeClient::builder().build();
463 if key_present {
464 assert!(result.is_ok());
465 } else {
466 assert!(matches!(result, Err(Error::Config(_))));
467 }
468 }
469
470 #[test]
471 fn builder_rejects_bad_base_url() {
472 let result = TypeSafeClient::builder()
473 .api_key("k")
474 .base_url("not a url")
475 .build();
476 assert!(matches!(result, Err(Error::Config(_))));
477 }
478
479 #[test]
480 fn builder_rejects_plaintext_http_base_url() {
481 let result = TypeSafeClient::builder()
483 .api_key("k")
484 .base_url("http://api.typesafe.ai")
485 .build();
486 let err = result.unwrap_err();
487 assert!(matches!(err, Error::Config(_)));
488 assert!(err.to_string().contains("https"), "{err}");
489
490 let result = TypeSafeClient::builder()
492 .api_key("k")
493 .base_url("ftp://example.com")
494 .build();
495 assert!(matches!(result.unwrap_err(), Error::Config(_)));
496 }
497
498 #[test]
499 fn builder_allows_loopback_http_for_testing() {
500 for url in [
501 "http://localhost:8080",
502 "http://127.0.0.1:8080",
503 "http://[::1]",
504 "http://test.localhost",
505 "https://api.example.com",
506 ] {
507 TypeSafeClient::builder()
508 .api_key("k")
509 .base_url(url)
510 .build()
511 .unwrap_or_else(|e| panic!("{url} should be accepted: {e}"));
512 }
513 }
514
515 #[test]
516 fn builder_rejects_key_with_control_characters() {
517 let result = TypeSafeClient::builder()
519 .api_key("good\r\nX-Injected: bad")
520 .build();
521 assert!(matches!(result.unwrap_err(), Error::Config(_)));
522 }
523
524 #[test]
525 fn debug_formatting_never_exposes_the_api_key() {
526 let secret = "apikey_supersecretvalue";
527 let builder = TypeSafeClient::builder().api_key(secret);
528 let client = TypeSafeClient::new(secret).unwrap();
529
530 for rendered in [format!("{builder:?}"), format!("{client:?}")] {
531 assert!(
532 !rendered.contains(secret),
533 "secret leaked into Debug output: {rendered}"
534 );
535 assert!(rendered.contains("[redacted]"), "{rendered}");
536 }
537 }
538
539 #[test]
540 fn rejects_empty_questions() {
541 let questions: Vec<(String, Question)> = vec![];
542 let err = build_questions_map(questions).unwrap_err();
543 assert!(matches!(err, Error::InvalidRequest(_)));
544 }
545
546 #[test]
547 fn rejects_empty_score_criteria() {
548 let questions = vec![(
549 "s",
550 Question::from(Score::new("rate", Vec::<String>::new())),
551 )];
552 let err = build_questions_map(questions).unwrap_err();
553 assert!(matches!(err, Error::InvalidRequest(_)));
554 }
555
556 #[test]
557 fn builds_mixed_question_map() {
558 let questions = vec![
559 (
560 "tone",
561 Question::from(Choice::options("Tone?", ["calm", "angry"])),
562 ),
563 ("billing", Question::from(Noul::new("Billing?"))),
564 ];
565 let map = build_questions_map(questions).unwrap();
566 assert_eq!(map.len(), 2);
567 assert_eq!(map["tone"]["type"], "choice");
568 assert_eq!(map["billing"]["type"], "noul");
569 let keys: Vec<_> = map.keys().collect();
571 assert_eq!(keys, ["tone", "billing"]);
572 }
573}