fix(ocr): narrow public error attribute writes and cover callback failure mapping

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Yujong Lee 2026-09-17 19:06:28 +00:00
parent 1f0c10147d
commit cd4d78a26a
4 changed files with 82 additions and 6 deletions

View file

@ -44,7 +44,7 @@ from litellm.llms.base_llm.base_model_iterator import (
MockResponseIterator,
)
from litellm.llms.base_llm.batches.transformation import BaseBatchesConfig
from litellm.llms.base_llm.chat.transformation import BaseConfig
from litellm.llms.base_llm.chat.transformation import BaseConfig, BaseLLMException
from litellm.llms.base_llm.containers.transformation import BaseContainerConfig
from litellm.llms.base_llm.embedding.transformation import BaseEmbeddingConfig
from litellm.llms.base_llm.evals.transformation import BaseEvalsAPIConfig
@ -6060,8 +6060,6 @@ class BaseLLMHTTPHandler:
error_headers = {}
if provider_config is None:
from litellm.llms.base_llm.chat.transformation import BaseLLMException
raise BaseLLMException(
status_code=status_code,
message=error_text,
@ -6074,7 +6072,11 @@ class BaseLLMHTTPHandler:
status_code=status_code,
headers=error_headers,
)
if isinstance(provider_config, BaseOCRConfig) and isinstance(error_response, httpx.Response):
if (
isinstance(provider_config, BaseOCRConfig)
and isinstance(provider_error, BaseLLMException)
and isinstance(error_response, httpx.Response)
):
provider_error.response = error_response
if not isinstance(received_status_code, int):
provider_error.status_code_is_synthesized = True

View file

@ -5,6 +5,7 @@ from types import MappingProxyType
from typing import Final
import httpx
import openai
from pydantic import TypeAdapter, ValidationError
import litellm
@ -59,7 +60,8 @@ def map_failure(error: Exception, request: LiteLLMOcrRequest, request_provider:
original: Final = _upstream_failure(error)
public_error: Final = failures.map_failure(original, request.model, request_provider, arguments(request))
if isinstance(original, UpstreamFailure) and public_error.__context__ is original:
public_error.response = original.response
public_error.status_code = original.status_code
public_error.__context__ = error
if isinstance(public_error, openai.APIStatusError):
public_error.response = original.response
public_error.status_code = original.status_code
return public_error

View file

@ -17,6 +17,7 @@ from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler
from litellm.ocr.main import _prepare_ocr_request
from litellm.rust_bridge import bindings, configuration, runtime
from litellm.rust_bridge.ocr.entrypoints import NATIVE_AOCR, NATIVE_OCR
from litellm.utils import ProviderConfigManager
@pytest.fixture
@ -277,6 +278,7 @@ def _prepare(model: str, document: object, **kwargs: object) -> object:
(
("https://example.com/file.pdf", "document must be a dict"),
({"type": "video_url", "video_url": "https://example.com/clip.mp4"}, "Invalid document type: video_url"),
({"type": "document_url", "document_url": ""}, "Document URL is required"),
),
)
def test_prepare_ocr_request_rejects_malformed_documents(document: object, match: str) -> None:
@ -284,6 +286,20 @@ def test_prepare_ocr_request_rejects_malformed_documents(document: object, match
_prepare("mistral/mistral-ocr-latest", document)
def test_prepare_ocr_request_maps_param_mapping_errors_to_bad_request(monkeypatch: pytest.MonkeyPatch) -> None:
config: Final = Mock()
config.resolve_connection_params.return_value = ("test-key", None)
config.get_supported_ocr_params.return_value = ["pages"]
config.map_ocr_params.side_effect = ValueError("pages must be a list")
monkeypatch.setattr(ProviderConfigManager, "get_provider_ocr_config", Mock(return_value=config))
with pytest.raises(litellm.BadRequestError, match="pages must be a list") as error:
_prepare("mistral/mistral-ocr-latest", dict(PRICING_DOCUMENT), pages="1")
assert error.value.llm_provider == "mistral"
assert isinstance(error.value.__cause__, ValueError)
def test_prepare_ocr_request_rejects_provider_without_ocr_support() -> None:
with pytest.raises(ValueError, match="OCR is not supported for provider: openai"):
_prepare("openai/gpt-4o", dict(PRICING_DOCUMENT))

View file

@ -1,4 +1,32 @@
from typing import Final
import pytest
import litellm
from litellm.rust_bridge.ocr.callbacks import UpstreamFailure, map_failure
from litellm.rust_bridge.ocr.callbacks import response as build_ocr_response
from litellm.rust_bridge.ocr.entrypoints import LiteLLMOcrRequest
REQUEST: Final = LiteLLMOcrRequest(
model="mistral/mistral-ocr-latest",
document={"type": "document_url", "document_url": "https://example.com/file.pdf"},
api_key="test-key",
api_base=None,
timeout=None,
custom_llm_provider=None,
extra_headers=None,
kwargs={"req_format": "markdown"},
)
class RustUpstreamError(Exception):
def __init__(self, status: int, body: str, headers: tuple[tuple[str, str], ...]) -> None:
super().__init__(status, body)
self.headers: Final = list(headers)
class RustFormatError(Exception):
ocr_request_format_error: Final = True
def test_rust_ocr_response_retains_provider_native_response():
@ -16,3 +44,31 @@ def test_rust_ocr_response_retains_provider_native_response():
assert response.get_provider_native_response() == provider_response
assert response.model_dump().get("provider_native_response") is None
def test_map_failure_builds_public_error_from_upstream_status_and_headers() -> None:
error: Final = RustUpstreamError(429, '{"message": "slow down"}', (("retry-after", "7"),))
public_error: Final = map_failure(error, REQUEST, "mistral")
assert isinstance(public_error, litellm.RateLimitError)
assert public_error.status_code == 429
assert public_error.response.headers["retry-after"] == "7"
assert public_error.response.text == '{"message": "slow down"}'
assert public_error.__context__ is error
assert public_error.llm_provider == "mistral"
def test_map_failure_leaves_non_upstream_errors_unwrapped() -> None:
error: Final = RuntimeError("bridge exploded")
public_error: Final = map_failure(error, REQUEST, "mistral")
assert not isinstance(public_error, UpstreamFailure)
assert isinstance(public_error, litellm.APIConnectionError)
assert "bridge exploded" in str(public_error)
def test_map_failure_reports_invalid_request_format_as_unsupported_params() -> None:
with pytest.raises(litellm.UnsupportedParamsError, match="Invalid `req_format`: 'markdown'"):
raise map_failure(RustFormatError(), REQUEST, "mistral")