From f3e31bc8efbd7e5634c612b86285540ca8e0c673 Mon Sep 17 00:00:00 2001 From: Gaurav Singh <103016722+gavksingh@users.noreply.github.com> Date: Thu, 26 Feb 2026 03:06:18 -0500 Subject: [PATCH 1/4] fix(proxy): honor MAX_STRING_LENGTH_PROMPT_IN_DB from config env vars (#22106) * fix(proxy): honor MAX_STRING_LENGTH_PROMPT_IN_DB from config env vars * fix(proxy): reuse constants fallback for MAX_STRING_LENGTH_PROMPT_IN_DB runtime resolver * test(proxy): restore PEP8 spacing between spend tracking tests --- .../spend_tracking/spend_tracking_utils.py | 39 +++++++++++++++---- .../test_spend_tracking_utils.py | 16 +++++++- 2 files changed, 46 insertions(+), 9 deletions(-) diff --git a/litellm/proxy/spend_tracking/spend_tracking_utils.py b/litellm/proxy/spend_tracking/spend_tracking_utils.py index 1ef8556233..31615a768d 100644 --- a/litellm/proxy/spend_tracking/spend_tracking_utils.py +++ b/litellm/proxy/spend_tracking/spend_tracking_utils.py @@ -1,5 +1,6 @@ import hashlib import json +import os import secrets from datetime import datetime from datetime import datetime as dt @@ -10,7 +11,10 @@ from pydantic import BaseModel import litellm from litellm._logging import verbose_proxy_logger -from litellm.constants import MAX_STRING_LENGTH_PROMPT_IN_DB, REDACTED_BY_LITELM_STRING +from litellm.constants import ( + MAX_STRING_LENGTH_PROMPT_IN_DB as DEFAULT_MAX_STRING_LENGTH_PROMPT_IN_DB, +) +from litellm.constants import REDACTED_BY_LITELM_STRING from litellm.litellm_core_utils.core_helpers import ( get_litellm_metadata_from_kwargs, reconstruct_model_name, @@ -30,6 +34,20 @@ from litellm.types.utils import ( from litellm.utils import get_end_user_id_for_cost_tracking +def _get_max_string_length_prompt_in_db() -> int: + """ + Resolve prompt truncation threshold at runtime so values loaded later via + proxy config environment_variables are honored. + """ + max_length_str = os.getenv("MAX_STRING_LENGTH_PROMPT_IN_DB") + if max_length_str is None: + return DEFAULT_MAX_STRING_LENGTH_PROMPT_IN_DB + try: + return int(max_length_str) + except (TypeError, ValueError): + return DEFAULT_MAX_STRING_LENGTH_PROMPT_IN_DB + + def _is_master_key(api_key: str, _master_key: Optional[str]) -> bool: if _master_key is None: return False @@ -609,6 +627,7 @@ def _get_messages_for_spend_logs_payload( def _sanitize_request_body_for_spend_logs_payload( request_body: dict, visited: Optional[set] = None, + max_string_length_prompt_in_db: Optional[int] = None, ) -> dict: """ Recursively sanitize request body to prevent logging large base64 strings or other large values. @@ -618,6 +637,8 @@ def _sanitize_request_body_for_spend_logs_payload( if visited is None: visited = set() + if max_string_length_prompt_in_db is None: + max_string_length_prompt_in_db = _get_max_string_length_prompt_in_db() # Get the object's memory address to track visited objects obj_id = id(request_body) @@ -627,27 +648,29 @@ def _sanitize_request_body_for_spend_logs_payload( def _sanitize_value(value: Any) -> Any: if isinstance(value, dict): - return _sanitize_request_body_for_spend_logs_payload(value, visited) + return _sanitize_request_body_for_spend_logs_payload( + value, visited, max_string_length_prompt_in_db + ) elif isinstance(value, list): return [_sanitize_value(item) for item in value] elif isinstance(value, str): - if len(value) > MAX_STRING_LENGTH_PROMPT_IN_DB: + if len(value) > max_string_length_prompt_in_db: # Keep 35% from beginning and 65% from end (end is usually more important) # This split ensures we keep more context from the end of conversations start_ratio = 0.35 end_ratio = 0.65 # Calculate character distribution - start_chars = int(MAX_STRING_LENGTH_PROMPT_IN_DB * start_ratio) - end_chars = int(MAX_STRING_LENGTH_PROMPT_IN_DB * end_ratio) + start_chars = int(max_string_length_prompt_in_db * start_ratio) + end_chars = int(max_string_length_prompt_in_db * end_ratio) # Ensure we don't exceed the total limit total_keep = start_chars + end_chars - if total_keep > MAX_STRING_LENGTH_PROMPT_IN_DB: - end_chars = MAX_STRING_LENGTH_PROMPT_IN_DB - start_chars + if total_keep > max_string_length_prompt_in_db: + end_chars = max_string_length_prompt_in_db - start_chars # If the string length is less than what we want to keep, just truncate normally - if len(value) <= MAX_STRING_LENGTH_PROMPT_IN_DB: + if len(value) <= max_string_length_prompt_in_db: return value # Calculate how many characters are being skipped diff --git a/tests/test_litellm/proxy/spend_tracking/test_spend_tracking_utils.py b/tests/test_litellm/proxy/spend_tracking/test_spend_tracking_utils.py index 30257cd2da..24f45cc5c9 100644 --- a/tests/test_litellm/proxy/spend_tracking/test_spend_tracking_utils.py +++ b/tests/test_litellm/proxy/spend_tracking/test_spend_tracking_utils.py @@ -161,6 +161,21 @@ def test_sanitize_request_body_for_spend_logs_payload_mixed_types(): assert len(sanitized["nested"]["dict"]["key"]) == expected_length +def test_sanitize_request_body_for_spend_logs_payload_uses_runtime_env_override( + monkeypatch: pytest.MonkeyPatch, +): + from litellm.constants import MAX_STRING_LENGTH_PROMPT_IN_DB + + override_max = max(MAX_STRING_LENGTH_PROMPT_IN_DB + 1000, 6000) + test_string = "a" * (MAX_STRING_LENGTH_PROMPT_IN_DB + 500) + + # Simulate config-loaded env var being set after module import. + monkeypatch.setenv("MAX_STRING_LENGTH_PROMPT_IN_DB", str(override_max)) + + sanitized = _sanitize_request_body_for_spend_logs_payload({"text": test_string}) + assert sanitized["text"] == test_string + + def test_sanitize_request_body_for_spend_logs_payload_circular_reference(): # Create a circular reference a: dict[str, Any] = {} @@ -1349,4 +1364,3 @@ def test_get_logging_payload_includes_request_duration_ms(): ) assert payload["request_duration_ms"] == 3000 - From efeaf650aace39e708d35dbdfe161b6a3c610579 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Benedikt=20=C3=93skarsson?= Date: Thu, 26 Feb 2026 08:08:35 +0000 Subject: [PATCH 2/4] fix(proxy): improve auth exception logging levels and add structured context (#22099) * fix(proxy): improve auth exception logging levels and add structured context Downgrade expected auth failures (ProxyException, HTTPException < 500, BudgetExceededError) from ERROR to WARNING log level to reduce noise from routine rejected requests (e.g. missing/invalid API keys on polled endpoints like /schedule/model_cost_map_reload/status). Unexpected exceptions and HTTPException with status >= 500 still log at ERROR with full traceback. Enrich log messages with structured context: route, HTTP method, masked API key (using existing abbreviate_api_key), error type, and error code. All fields also passed via log extra dict for log aggregation tools. Fixes #21293 * Update tests/test_litellm/proxy/auth/test_auth_exception_handler.py Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --------- Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- litellm/proxy/auth/auth_exception_handler.py | 70 ++++++- .../proxy/auth/test_auth_exception_handler.py | 196 ++++++++++++++++++ 2 files changed, 259 insertions(+), 7 deletions(-) diff --git a/litellm/proxy/auth/auth_exception_handler.py b/litellm/proxy/auth/auth_exception_handler.py index 9c306acd2c..4e35abf696 100644 --- a/litellm/proxy/auth/auth_exception_handler.py +++ b/litellm/proxy/auth/auth_exception_handler.py @@ -9,7 +9,7 @@ from fastapi import HTTPException, Request, status import litellm from litellm._logging import verbose_proxy_logger from litellm.proxy._types import ProxyErrorTypes, ProxyException, UserAPIKeyAuth -from litellm.proxy.auth.auth_utils import _get_request_ip_address +from litellm.proxy.auth.auth_utils import _get_request_ip_address, abbreviate_api_key from litellm.proxy.db.exception_handler import PrismaDBExceptionHandler from litellm.types.services import ServiceTypes @@ -75,13 +75,69 @@ class UserAPIKeyAuthExceptionHandler: request=request, use_x_forwarded_for=general_settings.get("use_x_forwarded_for", False), ) - verbose_proxy_logger.exception( - "litellm.proxy.proxy_server.user_api_key_auth(): Exception occured - {}\nRequester IP Address:{}".format( - str(e), - requester_ip, - ), - extra={"requester_ip": requester_ip}, + + # Build structured context for the log message + masked_key = abbreviate_api_key(api_key=api_key) if api_key else "None" + http_method = getattr(request, "method", "UNKNOWN") + + # Extract error category and status code from typed exceptions + if isinstance(e, ProxyException): + error_type = e.type + error_code = e.code + elif isinstance(e, HTTPException): + error_type = "http_exception" + error_code = str(getattr(e, "status_code", "unknown")) + elif isinstance(e, litellm.BudgetExceededError): + error_type = "budget_exceeded" + error_code = "400" + else: + error_type = type(e).__name__ + error_code = "401" + + log_extra = { + "requester_ip": requester_ip, + "route": route, + "api_key": masked_key, + "error_type": error_type, + "error_code": error_code, + "http_method": http_method, + } + + # Use warning level for expected auth failures to avoid noisy ERROR logs + # and full tracebacks for routine rejected requests (e.g. missing/invalid key). + # Reserve ERROR + traceback for truly unexpected exceptions and server errors. + _is_expected_auth_error = isinstance( + e, (ProxyException, litellm.BudgetExceededError) + ) or ( + isinstance(e, HTTPException) + and getattr(e, "status_code", 500) < 500 ) + if _is_expected_auth_error: + verbose_proxy_logger.warning( + "Auth failed: error_type={}, error_code={}, route={} {}, api_key={}, ip={} - {}".format( + error_type, + error_code, + http_method, + route, + masked_key, + requester_ip, + str(e), + ), + extra=log_extra, + ) + else: + verbose_proxy_logger.exception( + "Auth exception: error_type={}, error_code={}, route={} {}, api_key={}, ip={} - {}".format( + error_type, + error_code, + http_method, + route, + masked_key, + requester_ip, + str(e), + ), + extra=log_extra, + ) # Log this exception to OTEL, Datadog etc user_api_key_dict = UserAPIKeyAuth( diff --git a/tests/test_litellm/proxy/auth/test_auth_exception_handler.py b/tests/test_litellm/proxy/auth/test_auth_exception_handler.py index 3e780c6ee9..8952e67d6d 100644 --- a/tests/test_litellm/proxy/auth/test_auth_exception_handler.py +++ b/tests/test_litellm/proxy/auth/test_auth_exception_handler.py @@ -152,3 +152,199 @@ async def test_route_passed_to_post_call_failure_hook(): mock_post_call_failure_hook.assert_called_once() call_args = mock_post_call_failure_hook.call_args[1] assert call_args["user_api_key_dict"].request_route == test_route + + +@pytest.mark.asyncio +async def test_expected_auth_errors_log_at_warning_level(): + """ + Expected auth failures (ProxyException, HTTPException < 500, BudgetExceededError) + should log at WARNING level, not ERROR, to reduce log noise. + """ + handler = UserAPIKeyAuthExceptionHandler() + + mock_request = MagicMock() + mock_request.method = "GET" + mock_request_data = {} + mock_route = "/schedule/model_cost_map_reload/status" + mock_span = None + mock_api_key = "sk-test1234" + + expected_auth_errors = [ + ProxyException( + message="Token not found", + type=ProxyErrorTypes.token_not_found_in_db, + param="key", + code=401, + ), + HTTPException(status_code=401, detail="Invalid API key"), + HTTPException(status_code=403, detail="Forbidden"), + ] + + for error in expected_auth_errors: + with patch( + "litellm.proxy.proxy_server.general_settings", + {"allow_requests_on_db_unavailable": False}, + ), patch.object( + verbose_proxy_logger, "warning" + ) as mock_warning, patch.object( + verbose_proxy_logger, "exception" + ) as mock_exception: + try: + await handler._handle_authentication_error( + error, + mock_request, + mock_request_data, + mock_route, + mock_span, + mock_api_key, + ) + except Exception: + pass + + assert mock_warning.call_count == 1, ( + f"Expected warning log for {type(error).__name__}, got none" + ) + assert mock_exception.call_count == 0, ( + f"Did not expect exception log for {type(error).__name__}" + ) + + +@pytest.mark.asyncio +async def test_unexpected_errors_log_at_error_level(): + """ + Unexpected exceptions (bare Exception, HTTPException with 500) should + still log at ERROR level with full traceback. + """ + handler = UserAPIKeyAuthExceptionHandler() + + mock_request = MagicMock() + mock_request.method = "POST" + mock_request_data = {} + mock_route = "/chat/completions" + mock_span = None + mock_api_key = "sk-test1234" + + unexpected_errors = [ + Exception("Something unexpected broke"), + HTTPException(status_code=500, detail="Master key type error"), + ] + + for error in unexpected_errors: + with patch( + "litellm.proxy.proxy_server.general_settings", + {"allow_requests_on_db_unavailable": False}, + ), patch.object( + verbose_proxy_logger, "warning" + ) as mock_warning, patch.object( + verbose_proxy_logger, "exception" + ) as mock_exception: + try: + await handler._handle_authentication_error( + error, + mock_request, + mock_request_data, + mock_route, + mock_span, + mock_api_key, + ) + except Exception: + pass + + mock_exception.assert_called_once(), ( + f"Expected exception log for {type(error).__name__}, got none" + ) + mock_warning.assert_not_called(), ( + f"Did not expect warning log for {type(error).__name__}" + ) + + +@pytest.mark.asyncio +async def test_auth_error_log_contains_structured_context(): + """ + Log messages should include route, HTTP method, masked API key, error type, + and error code for easier debugging. + """ + handler = UserAPIKeyAuthExceptionHandler() + + mock_request = MagicMock() + mock_request.method = "GET" + mock_request_data = {} + mock_route = "/schedule/model_cost_map_reload/status" + mock_span = None + mock_api_key = "sk-test1234" + + error = ProxyException( + message="Token not found", + type=ProxyErrorTypes.token_not_found_in_db, + param="key", + code=401, + ) + + with patch( + "litellm.proxy.proxy_server.general_settings", + {"allow_requests_on_db_unavailable": False}, + ), patch.object(verbose_proxy_logger, "warning") as mock_warning: + try: + await handler._handle_authentication_error( + error, + mock_request, + mock_request_data, + mock_route, + mock_span, + mock_api_key, + ) + except Exception: + pass + + mock_warning.assert_called_once() + log_message = mock_warning.call_args[0][0] + log_extra = mock_warning.call_args[1].get("extra", {}) + + # Verify structured fields are in the log message + assert mock_route in log_message + assert "GET" in log_message + assert "sk-...1234" in log_message + + # Verify structured extra dict for log aggregation tools + assert log_extra["route"] == mock_route + assert log_extra["http_method"] == "GET" + assert log_extra["api_key"] == "sk-...1234" + assert log_extra["error_type"] == ProxyErrorTypes.token_not_found_in_db + + +@pytest.mark.asyncio +async def test_auth_error_log_handles_none_api_key(): + """ + When no API key is provided (None or empty), the log should show 'None' + instead of crashing. + """ + handler = UserAPIKeyAuthExceptionHandler() + + mock_request = MagicMock() + mock_request.method = "GET" + mock_request_data = {} + mock_route = "/test" + mock_span = None + + error = Exception("No api key passed in.") + + for empty_key in [None, ""]: + with patch( + "litellm.proxy.proxy_server.general_settings", + {"allow_requests_on_db_unavailable": False}, + ), patch.object(verbose_proxy_logger, "exception") as mock_exception: + try: + await handler._handle_authentication_error( + error, + mock_request, + mock_request_data, + mock_route, + mock_span, + empty_key, + ) + except Exception: + pass + + mock_exception.assert_called_once() + log_message = mock_exception.call_args[0][0] + assert "None" in log_message From 475bb94f5ae0ca8a2c29df76e7f93a7471d2a71e Mon Sep 17 00:00:00 2001 From: Roni Frantchi Date: Thu, 26 Feb 2026 10:10:18 +0200 Subject: [PATCH 3/4] fix(adapter): populate cache_read_input_tokens from prompt_tokens_details for OpenAI/Azure (#22090) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(adapter): populate cache_read_input_tokens from prompt_tokens_details The Anthropic adapter's translate_openai_response_to_anthropic checked only the private _cache_read_input_tokens attr (set by Anthropic/DeepSeek) but not prompt_tokens_details.cached_tokens (set by OpenAI/Azure). Use prompt_tokens_details.cached_tokens directly — it is already extracted and is the standard field populated by all providers. Fixes #22089 * fix(adapter): apply same cache_read_input_tokens fix to streaming path The streaming path in translate_streaming_openai_response_to_anthropic had the same bug — relying on _cache_read_input_tokens instead of prompt_tokens_details.cached_tokens. --- .../adapters/transformation.py | 16 +++---- ...al_pass_through_adapters_transformation.py | 45 +++++++++++++++++++ 2 files changed, 53 insertions(+), 8 deletions(-) diff --git a/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py b/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py index 8b21569546..a7362a9431 100644 --- a/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py +++ b/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py @@ -1106,19 +1106,19 @@ class LiteLLMAnthropicMessagesAdapter: # extract usage usage: Usage = getattr(response, "usage") uncached_input_tokens = usage.prompt_tokens or 0 + cached_tokens = 0 if hasattr(usage, "prompt_tokens_details") and usage.prompt_tokens_details: cached_tokens = getattr(usage.prompt_tokens_details, "cached_tokens", 0) or 0 uncached_input_tokens -= cached_tokens - + anthropic_usage = AnthropicUsage( input_tokens=uncached_input_tokens, output_tokens=usage.completion_tokens or 0, ) - # Add cache tokens if available (for prompt caching support) if hasattr(usage, "_cache_creation_input_tokens") and usage._cache_creation_input_tokens > 0: anthropic_usage["cache_creation_input_tokens"] = usage._cache_creation_input_tokens - if hasattr(usage, "_cache_read_input_tokens") and usage._cache_read_input_tokens > 0: - anthropic_usage["cache_read_input_tokens"] = usage._cache_read_input_tokens + if cached_tokens > 0: + anthropic_usage["cache_read_input_tokens"] = cached_tokens translated_obj = AnthropicMessagesResponse( id=response.id, @@ -1271,19 +1271,19 @@ class LiteLLMAnthropicMessagesAdapter: litellm_usage_chunk = None if litellm_usage_chunk is not None: uncached_input_tokens = litellm_usage_chunk.prompt_tokens or 0 + cached_tokens = 0 if hasattr(litellm_usage_chunk, "prompt_tokens_details") and litellm_usage_chunk.prompt_tokens_details: cached_tokens = getattr(litellm_usage_chunk.prompt_tokens_details, "cached_tokens", 0) or 0 uncached_input_tokens -= cached_tokens - + usage_delta = UsageDelta( input_tokens=uncached_input_tokens, output_tokens=litellm_usage_chunk.completion_tokens or 0, ) - # Add cache tokens if available (for prompt caching support) if hasattr(litellm_usage_chunk, "_cache_creation_input_tokens") and litellm_usage_chunk._cache_creation_input_tokens > 0: usage_delta["cache_creation_input_tokens"] = litellm_usage_chunk._cache_creation_input_tokens - if hasattr(litellm_usage_chunk, "_cache_read_input_tokens") and litellm_usage_chunk._cache_read_input_tokens > 0: - usage_delta["cache_read_input_tokens"] = litellm_usage_chunk._cache_read_input_tokens + if cached_tokens > 0: + usage_delta["cache_read_input_tokens"] = cached_tokens else: usage_delta = UsageDelta(input_tokens=0, output_tokens=0) return MessageBlockDelta( diff --git a/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py b/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py index 1ea1374cfb..839d032c43 100644 --- a/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py +++ b/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py @@ -1813,6 +1813,51 @@ def test_translate_openai_response_to_anthropic_input_tokens_no_cache(): assert anthropic_response["usage"]["output_tokens"] == 50 +def test_translate_openai_response_to_anthropic_cache_tokens_from_prompt_tokens_details(): + """ + OpenAI/Azure providers set prompt_tokens_details.cached_tokens but not + _cache_read_input_tokens. The adapter should populate cache_read_input_tokens + from prompt_tokens_details.cached_tokens directly. + """ + from litellm.types.utils import PromptTokensDetailsWrapper + + # OpenAI-style usage: only prompt_tokens_details, no cache_read_input_tokens kwarg + usage = Usage( + prompt_tokens=100, + completion_tokens=50, + total_tokens=150, + prompt_tokens_details=PromptTokensDetailsWrapper( + cached_tokens=30 + ), + ) + + response = ModelResponse( + id="test-id", + choices=[ + Choices( + index=0, + finish_reason="stop", + message=Message( + role="assistant", + content="Test response", + ), + ) + ], + model="gpt-4o-2024-08-06", + usage=usage, + ) + + adapter = LiteLLMAnthropicMessagesAdapter() + anthropic_response = adapter.translate_openai_response_to_anthropic( + response=response, + tool_name_mapping=None, + ) + + assert anthropic_response["usage"]["input_tokens"] == 70 + assert anthropic_response["usage"]["output_tokens"] == 50 + assert anthropic_response["usage"]["cache_read_input_tokens"] == 30 + + # ===================================================================== # Web Search Tool Transformation Tests # ===================================================================== From 95b8fb823b6014c2c91e5993b5b5e259fe2d2225 Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Thu, 26 Feb 2026 18:38:52 +0530 Subject: [PATCH 4/4] =?UTF-8?q?Revert=20"fix(proxy):=20improve=20auth=20ex?= =?UTF-8?q?ception=20logging=20levels=20and=20add=20structured=20=E2=80=A6?= =?UTF-8?q?"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit efeaf650aace39e708d35dbdfe161b6a3c610579. --- litellm/proxy/auth/auth_exception_handler.py | 70 +------ .../proxy/auth/test_auth_exception_handler.py | 196 ------------------ 2 files changed, 7 insertions(+), 259 deletions(-) diff --git a/litellm/proxy/auth/auth_exception_handler.py b/litellm/proxy/auth/auth_exception_handler.py index 4e35abf696..9c306acd2c 100644 --- a/litellm/proxy/auth/auth_exception_handler.py +++ b/litellm/proxy/auth/auth_exception_handler.py @@ -9,7 +9,7 @@ from fastapi import HTTPException, Request, status import litellm from litellm._logging import verbose_proxy_logger from litellm.proxy._types import ProxyErrorTypes, ProxyException, UserAPIKeyAuth -from litellm.proxy.auth.auth_utils import _get_request_ip_address, abbreviate_api_key +from litellm.proxy.auth.auth_utils import _get_request_ip_address from litellm.proxy.db.exception_handler import PrismaDBExceptionHandler from litellm.types.services import ServiceTypes @@ -75,69 +75,13 @@ class UserAPIKeyAuthExceptionHandler: request=request, use_x_forwarded_for=general_settings.get("use_x_forwarded_for", False), ) - - # Build structured context for the log message - masked_key = abbreviate_api_key(api_key=api_key) if api_key else "None" - http_method = getattr(request, "method", "UNKNOWN") - - # Extract error category and status code from typed exceptions - if isinstance(e, ProxyException): - error_type = e.type - error_code = e.code - elif isinstance(e, HTTPException): - error_type = "http_exception" - error_code = str(getattr(e, "status_code", "unknown")) - elif isinstance(e, litellm.BudgetExceededError): - error_type = "budget_exceeded" - error_code = "400" - else: - error_type = type(e).__name__ - error_code = "401" - - log_extra = { - "requester_ip": requester_ip, - "route": route, - "api_key": masked_key, - "error_type": error_type, - "error_code": error_code, - "http_method": http_method, - } - - # Use warning level for expected auth failures to avoid noisy ERROR logs - # and full tracebacks for routine rejected requests (e.g. missing/invalid key). - # Reserve ERROR + traceback for truly unexpected exceptions and server errors. - _is_expected_auth_error = isinstance( - e, (ProxyException, litellm.BudgetExceededError) - ) or ( - isinstance(e, HTTPException) - and getattr(e, "status_code", 500) < 500 + verbose_proxy_logger.exception( + "litellm.proxy.proxy_server.user_api_key_auth(): Exception occured - {}\nRequester IP Address:{}".format( + str(e), + requester_ip, + ), + extra={"requester_ip": requester_ip}, ) - if _is_expected_auth_error: - verbose_proxy_logger.warning( - "Auth failed: error_type={}, error_code={}, route={} {}, api_key={}, ip={} - {}".format( - error_type, - error_code, - http_method, - route, - masked_key, - requester_ip, - str(e), - ), - extra=log_extra, - ) - else: - verbose_proxy_logger.exception( - "Auth exception: error_type={}, error_code={}, route={} {}, api_key={}, ip={} - {}".format( - error_type, - error_code, - http_method, - route, - masked_key, - requester_ip, - str(e), - ), - extra=log_extra, - ) # Log this exception to OTEL, Datadog etc user_api_key_dict = UserAPIKeyAuth( diff --git a/tests/test_litellm/proxy/auth/test_auth_exception_handler.py b/tests/test_litellm/proxy/auth/test_auth_exception_handler.py index 8952e67d6d..3e780c6ee9 100644 --- a/tests/test_litellm/proxy/auth/test_auth_exception_handler.py +++ b/tests/test_litellm/proxy/auth/test_auth_exception_handler.py @@ -152,199 +152,3 @@ async def test_route_passed_to_post_call_failure_hook(): mock_post_call_failure_hook.assert_called_once() call_args = mock_post_call_failure_hook.call_args[1] assert call_args["user_api_key_dict"].request_route == test_route - - -@pytest.mark.asyncio -async def test_expected_auth_errors_log_at_warning_level(): - """ - Expected auth failures (ProxyException, HTTPException < 500, BudgetExceededError) - should log at WARNING level, not ERROR, to reduce log noise. - """ - handler = UserAPIKeyAuthExceptionHandler() - - mock_request = MagicMock() - mock_request.method = "GET" - mock_request_data = {} - mock_route = "/schedule/model_cost_map_reload/status" - mock_span = None - mock_api_key = "sk-test1234" - - expected_auth_errors = [ - ProxyException( - message="Token not found", - type=ProxyErrorTypes.token_not_found_in_db, - param="key", - code=401, - ), - HTTPException(status_code=401, detail="Invalid API key"), - HTTPException(status_code=403, detail="Forbidden"), - ] - - for error in expected_auth_errors: - with patch( - "litellm.proxy.proxy_server.general_settings", - {"allow_requests_on_db_unavailable": False}, - ), patch.object( - verbose_proxy_logger, "warning" - ) as mock_warning, patch.object( - verbose_proxy_logger, "exception" - ) as mock_exception: - try: - await handler._handle_authentication_error( - error, - mock_request, - mock_request_data, - mock_route, - mock_span, - mock_api_key, - ) - except Exception: - pass - - assert mock_warning.call_count == 1, ( - f"Expected warning log for {type(error).__name__}, got none" - ) - assert mock_exception.call_count == 0, ( - f"Did not expect exception log for {type(error).__name__}" - ) - - -@pytest.mark.asyncio -async def test_unexpected_errors_log_at_error_level(): - """ - Unexpected exceptions (bare Exception, HTTPException with 500) should - still log at ERROR level with full traceback. - """ - handler = UserAPIKeyAuthExceptionHandler() - - mock_request = MagicMock() - mock_request.method = "POST" - mock_request_data = {} - mock_route = "/chat/completions" - mock_span = None - mock_api_key = "sk-test1234" - - unexpected_errors = [ - Exception("Something unexpected broke"), - HTTPException(status_code=500, detail="Master key type error"), - ] - - for error in unexpected_errors: - with patch( - "litellm.proxy.proxy_server.general_settings", - {"allow_requests_on_db_unavailable": False}, - ), patch.object( - verbose_proxy_logger, "warning" - ) as mock_warning, patch.object( - verbose_proxy_logger, "exception" - ) as mock_exception: - try: - await handler._handle_authentication_error( - error, - mock_request, - mock_request_data, - mock_route, - mock_span, - mock_api_key, - ) - except Exception: - pass - - mock_exception.assert_called_once(), ( - f"Expected exception log for {type(error).__name__}, got none" - ) - mock_warning.assert_not_called(), ( - f"Did not expect warning log for {type(error).__name__}" - ) - - -@pytest.mark.asyncio -async def test_auth_error_log_contains_structured_context(): - """ - Log messages should include route, HTTP method, masked API key, error type, - and error code for easier debugging. - """ - handler = UserAPIKeyAuthExceptionHandler() - - mock_request = MagicMock() - mock_request.method = "GET" - mock_request_data = {} - mock_route = "/schedule/model_cost_map_reload/status" - mock_span = None - mock_api_key = "sk-test1234" - - error = ProxyException( - message="Token not found", - type=ProxyErrorTypes.token_not_found_in_db, - param="key", - code=401, - ) - - with patch( - "litellm.proxy.proxy_server.general_settings", - {"allow_requests_on_db_unavailable": False}, - ), patch.object(verbose_proxy_logger, "warning") as mock_warning: - try: - await handler._handle_authentication_error( - error, - mock_request, - mock_request_data, - mock_route, - mock_span, - mock_api_key, - ) - except Exception: - pass - - mock_warning.assert_called_once() - log_message = mock_warning.call_args[0][0] - log_extra = mock_warning.call_args[1].get("extra", {}) - - # Verify structured fields are in the log message - assert mock_route in log_message - assert "GET" in log_message - assert "sk-...1234" in log_message - - # Verify structured extra dict for log aggregation tools - assert log_extra["route"] == mock_route - assert log_extra["http_method"] == "GET" - assert log_extra["api_key"] == "sk-...1234" - assert log_extra["error_type"] == ProxyErrorTypes.token_not_found_in_db - - -@pytest.mark.asyncio -async def test_auth_error_log_handles_none_api_key(): - """ - When no API key is provided (None or empty), the log should show 'None' - instead of crashing. - """ - handler = UserAPIKeyAuthExceptionHandler() - - mock_request = MagicMock() - mock_request.method = "GET" - mock_request_data = {} - mock_route = "/test" - mock_span = None - - error = Exception("No api key passed in.") - - for empty_key in [None, ""]: - with patch( - "litellm.proxy.proxy_server.general_settings", - {"allow_requests_on_db_unavailable": False}, - ), patch.object(verbose_proxy_logger, "exception") as mock_exception: - try: - await handler._handle_authentication_error( - error, - mock_request, - mock_request_data, - mock_route, - mock_span, - empty_key, - ) - except Exception: - pass - - mock_exception.assert_called_once() - log_message = mock_exception.call_args[0][0] - assert "None" in log_message