From 9cf1abab2ff1f3294d4731ae8cab1ba3b5a432c0 Mon Sep 17 00:00:00 2001 From: Shubham-Kothari Date: Fri, 21 Aug 2026 19:10:12 -0400 Subject: [PATCH 1/9] feat(guardrails): add PointGuard AI integration Add PointGuard AI as a native LiteLLM guardrail provider with pre-call, during-call, and post-call inspection. Support policy blocking, input and output redaction, buffered streaming responses, standard GuardrailRaisedException handling, and configurable fail-open or fail-closed behavior. Inspect only the latest conversation input, optionally including system prompts, and only the latest model output. Register the provider in guardrail configuration and the Guardrail Garden with its bundled logo. Add focused coverage for request construction, response validation, content rewriting, streaming, multimodal messages, hook modes, and unreachable-provider behavior. --- .../guardrail_hooks/pointguardai/__init__.py | 61 + .../pointguardai/pointguardai.py | 1029 ++++++++ litellm/types/guardrails.py | 5 + .../guardrail_hooks/pointguardai.py | 37 + .../guardrail_hooks/test_pointguardai.py | 2128 +++++++++++++++++ .../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 + 10 files changed, 3280 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/guardrails/guardrail_hooks/pointguardai/__init__.py b/litellm/proxy/guardrails/guardrail_hooks/pointguardai/__init__.py new file mode 100644 index 00000000000..bcb859ebf42 --- /dev/null +++ b/litellm/proxy/guardrails/guardrail_hooks/pointguardai/__init__.py @@ -0,0 +1,61 @@ +from typing import TYPE_CHECKING, Final + +from litellm.secret_managers.main import get_secret_str +from litellm.types.guardrails import SupportedGuardrailIntegrations + +from .pointguardai import PointGuardAIGuardrail + +if TYPE_CHECKING: + from litellm.types.guardrails import Guardrail, LitellmParams + + +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 initialize_guardrail(litellm_params: "LitellmParams", guardrail: "Guardrail") -> 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_open" + ) + + 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=litellm_params.mode, + ) + + litellm.logging_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..12c6936c706 --- /dev/null +++ b/litellm/proxy/guardrails/guardrail_hooks/pointguardai/pointguardai.py @@ -0,0 +1,1029 @@ +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_skip_system_message_for_guardrail, + effective_skip_tool_message_for_guardrail, + role_out_of_guardrail_scope, +) +from litellm.llms.custom_httpx.http_handler import ( + get_async_httpx_client, + httpxSpecialProvider, +) +from litellm.types.guardrails import GuardrailEventHooks, Mode +from litellm.types.utils import GenericGuardrailAPIInputs + +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_open", + 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, + **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 = 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, + ) + + @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 + + 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 = [ # rebind-ok: each DLP violation produces independent category names + 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 item in 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, + } + ) + + # 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 status_code in (502, 503, 504): + 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 + ] + ) + ) + + if not messages: + return inputs + + # Transform to PointGuardAI format + new_messages: Final = self.transform_messages(messages=messages) + + # 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 + + # 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") + + matching_text_index = next( # rebind-ok: each modification searches for its own matching text + (index for index in range(len(texts) - 1, -1, -1) if original in texts[index]), + None, + ) + if matching_text_index is not None: + texts[matching_text_index] = texts[ # rebind-ok: applies provider redaction to LiteLLM hook text + matching_text_index + ].replace( # rebind-ok: applies provider redaction to LiteLLM hook text + original, replacement + ) + 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 + 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) + 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, + ) + ] + system_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() == "system" + ] + ) + latest_input: Final = next( + (message for message in reversed(scoped_messages) if str(message.get("role") or "").lower() != "system"), + None, + ) + return system_messages + ( + [latest_input] if latest_input is not None else [] # mutable-ok: LiteLLM message selection returns a list + ) + + 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.""" + if not texts: + return inputs + + latest_output_index: Final = next( + (index for index in range(len(texts) - 1, -1, -1) if texts[index]), + None, + ) + if latest_output_index is None: + return inputs + latest_output: Final = texts[latest_output_index] + + # 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=[latest_output], # mutable-ok: PointGuard output schema requires an array + ) + + # 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() + modifications_applied = False # rebind-ok: tracks whether the latest 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") + + if not isinstance(original, str) or not original: + continue + if modified is not None and not isinstance(modified, str): + continue + replacement = modified or "" + + if original in new_texts[latest_output_index]: + new_texts[latest_output_index] = new_texts[latest_output_index].replace(original, replacement) + modifications_applied = True + verbose_proxy_logger.info("PointGuardAI: Modified sensitive content in output") + + 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 + 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 c7cdfaad780..672be2edb76 100644 --- a/litellm/types/guardrails.py +++ b/litellm/types/guardrails.py @@ -39,6 +39,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, ) @@ -134,6 +137,7 @@ class SupportedGuardrailIntegrations(Enum): HEADROOM = "headroom" COMPRESR = "compresr" STRAIKER = "straiker" + POINTGUARDAI = "pointguard_ai" class Role(Enum): @@ -989,6 +993,7 @@ class LitellmParams( 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..7f257d4fec0 --- /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_open", + description=( + "Behavior when PointGuardAI is unreachable. 'fail_open' logs a critical " + "error and allows the request (default); 'fail_closed' raises an error." + ), + ) + + @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..873840b2f9b --- /dev/null +++ b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_pointguardai.py @@ -0,0 +1,2128 @@ +""" +Unit tests for PointGuardAI guardrail integration. +""" + +import json +from unittest.mock import AsyncMock, MagicMock, patch + +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 TestPointGuardAIGuardrailInit: + """Tests for PointGuardAIGuardrail initialization.""" + + def test_init_with_required_params(self): + """Test initialization with all required parameters.""" + guardrail = PointGuardAIGuardrail( + 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: + PointGuardAIGuardrail( + 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: + PointGuardAIGuardrail( + 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: + PointGuardAIGuardrail( + 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 = PointGuardAIGuardrail( + 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 = PointGuardAIGuardrail( + 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 = PointGuardAIGuardrail( + 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 = PointGuardAIGuardrail( + 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"): + PointGuardAIGuardrail( + 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 = PointGuardAIGuardrail( + 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 = PointGuardAIGuardrail( + 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 = PointGuardAIGuardrail( + 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 = PointGuardAIGuardrail( + 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 = PointGuardAIGuardrail( + 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 = PointGuardAIGuardrail( + 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 = PointGuardAIGuardrail( + 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 = PointGuardAIGuardrail( + 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 = PointGuardAIGuardrail( + 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 = PointGuardAIGuardrail( + 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 = PointGuardAIGuardrail( + 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 = PointGuardAIGuardrail( + 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 = PointGuardAIGuardrail( + 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 = PointGuardAIGuardrail( + 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 = PointGuardAIGuardrail( + 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 = PointGuardAIGuardrail( + 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 = PointGuardAIGuardrail( + 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 = PointGuardAIGuardrail( + 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 = PointGuardAIGuardrail( + 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]", + } + ] + + +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 = PointGuardAIGuardrail( + 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 = PointGuardAIGuardrail( + 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 = PointGuardAIGuardrail( + 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 = PointGuardAIGuardrail( + 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 = PointGuardAIGuardrail( + 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 = PointGuardAIGuardrail( + 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 = PointGuardAIGuardrail( + 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 = PointGuardAIGuardrail( + 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 = PointGuardAIGuardrail( + 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_only_latest_conversation_message(self): + guardrail = PointGuardAIGuardrail( + 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": []}, + } + mock_response.raise_for_status = MagicMock() + guardrail.async_handler.post = AsyncMock(return_value=mock_response) + messages = [ + {"role": "user", "content": "First input"}, + {"role": "assistant", "content": "First output"}, + {"role": "user", "content": "Second input"}, + ] + + await guardrail.apply_guardrail( + inputs=GenericGuardrailAPIInputs( + texts=["First input", "First output", "Second input"], + structured_messages=messages, + ), + 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": "user", "content": "Second input"}] + + @pytest.mark.asyncio + async def test_apply_guardrail_request_fallback_includes_system_and_latest_input(self): + guardrail = PointGuardAIGuardrail( + 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 = PointGuardAIGuardrail( + 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 = PointGuardAIGuardrail( + 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": "My SSN is 123-45-6789"}] + inputs = GenericGuardrailAPIInputs( + texts=["My SSN is 123-45-6789"], + structured_messages=messages, + ) + + 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"] == "My SSN is [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 = PointGuardAIGuardrail( + 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, + 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_response_no_violations(self): + """Test apply_guardrail for response with no violations.""" + guardrail = PointGuardAIGuardrail( + 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 = PointGuardAIGuardrail( + 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 = PointGuardAIGuardrail( + 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 = PointGuardAIGuardrail( + 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 = PointGuardAIGuardrail( + 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 = PointGuardAIGuardrail( + 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 = PointGuardAIGuardrail( + 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_and_modifies_only_latest_output_text(self): + guardrail = PointGuardAIGuardrail( + 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": "second@example.com", + "modifiedContent": "[SECOND_EMAIL]", + }, + ], + }, + } + mock_response.raise_for_status = MagicMock() + guardrail.async_handler.post = AsyncMock(return_value=mock_response) + inputs = GenericGuardrailAPIInputs( + texts=[ + "First: first@example.com", + "Second: second@example.com", + ], + 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": "Second: second@example.com"}, + ] + assert result["texts"] == [ + "First: first@example.com", + "Second: [SECOND_EMAIL]", + ] + assert result["model"] == "test-model" + + @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 = PointGuardAIGuardrail( + 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 = PointGuardAIGuardrail( + 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 = PointGuardAIGuardrail( + 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 = PointGuardAIGuardrail( + 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_fail_open_is_default_and_returns_all_inputs(self): + """Default fail-open should preserve every field when PointGuard is unreachable.""" + guardrail = PointGuardAIGuardrail( + api_key="test_key", + api_base="https://api.appsoc.com", + org_code="test-org", + policy_config_name="test-policy", + guardrail_name="pointguardai-guard", + ) + 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 = PointGuardAIGuardrail( + 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_malformed_success_response_fails_closed_despite_fail_open(self): + guardrail = PointGuardAIGuardrail( + 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 = PointGuardAIGuardrail( + 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 = PointGuardAIGuardrail( + 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 = PointGuardAIGuardrail( + 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 = PointGuardAIGuardrail( + 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 = PointGuardAIGuardrail( + 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 = PointGuardAIGuardrail( + 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 = PointGuardAIGuardrail( + 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_open" + + 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_open_default(self): + """PointGuard's YAML configuration should default to fail-open.""" + import litellm + 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", + ) + + with patch.object( + litellm.logging_callback_manager, + "add_litellm_callback", + ): + initialized = initialize_guardrail( + params, + {"guardrail_name": "pointguardai-guard", "litellm_params": params}, + ) + + assert initialized.unreachable_fallback == "fail_open" + + def test_initializer_preserves_explicit_fail_closed(self): + """An explicit PointGuard fail-closed setting should override its default.""" + import litellm + 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_closed", + ) + + with patch.object( + litellm.logging_callback_manager, + "add_litellm_callback", + ): + initialized = initialize_guardrail( + params, + {"guardrail_name": "pointguardai-guard", "litellm_params": params}, + ) + + assert initialized.unreachable_fallback == "fail_closed" + + def test_initializer_resolves_pointguard_environment_references(self, monkeypatch): + import litellm + 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", + ) + + with patch.object(litellm.logging_callback_manager, "add_litellm_callback"): + initialized = initialize_guardrail( + params, + {"guardrail_name": "pointguardai-guard", "litellm_params": params}, + ) + + assert initialized.pointguardai_org_code == "resolved-org" + assert initialized.pointguardai_policy_config_name == "resolved-policy" + + 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 13909e48185..fcacc0c8ecb 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", }; 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 744af89a357..84067b7a721 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 12aaba0d696..b7643512c67 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 @@ -18,6 +18,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"; @@ -81,6 +82,7 @@ export const guardrail_provider_map: Record = { LlmAsAJudge: "llm_as_a_judge", Xecguard: "xecguard", Deepkeep: "deepkeep", + PointguardAi: "pointguard_ai", QostodianNexus: "qostodian_nexus", Repelloai: "repelloai", }; @@ -190,6 +192,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, From 089c84216c1281279cb870371780439b82030172 Mon Sep 17 00:00:00 2001 From: Shubham-Kothari Date: Sat, 22 Aug 2026 21:44:54 -0400 Subject: [PATCH 2/9] fix(guardrails): satisfy PointGuard type discipline gate Document why the DLP category names remain a mutable JSON detail array. This resolves the LIT002 failure without changing PointGuard runtime behavior. --- .../guardrails/guardrail_hooks/pointguardai/pointguardai.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/litellm/proxy/guardrails/guardrail_hooks/pointguardai/pointguardai.py b/litellm/proxy/guardrails/guardrail_hooks/pointguardai/pointguardai.py index 12c6936c706..4a0bf2b8852 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/pointguardai/pointguardai.py +++ b/litellm/proxy/guardrails/guardrail_hooks/pointguardai/pointguardai.py @@ -405,7 +405,7 @@ class PointGuardAIGuardrail(CustomGuardrail): "categories", [], # mutable-ok: read-only fallback for absent category array ) - category_names = [ # rebind-ok: each DLP violation produces independent category names + 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) ] From 4aa4d54433051b8a47e79ec7ec913d89b58952cc Mon Sep 17 00:00:00 2001 From: Shubham-Kothari Date: Mon, 24 Aug 2026 10:33:45 -0400 Subject: [PATCH 3/9] fix(guardrails): inspect PointGuard current turn and all outputs Keep optional system and developer context while excluding completed conversation turns from repeated input inspection. Send every message in the active turn and every non-empty response choice to PointGuard, then apply output redactions by provider response position. Add regressions for prior-turn exclusion, multi-message current turns, earlier current-turn redaction, and ordered multi-output handling. --- .../pointguardai/pointguardai.py | 52 ++++++++++------ .../guardrail_hooks/test_pointguardai.py | 60 ++++++++++++++----- 2 files changed, 77 insertions(+), 35 deletions(-) diff --git a/litellm/proxy/guardrails/guardrail_hooks/pointguardai/pointguardai.py b/litellm/proxy/guardrails/guardrail_hooks/pointguardai/pointguardai.py index 4a0bf2b8852..e8f42275b35 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/pointguardai/pointguardai.py +++ b/litellm/proxy/guardrails/guardrail_hooks/pointguardai/pointguardai.py @@ -472,7 +472,7 @@ class PointGuardAIGuardrail(CustomGuardrail): 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 item in content_items: + for index, item in enumerate(content_items): if not isinstance(item, dict): # pyright: ignore[reportUnnecessaryIsInstance] # defensively validate untrusted provider JSON continue @@ -493,6 +493,7 @@ class PointGuardAIGuardrail(CustomGuardrail): "role": item.get("role", "user"), "originalContent": original_content, "modifiedContent": modified_content, + "index": index, } ) @@ -882,20 +883,28 @@ class PointGuardAIGuardrail(CustomGuardrail): skip_tool_message=skip_tool, ) ] - system_messages: Final = ( + 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() == "system" + message for message in scoped_messages if str(message.get("role") or "").lower() in context_roles ] ) - latest_input: Final = next( - (message for message in reversed(scoped_messages) if str(message.get("role") or "").lower() != "system"), - None, - ) - return system_messages + ( - [latest_input] if latest_input is not None else [] # mutable-ok: LiteLLM message selection returns a list + 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, @@ -943,13 +952,12 @@ class PointGuardAIGuardrail(CustomGuardrail): if not texts: return inputs - latest_output_index: Final = next( - (index for index in range(len(texts) - 1, -1, -1) if texts[index]), - None, - ) - if latest_output_index is None: + output_indices: Final = tuple(index for index, text in enumerate(texts) if text) + if not output_indices: return inputs - latest_output: Final = texts[latest_output_index] + output_texts: Final = [ # mutable-ok: PointGuard output schema requires a JSON array + texts[index] for index in output_indices + ] # For /output endpoint, we need both input and output request_input_messages: Final = self._get_input_messages_from_request_data(request_data) @@ -974,7 +982,7 @@ class PointGuardAIGuardrail(CustomGuardrail): modified_content: Final = await self.make_pointguard_api_request( request_data=request_data, new_messages=input_messages, - response_strings=[latest_output], # mutable-ok: PointGuard output schema requires an array + response_strings=output_texts, ) # Apply modifications to output if present @@ -985,7 +993,7 @@ class PointGuardAIGuardrail(CustomGuardrail): ) new_texts: Final = texts.copy() - modifications_applied = False # rebind-ok: tracks whether the latest output was redacted + 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 @@ -993,15 +1001,21 @@ class PointGuardAIGuardrail(CustomGuardrail): 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_indices): + continue replacement = modified or "" + text_index = output_indices[ # rebind-ok: each modification maps to its inspected output position + response_index + ] - if original in new_texts[latest_output_index]: - new_texts[latest_output_index] = new_texts[latest_output_index].replace(original, replacement) + 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") diff --git a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_pointguardai.py b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_pointguardai.py index 873840b2f9b..4fdf7b37c49 100644 --- a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_pointguardai.py +++ b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_pointguardai.py @@ -634,6 +634,7 @@ class TestPointGuardAIGuardrailResponseProcessing: "role": "assistant", "originalContent": "output secret", "modifiedContent": "[REDACTED OUTPUT]", + "index": 0, } ] @@ -941,7 +942,7 @@ class TestPointGuardAIGuardrailApplyGuardrail: assert "_pointguardai_input_messages" not in request_data["metadata"] @pytest.mark.asyncio - async def test_apply_guardrail_request_sends_only_latest_conversation_message(self): + async def test_apply_guardrail_request_sends_system_and_current_turn_messages(self): guardrail = PointGuardAIGuardrail( api_key="test_key", api_base="https://api.appsoc.com", @@ -949,6 +950,7 @@ class TestPointGuardAIGuardrailApplyGuardrail: 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 = { @@ -958,14 +960,22 @@ class TestPointGuardAIGuardrailApplyGuardrail: mock_response.raise_for_status = MagicMock() guardrail.async_handler.post = AsyncMock(return_value=mock_response) messages = [ - {"role": "user", "content": "First input"}, - {"role": "assistant", "content": "First output"}, - {"role": "user", "content": "Second input"}, + {"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=["First input", "First output", "Second input"], + texts=[ + "System prompt", + "Previous input", + "Previous output", + "Current input part one", + "Current input part two", + ], structured_messages=messages, ), request_data={"messages": messages}, @@ -974,7 +984,11 @@ class TestPointGuardAIGuardrailApplyGuardrail: ) sent_body = json.loads(guardrail.async_handler.post.call_args.kwargs["data"]) - assert sent_body["input"] == [{"role": "user", "content": "Second input"}] + 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_fallback_includes_system_and_latest_input(self): @@ -1099,9 +1113,19 @@ class TestPointGuardAIGuardrailApplyGuardrail: mock_response.raise_for_status = MagicMock() guardrail.async_handler.post = AsyncMock(return_value=mock_response) - messages = [{"role": "user", "content": "My SSN is 123-45-6789"}] + 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=["My SSN is 123-45-6789"], + texts=[ + "Previous input", + "Previous output", + "My SSN is 123-45-6789", + "Current clean input", + ], structured_messages=messages, ) @@ -1113,7 +1137,9 @@ class TestPointGuardAIGuardrailApplyGuardrail: ) # Content should be modified - assert result["structured_messages"][0]["content"] == "My SSN is [REDACTED]" + 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_modifies_multimodal_text_safely(self): @@ -1518,7 +1544,7 @@ class TestPointGuardAIGuardrailApplyGuardrail: ] @pytest.mark.asyncio - async def test_apply_guardrail_inspects_and_modifies_only_latest_output_text(self): + async def test_apply_guardrail_inspects_all_output_texts_and_preserves_positions(self): guardrail = PointGuardAIGuardrail( api_key="test_key", api_base="https://api.appsoc.com", @@ -1536,9 +1562,10 @@ class TestPointGuardAIGuardrailApplyGuardrail: "modified": True, "content": [ { - "originalContent": "second@example.com", - "modifiedContent": "[SECOND_EMAIL]", + "originalContent": "First: first@example.com", + "modifiedContent": "First: [FIRST_EMAIL]", }, + {"originalContent": "Second response is clean"}, ], }, } @@ -1547,7 +1574,7 @@ class TestPointGuardAIGuardrailApplyGuardrail: inputs = GenericGuardrailAPIInputs( texts=[ "First: first@example.com", - "Second: second@example.com", + "Second response is clean", ], model="test-model", ) @@ -1561,11 +1588,12 @@ class TestPointGuardAIGuardrailApplyGuardrail: sent_body = json.loads(guardrail.async_handler.post.call_args.kwargs["data"]) assert sent_body["output"] == [ - {"role": "assistant", "content": "Second: second@example.com"}, + {"role": "assistant", "content": "First: first@example.com"}, + {"role": "assistant", "content": "Second response is clean"}, ] assert result["texts"] == [ - "First: first@example.com", - "Second: [SECOND_EMAIL]", + "First: [FIRST_EMAIL]", + "Second response is clean", ] assert result["model"] == "test-model" From 90a980558a8b8de33d32c28d5fc1f15e7b98a08d Mon Sep 17 00:00:00 2001 From: Shubham-Kothari Date: Mon, 24 Aug 2026 10:59:22 -0400 Subject: [PATCH 4/9] test(guardrails): avoid patching PointGuard callback manager Inject a callback registrar into the PointGuard initializer while preserving the production default. Use an isolated recording registrar in initializer tests so they verify registration without patching LiteLLM internals or mutating shared callback state. --- .../guardrail_hooks/pointguardai/__init__.py | 17 +++++- .../guardrail_hooks/test_pointguardai.py | 55 ++++++++++--------- 2 files changed, 44 insertions(+), 28 deletions(-) diff --git a/litellm/proxy/guardrails/guardrail_hooks/pointguardai/__init__.py b/litellm/proxy/guardrails/guardrail_hooks/pointguardai/__init__.py index bcb859ebf42..50690c22e55 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/pointguardai/__init__.py +++ b/litellm/proxy/guardrails/guardrail_hooks/pointguardai/__init__.py @@ -1,4 +1,4 @@ -from typing import TYPE_CHECKING, Final +from typing import TYPE_CHECKING, Final, Protocol from litellm.secret_managers.main import get_secret_str from litellm.types.guardrails import SupportedGuardrailIntegrations @@ -9,13 +9,21 @@ 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 initialize_guardrail(litellm_params: "LitellmParams", guardrail: "Guardrail") -> PointGuardAIGuardrail: +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) @@ -39,7 +47,10 @@ def initialize_guardrail(litellm_params: "LitellmParams", guardrail: "Guardrail" event_hook=litellm_params.mode, ) - litellm.logging_callback_manager.add_litellm_callback(pointguardai_guardrail) + 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 diff --git a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_pointguardai.py b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_pointguardai.py index 4fdf7b37c49..b174f4fd9c6 100644 --- a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_pointguardai.py +++ b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_pointguardai.py @@ -3,7 +3,7 @@ Unit tests for PointGuardAI guardrail integration. """ import json -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock, MagicMock import httpx import pytest @@ -33,6 +33,14 @@ from litellm.types.utils import ( ) +class _RecordingCallbackManager: + def __init__(self) -> None: + self.callback: PointGuardAIGuardrail | None = None + + def add_litellm_callback(self, callback: PointGuardAIGuardrail) -> None: + self.callback = callback + + class TestPointGuardAIGuardrailInit: """Tests for PointGuardAIGuardrail initialization.""" @@ -2047,7 +2055,6 @@ class TestPointGuardAIGuardrailRegistry: def test_initializer_uses_pointguard_fail_open_default(self): """PointGuard's YAML configuration should default to fail-open.""" - import litellm from litellm.proxy.guardrails.guardrail_hooks.pointguardai import ( initialize_guardrail, ) @@ -2062,20 +2069,18 @@ class TestPointGuardAIGuardrailRegistry: policy_config_name="test-policy", ) - with patch.object( - litellm.logging_callback_manager, - "add_litellm_callback", - ): - initialized = initialize_guardrail( - params, - {"guardrail_name": "pointguardai-guard", "litellm_params": params}, - ) + 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_preserves_explicit_fail_closed(self): """An explicit PointGuard fail-closed setting should override its default.""" - import litellm from litellm.proxy.guardrails.guardrail_hooks.pointguardai import ( initialize_guardrail, ) @@ -2091,19 +2096,17 @@ class TestPointGuardAIGuardrailRegistry: unreachable_fallback="fail_closed", ) - with patch.object( - litellm.logging_callback_manager, - "add_litellm_callback", - ): - initialized = initialize_guardrail( - params, - {"guardrail_name": "pointguardai-guard", "litellm_params": params}, - ) + 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_resolves_pointguard_environment_references(self, monkeypatch): - import litellm from litellm.proxy.guardrails.guardrail_hooks.pointguardai import ( initialize_guardrail, ) @@ -2120,14 +2123,16 @@ class TestPointGuardAIGuardrailRegistry: policy_config_name="os.environ/POINTGUARDAI_TEST_POLICY", ) - with patch.object(litellm.logging_callback_manager, "add_litellm_callback"): - initialized = initialize_guardrail( - params, - {"guardrail_name": "pointguardai-guard", "litellm_params": params}, - ) + 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 e1e902ac291a69dc678da98d08eb59cdcb3a8a71 Mon Sep 17 00:00:00 2001 From: Shubham-Kothari Date: Mon, 24 Aug 2026 11:16:24 -0400 Subject: [PATCH 5/9] fix(guardrails): inspect PointGuard tool payloads Serialize request tool definitions and generated tool calls into the existing PointGuard inspection payload Map valid provider redactions back into LiteLLM tools and tool_calls while preserving normal prompt and response behavior Add focused coverage for request-side tool definitions and tool-call-only responses --- .../pointguardai/pointguardai.py | 99 +++++++++++--- .../guardrail_hooks/test_pointguardai.py | 121 ++++++++++++++++++ 2 files changed, 204 insertions(+), 16 deletions(-) diff --git a/litellm/proxy/guardrails/guardrail_hooks/pointguardai/pointguardai.py b/litellm/proxy/guardrails/guardrail_hooks/pointguardai/pointguardai.py index e8f42275b35..9a03e25d892 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/pointguardai/pointguardai.py +++ b/litellm/proxy/guardrails/guardrail_hooks/pointguardai/pointguardai.py @@ -20,7 +20,7 @@ from litellm.llms.custom_httpx.http_handler import ( httpxSpecialProvider, ) from litellm.types.guardrails import GuardrailEventHooks, Mode -from litellm.types.utils import GenericGuardrailAPIInputs +from litellm.types.utils import GenericGuardrailAPIInputs, OpenAIObject if TYPE_CHECKING: from litellm.litellm_core_utils.litellm_logging import ( @@ -177,6 +177,23 @@ class PointGuardAIGuardrail(CustomGuardrail): 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 + async def prepare_pointguard_ai_runtime_scanner_request( self, new_messages: list[dict], # mutable-ok: outbound PointGuard JSON message array @@ -799,11 +816,23 @@ class PointGuardAIGuardrail(CustomGuardrail): ) ) - if not messages: + tools: Final = 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 - # Transform to PointGuardAI format - new_messages: Final = self.transform_messages(messages=messages) + 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( @@ -820,6 +849,7 @@ class PointGuardAIGuardrail(CustomGuardrail): ) 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: @@ -852,6 +882,17 @@ class PointGuardAIGuardrail(CustomGuardrail): ) 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 + modifications_applied = True + if modifications_applied: modified_inputs: Final[ GenericGuardrailAPIInputs @@ -859,6 +900,8 @@ class PointGuardAIGuardrail(CustomGuardrail): 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( @@ -949,14 +992,22 @@ class PointGuardAIGuardrail(CustomGuardrail): request_data: dict, # mutable-ok: inherited LiteLLM guardrail hook payload ) -> GenericGuardrailAPIInputs: """Handle response-side (post-call) guardrail checks for output.""" - if not texts: - return inputs - output_indices: Final = tuple(index for index, text in enumerate(texts) if text) - if not output_indices: + 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 @@ -993,6 +1044,7 @@ class PointGuardAIGuardrail(CustomGuardrail): ) 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: @@ -1007,17 +1059,30 @@ class PointGuardAIGuardrail(CustomGuardrail): 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_indices): + if not isinstance(response_index, int) or not 0 <= response_index < len(output_texts): continue replacement = modified or "" - text_index = output_indices[ # rebind-ok: each modification maps to its inspected output position - response_index - ] + 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 - 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") + tool_call_position = response_index - len(output_indices) + if tool_call_position >= len(serialized_tool_calls): + continue + tool_call_index, serialized_tool_call = serialized_tool_calls[tool_call_position] + if serialized_tool_call != original: + continue + replacement_tool_call = self._deserialize_tool_payload(replacement) + if replacement_tool_call is None: + continue + new_tool_calls[tool_call_index] = replacement_tool_call + modifications_applied = True if modifications_applied: modified_inputs: Final[ @@ -1025,6 +1090,8 @@ class PointGuardAIGuardrail(CustomGuardrail): ] = {} # 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( diff --git a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_pointguardai.py b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_pointguardai.py index b174f4fd9c6..b735e3996a2 100644 --- a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_pointguardai.py +++ b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_pointguardai.py @@ -1217,6 +1217,68 @@ class TestPointGuardAIGuardrailApplyGuardrail: 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 = PointGuardAIGuardrail( + 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], + ) + + 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.""" @@ -1605,6 +1667,65 @@ class TestPointGuardAIGuardrailApplyGuardrail: ] assert result["model"] == "test-model" + @pytest.mark.asyncio + async def test_apply_guardrail_inspects_and_redacts_tool_call_only_response(self): + guardrail = PointGuardAIGuardrail( + 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]), + 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.""" From 928ca42418621b37dc9371cd96a3a2e5efa3f554 Mon Sep 17 00:00:00 2001 From: Shubham-Kothari Date: Mon, 24 Aug 2026 11:43:57 -0400 Subject: [PATCH 6/9] style(guardrails): format PointGuard tool inspection --- .../guardrails/guardrail_hooks/pointguardai/pointguardai.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/litellm/proxy/guardrails/guardrail_hooks/pointguardai/pointguardai.py b/litellm/proxy/guardrails/guardrail_hooks/pointguardai/pointguardai.py index 9a03e25d892..69f9dc24354 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/pointguardai/pointguardai.py +++ b/litellm/proxy/guardrails/guardrail_hooks/pointguardai/pointguardai.py @@ -829,7 +829,9 @@ class PointGuardAIGuardrail(CustomGuardrail): 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 + 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 ] From 3b489710e95f26dd3b873171b8e4ae02c26c3134 Mon Sep 17 00:00:00 2001 From: Shubham-Kothari Date: Mon, 24 Aug 2026 11:55:27 -0400 Subject: [PATCH 7/9] refactor(guardrails): reduce PointGuard response complexity Extract tool-call redaction resolution into a focused provider helper without changing inspection or mapping behavior --- .../pointguardai/pointguardai.py | 37 +++++++++++++++---- 1 file changed, 29 insertions(+), 8 deletions(-) diff --git a/litellm/proxy/guardrails/guardrail_hooks/pointguardai/pointguardai.py b/litellm/proxy/guardrails/guardrail_hooks/pointguardai/pointguardai.py index 69f9dc24354..c742f7112f0 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/pointguardai/pointguardai.py +++ b/litellm/proxy/guardrails/guardrail_hooks/pointguardai/pointguardai.py @@ -194,6 +194,26 @@ class PointGuardAIGuardrail(CustomGuardrail): return None return payload if isinstance(payload, dict) else None + @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 @@ -1074,15 +1094,16 @@ class PointGuardAIGuardrail(CustomGuardrail): verbose_proxy_logger.info("PointGuardAI: Modified sensitive content in output") continue - tool_call_position = response_index - len(output_indices) - if tool_call_position >= len(serialized_tool_calls): - continue - tool_call_index, serialized_tool_call = serialized_tool_calls[tool_call_position] - if serialized_tool_call != original: - continue - replacement_tool_call = self._deserialize_tool_payload(replacement) - if replacement_tool_call is None: + 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 modifications_applied = True From 258b1b3dbe747b577584ead7dc1b6de72b9afb88 Mon Sep 17 00:00:00 2001 From: Shubham-Kothari Date: Mon, 24 Aug 2026 12:18:50 -0400 Subject: [PATCH 8/9] fix(guardrails): default PointGuard to fail closed Align the provider constructor, configuration model, and initializer with LiteLLM's secure outage-handling convention Preserve fail-open as an explicit operator choice and cover both behaviors with focused tests --- .../guardrail_hooks/pointguardai/__init__.py | 2 +- .../pointguardai/pointguardai.py | 2 +- .../guardrail_hooks/pointguardai.py | 6 +- .../guardrail_hooks/test_pointguardai.py | 65 ++++++++++--------- 4 files changed, 38 insertions(+), 37 deletions(-) diff --git a/litellm/proxy/guardrails/guardrail_hooks/pointguardai/__init__.py b/litellm/proxy/guardrails/guardrail_hooks/pointguardai/__init__.py index 50690c22e55..1c8c4ae3fea 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/pointguardai/__init__.py +++ b/litellm/proxy/guardrails/guardrail_hooks/pointguardai/__init__.py @@ -33,7 +33,7 @@ def initialize_guardrail( else getattr(litellm_params, "__fields_set__", frozenset()) ) unreachable_fallback: Final = ( - litellm_params.unreachable_fallback if "unreachable_fallback" in configured_fields else "fail_open" + litellm_params.unreachable_fallback if "unreachable_fallback" in configured_fields else "fail_closed" ) pointguardai_guardrail: Final = PointGuardAIGuardrail( diff --git a/litellm/proxy/guardrails/guardrail_hooks/pointguardai/pointguardai.py b/litellm/proxy/guardrails/guardrail_hooks/pointguardai/pointguardai.py index c742f7112f0..28c85e0bc39 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/pointguardai/pointguardai.py +++ b/litellm/proxy/guardrails/guardrail_hooks/pointguardai/pointguardai.py @@ -51,7 +51,7 @@ class PointGuardAIGuardrail(CustomGuardrail): api_key: str | None = None, org_code: str | None = None, policy_config_name: str | None = None, - unreachable_fallback: Literal["fail_closed", "fail_open"] = "fail_open", + 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 diff --git a/litellm/types/proxy/guardrails/guardrail_hooks/pointguardai.py b/litellm/types/proxy/guardrails/guardrail_hooks/pointguardai.py index 7f257d4fec0..c4362c0d78c 100644 --- a/litellm/types/proxy/guardrails/guardrail_hooks/pointguardai.py +++ b/litellm/types/proxy/guardrails/guardrail_hooks/pointguardai.py @@ -25,10 +25,10 @@ class PointGuardAIGuardrailConfigModel(GuardrailConfigModel): description="PointGuardAI policy configuration name.", ) unreachable_fallback: Literal["fail_closed", "fail_open"] = Field( - default="fail_open", + default="fail_closed", description=( - "Behavior when PointGuardAI is unreachable. 'fail_open' logs a critical " - "error and allows the request (default); 'fail_closed' raises an error." + "Behavior when PointGuardAI is unreachable. 'fail_closed' raises an error " + "(default); 'fail_open' logs a critical error and allows the request." ), ) diff --git a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_pointguardai.py b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_pointguardai.py index b735e3996a2..48926ffb8eb 100644 --- a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_pointguardai.py +++ b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_pointguardai.py @@ -1837,14 +1837,15 @@ class TestPointGuardAIGuardrailErrorHandling: assert "unavailable" in str(exc_info.value.detail).lower() @pytest.mark.asyncio - async def test_fail_open_is_default_and_returns_all_inputs(self): - """Default fail-open should preserve every field when PointGuard is unreachable.""" + async def test_explicit_fail_open_returns_all_inputs(self): + """Explicit fail-open should preserve every field when PointGuard is unreachable.""" guardrail = PointGuardAIGuardrail( 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( @@ -2133,7 +2134,7 @@ class TestPointGuardAIGuardrailConfigModel: assert model.api_key is None assert model.org_code is None assert model.policy_config_name is None - assert model.unreachable_fallback == "fail_open" + assert model.unreachable_fallback == "fail_closed" def test_config_model_with_values(self): """Test config model with provided values""" @@ -2174,8 +2175,8 @@ class TestPointGuardAIGuardrailRegistry: assert initialize_guardrail is not None assert "pointguard_ai" in guardrail_initializer_registry - def test_initializer_uses_pointguard_fail_open_default(self): - """PointGuard's YAML configuration should default to fail-open.""" + 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, ) @@ -2197,36 +2198,36 @@ class TestPointGuardAIGuardrailRegistry: callback_manager=callback_manager, ) - assert initialized.unreachable_fallback == "fail_open" - assert callback_manager.callback is initialized - - def test_initializer_preserves_explicit_fail_closed(self): - """An explicit PointGuard fail-closed 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_closed", - ) - - 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 2013c754cc35b2b982e17ae5f3a6dc90626032ce Mon Sep 17 00:00:00 2001 From: Shubham-Kothari Date: Tue, 25 Aug 2026 07:29:03 -0400 Subject: [PATCH 9/9] fix(guardrails): redact duplicate PointGuard inputs Apply PointGuard replacements to every matching Responses API text entry so repeated sensitive values cannot remain partially unredacted. Add a focused regression test covering duplicate list inputs and preserve the existing request reconstruction flow. --- .../pointguardai/pointguardai.py | 25 ++++++----- .../guardrail_hooks/test_pointguardai.py | 44 +++++++++++++++++++ 2 files changed, 59 insertions(+), 10 deletions(-) diff --git a/litellm/proxy/guardrails/guardrail_hooks/pointguardai/pointguardai.py b/litellm/proxy/guardrails/guardrail_hooks/pointguardai/pointguardai.py index 28c85e0bc39..fdc0ca51a3f 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/pointguardai/pointguardai.py +++ b/litellm/proxy/guardrails/guardrail_hooks/pointguardai/pointguardai.py @@ -194,6 +194,20 @@ class PointGuardAIGuardrail(CustomGuardrail): 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(original, replacement) + modified = True + return modified + @classmethod def _resolve_tool_call_modification( cls, @@ -892,16 +906,7 @@ class PointGuardAIGuardrail(CustomGuardrail): modifications_applied = True verbose_proxy_logger.info("PointGuardAI: Modified input message content") - matching_text_index = next( # rebind-ok: each modification searches for its own matching text - (index for index in range(len(texts) - 1, -1, -1) if original in texts[index]), - None, - ) - if matching_text_index is not None: - texts[matching_text_index] = texts[ # rebind-ok: applies provider redaction to LiteLLM hook text - matching_text_index - ].replace( # rebind-ok: applies provider redaction to LiteLLM hook text - original, replacement - ) + 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 diff --git a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_pointguardai.py b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_pointguardai.py index 48926ffb8eb..0b305f0c058 100644 --- a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_pointguardai.py +++ b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_pointguardai.py @@ -1149,6 +1149,50 @@ class TestPointGuardAIGuardrailApplyGuardrail: 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 = PointGuardAIGuardrail( + 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."""