diff --git a/litellm/completion_extras/litellm_responses_transformation/transformation.py b/litellm/completion_extras/litellm_responses_transformation/transformation.py index 1b976f5a48b..4024ce5360e 100644 --- a/litellm/completion_extras/litellm_responses_transformation/transformation.py +++ b/litellm/completion_extras/litellm_responses_transformation/transformation.py @@ -1115,7 +1115,7 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge): responses_tools: Final[list[ALL_RESPONSES_API_TOOL_PARAMS]] = [] for tool in tools: # convert function tool from chat completion to responses API format - if tool.get("type") == "function": + if tool.get("type") == "function" and isinstance(tool.get("function"), dict): function_tool = cast(ChatCompletionToolParamFunctionChunk, tool.get("function")) responses_tools.append( FunctionToolParam( diff --git a/litellm/integrations/anthropic_cache_control_hook.py b/litellm/integrations/anthropic_cache_control_hook.py index 494d9e0935a..0d6cbc2232e 100644 --- a/litellm/integrations/anthropic_cache_control_hook.py +++ b/litellm/integrations/anthropic_cache_control_hook.py @@ -36,6 +36,7 @@ from litellm.types.integrations.anthropic_cache_control_hook import ( CacheControlMessageInjectionPoint, ) from litellm.types.llms.anthropic import ( + ANTHROPIC_TOOL_SEARCH_TOOL_TYPES, AllAnthropicToolsValues, AnthropicSystemMessageContent, ) @@ -124,6 +125,16 @@ def _carries_cache_breakpoint(block: object) -> bool: return isinstance(block, dict) and any(block.get(key) is not None for key in CACHE_BREAKPOINT_KEYS) +def _tool_carries_cache_breakpoint(tool: object) -> bool: + return _carries_cache_breakpoint(tool) or ( + isinstance(tool, dict) and _carries_cache_breakpoint(tool.get("function")) + ) + + +def _chat_transform_drops_tool_cache_control(tool: object) -> bool: + return isinstance(tool, dict) and tool.get("type") in ANTHROPIC_TOOL_SEARCH_TOOL_TYPES + + def _accepts_prompt_cache_breakpoint(block: object) -> bool: return isinstance(block, dict) and block.get("type") in OPENAI_PROMPT_CACHE_BREAKPOINT_BLOCK_TYPES @@ -134,6 +145,8 @@ def _accepts_prompt_cache_breakpoint(block: object) -> bool: # rather than spending them on a list that is still missing some of their targets. CARRY_UNMATCHED_MESSAGE_POINTS: Final = "_litellm_carry_unmatched_cache_control_points" +EXTERNAL_BREAKPOINTS_STAMP: Final = "_litellm_external_breakpoints" + class AnthropicCacheControlHook(CustomPromptManagement): @staticmethod @@ -199,19 +212,13 @@ class AnthropicCacheControlHook(CustomPromptManagement): # Create a deep copy of messages to avoid modifying the original list processed_messages = copy.deepcopy(messages) - # Separate message-level and non-message-level injection points - message_points: Final[list[CacheControlMessageInjectionPoint]] = [] - remaining_points: Final[list[CacheControlInjectionPoint]] = [] - for point in injection_points: - if point.get("location") == "message": - message_points.append(cast(CacheControlMessageInjectionPoint, point)) - else: - remaining_points.append(point) + message_points: Final = tuple( + cast(CacheControlMessageInjectionPoint, point) + for point in injection_points + if point.get("location") == "message" + ) + remaining_points: Final = tuple(point for point in injection_points if point.get("location") != "message") - # Non-message points (currently Bedrock tool_config) are handled in the - # provider transform, where each tool_config point appends at most one - # cachePoint to the tools. That block also counts toward Anthropic's - # limit, so reserve a slot for it here to leave room. stamped_dialect: Final = injection_points[0].get("_litellm_openai_dialect") openai_dialect: Final = ( stamped_dialect @@ -236,8 +243,10 @@ class AnthropicCacheControlHook(CustomPromptManagement): if carry_unmatched else tuple(message_points) ) - reserved_blocks: Final = ( - 1 if not openai_dialect and any(p.get("location") == "tool_config" for p in remaining_points) else 0 + stamped_external: Final = injection_points[0].get(EXTERNAL_BREAKPOINTS_STAMP) + external_breakpoints: Final = stamped_external if isinstance(stamped_external, int) else 0 + reserved_blocks: Final = AnthropicCacheControlHook._blocks_reserved_outside_messages( + remaining_points, external_breakpoints, openai_dialect ) breakpoints_before: Final = AnthropicCacheControlHook.count_request_cache_breakpoints(processed_messages) processed_messages = self._apply_message_injections( @@ -254,14 +263,19 @@ class AnthropicCacheControlHook(CustomPromptManagement): # Points this pass did not place: non-message ones for the provider transform, and # the deferred role-targeted ones. Deferring is what reaches the Responses API's - # `instructions`, which is only a system message once the bridge builds one. The - # judged stamp is what makes it safe: the next pass must not re-judge points - # against messages this pass already marked (see `_should_stand_down`). - carried_points: Final[Sequence[CacheControlInjectionPoint]] = (*remaining_points, *carried_message_points) + # `instructions`, which is only a system message once the bridge builds one. A later + # pass re-applies them safely: a target that already carries a mark is skipped and + # the census counts every mark on the wire, litellm's own included. + carried_points: Final[Sequence[CacheControlInjectionPoint]] = ( + *AnthropicCacheControlHook._points_with_a_slot_left( + remaining_points, + AnthropicCacheControlHook.count_request_cache_breakpoints(processed_messages) + external_breakpoints, + openai_dialect, + ), + *carried_message_points, + ) if carried_points: - non_default_params["cache_control_injection_points"] = AnthropicCacheControlHook._stamped_as_judged( - carried_points - ) + non_default_params["cache_control_injection_points"] = list(carried_points) return model, processed_messages, non_default_params @@ -296,6 +310,72 @@ class AnthropicCacheControlHook(CustomPromptManagement): ) return system_blocks + sum(AnthropicCacheControlHook._count_cache_control_blocks(msg) for msg in messages) + @staticmethod + def count_external_cache_breakpoints( + tools: Iterable[object] | None, cache_control: object = None, request_kwargs: object = None + ) -> int: + """Client breakpoints outside messages and system that the provider cap still counts. + + A tool carries its mark at the top level (Anthropic shape) or under ``function`` + (OpenAI shape). A top-level ``cache_control`` is Anthropic's automatic caching, + which places one breakpoint of its own on top of the explicit ones. The + ``extra_body`` envelope of ``request_kwargs`` is merged over the request on the + wire, so a ``tools`` or ``cache_control`` it carries replaces the direct value + and is counted in its place. Callers pass only the tools whose mark reaches the + provider on their path. + """ + extra_body: Final = ( + _validated_object_mapping(AnthropicCacheControlHook._request_value(request_kwargs, "extra_body")) or {} + ) + wire_cache_control: Final = extra_body.get("cache_control", cache_control) + wire_tools: Final = _validated_object_list(extra_body["tools"]) if "tools" in extra_body else tools + tool_blocks: Final = sum(1 for tool in wire_tools or () if _tool_carries_cache_breakpoint(tool)) + envelope_blocks: Final = AnthropicCacheControlHook.count_request_cache_breakpoints( + _validated_object_list(extra_body.get("messages")) or (), extra_body.get("system") + ) + return int(wire_cache_control is not None) + tool_blocks + envelope_blocks + + @staticmethod + def count_external_cache_breakpoints_on_messages_route( + tools: Iterable[object] | None, cache_control: object, request_kwargs: object + ) -> int: + """The /v1/messages census before the route splits. + + The native messages transforms drop the ``extra_body`` envelope while the + chat bridge merges it, so the cap reserves for whichever census is larger + rather than letting an envelope that unmarks a direct tool free a slot the + provider still counts. + """ + return max( + AnthropicCacheControlHook.count_external_cache_breakpoints(tools, cache_control), + AnthropicCacheControlHook.count_external_cache_breakpoints(tools, cache_control, request_kwargs), + ) + + @staticmethod + def _blocks_reserved_outside_messages( + remaining_points: Sequence[CacheControlInjectionPoint], external_breakpoints: int, openai_dialect: bool + ) -> int: + """Slots of the provider cap that the message census cannot see. + + The client's breakpoints on tools and its automatic top-level ``cache_control`` + are already on the wire, and a ``tool_config`` point becomes one more cachePoint + in the Bedrock converse transform. OpenAI's cap counts only its own block markers. + """ + if openai_dialect: + return 0 + tool_config_blocks: Final = 1 if any(p.get("location") == "tool_config" for p in remaining_points) else 0 + return external_breakpoints + tool_config_blocks + + @staticmethod + def _points_with_a_slot_left( + remaining_points: Sequence[CacheControlInjectionPoint], breakpoints_on_wire: int, openai_dialect: bool + ) -> tuple[CacheControlInjectionPoint, ...]: + """A ``tool_config`` point becomes a cachePoint the Bedrock converse transform never + counts against the cap, so it is forwarded only while the wire still has a slot.""" + if openai_dialect or breakpoints_on_wire < MAX_CACHE_CONTROL_BLOCKS: + return tuple(remaining_points) + return tuple(point for point in remaining_points if point.get("location") != "tool_config") + @staticmethod def _apply_message_injections( points: Sequence[CacheControlMessageInjectionPoint], @@ -476,11 +556,16 @@ class AnthropicCacheControlHook(CustomPromptManagement): def apply_to_anthropic_messages_request( messages: list[dict], system: str | list | None, - injection_points: list[CacheControlInjectionPoint], + injection_points: Sequence[CacheControlInjectionPoint], openai_dialect: bool = False, + external_breakpoints: int = 0, ) -> tuple[list[dict], str | list | None, list[CacheControlInjectionPoint]]: """Apply cache control injection for the Anthropic-native v1/messages endpoint. + ``external_breakpoints`` is the client's breakpoint count outside ``messages`` and + ``system`` (see ``count_external_cache_breakpoints``); it shrinks the budget so + the request never exceeds the provider cap. + Returns (messages, system, remaining_non_message_points). """ if not injection_points: @@ -489,22 +574,17 @@ class AnthropicCacheControlHook(CustomPromptManagement): processed_messages: list[dict] = copy.deepcopy(messages) processed_system = copy.deepcopy(system) if system is not None else None - message_points: Final[list[CacheControlMessageInjectionPoint]] = [] - system_points: Final[list[CacheControlMessageInjectionPoint]] = [] - remaining_points: Final[list[CacheControlInjectionPoint]] = [] + role_points: Final = tuple( + cast(CacheControlMessageInjectionPoint, point) + for point in injection_points + if point.get("location") == "message" + ) + system_points: Final = tuple(point for point in role_points if point.get("role") == "system") + message_points: Final = tuple(point for point in role_points if point.get("role") != "system") + remaining_points: Final = tuple(point for point in injection_points if point.get("location") != "message") - for point in injection_points: - if point.get("location") == "message": - msg_point = cast(CacheControlMessageInjectionPoint, point) - if msg_point.get("role") == "system": - system_points.append(msg_point) - else: - message_points.append(msg_point) - else: - remaining_points.append(point) - - reserved_blocks: Final = ( - 1 if not openai_dialect and any(p.get("location") == "tool_config" for p in remaining_points) else 0 + reserved_blocks: Final = AnthropicCacheControlHook._blocks_reserved_outside_messages( + remaining_points, external_breakpoints, openai_dialect ) max_blocks: Final = MAX_CACHE_CONTROL_BLOCKS - reserved_blocks @@ -541,8 +621,14 @@ class AnthropicCacheControlHook(CustomPromptManagement): max_blocks=max_blocks - system_blocks, openai_dialect=openai_dialect, ) + forwarded_points: Final = AnthropicCacheControlHook._points_with_a_slot_left( + remaining_points, + AnthropicCacheControlHook.count_request_cache_breakpoints(processed_messages, processed_system) + + external_breakpoints, + openai_dialect, + ) - return processed_messages, processed_system, remaining_points + return processed_messages, processed_system, list(forwarded_points) @staticmethod def _default_control() -> ChatCompletionCachedContent: @@ -559,31 +645,26 @@ class AnthropicCacheControlHook(CustomPromptManagement): return ChatCompletionCachedContent(type="ephemeral") @staticmethod - def _stamped_as_judged(points: Sequence[CacheControlInjectionPoint]) -> Sequence[Mapping[str, object]]: - """Mark written-back points as having passed the client cache_control judgment. - - Builds copies because config-owned point dicts are shared across - requests; mutating them would leak the stamp into future requests. - """ - return AnthropicCacheControlHook._stamped(points, "_litellm_judged", True) - - @staticmethod - def _judged_configured_points( + def _stamped_for_prompt_hook( points: Sequence[CacheControlInjectionPoint], - messages: list[AllMessageValues], - tools: list[object] | None, - cache_control: object, + external_breakpoints: int, model: str, custom_llm_provider: str | None, api_base: object, prompt_cache_options: object, - request_kwargs: object, - ) -> Sequence[Mapping[str, object]] | None: - if AnthropicCacheControlHook._should_stand_down(points, messages, None, tools, cache_control, request_kwargs): - return None - return AnthropicCacheControlHook._stamped_with_dialect( + ) -> Sequence[Mapping[str, object]]: + """Carry onto the points what the prompt-management hook never receives. + + The hook sees neither the tools nor the request kwargs, so the target dialect + and the client's breakpoint count outside the message list ride on the points. + Builds copies because config-owned point dicts are shared across requests. + """ + with_dialect: Final = AnthropicCacheControlHook._stamped_with_dialect( points, model, custom_llm_provider, api_base, prompt_cache_options ) + if external_breakpoints == 0: + return with_dialect + return AnthropicCacheControlHook._stamped(with_dialect, EXTERNAL_BREAKPOINTS_STAMP, external_breakpoints) @staticmethod def _stamped_with_dialect( @@ -604,35 +685,9 @@ class AnthropicCacheControlHook(CustomPromptManagement): ) @staticmethod - def _stamped( - points: Sequence[CacheControlInjectionPoint], key: str, value: object - ) -> Sequence[Mapping[str, object]]: + def _stamped(points: Sequence[Mapping[str, object]], key: str, value: object) -> Sequence[Mapping[str, object]]: return [{**point, key: value} for point in points] - @staticmethod - def _should_stand_down( - points: Sequence[CacheControlInjectionPoint], - messages: list[AllMessageValues], - system: str | list | None, - tools: list | None, - cache_control: object = None, - request_kwargs: object = None, - ) -> bool: - """Whether configured injection points must yield to client-set cache_control. - - Points that a prior pass over this request already judged and wrote - back carry the internal judged stamp; any re-entry (acompletion - re-entering completion, the async-to-sync /v1/messages dispatch, - interceptor sub-calls reusing the request kwargs) must not re-judge - them, because by then the messages carry litellm's own injected marks - and the judgment would misread those as client breakpoints. - """ - if all(point.get("_litellm_judged") for point in points): - return False - return AnthropicCacheControlHook._request_has_cache_control( - messages, system, tools, cache_control, request_kwargs - ) - @staticmethod def _request_has_cache_control( messages: list[AllMessageValues], @@ -641,27 +696,18 @@ class AnthropicCacheControlHook(CustomPromptManagement): cache_control: object = None, request_kwargs: object = None, ) -> bool: - """Client breakpoints own caching in both the request and its extra_body envelope.""" - bodies: Final = ( - {"messages": messages, "system": system, "tools": tools, "cache_control": cache_control}, - _validated_object_mapping(AnthropicCacheControlHook._request_value(request_kwargs, "extra_body")) or {}, - ) - return any( - body.get("cache_control") is not None - or AnthropicCacheControlHook.count_request_cache_breakpoints( - _validated_object_list(body.get("messages")) or (), body.get("system") - ) - > 0 - or any( - AnthropicCacheControlHook._request_value(tool, "cache_control") is not None - or AnthropicCacheControlHook._request_value( - AnthropicCacheControlHook._request_value(tool, "function"), "cache_control" - ) - is not None - for tool in (_validated_object_list(body.get("tools")) or ()) - ) - for body in bodies - ) + """Return True if the request already carries any client-supplied cache_control. + + Only the automatic defaults stand down on it: a client that marks its own + breakpoints (Claude Code does) has a caching strategy the defaults would + clash with, whether the marks sit in the request or in its ``extra_body`` + envelope. Configured injection points are an explicit instruction and are + applied alongside the client's marks, bounded by the provider cap. + """ + return ( + AnthropicCacheControlHook.count_request_cache_breakpoints(messages, system) + + AnthropicCacheControlHook.count_external_cache_breakpoints(tools, cache_control, request_kwargs) + ) > 0 @staticmethod def get_default_injection_points( @@ -769,34 +815,30 @@ class AnthropicCacheControlHook(CustomPromptManagement): ) -> None: """For /chat/completions: resolve the injection points the request should carry. - Configured injection points win over the automatic defaults, but stand - down entirely when the client already marked its own cache_control - breakpoints (messages or tools): injecting alongside them clashes with - the client's caching strategy and can exceed the provider's four-block - limit. The judgment happens once per request; points a prior pass - wrote back carry the judged stamp and are never re-judged (see - ``_should_stand_down``). Seeding the param lets the existing - prompt-management gate and the AnthropicCacheControlHook run - unchanged. + Configured injection points win over the automatic defaults and are applied + even when the client marked its own cache_control elsewhere in the request; + the provider's four-block cap bounds them, counting the client's marks on + messages, tools and the top-level ``cache_control``. Only the defaults stand + down on client marks. Seeding the param lets the existing prompt-management + gate and the AnthropicCacheControlHook run unchanged. """ import litellm - if non_default_params.get("cache_control_injection_points"): - judged: Final = AnthropicCacheControlHook._judged_configured_points( - non_default_params["cache_control_injection_points"], - messages, - tools, - non_default_params.get("cache_control"), + configured: Final = non_default_params.get("cache_control_injection_points") + if configured: + tools_keeping_marks: Final = tuple( + tool for tool in tools or () if not _chat_transform_drops_tool_cache_control(tool) + ) + non_default_params["cache_control_injection_points"] = AnthropicCacheControlHook._stamped_for_prompt_hook( + configured, + AnthropicCacheControlHook.count_external_cache_breakpoints( + tools_keeping_marks, non_default_params.get("cache_control"), non_default_params + ), model, custom_llm_provider, api_base, non_default_params.get("prompt_cache_options"), - non_default_params, ) - if judged is None: - non_default_params.pop("cache_control_injection_points") - else: - non_default_params["cache_control_injection_points"] = judged return points: Final = AnthropicCacheControlHook.get_default_injection_points( messages=messages, @@ -897,15 +939,14 @@ class AnthropicCacheControlHook(CustomPromptManagement): ) -> tuple[list[dict], str | list | None]: """Extract cache_control_injection_points from kwargs and apply if present. - Configured points stand down entirely when the client already marked - its own cache_control breakpoints anywhere in the request. The - judgment happens once per request; points a prior pass wrote back - carry the judged stamp and are never re-judged (see - ``_should_stand_down``). When none are configured but + Configured points are applied even when the client marked its own + cache_control elsewhere in the request, bounded by the provider cap, + which counts the client's marks on messages, system, tools and the + top-level ``cache_control``. When none are configured but ``litellm.enable_anthropic_prompt_caching`` or the per-request ``enable_prompt_caching`` kwarg (stamped from key metadata) is on, - synthesize default breakpoints for the native /v1/messages path. Pops - both keys from kwargs; + synthesize default breakpoints for the native /v1/messages path; those + defaults alone stand down on client marks. Pops both keys from kwargs; if remaining (non-message) points exist they are written back so downstream transforms can handle them. """ @@ -917,13 +958,8 @@ class AnthropicCacheControlHook(CustomPromptManagement): configured: Final = cast( # cast-ok: kwargs is untyped; this key only holds the documented injection-point list list[CacheControlInjectionPoint] | None, kwargs.pop("cache_control_injection_points", None) ) - if configured and AnthropicCacheControlHook._should_stand_down( - configured, typed_messages, system, tools, cache_control, kwargs - ): - return messages, system - injection_points: list[CacheControlInjectionPoint] = configured or [] - if not injection_points and model is not None: - injection_points = AnthropicCacheControlHook.get_default_injection_points( + injection_points: Final[Sequence[CacheControlInjectionPoint]] = configured or ( + AnthropicCacheControlHook.get_default_injection_points( messages=typed_messages, system=system, tools=tools, @@ -933,6 +969,9 @@ class AnthropicCacheControlHook(CustomPromptManagement): cache_control=cache_control, request_kwargs=kwargs, ) + if model is not None + else () + ) if not injection_points: return messages, system @@ -945,6 +984,9 @@ class AnthropicCacheControlHook(CustomPromptManagement): system=system, injection_points=injection_points, openai_dialect=openai_dialect, + external_breakpoints=AnthropicCacheControlHook.count_external_cache_breakpoints_on_messages_route( + tools, cache_control, kwargs + ), ) breakpoints_added: Final = ( AnthropicCacheControlHook.count_request_cache_breakpoints(messages, system) - breakpoints_before @@ -953,7 +995,7 @@ class AnthropicCacheControlHook(CustomPromptManagement): if openai_dialect and breakpoints_added > 0: kwargs.setdefault("prompt_cache_options", PromptCacheOptions(mode="explicit")) if remaining: - kwargs["cache_control_injection_points"] = AnthropicCacheControlHook._stamped_as_judged(remaining) + kwargs["cache_control_injection_points"] = remaining return messages, system @property diff --git a/litellm/llms/azure_ai/common_utils.py b/litellm/llms/azure_ai/common_utils.py index d5a05cb8ea5..cffe9049de6 100644 --- a/litellm/llms/azure_ai/common_utils.py +++ b/litellm/llms/azure_ai/common_utils.py @@ -6,6 +6,7 @@ from urllib.parse import urlparse import litellm from litellm.llms.base_llm.base_utils import BaseLLMModelInfo, BaseTokenCounter +from litellm.llms.openai.chat.gpt_5_transformation import OpenAIGPT5Config from litellm.secret_managers.main import get_secret_str from litellm.types.llms.openai import AllMessageValues from litellm.types.router import GenericLiteLLMParams @@ -150,6 +151,14 @@ def azure_ai_supports_native_responses(model: str | None, api_base: str | None) return AzureFoundryModelInfo.get_azure_ai_route(model) == "default" +def foundry_chat_rejects_function_tools_while_reasoning( + model: str, reasoning_effort: str | Mapping[str, object] | None +) -> bool: + if reasoning_effort is None: + return OpenAIGPT5Config.is_model_gpt_6_plus_model(model) + return OpenAIGPT5Config.is_model_gpt_5_6_plus_model(model) + + class AzureFoundryModelInfo(BaseLLMModelInfo): """Model info for Azure AI / Azure Foundry models.""" diff --git a/litellm/llms/openai/chat/gpt_5_transformation.py b/litellm/llms/openai/chat/gpt_5_transformation.py index 1b93df95341..d0e5ff01e71 100644 --- a/litellm/llms/openai/chat/gpt_5_transformation.py +++ b/litellm/llms/openai/chat/gpt_5_transformation.py @@ -1,5 +1,6 @@ """Support for OpenAI gpt-5 model family.""" +import re from typing import Final import litellm @@ -11,6 +12,8 @@ from litellm.utils import ( from .gpt_transformation import OpenAIGPTConfig +_GPT_SERIES_VERSION: Final = re.compile(r"^gpt-(\d+)(?:\.(\d+))?(?=[.-]|$)") + def _catalogue_declares_default_effort() -> bool: """Whether the loaded cost map carries default_reasoning_effort for ANY entry. @@ -112,20 +115,28 @@ class OpenAIGPT5Config(OpenAIGPTConfig): model_name: Final = model.split("/")[-1] return model_name.startswith("gpt-5.4") + @staticmethod + def _gpt_series_version(model: str) -> tuple[int, int] | None: + match: Final = _GPT_SERIES_VERSION.match(model.split("/")[-1]) + if match is None: + return None + return int(match.group(1)), int(match.group(2) or 0) + @classmethod def is_model_gpt_5_4_plus_model(cls, model: str) -> bool: """Check if the model is gpt-5.4 or newer (5.4, 5.5, 5.6, etc., including pro).""" - model_name: Final = model.split("/")[-1] - if model_name.startswith("gpt-6"): - return True - if not model_name.startswith("gpt-5."): - return False - try: - version_str: Final = model_name.replace("gpt-5.", "").split("-")[0] - major: Final = version_str.split(".")[0] - return int(major) >= 4 - except (ValueError, IndexError): - return False + version: Final = cls._gpt_series_version(model) + return version is not None and version >= (5, 4) + + @classmethod + def is_model_gpt_5_6_plus_model(cls, model: str) -> bool: + version: Final = cls._gpt_series_version(model) + return version is not None and version >= (5, 6) + + @classmethod + def is_model_gpt_6_plus_model(cls, model: str) -> bool: + version: Final = cls._gpt_series_version(model) + return version is not None and version >= (6, 0) @classmethod def _model_map_lookup_name(cls, model: str) -> str: diff --git a/litellm/main.py b/litellm/main.py index a27dc70f5d2..6704358e3ea 100644 --- a/litellm/main.py +++ b/litellm/main.py @@ -100,6 +100,10 @@ from litellm.litellm_core_utils.prompt_templates.common_utils import ( from litellm.litellm_core_utils.request_timeout_resolver import ( get_configured_request_timeout, ) +from litellm.llms.azure_ai.common_utils import ( + azure_ai_supports_native_responses, + foundry_chat_rejects_function_tools_while_reasoning, +) from litellm.llms.base_llm import BaseConfig, BaseImageGenerationConfig from litellm.llms.base_llm.base_model_iterator import ( convert_model_response_to_streaming, @@ -1106,10 +1110,18 @@ def responses_api_bridge_check( # provider with a custom api_base and gpt-5.4+ model names serve tools without # reasoning fine and have no /responses route, so they keep pre-existing # behavior (bridge only on an explicit reasoning_effort). + # - Azure AI Foundry's OpenAI v1 hosts (azure_ai provider) enforce it later in the series: + # an explicit effort with function tools is rejected from gpt-5.6 on, and the unset + # effort only from gpt-6 on (gpt-5.6 serves tools with reasoning silently off), so the + # azure_ai gate keys on those measured boundaries instead of gpt-5.4+. # - Older GPT-5 names (e.g. ``gpt-5``, ``gpt-5.1``): bridge only when a reasoning # summary alias is present with ``reasoning_effort`` (tools alone stay on chat). has_function_tool: Final = any( - (tool.get("type") == "function" if isinstance(tool, dict) else getattr(tool, "type", None) == "function") + ( + tool.get("type") == "function" and (isinstance(tool.get("function"), dict) or "name" in tool) + if isinstance(tool, dict) + else getattr(tool, "type", None) == "function" + ) for tool in (tools or ()) ) if isinstance(reasoning_effort, dict): @@ -1118,28 +1130,35 @@ def responses_api_bridge_check( reasoning_active = reasoning_effort != "none" # The reasoning+tools constraint is enforced by the real OpenAI backend behind any api.openai.com # host (the default URL or a PrivateLink hostname such as .privatelink.api.openai.com) and - # by Azure OpenAI. Resolve the effective base arg>global>env>default exactly as the chat handler - # does, so a custom base set via litellm.api_base or OPENAI_BASE_URL/OPENAI_API_BASE isn't misread - # as the default and bridged to a /responses route it lacks. A whitespace-only base collapses to - # the default too. + # by Azure OpenAI through the azure provider. Resolve the effective OpenAI base arg>global>env>default + # exactly as the chat handler does, so a custom base set via litellm.api_base or + # OPENAI_BASE_URL/OPENAI_API_BASE isn't misread as the default and bridged to a /responses route it + # lacks. A whitespace-only base collapses to the default too. resolved_api_base: Final = _resolve_openai_api_base(api_base).strip() + on_foundry_openai_endpoint: Final = custom_llm_provider == "azure_ai" and azure_ai_supports_native_responses( + model, api_base + ) on_constraint_enforcing_endpoint: Final = ( custom_llm_provider == "azure" or resolved_api_base == "" or _is_openai_backed_api_base(resolved_api_base) ) - if ( - custom_llm_provider in ("openai", "azure") - and model_info.get("mode") != "responses" - and OpenAIGPT5Config.is_model_gpt_5_model(model) - and not OpenAIGPT5Config.is_model_gpt_5_search_model(model) + chat_rejects_function_tools: Final = ( + has_function_tool + and reasoning_active and ( - (reasoning_effort is not None and reasoning_summary is not None) - or ( + foundry_chat_rejects_function_tools_while_reasoning(model, reasoning_effort) + if on_foundry_openai_endpoint + else ( OpenAIGPT5Config.is_model_gpt_5_4_plus_model(model) - and has_function_tool - and reasoning_active and (reasoning_effort is not None or on_constraint_enforcing_endpoint) ) ) + ) + if ( + (custom_llm_provider in ("openai", "azure") or on_foundry_openai_endpoint) + and model_info.get("mode") != "responses" + and OpenAIGPT5Config.is_model_gpt_5_model(model) + and not OpenAIGPT5Config.is_model_gpt_5_search_model(model) + and ((reasoning_effort is not None and reasoning_summary is not None) or chat_rejects_function_tools) ): model_info["mode"] = "responses" model = model.replace("responses/", "") diff --git a/litellm/types/integrations/anthropic_cache_control_hook.py b/litellm/types/integrations/anthropic_cache_control_hook.py index ef414f22c3b..20e7885a2bf 100644 --- a/litellm/types/integrations/anthropic_cache_control_hook.py +++ b/litellm/types/integrations/anthropic_cache_control_hook.py @@ -17,8 +17,8 @@ class CacheControlMessageInjectionPoint(TypedDict): role: Literal["user", "system", "assistant"] | None # Optional: target by role (user, system, assistant) index: int | str | None # Optional: target by specific index control: ChatCompletionCachedContent | None - _litellm_judged: NotRequired[bool] # Internal: written back by litellm once the client cache_control judgment ran _litellm_openai_dialect: NotRequired[ReadOnly[bool]] + _litellm_external_breakpoints: NotRequired[ReadOnly[int]] class CacheControlToolConfigInjectionPoint(TypedDict): @@ -26,8 +26,8 @@ class CacheControlToolConfigInjectionPoint(TypedDict): location: Literal["tool_config"] control: ChatCompletionCachedContent | None - _litellm_judged: NotRequired[bool] # Internal: written back by litellm once the client cache_control judgment ran _litellm_openai_dialect: NotRequired[ReadOnly[bool]] + _litellm_external_breakpoints: NotRequired[ReadOnly[int]] CacheControlInjectionPoint = CacheControlMessageInjectionPoint | CacheControlToolConfigInjectionPoint diff --git a/litellm/types/llms/anthropic.py b/litellm/types/llms/anthropic.py index c59c88698f7..a22eff79dbb 100644 --- a/litellm/types/llms/anthropic.py +++ b/litellm/types/llms/anthropic.py @@ -756,6 +756,10 @@ class ANTHROPIC_BETA_HEADER_VALUES(str, Enum): # Tool search beta header constant (for Anthropic direct API and Microsoft Foundry) ANTHROPIC_TOOL_SEARCH_BETA_HEADER: Final = "advanced-tool-use-2025-11-20" +ANTHROPIC_TOOL_SEARCH_TOOL_TYPES: Final = frozenset( + {"tool_search_tool_regex_20251119", "tool_search_tool_bm25_20251119"} +) + # Effort beta header constant ANTHROPIC_EFFORT_BETA_HEADER: Final = "effort-2025-11-24" diff --git a/tests/test_litellm/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_transformation.py b/tests/test_litellm/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_transformation.py index c326ad4a0f7..7e03a8886fb 100644 --- a/tests/test_litellm/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_transformation.py +++ b/tests/test_litellm/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_transformation.py @@ -830,6 +830,24 @@ def test_convert_tools_to_responses_format(): assert result[0]["name"] == "test" +def test_convert_tools_to_responses_format_passes_flat_function_tool_through(): + from litellm.completion_extras.litellm_responses_transformation.transformation import ( + LiteLLMResponsesTransformationHandler, + ) + + handler = LiteLLMResponsesTransformationHandler() + flat_tool = { + "type": "function", + "name": "shell", + "description": "Run a shell command", + "parameters": {"type": "object", "properties": {"cmd": {"type": "string"}}, "required": ["cmd"]}, + } + + converted = handler._convert_tools_to_responses_format([flat_tool]) + + assert converted == [flat_tool] + + def test_extract_extra_body_params_reasoning_effort_override(): """Test that reasoning_effort from extra_body overrides top-level reasoning_effort""" from litellm.completion_extras.litellm_responses_transformation.transformation import ( diff --git a/tests/test_litellm/integrations/test_anthropic_cache_control_hook.py b/tests/test_litellm/integrations/test_anthropic_cache_control_hook.py index 83649c3386a..7bf4533979a 100644 --- a/tests/test_litellm/integrations/test_anthropic_cache_control_hook.py +++ b/tests/test_litellm/integrations/test_anthropic_cache_control_hook.py @@ -4,10 +4,11 @@ import os import subprocess import sys import textwrap -from typing import List, Optional, Tuple +from typing import Final, List, Optional, Tuple from unittest.mock import MagicMock, patch import pytest +from pydantic import BaseModel, ConfigDict import litellm from litellm.integrations.anthropic_cache_control_hook import ( @@ -1276,11 +1277,7 @@ def test_cache_control_hook_reserves_slot_for_tool_config_point(): ) assert _count_cache_control(processed) == 3 - # The tool_config point is passed through for the provider transform, - # stamped so re-entries never re-judge it against litellm's own marks. - assert non_default_params["cache_control_injection_points"] == [ - {"location": "tool_config", "_litellm_judged": True} - ] + assert non_default_params["cache_control_injection_points"] == [{"location": "tool_config"}] @pytest.mark.asyncio @@ -1338,18 +1335,8 @@ async def test_cache_control_hook_bedrock_payload_caps_with_tool_config_point(mo client=client, ) - request_body = json.loads(mock_post.call_args.kwargs["data"]) - - cache_points = sum( - 1 for block in request_body.get("system", []) if isinstance(block, dict) and "cachePoint" in block - ) - for msg in request_body.get("messages", []): - content = msg.get("content", []) - if isinstance(content, list): - cache_points += sum(1 for block in content if isinstance(block, dict) and "cachePoint" in block) - for tool in request_body.get("toolConfig", {}).get("tools", []): - if isinstance(tool, dict) and "cachePoint" in tool: - cache_points += 1 + request_body = _ConverseBody.model_validate_json(mock_post.call_args.kwargs["data"]) + cache_points = _count_converse_cache_points(request_body) assert cache_points <= 4, ( f"Bedrock payload exceeded Anthropic's 4 cache_control block limit " @@ -1357,6 +1344,97 @@ async def test_cache_control_hook_bedrock_payload_caps_with_tool_config_point(mo ) +class _ConverseMessage(BaseModel): + model_config = ConfigDict(frozen=True) + + content: tuple[dict[str, object], ...] = () + + +class _ConverseToolConfig(BaseModel): + model_config = ConfigDict(frozen=True) + + tools: tuple[dict[str, object], ...] = () + + +class _ConverseBody(BaseModel): + model_config = ConfigDict(frozen=True) + + system: tuple[dict[str, object], ...] = () + messages: tuple[_ConverseMessage, ...] = () + toolConfig: _ConverseToolConfig = _ConverseToolConfig() + + +def _count_converse_cache_points(request_body: _ConverseBody) -> int: + blocks: Final = ( + *request_body.system, + *(block for message in request_body.messages for block in message.content), + *request_body.toolConfig.tools, + ) + return sum(1 for block in blocks if "cachePoint" in block) + + +@pytest.mark.asyncio +async def test_cache_control_hook_bedrock_tool_config_point_stands_down_when_client_marks_fill_the_cap( + monkeypatch: pytest.MonkeyPatch, +): + with patch.dict( + os.environ, + { + "AWS_ACCESS_KEY_ID": "fake_access_key_id", + "AWS_SECRET_ACCESS_KEY": "fake_secret_access_key", + "AWS_REGION_NAME": "us-east-1", + }, + ): + monkeypatch.setattr(litellm, "callbacks", [AnthropicCacheControlHook()]) + + mock_response = MagicMock() + mock_response.json.return_value = { + "output": {"message": {"role": "assistant", "content": "ok"}}, + "stopReason": "end_turn", + "usage": {"inputTokens": 100, "outputTokens": 4, "totalTokens": 104}, + } + mock_response.status_code = 200 + + client = AsyncHTTPHandler() + with patch.object(client, "post", return_value=mock_response) as mock_post: + marked = {"type": "ephemeral"} + messages = [ + {"role": "system", "content": [{"type": "text", "text": "sys", "cache_control": marked}]}, + *( + {"role": "user", "content": [{"type": "text", "text": f"turn {i}", "cache_control": marked}]} + for i in range(3) + ), + {"role": "user", "content": "What is the weather?"}, + ] + + await litellm.acompletion( + model="bedrock/us.anthropic.claude-opus-4-6-v1:0", + messages=messages, + max_tokens=32, + tools=[ + { + "type": "function", + "function": { + "name": "get_weather", + "description": "Get weather for a location", + "parameters": { + "type": "object", + "properties": {"location": {"type": "string"}}, + "required": ["location"], + }, + }, + } + ], + cache_control_injection_points=[{"location": "tool_config"}], + client=client, + ) + + request_body = _ConverseBody.model_validate_json(mock_post.call_args.kwargs["data"]) + + assert _count_converse_cache_points(request_body) == 4 + assert not any("cachePoint" in tool for tool in request_body.toolConfig.tools) + + class TestApplyToAnthropicMessagesRequest: """Tests for apply_to_anthropic_messages_request (v1/messages cache control).""" @@ -1683,13 +1761,17 @@ class TestEnableAnthropicPromptCaching: result_messages, result_system = AnthropicCacheControlHook.maybe_inject_cache_control( messages, system, kwargs, model, provider, tools=tools, ) - if client_control != "none": + if client_control != "none" and not configured: assert (result_messages, result_system, tools) == original assert kwargs["metadata"] == {} else: assert kwargs["metadata"]["litellm_gateway_injected_cache"] == "selected-deployment" assert sum(AnthropicCacheControlHook._count_cache_control_blocks(m) for m in result_messages) == 1 assert result_system[0]["cache_control"] == control + assert result_messages[-1]["content"][-1]["cache_control"] == control + assert tools == original[2] + assert (result_messages == original[0]) == (envelope == "request" and client_control == "message") + assert (result_system == original[1]) == (envelope == "request" and client_control == "system") if provider == "vertex_ai": wire = VertexAIAnthropicConfig().transform_request( model=model, messages=[{"role": "system", "content": result_system}, *result_messages], @@ -1706,7 +1788,7 @@ class TestEnableAnthropicPromptCaching: AnthropicCacheControlHook.maybe_seed_default_injection_points( seeded, [{"role": "system", "content": original[1]}, *original[0]], model, provider, tools=tools, ) - assert bool(seeded.get("cache_control_injection_points")) == (client_control == "none") + assert bool(seeded.get("cache_control_injection_points")) == (client_control == "none" or configured) @pytest.mark.asyncio @pytest.mark.parametrize("asynchronous", [False, True]) @@ -2257,13 +2339,11 @@ class TestPerKeyEnablePromptCaching: assert result_msgs == messages -class TestConfiguredInjectionPointsStandDown: - """Configured cache_control_injection_points must stand down entirely when the - client already set its own cache_control anywhere in the request (LIT-4582); - injecting alongside client breakpoints clashes with the client's caching - strategy and can push the request past Anthropic's four-block limit.""" - +class TestConfiguredInjectionPointsSurviveClientMarks: CONFIGURED = [{"location": "message", "role": "system"}] + TAIL_POINT = [{"location": "message", "index": -1}] + TOOL_CONFIG_POINT = [{"location": "tool_config"}] + EPHEMERAL = {"type": "ephemeral"} CLEAN_MESSAGES: List[AllMessageValues] = [ {"role": "system", "content": "sys"}, @@ -2277,6 +2357,37 @@ class TestConfiguredInjectionPointsStandDown: V1_MESSAGES = [{"role": "user", "content": [{"type": "text", "text": "hi"}]}] + MARKED_TOOL_TOP_LEVEL = { + "type": "function", + "function": {"name": "t", "parameters": {}}, + "cache_control": {"type": "ephemeral"}, + } + MARKED_TOOL_NESTED = { + "type": "function", + "function": {"name": "t", "parameters": {}, "cache_control": {"type": "ephemeral"}}, + } + UNMARKED_TOOL = {"type": "function", "function": {"name": "t", "parameters": {}}} + MARKED_V1_TOOL = {"name": "t", "input_schema": {}, "cache_control": {"type": "ephemeral"}} + UNMARKED_V1_TOOL = {"name": "t", "input_schema": {}} + MARKED_SYSTEM = [{"type": "text", "text": "sys", "cache_control": EPHEMERAL}] + MARKED_TOOL_SEARCH_REGEX = { + "type": "tool_search_tool_regex_20251119", + "name": "tool_search", + "cache_control": {"type": "ephemeral"}, + } + MARKED_TOOL_SEARCH_BM25 = { + "type": "tool_search_tool_bm25_20251119", + "name": "tool_search", + "cache_control": {"type": "ephemeral"}, + } + + @staticmethod + def _marked_user_turns(count: int) -> List[AllMessageValues]: + return [ + {"role": "user", "content": [{"type": "text", "text": f"turn {i}", "cache_control": {"type": "ephemeral"}}]} + for i in range(count) + ] + def _seed(self, params, messages, tools=None): AnthropicCacheControlHook.maybe_seed_default_injection_points( non_default_params=params, @@ -2286,6 +2397,17 @@ class TestConfiguredInjectionPointsStandDown: tools=tools, ) + def _chat(self, params: dict[str, object], messages: List[AllMessageValues]) -> List[AllMessageValues]: + _, processed, _ = AnthropicCacheControlHook().get_chat_completion_prompt( + model="claude-sonnet-4-5", + messages=messages, + non_default_params=params, + prompt_id=None, + prompt_variables=None, + dynamic_callback_params={}, + ) + return processed + def _inject(self, messages, kwargs, system="sys", tools=None): return AnthropicCacheControlHook.maybe_inject_cache_control( messages, @@ -2296,23 +2418,79 @@ class TestConfiguredInjectionPointsStandDown: tools=tools, ) - def test_configured_points_dropped_when_messages_carry_cache_control(self): + def test_chat_tail_point_applies_when_client_marked_the_system_block(self): + messages: List[AllMessageValues] = [ + {"role": "system", "content": [{"type": "text", "text": "sys", "cache_control": {"type": "ephemeral"}}]}, + {"role": "user", "content": "history"}, + {"role": "assistant", "content": "reply"}, + {"role": "user", "content": "question"}, + ] + params = {"cache_control_injection_points": copy.deepcopy(self.TAIL_POINT)} + self._seed(params, messages) + processed = self._chat(params, messages) + assert processed[0] == messages[0] + assert processed[-1] == {"role": "user", "content": "question", "cache_control": self.EPHEMERAL} + assert _count_cache_control(processed) == 2 + + def test_chat_configured_points_apply_when_messages_carry_cache_control(self): params = {"cache_control_injection_points": copy.deepcopy(self.CONFIGURED)} self._seed(params, copy.deepcopy(self.MARKED_MESSAGES)) - assert "cache_control_injection_points" not in params + processed = self._chat(params, copy.deepcopy(self.MARKED_MESSAGES)) + assert processed[0] == {"role": "system", "content": "sys", "cache_control": self.EPHEMERAL} + assert processed[1] == self.MARKED_MESSAGES[1] @pytest.mark.parametrize( - "tool", - [ - {"type": "function", "function": {"name": "t", "parameters": {}}, "cache_control": {"type": "ephemeral"}}, - {"type": "function", "function": {"name": "t", "parameters": {}, "cache_control": {"type": "ephemeral"}}}, - ], - ids=["top_level", "nested_in_function"], + "tool", [MARKED_TOOL_TOP_LEVEL, MARKED_TOOL_NESTED], ids=["top_level", "nested_in_function"] ) - def test_configured_points_dropped_when_tools_carry_cache_control(self, tool): + def test_chat_configured_points_apply_when_tools_carry_cache_control(self, tool): params = {"cache_control_injection_points": copy.deepcopy(self.CONFIGURED)} self._seed(params, copy.deepcopy(self.CLEAN_MESSAGES), tools=[tool]) - assert "cache_control_injection_points" not in params + processed = self._chat(params, copy.deepcopy(self.CLEAN_MESSAGES)) + assert processed[0] == {"role": "system", "content": "sys", "cache_control": self.EPHEMERAL} + + @pytest.mark.parametrize( + "tool,injected", + [(MARKED_TOOL_TOP_LEVEL, 0), (MARKED_TOOL_NESTED, 0), (UNMARKED_TOOL, 1)], + ids=["marked_top_level", "marked_nested_in_function", "unmarked"], + ) + def test_chat_cap_counts_client_marked_tools(self, tool, injected): + messages = [{"role": "system", "content": "sys"}, *self._marked_user_turns(3)] + params = {"cache_control_injection_points": copy.deepcopy(self.CONFIGURED)} + self._seed(params, copy.deepcopy(messages), tools=[tool]) + processed = self._chat(params, copy.deepcopy(messages)) + assert _count_cache_control(processed) == 3 + injected + + @pytest.mark.parametrize("tool", [MARKED_TOOL_SEARCH_REGEX, MARKED_TOOL_SEARCH_BM25], ids=["regex", "bm25"]) + def test_chat_cap_ignores_marked_tool_search_tools(self, tool): + messages = [{"role": "system", "content": "sys"}, *self._marked_user_turns(3)] + params = {"cache_control_injection_points": copy.deepcopy(self.CONFIGURED)} + self._seed(params, copy.deepcopy(messages), tools=[tool]) + processed = self._chat(params, copy.deepcopy(messages)) + assert _count_cache_control(processed) == 4 + + @pytest.mark.parametrize("marked_turns,forwarded", [(3, ["tool_config"]), (4, [])], ids=["slot_left", "cap_full"]) + def test_chat_forwards_tool_config_point_only_while_a_slot_is_left(self, marked_turns, forwarded): + messages = [{"role": "system", "content": "sys"}, *self._marked_user_turns(marked_turns)] + params = {"cache_control_injection_points": copy.deepcopy(self.TOOL_CONFIG_POINT)} + self._seed(params, copy.deepcopy(messages), tools=[self.UNMARKED_TOOL]) + self._chat(params, copy.deepcopy(messages)) + assert [p["location"] for p in params.get("cache_control_injection_points", [])] == forwarded + + @pytest.mark.parametrize("marked_turns,forwarded", [(3, ["tool_config"]), (4, [])], ids=["slot_left", "cap_full"]) + def test_v1_messages_forwards_tool_config_point_only_while_a_slot_is_left(self, marked_turns, forwarded): + kwargs = {"cache_control_injection_points": copy.deepcopy(self.TOOL_CONFIG_POINT)} + self._inject(self._marked_user_turns(marked_turns), kwargs, tools=[self.UNMARKED_V1_TOOL]) + assert [p["location"] for p in kwargs.get("cache_control_injection_points", [])] == forwarded + + @pytest.mark.parametrize("marked_turns,injected", [(2, 1), (3, 0)]) + def test_chat_root_cache_control_reserves_a_slot(self, marked_turns, injected): + messages = [{"role": "system", "content": "sys"}, *self._marked_user_turns(marked_turns)] + root_cache_control = {"type": "ephemeral"} + params = {"cache_control_injection_points": copy.deepcopy(self.CONFIGURED), "cache_control": root_cache_control} + self._seed(params, copy.deepcopy(messages)) + processed = self._chat(params, copy.deepcopy(messages)) + assert _count_cache_control(processed) == marked_turns + injected + assert params["cache_control"] is root_cache_control def test_configured_points_kept_when_request_is_unmarked(self): configured = copy.deepcopy(self.CONFIGURED) @@ -2320,43 +2498,59 @@ class TestConfiguredInjectionPointsStandDown: self._seed(params, copy.deepcopy(self.CLEAN_MESSAGES)) assert params["cache_control_injection_points"] is configured - def test_judged_remainder_survives_reentry_despite_injected_marks(self): - """acompletion() re-enters completion() after injection ran, with only the - stamped non-message points written back; the re-entry must not misread - litellm's own marks as client ones and drop that remainder.""" - remainder = [{"location": "tool_config", "_litellm_judged": True}] - params = {"cache_control_injection_points": remainder} - self._seed(params, copy.deepcopy(self.MARKED_MESSAGES)) - assert params["cache_control_injection_points"] is remainder + def test_chat_reentry_over_injected_messages_adds_no_duplicate_marks(self): + points = [{"location": "message", "role": "system"}, {"location": "tool_config"}] + first_params = {"cache_control_injection_points": copy.deepcopy(points)} + self._seed(first_params, copy.deepcopy(self.MARKED_MESSAGES)) + first = self._chat(first_params, copy.deepcopy(self.MARKED_MESSAGES)) + assert _count_cache_control(first) == 2 + assert first_params["cache_control_injection_points"] == [{"location": "tool_config"}] - def test_v1_messages_stand_down_when_content_block_marked(self): + second_params = {"cache_control_injection_points": copy.deepcopy(points)} + self._seed(second_params, copy.deepcopy(first)) + second = self._chat(second_params, copy.deepcopy(first)) + assert second == first + assert second_params["cache_control_injection_points"] == [{"location": "tool_config"}] + + def test_v1_messages_configured_point_applies_when_content_block_marked(self): messages = [ {"role": "user", "content": [{"type": "text", "text": "hi", "cache_control": {"type": "ephemeral"}}]} ] kwargs = {"cache_control_injection_points": copy.deepcopy(self.CONFIGURED)} result_msgs, result_sys = self._inject(copy.deepcopy(messages), kwargs) assert result_msgs == messages - assert result_sys == "sys" + assert result_sys == [{"type": "text", "text": "sys", "cache_control": self.EPHEMERAL}] assert "cache_control_injection_points" not in kwargs - def test_v1_messages_stand_down_when_system_block_marked(self): - """A configured point targeting a message must not fire when the client - marked the system prompt; the old behavior injected into the message - because only the exact targeted position was guarded.""" + def test_v1_messages_tail_point_applies_when_system_block_marked(self): system = [{"type": "text", "text": "s", "cache_control": {"type": "ephemeral"}}] - kwargs = {"cache_control_injection_points": [{"location": "message", "role": "user"}]} + kwargs = {"cache_control_injection_points": copy.deepcopy(self.TAIL_POINT)} result_msgs, result_sys = self._inject(copy.deepcopy(self.V1_MESSAGES), kwargs, system=system) - assert result_msgs == self.V1_MESSAGES + assert result_msgs == [ + {"role": "user", "content": [{"type": "text", "text": "hi", "cache_control": self.EPHEMERAL}]} + ] assert result_sys == system - assert "cache_control_injection_points" not in kwargs - def test_v1_messages_stand_down_when_tools_marked(self): - tools = [{"name": "t", "input_schema": {}, "cache_control": {"type": "ephemeral"}}] + def test_v1_messages_configured_point_applies_when_tools_marked(self): kwargs = {"cache_control_injection_points": copy.deepcopy(self.CONFIGURED)} - result_msgs, result_sys = self._inject(copy.deepcopy(self.V1_MESSAGES), kwargs, tools=tools) + result_msgs, result_sys = self._inject(copy.deepcopy(self.V1_MESSAGES), kwargs, tools=[self.MARKED_V1_TOOL]) assert result_msgs == self.V1_MESSAGES - assert result_sys == "sys" - assert "cache_control_injection_points" not in kwargs + assert result_sys == [{"type": "text", "text": "sys", "cache_control": self.EPHEMERAL}] + + @pytest.mark.parametrize( + "tool,expected_system", + [ + (MARKED_V1_TOOL, "sys"), + (MARKED_TOOL_SEARCH_REGEX, "sys"), + (MARKED_TOOL_SEARCH_BM25, "sys"), + (UNMARKED_V1_TOOL, [{"type": "text", "text": "sys", "cache_control": {"type": "ephemeral"}}]), + ], + ids=["marked", "marked_tool_search_regex", "marked_tool_search_bm25", "unmarked"], + ) + def test_v1_messages_cap_counts_client_marked_tools(self, tool, expected_system): + kwargs = {"cache_control_injection_points": copy.deepcopy(self.CONFIGURED)} + _, result_sys = self._inject(self._marked_user_turns(3), kwargs, tools=[tool]) + assert result_sys == expected_system def test_v1_messages_configured_points_apply_when_unmarked(self): kwargs = {"cache_control_injection_points": copy.deepcopy(self.CONFIGURED)} @@ -2364,16 +2558,73 @@ class TestConfiguredInjectionPointsStandDown: assert result_sys == [{"type": "text", "text": "sys", "cache_control": {"type": "ephemeral"}}] @pytest.mark.parametrize( - "configured", - [None, CONFIGURED], - ids=["automatic_defaults", "configured_points"], + "extra_body,injected", + [ + ({"tools": [MARKED_TOOL_TOP_LEVEL]}, 0), + ({"cache_control": {"type": "ephemeral"}}, 0), + ({"tools": [UNMARKED_TOOL]}, 1), + ], + ids=["marked_tool", "root_cache_control", "unmarked_tool"], ) - def test_v1_messages_stands_down_for_root_cache_control(self, monkeypatch, configured): + def test_chat_cap_counts_client_marks_sent_through_extra_body(self, extra_body, injected): + messages = [{"role": "system", "content": "sys"}, *self._marked_user_turns(3)] + params = {"cache_control_injection_points": copy.deepcopy(self.CONFIGURED), "extra_body": extra_body} + self._seed(params, copy.deepcopy(messages)) + processed = self._chat(params, copy.deepcopy(messages)) + assert _count_cache_control(processed) == 3 + injected + + @pytest.mark.parametrize( + "extra_body,expected_system", + [ + ({"cache_control": {"type": "ephemeral"}}, "sys"), + ({"tools": [MARKED_V1_TOOL]}, "sys"), + ({"tools": [UNMARKED_V1_TOOL]}, [{"type": "text", "text": "sys", "cache_control": {"type": "ephemeral"}}]), + ], + ids=["root_cache_control", "marked_tool", "unmarked_tool"], + ) + def test_v1_messages_cap_counts_client_marks_sent_through_extra_body(self, extra_body, expected_system): + kwargs = {"cache_control_injection_points": copy.deepcopy(self.CONFIGURED), "extra_body": extra_body} + _, result_sys = self._inject(self._marked_user_turns(3), kwargs) + assert result_sys == expected_system + + @pytest.mark.parametrize( + "params,tools,marked_turns,injected", + [ + ({"extra_body": {"tools": [MARKED_TOOL_TOP_LEVEL]}}, [MARKED_TOOL_TOP_LEVEL], 2, 1), + ({"extra_body": {"tools": [UNMARKED_TOOL]}}, [MARKED_TOOL_TOP_LEVEL], 3, 1), + ({"extra_body": {"tools": [MARKED_TOOL_TOP_LEVEL]}}, [UNMARKED_TOOL], 3, 0), + ({"extra_body": {"cache_control": EPHEMERAL}, "cache_control": EPHEMERAL}, None, 2, 1), + ], + ids=["same_marked_tool_both_ways", "extra_body_unmarks", "extra_body_marks", "root_cache_control_both_ways"], + ) + def test_chat_cap_counts_extra_body_fields_in_place_of_the_direct_ones(self, params, tools, marked_turns, injected): + messages = [{"role": "system", "content": "sys"}, *self._marked_user_turns(marked_turns)] + params = {"cache_control_injection_points": copy.deepcopy(self.CONFIGURED), **copy.deepcopy(params)} + self._seed(params, copy.deepcopy(messages), tools=tools) + processed = self._chat(params, copy.deepcopy(messages)) + assert _count_cache_control(processed) == marked_turns + injected + + @pytest.mark.parametrize( + "kwargs,tools,marked_turns,expected_system", + [ + ({"extra_body": {"tools": [MARKED_V1_TOOL]}}, [MARKED_V1_TOOL], 2, MARKED_SYSTEM), + ({"extra_body": {"tools": [UNMARKED_V1_TOOL]}}, [MARKED_V1_TOOL], 3, "sys"), + ({"extra_body": {"tools": [MARKED_V1_TOOL]}}, [UNMARKED_V1_TOOL], 3, "sys"), + ({"extra_body": {"cache_control": EPHEMERAL}, "cache_control": EPHEMERAL}, None, 2, MARKED_SYSTEM), + ], + ids=["same_marked_tool_both_ways", "extra_body_unmarks", "extra_body_marks", "root_cache_control_both_ways"], + ) + def test_v1_messages_cap_reserves_for_the_larger_of_direct_and_extra_body_marks( + self, kwargs, tools, marked_turns, expected_system + ): + kwargs = {"cache_control_injection_points": copy.deepcopy(self.CONFIGURED), **copy.deepcopy(kwargs)} + _, result_sys = self._inject(self._marked_user_turns(marked_turns), kwargs, tools=tools) + assert result_sys == expected_system + + def test_v1_messages_automatic_defaults_stand_down_for_root_cache_control(self, monkeypatch): monkeypatch.setattr(litellm, "enable_anthropic_prompt_caching", True) root_cache_control = {"type": "ephemeral"} kwargs = {"cache_control": root_cache_control, "litellm_metadata": {}} - if configured is not None: - kwargs["cache_control_injection_points"] = copy.deepcopy(configured) result_messages, result_system = self._inject(copy.deepcopy(self.V1_MESSAGES), kwargs) @@ -2382,17 +2633,28 @@ class TestConfiguredInjectionPointsStandDown: assert kwargs["cache_control"] is root_cache_control assert "litellm_gateway_injected_cache" not in kwargs["litellm_metadata"] + @pytest.mark.parametrize( + "marked_turns,expected_system", + [(2, [{"type": "text", "text": "sys", "cache_control": {"type": "ephemeral"}}]), (3, "sys")], + ) + def test_v1_messages_configured_points_apply_with_root_cache_control_reserving_a_slot( + self, marked_turns, expected_system + ): + root_cache_control = {"type": "ephemeral"} + kwargs = { + "cache_control": root_cache_control, + "cache_control_injection_points": copy.deepcopy(self.CONFIGURED), + } + _, result_system = self._inject(self._marked_user_turns(marked_turns), kwargs) + assert result_system == expected_system + assert kwargs["cache_control"] is root_cache_control + def test_v1_messages_reentry_flow_preserves_tool_config_remainder(self): - """The advisor interceptor re-enters anthropic_messages() with the outer - request's kwargs and post-injection messages. The first pass applies the - message point and writes back a stamped tool_config remainder; the - re-entry must keep that remainder even though the messages and system - now carry litellm's own marks.""" points = [{"location": "message", "role": "system"}, {"location": "tool_config"}] kwargs = {"cache_control_injection_points": copy.deepcopy(points)} msgs1, sys1 = self._inject(copy.deepcopy(self.V1_MESSAGES), kwargs) assert sys1[0]["cache_control"] == {"type": "ephemeral"} - expected_remainder = [{"location": "tool_config", "_litellm_judged": True}] + expected_remainder = [{"location": "tool_config"}] assert kwargs["cache_control_injection_points"] == expected_remainder msgs2, sys2 = self._inject(msgs1, kwargs, system=sys1) @@ -2631,22 +2893,26 @@ class TestOpenAIPromptCacheBreakpoint: assert system == [{"type": "text", "text": "sys", "cache_control": {"type": "ephemeral"}}] assert kwargs == {} - def test_v1_messages_client_content_breakpoint_makes_configured_points_stand_down(self): - messages = [{"role": "user", "content": [{"type": "text", "text": "hi", "prompt_cache_breakpoint": self.EXPLICIT}]}] + def test_v1_messages_configured_points_apply_beside_client_content_breakpoint(self): + messages = [ + {"role": "user", "content": [{"type": "text", "text": "hi", "prompt_cache_breakpoint": self.EXPLICIT}]} + ] kwargs = {"cache_control_injection_points": copy.deepcopy(self.SYSTEM_POINT)} result, system = self._inject(messages, "sys", kwargs) assert result == messages - assert system == "sys" - assert kwargs == {} + assert system == [{"type": "text", "text": "sys", "prompt_cache_breakpoint": self.EXPLICIT}] + assert kwargs == {"prompt_cache_options": self.EXPLICIT} - def test_v1_messages_client_system_breakpoint_makes_configured_points_stand_down(self): + def test_v1_messages_tail_point_applies_beside_client_system_breakpoint(self): system = [{"type": "text", "text": "sys", "prompt_cache_breakpoint": self.EXPLICIT}] messages = [{"role": "user", "content": [{"type": "text", "text": "hi"}]}] kwargs = {"cache_control_injection_points": [{"location": "message", "index": -1}]} result, result_system = self._inject(messages, system, kwargs) - assert result == messages + assert result == [ + {"role": "user", "content": [{"type": "text", "text": "hi", "prompt_cache_breakpoint": self.EXPLICIT}]} + ] assert result_system == system - assert kwargs == {} + assert kwargs == {"prompt_cache_options": self.EXPLICIT} def test_chat_system_string_wrapped_with_block_breakpoint(self): params = {"cache_control_injection_points": copy.deepcopy(self.SYSTEM_POINT)} @@ -2710,18 +2976,25 @@ class TestOpenAIPromptCacheBreakpoint: assert processed[0] == {"role": "system", "content": "sys", "cache_control": {"type": "ephemeral"}} assert params == {} - def test_chat_client_breakpoint_makes_seeded_points_stand_down(self): + def test_chat_seeded_points_apply_beside_client_breakpoint(self): params = {"cache_control_injection_points": copy.deepcopy(self.SYSTEM_POINT)} + messages = [ + {"role": "system", "content": "sys"}, + {"role": "user", "content": [{"type": "text", "text": "hi", "prompt_cache_breakpoint": self.EXPLICIT}]}, + ] AnthropicCacheControlHook.maybe_seed_default_injection_points( non_default_params=params, - messages=[ - {"role": "system", "content": "sys"}, - {"role": "user", "content": [{"type": "text", "text": "hi", "prompt_cache_breakpoint": self.EXPLICIT}]}, - ], + messages=messages, model="openai/gpt-5.6", custom_llm_provider="openai", ) - assert params == {} + assert params["cache_control_injection_points"] == [ + {"location": "message", "role": "system", "_litellm_openai_dialect": True} + ] + _, processed, _ = self._chat(messages, params) + assert processed[0]["content"] == [{"type": "text", "text": "sys", "prompt_cache_breakpoint": self.EXPLICIT}] + assert processed[1] == messages[1] + assert params["prompt_cache_options"] == self.EXPLICIT def test_cap_counts_client_breakpoints_of_both_kinds(self): messages = [ @@ -3315,7 +3588,6 @@ class TestRecordGatewayInjection: assert kwargs["litellm_metadata"][self.KEY] == self.DEPLOYMENT def test_configured_points_skipping_a_marked_target_record_nothing(self): - """Configured injection stands down on client breakpoints, so no marker lands.""" kwargs: dict = { "litellm_metadata": {}, "cache_control_injection_points": [{"location": "message", "role": "system", "index": None}], diff --git a/tests/test_litellm/llms/openai/test_is_model_gpt_5_model.py b/tests/test_litellm/llms/openai/test_is_model_gpt_5_model.py index 107a1afb2c6..0bb8425d95e 100644 --- a/tests/test_litellm/llms/openai/test_is_model_gpt_5_model.py +++ b/tests/test_litellm/llms/openai/test_is_model_gpt_5_model.py @@ -159,6 +159,58 @@ class TestOpenAIGPT5ConfigIsModelGpt54PlusModel: ), f"Expected '{model}' NOT to be classified as gpt-5.4-or-newer" +GPT5_6_PLUS_MODELS = [ + "gpt-6-astra", + "openai/gpt-6-astra", + "gpt-5.6", + "gpt-5.6-sol", + "gpt-5.6-terra", + "gpt-5.10-preview", +] + +GPT5_PRE_5_6_MODELS = [ + "gpt-5", + "gpt-5.4", + "gpt-5.4-mini", + "gpt-5.5", + "gpt-5.5-pro", + "gpt-4o", +] + +GPT6_PLUS_MODELS = [ + "gpt-6-astra", + "openai/gpt-6-astra", + "gpt-6", + "gpt-6.1-preview", +] + +GPT_PRE_6_MODELS = [ + "gpt-5.6-sol", + "gpt-5.5", + "gpt-5", + "gpt-4o", +] + + +class TestOpenAIGPT5ConfigSeriesBoundaries: + + @pytest.mark.parametrize("model", GPT5_6_PLUS_MODELS) + def test_gpt5_6_plus_models_are_classified_as_5_6_plus(self, model: str): + assert OpenAIGPT5Config.is_model_gpt_5_6_plus_model(model) + + @pytest.mark.parametrize("model", GPT5_PRE_5_6_MODELS) + def test_pre_5_6_models_are_not_classified_as_5_6_plus(self, model: str): + assert not OpenAIGPT5Config.is_model_gpt_5_6_plus_model(model) + + @pytest.mark.parametrize("model", GPT6_PLUS_MODELS) + def test_gpt6_plus_models_are_classified_as_6_plus(self, model: str): + assert OpenAIGPT5Config.is_model_gpt_6_plus_model(model) + + @pytest.mark.parametrize("model", GPT_PRE_6_MODELS) + def test_pre_6_models_are_not_classified_as_6_plus(self, model: str): + assert not OpenAIGPT5Config.is_model_gpt_6_plus_model(model) + + # --------------------------------------------------------------------------- # AzureOpenAIGPT5Config # --------------------------------------------------------------------------- diff --git a/tests/test_litellm/test_main.py b/tests/test_litellm/test_main.py index 2a8a4cce526..af754e069da 100644 --- a/tests/test_litellm/test_main.py +++ b/tests/test_litellm/test_main.py @@ -1049,6 +1049,35 @@ def test_responses_api_bridge_check_gpt_5_4_flat_function_tool_routes_to_respons assert model_info.get("mode") == "responses" +@pytest.mark.parametrize( + "custom_llm_provider, model_name, api_base", + [ + pytest.param("openai", "gpt-5.6", None, id="openai"), + pytest.param("azure_ai", "gpt-6-astra", "https://myproject.services.ai.azure.com", id="azure-ai-foundry"), + ], +) +def test_responses_api_bridge_check_function_tool_without_body_stays_chat( + monkeypatch, custom_llm_provider, model_name, api_base +): + import litellm + from litellm.main import responses_api_bridge_check + + monkeypatch.delenv("OPENAI_BASE_URL", raising=False) + monkeypatch.delenv("OPENAI_API_BASE", raising=False) + monkeypatch.setattr(litellm, "api_base", None) + + model_info, model = responses_api_bridge_check( + model=model_name, + custom_llm_provider=custom_llm_provider, + tools=[{"type": "function"}], + reasoning_effort=None, + api_base=api_base, + ) + + assert model == model_name + assert model_info.get("mode") != "responses" + + def test_responses_api_bridge_check_dict_effort_none_stays_chat(): """The escape hatch must honor litellm's dict form: {"effort": "none"} means reasoning off.""" from litellm.main import responses_api_bridge_check @@ -1308,6 +1337,68 @@ def test_responses_api_bridge_check_azure_with_api_base_and_unset_effort_routes( assert model_info.get("mode") == "responses" +_FOUNDRY_API_BASE: Final = "https://myproject.services.ai.azure.com" +_FOUNDRY_FUNCTION_TOOL: Final = ({"type": "function", "function": {"name": "get_weather"}},) + + +@pytest.mark.parametrize( + "model_name, api_base, reasoning_effort", + [ + pytest.param("gpt-6-astra", _FOUNDRY_API_BASE, None, id="gpt-6-unset-effort"), + pytest.param("gpt-6-astra", _FOUNDRY_API_BASE, "low", id="gpt-6-explicit-effort"), + pytest.param("gpt-6-astra", "https://myresource.openai.azure.com", None, id="gpt-6-azure-openai-host"), + pytest.param("gpt-5.6-sol", _FOUNDRY_API_BASE, "low", id="gpt-5.6-explicit-effort"), + pytest.param("gpt-5.6-sol", _FOUNDRY_API_BASE, {"effort": "high"}, id="gpt-5.6-explicit-effort-dict"), + ], +) +def test_responses_api_bridge_check_azure_ai_foundry_rejected_tools_route_to_responses( + model_name, api_base, reasoning_effort +): + from litellm.main import responses_api_bridge_check + + model_info, model = responses_api_bridge_check( + model=model_name, + custom_llm_provider="azure_ai", + tools=_FOUNDRY_FUNCTION_TOOL, + reasoning_effort=reasoning_effort, + api_base=api_base, + ) + + assert model == model_name + assert model_info.get("mode") == "responses" + + +@pytest.mark.parametrize( + "model_name, api_base, reasoning_effort", + [ + pytest.param("gpt-6-astra", _FOUNDRY_API_BASE, "none", id="explicit-none-stays-chat"), + pytest.param("gpt-5.6-sol", _FOUNDRY_API_BASE, None, id="gpt-5.6-unset-effort-stays-chat"), + pytest.param("gpt-5.6-sol", _FOUNDRY_API_BASE, "none", id="gpt-5.6-explicit-none-stays-chat"), + pytest.param("gpt-5.5", _FOUNDRY_API_BASE, "high", id="gpt-5.5-explicit-effort-stays-chat"), + pytest.param("gpt-5.4-mini", _FOUNDRY_API_BASE, None, id="gpt-5.4-mini-unset-effort-stays-chat"), + pytest.param("gpt-5.4-mini", _FOUNDRY_API_BASE, "low", id="gpt-5.4-mini-explicit-effort-stays-chat"), + pytest.param("gpt-6-astra", "https://myproject.models.ai.azure.com", None, id="serverless-host-stays-chat"), + pytest.param("Mistral-large-2411", _FOUNDRY_API_BASE, None, id="non-gpt-5-model-stays-chat"), + pytest.param("claude-opus-4-1", _FOUNDRY_API_BASE, None, id="claude-on-foundry-stays-chat"), + ], +) +def test_responses_api_bridge_check_azure_ai_without_foundry_responses_route_stays_chat( + model_name, api_base, reasoning_effort +): + from litellm.main import responses_api_bridge_check + + model_info, model = responses_api_bridge_check( + model=model_name, + custom_llm_provider="azure_ai", + tools=_FOUNDRY_FUNCTION_TOOL, + reasoning_effort=reasoning_effort, + api_base=api_base, + ) + + assert model == model_name + assert model_info.get("mode") != "responses" + + def test_responses_api_bridge_check_older_gpt_5_tools_without_reasoning_stays_chat(): """Pre-5.4 GPT-5 names keep the old boundary: tools alone never bridge.""" from litellm.main import responses_api_bridge_check @@ -1488,6 +1579,81 @@ def test_responses_bridge_preserves_reasoning_effort_with_drop_params( assert request_body["reasoning"] == {"effort": "high"} +_FOUNDRY_RESPONSES_FUNCTION_CALL_BODY: Final = { + "id": "resp_foundry", + "object": "response", + "created_at": 1789852145, + "status": "completed", + "model": "gpt-6-astra", + "output": [ + { + "id": "fc_1", + "type": "function_call", + "status": "completed", + "arguments": '{"city":"Paris"}', + "call_id": "call_1", + "name": "get_weather", + } + ], + "parallel_tool_calls": True, + "usage": { + "input_tokens": 53, + "output_tokens": 18, + "total_tokens": 71, + "output_tokens_details": {"reasoning_tokens": 0}, + }, + "error": None, + "incomplete_details": None, + "instructions": None, + "metadata": {}, + "temperature": 1.0, + "tool_choice": "auto", + "tools": [], + "top_p": 1.0, + "max_output_tokens": 200, + "previous_response_id": None, + "reasoning": {"effort": "medium", "summary": None}, + "truncation": "disabled", + "user": None, +} + + +def test_completion_bridges_azure_ai_foundry_gpt_5_4_plus_function_tools_to_responses( + respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch +): + monkeypatch.setattr(litellm, "disable_aiohttp_transport", True) + responses_route: Final = respx_mock.post(f"{_FOUNDRY_API_BASE}/openai/v1/responses").respond( + json=_FOUNDRY_RESPONSES_FUNCTION_CALL_BODY + ) + + response: Final = litellm.completion( + model="azure_ai/gpt-6-astra", + messages=[{"role": "user", "content": "What is the weather in Paris? Use the tool."}], + tools=[ + { + "type": "function", + "function": { + "name": "get_weather", + "description": "Get weather for a city", + "parameters": {"type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"]}, + }, + } + ], + max_tokens=200, + api_base=_FOUNDRY_API_BASE, + api_key="fake-foundry-key", + ) + + assert [str(call.request.url) for call in respx_mock.calls] == [f"{_FOUNDRY_API_BASE}/openai/v1/responses"] + request: Final = responses_route.calls[0].request + request_body: Final = json.loads(request.content) + assert request_body["tools"][0]["type"] == "function" + assert request_body["tools"][0]["name"] == "get_weather" + assert request.headers["api-key"] == "fake-foundry-key" + assert response.choices[0].finish_reason == "tool_calls" + assert response.choices[0].message.tool_calls[0].function.name == "get_weather" + + @pytest.mark.parametrize( "model, model_info, expected_model_param, expected_base_model_param", [