diff --git a/litellm/proxy/guardrails/guardrail_endpoints.py b/litellm/proxy/guardrails/guardrail_endpoints.py index 44caa24524a..e14782fa1f8 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/content_filter.py b/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/content_filter.py index 8f616337bdc..30b22f0916b 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,34 +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, - CompetitorIntentDetection, - CompetitorIntentResult, - ContentFilterCategoryConfig, - ContentFilterDetection, - PatternDetection, -) + BlockedWordDetection, CategoryKeywordDetection, CompetitorIntentDetection, + CompetitorIntentResult, ContentFilterCategoryConfig, + ContentFilterDetection, PatternDetection) -from .competitor_intent import AirlineCompetitorIntentChecker +from .competitor_intent import (AirlineCompetitorIntentChecker, + BaseCompetitorIntentChecker) from .patterns import PATTERN_EXTRA_CONFIG, get_compiled_pattern MAX_KEYWORD_VALUE_GAP_WORDS = 1 @@ -206,15 +183,24 @@ class ContentFilterGuardrail(CustomGuardrail): {} ) # category_name -> {identifier_words, block_words, action, severity} - # Competitor intent checker (optional, generic; industry presets add domain_words etc.) - self._competitor_intent_checker: Optional[AirlineCompetitorIntentChecker] = None + # 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: - self._competitor_intent_checker = AirlineCompetitorIntentChecker( - competitor_intent_config + 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" + "ContentFilterGuardrail: competitor intent checker enabled (%s)", + competitor_intent_type, ) except Exception as e: verbose_proxy_logger.warning( @@ -1822,8 +1808,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 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 index 03e9f80dcaf..1daaf4c26ff 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/examples/README.md +++ b/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/examples/README.md @@ -1,31 +1,31 @@ # Content Filter Examples -## Industry-specific competitor intent: Emirates (airline) +## Industry-specific competitor intent (airline) -The **generic** competitor intent blocker only needs: +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. `["emirates", "ek"]`) -- **competitors**: List of competitor names (e.g. `["qatar airways", "etihad"]`) +- **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 Emirates (airline): `airline`, `carrier`, `flight`, `business class`, `lounge`, etc. + 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 Emirates: `doha`, `dubai`, `abu dhabi`, `gulf`, `middle east`. + For airline: `country`, `hub-city`, `airport-code`, `region`. 3. **descriptor_lexicon** – Phrases that count as indirect competitor reference. - For aviation: `doha airline`, `oryx airline`, `gulf carrier`, `five star airline`, `skytrax`. + For aviation: `gulf carrier`, `five star airline`, etc. 4. **competitor_aliases** – Per-competitor aliases (IATA codes, nicknames). - Example: `qatar airways` → `["qr", "doha airline"]`, `etihad` → `["ey"]`. + 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 **emirates_competitor_intent_guardrail.yaml** for a full example you can copy into your proxy `guardrails` config or merge into an existing `litellm_content_filter` guardrail. +See the config examples below for how to add this to your proxy `guardrails` config. ### Using the example in your proxy config @@ -33,27 +33,20 @@ In `config.yaml`: ```yaml guardrails: - - guardrail_name: "emirates-competitor-intent" + - guardrail_name: "airline-competitor-intent" litellm_params: guardrail: litellm_content_filter mode: pre_call competitor_intent_config: - brand_self: [emirates, ek] - competitors: [qatar airways, qatar, etihad, turkish airlines] - domain_words: [airline, carrier, flight, business class, lounge] - route_geo_cues: [doha, dubai, abu dhabi, gulf] - descriptor_lexicon: [doha airline, gulf carrier, five star airline] - competitor_aliases: - qatar airways: [qr, doha airline] - etihad: [ey] + competitor_intent_type: airline + brand_self: [your-brand, your-code] + locations: [relevant-country, hub-city] policy: competitor_comparison: refuse possible_competitor_comparison: reframe - category_ranking: reframe - log_only: log_only ``` -Then attach this guardrail to your router/policy (e.g. `guardrails.add: [emirates-competitor-intent]`). +Then attach this guardrail to your router/policy (e.g. `guardrails.add: [airline-competitor-intent]`). ### Other industries diff --git a/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/examples/emirates_competitor_intent_guardrail.yaml b/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/examples/emirates_competitor_intent_guardrail.yaml deleted file mode 100644 index d3d0f52a3b5..00000000000 --- a/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/examples/emirates_competitor_intent_guardrail.yaml +++ /dev/null @@ -1,134 +0,0 @@ -# Emirates (airline) industry-specific competitor intent guardrail -# -# This is an example of building on the generic competitor intent blocker with -# industry-specific inputs: domain_words, route_geo_cues, descriptor_lexicon, -# and competitor_aliases for aviation. -# -# Use this guardrail in your proxy config under guardrails.litellm_params, or -# merge competitor_intent_config into an existing litellm_content_filter guardrail. -# -# Minimal required (generic): brand_self, competitors -# Industry layer (airline): domain_words, route_geo_cues, descriptor_lexicon, -# competitor_aliases (IATA/descriptive), optional indirect_competitor_patterns - -guardrail_name: "emirates-competitor-intent" -guardrail_id: "emirates-competitor-intent" -litellm_params: - guardrail: litellm_content_filter - mode: pre_call - default_on: false - - # Generic inputs (customer provides) - competitor_intent_config: - # Your brand (Emirates) and how users refer to it - brand_self: - - emirates - - ek - - # Direct competitors (canonical names; use competitor_aliases for codes/typos) - competitors: - - qatar airways - - qatar - - etihad - - turkish airlines - - singapore airlines - - british airways - - # Geo entities: countries, cities, airports (for other-meaning vs airline disambiguation) - locations: - - qatar - - doha - - doh - - hamad international - - lusail - - # Industry: airline domain words (trigger category_ranking / gate) - domain_words: - - airline - - airlines - - carrier - - flight - - flights - - business class - - first class - - lounge - - lounges - - premium cabin - - long haul - - # Industry: route/geo cues (Gulf, hubs) - route_geo_cues: - - doha - - dubai - - abu dhabi - - gulf - - middle east - - # Indirect references (descriptor lexicon) - descriptor_lexicon: - - doha airline - - oryx airline - - gulf carrier - - five star airline - - skytrax - - # Per-competitor aliases (IATA codes, nicknames) - competitor_aliases: - qatar airways: - - qr - - doha airline - qatar: - - qr - etihad: - - ey - - abu dhabi airline - emirates: - - ek - - dubai airline - - # Optional: regex patterns for indirect competitor mention (on normalized text) - # indirect_competitor_patterns: - # qatar airways: - # - "the doha airline" - # - "doha carrier" - # etihad: - # - "abu dhabi carrier" - - # Policy: what to do per intent band - 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 - - # Optional: custom messages - reframe_message_template: > - I'm here to help with Emirates services, bookings, and travel information. - Would you like to know about our routes, cabin classes, or loyalty program? - refuse_message_template: "We can't compare Emirates with other airlines. How can I help with Emirates travel?" - -# --- How to use in config.yaml --- -# -# guardrails: -# - guardrail_name: "emirates-competitor-intent" -# litellm_params: -# guardrail: litellm_content_filter -# mode: pre_call -# competitor_intent_config: -# brand_self: [emirates, ek] -# competitors: [qatar airways, etihad, ...] -# domain_words: [airline, carrier, flight, ...] -# route_geo_cues: [doha, dubai, abu dhabi] -# descriptor_lexicon: [doha airline, gulf carrier, ...] -# competitor_aliases: -# qatar airways: [qr, doha airline] -# etihad: [ey] -# policy: -# competitor_comparison: refuse -# possible_competitor_comparison: reframe -# category_ranking: reframe -# log_only: log_only 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 index e2deb3612c0..3f4098ba7e0 100644 --- 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 @@ -115,6 +115,46 @@ class TestAirlineCompetitorIntentChecker: 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 \ 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 0aad42feb08..62676737cc8 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 content filter setting (category, pattern, or keyword)" + "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); + }} /> ); }; @@ -774,6 +817,9 @@ const AddGuardrailForm: React.FC = ({ visible, onClose, a 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; return (
@@ -787,12 +833,12 @@ const AddGuardrailForm: React.FC = ({ visible, onClose, a - ) : ( 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 00000000000..f475efea367 --- /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 5715b3c136b..99100677b2a 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 1070453425b..1e106e2b1c4 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 c876638d769..2151a91d9d7 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 b7b66122677..3ed71ecab17 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -7817,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`;