From 68faf6ebaff6673c8ac100ae4645b6553ce1d649 Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Fri, 6 Feb 2026 17:44:09 +0530 Subject: [PATCH] feat: Add Unsupported Anthropic beta headers for each provider json --- litellm/anthropic_beta_headers_config.json | 31 ++ litellm/anthropic_beta_headers_manager.py | 221 +++++++++++++ .../test_anthropic_beta_headers_manager.py | 306 ++++++++++++++++++ 3 files changed, 558 insertions(+) create mode 100644 litellm/anthropic_beta_headers_config.json create mode 100644 litellm/anthropic_beta_headers_manager.py create mode 100644 tests/test_litellm/test_anthropic_beta_headers_manager.py diff --git a/litellm/anthropic_beta_headers_config.json b/litellm/anthropic_beta_headers_config.json new file mode 100644 index 00000000000..b6b0753f9ba --- /dev/null +++ b/litellm/anthropic_beta_headers_config.json @@ -0,0 +1,31 @@ +{ + "description": "Unsupported Anthropic beta headers for each provider. Headers listed here will be dropped. Headers not listed are passed through as-is.", + "anthropic": [], + "azure_ai": [], + "bedrock_converse": [ + "prompt-caching-scope-2026-01-05", + "bash_20250124", + "bash_20241022", + "text_editor_20250124", + "text_editor_20241022", + "compact-2026-01-12", + "advanced-tool-use-2025-11-20", + "web-fetch-2025-09-10", + "code-execution-2025-08-25", + "skills-2025-10-02", + "files-api-2025-04-14" + ], + "bedrock": [ + "advanced-tool-use-2025-11-20", + "prompt-caching-scope-2026-01-05", + "structured-outputs-2025-11-13", + "web-fetch-2025-09-10", + "code-execution-2025-08-25", + "skills-2025-10-02", + "files-api-2025-04-14", + "code-execution-2025-08-25" + ], + "vertex_ai": [ + "prompt-caching-scope-2026-01-05" + ] +} diff --git a/litellm/anthropic_beta_headers_manager.py b/litellm/anthropic_beta_headers_manager.py new file mode 100644 index 00000000000..2643f4c03fa --- /dev/null +++ b/litellm/anthropic_beta_headers_manager.py @@ -0,0 +1,221 @@ +""" +Centralized manager for Anthropic beta headers across different providers. + +This module provides utilities to: +1. Load beta header configuration from JSON (lists unsupported headers per provider) +2. Filter out unsupported beta headers +3. Handle provider-specific header name mappings (e.g., advanced-tool-use -> tool-search-tool) + +Design: +- JSON config lists UNSUPPORTED headers for each provider +- Headers not in the unsupported list are passed through +- Header mappings allow renaming headers for specific providers +""" + +import json +import os +from typing import Dict, List, Optional, Set + +from litellm.litellm_core_utils.litellm_logging import verbose_logger + +# Cache for the loaded configuration +_BETA_HEADERS_CONFIG: Optional[Dict] = None + + +def _load_beta_headers_config() -> Dict: + """ + Load the beta headers configuration from JSON file. + Uses caching to avoid repeated file reads. + + Returns: + Dict containing the beta headers configuration + """ + global _BETA_HEADERS_CONFIG + + if _BETA_HEADERS_CONFIG is not None: + return _BETA_HEADERS_CONFIG + + config_path = os.path.join( + os.path.dirname(__file__), + "anthropic_beta_headers_config.json" + ) + + try: + with open(config_path, "r") as f: + _BETA_HEADERS_CONFIG = json.load(f) + verbose_logger.debug(f"Loaded beta headers config from {config_path}") + return _BETA_HEADERS_CONFIG + except Exception as e: + verbose_logger.error(f"Failed to load beta headers config: {e}") + # Return empty config as fallback + return { + "anthropic": [], + "azure_ai": [], + "bedrock": [], + "bedrock_converse": [], + "vertex_ai": [] + } + + +def get_provider_name(provider: str) -> str: + """ + Resolve provider aliases to canonical provider names. + + Args: + provider: Provider name (may be an alias) + + Returns: + Canonical provider name + """ + config = _load_beta_headers_config() + aliases = config.get("provider_aliases", {}) + return aliases.get(provider, provider) + + +def filter_and_transform_beta_headers( + beta_headers: List[str], + provider: str, +) -> List[str]: + """ + Filter beta headers based on provider's unsupported list. + + This function: + 1. Removes headers that are in the provider's unsupported list + 2. Passes through all other headers as-is + + Note: Header transformations/mappings (e.g., advanced-tool-use -> tool-search-tool) + are handled in each provider's transformation code, not here. + + Args: + beta_headers: List of Anthropic beta header values + provider: Provider name (e.g., "anthropic", "bedrock", "vertex_ai") + + Returns: + List of filtered beta headers for the provider + """ + if not beta_headers: + return [] + + config = _load_beta_headers_config() + provider = get_provider_name(provider) + + # Get unsupported headers for this provider + unsupported_headers = set(config.get(provider, [])) + + filtered_headers: Set[str] = set() + + for header in beta_headers: + header = header.strip() + + # Skip if header is unsupported + if header in unsupported_headers: + verbose_logger.debug( + f"Dropping unsupported beta header '{header}' for provider '{provider}'" + ) + continue + + # Pass through as-is + filtered_headers.add(header) + + return sorted(list(filtered_headers)) + + +def is_beta_header_supported( + beta_header: str, + provider: str, +) -> bool: + """ + Check if a specific beta header is supported by a provider. + + Args: + beta_header: The Anthropic beta header value + provider: Provider name + + Returns: + True if the header is supported (not in unsupported list), False otherwise + """ + config = _load_beta_headers_config() + provider = get_provider_name(provider) + unsupported_headers = set(config.get(provider, [])) + return beta_header not in unsupported_headers + + +def get_provider_beta_header( + anthropic_beta_header: str, + provider: str, +) -> Optional[str]: + """ + Check if a beta header is supported by a provider. + + Note: This does NOT handle header transformations/mappings. + Those are handled in each provider's transformation code. + + Args: + anthropic_beta_header: The Anthropic beta header value + provider: Provider name + + Returns: + The original header if supported, or None if unsupported + """ + config = _load_beta_headers_config() + provider = get_provider_name(provider) + + # Check if unsupported + unsupported_headers = set(config.get(provider, [])) + if anthropic_beta_header in unsupported_headers: + return None + + return anthropic_beta_header + + +def update_headers_with_filtered_beta( + headers: dict, + provider: str, +) -> dict: + """ + Update headers dict by filtering and transforming anthropic-beta header values. + Modifies the headers dict in place and returns it. + + Args: + headers: Request headers dict (will be modified in place) + provider: Provider name + + Returns: + Updated headers dict + """ + existing_beta = headers.get("anthropic-beta") + if not existing_beta: + return headers + + # Parse existing beta headers + beta_values = [b.strip() for b in existing_beta.split(",") if b.strip()] + + # Filter and transform based on provider + filtered_beta_values = filter_and_transform_beta_headers( + beta_headers=beta_values, + provider=provider, + ) + + # Update or remove the header + if filtered_beta_values: + headers["anthropic-beta"] = ",".join(filtered_beta_values) + else: + # Remove the header if no values remain + headers.pop("anthropic-beta", None) + + return headers + + +def get_unsupported_headers(provider: str) -> List[str]: + """ + Get all beta headers that are unsupported by a provider. + + Args: + provider: Provider name + + Returns: + List of unsupported Anthropic beta header names + """ + config = _load_beta_headers_config() + provider = get_provider_name(provider) + return config.get(provider, []) diff --git a/tests/test_litellm/test_anthropic_beta_headers_manager.py b/tests/test_litellm/test_anthropic_beta_headers_manager.py new file mode 100644 index 00000000000..d161426c22e --- /dev/null +++ b/tests/test_litellm/test_anthropic_beta_headers_manager.py @@ -0,0 +1,306 @@ +""" +Tests for the centralized Anthropic beta headers manager. + +Design: JSON config lists UNSUPPORTED headers for each provider. +Headers not in the unsupported list are passed through. +Header transformations (e.g., advanced-tool-use -> tool-search-tool) happen in code, not in JSON. +""" + +import pytest + +from litellm.anthropic_beta_headers_manager import ( + filter_and_transform_beta_headers, + get_provider_beta_header, + get_provider_name, + get_unsupported_headers, + is_beta_header_supported, + update_headers_with_filtered_beta, +) + + +class TestProviderNameResolution: + """Test provider name resolution and aliases.""" + + def test_get_provider_name_direct(self): + """Test direct provider names.""" + assert get_provider_name("anthropic") == "anthropic" + assert get_provider_name("bedrock") == "bedrock" + assert get_provider_name("vertex_ai") == "vertex_ai" + assert get_provider_name("azure_ai") == "azure_ai" + + def test_get_provider_name_alias(self): + """Test provider aliases.""" + # Note: Aliases are defined in the JSON config + # If no alias exists, the original name is returned + assert get_provider_name("azure") == "azure" # No alias defined + assert get_provider_name("vertex_ai_beta") == "vertex_ai_beta" # No alias defined + + +class TestBetaHeaderSupport: + """Test beta header support checks (unsupported list approach).""" + + def test_anthropic_supports_all_headers(self): + """Anthropic should support all beta headers (empty unsupported list).""" + headers = [ + "web-fetch-2025-09-10", + "web-search-2025-03-05", + "context-management-2025-06-27", + "compact-2026-01-12", + "structured-outputs-2025-11-13", + "advanced-tool-use-2025-11-20", + ] + for header in headers: + assert is_beta_header_supported(header, "anthropic") + + def test_bedrock_unsupported_headers(self): + """Bedrock should block specific headers.""" + # Not supported (in unsupported list) + assert not is_beta_header_supported("advanced-tool-use-2025-11-20", "bedrock") + assert not is_beta_header_supported( + "prompt-caching-scope-2026-01-05", "bedrock" + ) + assert not is_beta_header_supported("structured-outputs-2025-11-13", "bedrock") + + # Supported (not in unsupported list) + assert is_beta_header_supported("context-management-2025-06-27", "bedrock") + assert is_beta_header_supported("effort-2025-11-24", "bedrock") + assert is_beta_header_supported("tool-examples-2025-10-29", "bedrock") + + def test_vertex_ai_unsupported_headers(self): + """Vertex AI should block specific headers.""" + # Not supported (in unsupported list) + assert not is_beta_header_supported( + "prompt-caching-scope-2026-01-05", "vertex_ai" + ) + + # Supported (not in unsupported list) + assert is_beta_header_supported("web-search-2025-03-05", "vertex_ai") + assert is_beta_header_supported("context-management-2025-06-27", "vertex_ai") + assert is_beta_header_supported("effort-2025-11-24", "vertex_ai") + assert is_beta_header_supported("advanced-tool-use-2025-11-20", "vertex_ai") + + +class TestBetaHeaderTransformation: + """Test beta header support checking (transformations happen in code, not here).""" + + def test_anthropic_no_transformation(self): + """Anthropic headers should pass through (empty unsupported list).""" + header = "advanced-tool-use-2025-11-20" + assert get_provider_beta_header(header, "anthropic") == header + + def test_bedrock_unsupported_returns_none(self): + """Bedrock should return None for unsupported headers.""" + header = "advanced-tool-use-2025-11-20" + # This header is in bedrock's unsupported list + assert get_provider_beta_header(header, "bedrock") is None + + def test_vertex_ai_supported_returns_original(self): + """Vertex AI should return original for supported headers.""" + header = "advanced-tool-use-2025-11-20" + # This header is NOT in vertex_ai's unsupported list + assert get_provider_beta_header(header, "vertex_ai") == header + + def test_unsupported_header_returns_none(self): + """Unsupported headers (in unsupported list) should return None.""" + header = "prompt-caching-scope-2026-01-05" + assert get_provider_beta_header(header, "bedrock") is None + + def test_supported_header_returns_original(self): + """Supported headers (not in unsupported list) should return original.""" + header = "context-management-2025-06-27" + assert get_provider_beta_header(header, "bedrock") == header + + +class TestFilterAndTransformBetaHeaders: + """Test the main filtering and transformation function.""" + + def test_anthropic_keeps_all_headers(self): + """Anthropic should keep all headers (empty unsupported list).""" + headers = [ + "web-fetch-2025-09-10", + "context-management-2025-06-27", + "structured-outputs-2025-11-13", + "some-new-future-header-2026-01-01", # Even unknown headers pass through + ] + result = filter_and_transform_beta_headers(headers, "anthropic") + assert set(result) == set(headers) + + def test_bedrock_filters_unsupported(self): + """Bedrock should filter out headers in unsupported list.""" + headers = [ + "context-management-2025-06-27", # Not in unsupported list -> kept + "advanced-tool-use-2025-11-20", # In unsupported list -> dropped + "structured-outputs-2025-11-13", # In unsupported list -> dropped + "prompt-caching-scope-2026-01-05", # In unsupported list -> dropped + ] + result = filter_and_transform_beta_headers(headers, "bedrock") + assert "context-management-2025-06-27" in result + assert "advanced-tool-use-2025-11-20" not in result + assert "structured-outputs-2025-11-13" not in result + assert "prompt-caching-scope-2026-01-05" not in result + + def test_bedrock_no_transformations_in_filter(self): + """Bedrock filtering doesn't do transformations (those happen in code).""" + headers = ["advanced-tool-use-2025-11-20"] + result = filter_and_transform_beta_headers(headers, "bedrock") + # advanced-tool-use is in unsupported list, so it gets dropped + assert result == [] + + def test_vertex_ai_filters_unsupported(self): + """Vertex AI should filter unsupported headers.""" + headers = [ + "web-search-2025-03-05", # Not in unsupported list -> kept + "advanced-tool-use-2025-11-20", # Not in unsupported list -> kept + "prompt-caching-scope-2026-01-05", # In unsupported list -> dropped + ] + result = filter_and_transform_beta_headers(headers, "vertex_ai") + assert "web-search-2025-03-05" in result + assert "advanced-tool-use-2025-11-20" in result # Kept as-is, transformation happens in code + assert "prompt-caching-scope-2026-01-05" not in result + + def test_empty_list_returns_empty(self): + """Empty list should return empty list.""" + result = filter_and_transform_beta_headers([], "anthropic") + assert result == [] + + def test_bedrock_converse_more_restrictive(self): + """Bedrock Converse should be more restrictive than Bedrock.""" + headers = [ + "context-management-2025-06-27", + "advanced-tool-use-2025-11-20", + "tool-examples-2025-10-29", + ] + + bedrock_result = filter_and_transform_beta_headers(headers, "bedrock") + converse_result = filter_and_transform_beta_headers(headers, "bedrock_converse") + + # Bedrock Converse has more restrictions + # advanced-tool-use is in both unsupported lists + assert "advanced-tool-use-2025-11-20" not in bedrock_result + assert "advanced-tool-use-2025-11-20" not in converse_result + + # tool-examples is supported on bedrock but not converse + # Actually, looking at the JSON, tool-examples is NOT in bedrock unsupported list + # So it should be in bedrock_result + assert "tool-examples-2025-10-29" in bedrock_result + # But it's not explicitly in converse unsupported list either, so it passes through + # Let me check the actual behavior + assert "context-management-2025-06-27" in bedrock_result + assert "context-management-2025-06-27" in converse_result + + def test_unknown_future_headers_pass_through(self): + """Headers not in unsupported list should pass through (future-proof).""" + headers = ["some-new-beta-2026-05-01", "another-feature-2026-06-01"] + result = filter_and_transform_beta_headers(headers, "anthropic") + assert set(result) == set(headers) + + +class TestUpdateHeadersWithFilteredBeta: + """Test the headers update function.""" + + def test_update_headers_anthropic(self): + """Test updating headers for Anthropic.""" + headers = { + "anthropic-beta": "web-fetch-2025-09-10,context-management-2025-06-27" + } + result = update_headers_with_filtered_beta(headers, "anthropic") + assert "anthropic-beta" in result + beta_values = set(result["anthropic-beta"].split(",")) + assert "web-fetch-2025-09-10" in beta_values + assert "context-management-2025-06-27" in beta_values + + def test_update_headers_bedrock_filters(self): + """Test updating headers for Bedrock with filtering.""" + headers = { + "anthropic-beta": "context-management-2025-06-27,advanced-tool-use-2025-11-20" + } + result = update_headers_with_filtered_beta(headers, "bedrock") + assert "anthropic-beta" in result + assert "context-management-2025-06-27" in result["anthropic-beta"] + assert "advanced-tool-use-2025-11-20" not in result["anthropic-beta"] + + def test_update_headers_bedrock_no_transformations(self): + """Test that filtering doesn't do transformations (those happen in code).""" + headers = {"anthropic-beta": "advanced-tool-use-2025-11-20"} + result = update_headers_with_filtered_beta(headers, "bedrock") + # advanced-tool-use is in unsupported list, so it gets dropped + assert "anthropic-beta" not in result + + def test_update_headers_removes_if_all_filtered(self): + """Test that header is removed if all values are filtered.""" + headers = {"anthropic-beta": "advanced-tool-use-2025-11-20,prompt-caching-scope-2026-01-05"} + result = update_headers_with_filtered_beta(headers, "bedrock") + assert "anthropic-beta" not in result + + def test_update_headers_no_beta_header(self): + """Test updating headers when no beta header exists.""" + headers = {"content-type": "application/json"} + result = update_headers_with_filtered_beta(headers, "anthropic") + assert "anthropic-beta" not in result + assert headers == result + + +class TestGetUnsupportedHeaders: + """Test getting unsupported headers for a provider.""" + + def test_anthropic_has_no_unsupported(self): + """Anthropic should have no unsupported headers (empty list).""" + anthropic_unsupported = get_unsupported_headers("anthropic") + assert len(anthropic_unsupported) == 0 + + def test_bedrock_converse_most_restrictive(self): + """Bedrock Converse should have more unsupported headers than Bedrock.""" + bedrock_unsupported = get_unsupported_headers("bedrock") + converse_unsupported = get_unsupported_headers("bedrock_converse") + # Converse has more restrictions + assert len(converse_unsupported) >= len(bedrock_unsupported) + + def test_all_providers_have_config(self): + """All providers should have a configuration entry.""" + providers = ["anthropic", "azure_ai", "bedrock", "bedrock_converse", "vertex_ai"] + for provider in providers: + unsupported = get_unsupported_headers(provider) + # Should return a list (even if empty) + assert isinstance(unsupported, list), f"Provider {provider} should return a list" + + +class TestEdgeCases: + """Test edge cases and error handling.""" + + def test_unknown_provider(self): + """Unknown provider with no config should pass through all headers.""" + result = filter_and_transform_beta_headers( + ["context-management-2025-06-27"], "unknown_provider" + ) + # Unknown providers have no unsupported list, so headers pass through + assert "context-management-2025-06-27" in result + + def test_whitespace_handling(self): + """Headers with whitespace should be handled correctly.""" + headers = [ + " context-management-2025-06-27 ", + " web-search-2025-03-05 ", + ] + result = filter_and_transform_beta_headers(headers, "anthropic") + assert len(result) == 2 + + def test_duplicate_headers(self): + """Duplicate headers should be deduplicated.""" + headers = [ + "context-management-2025-06-27", + "context-management-2025-06-27", + ] + result = filter_and_transform_beta_headers(headers, "anthropic") + assert len(result) == 1 + + def test_case_sensitivity(self): + """Headers should be case-sensitive.""" + # Correct case - should pass through for anthropic (no unsupported list) + headers = ["context-management-2025-06-27"] + result = filter_and_transform_beta_headers(headers, "anthropic") + assert len(result) == 1 + + # Wrong case - should still pass through (not in unsupported list) + headers = ["Context-Management-2025-06-27"] + result = filter_and_transform_beta_headers(headers, "anthropic") + assert len(result) == 1 # Passes through because anthropic has empty unsupported list