Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions litellm-proxy-extras/litellm_proxy_extras/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -914,6 +914,7 @@ model LiteLLM_PolicyAttachmentTable {
teams String[] @default([]) // Team aliases or patterns
keys String[] @default([]) // Key aliases or patterns
models String[] @default([]) // Model names or patterns
tags String[] @default([]) // Tag patterns (e.g., ["healthcare", "prod-*"])
created_at DateTime @default(now())
created_by String?
updated_at DateTime @default(now()) @updatedAt
Expand Down
3 changes: 3 additions & 0 deletions litellm/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -1298,6 +1298,9 @@
os.getenv("DEFAULT_SLACK_ALERTING_THRESHOLD", 300)
)
MAX_TEAM_LIST_LIMIT = int(os.getenv("MAX_TEAM_LIST_LIMIT", 20))
MAX_POLICY_ESTIMATE_IMPACT_ROWS = int(
os.getenv("MAX_POLICY_ESTIMATE_IMPACT_ROWS", 1000)
)
DEFAULT_PROMPT_INJECTION_SIMILARITY_THRESHOLD = float(
os.getenv("DEFAULT_PROMPT_INJECTION_SIMILARITY_THRESHOLD", 0.7)
)
Expand Down
29 changes: 29 additions & 0 deletions litellm/proxy/common_utils/callback_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -394,6 +394,14 @@ def get_logging_caching_headers(request_data: Dict) -> Optional[Dict]:
_metadata["applied_policies"]
)

if "policy_sources" in _metadata:
sources = _metadata["policy_sources"]
if isinstance(sources, dict) and sources:
# Use ';' as delimiter — matched_via reasons may contain commas
headers["x-litellm-policy-sources"] = "; ".join(
f"{name}={reason}" for name, reason in sources.items()
)

if "semantic-similarity" in _metadata:
headers["x-litellm-semantic-similarity"] = str(_metadata["semantic-similarity"])

