Coverage for .venv/lib/python3.13/site-packages/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/content_filter.py: 11%

993 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-10-10 12:01 +0000

1""" 

2Content Filter Guardrail for LiteLLM. 

3 

4This guardrail provides regex pattern matching and keyword filtering 

5to detect and block/mask sensitive content. 

6""" 

7 

8import asyncio 

9import json 

10import os 

11import re 

12import time 

13from collections.abc import AsyncGenerator, Coroutine, Mapping, Sequence 

14from dataclasses import dataclass, replace 

15from datetime import datetime 

16from re import Pattern 

17from typing import TYPE_CHECKING, Any, Final, Literal, Optional, TypedDict, cast 

18 

19import yaml 

20from fastapi import HTTPException 

21 

22from litellm import Router 

23from litellm._logging import verbose_proxy_logger 

24from litellm.constants import ( 

25 CONTENT_FILTER_STREAMING_HOLDBACK_CHARS, 

26 CONTENT_FILTER_STREAMING_SCAN_CONTEXT_CHARS, 

27 DEFAULT_MAX_RECURSE_DEPTH, 

28) 

29from litellm.integrations.custom_guardrail import CustomGuardrail 

30from litellm.proxy._types import UserAPIKeyAuth 

31from litellm.types.utils import ( 

32 CallTypes, 

33 Function, 

34 GenericGuardrailAPIInputs, 

35 GuardrailStatus, 

36 GuardrailTracingDetail, 

37 ModelResponse, 

38 ModelResponseStream, 

39) 

40 

41if TYPE_CHECKING: 41 ↛ 42line 41 didn't jump to line 42 because the condition on line 41 was never true

42 from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj 

43 

44from litellm.types.guardrails import ( 

45 BlockedWord, 

46 ContentFilterAction, 

47 ContentFilterPattern, 

48 GuardrailEventHooks, 

49 Mode, 

50) 

51from litellm.types.proxy.guardrails.guardrail_hooks.litellm_content_filter import ( 

52 BlockedWordDetection, 

53 CategoryKeywordDetection, 

54 CompetitorIntentDetection, 

55 CompetitorIntentResult, 

56 ContentFilterCategoryConfig, 

57 ContentFilterDetection, 

58 PatternDetection, 

59) 

60 

61from .competitor_intent import ( 

62 AirlineCompetitorIntentChecker, 

63 BaseCompetitorIntentChecker, 

64) 

65from .patterns import PATTERN_EXTRA_CONFIG, get_compiled_pattern 

66 

67MAX_KEYWORD_VALUE_GAP_WORDS: Final = 1 

68GAP_WORD_TOKENIZER: Final = re.compile(r"\b\w+\b") 

69SENTENCE_TERMINATORS: Final = re.compile(r"[.!?]+") 

70 

71 

72WORD_NUMBER_MAP: Final = { 

73 "zero": "0", 

74 "oh": "0", 

75 "one": "1", 

76 "two": "2", 

77 "three": "3", 

78 "four": "4", 

79 "five": "5", 

80 "six": "6", 

81 "seven": "7", 

82 "eight": "8", 

83 "nine": "9", 

84} 

85 

86WORD_NUMBER_TOKEN_REGEX: Final = "|".join(WORD_NUMBER_MAP.keys()) 

87WORD_NUMBER_SEQUENCE_PATTERN: Final = re.compile( 

88 rf"(?<![A-Za-z])(?:{WORD_NUMBER_TOKEN_REGEX})(?:[\s\-]+(?:{WORD_NUMBER_TOKEN_REGEX}))+(?![A-Za-z])", 

89 re.IGNORECASE, 

90) 

91WORD_NUMBER_TOKEN_FINDER: Final = re.compile(rf"(?:{WORD_NUMBER_TOKEN_REGEX})", re.IGNORECASE) 

92 

93 

94class ConditionalCategoryConfig(TypedDict): 

95 identifier_words: Sequence[str] 

96 block_words: Sequence[str] 

97 action: ContentFilterAction 

98 severity: str 

99 

100 

101class CompiledPatternEntry(TypedDict): 

102 regex: Pattern[str] 

103 pattern_name: str 

104 action: ContentFilterAction 

105 keyword_regex: Pattern[str] | None 

106 allow_word_numbers: bool 

107 

108 

109class _PatternExtraLookup(TypedDict): 

110 keyword_pattern: str | None 

111 allow_word_numbers: bool 

112 

113 

114class _CategoryConfigView(TypedDict): 

115 category: object 

116 enabled: object 

117 action: object 

118 category_file: str | None 

119 

120 

121@dataclass(frozen=True, slots=True) 

122class _StreamedChoiceState: 

123 buffered_text: str = "" 

124 yielded_masked_text_len: int = 0 

125 committed_detections: tuple[ContentFilterDetection, ...] = () 

126 latest_detections: tuple[ContentFilterDetection, ...] = () 

127 next_trim_len: int = 0 

128 

129 

130@dataclass(frozen=True, slots=True) 

131class _StreamedScanPlan: 

132 context_chars: int 

133 exception_phrases: tuple[str, ...] 

134 conditional_words: tuple[str, ...] 

135 

136 

137class CategoryFileData(TypedDict, total=False): 

138 category_name: str 

139 description: str 

140 default_action: str 

141 keywords: Sequence[Mapping[str, str]] 

142 exceptions: Sequence[str] 

143 identifier_words: Sequence[str] 

144 always_block_keywords: Sequence[Mapping[str, str]] 

145 inherit_from: str 

146 additional_block_words: Sequence[str] 

147 phrase_patterns: Sequence[str] 

148 

149 

150# Helper data structure for category-based detection 

151class CategoryConfig: 

152 """Configuration for a content category.""" 

153 

154 def __init__( 

155 self, 

156 category_name: str, 

157 description: str, 

158 default_action: ContentFilterAction, 

159 keywords: Sequence[Mapping[str, str]], 

160 exceptions: Sequence[str], 

161 identifier_words: Sequence[str] | None = None, 

162 always_block_keywords: Sequence[Mapping[str, str]] | None = None, 

163 inherit_from: str | None = None, 

164 additional_block_words: Sequence[str] | None = None, 

165 phrase_patterns: Sequence[str] | None = None, 

166 ): 

167 self.category_name = category_name 

168 self.description = description 

169 self.default_action = default_action 

170 self.keywords = keywords 

171 self.exceptions = [e.lower() for e in exceptions] 

172 # New fields for conditional child safety logic 

173 self.identifier_words = [w.lower() for w in identifier_words] if identifier_words else [] 

174 self.always_block_keywords = always_block_keywords or [] 

175 self.inherit_from = inherit_from 

176 self.additional_block_words = [w.lower() for w in additional_block_words] if additional_block_words else [] 

177 # Phrase patterns: regex patterns for catching paraphrases 

178 self.phrase_patterns: list[tuple[str, Pattern]] = [] 

179 for p in phrase_patterns or []: 

180 try: 

181 self.phrase_patterns.append((p, re.compile(p, re.IGNORECASE))) 

182 except re.error: 

183 verbose_proxy_logger.warning("Invalid phrase pattern in %s: %s", category_name, p) 

184 

185 

186class ContentFilterGuardrail(CustomGuardrail): 

187 """ 

188 Content filter guardrail that detects sensitive information using: 

189 - Prebuilt regex patterns (SSN, credit cards, API keys, etc.) 

190 - Custom user-defined regex patterns 

191 - Dictionary-based keyword matching 

192 

193 Actions: 

194 - BLOCK: Reject the request with an error 

195 - MASK: Replace the sensitive content with a redacted placeholder 

196 """ 

197 

198 # Redaction format constants 

199 PATTERN_REDACTION_FORMAT = "[{pattern_name}_REDACTED]" 

200 KEYWORD_REDACTION_STR = "[KEYWORD_REDACTED]" 

201 

202 def __init__( 

203 self, 

204 guardrail_name: str | None = None, 

205 guardrail_id: str | None = None, 

206 policy_template: str | None = None, 

207 patterns: list[ContentFilterPattern] | None = None, 

208 blocked_words: list[BlockedWord] | None = None, 

209 blocked_words_file: str | None = None, 

210 event_hook: GuardrailEventHooks | list[GuardrailEventHooks] | Mode | None = None, 

211 default_on: bool = False, 

212 pattern_redaction_format: str | None = None, 

213 keyword_redaction_tag: str | None = None, 

214 categories: list[ContentFilterCategoryConfig] | None = None, 

215 severity_threshold: str = "medium", 

216 llm_router: Router | None = None, 

217 image_model: str | None = None, 

218 competitor_intent_config: dict[str, object] | None = None, 

219 **kwargs, 

220 ): 

221 """ 

222 Initialize the Content Filter Guardrail. 

223 

224 Args: 

225 guardrail_name: Name of this guardrail instance 

226 patterns: List of ContentFilterPattern objects to detect 

227 blocked_words: List of BlockedWord objects with keywords and actions 

228 blocked_words_file: Path to YAML file containing blocked_words list 

229 event_hook: When to run this guardrail (pre_call, post_call, etc.) 

230 default_on: If True, runs on all requests by default 

231 pattern_redaction_format: Format string for pattern redaction (use {pattern_name} placeholder) 

232 keyword_redaction_tag: Tag to use for keyword redaction 

233 categories: List of category configurations with enabled/action/severity settings 

234 severity_threshold: Minimum severity to block ("high", "medium", "low") 

235 """ 

236 

237 super().__init__( 

238 guardrail_name=guardrail_name, 

239 supported_event_hooks=list(self.get_supported_event_hooks()), 

240 event_hook=event_hook or GuardrailEventHooks.pre_call, 

241 default_on=default_on, 

242 **kwargs, 

243 ) 

244 

245 self.guardrail_provider = "litellm_content_filter" 

246 self.config_guardrail_id = guardrail_id 

247 self.config_policy_template = policy_template 

248 self.pattern_redaction_format = pattern_redaction_format or self.PATTERN_REDACTION_FORMAT 

249 self.keyword_redaction_tag = keyword_redaction_tag or self.KEYWORD_REDACTION_STR 

250 self.severity_threshold = severity_threshold 

251 self.llm_router = llm_router 

252 self.image_model = image_model 

253 # Store loaded categories 

254 self.loaded_categories: dict[str, CategoryConfig] = {} 

255 self.category_keywords: dict[ 

256 str, tuple[str, str, ContentFilterAction] 

257 ] = {} # keyword -> (category, severity, action) 

258 # Always-block keywords are checked after exceptions (exceptions take precedence) 

259 self.always_block_category_keywords: dict[str, tuple[str, str, ContentFilterAction]] = {} 

260 # Store conditional categories (identifier_words + block_words) 

261 self.conditional_categories: dict[str, ConditionalCategoryConfig] = {} 

262 

263 # Competitor intent checker (optional; airline uses major_airlines.json, generic requires competitors) 

264 self._competitor_intent_checker: BaseCompetitorIntentChecker | None = None 

265 if competitor_intent_config and isinstance(competitor_intent_config, dict): 

266 self._init_competitor_intent_checker(competitor_intent_config) 

267 

268 # Load categories if provided 

269 if categories: 

270 self._load_categories(categories) 

271 

272 # Normalize inputs: convert dicts to Pydantic models for consistent handling 

273 normalized_patterns: Final = self._normalize_patterns(patterns) 

274 normalized_blocked_words: Final = self._normalize_blocked_words(blocked_words) 

275 

