diff --git a/litellm-rust/crates/core/src/ocr/provider_config.rs b/litellm-rust/crates/core/src/ocr/provider_config.rs index dcce6258a12..b798fd95841 100644 --- a/litellm-rust/crates/core/src/ocr/provider_config.rs +++ b/litellm-rust/crates/core/src/ocr/provider_config.rs @@ -419,4 +419,18 @@ mod tests { model.split_once('/').unwrap().1 ); } + + #[rstest] + #[case::prefix("not_a_provider/model", None)] + #[case::explicit("model", Some("not_a_provider"))] + fn ocr_contract_unknown_provider_is_bad_request( + #[case] model: &str, + #[case] provider: Option<&str>, + ) { + let error = resolve_provider_config(model, provider).unwrap_err(); + assert!( + matches!(&error, crate::ocr::Error::InvalidProvider(provider) if provider == "not_a_provider") + ); + assert_eq!(error.http_status_code(), Some(400)); + } } diff --git a/litellm-rust/crates/core/src/ocr/wire.rs b/litellm-rust/crates/core/src/ocr/wire.rs index b05f388a277..b2f07caa754 100644 --- a/litellm-rust/crates/core/src/ocr/wire.rs +++ b/litellm-rust/crates/core/src/ocr/wire.rs @@ -106,6 +106,35 @@ pub fn decode_document(value: Value) -> Result { #[cfg(test)] mod tests { use super::*; + use rstest::rstest; + use serde_json::json; + + #[rstest] + #[case::omitted(json!({"type":"document_url", "document_url":"https://example.com/a.pdf"}))] + #[case::null(json!({"type":"document_url", "document_url":"https://example.com/a.pdf", "document_name":null}))] + fn ocr_contract_optional_document_name(#[case] document: Value) { + let decoded = decode_document(document).unwrap(); + assert_eq!(decoded.source(), "https://example.com/a.pdf"); + } + + #[rstest] + #[case::non_object(json!([]), "document")] + #[case::missing_type(json!({"document_url":"https://example.com/a.pdf"}), "document")] + #[case::unsupported_type(json!({"type":"text"}), "document")] + #[case::missing_document_url(json!({"type":"document_url"}), "Document URL")] + #[case::missing_image_url(json!({"type":"image_url"}), "Document URL")] + fn ocr_contract_malformed_document_is_bad_request( + #[case] document: Value, + #[case] field: &str, + ) { + let error = decode_document(document).unwrap_err(); + assert!(matches!( + error, + Error::RequestField { .. } | Error::MissingDocumentUrl + )); + assert_eq!(error.http_status_code(), Some(400)); + assert!(error.to_string().contains(field)); + } #[test] fn option_projection_is_provider_specific_and_excludes_opaque_fields() { diff --git a/litellm-rust/crates/core/tests/ocr.rs b/litellm-rust/crates/core/tests/ocr.rs index 480774d1ad1..c094000ee06 100644 --- a/litellm-rust/crates/core/tests/ocr.rs +++ b/litellm-rust/crates/core/tests/ocr.rs @@ -1,5 +1,6 @@ use std::sync::{Arc, Mutex}; +use rstest::rstest; use serde_json::{Value, json}; use super::OcrClient; @@ -15,6 +16,59 @@ use super::{ }; use crate::call_lifecycle::{CallLifecycleContext, CallLifecycleTiming}; +#[rstest] +#[case::mistral("mistral/model", json!({}))] +#[case::vertex("vertex_ai/mistral-ocr-latest", json!({"vertex_project":"test-project", "vertex_location":"us-central1"}))] +#[tokio::test] +async fn ocr_contract_upstream_error_preserves_status_body_and_headers( + #[case] model: &str, + #[case] options: Value, +) { + let payload = json!({"message": format!("{} END-OF-PROVIDER-BODY", "x".repeat(4096))}); + let expected_body = serde_json::to_string(&payload).unwrap(); + let (base, seen, server) = mock_server(vec![MockResponse { + status: 422, + headers: vec![ + ("Retry-After", "17".into()), + ("X-Request-ID", "request-123".into()), + ("X-Future-Header", "retained".into()), + ], + body: payload, + }]) + .await; + let error = perform_ocr(wire_request(model, &base, options)) + .await + .unwrap_err(); + server.await.unwrap(); + assert_eq!(seen.lock().unwrap().len(), 1); + let super::Error::Provider { + status, + body, + headers, + } = error + else { + panic!("expected provider error, got {error:?}"); + }; + assert_eq!(status, 422); + for (name, value) in [ + ("retry-after", "17"), + ("x-request-id", "request-123"), + ("x-future-header", "retained"), + ] { + assert!( + headers + .iter() + .any(|(key, actual)| key.eq_ignore_ascii_case(name) && actual == value) + ); + } + assert_eq!( + body.len(), + expected_body.len(), + "provider error body was truncated" + ); + assert_eq!(body, expected_body); +} + #[test] fn request_boundary_selects_mistral_and_rejects_unknown_providers() { let request = OcrWireRequest { diff --git a/tests/test_litellm_rust/ocr/test_requests.py b/tests/test_litellm_rust/ocr/test_requests.py index 58bb6a77537..3e95258fb36 100644 --- a/tests/test_litellm_rust/ocr/test_requests.py +++ b/tests/test_litellm_rust/ocr/test_requests.py @@ -1,7 +1,10 @@ +import json from pathlib import Path from typing import Final +import httpx import pytest +from pydantic import JsonValue import litellm from litellm.llms.base_llm.ocr.transformation import OCRResponse @@ -17,6 +20,193 @@ from tests.test_litellm_rust.support.requests import ( pytestmark = pytest.mark.requires_rust_extension +@pytest.fixture(params=[False, True], ids=["python", "rust"]) +def ocr_backend(request: pytest.FixtureRequest, monkeypatch: pytest.MonkeyPatch) -> bool: + enabled: Final = bool(request.param) + monkeypatch.setenv("LITELLM_RUST", "1" if enabled else "0") + return enabled + + +@pytest.mark.asyncio +@pytest.mark.parametrize("asynchronous", [False, True], ids=["sync", "async"]) +async def test_ocr_contract_upstream_status( + ocr_server: RecordingServer, + ocr_backend: bool, + asynchronous: bool, +) -> None: + upstream: Final = ResponseSpec(body={"detail": "invalid provider option"}, status=422) + ocr_server.enqueue(upstream) + arguments: Final = { + "model": "vertex_ai/mistral-ocr-latest", + "vertex_project": "test-project", + "vertex_location": "us-central1", + "num_retries": 0, + } + with pytest.raises(litellm.BadRequestError) as caught: + if asynchronous: + await call_native_aocr(ocr_server, **arguments) + else: + call_native_ocr(ocr_server, **arguments) + assert caught.value.status_code == upstream.status + assert caught.value.response.status_code == upstream.status + + +@pytest.mark.asyncio +@pytest.mark.parametrize("asynchronous", [False, True], ids=["sync", "async"]) +@pytest.mark.parametrize("preserved", ["body", "headers"]) +async def test_ocr_contract_provider_error_details( + ocr_server: RecordingServer, + ocr_backend: bool, + asynchronous: bool, + preserved: str, +) -> None: + payload: Final = {"message": "rate limited"} + headers: Final = {"Retry-After": "17", "X-Request-ID": "ocr-request-123", "X-Future-Header": "retained"} + ocr_server.enqueue(ResponseSpec(body=payload, status=429, headers=headers)) + with pytest.raises(litellm.RateLimitError) as caught: + if asynchronous: + await call_native_aocr(ocr_server, num_retries=0) + else: + call_native_ocr(ocr_server, num_retries=0) + response: Final = caught.value.response + assert isinstance(response, httpx.Response) + if preserved == "body": + assert response.content == json.dumps(payload).encode() + else: + for name, value in headers.items(): + assert response.headers.get(name.lower()) == value + assert response.headers.get(name.upper()) == value + + +@pytest.mark.asyncio +@pytest.mark.parametrize("asynchronous", [False, True], ids=["sync", "async"]) +async def test_ocr_contract_invalid_response_format( + ocr_server: RecordingServer, + ocr_backend: bool, + asynchronous: bool, +) -> None: + ocr_server.expected_requests = 0 + with pytest.raises(litellm.UnsupportedParamsError) as caught: + if asynchronous: + await call_native_aocr(ocr_server, req_format="bogus", num_retries=0) + else: + call_native_ocr(ocr_server, req_format="bogus", num_retries=0) + assert caught.value.status_code == 400 + for value in ("req_format", "bogus", "native", "litellm"): + assert value in str(caught.value) + assert ocr_server.requests == [] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("asynchronous", [False, True], ids=["sync", "async"]) +@pytest.mark.parametrize( + "document,field", + [ + ([], "document"), + ({"document_url": "https://example.com/a.pdf"}, "type"), + ({"type": "text"}, "type"), + ], +) +async def test_ocr_contract_malformed_document_is_actionable( + ocr_server: RecordingServer, + ocr_backend: bool, + asynchronous: bool, + document: JsonValue, + field: str, +) -> None: + ocr_server.expected_requests = None + with pytest.raises(litellm.BadRequestError) as caught: + if asynchronous: + await call_native_aocr(ocr_server, document=document, num_retries=0) + else: + call_native_ocr(ocr_server, document=document, num_retries=0) + assert caught.value.status_code == 400 + assert field.lower() in str(caught.value).lower() + assert "NoneType: None" not in str(caught.value) + assert "indices must be" not in str(caught.value) + assert ocr_server.requests == [] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("asynchronous", [False, True], ids=["sync", "async"]) +@pytest.mark.parametrize("option,value,field", [("pages", [-1], "pages"), ("features", [1], "features")]) +async def test_ocr_contract_azure_invalid_options_are_bad_requests( + ocr_server: RecordingServer, + ocr_backend: bool, + asynchronous: bool, + option: str, + value: JsonValue, + field: str, +) -> None: + ocr_server.expected_requests = 0 + arguments: Final = {"model": "azure_ai/doc-intelligence/prebuilt-read", option: value, "num_retries": 0} + with pytest.raises(litellm.BadRequestError) as caught: + if asynchronous: + await call_native_aocr(ocr_server, **arguments) + else: + call_native_ocr(ocr_server, **arguments) + assert caught.value.status_code == 400 + assert field in str(caught.value) + assert ocr_server.requests == [] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("asynchronous", [False, True], ids=["sync", "async"]) +@pytest.mark.parametrize("model", ["mistral/mistral-ocr-latest", "azure_ai/mistral-ocr-latest", "reducto/parse-v3"]) +async def test_ocr_contract_native_format_supported( + ocr_server: RecordingServer, + ocr_backend: bool, + asynchronous: bool, + model: str, +) -> None: + ocr_server.expected_requests = None + payload: Final = ( + {"result": {"chunks": [{"content": "native OCR response"}]}, "usage": {"num_pages": 1}} + if model.startswith("reducto/") + else OCR_RESPONSE + ) + ocr_server.default_response = ResponseSpec(body=payload) + arguments: Final = { + "model": model, + "req_format": "native", + "num_retries": 0, + "document": {"type": "document_url", "document_url": "reducto://ready.pdf"} + if model.startswith("reducto/") + else OCR_DOCUMENT, + } + response: Final = ( + await call_native_aocr(ocr_server, **arguments) if asynchronous else call_native_ocr(ocr_server, **arguments) + ) + assert response.pages[0].markdown == "native OCR response" + assert response.get_provider_native_response() == payload + assert len(ocr_server.requests) == 1 + if ocr_backend: + assert_native_request(ocr_server) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("asynchronous", [False, True], ids=["sync", "async"]) +async def test_ocr_contract_unknown_reducto_model_reaches_provider( + ocr_server: RecordingServer, + ocr_backend: bool, + asynchronous: bool, +) -> None: + ocr_server.default_response = ResponseSpec(body={"result": {"chunks": [{"content": "future model response"}]}}) + arguments: Final = { + "model": "reducto/future-parse-model", + "document": {"type": "document_url", "document_url": "reducto://ready.pdf"}, + "num_retries": 0, + } + response: Final = ( + await call_native_aocr(ocr_server, **arguments) if asynchronous else call_native_ocr(ocr_server, **arguments) + ) + assert response.model == "future-parse-model" + assert response.pages[0].markdown == "future model response" + assert len(ocr_server.requests) == 1 + assert ocr_server.requests[0].path == "/parse" + assert ocr_server.requests[0].body == {"input": "reducto://ready.pdf"} + + @pytest.mark.asyncio @pytest.mark.parametrize("asynchronous", [False, True], ids=["sync", "async"]) async def test_native_azure_ocr_uses_token_provider_result_as_bearer_token( @@ -595,7 +785,9 @@ def test_native_file_preparation_rejects_unsupported_reader_results(ocr_server: @pytest.mark.parametrize("kind", ["bytes", "path", "reader"]) -def test_native_file_preparation_rejects_oversized_input(ocr_server: RecordingServer, kind: str, tmp_path: Path) -> None: +def test_native_file_preparation_rejects_oversized_input( + ocr_server: RecordingServer, kind: str, tmp_path: Path +) -> None: ocr_server.expected_requests = 0 limit: Final = 50 * 1024 * 1024 path: Final = tmp_path / "large.pdf"