Coverage for .venv/lib/python3.13/site-packages/litellm/proxy/policy_engine/pipeline_executor.py: 18%

294 statements  

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

1""" 

2Pipeline Executor - Executes guardrail pipelines with conditional step logic. 

3 

4Runs guardrails sequentially per pipeline step definitions, handling 

5pass/fail actions (allow, block, next, modify_response) and data forwarding. 

6""" 

7 

8import copy 

9import time 

10from collections.abc import Callable, Mapping, Sequence 

11from typing import TYPE_CHECKING, Final, Literal, TypeVar 

12 

13from pydantic import BaseModel 

14 

15import litellm 

16from litellm._logging import verbose_proxy_logger 

17from litellm.constants import LOGS_GUARDRAIL_INFORMATION_MARKER 

18from litellm.integrations.custom_guardrail import ( 

19 CustomGuardrail, 

20 ModifyResponseException, 

21) 

22from litellm.integrations.custom_logger import CustomLogger 

23from litellm.litellm_core_utils.core_helpers import ( 

24 get_metadata_variable_name_from_kwargs, 

25 get_or_create_metadata_bucket, 

26 independent_snapshot, 

27) 

28from litellm.proxy.common_utils.callback_utils import add_guardrail_to_applied_guardrails_header 

29from litellm.proxy.guardrails.guardrail_hooks.unified_guardrail.unified_guardrail import ( 

30 UnifiedLLMGuardrails, 

31) 

32from litellm.types.proxy.policy_engine.pipeline_types import ( 

33 PipelineExecutionResult, 

34 PipelineStep, 

35 PipelineStepResult, 

36) 

37from litellm.types.utils import GenericGuardrailAPIInputs, StandardLoggingGuardrailInformation 

38 

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

40 from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj 

41 from litellm.llms.base_llm.guardrail_translation.base_translation import ( 

42 BaseTranslation, 

43 ) 

44 from litellm.proxy._types import UserAPIKeyAuth 

45 

46try: 

47 from fastapi.exceptions import HTTPException 

48except ImportError: 

49 HTTPException = None 

50 

51 

52class UndeliverableStreamRewrite(Exception): 

53 def __init__(self, guardrail_name: str, reason: str) -> None: 

54 super().__init__(guardrail_name, reason) 

55 self.guardrail_name: Final = guardrail_name 

56 self.reason: Final = reason 

57 

58 def __str__(self) -> str: 

59 return ( 

60 f"Guardrail '{self.guardrail_name}' rewrote the streamed response but the rewrite cannot be written " 

61 f"back to the stream: {self.reason}" 

62 ) 

63 

64 

65def _tool_call_shape(tool_call: object) -> tuple[object, object]: 

66 plain: Final = tool_call.model_dump() if isinstance(tool_call, BaseModel) else tool_call 

67 function: Final = plain.get("function") if isinstance(plain, Mapping) else None 

68 if not isinstance(function, Mapping): 

69 return (None, None) 

70 return (function.get("name"), function.get("arguments")) 

71 

72 

73def _text_snapshot(texts: Sequence[str] | None) -> tuple[str, ...] | None: 

74 return None if texts is None else tuple(texts) 

75 

76 

77def _scanned_texts(texts: Sequence[str] | None) -> tuple[str, ...]: 

78 return tuple(texts or ()) 

79 

80 

81def _tool_call_shapes(tool_calls: Sequence[object] | None) -> tuple[tuple[object, object], ...] | None: 

82 return None if tool_calls is None else tuple(_tool_call_shape(tool_call) for tool_call in tool_calls) 

83 

84 

85def _rewrote(sent: tuple[object, ...] | None, returned: tuple[object, ...] | None) -> bool: 

86 return sent is not None and returned is not None and returned != sent 

87 

88 

89def _count_change(sent: tuple[object, ...] | None, returned: tuple[object, ...] | None) -> tuple[int, int] | None: 

90 if sent is None or returned is None or len(returned) == len(sent): 

91 return None 

92 return (len(sent), len(returned)) 

93 

94 

95def _tool_call_mismatch_reason( 

96 sent: tuple[tuple[object, object], ...] | None, returned: tuple[tuple[object, object], ...] | None 

97) -> str | None: 

98 if sent == returned: 

99 return None 

100 sent_count: Final = len(sent or ()) 

101 returned_count: Final = len(returned or ()) 

102 if sent_count == returned_count: 

103 return "the legacy hook changed a tool call's name or arguments, which this path cannot write back" 

104 return f"the legacy hook returned {returned_count} tool calls for a stream that carried {sent_count}" 

105 

106 

107_GuardrailMethodT = TypeVar("_GuardrailMethodT", bound=Callable[..., object]) 

108 

109 

110def _logged_by_inner_guardrail(method: _GuardrailMethodT) -> _GuardrailMethodT: 

111 vars(method)[LOGS_GUARDRAIL_INFORMATION_MARKER] = True 

112 return method 

113 

114 

115class _StreamRewriteObserver(CustomGuardrail): 

116 """Stand-in handed to the endpoint translation in place of a streaming pipeline step's 

117 guardrail. It records whether the guardrail returned different output than it was given, 

118 which for guardrails like Bedrock's ANONYMIZED action is only known at runtime. Text and 

119 tool-call rewrites are deliverable on translations that write them back across the 

120 buffered chunks (``delivers_ended_stream_rewrites``); rewrites on any other translation, 

121 and a rewrite that drops or adds a tool call on any translation, are discarded by the 

122 executor, which releases the original chunks. 

123 The inner guardrail's ``apply_guardrail`` already records the guardrail information 

124 and span, so the observer's stays out of ``log_guardrail_information``.""" 

125 

126 def __init__(self, inner: CustomGuardrail) -> None: 

127 super().__init__(guardrail_name=inner.guardrail_name) 

128 self.inner: Final = inner 

129 self.rewrote_texts = False 

130 self.rewrote_tool_calls = False 

131 self.tool_call_count_change: tuple[int, int] | None = None 

132 

133 def structured_messages_cover_full_request(self) -> bool: 

134 return self.inner.structured_messages_cover_full_request() 

135 

136 @_logged_by_inner_guardrail 

137 async def apply_guardrail( 

138 self, 

139 inputs: GenericGuardrailAPIInputs, 

140 request_data: dict, # mutable-ok: matches CustomGuardrail.apply_guardrail 

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

142 logging_obj: "LiteLLMLoggingObj | None" = None, 

143 ) -> GenericGuardrailAPIInputs: 

144 sent_texts: Final = _text_snapshot(inputs.get("texts")) 

145 sent_tool_shapes: Final = _tool_call_shapes(inputs.get("tool_calls")) 

146 outputs: Final = await self.inner.apply_guardrail( 

147 inputs=inputs, request_data=request_data, input_type=input_type, logging_obj=logging_obj 

148 ) 

149 returned_tool_shapes: Final = _tool_call_shapes(outputs.get("tool_calls")) 

150 self.rewrote_texts = self.rewrote_texts or _rewrote(sent_texts, _text_snapshot(outputs.get("texts"))) 

151 self.rewrote_tool_calls = self.rewrote_tool_calls or _rewrote(sent_tool_shapes, returned_tool_shapes) 

152 self.tool_call_count_change = self.tool_call_count_change or _count_change( 

153 sent_tool_shapes, returned_tool_shapes 

154 ) 

155 return outputs 

156 

157 def discard_reason(self, deliver_rewrites: bool) -> str | None: 

158 if self.tool_call_count_change is not None: 

159 sent, returned = self.tool_call_count_change 

160 return ( 

161 f"the guardrail returned {returned} tool calls for a stream that carried {sent}, and a rewrite " 

162 "that drops or adds a tool call cannot be written back" 

163 ) 

164 if not deliver_rewrites and (self.rewrote_texts or self.rewrote_tool_calls): 

165 return "this endpoint's streaming pipeline does not write ended-stream rewrites back yet" 

166 return None 

167 

168 

169class _ScannedTextRecorder(CustomGuardrail): 

170 def __init__(self, guardrail_name: str) -> None: 

171 super().__init__(guardrail_name=guardrail_name) 

172 self.inputs: GenericGuardrailAPIInputs | None = None 

173 

174 @_logged_by_inner_guardrail 

175 async def apply_guardrail( 

176 self, 

177 inputs: GenericGuardrailAPIInputs, 

178 request_data: dict, # mutable-ok: matches CustomGuardrail.apply_guardrail 

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

180 logging_obj: "LiteLLMLoggingObj | None" = None, 

181 ) -> GenericGuardrailAPIInputs: 

