diff --git a/litellm/litellm_core_utils/exception_mapping_utils.py b/litellm/litellm_core_utils/exception_mapping_utils.py index 3ddcae69315..d110129a53e 100644 --- a/litellm/litellm_core_utils/exception_mapping_utils.py +++ b/litellm/litellm_core_utils/exception_mapping_utils.py @@ -25,6 +25,7 @@ from ..exceptions import ( Timeout, UnprocessableEntityError, ) +from litellm.litellm_core_utils.redact_api_keys import redact_api_keys class ExceptionCheckers: @@ -251,10 +252,10 @@ def exception_type( # type: ignore # noqa: PLR0915 original_exception=original_exception ) try: - error_str = str(original_exception) + error_str = redact_api_keys(str(original_exception)) if model: if hasattr(original_exception, "message"): - error_str = str(original_exception.message) + error_str = redact_api_keys(str(original_exception.message)) if isinstance(original_exception, BaseException): exception_type = type(original_exception).__name__ else: @@ -360,6 +361,9 @@ def exception_type( # type: ignore # noqa: PLR0915 else: message = str(original_exception) + # Redact any API keys that may have leaked into the error message + message = redact_api_keys(message) + if message is not None and isinstance( message, str ): # done to prevent user-confusion. Relevant issue - https://github.com/BerriAI/litellm/issues/1414 @@ -2051,6 +2055,9 @@ def exception_type( # type: ignore # noqa: PLR0915 else: message = str(original_exception) + # Redact any API keys that may have leaked into the error message + message = redact_api_keys(message) + # Azure OpenAI (especially Images) often nests error details under # body["error"]. Detect content policy violations using the structured # payload in addition to string matching. diff --git a/litellm/litellm_core_utils/redact_api_keys.py b/litellm/litellm_core_utils/redact_api_keys.py new file mode 100644 index 00000000000..fcfa622a84a --- /dev/null +++ b/litellm/litellm_core_utils/redact_api_keys.py @@ -0,0 +1,68 @@ +# +-----------------------------------------------+ +# | | +# | Give Feedback / Get Help | +# | https://github.com/BerriAI/litellm/issues/new | +# | | +# +-----------------------------------------------+ +# +# Thank you users! We ❤️ you! - Krrish & Ishaan + +""" +Utility to redact API keys from error messages. + +API keys from various providers can leak into error messages when provider +APIs return errors that include the request's authentication details. +This module provides functions to detect and mask such keys before they +reach end users. +""" + +import re +from typing import Optional + +# Pattern to match common API key formats in error messages +# Covers: sk-xxx, Bearer xxx, key=xxx in URLs, and common provider key patterns +_API_KEY_PATTERNS = [ + # Anthropic keys: sk-ant- (must be before generic sk- pattern) + (re.compile(r"(sk-ant-)[A-Za-z0-9_-]{20,}"), r"\g<1>****"), + # OpenAI-style keys: sk- + # Match sk- followed by at least 20 chars of key material + (re.compile(r"(sk-)[A-Za-z0-9_-]{20,}"), r"\g<1>****"), + # Bearer token in messages: Bearer + (re.compile(r"(Bearer\s+)[A-Za-z0-9_.+/=-]{10,}"), r"\g<1>[REDACTED]"), + # Authorization header value patterns + (re.compile(r"(Authorization['\"]?\s*[:=]\s*['\"]?)[A-Za-z0-9_.+/=-]{10,}"), r"\g<1>[REDACTED]"), + # URL query param: key= or api_key= or apikey= + (re.compile(r"((?:api[_-]?key|key|token|secret|password|credential|auth)=)[A-Za-z0-9_.+/=-]{8,}(?=&|$|\s|['\"])"), r"\g<1>[REDACTED]"), + # Azure API keys (32-char hex strings preceded by api-key header context) + (re.compile(r"(api-key['\"]?\s*[:=]\s*['\"]?)[a-f0-9]{32,}"), r"\g<1>[REDACTED]"), + # Generic long hex/base64 strings that look like keys when preceded by key-related words + (re.compile(r"((?:api[_-]?key|secret|token|credential|auth[_-]?token)['\"]?\s*[:=]\s*['\"]?)[A-Za-z0-9_.+/=-]{16,}"), r"\g<1>[REDACTED]"), +] + + +def redact_api_keys(message: Optional[str]) -> Optional[str]: + """ + Redact API keys from an error message string. + + Scans the message for patterns that look like API keys and replaces them + with redacted placeholders. This prevents leaking sensitive credentials + in error responses returned to users. + + Args: + message: The error message string to redact. + + Returns: + The message with API keys redacted, or the original message if no keys found. + Returns None if input is None. + """ + if message is None: + return None + + if not isinstance(message, str): + return message + + redacted = message + for pattern, replacement in _API_KEY_PATTERNS: + redacted = pattern.sub(replacement, redacted) + + return redacted diff --git a/litellm/llms/custom_httpx/http_handler.py b/litellm/llms/custom_httpx/http_handler.py index ac9dd5998e2..6808e650d77 100644 --- a/litellm/llms/custom_httpx/http_handler.py +++ b/litellm/llms/custom_httpx/http_handler.py @@ -317,12 +317,14 @@ class MaskedHTTPStatusError(httpx.HTTPStatusError): def __init__( self, original_error, message: Optional[str] = None, text: Optional[str] = None ): + from litellm.litellm_core_utils.redact_api_keys import redact_api_keys + # Create a new error with the masked URL masked_url = mask_sensitive_info(str(original_error.request.url)) # Create a new error that looks like the original, but with a masked URL super().__init__( - message=original_error.message, + message=redact_api_keys(original_error.message) or original_error.message, request=httpx.Request( method=original_error.request.method, url=masked_url, @@ -335,8 +337,9 @@ class MaskedHTTPStatusError(httpx.HTTPStatusError): headers=original_error.response.headers, ), ) - self.message = message - self.text = text + # Redact API keys from message and text fields + self.message = redact_api_keys(message) if message else message + self.text = redact_api_keys(text) if text else text class AsyncHTTPHandler: diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 045d2fd5f14..85e205391ce 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -2958,7 +2958,9 @@ class ProxyException(Exception): openai_code: Optional[str] = None, # maps to 'code' in openai provider_specific_fields: Optional[dict] = None, ): - self.message = str(message) + from litellm.litellm_core_utils.redact_api_keys import redact_api_keys + + self.message = redact_api_keys(str(message)) or str(message) self.type = type self.param = param self.openai_code = openai_code or code diff --git a/tests/litellm/litellm_core_utils/test_redact_api_keys.py b/tests/litellm/litellm_core_utils/test_redact_api_keys.py new file mode 100644 index 00000000000..fba1503b4ef --- /dev/null +++ b/tests/litellm/litellm_core_utils/test_redact_api_keys.py @@ -0,0 +1,104 @@ +""" +Tests for the API key redaction utility. + +Ensures that various API key formats are properly masked in error messages +to prevent credential leakage to end users. +""" + +import pytest + +from litellm.litellm_core_utils.redact_api_keys import redact_api_keys + + +class TestRedactApiKeys: + """Tests for the redact_api_keys function.""" + + def test_returns_none_for_none_input(self): + assert redact_api_keys(None) is None + + def test_returns_non_string_as_is(self): + assert redact_api_keys(123) == 123 + + def test_no_keys_returns_unchanged(self): + msg = "Connection timeout after 30 seconds" + assert redact_api_keys(msg) == msg + + def test_redacts_openai_sk_key(self): + msg = "Error: Invalid API key: sk-1234567890abcdefghijklmnopqrstuv" + result = redact_api_keys(msg) + assert "sk-1234567890abcdefghijklmnopqrstuv" not in result + assert "sk-****" in result + + def test_redacts_anthropic_sk_ant_key(self): + msg = "AuthenticationError: Invalid API key: sk-ant-api03-abcdefghijklmnopqrstuvwxyz1234567890" + result = redact_api_keys(msg) + assert "sk-ant-api03-abcdefghijklmnopqrstuvwxyz1234567890" not in result + assert "sk-ant-****" in result + + def test_redacts_bearer_token(self): + msg = "Authorization failed with Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0" + result = redact_api_keys(msg) + assert "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" not in result + assert "Bearer [REDACTED]" in result + + def test_redacts_key_in_url_param(self): + msg = "Request to https://api.example.com/v1/chat?key=abcdef1234567890ghij failed" + result = redact_api_keys(msg) + assert "abcdef1234567890ghij" not in result + assert "key=[REDACTED]" in result + + def test_redacts_api_key_url_param(self): + msg = "Error calling https://api.example.com?api_key=sk_test_abc123def456ghi789 - 401" + result = redact_api_keys(msg) + assert "sk_test_abc123def456ghi789" not in result + assert "api_key=[REDACTED]" in result + + def test_redacts_azure_api_key_header(self): + msg = "api-key: a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4 was rejected" + result = redact_api_keys(msg) + assert "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4" not in result + assert "api-key: [REDACTED]" in result + + def test_redacts_authorization_header_value(self): + msg = "Authorization: sk-proj-abcdefghijklmnopqrstuvwxyz123456" + result = redact_api_keys(msg) + assert "sk-proj-abcdefghijklmnopqrstuvwxyz123456" not in result + + def test_preserves_error_context(self): + msg = "OpenAIException - Error code: 401 - Invalid API key provided: sk-abcdefghijklmnopqrstuvwxyz12345678. You can find your API key at https://platform.openai.com/account/api-keys." + result = redact_api_keys(msg) + assert "sk-abcdefghijklmnopqrstuvwxyz12345678" not in result + assert "Error code: 401" in result + assert "Invalid API key provided" in result + + def test_redacts_multiple_keys_in_same_message(self): + msg = "Tried key sk-aaaabbbbccccddddeeeeffffgggg1234 then sk-1111222233334444555566667777abcd" + result = redact_api_keys(msg) + assert "sk-aaaabbbbccccddddeeeeffffgggg1234" not in result + assert "sk-1111222233334444555566667777abcd" not in result + + def test_real_world_openai_error_with_key(self): + """Simulate a real OpenAI error that includes the API key in the message.""" + msg = ( + "Error code: 401 - {'error': {'message': 'Incorrect API key provided: sk-proj-aBcDeFgHiJkLmNoPqRsTuVwXyZ123456. " + "You can find your API key at https://platform.openai.com/account/api-keys.', " + "'type': 'invalid_request_error', 'param': None, 'code': 'invalid_api_key'}}" + ) + result = redact_api_keys(msg) + assert "sk-proj-aBcDeFgHiJkLmNoPqRsTuVwXyZ123456" not in result + assert "sk-" in result # prefix should still be visible + assert "invalid_api_key" in result # error context preserved + + def test_real_world_azure_error_with_key(self): + """Simulate an Azure error that includes the API key.""" + msg = "Access denied with api-key=a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6. Check your credentials." + result = redact_api_keys(msg) + assert "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6" not in result + + def test_empty_string(self): + assert redact_api_keys("") == "" + + def test_short_values_not_redacted(self): + """Short values that don't look like keys should not be redacted.""" + msg = "key=abc is too short" + assert redact_api_keys(msg) == msg