Merge pull request #25117 from stuxf/fix/credential-leak-prevention

security: prevent API key leaks in error tracebacks, logs, and alerts
This commit is contained in:
yuneng-jiang 2026-04-15 16:49:44 -07:00 committed by GitHub
commit 3f3bfdbe33
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
31 changed files with 890 additions and 245 deletions

View file

@ -86,6 +86,8 @@ _SECRET_RE = _build_secret_patterns()
def _redact_string(value: str) -> str:
if not _ENABLE_SECRET_REDACTION:
return value
return _SECRET_RE.sub(_REDACTED, value)

View file

@ -6,7 +6,7 @@ from typing import Any, Optional
import httpx
import litellm
from litellm._logging import verbose_logger
from litellm._logging import _redact_string, verbose_logger
from litellm.types.utils import LlmProviders
from ..exceptions import (
@ -2304,7 +2304,7 @@ def exception_type( # type: ignore # noqa: PLR0915
else:
# if no status code then it is an APIConnectionError: https://github.com/openai/openai-python#handling-errors
raise APIConnectionError(
message=f"{exception_provider} APIConnectionError - {message}\n{traceback.format_exc()}",
message=f"{exception_provider} APIConnectionError - {message}\n{_redact_string(traceback.format_exc())}",
llm_provider="azure",
model=model,
litellm_debug_info=extra_information,
@ -2431,7 +2431,7 @@ def exception_type( # type: ignore # noqa: PLR0915
else:
raise APIConnectionError(
message="{}\n{}".format(
str(original_exception), traceback.format_exc()
str(original_exception), _redact_string(traceback.format_exc())
),
llm_provider=custom_llm_provider,
model=model,
@ -2460,7 +2460,7 @@ def exception_type( # type: ignore # noqa: PLR0915
setattr(e, "litellm_response_headers", litellm_response_headers)
raise e # it's already mapped
raised_exc = APIConnectionError(
message="{}\n{}".format(original_exception, traceback.format_exc()),
message="{}\n{}".format(original_exception, _redact_string(traceback.format_exc())),
llm_provider="",
model="",
)

View file

@ -36,7 +36,7 @@ from litellm import (
log_raw_request_response,
turn_off_message_logging,
)
from litellm._logging import _is_debugging_on, verbose_logger
from litellm._logging import _is_debugging_on, _redact_string, verbose_logger
from litellm._uuid import uuid
from litellm.batches.batch_utils import _handle_completed_batch
from litellm.caching.caching import DualCache, InMemoryCache
@ -2854,7 +2854,11 @@ class Logging(LiteLLMLoggingBaseClass):
self.model_call_details["log_event_type"] = "failed_api_call"
self.model_call_details["exception"] = exception
self.model_call_details["traceback_exception"] = traceback_exception
self.model_call_details["traceback_exception"] = (
_redact_string(traceback_exception)
if isinstance(traceback_exception, str)
else traceback_exception
)
self.model_call_details["end_time"] = end_time
self.model_call_details.setdefault("original_response", None)
self.model_call_details["response_cost"] = 0
@ -2877,7 +2881,7 @@ class Logging(LiteLLMLoggingBaseClass):
end_time=end_time,
logging_obj=self,
status="failure",
error_str=str(exception),
error_str=_redact_string(str(exception)),
original_exception=exception,
standard_built_in_tools_params=self.standard_built_in_tools_params,
)

View file

@ -6,7 +6,7 @@ This requires websockets, and is currently only supported on LiteLLM Proxy.
from typing import Any, Optional, cast
from litellm._logging import verbose_proxy_logger
from litellm._logging import _redact_string, verbose_proxy_logger
from litellm.constants import REALTIME_WEBSOCKET_MAX_MESSAGE_SIZE_BYTES
from ....litellm_core_utils.litellm_logging import Logging as LiteLLMLogging
@ -118,7 +118,7 @@ class AzureOpenAIRealtime(AzureChatCompletion):
await realtime_streaming.bidirectional_forward()
except websockets.exceptions.InvalidStatusCode as e: # type: ignore
await websocket.close(code=e.status_code, reason=str(e))
await websocket.close(code=e.status_code, reason=_redact_string(str(e)))
except Exception:
verbose_proxy_logger.exception(
"Error in AzureOpenAIRealtime.async_realtime"

View file

@ -8,7 +8,7 @@ import asyncio
import json
from typing import Any, Optional
from litellm._logging import verbose_proxy_logger
from litellm._logging import _redact_string, verbose_proxy_logger
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLogging
from ..base_aws_llm import BaseAWSLLM
@ -152,7 +152,7 @@ class BedrockRealtime(BaseAWSLLM):
f"Error in BedrockRealtime.async_realtime: {e}"
)
try:
await websocket.close(code=1011, reason=f"Internal error: {str(e)}")
await websocket.close(code=1011, reason=_redact_string(f"Internal error: {str(e)}"))
except Exception:
pass
raise

View file

@ -316,16 +316,65 @@ def mask_sensitive_info(error_message):
return error_message
def _safe_get_response_text(response: httpx.Response) -> str:
"""Safely read response text, falling back to empty string on decoding errors."""
try:
return response.text
except Exception:
return ""
async def _safe_aread_response(response: httpx.Response) -> bytes:
"""Safely read async response body, falling back to empty bytes on errors."""
try:
return await response.aread()
except Exception:
return b""
def _safe_read_response(response: httpx.Response) -> bytes:
"""Safely read sync response body, falling back to empty bytes on errors."""
try:
return response.read()
except Exception:
return b""
def _raise_masked_sync_error(e: httpx.HTTPStatusError, stream: bool) -> None:
"""Raise a MaskedHTTPStatusError for sync HTTP handlers."""
if stream:
_body = mask_sensitive_info(_safe_read_response(e.response))
raise MaskedHTTPStatusError(e, message=_body, text=_body) from None
_text = mask_sensitive_info(_safe_get_response_text(e.response))
raise MaskedHTTPStatusError(e, message=_text, text=_text) from None
async def _raise_masked_async_error(e: httpx.HTTPStatusError, stream: bool) -> None:
"""Raise a MaskedHTTPStatusError for async HTTP handlers."""
if stream:
_body = mask_sensitive_info(await _safe_aread_response(e.response))
raise MaskedHTTPStatusError(e, message=_body, text=_body) from None
_text = mask_sensitive_info(_safe_get_response_text(e.response))
raise MaskedHTTPStatusError(e, message=_text, text=_text) from None
class MaskedHTTPStatusError(httpx.HTTPStatusError):
def __init__(
self, original_error, message: Optional[str] = None, text: Optional[str] = None
):
# 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
# Mask the original exception message too (it contains the full URL)
masked_original_message = mask_sensitive_info(str(original_error))
# Safely access response content — decompression can fail (e.g. zlib error)
try:
response_content = original_error.response.content
except Exception:
response_content = b""
super().__init__(
message=original_error.message,
message=masked_original_message,
request=httpx.Request(
method=original_error.request.method,
url=masked_url,
@ -334,12 +383,13 @@ class MaskedHTTPStatusError(httpx.HTTPStatusError):
),
response=httpx.Response(
status_code=original_error.response.status_code,
content=original_error.response.content,
content=response_content,
headers=original_error.response.headers,
),
)
self.message = message
self.text = text
self.status_code = original_error.response.status_code
class AsyncHTTPHandler:
@ -501,16 +551,7 @@ class AsyncHTTPHandler:
headers=headers,
)
except httpx.HTTPStatusError as e:
if stream is True:
setattr(e, "message", await e.response.aread())
setattr(e, "text", await e.response.aread())
else:
setattr(e, "message", mask_sensitive_info(e.response.text))
setattr(e, "text", mask_sensitive_info(e.response.text))
setattr(e, "status_code", e.response.status_code)
raise e
await _raise_masked_async_error(e, stream)
except Exception as e:
raise e
@ -571,12 +612,7 @@ class AsyncHTTPHandler:
headers=headers,
)
except httpx.HTTPStatusError as e:
setattr(e, "status_code", e.response.status_code)
if stream is True:
setattr(e, "message", await e.response.aread())
else:
setattr(e, "message", e.response.text)
raise e
await _raise_masked_async_error(e, stream)
except Exception as e:
raise e
@ -637,12 +673,7 @@ class AsyncHTTPHandler:
headers=headers,
)
except httpx.HTTPStatusError as e:
setattr(e, "status_code", e.response.status_code)
if stream is True:
setattr(e, "message", await e.response.aread())
else:
setattr(e, "message", e.response.text)
raise e
await _raise_masked_async_error(e, stream)
except Exception as e:
raise e
@ -690,12 +721,7 @@ class AsyncHTTPHandler:
finally:
await new_client.aclose()
except httpx.HTTPStatusError as e:
setattr(e, "status_code", e.response.status_code)
if stream is True:
setattr(e, "message", await e.response.aread())
else:
setattr(e, "message", e.response.text)
raise e
await _raise_masked_async_error(e, stream)
except Exception as e:
raise e
@ -1035,16 +1061,7 @@ class HTTPHandler:
llm_provider="litellm-httpx-handler",
)
except httpx.HTTPStatusError as e:
if stream is True:
setattr(e, "message", mask_sensitive_info(e.response.read()))
setattr(e, "text", mask_sensitive_info(e.response.read()))
else:
error_text = mask_sensitive_info(e.response.text)
setattr(e, "message", error_text)
setattr(e, "text", error_text)
setattr(e, "status_code", e.response.status_code)
raise e
_raise_masked_sync_error(e, stream)
except Exception as e:
raise e
@ -1083,17 +1100,7 @@ class HTTPHandler:
llm_provider="litellm-httpx-handler",
)
except httpx.HTTPStatusError as e:
if stream is True:
setattr(e, "message", mask_sensitive_info(e.response.read()))
setattr(e, "text", mask_sensitive_info(e.response.read()))
else:
error_text = mask_sensitive_info(e.response.text)
setattr(e, "message", error_text)
setattr(e, "text", error_text)
setattr(e, "status_code", e.response.status_code)
raise e
_raise_masked_sync_error(e, stream)
except Exception as e:
raise e
@ -1130,6 +1137,8 @@ class HTTPHandler:
model="default-model-name",
llm_provider="litellm-httpx-handler",
)
except httpx.HTTPStatusError as e:
_raise_masked_sync_error(e, stream)
except Exception as e:
raise e
@ -1168,17 +1177,7 @@ class HTTPHandler:
llm_provider="litellm-httpx-handler",
)
except httpx.HTTPStatusError as e:
if stream is True:
setattr(e, "message", mask_sensitive_info(e.response.read()))
setattr(e, "text", mask_sensitive_info(e.response.read()))
else:
error_text = mask_sensitive_info(e.response.text)
setattr(e, "message", error_text)
setattr(e, "text", error_text)
setattr(e, "status_code", e.response.status_code)
raise e
_raise_masked_sync_error(e, stream)
except Exception as e:
raise e

