This commit is contained in:
Mateo Wang 2026-10-31 06:02:33 +00:00 • committed by GitHub
commit 74cca2b877
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 1224 additions and 62 deletions

View file

@ -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)",

View file

@ -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

View file

@ -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()

View file

@ -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

View file

@ -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),

View file

@ -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.

View file

@ -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",
)

View file

@ -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)}")

View file

@ -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,
)
)

View file

@ -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)

View file

@ -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:

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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"}

View file

@ -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]}"

View file

@ -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

View file

@ -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."""

View file

@ -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"]

View file

@ -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

View file

@ -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

View file

@ -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 = {

View file

@ -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

View file

@ -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",

View file

@ -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",

View file

@ -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;