Skip to content

Commit 926b0e0

Browse files
committed
fix #9783: Retain schema field ordering for google gemini and vertex
1 parent ff3a683 commit 926b0e0

File tree

3 files changed

+197
-10
lines changed

3 files changed

+197
-10
lines changed

litellm/llms/vertex_ai/common_utils.py

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -165,9 +165,18 @@ def _check_text_in_content(parts: List[PartType]) -> bool:
165165
return has_text_param
166166

167167

168-
def _build_vertex_schema(parameters: dict):
168+
def _build_vertex_schema(parameters: dict, add_property_ordering: bool = False):
169169
"""
170170
This is a modified version of https://github.com/google-gemini/generative-ai-python/blob/8f77cc6ac99937cd3a81299ecf79608b91b06bbb/google/generativeai/types/content_types.py#L419
171+
172+
Updates the input parameters, removing extraneous fields, adjusting types, unwinding $defs, and adding propertyOrdering if specified, returning the updated parameters.
173+
174+
Parameters:
175+
parameters: dict - the json schema to build from
176+
add_property_ordering: bool - whether to add propertyOrdering to the schema. This is only applicable to schemas for structured outputs. See
177+
set_schema_property_ordering for more details.
178+
Returns:
179+
parameters: dict - the input parameters, modified in place
171180
"""
172181
# Get valid fields from Schema TypedDict
173182
valid_schema_fields = set(get_type_hints(Schema).keys())
@@ -186,8 +195,31 @@ def _build_vertex_schema(parameters: dict):
186195
add_object_type(parameters)
187196
# Postprocessing
188197
# Filter out fields that don't exist in Schema
189-
filtered_parameters = filter_schema_fields(parameters, valid_schema_fields)
190-
return filtered_parameters
198+
parameters = filter_schema_fields(parameters, valid_schema_fields)
199+
200+
if add_property_ordering:
201+
set_schema_property_ordering(parameters)
202+
return parameters
203+
204+
205+
def set_schema_property_ordering(schema: Dict[str, Any]) -> Dict[str, Any]:
206+
"""
207+
vertex ai and generativeai apis order output of fields alphabetically, unless you specify the order.
208+
python dicts retain order, so we just use that. Note that this field only applies to structured outputs, and not tools.
209+
Function tools are not afflicted by the same alphabetical ordering issue, (the order of keys returned seems to be arbitrary, up to the model)
210+
https://cloud.google.com/vertex-ai/docs/reference/rest/v1/projects.locations.cachedContents#Schema.FIELDS.property_ordering
211+
"""
212+
if "properties" in schema and isinstance(schema["properties"], dict):
213+
# retain propertyOrdering as an escape hatch if user already specifies it
214+
if "propertyOrdering" not in schema:
215+
schema["propertyOrdering"] = [k for k, v in schema["properties"].items()]
216+
for k, v in schema["properties"].items():
217+
set_schema_property_ordering(v)
218+
if "items" in schema:
219+
set_schema_property_ordering(schema["items"])
220+
return schema
221+
222+
191223

192224

193225
def filter_schema_fields(

litellm/llms/vertex_ai/gemini/vertex_and_google_ai_studio_gemini.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -207,7 +207,7 @@ def get_supported_openai_params(self, model: str) -> List[str]:
207207
"extra_headers",
208208
"seed",
209209
"logprobs",
210-
"top_logprobs", # Added this to list of supported openAI params
210+
"top_logprobs",
211211
"modalities",
212212
]
213213

@@ -308,9 +308,10 @@ def _map_response_schema(self, value: dict) -> dict:
308308
if isinstance(old_schema, list):
309309
for item in old_schema:
310310
if isinstance(item, dict):
311-
item = _build_vertex_schema(parameters=item)
311+
item = _build_vertex_schema(parameters=item, add_property_ordering=True)
312+
312313
elif isinstance(old_schema, dict):
313-
old_schema = _build_vertex_schema(parameters=old_schema)
314+
old_schema = _build_vertex_schema(parameters=old_schema, add_property_ordering=True)
314315
return old_schema
315316

316317
def apply_response_schema_transformation(self, value: dict, optional_params: dict):

tests/litellm/llms/vertex_ai/gemini/test_vertex_and_google_ai_studio.py

Lines changed: 158 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,8 @@
1-
import json
21
import os
32
import sys
4-
from unittest.mock import AsyncMock, MagicMock, patch
3+
from typing import List
54

6-
import httpx
7-
import pytest
5+
from pydantic import BaseModel
86

