Skip to content

Commit 5c4bae7

Browse files
GWealecopybara-github
authored andcommitted
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 <[email protected]> PiperOrigin-RevId: 843753810
1 parent 8782a69 commit 5c4bae7

File tree

2 files changed

+258
-15
lines changed

2 files changed

+258
-15
lines changed

src/google/adk/models/lite_llm.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
import copy
1919
import json
2020
import logging
21+
import mimetypes
2122
import os
2223
import re
2324
import sys
@@ -33,6 +34,7 @@
3334
from typing import Tuple
3435
from typing import TypedDict
3536
from typing import Union
37+
from urllib.parse import urlparse
3638
import uuid
3739
import warnings
3840

@@ -129,6 +131,51 @@ def _get_provider_from_model(model: str) -> str:
129131
return ""
130132

131133

134+
# Default MIME type when none can be inferred
135+
_DEFAULT_MIME_TYPE = "application/octet-stream"
136+
137+
138+
def _infer_mime_type_from_uri(uri: str) -> Optional[str]:
139+
"""Attempts to infer MIME type from a URI's path extension.
140+
141+
Args:
142+
uri: A URI string (e.g., 'gs://bucket/file.pdf' or
143+
'https://example.com/doc.json')
144+
145+
Returns:
146+
The inferred MIME type, or None if it cannot be determined.
147+
"""
148+
try:
149+
parsed = urlparse(uri)
150+
# Get the path component and extract filename
151+
path = parsed.path
152+
if not path:
153+
return None
154+
155+
# Many artifact URIs are versioned (for example, ".../filename/0" or
156+
# ".../filename/versions/0"). If the last path segment looks like a numeric
157+
# version, infer from the preceding filename instead.
158+
segments = [segment for segment in path.split("/") if segment]
159+
if not segments:
160+
return None
161+
162+
candidate = segments[-1]
163+
if candidate.isdigit():
164+
segments = segments[:-1]
165+
if segments and segments[-1].lower() in ("versions", "version"):
166+
segments = segments[:-1]
167+
168+
if not segments:
169+
return None
170+
171+
candidate = segments[-1]
172+
mime_type, _ = mimetypes.guess_type(candidate)
173+
return mime_type
174+
except (ValueError, AttributeError) as e:
175+
logger.debug("Could not infer MIME type from URI %s: %s", uri, e)
176+
return None
177+
178+
132179
def _decode_inline_text_data(raw_bytes: bytes) -> str:
133180
"""Decodes inline file bytes that represent textual content."""
134181
try:
@@ -553,6 +600,22 @@ async def _get_content(
553600
file_object: ChatCompletionFileUrlObject = {
554601
"file_id": part.file_data.file_uri,
555602
}
603+
# Determine MIME type: use explicit value, infer from URI, or use default
604+
mime_type = part.file_data.mime_type
605+
if not mime_type:
606+
mime_type = _infer_mime_type_from_uri(part.file_data.file_uri)
607+
if not mime_type and part.file_data.display_name:
608+
guessed_mime_type, _ = mimetypes.guess_type(part.file_data.display_name)
609+
mime_type = guessed_mime_type
610+
if not mime_type:
611+
# LiteLLM's Vertex AI backend requires format for GCS URIs
612+
mime_type = _DEFAULT_MIME_TYPE
613+
logger.debug(
614+
"Could not determine MIME type for file_uri %s, using default: %s",
615+
part.file_data.file_uri,
616+
mime_type,
617+
)
618+
file_object["format"] = mime_type
556619
content_objects.append({
557620
"type": "file",
558621
"file": file_object,

tests/unittests/models/test_litellm.py

Lines changed: 195 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1574,13 +1574,13 @@ async def test_content_to_message_param_user_message_with_file_uri(
15741574
)
15751575

15761576
message = await _content_to_message_param(content)
1577-
assert message["role"] == "user"
1578-
assert isinstance(message["content"], list)
1579-
assert message["content"][0]["type"] == "text"
1580-
assert message["content"][0]["text"] == "Summarize this file."
1581-
assert message["content"][1]["type"] == "file"
1582-
assert message["content"][1]["file"]["file_id"] == file_uri
1583-
assert "format" not in message["content"][1]["file"]
1577+
assert message == {
1578+
"role": "user",
1579+
"content": [
1580+
{"type": "text", "text": "Summarize this file."},
1581+
{"type": "file", "file": {"file_id": file_uri, "format": mime_type}},
1582+
],
1583+
}
15841584

15851585

15861586
@pytest.mark.asyncio
@@ -1597,11 +1597,88 @@ async def test_content_to_message_param_user_message_file_uri_only(
15971597
)
15981598

15991599
message = await _content_to_message_param(content)
1600-
assert message["role"] == "user"
1601-
assert isinstance(message["content"], list)
1602-
assert message["content"][0]["type"] == "file"
1603-
assert message["content"][0]["file"]["file_id"] == file_uri
1604-
assert "format" not in message["content"][0]["file"]
1600+
assert message == {
1601+
"role": "user",
1602+
"content": [
1603+
{"type": "file", "file": {"file_id": file_uri, "format": mime_type}},
1604+
],
1605+
}
1606+
1607+
1608+
@pytest.mark.asyncio
1609+
async def test_content_to_message_param_user_message_file_uri_without_mime_type():
1610+
"""Test handling of file_data without mime_type (GcsArtifactService scenario).
1611+
1612+
When using GcsArtifactService, artifacts may have file_uri (gs://...) but
1613+
without mime_type set. LiteLLM's Vertex AI backend requires the format
1614+
field to be present, so we infer MIME type from the URI extension or use
1615+
a default fallback to ensure compatibility.
1616+
1617+
See: https://github.com/google/adk-python/issues/3787
1618+
"""
1619+
file_part = types.Part(
1620+
file_data=types.FileData(
1621+
file_uri="gs://agent-artifact-bucket/app/user/session/artifact/0"
1622+
)
1623+
)
1624+
content = types.Content(
1625+
role="user",
1626+
parts=[
1627+
types.Part.from_text(text="Analyze this file."),
1628+
file_part,
1629+
],
1630+
)
1631+
1632+
message = await _content_to_message_param(content)
1633+
assert message == {
1634+
"role": "user",
1635+
"content": [
1636+
{"type": "text", "text": "Analyze this file."},
1637+
{
1638+
"type": "file",
1639+
"file": {
1640+
"file_id": (
1641+
"gs://agent-artifact-bucket/app/user/session/artifact/0"
1642+
),
1643+
"format": "application/octet-stream",
1644+
},
1645+
},
1646+
],
1647+
}
1648+
1649+
1650+
@pytest.mark.asyncio
1651+
async def test_content_to_message_param_user_message_file_uri_infer_mime_type():
1652+
"""Test MIME type inference from file_uri extension.
1653+
1654+
When file_data has a file_uri with a recognizable extension but no explicit
1655+
mime_type, the MIME type should be inferred from the extension.
1656+
1657+
See: https://github.com/google/adk-python/issues/3787
1658+
"""
1659+
file_part = types.Part(
1660+
file_data=types.FileData(
1661+
file_uri="gs://bucket/path/to/document.pdf",
1662+
)
1663+
)
1664+
content = types.Content(
1665+
role="user",
1666+
parts=[file_part],
1667+
)
1668+
1669+
message = await _content_to_message_param(content)
1670+
assert message == {
1671+
"role": "user",
1672+
"content": [
1673+
{
1674+
"type": "file",
1675+
"file": {
1676+
"file_id": "gs://bucket/path/to/document.pdf",
1677+
"format": "application/pdf",
1678+
},
1679+
},
1680+
],
1681+
}
16051682

16061683

16071684
@pytest.mark.asyncio
@@ -1995,9 +2072,112 @@ async def test_get_content_file_bytes(file_data, mime_type, expected_base64):
19952072
async def test_get_content_file_uri(file_uri, mime_type):
19962073
parts = [types.Part.from_uri(file_uri=file_uri, mime_type=mime_type)]
19972074
content = await _get_content(parts)
1998-
assert content[0]["type"] == "file"
1999-
assert content[0]["file"]["file_id"] == file_uri
2000-
assert "format" not in content[0]["file"]
2075+
assert content[0] == {
2076+
"type": "file",
2077+
"file": {"file_id": file_uri, "format": mime_type},
2078+
}
2079+
2080+
2081+
@pytest.mark.asyncio
2082+
async def test_get_content_file_uri_infer_mime_type():
2083+
"""Test MIME type inference from file_uri extension.
2084+
2085+
When file_data has a file_uri with a recognizable extension but no explicit
2086+
mime_type, the MIME type should be inferred from the extension.
2087+
2088+
See: https://github.com/google/adk-python/issues/3787
2089+
"""
2090+
# Use Part constructor directly to test MIME type inference in _get_content
2091+
# (types.Part.from_uri does its own inference, so we bypass it)
2092+
parts = [
2093+
types.Part(
2094+
file_data=types.FileData(file_uri="gs://bucket/path/to/document.pdf")
2095+
)
2096+
]
2097+
content = await _get_content(parts)
2098+
assert content[0] == {
2099+
"type": "file",
2100+
"file": {
2101+
"file_id": "gs://bucket/path/to/document.pdf",
2102+
"format": "application/pdf",
2103+
},
2104+
}
2105+
2106+
2107+
@pytest.mark.asyncio
2108+
async def test_get_content_file_uri_versioned_infer_mime_type():
2109+
"""Test MIME type inference from versioned artifact URIs."""
2110+
parts = [
2111+
types.Part(
2112+
file_data=types.FileData(
2113+
file_uri="gs://bucket/path/to/document.pdf/0"
2114+
)
2115+
)
2116+
]
2117+
content = await _get_content(parts)
2118+
assert content[0]["file"]["format"] == "application/pdf"
2119+
2120+
2121+
@pytest.mark.asyncio
2122+
async def test_get_content_file_uri_infers_from_display_name():
2123+
"""Test MIME type inference from display_name when URI lacks extension."""
2124+
parts = [
2125+
types.Part(
2126+
file_data=types.FileData(
2127+
file_uri="gs://bucket/artifact/0",
2128+
display_name="document.pdf",
2129+
)
2130+
)
2131+
]
2132+
content = await _get_content(parts)
2133+
assert content[0]["file"]["format"] == "application/pdf"
2134+
2135+
2136+
@pytest.mark.asyncio
2137+
async def test_get_content_file_uri_default_mime_type():
2138+
"""Test that file_uri without extension uses default MIME type.
2139+
2140+
When file_data has a file_uri without a recognizable extension and no explicit
2141+
mime_type, a default MIME type should be used to ensure compatibility with
2142+
LiteLLM backends.
2143+
2144+
See: https://github.com/google/adk-python/issues/3787
2145+
"""
2146+
# Use Part constructor directly to create file_data without mime_type
2147+
# (types.Part.from_uri requires a valid mime_type when it can't infer)
2148+
parts = [
2149+
types.Part(file_data=types.FileData(file_uri="gs://bucket/artifact/0"))
2150+
]
2151+
content = await _get_content(parts)
2152+
assert content[0] == {
2153+
"type": "file",
2154+
"file": {
2155+
"file_id": "gs://bucket/artifact/0",
2156+
"format": "application/octet-stream",
2157+
},
2158+
}
2159+
2160+
2161+
@pytest.mark.asyncio
2162+
@pytest.mark.parametrize(
2163+
"uri,expected_mime_type",
2164+
[
2165+
("gs://bucket/file.pdf", "application/pdf"),
2166+
("gs://bucket/path/to/document.json", "application/json"),
2167+
("gs://bucket/image.png", "image/png"),
2168+
("gs://bucket/image.jpg", "image/jpeg"),
2169+
("gs://bucket/audio.mp3", "audio/mpeg"),
2170+
("gs://bucket/video.mp4", "video/mp4"),
2171+
],
2172+
)
2173+
async def test_get_content_file_uri_mime_type_inference(
2174+
uri, expected_mime_type
2175+
):
2176+
"""Test MIME type inference from various file extensions."""
2177+
# Use Part constructor directly to test MIME type inference in _get_content
2178+
parts = [types.Part(file_data=types.FileData(file_uri=uri))]
2179+
content = await _get_content(parts)
2180+
assert content[0]["file"]["format"] == expected_mime_type
20012181

20022182

20032183
@pytest.mark.asyncio

0 commit comments

Comments
 (0)