View file

@ -22,7 +22,7 @@ import litellm
import litellm.litellm_core_utils
import litellm.types
import litellm.types.utils
from litellm._logging import verbose_logger
from litellm._logging import _redact_string, verbose_logger
from litellm.anthropic_beta_headers_manager import update_headers_with_filtered_beta
from litellm.constants import REALTIME_WEBSOCKET_MAX_MESSAGE_SIZE_BYTES
from litellm.litellm_core_utils.realtime_streaming import RealTimeStreaming
@ -4789,12 +4789,12 @@ class BaseLLMHTTPHandler:
except websockets.exceptions.InvalidStatusCode as e: # type: ignore
verbose_logger.exception(f"Error connecting to backend: {e}")
await websocket.close(code=e.status_code, reason=str(e))
await websocket.close(code=e.status_code, reason=_redact_string(str(e)))
except Exception as e:
verbose_logger.exception(f"Error connecting to backend: {e}")
try:
await websocket.close(
code=1011, reason=f"Internal server error: {str(e)}"
code=1011, reason=_redact_string(f"Internal server error: {str(e)}")
)
except RuntimeError as close_error:
if "already completed" in str(close_error) or "websocket.close" in str(
@ -5076,12 +5076,12 @@ class BaseLLMHTTPHandler:
except websockets.exceptions.InvalidStatusCode as e: # type: ignore
verbose_logger.exception(f"Error connecting to responses WS backend: {e}")
await websocket.close(code=e.status_code, reason=str(e))
await websocket.close(code=e.status_code, reason=_redact_string(str(e)))
except Exception as e:
verbose_logger.exception(f"Error in responses WS: {e}")
try:
await websocket.close(
code=1011, reason=f"Internal server error: {str(e)}"
code=1011, reason=_redact_string(f"Internal server error: {str(e)}")
)
except RuntimeError as close_error:
if "already completed" in str(close_error) or "websocket.close" in str(

View file

@ -28,7 +28,7 @@ class GeminiModelInfo(BaseLLMModelInfo):
api_key: Optional[str] = None,
api_base: Optional[str] = None,
) -> dict:
"""Google AI Studio sends api key in query params"""
"""Google AI Studio sends api key via x-goog-api-key header"""
return headers
@property
@ -75,7 +75,8 @@ class GeminiModelInfo(BaseLLMModelInfo):
)
response = litellm.module_level_client.get(
url=f"{api_base}{endpoint}?key={api_key}",
url=f"{api_base}{endpoint}",
headers={"x-goog-api-key": api_key},
)
if response.status_code != 200:

View file

@ -86,7 +86,7 @@ class GoogleAIStudioFilesHandler(GeminiModelInfo, BaseFilesConfig):
if not final_api_key:
raise ValueError("api_key is required")
url = "{}/{}?key={}".format(api_base, endpoint, final_api_key)
url = "{}/{}".format(api_base, endpoint)
return url
def get_supported_openai_params(
@ -231,9 +231,9 @@ class GoogleAIStudioFilesHandler(GeminiModelInfo, BaseFilesConfig):
)
api_base = api_base.rstrip("/")
url = f"{api_base}/v1beta/{file_part}?key={api_key}"
url = f"{api_base}/v1beta/{file_part}"
# Return empty params dict - API key is already in URL, no query params needed
# API key is passed via x-goog-api-key header (set in validate_environment)
return url, {}
def _normalize_gemini_file_id(self, file_id: str) -> str:

View file

@ -75,9 +75,13 @@ class GoogleAIStudioInteractionsConfig(BaseInteractionsAPIConfig):
model: str,
litellm_params: Optional[GenericLiteLLMParams],
) -> dict:
"""Google AI Studio uses API key in query params, not headers."""
"""Google AI Studio uses x-goog-api-key header for authentication."""
headers = headers or {}
headers["Content-Type"] = "application/json"
if litellm_params:
api_key = GeminiModelInfo.get_api_key(litellm_params.get("api_key"))
if api_key:
headers["x-goog-api-key"] = api_key
return headers
def get_complete_url(
@ -98,11 +102,10 @@ class GoogleAIStudioInteractionsConfig(BaseInteractionsAPIConfig):
"Google API key is required. Set GOOGLE_API_KEY or GEMINI_API_KEY environment variable."
)
query_params = f"key={api_key}"
if stream:
query_params += "&alt=sse"
return f"{api_base}/{self.api_version}/interactions?alt=sse"
return f"{api_base}/{self.api_version}/interactions?{query_params}"
return f"{api_base}/{self.api_version}/interactions"
def transform_request(
self,
@ -200,11 +203,10 @@ class GoogleAIStudioInteractionsConfig(BaseInteractionsAPIConfig):
) -> Tuple[str, Dict]:
"""GET /{api_version}/interactions/{interaction_id}"""
resolved_api_base = GeminiModelInfo.get_api_base(api_base)
api_key = GeminiModelInfo.get_api_key(litellm_params.api_key)
if not api_key:
if not GeminiModelInfo.get_api_key(litellm_params.api_key):
raise ValueError("Google API key is required")
return (
f"{resolved_api_base}/{self.api_version}/interactions/{interaction_id}?key={api_key}",
f"{resolved_api_base}/{self.api_version}/interactions/{interaction_id}",
{},
)
@ -234,11 +236,10 @@ class GoogleAIStudioInteractionsConfig(BaseInteractionsAPIConfig):
) -> Tuple[str, Dict]:
"""DELETE /{api_version}/interactions/{interaction_id}"""
resolved_api_base = GeminiModelInfo.get_api_base(api_base)
api_key = GeminiModelInfo.get_api_key(litellm_params.api_key)
if not api_key:
if not GeminiModelInfo.get_api_key(litellm_params.api_key):
raise ValueError("Google API key is required")
return (
f"{resolved_api_base}/{self.api_version}/interactions/{interaction_id}?key={api_key}",
f"{resolved_api_base}/{self.api_version}/interactions/{interaction_id}",
{},
)
@ -265,11 +266,10 @@ class GoogleAIStudioInteractionsConfig(BaseInteractionsAPIConfig):
) -> Tuple[str, Dict]:
"""POST /{api_version}/interactions/{interaction_id}:cancel (if supported)"""
resolved_api_base = GeminiModelInfo.get_api_base(api_base)
api_key = GeminiModelInfo.get_api_key(litellm_params.api_key)
if not api_key:
if not GeminiModelInfo.get_api_key(litellm_params.api_key):
raise ValueError("Google API key is required")
return (
f"{resolved_api_base}/{self.api_version}/interactions/{interaction_id}:cancel?key={api_key}",
f"{resolved_api_base}/{self.api_version}/interactions/{interaction_id}:cancel",
{},
)

