mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-07 08:26:10 +00:00
feat(rust-ocr): port file-input document normalization and proxy body guards with 1:1 parity tests
Co-Authored-By: Ishaan Jaffer <155045088+ishaan-berri@users.noreply.github.com>
This commit is contained in:
parent
11a02b9581
commit
77dad9695f
5 changed files with 869 additions and 79 deletions
|
|
@ -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"),
|
||||
];
|
||||
|
|
|
|||
550
litellm-rust/crates/ai-gateway/src/ocr/file_input.rs
Normal file
550
litellm-rust/crates/ai-gateway/src/ocr/file_input.rs
Normal file
|
|
@ -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<u8>),
|
||||
Reader {
|
||||
name: Option<String>,
|
||||
reader: Box<dyn Read>,
|
||||
},
|
||||
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<u8>, 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<FileInput>,
|
||||
mime_type_override: Option<&str>,
|
||||
) -> Result<Value, Error> {
|
||||
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<u8>,
|
||||
filename: Option<&str>,
|
||||
content_type: Option<&str>,
|
||||
) -> Result<Value, Error> {
|
||||
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<u8> {
|
||||
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,"));
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
133
litellm-rust/crates/ai-gateway/src/ocr/request_body.rs
Normal file
133
litellm-rust/crates/ai-gateway/src/ocr/request_body.rs
Normal file
|
|
@ -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<Map<String, Value>, 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<u8>,
|
||||
filename: Option<&str>,
|
||||
content_type: Option<&str>,
|
||||
fields: &[(String, String)],
|
||||
) -> Result<Map<String, Value>, 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>(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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue