test(ocr): complete Rust unit test parity (#39689)

* feat(ocr): complete Rust unit test parity

* test(ocr): keep parity changes harness-only

* test(ocr): share azure DI native fixture across response tests

* refactor(ocr): colocate gateway unit tests and extract lifecycle integration tests
This commit is contained in:
yujonglee 2026-09-03 21:15:01 -07:00 committed by GitHub
parent ee08c36fc0
commit eb67e5402b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 754 additions and 210 deletions

View file

@ -1415,7 +1415,6 @@ dependencies = [
"litellm-core",
"pyo3",
"reqwest",
"rstest",
"serde",
"serde_json",
"sha2 0.10.9",

View file

@ -44,5 +44,4 @@ trace-parity = ["server", "dep:tower", "litellm-core/observability"]
[dev-dependencies]
futures-channel = "0.3"
rstest.workspace = true
tower = { version = "0.5.3", features = ["util"] }

View file

@ -395,9 +395,11 @@ pub(super) async fn poll_document_intelligence(
#[cfg(test)]
mod tests {
use super::*;
use litellm_core::ocr::transformation::OcrResponseHandling;
use serde_json::json;
use super::*;
#[test]
fn blocks_private_and_metadata_ips() {
assert!(is_blocked_ip("127.0.0.1".parse().unwrap()));
@ -441,4 +443,87 @@ mod tests {
assert_eq!(transformed, document);
}
#[test]
fn truncate_error_body_passes_short_strings_through() {
let body = "Unauthorized";
assert_eq!(truncate_error_body(body), "Unauthorized");
}
#[test]
fn truncate_error_body_caps_long_payloads() {
let body = "x".repeat(306);
let truncated = truncate_error_body(&body);
assert!(truncated.ends_with("... (truncated)"));
let prefix_chars = truncated
.strip_suffix("... (truncated)")
.expect("truncated marker present")
.chars()
.count();
assert_eq!(prefix_chars, 256);
}
#[test]
fn truncate_error_body_does_not_split_multibyte_chars() {
let body = "é".repeat(266);
let truncated = truncate_error_body(&body);
assert!(truncated.is_char_boundary(truncated.len()));
}
#[test]
fn ocr_dispatch_supports_migrated_providers() {
assert!(ocr_provider_config("mistral", "mistral-ocr-latest").is_some());
assert!(
ocr_provider_config("azure_ai", "pixtral-12b-2409")
.expect("azure ai config resolves")
.requires_data_uri_document()
);
assert_eq!(
ocr_provider_config("azure_ai", "doc-intelligence/prebuilt-read")
.expect("document intelligence config resolves")
.response_handling(),
OcrResponseHandling::AzureDocumentIntelligencePoll
);
assert!(
ocr_provider_config("vertex_ai", "deepseek-ocr-maas")
.expect("vertex deepseek config resolves")
.supported_ocr_params()
.contains(&"temperature")
);
assert!(ocr_provider_config("openai", "gpt-4o").is_none());
}
#[test]
fn string_headers_accepts_string_values() {
let headers = json!({
"x-trace-id": "trace-1"
})
.as_object()
.unwrap()
.clone();
assert_eq!(
string_headers(Some(headers)).expect("string headers accepted"),
vec![("x-trace-id".to_string(), "trace-1".to_string())]
);
}
#[test]
fn string_headers_rejects_non_string_values() {
let headers = json!({
"x-retry-count": 3
})
.as_object()
.unwrap()
.clone();
let err = string_headers(Some(headers)).expect_err("non-string header rejected");
assert_eq!(
err,
Error::InvalidRequest(
"OCR extra_headers.x-retry-count must be a string, got number".to_string()
)
);
}
}

View file

@ -24,4 +24,151 @@ pub async fn ocr(request: OcrRequest<'_>) -> Result<Value, Error> {
}
#[cfg(test)]
mod tests;
mod tests {
use serde_json::{Map, json};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
use super::{OcrRequest, ocr};
use crate::integrations::types::RequestMetadata;
async fn read_http_request(socket: &mut TcpStream) -> String {
let mut request = Vec::new();
let mut buffer = [0_u8; 1024];
let header_end = loop {
let n = socket.read(&mut buffer).await.expect("reads request");
if n == 0 {
break request.len();
}
request.extend_from_slice(&buffer[..n]);
if let Some(position) = request.windows(4).position(|window| window == b"\r\n\r\n") {
break position + 4;
}
};
let headers = String::from_utf8_lossy(&request[..header_end]);
let content_length = headers
.lines()
.find_map(|line| {
let (name, value) = line.split_once(':')?;
name.eq_ignore_ascii_case("content-length")
.then(|| value.trim().parse::<usize>().ok())
.flatten()
})
.unwrap_or(0);
while request.len().saturating_sub(header_end) < content_length {
let n = socket.read(&mut buffer).await.expect("reads body");
if n == 0 {
break;
}
request.extend_from_slice(&buffer[..n]);
}
String::from_utf8(request).expect("request is utf8")
}
fn base_ocr_request(model: &str) -> OcrRequest<'_> {
OcrRequest {
model,
document: json!({
"type": "document_url",
"document_url": "https://example.com/doc.pdf"
}),
api_key: Some("sk-test"),
api_base: None,
custom_llm_provider: None,
extra_headers: None,
optional_params: Map::new(),
timeout: None,
callbacks: Vec::new(),
guardrails: Vec::new(),
request_metadata: RequestMetadata::default(),
litellm_call_id: None,
}
}
#[tokio::test]
async fn reducto_file_upload_then_parse_maps_response() {
let listener = TcpListener::bind("127.0.0.1:0")
.await
.expect("test listener binds");
let address = listener.local_addr().expect("listener has local address");
let server = tokio::spawn(async move {
let (mut upload_socket, _) = listener.accept().await.expect("accepts upload request");
let upload_request = read_http_request(&mut upload_socket).await;
let upload_body = r#"{"file_id":"reducto://uploaded.pdf"}"#;
let upload_response = format!(
"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
upload_body.len(),
upload_body
);
upload_socket
.write_all(upload_response.as_bytes())
.await
.expect("writes upload response");
let (mut parse_socket, _) = listener.accept().await.expect("accepts parse request");
let parse_request = read_http_request(&mut parse_socket).await;
let parse_body = r#"{"job_id":"job_123","usage":{"num_pages":3,"credits":3},"result":{"chunks":[{"content":"Page 1 block A","blocks":[{"content":"Page 1 block A","bbox":{"page":1},"kind":"text"}]},{"content":"Page 2 block A","blocks":[{"content":"Page 2 block A","bbox":{"page":2},"kind":"table"}]},{"content":"Page 1 block B","blocks":[{"content":"Page 1 block B","bbox":{"page":1},"kind":"text"}]},{"content":"Page 3 block A","blocks":[{"content":"Page 3 block A","bbox":{"page":3},"kind":"figure"}]}]}}"#;
let parse_response = format!(
"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
parse_body.len(),
parse_body
);
parse_socket
.write_all(parse_response.as_bytes())
.await
.expect("writes parse response");
(upload_request, parse_request)
});
let api_base = format!("http://{address}");
let mut request = base_ocr_request("reducto/parse-v3");
request.api_base = Some(&api_base);
request.api_key = None;
request.extra_headers = Some(Map::from_iter([
("Authorization".to_string(), json!("Bearer test-key")),
("x-trace-id".to_string(), json!("trace-1")),
]));
request.document = json!({
"type": "document_url",
"document_url": "data:application/pdf;base64,JVBERi0xLjQ="
});
request.optional_params = Map::from_iter([
(
"formatting".to_string(),
json!({"table_output_format": "html"}),
),
("retrieval".to_string(), json!({"chunk_mode": "section"})),
("settings".to_string(), json!({"ocr_system": "standard"})),
]);
let response = ocr(request).await.expect("Reducto OCR succeeds");
assert_eq!(response["pages"].as_array().map(Vec::len), Some(3));
assert_eq!(
response["pages"][0]["markdown"],
"Page 1 block A\n\nPage 1 block B"
);
assert_eq!(response["pages"][1]["markdown"], "Page 2 block A");
assert_eq!(response["pages"][2]["markdown"], "Page 3 block A");
assert_eq!(response["usage_info"]["pages_processed"], 3);
assert_eq!(response["usage_info"]["credits"], 3);
assert_eq!(response["provider_native_response"]["job_id"], "job_123");
let (upload_request, parse_request) = server.await.expect("server task completes");
assert!(
upload_request
.to_ascii_lowercase()
.contains("authorization: bearer test-key")
);
assert!(upload_request.contains("application/pdf"));
assert!(upload_request.contains("%PDF-1.4"));
assert!(upload_request.contains("x-trace-id: trace-1"));
assert!(
parse_request
.to_ascii_lowercase()
.contains("authorization: bearer test-key")
);
assert!(parse_request.contains(r#""input":"reducto://uploaded.pdf""#));
assert!(parse_request.contains(r#""table_output_format":"html""#));
assert!(parse_request.contains(r#""chunk_mode":"section""#));
assert!(parse_request.contains(r#""ocr_system":"standard""#));
}
}

View file

@ -110,3 +110,54 @@ fn new_ocr_call_id() -> String {
.unwrap_or(0);
format!("ocr-{timestamp}-{sequence}")
}
#[cfg(test)]
mod tests {
use litellm_core::error::Error;
use serde_json::{Map, json};
use super::{OcrRequest, prepare_ocr_call};
use crate::integrations::types::RequestMetadata;
fn base_ocr_request(model: &str) -> OcrRequest<'_> {
OcrRequest {
model,
document: json!({
"type": "document_url",
"document_url": "https://example.com/doc.pdf"
}),
api_key: Some("sk-test"),
api_base: None,
custom_llm_provider: None,
extra_headers: None,
optional_params: Map::new(),
timeout: None,
callbacks: Vec::new(),
guardrails: Vec::new(),
request_metadata: RequestMetadata::default(),
litellm_call_id: None,
}
}
fn request_with_format(format: &str) -> OcrRequest<'_> {
let mut request = base_ocr_request("mistral/mistral-ocr-latest");
request.optional_params = Map::from_iter([("req_format".to_string(), json!(format))]);
request
}
#[test]
fn native_format_rejected_for_provider_without_support_as_bad_request() {
let prepared = prepare_ocr_call(request_with_format("native"));
assert!(
matches!(prepared.request.config, Err(Error::InvalidRequest(message)) if message.contains("not supported for provider"))
);
}
#[test]
fn unknown_format_rejected_for_provider_without_support_as_bad_request() {
let prepared = prepare_ocr_call(request_with_format("raw"));
assert!(
matches!(prepared.request.config, Err(Error::InvalidRequest(message)) if message.contains("Invalid `req_format`"))
);
}
}

View file

@ -1,23 +1,19 @@
use std::sync::{Arc, Mutex};
use std::time::Duration;
use litellm_core::error::Error;
use litellm_core::http_utils::has_header;
use litellm_core::ocr::transformation::OcrResponseHandling;
use serde_json::{Map, Value, json};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
use super::common_utils::{ocr_provider_config, string_headers, truncate_error_body};
use super::{OcrRequest, ocr};
use crate::integrations::custom_guardrail::{
use litellm_ai_gateway::integrations::custom_guardrail::{
CustomGuardrail, GuardrailContext, GuardrailDecision, GuardrailError, GuardrailEventHook,
GuardrailFuture, GuardrailRequest,
};
use crate::integrations::custom_logger::{
use litellm_ai_gateway::integrations::custom_logger::{
CallbackTiming, CallbackValue, CustomLogger, LogFuture, ModelCallDetails,
};
use crate::integrations::types::RequestMetadata;
use litellm_ai_gateway::integrations::types::RequestMetadata;
use litellm_ai_gateway::ocr::{OcrRequest, ocr};
use litellm_core::error::Error;
use serde_json::{Map, Value, json};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
async fn read_http_headers(socket: &mut TcpStream) -> String {
let mut request = Vec::new();
@ -136,6 +132,7 @@ struct RecordingOcrGuardrail {
hooks: Vec<GuardrailEventHook>,
events: Mutex<Vec<&'static str>>,
block_pre_call: bool,
block_during_call: bool,
}
impl RecordingOcrGuardrail {
@ -144,6 +141,7 @@ impl RecordingOcrGuardrail {
hooks,
events: Mutex::new(Vec::new()),
block_pre_call: false,
block_during_call: false,
}
}
@ -152,6 +150,16 @@ impl RecordingOcrGuardrail {
hooks: vec![GuardrailEventHook::PreCall],
events: Mutex::new(Vec::new()),
block_pre_call: true,
block_during_call: false,
}
}
fn blocking_during_call() -> Self {
Self {
hooks: vec![GuardrailEventHook::DuringCall],
events: Mutex::new(Vec::new()),
block_pre_call: false,
block_during_call: true,
}
}
@ -193,91 +201,95 @@ impl CustomGuardrail for RecordingOcrGuardrail {
) -> GuardrailFuture<'a> {
Box::pin(async move {
self.events.lock().unwrap().push("async_moderation_hook");
if self.block_during_call {
return Ok(GuardrailDecision::Block(GuardrailError::blocked(
"blocked before provider",
)));
}
request.data["body"]["guarded_during"] = json!(true);
Ok(GuardrailDecision::Mask(request))
})
}
}
#[test]
fn truncate_error_body_passes_short_strings_through() {
let body = "Unauthorized";
assert_eq!(truncate_error_body(body), "Unauthorized");
fn base_ocr_request(model: &str) -> OcrRequest<'_> {
OcrRequest {
model,
document: json!({
"type": "document_url",
"document_url": "https://example.com/doc.pdf"
}),
api_key: Some("sk-test"),
api_base: None,
custom_llm_provider: None,
extra_headers: None,
optional_params: Map::new(),
timeout: None,
callbacks: Vec::new(),
guardrails: Vec::new(),
request_metadata: RequestMetadata::default(),
litellm_call_id: None,
}
}
#[test]
fn truncate_error_body_caps_long_payloads() {
let body = "x".repeat(306);
let truncated = truncate_error_body(&body);
#[tokio::test]
async fn reducto_during_call_guardrail_blocks_before_upload() {
let listener = TcpListener::bind("127.0.0.1:0")
.await
.expect("test listener binds");
let address = listener.local_addr().expect("listener has local address");
let api_base = format!("http://{address}");
let guardrail = Arc::new(RecordingOcrGuardrail::blocking_during_call());
let mut request = base_ocr_request("reducto/parse-v3");
request.api_base = Some(&api_base);
request.document = json!({
"type": "document_url",
"document_url": "data:application/pdf;base64,JVBERi0xLjQ="
});
request.guardrails = vec![guardrail.clone()];
assert!(truncated.ends_with("... (truncated)"));
let prefix_chars = truncated
.strip_suffix("... (truncated)")
.expect("truncated marker present")
.chars()
.count();
assert_eq!(prefix_chars, 256);
let error = ocr(request).await.expect_err("guardrail blocks upload");
assert!(matches!(error, Error::InvalidRequest(_)));
assert_eq!(guardrail.events(), vec!["async_moderation_hook"]);
let accepted = tokio::time::timeout(Duration::from_millis(100), listener.accept()).await;
assert!(accepted.is_err(), "upload socket should not be touched");
}
#[test]
fn truncate_error_body_does_not_split_multibyte_chars() {
let body = "é".repeat(266);
let truncated = truncate_error_body(&body);
assert!(truncated.is_char_boundary(truncated.len()));
}
#[tokio::test]
async fn reducto_upload_error_body_is_truncated() {
let listener = TcpListener::bind("127.0.0.1:0")
.await
.expect("test listener binds");
let address = listener.local_addr().expect("listener has local address");
let server = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.expect("accepts upload request");
let _request = read_http_request(&mut socket).await;
let body = "x".repeat(300);
let response = format!(
"HTTP/1.1 500 Internal Server Error\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
body.len(),
body
);
socket
.write_all(response.as_bytes())
.await
.expect("writes upload response");
});
let api_base = format!("http://{address}");
let mut request = base_ocr_request("reducto/parse-v3");
request.api_base = Some(&api_base);
request.document = json!({
"type": "document_url",
"document_url": "data:application/pdf;base64,JVBERi0xLjQ="
});
let error = ocr(request).await.expect_err("upload should fail");
#[test]
fn ocr_dispatch_supports_migrated_providers() {
assert!(ocr_provider_config("mistral", "mistral-ocr-latest").is_some());
assert!(
ocr_provider_config("azure_ai", "pixtral-12b-2409")
.expect("azure ai config resolves")
.requires_data_uri_document()
matches!(error, Error::Http { status: 500, body } if body.chars().count() < 300 && body.ends_with("... (truncated)"))
);
assert_eq!(
ocr_provider_config("azure_ai", "doc-intelligence/prebuilt-read")
.expect("document intelligence config resolves")
.response_handling(),
OcrResponseHandling::AzureDocumentIntelligencePoll
);
assert!(
ocr_provider_config("vertex_ai", "deepseek-ocr-maas")
.expect("vertex deepseek config resolves")
.supported_ocr_params()
.contains(&"temperature")
);
assert!(ocr_provider_config("openai", "gpt-4o").is_none());
}
#[test]
fn string_headers_accepts_string_values() {
let headers = json!({
"x-trace-id": "trace-1"
})
.as_object()
.unwrap()
.clone();
assert_eq!(
string_headers(Some(headers)).expect("string headers accepted"),
vec![("x-trace-id".to_string(), "trace-1".to_string())]
);
}
#[test]
fn auth_header_detection_is_case_insensitive() {
let headers = vec![
("x-trace-id".to_string(), "trace-1".to_string()),
("authorization".to_string(), "Bearer sk-test".to_string()),
];
assert!(has_header(&headers, "authorization"));
let headers = vec![("Authorization".to_string(), "Bearer sk-test".to_string())];
assert!(has_header(&headers, "authorization"));
let headers = vec![("x-trace-id".to_string(), "trace-1".to_string())];
assert!(!has_header(&headers, "authorization"));
server.await.expect("server task completes");
}
#[tokio::test]
@ -595,21 +607,3 @@ async fn document_intelligence_poll_uses_resolved_subscription_key() {
"{poll_request}"
);
}
#[test]
fn string_headers_rejects_non_string_values() {
let headers = json!({
"x-retry-count": 3
})
.as_object()
.unwrap()
.clone();
let err = string_headers(Some(headers)).expect_err("non-string header rejected");
assert_eq!(
err,
Error::InvalidRequest(
"OCR extra_headers.x-retry-count must be a string, got number".to_string()
)
);
}

View file

@ -101,6 +101,23 @@ mod tests {
assert!(!has_header(&headers, "authorization"));
}
#[test]
fn auth_header_detection_is_case_insensitive() {
let headers = vec![
("x-trace-id".to_string(), "trace-1".to_string()),
("authorization".to_string(), "Bearer sk-test".to_string()),
];
assert!(has_header(&headers, "authorization"));
let headers = vec![("Authorization".to_string(), "Bearer sk-test".to_string())];
assert!(has_header(&headers, "authorization"));
let headers = vec![("x-trace-id".to_string(), "trace-1".to_string())];
assert!(!has_header(&headers, "authorization"));
}
#[test]
fn bearer_detection_requires_a_non_empty_token() {
assert!(has_bearer_auth(&[(

View file

@ -663,10 +663,12 @@ mod tests {
.map(|(_, value)| value.as_str())
}
#[fixture]
fn native_operation() -> Value {
json!({
"status": "succeeded",
"createdDateTime": "2026-07-02T00:00:00Z",
"lastUpdatedDateTime": "2026-07-02T00:00:05Z",
"analyzeResult": {
"content": "Invoice\nInvoice No: INV-12345\nTotal: $100.00",
"pages": [{
@ -682,13 +684,65 @@ mod tests {
],
"words": [{"content": "Invoice", "confidence": 0.994}]
}],
"tables": [{"rowCount": 1, "columnCount": 1}],
"keyValuePairs": [{"key": {"content": "Invoice No"}, "value": {"content": "INV-12345"}}],
"tables": [
{
"rowCount": 2,
"columnCount": 2,
"cells": [
{"kind": "columnHeader", "rowIndex": 0, "columnIndex": 0, "content": "Item"},
{"kind": "columnHeader", "rowIndex": 0, "columnIndex": 1, "content": "Price"},
{"rowIndex": 1, "columnIndex": 0, "content": "Widget"},
{"rowIndex": 1, "columnIndex": 1, "content": "$100.00"}
]
},
{
"rowCount": 1,
"columnCount": 1,
"cells": [{"rowIndex": 0, "columnIndex": 0, "content": "Totals"}]
}
],
"keyValuePairs": [
{
"key": {"content": "Invoice No"},
"value": {"content": "INV-12345"},
"confidence": 0.98
},
{
"key": {"content": "Total"},
"value": {"content": "$100.00"},
"confidence": 0.95
}
],
"paragraphs": [{"content": "Invoice"}]
}
})
}
fn assert_native_fields_preserved(response: &OcrResponseData, operation: &Value) {
let analyze_result = &operation["analyzeResult"];
assert_eq!(response.extra_fields["content"], analyze_result["content"]);
assert_eq!(response.extra_fields["tables"], analyze_result["tables"]);
assert_eq!(
response.extra_fields["keyValuePairs"],
analyze_result["keyValuePairs"]
);
assert_eq!(response.object, "ocr");
assert_eq!(
response.usage_info,
Some(json!({"pages_processed": 1, "doc_size_bytes": null}))
);
assert_eq!(response.pages[0]["index"], 0);
assert_eq!(
response.pages[0]["markdown"],
"Invoice\nInvoice No: INV-12345\nTotal: $100.00"
);
assert_eq!(
response.pages[0]["dimensions"],
json!({"width": 816, "height": 1056, "dpi": 96})
);
}
#[test]
fn azure_ai_reuses_mistral_body_transform() {
let body = AZURE_AI_OCR_CONFIG
@ -798,15 +852,10 @@ mod tests {
#[case::nested_list(json!([["keyValuePairs"]]))]
#[case::object(json!({"feature": "keyValuePairs"}))]
#[case::number(json!(5))]
fn document_intelligence_url_rejects_invalid_features(#[case] features: Value) {
fn document_intelligence_mapping_rejects_invalid_features(#[case] features: Value) {
let params = serde_json::Map::from_iter([("features".to_string(), features)]);
let error = complete_document_intelligence_url(
Some("https://example.cognitiveservices.azure.com"),
"prebuilt-layout",
&params,
&|_| None,
)
.expect_err("invalid features must fail");
let error =
map_document_intelligence_ocr_params(&params).expect_err("invalid features must fail");
assert!(matches!(
error,
@ -814,28 +863,25 @@ mod tests {
));
}
#[test]
fn document_intelligence_maps_features() {
for (features, expected) in [
(json!(["keyValuePairs"]), "keyValuePairs"),
(
json!(["keyValuePairs", "languages"]),
"keyValuePairs,languages",
),
(json!("keyValuePairs"), "keyValuePairs"),
(json!("keyValuePairs,languages"), "keyValuePairs,languages"),
(json!("keyValuePairs, languages"), "keyValuePairs,languages"),
] {
let params = Map::from_iter([
("features".to_string(), features),
("unsupported".to_string(), json!(true)),
]);
#[rstest]
#[case::single_list(json!(["keyValuePairs"]), "keyValuePairs")]
#[case::multiple_list(
json!(["keyValuePairs", "languages"]),
"keyValuePairs,languages"
)]
#[case::single_string(json!("keyValuePairs"), "keyValuePairs")]
#[case::comma_separated(json!("keyValuePairs,languages"), "keyValuePairs,languages")]
#[case::spaces(json!("keyValuePairs, languages"), "keyValuePairs,languages")]
fn document_intelligence_maps_features(#[case] features: Value, #[case] expected: &str) {
let params = Map::from_iter([
("features".to_string(), features),
("unsupported".to_string(), json!(true)),
]);
assert_eq!(
AZURE_DOCUMENT_INTELLIGENCE_OCR_CONFIG.map_ocr_params(&params),
Map::from_iter([("features".to_string(), json!(expected))])
);
}
assert_eq!(
AZURE_DOCUMENT_INTELLIGENCE_OCR_CONFIG.map_ocr_params(&params),
Map::from_iter([("features".to_string(), json!(expected))])
);
}
#[test]
@ -852,43 +898,13 @@ mod tests {
assert_eq!(body, json!({"base64Source": "abc123"}));
}
#[test]
fn document_intelligence_response_normalizes_pages() {
#[rstest]
fn document_intelligence_response_normalizes_pages(native_operation: Value) {
let response = AZURE_DOCUMENT_INTELLIGENCE_OCR_CONFIG
.transform_ocr_response(
"prebuilt-layout",
json!({
"status": "succeeded",
"analyzeResult": {
"content": "hello\nworld",
"tables": [{"rowCount": 1, "columnCount": 1}],
"keyValuePairs": [{"key": {"content": "Total"}, "value": {"content": "$100.00"}}],
"pages": [{
"pageNumber": 2,
"width": 8.5,
"height": 11,
"unit": "inch",
"lines": [{"content": "hello"}, {"content": "world"}]
}]
}
}),
)
.transform_ocr_response("prebuilt-layout", native_operation.clone())
.expect("response transforms");
assert_eq!(response.pages[0]["index"], 1);
assert_eq!(response.pages[0]["markdown"], "hello\nworld");
assert_eq!(response.pages[0]["dimensions"]["width"], 816);
assert_eq!(response.extra_fields["content"], "hello\nworld");
assert_eq!(response.extra_fields["tables"][0]["rowCount"], 1);
assert_eq!(
response.extra_fields["keyValuePairs"][0]["key"]["content"],
"Total"
);
assert_eq!(response.object, "ocr");
assert_eq!(
response.usage_info,
Some(json!({"pages_processed": 1, "doc_size_bytes": null}))
);
assert_native_fields_preserved(&response, &native_operation);
}
#[test]
@ -923,28 +939,16 @@ mod tests {
);
}
#[test]
fn document_intelligence_async_response_preserves_normalized_fields() {
#[rstest]
fn document_intelligence_async_response_preserves_normalized_fields(native_operation: Value) {
let response = AZURE_DOCUMENT_INTELLIGENCE_OCR_CONFIG
.transform_ocr_response(
"azure_ai/doc-intelligence/prebuilt-layout",
native_operation(),
native_operation.clone(),
)
.expect("response transforms");
assert_eq!(
response.pages[0]["markdown"],
"Invoice\nInvoice No: INV-12345\nTotal: $100.00"
);
assert_eq!(
response.pages[0]["dimensions"],
json!({"width": 816, "height": 1056, "dpi": 96})
);
assert_eq!(response.extra_fields["tables"][0]["rowCount"], 1);
assert_eq!(
response.extra_fields["keyValuePairs"][0]["key"]["content"],
"Invoice No"
);
assert_native_fields_preserved(&response, &native_operation);
}
#[test]
@ -998,44 +1002,38 @@ mod tests {
);
}
#[test]
fn document_intelligence_native_format_carries_raw_operation() {
let operation = native_operation();
#[rstest]
fn document_intelligence_native_format_carries_raw_operation(native_operation: Value) {
let response = AZURE_DOCUMENT_INTELLIGENCE_OCR_CONFIG
.transform_ocr_response_with_params(
"azure_ai/doc-intelligence/prebuilt-layout",
operation.clone(),
native_operation.clone(),
&Map::from_iter([("req_format".to_string(), json!("native"))]),
)
.expect("native response transforms");
assert_eq!(response.provider_native_response, Some(operation));
assert_eq!(
response.extra_fields["content"],
"Invoice\nInvoice No: INV-12345\nTotal: $100.00"
);
assert_eq!(
response.usage_info.as_ref().expect("usage")["pages_processed"],
1
response.provider_native_response,
Some(native_operation.clone())
);
assert_native_fields_preserved(&response, &native_operation);
}
#[test]
fn document_intelligence_async_native_format_carries_raw_operation() {
let operation = native_operation();
#[rstest]
fn document_intelligence_async_native_format_carries_raw_operation(native_operation: Value) {
let response = AZURE_DOCUMENT_INTELLIGENCE_OCR_CONFIG
.transform_ocr_response_with_params(
"azure_ai/doc-intelligence/prebuilt-layout",
operation.clone(),
native_operation.clone(),
&Map::from_iter([("req_format".to_string(), json!("native"))]),
)
.expect("native response transforms");
assert_eq!(response.provider_native_response, Some(operation));
assert_eq!(
response.usage_info.as_ref().expect("usage")["pages_processed"],
1
response.provider_native_response,
Some(native_operation.clone())
);
assert_native_fields_preserved(&response, &native_operation);
}
#[rstest]
@ -1043,17 +1041,18 @@ mod tests {
#[case::litellm(Map::from_iter([("req_format".to_string(), json!("litellm"))]))]
fn document_intelligence_default_format_omits_raw_operation(
#[case] optional_params: Map<String, Value>,
native_operation: Value,
) {
let response = AZURE_DOCUMENT_INTELLIGENCE_OCR_CONFIG
.transform_ocr_response_with_params(
"azure_ai/doc-intelligence/prebuilt-layout",
native_operation(),
native_operation.clone(),
&optional_params,
)
.expect("response transforms");
assert_eq!(response.provider_native_response, None);
assert_eq!(response.extra_fields["tables"][0]["rowCount"], 1);
assert_native_fields_preserved(&response, &native_operation);
}
#[rstest]

View file

@ -10,4 +10,4 @@ For Python, those traced functions define the denominator. Static references and
For Rust, each traced function identifies its source file and module. If that source file has a colocated `#[cfg(test)] mod tests`, the harness inventories that module for the configured Rust target. Rust test names are therefore derived from traced implementation files, not from a hand-maintained list of OCR test modules
The Python-to-Rust mappings remain explicit because equivalent behavior often has different test boundaries and names in each SDK. The report validates those mappings against both live inventories, then shows mapped Python tests, unmapped Python tests that still need a Rust counterpart, and Rust-only tests
The Python-to-Rust mappings remain explicit because equivalent behavior often has different test boundaries and names in each SDK. Host-only exclusions require a reason. The report validates both against the live inventories, then shows mapped, excluded, and unmapped Python tests plus Rust-only tests

View file

@ -4,8 +4,10 @@ from typing import Final
from ....shared.unit_runners.rust_runner import RustTarget, RustTestIdentity
from ..contracts import (
MappingExclusionSpec,
MappingSpec,
PythonFunctionDiscoverySpec,
RustTestFamily,
RustUnitSpec,
TestMapping,
UnitParityExclusionSpec,
@ -21,12 +23,249 @@ _GATEWAY_TARGET: Final = RustTarget(
)
_AZURE_OCR_TESTS: Final = "providers::azure_ai::ocr::transformation::tests"
_MISTRAL_OCR_TESTS: Final = "providers::mistral::ocr::transformation::tests"
_VERTEX_OCR_TESTS: Final = "providers::vertex_ai::ocr::transformation::tests"
_REDUCTO_OCR_TESTS: Final = "providers::reducto::ocr::tests"
_GATEWAY_OCR_TESTS: Final = "ocr::tests"
_GATEWAY_PREPARE_OCR_TESTS: Final = "ocr::prepare::tests"
def _rust_test(target: RustTarget, module: str, test: str) -> RustTestIdentity:
return RustTestIdentity(target=target, name=f"{module}::{test}")
def _rust_family(target: RustTarget, module: str, test: str) -> RustTestFamily:
return RustTestFamily(target=target, name=f"{module}::{test}")
def _test_mappings(target: RustTarget, module: str, pairs: tuple[tuple[str, str], ...]) -> tuple[TestMapping, ...]:
return tuple(TestMapping(python=python, rust=_rust_test(target, module, test)) for python, test in pairs)
_AZURE_TRANSFORM_FILE: Final = "tests/test_litellm/llms/azure_ai/test_azure_document_intelligence_ocr_transformation.py"
_AZURE_PAGES_FILE: Final = "tests/ocr_tests/test_ocr_azure_document_intelligence.py"
_AZURE_BASE_FILE: Final = "tests/test_litellm/ocr/test_ocr_azure_document_intelligence_api_base.py"
_RUST_BRIDGE_FILE: Final = "tests/test_litellm/ocr/test_rust_bridge.py"
_AZURE_PORT_MAPPINGS: Final = _test_mappings(
_CORE_TARGET,
_AZURE_OCR_TESTS,
(
(
f"{_AZURE_TRANSFORM_FILE}::test_should_encode_azure_document_intelligence_model_id",
"azure_document_intelligence_model_id_is_encoded",
),
(
f"{_AZURE_TRANSFORM_FILE}::test_should_reject_dot_segment_azure_document_intelligence_model_id",
"azure_document_intelligence_dot_segment_model_id_is_rejected",
),
(
f"{_AZURE_TRANSFORM_FILE}::test_async_transform_ocr_response_preserves_azure_native_fields",
"document_intelligence_async_response_preserves_normalized_fields",
),
(
f"{_AZURE_TRANSFORM_FILE}::test_transform_ocr_response_tolerates_missing_native_fields",
"document_intelligence_response_tolerates_missing_native_fields",
),
(
f"{_AZURE_TRANSFORM_FILE}::test_transform_ocr_response_non_succeeded_status_raises",
"document_intelligence_non_succeeded_status_is_rejected",
),
(
f"{_AZURE_TRANSFORM_FILE}::test_get_supported_ocr_params_includes_features",
"document_intelligence_supported_params_include_features",
),
(
f"{_AZURE_TRANSFORM_FILE}::test_transform_ocr_response_native_format_carries_raw_operation",
"document_intelligence_native_format_carries_raw_operation",
),
(
f"{_AZURE_TRANSFORM_FILE}::test_async_transform_ocr_response_native_format_carries_raw_operation",
"document_intelligence_async_native_format_carries_raw_operation",
),
(
f"{_AZURE_TRANSFORM_FILE}::test_map_ocr_params_rejects_unknown_req_format_as_bad_request",
"document_intelligence_rejects_unknown_req_format",
),
(
f"{_AZURE_TRANSFORM_FILE}::test_get_complete_url_omits_req_format_query_param",
"document_intelligence_url_omits_req_format",
),
(
f"{_AZURE_TRANSFORM_FILE}::test_validate_environment_uses_subscription_key",
"document_intelligence_validate_environment_uses_subscription_key",
),
(
f"{_AZURE_TRANSFORM_FILE}::test_validate_environment_falls_back_to_entra_token",
"document_intelligence_validate_environment_falls_back_to_entra_token",
),
(
f"{_AZURE_PAGES_FILE}::TestAzureDocumentIntelligencePagesParam::test_get_supported_ocr_params_includes_pages_and_features",
"document_intelligence_supported_params_include_pages_features_and_req_format",
),
(
f"{_AZURE_PAGES_FILE}::TestAzureDocumentIntelligencePagesParam::test_map_ocr_params_mistral_zero_based_int_list",
"document_intelligence_maps_zero_based_page_list",
),
(
f"{_AZURE_PAGES_FILE}::TestAzureDocumentIntelligencePagesParam::test_map_ocr_params_dedupes_and_sorts",
"document_intelligence_page_mapping_dedupes_and_sorts",
),
(
f"{_AZURE_PAGES_FILE}::TestAzureDocumentIntelligencePagesParam::test_map_ocr_params_empty_list_omits_pages",
"document_intelligence_page_mapping_omits_empty_list",
),
(
f"{_AZURE_PAGES_FILE}::TestAzureDocumentIntelligencePagesParam::test_map_ocr_params_azure_native_string_range",
"document_intelligence_page_mapping_accepts_native_range",
),
(
f"{_AZURE_PAGES_FILE}::TestAzureDocumentIntelligencePagesParam::test_map_ocr_params_azure_native_string_with_spaces_stripped",
"document_intelligence_page_mapping_strips_spaces",
),
(
f"{_AZURE_PAGES_FILE}::TestAzureDocumentIntelligencePagesParam::test_map_ocr_params_list_of_string_tokens",
"document_intelligence_page_mapping_accepts_string_tokens",
),
(
f"{_AZURE_PAGES_FILE}::TestAzureDocumentIntelligencePagesParam::test_map_ocr_params_invalid_string_raises",
"document_intelligence_page_mapping_rejects_invalid_string",
),
(
f"{_AZURE_PAGES_FILE}::TestAzureDocumentIntelligencePagesParam::test_map_ocr_params_negative_index_raises",
"document_intelligence_page_mapping_rejects_negative_index",
),
(
f"{_AZURE_PAGES_FILE}::TestAzureDocumentIntelligencePagesParam::test_map_ocr_params_bool_list_raises",
"document_intelligence_page_mapping_rejects_bool_list",
),
(
f"{_AZURE_PAGES_FILE}::TestAzureDocumentIntelligencePagesParam::test_map_ocr_params_unsupported_type_raises",
"document_intelligence_page_mapping_rejects_unsupported_type",
),
(
f"{_AZURE_PAGES_FILE}::TestAzureDocumentIntelligencePagesParam::test_get_complete_url_appends_pages_query",
"document_intelligence_url_appends_pages_query",
),
(
f"{_AZURE_PAGES_FILE}::TestAzureDocumentIntelligencePagesParam::test_get_complete_url_no_pages_when_optional_params_empty",
"document_intelligence_url_has_no_pages_when_params_are_empty",
),
(
f"{_AZURE_PAGES_FILE}::TestAzureDocumentIntelligencePagesParam::test_transform_ocr_request_does_not_put_pages_in_body",
"document_intelligence_request_keeps_pages_out_of_body",
),
(
f"{_AZURE_PAGES_FILE}::TestAzureDocumentIntelligencePagesParam::test_end_to_end_mistral_shape_to_azure_query",
"document_intelligence_mistral_pages_flow_to_query_only",
),
(
"tests/test_litellm/llms/azure_ai/test_azure_ai_entra_auth.py::test_ocr_authenticates_with_entra_token",
"azure_ai_ocr_authenticates_with_entra_token",
),
(
f"{_AZURE_BASE_FILE}::TestDocIntelligenceApiBaseResolution::test_generic_azure_ai_base_does_not_hijack_doc_intelligence",
"document_intelligence_endpoint_ignores_generic_azure_ai_base",
),
(
f"{_AZURE_BASE_FILE}::TestDocIntelligenceApiBaseResolution::test_explicit_api_base_is_honoured_for_doc_intelligence",
"document_intelligence_endpoint_honors_explicit_api_base",
),
(
f"{_AZURE_BASE_FILE}::TestDocIntelligenceApiBaseResolution::test_generic_azure_ai_base_still_applies_to_mistral_ocr",
"azure_ai_mistral_ocr_uses_generic_api_base",
),
),
)
_REDUCTO_PORT_MAPPINGS: Final = _test_mappings(
_CORE_TARGET,
_REDUCTO_OCR_TESTS,
(
(
"tests/test_litellm/llms/reducto/test_parse_v3.py::test_parse_v3_reducto_id_passthrough_skips_upload",
"test_parse_v3_reducto_id_passthrough_skips_upload",
),
(
"tests/test_litellm/llms/reducto/test_parse_legacy.py::test_parse_legacy_wraps_enhance_under_options",
"test_parse_legacy_wraps_enhance_under_options",
),
(
"tests/test_litellm/llms/reducto/test_upload.py::test_parse_v3_image_data_uri_upload_uses_image_mime",
"test_parse_v3_image_data_uri_upload_uses_image_mime",
),
(
"tests/test_litellm/llms/reducto/test_upload.py::test_parse_v3_uses_programmatic_api_key_over_env",
"test_parse_v3_uses_programmatic_api_key_over_env",
),
),
)
_REDUCTO_GATEWAY_MAPPING: Final = TestMapping(
python="tests/test_litellm/llms/reducto/test_parse_v3.py::test_parse_v3_file_upload_and_response_mapping",
rust=_rust_test(_GATEWAY_TARGET, _GATEWAY_OCR_TESTS, "reducto_file_upload_then_parse_maps_response"),
)
_GATEWAY_PORT_MAPPINGS: Final = _test_mappings(
_GATEWAY_TARGET,
_GATEWAY_PREPARE_OCR_TESTS,
(
(
"tests/test_litellm/ocr/test_ocr_native_format.py::test_native_format_rejected_for_provider_without_support_as_bad_request",
"native_format_rejected_for_provider_without_support_as_bad_request",
),
(
"tests/test_litellm/ocr/test_ocr_native_format.py::test_unknown_format_rejected_for_provider_without_support_as_bad_request",
"unknown_format_rejected_for_provider_without_support_as_bad_request",
),
),
)
_HOST_ONLY_BRIDGE_EXCLUSIONS: Final = tuple(
MappingExclusionSpec(nodeid=f"{_RUST_BRIDGE_FILE}::{test}", reason=reason)
for test, reason in (
("test_ocr_routes_to_rust_when_enabled", "Python selects and invokes the native bridge."),
("test_ocr_routes_azure_ai_to_rust_when_enabled", "Python resolves provider arguments before the bridge."),
("test_ocr_rust_path_converts_file_document_before_bridge", "Python converts file inputs before the bridge."),
(
"test_ocr_exception_type_uses_resolved_provider_context",
"Python wraps bridge exceptions into public errors.",
),
("test_aocr_routes_to_async_rust_when_enabled", "Python selects and invokes the async native bridge."),
("test_aocr_exception_type_uses_resolved_provider_context", "Python wraps async bridge exceptions."),
("test_ocr_forwards_timeout_to_rust", "Python converts and forwards explicit timeouts."),
("test_ocr_passes_default_request_timeout_to_rust", "Python supplies its process-level default timeout."),
("test_ocr_falls_back_to_python_when_bridge_unavailable", "Python owns fallback when the extension is absent."),
)
)
_FAMILY_PORT_MAPPINGS: Final = (
TestMapping(
python=f"{_AZURE_TRANSFORM_FILE}::test_transform_ocr_response_default_format_omits_raw_operation",
rust=_rust_family(
_CORE_TARGET,
_AZURE_OCR_TESTS,
"document_intelligence_default_format_omits_raw_operation",
),
),
TestMapping(
python=f"{_AZURE_TRANSFORM_FILE}::test_map_ocr_params_passes_through_req_format",
rust=_rust_family(_CORE_TARGET, _AZURE_OCR_TESTS, "document_intelligence_maps_req_format"),
),
TestMapping(
python="tests/ocr_tests/test_ocr_vertex_ai.py::test_deepseek_request_uses_single_provider_namespace",
rust=_rust_family(
_CORE_TARGET,
_VERTEX_OCR_TESTS,
"vertex_deepseek_request_uses_single_provider_namespace",
),
),
TestMapping(
python="tests/test_litellm/llms/reducto/test_upload.py::test_parse_v3_rejects_plain_http_urls",
rust=_rust_family(_CORE_TARGET, _REDUCTO_OCR_TESTS, "test_parse_v3_rejects_plain_http_urls"),
),
)
OCR_CONTRACT: Final = UnitTestContract(
mapping=MappingSpec(
python_functions=PythonFunctionDiscoverySpec(
@ -71,7 +310,7 @@ OCR_CONTRACT: Final = UnitTestContract(
),
TestMapping(
python="tests/test_litellm/llms/azure_ai/test_azure_document_intelligence_ocr_transformation.py::test_map_ocr_params_features",
rust=_rust_test(_CORE_TARGET, _AZURE_OCR_TESTS, "document_intelligence_maps_features"),
rust=_rust_family(_CORE_TARGET, _AZURE_OCR_TESTS, "document_intelligence_maps_features"),
),
TestMapping(
python="tests/test_litellm/llms/azure_ai/test_azure_document_intelligence_ocr_transformation.py::test_map_ocr_params_empty_features_list_omitted",
@ -79,7 +318,11 @@ OCR_CONTRACT: Final = UnitTestContract(
),
TestMapping(
python="tests/test_litellm/llms/azure_ai/test_azure_document_intelligence_ocr_transformation.py::test_map_ocr_params_invalid_features_raises",
rust=_rust_test(_CORE_TARGET, _AZURE_OCR_TESTS, "document_intelligence_url_rejects_invalid_features"),
rust=_rust_family(
_CORE_TARGET,
_AZURE_OCR_TESTS,
"document_intelligence_mapping_rejects_invalid_features",
),
),
TestMapping(
python="tests/test_litellm/llms/azure_ai/test_azure_document_intelligence_ocr_transformation.py::test_get_complete_url_appends_features_query",
@ -145,7 +388,14 @@ OCR_CONTRACT: Final = UnitTestContract(
python="tests/test_litellm/llms/mistral/ocr/test_mistral_ocr_transformation.py::TestTransformOcrResponseOcr4Fields::test_ocr4_fields_survive_model_dump",
rust=_rust_test(_CORE_TARGET, _MISTRAL_OCR_TESTS, "transform_ocr_response_preserves_ocr4_page_fields"),
),
*_AZURE_PORT_MAPPINGS,
*_REDUCTO_PORT_MAPPINGS,
_REDUCTO_GATEWAY_MAPPING,
*_GATEWAY_PORT_MAPPINGS,
*_FAMILY_PORT_MAPPINGS,
),
exclusions=_HOST_ONLY_BRIDGE_EXCLUSIONS,
require_complete=True,
),
unit_parity=UnitParitySpec(
python_selectors=(

View file

@ -126,10 +126,13 @@ def test_should_derive_ocr_mapping_status_from_live_tests() -> None:
f"Missing Python tests: {list(report.missing_python_tests)}\n"
f"Missing Rust tests: {list(report.missing_rust_tests)}\n"
f"Duplicate Python mappings: {list(report.duplicate_python_mappings)}\n"
f"Invalid mapping exclusions: {list(report.invalid_mapping_exclusions)}\n"
f"Invalid parity exclusions: {list(report.invalid_unit_parity_exclusions)}"
)
assert report.mapped_count == len(OCR_CONTRACT.mapping.mappings)
assert report.total_count == report.mapped_count + len(report.unmapped_python_tests)
assert report.total_count == (
report.mapped_count + len(report.excluded_python_tests) + len(report.unmapped_python_tests)
)
def test_strategy_subcommand_accepts_function_filter(capsys: pytest.CaptureFixture[str]) -> None: