fix(vertex_ai): add includeThoughts=True for Gemini 3 reasoning_effort (#16838)

Gemini 3 models require 'includeThoughts: True' in the thinkingConfig to return the actual thought text. Previously, using reasoning_effort set the 'thinkingLevel' but missed the boolean flag, resulting in empty reasoning_content.

This fix:
1. Updates `_map_reasoning_effort_to_thinking_level` to include `includeThoughts: True` for low/medium/high.
2. Adds unit tests to verify the config mapping.
This commit is contained in:
Sebastian 2025-11-19 22:14:42 -05:00 committed by GitHub
parent 3b6f3e48cb
commit cb843684b8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 116 additions and 75 deletions

View file

@ -217,12 +217,12 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig):
@classmethod
def get_config(cls):
return super().get_config()
@staticmethod
def _is_gemini_3_or_newer(model: str) -> bool:
"""
Check if the model is Gemini 3 Pro or newer.
Gemini 3 models include:
- gemini-3-pro-preview
- Any future Gemini 3.x models
@ -230,7 +230,7 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig):
# Check for Gemini 3 models
if "gemini-3" in model:
return True
return False
def _supports_penalty_parameters(self, model: str) -> bool:
@ -260,11 +260,11 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig):
"parallel_tool_calls",
"web_search_options",
]
# Add penalty parameters only for non-preview models
if self._supports_penalty_parameters(model):
supported_params.extend(["frequency_penalty", "presence_penalty"])
if supports_reasoning(model):
supported_params.append("reasoning_effort")
supported_params.append("thinking")
@ -308,14 +308,14 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig):
) -> Tuple[dict, Optional[dict]]:
"""
Extract location configuration from googleMaps tool for Vertex AI toolConfig.
Supports two interface styles:
1. Nested (recommended): {"enableWidget": "...", "retrievalConfig": {"latitude": ..., "longitude": ...}}
2. Flat (backward compat): {"enableWidget": "...", "latitude": ..., "longitude": ...}
Args:
google_maps_config: The googleMaps tool configuration from LiteLLM
Returns:
Tuple of (cleaned_google_maps_config, retrieval_config):
- cleaned_google_maps_config: googleMaps config without location fields
@ -325,7 +325,7 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig):
latitude = google_maps_config.get("latitude")
longitude = google_maps_config.get("longitude")
language_code = google_maps_config.get("languageCode")
if latitude is not None and longitude is not None:
retrieval_config = {
"latLng": {
@ -335,21 +335,17 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig):
}
if language_code is not None:
retrieval_config["languageCode"] = language_code
# Remove location fields from tool definition
cleaned_config = {
k: v
for k, v in google_maps_config.items()
if k not in ["latitude", "longitude", "languageCode"]
}
return cleaned_config, retrieval_config
def get_tool_value(
self,
tool: dict,
tool_name: str
) -> Optional[dict]:
def get_tool_value(self, tool: dict, tool_name: str) -> Optional[dict]:
"""
Helper function to get tool value handling both camelCase and underscore_case variants
@ -373,19 +369,19 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig):
else:
return None
def _map_function( # noqa: PLR0915
def _map_function( # noqa: PLR0915
self, value: List[dict], optional_params: dict
) -> List[Tools]:
"""
Map OpenAI-style tools/functions to Vertex AI format.
Args:
value: List of tool definitions
optional_params: Request-scoped parameters to store retrieval config
Returns:
List of mapped tools in Vertex AI format
Side effects:
May add 'toolConfig' with 'retrievalConfig' to optional_params if
googleMaps tools contain location data
@ -432,25 +428,43 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig):
tool_name = list(tool.keys())[0] if len(tool.keys()) == 1 else None
if tool_name and (
tool_name == "codeExecution" or tool_name == VertexToolName.CODE_EXECUTION.value
tool_name == "codeExecution"
or tool_name == VertexToolName.CODE_EXECUTION.value
): # code_execution maintained for backwards compatibility
code_execution = self.get_tool_value(tool, "codeExecution")
elif tool_name and tool_name == VertexToolName.GOOGLE_SEARCH.value:
googleSearch = self.get_tool_value(tool, VertexToolName.GOOGLE_SEARCH.value)
elif tool_name and tool_name == VertexToolName.GOOGLE_SEARCH_RETRIEVAL.value:
googleSearchRetrieval = self.get_tool_value(tool, VertexToolName.GOOGLE_SEARCH_RETRIEVAL.value)
googleSearch = self.get_tool_value(
tool, VertexToolName.GOOGLE_SEARCH.value
)
elif (
tool_name and tool_name == VertexToolName.GOOGLE_SEARCH_RETRIEVAL.value
):
googleSearchRetrieval = self.get_tool_value(
tool, VertexToolName.GOOGLE_SEARCH_RETRIEVAL.value
)
elif tool_name and tool_name == VertexToolName.ENTERPRISE_WEB_SEARCH.value:
enterpriseWebSearch = self.get_tool_value(tool, VertexToolName.ENTERPRISE_WEB_SEARCH.value)
elif tool_name and (tool_name == VertexToolName.URL_CONTEXT.value or tool_name == "urlContext"):
enterpriseWebSearch = self.get_tool_value(
tool, VertexToolName.ENTERPRISE_WEB_SEARCH.value
)
elif tool_name and (
tool_name == VertexToolName.URL_CONTEXT.value
or tool_name == "urlContext"
):
urlContext = self.get_tool_value(tool, tool_name)
elif tool_name and (
tool_name == VertexToolName.GOOGLE_MAPS.value or tool_name == "google_maps"
tool_name == VertexToolName.GOOGLE_MAPS.value
or tool_name == "google_maps"
):
google_maps_value = self.get_tool_value(tool, VertexToolName.GOOGLE_MAPS.value)
google_maps_value = self.get_tool_value(
tool, VertexToolName.GOOGLE_MAPS.value
)
# Extract and transform location configuration for toolConfig
if google_maps_value is not None:
googleMaps, google_maps_retrieval_config = self._extract_google_maps_retrieval_config(
(
googleMaps,
google_maps_retrieval_config,
) = self._extract_google_maps_retrieval_config(
google_maps_config=google_maps_value
)
elif openai_function_object is not None:
@ -490,13 +504,15 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig):
_tools[VertexToolName.URL_CONTEXT.value] = urlContext
if googleMaps is not None:
_tools[VertexToolName.GOOGLE_MAPS.value] = googleMaps
# Add retrieval config to toolConfig if googleMaps has location data
if google_maps_retrieval_config is not None:
if "toolConfig" not in optional_params:
optional_params["toolConfig"] = {}
optional_params["toolConfig"]["retrievalConfig"] = google_maps_retrieval_config
optional_params["toolConfig"][
"retrievalConfig"
] = google_maps_retrieval_config
return [_tools]
def _map_response_schema(self, value: dict) -> dict:
@ -599,23 +615,27 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig):
Map reasoning_effort to thinking_level for Gemini 3+ models.
Args:
reasoning_effort: The reasoning effort value
model: The model name (for validation, currently unused but kept for consistency)
model: The model name
Returns:
GeminiThinkingConfig with thinkingLevel set
GeminiThinkingConfig with thinkingLevel and includeThoughts
"""
if reasoning_effort == "minimal":
return {"thinkingLevel": "low"}
return {"thinkingLevel": "low", "includeThoughts": True}
elif reasoning_effort == "low":
return {"thinkingLevel": "low"}
return {"thinkingLevel": "low", "includeThoughts": True}
elif reasoning_effort == "medium":
return {"thinkingLevel": "high"} # medium is not out yet
return {
"thinkingLevel": "high",
"includeThoughts": True,
} # medium is not out yet
elif reasoning_effort == "high":
return {"thinkingLevel": "high"}
return {"thinkingLevel": "high", "includeThoughts": True}
elif reasoning_effort == "disable":
return {"thinkingLevel": "low"} # gemini 3 cannot fully disable thinking, so we use "low"
# Gemini 3 cannot fully disable thinking, so we use "low" but hide thoughts
return {"thinkingLevel": "low", "includeThoughts": False}
elif reasoning_effort == "none":
return {"thinkingLevel": "low"} # gemini 3 cannot fully disable thinking, so we use "low"
return {"thinkingLevel": "low", "includeThoughts": False}
else:
raise ValueError(f"Invalid reasoning effort: {reasoning_effort}")
@ -663,7 +683,6 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig):
status_code=400,
)
@staticmethod
def _map_thinking_param(
thinking_param: AnthropicThinkingParam,
@ -835,9 +854,9 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig):
if VertexGeminiConfig._is_gemini_3_or_newer(model):
optional_params[
"thinkingConfig"
] = VertexGeminiConfig._map_reasoning_effort_to_thinking_level(
value, model
)
] = VertexGeminiConfig._map_reasoning_effort_to_thinking_level(
value, model
)
else:
optional_params[
"thinkingConfig"
@ -879,7 +898,10 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig):
if VertexGeminiConfig._is_gemini_3_or_newer(model):
if "temperature" not in optional_params:
optional_params["temperature"] = 1.0
if "thinkingConfig" not in optional_params or "thinkingLevel" not in optional_params.get("thinkingConfig", {}):
if (
"thinkingConfig" not in optional_params
or "thinkingLevel" not in optional_params.get("thinkingConfig", {})
):
thinking_config = optional_params.get("thinkingConfig", {})
thinking_config["thinkingLevel"] = "low"
optional_params["thinkingConfig"] = thinking_config
@ -1147,17 +1169,21 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig):
if "functionCall" in part:
_function_chunk: ChatCompletionToolCallFunctionChunk = {
"name": part["functionCall"]["name"],
"arguments": json.dumps(part["functionCall"]["args"], ensure_ascii=False),
"arguments": json.dumps(
part["functionCall"]["args"], ensure_ascii=False
),
}
# Extract thought signature if present
thought_signature = part.get("thoughtSignature")
if is_function_call is True:
function_dict: Dict[str, Any] = dict(_function_chunk)
if thought_signature:
if "provider_specific_fields" not in function_dict:
function_dict["provider_specific_fields"] = {}
function_dict["provider_specific_fields"]["thought_signature"] = thought_signature
function_dict["provider_specific_fields"][
"thought_signature"
] = thought_signature
function = cast(ChatCompletionToolCallFunctionChunk, function_dict)
else:
_tool_response_chunk: ChatCompletionToolCallChunk = {
@ -1506,7 +1532,6 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig):
annotations: List[ChatCompletionAnnotation] = []
for metadata in grounding_metadata:
# Extract groundingSupports - these map text segments to sources
grounding_supports = metadata.get("groundingSupports", [])
@ -1527,23 +1552,23 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig):
segment = support.get("segment", {})
start_index = segment.get("startIndex")
end_index = segment.get("endIndex")
# Get the chunk indices for this support
chunk_indices = support.get("groundingChunkIndices", [])
if start_index is not None and end_index is not None and chunk_indices:
# Use the first chunk's URL for the annotation
first_chunk_idx = chunk_indices[0]
if first_chunk_idx in chunk_to_uri_map:
uri_info = chunk_to_uri_map[first_chunk_idx]
url_citation: ChatCompletionAnnotationURLCitation = {
"start_index": start_index,
"end_index": end_index,
"url": uri_info["url"],
"title": uri_info["title"],
}
annotation: ChatCompletionAnnotation = {
"type": "url_citation",
"url_citation": url_citation,
@ -1643,9 +1668,11 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig):
chat_completion_message["reasoning_content"] = reasoning_content
if candidate_grounding_metadata:
annotations = VertexGeminiConfig._convert_grounding_metadata_to_annotations(
grounding_metadata=candidate_grounding_metadata,
content_text=content,
annotations = (
VertexGeminiConfig._convert_grounding_metadata_to_annotations(
grounding_metadata=candidate_grounding_metadata,
content_text=content,
)
)
if annotations:
chat_completion_message["annotations"] = annotations # type: ignore
@ -1924,7 +1951,9 @@ async def make_call(
)
try:
response = await client.post(api_base, headers=headers, data=data, stream=True, logging_obj=logging_obj)
response = await client.post(
api_base, headers=headers, data=data, stream=True, logging_obj=logging_obj
)
response.raise_for_status()
except httpx.HTTPStatusError as e:
exception_string = str(await e.response.aread())
@ -1971,7 +2000,9 @@ def make_sync_call(
if client is None:
client = HTTPHandler() # Create a new client if none provided
response = client.post(api_base, headers=headers, data=data, stream=True, logging_obj=logging_obj)
response = client.post(
api_base, headers=headers, data=data, stream=True, logging_obj=logging_obj
)
if response.status_code != 200 and response.status_code != 201:
raise VertexAIError(
@ -2026,7 +2057,6 @@ class VertexLLM(VertexBase):
gemini_api_key: Optional[str] = None,
extra_headers: Optional[dict] = None,
) -> CustomStreamWrapper:
should_use_v1beta1_features = self.is_using_v1beta1_features(
optional_params=optional_params
)
@ -2063,8 +2093,8 @@ class VertexLLM(VertexBase):
**data,
vertex_project=vertex_project,
vertex_location=vertex_location,
vertex_auth_header=auth_header) # type: ignore
vertex_auth_header=auth_header,
) # type: ignore
## LOGGING
logging_obj.pre_call(
@ -2157,7 +2187,8 @@ class VertexLLM(VertexBase):
**data,
vertex_project=vertex_project,
vertex_location=vertex_location,
vertex_auth_header=auth_header) # type: ignore
vertex_auth_header=auth_header,
) # type: ignore
_async_client_params = {}
if timeout:
@ -2181,7 +2212,10 @@ class VertexLLM(VertexBase):
try:
response = await client.post(
api_base, headers=headers, json=cast(dict, request_body), logging_obj=logging_obj
api_base,
headers=headers,
json=cast(dict, request_body),
logging_obj=logging_obj,
) # type: ignore
response.raise_for_status()
except httpx.HTTPStatusError as err:
@ -2335,9 +2369,10 @@ class VertexLLM(VertexBase):
## TRANSFORMATION ##
data = sync_transform_request_body(
**transform_request_params,
vertex_project=vertex_project,
vertex_project=vertex_project,
vertex_location=vertex_location,
vertex_auth_header=auth_header)
vertex_auth_header=auth_header,
)
## LOGGING
logging_obj.pre_call(

View file

@ -1507,7 +1507,7 @@ def test_is_gemini_3_or_newer():
def test_reasoning_effort_maps_to_thinking_level_gemini_3():
"""Test that reasoning_effort maps to thinking_level for Gemini 3+ models"""
"""Test that reasoning_effort maps to thinking_level AND includeThoughts for Gemini 3+ models"""
from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import (
VertexGeminiConfig,
)
@ -1516,7 +1516,7 @@ def test_reasoning_effort_maps_to_thinking_level_gemini_3():
model = "gemini-3-pro-preview"
optional_params = {}
# Test minimal -> low
# Test minimal -> low + includeThoughts=True
non_default_params = {"reasoning_effort": "minimal"}
result = v.map_openai_params(
non_default_params=non_default_params,
@ -1525,8 +1525,9 @@ def test_reasoning_effort_maps_to_thinking_level_gemini_3():
drop_params=False,
)
assert result["thinkingConfig"]["thinkingLevel"] == "low"
assert result["thinkingConfig"]["includeThoughts"] is True
# Test low -> low
# Test low -> low + includeThoughts=True
optional_params = {}
non_default_params = {"reasoning_effort": "low"}
result = v.map_openai_params(
@ -1536,8 +1537,9 @@ def test_reasoning_effort_maps_to_thinking_level_gemini_3():
drop_params=False,
)
assert result["thinkingConfig"]["thinkingLevel"] == "low"
assert result["thinkingConfig"]["includeThoughts"] is True
# Test medium -> high (medium not available yet)
# Test medium -> high + includeThoughts=True (medium not available yet)
optional_params = {}
non_default_params = {"reasoning_effort": "medium"}
result = v.map_openai_params(
@ -1547,8 +1549,9 @@ def test_reasoning_effort_maps_to_thinking_level_gemini_3():
drop_params=False,
)
assert result["thinkingConfig"]["thinkingLevel"] == "high"
assert result["thinkingConfig"]["includeThoughts"] is True
# Test high -> high
# Test high -> high + includeThoughts=True
optional_params = {}
non_default_params = {"reasoning_effort": "high"}
result = v.map_openai_params(
@ -1558,8 +1561,9 @@ def test_reasoning_effort_maps_to_thinking_level_gemini_3():
drop_params=False,
)
assert result["thinkingConfig"]["thinkingLevel"] == "high"
assert result["thinkingConfig"]["includeThoughts"] is True
# Test disable -> low (cannot fully disable in Gemini 3)
# Test disable -> low + includeThoughts=False (cannot fully disable in Gemini 3)
optional_params = {}
non_default_params = {"reasoning_effort": "disable"}
result = v.map_openai_params(
@ -1569,8 +1573,9 @@ def test_reasoning_effort_maps_to_thinking_level_gemini_3():
drop_params=False,
)
assert result["thinkingConfig"]["thinkingLevel"] == "low"
assert result["thinkingConfig"]["includeThoughts"] is False
# Test none -> low (cannot fully disable in Gemini 3)
# Test none -> low + includeThoughts=False (cannot fully disable in Gemini 3)
optional_params = {}
non_default_params = {"reasoning_effort": "none"}
result = v.map_openai_params(
@ -1580,6 +1585,7 @@ def test_reasoning_effort_maps_to_thinking_level_gemini_3():
drop_params=False,
)
assert result["thinkingConfig"]["thinkingLevel"] == "low"
assert result["thinkingConfig"]["includeThoughts"] is False
def test_temperature_default_for_gemini_3():