diff --git a/litellm/integrations/langfuse/langfuse.py b/litellm/integrations/langfuse/langfuse.py index 202013e448a..80d38a78f5b 100644 --- a/litellm/integrations/langfuse/langfuse.py +++ b/litellm/integrations/langfuse/langfuse.py @@ -285,7 +285,18 @@ class LangFuseLogger: litellm_params.get("metadata", {}) or {} ) # if litellm_params['metadata'] == None metadata = self.add_metadata_from_header(litellm_params, metadata) - optional_params = safe_deep_copy(kwargs.get("optional_params", {})) + # Prefer model_parameters from standard_logging_object — it is pre-filtered + # through ModelParamHelper.get_standard_logging_model_parameters() which + # whitelists only real OpenAI API params. This structurally prevents callback + # credentials (langfuse_secret_key, api keys, etc.) from ever reaching + # modelParameters, regardless of what per-key callback_vars are set. + _standard_logging_object = kwargs.get("standard_logging_object") + if _standard_logging_object is not None: + optional_params = dict( + _standard_logging_object.get("model_parameters") or {} + ) + else: + optional_params = safe_deep_copy(kwargs.get("optional_params", {})) prompt = {"messages": kwargs.get("messages")} @@ -293,8 +304,8 @@ class LangFuseLogger: tools = optional_params.pop("tools", None) # Remove secret_fields to prevent leaking sensitive data (e.g., authorization headers) optional_params.pop("secret_fields", None) - # Remove Langfuse credential keys - these are per-key/team callback vars that should - # not appear in modelParameters (they would expose secret keys in traces) + # Belt-and-suspenders: also pop any credential keys that may be present + # in the fallback path (direct litellm usage without standard_logging_object) optional_params.pop("langfuse_secret_key", None) optional_params.pop("langfuse_secret", None) optional_params.pop("langfuse_public_key", None) @@ -598,6 +609,15 @@ class LangFuseLogger: "endpoint", "caching_groups", "previous_models", + # user_api_key_auth is the full UserAPIKeyAuth object — it contains + # metadata.logging[].callback_vars with per-key secrets such as + # langfuse_secret_key, api keys, etc. Never log this object. + "user_api_key_auth", + # user_api_key_auth_metadata carries the raw virtual-key metadata + # dict including the `logging` key with callback_vars/credentials. + # The per-key identity fields (alias, team, spend, etc.) are already + # captured in the dedicated user_api_key_* generation fields. + "user_api_key_auth_metadata", ]: continue else: diff --git a/litellm/proxy/litellm_pre_call_utils.py b/litellm/proxy/litellm_pre_call_utils.py index cf4729db94b..858159580b7 100644 --- a/litellm/proxy/litellm_pre_call_utils.py +++ b/litellm/proxy/litellm_pre_call_utils.py @@ -667,10 +667,29 @@ class LiteLLMProxyRequestSetup: if user_api_key_dict.budget_reset_at else None ), - user_api_key_auth_metadata=user_api_key_dict.metadata, + user_api_key_auth_metadata=LiteLLMProxyRequestSetup._safe_key_metadata( + user_api_key_dict.metadata + ), ) return user_api_key_logged_metadata + @staticmethod + def _safe_key_metadata(metadata: Optional[dict]) -> Optional[dict]: + """ + Returns a copy of the virtual-key metadata with sensitive callback config stripped. + + The `logging` key contains per-key callback_vars (langfuse_secret_key, api keys, + etc.) that must never be propagated into observability payloads. Similarly, + `callback_settings` holds team-level callback secrets. Strip both before the dict + is stored in StandardLoggingUserAPIKeyMetadata and propagated to every logger. + """ + if not metadata: + return metadata + safe = dict(metadata) + safe.pop("logging", None) + safe.pop("callback_settings", None) + return safe + @staticmethod def add_user_api_key_auth_to_request_metadata( data: dict, diff --git a/tests/test_litellm/proxy/test_litellm_pre_call_utils.py b/tests/test_litellm/proxy/test_litellm_pre_call_utils.py index bc13cea939e..f02b1297ac7 100644 --- a/tests/test_litellm/proxy/test_litellm_pre_call_utils.py +++ b/tests/test_litellm/proxy/test_litellm_pre_call_utils.py @@ -1817,3 +1817,78 @@ async def test_bearer_token_not_in_debug_logs(): f"Bearer token leaked in debug logs. " f"Found token in log output:\n{log_output[:500]}" ) + + +def test_safe_key_metadata_strips_logging_key(): + """ + _safe_key_metadata must strip the `logging` key (contains raw callback_vars / + credentials) before the dict is stored in StandardLoggingUserAPIKeyMetadata + and propagated to every logger integration. + """ + raw_metadata = { + "team": "engineering", + "logging": [ + { + "callback_name": "langfuse", + "langfuse_secret_key": "sk-lf-super-secret", + "langfuse_public_key": "pk-lf-public", + } + ], + } + result = LiteLLMProxyRequestSetup._safe_key_metadata(raw_metadata) + assert result is not None + assert "logging" not in result, "logging key with raw credentials must be stripped" + assert result.get("team") == "engineering", "non-sensitive keys must be preserved" + + +def test_safe_key_metadata_strips_callback_settings_key(): + """ + _safe_key_metadata must strip `callback_settings` (team-level callback secrets). + """ + raw_metadata = { + "project": "demo", + "callback_settings": { + "langfuse": {"langfuse_secret_key": "sk-lf-team-secret"} + }, + } + result = LiteLLMProxyRequestSetup._safe_key_metadata(raw_metadata) + assert result is not None + assert "callback_settings" not in result, "callback_settings must be stripped" + assert result.get("project") == "demo" + + +def test_safe_key_metadata_none_and_empty(): + """ + _safe_key_metadata must handle None and empty dicts without error. + """ + assert LiteLLMProxyRequestSetup._safe_key_metadata(None) is None + assert LiteLLMProxyRequestSetup._safe_key_metadata({}) == {} + + +def test_get_sanitized_user_information_from_key_excludes_credentials(): + """ + get_sanitized_user_information_from_key must not expose per-key callback + credentials (e.g. langfuse_secret_key) in user_api_key_auth_metadata. + """ + user_api_key_dict = UserAPIKeyAuth( + api_key="hashed-test-key", + key_alias="my-key", + metadata={ + "team": "engineering", + "logging": [ + { + "callback_name": "langfuse", + "langfuse_secret_key": "sk-lf-should-not-appear", + } + ], + }, + ) + result = LiteLLMProxyRequestSetup.get_sanitized_user_information_from_key( + user_api_key_dict + ) + auth_metadata = result.get("user_api_key_auth_metadata") or {} + assert "logging" not in auth_metadata, ( + "logging key (contains raw credentials) must not appear in " + "user_api_key_auth_metadata exposed to loggers" + ) + assert auth_metadata.get("team") == "engineering"