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

1125 statements  

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

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

2# 

3# Use Bedrock Guardrails for your LLM calls 

4# 

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

6# Thank you users! We ❤️ you! - Krrish & Ishaan 

7 

8import os 

9import sys 

10 

11sys.path.insert(0, os.path.abspath("../..")) # Adds the parent directory to the system path 

12import asyncio 

13import copy 

14import json 

15import re 

16import sys 

17import time 

18from collections.abc import AsyncGenerator, Mapping, Sequence 

19from datetime import datetime, timezone 

20from itertools import accumulate, groupby 

21from types import MappingProxyType 

22from typing import TYPE_CHECKING, Any, ClassVar, Final, Literal, NamedTuple, Optional, cast 

23 

24import httpx 

25from fastapi import HTTPException 

26from pydantic import TypeAdapter, ValidationError 

27 

28import litellm 

29from litellm._logging import verbose_proxy_logger 

30from litellm.caching import DualCache 

31from litellm.constants import BEDROCK_APPLY_GUARDRAIL_CHUNK_BUDGET_CHARS 

32from litellm.exceptions import ModifyResponseException 

33from litellm.integrations.custom_guardrail import CustomGuardrail 

34from litellm.litellm_core_utils.api_route_to_call_types import get_call_types_for_route 

35from litellm.litellm_core_utils.core_helpers import redact_nested_match_and_regex_keys 

36from litellm.litellm_core_utils.litellm_logging import ( 

37 _get_masked_values, # pyright: ignore[reportPrivateUsage] # the shared header-masking helper has no public name 

38) 

39from litellm.litellm_core_utils.llm_cost_calc.guardrail_cost import ( 

40 bedrock_guardrail_cost_by_unit, 

41 guardrail_cost_total, 

42) 

43from litellm.llms.anthropic.chat.guardrail_translation.handler import AnthropicMessagesHandler 

44from litellm.llms.base_llm.guardrail_translation.utils import ( 

45 effective_scan_only_tool_results_for_guardrail, 

46) 

47from litellm.llms.bedrock.base_aws_llm import BaseAWSLLM, bedrock_bearer_token, run_aws_signing 

48from litellm.llms.custom_httpx.http_handler import ( 

49 get_async_httpx_client, 

50 httpxSpecialProvider, 

51) 

52from litellm.proxy._types import UserAPIKeyAuth 

53from litellm.proxy.common_request_processing import serialize_http_exception_detail 

54from litellm.proxy.common_utils.sse_keepalive import keepalive_ping_has_fired 

55from litellm.proxy.guardrails.anthropic_sse import ( 

56 anthropic_sse_chunks_from_response, 

57 anthropic_sse_error_frames, 

58 assemble_anthropic_sse_stream, 

59 is_raw_sse_stream, 

60 model_response_text, 

61) 

62from litellm.types.guardrails import ( 

63 BedrockChecksConfigModel, 

64 BedrockGuardrailStreamingParams, 

65 GuardrailEventHooks, 

66 LitellmParams, 

67) 

68from litellm.types.llms.openai import AllMessageValues, ChatCompletionUserMessage 

69from litellm.types.proxy.guardrails.guardrail_hooks.bedrock_guardrails import ( 

70 BedrockChecksMessage, 

71 BedrockChecksViolation, 

72 BedrockContentItem, 

73 BedrockGuardrailChecksResponse, 

74 BedrockGuardrailOutput, 

75 BedrockGuardrailQualifier, 

76 BedrockGuardrailResponse, 

77 BedrockGuardrailUsage, 

78 BedrockRequest, 

79 BedrockTextContent, 

80) 

81from litellm.types.utils import GenericGuardrailAPIInputs 

82 

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

84 from botocore.awsrequest import AWSPreparedRequest 

85 from botocore.credentials import Credentials 

86 

87 from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj 

88 

89from litellm.types.utils import ( 

90 CallTypes, 

91 CallTypesLiteral, 

92 Choices, 

93 GuardrailStatus, 

94 GuardrailTracingDetail, 

95 Message, 

96 ModelResponse, 

97 ModelResponseStream, 

98 StreamingChoices, 

99 TextChoices, 

100) 

101 

102GUARDRAIL_NAME: Final = "bedrock" 

103_BEDROCK_DYNAMIC_BODY_DENYLIST: Final = frozenset({"content", "source"}) 

104_BEDROCK_TOO_LARGE_ERROR_SUBSTRINGS: Final = ( 

105 "text unit", 

106 "maximum input size", 

107 "content size", 

108 "too long", 

109 "too large", 

110 "exceeds the maximum", 

111) 

112_BEDROCK_APPLY_GUARDRAIL_MAX_THROTTLE_RETRIES: Final = 3 

113_BEDROCK_APPLY_GUARDRAIL_BASE_BACKOFF_SECONDS: Final = 0.5 

114_BEDROCK_WHITESPACE: Final = re.compile(r"\s") 

115_NO_TRACING_DETAIL: Final[GuardrailTracingDetail] = {} 

116# Resource-less, detect-only InvokeGuardrailChecks API (no guardrail resource required). 

117_BEDROCK_INVOKE_GUARDRAIL_CHECKS_PATH: Final = "/guardrail-checks/invoke" 

118# InvokeGuardrailChecks accepts at most 10 content blocks per message. A message with 

119# more text blocks is split across multiple messages so ALL content is scanned -- 

120# never truncated (truncation would let a user hide content past the limit). 

121_BEDROCK_CHECKS_MAX_CONTENT_BLOCKS: Final = 10 

122_BEDROCK_CHECKS_KNOWN_KEYS: Final = frozenset({"contentFilter", "promptAttack", "sensitiveInformation"}) 

123# Keys in a sensitiveInformation result that pinpoint the PII location. They are 

124# stripped before the response is handed to standard logging / telemetry so the 

125# detected PII span cannot be reconstructed from logs. 

126_BEDROCK_CHECKS_PII_LOCATION_KEYS: Final = ( 

127 "beginOffset", 

128 "endOffset", 

129 "messageIndex", 

130 "contentIndex", 

131) 

132 

133# Maps an OpenAI message content-block ``type`` to the Bedrock guardrail qualifier 

134# it represents, so callers can drive contextual grounding by tagging their content. 

135# The model response is qualified as ``guard_content`` directly by the OUTPUT builder; 

136# the existing ``guarded_text`` marker is intentionally left unmapped here so its 

137# guardrail-hook payload is unchanged by this feature. 

138_CONTENT_TYPE_TO_QUALIFIER: Final[dict[str, BedrockGuardrailQualifier]] = { 

139 "grounding_source": "grounding_source", 

140 "query": "query", 

141} 

142 

143# Roles whose ``grounding_source`` blocks are trusted as reference material for the 

144# contextual-grounding check. Only app-authored roles qualify: ``tool``/``function`` 

145# results and ``user`` content can carry caller- or externally-influenced text, which 

146# must not be graded against as if it were the application's own source material. 

147_GROUNDING_SOURCE_TRUSTED_ROLES: Final = frozenset({"system", "developer"}) 

148 

149 

150class QualifiedTextBlock(NamedTuple): 

151 """A piece of message text paired with its Bedrock grounding qualifier (if any).""" 

152 

153 text: str 

154 qualifier: BedrockGuardrailQualifier | None 

155 

156 

157class GuardrailMessageFilterResult(NamedTuple): 

158 payload_messages: list[AllMessageValues] | None 

159 original_messages: list[AllMessageValues] | None 

160 target_indices: list[int] | None 

161 

162 

163class BedrockContentChunkResult(NamedTuple): 

164 """One chunk's ApplyGuardrail response, paired with enough bookkeeping to 

165 reconstruct global masked-output positions once every chunk is back. 

166 

167 `content` is the exact content items this chunk was called with -- needed 

168 so an all-clear chunk (empty `outputs`) can still contribute one unmasked 

169 placeholder per item it covers, keeping every later chunk's masked text 

170 aligned to its original global position. `fragment_group_size` is 1 for an 

171 ordinary chunk, and otherwise the total number of consecutive chunk results 

172 that together make up ONE original content item's own text (split because a 

173 list of length 1 could not be bisected by list length). All of them must be 

174 concatenated back into that one item's masked output rather than treated as 

175 separate items. It is a count rather than a boolean because one item can be 

176 bisected more than once: two levels of splitting produce four fragments for 

177 a single item, not two, and grouping them in fixed pairs would emit two 

178 outputs for one message and shift every later message's masked text. 

179 """ 

180 

181 response: BedrockGuardrailResponse 

182 content: tuple[BedrockContentItem, ...] 

183 fragment_group_size: int 

184 

185 

186class ApplyGuardrailMessageSelection(NamedTuple): 

187 """Messages selected for an apply_guardrail scan + write-back metadata.""" 

188 

189 filtered_messages: list[AllMessageValues] | None 

190 # Slice of the flat `texts` list actually scanned (offset, length), 

191 # used to write masked content back to the right positions. None = whole list. 

192 scanned_slice: tuple[int, int] | None 

193 # True when messages were selected by their original role. 

194 scanned_role_subset: bool 

195 # True when there is nothing to scan (e.g. no user-role message). 

196 skip_scan: bool = False 

197 

198 

199def _redact_pii_matches(response_json: dict) -> dict: 

200 """ 

201 Redact match-like fields from a Bedrock ApplyGuardrail JSON payload. 

202 

203 Delegates to :func:`redact_nested_match_and_regex_keys` (same rules as spend 

204 logging). Kept as a Bedrock-module entry point for existing unit tests. 

205 """ 

206 redacted: Final = redact_nested_match_and_regex_keys(response_json) 

207 return redacted if isinstance(redacted, dict) else response_json 

208 

209 

210def _redact_assessment_match_fields(assessments: list[dict]) -> list[dict]: 

211 """ 

212 Redact sensitive match-like fields from blocked assessment summaries. 

213 

214 This is used for customer-visible error payloads (HTTPException.detail) where 

215 we want to preserve policy/type/action metadata without echoing raw matched 

216 content. 

217 """ 

218 redacted: Final = redact_nested_match_and_regex_keys(assessments) 

219 return redacted if isinstance(redacted, list) else assessments 

220 

221 

222_RESPONSES_API_CALL_TYPES: Final = frozenset({CallTypes.responses, CallTypes.aresponses}) 

223 

224 

225def _is_responses_api_route(request_route: str | None) -> bool: 

226 if request_route is None: 

227 return False 

228 call_types: Final = get_call_types_for_route(request_route) 

229 return call_types is not None and any(call_type in _RESPONSES_API_CALL_TYPES for call_type in call_types) 

230 

231 

232class BedrockGuardrail(CustomGuardrail, BaseAWSLLM): 

233 # During-call must use async_moderation_hook (not unified apply_guardrail), otherwise 

234 # OpenAI translation always passes input_type="request" and spend/UI show PRE-CALL. 

235 use_native_during_call_hook: ClassVar[bool] = True 

236 

237 def __init__( 

238 self, 

239 guardrailIdentifier: str | None = None, 

240 guardrailVersion: str | None = None, 

241 disable_exception_on_block: bool | None = False, 

242 checks: BedrockChecksConfigModel | Mapping[str, object] | None = None, 

243 content_filter_threshold: float | None = 0.5, 

244 prompt_attack_threshold: float | None = 0.5, 

245 pii_confidence_threshold: float | None = 0.5, 

246 chunk_budget_chars: int = BEDROCK_APPLY_GUARDRAIL_CHUNK_BUDGET_CHARS, 

247 contextual_grounding_from_messages: bool = False, 

248 streaming_buffer_until_moderated: bool | None = None, 

249 streaming_sampling_rate: int | None = None, 

250 streaming_end_of_stream_only: bool | None = None, 

251 streaming_buffer_release_on_scan: bool | None = None, 

252 **kwargs, 

253 ): 

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

255 self._set_streaming_params( 

256 BedrockGuardrailStreamingParams.from_extras( 

257 MappingProxyType( 

258 { 

259 "streaming_buffer_until_moderated": streaming_buffer_until_moderated, 

260 "streaming_sampling_rate": streaming_sampling_rate, 

261 "streaming_end_of_stream_only": streaming_end_of_stream_only, 

262 "streaming_buffer_release_on_scan": streaming_buffer_release_on_scan, 

263 } 

264 ) 

265 ) 

266 ) 

267 self.guardrailIdentifier = guardrailIdentifier 

268 self.guardrailVersion = guardrailVersion 

269 self.guardrail_provider = "bedrock" 

270 self.chunk_budget_chars = chunk_budget_chars 

271 self.contextual_grounding_from_messages = contextual_grounding_from_messages 

272 self.experimental_use_latest_role_message_only = bool(kwargs.get("experimental_use_latest_role_message_only")) 

273 

274 # Resource-less, detect-only InvokeGuardrailChecks mode. Present `checks` 

275 # routes the guardrail to InvokeGuardrailChecks; absent => ApplyGuardrail. 

276 self.checks: dict[str, object] | None = self._normalize_checks(checks) 

277 # Per-check block thresholds; a score >= threshold blocks. None => the 

278 # check is detect-only (logged, never blocks). 

279 self.content_filter_threshold = content_filter_threshold 

280 self.prompt_attack_threshold = prompt_attack_threshold 

281 self.pii_confidence_threshold = pii_confidence_threshold 

282 

283 # store kwargs as optional_params 

284 self.optional_params = kwargs 

285 

286 self.disable_exception_on_block: bool = disable_exception_on_block or False 

287 """ 

288 If True, will not raise an exception when the guardrail is blocked. 

289 """ 

290 

291 # `checks` (InvokeGuardrailChecks) and `guardrailIdentifier`/`guardrailVersion` 

292 # (ApplyGuardrail) are two different APIs; configuring both is ambiguous. 

293 if self.checks is not None and (self.guardrailIdentifier is not None or self.guardrailVersion is not None): 

294 raise ValueError( 

295 "Bedrock guardrail accepts either 'guardrailIdentifier'/'guardrailVersion' (ApplyGuardrail) " 

296 "or 'checks' (InvokeGuardrailChecks), not both." 

297 ) 

298 

299 # Set supported event hooks to include MCP hooks 

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

301 

302 super().__init__(**kwargs) 

303 BaseAWSLLM.__init__(self) 

304 

305 # InvokeGuardrailChecks is detect-only: it never returns rewritten content, 

306 # so masking has no effect in checks mode. 

307 if self.checks is not None and ( 

308 getattr(self, "mask_request_content", False) or getattr(self, "mask_response_content", False) 

309 ): 

310 verbose_proxy_logger.warning( 

311 "Bedrock Guardrail: mask_request_content/mask_response_content have no " 

312 "effect with 'checks' (InvokeGuardrailChecks is detect-only)." 

313 ) 

314 

315 verbose_proxy_logger.debug( 

316 "Bedrock Guardrail initialized with guardrailIdentifier: %s, guardrailVersion: %s, checks: %s", 

317 self.guardrailIdentifier, 

318 self.guardrailVersion, 

319 list(self.checks.keys()) if self.checks else None, 

320 ) 

321 

322 def _set_streaming_params(self, streaming_params: BedrockGuardrailStreamingParams) -> None: 

323 self.streaming_buffer_until_moderated = streaming_params.streaming_buffer_until_moderated 

324 self.streaming_sampling_rate = streaming_params.streaming_sampling_rate 

325 self.streaming_end_of_stream_only = streaming_params.streaming_end_of_stream_only 

326 self.streaming_buffer_release_on_scan = streaming_params.streaming_buffer_release_on_scan 

327 

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

329 super().update_in_memory_litellm_params(litellm_params) 

330 self._set_streaming_params(BedrockGuardrailStreamingParams.from_extras(litellm_params.model_extra)) 

331 

332 def _streams_incrementally(self) -> bool: 

333 if self.mask_response_content: 

334 return False 

335 if not self.streaming_buffer_until_moderated: 

336 return True 

337 return self.streaming_buffer_release_on_scan and not self.streaming_end_of_stream_only 

338 

339 @classmethod 

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

341 return [ 

342 GuardrailEventHooks.pre_call, 

343 GuardrailEventHooks.post_call, 

344 GuardrailEventHooks.during_call, 

345 GuardrailEventHooks.pre_mcp_call, 

346 GuardrailEventHooks.during_mcp_call, 

347 ] 

348 

349 @staticmethod 

350 def _normalize_checks(checks: BedrockChecksConfigModel | Mapping[str, object] | None) -> dict[str, object] | None: 

351 """Normalize the configured `checks` into a plain dict for the API body. 

352 

353 Accepts a pydantic ``BedrockChecksConfigModel`` or a raw dict; drops None / 

354 unknown keys. Returns None when no usable check is configured (=> ApplyGuardrail). 

355 """ 

356 if checks is None: 

357 return None 

358 raw = checks.model_dump(exclude_none=True) if isinstance(checks, BedrockChecksConfigModel) else dict(checks) 

359 unknown_keys: Final = set(raw.keys()) - _BEDROCK_CHECKS_KNOWN_KEYS 

360 if unknown_keys: 

361 verbose_proxy_logger.warning( 

362 "BedrockGuardrail: unrecognized check key(s) %s will be ignored; " 

363 "recognized keys will still be used for InvokeGuardrailChecks. " 

364 "Known keys: %s.", 

365 sorted(unknown_keys), 

366 sorted(_BEDROCK_CHECKS_KNOWN_KEYS), 

367 ) 

368 cleaned = {key: value for key, value in raw.items() if key in _BEDROCK_CHECKS_KNOWN_KEYS and value is not None} 

369 if not cleaned and raw: 

370 raise ValueError( 

371 f"BedrockGuardrail: 'checks' block contained only unrecognized or empty keys {sorted(raw.keys())}. " 

372 f"Known keys: {sorted(_BEDROCK_CHECKS_KNOWN_KEYS)}. " 

373 "Fix the guardrail config or remove the 'checks' block to use ApplyGuardrail mode." 

374 ) 

375 return cleaned or None 

376 

377 def _create_bedrock_input_content_request(self, messages: list[AllMessageValues] | None) -> BedrockRequest: 

378 """ 

379 Create a bedrock request for the input content - the LLM request. 

380 """ 

381 bedrock_request: Final[BedrockRequest] = BedrockRequest(source="INPUT") 

382 bedrock_request_content: Final[list[BedrockContentItem]] = [] 

383 if messages is None: 

384 return bedrock_request 

385 for message in messages: 

386 blocks = self.get_content_items_for_message(message=message) 

387 if blocks is None: 

388 continue 

389 for block in blocks: 

390 # INPUT scans send plain text only. Grounding qualifiers are attached 

391 # exclusively when assembling the OUTPUT request, so a caller cannot use 

392 # a grounding_source/query tag to change how input-safety policies treat 

393 # their content (which would be an input-guardrail bypass). 

394 bedrock_request_content.append(BedrockContentItem(text=BedrockTextContent(text=block.text))) 

395 

396 bedrock_request["content"] = bedrock_request_content 

397 return bedrock_request 

398 

399 def _create_bedrock_output_content_request( 

400 self, 

401 response: object, 

402 messages: list[AllMessageValues] | None = None, 

403 ) -> BedrockRequest: 

404 """ 

405 Create a bedrock request for the output content - the LLM response. 

406 

407 Contextual grounding grades the response against the reference source and 

408 the user query from the request. When the request tagged any 

409 ``grounding_source``/``query`` blocks, they are emitted first and the 

410 response is qualified as ``guard_content`` so Bedrock can score grounding. 

411 Without such tags the payload is the legacy single response block. 

412 """ 

413 bedrock_request: Final[BedrockRequest] = BedrockRequest(source="OUTPUT") 

414 grounding_blocks: Final = self._collect_grounding_blocks(messages) 

415 bedrock_request_content: Final[list[BedrockContentItem]] = [ 

416 self._build_content_item(block) for block in grounding_blocks 

417 ] 

418 has_grounding: Final = len(bedrock_request_content) > 0 

419 # Append the response (the content to guard) after any grounding blocks; assign 

420 # unconditionally so harvested grounding blocks survive a non-ModelResponse input. 

421 bedrock_request_content.extend(self._build_response_content_items(response, has_grounding=has_grounding)) 

422 bedrock_request["content"] = bedrock_request_content 

423 return bedrock_request 

424 

425 def _build_response_content_items(self, response: object, has_grounding: bool) -> list[BedrockContentItem]: 

426 """Build content item(s) from the model response. When the request supplied 

427 grounding, the response is qualified ``guard_content`` so Bedrock can score it. 

428 """ 

429 items: Final[list[BedrockContentItem]] = [] 

430 if not isinstance(response, litellm.ModelResponse): 

431 return items 

432 for choice in response.choices: 

433 if ( 

434 isinstance(choice, litellm.Choices) 

435 and isinstance(choice.message.content, str) 

436 and choice.message.content 

437 ): 

438 block = QualifiedTextBlock( 

439 text=choice.message.content, 

440 qualifier="guard_content" if has_grounding else None, 

441 ) 

442 items.append(self._build_content_item(block)) 

443 return items 

444 

445 def convert_to_bedrock_format( 

446 self, 

447 source: Literal["INPUT", "OUTPUT"], 

448 messages: list[AllMessageValues] | None = None, 

449 response: object | None = None, 

450 ) -> BedrockRequest: 

451 """ 

452 Convert the litellm messages/response to the bedrock request format. 

453 

454 If source is "INPUT", then messages is required. 

455 If source is "OUTPUT", then response is required. 

456 

457 Returns: 

458 BedrockRequest: The bedrock request object. 

459 """ 

460 bedrock_request: BedrockRequest = BedrockRequest(source=source) 

461 if source == "INPUT": 

462 bedrock_request = self._create_bedrock_input_content_request(messages=messages) 

463 elif source == "OUTPUT": 

464 bedrock_request = self._create_bedrock_output_content_request(response=response, messages=messages) 

465 return bedrock_request 

466 

467 def get_content_items_for_message(self, message: AllMessageValues) -> list[QualifiedTextBlock] | None: 

468 """ 

469 Flatten a message into text blocks, preserving any contextual-grounding 

470 qualifier carried by the content-block ``type`` (grounding_source / query). 

471 Untagged text keeps ``qualifier=None``; the OUTPUT scan decides whether to 

472 derive grounding qualifiers from it. 

473 """ 

474 content: Final = message.get("content") 

475 if content is None: 

476 return None 

477 blocks: Final[list[QualifiedTextBlock]] = [] 

478 if isinstance(content, str): 

479 blocks.append(QualifiedTextBlock(text=content, qualifier=None)) 

480 elif isinstance(content, list): 

481 for item in content: 

482 if isinstance(item, dict) and "text" in item: 

483 qualifier = _CONTENT_TYPE_TO_QUALIFIER.get(item.get("type", "")) 

484 blocks.append(QualifiedTextBlock(text=item["text"], qualifier=qualifier)) 

485 elif isinstance(item, str): 

486 blocks.append(QualifiedTextBlock(text=item, qualifier=None)) 

487 return blocks 

488 

489 def _build_content_item(self, block: QualifiedTextBlock) -> BedrockContentItem: 

490 """Build a Bedrock content item, attaching qualifiers only when present.""" 

491 text_content: Final = BedrockTextContent(text=block.text) 

492 if block.qualifier is not None: 

493 text_content["qualifiers"] = [block.qualifier] 

494 return BedrockContentItem(text=text_content) 

495 

496 def _collect_grounding_blocks(self, messages: list[AllMessageValues] | None) -> list[QualifiedTextBlock]: 

497 """Harvest grounding_source/query blocks from the request for an OUTPUT scan. 

498 

499 ``grounding_source`` is honored only from app-authored roles (system / 

500 developer). A grounding_source tag on a ``user``, ``tool`` or ``function`` 

501 message is ignored, so neither a forwarded end-user message nor a tool/function 

502 result carrying externally-influenced content can supply fake evidence for the 

503 contextual-grounding check to grade the response against. ``query`` is accepted 

504 from any role (it is the user's question). 

505 

506 With ``contextual_grounding_from_messages`` on, a request with no tagged blocks 

507 falls back to the plain messages: system / developer text is the grounding 

508 source and the latest user message is the query. 

509 """ 

510 grounding: Final[list[QualifiedTextBlock]] = [] 

511 for message in messages or []: 

512 role = message.get("role") 

513 for block in self.get_content_items_for_message(message=message) or []: 

514 if ( 

515 block.qualifier == "query" 

516 or block.qualifier == "grounding_source" 

517 and role in _GROUNDING_SOURCE_TRUSTED_ROLES 

518 ): 

519 grounding.append(block) 

520 if grounding or not self.contextual_grounding_from_messages: 

521 return grounding 

522 return self._derive_grounding_blocks_from_plain_messages(messages) 

523 

524 def _derive_grounding_blocks_from_plain_messages( 

525 self, messages: list[AllMessageValues] | None 

526 ) -> list[QualifiedTextBlock]: 

527 if not messages: 

528 return [] 

529 latest_user_index: Final = self._find_latest_message_index(messages, target_role="user") 

530 if latest_user_index is None: 

531 return [] 

532 sources: Final = tuple( 

533 QualifiedTextBlock(text=block.text, qualifier="grounding_source") 

534 for message in messages 

535 if message.get("role") in _GROUNDING_SOURCE_TRUSTED_ROLES 

536 for block in self.get_content_items_for_message(message=message) or [] 

537 if block.text 

538 ) 

539 queries: Final = tuple( 

540 QualifiedTextBlock(text=block.text, qualifier="query") 

541 for block in self.get_content_items_for_message(message=messages[latest_user_index]) or [] 

542 if block.text 

543 ) 

544 if not sources or not queries: 

545 return [] 

546 return [*sources, *queries] 

547 

548 def supports_scan_only_tool_results(self) -> bool: 

549 return self.experimental_use_latest_role_message_only is not True 

550 

551 def _prepare_guardrail_messages_for_role( 

552 self, 

553 messages: list[AllMessageValues] | None, 

554 ) -> GuardrailMessageFilterResult: 

555 """Return payload + merge metadata for the latest user message.""" 

556 # NOTE: This logic probably belongs in CustomGuardrail once other guardrails adopt the feature. 

557 

558 if messages is None: 

559 return GuardrailMessageFilterResult(None, None, None) 

560 

561 if self.experimental_use_latest_role_message_only is not True: 

562 return GuardrailMessageFilterResult(messages, None, None) 

563 

564 latest_index: Final = self._find_latest_message_index(messages, target_role="user") 

565 if latest_index is None: 

566 return GuardrailMessageFilterResult(None, None, None) 

567 

568 original_messages: Final = list(messages) 

569 payload_messages: Final = [messages[latest_index]] 

570 return GuardrailMessageFilterResult( 

571 payload_messages=payload_messages, 

572 original_messages=original_messages, 

573 target_indices=[latest_index], 

574 ) 

575 

576 def _find_latest_message_index(self, messages: list[AllMessageValues], target_role: str) -> int | None: 

577 for index in range(len(messages) - 1, -1, -1): 

578 if messages[index].get("role", None) == target_role: 

579 return index 

580 return None 

581 

582 @staticmethod 

583 def _count_message_texts(message: AllMessageValues) -> int: 

584 """Count the text segments the guardrail translation layer extracts from a message.""" 

585 content: Final = message.get("content") 

586 if isinstance(content, str): 

587 return 1 

588 if isinstance(content, list): 

589 return sum(1 for item in content if isinstance(item, dict) and item.get("text") is not None) 

590 return 0 

591 

592 def _locate_message_texts_slice( 

593 self, 

594 structured_messages: list[AllMessageValues], 

595 target_index: int, 

596 texts: list[str], 

597 ) -> tuple[int, int] | None: 

598 """ 

599 Map one message's text segments to their (offset, length) slice in the 

600 flat `texts` list built by the guardrail translation handler. 

601 

602 Returns None when the reconstruction does not line up with `texts` 

603 (the caller must then avoid positional write-back). 

604 """ 

605 offset = 0 

606 total = 0 

607 target_count = 0 

608 for index, message in enumerate(structured_messages): 

609 count = self._count_message_texts(message) 

610 if index < target_index: 

611 offset += count 

612 elif index == target_index: 

613 target_count = count 

614 total += count 

615 if total != len(texts) or target_count == 0: 

616 return None 

617 return offset, target_count 

618 

619 def _select_messages_for_apply_guardrail( 

620 self, 

621 texts: list[str], 

622 inputs: "GenericGuardrailAPIInputs", 

623 request_data: dict, 

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

625 ) -> ApplyGuardrailMessageSelection: 

626 """ 

627 Decide which messages an apply_guardrail scan should cover. 

628 

629 With ``experimental_use_latest_role_message_only`` enabled, request 

630 scans must select by the ORIGINAL message roles. The flat `texts` list 

631 has no role information, and wrapping it in role="user" mock messages 

632 makes the latest-user filter degenerate to "latest text of any role", 

633 leaking tool/assistant content to the INPUT scan 

634 (https://github.com/BerriAI/litellm/issues/23476). 

635 """ 

636 mock_messages: list[AllMessageValues] = [ChatCompletionUserMessage(role="user", content=text) for text in texts] 

637 

638 if self.experimental_use_latest_role_message_only is not True: 

639 return ApplyGuardrailMessageSelection( 

640 filtered_messages=mock_messages, 

641 scanned_slice=None, 

642 scanned_role_subset=False, 

643 ) 

644 

645 # Prefer inputs["structured_messages"]: it is built alongside `texts` by 

646 # the translation handler and stays aligned with it even when 

647 # skip_system_message_in_guardrail / skip_tool_message_in_guardrail drop 

648 # messages. The fallback to request_data["messages"] is the *unfiltered* 

649 # list, so it only lines up with `texts` when no skip flags are active. 

650 # When a skip flag is set and we land on this fallback (direct 

651 # apply_guardrail callers with no structured_messages), 

652 # _locate_message_texts_slice will detect the length mismatch and return 

653 # None, and the write-back guard below safely skips masking rather than 

654 # corrupting positions. 

655 structured_messages: Final = cast( 

656 list[AllMessageValues] | None, 

657 inputs.get("structured_messages") or request_data.get("messages"), 

658 ) 

659 if input_type != "request" or not structured_messages: 

660 # No role information available (e.g. raw-text callers like 

661 # /guardrails/apply_guardrail) — keep the legacy behavior of 

662 # scanning the latest text only. 

663 filter_result: Final = self._prepare_guardrail_messages_for_role(messages=mock_messages) 

664 return ApplyGuardrailMessageSelection( 

665 filtered_messages=filter_result.payload_messages or mock_messages, 

666 scanned_slice=None, 

667 scanned_role_subset=False, 

668 ) 

669 

670 latest_user_index: Final = self._find_latest_message_index(structured_messages, target_role="user") 

671 if latest_user_index is None: 

672 if effective_scan_only_tool_results_for_guardrail(self): 

673 verbose_proxy_logger.warning( 

674 "Bedrock Guardrail: experimental_use_latest_role_message_only scans only the latest " 

675 "user message, so scan_only_tool_results leaves nothing to scan for this request" 

676 ) 

677 verbose_proxy_logger.debug("Bedrock Guardrail: no user-role message in request, skipping INPUT scan") 

678 return ApplyGuardrailMessageSelection(None, None, True, skip_scan=True) 

679 

680 selected_message: Final = structured_messages[latest_user_index] 

681 if self._count_message_texts(selected_message) == 0: 

682 verbose_proxy_logger.debug( 

683 "Bedrock Guardrail: latest user message has no text content, skipping INPUT scan" 

684 ) 

685 return ApplyGuardrailMessageSelection(None, None, True, skip_scan=True) 

686 

687 return ApplyGuardrailMessageSelection( 

688 filtered_messages=[selected_message], 

689 scanned_slice=self._locate_message_texts_slice( 

690 structured_messages=structured_messages, 

691 target_index=latest_user_index, 

692 texts=texts, 

693 ), 

694 scanned_role_subset=True, 

695 ) 

696 

697 def _merge_masked_texts( 

698 self, 

699 masked_texts: list, 

700 texts: list, 

701 scanned_slice: tuple[int, int] | None, 

702 scanned_role_subset: bool, 

703 ) -> list: 

704 """ 

705 Reconcile the guardrail's masked output with the flat `texts` list. 

706 

707 - No masked output: keep the originals (guardrail allowed content as-is). 

708 - A slice was scanned: write masked content back to those positions only, 

709 keeping the list aligned with the caller's message↔text mappings. 

710 - A role-selected subset was scanned but could not be mapped back to 

711 flat-text positions (scanned_slice is None): keep the originals rather 

712 than misapply masked content to the wrong message. Guarding on 

713 scanned_slice rather than a length comparison also covers the case 

714 where the masked subset happens to match len(texts) (e.g. both length 

715 1). 

716 - Otherwise (whole list scanned): use the masked output as-is. 

717 """ 

718 if not masked_texts: 

719 return texts 

720 if scanned_slice is not None: 

721 offset, length = scanned_slice 

722 merged_texts: Final = list(texts) 

723 for masked_index, masked_text in enumerate(masked_texts[:length]): 

724 merged_texts[offset + masked_index] = masked_text 

725 return merged_texts 

726 if scanned_role_subset: 

727 verbose_proxy_logger.warning( 

728 "Bedrock Guardrail: could not align masked texts with request texts, skipping masking write-back" 

729 ) 

730 return texts 

731 return masked_texts 

732 

733 def _merge_filtered_messages( 

734 self, 

735 original_messages: list[AllMessageValues] | None, 

736 updated_target_messages: list[AllMessageValues], 

737 target_indices: list[int] | None, 

738 ) -> list[AllMessageValues]: 

739 if not target_indices: 

740 return updated_target_messages 

741 

742 if not original_messages: 

743 original_messages = [] 

744 

745 merged_messages = list(original_messages) 

746 if not merged_messages: 

747 merged_messages = list(updated_target_messages) 

748 for replacement_index, updated_message in zip(target_indices, updated_target_messages): 

749 if replacement_index < len(merged_messages): 

750 merged_messages[replacement_index] = updated_message 

751 

752 return merged_messages 

753 

754 # NOTE: Consider moving these helpers to CustomGuardrail when the filtering 

755 # logic becomes shared across providers. 

756 

757 #### CALL HOOKS - proxy only #### 

758 def _load_credentials(self, bearer_token: str | None = None): 

759 try: 

760 from botocore.credentials import Credentials 

761 except ImportError: 

762 raise ImportError("Missing boto3 to call bedrock. Run 'pip install boto3'.") 

763 ## CREDENTIALS ## 

764 aws_secret_access_key: Final = self.optional_params.get("aws_secret_access_key", None) 

765 aws_access_key_id: Final = self.optional_params.get("aws_access_key_id", None) 

766 aws_session_token: Final = self.optional_params.get("aws_session_token", None) 

767 aws_region_name = self.optional_params.get("aws_region_name", None) 

768 aws_role_name: Final = self.optional_params.get("aws_role_name", None) 

769 aws_session_name: Final = self.optional_params.get("aws_session_name", None) 

770 aws_profile_name: Final = self.optional_params.get("aws_profile_name", None) 

771 aws_web_identity_token: Final = self.optional_params.get("aws_web_identity_token", None) 

772 aws_sts_endpoint: Final = self.optional_params.get("aws_sts_endpoint", None) 

773 aws_external_id: Final = self.optional_params.get("aws_external_id", None) 

774 

775 ### SET REGION NAME ### 

776 aws_region_name = self.get_aws_region_name_for_non_llm_api_calls( 

777 aws_region_name=aws_region_name, 

778 ) 

779 

780 credentials: Final[Credentials | None] = ( 

781 None 

782 if bearer_token is not None 

783 else self.get_credentials( 

784 aws_access_key_id=aws_access_key_id, 

785 aws_secret_access_key=aws_secret_access_key, 

786 aws_session_token=aws_session_token, 

787 aws_region_name=aws_region_name, 

788 aws_session_name=aws_session_name, 

789 aws_profile_name=aws_profile_name, 

790 aws_role_name=aws_role_name, 

791 aws_web_identity_token=aws_web_identity_token, 

792 aws_sts_endpoint=aws_sts_endpoint, 

793 aws_external_id=aws_external_id, 

794 ) 

795 ) 

796 return credentials, aws_region_name 

797 

798 def _prepare_request( 

799 self, 

800 credentials, 

801 data: dict, 

802 optional_params: dict, 

803 aws_region_name: str, 

804 api_key: str | None = None, 

805 extra_headers: dict | None = None, 

806 request_path: str | None = None, 

807 ): 

808 headers = {"Content-Type": "application/json"} 

809 if extra_headers is not None: 

810 headers = {"Content-Type": "application/json", **extra_headers} 

811 

812 aws_bedrock_runtime_endpoint: Final = self.optional_params.get("aws_bedrock_runtime_endpoint", None) 

813 _, proxy_endpoint_url = self.get_runtime_endpoint( 

814 api_base=None, 

815 aws_bedrock_runtime_endpoint=aws_bedrock_runtime_endpoint, 

816 aws_region_name=aws_region_name, 

817 ) 

818 # Default to the ApplyGuardrail resource path. Callers pass an explicit 

819 # request_path for the resource-less InvokeGuardrailChecks endpoint (where 

820 # guardrailIdentifier/guardrailVersion are None and must not be interpolated). 

821 if request_path is None: 

822 request_path = f"/guardrail/{self.guardrailIdentifier}/version/{self.guardrailVersion}/apply" 

823 proxy_endpoint_url = f"{proxy_endpoint_url}{request_path}" 

