From 5efa1c25da97270d3bf06d6b29e5096a6a0f96f9 Mon Sep 17 00:00:00 2001 From: Shubham-Kothari Date: Wed, 2 Sep 2026 12:57:11 -0400 Subject: [PATCH] feat(guardrails): add PointGuard AI integration Add PointGuard AI as a native LiteLLM guardrail provider with pre-call prompt inspection and post-call response inspection. Scope request scans to the current conversation turn, optionally include system and developer context, support tool-result-only scanning, and avoid resending prior conversation history. Handle policy blocks through GuardrailRaisedException, apply validated input and output redactions, preserve streaming response behavior, and route provider outages through configurable fail-open or fail-closed handling. Register the provider configuration, event hooks, generated schemas, Guardrail Garden metadata, bundled logo, and focused backend and dashboard coverage. --- litellm/proxy/_lazy_openapi_snapshot.json | 24 + .../guardrail_hooks/pointguardai/__init__.py | 86 + .../pointguardai/pointguardai.py | 1154 ++++++++ litellm/types/guardrails.py | 5 + .../guardrail_hooks/pointguardai.py | 37 + .../guardrail_hooks/test_pointguardai.py | 2448 +++++++++++++++++ .../public/assets/logos/pointguardai.png | Bin 0 -> 13613 bytes .../_components/guardrail_garden_configs.ts | 6 + .../_components/guardrail_garden_data.test.ts | 1 + .../_components/guardrail_garden_data.ts | 10 + .../_components/guardrail_info_helpers.tsx | 3 + ui/litellm-dashboard/src/lib/http/schema.d.ts | 10 + 12 files changed, 3784 insertions(+) create mode 100644 litellm/proxy/guardrails/guardrail_hooks/pointguardai/__init__.py create mode 100644 litellm/proxy/guardrails/guardrail_hooks/pointguardai/pointguardai.py create mode 100644 litellm/types/proxy/guardrails/guardrail_hooks/pointguardai.py create mode 100644 tests/test_litellm/proxy/guardrails/guardrail_hooks/test_pointguardai.py create mode 100644 ui/litellm-dashboard/public/assets/logos/pointguardai.png diff --git a/litellm/proxy/_lazy_openapi_snapshot.json b/litellm/proxy/_lazy_openapi_snapshot.json index 13c7a4c7cfa..f04d74c2642 100644 --- a/litellm/proxy/_lazy_openapi_snapshot.json +++ b/litellm/proxy/_lazy_openapi_snapshot.json @@ -11850,6 +11850,18 @@ ], "description": "Optional parameters for the guardrail" }, + "org_code": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Organization code for PointGuardAI.", + "title": "Org Code" + }, "output_parse_pii": { "anyOf": [ { @@ -11981,6 +11993,18 @@ "description": "Configuration for PII entity types and actions", "title": "Pii Entities Config" }, + "policy_config_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "PointGuardAI policy configuration name.", + "title": "Policy Config Name" + }, "policy_id": { "anyOf": [ { diff --git a/litellm/proxy/guardrails/guardrail_hooks/pointguardai/__init__.py b/litellm/proxy/guardrails/guardrail_hooks/pointguardai/__init__.py new file mode 100644 index 00000000000..a67d1c2ac0c --- /dev/null +++ b/litellm/proxy/guardrails/guardrail_hooks/pointguardai/__init__.py @@ -0,0 +1,86 @@ +from typing import TYPE_CHECKING, Final, Protocol + +from litellm.secret_managers.main import get_secret_str +from litellm.types.guardrails import ( + GuardrailEventHooks, + Mode, + SupportedGuardrailIntegrations, +) + +from .pointguardai import PointGuardAIGuardrail + +if TYPE_CHECKING: + from litellm.types.guardrails import Guardrail, LitellmParams + + +class _CallbackRegistrar(Protocol): + def add_litellm_callback(self, callback: PointGuardAIGuardrail) -> None: ... + + +def _resolve_secret_reference(value: str | None) -> str | None: + if value is not None and value.startswith("os.environ/"): + return get_secret_str(value) + return value + + +def _coerce_event_hook( + mode: str | list[str] | Mode, # mutable-ok: mirrors the LiteLLM mode configuration contract +) -> GuardrailEventHooks | list[GuardrailEventHooks] | Mode: # mutable-ok: inherited hook API requires a list + if isinstance(mode, Mode): + return mode + if isinstance(mode, list): + return [GuardrailEventHooks(item) for item in mode] + return GuardrailEventHooks(mode) + + +def initialize_guardrail( + litellm_params: "LitellmParams", + guardrail: "Guardrail", + callback_manager: _CallbackRegistrar | None = None, +) -> PointGuardAIGuardrail: + import litellm + + configured_fields_value: Final = getattr(litellm_params, "model_fields_set", None) + configured_fields: Final = ( + configured_fields_value + if configured_fields_value is not None + else getattr(litellm_params, "__fields_set__", frozenset()) + ) + unreachable_fallback: Final = ( + litellm_params.unreachable_fallback if "unreachable_fallback" in configured_fields else "fail_closed" + ) + + pointguardai_guardrail: Final = PointGuardAIGuardrail( + guardrail_name=guardrail.get("guardrail_name"), + api_key=litellm_params.api_key, + api_base=litellm_params.api_base, + org_code=_resolve_secret_reference(litellm_params.org_code), + policy_config_name=_resolve_secret_reference(litellm_params.policy_config_name), + unreachable_fallback=unreachable_fallback, + default_on=litellm_params.default_on or False, + event_hook=_coerce_event_hook(litellm_params.mode), + ) + + if callback_manager is None: + litellm.logging_callback_manager.add_litellm_callback(pointguardai_guardrail) + else: + callback_manager.add_litellm_callback(pointguardai_guardrail) + return pointguardai_guardrail + + +guardrail_initializer_registry: Final = { # mutable-ok: LiteLLM registry contract requires a dictionary + SupportedGuardrailIntegrations.POINTGUARDAI.value: initialize_guardrail, +} + + +guardrail_class_registry: Final = { # mutable-ok: LiteLLM registry contract requires a dictionary + SupportedGuardrailIntegrations.POINTGUARDAI.value: PointGuardAIGuardrail, +} + + +__all__: Final = ( + "PointGuardAIGuardrail", + "guardrail_class_registry", + "guardrail_initializer_registry", + "initialize_guardrail", +) diff --git a/litellm/proxy/guardrails/guardrail_hooks/pointguardai/pointguardai.py b/litellm/proxy/guardrails/guardrail_hooks/pointguardai/pointguardai.py new file mode 100644 index 00000000000..87bce2a96e9 --- /dev/null +++ b/litellm/proxy/guardrails/guardrail_hooks/pointguardai/pointguardai.py @@ -0,0 +1,1154 @@ +import json +from typing import TYPE_CHECKING, Final, Literal, NoReturn + +import httpx +from fastapi import HTTPException + +from litellm._logging import verbose_proxy_logger +from litellm.exceptions import GuardrailRaisedException +from litellm.integrations.custom_guardrail import ( + CustomGuardrail, + log_guardrail_information, +) +from litellm.llms.base_llm.guardrail_translation.utils import ( + effective_scan_only_tool_results_for_guardrail, + effective_skip_system_message_for_guardrail, + effective_skip_tool_message_for_guardrail, + role_out_of_guardrail_scope, +) +from litellm.llms.custom_httpx.http_handler import ( + AsyncHTTPHandler, + get_async_httpx_client, + httpxSpecialProvider, +) +from litellm.types.guardrails import GuardrailEventHooks, Mode +from litellm.types.utils import GenericGuardrailAPIInputs, OpenAIObject + +if TYPE_CHECKING: + from litellm.litellm_core_utils.litellm_logging import ( + Logging as LiteLLMLoggingObj, + ) + from litellm.types.proxy.guardrails.guardrail_hooks.base import GuardrailConfigModel + +GUARDRAIL_NAME: Final = "POINTGUARDAI" +DEFAULT_POINTGUARDAI_API_BASE: Final = "https://api.appsoc.com" + + +class _PointGuardAIUnavailableError(HTTPException): + def __init__(self, status_code: int, detail: str) -> None: + super().__init__(status_code=status_code, detail=detail) + + +class PointGuardAIGuardrail(CustomGuardrail): + @classmethod + def get_supported_event_hooks(cls) -> list[GuardrailEventHooks]: # mutable-ok: LiteLLM hook API requires a list + return [ # mutable-ok: LiteLLM hook API requires a list + GuardrailEventHooks.pre_call, + GuardrailEventHooks.post_call, + ] + + def __init__( + self, + api_base: str | None = None, + api_key: str | None = None, + org_code: str | None = None, + policy_config_name: str | None = None, + unreachable_fallback: Literal["fail_closed", "fail_open"] = "fail_closed", + guardrail_name: str | None = None, + event_hook: GuardrailEventHooks # mutable-ok: inherited LiteLLM hook contract + | list[GuardrailEventHooks] # mutable-ok: inherited LiteLLM hook contract + | Mode + | None = None, # mutable-ok: inherited LiteLLM hook contract + default_on: bool = False, + async_handler: AsyncHTTPHandler | None = None, + **kwargs: object, # kwargs-ok: forwarded to the inherited LiteLLM guardrail constructor + ) -> None: + self.pointguardai_api_base = api_base or DEFAULT_POINTGUARDAI_API_BASE + self.pointguardai_org_code = org_code or "" + self.pointguardai_policy_config_name = policy_config_name or "" + self.pointguardai_api_key = api_key or "" + self.unreachable_fallback: Literal["fail_closed", "fail_open"] = unreachable_fallback + self.streaming_transform_mode: Literal["block_only", "incremental_diff"] = "incremental_diff" + self.streaming_end_of_stream_only: bool = True + + # Validate required parameters + if not self.pointguardai_api_key: + raise HTTPException(status_code=401, detail="Missing required parameter: api_key") + if not self.pointguardai_org_code: + raise HTTPException(status_code=401, detail="Missing required parameter: org_code") + if not self.pointguardai_policy_config_name: + raise HTTPException(status_code=401, detail="Missing required parameter: policy_config_name") + + supported_event_hooks: Final = self.get_supported_event_hooks() + self._validate_event_hook(event_hook, supported_event_hooks) + + self.async_handler = ( + async_handler + if async_handler is not None + else get_async_httpx_client(llm_provider=httpxSpecialProvider.GuardrailCallback) + ) + + # Construct API endpoints + base_url: Final = self.pointguardai_api_base.rstrip("/") + self.input_endpoint = f"{base_url}/aisec-rdc-v2/api/v1/orgs/{self.pointguardai_org_code}/inspect/input" + self.output_endpoint = f"{base_url}/aisec-rdc-v2/api/v1/orgs/{self.pointguardai_org_code}/inspect/output" + + self.headers = { # mutable-ok: HTTP client headers contract requires a dictionary + "X-appsoc-api-key": self.pointguardai_api_key, + "Content-Type": "application/json", + } + + # store kwargs as optional_params + self.optional_params = kwargs + + verbose_proxy_logger.debug( + "PointGuardAI configured: api_base_present=%s org_code_present=%s policy_present=%s api_key_present=%s", + bool(self.pointguardai_api_base), + bool(self.pointguardai_org_code), + bool(self.pointguardai_policy_config_name), + bool(self.pointguardai_api_key), + ) + + kwargs.setdefault("supported_event_hooks", supported_event_hooks) + super().__init__( + guardrail_name=guardrail_name or GUARDRAIL_NAME, + event_hook=event_hook, + default_on=default_on, + **kwargs, # pyright: ignore[reportArgumentType] # inherited constructor accepts provider options dynamically + ) + + @staticmethod + def _extract_text_content(content: object) -> str: + """Convert supported message text content to PointGuard's string format.""" + if isinstance(content, str): + return content + if not isinstance(content, list): + return "" + + text_parts: Final[list[str]] = [] # mutable-ok: local text accumulator joined before return + for content_item in content: + if isinstance(content_item, str): + text_parts.append(content_item) + elif isinstance(content_item, dict): + text = content_item.get("text") + if isinstance(text, str): + text_parts.append(text) + return "\n".join(text_parts) + + def transform_messages( + self, + messages: list[dict], # mutable-ok: LiteLLM and PointGuard exchange JSON message arrays + ) -> list[dict]: # mutable-ok: LiteLLM and PointGuard exchange JSON message arrays + """Transform messages to PointGuard's text-only message format.""" + new_messages: Final[list[dict]] = [] # mutable-ok: outbound JSON message accumulator + for m in messages: + role = m.get("role") + new_messages.append( + { # mutable-ok: outbound JSON message object + "role": role if isinstance(role, str) and role else "user", + "content": self._extract_text_content(m.get("content")), + } + ) + return new_messages + + @staticmethod + def _replace_text_in_message_content( + message: dict, # mutable-ok: LiteLLM message payload is rewritten after redaction + original: str, + replacement: str, + ) -> bool: # mutable-ok: LiteLLM message payload is rewritten after redaction + """Replace text in string or multimodal text blocks, leaving other blocks intact.""" + content: Final = message.get("content") + if isinstance(content, str): + if original not in content: + return False + redacted_content: Final = content.replace(original, replacement) + message["content"] = redacted_content # rebind-ok: apply PointGuard redaction + return True + + if not isinstance(content, list): + return False + + modified = False # rebind-ok: tracks whether any multimodal text block was redacted + for index, content_item in enumerate(content): + if isinstance(content_item, str): + if original in content_item: + content[index] = content_item.replace(original, replacement) + modified = True + continue + if not isinstance(content_item, dict): + continue + text = content_item.get("text") + if isinstance(text, str) and original in text: + content_item["text"] = text.replace(original, replacement) + modified = True + return modified + + @staticmethod + def _serialize_tool_payload(payload: object) -> str | None: + normalized_payload: Final[object] = ( + payload.model_dump(exclude_none=True) if isinstance(payload, OpenAIObject) else payload + ) + if not isinstance(normalized_payload, dict): + return None + return json.dumps(normalized_payload, sort_keys=True, separators=(",", ":")) + + @staticmethod + def _deserialize_tool_payload(content: str) -> dict | None: # mutable-ok: LiteLLM tool payloads are JSON objects + try: + payload: Final = json.loads(content) + except json.JSONDecodeError: + return None + return payload if isinstance(payload, dict) else None + + @staticmethod + def _replace_text_in_all_entries( + texts: list[str], # mutable-ok: LiteLLM hook payload is rewritten after redaction + original: str, + replacement: str, + ) -> bool: + modified = False # rebind-ok: tracks whether any duplicate text entry was redacted + for index, text in enumerate(texts): + if original not in text: + continue + texts[index] = text.replace( # rebind-ok: applies provider redaction to the mutable hook payload + original, replacement + ) + modified = True + return modified + + @classmethod + def _resolve_tool_call_modification( + cls, + response_index: int, + text_output_count: int, + serialized_tool_calls: tuple[tuple[int, str], ...], + original: str, + replacement: str, + ) -> tuple[int, dict] | None: # mutable-ok: LiteLLM tool calls are JSON objects + tool_call_position: Final = response_index - text_output_count + if not 0 <= tool_call_position < len(serialized_tool_calls): + return None + tool_call_index, serialized_tool_call = serialized_tool_calls[tool_call_position] + if serialized_tool_call != original: + return None + replacement_tool_call: Final = cls._deserialize_tool_payload(replacement) + if replacement_tool_call is None: + return None + return tool_call_index, replacement_tool_call + + async def prepare_pointguard_ai_runtime_scanner_request( + self, + new_messages: list[dict], # mutable-ok: outbound PointGuard JSON message array + response_string: str | None = None, + response_strings: list[str] | None = None, # mutable-ok: LiteLLM output hook supplies a text list + ) -> dict[str, object] | None: # mutable-ok: HTTP client requires a JSON dictionary + """Prepare the request data for PointGuardAI API""" + try: + # Validate required parameters + if not hasattr(self, "pointguardai_policy_config_name") or not self.pointguardai_policy_config_name: + raise HTTPException( + status_code=500, + detail="PointGuardAI policy configuration is unavailable", + ) + + data: Final[dict[str, object]] = { # mutable-ok: outbound JSON body assembled by inspection mode + "policyName": self.pointguardai_policy_config_name, + } + + output_strings = response_strings # rebind-ok: normalizes singular and plural output inputs + if output_strings is None and response_string is not None: + output_strings = [ # mutable-ok: PointGuard output schema requires an array # rebind-ok: normalizes a singular output + response_string + ] # mutable-ok: PointGuard output schema requires an array # rebind-ok: normalizes a singular output + + if not new_messages and not output_strings: + verbose_proxy_logger.warning("PointGuardAI: No input messages or response string provided") + return None + + # Output endpoint requires BOTH input and output fields + # Input endpoint requires only input field + if output_strings is not None: + # Output endpoint - include both fields (input can be empty array) + data["input"] = ( + new_messages if new_messages else [] # mutable-ok: PointGuard input schema requires an array + ) # mutable-ok: PointGuard input schema requires an array + data["output"] = [ # mutable-ok: PointGuard output schema requires a JSON array + { # mutable-ok: PointGuard output schema requires a JSON object + "role": "assistant", + "content": text, + } # mutable-ok: PointGuard output schema requires a JSON object + for text in output_strings # mutable-ok: PointGuard output schema requires a JSON array + ] + else: + # Input endpoint - include only input field + data["input"] = new_messages + + verbose_proxy_logger.debug( + "PointGuardAI request prepared: input_messages=%d output_present=%s", + len(new_messages), + output_strings is not None, + ) + return data + + except Exception as e: + verbose_proxy_logger.error("Error preparing PointGuardAI request: %s", str(e)) + raise + + def _check_sections_present( + self, + response_data: dict, # mutable-ok: decoded PointGuard JSON response + new_messages: list[dict], # mutable-ok: outbound PointGuard JSON message array + response_string: str | None, + response_strings: list[str] | None = None, # mutable-ok: LiteLLM output hook supplies a text list + ) -> tuple[bool, bool]: + """Check if input or output sections are present in response""" + input_section_present: Final = bool(new_messages and response_data.get("input")) + + output_section_present: Final = bool((response_strings or response_string) and response_data.get("output")) + + return input_section_present, output_section_present + + @staticmethod + def _validate_response_data(response_data: object, output_present: bool) -> None: + if not isinstance(response_data, dict): + raise HTTPException(status_code=502, detail="Invalid response from PointGuardAI") + + policy_name: Final = response_data.get("policyName") + if not isinstance(policy_name, str) or not policy_name: + raise HTTPException(status_code=502, detail="Invalid PointGuardAI response: missing policyName") + + required_sections: Final = ("input", "output") if output_present else ("input",) + for section_name in required_sections: + section = response_data.get( + section_name + ) # rebind-ok: each loop iteration validates a different response section + if ( + not isinstance(section, dict) + or not isinstance(section.get("blocked"), bool) + or not isinstance(section.get("modified"), bool) + or not isinstance(section.get("content"), list) + ): + raise HTTPException( + status_code=502, + detail=f"Invalid PointGuardAI response: missing or malformed {section_name} inspection result", + ) + + def _extract_status_flags( + self, + response_data: dict, # mutable-ok: decoded PointGuard JSON response + input_section_present: bool, + output_section_present: bool, + ) -> tuple[bool, bool, bool, bool]: + """Extract blocking and modification flags from response""" + input_blocked: Final = ( + response_data.get("input", {}).get( # mutable-ok: read-only fallback for absent JSON section + "blocked", False + ) # mutable-ok: read-only fallback for absent JSON section + if input_section_present + else False + ) + output_blocked: Final = ( + response_data.get("output", {}).get( # mutable-ok: read-only fallback for absent JSON section + "blocked", False + ) # mutable-ok: read-only fallback for absent JSON section + if output_section_present + else False + ) + input_modified: Final = ( + response_data.get("input", {}).get( # mutable-ok: read-only fallback for absent JSON section + "modified", False + ) # mutable-ok: read-only fallback for absent JSON section + if input_section_present + else False + ) + output_modified: Final = ( + response_data.get("output", {}).get( # mutable-ok: read-only fallback for absent JSON section + "modified", False + ) # mutable-ok: read-only fallback for absent JSON section + if output_section_present + else False + ) + + return input_blocked, output_blocked, input_modified, output_modified + + def _extract_violations( + self, + response_data: dict, # mutable-ok: decoded provider JSON is normalized for LiteLLM error details + input_blocked: bool, + output_blocked: bool, + ) -> list[dict]: # mutable-ok: decoded provider JSON is normalized for LiteLLM error details + """Extract violations from blocked sections in format""" + violations: Final[list[dict]] = [] # mutable-ok: local violation accumulator returned to LiteLLM + + # Helper function to extract from content items + def extract_from_content( + content_items: list[dict], # mutable-ok: decoded provider JSON is normalized into JSON error details + ) -> list[dict]: # mutable-ok: decoded provider JSON is normalized into JSON error details + all_violations: Final[list[dict]] = [] # mutable-ok: local violation accumulator + for content_item in content_items: + if not isinstance(content_item, dict): # pyright: ignore[reportUnnecessaryIsInstance] # defensively validate untrusted provider JSON + continue + + # Extract DLP violations + dlp_violations = content_item.get( # rebind-ok: each provider content item has independent violations + "dlpViolations", + [], # mutable-ok: read-only fallback for absent provider array + ) + all_violations.extend( + { # mutable-ok: normalized LiteLLM violation detail + "type": "DLP", + "name": dlp.get("name", "Unknown"), + "dlp_data_type_id": dlp.get("dlpDataTypeId"), + "action": dlp.get("action", "UNKNOWN"), + "categories": dlp.get( + "categories", + [], # mutable-ok: read-only fallback for absent category array + ), # mutable-ok: read-only fallback for absent category array + "match_count": dlp.get("matchCount", 0), + } + for dlp in dlp_violations + ) + + # Extract AI violations + ai_violations = content_item.get( # rebind-ok: each provider content item has independent violations + "aiViolations", + [], # mutable-ok: read-only fallback for absent provider array + ) + all_violations.extend( + { # mutable-ok: normalized LiteLLM violation detail + "type": "AI_THREAT", + "name": ai.get("name", "Unknown"), + "ai_threat_category_id": ai.get("aiThreatCategoryId"), + "threat_type": ai.get("type", "UNKNOWN"), + "action": ai.get("action", "UNKNOWN"), + } + for ai in ai_violations + ) + return all_violations + + # Extract from input if blocked + if input_blocked and "input" in response_data: + input_content: Final = response_data["input"].get( + "content", + [], # mutable-ok: read-only fallback for absent provider content + ) + if isinstance(input_content, list): + violations.extend(extract_from_content(input_content)) + + # Extract from output if blocked + if output_blocked and "output" in response_data: + output_content: Final = response_data["output"].get( + "content", + [], # mutable-ok: read-only fallback for absent provider content + ) + if isinstance(output_content, list): + violations.extend(extract_from_content(output_content)) + + return violations + + def _create_violation_details( + self, + violations: list[dict], # mutable-ok: LiteLLM guardrail errors expose JSON detail arrays + ) -> list[dict]: # mutable-ok: LiteLLM guardrail errors expose JSON detail arrays + """Create detailed violation information""" + violation_details: Final[list[dict]] = [] # mutable-ok: local JSON detail accumulator + for violation in violations: + if not isinstance(violation, dict): # pyright: ignore[reportUnnecessaryIsInstance] # defensively validate normalized JSON before rendering details + continue + + violation_type = violation.get("type", "UNKNOWN") + + if violation_type == "DLP": + # DLP violation format + categories = violation.get( # rebind-ok: each DLP violation has independent categories + "categories", + [], # mutable-ok: read-only fallback for absent category array + ) + category_names = [ # mutable-ok: JSON detail array # rebind-ok: one list per violation + cat.get("name", cat.get("code", "")) for cat in categories if isinstance(cat, dict) + ] + + violation_details.append( + { # mutable-ok: normalized LiteLLM violation detail + "type": "DLP", + "name": violation.get("name", "Unknown DLP"), + "action": violation.get("action", "UNKNOWN"), + "categories": category_names, + "match_count": violation.get("match_count", 0), + "dlp_data_type_id": violation.get("dlp_data_type_id"), + } + ) + elif violation_type == "AI_THREAT": + # AI threat violation format + violation_details.append( + { # mutable-ok: normalized LiteLLM violation detail + "type": "AI_THREAT", + "name": violation.get("name", "Unknown Threat"), + "threat_type": violation.get("threat_type", "UNKNOWN"), + "action": violation.get("action", "UNKNOWN"), + "ai_threat_category_id": violation.get("ai_threat_category_id"), + } + ) + else: + # Generic violation + violation_details.append(violation) + + return violation_details + + def _handle_blocked_request( + self, + violation_details: list[dict], # mutable-ok: LiteLLM guardrail errors expose JSON detail arrays + ) -> None: # mutable-ok: LiteLLM guardrail errors expose JSON detail arrays + """Raise LiteLLM's standard exception for a PointGuard policy block.""" + error_message: Final = "Content blocked by PointGuardAI policy" + + verbose_proxy_logger.warning("PointGuardAI blocking request with violations: %s", violation_details) + + raise GuardrailRaisedException( + guardrail_name=getattr(self, "guardrail_name", None) or GUARDRAIL_NAME, + message=error_message, + should_wrap_with_default_message=False, + status_code=400, + blocked_content=True, + ) + + def _handle_modifications( + self, + response_data: dict, # mutable-ok: decoded PointGuard JSON response + input_modified: bool, + output_modified: bool, # mutable-ok: decoded PointGuard JSON response + ) -> list[dict] | None: # mutable-ok: modifications are returned as LiteLLM JSON objects + """Handle content modifications""" + verbose_proxy_logger.info( + "PointGuardAI modification detected - Input: %s, Output: %s", + input_modified, + output_modified, + ) + + # Extract modified content from content items + # Returns items with originalContent and modifiedContent for comparison + def extract_modified_content( + content_items: list[dict], # mutable-ok: decoded provider content is normalized into JSON modifications + ) -> list[dict]: # mutable-ok: decoded provider content is normalized into JSON modifications + modified_messages: Final[list[dict]] = [] # mutable-ok: local modification accumulator + for index, item in enumerate(content_items): + if not isinstance(item, dict): # pyright: ignore[reportUnnecessaryIsInstance] # defensively validate untrusted provider JSON + continue + + original_content = item.get( + "originalContent" + ) # rebind-ok: each provider item carries independent content + modified_content = item.get( + "modifiedContent" + ) # rebind-ok: each provider item carries independent content + if not isinstance(original_content, str) or not original_content: + continue + if not isinstance(modified_content, str): + continue + + # Return with both original and modified content for apply_guardrail to use + modified_messages.append( + { # mutable-ok: normalized PointGuard modification object + "role": item.get("role", "user"), + "originalContent": original_content, + "modifiedContent": modified_content, + "index": index, + } + ) + + # Log if content was actually modified + if item.get("modifiedContent") is not None: + verbose_proxy_logger.info( + "PointGuardAI: Content modified for role '%s'", + item.get("role", "user"), + ) + + return modified_messages + + # Post-call redactions take precedence when both sections are modified. + if output_modified and "output" in response_data: + output_data: Final = response_data["output"] + if isinstance(output_data, dict) and "content" in output_data: + content_items: Final = output_data.get( + "content", + [], # mutable-ok: read-only fallback for absent provider content + ) + if isinstance(content_items, list): + verbose_proxy_logger.info( + "PointGuardAI output modifications: %d items", + len(content_items), + ) + modifications: Final = extract_modified_content(content_items) + if modifications: + return modifications + raise HTTPException( + status_code=502, + detail="Invalid PointGuardAI response: output marked modified without replacement content", + ) + + if input_modified and "input" in response_data: + input_data: Final = response_data["input"] + if isinstance(input_data, dict) and "content" in input_data: + input_content_items: Final = input_data.get( + "content", + [], # mutable-ok: read-only fallback for absent provider content + ) + if isinstance(input_content_items, list): + verbose_proxy_logger.info( + "PointGuardAI input modifications: %d items", + len(input_content_items), + ) + input_modifications: Final = extract_modified_content(input_content_items) + if input_modifications: + return input_modifications + raise HTTPException( + status_code=502, + detail="Invalid PointGuardAI response: input marked modified without replacement content", + ) + + return None + + def _handle_http_status_error(self, e: httpx.HTTPStatusError) -> NoReturn: + """Handle HTTP status errors""" + status_code: Final = e.response.status_code + verbose_proxy_logger.error("PointGuardAI API HTTP error %s", status_code) + + error_messages: Final = { # mutable-ok: fixed HTTP status lookup table + 401: "PointGuardAI authentication failed: Invalid API credentials", + 400: "PointGuardAI bad request: Invalid configuration or parameters", + 403: "PointGuardAI access denied: Insufficient permissions", + 404: "PointGuardAI resource not found: Invalid endpoint or organization", + } + + detail: Final = error_messages.get(status_code, f"PointGuardAI API error ({status_code})") + if 500 <= status_code < 600: + raise _PointGuardAIUnavailableError(status_code=status_code, detail=detail) + raise HTTPException(status_code=status_code, detail=detail) + + def _handle_network_errors(self, e: httpx.ConnectError | httpx.TimeoutException | httpx.RequestError) -> NoReturn: + """Handle network-related errors""" + if isinstance(e, httpx.TimeoutException): + verbose_proxy_logger.error("PointGuardAI timeout error: %s", str(e)) + raise _PointGuardAIUnavailableError( + status_code=504, + detail="PointGuardAI request timeout: API request took too long to complete", + ) + else: + verbose_proxy_logger.error("PointGuardAI connection error: %s", str(e)) + raise _PointGuardAIUnavailableError( + status_code=503, + detail="PointGuardAI service unavailable: Cannot connect to API endpoint. Please check the API URL configuration.", + ) + + async def make_pointguard_api_request( + self, + request_data: dict, # mutable-ok: inherited LiteLLM hook request payload + new_messages: list[dict], # mutable-ok: outbound PointGuard JSON message array + response_string: str | None = None, + response_strings: list[str] | None = None, # mutable-ok: LiteLLM output hook supplies a text list + ) -> list[dict] | None: # mutable-ok: modifications are returned as LiteLLM JSON objects + """Make the API request to PointGuardAI API""" + try: + # Select appropriate endpoint based on whether we have output + # pre_call mode: use input endpoint + # post_call mode: use output endpoint + output_present: Final = response_strings is not None or response_string is not None + endpoint: Final = self.output_endpoint if output_present else self.input_endpoint + if output_present: + verbose_proxy_logger.debug("PointGuardAI: Using output endpoint") + else: + verbose_proxy_logger.debug("PointGuardAI: Using input endpoint") + + pointguardai_data: Final = await self.prepare_pointguard_ai_runtime_scanner_request( + new_messages=new_messages, + response_string=response_string, + response_strings=response_strings, + ) + + if pointguardai_data is None: + verbose_proxy_logger.warning("PointGuardAI: No data prepared for request") + return None + + _json_data: Final = json.dumps(pointguardai_data) + + verbose_proxy_logger.debug("PointGuardAI: Sending request to %s", endpoint) + + response: Final = await self.async_handler.post( + url=endpoint, + data=_json_data, + headers=self.headers, + ) + + verbose_proxy_logger.debug("PointGuardAI response status: %s", response.status_code) + # Raise HTTPStatusError for 4xx and 5xx responses + response.raise_for_status() + + # If we reach here, response.status_code is 2xx (success) + if response.status_code == 200: + try: + response_data: Final = response.json() + except json.JSONDecodeError as e: + verbose_proxy_logger.error("Failed to parse PointGuardAI response JSON: %s", e) + raise HTTPException( + status_code=502, + detail="Invalid JSON response from PointGuardAI", + ) + + self._validate_response_data(response_data, output_present) + + # Check sections and extract status flags + input_section_present, output_section_present = self._check_sections_present( + response_data, + new_messages, + response_string, + response_strings, + ) + input_blocked, output_blocked, input_modified, output_modified = self._extract_status_flags( + response_data, input_section_present, output_section_present + ) + + verbose_proxy_logger.info( + "PointGuardAI API response analysis - Input: blocked=%s, modified=%s | Output: blocked=%s, modified=%s", + input_blocked, + input_modified, + output_blocked, + output_modified, + ) + # Priority rule: If both blocked=true AND modified=true, BLOCK takes precedence + if input_blocked or output_blocked: + verbose_proxy_logger.warning( + "PointGuardAI blocked the request - Input blocked: %s, Output blocked: %s", + input_blocked, + output_blocked, + ) + + violations: Final = self._extract_violations(response_data, input_blocked, output_blocked) + violation_details: Final = self._create_violation_details(violations) + self._handle_blocked_request(violation_details) + + # Check for modifications only if not blocked + elif output_present and output_modified: + return self._handle_modifications(response_data, False, True) + elif not output_present and input_modified: + return self._handle_modifications(response_data, True, False) + + # No blocking or modification needed + verbose_proxy_logger.debug("PointGuardAI: No blocking or modifications required") + return None + + raise HTTPException( + status_code=502, + detail=f"Invalid PointGuardAI success status: {response.status_code}", + ) + + except (HTTPException, GuardrailRaisedException): + # Re-raise HTTP exceptions as-is + raise + except httpx.HTTPStatusError as e: + self._handle_http_status_error(e) + except (httpx.ConnectError, httpx.TimeoutException, httpx.RequestError) as e: + self._handle_network_errors(e) + except (KeyError, TypeError, ValueError) as e: + verbose_proxy_logger.error( + "Unexpected error in PointGuardAI API request: %s", + str(e), + exc_info=True, + ) + raise HTTPException( + status_code=500, + detail=f"Unexpected error in PointGuardAI integration: {e!s}", + ) + + @log_guardrail_information + async def apply_guardrail( + self, + inputs: GenericGuardrailAPIInputs, + request_data: dict, # mutable-ok: inherited LiteLLM guardrail hook contract + input_type: Literal["request", "response"], + logging_obj: "LiteLLMLoggingObj | None" = None, + ) -> GenericGuardrailAPIInputs: + """ + Apply PointGuardAI guardrail to the given inputs using the unified guardrail system. + + Args: + inputs: Dictionary containing: + - texts: List of texts to check + - structured_messages: Structured messages from the request (pre-call only) + request_data: The original request data + input_type: "request" for pre-call input validation, "response" for post-call output validation + logging_obj: Optional logging object + + Returns: + GenericGuardrailAPIInputs - modified if content changes are applied + + Raises: + HTTPException: If content is blocked by PointGuardAI + """ + texts: Final = inputs.get( + "texts", + [], # mutable-ok: read-only fallback required by LiteLLM hook payload + ) + structured_messages: Final = inputs.get( + "structured_messages", + [], # mutable-ok: read-only fallback required by LiteLLM hook payload + ) + + verbose_proxy_logger.debug( + "PointGuardAI: apply_guardrail called with input_type=%s, texts=%d, structured_messages=%d", + input_type, + len(texts), + len(structured_messages), + ) + + try: + if input_type == "request": + return await self._apply_guardrail_on_request( + inputs=inputs, + texts=texts, + structured_messages=structured_messages, + request_data=request_data, + ) + + # Post-call: validate output + return await self._apply_guardrail_on_response( + inputs=inputs, + texts=texts, + request_data=request_data, + ) + except _PointGuardAIUnavailableError as error: + if self.unreachable_fallback == "fail_open": + verbose_proxy_logger.critical( + "PointGuardAI unreachable (fail-open). Proceeding without guardrail. " + "status_code=%s guardrail_name=%s api_base=%s input_type=%s " + "litellm_call_id=%s litellm_trace_id=%s", + error.status_code, + getattr(self, "guardrail_name", None), + self.pointguardai_api_base, + input_type, + getattr(logging_obj, "litellm_call_id", None) if logging_obj else None, + getattr(logging_obj, "litellm_trace_id", None) if logging_obj else None, + exc_info=error, + ) + passthrough_inputs: Final[ + GenericGuardrailAPIInputs + ] = {} # mutable-ok: LiteLLM hook contract requires a mutable payload copy + passthrough_inputs.update(inputs) + return passthrough_inputs + raise + + async def _apply_guardrail_on_request( + self, + inputs: GenericGuardrailAPIInputs, + texts: list[str], # mutable-ok: inherited LiteLLM guardrail hook payload + structured_messages: list, # mutable-ok: inherited LiteLLM guardrail hook payload + request_data: dict, # mutable-ok: inherited LiteLLM guardrail hook payload + ) -> GenericGuardrailAPIInputs: + """Handle request-side (pre-call) guardrail checks for input messages.""" + request_messages: Final = self._get_input_messages_from_request_data(request_data) + messages: Final = ( + request_messages + if request_messages is not None + else ( + self.transform_messages(self._select_input_messages(structured_messages)) + if structured_messages + else [ # mutable-ok: PointGuard input schema requires a JSON array + {"role": "user", "content": text} # mutable-ok: PointGuard input schema requires a JSON object + for text in texts[-1:] # mutable-ok: PointGuard input schema requires a JSON array + ] + ) + ) + + scan_only_tool_results: Final = effective_scan_only_tool_results_for_guardrail(self) + tools: Final = ( + [] # mutable-ok: tool definitions are out of scope when scanning only tool results + if scan_only_tool_results + else inputs.get( + "tools", + [], # mutable-ok: read-only fallback required by LiteLLM hook payload + ) + ) + serialized_tools: Final = tuple( + (index, serialized) + for index, tool in enumerate(tools) + if (serialized := self._serialize_tool_payload(tool)) is not None + ) + + if not messages and not serialized_tools: + return inputs + + new_messages: Final = self.transform_messages( + messages=messages + ) + [ # mutable-ok: PointGuard input schema requires an array + {"role": "tool", "content": serialized} # mutable-ok: PointGuard input schema requires a JSON object + for _, serialized in serialized_tools + ] + + # Make PointGuardAI API request (input only - no output) + modified_content: Final = await self.make_pointguard_api_request( + request_data=request_data, + new_messages=new_messages, + response_string=None, + ) + + # Apply modifications if present + if modified_content: + verbose_proxy_logger.info( + "PointGuardAI: Applying %d modifications to input", + len(modified_content), + ) + + modifications_applied = False # rebind-ok: tracks whether any inspected input was redacted + new_tools: Final = tools.copy() + + # Modify the structured_messages or texts with string replacement + for mod_item in modified_content: + if not isinstance(mod_item, dict): # pyright: ignore[reportUnnecessaryIsInstance] # defensively validate provider modifications + continue + + original = mod_item.get("originalContent") + modified = mod_item.get("modifiedContent") + + if not isinstance(original, str) or not original: + continue + if modified is not None and not isinstance(modified, str): + continue + replacement = modified or "" + + for inspected_message in self._select_input_messages(structured_messages): + if self._replace_text_in_message_content(inspected_message, original, replacement): + modifications_applied = True + verbose_proxy_logger.info("PointGuardAI: Modified input message content") + + if self._replace_text_in_all_entries(texts, original, replacement): + modifications_applied = True + + matching_tool_index = next( # rebind-ok: each modification searches for its matching tool + (index for index, serialized in serialized_tools if serialized == original), + None, + ) + if matching_tool_index is not None: + replacement_tool = self._deserialize_tool_payload(replacement) + if replacement_tool is None: + continue + new_tools[matching_tool_index] = replacement_tool # pyright: ignore[reportCallIssue, reportArgumentType] # provider returns the inspected tool JSON shape + modifications_applied = True + + if modifications_applied: + modified_inputs: Final[ + GenericGuardrailAPIInputs + ] = {} # mutable-ok: LiteLLM hook contract requires a mutable payload copy + modified_inputs.update(inputs) + modified_inputs["texts"] = texts + modified_inputs["structured_messages"] = structured_messages + if new_tools != tools: + modified_inputs["tools"] = new_tools + return modified_inputs + + raise HTTPException( + status_code=502, + detail="Invalid PointGuardAI response: input modification did not match inspected content", + ) + + return inputs + + def _select_input_messages( + self, + messages: list[dict], # mutable-ok: inherited LiteLLM messages and PointGuard JSON use arrays of objects + ) -> list[dict]: # mutable-ok: inherited LiteLLM messages and PointGuard JSON use arrays of objects + skip_system: Final = effective_skip_system_message_for_guardrail(self) + skip_tool: Final = effective_skip_tool_message_for_guardrail(self) + scan_only_tool_results: Final = effective_scan_only_tool_results_for_guardrail(self) + scoped_messages: Final = [ # mutable-ok: selected LiteLLM messages remain a JSON-compatible list + message + for message in messages + if not role_out_of_guardrail_scope( + str(message.get("role") or "").lower(), + skip_system_message=skip_system, + skip_tool_message=skip_tool, + scan_only_tool_results=scan_only_tool_results, + ) + ] + context_roles: Final = ("system", "developer") + context_messages: Final = ( + [] # mutable-ok: LiteLLM message selection returns a list + if skip_system + else [ # mutable-ok: LiteLLM message selection returns a list + message for message in scoped_messages if str(message.get("role") or "").lower() in context_roles + ] + ) + latest_assistant_index: Final = next( + ( + index + for index in range(len(scoped_messages) - 1, -1, -1) + if str(scoped_messages[index].get("role") or "").lower() == "assistant" + ), + -1, + ) + current_turn_messages: Final = [ # mutable-ok: selected LiteLLM messages remain a JSON-compatible list + message + for message in scoped_messages[latest_assistant_index + 1 :] + if str(message.get("role") or "").lower() not in context_roles + ] + return context_messages + current_turn_messages + + def _get_input_messages_from_request_data( + self, + request_data: dict, # mutable-ok: inherited LiteLLM request and PointGuard messages are JSON containers + ) -> list[dict] | None: # mutable-ok: inherited LiteLLM request and PointGuard messages are JSON containers + """Read input messages from the request payload without copying them into metadata.""" + + messages: Final = request_data.get("messages") + if isinstance(messages, list): + chat_messages: Final = [ # mutable-ok: LiteLLM messages remain a JSON-compatible list + message for message in messages if isinstance(message, dict) + ] + return self.transform_messages(self._select_input_messages(chat_messages)) + + request_input = request_data.get("input") # rebind-ok: normalizes Responses API input into a message array + if isinstance(request_input, str): + return [ # mutable-ok: PointGuard input schema requires a JSON array + {"role": "user", "content": request_input} # mutable-ok: PointGuard input schema requires a JSON object + ] + if isinstance(request_input, dict): + request_input = [ # mutable-ok: normalizes a single Responses API object into an array # rebind-ok: replaces the singular form + request_input + ] # mutable-ok: normalizes a single Responses API object into an array # rebind-ok: replaces the singular form + if isinstance(request_input, list): + input_messages: Final[list[dict]] = [] # mutable-ok: local JSON message accumulator + for item in request_input: + if isinstance(item, str): + input_messages.append( + {"role": "user", "content": item} # mutable-ok: PointGuard input schema requires a JSON object + ) + elif isinstance(item, dict) and "content" in item: + input_messages.append(item) + if input_messages: + return self.transform_messages(self._select_input_messages(input_messages)) + + return None + + async def _apply_guardrail_on_response( + self, + inputs: GenericGuardrailAPIInputs, + texts: list[str], # mutable-ok: inherited LiteLLM guardrail hook payload + request_data: dict, # mutable-ok: inherited LiteLLM guardrail hook payload + ) -> GenericGuardrailAPIInputs: + """Handle response-side (post-call) guardrail checks for output.""" + output_indices: Final = tuple(index for index, text in enumerate(texts) if text) + tool_calls: Final = inputs.get( + "tool_calls", + [], # mutable-ok: read-only fallback required by LiteLLM hook payload + ) + serialized_tool_calls: Final = tuple( + (index, serialized) + for index, tool_call in enumerate(tool_calls) + if (serialized := self._serialize_tool_payload(tool_call)) is not None + ) + if not output_indices and not serialized_tool_calls: + return inputs + output_texts: Final = [ # mutable-ok: PointGuard output schema requires a JSON array + texts[index] for index in output_indices + ] + [ # mutable-ok: PointGuard output schema requires a JSON array + serialized for _, serialized in serialized_tool_calls + ] + + # For /output endpoint, we need both input and output + request_input_messages: Final = self._get_input_messages_from_request_data(request_data) + input_messages: Final = ( + request_input_messages + if request_input_messages is not None + else [] # mutable-ok: Swagger permits an empty input array for output inspection + ) + + # Swagger requires input but permits an empty array. + if request_input_messages is None: + verbose_proxy_logger.warning( + "PointGuardAI: Original input messages are unavailable for output validation; using an empty input array" + ) + else: + verbose_proxy_logger.info( + "PointGuardAI: Using %d request input messages for output validation", + len(input_messages), + ) + + # Make PointGuardAI API request with actual input and output + modified_content: Final = await self.make_pointguard_api_request( + request_data=request_data, + new_messages=input_messages, + response_strings=output_texts, + ) + + # Apply modifications to output if present + if modified_content: + verbose_proxy_logger.info( + "PointGuardAI: Applying %d modifications to output", + len(modified_content), + ) + + new_texts: Final = texts.copy() + new_tool_calls: Final = tool_calls.copy() + modifications_applied = False # rebind-ok: tracks whether any current output was redacted + + for mod_item in modified_content: + if not isinstance(mod_item, dict): # pyright: ignore[reportUnnecessaryIsInstance] # defensively validate provider modifications + continue + + original = mod_item.get("originalContent") + modified = mod_item.get("modifiedContent") + response_index = mod_item.get("index") + + if not isinstance(original, str) or not original: + continue + if modified is not None and not isinstance(modified, str): + continue + if not isinstance(response_index, int) or not 0 <= response_index < len(output_texts): + continue + replacement = modified or "" + if response_index < len(output_indices): + text_index = output_indices[ # rebind-ok: each modification maps to its inspected output position + response_index + ] + if original in new_texts[text_index]: + new_texts[text_index] = new_texts[text_index].replace(original, replacement) + modifications_applied = True + verbose_proxy_logger.info("PointGuardAI: Modified sensitive content in output") + continue + + tool_call_modification = self._resolve_tool_call_modification( + response_index=response_index, + text_output_count=len(output_indices), + serialized_tool_calls=serialized_tool_calls, + original=original, + replacement=replacement, + ) + if tool_call_modification is None: + continue + tool_call_index, replacement_tool_call = tool_call_modification + new_tool_calls[tool_call_index] = replacement_tool_call # pyright: ignore[reportCallIssue, reportArgumentType] # provider returns the inspected tool-call JSON shape + modifications_applied = True + + if modifications_applied: + modified_inputs: Final[ + GenericGuardrailAPIInputs + ] = {} # mutable-ok: LiteLLM hook contract requires a mutable payload copy + modified_inputs.update(inputs) + modified_inputs["texts"] = new_texts + if new_tool_calls != tool_calls: + modified_inputs["tool_calls"] = new_tool_calls + return modified_inputs + + raise HTTPException( + status_code=502, + detail="Invalid PointGuardAI response: output modification did not match inspected content", + ) + + return inputs + + @staticmethod + def get_config_model() -> type["GuardrailConfigModel"]: + from litellm.types.proxy.guardrails.guardrail_hooks.pointguardai import ( + PointGuardAIGuardrailConfigModel, + ) + + return PointGuardAIGuardrailConfigModel diff --git a/litellm/types/guardrails.py b/litellm/types/guardrails.py index c17103da890..aefd47e3999 100644 --- a/litellm/types/guardrails.py +++ b/litellm/types/guardrails.py @@ -41,6 +41,9 @@ from litellm.types.proxy.guardrails.guardrail_hooks.litellm_content_filter impor from litellm.types.proxy.guardrails.guardrail_hooks.ovalix import ( OvalixGuardrailConfigModel, ) +from litellm.types.proxy.guardrails.guardrail_hooks.pointguardai import ( + PointGuardAIGuardrailConfigModel, +) from litellm.types.proxy.guardrails.guardrail_hooks.promptguard import ( PromptGuardConfigModel, ) @@ -137,6 +140,7 @@ class SupportedGuardrailIntegrations(Enum): COMPRESR = "compresr" STRAIKER = "straiker" ALICE = "alice" + POINTGUARDAI = "pointguard_ai" class Role(Enum): @@ -1056,6 +1060,7 @@ class LitellmParams( # pyright: ignore[reportIncompatibleVariableOverride] # o ZscalerAIGuardConfigModel, AktoConfigModel, JavelinGuardrailConfigModel, + PointGuardAIGuardrailConfigModel, BaseLitellmParams, EnkryptAIGuardrailConfigs, IBMGuardrailsBaseConfigModel, diff --git a/litellm/types/proxy/guardrails/guardrail_hooks/pointguardai.py b/litellm/types/proxy/guardrails/guardrail_hooks/pointguardai.py new file mode 100644 index 00000000000..c4362c0d78c --- /dev/null +++ b/litellm/types/proxy/guardrails/guardrail_hooks/pointguardai.py @@ -0,0 +1,37 @@ +from typing import Literal + +from pydantic import Field + +from .base import GuardrailConfigModel + + +class PointGuardAIGuardrailConfigModel(GuardrailConfigModel): + """Configuration parameters for the PointGuardAI v2 guardrail""" + + org_code: str | None = Field( + default=None, + description="Organization code for PointGuardAI.", + ) + api_base: str | None = Field( + default=None, + description="Base URL for PointGuardAI. Defaults to https://api.appsoc.com.", + ) + api_key: str | None = Field( + default=None, + description="API key for PointGuardAI.", + ) + policy_config_name: str | None = Field( + default=None, + description="PointGuardAI policy configuration name.", + ) + unreachable_fallback: Literal["fail_closed", "fail_open"] = Field( + default="fail_closed", + description=( + "Behavior when PointGuardAI is unreachable. 'fail_closed' raises an error " + "(default); 'fail_open' logs a critical error and allows the request." + ), + ) + + @staticmethod + def ui_friendly_name() -> str: + return "PointGuard AI" diff --git a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_pointguardai.py b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_pointguardai.py new file mode 100644 index 00000000000..e29be271de8 --- /dev/null +++ b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_pointguardai.py @@ -0,0 +1,2448 @@ +""" +Unit tests for PointGuardAI guardrail integration. +""" + +import json +from unittest.mock import AsyncMock, MagicMock + +import httpx +import pytest +from fastapi import HTTPException + +from litellm.exceptions import GuardrailRaisedException +from litellm.llms.openai.chat.guardrail_translation.handler import ( + OpenAIChatCompletionsHandler, +) +from litellm.proxy._types import UserAPIKeyAuth +from litellm.proxy.guardrails.guardrail_hooks.pointguardai.pointguardai import ( + PointGuardAIGuardrail, +) +from litellm.proxy.guardrails.guardrail_hooks.unified_guardrail import ( + unified_guardrail as unified_module, +) +from litellm.proxy.guardrails.guardrail_hooks.unified_guardrail.unified_guardrail import ( + UnifiedLLMGuardrails, +) +from litellm.types.guardrails import GuardrailEventHooks +from litellm.types.utils import ( + CallTypes, + Delta, + GenericGuardrailAPIInputs, + ModelResponseStream, + StreamingChoices, +) + + +class _RecordingCallbackManager: + def __init__(self) -> None: + self.callback: PointGuardAIGuardrail | None = None + + def add_litellm_callback(self, callback: PointGuardAIGuardrail) -> None: + self.callback = callback + + +def _pointguard_guardrail(**kwargs: object) -> PointGuardAIGuardrail: + return PointGuardAIGuardrail( + async_handler=MagicMock(), + **kwargs, # pyright: ignore[reportArgumentType] # test helper forwards validated constructor fixtures + ) + + +class TestPointGuardAIGuardrailInit: + """Tests for PointGuardAIGuardrail initialization.""" + + def test_init_with_required_params(self): + """Test initialization with all required parameters.""" + guardrail = _pointguard_guardrail( + api_key="test_api_key", + api_base="https://api.appsoc.com", + org_code="test-org", + policy_config_name="test-policy", + guardrail_name="pointguardai-guard", + ) + + assert guardrail.pointguardai_api_key == "test_api_key" + assert guardrail.pointguardai_org_code == "test-org" + assert guardrail.pointguardai_policy_config_name == "test-policy" + assert guardrail.guardrail_name == "pointguardai-guard" + + def test_init_with_missing_api_key(self): + """Test that initialization fails without api_key.""" + with pytest.raises(HTTPException) as exc_info: + _pointguard_guardrail( + api_key="", + api_base="https://api.appsoc.com", + org_code="test-org", + policy_config_name="test-policy", + ) + + assert exc_info.value.status_code == 401 + assert "api_key" in str(exc_info.value.detail) + + def test_init_with_missing_org_code(self): + """Test that initialization fails without org_code.""" + with pytest.raises(HTTPException) as exc_info: + _pointguard_guardrail( + api_key="test_key", + api_base="https://api.appsoc.com", + org_code="", + policy_config_name="test-policy", + ) + + assert exc_info.value.status_code == 401 + assert "org_code" in str(exc_info.value.detail) + + def test_init_with_missing_policy_config_name(self): + """Test that initialization fails without policy_config_name.""" + with pytest.raises(HTTPException) as exc_info: + _pointguard_guardrail( + api_key="test_key", + api_base="https://api.appsoc.com", + org_code="test-org", + policy_config_name="", + ) + + assert exc_info.value.status_code == 401 + assert "policy_config_name" in str(exc_info.value.detail) + + def test_init_with_default_api_base(self): + """Test the production API base is used when no override is configured.""" + guardrail = _pointguard_guardrail( + api_key="test_key", + org_code="test-org", + policy_config_name="test-policy", + ) + + assert guardrail.pointguardai_api_base == "https://api.appsoc.com" + assert guardrail.input_endpoint == "https://api.appsoc.com/aisec-rdc-v2/api/v1/orgs/test-org/inspect/input" + assert guardrail.output_endpoint == "https://api.appsoc.com/aisec-rdc-v2/api/v1/orgs/test-org/inspect/output" + + def test_init_with_custom_api_base(self): + """Test initialization with custom API base URL.""" + guardrail = _pointguard_guardrail( + api_key="test_key", + api_base="https://custom.appsoc.com", + org_code="test-org", + policy_config_name="test-policy", + ) + + assert guardrail.pointguardai_api_base == "https://custom.appsoc.com" + assert guardrail.input_endpoint.startswith("https://custom.appsoc.com/") + assert guardrail.output_endpoint.startswith("https://custom.appsoc.com/") + + def test_init_with_org_code_template_replacement(self): + """Test that derived endpoints include the configured org code.""" + guardrail = _pointguard_guardrail( + api_key="test_key", + api_base="https://api.appsoc.com", + org_code="my-org-123", + policy_config_name="test-policy", + ) + + assert "my-org-123" in guardrail.input_endpoint + assert "my-org-123" in guardrail.output_endpoint + + def test_init_headers_configuration(self): + """Test that headers are correctly configured""" + guardrail = _pointguard_guardrail( + api_key="my_secret_key", + api_base="https://api.appsoc.com", + org_code="test-org", + policy_config_name="test-policy", + ) + + assert guardrail.headers["X-appsoc-api-key"] == "my_secret_key" + assert guardrail.headers["Content-Type"] == "application/json" + + def test_init_rejects_during_call_when_shared_strict_validation_is_disabled(self, monkeypatch): + monkeypatch.setenv("LITELLM_STRICT_GUARDRAIL_MODES", "false") + + with pytest.raises(ValueError, match="during_call"): + _pointguard_guardrail( + api_key="test_key", + api_base="https://api.appsoc.com", + org_code="test-org", + policy_config_name="test-policy", + event_hook=[ + GuardrailEventHooks.pre_call, + GuardrailEventHooks.during_call, + GuardrailEventHooks.post_call, + ], + ) + + +class TestPointGuardAIGuardrailMessageTransformation: + """Tests for message transformation to API format.""" + + def test_transform_messages_with_supported_roles(self): + """Test transformation of messages with supported roles.""" + guardrail = _pointguard_guardrail( + api_key="test_key", + api_base="https://api.appsoc.com", + org_code="test-org", + policy_config_name="test-policy", + ) + + messages = [ + {"role": "system", "content": "You are a helpful assistant."}, + {"role": "user", "content": "Hello!"}, + {"role": "assistant", "content": "Hi there!"}, + ] + + result = guardrail.transform_messages(messages) + + assert len(result) == 3 + assert result[0]["role"] == "system" + assert result[1]["role"] == "user" + assert result[2]["role"] == "assistant" + + def test_transform_messages_with_tool_role(self): + """Swagger-compatible string roles should retain their semantics.""" + guardrail = _pointguard_guardrail( + api_key="test_key", + api_base="https://api.appsoc.com", + org_code="test-org", + policy_config_name="test-policy", + ) + + messages = [ + {"role": "user", "content": "Get weather"}, + {"role": "tool", "content": "Weather is sunny", "tool_call_id": "123"}, + ] + + result = guardrail.transform_messages(messages) + + assert len(result) == 2 + assert result[0]["role"] == "user" + assert result[1]["role"] == "tool" + assert result[1]["content"] == "Weather is sunny" + + def test_transform_messages_emits_only_swagger_message_fields(self): + """PointGuard InspectMessage accepts only role and string content.""" + guardrail = _pointguard_guardrail( + api_key="test_key", + api_base="https://api.appsoc.com", + org_code="test-org", + policy_config_name="test-policy", + ) + + messages = [ + {"role": "user", "content": "This is my message", "extra_field": "value"}, + ] + + result = guardrail.transform_messages(messages) + + assert result == [{"role": "user", "content": "This is my message"}] + + def test_transform_messages_does_not_send_tool_metadata(self): + """Tool metadata must not leak into PointGuard's text-only payload.""" + guardrail = _pointguard_guardrail( + api_key="test_key", + api_base="https://api.appsoc.com", + org_code="test-org", + policy_config_name="test-policy", + ) + messages = [ + { + "role": "tool", + "content": "Weather is sunny", + "tool_call_id": "call-123", + "name": "get_weather", + } + ] + + result = guardrail.transform_messages(messages) + + assert result == [{"role": "tool", "content": "Weather is sunny"}] + + @pytest.mark.parametrize("role", [None, "", 123]) + def test_transform_messages_defaults_invalid_roles_to_user(self, role): + """InspectMessage always requires a non-empty string role.""" + guardrail = _pointguard_guardrail( + api_key="test_key", + api_base="https://api.appsoc.com", + org_code="test-org", + policy_config_name="test-policy", + ) + + result = guardrail.transform_messages([{"role": role, "content": "Hello"}]) + + assert result == [{"role": "user", "content": "Hello"}] + + def test_transform_messages_flattens_only_multimodal_text(self): + """PointGuard's text-only API must not receive image payloads or null content.""" + guardrail = _pointguard_guardrail( + api_key="test_key", + api_base="https://api.appsoc.com", + org_code="test-org", + policy_config_name="test-policy", + ) + messages = [ + { + "role": "user", + "content": [ + {"type": "text", "text": "First text block"}, + { + "type": "image_url", + "image_url": {"url": "data:image/png;base64,secret-image"}, + }, + {"type": "input_text", "text": "Second text block"}, + ], + }, + {"role": "assistant", "content": None}, + ] + + result = guardrail.transform_messages(messages) + + assert result[0]["content"] == "First text block\nSecond text block" + assert "secret-image" not in result[0]["content"] + assert result[1]["content"] == "" + + +class TestPointGuardAIGuardrailRequestPreparation: + """Tests for API request preparation.""" + + @pytest.mark.asyncio + async def test_prepare_request_with_input_only(self): + """Test request preparation with input messages only (pre_call).""" + guardrail = _pointguard_guardrail( + api_key="test_key", + api_base="https://api.appsoc.com", + org_code="test-org", + policy_config_name="my-policy", + ) + + messages = [{"role": "user", "content": "Hello"}] + + result = await guardrail.prepare_pointguard_ai_runtime_scanner_request( + new_messages=messages, + response_string=None, + ) + + assert result is not None + assert result["policyName"] == "my-policy" + assert "input" in result + assert result["input"] == messages + assert "output" not in result + + @pytest.mark.asyncio + async def test_prepare_request_with_output_only(self): + """Test request preparation with output only (post_call response).""" + guardrail = _pointguard_guardrail( + api_key="test_key", + api_base="https://api.appsoc.com", + org_code="test-org", + policy_config_name="my-policy", + ) + + result = await guardrail.prepare_pointguard_ai_runtime_scanner_request( + new_messages=[], + response_string="This is the response", + ) + + assert result is not None + assert result["policyName"] == "my-policy" + assert result["input"] == [] + assert "output" in result + assert result["output"][0]["role"] == "assistant" + assert result["output"][0]["content"] == "This is the response" + + @pytest.mark.asyncio + async def test_prepare_request_with_both_input_output(self): + """Test request preparation with both input and output.""" + guardrail = _pointguard_guardrail( + api_key="test_key", + api_base="https://api.appsoc.com", + org_code="test-org", + policy_config_name="my-policy", + ) + + messages = [{"role": "user", "content": "Hello"}] + + result = await guardrail.prepare_pointguard_ai_runtime_scanner_request( + new_messages=messages, + response_string="Hi there!", + ) + + assert result is not None + assert "input" in result + assert "output" in result + assert result["input"] == messages + assert result["output"][0]["content"] == "Hi there!" + + @pytest.mark.asyncio + async def test_prepare_request_returns_none_for_empty_data(self): + """Test that None is returned when no messages or response provided.""" + guardrail = _pointguard_guardrail( + api_key="test_key", + api_base="https://api.appsoc.com", + org_code="test-org", + policy_config_name="my-policy", + ) + + result = await guardrail.prepare_pointguard_ai_runtime_scanner_request( + new_messages=[], + response_string=None, + ) + + assert result is None + + @pytest.mark.asyncio + async def test_prepare_request_fails_closed_when_policy_state_is_missing(self): + """Invalid runtime configuration must not silently bypass inspection.""" + guardrail = _pointguard_guardrail( + api_key="test_key", + api_base="https://api.appsoc.com", + org_code="test-org", + policy_config_name="my-policy", + ) + guardrail.pointguardai_policy_config_name = "" + + with pytest.raises(HTTPException) as exc_info: + await guardrail.prepare_pointguard_ai_runtime_scanner_request( + new_messages=[{"role": "user", "content": "Hello"}], + ) + + assert exc_info.value.status_code == 500 + assert "policy configuration" in str(exc_info.value.detail) + + +class TestPointGuardAIGuardrailResponseProcessing: + """Tests for response processing and violation detection.""" + + def test_check_sections_present_with_input(self): + """Test detection of input section in response.""" + guardrail = _pointguard_guardrail( + api_key="test_key", + api_base="https://api.appsoc.com", + org_code="test-org", + policy_config_name="test-policy", + ) + + response_data = { + "input": { + "blocked": False, + "content": [{"originalContent": "Hello"}], + } + } + messages = [{"role": "user", "content": "Hello"}] + + input_present, output_present = guardrail._check_sections_present(response_data, messages, None) + + assert input_present is True + assert output_present is False + + def test_check_sections_present_with_output(self): + """Test detection of output section in response.""" + guardrail = _pointguard_guardrail( + api_key="test_key", + api_base="https://api.appsoc.com", + org_code="test-org", + policy_config_name="test-policy", + ) + + response_data = { + "output": { + "blocked": False, + "content": [{"originalContent": "Hi there"}], + } + } + + input_present, output_present = guardrail._check_sections_present(response_data, [], "Hi there") + + assert input_present is False + assert output_present is True + + def test_extract_status_flags_input_blocked(self): + """Test extraction of input blocked flag.""" + guardrail = _pointguard_guardrail( + api_key="test_key", + api_base="https://api.appsoc.com", + org_code="test-org", + policy_config_name="test-policy", + ) + + response_data = { + "input": {"blocked": True, "modified": False}, + "output": {"blocked": False, "modified": False}, + } + + ( + input_blocked, + output_blocked, + input_modified, + output_modified, + ) = guardrail._extract_status_flags(response_data, True, False) + + assert input_blocked is True + assert output_blocked is False + assert input_modified is False + assert output_modified is False + + def test_extract_status_flags_output_modified(self): + """Test extraction of output modified flag.""" + guardrail = _pointguard_guardrail( + api_key="test_key", + api_base="https://api.appsoc.com", + org_code="test-org", + policy_config_name="test-policy", + ) + + response_data = { + "input": {"blocked": False, "modified": False}, + "output": {"blocked": False, "modified": True}, + } + + ( + input_blocked, + output_blocked, + input_modified, + output_modified, + ) = guardrail._extract_status_flags(response_data, False, True) + + assert input_blocked is False + assert output_blocked is False + assert input_modified is False + assert output_modified is True + + def test_extract_violations_from_input(self): + """Test extraction of violations from input section.""" + guardrail = _pointguard_guardrail( + api_key="test_key", + api_base="https://api.appsoc.com", + org_code="test-org", + policy_config_name="test-policy", + ) + + response_data = { + "input": { + "blocked": True, + "content": [ + { + "dlpViolations": [ + { + "name": "credit-card", + "dlpDataTypeId": "cc", + "action": "BLOCK", + "categories": [{"name": "pii"}], + "matchCount": 1, + } + ], + "aiViolations": [ + { + "name": "prompt-injection", + "aiThreatCategoryId": "threat-1", + "type": "PROMPT_INJECTION", + "action": "BLOCK", + } + ], + } + ], + } + } + + violations = guardrail._extract_violations(response_data, True, False) + + assert len(violations) == 2 + assert violations[0]["type"] == "DLP" + assert violations[1]["type"] == "AI_THREAT" + + def test_create_violation_details(self): + """Test creation of violation detail objects.""" + guardrail = _pointguard_guardrail( + api_key="test_key", + api_base="https://api.appsoc.com", + org_code="test-org", + policy_config_name="test-policy", + ) + + violations = [ + { + "type": "DLP", + "name": "credit-card", + "action": "BLOCK", + "categories": [{"name": "credit_card"}, {"code": "ssn"}], + "match_count": 2, + "dlp_data_type_id": "cc", + }, + { + "type": "AI_THREAT", + "name": "prompt-injection", + "threat_type": "PROMPT_INJECTION", + "action": "BLOCK", + "ai_threat_category_id": "threat-1", + }, + ] + + details = guardrail._create_violation_details(violations) + + assert len(details) == 2 + assert details[0]["type"] == "DLP" + assert details[0]["name"] == "credit-card" + assert details[0]["categories"] == ["credit_card", "ssn"] + assert details[1]["type"] == "AI_THREAT" + assert details[1]["threat_type"] == "PROMPT_INJECTION" + + def test_handle_modifications_input(self): + """Test handling of input modifications.""" + guardrail = _pointguard_guardrail( + api_key="test_key", + api_base="https://api.appsoc.com", + org_code="test-org", + policy_config_name="test-policy", + ) + + response_data = { + "input": { + "modified": True, + "content": [ + { + "originalContent": "My SSN is 123-45-6789", + "modifiedContent": "My SSN is [REDACTED]", + } + ], + } + } + + result = guardrail._handle_modifications(response_data, True, False) + + assert result is not None + assert len(result) == 1 + assert result[0]["modifiedContent"] == "My SSN is [REDACTED]" + + def test_handle_modifications_prefers_output_when_both_are_modified(self): + """Output redaction must not be lost when both response sections are modified.""" + guardrail = _pointguard_guardrail( + api_key="test_key", + api_base="https://api.appsoc.com", + org_code="test-org", + policy_config_name="test-policy", + ) + + response_data = { + "input": { + "modified": True, + "content": [ + { + "role": "user", + "originalContent": "input secret", + "modifiedContent": "[REDACTED INPUT]", + } + ], + }, + "output": { + "modified": True, + "content": [ + { + "role": "assistant", + "originalContent": "output secret", + "modifiedContent": "[REDACTED OUTPUT]", + } + ], + }, + } + + result = guardrail._handle_modifications(response_data, True, True) + + assert result == [ + { + "role": "assistant", + "originalContent": "output secret", + "modifiedContent": "[REDACTED OUTPUT]", + "index": 0, + } + ] + + +class TestPointGuardAIGuardrailAPICall: + """Tests for API call with httpx client.""" + + @pytest.mark.asyncio + async def test_api_call_no_violations(self): + """Test API call when no violations are detected.""" + guardrail = _pointguard_guardrail( + api_key="test_key", + api_base="https://api.appsoc.com", + org_code="test-org", + policy_config_name="test-policy", + ) + + # Mock successful response with no violations + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "policyName": "test-policy", + "input": {"blocked": False, "modified": False, "content": []}, + } + mock_response.raise_for_status = MagicMock() + guardrail.async_handler.post = AsyncMock(return_value=mock_response) + + messages = [{"role": "user", "content": "Hello"}] + + result = await guardrail.make_pointguard_api_request( + request_data={}, + new_messages=messages, + response_string=None, + ) + + assert result is None # No modifications + guardrail.async_handler.post.assert_called_once() + + @pytest.mark.asyncio + async def test_api_call_with_blocked_content(self): + """Test API call when content is blocked.""" + guardrail = _pointguard_guardrail( + api_key="test_key", + api_base="https://api.appsoc.com", + org_code="test-org", + policy_config_name="test-policy", + ) + + # Mock blocked response + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "policyName": "test-policy", + "input": { + "blocked": True, + "modified": False, + "content": [ + { + "aiViolations": [ + { + "name": "prompt-injection", + "aiThreatCategoryId": "threat-1", + "type": "PROMPT_INJECTION", + "action": "BLOCK", + } + ] + } + ], + }, + } + mock_response.raise_for_status = MagicMock() + guardrail.async_handler.post = AsyncMock(return_value=mock_response) + + messages = [{"role": "user", "content": "Bad content"}] + + with pytest.raises(GuardrailRaisedException) as exc_info: + await guardrail.make_pointguard_api_request( + request_data={}, + new_messages=messages, + response_string=None, + ) + + assert exc_info.value.status_code == 400 + assert exc_info.value.blocked_content is True + assert exc_info.value.guardrail_name == "POINTGUARDAI" + assert str(exc_info.value) == "Content blocked by PointGuardAI policy" + + @pytest.mark.asyncio + async def test_api_call_with_modified_content(self): + """Test API call when content is modified.""" + guardrail = _pointguard_guardrail( + api_key="test_key", + api_base="https://api.appsoc.com", + org_code="test-org", + policy_config_name="test-policy", + ) + + # Mock modified response + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "policyName": "test-policy", + "input": { + "blocked": False, + "modified": True, + "content": [ + { + "originalContent": "My email is test@example.com", + "modifiedContent": "My email is [REDACTED]", + } + ], + }, + } + mock_response.raise_for_status = MagicMock() + guardrail.async_handler.post = AsyncMock(return_value=mock_response) + + messages = [{"role": "user", "content": "My email is test@example.com"}] + + result = await guardrail.make_pointguard_api_request( + request_data={}, + new_messages=messages, + response_string=None, + ) + + assert result is not None + assert len(result) == 1 + assert result[0]["modifiedContent"] == "My email is [REDACTED]" + + @pytest.mark.asyncio + async def test_api_call_correct_headers(self): + """Test that correct headers are sent with API request.""" + guardrail = _pointguard_guardrail( + api_key="my_secret_key", + api_base="https://api.appsoc.com", + org_code="test-org", + policy_config_name="test-policy", + ) + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "policyName": "test-policy", + "input": {"blocked": False, "modified": False, "content": []}, + } + mock_response.raise_for_status = MagicMock() + guardrail.async_handler.post = AsyncMock(return_value=mock_response) + + messages = [{"role": "user", "content": "Hello"}] + + await guardrail.make_pointguard_api_request( + request_data={}, + new_messages=messages, + response_string=None, + ) + + call_kwargs = guardrail.async_handler.post.call_args[1] + assert call_kwargs["headers"]["X-appsoc-api-key"] == "my_secret_key" + assert "X-appsoc-api-email" not in call_kwargs["headers"] + + @pytest.mark.asyncio + async def test_api_call_non_200_success_is_invalid(self): + guardrail = _pointguard_guardrail( + api_key="test_key", + api_base="https://api.appsoc.com", + org_code="test-org", + policy_config_name="test-policy", + ) + + mock_response = MagicMock() + mock_response.status_code = 204 + mock_response.text = "" + mock_response.raise_for_status = MagicMock() + guardrail.async_handler.post = AsyncMock(return_value=mock_response) + + messages = [{"role": "user", "content": "Hello"}] + + with pytest.raises(HTTPException) as exc_info: + await guardrail.make_pointguard_api_request( + request_data={}, + new_messages=messages, + response_string=None, + ) + + assert exc_info.value.status_code == 502 + + +class TestPointGuardAIGuardrailApplyGuardrail: + """Tests for the unified apply_guardrail method.""" + + @pytest.mark.parametrize( + ("request_data", "expected"), + [ + ( + {"input": "Hello from the Responses API"}, + [{"role": "user", "content": "Hello from the Responses API"}], + ), + ( + {"input": [{"role": "user", "content": "Structured input"}]}, + [{"role": "user", "content": "Structured input"}], + ), + ], + ) + def test_get_input_messages_from_responses_api_request(self, request_data, expected): + """Responses API input should be usable without a metadata copy.""" + guardrail = _pointguard_guardrail( + api_key="test_key", + api_base="https://api.appsoc.com", + org_code="test-org", + policy_config_name="test-policy", + ) + + assert guardrail._get_input_messages_from_request_data(request_data) == expected + + def test_get_input_messages_uses_only_latest_conversation_message(self): + guardrail = _pointguard_guardrail( + api_key="test_key", + api_base="https://api.appsoc.com", + org_code="test-org", + policy_config_name="test-policy", + ) + + request_data = { + "messages": [ + {"role": "user", "content": "First input"}, + {"role": "assistant", "content": "First output"}, + {"role": "user", "content": "Second input"}, + ] + } + + assert guardrail._get_input_messages_from_request_data(request_data) == [ + {"role": "user", "content": "Second input"} + ] + + @pytest.mark.parametrize( + ("skip_system", "expected"), + [ + ( + False, + [ + {"role": "system", "content": "System prompt"}, + {"role": "user", "content": "Second input"}, + ], + ), + (True, [{"role": "user", "content": "Second input"}]), + ], + ) + def test_get_input_messages_includes_system_prompt_when_enabled(self, skip_system, expected): + guardrail = _pointguard_guardrail( + api_key="test_key", + api_base="https://api.appsoc.com", + org_code="test-org", + policy_config_name="test-policy", + ) + guardrail.skip_system_message_in_guardrail = skip_system + request_data = { + "messages": [ + {"role": "system", "content": "System prompt"}, + {"role": "user", "content": "First input"}, + {"role": "assistant", "content": "First output"}, + {"role": "user", "content": "Second input"}, + ] + } + + assert guardrail._get_input_messages_from_request_data(request_data) == expected + + @pytest.mark.asyncio + async def test_apply_guardrail_request_no_violations(self): + """Test apply_guardrail for request input with no violations.""" + guardrail = _pointguard_guardrail( + api_key="test_key", + api_base="https://api.appsoc.com", + org_code="test-org", + policy_config_name="test-policy", + guardrail_name="pointguardai-guard", + ) + + # Mock API response + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "policyName": "test-policy", + "input": {"blocked": False, "modified": False, "content": []}, + } + mock_response.raise_for_status = MagicMock() + guardrail.async_handler.post = AsyncMock(return_value=mock_response) + + inputs = GenericGuardrailAPIInputs( + texts=["Hello, world!"], + structured_messages=[{"role": "user", "content": "Hello, world!"}], + ) + + request_data = { + "messages": [{"role": "user", "content": "Hello, world!"}], + "metadata": {"existing": "value"}, + } + + result = await guardrail.apply_guardrail( + inputs=inputs, + request_data=request_data, + input_type="request", + logging_obj=None, + ) + + assert result == inputs # No modifications + assert "_pointguardai_input_messages" not in request_data["metadata"] + + @pytest.mark.asyncio + async def test_apply_guardrail_request_sends_system_and_current_turn_messages(self): + guardrail = _pointguard_guardrail( + api_key="test_key", + api_base="https://api.appsoc.com", + org_code="test-org", + policy_config_name="test-policy", + guardrail_name="pointguardai-guard", + ) + guardrail.skip_system_message_in_guardrail = False + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "policyName": "test-policy", + "input": {"blocked": False, "modified": False, "content": []}, + } + mock_response.raise_for_status = MagicMock() + guardrail.async_handler.post = AsyncMock(return_value=mock_response) + messages = [ + {"role": "system", "content": "System prompt"}, + {"role": "user", "content": "Previous input"}, + {"role": "assistant", "content": "Previous output"}, + {"role": "user", "content": "Current input part one"}, + {"role": "user", "content": "Current input part two"}, + ] + + await guardrail.apply_guardrail( + inputs=GenericGuardrailAPIInputs( + texts=[ + "System prompt", + "Previous input", + "Previous output", + "Current input part one", + "Current input part two", + ], + structured_messages=messages, # pyright: ignore[reportArgumentType] # fixture uses the equivalent message dictionary shape + ), + request_data={"messages": messages}, + input_type="request", + logging_obj=None, + ) + + sent_body = json.loads(guardrail.async_handler.post.call_args.kwargs["data"]) + assert sent_body["input"] == [ + {"role": "system", "content": "System prompt"}, + {"role": "user", "content": "Current input part one"}, + {"role": "user", "content": "Current input part two"}, + ] + + @pytest.mark.asyncio + async def test_apply_guardrail_request_scan_only_tool_results(self): + guardrail = _pointguard_guardrail( + api_key="test_key", + api_base="https://api.appsoc.com", + org_code="test-org", + policy_config_name="test-policy", + guardrail_name="pointguardai-guard", + ) + guardrail.scan_only_tool_results = True + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "policyName": "test-policy", + "input": {"blocked": False, "modified": False, "content": []}, + } + mock_response.raise_for_status = MagicMock() + guardrail.async_handler.post = AsyncMock(return_value=mock_response) + messages = [ + {"role": "system", "content": "System prompt"}, + {"role": "user", "content": "User prompt"}, + {"role": "assistant", "content": "Calling a tool"}, + {"role": "tool", "content": "Tool result"}, + ] + + await guardrail.apply_guardrail( + inputs=GenericGuardrailAPIInputs( + texts=["System prompt", "User prompt", "Calling a tool", "Tool result"], + structured_messages=messages, # pyright: ignore[reportArgumentType] # fixture uses the equivalent message dictionary shape + tools=[ + { + "type": "function", + "function": { + "name": "lookup", + "description": "Tool definition must not be inspected", + }, + } + ], + ), + request_data={"messages": messages}, + input_type="request", + logging_obj=None, + ) + + sent_body = json.loads(guardrail.async_handler.post.call_args.kwargs["data"]) + assert sent_body["input"] == [{"role": "tool", "content": "Tool result"}] + + @pytest.mark.asyncio + async def test_apply_guardrail_request_fallback_includes_system_and_latest_input(self): + guardrail = _pointguard_guardrail( + api_key="test_key", + api_base="https://api.appsoc.com", + org_code="test-org", + policy_config_name="test-policy", + guardrail_name="pointguardai-guard", + ) + guardrail.skip_system_message_in_guardrail = False + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "policyName": "test-policy", + "input": {"blocked": False, "modified": False, "content": []}, + } + mock_response.raise_for_status = MagicMock() + guardrail.async_handler.post = AsyncMock(return_value=mock_response) + + await guardrail.apply_guardrail( + inputs=GenericGuardrailAPIInputs( + texts=["First input", "First output", "Second input"], + structured_messages=[ + {"role": "system", "content": "System prompt"}, + {"role": "user", "content": "First input"}, + {"role": "assistant", "content": "First output"}, + {"role": "user", "content": "Second input"}, + ], + ), + request_data={}, + input_type="request", + logging_obj=None, + ) + + sent_body = json.loads(guardrail.async_handler.post.call_args.kwargs["data"]) + assert sent_body["input"] == [ + {"role": "system", "content": "System prompt"}, + {"role": "user", "content": "Second input"}, + ] + + @pytest.mark.asyncio + async def test_apply_guardrail_request_blocked(self): + """Test apply_guardrail for request that gets blocked.""" + guardrail = _pointguard_guardrail( + api_key="test_key", + api_base="https://api.appsoc.com", + org_code="test-org", + policy_config_name="test-policy", + guardrail_name="pointguardai-guard", + ) + + # Mock blocked response + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "policyName": "test-policy", + "input": { + "blocked": True, + "modified": False, + "content": [ + { + "aiViolations": [ + { + "name": "prompt-injection", + "aiThreatCategoryId": "threat-1", + "type": "PROMPT_INJECTION", + "action": "BLOCK", + } + ] + } + ], + }, + } + mock_response.raise_for_status = MagicMock() + guardrail.async_handler.post = AsyncMock(return_value=mock_response) + + inputs = GenericGuardrailAPIInputs( + texts=["Bad content"], + structured_messages=[{"role": "user", "content": "Bad content"}], + ) + + with pytest.raises(GuardrailRaisedException) as exc_info: + await guardrail.apply_guardrail( + inputs=inputs, + request_data={"messages": [{"role": "user", "content": "Bad content"}]}, + input_type="request", + logging_obj=None, + ) + + assert exc_info.value.status_code == 400 + assert exc_info.value.blocked_content is True + assert exc_info.value.guardrail_name == "pointguardai-guard" + + @pytest.mark.asyncio + async def test_apply_guardrail_request_modified(self): + """Test apply_guardrail for request with content modification.""" + guardrail = _pointguard_guardrail( + api_key="test_key", + api_base="https://api.appsoc.com", + org_code="test-org", + policy_config_name="test-policy", + guardrail_name="pointguardai-guard", + ) + + # Mock modified response + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "policyName": "test-policy", + "input": { + "blocked": False, + "modified": True, + "content": [ + { + "originalContent": "My SSN is 123-45-6789", + "modifiedContent": "My SSN is [REDACTED]", + } + ], + }, + } + mock_response.raise_for_status = MagicMock() + guardrail.async_handler.post = AsyncMock(return_value=mock_response) + + messages = [ + {"role": "user", "content": "Previous input"}, + {"role": "assistant", "content": "Previous output"}, + {"role": "user", "content": "My SSN is 123-45-6789"}, + {"role": "user", "content": "Current clean input"}, + ] + inputs = GenericGuardrailAPIInputs( + texts=[ + "Previous input", + "Previous output", + "My SSN is 123-45-6789", + "Current clean input", + ], + structured_messages=messages, # pyright: ignore[reportArgumentType] # fixture uses the equivalent message dictionary shape + ) + + result = await guardrail.apply_guardrail( + inputs=inputs, + request_data={"messages": messages}, + input_type="request", + logging_obj=None, + ) + + # Content should be modified + assert result["structured_messages"][0]["content"] == "Previous input" + assert result["structured_messages"][2]["content"] == "My SSN is [REDACTED]" + assert result["structured_messages"][3]["content"] == "Current clean input" + + @pytest.mark.asyncio + async def test_apply_guardrail_request_redacts_duplicate_responses_api_texts(self): + guardrail = _pointguard_guardrail( + api_key="test_key", + api_base="https://api.appsoc.com", + org_code="test-org", + policy_config_name="test-policy", + ) + duplicate_text = "Contact test@example.com" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "policyName": "test-policy", + "input": { + "blocked": False, + "modified": True, + "content": [ + { + "originalContent": "test@example.com", + "modifiedContent": "[EMAIL_REDACTED]", + } + ], + }, + } + mock_response.raise_for_status = MagicMock() + guardrail.async_handler.post = AsyncMock(return_value=mock_response) + + result = await guardrail.apply_guardrail( + inputs=GenericGuardrailAPIInputs(texts=[duplicate_text, duplicate_text]), + request_data={ + "input": [ + {"role": "user", "content": duplicate_text}, + {"role": "user", "content": duplicate_text}, + ] + }, + input_type="request", + logging_obj=None, + ) + + assert result["texts"] == [ + "Contact [EMAIL_REDACTED]", + "Contact [EMAIL_REDACTED]", + ] + + @pytest.mark.asyncio + async def test_apply_guardrail_request_modifies_multimodal_text_safely(self): + """Text blocks are modified without touching images or null message content.""" + guardrail = _pointguard_guardrail( + api_key="test_key", + api_base="https://api.appsoc.com", + org_code="test-org", + policy_config_name="test-policy", + guardrail_name="pointguardai-guard", + ) + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "policyName": "test-policy", + "input": { + "blocked": False, + "modified": True, + "content": [ + { + "originalContent": "test@example.com", + "modifiedContent": "[EMAIL_REDACTED]", + } + ], + }, + } + mock_response.raise_for_status = MagicMock() + guardrail.async_handler.post = AsyncMock(return_value=mock_response) + + image_block = { + "type": "image_url", + "image_url": {"url": "data:image/png;base64,unchanged-image"}, + } + messages = [ + {"role": "assistant", "content": None}, + { + "role": "user", + "content": [ + {"type": "text", "text": "Contact test@example.com"}, + image_block, + ], + }, + ] + inputs = GenericGuardrailAPIInputs( + texts=["Contact test@example.com"], + structured_messages=messages, # pyright: ignore[reportArgumentType] # fixture covers mixed multimodal message dictionaries + images=["https://example.com/unchanged.png"], + model="test-model", + stream_holdback_chars=[4], + ) + + result = await guardrail.apply_guardrail( + inputs=inputs, + request_data={"messages": messages}, + input_type="request", + logging_obj=None, + ) + + assert result["structured_messages"][0]["content"] is None + assert result["structured_messages"][1]["content"] == [ + {"type": "text", "text": "Contact [EMAIL_REDACTED]"}, + image_block, + ] + assert result["texts"] == ["Contact [EMAIL_REDACTED]"] + assert result["images"] == ["https://example.com/unchanged.png"] + assert result["model"] == "test-model" + assert result["stream_holdback_chars"] == [4] + + @pytest.mark.asyncio + async def test_apply_guardrail_request_inspects_and_redacts_tool_definitions(self): + guardrail = _pointguard_guardrail( + api_key="test_key", + api_base="https://api.appsoc.com", + org_code="test-org", + policy_config_name="test-policy", + ) + original_tool = { + "type": "function", + "function": { + "name": "send_contact", + "description": "Send test@example.com", + "parameters": {"type": "object"}, + }, + } + modified_tool = { + "type": "function", + "function": { + "name": "send_contact", + "description": "Send [EMAIL_REDACTED]", + "parameters": {"type": "object"}, + }, + } + original_content = json.dumps(original_tool, sort_keys=True, separators=(",", ":")) + modified_content = json.dumps(modified_tool, sort_keys=True, separators=(",", ":")) + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "policyName": "test-policy", + "input": { + "blocked": False, + "modified": True, + "content": [ + {"originalContent": "Use the contact tool"}, + { + "role": "tool", + "originalContent": original_content, + "modifiedContent": modified_content, + }, + ], + }, + } + mock_response.raise_for_status = MagicMock() + guardrail.async_handler.post = AsyncMock(return_value=mock_response) + inputs = GenericGuardrailAPIInputs( + texts=["Use the contact tool"], + structured_messages=[{"role": "user", "content": "Use the contact tool"}], + tools=[original_tool], # pyright: ignore[reportArgumentType] # fixture uses the equivalent tool dictionary shape + ) + + result = await guardrail.apply_guardrail( + inputs=inputs, + request_data={"messages": [{"role": "user", "content": "Use the contact tool"}]}, + input_type="request", + logging_obj=None, + ) + + sent_body = json.loads(guardrail.async_handler.post.call_args.kwargs["data"]) + assert sent_body["input"][-1] == {"role": "tool", "content": original_content} + assert result["tools"] == [modified_tool] + + @pytest.mark.asyncio + async def test_apply_guardrail_response_no_violations(self): + """Test apply_guardrail for response with no violations.""" + guardrail = _pointguard_guardrail( + api_key="test_key", + api_base="https://api.appsoc.com", + org_code="test-org", + policy_config_name="test-policy", + guardrail_name="pointguardai-guard", + ) + + # Mock API response + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "policyName": "test-policy", + "input": {"blocked": False, "modified": False, "content": []}, + "output": {"blocked": False, "modified": False, "content": []}, + } + mock_response.raise_for_status = MagicMock() + guardrail.async_handler.post = AsyncMock(return_value=mock_response) + + inputs = GenericGuardrailAPIInputs( + texts=["I'm doing well, thanks!"], + ) + + input_messages = [ + {"role": "user", "content": "First input"}, + {"role": "assistant", "content": "First output"}, + {"role": "user", "content": "How are you?"}, + ] + request_data = { + "messages": input_messages, + "metadata": {"existing": "value"}, + } + + result = await guardrail.apply_guardrail( + inputs=inputs, + request_data=request_data, + input_type="response", + logging_obj=None, + ) + + assert result == inputs + sent_body = json.loads(guardrail.async_handler.post.call_args.kwargs["data"]) + assert sent_body["input"] == [{"role": "user", "content": "How are you?"}] + assert "_pointguardai_input_messages" not in request_data["metadata"] + + @pytest.mark.asyncio + async def test_streaming_output_modifications_are_emitted(self, monkeypatch): + monkeypatch.setattr( + unified_module, + "endpoint_guardrail_translation_mappings", + {CallTypes.acompletion: OpenAIChatCompletionsHandler}, + ) + guardrail = _pointguard_guardrail( + api_key="test_key", + api_base="https://api.appsoc.com", + org_code="test-org", + policy_config_name="test-policy", + guardrail_name="pointguardai-guard", + event_hook=GuardrailEventHooks.post_call, + ) + original = "Contact me at test@example.com" + redacted = "Contact me at [EMAIL_REDACTED]" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "policyName": "test-policy", + "input": {"blocked": False, "modified": False, "content": []}, + "output": { + "blocked": False, + "modified": True, + "content": [{"originalContent": original, "modifiedContent": redacted}], + }, + } + mock_response.raise_for_status = MagicMock() + guardrail.async_handler.post = AsyncMock(return_value=mock_response) + + async def response_stream(): + for content, finish_reason in ( + ("Contact me at test", None), + ("@example.com", None), + ("", "stop"), + ): + yield ModelResponseStream( + choices=[ + StreamingChoices( + index=0, + delta=Delta(content=content, role="assistant"), + finish_reason=finish_reason, + ) + ] + ) + + emitted = [] + async for item in UnifiedLLMGuardrails().async_post_call_streaming_iterator_hook( + user_api_key_dict=UserAPIKeyAuth(api_key="test-key", request_route="/v1/chat/completions"), + response=response_stream(), + request_data={ + "guardrail_to_apply": guardrail, + "guardrails": ["pointguardai-guard"], + "model": "test-model", + "messages": [{"role": "user", "content": "Provide a contact address"}], + }, + ): + emitted.append(item) + + streamed = "".join(item.choices[0].delta.content or "" for item in emitted if item.choices) + assert streamed == redacted + guardrail.async_handler.post.assert_awaited_once() + + @pytest.mark.asyncio + async def test_apply_guardrail_response_ignores_input_modifications(self): + guardrail = _pointguard_guardrail( + api_key="test_key", + api_base="https://api.appsoc.com", + org_code="test-org", + policy_config_name="test-policy", + ) + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "policyName": "test-policy", + "input": { + "blocked": False, + "modified": True, + "content": [ + { + "originalContent": "same text", + "modifiedContent": "modified input", + } + ], + }, + "output": {"blocked": False, "modified": False, "content": []}, + } + mock_response.raise_for_status = MagicMock() + guardrail.async_handler.post = AsyncMock(return_value=mock_response) + inputs = GenericGuardrailAPIInputs(texts=["same text"]) + + result = await guardrail.apply_guardrail( + inputs=inputs, + request_data={"messages": [{"role": "user", "content": "same text"}]}, + input_type="response", + logging_obj=None, + ) + + assert result["texts"] == ["same text"] + + @pytest.mark.asyncio + async def test_apply_guardrail_response_rejects_unmatched_output_modification(self): + guardrail = _pointguard_guardrail( + api_key="test_key", + api_base="https://api.appsoc.com", + org_code="test-org", + policy_config_name="test-policy", + unreachable_fallback="fail_closed", + ) + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "policyName": "test-policy", + "input": {"blocked": False, "modified": False, "content": []}, + "output": { + "blocked": False, + "modified": True, + "content": [ + { + "originalContent": "content not present in the response", + "modifiedContent": "replacement", + } + ], + }, + } + mock_response.raise_for_status = MagicMock() + guardrail.async_handler.post = AsyncMock(return_value=mock_response) + + with pytest.raises(HTTPException) as exc_info: + await guardrail.apply_guardrail( + inputs=GenericGuardrailAPIInputs(texts=["actual model response"]), + request_data={"messages": [{"role": "user", "content": "question"}]}, + input_type="response", + logging_obj=None, + ) + + assert exc_info.value.status_code == 502 + + @pytest.mark.asyncio + async def test_output_inspection_honors_message_role_filters(self): + guardrail = _pointguard_guardrail( + api_key="test_key", + api_base="https://api.appsoc.com", + org_code="test-org", + policy_config_name="test-policy", + ) + guardrail.skip_system_message_in_guardrail = True + guardrail.skip_tool_message_in_guardrail = True + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "policyName": "test-policy", + "input": {"blocked": False, "modified": False, "content": []}, + "output": {"blocked": False, "modified": False, "content": []}, + } + mock_response.raise_for_status = MagicMock() + guardrail.async_handler.post = AsyncMock(return_value=mock_response) + + await guardrail.apply_guardrail( + inputs=GenericGuardrailAPIInputs(texts=["answer"]), + request_data={ + "messages": [ + {"role": "system", "content": "system instructions"}, + {"role": "user", "content": "question"}, + {"role": "tool", "content": "tool result"}, + ] + }, + input_type="response", + logging_obj=None, + ) + + sent_body = json.loads(guardrail.async_handler.post.call_args.kwargs["data"]) + assert sent_body["input"] == [{"role": "user", "content": "question"}] + + @pytest.mark.asyncio + async def test_apply_guardrail_response_blocked(self): + """Test apply_guardrail for response that gets blocked.""" + guardrail = _pointguard_guardrail( + api_key="test_key", + api_base="https://api.appsoc.com", + org_code="test-org", + policy_config_name="test-policy", + guardrail_name="pointguardai-guard", + ) + + # Mock blocked response + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "policyName": "test-policy", + "input": {"blocked": False, "modified": False, "content": []}, + "output": { + "blocked": True, + "modified": False, + "content": [ + { + "aiViolations": [ + { + "name": "policy-violation", + "aiThreatCategoryId": "threat-2", + "type": "SENSITIVE_OUTPUT", + "action": "BLOCK", + } + ] + } + ], + }, + } + mock_response.raise_for_status = MagicMock() + guardrail.async_handler.post = AsyncMock(return_value=mock_response) + + inputs = GenericGuardrailAPIInputs( + texts=["Sensitive response"], + ) + + with pytest.raises(GuardrailRaisedException) as exc_info: + await guardrail.apply_guardrail( + inputs=inputs, + request_data={}, + input_type="response", + logging_obj=None, + ) + + assert exc_info.value.status_code == 400 + assert exc_info.value.blocked_content is True + + @pytest.mark.asyncio + async def test_apply_guardrail_response_modified(self): + """Test apply_guardrail for response with content modification.""" + guardrail = _pointguard_guardrail( + api_key="test_key", + api_base="https://api.appsoc.com", + org_code="test-org", + policy_config_name="test-policy", + guardrail_name="pointguardai-guard", + ) + + # Mock modified response + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "policyName": "test-policy", + "input": {"blocked": False, "modified": False, "content": []}, + "output": { + "blocked": False, + "modified": True, + "content": [ + { + "originalContent": "Contact me at test@example.com", + "modifiedContent": "Contact me at [EMAIL_REDACTED]", + } + ], + }, + } + mock_response.raise_for_status = MagicMock() + guardrail.async_handler.post = AsyncMock(return_value=mock_response) + + inputs = GenericGuardrailAPIInputs( + texts=["Contact me at test@example.com"], + images=["https://example.com/unchanged.png"], + model="test-model", + stream_holdback_chars=[7], + ) + + result = await guardrail.apply_guardrail( + inputs=inputs, + request_data={}, + input_type="response", + logging_obj=None, + ) + + # Content should be modified + assert result["texts"][0] == "Contact me at [EMAIL_REDACTED]" + assert result["images"] == ["https://example.com/unchanged.png"] + assert result["model"] == "test-model" + assert result["stream_holdback_chars"] == [7] + sent_body = json.loads(guardrail.async_handler.post.call_args.kwargs["data"]) + assert sent_body["input"] == [] + assert sent_body["output"] == [ + { + "role": "assistant", + "content": "Contact me at test@example.com", + } + ] + + @pytest.mark.asyncio + async def test_apply_guardrail_inspects_all_output_texts_and_preserves_positions(self): + guardrail = _pointguard_guardrail( + api_key="test_key", + api_base="https://api.appsoc.com", + org_code="test-org", + policy_config_name="test-policy", + guardrail_name="pointguardai-guard", + ) + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "policyName": "test-policy", + "input": {"blocked": False, "modified": False, "content": []}, + "output": { + "blocked": False, + "modified": True, + "content": [ + { + "originalContent": "First: first@example.com", + "modifiedContent": "First: [FIRST_EMAIL]", + }, + {"originalContent": "Second response is clean"}, + ], + }, + } + mock_response.raise_for_status = MagicMock() + guardrail.async_handler.post = AsyncMock(return_value=mock_response) + inputs = GenericGuardrailAPIInputs( + texts=[ + "First: first@example.com", + "Second response is clean", + ], + model="test-model", + ) + + result = await guardrail.apply_guardrail( + inputs=inputs, + request_data={"messages": [{"role": "user", "content": "List contacts"}]}, + input_type="response", + logging_obj=None, + ) + + sent_body = json.loads(guardrail.async_handler.post.call_args.kwargs["data"]) + assert sent_body["output"] == [ + {"role": "assistant", "content": "First: first@example.com"}, + {"role": "assistant", "content": "Second response is clean"}, + ] + assert result["texts"] == [ + "First: [FIRST_EMAIL]", + "Second response is clean", + ] + assert result["model"] == "test-model" + + @pytest.mark.asyncio + async def test_apply_guardrail_inspects_and_redacts_tool_call_only_response(self): + guardrail = _pointguard_guardrail( + api_key="test_key", + api_base="https://api.appsoc.com", + org_code="test-org", + policy_config_name="test-policy", + ) + original_tool_call = { + "id": "call-1", + "type": "function", + "index": 0, + "function": { + "name": "send_contact", + "arguments": '{"email":"test@example.com"}', + }, + } + modified_tool_call = { + "id": "call-1", + "type": "function", + "index": 0, + "function": { + "name": "send_contact", + "arguments": '{"email":"[EMAIL_REDACTED]"}', + }, + } + original_content = json.dumps(original_tool_call, sort_keys=True, separators=(",", ":")) + modified_content = json.dumps(modified_tool_call, sort_keys=True, separators=(",", ":")) + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "policyName": "test-policy", + "input": {"blocked": False, "modified": False, "content": []}, + "output": { + "blocked": False, + "modified": True, + "content": [ + { + "role": "assistant", + "originalContent": original_content, + "modifiedContent": modified_content, + } + ], + }, + } + mock_response.raise_for_status = MagicMock() + guardrail.async_handler.post = AsyncMock(return_value=mock_response) + + result = await guardrail.apply_guardrail( + inputs=GenericGuardrailAPIInputs( + texts=[], + tool_calls=[original_tool_call], # pyright: ignore[reportArgumentType] # fixture uses the equivalent tool-call dictionary shape + ), + request_data={"messages": [{"role": "user", "content": "Send the contact"}]}, + input_type="response", + logging_obj=None, + ) + + sent_body = json.loads(guardrail.async_handler.post.call_args.kwargs["data"]) + assert sent_body["output"] == [{"role": "assistant", "content": original_content}] + assert result["tool_calls"] == [modified_tool_call] + + @pytest.mark.asyncio + async def test_apply_guardrail_extracts_messages_from_request_data(self): + """Test that apply_guardrail extracts messages from request_data when not in inputs.""" + guardrail = _pointguard_guardrail( + api_key="test_key", + api_base="https://api.appsoc.com", + org_code="test-org", + policy_config_name="test-policy", + guardrail_name="pointguardai-guard", + ) + + # Mock API response + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "policyName": "test-policy", + "input": {"blocked": False, "modified": False, "content": []}, + } + mock_response.raise_for_status = MagicMock() + guardrail.async_handler.post = AsyncMock(return_value=mock_response) + + # No structured_messages in inputs + inputs = GenericGuardrailAPIInputs( + texts=["Hello"], + ) + + await guardrail.apply_guardrail( + inputs=inputs, + request_data={"messages": [{"role": "user", "content": "Hello"}]}, + input_type="request", + logging_obj=None, + ) + + # Should have called the API with transformed messages + guardrail.async_handler.post.assert_called_once() + + +class TestPointGuardAIGuardrailErrorHandling: + """Tests for error handling.""" + + @pytest.mark.asyncio + async def test_handle_http_status_error_401(self): + """Test handling of 401 authentication error.""" + guardrail = _pointguard_guardrail( + api_key="test_key", + api_base="https://api.appsoc.com", + org_code="test-org", + policy_config_name="test-policy", + ) + + # Mock 401 error + mock_response = MagicMock() + mock_response.status_code = 401 + mock_response.text = "Unauthorized" + + def raise_status_error(): + raise Exception("HTTP 401") + + mock_response.raise_for_status = raise_status_error + + import httpx + + error = httpx.HTTPStatusError("401 error", request=MagicMock(), response=mock_response) + + with pytest.raises(HTTPException) as exc_info: + guardrail._handle_http_status_error(error) + + assert exc_info.value.status_code == 401 + assert "authentication failed" in str(exc_info.value.detail).lower() + + @pytest.mark.asyncio + async def test_handle_network_timeout(self): + """Test handling of timeout error.""" + guardrail = _pointguard_guardrail( + api_key="test_key", + api_base="https://api.appsoc.com", + org_code="test-org", + policy_config_name="test-policy", + ) + + import httpx + + timeout_error = httpx.TimeoutException("Request timeout") + + with pytest.raises(HTTPException) as exc_info: + guardrail._handle_network_errors(timeout_error) + + assert exc_info.value.status_code == 504 + assert "timeout" in str(exc_info.value.detail).lower() + + @pytest.mark.asyncio + async def test_handle_connection_error(self): + """Test handling of connection error.""" + guardrail = _pointguard_guardrail( + api_key="test_key", + api_base="https://api.appsoc.com", + org_code="test-org", + policy_config_name="test-policy", + ) + + import httpx + + conn_error = httpx.ConnectError("Connection refused") + + with pytest.raises(HTTPException) as exc_info: + guardrail._handle_network_errors(conn_error) + + assert exc_info.value.status_code == 503 + assert "unavailable" in str(exc_info.value.detail).lower() + + @pytest.mark.asyncio + async def test_explicit_fail_open_returns_all_inputs(self): + """Explicit fail-open should preserve every field when PointGuard is unreachable.""" + guardrail = _pointguard_guardrail( + api_key="test_key", + api_base="https://api.appsoc.com", + org_code="test-org", + policy_config_name="test-policy", + guardrail_name="pointguardai-guard", + unreachable_fallback="fail_open", + ) + guardrail.async_handler.post = AsyncMock(side_effect=httpx.ConnectError("Connection refused")) + inputs = GenericGuardrailAPIInputs( + texts=["Hello"], + structured_messages=[{"role": "user", "content": "Hello"}], + images=["https://example.com/image.png"], + ) + + result = await guardrail.apply_guardrail( + inputs=inputs, + request_data={"messages": inputs["structured_messages"]}, + input_type="request", + logging_obj=MagicMock( + litellm_call_id="call-123", + litellm_trace_id="trace-456", + ), + ) + + assert result == inputs + assert result is not inputs + + @pytest.mark.asyncio + async def test_explicit_fail_closed_blocks_on_connection_errors(self): + """Explicit fail-closed should block when PointGuard is unavailable.""" + guardrail = _pointguard_guardrail( + api_key="test_key", + api_base="https://api.appsoc.com", + org_code="test-org", + policy_config_name="test-policy", + guardrail_name="pointguardai-guard", + unreachable_fallback="fail_closed", + ) + guardrail.async_handler.post = AsyncMock(side_effect=httpx.ConnectError("Connection refused")) + + with pytest.raises(HTTPException) as exc_info: + await guardrail.apply_guardrail( + inputs=GenericGuardrailAPIInputs( + texts=["Hello"], + structured_messages=[{"role": "user", "content": "Hello"}], + ), + request_data={"messages": [{"role": "user", "content": "Hello"}]}, + input_type="request", + logging_obj=None, + ) + + assert exc_info.value.status_code == 503 + + @pytest.mark.asyncio + async def test_explicit_fail_open_returns_inputs_on_provider_500(self): + guardrail = _pointguard_guardrail( + api_key="test_key", + api_base="https://api.appsoc.com", + org_code="test-org", + policy_config_name="test-policy", + guardrail_name="pointguardai-guard", + unreachable_fallback="fail_open", + ) + mock_response = MagicMock() + mock_response.status_code = 500 + mock_response.raise_for_status.side_effect = httpx.HTTPStatusError( + "PointGuard internal error", + request=MagicMock(), + response=mock_response, + ) + guardrail.async_handler.post = AsyncMock(return_value=mock_response) + inputs = GenericGuardrailAPIInputs( + texts=["Hello"], + structured_messages=[{"role": "user", "content": "Hello"}], + ) + + result = await guardrail.apply_guardrail( + inputs=inputs, + request_data={"messages": inputs["structured_messages"]}, + input_type="request", + logging_obj=None, + ) + + assert result == inputs + + @pytest.mark.asyncio + async def test_explicit_fail_closed_blocks_on_provider_500(self): + guardrail = _pointguard_guardrail( + api_key="test_key", + api_base="https://api.appsoc.com", + org_code="test-org", + policy_config_name="test-policy", + guardrail_name="pointguardai-guard", + unreachable_fallback="fail_closed", + ) + mock_response = MagicMock() + mock_response.status_code = 500 + mock_response.raise_for_status.side_effect = httpx.HTTPStatusError( + "PointGuard internal error", + request=MagicMock(), + response=mock_response, + ) + guardrail.async_handler.post = AsyncMock(return_value=mock_response) + + with pytest.raises(HTTPException) as exc_info: + await guardrail.apply_guardrail( + inputs=GenericGuardrailAPIInputs( + texts=["Hello"], + structured_messages=[{"role": "user", "content": "Hello"}], + ), + request_data={"messages": [{"role": "user", "content": "Hello"}]}, + input_type="request", + logging_obj=None, + ) + + assert exc_info.value.status_code == 500 + + @pytest.mark.asyncio + async def test_malformed_success_response_fails_closed_despite_fail_open(self): + guardrail = _pointguard_guardrail( + api_key="test_key", + api_base="https://api.appsoc.com", + org_code="test-org", + policy_config_name="test-policy", + ) + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {} + mock_response.raise_for_status = MagicMock() + guardrail.async_handler.post = AsyncMock(return_value=mock_response) + inputs = GenericGuardrailAPIInputs( + texts=["Hello"], + structured_messages=[{"role": "user", "content": "Hello"}], + ) + + with pytest.raises(HTTPException) as exc_info: + await guardrail.apply_guardrail( + inputs=inputs, + request_data={"messages": inputs["structured_messages"]}, + input_type="request", + logging_obj=None, + ) + + assert exc_info.value.status_code == 502 + + @pytest.mark.asyncio + async def test_malformed_success_response_follows_fail_closed(self): + guardrail = _pointguard_guardrail( + api_key="test_key", + api_base="https://api.appsoc.com", + org_code="test-org", + policy_config_name="test-policy", + unreachable_fallback="fail_closed", + ) + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {} + mock_response.raise_for_status = MagicMock() + guardrail.async_handler.post = AsyncMock(return_value=mock_response) + + with pytest.raises(HTTPException) as exc_info: + await guardrail.apply_guardrail( + inputs=GenericGuardrailAPIInputs( + texts=["Hello"], + structured_messages=[{"role": "user", "content": "Hello"}], + ), + request_data={"messages": [{"role": "user", "content": "Hello"}]}, + input_type="request", + logging_obj=None, + ) + + assert exc_info.value.status_code == 502 + + @pytest.mark.asyncio + async def test_output_success_response_requires_input_result(self): + guardrail = _pointguard_guardrail( + api_key="test_key", + api_base="https://api.appsoc.com", + org_code="test-org", + policy_config_name="test-policy", + unreachable_fallback="fail_closed", + ) + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "policyName": "test-policy", + "output": {"blocked": False, "modified": False, "content": []}, + } + mock_response.raise_for_status = MagicMock() + guardrail.async_handler.post = AsyncMock(return_value=mock_response) + + with pytest.raises(HTTPException) as exc_info: + await guardrail.apply_guardrail( + inputs=GenericGuardrailAPIInputs(texts=["model response"]), + request_data={"messages": [{"role": "user", "content": "question"}]}, + input_type="response", + logging_obj=None, + ) + + assert exc_info.value.status_code == 502 + + @pytest.mark.asyncio + async def test_fail_open_does_not_bypass_policy_block(self): + """Fail-open must apply only to unavailability, never policy decisions.""" + guardrail = _pointguard_guardrail( + api_key="test_key", + api_base="https://api.appsoc.com", + org_code="test-org", + policy_config_name="test-policy", + guardrail_name="pointguardai-guard", + unreachable_fallback="fail_open", + ) + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "policyName": "test-policy", + "input": { + "blocked": True, + "modified": False, + "content": [ + { + "aiViolations": [ + { + "name": "prompt-injection", + "aiThreatCategoryId": "threat-1", + "type": "PROMPT_INJECTION", + "action": "BLOCK", + } + ] + } + ], + }, + } + mock_response.raise_for_status = MagicMock() + guardrail.async_handler.post = AsyncMock(return_value=mock_response) + + with pytest.raises(GuardrailRaisedException) as exc_info: + await guardrail.apply_guardrail( + inputs=GenericGuardrailAPIInputs( + texts=["Bad content"], + structured_messages=[{"role": "user", "content": "Bad content"}], + ), + request_data={"messages": [{"role": "user", "content": "Bad content"}]}, + input_type="request", + logging_obj=None, + ) + + assert exc_info.value.status_code == 400 + assert exc_info.value.blocked_content is True + + +class TestPointGuardAIGuardrailShouldRun: + """Tests for should_run_guardrail method.""" + + def test_should_run_guardrail_with_guardrail_in_metadata(self): + """Test that guardrail runs when specified in metadata.""" + guardrail = _pointguard_guardrail( + api_key="test_key", + api_base="https://api.appsoc.com", + org_code="test-org", + policy_config_name="test-policy", + guardrail_name="pointguardai-guard", + event_hook=GuardrailEventHooks.pre_call, + ) + + data = { + "messages": [{"role": "user", "content": "test"}], + "metadata": {"guardrails": ["pointguardai-guard"]}, + } + + result = guardrail.should_run_guardrail(data=data, event_type=GuardrailEventHooks.pre_call) + + assert result is True + + def test_should_not_run_guardrail_when_not_in_metadata(self): + """Test that guardrail doesn't run when not specified in metadata.""" + guardrail = _pointguard_guardrail( + api_key="test_key", + api_base="https://api.appsoc.com", + org_code="test-org", + policy_config_name="test-policy", + guardrail_name="pointguardai-guard", + event_hook=GuardrailEventHooks.pre_call, + ) + + data = { + "messages": [{"role": "user", "content": "test"}], + "metadata": {"guardrails": ["other-guardrail"]}, + } + + result = guardrail.should_run_guardrail(data=data, event_type=GuardrailEventHooks.pre_call) + + assert result is False + + def test_should_run_guardrail_with_default_on(self): + """Test that guardrail runs when default_on is True.""" + guardrail = _pointguard_guardrail( + api_key="test_key", + api_base="https://api.appsoc.com", + org_code="test-org", + policy_config_name="test-policy", + guardrail_name="pointguardai-guard", + event_hook=GuardrailEventHooks.pre_call, + default_on=True, + ) + + data = { + "messages": [{"role": "user", "content": "test"}], + } + + result = guardrail.should_run_guardrail(data=data, event_type=GuardrailEventHooks.pre_call) + + assert result is True + + def test_should_run_guardrail_with_wrong_event_hook(self): + """Test that guardrail doesn't run with mismatched event hook.""" + guardrail = _pointguard_guardrail( + api_key="test_key", + api_base="https://api.appsoc.com", + org_code="test-org", + policy_config_name="test-policy", + guardrail_name="pointguardai-guard", + event_hook=GuardrailEventHooks.pre_call, + default_on=True, + ) + + data = { + "messages": [{"role": "user", "content": "test"}], + } + + result = guardrail.should_run_guardrail(data=data, event_type=GuardrailEventHooks.post_call) + + assert result is False + + +class TestPointGuardAIGuardrailConfigModel: + """Tests for PointGuardAIGuardrailConfigModel.""" + + def test_config_model_ui_friendly_name(self): + """Test that config model has correct UI friendly name.""" + from litellm.types.proxy.guardrails.guardrail_hooks.pointguardai import ( + PointGuardAIGuardrailConfigModel, + ) + + assert PointGuardAIGuardrailConfigModel.ui_friendly_name() == "PointGuard AI" + + def test_config_model_fields(self): + """Test that config model has expected fields for API.""" + from litellm.types.proxy.guardrails.guardrail_hooks.pointguardai import ( + PointGuardAIGuardrailConfigModel, + ) + + model = PointGuardAIGuardrailConfigModel() + + # Check default values are None + assert model.api_key is None + assert model.org_code is None + assert model.policy_config_name is None + assert model.unreachable_fallback == "fail_closed" + + def test_config_model_with_values(self): + """Test config model with provided values""" + from litellm.types.proxy.guardrails.guardrail_hooks.pointguardai import ( + PointGuardAIGuardrailConfigModel, + ) + + model = PointGuardAIGuardrailConfigModel( + api_key="test_key", + org_code="test-org", + policy_config_name="test-policy", + unreachable_fallback="fail_open", + ) + + assert model.api_key == "test_key" + assert model.org_code == "test-org" + assert model.policy_config_name == "test-policy" + assert model.unreachable_fallback == "fail_open" + + +class TestPointGuardAIGuardrailRegistry: + """Tests for guardrail registry integration.""" + + def test_pointguardai_in_supported_integrations(self): + """Test that POINTGUARDAI is in SupportedGuardrailIntegrations enum.""" + from litellm.types.guardrails import SupportedGuardrailIntegrations + + assert hasattr(SupportedGuardrailIntegrations, "POINTGUARDAI") + assert SupportedGuardrailIntegrations.POINTGUARDAI.value == "pointguard_ai" + + def test_initialize_guardrail_function_exists(self): + """Test that initialize_guardrail function is properly exported.""" + from litellm.proxy.guardrails.guardrail_hooks.pointguardai import ( + guardrail_initializer_registry, + initialize_guardrail, + ) + + assert initialize_guardrail is not None + assert "pointguard_ai" in guardrail_initializer_registry + + def test_initializer_uses_pointguard_fail_closed_default(self): + """PointGuard's YAML configuration should default to fail-closed.""" + from litellm.proxy.guardrails.guardrail_hooks.pointguardai import ( + initialize_guardrail, + ) + from litellm.types.guardrails import LitellmParams + + params = LitellmParams( + guardrail="pointguard_ai", + mode="pre_call", + api_key="test-key", + api_base="https://api.appsoc.com", + org_code="test-org", + policy_config_name="test-policy", + ) + + callback_manager = _RecordingCallbackManager() + initialized = initialize_guardrail( + params, + {"guardrail_name": "pointguardai-guard", "litellm_params": params}, + callback_manager=callback_manager, + ) + + assert initialized.unreachable_fallback == "fail_closed" + assert callback_manager.callback is initialized + + def test_initializer_preserves_explicit_fail_open(self): + """An explicit PointGuard fail-open setting should override its default.""" + from litellm.proxy.guardrails.guardrail_hooks.pointguardai import ( + initialize_guardrail, + ) + from litellm.types.guardrails import LitellmParams + + params = LitellmParams( + guardrail="pointguard_ai", + mode="pre_call", + api_key="test-key", + api_base="https://api.appsoc.com", + org_code="test-org", + policy_config_name="test-policy", + unreachable_fallback="fail_open", + ) + + callback_manager = _RecordingCallbackManager() + initialized = initialize_guardrail( + params, + {"guardrail_name": "pointguardai-guard", "litellm_params": params}, + callback_manager=callback_manager, + ) + + assert initialized.unreachable_fallback == "fail_open" + assert callback_manager.callback is initialized + + def test_initializer_resolves_pointguard_environment_references(self, monkeypatch): + from litellm.proxy.guardrails.guardrail_hooks.pointguardai import ( + initialize_guardrail, + ) + from litellm.types.guardrails import LitellmParams + + monkeypatch.setenv("POINTGUARDAI_TEST_ORG", "resolved-org") + monkeypatch.setenv("POINTGUARDAI_TEST_POLICY", "resolved-policy") + params = LitellmParams( + guardrail="pointguard_ai", + mode="pre_call", + api_key="test-key", + api_base="https://api.appsoc.com", + org_code="os.environ/POINTGUARDAI_TEST_ORG", + policy_config_name="os.environ/POINTGUARDAI_TEST_POLICY", + ) + + callback_manager = _RecordingCallbackManager() + initialized = initialize_guardrail( + params, + {"guardrail_name": "pointguardai-guard", "litellm_params": params}, + callback_manager=callback_manager, + ) + + assert initialized.pointguardai_org_code == "resolved-org" + assert initialized.pointguardai_policy_config_name == "resolved-policy" + assert callback_manager.callback is initialized + + def test_guardrail_class_registry_exists(self): + """Test that guardrail_class_registry is properly exported.""" + from litellm.proxy.guardrails.guardrail_hooks.pointguardai import ( + guardrail_class_registry, + ) + + assert "pointguard_ai" in guardrail_class_registry + assert guardrail_class_registry["pointguard_ai"] == PointGuardAIGuardrail + + def test_get_config_model_returns_correct_class(self): + """Test that get_config_model returns the correct config model class.""" + config_model = PointGuardAIGuardrail.get_config_model() + + from litellm.types.proxy.guardrails.guardrail_hooks.pointguardai import ( + PointGuardAIGuardrailConfigModel, + ) + + assert config_model == PointGuardAIGuardrailConfigModel + + def test_supported_event_hooks(self): + """Test that the integration advertises every documented execution mode.""" + assert PointGuardAIGuardrail.get_supported_event_hooks() == [ + GuardrailEventHooks.pre_call, + GuardrailEventHooks.post_call, + ] diff --git a/ui/litellm-dashboard/public/assets/logos/pointguardai.png b/ui/litellm-dashboard/public/assets/logos/pointguardai.png new file mode 100644 index 0000000000000000000000000000000000000000..907ecc945988882f7d0c2d79652f6ed045124cf6 GIT binary patch literal 13613 zcmX|I1yCGK)5hK1-QC^&aCZn6g6rYIJ-9<~2^O5-F2UX12^{Va^uN4c)ql0OHPZd` z(>+qNGrQ61s`98vL`V=25U2nJ8BGWX$UAVl1pyj-pKmmtf`EXAP*>KLRkn52fzV;c zab?4C`+x;kf`5T*IIdz40qo!s9dP!eTO49l1f^0F5_|_su8E+milTf3LVyzx5ZbFE z$l&rocx+X64rI6L&eLTop7C}%J-xRw>$g%$fB&OfpL6xqSLcy6p%E(_ui>mo?& zU@>erF!H7-%Bm0om<%JD6Ik&>6k=HzOc)5(TKgyC3Rd|~(se}`c})ZfoL&`1-V{Rt zBe3Iuxw)+fBmI-aaa$EZS`~u-5BVP%@C8mU@j)&MBd!V~ZHu9R#aRCVf|>sVUlxI0 z5kdy5@BTwW0y788|Cc~q5r+StBrA^nvIx?eFu3;rIfD`0!0O;@Qxthy6#0J)IdD9{ z>WhM4GXMDe=OfaJ5Yj($SZ-j~{)GkI`9Ht@SzH!E*%tesga6oqt^W@btPaN67ypkf zSPR<~Y?>3-`#%B1Em36f1up#0LD!vsPW`jEA^QL7$g9Ht#K3p37&|tQ71J5qreJse zN0{Zmc!IOwDsb?@F8qtzf1zf^asfkuOTcLC*zVv$FwXx3z;6CCitYlg0@GgmPm2xP z9oz?Y#lSM){^P=l?#I0{G-73>HnuGg_7 zDi^LdxJ8a6QNeKoJG&)@vLT9cAc3+cj&dl0@<#%d8`tkd67@_9^;R034!D#;eUe4x z#S7-e3sE;N0S^v{ZJ?&A76kO$D-)_c?eilO|5V-KDW8Gt~gyn{(bMTKA*%7dhAAy12fW{?~UQN~TVkQn?% z6ALLQ{1A}1U@&oDaW!0v%3FWGi6l&4rkKx_0>0P$f+f#?dA%MOhAWUV*lnq^&L8l zj6U4BmVX_A0CnhS)4wcgFThf27~c)g*RilA49jb_ecL7d>){%e*Ni@3w|`%h(V^{Uc49HLTK@u zFLd5}B{esGH~GatNv;%BBQtfS8G`N1?~zID)ku<~1ypa#$oPkSz0Owi`5A{9wn1XL zPu!%l)!R2+3dshxQGXEH%*G%kMHamrCRI#%MsH4M>_MKm7f}5PAae#TcD2kBo_X|f zHjwhx30k@c-p@zHsP;p~xQVlAcTzx@_Aad-G;y6@9nWUVe{1SD!ZO{Gb#)e$djAU3 z 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 QhY%0|Syh=DDYKCO17FV# = { mode: "pre_call", defaultOn: false, }, + pointguardai: { + provider: "PointguardAi", + guardrailNameSuggestion: "PointGuard AI", + mode: "pre_call", + defaultOn: false, + }, repelloai: { provider: "Repelloai", guardrailNameSuggestion: "RepelloAI Argus", diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_garden_data.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_garden_data.test.ts index 1e486639840..60e64c53937 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_garden_data.test.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_garden_data.test.ts @@ -25,6 +25,7 @@ const EXPECTED_PARTNER_LOGO_FILES: Record = { promptguard: "promptguard.svg", xecguard: "xecguard.svg", deepkeep: "deepkeep.svg", + pointguardai: "pointguardai.png", repelloai: "repelloai.png", straiker: "straiker.svg", alice: "alice.svg", diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_garden_data.ts b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_garden_data.ts index 931b3a111d8..fe9dacf2696 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_garden_data.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_garden_data.ts @@ -444,6 +444,16 @@ export const PARTNER_GUARDRAIL_CARDS: GuardrailCardInfo[] = [ tags: ["Security", "Prompt Injection", "PII", "Firewall"], providerKey: "Deepkeep", }, + { + id: "pointguardai", + name: "PointGuard AI", + description: + "PointGuard AI runtime guardrails inspect prompts and responses for prompt injection, sensitive data exposure, harmful content, and policy violations.", + category: "partner", + logo: guardrailLogoMap["PointGuard AI"], + tags: ["Security", "Prompt Injection", "Data Protection", "Policy"], + providerKey: "PointguardAi", + }, { id: "repelloai", name: "RepelloAI Argus", diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_info_helpers.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_info_helpers.tsx index c1f2ddcf51c..c3b4df037c6 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_info_helpers.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_info_helpers.tsx @@ -19,6 +19,7 @@ import openaiSmallLogo from "../../../../../public/assets/logos/openai_small.svg import paloAltoNetworksLogo from "../../../../../public/assets/logos/palo_alto_networks.jpeg"; import pangeaLogo from "../../../../../public/assets/logos/pangea.png"; import pillarLogo from "../../../../../public/assets/logos/pillar.jpeg"; +import pointguardAiLogo from "../../../../../public/assets/logos/pointguardai.png"; import promptSecurityLogo from "../../../../../public/assets/logos/prompt_security.png"; import promptguardLogo from "../../../../../public/assets/logos/promptguard.svg"; import qohashLogo from "../../../../../public/assets/logos/qohash.jpg"; @@ -82,6 +83,7 @@ export const guardrail_provider_map: Record = { LlmAsAJudge: "llm_as_a_judge", Xecguard: "xecguard", Deepkeep: "deepkeep", + PointguardAi: "pointguard_ai", QostodianNexus: "qostodian_nexus", Repelloai: "repelloai", Alice: "alice", @@ -203,6 +205,7 @@ export const guardrailLogoMap = { "LiteLLM LLM as a Judge": litellmLogo.src, Akto: aktoLogo.src, "DeepKeep AI Firewall": deepkeepLogo.src, + "PointGuard AI": pointguardAiLogo.src, "Qostodian Nexus": qohashLogo.src, "RepelloAI Argus": repelloAiLogo.src, Straiker: straikerLogo.src, diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 6f044fec3f3..c0e40ce2da1 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -30464,6 +30464,11 @@ export interface components { only_scan_new_messages: boolean | null; /** @description Optional parameters for the guardrail */ optional_params?: components["schemas"]["CiscoAIDefenseGuardrailConfigModelOptionalParams"] | null; + /** + * Org Code + * @description Organization code for PointGuardAI. + */ + org_code?: string | null; /** * Output Parse Pii * @description When True, LiteLLM will replace the masked text with the original text in the response @@ -30518,6 +30523,11 @@ export interface components { pii_entities_config?: { [key: string]: components["schemas"]["PiiAction"]; } | null; + /** + * Policy Config Name + * @description PointGuardAI policy configuration name. + */ + policy_config_name?: string | null; /** * Policy Id * @description Policy ID for Zscaler AI Guard. Can also be set via ZSCALER_AI_GUARD_POLICY_ID environment variable