From e7d432a32851a87cab06bf70588e906d9c3158ea Mon Sep 17 00:00:00 2001 From: Joshua Valluru <326636767+joshua-berri@users.noreply.github.com> Date: Tue, 20 Oct 2026 17:41:23 -0700 Subject: [PATCH] fix(bedrock): adapt native output backports to release interfaces --- .../prompt_templates/common_utils.py | 2 + litellm/llms/anthropic/chat/transformation.py | 5 + .../bedrock/chat/converse_transformation.py | 1 + .../anthropic_claude3_transformation.py | 13 +- litellm/llms/bedrock/common_utils.py | 6 + .../anthropic_claude3_transformation.py | 2 +- litellm/llms/custom_httpx/llm_http_handler.py | 9 +- ...vertex_anthropic_structured_output_wire.py | 2 +- ...edrock_native_output_register_model_sdk.py | 4 +- tests/unit/conftest.py | 4 +- ...ore_utils_prompt_templates_common_utils.py | 64 +- ...llm_core_utils_prompt_templates_factory.py | 11 +- .../test_anthropic_chat_transformation.py | 38 +- .../anthropic/test_anthropic_schema_filter.py | 1 + ...ations_anthropic_claude3_transformation.py | 9 +- .../chat/test_converse_transformation.py | 584 ++++++------------ .../test_bedrock_files_transformation.py | 397 ++++-------- .../test_anthropic_claude3_transformation.py | 4 + .../llms/bedrock/test_bedrock_common_utils.py | 123 +--- tests/unit/test_utils.py | 33 +- tests/unit/test_utils_get_model_info.py | 70 +-- tests/unit/types/test_completion.py | 6 +- 22 files changed, 530 insertions(+), 858 deletions(-) diff --git a/litellm/litellm_core_utils/prompt_templates/common_utils.py b/litellm/litellm_core_utils/prompt_templates/common_utils.py index b217c46a28f..4dac95faaa4 100644 --- a/litellm/litellm_core_utils/prompt_templates/common_utils.py +++ b/litellm/litellm_core_utils/prompt_templates/common_utils.py @@ -1,3 +1,5 @@ +from pydantic import TypeAdapter + """ Common utility functions used for translating messages across providers """ diff --git a/litellm/llms/anthropic/chat/transformation.py b/litellm/llms/anthropic/chat/transformation.py index 6c26a2797ce..1a405fe5d31 100644 --- a/litellm/llms/anthropic/chat/transformation.py +++ b/litellm/llms/anthropic/chat/transformation.py @@ -1382,6 +1382,11 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): return None return AnthropicConfig.filter_anthropic_output_schema(dict(json_schema)) + def uses_native_structured_output(self, model: str) -> bool: + return AnthropicConfig._supports_model_capability( + model, "supports_native_structured_output", self._resolved_provider + ) + def uses_native_structured_output_for_schema( self, model: str, diff --git a/litellm/llms/bedrock/chat/converse_transformation.py b/litellm/llms/bedrock/chat/converse_transformation.py index 11152f89939..ea58772e775 100644 --- a/litellm/llms/bedrock/chat/converse_transformation.py +++ b/litellm/llms/bedrock/chat/converse_transformation.py @@ -52,6 +52,7 @@ from litellm.llms.anthropic.chat.transformation import ( from litellm.llms.anthropic.common_utils import AnthropicModelInfo from litellm.llms.base_llm.chat.transformation import REQUEST_PARAMS, BaseConfig, BaseLLMException from litellm.llms.bedrock.common_utils import ( + bedrock_capability_model, bedrock_chat_uses_native_structured_output, bedrock_cleaned_output_schema, bedrock_native_output_format, diff --git a/litellm/llms/bedrock/chat/invoke_transformations/anthropic_claude3_transformation.py b/litellm/llms/bedrock/chat/invoke_transformations/anthropic_claude3_transformation.py index d657442ae8e..635e11264f3 100644 --- a/litellm/llms/bedrock/chat/invoke_transformations/anthropic_claude3_transformation.py +++ b/litellm/llms/bedrock/chat/invoke_transformations/anthropic_claude3_transformation.py @@ -1,6 +1,8 @@ +from collections.abc import Mapping from typing import TYPE_CHECKING, Any, Final import httpx +from pydantic import TypeAdapter from litellm.anthropic_beta_headers_manager import filter_and_transform_beta_headers from litellm.constants import RESPONSE_FORMAT_TOOL_NAME @@ -107,18 +109,7 @@ class AmazonAnthropicClaudeConfig(AmazonInvokeConfig, AnthropicConfig): model: str, drop_params: bool, ) -> dict: - # Force tool-based structured outputs for Bedrock Invoke - # (similar to VertexAI fix in #19201) unless the model map advertises - # native structured output - from litellm.utils import supports_native_structured_output - original_model: Final = model - if "response_format" in non_default_params and not supports_native_structured_output( - model=model, custom_llm_provider="bedrock" - ): - # Use a model name that forces tool-based approach - model = "claude-3-sonnet-20240229" - # Clamp ``reasoning_effort`` to the Bedrock effort ceiling before the # parent mapping converts it to ``output_config.effort`` and the # downstream effort gate runs. Mirrors the converse path's diff --git a/litellm/llms/bedrock/common_utils.py b/litellm/llms/bedrock/common_utils.py index b7d7749e52a..54a4730d080 100644 --- a/litellm/llms/bedrock/common_utils.py +++ b/litellm/llms/bedrock/common_utils.py @@ -954,6 +954,12 @@ def is_bedrock_application_inference_profile_arn(model: str) -> bool: return ":application-inference-profile/" in model +def bedrock_capability_model(model: str, base_model: object) -> str: + if isinstance(base_model, str) and base_model and is_bedrock_application_inference_profile_arn(model): + return strip_bedrock_routing_prefix(base_model) + return model + + def strip_bedrock_routing_prefix(model: str) -> str: """Strip LiteLLM routing prefixes from model name.""" for prefix in ["bedrock/", "converse/", "invoke/", "openai/", "mantle/", "nova-2/", "nova/"]: diff --git a/litellm/llms/bedrock/messages/invoke_transformations/anthropic_claude3_transformation.py b/litellm/llms/bedrock/messages/invoke_transformations/anthropic_claude3_transformation.py index cc7745045f5..f955f1380e0 100644 --- a/litellm/llms/bedrock/messages/invoke_transformations/anthropic_claude3_transformation.py +++ b/litellm/llms/bedrock/messages/invoke_transformations/anthropic_claude3_transformation.py @@ -35,8 +35,8 @@ from litellm.llms.bedrock.common_utils import ( bedrock_supports_tool_search, ensure_bedrock_anthropic_messages_tool_names, get_anthropic_beta_from_headers, - is_claude_4_5_on_bedrock, is_bedrock_native_output_rejection, + is_claude_4_5_on_bedrock, normalize_bedrock_opus_output_config_effort, normalize_custom_field_on_tools, normalize_tool_input_schema_types_for_bedrock_invoke, diff --git a/litellm/llms/custom_httpx/llm_http_handler.py b/litellm/llms/custom_httpx/llm_http_handler.py index 2f0e4fca6a4..2df55a2c5ed 100644 --- a/litellm/llms/custom_httpx/llm_http_handler.py +++ b/litellm/llms/custom_httpx/llm_http_handler.py @@ -26,6 +26,7 @@ import httpx from httpx import USE_CLIENT_DEFAULT from httpx._types import FileContent from openai.types.file_deleted import FileDeleted +from pydantic import TypeAdapter from typing_extensions import ReadOnly import litellm @@ -2185,7 +2186,9 @@ class BaseLLMHTTPHandler: native_output_fallback_params: Final = anthropic_messages_provider_config.native_output_fallback_litellm_params( model=model, - anthropic_messages_optional_request_params=_OPTIONAL_PARAMS.validate_python(anthropic_messages_optional_request_params), + anthropic_messages_optional_request_params=_OPTIONAL_PARAMS.validate_python( + anthropic_messages_optional_request_params + ), litellm_params=litellm_params, ) rebuild_without_native_output: Final = ( @@ -2195,7 +2198,9 @@ class BaseLLMHTTPHandler: anthropic_messages_provider_config.transform_anthropic_messages_request, model=model, messages=copy.deepcopy(_JSON_OBJECTS.validate_python(messages)), - anthropic_messages_optional_request_params=copy.deepcopy(_JSON_OBJECT.validate_python(anthropic_messages_optional_request_params)), + anthropic_messages_optional_request_params=copy.deepcopy( + _JSON_OBJECT.validate_python(anthropic_messages_optional_request_params) + ), litellm_params=native_output_fallback_params, headers=_JSON_OBJECT.validate_python(headers), ) diff --git a/tests/integration/providers/test_vertex_anthropic_structured_output_wire.py b/tests/integration/providers/test_vertex_anthropic_structured_output_wire.py index bba79049283..9c472e4bfc3 100644 --- a/tests/integration/providers/test_vertex_anthropic_structured_output_wire.py +++ b/tests/integration/providers/test_vertex_anthropic_structured_output_wire.py @@ -24,11 +24,11 @@ import pytest import yaml from integration._support.client import Gateway, Scenario, eventually, list_value, object_value, string_value from integration._support.database import read_rows +from integration._support.ports import allocate_port from integration._support.process import OwnedProxy, graceful_stop_seconds, owned_proxy_process from integration._support.upstream import aws_event_stream_frame from integration._support.vertex import service_account_json from integration._support.wire import Reply, Request, Wire, wire_server -from integration._support.ports import allocate_port from pydantic import JsonValue, TypeAdapter _JSON_TOOL: Final = "json_tool_call" diff --git a/tests/integration/sdk/test_bedrock_native_output_register_model_sdk.py b/tests/integration/sdk/test_bedrock_native_output_register_model_sdk.py index 1c419e5b11c..df0390bf9ec 100644 --- a/tests/integration/sdk/test_bedrock_native_output_register_model_sdk.py +++ b/tests/integration/sdk/test_bedrock_native_output_register_model_sdk.py @@ -10,7 +10,9 @@ from pydantic import JsonValue, TypeAdapter import litellm from litellm import ModelResponse, Router -from litellm.utils import _invalidate_model_cost_lowercase_map # pyright: ignore[reportPrivateUsage] # required cache invalidation +from litellm.utils import ( + _invalidate_model_cost_lowercase_map, # pyright: ignore[reportPrivateUsage] # required cache invalidation +) _OPUS_4_8: Final = "us.anthropic.claude-opus-4-8" _JSON_TOOL: Final = "json_tool_call" diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index 73491fbf035..b2f15299662 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -239,7 +239,9 @@ def isolate_litellm_globals() -> Iterator[None]: original_callbacks: Final = {name: list(getattr(litellm, name) or []) for name in CALLBACK_LISTS} original_reset: Final = {name: getattr(litellm, name) for name in RESET_TO_NONE_GLOBALS} original_restored: Final = {name: getattr(litellm, name) for name in RESTORED_GLOBALS if hasattr(litellm, name)} - original_clients: Final = {name: litellm.__dict__[name] for name in MODULE_LEVEL_CLIENTS if name in litellm.__dict__} + original_clients: Final = { + name: litellm.__dict__[name] for name in MODULE_LEVEL_CLIENTS if name in litellm.__dict__ + } original_loggers: Final = { logger: (logger.level, logger.disabled, logger.propagate, list(logger.handlers), list(logger.filters)) for logger in ALL_LOGGERS 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 ce005fc9464..3c27186c065 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 @@ -1988,23 +1988,66 @@ def _object_with(properties: Mapping[str, object], **defs: Mapping[str, object]) ("schema", "unresolvable"), [ (_object_with({"a": {"type": "string"}}), False), - (_object_with({"a": {"$ref": "#/$defs/A"}, "b": {"$ref": "#/$defs/A"}}, **{"$defs": {"A": {"type": "string"}}}), False), - (_object_with({"a": {"$ref": "#/definitions/A"}}, definitions={"A": {"$ref": "#/definitions/B"}, "B": {"type": "integer"}}), False), - (_object_with({"a": {"$ref": "/$defs/A"}}, **{"$defs": {"A": {"items": {"$ref": "/$defs/B"}}, "B": {"type": "string"}}}), False), + ( + _object_with( + {"a": {"$ref": "#/$defs/A"}, "b": {"$ref": "#/$defs/A"}}, **{"$defs": {"A": {"type": "string"}}} + ), + False, + ), + ( + _object_with( + {"a": {"$ref": "#/definitions/A"}}, + definitions={"A": {"$ref": "#/definitions/B"}, "B": {"type": "integer"}}, + ), + False, + ), + ( + _object_with( + {"a": {"$ref": "/$defs/A"}}, + **{"$defs": {"A": {"items": {"$ref": "/$defs/B"}}, "B": {"type": "string"}}}, + ), + False, + ), (_object_with({"a": {"$ref": "#/$defs/Missing"}}, **{"$defs": {"A": {"type": "string"}}}), True), (_object_with({"a": {"$ref": "other.json#/A"}}), True), (_object_with({"parent": {"$ref": "#"}}), True), - (_object_with({"a": {"$ref": "#/$defs/A"}}, **{"$defs": {"A": {"type": "array", "items": {"$ref": "#/$defs/A"}}}}), True), - (_object_with({"a": {"$ref": "/$defs/A"}}, **{"$defs": {"A": {"type": "array", "items": {"$ref": "/$defs/A"}}}}), True), + ( + _object_with( + {"a": {"$ref": "#/$defs/A"}}, **{"$defs": {"A": {"type": "array", "items": {"$ref": "#/$defs/A"}}}} + ), + True, + ), + ( + _object_with( + {"a": {"$ref": "/$defs/A"}}, **{"$defs": {"A": {"type": "array", "items": {"$ref": "/$defs/A"}}}} + ), + True, + ), ( _object_with( {"a": {"$ref": "#/$defs/A"}}, - **{"$defs": {"A": {"properties": {"b": {"$ref": "#/$defs/B"}}}, "B": {"properties": {"a": {"$ref": "#/$defs/A"}}}}}, + **{ + "$defs": { + "A": {"properties": {"b": {"$ref": "#/$defs/B"}}}, + "B": {"properties": {"a": {"$ref": "#/$defs/A"}}}, + } + }, ), True, ), ], - ids=["no-refs", "shared-def", "chained-definitions", "root-path-defs", "missing-def", "external", "root", "self-cycle", "root-path-self-cycle", "mutual-cycle"], + ids=[ + "no-refs", + "shared-def", + "chained-definitions", + "root-path-defs", + "missing-def", + "external", + "root", + "self-cycle", + "root-path-self-cycle", + "mutual-cycle", + ], ) def test_schema_has_unresolvable_ref_flags_refs_that_cannot_be_inlined(schema, unresolvable): assert schema_has_unresolvable_ref(schema) is unresolvable @@ -2013,7 +2056,11 @@ def test_schema_has_unresolvable_ref_flags_refs_that_cannot_be_inlined(schema, u @pytest.mark.parametrize( ("schema", "properties", "branches"), [ - (_object_with({"a": {"type": "string"}, "b": {"type": "string"}, "c": {"type": "string"}}, required=["a"]), 3, 8), + ( + _object_with({"a": {"type": "string"}, "b": {"type": "string"}, "c": {"type": "string"}}, required=["a"]), + 3, + 8, + ), ( _object_with( {"x": {"$ref": "#/$defs/Pair"}, "y": {"$ref": "#/$defs/Pair"}}, @@ -2030,4 +2077,3 @@ def test_schema_has_unresolvable_ref_flags_refs_that_cannot_be_inlined(schema, u def test_schema_size_counts_every_written_object_once(schema, properties, branches): assert schema_property_count(schema) == properties assert schema_grammar_branch_count(schema) == branches - diff --git a/tests/unit/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_factory.py b/tests/unit/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_factory.py index f84086138dc..6d4d6ad4113 100644 --- a/tests/unit/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_factory.py +++ b/tests/unit/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_factory.py @@ -8,15 +8,19 @@ from unittest.mock import MagicMock, patch import pytest import litellm +from litellm.litellm_core_utils.logging_worker import GLOBAL_LOGGING_WORKER +from litellm.litellm_core_utils.prompt_templates.common_utils import get_completion_messages from litellm.litellm_core_utils.prompt_templates.factory import ( BEDROCK_DOCUMENT_PLACEHOLDER_TEXT, + THOUGHT_SIGNATURE_SEPARATOR, BedrockConverseMessagesProcessor, BedrockImageProcessor, _bedrock_converse_messages_pt, _bedrock_tools_pt, - _rename_duplicate_bedrock_document_names, _convert_to_bedrock_tool_call_invoke, _convert_to_bedrock_tool_call_result, + _rename_duplicate_bedrock_document_names, + _sanitize_anthropic_tool_use_id, anthropic_messages_pt, convert_to_anthropic_tool_result, convert_to_gemini_tool_call_result, @@ -73,6 +77,9 @@ def test_ollama_pt_consecutive_user_messages(): assert result["prompt"] == expected_prompt + + + @pytest.mark.asyncio async def test_anthropic_bedrock_thinking_blocks_with_none_content(): """ @@ -2008,8 +2015,8 @@ def test_bedrock_tools_unpack_defs_no_oom_with_nested_refs(): This test creates a schema with multiple nested $defs that reference each other to verify the fix prevents memory explosion while still correctly resolving refs. """ - import sys import copy + import sys from litellm.litellm_core_utils.prompt_templates.factory import _bedrock_tools_pt diff --git a/tests/unit/llms/anthropic/chat/test_anthropic_chat_transformation.py b/tests/unit/llms/anthropic/chat/test_anthropic_chat_transformation.py index a8b929ffa20..63f018aecae 100644 --- a/tests/unit/llms/anthropic/chat/test_anthropic_chat_transformation.py +++ b/tests/unit/llms/anthropic/chat/test_anthropic_chat_transformation.py @@ -6,6 +6,7 @@ from unittest.mock import MagicMock, patch import httpx import pytest import respx +from httpx import Headers import litellm from litellm.constants import ( @@ -1331,7 +1332,6 @@ def test_dict_fields_take_the_json_tool_on_a_native_structured_output_model(fiel assert ([tool["name"] for tool in optional_params.get("tools", [])] == ["json_tool_call"]) is not native - @pytest.mark.parametrize( ("model", "thinking_params"), [ @@ -1344,7 +1344,13 @@ def test_dict_fields_take_the_json_tool_on_a_native_structured_output_model(fiel {"tools": [{"type": "function", "function": {"name": "lookup", "parameters": {"type": "object"}}}]}, ), ], - ids=["adaptive-thinking", "reasoning-effort", "reasoning-effort-none", "model-without-forced-tools", "caller-tools"], + ids=[ + "adaptive-thinking", + "reasoning-effort", + "reasoning-effort-none", + "model-without-forced-tools", + "caller-tools", + ], ) def test_dict_fields_keep_native_output_when_the_json_tool_would_not_be_forced(model, thinking_params): optional_params: Final = AnthropicConfig().map_openai_params( @@ -1370,6 +1376,7 @@ def test_dict_fields_keep_native_output_when_the_json_tool_would_not_be_forced(m assert "output_format" in optional_params assert "json_tool_call" not in [tool["name"] for tool in optional_params.get("tools", [])] + def test_native_structured_output_uses_bundled_capability_when_remote_map_lags( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -6868,6 +6875,9 @@ def test_chat_dummy_tool_result_for_an_orphaned_tool_call_replays_a_byte_identic assert requests[0]["messages"][2]["content"][0]["type"] == "tool_result" + + + def test_json_mode_keeps_the_thinking_that_preceded_the_json_tool_call(): thinking_block: Final = {"type": "thinking", "thinking": "The user wants a greeting.", "signature": "sig-1"} body: Final = _json_mode_message_body("tool_use", None) @@ -6878,3 +6888,27 @@ def test_json_mode_keeps_the_thinking_that_preceded_the_json_tool_call(): assert message.reasoning_content == thinking_block["thinking"] assert [block["signature"] for block in message.thinking_blocks] == [thinking_block["signature"]] + +def _json_mode_message_body(stop_reason: str, stop_details: dict | None) -> dict: + return { + "id": "msg_json_mode", + "type": "message", + "role": "assistant", + "model": "claude-fable-5", + "stop_reason": stop_reason, + "stop_details": stop_details, + "usage": {"input_tokens": 12, "output_tokens": 8}, + "content": [ + {"type": "tool_use", "id": "toolu_json", "name": RESPONSE_FORMAT_TOOL_NAME, "input": {"answer": "Hello"}} + ], + } + + +def _transform_json_mode_body(body: dict) -> litellm.ModelResponse: + return AnthropicConfig().transform_parsed_response( + completion_response=body, + raw_response=httpx.Response(status_code=200, headers={}), + model_response=litellm.ModelResponse(), + json_mode=True, + prefix_prompt=None, + ) diff --git a/tests/unit/llms/anthropic/test_anthropic_schema_filter.py b/tests/unit/llms/anthropic/test_anthropic_schema_filter.py index 407a52c183e..5767e3ed390 100644 --- a/tests/unit/llms/anthropic/test_anthropic_schema_filter.py +++ b/tests/unit/llms/anthropic/test_anthropic_schema_filter.py @@ -5,6 +5,7 @@ Related to: https://platform.claude.com/docs/en/build-with-claude/structured-out """ import pytest + from litellm.llms.anthropic.chat.transformation import AnthropicConfig diff --git a/tests/unit/llms/bedrock/chat/invoke_transformations/test_bedrock_chat_invoke_transformations_anthropic_claude3_transformation.py b/tests/unit/llms/bedrock/chat/invoke_transformations/test_bedrock_chat_invoke_transformations_anthropic_claude3_transformation.py index b8f087d3393..d97422a6f0d 100644 --- a/tests/unit/llms/bedrock/chat/invoke_transformations/test_bedrock_chat_invoke_transformations_anthropic_claude3_transformation.py +++ b/tests/unit/llms/bedrock/chat/invoke_transformations/test_bedrock_chat_invoke_transformations_anthropic_claude3_transformation.py @@ -1,10 +1,11 @@ import asyncio import base64 import copy +import importlib import json import uuid -from types import SimpleNamespace from collections.abc import Mapping +from types import SimpleNamespace from typing import Final from unittest.mock import patch @@ -674,7 +675,6 @@ def test_shared_defs_reach_native_output_once_instead_of_inlined_per_use(set_nat assert "tools" not in result - class Relabel(BaseModel): model_config = ConfigDict(frozen=True) @@ -711,6 +711,7 @@ def test_nested_pydantic_response_format_reaches_native_output_with_refs_that_re assert item_ref.removeprefix("#/$defs/") in sent["$defs"] assert "tools" not in result + def test_output_config_format_forwarded_for_bedrock_chat_invoke_request(local_model_cost_map): """Bedrock Invoke chat path forwards ``output_config.format`` alongside effort for models with native structured-output support (Claude Sonnet 4.6).""" @@ -1266,6 +1267,9 @@ def test_chat_flagged_model_replays_a_byte_identical_prefix_around_a_mid_convers assert [m["role"] for m in requests[2]["messages"]] == ["user", "assistant", "user", "system", "assistant", "user"] + + + _CITY_SCHEMA: Final = { "type": "object", "properties": {"city": {"type": "string"}}, @@ -1326,4 +1330,3 @@ def test_invoke_uses_opt_in_native_output_is_false_when_native_output_did_not_co assert not AmazonAnthropicClaudeConfig().uses_opt_in_native_output( model, optional_params, {"model_info": model_info} ) - diff --git a/tests/unit/llms/bedrock/chat/test_converse_transformation.py b/tests/unit/llms/bedrock/chat/test_converse_transformation.py index 3ba98d0f748..405f8334f60 100644 --- a/tests/unit/llms/bedrock/chat/test_converse_transformation.py +++ b/tests/unit/llms/bedrock/chat/test_converse_transformation.py @@ -1,12 +1,14 @@ import copy import json import os +import re from collections.abc import Mapping from typing import Final from unittest.mock import MagicMock, patch import httpx import pytest +import respx import litellm from litellm import ModelResponse, Router, completion @@ -20,10 +22,11 @@ from litellm.llms.anthropic.chat.transformation import ( NATIVE_OUTPUT_MAX_OPTIONAL_PARAMETERS, NATIVE_OUTPUT_MAX_UNION_PARAMETERS, ) -from litellm.llms.bedrock.common_utils import BEDROCK_NATIVE_OUTPUT_MAX_PROPERTIES from litellm.llms.bedrock.base_aws_llm import BaseAWSLLM from litellm.llms.bedrock.chat.converse_transformation import AmazonConverseConfig -from litellm.types.llms.bedrock import ConverseTokenUsageBlock +from litellm.llms.bedrock.common_utils import BEDROCK_NATIVE_OUTPUT_MAX_PROPERTIES +from litellm.llms.custom_httpx.http_handler import HTTPHandler +from litellm.types.llms.bedrock import ConverseTokenUsageBlock, RequestObject def test_transform_usage(): @@ -42,16 +45,11 @@ def test_transform_usage(): openai_usage = config.transform_usage(usage) assert ( openai_usage.prompt_tokens - == usage["inputTokens"] - + usage["cacheReadInputTokens"] - + usage["cacheWriteInputTokens"] + == usage["inputTokens"] + usage["cacheReadInputTokens"] + usage["cacheWriteInputTokens"] ) assert openai_usage.completion_tokens == usage["outputTokens"] assert openai_usage.total_tokens == usage["totalTokens"] - assert ( - openai_usage.prompt_tokens_details.cached_tokens - == usage["cacheReadInputTokens"] - ) + assert openai_usage.prompt_tokens_details.cached_tokens == usage["cacheReadInputTokens"] assert openai_usage._cache_creation_input_tokens == usage["cacheWriteInputTokens"] assert openai_usage._cache_read_input_tokens == usage["cacheReadInputTokens"] # completion_tokens_details should always be populated @@ -99,10 +97,7 @@ def test_transform_usage_with_mismatched_cache_details_falls_back(): ) config = AmazonConverseConfig() openai_usage = config.transform_usage(usage) - assert ( - getattr(openai_usage.prompt_tokens_details, "cache_creation_token_details", None) - is None - ) + assert getattr(openai_usage.prompt_tokens_details, "cache_creation_token_details", None) is None def test_transform_usage_without_cache_details_stays_none(): @@ -118,10 +113,7 @@ def test_transform_usage_without_cache_details_stays_none(): ) config = AmazonConverseConfig() openai_usage = config.transform_usage(usage) - assert ( - getattr(openai_usage.prompt_tokens_details, "cache_creation_token_details", None) - is None - ) + assert getattr(openai_usage.prompt_tokens_details, "cache_creation_token_details", None) is None def test_bedrock_converse_1h_cache_write_billed_at_1h_rate(monkeypatch): @@ -379,14 +371,10 @@ def test_apply_tool_call_transformation_if_needed(): role="user", content=json.dumps(tool_response), ) - transformed_message, _ = config.apply_tool_call_transformation_if_needed( - message, tool_calls - ) + transformed_message, _ = config.apply_tool_call_transformation_if_needed(message, tool_calls) assert len(transformed_message.tool_calls) == 1 assert transformed_message.tool_calls[0].function.name == "test_function" - assert transformed_message.tool_calls[0].function.arguments == json.dumps( - tool_response["parameters"] - ) + assert transformed_message.tool_calls[0].function.arguments == json.dumps(tool_response["parameters"]) def test_transform_tool_call_with_cache_control(): @@ -435,12 +423,7 @@ def test_transform_tool_call_with_cache_control(): print(function_out_msg) assert function_out_msg["toolSpec"]["name"] == "get_location" assert function_out_msg["toolSpec"]["description"] == "Get the user's location" - assert ( - function_out_msg["toolSpec"]["inputSchema"]["json"]["properties"]["location"][ - "type" - ] - == "string" - ) + assert function_out_msg["toolSpec"]["inputSchema"]["json"]["properties"]["location"]["type"] == "string" transformed_cache_msg = result["toolConfig"]["tools"][1] assert "cachePoint" in transformed_cache_msg @@ -603,9 +586,7 @@ def test_reasoning_effort_none_omits_thinking_for_anthropic_converse(model): ("bedrock/converse/us.anthropic.claude-sonnet-4-6", "minimal", "low"), ], ) -def test_reasoning_effort_sets_output_config_for_adaptive_models_converse( - model, effort, expected_effort -): +def test_reasoning_effort_sets_output_config_for_adaptive_models_converse(model, effort, expected_effort): """Adaptive Claude 4.6 / 4.7 on Bedrock Converse routes the tier via ``output_config.effort``.""" config = AmazonConverseConfig() @@ -652,6 +633,9 @@ def test_output_config_effort_forwarded_into_additional_request_fields(model): + + + def test_reasoning_effort_requests_summarized_display_converse(): """Regression LIT-5714: adaptive thinking synthesized from reasoning_effort must request the summarized display, otherwise the provider returns a blank thinking @@ -836,9 +820,7 @@ def test_output_config_format_translated_to_native_output_config_converse(): assert additional.get("output_config") == {"effort": "max"} assert "format" not in additional["output_config"] assert result["outputConfig"]["textFormat"]["type"] == "json_schema" - parsed_schema = json.loads( - result["outputConfig"]["textFormat"]["structure"]["jsonSchema"]["schema"] - ) + parsed_schema = json.loads(result["outputConfig"]["textFormat"]["structure"]["jsonSchema"]["schema"]) assert parsed_schema == {**schema, "additionalProperties": False} @@ -867,10 +849,7 @@ def test_output_config_format_dropped_on_unsupported_converse_model_warns(caplog ) assert "outputConfig" not in result - assert any( - "dropping `output_config.format`" in record.getMessage() - for record in caplog.records - ) + assert any("dropping `output_config.format`" in record.getMessage() for record in caplog.records) def test_output_config_normalized_marker_does_not_leak_into_optional_params(): @@ -906,9 +885,7 @@ def test_output_config_normalized_marker_does_not_leak_into_optional_params(): ("bedrock/converse/us.anthropic.claude-opus-4-7", "xhigh"), ], ) -def test_output_config_effort_normalized_for_bedrock_converse_opus( - model, expected_effort -): +def test_output_config_effort_normalized_for_bedrock_converse_opus(model, expected_effort): """Bedrock Converse accepts ``xhigh`` and forwards the provider-safe effort.""" config = AmazonConverseConfig() @@ -1181,17 +1158,13 @@ def test_get_supported_openai_params_bedrock_converse(): for model in litellm.BEDROCK_CONVERSE_MODELS: print(f"Testing model: {model}") config = AmazonConverseConfig() - supported_params_without_prefix = config.get_supported_openai_params( - model=model - ) + supported_params_without_prefix = config.get_supported_openai_params(model=model) - supported_params_with_prefix = config.get_supported_openai_params( - model=f"bedrock/converse/{model}" - ) + supported_params_with_prefix = config.get_supported_openai_params(model=f"bedrock/converse/{model}") - assert set(supported_params_without_prefix) == set( - supported_params_with_prefix - ), f"Supported params mismatch for model: {model}. Without prefix: {supported_params_without_prefix}, With prefix: {supported_params_with_prefix}" + assert set(supported_params_without_prefix) == set(supported_params_with_prefix), ( + f"Supported params mismatch for model: {model}. Without prefix: {supported_params_without_prefix}, With prefix: {supported_params_with_prefix}" + ) print(f"✅ Passed for model: {model}") @@ -1683,9 +1656,7 @@ def test_transform_response_with_structured_response_calling_tool(): "output": { "message": { "content": [ - { - "text": "I'll check the current weather in San Francisco for you." - }, + {"text": "I'll check the current weather in San Francisco for you."}, { "toolUse": { "input": { @@ -2195,9 +2166,7 @@ def test_transform_request_with_function_tool(): } ] - messages = [ - {"role": "user", "content": "What's the weather like in San Francisco?"} - ] + messages = [{"role": "user", "content": "What's the weather like in San Francisco?"}] # Transform request request_data = config.transform_request( @@ -2305,22 +2274,18 @@ async def test_assistant_message_cache_control(): llm_provider="bedrock_converse", ) - async_result = ( - await BedrockConverseMessagesProcessor._bedrock_converse_messages_pt_async( - messages=messages, - model="bedrock/us.anthropic.claude-haiku-4-5-20251001-v1:0", - llm_provider="bedrock_converse", - ) + async_result = await BedrockConverseMessagesProcessor._bedrock_converse_messages_pt_async( + messages=messages, + model="bedrock/us.anthropic.claude-haiku-4-5-20251001-v1:0", + llm_provider="bedrock_converse", ) assert result == async_result - async_result = ( - await BedrockConverseMessagesProcessor._bedrock_converse_messages_pt_async( - messages=messages, - model="bedrock/us.anthropic.claude-haiku-4-5-20251001-v1:0", - llm_provider="bedrock_converse", - ) + async_result = await BedrockConverseMessagesProcessor._bedrock_converse_messages_pt_async( + messages=messages, + model="bedrock/us.anthropic.claude-haiku-4-5-20251001-v1:0", + llm_provider="bedrock_converse", ) assert result == async_result @@ -2366,12 +2331,10 @@ async def test_assistant_message_list_content_cache_control(): llm_provider="bedrock_converse", ) - async_result = ( - await BedrockConverseMessagesProcessor._bedrock_converse_messages_pt_async( - messages=messages, - model="bedrock/us.anthropic.claude-haiku-4-5-20251001-v1:0", - llm_provider="bedrock_converse", - ) + async_result = await BedrockConverseMessagesProcessor._bedrock_converse_messages_pt_async( + messages=messages, + model="bedrock/us.anthropic.claude-haiku-4-5-20251001-v1:0", + llm_provider="bedrock_converse", ) assert result == async_result @@ -2424,12 +2387,10 @@ async def test_tool_message_cache_control(): llm_provider="bedrock_converse", ) - async_result = ( - await BedrockConverseMessagesProcessor._bedrock_converse_messages_pt_async( - messages=messages, - model="bedrock/us.anthropic.claude-haiku-4-5-20251001-v1:0", - llm_provider="bedrock_converse", - ) + async_result = await BedrockConverseMessagesProcessor._bedrock_converse_messages_pt_async( + messages=messages, + model="bedrock/us.anthropic.claude-haiku-4-5-20251001-v1:0", + llm_provider="bedrock_converse", ) assert result == async_result @@ -2443,10 +2404,7 @@ async def test_tool_message_cache_control(): # First should be tool result assert "toolResult" in tool_message_content[0] - assert ( - tool_message_content[0]["toolResult"]["content"][0]["text"] - == "Weather data: sunny, 25°C" - ) + assert tool_message_content[0]["toolResult"]["content"][0]["text"] == "Weather data: sunny, 25°C" # Second should be cachePoint assert "cachePoint" in tool_message_content[1] @@ -2488,12 +2446,10 @@ async def test_tool_message_string_content_cache_control(): llm_provider="bedrock_converse", ) - async_result = ( - await BedrockConverseMessagesProcessor._bedrock_converse_messages_pt_async( - messages=messages, - model="bedrock/us.anthropic.claude-haiku-4-5-20251001-v1:0", - llm_provider="bedrock_converse", - ) + async_result = await BedrockConverseMessagesProcessor._bedrock_converse_messages_pt_async( + messages=messages, + model="bedrock/us.anthropic.claude-haiku-4-5-20251001-v1:0", + llm_provider="bedrock_converse", ) assert result == async_result @@ -2504,10 +2460,7 @@ async def test_tool_message_string_content_cache_control(): # First should be tool result assert "toolResult" in tool_message_content[0] - assert ( - tool_message_content[0]["toolResult"]["content"][0]["text"] - == "Weather: sunny, 25°C" - ) + assert tool_message_content[0]["toolResult"]["content"][0]["text"] == "Weather: sunny, 25°C" # Second should be cachePoint assert "cachePoint" in tool_message_content[1] @@ -2547,9 +2500,7 @@ async def test_tool_message_search_results_maps_to_bedrock_search_result_block() "source": "Great Source of Information About Apptio", "title": "12adbd74-46bd-4a88-88b2-0048755f6eb5", "content": [ - { - "text": "Apptio is a company that makes calls to Bedrock using passthrough APIs via LiteLLM" - } + {"text": "Apptio is a company that makes calls to Bedrock using passthrough APIs via LiteLLM"} ], "citations": {"enabled": True}, } @@ -2562,12 +2513,10 @@ async def test_tool_message_search_results_maps_to_bedrock_search_result_block() model="bedrock/us.anthropic.claude-haiku-4-5-20251001-v1:0", llm_provider="bedrock_converse", ) - async_result = ( - await BedrockConverseMessagesProcessor._bedrock_converse_messages_pt_async( - messages=messages, - model="bedrock/us.anthropic.claude-haiku-4-5-20251001-v1:0", - llm_provider="bedrock_converse", - ) + async_result = await BedrockConverseMessagesProcessor._bedrock_converse_messages_pt_async( + messages=messages, + model="bedrock/us.anthropic.claude-haiku-4-5-20251001-v1:0", + llm_provider="bedrock_converse", ) assert result == async_result @@ -2576,10 +2525,7 @@ async def test_tool_message_search_results_maps_to_bedrock_search_result_block() assert tool_result["status"] == "success" assert len(tool_result["content"]) == 1 assert "searchResult" in tool_result["content"][0] - assert ( - tool_result["content"][0]["searchResult"]["title"] - == "12adbd74-46bd-4a88-88b2-0048755f6eb5" - ) + assert tool_result["content"][0]["searchResult"]["title"] == "12adbd74-46bd-4a88-88b2-0048755f6eb5" @pytest.mark.asyncio @@ -2616,12 +2562,10 @@ async def test_tool_message_empty_search_results_falls_back_to_content(): model="bedrock/us.anthropic.claude-haiku-4-5-20251001-v1:0", llm_provider="bedrock_converse", ) - async_result = ( - await BedrockConverseMessagesProcessor._bedrock_converse_messages_pt_async( - messages=messages, - model="bedrock/us.anthropic.claude-haiku-4-5-20251001-v1:0", - llm_provider="bedrock_converse", - ) + async_result = await BedrockConverseMessagesProcessor._bedrock_converse_messages_pt_async( + messages=messages, + model="bedrock/us.anthropic.claude-haiku-4-5-20251001-v1:0", + llm_provider="bedrock_converse", ) assert result == async_result @@ -2783,12 +2727,10 @@ async def test_assistant_tool_calls_cache_control(): llm_provider="bedrock_converse", ) - async_result = ( - await BedrockConverseMessagesProcessor._bedrock_converse_messages_pt_async( - messages=messages, - model="bedrock/us.anthropic.claude-haiku-4-5-20251001-v1:0", - llm_provider="bedrock_converse", - ) + async_result = await BedrockConverseMessagesProcessor._bedrock_converse_messages_pt_async( + messages=messages, + model="bedrock/us.anthropic.claude-haiku-4-5-20251001-v1:0", + llm_provider="bedrock_converse", ) assert result == async_result @@ -2843,12 +2785,10 @@ async def test_multiple_tool_calls_with_mixed_cache_control(): llm_provider="bedrock_converse", ) - async_result = ( - await BedrockConverseMessagesProcessor._bedrock_converse_messages_pt_async( - messages=messages, - model="bedrock/us.anthropic.claude-haiku-4-5-20251001-v1:0", - llm_provider="bedrock_converse", - ) + async_result = await BedrockConverseMessagesProcessor._bedrock_converse_messages_pt_async( + messages=messages, + model="bedrock/us.anthropic.claude-haiku-4-5-20251001-v1:0", + llm_provider="bedrock_converse", ) assert result == async_result @@ -2894,12 +2834,10 @@ async def test_no_cache_control_no_cache_point(): llm_provider="bedrock_converse", ) - async_result = ( - await BedrockConverseMessagesProcessor._bedrock_converse_messages_pt_async( - messages=messages, - model="bedrock/us.anthropic.claude-haiku-4-5-20251001-v1:0", - llm_provider="bedrock_converse", - ) + async_result = await BedrockConverseMessagesProcessor._bedrock_converse_messages_pt_async( + messages=messages, + model="bedrock/us.anthropic.claude-haiku-4-5-20251001-v1:0", + llm_provider="bedrock_converse", ) assert result == async_result @@ -3069,10 +3007,7 @@ def test_guarded_text_with_mixed_content_types(): # Third should be guardContent assert "guardContent" in content[2] - assert ( - content[2]["guardContent"]["text"]["text"] - == "This sensitive content should be guarded" - ) + assert content[2]["guardContent"]["text"]["text"] == "This sensitive content should be guarded" @pytest.mark.asyncio @@ -3167,10 +3102,7 @@ def test_guarded_text_with_tool_calls(): # Second should be guardContent assert "guardContent" in content[1] - assert ( - content[1]["guardContent"]["text"]["text"] - == "Please be careful with sensitive information" - ) + assert content[1]["guardContent"]["text"]["text"] == "Please be careful with sensitive information" # Other messages should not have guardContent for i in range(1, 3): @@ -3231,52 +3163,36 @@ def test_auto_convert_last_user_message_to_guarded_text(): } ] - optional_params = { - "guardrailConfig": {"guardrailIdentifier": "gr-abc123", "guardrailVersion": "1"} - } + optional_params = {"guardrailConfig": {"guardrailIdentifier": "gr-abc123", "guardrailVersion": "1"}} # Test the helper method directly - converted_messages = config._convert_consecutive_user_messages_to_guarded_text( - messages, optional_params - ) + converted_messages = config._convert_consecutive_user_messages_to_guarded_text(messages, optional_params) # Verify the conversion assert len(converted_messages) == 1 assert converted_messages[0]["role"] == "user" assert len(converted_messages[0]["content"]) == 1 assert converted_messages[0]["content"][0]["type"] == "guarded_text" - assert ( - converted_messages[0]["content"][0]["text"] - == "What is the main topic of this legal document?" - ) + assert converted_messages[0]["content"][0]["text"] == "What is the main topic of this legal document?" def test_auto_convert_last_user_message_string_content(): """Test that last user message with string content is automatically converted to guarded_text when guardrailConfig is present.""" config = AmazonConverseConfig() - messages = [ - {"role": "user", "content": "What is the main topic of this legal document?"} - ] + messages = [{"role": "user", "content": "What is the main topic of this legal document?"}] - optional_params = { - "guardrailConfig": {"guardrailIdentifier": "gr-abc123", "guardrailVersion": "1"} - } + optional_params = {"guardrailConfig": {"guardrailIdentifier": "gr-abc123", "guardrailVersion": "1"}} # Test the helper method directly - converted_messages = config._convert_consecutive_user_messages_to_guarded_text( - messages, optional_params - ) + converted_messages = config._convert_consecutive_user_messages_to_guarded_text(messages, optional_params) # Verify the conversion assert len(converted_messages) == 1 assert converted_messages[0]["role"] == "user" assert len(converted_messages[0]["content"]) == 1 assert converted_messages[0]["content"][0]["type"] == "guarded_text" - assert ( - converted_messages[0]["content"][0]["text"] - == "What is the main topic of this legal document?" - ) + assert converted_messages[0]["content"][0]["text"] == "What is the main topic of this legal document?" def test_no_conversion_when_no_guardrail_config(): @@ -3298,9 +3214,7 @@ def test_no_conversion_when_no_guardrail_config(): optional_params = {} # Test the helper method directly - converted_messages = config._convert_consecutive_user_messages_to_guarded_text( - messages, optional_params - ) + converted_messages = config._convert_consecutive_user_messages_to_guarded_text(messages, optional_params) # Verify no conversion happened assert converted_messages == messages @@ -3317,14 +3231,10 @@ def test_no_conversion_when_guarded_text_already_present(): } ] - optional_params = { - "guardrailConfig": {"guardrailIdentifier": "gr-abc123", "guardrailVersion": "1"} - } + optional_params = {"guardrailConfig": {"guardrailIdentifier": "gr-abc123", "guardrailVersion": "1"}} # Test the helper method directly - converted_messages = config._convert_consecutive_user_messages_to_guarded_text( - messages, optional_params - ) + converted_messages = config._convert_consecutive_user_messages_to_guarded_text(messages, optional_params) # Verify no conversion happened assert converted_messages == messages @@ -3350,14 +3260,10 @@ def test_auto_convert_with_mixed_content(): } ] - optional_params = { - "guardrailConfig": {"guardrailIdentifier": "gr-abc123", "guardrailVersion": "1"} - } + optional_params = {"guardrailConfig": {"guardrailIdentifier": "gr-abc123", "guardrailVersion": "1"}} # Test the helper method directly - converted_messages = config._convert_consecutive_user_messages_to_guarded_text( - messages, optional_params - ) + converted_messages = config._convert_consecutive_user_messages_to_guarded_text(messages, optional_params) # Verify the conversion assert len(converted_messages) == 1 @@ -3366,17 +3272,11 @@ def test_auto_convert_with_mixed_content(): # First element should be converted to guarded_text assert converted_messages[0]["content"][0]["type"] == "guarded_text" - assert ( - converted_messages[0]["content"][0]["text"] - == "What is the main topic of this legal document?" - ) + assert converted_messages[0]["content"][0]["text"] == "What is the main topic of this legal document?" # Second element should remain unchanged assert converted_messages[0]["content"][1]["type"] == "image_url" - assert ( - converted_messages[0]["content"][1]["image_url"]["url"] - == "https://example.com/image.jpg" - ) + assert converted_messages[0]["content"][1]["image_url"]["url"] == "https://example.com/image.jpg" def test_auto_convert_in_full_transformation(): @@ -3395,9 +3295,7 @@ def test_auto_convert_in_full_transformation(): } ] - optional_params = { - "guardrailConfig": {"guardrailIdentifier": "gr-abc123", "guardrailVersion": "1"} - } + optional_params = {"guardrailConfig": {"guardrailIdentifier": "gr-abc123", "guardrailVersion": "1"}} # Test the full transformation result = config._transform_request( @@ -3417,10 +3315,7 @@ def test_auto_convert_in_full_transformation(): assert "content" in message assert len(message["content"]) == 1 assert "guardContent" in message["content"][0] - assert ( - message["content"][0]["guardContent"]["text"]["text"] - == "What is the main topic of this legal document?" - ) + assert message["content"][0]["guardContent"]["text"]["text"] == "What is the main topic of this legal document?" def test_convert_consecutive_user_messages_to_guarded_text(): @@ -3434,14 +3329,10 @@ def test_convert_consecutive_user_messages_to_guarded_text(): {"role": "user", "content": [{"type": "text", "text": "Third user message"}]}, ] - optional_params = { - "guardrailConfig": {"guardrailIdentifier": "gr-abc123", "guardrailVersion": "1"} - } + optional_params = {"guardrailConfig": {"guardrailIdentifier": "gr-abc123", "guardrailVersion": "1"}} # Test the helper method directly - converted_messages = config._convert_consecutive_user_messages_to_guarded_text( - messages, optional_params - ) + converted_messages = config._convert_consecutive_user_messages_to_guarded_text(messages, optional_params) # Verify the conversion - only the last two user messages should be converted assert len(converted_messages) == 4 @@ -3476,14 +3367,10 @@ def test_convert_all_user_messages_when_all_consecutive(): {"role": "user", "content": [{"type": "text", "text": "Third user message"}]}, ] - optional_params = { - "guardrailConfig": {"guardrailIdentifier": "gr-abc123", "guardrailVersion": "1"} - } + optional_params = {"guardrailConfig": {"guardrailIdentifier": "gr-abc123", "guardrailVersion": "1"}} # Test the helper method directly - converted_messages = config._convert_consecutive_user_messages_to_guarded_text( - messages, optional_params - ) + converted_messages = config._convert_consecutive_user_messages_to_guarded_text(messages, optional_params) # Verify all three user messages are converted assert len(converted_messages) == 3 @@ -3507,14 +3394,10 @@ def test_convert_consecutive_user_messages_with_string_content(): {"role": "user", "content": "Second user message"}, ] - optional_params = { - "guardrailConfig": {"guardrailIdentifier": "gr-abc123", "guardrailVersion": "1"} - } + optional_params = {"guardrailConfig": {"guardrailIdentifier": "gr-abc123", "guardrailVersion": "1"}} # Test the helper method directly - converted_messages = config._convert_consecutive_user_messages_to_guarded_text( - messages, optional_params - ) + converted_messages = config._convert_consecutive_user_messages_to_guarded_text(messages, optional_params) # Verify the conversion assert len(converted_messages) == 3 @@ -3547,14 +3430,10 @@ def test_skip_consecutive_user_messages_with_existing_guarded_text(): {"role": "user", "content": [{"type": "text", "text": "Should be converted"}]}, ] - optional_params = { - "guardrailConfig": {"guardrailIdentifier": "gr-abc123", "guardrailVersion": "1"} - } + optional_params = {"guardrailConfig": {"guardrailIdentifier": "gr-abc123", "guardrailVersion": "1"}} # Test the helper method directly - converted_messages = config._convert_consecutive_user_messages_to_guarded_text( - messages, optional_params - ) + converted_messages = config._convert_consecutive_user_messages_to_guarded_text(messages, optional_params) # Verify the conversion assert len(converted_messages) == 2 @@ -4113,24 +3992,22 @@ def test_drop_thinking_param_when_thinking_blocks_missing(): optional_params = {"thinking": {"type": "enabled", "budget_tokens": 1000}} # Verify the condition is detected - assert last_assistant_with_tool_calls_has_no_thinking_blocks( - messages_without_thinking_blocks - ), "Should detect missing thinking_blocks" + assert last_assistant_with_tool_calls_has_no_thinking_blocks(messages_without_thinking_blocks), ( + "Should detect missing thinking_blocks" + ) # Simulate what _transform_request_helper does if ( optional_params.get("thinking") is not None and messages_without_thinking_blocks is not None - and last_assistant_with_tool_calls_has_no_thinking_blocks( - messages_without_thinking_blocks - ) + and last_assistant_with_tool_calls_has_no_thinking_blocks(messages_without_thinking_blocks) ): if litellm.modify_params: optional_params.pop("thinking", None) - assert ( - "thinking" not in optional_params - ), "thinking param should be dropped when modify_params=True and thinking_blocks are missing" + assert "thinking" not in optional_params, ( + "thinking param should be dropped when modify_params=True and thinking_blocks are missing" + ) # Test case 2: thinking should NOT be dropped when thinking_blocks are present messages_with_thinking_blocks = [ @@ -4145,58 +4022,46 @@ def test_drop_thinking_param_when_thinking_blocks_missing(): "function": {"name": "search", "arguments": "{}"}, } ], - "thinking_blocks": [ - {"type": "thinking", "thinking": "Let me search for weather..."} - ], + "thinking_blocks": [{"type": "thinking", "thinking": "Let me search for weather..."}], }, {"role": "tool", "content": "Weather is sunny", "tool_call_id": "call_123"}, ] - optional_params_with_thinking = { - "thinking": {"type": "enabled", "budget_tokens": 1000} - } + optional_params_with_thinking = {"thinking": {"type": "enabled", "budget_tokens": 1000}} # Verify the condition is NOT detected when thinking_blocks are present - assert not last_assistant_with_tool_calls_has_no_thinking_blocks( - messages_with_thinking_blocks - ), "Should NOT detect missing thinking_blocks when they are present" + assert not last_assistant_with_tool_calls_has_no_thinking_blocks(messages_with_thinking_blocks), ( + "Should NOT detect missing thinking_blocks when they are present" + ) # Simulate what _transform_request_helper does if ( optional_params_with_thinking.get("thinking") is not None and messages_with_thinking_blocks is not None - and last_assistant_with_tool_calls_has_no_thinking_blocks( - messages_with_thinking_blocks - ) + and last_assistant_with_tool_calls_has_no_thinking_blocks(messages_with_thinking_blocks) ): if litellm.modify_params: optional_params_with_thinking.pop("thinking", None) - assert ( - "thinking" in optional_params_with_thinking - ), "thinking param should NOT be dropped when thinking_blocks are present" + assert "thinking" in optional_params_with_thinking, ( + "thinking param should NOT be dropped when thinking_blocks are present" + ) # Test case 3: thinking should NOT be dropped when modify_params=False litellm.modify_params = False - optional_params_no_modify = { - "thinking": {"type": "enabled", "budget_tokens": 1000} - } + optional_params_no_modify = {"thinking": {"type": "enabled", "budget_tokens": 1000}} # Simulate what _transform_request_helper does if ( optional_params_no_modify.get("thinking") is not None and messages_without_thinking_blocks is not None - and last_assistant_with_tool_calls_has_no_thinking_blocks( - messages_without_thinking_blocks - ) + and last_assistant_with_tool_calls_has_no_thinking_blocks(messages_without_thinking_blocks) ): if litellm.modify_params: optional_params_no_modify.pop("thinking", None) - assert ( - "thinking" in optional_params_no_modify - ), "thinking param should NOT be dropped when modify_params=False" + assert "thinking" in optional_params_no_modify, "thinking param should NOT be dropped when modify_params=False" finally: # Restore original modify_params setting @@ -4280,19 +4145,14 @@ def test_translate_response_format_native_output_config(monkeypatch): assert "fake_stream" not in result # Verify the schema content (additionalProperties: false is added by normalization) - schema_str = result["outputConfig"]["textFormat"]["structure"]["jsonSchema"][ - "schema" - ] + schema_str = result["outputConfig"]["textFormat"]["structure"]["jsonSchema"]["schema"] parsed_schema = json.loads(schema_str) expected_schema = { **response_format["json_schema"]["schema"], "additionalProperties": False, } assert parsed_schema == expected_schema - assert ( - result["outputConfig"]["textFormat"]["structure"]["jsonSchema"]["name"] - == "WeatherResult" - ) + assert result["outputConfig"]["textFormat"]["structure"]["jsonSchema"]["name"] == "WeatherResult" finally: litellm.model_cost = old_cost if old_env is None: @@ -4785,7 +4645,6 @@ def test_recursive_output_config_format_follows_the_profile_native_key( assert ("outputConfig" in request) is forwarded - def test_sonnet_4_6_json_schema_still_uses_native_output_config(monkeypatch): monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "True") monkeypatch.setattr(litellm, "model_cost", litellm.get_model_cost_map(url="")) @@ -4866,9 +4725,7 @@ def test_native_structured_output_no_fake_stream(monkeypatch): assert "fake_stream" not in result # Verify the schema content - schema_str = result["outputConfig"]["textFormat"]["structure"]["jsonSchema"][ - "schema" - ] + schema_str = result["outputConfig"]["textFormat"]["structure"]["jsonSchema"]["schema"] assert json.loads(schema_str) == { "type": "object", "properties": {"answer": {"type": "string"}}, @@ -4921,10 +4778,7 @@ def test_transform_request_with_output_config(): assert "outputConfig" in result assert result["outputConfig"]["textFormat"]["type"] == "json_schema" - assert ( - result["outputConfig"]["textFormat"]["structure"]["jsonSchema"]["name"] - == "TestSchema" - ) + assert result["outputConfig"]["textFormat"]["structure"]["jsonSchema"]["name"] == "TestSchema" def test_transform_request_strips_anthropic_output_config(): @@ -5045,10 +4899,7 @@ def test_transform_response_native_structured_output(): ) # Content should be the JSON text directly - assert ( - result.choices[0].message.content - == '{"temp": 62, "description": "Mild and foggy"}' - ) + assert result.choices[0].message.content == '{"temp": 62, "description": "Mild and foggy"}' # Should NOT have tool_calls assert result.choices[0].message.tool_calls is None assert result.choices[0].finish_reason == "stop" @@ -5161,10 +5012,7 @@ def test_add_additional_properties_definitions(): # definitions object assert result["definitions"]["Item"]["additionalProperties"] is False # Nested object inside definitions - assert ( - result["definitions"]["Item"]["properties"]["details"]["additionalProperties"] - is False - ) + assert result["definitions"]["Item"]["properties"]["details"]["additionalProperties"] is False def test_json_object_no_schema_skips_tool_injection(monkeypatch): @@ -5221,9 +5069,7 @@ def test_output_config_applies_additional_properties(): output_config = AmazonConverseConfig._create_output_config_for_response_format( json_schema=schema, name="test_schema" ) - parsed = json.loads( - output_config["textFormat"]["structure"]["jsonSchema"]["schema"] - ) + parsed = json.loads(output_config["textFormat"]["structure"]["jsonSchema"]["schema"]) assert parsed["additionalProperties"] is False assert parsed["properties"]["nested"]["additionalProperties"] is False @@ -5272,12 +5118,7 @@ def test_parallel_tool_calls_newer_model_adds_disable_flag(): assert "additionalModelRequestFields" in request_data assert "tool_choice" in request_data["additionalModelRequestFields"] - assert ( - request_data["additionalModelRequestFields"]["tool_choice"][ - "disable_parallel_tool_use" - ] - is True - ) + assert request_data["additionalModelRequestFields"]["tool_choice"]["disable_parallel_tool_use"] is True assert "parallel_tool_calls" not in request_data["additionalModelRequestFields"] @@ -5309,12 +5150,7 @@ def test_parallel_tool_calls_flag_decoupled_from_ttl_pricing(monkeypatch): headers={}, ) - assert ( - request_data["additionalModelRequestFields"]["tool_choice"][ - "disable_parallel_tool_use" - ] - is True - ) + assert request_data["additionalModelRequestFields"]["tool_choice"]["disable_parallel_tool_use"] is True def test_parallel_tool_calls_older_model_drops_disable_flag(): @@ -5461,9 +5297,7 @@ def test_parallel_tool_use_merge_preserves_user_tool_choice_type(): class TestBedrockMinThinkingBudgetTokens: """Test that thinking.budget_tokens is clamped to the Bedrock minimum (1024).""" - def _map_params( - self, thinking_value, model="anthropic.claude-sonnet-4-5-20250929-v1:0" - ): + def _map_params(self, thinking_value, model="anthropic.claude-sonnet-4-5-20250929-v1:0"): """Helper to call map_openai_params with the given thinking value.""" config = AmazonConverseConfig() non_default_params = {"thinking": thinking_value} @@ -5690,9 +5524,7 @@ def test_streaming_filters_json_tool_call_with_real_tools(): # Chunk 2: json_tool_call delta — should become text, not tool_use json_delta = ContentBlockDeltaEvent(toolUse={"input": '{"temp": 62}'}) - text_2, tool_use_2, _, _, _ = decoder._handle_converse_delta_event( - json_delta, index=0 - ) + text_2, tool_use_2, _, _, _ = decoder._handle_converse_delta_event(json_delta, index=0) assert text_2 == '{"temp": 62}' assert tool_use_2 is None @@ -5716,9 +5548,7 @@ def test_streaming_filters_json_tool_call_with_real_tools(): # Chunk 5: real tool delta real_delta = ContentBlockDeltaEvent(toolUse={"input": '{"location": "SF"}'}) - text_5, tool_use_5, _, _, _ = decoder._handle_converse_delta_event( - real_delta, index=1 - ) + text_5, tool_use_5, _, _, _ = decoder._handle_converse_delta_event(real_delta, index=1) assert text_5 == "" assert tool_use_5 is not None assert tool_use_5["function"]["arguments"] == '{"location": "SF"}' @@ -5751,9 +5581,7 @@ def test_streaming_without_json_mode_passes_all_tools(): # json_tool_call delta — should be a tool_use, not text json_delta = ContentBlockDeltaEvent(toolUse={"input": '{"data": 1}'}) - text, tool_use_delta, _, _, _ = decoder._handle_converse_delta_event( - json_delta, index=0 - ) + text, tool_use_delta, _, _, _ = decoder._handle_converse_delta_event(json_delta, index=0) assert text == "" assert tool_use_delta is not None assert tool_use_delta["function"]["arguments"] == '{"data": 1}' @@ -6090,8 +5918,6 @@ def test_tool_config_cachepoint_not_placed_or_credited_for_model_without_prompt_ assert "litellm_gateway_injected_cache" not in bucket - - def test_transform_response_finish_reason_stop_when_json_mode_filters_all_tools(): """ When json_mode is True and _filter_json_mode_tools strips all synthetic @@ -6286,11 +6112,7 @@ def test_transform_response_citation_null_source_title_become_empty_strings(): "content": [ { "citationsContent": { - "content": [ - { - "text": "Apptio is a company that makes calls to Bedrock" - } - ], + "content": [{"text": "Apptio is a company that makes calls to Bedrock"}], "citations": [ { "location": { @@ -6425,15 +6247,11 @@ def test_transform_response_citations_offset_tracks_text_only_blocks(): message = result.choices[0].message expected_start = len(leading_text) assert message.content == leading_text + cited_text - assert ( - message.content[expected_start : expected_start + len(cited_text)] == cited_text - ) + assert message.content[expected_start : expected_start + len(cited_text)] == cited_text assert message.annotations is not None assert len(message.annotations) == 1 assert message.annotations[0]["url_citation"]["start_index"] == expected_start - assert message.annotations[0]["url_citation"]["end_index"] == expected_start + len( - cited_text - ) + assert message.annotations[0]["url_citation"]["end_index"] == expected_start + len(cited_text) def test_transform_response_stitches_citations_for_whitespace_punctuation_text(): @@ -6543,9 +6361,7 @@ def test_bedrock_tool_message_openai_file_pdf_becomes_document(): }, ] - translated_msg = _bedrock_converse_messages_pt( - messages=messages, model="", llm_provider="" - ) + translated_msg = _bedrock_converse_messages_pt(messages=messages, model="", llm_provider="") tool_result = translated_msg[-1]["content"][-1]["toolResult"] assert tool_result["toolUseId"] == "tooluse_pdf_1" @@ -6587,9 +6403,7 @@ def test_bedrock_tool_message_image_url_pdf_data_uri_becomes_document(): }, ] - translated_msg = _bedrock_converse_messages_pt( - messages=messages, model="", llm_provider="" - ) + translated_msg = _bedrock_converse_messages_pt(messages=messages, model="", llm_provider="") tool_result = translated_msg[-1]["content"][-1]["toolResult"] assert tool_result["toolUseId"] == "tooluse_pdf_img_1" @@ -6646,9 +6460,7 @@ def test_bedrock_tool_message_file_id_http_url_becomes_document(): "process_image_sync", return_value=fake_document_block, ) as mock_proc: - translated_msg = _bedrock_converse_messages_pt( - messages=messages, model="", llm_provider="" - ) + translated_msg = _bedrock_converse_messages_pt(messages=messages, model="", llm_provider="") mock_proc.assert_called_once() assert mock_proc.call_args.kwargs["image_url"] == pdf_url @@ -6719,9 +6531,7 @@ def test_bedrock_tool_message_image_url_png_still_becomes_image(): }, ] - translated_msg = _bedrock_converse_messages_pt( - messages=messages, model="", llm_provider="" - ) + translated_msg = _bedrock_converse_messages_pt(messages=messages, model="", llm_provider="") tool_result = translated_msg[-1]["content"][-1]["toolResult"] assert len(tool_result["content"]) == 1 @@ -6916,12 +6726,10 @@ async def test_grounding_source_and_query_rendered_as_text(): model="bedrock/us.anthropic.claude-haiku-4-5-20251001-v1:0", llm_provider="bedrock_converse", ) - async_result = ( - await BedrockConverseMessagesProcessor._bedrock_converse_messages_pt_async( - messages=messages, - model="bedrock/us.anthropic.claude-haiku-4-5-20251001-v1:0", - llm_provider="bedrock_converse", - ) + async_result = await BedrockConverseMessagesProcessor._bedrock_converse_messages_pt_async( + messages=messages, + model="bedrock/us.anthropic.claude-haiku-4-5-20251001-v1:0", + llm_provider="bedrock_converse", ) assert result == async_result @@ -6965,9 +6773,7 @@ def test_neutralize_orphaned_tool_blocks_rewrites_when_no_tools(): (#24158, #27138).""" messages = _orphaned_tool_history_messages() - result = AmazonConverseConfig._neutralize_orphaned_tool_blocks( - messages, optional_params={} - ) + result = AmazonConverseConfig._neutralize_orphaned_tool_blocks(messages, optional_params={}) serialized = json.dumps(result) assert "tool_calls" not in serialized @@ -6988,9 +6794,7 @@ def test_neutralize_orphaned_tool_blocks_rewrites_when_tools_empty(tools_value): structured tool blocks with no toolConfig.""" messages = _orphaned_tool_history_messages() - result = AmazonConverseConfig._neutralize_orphaned_tool_blocks( - messages, optional_params={"tools": tools_value} - ) + result = AmazonConverseConfig._neutralize_orphaned_tool_blocks(messages, optional_params={"tools": tools_value}) serialized = json.dumps(result) assert "tool_calls" not in serialized @@ -7006,9 +6810,7 @@ def test_neutralize_orphaned_tool_blocks_rewrites_tool_result_only_history(): {"role": "tool", "tool_call_id": "call_xyz", "content": "lookup result"}, ] - result = AmazonConverseConfig._neutralize_orphaned_tool_blocks( - messages, optional_params={} - ) + result = AmazonConverseConfig._neutralize_orphaned_tool_blocks(messages, optional_params={}) assert not any(m.get("role") in ("tool", "function") for m in result) serialized = json.dumps(result) @@ -7045,13 +6847,9 @@ def test_neutralize_orphaned_tool_blocks_non_text_result_marked_not_empty(): }, ] - result = AmazonConverseConfig._neutralize_orphaned_tool_blocks( - messages, optional_params={} - ) + result = AmazonConverseConfig._neutralize_orphaned_tool_blocks(messages, optional_params={}) - rewritten = next( - m for m in result if m.get("role") == "user" and m is not messages[0] - ) + rewritten = next(m for m in result if m.get("role") == "user" and m is not messages[0]) text = rewritten["content"] assert text.strip() # never empty assert "non-text tool result omitted" in text @@ -7074,9 +6872,7 @@ def test_neutralize_orphaned_tool_blocks_noop_when_no_tool_history(): """Plain conversation with no tool blocks is returned unchanged.""" messages = [{"role": "user", "content": "hi"}] - result = AmazonConverseConfig._neutralize_orphaned_tool_blocks( - messages, optional_params={} - ) + result = AmazonConverseConfig._neutralize_orphaned_tool_blocks(messages, optional_params={}) assert result is messages @@ -7087,14 +6883,9 @@ def test_neutralize_orphaned_tool_blocks_logs_warning(caplog): messages = _orphaned_tool_history_messages() with caplog.at_level("WARNING"): - AmazonConverseConfig._neutralize_orphaned_tool_blocks( - messages, optional_params={} - ) + AmazonConverseConfig._neutralize_orphaned_tool_blocks(messages, optional_params={}) - assert any( - "neutralizing orphaned tool blocks" in record.getMessage() - for record in caplog.records - ) + assert any("neutralizing orphaned tool blocks" in record.getMessage() for record in caplog.records) def _assert_no_structured_tool_blocks(result): @@ -7212,9 +7003,7 @@ def test_transform_request_neutralized_tool_output_is_guarded(monkeypatch): }, {"role": "tool", "tool_call_id": "c1", "content": "secret tool output"}, ], - optional_params={ - "guardrailConfig": {"guardrailIdentifier": "gid", "guardrailVersion": "1"} - }, + optional_params={"guardrailConfig": {"guardrailIdentifier": "gid", "guardrailVersion": "1"}}, litellm_params={}, headers={}, ) @@ -7254,23 +7043,19 @@ def test_transform_request_neutralized_tool_output_guarded_mid_history(monkeypat {"role": "assistant", "content": "Here is the summary."}, {"role": "user", "content": "thanks"}, ], - optional_params={ - "guardrailConfig": {"guardrailIdentifier": "gid", "guardrailVersion": "1"} - }, + optional_params={"guardrailConfig": {"guardrailIdentifier": "gid", "guardrailVersion": "1"}}, litellm_params={}, headers={}, ) _assert_no_structured_tool_blocks(result) blocks = [block for message in result["messages"] for block in message["content"]] - guarded_texts = [ - block["guardContent"]["text"]["text"] for block in blocks if "guardContent" in block - ] + guarded_texts = [block["guardContent"]["text"]["text"] for block in blocks if "guardContent" in block] plain_texts = [block["text"] for block in blocks if "text" in block and "guardContent" not in block] assert any("malware" in text for text in guarded_texts), "mid-history tool output must be guarded" - assert not any( - "malware" in text for text in plain_texts - ), "mid-history tool output must not reach the model as unguarded text" + assert not any("malware" in text for text in plain_texts), ( + "mid-history tool output must not reach the model as unguarded text" + ) @pytest.mark.asyncio @@ -7406,10 +7191,7 @@ def _agentic_messages_with_ttl(ttl_target: str): def _collect_cache_points(result): return [ - block["cachePoint"] - for message in result - for block in message.get("content") or [] - if "cachePoint" in block + block["cachePoint"] for message in result for block in message.get("content") or [] if "cachePoint" in block ] @@ -7435,12 +7217,10 @@ async def test_message_level_cache_control_honors_ttl_for_supported_model( model="global.anthropic.claude-opus-4-7", llm_provider="bedrock_converse", ) - async_result = ( - await BedrockConverseMessagesProcessor._bedrock_converse_messages_pt_async( - messages=messages, - model="global.anthropic.claude-opus-4-7", - llm_provider="bedrock_converse", - ) + async_result = await BedrockConverseMessagesProcessor._bedrock_converse_messages_pt_async( + messages=messages, + model="global.anthropic.claude-opus-4-7", + llm_provider="bedrock_converse", ) assert result == async_result @@ -7752,9 +7532,7 @@ def test_update_optional_params_with_thinking_tokens_bool_thinking_does_not_cras ("us.anthropic.claude-opus-4-8", False), ], ) -def test_disabled_thinking_omitted_for_always_on_models_converse( - local_model_cost_map, model, expected_dropped -): +def test_disabled_thinking_omitted_for_always_on_models_converse(local_model_cost_map, model, expected_dropped): """Bedrock Converse: ``thinking={"type": "disabled"}`` is omitted for always-on-thinking models and forwarded verbatim for models that accept it.""" config = AmazonConverseConfig() @@ -7773,6 +7551,7 @@ def test_disabled_thinking_omitted_for_always_on_models_converse( else: assert additional.get("thinking") == {"type": "disabled"} + @pytest.mark.parametrize( "model", ["anthropic.claude-fable-5-1", "us.anthropic.claude-fable-5-1"], @@ -7781,14 +7560,10 @@ def test_disabled_thinking_omitted_for_always_on_models_converse( "tool_choice", ["required", {"type": "function", "function": {"name": "get_weather"}}], ) -def test_forced_tool_choice_downgraded_to_auto_on_fable_5_1_converse( - local_model_cost_map, model, tool_choice -): +def test_forced_tool_choice_downgraded_to_auto_on_fable_5_1_converse(local_model_cost_map, model, tool_choice): config = AmazonConverseConfig() - result = config.map_tool_choice_values( - model=model, tool_choice=tool_choice, drop_params=True - ) + result = config.map_tool_choice_values(model=model, tool_choice=tool_choice, drop_params=True) assert result == {"auto": {}} @@ -7797,16 +7572,12 @@ def test_forced_tool_choice_downgraded_to_auto_on_fable_5_1_converse( "tool_choice", ["required", {"type": "function", "function": {"name": "get_weather"}}], ) -def test_forced_tool_choice_raises_clean_error_on_fable_5_1_converse( - local_model_cost_map, tool_choice, monkeypatch -): +def test_forced_tool_choice_raises_clean_error_on_fable_5_1_converse(local_model_cost_map, tool_choice, monkeypatch): monkeypatch.setattr(litellm, "drop_params", False) config = AmazonConverseConfig() with pytest.raises(litellm.utils.UnsupportedParamsError, match="forced tool use"): - config.map_tool_choice_values( - model="anthropic.claude-fable-5-1", tool_choice=tool_choice, drop_params=False - ) + config.map_tool_choice_values(model="anthropic.claude-fable-5-1", tool_choice=tool_choice, drop_params=False) @pytest.mark.parametrize("tool_choice", ["auto", "none"]) @@ -7854,15 +7625,11 @@ def test_response_format_avoids_native_and_forced_tool_choice_on_fable_5_1_conve assert result.get("json_mode") is True -def test_forced_tool_choice_forwarded_on_converse_models_that_support_it( - local_model_cost_map, monkeypatch -): +def test_forced_tool_choice_forwarded_on_converse_models_that_support_it(local_model_cost_map, monkeypatch): monkeypatch.setattr(litellm, "drop_params", False) config = AmazonConverseConfig() - result = config.map_tool_choice_values( - model="anthropic.claude-fable-5", tool_choice="required", drop_params=False - ) + result = config.map_tool_choice_values(model="anthropic.claude-fable-5", tool_choice="required", drop_params=False) assert result == {"any": {}} @@ -8210,7 +7977,11 @@ def _preserved_thinking_turns(reminder_after_user: bool) -> tuple[list[dict], li second_question = {"role": "user", "content": "Second question"} second_turn = [second_question, reminder] if reminder_after_user else [reminder, second_question] turn_n_plus_one = [*turn_n, _thinking_reply("First answer"), *second_turn] - turn_n_plus_two = [*turn_n_plus_one, _thinking_reply("Second answer"), {"role": "user", "content": "Third question"}] + turn_n_plus_two = [ + *turn_n_plus_one, + _thinking_reply("Second answer"), + {"role": "user", "content": "Third question"}, + ] return turn_n, turn_n_plus_one, turn_n_plus_two @@ -8342,6 +8113,9 @@ def test_supports_sampling_params_prefixed_and_anthropic_fallback(monkeypatch: p assert AmazonConverseConfig._supports_sampling_params("anthropic.claude-custom-unregistered") is True + + + def _converse_params( model: str, model_info: Mapping[str, object] | None, non_default_params: Mapping[str, object] ) -> Mapping[str, object]: @@ -8414,13 +8188,20 @@ def test_uses_opt_in_native_output_reads_the_base_model_of_an_application_infere _CALLER_OUTPUT_CONFIG_WITH_A_SCHEMA_OBJECT: Final = { - "textFormat": {"type": "json_schema", "structure": {"jsonSchema": {"name": "capital", "schema": {"type": "object"}}}} + "textFormat": { + "type": "json_schema", + "structure": {"jsonSchema": {"name": "capital", "schema": {"type": "object"}}}, + } } _BEDROCK_SCHEMA_TYPE_ERROR: Final = "outputConfig.textFormat.structure.jsonSchema.schema must be a string" +_OPUS: Final = "us.anthropic.claude-opus-4-8" +_LLAMA: Final = "us.meta.llama3-3-70b-instruct-v1:0" + + @pytest.mark.parametrize("model", [_OPUS, _LLAMA], ids=["opt_in", "not_opt_in"]) @pytest.mark.respx(assert_all_called=True) def test_a_malformed_caller_output_config_gets_bedrocks_own_400( @@ -8492,4 +8273,3 @@ def test_a_refused_native_schema_is_sent_again_only_when_the_retry_drops_native_ assert route.call_count == expected_calls assert all("outputConfig" in json.loads(call.request.content) for call in route.calls[:1]) assert all("outputConfig" not in json.loads(call.request.content) for call in route.calls[1:]) - diff --git a/tests/unit/llms/bedrock/files/test_bedrock_files_transformation.py b/tests/unit/llms/bedrock/files/test_bedrock_files_transformation.py index 0071928bdc6..fc74d77205b 100644 --- a/tests/unit/llms/bedrock/files/test_bedrock_files_transformation.py +++ b/tests/unit/llms/bedrock/files/test_bedrock_files_transformation.py @@ -34,9 +34,7 @@ class TestBedrockFilesTransformation: transformation = BedrockJsonlFilesTransformation() # Load input JSONL file - input_file_path = os.path.join( - os.path.dirname(__file__), "input_batch_completions.jsonl" - ) + input_file_path = os.path.join(os.path.dirname(__file__), "input_batch_completions.jsonl") # Read and parse the JSONL content openai_jsonl_content = [] @@ -124,28 +122,20 @@ class TestBedrockFilesTransformation: model_input = record["modelInput"] # Must have inferenceConfig with maxTokens, NOT top-level max_tokens - assert ( - "inferenceConfig" in model_input - ), "Nova modelInput must contain inferenceConfig" + assert "inferenceConfig" in model_input, "Nova modelInput must contain inferenceConfig" assert model_input["inferenceConfig"]["maxTokens"] == 50 assert model_input["inferenceConfig"]["temperature"] == 0.7 - assert ( - "max_tokens" not in model_input - ), "max_tokens must NOT be at the top level for Nova" - assert ( - "temperature" not in model_input - ), "temperature must NOT be at the top level for Nova" + assert "max_tokens" not in model_input, "max_tokens must NOT be at the top level for Nova" + assert "temperature" not in model_input, "temperature must NOT be at the top level for Nova" # Must have messages assert "messages" in model_input # Nova Pro rejects empty additionalModelRequestFields / system — they must be absent - assert ( - "additionalModelRequestFields" not in model_input - ), "Nova: empty additionalModelRequestFields must be omitted, not serialized as {}" - assert ( - "system" not in model_input - ), "Nova: empty system must be omitted, not serialized as []" + assert "additionalModelRequestFields" not in model_input, ( + "Nova: empty additionalModelRequestFields must be omitted, not serialized as {}" + ) + assert "system" not in model_input, "Nova: empty system must be omitted, not serialized as []" def test_nova_batch_jsonl_omits_empty_converse_fields(self): """ @@ -218,10 +208,7 @@ class TestBedrockFilesTransformation: config = BedrockFilesConfig() # 1x1 transparent PNG - img_b64 = ( - "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4" - "2mP8z8BQDwADhQGAWjR9awAAAABJRU5ErkJggg==" - ) + img_b64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwADhQGAWjR9awAAAABJRU5ErkJggg==" openai_jsonl_content = [ { @@ -237,9 +224,7 @@ class TestBedrockFilesTransformation: {"type": "text", "text": "Describe this image."}, { "type": "image_url", - "image_url": { - "url": "data:image/png;base64," + img_b64 - }, + "image_url": {"url": "data:image/png;base64," + img_b64}, }, ], } @@ -277,16 +262,10 @@ class TestBedrockFilesTransformation: # Verify Converse image format assert "format" in block["image"], "Image block must have format field" assert "source" in block["image"], "Image block must have source field" - assert ( - "bytes" in block["image"]["source"] - ), "Image source must have bytes field" + assert "bytes" in block["image"]["source"], "Image source must have bytes field" # Must NOT have OpenAI-style image_url - assert ( - "image_url" not in block - ), "image_url must not appear in Converse format" - assert ( - block.get("type") != "image_url" - ), "type=image_url must not appear in Converse format" + assert "image_url" not in block, "image_url must not appear in Converse format" + assert block.get("type") != "image_url", "type=image_url must not appear in Converse format" assert has_text, "Should have a text content block" assert has_image, "Should have an image content block" @@ -371,9 +350,7 @@ class TestBedrockFilesTransformation: ) assert "us-gov-west-1" in url, f"Expected us-gov-west-1 in URL but got: {url}" - assert ( - "us-west-2" not in url - ), f"us-west-2 must not appear when s3_region_name is set, got: {url}" + assert "us-west-2" not in url, f"us-west-2 must not appear when s3_region_name is set, got: {url}" assert "litellm-batch-352026" in url def test_get_complete_file_url_sanitizes_untrusted_filename(self): @@ -405,9 +382,7 @@ class TestBedrockFilesTransformation: from litellm.llms.bedrock.files.transformation import BedrockFilesConfig config = BedrockFilesConfig() - object_name = config._get_s3_object_name_from_batch_jsonl( - [{"body": {"model": "bedrock/../../secret:model"}}] - ) + object_name = config._get_s3_object_name_from_batch_jsonl([{"body": {"model": "bedrock/../../secret:model"}}]) assert object_name.startswith("litellm-bedrock-files-") assert object_name.endswith(".jsonl") @@ -462,9 +437,9 @@ class TestBedrockFilesTransformation: litellm_params=litellm_params, ) - assert ( - captured_optional_params.get("aws_region_name") == "us-gov-west-1" - ), "s3_region_name must be forwarded as aws_region_name for SigV4 signing" + assert captured_optional_params.get("aws_region_name") == "us-gov-west-1", ( + "s3_region_name must be forwarded as aws_region_name for SigV4 signing" + ) def test_s3_region_name_wins_over_aws_region_name_for_signing(self): """ @@ -518,9 +493,9 @@ class TestBedrockFilesTransformation: litellm_params=litellm_params, ) - assert ( - captured_optional_params.get("aws_region_name") == "us-gov-west-1" - ), "s3_region_name must override aws_region_name for SigV4 signing" + assert captured_optional_params.get("aws_region_name") == "us-gov-west-1", ( + "s3_region_name must override aws_region_name for SigV4 signing" + ) def _signed_upload_request(self, litellm_params: dict) -> dict: from litellm.llms.bedrock.files.transformation import BedrockFilesConfig @@ -1115,9 +1090,9 @@ class TestBedrockFilesEmbeddingTransformation: } ] ) - assert result[0]["modelInput"] == { - "inputText": "Hi" - }, f"model id {model_id} did not route to Titan v2 embedding path" + assert result[0]["modelInput"] == {"inputText": "Hi"}, ( + f"model id {model_id} did not route to Titan v2 embedding path" + ) def test_pretokenized_input_list_of_ints_raises(self): """`input: List[int]` (pre-tokenized) is rejected, not silently mis-shaped.""" @@ -1187,7 +1162,6 @@ class TestBedrockFilesEmbeddingTransformation: assert "messages" in result[0]["modelInput"] assert "inputText" not in result[0]["modelInput"] - def test_titan_v2_marker_boundary_rejects_lookalikes(self): """The marker must end at `:`, `/`, or end-of-string to avoid false positives.""" from litellm.llms.bedrock.files.transformation import BedrockFilesConfig @@ -1198,9 +1172,9 @@ class TestBedrockFilesEmbeddingTransformation: "bedrock/amazon.titan-embed-text-v2-experimental:0", "bedrock/amazon.titan-embed-text-v2foo", ): - assert not BedrockFilesConfig._is_titan_v2_embed_model( - model - ), f"{model} unexpectedly matched the Titan v2 marker" + assert not BedrockFilesConfig._is_titan_v2_embed_model(model), ( + f"{model} unexpectedly matched the Titan v2 marker" + ) # Real Titan v2 ids that MUST match for model in ( @@ -1209,9 +1183,9 @@ class TestBedrockFilesEmbeddingTransformation: "us.amazon.titan-embed-text-v2:0", "arn:aws:bedrock:us-east-1:123:foundation-model/amazon.titan-embed-text-v2:0", ): - assert BedrockFilesConfig._is_titan_v2_embed_model( - model - ), f"{model} unexpectedly missed the Titan v2 marker" + assert BedrockFilesConfig._is_titan_v2_embed_model(model), ( + f"{model} unexpectedly missed the Titan v2 marker" + ) def test_titan_v2_accepted_when_registry_schema_field_matches(self, mocker): """Registry-driven happy path: nested @@ -1221,13 +1195,9 @@ class TestBedrockFilesEmbeddingTransformation: mocker.patch( "litellm.get_model_info", - return_value={ - "provider_specific_entry": {"bedrock_invocation_schema": "titan_v2"} - }, - ) - assert BedrockFilesConfig._is_titan_v2_embed_model( - "amazon.titan-embed-text-v2:0" + return_value={"provider_specific_entry": {"bedrock_invocation_schema": "titan_v2"}}, ) + assert BedrockFilesConfig._is_titan_v2_embed_model("amazon.titan-embed-text-v2:0") def test_titan_v2_rejected_when_registry_schema_field_differs(self, mocker): """Registry resolves with a different schema value (e.g. a hypothetical @@ -1237,19 +1207,13 @@ class TestBedrockFilesEmbeddingTransformation: mocker.patch( "litellm.get_model_info", - return_value={ - "provider_specific_entry": {"bedrock_invocation_schema": "cohere_v3"} - }, + return_value={"provider_specific_entry": {"bedrock_invocation_schema": "cohere_v3"}}, ) # Even though the model id looks like Titan v2, the registry says # otherwise and we trust it. - assert not BedrockFilesConfig._is_titan_v2_embed_model( - "amazon.titan-embed-text-v2:0" - ) + assert not BedrockFilesConfig._is_titan_v2_embed_model("amazon.titan-embed-text-v2:0") - def test_titan_v2_falls_back_to_marker_when_registry_lacks_schema_field( - self, mocker - ): + def test_titan_v2_falls_back_to_marker_when_registry_lacks_schema_field(self, mocker): """Registry resolves but the entry has no `provider_specific_entry.bedrock_invocation_schema` field yet (e.g. a stale local registry) -> fall through to substring.""" @@ -1260,9 +1224,7 @@ class TestBedrockFilesEmbeddingTransformation: "litellm.get_model_info", return_value={"mode": "embedding"}, ) - assert BedrockFilesConfig._is_titan_v2_embed_model( - "amazon.titan-embed-text-v2:0" - ) + assert BedrockFilesConfig._is_titan_v2_embed_model("amazon.titan-embed-text-v2:0") # provider_specific_entry present but missing the schema key mocker.patch( @@ -1272,9 +1234,7 @@ class TestBedrockFilesEmbeddingTransformation: "provider_specific_entry": {"unrelated": "value"}, }, ) - assert BedrockFilesConfig._is_titan_v2_embed_model( - "amazon.titan-embed-text-v2:0" - ) + assert BedrockFilesConfig._is_titan_v2_embed_model("amazon.titan-embed-text-v2:0") def test_titan_v2_accepted_when_registry_silent(self, mocker): """Marker-only match is fine for ids the registry can't resolve @@ -1282,9 +1242,7 @@ class TestBedrockFilesEmbeddingTransformation: from litellm.llms.bedrock.files.transformation import BedrockFilesConfig mocker.patch("litellm.get_model_info", side_effect=Exception("not mapped")) - assert BedrockFilesConfig._is_titan_v2_embed_model( - "us.amazon.titan-embed-text-v2:0" - ) + assert BedrockFilesConfig._is_titan_v2_embed_model("us.amazon.titan-embed-text-v2:0") assert BedrockFilesConfig._is_titan_v2_embed_model( "arn:aws:bedrock:us-east-1:123:foundation-model/amazon.titan-embed-text-v2:0" ) @@ -1296,87 +1254,49 @@ class TestBedrockFilesEmbeddingTransformation: # Happy path: returns the nested field's string value mocker.patch( "litellm.get_model_info", - return_value={ - "provider_specific_entry": {"bedrock_invocation_schema": "titan_v2"} - }, - ) - assert ( - BedrockFilesConfig._lookup_provider_specific_field( - "anything", "bedrock_invocation_schema" - ) - == "titan_v2" + return_value={"provider_specific_entry": {"bedrock_invocation_schema": "titan_v2"}}, ) + assert BedrockFilesConfig._lookup_provider_specific_field("anything", "bedrock_invocation_schema") == "titan_v2" # Registry raises -> None mocker.patch("litellm.get_model_info", side_effect=Exception("not mapped")) - assert ( - BedrockFilesConfig._lookup_provider_specific_field("anything", "any") - is None - ) + assert BedrockFilesConfig._lookup_provider_specific_field("anything", "any") is None # Registry returns non-dict -> None mocker.patch("litellm.get_model_info", return_value="not a dict") - assert ( - BedrockFilesConfig._lookup_provider_specific_field("anything", "any") - is None - ) + assert BedrockFilesConfig._lookup_provider_specific_field("anything", "any") is None # Registry returns dict without provider_specific_entry -> None mocker.patch("litellm.get_model_info", return_value={"mode": "embedding"}) - assert ( - BedrockFilesConfig._lookup_provider_specific_field( - "anything", "bedrock_invocation_schema" - ) - is None - ) + assert BedrockFilesConfig._lookup_provider_specific_field("anything", "bedrock_invocation_schema") is None # provider_specific_entry exists but isn't a dict -> None mocker.patch( "litellm.get_model_info", return_value={"provider_specific_entry": "not a dict"}, ) - assert ( - BedrockFilesConfig._lookup_provider_specific_field( - "anything", "bedrock_invocation_schema" - ) - is None - ) + assert BedrockFilesConfig._lookup_provider_specific_field("anything", "bedrock_invocation_schema") is None # provider_specific_entry dict missing the requested field -> None mocker.patch( "litellm.get_model_info", return_value={"provider_specific_entry": {"unrelated": "x"}}, ) - assert ( - BedrockFilesConfig._lookup_provider_specific_field( - "anything", "bedrock_invocation_schema" - ) - is None - ) + assert BedrockFilesConfig._lookup_provider_specific_field("anything", "bedrock_invocation_schema") is None # Non-string nested value -> None mocker.patch( "litellm.get_model_info", return_value={"provider_specific_entry": {"bedrock_invocation_schema": 42}}, ) - assert ( - BedrockFilesConfig._lookup_provider_specific_field( - "anything", "bedrock_invocation_schema" - ) - is None - ) + assert BedrockFilesConfig._lookup_provider_specific_field("anything", "bedrock_invocation_schema") is None # Empty-string nested value -> None mocker.patch( "litellm.get_model_info", return_value={"provider_specific_entry": {"bedrock_invocation_schema": ""}}, ) - assert ( - BedrockFilesConfig._lookup_provider_specific_field( - "anything", "bedrock_invocation_schema" - ) - is None - ) + assert BedrockFilesConfig._lookup_provider_specific_field("anything", "bedrock_invocation_schema") is None def test_classify_batch_record_helper(self): """Helper classifies by `url` first, then by body shape.""" @@ -1384,28 +1304,18 @@ class TestBedrockFilesEmbeddingTransformation: from litellm.types.llms.bedrock import BedrockBatchRecordKind assert ( - BedrockFilesConfig._classify_batch_record( - {"url": "/v1/embeddings", "body": {"input": "x"}} - ) + BedrockFilesConfig._classify_batch_record({"url": "/v1/embeddings", "body": {"input": "x"}}) is BedrockBatchRecordKind.EMBEDDING ) # body-only fallback - assert ( - BedrockFilesConfig._classify_batch_record({"body": {"input": "x"}}) - is BedrockBatchRecordKind.EMBEDDING - ) + assert BedrockFilesConfig._classify_batch_record({"body": {"input": "x"}}) is BedrockBatchRecordKind.EMBEDDING # chat shape assert ( - BedrockFilesConfig._classify_batch_record( - {"url": "/v1/chat/completions", "body": {"messages": []}} - ) + BedrockFilesConfig._classify_batch_record({"url": "/v1/chat/completions", "body": {"messages": []}}) is BedrockBatchRecordKind.CHAT ) # ambiguous body without any recognized key is treated as chat - assert ( - BedrockFilesConfig._classify_batch_record({"body": {}}) - is BedrockBatchRecordKind.CHAT - ) + assert BedrockFilesConfig._classify_batch_record({"body": {}}) is BedrockBatchRecordKind.CHAT @pytest.mark.parametrize("body", ["not a mapping", ["messages"], 7, None], ids=["str", "list", "int", "missing"]) def test_classify_batch_record_falls_back_to_chat_for_non_mapping_body(self, body): @@ -1445,7 +1355,6 @@ class TestBedrockFilesEmbeddingTransformation: transformer, corrupting the modelInput. """ from litellm.llms.bedrock.files.transformation import BedrockFilesConfig - from litellm.types.llms.bedrock import BedrockBatchRecordKind # Direct helper assertion @@ -1483,9 +1392,7 @@ class TestBedrockFilesEmbeddingTransformation: ) model_input = result[0]["modelInput"] - assert ( - "inputText" not in model_input - ), "explicit chat URL must not produce an embedding-shaped modelInput" + assert "inputText" not in model_input, "explicit chat URL must not produce an embedding-shaped modelInput" def test_coerce_embedding_input_helper_isolated(self): """Direct coverage of the extracted input-normalization helper.""" @@ -1495,9 +1402,7 @@ class TestBedrockFilesEmbeddingTransformation: # Happy paths assert BedrockFilesConfig._coerce_embedding_input_to_string("hello") == "hello" - assert ( - BedrockFilesConfig._coerce_embedding_input_to_string(["hello"]) == "hello" - ) + assert BedrockFilesConfig._coerce_embedding_input_to_string(["hello"]) == "hello" # Error paths with pytest.raises(ValueError, match="missing required `input`"): @@ -1525,22 +1430,16 @@ class TestBedrockFilesEmbeddingTransformation: # /v1/completions (legacy completions endpoint) assert ( - BedrockFilesConfig._classify_batch_record( - {"url": "/v1/completions", "body": {"input": "x"}} - ) + BedrockFilesConfig._classify_batch_record({"url": "/v1/completions", "body": {"input": "x"}}) is BedrockBatchRecordKind.TEXT_COMPLETION ) assert ( - BedrockFilesConfig._classify_batch_record( - {"url": "/v1/responses", "body": {"input": "x"}} - ) + BedrockFilesConfig._classify_batch_record({"url": "/v1/responses", "body": {"input": "x"}}) is BedrockBatchRecordKind.RESPONSES ) # Arbitrary unknown url - caller's explicit signal still wins assert ( - BedrockFilesConfig._classify_batch_record( - {"url": "/v1/moderations", "body": {"input": "x"}} - ) + BedrockFilesConfig._classify_batch_record({"url": "/v1/moderations", "body": {"input": "x"}}) is BedrockBatchRecordKind.CHAT ) @@ -1692,7 +1591,10 @@ class TestBedrockBatchNonChatEndpointRecords: assert model_input["messages"][0]["content"] == [{"type": "text", "text": "Weather in Paris?"}] tool = model_input["tools"][0] function = tool.get("function", tool) - assert (function["name"], function.get("parameters", function.get("input_schema"))) == ("get_weather", parameters) + assert (function["name"], function.get("parameters", function.get("input_schema"))) == ( + "get_weather", + parameters, + ) @pytest.mark.parametrize( ("url", "body"), @@ -1850,9 +1752,7 @@ class TestBedrockBatchNonChatEndpointRecords: "embedding", ] for record in result[:3]: - assert record["modelInput"]["messages"] == [ - {"role": "user", "content": [{"type": "text", "text": "hi"}]} - ] + assert record["modelInput"]["messages"] == [{"role": "user", "content": [{"type": "text", "text": "hi"}]}] assert result[3]["modelInput"] == {"inputText": "hi"} @pytest.mark.parametrize( @@ -1897,9 +1797,7 @@ class TestBedrockBatchNonChatEndpointRecords: } ) - assert model_input["messages"] == [ - {"role": "user", "content": [{"type": "text", "text": "from messages"}]} - ] + assert model_input["messages"] == [{"role": "user", "content": [{"type": "text", "text": "from messages"}]}] class TestBedrockBatchAnthropicRowParams: @@ -1980,7 +1878,10 @@ class TestBedrockBatchAnthropicRowParams: @pytest.mark.parametrize( ("url", "body"), [ - ("/v1/chat/completions", {"messages": [{"role": "user", "content": "17 * 23?"}], "reasoning_effort": "low"}), + ( + "/v1/chat/completions", + {"messages": [{"role": "user", "content": "17 * 23?"}], "reasoning_effort": "low"}, + ), ("/v1/responses", {"input": "17 * 23?", "reasoning": {"effort": "low"}}), ], ids=["chat", "responses"], @@ -2140,16 +2041,24 @@ class TestBedrockFileDeletion: with respx.mock, closing(HTTPHandler()) as client: route: Final = respx.delete(self.URL).mock(return_value=httpx.Response(204)) deleted: Final = litellm.file_delete( - file_id=self.S3_URI, custom_llm_provider="bedrock", client=client, - aws_access_key_id="AKIAEXAMPLE", aws_secret_access_key="test-secret", aws_region_name="us-west-2", + file_id=self.S3_URI, + custom_llm_provider="bedrock", + client=client, + aws_access_key_id="AKIAEXAMPLE", + aws_secret_access_key="test-secret", + aws_region_name="us-west-2", ) assert route.call_count == 1 request: Final = route.calls[0].request assert request.content == b"" - signed: Final = AWSRequest(method="DELETE", url=self.URL, headers={ - "X-Amz-Date": request.headers["X-Amz-Date"], - "X-Amz-Content-SHA256": request.headers["X-Amz-Content-SHA256"], - }) + signed: Final = AWSRequest( + method="DELETE", + url=self.URL, + headers={ + "X-Amz-Date": request.headers["X-Amz-Date"], + "X-Amz-Content-SHA256": request.headers["X-Amz-Content-SHA256"], + }, + ) signed.context["timestamp"] = request.headers["X-Amz-Date"] auth: Final = S3SigV4Auth(Credentials("AKIAEXAMPLE", "test-secret"), "s3", "us-west-2") signature: Final = auth.signature(auth.string_to_sign(signed, auth.canonical_request(signed)), signed) @@ -2177,15 +2086,22 @@ class TestBedrockFileDeletion: with pytest.raises(BedrockError, match="AccessDenied"): await litellm.afile_delete( - file_id=self.S3_URI, custom_llm_provider="bedrock", client=client, - aws_access_key_id="AKIAEXAMPLE", aws_secret_access_key="test-secret", aws_region_name="us-west-2", + file_id=self.S3_URI, + custom_llm_provider="bedrock", + client=client, + aws_access_key_id="AKIAEXAMPLE", + aws_secret_access_key="test-secret", + aws_region_name="us-west-2", ) assert route.call_count == 1 - @pytest.mark.parametrize("file_id, message", [ - ("s3://other-bucket/litellm-bedrock-files-model-abc.jsonl", "configured storage bucket"), - ("s3://my-bucket/private/data.jsonl", "LiteLLM-managed"), - ]) + @pytest.mark.parametrize( + "file_id, message", + [ + ("s3://other-bucket/litellm-bedrock-files-model-abc.jsonl", "configured storage bucket"), + ("s3://my-bucket/private/data.jsonl", "LiteLLM-managed"), + ], + ) def test_delete_rejects_untrusted_objects_before_signing( self, file_id: str, message: str, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -2233,11 +2149,7 @@ class TestBedrockFileContentTransformation: assert params == {} signed_headers = litellm_params[S3_SIGNED_REQUEST_HEADERS_PARAM] - content_hashes = { - value - for name, value in signed_headers.items() - if name.lower() == "x-amz-content-sha256" - } + content_hashes = {value for name, value in signed_headers.items() if name.lower() == "x-amz-content-sha256"} assert content_hashes == {hashlib.sha256(b"").hexdigest()}, ( "GET has no payload, so the content hash must be the empty-body hash." " The header name is matched case-insensitively because botocore picks" @@ -2260,9 +2172,7 @@ class TestBedrockFileContentTransformation: unified_file_id = SpecialEnums.LITELLM_MANAGED_FILE_COMPLETE_STR.value.format( "application/json", "unified-id", "", self.S3_URI, "model-id" ) - encoded_file_id = ( - base64.urlsafe_b64encode(unified_file_id.encode()).decode().rstrip("=") - ) + encoded_file_id = base64.urlsafe_b64encode(unified_file_id.encode()).decode().rstrip("=") url, _ = BedrockFilesConfig().transform_file_content_request( file_content_request={"file_id": encoded_file_id}, @@ -2280,16 +2190,13 @@ class TestBedrockFileContentTransformation: with pytest.raises(BedrockError, match="configured storage bucket") as rejection: BedrockFilesConfig().transform_file_content_request( - file_content_request={ - "file_id": "s3://other-bucket/litellm-batch-outputs/job/x.jsonl.out" - }, + file_content_request={"file_id": "s3://other-bucket/litellm-batch-outputs/job/x.jsonl.out"}, optional_params={}, litellm_params=self._litellm_params(), ) assert rejection.value.status_code == 400 - def test_transform_file_content_request_rejects_unmanaged_key(self, monkeypatch): from litellm.llms.bedrock.common_utils import BedrockError from litellm.llms.bedrock.files.transformation import BedrockFilesConfig @@ -2305,7 +2212,6 @@ class TestBedrockFileContentTransformation: assert rejection.value.status_code == 400 - def test_extract_s3_uri_rejects_non_managed_file_id(self): """A file id that is neither an s3:// URI nor a unified id must be rejected.""" from litellm.llms.bedrock.files.transformation import ( @@ -2315,9 +2221,7 @@ class TestBedrockFileContentTransformation: with pytest.raises(ValueError, match="managed LiteLLM S3 file id"): extract_s3_uri_from_file_id("file-1234567890") - def test_transform_file_content_request_requires_configured_bucket( - self, monkeypatch - ): + def test_transform_file_content_request_requires_configured_bucket(self, monkeypatch): """Without a server-configured bucket (env or snapshot), the request must fail before any S3 call rather than guessing a bucket from the file id.""" from litellm.llms.bedrock.files.transformation import BedrockFilesConfig @@ -2354,9 +2258,7 @@ class TestBedrockFileContentTransformation: from litellm.types.router import CredentialLiteLLMParams - snapshot = CredentialLiteLLMParams(**deployment_litellm_params).model_dump( - exclude_none=True - ) + snapshot = CredentialLiteLLMParams(**deployment_litellm_params).model_dump(exclude_none=True) params = self._litellm_params() params["_litellm_internal_model_credentials"] = MappingProxyType(snapshot) return params @@ -2373,19 +2275,12 @@ class TestBedrockFileContentTransformation: monkeypatch.delenv("AWS_S3_OUTPUT_BUCKET_NAME", raising=False) url, _ = BedrockFilesConfig().transform_file_content_request( - file_content_request={ - "file_id": "s3://out-bucket/litellm-batch-outputs/job/in.jsonl.out" - }, + file_content_request={"file_id": "s3://out-bucket/litellm-batch-outputs/job/in.jsonl.out"}, optional_params={}, - litellm_params=self._trusted( - s3_bucket_name="in-bucket", s3_output_bucket_name="out-bucket" - ), + litellm_params=self._trusted(s3_bucket_name="in-bucket", s3_output_bucket_name="out-bucket"), ) - assert ( - url - == "https://s3.us-west-2.amazonaws.com/out-bucket/litellm-batch-outputs/job/in.jsonl.out" - ) + assert url == "https://s3.us-west-2.amazonaws.com/out-bucket/litellm-batch-outputs/job/in.jsonl.out" def test_output_bucket_falls_back_to_env(self, monkeypatch): """The output bucket resolves from AWS_S3_OUTPUT_BUCKET_NAME when not in @@ -2396,17 +2291,12 @@ class TestBedrockFileContentTransformation: monkeypatch.setenv("AWS_S3_OUTPUT_BUCKET_NAME", "env-out-bucket") url, _ = BedrockFilesConfig().transform_file_content_request( - file_content_request={ - "file_id": "s3://env-out-bucket/litellm-batch-outputs/job/in.jsonl.out" - }, + file_content_request={"file_id": "s3://env-out-bucket/litellm-batch-outputs/job/in.jsonl.out"}, optional_params={}, litellm_params=self._litellm_params(), ) - assert ( - url - == "https://s3.us-west-2.amazonaws.com/env-out-bucket/litellm-batch-outputs/job/in.jsonl.out" - ) + assert url == "https://s3.us-west-2.amazonaws.com/env-out-bucket/litellm-batch-outputs/job/in.jsonl.out" def test_input_bucket_still_validates_when_output_bucket_set(self, monkeypatch): """Adding output-bucket support must not break retrieval of input-bucket @@ -2417,19 +2307,12 @@ class TestBedrockFileContentTransformation: monkeypatch.delenv("AWS_S3_OUTPUT_BUCKET_NAME", raising=False) url, _ = BedrockFilesConfig().transform_file_content_request( - file_content_request={ - "file_id": "s3://in-bucket/litellm-batch-outputs/job/in.jsonl.out" - }, + file_content_request={"file_id": "s3://in-bucket/litellm-batch-outputs/job/in.jsonl.out"}, optional_params={}, - litellm_params=self._trusted( - s3_bucket_name="in-bucket", s3_output_bucket_name="out-bucket" - ), + litellm_params=self._trusted(s3_bucket_name="in-bucket", s3_output_bucket_name="out-bucket"), ) - assert ( - url - == "https://s3.us-west-2.amazonaws.com/in-bucket/litellm-batch-outputs/job/in.jsonl.out" - ) + assert url == "https://s3.us-west-2.amazonaws.com/in-bucket/litellm-batch-outputs/job/in.jsonl.out" def test_rejects_bucket_outside_input_and_output(self, monkeypatch): """A file id whose bucket is neither the input nor the output bucket is @@ -2442,18 +2325,13 @@ class TestBedrockFileContentTransformation: with pytest.raises(BedrockError, match="configured storage bucket") as rejection: BedrockFilesConfig().transform_file_content_request( - file_content_request={ - "file_id": "s3://other-bucket/litellm-batch-outputs/job/x.jsonl.out" - }, + file_content_request={"file_id": "s3://other-bucket/litellm-batch-outputs/job/x.jsonl.out"}, optional_params={}, - litellm_params=self._trusted( - s3_bucket_name="in-bucket", s3_output_bucket_name="out-bucket" - ), + litellm_params=self._trusted(s3_bucket_name="in-bucket", s3_output_bucket_name="out-bucket"), ) assert rejection.value.status_code == 400 - def test_sign_request_without_botocore_raises_helpful_error(self, monkeypatch): """A missing botocore must surface an actionable 'install boto3' error rather than a raw import failure.""" @@ -2479,9 +2357,7 @@ class TestBedrockFileContentTransformation: monkeypatch.delenv("AWS_S3_BUCKET_NAME", raising=False) litellm_params = self._litellm_params() - litellm_params["_litellm_internal_model_credentials"] = MappingProxyType( - {"s3_bucket_name": "my-bucket"} - ) + litellm_params["_litellm_internal_model_credentials"] = MappingProxyType({"s3_bucket_name": "my-bucket"}) url, _ = BedrockFilesConfig().transform_file_content_request( file_content_request={"file_id": self.S3_URI}, @@ -2521,9 +2397,10 @@ class TestBedrockFileContentTransformation: s3_endpoint_url="https://bucket.vpce-abc.s3.us-east-1.vpce.amazonaws.com", ) - assert BedrockFilesConfig()._s3_request_target( - optional_params={}, litellm_params=lp - ).endpoint_url == "https://bucket.vpce-abc.s3.us-east-1.vpce.amazonaws.com" + assert ( + BedrockFilesConfig()._s3_request_target(optional_params={}, litellm_params=lp).endpoint_url + == "https://bucket.vpce-abc.s3.us-east-1.vpce.amazonaws.com" + ) def test_validate_environment_merges_and_pops_signed_get_headers(self): from litellm.llms.bedrock.files.transformation import ( @@ -2531,9 +2408,7 @@ class TestBedrockFileContentTransformation: BedrockFilesConfig, ) - litellm_params = { - S3_SIGNED_REQUEST_HEADERS_PARAM: {"Authorization": "AWS4-HMAC-SHA256 test"} - } + litellm_params = {S3_SIGNED_REQUEST_HEADERS_PARAM: {"Authorization": "AWS4-HMAC-SHA256 test"}} headers = BedrockFilesConfig().validate_environment( headers={"x-custom": "kept"}, @@ -2599,9 +2474,7 @@ class TestBedrockFileContentTransformation: monkeypatch.setenv("AWS_S3_BUCKET_NAME", "my-bucket") with respx.mock: - route = respx.get(self.EXPECTED_URL).mock( - return_value=httpx.Response(200, content=b'{"recordId": "x"}') - ) + route = respx.get(self.EXPECTED_URL).mock(return_value=httpx.Response(200, content=b'{"recordId": "x"}')) response = litellm.file_content( file_id=self.S3_URI, @@ -2629,9 +2502,7 @@ class TestBedrockFileContentTransformation: litellm.in_memory_llm_clients_cache.flush_cache() with respx.mock: - route = respx.get(self.EXPECTED_URL).mock( - return_value=httpx.Response(200, content=b'{"recordId": "x"}') - ) + route = respx.get(self.EXPECTED_URL).mock(return_value=httpx.Response(200, content=b'{"recordId": "x"}')) response = await litellm.afile_content( file_id=self.S3_URI, @@ -2640,11 +2511,7 @@ class TestBedrockFileContentTransformation: ) assert route.called - assert ( - route.calls[0] - .request.headers["Authorization"] - .startswith("AWS4-HMAC-SHA256") - ) + assert route.calls[0].request.headers["Authorization"].startswith("AWS4-HMAC-SHA256") assert response.content == b'{"recordId": "x"}' @@ -2678,9 +2545,7 @@ class TestBedrockFilesS3SignatureEncoding: headers: Mapping[str, str], ) -> str: sent = {name.lower(): value for name, value in headers.items()} - signed_names = ( - sent["authorization"].split("SignedHeaders=")[1].split(",")[0].split(";") - ) + signed_names = sent["authorization"].split("SignedHeaders=")[1].split(",")[0].split(";") request = AWSRequest( method=method, url=url, @@ -2688,12 +2553,8 @@ class TestBedrockFilesS3SignatureEncoding: headers={name: sent[name] for name in signed_names if name in sent}, ) request.context["timestamp"] = sent["x-amz-date"] - signer = signer_cls( - Credentials(self.ACCESS_KEY, self.SECRET_KEY), "s3", self.REGION - ) - return signer.signature( - signer.string_to_sign(request, signer.canonical_request(request)), request - ) + signer = signer_cls(Credentials(self.ACCESS_KEY, self.SECRET_KEY), "s3", self.REGION) + return signer.signature(signer.string_to_sign(request, signer.canonical_request(request)), request) def _assert_signed_the_way_s3_reads_it( self, @@ -2704,12 +2565,8 @@ class TestBedrockFilesS3SignatureEncoding: ) -> None: assert "%20" in url, "the object key must reach the wire percent-encoded" sent_signature = headers["Authorization"].split("Signature=")[1].strip() - assert sent_signature == self._signature_under( - S3SigV4Auth, method, url, body, headers - ) - assert sent_signature != self._signature_under( - SigV4Auth, method, url, body, headers - ) + assert sent_signature == self._signature_under(S3SigV4Auth, method, url, body, headers) + assert sent_signature != self._signature_under(SigV4Auth, method, url, body, headers) def test_create_file_signs_spaced_object_key_the_way_s3_does(self) -> None: from litellm.llms.bedrock.files.transformation import BedrockFilesConfig @@ -2743,9 +2600,7 @@ class TestBedrockFilesS3SignatureEncoding: headers=signed["headers"], ) - def test_file_content_signs_spaced_object_key_the_way_s3_does( - self, monkeypatch: pytest.MonkeyPatch - ) -> None: + def test_file_content_signs_spaced_object_key_the_way_s3_does(self, monkeypatch: pytest.MonkeyPatch) -> None: from litellm.llms.bedrock.files.transformation import ( S3_SIGNED_REQUEST_HEADERS_PARAM, BedrockFilesConfig, @@ -2759,9 +2614,7 @@ class TestBedrockFilesS3SignatureEncoding: } url, _ = BedrockFilesConfig().transform_file_content_request( - file_content_request={ - "file_id": "s3://my-bucket/LLM AI Projects/litellm-bedrock-files-model-abc.jsonl" - }, + file_content_request={"file_id": "s3://my-bucket/LLM AI Projects/litellm-bedrock-files-model-abc.jsonl"}, optional_params={}, litellm_params=litellm_params, ) @@ -3092,7 +2945,6 @@ class TestBedrockFileDeletionTransformation: assert rejection.value.status_code == 400 - def test_transform_delete_file_request_rejects_unmanaged_key(self, monkeypatch): from litellm.llms.bedrock.common_utils import BedrockError from litellm.llms.bedrock.files.transformation import BedrockFilesConfig @@ -3108,7 +2960,6 @@ class TestBedrockFileDeletionTransformation: assert rejection.value.status_code == 400 - def test_transform_delete_file_response_echoes_the_deleted_id(self): import httpx diff --git a/tests/unit/llms/bedrock/messages/invoke_transformations/test_anthropic_claude3_transformation.py b/tests/unit/llms/bedrock/messages/invoke_transformations/test_anthropic_claude3_transformation.py index 4e7817d5f6d..403884ee2a3 100644 --- a/tests/unit/llms/bedrock/messages/invoke_transformations/test_anthropic_claude3_transformation.py +++ b/tests/unit/llms/bedrock/messages/invoke_transformations/test_anthropic_claude3_transformation.py @@ -15,6 +15,7 @@ from unittest.mock import Mock import httpx import pytest +from pydantic import TypeAdapter # Ensure the project root is on the import path so `litellm` can be imported when # tests are executed from any working directory. @@ -25,6 +26,7 @@ from litellm.llms.bedrock.common_utils import ( normalize_custom_field_on_tools, normalize_tool_input_schema_types_for_bedrock_invoke, ) +import litellm from litellm.constants import ( BEDROCK_MIN_THINKING_BUDGET_TOKENS, DEFAULT_REASONING_EFFORT_HIGH_THINKING_BUDGET, @@ -3781,3 +3783,5 @@ async def test_bedrock_messages_400_is_not_retried_unless_it_rejects_opt_in_nati assert isinstance(raised, litellm.BadRequestError) assert len(bedrock.bodies) == 1 + +REASONING_BODY_ADAPTER: Final = TypeAdapter(dict[str, object]) diff --git a/tests/unit/llms/bedrock/test_bedrock_common_utils.py b/tests/unit/llms/bedrock/test_bedrock_common_utils.py index 868580ba9f2..7cc3319bd73 100644 --- a/tests/unit/llms/bedrock/test_bedrock_common_utils.py +++ b/tests/unit/llms/bedrock/test_bedrock_common_utils.py @@ -1,9 +1,15 @@ - -import asyncio, importlib, litellm, litellm.litellm_core_utils.get_model_cost_map as bedrock_govcloud_model_cost_map, pytest +import asyncio +import importlib +from collections.abc import Iterator from typing import Final +from unittest.mock import Mock, patch +import pytest -from litellm.llms.bedrock.common_utils import( +import litellm +import litellm.litellm_core_utils.get_model_cost_map as bedrock_govcloud_model_cost_map +from litellm import completion +from litellm.llms.bedrock.common_utils import ( AmazonBedrockGlobalConfig, BedrockModelInfo, bedrock_native_output_is_opt_in, @@ -16,11 +22,8 @@ from litellm.llms.bedrock.common_utils import( strip_bedrock_throughput_suffix, without_bedrock_native_output_opt_in, ) -from collections.abc import Iterator -from litellm import completion from litellm.llms.bedrock.count_tokens.bedrock_token_counter import BedrockTokenCounter from tests._vcr_conftest_common import install_live_call_probe, record_vcr_outcome -from unittest.mock import Mock, patch # --------------------------------------------------------------------------- # # get_bedrock_response_stream_shape lazy-load tests # @@ -48,9 +51,7 @@ def test_bedrock_response_stream_shape_lazy_loads_once(): import litellm.llms.bedrock.common_utils as mod sentinel = MagicMock() - with patch.object( - mod, "_load_bedrock_response_stream_shape", return_value=sentinel - ) as mock_load: + with patch.object(mod, "_load_bedrock_response_stream_shape", return_value=sentinel) as mock_load: assert mod.get_bedrock_response_stream_shape() is sentinel assert mod.get_bedrock_response_stream_shape() is sentinel mock_load.assert_called_once() @@ -97,9 +98,7 @@ def test_bedrock_response_stream_shape_is_structure_shape(): from litellm.llms.bedrock.common_utils import get_bedrock_response_stream_shape loaded_shape = get_bedrock_response_stream_shape() - assert ( - loaded_shape is not None - ), "get_bedrock_response_stream_shape() is None — botocore may not be installed" + assert loaded_shape is not None, "get_bedrock_response_stream_shape() is None — botocore may not be installed" shape: StructureShape = loaded_shape assert isinstance(shape, StructureShape) assert shape.name == "ResponseStream" @@ -164,9 +163,7 @@ def test_deepseek_cris(): Test that DeepSeek models with cross-region inference prefix use converse route """ bedrock_model_info = BedrockModelInfo - bedrock_route = bedrock_model_info.get_bedrock_route( - model="bedrock/us.deepseek.r1-v1:0" - ) + bedrock_route = bedrock_model_info.get_bedrock_route(model="bedrock/us.deepseek.r1-v1:0") assert bedrock_route == "converse" @@ -239,27 +236,19 @@ def test_govcloud_cross_region_inference_prefix(): bedrock_model_info = BedrockModelInfo # Test us-gov prefix is stripped correctly for Claude models - base_model = bedrock_model_info.get_base_model( - model="bedrock/us-gov.anthropic.claude-haiku-4-5-20251001-v1:0" - ) + base_model = bedrock_model_info.get_base_model(model="bedrock/us-gov.anthropic.claude-haiku-4-5-20251001-v1:0") assert base_model == "anthropic.claude-haiku-4-5-20251001-v1:0" # Test us-gov prefix is stripped correctly for different Claude versions - base_model = bedrock_model_info.get_base_model( - model="bedrock/us-gov.anthropic.claude-sonnet-4-5-20250929-v1:0" - ) + base_model = bedrock_model_info.get_base_model(model="bedrock/us-gov.anthropic.claude-sonnet-4-5-20250929-v1:0") assert base_model == "anthropic.claude-sonnet-4-5-20250929-v1:0" # Test us-gov prefix is stripped correctly for Haiku models - base_model = bedrock_model_info.get_base_model( - model="bedrock/us-gov.anthropic.claude-3-haiku-20240307-v1:0" - ) + base_model = bedrock_model_info.get_base_model(model="bedrock/us-gov.anthropic.claude-3-haiku-20240307-v1:0") assert base_model == "anthropic.claude-3-haiku-20240307-v1:0" # Test us-gov prefix is stripped correctly for Meta models - base_model = bedrock_model_info.get_base_model( - model="bedrock/us-gov.meta.llama3-8b-instruct-v1:0" - ) + base_model = bedrock_model_info.get_base_model(model="bedrock/us-gov.meta.llama3-8b-instruct-v1:0") assert base_model == "meta.llama3-8b-instruct-v1:0" @@ -273,23 +262,14 @@ def test_context_window_suffix_stripped_for_cost_lookup(): """ from litellm.llms.bedrock.common_utils import get_bedrock_base_model - assert ( - get_bedrock_base_model("us.anthropic.claude-opus-4-6-v1[1m]") - == "anthropic.claude-opus-4-6-v1" - ) - assert ( - get_bedrock_base_model("us.anthropic.claude-sonnet-4-6[1m]") - == "anthropic.claude-sonnet-4-6" - ) + assert get_bedrock_base_model("us.anthropic.claude-opus-4-6-v1[1m]") == "anthropic.claude-opus-4-6-v1" + assert get_bedrock_base_model("us.anthropic.claude-sonnet-4-6[1m]") == "anthropic.claude-sonnet-4-6" assert ( get_bedrock_base_model("global.anthropic.claude-opus-4-5-20251101-v1:0[1m]") == "anthropic.claude-opus-4-5-20251101-v1:0" ) # Ensure models without suffix are unaffected - assert ( - get_bedrock_base_model("us.anthropic.claude-opus-4-6-v1") - == "anthropic.claude-opus-4-6-v1" - ) + assert get_bedrock_base_model("us.anthropic.claude-opus-4-6-v1") == "anthropic.claude-opus-4-6-v1" # Ensure :51k throughput suffix still works assert ( get_bedrock_base_model("anthropic.claude-3-5-sonnet-20241022-v2:0:51k") @@ -339,9 +319,7 @@ def test_output_config_effort_normalization_uses_model_info_ceiling(monkeypatch) ("us.anthropic.claude-opus-4-7", "xhigh"), ], ) -def test_bundled_bedrock_opus_model_info_declares_output_config_effort_ceiling( - model, expected_ceiling -): +def test_bundled_bedrock_opus_model_info_declares_output_config_effort_ceiling(model, expected_ceiling): from litellm.litellm_core_utils.get_model_cost_map import GetModelCostMap model_info = GetModelCostMap.load_local_model_cost_map()[model] @@ -360,54 +338,24 @@ def test_route_prefix_matched_as_path_segment_not_substring(): or a ``/`` boundary. """ # The bedrock_mantle/ provider prefix must NOT be read as the mantle/ route. - assert ( - BedrockModelInfo.get_bedrock_route("bedrock_mantle/openai.gpt-5.5") != "mantle" - ) - assert ( - BedrockModelInfo.get_bedrock_route("bedrock_mantle/openai.gpt-5.4") == "converse" - ) - assert ( - BedrockModelInfo._explicit_mantle_route("bedrock_mantle/openai.gpt-5.5") - is False - ) + assert BedrockModelInfo.get_bedrock_route("bedrock_mantle/openai.gpt-5.5") != "mantle" + assert BedrockModelInfo.get_bedrock_route("bedrock_mantle/openai.gpt-5.4") == "converse" + assert BedrockModelInfo._explicit_mantle_route("bedrock_mantle/openai.gpt-5.5") is False # A genuine mantle route still resolves, via the startswith branch... - assert ( - BedrockModelInfo.get_bedrock_route("mantle/anthropic.claude-mythos-preview") - == "mantle" - ) + assert BedrockModelInfo.get_bedrock_route("mantle/anthropic.claude-mythos-preview") == "mantle" # ...and via the mid-path "/mantle/" branch (after the bedrock/ provider prefix). - assert ( - BedrockModelInfo.get_bedrock_route( - "bedrock/mantle/anthropic.claude-mythos-preview" - ) - == "mantle" - ) + assert BedrockModelInfo.get_bedrock_route("bedrock/mantle/anthropic.claude-mythos-preview") == "mantle" def test_model_has_route_prefix_exercises_both_branches(): """``_model_has_route_prefix`` matches on ``startswith`` or a ``/`` boundary only.""" # startswith branch - assert ( - BedrockModelInfo._model_has_route_prefix( - "mantle/anthropic.claude-mythos-preview", "mantle/" - ) - is True - ) + assert BedrockModelInfo._model_has_route_prefix("mantle/anthropic.claude-mythos-preview", "mantle/") is True # f"/{prefix}" boundary branch - assert ( - BedrockModelInfo._model_has_route_prefix( - "bedrock/mantle/anthropic.claude-mythos-preview", "mantle/" - ) - is True - ) + assert BedrockModelInfo._model_has_route_prefix("bedrock/mantle/anthropic.claude-mythos-preview", "mantle/") is True # neither branch: the token only appears glued to another segment - assert ( - BedrockModelInfo._model_has_route_prefix( - "bedrock_mantle/openai.gpt-5.5", "mantle/" - ) - is False - ) + assert BedrockModelInfo._model_has_route_prefix("bedrock_mantle/openai.gpt-5.5", "mantle/") is False @pytest.mark.parametrize( @@ -457,16 +405,10 @@ def test_explicit_invoke_route_does_not_match_async_invoke(): """ async_invoke_model = "async_invoke/twelvelabs.marengo-embed-2-7-v1:0" assert BedrockModelInfo._explicit_invoke_route(async_invoke_model) is False - assert ( - BedrockModelInfo._explicit_invoke_route(f"bedrock/{async_invoke_model}") - is False - ) + assert BedrockModelInfo._explicit_invoke_route(f"bedrock/{async_invoke_model}") is False # ...while async_invoke/ is still detected as its own route. assert BedrockModelInfo._explicit_async_invoke_route(async_invoke_model) is True - assert ( - BedrockModelInfo._explicit_async_invoke_route(f"bedrock/{async_invoke_model}") - is True - ) + assert BedrockModelInfo._explicit_async_invoke_route(f"bedrock/{async_invoke_model}") is True def test_capability_lookups_fall_back_to_base_model_when_regional_entry_lacks_field(monkeypatch): @@ -1010,7 +952,9 @@ def test_unmapped_openai_family_model_routes_to_converse(): _FORMAT_REJECTION: Final = "The model returned the following errors: output_config.format: Extra inputs are not permitted" -_COMPILE_REJECTION: Final = "The model returned the following errors: compilation exceeded limits: Schema is too complex." +_COMPILE_REJECTION: Final = ( + "The model returned the following errors: compilation exceeded limits: Schema is too complex." +) _SCHEMA_PATTERN_REJECTION: Final = ( @@ -1076,4 +1020,3 @@ def test_without_bedrock_native_output_opt_in_turns_the_opt_in_off_and_keeps_the assert turned_off["api_base"] == "https://bedrock.example" assert deployment_model_info(turned_off)["id"] == "deployment-1" assert litellm_params["model_info"] == {"id": "deployment-1"} - diff --git a/tests/unit/test_utils.py b/tests/unit/test_utils.py index 4bd30764197..fb58edd8840 100644 --- a/tests/unit/test_utils.py +++ b/tests/unit/test_utils.py @@ -2,11 +2,14 @@ import asyncio import base64 import contextlib import contextvars +import copy +import importlib import io import json import logging import os import queue +import re import threading from collections.abc import Callable, Iterator, Mapping from concurrent.futures import Future, ThreadPoolExecutor @@ -36,6 +39,7 @@ from litellm.integrations.custom_guardrail import CustomGuardrail from litellm.integrations.custom_logger import CustomLogger from litellm.litellm_core_utils.get_litellm_params import get_litellm_params from litellm.litellm_core_utils.thread_pool_executor import executor as logging_executor +from litellm.llms.base_llm.audio_transcription.transformation import BaseAudioTranscriptionConfig from litellm.llms.base_llm.base_model_iterator import MockResponseIterator from litellm.proxy.utils import is_valid_api_key from litellm.types.integrations.custom_logger import HEADROOM_CONVERTED_STREAM_KEY @@ -70,12 +74,19 @@ from litellm.utils import ( _is_streaming_request, _run_success_deployment_hook_on_converted_chat_stream, _snapshot_exception_for_hook, + add_custom_logger_callback_to_specific_event, async_post_call_failure_deployment_hook, async_post_call_success_deployment_hook, calculate_max_parallel_requests, + check_valid_key, client, + filter_out_litellm_params, + get_applied_guardrails, + get_llm_provider, get_non_default_completion_params, + get_optional_params_embeddings, get_optional_params_image_gen, + get_potential_model_names, get_prompt_cache_min_tokens, is_cached_message, is_prompt_caching_valid_prompt, @@ -3135,9 +3146,7 @@ class TestExtraBodyCannotOverrideModel: } ] - untouched = litellm.get_optional_params( - model="my-vllm-model", custom_llm_provider="hosted_vllm", tools=tools() - ) + untouched = litellm.get_optional_params(model="my-vllm-model", custom_llm_provider="hosted_vllm", tools=tools()) assert untouched["tools"][0]["function"]["custom_marker"] == "LEAK", untouched result = litellm.get_optional_params( @@ -4106,6 +4115,9 @@ def test_is_prompt_caching_valid_prompt_explicit_min_token_count_overrides_model ) + + + def test_custom_logger_guards_ignore_subclass_instances(monkeypatch: pytest.MonkeyPatch) -> None: """Regression LIT-4392: the success/failure existence guards used isinstance, so a user subclass of a built-in logger already promoted into the callback lists made the guard @@ -4517,7 +4529,13 @@ _SUCCESS_RESPONSES_BY_CALL_TYPE: Final = ( pytest.param(EmbeddingResponse(model="text-embedding-3-small"), CallTypes.aembedding, id="embedding"), pytest.param( ResponsesAPIResponse( - id="resp_abc", created_at=1, output=[], parallel_tool_calls=False, tool_choice="auto", tools=[], model="gpt-5.6" + id="resp_abc", + created_at=1, + output=[], + parallel_tool_calls=False, + tool_choice="auto", + tools=[], + model="gpt-5.6", ), CallTypes.aresponses, id="responses", @@ -4545,7 +4563,9 @@ async def test_success_deployment_hook_raising_keeps_response_and_runs_later_hoo assert result is response assert second_hook.seen_responses == (response,) - failure_logs: Final = tuple(r for r in caplog.records if "async_post_call_success_deployment_hook error" in r.message) + failure_logs: Final = tuple( + r for r in caplog.records if "async_post_call_success_deployment_hook error" in r.message + ) assert len(failure_logs) == 1 assert "_ChatShapedSuccessDeploymentHook" in failure_logs[0].message assert str(call_type) in failure_logs[0].message @@ -4669,6 +4689,9 @@ async def test_wrapper_async_logs_converted_responses_stream_with_standard_loggi assert success_kwargs["stream"] is True + + + @pytest.mark.asyncio async def test_wrapper_async_replays_cached_converted_chat_stream_as_stream( monkeypatch: pytest.MonkeyPatch, diff --git a/tests/unit/test_utils_get_model_info.py b/tests/unit/test_utils_get_model_info.py index c3aa223b41f..4cd15021acf 100644 --- a/tests/unit/test_utils_get_model_info.py +++ b/tests/unit/test_utils_get_model_info.py @@ -1,8 +1,10 @@ import json +import os import re from collections.abc import Collection, Iterator, Mapping from pathlib import Path from typing import Final, Literal, cast +from unittest.mock import MagicMock, patch import httpx import pytest @@ -13,8 +15,6 @@ from litellm.llms.bedrock.common_utils import BedrockModelInfo from litellm.llms.custom_httpx.http_handler import HTTPHandler from litellm.types.utils import ModelInfoBase from litellm.utils import _invalidate_model_cost_lowercase_map -import os -from unittest.mock import MagicMock, patch @pytest.fixture(autouse=True) @@ -169,9 +169,7 @@ def test_model_info_bedrock_converse(monkeypatch): except FileNotFoundError: pytest.skip("whitelisted_bedrock_models.txt not found") - _enforce_bedrock_converse_models( - model_cost=litellm.model_cost, whitelist_models=whitelist_models - ) + _enforce_bedrock_converse_models(model_cost=litellm.model_cost, whitelist_models=whitelist_models) @pytest.mark.flaky(retries=6, delay=2) @@ -195,9 +193,7 @@ def test_model_info_bedrock_converse_enforcement(monkeypatch): # Check for unwhitelisted models with pytest.raises(AssertionError, match=r"fake\.bedrock-chat-model"): - _enforce_bedrock_converse_models( - model_cost=litellm.model_cost, whitelist_models=whitelist_models - ) + _enforce_bedrock_converse_models(model_cost=litellm.model_cost, whitelist_models=whitelist_models) except FileNotFoundError as e: pytest.skip("whitelisted_bedrock_models.txt not found") @@ -256,11 +252,7 @@ def test_get_model_info_bedrock_models(): k = k.replace(f"{commitment}/", "") base_model = BedrockModelInfo.get_base_model(k) # get_base_model() returns model id without "bedrock/" prefix; cost map keys use "bedrock/" - base_model_key = ( - base_model - if base_model in litellm.model_cost - else f"bedrock/{base_model}" - ) + base_model_key = base_model if base_model in litellm.model_cost else f"bedrock/{base_model}" if base_model_key not in litellm.model_cost: continue base_model_info = litellm.model_cost[base_model_key] @@ -268,15 +260,13 @@ def test_get_model_info_bedrock_models(): if "invoke/" in k: continue if base_model_key.startswith("supports_"): - assert ( - base_model_key in v - ), f"{base_model_key} is not in model cost map for {k}" + assert base_model_key in v, f"{base_model_key} is not in model cost map for {k}" if _govcloud_may_lag(k, base_model_key): assert base_model_value or not v[base_model_key], f"{base_model_key} is on {k} but not its base" continue - assert ( - v[base_model_key] == base_model_value - ), f"{base_model_key} is not equal to {base_model_value} for model {k}" + assert v[base_model_key] == base_model_value, ( + f"{base_model_key} is not equal to {base_model_value} for model {k}" + ) def _cross_region_base_model_key( @@ -299,10 +289,7 @@ def _cross_region_profiles( (model_key, model_info, base_model_key) for model_key, model_info in model_cost.items() if str(model_info.get("litellm_provider", "")).startswith("bedrock") - and ( - base_model_key := _cross_region_base_model_key(model_key, prefixes, model_cost) - ) - is not None + and (base_model_key := _cross_region_base_model_key(model_key, prefixes, model_cost)) is not None ) @@ -336,9 +323,7 @@ def test_get_model_info_bedrock_cross_region_capability_parity(): if _govcloud_may_lag(k, cap): assert base_value or not v[cap], f"{cap} is on {k} but not on {base_model_key}" continue - assert ( - v[cap] == base_value - ), f"{cap} is {v[cap]} on {k} but {base_value} on {base_model_key}" + assert v[cap] == base_value, f"{cap} is {v[cap]} on {k} but {base_value} on {base_model_key}" assert checked > 0, "no cross-region bedrock profiles found - the filter is inert" @@ -367,9 +352,7 @@ def test_get_model_info_bedrock_priced_cross_region_profile_has_priced_base(): base = litellm.model_cost[base_model_key] for cost_key in ("input_cost_per_token", "output_cost_per_token"): if (v.get(cost_key) or 0) > 0: - assert ( - base.get(cost_key) or 0 - ) > 0, f"{k} charges {cost_key} but its base {base_model_key} is free" + assert (base.get(cost_key) or 0) > 0, f"{k} charges {cost_key} but its base {base_model_key} is free" assert checked > 0, "no cross-region bedrock profiles found - the filter is inert" @@ -401,23 +384,17 @@ def test_get_model_info_case_insensitive_lookup(monkeypatch): ) # Test 1: Exact case should work - info = litellm.get_model_info( - model="Qwen/Qwen3-Next-80B-A3B-Thinking", custom_llm_provider="together_ai" - ) + info = litellm.get_model_info(model="Qwen/Qwen3-Next-80B-A3B-Thinking", custom_llm_provider="together_ai") assert info is not None assert info["supports_function_calling"] is True # Test 2: Lowercase should also work (case-insensitive lookup) - info_lower = litellm.get_model_info( - model="qwen/qwen3-next-80b-a3b-thinking", custom_llm_provider="together_ai" - ) + info_lower = litellm.get_model_info(model="qwen/qwen3-next-80b-a3b-thinking", custom_llm_provider="together_ai") assert info_lower is not None assert info_lower["supports_function_calling"] is True # Test 3: Mixed case should also work - info_mixed = litellm.get_model_info( - model="QWEN/qwen3-NEXT-80b-a3b-thinking", custom_llm_provider="together_ai" - ) + info_mixed = litellm.get_model_info(model="QWEN/qwen3-NEXT-80b-a3b-thinking", custom_llm_provider="together_ai") assert info_mixed is not None assert info_mixed["supports_function_calling"] is True @@ -445,21 +422,14 @@ def test_get_model_info_case_insensitive_supports_function_calling(monkeypatch): from litellm.utils import supports_function_calling # Exact case - assert ( - supports_function_calling("TestModel-ABC", custom_llm_provider="test_provider") - is True - ) + assert supports_function_calling("TestModel-ABC", custom_llm_provider="test_provider") is True # Lowercase (should now work with case-insensitive lookup) - assert ( - supports_function_calling("testmodel-abc", custom_llm_provider="test_provider") - is True - ) + assert supports_function_calling("testmodel-abc", custom_llm_provider="test_provider") is True def test_get_model_info_custom_model_router(): - from litellm import Router - from litellm import get_model_info + from litellm import Router, get_model_info litellm.turn_on_debug() @@ -517,6 +487,4 @@ def test_get_model_info_custom_provider(): # Get registered model info from litellm import get_model_info - get_model_info( - model="my-custom-llm/my-fake-model" - ) # 💥 "Exception: This model isn't mapped yet." in v1.56.10 + get_model_info(model="my-custom-llm/my-fake-model") # 💥 "Exception: This model isn't mapped yet." in v1.56.10 diff --git a/tests/unit/types/test_completion.py b/tests/unit/types/test_completion.py index 461d43fd28c..67ba5b9a6e8 100644 --- a/tests/unit/types/test_completion.py +++ b/tests/unit/types/test_completion.py @@ -1,7 +1,7 @@ """ Tests for litellm.types.completion module -This test suite validates the CompletionRequest model and its compatibility with +This test suite validates the CompletionRequest model and its compatibility with OpenAI ChatCompletion API message formats. Usage: @@ -102,9 +102,7 @@ def test_completion_request_multimodal_content(): {"type": "text", "text": "What's in this image?"}, { "type": "image_url", - "image_url": { - "url": "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD..." - }, + "image_url": {"url": "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD..."}, }, ], }