diff --git a/litellm/caching/caching.py b/litellm/caching/caching.py index b58f40af155..91cf9bb2cfe 100644 --- a/litellm/caching/caching.py +++ b/litellm/caching/caching.py @@ -25,7 +25,11 @@ from litellm._internal_context import current_service_target, service_target from litellm._logging import verbose_logger from litellm.constants import CACHED_STREAMING_CHUNK_DELAY from litellm.integrations.otel.runtime import phase_span -from litellm.litellm_core_utils.model_param_helper import ModelParamHelper, peek_reasoning_summary_aliases +from litellm.litellm_core_utils.model_param_helper import ( + ModelParamHelper, + embeds_thought_signature_in_tool_call_id, + peek_reasoning_summary_aliases, +) from litellm.types.caching import * from litellm.types.utils import EmbeddingResponse, is_litellm_owned_kwarg @@ -421,7 +425,12 @@ class Cache: reasoning_summary_material: Final = ( "" if reasoning_summary is None else f"reasoning_summary: {reasoning_summary}" ) - hashed_cache_key = Cache._get_hashed_cache_key(cache_key + reasoning_summary_material) + tool_call_id_shape_material: Final = ( + "" if embeds_thought_signature_in_tool_call_id(kwargs) else "thought_signature_in_tool_call_id: False" + ) + hashed_cache_key = Cache._get_hashed_cache_key( + cache_key + reasoning_summary_material + tool_call_id_shape_material + ) hashed_cache_key = self._add_namespace_to_cache_key(hashed_cache_key, **kwargs) verbose_logger.debug( "\nCreated cache key: %s (source material length: %d)", diff --git a/litellm/litellm_core_utils/get_litellm_params.py b/litellm/litellm_core_utils/get_litellm_params.py index 29f96d61976..d6d9be4e132 100644 --- a/litellm/litellm_core_utils/get_litellm_params.py +++ b/litellm/litellm_core_utils/get_litellm_params.py @@ -84,6 +84,7 @@ OPTIONAL_KWARGS_KEYS: Final = ( "github_copilot_host", "github_copilot_user_session", "fireworks_forward_user_id", + "thought_signature_in_tool_call_id", "responses_api_version", "allowed_openai_params", PROVIDER_AFFINITY_HEADER_KWARG_KEY, @@ -99,6 +100,8 @@ OPTIONAL_KWARGS_KEYS: Final = ( # Backward-compatible alias for existing imports/tests. _OPTIONAL_KWARGS_KEYS: Final = OPTIONAL_KWARGS_KEYS +SUPPLEMENTAL_KWARGS_KEYS: Final = OPTIONAL_KWARGS_KEYS | {"provider_specific_header", "extra_headers"} + _CONTROL_OPTIONS: Final = TypeAdapter(ControlOptions) _CONTROL_OPTION_NAMES: Final = tuple(field.name for field in fields(ControlOptions)) _MAX_SHOWN_INT_BITS: Final = 64 diff --git a/litellm/litellm_core_utils/model_param_helper.py b/litellm/litellm_core_utils/model_param_helper.py index c9c7a0e89ac..b08596e5244 100644 --- a/litellm/litellm_core_utils/model_param_helper.py +++ b/litellm/litellm_core_utils/model_param_helper.py @@ -17,6 +17,7 @@ from openai.types.responses.response_create_params import ( ResponseCreateParamsNonStreaming, ResponseCreateParamsStreaming, ) +from pydantic import TypeAdapter, ValidationError from litellm._logging import verbose_logger from litellm.types.llms.anthropic import AnthropicMessagesRequest @@ -41,6 +42,16 @@ def peek_reasoning_summary_aliases(optional_params: Mapping[str, object]) -> obj return None +_OPTIONAL_BOOL: Final = TypeAdapter[bool | None](bool | None) + + +def embeds_thought_signature_in_tool_call_id(litellm_params: Mapping[str, object]) -> bool: + try: + return _OPTIONAL_BOOL.validate_python(litellm_params.get("thought_signature_in_tool_call_id")) is not False + except ValidationError: + return True + + class ModelParamHelper: # Cached at class level — deterministic set built from static OpenAI type annotations relevant_logging_args: ClassVar[frozenset[str]] = frozenset() diff --git a/litellm/litellm_core_utils/prompt_templates/factory.py b/litellm/litellm_core_utils/prompt_templates/factory.py index de896cde4dc..dc674017f5e 100644 --- a/litellm/litellm_core_utils/prompt_templates/factory.py +++ b/litellm/litellm_core_utils/prompt_templates/factory.py @@ -1252,6 +1252,22 @@ def encode_tool_call_id_with_signature(tool_call_id: str, thought_signature: str _encode_tool_call_id_with_signature = encode_tool_call_id_with_signature +def _tool_call_id_prefix(tool_call_id: object) -> str | None: + return tool_call_id.split(THOUGHT_SIGNATURE_SEPARATOR, 1)[0] if isinstance(tool_call_id, str) else None + + +def _called_tool_name(tool_call_id: str, tool_calls: Sequence[Mapping[str, object]]) -> str | None: + exact_matches: Final = tuple(call for call in tool_calls if call.get("id") == tool_call_id) + prefix: Final = _tool_call_id_prefix(tool_call_id) + prefix_matches: Final = tuple( + call for call in tool_calls if prefix and _tool_call_id_prefix(call.get("id")) == prefix + ) + matches: Final = exact_matches or (prefix_matches if len(prefix_matches) == 1 else ()) + function: Final = matches[0].get("function") if matches else None + name: Final = function.get("name") if isinstance(function, Mapping) else None + return name if isinstance(name, str) else None + + def get_thought_signature_from_tool(tool: Mapping[str, object]) -> str | None: """Extract thought signature from tool call's provider_specific_fields. @@ -1691,16 +1707,14 @@ def convert_to_gemini_tool_call_result( ) ) is not None: media_parts.append(media_part) - name: str | None = message.get("name", "") - # Recover name from last message with tool calls - if last_message_with_tool_calls: - tools: Final = last_message_with_tool_calls.get("tool_calls", []) - msg_tool_call_id: Final = message.get("tool_call_id", None) - for tool in tools: - prev_tool_call_id = tool.get("id", None) - if msg_tool_call_id and prev_tool_call_id and msg_tool_call_id == prev_tool_call_id: - name = tool.get("function", {}).get("name", "") + msg_tool_call_id: Final = message.get("tool_call_id", None) + called_tool_name: Final = ( + _called_tool_name(msg_tool_call_id, last_message_with_tool_calls.get("tool_calls") or ()) + if last_message_with_tool_calls and isinstance(msg_tool_call_id, str) and msg_tool_call_id + else None + ) + name: Final[str | None] = called_tool_name if called_tool_name is not None else message.get("name", "") # Echo the OpenAI tool_call_id on functionResponse (strip thought-signature suffix). gemini_call_id: str | None = None diff --git a/litellm/llms/anthropic/messages/adapters/transformation.py b/litellm/llms/anthropic/messages/adapters/transformation.py index bc9649ed867..138d016450d 100644 --- a/litellm/llms/anthropic/messages/adapters/transformation.py +++ b/litellm/llms/anthropic/messages/adapters/transformation.py @@ -398,6 +398,10 @@ class LiteLLMAnthropicMessagesAdapter: return None + def _streamed_tool_call_signature(self, tool_call_id: str, tool_call: object) -> str | None: + id_parts: Final = tool_call_id.split(THOUGHT_SIGNATURE_SEPARATOR, 1) + return id_parts[1] if len(id_parts) > 1 else self._extract_signature_from_tool_call(tool_call) + def _extract_signature_from_tool_use_content(self, content: Mapping[str, object]) -> str | None: """ Extract signature from a tool_use content block's provider_specific_fields. @@ -1712,10 +1716,7 @@ class LiteLLMAnthropicMessagesAdapter: ): raw_id = choice.delta.tool_calls[0].id or str(uuid.uuid4()) tool_name = choice.delta.tool_calls[0].function.name or "" - thought_sig: str | None = None - if THOUGHT_SIGNATURE_SEPARATOR in raw_id: - parts = raw_id.split(THOUGHT_SIGNATURE_SEPARATOR, 1) - thought_sig = parts[1] if len(parts) > 1 else None + thought_sig = self._streamed_tool_call_signature(raw_id, choice.delta.tool_calls[0]) tool_block: dict[str, object] = { "type": "tool_use", "id": normalize_anthropic_tool_use_id(raw_id), diff --git a/litellm/llms/vertex_ai/gemini/vertex_and_google_ai_studio_gemini.py b/litellm/llms/vertex_ai/gemini/vertex_and_google_ai_studio_gemini.py index 1ef5267df14..69580687048 100644 --- a/litellm/llms/vertex_ai/gemini/vertex_and_google_ai_studio_gemini.py +++ b/litellm/llms/vertex_ai/gemini/vertex_and_google_ai_studio_gemini.py @@ -31,9 +31,8 @@ from litellm.exceptions import UnsupportedParamsError from litellm.litellm_core_utils.core_helpers import as_str_mapping, drops_unsupported_params, normalize_drop_params from litellm.litellm_core_utils.get_model_cost_map import GetModelCostMap from litellm.litellm_core_utils.json_fragment_accumulator import JSONFragmentAccumulator -from litellm.litellm_core_utils.prompt_templates.factory import ( - encode_tool_call_id_with_signature, -) +from litellm.litellm_core_utils.model_param_helper import embeds_thought_signature_in_tool_call_id +from litellm.litellm_core_utils.prompt_templates.factory import encode_tool_call_id_with_signature from litellm.litellm_core_utils.reasoning_effort_utils import warn_dropped_reasoning from litellm.llms.base_llm.chat.transformation import BaseConfig, BaseLLMException from litellm.llms.custom_httpx.http_handler import ( @@ -2091,6 +2090,7 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig): parts: list[HttpxPartType], cumulative_tool_call_idx: int, is_function_call: bool | None, + thought_signature_in_tool_call_id: bool = True, ) -> tuple[ ChatCompletionToolCallFunctionChunk | None, list[ChatCompletionToolCallChunk] | None, @@ -2129,9 +2129,9 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig): # back on the matching `functionResponse`. if gemini_call_id: _tool_response_chunk["id"] = gemini_call_id - # Embed thought signature in ID for OpenAI client compatibility if thought_signature: _tool_response_chunk["provider_specific_fields"] = {"thought_signature": thought_signature} + if thought_signature and thought_signature_in_tool_call_id: _tool_response_chunk["id"] = encode_tool_call_id_with_signature( _tool_response_chunk["id"] or "", thought_signature ) @@ -2793,6 +2793,7 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig): model_response: Union[ModelResponse, "ModelResponseStream"], standard_optional_params: dict, cumulative_tool_call_index: int = 0, + thought_signature_in_tool_call_id: bool = True, ) -> tuple[list[dict], list[dict], list, list, int]: """ Helper method to process candidates and extract metadata @@ -2895,6 +2896,7 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig): parts=candidate["content"]["parts"], cumulative_tool_call_idx=cumulative_tool_call_index, is_function_call=is_function_call(standard_optional_params), + thought_signature_in_tool_call_id=thought_signature_in_tool_call_id, ) if "logprobsResult" in candidate: @@ -2978,6 +2980,7 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig): model_response: Union[ModelResponse, "ModelResponseStream"], standard_optional_params: dict[str, object], # mutable-ok: mirrors override contract cumulative_tool_call_index: int = 0, + thought_signature_in_tool_call_id: bool = True, ) -> tuple[ # mutable-ok: mirrors override contract list[dict[str, object]], list[dict[str, object]], @@ -2986,7 +2989,11 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig): int, ]: return cls._process_candidates( - _candidates, model_response, standard_optional_params, cumulative_tool_call_index + _candidates, + model_response, + standard_optional_params, + cumulative_tool_call_index, + thought_signature_in_tool_call_id, ) def transform_response( @@ -3069,7 +3076,14 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig): safety_ratings, citation_metadata, _, # cumulative_tool_call_index not needed in non-streaming - ) = VertexGeminiConfig._process_candidates(_candidates, model_response, logging_obj.optional_params) + ) = VertexGeminiConfig._process_candidates( + _candidates, + model_response, + logging_obj.optional_params, + thought_signature_in_tool_call_id=embeds_thought_signature_in_tool_call_id( + logging_obj.litellm_params # pyright: ignore[reportUnknownMemberType, reportUnknownArgumentType] # Logging.litellm_params is untyped + ), + ) usage: Final = VertexGeminiConfig._calculate_usage( completion_response=completion_response, @@ -3905,6 +3919,9 @@ class ModelResponseIterator: self.logging_obj = logging_obj self.response_headers = response_headers or {} self.is_function_call = check_is_function_call(logging_obj) + self.thought_signature_in_tool_call_id: Final = embeds_thought_signature_in_tool_call_id( + logging_obj.litellm_params # pyright: ignore[reportUnknownMemberType, reportUnknownArgumentType] # Logging.litellm_params is untyped + ) self.cumulative_tool_call_index: int = 0 self.has_seen_tool_calls: bool = False self._resume = resume @@ -3969,6 +3986,7 @@ class ModelResponseIterator: model_response, self.logging_obj.optional_params, cumulative_tool_call_index=self.cumulative_tool_call_index, + thought_signature_in_tool_call_id=self.thought_signature_in_tool_call_id, ) # Track whether tool_calls have been seen across streaming chunks. diff --git a/litellm/main.py b/litellm/main.py index 2320d1a3e01..d34910567a7 100644 --- a/litellm/main.py +++ b/litellm/main.py @@ -23,6 +23,7 @@ from collections.abc import AsyncIterator, Callable, Coroutine, Iterable, Mappin from concurrent import futures from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, wait from copy import deepcopy +from dataclasses import dataclass from functools import partial from types import MappingProxyType from typing import TYPE_CHECKING, Any, Final, Literal, Optional, Protocol, Union, cast, get_args @@ -82,8 +83,8 @@ from litellm.litellm_core_utils.dd_tracing import tracer from litellm.litellm_core_utils.get_litellm_params import ( AWS_CREDENTIAL_KWARGS_KEYS, OAUTH_TOKEN_EXCHANGE_KWARGS_KEYS, - OPTIONAL_KWARGS_KEYS, PROVIDER_AFFINITY_HEADER_KWARG_KEY, + SUPPLEMENTAL_KWARGS_KEYS, InvalidControlOption, parse_control_options, with_control_options, @@ -157,7 +158,7 @@ from litellm.types.completion import ( ) from litellm.types.litellm_params import ControlOptions, RetryStrategy from litellm.types.llms.openai import AllMessageValues -from litellm.types.router import GenericLiteLLMParams +from litellm.types.router import GenericLiteLLMParams, validate_litellm_params_strictly from litellm.types.utils import ( AgenticLoopParams, BaseUrlRoutingHint, @@ -399,6 +400,34 @@ def _resolve_control_options(kwargs: Mapping[str, object], model: str) -> Contro return assert_never(control) +@dataclass(frozen=True, slots=True) +class _SupplementalProviderParams: + params: GenericLiteLLMParams + dropped_keys: frozenset[str] = frozenset() + + +def _supplemental_provider_params(kwargs: Mapping[str, object], model: str) -> _SupplementalProviderParams: + supplemental: Final = {key: kwargs[key] for key in SUPPLEMENTAL_KWARGS_KEYS if key in kwargs} + try: + return _SupplementalProviderParams(params=validate_litellm_params_strictly(GenericLiteLLMParams, 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 _SupplementalProviderParams( + params=GenericLiteLLMParams.model_validate( + {key: value for key, value in supplemental.items() if key not in invalid_keys} + ), + dropped_keys=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 + + PROVIDERS_WITHOUT_DOCUMENT_INPUT: Final = frozenset({"ollama", "ollama_chat"}) PROVIDERS_DROPPING_UNDECLARED_PDF_INPUT: Final = frozenset({"azure_ai"}) @@ -830,21 +859,17 @@ 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 | {"provider_specific_header"} if k in kwargs}, - "extra_headers": extra_headers, - }, - ) + 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_ADAPTER.validate_python({**kwargs, "extra_headers": extra_headers}), model + ).params, ) if fallbacks is not None: @@ -4382,6 +4407,7 @@ _ADDITIONAL_DROP_PARAMS_ADAPTER: Final = TypeAdapter(list[str]) _OPTIONAL_PARAMS_ADAPTER: Final = TypeAdapter(dict[str, object]) _OLLAMA_EMBEDDING_HEADERS_ADAPTER: Final = TypeAdapter(dict[str, str]) _MODEL_INFO_ADAPTER: Final = TypeAdapter(Mapping[str, object]) +_REQUEST_KWARGS_ADAPTER: Final = TypeAdapter(Mapping[str, object]) _PROVIDER_ROUTING_HINTS: Final = TypeAdapter(ProviderRoutingHints) _BASE_URL_HINT: Final = TypeAdapter(BaseUrlRoutingHint) _OPAQUE_VALUE_ADAPTER: Final = TypeAdapter(object) @@ -5625,6 +5651,11 @@ def completion( ######### unpacking kwargs ##################### args: Final = _locals_snapshot(locals()) + supplemental_params: Final = _supplemental_provider_params( + _REQUEST_KWARGS_ADAPTER.validate_python({**kwargs, "extra_headers": extra_headers}), model + ) + for dropped_key in supplemental_params.dropped_keys: + kwargs.pop(dropped_key) # Set by the responses->completion fallback so completion() does not bridge # back to the Responses API: that round-trip mutually recurses forever for a @@ -5850,21 +5881,14 @@ 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 | {"provider_specific_header"} if k in kwargs}, - "extra_headers": extra_headers, - } + routing_kwargs: Final = cast("Mapping[str, object]", kwargs) # cast-ok: kwargs is the untyped request dict requested_model: Final = model 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_params.params, ) _record_agentic_loop_params( logging_obj=logging, @@ -5884,7 +5908,6 @@ def completion( api_base=api_base, deployment_model_info=deployment_model_info, ) - 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 ) @@ -6220,6 +6243,7 @@ def completion( "github_copilot_user_session", PROVIDER_AFFINITY_HEADER_KWARG_KEY, "fireworks_forward_user_id", + "thought_signature_in_tool_call_id", "responses_api_version", "allowed_openai_params", ) diff --git a/litellm/responses/litellm_completion_transformation/handler.py b/litellm/responses/litellm_completion_transformation/handler.py index e13151eff1b..04fe027cbcc 100644 --- a/litellm/responses/litellm_completion_transformation/handler.py +++ b/litellm/responses/litellm_completion_transformation/handler.py @@ -10,6 +10,7 @@ from pydantic import TypeAdapter import litellm from litellm.litellm_core_utils.core_helpers import drops_unsupported_params +from litellm.litellm_core_utils.model_param_helper import embeds_thought_signature_in_tool_call_id from litellm.responses.additional_tools import HoistedAdditionalTools, hoist_additional_tools from litellm.responses.litellm_completion_transformation.client_tool_search import bridge_client_tool_search from litellm.responses.litellm_completion_transformation.compaction_items import holds_compaction_summary @@ -148,6 +149,9 @@ class LiteLLMCompletionTransformationHandler: responses_api_request=bridged_request, custom_llm_provider=custom_llm_provider, litellm_metadata=kwargs.get("litellm_metadata", {}), + tool_call_provider_fields=not embeds_thought_signature_in_tool_call_id( + _REQUEST_KWARGS_ADAPTER.validate_python(kwargs) + ), ) raise ValueError(f"Unexpected response type: {type(litellm_completion_response)}") @@ -240,5 +244,8 @@ class LiteLLMCompletionTransformationHandler: responses_api_request=responses_api_request, custom_llm_provider=litellm_completion_request.get("custom_llm_provider"), litellm_metadata=kwargs.get("litellm_metadata", {}), + tool_call_provider_fields=not embeds_thought_signature_in_tool_call_id( + _REQUEST_KWARGS_ADAPTER.validate_python(kwargs) + ), ) raise ValueError(f"Unexpected response type: {type(litellm_completion_response)}") diff --git a/litellm/responses/litellm_completion_transformation/session_handler.py b/litellm/responses/litellm_completion_transformation/session_handler.py index ce2396355a2..7cf163e7ce8 100644 --- a/litellm/responses/litellm_completion_transformation/session_handler.py +++ b/litellm/responses/litellm_completion_transformation/session_handler.py @@ -200,6 +200,7 @@ class ResponsesSessionHandler: previous_response_id: str, item_cache_control: bool = False, client_response_ids: tuple[str, ...] = (), + tool_call_provider_fields: bool = False, stored_turns: StoredTurns | None = None, ) -> ChatCompletionSession: """ @@ -241,9 +242,13 @@ class ResponsesSessionHandler: for index in range(start.index, replayed_turns) ], item_cache_control, + tool_call_provider_fields, ) history_before_replay: Final = ResponsesSessionHandler._history_of_turns( - all_spend_logs[: start.index], proxy_server_requests[: start.index], item_cache_control + all_spend_logs[: start.index], + proxy_server_requests[: start.index], + item_cache_control, + tool_call_provider_fields, ) chat_completion_message_history: Final = ( list(with_stored_calls_the_input_answers(replayed_history, history_before_replay)) @@ -305,6 +310,7 @@ class ResponsesSessionHandler: spend_logs: Sequence["SpendLogsPayload"], proxy_server_requests: Sequence[Mapping[str, object] | None], item_cache_control: bool, + tool_call_provider_fields: bool, ) -> list["SessionMessage"]: return list( chain.from_iterable( @@ -313,6 +319,7 @@ class ResponsesSessionHandler: proxy_server_request_dict=proxy_server_request, chat_completion_message_history=[], item_cache_control=item_cache_control, + tool_call_provider_fields=tool_call_provider_fields, ) for spend_log, proxy_server_request in zip(spend_logs, proxy_server_requests, strict=True) ) @@ -330,6 +337,7 @@ class ResponsesSessionHandler: | Message ], item_cache_control: bool = False, + tool_call_provider_fields: bool = False, ): """ Extend the chat completion message history with the spend log payload @@ -362,6 +370,7 @@ class ResponsesSessionHandler: responses_api_request={}, replay_reasoning=True, item_cache_control=item_cache_control, + tool_call_provider_fields=tool_call_provider_fields, only_since_compaction=True, ) ) @@ -377,6 +386,7 @@ class ResponsesSessionHandler: responses_api_request={}, replay_reasoning=True, item_cache_control=item_cache_control, + tool_call_provider_fields=tool_call_provider_fields, only_since_compaction=True, ) ) diff --git a/litellm/responses/litellm_completion_transformation/streaming_iterator.py b/litellm/responses/litellm_completion_transformation/streaming_iterator.py index 892da541935..394f221e0d0 100644 --- a/litellm/responses/litellm_completion_transformation/streaming_iterator.py +++ b/litellm/responses/litellm_completion_transformation/streaming_iterator.py @@ -88,6 +88,20 @@ def _output_items_with_id(items: tuple[Any, ...], item_type: str, item_id: str | ) +def _tool_call_objects(tool_calls: object) -> tuple[object, ...]: + return tuple(tool_calls) if isinstance(tool_calls, list) else () + + +def _tool_call_provider_specific_fields(tool_call: object) -> Mapping[str, object]: + mapping: Final = as_str_mapping(tool_call) + fields: Final[object] = ( + mapping.get("provider_specific_fields") + if mapping is not None + else getattr(tool_call, "provider_specific_fields", None) + ) + return {"provider_specific_fields": fields} if fields else {} + + def _is_signed_thinking_block(block: object) -> bool: fields: Final = as_str_mapping(block) return fields is not None and isinstance(block, dict) and bool(fields.get("signature") or fields.get("data")) @@ -132,6 +146,7 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator): responses_api_request: ResponsesAPIOptionalRequestParams, custom_llm_provider: str | None = None, litellm_metadata: dict | None = None, + tool_call_provider_fields: bool = False, ): self.model: str = model self.litellm_custom_stream_wrapper: litellm.CustomStreamWrapper = litellm_custom_stream_wrapper @@ -139,6 +154,7 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator): self.responses_api_request: ResponsesAPIOptionalRequestParams = responses_api_request self.custom_llm_provider: str | None = custom_llm_provider self.litellm_metadata: dict | None = litellm_metadata or {} + self.tool_call_provider_fields: Final = tool_call_provider_fields # Store lightweight dict snapshots for stream_chunk_builder to reduce # repeated Pydantic attribute access in end-of-stream assembly. self.collected_chat_completion_chunks: list[dict[str, object]] = [] @@ -490,10 +506,7 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator): except Exception: tool_calls = None - if not tool_calls or not isinstance(tool_calls, list): - return - - for tc in tool_calls: + for tc in _tool_call_objects(tool_calls): call_id_raw = tc.get("id") if isinstance(tc, dict) else getattr(tc, "id", None) if not call_id_raw: continue @@ -568,7 +581,10 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator): type=ResponsesAPIStreamEvents.OUTPUT_ITEM_DONE, output_index=output_index, sequence_number=self._sequence_number, - item=BaseLiteLLMOpenAIResponseObject(**item_kwargs), + item=BaseLiteLLMOpenAIResponseObject( + **item_kwargs, + **(_tool_call_provider_specific_fields(tc) if self.tool_call_provider_fields else {}), + ), ) self._pending_tool_events.append(item_done_event) diff --git a/litellm/responses/litellm_completion_transformation/transformation.py b/litellm/responses/litellm_completion_transformation/transformation.py index 8f0a85d67de..4ac0d9f1257 100644 --- a/litellm/responses/litellm_completion_transformation/transformation.py +++ b/litellm/responses/litellm_completion_transformation/transformation.py @@ -46,7 +46,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, ) @@ -56,6 +56,7 @@ from litellm.litellm_core_utils.hidden_params import ( set_hidden_params, ) from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj +from litellm.litellm_core_utils.model_param_helper import embeds_thought_signature_in_tool_call_id from litellm.llms.anthropic.common_utils import supports_anthropic_cache_control from litellm.llms.bedrock.base_aws_llm import BaseAWSLLM from litellm.llms.bedrock.common_utils import bedrock_declared_cache_point_support @@ -365,8 +366,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): @@ -607,17 +609,28 @@ def _dropped_cache_control(_: object) -> None: return None -def _without_cache_control_key(item: object) -> object: +def _without_keys(item: object, keys: frozenset[str]) -> object: if not isinstance(item, dict): return item - return {key: value for key, value in _STR_KEY_DICT_ADAPTER.validate_python(item).items() if key != "cache_control"} + return {key: value for key, value in _STR_KEY_DICT_ADAPTER.validate_python(item).items() if key not in keys} -def without_item_cache_control(input: str | ResponseInputParam) -> str | ResponseInputParam: - if not isinstance(input, list): +def _without_item_keys(input: str | ResponseInputParam, keys: frozenset[str]) -> str | ResponseInputParam: + if not keys or not isinstance(input, list): return input - return cast( # cast-ok: the same input items, each minus its own cache_control key - ResponseInputParam, [_without_cache_control_key(item) for item in input] + return cast( # cast-ok: the same input items, each minus the given keys + ResponseInputParam, [_without_keys(item, keys) for item in input] + ) + + +def _dropped_item_keys(item_cache_control: bool, tool_call_provider_fields: bool) -> frozenset[str]: + return frozenset( + key + for key, kept in ( + ("cache_control", item_cache_control), + ("provider_specific_fields", tool_call_provider_fields), + ) + if not kept ) @@ -1187,6 +1200,7 @@ class LiteLLMCompletionResponsesConfig: responses_api_request=responses_api_request, replay_reasoning=True, item_cache_control=keeps_item_marks, + tool_call_provider_fields=not embeds_thought_signature_in_tool_call_id(request_kwargs), native_compaction=native_compaction, only_since_compaction=not native_compaction, ) @@ -1250,6 +1264,7 @@ class LiteLLMCompletionResponsesConfig: responses_api_request: ResponsesAPIOptionalRequestParams | dict, replay_reasoning: bool = False, item_cache_control: bool = False, + tool_call_provider_fields: bool = False, native_compaction: bool = False, only_since_compaction: bool = False, ) -> list[ @@ -1267,7 +1282,9 @@ class LiteLLMCompletionResponsesConfig: assistant ``reasoning_content`` and signed ``thinking_blocks`` so the provider gets its own chain-of-thought back instead of reading it as visible text. ``item_cache_control`` belongs to the same callers, when - the target keeps an input item's ``cache_control`` on the message. + the target keeps an input item's ``cache_control`` on the message, and + ``tool_call_provider_fields`` when the target reads a function call's + thought signature from ``provider_specific_fields`` instead of its id. Callers that only inspect the messages (token counting, rate limiting, guardrail scanning) leave both off, because they need every piece of text @@ -1305,7 +1322,7 @@ class LiteLLMCompletionResponsesConfig: read_input: Final = _input_to_read(dumped_input, only_since_compaction, native_compaction) messages.extend( LiteLLMCompletionResponsesConfig._transform_response_input_param_to_chat_completion_message( - input=read_input if item_cache_control else without_item_cache_control(read_input), + input=_without_item_keys(read_input, _dropped_item_keys(item_cache_control, tool_call_provider_fields)), replay_reasoning=replay_reasoning, native_compaction=native_compaction, ) @@ -1363,6 +1380,7 @@ class LiteLLMCompletionResponsesConfig: ResponsesSessionHandler.get_chat_completion_message_history_for_previous_response_id, item_cache_control=keeps_item_marks, client_response_ids=previous_response_client_ids, + tool_call_provider_fields=not embeds_thought_signature_in_tool_call_id(request_kwargs), stored_turns=stored_turns, ) chat_completion_session: Final = ( @@ -2546,7 +2564,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, @@ -2554,9 +2572,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 _transform_input_file_item_to_file_item(item: Mapping[str, object]) -> _ResponsesFilePart: diff --git a/litellm/router.py b/litellm/router.py index 535072d5fcf..381faf350ff 100644 --- a/litellm/router.py +++ b/litellm/router.py @@ -146,6 +146,7 @@ from litellm.litellm_core_utils.coroutine_checker import coroutine_checker from litellm.litellm_core_utils.credential_accessor import CredentialAccessor, reject_api_key_secret_reference from litellm.litellm_core_utils.dd_tracing import tracer from litellm.litellm_core_utils.deployment_pricing import deployment_pricing_models +from litellm.litellm_core_utils.get_litellm_params import SUPPLEMENTAL_KWARGS_KEYS from litellm.litellm_core_utils.get_llm_provider_logic import ( declared_authenticating_provider, inferred_provider, @@ -478,6 +479,7 @@ from litellm.types.router import ( SearchToolTypedDict, TaggedPreRoutingStrategy, holds_secret_pointer, + validate_litellm_params_strictly, ) from litellm.types.services import ServiceTypes from litellm.types.utils import ( @@ -722,6 +724,35 @@ def _drops_malformed_request_tools( return drop_policy({**deployment_params, **set_defaults, **request}).drops("tools") +def _malformed_request_params_the_deployment_sets( + deployment_params: Mapping[str, object], defaults: Mapping[str, object], request: Mapping[str, object] +) -> frozenset[str]: + overrides: Final = { + key: value + for key, value in request.items() + if key in SUPPLEMENTAL_KWARGS_KEYS and deployment_params.get(key) is not None + } + if not overrides: + return frozenset() + try: + validate_litellm_params_strictly(GenericLiteLLMParams, overrides) + except ValidationError as error: + set_defaults: Final = {name: value for name, value in defaults.items() if value is not None} + policy: Final = drop_policy({**deployment_params, **set_defaults, **request}) + malformed: Final = (str(detail["loc"][0]) for detail in error.errors() if detail["loc"]) + return frozenset(key for key in malformed if policy.drops(key)) + return frozenset() + + +def _drop_malformed_request_params_the_deployment_sets( + kwargs: dict[str, object], # mutable-ok: drops the keys from the router's request kwargs in place + deployment_params: Mapping[str, object], + defaults: Mapping[str, object], +) -> None: + for key in _malformed_request_params_the_deployment_sets(deployment_params, defaults, kwargs): + kwargs.pop(key) + + def _merged_tools(deployment_tools: Sequence[object], request_tools: object) -> list[object] | None: if not isinstance(request_tools, (list, tuple)): return None @@ -5214,6 +5245,11 @@ class Router(DeprecatedRouterMembers): if isinstance(raw_deployment_params, LiteLLM_Params) else raw_deployment_params ) + _drop_malformed_request_params_the_deployment_sets( + kwargs, # pyright: ignore[reportUnknownArgumentType] # kwargs is the router's untyped request dict + deployment_context, + defaults, + ) declared_provider: Final = deployment_context.get("custom_llm_provider") declared_model: Final = deployment_context.get("model") deployment_provider: Final = ( @@ -10939,7 +10975,9 @@ class Router(DeprecatedRouterMembers): merged_params: Final[Mapping[str, object]] = ( _litellm_params if zeroed_pricing is None else MappingProxyType({**_litellm_params, **zeroed_pricing}) ) - litellm_params: Final[LiteLLM_Params] = LiteLLM_Params.model_validate(dict(**merged_params)) + litellm_params: Final[LiteLLM_Params] = validate_litellm_params_strictly( + LiteLLM_Params, dict(**merged_params) + ) declared_params: Final = _LITELLM_PARAMS_ADAPTER.validate_python(_litellm_params) warn_on_provider_credential_mismatch(model_name=_model_name, litellm_params=declared_params) warn_on_ignored_model_capability_flags(model_name=_model_name, litellm_params=declared_params) diff --git a/litellm/types/litellm_params.py b/litellm/types/litellm_params.py index 9f1b35fdce3..1f90552e869 100644 --- a/litellm/types/litellm_params.py +++ b/litellm/types/litellm_params.py @@ -288,6 +288,7 @@ class PromptOptions: @dataclass(frozen=True, slots=True, kw_only=True) class ResponseOptions: merge_reasoning_content_in_choices: bool | None = None + thought_signature_in_tool_call_id: bool | None = None enable_json_schema_validation: bool | None = None complete_response: bool | None = None keepalive_seconds: float | None = None diff --git a/litellm/types/router.py b/litellm/types/router.py index cab78cee832..41a986837da 100644 --- a/litellm/types/router.py +++ b/litellm/types/router.py @@ -25,7 +25,20 @@ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError import httpx from openai import Timeout as SDKTimeout -from pydantic import BeforeValidator, ConfigDict, Field, JsonValue, StrictInt, field_validator, model_validator +from pydantic import ( + BaseModel, + BeforeValidator, + ConfigDict, + Field, + JsonValue, + StrictInt, + ValidationError, + ValidationInfo, + ValidatorFunctionWrapHandler, + WrapValidator, + field_validator, + model_validator, +) from typing_extensions import Protocol, ReadOnly, Required, TypedDict, runtime_checkable from litellm._logging import verbose_logger @@ -531,6 +544,27 @@ def holds_secret_pointer(param_name: str) -> bool: _RESERVED_INIT_KEYS: Final = frozenset({"self", "params", "__class__"}) +_STRICT_LITELLM_PARAMS: Final = MappingProxyType({"strict_litellm_params": True}) + +_StrictlyValidatedParams: Final = TypeVar("_StrictlyValidatedParams", bound=BaseModel) + + +def validate_litellm_params_strictly( + params_model: type[_StrictlyValidatedParams], params: Mapping[str, object] +) -> _StrictlyValidatedParams: + return params_model.model_validate(params, context=_STRICT_LITELLM_PARAMS) + + +def _none_when_malformed_outside_strict_validation( + value: object, handler: ValidatorFunctionWrapHandler, info: ValidationInfo +) -> object: + try: + return handler(value) + except ValidationError: + if info.context is _STRICT_LITELLM_PARAMS: + raise + return None + _DAILY_RESET_TIME_PATTERN: Final = re.compile(r"\d{2}:\d{2}") @@ -612,6 +646,9 @@ class GenericLiteLLMParams(CredentialLiteLLMParams, CustomPricingLiteLLMParams): ) model_config = ConfigDict(extra="allow", arbitrary_types_allowed=True) merge_reasoning_content_in_choices: bool | None = False + thought_signature_in_tool_call_id: Annotated[ + bool | None, WrapValidator(_none_when_malformed_outside_strict_validation) + ] = None model_info: dict | None = None mock_response: str | ModelResponse | Exception | object | None = None diff --git a/tests/code_coverage_tests/router_code_coverage.py b/tests/code_coverage_tests/router_code_coverage.py index 69ad0c91ee9..7df1cc8c1b0 100644 --- a/tests/code_coverage_tests/router_code_coverage.py +++ b/tests/code_coverage_tests/router_code_coverage.py @@ -124,6 +124,8 @@ ignored_function_names = [ "_is_fallback_hop", # Tested through the order fallback hop tests in test_router_order_fallback.py "_deployment_that_just_failed", # Tested through the same-boundary hop test in test_router_order_fallback.py "_merged_tools", # Tested through the _update_kwargs_with_deployment tools merge tests in test_router.py + "_malformed_request_params_the_deployment_sets", # Run by _update_kwargs_with_deployment, tested through the drop_params deployment test in test_thought_signature_in_tool_call_id.py + "_drop_malformed_request_params_the_deployment_sets", # Run by _update_kwargs_with_deployment, tested through the drop_params deployment test in test_thought_signature_in_tool_call_id.py "_drops_malformed_request_tools", # Tested through the _update_kwargs_with_deployment tools merge tests in test_router.py "_collect_ptu_share_teams", # Cached behind team_ptu_ceiling, tested through the live model list test in test_router.py "_resolve_team_ptu_ceiling", # Cached behind team_ptu_ceiling, tested through the live model list test in test_router.py diff --git a/tests/e2e/coverage_registry/llm_conversational.yaml b/tests/e2e/coverage_registry/llm_conversational.yaml index 0e450b08ebe..ad5e050a149 100644 --- a/tests/e2e/coverage_registry/llm_conversational.yaml +++ b/tests/e2e/coverage_registry/llm_conversational.yaml @@ -45,6 +45,9 @@ - {id: llm.chat_completions.vertex.basic.nonstream.works, module: llm, tier: P0, subject_endpoint: chat_completions, route: vertex, capability: basic, streaming: nonstream, assertions: [works], source: "proxy_server.py:8455", rationale: "P0 route; Vertex AI"} - {id: llm.chat_completions.gemini.basic.nonstream.works, module: llm, tier: P0, subject_endpoint: chat_completions, route: gemini, capability: basic, streaming: nonstream, assertions: [works], source: "test_chat_completions_regression_e2e.py", rationale: "Gemini OpenAI-compatible chat translation"} - {id: llm.chat_completions.gemini.basic.nonstream.cost_logged, module: llm, tier: P0, subject_endpoint: chat_completions, route: gemini, capability: basic, streaming: nonstream, assertions: [works, cost_logged], source: "test_chat_completions_regression_e2e.py", rationale: "Gemini chat cost lands in SpendLogs"} +- {id: llm.chat_completions.gemini.tool_use.nonstream.works, module: llm, tier: P1, subject_endpoint: chat_completions, route: gemini, capability: tool_use, streaming: nonstream, assertions: [works], source: "llm_translation/test_gemini_tool_call_id_length_e2e.py", rationale: "Gemini tool-call ids fit the AgentCore tool-use id shape"} +- {id: llm.chat_completions.gemini.tool_use.stream.works, module: llm, tier: P1, subject_endpoint: chat_completions, route: gemini, capability: tool_use, streaming: stream, assertions: [works], source: "llm_translation/test_gemini_tool_call_id_length_e2e.py", rationale: "Streamed Gemini tool-call ids fit the AgentCore tool-use id shape"} +- {id: llm.chat_completions.gemini.multi_turn.nonstream.works, module: llm, tier: P1, subject_endpoint: chat_completions, route: gemini, capability: multi_turn, streaming: nonstream, assertions: [works], source: "llm_translation/test_gemini_tool_call_id_length_e2e.py", rationale: "A short Gemini tool-call id still round-trips when the client drops provider fields"} - {id: llm.chat_completions.hosted_vllm.basic.nonstream.works, module: llm, tier: P0, subject_endpoint: chat_completions, route: hosted_vllm, capability: basic, streaming: nonstream, assertions: [works], source: "test_chat_completions_regression_e2e.py", rationale: "OpenAI-compatible hosted_vllm chat is a confirmed self-hosted backend path"} - {id: llm.chat_completions.cohere.basic.nonstream.works, module: llm, tier: P1, subject_endpoint: chat_completions, route: cohere, capability: basic, streaming: nonstream, assertions: [works], source: "test_chat_completions_regression_e2e.py", rationale: "Cohere chat via OpenAI-compatible /chat/completions"} 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 new file mode 100644 index 00000000000..64fc3aa72af --- /dev/null +++ b/tests/e2e/llm_translation/test_gemini_tool_call_id_length_e2e.py @@ -0,0 +1,187 @@ +from __future__ import annotations + +import re +from collections.abc import Iterator +from typing import Final + +import pytest +from e2e_config import unique_marker +from e2e_metadata import Capability, Domain, Mode, Provider, Route, Subject, meta +from lifecycle import ResourceManager +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 + +pytestmark = pytest.mark.e2e + +BACKEND_MODEL: Final = "gemini/gemini-3.8-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[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), + ) + + +def _register_model(client: PassthroughClient, resources: ResourceManager) -> str: + model_name: Final = f"e2e-gemini-tool-call-id-{unique_marker()}" + model_id: Final = client.proxy.create_model( + model_name, + LiteLLMParamsBody( + model=BACKEND_MODEL, + api_key=GEMINI_API_KEY, + thought_signature_in_tool_call_id=False, + ), + ) + resources.defer(lambda: client.proxy.delete_model(model_id)) + return model_name + + +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) + ) + assert calls, f"Gemini returned no tool calls for tool_choice='required': {completion.model_dump_json()[:400]}" + return calls + + +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 _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: + @pytest.mark.covers( + "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, sdk: SdkClients + ) -> None: + model: Final = _register_model(client, resources) + completion: Final = sdk.openai(resources.key()).chat.completions.create( + model=model, + messages=[_user_message()], + tools=[WEATHER_TOOL], + tool_choice="required", + max_tokens=2048, + extra_body=NO_PROXY_CACHE, + ) + + _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, sdk: SdkClients + ) -> None: + model: Final = _register_model(client, resources) + 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=2048, + stream=True, + extra_body=NO_PROXY_CACHE, + ) + ) + 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) + + @pytest.mark.covers( + "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, sdk: SdkClients + ) -> None: + model: Final = _register_model(client, resources) + 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=2048, + 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: 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=2048, + 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/e2e/models.py b/tests/e2e/models.py index 504adec5401..f76e5cec8e1 100644 --- a/tests/e2e/models.py +++ b/tests/e2e/models.py @@ -1363,6 +1363,7 @@ class LiteLLMParamsBody(BaseModel): realtime_protocol: str | None = None allowed_openai_params: list[str] | None = None drop_params: bool | None = None + thought_signature_in_tool_call_id: bool | None = None aws_access_key_id: str | None = Field(default=None, repr=False) aws_secret_access_key: str | None = Field(default=None, repr=False) aws_region_name: str | None = None diff --git a/tests/unit/caching/test_caching.py b/tests/unit/caching/test_caching.py index c4727cbeff0..0f6ef2304fb 100644 --- a/tests/unit/caching/test_caching.py +++ b/tests/unit/caching/test_caching.py @@ -472,6 +472,23 @@ def test_cache_key_tells_apart_requests_that_differ_only_in_continuation_token() assert cache.get_cache_key(**request, continuation_token="") == first_segment_key +def test_cache_key_tells_apart_requests_that_differ_only_in_tool_call_id_shape() -> None: + cache: Final = Cache(type=LiteLLMCacheType.LOCAL) + request: Final = { + "model": "gemini/gemini-3.5-flash", + "messages": [{"role": "user", "content": "What is the weather in Paris?"}], + } + + signed_ids_key: Final = cache.get_cache_key(**request) + short_ids_key: Final = cache.get_cache_key(**request, thought_signature_in_tool_call_id=False) + + assert short_ids_key != signed_ids_key + assert cache.get_cache_key(**request, thought_signature_in_tool_call_id="false") == short_ids_key + assert cache.get_cache_key(**request, thought_signature_in_tool_call_id=0) == short_ids_key + assert cache.get_cache_key(**request, thought_signature_in_tool_call_id=True) == signed_ids_key + assert cache.get_cache_key(**request, thought_signature_in_tool_call_id=None) == signed_ids_key + + class PhaseRecordingCache(InMemoryCache): """Records the target and the active span each read / write ran under, as a Redis span would.""" 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 6c5c3f8c05f..4f3b6fa4793 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 @@ -9,7 +9,17 @@ Note: Embedding signatures in tool call IDs is a beta feature that requires enable_preview_features=True to be enabled. """ +import json +import re +from collections.abc import AsyncIterator, Iterator, Mapping, Sequence +from itertools import chain +from types import MappingProxyType +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 ( @@ -18,9 +28,11 @@ from litellm.litellm_core_utils.prompt_templates.factory import ( get_thought_signature_from_tool, convert_to_gemini_tool_call_invoke, ) +from litellm.litellm_core_utils.streaming_handler import CustomStreamWrapper from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import ( VertexGeminiConfig, ) +from litellm.types.llms.openai import ResponsesAPIResponse from litellm.types.llms.vertex_ai import HttpxPartType @@ -285,3 +297,568 @@ def test_parallel_tool_calls_with_signatures(enable_preview_features): assert THOUGHT_SIGNATURE_SEPARATOR not in tools[1]["id"] sig2 = get_thought_signature_from_tool({"id": tools[1]["id"], "type": "function"}) assert sig2 is None + + +GEMINI_URL_PATTERN: Final = r"https://generativelanguage\.googleapis\.com/v1[a-z]*/models/gemini-3\.5-flash:.*" +BOUNDED_TOOL_CALL_ID: Final = re.compile(r"[A-Za-z0-9_-]{1,64}") +LONG_SIGNATURE: Final = "Ab+/" * 200 +WEATHER_FUNCTION: Final = { + "name": "get_weather", + "parameters": {"type": "object", "properties": {"city": {"type": "string"}}}, +} +WEATHER_TOOL: Final = {"type": "function", "function": WEATHER_FUNCTION} +USER_TURN: Final = {"role": "user", "content": "What is the weather in Paris?"} + + +def _function_call_candidate(native_id: str | None) -> dict[str, object]: + function_call: Final = {"name": "get_weather", "args": {"city": "Paris"}} + return { + "content": { + "role": "model", + "parts": [ + { + "functionCall": function_call if native_id is None else {**function_call, "id": native_id}, + "thoughtSignature": LONG_SIGNATURE, + } + ], + }, + "finishReason": "STOP", + "index": 0, + } + + +def _gemini_body(native_id: str | None) -> dict[str, object]: + return { + "candidates": [_function_call_candidate(native_id)], + "usageMetadata": {"promptTokenCount": 5, "candidatesTokenCount": 7, "totalTokenCount": 12}, + } + + +def _gemini_response(native_id: str | None, stream: bool) -> httpx.Response: + body: Final = _gemini_body(native_id) + if stream: + return httpx.Response(200, text=f"data: {json.dumps(body)}\n\n", headers={"content-type": "text/event-stream"}) + 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]]: + with respx.mock: + route: Final = respx.post(url__regex=GEMINI_URL_PATTERN).mock(return_value=_gemini_response(native_id, stream)) + response: Final = litellm.completion( + model="gemini/gemini-3.5-flash", + messages=[USER_TURN], + tools=[WEATHER_TOOL], + stream=stream, + api_key="test-gemini-key", + **request_params, + ) + assert isinstance(response, CustomStreamWrapper) == stream + returned_calls: Final = ( + chain.from_iterable(chunk.choices[0].delta.tool_calls or () for chunk in response) + if isinstance(response, CustomStreamWrapper) + else response.choices[0].message.tool_calls + ) + tool_calls: Final = [call.model_dump() for call in returned_calls] + sent_body: Final = json.loads(route.calls.last.request.content) + return tool_calls, sent_body + + +@pytest.mark.parametrize("stream", [False, True]) +@pytest.mark.parametrize("native_id", [None, "kq1z0x7p"]) +def test_tool_call_id_stays_bounded_when_thought_signature_kept_out_of_it(stream: bool, native_id: str | None): + tool_calls, sent_body = _returned_tool_calls(native_id, stream, {"thought_signature_in_tool_call_id": False}) + + tool_call_ids: Final = [call["id"] for call in tool_calls if call["id"]] + assert len(tool_call_ids) == 1 + 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) + + +@pytest.mark.parametrize("stream", [False, True]) +def test_tool_call_id_carries_thought_signature_by_default(stream: bool): + tool_calls, _ = _returned_tool_calls(None, stream, {}) + + tool_call_ids: Final = [call["id"] for call in tool_calls if call["id"]] + assert len(tool_call_ids) == 1 + assert get_thought_signature_from_tool({"id": tool_call_ids[0]}) == LONG_SIGNATURE + + +@pytest.mark.parametrize("client_keeps_provider_fields", [True, False]) +def test_bounded_tool_call_id_round_trips_to_gemini(client_keeps_provider_fields: bool): + tool_calls, _ = _returned_tool_calls(None, False, {"thought_signature_in_tool_call_id": False}) + returned_call: Final = tool_calls[0] + replayed_call: Final = { + "id": returned_call["id"], + "type": "function", + "function": {"name": "get_weather", "arguments": '{"city": "Paris"}'}, + **( + {"provider_specific_fields": returned_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.completion( + model="gemini/gemini-3.5-flash", + messages=[ + USER_TURN, + {"role": "assistant", "content": None, "tool_calls": [replayed_call]}, + {"role": "tool", "tool_call_id": returned_call["id"], "content": '{"temp_c": 21}'}, + ], + tools=[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] + + +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=_gemini_answer()) + litellm.completion( + model="gemini/gemini-3.5-flash", + messages=[ + USER_TURN, + { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "id": encode_tool_call_id_with_signature(bare_id, LONG_SIGNATURE), + "type": "function", + "function": {"name": "get_weather", "arguments": '{"city": "Paris"}'}, + } + ], + }, + {"role": "tool", "tool_call_id": bare_id, "content": '{"temp_c": 21}'}, + ], + tools=[WEATHER_TOOL], + api_key="test-gemini-key", + thought_signature_in_tool_call_id=False, + ) + sent_body: Final = json.loads(route.calls.last.request.content) + + parts: Final = list(chain.from_iterable(turn["parts"] for turn in sent_body["contents"])) + function_calls: Final = [part for part in parts if "function_call" in part] + function_responses: Final = [part["function_response"] for part in parts if "function_response" in part] + assert [part["thoughtSignature"] for part in function_calls] == [LONG_SIGNATURE] + assert [response["name"] for response in function_responses] == ["get_weather"] + + +ANTHROPIC_WEATHER_TOOL: Final = { + "name": "get_weather", + "input_schema": {"type": "object", "properties": {"city": {"type": "string"}}}, +} + + +def _is_tool_use_start(event: Mapping[str, object]) -> bool: + block: Final = event.get("content_block") + return event.get("type") == "content_block_start" and isinstance(block, dict) and block.get("type") == "tool_use" + + +async def _messages_tool_use_blocks(stream: bool) -> list[Mapping[str, object]]: + with respx.mock: + respx.post(url__regex=GEMINI_URL_PATTERN).mock(return_value=_gemini_response(None, stream)) + response: Final = await litellm.anthropic.messages.acreate( + max_tokens=256, + messages=[USER_TURN], + model="gemini/gemini-3.5-flash", + tools=[ANTHROPIC_WEATHER_TOOL], + stream=stream, + api_key="test-gemini-key", + thought_signature_in_tool_call_id=False, + ) + if not stream: + return [block for block in response["content"] if block["type"] == "tool_use"] + assert isinstance(response, AsyncIterator) + relayed: Final = b"".join([chunk async for chunk in response]) + events: Final = [json.loads(line.removeprefix(b"data: ")) for line in relayed.splitlines() if line.startswith(b"data:")] + return [event["content_block"] for event in events if _is_tool_use_start(event)] + + +@pytest.mark.parametrize("stream", [False, True]) +async def test_messages_tool_use_keeps_thought_signature_kept_out_of_its_id( + stream: bool, monkeypatch: pytest.MonkeyPatch +): + monkeypatch.setattr(litellm, "disable_aiohttp_transport", True) + tool_uses: Final = await _messages_tool_use_blocks(stream) + + assert len(tool_uses) == 1 + assert _is_bounded(tool_uses[0]["id"]) + assert tool_uses[0]["provider_specific_fields"]["signature"] == LONG_SIGNATURE + + +RESPONSES_WEATHER_TOOL: Final = {"type": "function", **WEATHER_FUNCTION} +SHORT_IDS: Final = MappingProxyType({"thought_signature_in_tool_call_id": False}) + + +def _responses_function_call_items(stream: bool, request_params: Mapping[str, object] = SHORT_IDS) -> list[object]: + with respx.mock: + respx.post(url__regex=GEMINI_URL_PATTERN).mock(return_value=_gemini_response(None, stream)) + response: Final = litellm.responses( + model="gemini/gemini-3.5-flash", + input=USER_TURN["content"], + tools=[RESPONSES_WEATHER_TOOL], + stream=stream, + api_key="test-gemini-key", + **request_params, + ) + assert isinstance(response, Iterator) == stream + items: Final = ( + [event.item for event in response if event.type == "response.output_item.done"] + if isinstance(response, Iterator) and not isinstance(response, ResponsesAPIResponse) + else response.output + ) + return [item for item in items if getattr(item, "type", None) == "function_call"] + + +@pytest.mark.parametrize("stream", [False, True]) +def test_responses_function_call_keeps_thought_signature_kept_out_of_its_id(stream: bool): + function_calls: Final = _responses_function_call_items(stream) + + assert len(function_calls) == 1 + assert _is_bounded(function_calls[0].call_id) + assert function_calls[0].provider_specific_fields["thought_signature"] == LONG_SIGNATURE + + +def test_streamed_function_call_carries_its_thought_signature_only_in_its_id_by_default(): + function_calls: Final = _responses_function_call_items(True, {}) + + assert len(function_calls) == 1 + assert get_thought_signature_from_tool({"id": function_calls[0].call_id}) == LONG_SIGNATURE + assert getattr(function_calls[0], "provider_specific_fields", None) is None + + +def _deepseek_answer() -> httpx.Response: + return httpx.Response( + 200, + json={ + "id": "chatcmpl-1", + "object": "chat.completion", + "created": 1, + "model": "deepseek-v4-flash", + "choices": [{"index": 0, "message": {"role": "assistant", "content": "Sunny"}, "finish_reason": "stop"}], + "usage": {"prompt_tokens": 9, "completion_tokens": 1, "total_tokens": 10}, + }, + ) + + +@pytest.mark.parametrize("request_params", [{}, SHORT_IDS]) +def test_responses_function_call_provider_fields_reach_the_provider_only_with_short_ids( + request_params: Mapping[str, object], +): + replayed_item: Final = { + "type": "function_call", + "call_id": "call_1", + "name": "get_weather", + "arguments": '{"city": "Paris"}', + "provider_specific_fields": {"thought_signature": LONG_SIGNATURE}, + } + + with respx.mock: + route: Final = respx.post(url__regex=r"https://api\.deepseek\.com/.*").mock(return_value=_deepseek_answer()) + litellm.responses( + model="deepseek/deepseek-v4-flash", + input=[ + {"role": "user", "content": USER_TURN["content"]}, + replayed_item, + {"type": "function_call_output", "call_id": "call_1", "output": '{"temp_c": 21}'}, + ], + tools=[RESPONSES_WEATHER_TOOL], + api_key="test-deepseek-key", + **request_params, + ) + sent_body: Final = json.loads(route.calls.last.request.content) + + sent_tool_calls: Final = list(chain.from_iterable(message.get("tool_calls") or () for message in sent_body["messages"])) + assert len(sent_tool_calls) == 1 + assert ("provider_specific_fields" in sent_tool_calls[0]) is bool(request_params) + + +@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( + model_list=[ + { + "model_name": "gemini-agentcore", + "litellm_params": { + "model": "gemini/gemini-3.5-flash", + "api_key": "test-gemini-key", + "thought_signature_in_tool_call_id": configured_value, + }, + } + ] + ) + with respx.mock: + 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 _is_bounded(response.choices[0].message.tool_calls[0].id) + + +def test_deployment_setting_rejects_a_value_that_is_not_a_boolean(): + with pytest.raises(ValueError, match="thought_signature_in_tool_call_id"): + litellm.Router( + model_list=[ + { + "model_name": "gemini-agentcore", + "litellm_params": {"model": "gemini/gemini-3.5-flash", "thought_signature_in_tool_call_id": "maybe"}, + } + ] + ) + + +def test_deployment_setting_survives_a_request_value_that_drop_params_drops(): + router: Final = litellm.Router( + model_list=[ + { + "model_name": "gemini-agentcore", + "litellm_params": { + "model": "gemini/gemini-3.5-flash", + "api_key": "test-gemini-key", + "thought_signature_in_tool_call_id": False, + }, + } + ] + ) + with respx.mock: + 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], + thought_signature_in_tool_call_id="maybe", + drop_params=True, + ) + + assert _is_bounded(response.choices[0].message.tool_calls[0].id) + + +def test_responses_request_setting_that_is_not_a_boolean_answers_400(): + with pytest.raises(litellm.BadRequestError, match="thought_signature_in_tool_call_id") as raised: + _responses_function_call_items(False, {"thought_signature_in_tool_call_id": "maybe"}) + + assert raised.value.status_code == 400 + + +def test_responses_request_setting_that_is_not_a_boolean_is_dropped_under_drop_params(): + function_calls: Final = _responses_function_call_items( + False, {"thought_signature_in_tool_call_id": "maybe", "drop_params": True} + ) + + assert len(function_calls) == 1 + assert get_thought_signature_from_tool({"id": function_calls[0].call_id}) == LONG_SIGNATURE + + +@pytest.mark.parametrize("drop_params", [False, True]) +async def test_messages_request_setting_that_is_not_a_boolean_follows_drop_params( + drop_params: bool, monkeypatch: pytest.MonkeyPatch +): + monkeypatch.setattr(litellm, "disable_aiohttp_transport", True) + with respx.mock(assert_all_called=False) as mocked: + mocked.post(url__regex=GEMINI_URL_PATTERN).mock(return_value=_gemini_response(None, False)) + request: Final = litellm.anthropic.messages.acreate( + max_tokens=256, + messages=[USER_TURN], + model="gemini/gemini-3.5-flash", + tools=[ANTHROPIC_WEATHER_TOOL], + api_key="test-gemini-key", + thought_signature_in_tool_call_id="maybe", + drop_params=drop_params, + ) + if not drop_params: + with pytest.raises(litellm.BadRequestError, match="thought_signature_in_tool_call_id"): + await request + return + response: Final = await request + + tool_uses: Final = [block for block in response["content"] if block["type"] == "tool_use"] + assert len(tool_uses) == 1 + + +def test_setting_that_is_not_a_boolean_is_ignored_where_nothing_reads_it(): + embedding_vector: Final = [0.25, 0.5] + with respx.mock: + route: Final = respx.post("https://api.openai.com/v1/embeddings").mock( + return_value=httpx.Response( + 200, + json={ + "object": "list", + "data": [{"object": "embedding", "index": 0, "embedding": embedding_vector}], + "model": "text-embedding-3-small", + "usage": {"prompt_tokens": 1, "total_tokens": 1}, + }, + ) + ) + response: Final = litellm.embedding( + model="openai/text-embedding-3-small", + input=["hi"], + api_key="test-openai-key", + thought_signature_in_tool_call_id="maybe", + ) + sent_body: Final = json.loads(route.calls.last.request.content) + + assert response.data[0]["embedding"] == embedding_vector + assert "thought_signature_in_tool_call_id" not in sent_body + + +TIME_TOOL: Final = { + "type": "function", + "function": {"name": "get_time", "parameters": {"type": "object", "properties": {"city": {"type": "string"}}}}, +} + + +@pytest.mark.parametrize( + "tool_call_ids", + [ + ("call_1__thought__S1", "call_1__thought__S2"), + ("__thought__S1", "__thought__S2"), + ], +) +def test_tool_results_keep_their_own_names_when_tool_call_ids_share_a_prefix(tool_call_ids: tuple[str, str]): + called_tools: Final = (("get_weather", tool_call_ids[0]), ("get_time", tool_call_ids[1])) + with respx.mock: + route: Final = respx.post(url__regex=GEMINI_URL_PATTERN).mock(return_value=_gemini_answer()) + litellm.completion( + model="gemini/gemini-3.5-flash", + messages=[ + USER_TURN, + { + "role": "assistant", + "content": None, + "tool_calls": [ + {"id": call_id, "type": "function", "function": {"name": name, "arguments": '{"city": "Paris"}'}} + for name, call_id in called_tools + ], + }, + *({"role": "tool", "tool_call_id": call_id, "content": f'{{"{name}": 1}}'} for name, call_id in called_tools), + ], + tools=[WEATHER_TOOL, TIME_TOOL], + api_key="test-gemini-key", + ) + sent_body: Final = json.loads(route.calls.last.request.content) + + parts: Final = chain.from_iterable(turn["parts"] for turn in sent_body["contents"]) + sent_names: Final = [part["function_response"]["name"] for part in parts if "function_response" in part] + assert sent_names == ["get_weather", "get_time"] diff --git a/tests/unit/proxy/pass_through_endpoints/llm_provider_handlers/test_gemini_passthrough_logging_handler.py b/tests/unit/proxy/pass_through_endpoints/llm_provider_handlers/test_gemini_passthrough_logging_handler.py index f780c056f64..31d95984996 100644 --- a/tests/unit/proxy/pass_through_endpoints/llm_provider_handlers/test_gemini_passthrough_logging_handler.py +++ b/tests/unit/proxy/pass_through_endpoints/llm_provider_handlers/test_gemini_passthrough_logging_handler.py @@ -83,6 +83,7 @@ class TestGeminiPassthroughLoggingHandler: mock_logging_obj = MagicMock(spec=LiteLLMLoggingObj) mock_logging_obj.model_call_details = {} mock_logging_obj.optional_params = {} + mock_logging_obj.litellm_params = {} mock_logging_obj.litellm_call_id = "test-call-id-123" return mock_logging_obj diff --git a/tests/unit/proxy/pass_through_endpoints/test_streaming_handler.py b/tests/unit/proxy/pass_through_endpoints/test_streaming_handler.py index 7d56487a844..bf2cf8f1120 100644 --- a/tests/unit/proxy/pass_through_endpoints/test_streaming_handler.py +++ b/tests/unit/proxy/pass_through_endpoints/test_streaming_handler.py @@ -83,6 +83,7 @@ def _logging_obj() -> LiteLLMLoggingObj: logging_obj = MagicMock(spec=LiteLLMLoggingObj) logging_obj.model_call_details = {} logging_obj.optional_params = {} + logging_obj.litellm_params = {} logging_obj.litellm_call_id = "test-call-id" return logging_obj diff --git a/tests/unit/responses/litellm_completion_transformation/test_litellm_completion_responses.py b/tests/unit/responses/litellm_completion_transformation/test_litellm_completion_responses.py index 4250c78c669..a6a1335fdc4 100644 --- a/tests/unit/responses/litellm_completion_transformation/test_litellm_completion_responses.py +++ b/tests/unit/responses/litellm_completion_transformation/test_litellm_completion_responses.py @@ -4363,6 +4363,7 @@ class TestEnsureOutputItemContentPartAdded: iterator._queued_web_search_call_ids = set() iterator._tool_search = tool_search_routing(None) iterator._tool_search_executions = MappingProxyType({}) + iterator.tool_call_provider_fields = False return iterator def _make_text_chunk(self): @@ -4745,6 +4746,29 @@ class TestEnsureOutputItemContentPartAdded: assert done.item.name == "spawn_agent" assert done.item.namespace == "collaboration" + def test_streaming_final_only_dict_tool_call_keeps_its_provider_specific_fields(self): + from unittest.mock import MagicMock + + iterator = self._make_iterator() + iterator.tool_call_provider_fields = True + provider_specific_fields = {"thought_signature": "sig-" + "x" * 512} + message = MagicMock() + message.tool_calls = [ + { + "id": "call_1", + "function": {"name": "get_weather", "arguments": '{"city":"Paris"}'}, + "provider_specific_fields": provider_specific_fields, + } + ] + complete_response = MagicMock() + complete_response.choices = [MagicMock(message=message)] + + iterator._queue_final_tool_call_done_events(complete_response) + + done = iterator._pending_tool_events[-1] + assert done.type == "response.output_item.done" + assert done.item.provider_specific_fields == provider_specific_fields + def test_streaming_top_level_function_collision_stays_unnamespaced(self): iterator = self._make_iterator() iterator.responses_api_request = { diff --git a/tests/unit/responses/litellm_completion_transformation/test_session_handler.py b/tests/unit/responses/litellm_completion_transformation/test_session_handler.py index 8d5befd9443..f1d841a8e5e 100644 --- a/tests/unit/responses/litellm_completion_transformation/test_session_handler.py +++ b/tests/unit/responses/litellm_completion_transformation/test_session_handler.py @@ -7,6 +7,8 @@ from typing import Final from unittest.mock import AsyncMock, patch import pytest +import httpx +import respx from fastapi import HTTPException from fastapi.testclient import TestClient @@ -1245,3 +1247,69 @@ async def test_session_lookup_follows_the_owner_across_keys_and_keeps_ownerless_ assert await _history_request_ids(sqlite_db, "alice-new-key") == ["alice-old-key", "alice-new-key"] assert await _history_request_ids(sqlite_db, "ownerless-2") == ["ownerless-2"] + + +GEMINI_URL_PATTERN: Final = r"https://generativelanguage\.googleapis\.com/v1[a-z]*/models/gemini-3\.5-flash:.*" +LONG_SIGNATURE: Final = "Ab+/" * 200 + + +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}, + }, + ) + + +def _stored_function_call_turn(response_id: str) -> dict: + return { + "request_id": response_id, + "call_type": "aresponses", + "session_id": "gemini-session", + "proxy_server_request": {"input": "What is the weather in Paris?", "model": "gemini-short-ids"}, + "response": { + "id": response_id, + "object": "response", + "output": [ + { + "type": "function_call", + "call_id": "call_1", + "name": "get_weather", + "arguments": '{"city": "Paris"}', + "status": "completed", + "provider_specific_fields": {"thought_signature": LONG_SIGNATURE}, + } + ], + }, + } + + +@pytest.mark.parametrize("short_ids", [False, True]) +async def test_previous_response_replays_a_thought_signature_kept_out_of_the_id_only_with_short_ids( + short_ids: bool, monkeypatch: pytest.MonkeyPatch +): + monkeypatch.setattr(litellm, "disable_aiohttp_transport", True) + response_id: Final = ResponsesAPIRequestUtils._build_responses_api_response_id( + custom_llm_provider="gemini", model_id=None, response_id="chatcmpl-gemini-turn-1" + ) + stored: Final = _StoredSpendLogs((_stored_function_call_turn(response_id),)) + + with patch("litellm.proxy.proxy_server.prisma_client", SimpleNamespace(db=stored)), respx.mock: + route: Final = respx.post(url__regex=GEMINI_URL_PATTERN).mock(return_value=_gemini_answer()) + await litellm.aresponses( + model="gemini/gemini-3.5-flash", + input=[{"type": "function_call_output", "call_id": "call_1", "output": '{"temp_c": 21}'}], + previous_response_id=response_id, + api_key="test-gemini-key", + **({"thought_signature_in_tool_call_id": False} if short_ids else {}), + ) + 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 = list(chain.from_iterable(turn["parts"] for turn in model_turns)) + assert len(model_parts) == 1 + assert (model_parts[0]["thoughtSignature"] == LONG_SIGNATURE) is short_ids diff --git a/tests/unit/test_main.py b/tests/unit/test_main.py index 9f2d2a9bbc5..85864b2024e 100644 --- a/tests/unit/test_main.py +++ b/tests/unit/test_main.py @@ -11235,6 +11235,67 @@ async def test_converse_fallback_prices_the_current_attempt( configuration.reset_rust_configuration() +def _openai_chat_answer() -> httpx.Response: + return httpx.Response( + 200, + json={ + "id": "chatcmpl-1", + "object": "chat.completion", + "created": 1, + "model": "gpt-5.6", + "choices": [{"index": 0, "message": {"role": "assistant", "content": "hi"}, "finish_reason": "stop"}], + "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}, + }, + ) + + +async def _openai_completion(use_async: bool, **request_params: object) -> litellm.ModelResponse: + request: Final = { + "model": "openai/gpt-5.6", + "messages": [{"role": "user", "content": "hi"}], + "api_key": "fake-key", + **request_params, + } + return await litellm.acompletion(**request) if use_async else litellm.completion(**request) + + +class _PreCallParamsRecorder(CustomLogger): + def __init__(self) -> None: + super().__init__() + self.litellm_params: tuple[object, ...] = () + + def log_pre_api_call(self, model: str, messages: object, kwargs: Mapping[str, object]) -> None: + self.litellm_params = (*self.litellm_params, kwargs.get("litellm_params")) + + +@pytest.mark.parametrize("use_async", [False, True]) +async def test_malformed_routing_kwarg_answers_400_before_reaching_the_provider(use_async: bool): + with respx.mock(assert_all_called=False) as mock: + route: Final = mock.post("https://api.openai.com/v1/chat/completions").mock(return_value=_openai_chat_answer()) + with pytest.raises(litellm.BadRequestError, match="tpm") as raised: + await _openai_completion(use_async, tpm="abc") + + assert raised.value.status_code == 400 + assert not route.called + + +@pytest.mark.parametrize("use_async", [False, True]) +async def test_malformed_routing_kwarg_is_dropped_from_the_whole_request_under_drop_params( + use_async: bool, monkeypatch: pytest.MonkeyPatch +): + monkeypatch.setattr(litellm, "disable_aiohttp_transport", True) + recorder: Final = _PreCallParamsRecorder() + monkeypatch.setattr(litellm, "callbacks", [recorder]) + with respx.mock: + respx.post("https://api.openai.com/v1/chat/completions").mock(return_value=_openai_chat_answer()) + response: Final = await _openai_completion(use_async, tpm="abc", drop_params=True) + + assert response.choices[0].message.content == "hi" + assert len(recorder.litellm_params) == 1 + assert isinstance(recorder.litellm_params[0], Mapping) + assert recorder.litellm_params[0].get("tpm") is None + + _LOOKUP_FUNCTION: Final = { "name": "lookup_record", "description": "Look up a record", diff --git a/tests/unit/types/test_litellm_params.py b/tests/unit/types/test_litellm_params.py index 22eb9929e7f..86a75ec65b9 100644 --- a/tests/unit/types/test_litellm_params.py +++ b/tests/unit/types/test_litellm_params.py @@ -222,6 +222,7 @@ OPTION_NAMES: Final = ( "assistant_continue_message", "disable_add_transform_inline_image_block", "merge_reasoning_content_in_choices", + "thought_signature_in_tool_call_id", "enable_json_schema_validation", "complete_response", "stream_chunk_size", diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 02b8a80e128..0ea6ac172ef 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -35908,6 +35908,8 @@ export interface components { tags?: string[] | null; /** Tenant Id */ tenant_id?: string | null; + /** Thought Signature In Tool Call Id */ + thought_signature_in_tool_call_id?: boolean | null; /** Tiered Pricing */ tiered_pricing?: { [key: string]: unknown; @@ -51436,6 +51438,8 @@ export interface components { tags?: string[] | null; /** Tenant Id */ tenant_id?: string | null; + /** Thought Signature In Tool Call Id */ + thought_signature_in_tool_call_id?: boolean | null; /** Tiered Pricing */ tiered_pricing?: { [key: string]: unknown;