jevapi 0.1.0

An asynchronous, typed Rust client for the TypeSafe AI evaluation API
Documentation
use jevapi::{Answer, Client, ClientBuilder, Error, EvaluationRequest, Question, RetryPolicy};
use serde_json::{Value, json};
use std::{
    sync::{
        Arc, Mutex,
        atomic::{AtomicUsize, Ordering},
    },
    time::Duration,
};
use wiremock::{
    Mock, MockServer, Request, ResponseTemplate,
    matchers::{body_json, header, method, path},
};

fn request() -> EvaluationRequest {
    EvaluationRequest::new("Service is down").question("urgent", Question::noul("Urgent?"))
}

fn response() -> Value {
    json!({"model": "jev-test", "answers": {"urgent": {"type": "noul", "noul": 0.95}}, "usage": {"input_tokens": 20, "output_tokens": 5}})
}

fn builder(server: &MockServer) -> ClientBuilder {
    Client::builder()
        .api_key("test-key")
        .base_url(format!("{}/v1", server.uri()))
        .http_client(
            reqwest::Client::builder()
                .no_proxy()
                .redirect(reqwest::redirect::Policy::none())
                .retry(reqwest::retry::never())
                .build()
                .unwrap(),
        )
}

fn fast_retry(max_retries: u32) -> RetryPolicy {
    RetryPolicy {
        max_retries,
        initial_delay: Duration::from_millis(10),
        max_delay: Duration::from_millis(25),
    }
}

#[tokio::test]
async fn posts_documented_path_headers_body_and_decodes_response() {
    let server = MockServer::start().await;
    Mock::given(method("POST")).and(path("/proxy/v1/systemone"))
        .and(header("authorization", "Bearer test-key"))
        .and(header("content-type", "application/json"))
        .and(header("accept", "application/json"))
        .and(header("user-agent", concat!("jevapi/", env!("CARGO_PKG_VERSION"))))
        .and(body_json(json!({"state": "Service is down", "model": "jev-latest", "questions": {"urgent": {"type": "noul", "instructions": "Urgent?"}}})))
        .respond_with(ResponseTemplate::new(200).set_body_json(response())).expect(2).mount(&server).await;
    for suffix in ["/proxy/v1", "/proxy/v1/"] {
        let client = builder(&server)
            .base_url(format!("{}{suffix}", server.uri()))
            .build()
            .unwrap();
        let result = client.evaluate(&request()).await.unwrap();
        assert_eq!(result.model, "jev-test");
        assert_eq!(result.answers["urgent"], Answer::Noul { noul: 0.95 });
        assert_eq!(result.usage.input_tokens, 20);
    }
}

#[tokio::test]
async fn invalid_deserialized_request_never_reaches_server() {
    let server = MockServer::start().await;
    let invalid: EvaluationRequest = serde_json::from_value(json!({
        "state": [], "model": "jev-latest", "questions": {"rating": {"type": "score", "instructions": "Rate", "criteria": ["One"]}}
    })).unwrap();
    let error = builder(&server)
        .build()
        .unwrap()
        .evaluate(&invalid)
        .await
        .unwrap_err();
    assert!(matches!(error, Error::Validation(_)));
    assert!(server.received_requests().await.unwrap().is_empty());
}

#[tokio::test]
async fn retries_429_and_529_then_succeeds() {
    for status in [429, 529] {
        let server = MockServer::start().await;
        let count = Arc::new(AtomicUsize::new(0));
        let seen = count.clone();
        Mock::given(method("POST"))
            .respond_with(move |_: &Request| {
                if seen.fetch_add(1, Ordering::SeqCst) == 0 {
                    ResponseTemplate::new(status).set_body_string("busy")
                } else {
                    ResponseTemplate::new(200).set_body_json(response())
                }
            })
            .expect(2)
            .mount(&server)
            .await;
        builder(&server)
            .retry_policy(fast_retry(1))
            .build()
            .unwrap()
            .evaluate(&request())
            .await
            .unwrap();
        assert_eq!(count.load(Ordering::SeqCst), 2);
        let requests = server.received_requests().await.unwrap();
        assert_eq!(requests[0].body, requests[1].body);
    }
}

