From 8034fdbb9e9c791f518f39654cdcbffaff99906b Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Tue, 20 Oct 2026 20:47:01 -0700 Subject: [PATCH] fix(gemini): read the thought signature back from Responses input and coerce the per-request setting A Responses function_call input item now keeps its provider_specific_fields, so a client that sends the item back gets its real thought signature replayed to Gemini. A per-request thought_signature_in_tool_call_id of "false" or 0 now keeps ids short, and a value that is not a boolean answers 400, or is dropped under drop_params, instead of a 500. The e2e test now uses the sdk fixture and @meta subjects --- .../prompt_templates/factory.py | 6 +- litellm/main.py | 39 ++- .../transformation.py | 17 +- .../test_gemini_tool_call_id_length_e2e.py | 237 ++++++++---------- .../test_thought_signature_in_tool_call_id.py | 148 ++++++++--- 5 files changed, 275 insertions(+), 172 deletions(-) diff --git a/litellm/litellm_core_utils/prompt_templates/factory.py b/litellm/litellm_core_utils/prompt_templates/factory.py index ad966fde244..5f5249bfc9f 100644 --- a/litellm/litellm_core_utils/prompt_templates/factory.py +++ b/litellm/litellm_core_utils/prompt_templates/factory.py @@ -221,6 +221,7 @@ def _handle_ollama_system_message(messages: list, prompt: str, msg_i: int) -> tu _OLLAMA_USER_ROLES: Final = frozenset({"user", "tool", "function"}) _DECODED_JSON: Final = TypeAdapter(object) +_OPTIONAL_BOOL: Final = TypeAdapter[bool | None](bool | None) def _ollama_bad_message(model: str, message: AllMessageValues, msg_i: int, detail: str) -> "litellm.BadRequestError": @@ -1269,7 +1270,10 @@ def _same_tool_call(tool_call_id: str, other_tool_call_id: str) -> bool: def embeds_thought_signature_in_tool_call_id(litellm_params: Mapping[str, object]) -> bool: - return litellm_params.get("thought_signature_in_tool_call_id") is not False + try: + return _OPTIONAL_BOOL.validate_python(litellm_params.get("thought_signature_in_tool_call_id")) is not False + except ValidationError: + return True def get_thought_signature_from_tool(tool: Mapping[str, object]) -> str | None: diff --git a/litellm/main.py b/litellm/main.py index c9641f1cd54..701a16fda32 100644 --- a/litellm/main.py +++ b/litellm/main.py @@ -381,6 +381,27 @@ def _resolve_control_options(kwargs: Mapping[str, object], model: str) -> Contro return assert_never(control) +def _supplemental_provider_params(kwargs: Mapping[str, object], model: str) -> GenericLiteLLMParams | None: + supplemental: Final = {key: kwargs[key] for key in OPTIONAL_KWARGS_KEYS if key in kwargs} + if not supplemental: + return None + try: + return GenericLiteLLMParams.model_validate(supplemental) + except ValidationError as error: + invalid: Final = tuple(detail for detail in error.errors() if detail["loc"]) + invalid_keys: Final = frozenset(str(detail["loc"][0]) for detail in invalid) + if litellm.drop_params is True or normalize_drop_params(kwargs.get("drop_params")) is True: + return GenericLiteLLMParams.model_validate( + {key: value for key, value in supplemental.items() if key not in invalid_keys} + ) + raise litellm.BadRequestError( + message="; ".join(f"{detail['loc'][0]}: {detail['msg']}" for detail in invalid), + model=model, + llm_provider=None, + body={"param": ", ".join(sorted(invalid_keys))}, + ) from error + + ####### COMPLETION ENDPOINTS ################ @@ -672,17 +693,15 @@ async def acompletion( "enable_json_schema_validation": enable_json_schema_validation, } if custom_llm_provider is None: - _supplemental: Final = cast( # cast-ok: kwargs values are request-scoped objects - "dict[str, object]", {k: kwargs[k] for k in OPTIONAL_KWARGS_KEYS if k in kwargs} - ) + request_kwargs: Final = cast("Mapping[str, object]", kwargs) # cast-ok: untyped request kwargs _, custom_llm_provider, _, _ = get_llm_provider( model=model, custom_llm_provider=custom_llm_provider, api_base=cast( # cast-ok: api_base arrives as a str in the request kwargs - "str | None", kwargs.get("api_base") + "str | None", request_kwargs.get("api_base") ) or base_url, - litellm_params=(GenericLiteLLMParams.model_validate(_supplemental) if _supplemental else None), + litellm_params=_supplemental_provider_params(request_kwargs, model), ) fallbacks = fallbacks or litellm.model_fallbacks @@ -5657,16 +5676,15 @@ def completion( if deployment_id is not None: # azure llms model = deployment_id custom_llm_provider = "azure" - _supplemental_provider_params: Final = {k: kwargs[k] for k in OPTIONAL_KWARGS_KEYS if k in kwargs} + routing_kwargs: Final = cast("Mapping[str, object]", kwargs) # cast-ok: kwargs is the untyped request dict model, custom_llm_provider, dynamic_api_key, api_base = get_llm_provider( model=model, custom_llm_provider=custom_llm_provider, api_base=api_base, api_key=api_key, - litellm_params=( - GenericLiteLLMParams.model_validate(_supplemental_provider_params) - if _supplemental_provider_params - else None + litellm_params=_supplemental_provider_params( + routing_kwargs, + cast(str, model), # cast-ok: model is the alias-resolved request model name ), ) @@ -5680,7 +5698,6 @@ def completion( cast(object, model_info) # cast-ok: model_info is the untyped dict kwarg ), ) - routing_kwargs: Final = cast("Mapping[str, object]", kwargs) # cast-ok: kwargs is the untyped request dict drop_unsupported_reasoning: Final = ( litellm.drop_params is True or normalize_drop_params(routing_kwargs.get("drop_params")) is True ) diff --git a/litellm/responses/litellm_completion_transformation/transformation.py b/litellm/responses/litellm_completion_transformation/transformation.py index d9ebf50cc1a..1c37aaac6fe 100644 --- a/litellm/responses/litellm_completion_transformation/transformation.py +++ b/litellm/responses/litellm_completion_transformation/transformation.py @@ -45,7 +45,7 @@ from litellm.constants import ( REDACTED_TOOL_CALL_ARGUMENTS_PLACEHOLDER, ) from litellm.integrations.anthropic_cache_control_hook import MAX_CACHE_CONTROL_BLOCKS, AnthropicCacheControlHook -from litellm.litellm_core_utils.core_helpers import drops_unsupported_params, normalize_drop_params +from litellm.litellm_core_utils.core_helpers import as_str_mapping, drops_unsupported_params, normalize_drop_params from litellm.litellm_core_utils.get_supported_openai_params import ( get_supported_openai_params, ) @@ -252,8 +252,9 @@ class _ResponsesToolCallItem(Protocol): def get(self, key: str, /) -> object: ... -class _MarkedToolCallChunk(ChatCompletionToolCallChunk): +class _ReplayedToolCallChunk(ChatCompletionToolCallChunk, total=False): cache_control: ReadOnly[ChatCompletionCachedContent] + provider_specific_fields: ReadOnly[Mapping[str, object]] class _MarkedToolMessage(ChatCompletionToolMessage): @@ -2231,7 +2232,7 @@ class LiteLLMCompletionResponsesConfig: tool_call: Final = ( ChatCompletionToolCallChunk(id=tool_call_id, type="function", function=tool_call_function, index=0) if function_call_cache_control is None - else _MarkedToolCallChunk( + else _ReplayedToolCallChunk( id=tool_call_id, type="function", function=tool_call_function, @@ -2239,9 +2240,17 @@ class LiteLLMCompletionResponsesConfig: cache_control=function_call_cache_control, ) ) + provider_specific_fields: Final = as_str_mapping(function_call.get("provider_specific_fields")) + replayed_tool_call: Final = ( + tool_call + if provider_specific_fields is None + else cast( # cast-ok: the same tool call plus the provider_specific_fields the client sent back + _ReplayedToolCallChunk, {**tool_call, "provider_specific_fields": provider_specific_fields} + ) + ) # Create an assistant message with the tool call - return [ChatCompletionResponseMessage(tool_calls=[tool_call], role="assistant", content=None)] + return [ChatCompletionResponseMessage(tool_calls=[replayed_tool_call], role="assistant", content=None)] @staticmethod def _resolve_file_id(item: Mapping[str, object]) -> object: diff --git a/tests/e2e/llm_translation/test_gemini_tool_call_id_length_e2e.py b/tests/e2e/llm_translation/test_gemini_tool_call_id_length_e2e.py index c530822f3a3..883b6c08aa2 100644 --- a/tests/e2e/llm_translation/test_gemini_tool_call_id_length_e2e.py +++ b/tests/e2e/llm_translation/test_gemini_tool_call_id_length_e2e.py @@ -1,26 +1,27 @@ from __future__ import annotations import re -from itertools import chain +from collections.abc import Iterator from typing import Final import pytest from e2e_config import unique_marker -from e2e_http import Success, unwrap +from e2e_metadata import Capability, Domain, Mode, Provider, Route, Subject, meta from lifecycle import ResourceManager -from models import ( - ChatAssistantTurn, - ChatBody, - ChatMessage, - ChatResponse, - ChatTool, - ChatToolFunction, - ChatToolResultTurn, - LiteLLMParamsBody, - ToolCall, +from llm_translation.sdk_clients import NO_PROXY_CACHE, SdkClients +from models import LiteLLMParamsBody +from openai.types.chat import ( + ChatCompletion, + ChatCompletionAssistantMessageParam, + ChatCompletionChunk, + ChatCompletionMessageFunctionToolCallParam, + ChatCompletionToolMessageParam, + ChatCompletionToolParam, + ChatCompletionUserMessageParam, ) +from openai.types.chat.chat_completion_chunk import ChoiceDeltaToolCall +from openai.types.chat.chat_completion_message_function_tool_call import ChatCompletionMessageFunctionToolCall from passthrough_client import PassthroughClient -from pydantic import BaseModel pytestmark = pytest.mark.e2e @@ -28,33 +29,25 @@ BACKEND_MODEL: Final = "gemini/gemini-3.5-flash" GEMINI_API_KEY: Final = "os.environ/GEMINI_API_KEY" # bedrock-agentcore HarnessToolUseId shape in botocore 1.43.6, read 2026-10-20 AGENTCORE_TOOL_USE_ID: Final = re.compile(r"[a-zA-Z0-9_-]{1,64}") -WEATHER_TOOL: Final = ChatTool( - function=ChatToolFunction( - name="get_weather", - description="Get the weather for a city", - parameters={ - "type": "object", - "properties": {"city": {"type": "string"}}, - "required": ["city"], - }, +WEATHER_TOOL: Final[ChatCompletionToolParam] = { + "type": "function", + "function": { + "name": "get_weather", + "description": "Get the weather for a city", + "parameters": {"type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"]}, + }, +} + + +def _subject(mode: Mode) -> Subject: + return Subject( + domain=Domain.LLM_TRANSLATION, + route=Route.CHAT_COMPLETIONS, + providers=(Provider.GEMINI,), + models=(BACKEND_MODEL,), + mode=mode, + capabilities=(Capability.FUNCTION_CALLING, Capability.TOOL_CHOICE), ) -) - - -class _StreamToolCall(BaseModel): - id: str | None = None - - -class _StreamDelta(BaseModel): - tool_calls: tuple[_StreamToolCall, ...] = () - - -class _StreamChoice(BaseModel): - delta: _StreamDelta - - -class _StreamChunk(BaseModel): - choices: tuple[_StreamChoice, ...] = () def _register_model(client: PassthroughClient, resources: ResourceManager) -> str: @@ -71,44 +64,42 @@ def _register_model(client: PassthroughClient, resources: ResourceManager) -> st return model_name -def _tool_request(model: str, *, stream: bool = False) -> ChatBody: - return ChatBody( - model=model, - messages=( - ChatMessage( - role="user", - content=f"Use get_weather for San Francisco and return its result. {unique_marker()}", - ), - ), - tools=(WEATHER_TOOL,), - tool_choice="required", - max_tokens=128, - stream=stream, +def _user_message() -> ChatCompletionUserMessageParam: + return {"role": "user", "content": f"Use get_weather for San Francisco and return its result. {unique_marker()}"} + + +def _tool_calls(completion: ChatCompletion) -> tuple[ChatCompletionMessageFunctionToolCall, ...]: + assert completion.choices, f"Gemini returned no choices: {completion.model_dump_json()[:400]}" + calls: Final = tuple( + call + for call in completion.choices[0].message.tool_calls or () + if isinstance(call, ChatCompletionMessageFunctionToolCall) ) - - -def _tool_calls(response: ChatResponse) -> tuple[ToolCall, ...]: - assert response.choices, f"Gemini returned no choices: {response}" - message: Final = response.choices[0].message - calls: Final = tuple(message.tool_calls or ()) if message else () - assert calls, f"Gemini returned no tool calls for tool_choice='required': {response}" + assert calls, f"Gemini returned no tool calls for tool_choice='required': {completion.model_dump_json()[:400]}" return calls -def _tool_call_id(call: ToolCall) -> str: - assert call.id, f"Gemini returned a tool call without an id: {call}" - return call.id - - def _assert_agentcore_accepts_ids(ids: tuple[str, ...]) -> None: rejected: Final = tuple(call_id for call_id in ids if not AGENTCORE_TOOL_USE_ID.fullmatch(call_id)) assert not rejected, f"tool call id(s) AgentCore would reject: {tuple(call_id[:80] for call_id in rejected)}" -def _stream_tool_calls(events: list[str]) -> tuple[_StreamToolCall, ...]: - chunks: Final = tuple(_StreamChunk.model_validate_json(event) for event in events) - choices: Final = chain.from_iterable(chunk.choices for chunk in chunks) - return tuple(chain.from_iterable(choice.delta.tool_calls for choice in choices)) +def _streamed_tool_calls(chunks: tuple[ChatCompletionChunk, ...]) -> Iterator[ChoiceDeltaToolCall]: + for chunk in chunks: + for choice in chunk.choices: + yield from choice.delta.tool_calls or () + + +def _short_id_tool_call(call: ChatCompletionMessageFunctionToolCall) -> ChatCompletionMessageFunctionToolCallParam: + return { + "id": call.id, + "type": "function", + "function": {"name": call.function.name, "arguments": call.function.arguments}, + } + + +def _tool_result(call: ChatCompletionMessageFunctionToolCall) -> ChatCompletionToolMessageParam: + return {"role": "tool", "tool_call_id": call.id, "content": '{"temperature": 21, "condition": "sunny"}'} class TestGeminiToolCallIdLength: @@ -116,37 +107,44 @@ class TestGeminiToolCallIdLength: "llm.chat_completions.gemini.tool_use.nonstream.works", exercised_on=["chat_completions"], ) + @meta(_subject(Mode.NONSTREAM)) def test_non_streaming_tool_call_id_fits_agentcore( - self, client: PassthroughClient, resources: ResourceManager + self, client: PassthroughClient, resources: ResourceManager, sdk: SdkClients ) -> None: model: Final = _register_model(client, resources) - response: Final = unwrap( - client.proxy.chat(resources.key(), _tool_request(model)) + completion: Final = sdk.openai(resources.key()).chat.completions.create( + model=model, + messages=[_user_message()], + tools=[WEATHER_TOOL], + tool_choice="required", + max_tokens=128, + extra_body=NO_PROXY_CACHE, ) - calls: Final = _tool_calls(response) - ids: Final = tuple(_tool_call_id(call) for call in calls) - _assert_agentcore_accepts_ids(ids) + _assert_agentcore_accepts_ids(tuple(call.id for call in _tool_calls(completion))) @pytest.mark.covers( "llm.chat_completions.gemini.tool_use.stream.works", exercised_on=["chat_completions"], ) + @meta(_subject(Mode.STREAM)) def test_streaming_tool_call_id_fits_agentcore( - self, client: PassthroughClient, resources: ResourceManager + self, client: PassthroughClient, resources: ResourceManager, sdk: SdkClients ) -> None: model: Final = _register_model(client, resources) - result: Final = client.proxy.chat_stream( - resources.key(), _tool_request(model, stream=True) + chunks: Final[tuple[ChatCompletionChunk, ...]] = tuple( + sdk.openai(resources.key()).chat.completions.create( + model=model, + messages=[_user_message()], + tools=[WEATHER_TOOL], + tool_choice="required", + max_tokens=128, + stream=True, + extra_body=NO_PROXY_CACHE, + ) ) - assert result.ok, f"Gemini chat stream failed: {result}" - assert result.is_streaming, f"Gemini response was not SSE: {result.content_type!r}" - assert result.stream_error is None, f"Gemini stream contained an error: {result.stream_error}" - - calls: Final = _stream_tool_calls(result.stream_events) - assert calls, f"Gemini stream returned no tool call deltas: {result.stream_events[:5]}" - ids: Final = tuple(call.id for call in calls if call.id) - assert ids, f"Gemini stream tool call deltas contained no ids: {calls}" + ids: Final = tuple(call.id for call in _streamed_tool_calls(chunks) if call.id) + assert ids, f"Gemini stream returned no tool call ids in {len(chunks)} chunks" _assert_agentcore_accepts_ids(ids) @@ -154,49 +152,36 @@ class TestGeminiToolCallIdLength: "llm.chat_completions.gemini.multi_turn.nonstream.works", exercised_on=["chat_completions"], ) + @meta(_subject(Mode.NONSTREAM)) def test_tool_call_round_trip_succeeds( - self, client: PassthroughClient, resources: ResourceManager + self, client: PassthroughClient, resources: ResourceManager, sdk: SdkClients ) -> None: model: Final = _register_model(client, resources) - key: Final = resources.key() - first_request: Final = _tool_request(model) - first_response: Final = unwrap(client.proxy.chat(key, first_request)) - calls: Final = _tool_calls(first_response) - first_message: Final = first_response.choices[0].message - assert first_message is not None, f"Gemini returned no assistant message: {first_response}" - ids: Final = tuple(_tool_call_id(call) for call in calls) + openai_client: Final = sdk.openai(resources.key()) + question: Final = _user_message() + first: Final = openai_client.chat.completions.create( + model=model, + messages=[question], + tools=[WEATHER_TOOL], + tool_choice="required", + max_tokens=128, + extra_body=NO_PROXY_CACHE, + ) + calls: Final = _tool_calls(first) + assistant: Final[ChatCompletionAssistantMessageParam] = { + "role": "assistant", + "tool_calls": [_short_id_tool_call(call) for call in calls], + } - second_result: Final = client.proxy.chat( - key, - ChatBody( - model=model, - messages=( - *first_request.messages, - ChatAssistantTurn( - content=first_message.content, - reasoning_content=first_message.reasoning_content, - tool_calls=first_message.tool_calls, - ), - *tuple( - ChatToolResultTurn( - tool_call_id=call_id, - content='{"temperature": 21, "condition": "sunny"}', - ) - for call_id in ids - ), - ), - tools=(WEATHER_TOOL,), - tool_choice="none", - max_tokens=128, - ), - ) - assert isinstance(second_result, Success), f"Gemini turn 2 failed: {second_result}" - assert second_result.status_code == 200, ( - f"Gemini turn 2 returned HTTP {second_result.status_code}, expected 200" - ) - second_response: Final = second_result.data - assert second_response.choices, f"Gemini turn 2 returned no choices: {second_response}" - answer: Final = second_response.choices[0].message - assert answer is not None and answer.content and answer.content.strip(), ( - f"Gemini turn 2 returned an empty answer: {second_response}" + second: Final = openai_client.chat.completions.create( + model=model, + messages=[question, assistant, *(_tool_result(call) for call in calls)], + tools=[WEATHER_TOOL], + tool_choice="none", + max_tokens=128, + extra_body=NO_PROXY_CACHE, ) + + assert second.choices, f"Gemini turn 2 returned no choices: {second.model_dump_json()[:400]}" + answer: Final = second.choices[0].message.content + assert answer and answer.strip(), f"Gemini turn 2 returned an empty answer: {second.model_dump_json()[:400]}" diff --git a/tests/unit/llms/vertex_ai/gemini/test_thought_signature_in_tool_call_id.py b/tests/unit/llms/vertex_ai/gemini/test_thought_signature_in_tool_call_id.py index 3f64b108a54..6c3aed5b04f 100644 --- a/tests/unit/llms/vertex_ai/gemini/test_thought_signature_in_tool_call_id.py +++ b/tests/unit/llms/vertex_ai/gemini/test_thought_signature_in_tool_call_id.py @@ -11,13 +11,14 @@ enable_preview_features=True to be enabled. import json import re -from collections.abc import Mapping +from collections.abc import Mapping, Sequence from itertools import chain from typing import Final import httpx import pytest import respx +from typing_extensions import ReadOnly, TypedDict import litellm from litellm.litellm_core_utils.prompt_templates.factory import ( @@ -339,6 +340,37 @@ def _gemini_response(native_id: str | None, stream: bool) -> httpx.Response: return httpx.Response(200, json=body) +def _gemini_answer() -> httpx.Response: + return httpx.Response( + 200, + json={ + "candidates": [ + {"content": {"role": "model", "parts": [{"text": "Sunny"}]}, "finishReason": "STOP", "index": 0} + ], + "usageMetadata": {"promptTokenCount": 9, "candidatesTokenCount": 1, "totalTokenCount": 10}, + }, + ) + + +class _GeminiTurn(TypedDict): + role: ReadOnly[str] + parts: ReadOnly[Sequence[Mapping[str, object]]] + + +class _GeminiRequestBody(TypedDict): + contents: ReadOnly[Sequence[_GeminiTurn]] + + +def _is_bounded(tool_call_id: object) -> bool: + return isinstance(tool_call_id, str) and BOUNDED_TOOL_CALL_ID.fullmatch(tool_call_id) is not None + + +def _replayed_signatures(sent_body: _GeminiRequestBody) -> list[object]: + model_turns: Final = [turn for turn in sent_body["contents"] if turn["role"] == "model"] + model_parts: Final = chain.from_iterable(turn["parts"] for turn in model_turns) + return [part["thoughtSignature"] for part in model_parts if "function_call" in part] + + def _returned_tool_calls( native_id: str | None, stream: bool, request_params: Mapping[str, object] ) -> tuple[list[Mapping[str, object]], Mapping[str, object]]: @@ -369,7 +401,7 @@ def test_tool_call_id_stays_bounded_when_thought_signature_kept_out_of_it(stream tool_call_ids: Final = [call["id"] for call in tool_calls if call["id"]] assert len(tool_call_ids) == 1 - assert BOUNDED_TOOL_CALL_ID.fullmatch(tool_call_ids[0]) + assert _is_bounded(tool_call_ids[0]) assert native_id is None or tool_call_ids[0] == native_id assert tool_calls[0]["provider_specific_fields"]["thought_signature"] == LONG_SIGNATURE assert "thought_signature_in_tool_call_id" not in json.dumps(sent_body) @@ -400,17 +432,7 @@ def test_bounded_tool_call_id_round_trips_to_gemini(client_keeps_provider_fields } with respx.mock: - route: Final = respx.post(url__regex=GEMINI_URL_PATTERN).mock( - return_value=httpx.Response( - 200, - json={ - "candidates": [ - {"content": {"role": "model", "parts": [{"text": "Sunny"}]}, "finishReason": "STOP", "index": 0} - ], - "usageMetadata": {"promptTokenCount": 9, "candidatesTokenCount": 1, "totalTokenCount": 10}, - }, - ) - ) + route: Final = respx.post(url__regex=GEMINI_URL_PATTERN).mock(return_value=_gemini_answer()) litellm.completion( model="gemini/gemini-3.5-flash", messages=[ @@ -424,9 +446,7 @@ def test_bounded_tool_call_id_round_trips_to_gemini(client_keeps_provider_fields ) sent_body: Final = json.loads(route.calls.last.request.content) - model_turns: Final = [turn for turn in sent_body["contents"] if turn["role"] == "model"] - model_parts: Final = chain.from_iterable(turn["parts"] for turn in model_turns) - replayed_signatures: Final = [part["thoughtSignature"] for part in model_parts if "function_call" in part] + replayed_signatures: Final = _replayed_signatures(sent_body) assert len(replayed_signatures) == 1 assert (replayed_signatures[0] == LONG_SIGNATURE) is client_keeps_provider_fields assert replayed_signatures[0] @@ -435,17 +455,7 @@ def test_bounded_tool_call_id_round_trips_to_gemini(client_keeps_provider_fields def test_tool_result_with_bare_id_matches_call_whose_id_the_client_re_signed(): bare_id: Final = "call_601401" with respx.mock: - route: Final = respx.post(url__regex=GEMINI_URL_PATTERN).mock( - return_value=httpx.Response( - 200, - json={ - "candidates": [ - {"content": {"role": "model", "parts": [{"text": "Sunny"}]}, "finishReason": "STOP", "index": 0} - ], - "usageMetadata": {"promptTokenCount": 9, "candidatesTokenCount": 1, "totalTokenCount": 10}, - }, - ) - ) + route: Final = respx.post(url__regex=GEMINI_URL_PATTERN).mock(return_value=_gemini_answer()) litellm.completion( model="gemini/gemini-3.5-flash", messages=[ @@ -514,7 +524,7 @@ async def test_messages_tool_use_keeps_thought_signature_kept_out_of_its_id( tool_uses: Final = await _messages_tool_use_blocks(stream) assert len(tool_uses) == 1 - assert BOUNDED_TOOL_CALL_ID.fullmatch(tool_uses[0]["id"]) + assert _is_bounded(tool_uses[0]["id"]) assert tool_uses[0]["provider_specific_fields"]["signature"] == LONG_SIGNATURE @@ -545,10 +555,88 @@ def test_responses_function_call_keeps_thought_signature_kept_out_of_its_id(stre function_calls: Final = _responses_function_call_items(stream) assert len(function_calls) == 1 - assert BOUNDED_TOOL_CALL_ID.fullmatch(function_calls[0].call_id) + assert _is_bounded(function_calls[0].call_id) assert function_calls[0].provider_specific_fields["thought_signature"] == LONG_SIGNATURE +@pytest.mark.parametrize("client_keeps_provider_fields", [True, False]) +def test_responses_function_call_replays_its_thought_signature_to_gemini(client_keeps_provider_fields: bool): + function_call: Final = _responses_function_call_items(False)[0] + replayed_item: Final = { + "type": "function_call", + "call_id": function_call.call_id, + "name": function_call.name, + "arguments": function_call.arguments, + **( + {"provider_specific_fields": function_call.provider_specific_fields} + if client_keeps_provider_fields + else {} + ), + } + + with respx.mock: + route: Final = respx.post(url__regex=GEMINI_URL_PATTERN).mock(return_value=_gemini_answer()) + litellm.responses( + model="gemini/gemini-3.5-flash", + input=[ + {"role": "user", "content": USER_TURN["content"]}, + replayed_item, + {"type": "function_call_output", "call_id": function_call.call_id, "output": '{"temp_c": 21}'}, + ], + tools=[RESPONSES_WEATHER_TOOL], + api_key="test-gemini-key", + thought_signature_in_tool_call_id=False, + ) + sent_body: Final = json.loads(route.calls.last.request.content) + + replayed_signatures: Final = _replayed_signatures(sent_body) + assert len(replayed_signatures) == 1 + assert (replayed_signatures[0] == LONG_SIGNATURE) is client_keeps_provider_fields + assert replayed_signatures[0] + + +@pytest.mark.parametrize("requested_value", ["false", "False", 0]) +def test_request_setting_given_as_a_string_or_number_keeps_ids_bounded(requested_value: object): + tool_calls, _ = _returned_tool_calls(None, False, {"thought_signature_in_tool_call_id": requested_value}) + + assert _is_bounded(tool_calls[0]["id"]) + assert tool_calls[0]["provider_specific_fields"]["thought_signature"] == LONG_SIGNATURE + + +async def _complete(use_async: bool, **request_params: object) -> litellm.ModelResponse: + request: Final = { + "model": "gemini/gemini-3.5-flash", + "messages": [USER_TURN], + "tools": [WEATHER_TOOL], + "api_key": "test-gemini-key", + **request_params, + } + return await litellm.acompletion(**request) if use_async else litellm.completion(**request) + + +@pytest.mark.parametrize("use_async", [False, True]) +async def test_request_setting_that_is_not_a_boolean_answers_400(use_async: bool): + with respx.mock(assert_all_called=False): + respx.post(url__regex=GEMINI_URL_PATTERN).mock(return_value=_gemini_response(None, False)) + with pytest.raises(litellm.BadRequestError, match="thought_signature_in_tool_call_id") as raised: + await _complete(use_async, thought_signature_in_tool_call_id="maybe") + + assert raised.value.status_code == 400 + + +@pytest.mark.parametrize("use_async", [False, True]) +async def test_request_setting_that_is_not_a_boolean_is_dropped_under_drop_params( + use_async: bool, monkeypatch: pytest.MonkeyPatch +): + monkeypatch.setattr(litellm, "disable_aiohttp_transport", True) + with respx.mock: + respx.post(url__regex=GEMINI_URL_PATTERN).mock(return_value=_gemini_response(None, False)) + response: Final = await _complete(use_async, thought_signature_in_tool_call_id="maybe", drop_params=True) + + tool_call_id: Final = response.choices[0].message.tool_calls[0].id + assert get_thought_signature_from_tool({"id": tool_call_id}) == LONG_SIGNATURE + + @pytest.mark.parametrize("configured_value", ["false", "False", 0, False]) def test_deployment_setting_given_as_a_string_or_number_keeps_ids_bounded(configured_value: object): router: Final = litellm.Router( @@ -567,7 +655,7 @@ def test_deployment_setting_given_as_a_string_or_number_keeps_ids_bounded(config respx.post(url__regex=GEMINI_URL_PATTERN).mock(return_value=_gemini_response(None, False)) response: Final = router.completion(model="gemini-agentcore", messages=[USER_TURN], tools=[WEATHER_TOOL]) - assert BOUNDED_TOOL_CALL_ID.fullmatch(response.choices[0].message.tool_calls[0].id) + assert _is_bounded(response.choices[0].message.tool_calls[0].id) def test_deployment_setting_rejects_a_value_that_is_not_a_boolean():