#![cfg(feature = "openai")]
use rstructor::{
AnyClient, ApiErrorKind, AttemptKind, AttemptOutcome, Instructor, LLMClient, MediaFile,
OpenAIClient, RStructorError,
};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
struct OpenAiEnvGuard(Option<std::ffi::OsString>);
impl OpenAiEnvGuard {
fn set_for_test() -> Self {
let saved = std::env::var_os("OPENAI_API_KEY");
unsafe {
std::env::set_var("OPENAI_API_KEY", "routed-client-test-key");
}
Self(saved)
}
}
struct EnvVarGuard {
key: &'static str,
saved: Option<std::ffi::OsString>,
}
impl EnvVarGuard {
fn set(key: &'static str, value: &str) -> Self {
let saved = std::env::var_os(key);
unsafe {
std::env::set_var(key, value);
}
Self { key, saved }
}
}
impl Drop for EnvVarGuard {
fn drop(&mut self) {
unsafe {
match self.saved.take() {
Some(value) => std::env::set_var(self.key, value),
None => std::env::remove_var(self.key),
}
}
}
}
impl Drop for OpenAiEnvGuard {
fn drop(&mut self) {
unsafe {
match self.0.take() {
Some(value) => std::env::set_var("OPENAI_API_KEY", value),
None => std::env::remove_var("OPENAI_API_KEY"),
}
}
}
}
#[derive(Instructor, Serialize, Deserialize, Debug, PartialEq)]
#[llm(validate = "validate_movie")]
struct Movie {
title: String,
year: u16,
}
#[derive(Instructor, Serialize, Deserialize, Debug, PartialEq)]
struct Portfolio {
portfolio_id: String,
positions: Vec<Position>,
}
#[derive(Instructor, Serialize, Deserialize, Debug, PartialEq)]
struct Position {
symbol: String,
quantity: i64,
}
#[derive(Instructor, Serialize, Deserialize, Debug, PartialEq)]
#[serde(rename_all = "snake_case")]
enum RevenueTrend {
Rising,
Falling,
Flat,
Mixed,
}
#[derive(Instructor, Serialize, Deserialize, Debug, PartialEq)]
struct MonthlyRevenue {
month: String,
revenue_millions: f64,
}
#[derive(Instructor, Serialize, Deserialize, Debug, PartialEq)]
struct RevenueChart {
title: String,
monthly_revenue: Vec<MonthlyRevenue>,
peak_month: String,
peak_revenue_millions: f64,
total_revenue_millions: f64,
overall_trend: RevenueTrend,
notable_change: String,
}
fn validate_movie(m: &Movie) -> rstructor::Result<()> {
if m.year < 1888 {
return Err(RStructorError::ValidationError(
"year predates cinema".into(),
));
}
Ok(())
}
fn chat_completion(content: &str) -> String {
json!({
"choices": [{
"message": { "role": "assistant", "content": content },
"finish_reason": "stop",
}]
})
.to_string()
}
fn chat_completion_with_usage(
content: Option<&str>,
model: &str,
input_tokens: u64,
output_tokens: u64,
) -> String {
json!({
"choices": [{
"message": { "role": "assistant", "content": content },
"finish_reason": "stop",
}],
"usage": {
"prompt_tokens": input_tokens,
"completion_tokens": output_tokens,
"total_tokens": input_tokens + output_tokens,
},
"model": model,
})
.to_string()
}
fn client(server: &mockito::Server) -> OpenAIClient {
OpenAIClient::new("test-key")
.unwrap()
.base_url(server.url())
.model("gpt-4o-mini")
}
#[tokio::test]
async fn materialize_parses_a_real_response() {
let mut server = mockito::Server::new_async().await;
let m = server
.mock("POST", "/chat/completions")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(chat_completion(r#"{"title":"Inception","year":2010}"#))
.expect(1)
.create_async()
.await;
let movie: Movie = client(&server)
.materialize("Describe Inception")
.await
.unwrap();
assert_eq!(
movie,
Movie {
title: "Inception".into(),
year: 2010
}
);
m.assert_async().await;
}
#[tokio::test]
async fn default_client_sends_the_recommended_openai_model() {
let mut server = mockito::Server::new_async().await;
let request = server
.mock("POST", "/chat/completions")
.match_body(mockito::Matcher::PartialJson(json!({
"model": "gpt-5.6-sol",
})))
.with_status(200)
.with_header("content-type", "application/json")
.with_body(chat_completion(
r#"{"portfolio_id":"HF-ALPHA-001","positions":[{"symbol":"ESU6","quantity":-240}]}"#,
))
.expect(1)
.create_async()
.await;
let portfolio: Portfolio = OpenAIClient::new("test-key")
.unwrap()
.base_url(server.url())
.materialize("Extract the reconciled futures position")
.await
.unwrap();
assert_eq!(portfolio.portfolio_id, "HF-ALPHA-001");
assert_eq!(portfolio.positions[0].symbol, "ESU6");
assert_eq!(portfolio.positions[0].quantity, -240);
request.assert_async().await;
}
#[tokio::test]
async fn routed_client_sends_the_full_custom_model_string() {
let _env = OpenAiEnvGuard::set_for_test();
let mut server = mockito::Server::new_async().await;
let request = server
.mock("POST", "/chat/completions")
.match_body(mockito::Matcher::PartialJson(json!({
"model": "vendor/some-model",
})))
.with_status(200)
.with_header("content-type", "application/json")
.with_body(chat_completion(
r#"{"title":"Provider Routing","year":2026}"#,
))
.expect(1)
.create_async()
.await;
let routed = rstructor::client("openai/vendor/some-model").unwrap();
let client = match routed {
AnyClient::OpenAI(client) => client.base_url(server.url()).no_retries(),
_ => panic!("openai prefix should construct the OpenAI AnyClient variant"),
};
let movie: Movie = client
.materialize("Describe provider routing")
.await
.unwrap();
assert_eq!(movie.title, "Provider Routing");
request.assert_async().await;
}
#[tokio::test]
async fn ollama_client_sends_to_the_compatible_path_without_authorization() {
let mut server = mockito::Server::new_async().await;
let request = server
.mock("POST", "/chat/completions")
.match_header("authorization", mockito::Matcher::Missing)
.match_body(mockito::Matcher::PartialJson(json!({
"model": "llama3.3",
})))
.with_status(200)
.with_header("content-type", "application/json")
.with_body(chat_completion(
r#"{"title":"Local Inference","year":2026}"#,
))
.expect(1)
.create_async()
.await;
let movie: Movie = OpenAIClient::ollama()
.unwrap()
.base_url(server.url())
.model("llama3.3")
.no_retries()
.materialize("Describe local inference")
.await
.unwrap();
assert_eq!(movie.title, "Local Inference");
request.assert_async().await;
}
#[tokio::test]
async fn lm_studio_client_sends_to_the_compatible_path_without_authorization() {
let mut server = mockito::Server::new_async().await;
let request = server
.mock("POST", "/chat/completions")
.match_header("authorization", mockito::Matcher::Missing)
.match_body(mockito::Matcher::PartialJson(json!({
"model": "lmstudio-community/local-model",
})))
.with_status(200)
.with_header("content-type", "application/json")
.with_body(chat_completion(r#"{"title":"Local Studio","year":2026}"#))
.expect(1)
.create_async()
.await;
let movie: Movie = OpenAIClient::lm_studio()
.unwrap()
.base_url(server.url())
.model("lmstudio-community/local-model")
.no_retries()
.materialize("Describe local inference")
.await
.unwrap();
assert_eq!(movie.title, "Local Studio");
request.assert_async().await;
}
#[tokio::test]
async fn aggregator_client_sends_its_environment_key_as_bearer_auth() {
let _env = EnvVarGuard::set("OPENROUTER_API_KEY", "openrouter-test-key");
let mut server = mockito::Server::new_async().await;
let request = server
.mock("POST", "/chat/completions")
.match_header("authorization", "Bearer openrouter-test-key")
.match_body(mockito::Matcher::PartialJson(json!({
"model": "moonshotai/kimi-k3",
})))
.with_status(200)
.with_header("content-type", "application/json")
.with_body(chat_completion(
r#"{"title":"Aggregated Inference","year":2026}"#,
))
.expect(1)
.create_async()
.await;
let movie: Movie = OpenAIClient::openrouter()
.unwrap()
.base_url(server.url())
.model("moonshotai/kimi-k3")
.no_retries()
.materialize("Describe aggregated inference")
.await
.unwrap();
assert_eq!(movie.title, "Aggregated Inference");
request.assert_async().await;
}
#[tokio::test]
async fn moonshot_kimi_k3_materializes_a_chart_from_an_inline_png() {
let _env = EnvVarGuard::set("MOONSHOT_API_KEY", "moonshot-test-key");
let mut server = mockito::Server::new_async().await;
let extracted_chart = include_str!("fixtures/structured/kimi_k3_revenue_chart.json");
let request = server
.mock("POST", "/chat/completions")
.match_header("authorization", "Bearer moonshot-test-key")
.match_body(mockito::Matcher::PartialJson(json!({
"model": "kimi-k3",
"temperature": 1.0,
"messages": [{
"role": "user",
"content": [
{ "type": "text", "text": "Extract the revenue chart." },
{
"type": "image_url",
"image_url": {
"url": "data:image/png;base64,iVBORw0KGgo=",
"detail": "auto",
},
},
],
}],
})))
.with_status(200)
.with_header("content-type", "application/json")
.with_body(chat_completion(extracted_chart))
.expect(1)
.create_async()
.await;
let media = [MediaFile::from_bytes(b"\x89PNG\r\n\x1a\n", "image/png")];
let chart: RevenueChart = OpenAIClient::moonshot()
.unwrap()
.base_url(server.url())
.model("kimi-k3")
.temperature(1.0)
.no_retries()
.materialize_with_media("Extract the revenue chart.", &media)
.await
.unwrap();
assert_eq!(chart.monthly_revenue.len(), 6);
assert_eq!(chart.peak_month, "Jun");
assert_eq!(chart.total_revenue_millions, 23.0);
assert_eq!(chart.overall_trend, RevenueTrend::Rising);
request.assert_async().await;
}
#[tokio::test]
async fn any_client_dispatches_attempt_reports_to_the_concrete_provider() {
let mut server = mockito::Server::new_async().await;
let response = server
.mock("POST", "/chat/completions")
.with_status(200)
.with_body(chat_completion_with_usage(
Some(r#"{"title":"Margin Call","year":2011}"#),
"gpt-4o-mini",
30,
9,
))
.expect(1)
.create_async()
.await;
let any: AnyClient = client(&server).into();
let report = any
.materialize_with_attempts::<Movie>("a finance film")
.await
.unwrap();
assert_eq!(report.data.title, "Margin Call");
assert_eq!(report.attempts.len(), 1);
assert_eq!(report.cumulative_usage.as_ref().unwrap().total_tokens(), 39);
response.assert_async().await;
}
#[tokio::test]
async fn reask_loop_recovers_from_validation_failure() {
let mut server = mockito::Server::new_async().await;
let bad = server
.mock("POST", "/chat/completions")
.with_status(200)
.with_body(chat_completion(r#"{"title":"Old","year":1700}"#))
.expect(1)
.create_async()
.await;
let good = server
.mock("POST", "/chat/completions")
.with_status(200)
.with_body(chat_completion(r#"{"title":"Metropolis","year":1927}"#))
.expect(1)
.create_async()
.await;
let movie: Movie = client(&server).materialize("a film").await.unwrap();
assert_eq!(movie.year, 1927);
bad.assert_async().await;
good.assert_async().await;
}
#[tokio::test]
async fn reask_feedback_includes_the_nested_decode_path() {
let mut server = mockito::Server::new_async().await;
let invalid = include_str!("fixtures/structured/portfolio_invalid_quantity.json");
let valid = include_str!("fixtures/structured/portfolio_valid.json");
let bad = server
.mock("POST", "/chat/completions")
.with_status(200)
.with_body(chat_completion(invalid))
.expect(1)
.create_async()
.await;
let good = server
.mock("POST", "/chat/completions")
.match_body(mockito::Matcher::Regex(
r"\$\.positions\[1\]\.quantity".to_string(),
))
.with_status(200)
.with_body(chat_completion(valid))
.expect(1)
.create_async()
.await;
let portfolio: Portfolio = client(&server)
.materialize("reconcile the portfolio positions")
.await
.unwrap();
assert_eq!(portfolio.portfolio_id, "HF-ALPHA-001");
assert_eq!(portfolio.positions[1].quantity, -240);
bad.assert_async().await;
good.assert_async().await;
}
#[tokio::test]
async fn retryable_status_is_retried() {
let mut server = mockito::Server::new_async().await;
let rate_limited = server
.mock("POST", "/chat/completions")
.with_status(429)
.with_header("retry-after", "0")
.with_body("{}")
.expect(1)
.create_async()
.await;
let ok = server
.mock("POST", "/chat/completions")
.with_status(200)
.with_body(chat_completion(r#"{"title":"Dune","year":2021}"#))
.expect(1)
.create_async()
.await;
let movie: Movie = client(&server).materialize("a film").await.unwrap();
assert_eq!(movie.title, "Dune");
rate_limited.assert_async().await;
ok.assert_async().await;
}
#[tokio::test]
async fn attempt_report_accumulates_semantic_retry_usage_by_model() {
let mut server = mockito::Server::new_async().await;
let invalid = include_str!("fixtures/structured/portfolio_invalid_quantity.json");
let valid = include_str!("fixtures/structured/portfolio_valid.json");
let bad = server
.mock("POST", "/chat/completions")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(chat_completion_with_usage(
Some(invalid),
"risk-router-2026-07-01",
210,
35,
))
.expect(1)
.create_async()
.await;
let good = server
.mock("POST", "/chat/completions")
.match_body(mockito::Matcher::Regex(
r"\$\.positions\[1\]\.quantity".to_string(),
))
.with_status(200)
.with_header("content-type", "application/json")
.with_body(chat_completion_with_usage(
Some(valid),
"risk-router-2026-07-15",
280,
42,
))
.expect(1)
.create_async()
.await;
let report = client(&server)
.materialize_with_attempts::<Portfolio>("reconcile the futures book")
.await
.unwrap();
assert_eq!(report.data.portfolio_id, "HF-ALPHA-001");
assert_eq!(report.attempts.len(), 2);
assert_eq!(report.attempts[0].kind, AttemptKind::Semantic);
assert_eq!(report.attempts[1].kind, AttemptKind::Semantic);
assert!(matches!(
report.attempts[0].outcome,
AttemptOutcome::Failed {
disposition: rstructor::RetryDisposition::Retried,
..
}
));
assert_eq!(report.attempts[1].outcome, AttemptOutcome::Succeeded);
assert_eq!(
report.final_usage.as_ref().unwrap().model,
"risk-router-2026-07-15"
);
let cumulative = report.cumulative_usage.unwrap();
assert_eq!(cumulative.reported_attempts, 2);
assert_eq!(cumulative.input_tokens, 490);
assert_eq!(cumulative.output_tokens, 77);
assert_eq!(
cumulative.by_model["risk-router-2026-07-01"].total_tokens(),
245
);
assert_eq!(
cumulative.by_model["risk-router-2026-07-15"].total_tokens(),
322
);
bad.assert_async().await;
good.assert_async().await;
}
#[tokio::test]
async fn existing_metadata_keeps_final_response_usage_after_reask() {
let mut server = mockito::Server::new_async().await;
let invalid = include_str!("fixtures/structured/portfolio_invalid_quantity.json");
let valid = include_str!("fixtures/structured/portfolio_valid.json");
let bad = server
.mock("POST", "/chat/completions")
.with_status(200)
.with_body(chat_completion_with_usage(
Some(invalid),
"risk-router",
210,
35,
))
.expect(1)
.create_async()
.await;
let good = server
.mock("POST", "/chat/completions")
.match_body(mockito::Matcher::Regex(
r"\$\.positions\[1\]\.quantity".to_string(),
))
.with_status(200)
.with_body(chat_completion_with_usage(
Some(valid),
"risk-router",
280,
42,
))
.expect(1)
.create_async()
.await;
let result = client(&server)
.materialize_with_metadata::<Portfolio>("reconcile the futures book")
.await
.unwrap();
let usage = result.usage.unwrap();
assert_eq!(usage.input_tokens, 280);
assert_eq!(usage.output_tokens, 42);
bad.assert_async().await;
good.assert_async().await;
}
#[tokio::test]
async fn earlier_usage_survives_when_success_omits_usage_but_legacy_metadata_stays_final_only() {
let invalid = include_str!("fixtures/structured/portfolio_invalid_quantity.json");
let valid = include_str!("fixtures/structured/portfolio_valid.json");
let mut report_server = mockito::Server::new_async().await;
let report_bad = report_server
.mock("POST", "/chat/completions")
.with_status(200)
.with_body(chat_completion_with_usage(
Some(invalid),
"risk-router",
90,
15,
))
.expect(1)
.create_async()
.await;
let report_good = report_server
.mock("POST", "/chat/completions")
.match_body(mockito::Matcher::Regex(
r"\$\.positions\[1\]\.quantity".to_string(),
))
.with_status(200)
.with_body(chat_completion(valid))
.expect(1)
.create_async()
.await;
let report = client(&report_server)
.max_retries(1)
.materialize_with_attempts::<Portfolio>("reconcile the futures book")
.await
.unwrap();
assert!(report.attempts_complete);
assert_eq!(report.attempts.len(), 2);
assert!(report.final_usage.is_none());
assert!(report.attempts[1].usage.is_none());
assert_eq!(
report.cumulative_usage.as_ref().unwrap().total_tokens(),
105
);
report_bad.assert_async().await;
report_good.assert_async().await;
let mut legacy_server = mockito::Server::new_async().await;
let legacy_bad = legacy_server
.mock("POST", "/chat/completions")
.with_status(200)
.with_body(chat_completion_with_usage(
Some(invalid),
"risk-router",
90,
15,
))
.expect(1)
.create_async()
.await;
let legacy_good = legacy_server
.mock("POST", "/chat/completions")
.match_body(mockito::Matcher::Regex(
r"\$\.positions\[1\]\.quantity".to_string(),
))
.with_status(200)
.with_body(chat_completion(valid))
.expect(1)
.create_async()
.await;
let legacy = client(&legacy_server)
.max_retries(1)
.materialize_with_metadata::<Portfolio>("reconcile the futures book")
.await
.unwrap();
assert!(legacy.usage.is_none());
legacy_bad.assert_async().await;
legacy_good.assert_async().await;
}
#[tokio::test]
async fn retryable_provider_error_is_a_transport_attempt_without_history_mutation() {
let mut server = mockito::Server::new_async().await;
let rate_limited = server
.mock("POST", "/chat/completions")
.with_status(429)
.with_header("retry-after", "0")
.with_body("{}")
.expect(1)
.create_async()
.await;
let ok = server
.mock("POST", "/chat/completions")
.match_body(mockito::Matcher::Regex(
r#""messages":\[\{"role":"user""#.to_string(),
))
.with_status(200)
.with_body(chat_completion_with_usage(
Some(r#"{"title":"Dune","year":2021}"#),
"gpt-4o-mini",
25,
8,
))
.expect(1)
.create_async()
.await;
let report = client(&server)
.materialize_with_attempts::<Movie>("a film")
.await
.unwrap();
assert_eq!(report.attempts.len(), 2);
assert_eq!(report.attempts[0].kind, AttemptKind::Transport);
assert!(matches!(
report.attempts[0].outcome,
AttemptOutcome::Failed {
disposition: rstructor::RetryDisposition::Retried,
..
}
));
assert_eq!(report.attempts[1].kind, AttemptKind::Semantic);
assert_eq!(
report.cumulative_usage.as_ref().unwrap().reported_attempts,
1
);
rate_limited.assert_async().await;
ok.assert_async().await;
}
#[tokio::test]
async fn empty_envelope_retains_usage_as_an_unretried_transport_attempt() {
let mut server = mockito::Server::new_async().await;
let malformed = server
.mock("POST", "/chat/completions")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
json!({
"choices": [],
"usage": {
"prompt_tokens": 55,
"completion_tokens": 3,
"total_tokens": 58,
},
"model": "gpt-4o-mini",
})
.to_string(),
)
.expect(1)
.create_async()
.await;
let failure = client(&server)
.materialize_with_attempts::<Movie>("a film")
.await
.unwrap_err();
assert!(matches!(
failure.error().api_error_kind(),
Some(ApiErrorKind::UnexpectedResponse { .. })
));
assert_eq!(failure.attempts.len(), 1);
assert_eq!(failure.attempts[0].kind, AttemptKind::Transport);
assert!(matches!(
failure.attempts[0].outcome,
AttemptOutcome::Failed {
disposition: rstructor::RetryDisposition::NonRetryable,
..
}
));
assert_eq!(
failure.attempts[0].usage.as_ref().unwrap().total_tokens(),
58
);
assert_eq!(
failure.cumulative_usage.as_ref().unwrap().total_tokens(),
58
);
malformed.assert_async().await;
}
#[tokio::test]
async fn malformed_usage_with_valid_content_preserves_legacy_fail_fast_error() {
let mut server = mockito::Server::new_async().await;
let response = server
.mock("POST", "/chat/completions")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
json!({
"choices": [{
"message": {
"role": "assistant",
"content": r#"{"title":"Dune","year":2021}"#,
},
"finish_reason": "stop",
}],
"usage": "not-an-object",
"model": "gpt-4o-mini",
})
.to_string(),
)
.expect(1)
.create_async()
.await;
let error = client(&server)
.materialize::<Movie>("a film")
.await
.unwrap_err();
assert!(matches!(error, RStructorError::HttpError(_)));
response.assert_async().await;
}
#[tokio::test]
async fn malformed_usage_with_invalid_content_is_not_reclassified_as_retryable() {
let mut server = mockito::Server::new_async().await;
let invalid = include_str!("fixtures/structured/portfolio_invalid_quantity.json");
let response = server
.mock("POST", "/chat/completions")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
json!({
"choices": [{
"message": {
"role": "assistant",
"content": invalid,
},
"finish_reason": "stop",
}],
"usage": "not-an-object",
"model": "risk-router",
})
.to_string(),
)
.expect(1)
.create_async()
.await;
let error = client(&server)
.materialize::<Portfolio>("reconcile the futures book")
.await
.unwrap_err();
assert!(matches!(error, RStructorError::HttpError(_)));
response.assert_async().await;
}
#[tokio::test]
async fn invalid_request_url_is_preflight_and_records_no_provider_attempt() {
let failure = OpenAIClient::new("test-key")
.unwrap()
.base_url("://invalid-url")
.materialize_with_attempts::<Movie>("a film")
.await
.unwrap_err();
assert!(matches!(
failure.error(),
RStructorError::HttpError(error) if error.is_builder()
));
assert!(failure.attempts.is_empty());
assert!(failure.cumulative_usage.is_none());
}
#[tokio::test]
async fn media_attempt_report_uses_provider_path_and_retains_usage() {
use rstructor::MediaFile;
let mut server = mockito::Server::new_async().await;
let valid = include_str!("fixtures/structured/portfolio_valid.json");
let request = server
.mock("POST", "/chat/completions")
.match_body(mockito::Matcher::PartialJson(json!({
"messages": [{
"role": "user",
"content": [
{ "type": "text", "text": "reconcile the chart" },
{
"type": "image_url",
"image_url": {
"url": "data:image/png;base64,YWJj",
"detail": "auto",
},
},
],
}],
})))
.with_status(200)
.with_body(chat_completion_with_usage(
Some(valid),
"vision-risk-router",
75,
12,
))
.expect(1)
.create_async()
.await;
let media = [MediaFile::from_bytes(b"abc", "image/png")];
let report = client(&server)
.materialize_with_media_and_attempts::<Portfolio>("reconcile the chart", &media)
.await
.unwrap();
assert!(report.attempts_complete);
assert_eq!(report.attempts.len(), 1);
assert_eq!(
report.final_usage.as_ref().unwrap().model,
"vision-risk-router"
);
assert_eq!(report.cumulative_usage.as_ref().unwrap().total_tokens(), 87);
request.assert_async().await;
}
#[tokio::test]
async fn semantic_exhaustion_exposes_usage_while_legacy_api_keeps_bare_error() {
let mut report_server = mockito::Server::new_async().await;
let invalid = include_str!("fixtures/structured/portfolio_invalid_quantity.json");
let first = report_server
.mock("POST", "/chat/completions")
.with_status(200)
.with_body(chat_completion_with_usage(
Some(invalid),
"risk-router",
120,
20,
))
.expect(1)
.create_async()
.await;
let second = report_server
.mock("POST", "/chat/completions")
.with_status(200)
.with_body(chat_completion_with_usage(
Some(invalid),
"risk-router",
160,
25,
))
.expect(1)
.create_async()
.await;
let failure = client(&report_server)
.max_retries(1)
.materialize_with_attempts::<Portfolio>("reconcile")
.await
.unwrap_err();
assert!(matches!(
failure.error(),
RStructorError::OutputDecodeError { path, .. }
if path == "$.positions[1].quantity"
));
assert_eq!(failure.attempts.len(), 2);
assert_eq!(
failure.cumulative_usage.as_ref().unwrap().total_tokens(),
325
);
first.assert_async().await;
second.assert_async().await;
let mut legacy_server = mockito::Server::new_async().await;
let legacy_first = legacy_server
.mock("POST", "/chat/completions")
.with_status(200)
.with_body(chat_completion(invalid))
.expect(1)
.create_async()
.await;
let legacy_second = legacy_server
.mock("POST", "/chat/completions")
.with_status(200)
.with_body(chat_completion(invalid))
.expect(1)
.create_async()
.await;
let error = client(&legacy_server)
.max_retries(1)
.materialize::<Portfolio>("reconcile")
.await
.unwrap_err();
assert!(matches!(
error,
RStructorError::OutputDecodeError { ref path, .. }
if path == "$.positions[1].quantity"
));
legacy_first.assert_async().await;
legacy_second.assert_async().await;
}
#[tokio::test]
async fn auth_error_is_surfaced_and_not_retried() {
let mut server = mockito::Server::new_async().await;
let m = server
.mock("POST", "/chat/completions")
.with_status(401)
.with_body(r#"{"error":{"message":"invalid api key"}}"#)
.expect(1) .create_async()
.await;
let err = client(&server)
.materialize::<Movie>("a film")
.await
.unwrap_err();
assert!(
matches!(
err.api_error_kind(),
Some(ApiErrorKind::AuthenticationFailed)
),
"expected AuthenticationFailed, got {err:?}"
);
m.assert_async().await;
}
#[tokio::test]
async fn generate_with_metadata_parses_content_and_usage() {
let mut server = mockito::Server::new_async().await;
let body = json!({
"choices": [{
"message": { "role": "assistant", "content": "hello there" },
"finish_reason": "stop",
}],
"usage": { "prompt_tokens": 3, "completion_tokens": 5, "total_tokens": 8 },
"model": "gpt-4o-mini",
})
.to_string();
let captured: std::sync::Arc<std::sync::Mutex<Vec<Value>>> =
std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
let sink = captured.clone();
let m = server
.mock("POST", "/chat/completions")
.match_request(move |req| {
if let Ok(b) = req.utf8_lossy_body()
&& let Ok(v) = serde_json::from_str::<Value>(&b)
{
sink.lock().unwrap().push(v);
}
true
})
.with_status(200)
.with_body(body)
.expect(1)
.create_async()
.await;
let result = client(&server).generate_with_metadata("hi").await.unwrap();
assert_eq!(result.text, "hello there");
let usage = result.usage.expect("usage should be parsed");
assert_eq!(usage.input_tokens, 3);
assert_eq!(usage.output_tokens, 5);
assert_eq!(usage.total_tokens(), 8);
m.assert_async().await;
let bodies = captured.lock().unwrap();
assert_eq!(bodies.len(), 1, "expected exactly one request");
assert!(
bodies[0].get("response_format").is_none(),
"response_format must be absent for plain generation, got {}",
bodies[0]
);
}
#[tokio::test]
async fn generate_returns_text_content() {
let mut server = mockito::Server::new_async().await;
let m = server
.mock("POST", "/chat/completions")
.with_status(200)
.with_body(chat_completion("plain answer"))
.expect(1)
.create_async()
.await;
let text = client(&server).generate("hi").await.unwrap();
assert_eq!(text, "plain answer");
m.assert_async().await;
}
#[tokio::test]
async fn generate_empty_choices_is_unexpected_response() {
let mut server = mockito::Server::new_async().await;
let m = server
.mock("POST", "/chat/completions")
.with_status(200)
.with_body(json!({ "choices": [] }).to_string())
.expect(1)
.create_async()
.await;
let err = client(&server).generate("hi").await.unwrap_err();
assert!(
matches!(
err.api_error_kind(),
Some(ApiErrorKind::UnexpectedResponse { .. })
),
"expected UnexpectedResponse, got {err:?}"
);
m.assert_async().await;
}
#[tokio::test]
async fn generate_null_content_is_unexpected_response() {
let mut server = mockito::Server::new_async().await;
let body = json!({
"choices": [{
"message": { "role": "assistant", "content": null },
"finish_reason": "stop",
}]
})
.to_string();
let m = server
.mock("POST", "/chat/completions")
.with_status(200)
.with_body(body)
.expect(1)
.create_async()
.await;
let err = client(&server).generate("hi").await.unwrap_err();
assert!(
matches!(
err.api_error_kind(),
Some(ApiErrorKind::UnexpectedResponse { .. })
),
"expected UnexpectedResponse, got {err:?}"
);
m.assert_async().await;
}
#[tokio::test]
async fn generate_request_body_carries_attached_image() {
use rstructor::{MediaFile, RequestExt};
let mut server = mockito::Server::new_async().await;
let m = server
.mock("POST", "/chat/completions")
.match_body(mockito::Matcher::PartialJson(json!({
"messages": [{
"role": "user",
"content": [
{ "type": "text", "text": "describe" },
{
"type": "image_url",
"image_url": { "url": "data:image/png;base64,YWJj", "detail": "auto" },
},
],
}],
})))
.with_status(200)
.with_body(chat_completion("a red square"))
.expect(1)
.create_async()
.await;
let media = [MediaFile::from_bytes(b"abc", "image/png")];
let text = client(&server)
.with_media(&media)
.generate("describe")
.await
.unwrap();
assert_eq!(text, "a red square");
m.assert_async().await;
}
#[tokio::test]
async fn generate_request_body_carries_attached_pdf_as_file_part() {
use rstructor::MediaFile;
let mut server = mockito::Server::new_async().await;
let m = server
.mock("POST", "/chat/completions")
.match_body(mockito::Matcher::PartialJson(json!({
"messages": [{
"role": "user",
"content": [
{ "type": "text", "text": "summarize" },
{
"type": "file",
"file": {
"filename": "document.pdf",
"file_data": "data:application/pdf;base64,JVBERg==",
},
},
],
}],
})))
.with_status(200)
.with_body(chat_completion("a summary"))
.expect(1)
.create_async()
.await;
let media = [MediaFile::from_bytes(b"%PDF", "application/pdf")];
let text = client(&server)
.generate_with_media("summarize", &media)
.await
.unwrap();
assert_eq!(text, "a summary");
m.assert_async().await;
}
#[tokio::test]
async fn generate_with_url_pdf_errors_without_sending_request() {
use rstructor::MediaFile;
let mut server = mockito::Server::new_async().await;
let m = server
.mock("POST", "/chat/completions")
.expect(0) .create_async()
.await;
let media = [MediaFile::new(
"https://example.com/report.pdf",
"application/pdf",
)];
let err = client(&server)
.generate_with_media("summarize", &media)
.await
.unwrap_err();
assert!(
err.to_string().contains("URL-based PDF"),
"expected a clear URL-PDF error, got: {err}"
);
m.assert_async().await;
}
#[tokio::test]
async fn gpt5_sends_reasoning_effort_and_forces_temperature_one() {
let mut server = mockito::Server::new_async().await;
let m = server
.mock("POST", "/chat/completions")
.match_body(mockito::Matcher::PartialJson(json!({
"reasoning_effort": "medium",
"temperature": 1.0,
})))
.with_status(200)
.with_body(chat_completion("ok"))
.expect(1)
.create_async()
.await;
let text = OpenAIClient::new("test-key")
.unwrap()
.base_url(server.url())
.model("gpt-5")
.generate("hi")
.await
.unwrap();
assert_eq!(text, "ok");
m.assert_async().await;
}
#[tokio::test]
async fn non_gpt5_omits_reasoning_effort_and_passes_temperature_through() {
let mut server = mockito::Server::new_async().await;
let captured: std::sync::Arc<std::sync::Mutex<Vec<Value>>> =
std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
let sink = captured.clone();
let m = server
.mock("POST", "/chat/completions")
.match_request(move |req| {
if let Ok(body) = req.utf8_lossy_body()
&& let Ok(v) = serde_json::from_str::<Value>(&body)
{
sink.lock().unwrap().push(v);
}
true
})
.with_status(200)
.with_body(chat_completion("ok"))
.expect(1)
.create_async()
.await;
let text = OpenAIClient::new("test-key")
.unwrap()
.base_url(server.url())
.model("gpt-4o-mini")
.temperature(0.2)
.generate("hi")
.await
.unwrap();
assert_eq!(text, "ok");
m.assert_async().await;
let bodies = captured.lock().unwrap();
assert_eq!(bodies.len(), 1, "expected exactly one request");
let body = &bodies[0];
assert!(
body.get("reasoning_effort").is_none(),
"reasoning_effort must be omitted for non-gpt-5, got {body}"
);
assert_eq!(
body["temperature"],
json!(0.2),
"configured temperature must pass through unchanged"
);
}
#[tokio::test]
async fn list_models_keeps_only_chat_models() {
let mut server = mockito::Server::new_async().await;
let body = json!({
"data": [
{ "id": "gpt-4o" },
{ "id": "o3" },
{ "id": "o4-mini" },
{ "id": "o1-pro" },
{ "id": "whisper-1" },
{ "id": "text-embedding-3-small" },
{ "id": "dall-e-3" },
]
})
.to_string();
let m = server
.mock("GET", "/models")
.with_status(200)
.with_body(body)
.expect(1)
.create_async()
.await;
let models = client(&server).list_models().await.unwrap();
let ids: Vec<&str> = models.iter().map(|m| m.id.as_str()).collect();
assert_eq!(ids, vec!["gpt-4o"]);
m.assert_async().await;
}
#[tokio::test]
async fn list_models_no_data_returns_empty() {
let mut server = mockito::Server::new_async().await;
let m = server
.mock("GET", "/models")
.with_status(200)
.with_body("{}")
.expect(1)
.create_async()
.await;
let models = client(&server).list_models().await.unwrap();
assert!(models.is_empty(), "expected empty list, got {models:?}");
m.assert_async().await;
}
#[tokio::test]
async fn usage_model_name_falls_back_to_client_model() {
let mut server = mockito::Server::new_async().await;
let body = json!({
"choices": [{
"message": { "role": "assistant", "content": "hi" },
"finish_reason": "stop",
}],
"usage": { "prompt_tokens": 1, "completion_tokens": 2, "total_tokens": 3 },
})
.to_string();
let m = server
.mock("POST", "/chat/completions")
.with_status(200)
.with_body(body)
.expect(1)
.create_async()
.await;
let result = OpenAIClient::new("test-key")
.unwrap()
.base_url(server.url())
.model("gpt-4o-mini")
.generate_with_metadata("hi")
.await
.unwrap();
let usage = result.usage.expect("usage should be parsed");
assert_eq!(usage.model, "gpt-4o-mini");
m.assert_async().await;
}
#[cfg(feature = "tools")]
fn tool_call_response(call_id: &str, name: &str, args: &str) -> String {
json!({
"choices": [{
"message": {
"role": "assistant",
"content": null,
"tool_calls": [{
"id": call_id,
"type": "function",
"function": { "name": name, "arguments": args },
}],
},
"finish_reason": "tool_calls",
}]
})
.to_string()
}
#[cfg(feature = "tools")]
fn recording_add_tool(
flag: std::sync::Arc<std::sync::atomic::AtomicBool>,
) -> rstructor::FnTool<
AddArgs,
impl Fn(AddArgs) -> std::future::Ready<rstructor::Result<Value>> + Clone,
> {
rstructor::FnTool::new("add", "Add two integers", move |args: AddArgs| {
flag.store(true, std::sync::atomic::Ordering::SeqCst);
std::future::ready(Ok(json!({ "sum": args.a + args.b })))
})
}
#[cfg(feature = "tools")]
#[derive(Instructor, Serialize, Deserialize)]
struct AddArgs {
#[llm(description = "First addend")]
a: i64,
#[llm(description = "Second addend")]
b: i64,
}
#[cfg(feature = "tools")]
#[tokio::test]
async fn gpt56_tool_request_disables_reasoning() {
use rstructor::{RequestExt, Toolbox};
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
let mut server = mockito::Server::new_async().await;
let captured: Arc<std::sync::Mutex<Option<Value>>> = Arc::new(std::sync::Mutex::new(None));
let sink = captured.clone();
let request = server
.mock("POST", "/chat/completions")
.match_request(move |req| {
let body = serde_json::from_str(&req.utf8_lossy_body().unwrap()).unwrap();
*sink.lock().unwrap() = Some(body);
true
})
.with_status(200)
.with_body(chat_completion("tools are ready"))
.expect(1)
.create_async()
.await;
let toolbox = Toolbox::new().with(recording_add_tool(Arc::new(AtomicBool::new(false))));
let answer = OpenAIClient::new("test-key")
.unwrap()
.base_url(server.url())
.model("gpt-5.6")
.with_tools(&toolbox)
.run("say hello")
.await
.unwrap();
assert_eq!(answer, "tools are ready");
request.assert_async().await;
let body = captured.lock().unwrap();
let body = body.as_ref().expect("request body should be captured");
assert_eq!(body["reasoning_effort"], json!("none"));
assert_eq!(body["temperature"], json!(1.0));
}
#[cfg(feature = "tools")]
#[tokio::test]
async fn ollama_tool_request_sends_no_authorization_header() {
use rstructor::{RequestExt, Toolbox};
let mut server = mockito::Server::new_async().await;
let request = server
.mock("POST", "/chat/completions")
.match_header("authorization", mockito::Matcher::Missing)
.with_status(200)
.with_body(chat_completion("local tools are ready"))
.expect(1)
.create_async()
.await;
let toolbox = Toolbox::new();
let answer = OpenAIClient::ollama()
.unwrap()
.base_url(server.url())
.model("llama3.3")
.with_tools(&toolbox)
.run("say hello")
.await
.unwrap();
assert_eq!(answer, "local tools are ready");
request.assert_async().await;
}
#[cfg(feature = "tools")]
#[tokio::test]
async fn tool_loop_full_round_trip() {
use rstructor::{RequestExt, Toolbox};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
let mut server = mockito::Server::new_async().await;
let captured: Arc<std::sync::Mutex<Vec<Value>>> = Arc::new(std::sync::Mutex::new(Vec::new()));
let sink1 = captured.clone();
let first = server
.mock("POST", "/chat/completions")
.match_request(move |req| {
let v: Value = serde_json::from_str(&req.utf8_lossy_body().unwrap()).unwrap();
sink1.lock().unwrap().push(v.clone());
!messages_contain_tool_role(&v)
})
.with_status(200)
.with_body(tool_call_response("c1", "add", r#"{"a":2,"b":3}"#))
.expect(1)
.create_async()
.await;
let sink2 = captured.clone();
let second = server
.mock("POST", "/chat/completions")
.match_request(move |req| {
let v: Value = serde_json::from_str(&req.utf8_lossy_body().unwrap()).unwrap();
sink2.lock().unwrap().push(v.clone());
messages_contain_tool_role(&v)
})
.with_status(200)
.with_body(chat_completion("the sum is 5"))
.expect(1)
.create_async()
.await;
let invoked = Arc::new(AtomicBool::new(false));
let toolbox = Toolbox::new().with(recording_add_tool(invoked.clone()));
let answer = client(&server)
.with_tools(&toolbox)
.run("add 2 and 3")
.await
.unwrap();
assert_eq!(answer, "the sum is 5");
assert!(
invoked.load(Ordering::SeqCst),
"the real tool closure must have run"
);
first.assert_async().await;
second.assert_async().await;
let bodies = captured.lock().unwrap();
let second_body = bodies
.iter()
.find(|v| messages_contain_tool_role(v))
.expect("a request carrying the tool result must exist");
let messages = second_body["messages"].as_array().unwrap();
let tool_msg = messages
.iter()
.find(|m| m["role"] == json!("tool"))
.expect("a role:tool message must be present");
assert_eq!(tool_msg["tool_call_id"], json!("c1"));
let content = tool_msg["content"].as_str().unwrap();
assert!(
content.contains("\"sum\":5"),
"tool result content should carry the sum, got {content}"
);
}
#[cfg(feature = "tools")]
fn messages_contain_tool_role(body: &Value) -> bool {
body.get("messages")
.and_then(Value::as_array)
.map(|msgs| msgs.iter().any(|m| m.get("role") == Some(&json!("tool"))))
.unwrap_or(false)
}
#[cfg(feature = "tools")]
#[tokio::test]
async fn tool_loop_unknown_tool_continues() {
use rstructor::{RequestExt, Toolbox};
use std::sync::Arc;
let mut server = mockito::Server::new_async().await;
let captured: Arc<std::sync::Mutex<Vec<Value>>> = Arc::new(std::sync::Mutex::new(Vec::new()));
let sink1 = captured.clone();
let first = server
.mock("POST", "/chat/completions")
.match_request(move |req| {
let v: Value = serde_json::from_str(&req.utf8_lossy_body().unwrap()).unwrap();
sink1.lock().unwrap().push(v.clone());
!messages_contain_tool_role(&v)
})
.with_status(200)
.with_body(tool_call_response("c1", "does_not_exist", "{}"))
.expect(1)
.create_async()
.await;
let sink2 = captured.clone();
let second = server
.mock("POST", "/chat/completions")
.match_request(move |req| {
let v: Value = serde_json::from_str(&req.utf8_lossy_body().unwrap()).unwrap();
sink2.lock().unwrap().push(v.clone());
messages_contain_tool_role(&v)
})
.with_status(200)
.with_body(chat_completion("recovered"))
.expect(1)
.create_async()
.await;
let invoked = Arc::new(std::sync::atomic::AtomicBool::new(false));
let toolbox = Toolbox::new().with(recording_add_tool(invoked.clone()));
let answer = client(&server)
.with_tools(&toolbox)
.run("call a missing tool")
.await
.unwrap();
assert_eq!(answer, "recovered");
assert!(
!invoked.load(std::sync::atomic::Ordering::SeqCst),
"the real add tool must NOT have run for an unknown tool"
);
first.assert_async().await;
second.assert_async().await;
let bodies = captured.lock().unwrap();
let second_body = bodies
.iter()
.find(|v| messages_contain_tool_role(v))
.expect("a request carrying the error result must exist");
let messages = second_body["messages"].as_array().unwrap();
let tool_msg = messages
.iter()
.find(|m| m["role"] == json!("tool"))
.expect("a role:tool message must be present");
let content = tool_msg["content"].as_str().unwrap();
assert!(
content.contains("unknown tool: does_not_exist"),
"error content should name the unknown tool, got {content}"
);
}
#[cfg(feature = "tools")]
#[tokio::test]
async fn tool_loop_tool_error_is_swallowed() {
use rstructor::{FnTool, RequestExt, Toolbox};
use std::sync::Arc;
let mut server = mockito::Server::new_async().await;
let captured: Arc<std::sync::Mutex<Vec<Value>>> = Arc::new(std::sync::Mutex::new(Vec::new()));
let sink1 = captured.clone();
let first = server
.mock("POST", "/chat/completions")
.match_request(move |req| {
let v: Value = serde_json::from_str(&req.utf8_lossy_body().unwrap()).unwrap();
sink1.lock().unwrap().push(v.clone());
!messages_contain_tool_role(&v)
})
.with_status(200)
.with_body(tool_call_response("c1", "boom", r#"{"a":1,"b":1}"#))
.expect(1)
.create_async()
.await;
let sink2 = captured.clone();
let second = server
.mock("POST", "/chat/completions")
.match_request(move |req| {
let v: Value = serde_json::from_str(&req.utf8_lossy_body().unwrap()).unwrap();
sink2.lock().unwrap().push(v.clone());
messages_contain_tool_role(&v)
})
.with_status(200)
.with_body(chat_completion("handled"))
.expect(1)
.create_async()
.await;
let boom = FnTool::new("boom", "always fails", |_args: AddArgs| {
std::future::ready(Err(RStructorError::ValidationError(
"tool blew up".to_string(),
)))
});
let toolbox = Toolbox::new().with(boom);
let answer = client(&server)
.with_tools(&toolbox)
.run("trigger the failing tool")
.await
.unwrap();
assert_eq!(answer, "handled");
first.assert_async().await;
second.assert_async().await;
let bodies = captured.lock().unwrap();
let second_body = bodies
.iter()
.find(|v| messages_contain_tool_role(v))
.expect("a request carrying the error result must exist");
let messages = second_body["messages"].as_array().unwrap();
let tool_msg = messages
.iter()
.find(|m| m["role"] == json!("tool"))
.expect("a role:tool message must be present");
let content = tool_msg["content"].as_str().unwrap();
assert!(
content.contains("error"),
"swallowed tool error should appear in the content, got {content}"
);
assert!(
content.contains("tool blew up"),
"the tool's error message should be preserved, got {content}"
);
}
#[cfg(feature = "tools")]
#[tokio::test]
async fn tool_loop_exhaustion_errors() {
use rstructor::{RequestExt, Toolbox};
use std::sync::Arc;
let mut server = mockito::Server::new_async().await;
let always_tool = server
.mock("POST", "/chat/completions")
.with_status(200)
.with_body(tool_call_response("c1", "add", r#"{"a":1,"b":1}"#))
.expect(2)
.create_async()
.await;
let invoked = Arc::new(std::sync::atomic::AtomicBool::new(false));
let toolbox = Toolbox::new().with(recording_add_tool(invoked.clone()));
let err = client(&server)
.with_tools(&toolbox)
.max_iterations(2)
.run("loop forever")
.await
.unwrap_err();
let msg = err.to_string();
assert!(
matches!(err, RStructorError::ValidationError(_)),
"expected ValidationError, got {err:?}"
);
assert!(
msg.contains("did not converge"),
"error should say it did not converge, got: {msg}"
);
assert!(
msg.contains('2'),
"error should mention the iteration budget (2), got: {msg}"
);
always_tool.assert_async().await;
}
#[cfg(feature = "tools")]
#[tokio::test]
async fn tool_run_request_body_carries_attached_media() {
use rstructor::{MediaFile, RequestExt, Toolbox};
use std::sync::Arc;
let mut server = mockito::Server::new_async().await;
let m = server
.mock("POST", "/chat/completions")
.match_body(mockito::Matcher::PartialJson(json!({
"messages": [{
"role": "user",
"content": [
{ "type": "text", "text": "what is in the image?" },
{
"type": "image_url",
"image_url": { "url": "data:image/png;base64,YWJj", "detail": "auto" },
},
],
}],
})))
.with_status(200)
.with_body(chat_completion("a red square"))
.expect(1)
.create_async()
.await;
let invoked = Arc::new(std::sync::atomic::AtomicBool::new(false));
let toolbox = Toolbox::new().with(recording_add_tool(invoked.clone()));
let media = [MediaFile::from_bytes(b"abc", "image/png")];
let answer = client(&server)
.with_tools(&toolbox)
.media(media.to_vec())
.run("what is in the image?")
.await
.unwrap();
assert_eq!(answer, "a red square");
m.assert_async().await;
}