From 7dc8df7eda4d44023a0807572fcf7d272addc0d5 Mon Sep 17 00:00:00 2001 From: Yujong Lee Date: Fri, 4 Sep 2026 12:11:37 -0700 Subject: [PATCH] refactor(auth): build normalized headers immutably --- litellm/llms/azure/common_utils.py | 14 +++++++------- .../anthropic/messages_transformation.py | 17 ++++++++++------- .../llms/azure_ai/anthropic/transformation.py | 17 ++++++++++------- litellm/llms/azure_ai/chat/transformation.py | 11 ++++++----- .../audio_transcription/transformation.py | 9 +++++---- litellm/llms/reducto/ocr/transformation.py | 5 ++++- .../test_azure_anthropic_transformation.py | 16 ++++++---------- 7 files changed, 48 insertions(+), 41 deletions(-) diff --git a/litellm/llms/azure/common_utils.py b/litellm/llms/azure/common_utils.py index 5a331f40253..823ac89af2f 100644 --- a/litellm/llms/azure/common_utils.py +++ b/litellm/llms/azure/common_utils.py @@ -718,11 +718,11 @@ class BaseAzureLLM(BaseOpenAILLM): (value for name, value in headers.items() if name.lower() == "api-key" and str(value).strip()), None, ) - non_auth_headers: Final = { - name: value for name, value in headers.items() if name.lower() not in {"api-key", "authorization"} - } + non_auth_headers: Final = MappingProxyType( + {name: value for name, value in headers.items() if name.lower() not in ("api-key", "authorization")} + ) if forwarded_api_key is not None: - return {**non_auth_headers, "api-key": forwarded_api_key} + return MappingProxyType({**non_auth_headers, "api-key": forwarded_api_key}).copy() api_key: Final = ( litellm_params.api_key @@ -733,15 +733,15 @@ class BaseAzureLLM(BaseOpenAILLM): ) if api_key: - return {**non_auth_headers, "api-key": api_key} + return MappingProxyType({**non_auth_headers, "api-key": api_key}).copy() ### Fallback to Azure AD token-based authentication if no API key is available ### Retrieves Azure AD token and adds it to the Authorization header azure_ad_token: Final = get_azure_ad_token(litellm_params) if azure_ad_token: - return {**non_auth_headers, "Authorization": f"Bearer {azure_ad_token}"} + return MappingProxyType({**non_auth_headers, "Authorization": f"Bearer {azure_ad_token}"}).copy() - return non_auth_headers + return non_auth_headers.copy() @staticmethod def _get_base_azure_url( diff --git a/litellm/llms/azure_ai/anthropic/messages_transformation.py b/litellm/llms/azure_ai/anthropic/messages_transformation.py index ff833498588..afb8983acba 100644 --- a/litellm/llms/azure_ai/anthropic/messages_transformation.py +++ b/litellm/llms/azure_ai/anthropic/messages_transformation.py @@ -2,6 +2,7 @@ Azure Anthropic messages transformation config - extends AnthropicMessagesConfig with Azure authentication """ +from types import MappingProxyType from typing import Any, Final from litellm.llms.anthropic.experimental_pass_through.messages.transformation import ( @@ -57,16 +58,18 @@ class AzureAnthropicMessagesConfig(AnthropicMessagesConfig): None, ) if azure_api_key is not None: - headers = { - **{ + non_auth_headers: Final = MappingProxyType( + { name: value for name, value in headers.items() - if name.lower() not in {"api-key", "x-api-key", "authorization"} - }, - "x-api-key": azure_api_key, - } + if name.lower() not in ("api-key", "x-api-key", "authorization") + } + ) + headers = MappingProxyType({**non_auth_headers, "x-api-key": azure_api_key}).copy() elif any(name.lower() == "authorization" for name in headers): - headers = {name: value for name, value in headers.items() if name.lower() != "x-api-key"} + headers = MappingProxyType( + {name: value for name, value in headers.items() if name.lower() != "x-api-key"} + ).copy() # Set anthropic-version header if "anthropic-version" not in headers: diff --git a/litellm/llms/azure_ai/anthropic/transformation.py b/litellm/llms/azure_ai/anthropic/transformation.py index 8bf2a7aa7c1..0aa58b4d1fd 100644 --- a/litellm/llms/azure_ai/anthropic/transformation.py +++ b/litellm/llms/azure_ai/anthropic/transformation.py @@ -2,6 +2,7 @@ Azure Anthropic transformation config - extends AnthropicConfig with Azure authentication """ +from types import MappingProxyType from typing import Final from litellm.llms.anthropic.chat.transformation import AnthropicConfig @@ -80,14 +81,14 @@ class AzureAnthropicConfig(AnthropicConfig): None, ) if azure_api_key is not None: - headers = { - **{ + non_auth_headers: Final = MappingProxyType( + { name: value for name, value in headers.items() - if name.lower() not in {"api-key", "x-api-key", "authorization"} - }, - "x-api-key": azure_api_key, - } + if name.lower() not in ("api-key", "x-api-key", "authorization") + } + ) + headers = MappingProxyType({**non_auth_headers, "x-api-key": azure_api_key}).copy() # Get tools and other anthropic-specific setup tools: Final = optional_params.get("tools") @@ -114,7 +115,9 @@ class AzureAnthropicConfig(AnthropicConfig): # Merge headers - Azure auth (api-key or Authorization) takes precedence headers = {**anthropic_headers, **headers} if "Authorization" in headers: - headers = {name: value for name, value in headers.items() if name.lower() != "x-api-key"} + headers = MappingProxyType( + {name: value for name, value in headers.items() if name.lower() != "x-api-key"} + ).copy() # Ensure anthropic-version header is set if "anthropic-version" not in headers: diff --git a/litellm/llms/azure_ai/chat/transformation.py b/litellm/llms/azure_ai/chat/transformation.py index 36620285f4f..8b87f2c17bf 100644 --- a/litellm/llms/azure_ai/chat/transformation.py +++ b/litellm/llms/azure_ai/chat/transformation.py @@ -1,6 +1,7 @@ import copy import enum import re +from types import MappingProxyType from typing import TYPE_CHECKING, Final, cast from urllib.parse import urlparse @@ -81,14 +82,14 @@ class AzureAIStudioConfig(OpenAIConfig): api_key: str | None = None, api_base: str | None = None, ) -> dict: - non_auth_headers: Final = { - name: value for name, value in headers.items() if name.lower() not in {"api-key", "authorization"} - } + non_auth_headers: Final = MappingProxyType( + {name: value for name, value in headers.items() if name.lower() not in ("api-key", "authorization")} + ) if api_key: if api_base and self._should_use_api_key_header(api_base): - headers = {**non_auth_headers, "api-key": api_key} + headers = MappingProxyType({**non_auth_headers, "api-key": api_key}).copy() else: - headers = {**non_auth_headers, "Authorization": f"Bearer {api_key}"} + headers = MappingProxyType({**non_auth_headers, "Authorization": f"Bearer {api_key}"}).copy() else: # No api_key provided — fall back to Azure AD token-based auth litellm_params_obj = GenericLiteLLMParams(**(litellm_params if isinstance(litellm_params, dict) else {})) diff --git a/litellm/llms/mistral/audio_transcription/transformation.py b/litellm/llms/mistral/audio_transcription/transformation.py index 9c7ec86e614..ef48aa26877 100644 --- a/litellm/llms/mistral/audio_transcription/transformation.py +++ b/litellm/llms/mistral/audio_transcription/transformation.py @@ -4,6 +4,7 @@ Support for Mistral Voxtral audio transcription via ``/v1/audio/transcriptions`` API reference: https://docs.mistral.ai/api/#tag/audio/operation/audio_transcriptions_v1_audio_transcriptions_post """ +from types import MappingProxyType from typing import Final import httpx @@ -86,10 +87,10 @@ class MistralAudioTranscriptionConfig(BaseAudioTranscriptionConfig): "Authorization": f"Bearer {api_key}", "accept": "application/json", } - non_auth_headers: Final = { - name: value for name, value in (headers or {}).items() if name.lower() != "authorization" - } - return {**non_auth_headers, **default_headers} + non_auth_headers: Final = MappingProxyType( + {name: value for name, value in headers.items() if name.lower() != "authorization"} + ) + return MappingProxyType({**non_auth_headers, **default_headers}).copy() def transform_audio_transcription_request( self, diff --git a/litellm/llms/reducto/ocr/transformation.py b/litellm/llms/reducto/ocr/transformation.py index 12412fce379..f298ab6a360 100644 --- a/litellm/llms/reducto/ocr/transformation.py +++ b/litellm/llms/reducto/ocr/transformation.py @@ -1,3 +1,4 @@ +from types import MappingProxyType from typing import TYPE_CHECKING, Any, Final import httpx @@ -55,7 +56,9 @@ class _BaseReductoOCRConfig(BaseOCRConfig): "Missing REDUCTO_API_KEY - set it in the environment or pass api_key to litellm.ocr()/litellm.aocr()" ) - non_auth_headers: Final = {name: value for name, value in headers.items() if name.lower() != "authorization"} + non_auth_headers: Final = MappingProxyType( + {name: value for name, value in headers.items() if name.lower() != "authorization"} + ) return { **non_auth_headers, "Authorization": f"Bearer {resolved_key}", diff --git a/tests/test_litellm/llms/azure_ai/claude/test_azure_anthropic_transformation.py b/tests/test_litellm/llms/azure_ai/claude/test_azure_anthropic_transformation.py index 7f1ac5459ae..5128b11a46e 100644 --- a/tests/test_litellm/llms/azure_ai/claude/test_azure_anthropic_transformation.py +++ b/tests/test_litellm/llms/azure_ai/claude/test_azure_anthropic_transformation.py @@ -5,7 +5,7 @@ sys.path.insert( 0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../../../..")) ) -from unittest.mock import MagicMock, patch +from unittest.mock import patch import pytest @@ -118,8 +118,7 @@ class TestAzureAnthropicConfig: call_args = mock_validate.call_args assert call_args[1]["litellm_params"].api_key == "provided-api-key" - def test_validate_environment_preserves_api_key_header(self): - """Test that api-key header is preserved as-is (Azure handles the header internally)""" + def test_validate_environment_converts_api_key_header(self): config = AzureAnthropicConfig() headers = {} model = "claude-sonnet-4-5" @@ -140,9 +139,8 @@ class TestAzureAnthropicConfig: litellm_params=litellm_params, ) - # Verify api-key header is preserved as-is - assert "api-key" in result - assert result["api-key"] == "test-api-key" + assert result["x-api-key"] == "test-api-key" + assert "api-key" not in result def test_validate_environment_sets_anthropic_version(self): """Test that anthropic-version header is set""" @@ -217,8 +215,8 @@ class TestAzureAnthropicConfig: See: https://github.com/BerriAI/litellm/issues/XXXX """ config = AzureAnthropicConfig() - messages = [{"role": "user", "content": "Hello"}] + optional_params = { "max_tokens": 100, } @@ -260,8 +258,8 @@ class TestAzureAnthropicConfig: def test_context_management_compact_beta_header(self): """Test that context_management with compact adds the correct beta header for Azure AI""" config = AzureAnthropicConfig() - messages = [{"role": "user", "content": "Hello"}] + optional_params = { "context_management": {"edits": [{"type": "compact_20260112"}]}, "max_tokens": 100, @@ -289,7 +287,6 @@ class TestAzureAnthropicConfig: """Test that compact beta header is added to headers for Azure AI""" config = AzureAnthropicConfig() - messages = [{"role": "user", "content": "Hello"}] optional_params = { "context_management": {"edits": [{"type": "compact_20260112"}]}, "max_tokens": 100, @@ -428,7 +425,6 @@ class TestAzureAnthropicConfig: """Test that context_management with both compact and other edits adds both beta headers""" config = AzureAnthropicConfig() - messages = [{"role": "user", "content": "Hello"}] optional_params = { "context_management": { "edits": [