From 1b2430b8b6c468a017bea31d8d7d7b3164976d1f Mon Sep 17 00:00:00 2001 From: devin-ai-integration Date: Mon, 10 Aug 2026 10:25:37 +0000 Subject: [PATCH] fix(bedrock): report uploaded size in the FileObject returned by managed batch uploads --- litellm/llms/bedrock/files/transformation.py | 25 ++++++--- .../test_bedrock_files_transformation.py | 54 +++++++++++++++++++ 2 files changed, 72 insertions(+), 7 deletions(-) diff --git a/litellm/llms/bedrock/files/transformation.py b/litellm/llms/bedrock/files/transformation.py index bd3570d50a3..d5e7957a1a9 100644 --- a/litellm/llms/bedrock/files/transformation.py +++ b/litellm/llms/bedrock/files/transformation.py @@ -62,6 +62,10 @@ from ..common_utils import BedrockError, resolve_s3_encryption_key_id # Same pattern as the `upload_url` handoff in `transform_create_file_request`. S3_SIGNED_GET_HEADERS_PARAM: Final = "_s3_signed_get_headers" +# litellm_params key carrying the size of the body uploaded to S3, handed from +# `transform_create_file_request` to `transform_create_file_response`. +UPLOAD_CONTENT_LENGTH_PARAM: Final = "_s3_upload_content_length" + def _frozen_mapping(items: Iterable[tuple[str, Any]]) -> Mapping[str, Any]: return MappingProxyType(dict(items)) @@ -154,6 +158,18 @@ def get_configured_s3_bucket_name(litellm_params: Mapping[str, object]) -> str: return bucket_name +def _uploaded_object_size(litellm_params: Mapping[str, object], raw_response: Response) -> int: + """ + S3 answers PutObject with an empty body, so the stored object size comes from the + signed request recorded by `transform_create_file_request`, not the response headers. + """ + uploaded_size: Final = litellm_params.get(UPLOAD_CONTENT_LENGTH_PARAM) + if isinstance(uploaded_size, int): + return uploaded_size + response_content_length: Final = raw_response.headers.get("Content-Length", "0") + return int(response_content_length) if response_content_length.isdigit() else 0 + + class BedrockFilesConfig(BaseAWSLLM, BaseFilesConfig): """ Config for Bedrock Files - handles S3 uploads for Bedrock batch processing @@ -861,6 +877,7 @@ class BedrockFilesConfig(BaseAWSLLM, BaseFilesConfig): ) litellm_params["upload_url"] = api_base + litellm_params[UPLOAD_CONTENT_LENGTH_PARAM] = len(file_content.encode("utf-8")) # Return a dict that tells the HTTP handler exactly what to do return { @@ -1018,12 +1035,6 @@ class BedrockFilesConfig(BaseAWSLLM, BaseFilesConfig): """ Transform S3 File upload response into OpenAI-style FileObject """ - # For S3 uploads, we typically get an ETag and other metadata - response_headers: Final = raw_response.headers - # Extract S3 object information from the response - # S3 PUT object returns ETag and other metadata in headers - content_length: Final = response_headers.get("Content-Length", "0") - # Use the actual upload URL that was used for the S3 upload upload_url: Final = litellm_params.get("upload_url") file_id: str = "" @@ -1038,7 +1049,7 @@ class BedrockFilesConfig(BaseAWSLLM, BaseFilesConfig): filename=filename, created_at=int(time.time()), # Current timestamp status="uploaded", - bytes=int(content_length) if content_length.isdigit() else 0, + bytes=_uploaded_object_size(litellm_params=litellm_params, raw_response=raw_response), object="file", ) diff --git a/tests/test_litellm/llms/bedrock/files/test_bedrock_files_transformation.py b/tests/test_litellm/llms/bedrock/files/test_bedrock_files_transformation.py index 270add48e0e..e0b235d68fd 100644 --- a/tests/test_litellm/llms/bedrock/files/test_bedrock_files_transformation.py +++ b/tests/test_litellm/llms/bedrock/files/test_bedrock_files_transformation.py @@ -586,6 +586,60 @@ class TestBedrockFilesTransformation: assert "x-amz-server-side-encryption" not in headers assert "x-amz-server-side-encryption-aws-kms-key-id" not in headers + def test_create_file_response_reports_uploaded_object_size(self): + """ + S3 answers PutObject with an empty body, so the returned FileObject must report the + size of the body that was uploaded instead of the response's Content-Length (always 0). + """ + import httpx + + from litellm.llms.bedrock.files.transformation import BedrockFilesConfig + + config = BedrockFilesConfig() + litellm_params: dict = {"s3_bucket_name": "litellm-batch-bucket"} + jsonl_content = json.dumps( + { + "custom_id": "req-1", + "method": "POST", + "url": "/v1/chat/completions", + "body": { + "model": "bedrock/amazon.nova-pro-v1:0", + "messages": [{"role": "user", "content": "Hello"}], + "max_tokens": 10, + }, + } + ).encode() + + request = config.transform_create_file_request( + model="amazon.nova-pro-v1:0", + create_file_data={ + "file": ("batch.jsonl", jsonl_content, "application/jsonl"), + "purpose": "batch", + }, + optional_params={ + "aws_access_key_id": "test-key-id", + "aws_secret_access_key": "test-secret", + "aws_region_name": "us-west-2", + }, + litellm_params=litellm_params, + ) + assert isinstance(request, dict) + uploaded_size = len(request["data"].encode("utf-8")) + assert uploaded_size > 0 + + file_object = config.transform_create_file_response( + model=None, + raw_response=httpx.Response( + status_code=200, + headers={"Content-Length": "0", "ETag": '"abc123"'}, + content=b"", + ), + logging_obj=MagicMock(), + litellm_params=litellm_params, + ) + + assert file_object.bytes == uploaded_size + def test_openai_passthrough_still_works(self): """ Regression test: ensure OpenAI-compatible models (e.g. gpt-oss)