Coverage for .venv/lib/python3.13/site-packages/litellm/proxy/guardrails/guardrail_hooks/qualifire/qualifire.py: 12%

186 statements  

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

1# +-------------------------------------------------------------+ 

2# 

3# Use Qualifire for your LLM calls 

4# 

5# +-------------------------------------------------------------+ 

6# Qualifire - Evaluate LLM outputs for quality, safety, and reliability 

7 

8import json 

9import os 

10from typing import Any, Final, Literal 

11 

12from fastapi import HTTPException 

13 

14from litellm._logging import verbose_proxy_logger 

15from litellm.integrations.custom_guardrail import ( 

16 CustomGuardrail, 

17 log_guardrail_information, 

18) 

19from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj 

20from litellm.llms.custom_httpx.http_handler import ( 

21 get_async_httpx_client, 

22 httpxSpecialProvider, 

23) 

24from litellm.secret_managers.main import get_secret_str 

25from litellm.types.guardrails import GuardrailEventHooks, LitellmParams 

26from litellm.types.llms.openai import AllMessageValues 

27from litellm.types.proxy.guardrails.guardrail_hooks.base import GuardrailConfigModel 

28from litellm.types.utils import GenericGuardrailAPIInputs 

29 

30GUARDRAIL_NAME: Final = "qualifire" 

31DEFAULT_QUALIFIRE_API_BASE: Final = "https://proxy.qualifire.ai" 

32 

33 

34class QualifireGuardrail(CustomGuardrail): 

35 @classmethod 

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

37 return [ 

38 GuardrailEventHooks.pre_call, 

39 GuardrailEventHooks.during_call, 

40 GuardrailEventHooks.post_call, 

41 ] 

42 

43 def __init__( 

44 self, 

45 api_key: str | None = None, 

46 api_base: str | None = None, 

47 evaluation_id: str | None = None, 

48 prompt_injections: bool | None = None, 

49 hallucinations_check: bool | None = None, 

50 grounding_check: bool | None = None, 

51 pii_check: bool | None = None, 

52 content_moderation_check: bool | None = None, 

53 tool_selection_quality_check: bool | None = None, 

54 assertions: list[str] | None = None, 

55 on_flagged: str | None = "block", 

56 **kwargs, 

57 ): 

58 """ 

59 Initialize the QualifireGuardrail class. 

60 

61 Args: 

62 api_key: API key for Qualifire (or use QUALIFIRE_API_KEY env var) 

63 api_base: Optional custom API base URL (defaults to https://api.qualifire.ai) 

64 evaluation_id: Pre-configured evaluation ID from Qualifire dashboard 

65 prompt_injections: Enable prompt injection detection (default if no other checks) 

66 hallucinations_check: Enable hallucination detection 

67 grounding_check: Enable grounding verification 

68 pii_check: Enable PII detection 

69 content_moderation_check: Enable content moderation 

70 tool_selection_quality_check: Enable tool selection quality check 

71 assertions: Custom assertions to validate against the output 

72 on_flagged: Action when content is flagged: "block" or "monitor" 

73 """ 

74 self.qualifire_api_key = api_key or get_secret_str("QUALIFIRE_API_KEY") or os.environ.get("QUALIFIRE_API_KEY") 

75 self.qualifire_api_base = ( 

76 api_base 

77 or get_secret_str("QUALIFIRE_BASE_URL") 

78 or os.environ.get("QUALIFIRE_BASE_URL") 

79 or DEFAULT_QUALIFIRE_API_BASE 

80 ) 

81 self.evaluation_id = evaluation_id 

82 self.prompt_injections = prompt_injections 

83 self.hallucinations_check = hallucinations_check 

84 self.grounding_check = grounding_check 

85 self.pii_check = pii_check 

86 self.content_moderation_check = content_moderation_check 

87 self.tool_selection_quality_check = tool_selection_quality_check 

88 self.assertions = assertions 

89 self.on_flagged = on_flagged or "block" 

90 self._validate_on_flagged(self.on_flagged) 

91 

92 # If no checks are specified and no evaluation_id, default to prompt_injections 

93 if not self._has_any_check_enabled() and not self.evaluation_id: 

94 self.prompt_injections = True 

95 

96 # Initialize async HTTP client for direct API calls 

97 self.async_handler = get_async_httpx_client(llm_provider=httpxSpecialProvider.GuardrailCallback) 

98 

99 kwargs.setdefault("supported_event_hooks", list(self.get_supported_event_hooks())) 

100 super().__init__(**kwargs) 

101 

102 def _validate_on_flagged(self, on_flagged: str) -> None: 

103 if on_flagged not in ("block", "monitor"): 

104 # on_flagged is defined on LakeraV2GuardrailConfigModel but LitellmParams 

105 # flattens every guardrail config mixin together, so a value Lakera 