182 self.inputs = inputs 

183 return inputs 

184 

185 

186class _LegacyHookStreamAdapter(CustomGuardrail): 

187 """Runs a guardrail that only implements the legacy post-call hook (no unified 

188 ``apply_guardrail``, or ``use_native_lifecycle_hooks``) as a streaming pipeline step. The 

189 endpoint translation hands it the texts it scanned plus the assembled response under 

190 ``request_data["response"]``; the hook gets that response in the shape its route gives 

191 non-streaming hooks, an exception it raises ends the stream through the executor's 

192 fail/error classification, and the response it hands back, or the one it changed in place 

193 and returned ``None`` for, is re-scanned by the same translation so its texts reach the 

194 client through the translation's ended-stream write-back. A 

195 replacement whose scanned texts do not line up with the originals, or whose tool calls 

196 differ from them, is undeliverable, so the executor releases the original chunks. A stream 

197 that carried no text to scan, such as a tool-only Anthropic message, stays deliverable as 

198 long as the hook left the tool calls alone.""" 

199 

200 def __init__( 

201 self, 

202 inner: CustomGuardrail, 

203 endpoint_translation: "BaseTranslation", 

204 user_api_key_dict: "UserAPIKeyAuth", 

205 ) -> None: 

206 super().__init__(guardrail_name=inner.guardrail_name) 

207 self.inner: Final = inner 

208 self.endpoint_translation: Final = endpoint_translation 

209 self.user_api_key_dict: Final = user_api_key_dict 

210 

211 def structured_messages_cover_full_request(self) -> bool: 

212 return self.inner.structured_messages_cover_full_request() 

213 

214 @_logged_by_inner_guardrail 

215 async def apply_guardrail( 

216 self, 

217 inputs: GenericGuardrailAPIInputs, 

218 request_data: dict, # mutable-ok: matches CustomGuardrail.apply_guardrail 

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

220 logging_obj: "LiteLLMLoggingObj | None" = None, 

221 ) -> GenericGuardrailAPIInputs: 

222 hooked: Final = self.endpoint_translation.post_call_hook_response(request_data.get("response")) 

223 replacement: Final = await self.inner.async_post_call_success_hook( 

224 data=request_data, 

225 user_api_key_dict=self.user_api_key_dict, 

226 response=hooked, 

227 ) 

228 rewrite: Final = hooked if replacement is None else replacement 

229 if rewrite is None: 

230 return inputs 

231 rescanned: Final = await self._rescan(rewrite, logging_obj) 

232 guardrail_name: Final = self.guardrail_name or "unknown" 

233 if rescanned is None: 

234 raise UndeliverableStreamRewrite( 

235 guardrail_name, "the legacy hook's response could not be rescanned by this endpoint's translation" 

236 ) 

237 rewritten: Final = rescanned.get("texts") 

238 returned_text_count: Final = len(_scanned_texts(rewritten)) 

239 sent_text_count: Final = len(_scanned_texts(inputs.get("texts"))) 

240 if returned_text_count != sent_text_count: 

241 raise UndeliverableStreamRewrite( 

242 guardrail_name, 

243 f"the legacy hook returned {returned_text_count} texts for a stream that carried {sent_text_count}", 

244 ) 

245 tool_call_mismatch: Final = _tool_call_mismatch_reason( 

246 _tool_call_shapes(inputs.get("tool_calls")), _tool_call_shapes(rescanned.get("tool_calls")) 

247 ) 

248 if tool_call_mismatch is not None: 

249 raise UndeliverableStreamRewrite(guardrail_name, tool_call_mismatch) 

250 if not rewritten: 

251 return inputs 

252 rewritten_inputs: Final[GenericGuardrailAPIInputs] = {**inputs, "texts": rewritten} 

253 return rewritten_inputs 

254 

255 async def _rescan( 

256 self, response: object, logging_obj: "LiteLLMLoggingObj | None" 

257 ) -> GenericGuardrailAPIInputs | None: 

258 recorder: Final = _ScannedTextRecorder(self.guardrail_name or "unknown") 

259 await self.endpoint_translation.process_output_response( 

260 response=response, 

261 guardrail_to_apply=recorder, 

262 litellm_logging_obj=logging_obj, 

263 user_api_key_dict=self.user_api_key_dict, 

264 ) 

265 return recorder.inputs 

266 

267 

268def _prepare_hook_input( 

269 step: PipelineStep, 

270 callback: CustomGuardrail, 

271 data: dict, # mutable-ok: same request-payload shape the hooks mutate 

272 raw_request_snapshot: dict | None, # mutable-ok: same request-payload shape as data 

273) -> tuple[dict, bool]: # mutable-ok: returns that same request-payload dict 

274 """Inject the step's guardrail name into metadata so should_run_guardrail() allows it, 

275 and pick the payload the step scans: a scan_raw_request step evaluates the pristine 

276 pre-pipeline snapshot instead of `data` (which earlier pass_data steps in this same 

277 pipeline may have already rewritten), same reason the normal sequential/parallel 

278 guardrail loops do this.""" 

279 if "metadata" not in data: 

280 data["metadata"] = {} # mutable-ok: request metadata bucket, hooks mutate it 

281 data["metadata"]["guardrails"] = [step.guardrail] 

282 

283 scans_raw_request: Final = callback.scan_raw_request 

284 hook_input: Final[dict] = ( # mutable-ok: same request-payload shape as data 

285 independent_snapshot(raw_request_snapshot) if scans_raw_request and raw_request_snapshot is not None else data 

286 ) 

287 if hook_input is not data: 

288 hook_input.setdefault("metadata", {})["guardrails"] = [step.guardrail] # mutable-ok: request metadata shape 

289 return hook_input, scans_raw_request 

290 

291 

292def _release_original_chunks( 

293 guardrail_name: str, 

294 reason: str, 

295 streaming_chunks: list[object], # mutable-ok: shared buffered-stream chunks, restored in place 

296 originals: Sequence[object], 

297) -> None: 

298 streaming_chunks[:] = originals # rebind-ok: the caller's buffer is the stream the client receives 

299 verbose_proxy_logger.warning( 

300 "Pipeline: guardrail '%s' rewrote the streamed response but the rewrite could not be written back to " 

301 "the stream: %s. The whole rewrite, text rewrites included, was discarded and the original stream released", 

302 guardrail_name, 

303 reason, 

304 ) 

305 

306 

307class PipelineExecutor: 

308 """Executes guardrail pipelines with ordered, conditional step logic.""" 

309 

310 @staticmethod 

311 async def execute_steps( 

312 steps: list[PipelineStep], 

313 mode: str, 

314 data: dict, 

315 user_api_key_dict: "UserAPIKeyAuth", 

316 call_type: str, 

317 policy_name: str, 

318 raw_request_snapshot: dict | None = None, # mutable-ok: same request-payload shape as data 

319 streaming_chunks: list[object] | None = None, # mutable-ok: shared buffered-stream chunks, read per step 

320 endpoint_translation: "BaseTranslation | None" = None, 

321 ) -> PipelineExecutionResult: 

322 """ 

323 Execute pipeline steps sequentially with conditional actions. 

324 

325 Args: 

326 steps: Ordered list of pipeline steps 

327 mode: Event hook mode (pre_call, post_call) 

328 data: Request data dict 

329 user_api_key_dict: User API key auth 

330 call_type: Type of call (completion, etc.) 

331 policy_name: Name of the owning policy (for logging) 

332 raw_request_snapshot: pristine pre-pipeline, pre-guardrail request 

333 (taken by the caller before any guardrail or pipeline ran), so a 

334 step whose guardrail opted into ``scan_raw_request`` evaluates 

335 the original request instead of whatever an earlier 

336 ``pass_data`` step in this same pipeline already rewrote. 

337 streaming_chunks: buffered chunks of a completed stream. When set 

338 (with ``endpoint_translation``), post_call steps scan the 

339 assembled streamed output through the endpoint translation 

340 instead of calling ``async_post_call_success_hook``. 

341 endpoint_translation: the guardrail translation for the streamed 

342 endpoint, resolved by the caller. 

343 

344 Returns: 

345 PipelineExecutionResult with terminal action and step results 

346 """ 

347 step_results: Final[list[PipelineStepResult]] = [] 

348 working_data = data.copy() 

349 if "metadata" in working_data: 

350 working_data["metadata"] = working_data["metadata"].copy() 

351 

352 for i, step in enumerate(steps): 

353 start_time = time.perf_counter() 

354 

355 ( 

356 outcome, 

357 modified_data, 

358 error_detail, 

359 original_exception, 

360 ) = await PipelineExecutor._run_step( 

361 step=step, 

362 mode=mode, 

363 data=working_data, 

364 user_api_key_dict=user_api_key_dict, 

365 call_type=call_type, 

366 raw_request_snapshot=raw_request_snapshot, 

367 streaming_chunks=streaming_chunks, 

368 endpoint_translation=endpoint_translation, 

369 ) 

370 

371 duration = time.perf_counter() - start_time 

372 

373 action = _pipeline_action_for_outcome(step, outcome) 

374 

375 step_result = PipelineStepResult( 

376 guardrail_name=step.guardrail, 

377 outcome=outcome, 

378 action_taken=action, 

379 modified_data=modified_data, 

380 error_detail=error_detail, 

381 duration_seconds=round(duration, 4), 

382 ) 

383 step_results.append(step_result) 

384 

385 verbose_proxy_logger.debug( 

386 "Pipeline '%s' step %s: guardrail=%s, outcome=%s, action=%s", 

387 policy_name, 

388 i, 

389 step.guardrail, 

390 outcome, 

391 action, 

392 ) 

393 

394 # Forward modified data to the next step if pass_data is True; 

395 # post_call response replacements always chain, matching the flat 

396 # callback loop where each hook sees the previous hook's response 

397 if modified_data is not None and (step.pass_data or mode == "post_call"): 

398 working_data = {**working_data, **modified_data} 

399 

400 # Handle terminal actions 

401 if action == "allow": 

402 return _allow_result(step_results=step_results, working_data=working_data, request_data=data) 

403 

404 if action == "block": 

405 _carry_working_guardrail_information(working_data=working_data, request_data=data) 

406 return PipelineExecutionResult( 

407 terminal_action="block", 

408 step_results=step_results, 

409 error_message=error_detail, 

410 original_exception=original_exception, 

411 modified_data=working_data if working_data != data else None, 

412 ) 

413 

414 if action == "modify_response": 

415 _carry_working_guardrail_information(working_data=working_data, request_data=data) 

416 return PipelineExecutionResult( 

417 terminal_action="modify_response", 

418 step_results=step_results, 

419 modify_response_message=step.modify_response_message or error_detail, 

420 modified_data=working_data if working_data != data else None, 

421 ) 

422 

423 # action == "next" → continue to next step 

424 

425 # Ran out of steps without a terminal action → default allow 

426 return _allow_result(step_results=step_results, working_data=working_data, request_data=data) 

427 

428 @staticmethod 

429 async def _run_streaming_step( 

430 step: PipelineStep, 

431 callback: CustomGuardrail, 

432 endpoint_translation: "BaseTranslation", 

433 streaming_chunks: list[object], # mutable-ok: shared buffered-stream chunks the translation rewrites in place 

434 hook_input: dict[str, object], # mutable-ok: same request-payload shape as data 

435 user_api_key_dict: "UserAPIKeyAuth", 

436 litellm_logging_obj: "LiteLLMLoggingObj | None", 

437 ) -> None: 

438 """Run one streaming post_call step through the endpoint translation, delivering 

439 text and tool-call rewrites on translations that support ended-stream write-back. A 

440 guardrail without the unified interface runs its legacy post-call hook against the 

441 assembled response through ``_LegacyHookStreamAdapter``. A rewrite that cannot reach the 

442 client yet (one on a translation without write-back, one that drops or adds a tool call, 

443 or one the translation or adapter refused with ``UndeliverableStreamRewrite``) is 

444 discarded: the buffered chunks go back to the originals and the step passes, so the 

445 client gets the stream the merge base sent, and the guardrail stays out of the 

446 applied-guardrails header since its output never reached the client. The response an 

447 earlier step's translation stored under ``request_data["response"]`` is dropped first, 

448 so this step's hook sees the stream as the steps before it left it.""" 

449 scanner: Final = ( 

450 callback 

451 if PipelineExecutor.supports_unified_execution(callback) 

452 else _LegacyHookStreamAdapter(callback, endpoint_translation, user_api_key_dict) 

453 ) 

454 observer: Final = _StreamRewriteObserver(scanner) 

455 deliver_rewrites: Final = type(endpoint_translation).delivers_ended_stream_rewrites 

456 originals: Final = copy.deepcopy(streaming_chunks) 

457 hook_input.pop("response", None) 

458 try: 

459 if deliver_rewrites: 

460 await endpoint_translation.process_output_streaming_response( 

461 responses_so_far=streaming_chunks, 

462 guardrail_to_apply=observer, 

463 litellm_logging_obj=litellm_logging_obj, 

464 user_api_key_dict=user_api_key_dict, 

465 request_data=hook_input, 

466 deliver_ended_stream_rewrites=True, 

467 ) 

468 else: 

469 await endpoint_translation.process_output_streaming_response( 

470 responses_so_far=streaming_chunks, 

471 guardrail_to_apply=observer, 

472 litellm_logging_obj=litellm_logging_obj, 

473 user_api_key_dict=user_api_key_dict, 

474 request_data=hook_input, 

475 ) 

476 except UndeliverableStreamRewrite as undeliverable: 

477 _release_original_chunks(step.guardrail, undeliverable.reason, streaming_chunks, originals) 

478 return 

479 discard_reason: Final = observer.discard_reason(deliver_rewrites) 

480 if discard_reason is not None: 

481 _release_original_chunks(step.guardrail, discard_reason, streaming_chunks, originals) 

482 return 

483 if not callback.records_own_guardrail_information: 

484 add_guardrail_to_applied_guardrails_header(request_data=hook_input, guardrail_name=step.guardrail) 

485 

486 @staticmethod 

487 async def _run_step( 

488 step: PipelineStep, 

489 mode: str, 

490 data: dict, 

491 user_api_key_dict: "UserAPIKeyAuth", 

492 call_type: str, 

493 raw_request_snapshot: dict | None = None, # mutable-ok: same request-payload shape as data 

494 streaming_chunks: list[object] | None = None, # mutable-ok: shared buffered-stream chunks, read per step 

495 endpoint_translation: "BaseTranslation | None" = None, 

496 ) -> tuple[ 

497 Literal["pass", "fail", "error"], 

498 dict | None, 

499 str | None, 

500 Exception | None, 

501 ]: 

502 """ 

503 Run a single pipeline step's guardrail. 

504 

505 Returns: 

506 Tuple of (outcome, modified_data, error_detail, original_exception): 

507 - outcome: "pass", "fail", or "error" 

508 - modified_data: dict if guardrail returned modified data, else None 

509 - error_detail: error message string if fail/error, else None 

510 - original_exception: the exception the guardrail raised, so the 

511 pipeline can re-raise it verbatim and match the direct-attachment 

512 response/trace, else None 

513 """ 

514 callback: Final = PipelineExecutor.find_guardrail_callback(step.guardrail) 

515 if callback is None: 

516 verbose_proxy_logger.warning("Pipeline: guardrail '%s' not found in callbacks", step.guardrail) 

517 return ("error", None, f"Guardrail '{step.guardrail}' not found", None) 

518 

519 hook_input, scans_raw_request = _prepare_hook_input(step, callback, data, raw_request_snapshot) 

520 snapshot_entries_before: Final = len(_recorded_guardrail_information(hook_input)) 

521 

522 # Use unified_guardrail path if callback implements apply_guardrail 

523 target: CustomLogger = callback 

524 use_unified: Final = PipelineExecutor.supports_unified_execution(callback) 

525 if use_unified and streaming_chunks is None: 

526 hook_input["guardrail_to_apply"] = callback 

527 target = UnifiedLLMGuardrails() 

528 

529 try: 

530 if mode == "pre_call": 

531 response = await target.async_pre_call_hook( 

532 user_api_key_dict=user_api_key_dict, 

533 cache=None, 

534 data=hook_input, 

535 call_type=call_type, 

536 ) 

537 if isinstance(callback, CustomGuardrail): 

538 callback.mark_pre_call_hook_ran(data) 

539 if isinstance(response, dict): 

540 callback.mark_pre_call_hook_ran(response) 

541 elif mode == "post_call" and streaming_chunks is not None: 

542 if endpoint_translation is None: 

543 return ( 

544 "error", 

545 None, 

546 f"Guardrail '{step.guardrail}' cannot run on a stream without an endpoint translation", 

547 None, 

548 ) 

549 await PipelineExecutor._run_streaming_step( 

550 step=step, 

551 callback=callback, 

552 endpoint_translation=endpoint_translation, 

553 streaming_chunks=streaming_chunks, 

554 hook_input=hook_input, 

555 user_api_key_dict=user_api_key_dict, 

556 litellm_logging_obj=data.get("litellm_logging_obj"), 

557 ) 

558 response = None 

559 elif mode == "post_call": 

560 response = await target.async_post_call_success_hook( 

561 user_api_key_dict=user_api_key_dict, 

562 data=data, 

563 response=data.get("response"), 

564 ) 

565 else: 

566 return ("error", None, f"Unsupported pipeline mode: {mode}", None) 

567 

568 # Normal return means pass. A scan_raw_request step is block-only, 

569 # same contract as run_in_parallel/scan_raw_request elsewhere: any 

570 # data it returned is discarded, since applying it on top of the 

571 # raw snapshot would silently undo whatever an earlier step in 

572 # this pipeline already did. A post_call hook's non-None return is 

573 # a replacement response (the flat callback-loop contract), carried 

574 # under the same "response" key the step input uses. 

575 if response is None or scans_raw_request: 

576 return ("pass", None, None, None) 

577 if mode == "post_call": 

578 return ( 

579 "pass", 

580 {"response": response}, 

581 None, 

582 None, 

583 ) 

584 return ("pass", response if isinstance(response, dict) else None, None, None) 

585 

586 except Exception as e: 

587 if CustomGuardrail._is_guardrail_intervention(e): 

588 error_msg: Final = _extract_error_message(e) 

589 return ("fail", None, error_msg, e) 

590 else: 

591 verbose_proxy_logger.error("Pipeline: unexpected error from guardrail '%s': %s", step.guardrail, e) 

592 return ("error", None, str(e), e) 

593 finally: 

594 if hook_input is not data: 

595 _append_guardrail_information( 

596 request_data=data, 

597 entries=_recorded_guardrail_information(hook_input)[snapshot_entries_before:], 

598 ) 

599 

600 @staticmethod 

601 def supports_unified_execution(callback: CustomGuardrail) -> bool: 

602 """Whether this guardrail runs through the unified apply_guardrail path.""" 

603 return "apply_guardrail" in type(callback).__dict__ and not callback.use_native_lifecycle_hooks 

604 

605 @staticmethod 

606 def supports_streaming_execution(callback: CustomGuardrail) -> bool: 

607 """Whether a streaming pipeline step can run this guardrail against the buffered 

608 stream: through the unified path, or through its post-call hook on the assembled 

609 response when that hook is its only streaming path. A guardrail with its own 

610 streaming iterator hook, or with neither hook, keeps running on its own.""" 

611 callback_type: Final = type(callback) 

612 return PipelineExecutor.supports_unified_execution(callback) or ( 

613 callback_type.async_post_call_success_hook is not CustomLogger.async_post_call_success_hook 

614 and callback_type.async_post_call_streaming_iterator_hook 

615 is CustomLogger.async_post_call_streaming_iterator_hook 

616 ) 

617 

618 @staticmethod 

619 def find_guardrail_callback(guardrail_name: str) -> CustomGuardrail | None: 

620 """Look up an initialized guardrail callback by name from litellm.callbacks.""" 

621 for callback in litellm.callbacks: 

622 if isinstance(callback, CustomGuardrail): 

623 if callback.guardrail_name == guardrail_name: 

624 return callback 

625 return None 

626 

627 

628def _allow_result( 

629 step_results: Sequence[PipelineStepResult], 

630 working_data: dict, # mutable-ok: same request-payload shape as execute_steps' data 

631 request_data: dict, # mutable-ok: same request-payload shape as execute_steps' data 

632) -> PipelineExecutionResult: 

633 """Build the terminal-allow result, propagating pipeline modifications without the per-step guardrail override.""" 

634 restored: Final = _restore_request_guardrails(working_data, request_data) 

635 return PipelineExecutionResult( 

636 terminal_action="allow", 

637 step_results=list(step_results), # mutable-ok: PipelineExecutionResult field is a list 

638 modified_data=restored if restored != request_data else None, 

639 ) 

640 

641 

642def _restore_request_guardrails( 

643 working_data: dict, # mutable-ok: same request-payload shape as execute_steps' data 

644 request_data: dict, # mutable-ok: same request-payload shape as execute_steps' data 

645) -> dict: # mutable-ok: merged back into the request dict, which downstream code mutates 

646 """ 

647 Restore the request's own metadata["guardrails"] activation list. 

648 

649 _run_step overrides it to [step.guardrail] so should_run_guardrail() allows each 

650 step; letting that override escape via modified_data permanently drops every 

651 independently activated guardrail from later lifecycle stages (post_call, etc.). 

652 """ 

653 working_metadata: Final = working_data.get("metadata") 

654 if not isinstance(working_metadata, dict): 

655 return working_data 

656 request_metadata: Final = request_data.get("metadata") 

657 original_guardrails: Final = request_metadata.get("guardrails") if isinstance(request_metadata, dict) else None 

658 stripped: Final = {k: v for k, v in working_metadata.items() if k != "guardrails"} # mutable-ok: request dict 

659 if original_guardrails is not None: 

660 restored: Final = {**stripped, "guardrails": original_guardrails} # mutable-ok: request dict 

661 return {**working_data, "metadata": restored} # mutable-ok: request dict 

662 if not stripped and not isinstance(request_metadata, dict): 

663 return {k: v for k, v in working_data.items() if k != "metadata"} # mutable-ok: request dict 

664 return {**working_data, "metadata": stripped} # mutable-ok: request dict 

665 

666 

667_GUARDRAIL_INFORMATION_KEY: Final = "standard_logging_guardrail_information" 

668 

669 

670def _recorded_guardrail_information(source: Mapping[str, object]) -> list[StandardLoggingGuardrailInformation]: 

671 bucket: Final = source.get(get_metadata_variable_name_from_kwargs(source)) 

672 recorded: Final = bucket.get(_GUARDRAIL_INFORMATION_KEY) if isinstance(bucket, dict) else None 

673 return recorded if isinstance(recorded, list) else [] 

674 

675 

676def _append_guardrail_information( 

677 request_data: dict[str, object], # mutable-ok: same request-payload shape as execute_steps' data 

678 entries: Sequence[StandardLoggingGuardrailInformation], 

679) -> None: 

680 if not entries: 

681 return 

682 _, request_bucket = get_or_create_metadata_bucket(request_data) 

683 existing: Final = request_bucket.get(_GUARDRAIL_INFORMATION_KEY) 

684 if isinstance(existing, list): 

685 existing.extend(entries) 

686 return 

687 request_bucket[_GUARDRAIL_INFORMATION_KEY] = list(entries) 

688 

689 

690def _carry_working_guardrail_information( 

691 working_data: Mapping[str, object], 

692 request_data: dict[str, object], # mutable-ok: same request-payload shape as execute_steps' data 

693) -> None: 

694 recorded: Final = _recorded_guardrail_information(working_data) 

695 existing: Final = _recorded_guardrail_information(request_data) 

696 if recorded is existing: 

697 return 

698 _append_guardrail_information(request_data=request_data, entries=[e for e in recorded if e not in existing]) 

699 

700 

701def _pipeline_action_for_outcome(step: PipelineStep, outcome: str) -> str: 

702 """ 

703 Map pipeline step outcome to the configured action. 

704 

705 - pass -> on_pass 

706 - fail -> on_fail (content/policy intervention) 

707 - error -> on_error if set, else on_fail (backward compatible) 

708 """ 

709 if outcome == "pass": 

710 return step.on_pass 

711 if outcome == "fail": 

712 return step.on_fail 

713 if step.on_error is not None: 

714 return step.on_error 

715 return step.on_fail 

716 

717 

718def _extract_error_message(e: Exception) -> str: 

719 """Extract a human-readable error message from a guardrail exception.""" 

720 if isinstance(e, ModifyResponseException): 

721 return str(e) 

722 if HTTPException is not None and isinstance(e, HTTPException): 

723 detail: Final[object] = getattr(e, "detail", None) 

724 if detail: 

725 return str(detail) 

726 return str(e)