diff --git a/litellm/litellm_core_utils/prompt_templates/common_utils.py b/litellm/litellm_core_utils/prompt_templates/common_utils.py index 0572ca66fc9..2e6f865b575 100644 --- a/litellm/litellm_core_utils/prompt_templates/common_utils.py +++ b/litellm/litellm_core_utils/prompt_templates/common_utils.py @@ -2,10 +2,14 @@ Common utility functions used for translating messages across providers """ +import importlib import json +import sys +import warnings from collections.abc import Hashable, Iterable, Mapping, Sequence from dataclasses import dataclass from itertools import chain, groupby +from types import MappingProxyType, ModuleType from typing import TYPE_CHECKING, Final, Literal, TypeAlias, cast from pydantic import TypeAdapter @@ -222,6 +226,108 @@ _extract_reasoning_content: Final = extract_reasoning_content _get_image_mime_type_from_url: Final = get_image_mime_type_from_url _parse_content_for_reasoning: Final = parse_content_for_reasoning +_MOVED_PRIVATE_NAME_MODULES: Final[Mapping[str, str]] = MappingProxyType( + { + **dict.fromkeys( + ("_decode_vector_store_ids_in_tools",), + "file_id_utils", + ), + **dict.fromkeys( + ( + "_EMPTY_SCHEMA", + "_LOCAL_SCHEMA_REF_PREFIXES", + "_MAX_SCHEMA_FLATTEN_DEPTH", + "_MAX_SCHEMA_NESTING", + "_OPENAI_REJECTED_TOP_LEVEL_SCHEMA_KEYS", + "_REGEX_LOOKAROUND_RE", + "_SCHEMA_LIST", + "_SUBSCHEMA_KEYWORDS", + "_SUBSCHEMA_LIST_KEYWORDS", + "_SUBSCHEMA_MAP_KEYWORDS", + "_TOP_LEVEL_SCHEMA_COMBINATORS", + "_any_of", + "_combinator_required_names", + "_dropped_pattern_properties", + "_flatten_schema_against_root", + "_grammar_branches", + "_is_not_python_regex", + "_is_object_schema", + "_is_open_object", + "_is_union", + "_keyword_value_rebuilt", + "_list_at", + "_may_be_string", + "_mergeable_branch", + "_named_schemas", + "_node_without_rejected_regex", + "_optional_property_names", + "_property_schemas", + "_refs_in", + "_resolve_local_schema_ref", + "_root_definitions", + "_schema_at", + "_schema_branches", + "_schema_levels", + "_schema_nodes", + "_schema_properties", + "_schema_required_names", + "_schema_without_rejected_regex", + "_subschemas", + "_uses_regex_lookaround", + ), + "json_schema_utils", + ), + **dict.fromkeys( + ( + "_BREAKPOINT_BLOCKS", + "_BREAKPOINT_BLOCK_LIST_KEYS", + "_BREAKPOINT_FIELDS", + "_MarkedT", + "_PROMPT_CACHE_BREAKPOINT", + "_breakpoints_stripped_from", + "_without_prompt_cache_breakpoint", + ), + "prompt_cache_breakpoint_utils", + ), + **dict.fromkeys( + ( + "_carries_encrypted_reasoning", + "_converse_wrapped_payload", + "_encrypted_reasoning_field", + "_provider_text_or_none", + "_readable_thinking_text", + "_reasoning_item_from_block_group", + "_reasoning_replay_group_key", + "_strip_encrypted_reasoning_from_blocks", + ), + "reasoning_utils", + ), + **dict.fromkeys( + ( + "_DECODED_JSON", + "_attempt_json_repair", + ), + "tool_call_json_repair", + ), + **dict.fromkeys( + ( + "_TPMMediaProjection", + "_tpm_blank_fields", + "_tpm_code_output_media", + "_tpm_document_media", + "_tpm_file_media", + "_tpm_image_part", + "_tpm_input_media", + "_tpm_media_leaf", + "_tpm_media_list", + "_tpm_replace_field", + "_tpm_tool_result_media", + ), + "tpm_media_utils", + ), + } +) + if TYPE_CHECKING: from litellm.litellm_core_utils.litellm_logging import Logging as LoggingClass @@ -1578,3 +1684,72 @@ def invalid_type_message(field_path: str, value: object) -> str: def is_unhashable_value_behind(error: TypeError, value: object) -> bool: return not isinstance(value, Hashable) and f"unhashable type: '{type(value).__name__}'" in str(error) + + +_UNBOUND: Final = object() + + +def _module(module_name: str) -> ModuleType: + return importlib.import_module(f"litellm.litellm_core_utils.prompt_templates.{module_name}") + + +def _namespace(module: ModuleType) -> Mapping[str, object]: + return vars(module) + + +def _moved_name_home(name: str) -> ModuleType: + module_name: Final = _MOVED_PRIVATE_NAME_MODULES.get(name) + if module_name is None: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + return _module(module_name) + + +def _split_modules_sharing(name: str, value: object) -> tuple[ModuleType, ...]: + if value is _UNBOUND: + return () + split_modules: Final = (_module(module_name) for module_name in frozenset(_MOVED_PRIVATE_NAME_MODULES.values())) + return tuple(module for module in split_modules if _namespace(module).get(name, _UNBOUND) is value) + + +def _warn_old_path(name: str, home: ModuleType) -> None: + warnings.warn( + f"{__name__}.{name} moved to {home.__name__}, import or patch it there. " + "The old path will be removed in a future release", + DeprecationWarning, + stacklevel=3, + ) + + +_RELEASED_MOVED_VALUES: Final = MappingProxyType( + {name: _namespace(_moved_name_home(name))[name] for name in _MOVED_PRIVATE_NAME_MODULES} +) + + +class _CommonUtilsModule(ModuleType): + def __getattr__(self, name: str) -> object: + home: Final = _moved_name_home(name) + value: Final = _namespace(home).get(name, _UNBOUND) + if value is _UNBOUND: + raise AttributeError(f"module {home.__name__!r} has no attribute {name!r}") + _warn_old_path(name, home) + return value + + def __setattr__(self, name: str, value: object) -> None: + if name in _MOVED_PRIVATE_NAME_MODULES: + home: Final = _moved_name_home(name) + _warn_old_path(name, home) + setattr(home, name, value) + return + for module in _split_modules_sharing(name, _namespace(self).get(name, _UNBOUND)): + setattr(module, name, value) + super().__setattr__(name, value) + + def __delattr__(self, name: str) -> None: + released: Final = _RELEASED_MOVED_VALUES.get(name, _UNBOUND) + if released is _UNBOUND: + super().__delattr__(name) + return + setattr(_moved_name_home(name), name, released) + + +sys.modules[__name__].__class__ = _CommonUtilsModule diff --git a/tests/unit/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_common_utils.py b/tests/unit/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_common_utils.py index 9d3c19b7460..777bef7afcc 100644 --- a/tests/unit/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_common_utils.py +++ b/tests/unit/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_common_utils.py @@ -1,10 +1,15 @@ +import contextlib import copy +import importlib import json +import warnings from collections.abc import Mapping from typing import Final, cast +from unittest import mock import pytest +from litellm.litellm_core_utils.prompt_templates import common_utils from litellm.litellm_core_utils.prompt_templates.common_utils import ( TOOL_RESULT_IMAGE_BOUNDARY, TOOL_RESULT_IMAGE_PLACEHOLDER, @@ -21,6 +26,10 @@ from litellm.litellm_core_utils.prompt_templates.common_utils import ( strip_litellm_response_only_fields, system_messages_first, ) +from litellm.litellm_core_utils.prompt_templates.json_schema_utils import ( + flatten_combinators_and_drop_non_python_regex_patterns, +) +from litellm.litellm_core_utils.prompt_templates.tool_call_json_repair import parse_tool_call_arguments from litellm.types.llms.openai import ChatCompletionAudioObject, ChatCompletionFileObject, ChatCompletionImageObject from litellm.types.utils import ChatCompletionMessageToolCall, Choices, Function, Message, ModelResponse @@ -987,6 +996,180 @@ def test_content_list_flattening_returns_new_messages_and_leaves_the_callers_unt assert messages == messages_before +_MOVED_PRIVATE_NAMES_BY_MODULE: Final = ( + ( + "file_id_utils", + ("_decode_vector_store_ids_in_tools",), + ), + ( + "json_schema_utils", + ( + "_EMPTY_SCHEMA", + "_LOCAL_SCHEMA_REF_PREFIXES", + "_MAX_SCHEMA_FLATTEN_DEPTH", + "_MAX_SCHEMA_NESTING", + "_OPENAI_REJECTED_TOP_LEVEL_SCHEMA_KEYS", + "_REGEX_LOOKAROUND_RE", + "_SCHEMA_LIST", + "_SUBSCHEMA_KEYWORDS", + "_SUBSCHEMA_LIST_KEYWORDS", + "_SUBSCHEMA_MAP_KEYWORDS", + "_TOP_LEVEL_SCHEMA_COMBINATORS", + "_any_of", + "_combinator_required_names", + "_dropped_pattern_properties", + "_flatten_schema_against_root", + "_grammar_branches", + "_is_not_python_regex", + "_is_object_schema", + "_is_open_object", + "_is_union", + "_keyword_value_rebuilt", + "_list_at", + "_may_be_string", + "_mergeable_branch", + "_named_schemas", + "_node_without_rejected_regex", + "_optional_property_names", + "_property_schemas", + "_refs_in", + "_resolve_local_schema_ref", + "_root_definitions", + "_schema_at", + "_schema_branches", + "_schema_levels", + "_schema_nodes", + "_schema_properties", + "_schema_required_names", + "_schema_without_rejected_regex", + "_subschemas", + "_uses_regex_lookaround", + ), + ), + ( + "prompt_cache_breakpoint_utils", + ( + "_BREAKPOINT_BLOCKS", + "_BREAKPOINT_BLOCK_LIST_KEYS", + "_BREAKPOINT_FIELDS", + "_MarkedT", + "_PROMPT_CACHE_BREAKPOINT", + "_breakpoints_stripped_from", + "_without_prompt_cache_breakpoint", + ), + ), + ( + "reasoning_utils", + ( + "_carries_encrypted_reasoning", + "_converse_wrapped_payload", + "_encrypted_reasoning_field", + "_provider_text_or_none", + "_readable_thinking_text", + "_reasoning_item_from_block_group", + "_reasoning_replay_group_key", + "_strip_encrypted_reasoning_from_blocks", + ), + ), + ( + "tool_call_json_repair", + ( + "_DECODED_JSON", + "_attempt_json_repair", + ), + ), + ( + "tpm_media_utils", + ( + "_TPMMediaProjection", + "_tpm_blank_fields", + "_tpm_code_output_media", + "_tpm_document_media", + "_tpm_file_media", + "_tpm_image_part", + "_tpm_input_media", + "_tpm_media_leaf", + "_tpm_media_list", + "_tpm_replace_field", + "_tpm_tool_result_media", + ), + ), +) + + +@pytest.mark.parametrize(("module_name", "names"), _MOVED_PRIVATE_NAMES_BY_MODULE) +def test_moved_private_helpers_still_import_from_common_utils(module_name: str, names: tuple[str, ...]) -> None: + new_home: Final = importlib.import_module(f"litellm.litellm_core_utils.prompt_templates.{module_name}") + unreachable: Final = [name for name in names if getattr(common_utils, name, None) is not getattr(new_home, name)] + assert unreachable == [] + + +def test_unknown_private_name_still_raises_attribute_error_on_common_utils() -> None: + missing_name: Final = "_not_a_moved_helper" + with pytest.raises(AttributeError, match=missing_name): + getattr(common_utils, missing_name) + + +def test_old_path_access_warns_from_the_callers_line_with_the_new_home() -> None: + moved_name: Final = "_any_of" + with pytest.warns(DeprecationWarning, match=r"common_utils\._any_of moved to .*\.json_schema_utils") as caught: + getattr(common_utils, moved_name) + assert [warning.filename for warning in caught] == [__file__] + + +def test_patching_a_moved_helper_at_its_old_path_reaches_the_callers_in_its_new_home() -> None: + patched_result: Final = {"patched": True} + with mock.patch( + "litellm.litellm_core_utils.prompt_templates.common_utils._attempt_json_repair", return_value=patched_result + ): + assert parse_tool_call_arguments('{"a": 1') == patched_result + assert parse_tool_call_arguments('{"a": 1') == {"a": 1} + + +def test_patching_a_re_exported_helper_at_its_old_path_reaches_the_callers_in_its_new_home() -> None: + schema: Final = {"anyOf": [{"type": "object", "properties": {"a": {"type": "string"}}}]} + unpatched: Final = flatten_combinators_and_drop_non_python_regex_patterns(schema) + patched_result: Final = {"patched": True} + with mock.patch( + "litellm.litellm_core_utils.prompt_templates.common_utils.flatten_top_level_schema_combinators", + return_value=patched_result, + ): + assert flatten_combinators_and_drop_non_python_regex_patterns(schema) == patched_result + assert flatten_combinators_and_drop_non_python_regex_patterns(schema) == unpatched + + +def test_old_path_patch_restores_the_moved_helper_when_warnings_become_errors_inside_it() -> None: + with warnings.catch_warnings(), contextlib.suppress(DeprecationWarning): + with mock.patch( + "litellm.litellm_core_utils.prompt_templates.common_utils._attempt_json_repair", return_value=None + ): + warnings.simplefilter("error", DeprecationWarning) + assert parse_tool_call_arguments('{"a": 1') == {"a": 1} + + +def test_old_path_patch_with_create_leaves_the_moved_helper_in_place() -> None: + with mock.patch( + "litellm.litellm_core_utils.prompt_templates.common_utils._attempt_json_repair", return_value=None, create=True + ): + assert parse_tool_call_arguments("{}") == {} + assert parse_tool_call_arguments('{"a": 1') == {"a": 1} + + +def test_overriding_a_moved_helper_through_the_old_path_warns_and_deleting_it_restores_the_released_one() -> None: + moved_name: Final = "_attempt_json_repair" + with pytest.warns(DeprecationWarning, match=r"common_utils\._attempt_json_repair moved to .*\.tool_call_json_repair"): + setattr(common_utils, moved_name, mock.Mock(return_value={"overridden": True})) + assert parse_tool_call_arguments('{"a": 1') == {"overridden": True} + delattr(common_utils, moved_name) + assert parse_tool_call_arguments('{"a": 1') == {"a": 1} + + +def test_patching_a_name_common_utils_still_defines_stays_on_common_utils() -> None: + with mock.patch.object(common_utils, "invalid_type_message", return_value="patched"): + assert common_utils.invalid_type_message("field", 1) == "patched" + assert common_utils.invalid_type_message("field", 1) != "patched" + + def test_get_content_from_model_response_includes_server_tool_calls_before_client_calls() -> None: server_call: Final = ChatCompletionMessageToolCall( id="srvtoolu_01", type="function", function=Function(name="web_search", arguments='{"query": "persimmon"}') diff --git a/tests/unit/litellm_core_utils/prompt_templates/test_file_id_utils.py b/tests/unit/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_file_id_utils.py similarity index 100% rename from tests/unit/litellm_core_utils/prompt_templates/test_file_id_utils.py rename to tests/unit/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_file_id_utils.py diff --git a/tests/unit/litellm_core_utils/prompt_templates/test_json_schema_utils.py b/tests/unit/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_json_schema_utils.py similarity index 100% rename from tests/unit/litellm_core_utils/prompt_templates/test_json_schema_utils.py rename to tests/unit/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_json_schema_utils.py diff --git a/tests/unit/litellm_core_utils/prompt_templates/test_prompt_cache_breakpoint_utils.py b/tests/unit/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_prompt_cache_breakpoint_utils.py similarity index 100% rename from tests/unit/litellm_core_utils/prompt_templates/test_prompt_cache_breakpoint_utils.py rename to tests/unit/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_prompt_cache_breakpoint_utils.py diff --git a/tests/unit/litellm_core_utils/prompt_templates/test_reasoning_utils.py b/tests/unit/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_reasoning_utils.py similarity index 100% rename from tests/unit/litellm_core_utils/prompt_templates/test_reasoning_utils.py rename to tests/unit/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_reasoning_utils.py diff --git a/tests/unit/litellm_core_utils/prompt_templates/test_tool_call_json_repair.py b/tests/unit/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_tool_call_json_repair.py similarity index 100% rename from tests/unit/litellm_core_utils/prompt_templates/test_tool_call_json_repair.py rename to tests/unit/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_tool_call_json_repair.py diff --git a/tests/unit/litellm_core_utils/prompt_templates/test_tpm_media_utils.py b/tests/unit/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_tpm_media_utils.py similarity index 100% rename from tests/unit/litellm_core_utils/prompt_templates/test_tpm_media_utils.py rename to tests/unit/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_tpm_media_utils.py diff --git a/tests/unit/llms/test_file_content_block.py b/tests/unit/llms/test_file_content_block.py index 297b3b6f7d6..6e605eee033 100644 --- a/tests/unit/llms/test_file_content_block.py +++ b/tests/unit/llms/test_file_content_block.py @@ -6,7 +6,9 @@ Regression tests for: - litellm/llms/vertex_ai/gemini/transformation.py - litellm/llms/gemini/chat/transformation.py - litellm/litellm_core_utils/prompt_templates/common_utils.py - (migrate_file_to_image_url raises on missing `file`; file-id helpers skip non-OpenAI shapes) + (migrate_file_to_image_url raises on missing `file`) +- litellm/litellm_core_utils/prompt_templates/file_id_utils.py + (file-id helpers skip non-OpenAI shapes) - litellm/litellm_core_utils/prompt_templates/factory.py (Bedrock + Anthropic) - litellm/llms/openai/chat/gpt_transformation.py """