Skip to content

Commit 7ea4aed

Browse files
GWealecopybara-github
authored andcommitted
fix: Add support for structured output schemas in LiteLLM models
Add `_to_litellm_response_format` to convert ADK's `response_schema` types (Pydantic models, JSON schema dicts) into the format needed by LiteLLM for JSON object/schema constraints Close #1967 Co-authored-by: George Weale <gweale@google.com> PiperOrigin-RevId: 829037987
1 parent d672349 commit 7ea4aed

File tree

4 files changed

+191
-3
lines changed

4 files changed

+191
-3
lines changed
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# Copyright 2025 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
from . import agent
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
# Copyright 2025 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""Sample agent showing LiteLLM structured output support."""
16+
17+
from __future__ import annotations
18+
19+
from google.adk import Agent
20+
from google.adk.models.lite_llm import LiteLlm
21+
from pydantic import BaseModel
22+
from pydantic import Field
23+
24+
25+
class CitySummary(BaseModel):
26+
"""Simple structure used to verify LiteLLM JSON schema handling."""
27+
28+
city: str = Field(description="Name of the city being described.")
29+
highlights: list[str] = Field(
30+
description="Bullet points summarising the city's key highlights.",
31+
)
32+
recommended_visit_length_days: int = Field(
33+
description="Recommended number of days for a typical visit.",
34+
)
35+
36+
37+
root_agent = Agent(
38+
name="litellm_structured_output_agent",
39+
model=LiteLlm(model="gemini-2.5-flash"),
40+
description="Generates structured travel recommendations for a given city.",
41+
instruction="""
42+
Produce a JSON object that follows the CitySummary schema.
43+
Only include fields that appear in the schema and ensure highlights
44+
contains short bullet points.
45+
""".strip(),
46+
output_schema=CitySummary,
47+
)

src/google/adk/models/lite_llm.py

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@
6363

6464
_NEW_LINE = "\n"
6565
_EXCLUDED_PART_FIELD = {"inline_data": {"data"}}
66+
_LITELLM_STRUCTURED_TYPES = {"json_object", "json_schema"}
6667

6768
# Mapping of LiteLLM finish_reason strings to FinishReason enum values
6869
# Note: tool_calls/function_call map to STOP because:
@@ -673,12 +674,50 @@ def _message_to_generate_content_response(
673674
)
674675

675676

