jevapi 0.1.0

An asynchronous, typed Rust client for the TypeSafe AI evaluation API
Documentation
use jevapi::{Answer, Content, EvaluationRequest, EvaluationResponse, NoulCriteria, Question};
use serde_json::json;

#[test]
fn request_serializes_all_question_types_and_structured_content() {
    let request = EvaluationRequest::new(
        Content::try_from(json!({"message": "Please investigate", "attempts": 3})).unwrap(),
    )
    .model("jev-pinned-version")
    .question(
        "urgent",
        Question::noul_with_criteria(
            Content::try_from(json!({"question": "Urgent?", "context": ["support"]})).unwrap(),
            NoulCriteria::default().yes("Time-sensitive").no("Routine"),
        ),
    )
    .question(
        "team",
        Question::choice(
            "Route to a team",
            [("support", Some("Help".into())), ("sales", None)],
        ),
    )
    .question(
        "severity",
        Question::score("Rate severity", ["Low", "High"]),
    );
    request.validate().unwrap();
    let expected = json!({
        "state": {"message": "Please investigate", "attempts": 3},
        "model": "jev-pinned-version",
        "questions": {
            "urgent": {"type": "noul", "instructions": {"question": "Urgent?", "context": ["support"]}, "criteria": {"true": "Time-sensitive", "false": "Routine"}},
            "team": {"type": "choice", "instructions": "Route to a team", "criteria": {"support": "Help", "sales": null}},
            "severity": {"type": "score", "instructions": "Rate severity", "criteria": ["Low", "High"]}
        }
    });
    assert_eq!(serde_json::to_value(&request).unwrap(), expected);
    assert_eq!(
        serde_json::from_value::<EvaluationRequest>(expected).unwrap(),
        request
    );
}

#[test]
fn optional_noul_criteria_are_omitted() {
    assert_eq!(
        serde_json::to_value(Question::noul("Ready?")).unwrap(),
        json!({"type": "noul", "instructions": "Ready?"})
    );
    assert_eq!(
        serde_json::to_value(NoulCriteria::default().yes("Ready")).unwrap(),
        json!({"true": "Ready"})
    );
}

#[test]
fn content_enforces_only_top_level_shape() {
    for value in [json!(null), json!(false), json!(42), json!(0.5)] {
        assert!(Content::try_from(value.clone()).is_err());
        assert!(serde_json::from_value::<Content>(value).is_err());
    }
    for value in [
        json!("text"),
        json!({"nested": null}),
        json!([true, 42, null, {"a": []}]),
    ] {
        let content = Content::try_from(value.clone()).unwrap();
        assert_eq!(serde_json::to_value(content).unwrap(), value);
    }
}

#[test]
fn rubric_boundaries_and_request_validation() {
    for count in [0, 1, 2, 10, 11] {
        let question = Question::score("Rate", vec!["level"; count]);
        assert_eq!(question.validate().is_ok(), (2..=10).contains(&count));
    }
    for count in [0, 1, 255, 256] {
        let question = Question::choice("Select", (0..count).map(|i| (i.to_string(), None)));
        assert_eq!(question.validate().is_ok(), (1..=255).contains(&count));
    }
    assert_eq!(
        EvaluationRequest::new("state").validate().unwrap_err().path,
        "questions"
    );
    let invalid =
        EvaluationRequest::new("state").question("severity", Question::score("Rate", ["Only"]));
    assert_eq!(
        invalid.validate().unwrap_err().path,
        "questions[\"severity\"].criteria"
    );
    assert_eq!(invalid.model("  ").validate().unwrap_err().path, "model");
}

#[test]
fn response_decodes_all_variants_and_ignores_additive_fields() {
    let value = json!({
        "model": "jev-version",
        "answers": {
            "urgent": {"type": "noul", "noul": 0.95},
            "team": {"type": "choice", "choice": "support", "probabilities": {"support": 0.8, "sales": 0.2}, "confidence": 0.7},
            "severity": {"type": "score", "score": 1.25, "legend": {"0": "Low", "1": "Medium", "2": "High"}, "probabilities": {"0": 0.0, "1": 0.75, "2": 0.25}, "confidence": 0.6}
        },
        "usage": {"input_tokens": 300, "output_tokens": 50},
        "future_field": "ignored"
    });
    let response: EvaluationResponse = serde_json::from_value(value).unwrap();
    assert!(matches!(response.answers["urgent"], Answer::Noul { noul } if noul == 0.95));
    match &response.answers["team"] {
        Answer::Choice {
            choice,
            probabilities,
            confidence,
        } => {
            assert_eq!(choice, "support");
            assert_eq!(probabilities["sales"], 0.2);
            assert_eq!(*confidence, 0.7);
        }
        _ => panic!("wrong answer type"),
    }
    match &response.answers["severity"] {
        Answer::Score {
            score,
            legend,
            probabilities,
            confidence,
        } => {
            assert_eq!(*score, 1.25);
            assert_eq!(legend["2"], "High");
            assert_eq!(probabilities["1"], 0.75);
            assert_eq!(*confidence, 0.6);
        }
        _ => panic!("wrong answer type"),
    }
    assert_eq!(response.usage.input_tokens, 300);
    assert_eq!(response.usage.output_tokens, 50);
    assert!(
        serde_json::from_value::<Answer>(json!({"type": "choice", "choice": "support"})).is_err()
    );
    assert!(serde_json::from_value::<Answer>(json!({"type": "unknown"})).is_err());
}