106 # supports (e.g. "inject_system_message") type-checks for any guardrail, 

107 # including this one, which never implements it. Reject it explicitly 

108 # instead of silently falling through to a block-on-anything-else branch. 

109 raise ValueError( 

110 f"Qualifire guardrail does not support on_flagged={on_flagged!r}; " 

111 "only 'block' and 'monitor' are supported." 

112 ) 

113 

114 def update_in_memory_litellm_params(self, litellm_params: LitellmParams) -> None: 

115 """ 

116 The base implementation blindly ``setattr``s every field on ``litellm_params`` 

117 (including ``on_flagged``) onto this live instance with no revalidation, so an 

118 in-place config update (via the DB/UI, without a restart) could otherwise 

119 reintroduce the exact invalid on_flagged value __init__ rejects. Validate the 

120 prospective post-update value *before* mutating, so a rejected update leaves 

121 the live instance untouched instead of raising after it's already been 

122 corrupted. Mirrors LakeraAIGuardrail's own override of this same method. 

123 """ 

124 prospective_on_flagged: Final = litellm_params.on_flagged or self.on_flagged 

125 self._validate_on_flagged(prospective_on_flagged) 

126 super().update_in_memory_litellm_params(litellm_params=litellm_params) 

127 

128 def _has_any_check_enabled(self) -> bool: 

129 """Check if any evaluation check is explicitly enabled.""" 

130 return any( 

131 [ 

132 self.prompt_injections, 

133 self.hallucinations_check, 

134 self.grounding_check, 

135 self.pii_check, 

136 self.content_moderation_check, 

137 self.tool_selection_quality_check, 

138 self.assertions, 

139 ] 

140 ) 

141 

142 def _convert_messages_to_api_format(self, messages: list[AllMessageValues]) -> list[dict[str, object]]: 

143 """ 

144 Convert LiteLLM messages to Qualifire API format. 

145 Supports tool calls for tool_selection_quality_check. 

146 

147 Returns a list of dicts matching the API's ModelInvocationCanonicalMessage schema: 

148 { 

149 "role": "user" | "assistant" | "system" | "tool", 

150 "content": "...", 

151 "tool_call_id": "...", # optional 

152 "tool_calls": [{"id": "...", "name": "...", "arguments": {...}}] # optional 

153 } 

154 """ 

155 api_messages: Final = [] 

156 for msg in messages: 

157 role = msg.get("role", "user") 

158 content = msg.get("content", "") 

159 

160 # Handle content that might be a list (multimodal) 

161 if isinstance(content, list): 

162 text_parts = [] 

163 for part in content: 

164 if isinstance(part, dict) and part.get("type") == "text": 

165 text_parts.append(part.get("text", "")) 

166 elif isinstance(part, str): 

167 text_parts.append(part) 

168 content = "\n".join(text_parts) 

169 

170 api_message: dict[str, object] = { 

171 "role": role, 

172 "content": content if isinstance(content, str) else str(content), 

173 } 

174 

175 # Handle tool_call_id for tool response messages 

176 tool_call_id = msg.get("tool_call_id") 

177 if tool_call_id: 

178 api_message["tool_call_id"] = tool_call_id 

179 

180 # Handle tool calls if present 

181 tool_calls = msg.get("tool_calls") 

182 if tool_calls and isinstance(tool_calls, list): 

183 api_tool_calls = [] 

184 for tc in tool_calls: 

185 if isinstance(tc, dict): 

186 function_info = tc.get("function", {}) 

187 # Arguments can be a string (JSON) or dict 

188 args = function_info.get("arguments", {}) 

189 if isinstance(args, str): 

190 try: 

191 args = json.loads(args) 

192 except json.JSONDecodeError: 

193 args = {} 

194 api_tool_calls.append( 

195 { 

196 "id": tc.get("id") or "", 

197 "name": function_info.get("name") or "", 

198 "arguments": args if isinstance(args, dict) else {}, 

199 } 

200 ) 

201 if api_tool_calls: 

202 api_message["tool_calls"] = api_tool_calls 

203 

204 api_messages.append(api_message) 

205 

206 return api_messages 

207 

208 def _convert_tools_to_api_format(self, tools: list[object] | None) -> list[dict[str, object]] | None: 

209 """ 

210 Convert OpenAI-format tools to Qualifire API format. 

211 

212 Returns a list of dicts matching the API's ModelInvocationToolDefinition schema: 

213 { 

214 "name": "...", 

215 "description": "...", 

216 "parameters": {...} 

217 } 

218 """ 

219 if not tools: 

220 return None 

221 

222 api_tools: Final = [] 

223 for tool in tools: 

224 if isinstance(tool, dict): 

225 # Handle OpenAI function tool format 

226 if tool.get("type") == "function": 

