From 2a863f8bdd19cfea2e5b95a14433d6091853c599 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Thu, 20 Aug 2026 16:46:26 -0700 Subject: [PATCH] fix(containers): surface provider errors from container file content endpoint (#37737) The generic container handler returned response.content for endpoints marked returns_binary before it ran any status or error check, so a non-2xx answer from the provider was handed back to the caller as raw bytes. Asking for the content of a container file that does not exist returned the provider's 404 error body as an opaque payload instead of raising. Move the check ahead of the binary short-circuit and apply it to every container file endpoint, falling back to the response text when the error body is not JSON. --- .../llms/custom_httpx/container_handler.py | 101 +++++++++-------- .../custom_httpx/test_container_handler.py | 102 ++++++++++++++++++ 2 files changed, 161 insertions(+), 42 deletions(-) create mode 100644 tests/test_litellm/llms/custom_httpx/test_container_handler.py diff --git a/litellm/llms/custom_httpx/container_handler.py b/litellm/llms/custom_httpx/container_handler.py index 7690351e3b2..91d68aa3bfb 100644 --- a/litellm/llms/custom_httpx/container_handler.py +++ b/litellm/llms/custom_httpx/container_handler.py @@ -39,6 +39,10 @@ RESPONSE_TYPES: Final[dict[str, type]] = { "DeleteContainerFileResponse": DeleteContainerFileResponse, } +ContainerEndpointResponse = ( + ContainerFileListResponse | ContainerFileObject | DeleteContainerFileResponse | bytes | dict[str, object] +) + def _load_endpoints_config() -> dict: """Load the endpoints configuration from JSON file.""" @@ -101,6 +105,51 @@ def _build_query_params( return params +def _error_message_from_response(response: httpx.Response) -> str: + try: + body: Final = response.json() + except ValueError: + return response.text + + if isinstance(body, dict) and isinstance(body.get("error"), dict): + message: Final = body["error"].get("message") + if isinstance(message, str): + return message + + return response.text + + +def _transform_response( + response: httpx.Response, + returns_binary: bool, + response_type_name: str, +) -> ContainerEndpointResponse: + from litellm.llms.base_llm.chat.transformation import BaseLLMException + + if httpx.codes.is_error(response.status_code): + raise BaseLLMException( + status_code=response.status_code, + message=_error_message_from_response(response), + headers=dict(response.headers), + ) + + if returns_binary: + return response.content + + response_json: Final = response.json() + if "error" in response_json: + raise BaseLLMException( + status_code=response.status_code, + message=response_json.get("error", {}).get("message", str(response_json)), + headers=dict(response.headers), + ) + + response_type: Final = RESPONSE_TYPES.get(response_type_name) + if response_type: + return response_type(**response_json) + return response_json + + def _prepare_multipart_file_upload( file: Any, headers: dict[str, Any], @@ -270,27 +319,11 @@ class GenericContainerHandler: else: raise ValueError(f"Unsupported HTTP method: {method}") - # For binary responses, return raw content - if returns_binary: - return response.content - - # Check for error response - response_json: Final = response.json() - if "error" in response_json: - from litellm.llms.base_llm.chat.transformation import BaseLLMException - - error_msg: Final = response_json.get("error", {}).get("message", str(response_json)) - raise BaseLLMException( - status_code=response.status_code, - message=error_msg, - headers=dict(response.headers), - ) - - # Parse response - response_type: Final = RESPONSE_TYPES.get(endpoint_config["response_type"]) - if response_type: - return response_type(**response_json) - return response_json + return _transform_response( + response=response, + returns_binary=returns_binary, + response_type_name=endpoint_config["response_type"], + ) except Exception as e: raise e @@ -378,27 +411,11 @@ class GenericContainerHandler: else: raise ValueError(f"Unsupported HTTP method: {method}") - # For binary responses, return raw content - if returns_binary: - return response.content - - # Check for error response - response_json: Final = response.json() - if "error" in response_json: - from litellm.llms.base_llm.chat.transformation import BaseLLMException - - error_msg: Final = response_json.get("error", {}).get("message", str(response_json)) - raise BaseLLMException( - status_code=response.status_code, - message=error_msg, - headers=dict(response.headers), - ) - - # Parse response - response_type: Final = RESPONSE_TYPES.get(endpoint_config["response_type"]) - if response_type: - return response_type(**response_json) - return response_json + return _transform_response( + response=response, + returns_binary=returns_binary, + response_type_name=endpoint_config["response_type"], + ) except Exception as e: raise e diff --git a/tests/test_litellm/llms/custom_httpx/test_container_handler.py b/tests/test_litellm/llms/custom_httpx/test_container_handler.py new file mode 100644 index 00000000000..a1b5a66696d --- /dev/null +++ b/tests/test_litellm/llms/custom_httpx/test_container_handler.py @@ -0,0 +1,102 @@ +from unittest.mock import MagicMock + +import httpx +import pytest + +import litellm +from litellm.llms.base_llm.chat.transformation import BaseLLMException +from litellm.llms.custom_httpx.container_handler import generic_container_handler +from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler +from litellm.types.router import GenericLiteLLMParams +from litellm.utils import ProviderConfigManager + +FILE_NOT_FOUND_BODY = { + "error": { + "message": "File not found.", + "type": "invalid_request_error", + "param": None, + "code": None, + } +} + + +def _sync_client(response: httpx.Response) -> HTTPHandler: + handler = HTTPHandler() + handler.client = httpx.Client(transport=httpx.MockTransport(lambda _request: response)) + return handler + + +def _async_client(response: httpx.Response) -> AsyncHTTPHandler: + handler = AsyncHTTPHandler() + handler.client = httpx.AsyncClient(transport=httpx.MockTransport(lambda _request: response)) + return handler + + +def _handle(endpoint_name: str, client, **overrides): + return generic_container_handler.handle( + endpoint_name=endpoint_name, + container_provider_config=ProviderConfigManager.get_provider_container_config( + provider=litellm.LlmProviders.OPENAI + ), + litellm_params=GenericLiteLLMParams(api_key="sk-test"), + logging_obj=MagicMock(), + client=client, + container_id="cntr_real", + file_id="cfile_nonexistent", + **overrides, + ) + + +def test_binary_endpoint_raises_on_error_status(): + with pytest.raises(BaseLLMException) as exc_info: + _handle( + "retrieve_container_file_content", + _sync_client(httpx.Response(404, json=FILE_NOT_FOUND_BODY)), + ) + + assert exc_info.value.status_code == 404 + assert exc_info.value.message == "File not found." + + +@pytest.mark.asyncio +async def test_async_binary_endpoint_raises_on_error_status(): + with pytest.raises(BaseLLMException) as exc_info: + await _handle( + "aretrieve_container_file_content", + _async_client(httpx.Response(404, json=FILE_NOT_FOUND_BODY)), + _is_async=True, + ) + + assert exc_info.value.status_code == 404 + assert exc_info.value.message == "File not found." + + +def test_binary_endpoint_returns_raw_content_on_success(): + content = _handle( + "retrieve_container_file_content", + _sync_client(httpx.Response(200, content=b"\x00binary-payload")), + ) + + assert content == b"\x00binary-payload" + + +def test_error_status_with_non_json_body_surfaces_response_text(): + with pytest.raises(BaseLLMException) as exc_info: + _handle( + "retrieve_container_file_content", + _sync_client(httpx.Response(502, content=b"bad gateway")), + ) + + assert exc_info.value.status_code == 502 + assert exc_info.value.message == "bad gateway" + + +def test_json_endpoint_still_raises_provider_error_message(): + with pytest.raises(BaseLLMException) as exc_info: + _handle( + "retrieve_container_file", + _sync_client(httpx.Response(404, json=FILE_NOT_FOUND_BODY)), + ) + + assert exc_info.value.status_code == 404 + assert exc_info.value.message == "File not found."