mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-13 23:11:40 +00:00
fix(guardrails): stop logging the request payload as guardrail_response on pre_call hooks (#39699)
* fix(guardrails): stop logging the request payload as guardrail_response on pre_call hooks Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(guardrails): snapshot pre_call request before the hook so in-place edits log as mask Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(guardrails): log non-mapping pre_call hook results as mask instead of raising Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(guardrails): treat legacy functions and tool_choice edits as mask in pre_call logging Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(guardrails): log a pre_call rejection string as is instead of "mask" Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: shivam <shivam@berri.ai> Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: yucheng <yucheng@berri.ai>
This commit is contained in:
parent
4e9d414603
commit
d78861bb29
3 changed files with 187 additions and 40 deletions
|
|
@ -1130,7 +1130,7 @@ class CustomGuardrail(CustomLogger):
|
|||
|
||||
def add_standard_logging_guardrail_information_to_request_data(
|
||||
self,
|
||||
guardrail_json_response: Exception | str | dict | list[dict],
|
||||
guardrail_json_response: object,
|
||||
request_data: dict,
|
||||
guardrail_status: GuardrailStatus,
|
||||
start_time: float | None = None,
|
||||
|
|
@ -1275,17 +1275,10 @@ class CustomGuardrail(CustomLogger):
|
|||
|
||||
This gets logged on downsteam Langfuse, DataDog, etc.
|
||||
"""
|
||||
# Convert None to empty dict to satisfy type requirements
|
||||
guardrail_response: dict[str, object] | str = {} if response is None else response
|
||||
|
||||
# For apply_guardrail functions in custom_code_guardrail scenario,
|
||||
# simplify the logged response to "allow", "deny", or "mask"
|
||||
if original_inputs is not None and isinstance(response, dict):
|
||||
# Check if inputs were modified by comparing them
|
||||
if self._inputs_were_modified(original_inputs, response):
|
||||
guardrail_response = "mask"
|
||||
else:
|
||||
guardrail_response = "allow"
|
||||
guardrail_response: Final = self._summarize_guardrail_response(
|
||||
response=response,
|
||||
original_inputs=original_inputs,
|
||||
)
|
||||
|
||||
verbose_logger.debug("Guardrail response: %s", response)
|
||||
|
||||
|
|
@ -1300,6 +1293,27 @@ class CustomGuardrail(CustomLogger):
|
|||
)
|
||||
return response
|
||||
|
||||
def _summarize_guardrail_response(
|
||||
self,
|
||||
response: object,
|
||||
original_inputs: Mapping[str, object] | None,
|
||||
) -> object:
|
||||
"""Reduce a hook's return value to what is safe to log as ``guardrail_response``.
|
||||
|
||||
``apply_guardrail`` returns the (possibly masked) inputs and ``async_pre_call_hook``
|
||||
returns the (possibly modified) request payload. Neither is a provider verdict, and
|
||||
logging them verbatim ships the user's prompt to every logging sink (OTEL spans,
|
||||
Datadog, spend logs), so both collapse to ``"allow"`` / ``"mask"`` by comparing
|
||||
against ``original_inputs``, a copy taken before the hook ran. A string result is the
|
||||
hook's own rejection message (the proxy turns it into a 400), not user input, so it is
|
||||
logged as is.
|
||||
"""
|
||||
if response is None:
|
||||
return {}
|
||||
if original_inputs is None or not isinstance(response, Mapping):
|
||||
return response
|
||||
return "mask" if self._inputs_were_modified(original_inputs, response) else "allow"
|
||||
|
||||
@staticmethod
|
||||
def _is_guardrail_intervention(e: Exception) -> bool:
|
||||
"""Retained spelling for existing callers; prefer ``is_guardrail_intervention``."""
|
||||
|
|
@ -1339,24 +1353,9 @@ class CustomGuardrail(CustomLogger):
|
|||
)
|
||||
raise e
|
||||
|
||||
def _inputs_were_modified(self, original_inputs: dict, response: dict) -> bool:
|
||||
"""
|
||||
Compare original inputs with response to determine if content was modified.
|
||||
|
||||
Returns True if the inputs were modified (mask scenario), False otherwise (allow scenario).
|
||||
"""
|
||||
# Get all keys from both dictionaries
|
||||
all_keys: Final = set(original_inputs.keys()) | set(response.keys())
|
||||
|
||||
# Compare each key's value
|
||||
for key in all_keys:
|
||||
original_value = original_inputs.get(key)
|
||||
response_value = response.get(key)
|
||||
if original_value != response_value:
|
||||
return True
|
||||
|
||||
# No modifications detected
|
||||
return False
|
||||
def _inputs_were_modified(self, original_inputs: Mapping[str, object], response: Mapping[str, object]) -> bool:
|
||||
"""True when any baseline key's value differs in ``response`` (mask), False otherwise (allow)."""
|
||||
return any(response.get(key) != value for key, value in original_inputs.items())
|
||||
|
||||
def mask_content_in_string(
|
||||
self,
|
||||
|
|
@ -1463,6 +1462,31 @@ def _sync_guardrail_info_to_logging_obj(request_data: dict, logging_obj: object)
|
|||
_append_slg_to_litellm_params(mcd.get("litellm_params"), entries)
|
||||
|
||||
|
||||
_PRE_CALL_CONTENT_KEYS: Final = frozenset(
|
||||
{"messages", "input", "prompt", "system", "instructions", "tools", "functions", "function_call", "tool_choice"}
|
||||
)
|
||||
|
||||
|
||||
def _original_inputs_for(
|
||||
func_name: str,
|
||||
kwargs: Mapping[str, object],
|
||||
request_data: Mapping[str, object],
|
||||
event_type: GuardrailEventHooks | None,
|
||||
) -> dict | None: # mutable-ok: matches _process_response(original_inputs=) signature
|
||||
"""Baseline the hook's return value is compared against to decide "allow" vs "mask".
|
||||
|
||||
``apply_guardrail`` masks a fresh ``inputs`` dict, so that dict is the baseline. Pre-call
|
||||
hooks edit the request in place and return it, so the baseline is a deep copy of the
|
||||
prompt-bearing keys taken before the hook runs.
|
||||
"""
|
||||
if func_name == "apply_guardrail":
|
||||
inputs: Final = kwargs.get("inputs")
|
||||
return inputs if isinstance(inputs, dict) else None
|
||||
if event_type != GuardrailEventHooks.pre_call:
|
||||
return None
|
||||
return {key: copy.deepcopy(value) for key, value in request_data.items() if key in _PRE_CALL_CONTENT_KEYS}
|
||||
|
||||
|
||||
def log_guardrail_information(func):
|
||||
"""
|
||||
Decorator to add standard logging guardrail information to any function
|
||||
|
|
@ -1521,9 +1545,7 @@ def log_guardrail_information(func):
|
|||
event_type: Final = _infer_event_type_from_function_name(func.__name__)
|
||||
|
||||
# Store original inputs for comparison (for apply_guardrail functions)
|
||||
original_inputs = None
|
||||
if func.__name__ == "apply_guardrail" and "inputs" in kwargs:
|
||||
original_inputs = kwargs.get("inputs")
|
||||
original_inputs: Final = _original_inputs_for(func.__name__, kwargs, request_data, event_type)
|
||||
|
||||
logging_obj: Final = kwargs.get("logging_obj") or request_data.get("litellm_logging_obj")
|
||||
self_recorded_token: Final = _guardrail_self_recorded.set(False)
|
||||
|
|
@ -1563,9 +1585,7 @@ def log_guardrail_information(func):
|
|||
event_type: Final = _infer_event_type_from_function_name(func.__name__)
|
||||
|
||||
# Store original inputs for comparison (for apply_guardrail functions)
|
||||
original_inputs = None
|
||||
if func.__name__ == "apply_guardrail" and "inputs" in kwargs:
|
||||
original_inputs = kwargs.get("inputs")
|
||||
original_inputs: Final = _original_inputs_for(func.__name__, kwargs, request_data, event_type)
|
||||
|
||||
logging_obj: Final = kwargs.get("logging_obj") or request_data.get("litellm_logging_obj")
|
||||
self_recorded_token: Final = _guardrail_self_recorded.set(False)
|
||||
|
|
|
|||
|
|
@ -338,10 +338,9 @@ class AzureContentSafetyPromptShieldGuardrail(AzureGuardrailBase, CustomGuardrai
|
|||
estimated cost) and the ``azure`` provider label to the recorded guardrail
|
||||
information. Follows the OpenAI moderation override pattern
|
||||
(openai/moderations.py)."""
|
||||
guardrail_response: Final[dict | str] = ( # mutable-ok: mirrors CustomGuardrail._process_response
|
||||
("mask" if self._inputs_were_modified(original_inputs, response) else "allow")
|
||||
if original_inputs is not None and isinstance(response, dict)
|
||||
else ({} if response is None else response) # mutable-ok: empty placeholder, never mutated
|
||||
guardrail_response: Final = self._summarize_guardrail_response(
|
||||
response=response,
|
||||
original_inputs=original_inputs,
|
||||
)
|
||||
self.add_standard_logging_guardrail_information_to_request_data(
|
||||
guardrail_json_response=guardrail_response,
|
||||
|
|
|
|||
|
|
@ -2829,3 +2829,131 @@ class TestCustomGuardrailPostCallSuccessDeploymentHook:
|
|||
assert response.choices[0].message.content == "filtered response"
|
||||
assert "guardrail_to_apply" not in request_data
|
||||
assert len(_guardrail_entries(request_data)) == 1
|
||||
|
||||
|
||||
class TestPreCallHookResponseIsNotLoggedVerbatim:
|
||||
"""Regression for LIT-6935: a pre_call hook returning the request payload leaked the prompt
|
||||
into ``guardrail_response`` and from there onto OTEL guardrail spans."""
|
||||
|
||||
@staticmethod
|
||||
def _logged_response(request_data: dict[str, object]) -> object:
|
||||
metadata = request_data["litellm_metadata"]
|
||||
assert isinstance(metadata, dict)
|
||||
entries = metadata["standard_logging_guardrail_information"]
|
||||
assert len(entries) == 1
|
||||
return entries[0]["guardrail_response"]
|
||||
|
||||
@staticmethod
|
||||
def _request() -> dict[str, object]:
|
||||
return {
|
||||
"model": "gpt-4.1-mini",
|
||||
"input": "SECRET_PROMPT",
|
||||
"messages": [{"role": "user", "content": "SECRET_PROMPT"}],
|
||||
"litellm_metadata": {},
|
||||
}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pre_call_hook_returning_request_logs_allow(self):
|
||||
class PassthroughGuardrail(CustomGuardrail):
|
||||
@log_guardrail_information
|
||||
async def async_pre_call_hook(
|
||||
self,
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
cache: object,
|
||||
data: dict[str, object],
|
||||
call_type: str,
|
||||
) -> dict[str, object]:
|
||||
return data
|
||||
|
||||
data = self._request()
|
||||
await PassthroughGuardrail(guardrail_name="g").async_pre_call_hook(
|
||||
user_api_key_dict=UserAPIKeyAuth(), cache=None, data=data, call_type="aresponses"
|
||||
)
|
||||
|
||||
assert self._logged_response(data) == "allow"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pre_call_hook_returning_modified_copy_logs_mask(self):
|
||||
class MaskingGuardrail(CustomGuardrail):
|
||||
@log_guardrail_information
|
||||
async def async_pre_call_hook(
|
||||
self,
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
cache: object,
|
||||
data: dict[str, object],
|
||||
call_type: str,
|
||||
) -> dict[str, object]:
|
||||
return {**data, "input": "[MASKED]"}
|
||||
|
||||
data = self._request()
|
||||
await MaskingGuardrail(guardrail_name="g").async_pre_call_hook(
|
||||
user_api_key_dict=UserAPIKeyAuth(), cache=None, data=data, call_type="aresponses"
|
||||
)
|
||||
|
||||
assert self._logged_response(data) == "mask"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pre_call_hook_mutating_request_in_place_logs_mask(self):
|
||||
class InPlaceMaskingGuardrail(CustomGuardrail):
|
||||
@log_guardrail_information
|
||||
async def async_pre_call_hook(
|
||||
self,
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
cache: object,
|
||||
data: dict[str, object],
|
||||
call_type: str,
|
||||
) -> dict[str, object]:
|
||||
messages = data["messages"]
|
||||
assert isinstance(messages, list)
|
||||
messages[0]["content"] = "[MASKED]"
|
||||
return data
|
||||
|
||||
data = self._request()
|
||||
await InPlaceMaskingGuardrail(guardrail_name="g").async_pre_call_hook(
|
||||
user_api_key_dict=UserAPIKeyAuth(), cache=None, data=data, call_type="acompletion"
|
||||
)
|
||||
|
||||
assert self._logged_response(data) == "mask"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pre_call_hook_returning_rejection_string_logs_that_string(self):
|
||||
class RejectingGuardrail(CustomGuardrail):
|
||||
@log_guardrail_information
|
||||
async def async_pre_call_hook(
|
||||
self,
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
cache: object,
|
||||
data: dict[str, object],
|
||||
call_type: str,
|
||||
) -> str:
|
||||
return "Blocked by policy"
|
||||
|
||||
data = self._request()
|
||||
result = await RejectingGuardrail(guardrail_name="g").async_pre_call_hook(
|
||||
user_api_key_dict=UserAPIKeyAuth(), cache=None, data=data, call_type="acompletion"
|
||||
)
|
||||
|
||||
assert result == "Blocked by policy"
|
||||
assert self._logged_response(data) == "Blocked by policy"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pre_call_hook_removing_legacy_functions_in_place_logs_mask(self):
|
||||
class FunctionStrippingGuardrail(CustomGuardrail):
|
||||
@log_guardrail_information
|
||||
async def async_pre_call_hook(
|
||||
self,
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
cache: object,
|
||||
data: dict[str, object],
|
||||
call_type: str,
|
||||
) -> dict[str, object]:
|
||||
data["functions"] = []
|
||||
data["function_call"] = "none"
|
||||
return data
|
||||
|
||||
data = {**self._request(), "functions": [{"name": "delete_db"}], "function_call": "auto"}
|
||||
await FunctionStrippingGuardrail(guardrail_name="g").async_pre_call_hook(
|
||||
user_api_key_dict=UserAPIKeyAuth(), cache=None, data=data, call_type="acompletion"
|
||||
)
|
||||
|
||||
assert self._logged_response(data) == "mask"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue