mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-07 02:59:05 +00:00
fix(ocr): preserve reducto native compatibility
This commit is contained in:
parent
fe8759df7b
commit
fd48f9da75
6 changed files with 131 additions and 121 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
11
tests/test_litellm/llms/reducto/conftest.py
Normal file
11
tests/test_litellm/llms/reducto/conftest.py
Normal 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
|
||||
|
|
@ -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"}]}},
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue