Skip to content

Commit c303c62

Browse files
1wosGWeale
authored andcommitted
fix: propagate Gemini grounding metadata from ModelResponse
Merge #5661 ### Link to Issue - Closes: #5659 ### Problem LiteLLM exposes Gemini's grounding metadata on `ModelResponse.vertex_ai_grounding_metadata` rather than inside the message, and `_model_response_to_generate_content_response` in `lite_llm.py` never reads it. As a result `LlmResponse.grounding_metadata` is always `None` when Gemini is called through `LiteLlm`, breaking `event.grounding_metadata`, `after_model_callback`, and citation pipelines for the entire LiteLlm path. The native `Gemini()` path picks grounding up from `candidate.grounding_metadata` (`llm_response.py:190`). ### Solution Pull `vertex_ai_grounding_metadata` off the `ModelResponse` after the message is converted, and attach it to `LlmResponse.grounding_metadata`. Three shapes are accepted: - `types.GroundingMetadata` instance → used as-is. - `dict` → validated via `types.GroundingMetadata.model_validate`. - `list` (one entry per candidate) → first entry, then the same dispatch. If validation fails the handler logs a warning and leaves grounding unset, so a single malformed payload doesn't break the rest of the response. ### Testing Plan **Unit Tests** in `tests/unittests/models/test_litellm.py`: - `test_model_response_to_generate_content_response_grounding_metadata_dict` — dict payload is propagated. - `test_model_response_to_generate_content_response_grounding_metadata_list` — list payload uses the first entry. - `test_model_response_to_generate_content_response_no_grounding_metadata` — missing attribute leaves grounding as `None` (no regression for non-Gemini LiteLlm models). Full `test_litellm.py` suite passes locally: ``` 259 passed in 9.36s ``` **Manual E2E**: Live Gemini API verification was not possible locally (no API key configured). The unit tests cover the conversion function the bug report points to. ### Checklist - [x] I have read the CONTRIBUTING.md document. - [x] I have performed a self-review of my own code. - [x] I have commented my code, particularly in hard-to-understand areas. - [x] I have added tests that prove my fix is effective. - [x] New and existing unit tests pass locally with my changes. - [ ] I have manually tested my changes end-to-end. (See above — requires live Gemini access.) Co-authored-by: George Weale <gweale@google.com> COPYBARA_INTEGRATE_REVIEW=#5661 from 1wos:fix-litellm-grounding 4657c9d PiperOrigin-RevId: 934010043
1 parent ea772b9 commit c303c62

2 files changed

Lines changed: 161 additions & 1 deletion

File tree

src/google/adk/models/lite_llm.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1741,6 +1741,38 @@ def _has_meaningful_signal(message: Message | Delta | None) -> bool:
17411741
) from e
17421742

17431743

1744+
def _extract_grounding_metadata(
1745+
response: ModelResponse | ModelResponseStream,
1746+
) -> types.GroundingMetadata | None:
1747+
"""Pulls Gemini grounding metadata off a LiteLLM response or stream chunk.
1748+
1749+
LiteLLM exposes Gemini's grounding metadata on the response/chunk object
1750+
rather than inside the message, so the native Gemini path
1751+
(`candidate.grounding_metadata`) misses it. Mirroring it here lets downstream
1752+
consumers (event.grounding_metadata, after_model_callback, citation
1753+
pipelines, ...) rely on it for both model paths.
1754+
1755+
Returns the parsed metadata, or None when it is absent or malformed.
1756+
"""
1757+
raw_grounding = getattr(response, "vertex_ai_grounding_metadata", None)
1758+
if not raw_grounding:
1759+
return None
1760+
# LiteLLM may emit a list (one entry per candidate) or a single value.
1761+
if isinstance(raw_grounding, list):
1762+
raw_grounding = raw_grounding[0] if raw_grounding else None
1763+
if isinstance(raw_grounding, types.GroundingMetadata):
1764+
return raw_grounding
1765+
if isinstance(raw_grounding, dict):
1766+
try:
1767+
return types.GroundingMetadata.model_validate(raw_grounding)
1768+
except Exception: # pragma: no cover
1769+
logger.warning(
1770+
"LiteLlm: vertex_ai_grounding_metadata did not match the"
1771+
" GroundingMetadata schema and was dropped."
1772+
)
1773+
return None
1774+
1775+
17441776
def _model_response_to_generate_content_response(
17451777
response: ModelResponse,
17461778
) -> LlmResponse:
@@ -1792,6 +1824,11 @@ def _model_response_to_generate_content_response(
17921824
cached_content_token_count=_extract_cached_prompt_tokens(usage_dict),
17931825
thoughts_token_count=reasoning_tokens if reasoning_tokens else None,
17941826
)
1827+
1828+
grounding_metadata = _extract_grounding_metadata(response)
1829+
if grounding_metadata:
1830+
llm_response.grounding_metadata = grounding_metadata
1831+
17951832
return llm_response
17961833

17971834

@@ -2374,6 +2411,7 @@ async def generate_content_async(
23742411
aggregated_llm_response = None
23752412
aggregated_llm_response_with_tool_call = None
23762413
usage_metadata = None
2414+
grounding_metadata = None
23772415
fallback_index = 0
23782416

23792417
def _finalize_tool_call_response(
@@ -2459,6 +2497,11 @@ def _reset_stream_buffers() -> None:
24592497
function_calls.clear()
24602498

24612499
async for part in await self.llm_client.acompletion(**completion_args):
2500+
# Grounding metadata can arrive on the first chunk (search queries) or
2501+
# the final chunk (supports); keep the latest non-empty one.
2502+
part_grounding = _extract_grounding_metadata(part)
2503+
if part_grounding:
2504+
grounding_metadata = part_grounding
24622505
for chunk, finish_reason in _model_response_to_chunk(part):
24632506
if isinstance(chunk, FunctionChunk):
24642507
index = chunk.index or fallback_index
@@ -2560,11 +2603,17 @@ def _reset_stream_buffers() -> None:
25602603
if usage_metadata:
25612604
aggregated_llm_response.usage_metadata = usage_metadata
25622605
usage_metadata = None
2606+
if grounding_metadata:
2607+
aggregated_llm_response.grounding_metadata = grounding_metadata
25632608
yield aggregated_llm_response
25642609

25652610
if aggregated_llm_response_with_tool_call:
25662611
if usage_metadata:
25672612
aggregated_llm_response_with_tool_call.usage_metadata = usage_metadata
2613+
if grounding_metadata:
2614+
aggregated_llm_response_with_tool_call.grounding_metadata = (
2615+
grounding_metadata
2616+
)
25682617
yield aggregated_llm_response_with_tool_call
25692618

25702619
else:

tests/unittests/models/test_litellm.py

Lines changed: 112 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2473,6 +2473,68 @@ def test_model_response_to_generate_content_response_reasoning_field():
24732473
assert response.content.parts[1].text == "Result"
24742474

24752475

2476+
def test_model_response_to_generate_content_response_grounding_metadata_dict():
2477+
"""vertex_ai_grounding_metadata as a dict is propagated to the LlmResponse."""
2478+
model_response = ModelResponse(
2479+
model="gemini/gemini-2.5-flash",
2480+
choices=[{
2481+
"message": {"role": "assistant", "content": "Answer"},
2482+
"finish_reason": "stop",
2483+
}],
2484+
)
2485+
model_response.vertex_ai_grounding_metadata = {
2486+
"grounding_chunks": [
2487+
{"web": {"uri": "https://example.com", "title": "Example"}}
2488+
],
2489+
}
2490+
2491+
response = _model_response_to_generate_content_response(model_response)
2492+
2493+
assert response.grounding_metadata is not None
2494+
assert (
2495+
response.grounding_metadata.grounding_chunks[0].web.uri
2496+
== "https://example.com"
2497+
)
2498+
2499+
2500+
def test_model_response_to_generate_content_response_grounding_metadata_list():
2501+
"""LiteLLM may emit a list (per candidate); the first entry is used."""
2502+
model_response = ModelResponse(
2503+
model="gemini/gemini-2.5-flash",
2504+
choices=[{
2505+
"message": {"role": "assistant", "content": "Answer"},
2506+
"finish_reason": "stop",
2507+
}],
2508+
)
2509+
model_response.vertex_ai_grounding_metadata = [
2510+
{"grounding_chunks": [{"web": {"uri": "https://a.test", "title": "A"}}]},
2511+
{"grounding_chunks": [{"web": {"uri": "https://b.test", "title": "B"}}]},
2512+
]
2513+
2514+
response = _model_response_to_generate_content_response(model_response)
2515+
2516+
assert response.grounding_metadata is not None
2517+
assert (
2518+
response.grounding_metadata.grounding_chunks[0].web.uri
2519+
== "https://a.test"
2520+
)
2521+
2522+
2523+
def test_model_response_to_generate_content_response_no_grounding_metadata():
2524+
"""Without vertex_ai_grounding_metadata, grounding_metadata stays None."""
2525+
model_response = ModelResponse(
2526+
model="gemini/gemini-2.5-flash",
2527+
choices=[{
2528+
"message": {"role": "assistant", "content": "Answer"},
2529+
"finish_reason": "stop",
2530+
}],
2531+
)
2532+
2533+
response = _model_response_to_generate_content_response(model_response)
2534+
2535+
assert response.grounding_metadata is None
2536+
2537+
24762538
def test_reasoning_content_takes_precedence_over_reasoning():
24772539
"""Test that 'reasoning_content' is prioritized over 'reasoning'."""
24782540
message = {
@@ -3775,7 +3837,56 @@ async def test_completion_with_drop_params(mock_completion, mock_client):
37753837

37763838

37773839
@pytest.mark.asyncio
3778-
async def test_generate_content_async_stream(
3840+
async def test_generate_content_async_stream_grounding_metadata(
3841+
mock_completion, lite_llm_instance
3842+
):
3843+
final_chunk = ModelResponseStream(
3844+
model="test_model",
3845+
choices=[StreamingChoices(finish_reason="stop", delta=Delta())],
3846+
)
3847+
final_chunk.vertex_ai_grounding_metadata = {
3848+
"grounding_chunks": [
3849+
{"web": {"uri": "https://example.com", "title": "Example"}}
3850+
],
3851+
}
3852+
mock_completion.return_value = iter([
3853+
ModelResponseStream(
3854+
model="test_model",
3855+
choices=[
3856+
StreamingChoices(
3857+
finish_reason=None,
3858+
delta=Delta(role="assistant", content="Grounded answer"),
3859+
)
3860+
],
3861+
),
3862+
final_chunk,
3863+
])
3864+
3865+
llm_request = LlmRequest(
3866+
contents=[
3867+
types.Content(
3868+
role="user", parts=[types.Part.from_text(text="Test prompt")]
3869+
)
3870+
],
3871+
)
3872+
3873+
responses = [
3874+
response
3875+
async for response in lite_llm_instance.generate_content_async(
3876+
llm_request, stream=True
3877+
)
3878+
]
3879+
3880+
assert responses[-1].partial is False
3881+
assert responses[-1].grounding_metadata is not None
3882+
assert (
3883+
responses[-1].grounding_metadata.grounding_chunks[0].web.uri
3884+
== "https://example.com"
3885+
)
3886+
3887+
3888+
@pytest.mark.asyncio
3889+
async def test_generate_content_async_stream_with_usage_metadata(
37793890
mock_completion, lite_llm_instance
37803891
):
37813892

0 commit comments

Comments
 (0)