View file

@ -85,6 +85,10 @@ class GeminiRealtimeConfig(BaseRealtimeConfig):
raise ValueError("api_key is required for Gemini API calls")
api_base = api_base.replace("https://", "wss://")
api_base = api_base.replace("http://", "ws://")
# WebSocket connections do not support custom HTTP headers in all clients,
# so the API key must remain as a query parameter here. This is an accepted
# limitation; httpx is not used for WebSocket so MaskedHTTPStatusError
# already covers the main leak vector.
return f"{api_base}/ws/google.ai.generativelanguage.v1beta.GenerativeService.BidiGenerateContent?key={api_key}"
def map_model_turn_event(

View file

@ -48,7 +48,7 @@ class GeminiVectorStoreConfig(BaseVectorStoreConfig):
def get_auth_credentials(
self, litellm_params: dict
) -> BaseVectorStoreAuthCredentials:
"""Gemini uses API key in query params, not headers."""
"""Gemini uses x-goog-api-key header for authentication."""
return {}
def get_vector_store_endpoints_by_type(self) -> VectorStoreIndexEndpoints:
@ -79,6 +79,7 @@ class GeminiVectorStoreConfig(BaseVectorStoreConfig):
api_key = litellm_params.get("api_key") or get_api_key_from_env()
if api_key:
self._cached_api_key = api_key
headers["x-goog-api-key"] = api_key
return headers
@ -133,13 +134,10 @@ class GeminiVectorStoreConfig(BaseVectorStoreConfig):
if model and model.startswith("gemini/"):
model = model.replace("gemini/", "")
# Get API key - Gemini requires it as a query parameter
api_key = litellm_params.get("api_key") or GeminiModelInfo.get_api_key()
if not api_key:
raise ValueError("GEMINI_API_KEY or GOOGLE_API_KEY is required")
# Build the URL for generateContent with API key
url = f"{api_base}/models/{model}:generateContent?key={api_key}"
url = f"{api_base}/models/{model}:generateContent"
# Build file_search tool configuration (using snake_case as per Gemini docs)
file_search_config: Dict[str, Any] = {
@ -286,10 +284,7 @@ class GeminiVectorStoreConfig(BaseVectorStoreConfig):
"""
url = f"{api_base}/fileSearchStores"
# Append API key as query parameter (required by Gemini)
api_key = self._cached_api_key or get_api_key_from_env()
if api_key:
url = f"{url}?key={api_key}"
# API key is passed via x-goog-api-key header (set in validate_environment)
request_body: Dict[str, Any] = {}

View file

@ -6,6 +6,7 @@ This requires websockets, and is currently only supported on LiteLLM Proxy.
from typing import Any, Optional, cast
from litellm._logging import _redact_string
from litellm.constants import REALTIME_WEBSOCKET_MAX_MESSAGE_SIZE_BYTES
from litellm.types.realtime import RealtimeQueryParams
@ -148,11 +149,11 @@ class OpenAIRealtime(OpenAIChatCompletion):
await realtime_streaming.bidirectional_forward()
except websockets.exceptions.InvalidStatusCode as e: # type: ignore
await websocket.close(code=e.status_code, reason=str(e))
await websocket.close(code=e.status_code, reason=_redact_string(str(e)))
except Exception as e:
try:
await websocket.close(
code=1011, reason=f"Internal server error: {str(e)}"
code=1011, reason=_redact_string(f"Internal server error: {str(e)}")
)
except RuntimeError as close_error:
if "already completed" in str(close_error) or "websocket.close" in str(

View file

@ -337,8 +337,13 @@ def _get_gemini_url(
mode: all_gemini_url_modes,
model: str,
stream: Optional[bool],
gemini_api_key: Optional[str],
) -> Tuple[str, str]:
"""Build the Gemini API URL for the given mode.
The API key is NOT included in the URL. Callers must pass it via the
``x-goog-api-key`` header instead to avoid leaking credentials in
error tracebacks.
"""
from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import (
VertexGeminiConfig,
)
@ -352,27 +357,27 @@ def _get_gemini_url(
endpoint = "generateContent"
if stream is True:
endpoint = "streamGenerateContent"
url = "https://generativelanguage.googleapis.com/{}/{}:{}?key={}&alt=sse".format(
api_version, _gemini_model_name, endpoint, gemini_api_key
url = "https://generativelanguage.googleapis.com/{}/{}:{}?alt=sse".format(
api_version, _gemini_model_name, endpoint
)
else:
url = "https://generativelanguage.googleapis.com/{}/{}:{}?key={}".format(
api_version, _gemini_model_name, endpoint, gemini_api_key
url = "https://generativelanguage.googleapis.com/{}/{}:{}".format(
api_version, _gemini_model_name, endpoint
)
elif mode == "embedding":
endpoint = "embedContent"
url = "https://generativelanguage.googleapis.com/v1beta/{}:{}?key={}".format(
_gemini_model_name, endpoint, gemini_api_key
url = "https://generativelanguage.googleapis.com/v1beta/{}:{}".format(
_gemini_model_name, endpoint
)
elif mode == "batch_embedding":
endpoint = "batchEmbedContents"
url = "https://generativelanguage.googleapis.com/v1beta/{}:{}?key={}".format(
_gemini_model_name, endpoint, gemini_api_key
url = "https://generativelanguage.googleapis.com/v1beta/{}:{}".format(
_gemini_model_name, endpoint
)
elif mode == "count_tokens":
endpoint = "countTokens"
url = "https://generativelanguage.googleapis.com/v1beta/{}:{}?key={}".format(
_gemini_model_name, endpoint, gemini_api_key
url = "https://generativelanguage.googleapis.com/v1beta/{}:{}".format(
_gemini_model_name, endpoint
)
elif mode == "image_generation":
raise ValueError(

View file

@ -62,10 +62,10 @@ class ContextCachingEndpoints(VertexBase):
token, url
"""
if custom_llm_provider == "gemini":
auth_header = None
auth_header = {"x-goog-api-key": gemini_api_key} # type: ignore[assignment]
endpoint = "cachedContents"
url = "https://generativelanguage.googleapis.com/v1beta/{}?key={}".format(
endpoint, gemini_api_key
url = "https://generativelanguage.googleapis.com/v1beta/{}".format(
endpoint
)
elif custom_llm_provider == "vertex_ai":
auth_header = vertex_auth_header
@ -353,7 +353,9 @@ class ContextCachingEndpoints(VertexBase):
headers = {
"Content-Type": "application/json",
}
if token is not None:
if isinstance(token, dict):
headers.update(token)
elif token is not None:
headers["Authorization"] = f"Bearer {token}"
if extra_headers is not None:
headers.update(extra_headers)
@ -501,7 +503,9 @@ class ContextCachingEndpoints(VertexBase):
headers = {
"Content-Type": "application/json",
}
if token is not None:
if isinstance(token, dict):
headers.update(token)
elif token is not None:
headers["Authorization"] = f"Bearer {token}"
if extra_headers is not None:
headers.update(extra_headers)

View file

@ -412,7 +412,7 @@ class VertexBase:
url = "{}/models/{}:{}".format(api_base, model, endpoint)
if gemini_api_key is None:
raise ValueError(
"Missing gemini_api_key, please set `GEMINI_API_KEY`"
"Missing Gemini API key. Set the GEMINI_API_KEY or GOOGLE_API_KEY environment variable."
)
if gemini_api_key is not None:
auth_header = {"x-goog-api-key": gemini_api_key} # type: ignore[assignment]
@ -469,13 +469,16 @@ class VertexBase:
"""
version: Optional[Literal["v1beta1", "v1"]] = None
if custom_llm_provider == "gemini":
if not gemini_api_key:
raise ValueError(
"Missing Gemini API key. Set the GEMINI_API_KEY or GOOGLE_API_KEY environment variable."
)
url, endpoint = _get_gemini_url(
mode=mode,
model=model,
stream=stream,
gemini_api_key=gemini_api_key,
)
auth_header = None # this field is not used for gemin
auth_header = {"x-goog-api-key": gemini_api_key} # type: ignore[assignment]
else:
vertex_location = self.get_vertex_region(
vertex_region=vertex_location,

View file

@ -40,6 +40,7 @@ from typing import (
get_args,
)
from litellm._logging import _redact_string
from litellm._uuid import uuid
if TYPE_CHECKING:
@ -7244,7 +7245,7 @@ async def ahealth_check(
f"Mode {mode} not supported. See modes here: https://docs.litellm.ai/docs/proxy/health"
)
except Exception as e:
stack_trace = traceback.format_exc()
stack_trace = _redact_string(traceback.format_exc())
if isinstance(stack_trace, str):
stack_trace = stack_trace[:1000]

View file

@ -22,7 +22,7 @@ from fastapi import HTTPException, Request, status
from fastapi.responses import JSONResponse, Response, StreamingResponse
import litellm
from litellm._logging import verbose_proxy_logger
from litellm._logging import _redact_string, verbose_proxy_logger
from litellm._uuid import uuid
from litellm.constants import (
DD_TRACER_STREAMING_CHUNK_YIELD_RESOURCE,
@ -1785,7 +1785,7 @@ class ProxyBaseLLMRequestProcessing:
if isinstance(e, HTTPException):
raise e
error_traceback = traceback.format_exc()
error_traceback = _redact_string(traceback.format_exc())
error_msg = f"{str(e)}\n\n{error_traceback}"
proxy_exception = ProxyException(
message=getattr(e, "message", error_msg),

View file

@ -77,7 +77,7 @@ from litellm import (
ModelResponseStream,
Router,
)
from litellm._logging import verbose_proxy_logger
from litellm._logging import _redact_string, verbose_proxy_logger
from litellm._service_logger import ServiceLogging, ServiceTypes
from litellm.caching.caching import DualCache, RedisCache
from litellm.caching.dual_cache import LimitedSizeOrderedDict
@ -155,7 +155,7 @@ def print_verbose(print_statement):
verbose_proxy_logger.debug("{}\n{}".format(print_statement, traceback.format_exc()))
if litellm.set_verbose:
print(f"LiteLLM Proxy: {print_statement}") # noqa
print(f"LiteLLM Proxy: {_redact_string(str(print_statement))}") # noqa
def _get_email_logger_class():
@ -1721,6 +1721,7 @@ class ProxyLogging:
error_message = str(original_exception)
if isinstance(traceback_str, str):
error_message += traceback_str[:1000]
error_message = _redact_string(error_message)
asyncio.create_task(
self.alerting_handler(
message=f"DB read/write call failed: {error_message}",
@ -1791,7 +1792,7 @@ class ProxyLogging:
asyncio.create_task(
self.alerting_handler(
message=f"LLM API call failed: `{exception_str}`",
message=_redact_string(f"LLM API call failed: `{exception_str}`"),
level="High",
alert_type=AlertType.llm_exceptions,
request_data=request_data,

View file

@ -143,7 +143,7 @@ class GeminiRAGIngestion(BaseRAGIngestion):
Returns:
Store name (format: fileSearchStores/xxxxxxx)
"""
url = f"{base_url}/fileSearchStores?key={api_key}"
url = f"{base_url}/fileSearchStores"
request_body = {"displayName": display_name}
@ -154,7 +154,10 @@ class GeminiRAGIngestion(BaseRAGIngestion):
response = await client.post(
url,
json=request_body,
headers={"Content-Type": "application/json"},
headers={
"Content-Type": "application/json",
"x-goog-api-key": api_key,
},
)
if response.status_code != 200:
@ -228,7 +231,7 @@ class GeminiRAGIngestion(BaseRAGIngestion):
# base_url is like: https://generativelanguage.googleapis.com/v1beta
# We need: https://generativelanguage.googleapis.com/upload/v1beta/{store_id}:uploadToFileSearchStore
api_base = base_url.replace("/v1beta", "") # Get base without version
url = f"{api_base}/upload/v1beta/{vector_store_id}:uploadToFileSearchStore?key={api_key}"
url = f"{api_base}/upload/v1beta/{vector_store_id}:uploadToFileSearchStore"
# Build request body with chunking config and metadata if provided
request_body: Dict[str, Any] = {"displayName": filename}
@ -263,6 +266,7 @@ class GeminiRAGIngestion(BaseRAGIngestion):
"X-Goog-Upload-Header-Content-Length": str(file_size),
"X-Goog-Upload-Header-Content-Type": content_type,
"Content-Type": "application/json",
"x-goog-api-key": api_key,
}
verbose_logger.debug(f"Initiating resumable upload: {url}")

View file

@ -310,7 +310,7 @@ def test_gemini_multimodal_embedding_e2e():
) as mock_get_token:
mock_get_token.return_value = (
{"x-goog-api-key": "test-key"},
"https://generativelanguage.googleapis.com/v1beta/models/gemini-embedding-2-preview:embedContent?key=test-key"
"https://generativelanguage.googleapis.com/v1beta/models/gemini-embedding-2-preview:embedContent"
)
mock_response = MagicMock()

View file

@ -320,29 +320,30 @@ async def test_generationconfig_to_config_mapping(sample_request_payload):
for Google GenAI compatibility in the main functions.
"""
from litellm.google_genai.main import agenerate_content
# Create a copy of the payload to avoid modifying the fixture
test_data = sample_request_payload.copy()
# Test that agenerate_content can handle generationConfig parameter
# This should not raise an error about parameter handling
try:
# This will fail due to missing API key, but should not fail due to parameter handling
with patch(
"litellm.google_genai.main.base_llm_http_handler.generate_content_handler"
) as mock_generate_content_handler:
mock_generate_content_handler.return_value = {"text": "mock response"}
await agenerate_content(
model="gemini/gemini-2.5-flash",
contents=test_data["contents"],
generationConfig=test_data["generationConfig"], # Pass as generationConfig
custom_llm_provider="gemini"
generationConfig=test_data["generationConfig"],
custom_llm_provider="gemini",
)
except Exception as e:
# Should not fail due to parameter handling issues
error_msg = str(e).lower()
if "generationconfig" in error_msg or "config" in error_msg or "parameter" in error_msg:
pytest.fail(f"Parameter handling failed: {e}")
# Other errors (like API key missing) are expected
print(f"✅ Parameter handling worked (API error expected): {type(e).__name__}")
print("✅ generationConfig to config mapping test passed")
mock_generate_content_handler.assert_called_once()
generate_content_config_dict = mock_generate_content_handler.call_args.kwargs[
"generate_content_config_dict"
]
assert generate_content_config_dict["temperature"] == 0
assert generate_content_config_dict["topP"] == 1
assert generate_content_config_dict["responseMimeType"] == "application/json"
assert "responseJsonSchema" in generate_content_config_dict
@pytest.mark.asyncio
@ -405,7 +406,7 @@ async def test_gemini_custom_api_base_proxy_integration():
print(f"✅ Custom API base streaming URL test passed: {result_url_streaming}")
# Test case 3: Error handling - missing API key
with pytest.raises(ValueError, match="Missing gemini_api_key"):
with pytest.raises(ValueError, match="Missing Gemini API key"):
vertex_base._check_custom_proxy(
api_base=custom_api_base,
custom_llm_provider="gemini",

View file

@ -1127,80 +1127,58 @@ async def test_google_generate_content_with_openai():
passed_fields = passed_fields - set(GenericLiteLLMParams.model_fields.keys())
# extra_headers is now explicitly passed through for providers that need custom headers
assert passed_fields == set(["model", "messages", "extra_headers"]), f"Expected model, messages, and extra_headers to be passed through, got {passed_fields}"
@pytest.mark.asyncio
async def test_agenerate_content_x_goog_api_key_header():
def test_validate_environment_sets_x_goog_api_key():
"""
Test that agenerate_content passes x-goog-api-key header correctly.
This test verifies that when calling agenerate_content with a Google GenAI model,
the HTTP request includes the x-goog-api-key header with the correct API key value.
"""
import os
import unittest.mock
Test that VertexGeminiConfig.validate_environment correctly merges an
x-goog-api-key dict into the request headers.
This is the mechanism by which Google AI Studio (Gemini) requests get
authenticated via header instead of a query-string ?key= parameter.
"""
from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import (
VertexGeminiConfig,
)
import httpx
test_api_key = "test-gemini-api-key-123"
# Mock environment to ensure we use our test API key
with unittest.mock.patch.dict(os.environ, {"GEMINI_API_KEY": test_api_key}, clear=False):
# Mock the AsyncHTTPHandler's post method to capture headers
with unittest.mock.patch("litellm.llms.custom_httpx.http_handler.AsyncHTTPHandler.post", new_callable=unittest.mock.AsyncMock) as mock_post:
# Mock a successful response
mock_response = unittest.mock.MagicMock()
mock_response.json.return_value = {
"candidates": [
{
"content": {
"parts": [{"text": "Hello! How can I help you today?"}],
"role": "model"
},
"finishReason": "STOP",
"index": 0
}
],
"usageMetadata": {
"promptTokenCount": 5,
"candidatesTokenCount": 10,
"totalTokenCount": 15
}
}
mock_response.status_code = 200
mock_response.headers = {}
mock_post.return_value = mock_response
# Call agenerate_content with Google AI Studio model
try:
response = await agenerate_content(
model="gemini/gemini-1.5-flash",
contents=[
{"role": "user", "parts": [{"text": "Hello, world!"}]}
],
api_key=test_api_key
)
except Exception:
# Ignore any response processing errors, we just want to check the headers
pass
# Verify that AsyncHTTPHandler.post was called
mock_post.assert_called_once()
# Get the arguments passed to the post call
call_args, call_kwargs = mock_post.call_args
# Verify that headers contain x-goog-api-key
headers = call_kwargs.get("headers", {})
assert "x-goog-api-key" in headers, f"x-goog-api-key header not found in headers: {list(headers.keys())}"
# Verify the API key is set (could be our test key or from api_key parameter)
api_key_value = headers["x-goog-api-key"]
assert api_key_value == test_api_key, f"Expected x-goog-api-key to be {test_api_key}, got {api_key_value}"
# Verify other expected headers
assert headers.get("Content-Type") == "application/json", f"Expected Content-Type application/json, got {headers.get('Content-Type')}"
print(f"✓ Test passed: x-goog-api-key header correctly set to {api_key_value}")
print(f"✓ All headers: {list(headers.keys())}")
# Simulate what _get_token_and_url returns for Gemini: a dict auth_header
auth_header_dict = {"x-goog-api-key": test_api_key}
headers = VertexGeminiConfig().validate_environment(
api_key=auth_header_dict,
headers=None,
model="gemini-2.5-flash",
messages=[],
optional_params={},
litellm_params={},
)
assert "x-goog-api-key" in headers, f"x-goog-api-key not in headers: {headers}"
assert headers["x-goog-api-key"] == test_api_key
assert headers["Content-Type"] == "application/json"
def test_get_gemini_url_excludes_api_key():
"""
Verify that _get_gemini_url never embeds the API key in the URL.
API keys in URLs leak through httpx error tracebacks. The key must be
sent via the x-goog-api-key header instead.
"""
from litellm.llms.vertex_ai.common_utils import _get_gemini_url
for mode in ("chat", "embedding", "batch_embedding", "count_tokens"):
url, _ = _get_gemini_url(
mode=mode,
model="gemini-2.5-flash",
stream=False,
)
assert "key=" not in url, f"API key found in URL for mode={mode}: {url}"
# Streaming chat should only have ?alt=sse
url, _ = _get_gemini_url(mode="chat", model="gemini-2.5-flash", stream=True)
assert "key=" not in url, f"API key found in streaming URL: {url}"
assert "alt=sse" in url, f"Missing alt=sse in streaming URL: {url}"
def test_inline_data_base64_image_transformation():

View file

@ -0,0 +1,148 @@
"""
Tests for Gemini Interactions API transformation.
Covers credential leak prevention changes:
- validate_environment sets x-goog-api-key header
- get_complete_url excludes API key from URL
- get/delete/cancel interaction request URLs exclude API key
"""
import os
import sys
from unittest.mock import patch
import pytest
sys.path.insert(0, os.path.abspath("../../.."))
from litellm.llms.gemini.interactions.transformation import (
GoogleAIStudioInteractionsConfig,
)
from litellm.types.router import GenericLiteLLMParams
_PATCH_GET_API_KEY = "litellm.llms.gemini.common_utils.GeminiModelInfo.get_api_key"
@pytest.fixture
def config():
return GoogleAIStudioInteractionsConfig()
class TestValidateEnvironment:
def test_sets_x_goog_api_key_header(self, config):
litellm_params = GenericLiteLLMParams(api_key="test-api-key-123")
headers = config.validate_environment(
headers={},
model="gemini-2.5-flash",
litellm_params=litellm_params,
)
assert headers["x-goog-api-key"] == "test-api-key-123"
assert headers["Content-Type"] == "application/json"
def test_no_api_key_skips_header(self, config):
litellm_params = GenericLiteLLMParams(api_key=None)
with patch(_PATCH_GET_API_KEY, return_value=None):
headers = config.validate_environment(
headers={},
model="gemini-2.5-flash",
litellm_params=litellm_params,
)
assert "x-goog-api-key" not in headers
assert headers["Content-Type"] == "application/json"
def test_no_litellm_params_skips_header(self, config):
headers = config.validate_environment(
headers={},
model="gemini-2.5-flash",
litellm_params=None,
)
assert "x-goog-api-key" not in headers
assert headers["Content-Type"] == "application/json"
def test_preserves_existing_headers(self, config):
litellm_params = GenericLiteLLMParams(api_key="test-key")
headers = config.validate_environment(
headers={"X-Custom": "value"},
model="gemini-2.5-flash",
litellm_params=litellm_params,
)
assert headers["X-Custom"] == "value"
assert headers["x-goog-api-key"] == "test-key"
class TestGetCompleteUrl:
def test_url_excludes_api_key(self, config):
with patch(_PATCH_GET_API_KEY, return_value="secret-key"):
url = config.get_complete_url(
api_base=None,
model="gemini-2.5-flash",
litellm_params={"api_key": "secret-key"},
)
assert "key=" not in url
assert "secret-key" not in url
assert url.endswith("/interactions")
def test_stream_url_has_alt_sse_only(self, config):
with patch(_PATCH_GET_API_KEY, return_value="secret-key"):
url = config.get_complete_url(
api_base=None,
model="gemini-2.5-flash",
litellm_params={"api_key": "secret-key"},
stream=True,
)
assert "key=" not in url
assert "secret-key" not in url
assert "alt=sse" in url
def test_raises_without_api_key(self, config):
with patch(_PATCH_GET_API_KEY, return_value=None):
with pytest.raises(ValueError, match="Google API key is required"):
config.get_complete_url(
api_base=None,
model="gemini-2.5-flash",
litellm_params={"api_key": None},
)
class TestInteractionOperationUrls:
"""Test that get/delete/cancel interaction URLs exclude API key."""
@pytest.mark.parametrize(
"method_name,interaction_id,expected_suffix",
[
("transform_get_interaction_request", "interaction-123", "interaction-123"),
("transform_delete_interaction_request", "interaction-456", "interaction-456"),
("transform_cancel_interaction_request", "interaction-789", "interaction-789:cancel"),
],
)
def test_url_excludes_key(self, config, method_name, interaction_id, expected_suffix):
with patch(_PATCH_GET_API_KEY, return_value="secret-key"):
url, params = getattr(config, method_name)(
interaction_id=interaction_id,
api_base="https://generativelanguage.googleapis.com",
litellm_params=GenericLiteLLMParams(api_key="secret-key"),
headers={},
)
assert "key=" not in url
assert "secret-key" not in url
assert expected_suffix in url
def test_get_interaction_raises_without_key(self, config):
with patch(_PATCH_GET_API_KEY, return_value=None):
with pytest.raises(ValueError, match="Google API key is required"):
config.transform_get_interaction_request(
interaction_id="interaction-123",
api_base="https://generativelanguage.googleapis.com",
litellm_params=GenericLiteLLMParams(api_key=None),
headers={},
)

View file

@ -0,0 +1,234 @@
"""
Tests for credential leak prevention in HTTP handlers.
Covers:
- MaskedHTTPStatusError construction and masking behavior
- _safe_get_response_text, _safe_aread_response, _safe_read_response helpers
- _raise_masked_sync_error and _raise_masked_async_error
"""
import os
import sys
from unittest.mock import AsyncMock, MagicMock, patch
import httpx
import pytest
sys.path.insert(0, os.path.abspath("../../../.."))
from litellm.llms.custom_httpx.http_handler import (
AsyncHTTPHandler,
HTTPHandler,
MaskedHTTPStatusError,
_raise_masked_async_error,
_raise_masked_sync_error,
_safe_aread_response,
_safe_get_response_text,
_safe_read_response,
)
def _make_httpx_status_error(
status_code: int = 400,
url: str = "https://example.com/v1/models?key=SECRET_KEY_123",
body: str = "Bad Request",
) -> httpx.HTTPStatusError:
"""Create a real httpx.HTTPStatusError for testing."""
request = httpx.Request("POST", url)
response = httpx.Response(status_code, request=request, content=body.encode())
return httpx.HTTPStatusError(
message=f"Client error '{status_code}' for url '{url}'",
request=request,
response=response,
)
class TestMaskedHTTPStatusError:
def test_masks_url_in_request(self):
orig = _make_httpx_status_error(url="https://api.example.com?key=MY_SECRET")
masked = MaskedHTTPStatusError(orig)
assert "MY_SECRET" not in str(masked.request.url)
assert "[REDACTED_API_KEY]" in str(masked.request.url)
def test_masks_original_message(self):
orig = _make_httpx_status_error(url="https://api.example.com?key=SUPER_SECRET")
masked = MaskedHTTPStatusError(orig)
assert "SUPER_SECRET" not in str(masked)
assert "[REDACTED_API_KEY]" in str(masked)
def test_preserves_status_code(self):
orig = _make_httpx_status_error(status_code=403)
masked = MaskedHTTPStatusError(orig)
assert masked.status_code == 403
assert masked.response.status_code == 403
def test_preserves_message_and_text_attrs(self):
orig = _make_httpx_status_error()
masked = MaskedHTTPStatusError(orig, message="custom msg", text="custom text")
assert masked.message == "custom msg"
assert masked.text == "custom text"
def test_handles_response_content_decompression_failure(self):
"""If response.content raises (e.g. zlib error), should fall back to b''."""
orig = _make_httpx_status_error()
with patch.object(
type(orig.response), "content",
new_callable=lambda: property(lambda self: (_ for _ in ()).throw(Exception("zlib error"))),
):
masked = MaskedHTTPStatusError(orig)
assert masked.response.content == b""
assert masked.status_code == 400
class TestSafeResponseHelpers:
def test_safe_get_response_text_normal(self):
response = httpx.Response(200, content=b"hello world")
assert _safe_get_response_text(response) == "hello world"
def test_safe_get_response_text_error(self):
response = MagicMock(spec=httpx.Response)
type(response).text = property(lambda self: (_ for _ in ()).throw(UnicodeDecodeError("utf-8", b"", 0, 1, "bad")))
assert _safe_get_response_text(response) == ""
def test_safe_read_response_normal(self):
response = httpx.Response(200, content=b"raw bytes")
result = _safe_read_response(response)
assert result == b"raw bytes"
def test_safe_read_response_error(self):
response = MagicMock(spec=httpx.Response)
response.read.side_effect = Exception("read failure")
assert _safe_read_response(response) == b""
@pytest.mark.asyncio
async def test_safe_aread_response_normal(self):
response = MagicMock(spec=httpx.Response)
response.aread = AsyncMock(return_value=b"async bytes")
result = await _safe_aread_response(response)
assert result == b"async bytes"
@pytest.mark.asyncio
async def test_safe_aread_response_error(self):
response = MagicMock(spec=httpx.Response)
response.aread = AsyncMock(side_effect=Exception("async read failure"))
result = await _safe_aread_response(response)
assert result == b""
class TestRaiseMaskedError:
def test_sync_non_stream(self):
orig = _make_httpx_status_error(
url="https://api.example.com?key=LEAKED_KEY", body="error body"
)
with pytest.raises(MaskedHTTPStatusError) as exc_info:
_raise_masked_sync_error(orig, stream=False)
err = exc_info.value
assert "LEAKED_KEY" not in str(err.request.url)
assert err.status_code == 400
assert err.text == "error body"
def test_sync_stream(self):
orig = _make_httpx_status_error(
url="https://api.example.com?key=LEAKED_KEY", body="stream body"
)
with pytest.raises(MaskedHTTPStatusError) as exc_info:
_raise_masked_sync_error(orig, stream=True)
err = exc_info.value
assert "LEAKED_KEY" not in str(err.request.url)
assert err.message is not None
def test_sync_breaks_exception_chain(self):
orig = _make_httpx_status_error()
with pytest.raises(MaskedHTTPStatusError) as exc_info:
_raise_masked_sync_error(orig, stream=False)
assert exc_info.value.__cause__ is None
@pytest.mark.asyncio
async def test_async_non_stream(self):
orig = _make_httpx_status_error(
url="https://api.example.com?key=LEAKED_KEY", body="async error"
)
with pytest.raises(MaskedHTTPStatusError) as exc_info:
await _raise_masked_async_error(orig, stream=False)
err = exc_info.value
assert "LEAKED_KEY" not in str(err.request.url)
assert err.status_code == 400
assert err.text == "async error"
@pytest.mark.asyncio
async def test_async_stream(self):
orig = _make_httpx_status_error(
url="https://api.example.com?key=LEAKED_KEY", body="async stream"
)
with pytest.raises(MaskedHTTPStatusError) as exc_info:
await _raise_masked_async_error(orig, stream=True)
err = exc_info.value
assert "LEAKED_KEY" not in str(err.request.url)
assert err.message is not None
@pytest.mark.asyncio
async def test_async_breaks_chain(self):
orig = _make_httpx_status_error()
with pytest.raises(MaskedHTTPStatusError) as exc_info:
await _raise_masked_async_error(orig, stream=False)
assert exc_info.value.__cause__ is None
class TestHTTPHandlerErrorPaths:
"""Test that HTTP handler methods raise MaskedHTTPStatusError on HTTPStatusError."""
@pytest.fixture
def sync_handler(self):
handler = HTTPHandler()
yield handler
handler.close()
@pytest.fixture
async def async_handler(self):
handler = AsyncHTTPHandler()
yield handler
await handler.close()
@pytest.mark.parametrize("method", ["post", "put", "patch", "delete"])
def test_sync_raises_masked_error(self, sync_handler, method):
with patch.object(
sync_handler.client,
"send",
side_effect=_make_httpx_status_error(url="https://api.test.com?key=SECRET"),
):
with pytest.raises(MaskedHTTPStatusError) as exc_info:
kwargs = {"url": "https://api.test.com?key=SECRET"}
if method != "delete":
kwargs["data"] = {"test": 1}
getattr(sync_handler, method)(**kwargs)
assert "SECRET" not in str(exc_info.value.request.url)
@pytest.mark.parametrize("method", ["post", "put", "patch", "delete"])
@pytest.mark.asyncio
async def test_async_raises_masked_error(self, async_handler, method):
with patch.object(
async_handler.client,
"send",
new_callable=AsyncMock,
side_effect=_make_httpx_status_error(url="https://api.test.com?key=SECRET"),
):
with pytest.raises(MaskedHTTPStatusError) as exc_info:
kwargs = {"url": "https://api.test.com?key=SECRET"}
if method != "delete":
kwargs["data"] = {"test": 1}
await getattr(async_handler, method)(**kwargs)
assert "SECRET" not in str(exc_info.value.request.url)

View file

@ -37,12 +37,12 @@ class TestGoogleAIStudioFilesTransformation:
litellm_params=litellm_params,
)
# Verify URL is constructed exactly as required:
# https://generativelanguage.googleapis.com/v1beta/files/{file_id}?key=API_KEY
# API key is passed via x-goog-api-key header, not in URL
assert (
url
== "https://generativelanguage.googleapis.com/v1beta/files/test123?key=test-api-key"
== "https://generativelanguage.googleapis.com/v1beta/files/test123"
)
assert "key=" not in url
# CRITICAL: params should be empty dict, not contain Content-Type or any other params
# These would be incorrectly interpreted as query parameters
@ -64,12 +64,12 @@ class TestGoogleAIStudioFilesTransformation:
litellm_params=litellm_params,
)
# Verify URL is constructed exactly as required:
# https://generativelanguage.googleapis.com/v1beta/files/{file_id}?key=API_KEY
# API key is passed via x-goog-api-key header, not in URL
assert (
url
== "https://generativelanguage.googleapis.com/v1beta/files/test123?key=test-api-key"
== "https://generativelanguage.googleapis.com/v1beta/files/test123"
)
assert "key=" not in url
# CRITICAL: params should be empty dict
assert params == {}, f"Expected empty params dict, got: {params}"
@ -79,11 +79,10 @@ class TestGoogleAIStudioFilesTransformation:
def test_transform_retrieve_file_request_with_raw_id_only(self):
"""
Regression guard for the exact retrieval URL format.
Regression guard: API key must NOT appear in the URL.
If someone changes the method and stops producing:
https://generativelanguage.googleapis.com/v1beta/files/{file_id}?key=API_KEY
this test should fail.
The key is sent via x-goog-api-key header to prevent leaking
credentials in httpx error tracebacks.
"""
file_id = "cctqueckiggb"
litellm_params = {"api_key": "test-api-key"}
@ -96,8 +95,9 @@ class TestGoogleAIStudioFilesTransformation:
assert (
url
== "https://generativelanguage.googleapis.com/v1beta/files/cctqueckiggb?key=test-api-key"
== "https://generativelanguage.googleapis.com/v1beta/files/cctqueckiggb"
)
assert "key=" not in url
assert params == {}
@patch.dict("os.environ", {}, clear=True)
@ -285,10 +285,10 @@ class TestGoogleAIStudioFilesTransformation:
litellm_params={},
)
# Verify URL structure
# Verify URL structure - API key must NOT be in URL
assert api_base in url
assert "upload/v1beta/files" in url
assert f"key={api_key}" in url
assert "key=" not in url
def test_transform_delete_file_request_with_full_uri(self):
"""Test delete file request transformation with full URI"""

View file

@ -93,6 +93,7 @@ def test_completion_pydantic_obj_2():
model="gemini/gemini-2.5-flash",
messages=messages,
response_format=EventsList,
api_key="test-api-key",
client=client,
)
# print(response)
@ -285,6 +286,7 @@ def test_function_calling_with_gemini():
},
},
],
api_key="test-api-key",
client=client,
)
except Exception as e:
@ -372,7 +374,10 @@ def test_multiple_function_call():
with patch.object(client, "post", return_value=mock_response) as mock_post:
r = litellm.completion(
messages=messages, model="gemini/gemini-1.5-flash-002", client=client
messages=messages,
model="gemini/gemini-1.5-flash-002",
api_key="test-api-key",
client=client,
)
assert len(r.choices) > 0
@ -478,7 +483,10 @@ def test_multiple_function_call_changed_text_pos():
with patch.object(client, "post", return_value=mock_response) as mock_post:
resp = litellm.completion(
messages=messages, model="gemini/gemini-1.5-flash-002", client=client
messages=messages,
model="gemini/gemini-1.5-flash-002",
api_key="test-api-key",
client=client,
)
assert len(resp.choices) > 0
mock_post.assert_called_once()
@ -599,6 +607,7 @@ def test_function_calling_with_gemini_multiple_results():
messages=messages,
tools=tools,
tool_choice="required",
api_key="test-api-key",
client=client,
)
print("Response\n", response)
@ -1182,6 +1191,7 @@ def test_logprobs():
{"role": "user", "content": "What's the weather like in San Francisco?"}
],
logprobs=True,
api_key="test-api-key",
client=client,
)
print(resp)

