fix: Add MIME type inference and default for file URIs in LiteLLM

This change ensures that file URI parts passed to LiteLLM always include a "format" field. If `mime_type` is not explicitly provided in `FileData`, the system attempts to infer it from the URI's file extension. If inference fails, a default "application/octet-stream" is used. This is necessary because LiteLLM's Vertex AI backend requires the "format" field for GCS URIs.

Close #3787

Co-authored-by: George Weale <gweale@google.com>
PiperOrigin-RevId: 843753810
This commit is contained in:
George Weale
2025-12-12 10:51:55 -08:00
committed by Copybara-Service
parent 8782a69503
commit 5c4bae7ff2
2 changed files with 258 additions and 15 deletions
+63
View File
@@ -18,6 +18,7 @@ import base64
import copy
import json
import logging
import mimetypes
import os
import re
import sys
@@ -33,6 +34,7 @@ from typing import Optional
from typing import Tuple
from typing import TypedDict
from typing import Union
from urllib.parse import urlparse
import uuid
import warnings
@@ -129,6 +131,51 @@ def _get_provider_from_model(model: str) -> str:
return ""
# Default MIME type when none can be inferred
_DEFAULT_MIME_TYPE = "application/octet-stream"
def _infer_mime_type_from_uri(uri: str) -> Optional[str]:
"""Attempts to infer MIME type from a URI's path extension.
Args:
uri: A URI string (e.g., 'gs://bucket/file.pdf' or
'https://example.com/doc.json')
Returns:
The inferred MIME type, or None if it cannot be determined.
"""
try:
parsed = urlparse(uri)
# Get the path component and extract filename
path = parsed.path
if not path:
return None
# Many artifact URIs are versioned (for example, ".../filename/0" or
# ".../filename/versions/0"). If the last path segment looks like a numeric
# version, infer from the preceding filename instead.
segments = [segment for segment in path.split("/") if segment]
if not segments:
return None
candidate = segments[-1]
if candidate.isdigit():
segments = segments[:-1]
if segments and segments[-1].lower() in ("versions", "version"):
segments = segments[:-1]
if not segments:
return None
candidate = segments[-1]
mime_type, _ = mimetypes.guess_type(candidate)
return mime_type
except (ValueError, AttributeError) as e:
logger.debug("Could not infer MIME type from URI %s: %s", uri, e)
return None
def _decode_inline_text_data(raw_bytes: bytes) -> str:
"""Decodes inline file bytes that represent textual content."""
try:
@@ -553,6 +600,22 @@ async def _get_content(
file_object: ChatCompletionFileUrlObject = {
"file_id": part.file_data.file_uri,
}
# Determine MIME type: use explicit value, infer from URI, or use default
mime_type = part.file_data.mime_type
if not mime_type:
mime_type = _infer_mime_type_from_uri(part.file_data.file_uri)
if not mime_type and part.file_data.display_name:
guessed_mime_type, _ = mimetypes.guess_type(part.file_data.display_name)
mime_type = guessed_mime_type
if not mime_type:
# LiteLLM's Vertex AI backend requires format for GCS URIs
mime_type = _DEFAULT_MIME_TYPE
logger.debug(
"Could not determine MIME type for file_uri %s, using default: %s",
part.file_data.file_uri,
mime_type,
)
file_object["format"] = mime_type
content_objects.append({
"type": "file",
"file": file_object,
+195 -15
View File
@@ -1574,13 +1574,13 @@ async def test_content_to_message_param_user_message_with_file_uri(
)
message = await _content_to_message_param(content)
assert message["role"] == "user"
assert isinstance(message["content"], list)
assert message["content"][0]["type"] == "text"
assert message["content"][0]["text"] == "Summarize this file."
assert message["content"][1]["type"] == "file"
assert message["content"][1]["file"]["file_id"] == file_uri
assert "format" not in message["content"][1]["file"]
assert message == {
"role": "user",
"content": [
{"type": "text", "text": "Summarize this file."},
{"type": "file", "file": {"file_id": file_uri, "format": mime_type}},
],
}
@pytest.mark.asyncio
@@ -1597,11 +1597,88 @@ async def test_content_to_message_param_user_message_file_uri_only(
)
message = await _content_to_message_param(content)
assert message["role"] == "user"
assert isinstance(message["content"], list)
assert message["content"][0]["type"] == "file"
assert message["content"][0]["file"]["file_id"] == file_uri
assert "format" not in message["content"][0]["file"]
assert message == {
"role": "user",
"content": [
{"type": "file", "file": {"file_id": file_uri, "format": mime_type}},
],
}
@pytest.mark.asyncio
async def test_content_to_message_param_user_message_file_uri_without_mime_type():
"""Test handling of file_data without mime_type (GcsArtifactService scenario).
When using GcsArtifactService, artifacts may have file_uri (gs://...) but
without mime_type set. LiteLLM's Vertex AI backend requires the format
field to be present, so we infer MIME type from the URI extension or use
a default fallback to ensure compatibility.
See: https://github.com/google/adk-python/issues/3787
"""
file_part = types.Part(
file_data=types.FileData(
file_uri="gs://agent-artifact-bucket/app/user/session/artifact/0"
)
)
content = types.Content(
role="user",
parts=[
types.Part.from_text(text="Analyze this file."),
file_part,
],
)
message = await _content_to_message_param(content)
assert message == {
"role": "user",
"content": [
{"type": "text", "text": "Analyze this file."},
{
"type": "file",
"file": {
"file_id": (
"gs://agent-artifact-bucket/app/user/session/artifact/0"
),
"format": "application/octet-stream",
},
},
],
}
@pytest.mark.asyncio
async def test_content_to_message_param_user_message_file_uri_infer_mime_type():
"""Test MIME type inference from file_uri extension.
When file_data has a file_uri with a recognizable extension but no explicit
mime_type, the MIME type should be inferred from the extension.
See: https://github.com/google/adk-python/issues/3787
"""
file_part = types.Part(
file_data=types.FileData(
file_uri="gs://bucket/path/to/document.pdf",
)
)
content = types.Content(
role="user",
parts=[file_part],
)
message = await _content_to_message_param(content)
assert message == {
"role": "user",
"content": [
{
"type": "file",
"file": {
"file_id": "gs://bucket/path/to/document.pdf",
"format": "application/pdf",
},
},
],
}
@pytest.mark.asyncio
@@ -1995,9 +2072,112 @@ async def test_get_content_file_bytes(file_data, mime_type, expected_base64):
async def test_get_content_file_uri(file_uri, mime_type):
parts = [types.Part.from_uri(file_uri=file_uri, mime_type=mime_type)]
content = await _get_content(parts)
assert content[0]["type"] == "file"
assert content[0]["file"]["file_id"] == file_uri
assert "format" not in content[0]["file"]
assert content[0] == {
"type": "file",
"file": {"file_id": file_uri, "format": mime_type},
}
@pytest.mark.asyncio
async def test_get_content_file_uri_infer_mime_type():
"""Test MIME type inference from file_uri extension.
When file_data has a file_uri with a recognizable extension but no explicit
mime_type, the MIME type should be inferred from the extension.
See: https://github.com/google/adk-python/issues/3787
"""
# Use Part constructor directly to test MIME type inference in _get_content
# (types.Part.from_uri does its own inference, so we bypass it)
parts = [
types.Part(
file_data=types.FileData(file_uri="gs://bucket/path/to/document.pdf")
)
]
content = await _get_content(parts)
assert content[0] == {
"type": "file",
"file": {
"file_id": "gs://bucket/path/to/document.pdf",
"format": "application/pdf",
},
}
@pytest.mark.asyncio
async def test_get_content_file_uri_versioned_infer_mime_type():
"""Test MIME type inference from versioned artifact URIs."""
parts = [
types.Part(
file_data=types.FileData(
file_uri="gs://bucket/path/to/document.pdf/0"
)
)
]
content = await _get_content(parts)
assert content[0]["file"]["format"] == "application/pdf"
@pytest.mark.asyncio
async def test_get_content_file_uri_infers_from_display_name():
"""Test MIME type inference from display_name when URI lacks extension."""
parts = [
types.Part(
file_data=types.FileData(
file_uri="gs://bucket/artifact/0",
display_name="document.pdf",
)
)
]
content = await _get_content(parts)
assert content[0]["file"]["format"] == "application/pdf"
@pytest.mark.asyncio
async def test_get_content_file_uri_default_mime_type():
"""Test that file_uri without extension uses default MIME type.
When file_data has a file_uri without a recognizable extension and no explicit
mime_type, a default MIME type should be used to ensure compatibility with
LiteLLM backends.
See: https://github.com/google/adk-python/issues/3787
"""
# Use Part constructor directly to create file_data without mime_type
# (types.Part.from_uri requires a valid mime_type when it can't infer)
parts = [
types.Part(file_data=types.FileData(file_uri="gs://bucket/artifact/0"))
]
content = await _get_content(parts)
assert content[0] == {
"type": "file",
"file": {
"file_id": "gs://bucket/artifact/0",
"format": "application/octet-stream",
},
}
@pytest.mark.asyncio
@pytest.mark.parametrize(
"uri,expected_mime_type",
[
("gs://bucket/file.pdf", "application/pdf"),
("gs://bucket/path/to/document.json", "application/json"),
("gs://bucket/image.png", "image/png"),
("gs://bucket/image.jpg", "image/jpeg"),
("gs://bucket/audio.mp3", "audio/mpeg"),
("gs://bucket/video.mp4", "video/mp4"),
],
)
async def test_get_content_file_uri_mime_type_inference(
uri, expected_mime_type
):
"""Test MIME type inference from various file extensions."""
# Use Part constructor directly to test MIME type inference in _get_content
parts = [types.Part(file_data=types.FileData(file_uri=uri))]
content = await _get_content(parts)
assert content[0]["file"]["format"] == expected_mime_type
@pytest.mark.asyncio