mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-19 00:01:29 +00:00
fix(ocr): build upstream httpx response in Python and satisfy PT012
The Rust bridge imported httpx to construct the provider error response, which fails in the isolated wheel check where httpx is absent. Rust now raises RustUpstreamError with a headers attribute and the Python lifecycle wraps it in a typed UpstreamFailure carrying the httpx.Response before legacy mapping. Test helpers gained call_native so pytest.raises blocks hold a single call Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
parent
f91d1f7ea1
commit
4ecc55ec70
4 changed files with 45 additions and 35 deletions
|
|
@ -1,7 +1,6 @@
|
|||
use litellm_core::ocr::Error;
|
||||
use pyo3::exceptions::{PyFileNotFoundError, PyOSError};
|
||||
use pyo3::prelude::*;
|
||||
use pyo3::types::PyDict;
|
||||
|
||||
use crate::errors::{RustUpstreamError, core_error_to_pyerr};
|
||||
|
||||
|
|
@ -42,15 +41,8 @@ fn upstream_error(
|
|||
body: String,
|
||||
headers: Vec<(String, String)>,
|
||||
) -> PyResult<PyErr> {
|
||||
let kwargs = PyDict::new(py);
|
||||
kwargs.set_item("content", &body)?;
|
||||
kwargs.set_item("headers", headers)?;
|
||||
let response = py
|
||||
.import("httpx")?
|
||||
.getattr("Response")?
|
||||
.call((status,), Some(&kwargs))?;
|
||||
let error = RustUpstreamError::new_err((status, body));
|
||||
error.value(py).setattr("response", response)?;
|
||||
error.value(py).setattr("headers", headers)?;
|
||||
Ok(error)
|
||||
}
|
||||
|
||||
|
|
@ -89,9 +81,15 @@ mod tests {
|
|||
let mapped = to_pyerr(Error::Provider {
|
||||
status: 429,
|
||||
body: r#"{"message":"rate limited"}"#.to_string(),
|
||||
headers: Vec::new(),
|
||||
headers: vec![("Retry-After".to_string(), "17".to_string())],
|
||||
});
|
||||
assert!(mapped.is_instance_of::<RustUpstreamError>(py));
|
||||
let headers: Vec<(String, String)> = mapped
|
||||
.value(py)
|
||||
.getattr("headers")
|
||||
.and_then(|headers| headers.extract())
|
||||
.expect("OCR failures retain provider headers");
|
||||
assert_eq!(headers, vec![("Retry-After".to_string(), "17".to_string())]);
|
||||
let args: (u16, String) = mapped
|
||||
.value(py)
|
||||
.getattr("args")
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ from collections.abc import Awaitable, Mapping, Sequence
|
|||
from typing import Final, Protocol, cast # noqa: TID251 # validates dynamically loaded native callables
|
||||
|
||||
import httpx
|
||||
from pydantic import TypeAdapter, ValidationError
|
||||
|
||||
import litellm
|
||||
from litellm.llms.base_llm.ocr.transformation import OCRResponse
|
||||
|
|
@ -41,6 +42,27 @@ def _binding(value: object) -> NativeOcrLifecycle | None:
|
|||
|
||||
NATIVE_OCR_LIFECYCLE: Final = NativeBinding("_ocr_lifecycle", validate=_binding)
|
||||
|
||||
_UPSTREAM_ARGS: Final = TypeAdapter(tuple[int, str])
|
||||
_UPSTREAM_HEADERS: Final = TypeAdapter(list[tuple[str, str]])
|
||||
|
||||
|
||||
class UpstreamFailure(Exception):
|
||||
def __init__(self, response: httpx.Response, cause: Exception) -> None:
|
||||
super().__init__(str(cause))
|
||||
self.message: Final = str(cause)
|
||||
self.response: Final = response
|
||||
self.status_code: Final = response.status_code
|
||||
self.__cause__ = cause
|
||||
|
||||
|
||||
def _upstream_failure(error: Exception) -> Exception:
|
||||
try:
|
||||
status, body = _UPSTREAM_ARGS.validate_python(error.args)
|
||||
headers: Final = _UPSTREAM_HEADERS.validate_python(getattr(error, "headers", None))
|
||||
except ValidationError:
|
||||
return error
|
||||
return UpstreamFailure(httpx.Response(status, content=body.encode(), headers=headers), error)
|
||||
|
||||
|
||||
def select(request: LiteLLMOcrRequest) -> NativeOcrLifecycle | None:
|
||||
if request.kwargs.get("aocr"):
|
||||
|
|
@ -63,18 +85,18 @@ def map_failure(error: Exception, request: LiteLLMOcrRequest, request_provider:
|
|||
mapper: Final = cast( # cast-ok: bounded adapter for the legacy public exception mapper
|
||||
ExceptionMapper, litellm.exception_type
|
||||
)
|
||||
original: Final = _upstream_failure(error)
|
||||
try:
|
||||
return mapper(
|
||||
model=model,
|
||||
custom_llm_provider=request_provider,
|
||||
original_exception=error,
|
||||
original_exception=original,
|
||||
completion_kwargs=dict(arguments(request)), # mutable-ok: exception mapper requires owned kwargs
|
||||
extra_kwargs=dict(request.kwargs), # mutable-ok: exception mapper requires owned kwargs
|
||||
)
|
||||
except Exception as public_error:
|
||||
response: Final = getattr(error, "response", None)
|
||||
if isinstance(response, httpx.Response):
|
||||
public_error.response = response
|
||||
public_error.status_code = response.status_code
|
||||
if isinstance(original, UpstreamFailure):
|
||||
public_error.response = original.response
|
||||
public_error.status_code = original.status_code
|
||||
public_error.__context__ = error
|
||||
return public_error
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ from tests.test_litellm_rust.support.recording_server import RecordingServer, Re
|
|||
from tests.test_litellm_rust.support.requests import (
|
||||
OCR_DOCUMENT,
|
||||
OCR_RESPONSE,
|
||||
call_native,
|
||||
call_native_aocr,
|
||||
call_native_ocr,
|
||||
)
|
||||
|
|
@ -43,10 +44,7 @@ async def test_ocr_contract_upstream_status(
|
|||
"num_retries": 0,
|
||||
}
|
||||
with pytest.raises(litellm.BadRequestError) as caught:
|
||||
if asynchronous:
|
||||
await call_native_aocr(ocr_server, **arguments)
|
||||
else:
|
||||
call_native_ocr(ocr_server, **arguments)
|
||||
await call_native(ocr_server, asynchronous, **arguments)
|
||||
assert caught.value.status_code == upstream.status
|
||||
assert caught.value.response.status_code == upstream.status
|
||||
|
||||
|
|
@ -64,10 +62,7 @@ async def test_ocr_contract_provider_error_details(
|
|||
headers: Final = {"Retry-After": "17", "X-Request-ID": "ocr-request-123", "X-Future-Header": "retained"}
|
||||
ocr_server.enqueue(ResponseSpec(body=payload, status=429, headers=headers))
|
||||
with pytest.raises(litellm.RateLimitError) as caught:
|
||||
if asynchronous:
|
||||
await call_native_aocr(ocr_server, num_retries=0)
|
||||
else:
|
||||
call_native_ocr(ocr_server, num_retries=0)
|
||||
await call_native(ocr_server, asynchronous, num_retries=0)
|
||||
response: Final = caught.value.response
|
||||
assert isinstance(response, httpx.Response)
|
||||
if preserved == "body":
|
||||
|
|
@ -87,10 +82,7 @@ async def test_ocr_contract_invalid_response_format(
|
|||
) -> None:
|
||||
ocr_server.expected_requests = 0
|
||||
with pytest.raises(litellm.UnsupportedParamsError) as caught:
|
||||
if asynchronous:
|
||||
await call_native_aocr(ocr_server, req_format="bogus", num_retries=0)
|
||||
else:
|
||||
call_native_ocr(ocr_server, req_format="bogus", num_retries=0)
|
||||
await call_native(ocr_server, asynchronous, req_format="bogus", num_retries=0)
|
||||
assert caught.value.status_code == 400
|
||||
for value in ("req_format", "bogus", "native", "litellm"):
|
||||
assert value in str(caught.value)
|
||||
|
|
@ -116,10 +108,7 @@ async def test_ocr_contract_malformed_document_is_actionable(
|
|||
) -> None:
|
||||
ocr_server.expected_requests = None
|
||||
with pytest.raises(litellm.BadRequestError) as caught:
|
||||
if asynchronous:
|
||||
await call_native_aocr(ocr_server, document=document, num_retries=0)
|
||||
else:
|
||||
call_native_ocr(ocr_server, document=document, num_retries=0)
|
||||
await call_native(ocr_server, asynchronous, document=document, num_retries=0)
|
||||
assert caught.value.status_code == 400
|
||||
assert field.lower() in str(caught.value).lower()
|
||||
assert "NoneType: None" not in str(caught.value)
|
||||
|
|
@ -141,10 +130,7 @@ async def test_ocr_contract_azure_invalid_options_are_bad_requests(
|
|||
ocr_server.expected_requests = 0
|
||||
arguments: Final = {"model": "azure_ai/doc-intelligence/prebuilt-read", option: value, "num_retries": 0}
|
||||
with pytest.raises(litellm.BadRequestError) as caught:
|
||||
if asynchronous:
|
||||
await call_native_aocr(ocr_server, **arguments)
|
||||
else:
|
||||
call_native_ocr(ocr_server, **arguments)
|
||||
await call_native(ocr_server, asynchronous, **arguments)
|
||||
assert caught.value.status_code == 400
|
||||
assert field in str(caught.value)
|
||||
assert ocr_server.requests == []
|
||||
|
|
|
|||
|
|
@ -42,6 +42,10 @@ async def call_native_aocr(server: RecordingServer, **kwargs: object) -> OCRResp
|
|||
return await call_aocr(server, **kwargs)
|
||||
|
||||
|
||||
async def call_native(server: RecordingServer, asynchronous: bool, **kwargs: object) -> OCRResponse:
|
||||
return await call_native_aocr(server, **kwargs) if asynchronous else call_native_ocr(server, **kwargs)
|
||||
|
||||
|
||||
def request_body(kwargs: dict[str, object]) -> dict[str, object]:
|
||||
additional_args = kwargs["additional_args"]
|
||||
assert isinstance(additional_args, dict)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue