chore(proxy): harden request control fields

This commit is contained in:
user 2026-04-29 22:35:17 -07:00
parent d3891e6eae
commit 842eea0131
20 changed files with 553 additions and 159 deletions

View file

@ -60,6 +60,9 @@ def _redact_choice_content(choice):
def _redact_responses_api_output(output_items): def _redact_responses_api_output(output_items):
"""Helper to redact ResponsesAPIResponse output items.""" """Helper to redact ResponsesAPIResponse output items."""
for output_item in output_items: for output_item in output_items:
if hasattr(output_item, "text"):
output_item.text = "redacted-by-litellm"
if hasattr(output_item, "content") and isinstance(output_item.content, list): if hasattr(output_item, "content") and isinstance(output_item.content, list):
for content_part in output_item.content: for content_part in output_item.content:
if hasattr(content_part, "text"): if hasattr(content_part, "text"):
@ -75,6 +78,28 @@ def _redact_responses_api_output(output_items):
summary_item.text = "redacted-by-litellm" summary_item.text = "redacted-by-litellm"
def _redact_responses_api_output_dict(output_items, redacted_str: str):
"""Helper to redact ResponsesAPIResponse output items in dict form."""
for output_item in output_items:
if not isinstance(output_item, dict):
continue
if "text" in output_item:
output_item["text"] = redacted_str
if isinstance(output_item.get("content"), list):
for content_item in output_item["content"]:
if isinstance(content_item, dict) and "text" in content_item:
content_item["text"] = redacted_str
if output_item.get("type") == "reasoning" and isinstance(
output_item.get("summary"), list
):
for summary_item in output_item["summary"]:
if isinstance(summary_item, dict) and "text" in summary_item:
summary_item["text"] = redacted_str
def _redact_standard_logging_object(model_call_details: dict): def _redact_standard_logging_object(model_call_details: dict):
"""Redact messages and response inside standard_logging_object if present.""" """Redact messages and response inside standard_logging_object if present."""
standard_logging_object = model_call_details.get("standard_logging_object") standard_logging_object = model_call_details.get("standard_logging_object")
@ -93,15 +118,7 @@ def _redact_standard_logging_object(model_call_details: dict):
if isinstance(response, dict) and "output" in response: if isinstance(response, dict) and "output" in response:
# ResponsesAPIResponse format - redact content in output items # ResponsesAPIResponse format - redact content in output items
if isinstance(response.get("output"), list): if isinstance(response.get("output"), list):
for output_item in response["output"]: _redact_responses_api_output_dict(response["output"], redacted_str)
if isinstance(output_item, dict) and "content" in output_item:
if isinstance(output_item["content"], list):
for content_item in output_item["content"]:
if (
isinstance(content_item, dict)
and "text" in content_item
):
content_item["text"] = redacted_str
elif isinstance(response, dict) and "choices" in response: elif isinstance(response, dict) and "choices" in response:
# ModelResponse dict format - redact content in choices # ModelResponse dict format - redact content in choices
if isinstance(response.get("choices"), list): if isinstance(response.get("choices"), list):
@ -122,6 +139,29 @@ def _redact_standard_logging_object(model_call_details: dict):
standard_logging_object["response"] = {"text": redacted_str} standard_logging_object["response"] = {"text": redacted_str}
def _redact_model_response_dict_choices(choices, redacted_str: str):
for choice in choices:
if isinstance(choice, dict):
if "message" in choice and isinstance(choice["message"], dict):
choice["message"]["content"] = redacted_str
if "reasoning_content" in choice["message"]:
choice["message"]["reasoning_content"] = redacted_str
if "thinking_blocks" in choice["message"]:
choice["message"]["thinking_blocks"] = None
if "audio" in choice["message"]:
choice["message"]["audio"] = None
elif "delta" in choice and isinstance(choice["delta"], dict):
choice["delta"]["content"] = redacted_str
if "reasoning_content" in choice["delta"]:
choice["delta"]["reasoning_content"] = redacted_str
if "thinking_blocks" in choice["delta"]:
choice["delta"]["thinking_blocks"] = None
if "audio" in choice["delta"]:
choice["delta"]["audio"] = None
else:
_redact_choice_content(choice)
def perform_redaction(model_call_details: dict, result): def perform_redaction(model_call_details: dict, result):
""" """
Performs the actual redaction on the logging object and result. Performs the actual redaction on the logging object and result.
@ -132,6 +172,7 @@ def perform_redaction(model_call_details: dict, result):
] ]
model_call_details["prompt"] = "" model_call_details["prompt"] = ""
model_call_details["input"] = "" model_call_details["input"] = ""
_redact_standard_logging_object(model_call_details)
# Redact streaming response # Redact streaming response
if ( if (
@ -171,30 +212,14 @@ def perform_redaction(model_call_details: dict, result):
elif isinstance(_result, dict) and "choices" in _result: elif isinstance(_result, dict) and "choices" in _result:
# Handle dict representation of ModelResponse (e.g., from model_dump()) # Handle dict representation of ModelResponse (e.g., from model_dump())
if _result.get("choices") is not None: if _result.get("choices") is not None:
for choice in _result["choices"]: _redact_model_response_dict_choices(
if isinstance(choice, dict): _result["choices"], "redacted-by-litellm"
if "message" in choice and isinstance(choice["message"], dict): )
choice["message"]["content"] = "redacted-by-litellm" elif isinstance(_result, dict) and "output" in _result:
if "reasoning_content" in choice["message"]: if isinstance(_result.get("output"), list):
choice["message"][ _redact_responses_api_output_dict(
"reasoning_content" _result["output"], "redacted-by-litellm"
] = "redacted-by-litellm" )
if "thinking_blocks" in choice["message"]:
choice["message"]["thinking_blocks"] = None
if "audio" in choice["message"]:
choice["message"]["audio"] = None
elif "delta" in choice and isinstance(choice["delta"], dict):
choice["delta"]["content"] = "redacted-by-litellm"
if "reasoning_content" in choice["delta"]:
choice["delta"][
"reasoning_content"
] = "redacted-by-litellm"
if "thinking_blocks" in choice["delta"]:
choice["delta"]["thinking_blocks"] = None
if "audio" in choice["delta"]:
choice["delta"]["audio"] = None
else:
_redact_choice_content(choice)
elif isinstance(_result, litellm.ResponsesAPIResponse): elif isinstance(_result, litellm.ResponsesAPIResponse):
if hasattr(_result, "output"): if hasattr(_result, "output"):
_redact_responses_api_output(_result.output) _redact_responses_api_output(_result.output)
@ -214,12 +239,15 @@ def should_redact_message_logging(model_call_details: dict) -> bool:
Determine if message logging should be redacted. Determine if message logging should be redacted.
Priority order: Priority order:
1. Dynamic parameter (turn_off_message_logging in request) 1. Global setting (litellm.turn_off_message_logging)
2. Headers (litellm-disable-message-redaction / litellm-enable-message-redaction) 2. Dynamic parameter (turn_off_message_logging in request)
3. Global setting (litellm.turn_off_message_logging) 3. Headers (litellm-disable-message-redaction / litellm-enable-message-redaction)
""" """
litellm_params = model_call_details.get("litellm_params", {}) litellm_params = model_call_details.get("litellm_params", {})
if litellm.turn_off_message_logging is True:
return True
metadata_field = get_metadata_variable_name_from_kwargs(litellm_params) metadata_field = get_metadata_variable_name_from_kwargs(litellm_params)
metadata = litellm_params.get(metadata_field, {}) metadata = litellm_params.get(metadata_field, {})
if not isinstance(metadata, dict): if not isinstance(metadata, dict):
@ -231,13 +259,6 @@ def should_redact_message_logging(model_call_details: dict) -> bool:
# Get headers from the metadata # Get headers from the metadata
request_headers = metadata.get("headers", {}) request_headers = metadata.get("headers", {})
# Check for headers that explicitly control redaction
if request_headers and bool(
request_headers.get("litellm-disable-message-redaction", False)
):
# User explicitly disabled redaction via header
return False
possible_enable_headers = [ possible_enable_headers = [
"litellm-enable-message-redaction", # old header. maintain backwards compatibility "litellm-enable-message-redaction", # old header. maintain backwards compatibility
"x-litellm-enable-message-redaction", # new header "x-litellm-enable-message-redaction", # new header
@ -257,12 +278,18 @@ def should_redact_message_logging(model_call_details: dict) -> bool:
# Dynamic parameter is explicitly set, use it # Dynamic parameter is explicitly set, use it
return dynamic_turn_off return dynamic_turn_off
if request_headers and bool(
request_headers.get("litellm-disable-message-redaction", False)
):
# User explicitly disabled redaction via header
return False
# Priority 2: Check if header explicitly enables redaction # Priority 2: Check if header explicitly enables redaction
if is_redaction_enabled_via_header: if is_redaction_enabled_via_header:
return True return True
# Priority 3: Fall back to global setting # Priority 3: Fall back to global setting
return litellm.turn_off_message_logging is True return False
def redact_message_input_output_from_logging( def redact_message_input_output_from_logging(

View file

@ -14,6 +14,8 @@ from litellm.types.utils import (
blue_color_code = "\033[94m" blue_color_code = "\033[94m"
reset_color_code = "\033[0m" reset_color_code = "\033[0m"
TRUSTED_PILLAR_RESPONSE_HEADERS_METADATA_KEY = "_pillar_response_headers_trusted"
if TYPE_CHECKING: if TYPE_CHECKING:
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLogging from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLogging
@ -417,10 +419,19 @@ def get_logging_caching_headers(request_data: Dict) -> Optional[Dict]:
if "semantic-similarity" in _metadata: if "semantic-similarity" in _metadata:
headers["x-litellm-semantic-similarity"] = str(_metadata["semantic-similarity"]) headers["x-litellm-semantic-similarity"] = str(_metadata["semantic-similarity"])
is_trusted_pillar_metadata = (
_metadata.get(TRUSTED_PILLAR_RESPONSE_HEADERS_METADATA_KEY) is True
)
pillar_headers = _metadata.get("pillar_response_headers") pillar_headers = _metadata.get("pillar_response_headers")
if isinstance(pillar_headers, dict): if is_trusted_pillar_metadata and isinstance(pillar_headers, dict):
headers.update(pillar_headers) headers.update(
elif "pillar_flagged" in _metadata: {
key: str(value)
for key, value in pillar_headers.items()
if isinstance(key, str) and key.lower().startswith("x-pillar-")
}
)
elif is_trusted_pillar_metadata and "pillar_flagged" in _metadata:
headers["x-pillar-flagged"] = str(_metadata["pillar_flagged"]).lower() headers["x-pillar-flagged"] = str(_metadata["pillar_flagged"]).lower()
return headers return headers

View file

@ -71,6 +71,7 @@ from litellm.types.utils import (
) )
GUARDRAIL_NAME = "bedrock" GUARDRAIL_NAME = "bedrock"
_BEDROCK_DYNAMIC_BODY_DENYLIST = frozenset({"content", "source"})
class GuardrailMessageFilterResult(NamedTuple): class GuardrailMessageFilterResult(NamedTuple):
@ -413,11 +414,18 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM):
) )
api_key: Optional[str] = None api_key: Optional[str] = None
if request_data: if request_data:
bedrock_request_data.update( dynamic_request_body_params = (
self.get_guardrail_dynamic_request_body_params( self.get_guardrail_dynamic_request_body_params(
request_data=request_data request_data=request_data
) )
) )
bedrock_request_data.update(
{
key: value
for key, value in dynamic_request_body_params.items()
if key not in _BEDROCK_DYNAMIC_BODY_DENYLIST
}
)
if request_data.get("api_key") is not None: if request_data.get("api_key") is not None:
api_key = request_data["api_key"] api_key = request_data["api_key"]

View file

@ -29,6 +29,7 @@ from litellm.llms.custom_httpx.http_handler import (
) )
from litellm.proxy._types import UserAPIKeyAuth from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.common_utils.callback_utils import ( from litellm.proxy.common_utils.callback_utils import (
TRUSTED_PILLAR_RESPONSE_HEADERS_METADATA_KEY,
add_guardrail_to_applied_guardrails_header, add_guardrail_to_applied_guardrails_header,
get_metadata_variable_name_from_kwargs, get_metadata_variable_name_from_kwargs,
) )
@ -144,6 +145,7 @@ def build_pillar_response_headers(metadata_store: Dict[str, Any]) -> Dict[str, s
if headers: if headers:
metadata_store["pillar_response_headers"] = headers metadata_store["pillar_response_headers"] = headers
metadata_store[TRUSTED_PILLAR_RESPONSE_HEADERS_METADATA_KEY] = True
return headers return headers

View file

@ -41,6 +41,7 @@ class KeyManagementEventHooks:
""" """
from litellm.proxy.management_helpers.audit_logs import ( from litellm.proxy.management_helpers.audit_logs import (
create_audit_log_for_update, create_audit_log_for_update,
get_audit_log_changed_by,
) )
from litellm.proxy.proxy_server import litellm_proxy_admin_name from litellm.proxy.proxy_server import litellm_proxy_admin_name
@ -61,9 +62,11 @@ class KeyManagementEventHooks:
request_data=LiteLLM_AuditLogs( request_data=LiteLLM_AuditLogs(
id=str(uuid.uuid4()), id=str(uuid.uuid4()),
updated_at=datetime.now(timezone.utc), updated_at=datetime.now(timezone.utc),
changed_by=litellm_changed_by changed_by=get_audit_log_changed_by(
or user_api_key_dict.user_id litellm_changed_by=litellm_changed_by,
or litellm_proxy_admin_name, user_api_key_dict=user_api_key_dict,
litellm_proxy_admin_name=litellm_proxy_admin_name,
),
changed_by_api_key=user_api_key_dict.api_key, changed_by_api_key=user_api_key_dict.api_key,
table_name=LitellmTableNames.KEY_TABLE_NAME, table_name=LitellmTableNames.KEY_TABLE_NAME,
object_id=response.token_id or "", object_id=response.token_id or "",
@ -102,6 +105,7 @@ class KeyManagementEventHooks:
""" """
from litellm.proxy.management_helpers.audit_logs import ( from litellm.proxy.management_helpers.audit_logs import (
create_audit_log_for_update, create_audit_log_for_update,
get_audit_log_changed_by,
) )
from litellm.proxy.proxy_server import litellm_proxy_admin_name from litellm.proxy.proxy_server import litellm_proxy_admin_name
@ -117,9 +121,11 @@ class KeyManagementEventHooks:
request_data=LiteLLM_AuditLogs( request_data=LiteLLM_AuditLogs(
id=str(uuid.uuid4()), id=str(uuid.uuid4()),
updated_at=datetime.now(timezone.utc), updated_at=datetime.now(timezone.utc),
changed_by=litellm_changed_by changed_by=get_audit_log_changed_by(
or user_api_key_dict.user_id litellm_changed_by=litellm_changed_by,
or litellm_proxy_admin_name, user_api_key_dict=user_api_key_dict,
litellm_proxy_admin_name=litellm_proxy_admin_name,
),
changed_by_api_key=user_api_key_dict.api_key, changed_by_api_key=user_api_key_dict.api_key,
table_name=LitellmTableNames.KEY_TABLE_NAME, table_name=LitellmTableNames.KEY_TABLE_NAME,
object_id=data.key, object_id=data.key,
@ -140,6 +146,7 @@ class KeyManagementEventHooks:
): ):
from litellm.proxy.management_helpers.audit_logs import ( from litellm.proxy.management_helpers.audit_logs import (
create_audit_log_for_update, create_audit_log_for_update,
get_audit_log_changed_by,
) )
from litellm.proxy.proxy_server import litellm_proxy_admin_name from litellm.proxy.proxy_server import litellm_proxy_admin_name
@ -189,9 +196,11 @@ class KeyManagementEventHooks:
request_data=LiteLLM_AuditLogs( request_data=LiteLLM_AuditLogs(
id=str(uuid.uuid4()), id=str(uuid.uuid4()),
updated_at=datetime.now(timezone.utc), updated_at=datetime.now(timezone.utc),
changed_by=litellm_changed_by changed_by=get_audit_log_changed_by(
or user_api_key_dict.user_id litellm_changed_by=litellm_changed_by,
or litellm_proxy_admin_name, user_api_key_dict=user_api_key_dict,
litellm_proxy_admin_name=litellm_proxy_admin_name,
),
changed_by_api_key=user_api_key_dict.token, changed_by_api_key=user_api_key_dict.token,
table_name=LitellmTableNames.KEY_TABLE_NAME, table_name=LitellmTableNames.KEY_TABLE_NAME,
object_id=existing_key_row.token, object_id=existing_key_row.token,
@ -220,6 +229,7 @@ class KeyManagementEventHooks:
""" """
from litellm.proxy.management_helpers.audit_logs import ( from litellm.proxy.management_helpers.audit_logs import (
create_audit_log_for_update, create_audit_log_for_update,
get_audit_log_changed_by,
) )
from litellm.proxy.proxy_server import litellm_proxy_admin_name from litellm.proxy.proxy_server import litellm_proxy_admin_name
@ -237,9 +247,11 @@ class KeyManagementEventHooks:
request_data=LiteLLM_AuditLogs( request_data=LiteLLM_AuditLogs(
id=str(uuid.uuid4()), id=str(uuid.uuid4()),
updated_at=datetime.now(timezone.utc), updated_at=datetime.now(timezone.utc),
changed_by=litellm_changed_by changed_by=get_audit_log_changed_by(
or user_api_key_dict.user_id litellm_changed_by=litellm_changed_by,
or litellm_proxy_admin_name, user_api_key_dict=user_api_key_dict,
litellm_proxy_admin_name=litellm_proxy_admin_name,
),
changed_by_api_key=user_api_key_dict.token, changed_by_api_key=user_api_key_dict.token,
table_name=LitellmTableNames.KEY_TABLE_NAME, table_name=LitellmTableNames.KEY_TABLE_NAME,
object_id=key.token, object_id=key.token,

View file

@ -192,13 +192,19 @@ class UserManagementEventHooks:
if not litellm.store_audit_logs: if not litellm.store_audit_logs:
return return
from litellm.proxy.management_helpers.audit_logs import (
get_audit_log_changed_by,
)
await create_audit_log_for_update( await create_audit_log_for_update(
request_data=LiteLLM_AuditLogs( request_data=LiteLLM_AuditLogs(
id=str(uuid.uuid4()), id=str(uuid.uuid4()),
updated_at=datetime.now(timezone.utc), updated_at=datetime.now(timezone.utc),
changed_by=litellm_changed_by changed_by=get_audit_log_changed_by(
or user_api_key_dict.user_id litellm_changed_by=litellm_changed_by,
or litellm_proxy_admin_name, user_api_key_dict=user_api_key_dict,
litellm_proxy_admin_name=litellm_proxy_admin_name,
),
changed_by_api_key=user_api_key_dict.api_key, changed_by_api_key=user_api_key_dict.api_key,
table_name=LitellmTableNames.USER_TABLE_NAME, table_name=LitellmTableNames.USER_TABLE_NAME,
object_id=user_id, object_id=user_id,

View file

@ -104,6 +104,59 @@ LITELLM_METADATA_ROUTES = (
"files", "files",
) )
_UNTRUSTED_ROOT_CONTROL_FIELDS = (
"proxy_server_request",
"standard_logging_object",
"secret_fields",
"mock_response",
"mock_tool_calls",
"disable_global_guardrails",
"disable_global_guardrail",
"opted_out_global_guardrails",
"applied_guardrails",
"applied_policies",
"policy_sources",
"pillar_response_headers",
"_guardrail_pipelines",
"_pipeline_managed_guardrails",
)
_UNTRUSTED_METADATA_CONTROL_FIELDS = (
"disable_global_guardrails",
"disable_global_guardrail",
"opted_out_global_guardrails",
"pillar_response_headers",
"_pillar_response_headers_trusted",
"pillar_flagged",
"pillar_scanners",
"pillar_evidence",
"pillar_evidence_truncated",
"pillar_session_id_response",
"applied_guardrails",
"applied_policies",
"policy_sources",
"standard_logging_object",
"proxy_server_request",
"secret_fields",
"_guardrail_pipelines",
"_pipeline_managed_guardrails",
)
_CLIENT_MOCK_CONTROL_FIELDS = frozenset({"mock_response", "mock_tool_calls"})
_ALLOW_CLIENT_MOCK_RESPONSE_METADATA_KEY = "allow_client_mock_response"
def _key_or_team_allows_client_mock_response(
user_api_key_dict: UserAPIKeyAuth,
) -> bool:
for admin_metadata in (user_api_key_dict.metadata, user_api_key_dict.team_metadata):
if (
isinstance(admin_metadata, dict)
and admin_metadata.get(_ALLOW_CLIENT_MOCK_RESPONSE_METADATA_KEY) is True
):
return True
return False
def _get_metadata_variable_name(request: Request) -> str: def _get_metadata_variable_name(request: Request) -> str:
""" """
@ -962,11 +1015,12 @@ async def add_litellm_data_to_request( # noqa: PLR0915
# Strip internal-only keys from user input before the proxy sets its own. # Strip internal-only keys from user input before the proxy sets its own.
# These keys are injected by the proxy itself below — user-supplied values # These keys are injected by the proxy itself below — user-supplied values
# must not be trusted. # must not be trusted.
for _internal_key in ( _allow_client_mock_response = _key_or_team_allows_client_mock_response(
"proxy_server_request", user_api_key_dict
"standard_logging_object", )
"secret_fields", for _internal_key in _UNTRUSTED_ROOT_CONTROL_FIELDS:
): if _allow_client_mock_response and _internal_key in _CLIENT_MOCK_CONTROL_FIELDS:
continue
data.pop(_internal_key, None) data.pop(_internal_key, None)
# Strip spoofable auth metadata from user-supplied metadata dict # Strip spoofable auth metadata from user-supplied metadata dict
_user_metadata = data.get("metadata") _user_metadata = data.get("metadata")
@ -1144,8 +1198,12 @@ async def add_litellm_data_to_request( # noqa: PLR0915
for _meta_key in ("metadata", "litellm_metadata"): for _meta_key in ("metadata", "litellm_metadata"):
_user_meta = data.get(_meta_key) _user_meta = data.get(_meta_key)
if isinstance(_user_meta, dict): if isinstance(_user_meta, dict):
_user_meta.pop("_pipeline_managed_guardrails", None) for _k in [
for _k in [k for k in _user_meta if k.startswith("user_api_key_")]: k
for k in _user_meta
if k.startswith("user_api_key_")
or k in _UNTRUSTED_METADATA_CONTROL_FIELDS
]:
_user_meta.pop(_k, None) _user_meta.pop(_k, None)
# Strip caller-supplied routing/budget tags unless the admin has opted # Strip caller-supplied routing/budget tags unless the admin has opted

View file

@ -2162,8 +2162,8 @@ async def delete_user(
request_data=LiteLLM_AuditLogs( request_data=LiteLLM_AuditLogs(
id=str(uuid.uuid4()), id=str(uuid.uuid4()),
updated_at=datetime.now(timezone.utc), updated_at=datetime.now(timezone.utc),
changed_by=litellm_changed_by changed_by=user_api_key_dict.user_id
or user_api_key_dict.user_id or litellm_changed_by
or litellm_proxy_admin_name, or litellm_proxy_admin_name,
changed_by_api_key=user_api_key_dict.api_key, changed_by_api_key=user_api_key_dict.api_key,
table_name=LitellmTableNames.USER_TABLE_NAME, table_name=LitellmTableNames.USER_TABLE_NAME,

View file

@ -5297,8 +5297,8 @@ async def block_key(
request_data=LiteLLM_AuditLogs( request_data=LiteLLM_AuditLogs(
id=str(uuid.uuid4()), id=str(uuid.uuid4()),
updated_at=datetime.now(timezone.utc), updated_at=datetime.now(timezone.utc),
changed_by=litellm_changed_by changed_by=user_api_key_dict.user_id
or user_api_key_dict.user_id or litellm_changed_by
or litellm_proxy_admin_name, or litellm_proxy_admin_name,
changed_by_api_key=user_api_key_dict.api_key, changed_by_api_key=user_api_key_dict.api_key,
table_name=LitellmTableNames.KEY_TABLE_NAME, table_name=LitellmTableNames.KEY_TABLE_NAME,
@ -5406,8 +5406,8 @@ async def unblock_key(
request_data=LiteLLM_AuditLogs( request_data=LiteLLM_AuditLogs(
id=str(uuid.uuid4()), id=str(uuid.uuid4()),
updated_at=datetime.now(timezone.utc), updated_at=datetime.now(timezone.utc),
changed_by=litellm_changed_by changed_by=user_api_key_dict.user_id
or user_api_key_dict.user_id or litellm_changed_by
or litellm_proxy_admin_name, or litellm_proxy_admin_name,
changed_by_api_key=user_api_key_dict.api_key, changed_by_api_key=user_api_key_dict.api_key,
table_name=LitellmTableNames.KEY_TABLE_NAME, table_name=LitellmTableNames.KEY_TABLE_NAME,
@ -5589,7 +5589,6 @@ async def test_key_logging(
"content": "Hello, this is a test from litellm /key/health. No LLM API call was made for this", "content": "Hello, this is a test from litellm /key/health. No LLM API call was made for this",
} }
], ],
"mock_response": "test response",
} }
data = await add_litellm_data_to_request( data = await add_litellm_data_to_request(
data=data, data=data,
@ -5598,6 +5597,7 @@ async def test_key_logging(
general_settings=general_settings, general_settings=general_settings,
request=request, request=request,
) )
data["mock_response"] = "test response"
await litellm.acompletion( await litellm.acompletion(
**data **data
) # make mock completion call to trigger key based callbacks ) # make mock completion call to trigger key based callbacks

View file

@ -2230,7 +2230,7 @@ if MCP_AVAILABLE:
detail={"error": "Only proxy admins can create MCP toolsets."}, detail={"error": "Only proxy admins can create MCP toolsets."},
) )
touched_by = ( touched_by = (
litellm_changed_by or user_api_key_dict.user_id or LITELLM_PROXY_ADMIN_NAME user_api_key_dict.user_id or litellm_changed_by or LITELLM_PROXY_ADMIN_NAME
) )
try: try:
result = await create_mcp_toolset(prisma_client, payload, touched_by) result = await create_mcp_toolset(prisma_client, payload, touched_by)
@ -2321,7 +2321,7 @@ if MCP_AVAILABLE:
detail={"error": "Only proxy admins can update MCP toolsets."}, detail={"error": "Only proxy admins can update MCP toolsets."},
) )
touched_by = ( touched_by = (
litellm_changed_by or user_api_key_dict.user_id or LITELLM_PROXY_ADMIN_NAME user_api_key_dict.user_id or litellm_changed_by or LITELLM_PROXY_ADMIN_NAME
) )
try: try:
result = await update_mcp_toolset(prisma_client, payload, touched_by) result = await update_mcp_toolset(prisma_client, payload, touched_by)

View file

@ -1174,8 +1174,8 @@ async def new_team( # noqa: PLR0915
request_data=LiteLLM_AuditLogs( request_data=LiteLLM_AuditLogs(
id=str(uuid.uuid4()), id=str(uuid.uuid4()),
updated_at=datetime.now(timezone.utc), updated_at=datetime.now(timezone.utc),
changed_by=litellm_changed_by changed_by=user_api_key_dict.user_id
or user_api_key_dict.user_id or litellm_changed_by
or litellm_proxy_admin_name, or litellm_proxy_admin_name,
changed_by_api_key=user_api_key_dict.api_key, changed_by_api_key=user_api_key_dict.api_key,
table_name=LitellmTableNames.TEAM_TABLE_NAME, table_name=LitellmTableNames.TEAM_TABLE_NAME,
@ -1225,8 +1225,8 @@ async def _create_team_update_audit_log(
request_data=LiteLLM_AuditLogs( request_data=LiteLLM_AuditLogs(
id=str(uuid.uuid4()), id=str(uuid.uuid4()),
updated_at=datetime.now(timezone.utc), updated_at=datetime.now(timezone.utc),
changed_by=litellm_changed_by changed_by=user_api_key_dict.user_id
or user_api_key_dict.user_id or litellm_changed_by
or litellm_proxy_admin_name, or litellm_proxy_admin_name,
changed_by_api_key=user_api_key_dict.api_key, changed_by_api_key=user_api_key_dict.api_key,
table_name=LitellmTableNames.TEAM_TABLE_NAME, table_name=LitellmTableNames.TEAM_TABLE_NAME,
@ -3054,8 +3054,8 @@ async def delete_team(
request_data=LiteLLM_AuditLogs( request_data=LiteLLM_AuditLogs(
id=str(uuid.uuid4()), id=str(uuid.uuid4()),
updated_at=datetime.now(timezone.utc), updated_at=datetime.now(timezone.utc),
changed_by=litellm_changed_by changed_by=user_api_key_dict.user_id
or user_api_key_dict.user_id or litellm_changed_by
or litellm_proxy_admin_name, or litellm_proxy_admin_name,
changed_by_api_key=user_api_key_dict.api_key, changed_by_api_key=user_api_key_dict.api_key,
table_name=LitellmTableNames.TEAM_TABLE_NAME, table_name=LitellmTableNames.TEAM_TABLE_NAME,

View file

@ -23,6 +23,15 @@ from litellm.types.utils import StandardAuditLogPayload
_audit_log_callback_cache: Dict[str, CustomLogger] = {} _audit_log_callback_cache: Dict[str, CustomLogger] = {}
def get_audit_log_changed_by(
*,
litellm_changed_by: Optional[str],
user_api_key_dict: UserAPIKeyAuth,
litellm_proxy_admin_name: Optional[str],
) -> Optional[str]:
return user_api_key_dict.user_id or litellm_changed_by or litellm_proxy_admin_name
def _resolve_audit_log_callback(name: str) -> Optional[CustomLogger]: def _resolve_audit_log_callback(name: str) -> Optional[CustomLogger]:
"""Resolve a string callback name to a CustomLogger instance, with caching.""" """Resolve a string callback name to a CustomLogger instance, with caching."""
if name in _audit_log_callback_cache: if name in _audit_log_callback_cache:
@ -143,8 +152,10 @@ async def create_object_audit_log(
if _store_audit_logs is not True: if _store_audit_logs is not True:
return return
_changed_by = ( _changed_by = get_audit_log_changed_by(
litellm_changed_by or user_api_key_dict.user_id or litellm_proxy_admin_name litellm_changed_by=litellm_changed_by,
user_api_key_dict=user_api_key_dict,
litellm_proxy_admin_name=litellm_proxy_admin_name,
) )
await create_audit_log_for_update( await create_audit_log_for_update(

View file

@ -750,7 +750,7 @@ def test_redact_msgs_from_logs_with_dynamic_params():
In all tests litellm.turn_off_message_logging is True In all tests litellm.turn_off_message_logging is True
1. When standard_callback_dynamic_params.turn_off_message_logging is False (or not set): No redaction should occur. User has opted out of redaction. 1. When standard_callback_dynamic_params.turn_off_message_logging is False: global redaction still wins.
2. When standard_callback_dynamic_params.turn_off_message_logging is True: Redaction should occur. User has opted in to redaction. 2. When standard_callback_dynamic_params.turn_off_message_logging is True: Redaction should occur. User has opted in to redaction.
3. standard_callback_dynamic_params.turn_off_message_logging not set, litellm.turn_off_message_logging is True: Redaction should occur. 3. standard_callback_dynamic_params.turn_off_message_logging not set, litellm.turn_off_message_logging is True: Redaction should occur.
""" """
@ -784,7 +784,7 @@ def test_redact_msgs_from_logs_with_dynamic_params():
function_id="1234", function_id="1234",
) )
# Test Case 1: standard_callback_dynamic_params = False (or not set) # Test Case 1: standard_callback_dynamic_params = False
standard_callback_dynamic_params = StandardCallbackDynamicParams( standard_callback_dynamic_params = StandardCallbackDynamicParams(
turn_off_message_logging=False turn_off_message_logging=False
) )
@ -795,8 +795,8 @@ def test_redact_msgs_from_logs_with_dynamic_params():
result=response_obj, result=response_obj,
model_call_details=litellm_logging_obj.model_call_details, model_call_details=litellm_logging_obj.model_call_details,
) )
# Assert no redaction occurred # Assert global redaction still occurred
assert _redacted_response_obj.choices[0].message.content == test_content assert _redacted_response_obj.choices[0].message.content == "redacted-by-litellm"
# Test Case 2: standard_callback_dynamic_params = True # Test Case 2: standard_callback_dynamic_params = True
standard_callback_dynamic_params = StandardCallbackDynamicParams( standard_callback_dynamic_params = StandardCallbackDynamicParams(

View file

@ -13,11 +13,13 @@ import logging
import time import time
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, patch
import httpx
import pytest import pytest
import litellm import litellm
from litellm._logging import verbose_logger from litellm._logging import verbose_logger
from litellm.integrations.custom_logger import CustomLogger from litellm.integrations.custom_logger import CustomLogger
from litellm.responses.main import mock_responses_api_response
from litellm.types.utils import StandardLoggingPayload from litellm.types.utils import StandardLoggingPayload
@ -126,17 +128,10 @@ async def test_redaction_responses_api():
test_custom_logger = TestCustomLogger(turn_off_message_logging=True) test_custom_logger = TestCustomLogger(turn_off_message_logging=True)
litellm.callbacks = [test_custom_logger] litellm.callbacks = [test_custom_logger]
# Mock a ResponsesAPIResponse-style response
mock_response = {
"output": [{"text": "This is a test response"}],
"model": "gpt-3.5-turbo",
"usage": {"input_tokens": 5, "output_tokens": 5, "total_tokens": 10},
}
response = await litellm.aresponses( response = await litellm.aresponses(
model="gpt-3.5-turbo", model="gpt-3.5-turbo",
input="hi", input="hi",
mock_response=mock_response, mock_response="This is a test response",
) )
await asyncio.sleep(1) await asyncio.sleep(1)
@ -163,6 +158,7 @@ async def test_redaction_responses_api():
assert ( assert (
content_item["text"] == "redacted-by-litellm" content_item["text"] == "redacted-by-litellm"
), f"Expected redacted text but got: {content_item['text']}" ), f"Expected redacted text but got: {content_item['text']}"
assert "This is a test response" not in json.dumps(standard_logging_payload)
print( print(
"logged standard logging payload for ResponsesAPIResponse", "logged standard logging payload for ResponsesAPIResponse",
json.dumps(standard_logging_payload, indent=2), json.dumps(standard_logging_payload, indent=2),
@ -176,27 +172,34 @@ async def test_redaction_responses_api_stream():
test_custom_logger = TestCustomLogger(turn_off_message_logging=True) test_custom_logger = TestCustomLogger(turn_off_message_logging=True)
litellm.callbacks = [test_custom_logger] litellm.callbacks = [test_custom_logger]
# Mock a ResponsesAPIResponse-style response with streaming chunks mocked_response_payload = mock_responses_api_response(
mock_response = [ "This is a test response"
{ ).model_dump()
"output": [{"text": "This"}],
"model": "gpt-3.5-turbo",
},
{
"output": [{"text": " is"}],
"model": "gpt-3.5-turbo",
},
{
"output": [{"text": " a test response"}],
"model": "gpt-3.5-turbo",
"usage": {"input_tokens": 5, "output_tokens": 5, "total_tokens": 10},
},
]
async def mock_post(self, url, headers, timeout, stream=False, **kwargs):
stream_content = (
"data: "
+ json.dumps(
{
"type": "response.completed",
"response": mocked_response_payload,
}
)
+ "\n\ndata: [DONE]\n\n"
)
return httpx.Response(
status_code=200,
content=stream_content,
request=httpx.Request("POST", url),
)
with patch(
"litellm.llms.custom_httpx.http_handler.AsyncHTTPHandler.post",
new=mock_post,
):
response = await litellm.aresponses( response = await litellm.aresponses(
model="gpt-3.5-turbo", model="gpt-3.5-turbo",
input="hi", input="hi",
mock_response=mock_response,
stream=True, stream=True,
) )
@ -445,18 +448,11 @@ async def test_disable_redaction_header_responses_api():
test_custom_logger = TestCustomLogger() test_custom_logger = TestCustomLogger()
litellm.callbacks = [test_custom_logger] litellm.callbacks = [test_custom_logger]
# Mock a ResponsesAPIResponse-style response
mock_response = {
"output": [{"text": "This is a test response"}],
"model": "gpt-3.5-turbo",
"usage": {"input_tokens": 5, "output_tokens": 5, "total_tokens": 10},
}
# Pass the header via litellm_metadata (as the proxy does for Responses API) # Pass the header via litellm_metadata (as the proxy does for Responses API)
response = await litellm.aresponses( response = await litellm.aresponses(
model="gpt-3.5-turbo", model="gpt-3.5-turbo",
input="hi", input="hi",
mock_response=mock_response, mock_response="This is a test response",
litellm_metadata={"headers": {"litellm-disable-message-redaction": "true"}}, litellm_metadata={"headers": {"litellm-disable-message-redaction": "true"}},
) )
@ -464,15 +460,16 @@ async def test_disable_redaction_header_responses_api():
standard_logging_payload = test_custom_logger.logged_standard_logging_payload standard_logging_payload = test_custom_logger.logged_standard_logging_payload
assert standard_logging_payload is not None assert standard_logging_payload is not None
# Verify that messages are NOT redacted because the header was set # Verify that global redaction is not disabled by the header
print( print(
"logged standard logging payload for ResponsesAPI with disable header", "logged standard logging payload for ResponsesAPI with disable header",
json.dumps(standard_logging_payload, indent=2, default=str), json.dumps(standard_logging_payload, indent=2, default=str),
) )
# The content should NOT be redacted response = standard_logging_payload["response"]
assert standard_logging_payload["response"] != {"text": "redacted-by-litellm"} assert response["output"][0]["content"][0]["text"] == "redacted-by-litellm"
assert standard_logging_payload["messages"][0]["content"] == "hi" assert "This is a test response" not in json.dumps(standard_logging_payload)
assert standard_logging_payload["messages"][0]["content"] == "redacted-by-litellm"
@pytest.mark.asyncio @pytest.mark.asyncio

View file

@ -45,8 +45,11 @@ verbose_proxy_logger.setLevel(level=logging.DEBUG)
from starlette.datastructures import URL from starlette.datastructures import URL
from litellm.proxy.management_helpers.audit_logs import create_audit_log_for_update from litellm.proxy.management_helpers.audit_logs import (
from litellm.proxy._types import LiteLLM_AuditLogs, LitellmTableNames create_audit_log_for_update,
get_audit_log_changed_by,
)
from litellm.proxy._types import LiteLLM_AuditLogs, LitellmTableNames, UserAPIKeyAuth
from litellm.caching.caching import DualCache from litellm.caching.caching import DualCache
from unittest.mock import patch, AsyncMock from unittest.mock import patch, AsyncMock
@ -54,6 +57,35 @@ proxy_logging_obj = ProxyLogging(user_api_key_cache=DualCache())
import json import json
def test_get_audit_log_changed_by_prefers_authenticated_user():
user_api_key_dict = UserAPIKeyAuth(
api_key="test-key",
user_id="authenticated-user",
)
assert (
get_audit_log_changed_by(
litellm_changed_by="spoofed-user",
user_api_key_dict=user_api_key_dict,
litellm_proxy_admin_name="proxy-admin",
)
== "authenticated-user"
)
def test_get_audit_log_changed_by_falls_back_to_header_when_user_id_missing():
user_api_key_dict = UserAPIKeyAuth(api_key="test-key")
assert (
get_audit_log_changed_by(
litellm_changed_by="delegated-user",
user_api_key_dict=user_api_key_dict,
litellm_proxy_admin_name="proxy-admin",
)
== "delegated-user"
)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_create_audit_log_for_update_premium_user(): async def test_create_audit_log_for_update_premium_user():
""" """

View file

@ -1553,6 +1553,7 @@ async def test_add_callback_via_key(prisma_client):
fastapi_response=Response(), fastapi_response=Response(),
user_api_key_dict=UserAPIKeyAuth( user_api_key_dict=UserAPIKeyAuth(
metadata={ metadata={
"allow_client_mock_response": True,
"logging": [ "logging": [
{ {
"callback_name": "langfuse", # 'otel', 'langfuse', 'lunary' "callback_name": "langfuse", # 'otel', 'langfuse', 'lunary'
@ -1563,7 +1564,7 @@ async def test_add_callback_via_key(prisma_client):
"langfuse_host": "https://us.cloud.langfuse.com", "langfuse_host": "https://us.cloud.langfuse.com",
}, },
} }
] ],
} }
), ),
) )
@ -1657,6 +1658,7 @@ async def test_add_callback_via_key_litellm_pre_call_utils(
team_id=None, team_id=None,
max_parallel_requests=None, max_parallel_requests=None,
metadata={ metadata={
"allow_client_mock_response": True,
"logging": [ "logging": [
{ {
"callback_name": "langfuse", "callback_name": "langfuse",
@ -1667,7 +1669,7 @@ async def test_add_callback_via_key_litellm_pre_call_utils(
"langfuse_host": "https://us.cloud.langfuse.com", "langfuse_host": "https://us.cloud.langfuse.com",
}, },
} }
] ],
}, },
tpm_limit=None, tpm_limit=None,
rpm_limit=None, rpm_limit=None,
@ -1813,6 +1815,7 @@ async def test_add_callback_via_key_litellm_pre_call_utils_gcs_bucket(
team_id=None, team_id=None,
max_parallel_requests=None, max_parallel_requests=None,
metadata={ metadata={
"allow_client_mock_response": True,
"logging": [ "logging": [
{ {
"callback_name": "gcs_bucket", "callback_name": "gcs_bucket",
@ -1822,7 +1825,7 @@ async def test_add_callback_via_key_litellm_pre_call_utils_gcs_bucket(
"gcs_path_service_account": "pathrise-convert-1606954137718-a956eef1a2a8.json", "gcs_path_service_account": "pathrise-convert-1606954137718-a956eef1a2a8.json",
}, },
} }
] ],
}, },
tpm_limit=None, tpm_limit=None,
rpm_limit=None, rpm_limit=None,
@ -1946,6 +1949,7 @@ async def test_add_callback_via_key_litellm_pre_call_utils_langsmith(
team_id=None, team_id=None,
max_parallel_requests=None, max_parallel_requests=None,
metadata={ metadata={
"allow_client_mock_response": True,
"logging": [ "logging": [
{ {
"callback_name": "langsmith", "callback_name": "langsmith",
@ -1956,7 +1960,7 @@ async def test_add_callback_via_key_litellm_pre_call_utils_langsmith(
"langsmith_base_url": "https://api.smith.langchain.com", "langsmith_base_url": "https://api.smith.langchain.com",
}, },
} }
] ],
}, },
tpm_limit=None, tpm_limit=None,
rpm_limit=None, rpm_limit=None,

View file

@ -68,13 +68,20 @@ class TestShouldRedactMessageLogging:
assert should_redact_message_logging(details) is True assert should_redact_message_logging(details) is True
def test_disable_redaction_via_header_proxy_flow(self): def test_disable_redaction_via_header_proxy_flow(self):
"""litellm-disable-message-redaction should suppress redaction """Global redaction should override litellm-disable-message-redaction."""
even when global setting is on, and litellm_metadata is None."""
litellm.turn_off_message_logging = True litellm.turn_off_message_logging = True
details = _make_model_call_details( details = _make_model_call_details(
metadata_headers={"litellm-disable-message-redaction": "true"}, metadata_headers={"litellm-disable-message-redaction": "true"},
litellm_metadata=None, litellm_metadata=None,
) )
assert should_redact_message_logging(details) is True
def test_disable_redaction_via_header_when_global_off(self):
"""litellm-disable-message-redaction is still honored when global redaction is off."""
details = _make_model_call_details(
metadata_headers={"litellm-disable-message-redaction": "true"},
litellm_metadata=None,
)
assert should_redact_message_logging(details) is False assert should_redact_message_logging(details) is False
# ---- SDK direct-call flow: headers in litellm_metadata ---- # ---- SDK direct-call flow: headers in litellm_metadata ----
@ -127,6 +134,16 @@ class TestShouldRedactMessageLogging:
) )
assert should_redact_message_logging(details) is False assert should_redact_message_logging(details) is False
def test_global_redaction_overrides_dynamic_param_false(self):
"""Global redaction cannot be disabled by a dynamic parameter."""
litellm.turn_off_message_logging = True
details = _make_model_call_details(
metadata_headers={},
litellm_metadata=None,
standard_callback_dynamic_params={"turn_off_message_logging": False},
)
assert should_redact_message_logging(details) is True
# ---- non-dict metadata safety ---- # ---- non-dict metadata safety ----
def test_both_metadata_fields_none(self): def test_both_metadata_fields_none(self):

View file

@ -1853,6 +1853,60 @@ async def test_make_bedrock_api_request_logging_event_type_for_spend_logs():
assert mock_log.call_args.kwargs["event_type"] == GuardrailEventHooks.pre_call assert mock_log.call_args.kwargs["event_type"] == GuardrailEventHooks.pre_call
@pytest.mark.asyncio
async def test_make_bedrock_api_request_filters_dynamic_evaluation_overrides():
guardrail = BedrockGuardrail(
guardrailIdentifier="test-guardrail", guardrailVersion="DRAFT"
)
mock_credentials = MagicMock()
mock_credentials.access_key = "test-access-key"
mock_credentials.secret_key = "test-secret-key"
mock_credentials.token = None
mock_bedrock_response = MagicMock()
mock_bedrock_response.status_code = 200
mock_bedrock_response.json.return_value = {"action": "NONE", "assessments": []}
prepared_request = MagicMock()
prepared_request.url = "https://bedrock.test/apply"
prepared_request.body = b"{}"
prepared_request.headers = {}
with (
patch.object(
guardrail.async_handler, "post", new_callable=AsyncMock
) as mock_post,
patch.object(
guardrail, "_load_credentials", return_value=(mock_credentials, "us-east-1")
),
patch.object(
guardrail, "_prepare_request", return_value=prepared_request
) as mock_prepare_request,
patch.object(
guardrail,
"get_guardrail_dynamic_request_body_params",
return_value={
"content": [{"text": {"text": "benign replacement"}}],
"source": "OUTPUT",
"outputScope": "FULL",
},
),
):
mock_post.return_value = mock_bedrock_response
await guardrail.make_bedrock_api_request(
source="INPUT",
messages=[{"role": "user", "content": "actual prompt"}],
request_data={"model": "gpt-4o"},
)
prepared_data = mock_prepare_request.call_args.kwargs["data"]
assert prepared_data["source"] == "INPUT"
assert "actual prompt" in json.dumps(prepared_data["content"])
assert "benign replacement" not in json.dumps(prepared_data["content"])
assert prepared_data["outputScope"] == "FULL"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_during_call_hook_invokes_bedrock_async_moderation_hook(): async def test_during_call_hook_invokes_bedrock_async_moderation_hook():
""" """

View file

@ -505,6 +505,38 @@ def test_get_logging_caching_headers_pillar_metadata():
) )
def test_get_logging_caching_headers_ignores_untrusted_pillar_headers():
request_data = {
"metadata": {
"pillar_response_headers": {
"set-cookie": "session=evil",
"x-pillar-flagged": "true",
},
"pillar_flagged": True,
}
}
headers = get_logging_caching_headers(request_data)
assert "set-cookie" not in headers
assert "x-pillar-flagged" not in headers
def test_get_logging_caching_headers_filters_non_pillar_headers():
request_data = {
"metadata": {
"pillar_flagged": True,
}
}
build_pillar_response_headers(request_data["metadata"])
request_data["metadata"]["pillar_response_headers"]["set-cookie"] = "session=evil"
headers = get_logging_caching_headers(request_data)
assert headers["x-pillar-flagged"] == "true"
assert "set-cookie" not in headers
def test_get_logging_caching_headers_truncates_large_evidence(): def test_get_logging_caching_headers_truncates_large_evidence():
long_text = "悪" * 6000 # multi-byte unicode to test URL encoding and truncation long_text = "悪" * 6000 # multi-byte unicode to test URL encoding and truncation
request_data = { request_data = {

View file

@ -512,6 +512,122 @@ async def test_add_litellm_data_to_request_strips_string_encoded_admin_injection
assert "_pipeline_managed_guardrails" not in other assert "_pipeline_managed_guardrails" not in other
@pytest.mark.asyncio
async def test_add_litellm_data_to_request_strips_user_control_fields():
"""Strip untrusted proxy-control fields before guardrails, logging, and headers read metadata."""
request_mock = MagicMock(spec=Request)
request_mock.url.path = "/v1/chat/completions"
request_mock.url = MagicMock()
request_mock.url.__str__.return_value = "http://localhost/v1/chat/completions"
request_mock.method = "POST"
request_mock.query_params = {}
request_mock.headers = {"Content-Type": "application/json"}
request_mock.client = MagicMock()
request_mock.client.host = "127.0.0.1"
malicious_metadata = {
"disable_global_guardrails": True,
"opted_out_global_guardrails": ["pii"],
"pillar_response_headers": {"set-cookie": "session=evil"},
"_pillar_response_headers_trusted": True,
"pillar_flagged": True,
"pillar_scanners": {"jailbreak": True},
"pillar_evidence": [{"evidence": "spoofed"}],
"pillar_session_id_response": "spoofed-session",
"applied_guardrails": ["spoofed"],
"applied_policies": ["spoofed-policy"],
"policy_sources": {"spoofed-policy": "request"},
"_guardrail_pipelines": [{"name": "spoofed"}],
"_pipeline_managed_guardrails": ["evaded"],
"safe_user_metadata": "kept",
}
data = {
"model": "gpt-3.5-turbo",
"messages": [{"role": "user", "content": "hello"}],
"mock_response": "free response",
"mock_tool_calls": [{"id": "call_1"}],
"disable_global_guardrails": True,
"metadata": copy.deepcopy(malicious_metadata),
"litellm_metadata": copy.deepcopy(malicious_metadata),
}
updated = await add_litellm_data_to_request(
data=data,
request=request_mock,
user_api_key_dict=UserAPIKeyAuth(api_key="hashed-key"),
proxy_config=MagicMock(),
general_settings={},
version="test-version",
)
assert "mock_response" not in updated
assert "mock_tool_calls" not in updated
assert "disable_global_guardrails" not in updated
stripped_keys = {
"disable_global_guardrails",
"opted_out_global_guardrails",
"pillar_response_headers",
"_pillar_response_headers_trusted",
"pillar_flagged",
"pillar_scanners",
"pillar_evidence",
"pillar_session_id_response",
"applied_guardrails",
"applied_policies",
"policy_sources",
"_guardrail_pipelines",
"_pipeline_managed_guardrails",
}
for metadata_key in ("metadata", "litellm_metadata"):
cleaned_metadata = updated.get(metadata_key) or {}
for stripped_key in stripped_keys:
assert stripped_key not in cleaned_metadata
assert cleaned_metadata.get("safe_user_metadata") == "kept"
requester_metadata = updated["metadata"]["requester_metadata"]
for stripped_key in stripped_keys:
assert stripped_key not in requester_metadata
snapshot_body = updated["proxy_server_request"]["body"]
assert "mock_response" not in snapshot_body
assert "mock_tool_calls" not in snapshot_body
assert "pillar_response_headers" not in snapshot_body["metadata"]
@pytest.mark.asyncio
async def test_add_litellm_data_to_request_allows_client_mock_response_with_admin_opt_in():
request_mock = MagicMock(spec=Request)
request_mock.url.path = "/v1/chat/completions"
request_mock.url = MagicMock()
request_mock.url.__str__.return_value = "http://localhost/v1/chat/completions"
request_mock.method = "POST"
request_mock.query_params = {}
request_mock.headers = {"Content-Type": "application/json"}
request_mock.client = MagicMock()
request_mock.client.host = "127.0.0.1"
updated = await add_litellm_data_to_request(
data={
"model": "gpt-3.5-turbo",
"messages": [{"role": "user", "content": "hello"}],
"mock_response": "allowed mock",
"mock_tool_calls": [{"id": "call_1"}],
},
request=request_mock,
user_api_key_dict=UserAPIKeyAuth(
api_key="hashed-key",
metadata={"allow_client_mock_response": True},
),
proxy_config=MagicMock(),
general_settings={},
version="test-version",
)
assert updated["mock_response"] == "allowed mock"
assert updated["mock_tool_calls"] == [{"id": "call_1"}]
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_add_litellm_data_to_request_ignores_x_litellm_tags_header_without_permission(): async def test_add_litellm_data_to_request_ignores_x_litellm_tags_header_without_permission():
"""Regression: the `x-litellm-tags` header bypassed the body-metadata """Regression: the `x-litellm-tags` header bypassed the body-metadata
@ -3326,7 +3442,9 @@ async def test_team_guardrail_merges_with_global_policy():
policy_registry = get_policy_registry() policy_registry = get_policy_registry()
policy_registry._policies = { policy_registry._policies = {
"global-policy": Policy( "global-policy": Policy(
guardrails=PolicyGuardrails(add=["policy-guardrail-1", "policy-guardrail-2"]), guardrails=PolicyGuardrails(
add=["policy-guardrail-1", "policy-guardrail-2"]
),
), ),
} }
policy_registry._initialized = True policy_registry._initialized = True
@ -3347,14 +3465,18 @@ async def test_team_guardrail_merges_with_global_policy():
guardrails = data["metadata"].get("guardrails", []) guardrails = data["metadata"].get("guardrails", [])
assert "team-direct-guardrail" in guardrails, \ assert (
f"Team guardrail missing from merged list: {guardrails}" "team-direct-guardrail" in guardrails
assert "policy-guardrail-1" in guardrails, \ ), f"Team guardrail missing from merged list: {guardrails}"
f"policy-guardrail-1 missing: {guardrails}" assert (
assert "policy-guardrail-2" in guardrails, \ "policy-guardrail-1" in guardrails
f"policy-guardrail-2 missing: {guardrails}" ), f"policy-guardrail-1 missing: {guardrails}"
assert len(guardrails) == len(set(guardrails)), \ assert (
f"Duplicates in guardrails list: {guardrails}" "policy-guardrail-2" in guardrails
), f"policy-guardrail-2 missing: {guardrails}"
assert len(guardrails) == len(
set(guardrails)
), f"Duplicates in guardrails list: {guardrails}"
# Verify get_guardrail_from_metadata returns the merged list even # Verify get_guardrail_from_metadata returns the merged list even
# when litellm_metadata is present (the bug: it returned [] before fix) # when litellm_metadata is present (the bug: it returned [] before fix)
@ -3365,9 +3487,9 @@ async def test_team_guardrail_merges_with_global_policy():
dummy = _DummyGuardrail(guardrail_name="team-direct-guardrail") dummy = _DummyGuardrail(guardrail_name="team-direct-guardrail")
returned = dummy.get_guardrail_from_metadata(data) returned = dummy.get_guardrail_from_metadata(data)
assert "team-direct-guardrail" in returned, ( assert (
f"get_guardrail_from_metadata shadowed by litellm_metadata; got: {returned}" "team-direct-guardrail" in returned
) ), f"get_guardrail_from_metadata shadowed by litellm_metadata; got: {returned}"
finally: finally:
policy_registry._policies = {} policy_registry._policies = {}
@ -3396,9 +3518,10 @@ async def test_get_guardrail_from_metadata_prefers_metadata_over_litellm_metadat
} }
result = dummy.get_guardrail_from_metadata(data) result = dummy.get_guardrail_from_metadata(data)
assert result == ["my-guardrail", "other-guardrail"], ( assert result == [
f"Expected guardrails from metadata, got: {result}" "my-guardrail",
) "other-guardrail",
], f"Expected guardrails from metadata, got: {result}"
def test_get_guardrail_from_metadata_reads_litellm_metadata_when_no_metadata(): def test_get_guardrail_from_metadata_reads_litellm_metadata_when_no_metadata():
@ -3419,6 +3542,6 @@ def test_get_guardrail_from_metadata_reads_litellm_metadata_when_no_metadata():
} }
result = dummy.get_guardrail_from_metadata(data) result = dummy.get_guardrail_from_metadata(data)
assert result == ["my-guardrail"], ( assert result == [
f"Expected guardrails from litellm_metadata fallback, got: {result}" "my-guardrail"
) ], f"Expected guardrails from litellm_metadata fallback, got: {result}"