677+
def _to_litellm_response_format(
678+
response_schema: types.SchemaUnion,
679+
) -> Optional[Dict[str, Any]]:
680+
"""Converts ADK response schema objects into LiteLLM-compatible payloads."""
681+
682+
if isinstance(response_schema, dict):
683+
schema_type = response_schema.get("type")
684+
if (
685+
isinstance(schema_type, str)
686+
and schema_type.lower() in _LITELLM_STRUCTURED_TYPES
687+
):
688+
return response_schema
689+
schema_dict = dict(response_schema)
690+
elif isinstance(response_schema, type) and issubclass(
691+
response_schema, BaseModel
692+
):
693+
schema_dict = response_schema.model_json_schema()
694+
elif isinstance(response_schema, BaseModel):
695+
if isinstance(response_schema, types.Schema):
696+
# GenAI Schema instances already represent JSON schema definitions.
697+
schema_dict = response_schema.model_dump(exclude_none=True, mode="json")
698+
else:
699+
schema_dict = response_schema.__class__.model_json_schema()
700+
elif hasattr(response_schema, "model_dump"):
701+
schema_dict = response_schema.model_dump(exclude_none=True, mode="json")
702+
else:
703+
logger.warning(
704+
"Unsupported response_schema type %s for LiteLLM structured outputs.",
705+
type(response_schema),
706+
)
707+
return None
708+
709+
return {
710+
"type": "json_object",
711+
"response_schema": schema_dict,
712+
}
713+
714+
676715
def _get_completion_inputs(
677716
llm_request: LlmRequest,
678717
) -> Tuple[
679718
List[Message],
680719
Optional[List[Dict]],
681-
Optional[types.SchemaUnion],
720+
Optional[Dict[str, Any]],
682721
Optional[Dict],
683722
]:
684723
"""Converts an LlmRequest to litellm inputs and extracts generation params.
@@ -721,9 +760,11 @@ def _get_completion_inputs(
721760
]
722761

723762
# 3. Handle response format
724-
response_format: Optional[types.SchemaUnion] = None
763+
response_format: Optional[Dict[str, Any]] = None
725764
if llm_request.config and llm_request.config.response_schema:
726-
response_format = llm_request.config.response_schema
765+
response_format = _to_litellm_response_format(
766+
llm_request.config.response_schema
767+
)
727768

728769
# 4. Extract generation parameters
729770
generation_params: Optional[Dict] = None

tests/unittests/models/test_litellm.py

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,11 @@
2121
from google.adk.models.lite_llm import _content_to_message_param
2222
from google.adk.models.lite_llm import _FINISH_REASON_MAPPING
2323
from google.adk.models.lite_llm import _function_declaration_to_tool_param
24+
from google.adk.models.lite_llm import _get_completion_inputs
2425
from google.adk.models.lite_llm import _get_content
2526
from google.adk.models.lite_llm import _message_to_generate_content_response
2627
from google.adk.models.lite_llm import _model_response_to_chunk
28+
from google.adk.models.lite_llm import _to_litellm_response_format
2729
from google.adk.models.lite_llm import _to_litellm_role
2830
from google.adk.models.lite_llm import FunctionChunk
2931
from google.adk.models.lite_llm import LiteLlm
@@ -40,6 +42,8 @@
4042
from litellm.types.utils import Delta
4143
from litellm.types.utils import ModelResponse
4244
from litellm.types.utils import StreamingChoices
45+
from pydantic import BaseModel
46+
from pydantic import Field
4347
import pytest
4448

4549
LLM_REQUEST_WITH_FUNCTION_DECLARATION = LlmRequest(
@@ -179,6 +183,87 @@
179183
),
180184
]
181185

186+
187+
class _StructuredOutput(BaseModel):
188+
value: int = Field(description="Value to emit")
189+
190+
191+
class _ModelDumpOnly:
192+
"""Test helper that mimics objects exposing only model_dump."""
193+
194+
def __init__(self):
195+
self._schema = {
196+
"type": "object",
197+
"properties": {"foo": {"type": "string"}},
198+
}
199+
200+
def model_dump(self, *, exclude_none=True, mode="json"):
201+
# The method signature matches pydantic BaseModel.model_dump to simulate
202+
# google.genai schema-like objects.
203+
del exclude_none
204+
del mode
205+
return self._schema
206+
207+
208+
def test_get_completion_inputs_formats_pydantic_schema_for_litellm():
209+
llm_request = LlmRequest(
210+
config=types.GenerateContentConfig(response_schema=_StructuredOutput)
211+
)
212+
213+
_, _, response_format, _ = _get_completion_inputs(llm_request)
214+
215+
assert response_format == {
216+
"type": "json_object",
217+
"response_schema": _StructuredOutput.model_json_schema(),
218+
}
219+
220+
221+
def test_to_litellm_response_format_passes_preformatted_dict():
222+
response_format = {
223+
"type": "json_object",
224+
"response_schema": {
225+
"type": "object",
226+
"properties": {"foo": {"type": "string"}},
227+
},
228+
}
229+
230+
assert _to_litellm_response_format(response_format) == response_format
231+
232+
233+
def test_to_litellm_response_format_wraps_json_schema_dict():
234+
schema = {
235+
"type": "object",
236+
"properties": {"foo": {"type": "string"}},
237+
}
238+
239+
formatted = _to_litellm_response_format(schema)
240+
assert formatted["type"] == "json_object"
241+
assert formatted["response_schema"] == schema
242+
243+
244+
def test_to_litellm_response_format_handles_model_dump_object():
245+
schema_obj = _ModelDumpOnly()
246+
247+
formatted = _to_litellm_response_format(schema_obj)
248+
249+
assert formatted["type"] == "json_object"
250+
assert formatted["response_schema"] == schema_obj.model_dump()
251+
252+
253+
def test_to_litellm_response_format_handles_genai_schema_instance():
254+
schema_instance = types.Schema(
255+
type=types.Type.OBJECT,
256+
properties={"foo": types.Schema(type=types.Type.STRING)},
257+
required=["foo"],
258+
)
259+
260+
formatted = _to_litellm_response_format(schema_instance)
261+
assert formatted["type"] == "json_object"
262+
assert formatted["response_schema"] == schema_instance.model_dump(
263+
exclude_none=True, mode="json"
264+
)
265+
266+
182267
MULTIPLE_FUNCTION_CALLS_STREAM = [
183268
ModelResponse(
184269
choices=[

0 commit comments

Comments
 (0)