Merge pull request #36392 from BerriAI/devin_ai_fix_bedrock_batch_file_bytes_36388

fix(bedrock): report uploaded size in the FileObject returned by managed batch uploads
This commit is contained in:
Mateo Wang 2026-08-17 10:00:53 -07:00 • committed by GitHub
commit a1644eaf84
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 73 additions and 7 deletions

View file

@ -64,6 +64,10 @@ from ..common_utils import BedrockError, merge_bedrock_aws_request_params, resol
# 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, object]]) -> Mapping[str, object]:
return MappingProxyType(dict(items))
@ -197,6 +201,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
@ -924,6 +940,8 @@ class BedrockFilesConfig(BaseAWSLLM, BaseFilesConfig):
)
litellm_params["upload_url"] = api_base
upload_content_length: Final = len(file_content.encode("utf-8"))
litellm_params[UPLOAD_CONTENT_LENGTH_PARAM] = upload_content_length # rebind-ok: same handoff as upload_url
# Return a dict that tells the HTTP handler exactly what to do
return {
@ -1081,12 +1099,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[str] = 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 = ""
@ -1101,7 +1113,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",
)

View file

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