Skip to content

Commit c3143e3

Browse files
authored
Add thought signature support to v1/messages api (#16812)
* Add thought signature support to v1/messages api * update the thinking level handling logic * update the thinking level handling logic * Add streaming support * fix intalling litellm error
1 parent 87be419 commit c3143e3

File tree

13 files changed

+481
-46
lines changed

13 files changed

+481
-46
lines changed

litellm/litellm_core_utils/prompt_templates/factory.py

Lines changed: 55 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1162,16 +1162,58 @@ def _gemini_tool_call_invoke_helper(
11621162
return function_call
11631163

11641164

1165-
def _get_thought_signature_from_tool(tool: dict) -> Optional[str]:
1166-
"""Extract thought signature from tool call's provider_specific_fields"""
1165+
def _get_thought_signature_from_tool(tool: dict, model: Optional[str] = None) -> Optional[str]:
1166+
"""Extract thought signature from tool call's provider_specific_fields.
1167+
1168+
Checks both tool.provider_specific_fields and tool.function.provider_specific_fields.
1169+
If no signature is found and model is gemini-3, returns a dummy signature.
1170+
"""
1171+
# First check tool's provider_specific_fields
11671172
provider_fields = tool.get("provider_specific_fields") or {}
11681173
if isinstance(provider_fields, dict):
1169-
return provider_fields.get("thought_signature")
1174+
signature = provider_fields.get("thought_signature")
1175+
if signature:
1176+
return signature
1177+
1178+
# Then check function's provider_specific_fields
1179+
function = tool.get("function")
1180+
if function:
1181+
if isinstance(function, dict):
1182+
func_provider_fields = function.get("provider_specific_fields") or {}
1183+
if isinstance(func_provider_fields, dict):
1184+
signature = func_provider_fields.get("thought_signature")
1185+
if signature:
1186+
return signature
1187+
elif hasattr(function, "provider_specific_fields") and function.provider_specific_fields:
1188+
if isinstance(function.provider_specific_fields, dict):
1189+
signature = function.provider_specific_fields.get("thought_signature")
1190+
if signature:
1191+
return signature
1192+
1193+
# If no signature found and model is gemini-3, return dummy signature
1194+
from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import VertexGeminiConfig
1195+
if model and VertexGeminiConfig._is_gemini_3_or_newer(model):
1196+
return _get_dummy_thought_signature()
1197+
11701198
return None
11711199

11721200

1201+
def _get_dummy_thought_signature() -> str:
1202+
"""Generate a dummy thought signature for models that require it.
1203+
1204+
This is used when transferring conversation history from older models
1205+
(like gemini-2.5-flash) to gemini-3, which requires thought_signature
1206+
for strict validation.
1207+
"""
1208+
# Return a base64-encoded dummy signature string
1209+
# Below dummy signature is recommended by google - https://ai.google.dev/gemini-api/docs/thought-signatures#faqs
1210+
dummy_data = b"skip_thought_signature_validator"
1211+
return base64.b64encode(dummy_data).decode("utf-8")
1212+
1213+
11731214
def convert_to_gemini_tool_call_invoke(
11741215
message: ChatCompletionAssistantMessage,
1216+
model: Optional[str] = None,
11751217
) -> List[VertexPartType]:
11761218
"""
11771219
OpenAI tool invokes:
@@ -1229,7 +1271,7 @@ def convert_to_gemini_tool_call_invoke(
12291271
part_dict: VertexPartType = {
12301272
"function_call": gemini_function_call
12311273
}
1232-
thought_signature = _get_thought_signature_from_tool(dict(tool))
1274+
thought_signature = _get_thought_signature_from_tool(dict(tool), model=model)
12331275
if thought_signature:
12341276
part_dict["thoughtSignature"] = thought_signature
12351277

@@ -1250,11 +1292,18 @@ def convert_to_gemini_tool_call_invoke(
12501292
}
12511293

12521294
# Extract thought signature from function_call's provider_specific_fields
1295+
thought_signature = None
12531296
provider_fields = function_call.get("provider_specific_fields") if isinstance(function_call, dict) else {}
12541297
if isinstance(provider_fields, dict):
12551298
thought_signature = provider_fields.get("thought_signature")
1256-
if thought_signature:
1257-
part_dict_function["thoughtSignature"] = thought_signature
1299+
1300+
# If no signature found and model is gemini-3, use dummy signature
1301+
from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import VertexGeminiConfig
1302+
if not thought_signature and model and VertexGeminiConfig._is_gemini_3_or_newer(model):
1303+
thought_signature = _get_dummy_thought_signature()
1304+
1305+
if thought_signature:
1306+
part_dict_function["thoughtSignature"] = thought_signature
12581307

12591308
_parts_list.append(part_dict_function)
12601309
else: # don't silently drop params. Make it clear to user what's happening.

litellm/litellm_core_utils/streaming_chunk_builder_utils.py

Lines changed: 36 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,7 @@ def get_combined_tool_content(
137137
"name": None,
138138
"type": None,
139139
"arguments": [],
140+
"provider_specific_fields": None,
140141
}
141142

142143
if hasattr(tool_call, "id") and tool_call.id:
@@ -156,22 +157,48 @@ def get_combined_tool_content(
156157
tool_call_map[index]["arguments"].append(
157158
tool_call.function.arguments
158159
)
160+
161+
# Preserve provider_specific_fields from streaming chunks
162+
provider_fields = None
163+
if hasattr(tool_call, "provider_specific_fields") and tool_call.provider_specific_fields:
164+
provider_fields = tool_call.provider_specific_fields
165+
elif hasattr(tool_call, "function") and hasattr(tool_call.function, "provider_specific_fields") and tool_call.function.provider_specific_fields:
166+
provider_fields = tool_call.function.provider_specific_fields
167+
168+
if provider_fields:
169+
# Merge provider_specific_fields if multiple chunks have them
170+
if tool_call_map[index]["provider_specific_fields"] is None:
171+
tool_call_map[index]["provider_specific_fields"] = {}
172+
if isinstance(provider_fields, dict):
173+
tool_call_map[index]["provider_specific_fields"].update(
174+
provider_fields
175+
)
159176

160177
# Convert the map to a list of tool calls
161178
for index in sorted(tool_call_map.keys()):
162179
tool_call_data = tool_call_map[index]
163180
if tool_call_data["id"] and tool_call_data["name"]:
164181
combined_arguments = "".join(tool_call_data["arguments"]) or "{}"
165-
tool_calls_list.append(
166-
ChatCompletionMessageToolCall(
167-
id=tool_call_data["id"],
168-
function=Function(
169-
arguments=combined_arguments,
170-
name=tool_call_data["name"],
171-
),
172-
type=tool_call_data["type"] or "function",
173-
)
182+
183+
# Build function - provider_specific_fields should be on tool_call level, not function level
184+
function = Function(
185+
arguments=combined_arguments,
186+
name=tool_call_data["name"],
174187
)
188+
189+
# Prepare params for ChatCompletionMessageToolCall
190+
tool_call_params = {
191+
"id": tool_call_data["id"],
192+
"function": function,
193+
"type": tool_call_data["type"] or "function",
194+
}
195+
196+
# Add provider_specific_fields if present (for thought signatures in Gemini 3)
197+
if tool_call_data.get("provider_specific_fields"):
198+
tool_call_params["provider_specific_fields"] = tool_call_data["provider_specific_fields"]
199+
200+
tool_call = ChatCompletionMessageToolCall(**tool_call_params)
201+
tool_calls_list.append(tool_call)
175202

176203
return tool_calls_list
177204

litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py

Lines changed: 66 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
TYPE_CHECKING,
44
Any,
55
AsyncIterator,
6+
Dict,
67
List,
78
Literal,
89
Optional,
@@ -129,6 +130,39 @@ def __init__(self):
129130

130131
### FOR [BETA] `/v1/messages` endpoint support
131132

133+
def _extract_signature_from_tool_call(
134+
self, tool_call: Any
135+
) -> Optional[str]:
136+
"""
137+
Extract signature from a tool call's provider_specific_fields.
138+
Only checks provider_specific_fields, not thinking blocks.
139+
"""
140+
signature = None
141+
142+
if hasattr(tool_call, "provider_specific_fields") and tool_call.provider_specific_fields:
143+
if "thought_signature" in tool_call.provider_specific_fields:
144+
signature = tool_call.provider_specific_fields["thought_signature"]
145+
elif (
146+
hasattr(tool_call.function, "provider_specific_fields")
147+
and tool_call.function.provider_specific_fields
148+
):
149+
if "thought_signature" in tool_call.function.provider_specific_fields:
150+
signature = tool_call.function.provider_specific_fields["thought_signature"]
151+
152+
return signature
153+
154+
def _extract_signature_from_tool_use_content(
155+
self, content: Dict[str, Any]
156+
) -> Optional[str]:
157+
"""
158+
Extract signature from a tool_use content block's provider_specific_fields.
159+
"""
160+
provider_specific_fields = content.get("provider_specific_fields", {})
161+
if provider_specific_fields:
162+
return provider_specific_fields.get("signature")
163+
return None
164+
165+
132166
def translatable_anthropic_params(self) -> List:
133167
"""
134168
Which anthropic params, we need to translate to the openai format.
@@ -263,10 +297,18 @@ def translate_anthropic_messages_to_openai( # noqa: PLR0915
263297
else:
264298
assistant_message_str += content.get("text", "")
265299
elif content.get("type") == "tool_use":
266-
function_chunk = ChatCompletionToolCallFunctionChunk(
267-
name=content.get("name", ""),
268-
arguments=json.dumps(content.get("input", {})),
269-
)
300+
function_chunk: ChatCompletionToolCallFunctionChunk = {
301+
"name": content.get("name", ""),
302+
"arguments": json.dumps(content.get("input", {})),
303+
}
304+
signature = self._extract_signature_from_tool_use_content(content)
305+
306+
if signature:
307+
provider_specific_fields: Dict[str, Any] = (
308+
function_chunk.get("provider_specific_fields") or {}
309+
)
310+
provider_specific_fields["thought_signature"] = signature
311+
function_chunk["provider_specific_fields"] = provider_specific_fields
270312

271313
tool_calls.append(
272314
ChatCompletionAssistantToolCall(
@@ -512,18 +554,27 @@ def _translate_openai_content_to_anthropic(self, choices: List[Choices]) -> List
512554
and len(choice.message.tool_calls) > 0
513555
):
514556
for tool_call in choice.message.tool_calls:
515-
new_content.append(
516-
AnthropicResponseContentBlockToolUse(
517-
type="tool_use",
518-
id=tool_call.id,
519-
name=tool_call.function.name or "",
520-
input=(
521-
json.loads(tool_call.function.arguments)
522-
if tool_call.function.arguments
523-
else {}
524-
),
525-
)
557+
# Extract signature from provider_specific_fields only
558+
signature = self._extract_signature_from_tool_call(tool_call)
559+
560+
provider_specific_fields = {}
561+
if signature:
562+
provider_specific_fields["signature"] = signature
563+
564+
tool_use_block = AnthropicResponseContentBlockToolUse(
565+
type="tool_use",
566+
id=tool_call.id,
567+
name=tool_call.function.name or "",
568+
input=(
569+
json.loads(tool_call.function.arguments)
570+
if tool_call.function.arguments
571+
else {}
572+
),
526573
)
574+
# Add provider_specific_fields if signature is present
575+
if provider_specific_fields:
576+
tool_use_block.provider_specific_fields = provider_specific_fields
577+
new_content.append(tool_use_block)
527578
# Handle text content
528579
elif choice.message.content is not None:
529580
new_content.append(

litellm/llms/gemini/chat/transformation.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,4 +140,4 @@ def _transform_messages(
140140
except Exception:
141141
# If conversion fails, leave as is and let the API handle it
142142
pass
143-
return _gemini_convert_messages_with_history(messages=messages)
143+
return _gemini_convert_messages_with_history(messages=messages, model=model)

litellm/llms/vertex_ai/context_caching/transformation.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,7 @@ def transform_openai_messages_to_gemini_context_caching(
173173
supports_system_message=supports_system_message, messages=messages
174174
)
175175

176-
transformed_messages = _gemini_convert_messages_with_history(messages=new_messages)
176+
transformed_messages = _gemini_convert_messages_with_history(messages=new_messages, model=model)
177177

178178
model_name = "models/{}".format(model)
179179

litellm/llms/vertex_ai/gemini/transformation.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,7 @@ def check_if_part_exists_in_parts(
195195

196196
def _gemini_convert_messages_with_history( # noqa: PLR0915
197197
messages: List[AllMessageValues],
198+
model: Optional[str] = None,
198199
) -> List[ContentType]:
199200
"""
200201
Converts given messages from OpenAI format to Gemini format
@@ -379,7 +380,7 @@ def _gemini_convert_messages_with_history( # noqa: PLR0915
379380
or assistant_msg.get("function_call") is not None
380381
): # support assistant tool invoke conversion
381382
gemini_tool_call_parts = convert_to_gemini_tool_call_invoke(
382-
assistant_msg
383+
assistant_msg, model=model
383384
)
384385
## check if gemini_tool_call already exists in assistant_content
385386
for gemini_tool_call_part in gemini_tool_call_parts:

litellm/llms/vertex_ai/gemini/vertex_and_google_ai_studio_gemini.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -898,11 +898,11 @@ def map_openai_params( # noqa: PLR0915
898898
if VertexGeminiConfig._is_gemini_3_or_newer(model):
899899
if "temperature" not in optional_params:
900900
optional_params["temperature"] = 1.0
901+
thinking_config = optional_params.get("thinkingConfig", {})
901902
if (
902-
"thinkingConfig" not in optional_params
903-
or "thinkingLevel" not in optional_params.get("thinkingConfig", {})
903+
"thinkingLevel" not in thinking_config
904+
and "thinkingBudget" not in thinking_config
904905
):
905-
thinking_config = optional_params.get("thinkingConfig", {})
906906
thinking_config["thinkingLevel"] = "low"
907907
optional_params["thinkingConfig"] = thinking_config
908908

@@ -1892,7 +1892,7 @@ def _transform_google_generate_content_to_openai_model_response(
18921892
def _transform_messages(
18931893
self, messages: List[AllMessageValues], model: Optional[str] = None
18941894
) -> List[ContentType]:
1895-
return _gemini_convert_messages_with_history(messages=messages)
1895+
return _gemini_convert_messages_with_history(messages=messages, model=model)
18961896

18971897
def get_error_class(
18981898
self, error_message: str, status_code: int, headers: Union[Dict, httpx.Headers]

0 commit comments

Comments
 (0)