litellm/tests/test_litellm_rust/test_ocr.py
Yujong Lee 60d046ccc4 Revert "refactor callback"
This reverts commit 1771e5bb68.
2026-09-16 19:07:43 -07:00

594 lines
21 KiB
Python

import json
import asyncio
import base64
import threading
import weakref
import gc
from pathlib import Path
from collections.abc import Generator
from datetime import datetime, timezone
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from io import BytesIO
from typing import Final, Protocol
import httpx
import pytest
import litellm
from litellm.litellm_core_utils.litellm_logging import Logging
from litellm.llms.azure_ai.ocr.cohere_parse_transformation import AzureAICohereParseConfig
from litellm.llms.azure_ai.ocr.document_intelligence.transformation import AzureDocumentIntelligenceOCRConfig
from litellm.llms.azure_ai.ocr.transformation import AzureAIOCRConfig
from litellm.llms.base_llm.ocr.transformation import OCRResponse
from litellm.llms.cohere.ocr.transformation import CohereParseConfig
from litellm.llms.mistral.ocr.transformation import MistralOCRConfig
from litellm.llms.reducto.ocr.transformation import ReductoParseLegacyConfig, ReductoParseV3Config
from litellm.llms.vertex_ai.ocr.deepseek_transformation import VertexAIDeepSeekOCRConfig
from litellm.llms.vertex_ai.ocr.transformation import VertexAIOCRConfig
from tests.test_litellm_rust.support.recording_server import ResponseSpec, recording_service
pytestmark = pytest.mark.requires_rust_extension
class _PythonOcrRequest(Protocol):
@property
def data(self) -> object: ...
class _PythonOcrConfig(Protocol):
def map_ocr_params(
self, non_default_params: dict[str, object], optional_params: dict[str, object], model: str
) -> dict[str, object]: ...
def transform_ocr_request(
self, model: str, document: dict[str, str], optional_params: dict[str, object], headers: dict[str, str]
) -> _PythonOcrRequest: ...
def transform_ocr_response(self, model: str, raw_response: httpx.Response, logging_obj: Logging) -> OCRResponse: ...
@pytest.mark.parametrize(
"provider,config,document,options,payload",
[
pytest.param(
provider,
config,
{"type": "document_url", "document_url": "data:application/pdf;base64,YWJj"},
{"pages": None, "include_image_base64": False},
{
"pages": [{"index": "2", "markdown": "text", "dimensions": {"width": 1.0}, "extension": False}],
"usage_info": {"pages_processed": True, "credits": "1.5", "custom": 0},
"extra": "ignored",
},
id=provider,
)
for provider, config in (
("mistral", MistralOCRConfig()),
("azure_ai", AzureAIOCRConfig()),
("vertex_ai", VertexAIOCRConfig()),
)
]
+ [
pytest.param(
"azure_ai",
AzureAICohereParseConfig(),
{"type": "image_url", "image_url": "data:image/png;base64,YWJj"},
{"output_format": "blocks"},
{"pages": [{"blocks": [{"type": "future", "data": None}]}]},
id="azure-cohere",
),
pytest.param(
"azure_ai",
AzureDocumentIntelligenceOCRConfig(),
{"type": "document_url", "document_url": "https://example.com/a.pdf"},
{"pages": [0, 2], "features": ["languages"]},
{
"status": "succeeded",
"analyzeResult": {
"pages": [{"pageNumber": 2, "width": 1.5, "height": 2, "lines": [{"content": "text"}]}],
"content": "text",
"tables": [{"extension": None}],
"keyValuePairs": [],
},
},
id="azure-document-intelligence",
),
pytest.param(
"cohere",
CohereParseConfig(),
{"type": "image_url", "image_url": "https://example.com/a.png", "ignored": "field"},
{"output_format": None},
{
"pages": [
{
"markdown": {
"images": [{"bounding_box": {"x": 1}, "category": "future"}, {"image_base64": "encoded"}]
},
"blocks": [{"type": "text", "text": "Total Due: $4.00"}, {"type": "future", "payload": None}],
}
],
"meta": {"billed_units": {"pages": 0}},
},
id="cohere",
),
pytest.param(
"reducto",
ReductoParseV3Config(),
{"type": "document_url", "document_url": "reducto://ready.pdf"},
{"formatting": None, "settings": {"future": True}},
{
"result": {
"chunks": [
{
"blocks": [
{"content": "text", "bbox": {"page": "2"}, "extension": None},
{"content": "ignored", "bbox": {"page": "bad"}},
]
}
]
},
"usage": {"num_pages": "1", "credits": True},
},
id="reducto-v3",
),
pytest.param(
"reducto",
ReductoParseLegacyConfig(),
{"type": "document_url", "document_url": "reducto://ready.pdf"},
{"enhance": None},
{"result": None, "chunks": [{"content": "ignored"}]},
id="reducto-legacy",
),
]
+ [
pytest.param(
"vertex_ai",
VertexAIDeepSeekOCRConfig(),
{"type": "document_url", "document_url": "gs://bucket/a.pdf"},
{"temperature": None},
{"choices": [{"message": {"content": content}}], "usage": {"prompt_tokens": 1}},
id=f"deepseek-{name}",
)
for name, content in (
("markdown", "text"),
("empty-pages", {"pages": []}),
(
"structured",
{
"pages": [{"index": "2", "markdown": "text", "ignored": True}],
"document_annotation": {"language": "en"},
"ignored": True,
},
),
("explicit-null-usage", {"model": "returned", "usage_info": None}),
("object-text", {"z": ["한글", "😀"], "a": "last"}),
)
],
)
def test_native_provider_transforms_match_python_shapes(
provider: str,
config: _PythonOcrConfig,
document: dict[str, str],
options: dict[str, object],
payload: dict[str, object],
) -> None:
from litellm.rust_bridge import _native
model: Final = (
"deepseek-ocr"
if isinstance(config, VertexAIDeepSeekOCRConfig)
else "cohere-parse"
if isinstance(config, AzureAICohereParseConfig)
else "doc-intelligence/prebuilt-read"
if isinstance(config, AzureDocumentIntelligenceOCRConfig)
else "parse-legacy"
if provider == "reducto" and "enhance" in options
else "model"
)
mapped: Final = config.map_ocr_params(
options, options if isinstance(config, VertexAIDeepSeekOCRConfig) else {}, model
)
expected_request: Final = config.transform_ocr_request(model, document, mapped, {}).data
logging: Final = Logging(
model=model,
messages=[],
stream=False,
call_type="ocr",
start_time=datetime.now(timezone.utc),
litellm_call_id="ocr-shape-parity",
function_id="ocr-shape-parity",
)
expected_response: Final = config.transform_ocr_response(
model, httpx.Response(200, json=payload), logging
).model_dump()
with recording_service() as server:
server.enqueue(ResponseSpec(body=payload))
response: Final = _native.ocr(
model=model,
document={**document},
api_key="test-key",
api_base=server.base_url,
custom_llm_provider=provider,
extra_headers=None,
timeout=3,
**{**options, **({"vertex_project": "project"} if provider == "vertex_ai" else {})},
)
assert response.model_dump() == expected_response
assert server.requests[0].body == expected_request
@pytest.fixture
def ocr_server() -> Generator[tuple[ThreadingHTTPServer, list[dict[str, object]]]]:
requests: Final[list[dict[str, object]]] = []
class Handler(BaseHTTPRequestHandler):
def do_POST(self) -> None:
requests.append(
{
"headers": {name.lower(): value for name, value in self.headers.items()},
"body": json.loads(self.rfile.read(int(self.headers["Content-Length"]))),
}
)
if self.headers.get("x-test-stall") == "true":
self.connection.settimeout(2)
try:
self.rfile.read(1)
except TimeoutError:
pass
return
if self.headers.get("User-Agent", "").startswith("python-httpx"):
self.send_response(418)
self.end_headers()
return
status = int(self.headers.get("x-test-status", "200"))
if status != 200:
body = b'{"error":"provider unavailable"}'
self.send_response(status)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
return
response: Final = json.dumps(
{
"pages": [{"index": 0, "markdown": "native OCR response", "images": [], "dimensions": None}],
"model": "mistral-ocr-latest",
"usage_info": {"pages_processed": 1, "doc_size_bytes": 3},
}
).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(response)))
self.end_headers()
self.wfile.write(response)
def log_message(self, format: str, *args: object) -> None:
pass
server: Final = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
thread: Final = threading.Thread(target=lambda: server.serve_forever(poll_interval=0.01), daemon=True)
thread.start()
try:
yield server, requests
finally:
server.shutdown()
server.server_close()
thread.join()
def test_native_ocr_with_compiled_rust_extension(
ocr_server: tuple[ThreadingHTTPServer, list[dict[str, object]]],
) -> None:
from litellm.rust_bridge import _native
server, requests = ocr_server
address: Final = server.server_address
host: Final = str(address[0])
port: Final = int(address[1])
response: Final = _native.ocr(
model="mistral-ocr-latest",
document={"type": "document_url", "document_url": "data:application/pdf;base64,YWJj"},
api_key="test-key",
api_base=f"http://{host}:{port}",
custom_llm_provider="mistral",
extra_headers=None,
timeout=None,
)
assert response is not None
assert response.pages[0].markdown == "native OCR response"
assert len(requests) == 1
assert not requests[0]["headers"].get("user-agent", "").startswith("python-httpx")
assert requests[0]["body"] == {
"model": "mistral-ocr-latest",
"document": {"type": "document_url", "document_url": "data:application/pdf;base64,YWJj"},
}
@pytest.mark.parametrize(
"file_input,mime_type,expected_type,expected_field,expected_uri",
[
(b"abc", "application/pdf", "document_url", "document_url", "data:application/pdf;base64,YWJj"),
(BytesIO(b"abc"), "image/png", "image_url", "image_url", "data:image/png;base64,YWJj"),
],
)
def test_native_lifecycle_core_encodes_python_file_input(
ocr_server,
file_input,
mime_type,
expected_type,
expected_field,
expected_uri,
):
server, requests = ocr_server
litellm.rust(True)
response = litellm.ocr(
model="mistral/mistral-ocr-latest",
document={"type": "file", "file": file_input, "mime_type": mime_type},
api_key="test-key",
api_base=f"http://127.0.0.1:{server.server_port}",
opaque_extension={"nested": [None, False, 0]},
)
assert response.pages[0].markdown == "native OCR response"
assert requests[0]["body"]["document"] == {
"type": expected_type,
expected_field: expected_uri,
}
assert requests[0]["body"]["opaque_extension"] == {"nested": [None, False, 0]}
@pytest.mark.parametrize("asynchronous", [False, True])
@pytest.mark.parametrize("source", ["path", "pathlike", "reader", "text", "bytes"])
@pytest.mark.asyncio
async def test_native_file_sources_preserve_sdk_behavior(ocr_server, tmp_path, asynchronous, source):
server, requests = ocr_server
path: Final = tmp_path / "scan.png"
content: Final = b"document bytes"
path.write_bytes(content)
class CustomPath:
def __fspath__(self) -> str:
return str(path)
def __str__(self) -> str:
return "/not/the/document.pdf"
class Reader:
name = "scan.png"
def __init__(self) -> None:
self.stream = BytesIO(b"prefix" + content)
self.stream.seek(6)
self.calls = 0
def read(self) -> bytes | str:
assert threading.get_ident() == caller_thread
if asynchronous:
assert asyncio.current_task() is caller_task
self.calls += 1
value: Final = self.stream.read()
return value.decode() if source == "text" else value
caller_thread: Final = threading.get_ident()
caller_task: Final = asyncio.current_task()
reader: Final = Reader()
file_input: Final = {
"path": path,
"pathlike": CustomPath(),
"reader": reader,
"text": reader,
"bytes": content,
}[source]
document: Final = {"type": "file", "file": file_input, **({"mime_type": "image/png"} if source == "bytes" else {})}
litellm.rust(True)
kwargs: Final = {
"model": "mistral/mistral-ocr-latest",
"document": document,
"api_key": "test-key",
"api_base": f"http://127.0.0.1:{server.server_port}",
}
response: Final = await litellm.aocr(**kwargs) if asynchronous else litellm.ocr(**kwargs)
assert response.pages[0].markdown == "native OCR response"
assert len(requests) == 1
assert requests[0]["body"]["document"] == {
"type": "image_url",
"image_url": "data:image/png;base64," + base64.b64encode(content).decode(),
}
assert reader.calls == (1 if source in {"reader", "text"} else 0)
assert not reader.stream.closed
@pytest.mark.parametrize("asynchronous", [False, True])
@pytest.mark.asyncio
async def test_native_file_failures_preserve_identity_and_do_not_send(ocr_server, tmp_path, asynchronous):
from litellm.rust_bridge import _native
server, requests = ocr_server
failure: Final = KeyError("reader failed")
class Reader:
def __init__(self) -> None:
self.calls = 0
def read(self) -> bytes:
self.calls += 1
raise failure
reader: Final = Reader()
path: Final = tmp_path / "missing.pdf"
kwargs: Final = {
"model": "mistral/mistral-ocr-latest",
"api_key": "test-key",
"api_base": f"http://127.0.0.1:{server.server_port}",
}
for source, error in ((reader, KeyError), (path, FileNotFoundError)):
with pytest.raises(error) as caught:
if asynchronous:
await _native.aocr(document={"type": "file", "file": source}, **kwargs)
else:
_native.ocr(document={"type": "file", "file": source}, **kwargs)
if source is reader:
assert caught.value is failure
else:
assert caught.value.filename == str(path)
assert reader.calls == 1
assert requests == []
def test_unstarted_native_file_call_does_not_read_and_releases_reader():
from litellm.rust_bridge import _native
class Reader:
def read(self) -> bytes:
raise AssertionError("unstarted call consumed its document")
def create_call():
reader: Final = Reader()
pending: Final = _native.aocr(
model="mistral/mistral-ocr-latest", document={"type": "file", "file": reader}
)
reader.pending = pending
return pending, weakref.ref(reader)
pending, reference = create_call()
pending.close()
del pending
gc.collect()
assert reference() is None
@pytest.mark.parametrize("asynchronous", [False, True])
@pytest.mark.parametrize("model", ["mistral/mistral-ocr-latest", "azure_ai/doc-intelligence/prebuilt-read"])
@pytest.mark.asyncio
async def test_native_public_ocr_matches_python(model, asynchronous):
import json
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from threading import Thread
from typing import Final
from urllib.parse import parse_qsl, urlsplit
from litellm.rust_bridge import _native
assert callable(_native.ocr)
calls: Final = []
class Handler(BaseHTTPRequestHandler):
def do_POST(self):
body: Final = json.loads(self.rfile.read(int(self.headers["Content-Length"])))
target: Final = urlsplit(self.path)
calls.append(
(
target.path,
parse_qsl(target.query),
self.headers.get("Authorization"),
self.headers.get("Ocp-Apim-Subscription-Key"),
body,
)
)
payload: Final = (
{"status": "succeeded", "analyzeResult": {"pages": []}}
if "doc-intelligence" in model
else {"pages": [{"index": 0, "markdown": "hello"}]}
)
encoded: Final = json.dumps(payload).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(encoded)))
self.end_headers()
self.wfile.write(encoded)
def log_message(self, *_args):
pass
server: Final = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
thread: Final = Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
litellm.rust(True)
arguments: Final = {
"model": model,
"document": {"type": "document_url", "document_url": "data:application/pdf;base64,YWJj"},
"api_key": "test-key",
"api_base": f"http://127.0.0.1:{server.server_port}",
"pages": [0, 2],
"timeout": 3.0,
}
response: Final = await litellm.aocr(**arguments) if asynchronous else litellm.ocr(**arguments)
response_data: Final = response.model_dump()
assert len(calls) == 1
assert response_data["object"] == "ocr"
finally:
server.shutdown()
server.server_close()
thread.join(timeout=3)
@pytest.mark.parametrize("asynchronous", [False, True])
@pytest.mark.asyncio
async def test_native_ocr_failures_do_not_retry_on_python(ocr_server, asynchronous):
server, requests = ocr_server
arguments = {
"model": "mistral-ocr-latest",
"custom_llm_provider": "mistral",
"document": {"type": "document_url", "document_url": "data:application/pdf;base64,YWJj"},
"api_key": "test-key",
"api_base": f"http://127.0.0.1:{server.server_port}",
"extra_headers": {"x-test-status": "503"},
"num_retries": 0,
}
litellm.rust(True)
with pytest.raises(litellm.ServiceUnavailableError) as caught:
await litellm.aocr(**arguments) if asynchronous else litellm.ocr(**arguments)
assert caught.value.status_code == 503
assert len(requests) == 1
assert not requests[0]["headers"].get("user-agent", "").startswith("python-httpx")
@pytest.mark.parametrize(
"custom_provider,error_type,message",
[
("mistral", litellm.BadRequestError, "document"),
("not-a-provider", litellm.APIConnectionError, "invalid provider"),
],
)
def test_native_ocr_rejects_invalid_input_before_network(ocr_server, custom_provider, error_type, message):
from litellm.rust_bridge import _native
server, requests = ocr_server
with pytest.raises(error_type, match=message):
_native.ocr(
model="mistral-ocr-latest",
custom_llm_provider=custom_provider,
document={"type": "document_url"},
api_key="test-key",
api_base=f"http://127.0.0.1:{server.server_port}",
)
assert requests == []
@pytest.mark.parametrize("asynchronous", [False, True])
@pytest.mark.asyncio
async def test_native_ocr_enforces_request_deadline_without_fallback(ocr_server, asynchronous):
import asyncio
import time
server, requests = ocr_server
litellm.rust(True)
arguments = {
"model": "mistral/mistral-ocr-latest",
"document": {"type": "document_url", "document_url": "data:application/pdf;base64,YWJj"},
"api_key": "test-key",
"api_base": f"http://127.0.0.1:{server.server_port}",
"extra_headers": {"x-test-stall": "true"},
"timeout": 0.1,
"num_retries": 0,
}
started = time.monotonic()
with pytest.raises(litellm.Timeout):
await asyncio.wait_for(
litellm.aocr(**arguments) if asynchronous else asyncio.to_thread(litellm.ocr, **arguments),
timeout=3,
)
assert 0.09 <= time.monotonic() - started < 3
assert len(requests) == 1
assert not requests[0]["headers"].get("user-agent", "").startswith("python-httpx")