824 encoded_data: Final = json.dumps(data).encode("utf-8") 

825 

826 aws_bearer_token: Final = bedrock_bearer_token(api_key) 

827 

828 if aws_bearer_token is not None: 

829 try: 

830 from botocore.awsrequest import AWSRequest 

831 except ImportError: 

832 raise ImportError("Missing boto3 to call bedrock. Run 'pip install boto3'.") 

833 headers["Authorization"] = f"Bearer {aws_bearer_token}" 

834 request = AWSRequest( 

835 method="POST", 

836 url=proxy_endpoint_url, 

837 data=encoded_data, 

838 headers=headers, 

839 ) 

840 else: 

841 try: 

842 from botocore.auth import SigV4Auth 

843 from botocore.awsrequest import AWSRequest 

844 except ImportError: 

845 raise ImportError("Missing boto3 to call bedrock. Run 'pip install boto3'.") 

846 

847 sigv4: Final = SigV4Auth(credentials, "bedrock", aws_region_name) 

848 request = AWSRequest( 

849 method="POST", 

850 url=proxy_endpoint_url, 

851 data=encoded_data, 

852 headers=headers, 

853 ) 

854 sigv4.add_auth(request) 

855 if ( 

856 extra_headers is not None and "Authorization" in extra_headers 

857 ): # prevent sigv4 from overwriting the auth header 

858 request.headers["Authorization"] = extra_headers["Authorization"] 

859 prepped_request: Final = request.prepare() 

860 

861 return prepped_request 

862 

863 async def make_bedrock_api_request( 

864 self, 

865 source: Literal["INPUT", "OUTPUT"], 

866 messages: list[AllMessageValues] | None = None, 

867 response: litellm.ModelResponse | None = None, 

868 request_data: dict | None = None, 

869 logging_event_type: GuardrailEventHooks | None = None, 

870 ) -> BedrockGuardrailResponse: 

871 """Dispatch to the configured Bedrock guardrail API. 

872 

873 ``checks`` selects the resource-less, detect-only InvokeGuardrailChecks API; 

874 otherwise the ApplyGuardrail API is used. Both return a ``BedrockGuardrailResponse`` 

875 (the checks path returns an empty one on a pass, which downstream masking treats 

876 as a no-op) and raise on a blocked request. 

877 """ 

878 if self.checks is not None: 

879 return await self._make_invoke_guardrail_checks_request( 

880 source=source, 

881 messages=messages, 

882 response=response, 

883 request_data=request_data, 

884 logging_event_type=logging_event_type, 

885 ) 

886 return await self._make_apply_guardrail_request( 

887 source=source, 

888 messages=messages, 

889 response=response, 

890 request_data=request_data, 

891 logging_event_type=logging_event_type, 

892 ) 

893 

894 async def _make_apply_guardrail_request( 

895 self, 

896 source: Literal["INPUT", "OUTPUT"], 

897 messages: list[AllMessageValues] | None = None, 

898 response: litellm.ModelResponse | None = None, 

899 request_data: dict | None = None, 

900 logging_event_type: GuardrailEventHooks | None = None, 

901 ) -> BedrockGuardrailResponse: 

902 """Scan `messages`/`response` with ApplyGuardrail, chunking if it is too large. 

903 

904 Content is bin-packed into budget-sized batches and each batch posted 

905 sequentially, every batch independently falling back to bisection if AWS 

906 rejects it. The per-batch responses are merged so callers cannot tell whether 

907 chunking happened. 

908 

909 Content using contextual grounding opts out of chunking entirely: grounding is 

910 scored holistically against the whole reference source, so bisecting it would 

911 fragment that evaluation and yield misleading scores. Such a request keeps the 

912 old behavior of surfacing a too-large error rather than being split. 

913 

914 `logging_event_type` drives what UI and spend logs report. It is distinct from 

915 Bedrock's `source`, which is INPUT vs OUTPUT for the API body and must not be 

916 confused with the proxy hook (pre_call / during_call / post_call); when omitted, 

917 the legacy source-derived mapping is kept for backward compatibility. 

918 

919 A guardrail *block* is logged where it happens, in 

920 `_post_apply_guardrail_content`, because chunking stops immediately and there is 

921 no later merged response to log instead. Everything else that fails out of the 

922 chunking flow (an unrecoverable too-large error, a non-size validation error, 

923 exhausted throttle retries) is a genuine end-to-end failure of this one logical 

924 guardrail call and is logged exactly once here. 

925 """ 

926 start_time: Final = datetime.now(timezone.utc) 

927 bedrock_request_data: Final[dict] = dict( 

928 self.convert_to_bedrock_format(source=source, messages=messages, response=response) 

929 ) 

930 api_key: str | None = None 

931 if request_data: 

932 dynamic_request_body_params = self.get_guardrail_dynamic_request_body_params(request_data=request_data) 

933 bedrock_request_data.update( 

934 { 

935 key: value 

936 for key, value in dynamic_request_body_params.items() 

937 if key not in _BEDROCK_DYNAMIC_BODY_DENYLIST 

938 } 

939 ) 

940 if request_data.get("api_key") is not None: 

941 api_key = request_data["api_key"] 

942 

943 event_type: Final = ( 

944 logging_event_type 

945 if logging_event_type is not None 

946 else (GuardrailEventHooks.pre_call if source == "INPUT" else GuardrailEventHooks.post_call) 

947 ) 

948 

949 content: Final[tuple[BedrockContentItem, ...]] = tuple(bedrock_request_data.get("content") or ()) 

950 if not content: 

951 # ApplyGuardrail rejects an empty content list with a 400, so a turn this extractor 

952 # found no text in is skipped rather than turned into a failed request 

953 verbose_proxy_logger.debug( 

954 "Bedrock Guardrail %s: no %s content to scan, skipping ApplyGuardrail", 

955 self.guardrail_name, 

956 source, 

957 ) 

958 return BedrockGuardrailResponse() 

959 credentials, aws_region_name = await run_aws_signing( 

960 self._load_credentials, bearer_token=bedrock_bearer_token(api_key) 

961 ) 

962 allow_chunking: Final = not self._content_uses_contextual_grounding(content) 

963 

964 completed_chunk_usages: Final[list[BedrockGuardrailUsage]] = [] # mutable-ok: billed-chunk usage accumulator 

965 try: 

966 responses: Final = await self._apply_guardrail_content_with_chunking( 

967 content=content, 

968 base_request_data=bedrock_request_data, 

969 credentials=credentials, 

970 aws_region_name=aws_region_name, 

971 api_key=api_key, 

972 request_data=request_data, 

973 event_type=event_type, 

974 start_time=start_time, 

975 allow_chunking=allow_chunking, 

976 completed_chunk_usages=completed_chunk_usages, 

977 ) 

978 except HTTPException as exc: 

979 if not isinstance(exc.detail, dict): 

980 self._log_apply_guardrail_failure( 

981 detail=exc.detail, 

982 request_data=request_data, 

983 event_type=event_type, 

984 start_time=start_time, 

985 aws_region_name=aws_region_name, 

986 completed_chunk_usages=completed_chunk_usages, 

987 ) 

988 raise 

989 merged_response: Final = self._merge_bedrock_guardrail_responses(responses) 

990 self._log_apply_guardrail_success( 

991 merged_response=merged_response, 

992 request_data=request_data, 

993 event_type=event_type, 

994 start_time=start_time, 

995 aws_region_name=aws_region_name, 

996 ) 

997 return merged_response 

998 

999 async def _apply_guardrail_content_with_chunking( 

1000 self, 

1001 content: Sequence[BedrockContentItem], 

1002 base_request_data: Mapping[str, object], 

1003 credentials: "Credentials | None", 

1004 aws_region_name: str, 

1005 api_key: str | None, 

1006 request_data: dict | None, # mutable-ok: proxy request body dict, mutated by the logging helper 

1007 event_type: GuardrailEventHooks, 

1008 start_time: "datetime", 

1009 allow_chunking: bool, 

1010 completed_chunk_usages: list[BedrockGuardrailUsage], # mutable-ok: billed-chunk usage accumulator 

1011 ) -> tuple[BedrockContentChunkResult, ...]: 

1012 """Post `content` to ApplyGuardrail, chunking only if AWS rejects it as too large. 

1013 

1014 Tries `content` as a single call first. AWS's per-request "maximum input 

1015 size in text units" quota is account/region/policy-dependent and cannot be 

1016 predicted ahead of time, so it is only ever discovered reactively: on an 

1017 error whose message indicates the input was too large (a ThrottlingException 

1018 in practice, a ValidationException per the docs -- see 

1019 ``_is_input_too_large_error``), the content is re-sent in smaller pieces. 

1020 

1021 Probing with the whole payload first is what keeps a request AWS would have 

1022 accepted at exactly one call. Packing into fixed batches up front instead 

1023 would split conversations AWS was happy to take whole, multiplying billed 

1024 calls and guardrail latency on traffic that never had a size problem, and 

1025 no fixed budget can avoid that because the real cap is unknown here. 

1026 

1027 Once a rejection proves the payload is over the cap, a multi-item payload is 

1028 re-sent as ``chunk_budget_chars``-sized batches rather than bisected: that 

1029 reaches a working size in one step instead of paying an O(log n) ladder of 

1030 rejected calls. Bisection remains the fallback for anything bin-packing 

1031 cannot make smaller, which is what makes the recursion terminate: a batch 

1032 already inside the budget packs back to itself, so it falls through to the 

1033 split below. A single oversized 

1034 content item (one very long message) is split by its own text instead of 

1035 by list length, since a list of length 1 has no items left to bisect -- 

1036 the resulting fragments all carry a ``fragment_group_size`` so the merge 

1037 step can recombine them into the one content item they came from, rather 

1038 than treating each fragment as its own item when reconstructing positions 

1039 for masking. That count covers however many fragments the item ended up 

1040 split into, not just two, since it can be bisected repeatedly: the 

1041 outermost single-item split stamps the total leaf count on every leaf 

1042 below it, overwriting any smaller count an inner split had set. A real 

1043 guardrail block on any (sub-)chunk raises immediately 

1044 -- callers must not lose that signal by continuing to post the remaining 

1045 chunks. 

1046 """ 

1047 try: 

1048 response: Final = await self._post_apply_guardrail_content_with_retry( 

1049 content=content, 

1050 base_request_data=base_request_data, 

1051 credentials=credentials, 

1052 aws_region_name=aws_region_name, 

1053 api_key=api_key, 

1054 request_data=request_data, 

1055 event_type=event_type, 

1056 start_time=start_time, 

1057 completed_chunk_usages=completed_chunk_usages, 

1058 ) 

1059 return ( 

1060 BedrockContentChunkResult( 

1061 response=response, 

1062 content=tuple(content), 

1063 fragment_group_size=1, 

1064 ), 

1065 ) 

1066 except HTTPException as exc: 

1067 if allow_chunking and self._is_input_too_large_error(exc.detail): 

1068 batches: Final = self._bin_pack_bedrock_content(content, budget=self.chunk_budget_chars) 

1069 if len(batches) > 1: 

1070 verbose_proxy_logger.warning( 

1071 "Bedrock Guardrail: ApplyGuardrail rejected %d content item(s) as too large; " 

1072 "re-sending as %d batches of at most %d characters", 

1073 len(content), 

1074 len(batches), 

1075 self.chunk_budget_chars, 

1076 ) 

1077 batch_results: Final = [ # mutable-ok: await needs a list comprehension; frozen to a tuple below 

1078 await self._apply_guardrail_content_with_chunking( 

1079 content=batch, 

1080 base_request_data=base_request_data, 

1081 credentials=credentials, 

1082 aws_region_name=aws_region_name, 

1083 api_key=api_key, 

1084 request_data=request_data, 

1085 event_type=event_type, 

1086 start_time=start_time, 

1087 allow_chunking=allow_chunking, 

1088 completed_chunk_usages=completed_chunk_usages, 

1089 ) 

1090 for batch in batches 

1091 ] 

1092 return tuple(result for results in batch_results for result in results) 

1093 split_content: Final = self._split_bedrock_content(content) 

1094 if split_content is None: 

1095 raise 

1096 first_half, second_half = split_content 

1097 is_single_item_text_split: Final = len(content) == 1 

1098 verbose_proxy_logger.warning( 

1099 "Bedrock Guardrail: ApplyGuardrail rejected %d content item(s) as too large; " 

1100 "splitting into %d + %d and retrying each", 

1101 len(content), 

1102 len(first_half), 

1103 len(second_half), 

1104 ) 

1105 first_results: Final = await self._apply_guardrail_content_with_chunking( 

1106 content=first_half, 

1107 base_request_data=base_request_data, 

1108 credentials=credentials, 

1109 aws_region_name=aws_region_name, 

1110 api_key=api_key, 

1111 request_data=request_data, 

1112 event_type=event_type, 

1113 start_time=start_time, 

1114 allow_chunking=allow_chunking, 

1115 completed_chunk_usages=completed_chunk_usages, 

1116 ) 

1117 second_results: Final = await self._apply_guardrail_content_with_chunking( 

1118 content=second_half, 

1119 base_request_data=base_request_data, 

1120 credentials=credentials, 

1121 aws_region_name=aws_region_name, 

1122 api_key=api_key, 

1123 request_data=request_data, 

1124 event_type=event_type, 

1125 start_time=start_time, 

1126 allow_chunking=allow_chunking, 

1127 completed_chunk_usages=completed_chunk_usages, 

1128 ) 

1129 combined_results: Final = tuple(first_results) + tuple(second_results) 

1130 if is_single_item_text_split: 

1131 return tuple( 

1132 result._replace(fragment_group_size=len(combined_results)) for result in combined_results 

1133 ) 

1134 return combined_results 

1135 raise 

1136 

1137 async def _post_apply_guardrail_content_with_retry( 

1138 self, 

1139 content: Sequence[BedrockContentItem], 

1140 base_request_data: Mapping[str, object], 

1141 credentials: "Credentials | None", 

1142 aws_region_name: str, 

1143 api_key: str | None, 

1144 request_data: dict | None, # mutable-ok: proxy request body dict, mutated by the logging helper 

1145 event_type: GuardrailEventHooks, 

1146 start_time: "datetime", 

1147 completed_chunk_usages: list[BedrockGuardrailUsage], # mutable-ok: passed through to the single-call layer 

1148 ) -> BedrockGuardrailResponse: 

1149 """Post one ApplyGuardrail call for `content`, retrying with exponential 

1150 backoff on AWS ThrottlingException (HTTP 429). 

1151 

1152 Chunking already trades one oversized call for several smaller ones, so 

1153 retries here are capped low -- they must not multiply per-request latency 

1154 by an order of magnitude when the account's per-second text-unit quota is 

1155 the binding constraint rather than the per-request size quota. 

1156 

1157 A too-large rejection is deliberately excluded from the retry. AWS reports 

1158 it as a ThrottlingException (429), not only as a ValidationException, but 

1159 unlike a genuine throttle it is not transient: re-posting the same 

1160 oversized content can never succeed. Retrying it would burn every backoff 

1161 sleep and every (billed) attempt before the caller's bisection gets a 

1162 chance to split the content, at every level of the recursion. 

1163 """ 

1164 for attempt in range(_BEDROCK_APPLY_GUARDRAIL_MAX_THROTTLE_RETRIES + 1): 

1165 try: 

1166 return await self._post_apply_guardrail_content( 

1167 content=content, 

1168 base_request_data=base_request_data, 

1169 credentials=credentials, 

1170 aws_region_name=aws_region_name, 

1171 api_key=api_key, 

1172 request_data=request_data, 

1173 event_type=event_type, 

1174 start_time=start_time, 

1175 completed_chunk_usages=completed_chunk_usages, 

1176 ) 

1177 except HTTPException as exc: 

1178 if ( 

1179 exc.status_code != 429 

1180 or self._is_input_too_large_error(exc.detail) 

1181 or attempt >= _BEDROCK_APPLY_GUARDRAIL_MAX_THROTTLE_RETRIES 

1182 ): 

1183 raise 

1184 await asyncio.sleep(_BEDROCK_APPLY_GUARDRAIL_BASE_BACKOFF_SECONDS * (2**attempt)) 

1185 raise HTTPException(status_code=500, detail="Bedrock guardrail throttle retries exhausted") 

1186 

1187 async def _post_apply_guardrail_content( 

1188 self, 

1189 content: Sequence[BedrockContentItem], 

1190 base_request_data: Mapping[str, object], 

1191 credentials: "Credentials | None", 

1192 aws_region_name: str, 

1193 api_key: str | None, 

1194 request_data: dict | None, # mutable-ok: proxy request body dict, mutated by the logging helper 

1195 event_type: GuardrailEventHooks, 

1196 start_time: "datetime", 

1197 completed_chunk_usages: list[BedrockGuardrailUsage], # mutable-ok: billed-chunk usage accumulator 

1198 ) -> BedrockGuardrailResponse: 

1199 """Make exactly one signed ApplyGuardrail HTTP call for `content` and 

1200 parse the result. Raises HTTPException on a guardrail block or any 

1201 non-200 response (including 429, handled by the retry wrapper above). 

1202 

1203 AWS also reports some failures inside a 200 body, tagging ``Output.__type`` 

1204 with an Exception marker. Those deliberately do NOT raise: the request proceeds, 

1205 matching the behaviour of this code before chunking existed. The marker survives 

1206 the merge, so the one consolidated log entry still records 

1207 ``guardrail_failed_to_respond`` rather than a success. Making that path fail 

1208 closed is a separate change, tracked apart from this PR, and belongs behind the 

1209 existing ``unreachable_fallback`` setting rather than a hardcoded status. 

1210 

1211 A block is logged here rather than by the caller: it ends the whole chunking 

1212 flow immediately, with no further chunks attempted, so there is no later 

1213 merged response for the caller to log instead. The logged usage still spans 

1214 the whole logical request: chunks that passed before the block appended what 

1215 AWS billed them to ``completed_chunk_usages``, and the attempt log sums those 

1216 with the blocking call's own usage. 

1217 """ 

1218 bedrock_request_data: Final = { # mutable-ok: outbound JSON request body 

1219 **base_request_data, 

1220 "content": content, 

1221 } 

1222 prepared_request: Final = await run_aws_signing( 

1223 self._prepare_request, 

1224 credentials=credentials, 

1225 data=bedrock_request_data, 

1226 optional_params=self.optional_params, 

1227 aws_region_name=aws_region_name, 

1228 api_key=api_key, 

1229 ) 

1230 headers_dict: Final = dict(prepared_request.headers) # mutable-ok: the masking helper requires a dict 

1231 verbose_proxy_logger.debug( 

1232 "Bedrock AI request body: %s, url %s, headers: %s", 

1233 bedrock_request_data, 

1234 prepared_request.url, 

1235 _get_masked_values(headers_dict), 

1236 ) 

1237 

1238 httpx_response: Final = await self._sign_and_post( 

1239 prepared_request=prepared_request, 

1240 request_data=request_data, 

1241 event_type=event_type, 

1242 start_time=start_time, 

1243 log_transport_failure=False, 

1244 ) 

1245 

1246 if httpx_response.status_code == 200: 

1247 _json_response: Final = httpx_response.json() 

1248 # check if the response was flagged 

1249 verbose_proxy_logger.debug( 

1250 "Bedrock AI response : %s", 

1251 redact_nested_match_and_regex_keys(_json_response), 

1252 ) 

1253 bedrock_guardrail_response = BedrockGuardrailResponse(**_json_response) 

1254 if self._should_raise_guardrail_blocked_exception(bedrock_guardrail_response): 

1255 self._log_apply_guardrail_attempt( 

1256 httpx_response=httpx_response, 

1257 json_response=_json_response, 

1258 request_data=request_data, 

1259 event_type=event_type, 

1260 start_time=start_time, 

1261 aws_region_name=aws_region_name, 

1262 completed_chunk_usages=completed_chunk_usages, 

1263 ) 

1264 raise self._get_http_exception_for_blocked_guardrail( 

1265 bedrock_guardrail_response, request_data=request_data 

1266 ) 

1267 response_usage: Final = bedrock_guardrail_response.get("usage") 

1268 if isinstance(response_usage, dict): 

1269 completed_chunk_usages.append(response_usage) 

1270 return bedrock_guardrail_response 

1271 

1272 status_code, detail_message = self._parse_bedrock_guardrail_error_response(httpx_response) 

1273 verbose_proxy_logger.error( 

1274 "Bedrock AI: error in response. Status code: %s, response: %s", 

1275 httpx_response.status_code, 

1276 httpx_response.text, 

1277 ) 

1278 raise HTTPException(status_code=status_code, detail=detail_message) 

1279 

1280 def _log_apply_guardrail_attempt( 

1281 self, 

1282 httpx_response: httpx.Response, 

1283 json_response: dict, # mutable-ok: raw AWS JSON payload 

1284 request_data: dict | None, # mutable-ok: proxy request body dict, mutated by the logging helper 

1285 event_type: GuardrailEventHooks, 

1286 start_time: "datetime", 

1287 aws_region_name: str | None, 

1288 completed_chunk_usages: Sequence[BedrockGuardrailUsage], 

1289 ) -> None: 

1290 """Log the blocking ApplyGuardrail attempt, which ends the whole chunking 

1291 flow immediately. Its status derives from its own response, but its usage 

1292 (and so its cost) spans every billed call of the logical request: the 

1293 chunks that passed before the block plus the blocking call itself.""" 

1294 blocking_usage: Final = json_response.get("usage") 

1295 billed_usages: Final[tuple[BedrockGuardrailUsage, ...]] = tuple(completed_chunk_usages) + ( 

1296 (blocking_usage,) if isinstance(blocking_usage, dict) else () 

1297 ) 

1298 logged_json_response: Final = ( 

1299 { # mutable-ok: raw AWS JSON payload carrying the total billed usage 

1300 **json_response, 

1301 "usage": self._sum_usage_counters(billed_usages), 

1302 } 

1303 if completed_chunk_usages 

1304 else json_response 

1305 ) 

1306 tracing_detail: Final = self._build_tracing_detail( 

1307 BedrockGuardrailResponse(**logged_json_response), aws_region_name=aws_region_name 

1308 ) 

1309 self.add_standard_logging_guardrail_information_to_request_data( 

1310 guardrail_provider=self.guardrail_provider, 

1311 guardrail_json_response=logged_json_response, 

1312 request_data=request_data or {}, # mutable-ok: logging helper requires a dict 

1313 guardrail_status=self._get_bedrock_guardrail_response_status(response=httpx_response), 

1314 start_time=start_time.timestamp(), 

1315 end_time=datetime.now(timezone.utc).timestamp(), 

1316 duration=(datetime.now(timezone.utc) - start_time).total_seconds(), 

1317 event_type=event_type, 

1318 tracing_detail=tracing_detail or None, 

1319 ) 

1320 

1321 def _log_apply_guardrail_success( 

1322 self, 

1323 merged_response: BedrockGuardrailResponse, 

1324 request_data: dict | None, # mutable-ok: proxy request body dict, mutated by the logging helper 

1325 event_type: GuardrailEventHooks, 

1326 start_time: "datetime", 

1327 aws_region_name: str | None, 

1328 ) -> None: 

1329 """Log one logical ApplyGuardrail call -- possibly several chunk calls 

1330 under the hood -- using its final merged response, so a chunked 

1331 request produces exactly one telemetry entry, the same as an 

1332 unchunked one would. 

1333 

1334 AWS can report a failure inside an HTTP 200 body by tagging 

1335 ``Output.__type`` with an exception marker. That marker survives the merge, 

1336 so the status is derived from the merged response rather than assumed to be 

1337 a success, which is what the pre-chunking code reported for that shape.""" 

1338 tracing_detail: Final = self._build_tracing_detail(merged_response, aws_region_name=aws_region_name) 

1339 self.add_standard_logging_guardrail_information_to_request_data( 

1340 guardrail_provider=self.guardrail_provider, 

1341 guardrail_json_response=dict(merged_response), # mutable-ok: logging helper requires a dict 

1342 request_data=request_data or {}, # mutable-ok: logging helper requires a dict 

1343 guardrail_status=( 

1344 "guardrail_failed_to_respond" 

1345 if "Exception" in str((merged_response.get("Output") or {}).get("__type", "")) 

1346 else "success" 

1347 ), 

1348 start_time=start_time.timestamp(), 

1349 end_time=datetime.now(timezone.utc).timestamp(), 

1350 duration=(datetime.now(timezone.utc) - start_time).total_seconds(), 

1351 event_type=event_type, 

1352 tracing_detail=tracing_detail or None, 

1353 ) 

1354 

1355 def _log_apply_guardrail_failure( 

1356 self, 

1357 detail: object, 

1358 request_data: dict | None, # mutable-ok: proxy request body dict, mutated by the logging helper 

1359 event_type: GuardrailEventHooks, 

1360 start_time: "datetime", 

1361 aws_region_name: str | None, 

1362 completed_chunk_usages: Sequence[BedrockGuardrailUsage], 

1363 ) -> None: 

1364 """Log one logical ApplyGuardrail call that failed end-to-end (an 

1365 unrecoverable too-large error, a non-size validation error, or 

1366 exhausted throttle retries) as a single failure, rather than logging 

1367 every failed attempt chunking made along the way. Chunk calls AWS 

1368 billed before the failure still carry their usage and cost.""" 

1369 billed_usage: Final = self._sum_usage_counters(completed_chunk_usages) if completed_chunk_usages else None 

1370 error_payload: Final = {"error": str(detail)} # mutable-ok: logging helper requires a dict 

1371 json_response: Final = ( 

1372 {**error_payload, "usage": billed_usage} # mutable-ok: logging helper requires a dict 

1373 if billed_usage is not None 

1374 else error_payload 

1375 ) 

1376 tracing_detail: Final = ( 

1377 self._build_tracing_detail(BedrockGuardrailResponse(usage=billed_usage), aws_region_name=aws_region_name) 

1378 if billed_usage is not None 

1379 else None 

1380 ) 

1381 self.add_standard_logging_guardrail_information_to_request_data( 

1382 guardrail_provider=self.guardrail_provider, 

1383 guardrail_json_response=json_response, 

1384 request_data=request_data or {}, # mutable-ok: logging helper requires a dict 

1385 guardrail_status="guardrail_failed_to_respond", 

1386 start_time=start_time.timestamp(), 

1387 end_time=datetime.now(timezone.utc).timestamp(), 

1388 duration=(datetime.now(timezone.utc) - start_time).total_seconds(), 

1389 event_type=event_type, 

1390 tracing_detail=tracing_detail or None, 

1391 ) 

1392 

1393 @staticmethod 

1394 def _content_uses_contextual_grounding(content: Sequence[BedrockContentItem]) -> bool: 

1395 """True if any content item carries a contextual-grounding qualifier 

1396 (``grounding_source``, ``query``, or the ``guard_content`` the response 

1397 itself is tagged with once grounding is present).""" 

1398 for item in content: 

1399 if (item.get("text") or {}).get("qualifiers"): # mutable-ok: read-only empty fallback 

1400 return True 

1401 return False 

1402 

1403 @staticmethod 

1404 def _bin_pack_bedrock_content( 

1405 content: Sequence[BedrockContentItem], 

1406 budget: int, 

1407 ) -> tuple[tuple[BedrockContentItem, ...], ...]: 

1408 """Pack whole content items, in order, into batches whose combined text 

1409 length stays within `budget`, in a single pass that carries the running 

1410 total rather than re-summing the open batch per item. 

1411 

1412 This is the fast-path half of the hybrid chunking strategy: bin-packing 

1413 at a conservative fixed budget keeps the common case at O(n / budget) 

1414 ApplyGuardrail calls instead of the O(log n) round trips pure reactive 

1415 bisection pays on every oversized request. An item whose own text 

1416 already exceeds `budget` is not split here -- it becomes its own 

1417 (still oversized) batch and is sent as-is; if AWS rejects that batch as 

1418 too large, `_apply_guardrail_content_with_chunking`'s existing 

1419 recursive-bisection fallback takes over for that batch only. 

1420 

1421 `budget` comes from the guardrail's ``chunk_budget_chars`` setting and 

1422 defaults to 25,000, matching ApplyGuardrail's default quota of 25 text 

1423 units (roughly 1,000 characters each) per second. Packing to that size and 

1424 posting sequentially is what keeps chunking from tripping the rate quota 

1425 and trading a size error for a throttle. Accounts with raised quotas can 

1426 configure a larger budget to spend fewer calls. 

1427 

1428 The budget is not a correctness dependency either way. AWS's effective cap 

1429 varies by account, region, and policy, is not a fixed character count, and 

1430 cannot be read from config, so any batch it still rejects falls back to 

1431 bisection, which self-corrects however wrong the value was. An over-large 

1432 budget therefore costs one extra probe-and-bisect round trip rather than 

1433 failing the request. 

1434 """ 

1435 if not content: 

1436 return (tuple(content),) 

1437 

1438 lengths: Final = tuple(len((item.get("text") or BedrockTextContent()).get("text") or "") for item in content) 

1439 

1440 def assign(carried: tuple[int, int], length: int) -> tuple[int, int]: 

1441 batch_index, used = carried 

1442 if used + length <= budget: 

1443 return batch_index, used + length 

1444 return batch_index + 1, length 

1445 

1446 batch_numbers: Final = (index for index, _ in tuple(accumulate(lengths, assign, initial=(0, 0)))[1:]) 

1447 return tuple( 

1448 tuple(item for _, item in group) 

1449 for _, group in groupby(zip(batch_numbers, content), key=lambda pair: pair[0]) 

1450 ) 

1451 

1452 @staticmethod 

1453 def _split_bedrock_content( 

1454 content: Sequence[BedrockContentItem], 

1455 ) -> tuple[tuple[BedrockContentItem, ...], tuple[BedrockContentItem, ...]] | None: 

1456 """Bisect `content` into two roughly-equal, non-empty halves. 

1457 

1458 When `content` already holds more than one item, it is split by list 

1459 length. When it holds exactly one item, that item's own text is split 

1460 instead (a list of length 1 has no items left to bisect, but one very 

1461 long message is still a single content item) -- at the whitespace 

1462 character nearest the midpoint rather than a raw character index, so 

1463 the cut never lands inside a word/token. This is a plain, lossless 

1464 cut with no overlap: concatenating the two fragments in order always 

1465 reproduces the original text exactly, so merging back at 

1466 ``_merge_logical_unit_outputs`` needs no reconciliation step. 

1467 

1468 Known, accepted limitation: whitespace splitting only guards against 

1469 *accidentally* severing a single token (one denied word, one PII 

1470 pattern) across the cut. It does not, and cannot without an overlap 

1471 window, stop a *multi-word* denied phrase deliberately positioned to 

1472 straddle the boundary -- each fragment can scan clean on its own and 

1473 still reassemble into the flagged phrase. AWS's own guidance on this 

1474 API acknowledges the same gap for input chunking ("a critical piece of 

1475 text could span two (or more) chunks if not carefully divided") with 

1476 no documented resolution, and overlap-and-reconcile was evaluated and 

1477 rejected for this PR: AWS's masking output has no documented 

1478 length-preservation guarantee, so reconciling an overlap region against 

1479 masked text is not sound in general. Out of scope for this PR. 

1480 

1481 Returns None when there is nothing left to split -- a single item 

1482 whose text is too short to halve into two non-empty pieces -- so the 

1483 caller can give up and propagate the original too-large error instead 

1484 of recursing forever. 

1485 """ 

1486 if len(content) > 1: 

1487 midpoint: Final = max(1, len(content) // 2) 

1488 return tuple(content[:midpoint]), tuple(content[midpoint:]) 

1489 

1490 text_content: Final = content[0].get("text") or BedrockTextContent() 

1491 text: Final = text_content.get("text") or "" 

1492 if len(text) < 2: 

1493 return None 

1494 split_at: Final = BedrockGuardrail._nearest_whitespace_split_index(text) 

1495 qualifiers: Final = text_content.get("qualifiers") 

1496 

1497 def fragment(piece: str) -> BedrockContentItem: 

1498 block: Final = ( 

1499 BedrockTextContent(text=piece, qualifiers=qualifiers) if qualifiers else BedrockTextContent(text=piece) 

1500 ) 

1501 return BedrockContentItem(text=block) 

1502 

1503 return (fragment(text[:split_at]),), (fragment(text[split_at:]),) 

1504 

1505 @staticmethod 

1506 def _nearest_whitespace_split_index(text: str) -> int: 

1507 """Return the index nearest `text`'s midpoint that falls on a whitespace 

1508 boundary, so splitting `text[:i]` / `text[i:]` there never severs a word. 

1509 

1510 Any Unicode whitespace counts, not just an ASCII space. Matching only `" "` 

1511 would leave the boundary unguarded for exactly the payloads that get large 

1512 enough to need splitting: JSON lines, source code, logs and transcripts are 

1513 newline or tab delimited, so a deny-listed word sitting at the midpoint of 

1514 one would be cut in half, scan clean on both fragments, and reassemble 

1515 intact. 

1516 

1517 The returned index always leaves both sides non-empty, which is what makes 

1518 the caller's recursion terminate. A boundary that would put the split at 0 

1519 or at ``len(text)`` is discarded: it would hand back a fragment identical to 

1520 the text just rejected as too large, AWS would reject that again, and each 

1521 retry would re-split it into the same unchanged fragment until the stack ran 

1522 out. The dangerous shape is a text whose only space at or after the midpoint 

1523 is its final character. 

1524 

1525 Falls back to the raw midpoint when no usable whitespace boundary exists, either 

1526 because `text` has none at all (a single giant token) or because the only 

1527 candidates were degenerate. That is still a correct, lossless split, just no 

1528 longer guaranteed word-safe for those cases. `text` must be at least two 

1529 characters, which `_split_bedrock_content` guarantees, so the midpoint itself 

1530 is never degenerate. 

1531 """ 

1532 midpoint: Final = len(text) // 2 

1533 before: Final = max((found.end() for found in _BEDROCK_WHITESPACE.finditer(text, 0, midpoint)), default=None) 

1534 after_match: Final = _BEDROCK_WHITESPACE.search(text, midpoint) 

1535 candidates: Final = sorted( 

1536 (split for split in (before, after_match.end() if after_match else None) if split is not None), 

1537 key=lambda split: abs(split - midpoint), 

1538 ) 

1539 return next((split for split in candidates if 0 < split < len(text)), midpoint) 

1540 

1541 @staticmethod 

1542 def _is_input_too_large_error(detail: object) -> bool: 

1543 """True if `detail` is an AWS error message for input exceeding the 

1544 per-request text-unit quota. 

1545 

1546 Matched on the message rather than the status code on purpose: AWS is not 

1547 consistent about which error it raises for this. Observed against a live 

1548 guardrail with an active content-filter policy, an oversized request comes 

1549 back as a *ThrottlingException* (429) reading ``Input text size (3273 text 

1550 units) exceeds the maximum allowed (1000 text units) for the content filter 

1551 policy (Classic tier)``, while the documented failure mode is a 

1552 ValidationException (400). Keying off the message covers both. 

1553 

1554 A guardrail *block* is also raised as an HTTPException with status 400, 

1555 but its ``detail`` is always a dict (built by 

1556 ``_get_http_exception_for_blocked_guardrail``); a non-200 API error's 

1557 ``detail`` is always the plain string returned by 

1558 ``_parse_bedrock_guardrail_error_response``. Checking ``isinstance(detail, 

1559 str)`` is therefore sufficient to never mistake a real block for a 

1560 too-large error. 

1561 """ 

1562 if not isinstance(detail, str): 

1563 return False 

1564 lowered: Final = detail.lower() 

1565 return any(substring in lowered for substring in _BEDROCK_TOO_LARGE_ERROR_SUBSTRINGS) 

1566 

1567 @staticmethod 

1568 def _merge_bedrock_guardrail_responses( 

1569 chunk_results: Sequence[BedrockContentChunkResult], 

1570 ) -> BedrockGuardrailResponse: 

1571 """Merge the per-chunk ApplyGuardrail responses of a chunked request into 

1572 one, so a caller cannot tell whether chunking happened. 

1573 

1574 Only ever called with responses that all passed (a block raises 

1575 immediately from ``_apply_guardrail_content_with_chunking`` and is never 

1576 added to this list). ``action`` is only set on the merged response when 

1577 at least one chunk's raw response included it, and left absent otherwise 

1578 -- mirroring a real single-call response and matching what 

1579 ``_build_tracing_detail`` treats as "Bedrock didn't report an action". 

1580 

1581 Fields this merge has no opinion on (``actionReason``, ``guardrailCoverage``, 

1582 ``blockedResponse``, anything AWS adds later) are carried over from the chunk 

1583 responses rather than dropped, so the response and the logged telemetry keep 

1584 the shape a single unchunked call returned. The merged keys below win. 

1585 

1586 Per AWS's documented ApplyGuardrail contract, a single call's ``outputs`` 

1587 is positionally parallel to the ``content`` items *of that call*: an 

1588 entry per item when anything in the call was masked, or an empty list 

1589 when nothing in the whole call was masked. Downstream masking 

1590 (``_apply_masking_to_messages``) walks the merged ``outputs`` by a single 

1591 running index across the *original, unchunked* message list, so a later 

1592 chunk's masked text must land at the same global position it would have 

1593 if chunking had never happened. Naively concatenating each chunk's 

1594 ``outputs`` breaks that whenever a chunk had nothing masked (its empty 

1595 list would otherwise silently swallow its items' slots, shifting every 

1596 later chunk's masked text left onto the wrong message). So every 

1597 item -- masked or not -- always contributes exactly one entry here, 

1598 falling back to that item's own original (unmasked) text when its 

1599 chunk returned no output for it; a wholly-untouched result is then 

1600 collapsed back to an empty ``outputs`` list to match a real single-call 

1601 no-op response. A chunk that returns a nonzero output count not equal 

1602 to its item count is passed through as-is instead of guessed at, since 

1603 AWS's docs don't cover partial masking within one multi-item call. 

1604 """ 

1605 logical_units: Final = BedrockGuardrail._group_fragment_units(chunk_results) 

1606 per_unit_outputs: Final = tuple(BedrockGuardrail._merge_logical_unit_outputs(unit) for unit in logical_units) 

1607 merged_outputs: Final = [ # mutable-ok: logged payload; redaction only traverses dict/list 

1608 output for outputs, _ in per_unit_outputs for output in outputs 

1609 ] 

1610 any_masked: Final = any(masked for _, masked in per_unit_outputs) 

1611 

1612 actions: Final = tuple( 

1613 chunk_result.response.get("action") 

1614 for chunk_result in chunk_results 

1615 if isinstance(chunk_result.response.get("action"), str) 

1616 ) 

1617 merged_action: Final = ( 

1618 "GUARDRAIL_INTERVENED" if "GUARDRAIL_INTERVENED" in actions else (actions[-1] if actions else None) 

1619 ) 

1620 merged_assessments: Final = [ # mutable-ok: logged payload; redaction only traverses dict/list 

1621 assessment 

1622 for chunk_result in chunk_results 

1623 for assessment in (chunk_result.response.get("assessments") or []) # mutable-ok: logged payload 

1624 ] 

1625 any_usage_reported: Final = any(chunk_result.response.get("usage") for chunk_result in chunk_results) 

1626 

1627 merged: Final[BedrockGuardrailResponse] = cast( # cast-ok: TypedDict assembled from a comprehension 

1628 BedrockGuardrailResponse, 

1629 { # mutable-ok: builds the TypedDict payload 

1630 key: value for chunk_result in chunk_results for key, value in chunk_result.response.items() 

1631 }, 

1632 ) 

1633 if merged_action is not None: 

1634 merged["action"] = merged_action 

1635 if merged_outputs and any_masked: 

1636 merged["outputs"] = merged_outputs 

1637 merged["output"] = merged_outputs 

1638 if merged_assessments: 

1639 merged["assessments"] = merged_assessments 

1640 if any_usage_reported: 

1641 merged["usage"] = BedrockGuardrail._sum_bedrock_guardrail_usage(chunk_results) 

1642 return merged 

1643 

1644 @staticmethod 

1645 def _sum_bedrock_guardrail_usage( 

1646 chunk_results: Sequence[BedrockContentChunkResult], 

1647 ) -> BedrockGuardrailUsage: 

1648 """Sum each chunk's ``usage`` counters field-by-field into one totals dict. 

1649 

1650 Keys are taken from the responses rather than from a fixed list, so a counter 

1651 this code does not know about (AWS has added several) is still summed and 

1652 reported instead of being silently dropped to zero.""" 

1653 return BedrockGuardrail._sum_usage_counters( 

1654 tuple( 

1655 chunk_result.response.get("usage") or {} # mutable-ok: read-only empty fallback 

1656 for chunk_result in chunk_results 

1657 ) 

1658 ) 

1659 

1660 @staticmethod 

1661 def _sum_usage_counters(usages: Sequence[BedrockGuardrailUsage]) -> BedrockGuardrailUsage: 

1662 return cast( # cast-ok: TypedDict assembled from a comprehension 

1663 BedrockGuardrailUsage, 

1664 { # mutable-ok: builds the TypedDict payload 

1665 key: sum(usage.get(key) or 0 for usage in usages) 

1666 for key in dict.fromkeys(key for usage in usages for key in usage) 

1667 }, 

1668 ) 

1669 

1670 @staticmethod 

1671 def _group_fragment_units( 

1672 chunk_results: Sequence[BedrockContentChunkResult], 

1673 ) -> tuple[tuple[BedrockContentChunkResult, ...], ...]: 

1674 """Group consecutive text-fragment chunk results back into the one content 

1675 item each group came from, leaving every ordinary chunk result as a unit of 

1676 one. 

1677 

1678 The group size is read off the results themselves rather than assumed, 

1679 because a single content item can be bisected repeatedly: two levels of 

1680 splitting yield four fragments for one item, not two. Assuming a fixed pair 

1681 here would emit two outputs for one message and shift every later message's 

1682 masked text onto the wrong message.""" 

1683 

1684 def advance(carried: tuple[int, bool], result: BedrockContentChunkResult) -> tuple[int, bool]: 

1685 remaining, _ = carried 

1686 if remaining == 0: 

1687 return max(1, result.fragment_group_size) - 1, True 

1688 return remaining - 1, False 

1689 

1690 starts: Final = tuple( 

1691 index 

1692 for index, (_, starts_unit) in enumerate(tuple(accumulate(chunk_results, advance, initial=(0, False)))[1:]) 

1693 if starts_unit 

1694 ) 

1695 return tuple(tuple(chunk_results[start:end]) for start, end in zip(starts, starts[1:] + (len(chunk_results),))) 

1696 

1697 @staticmethod 

1698 def _merge_logical_unit_outputs( 

1699 unit: tuple[BedrockContentChunkResult, ...], 

1700 ) -> tuple[tuple[BedrockGuardrailOutput, ...], bool]: 

1701 """Reduce one logical unit (a fragment group of any size, or a single chunk 

1702 result) to the ``BedrockGuardrailOutput`` entries it contributes to the 

1703 merged response, plus whether any masking actually happened in it. 

1704 

1705 Per AWS's documented ApplyGuardrail contract, a single call's 

1706 ``outputs`` is positionally parallel to the ``content`` items *of that 

1707 call*: an entry per item when anything in the call was masked, or an 

1708 empty list when nothing in the whole call was masked. Downstream 

1709 masking (``_apply_masking_to_messages``) walks the merged ``outputs`` 

1710 by a single running index across the *original, unchunked* message 

1711 list, so a later chunk's masked text must land at the same global 

1712 position it would have if chunking had never happened. So every item 

1713 -- masked or not -- always contributes exactly one entry here, falling 

1714 back to that item's own original (unmasked) text when its chunk 

1715 returned no output for it. A chunk that returns a nonzero output count 

1716 not equal to its item count is passed through as-is instead of guessed 

1717 at, since AWS's docs don't cover partial masking within one multi-item 

1718 call. 

1719 

1720 A unit holding more than one result is a fragment group: every result in it 

1721 is one fragment of a single content item's text, so the group collapses to 

1722 one entry built from each fragment's masked text (or that fragment's own 

1723 original text where it came back unmasked), concatenated in order. This 

1724 holds for any group size, not only two. 

1725 """ 

1726 if len(unit) > 1: 

1727 

1728 def fragment_outputs(result: BedrockContentChunkResult) -> tuple[BedrockGuardrailOutput, ...]: 

1729 return tuple(result.response.get("outputs") or result.response.get("output") or ()) 

1730 

1731 def fragment_text(result: BedrockContentChunkResult) -> str: 

1732 source: Final = (result.content[0].get("text") or {}).get( # mutable-ok: read-only fallback 

1733 "text" 

1734 ) or "" 

1735 outputs: Final = fragment_outputs(result) 

1736 masked: Final = outputs[0].get("text") if outputs else None 

1737 return masked if masked is not None else source 

1738 

1739 merged_text: Final = "".join(fragment_text(result) for result in unit) 

1740 any_masked: Final = any(fragment_outputs(result) for result in unit) 

1741 return (BedrockGuardrailOutput(text=merged_text),), any_masked 

1742 

1743 (chunk_result,) = unit 

1744 chunk_outputs: Final = chunk_result.response.get("outputs") or chunk_result.response.get("output") or () 

1745 if len(chunk_outputs) == len(chunk_result.content): 

1746 return tuple(chunk_outputs), bool(chunk_outputs) 

1747 if not chunk_outputs: 

1748 return tuple( 

1749 BedrockGuardrailOutput( 

1750 text=(item.get("text") or {}).get("text") or "" # mutable-ok: read-only fallback 

1751 ) 

1752 for item in chunk_result.content 

1753 ), False 

1754 return tuple(chunk_outputs), True 

1755 

1756 async def _sign_and_post( 

1757 self, 

1758 prepared_request: "AWSPreparedRequest", 

1759 request_data: dict | None, 

1760 event_type: GuardrailEventHooks, 

1761 start_time: "datetime", 

1762 log_transport_failure: bool = True, 

1763 ) -> httpx.Response: 

1764 """POST a signed Bedrock request, logging+raising on network/HTTP errors. 

1765 

1766 Shared by both the ApplyGuardrail and InvokeGuardrailChecks paths so their 

1767 transport-error handling cannot drift. Returns the raw ``httpx.Response`` on 

1768 success (including non-2xx that httpx did not raise on); the 200-path logging, 

1769 status and tracing stay with each caller because the two APIs report differently. 

1770 

1771 ``log_transport_failure=False`` suppresses the ``guardrail_failed_to_respond`` 

1772 entry for a non-200 that is re-raised as an ``HTTPException``, for callers that 

1773 own consolidated per-request logging. The ApplyGuardrail path needs this: 

1774 ``AsyncHTTPHandler.post`` calls ``raise_for_status()``, so every non-200 lands 

1775 in this handler, and one logical request can legitimately produce several of 

1776 them (a too-large probe, then each rejected bisection level) while still 

1777 succeeding overall. Logging per attempt would report a recovered request as 

1778 several failures plus a success. 

1779 

1780 The connection-level branch below (timeout, endpoint down) still logs 

1781 unconditionally: it re-raises the original exception rather than an 

1782 ``HTTPException``, so no consolidating caller catches it, and suppressing it 

1783 would drop the only record of the failure. 

1784 """ 

1785 try: 

1786 return await self.async_handler.post( 

1787 url=prepared_request.url, 

1788 data=prepared_request.body, 

1789 headers=prepared_request.headers, 

1790 ) 

1791 except HTTPException: 

1792 # Propagate HTTPException (e.g. from non-200 path) as-is 

1793 raise 

1794 except Exception as e: 

1795 # If this is an HTTP error with a response body (e.g. httpx.HTTPStatusError), 

1796 # extract the AWS error message and propagate it 

1797 err_response: Final = getattr(e, "response", None) 

1798 if isinstance(err_response, httpx.Response): 

1799 try: 

1800 ( 

1801 status_code, 

1802 detail_message, 

1803 ) = self._parse_bedrock_guardrail_error_response(err_response) 

1804 if log_transport_failure: 

1805 self.add_standard_logging_guardrail_information_to_request_data( 

1806 guardrail_provider=self.guardrail_provider, 

1807 guardrail_json_response={ # mutable-ok: logging helper requires a dict 

1808 "error": detail_message 

1809 }, 

1810 request_data=request_data or {}, # mutable-ok: logging helper requires a dict 

1811 guardrail_status="guardrail_failed_to_respond", 

1812 start_time=start_time.timestamp(), 

1813 end_time=datetime.now(timezone.utc).timestamp(), 

1814 duration=(datetime.now(timezone.utc) - start_time).total_seconds(), 

1815 event_type=event_type, 

1816 ) 

1817 raise HTTPException(status_code=status_code, detail=detail_message) from e 

1818 except HTTPException: 

1819 raise 

1820 # Endpoint down, timeout, or other HTTP/network errors 

1821 verbose_proxy_logger.error("Bedrock AI: failed to make guardrail request: %s", str(e)) 

1822 self.add_standard_logging_guardrail_information_to_request_data( 

1823 guardrail_provider=self.guardrail_provider, 

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

1825 request_data=request_data or {}, # mutable-ok: logging helper requires a dict 

1826 guardrail_status="guardrail_failed_to_respond", 

1827 start_time=start_time.timestamp(), 

1828 end_time=datetime.now(timezone.utc).timestamp(), 

1829 duration=(datetime.now(timezone.utc) - start_time).total_seconds(), 

1830 event_type=event_type, 

1831 ) 

1832 raise 

1833 

1834 ########### InvokeGuardrailChecks (resource-less, detect-only) ############ 

1835 

1836 @staticmethod 

1837 def _chunk_texts_into_checks_messages( 

1838 role: Literal["user", "assistant", "system"], texts: list[str] 

1839 ) -> list[BedrockChecksMessage]: 

1840 """Group ``texts`` into role-tagged messages of <= the API content-block cap. 

1841 

1842 A source message with more text blocks than the per-message limit is split 

1843 across multiple messages so EVERY block is scanned. Truncating instead would 

1844 let a user hide prohibited content past the limit (guardrail bypass). 

1845 """ 

1846 cap: Final = _BEDROCK_CHECKS_MAX_CONTENT_BLOCKS 

1847 return [ 

1848 BedrockChecksMessage( 

1849 role=role, 

1850 content=[{"text": text} for text in texts[start : start + cap]], 

1851 ) 

1852 for start in range(0, len(texts), cap) 

1853 ] 

1854 

1855 def _build_invoke_guardrail_checks_messages( 

1856 self, 

1857 source: Literal["INPUT", "OUTPUT"], 

1858 messages: list[AllMessageValues] | None = None, 

1859 response: litellm.ModelResponse | None = None, 

1860 ) -> list[BedrockChecksMessage]: 

1861 """Build the role-tagged `messages` array for InvokeGuardrailChecks. 

1862 

1863 INPUT scans the request messages, OUTPUT scans the model response as an 

1864 ``assistant`` turn. Every non-empty text block of every message is scanned; 

1865 messages exceeding the per-message content-block cap are split into multiple 

1866 messages rather than truncated. 

1867 

1868 INPUT content is tagged ``user`` regardless of the caller-supplied role. 

1869 Bedrock excludes ``system`` content from prompt-attack evaluation, so 

1870 trusting a caller's ``system``/``developer`` label would let an injection 

1871 avoid the promptAttack check. At the proxy every INPUT message is 

1872 caller-controlled, so all of it is treated as untrusted user input, matching 

1873 AWS guidance to tag untrusted content as user input. 

1874 """ 

1875 if source == "OUTPUT": 

1876 # Reuse the ApplyGuardrail output extractor (single source of truth for 

1877 # pulling assistant text out of a ModelResponse), then re-tag as an 

1878 # assistant turn for the role-based InvokeGuardrailChecks payload. 

1879 output_request: Final = self._create_bedrock_output_content_request(response=response) 

1880 output_texts: Final = [ 

1881 text for item in output_request.get("content") or [] if (text := (item.get("text") or {}).get("text")) 

1882 ] 

1883 return self._chunk_texts_into_checks_messages("assistant", output_texts) 

1884 

1885 return [ 

1886 checks_message 

1887 for message in messages or [] 

1888 for checks_message in self._chunk_texts_into_checks_messages( 

1889 "user", 

1890 [block.text for block in self.get_content_items_for_message(message) or [] if block.text], 

1891 ) 

1892 ] 

1893 

1894 async def _make_invoke_guardrail_checks_request( 

1895 self, 

1896 source: Literal["INPUT", "OUTPUT"], 

1897 messages: list[AllMessageValues] | None = None, 

1898 response: litellm.ModelResponse | None = None, 

1899 request_data: dict | None = None, 

1900 logging_event_type: GuardrailEventHooks | None = None, 

1901 ) -> BedrockGuardrailResponse: 

1902 """Run the resource-less InvokeGuardrailChecks API and enforce thresholds. 

1903 

1904 Detect-only: the API returns scores, never rewritten content. We map scores 

1905 to a block decision via the configured thresholds. On a pass we return an 

1906 empty ``BedrockGuardrailResponse`` (downstream masking treats it as a no-op). 

1907 """ 

1908 start_time: Final = datetime.now(timezone.utc) 

1909 

1910 checks_messages: Final = self._build_invoke_guardrail_checks_messages( 

1911 source=source, messages=messages, response=response 

1912 ) 

1913 if not checks_messages: 

1914 # Nothing to scan (e.g. tool-only turn) -> allow, like ApplyGuardrail does. 

1915 return BedrockGuardrailResponse() 

1916 

1917 api_key: Final[str | None] = request_data.get("api_key") if request_data else None 

1918 credentials, aws_region_name = await run_aws_signing( 

1919 self._load_credentials, bearer_token=bedrock_bearer_token(api_key) 

1920 ) 

1921 body: Final[dict[str, object]] = {"messages": checks_messages, "checks": self.checks} 

1922 

1923 prepared_request: Final = await run_aws_signing( 

1924 self._prepare_request, 

1925 credentials=credentials, 

1926 data=body, 

1927 optional_params=self.optional_params, 

1928 aws_region_name=aws_region_name, 

1929 api_key=api_key, 

1930 request_path=_BEDROCK_INVOKE_GUARDRAIL_CHECKS_PATH, 

1931 ) 

1932 verbose_proxy_logger.debug("Bedrock InvokeGuardrailChecks request url: %s", prepared_request.url) 

1933 

1934 event_type: Final = logging_event_type or ( 

1935 GuardrailEventHooks.pre_call if source == "INPUT" else GuardrailEventHooks.post_call 

1936 ) 

1937 

1938 httpx_response: Final = await self._sign_and_post( 

1939 prepared_request=prepared_request, 

1940 request_data=request_data, 

1941 event_type=event_type, 

1942 start_time=start_time, 

1943 ) 

1944 

1945 if httpx_response.status_code != 200: 

1946 status_code, detail_message = self._parse_bedrock_guardrail_error_response(httpx_response) 

1947 verbose_proxy_logger.error( 

1948 "Bedrock InvokeGuardrailChecks: error response. Status %s: %s", 

1949 httpx_response.status_code, 

1950 detail_message, 

1951 ) 

1952 self.add_standard_logging_guardrail_information_to_request_data( 

1953 guardrail_provider=self.guardrail_provider, 

1954 guardrail_json_response={"error": detail_message}, 

1955 request_data=request_data or {}, # mutable-ok: logging helper requires a dict 

1956 guardrail_status="guardrail_failed_to_respond", 

1957 start_time=start_time.timestamp(), 

1958 end_time=datetime.now(timezone.utc).timestamp(), 

1959 duration=(datetime.now(timezone.utc) - start_time).total_seconds(), 

1960 event_type=event_type, 

1961 ) 

1962 raise HTTPException(status_code=status_code, detail=detail_message) 

1963 

1964 try: 

1965 json_response: Final = TypeAdapter(BedrockGuardrailChecksResponse).validate_python(httpx_response.json()) 

1966 except (ValidationError, ValueError) as e: 

1967 verbose_proxy_logger.error("Bedrock InvokeGuardrailChecks: unparseable 200 response: %s", str(e)) 

1968 self.add_standard_logging_guardrail_information_to_request_data( 

1969 guardrail_provider=self.guardrail_provider, 

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

1971 request_data=request_data or {}, # mutable-ok: logging helper requires a dict 

1972 guardrail_status="guardrail_failed_to_respond", 

1973 start_time=start_time.timestamp(), 

1974 end_time=datetime.now(timezone.utc).timestamp(), 

1975 duration=(datetime.now(timezone.utc) - start_time).total_seconds(), 

1976 event_type=event_type, 

1977 ) 

1978 raise HTTPException( 

1979 status_code=500, 

1980 detail={"error": "Bedrock InvokeGuardrailChecks returned an unexpected response shape"}, 

1981 ) from e 

1982 violations: Final = self._collect_invoke_checks_violations(json_response) 

1983 

1984 # Log a copy with PII location offsets stripped: offsets + the (separately 

1985 # logged) request messages would otherwise reconstruct the detected PII span. 

1986 self.add_standard_logging_guardrail_information_to_request_data( 

1987 guardrail_provider=self.guardrail_provider, 

1988 guardrail_json_response=self._sanitize_invoke_checks_response_for_logging(json_response), 

1989 request_data=request_data or {}, # mutable-ok: logging helper requires a dict 

1990 guardrail_status=self._get_invoke_checks_status(bool(violations)), 

1991 start_time=start_time.timestamp(), 

1992 end_time=datetime.now(timezone.utc).timestamp(), 

1993 duration=(datetime.now(timezone.utc) - start_time).total_seconds(), 

1994 event_type=event_type, 

1995 tracing_detail=self._build_invoke_checks_tracing_detail(violations) if violations else None, 

1996 ) 

1997 

1998 if violations: 

1999 raise self._get_block_exception_for_checks(violations, request_data=request_data) 

2000 

2001 return BedrockGuardrailResponse() 

2002 

2003 def _collect_invoke_checks_violations( 

2004 self, response: BedrockGuardrailChecksResponse | None 

2005 ) -> list[BedrockChecksViolation]: 

2006 """Return the check results whose score meets/exceeds the configured threshold. 

2007 

2008 Only checks present in the configured ``checks`` block are evaluated; a 

2009 threshold of ``None`` makes that check detect-only (never contributes a 

2010 violation). A truncated sensitiveInformation result counts as a violation 

2011 (fail closed: omitted detections were never scored). Only the non-sensitive 

2012 label (category/type) and the numeric score are kept -- never offsets or 

2013 matched text. 

2014 """ 

2015 results: Final[dict[str, Any]] = dict((response or {}).get("results") or {}) 

2016 # (results key, score field, label field, threshold). PII uses 

2017 # confidenceScore/type; the other two use severityScore/category. 

2018 check_specs: Final = [ 

2019 ( 

2020 "contentFilter", 

2021 "severityScore", 

2022 "category", 

2023 self.content_filter_threshold, 

2024 ), 

2025 ("promptAttack", "severityScore", "category", self.prompt_attack_threshold), 

2026 ( 

2027 "sensitiveInformation", 

2028 "confidenceScore", 

2029 "type", 

2030 self.pii_confidence_threshold, 

2031 ), 

2032 ] 

2033 

2034 configured_checks: Final = self.checks or {} 

2035 violations: Final[list[BedrockChecksViolation]] = [] 

2036 for check_key, score_field, label_field, threshold in check_specs: 

2037 if threshold is None or check_key not in configured_checks: 

2038 continue 

2039 check_result = results.get(check_key) or {} 

2040 if check_key == "sensitiveInformation" and check_result.get("truncated"): 

2041 violations.append({"check": check_key, "truncated": True}) 

2042 for entry in check_result.get("results") or []: 

2043 score = entry.get(score_field) 

2044 if isinstance(score, (int, float)) and float(score) >= threshold: 

2045 violation: BedrockChecksViolation = ( 

2046 {"check": check_key, "category": entry.get("category"), "severityScore": float(score)} 

2047 if score_field == "severityScore" 

2048 else {"check": check_key, "type": entry.get("type"), "confidenceScore": float(score)} 

2049 ) 

2050 violations.append(violation) 

2051 return violations 

2052 

2053 @staticmethod 

2054 def _sanitize_invoke_checks_response_for_logging( 

2055 response: BedrockGuardrailChecksResponse, 

2056 ) -> dict[str, Any]: 

2057 """Strip PII location offsets from a checks response before it is logged.""" 

2058 sanitized: Final[dict[str, Any]] = copy.deepcopy(dict(response)) 

2059 sensitive: Final = (sanitized.get("results") or {}).get("sensitiveInformation") or {} 

2060 for entry in sensitive.get("results") or []: 

2061 if isinstance(entry, dict): 

2062 for key in _BEDROCK_CHECKS_PII_LOCATION_KEYS: 

2063 entry.pop(key, None) 

2064 return sanitized 

2065 

2066 @staticmethod 

2067 def _get_invoke_checks_status(over_threshold: bool) -> GuardrailStatus: 

2068 return "guardrail_intervened" if over_threshold else "success" 

2069 

2070 @staticmethod 

2071 def _build_invoke_checks_tracing_detail( 

2072 violations: list[BedrockChecksViolation], 

2073 ) -> GuardrailTracingDetail: 

2074 tracing_detail: Final[GuardrailTracingDetail] = {} 

2075 categories: Final = [ 

2076 label 

2077 for label in (v.get("category") or v.get("type") for v in violations) 

2078 if isinstance(label, str) and label 

2079 ] 

2080 if categories: 

2081 tracing_detail["violation_categories"] = categories 

2082 tracing_detail["guardrail_action"] = "GUARDRAIL_INTERVENED" if violations else "NONE" 

2083 return tracing_detail 

2084 

2085 def _get_block_exception_for_checks( 

2086 self, violations: list[BedrockChecksViolation], request_data: dict | None = None 

2087 ) -> HTTPException | ModifyResponseException: 

2088 """Build the block exception for an over-threshold InvokeGuardrailChecks result. 

2089 

2090 Mirrors ``_get_http_exception_for_blocked_guardrail``'s return-type branching. 

2091 The detail carries only non-sensitive labels + scores (no offsets / raw input). 

2092 """ 

2093 if self.disable_exception_on_block is True: 

2094 _request_data: Final = request_data or {} 

2095 return ModifyResponseException( 

2096 message="Violated guardrail policy", 

2097 model=_request_data.get("model", "bedrock-guardrail"), 

2098 request_data=_request_data, 

2099 guardrail_name=self.guardrail_name, 

2100 ) 

2101 return HTTPException( 

2102 status_code=400, 

2103 detail={ 

2104 "error": "Violated guardrail policy", 

2105 "bedrock_guardrail_checks": violations, 

2106 }, 

2107 ) 

2108 

2109 def _check_bedrock_response_for_exception(self, response) -> bool: 

2110 """ 

2111 Return True if the Bedrock ApplyGuardrail response indicates an exception. 

2112 

2113 Works with real httpx.Response objects and MagicMock responses used in tests. 

2114 """ 

2115 payload = None 

2116 

2117 try: 

2118 json_method: Final = getattr(response, "json", None) 

2119 if callable(json_method): 

2120 payload = json_method() 

2121 except Exception: 

2122 payload = None 

2123 

2124 if payload is None: 

2125 try: 

2126 raw: Final = getattr(response, "content", None) 

2127 if isinstance(raw, (bytes, bytearray)): 

2128 payload = json.loads(raw.decode("utf-8")) 

2129 else: 

2130 text: Final = getattr(response, "text", None) 

2131 if isinstance(text, str): 

2132 payload = json.loads(text) 

2133 except Exception: 

2134 # Can't parse -> assume no explicit Exception marker 

2135 return False 

2136 

2137 if not isinstance(payload, dict): 

2138 return False 

2139 

2140 return "Exception" in payload.get("Output", {}).get("__type", "") 

2141 

2142 def _get_bedrock_guardrail_response_status(self, response: httpx.Response) -> GuardrailStatus: 

2143 """ 

2144 Get the status of the bedrock guardrail response. 

2145 

2146 Returns: 

2147 "success": Content allowed through with no violations 

2148 "guardrail_intervened": Content blocked due to policy violations 

2149 "guardrail_failed_to_respond": Technical error or API failure 

2150 """ 

2151 if response.status_code == 200: 

2152 if self._check_bedrock_response_for_exception(response): 

2153 return "guardrail_failed_to_respond" 

2154 

2155 # Check if the guardrail would block content 

2156 try: 

2157 _json_response: Final = response.json() 

2158 bedrock_guardrail_response: Final = BedrockGuardrailResponse(**_json_response) 

2159 if self._should_raise_guardrail_blocked_exception(bedrock_guardrail_response): 

2160 return "guardrail_intervened" 

2161 except Exception: 

2162 pass 

2163 

2164 return "success" 

2165 return "guardrail_failed_to_respond" 

2166 

2167 def _parse_bedrock_guardrail_error_response(self, response: httpx.Response) -> tuple[int, str]: 

2168 """ 

2169 Parse AWS Bedrock guardrail error response body to extract status code and message. 

2170 

2171 AWS may return shapes like {"message": "..."} or {"error": {"message": "..."}}. 

2172 Returns (status_code, message) for use in HTTPException. 

2173 """ 

2174 status_code: Final = response.status_code 

2175 message: Final = "Bedrock guardrail request failed" 

2176 try: 

2177 body: Final = response.json() 

2178 except Exception: 

2179 text: Final = getattr(response, "text", None) or "" 

2180 if isinstance(text, str) and text.strip(): 

2181 return (status_code, text.strip()) 

2182 return (status_code, message) 

2183 if isinstance(body, dict): 

2184 if isinstance(body.get("message"), str): 

2185 return (status_code, body["message"]) 

2186 err: Final = body.get("error") 

2187 if isinstance(err, dict) and isinstance(err.get("message"), str): 

2188 return (status_code, err["message"]) 

2189 if isinstance(err, str): 

2190 return (status_code, err) 

2191 return (status_code, message) 

2192 

2193 def _build_tracing_detail( 

2194 self, response: BedrockGuardrailResponse, aws_region_name: str | None 

2195 ) -> GuardrailTracingDetail: 

2196 """ 

2197 Build the tracing detail from the raw Bedrock response, before 

2198 redaction, so downstream loggers (OTEL, Langfuse, ...) get the 

2199 actual category names rather than the "[REDACTED]" sentinel that 

2200 replaces customWords.match later. Bedrock's top-level ``action`` 

2201 field ("GUARDRAIL_INTERVENED" or "NONE") is also surfaced so the 

2202 OTEL integration can expose it as a queryable span attribute 

2203 without re-parsing the redacted guardrail_response blob. 

2204 """ 

2205 violation_categories: Final = self._extract_violation_category_names(response) 

2206 bedrock_action: Final = response.get("action") 

2207 categories_detail: Final[GuardrailTracingDetail] = {"violation_categories": violation_categories} 

2208 action_detail: Final[GuardrailTracingDetail] = {"guardrail_action": bedrock_action} 

2209 tracing_detail: Final[GuardrailTracingDetail] = { 

2210 **(categories_detail if violation_categories else _NO_TRACING_DETAIL), 

2211 **(action_detail if isinstance(bedrock_action, str) else _NO_TRACING_DETAIL), 

2212 **self._usage_tracing_detail(response.get("usage"), aws_region_name), 

2213 } 

2214 return tracing_detail 

2215 

2216 @staticmethod 

2217 def _usage_tracing_detail( 

2218 usage: BedrockGuardrailUsage | None, aws_region_name: str | None 

2219 ) -> GuardrailTracingDetail: 

2220 if not isinstance(usage, dict): 

2221 return _NO_TRACING_DETAIL 

2222 usage_units: Final = { # mutable-ok: json.dumps'd into spend log metadata downstream 

2223 key: value for key, value in usage.items() if isinstance(value, int) 

2224 } 

2225 if not usage_units: 

2226 return _NO_TRACING_DETAIL 

2227 cost_by_unit: Final = bedrock_guardrail_cost_by_unit(usage_units=usage_units, aws_region_name=aws_region_name) 

2228 priced_detail: Final[GuardrailTracingDetail] = {"guardrail_cost_by_unit": cost_by_unit} 

2229 usage_detail: Final[GuardrailTracingDetail] = { 

2230 "guardrail_usage": usage_units, 

2231 "guardrail_cost": guardrail_cost_total(cost_by_unit), 

2232 **(priced_detail if cost_by_unit is not None else _NO_TRACING_DETAIL), 

2233 } 

2234 return usage_detail 

2235 

2236 def _extract_violation_category_names(self, response: BedrockGuardrailResponse) -> list[str]: 

2237 """ 

2238 Flatten the BLOCKED assessments into a list of human-readable category 

2239 names suitable for queryable OTEL / standard-logging attributes. 

2240 

2241 SECURITY: only emits the non-sensitive policy *label* (topic name, 

2242 content-filter type, PII entity type, named-regex name). The raw 

2243 ``match`` field is intentionally NOT used — it carries the user's 

2244 original input that triggered the rule (e.g. a credit-card number 

2245 that hit a regex, or the literal custom word). Surfacing it to 

2246 telemetry would re-introduce the sensitive content the guardrail 

2247 was supposed to keep out. Entries that only have a ``match`` (bare 

2248 customWords, unnamed regexes) are therefore skipped — operators 

2249 can still see the count in ``_extract_blocked_assessments`` which 

2250 feeds the HTTP error detail. 

2251 """ 

2252 names: Final[list[str]] = [] 

2253 for block in self._extract_blocked_assessments(response): 

2254 for match in block.get("matches", []) or []: 

2255 # Allow-list non-sensitive labels only. Never fall back to 

2256 # `match.get("match")` — that's user-submitted content. 

2257 label = match.get("name") or match.get("type") 

2258 if isinstance(label, str) and label: 

2259 names.append(label) 

2260 return names 

2261 

2262 def _extract_blocked_assessments(self, response: BedrockGuardrailResponse) -> list[dict]: 

2263 """ 

2264 Walk the Bedrock guardrail response and emit a structured list of 

2265 BLOCKED assessment entries describing exactly which policies fired. 

2266 

2267 Mirrors the iteration in `_should_raise_guardrail_blocked_exception()` 

2268 but produces a list of `{policy, matches}` dicts instead of a bool. 

2269 Each `match` carries the originating subcategory, type, action, and 

2270 matched term where available, so the client can render a precise 

2271 explanation of the violation. 

2272 """ 

2273 blocked: Final[list[dict]] = [] 

2274 assessments: Final = response.get("assessments", []) or [] 

2275 

2276 for assessment in assessments: 

2277 # Topic policy 

2278 topic_policy = assessment.get("topicPolicy") 

2279 if topic_policy: 

2280 topic_matches = [ 

2281 { 

2282 "category": "topics", 

2283 "name": t.get("name"), 

2284 "type": t.get("type"), 

2285 "action": t.get("action"), 

2286 } 

2287 for t in (topic_policy.get("topics") or []) 

2288 if t.get("action") == "BLOCKED" 

2289 ] 

2290 if topic_matches: 

2291 blocked.append({"policy": "topicPolicy", "matches": topic_matches}) 

2292 

2293 # Content policy 

2294 content_policy = assessment.get("contentPolicy") 

2295 if content_policy: 

2296 content_matches = [ 

2297 { 

2298 "category": "filters", 

2299 "type": f.get("type"), 

2300 "confidence": f.get("confidence"), 

2301 "filterStrength": f.get("filterStrength"), 

2302 "action": f.get("action"), 

2303 } 

2304 for f in (content_policy.get("filters") or []) 

2305 if f.get("action") == "BLOCKED" 

2306 ] 

2307 if content_matches: 

2308 blocked.append({"policy": "contentPolicy", "matches": content_matches}) 

2309 

2310 # Word policy 

2311 word_policy = assessment.get("wordPolicy") 

2312 if word_policy: 

2313 word_matches: list[dict] = [] 

2314 for w in word_policy.get("customWords") or []: 

2315 if w.get("action") == "BLOCKED": 

2316 word_matches.append( 

2317 { 

2318 "category": "customWords", 

2319 "match": w.get("match"), 

2320 "action": w.get("action"), 

2321 } 

2322 ) 

2323 for mw in word_policy.get("managedWordLists") or []: 

2324 if mw.get("action") == "BLOCKED": 

2325 word_matches.append( 

2326 { 

2327 "category": "managedWordLists", 

2328 "type": mw.get("type"), 

2329 "match": mw.get("match"), 

2330 "action": mw.get("action"), 

2331 } 

2332 ) 

2333 if word_matches: 

2334 blocked.append({"policy": "wordPolicy", "matches": word_matches}) 

2335 

2336 # Sensitive information policy (PII) 

2337 sensitive_info = assessment.get("sensitiveInformationPolicy") 

2338 if sensitive_info: 

2339 pii_matches: list[dict] = [] 

2340 for p in sensitive_info.get("piiEntities") or []: 

2341 if p.get("action") == "BLOCKED": 

2342 pii_matches.append( 

2343 { 

2344 "category": "piiEntities", 

2345 "type": p.get("type"), 

2346 "match": p.get("match"), 

2347 "action": p.get("action"), 

2348 } 

2349 ) 

2350 for r in sensitive_info.get("regexes") or []: 

2351 if r.get("action") == "BLOCKED": 

2352 pii_matches.append( 

2353 { 

2354 "category": "regexes", 

2355 "name": r.get("name"), 

2356 "regex": r.get("regex"), 

2357 "match": r.get("match"), 

2358 "action": r.get("action"), 

2359 } 

2360 ) 

2361 if pii_matches: 

2362 blocked.append( 

2363 { 

2364 "policy": "sensitiveInformationPolicy", 

2365 "matches": pii_matches, 

2366 } 

2367 ) 

2368 

2369 # Contextual grounding policy 

2370 contextual = assessment.get("contextualGroundingPolicy") 

2371 if contextual: 

2372 grounding_matches = [ 

2373 { 

2374 "category": "filters", 

2375 "type": f.get("type"), 

2376 "threshold": f.get("threshold"), 

2377 "score": f.get("score"), 

2378 "action": f.get("action"), 

2379 } 

2380 for f in (contextual.get("filters") or []) 

2381 if f.get("action") == "BLOCKED" 

2382 ] 

2383 if grounding_matches: 

2384 blocked.append( 

2385 { 

2386 "policy": "contextualGroundingPolicy", 

2387 "matches": grounding_matches, 

2388 } 

2389 ) 

2390 

2391 return blocked 

2392 

2393 def _get_http_exception_for_blocked_guardrail( 

2394 self, response: BedrockGuardrailResponse, request_data: dict | None = None 

2395 ) -> HTTPException | ModifyResponseException: 

2396 """ 

2397 Get the HTTP exception for a blocked guardrail. 

2398 """ 

2399 bedrock_guardrail_output_text: str = "" 

2400 outputs: Final[list[BedrockGuardrailOutput] | None] = response.get("outputs", []) or [] 

2401 if outputs: 

2402 for output in outputs: 

2403 if output.get("text"): 

2404 bedrock_guardrail_output_text += output.get("text") or "" 

2405 

2406 if self.disable_exception_on_block is True: 

2407 _request_data: Final = request_data or {} 

2408 return ModifyResponseException( 

2409 message=bedrock_guardrail_output_text, 

2410 model=_request_data.get("model", "bedrock-guardrail"), 

2411 request_data=_request_data, 

2412 guardrail_name=self.guardrail_name, 

2413 ) 

2414 

2415 detail: Final[dict[str, object]] = { 

2416 "error": "Violated guardrail policy", 

2417 "bedrock_guardrail_response": bedrock_guardrail_output_text, 

2418 } 

2419 if self.guardrailIdentifier: 

2420 detail["guardrailIdentifier"] = self.guardrailIdentifier 

2421 if self.guardrailVersion: 

2422 detail["guardrailVersion"] = self.guardrailVersion 

2423 

2424 assessments: Final = self._extract_blocked_assessments(response) 

2425 if assessments: 

2426 detail["assessments"] = _redact_assessment_match_fields(assessments) 

2427 

2428 return HTTPException(status_code=400, detail=detail) 

2429 

2430 def _should_raise_guardrail_blocked_exception(self, response: BedrockGuardrailResponse) -> bool: 

2431 """ 

2432 Only raise exception for "BLOCKED" actions, not for "ANONYMIZED" actions. 

2433 

2434 If `self.mask_request_content` or `self.mask_response_content` is set to `True`, 

2435 then use the output from the guardrail to mask the request or response content. 

2436 

2437 However, even with masking enabled, content with action="BLOCKED" should still 

2438 raise an exception, only content with action="ANONYMIZED" should be masked. 

2439 """ 

2440 

2441 # if no intervention, return False 

2442 if response.get("action") != "GUARDRAIL_INTERVENED": 

2443 return False 

2444 

2445 # Check assessments to determine if any actions were BLOCKED (vs ANONYMIZED) 

2446 # NOTE: Use `.get("k") or []` not `.get("k", [])` — Bedrock can return explicit 

2447 # JSON null; dict.get("k", []) then yields None, and `for x in None` raises. 

2448 assessments: Final = response.get("assessments") or [] 

2449 if not assessments: 

2450 return False 

2451 

2452 for assessment in assessments: 

2453 # Check topic policy 

2454 topic_policy = assessment.get("topicPolicy") 

2455 if topic_policy: 

2456 topics = topic_policy.get("topics") or [] 

2457 for topic in topics: 

2458 if topic.get("action") == "BLOCKED": 

2459 return True 

2460 

2461 # Check content policy 

2462 content_policy = assessment.get("contentPolicy") 

2463 if content_policy: 

2464 filters = content_policy.get("filters") or [] 

2465 for filter_item in filters: 

2466 if filter_item.get("action") == "BLOCKED": 

2467 return True 

2468 

2469 # Check word policy 

2470 word_policy = assessment.get("wordPolicy") 

2471 if word_policy: 

2472 custom_words = word_policy.get("customWords") or [] 

2473 for custom_word in custom_words: 

2474 if custom_word.get("action") == "BLOCKED": 

2475 return True 

2476 managed_words = word_policy.get("managedWordLists") or [] 

2477 for managed_word in managed_words: 

2478 if managed_word.get("action") == "BLOCKED": 

2479 return True 

2480 

2481 # Check sensitive information policy 

2482 sensitive_info_policy = assessment.get("sensitiveInformationPolicy") 

2483 if sensitive_info_policy: 

2484 pii_entities = sensitive_info_policy.get("piiEntities") or [] 

2485 if pii_entities: 

2486 for pii_entity in pii_entities: 

2487 if pii_entity.get("action") == "BLOCKED": 

2488 return True 

2489 regexes = sensitive_info_policy.get("regexes") or [] 

2490 if regexes: 

2491 for regex in regexes: 

2492 if regex.get("action") == "BLOCKED": 

2493 return True 

2494 

2495 # Check contextual grounding policy 

2496 contextual_grounding_policy = assessment.get("contextualGroundingPolicy") 

2497 if contextual_grounding_policy: 

2498 grounding_filters = contextual_grounding_policy.get("filters") or [] 

2499 for grounding_filter in grounding_filters: 

2500 if grounding_filter.get("action") == "BLOCKED": 

2501 return True 

2502 

2503 # If we got here, intervention occurred but no BLOCKED actions found 

2504 # This means all actions were ANONYMIZED or NONE, so don't raise exception 

2505 return False 

2506 

2507 async def async_pre_call_hook( 

2508 self, 

2509 user_api_key_dict: UserAPIKeyAuth, 

2510 cache: DualCache, 

2511 data: dict, 

2512 call_type: CallTypesLiteral, 

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

2514 verbose_proxy_logger.debug("Inside Bedrock Pre-Call Hook for call_type: %s", call_type) 

2515 

2516 from litellm.proxy.common_utils.callback_utils import ( 

2517 add_guardrail_to_applied_guardrails_header, 

2518 ) 

2519 

2520 event_type: Final[GuardrailEventHooks] = GuardrailEventHooks.pre_call 

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

2522 return data 

2523 

2524 new_messages: Final = self.get_guardrails_messages_for_call_type( 

2525 call_type=cast(CallTypes, call_type), 

2526 data=data, 

2527 ) 

2528 

2529 # Handle None case 

2530 if new_messages is None: 

2531 verbose_proxy_logger.debug("No messages found for call_type, skipping guardrail") 

2532 return data 

2533 

2534 filter_result: Final = self._prepare_guardrail_messages_for_role(messages=new_messages) 

2535 

2536 filtered_messages: Final = filter_result.payload_messages 

2537 if not filtered_messages: 

2538 verbose_proxy_logger.debug("No user-role messages available for guardrail payload") 

2539 return data 

2540 

2541 ######################################################### 

2542 ########## 1. Make the Bedrock API request ########## 

2543 ######################################################### 

2544 # A block with disable_exception_on_block=True raises ModifyResponseException 

2545 # from make_bedrock_api_request; that propagates to the endpoint handler, 

2546 # which returns a 200 whose message is the guardrail's blockedInputMessaging. 

2547 bedrock_guardrail_response: Final = await self.make_bedrock_api_request( 

2548 source="INPUT", 

2549 messages=filtered_messages, 

2550 request_data=data, 

2551 logging_event_type=GuardrailEventHooks.pre_call, 

2552 ) 

2553 ######################################################### 

2554 

2555 ######################################################### 

2556 ########## 2. Update the messages with the guardrail response ########## 

2557 ######################################################### 

2558 updated_subset: Final = self._update_messages_with_updated_bedrock_guardrail_response( 

2559 messages=filtered_messages, 

2560 bedrock_guardrail_response=bedrock_guardrail_response, 

2561 ) 

2562 data["messages"] = self._merge_filtered_messages( 

2563 original_messages=filter_result.original_messages or new_messages, 

2564 updated_target_messages=updated_subset, 

2565 target_indices=filter_result.target_indices, 

2566 ) 

2567 

2568 ######################################################### 

2569 ########## 3. Add the guardrail to the applied guardrails header ########## 

2570 ######################################################### 

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

2572 return data 

2573 

2574 async def async_moderation_hook( 

2575 self, 

2576 data: dict, 

2577 user_api_key_dict: UserAPIKeyAuth, 

2578 call_type: CallTypesLiteral, 

2579 ): 

2580 from litellm.proxy.common_utils.callback_utils import ( 

2581 add_guardrail_to_applied_guardrails_header, 

2582 ) 

2583 

2584 event_type: GuardrailEventHooks = GuardrailEventHooks.during_call 

2585 if call_type == CallTypes.call_mcp_tool.value: 

2586 event_type = GuardrailEventHooks.during_mcp_call 

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

2588 return 

2589 

2590 new_messages: Final = self.get_guardrails_messages_for_call_type( 

2591 call_type=cast(CallTypes, call_type), 

2592 data=data, 

2593 ) 

2594 

2595 if new_messages is None: 

2596 verbose_proxy_logger.warning("Bedrock AI: not running guardrail. No messages in data") 

2597 return 

2598 

2599 filter_result: Final = self._prepare_guardrail_messages_for_role(messages=new_messages) 

2600 filtered_messages: Final = filter_result.payload_messages 

2601 if not filtered_messages: 

2602 verbose_proxy_logger.debug("Bedrock AI: not running guardrail. No user-role messages") 

2603 return 

2604 

2605 ######################################################### 

2606 ########## 1. Make the Bedrock API request ########## 

2607 ######################################################### 

2608 # A block with disable_exception_on_block=True raises ModifyResponseException 

2609 # from make_bedrock_api_request. Because during_call runs in an asyncio.gather 

2610 # alongside the LLM call (common_request_processing.py), swallowing the 

2611 # exception here to set data["mock_response"] was ineffective: route_request 

2612 # unpacked kwargs before this hook ran, and the LLM task's response was taken 

2613 # unconditionally. Letting the exception propagate cancels the LLM task and 

2614 # the endpoint handler returns the block response. 

2615 bedrock_guardrail_response: Final = await self.make_bedrock_api_request( 

2616 source="INPUT", 

2617 messages=filtered_messages, 

2618 request_data=data, 

2619 logging_event_type=event_type, 

2620 ) 

2621 ######################################################### 

2622 

2623 ######################################################### 

2624 ########## 2. Update the messages with the guardrail response ########## 

2625 ######################################################### 

2626 updated_subset: Final = self._update_messages_with_updated_bedrock_guardrail_response( 

2627 messages=filtered_messages, 

2628 bedrock_guardrail_response=bedrock_guardrail_response, 

2629 ) 

2630 data["messages"] = self._merge_filtered_messages( 

2631 original_messages=filter_result.original_messages or new_messages, 

2632 updated_target_messages=updated_subset, 

2633 target_indices=filter_result.target_indices, 

2634 ) 

2635 

2636 ######################################################### 

2637 ########## 3. Add the guardrail to the applied guardrails header ########## 

2638 ######################################################### 

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

2640 

2641 return data 

2642 

2643 async def async_post_call_success_hook( 

2644 self, 

2645 data: dict, 

2646 user_api_key_dict: UserAPIKeyAuth, 

2647 response, 

2648 ): 

2649 from litellm.proxy.common_utils.callback_utils import ( 

2650 add_guardrail_to_applied_guardrails_header, 

2651 ) 

2652 from litellm.types.guardrails import GuardrailEventHooks 

2653 

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

2655 return 

2656 

2657 new_messages: Final[list[AllMessageValues] | None] = data.get("messages") 

2658 if new_messages is None: 

2659 verbose_proxy_logger.warning("Bedrock AI: not running guardrail. No messages in data") 

2660 return 

2661 

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

2663 # to avoid sending empty content to Bedrock (e.g., during tool calls) 

2664 if isinstance(response, litellm.ModelResponse): 

2665 has_text_content = False 

2666 for choice in response.choices: 

2667 if isinstance(choice, litellm.Choices): 

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

2669 has_text_content = True 

2670 break 

2671 

2672 if not has_text_content: 

2673 verbose_proxy_logger.warning("Bedrock AI: not running guardrail. No output text in response") 

2674 return 

2675 

2676 ######################################################### 

2677 ########## 1. Make Bedrock API requests ########## 

2678 ######################################################### 

2679 # post_call is the response-validation hook by definition — only scan 

2680 # OUTPUT. Input scanning belongs to pre_call / during_call hooks, which 

2681 # users should configure if they want input validation. Running an 

2682 # extra INPUT scan here produced a duplicate post-call entry in the 

2683 # trace and made no semantic sense for a "post-call" event. 

2684 # A block with disable_exception_on_block=True raises ModifyResponseException 

2685 # from make_bedrock_api_request; that propagates to the endpoint handler, 

2686 # which returns a 200 whose message is the guardrail's blockedInputMessaging. 

2687 # Attach the LLM response to original_response so the synthetic block reply 

2688 # reports the real token usage the upstream call consumed instead of zero. 

2689 try: 

2690 output_content_bedrock: Final = await self.make_bedrock_api_request( 

2691 source="OUTPUT", 

2692 response=response, 

2693 messages=new_messages, 

2694 request_data=data, 

2695 logging_event_type=GuardrailEventHooks.post_call, 

2696 ) 

2697 except ModifyResponseException as e: 

2698 if e.original_response is None: 

2699 e.original_response = response 

2700 raise 

2701 

2702 ######################################################### 

2703 ########## 2. Apply masking to response with output guardrail response ########## 

2704 ######################################################### 

2705 if output_content_bedrock is not None: 

2706 self._apply_masking_to_response( 

2707 response=response, 

2708 bedrock_guardrail_response=output_content_bedrock, 

2709 ) 

2710 

2711 ######################################################### 

2712 ########## 3. Add the guardrail to the applied guardrails header ########## 

2713 ######################################################### 

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

2715 

2716 ########### HELPER FUNCTIONS for bedrock guardrails ############################ 

2717 ############################################################################## 

2718 ############################################################################## 

2719 def _update_messages_with_updated_bedrock_guardrail_response( 

2720 self, 

2721 messages: list[AllMessageValues], 

2722 bedrock_guardrail_response: BedrockGuardrailResponse, 

2723 ) -> list[AllMessageValues]: 

2724 """ 

2725 Use the output from the bedrock guardrail to mask sensitive content in messages. 

2726 

2727 Args: 

2728 messages: Original list of messages 

2729 bedrock_guardrail_response: Response from Bedrock guardrail containing masked content 

2730 

2731 Returns: 

2732 List of messages with content masked according to guardrail response 

2733 """ 

2734 # Get masked texts from guardrail response 

2735 masked_texts: Final = self._extract_masked_texts_from_response(bedrock_guardrail_response) 

2736 

2737 # If guardrail provided masked output, use it regardless of masking flags 

2738 # because the guardrail has already determined this content needs anonymization 

2739 if masked_texts: 

2740 verbose_proxy_logger.debug("Bedrock guardrail provided masked output, applying to messages") 

2741 return self._apply_masking_to_messages(messages=messages, masked_texts=masked_texts) 

2742 

2743 # If masking is enabled but no masked texts available, still try to apply 

2744 # (this maintains backward compatibility for edge cases) 

2745 if self.mask_request_content or self.mask_response_content: 

2746 verbose_proxy_logger.debug( 

2747 "Masking enabled but no masked output from guardrail, returning original messages" 

2748 ) 

2749 

2750 return messages 

2751 

2752 async def async_post_call_streaming_iterator_hook( 

2753 self, 

2754 user_api_key_dict: UserAPIKeyAuth, 

2755 response: Any, 

2756 request_data: dict, 

2757 ) -> AsyncGenerator[ModelResponseStream, None]: 

2758 """ 

2759 Process streaming response chunks. 

2760 

2761 Collect content from the stream and run the bedrock OUTPUT scan 

2762 (post_call only validates the response). 

2763 """ 

2764 if self._streams_incrementally(): 

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

2766 UnifiedLLMGuardrails, 

2767 ) 

2768 

2769 async for streamed_chunk in UnifiedLLMGuardrails().async_post_call_streaming_iterator_hook( 

2770 user_api_key_dict=user_api_key_dict, 

2771 response=response, 

2772 request_data=request_data, 

2773 guardrail_to_apply=self, 

2774 buffer_until_moderated_default=False, 

2775 ): 

2776 yield streamed_chunk 

2777 return 

2778 

2779 # Responses-API events are neither chat-completions chunks nor raw 

2780 # Anthropic SSE, so the assembly below cannot scan them; the unified 

2781 # guardrail's translation layer can, with buffering semantics kept. 

2782 if _is_responses_api_route(user_api_key_dict.request_route): 

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

2784 UnifiedLLMGuardrails, 

2785 ) 

2786 

2787 async for translated_chunk in UnifiedLLMGuardrails().async_post_call_streaming_iterator_hook( 

2788 user_api_key_dict=user_api_key_dict, 

2789 response=response, 

2790 request_data=request_data, 

2791 guardrail_to_apply=self, 

2792 buffer_until_moderated_default=True, 

2793 ): 

2794 yield translated_chunk 

2795 return 

2796 

2797 # Import here to avoid circular imports 

2798 from litellm.llms.base_llm.base_model_iterator import MockResponseIterator 

2799 from litellm.main import stream_chunk_builder 

2800 from litellm.types.utils import TextCompletionResponse 

2801 

2802 # Collect all chunks to process them together 

2803 started_at: Final = time.monotonic() 

2804 all_chunks: Final[list[ModelResponseStream]] = [] 

2805 async for chunk in response: 

2806 all_chunks.append(chunk) 

2807 

2808 # /v1/messages arrives as SSE frames, which stream_chunk_builder cannot assemble 

2809 raw_sse: Final = is_raw_sse_stream(all_chunks) 

2810 assembled_model_response: ModelResponse | TextCompletionResponse | None = ( 

2811 assemble_anthropic_sse_stream(all_chunks, restore_identity=True) 

2812 if raw_sse 

2813 else stream_chunk_builder(chunks=all_chunks) 

2814 ) 

2815 if isinstance(assembled_model_response, ModelResponse): 

2816 pre_guardrail_text: Final = model_response_text(assembled_model_response) 

2817 _pre_block_response: Final = assembled_model_response 

2818 #################################################################### 

2819 ########## 1. Make Bedrock Apply Guardrail API request ########## 

2820 # 

2821 # post_call only scans OUTPUT — input scanning belongs to 

2822 # pre_call / during_call. Bedrock will raise if the response 

2823 # violates the guardrail policy. 

2824 ################################################################### 

2825 # A block with disable_exception_on_block=True raises ModifyResponseException 

2826 # from make_bedrock_api_request. Non-streaming paths let it propagate so 

2827 # the endpoint handler turns it into a 200. Streaming can't do that: the 

2828 # SSE response headers are already flushed, so a raise would be serialized 

2829 # as an error frame by async_streaming_data_generator. Instead, replace 

2830 # the assembled response with the synthetic block content in-place and 

2831 # yield it as a normal stream, matching the shape a non-streaming block 

2832 # produces. 

2833 try: 

2834 output_guardrail_response = await self.make_bedrock_api_request( 

2835 source="OUTPUT", 

2836 response=assembled_model_response, 

2837 messages=request_data.get("messages"), 

2838 request_data=request_data, 

2839 logging_event_type=GuardrailEventHooks.post_call, 

2840 ) 

2841 except HTTPException as block_exc: 

2842 block_detail: Final = block_exc.detail 

2843 # A policy block is the only 400 carrying a structured detail; a service failure 

2844 # either details a plain string or reports a non-400 status. Re-raising a service 

2845 # failure keeps its real status, but only while the headers are unflushed: past the 

