use std::sync::Arc;
use std::time::Duration;
use openkind_api::middleware::RateLimiter;
use openkind_api::{AppState, AuthConfig, RateLimitConfig};
use openkind_client::error::ApiErrorKind;
use openkind_client::{question, Client, Error, RetryPolicy};
use openkind_engine::{EngineRegistry, MockEngine};
async fn spawn_server(auth_key: Option<&str>, rate_limit: Option<RateLimitConfig>) -> String {
let mut registry = EngineRegistry::new();
registry.register("mock", Arc::new(MockEngine::new()));
registry.register("jev-latest", Arc::new(MockEngine::new()));
let state = AppState::new(registry);
let auth = AuthConfig::new(auth_key.map(str::to_owned));
let router = match rate_limit {
Some(config) => openkind_api::http::router_with_state_auth_rate_limit(
state,
auth,
16 * 1024 * 1024,
RateLimiter::new(config),
),
None => openkind_api::router_with_state(state, auth),
};
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
axum::serve(
listener,
router.into_make_service_with_connect_info::<std::net::SocketAddr>(),
)
.await
.unwrap();
});
format!("http://{addr}")
}
fn test_client(base_url: &str) -> Client {
Client::builder()
.api_key("test-key")
.base_url(base_url)
.default_model("mock")
.timeout(Duration::from_secs(5))
.retry(RetryPolicy::new().max_retries(0))
.build()
.unwrap()
}
#[tokio::test]
async fn pooled_http_requests_can_overlap() {
let barrier = Arc::new(tokio::sync::Barrier::new(2));
let router = axum::Router::new().route(
"/health",
axum::routing::get({
let barrier = barrier.clone();
move || {
let barrier = barrier.clone();
async move {
barrier.wait().await;
axum::Json(serde_json::json!({"status": "ok"}))
}
}
}),
);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let base_url = format!("http://{}", listener.local_addr().unwrap());
let server = tokio::spawn(async move { axum::serve(listener, router).await.unwrap() });
let client = test_client(&base_url);
let pair = tokio::time::timeout(Duration::from_secs(3), async {
tokio::join!(client.health(), client.health())
})
.await
.expect("concurrent requests must reach the server before either returns");
assert_eq!(pair.0.unwrap().status, "ok");
assert_eq!(pair.1.unwrap().status, "ok");
server.abort();
}
#[tokio::test]
async fn system_one_round_trip_all_question_types() {
let base_url = spawn_server(Some("test-key"), None).await;
let client = test_client(&base_url);
let response = client
.system_one(
question::text("I was charged twice. Please help."),
[
("billing", question::noul("Is this about billing?")),
(
"tone",
question::choice("What is the tone?", [("calm", None), ("angry", None)]),
),
(
"severity",
question::score("How severe?", ["low", "medium", "high"]),
),
(
"dispute",
question::noul_with("Does the user dispute?", "disputed", "accepted"),
),
],
)
.await
.unwrap();
assert_eq!(response.model, "mock");
assert_eq!(response.answers.len(), 4);
match &response.answers["billing"] {
openkind_client::Answer::Noul(answer) => {
assert!((0.0..=1.0).contains(&answer.noul));
}
other => panic!("expected noul answer, got {other:?}"),
}
match &response.answers["tone"] {
openkind_client::Answer::Choice(answer) => {
assert!(["calm", "angry"].contains(&answer.choice.as_str()));
let sum: f64 = answer.probabilities.values().sum();
assert!((sum - 1.0).abs() < 1e-9, "probabilities must sum to 1");
}
other => panic!("expected choice answer, got {other:?}"),
}
match &response.answers["severity"] {
openkind_client::Answer::Score(answer) => {
assert_eq!(answer.legend.len(), 3);
}
other => panic!("expected score answer, got {other:?}"),
}
}
#[tokio::test]
async fn evaluate_with_explicit_system_request() {
let base_url = spawn_server(Some("test-key"), None).await;
let client = test_client(&base_url);
let request = openkind_client::SystemRequest {
state: openkind_client::State::Object(
serde_json::json!({
"user": {"id": 7},
"logs": ["payment failed", "user asked for help"],
})
.as_object()
.unwrap()
.clone(),
),
model: "mock".into(),
questions: std::collections::HashMap::from_iter([(
"billing".to_string(),
question::noul("Is this about billing?"),
)]),
};
let response = client.evaluate(request).await.unwrap();
assert_eq!(response.answers.len(), 1);
}
#[tokio::test]
async fn wrong_api_key_is_authentication_error_with_request_id() {
let base_url = spawn_server(Some("real-key"), None).await;
let client = Client::builder()
.api_key("wrong-key")
.base_url(&base_url)
.default_model("mock")
.retry(RetryPolicy::new().max_retries(0))
.build()
.unwrap();
let err = client
.system_one("hello", [("q", question::noul("Is this billing?"))])
.await
.unwrap_err();
assert_eq!(err.kind(), Some(ApiErrorKind::Authentication));
assert_eq!(err.status(), Some(401));
let api = match &err {
Error::Api(api) => api,
other => panic!("expected Api error, got {other:?}"),
};
assert_eq!(api.code.as_deref(), Some("unauthorized"));
assert!(
api.request_id.as_deref().unwrap_or_default().len() >= 32,
"request id must be present: {api:?}"
);
}
#[tokio::test]
async fn unknown_model_is_not_found() {
let base_url = spawn_server(Some("test-key"), None).await;
let client = test_client(&base_url);
let request = openkind_client::SystemRequest {
state: openkind_client::State::Text("hello".into()),
model: "no-such-model".into(),
questions: std::collections::HashMap::from_iter([(
"q".to_string(),
question::noul("Is this billing?"),
)]),
};
let err = client.evaluate(request).await.unwrap_err();
assert_eq!(err.kind(), Some(ApiErrorKind::NotFound));
assert_eq!(err.status(), Some(404));
match &err {
Error::Api(api) => assert_eq!(api.code.as_deref(), Some("unknown_model")),
other => panic!("expected Api error, got {other:?}"),
}
}
#[tokio::test]
async fn schema_violation_is_unprocessable_entity() {
let base_url = spawn_server(Some("test-key"), None).await;
let client = test_client(&base_url);
let err = client
.system_one(
"hello",
[("s", question::score("Rate this", ["only-level"]))],
)
.await
.unwrap_err();
assert_eq!(err.kind(), Some(ApiErrorKind::UnprocessableEntity));
assert_eq!(err.status(), Some(422));
match &err {
Error::Api(api) => assert_eq!(api.code.as_deref(), Some("invalid_body")),
other => panic!("expected Api error, got {other:?}"),
}
}
#[tokio::test]
async fn list_models_round_trip() {
let base_url = spawn_server(Some("test-key"), None).await;
let client = test_client(&base_url);
let models = client.list_models().await.unwrap();
let names: Vec<&str> = models.models.iter().map(|m| m.name.as_str()).collect();
assert!(names.contains(&"mock"));
assert!(names.contains(&"jev-latest"));
}
#[tokio::test]
async fn health_works_without_valid_key() {
let base_url = spawn_server(Some("real-key"), None).await;
let client = Client::builder()
.api_key("anything")
.base_url(&base_url)
.build()
.unwrap();
let health = client.health().await.unwrap();
assert_eq!(health.status, "ok");
}
#[tokio::test]
async fn real_rate_limiter_429_surfaces_rate_limit_error_with_retry_after() {
let base_url = spawn_server(
Some("test-key"),
Some(RateLimitConfig {
max_requests: 1,
window: Duration::from_secs(60),
}),
)
.await;
let client = test_client(&base_url);
let first = client
.system_one("hello", [("q", question::noul("Is this billing?"))])
.await;
assert!(first.is_ok(), "first request must pass the limiter");
let err = client
.system_one("hello", [("q", question::noul("Is this billing?"))])
.await
.unwrap_err();
assert_eq!(err.kind(), Some(ApiErrorKind::RateLimit));
assert_eq!(err.status(), Some(429));
match &err {
Error::Api(api) => {
assert_eq!(api.code.as_deref(), Some("rate_limited"));
assert!(
api.retry_after.is_some(),
"server must send retry-after headers: {api:?}"
);
assert!(api.request_id.is_some());
}
other => panic!("expected Api error, got {other:?}"),
}
}
#[tokio::test]
async fn question_schema_validation_is_left_to_api() {
let base_url = spawn_server(Some("test-key"), None).await;
let client = test_client(&base_url);
let err = client
.system_one(
"hello",
[(
"s",
openkind_client::Question::Score(openkind_core::ScoreQuestion {
instructions: serde_json::json!("Rate this"),
criteria: Vec::new(),
}),
)],
)
.await
.unwrap_err();
assert_eq!(err.kind(), Some(ApiErrorKind::UnprocessableEntity));
match &err {
Error::Api(api) => assert_eq!(api.code.as_deref(), Some("invalid_body")),
other => panic!("expected Api error, got {other:?}"),
}
let err = client
.system_one(
"hello",
[(
"c",
openkind_client::Question::Choice(openkind_core::ChoiceQuestion {
instructions: serde_json::json!("Pick one"),
criteria: std::collections::HashMap::default(),
}),
)],
)
.await
.unwrap_err();
assert_eq!(err.kind(), Some(ApiErrorKind::UnprocessableEntity));
let err = client
.system_one(
"hello",
[(
"n",
openkind_client::Question::Noul(openkind_core::NoulQuestion {
instructions: serde_json::json!(""),
criteria: None,
}),
)],
)
.await
.unwrap_err();
assert_eq!(err.kind(), Some(ApiErrorKind::UnprocessableEntity));
}
#[tokio::test]
async fn missing_api_key_fails_at_build_time() {
let result = Client::builder().base_url("http://127.0.0.1:1").build();
if std::env::var("OPENKIND_API_KEY").is_err() && std::env::var("TYPESAFE_API_KEY").is_err() {
match result {
Err(Error::Config(message)) => {
assert!(message.contains("API key"), "{message}");
}
other => panic!("expected Config error, got {other:?}"),
}
}
}
#[tokio::test]
async fn client_evaluates_openapi_documented_examples() {
let base_url = spawn_server(Some("test-key"), None).await;
let client = test_client(&base_url);
let resp1 = client
.system_one(
"I was charged twice for order #1042.",
[(
"billing",
question::noul("Is this inquiry related to a billing issue?"),
)],
)
.await
.unwrap();
assert_eq!(resp1.model, "mock");
match &resp1.answers["billing"] {
openkind_client::Answer::Noul(ans) => {
assert!((0.0..=1.0).contains(&ans.noul));
}
other => panic!("expected Noul, got {other:?}"),
}
let resp2 = client
.system_one(
"Customer support transcript: Agent resolved issue in 4 minutes.",
[
(
"is_resolved",
question::noul_with(
"Was the issue resolved?",
"Customer issue was successfully resolved.",
"Issue remains unresolved or escalated.",
),
),
(
"department",
question::choice(
"Which team handled this request?",
[
("billing", Some("Payments, invoicing, refunds".to_string())),
("technical", Some("Bugs, outages, integrations".to_string())),
("sales", None),
],
),
),
(
"satisfaction",
question::score(
"Rate customer satisfaction",
["Dissatisfied", "Neutral", "Delighted"],
),
),
],
)
.await
.unwrap();
assert_eq!(resp2.answers.len(), 3);
match &resp2.answers["is_resolved"] {
openkind_client::Answer::Noul(ans) => assert!((0.0..=1.0).contains(&ans.noul)),
other => panic!("expected Noul, got {other:?}"),
}
match &resp2.answers["department"] {
openkind_client::Answer::Choice(ans) => {
assert!(["billing", "technical", "sales"].contains(&ans.choice.as_str()));
assert!((0.0..=1.0).contains(&ans.confidence));
}
other => panic!("expected Choice, got {other:?}"),
}
match &resp2.answers["satisfaction"] {
openkind_client::Answer::Score(ans) => {
assert!((0.0..=2.0).contains(&ans.score));
assert_eq!(ans.legend.len(), 3);
assert!((0.0..=1.0).contains(&ans.confidence));
}
other => panic!("expected Score, got {other:?}"),
}
}