fix(ocr): preserve reducto native compatibility

This commit is contained in:
Yujong Lee 2026-09-12 08:40:50 -07:00
parent fe8759df7b
commit fd48f9da75
6 changed files with 131 additions and 121 deletions

View file

@ -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,

View file

@ -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]

View file

@ -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

View file

@ -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"}]}},

View file

@ -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"

View file

@ -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):