mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-01 07:03:02 +00:00
Merge 827e2f87b8 into afec145edc
This commit is contained in:
commit
74cca2b877
27 changed files with 1224 additions and 62 deletions
|
|
@ -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)",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)}")
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
||||
|
|
|
|||
187
tests/e2e/llm_translation/test_gemini_tool_call_id_length_e2e.py
Normal file
187
tests/e2e/llm_translation/test_gemini_tool_call_id_length_e2e.py
Normal 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]}"
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
4
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
4
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue