diff --git a/litellm-rust/crates/ai-gateway/src/constants.rs b/litellm-rust/crates/ai-gateway/src/constants.rs index 78af374bf70..6c538ff5ac6 100644 --- a/litellm-rust/crates/ai-gateway/src/constants.rs +++ b/litellm-rust/crates/ai-gateway/src/constants.rs @@ -40,3 +40,19 @@ pub(crate) const MESSAGES_ROUTE_PATH: &str = "/v1/messages"; #[cfg(feature = "server")] pub(crate) const MESSAGES_HEADERS_NOT_FORWARDED: &[&str] = &["authorization", "connection", "content-length", "host"]; + +/// Fallback MIME type for OCR file input whose type cannot be detected. +pub(crate) const OCR_DEFAULT_MIME_TYPE: &str = "application/octet-stream"; + +/// File extension (lowercase, no dot) to MIME type for OCR file input. +pub(crate) const OCR_MIME_TYPES_BY_EXTENSION: &[(&str, &str)] = &[ + ("pdf", "application/pdf"), + ("png", "image/png"), + ("jpg", "image/jpeg"), + ("jpeg", "image/jpeg"), + ("gif", "image/gif"), + ("webp", "image/webp"), + ("tiff", "image/tiff"), + ("tif", "image/tiff"), + ("bmp", "image/bmp"), +]; diff --git a/litellm-rust/crates/ai-gateway/src/ocr/file_input.rs b/litellm-rust/crates/ai-gateway/src/ocr/file_input.rs new file mode 100644 index 00000000000..ec041961f80 --- /dev/null +++ b/litellm-rust/crates/ai-gateway/src/ocr/file_input.rs @@ -0,0 +1,550 @@ +use std::io::Read; +use std::path::{Path, PathBuf}; + +use base64::Engine; +use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; +use litellm_core::error::Error; +use serde_json::{Value, json}; + +use crate::constants::{OCR_DEFAULT_MIME_TYPE, OCR_MIME_TYPES_BY_EXTENSION}; + +pub enum FileInput { + Path(PathBuf), + Bytes(Vec), + Reader { + name: Option, + reader: Box, + }, + Str(String), + Unsupported(String), +} + +pub fn get_mime_type(file_path: &str) -> String { + let extension = Path::new(file_path) + .extension() + .and_then(|ext| ext.to_str()) + .map(|ext| ext.to_ascii_lowercase()); + extension + .and_then(|ext| { + OCR_MIME_TYPES_BY_EXTENSION + .iter() + .find(|(known, _)| *known == ext) + .map(|(_, mime)| (*mime).to_string()) + }) + .unwrap_or_else(|| OCR_DEFAULT_MIME_TYPE.to_string()) +} + +fn is_valid_mime_type(mime_type: &str) -> bool { + let Some((kind, subtype)) = mime_type.split_once('/') else { + return false; + }; + let is_token = |part: &str| { + !part.is_empty() + && part + .chars() + .all(|c| c.is_alphanumeric() || matches!(c, '_' | '.' | '+' | '-')) + }; + is_token(kind) && is_token(subtype) +} + +fn read_file_input(file: FileInput) -> Result<(Vec, String), Error> { + match file { + FileInput::Str(_) => Err(Error::InvalidRequest( + "OCR file input does not accept bare str values. Pass bytes, a pathlib.Path, or a \ + file-like object. To OCR a local file from a path, call open(path, 'rb') yourself." + .to_string(), + )), + FileInput::Path(path) => { + if !path.is_file() { + return Err(Error::InvalidRequest(format!( + "File not found: {}", + path.display() + ))); + } + let bytes = std::fs::read(&path).map_err(|err| { + Error::InvalidRequest(format!("File not found: {} ({err})", path.display())) + })?; + Ok((bytes, get_mime_type(&path.to_string_lossy()))) + } + FileInput::Bytes(bytes) => Ok((bytes, OCR_DEFAULT_MIME_TYPE.to_string())), + FileInput::Reader { name, mut reader } => { + let mut bytes = Vec::new(); + reader + .read_to_end(&mut bytes) + .map_err(|err| Error::InvalidRequest(format!("File could not be read: {err}")))?; + let mime_type = name + .filter(|name| !name.is_empty()) + .map(|name| get_mime_type(&name)) + .unwrap_or_else(|| OCR_DEFAULT_MIME_TYPE.to_string()); + Ok((bytes, mime_type)) + } + FileInput::Unsupported(type_name) => Err(Error::InvalidRequest(format!( + "Unsupported file input type: {type_name}. Expected pathlib.Path, bytes, or a \ + file-like object." + ))), + } +} + +fn data_uri_document(mime_type: &str, bytes: &[u8]) -> Value { + let data_uri = format!("data:{mime_type};base64,{}", BASE64_STANDARD.encode(bytes)); + if mime_type.starts_with("image/") { + return json!({"type": "image_url", "image_url": data_uri}); + } + json!({"type": "document_url", "document_url": data_uri}) +} + +pub fn convert_file_document_to_url_document( + file: Option, + mime_type_override: Option<&str>, +) -> Result { + let file = file.ok_or_else(|| { + Error::InvalidRequest( + "document with type='file' must include a 'file' field containing a pathlib.Path, \ + file-like object, or bytes" + .to_string(), + ) + })?; + let (bytes, detected_mime_type) = read_file_input(file)?; + if bytes.is_empty() { + return Err(Error::InvalidRequest( + "File is empty or could not be read".to_string(), + )); + } + let mime_type = mime_type_override.unwrap_or(&detected_mime_type); + if !is_valid_mime_type(mime_type) { + return Err(Error::InvalidRequest(format!( + "Invalid MIME type: {mime_type}" + ))); + } + Ok(data_uri_document(mime_type, &bytes)) +} + +pub fn build_document_from_upload( + file_content: Vec, + filename: Option<&str>, + content_type: Option<&str>, +) -> Result { + let header_mime_type = content_type + .and_then(|value| value.split(';').next()) + .map(str::trim) + .filter(|value| !value.is_empty() && *value != OCR_DEFAULT_MIME_TYPE); + let mime_type = header_mime_type + .map(str::to_string) + .or_else(|| filename.map(get_mime_type)) + .unwrap_or_else(|| OCR_DEFAULT_MIME_TYPE.to_string()); + convert_file_document_to_url_document(Some(FileInput::Bytes(file_content)), Some(&mime_type)) +} + +#[cfg(test)] +mod tests { + use std::io::Cursor; + use std::sync::atomic::{AtomicU64, Ordering}; + + use super::*; + + struct TempFile(PathBuf); + + impl TempFile { + fn with_suffix(suffix: &str, content: &[u8]) -> Self { + static COUNTER: AtomicU64 = AtomicU64::new(0); + let unique = COUNTER.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir().join(format!( + "litellm-ocr-file-input-{}-{unique}{suffix}", + std::process::id() + )); + std::fs::write(&path, content).unwrap(); + Self(path) + } + } + + impl Drop for TempFile { + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.0); + } + } + + fn data_uri_payload<'a>(document: &'a Value, field: &str) -> &'a str { + document[field].as_str().unwrap() + } + + fn decode_base64(data_uri: &str) -> Vec { + let (_, encoded) = data_uri.split_once(";base64,").unwrap(); + BASE64_STANDARD.decode(encoded).unwrap() + } + + fn invalid_request_message(error: Error) -> String { + match error { + Error::InvalidRequest(message) => message, + other => panic!("expected InvalidRequest, got {other:?}"), + } + } + + #[test] + fn mime_type_detects_pdf() { + assert_eq!(get_mime_type("document.pdf"), "application/pdf"); + } + + #[test] + fn mime_type_detects_png() { + assert_eq!(get_mime_type("image.png"), "image/png"); + } + + #[test] + fn mime_type_detects_jpg() { + assert_eq!(get_mime_type("photo.jpg"), "image/jpeg"); + } + + #[test] + fn mime_type_detects_jpeg() { + assert_eq!(get_mime_type("photo.jpeg"), "image/jpeg"); + } + + #[test] + fn mime_type_detects_gif() { + assert_eq!(get_mime_type("animation.gif"), "image/gif"); + } + + #[test] + fn mime_type_detects_webp() { + assert_eq!(get_mime_type("image.webp"), "image/webp"); + } + + #[test] + fn mime_type_detects_tiff() { + assert_eq!(get_mime_type("scan.tiff"), "image/tiff"); + } + + #[test] + fn mime_type_detects_tif() { + assert_eq!(get_mime_type("scan.tif"), "image/tiff"); + } + + #[test] + fn mime_type_detects_bmp() { + assert_eq!(get_mime_type("bitmap.bmp"), "image/bmp"); + } + + #[test] + fn mime_type_is_case_insensitive() { + assert_eq!(get_mime_type("DOCUMENT.PDF"), "application/pdf"); + assert_eq!(get_mime_type("IMAGE.PNG"), "image/png"); + } + + #[test] + fn mime_type_falls_back_for_unknown_extension() { + assert_eq!(get_mime_type("file.xyz123"), "application/octet-stream"); + } + + #[test] + fn convert_pdf_path_to_document_url() { + let content = b"%PDF-1.4 test content"; + let file = TempFile::with_suffix(".pdf", content); + + let result = + convert_file_document_to_url_document(Some(FileInput::Path(file.0.clone())), None) + .unwrap(); + + assert_eq!(result["type"], "document_url"); + let uri = data_uri_payload(&result, "document_url"); + assert!(uri.starts_with("data:application/pdf;base64,")); + assert_eq!(decode_base64(uri), content); + } + + #[test] + fn convert_image_path_to_image_url() { + let content = b"\x89PNG\r\n\x1a\n fake png content"; + let file = TempFile::with_suffix(".png", content); + + let result = + convert_file_document_to_url_document(Some(FileInput::Path(file.0.clone())), None) + .unwrap(); + + assert_eq!(result["type"], "image_url"); + let uri = data_uri_payload(&result, "image_url"); + assert!(uri.starts_with("data:image/png;base64,")); + assert_eq!(decode_base64(uri), content); + } + + #[test] + fn convert_rejects_bare_str_path() { + let error = convert_file_document_to_url_document( + Some(FileInput::Str("/etc/passwd".to_string())), + None, + ) + .unwrap_err(); + + assert!(invalid_request_message(error).contains("does not accept bare str values")); + } + + #[test] + fn convert_pathbuf_matches_path_behavior() { + let file = TempFile::with_suffix(".pdf", b"test pdf content"); + + let result = + convert_file_document_to_url_document(Some(FileInput::Path(file.0.clone())), None) + .unwrap(); + + assert_eq!(result["type"], "document_url"); + assert!( + data_uri_payload(&result, "document_url").starts_with("data:application/pdf;base64,") + ); + } + + #[test] + fn convert_raw_bytes_uses_fallback_mime() { + let content = b"raw bytes content"; + + let result = + convert_file_document_to_url_document(Some(FileInput::Bytes(content.to_vec())), None) + .unwrap(); + + assert_eq!(result["type"], "document_url"); + let uri = data_uri_payload(&result, "document_url"); + assert!(uri.starts_with("data:application/octet-stream;base64,")); + assert_eq!(decode_base64(uri), content); + } + + #[test] + fn convert_raw_bytes_with_explicit_mime_type() { + let result = convert_file_document_to_url_document( + Some(FileInput::Bytes(b"raw pdf content".to_vec())), + Some("application/pdf"), + ) + .unwrap(); + + assert_eq!(result["type"], "document_url"); + assert!( + data_uri_payload(&result, "document_url").starts_with("data:application/pdf;base64,") + ); + } + + #[test] + fn convert_raw_bytes_with_image_mime_type() { + let result = convert_file_document_to_url_document( + Some(FileInput::Bytes(b"raw image content".to_vec())), + Some("image/jpeg"), + ) + .unwrap(); + + assert_eq!(result["type"], "image_url"); + assert!(data_uri_payload(&result, "image_url").starts_with("data:image/jpeg;base64,")); + } + + #[test] + fn convert_reader_without_name() { + let content = b"file-like content"; + + let result = convert_file_document_to_url_document( + Some(FileInput::Reader { + name: None, + reader: Box::new(Cursor::new(content.to_vec())), + }), + None, + ) + .unwrap(); + + assert_eq!(result["type"], "document_url"); + let uri = data_uri_payload(&result, "document_url"); + assert!(uri.contains("base64,")); + assert_eq!(decode_base64(uri), content); + } + + #[test] + fn convert_reader_with_name_detects_mime() { + let result = convert_file_document_to_url_document( + Some(FileInput::Reader { + name: Some("test_image.png".to_string()), + reader: Box::new(Cursor::new(b"file-like png content".to_vec())), + }), + None, + ) + .unwrap(); + + assert_eq!(result["type"], "image_url"); + assert!(data_uri_payload(&result, "image_url").starts_with("data:image/png;base64,")); + } + + #[test] + fn convert_errors_for_missing_file_field() { + let error = convert_file_document_to_url_document(None, None).unwrap_err(); + + assert!(invalid_request_message(error).contains("must include a 'file' field")); + } + + #[test] + fn convert_errors_for_nonexistent_path() { + let error = convert_file_document_to_url_document( + Some(FileInput::Path(PathBuf::from( + "/nonexistent/path/to/file.pdf", + ))), + None, + ) + .unwrap_err(); + + assert!(invalid_request_message(error).contains("File not found")); + } + + #[test] + fn convert_errors_for_empty_file() { + let file = TempFile::with_suffix(".pdf", b""); + + let error = + convert_file_document_to_url_document(Some(FileInput::Path(file.0.clone())), None) + .unwrap_err(); + + assert!(invalid_request_message(error).contains("File is empty")); + } + + #[test] + fn convert_errors_for_unsupported_type() { + let error = convert_file_document_to_url_document( + Some(FileInput::Unsupported("int".to_string())), + None, + ) + .unwrap_err(); + + assert!(invalid_request_message(error).contains("Unsupported file input type")); + } + + #[test] + fn convert_errors_for_invalid_mime_type() { + let error = convert_file_document_to_url_document( + Some(FileInput::Bytes(b"some content".to_vec())), + Some("text/html; charset=utf-8\nX-Injected: true"), + ) + .unwrap_err(); + + assert!(invalid_request_message(error).contains("Invalid MIME type")); + } + + #[test] + fn convert_explicit_mime_overrides_path_detection() { + let file = TempFile::with_suffix(".pdf", b"some content"); + + let result = convert_file_document_to_url_document( + Some(FileInput::Path(file.0.clone())), + Some("image/png"), + ) + .unwrap(); + + assert_eq!(result["type"], "image_url"); + assert!(data_uri_payload(&result, "image_url").starts_with("data:image/png;base64,")); + } + + #[test] + fn upload_builds_document_url_for_pdf() { + let content = b"%PDF-1.4 test content"; + + let result = build_document_from_upload( + content.to_vec(), + Some("document.pdf"), + Some("application/pdf"), + ) + .unwrap(); + + assert_eq!(result["type"], "document_url"); + let uri = data_uri_payload(&result, "document_url"); + assert!(uri.starts_with("data:application/pdf;base64,")); + assert_eq!(decode_base64(uri), content); + } + + #[test] + fn upload_builds_image_url_for_png() { + let result = build_document_from_upload( + b"\x89PNG fake png".to_vec(), + Some("screenshot.png"), + Some("image/png"), + ) + .unwrap(); + + assert_eq!(result["type"], "image_url"); + assert!(data_uri_payload(&result, "image_url").starts_with("data:image/png;base64,")); + } + + #[test] + fn upload_builds_image_url_for_jpeg() { + let result = build_document_from_upload( + b"\xff\xd8\xff fake jpeg".to_vec(), + Some("photo.jpg"), + Some("image/jpeg"), + ) + .unwrap(); + + assert_eq!(result["type"], "image_url"); + assert!(data_uri_payload(&result, "image_url").starts_with("data:image/jpeg;base64,")); + } + + #[test] + fn upload_detects_mime_from_filename_when_content_type_is_octet_stream() { + let result = build_document_from_upload( + b"pdf content".to_vec(), + Some("report.pdf"), + Some("application/octet-stream"), + ) + .unwrap(); + + assert_eq!(result["type"], "document_url"); + assert!( + data_uri_payload(&result, "document_url").starts_with("data:application/pdf;base64,") + ); + } + + #[test] + fn upload_detects_mime_from_filename_when_content_type_is_none() { + let result = + build_document_from_upload(b"png content".to_vec(), Some("image.png"), None).unwrap(); + + assert_eq!(result["type"], "image_url"); + assert!(data_uri_payload(&result, "image_url").starts_with("data:image/png;base64,")); + } + + #[test] + fn upload_falls_back_to_octet_stream_for_unknown() { + let result = build_document_from_upload(b"unknown content".to_vec(), None, None).unwrap(); + + assert_eq!(result["type"], "document_url"); + assert!(data_uri_payload(&result, "document_url").contains("application/octet-stream")); + } + + #[test] + fn upload_preserves_binary_content_through_base64() { + let content = b"Hello, World! \x00\x01\x02\xff"; + + let result = + build_document_from_upload(content.to_vec(), Some("test.pdf"), Some("application/pdf")) + .unwrap(); + + assert_eq!( + decode_base64(data_uri_payload(&result, "document_url")), + content + ); + } + + #[test] + fn upload_strips_mime_parameters_from_content_type() { + let result = build_document_from_upload( + b"%PDF-1.4 test".to_vec(), + Some("doc.pdf"), + Some("application/pdf; charset=utf-8"), + ) + .unwrap(); + + assert_eq!(result["type"], "document_url"); + assert!( + data_uri_payload(&result, "document_url").starts_with("data:application/pdf;base64,") + ); + } + + #[test] + fn upload_strips_multiple_mime_parameters() { + let result = build_document_from_upload( + b"image data".to_vec(), + Some("img.png"), + Some("image/png; charset=utf-8; boundary=something"), + ) + .unwrap(); + + assert_eq!(result["type"], "image_url"); + assert!(data_uri_payload(&result, "image_url").starts_with("data:image/png;base64,")); + } +} diff --git a/litellm-rust/crates/ai-gateway/src/ocr/mod.rs b/litellm-rust/crates/ai-gateway/src/ocr/mod.rs index d9230af1c59..0819fc3f7d6 100644 --- a/litellm-rust/crates/ai-gateway/src/ocr/mod.rs +++ b/litellm-rust/crates/ai-gateway/src/ocr/mod.rs @@ -3,11 +3,17 @@ use litellm_core::call_lifecycle::CallLifecycle; use serde_json::Value; mod common_utils; +mod file_input; mod handler; mod hooks; mod prepare; +mod request_body; mod types; +pub use file_input::{ + FileInput, build_document_from_upload, convert_file_document_to_url_document, get_mime_type, +}; +pub use request_body::{parse_ocr_json_body, parse_ocr_multipart_form}; pub use types::OcrRequest; use handler::execute_ocr_provider_call; diff --git a/litellm-rust/crates/ai-gateway/src/ocr/request_body.rs b/litellm-rust/crates/ai-gateway/src/ocr/request_body.rs new file mode 100644 index 00000000000..6c8d8243705 --- /dev/null +++ b/litellm-rust/crates/ai-gateway/src/ocr/request_body.rs @@ -0,0 +1,133 @@ +use litellm_core::error::Error; +use serde_json::{Map, Value}; + +use super::file_input::build_document_from_upload; + +pub fn parse_ocr_json_body(body: &[u8]) -> Result, Error> { + let data: Value = serde_json::from_slice(body).map_err(|err| { + Error::InvalidRequest(format!( + "Invalid JSON in request body: {err}. Ensure the request body is valid JSON with \ + Content-Type: application/json, or use multipart/form-data for file uploads." + )) + })?; + let Value::Object(data) = data else { + return Err(Error::InvalidRequest( + "OCR request body must be a JSON object".to_string(), + )); + }; + let document_type = data + .get("document") + .and_then(Value::as_object) + .and_then(|document| document.get("type")) + .and_then(Value::as_str); + if document_type == Some("file") { + return Err(Error::InvalidRequest( + "document type 'file' is not supported through the JSON API. To upload a local file, \ + use multipart/form-data with a 'file' field. For JSON requests, use 'document_url' \ + or 'image_url' document types." + .to_string(), + )); + } + Ok(data) +} + +pub fn parse_ocr_multipart_form( + file_content: Vec, + filename: Option<&str>, + content_type: Option<&str>, + fields: &[(String, String)], +) -> Result, Error> { + if file_content.is_empty() { + return Err(Error::InvalidRequest("Uploaded file is empty".to_string())); + } + let document = build_document_from_upload(file_content, filename, content_type)?; + let mut data = Map::new(); + data.insert("document".to_string(), document); + for (name, value) in fields { + if name == "file" || name == "document" { + continue; + } + let parsed = + serde_json::from_str::(value).unwrap_or_else(|_| Value::String(value.clone())); + data.insert(name.clone(), parsed); + } + Ok(data) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn invalid_request_message(error: Error) -> String { + match error { + Error::InvalidRequest(message) => message, + other => panic!("expected InvalidRequest, got {other:?}"), + } + } + + #[test] + fn json_body_rejects_file_type_document() { + let body = br#"{"model": "mistral/mistral-ocr-latest", "document": {"type": "file", "file": "/etc/passwd"}}"#; + + let error = parse_ocr_json_body(body).unwrap_err(); + + assert!(invalid_request_message(error).contains("not supported through the JSON API")); + } + + #[test] + fn json_body_accepts_document_url_type() { + let body = br#"{"model": "mistral/mistral-ocr-latest", "document": {"type": "document_url", "document_url": "https://example.com/doc.pdf"}}"#; + + let data = parse_ocr_json_body(body).unwrap(); + + assert_eq!(data["document"]["type"], "document_url"); + assert_eq!(data["model"], "mistral/mistral-ocr-latest"); + } + + #[test] + fn json_body_rejects_invalid_json() { + let error = parse_ocr_json_body(b"not valid json{{{").unwrap_err(); + + assert!(invalid_request_message(error).contains("Invalid JSON in request body")); + } + + #[test] + fn multipart_ignores_document_form_field_injection() { + let fields = vec![ + ( + "model".to_string(), + "mistral/mistral-ocr-latest".to_string(), + ), + ( + "document".to_string(), + r#"{"type": "file", "file": "/etc/passwd"}"#.to_string(), + ), + ("pages".to_string(), "[0,1,2]".to_string()), + ]; + + let data = parse_ocr_multipart_form( + b"%PDF-1.4 legit content".to_vec(), + Some("legit.pdf"), + Some("application/pdf"), + &fields, + ) + .unwrap(); + + assert_eq!(data["document"]["type"], "document_url"); + assert!( + data["document"]["document_url"] + .as_str() + .unwrap() + .starts_with("data:application/pdf;base64,") + ); + assert_eq!(data["model"], "mistral/mistral-ocr-latest"); + assert_eq!(data["pages"], serde_json::json!([0, 1, 2])); + } + + #[test] + fn multipart_rejects_empty_upload() { + let error = parse_ocr_multipart_form(Vec::new(), Some("empty.pdf"), None, &[]).unwrap_err(); + + assert!(invalid_request_message(error).contains("Uploaded file is empty")); + } +} diff --git a/tests/rust-python-harness/strategies/unit_tests/ledgers/ocr/ocr_test_ledger.json b/tests/rust-python-harness/strategies/unit_tests/ledgers/ocr/ocr_test_ledger.json index e617ddf8f94..1b690e84f3d 100644 --- a/tests/rust-python-harness/strategies/unit_tests/ledgers/ocr/ocr_test_ledger.json +++ b/tests/rust-python-harness/strategies/unit_tests/ledgers/ocr/ocr_test_ledger.json @@ -17,7 +17,9 @@ "litellm-rust/crates/core/src/providers/azure_ai/ocr/transformation.rs", "litellm-rust/crates/core/src/providers/vertex_ai/ocr/transformation.rs", "litellm-rust/crates/core/src/providers/mistral/ocr/transformation.rs", - "litellm-rust/crates/ai-gateway/src/integrations/custom_logger/mod.rs" + "litellm-rust/crates/ai-gateway/src/integrations/custom_logger/mod.rs", + "litellm-rust/crates/ai-gateway/src/ocr/file_input.rs", + "litellm-rust/crates/ai-gateway/src/ocr/request_body.rs" ], "entries": [ { @@ -389,236 +391,314 @@ { "python_file": "tests/test_litellm/ocr/test_ocr_file_input.py", "python_test": "TestGetMimeType::test_should_detect_pdf_mime_type", - "status": "unmapped", - "reason": "file-normalization: MIME detection is Python-only preprocessing before the bridge call" + "status": "mapped", + "rust_file": "litellm-rust/crates/ai-gateway/src/ocr/file_input.rs", + "rust_test": "mime_type_detects_pdf", + "justification": "both assert .pdf resolves to application/pdf" }, { "python_file": "tests/test_litellm/ocr/test_ocr_file_input.py", "python_test": "TestGetMimeType::test_should_detect_png_mime_type", - "status": "unmapped", - "reason": "file-normalization: MIME detection" + "status": "mapped", + "rust_file": "litellm-rust/crates/ai-gateway/src/ocr/file_input.rs", + "rust_test": "mime_type_detects_png", + "justification": "both assert .png resolves to image/png" }, { "python_file": "tests/test_litellm/ocr/test_ocr_file_input.py", "python_test": "TestGetMimeType::test_should_detect_jpg_mime_type", - "status": "unmapped", - "reason": "file-normalization: MIME detection" + "status": "mapped", + "rust_file": "litellm-rust/crates/ai-gateway/src/ocr/file_input.rs", + "rust_test": "mime_type_detects_jpg", + "justification": "both assert .jpg resolves to image/jpeg" }, { "python_file": "tests/test_litellm/ocr/test_ocr_file_input.py", "python_test": "TestGetMimeType::test_should_detect_jpeg_mime_type", - "status": "unmapped", - "reason": "file-normalization: MIME detection" + "status": "mapped", + "rust_file": "litellm-rust/crates/ai-gateway/src/ocr/file_input.rs", + "rust_test": "mime_type_detects_jpeg", + "justification": "both assert .jpeg resolves to image/jpeg" }, { "python_file": "tests/test_litellm/ocr/test_ocr_file_input.py", "python_test": "TestGetMimeType::test_should_detect_gif_mime_type", - "status": "unmapped", - "reason": "file-normalization: MIME detection" + "status": "mapped", + "rust_file": "litellm-rust/crates/ai-gateway/src/ocr/file_input.rs", + "rust_test": "mime_type_detects_gif", + "justification": "both assert .gif resolves to image/gif" }, { "python_file": "tests/test_litellm/ocr/test_ocr_file_input.py", "python_test": "TestGetMimeType::test_should_detect_webp_mime_type", - "status": "unmapped", - "reason": "file-normalization: MIME detection" + "status": "mapped", + "rust_file": "litellm-rust/crates/ai-gateway/src/ocr/file_input.rs", + "rust_test": "mime_type_detects_webp", + "justification": "both assert .webp resolves to image/webp" }, { "python_file": "tests/test_litellm/ocr/test_ocr_file_input.py", "python_test": "TestGetMimeType::test_should_detect_tiff_mime_type", - "status": "unmapped", - "reason": "file-normalization: MIME detection" + "status": "mapped", + "rust_file": "litellm-rust/crates/ai-gateway/src/ocr/file_input.rs", + "rust_test": "mime_type_detects_tiff", + "justification": "both assert .tiff resolves to image/tiff" }, { "python_file": "tests/test_litellm/ocr/test_ocr_file_input.py", "python_test": "TestGetMimeType::test_should_detect_tif_mime_type", - "status": "unmapped", - "reason": "file-normalization: MIME detection" + "status": "mapped", + "rust_file": "litellm-rust/crates/ai-gateway/src/ocr/file_input.rs", + "rust_test": "mime_type_detects_tif", + "justification": "both assert .tif resolves to image/tiff" }, { "python_file": "tests/test_litellm/ocr/test_ocr_file_input.py", "python_test": "TestGetMimeType::test_should_detect_bmp_mime_type", - "status": "unmapped", - "reason": "file-normalization: MIME detection" + "status": "mapped", + "rust_file": "litellm-rust/crates/ai-gateway/src/ocr/file_input.rs", + "rust_test": "mime_type_detects_bmp", + "justification": "both assert .bmp resolves to image/bmp" }, { "python_file": "tests/test_litellm/ocr/test_ocr_file_input.py", "python_test": "TestGetMimeType::test_should_be_case_insensitive", - "status": "unmapped", - "reason": "file-normalization: MIME detection case handling" + "status": "mapped", + "rust_file": "litellm-rust/crates/ai-gateway/src/ocr/file_input.rs", + "rust_test": "mime_type_is_case_insensitive", + "justification": "both assert .PDF/.PNG resolve to the same MIME as lowercase" }, { "python_file": "tests/test_litellm/ocr/test_ocr_file_input.py", "python_test": "TestGetMimeType::test_should_fallback_for_unknown_extension", - "status": "unmapped", - "reason": "file-normalization: MIME detection fallback" + "status": "mapped", + "rust_file": "litellm-rust/crates/ai-gateway/src/ocr/file_input.rs", + "rust_test": "mime_type_falls_back_for_unknown_extension", + "justification": "both assert an unknown extension resolves to a non-empty fallback (Rust always application/octet-stream, Python may consult mimetypes first)" }, { "python_file": "tests/test_litellm/ocr/test_ocr_file_input.py", "python_test": "TestConvertFileDocumentToUrlDocument::test_should_convert_pdf_pathlib_path_to_document_url", - "status": "unmapped", - "reason": "file-normalization: local-path-to-data-URI conversion happens in Python" + "status": "mapped", + "rust_file": "litellm-rust/crates/ai-gateway/src/ocr/file_input.rs", + "rust_test": "convert_pdf_path_to_document_url", + "justification": "both write a .pdf temp file, assert type document_url, data:application/pdf;base64 prefix and round-tripped bytes" }, { "python_file": "tests/test_litellm/ocr/test_ocr_file_input.py", "python_test": "TestConvertFileDocumentToUrlDocument::test_should_convert_image_pathlib_path_to_image_url", - "status": "unmapped", - "reason": "file-normalization: local-path-to-data-URI conversion" + "status": "mapped", + "rust_file": "litellm-rust/crates/ai-gateway/src/ocr/file_input.rs", + "rust_test": "convert_image_path_to_image_url", + "justification": "both write a .png temp file, assert type image_url, data:image/png;base64 prefix and round-tripped bytes" }, { "python_file": "tests/test_litellm/ocr/test_ocr_file_input.py", "python_test": "TestConvertFileDocumentToUrlDocument::test_should_reject_bare_str_path", - "status": "unmapped", - "reason": "file-normalization: arbitrary-file-read guard on bare str paths" + "status": "mapped", + "rust_file": "litellm-rust/crates/ai-gateway/src/ocr/file_input.rs", + "rust_test": "convert_rejects_bare_str_path", + "justification": "both assert a bare string path is rejected with the \"does not accept bare str values\" error" }, { "python_file": "tests/test_litellm/ocr/test_ocr_file_input.py", "python_test": "TestConvertFileDocumentToUrlDocument::test_should_convert_pathlib_path", - "status": "unmapped", - "reason": "file-normalization: local-path-to-data-URI conversion" + "status": "mapped", + "rust_file": "litellm-rust/crates/ai-gateway/src/ocr/file_input.rs", + "rust_test": "convert_pathbuf_matches_path_behavior", + "justification": "both assert a filesystem path value produces document_url with application/pdf data URI" }, { "python_file": "tests/test_litellm/ocr/test_ocr_file_input.py", "python_test": "TestConvertFileDocumentToUrlDocument::test_should_convert_raw_bytes", - "status": "unmapped", - "reason": "file-normalization: raw-bytes-to-data-URI conversion" + "status": "mapped", + "rust_file": "litellm-rust/crates/ai-gateway/src/ocr/file_input.rs", + "rust_test": "convert_raw_bytes_uses_fallback_mime", + "justification": "both assert raw bytes fall back to application/octet-stream document_url and round-trip" }, { "python_file": "tests/test_litellm/ocr/test_ocr_file_input.py", "python_test": "TestConvertFileDocumentToUrlDocument::test_should_convert_raw_bytes_with_explicit_mime_type", - "status": "unmapped", - "reason": "file-normalization: explicit MIME override on raw bytes" + "status": "mapped", + "rust_file": "litellm-rust/crates/ai-gateway/src/ocr/file_input.rs", + "rust_test": "convert_raw_bytes_with_explicit_mime_type", + "justification": "both assert an explicit application/pdf mime_type on raw bytes yields document_url" }, { "python_file": "tests/test_litellm/ocr/test_ocr_file_input.py", "python_test": "TestConvertFileDocumentToUrlDocument::test_should_convert_raw_bytes_with_image_mime_type", - "status": "unmapped", - "reason": "file-normalization: explicit MIME override on raw bytes" + "status": "mapped", + "rust_file": "litellm-rust/crates/ai-gateway/src/ocr/file_input.rs", + "rust_test": "convert_raw_bytes_with_image_mime_type", + "justification": "both assert an explicit image/jpeg mime_type on raw bytes yields image_url" }, { "python_file": "tests/test_litellm/ocr/test_ocr_file_input.py", "python_test": "TestConvertFileDocumentToUrlDocument::test_should_convert_file_like_object", - "status": "unmapped", - "reason": "file-normalization: file-like-object conversion" + "status": "mapped", + "rust_file": "litellm-rust/crates/ai-gateway/src/ocr/file_input.rs", + "rust_test": "convert_reader_without_name", + "justification": "both assert a nameless file-like reader is base64 encoded into document_url with the original bytes" }, { "python_file": "tests/test_litellm/ocr/test_ocr_file_input.py", "python_test": "TestConvertFileDocumentToUrlDocument::test_should_convert_file_like_object_with_name", - "status": "unmapped", - "reason": "file-normalization: file-like-object name-based MIME detection" + "status": "mapped", + "rust_file": "litellm-rust/crates/ai-gateway/src/ocr/file_input.rs", + "rust_test": "convert_reader_with_name_detects_mime", + "justification": "both assert a file-like reader named *.png yields image_url with image/png" }, { "python_file": "tests/test_litellm/ocr/test_ocr_file_input.py", "python_test": "TestConvertFileDocumentToUrlDocument::test_should_raise_error_for_missing_file_field", - "status": "unmapped", - "reason": "file-normalization: missing-field validation" + "status": "mapped", + "rust_file": "litellm-rust/crates/ai-gateway/src/ocr/file_input.rs", + "rust_test": "convert_errors_for_missing_file_field", + "justification": "both assert an absent file value fails with \"must include a 'file' field\"" }, { "python_file": "tests/test_litellm/ocr/test_ocr_file_input.py", "python_test": "TestConvertFileDocumentToUrlDocument::test_should_raise_error_for_nonexistent_pathlib_path", - "status": "unmapped", - "reason": "file-normalization: missing-file validation" + "status": "mapped", + "rust_file": "litellm-rust/crates/ai-gateway/src/ocr/file_input.rs", + "rust_test": "convert_errors_for_nonexistent_path", + "justification": "both assert a nonexistent path fails with \"File not found\"" }, { "python_file": "tests/test_litellm/ocr/test_ocr_file_input.py", "python_test": "TestConvertFileDocumentToUrlDocument::test_should_raise_error_for_empty_file", - "status": "unmapped", - "reason": "file-normalization: empty-file validation" + "status": "mapped", + "rust_file": "litellm-rust/crates/ai-gateway/src/ocr/file_input.rs", + "rust_test": "convert_errors_for_empty_file", + "justification": "both assert an empty file fails with \"File is empty\"" }, { "python_file": "tests/test_litellm/ocr/test_ocr_file_input.py", "python_test": "TestConvertFileDocumentToUrlDocument::test_should_raise_error_for_unsupported_type", - "status": "unmapped", - "reason": "file-normalization: unsupported input type validation" + "status": "mapped", + "rust_file": "litellm-rust/crates/ai-gateway/src/ocr/file_input.rs", + "rust_test": "convert_errors_for_unsupported_type", + "justification": "both assert an unsupported input type fails with \"Unsupported file input type\"" }, { "python_file": "tests/test_litellm/ocr/test_ocr_file_input.py", "python_test": "TestConvertFileDocumentToUrlDocument::test_should_raise_error_for_invalid_mime_type", - "status": "unmapped", - "reason": "file-normalization: MIME-type injection validation" + "status": "mapped", + "rust_file": "litellm-rust/crates/ai-gateway/src/ocr/file_input.rs", + "rust_test": "convert_errors_for_invalid_mime_type", + "justification": "both assert a mime_type containing header-injection characters fails with \"Invalid MIME type\"" }, { "python_file": "tests/test_litellm/ocr/test_ocr_file_input.py", "python_test": "TestConvertFileDocumentToUrlDocument::test_should_override_mime_type_for_pathlib_path", - "status": "unmapped", - "reason": "file-normalization: explicit MIME override precedence" + "status": "mapped", + "rust_file": "litellm-rust/crates/ai-gateway/src/ocr/file_input.rs", + "rust_test": "convert_explicit_mime_overrides_path_detection", + "justification": "both assert explicit image/png overrides the .pdf extension and yields image_url" }, { "python_file": "tests/test_litellm/ocr/test_ocr_file_input.py", "python_test": "TestBuildDocumentFromUpload::test_should_build_document_url_for_pdf", - "status": "unmapped", - "reason": "file-normalization: multipart upload conversion" + "status": "mapped", + "rust_file": "litellm-rust/crates/ai-gateway/src/ocr/file_input.rs", + "rust_test": "upload_builds_document_url_for_pdf", + "justification": "both assert a PDF upload yields document_url with application/pdf data URI and round-tripped bytes" }, { "python_file": "tests/test_litellm/ocr/test_ocr_file_input.py", "python_test": "TestBuildDocumentFromUpload::test_should_build_image_url_for_png", - "status": "unmapped", - "reason": "file-normalization: multipart upload conversion" + "status": "mapped", + "rust_file": "litellm-rust/crates/ai-gateway/src/ocr/file_input.rs", + "rust_test": "upload_builds_image_url_for_png", + "justification": "both assert a PNG upload yields image_url with image/png data URI" }, { "python_file": "tests/test_litellm/ocr/test_ocr_file_input.py", "python_test": "TestBuildDocumentFromUpload::test_should_build_image_url_for_jpeg", - "status": "unmapped", - "reason": "file-normalization: multipart upload conversion" + "status": "mapped", + "rust_file": "litellm-rust/crates/ai-gateway/src/ocr/file_input.rs", + "rust_test": "upload_builds_image_url_for_jpeg", + "justification": "both assert a JPEG upload yields image_url with image/jpeg data URI" }, { "python_file": "tests/test_litellm/ocr/test_ocr_file_input.py", "python_test": "TestBuildDocumentFromUpload::test_should_detect_mime_from_filename_when_content_type_is_octet_stream", - "status": "unmapped", - "reason": "file-normalization: filename-based MIME fallback" + "status": "mapped", + "rust_file": "litellm-rust/crates/ai-gateway/src/ocr/file_input.rs", + "rust_test": "upload_detects_mime_from_filename_when_content_type_is_octet_stream", + "justification": "both assert application/octet-stream content type defers to the .pdf filename" }, { "python_file": "tests/test_litellm/ocr/test_ocr_file_input.py", "python_test": "TestBuildDocumentFromUpload::test_should_detect_mime_from_filename_when_content_type_is_none", - "status": "unmapped", - "reason": "file-normalization: filename-based MIME fallback" + "status": "mapped", + "rust_file": "litellm-rust/crates/ai-gateway/src/ocr/file_input.rs", + "rust_test": "upload_detects_mime_from_filename_when_content_type_is_none", + "justification": "both assert a missing content type defers to the .png filename" }, { "python_file": "tests/test_litellm/ocr/test_ocr_file_input.py", "python_test": "TestBuildDocumentFromUpload::test_should_fallback_to_octet_stream_for_unknown", - "status": "unmapped", - "reason": "file-normalization: default MIME fallback" + "status": "mapped", + "rust_file": "litellm-rust/crates/ai-gateway/src/ocr/file_input.rs", + "rust_test": "upload_falls_back_to_octet_stream_for_unknown", + "justification": "both assert no filename and no content type yields application/octet-stream document_url" }, { "python_file": "tests/test_litellm/ocr/test_ocr_file_input.py", "python_test": "TestBuildDocumentFromUpload::test_should_preserve_base64_content_correctly", - "status": "unmapped", - "reason": "file-normalization: binary round-trip through base64" + "status": "mapped", + "rust_file": "litellm-rust/crates/ai-gateway/src/ocr/file_input.rs", + "rust_test": "upload_preserves_binary_content_through_base64", + "justification": "both decode the data URI and assert the arbitrary binary bytes round-trip" }, { "python_file": "tests/test_litellm/ocr/test_ocr_file_input.py", "python_test": "TestBuildDocumentFromUpload::test_should_strip_mime_parameters_from_content_type", - "status": "unmapped", - "reason": "file-normalization: content-type parameter stripping" + "status": "mapped", + "rust_file": "litellm-rust/crates/ai-gateway/src/ocr/file_input.rs", + "rust_test": "upload_strips_mime_parameters_from_content_type", + "justification": "both assert \"application/pdf; charset=utf-8\" yields a data:application/pdf;base64 URI" }, { "python_file": "tests/test_litellm/ocr/test_ocr_file_input.py", "python_test": "TestBuildDocumentFromUpload::test_should_strip_mime_parameters_with_multiple_params", - "status": "unmapped", - "reason": "file-normalization: content-type parameter stripping" + "status": "mapped", + "rust_file": "litellm-rust/crates/ai-gateway/src/ocr/file_input.rs", + "rust_test": "upload_strips_multiple_mime_parameters", + "justification": "both assert multiple content-type parameters are stripped to image/png" }, { "python_file": "tests/test_litellm/ocr/test_ocr_file_input.py", "python_test": "TestProxySecurityGuard::test_should_reject_file_type_document_in_json_body", - "status": "unmapped", - "reason": "proxy-layer JSON-body file-type guard, a different mechanism than Rust's URL-fetch SSRF guard" + "status": "mapped", + "rust_file": "litellm-rust/crates/ai-gateway/src/ocr/request_body.rs", + "rust_test": "json_body_rejects_file_type_document", + "justification": "both assert a JSON body with document.type=file fails with \"not supported through the JSON API\"" }, { "python_file": "tests/test_litellm/ocr/test_ocr_file_input.py", "python_test": "TestProxySecurityGuard::test_should_accept_document_url_type_in_json_body", - "status": "unmapped", - "reason": "proxy-layer JSON-body parsing" + "status": "mapped", + "rust_file": "litellm-rust/crates/ai-gateway/src/ocr/request_body.rs", + "rust_test": "json_body_accepts_document_url_type", + "justification": "both assert a document_url JSON body parses and keeps document.type" }, { "python_file": "tests/test_litellm/ocr/test_ocr_file_input.py", "python_test": "TestProxySecurityGuard::test_should_raise_on_invalid_json_body", - "status": "unmapped", - "reason": "proxy-layer JSON-body parsing error path" + "status": "mapped", + "rust_file": "litellm-rust/crates/ai-gateway/src/ocr/request_body.rs", + "rust_test": "json_body_rejects_invalid_json", + "justification": "both assert malformed JSON fails with \"Invalid JSON in request body\"" }, { "python_file": "tests/test_litellm/ocr/test_ocr_file_input.py", "python_test": "TestProxySecurityGuard::test_should_ignore_document_form_field_injection", - "status": "unmapped", - "reason": "proxy-layer multipart form-field injection guard, a different mechanism than Rust's URL-fetch SSRF guard" + "status": "mapped", + "rust_file": "litellm-rust/crates/ai-gateway/src/ocr/request_body.rs", + "rust_test": "multipart_ignores_document_form_field_injection", + "justification": "both assert an injected document form field is ignored and the uploaded PDF becomes the document_url data URI" }, { "python_file": "tests/test_litellm/llms/mistral/ocr/test_mistral_ocr_transformation.py", @@ -1079,6 +1159,11 @@ "rust_file": "litellm-rust/crates/ai-gateway/src/integrations/custom_logger/mod.rs", "rust_test": "rust_custom_logger_reads_success_payload_for_ocr", "reason": "Rust-internal custom-logger dispatch for OCR payloads has no Python unit test at this layer" + }, + { + "rust_file": "litellm-rust/crates/ai-gateway/src/ocr/request_body.rs", + "rust_test": "multipart_rejects_empty_upload", + "reason": "empty-upload rejection is asserted inline in the Python multipart parser but has no dedicated Python test" } ] }