#[tokio::test]
async fn retry_budget_and_exponential_delays_are_enforced() {
    let server = MockServer::start().await;
    let times = Arc::new(Mutex::new(Vec::new()));
    let seen = times.clone();
    Mock::given(method("POST"))
        .respond_with(move |_: &Request| {
            seen.lock().unwrap().push(tokio::time::Instant::now());
            ResponseTemplate::new(529).set_body_string("overloaded")
        })
        .expect(5)
        .mount(&server)
        .await;
    let error = builder(&server)
        .retry_policy(fast_retry(4))
        .build()
        .unwrap()
        .evaluate(&request())
        .await
        .unwrap_err();
    assert!(
        matches!(error, Error::Api(ref error) if error.status.as_u16() == 529 && error.body == "overloaded")
    );
    let times = times.lock().unwrap();
    for (pair, minimum_micros) in times.windows(2).zip([5_000, 10_000, 12_500, 12_500]) {
        assert!(pair[1].duration_since(pair[0]) >= Duration::from_micros(minimum_micros));
    }
}

#[tokio::test]
async fn nonretryable_statuses_preserve_json_or_plain_text_errors() {
    for status in [401, 422, 500] {
        let server = MockServer::start().await;
        Mock::given(method("POST"))
            .respond_with(
                ResponseTemplate::new(status)
                    .insert_header("x-request-id", "request-123")
                    .set_body_json(json!({"detail": [{"field": "state", "message": "invalid"}]})),
            )
            .expect(1)
            .mount(&server)
            .await;
        let Error::Api(error) = builder(&server)
            .build()
            .unwrap()
            .evaluate(&request())
            .await
            .unwrap_err()
        else {
            panic!("expected API error")
        };
        assert_eq!(error.status.as_u16(), status);
        assert_eq!(error.headers["x-request-id"], "request-123");
        assert_eq!(error.json().unwrap()["detail"][0]["field"], "state");
        assert!(!error.is_retryable());
        assert!(!error.to_string().contains("detail"));
    }
    let server = MockServer::start().await;
    Mock::given(method("POST"))
        .respond_with(
            ResponseTemplate::new(502).set_body_string("<html>upstream unavailable</html>"),
        )
        .expect(1)
        .mount(&server)
        .await;
    let Error::Api(error) = builder(&server)
        .build()
        .unwrap()
        .evaluate(&request())
        .await
        .unwrap_err()
    else {
        panic!("expected API error")
    };
    assert_eq!(error.body, "<html>upstream unavailable</html>");
    assert!(error.json().is_err());
}

#[tokio::test]
async fn retry_policy_can_be_disabled() {
    let server = MockServer::start().await;
    Mock::given(method("POST"))
        .respond_with(ResponseTemplate::new(429))
        .expect(1)
        .mount(&server)
        .await;
    let error = builder(&server)
        .retry_policy(RetryPolicy::none())
        .build()
        .unwrap()
        .evaluate(&request())
        .await
        .unwrap_err();
    assert!(matches!(error, Error::Api(_)));
}

#[tokio::test]
async fn malformed_success_is_a_decode_error_without_retry() {
    for body in [
        "not json",
        "{}",
        "{\"model\":\"test\",\"answers\":{},\"usage\":null}",
    ] {
        let server = MockServer::start().await;
        Mock::given(method("POST"))
            .respond_with(ResponseTemplate::new(200).set_body_string(body))
            .expect(1)
            .mount(&server)
            .await;
        let error = builder(&server)
            .build()
            .unwrap()
            .evaluate(&request())
            .await
            .unwrap_err();
        assert!(matches!(error, Error::Decode(_)));
    }
}

#[tokio::test]
async fn overall_deadline_bounds_slow_responses_and_retry_after() {
    for hint in [None, Some("1"), Some("18446744073709551615")] {
        let server = MockServer::start().await;
        let template = match hint {
            Some(hint) => ResponseTemplate::new(429).insert_header("retry-after", hint),
            None => ResponseTemplate::new(200)
                .set_body_json(response())
                .set_delay(Duration::from_secs(1)),
        };
        Mock::given(method("POST"))
            .respond_with(template)
            .expect(1)
            .mount(&server)
            .await;
        let error = builder(&server)
            .timeout(Duration::from_millis(100))
            .retry_policy(fast_retry(3))
            .build()
            .unwrap()
            .evaluate(&request())
            .await
            .unwrap_err();
        assert!(matches!(error, Error::Timeout));
    }
}

#[tokio::test]
async fn cloned_clients_and_futures_support_concurrent_spawned_tasks() {
    fn assert_send_sync<T: Send + Sync>() {}
    assert_send_sync::<Client>();
    let server = MockServer::start().await;
    Mock::given(method("POST"))
        .respond_with(ResponseTemplate::new(200).set_body_json(response()))
        .expect(2)
        .mount(&server)
        .await;
    let first = builder(&server).build().unwrap();
    let second = first.clone();
    let a = tokio::spawn(async move { first.evaluate(&request()).await });
    let b = tokio::spawn(async move { second.evaluate(&request()).await });
    a.await.unwrap().unwrap();
    b.await.unwrap().unwrap();
}

#[tokio::test]
async fn default_client_does_not_follow_redirects() {
    let server = MockServer::start().await;
    Mock::given(path("/v1/systemone"))
        .respond_with(
            ResponseTemplate::new(307)
                .insert_header("location", format!("{}/redirected", server.uri())),
        )
        .expect(1)
        .mount(&server)
        .await;
    Mock::given(path("/redirected"))
        .respond_with(ResponseTemplate::new(200).set_body_json(response()))
        .expect(0)
        .mount(&server)
        .await;
    let client = Client::builder()
        .api_key("test-key")
        .base_url(format!("{}/v1", server.uri()))
        .build()
        .unwrap();
    let error = client.evaluate(&request()).await.unwrap_err();
    assert!(matches!(error, Error::Api(ref error) if error.status.as_u16() == 307));
}

#[test]
fn invalid_configuration_is_rejected_and_secrets_are_redacted() {
    for key in ["", "  ", "secret\r\ninjected"] {
        let error = Client::new(key).unwrap_err();
        assert!(matches!(error, Error::Configuration(_)));
        assert!(!format!("{error:?}").contains("secret"));
    }
    for url in [
        "relative/path",
        "ftp://example.com",
        "https://user:secret@example.com/v1",
        "https://example.com/v1?key=secret",
        "https://example.com/v1#fragment",
    ] {
        assert!(
            Client::builder()
                .api_key("test-key")
                .base_url(url)
                .build()
                .is_err()
        );
    }
    assert!(
        Client::builder()
            .api_key("key")
            .timeout(Duration::ZERO)
            .build()
            .is_err()
    );
    assert!(
        Client::builder()
            .api_key("key")
            .timeout(Duration::MAX)
            .build()
            .is_err()
    );
    assert!(
        Client::builder()
            .api_key("key")
            .retry_policy(RetryPolicy {
                initial_delay: Duration::ZERO,
                ..RetryPolicy::default()
            })
            .build()
            .is_err()
    );
    assert!(
        Client::builder()
            .api_key("key")
            .retry_policy(RetryPolicy {
                initial_delay: Duration::from_secs(9),
                max_delay: Duration::from_secs(8),
                ..RetryPolicy::default()
            })
            .build()
            .is_err()
    );
    let builder = Client::builder().api_key("never-print-this-secret");
    assert!(!format!("{builder:?}").contains("never-print-this-secret"));
    assert!(!format!("{:?}", builder.build().unwrap()).contains("never-print-this-secret"));
}