276 # Compile regex patterns 

277 self.compiled_patterns: list[CompiledPatternEntry] = [] 

278 for pattern_config in normalized_patterns: 

279 self._add_pattern(pattern_config) 

280 

281 # Warn if using during_call with MASK action (unstable) 

282 if self.event_hook == GuardrailEventHooks.during_call and any( 

283 p["action"] == ContentFilterAction.MASK for p in self.compiled_patterns 

284 ): 

285 verbose_proxy_logger.warning( 

286 "ContentFilterGuardrail '%s': 'during_call' mode with 'MASK' action is unstable due to race conditions. Use 'pre_call' mode for reliable request masking.", 

287 self.guardrail_name, 

288 ) 

289 

290 # Load blocked words - always initialize as dict 

291 self.blocked_words: dict[str, tuple[ContentFilterAction, str | None]] = {} 

292 for word in normalized_blocked_words: 

293 self.blocked_words[word.keyword.lower()] = (word.action, word.description) 

294 

295 # Defensive check: ensure blocked_words is a dict (not a list) 

296 if not isinstance(self.blocked_words, dict): 

297 verbose_proxy_logger.error( 

298 "blocked_words is not a dict, got %s. Resetting to empty dict.", type(self.blocked_words) 

299 ) 

300 self.blocked_words = {} 

301 

302 # Load blocked words from file if provided 

303 if blocked_words_file: 

304 self._load_blocked_words_file(blocked_words_file) 

305 

306 verbose_proxy_logger.debug( 

307 "ContentFilterGuardrail initialized with %s patterns and %s blocked words", 

308 len(self.compiled_patterns), 

309 len(self.blocked_words), 

310 ) 

311 verbose_proxy_logger.debug( 

312 "Loaded %s categories with %s keywords", len(self.loaded_categories), len(self.category_keywords) 

313 ) 

314 

315 def _init_competitor_intent_checker(self, competitor_intent_config: dict[str, object]) -> None: 

316 try: 

317 competitor_intent_type: Final = competitor_intent_config.get("competitor_intent_type", "airline") 

318 if competitor_intent_type == "generic": 

319 self._competitor_intent_checker = BaseCompetitorIntentChecker(competitor_intent_config) 

320 else: 

321 self._competitor_intent_checker = AirlineCompetitorIntentChecker(competitor_intent_config) 

322 verbose_proxy_logger.debug( 

323 "ContentFilterGuardrail: competitor intent checker enabled (%s)", 

324 competitor_intent_type, 

325 ) 

326 except Exception as e: 

327 verbose_proxy_logger.warning( 

328 "ContentFilterGuardrail: failed to init competitor intent checker: %s", 

329 e, 

330 ) 

331 

332 @staticmethod 

333 def _normalize_patterns( 

334 patterns: list[ContentFilterPattern] | None, 

335 ) -> list[ContentFilterPattern]: 

336 result: Final[list[ContentFilterPattern]] = [] 

337 if patterns: 

338 for pattern_config in patterns: 

339 if isinstance(pattern_config, dict): 

340 result.append(ContentFilterPattern(**pattern_config)) 

341 else: 

342 result.append(pattern_config) 

343 return result 

344 

345 @staticmethod 

346 def _normalize_blocked_words( 

347 blocked_words: list[BlockedWord] | None, 

348 ) -> list[BlockedWord]: 

349 result: Final[list[BlockedWord]] = [] 

350 if blocked_words: 

351 for word in blocked_words: 

352 if isinstance(word, dict): 

353 result.append(BlockedWord(**word)) 

354 else: 

355 result.append(word) 

356 return result 

357 

358 @staticmethod 

359 def _category_config_view(cat_config: ContentFilterCategoryConfig) -> _CategoryConfigView: 

360 return { 

361 "category": cat_config.get("category"), 

362 "enabled": cat_config.get("enabled", True), 

363 "action": cat_config.get("action"), 

364 "category_file": cat_config.get("category_file"), 

365 } 

366 

367 @staticmethod 

368 def _assert_within_categories_dir(path: str, categories_dir: str) -> None: 

369 """Raise ValueError if path escapes the categories directory.""" 

370 resolved: Final = os.path.realpath(path) 

371 allowed: Final = os.path.realpath(categories_dir) 

372 try: 

373 common: Final = os.path.commonpath([resolved, allowed]) 

374 except ValueError: 

375 # commonpath() raises ValueError on Windows when paths span different drives 

376 raise ValueError(f"Category file path '{path}' is outside the allowed categories directory") 

377 if common != allowed: 

378 raise ValueError( 

379 f"Category file path '{path}' is outside the allowed categories directory '{categories_dir}'" 

380 ) 

381 

382 def _resolve_category_file_path(self, file_path: str) -> str: 

383 """ 

384 Resolve a category file path that may be relative. 

385 

386 Paths in policy templates (e.g. category_file) are often stored as 

387 relative paths like "litellm/proxy/.../policy_templates/file.yaml". 

388 These only work when the CWD is the project root. In production 

389 (Docker, installed packages, etc.) the CWD is different, so the 

390 file isn't found. 

391 

392 Resolution order: 

393 1. Return as-is if absolute or already exists (jailed to module dir). 

394 2. Try joining the full path relative to this module's directory (jailed). 

395 3. Progressively strip leading path components and try each suffix 

396 relative to this module's directory (jailed). 

397 

398 The directory jail can be disabled for deployments that legitimately 

399 store category files outside the package (e.g. mounted volumes) by 

400 setting the environment variable 

401 ``LITELLM_CONTENT_FILTER_ALLOW_EXTERNAL_PATHS=true``. Use only in 

402 trusted environments where the proxy configuration cannot be influenced 

403 by untrusted input. 

404 

405 Args: 

406 file_path: The file path to resolve (absolute or relative). 

407 

408 Returns: 

409 The resolved absolute-ish path, or the original path if 

410 resolution fails (caller should check existence). 

411 

412 Raises: 

413 ValueError: If the resolved path escapes the module directory 

414 and ``LITELLM_CONTENT_FILTER_ALLOW_EXTERNAL_PATHS`` is not set. 

415 """ 

416 module_dir: Final = os.path.dirname(__file__) 

417 allow_external: Final = os.environ.get("LITELLM_CONTENT_FILTER_ALLOW_EXTERNAL_PATHS", "").lower() == "true" 

418 

419 if os.path.isabs(file_path) or os.path.exists(file_path): 

420 if not allow_external: 

421 self._assert_within_categories_dir(file_path, module_dir) 

422 else: 

423 verbose_proxy_logger.warning( 

424 "LITELLM_CONTENT_FILTER_ALLOW_EXTERNAL_PATHS is set — " 

425 "skipping directory jail for category_file '%s'", 

426 file_path, 

427 ) 

428 return file_path 

429 

430 # Try the full relative path joined to the module directory 

431 candidate = os.path.join(module_dir, file_path) 

432 if os.path.exists(candidate): 

433 if not allow_external: 

434 self._assert_within_categories_dir(candidate, module_dir) 

435 return candidate 

436 

437 # Progressively strip leading components to find a matching suffix 

438 parts: Final = file_path.split("/") 

439 for i in range(1, len(parts)): 

440 suffix = os.path.join(*parts[i:]) 

441 candidate = os.path.join(module_dir, suffix) 

442 if os.path.exists(candidate): 

443 if not allow_external: 

444 self._assert_within_categories_dir(candidate, module_dir) 

445 return candidate 

446 

447 # File not found via any resolution strategy — jail the module-relative 

448 # path anyway to reject traversal attempts (e.g. "../../../../etc/passwd") 

449 # regardless of CWD or whether the target file exists. 

450 if not allow_external: 

451 self._assert_within_categories_dir(os.path.join(module_dir, file_path), module_dir) 

452 return file_path 

453 

454 def _load_categories(self, categories: list[ContentFilterCategoryConfig]) -> None: 

455 """ 

456 Load content categories from configuration. 

457 

458 Args: 

459 categories: List of category configurations with format: 

460 - category: "harmful_self_harm" 

461 enabled: true 

462 action: "BLOCK" 

463 severity_threshold: "medium" 

464 category_file: "/path/to/custom_file.yaml" # optional override 

465 """ 

466 categories_dir: Final = os.path.join(os.path.dirname(__file__), "categories") 

467 

468 for cat_config in categories: 

469 view = self._category_config_view(cat_config) 

470 category_name = view["category"] 

471 if not category_name or not isinstance(category_name, str): 

472 verbose_proxy_logger.warning("Category name missing or invalid in config, skipping") 

473 continue 

474 

475 # Prevent path traversal via category_name (e.g. "../../etc/passwd") 

476 if not re.match(r"^[a-zA-Z0-9_\-]+$", category_name): 

477 verbose_proxy_logger.warning("Category name '%s' contains invalid characters, skipping", category_name) 

478 continue 

479 

480 enabled = view["enabled"] 

481 action = view["action"] 

482 severity_threshold = ( 

483 cat_config.get("severity_threshold", self.severity_threshold) or self.severity_threshold 

484 ) 

485 custom_file = view["category_file"] 

486 

487 if not enabled: 

488 verbose_proxy_logger.debug("Category %s is disabled, skipping", category_name) 

489 continue 

490 

491 # Load category file (custom or default) 

492 if custom_file: 

493 try: 

494 category_file_path = self._resolve_category_file_path(custom_file) 

495 except ValueError as e: 

496 verbose_proxy_logger.warning( 

497 "Category %s: invalid category_file path, skipping. %s", category_name, e 

498 ) 

499 continue 

500 else: 

501 # Try .yaml first, then .json (e.g. harm_toxic_abuse.json) 

502 yaml_path = os.path.join(categories_dir, f"{category_name}.yaml") 

503 json_path = os.path.join(categories_dir, f"{category_name}.json") 

504 if os.path.exists(yaml_path): 

505 category_file_path = yaml_path 

506 elif os.path.exists(json_path): 

507 category_file_path = json_path 

508 else: 

509 category_file_path = yaml_path # will trigger "not found" below 

510 

511 if not os.path.exists(category_file_path): 

512 verbose_proxy_logger.warning("Category file not found: %s, skipping", category_file_path) 

513 continue 

514 

515 try: 

516 category_config_obj = self._load_category_file(category_file_path) 

517 self.loaded_categories[category_name] = category_config_obj 

518 

519 # Use action from config, or default from category file 

520 category_action = ContentFilterAction(action if action else category_config_obj.default_action) 

521 

522 # Handle conditional categories (with identifier_words + block words) 

523 if category_config_obj.identifier_words and ( 

524 category_config_obj.inherit_from or category_config_obj.additional_block_words 

525 ): 

526 self._load_conditional_category( 

527 category_name, 

528 category_config_obj, 

529 category_action, 

530 severity_threshold, 

531 categories_dir, 

532 ) 

533 

534 # Add always_block_keywords if present 

535 if category_config_obj.always_block_keywords: 

536 for keyword_data in category_config_obj.always_block_keywords: 

537 keyword = keyword_data["keyword"].lower() 

538 severity = keyword_data.get("severity", "high") 

539 if self._should_apply_severity(severity, severity_threshold): 

540 self.always_block_category_keywords[keyword] = ( 

541 category_name, 

542 severity, 

543 category_action, 

544 ) 

545 

546 # Add regular keywords from this category 

547 for keyword_data in category_config_obj.keywords: 

548 keyword = keyword_data["keyword"].lower() 

549 severity = keyword_data["severity"] 

550 

551 # Check if keyword meets severity threshold 

552 if self._should_apply_severity(severity, severity_threshold): 

553 self.category_keywords[keyword] = ( 

554 category_name, 

555 severity, 

556 category_action, 

557 ) 

558 

559 verbose_proxy_logger.info( 

560 "Loaded category %s: %s keywords, %s always-block keywords, conditional: %s", 

561 category_name, 

562 len(category_config_obj.keywords), 

563 len(category_config_obj.always_block_keywords), 

564 bool(category_config_obj.identifier_words), 

565 ) 

566 except Exception as e: 

567 verbose_proxy_logger.error("Error loading category %s: %s", category_name, e) 

568 

569 def _load_conditional_category( 

570 self, 

571 category_name: str, 

572 category_config_obj: CategoryConfig, 

573 category_action: ContentFilterAction, 

574 severity_threshold: str, 

575 categories_dir: str, 

576 ) -> None: 

577 """ 

578 Load a conditional category that uses identifier_words + block_words. 

579 Block words can come from inherited category or additional_block_words. 

580 

581 Args: 

582 category_name: Name of the category 

583 category_config_obj: CategoryConfig object with identifier_words 

584 category_action: Action to take when match is found 

585 severity_threshold: Minimum severity threshold 

586 categories_dir: Directory containing category files 

587 """ 

588 try: 

589 block_words: Final[list[str]] = [] 

590 inherit_from = category_config_obj.inherit_from 

591 

592 # Load inherited block words if specified 

593 if inherit_from: 

594 # Remove .json or .yaml extension if included 

595 inherit_base: Final = inherit_from.replace(".json", "").replace(".yaml", "") 

596 

597 # Find the inherited category file 

598 inherit_yaml_path: Final = os.path.join(categories_dir, f"{inherit_base}.yaml") 

599 inherit_json_path: Final = os.path.join(categories_dir, f"{inherit_base}.json") 

600 

601 inherit_file_path = None 

602 if os.path.exists(inherit_yaml_path): 

603 inherit_file_path = inherit_yaml_path 

604 elif os.path.exists(inherit_json_path): 

605 inherit_file_path = inherit_json_path 

606 else: 

607 verbose_proxy_logger.warning( 

608 "Category %s: inherit_from '%s' file not found at %s", 

609 category_name, 

610 inherit_from, 

611 categories_dir, 

612 ) 

613 verbose_proxy_logger.debug("Tried paths: %s, %s", inherit_yaml_path, inherit_json_path) 

614 

615 if inherit_file_path: 

616 # Load the inherited category 

617 inherited_category: Final = self._load_category_file(inherit_file_path) 

618 

619 # Extract block words from inherited category that meet severity threshold 

620 for keyword_data in inherited_category.keywords: 

621 keyword = keyword_data["keyword"].lower() 

622 severity = keyword_data["severity"] 

623 if self._should_apply_severity(severity, severity_threshold): 

624 block_words.append(keyword) 

625 else: 

626 # If inherit file not found, set inherit_from to None for logging 

627 inherit_from = None 

628 

629 # Add additional block words specific to this category 

630 if category_config_obj.additional_block_words: 

631 block_words.extend(category_config_obj.additional_block_words) 

632 

633 # Store the conditional category configuration 

634 self.conditional_categories[category_name] = { 

635 "identifier_words": category_config_obj.identifier_words, 

636 "block_words": block_words, 

637 "action": category_action, 

638 "severity": "high", # Combinations are always high severity 

639 } 

640 

641 # Build log message 

642 log_msg = ( 

643 f"Loaded conditional category {category_name}: " 

644 f"{len(category_config_obj.identifier_words)} identifiers + " 

645 f"{len(block_words)} block words" 

646 ) 

647 if inherit_from and category_config_obj.additional_block_words: 

648 inherited_count: Final = len(block_words) - len(category_config_obj.additional_block_words) 

649 log_msg += ( 

650 f" ({len(category_config_obj.additional_block_words)} additional + " 

651 f"{inherited_count} from {inherit_from})" 

652 ) 

653 elif inherit_from: 

654 log_msg += f" (from {inherit_from})" 

655 elif category_config_obj.additional_block_words: 

656 log_msg += f" ({len(block_words)} from additional_block_words)" 

657 

658 verbose_proxy_logger.info(log_msg) 

659 except Exception as e: 

660 verbose_proxy_logger.error("Error loading conditional category for %s: %s", category_name, e) 

661 

662 def _load_category_file(self, file_path: str) -> CategoryConfig: 

663 """ 

664 Load a category definition from a YAML or JSON file. 

665 

666 YAML format: category_name, description, default_action, keywords (list of 

667 {keyword, severity}), exceptions. 

668 Optional: identifier_words, always_block_keywords, inherit_from. 

669 JSON format: list of {id, match, tags, severity}; match is pipe-separated 

670 phrases; severity 1-4 mapped to low/medium/high. Used for harm_toxic_abuse. 

671 

672 Args: 

673 file_path: Path to category YAML or JSON file 

674 

675 Returns: 

676 CategoryConfig object 

677 """ 

678 if file_path.lower().endswith(".json"): 

679 return self._load_category_file_json(file_path) 

680 data: Final = self._read_category_yaml(file_path) 

681 

682 return CategoryConfig( 

683 category_name=data.get("category_name", "unknown"), 

684 description=data.get("description", ""), 

685 default_action=ContentFilterAction(data.get("default_action", "BLOCK")), 

686 keywords=data.get("keywords", []), 

687 exceptions=data.get("exceptions", []), 

688 identifier_words=data.get("identifier_words"), 

689 always_block_keywords=data.get("always_block_keywords", []), 

690 inherit_from=data.get("inherit_from"), 

691 additional_block_words=data.get("additional_block_words"), 

692 phrase_patterns=data.get("phrase_patterns"), 

693 ) 

694 

695 @staticmethod 

696 def _read_category_yaml(file_path: str) -> CategoryFileData: 

697 with open(file_path, "r") as f: 

698 return yaml.safe_load(f) 

699 

700 def _load_category_file_json(self, file_path: str) -> CategoryConfig: 

701 """ 

702 Load a category from the harm_toxic_abuse-style JSON format. 

703 

704 Each entry has: id, match (pipe-separated phrases), tags, severity (1-4). 

705 Severity mapping: 4,3 -> high; 2 -> medium; 1 -> low. 

706 """ 

707 with open(file_path, "r") as f: 

708 entries = json.load(f) 

709 if not isinstance(entries, list): 

710 entries = [entries] 

711 # Derive category name from filename (e.g. harm_toxic_abuse.json -> harm_toxic_abuse) 

712 category_name: Final = os.path.splitext(os.path.basename(file_path))[0] 

713 severity_map: Final = {4: "high", 3: "high", 2: "medium", 1: "low"} 

714 keywords: Final[list[dict[str, str]]] = [] 

715 seen: Final = set() 

716 for item in entries: 

717 if not isinstance(item, dict): 

718 continue 

719 match_str = item.get("match") or "" 

720 raw_severity = item.get("severity", 2) 

721 severity = severity_map.get(raw_severity if isinstance(raw_severity, int) else 2, "medium") 

722 for phrase in match_str.split("|"): 

723 phrase = phrase.strip().lower() 

724 if not phrase or phrase in seen: 

725 continue 

726 seen.add(phrase) 

727 keywords.append({"keyword": phrase, "severity": severity}) 

728 return CategoryConfig( 

729 category_name=category_name, 

730 description="Detects harmful, toxic, or abusive language and content", 

731 default_action=ContentFilterAction("BLOCK"), 

732 keywords=keywords, 

733 exceptions=[], 

734 ) 

735 

736 def _should_apply_severity(self, severity: str, threshold: str) -> bool: 

737 """ 

738 Check if a given severity meets the threshold. 

739 

740 Args: 

741 severity: The severity level of the item ("high", "medium", "low") 

742 threshold: The minimum severity threshold 

743 

744 Returns: 

745 True if severity meets or exceeds threshold 

746 """ 

747 severity_order: Final = {"low": 0, "medium": 1, "high": 2} 

748 return severity_order.get(severity, 0) >= severity_order.get(threshold, 1) 

749 

750 def _add_pattern(self, pattern_config: ContentFilterPattern) -> None: 

751 """ 

752 Add a pattern to the compiled patterns list. 

753 

754 Args: 

755 pattern_config: ContentFilterPattern configuration 

756 """ 

757 try: 

758 extra_config: _PatternExtraLookup = {"keyword_pattern": None, "allow_word_numbers": False} 

759 if pattern_config.pattern_type == "prebuilt": 

760 if not pattern_config.pattern_name: 

761 raise ValueError("pattern_name is required for prebuilt patterns") 

762 compiled = get_compiled_pattern(pattern_config.pattern_name) 

763 pattern_name = pattern_config.pattern_name 

764 extra_config = self._lookup_pattern_extra(pattern_name) 

765 elif pattern_config.pattern_type == "regex": 

766 if not pattern_config.pattern: 

767 raise ValueError("pattern is required for regex patterns") 

768 compiled = re.compile(pattern_config.pattern, re.IGNORECASE) 

769 pattern_name = pattern_config.name or "custom_regex" 

770 else: 

771 raise ValueError(f"Unknown pattern_type: {pattern_config.pattern_type}") 

772 

773 keyword_pattern: Final = extra_config["keyword_pattern"] 

774 keyword_regex: Final = re.compile(keyword_pattern, re.IGNORECASE) if keyword_pattern else None 

775 

776 self.compiled_patterns.append( 

777 { 

778 "regex": compiled, 

779 "pattern_name": pattern_name, 

780 "action": pattern_config.action, 

781 "keyword_regex": keyword_regex, 

782 "allow_word_numbers": extra_config["allow_word_numbers"], 

783 } 

784 ) 

785 verbose_proxy_logger.debug("Added pattern: %s with action %s", pattern_name, pattern_config.action) 

786 except Exception as e: 

787 verbose_proxy_logger.error("Error adding pattern %s: %s", pattern_config, e) 

788 raise 

789 

790 @staticmethod 

791 def _lookup_pattern_extra(pattern_name: str) -> _PatternExtraLookup: 

792 extra: Final = PATTERN_EXTRA_CONFIG.get(pattern_name) 

793 return { 

794 "keyword_pattern": extra.get("keyword_pattern") if extra is not None else None, 

795 "allow_word_numbers": bool(extra.get("allow_word_numbers")) if extra is not None else False, 

796 } 

797 

798 def _load_blocked_words_file(self, file_path: str) -> None: 

799 """ 

800 Load blocked words from a YAML file. 

801 

802 Args: 

803 file_path: Path to YAML file containing blocked_words list 

804 

805 Expected format: 

806 ```yaml 

807 blocked_words: 

808 - keyword: "sensitive_term" 

809 action: "BLOCK" 

810 description: "Optional description" 

811 ``` 

812 """ 

813 try: 

814 with open(file_path, "r") as f: 

815 data: Final = yaml.safe_load(f) 

816 

817 if not isinstance(data, dict) or "blocked_words" not in data: 

818 raise ValueError("Invalid format: file must contain 'blocked_words' key with list of words") 

819 

820 for word_data in data["blocked_words"]: 

821 if not isinstance(word_data, dict) or "keyword" not in word_data or "action" not in word_data: 

822 verbose_proxy_logger.warning("Skipping invalid word entry: %s", word_data) 

823 continue 

824 

825 keyword = word_data["keyword"].lower() 

826 action = ContentFilterAction(word_data["action"]) 

827 description = word_data.get("description") 

828 

829 self.blocked_words[keyword] = (action, description) 

830 

831 verbose_proxy_logger.info("Loaded %s blocked words from %s", len(data["blocked_words"]), file_path) 

832 except FileNotFoundError: 

833 raise FileNotFoundError(f"Blocked words file not found: {file_path}") 

834 except Exception as e: 

835 raise Exception(f"Error loading blocked words file {file_path}: {e}") 

836 

837 def _find_pattern_spans(self, text: str, pattern_entry: CompiledPatternEntry) -> list[tuple[int, int]]: 

838 """Return all match spans for a pattern, applying contextual rules if required.""" 

839 

840 regex: Final[Pattern[str]] = pattern_entry["regex"] 

841 keyword_regex: Final[Pattern[str] | None] = pattern_entry.get("keyword_regex") 

842 allow_word_numbers: Final[bool] = pattern_entry.get("allow_word_numbers", False) 

843 

844 keyword_matches: Final = list(keyword_regex.finditer(text)) if keyword_regex is not None else None 

845 if keyword_matches is not None and not keyword_matches: 

846 return [] 

847 

848 match_spans: Final[list[tuple[int, int]]] = [] 

849 

850 for match in regex.finditer(text): 

851 if keyword_matches is not None and not self._match_near_keyword( 

852 match.start(), match.end(), keyword_matches, text 

853 ): 

854 continue 

855 match_spans.append((match.start(), match.end())) 

856 

857 if allow_word_numbers: 

858 for word_match in WORD_NUMBER_SEQUENCE_PATTERN.finditer(text): 

859 digits = self._convert_word_number_sequence(word_match.group()) 

860 if not digits: 

861 continue 

862 if not regex.fullmatch(digits): 

863 continue 

864 if keyword_matches is not None and not self._match_near_keyword( 

865 word_match.start(), word_match.end(), keyword_matches, text 

866 ): 

867 continue 

868 match_spans.append((word_match.start(), word_match.end())) 

869 

870 return self._merge_spans(match_spans) 

871 

872 def _match_near_keyword( 

873 self, 

874 value_start: int, 

875 value_end: int, 

876 keyword_matches: Sequence[re.Match[str]], 

877 text: str, 

878 ) -> bool: 

879 """Check if a value is separated from a keyword by an allowed gap.""" 

880 

881 for keyword_match in keyword_matches: 

882 keyword_start = keyword_match.start() 

883 keyword_end = keyword_match.end() 

884 

885 if value_start >= keyword_end: 

886 gap_text = text[keyword_end:value_start] 

887 elif keyword_start >= value_end: 

888 gap_text = text[value_end:keyword_start] 

889 else: 

890 return True # overlapping 

891 

892 if self._gap_text_allowed(gap_text): 

893 return True 

894 return False 

895 

896 def _gap_text_allowed(self, gap_text: str) -> bool: 

897 """Return True if the gap between keyword and value meets word-count rules.""" 

898 

899 if not gap_text.strip(): 

900 return True 

901 if any(char.isdigit() for char in gap_text): 

902 return False 

903 

904 words: Final = GAP_WORD_TOKENIZER.findall(gap_text) 

905 return len(words) <= MAX_KEYWORD_VALUE_GAP_WORDS 

906 

907 def _merge_spans(self, spans: list[tuple[int, int]]) -> list[tuple[int, int]]: 

908 """Merge overlapping spans to avoid double-masking.""" 

909 

910 if not spans: 

911 return [] 

912 

913 spans.sort(key=lambda item: item[0]) 

914 merged: Final[list[tuple[int, int]]] = [spans[0]] 

915 

916 for start, end in spans[1:]: 

917 last_start, last_end = merged[-1] 

918 if start <= last_end: 

919 merged[-1] = (last_start, max(last_end, end)) 

920 else: 

921 merged.append((start, end)) 

922 return merged 

923 

924 def _mask_spans(self, text: str, spans: list[tuple[int, int]], redaction: str) -> str: 

925 """Apply masking for the provided spans using the given redaction tag.""" 

926 

927 if not spans: 

928 return text 

929 

930 result_parts: Final[list[str]] = [] 

931 previous_end = 0 

932 for start, end in spans: 

933 result_parts.append(text[previous_end:start]) 

934 result_parts.append(redaction) 

935 previous_end = end 

936 result_parts.append(text[previous_end:]) 

937 return "".join(result_parts) 

938 

939 def _convert_word_number_sequence(self, sequence: str) -> str | None: 

940 """Convert a spelled-out digit sequence (e.g., 'One-Two') into digits.""" 

941 

942 tokens: Final[list[str]] = WORD_NUMBER_TOKEN_FINDER.findall(sequence) 

943 if not tokens: 

944 return None 

945 

946 digits: Final[list[str]] = [] 

947 for token in tokens: 

948 digit = WORD_NUMBER_MAP.get(token.lower()) 

949 if digit is None: 

950 return None 

951 digits.append(digit) 

952 

953 return "".join(digits) if digits else None 

954 

955 def _check_patterns(self, text: str) -> tuple[str, str, ContentFilterAction] | None: 

956 """ 

957 Check text against all compiled regex patterns. 

958 

959 Args: 

960 text: Text to check 

961 

962 Returns: 

963 Tuple of (matched_text, pattern_name, action) if match found, None otherwise 

964 """ 

965 for pattern_entry in self.compiled_patterns: 

966 spans = self._find_pattern_spans(text, pattern_entry) 

967 if spans: 

968 start, end = spans[0] 

969 matched_text = text[start:end] 

970 pattern_name = pattern_entry["pattern_name"] 

971 action = pattern_entry["action"] 

972 verbose_proxy_logger.debug("Pattern '%s' matched: %s...", pattern_name, matched_text[:20]) 

973 return (matched_text, pattern_name, action) 

974 return None 

975 

976 def _check_conditional_categories( 

977 self, text: str, exceptions: list[str] 

978 ) -> tuple[str, str, str, ContentFilterAction] | None: 

979 """ 

980 Check text for conditional category matches (identifier + block word in same sentence). 

981 

982 This implements logic like: if text contains both an identifier word (e.g., "minor") 

983 AND a block word (e.g., "romantic"), then block it. 

984 

985 Args: 

986 text: Text to check 

987 exceptions: List of exception phrases to ignore 

988 

989 Returns: 

990 Tuple of (matched_phrase, category, severity, action) if match found, None otherwise 

991 """ 

992 text_lower: Final = text.lower() 

993 

994 # First check if any exception applies 

995 for exception in exceptions: 

996 if exception in text_lower: 

997 return None 

998 

999 # Split text into sentences for more precise matching 

1000 # Simple sentence splitting on common terminators 

1001 sentences: Final = SENTENCE_TERMINATORS.split(text) 

1002 

1003 for category_name, config in self.conditional_categories.items(): 

1004 identifier_words = config["identifier_words"] 

1005 block_words = config["block_words"] 

1006 action = config["action"] 

1007 severity = config["severity"] 

1008 

1009 # Check category-specific exceptions 

1010 category_obj = self.loaded_categories.get(category_name) 

1011 if category_obj: 

1012 exception_found = False 

1013 for exception in category_obj.exceptions: 

1014 if exception in text_lower: 

1015 verbose_proxy_logger.debug( 

1016 "Category exception '%s' found for %s, skipping", exception, category_name 

1017 ) 

1018 exception_found = True 

1019 break 

1020 if exception_found: 

1021 continue 

1022 

1023 # Check each sentence for identifier + block word combination 

1024 for sentence in sentences: 

1025 sentence_lower = sentence.lower().strip() 

1026 if not sentence_lower: 

1027 continue 

1028 

1029 # Check if sentence contains ANY identifier word 

1030 identifier_found = None 

1031 for identifier in identifier_words: 

1032 if identifier in sentence_lower: 

1033 identifier_found = identifier 

1034 break 

1035 

1036 if not identifier_found: 

1037 continue 

1038 

1039 # Check if sentence also contains ANY block word 

1040 block_word_found = None 

1041 for block_word in block_words: 

1042 # Use word boundary for single words to avoid false positives 

1043 if " " in block_word: 

1044 # Multi-word phrase 

1045 if block_word in sentence_lower: 

1046 block_word_found = block_word 

1047 break 

1048 else: 

1049 # Single word - use word boundary 

1050 pattern = r"\b" + re.escape(block_word) + r"\b" 

1051 if re.search(pattern, sentence_lower): 

1052 block_word_found = block_word 

1053 break 

1054 

1055 if block_word_found: 

1056 matched_phrase = f"{identifier_found} + {block_word_found}" 

1057 verbose_proxy_logger.warning( 

1058 "Conditional match in %s: '%s' in sentence", category_name, matched_phrase 

1059 ) 

1060 return (matched_phrase, category_name, severity, action) 

1061 

1062 return None 

1063 

1064 def _check_phrase_patterns( 

1065 self, text: str, exceptions: list[str] 

1066 ) -> tuple[str, str, str, ContentFilterAction] | None: 

1067 """ 

1068 Check text against phrase patterns from loaded categories. 

1069 

1070 Phrase patterns are regex patterns that catch paraphrased requests 

1071 (e.g., "put my money to make it grow" for financial advice). 

1072 

1073 Args: 

1074 text: Text to check 

1075 exceptions: List of exception phrases to ignore 

1076 

1077 Returns: 

1078 Tuple of (matched_pattern, category, severity, action) if match found, None otherwise 

1079 """ 

1080 text_lower: Final = text.lower() 

1081 

1082 for exception in exceptions: 

1083 if exception in text_lower: 

1084 return None 

1085 

1086 for category_name, config in self.loaded_categories.items(): 

1087 if not config.phrase_patterns: 

1088 continue 

1089 

1090 # Check category-specific exceptions 

1091 for exception in config.exceptions: 

1092 if exception in text_lower: 

1093 break 

1094 else: 

1095 # Determine action for this category 

1096 action = ContentFilterAction(config.default_action) 

1097 # Check if we have a configured action in conditional_categories 

1098 if category_name in self.conditional_categories: 

1099 action = self.conditional_categories[category_name]["action"] 

1100 

1101 for pattern_str, pattern in config.phrase_patterns: 

1102 if pattern.search(text): 

1103 verbose_proxy_logger.warning("Phrase pattern match in %s: '%s'", category_name, pattern_str) 

1104 return ( 

1105 f"phrase: {pattern_str}", 

1106 category_name, 

1107 "high", 

1108 action, 

1109 ) 

1110 

1111 return None 

1112 

1113 def _check_category_keywords( 

1114 self, text: str, exceptions: list[str] 

1115 ) -> tuple[str, str, str, ContentFilterAction] | None: 

1116 """ 

1117 Check text for category keywords. 

1118 

1119 Args: 

1120 text: Text to check 

1121 exceptions: List of exception phrases to ignore 

1122 

1123 Returns: 

1124 Tuple of (keyword, category, severity, action) if match found, None otherwise 

1125 """ 

1126 text_lower: Final = text.lower() 

1127 

1128 # Check exceptions first — they take precedence over always-block keywords too. 

1129 for exception in exceptions: 

1130 if exception in text_lower: 

1131 verbose_proxy_logger.debug("Exception phrase '%s' found, skipping category keyword check", exception) 

1132 return None 

1133 

1134 # Always-block keywords are checked after exceptions. 

1135 for keyword, ( 

1136 category, 

1137 severity, 

1138 action, 

1139 ) in self.always_block_category_keywords.items(): 

1140 keyword_pattern_str = self._keyword_to_regex_pattern(keyword) 

1141 if " " in keyword: 

1142 keyword_found = bool(re.search(keyword_pattern_str, text_lower)) 

1143 else: 

1144 keyword_pattern = r"\b" + keyword_pattern_str + r"\b" 

1145 keyword_found = bool(re.search(keyword_pattern, text_lower)) 

1146 if keyword_found: 

1147 verbose_proxy_logger.debug("Always-block keyword '%s' found in category '%s'", keyword, category) 

1148 return (keyword, category, severity, action) 

1149 

1150 # Check category keywords 

1151 for keyword, (category, severity, action) in self.category_keywords.items(): 

1152 # Convert asterisks (*) in keywords to regex wildcards 

1153 # Asterisks are used in the source data to obfuscate profanity (e.g., "fu*c*k" -> "fuck") 

1154 # We treat * as a wildcard matching zero or one character 

1155 keyword_pattern_str = self._keyword_to_regex_pattern(keyword) 

1156 

1157 # Use word boundary matching for single words to avoid false positives 

1158 # (e.g., "men" should not match "recommend") 

1159 # For multi-word phrases, use substring matching 

1160 if " " in keyword: 

1161 # Multi-word phrase - use substring matching with wildcards 

1162 keyword_pattern = keyword_pattern_str 

1163 keyword_found = bool(re.search(keyword_pattern, text_lower)) 

1164 else: 

1165 # Single word - use word boundary matching to match whole words only 

1166 keyword_pattern = r"\b" + keyword_pattern_str + r"\b" 

1167 keyword_found = bool(re.search(keyword_pattern, text_lower)) 

1168 

1169 if keyword_found: 

1170 # Check if this keyword has exceptions 

1171 category_obj = self.loaded_categories.get(category) 

1172 if category_obj: 

1173 # Check category-specific exceptions 

1174 exception_found = False 

1175 for exception in category_obj.exceptions: 

1176 if exception in text_lower: 

1177 verbose_proxy_logger.debug( 

1178 "Category exception '%s' found for keyword '%s', skipping", exception, keyword 

1179 ) 

1180 exception_found = True 

1181 break 

1182 if exception_found: 

1183 continue 

1184 

1185 verbose_proxy_logger.debug( 

1186 "Category keyword '%s' found in category '%s' with severity %s", keyword, category, severity 

1187 ) 

1188 return (keyword, category, severity, action) 

1189 return None 

1190 

1191 def _check_blocked_words(self, text: str) -> tuple[str, ContentFilterAction, str | None] | None: 

1192 """ 

1193 Check text for blocked keywords. 

1194 

1195 Args: 

1196 text: Text to check 

1197 

1198 Returns: 

1199 Tuple of (keyword, action, description) if match found, None otherwise 

1200 """ 

1201 # Handle case where blocked_words might still be a list (old instances) 

1202 if isinstance(self.blocked_words, list): 

1203 verbose_proxy_logger.warning( 

1204 "blocked_words is a list instead of dict. Re-initializing. " 

1205 "This suggests an old guardrail instance is still in use. Please restart the server." 

1206 ) 

1207 # Convert list to dict on-the-fly 

1208 temp_dict: Final[dict[str, tuple[ContentFilterAction, str | None]]] = {} 

1209 for word in self.blocked_words: 

1210 if isinstance(word, dict): 

1211 temp_dict[word.get("keyword", "").lower()] = ( 

1212 word.get("action", ContentFilterAction.BLOCK), 

1213 word.get("description"), 

1214 ) 

1215 self.blocked_words = temp_dict 

1216 

1217 if not self.blocked_words: 

1218 return None 

1219 

1220 text_lower: Final = text.lower() 

1221 for keyword, (action, description) in self.blocked_words.items(): 

1222 if keyword in text_lower: 

1223 verbose_proxy_logger.debug("Blocked word '%s' found with action %s", keyword, action) 

1224 return (keyword, action, description) 

1225 return None 

1226 

1227 def _handle_conditional_match( 

1228 self, 

1229 matched_phrase: str, 

1230 category_name: str, 

1231 severity: str, 

1232 action: ContentFilterAction, 

1233 detections: list[ContentFilterDetection] | None, 

1234 ) -> None: 

1235 """Handle conditional category match detection and action.""" 

1236 if detections is not None: 

1237 category_detection: Final[CategoryKeywordDetection] = { 

1238 "type": "category_keyword", 

1239 "category": category_name, 

1240 "keyword": matched_phrase, 

1241 "severity": severity, 

1242 "action": action.value, 

1243 } 

1244 detections.append(category_detection) 

1245 

1246 if action == ContentFilterAction.BLOCK: 

1247 error_msg: Final = ( 

1248 f"Content blocked: {category_name} conditional match '{matched_phrase}' detected (severity: {severity})" 

1249 ) 

1250 verbose_proxy_logger.warning(error_msg) 

1251 raise HTTPException( 

1252 status_code=400, 

1253 detail={ 

1254 "error": error_msg, 

1255 "category": category_name, 

1256 "matched_phrase": matched_phrase, 

1257 "severity": severity, 

1258 }, 

1259 ) 

1260 elif action == ContentFilterAction.MASK: 

1261 verbose_proxy_logger.warning( 

1262 "Conditional match '%s' from %s detected but MASK action not supported for conditional categories", 

1263 matched_phrase, 

1264 category_name, 

1265 ) 

1266 

1267 def _handle_category_keyword_match( 

1268 self, 

1269 keyword: str, 

1270 category_name: str, 

1271 severity: str, 

1272 action: ContentFilterAction, 

1273 text: str, 

1274 detections: list[ContentFilterDetection] | None, 

1275 ) -> str: 

1276 """Handle category keyword match detection and action.""" 

1277 if detections is not None: 

1278 category_detection: Final[CategoryKeywordDetection] = { 

1279 "type": "category_keyword", 

1280 "category": category_name, 

1281 "keyword": keyword, 

1282 "severity": severity, 

1283 "action": action.value, 

1284 } 

1285 detections.append(category_detection) 

1286 

1287 if action == ContentFilterAction.BLOCK: 

1288 error_msg = f"Content blocked: {category_name} category keyword '{keyword}' detected (severity: {severity})" 

1289 verbose_proxy_logger.warning(error_msg) 

1290 raise HTTPException( 

1291 status_code=400, 

1292 detail={ 

1293 "error": error_msg, 

1294 "category": category_name, 

1295 "keyword": keyword, 

1296 "severity": severity, 

1297 }, 

1298 ) 

1299 elif action == ContentFilterAction.MASK: 

1300 keyword_pattern_for_masking: Final = self._keyword_to_regex_pattern(keyword) 

1301 text = re.sub( 

1302 keyword_pattern_for_masking, 

1303 self.keyword_redaction_tag, 

1304 text, 

1305 flags=re.IGNORECASE, 

1306 ) 

1307 verbose_proxy_logger.info( 

1308 "Masked category keyword '%s' from %s (severity: %s)", keyword, category_name, severity 

1309 ) 

1310 

1311 return text 

1312 

1313 def _handle_pattern_match( 

1314 self, 

1315 pattern_name: str, 

1316 action: ContentFilterAction, 

1317 text: str, 

1318 spans: list[tuple[int, int]], 

1319 detections: list[ContentFilterDetection] | None, 

1320 ) -> str: 

1321 """Handle regex pattern match detection and action.""" 

1322 if detections is not None: 

1323 pattern_detection: Final[PatternDetection] = { 

1324 "type": "pattern", 

1325 "pattern_name": pattern_name, 

1326 "action": action.value, 

1327 } 

1328 detections.append(pattern_detection) 

1329 

1330 if action == ContentFilterAction.BLOCK: 

1331 error_msg: Final = f"Content blocked: {pattern_name} pattern detected" 

1332 verbose_proxy_logger.warning(error_msg) 

1333 raise HTTPException( 

1334 status_code=400, 

1335 detail={"error": error_msg, "pattern": pattern_name}, 

1336 ) 

1337 elif action == ContentFilterAction.MASK: 

1338 redaction_tag: Final = self.pattern_redaction_format.format(pattern_name=pattern_name.upper()) 

1339 text = self._mask_spans(text, spans, redaction_tag) 

1340 verbose_proxy_logger.info("Masked all %s matches in content", pattern_name) 

1341 

1342 return text 

1343 

1344 def _handle_blocked_word_match( 

1345 self, 

1346 keyword: str, 

1347 action: ContentFilterAction, 

1348 description: str | None, 

1349 text: str, 

1350 detections: list[ContentFilterDetection] | None, 

1351 ) -> str: 

1352 """Handle blocked word match detection and action.""" 

1353 verbose_proxy_logger.debug("Blocked word '%s' found with action %s", keyword, action) 

1354 

1355 if detections is not None: 

1356 blocked_word_detection: Final[BlockedWordDetection] = { 

1357 "type": "blocked_word", 

1358 "keyword": keyword, 

1359 "action": action.value, 

1360 "description": description, 

1361 } 

1362 detections.append(blocked_word_detection) 

1363 

1364 if action == ContentFilterAction.BLOCK: 

1365 error_msg = f"Content blocked: keyword '{keyword}' detected" 

1366 if description: 

1367 error_msg += f" ({description})" 

1368 verbose_proxy_logger.warning(error_msg) 

1369 raise HTTPException( 

1370 status_code=400, 

1371 detail={ 

1372 "error": error_msg, 

1373 "keyword": keyword, 

1374 "description": description, 

1375 }, 

1376 ) 

1377 elif action == ContentFilterAction.MASK: 

1378 keyword_pattern_for_masking: Final = self._keyword_to_regex_pattern(keyword) 

1379 text = re.sub( 

1380 keyword_pattern_for_masking, 

1381 self.keyword_redaction_tag, 

1382 text, 

1383 flags=re.IGNORECASE, 

1384 ) 

1385 verbose_proxy_logger.info("Masked keyword '%s' in content", keyword) 

1386 

1387 return text 

1388 

1389 def _filter_single_text(self, text: str, detections: list[ContentFilterDetection] | None = None) -> str: 

1390 """ 

1391 Apply all content filtering checks to a single text. 

1392 

1393 This method performs: 

1394 1. Category keyword checks 

1395 2. Regex pattern checks 

1396 3. Blocked word checks 

1397 

1398 Args: 

1399 text: Text to filter 

1400 detections: Optional list to append detection information 

1401 

1402 Returns: 

1403 Filtered text (with masking applied if action is MASK) 

1404 

1405 Raises: 

1406 HTTPException: If sensitive content is detected and action is BLOCK 

1407 """ 

1408 # Collect all exceptions from loaded categories 

1409 all_exceptions: Final[list[str]] = [] 

1410 for category in self.loaded_categories.values(): 

1411 all_exceptions.extend(category.exceptions) 

1412 

1413 # Check conditional categories first 

1414 conditional_match: Final = self._check_conditional_categories(text, all_exceptions) 

1415 if conditional_match: 

1416 matched_phrase, category_name, severity, action = conditional_match 

1417 self._handle_conditional_match(matched_phrase, category_name, severity, action, detections) 

1418 

1419 # Check phrase patterns (regex-based paraphrase detection) 

1420 phrase_match: Final = self._check_phrase_patterns(text, all_exceptions) 

1421 if phrase_match: 

1422 matched_phrase, category_name, severity, action = phrase_match 

1423 self._handle_conditional_match(matched_phrase, category_name, severity, action, detections) 

1424 

1425 # Check category keywords 

1426 category_keyword_match: Final = self._check_category_keywords(text, all_exceptions) 

1427 if category_keyword_match: 

1428 keyword, category_name, severity, action = category_keyword_match 

1429 text = self._handle_category_keyword_match(keyword, category_name, severity, action, text, detections) 

1430 

1431 # Check regex patterns - process ALL patterns, not just first match 

1432 for pattern_entry in self.compiled_patterns: 

1433 spans = self._find_pattern_spans(text, pattern_entry) 

1434 if spans: 

1435 pattern_name = pattern_entry["pattern_name"] 

1436 action = pattern_entry["action"] 

1437 text = self._handle_pattern_match(pattern_name, action, text, spans, detections) 

1438 

1439 # Check blocked words - iterate through ALL blocked words 

1440 text_lower = text.lower() 

1441 for keyword, (action, description) in self.blocked_words.items(): 

1442 keyword_pattern_str = self._keyword_to_regex_pattern(keyword) 

1443 if re.search(keyword_pattern_str, text_lower): 

1444 text = self._handle_blocked_word_match(keyword, action, description, text, detections) 

1445 text_lower = text.lower() # Update after masking 

1446 

1447 return text 

1448 

1449 @staticmethod 

1450 def _keyword_to_regex_pattern(keyword: str) -> str: 

1451 """ 

1452 Convert a keyword into a safe regex pattern. 

1453 

1454 Escape all regex metacharacters to prevent malformed patterns from 

1455 user/template keywords. Preserve existing '*' wildcard semantics by 

1456 translating escaped '*' into '.?'. 

1457 """ 

1458 return re.escape(keyword).replace(r"\*", ".?") 

1459 

1460 def _mask_content(self, text: str, pattern_name: str) -> str: 

1461 """ 

1462 Mask sensitive content in text. 

1463 

1464 Args: 

1465 text: Text containing sensitive content 

1466 pattern_name: Name of the pattern that matched 

1467 

1468 Returns: 

1469 Text with sensitive content masked 

1470 """ 

1471 redaction_tag: Final = self.pattern_redaction_format.format(pattern_name=pattern_name.upper()) 

1472 return redaction_tag 

1473 

1474 async def _process_images(self, images: list[str], detections: list[ContentFilterDetection]) -> None: 

1475 """ 

1476 Process images by describing them and applying content filtering. 

1477 

1478 Args: 

1479 images: List of image URLs 

1480 detections: List to append detection information 

1481 """ 

1482 if not (images and self.image_model and self.llm_router): 

1483 return 

1484 

1485 tasks: Final[list[Coroutine[object, object, ModelResponse]]] = [] 

1486 for image in images: 

1487 task = self.llm_router.acompletion( 

1488 model=self.image_model, 

1489 messages=[ 

1490 { 

1491 "role": "system", 

1492 "content": "Describe the image in detail.", 

1493 }, 

1494 { 

1495 "role": "user", 

1496 "content": [ 

1497 {"type": "image_url", "image_url": {"url": image}}, 

1498 ], 

1499 }, 

1500 ], 

1501 stream=False, 

1502 ) 

1503 tasks.append(task) 

1504 

1505 responses: Final = await asyncio.gather(*tasks) 

1506 descriptions: Final[list[str]] = [] 

1507 for response in responses: 

1508 image_description = self._describe_image_response_content(response) 

1509 if image_description: 

1510 verbose_proxy_logger.debug("Image description: %s", image_description) 

1511 descriptions.append(image_description) 

1512 else: 

1513 verbose_proxy_logger.warning("No image description found") 

1514 

1515 # Apply content filtering to image descriptions 

1516 verbose_proxy_logger.debug( 

1517 "ContentFilterGuardrail: Applying guardrail to %s image description(s)", len(descriptions) 

1518 ) 

1519 for description in descriptions: 

1520 # This will raise HTTPException if BLOCK action is triggered 

1521 try: 

1522 self._filter_single_text(description, detections=detections) 

1523 except HTTPException as e: 

1524 # e.detail can be a string or dict 

1525 if isinstance(e.detail, dict) and "error" in e.detail: 

1526 detail_dict = cast(dict[str, str], e.detail) 

1527 detail_dict["error"] = detail_dict["error"] + " (Image description): " + description 

1528 elif isinstance(e.detail, str): 

1529 e.detail = e.detail + " (Image description): " + description 

1530 else: 

1531 e.detail = "Content blocked: Image description detected" + description 

1532 raise e 

1533 

1534 @staticmethod 

1535 def _describe_image_response_content(response: ModelResponse) -> str | None: 

1536 choice = response.choices[0] 

1537 message = getattr(choice, "message", None) 

1538 if message and getattr(message, "content", None): 

1539 return message.content 

1540 return None 

1541 

1542 def _count_masked_entities( 

1543 self, 

1544 detections: list[ContentFilterDetection], 

1545 masked_entity_count: dict[str, int], 

1546 ) -> None: 

1547 """ 

1548 Count masked entities by type from detections. 

1549 

1550 Args: 

1551 detections: List of detection dictionaries 

1552 masked_entity_count: Dictionary to update with counts 

1553 """ 

1554 for detection in detections: 

1555 if detection.get("type") == "competitor_intent": 

1556 continue 

1557 if detection.get("action") == ContentFilterAction.MASK.value: 

1558 detection_type = detection["type"] 

1559 if detection_type == "pattern": 

1560 pattern_detection = cast(PatternDetection, detection) 

1561 pattern_name = pattern_detection["pattern_name"] 

1562 masked_entity_count[pattern_name] = masked_entity_count.get(pattern_name, 0) + 1 

1563 elif detection_type == "blocked_word": 

1564 entity_type = "blocked_word" 

1565 masked_entity_count[entity_type] = masked_entity_count.get(entity_type, 0) + 1 

1566 elif detection_type == "category_keyword": 

1567 category_detection = cast(CategoryKeywordDetection, detection) 

1568 category = category_detection["category"] 

1569 masked_entity_count[category] = masked_entity_count.get(category, 0) + 1 

1570 

1571 def _build_match_details(self, detections: list[ContentFilterDetection]) -> list[dict[str, object]]: 

1572 """Build match_details list from content filter detections.""" 

1573 match_details: Final[list[dict[str, object]]] = [] 

1574 for detection in detections: 

1575 action_taken = detection.get("action", detection.get("action_hint", "")) 

1576 detail: dict[str, object] = {"type": detection["type"], "action_taken": action_taken} 

1577 if detection["type"] == "pattern": 

1578 detail["detection_method"] = "regex" 

1579 detail["snippet"] = cast(PatternDetection, detection).get("pattern_name", "") 

1580 elif detection["type"] == "blocked_word": 

1581 detail["detection_method"] = "keyword" 

1582 detail["snippet"] = cast(BlockedWordDetection, detection).get("keyword", "") 

1583 elif detection["type"] == "category_keyword": 

1584 detail["detection_method"] = "keyword" 

1585 cat_det = cast(CategoryKeywordDetection, detection) 

1586 detail["snippet"] = cat_det.get("keyword", "") 

1587 detail["category"] = cat_det.get("category", "") 

1588 elif detection["type"] == "competitor_intent": 

1589 detail["detection_method"] = "intent" 

1590 detail["snippet"] = detection.get("intent", "") 

1591 detail["confidence"] = detection.get("confidence") 

1592 match_details.append(detail) 

1593 return match_details 

1594 

1595 def _get_detection_methods(self, detections: list[ContentFilterDetection]) -> str: 

1596 """Get comma-separated detection methods used.""" 

1597 methods: Final[set[str]] = set() 

1598 for detection in detections: 

1599 if detection["type"] == "pattern": 

1600 methods.add("regex") 

1601 elif detection["type"] == "competitor_intent": 

1602 methods.add("intent") 

1603 else: 

1604 methods.add("keyword") 

1605 return ",".join(sorted(methods)) if methods else "" 

1606 

1607 def _get_patterns_checked_count(self) -> int: 

1608 """Get total number of patterns and keywords that were evaluated.""" 

1609 return ( 

1610 len(self.compiled_patterns) 

1611 + len(self.blocked_words) 

1612 + len(self.category_keywords) 

1613 + len(self.always_block_category_keywords) 

1614 ) 

1615 

1616 def _get_policy_templates(self) -> str | None: 

1617 """Get comma-separated policy template names from loaded categories.""" 

1618 if not self.loaded_categories: 

1619 return None 

1620 names: Final = [cat.description or cat.category_name for cat in self.loaded_categories.values()] 

1621 return ", ".join(names) if names else None 

1622 

1623 def _compute_risk_score( 

1624 self, 

1625 detections: list[ContentFilterDetection], 

1626 masked_entity_count: dict[str, int], 

1627 status: "GuardrailStatus", 

1628 ) -> float: 

1629 """ 

1630 Compute a risk score from 0-10 for this guardrail evaluation. 

1631 

1632 Factors: 

1633 - Match ratio: how many patterns matched vs total checked 

1634 - Number of entities masked 

1635 - Whether the guardrail blocked the request (max risk) 

1636 """ 

1637 if status == "guardrail_intervened": 

1638 return 10.0 

1639 

1640 total_masked: Final = sum(masked_entity_count.values()) if masked_entity_count else 0 

1641 patterns_checked: Final = self._get_patterns_checked_count() 

1642 

1643 # Match ratio contribution (0-7 points) 

1644 match_ratio: Final = total_masked / patterns_checked if patterns_checked > 0 else 0.0 

1645 ratio_score: Final = match_ratio * 7.0 

1646 

1647 # Detection count contribution (0-3 points, capped) 

1648 detection_score: Final = min(len(detections), 5) * 0.6 

1649 

1650 score = ratio_score + detection_score 

1651 

1652 # Floor: if anything matched, minimum risk is 2 

1653 if total_masked > 0 and score < 2.0: 

1654 score = 2.0 

1655 

1656 return round(min(10.0, score), 1) 

1657 

1658 def _apply_competitor_intent_policy( 

1659 self, 

1660 intent_result: CompetitorIntentResult, 

1661 request_data: dict, 

1662 detections: list[ContentFilterDetection], 

1663 ) -> None: 

1664 """ 

1665 Apply policy for competitor intent result: refuse (raise), reframe (passthrough), or log_only/allow (return). 

1666 Appends competitor_intent detection to detections. Never returns for refuse/reframe. 

1667 """ 

1668 intent_val: Final = intent_result.get("intent", "other") 

1669 confidence_val: Final = intent_result.get("confidence", 0.0) 

1670 action_hint_val: Final = intent_result.get("action_hint", "allow") 

1671 evidence_list: Final = intent_result.get("evidence", []) 

1672 detection: Final[CompetitorIntentDetection] = { 

1673 "type": "competitor_intent", 

1674 "intent": intent_val, 

1675 "confidence": confidence_val, 

1676 "action_hint": action_hint_val, 

1677 "entities": intent_result.get("entities", {}), 

1678 "signals": intent_result.get("signals", []), 

1679 "evidence": [dict(e) for e in evidence_list], 

1680 } 

1681 detections.append(detection) 

1682 

1683 if action_hint_val == "refuse": 

1684 msg = "Content blocked: competitor comparison or ranking intent detected." 

1685 if self._competitor_intent_checker and getattr( 

1686 self._competitor_intent_checker, "refuse_message_template", None 

1687 ): 

1688 msg = self._competitor_intent_checker.refuse_message_template or msg 

1689 verbose_proxy_logger.warning("ContentFilterGuardrail: competitor intent refuse - %s", intent_val) 

1690 raise HTTPException( 

1691 status_code=400, 

1692 detail={ 

1693 "error": msg, 

1694 "intent": intent_val, 

1695 "confidence": confidence_val, 

1696 }, 

1697 ) 

1698 if action_hint_val == "reframe": 

1699 msg = ( 

1700 "I can help with questions about our products and services. " 

1701 "Would you like to compare specific features or get more information?" 

1702 ) 

1703 if self._competitor_intent_checker and getattr( 

1704 self._competitor_intent_checker, "reframe_message_template", None 

1705 ): 

1706 msg = self._competitor_intent_checker.reframe_message_template or msg 

1707 verbose_proxy_logger.info("ContentFilterGuardrail: competitor intent reframe - %s", intent_val) 

1708 self.raise_passthrough_exception( 

1709 violation_message=msg, 

1710 request_data=request_data, 

1711 detection_info=dict(intent_result), 

1712 ) 

1713 # log_only or allow: just log (detection already appended) 

1714 verbose_proxy_logger.debug( 

1715 "ContentFilterGuardrail: competitor intent %s (action_hint=%s)", 

1716 intent_val, 

1717 action_hint_val, 

1718 ) 

1719 

1720 def _log_guardrail_information( 

1721 self, 

1722 request_data: dict, 

1723 detections: list[ContentFilterDetection], 

1724 status: "GuardrailStatus", 

1725 start_time: datetime, 

1726 masked_entity_count: dict[str, int], 

1727 exception_str: str, 

1728 duration: float | None = None, 

1729 ) -> None: 

1730 """ 

1731 Log guardrail information to request_data metadata. 

1732 

1733 Args: 

1734 request_data: Request data dictionary 

1735 detections: List of detection dictionaries 

1736 status: Guardrail status 

1737 start_time: Start time of guardrail execution 

1738 masked_entity_count: Count of masked entities by type 

1739 exception_str: Exception string if guardrail failed 

1740 duration: Seconds spent inside the guardrail; defaults to the wall clock since start_time 

1741 """ 

1742 # Convert TypedDict detections to regular dicts for JSON serialization 

1743 guardrail_json_response: Exception | str | dict | list[dict] = [dict(detection) for detection in detections] 

1744 if status != "success": 

1745 guardrail_json_response = exception_str if exception_str else [dict(detection) for detection in detections] 

1746 

1747 # Competitor intent: add confidence and classification to tracing if present 

1748 tracing_kw: Final[GuardrailTracingDetail] = { 

1749 "guardrail_id": self.config_guardrail_id or self.guardrail_name, 

1750 "policy_template": self.config_policy_template or self._get_policy_templates(), 

1751 "detection_method": (self._get_detection_methods(detections) if detections else None), 

1752 "match_details": (self._build_match_details(detections) if detections else None), 

1753 "patterns_checked": self._get_patterns_checked_count(), 

1754 "risk_score": self._compute_risk_score(detections, masked_entity_count, status), 

1755 } 

1756 for d in detections: 

1757 if isinstance(d, dict) and d.get("type") == "competitor_intent": 

1758 tracing_kw["confidence_score"] = d.get("confidence") 

1759 tracing_kw["classification"] = dict(d) 

1760 break 

1761 

1762 self.add_standard_logging_guardrail_information_to_request_data( 

1763 guardrail_provider=self.guardrail_provider, 

1764 guardrail_json_response=guardrail_json_response, 

1765 request_data=request_data, 

1766 guardrail_status=status, 

1767 start_time=start_time.timestamp(), 

1768 end_time=datetime.now().timestamp(), 

1769 duration=(datetime.now() - start_time).total_seconds() if duration is None else duration, 

1770 masked_entity_count=masked_entity_count, 

1771 tracing_detail=GuardrailTracingDetail(**tracing_kw), 

1772 ) 

1773 

1774 @staticmethod 

1775 def _get_mcp_tool_name(request_data: dict) -> str | None: 

1776 raw_name: Final[object] = request_data.get("mcp_tool_name") 

1777 if isinstance(raw_name, str) and raw_name: 

1778 return raw_name 

1779 return None 

1780 

1781 def _assert_argument_label_clean( 

1782 self, text: str, detections: list[ContentFilterDetection], context_label: str 

1783 ) -> None: 

1784 if self._filter_single_text(text, detections=detections) != text: 

1785 raise HTTPException( 

1786 status_code=400, 

1787 detail={ 

1788 "error": ( 

1789 f"Content blocked: {context_label} argument matched a masking rule on a non-rewritable field" 

1790 ) 

1791 }, 

1792 ) 

1793 

1794 def _filter_argument_value( 

1795 self, 

1796 value: object, 

1797 detections: list[ContentFilterDetection], 

1798 context_label: str, 

1799 depth: int = 0, 

1800 ) -> object: 

1801 if depth > DEFAULT_MAX_RECURSE_DEPTH: 

1802 raise HTTPException( 

1803 status_code=400, 

1804 detail={"error": f"Content blocked: {context_label} arguments exceed the maximum nesting depth"}, 

1805 ) 

1806 if isinstance(value, str): 

1807 return self._filter_single_text(value, detections=detections) 

1808 if isinstance(value, (int, float)) and not isinstance(value, bool): 

1809 self._assert_argument_label_clean(str(value), detections, context_label) 

1810 return value 

1811 if isinstance(value, dict): 

1812 for key in value: 

1813 if isinstance(key, str): 

1814 self._assert_argument_label_clean(key, detections, context_label) 

1815 return { 

1816 key: self._filter_argument_value(item, detections, context_label, depth + 1) 

1817 for key, item in value.items() 

1818 } 

1819 if isinstance(value, list): 

1820 return [self._filter_argument_value(item, detections, context_label, depth + 1) for item in value] 

1821 return value 

1822 

1823 def _scan_mcp_tool_call_arguments( 

1824 self, 

1825 request_data: dict, 

1826 detections: list[ContentFilterDetection], 

1827 logging_obj: Optional["LiteLLMLoggingObj"] = None, 

1828 ) -> None: 

1829 if not self._event_hook_is_event_type(GuardrailEventHooks.pre_mcp_call): 

1830 return 

1831 call_type: Final[object] = getattr(logging_obj, "call_type", None) 

1832 if logging_obj is not None and call_type != CallTypes.call_mcp_tool.value: 

1833 return 

1834 if self._get_mcp_tool_name(request_data) is None: 

1835 return 

1836 raw_arguments: Final[object] = request_data.get("mcp_arguments") 

1837 if not isinstance(raw_arguments, dict) or not raw_arguments: 

1838 return 

1839 filtered_arguments: Final = self._filter_argument_value(raw_arguments, detections, "MCP tool call") 

1840 if filtered_arguments == raw_arguments: 

1841 return 

1842 request_data["mcp_arguments"] = filtered_arguments 

1843 request_data["modified_arguments"] = filtered_arguments 

1844 

1845 @staticmethod 

1846 def _get_tool_call_arguments(tool_call: object) -> str | None: 

1847 function: Final[object] = ( 

1848 tool_call.get("function") if isinstance(tool_call, dict) else getattr(tool_call, "function", None) 

1849 ) 

1850 arguments: Final[object] = ( 

1851 function.get("arguments") if isinstance(function, dict) else getattr(function, "arguments", None) 

1852 ) 

1853 return arguments if isinstance(arguments, str) and arguments.strip() else None 

1854 

1855 @staticmethod 

1856 def _set_tool_call_arguments(tool_call: object, arguments: str) -> None: 

1857 function: Final[object] = ( 

1858 tool_call.get("function") if isinstance(tool_call, dict) else getattr(tool_call, "function", None) 

1859 ) 

1860 if isinstance(function, dict): 

1861 function["arguments"] = arguments 

1862 elif isinstance(function, Function): 

1863 function.arguments = arguments 

1864 

1865 def _filter_tool_call_arguments( 

1866 self, 

1867 arguments: str, 

1868 detections: list[ContentFilterDetection], # mutable-ok: _filter_single_text appends into a caller-owned list 

1869 ) -> str: 

1870 try: 

1871 parsed: Final[object] = json.loads(arguments) 

1872 except (json.JSONDecodeError, TypeError, ValueError): 

1873 return self._filter_single_text(arguments, detections=detections) 

1874 if not isinstance(parsed, (dict, list)): 

1875 return self._filter_single_text(arguments, detections=detections) 

1876 filtered: Final = self._filter_argument_value(parsed, detections, "tool call") 

1877 return arguments if filtered == parsed else json.dumps(filtered) 

1878 

1879 def _scan_tool_call_arguments( 

1880 self, 

1881 inputs: "GenericGuardrailAPIInputs", 

1882 detections: list[ContentFilterDetection], # mutable-ok: _filter_single_text appends into a caller-owned list 

1883 ) -> None: 

1884 for tool_call in inputs.get("tool_calls") or (): 

1885 arguments = self._get_tool_call_arguments(tool_call) 

1886 if arguments is None: 

1887 continue 

1888 filtered_arguments = self._filter_tool_call_arguments(arguments, detections) 

1889 if filtered_arguments != arguments: 

1890 self._set_tool_call_arguments(tool_call, filtered_arguments) 

1891 

1892 async def apply_guardrail( 

1893 self, 

1894 inputs: "GenericGuardrailAPIInputs", 

1895 request_data: dict, 

1896 input_type: Literal["request", "response"], 

1897 logging_obj: Optional["LiteLLMLoggingObj"] = None, 

1898 ) -> "GenericGuardrailAPIInputs": 

1899 """ 

1900 Apply content filtering guardrail to a batch of texts. 

1901 

1902 This method checks for sensitive patterns and blocked keywords, 

1903 either blocking the request or masking the sensitive content. 

1904 

1905 Args: 

1906 inputs: Dictionary containing texts and optional images 

1907 request_data: Request data dictionary for logging metadata 

1908 input_type: Whether this is a "request" or "response" 

1909 logging_obj: Optional logging object 

1910 

1911 Returns: 

1912 GenericGuardrailAPIInputs - processed_texts may be masked, images unchanged 

1913 

1914 Raises: 

1915 HTTPException: If sensitive content is detected and action is BLOCK 

1916 """ 

1917 start_time: Final = datetime.now() 

1918 detections: Final[list[ContentFilterDetection]] = [] 

1919 masked_entity_count: Final[dict[str, int]] = {} 

1920 status: GuardrailStatus = "success" 

1921 exception_str: str = "" 

1922 

1923 try: 

1924 texts: Final = inputs.get("texts", []) 

1925 images: Final = inputs.get("images", []) 

1926 

1927 # Process images if present 

1928 await self._process_images(images, detections) 

1929 

1930 # Process texts 

1931 verbose_proxy_logger.debug("ContentFilterGuardrail: Applying guardrail to %s text(s)", len(texts)) 

1932 

1933 processed_texts: Final = [] 

1934 for text in texts: 

1935 # Competitor intent check first (optional; may refuse/reframe) 

1936 if self._competitor_intent_checker and text: 

1937 intent_result = self._competitor_intent_checker.run(text) 

1938 if intent_result.get("intent", "other") != "other": 

1939 self._apply_competitor_intent_policy(intent_result, request_data, detections) 

1940 filtered_text = self._filter_single_text(text, detections=detections) 

1941 processed_texts.append(filtered_text) 

1942 

1943 verbose_proxy_logger.debug("ContentFilterGuardrail: Guardrail applied successfully") 

1944 inputs["texts"] = processed_texts 

1945 

1946 self._scan_tool_call_arguments(inputs=inputs, detections=detections) 

1947 

1948 if input_type == "request": 

1949 self._scan_mcp_tool_call_arguments( 

1950 request_data=request_data, detections=detections, logging_obj=logging_obj 

1951 ) 

1952 

1953 # Count masked entities by type 

1954 self._count_masked_entities(detections, masked_entity_count) 

1955 

1956 return inputs 

1957 except HTTPException: 

1958 status = "guardrail_intervened" 

1959 raise 

1960 except Exception as e: 

1961 status = "guardrail_failed_to_respond" 

1962 exception_str = str(e) 

1963 raise e 

1964 finally: 

1965 # Log guardrail information 

1966 self._log_guardrail_information( 

1967 request_data=request_data, 

1968 detections=detections, 

1969 status=status, 

1970 start_time=start_time, 

1971 masked_entity_count=masked_entity_count, 

1972 exception_str=exception_str, 

1973 ) 

1974 

1975 def _streamed_scan_plan(self) -> _StreamedScanPlan: 

1976 """ 

1977 Per-stream inputs for buffer trimming: the retained tail length (the default 

1978 context, widened to the longest configured keyword), the category exception 

1979 phrases, which suppress matches anywhere in the scanned text, and the conditional 

1980 category words, which only match when paired inside one sentence. 

1981 """ 

1982 longest_keyword: Final = max( 

1983 map(len, (*self.blocked_words, *self.category_keywords, *self.always_block_category_keywords)), 

1984 default=0, 

1985 ) 

1986 return _StreamedScanPlan( 

1987 context_chars=max(CONTENT_FILTER_STREAMING_SCAN_CONTEXT_CHARS, longest_keyword), 

1988 exception_phrases=tuple( 

1989 phrase for category in self.loaded_categories.values() for phrase in category.exceptions 

1990 ), 

1991 conditional_words=tuple( 

1992 word 

1993 for config in self.conditional_categories.values() 

1994 for word in (*config["identifier_words"], *config["block_words"]) 

1995 ), 

1996 ) 

1997 

1998 @staticmethod 

1999 def _cut_breaks_wider_context(buffered_text: str, head: str, tail: str, plan: _StreamedScanPlan) -> bool: 

2000 buffered_lower: Final = buffered_text.lower() 

2001 tail_lower: Final = tail.lower() 

2002 if any(phrase in buffered_lower and phrase not in tail_lower for phrase in plan.exception_phrases): 

2003 return True 

2004 cut_sentence: Final = ( 

2005 SENTENCE_TERMINATORS.split(head.lower())[-1] + SENTENCE_TERMINATORS.split(tail_lower, maxsplit=1)[0] 

2006 ) 

2007 return any(word in cut_sentence for word in plan.conditional_words) 

2008 

2009 def _trim_streamed_choice_buffer( 

2010 self, state: _StreamedChoiceState, masked_text: str, plan: _StreamedScanPlan 

2011 ) -> _StreamedChoiceState: 

2012 """ 

2013 Bound the per-choice buffer rescanned on every streamed chunk. 

2014 

2015 Once the buffer exceeds twice the scan context, drop everything but the last 

2016 context-sized tail, provided no exception phrase or unfinished conditional sentence 

2017 would leave the buffer, the two halves mask to the same output as the whole (so no 

2018 match or phrase straddles the cut), and the dropped prefix has already been yielded. 

2019 Otherwise keep the buffer and retry once it has grown by another context length. 

2020 

2021 Detections found in the dropped prefix move to the state's committed detections. 

2022 """ 

2023 if len(state.buffered_text) <= max(2 * plan.context_chars, state.next_trim_len): 

2024 return state 

2025 deferred: Final = replace(state, next_trim_len=len(state.buffered_text) + plan.context_chars) 

2026 head: Final = state.buffered_text[: -plan.context_chars] 

2027 tail: Final = state.buffered_text[-plan.context_chars :] 

2028 if self._cut_breaks_wider_context(state.buffered_text, head, tail, plan): 

2029 return deferred 

2030 head_detections: Final[list[ContentFilterDetection]] = [] # mutable-ok: filled by _filter_single_text 

2031 try: 

2032 masked_head: Final = self._filter_single_text(head, detections=head_detections) 

2033 masked_tail: Final = self._filter_single_text(tail) 

2034 except Exception: 

2035 return deferred 

2036 if masked_head + masked_tail != masked_text or len(masked_head) > state.yielded_masked_text_len: 

2037 return deferred 

2038 return replace( 

2039 state, 

2040 buffered_text=tail, 

2041 yielded_masked_text_len=state.yielded_masked_text_len - len(masked_head), 

2042 committed_detections=state.committed_detections + tuple(head_detections), 

2043 next_trim_len=0, 

2044 ) 

2045 

2046 @staticmethod 

2047 def _merge_detections(detections: Sequence[ContentFilterDetection]) -> tuple[ContentFilterDetection, ...]: 

2048 return tuple(detection for index, detection in enumerate(detections) if detection not in detections[:index]) 

2049 

2050 async def async_post_call_streaming_iterator_hook( 

2051 self, 

2052 user_api_key_dict: UserAPIKeyAuth, 

2053 response: Any, 

2054 request_data: dict, 

2055 ) -> AsyncGenerator[ModelResponseStream, None]: 

2056 """ 

2057 Process streaming response chunks and check for blocked content. 

2058 

2059 For BLOCK action: Raises HTTPException immediately when blocked content is detected. 

2060 For MASK action: Content is buffered to handle patterns split across chunks. 

2061 

2062 At stream end (including when BLOCK raises HTTPException), write a 

2063 standard_logging_guardrail_information entry into request_data["metadata"] 

2064 so the post-call log row reaches standard_logging_object.guardrail_information 

2065 and the UI Request Lifecycle panel. Mirrors apply_guardrail's finally-block 

2066 contract. 

2067 """ 

2068 state_by_choice: Final[dict[int, _StreamedChoiceState]] = {} 

2069 plan: Final = self._streamed_scan_plan() 

2070 

2071 start_time: Final = datetime.now() 

2072 scan_seconds: float = 0.0 # rebind-ok: accumulates per-chunk scan time across the stream 

2073 detections: list[ContentFilterDetection] = [] 

2074 masked_entity_count: Final[dict[str, int]] = {} 

2075 status: GuardrailStatus = "success" 

2076 exception_str: str = "" 

2077 

2078 verbose_proxy_logger.info( 

2079 "ContentFilterGuardrail: Starting robust streaming masking for model %s", request_data.get("model") 

2080 ) 

2081 

2082 try: 

2083 async for item in response: 

2084 if isinstance(item, ModelResponseStream) and item.choices: 

2085 for choice in item.choices: 

2086 if not (hasattr(choice, "delta") and choice.delta): 

2087 continue 

2088 

2089 choice_index = getattr(choice, "index", 0) 

2090 if not isinstance(choice_index, int): 

2091 choice_index = 0 

2092 

2093 content = getattr(choice.delta, "content", None) 

2094 is_final = bool(getattr(choice, "finish_reason", None)) 

2095 new_content = content if isinstance(content, str) else "" 

2096 if not new_content and not is_final: 

2097 continue 

2098 

2099 previous_state = state_by_choice.get(choice_index, _StreamedChoiceState()) 

2100 buffered_text = previous_state.buffered_text + new_content 

2101 if not buffered_text: 

2102 continue 

2103 

2104 # Add a space at the end if it's the final chunk to trigger word boundaries (\b) 

2105 text_to_scan = buffered_text + (" " if is_final else "") 

2106 choice_detections: list[ContentFilterDetection] = [] 

2107 scan_started = time.perf_counter() 

2108 

2109 try: 

2110 masked_text = self._filter_single_text(text_to_scan, detections=choice_detections) 

2111 if is_final and masked_text.endswith(" "): 

2112 masked_text = masked_text[:-1] 

2113 latest_detections = tuple(choice_detections) 

2114 except HTTPException: 

2115 state_by_choice[choice_index] = replace( 

2116 previous_state, latest_detections=tuple(choice_detections) 

2117 ) 

2118 raise 

2119 except Exception as e: 

2120 verbose_proxy_logger.error("ContentFilterGuardrail: Error in masking: %s", e) 

2121 masked_text = text_to_scan # Fallback to current text 

2122 latest_detections = previous_state.latest_detections 

2123 finally: 

2124 scan_seconds += time.perf_counter() - scan_started 

2125 

2126 safe_to_yield_len = max( 

2127 previous_state.yielded_masked_text_len, 

2128 len(masked_text) - (0 if is_final else CONTENT_FILTER_STREAMING_HOLDBACK_CHARS), 

2129 ) 

2130 choice.delta.content = masked_text[previous_state.yielded_masked_text_len : safe_to_yield_len] 

2131 next_state = replace( 

2132 previous_state, 

2133 buffered_text=buffered_text, 

2134 yielded_masked_text_len=safe_to_yield_len, 

2135 latest_detections=latest_detections, 

2136 ) 

2137 if is_final: 

2138 state_by_choice[choice_index] = next_state 

2139 continue 

2140 

2141 trim_started = time.perf_counter() 

2142 state_by_choice[choice_index] = self._trim_streamed_choice_buffer(next_state, masked_text, plan) 

2143 scan_seconds += time.perf_counter() - trim_started 

2144 

2145 yield item 

2146 else: 

2147 # Not a ModelResponseStream or no choices - yield as is 

2148 yield item 

2149 except HTTPException: 

2150 status = "guardrail_intervened" 

2151 raise 

2152 except Exception as e: 

2153 status = "guardrail_failed_to_respond" 

2154 exception_str = str(e) 

2155 raise e 

2156 finally: 

2157 detections = [ 

2158 detection 

2159 for state in state_by_choice.values() 

2160 for detection in self._merge_detections((*state.committed_detections, *state.latest_detections)) 

2161 ] 

2162 self._count_masked_entities(detections, masked_entity_count) 

2163 self._log_guardrail_information( 

2164 request_data=request_data, 

2165 detections=detections, 

2166 status=status, 

2167 start_time=start_time, 

2168 masked_entity_count=masked_entity_count, 

2169 exception_str=exception_str, 

2170 duration=scan_seconds, 

2171 ) 

2172 

2173 @staticmethod 

2174 def get_config_model(): 

2175 from litellm.types.proxy.guardrails.guardrail_hooks.litellm_content_filter import ( 

2176 LitellmContentFilterGuardrailConfigModel, 

2177 ) 

2178 

2179 return LitellmContentFilterGuardrailConfigModel 

2180 

2181 @classmethod 

2182 def get_supported_event_hooks(cls) -> list[GuardrailEventHooks]: 

2183 return [ 

2184 GuardrailEventHooks.pre_call, 

2185 GuardrailEventHooks.post_call, 

2186 GuardrailEventHooks.during_call, 

2187 GuardrailEventHooks.realtime_input_transcription, 

2188 GuardrailEventHooks.pre_mcp_call, 

2189 GuardrailEventHooks.post_mcp_call, 

2190 ]