From 6105aaa63e66f52c591b9baf74e4290cb6978f19 Mon Sep 17 00:00:00 2001 From: ishaan-j-berri Date: Sat, 31 Oct 2026 16:37:45 -0700 Subject: [PATCH] Configure full Bedrock guardrail assessments (#49217) (#49470) --- litellm/proxy/_lazy_openapi_snapshot.json | 19 ++++++ .../guardrail_hooks/bedrock_guardrails.py | 11 +-- .../guardrails/guardrail_initializers.py | 1 + litellm/types/guardrails.py | 10 +++ .../test_bedrock_guardrails.py | 67 ++++++++++++++++++- ui/litellm-dashboard/src/lib/http/schema.d.ts | 7 ++ 6 files changed, 109 insertions(+), 6 deletions(-) diff --git a/litellm/proxy/_lazy_openapi_snapshot.json b/litellm/proxy/_lazy_openapi_snapshot.json index 5f9d03dbb05..d6d1636909b 100644 --- a/litellm/proxy/_lazy_openapi_snapshot.json +++ b/litellm/proxy/_lazy_openapi_snapshot.json @@ -16484,6 +16484,14 @@ "title": "BedrockChecksSensitiveInformationModel", "type": "object" }, + "BedrockGuardrailOutputScope": { + "enum": [ + "INTERVENTIONS", + "FULL" + ], + "title": "BedrockGuardrailOutputScope", + "type": "string" + }, "BlockedWord": { "description": "Represents a blocked word with its action and optional description", "properties": { @@ -18716,6 +18724,17 @@ ], "description": "Optional parameters for the guardrail" }, + "outputScope": { + "anyOf": [ + { + "$ref": "#/components/schemas/BedrockGuardrailOutputScope" + }, + { + "type": "null" + } + ], + "description": "ApplyGuardrail response scope. FULL includes detected and non-detected findings. Omit to retain Bedrock's default of returning detected findings only." + }, "output_parse_pii": { "anyOf": [ { diff --git a/litellm/proxy/guardrails/guardrail_hooks/bedrock_guardrails.py b/litellm/proxy/guardrails/guardrail_hooks/bedrock_guardrails.py index 3f412d7c379..6a773f41f01 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/bedrock_guardrails.py +++ b/litellm/proxy/guardrails/guardrail_hooks/bedrock_guardrails.py @@ -65,6 +65,7 @@ from litellm.proxy.guardrails.anthropic_sse import ( ) from litellm.types.guardrails import ( BedrockChecksConfigModel, + BedrockGuardrailOutputScope, BedrockGuardrailStreamingParams, GuardrailEventHooks, LitellmParams, @@ -289,6 +290,7 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM): streaming_buffer_release_on_scan: bool | None = None, streaming_scan_in_background: bool | None = None, async_handler: AsyncHTTPHandler | None = None, + outputScope: BedrockGuardrailOutputScope | None = None, **kwargs, ): self.async_handler = async_handler or get_async_httpx_client( @@ -309,6 +311,7 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM): ) self.guardrailIdentifier = guardrailIdentifier self.guardrailVersion = guardrailVersion + self.outputScope = outputScope self.guardrail_provider = "bedrock" self.chunk_budget_chars = chunk_budget_chars self.contextual_grounding_from_messages = contextual_grounding_from_messages @@ -1280,10 +1283,10 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM): AWS billed them to ``completed_chunk_usages``, and the attempt log sums those with the blocking call's own usage. """ - bedrock_request_data: Final = { - **base_request_data, - "content": content, - } + bedrock_request_data: Final = dict(base_request_data) + bedrock_request_data["content"] = content + if self.outputScope is not None and "outputScope" not in bedrock_request_data: + bedrock_request_data["outputScope"] = self.outputScope.value prepared_request: Final = await run_aws_signing( self._prepare_request, credentials=credentials, diff --git a/litellm/proxy/guardrails/guardrail_initializers.py b/litellm/proxy/guardrails/guardrail_initializers.py index 8dbabc49316..ebf1d8560ed 100644 --- a/litellm/proxy/guardrails/guardrail_initializers.py +++ b/litellm/proxy/guardrails/guardrail_initializers.py @@ -18,6 +18,7 @@ def initialize_bedrock(litellm_params: LitellmParams, guardrail: Guardrail): event_hook=litellm_params.mode, guardrailIdentifier=litellm_params.guardrailIdentifier, guardrailVersion=litellm_params.guardrailVersion, + outputScope=litellm_params.outputScope, checks=litellm_params.checks if isinstance(litellm_params.checks, BedrockChecksConfigModel) else None, content_filter_threshold=litellm_params.content_filter_threshold, prompt_attack_threshold=litellm_params.prompt_attack_threshold, diff --git a/litellm/types/guardrails.py b/litellm/types/guardrails.py index 980cd8bfed3..718a651bdee 100644 --- a/litellm/types/guardrails.py +++ b/litellm/types/guardrails.py @@ -604,6 +604,11 @@ class BedrockChecksConfigModel(LiteLLMBaseModel): return self +class BedrockGuardrailOutputScope(Enum): + INTERVENTIONS = "INTERVENTIONS" + FULL = "FULL" + + class BedrockGuardrailConfigModel(LiteLLMBaseModel): """Configuration parameters for the AWS Bedrock guardrail""" @@ -612,6 +617,11 @@ class BedrockGuardrailConfigModel(LiteLLMBaseModel): default=None, description="The version of your Bedrock guardrail (e.g., DRAFT or version number)", ) + outputScope: BedrockGuardrailOutputScope | None = Field( + default=None, + description="ApplyGuardrail response scope. FULL includes detected and non-detected findings. " + "Omit to retain Bedrock's default of returning detected findings only.", + ) disable_exception_on_block: bool | None = Field( default=False, description="If True, will not raise an exception when the guardrail is blocked. Useful for OpenWebUI where exceptions can end the chat flow.", diff --git a/tests/unit/proxy/guardrails/guardrail_hooks/test_bedrock_guardrails.py b/tests/unit/proxy/guardrails/guardrail_hooks/test_bedrock_guardrails.py index 8c8aa7af19f..1fb56509b7f 100644 --- a/tests/unit/proxy/guardrails/guardrail_hooks/test_bedrock_guardrails.py +++ b/tests/unit/proxy/guardrails/guardrail_hooks/test_bedrock_guardrails.py @@ -27,8 +27,9 @@ from litellm.proxy.guardrails.guardrail_hooks.bedrock_guardrails import ( BedrockGuardrail, _redact_pii_matches, ) +from litellm.proxy.guardrails.guardrail_initializers import initialize_bedrock from litellm.proxy.utils import ProxyLogging -from litellm.types.guardrails import GuardrailEventHooks +from litellm.types.guardrails import BedrockGuardrailOutputScope, Guardrail, GuardrailEventHooks, LitellmParams from litellm.types.proxy.guardrails.guardrail_hooks.bedrock_guardrails import ( BedrockContentItem, BedrockTextContent, @@ -1020,6 +1021,64 @@ def _anthropic_tool_result_conversation( ] +@pytest.mark.asyncio +@pytest.mark.parametrize("scope", [None, "INTERVENTIONS", "FULL"]) +async def test_configured_output_scope_reaches_bedrock_and_preserves_healthy_assessments( + monkeypatch: pytest.MonkeyPatch, respx_mock: respx.MockRouter, scope: str | None +) -> None: + monkeypatch.setattr(litellm, "disable_aiohttp_transport", True) + params: Final = LitellmParams.model_validate( + { + "guardrail": "bedrock", + "mode": "post_call", + "guardrailIdentifier": "test-guardrail", + "guardrailVersion": "DRAFT", + "contextual_grounding_from_messages": True, + "aws_access_key_id": "test-access-key", + "aws_secret_access_key": "test-secret-key", + "aws_region_name": "us-east-1", + "aws_bedrock_runtime_endpoint": "https://bedrock.example", + **({"outputScope": scope} if scope is not None else {}), + } + ) + guardrail: Final = initialize_bedrock(params, Guardrail(guardrail_name="grounding", litellm_params=params)) + assessment: Final = { + "contextualGroundingPolicy": { + "filters": [{"type": "GROUNDING", "score": 0.96, "threshold": 0.8, "detected": False, "action": "NONE"}] + } + } + + def provider(request: httpx.Request) -> httpx.Response: + body: Final = json.loads(request.content) + return httpx.Response( + 200, + json={"action": "NONE", "assessments": [assessment] if body.get("outputScope") == "FULL" else []}, + ) + + route: Final = respx_mock.post("https://bedrock.example/guardrail/test-guardrail/version/DRAFT/apply").mock( + side_effect=provider + ) + request_data: Final = { + "messages": [ + {"role": "system", "content": "The tower is 330 metres tall."}, + {"role": "user", "content": "How tall is the tower?"}, + ] + } + result: Final = await guardrail.apply_guardrail( + inputs={"texts": ["The tower is 330 metres tall."]}, request_data=request_data, input_type="response" + ) + + sent: Final = json.loads(route.calls.last.request.content) + if scope is None: + assert "outputScope" not in sent + else: + assert sent["outputScope"] == scope + assert sent["source"] == "OUTPUT" + assert result["texts"] == ["The tower is 330 metres tall."] + recorded: Final = request_data["metadata"]["standard_logging_guardrail_information"][-1]["guardrail_response"] + assert recorded["assessments"] == ([assessment] if scope == "FULL" else []) + + @pytest.mark.asyncio async def test_during_call_hook_skips_bedrock_call_for_tool_result_only_turn(): """A tool_result-only latest user turn must not post an empty content list to Bedrock. @@ -1805,7 +1864,11 @@ async def test_make_bedrock_api_request_logging_event_type_for_spend_logs(): @pytest.mark.asyncio async def test_make_bedrock_api_request_filters_dynamic_evaluation_overrides(): - guardrail = BedrockGuardrail(guardrailIdentifier="test-guardrail", guardrailVersion="DRAFT") + guardrail = BedrockGuardrail( + guardrailIdentifier="test-guardrail", + guardrailVersion="DRAFT", + outputScope=BedrockGuardrailOutputScope.INTERVENTIONS, + ) mock_credentials = MagicMock() mock_credentials.access_key = "test-access-key" mock_credentials.secret_key = "test-secret-key" diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index f252669beb7..90582cc5023 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -27607,6 +27607,11 @@ export interface components { /** Entities */ entities: components["schemas"]["BedrockChecksSensitiveInformationEntityItem"][]; }; + /** + * BedrockGuardrailOutputScope + * @enum {string} + */ + BedrockGuardrailOutputScope: "INTERVENTIONS" | "FULL"; /** BillingPeriod */ BillingPeriod: { /** @@ -37417,6 +37422,8 @@ export interface components { only_scan_new_messages: boolean | null; /** @description Optional parameters for the guardrail */ optional_params?: components["schemas"]["CiscoAIDefenseGuardrailConfigModelOptionalParams"] | null; + /** @description ApplyGuardrail response scope. FULL includes detected and non-detected findings. Omit to retain Bedrock's default of returning detected findings only. */ + outputScope?: components["schemas"]["BedrockGuardrailOutputScope"] | null; /** * Output Parse Pii * @description When True, LiteLLM will replace the masked text with the original text in the response