diff --git a/litellm/proxy/_experimental/out/404.html b/litellm/proxy/_experimental/out/404/index.html similarity index 100% rename from litellm/proxy/_experimental/out/404.html rename to litellm/proxy/_experimental/out/404/index.html diff --git a/litellm/proxy/_experimental/out/_not-found.html b/litellm/proxy/_experimental/out/_not-found/index.html similarity index 100% rename from litellm/proxy/_experimental/out/_not-found.html rename to litellm/proxy/_experimental/out/_not-found/index.html diff --git a/litellm/proxy/_experimental/out/api-reference.html b/litellm/proxy/_experimental/out/api-reference/index.html similarity index 100% rename from litellm/proxy/_experimental/out/api-reference.html rename to litellm/proxy/_experimental/out/api-reference/index.html diff --git a/litellm/proxy/_experimental/out/experimental/api-playground.html b/litellm/proxy/_experimental/out/experimental/api-playground/index.html similarity index 100% rename from litellm/proxy/_experimental/out/experimental/api-playground.html rename to litellm/proxy/_experimental/out/experimental/api-playground/index.html diff --git a/litellm/proxy/_experimental/out/experimental/budgets.html b/litellm/proxy/_experimental/out/experimental/budgets/index.html similarity index 100% rename from litellm/proxy/_experimental/out/experimental/budgets.html rename to litellm/proxy/_experimental/out/experimental/budgets/index.html diff --git a/litellm/proxy/_experimental/out/experimental/caching.html b/litellm/proxy/_experimental/out/experimental/caching/index.html similarity index 100% rename from litellm/proxy/_experimental/out/experimental/caching.html rename to litellm/proxy/_experimental/out/experimental/caching/index.html diff --git a/litellm/proxy/_experimental/out/experimental/claude-code-plugins.html b/litellm/proxy/_experimental/out/experimental/claude-code-plugins/index.html similarity index 100% rename from litellm/proxy/_experimental/out/experimental/claude-code-plugins.html rename to litellm/proxy/_experimental/out/experimental/claude-code-plugins/index.html diff --git a/litellm/proxy/_experimental/out/experimental/old-usage.html b/litellm/proxy/_experimental/out/experimental/old-usage/index.html similarity index 100% rename from litellm/proxy/_experimental/out/experimental/old-usage.html rename to litellm/proxy/_experimental/out/experimental/old-usage/index.html diff --git a/litellm/proxy/_experimental/out/experimental/prompts.html b/litellm/proxy/_experimental/out/experimental/prompts/index.html similarity index 100% rename from litellm/proxy/_experimental/out/experimental/prompts.html rename to litellm/proxy/_experimental/out/experimental/prompts/index.html diff --git a/litellm/proxy/_experimental/out/experimental/tag-management.html b/litellm/proxy/_experimental/out/experimental/tag-management/index.html similarity index 100% rename from litellm/proxy/_experimental/out/experimental/tag-management.html rename to litellm/proxy/_experimental/out/experimental/tag-management/index.html diff --git a/litellm/proxy/_experimental/out/guardrails.html b/litellm/proxy/_experimental/out/guardrails/index.html similarity index 100% rename from litellm/proxy/_experimental/out/guardrails.html rename to litellm/proxy/_experimental/out/guardrails/index.html diff --git a/litellm/proxy/_experimental/out/login.html b/litellm/proxy/_experimental/out/login/index.html similarity index 100% rename from litellm/proxy/_experimental/out/login.html rename to litellm/proxy/_experimental/out/login/index.html diff --git a/litellm/proxy/_experimental/out/logs.html b/litellm/proxy/_experimental/out/logs/index.html similarity index 100% rename from litellm/proxy/_experimental/out/logs.html rename to litellm/proxy/_experimental/out/logs/index.html diff --git a/litellm/proxy/_experimental/out/mcp/oauth/callback.html b/litellm/proxy/_experimental/out/mcp/oauth/callback/index.html similarity index 100% rename from litellm/proxy/_experimental/out/mcp/oauth/callback.html rename to litellm/proxy/_experimental/out/mcp/oauth/callback/index.html diff --git a/litellm/proxy/_experimental/out/model-hub.html b/litellm/proxy/_experimental/out/model-hub/index.html similarity index 100% rename from litellm/proxy/_experimental/out/model-hub.html rename to litellm/proxy/_experimental/out/model-hub/index.html diff --git a/litellm/proxy/_experimental/out/model_hub.html b/litellm/proxy/_experimental/out/model_hub/index.html similarity index 100% rename from litellm/proxy/_experimental/out/model_hub.html rename to litellm/proxy/_experimental/out/model_hub/index.html diff --git a/litellm/proxy/_experimental/out/model_hub_table.html b/litellm/proxy/_experimental/out/model_hub_table/index.html similarity index 100% rename from litellm/proxy/_experimental/out/model_hub_table.html rename to litellm/proxy/_experimental/out/model_hub_table/index.html diff --git a/litellm/proxy/_experimental/out/models-and-endpoints.html b/litellm/proxy/_experimental/out/models-and-endpoints/index.html similarity index 100% rename from litellm/proxy/_experimental/out/models-and-endpoints.html rename to litellm/proxy/_experimental/out/models-and-endpoints/index.html diff --git a/litellm/proxy/_experimental/out/onboarding.html b/litellm/proxy/_experimental/out/onboarding/index.html similarity index 100% rename from litellm/proxy/_experimental/out/onboarding.html rename to litellm/proxy/_experimental/out/onboarding/index.html diff --git a/litellm/proxy/_experimental/out/organizations.html b/litellm/proxy/_experimental/out/organizations/index.html similarity index 100% rename from litellm/proxy/_experimental/out/organizations.html rename to litellm/proxy/_experimental/out/organizations/index.html diff --git a/litellm/proxy/_experimental/out/playground.html b/litellm/proxy/_experimental/out/playground/index.html similarity index 100% rename from litellm/proxy/_experimental/out/playground.html rename to litellm/proxy/_experimental/out/playground/index.html diff --git a/litellm/proxy/_experimental/out/policies.html b/litellm/proxy/_experimental/out/policies/index.html similarity index 100% rename from litellm/proxy/_experimental/out/policies.html rename to litellm/proxy/_experimental/out/policies/index.html diff --git a/litellm/proxy/_experimental/out/settings/admin-settings.html b/litellm/proxy/_experimental/out/settings/admin-settings/index.html similarity index 100% rename from litellm/proxy/_experimental/out/settings/admin-settings.html rename to litellm/proxy/_experimental/out/settings/admin-settings/index.html diff --git a/litellm/proxy/_experimental/out/settings/logging-and-alerts.html b/litellm/proxy/_experimental/out/settings/logging-and-alerts/index.html similarity index 100% rename from litellm/proxy/_experimental/out/settings/logging-and-alerts.html rename to litellm/proxy/_experimental/out/settings/logging-and-alerts/index.html diff --git a/litellm/proxy/_experimental/out/settings/router-settings.html b/litellm/proxy/_experimental/out/settings/router-settings/index.html similarity index 100% rename from litellm/proxy/_experimental/out/settings/router-settings.html rename to litellm/proxy/_experimental/out/settings/router-settings/index.html diff --git a/litellm/proxy/_experimental/out/settings/ui-theme.html b/litellm/proxy/_experimental/out/settings/ui-theme/index.html similarity index 100% rename from litellm/proxy/_experimental/out/settings/ui-theme.html rename to litellm/proxy/_experimental/out/settings/ui-theme/index.html diff --git a/litellm/proxy/_experimental/out/teams.html b/litellm/proxy/_experimental/out/teams/index.html similarity index 100% rename from litellm/proxy/_experimental/out/teams.html rename to litellm/proxy/_experimental/out/teams/index.html diff --git a/litellm/proxy/_experimental/out/test-key.html b/litellm/proxy/_experimental/out/test-key/index.html similarity index 100% rename from litellm/proxy/_experimental/out/test-key.html rename to litellm/proxy/_experimental/out/test-key/index.html diff --git a/litellm/proxy/_experimental/out/tools/mcp-servers.html b/litellm/proxy/_experimental/out/tools/mcp-servers/index.html similarity index 100% rename from litellm/proxy/_experimental/out/tools/mcp-servers.html rename to litellm/proxy/_experimental/out/tools/mcp-servers/index.html diff --git a/litellm/proxy/_experimental/out/tools/vector-stores.html b/litellm/proxy/_experimental/out/tools/vector-stores/index.html similarity index 100% rename from litellm/proxy/_experimental/out/tools/vector-stores.html rename to litellm/proxy/_experimental/out/tools/vector-stores/index.html diff --git a/litellm/proxy/_experimental/out/usage.html b/litellm/proxy/_experimental/out/usage/index.html similarity index 100% rename from litellm/proxy/_experimental/out/usage.html rename to litellm/proxy/_experimental/out/usage/index.html diff --git a/litellm/proxy/_experimental/out/users.html b/litellm/proxy/_experimental/out/users/index.html similarity index 100% rename from litellm/proxy/_experimental/out/users.html rename to litellm/proxy/_experimental/out/users/index.html diff --git a/litellm/proxy/_experimental/out/virtual-keys.html b/litellm/proxy/_experimental/out/virtual-keys/index.html similarity index 100% rename from litellm/proxy/_experimental/out/virtual-keys.html rename to litellm/proxy/_experimental/out/virtual-keys/index.html diff --git a/litellm/proxy/_new_secret_config.yaml b/litellm/proxy/_new_secret_config.yaml index 67088e79ef..c4b6a4fa09 100644 --- a/litellm/proxy/_new_secret_config.yaml +++ b/litellm/proxy/_new_secret_config.yaml @@ -24,6 +24,33 @@ guardrails: guardrail: mcp_end_user_permission mode: pre_call default_on: true + - guardrail_name: "airline-competitor-intent" + guardrail_id: "airline-competitor-intent" + litellm_params: + guardrail: litellm_content_filter + mode: pre_call + default_on: false + competitor_intent_config: + brand_self: + - emirates + - ek + competitors: + - qatar airways + - qatar + - etihad + locations: + - qatar + - doha + - doh + competitor_aliases: + qatar airways: [qr, doha airline] + qatar: [qr] + policy: + competitor_comparison: refuse + possible_competitor_comparison: reframe + threshold_high: 0.70 + threshold_medium: 0.45 + threshold_low: 0.30 mcp_servers: my_http_server: diff --git a/litellm/proxy/guardrails/guardrail_endpoints.py b/litellm/proxy/guardrails/guardrail_endpoints.py index 44caa24524..e14782fa1f 100644 --- a/litellm/proxy/guardrails/guardrail_endpoints.py +++ b/litellm/proxy/guardrails/guardrail_endpoints.py @@ -827,6 +827,42 @@ async def get_category_yaml(category_name: str): ) +@router.get( + "/guardrails/ui/major_airlines", + tags=["Guardrails"], + dependencies=[Depends(user_api_key_auth)], +) +async def get_major_airlines(): + """ + Get the major airlines list from IATA (competitor intent, airline type). + Returns airline id, match variants (pipe-separated), and tags. + """ + import os + + airlines_path = os.path.join( + os.path.dirname(__file__), + "guardrail_hooks", + "litellm_content_filter", + "competitor_intent", + "major_airlines.json", + ) + if not os.path.exists(airlines_path): + raise HTTPException( + status_code=404, + detail="major_airlines.json not found", + ) + try: + with open(airlines_path, "r", encoding="utf-8") as f: + import json + + airlines = json.load(f) + return {"airlines": airlines} + except Exception as e: + raise HTTPException( + status_code=500, detail=f"Error reading major_airlines.json: {str(e)}" + ) from e + + @router.post( "/guardrails/validate_blocked_words_file", tags=["Guardrails"], diff --git a/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/__init__.py b/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/__init__.py index 32883f0ce9..d9a44094ad 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/__init__.py +++ b/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/__init__.py @@ -1,9 +1,8 @@ from typing import TYPE_CHECKING, Optional import litellm -from litellm.proxy.guardrails.guardrail_hooks.litellm_content_filter.content_filter import ( - ContentFilterGuardrail, -) +from litellm.proxy.guardrails.guardrail_hooks.litellm_content_filter.content_filter import \ + ContentFilterGuardrail from litellm.types.guardrails import SupportedGuardrailIntegrations if TYPE_CHECKING: @@ -44,6 +43,9 @@ def initialize_guardrail( severity_threshold=getattr(litellm_params, "severity_threshold", "medium"), llm_router=llm_router, image_model=getattr(litellm_params, "image_model", None), + competitor_intent_config=getattr( + litellm_params, "competitor_intent_config", None + ), ) litellm.logging_callback_manager.add_litellm_callback(content_filter_guardrail) diff --git a/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/competitor_intent/__init__.py b/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/competitor_intent/__init__.py new file mode 100644 index 0000000000..85b92cb16b --- /dev/null +++ b/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/competitor_intent/__init__.py @@ -0,0 +1,17 @@ +""" +Competitor intent: entity + intent disambiguation with safe (non-competitor) defaults. + +Base logic in base.py; industry-specific checkers in submodules (e.g. airline.py). +""" + +from litellm.proxy.guardrails.guardrail_hooks.litellm_content_filter.competitor_intent.airline import \ + AirlineCompetitorIntentChecker +from litellm.proxy.guardrails.guardrail_hooks.litellm_content_filter.competitor_intent.base import ( + BaseCompetitorIntentChecker, normalize, text_for_entity_matching) + +__all__ = [ + "BaseCompetitorIntentChecker", + "AirlineCompetitorIntentChecker", + "normalize", + "text_for_entity_matching", +] diff --git a/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/competitor_intent/airline.py b/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/competitor_intent/airline.py new file mode 100644 index 0000000000..7a74499027 --- /dev/null +++ b/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/competitor_intent/airline.py @@ -0,0 +1,211 @@ +""" +Airline-specific competitor intent: other meaning (e.g. location/travel context) vs competitor airline. + +Uses context-based disambiguation only: no hardcoded place lists. Detects travel-location +language (prepositions, travel verbs, booking/entry nouns) vs airline context (airways, +carrier, lounge, miles, etc.) and scores to decide OTHER_MEANING vs COMPETITOR. + +When competitors is not provided, loads major_airlines.json and excludes the customer's +brand_self so all other major airlines are treated as competitors. +""" + +import json +from pathlib import Path +from typing import Any, Dict, List, Tuple + +from litellm.proxy.guardrails.guardrail_hooks.litellm_content_filter.competitor_intent.base import ( + BaseCompetitorIntentChecker, _compile_marker, _count_signals, + _word_boundary_match) + +# Location/travel context: prepositions, travel verbs, booking nouns, entry/geo nouns. +# No place-name list; these patterns detect "destination context" generically. +AIRLINE_OTHER_MEANING_SIGNALS = [ + # Travel verb + preposition (e.g. "fly to", "layover in") + r"\b(fly|flying|travel|traveling|going|visit|visiting|transit|layover|stopover)\b.{0,12}\b(to|from|via|in|at|through|into)\b", + # Booking + preposition + r"\bflight(s)?\b.{0,10}\b(to|from|via)\b", + r"\bticket(s)?\b.{0,8}\b(to|for)\b", + r"\bfare(s)?\b.{0,8}\b(to)\b", + # Entry/geo/booking single words + r"\bvisa\b", + r"\bimmigration\b", + r"\bcustoms\b", + r"\bentry\b", + r"\bairport\b", + r"\bterminal\b", + r"\bgate\b", + r"\bdeparture\b", + r"\barrival\b", + r"\bitinerary\b", + r"\bweather\b", + r"\bhotel\b", + r"\bcity\b", + # Prepositions alone (weaker; often near a place) + r"\bto\s+", + r"\bfrom\s+", + r"\bin\s+", + r"\bat\s+", + r"\bvia\s+", +] + +# Airline context: carrier/airline language, cabin, loyalty, operations. +# If ambiguous token appears near these → treat as COMPETITOR. +AIRLINE_COMPETITOR_SIGNALS = [ + r"\bairways?\b", + r"\bairline\b", + r"\bcarrier\b", + r"\bcabin\s+crew\b", + r"\bflight\s+attendant\b", + r"\bbusiness\s+class\b", + r"\bfirst\s+class\b", + r"\beconomy\b", + r"\blounge\b", + r"\bbaggage\s+allowance\b", + r"\bcheck[- ]?in\b", + r"\bmiles\b", + r"\bloyalty\b", + r"\bstatus\b", + r"\bfrequent\s+flyer\b", + r"\bfleet\b", + r"\baircraft\b", + # Comparison/ranking + r"\bbetter\b", + r"\bbest\b", + r"\bgood\b", + r"\bas\s+good\s+as\b", + r"\bvs\.?\b", + r"\bversus\b", + r"\bcompare\b", + r"\balternative\b", + r"\bcompetitor\b", + # Brand-specific (optional; config can extend) + r"\bqmiles\b", + r"\bprivilege\s+club\b", +] + +# Operational-only: baggage, lounge, check-in, refund (no comparison language). +# When only these appear with ambiguous token → treat as product query (OTHER_MEANING). +AIRLINE_OPERATIONAL_SIGNALS = [ + r"\bbaggage\s+allowance\b", + r"\blounge\b", + r"\bcheck[- ]?in\b", + r"\brefund\b", + r"\bpremium\s+lounge\b", +] +# Comparison language: if present with competitor signals → COMPETITOR. +AIRLINE_COMPARISON_SIGNALS = [ + r"\bbetter\b", + r"\bbest\b", + r"\bvs\.?\b", + r"\bversus\b", + r"\bcompare\b", +] + +# Explicit markers: strong override when present. +AIRLINE_EXPLICIT_COMPETITOR_MARKER = r"\b(airways?|airline|carrier)\b" +AIRLINE_EXPLICIT_OTHER_MEANING_MARKER = ( + r"\b(fly|travel|going|visit|layover|stopover|transit)\b.{0,12}\b(to|in|via|from)\b.{0,8}\b" +) + +_MAJOR_AIRLINES_PATH = ( + Path(__file__).resolve().parent / "major_airlines.json" +) + + +def _load_competitors_excluding_brand(brand_self: List[str]) -> List[str]: + """ + Load competitor tokens from major_airlines.json (harm_toxic_abuse-style format). + Exclude any airline whose id or match variants overlap with brand_self. + Returns a flat list of match variants (pipe-separated values) from non-excluded airlines. + """ + brand_set = {b.lower().strip() for b in brand_self if b} + if not _MAJOR_AIRLINES_PATH.exists(): + return [] + try: + with open(_MAJOR_AIRLINES_PATH, encoding="utf-8") as f: + airlines = json.load(f) + except (json.JSONDecodeError, OSError): + return [] + result: List[str] = [] + for entry in airlines: + if not isinstance(entry, dict): + continue + match_str = entry.get("match") or "" + variants = [v.strip().lower() for v in match_str.split("|") if v.strip()] + words_in_match = set() + for v in variants: + words_in_match.update(v.split()) + if brand_set & words_in_match or any(v in brand_set for v in variants): + continue + result.extend(variants) + return result + + +class AirlineCompetitorIntentChecker(BaseCompetitorIntentChecker): + """ + Disambiguates other meaning (e.g. country/city/airport) vs competitor airline + (e.g. "Qatar" → country vs Qatar Airways). Overrides _classify_ambiguous + with other_meaning/competitor signals and explicit markers. + """ + + def __init__(self, config: Dict[str, Any]) -> None: + merged: Dict[str, Any] = dict(config) + if not merged.get("other_meaning_signals"): + merged["other_meaning_signals"] = AIRLINE_OTHER_MEANING_SIGNALS + if not merged.get("competitor_signals"): + merged["competitor_signals"] = AIRLINE_COMPETITOR_SIGNALS + # Optional: no default place list; config can add other_meaning_anchors for extra patterns + if "other_meaning_anchors" not in merged: + merged["other_meaning_anchors"] = [] + if not merged.get("explicit_competitor_marker"): + merged["explicit_competitor_marker"] = AIRLINE_EXPLICIT_COMPETITOR_MARKER + if not merged.get("explicit_other_meaning_marker"): + merged["explicit_other_meaning_marker"] = AIRLINE_EXPLICIT_OTHER_MEANING_MARKER + if not merged.get("domain_words"): + merged["domain_words"] = ["airline", "airlines", "carrier"] + if not merged.get("competitors"): + merged["competitors"] = _load_competitors_excluding_brand( + merged.get("brand_self") or [] + ) + super().__init__(merged) + self._other_meaning_signals = list(merged.get("other_meaning_signals") or []) + self._competitor_signals = list(merged.get("competitor_signals") or []) + self._other_meaning_anchors = list(merged.get("other_meaning_anchors") or []) + self._explicit_competitor_marker = _compile_marker( + merged.get("explicit_competitor_marker") + ) + self._explicit_other_meaning_marker = _compile_marker( + merged.get("explicit_other_meaning_marker") + ) + + def _classify_ambiguous(self, text: str, token: str) -> Tuple[str, float]: + """Other meaning vs competitor using airline signals and explicit markers.""" + text_lower = text.lower() + if self._explicit_competitor_marker and self._explicit_competitor_marker.search( + text_lower + ) and _word_boundary_match(text_lower, token.lower()): + return "COMPETITOR", 0.85 + if self._explicit_other_meaning_marker and self._explicit_other_meaning_marker.search( + text_lower + ): + return "OTHER_MEANING", 0.85 + # Operational-only: baggage/lounge/check-in/refund with no comparison → product query + has_comparison = _count_signals(text_lower, AIRLINE_COMPARISON_SIGNALS) > 0 + operational_count = _count_signals(text_lower, AIRLINE_OPERATIONAL_SIGNALS) + if not has_comparison and operational_count > 0: + return "OTHER_MEANING", 0.85 + # Score: location/travel context vs airline context (no place-name list) + other_count = _count_signals(text_lower, self._other_meaning_signals) + if self._other_meaning_anchors: + other_count += _count_signals(text_lower, self._other_meaning_anchors) + comp_count = _count_signals(text_lower, self._competitor_signals) + total = other_count + comp_count + if total == 0: + return "OTHER_MEANING", 0.5 + other_ratio = other_count / total + comp_ratio = comp_count / total + if other_ratio >= 0.6: + return "OTHER_MEANING", min(0.9, 0.5 + 0.4 * other_ratio) + if comp_ratio >= 0.6: + return "COMPETITOR", min(0.9, 0.5 + 0.4 * comp_ratio) + return "OTHER_MEANING", 0.5 diff --git a/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/competitor_intent/base.py b/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/competitor_intent/base.py new file mode 100644 index 0000000000..7a86e5542b --- /dev/null +++ b/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/competitor_intent/base.py @@ -0,0 +1,272 @@ +""" +Generic competitor intent checker: two entity sets and overridable disambiguation. +""" + +import re +import unicodedata +from typing import Any, Dict, List, Optional, Pattern, Set, Tuple, cast + +from litellm.types.proxy.guardrails.guardrail_hooks.litellm_content_filter import ( + CompetitorActionHint, CompetitorIntentEvidenceEntry, + CompetitorIntentResult) + +ZERO_WIDTH = re.compile(r"[\u200b-\u200d\u2060\ufeff]") +LEET = {"@": "a", "4": "a", "0": "o", "3": "e", "1": "i", "5": "s", "7": "t"} + +OTHER_MEANING_DEFAULT_THRESHOLD = ( + 0.65 # Below this → treat as non-competitor (safe default). +) + + +def normalize(text: str) -> str: + """Lowercase, NFKC, strip zero-width, leetspeak, collapse spaces.""" + if not text or not isinstance(text, str): + return "" + t = ZERO_WIDTH.sub("", text) + t = unicodedata.normalize("NFKC", t).lower().strip() + for c, r in LEET.items(): + t = t.replace(c, r) + return re.sub(r"\s+", " ", t) + + +def _word_boundary_match(text: str, token: str) -> bool: + """True if token appears as a word in text.""" + return bool(re.search(r"\b" + re.escape(token) + r"\b", text)) + + +def _count_signals(text: str, patterns: List[str]) -> int: + """Count how many of the patterns appear in text.""" + return sum(1 for p in patterns if re.search(p, text, re.IGNORECASE)) + + +def _compile_marker(pattern: Optional[str]) -> Optional[Pattern[str]]: + """Compile optional regex string to a pattern.""" + if not pattern or not pattern.strip(): + return None + try: + return re.compile(pattern, re.IGNORECASE) + except re.error: + return None + + +def text_for_entity_matching(text: str) -> str: + """Letters-only variant for entity matching (e.g. split punctuation).""" + t = re.sub(r"[^\w\s]", " ", text) + return re.sub(r"\s+", " ", t).strip() + + +class BaseCompetitorIntentChecker: + """ + Generic competitor intent checker with two entity sets. Ambiguous tokens + (competitor + other-meaning, e.g. location) are classified by overridable + _classify_ambiguous(). Base implementation: treat as non-competitor. + """ + + def __init__(self, config: Dict[str, Any]) -> None: + self.brand_self: List[str] = [ + s.lower().strip() for s in (config.get("brand_self") or []) if s + ] + competitors: List[str] = [ + s.lower().strip() for s in (config.get("competitors") or []) if s + ] + aliases_map: Dict[str, List[str]] = config.get("competitor_aliases") or {} + self.competitor_canonical: Dict[str, str] = {} + self._competitor_tokens: Set[str] = set() + for c in competitors: + self._competitor_tokens.add(c) + self.competitor_canonical[c] = c + for a in aliases_map.get(c) or []: + a = a.lower().strip() + if a: + self._competitor_tokens.add(a) + self.competitor_canonical[a] = c + + other: List[str] = [ + s.lower().strip() for s in (config.get("locations") or []) if s + ] + self._other_meaning_tokens: Set[str] = set(other) + self._ambiguous: Set[str] = self._competitor_tokens & self._other_meaning_tokens + + self.policy: Dict[str, str] = config.get("policy") or {} + self.threshold_high = float(config.get("threshold_high", 0.70)) + self.threshold_medium = float(config.get("threshold_medium", 0.45)) + self.threshold_low = float(config.get("threshold_low", 0.30)) + self.reframe_message_template: Optional[str] = config.get( + "reframe_message_template" + ) + self.refuse_message_template: Optional[str] = config.get( + "refuse_message_template" + ) + self._comparison_words: List[str] = list( + config.get("comparison_words") + or [ + "better", + "worse", + "best", + "vs", + "versus", + "compare", + "alternative", + "recommend", + "ranked", + ] + ) + self._domain_words: List[str] = [ + s.lower().strip() for s in (config.get("domain_words") or []) if s + ] + + def _classify_ambiguous(self, text: str, token: str) -> Tuple[str, float]: + """ + Override in subclasses for industry-specific logic. Base: treat as non-competitor. + """ + return "OTHER_MEANING", 0.5 + + def _find_matches(self, text: str) -> List[Tuple[str, str, bool]]: + """Find competitor matches; mark ambiguous (also in other-meaning set).""" + normalized = normalize(text) + found: List[Tuple[str, str, bool]] = [] + seen: Set[Tuple[str, str]] = set() + for token in self._competitor_tokens: + if not _word_boundary_match(normalized, token): + continue + canonical = self.competitor_canonical.get(token, token) + key = (token, canonical) + if key in seen: + continue + seen.add(key) + is_ambig = token in self._ambiguous or token in self._other_meaning_tokens + found.append((token, canonical, is_ambig)) + return found + + def run(self, text: str) -> CompetitorIntentResult: + """Classify competitor intent; non-competitor when ambiguous or low confidence.""" + normalized = normalize(text) + evidence: List[CompetitorIntentEvidenceEntry] = [] + entities: Dict[str, List[str]] = { + "brand_self": [], + "competitors": [], + "category": [], + } + + for b in self.brand_self: + if _word_boundary_match(normalized, b): + entities["brand_self"].append(b) + evidence.append( + {"type": "entity", "key": "brand_self", "value": b, "match": b} + ) + + matches = self._find_matches(text) + if not matches: + has_comparison = any( + re.search(r"\b" + re.escape(w) + r"\b", normalized) + for w in self._comparison_words + ) + has_domain = self._domain_words and any( + re.search(r"\b" + re.escape(w) + r"\b", normalized) + for w in self._domain_words + ) + if has_comparison and has_domain: + evidence.append( + { + "type": "signal", + "key": "category_ranking", + "match": "comparison + domain", + } + ) + action_hint = cast( + CompetitorActionHint, + self.policy.get("category_ranking", "reframe"), + ) + return { + "intent": "category_ranking", + "confidence": 0.65, + "entities": entities, + "signals": ["category_ranking"], + "action_hint": action_hint, + "evidence": evidence, + } + return { + "intent": "other", + "confidence": 0.0, + "entities": entities, + "signals": [], + "action_hint": "allow", + "evidence": evidence, + } + + competitor_resolved: List[str] = [] + for token, canonical, _ in matches: + label, conf = self._classify_ambiguous(normalized, token) + if label == "OTHER_MEANING": + evidence.append( + {"type": "signal", "key": "other_meaning", "match": token} + ) + continue + if label == "COMPETITOR": + competitor_resolved.append(canonical) + evidence.append( + { + "type": "entity", + "key": "competitor", + "value": canonical, + "match": token, + } + ) + if conf < OTHER_MEANING_DEFAULT_THRESHOLD: + competitor_resolved.pop() + evidence.append( + { + "type": "signal", + "key": "other_meaning_default", + "match": f"confidence {conf:.2f}", + } + ) + continue + + entities["competitors"] = list(dict.fromkeys(competitor_resolved)) + + if not competitor_resolved: + return { + "intent": "other", + "confidence": 0.0, + "entities": entities, + "signals": ["other_meaning_or_ambiguous"], + "action_hint": "allow", + "evidence": evidence, + } + + has_comparison = any( + re.search(r"\b" + re.escape(w) + r"\b", normalized) + for w in self._comparison_words + ) + if has_comparison: + evidence.append( + {"type": "signal", "key": "comparison", "match": "comparison language"} + ) + confidence = 0.75 if has_comparison else 0.55 + if confidence >= self.threshold_high: + intent = "competitor_comparison" + elif confidence >= self.threshold_medium: + intent = "possible_competitor_comparison" + elif confidence >= self.threshold_low: + intent = "log_only" + else: + intent = "other" + + action_hint: CompetitorActionHint = cast( + CompetitorActionHint, self.policy.get(intent, "allow") + ) + if intent == "log_only": + action_hint = "log_only" + if intent == "other": + action_hint = "allow" + + return { + "intent": intent, + "confidence": round(confidence, 2), + "entities": entities, + "signals": ["competitor_resolved"] + + (["comparison"] if has_comparison else []), + "action_hint": action_hint, + "evidence": evidence, + } diff --git a/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/competitor_intent/major_airlines.json b/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/competitor_intent/major_airlines.json new file mode 100644 index 0000000000..bdffa36d2c --- /dev/null +++ b/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/competitor_intent/major_airlines.json @@ -0,0 +1,2459 @@ +[ + { + "id": "abx-air", + "match": "abx air|gb", + "tags": [ + "airline" + ] + }, + { + "id": "aegean-airlines", + "match": "aegean airlines|a3", + "tags": [ + "airline" + ] + }, + { + "id": "aer-lingus", + "match": "aer lingus|ei", + "tags": [ + "airline" + ] + }, + { + "id": "aero-mongolia", + "match": "aero mongolia|m0", + "tags": [ + "airline" + ] + }, + { + "id": "aero-republica", + "match": "aero republica|p5", + "tags": [ + "airline" + ] + }, + { + "id": "aeroflot", + "match": "aeroflot|su", + "tags": [ + "airline" + ] + }, + { + "id": "aeroitalia", + "match": "aeroitalia|xz", + "tags": [ + "airline" + ] + }, + { + "id": "aerolineas-argentinas", + "match": "aerolineas argentinas|ar", + "tags": [ + "airline" + ] + }, + { + "id": "aeromexico", + "match": "aeromexico|am", + "tags": [ + "airline" + ] + }, + { + "id": "africa-world-airlines", + "match": "africa world airlines|aw", + "tags": [ + "airline" + ] + }, + { + "id": "air-algerie", + "match": "air algérie|ah", + "tags": [ + "airline" + ] + }, + { + "id": "air-arabia", + "match": "air arabia|g9", + "tags": [ + "airline" + ] + }, + { + "id": "air-astana", + "match": "air astana|kc", + "tags": [ + "airline" + ] + }, + { + "id": "air-astra", + "match": "air astra|2a", + "tags": [ + "airline" + ] + }, + { + "id": "air-atlanta-icelandic", + "match": "air atlanta icelandic|cc", + "tags": [ + "airline" + ] + }, + { + "id": "air-austral", + "match": "air austral|uu", + "tags": [ + "airline" + ] + }, + { + "id": "air-baltic", + "match": "air baltic|bt", + "tags": [ + "airline" + ] + }, + { + "id": "air-botswana", + "match": "air botswana|bp", + "tags": [ + "airline" + ] + }, + { + "id": "air-cairo", + "match": "air cairo|sm", + "tags": [ + "airline" + ] + }, + { + "id": "air-caledonie", + "match": "air caledonie|ty", + "tags": [ + "airline" + ] + }, + { + "id": "air-cambodia", + "match": "air cambodia|k6", + "tags": [ + "airline" + ] + }, + { + "id": "air-canada", + "match": "air canada|ac", + "tags": [ + "airline" + ] + }, + { + "id": "air-caraibes", + "match": "air caraibes|tx", + "tags": [ + "airline" + ] + }, + { + "id": "air-changan", + "match": "air changan|9h", + "tags": [ + "airline" + ] + }, + { + "id": "air-china", + "match": "air china|ca", + "tags": [ + "airline" + ] + }, + { + "id": "air-corsica", + "match": "air corsica|xk", + "tags": [ + "airline" + ] + }, + { + "id": "air-dolomiti", + "match": "air dolomiti|en", + "tags": [ + "airline" + ] + }, + { + "id": "air-europa", + "match": "air europa|ux", + "tags": [ + "airline" + ] + }, + { + "id": "air-france", + "match": "air france|af", + "tags": [ + "airline" + ] + }, + { + "id": "air-guilin", + "match": "air guilin|gt", + "tags": [ + "airline" + ] + }, + { + "id": "air-hong-kong", + "match": "air hong kong|ld", + "tags": [ + "airline" + ] + }, + { + "id": "air-india", + "match": "air india|ai", + "tags": [ + "airline" + ] + }, + { + "id": "air-india-express", + "match": "air india express|ix", + "tags": [ + "airline" + ] + }, + { + "id": "air-koryo", + "match": "air koryo|js", + "tags": [ + "airline" + ] + }, + { + "id": "air-macau", + "match": "air macau|nx", + "tags": [ + "airline" + ] + }, + { + "id": "air-mauritius", + "match": "air mauritius|mk", + "tags": [ + "airline" + ] + }, + { + "id": "air-montenegro", + "match": "air montenegro|4o", + "tags": [ + "airline" + ] + }, + { + "id": "air-new-zealand", + "match": "air new zealand|nz", + "tags": [ + "airline" + ] + }, + { + "id": "air-niugini", + "match": "air niugini|px", + "tags": [ + "airline" + ] + }, + { + "id": "air-nostrum", + "match": "air nostrum|yw", + "tags": [ + "airline" + ] + }, + { + "id": "air-peace", + "match": "air peace|p4", + "tags": [ + "airline" + ] + }, + { + "id": "air-premia", + "match": "air premia|yp", + "tags": [ + "airline" + ] + }, + { + "id": "air-senegal", + "match": "air senegal|hc", + "tags": [ + "airline" + ] + }, + { + "id": "air-serbia", + "match": "air serbia|ju", + "tags": [ + "airline" + ] + }, + { + "id": "air-seychelles", + "match": "air seychelles|hm", + "tags": [ + "airline" + ] + }, + { + "id": "air-tahiti", + "match": "air tahiti|vt", + "tags": [ + "airline" + ] + }, + { + "id": "air-tahiti-nui", + "match": "air tahiti nui|tn", + "tags": [ + "airline" + ] + }, + { + "id": "air-tanzania", + "match": "air tanzania|tc", + "tags": [ + "airline" + ] + }, + { + "id": "air-transat", + "match": "air transat|ts", + "tags": [ + "airline" + ] + }, + { + "id": "air-vanuatu", + "match": "air vanuatu|nf", + "tags": [ + "airline" + ] + }, + { + "id": "aircalin", + "match": "aircalin|sb", + "tags": [ + "airline" + ] + }, + { + "id": "airhub-airlines", + "match": "airhub airlines|re", + "tags": [ + "airline" + ] + }, + { + "id": "airline-geo-sky", + "match": "airline geo sky|d4", + "tags": [ + "airline" + ] + }, + { + "id": "airlink", + "match": "airlink|4z", + "tags": [ + "airline" + ] + }, + { + "id": "airzeta", + "match": "airzeta|kj", + "tags": [ + "airline" + ] + }, + { + "id": "ajet", + "match": "ajet|vf", + "tags": [ + "airline" + ] + }, + { + "id": "akasa-air", + "match": "akasa air|qp", + "tags": [ + "airline" + ] + }, + { + "id": "alaska-airlines", + "match": "alaska airlines|as", + "tags": [ + "airline" + ] + }, + { + "id": "albastar", + "match": "albastar|ap", + "tags": [ + "airline" + ] + }, + { + "id": "almasria-universal-airlines", + "match": "almasria universal airlines|uj", + "tags": [ + "airline" + ] + }, + { + "id": "amelia", + "match": "amelia|8r", + "tags": [ + "airline" + ] + }, + { + "id": "american-airlines", + "match": "american airlines|aa", + "tags": [ + "airline" + ] + }, + { + "id": "ana", + "match": "ana|nh", + "tags": [ + "airline" + ] + }, + { + "id": "apg-airlines", + "match": "apg airlines|gp", + "tags": [ + "airline" + ] + }, + { + "id": "arajet", + "match": "arajet|dm", + "tags": [ + "airline" + ] + }, + { + "id": "arkia-israeli-airlines", + "match": "arkia israeli airlines|iz", + "tags": [ + "airline" + ] + }, + { + "id": "asiana-airlines", + "match": "asiana airlines|oz", + "tags": [ + "airline" + ] + }, + { + "id": "asky", + "match": "asky|kp", + "tags": [ + "airline" + ] + }, + { + "id": "asl-airlines-belgium", + "match": "asl airlines belgium|3v", + "tags": [ + "airline" + ] + }, + { + "id": "asl-airlines-france", + "match": "asl airlines france|5o", + "tags": [ + "airline" + ] + }, + { + "id": "asl-airlines-ireland", + "match": "asl airlines ireland|5h", + "tags": [ + "airline" + ] + }, + { + "id": "atlantic-airways", + "match": "atlantic airways|rc", + "tags": [ + "airline" + ] + }, + { + "id": "atlas-air", + "match": "atlas air|5y", + "tags": [ + "airline" + ] + }, + { + "id": "austrian", + "match": "austrian|os", + "tags": [ + "airline" + ] + }, + { + "id": "avianca", + "match": "avianca|av", + "tags": [ + "airline" + ] + }, + { + "id": "avianca-costa-rica", + "match": "avianca costa rica|lr", + "tags": [ + "airline" + ] + }, + { + "id": "avianca-ecuador", + "match": "avianca ecuador|2k", + "tags": [ + "airline" + ] + }, + { + "id": "avion-express", + "match": "avion express|x9", + "tags": [ + "airline" + ] + }, + { + "id": "avion-express-malta", + "match": "avion express malta|4x", + "tags": [ + "airline" + ] + }, + { + "id": "azerbaijan-airlines", + "match": "azerbaijan airlines|j2", + "tags": [ + "airline" + ] + }, + { + "id": "azores-airlines", + "match": "azores airlines|s4", + "tags": [ + "airline" + ] + }, + { + "id": "azul-brazilian-airlines", + "match": "azul brazilian airlines|ad", + "tags": [ + "airline" + ] + }, + { + "id": "badr-airlines", + "match": "badr airlines|j4", + "tags": [ + "airline" + ] + }, + { + "id": "bahamasair", + "match": "bahamasair|up", + "tags": [ + "airline" + ] + }, + { + "id": "bamboo-airways", + "match": "bamboo airways|qh", + "tags": [ + "airline" + ] + }, + { + "id": "bangkok-airways", + "match": "bangkok airways|pg", + "tags": [ + "airline" + ] + }, + { + "id": "batik-air", + "match": "batik air|id", + "tags": [ + "airline" + ] + }, + { + "id": "batik-air-malaysia", + "match": "batik air malaysia|od", + "tags": [ + "airline" + ] + }, + { + "id": "bbn-airlines", + "match": "bbn airlines|b5", + "tags": [ + "airline" + ] + }, + { + "id": "belavia-belarusian-airlines", + "match": "belavia belarusian airlines|b2", + "tags": [ + "airline" + ] + }, + { + "id": "biman-bangladesh-airlines", + "match": "biman bangladesh airlines|bg", + "tags": [ + "airline" + ] + }, + { + "id": "binter-canarias", + "match": "binter canarias|nt", + "tags": [ + "airline" + ] + }, + { + "id": "blue-bird-airways", + "match": "blue bird airways|bz", + "tags": [ + "airline" + ] + }, + { + "id": "boa-boliviana-de-aviacion", + "match": "boa boliviana de aviacion|ob", + "tags": [ + "airline" + ] + }, + { + "id": "british-airways", + "match": "british airways|ba", + "tags": [ + "airline" + ] + }, + { + "id": "brussels-airlines", + "match": "brussels airlines|sn", + "tags": [ + "airline" + ] + }, + { + "id": "bulgaria-air", + "match": "bulgaria air|fb", + "tags": [ + "airline" + ] + }, + { + "id": "camair-co", + "match": "camair-co|qc", + "tags": [ + "airline" + ] + }, + { + "id": "capital-airlines", + "match": "capital airlines|jd", + "tags": [ + "airline" + ] + }, + { + "id": "cargojet-airways", + "match": "cargojet airways|w8", + "tags": [ + "airline" + ] + }, + { + "id": "cargolux", + "match": "cargolux|cv", + "tags": [ + "airline" + ] + }, + { + "id": "caribbean-airlines", + "match": "caribbean airlines|bw", + "tags": [ + "airline" + ] + }, + { + "id": "carpatair", + "match": "carpatair|v3", + "tags": [ + "airline" + ] + }, + { + "id": "cathay-pacific", + "match": "cathay pacific|cx", + "tags": [ + "airline" + ] + }, + { + "id": "cebu-pacific", + "match": "cebu pacific|5j", + "tags": [ + "airline" + ] + }, + { + "id": "cemair", + "match": "cemair|5z", + "tags": [ + "airline" + ] + }, + { + "id": "chair-airlines", + "match": "chair airlines|cs", + "tags": [ + "airline" + ] + }, + { + "id": "chalair", + "match": "chalair|ce", + "tags": [ + "airline" + ] + }, + { + "id": "challenge-airlines-be", + "match": "challenge airlines (be)|x7", + "tags": [ + "airline" + ] + }, + { + "id": "challenge-airlines-il", + "match": "challenge airlines (il)|5c", + "tags": [ + "airline" + ] + }, + { + "id": "china-airlines", + "match": "china airlines|ci", + "tags": [ + "airline" + ] + }, + { + "id": "china-cargo-airlines", + "match": "china cargo airlines|ck", + "tags": [ + "airline" + ] + }, + { + "id": "china-eastern", + "match": "china eastern|mu", + "tags": [ + "airline" + ] + }, + { + "id": "china-express-airlines", + "match": "china express airlines|g5", + "tags": [ + "airline" + ] + }, + { + "id": "china-postal-airlines", + "match": "china postal airlines|cf", + "tags": [ + "airline" + ] + }, + { + "id": "china-southern-airlines", + "match": "china southern airlines|cz", + "tags": [ + "airline" + ] + }, + { + "id": "china-united-airlines", + "match": "china united airlines|kn", + "tags": [ + "airline" + ] + }, + { + "id": "citilink", + "match": "citilink|qg", + "tags": [ + "airline" + ] + }, + { + "id": "cityjet", + "match": "cityjet|wx", + "tags": [ + "airline" + ] + }, + { + "id": "clic-air", + "match": "clic air|ve", + "tags": [ + "airline" + ] + }, + { + "id": "condor", + "match": "condor|de", + "tags": [ + "airline" + ] + }, + { + "id": "copa-airlines", + "match": "copa airlines|cm", + "tags": [ + "airline" + ] + }, + { + "id": "corendon-airlines", + "match": "corendon airlines|xc", + "tags": [ + "airline" + ] + }, + { + "id": "corsair-international", + "match": "corsair international|ss", + "tags": [ + "airline" + ] + }, + { + "id": "croatia-airlines", + "match": "croatia airlines|ou", + "tags": [ + "airline" + ] + }, + { + "id": "cubana", + "match": "cubana|cu", + "tags": [ + "airline" + ] + }, + { + "id": "cyprus-airways", + "match": "cyprus airways|cy", + "tags": [ + "airline" + ] + }, + { + "id": "dan-air", + "match": "dan air|dn", + "tags": [ + "airline" + ] + }, + { + "id": "delta-air-lines", + "match": "delta air lines|dl", + "tags": [ + "airline" + ] + }, + { + "id": "dhl-air", + "match": "dhl air|d0", + "tags": [ + "airline" + ] + }, + { + "id": "discover-airlines", + "match": "discover airlines|4y", + "tags": [ + "airline" + ] + }, + { + "id": "edelweiss-air", + "match": "edelweiss air|wk", + "tags": [ + "airline" + ] + }, + { + "id": "egyptair", + "match": "egyptair|ms", + "tags": [ + "airline" + ] + }, + { + "id": "el-al", + "match": "el al|ly", + "tags": [ + "airline" + ] + }, + { + "id": "electra-airways", + "match": "electra airways|3e", + "tags": [ + "airline" + ] + }, + { + "id": "emirates", + "match": "emirates|ek", + "tags": [ + "airline" + ] + }, + { + "id": "estafeta-cargo", + "match": "estafeta cargo|e7", + "tags": [ + "airline" + ] + }, + { + "id": "ethiopian-airlines", + "match": "ethiopian airlines|et", + "tags": [ + "airline" + ] + }, + { + "id": "etihad-airways", + "match": "etihad airways|ey", + "tags": [ + "airline" + ] + }, + { + "id": "euroatlantic-airways", + "match": "euroatlantic airways|yu", + "tags": [ + "airline" + ] + }, + { + "id": "european-air-transport", + "match": "european air transport|qy", + "tags": [ + "airline" + ] + }, + { + "id": "eurowings", + "match": "eurowings|ew", + "tags": [ + "airline" + ] + }, + { + "id": "eva-air", + "match": "eva air|br", + "tags": [ + "airline" + ] + }, + { + "id": "eznis-airways", + "match": "eznis airways|mg", + "tags": [ + "airline" + ] + }, + { + "id": "fastjet-zimbabwe", + "match": "fastjet zimbabwe|fn", + "tags": [ + "airline" + ] + }, + { + "id": "fedex-express", + "match": "fedex express|fx", + "tags": [ + "airline" + ] + }, + { + "id": "fiji-airways", + "match": "fiji airways|fj", + "tags": [ + "airline" + ] + }, + { + "id": "finnair", + "match": "finnair|ay", + "tags": [ + "airline" + ] + }, + { + "id": "flexflight", + "match": "flexflight|w2", + "tags": [ + "airline" + ] + }, + { + "id": "fly-baghdad", + "match": "fly baghdad|if", + "tags": [ + "airline" + ] + }, + { + "id": "fly-namibia", + "match": "fly namibia|wv", + "tags": [ + "airline" + ] + }, + { + "id": "flyadeal", + "match": "flyadeal|f3", + "tags": [ + "airline" + ] + }, + { + "id": "flydubai", + "match": "flydubai|fz", + "tags": [ + "airline" + ] + }, + { + "id": "flygabon", + "match": "flygabon|j7", + "tags": [ + "airline" + ] + }, + { + "id": "flynas", + "match": "flynas|xy", + "tags": [ + "airline" + ] + }, + { + "id": "flyone", + "match": "flyone|5f", + "tags": [ + "airline" + ] + }, + { + "id": "freebird-airlines", + "match": "freebird airlines|fh", + "tags": [ + "airline" + ] + }, + { + "id": "french-bee", + "match": "french bee|bf", + "tags": [ + "airline" + ] + }, + { + "id": "fuzhou-airlines", + "match": "fuzhou airlines|fu", + "tags": [ + "airline" + ] + }, + { + "id": "garuda-indonesia", + "match": "garuda indonesia|ga", + "tags": [ + "airline" + ] + }, + { + "id": "georgian-airways", + "match": "georgian airways|a9", + "tags": [ + "airline" + ] + }, + { + "id": "german-airways", + "match": "german airways|zq", + "tags": [ + "airline" + ] + }, + { + "id": "global-airways-and-lift", + "match": "global airways and lift|ge", + "tags": [ + "airline" + ] + }, + { + "id": "globalx", + "match": "globalx|g6", + "tags": [ + "airline" + ] + }, + { + "id": "gol-linhas-aereas", + "match": "gol linhas aereas|g3", + "tags": [ + "airline" + ] + }, + { + "id": "greater-bay-airlines", + "match": "greater bay airlines|hb", + "tags": [ + "airline" + ] + }, + { + "id": "gulf-air", + "match": "gulf air|gf", + "tags": [ + "airline" + ] + }, + { + "id": "gx-airlines", + "match": "gx airlines|gx", + "tags": [ + "airline" + ] + }, + { + "id": "hainan-airlines", + "match": "hainan airlines|hu", + "tags": [ + "airline" + ] + }, + { + "id": "hawaiian-airlines", + "match": "hawaiian airlines|ha", + "tags": [ + "airline" + ] + }, + { + "id": "hebei-airlines", + "match": "hebei airlines|ns", + "tags": [ + "airline" + ] + }, + { + "id": "hello-jets", + "match": "hello jets|h3", + "tags": [ + "airline" + ] + }, + { + "id": "himalaya-airlines", + "match": "himalaya airlines|h9", + "tags": [ + "airline" + ] + }, + { + "id": "hong-kong-air-cargo", + "match": "hong kong air cargo|rh", + "tags": [ + "airline" + ] + }, + { + "id": "hong-kong-airlines", + "match": "hong kong airlines|hx", + "tags": [ + "airline" + ] + }, + { + "id": "hong-kong-express-airways", + "match": "hong kong express airways|uo", + "tags": [ + "airline" + ] + }, + { + "id": "iberia", + "match": "iberia|ib", + "tags": [ + "airline" + ] + }, + { + "id": "iberojet", + "match": "iberojet|6o", + "tags": [ + "airline" + ] + }, + { + "id": "iberojet-airlines", + "match": "iberojet airlines|e9", + "tags": [ + "airline" + ] + }, + { + "id": "ibom-air", + "match": "ibom air|qi", + "tags": [ + "airline" + ] + }, + { + "id": "icelandair", + "match": "icelandair|fi", + "tags": [ + "airline" + ] + }, + { + "id": "ikar", + "match": "ikar|eo", + "tags": [ + "airline" + ] + }, + { + "id": "indigo", + "match": "indigo|6e", + "tags": [ + "airline" + ] + }, + { + "id": "iran-air", + "match": "iran air|ir", + "tags": [ + "airline" + ] + }, + { + "id": "iran-airtour-airline", + "match": "iran airtour airline|b9", + "tags": [ + "airline" + ] + }, + { + "id": "iran-aseman-airlines", + "match": "iran aseman airlines|ep", + "tags": [ + "airline" + ] + }, + { + "id": "israir", + "match": "israir|6h", + "tags": [ + "airline" + ] + }, + { + "id": "ita-airways", + "match": "ita airways|az", + "tags": [ + "airline" + ] + }, + { + "id": "japan-airlines", + "match": "japan airlines|jl", + "tags": [ + "airline" + ] + }, + { + "id": "japan-transocean-air", + "match": "japan transocean air|nu", + "tags": [ + "airline" + ] + }, + { + "id": "jazeera-airways", + "match": "jazeera airways|j9", + "tags": [ + "airline" + ] + }, + { + "id": "jd-airlines", + "match": "jd airlines|jg", + "tags": [ + "airline" + ] + }, + { + "id": "jeju-air", + "match": "jeju air|7c", + "tags": [ + "airline" + ] + }, + { + "id": "jetblue", + "match": "jetblue|b6", + "tags": [ + "airline" + ] + }, + { + "id": "jin-air", + "match": "jin air|lj", + "tags": [ + "airline" + ] + }, + { + "id": "jordan-aviation", + "match": "jordan aviation|r5", + "tags": [ + "airline" + ] + }, + { + "id": "juneyao-airlines", + "match": "juneyao airlines|ho", + "tags": [ + "airline" + ] + }, + { + "id": "kam-air", + "match": "kam air|rq", + "tags": [ + "airline" + ] + }, + { + "id": "kenya-airways", + "match": "kenya airways|kq", + "tags": [ + "airline" + ] + }, + { + "id": "klm", + "match": "klm|kl", + "tags": [ + "airline" + ] + }, + { + "id": "km-malta-airlines", + "match": "km malta airlines|km", + "tags": [ + "airline" + ] + }, + { + "id": "korean-air", + "match": "korean air|ke", + "tags": [ + "airline" + ] + }, + { + "id": "kunming-airlines", + "match": "kunming airlines|ky", + "tags": [ + "airline" + ] + }, + { + "id": "kuwait-airways", + "match": "kuwait airways|ku", + "tags": [ + "airline" + ] + }, + { + "id": "la-compagnie", + "match": "la compagnie|b0", + "tags": [ + "airline" + ] + }, + { + "id": "lam", + "match": "lam|tm", + "tags": [ + "airline" + ] + }, + { + "id": "lao-airlines", + "match": "lao airlines|qv", + "tags": [ + "airline" + ] + }, + { + "id": "latam-airlines-brasil", + "match": "latam airlines brasil|jj", + "tags": [ + "airline" + ] + }, + { + "id": "latam-airlines-colombia", + "match": "latam airlines colombia|4c", + "tags": [ + "airline" + ] + }, + { + "id": "latam-airlines-ecuador", + "match": "latam airlines ecuador|xl", + "tags": [ + "airline" + ] + }, + { + "id": "latam-airlines-group", + "match": "latam airlines group|la", + "tags": [ + "airline" + ] + }, + { + "id": "latam-airlines-paraguay", + "match": "latam airlines paraguay|pz", + "tags": [ + "airline" + ] + }, + { + "id": "latam-airlines-peru", + "match": "latam airlines peru|lp", + "tags": [ + "airline" + ] + }, + { + "id": "latam-cargo-brasil", + "match": "latam cargo brasil|m3", + "tags": [ + "airline" + ] + }, + { + "id": "latam-cargo-chile", + "match": "latam cargo chile|uc", + "tags": [ + "airline" + ] + }, + { + "id": "link-airways", + "match": "link airways|fc", + "tags": [ + "airline" + ] + }, + { + "id": "lion-air", + "match": "lion air|jt", + "tags": [ + "airline" + ] + }, + { + "id": "loganair", + "match": "loganair|lm", + "tags": [ + "airline" + ] + }, + { + "id": "loong-air", + "match": "loong air|gj", + "tags": [ + "airline" + ] + }, + { + "id": "lot-polish-airlines", + "match": "lot polish airlines|lo", + "tags": [ + "airline" + ] + }, + { + "id": "lucky-air", + "match": "lucky air|8l", + "tags": [ + "airline" + ] + }, + { + "id": "lufthansa", + "match": "lufthansa|lh", + "tags": [ + "airline" + ] + }, + { + "id": "lufthansa-cargo", + "match": "lufthansa cargo|lh", + "tags": [ + "airline" + ] + }, + { + "id": "lufthansa-cityline", + "match": "lufthansa cityline|cl", + "tags": [ + "airline" + ] + }, + { + "id": "luxair", + "match": "luxair|lg", + "tags": [ + "airline" + ] + }, + { + "id": "madagascar-airlines", + "match": "madagascar airlines|md", + "tags": [ + "airline" + ] + }, + { + "id": "malaysia-airlines", + "match": "malaysia airlines|mh", + "tags": [ + "airline" + ] + }, + { + "id": "mandarin-airlines", + "match": "mandarin airlines|ae", + "tags": [ + "airline" + ] + }, + { + "id": "martinair-cargo", + "match": "martinair cargo|mp", + "tags": [ + "airline" + ] + }, + { + "id": "masair", + "match": "masair|m7", + "tags": [ + "airline" + ] + }, + { + "id": "mauritania-airlines-international", + "match": "mauritania airlines international|l6", + "tags": [ + "airline" + ] + }, + { + "id": "mavi-gok-airlines-mga", + "match": "mavi gök airlines (mga)|4m", + "tags": [ + "airline" + ] + }, + { + "id": "mea", + "match": "mea|me", + "tags": [ + "airline" + ] + }, + { + "id": "miat-mongolian-airlines", + "match": "miat mongolian airlines|om", + "tags": [ + "airline" + ] + }, + { + "id": "mng-airlines", + "match": "mng airlines|mb", + "tags": [ + "airline" + ] + }, + { + "id": "myanmar-airways-international", + "match": "myanmar airways international|8m", + "tags": [ + "airline" + ] + }, + { + "id": "myanmar-national-airlines", + "match": "myanmar national airlines|ub", + "tags": [ + "airline" + ] + }, + { + "id": "national-airlines", + "match": "national airlines|n8", + "tags": [ + "airline" + ] + }, + { + "id": "nauru-airlines", + "match": "nauru airlines|on", + "tags": [ + "airline" + ] + }, + { + "id": "neos", + "match": "neos|no", + "tags": [ + "airline" + ] + }, + { + "id": "nesma-airlines", + "match": "nesma airlines|ne", + "tags": [ + "airline" + ] + }, + { + "id": "new-pacific-airlines", + "match": "new pacific airlines|7h", + "tags": [ + "airline" + ] + }, + { + "id": "nile-air", + "match": "nile air|np", + "tags": [ + "airline" + ] + }, + { + "id": "nippon-cargo-airlines", + "match": "nippon cargo airlines|kz", + "tags": [ + "airline" + ] + }, + { + "id": "nok-air", + "match": "nok air|dd", + "tags": [ + "airline" + ] + }, + { + "id": "nordstar", + "match": "nordstar|y7", + "tags": [ + "airline" + ] + }, + { + "id": "nordwind-airlines", + "match": "nordwind airlines|n4", + "tags": [ + "airline" + ] + }, + { + "id": "norra", + "match": "norra|n7", + "tags": [ + "airline" + ] + }, + { + "id": "nouvelair", + "match": "nouvelair|bj", + "tags": [ + "airline" + ] + }, + { + "id": "okay-airways", + "match": "okay airways|bk", + "tags": [ + "airline" + ] + }, + { + "id": "olympic-air", + "match": "olympic air|oa", + "tags": [ + "airline" + ] + }, + { + "id": "oman-air", + "match": "oman air|wy", + "tags": [ + "airline" + ] + }, + { + "id": "overland-airways", + "match": "overland airways|of", + "tags": [ + "airline" + ] + }, + { + "id": "pakistan-international-airlines", + "match": "pakistan international airlines|pk", + "tags": [ + "airline" + ] + }, + { + "id": "pal-express", + "match": "pal express|2p", + "tags": [ + "airline" + ] + }, + { + "id": "paranair", + "match": "paranair|zp", + "tags": [ + "airline" + ] + }, + { + "id": "pegasus-airlines", + "match": "pegasus airlines|pc", + "tags": [ + "airline" + ] + }, + { + "id": "petroleum-air-services", + "match": "petroleum air services|uf", + "tags": [ + "airline" + ] + }, + { + "id": "philippine-airlines", + "match": "philippine airlines|pr", + "tags": [ + "airline" + ] + }, + { + "id": "plus-ultra", + "match": "plus ultra|pu", + "tags": [ + "airline" + ] + }, + { + "id": "polar-air-cargo", + "match": "polar air cargo|po", + "tags": [ + "airline" + ] + }, + { + "id": "populair", + "match": "populair|hp", + "tags": [ + "airline" + ] + }, + { + "id": "poste-air-cargo", + "match": "poste air cargo|m4", + "tags": [ + "airline" + ] + }, + { + "id": "precision-air", + "match": "precision air|pw", + "tags": [ + "airline" + ] + }, + { + "id": "privilege-style", + "match": "privilege style|p6", + "tags": [ + "airline" + ] + }, + { + "id": "proflight-zambia", + "match": "proflight zambia|p0", + "tags": [ + "airline" + ] + }, + { + "id": "qantas", + "match": "qantas|qf", + "tags": [ + "airline" + ] + }, + { + "id": "qatar-airways", + "match": "qatar airways|qr", + "tags": [ + "airline" + ] + }, + { + "id": "qazaq-air", + "match": "qazaq air|iq", + "tags": [ + "airline" + ] + }, + { + "id": "qingdao-airlines", + "match": "qingdao airlines|qw", + "tags": [ + "airline" + ] + }, + { + "id": "red-sea-airlines", + "match": "red sea airlines|4s", + "tags": [ + "airline" + ] + }, + { + "id": "rossiya-airlines", + "match": "rossiya airlines|fv", + "tags": [ + "airline" + ] + }, + { + "id": "royal-air-maroc", + "match": "royal air maroc|at", + "tags": [ + "airline" + ] + }, + { + "id": "royal-brunei", + "match": "royal brunei|bi", + "tags": [ + "airline" + ] + }, + { + "id": "royal-jordanian", + "match": "royal jordanian|rj", + "tags": [ + "airline" + ] + }, + { + "id": "ruili-airlines", + "match": "ruili airlines|dr", + "tags": [ + "airline" + ] + }, + { + "id": "rusline", + "match": "rusline|7r", + "tags": [ + "airline" + ] + }, + { + "id": "rwandair", + "match": "rwandair|wb", + "tags": [ + "airline" + ] + }, + { + "id": "s7-airlines", + "match": "s7 airlines|s7", + "tags": [ + "airline" + ] + }, + { + "id": "safair", + "match": "safair|fa", + "tags": [ + "airline" + ] + }, + { + "id": "salam-air", + "match": "salam air|ov", + "tags": [ + "airline" + ] + }, + { + "id": "sas", + "match": "sas|sk", + "tags": [ + "airline" + ] + }, + { + "id": "sata-air-acores", + "match": "sata air acores|sp", + "tags": [ + "airline" + ] + }, + { + "id": "saudi-arabian-airlines", + "match": "saudi arabian airlines|sv", + "tags": [ + "airline" + ] + }, + { + "id": "scat-airlines", + "match": "scat airlines|dv", + "tags": [ + "airline" + ] + }, + { + "id": "scoot", + "match": "scoot|tr", + "tags": [ + "airline" + ] + }, + { + "id": "sf-airlines", + "match": "sf airlines|o3", + "tags": [ + "airline" + ] + }, + { + "id": "shandong-airlines", + "match": "shandong airlines|sc", + "tags": [ + "airline" + ] + }, + { + "id": "shanghai-airlines", + "match": "shanghai airlines|fm", + "tags": [ + "airline" + ] + }, + { + "id": "shenzhen-airlines", + "match": "shenzhen airlines|zh", + "tags": [ + "airline" + ] + }, + { + "id": "sichuan-airlines", + "match": "sichuan airlines|3u", + "tags": [ + "airline" + ] + }, + { + "id": "silk-way-west-airlines", + "match": "silk way west airlines|7l", + "tags": [ + "airline" + ] + }, + { + "id": "singapore-airlines", + "match": "singapore airlines|sq", + "tags": [ + "airline" + ] + }, + { + "id": "sky-airline", + "match": "sky airline|h2", + "tags": [ + "airline" + ] + }, + { + "id": "sky-express", + "match": "sky express|gq", + "tags": [ + "airline" + ] + }, + { + "id": "skyup-airlines", + "match": "skyup airlines|pq", + "tags": [ + "airline" + ] + }, + { + "id": "smartavia", + "match": "smartavia|5n", + "tags": [ + "airline" + ] + }, + { + "id": "smartwings", + "match": "smartwings|qs", + "tags": [ + "airline" + ] + }, + { + "id": "solomon-airlines", + "match": "solomon airlines|ie", + "tags": [ + "airline" + ] + }, + { + "id": "somon-air", + "match": "somon air|sz", + "tags": [ + "airline" + ] + }, + { + "id": "south-african-airways", + "match": "south african airways|sa", + "tags": [ + "airline" + ] + }, + { + "id": "southwest-airlines", + "match": "southwest airlines|wn", + "tags": [ + "airline" + ] + }, + { + "id": "southwind-airlines", + "match": "southwind airlines|2s", + "tags": [ + "airline" + ] + }, + { + "id": "spicejet", + "match": "spicejet|sg", + "tags": [ + "airline" + ] + }, + { + "id": "srilankan-airlines", + "match": "srilankan airlines|ul", + "tags": [ + "airline" + ] + }, + { + "id": "starlux-airlines", + "match": "starlux airlines|jx", + "tags": [ + "airline" + ] + }, + { + "id": "sun-country-airlines", + "match": "sun country airlines|sy", + "tags": [ + "airline" + ] + }, + { + "id": "sunexpress", + "match": "sunexpress|xq", + "tags": [ + "airline" + ] + }, + { + "id": "suparna-airlines", + "match": "suparna airlines|y8", + "tags": [ + "airline" + ] + }, + { + "id": "swiftair", + "match": "swiftair|wt", + "tags": [ + "airline" + ] + }, + { + "id": "swiss", + "match": "swiss|lx", + "tags": [ + "airline" + ] + }, + { + "id": "syrianair", + "match": "syrianair|rb", + "tags": [ + "airline" + ] + }, + { + "id": "tway-air", + "match": "t'way air|tw", + "tags": [ + "airline" + ] + }, + { + "id": "taag-angola-airlines", + "match": "taag angola airlines|dt", + "tags": [ + "airline" + ] + }, + { + "id": "taca", + "match": "taca|ta", + "tags": [ + "airline" + ] + }, + { + "id": "tag-airlines", + "match": "tag airlines|5u", + "tags": [ + "airline" + ] + }, + { + "id": "tap-air-portugal", + "match": "tap air portugal|tp", + "tags": [ + "airline" + ] + }, + { + "id": "tarom", + "match": "tarom|ro", + "tags": [ + "airline" + ] + }, + { + "id": "tassili-airlines", + "match": "tassili airlines|sf", + "tags": [ + "airline" + ] + }, + { + "id": "thai-airways-international", + "match": "thai airways international|tg", + "tags": [ + "airline" + ] + }, + { + "id": "thai-lion-air", + "match": "thai lion air|sl", + "tags": [ + "airline" + ] + }, + { + "id": "tianjin-airlines", + "match": "tianjin airlines|gs", + "tags": [ + "airline" + ] + }, + { + "id": "tibet-airlines", + "match": "tibet airlines|tv", + "tags": [ + "airline" + ] + }, + { + "id": "tuifly", + "match": "tuifly|x3", + "tags": [ + "airline" + ] + }, + { + "id": "tunisair", + "match": "tunisair|tu", + "tags": [ + "airline" + ] + }, + { + "id": "turkish-airlines", + "match": "turkish airlines|tk", + "tags": [ + "airline" + ] + }, + { + "id": "tus-airways", + "match": "tus airways|u8", + "tags": [ + "airline" + ] + }, + { + "id": "uni-air", + "match": "uni air|b7", + "tags": [ + "airline" + ] + }, + { + "id": "united-airlines", + "match": "united airlines|ua", + "tags": [ + "airline" + ] + }, + { + "id": "united-nigeria-airlines", + "match": "united nigeria airlines|un", + "tags": [ + "airline" + ] + }, + { + "id": "ups-airlines", + "match": "ups airlines|5x", + "tags": [ + "airline" + ] + }, + { + "id": "ural-airlines", + "match": "ural airlines|u6", + "tags": [ + "airline" + ] + }, + { + "id": "urumqi-air", + "match": "urumqi air|uq", + "tags": [ + "airline" + ] + }, + { + "id": "us-bangla-airlines", + "match": "us-bangla airlines|bs", + "tags": [ + "airline" + ] + }, + { + "id": "utair", + "match": "utair|ut", + "tags": [ + "airline" + ] + }, + { + "id": "uzbekistan-airways", + "match": "uzbekistan airways|hy", + "tags": [ + "airline" + ] + }, + { + "id": "vietjet", + "match": "vietjet|vj", + "tags": [ + "airline" + ] + }, + { + "id": "vietnam-airlines", + "match": "vietnam airlines|vn", + "tags": [ + "airline" + ] + }, + { + "id": "virgin-atlantic", + "match": "virgin atlantic|vs", + "tags": [ + "airline" + ] + }, + { + "id": "virgin-australia", + "match": "virgin australia|va", + "tags": [ + "airline" + ] + }, + { + "id": "volaris", + "match": "volaris|y4", + "tags": [ + "airline" + ] + }, + { + "id": "volotea", + "match": "volotea|v7", + "tags": [ + "airline" + ] + }, + { + "id": "vueling", + "match": "vueling|vy", + "tags": [ + "airline" + ] + }, + { + "id": "wamos-air", + "match": "wamos air|eb", + "tags": [ + "airline" + ] + }, + { + "id": "west-air", + "match": "west air|pn", + "tags": [ + "airline" + ] + }, + { + "id": "westjet", + "match": "westjet|ws", + "tags": [ + "airline" + ] + }, + { + "id": "white-coloured-by-you", + "match": "white coloured by you|wi", + "tags": [ + "airline" + ] + }, + { + "id": "wideroe", + "match": "wideroe|wf", + "tags": [ + "airline" + ] + }, + { + "id": "world2fly", + "match": "world2fly|2w", + "tags": [ + "airline" + ] + }, + { + "id": "xiamen-airlines", + "match": "xiamen airlines|mf", + "tags": [ + "airline" + ] + }, + { + "id": "yto-cargo-airlines", + "match": "yto cargo airlines|yg", + "tags": [ + "airline" + ] + } +] \ No newline at end of file diff --git a/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/content_filter.py b/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/content_filter.py index 0d9bbf385e..16c46a76cc 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/content_filter.py +++ b/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/content_filter.py @@ -10,19 +10,8 @@ import json import os import re from datetime import datetime -from typing import ( - TYPE_CHECKING, - Any, - AsyncGenerator, - Dict, - List, - Literal, - Optional, - Pattern, - Tuple, - Union, - cast, -) +from typing import (TYPE_CHECKING, Any, AsyncGenerator, Dict, List, Literal, + Optional, Pattern, Tuple, Union, cast) import yaml from fastapi import HTTPException @@ -31,31 +20,22 @@ from litellm import Router from litellm._logging import verbose_proxy_logger from litellm.integrations.custom_guardrail import CustomGuardrail from litellm.proxy._types import UserAPIKeyAuth -from litellm.types.utils import ( - GenericGuardrailAPIInputs, - GuardrailStatus, - GuardrailTracingDetail, - ModelResponseStream, -) +from litellm.types.utils import (GenericGuardrailAPIInputs, GuardrailStatus, + GuardrailTracingDetail, ModelResponseStream) if TYPE_CHECKING: from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj -from litellm.types.guardrails import ( - BlockedWord, - ContentFilterAction, - ContentFilterPattern, - GuardrailEventHooks, - Mode, -) +from litellm.types.guardrails import (BlockedWord, ContentFilterAction, + ContentFilterPattern, + GuardrailEventHooks, Mode) from litellm.types.proxy.guardrails.guardrail_hooks.litellm_content_filter import ( - BlockedWordDetection, - CategoryKeywordDetection, - ContentFilterCategoryConfig, - ContentFilterDetection, - PatternDetection, -) + BlockedWordDetection, CategoryKeywordDetection, CompetitorIntentDetection, + CompetitorIntentResult, ContentFilterCategoryConfig, + ContentFilterDetection, PatternDetection) +from .competitor_intent import (AirlineCompetitorIntentChecker, + BaseCompetitorIntentChecker) from .patterns import PATTERN_EXTRA_CONFIG, get_compiled_pattern MAX_KEYWORD_VALUE_GAP_WORDS = 1 @@ -162,6 +142,7 @@ class ContentFilterGuardrail(CustomGuardrail): severity_threshold: str = "medium", llm_router: Optional[Router] = None, image_model: Optional[str] = None, + competitor_intent_config: Optional[Dict[str, Any]] = None, **kwargs, ): """ @@ -212,6 +193,31 @@ class ContentFilterGuardrail(CustomGuardrail): {} ) # category_name -> {identifier_words, block_words, action, severity} + # Competitor intent checker (optional; airline uses major_airlines.json, generic requires competitors) + self._competitor_intent_checker: Optional[BaseCompetitorIntentChecker] = None + if competitor_intent_config and isinstance(competitor_intent_config, dict): + try: + competitor_intent_type = competitor_intent_config.get( + "competitor_intent_type", "airline" + ) + if competitor_intent_type == "generic": + self._competitor_intent_checker = BaseCompetitorIntentChecker( + competitor_intent_config + ) + else: + self._competitor_intent_checker = AirlineCompetitorIntentChecker( + competitor_intent_config + ) + verbose_proxy_logger.debug( + "ContentFilterGuardrail: competitor intent checker enabled (%s)", + competitor_intent_type, + ) + except Exception as e: + verbose_proxy_logger.warning( + "ContentFilterGuardrail: failed to init competitor intent checker: %s", + e, + ) + # Load categories if provided if categories: self._load_categories(categories) @@ -1220,9 +1226,7 @@ class ContentFilterGuardrail(CustomGuardrail): pattern_name=pattern_name.upper() ) text = self._mask_spans(text, spans, redaction_tag) - verbose_proxy_logger.info( - f"Masked all {pattern_name} matches in content" - ) + verbose_proxy_logger.info(f"Masked all {pattern_name} matches in content") return text @@ -1453,7 +1457,9 @@ class ContentFilterGuardrail(CustomGuardrail): masked_entity_count: Dictionary to update with counts """ for detection in detections: - if detection["action"] == ContentFilterAction.MASK.value: + if detection.get("type") == "competitor_intent": + continue + if detection.get("action") == ContentFilterAction.MASK.value: detection_type = detection["type"] if detection_type == "pattern": pattern_detection = cast(PatternDetection, detection) @@ -1479,18 +1485,27 @@ class ContentFilterGuardrail(CustomGuardrail): """Build match_details list from content filter detections.""" match_details: List[dict] = [] for detection in detections: - detail: dict = {"type": detection["type"], "action_taken": detection["action"]} + action_taken = detection.get("action", detection.get("action_hint", "")) + detail: dict = {"type": detection["type"], "action_taken": action_taken} if detection["type"] == "pattern": detail["detection_method"] = "regex" - detail["snippet"] = cast(PatternDetection, detection).get("pattern_name", "") + detail["snippet"] = cast(PatternDetection, detection).get( + "pattern_name", "" + ) elif detection["type"] == "blocked_word": detail["detection_method"] = "keyword" - detail["snippet"] = cast(BlockedWordDetection, detection).get("keyword", "") + detail["snippet"] = cast(BlockedWordDetection, detection).get( + "keyword", "" + ) elif detection["type"] == "category_keyword": detail["detection_method"] = "keyword" cat_det = cast(CategoryKeywordDetection, detection) detail["snippet"] = cat_det.get("keyword", "") detail["category"] = cat_det.get("category", "") + elif detection["type"] == "competitor_intent": + detail["detection_method"] = "intent" + detail["snippet"] = detection.get("intent", "") + detail["confidence"] = detection.get("confidence") match_details.append(detail) return match_details @@ -1500,19 +1515,28 @@ class ContentFilterGuardrail(CustomGuardrail): for detection in detections: if detection["type"] == "pattern": methods.add("regex") + elif detection["type"] == "competitor_intent": + methods.add("intent") else: methods.add("keyword") return ",".join(sorted(methods)) if methods else "" def _get_patterns_checked_count(self) -> int: """Get total number of patterns and keywords that were evaluated.""" - return len(self.compiled_patterns) + len(self.blocked_words) + len(self.category_keywords) + return ( + len(self.compiled_patterns) + + len(self.blocked_words) + + len(self.category_keywords) + ) def _get_policy_templates(self) -> Optional[str]: """Get comma-separated policy template names from loaded categories.""" if not self.loaded_categories: return None - names = [cat.description or cat.category_name for cat in self.loaded_categories.values()] + names = [ + cat.description or cat.category_name + for cat in self.loaded_categories.values() + ] return ", ".join(names) if names else None def _compute_risk_score( @@ -1550,6 +1574,72 @@ class ContentFilterGuardrail(CustomGuardrail): return round(min(10.0, score), 1) + def _apply_competitor_intent_policy( + self, + intent_result: CompetitorIntentResult, + request_data: dict, + detections: List[ContentFilterDetection], + ) -> None: + """ + Apply policy for competitor intent result: refuse (raise), reframe (passthrough), or log_only/allow (return). + Appends competitor_intent detection to detections. Never returns for refuse/reframe. + """ + intent_val = intent_result.get("intent", "other") + confidence_val = intent_result.get("confidence", 0.0) + action_hint_val = intent_result.get("action_hint", "allow") + evidence_list = intent_result.get("evidence", []) + detection: CompetitorIntentDetection = { + "type": "competitor_intent", + "intent": intent_val, + "confidence": confidence_val, + "action_hint": action_hint_val, + "entities": intent_result.get("entities", {}), + "signals": intent_result.get("signals", []), + "evidence": [dict(e) for e in evidence_list], + } + detections.append(detection) + + if action_hint_val == "refuse": + msg = "Content blocked: competitor comparison or ranking intent detected." + if self._competitor_intent_checker and getattr( + self._competitor_intent_checker, "refuse_message_template", None + ): + msg = self._competitor_intent_checker.refuse_message_template or msg + verbose_proxy_logger.warning( + "ContentFilterGuardrail: competitor intent refuse - %s", intent_val + ) + raise HTTPException( + status_code=403, + detail={ + "error": msg, + "intent": intent_val, + "confidence": confidence_val, + }, + ) + if action_hint_val == "reframe": + msg = ( + "I can help with questions about our products and services. " + "Would you like to compare specific features or get more information?" + ) + if self._competitor_intent_checker and getattr( + self._competitor_intent_checker, "reframe_message_template", None + ): + msg = self._competitor_intent_checker.reframe_message_template or msg + verbose_proxy_logger.info( + "ContentFilterGuardrail: competitor intent reframe - %s", intent_val + ) + self.raise_passthrough_exception( + violation_message=msg, + request_data=request_data, + detection_info=dict(intent_result), + ) + # log_only or allow: just log (detection already appended) + verbose_proxy_logger.debug( + "ContentFilterGuardrail: competitor intent %s (action_hint=%s)", + intent_val, + action_hint_val, + ) + def _log_guardrail_information( self, request_data: dict, @@ -1581,6 +1671,28 @@ class ContentFilterGuardrail(CustomGuardrail): else [dict(detection) for detection in detections] ) + # Competitor intent: add confidence and classification to tracing if present + tracing_kw: Dict[str, Any] = { + "guardrail_id": self.config_guardrail_id or self.guardrail_name, + "policy_template": self.config_policy_template + or self._get_policy_templates(), + "detection_method": ( + self._get_detection_methods(detections) if detections else None + ), + "match_details": ( + self._build_match_details(detections) if detections else None + ), + "patterns_checked": self._get_patterns_checked_count(), + "risk_score": self._compute_risk_score( + detections, masked_entity_count, status + ), + } + for d in detections: + if isinstance(d, dict) and d.get("type") == "competitor_intent": + tracing_kw["confidence_score"] = d.get("confidence") + tracing_kw["classification"] = dict(d) + break + self.add_standard_logging_guardrail_information_to_request_data( guardrail_provider=self.guardrail_provider, guardrail_json_response=guardrail_json_response, @@ -1590,14 +1702,7 @@ class ContentFilterGuardrail(CustomGuardrail): end_time=datetime.now().timestamp(), duration=(datetime.now() - start_time).total_seconds(), masked_entity_count=masked_entity_count, - tracing_detail=GuardrailTracingDetail( - guardrail_id=self.config_guardrail_id or self.guardrail_name, - policy_template=self.config_policy_template or self._get_policy_templates(), - detection_method=self._get_detection_methods(detections) if detections else None, - match_details=self._build_match_details(detections) if detections else None, - patterns_checked=self._get_patterns_checked_count(), - risk_score=self._compute_risk_score(detections, masked_entity_count, status), - ), + tracing_detail=GuardrailTracingDetail(**tracing_kw), ) async def apply_guardrail( @@ -1645,6 +1750,13 @@ class ContentFilterGuardrail(CustomGuardrail): processed_texts = [] for text in texts: + # Competitor intent check first (optional; may refuse/reframe) + if self._competitor_intent_checker and text: + intent_result = self._competitor_intent_checker.run(text) + if intent_result.get("intent", "other") != "other": + self._apply_competitor_intent_policy( + intent_result, request_data, detections + ) filtered_text = self._filter_single_text(text, detections=detections) processed_texts.append(filtered_text) @@ -1766,8 +1878,7 @@ class ContentFilterGuardrail(CustomGuardrail): @staticmethod def get_config_model(): - from litellm.types.proxy.guardrails.guardrail_hooks.litellm_content_filter import ( - LitellmContentFilterGuardrailConfigModel, - ) + from litellm.types.proxy.guardrails.guardrail_hooks.litellm_content_filter import \ + LitellmContentFilterGuardrailConfigModel - return LitellmContentFilterGuardrailConfigModel \ No newline at end of file + return LitellmContentFilterGuardrailConfigModel diff --git a/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/examples/README.md b/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/examples/README.md new file mode 100644 index 0000000000..1daaf4c26f --- /dev/null +++ b/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/examples/README.md @@ -0,0 +1,58 @@ +# Content Filter Examples + +## Industry-specific competitor intent (airline) + +Use **competitor_intent_type: airline** for simplified config; competitors are auto-loaded from IATA `major_airlines.json`, excluding your `brand_self`. Use **competitor_intent_type: generic** when you want to specify competitors manually. + +- **brand_self**: Your brand names and aliases (e.g. `["your-brand", "your-code"]`) +- **competitors** (generic only): List of competitor names + +To make it effective for a specific industry (e.g. airlines), add an **industry layer** on top: + +1. **domain_words** – Terms that signal “this is about our vertical.” + For airline: `airline`, `carrier`, `flight`, `business class`, `lounge`, etc. + This enables the **category_ranking** path (e.g. “Which Gulf airline is the best?”) and the scoring **gate** (so “best” alone doesn’t trigger without domain/geo). + +2. **route_geo_cues** – Optional geography/hub terms. + For airline: `country`, `hub-city`, `airport-code`, `region`. + +3. **descriptor_lexicon** – Phrases that count as indirect competitor reference. + For aviation: `gulf carrier`, `five star airline`, etc. + +4. **competitor_aliases** – Per-competitor aliases (IATA codes, nicknames). + Example: `competitor-name` → `["iata-code", "nickname"]`. + +5. **policy** – What to do per intent band: `refuse`, `reframe`, `log_only`, or `allow`. + Example: `competitor_comparison: refuse`, `category_ranking: reframe`. + +See the config examples below for how to add this to your proxy `guardrails` config. + +### Using the example in your proxy config + +In `config.yaml`: + +```yaml +guardrails: + - guardrail_name: "airline-competitor-intent" + litellm_params: + guardrail: litellm_content_filter + mode: pre_call + competitor_intent_config: + competitor_intent_type: airline + brand_self: [your-brand, your-code] + locations: [relevant-country, hub-city] + policy: + competitor_comparison: refuse + possible_competitor_comparison: reframe +``` + +Then attach this guardrail to your router/policy (e.g. `guardrails.add: [airline-competitor-intent]`). + +### Other industries + +Use the same pattern: + +- **SaaS**: `domain_words`: `["platform", "tool", "solution", "integration"]`; optional `route_geo_cues` if regional. +- **Retail**: `domain_words`: `["store", "brand", "product line"]`; `competitor_aliases` for brand nicknames. + +The implementation is generic; only the config (and optional industry presets) are industry-specific. diff --git a/litellm/proxy/management_endpoints/policy_endpoints/endpoints.py b/litellm/proxy/management_endpoints/policy_endpoints/endpoints.py index 36745a607c..069603afdf 100644 --- a/litellm/proxy/management_endpoints/policy_endpoints/endpoints.py +++ b/litellm/proxy/management_endpoints/policy_endpoints/endpoints.py @@ -12,14 +12,7 @@ All /policy management endpoints import copy import json import os -from typing import ( - TYPE_CHECKING, - AsyncIterator, - List, - Literal, - Optional, - cast, -) +from typing import TYPE_CHECKING, AsyncIterator, List, Literal, Optional, cast from fastapi import APIRouter, Depends, HTTPException, Request from fastapi.responses import StreamingResponse @@ -27,11 +20,9 @@ from pydantic import BaseModel, Field from typing_extensions import TypedDict from litellm._logging import verbose_proxy_logger -from litellm.constants import ( - COMPETITOR_LLM_TEMPERATURE, - DEFAULT_COMPETITOR_DISCOVERY_MODEL, - MAX_COMPETITOR_NAMES, -) +from litellm.constants import (COMPETITOR_LLM_TEMPERATURE, + DEFAULT_COMPETITOR_DISCOVERY_MODEL, + MAX_COMPETITOR_NAMES) from litellm.integrations.custom_guardrail import CustomGuardrail from litellm.proxy._types import UserAPIKeyAuth from litellm.proxy.auth.user_api_key_auth import user_api_key_auth @@ -39,21 +30,20 @@ from litellm.proxy.guardrails.guardrail_registry import GuardrailRegistry from litellm.proxy.management_helpers.utils import management_endpoint_wrapper from litellm.proxy.policy_engine.policy_registry import get_policy_registry from litellm.proxy.policy_engine.policy_resolver import PolicyResolver -from litellm.types.proxy.policy_engine import ( - PolicyGuardrailsResponse, - PolicyInfoResponse, - PolicyListResponse, - PolicyMatchContext, - PolicyScopeResponse, - PolicySummaryItem, - PolicyTestResponse, - PolicyValidateRequest, - PolicyValidationResponse, -) +from litellm.types.proxy.policy_engine import (PolicyGuardrailsResponse, + PolicyInfoResponse, + PolicyListResponse, + PolicyMatchContext, + PolicyScopeResponse, + PolicySummaryItem, + PolicyTestResponse, + PolicyValidateRequest, + PolicyValidationResponse) from litellm.types.utils import GenericGuardrailAPIInputs if TYPE_CHECKING: - from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj + from litellm.litellm_core_utils.litellm_logging import \ + Logging as LiteLLMLoggingObj router = APIRouter() @@ -86,6 +76,19 @@ class ApplyPoliciesResult(TypedDict): guardrail_errors: List[GuardrailErrorEntry] +class ApplyPoliciesPerItemResult(TypedDict): + """Result for one input when using inputs_list.""" + + inputs: GenericGuardrailAPIInputs + guardrail_errors: List[GuardrailErrorEntry] + + +class ApplyPoliciesListResult(TypedDict): + """Result when using inputs_list: one result per input.""" + + results: List[ApplyPoliciesPerItemResult] + + async def apply_policies( policy_names: Optional[list[str]], inputs: GenericGuardrailAPIInputs, @@ -187,7 +190,14 @@ class TestPoliciesAndGuardrailsRequest(BaseModel): policy_names: Optional[List[str]] = Field(default=None, description="Policy names to resolve guardrails from") guardrail_names: Optional[List[str]] = Field(default=None, description="Guardrail names to apply directly") - inputs: dict = Field(description="GenericGuardrailAPIInputs, e.g. { \"texts\": [\"...\"] }") + inputs: Optional[dict] = Field( + default=None, + description="GenericGuardrailAPIInputs, e.g. { \"texts\": [\"...\"] }. Use inputs_list for per-input processing.", + ) + inputs_list: Optional[List[dict]] = Field( + default=None, + description="List of GenericGuardrailAPIInputs; each item processed separately (for batch compliance testing).", + ) request_data: dict = Field(default_factory=dict, description="Request context (model, user_id, etc.)") input_type: Literal["request", "response"] = Field(default="request", description="Whether inputs are request or response") @@ -206,26 +216,48 @@ async def test_policies_and_guardrails( """ Apply policies and/or guardrails to inputs (for compliance UI testing). - Runs all guardrails in order; failures are collected and returned in guardrail_errors. - Returns inputs (possibly modified) and any guardrail errors so the UI can show which - guardrails failed and why. + Use inputs_list for batch testing: each input is processed as a separate call so + per-input block/allow and errors are returned. + + Use inputs for a single call (legacy). """ - from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj + from litellm.litellm_core_utils.litellm_logging import \ + Logging as LiteLLMLoggingObj from litellm.proxy.proxy_server import proxy_logging_obj from litellm.proxy.utils import handle_exception_on_proxy try: - inputs_typed = cast(GenericGuardrailAPIInputs, data.inputs) logging_obj = cast(LiteLLMLoggingObj, proxy_logging_obj) - result = await apply_policies( - policy_names=data.policy_names, - inputs=inputs_typed, - request_data=data.request_data, - input_type=data.input_type, - proxy_logging_obj=logging_obj, - guardrail_names=data.guardrail_names, - ) - return result + if data.inputs_list is not None: + results: List[ApplyPoliciesPerItemResult] = [] + for inp in data.inputs_list: + inputs_typed = cast(GenericGuardrailAPIInputs, inp) + item_result = await apply_policies( + policy_names=data.policy_names, + inputs=inputs_typed, + request_data=data.request_data, + input_type=data.input_type, + proxy_logging_obj=logging_obj, + guardrail_names=data.guardrail_names, + ) + results.append( + ApplyPoliciesPerItemResult( + inputs=item_result["inputs"], + guardrail_errors=item_result["guardrail_errors"], + ) + ) + return ApplyPoliciesListResult(results=results) + if data.inputs is not None: + inputs_typed = cast(GenericGuardrailAPIInputs, data.inputs) + return await apply_policies( + policy_names=data.policy_names, + inputs=inputs_typed, + request_data=data.request_data, + input_type=data.input_type, + proxy_logging_obj=logging_obj, + guardrail_names=data.guardrail_names, + ) + raise ValueError("Either inputs or inputs_list must be provided") except Exception as e: raise handle_exception_on_proxy(e) @@ -506,7 +538,8 @@ async def get_policy_templates( return _load_policy_templates_from_local_backup() try: - from litellm.llms.custom_httpx.http_handler import get_async_httpx_client + from litellm.llms.custom_httpx.http_handler import \ + get_async_httpx_client from litellm.types.llms.custom_http import httpxSpecialProvider async_client = get_async_httpx_client( @@ -981,9 +1014,8 @@ async def suggest_policy_templates( Calls an LLM with tool calling to match user requirements to available templates. """ - from litellm.proxy.management_endpoints.policy_endpoints.ai_policy_suggester import ( - AiPolicySuggester, - ) + from litellm.proxy.management_endpoints.policy_endpoints.ai_policy_suggester import \ + AiPolicySuggester templates = _load_policy_templates_from_local_backup() suggester = AiPolicySuggester() @@ -1053,9 +1085,8 @@ async def _test_guardrail_definitions( text: str, ) -> List[GuardrailTestResultEntry]: """Instantiate and run each guardrail definition against the text.""" - from litellm.proxy.guardrails.guardrail_hooks.litellm_content_filter.content_filter import ( - ContentFilterGuardrail, - ) + from litellm.proxy.guardrails.guardrail_hooks.litellm_content_filter.content_filter import \ + ContentFilterGuardrail results: List[GuardrailTestResultEntry] = [] diff --git a/litellm/types/proxy/guardrails/guardrail_hooks/litellm_content_filter.py b/litellm/types/proxy/guardrails/guardrail_hooks/litellm_content_filter.py index 686f30f3b5..b27789fd7f 100644 --- a/litellm/types/proxy/guardrails/guardrail_hooks/litellm_content_filter.py +++ b/litellm/types/proxy/guardrails/guardrail_hooks/litellm_content_filter.py @@ -1,10 +1,42 @@ from enum import Enum -from typing import List, Literal, Optional, TypedDict, Union +from typing import Any, Dict, List, Literal, Optional, TypedDict, Union from pydantic import Field from litellm.types.llms.base import BaseLiteLLMOpenAIResponseObject -from litellm.types.proxy.guardrails.guardrail_hooks.base import GuardrailConfigModel +from litellm.types.proxy.guardrails.guardrail_hooks.base import \ + GuardrailConfigModel + +# --- Competitor intent blocker (generic, industry-agnostic) --- + +CompetitorIntentType = Literal[ + "competitor_comparison", + "possible_competitor_comparison", + "category_ranking", + "log_only", + "other", +] +CompetitorActionHint = Literal["allow", "reframe", "refuse", "escalate", "log_only"] + + +class CompetitorIntentEvidenceEntry(TypedDict, total=False): + """Single evidence entry: what matched and what it resolved to.""" + + type: Literal["entity", "signal"] + key: str # e.g. "competitor", "ranking", "brand_self" + value: Optional[str] # resolved canonical value (e.g. "qatar_airways") + match: str # matched substring + + +class CompetitorIntentResult(TypedDict, total=False): + """Structured output from competitor intent checker.""" + + intent: CompetitorIntentType + confidence: float + entities: Dict[str, List[str]] # brand_self, competitors, category + signals: List[str] + action_hint: CompetitorActionHint + evidence: List[CompetitorIntentEvidenceEntry] # Detection type enum @@ -37,7 +69,24 @@ class CategoryKeywordDetection(TypedDict): action: str # ContentFilterAction.value -ContentFilterDetection = Union[PatternDetection, BlockedWordDetection, CategoryKeywordDetection] +class CompetitorIntentDetection(TypedDict): + """Detection from competitor intent checker (intent + evidence).""" + + type: Literal["competitor_intent"] + intent: str + confidence: float + action_hint: str + entities: Dict[str, List[str]] + signals: List[str] + evidence: List[Dict[str, Any]] + + +ContentFilterDetection = Union[ + PatternDetection, + BlockedWordDetection, + CategoryKeywordDetection, + CompetitorIntentDetection, +] class ContentFilterCategoryConfig(BaseLiteLLMOpenAIResponseObject): @@ -113,6 +162,17 @@ class LitellmContentFilterGuardrailConfigModel(GuardrailConfigModel): description="Tag to use for keyword redaction", ) + # Competitor intent blocker (generic; industry presets add domain_words, etc.) + competitor_intent_config: Optional[Dict[str, Any]] = Field( + default=None, + description="Optional config for intent-based competitor comparison detection. " + "Keys: brand_self (list), competitors (list), competitor_aliases (dict), " + "domain_words (list, optional), route_geo_cues (list, optional), " + "descriptor_lexicon (list, optional), indirect_competitor_patterns (dict, optional), " + "policy (dict), threshold_high, threshold_medium, threshold_low, " + "reframe_message_template, refuse_message_template.", + ) + @staticmethod def ui_friendly_name() -> str: return "LiteLLM Content Filter" diff --git a/tests/test_litellm/proxy/guardrails/guardrail_hooks/content_filter/test_competitor_intent.py b/tests/test_litellm/proxy/guardrails/guardrail_hooks/content_filter/test_competitor_intent.py new file mode 100644 index 0000000000..3f4098ba7e --- /dev/null +++ b/tests/test_litellm/proxy/guardrails/guardrail_hooks/content_filter/test_competitor_intent.py @@ -0,0 +1,314 @@ +""" +Tests for competitor intent detection (normalize, entity layer, scoring, policy). +""" + +import pytest + +from litellm.proxy.guardrails.guardrail_hooks.litellm_content_filter.competitor_intent import ( + AirlineCompetitorIntentChecker, normalize, text_for_entity_matching) + + +class TestNormalize: + """Test text normalization (leetspeak, spacing, zero-width).""" + + def test_normalize_lowercase(self): + assert normalize("Is Qatar Better?") == "is qatar better?" + + def test_normalize_leetspeak(self): + assert "qatar" in normalize("q@tar") + assert "qatar" in normalize("q4tar") + + def test_normalize_collapse_whitespace(self): + assert normalize("hello world") == "hello world" + + def test_normalize_spaced_out_letters(self): + # Single-letter tokens collapsed into word + assert "qatar" in normalize("q a t a r").replace(" ", "") + + def test_normalize_empty(self): + assert normalize("") == "" + assert normalize(None) == "" + + def test_text_for_entity_matching_removes_punctuation(self): + t = text_for_entity_matching("q.a.t.a.r emirates") + assert "emirates" in t + assert "." not in t + + +class TestAirlineCompetitorIntentChecker: + """Test AirlineCompetitorIntentChecker run() and intent bands.""" + + @pytest.fixture + def generic_config(self): + return { + "brand_self": ["emirates", "ek"], + "competitors": ["qatar airways", "etihad", "qatar"], + "competitor_aliases": { + "qatar airways": ["qr", "doha airline"], + "qatar": ["qr"], + }, + "locations": ["qatar", "doha", "doh"], + "domain_words": ["airline", "carrier", "flight", "business class"], + "route_geo_cues": ["doha", "dubai", "abu dhabi"], + "policy": { + "competitor_comparison": "refuse", + "possible_competitor_comparison": "reframe", + "category_ranking": "reframe", + "log_only": "log_only", + }, + "threshold_high": 0.70, + "threshold_medium": 0.45, + "threshold_low": 0.30, + } + + def test_run_other_intent(self, generic_config): + checker = AirlineCompetitorIntentChecker(generic_config) + result = checker.run("What is the weather today?") + assert result["intent"] == "other" + assert result["action_hint"] == "allow" + + def test_run_competitor_comparison_direct(self, generic_config): + checker = AirlineCompetitorIntentChecker(generic_config) + result = checker.run("Is Qatar better than Emirates?") + assert result["intent"] in ("competitor_comparison", "possible_competitor_comparison") + assert "competitor_entity" in result.get("signals", []) or "competitors" in str(result.get("entities", {})) + assert result["confidence"] >= 0.45 + + def test_run_competitor_comparison_as_good_as(self, generic_config): + checker = AirlineCompetitorIntentChecker(generic_config) + result = checker.run("Is Qatar as good as Emirates?") + assert result["intent"] != "other" + assert result["confidence"] >= 0.45 + + def test_run_ranking_with_competitor(self, generic_config): + checker = AirlineCompetitorIntentChecker(generic_config) + result = checker.run("Why is Qatar Airways the best?") + assert result["intent"] != "other" + assert "qatar" in str(result.get("entities", {}).get("competitors", [])).lower() or "competitor" in str(result.get("signals", [])) + + def test_run_ranking_without_competitor_category_ranking(self, generic_config): + checker = AirlineCompetitorIntentChecker(generic_config) + result = checker.run("Which Gulf airline is the best?") + # domain_words "airline" + ranking "best" + geo "gulf" not in route_geo_cues but "airline" is domain + assert result["intent"] in ("category_ranking", "possible_competitor_comparison", "log_only", "other") + + def test_run_evidence_populated(self, generic_config): + checker = AirlineCompetitorIntentChecker(generic_config) + result = checker.run("Is Qatar better than Emirates?") + assert "evidence" in result + assert isinstance(result["evidence"], list) + + def test_run_gate_prevents_false_positive(self, generic_config): + # "best" alone without entity or domain should not trigger competitor_comparison + checker = AirlineCompetitorIntentChecker(generic_config) + result = checker.run("What is the best way to cook pasta?") + assert result["intent"] in ("other", "log_only") + + def test_other_meaning_context_suppression(self, generic_config): + # "flights to qatar" = other meaning (country), not competitor airline + checker = AirlineCompetitorIntentChecker(generic_config) + result = checker.run("how expensive are flights to qatar?") + assert result["intent"] == "other" + assert not result.get("entities", {}).get("competitors") + + +class TestContentFilterWithCompetitorIntent: + """Integration: ContentFilterGuardrail with competitor_intent_config.""" + + @pytest.mark.asyncio + async def test_competitor_intent_type_airline_uses_airline_checker(self): + """When competitor_intent_type is airline (default), use AirlineCompetitorIntentChecker.""" + from litellm.proxy.guardrails.guardrail_hooks.litellm_content_filter.content_filter import \ + ContentFilterGuardrail + + guardrail = ContentFilterGuardrail( + guardrail_name="test-airline", + competitor_intent_config={ + "competitor_intent_type": "airline", + "brand_self": ["emirates", "ek"], + "locations": ["qatar", "doha"], + "policy": {"competitor_comparison": "refuse"}, + }, + ) + assert guardrail._competitor_intent_checker is not None + from litellm.proxy.guardrails.guardrail_hooks.litellm_content_filter.competitor_intent import \ + AirlineCompetitorIntentChecker + assert isinstance(guardrail._competitor_intent_checker, AirlineCompetitorIntentChecker) + + @pytest.mark.asyncio + async def test_competitor_intent_type_generic_uses_base_checker(self): + """When competitor_intent_type is generic, use BaseCompetitorIntentChecker.""" + from litellm.proxy.guardrails.guardrail_hooks.litellm_content_filter.competitor_intent import \ + BaseCompetitorIntentChecker + from litellm.proxy.guardrails.guardrail_hooks.litellm_content_filter.content_filter import \ + ContentFilterGuardrail + + guardrail = ContentFilterGuardrail( + guardrail_name="test-generic", + competitor_intent_config={ + "competitor_intent_type": "generic", + "brand_self": ["acme"], + "competitors": ["widget inc", "gadget corp"], + "policy": {"competitor_comparison": "refuse"}, + }, + ) + assert guardrail._competitor_intent_checker is not None + assert isinstance(guardrail._competitor_intent_checker, BaseCompetitorIntentChecker) + + @pytest.mark.asyncio + async def test_apply_guardrail_with_competitor_intent_allow(self): + from litellm.proxy.guardrails.guardrail_hooks.litellm_content_filter.content_filter import \ + ContentFilterGuardrail + + guardrail = ContentFilterGuardrail( + guardrail_name="test-competitor", + competitor_intent_config={ + "brand_self": ["emirates"], + "competitors": ["qatar"], + "domain_words": ["airline"], + "policy": {"competitor_comparison": "refuse", "possible_competitor_comparison": "reframe"}, + }, + ) + inputs = {"texts": ["What is the capital of France?"]} + result = await guardrail.apply_guardrail( + inputs, request_data={}, input_type="request" + ) + assert result["texts"] == ["What is the capital of France?"] + + @pytest.mark.asyncio + async def test_apply_guardrail_with_competitor_intent_refuse(self): + from fastapi import HTTPException + + from litellm.proxy.guardrails.guardrail_hooks.litellm_content_filter.content_filter import \ + ContentFilterGuardrail + + guardrail = ContentFilterGuardrail( + guardrail_name="test-competitor", + competitor_intent_config={ + "brand_self": ["emirates"], + "competitors": ["qatar airways"], + "domain_words": ["airline", "flight"], + "policy": {"competitor_comparison": "refuse"}, + "threshold_high": 0.5, + }, + ) + inputs = {"texts": ["Is Qatar Airways better than Emirates?"]} + with pytest.raises(HTTPException) as exc_info: + await guardrail.apply_guardrail( + inputs, request_data={}, input_type="request" + ) + assert exc_info.value.status_code == 403 + + +# Exact config from litellm/proxy/_new_secret_config.yaml (lines 27-53). +AIRLINE_PROXY_CONFIG = { + "brand_self": ["emirates", "ek"], + "competitors": ["qatar airways", "qatar", "etihad"], + "locations": ["qatar", "doha", "doh"], + "competitor_aliases": { + "qatar airways": ["qr", "doha airline"], + "qatar": ["qr"], + }, + "policy": { + "competitor_comparison": "refuse", + "possible_competitor_comparison": "reframe", + }, + "threshold_high": 0.70, + "threshold_medium": 0.45, + "threshold_low": 0.30, +} + +# Config matching proxy + domain_words, category_ranking, and competitors for full dataset. +AIRLINE_COMPLIANCE_CONFIG = { + "brand_self": ["emirates", "ek"], + "competitors": [ + "qatar airways", + "qatar", + "etihad", + "turkish airlines", + "lufthansa", + "singapore airlines", + ], + "locations": ["qatar", "doha", "doh"], + "competitor_aliases": { + "qatar airways": ["qr", "doha airline"], + "qatar": ["qr"], + }, + "domain_words": ["airline", "airlines", "carrier"], + "policy": { + "competitor_comparison": "refuse", + "possible_competitor_comparison": "reframe", + "category_ranking": "reframe", + }, + "threshold_high": 0.70, + "threshold_medium": 0.45, + "threshold_low": 0.30, +} + +# Dataset from ui/litellm-dashboard/src/data/compliancePrompts.ts airlineCompliancePrompts +AIRLINE_COMPLIANCE_DATASET = [ + # Destination Intent (should PASS) + ("airline_001", "Do you have flights to Qatar?", "pass"), + ("airline_002", "How long is the flight to Doha?", "pass"), + ("airline_003", "Do I need a visa to transit through Qatar?", "pass"), + ("airline_004", "Are there lounges at Hamad Airport?", "pass"), + ("airline_005", "What documents are required to enter Singapore?", "pass"), + ("airline_006", "What airlines fly to Turkey?", "pass"), + ("airline_007", "What is the airport code for Doha?", "pass"), + ("airline_008", "Is Doha a good place for a long layover?", "pass"), + ("airline_009", "What is the weather like in Qatar in March?", "pass"), + ("airline_010", "How much connection time do I need in Doha?", "pass"), + # Competitor Comparison (should FAIL) + ("airline_011", "Is Qatar Airways better than Emirates?", "fail"), + ("airline_012", "Which airline has better business class, Qatar or Singapore Airlines?", "fail"), + ("airline_013", "Which airline is the best for long haul flights?", "fail"), + ("airline_014", "Should I choose Qatar Airways or another airline?", "fail"), + ("airline_015", "Compare Turkish Airlines vs Lufthansa.", "fail"), + ("airline_016", "Which airline has better lounges?", "fail"), + ("airline_017", "Which airline has the best customer satisfaction?", "fail"), + ("airline_018", "Is the Doha airline better than other carriers?", "fail"), + ("airline_019", "Should I switch to another airline for a better experience?", "fail"), + ("airline_020", "Which airline is ranked number one worldwide?", "fail"), + # Ambiguous Entity (should PASS) + ("airline_021", "Qatar baggage allowance", "pass"), + ("airline_022", "Qatar lounge access rules", "pass"), + ("airline_023", "Qatar check in time", "pass"), + ("airline_024", "Doha premium lounge access", "pass"), + ("airline_025", "Qatar refund policy", "pass"), +] + + +class TestAirlineComplianceDataset: + """Run full airline compliance dataset with proxy config; all cases must match expected outcome.""" + + def test_airline_001_passes_with_exact_proxy_config(self): + """With exact proxy config, first compliance case (flights to Qatar) must pass (allow).""" + checker = AirlineCompetitorIntentChecker(AIRLINE_PROXY_CONFIG) + result = checker.run("Do you have flights to Qatar?") + assert result["intent"] == "other" + assert result["action_hint"] == "allow" + + def test_airline_compliance_dataset_with_proxy_config(self): + """Every prompt must get intent/action consistent with expectedResult (pass=allow, fail=refuse/reframe).""" + checker = AirlineCompetitorIntentChecker(AIRLINE_COMPLIANCE_CONFIG) + failures = [] + for prompt_id, prompt_text, expected in AIRLINE_COMPLIANCE_DATASET: + result = checker.run(prompt_text) + intent = result.get("intent", "other") + action_hint = result.get("action_hint", "allow") + if expected == "pass": + allowed = intent == "other" and action_hint == "allow" + if not allowed: + failures.append( + f"{prompt_id}: expected pass, got intent={intent!r} action_hint={action_hint!r} for {prompt_text!r}" + ) + else: + blocked = ( + intent != "other" + and action_hint in ("refuse", "reframe") + ) + if not blocked: + failures.append( + f"{prompt_id}: expected fail, got intent={intent!r} action_hint={action_hint!r} for {prompt_text!r}" + ) + assert not failures, f"Airline compliance dataset failures:\n" + "\n".join(failures) diff --git a/ui/litellm-dashboard/src/components/guardrails/add_guardrail_form.tsx b/ui/litellm-dashboard/src/components/guardrails/add_guardrail_form.tsx index fbd0af9918..b984c877e2 100644 --- a/ui/litellm-dashboard/src/components/guardrails/add_guardrail_form.tsx +++ b/ui/litellm-dashboard/src/components/guardrails/add_guardrail_form.tsx @@ -110,6 +110,8 @@ const AddGuardrailForm: React.FC = ({ visible, onClose, a const [blockedWords, setBlockedWords] = useState([]); const [selectedContentCategories, setSelectedContentCategories] = useState([]); const [pendingCategorySelection, setPendingCategorySelection] = useState(""); + const [competitorIntentEnabled, setCompetitorIntentEnabled] = useState(false); + const [competitorIntentConfig, setCompetitorIntentConfig] = useState(null); const [toolPermissionConfig, setToolPermissionConfig] = useState({ rules: [], default_action: "deny", @@ -175,6 +177,8 @@ const AddGuardrailForm: React.FC = ({ visible, onClose, a setBlockedWords([]); setSelectedContentCategories([]); setPendingCategorySelection(""); + setCompetitorIntentEnabled(false); + setCompetitorIntentConfig(null); setToolPermissionConfig({ rules: [], @@ -254,7 +258,13 @@ const AddGuardrailForm: React.FC = ({ visible, onClose, a setCurrentStep(currentStep - 1); }; - const handleAddAndContinue = () => { + const handleAddAndContinue = (competitorIntentOnly?: boolean) => { + // Competitor intent only: just advance to next step (no category to add) + if (competitorIntentOnly) { + setCurrentStep(currentStep + 1); + return; + } + if (!pendingCategorySelection || !guardrailSettings) return; const contentFilterSettings = guardrailSettings.content_filter_settings; @@ -363,12 +373,20 @@ const AddGuardrailForm: React.FC = ({ visible, onClose, a } } - // For Content Filter, add patterns, blocked words, and categories + // For Content Filter, add patterns, blocked words, categories, and optionally competitor intent if (shouldRenderContentFilterConfigSettings(values.provider)) { // Validate that at least one content filter setting is configured - if (selectedPatterns.length === 0 && blockedWords.length === 0 && selectedContentCategories.length === 0) { + const hasCompetitorIntent = + competitorIntentEnabled && + competitorIntentConfig?.brand_self?.length > 0; + if ( + selectedPatterns.length === 0 && + blockedWords.length === 0 && + selectedContentCategories.length === 0 && + !hasCompetitorIntent + ) { NotificationsManager.fromBackend( - "Please configure at least one setting (denied topic, pattern, or word filter)" + "Please configure at least one content filter setting (category, pattern, keyword, or competitor intent)" ); setLoading(false); return; @@ -398,6 +416,25 @@ const AddGuardrailForm: React.FC = ({ visible, onClose, a severity_threshold: c.severity_threshold || "medium", })); } + if (competitorIntentEnabled && competitorIntentConfig?.brand_self?.length > 0) { + guardrailData.litellm_params.competitor_intent_config = { + competitor_intent_type: competitorIntentConfig.competitor_intent_type ?? "airline", + brand_self: competitorIntentConfig.brand_self, + locations: + competitorIntentConfig.locations?.length > 0 + ? competitorIntentConfig.locations + : undefined, + competitors: + competitorIntentConfig.competitor_intent_type === "generic" && + competitorIntentConfig.competitors?.length > 0 + ? competitorIntentConfig.competitors + : undefined, + policy: competitorIntentConfig.policy, + threshold_high: competitorIntentConfig.threshold_high, + threshold_medium: competitorIntentConfig.threshold_medium, + threshold_low: competitorIntentConfig.threshold_low, + }; + } } // Add config values to the guardrail_info if provided else if (values.config) { @@ -712,6 +749,12 @@ const AddGuardrailForm: React.FC = ({ visible, onClose, a onPendingCategorySelectionChange={setPendingCategorySelection} accessToken={accessToken} showStep={step} + competitorIntentEnabled={competitorIntentEnabled} + competitorIntentConfig={competitorIntentConfig} + onCompetitorIntentChange={(enabled, config) => { + setCompetitorIntentEnabled(enabled); + setCompetitorIntentConfig(config); + }} /> ); }; @@ -769,28 +812,52 @@ const AddGuardrailForm: React.FC = ({ visible, onClose, a } }; - const getStepConfigs = () => { - const isContentFilter = shouldRenderContentFilterConfigSettings(selectedProvider); - const isPII = shouldRenderPIIConfigSettings(selectedProvider); + const renderStepButtons = () => { + const totalSteps = shouldRenderContentFilterConfigSettings(selectedProvider) ? 4 : 2; + const isLastStep = currentStep === totalSteps - 1; + const isCategoriesStep = shouldRenderContentFilterConfigSettings(selectedProvider) && currentStep === 1; + const hasPendingCategory = pendingCategorySelection !== ""; + const hasCompetitorIntentConfigured = + competitorIntentEnabled && (competitorIntentConfig?.brand_self?.length ?? 0) > 0; + const canContinueFromCategoriesStep = hasPendingCategory || hasCompetitorIntentConfigured; - const steps = [ - { title: "Guardrail details", optional: false }, - { - title: isPII - ? "PII Configuration" - : isContentFilter - ? "Denied topics" - : "Provider Configuration", - optional: true, - }, - ]; - - if (isContentFilter) { - steps.push({ title: "Patterns", optional: true }); - steps.push({ title: "Word filters", optional: true }); - } - - return steps; + return ( +
+ {currentStep > 0 && ( + + )} + {isCategoriesStep ? ( + <> + + + + ) : ( + <> + {!isLastStep && ( + + )} + {isLastStep && ( + + )} + + )} + +
+ ); }; const stepConfigs = getStepConfigs(); diff --git a/ui/litellm-dashboard/src/components/guardrails/content_filter/CompetitorIntentConfiguration.tsx b/ui/litellm-dashboard/src/components/guardrails/content_filter/CompetitorIntentConfiguration.tsx new file mode 100644 index 0000000000..f475efea36 --- /dev/null +++ b/ui/litellm-dashboard/src/components/guardrails/content_filter/CompetitorIntentConfiguration.tsx @@ -0,0 +1,335 @@ +import React, { useEffect, useState } from "react"; +import { + Card, + Typography, + Select, + Switch, + Form, + Space, + InputNumber, +} from "antd"; +import { getMajorAirlines } from "../../networking"; + +const { Title, Text } = Typography; +const { Option } = Select; + +export interface MajorAirline { + id: string; + match: string; + tags: string[]; +} + +export interface CompetitorIntentConfig { + competitor_intent_type: "airline" | "generic"; + brand_self: string[]; + locations?: string[]; + competitors?: string[]; + policy?: { + competitor_comparison?: "refuse" | "reframe"; + possible_competitor_comparison?: "refuse" | "reframe"; + }; + threshold_high?: number; + threshold_medium?: number; + threshold_low?: number; +} + +interface CompetitorIntentConfigurationProps { + enabled: boolean; + config: CompetitorIntentConfig | null; + onChange: (enabled: boolean, config: CompetitorIntentConfig | null) => void; + accessToken?: string | null; +} + +const DEFAULT_CONFIG: CompetitorIntentConfig = { + competitor_intent_type: "airline", + brand_self: [], + locations: [], + policy: { + competitor_comparison: "refuse", + possible_competitor_comparison: "reframe", + }, + threshold_high: 0.7, + threshold_medium: 0.45, + threshold_low: 0.3, +}; + +const CompetitorIntentConfiguration: React.FC< + CompetitorIntentConfigurationProps +> = ({ enabled, config, onChange, accessToken }) => { + const effectiveConfig = config ?? DEFAULT_CONFIG; + const [airlineOptions, setAirlineOptions] = useState([]); + const [loadingAirlines, setLoadingAirlines] = useState(false); + + useEffect(() => { + if ( + effectiveConfig.competitor_intent_type === "airline" && + accessToken && + airlineOptions.length === 0 + ) { + setLoadingAirlines(true); + getMajorAirlines(accessToken) + .then((res) => setAirlineOptions(res.airlines ?? [])) + .catch(() => setAirlineOptions([])) + .finally(() => setLoadingAirlines(false)); + } + }, [effectiveConfig.competitor_intent_type, accessToken, airlineOptions.length]); + + const handleEnabledChange = (checked: boolean) => { + onChange(checked, checked ? { ...DEFAULT_CONFIG } : null); + }; + + const handleConfigChange = (field: string, value: unknown) => { + onChange(enabled, { ...effectiveConfig, [field]: value }); + }; + + const handlePolicyChange = (key: string, value: string) => { + onChange(enabled, { + ...effectiveConfig, + policy: { ...effectiveConfig.policy, [key]: value }, + }); + }; + + const handleNestedArrayChange = (field: "brand_self" | "locations" | "competitors", values: string[]) => { + onChange(enabled, { ...effectiveConfig, [field]: values.filter(Boolean) }); + }; + + const handleBrandSelfChange = (values: string[]) => { + const filtered = values.filter(Boolean); + const expanded: string[] = []; + const seen = new Set(); + for (const v of filtered) { + const airline = airlineOptions.find((a) => { + const primary = a.match.split("|")[0]?.trim().toLowerCase(); + return primary === v.toLowerCase(); + }); + if (airline) { + for (const variant of airline.match.split("|").map((s) => s.trim().toLowerCase()).filter(Boolean)) { + if (!seen.has(variant)) { + seen.add(variant); + expanded.push(variant); + } + } + } else if (!seen.has(v.toLowerCase())) { + seen.add(v.toLowerCase()); + expanded.push(v); + } + } + onChange(enabled, { ...effectiveConfig, brand_self: expanded }); + }; + + + if (!enabled) { + return ( + + + Competitor Intent Filter + + + + } + size="small" + > + + Block or reframe competitor comparison questions. When enabled, airline type + auto-loads competitors from IATA; generic type requires manual competitor list. + + + ); + } + + return ( + + + Competitor Intent Filter + + + + } + size="small" + > + + Block or reframe competitor comparison questions. Airline type uses major airlines + (excluding your brand); generic requires manual competitor list. + +
+ + + + + + handleNestedArrayChange("locations", v ?? [])} + tokenSeparators={[","]} + /> + + )} + + {effectiveConfig.competitor_intent_type === "generic" && ( + + handlePolicyChange("competitor_comparison", v)} + style={{ width: "100%" }} + > + + + + + + + + + + + Classify competitor intent by confidence (0–1). Higher confidence → stronger intent. +
    +
  • + High (≥): Treat as full competitor comparison → uses "Competitor comparison" policy +
  • +
  • + Medium (≥): Treat as possible comparison → uses "Possible competitor comparison" policy +
  • +
  • + Low (≥): Log only; allow request. Below Low → allow with no action +
  • +
+ Raise thresholds to be more permissive; lower them to be stricter. + + } + > + + + handleConfigChange("threshold_high", v ?? 0.7)} + style={{ width: 80 }} + /> + + + handleConfigChange("threshold_medium", v ?? 0.45)} + style={{ width: 80 }} + /> + + + handleConfigChange("threshold_low", v ?? 0.3)} + style={{ width: 80 }} + /> + + +
+
+
+ ); +}; + +export default CompetitorIntentConfiguration; diff --git a/ui/litellm-dashboard/src/components/guardrails/content_filter/ContentFilterConfiguration.tsx b/ui/litellm-dashboard/src/components/guardrails/content_filter/ContentFilterConfiguration.tsx index 5715b3c136..99100677b2 100644 --- a/ui/litellm-dashboard/src/components/guardrails/content_filter/ContentFilterConfiguration.tsx +++ b/ui/litellm-dashboard/src/components/guardrails/content_filter/ContentFilterConfiguration.tsx @@ -9,6 +9,9 @@ import KeywordModal from "./KeywordModal"; import PatternTable from "./PatternTable"; import KeywordTable from "./KeywordTable"; import ContentCategoryConfiguration from "./ContentCategoryConfiguration"; +import CompetitorIntentConfiguration, { + CompetitorIntentConfig, +} from "./CompetitorIntentConfiguration"; const { Title, Text } = Typography; @@ -63,7 +66,7 @@ interface ContentFilterConfigurationProps { onBlockedWordUpdate: (id: string, field: string, value: any) => void; onFileUpload?: (content: string) => void; accessToken: string | null; - showStep?: "patterns" | "keywords" | "categories"; + showStep?: "patterns" | "keywords" | "categories" | "competitor_intent"; contentCategories?: ContentCategory[]; selectedContentCategories?: SelectedContentCategory[]; onContentCategoryAdd?: (category: SelectedContentCategory) => void; @@ -71,6 +74,12 @@ interface ContentFilterConfigurationProps { onContentCategoryUpdate?: (id: string, field: string, value: any) => void; pendingCategorySelection?: string; onPendingCategorySelectionChange?: (value: string) => void; + competitorIntentEnabled?: boolean; + competitorIntentConfig?: CompetitorIntentConfig | null; + onCompetitorIntentChange?: ( + enabled: boolean, + config: CompetitorIntentConfig | null + ) => void; } const ContentFilterConfiguration: React.FC = ({ @@ -94,6 +103,9 @@ const ContentFilterConfiguration: React.FC = ({ onContentCategoryUpdate, pendingCategorySelection, onPendingCategorySelectionChange, + competitorIntentEnabled = false, + competitorIntentConfig = null, + onCompetitorIntentChange, }) => { const [patternModalVisible, setPatternModalVisible] = useState(false); const [keywordModalVisible, setKeywordModalVisible] = useState(false); @@ -197,6 +209,8 @@ const ContentFilterConfiguration: React.FC = ({ const showPatterns = !showStep || showStep === "patterns"; const showKeywords = !showStep || showStep === "keywords"; const showCategories = !showStep || showStep === "categories"; + const showCompetitorIntent = + !showStep || showStep === "competitor_intent" || showStep === "categories"; return (
@@ -274,6 +288,16 @@ const ContentFilterConfiguration: React.FC = ({ )} + {showCompetitorIntent && + onCompetitorIntentChange && ( + + )} + {showCategories && contentCategories.length > 0 && onContentCategoryAdd && onContentCategoryRemove && onContentCategoryUpdate && ( { ); await waitFor(() => { - expect(mockOnDataChange).toHaveBeenCalledWith([], [], []); + expect(mockOnDataChange).toHaveBeenCalledWith([], [], [], false, null); }); }); diff --git a/ui/litellm-dashboard/src/components/guardrails/content_filter/ContentFilterManager.tsx b/ui/litellm-dashboard/src/components/guardrails/content_filter/ContentFilterManager.tsx index 1070453425..1e106e2b1c 100644 --- a/ui/litellm-dashboard/src/components/guardrails/content_filter/ContentFilterManager.tsx +++ b/ui/litellm-dashboard/src/components/guardrails/content_filter/ContentFilterManager.tsx @@ -2,6 +2,7 @@ import { Alert, Divider, Typography } from "antd"; import React, { useEffect, useState } from "react"; import ContentFilterConfiguration from "./ContentFilterConfiguration"; import ContentFilterDisplay from "./ContentFilterDisplay"; +import type { CompetitorIntentConfig } from "./CompetitorIntentConfiguration"; const { Text } = Typography @@ -55,7 +56,13 @@ interface ContentFilterManagerProps { guardrailSettings: GuardrailSettings | null; isEditing: boolean; accessToken: string | null; - onDataChange?: (patterns: Pattern[], blockedWords: BlockedWord[], categories: SelectedContentCategory[]) => void; + onDataChange?: ( + patterns: Pattern[], + blockedWords: BlockedWord[], + categories: SelectedContentCategory[], + competitorIntentEnabled?: boolean, + competitorIntentConfig?: CompetitorIntentConfig | null + ) => void; onUnsavedChanges?: (hasChanges: boolean) => void; } @@ -73,6 +80,10 @@ const ContentFilterManager: React.FC = ({ const [originalPatterns, setOriginalPatterns] = useState([]); const [originalBlockedWords, setOriginalBlockedWords] = useState([]); const [originalContentCategories, setOriginalContentCategories] = useState([]); + const [competitorIntentEnabled, setCompetitorIntentEnabled] = useState(false); + const [competitorIntentConfig, setCompetitorIntentConfig] = useState(null); + const [originalCompetitorIntentEnabled, setOriginalCompetitorIntentEnabled] = useState(false); + const [originalCompetitorIntentConfig, setOriginalCompetitorIntentConfig] = useState(null); // Load data from guardrail on mount or when guardrailData changes useEffect(() => { @@ -128,22 +139,73 @@ const ContentFilterManager: React.FC = ({ setSelectedContentCategories([]); setOriginalContentCategories([]); } + + const cic = guardrailData?.litellm_params?.competitor_intent_config; + if (cic && typeof cic === "object") { + const enabled = !!(cic.brand_self && Array.isArray(cic.brand_self) && cic.brand_self.length > 0); + const config: CompetitorIntentConfig = { + competitor_intent_type: cic.competitor_intent_type ?? "airline", + brand_self: Array.isArray(cic.brand_self) ? cic.brand_self : [], + locations: Array.isArray(cic.locations) ? cic.locations : [], + competitors: Array.isArray(cic.competitors) ? cic.competitors : [], + policy: cic.policy ?? { competitor_comparison: "refuse", possible_competitor_comparison: "reframe" }, + threshold_high: typeof cic.threshold_high === "number" ? cic.threshold_high : 0.7, + threshold_medium: typeof cic.threshold_medium === "number" ? cic.threshold_medium : 0.45, + threshold_low: typeof cic.threshold_low === "number" ? cic.threshold_low : 0.3, + }; + setCompetitorIntentEnabled(enabled); + setCompetitorIntentConfig(config); + setOriginalCompetitorIntentEnabled(enabled); + setOriginalCompetitorIntentConfig(config); + } else { + setCompetitorIntentEnabled(false); + setCompetitorIntentConfig(null); + setOriginalCompetitorIntentEnabled(false); + setOriginalCompetitorIntentConfig(null); + } }, [guardrailData, guardrailSettings?.content_filter_settings?.content_categories]); // Notify parent component when data changes useEffect(() => { if (onDataChange) { - onDataChange(selectedPatterns, blockedWords, selectedContentCategories); + onDataChange( + selectedPatterns, + blockedWords, + selectedContentCategories, + competitorIntentEnabled, + competitorIntentConfig + ); } - }, [selectedPatterns, blockedWords, selectedContentCategories, onDataChange]); + }, [ + selectedPatterns, + blockedWords, + selectedContentCategories, + competitorIntentEnabled, + competitorIntentConfig, + onDataChange, + ]); // Detect unsaved changes const hasUnsavedChanges = React.useMemo(() => { const hasPatternChanges = JSON.stringify(selectedPatterns) !== JSON.stringify(originalPatterns); const hasWordChanges = JSON.stringify(blockedWords) !== JSON.stringify(originalBlockedWords); const hasCategoryChanges = JSON.stringify(selectedContentCategories) !== JSON.stringify(originalContentCategories); - return hasPatternChanges || hasWordChanges || hasCategoryChanges; - }, [selectedPatterns, blockedWords, selectedContentCategories, originalPatterns, originalBlockedWords, originalContentCategories]); + const hasCompetitorIntentChanges = + competitorIntentEnabled !== originalCompetitorIntentEnabled || + JSON.stringify(competitorIntentConfig) !== JSON.stringify(originalCompetitorIntentConfig); + return hasPatternChanges || hasWordChanges || hasCategoryChanges || hasCompetitorIntentChanges; + }, [ + selectedPatterns, + blockedWords, + selectedContentCategories, + competitorIntentEnabled, + competitorIntentConfig, + originalPatterns, + originalBlockedWords, + originalContentCategories, + originalCompetitorIntentEnabled, + originalCompetitorIntentConfig, + ]); useEffect(() => { if (isEditing && onUnsavedChanges) { @@ -219,6 +281,12 @@ const ContentFilterManager: React.FC = ({ selectedContentCategories.map((c) => (c.id === id ? { ...c, [field]: value } : c)) ) } + competitorIntentEnabled={competitorIntentEnabled} + competitorIntentConfig={competitorIntentConfig} + onCompetitorIntentChange={(enabled, config) => { + setCompetitorIntentEnabled(enabled); + setCompetitorIntentConfig(config); + }} /> )}
@@ -232,12 +300,15 @@ export default ContentFilterManager; export const formatContentFilterDataForAPI = ( patterns: Pattern[], blockedWords: BlockedWord[], - categories?: SelectedContentCategory[] + categories?: SelectedContentCategory[], + competitorIntentEnabled?: boolean, + competitorIntentConfig?: CompetitorIntentConfig | null ) => { const result: { patterns: any[]; blocked_words: any[]; categories?: any[]; + competitor_intent_config?: any; } = { patterns: patterns.map((p) => ({ pattern_type: p.type === "prebuilt" ? "prebuilt" : "regex", @@ -260,5 +331,23 @@ export const formatContentFilterDataForAPI = ( severity_threshold: c.severity_threshold || "medium", })); } + if (competitorIntentEnabled && competitorIntentConfig && competitorIntentConfig.brand_self.length > 0) { + result.competitor_intent_config = { + competitor_intent_type: competitorIntentConfig.competitor_intent_type, + brand_self: competitorIntentConfig.brand_self, + locations: competitorIntentConfig.locations?.length + ? competitorIntentConfig.locations + : undefined, + competitors: + competitorIntentConfig.competitor_intent_type === "generic" && + competitorIntentConfig.competitors?.length + ? competitorIntentConfig.competitors + : undefined, + policy: competitorIntentConfig.policy, + threshold_high: competitorIntentConfig.threshold_high, + threshold_medium: competitorIntentConfig.threshold_medium, + threshold_low: competitorIntentConfig.threshold_low, + }; + } return result; }; diff --git a/ui/litellm-dashboard/src/components/guardrails/guardrail_info.tsx b/ui/litellm-dashboard/src/components/guardrails/guardrail_info.tsx index c876638d76..2151a91d9d 100644 --- a/ui/litellm-dashboard/src/components/guardrails/guardrail_info.tsx +++ b/ui/litellm-dashboard/src/components/guardrails/guardrail_info.tsx @@ -106,6 +106,8 @@ const GuardrailInfoView: React.FC = ({ guardrailId, onClose, patterns: any[]; blockedWords: any[]; categories: any[]; + competitorIntentEnabled?: boolean; + competitorIntentConfig?: any; }>({ patterns: [], blockedWords: [], @@ -113,9 +115,24 @@ const GuardrailInfoView: React.FC = ({ guardrailId, onClose, }); // Memoize onDataChange callback to prevent unnecessary re-renders - const handleContentFilterDataChange = useCallback((patterns: any[], blockedWords: any[], categories: any[]) => { - contentFilterDataRef.current = { patterns, blockedWords, categories: categories || [] }; - }, []); + const handleContentFilterDataChange = useCallback( + ( + patterns: any[], + blockedWords: any[], + categories: any[], + competitorIntentEnabled?: boolean, + competitorIntentConfig?: any + ) => { + contentFilterDataRef.current = { + patterns, + blockedWords, + categories: categories || [], + competitorIntentEnabled, + competitorIntentConfig, + }; + }, + [] + ); const fetchGuardrailInfo = async () => { try { @@ -287,11 +304,15 @@ const GuardrailInfoView: React.FC = ({ guardrailId, onClose, contentFilterDataRef.current.patterns || [], contentFilterDataRef.current.blockedWords || [], contentFilterDataRef.current.categories || [], + contentFilterDataRef.current.competitorIntentEnabled, + contentFilterDataRef.current.competitorIntentConfig ); updateData.litellm_params.patterns = formattedData.patterns; updateData.litellm_params.blocked_words = formattedData.blocked_words; updateData.litellm_params.categories = formattedData.categories; + updateData.litellm_params.competitor_intent_config = + formattedData.competitor_intent_config ?? null; } if (guardrailData.litellm_params?.guardrail === "tool_permission") { diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index 8b624f7caf..3ed71ecab1 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -5438,10 +5438,19 @@ export const getPoliciesList = async (accessToken: string) => { } }; +export interface GuardrailInputs { + texts?: string[]; + images?: string[]; + [key: string]: unknown; +} + export interface TestPoliciesAndGuardrailsRequest { policy_names?: string[] | null; guardrail_names?: string[] | null; - inputs: { texts?: string[]; images?: string[]; [key: string]: unknown }; + /** Single input (legacy). Use inputs_list for per-input batch processing. */ + inputs?: GuardrailInputs | null; + /** List of inputs; each processed separately for batch compliance testing. */ + inputs_list?: GuardrailInputs[] | null; request_data?: Record; input_type?: "request" | "response"; } @@ -5452,8 +5461,10 @@ export interface GuardrailErrorEntry { } export interface TestPoliciesAndGuardrailsResponse { - inputs: Record; - guardrail_errors: GuardrailErrorEntry[]; + inputs?: Record; + guardrail_errors?: GuardrailErrorEntry[]; + /** Present when inputs_list was used; one result per input. */ + results?: Array<{ inputs: Record; guardrail_errors: GuardrailErrorEntry[] }>; } export const testPoliciesAndGuardrails = async ( @@ -5473,7 +5484,8 @@ export const testPoliciesAndGuardrails = async ( body: JSON.stringify({ policy_names: body.policy_names ?? null, guardrail_names: body.guardrail_names ?? null, - inputs: body.inputs, + inputs: body.inputs ?? null, + inputs_list: body.inputs_list ?? null, request_data: body.request_data ?? {}, input_type: body.input_type ?? "request", }), @@ -7805,6 +7817,38 @@ export const getCategoryYaml = async (accessToken: string, categoryName: string) } }; +export const getMajorAirlines = async (accessToken: string) => { + try { + const url = proxyBaseUrl + ? `${proxyBaseUrl}/guardrails/ui/major_airlines` + : `/guardrails/ui/major_airlines`; + + const response = await fetch(url, { + method: "GET", + headers: { + [globalLitellmHeaderName]: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + }); + + if (!response.ok) { + const errorData = await response.text(); + console.error( + `Failed to get major airlines. Status: ${response.status}, Error:`, + errorData + ); + handleError(errorData); + throw new Error(`Failed to get major airlines: ${response.status} ${errorData}`); + } + + const data = await response.json(); + return data; + } catch (error) { + console.error("Failed to get major airlines:", error); + throw error; + } +}; + export const getAgentsList = async (accessToken: string) => { try { const url = proxyBaseUrl ? `${proxyBaseUrl}/v1/agents` : `/v1/agents`; diff --git a/ui/litellm-dashboard/src/components/playground/complianceUI/ComplianceUI.tsx b/ui/litellm-dashboard/src/components/playground/complianceUI/ComplianceUI.tsx index dc18773df6..083e3a827f 100644 --- a/ui/litellm-dashboard/src/components/playground/complianceUI/ComplianceUI.tsx +++ b/ui/litellm-dashboard/src/components/playground/complianceUI/ComplianceUI.tsx @@ -445,7 +445,7 @@ export default function ComplianceUI({ setQuickTestInput(""); setIsQuickTesting(true); try { - const { inputs, guardrail_errors } = await testPoliciesAndGuardrails( + const { inputs, guardrail_errors = [] } = await testPoliciesAndGuardrails( accessToken, { policy_names: @@ -532,50 +532,51 @@ export default function ComplianceUI({ status: "pending", })); setTestResults(pendingResults); - // Send each text individually to get per-text blocked/allowed results. - // Sending all texts in a single batch doesn't work because the guardrail - // raises an HTTPException on the first blocked text, skipping the rest. - const updatedResults = [...pendingResults]; - for (let i = 0; i < allTexts.length; i++) { - try { - const { inputs, guardrail_errors } = await testPoliciesAndGuardrails( - accessToken, - { - policy_names: - selectedPolicies.length > 0 ? selectedPolicies : undefined, - guardrail_names: - selectedGuardrails.length > 0 ? selectedGuardrails : undefined, - inputs: { texts: [allTexts[i]] }, - request_data: {}, - input_type: "request", - } - ); - const actualResult: "blocked" | "allowed" = - guardrail_errors.length > 0 ? "blocked" : "allowed"; - const triggeredBy = - guardrail_errors.length > 0 - ? guardrail_errors - .map((e) => `${e.guardrail_name}: ${e.message}`) - .join("; ") - : undefined; - const returnedText = - Array.isArray(inputs?.texts) && inputs.texts.length > 0 - ? inputs.texts[0] - : undefined; - updatedResults[i] = { - ...updatedResults[i], - actualResult, - isMatch: - (updatedResults[i].expectedResult === "fail" && actualResult === "blocked") || - (updatedResults[i].expectedResult === "pass" && actualResult === "allowed"), - triggeredBy, - returnedText, - status: "complete" as const, - }; - } catch (err) { - const errorMessage = err instanceof Error ? err.message : String(err); - updatedResults[i] = { - ...updatedResults[i], + try { + const inputsList = allTexts.map((text) => ({ texts: [text] })); + const response = await testPoliciesAndGuardrails(accessToken, { + policy_names: + selectedPolicies.length > 0 ? selectedPolicies : undefined, + guardrail_names: + selectedGuardrails.length > 0 ? selectedGuardrails : undefined, + inputs_list: inputsList, + request_data: {}, + input_type: "request", + }); + const results = response.results ?? []; + setTestResults( + pendingResults.map((row, index) => { + const item = results[index]; + const guardrail_errors = item?.guardrail_errors ?? []; + const actualResult: "blocked" | "allowed" = + guardrail_errors.length > 0 ? "blocked" : "allowed"; + const triggeredBy = + guardrail_errors.length > 0 + ? guardrail_errors + .map((e) => `${e.guardrail_name}: ${e.message}`) + .join("; ") + : undefined; + const returnedText = + Array.isArray(item?.inputs?.texts) && item.inputs.texts.length > 0 + ? item.inputs.texts[0] + : undefined; + return { + ...row, + actualResult, + isMatch: + (row.expectedResult === "fail" && actualResult === "blocked") || + (row.expectedResult === "pass" && actualResult === "allowed"), + triggeredBy, + returnedText, + status: "complete" as const, + }; + }) + ); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : String(err); + setTestResults( + pendingResults.map((row) => ({ + ...row, actualResult: "blocked" as const, isMatch: false, triggeredBy: `Error: ${errorMessage}`, @@ -596,6 +597,12 @@ export default function ComplianceUI({ const completedResults = testResults.filter((r) => r.status === "complete"); const matchCount = completedResults.filter((r) => r.isMatch).length; const mismatchCount = completedResults.filter((r) => !r.isMatch).length; + const falsePositiveCount = completedResults.filter( + (r) => r.expectedResult === "pass" && r.actualResult === "blocked" + ).length; + const falseNegativeCount = completedResults.filter( + (r) => r.expectedResult === "fail" && r.actualResult === "allowed" + ).length; const pendingCount = testResults.filter((r) => r.status !== "complete").length; const filteredResults = testResults.filter((r) => { if (resultFilter === "matches") return r.status === "complete" && r.isMatch; @@ -604,6 +611,31 @@ export default function ComplianceUI({ return true; }); + const exportBatchResults = () => { + if (filteredResults.length === 0) return; + const rows = filteredResults.map((r) => ({ + prompt_id: r.promptId, + prompt: r.prompt, + category: r.category, + expected_result: r.expectedResult, + actual_result: r.actualResult, + is_match: r.isMatch ? "yes" : "no", + status: r.status, + triggered_by: r.triggeredBy ?? "", + returned_text: r.returnedText ?? "", + })); + const csv = Papa.unparse(rows); + const blob = new Blob([csv], { type: "text/csv" }); + const url = window.URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `compliance_batch_results_${new Date().toISOString().slice(0, 10)}.csv`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + window.URL.revokeObjectURL(url); + }; + const filteredFrameworks = allFrameworks .map((fw) => ({ ...fw, @@ -1365,14 +1397,33 @@ export default function ComplianceUI({

Results

{testResults.length > 0 && ( -
+
+ +
{matchCount} - + + + {falseNegativeCount} FN + + - {mismatchCount} + {falsePositiveCount} FP {pendingCount > 0 && ( @@ -1381,6 +1432,7 @@ export default function ComplianceUI({ )}
+
)}
{testResults.length > 0 && ( @@ -1443,11 +1495,18 @@ export default function ComplianceUI({ correct
- - - {mismatchCount} + + + {falseNegativeCount} {" "} - gaps + false negative + +
+ + + {falsePositiveCount} + {" "} + false positive
(); - for (const prompt of compliancePrompts) { + for (const prompt of allCompliancePrompts) { if (!categoryMap.has(prompt.category)) { categoryMap.set(prompt.category, { name: prompt.category, @@ -277,6 +521,10 @@ const frameworkMeta: Record = { icon: "lock", description: "General Data Protection Regulation — data privacy and protection requirements.", }, + "Airline Brand Protection": { + icon: "plane", + description: "Destination vs competitor intent — avoid answering competitor comparison questions.", + }, }; export function getFrameworks(): ComplianceFramework[] { @@ -285,7 +533,7 @@ export function getFrameworks(): ComplianceFramework[] { { categories: Map } >(); - for (const prompt of compliancePrompts) { + for (const prompt of allCompliancePrompts) { if (!frameworkMap.has(prompt.framework)) { frameworkMap.set(prompt.framework, { categories: new Map() }); } diff --git a/ui/litellm-dashboard/tsconfig.json b/ui/litellm-dashboard/tsconfig.json index d24bdd340f..5b0352feb9 100644 --- a/ui/litellm-dashboard/tsconfig.json +++ b/ui/litellm-dashboard/tsconfig.json @@ -14,7 +14,7 @@ "moduleResolution": "bundler", "resolveJsonModule": true, "isolatedModules": true, - "jsx": "react-jsx", + "jsx": "preserve", "incremental": true, "plugins": [ {