227 function_def = tool.get("function", {}) 

228 api_tools.append( 

229 { 

230 "name": function_def.get("name", ""), 

231 "description": function_def.get("description", ""), 

232 "parameters": function_def.get("parameters", {}), 

233 } 

234 ) 

235 # Handle direct tool format 

236 elif "name" in tool: 

237 api_tools.append( 

238 { 

239 "name": tool.get("name", ""), 

240 "description": tool.get("description", ""), 

241 "parameters": tool.get("parameters", {}), 

242 } 

243 ) 

244 

245 return api_tools if api_tools else None 

246 

247 def _check_if_flagged(self, result: dict[str, Any]) -> bool: 

248 """ 

249 Check if the Qualifire evaluation result indicates flagged content. 

250 

251 Returns True only if there are explicitly flagged items in the evaluation results. 

252 A high score (close to 100) indicates GOOD content, low score indicates problems. 

253 """ 

254 # Check evaluation results for any flagged items 

255 evaluation_results: Final = result.get("evaluationResults", []) or [] 

256 

257 for eval_result in evaluation_results: 

258 results = eval_result.get("results", []) or [] 

259 for r in results: 

260 if r.get("flagged"): 

261 return True 

262 

263 return False 

264 

265 def _build_evaluate_payload( 

266 self, 

267 api_messages: list[dict[str, object]], 

268 output: str | None, 

269 assertions: list[str] | None, 

270 available_tools: list[dict[str, object]] | None, 

271 ) -> dict[str, object]: 

272 """Build payload dictionary for the /api/evaluation/evaluate endpoint.""" 

273 payload: Final[dict[str, object]] = {"messages": api_messages} 

274 

275 if output is not None: 

276 payload["output"] = output 

277 

278 # Add enabled checks 

279 if self.prompt_injections: 

280 payload["prompt_injections"] = True 

281 if self.hallucinations_check: 

282 payload["hallucinations_check"] = True 

283 if self.grounding_check: 

284 payload["grounding_check"] = True 

285 if self.pii_check: 

286 payload["pii_check"] = True 

287 if self.content_moderation_check: 

288 payload["content_moderation_check"] = True 

289 if self.tool_selection_quality_check: 

290 # Only enable tool_selection_quality_check if available_tools is provided 

291 if available_tools: 

292 payload["tool_selection_quality_check"] = True 

293 payload["available_tools"] = available_tools 

294 else: 

295 verbose_proxy_logger.debug( 

296 "Qualifire Guardrail: tool_selection_quality_check enabled but no available_tools provided, skipping this check" 

297 ) 

298 if assertions: 

299 payload["assertions"] = assertions 

300 

301 return payload 

302 

303 async def _run_qualifire_check( 

304 self, 

305 messages: list[AllMessageValues], 

306 output: str | None, 

307 dynamic_params: dict[str, Any], 

308 available_tools: list[object] | None = None, 

309 ) -> None: 

310 """ 

311 Core Qualifire check logic - shared between hooks. 

312 

313 Args: 

314 messages: The conversation messages 

315 output: The LLM output text (for post_call) 

316 dynamic_params: Dynamic parameters from request body 

317 available_tools: Available tools from the request (for tool_selection_quality_check) 

318 

319 Raises: 

320 HTTPException: If content is blocked 

321 """ 

322 # Apply dynamic param overrides 

323 evaluation_id: Final = dynamic_params.get("evaluation_id") or self.evaluation_id 

324 assertions: Final = dynamic_params.get("assertions") or self.assertions 

325 on_flagged: Final = dynamic_params.get("on_flagged") or self.on_flagged 

326 

327 # Prepare headers 

328 headers: Final = { 

329 "X-Qualifire-API-Key": self.qualifire_api_key or "", 

330 "Content-Type": "application/json", 

331 } 

332 

333 try: 

334 # Convert messages to API format 

335 api_messages: Final = self._convert_messages_to_api_format(messages) 

336 

337 # Use invoke endpoint if evaluation_id is provided 

338 if evaluation_id: 

339 # For invoke_evaluation, we need to extract input/output 

340 input_text = "" 

341 

342 # Get the last user message as input 

343 for msg in reversed(messages): 

344 if msg.get("role") == "user": 

345 content = msg.get("content", "") 

346 if isinstance(content, str): 

347 input_text = content 

348 break 

349 

350 payload = { 

351 "evaluation_id": evaluation_id, 

352 "input": input_text, 

353 "output": output or "", 

354 "messages": api_messages, 

355 } 

356 

357 # Convert tools if provided 

358 api_tools = self._convert_tools_to_api_format(available_tools) 

359 if api_tools: 

360 payload["available_tools"] = api_tools 

361 

362 url = f"{self.qualifire_api_base}/api/evaluation/invoke" 

363 else: 

364 # Use evaluate endpoint with individual checks 

365 api_tools = self._convert_tools_to_api_format(available_tools) 

366 payload = self._build_evaluate_payload( 

367 api_messages=api_messages, 

368 output=output, 

369 assertions=assertions, 

370 available_tools=api_tools, 

371 ) 

372 url = f"{self.qualifire_api_base}/api/evaluation/evaluate" 

373 

374 verbose_proxy_logger.debug("Qualifire Guardrail: Making request to %s", url) 

375 

376 # Make the API request 

377 response: Final = await self.async_handler.post( 

378 url=url, 

379 headers=headers, 

380 json=payload, 

381 ) 

382 response.raise_for_status() 

383 result: Final = response.json() 

384 

385 # Extract response info for logging 

386 qualifire_response: Final[dict[str, object]] = { 

387 "score": result.get("score"), 

388 "status": result.get("status"), 

389 } 

390 

391 verbose_proxy_logger.debug( 

392 "Qualifire Guardrail: Got result from API, score=%s, status=%s", 

393 qualifire_response["score"], 

394 qualifire_response["status"], 

395 ) 

396 

397 # Check if any evaluation flagged the content 

398 is_flagged: Final = self._check_if_flagged(result) 

399 

400 if is_flagged: 

401 if on_flagged == "monitor": 

402 verbose_proxy_logger.warning( 

403 "Qualifire Guardrail: Monitoring mode - violation detected but allowing request. Response: %s", 

404 qualifire_response, 

405 ) 

406 else: 

407 # Block the request 

408 raise HTTPException( 

409 status_code=400, 

410 detail={ 

411 "error": "Violated guardrail policy", 

412 "qualifire_response": qualifire_response, 

413 }, 

414 ) 

415 

416 except HTTPException: 

417 raise 

418 except Exception as e: 

419 verbose_proxy_logger.exception("Qualifire Guardrail error: %s", e) 

420 raise 

421 

422 @log_guardrail_information 

423 async def apply_guardrail( 

424 self, 

425 inputs: GenericGuardrailAPIInputs, 

426 request_data: dict, 

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

428 logging_obj: LiteLLMLoggingObj | None = None, 

429 ) -> GenericGuardrailAPIInputs: 

430 """ 

431 Apply Qualifire guardrail to the given inputs. 

432 

433 This method is called by the unified guardrail system for both 

434 input (request) and output (response) validation. 

435 

436 Args: 

437 inputs: Dictionary containing: 

438 - texts: List of texts to check 

439 - structured_messages: Structured messages from the request (pre-call only) 

440 - tool_calls: Tool calls if present 

441 request_data: The original request data 

442 input_type: "request" for pre-call, "response" for post-call 

443 logging_obj: Optional logging object 

444 

445 Returns: 

446 GenericGuardrailAPIInputs - unchanged if allowed through 

447 

448 Raises: 

449 HTTPException: If content is blocked 

450 """ 

451 # Get dynamic params from request body (allows runtime overrides) 

452 dynamic_params: Final = self.get_guardrail_dynamic_request_body_params(request_data=request_data) 

453 

454 # Extract messages from structured_messages or request_data 

455 messages: list[AllMessageValues] | None = inputs.get("structured_messages") 

456 if not messages: 

457 messages = request_data.get("messages") 

458 

459 # For response (post_call), messages may not be available in the inputs 

460 # We need to work with texts instead and construct messages if needed 

461 output: str | None = None 

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

463 

464 if input_type == "response": 

465 # For post_call, extract output from texts 

466 if texts: 

467 output = texts[-1] if isinstance(texts, list) else str(texts) 

468 

469 # If no structured messages available, construct from texts 

470 if not messages and texts: 

471 # Create a simple message structure for the output 

472 messages = [{"role": "assistant", "content": output or ""}] 

473 

474 if not messages: 

475 # For pre_call with no messages, try to construct from texts 

476 if texts: 

477 messages = [{"role": "user", "content": texts[-1] if texts else ""}] 

478 else: 

479 verbose_proxy_logger.debug("Qualifire Guardrail: No messages or texts found, skipping") 

480 return inputs 

481 

482 # Get available tools from request_data for tool_selection_quality_check 

483 available_tools: Final = request_data.get("tools") 

484 

485 await self._run_qualifire_check( 

486 messages=messages, 

487 output=output, 

488 dynamic_params=dynamic_params, 

489 available_tools=available_tools, 

490 ) 

491 

492 return inputs 

493 

494 @staticmethod 

495 def get_config_model() -> type["GuardrailConfigModel"] | None: 

496 from litellm.types.proxy.guardrails.guardrail_hooks.qualifire import ( 

497 QualifireGuardrailConfigModel, 

498 ) 

499 

500 return QualifireGuardrailConfigModel