97
sys.path.insert(
108
0, os.path.abspath("../../../../..")
@@ -66,3 +64,159 @@ def test_get_model_name_from_gemini_spec_model():
6664
model = "gemini/ft-uuid-123"
6765
result = VertexGeminiConfig._get_model_name_from_gemini_spec_model(model)
6866
assert result == "ft-uuid-123"
67+
68+
69+
def test_vertex_ai_response_schema_dict():
70+
v = VertexGeminiConfig()
71+
transformed_request = v.map_openai_params(
72+
non_default_params={
73+
"messages": [{"role": "user", "content": "Hello, world!"}],
74+
"response_format": {
75+
"type": "json_schema",
76+
"json_schema": {
77+
"name": "math_reasoning",
78+
"schema": {
79+
"type": "object",
80+
"properties": {
81+
"steps": {
82+
"type": "array",
83+
"items": {
84+
"type": "object",
85+
"properties": {
86+
"thought": {"type": "string"},
87+
"output": {"type": "string"},
88+
},
89+
"required": ["thought", "output"],
90+
"additionalProperties": False,
91+
},
92+
},
93+
"final_answer": {"type": "string"},
94+
},
95+
"required": ["steps", "final_answer"],
96+
"additionalProperties": False,
97+
},
98+
"strict": False,
99+
},
100+
},
101+
},
102+
optional_params={},
103+
model="gemini-2.0-flash-lite",
104+
drop_params=False,
105+
)
106+
107+
schema = transformed_request["response_schema"]
108+
# should add propertyOrdering
109+
assert schema["propertyOrdering"] == ["steps", "final_answer"]
110+
# should add propertyOrdering (recursively, including array items)
111+
assert schema["properties"]["steps"]["items"]["propertyOrdering"] == [
112+
"thought",
113+
"output",
114+
]
115+
# should strip strict and additionalProperties
116+
assert "strict" not in schema
117+
assert "additionalProperties" not in schema
118+
# validate the whole thing to catch regressions
119+
assert transformed_request["response_schema"] == {
120+
"type": "object",
121+
"properties": {
122+
"steps": {
123+
"type": "array",
124+
"items": {
125+
"type": "object",
126+
"properties": {
127+
"thought": {"type": "string"},
128+
"output": {"type": "string"},
129+
},
130+
"required": ["thought", "output"],
131+
"propertyOrdering": ["thought", "output"],
132+
},
133+
},
134+
"final_answer": {"type": "string"},
135+
},
136+
"required": ["steps", "final_answer"],
137+
"propertyOrdering": ["steps", "final_answer"],
138+
}
139+
140+
141+
class MathReasoning(BaseModel):
142+
steps: List["Step"]
143+
final_answer: str
144+
145+
146+
class Step(BaseModel):
147+
thought: str
148+
output: str
149+
150+
151+
def test_vertex_ai_response_schema_defs():
152+
v = VertexGeminiConfig()
153+
154+
schema = v.get_json_schema_from_pydantic_object(MathReasoning)
155+
156+
# pydantic conversion by default adds $defs to the schema, make sure this is still the case, otherwise this test isn't really testing anything
157+
assert "$defs" in schema["json_schema"]["schema"]
158+
159+
transformed_request = v.map_openai_params(
160+
non_default_params={
161+
"messages": [{"role": "user", "content": "Hello, world!"}],
162+
"response_format": schema,
163+
},
164+
optional_params={},
165+
model="gemini-2.0-flash-lite",
166+
drop_params=False,
167+
)
168+
169+
assert "$defs" not in transformed_request["response_schema"]
170+
assert transformed_request["response_schema"] == {
171+
"title": "MathReasoning",
172+
"type": "object",
173+
"properties": {
174+
"steps": {
175+
"title": "Steps",
176+
"type": "array",
177+
"items": {
178+
"title": "Step",
179+
"type": "object",
180+
"properties": {
181+
"thought": {"title": "Thought", "type": "string"},
182+
"output": {"title": "Output", "type": "string"},
183+
},
184+
"required": ["thought", "output"],
185+
"propertyOrdering": ["thought", "output"],
186+
},
187+
},
188+
"final_answer": {"title": "Final Answer", "type": "string"},
189+
},
190+
"required": ["steps", "final_answer"],
191+
"propertyOrdering": ["steps", "final_answer"],
192+
}
193+
194+
195+
def test_vertex_ai_retain_property_ordering():
196+
v = VertexGeminiConfig()
197+
transformed_request = v.map_openai_params(
198+
non_default_params={
199+
"messages": [{"role": "user", "content": "Hello, world!"}],
200+
"response_format": {
201+
"type": "json_schema",
202+
"json_schema": {
203+
"name": "math_reasoning",
204+
"schema": {
205+
"type": "object",
206+
"properties": {
207+
"output": {"type": "string"},
208+
"thought": {"type": "string"},
209+
},
210+
"propertyOrdering": ["thought", "output"],
211+
},
212+
},
213+
},
214+
},
215+
optional_params={},
216+
model="gemini-2.0-flash-lite",
217+
drop_params=False,
218+
)
219+
220+
schema = transformed_request["response_schema"]
221+
# should leave existing value alone, despite dictionary ordering
222+
assert schema["propertyOrdering"] == ["thought", "output"]

0 commit comments

Comments
 (0)