2846 # first keepalive ping the raise reaches nobody, so it has to travel as a frame too 

2847 is_block: Final = raw_sse and block_exc.status_code == 400 and isinstance(block_detail, Mapping) 

2848 headers_flushed: Final = keepalive_ping_has_fired( 

2849 time.monotonic() - started_at, litellm.anthropic_sse_ping_interval_seconds 

2850 ) 

2851 if not raw_sse or (not is_block and not headers_flushed): 

2852 raise 

2853 block_message, _ = serialize_http_exception_detail(block_detail) 

2854 for error_frame in anthropic_sse_error_frames( 

2855 block_message if is_block else f"{block_exc.status_code}: {block_message}" 

2856 ): 

2857 yield error_frame 

2858 return 

2859 except ModifyResponseException as e: 

2860 if raw_sse: 

2861 e.model = _pre_block_response.model or e.model 

2862 if e.original_response is None: 

2863 e.original_response = _pre_block_response 

2864 for block_chunk in AnthropicMessagesHandler().build_block_sse_chunks(e, stream_started=False): 

2865 yield block_chunk 

2866 return 

2867 # Preserve upstream usage from the LLM call we already 

2868 # consumed. Non-streaming blocks carry it via 

2869 # ModifyResponseException.original_response + 

2870 # _blocked_response_usage; streaming has to do the copy 

2871 # itself since the exception can't escape this generator. 

2872 _original_usage: Final = getattr(assembled_model_response, "usage", None) 

2873 assembled_model_response = ModelResponse( 

2874 choices=[ 

2875 Choices( 

2876 index=0, 

2877 message=Message(role="assistant", content=e.message), 

2878 finish_reason="content_filter", 

2879 ) 

2880 ], 

2881 model=e.model, 

2882 ) 

2883 if _original_usage is not None: 

2884 assembled_model_response.usage = _original_usage 

2885 output_guardrail_response = None 

2886 

2887 ######################################################################### 

2888 ########## 2. Apply masking to response with output guardrail response ########## 

2889 ######################################################################### 

2890 if output_guardrail_response is not None: 

2891 self._apply_masking_to_response( 

2892 response=assembled_model_response, 

2893 bedrock_guardrail_response=output_guardrail_response, 

2894 ) 

2895 

2896 ######################################################################### 

2897 ########## 3. Return the (potentially masked) chunks ########## 

2898 ######################################################################### 

2899 if raw_sse: 

2900 for sse_chunk in ( 

2901 anthropic_sse_chunks_from_response(assembled_model_response) 

2902 if model_response_text(assembled_model_response) != pre_guardrail_text 

2903 else all_chunks 

2904 ): 

2905 yield sse_chunk 

2906 return 

2907 

2908 mock_response: Final = MockResponseIterator(model_response=assembled_model_response) 

2909 

2910 # Return the reconstructed stream 

2911 async for chunk in mock_response: 

2912 yield chunk 

2913 elif raw_sse: 

2914 # Forwarding an unscannable stream would silently disable the guardrail, so fail closed. 

2915 # A raise cannot reach the client once a keepalive ping has flushed the headers, so the 

2916 # refusal travels as a frame, matching how a block is delivered above 

2917 for error_frame in anthropic_sse_error_frames( 

2918 f"{self.guardrail_name}: streamed response could not be assembled for scanning, blocking it" 

2919 ): 

2920 yield error_frame 

2921 return 

2922 else: 

2923 for chunk in all_chunks: 

2924 yield chunk 

2925 

2926 def _extract_masked_texts_from_response(self, bedrock_guardrail_response: BedrockGuardrailResponse) -> list[str]: 

2927 """ 

2928 Extract all masked text outputs from the guardrail response. 

2929 

2930 Args: 

2931 bedrock_guardrail_response: Response from Bedrock guardrail 

2932 

2933 Returns: 

2934 List of masked text strings 

2935 """ 

2936 masked_output_text: Final[list[str]] = [] 

2937 masked_outputs: Final[list[BedrockGuardrailOutput] | None] = bedrock_guardrail_response.get("outputs", []) or [] 

2938 if not masked_outputs: 

2939 verbose_proxy_logger.debug("No masked outputs found in guardrail response") 

2940 return [] 

2941 

2942 for output in masked_outputs: 

2943 text_content: str | None = output.get("text") 

2944 if text_content is not None: 

2945 masked_output_text.append(text_content) 

2946 

2947 return masked_output_text 

2948 

2949 def _apply_masking_to_messages( 

2950 self, messages: list[AllMessageValues], masked_texts: list[str] 

2951 ) -> list[AllMessageValues]: 

2952 """ 

2953 Apply masked texts to message content using index tracking. 

2954 

2955 Args: 

2956 messages: Original messages 

2957 masked_texts: List of masked text strings from guardrail 

2958 

2959 Returns: 

2960 Updated messages with masked content 

2961 """ 

2962 updated_messages: Final = [] 

2963 masking_index = 0 

2964 

2965 for message in messages: 

2966 new_message = message.copy() 

2967 content = new_message.get("content") 

2968 

2969 # Skip messages with no content 

2970 if content is None: 

2971 updated_messages.append(new_message) 

2972 continue 

2973 

2974 # Handle string content 

2975 if isinstance(content, str): 

2976 if masking_index < len(masked_texts): 

2977 new_message["content"] = masked_texts[masking_index] 

2978 masking_index += 1 

2979 # Handle list content 

2980 elif isinstance(content, list): 

2981 new_message["content"], masking_index = self._mask_content_list( 

2982 content_list=content, 

2983 masked_texts=masked_texts, 

2984 masking_index=masking_index, 

2985 ) 

2986 

2987 updated_messages.append(new_message) 

2988 

2989 return updated_messages 

2990 

2991 def _mask_content_list( 

2992 self, content_list: Sequence[object], masked_texts: list[str], masking_index: int 

2993 ) -> tuple[list[Any], int]: 

2994 """ 

2995 Apply masking to a list of content items. 

2996 

2997 Args: 

2998 content_list: List of content items 

2999 masked_texts: List of masked text strings 

3000 starting_index: Starting index in the masked_texts list 

3001 

3002 Returns: 

3003 Updated content list with masked items 

3004 """ 

3005 new_content: Final[list[dict[str, object] | str]] = [] 

3006 for item in content_list: 

3007 if isinstance(item, dict) and "text" in item: 

3008 new_item = item.copy() 

3009 if masking_index < len(masked_texts): 

3010 new_item["text"] = masked_texts[masking_index] 

3011 masking_index += 1 

3012 new_content.append(new_item) 

3013 elif isinstance(item, str): 

3014 if masking_index < len(masked_texts): 

3015 item = masked_texts[masking_index] 

3016 masking_index += 1 

3017 if item is not None: 

3018 new_content.append(item) 

3019 

3020 return new_content, masking_index 

3021 

3022 def _apply_masking_to_response( 

3023 self, 

3024 response: object, 

3025 bedrock_guardrail_response: BedrockGuardrailResponse, 

3026 ) -> None: 

3027 """ 

3028 Apply masked content from bedrock guardrail to the response object. 

3029 

3030 Args: 

3031 response: The response object to modify 

3032 bedrock_guardrail_response: Response from Bedrock guardrail containing masked content 

3033 """ 

3034 # Get masked texts from guardrail response 

3035 masked_texts: Final = self._extract_masked_texts_from_response(bedrock_guardrail_response) 

3036 

3037 if not masked_texts: 

3038 verbose_proxy_logger.debug("No masked outputs found, skipping response masking") 

3039 return 

3040 

3041 verbose_proxy_logger.debug("Applying masking to response with %d masked texts", len(masked_texts)) 

3042 

3043 # Apply masking to ModelResponse 

3044 if isinstance(response, litellm.ModelResponse): 

3045 self._apply_masking_to_model_response(response, masked_texts) 

3046 else: 

3047 verbose_proxy_logger.warning("Unsupported response type for masking: %s", type(response)) 

3048 

3049 def _apply_masking_to_model_response(self, response: litellm.ModelResponse, masked_texts: list[str]) -> None: 

3050 """ 

3051 Apply masked texts to a ModelResponse object. 

3052 

3053 Args: 

3054 response: The ModelResponse object to modify in-place 

3055 masked_texts: List of masked text strings from guardrail 

3056 """ 

3057 masking_index = 0 

3058 

3059 for choice in response.choices: 

3060 if isinstance(choice, Choices): 

3061 # For chat completions 

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

3063 if masking_index < len(masked_texts): 

3064 choice.message.content = masked_texts[masking_index] 

3065 masking_index += 1 

3066 verbose_proxy_logger.debug("Applied masking to choice message content") 

3067 elif isinstance(choice, StreamingChoices): 

3068 # For streaming responses, modify delta content 

3069 if choice.delta.content and isinstance(choice.delta.content, str): 

3070 if masking_index < len(masked_texts): 

3071 choice.delta.content = masked_texts[masking_index] 

3072 masking_index += 1 

3073 verbose_proxy_logger.debug("Applied masking to choice delta content") 

3074 elif isinstance(choice, TextChoices): 

3075 # For text completions 

3076 if choice.text and isinstance(choice.text, str): 

3077 if masking_index < len(masked_texts): 

3078 choice.text = masked_texts[masking_index] 

3079 masking_index += 1 

3080 verbose_proxy_logger.debug("Applied masking to choice text content") 

3081 

3082 @staticmethod 

3083 def _incremental_scan_cache() -> DualCache: 

3084 """Resolve the cache used to remember which segments a session already scanned. 

3085 

3086 Prefers the proxy's shared cache (``internal_usage_cache.dual_cache``), which is 

3087 backed by Redis when the deployment configures it, so incremental state is shared 

3088 across proxy instances. Falls back to a process-local ``DualCache`` singleton when 

3089 the proxy is not running (e.g. unit tests), where sharing does not apply. 

3090 """ 

3091 from litellm.integrations.custom_guardrail import dc as fallback_cache 

3092 

3093 try: 

3094 from litellm.proxy.proxy_server import proxy_logging_obj as _proxy_logging 

3095 except Exception: # noqa: BLE001 # proxy not importable outside the server; use local fallback 

3096 return fallback_cache 

3097 if _proxy_logging is not None: 

3098 return _proxy_logging.internal_usage_cache.dual_cache 

3099 return fallback_cache 

3100 

3101 def _bedrock_response_has_masked_output(self, response: BedrockGuardrailResponse) -> bool: 

3102 """Return True if the guardrail rewrote (masked/anonymized) any scanned text. 

3103 

3104 Bedrock returns non-empty ``output``/``outputs`` text only when it changed the 

3105 content; an ``action == "NONE"`` response leaves both empty. 

3106 """ 

3107 for field in ("output", "outputs"): 

3108 items = response.get(field) or [] 

3109 if any(isinstance(item, dict) and item.get("text") for item in items): 

3110 return True 

3111 return False 

3112 

3113 async def _apply_incremental_request_scan( 

3114 self, 

3115 texts: list[str], 

3116 inputs: "GenericGuardrailAPIInputs", 

3117 request_data: dict, 

3118 ) -> Optional["GenericGuardrailAPIInputs"]: 

3119 """Scan only the text segments not already seen earlier in this session. 

3120 

3121 Returns ``None`` when incremental scanning is inactive (feature off, no 

3122 session id, masking enabled, or cache unavailable) or when the guardrail 

3123 turns out to mask content, telling the caller to run the normal full scan. 

3124 Otherwise scans only the new segments and skips the Bedrock call entirely 

3125 when nothing is new. Incremental mode is for blocking/detection guardrails 

3126 only: if the guardrail returns masked output it cannot be applied to the 

3127 skipped context, so the scan falls back to the full path and no session 

3128 state is recorded. 

3129 """ 

3130 cache: Final = self._incremental_scan_cache() 

3131 

3132 new_texts: Final = await self.filter_new_texts_for_session( 

3133 texts=texts, 

3134 request_data=request_data, 

3135 cache=cache, 

3136 ) 

3137 if new_texts is None: 

3138 return None 

3139 

3140 if not new_texts: 

3141 verbose_proxy_logger.debug("Bedrock Guardrail: no new messages to scan for this session, skipping API call") 

3142 return inputs 

3143 

3144 bedrock_response: Final = await self.make_bedrock_api_request( 

3145 source="INPUT", 

3146 messages=[ChatCompletionUserMessage(role="user", content=text) for text in new_texts], 

3147 request_data=request_data, 

3148 logging_event_type=GuardrailEventHooks.pre_call, 

3149 ) 

3150 

3151 if self._bedrock_response_has_masked_output(bedrock_response): 

3152 verbose_proxy_logger.warning( 

3153 "Bedrock Guardrail %s: guardrail returned masked/anonymized content; " 

3154 "only_scan_new_messages cannot apply masking to skipped context, falling back to a full-context scan", 

3155 self.guardrail_name, 

3156 ) 

3157 return None 

3158 

3159 await self.mark_texts_scanned( 

3160 texts=texts, 

3161 request_data=request_data, 

3162 cache=cache, 

3163 ) 

3164 return inputs 

3165 

3166 async def apply_guardrail( 

3167 self, 

3168 inputs: "GenericGuardrailAPIInputs", 

3169 request_data: dict, 

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

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

3172 ) -> "GenericGuardrailAPIInputs": 

3173 """ 

3174 Apply Bedrock guardrail to a batch of texts for testing purposes. 

3175 

3176 This method allows users to test Bedrock guardrails without making actual LLM calls. 

3177 It creates mock messages to test the guardrail functionality. 

3178 

3179 Args: 

3180 inputs: Dictionary containing texts and optional images 

3181 request_data: Request data dictionary for logging metadata 

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

3183 logging_obj: Optional logging object 

3184 

3185 Returns: 

3186 GenericGuardrailAPIInputs - processed_texts may be masked, images unchanged 

3187 

3188 Raises: 

3189 Exception: If content is blocked by Bedrock guardrail 

3190 """ 

3191 # NOTE: Use `or []` to handle case where inputs["texts"] is explicitly None. 

3192 # dict.get("texts", []) would return None if the key exists with a None value. 

3193 texts: Final = inputs.get("texts") or [] 

3194 try: 

3195 verbose_proxy_logger.debug("Bedrock Guardrail: Applying guardrail to %s text(s)", len(texts)) 

3196 

3197 if input_type == "request": 

3198 incremental_result: Final = await self._apply_incremental_request_scan( 

3199 texts=texts, 

3200 inputs=inputs, 

3201 request_data=request_data, 

3202 ) 

3203 if incremental_result is not None: 

3204 return incremental_result 

3205 

3206 masked_texts = [] 

3207 

3208 selection: Final = self._select_messages_for_apply_guardrail( 

3209 texts=texts, 

3210 inputs=inputs, 

3211 request_data=request_data, 

3212 input_type=input_type, 

3213 ) 

3214 if selection.skip_scan: 

3215 return inputs 

3216 filtered_messages: Final = selection.filtered_messages 

3217 scanned_slice: Final = selection.scanned_slice 

3218 scanned_role_subset: Final = selection.scanned_role_subset 

3219 

3220 # Bedrock will throw an error if there is no text to process 

3221 if filtered_messages: 

3222 _log_hook = GuardrailEventHooks.pre_call if input_type == "request" else GuardrailEventHooks.post_call 

3223 # Map the abstract input_type to the Bedrock source parameter. 

3224 # "request" -> INPUT (scan user-supplied content) 

3225 # "response" -> OUTPUT (scan model-generated content) 

3226 # Bedrock guardrail policies are often configured differently 

3227 # for Input vs Output (e.g. PII blocking only on Output), so 

3228 # the source MUST match where the text originated. 

3229 bedrock_source: Final[Literal["INPUT", "OUTPUT"]] = "OUTPUT" if input_type == "response" else "INPUT" 

3230 if bedrock_source == "OUTPUT": 

3231 # Build a synthetic ModelResponse whose choices carry the 

3232 # text(s) to scan, so _create_bedrock_output_content_request 

3233 # can produce the correct Bedrock OUTPUT payload. 

3234 synthetic_response: Final = ModelResponse( 

3235 choices=[ 

3236 Choices( 

3237 index=_idx, 

3238 message=Message( 

3239 role="assistant", 

3240 content=str(_msg.get("content") or ""), 

3241 ), 

3242 finish_reason="stop", 

3243 ) 

3244 for _idx, _msg in enumerate(filtered_messages) 

3245 ] 

3246 ) 

3247 bedrock_response = await self.make_bedrock_api_request( 

3248 source="OUTPUT", 

3249 response=synthetic_response, 

3250 messages=request_data.get("messages"), 

3251 request_data=request_data, 

3252 logging_event_type=_log_hook, 

3253 ) 

3254 else: 

3255 bedrock_response = await self.make_bedrock_api_request( 

3256 source="INPUT", 

3257 messages=filtered_messages, 

3258 request_data=request_data, 

3259 logging_event_type=_log_hook, 

3260 ) 

3261 

3262 # Apply any masking that was applied by the guardrail 

3263 output_list: Final = bedrock_response.get("output") 

3264 if output_list: 

3265 # If the guardrail returned modified content, use that 

3266 for output_item in output_list: 

3267 text_content = output_item.get("text") 

3268 if text_content: 

3269 masked_text = str(text_content) 

3270 masked_texts.append(masked_text) 

3271 else: 

3272 outputs_list: Final = bedrock_response.get("outputs") 

3273 if outputs_list: 

3274 # Fallback to outputs field if output is not available 

3275 for output_item in outputs_list: 

3276 text_content = output_item.get("text") 

3277 if text_content: 

3278 masked_text = str(text_content) 

3279 masked_texts.append(masked_text) 

3280 

3281 # Reconcile masked output with the flat `texts` list (write back to 

3282 # the scanned slice only, or skip if it can't be aligned). 

3283 masked_texts = self._merge_masked_texts( 

3284 masked_texts=masked_texts, 

3285 texts=texts, 

3286 scanned_slice=scanned_slice, 

3287 scanned_role_subset=scanned_role_subset, 

3288 ) 

3289 

3290 verbose_proxy_logger.debug("Bedrock Guardrail: Successfully applied guardrail") 

3291 

3292 inputs["texts"] = masked_texts 

3293 return inputs 

3294 

3295 except (HTTPException, ModifyResponseException): 

3296 # Let guardrail blocking exceptions propagate as-is so the proxy can 

3297 # return the correct HTTP status (400 for HTTPException, 200 with the 

3298 # block message for ModifyResponseException in disable_exception_on_block 

3299 # mode). Without this, the generic except below wraps them into a plain 

3300 # Exception, losing the semantics and preventing the proxy from 

3301 # properly blocking the call. 

3302 raise 

3303 except Exception as e: 

3304 verbose_proxy_logger.error("Bedrock Guardrail: Failed to apply guardrail: %s", str(e)) 

3305 raise Exception(f"Bedrock guardrail failed: {e}")