Expand Down Expand Up @@ -441,6 +449,27 @@ def add_policy_to_applied_policies_header(
request_data["metadata"] = _metadata


def add_policy_sources_to_metadata(
request_data: Dict, policy_sources: Dict[str, str]
):
"""
Store policy match reasons in metadata for x-litellm-policy-sources header.

Args:
request_data: The request data dict
policy_sources: Map of policy_name -> matched_via reason
"""
if not policy_sources:
return
_metadata = request_data.get("metadata", None) or {}
existing = _metadata.get("policy_sources", {})
if not isinstance(existing, dict):
existing = {}
existing.update(policy_sources)
_metadata["policy_sources"] = existing
request_data["metadata"] = _metadata


def add_guardrail_response_to_standard_logging_object(
litellm_logging_obj: Optional["LiteLLMLogging"],
guardrail_response: StandardLoggingGuardrailInformation,
Expand Down
36 changes: 32 additions & 4 deletions litellm/proxy/litellm_pre_call_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -1539,8 +1539,15 @@ def add_guardrails_from_policy_engine(
"""
from litellm._logging import verbose_proxy_logger
from litellm.proxy.common_utils.callback_utils import (
add_policy_sources_to_metadata,
add_policy_to_applied_policies_header,
)
from litellm.proxy.common_utils.http_parsing_utils import (
get_tags_from_request_body,
)
from litellm.proxy.policy_engine.attachment_registry import (
get_attachment_registry,
)
from litellm.proxy.policy_engine.policy_matcher import PolicyMatcher
from litellm.proxy.policy_engine.policy_registry import get_policy_registry
from litellm.proxy.policy_engine.policy_resolver import PolicyResolver
Expand All @@ -1561,20 +1568,31 @@ def add_guardrails_from_policy_engine(
)
return

# Build context from request
# Extract tags using the shared helper (handles metadata / litellm_metadata,
# top-level tags, deduplication, and type filtering).

all_tags = get_tags_from_request_body(data) or None

context = PolicyMatchContext(
team_alias=user_api_key_dict.team_alias,
key_alias=user_api_key_dict.key_alias,
model=data.get("model"),
tags=all_tags,
)

verbose_proxy_logger.debug(
f"Policy engine: matching policies for context team_alias={context.team_alias}, "
f"key_alias={context.key_alias}, model={context.model}"
f"key_alias={context.key_alias}, model={context.model}, tags={context.tags}"
)

# Get matching policies via attachments
matching_policy_names = PolicyMatcher.get_matching_policies(context=context)
# Get matching policies via attachments (with match reasons for attribution)
attachment_registry = get_attachment_registry()
matches_with_reasons = attachment_registry.get_attached_policies_with_reasons(
context
)
matching_policy_names = [m["policy_name"] for m in matches_with_reasons]
# Build reasons map: {"hipaa-policy": "tag:healthcare", ...}
policy_reasons = {m["policy_name"]: m["matched_via"] for m in matches_with_reasons}

verbose_proxy_logger.debug(
f"Policy engine: matched policies via attachments: {matching_policy_names}"
Expand Down Expand Up @@ -1607,6 +1625,16 @@ def add_guardrails_from_policy_engine(
request_data=data, policy_name=policy_name
)

# Track policy attribution sources for x-litellm-policy-sources header
applied_reasons = {
name: policy_reasons[name]
for name in applied_policy_names
if name in policy_reasons
}
add_policy_sources_to_metadata(
request_data=data, policy_sources=applied_reasons
)

# Resolve guardrails from matching policies
resolved_guardrails = PolicyResolver.resolve_guardrails_for_context(context=context)

Expand Down
62 changes: 58 additions & 4 deletions litellm/proxy/policy_engine/attachment_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ def _parse_attachment(self, attachment_data: Dict[str, Any]) -> PolicyAttachment
teams=attachment_data.get("teams"),
keys=attachment_data.get("keys"),
models=attachment_data.get("models"),
tags=attachment_data.get("tags"),
)

def get_attached_policies(self, context: PolicyMatchContext) -> List[str]:
Expand All @@ -96,21 +97,68 @@ def get_attached_policies(self, context: PolicyMatchContext) -> List[str]:
Returns:
List of policy names that are attached to matching scopes
"""
return [r["policy_name"] for r in self.get_attached_policies_with_reasons(context)]

def get_attached_policies_with_reasons(
self, context: PolicyMatchContext
) -> List[Dict[str, Any]]:
"""
Get list of policy names and match reasons for the given context.

Returns a list of dicts with 'policy_name' and 'matched_via' keys.
The 'matched_via' describes which dimension caused the match.
"""
from litellm.proxy.policy_engine.policy_matcher import PolicyMatcher

attached_policies: List[str] = []
results: List[Dict[str, Any]] = []
seen_policies: set = set()

for attachment in self._attachments:
scope = attachment.to_policy_scope()
if PolicyMatcher.scope_matches(scope=scope, context=context):
if attachment.policy not in attached_policies:
attached_policies.append(attachment.policy)
if attachment.policy not in seen_policies:
seen_policies.add(attachment.policy)
matched_via = self._describe_match_reason(attachment, context)
results.append(
{
"policy_name": attachment.policy,
"matched_via": matched_via,
}
)
verbose_proxy_logger.debug(
f"Attachment matched: policy={attachment.policy}, "
f"matched_via={matched_via}, "
f"context=(team={context.team_alias}, key={context.key_alias}, model={context.model})"
)

return attached_policies
return results

@staticmethod
def _describe_match_reason(
attachment: PolicyAttachment, context: PolicyMatchContext
) -> str:
"""Describe why an attachment matched the context."""
from litellm.proxy.policy_engine.policy_matcher import PolicyMatcher

if attachment.is_global():
return "scope:*"

reasons = []
if attachment.tags and context.tags:
matching_tags = [
t for t in context.tags
if PolicyMatcher.matches_pattern(t, attachment.tags)
]
if matching_tags:
reasons.append(f"tag:{matching_tags[0]}")
if attachment.teams and context.team_alias:
reasons.append(f"team:{context.team_alias}")
if attachment.keys and context.key_alias:
reasons.append(f"key:{context.key_alias}")
if attachment.models and context.model:
reasons.append(f"model:{context.model}")

return "+".join(reasons) if reasons else "scope:default"

def is_policy_attached(
self, policy_name: str, context: PolicyMatchContext
Expand Down Expand Up @@ -238,6 +286,7 @@ async def add_attachment_to_db(
"teams": attachment_request.teams or [],
"keys": attachment_request.keys or [],
"models": attachment_request.models or [],
"tags": attachment_request.tags or [],
"created_at": datetime.now(timezone.utc),
"updated_at": datetime.now(timezone.utc),
"created_by": created_by,
Expand All @@ -253,6 +302,7 @@ async def add_attachment_to_db(
teams=attachment_request.teams,
keys=attachment_request.keys,
models=attachment_request.models,
tags=attachment_request.tags,
)
self.add_attachment(attachment)

Expand All @@ -263,6 +313,7 @@ async def add_attachment_to_db(
teams=created_attachment.teams or [],
keys=created_attachment.keys or [],
models=created_attachment.models or [],
tags=created_attachment.tags or [],
created_at=created_attachment.created_at,
updated_at=created_attachment.updated_at,
created_by=created_attachment.created_by,
Expand Down Expand Up @@ -344,6 +395,7 @@ async def get_attachment_by_id_from_db(
teams=attachment.teams or [],
keys=attachment.keys or [],
models=attachment.models or [],
tags=attachment.tags or [],
created_at=attachment.created_at,
updated_at=attachment.updated_at,
created_by=attachment.created_by,
Expand Down Expand Up @@ -381,6 +433,7 @@ async def get_all_attachments_from_db(
teams=a.teams or [],
keys=a.keys or [],
models=a.models or [],
tags=a.tags or [],
created_at=a.created_at,
updated_at=a.updated_at,
created_by=a.created_by,
Expand Down Expand Up @@ -415,6 +468,7 @@ async def sync_attachments_from_db(
teams=attachment_response.teams if attachment_response.teams else None,
keys=attachment_response.keys if attachment_response.keys else None,
models=attachment_response.models if attachment_response.models else None,
tags=attachment_response.tags if attachment_response.tags else None,
)
self._attachments.append(attachment)

Expand Down
Loading
Loading