From fd48f9da755e61202b584a2134231ff9d2fb79ae Mon Sep 17 00:00:00 2001 From: Yujong Lee Date: Sat, 12 Sep 2026 08:40:50 -0700 Subject: [PATCH] fix(ocr): preserve reducto native compatibility --- litellm-rust/crates/core/src/ocr/error.rs | 4 +- litellm-rust/crates/core/src/ocr/registry.rs | 16 +-- tests/test_litellm/llms/reducto/conftest.py | 11 ++ .../llms/reducto/test_parse_legacy.py | 54 +++++---- .../llms/reducto/test_parse_v3.py | 63 +++++------ .../test_litellm/llms/reducto/test_upload.py | 104 +++++++++--------- 6 files changed, 131 insertions(+), 121 deletions(-) create mode 100644 tests/test_litellm/llms/reducto/conftest.py diff --git a/litellm-rust/crates/core/src/ocr/error.rs b/litellm-rust/crates/core/src/ocr/error.rs index 73a4043afca..98e618834cf 100644 --- a/litellm-rust/crates/core/src/ocr/error.rs +++ b/litellm-rust/crates/core/src/ocr/error.rs @@ -20,7 +20,9 @@ pub enum OcrRequestError { MissingField(&'static str), #[error("invalid OCR document data URI")] InvalidDataUri, - #[error("Reducto requires a reducto:// id or a data URI")] + #[error( + "Reducto requires a reducto:// id or a data URI; plain HTTP URLs are not supported, upload the file first" + )] ReductoSource, #[error("inline OCR document exceeds the size limit")] InlineDocumentTooLarge, diff --git a/litellm-rust/crates/core/src/ocr/registry.rs b/litellm-rust/crates/core/src/ocr/registry.rs index f47dcb5e6fc..ed7d4fd5cf2 100644 --- a/litellm-rust/crates/core/src/ocr/registry.rs +++ b/litellm-rust/crates/core/src/ocr/registry.rs @@ -78,12 +78,7 @@ pub(crate) fn resolve_wire_adapter( OcrProvider::Reducto if provider.model.eq_ignore_ascii_case("parse-v3") => { OcrAdapterKind::ReductoV3 } - OcrProvider::Reducto => { - return Err(Error::InvalidRequest(format!( - "unsupported Reducto OCR model: {}", - provider.model - ))); - } + OcrProvider::Reducto => OcrAdapterKind::ReductoV3, OcrProvider::VertexAi if provider.model.to_ascii_lowercase().contains("deepseek") => { OcrAdapterKind::VertexDeepSeek } @@ -117,11 +112,10 @@ mod tests { } #[test] - fn unknown_reducto_models_are_rejected() { - assert!(matches!( - resolve_wire_adapter("reducto/future-parse-model", None), - Err(Error::InvalidRequest(_)) - )); + fn unknown_reducto_models_use_the_current_protocol() { + let (model, adapter) = resolve_wire_adapter("reducto/future-parse-model", None).unwrap(); + assert_eq!(model, "future-parse-model"); + assert_eq!(adapter, OcrAdapterKind::ReductoV3); } #[test] diff --git a/tests/test_litellm/llms/reducto/conftest.py b/tests/test_litellm/llms/reducto/conftest.py new file mode 100644 index 00000000000..4ff3ab43006 --- /dev/null +++ b/tests/test_litellm/llms/reducto/conftest.py @@ -0,0 +1,11 @@ +from collections.abc import Generator + +import pytest + +from tests.test_litellm_rust.support.recording_server import RecordingServer, recording_service + + +@pytest.fixture +def reducto_server() -> Generator[RecordingServer]: + with recording_service() as server: + yield server diff --git a/tests/test_litellm/llms/reducto/test_parse_legacy.py b/tests/test_litellm/llms/reducto/test_parse_legacy.py index db19460baa3..252369cbd3d 100644 --- a/tests/test_litellm/llms/reducto/test_parse_legacy.py +++ b/tests/test_litellm/llms/reducto/test_parse_legacy.py @@ -1,7 +1,7 @@ -import json +import pytest import litellm -import pytest +from tests.test_litellm_rust.support.recording_server import RecordingServer, ResponseSpec @pytest.fixture() @@ -17,24 +17,28 @@ def disable_aiohttp_transport(): @pytest.mark.asyncio -async def test_parse_legacy_wraps_enhance_under_options( - disable_aiohttp_transport, respx_mock -): - upload_route = respx_mock.post("https://platform.reducto.ai/upload").respond( - json={"file_id": "reducto://legacy.pdf"} - ) - parse_route = respx_mock.post("https://platform.reducto.ai/parse").respond( - json={ - "usage": {"num_pages": 1, "credits": 1}, - "result": { - "chunks": [ - { - "content": "Legacy parse", - "blocks": [{"content": "Legacy parse", "bbox": {"page": 1}}], - } - ] - }, - } +async def test_parse_legacy_wraps_enhance_under_options(disable_aiohttp_transport, reducto_server: RecordingServer): + reducto_server.expected_requests = 2 + reducto_server.enqueue(ResponseSpec(body={"file_id": "reducto://legacy.pdf"})) + reducto_server.enqueue( + ResponseSpec( + body={ + "usage": {"num_pages": 1, "credits": 1}, + "result": { + "chunks": [ + { + "content": "Legacy parse", + "blocks": [ + { + "content": "Legacy parse", + "bbox": {"page": 1}, + } + ], + } + ] + }, + } + ) ) response = await litellm.aocr( @@ -45,13 +49,15 @@ async def test_parse_legacy_wraps_enhance_under_options( "mime_type": "application/pdf", }, api_key="legacy-key", - api_base="https://platform.reducto.ai", + api_base=reducto_server.base_url, enhance={"agentic": [{"type": "table"}]}, ) - assert upload_route.called - assert parse_route.called - request_body = json.loads(parse_route.calls[0].request.read()) + upload_request, parse_request = reducto_server.requests + assert upload_request.path == "/upload" + assert parse_request.path == "/parse" + assert isinstance(parse_request.body, dict) + request_body = parse_request.body assert request_body == { "document_url": "reducto://legacy.pdf", "options": {"enhance": {"agentic": [{"type": "table"}]}}, diff --git a/tests/test_litellm/llms/reducto/test_parse_v3.py b/tests/test_litellm/llms/reducto/test_parse_v3.py index 1d0c826ef8b..0ebc0d926c4 100644 --- a/tests/test_litellm/llms/reducto/test_parse_v3.py +++ b/tests/test_litellm/llms/reducto/test_parse_v3.py @@ -1,8 +1,7 @@ -import json - import pytest import litellm +from tests.test_litellm_rust.support.recording_server import RecordingServer, ResponseSpec def _reducto_parse_response() -> dict: @@ -69,11 +68,11 @@ def disable_aiohttp_transport(): @pytest.mark.asyncio -async def test_parse_v3_file_upload_and_response_mapping(disable_aiohttp_transport, respx_mock): - upload_route = respx_mock.post("https://platform.reducto.ai/upload").respond( - json={"file_id": "reducto://uploaded.pdf"} - ) - parse_route = respx_mock.post("https://platform.reducto.ai/parse").respond(json=_reducto_parse_response()) +async def test_parse_v3_file_upload_and_response_mapping(disable_aiohttp_transport, reducto_server: RecordingServer): + reducto_server.expected_requests = 2 + provider_response = _reducto_parse_response() + reducto_server.enqueue(ResponseSpec(body={"file_id": "reducto://uploaded.pdf"})) + reducto_server.enqueue(ResponseSpec(body=provider_response)) response = await litellm.aocr( model="reducto/parse-v3", @@ -83,25 +82,24 @@ async def test_parse_v3_file_upload_and_response_mapping(disable_aiohttp_transpo "mime_type": "application/pdf", }, api_key="test-key", - api_base="https://platform.reducto.ai", + api_base=reducto_server.base_url, formatting={"table_output_format": "html"}, retrieval={"chunk_mode": "section"}, settings={"ocr_system": "standard"}, + req_format="native", ) - assert upload_route.called - assert parse_route.called - assert len(upload_route.calls) == 1 - assert len(parse_route.calls) == 1 - - upload_request = upload_route.calls[0].request + upload_request, parse_request = reducto_server.requests + assert upload_request.path == "/upload" + assert parse_request.path == "/parse" assert upload_request.headers["authorization"] == "Bearer test-key" assert "application/json" not in upload_request.headers["content-type"] - upload_body = upload_request.read() + upload_body = upload_request.raw_body assert b'filename="document"' in upload_body assert b"application/pdf" in upload_body - parse_request_body = json.loads(parse_route.calls[0].request.read()) + assert isinstance(parse_request.body, dict) + parse_request_body = parse_request.body assert parse_request_body["input"] == "reducto://uploaded.pdf" assert parse_request_body["formatting"] == {"table_output_format": "html"} assert parse_request_body["retrieval"] == {"chunk_mode": "section"} @@ -116,15 +114,12 @@ async def test_parse_v3_file_upload_and_response_mapping(disable_aiohttp_transpo assert getattr(response.pages[0], "blocks")[0]["bbox"]["page"] == 1 assert response.pages[1].markdown == "Page 2 block A" assert response.pages[2].markdown == "Page 3 block A" - assert response._hidden_params["reducto_raw"]["usage"]["credits"] == 3 + assert response.get_provider_native_response() == provider_response @pytest.mark.asyncio -async def test_parse_v3_reducto_id_passthrough_skips_upload(disable_aiohttp_transport, respx_mock): - upload_route = respx_mock.post("https://platform.reducto.ai/upload").respond( - json={"file_id": "reducto://should-not-upload.pdf"} - ) - parse_route = respx_mock.post("https://platform.reducto.ai/parse").respond(json=_reducto_parse_response()) +async def test_parse_v3_reducto_id_passthrough_skips_upload(disable_aiohttp_transport, reducto_server: RecordingServer): + reducto_server.enqueue(ResponseSpec(body=_reducto_parse_response())) response = await litellm.aocr( model="reducto/parse-v3", @@ -133,13 +128,15 @@ async def test_parse_v3_reducto_id_passthrough_skips_upload(disable_aiohttp_tran "document_url": "reducto://already-uploaded.pdf", }, api_key="test-key", - api_base="https://platform.reducto.ai", + api_base=reducto_server.base_url, retrieval={"chunk_mode": "section"}, ) - assert not upload_route.called - assert parse_route.called - parse_request_body = json.loads(parse_route.calls[0].request.read()) + assert len(reducto_server.requests) == 1 + parse_request = reducto_server.requests[0] + assert parse_request.path == "/parse" + assert isinstance(parse_request.body, dict) + parse_request_body = parse_request.body assert parse_request_body["input"] == "reducto://already-uploaded.pdf" assert parse_request_body["retrieval"]["chunk_mode"] == "section" assert response.pages[0].markdown.startswith("Page 1 block A") @@ -147,11 +144,9 @@ async def test_parse_v3_reducto_id_passthrough_skips_upload(disable_aiohttp_tran @pytest.mark.asyncio async def test_unknown_model_uses_current_protocol_without_local_rejection( - disable_aiohttp_transport, respx_mock + disable_aiohttp_transport, reducto_server: RecordingServer ): - parse_route = respx_mock.post("https://platform.reducto.ai/parse").respond( - json=_reducto_parse_response() - ) + reducto_server.enqueue(ResponseSpec(body=_reducto_parse_response())) response = await litellm.aocr( model="reducto/future-parse-model", @@ -160,11 +155,9 @@ async def test_unknown_model_uses_current_protocol_without_local_rejection( "document_url": "reducto://already-uploaded.pdf", }, api_key="test-key", - api_base="https://platform.reducto.ai", + api_base=reducto_server.base_url, ) - assert parse_route.called - assert json.loads(parse_route.calls[0].request.read()) == { - "input": "reducto://already-uploaded.pdf" - } + assert reducto_server.requests[0].path == "/parse" + assert reducto_server.requests[0].body == {"input": "reducto://already-uploaded.pdf"} assert response.model == "future-parse-model" diff --git a/tests/test_litellm/llms/reducto/test_upload.py b/tests/test_litellm/llms/reducto/test_upload.py index 4fae90436bb..adfc2663fb0 100644 --- a/tests/test_litellm/llms/reducto/test_upload.py +++ b/tests/test_litellm/llms/reducto/test_upload.py @@ -1,16 +1,16 @@ -import json import os from unittest.mock import AsyncMock, Mock import httpx -import litellm import pytest +import litellm from litellm.llms.reducto.common import ( extract_file_id_or_bytes, upload_bytes_async, upload_bytes_sync, ) +from tests.test_litellm_rust.support.recording_server import RecordingServer, ResponseSpec @pytest.fixture() @@ -28,7 +28,8 @@ def disable_aiohttp_transport(monkeypatch): @pytest.mark.asyncio -async def test_parse_v3_rejects_plain_http_urls(disable_aiohttp_transport): +async def test_parse_v3_rejects_plain_http_urls(disable_aiohttp_transport, reducto_server: RecordingServer): + reducto_server.expected_requests = 0 with pytest.raises(litellm.BadRequestError, match="upload the file first"): await litellm.aocr( model="reducto/parse-v3", @@ -37,29 +38,30 @@ async def test_parse_v3_rejects_plain_http_urls(disable_aiohttp_transport): "document_url": "https://example.com/document.pdf", }, api_key="test-key", - api_base="https://platform.reducto.ai", + api_base=reducto_server.base_url, ) @pytest.mark.asyncio async def test_parse_v3_image_data_uri_upload_uses_image_mime( - disable_aiohttp_transport, respx_mock + disable_aiohttp_transport, reducto_server: RecordingServer ): - upload_route = respx_mock.post("https://custom.reducto.test/upload").respond( - json={"file_id": "reducto://uploaded-image.png"} - ) - parse_route = respx_mock.post("https://custom.reducto.test/parse").respond( - json={ - "usage": {"num_pages": 1, "credits": 1}, - "result": { - "chunks": [ - { - "content": "Image OCR", - "blocks": [{"content": "Image OCR", "bbox": {"page": 1}}], - } - ] - }, - } + reducto_server.expected_requests = 2 + reducto_server.enqueue(ResponseSpec(body={"file_id": "reducto://uploaded-image.png"})) + reducto_server.enqueue( + ResponseSpec( + body={ + "usage": {"num_pages": 1, "credits": 1}, + "result": { + "chunks": [ + { + "content": "Image OCR", + "blocks": [{"content": "Image OCR", "bbox": {"page": 1}}], + } + ] + }, + } + ) ) response = await litellm.aocr( @@ -70,41 +72,43 @@ async def test_parse_v3_image_data_uri_upload_uses_image_mime( "mime_type": "image/png", }, api_key="programmatic-key", - api_base="https://custom.reducto.test/", + api_base=f"{reducto_server.base_url}/", ) - assert upload_route.called - assert parse_route.called - upload_request = upload_route.calls[0].request + upload_request, parse_request = reducto_server.requests + assert upload_request.path == "/upload" + assert parse_request.path == "/parse" assert upload_request.headers["authorization"] == "Bearer programmatic-key" - assert b"image/png" in upload_request.read() + assert b"image/png" in upload_request.raw_body - parse_request_body = json.loads(parse_route.calls[0].request.read()) - assert parse_request_body["input"] == "reducto://uploaded-image.png" + assert isinstance(parse_request.body, dict) + assert parse_request.body["input"] == "reducto://uploaded-image.png" assert response.pages[0].markdown == "Image OCR" @pytest.mark.asyncio -async def test_parse_v3_uses_programmatic_api_key_over_env( - disable_aiohttp_transport, respx_mock -): - upload_route = respx_mock.post("https://platform.reducto.ai/upload").respond( - json={"file_id": "reducto://uploaded.pdf"} - ) - parse_route = respx_mock.post("https://platform.reducto.ai/parse").respond( - json={ - "usage": {"num_pages": 1, "credits": 1}, - "result": { - "chunks": [ - { - "content": "Programmatic auth", - "blocks": [ - {"content": "Programmatic auth", "bbox": {"page": 1}} - ], - } - ] - }, - } +async def test_parse_v3_uses_programmatic_api_key_over_env(disable_aiohttp_transport, reducto_server: RecordingServer): + reducto_server.expected_requests = 2 + reducto_server.enqueue(ResponseSpec(body={"file_id": "reducto://uploaded.pdf"})) + reducto_server.enqueue( + ResponseSpec( + body={ + "usage": {"num_pages": 1, "credits": 1}, + "result": { + "chunks": [ + { + "content": "Programmatic auth", + "blocks": [ + { + "content": "Programmatic auth", + "bbox": {"page": 1}, + } + ], + } + ] + }, + } + ) ) await litellm.aocr( @@ -115,11 +119,11 @@ async def test_parse_v3_uses_programmatic_api_key_over_env( "mime_type": "application/pdf", }, api_key="passed-key", - api_base="https://platform.reducto.ai", + api_base=reducto_server.base_url, ) - assert upload_route.calls[0].request.headers["authorization"] == "Bearer passed-key" - assert parse_route.calls[0].request.headers["authorization"] == "Bearer passed-key" + assert reducto_server.requests[0].headers["authorization"] == "Bearer passed-key" + assert reducto_server.requests[1].headers["authorization"] == "Bearer passed-key" def test_upload_bytes_sync_uses_shared_client(monkeypatch):