View file

@ -810,7 +810,7 @@ class TestVertexBase:
if custom_llm_provider == "gemini" and api_base and gemini_api_key is None:
# Test case 5: Should raise ValueError for Gemini without API key
with pytest.raises(ValueError, match="Missing gemini_api_key"):
with pytest.raises(ValueError, match="Missing Gemini API key"):
vertex_base._check_custom_proxy(
api_base=api_base,
custom_llm_provider=custom_llm_provider,

View file

@ -247,8 +247,10 @@ class TestPromptVersionsEndpoint:
),
}
# Mock the IN_MEMORY_PROMPT_REGISTRY at the import location
with patch("litellm.proxy.prompts.prompt_registry.IN_MEMORY_PROMPT_REGISTRY") as mock_registry:
# Force the in-memory path so this test is isolated from any leaked prisma mocks.
with patch("litellm.proxy.proxy_server.prisma_client", None), patch(
"litellm.proxy.prompts.prompt_registry.IN_MEMORY_PROMPT_REGISTRY"
) as mock_registry:
mock_registry.IN_MEMORY_PROMPTS = mock_prompts
# Test with base prompt ID
@ -293,7 +295,9 @@ class TestPromptVersionsEndpoint:
user_role=LitellmUserRoles.PROXY_ADMIN
)
with patch("litellm.proxy.prompts.prompt_registry.IN_MEMORY_PROMPT_REGISTRY") as mock_registry:
with patch("litellm.proxy.proxy_server.prisma_client", None), patch(
"litellm.proxy.prompts.prompt_registry.IN_MEMORY_PROMPT_REGISTRY"
) as mock_registry:
mock_registry.IN_MEMORY_PROMPTS = {}
with pytest.raises(HTTPException) as exc_info:
@ -304,4 +308,3 @@ class TestPromptVersionsEndpoint:
assert exc_info.value.status_code == 404
assert "No versions found" in exc_info.value.detail

