feat: ui tweaks for prod

This commit is contained in:
Krrish Dholakia 2026-02-20 18:40:53 -08:00
parent 027b92b27a
commit 434ff5289d
12 changed files with 685 additions and 218 deletions

View file

@ -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"],

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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 \

View file

@ -110,6 +110,8 @@ const AddGuardrailForm: React.FC<AddGuardrailFormProps> = ({ visible, onClose, a
const [blockedWords, setBlockedWords] = useState<any[]>([]);
const [selectedContentCategories, setSelectedContentCategories] = useState<any[]>([]);
const [pendingCategorySelection, setPendingCategorySelection] = useState<string>("");
const [competitorIntentEnabled, setCompetitorIntentEnabled] = useState(false);
const [competitorIntentConfig, setCompetitorIntentConfig] = useState<any>(null);
const [toolPermissionConfig, setToolPermissionConfig] = useState<ToolPermissionConfig>({
rules: [],
default_action: "deny",
@ -175,6 +177,8 @@ const AddGuardrailForm: React.FC<AddGuardrailFormProps> = ({ visible, onClose, a
setBlockedWords([]);
setSelectedContentCategories([]);
setPendingCategorySelection("");
setCompetitorIntentEnabled(false);
setCompetitorIntentConfig(null);
setToolPermissionConfig({
rules: [],
@ -254,7 +258,13 @@ const AddGuardrailForm: React.FC<AddGuardrailFormProps> = ({ 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<AddGuardrailFormProps> = ({ 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<AddGuardrailFormProps> = ({ 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<AddGuardrailFormProps> = ({ 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<AddGuardrailFormProps> = ({ 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 (
<div className="flex justify-end space-x-2 mt-4">
@ -787,12 +833,12 @@ const AddGuardrailForm: React.FC<AddGuardrailFormProps> = ({ visible, onClose, a
<Button onClick={nextStep}>
Skip
</Button>
<Button
type="primary"
onClick={handleAddAndContinue}
disabled={!hasPendingCategory}
<Button
type="primary"
onClick={() => handleAddAndContinue(hasCompetitorIntentConfigured)}
disabled={!canContinueFromCategoriesStep}
>
Add & Continue →
{hasPendingCategory ? "Add & Continue →" : "Continue →"}
</Button>
</>
) : (

View file

@ -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<MajorAirline[]>([]);
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<string>();
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 (
<Card
title={
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<Title level={5} style={{ margin: 0 }}>
Competitor Intent Filter
</Title>
<Switch checked={false} onChange={handleEnabledChange} />
</div>
}
size="small"
>
<Text type="secondary">
Block or reframe competitor comparison questions. When enabled, airline type
auto-loads competitors from IATA; generic type requires manual competitor list.
</Text>
</Card>
);
}
return (
<Card
title={
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<Title level={5} style={{ margin: 0 }}>
Competitor Intent Filter
</Title>
<Switch checked={enabled} onChange={handleEnabledChange} />
</div>
}
size="small"
>
<Text type="secondary" style={{ display: "block", marginBottom: 16 }}>
Block or reframe competitor comparison questions. Airline type uses major airlines
(excluding your brand); generic requires manual competitor list.
</Text>
<Form layout="vertical" size="small">
<Form.Item label="Type">
<Select
value={effectiveConfig.competitor_intent_type}
onChange={(v) => handleConfigChange("competitor_intent_type", v)}
style={{ width: "100%" }}
>
<Option value="airline">Airline (auto-load competitors from IATA)</Option>
<Option value="generic">Generic (specify competitors manually)</Option>
</Select>
</Form.Item>
<Form.Item
label="Your Brand (brand_self)"
required
help={
effectiveConfig.competitor_intent_type === "airline"
? "Select your airline from the list (excluded from competitors) or type to add a custom term"
: "Names/codes users use for your brand"
}
>
<Select
mode="tags"
style={{ width: "100%" }}
placeholder={
loadingAirlines
? "Loading airlines..."
: effectiveConfig.competitor_intent_type === "airline"
? "Search or select airline, or type to add custom"
: "Type and press Enter to add"
}
value={effectiveConfig.brand_self}
onChange={(v) =>
effectiveConfig.competitor_intent_type === "airline" && airlineOptions.length > 0
? handleBrandSelfChange(v ?? [])
: handleNestedArrayChange("brand_self", v ?? [])
}
tokenSeparators={[","]}
loading={loadingAirlines}
showSearch
filterOption={(input, option) =>
(option?.label?.toString().toLowerCase() ?? "").includes(
input.toLowerCase()
)
}
optionFilterProp="label"
options={
effectiveConfig.competitor_intent_type === "airline" && airlineOptions.length > 0
? airlineOptions.map((a) => {
const primary = a.match.split("|")[0]?.trim() ?? a.id;
const variants = a.match
.split("|")
.map((s) => s.trim().toLowerCase())
.filter(Boolean);
return {
value: primary.toLowerCase(),
label: `${primary}${variants.length > 1 ? ` (${variants.slice(1).join(", ")})` : ""}`,
};
})
: undefined
}
/>
</Form.Item>
{effectiveConfig.competitor_intent_type === "airline" && (
<Form.Item
label="Locations (optional)"
help="Countries, cities, airports for disambiguation (e.g. qatar, doha)"
>
<Select
mode="tags"
style={{ width: "100%" }}
placeholder="Type and press Enter to add"
value={effectiveConfig.locations ?? []}
onChange={(v) => handleNestedArrayChange("locations", v ?? [])}
tokenSeparators={[","]}
/>
</Form.Item>
)}
{effectiveConfig.competitor_intent_type === "generic" && (
<Form.Item
label="Competitors"
required
help="Competitor names to detect (required for generic type)"
>
<Select
mode="tags"
style={{ width: "100%" }}
placeholder="Type and press Enter to add"
value={effectiveConfig.competitors ?? []}
onChange={(v) => handleNestedArrayChange("competitors", v ?? [])}
tokenSeparators={[","]}
/>
</Form.Item>
)}
<Form.Item label="Policy: Competitor comparison">
<Select
value={effectiveConfig.policy?.competitor_comparison ?? "refuse"}
onChange={(v) => handlePolicyChange("competitor_comparison", v)}
style={{ width: "100%" }}
>
<Option value="refuse">Refuse (block request)</Option>
<Option value="reframe">Reframe (suggest alternative)</Option>
</Select>
</Form.Item>
<Form.Item label="Policy: Possible competitor comparison">
<Select
value={effectiveConfig.policy?.possible_competitor_comparison ?? "reframe"}
onChange={(v) => handlePolicyChange("possible_competitor_comparison", v)}
style={{ width: "100%" }}
>
<Option value="refuse">Refuse (block request)</Option>
<Option value="reframe">Reframe (suggest alternative to backend LLM)</Option>
</Select>
</Form.Item>
<Form.Item
label="Confidence thresholds"
help={
<>
Classify competitor intent by confidence (0–1). Higher confidence → stronger intent.
<ul style={{ marginBottom: 0, marginTop: 4, paddingLeft: 20 }}>
<li>
<strong>High (≥)</strong>: Treat as full competitor comparison → uses &quot;Competitor comparison&quot; policy
</li>
<li>
<strong>Medium (≥)</strong>: Treat as possible comparison → uses &quot;Possible competitor comparison&quot; policy
</li>
<li>
<strong>Low (≥)</strong>: Log only; allow request. Below Low → allow with no action
</li>
</ul>
Raise thresholds to be more permissive; lower them to be stricter.
</>
}
>
<Space wrap>
<Form.Item label="High" style={{ marginBottom: 0 }} help="e.g. 0.7">
<InputNumber
min={0}
max={1}
step={0.05}
value={effectiveConfig.threshold_high ?? 0.7}
onChange={(v) => handleConfigChange("threshold_high", v ?? 0.7)}
style={{ width: 80 }}
/>
</Form.Item>
<Form.Item label="Medium" style={{ marginBottom: 0 }} help="e.g. 0.45">
<InputNumber
min={0}
max={1}
step={0.05}
value={effectiveConfig.threshold_medium ?? 0.45}
onChange={(v) => handleConfigChange("threshold_medium", v ?? 0.45)}
style={{ width: 80 }}
/>
</Form.Item>
<Form.Item label="Low" style={{ marginBottom: 0 }} help="e.g. 0.3">
<InputNumber
min={0}
max={1}
step={0.05}
value={effectiveConfig.threshold_low ?? 0.3}
onChange={(v) => handleConfigChange("threshold_low", v ?? 0.3)}
style={{ width: 80 }}
/>
</Form.Item>
</Space>
</Form.Item>
</Form>
</Card>
);
};
export default CompetitorIntentConfiguration;

View file

@ -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<ContentFilterConfigurationProps> = ({
@ -94,6 +103,9 @@ const ContentFilterConfiguration: React.FC<ContentFilterConfigurationProps> = ({
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<ContentFilterConfigurationProps> = ({
const showPatterns = !showStep || showStep === "patterns";
const showKeywords = !showStep || showStep === "keywords";
const showCategories = !showStep || showStep === "categories";
const showCompetitorIntent =
!showStep || showStep === "competitor_intent" || showStep === "categories";
return (
<div className="space-y-6">
@ -274,6 +288,16 @@ const ContentFilterConfiguration: React.FC<ContentFilterConfigurationProps> = ({
</Card>
)}
{showCompetitorIntent &&
onCompetitorIntentChange && (
<CompetitorIntentConfiguration
enabled={competitorIntentEnabled}
config={competitorIntentConfig}
onChange={onCompetitorIntentChange}
accessToken={accessToken}
/>
)}
{showCategories && contentCategories.length > 0 && onContentCategoryAdd && onContentCategoryRemove && onContentCategoryUpdate && (
<ContentCategoryConfiguration
availableCategories={contentCategories}

View file

@ -384,7 +384,7 @@ describe("ContentFilterManager", () => {
);
await waitFor(() => {
expect(mockOnDataChange).toHaveBeenCalledWith([], [], []);
expect(mockOnDataChange).toHaveBeenCalledWith([], [], [], false, null);
});
});

View file

@ -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<ContentFilterManagerProps> = ({
const [originalPatterns, setOriginalPatterns] = useState<Pattern[]>([]);
const [originalBlockedWords, setOriginalBlockedWords] = useState<BlockedWord[]>([]);
const [originalContentCategories, setOriginalContentCategories] = useState<SelectedContentCategory[]>([]);
const [competitorIntentEnabled, setCompetitorIntentEnabled] = useState(false);
const [competitorIntentConfig, setCompetitorIntentConfig] = useState<CompetitorIntentConfig | null>(null);
const [originalCompetitorIntentEnabled, setOriginalCompetitorIntentEnabled] = useState(false);
const [originalCompetitorIntentConfig, setOriginalCompetitorIntentConfig] = useState<CompetitorIntentConfig | null>(null);
// Load data from guardrail on mount or when guardrailData changes
useEffect(() => {
@ -128,22 +139,73 @@ const ContentFilterManager: React.FC<ContentFilterManagerProps> = ({
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<ContentFilterManagerProps> = ({
selectedContentCategories.map((c) => (c.id === id ? { ...c, [field]: value } : c))
)
}
competitorIntentEnabled={competitorIntentEnabled}
competitorIntentConfig={competitorIntentConfig}
onCompetitorIntentChange={(enabled, config) => {
setCompetitorIntentEnabled(enabled);
setCompetitorIntentConfig(config);
}}
/>
)}
</div>
@ -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;
};

View file

@ -106,6 +106,8 @@ const GuardrailInfoView: React.FC<GuardrailInfoProps> = ({ guardrailId, onClose,
patterns: any[];
blockedWords: any[];
categories: any[];
competitorIntentEnabled?: boolean;
competitorIntentConfig?: any;
}>({
patterns: [],
blockedWords: [],
@ -113,9 +115,24 @@ const GuardrailInfoView: React.FC<GuardrailInfoProps> = ({ 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<GuardrailInfoProps> = ({ 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") {

View file

@ -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`;