fix(bedrock): return metadata for empty S3 objects

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
mrinal 2026-10-08 17:09:17 +00:00
parent 9721d9f82b
commit a04b2437e4
5 changed files with 183 additions and 4 deletions

View file

@ -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,

View file

@ -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:
"""

View file

@ -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,

View file

@ -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"<Error><Code>InvalidRange</Code><ActualObjectSize>0</ActualObjectSize></Error>",
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"<Error><Code>InvalidRange</Code></Error>",
request=httpx.Request("GET", self.S3_URI),
),
logging_obj=MagicMock(),

View file

@ -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"<Error><Code>InvalidRange</Code><ActualObjectSize>0</ActualObjectSize></Error>",
{},
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"<Error><Code>InvalidRange</Code></Error>",
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()