From b28ec1c75eee0d32e8840fdd30a016a1502d8ad6 Mon Sep 17 00:00:00 2001 From: jtsaw Date: Thu, 19 Feb 2026 11:53:10 -0800 Subject: [PATCH 1/3] support reasoning + effort on sonnet 4.6 --- litellm/llms/anthropic/chat/transformation.py | 52 ++- .../test_anthropic_chat_transformation.py | 441 ++++++++++-------- 2 files changed, 278 insertions(+), 215 deletions(-) diff --git a/litellm/llms/anthropic/chat/transformation.py b/litellm/llms/anthropic/chat/transformation.py index 364126d822e..5855fa2fd54 100644 --- a/litellm/llms/anthropic/chat/transformation.py +++ b/litellm/llms/anthropic/chat/transformation.py @@ -171,9 +171,22 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): return tool_call @staticmethod - def _is_claude_opus_4_6(model: str) -> bool: - """Check if the model is Claude Opus 4.5 or Sonnet 4.6.""" - return "opus-4-6" in model.lower() or "opus_4_6" in model.lower() or "sonnet-4-6" in model.lower() or "sonnet_4_6" in model.lower() or "sonnet-4.6" in model.lower() + def _is_claude_4_6_model(model: str) -> bool: + """Check if the model is a Claude 4.6 model that uses adaptive thinking.""" + model_lower = model.lower() + return any( + model_variant in model_lower + for model_variant in ( + "opus-4-6", + "opus_4_6", + "opus-4.6", + "opus_4.6", + "sonnet-4-6", + "sonnet_4_6", + "sonnet-4.6", + "sonnet_4.6", + ) + ) def get_supported_openai_params(self, model: str): params = [ @@ -194,9 +207,13 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): "context_management", ] - if "claude-3-7-sonnet" in model or supports_reasoning( - model=model, - custom_llm_provider=self.custom_llm_provider, + if ( + "claude-3-7-sonnet" in model + or AnthropicConfig._is_claude_4_6_model(model) + or supports_reasoning( + model=model, + custom_llm_provider=self.custom_llm_provider, + ) ): params.append("thinking") params.append("reasoning_effort") @@ -207,7 +224,7 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): def filter_anthropic_output_schema(schema: Dict[str, Any]) -> Dict[str, Any]: """ Filter out unsupported fields from JSON schema for Anthropic's output_format API. - + Anthropic's output_format doesn't support certain JSON schema properties: - maxItems/minItems: Not supported for array types - minimum/maximum: Not supported for numeric types @@ -220,10 +237,9 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): 1. Remove unsupported constraints from schema 2. Add constraint info to description (e.g., "Must be at least 100") 3. Validate responses against original schema - Args: schema: The JSON schema dictionary to filter - + Returns: A new dictionary with unsupported fields removed and descriptions updated @@ -706,12 +722,12 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): @staticmethod def _map_reasoning_effort( - reasoning_effort: Optional[Union[REASONING_EFFORT, str]], + reasoning_effort: Optional[Union[REASONING_EFFORT, str]], model: str, ) -> Optional[AnthropicThinkingParam]: if reasoning_effort is None or reasoning_effort == "none": return None - if AnthropicConfig._is_claude_opus_4_6(model): + if AnthropicConfig._is_claude_4_6_model(model): return AnthropicThinkingParam( type="adaptive", ) @@ -759,10 +775,10 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): ) if json_schema is None: return None - + # Filter out unsupported fields for Anthropic's output_format API filtered_schema = self.filter_anthropic_output_schema(json_schema) - + return AnthropicOutputSchema( type="json_schema", schema=filtered_schema, @@ -1140,7 +1156,7 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): """ Ensure a beta header value is present in the anthropic-beta header. Merges with existing values instead of overriding them. - + Args: headers: Dictionary of headers to update beta_value: The beta header value to add @@ -1196,7 +1212,7 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): self, headers: dict, optional_params: dict ) -> dict: """Update headers with optional anthropic beta.""" - + # Skip adding beta headers for Vertex requests # Vertex AI handles these headers differently is_vertex_request = optional_params.get("is_vertex_request", False) @@ -1435,7 +1451,7 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): elif content["type"] == "web_fetch_tool_result": if web_search_results is None: web_search_results = [] - web_search_results.append(content) + web_search_results.append(content) else: # All other tool results (bash_code_execution_tool_result, text_editor_code_execution_tool_result, etc.) if tool_results is None: @@ -1452,7 +1468,7 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): thinking_blocks.append( cast(ChatCompletionRedactedThinkingBlock, content) ) - + ## COMPACTION elif content["type"] == "compaction": if compaction_blocks is None: @@ -1660,7 +1676,7 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): provider_specific_fields["container"] = container if compaction_blocks is not None: provider_specific_fields["compaction_blocks"] = compaction_blocks - + _message = litellm.Message( tool_calls=tool_calls, content=text_content or None, diff --git a/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_transformation.py b/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_transformation.py index 4b15d30ccec..357a3bf42ea 100644 --- a/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_transformation.py +++ b/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_transformation.py @@ -737,17 +737,17 @@ def test_anthropic_beta_header_merging_with_output_format(): """ Test that anthropic-beta headers from extra_headers are merged with output_format beta headers instead of being overridden. - + This is a regression test for: https://github.com/BerriAI/litellm/issues/... When using response_format with a Pydantic model AND extra_headers with anthropic-beta (e.g., for context-1m extension), both beta headers should be present in the final request. """ config = AnthropicConfig() - + # Simulate headers that already have the context-1m beta header from extra_headers headers = {"anthropic-beta": "context-1m-2025-08-07"} - + # Simulate output_format being set (happens when using response_format with Sonnet 4.5) optional_params = { "output_format": { @@ -755,11 +755,11 @@ def test_anthropic_beta_header_merging_with_output_format(): "schema": {"type": "object", "properties": {}} } } - + result_headers = config.update_headers_with_optional_anthropic_beta( headers, optional_params ) - + # Both beta headers should be present beta_value = result_headers["anthropic-beta"] assert "context-1m-2025-08-07" in beta_value, \ @@ -773,10 +773,10 @@ def test_anthropic_beta_header_merging_with_multiple_features(): Test that multiple beta headers can be merged when using multiple features. """ config = AnthropicConfig() - + # Start with a user-provided beta header headers = {"anthropic-beta": "context-1m-2025-08-07"} - + # Use multiple features that require beta headers optional_params = { "output_format": { @@ -786,13 +786,13 @@ def test_anthropic_beta_header_merging_with_multiple_features(): "context_management": _sample_context_management_payload(), "tools": [{"type": "web_fetch_20250910", "name": "web_fetch"}] } - + result_headers = config.update_headers_with_optional_anthropic_beta( headers, optional_params ) - + beta_value = result_headers["anthropic-beta"] - + # All beta headers should be present assert "context-1m-2025-08-07" in beta_value assert "structured-outputs-2025-11-13" in beta_value @@ -946,9 +946,9 @@ def test_non_structured_output_model_uses_tool_workaround(): def test_tool_search_regex_detection(): """Test that tool search regex tools are properly detected""" from litellm.llms.anthropic.common_utils import AnthropicModelInfo - + config = AnthropicModelInfo() - + # Test with tool search regex tool tools = [ { @@ -957,7 +957,7 @@ def test_tool_search_regex_detection(): } ] assert config.is_tool_search_used(tools) is True - + # Test without tool search tools = [ { @@ -971,9 +971,9 @@ def test_tool_search_regex_detection(): def test_tool_search_bm25_detection(): """Test that tool search BM25 tools are properly detected""" from litellm.llms.anthropic.common_utils import AnthropicModelInfo - + config = AnthropicModelInfo() - + # Test with tool search BM25 tool tools = [ { @@ -987,14 +987,14 @@ def test_tool_search_bm25_detection(): def test_tool_search_beta_header(): """Test that tool search beta header is automatically added""" from litellm.llms.anthropic.common_utils import AnthropicModelInfo - + config = AnthropicModelInfo() - + headers = config.get_anthropic_headers( api_key="test-key", tool_search_used=True, ) - + assert "anthropic-beta" in headers assert "advanced-tool-use-2025-11-20" in headers["anthropic-beta"] @@ -1002,14 +1002,14 @@ def test_tool_search_beta_header(): def test_tool_search_regex_mapping(): """Test that tool search regex tools are properly mapped""" config = AnthropicConfig() - + tool = { "type": "tool_search_tool_regex_20251119", "name": "tool_search_tool_regex" } - + mapped_tool, mcp_server = config._map_tool_helper(tool) - + assert mapped_tool is not None assert mapped_tool["type"] == "tool_search_tool_regex_20251119" assert mapped_tool["name"] == "tool_search_tool_regex" @@ -1019,14 +1019,14 @@ def test_tool_search_regex_mapping(): def test_tool_search_bm25_mapping(): """Test that tool search BM25 tools are properly mapped""" config = AnthropicConfig() - + tool = { "type": "tool_search_tool_bm25_20251119", "name": "tool_search_tool_bm25" } - + mapped_tool, mcp_server = config._map_tool_helper(tool) - + assert mapped_tool is not None assert mapped_tool["type"] == "tool_search_tool_bm25_20251119" assert mapped_tool["name"] == "tool_search_tool_bm25" @@ -1036,7 +1036,7 @@ def test_tool_search_bm25_mapping(): def test_deferred_tools_separation(): """Test that deferred and non-deferred tools are properly separated""" config = AnthropicConfig() - + tools = [ { "type": "tool_search_tool_regex_20251119", @@ -1053,9 +1053,9 @@ def test_deferred_tools_separation(): "defer_loading": False } ] - + non_deferred, deferred = config._separate_deferred_tools(tools) - + assert len(non_deferred) == 2 # tool_search and search_files assert len(deferred) == 1 # get_weather @@ -1063,7 +1063,7 @@ def test_deferred_tools_separation(): def test_server_tool_use_in_response(): """Test that server_tool_use blocks are parsed correctly""" config = AnthropicConfig() - + completion_response = { "content": [ { @@ -1074,7 +1074,7 @@ def test_server_tool_use_in_response(): } ] } - + text, citations, thinking_blocks, reasoning_content, tool_calls, web_search_results, tool_results, compaction_blocks = config.extract_response_content( completion_response ) @@ -1088,7 +1088,7 @@ def test_server_tool_use_in_response(): def test_tool_search_usage_tracking(): """Test that tool_search_requests are tracked in usage""" config = AnthropicConfig() - + usage_object = { "input_tokens": 100, "output_tokens": 50, @@ -1096,9 +1096,9 @@ def test_tool_search_usage_tracking(): "tool_search_requests": 2 } } - + usage = config.calculate_usage(usage_object=usage_object, reasoning_content=None) - + assert usage.server_tool_use is not None assert usage.server_tool_use.tool_search_requests == 2 @@ -1106,7 +1106,7 @@ def test_tool_search_usage_tracking(): def test_tool_reference_expansion(): """Test that tool_reference blocks are expanded correctly""" config = AnthropicConfig() - + deferred_tools = [ { "type": "function", @@ -1116,14 +1116,14 @@ def test_tool_reference_expansion(): } } ] - + content = [ {"type": "text", "text": "I'll search for tools"}, {"type": "tool_reference", "tool_name": "get_weather"} ] - + expanded = config._expand_tool_references(content, deferred_tools) - + assert len(expanded) == 2 assert expanded[0]["type"] == "text" assert expanded[1]["type"] == "function" @@ -1133,7 +1133,7 @@ def test_tool_reference_expansion(): def test_defer_loading_preserved_in_transformation(): """Test that defer_loading parameter is preserved when transforming tools""" config = AnthropicConfig() - + tool = { "type": "function", "function": { @@ -1149,9 +1149,9 @@ def test_defer_loading_preserved_in_transformation(): }, "defer_loading": True } - + mapped_tool, mcp_server = config._map_tool_helper(tool) - + assert mapped_tool is not None assert mapped_tool.get("defer_loading") is True assert mapped_tool["name"] == "get_weather" @@ -1161,7 +1161,7 @@ def test_defer_loading_preserved_in_transformation(): def test_tool_search_complete_response_parsing(): """Test parsing a complete tool search response with server_tool_use and tool_search_tool_result blocks""" config = AnthropicConfig() - + # Simulating actual Anthropic API response with tool search completion_response = { "content": [ @@ -1201,7 +1201,7 @@ def test_tool_search_complete_response_parsing(): "server_tool_use": {"web_search_requests": 0} } } - + # Extract content text, citations, thinking_blocks, reasoning_content, tool_calls, web_search_results, tool_results, compaction_blocks = config.extract_response_content( completion_response @@ -1218,14 +1218,14 @@ def test_tool_search_complete_response_parsing(): # Verify web_search_results is None (this response has tool_search, not web_search) assert web_search_results is None - + # Verify usage calculation counts tool_search_requests from content usage = config.calculate_usage( usage_object=completion_response["usage"], reasoning_content=None, completion_response=completion_response ) - + assert usage.server_tool_use is not None assert usage.server_tool_use.web_search_requests == 0 assert usage.server_tool_use.tool_search_requests == 1 # Counted from server_tool_use blocks @@ -1234,7 +1234,7 @@ def test_tool_search_complete_response_parsing(): def test_allowed_callers_field_preservation(): """Test that allowed_callers field is preserved during tool transformation.""" config = AnthropicConfig() - + # Test with top-level allowed_callers tool_with_allowed_callers = { "type": "function", @@ -1251,7 +1251,7 @@ def test_allowed_callers_field_preservation(): }, "allowed_callers": ["code_execution_20250825"] } - + transformed_tool, _ = config._map_tool_helper(tool_with_allowed_callers) assert transformed_tool is not None assert "allowed_callers" in transformed_tool @@ -1261,9 +1261,9 @@ def test_allowed_callers_field_preservation(): def test_programmatic_tool_calling_beta_header(): """Test that beta header is automatically added when programmatic tool calling is detected.""" from litellm.llms.anthropic.common_utils import AnthropicModelInfo - + model_info = AnthropicModelInfo() - + # Test detection with allowed_callers tools = [ { @@ -1280,16 +1280,16 @@ def test_programmatic_tool_calling_beta_header(): "allowed_callers": ["code_execution_20250825"] } ] - + is_programmatic = model_info.is_programmatic_tool_calling_used(tools) assert is_programmatic is True - + # Test header generation headers = model_info.get_anthropic_headers( api_key="test-key", programmatic_tool_calling_used=True ) - + assert "anthropic-beta" in headers assert "advanced-tool-use-2025-11-20" in headers["anthropic-beta"] @@ -1297,7 +1297,7 @@ def test_programmatic_tool_calling_beta_header(): def test_caller_field_in_response(): """Test that caller field is correctly parsed from tool_use blocks.""" config = AnthropicConfig() - + # Mock response with programmatic tool call completion_response = { "id": "msg_test", @@ -1322,7 +1322,7 @@ def test_caller_field_in_response(): "stop_reason": "tool_use", "usage": {"input_tokens": 100, "output_tokens": 50} } - + text, citations, thinking, reasoning, tool_calls, web_search_results, tool_results, compaction_blocks = config.extract_response_content(completion_response) assert len(tool_calls) == 1 @@ -1337,12 +1337,12 @@ def test_caller_field_in_response(): def test_code_execution_20250825_tool_type(): """Test that code_execution_20250825 tool type is handled correctly.""" config = AnthropicConfig() - + tool = { "type": "code_execution_20250825", "name": "code_execution" } - + transformed_tool, _ = config._map_tool_helper(tool) assert transformed_tool is not None assert transformed_tool["type"] == "code_execution_20250825" @@ -1352,7 +1352,7 @@ def test_code_execution_20250825_tool_type(): def test_allowed_callers_in_function_field(): """Test that allowed_callers in function field is also preserved.""" config = AnthropicConfig() - + # Test with function.allowed_callers tool = { "type": "function", @@ -1369,7 +1369,7 @@ def test_allowed_callers_in_function_field(): "allowed_callers": ["code_execution_20250825"] } } - + transformed_tool, _ = config._map_tool_helper(tool) assert transformed_tool is not None assert "allowed_callers" in transformed_tool @@ -1379,7 +1379,7 @@ def test_allowed_callers_in_function_field(): def test_input_examples_field_preservation(): """Test that input_examples field is preserved during tool transformation.""" config = AnthropicConfig() - + # Test with top-level input_examples tool_with_examples = { "type": "function", @@ -1400,7 +1400,7 @@ def test_input_examples_field_preservation(): {"location": "Tokyo, Japan", "unit": "celsius"} ] } - + transformed_tool, _ = config._map_tool_helper(tool_with_examples) assert transformed_tool is not None assert "input_examples" in transformed_tool @@ -1411,9 +1411,9 @@ def test_input_examples_field_preservation(): def test_input_examples_beta_header(): """Test that beta header is automatically added when input_examples is detected.""" from litellm.llms.anthropic.common_utils import AnthropicModelInfo - + model_info = AnthropicModelInfo() - + # Test detection with input_examples tools = [ { @@ -1428,16 +1428,16 @@ def test_input_examples_beta_header(): ] } ] - + is_examples_used = model_info.is_input_examples_used(tools) assert is_examples_used is True - + # Test header generation headers = model_info.get_anthropic_headers( api_key="test-key", input_examples_used=True ) - + assert "anthropic-beta" in headers assert "advanced-tool-use-2025-11-20" in headers["anthropic-beta"] @@ -1445,7 +1445,7 @@ def test_input_examples_beta_header(): def test_input_examples_in_function_field(): """Test that input_examples in function field is also preserved.""" config = AnthropicConfig() - + # Test with function.input_examples tool = { "type": "function", @@ -1465,7 +1465,7 @@ def test_input_examples_in_function_field(): ] } } - + transformed_tool, _ = config._map_tool_helper(tool) assert transformed_tool is not None assert "input_examples" in transformed_tool @@ -1475,7 +1475,7 @@ def test_input_examples_in_function_field(): def test_input_examples_with_other_features(): """Test that input_examples works alongside other tool features.""" config = AnthropicConfig() - + # Tool with input_examples, defer_loading, and allowed_callers tool = { "type": "function", @@ -1496,7 +1496,7 @@ def test_input_examples_with_other_features(): "defer_loading": True, "allowed_callers": ["code_execution_20250825"] } - + transformed_tool, _ = config._map_tool_helper(tool) assert transformed_tool is not None assert "input_examples" in transformed_tool @@ -1509,7 +1509,7 @@ def test_input_examples_with_other_features(): def test_input_examples_empty_list_not_added(): """Test that empty input_examples list is not added to transformed tool.""" config = AnthropicConfig() - + # Tool with empty input_examples tool = { "type": "function", @@ -1526,7 +1526,7 @@ def test_input_examples_empty_list_not_added(): }, "input_examples": [] } - + transformed_tool, _ = config._map_tool_helper(tool) assert transformed_tool is not None # Empty list should not be added @@ -1539,14 +1539,14 @@ def test_input_examples_empty_list_not_added(): def test_effort_output_config_preservation(): """Test that output_config with effort is preserved in transformation.""" config = AnthropicConfig() - + messages = [{"role": "user", "content": "Analyze this code"}] optional_params = { "output_config": { "effort": "medium" } } - + result = config.transform_request( model="claude-opus-4-5-20251101", messages=messages, @@ -1554,7 +1554,7 @@ def test_effort_output_config_preservation(): litellm_params={}, headers={} ) - + assert "output_config" in result assert result["output_config"]["effort"] == "medium" @@ -1562,24 +1562,24 @@ def test_effort_output_config_preservation(): def test_effort_beta_header_injection(): """Test that effort beta header is automatically added when output_config is detected.""" from litellm.llms.anthropic.common_utils import AnthropicModelInfo - + model_info = AnthropicModelInfo() - + # Test with effort parameter optional_params = { "output_config": { "effort": "low" } } - + effort_used = model_info.is_effort_used(optional_params=optional_params) assert effort_used is True - + headers = model_info.get_anthropic_headers( api_key="test-key", effort_used=effort_used ) - + assert "anthropic-beta" in headers assert "effort-2025-11-24" in headers["anthropic-beta"] @@ -1587,9 +1587,9 @@ def test_effort_beta_header_injection(): def test_effort_validation(): """Test that only valid effort values are accepted.""" config = AnthropicConfig() - + messages = [{"role": "user", "content": "Test"}] - + # Valid values should work for effort in ["high", "medium", "low"]: optional_params = {"output_config": {"effort": effort}} @@ -1601,7 +1601,7 @@ def test_effort_validation(): headers={} ) assert result["output_config"]["effort"] == effort - + # Invalid value should raise error with pytest.raises(ValueError, match="Invalid effort value"): optional_params = {"output_config": {"effort": "invalid"}} @@ -1617,14 +1617,14 @@ def test_effort_validation(): def test_effort_with_claude_opus_45(): """Test effort parameter works with Claude Opus 4.5 model.""" config = AnthropicConfig() - + messages = [{"role": "user", "content": "Complex analysis task"}] optional_params = { "output_config": { "effort": "high" } } - + result = config.transform_request( model="claude-opus-4-5-20251101", messages=messages, @@ -1632,7 +1632,7 @@ def test_effort_with_claude_opus_45(): litellm_params={}, headers={} ) - + assert "output_config" in result assert result["output_config"]["effort"] == "high" assert result["model"] == "claude-opus-4-5-20251101" @@ -1676,7 +1676,7 @@ def test_max_effort_rejected_for_opus_45(): def test_effort_with_other_features(): """Test effort works alongside other features (thinking, tools).""" config = AnthropicConfig() - + messages = [{"role": "user", "content": "Use tools efficiently"}] tools = [ { @@ -1704,7 +1704,7 @@ def test_effort_with_other_features(): "budget_tokens": 1000 } } - + result = config.transform_request( model="claude-opus-4-5-20251101", messages=messages, @@ -1947,7 +1947,7 @@ def test_calculate_usage_completion_tokens_details_always_populated(): """ Test that completion_tokens_details is always populated in Usage object, not just when there's reasoning_content. - + Fixes: https://github.com/BerriAI/litellm/issues/18772 Bug: completion_tokens_details was None for regular Claude responses without reasoning """ @@ -1959,7 +1959,7 @@ def test_calculate_usage_completion_tokens_details_always_populated(): "output_tokens": 248, } usage = config.calculate_usage(usage_object=usage_object, reasoning_content=None) - + # completion_tokens_details should NOT be None assert usage.completion_tokens_details is not None assert usage.completion_tokens_details.reasoning_tokens is 0 @@ -1973,7 +1973,7 @@ def test_calculate_usage_completion_tokens_details_with_reasoning(): """ Test that completion_tokens_details correctly splits text_tokens and reasoning_tokens when reasoning_content is present. - + Fixes: https://github.com/BerriAI/litellm/issues/18772 """ config = AnthropicConfig() @@ -1985,12 +1985,12 @@ def test_calculate_usage_completion_tokens_details_with_reasoning(): } # Simulating reasoning content that would count as ~50 tokens reasoning_content = "Let me think about this step by step. " * 10 # Roughly 50 tokens - + usage = config.calculate_usage( - usage_object=usage_object, + usage_object=usage_object, reasoning_content=reasoning_content ) - + # completion_tokens_details should be populated with both reasoning and text tokens assert usage.completion_tokens_details is not None assert usage.completion_tokens_details.reasoning_tokens is not None @@ -2004,45 +2004,92 @@ def test_calculate_usage_completion_tokens_details_with_reasoning(): # ============ Reasoning Effort Tests ============ -def test_reasoning_effort_maps_to_adaptive_thinking_for_opus_4_6(): +def test_reasoning_effort_maps_to_adaptive_thinking_for_claude_4_6_models(): """ - Test that reasoning_effort maps to adaptive thinking type for Claude Opus 4.6. - - For Claude Opus 4.6, reasoning_effort should map to {"type": "adaptive"} + Test that reasoning_effort maps to adaptive thinking type for Claude 4.6 models. + + For Claude Opus 4.6 and Claude Sonnet 4.6, reasoning_effort should map to {"type": "adaptive"} regardless of the effort level specified. """ config = AnthropicConfig() - + # Test with different reasoning_effort values - all should map to adaptive - for effort in ["low", "medium", "high", "minimal"]: - non_default_params = {"reasoning_effort": effort} - optional_params = {} - - result = config.map_openai_params( - non_default_params=non_default_params, - optional_params=optional_params, - model="claude-opus-4-6-20250514", - drop_params=False - ) - - # Should map to adaptive thinking type - assert "thinking" in result - assert result["thinking"]["type"] == "adaptive" - # Should not have budget_tokens for adaptive type - assert "budget_tokens" not in result["thinking"] - # reasoning_effort should not be in the result (it's transformed to thinking) - assert "reasoning_effort" not in result + for model in ["claude-opus-4-6-20250514", "claude-sonnet-4-6-20260219"]: + for effort in ["low", "medium", "high", "minimal"]: + non_default_params = {"reasoning_effort": effort} + optional_params = {} + + result = config.map_openai_params( + non_default_params=non_default_params, + optional_params=optional_params, + model=model, + drop_params=False + ) + + # Should map to adaptive thinking type + assert "thinking" in result + assert result["thinking"]["type"] == "adaptive" + # Should not have budget_tokens for adaptive type + assert "budget_tokens" not in result["thinking"] + # reasoning_effort should not be in the result (it's transformed to thinking) + assert "reasoning_effort" not in result + + +def test_get_supported_params_includes_reasoning_for_sonnet_4_6_alias(): + """Sonnet 4.6 aliases should expose thinking + reasoning_effort in supported params.""" + config = AnthropicConfig() + + params = config.get_supported_openai_params(model="claude-sonnet-4-6-20260219") + + assert "thinking" in params + assert "reasoning_effort" in params + + +def test_get_supported_params_includes_reasoning_for_sonnet_4_6_dotted_alias(): + """Dotted Sonnet 4.6 aliases should expose thinking + reasoning_effort in supported params.""" + config = AnthropicConfig() + + params = config.get_supported_openai_params(model="claude-sonnet-4.6") + + assert "thinking" in params + assert "reasoning_effort" in params + + +def test_sonnet_4_6_reasoning_effort_to_transform_request_payload(): + """ + Sonnet 4.6 should convert reasoning_effort to adaptive thinking in final request payload. + """ + config = AnthropicConfig() + messages = [{"role": "user", "content": "Think through this carefully."}] + + mapped_optional_params = config.map_openai_params( + non_default_params={"reasoning_effort": "high"}, + optional_params={}, + model="claude-sonnet-4-6-20260219", + drop_params=False, + ) + result = config.transform_request( + model="claude-sonnet-4-6-20260219", + messages=messages, + optional_params=mapped_optional_params, + litellm_params={}, + headers={}, + ) + + assert "thinking" in result + assert result["thinking"]["type"] == "adaptive" + assert "budget_tokens" not in result["thinking"] def test_reasoning_effort_maps_to_budget_thinking_for_non_opus_4_6(): """ Test that reasoning_effort maps to budget-based thinking config for non-Opus 4.6 models. - - For models other than Claude Opus 4.6, reasoning_effort should map to + + For models other than Claude Opus 4.6, reasoning_effort should map to thinking config with budget_tokens based on the effort level. """ config = AnthropicConfig() - + # Test with Claude Sonnet 4.5 (non-Opus 4.6 model) test_cases = [ ("low", 1024), # DEFAULT_REASONING_EFFORT_LOW_THINKING_BUDGET @@ -2050,18 +2097,18 @@ def test_reasoning_effort_maps_to_budget_thinking_for_non_opus_4_6(): ("high", 4096), # DEFAULT_REASONING_EFFORT_HIGH_THINKING_BUDGET ("minimal", 128), # DEFAULT_REASONING_EFFORT_MINIMAL_THINKING_BUDGET ] - + for effort, expected_budget in test_cases: non_default_params = {"reasoning_effort": effort} optional_params = {} - + result = config.map_openai_params( non_default_params=non_default_params, optional_params=optional_params, model="claude-sonnet-4-5-20250929", drop_params=False ) - + # Should map to enabled thinking type with budget_tokens assert "thinking" in result assert result["thinking"]["type"] == "enabled" @@ -2072,18 +2119,18 @@ def test_reasoning_effort_maps_to_budget_thinking_for_non_opus_4_6(): def test_code_execution_tool_results_extraction(): """ - Test that code execution tool results (bash_code_execution_tool_result, - text_editor_code_execution_tool_result) are properly extracted and exposed + Test that code execution tool results (bash_code_execution_tool_result, + text_editor_code_execution_tool_result) are properly extracted and exposed in provider_specific_fields. - + Related to: https://github.com/BerriAI/litellm/issues/xxxxx """ import httpx from litellm.types.utils import ModelResponse - + config = AnthropicConfig() - + # Mock Anthropic response with code execution tool results mock_anthropic_response = { "id": "msg_01XYZ", @@ -2143,15 +2190,15 @@ def test_code_execution_tool_results_extraction(): "output_tokens": 50 } } - + # Create mock HTTP response mock_raw_response = MagicMock(spec=httpx.Response) mock_raw_response.json.return_value = mock_anthropic_response mock_raw_response.status_code = 200 mock_raw_response.headers = {} - + model_response = ModelResponse() - + transformed_response = config.transform_parsed_response( completion_response=mock_anthropic_response, raw_response=mock_raw_response, @@ -2159,39 +2206,39 @@ def test_code_execution_tool_results_extraction(): json_mode=False, prefix_prompt=None, ) - + # Verify tool calls are present assert transformed_response.choices[0].message.tool_calls is not None assert len(transformed_response.choices[0].message.tool_calls) == 2 - + # Verify first tool call assert transformed_response.choices[0].message.tool_calls[0].id == "srvtoolu_01ABC" assert transformed_response.choices[0].message.tool_calls[0].function.name == "bash_code_execution" - + # Verify second tool call assert transformed_response.choices[0].message.tool_calls[1].id == "srvtoolu_01DEF" assert transformed_response.choices[0].message.tool_calls[1].function.name == "text_editor_code_execution" - + # Verify tool results are in provider_specific_fields provider_fields = transformed_response.choices[0].message.provider_specific_fields assert provider_fields is not None assert "tool_results" in provider_fields assert provider_fields["tool_results"] is not None assert len(provider_fields["tool_results"]) == 2 - + # Verify bash_code_execution_tool_result bash_result = provider_fields["tool_results"][0] assert bash_result["type"] == "bash_code_execution_tool_result" assert bash_result["tool_use_id"] == "srvtoolu_01ABC" assert bash_result["content"]["stdout"] == "4\n" assert bash_result["content"]["return_code"] == 0 - + # Verify text_editor_code_execution_tool_result editor_result = provider_fields["tool_results"][1] assert editor_result["type"] == "text_editor_code_execution_tool_result" assert editor_result["tool_use_id"] == "srvtoolu_01DEF" assert editor_result["content"]["is_file_update"] is False - + # Verify text content is properly concatenated assert "I'll calculate that for you." in transformed_response.choices[0].message.content assert "Done!" in transformed_response.choices[0].message.content @@ -2205,9 +2252,9 @@ def test_tool_search_tool_result_not_in_tool_results(): import httpx from litellm.types.utils import ModelResponse - + config = AnthropicConfig() - + mock_anthropic_response = { "id": "msg_01XYZ", "type": "message", @@ -2230,14 +2277,14 @@ def test_tool_search_tool_result_not_in_tool_results(): "output_tokens": 50 } } - + mock_raw_response = MagicMock(spec=httpx.Response) mock_raw_response.json.return_value = mock_anthropic_response mock_raw_response.status_code = 200 mock_raw_response.headers = {} - + model_response = ModelResponse() - + transformed_response = config.transform_parsed_response( completion_response=mock_anthropic_response, raw_response=mock_raw_response, @@ -2245,7 +2292,7 @@ def test_tool_search_tool_result_not_in_tool_results(): json_mode=False, prefix_prompt=None, ) - + # Verify tool_search_tool_result is NOT in tool_results provider_fields = transformed_response.choices[0].message.provider_specific_fields assert provider_fields.get("tool_results") is None @@ -2259,9 +2306,9 @@ def test_web_search_tool_result_backwards_compatibility(): import httpx from litellm.types.utils import ModelResponse - + config = AnthropicConfig() - + mock_anthropic_response = { "id": "msg_01XYZ", "type": "message", @@ -2285,14 +2332,14 @@ def test_web_search_tool_result_backwards_compatibility(): "output_tokens": 50 } } - + mock_raw_response = MagicMock(spec=httpx.Response) mock_raw_response.json.return_value = mock_anthropic_response mock_raw_response.status_code = 200 mock_raw_response.headers = {} - + model_response = ModelResponse() - + transformed_response = config.transform_parsed_response( completion_response=mock_anthropic_response, raw_response=mock_raw_response, @@ -2300,14 +2347,14 @@ def test_web_search_tool_result_backwards_compatibility(): json_mode=False, prefix_prompt=None, ) - + # Verify web_search_tool_result is in web_search_results (not tool_results) provider_fields = transformed_response.choices[0].message.provider_specific_fields assert "web_search_results" in provider_fields assert provider_fields["web_search_results"] is not None assert len(provider_fields["web_search_results"]) == 1 assert provider_fields["web_search_results"][0]["type"] == "web_search_tool_result" - + # Should NOT be in tool_results assert provider_fields.get("tool_results") is None @@ -2320,7 +2367,7 @@ def test_compaction_block_extraction(): Test that compaction blocks are correctly extracted from Anthropic response. """ config = AnthropicConfig() - + completion_response = { "id": "msg_compaction_test", "type": "message", @@ -2343,17 +2390,17 @@ def test_compaction_block_extraction(): "output_tokens": 100 } } - + text, citations, thinking_blocks, reasoning_content, tool_calls, web_search_results, tool_results, compaction_blocks = config.extract_response_content( completion_response ) - + # Verify compaction blocks are extracted assert compaction_blocks is not None assert len(compaction_blocks) == 1 assert compaction_blocks[0]["type"] == "compaction" assert "Summary of the conversation" in compaction_blocks[0]["content"] - + # Verify text content is extracted assert "I don't have access to real-time data" in text @@ -2365,9 +2412,9 @@ def test_compaction_block_in_provider_specific_fields(): import httpx from litellm.types.utils import ModelResponse - + config = AnthropicConfig() - + completion_response = { "id": "msg_compaction_provider_fields", "type": "message", @@ -2389,10 +2436,10 @@ def test_compaction_block_in_provider_specific_fields(): "output_tokens": 25 } } - + raw_response = httpx.Response(status_code=200, headers={}) model_response = ModelResponse() - + result = config.transform_parsed_response( completion_response=completion_response, raw_response=raw_response, @@ -2400,7 +2447,7 @@ def test_compaction_block_in_provider_specific_fields(): json_mode=False, prefix_prompt=None, ) - + # Verify compaction_blocks is in provider_specific_fields provider_fields = result.choices[0].message.provider_specific_fields assert provider_fields is not None @@ -2415,7 +2462,7 @@ def test_multiple_compaction_blocks(): Test that multiple compaction blocks are all extracted. """ config = AnthropicConfig() - + completion_response = { "content": [ { @@ -2432,11 +2479,11 @@ def test_multiple_compaction_blocks(): } ] } - + text, citations, thinking_blocks, reasoning_content, tool_calls, web_search_results, tool_results, compaction_blocks = config.extract_response_content( completion_response ) - + # Verify both compaction blocks are extracted assert compaction_blocks is not None assert len(compaction_blocks) == 2 @@ -2452,7 +2499,7 @@ def test_compaction_block_request_transformation(): from litellm.litellm_core_utils.prompt_templates.factory import ( anthropic_messages_pt, ) - + messages = [ { "role": "user", @@ -2480,28 +2527,28 @@ def test_compaction_block_request_transformation(): "content": "What about New York?" } ] - + result = anthropic_messages_pt( messages=messages, model="claude-opus-4-6", llm_provider="anthropic" ) - + # Find the assistant message assistant_message = None for msg in result: if msg["role"] == "assistant": assistant_message = msg break - + assert assistant_message is not None assert "content" in assistant_message assert isinstance(assistant_message["content"], list) - + # Verify compaction block is at the beginning assert assistant_message["content"][0]["type"] == "compaction" assert "Summary of the conversation" in assistant_message["content"][0]["content"] - + # Verify text content follows text_blocks = [c for c in assistant_message["content"] if c.get("type") == "text"] assert len(text_blocks) > 0 @@ -2513,7 +2560,7 @@ def test_compaction_with_context_management(): Test that compaction works with context_management parameter. """ config = AnthropicConfig() - + messages = [{"role": "user", "content": "Hello"}] optional_params = { "context_management": { @@ -2525,7 +2572,7 @@ def test_compaction_with_context_management(): }, "max_tokens": 100 } - + result = config.transform_request( model="claude-opus-4-6", messages=messages, @@ -2533,7 +2580,7 @@ def test_compaction_with_context_management(): litellm_params={}, headers={} ) - + # Verify context_management is included assert "context_management" in result assert result["context_management"]["edits"][0]["type"] == "compact_20260112" @@ -2544,7 +2591,7 @@ def test_compaction_block_with_other_content_types(): Test that compaction blocks work alongside other content types like thinking blocks and tool calls. """ config = AnthropicConfig() - + completion_response = { "content": [ { @@ -2567,11 +2614,11 @@ def test_compaction_block_with_other_content_types(): } ] } - + text, citations, thinking_blocks, reasoning_content, tool_calls, web_search_results, tool_results, compaction_blocks = config.extract_response_content( completion_response ) - + # Verify all content types are extracted assert compaction_blocks is not None assert len(compaction_blocks) == 1 @@ -2677,9 +2724,9 @@ def test_compaction_block_empty_list_not_added(): import httpx from litellm.types.utils import ModelResponse - + config = AnthropicConfig() - + # Response without compaction blocks completion_response = { "id": "msg_no_compaction", @@ -2698,10 +2745,10 @@ def test_compaction_block_empty_list_not_added(): "output_tokens": 5 } } - + raw_response = httpx.Response(status_code=200, headers={}) model_response = ModelResponse() - + result = config.transform_parsed_response( completion_response=completion_response, raw_response=raw_response, @@ -2709,7 +2756,7 @@ def test_compaction_block_empty_list_not_added(): json_mode=False, prefix_prompt=None, ) - + # Verify compaction_blocks is not in provider_specific_fields when there are none provider_fields = result.choices[0].message.provider_specific_fields if provider_fields: @@ -2721,15 +2768,15 @@ def test_fast_mode_beta_header(): Test that fast mode correctly adds the fast-mode-2026-02-01 beta header. """ config = AnthropicConfig() - + headers = {} optional_params = {"speed": "fast"} - + result_headers = config.update_headers_with_optional_anthropic_beta( headers=headers, optional_params=optional_params ) - + assert "anthropic-beta" in result_headers assert "fast-mode-2026-02-01" in result_headers["anthropic-beta"] @@ -2739,18 +2786,18 @@ def test_fast_mode_with_other_beta_headers(): Test that fast mode beta header is combined with other beta headers. """ config = AnthropicConfig() - + headers = {} optional_params = { "speed": "fast", "output_format": {"type": "json_object"} } - + result_headers = config.update_headers_with_optional_anthropic_beta( headers=headers, optional_params=optional_params ) - + assert "anthropic-beta" in result_headers assert "fast-mode-2026-02-01" in result_headers["anthropic-beta"] assert "structured-outputs-2025-11-13" in result_headers["anthropic-beta"] @@ -2761,18 +2808,18 @@ def test_fast_mode_usage_calculation(): Test that fast mode speed parameter is passed through to usage object. """ config = AnthropicConfig() - + usage_object = { "input_tokens": 1000, "output_tokens": 500, } - + usage = config.calculate_usage( usage_object=usage_object, reasoning_content=None, speed="fast" ) - + assert usage.prompt_tokens == 1000 assert usage.completion_tokens == 500 assert hasattr(usage, "speed") @@ -2791,19 +2838,19 @@ def test_fast_mode_cost_calculation(): # Mock the generic_cost_per_token to verify correct model name is passed with patch('litellm.llms.anthropic.cost_calculation.generic_cost_per_token') as mock_cost: mock_cost.return_value = (0.03, 0.15) # $30 and $150 per MTok - + # Test fast mode usage_fast = Usage( prompt_tokens=1000, completion_tokens=1000, speed="fast" ) - + prompt_cost, completion_cost = cost_per_token( model="claude-opus-4-6", usage=usage_fast ) - + # Verify that generic_cost_per_token was called with "fast/claude-opus-4-6" mock_cost.assert_called_once() call_args = mock_cost.call_args @@ -2824,7 +2871,7 @@ def test_fast_mode_with_inference_geo(): # Mock the generic_cost_per_token to verify correct model name is passed with patch('litellm.llms.anthropic.cost_calculation.generic_cost_per_token') as mock_cost: mock_cost.return_value = (0.03, 0.15) - + # Test with both speed and inference_geo usage = Usage( prompt_tokens=1000, @@ -2832,13 +2879,13 @@ def test_fast_mode_with_inference_geo(): speed="fast", inference_geo="us" ) - + # This should look up "fast/us/claude-opus-4-6" in pricing prompt_cost, completion_cost = cost_per_token( model="claude-opus-4-6", usage=usage ) - + # Verify that generic_cost_per_token was called with "fast/us/claude-opus-4-6" mock_cost.assert_called_once() call_args = mock_cost.call_args @@ -2851,9 +2898,9 @@ def test_fast_mode_parameter_in_supported_params(): Test that 'speed' is in the list of supported OpenAI params. """ config = AnthropicConfig() - + supported_params = config.get_supported_openai_params(model="claude-opus-4-6") - + assert "speed" in supported_params @@ -2862,16 +2909,16 @@ def test_fast_mode_parameter_mapping(): Test that speed parameter is correctly mapped in map_openai_params. """ config = AnthropicConfig() - + non_default_params = {"speed": "fast"} optional_params = {} - + result = config.map_openai_params( non_default_params=non_default_params, optional_params=optional_params, model="claude-opus-4-6", drop_params=False ) - + assert "speed" in result assert result["speed"] == "fast" From 063563608d45fd24cba3fa93a09eb992df64010d Mon Sep 17 00:00:00 2001 From: jtsaw Date: Thu, 19 Feb 2026 12:32:56 -0800 Subject: [PATCH 2/3] fix lint --- litellm/llms/anthropic/chat/transformation.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/litellm/llms/anthropic/chat/transformation.py b/litellm/llms/anthropic/chat/transformation.py index 5855fa2fd54..53f4bd2e5e4 100644 --- a/litellm/llms/anthropic/chat/transformation.py +++ b/litellm/llms/anthropic/chat/transformation.py @@ -229,10 +229,10 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): - maxItems/minItems: Not supported for array types - minimum/maximum: Not supported for numeric types - minLength/maxLength: Not supported for string types - + This mirrors the transformation done by the Anthropic Python SDK. See: https://platform.claude.com/docs/en/build-with-claude/structured-outputs#how-sdk-transformation-works - + The SDK approach: 1. Remove unsupported constraints from schema 2. Add constraint info to description (e.g., "Must be at least 100") @@ -242,8 +242,8 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): Returns: A new dictionary with unsupported fields removed and descriptions updated - - Related issues: + + Related issues: - https://github.com/BerriAI/litellm/issues/19444 """ if not isinstance(schema, dict): @@ -252,7 +252,7 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): # All numeric/string/array constraints not supported by Anthropic unsupported_fields = { "maxItems", "minItems", # array constraints - "minimum", "maximum", # numeric constraints + "minimum", "maximum", # numeric constraints "exclusiveMinimum", "exclusiveMaximum", # numeric constraints "minLength", "maxLength", # string constraints } @@ -1373,7 +1373,7 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): raise ValueError( f"Invalid effort value: {effort}. Must be one of: 'high', 'medium', 'low', 'max'" ) - if effort == "max" and not self._is_claude_opus_4_6(model): + if effort == "max" and not self._is_claude_4_6_model(model): raise ValueError( f"effort='max' is only supported by Claude Opus 4.6. Got model: {model}" ) From 093a67f7745e13f2b0bb12c7f4f6ed93028c4574 Mon Sep 17 00:00:00 2001 From: jtsaw <166962251+jtsaw@users.noreply.github.com> Date: Thu, 19 Feb 2026 12:48:37 -0800 Subject: [PATCH 3/3] Update litellm/llms/anthropic/chat/transformation.py Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- litellm/llms/anthropic/chat/transformation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/litellm/llms/anthropic/chat/transformation.py b/litellm/llms/anthropic/chat/transformation.py index 53f4bd2e5e4..d46714a0c6b 100644 --- a/litellm/llms/anthropic/chat/transformation.py +++ b/litellm/llms/anthropic/chat/transformation.py @@ -1375,7 +1375,7 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): ) if effort == "max" and not self._is_claude_4_6_model(model): raise ValueError( - f"effort='max' is only supported by Claude Opus 4.6. Got model: {model}" + f"effort='max' is only supported by Claude 4.6 models (Opus 4.6, Sonnet 4.6). Got model: {model}" ) data["output_config"] = output_config