diff --git a/litellm/llms/base_llm/files/transformation.py b/litellm/llms/base_llm/files/transformation.py index ec0b28a63ad..cc36962da6b 100644 --- a/litellm/llms/base_llm/files/transformation.py +++ b/litellm/llms/base_llm/files/transformation.py @@ -135,6 +135,9 @@ class BaseFilesConfig(BaseConfig): ) -> OpenAIFileObject: """Transform file retrieve response into OpenAI format.""" + def is_retrieve_file_response_successful(self, response: httpx.Response) -> bool: + return not httpx.codes.is_error(response.status_code) + @abstractmethod def transform_delete_file_request( self, diff --git a/litellm/llms/bedrock/files/transformation.py b/litellm/llms/bedrock/files/transformation.py index c4aa84004dc..fe3d69303d1 100644 --- a/litellm/llms/bedrock/files/transformation.py +++ b/litellm/llms/bedrock/files/transformation.py @@ -79,8 +79,22 @@ LIST_FILES_PURPOSE_PARAM: Final = "_s3_list_files_purpose" LIST_FILES_LOCATION_PARAM: Final = "_s3_list_files_location" +def _is_empty_s3_object_range_error(raw_response: Response) -> bool: + if raw_response.status_code != 416: + return False + if raw_response.headers.get("Content-Range") == "bytes */0": + return True + try: + error_xml: Final = ET.fromstring(raw_response.content) + except ET.ParseError: + return False + return error_xml.findtext("ActualObjectSize") == "0" + + def _retrieved_s3_file_size(raw_response: Response) -> int: status_code: Final = raw_response.status_code + if _is_empty_s3_object_range_error(raw_response): + return 0 if status_code == 206: content_range: Final = raw_response.headers.get("Content-Range", "") range_parts: Final = content_range.removeprefix("bytes 0-0/") @@ -439,6 +453,9 @@ class BedrockFilesConfig(BaseAWSLLM, BaseFilesConfig): def custom_llm_provider(self) -> LlmProviders: return LlmProviders.BEDROCK + def is_retrieve_file_response_successful(self, response: httpx.Response) -> bool: + return not httpx.codes.is_error(response.status_code) or _is_empty_s3_object_range_error(response) + @property def file_upload_http_method(self) -> str: """ diff --git a/litellm/llms/custom_httpx/llm_http_handler.py b/litellm/llms/custom_httpx/llm_http_handler.py index b57ce8ea2e8..f09e6c2bfe0 100644 --- a/litellm/llms/custom_httpx/llm_http_handler.py +++ b/litellm/llms/custom_httpx/llm_http_handler.py @@ -4660,7 +4660,8 @@ class BaseLLMHTTPHandler: except Exception as e: raise self._handle_error(e=e, provider_config=provider_config) - self._raise_for_provider_error_status(response=response, provider_config=provider_config) + if not provider_config.is_retrieve_file_response_successful(response): + self._raise_for_provider_error_status(response=response, provider_config=provider_config) return provider_config.transform_retrieve_file_response( raw_response=response, logging_obj=logging_obj, @@ -4718,7 +4719,8 @@ class BaseLLMHTTPHandler: except Exception as e: raise self._handle_error(e=e, provider_config=provider_config) - self._raise_for_provider_error_status(response=response, provider_config=provider_config) + if not provider_config.is_retrieve_file_response_successful(response): + self._raise_for_provider_error_status(response=response, provider_config=provider_config) return provider_config.transform_retrieve_file_response( raw_response=response, logging_obj=logging_obj, diff --git a/tests/unit/llms/bedrock/files/test_bedrock_files_transformation.py b/tests/unit/llms/bedrock/files/test_bedrock_files_transformation.py index a647b94b6e8..1a919e7b6ad 100644 --- a/tests/unit/llms/bedrock/files/test_bedrock_files_transformation.py +++ b/tests/unit/llms/bedrock/files/test_bedrock_files_transformation.py @@ -2276,7 +2276,33 @@ class TestBedrockFileContentTransformation: assert response.status == "processed" assert response.object == "file" - def test_transform_retrieve_file_response_raises_for_empty_object(self, monkeypatch): + def test_transform_retrieve_file_response_accepts_verified_empty_object(self, monkeypatch): + import httpx + + from litellm.llms.bedrock.files.transformation import BedrockFilesConfig + + monkeypatch.setenv("AWS_S3_BUCKET_NAME", "my-bucket") + litellm_params = self._litellm_params() + BedrockFilesConfig().transform_retrieve_file_request( + file_id=self.S3_URI, + optional_params={}, + litellm_params=litellm_params, + ) + response = BedrockFilesConfig().transform_retrieve_file_response( + raw_response=httpx.Response( + 416, + content=b"InvalidRange0", + request=httpx.Request("GET", self.S3_URI), + ), + logging_obj=MagicMock(), + litellm_params=litellm_params, + ) + + assert response.bytes == 0 + assert response.filename == "input.jsonl.out" + assert response.purpose == "batch_output" + + def test_transform_retrieve_file_response_rejects_unverified_empty_object(self, monkeypatch): import httpx from litellm.llms.bedrock.common_utils import BedrockError @@ -2293,7 +2319,7 @@ class TestBedrockFileContentTransformation: BedrockFilesConfig().transform_retrieve_file_response( raw_response=httpx.Response( 416, - headers={"Content-Range": "bytes */0"}, + content=b"InvalidRange", request=httpx.Request("GET", self.S3_URI), ), logging_obj=MagicMock(), diff --git a/tests/unit/llms/custom_httpx/test_llm_http_handler.py b/tests/unit/llms/custom_httpx/test_llm_http_handler.py index 82cc9e9d3ac..25d2d11b5ca 100644 --- a/tests/unit/llms/custom_httpx/test_llm_http_handler.py +++ b/tests/unit/llms/custom_httpx/test_llm_http_handler.py @@ -45,6 +45,7 @@ from litellm.llms.azure.videos.transformation import AzureVideoConfig from litellm.llms.bedrock.messages.invoke_transformations.anthropic_claude3_transformation import ( AmazonAnthropicClaudeMessagesConfig, ) +from litellm.llms.bedrock.files.transformation import BedrockFilesConfig from litellm.llms.anthropic.skills.transformation import AnthropicSkillsConfig from litellm.llms.openai.evals.transformation import OpenAIEvalsConfig from litellm.llms.mistral.files.transformation import MistralFilesConfig @@ -4925,3 +4926,133 @@ async def test_lookup_handlers_raise_the_provider_error_status(name: str, is_asy assert error.value.status_code == status_code assert "No such object" in error.value.message + + +@pytest.mark.asyncio +@pytest.mark.parametrize("is_async", (False, True)) +@pytest.mark.parametrize( + ("status_code", "content", "headers", "expected_bytes"), + ( + ( + 416, + b"InvalidRange0", + {}, + 0, + ), + (206, b"", {"Content-Range": "bytes 0-0/4321"}, 4321), + ), +) +async def test_retrieve_file_accepts_bedrock_successful_range_responses( + is_async: bool, + status_code: int, + content: bytes, + headers: dict[str, str], + expected_bytes: int, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("AWS_S3_BUCKET_NAME", "my-bucket") + file_id = "s3://my-bucket/litellm-batch-outputs/job-123/output.jsonl" + transport = httpx.MockTransport( + lambda request: httpx.Response( + status_code, + content=content, + headers=headers, + request=request, + ) + ) + params = { + "aws_access_key_id": "AKIAEXAMPLE", + "aws_secret_access_key": "secret", + "aws_region_name": "us-west-2", + } + handler = BaseLLMHTTPHandler() + + if is_async: + client = AsyncHTTPHandler() + await client.close() + async_client = httpx.AsyncClient(transport=transport) + client.client = async_client + try: + result = await handler.async_retrieve_file( + file_id=file_id, + provider_config=BedrockFilesConfig(), + litellm_params=params, + headers={}, + logging_obj=Mock(), + client=client, + ) + finally: + await async_client.aclose() + else: + sync_client = httpx.Client(transport=transport) + client = HTTPHandler(client=sync_client) + try: + result = handler.retrieve_file( + file_id=file_id, + provider_config=BedrockFilesConfig(), + litellm_params=params, + headers={}, + logging_obj=Mock(), + client=client, + ) + finally: + sync_client.close() + + assert result.bytes == expected_bytes + assert result.filename == "output.jsonl" + assert result.purpose == "batch_output" + + +@pytest.mark.asyncio +@pytest.mark.parametrize("is_async", (False, True)) +async def test_retrieve_file_rejects_unverified_bedrock_range_error( + is_async: bool, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("AWS_S3_BUCKET_NAME", "my-bucket") + file_id = "s3://my-bucket/litellm-batch-outputs/job-123/output.jsonl" + transport = httpx.MockTransport( + lambda request: httpx.Response( + 416, + content=b"InvalidRange", + request=request, + ) + ) + params = { + "aws_access_key_id": "AKIAEXAMPLE", + "aws_secret_access_key": "secret", + "aws_region_name": "us-west-2", + } + handler = BaseLLMHTTPHandler() + + if is_async: + client = AsyncHTTPHandler() + await client.close() + async_client = httpx.AsyncClient(transport=transport) + client.client = async_client + try: + with pytest.raises(BaseLLMException, match="InvalidRange"): + await handler.async_retrieve_file( + file_id=file_id, + provider_config=BedrockFilesConfig(), + litellm_params=params, + headers={}, + logging_obj=Mock(), + client=client, + ) + finally: + await async_client.aclose() + else: + sync_client = httpx.Client(transport=transport) + client = HTTPHandler(client=sync_client) + try: + with pytest.raises(BaseLLMException, match="InvalidRange"): + handler.retrieve_file( + file_id=file_id, + provider_config=BedrockFilesConfig(), + litellm_params=params, + headers={}, + logging_obj=Mock(), + client=client, + ) + finally: + sync_client.close()