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

177 statements  

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

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

2# 

3# Use DynamoAI Guardrails for your LLM calls 

4# https://dynamo.ai 

5# 

6# +-------------------------------------------------------------+ 

7 

8import os 

9from collections.abc import AsyncGenerator, AsyncIterable 

10from datetime import datetime 

11from typing import Any, Final 

12 

13import httpx 

14 

15import litellm 

16from litellm._logging import verbose_proxy_logger 

17from litellm.caching.caching import DualCache 

18from litellm.integrations.custom_guardrail import CustomGuardrail 

19from litellm.llms.custom_httpx.http_handler import ( 

20 get_async_httpx_client, 

21 httpxSpecialProvider, 

22) 

23from litellm.proxy._types import UserAPIKeyAuth 

24from litellm.types.guardrails import GuardrailEventHooks 

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

26from litellm.types.proxy.guardrails.guardrail_hooks.dynamoai import ( 

27 DynamoAIProcessedResult, 

28 DynamoAIRequest, 

29 DynamoAIResponse, 

30) 

31from litellm.types.utils import CallTypesLiteral, GuardrailStatus, ModelResponseStream 

32 

33GUARDRAIL_NAME: Final = "dynamoai" 

34 

35 

36class DynamoAIGuardrails(CustomGuardrail): 

37 """ 

38 DynamoAI Guardrails integration for LiteLLM. 

39 

40 Provides content moderation and policy enforcement using DynamoAI's guardrail API. 

41 """ 

42 

43 def __init__( 

44 self, 

45 guardrail_name: str = "litellm_test", 

46 api_key: str | None = None, 

47 api_base: str | None = None, 

48 model_id: str = "", 

49 policy_ids: list[str] = [], 

50 **kwargs, 

51 ): 

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

53 

54 # Set API configuration 

55 self.api_key = api_key or os.getenv("DYNAMOAI_API_KEY") 

56 if not self.api_key: 

57 raise ValueError( 

58 "DynamoAI API key is required. Set DYNAMOAI_API_KEY environment variable or pass api_key parameter." 

59 ) 

60 

61 self.api_base = api_base or os.getenv("DYNAMOAI_API_BASE", "https://api.dynamo.ai") 

62 self.api_url = f"{self.api_base}/v1/moderation/analyze/" 

63 

64 # Model ID for tracking/logging purposes 

65 self.model_id = model_id or os.getenv("DYNAMOAI_MODEL_ID", "") 

66 

67 # Policy IDs - get from parameter, env var, or use empty list 

68 env_policy_ids: Final = os.getenv("DYNAMOAI_POLICY_IDS", "") 

69 self.policy_ids = policy_ids or (env_policy_ids.split(",") if env_policy_ids else []) 

70 self.guardrail_name = guardrail_name 

71 self.guardrail_provider = "dynamoai" 

72 

73 # store kwargs as optional_params 

74 self.optional_params = kwargs 

75 

76 # Set supported event hooks 

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

78 

79 super().__init__(guardrail_name=guardrail_name, **kwargs) 

80 

81 verbose_proxy_logger.debug( 

82 "DynamoAI Guardrail initialized with guardrail_name=%s, model_id=%s", 

83 self.guardrail_name, 

84 self.model_id, 

85 ) 

86 

87 async def _call_dynamoai_guardrails( 

88 self, 

89 messages: list[dict[str, Any]], 

90 event_type: GuardrailEventHooks, 

91 text_type: str = "input", 

92 request_data: dict | None = None, 

93 ) -> DynamoAIResponse: 

94 """ 

95 Call DynamoAI Guardrails API to analyze messages for policy violations. 

96 

97 Args: 

98 messages: List of messages to analyze 

99 text_type: Type of text being analyzed ("input" or "output") 

100 request_data: Optional request data for logging purposes 

101 

102 Returns: 

103 DynamoAIResponse: Response from the DynamoAI Guardrails API 

104 """ 

105 start_time: Final = datetime.now() 

106 

107 payload: Final[DynamoAIRequest] = { 

108 "messages": messages, 

109 } 

110 

111 # Add optional fields if provided 

112 if self.policy_ids: 

113 payload["policyIds"] = self.policy_ids 

114 if self.model_id: 

115 payload["modelId"] = self.model_id 

116 

117 headers: Final = { 

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

119 "Authorization": f"Bearer {self.api_key}", 

120 } 

121 

122 verbose_proxy_logger.debug( 

123 "DynamoAI request to %s with payload=%s", 

124 self.api_url, 

125 payload, 

126 ) 

127 

128 try: 

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

130 url=self.api_url, 

131 json=dict(payload), 

132 headers=headers, 

133 ) 

134 response.raise_for_status() 

135 response_json: Final = response.json() 

136 

137 end_time = datetime.now() 

138 duration = (end_time - start_time).total_seconds() 

139 

140 # Add guardrail information to request trace 

141 if request_data: 

142 guardrail_status: Final = self._determine_guardrail_status(response_json) 

143 self.add_standard_logging_guardrail_information_to_request_data( 

144 guardrail_provider=self.guardrail_provider, 

145 guardrail_json_response=response_json, 

146 request_data=request_data, 

147 guardrail_status=guardrail_status, 

148 start_time=start_time.timestamp(), 

149 end_time=end_time.timestamp(), 

150 duration=duration, 

151 event_type=event_type, 

152 ) 

153 

154 return response_json 

155 

156 except httpx.HTTPError as e: 

157 end_time = datetime.now() 

158 duration = (end_time - start_time).total_seconds() 

159 

160 verbose_proxy_logger.error("DynamoAI API request failed: %s", str(e)) 

161 

162 # Add guardrail information with failure status 

163 if request_data: 

164 self.add_standard_logging_guardrail_information_to_request_data( 

165 guardrail_provider=self.guardrail_provider, 

166 guardrail_json_response={"error": str(e)}, 

167 request_data=request_data, 

168 guardrail_status="guardrail_failed_to_respond", 

169 start_time=start_time.timestamp(), 

170 end_time=end_time.timestamp(), 

171 duration=duration, 

172 event_type=event_type, 

173 ) 

174 

175 raise 

176 

177 def _process_dynamoai_guardrails_response(self, response: DynamoAIResponse) -> DynamoAIProcessedResult: 

178 """ 

179 Process the response from the DynamoAI Guardrails API 

180 

181 Args: 

182 response: The response from the API with 'finalAction' and 'appliedPolicies' keys 

183 

184 Returns: 

185 DynamoAIProcessedResult: Processed response with detected violations 

186 """ 

187 final_action: Final = response.get("finalAction", "NONE") 

188 applied_policies: Final = response.get("appliedPolicies", []) 

189 

190 violations_detected: Final[list[str]] = [] 

191 violation_details: Final[dict[str, object]] = {} 

192 

193 # For now, only handle BLOCK action 

194 if final_action == "BLOCK": 

195 for applied_policy in applied_policies: 

196 policy_info = applied_policy.get("policy", {}) 

197 policy_outputs = applied_policy.get("outputs", {}) 

198 

199 # Get policy name and action 

200 policy_name = policy_info.get("name", "unknown") 

201 

202 # Check for action in multiple places 

203 policy_action = ( 

204 applied_policy.get("action") or (policy_outputs.get("action") if policy_outputs else None) or "NONE" 

205 ) 

206 

207 # Only include policies with BLOCK action 

208 if policy_action == "BLOCK": 

209 violations_detected.append(policy_name) 

210 violation_details[policy_name] = { 

211 "policyId": policy_info.get("id"), 

212 "action": policy_action, 

213 "method": policy_info.get("method"), 

214 "description": policy_info.get("description"), 

215 "message": (policy_outputs.get("message") if policy_outputs else None), 

216 } 

217 

218 return { 

219 "violations_detected": violations_detected, 

220 "violation_details": violation_details, 

221 } 

222 

223 def _determine_guardrail_status(self, response_json: DynamoAIResponse) -> GuardrailStatus: 

224 """ 

225 Determine the guardrail status based on DynamoAI API response. 

226 

227 Returns: 

228 "success": Content allowed through with no violations (finalAction is NONE) 

229 "guardrail_intervened": Content blocked (finalAction is BLOCK) 

230 "guardrail_failed_to_respond": Technical error or API failure 

231 """ 

232 try: 

233 if not isinstance(response_json, dict): 

234 return "guardrail_failed_to_respond" 

235 

236 # Check for error in response 

237 if response_json.get("error"): 

238 return "guardrail_failed_to_respond" 

239 

240 final_action: Final = response_json.get("finalAction", "NONE") 

241 

242 if final_action == "NONE": 

243 return "success" 

244 elif final_action == "BLOCK": 

245 return "guardrail_intervened" 

246 

247 # For now, treat other actions as success (WARN, REDACT, SANITIZE not implemented yet) 

248 return "success" 

249 

250 except Exception as e: 

251 verbose_proxy_logger.error("Error determining DynamoAI guardrail status: %s", str(e)) 

252 return "guardrail_failed_to_respond" 

253 

254 def _create_error_message(self, processed_result: DynamoAIProcessedResult) -> str: 

255 """ 

256 Create a detailed error message from processed guardrail results. 

257 

258 Args: 

259 processed_result: Processed response with detected violations 

260 

261 Returns: 

262 Formatted error message string 

263 """ 

264 violations_detected: Final = processed_result["violations_detected"] 

265 violation_details: Final = processed_result["violation_details"] 

266 

267 error_message = f"Guardrail failed: {len(violations_detected)} violation(s) detected\n\n" 

268 

269 for policy_name in violations_detected: 

270 error_message += f"- {policy_name.upper()}:\n" 

271 details = violation_details.get(policy_name, {}) 

272 

273 # Format violation details 

274 if details.get("action"): 

275 error_message += f" Action: {details['action']}\n" 

276 if details.get("method"): 

277 error_message += f" Method: {details['method']}\n" 

278 if details.get("description"): 

279 error_message += f" Description: {details['description']}\n" 

280 if details.get("message"): 

281 error_message += f" Message: {details['message']}\n" 

282 if details.get("policyId"): 

283 error_message += f" Policy ID: {details['policyId']}\n" 

284 error_message += "\n" 

285 

286 return error_message.strip() 

287 

288 async def async_pre_call_hook( 

289 self, 

290 user_api_key_dict: UserAPIKeyAuth, 

291 cache: DualCache, 

292 data: dict, 

293 call_type: CallTypesLiteral, 

294 ) -> Exception | str | dict | None: 

295 """ 

296 Runs before the LLM API call 

297 Runs on only Input 

298 Use this if you want to MODIFY the input 

299 """ 

300 verbose_proxy_logger.debug("Running DynamoAI pre-call hook") 

301 

302 from litellm.proxy.common_utils.callback_utils import ( 

303 add_guardrail_to_applied_guardrails_header, 

304 ) 

305 

306 event_type: Final[GuardrailEventHooks] = GuardrailEventHooks.pre_call 

307 if self.should_run_guardrail(data=data, event_type=event_type) is not True: 

308 return data 

309 

310 _messages: Final = data.get("messages") 

311 if _messages: 

312 result: Final = await self._call_dynamoai_guardrails( 

313 messages=_messages, 

314 text_type="input", 

315 request_data=data, 

316 event_type=GuardrailEventHooks.pre_call, 

317 ) 

318 

319 verbose_proxy_logger.debug("Guardrails async_pre_call_hook result=%s", result) 

320 

321 # Process the guardrails response 

322 processed_result: Final = self._process_dynamoai_guardrails_response(result) 

323 violations_detected: Final = processed_result["violations_detected"] 

324 

325 # If any violations are detected, raise an error 

326 if violations_detected: 

327 error_message: Final = self._create_error_message(processed_result) 

328 raise ValueError(error_message) 

329 

330 # Add guardrail to applied guardrails header 

331 add_guardrail_to_applied_guardrails_header(request_data=data, guardrail_name=self.guardrail_name) 

332 

333 return data 

334 

335 async def async_moderation_hook( 

336 self, 

337 data: dict, 

338 user_api_key_dict: UserAPIKeyAuth, 

339 call_type: CallTypesLiteral, 

340 ): 

341 """ 

342 Runs in parallel to LLM API call 

343 Runs on only Input 

344 

345 This can NOT modify the input, only used to reject or accept a call before going to LLM API 

346 """ 

347 from litellm.proxy.common_utils.callback_utils import ( 

348 add_guardrail_to_applied_guardrails_header, 

349 ) 

350 

351 event_type: Final[GuardrailEventHooks] = GuardrailEventHooks.during_call 

352 if self.should_run_guardrail(data=data, event_type=event_type) is not True: 

353 return 

354 

355 _messages: Final = data.get("messages") 

356 if _messages: 

357 result: Final = await self._call_dynamoai_guardrails( 

358 messages=_messages, 

359 text_type="input", 

360 request_data=data, 

361 event_type=GuardrailEventHooks.during_call, 

362 ) 

363 

364 verbose_proxy_logger.debug("Guardrails async_moderation_hook result=%s", result) 

365 

366 # Process the guardrails response 

367 processed_result: Final = self._process_dynamoai_guardrails_response(result) 

368 violations_detected: Final = processed_result["violations_detected"] 

369 

370 # If any violations are detected, raise an error 

371 if violations_detected: 

372 error_message: Final = self._create_error_message(processed_result) 

373 raise ValueError(error_message) 

374 

375 # Add guardrail to applied guardrails header 

376 add_guardrail_to_applied_guardrails_header(request_data=data, guardrail_name=self.guardrail_name) 

377 

378 return data 

379 

380 async def async_post_call_success_hook( 

381 self, 

382 data: dict, 

383 user_api_key_dict: UserAPIKeyAuth, 

384 response, 

385 ): 

386 """ 

387 Runs on response from LLM API call 

388 

389 It can be used to reject a response 

390 

391 Uses DynamoAI guardrails to check the response for policy violations 

392 """ 

393 from litellm.proxy.common_utils.callback_utils import ( 

394 add_guardrail_to_applied_guardrails_header, 

395 ) 

396 from litellm.types.guardrails import GuardrailEventHooks 

397 

398 if self.should_run_guardrail(data=data, event_type=GuardrailEventHooks.post_call) is not True: 

399 return 

400 

401 verbose_proxy_logger.debug("async_post_call_success_hook response=%s", response) 

402 

403 # Check if the ModelResponse has text content in its choices 

404 # to avoid sending empty content to DynamoAI (e.g., during tool calls) 

405 if isinstance(response, litellm.ModelResponse): 

406 has_text_content = False 

407 dynamoai_messages: Final[list[dict[str, str]]] = [] 

408 

409 for choice in response.choices: 

410 if isinstance(choice, litellm.Choices): 

411 if choice.message.content and isinstance(choice.message.content, str): 

412 has_text_content = True 

413 dynamoai_messages.append( 

414 { 

415 "role": choice.message.role or "assistant", 

416 "content": choice.message.content, 

417 } 

418 ) 

419 

420 if not has_text_content: 

421 verbose_proxy_logger.warning("DynamoAI: not running guardrail. No output text in response") 

422 return 

423 

424 if dynamoai_messages: 

425 result: Final = await self._call_dynamoai_guardrails( 

426 messages=dynamoai_messages, 

427 text_type="output", 

428 request_data=data, 

429 event_type=GuardrailEventHooks.post_call, 

430 ) 

431 

432 verbose_proxy_logger.debug("Guardrails async_post_call_success_hook result=%s", result) 

433 

434 # Process the guardrails response 

435 processed_result: Final = self._process_dynamoai_guardrails_response(result) 

436 violations_detected: Final = processed_result["violations_detected"] 

437 

438 # If any violations are detected, raise an error 

439 if violations_detected: 

440 error_message: Final = self._create_error_message(processed_result) 

441 raise ValueError(error_message) 

442 

443 # Add guardrail to applied guardrails header 

444 add_guardrail_to_applied_guardrails_header(request_data=data, guardrail_name=self.guardrail_name) 

445 

446 async def async_post_call_streaming_iterator_hook( 

447 self, 

448 user_api_key_dict: UserAPIKeyAuth, 

449 response: AsyncIterable[ModelResponseStream], 

450 request_data: dict, 

451 ) -> AsyncGenerator[ModelResponseStream, None]: 

452 """ 

453 Passes the entire stream to the guardrail 

454 

455 This is useful for guardrails that need to see the entire response, such as PII masking. 

456 

457 Triggered by mode: 'post_call' 

458 """ 

459 async for item in response: 

460 yield item 

461 

462 @staticmethod 

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

464 from litellm.types.proxy.guardrails.guardrail_hooks.dynamoai import ( 

465 DynamoAIGuardrailConfigModel, 

466 ) 

467 

468 return DynamoAIGuardrailConfigModel 

469 

470 @classmethod 

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

472 return [ 

473 GuardrailEventHooks.pre_call, 

474 GuardrailEventHooks.post_call, 

475 GuardrailEventHooks.during_call, 

476 ]