View file

@ -213,6 +213,8 @@ async def test_url_with_format_param(model, sync_mode, monkeypatch):
}
],
}
if model.startswith("gemini/"):
args["api_key"] = "test-api-key"
with patch.object(client, "post", new=MagicMock()) as mock_client:
try:
if sync_mode:

View file

@ -0,0 +1,245 @@
"""
Tests for _redact_string usage in error/logging paths.
Covers actual execution of redaction in:
- WebSocket close reasons in realtime handlers (openai, azure, bedrock)
- Gemini RAG ingestion x-goog-api-key header usage
- Traceback redaction pattern used in proxy streaming
"""
import os
import sys
import traceback
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
sys.path.insert(0, os.path.abspath("../.."))
from litellm._logging import _ENABLE_SECRET_REDACTION, _redact_string
class TestRedactStringFunction:
def test_redacts_bearer_token(self):
text = "Authorization: Bearer sk-1234567890abcdefghij"
result = _redact_string(text)
assert "sk-1234567890abcdefghij" not in result
assert "REDACTED" in result
def test_redacts_api_key_in_url(self):
text = "Error at https://example.com?api_key=my-secret-key-value-here"
result = _redact_string(text)
assert "my-secret-key-value-here" not in result
def test_redacts_google_api_key(self):
text = "key=AIzaSyB1234567890abcdefghijklmnopqrstuvwx"
result = _redact_string(text)
assert "AIzaSyB1234567890abcdefghijklmnopqrstuvwx" not in result
def test_passes_clean_text_through(self):
text = "This is a normal error message with no secrets"
assert _redact_string(text) == text
@pytest.mark.skipif(
not _ENABLE_SECRET_REDACTION, reason="redaction disabled via env var"
)
def test_redaction_enabled_by_default(self):
text = "Bearer sk-1234567890abcdefghij"
result = _redact_string(text)
assert "sk-1234567890abcdefghij" not in result
class TestOpenAIRealtimeRedaction:
"""Test that OpenAI realtime handler redacts secrets in websocket close reasons."""
def _make_patches(self, handler):
"""Shared patches for OpenAI realtime handler tests."""
return (
patch.object(handler, "_construct_url", return_value="wss://api.openai.com/v1/realtime?model=gpt-4"),
patch.object(handler, "_get_ssl_config", return_value=None),
patch.object(handler, "_get_additional_headers", return_value={}),
)
def _call_kwargs(self):
return dict(
model="gpt-4",
websocket=AsyncMock(),
logging_obj=MagicMock(),
api_base="https://api.openai.com/",
api_key="test-key",
)
@pytest.mark.asyncio
async def test_invalid_status_code_redacts_reason(self):
import websockets.exceptions
from litellm.llms.openai.realtime.handler import OpenAIRealtime
handler = OpenAIRealtime()
exc = websockets.exceptions.InvalidStatusCode(403, None)
exc.status_code = 403
kwargs = self._call_kwargs()
mock_ws = kwargs["websocket"]
p1, p2, p3 = self._make_patches(handler)
with p1, p2, p3, patch("websockets.connect", side_effect=exc):
await handler.async_realtime(**kwargs)
mock_ws.close.assert_called_once()
assert mock_ws.close.call_args[1]["code"] == 403
@pytest.mark.asyncio
async def test_generic_exception_redacts_reason(self):
from litellm.llms.openai.realtime.handler import OpenAIRealtime
handler = OpenAIRealtime()
secret_error = RuntimeError("Connection failed for api_key=sk-1234567890abcdefghij")
kwargs = self._call_kwargs()
mock_ws = kwargs["websocket"]
p1, p2, p3 = self._make_patches(handler)
with p1, p2, p3, patch("websockets.connect", side_effect=secret_error):
await handler.async_realtime(**kwargs)
mock_ws.close.assert_called_once()
assert mock_ws.close.call_args[1]["code"] == 1011
assert "sk-1234567890abcdefghij" not in mock_ws.close.call_args[1]["reason"]
class TestAzureRealtimeRedaction:
"""Test that Azure realtime handler redacts secrets in websocket close reasons."""
@pytest.mark.asyncio
async def test_invalid_status_code_redacts_reason(self):
import websockets.exceptions
from litellm.llms.azure.realtime.handler import AzureOpenAIRealtime
handler = AzureOpenAIRealtime()
mock_ws = AsyncMock()
exc = websockets.exceptions.InvalidStatusCode(403, None)
exc.status_code = 403
with patch.object(handler, "_construct_url", return_value="wss://test.openai.azure.com/openai/realtime"), \
patch("websockets.connect", side_effect=exc):
await handler.async_realtime(
model="gpt-4",
websocket=mock_ws,
logging_obj=MagicMock(),
api_base="https://test.openai.azure.com/",
api_key="test-key",
api_version="2024-10-01-preview",
)
mock_ws.close.assert_called_once()
assert mock_ws.close.call_args[1]["code"] == 403
class TestBedrockRealtimeRedaction:
"""Test that _redact_string produces safe close reasons for Bedrock-style errors."""
def test_internal_error_message_redacted(self):
secret_error = RuntimeError("Failed with aws_secret_access_key=AKIAIOSFODNN7EXAMPLE123456")
reason = _redact_string(f"Internal error: {str(secret_error)}")
assert "AKIAIOSFODNN7EXAMPLE123456" not in reason
class TestLLMHTTPHandlerRealtimeRedaction:
"""Test _redact_string on the exact patterns used in llm_http_handler WS close."""
def test_invalid_status_pattern(self):
error_msg = "InvalidStatusCode: 403 for wss://api.example.com?api_key=sk-leaked-key-here"
assert "sk-leaked-key-here" not in _redact_string(str(error_msg))
def test_internal_server_error_pattern(self):
error_msg = "Connection failed for api_key=sk-secret-key-12345678"
assert "sk-secret-key-12345678" not in _redact_string(f"Internal server error: {error_msg}")
class TestProxyStreamingDataGeneratorRedaction:
"""Test _redact_string on traceback.format_exc() — the pattern at common_request_processing.py:1733."""
def test_redact_traceback_format_exc(self):
try:
raise RuntimeError(
"Failed connecting to api_key=sk-1234567890abcdefghij at https://api.example.com"
)
except RuntimeError:
raw_tb = traceback.format_exc()
redacted_tb = _redact_string(raw_tb)
assert "sk-1234567890abcdefghij" not in redacted_tb
assert "Traceback" in redacted_tb
assert "RuntimeError" in redacted_tb
def _make_mock_ingest_options():
mock = MagicMock()
mock.vector_store_config = {}
mock.ingest_name = "test"
mock.chunking_strategy = None
mock.embedding_model = None
mock.vector_db_type = "gemini"
return mock
class TestGeminiIngestionHeaders:
"""Test that Gemini RAG ingestion uses x-goog-api-key header."""
@pytest.mark.asyncio
async def test_create_file_search_store_sends_header(self):
from litellm.rag.ingestion.gemini_ingestion import GeminiRAGIngestion
ingestion = GeminiRAGIngestion(ingest_options=_make_mock_ingest_options())
mock_client = AsyncMock()
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {"name": "fileSearchStores/abc123"}
mock_client.post.return_value = mock_response
with patch(
"litellm.rag.ingestion.gemini_ingestion.get_async_httpx_client",
return_value=mock_client,
):
result = await ingestion._create_file_search_store(
api_key="test-gemini-key",
base_url="https://generativelanguage.googleapis.com/v1beta",
display_name="test-store",
)
assert result == "fileSearchStores/abc123"
call_kwargs = mock_client.post.call_args
assert call_kwargs[1]["headers"]["x-goog-api-key"] == "test-gemini-key"
assert "key=" not in call_kwargs[0][0]
@pytest.mark.asyncio
async def test_initiate_resumable_upload_sends_header(self):
from litellm.rag.ingestion.gemini_ingestion import GeminiRAGIngestion
ingestion = GeminiRAGIngestion(ingest_options=_make_mock_ingest_options())
mock_client = AsyncMock()
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.headers = {"x-goog-upload-url": "https://upload.example.com/upload123"}
mock_client.post.return_value = mock_response
with patch(
"litellm.rag.ingestion.gemini_ingestion.get_async_httpx_client",
return_value=mock_client,
):
result = await ingestion._initiate_resumable_upload(
api_key="test-gemini-key",
base_url="https://generativelanguage.googleapis.com/v1beta",
vector_store_id="fileSearchStores/abc123",
filename="test.txt",
file_size=1024,
content_type="text/plain",
)
assert result == "https://upload.example.com/upload123"
call_kwargs = mock_client.post.call_args
assert call_kwargs[1]["headers"]["x-goog-api-key"] == "test-gemini-key"
assert "key=" not in call_kwargs[0][0]