fix(bedrock): map guardrailConfig to InvokeModel guardrail headers - #31985
Conversation
The InvokeModel API takes the guardrail identifier, version and trace as X-Amzn-Bedrock-* request headers, unlike Converse which takes them in the request body. The invoke transformer never set these headers, so guardrailConfig was silently dropped (or leaked into the request body) and Bedrock guardrails never ran on invoke-route models. Pop guardrailConfig in AmazonInvokeConfig.validate_environment, validate it, and set the headers before SigV4 signing; explicitly passed headers keep winning over guardrailConfig so existing workarounds are unaffected
|
Generated by Claude Code |
Greptile SummaryThis PR fixes a bug where
Confidence Score: 5/5Safe to merge — the change is narrowly scoped to the InvokeModel validate_environment path and is covered by tests for all major provider transforms. The fix correctly addresses the root cause (guardrailConfig not converted to InvokeModel headers), the pop-before-transform approach reliably prevents body leakage, explicit-header precedence preserves backwards compatibility for callers using the workaround, and the tests cover the happy path, edge cases, and malformed input. No files require special attention.
|
| Filename | Overview |
|---|---|
| litellm/llms/bedrock/chat/invoke_transformations/base_invoke_transformation.py | Adds guardrail header mapping in validate_environment; pops guardrailConfig from optional_params to prevent body leakage, validates with Pydantic, and merges X-Amzn-Bedrock-* headers with case-insensitive explicit-header precedence. |
| litellm/types/llms/bedrock.py | Adds enabled_full to GuardrailConfigBlock.trace Literal to match the full set of values accepted by both Converse and InvokeModel APIs. |
| tests/test_litellm/llms/bedrock/chat/invoke_transformations/test_base_invoke_transformation.py | Adds comprehensive mock-only tests for guardrail header mapping, absent-config no-op, explicit-header precedence, malformed-config rejection, and full handler-flow validation across four provider transforms. |
Reviews (2): Last reviewed commit: "fix(bedrock): reject guardrailConfig mis..." | Re-trigger Greptile
| api_base: Optional[str] = None, | ||
| ) -> dict: | ||
| return headers | ||
| raw_guardrail_config = optional_params.pop("guardrailConfig", None) |
There was a problem hiding this comment.
Mutation of caller's optional_params on retry paths
optional_params.pop("guardrailConfig") mutates the dict in-place. If LiteLLM rebuilds the headers on a retry (calling validate_environment a second time with the same dict object), guardrailConfig will already be absent and the guardrail headers won't be set for the retried request — silently, with no error. This is consistent with how other optional-params fields are handled in this codebase today, but it's worth being aware of when retry logic is considered.
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
A guardrailConfig without guardrailIdentifier (e.g. an empty dict) would validate, produce no guardrail headers, and let the request proceed with guardrails silently not applied; that silent skip is the exact failure mode this fix exists to remove, so fail fast with a 400 instead
| guardrail_headers = { | ||
| name: value | ||
| for name, value in _bedrock_invoke_guardrail_headers(raw_guardrail_config).items() | ||
| if name.lower() not in existing_header_names |
There was a problem hiding this comment.
Medium: Guardrail override via caller-supplied headers
headers can already contain caller-supplied outbound headers, and this filter makes those values win over the guardrailConfig values. A caller can include X-Amzn-Bedrock-GuardrailIdentifier and X-Amzn-Bedrock-GuardrailVersion to invoke a model configured with a fixed Bedrock guardrail using a different guardrail instead; make the configured guardrailConfig authoritative here, or only preserve existing guardrail headers when they come from trusted configuration rather than request data.
There was a problem hiding this comment.
The precedence here is deliberate and flipping it would not create a trust boundary. At this layer there is no provenance left to tell trusted configuration from request data: both extra_headers and guardrailConfig can come from deployment config or from the request body, and the router merges request kwargs over deployment litellm_params ({**litellm_params, ..., **kwargs} in router.py), so a caller who can pass body params can already override a configured guardrailConfig directly via extra_body; the converse route has the same property today. Making guardrailConfig win over headers would only change which caller-controllable channel takes precedence, while silently overriding the explicit X-Amzn-Bedrock-* header workaround that existing deployments rely on, which this PR intentionally preserves
Pinning a guardrail against untrusted callers is proxy-level policy (key/team guardrails, restricting which request params are allowed), not something this transformer can enforce
Generated by Claude Code
PR overviewThis pull request updates the Bedrock InvokeModel chat transformation so values from There is still one open security issue: caller-supplied outbound headers can take precedence over the configured Open issues (1)
Fixed/addressed: 0 · PR risk: 6/10 |
Relevant issues
Reported by a customer: passing
guardrailConfigto a Bedrock invoke-route model did nothing, so their Bedrock guardrails never ran. They are currently working around it by passing theX-Amzn-Bedrock-*headers explicitly on every requestLinear ticket
N/A
Pre-Submission checklist
Please complete all items before asking a LiteLLM maintainer to review your PR
@greptileaiand received a Confidence Score of at least 4/5 before requesting a maintainer reviewScreenshots / Proof of Fix
The Converse API takes the guardrail identifier, version and trace in the request body, and LiteLLM supports that. The InvokeModel API takes them as request headers (
X-Amzn-Bedrock-GuardrailIdentifier,X-Amzn-Bedrock-GuardrailVersion,X-Amzn-Bedrock-Trace, see the InvokeModel API reference), and the invoke transformer had no code to set themRan the proxy with
python litellm/proxy/proxy_cli.py --config litellm/proxy/dev_config.yaml --detailed_debugafter adding an invoke-route model to the config:Then sent the exact request an end user would send:
On
litellm_internal_staging(before this fix),--detailed_debugshows the request LiteLLM sends to Bedrock has no guardrail headers and leaksguardrailConfiginto the body, which the Anthropic invoke spec rejects as an extra input:On this branch, the same curl produces a request with the three guardrail headers set (and SigV4-signed, since
x-amzn-*headers are included in signing) and a clean body:The sandbox this ran in has no valid AWS credentials, so AWS answered both runs with
InvalidClientTokenIdafter the request reachedbedrock-runtime.us-east-1.amazonaws.com; the excerpts above are the real signed requests LiteLLM put on the wire. To close the loop against a live guardrail, run the same curl with valid AWS creds and the id of a guardrail that has a denied topic, send a prompt on that topic, and confirm the response is the guardrail's blocked message (and thattrace: enabledreturns the guardrail trace) while the same prompt withoutguardrailConfiganswers normallyType
🐛 Bug Fix
Changes
AmazonInvokeConfig.validate_environmentnow popsguardrailConfigfrom the optional params, validates it againstGuardrailConfigBlockwith a pydanticTypeAdapter(a malformed value raises a 400BedrockErrorclient-side instead of an opaque AWS error), and merges theX-Amzn-Bedrock-GuardrailIdentifier,X-Amzn-Bedrock-GuardrailVersionandX-Amzn-Bedrock-Trace(uppercased, per the invoke spec) headers into the request before SigV4 signing. Headers the caller already set win overguardrailConfig, case-insensitively, so anyone who worked around this bug by passing the AWS headers directly sees no behavior changePer Greptile's review, a
guardrailConfigwithoutguardrailIdentifier(e.g. an empty dict) is also rejected with a 400 instead of silently producing no headers, since a request proceeding with guardrails silently not applied is the exact failure mode this fix removesEvery invoke provider config (Anthropic, Nova, Titan, Llama, Mistral, Cohere, AI21, DeepSeek, Qwen, Moonshot) inherits this
validate_environment, so the fix covers all invoke-route models, both/invokeand/invoke-with-response-stream. Popping the key invalidate_environment, which the handler calls beforetransform_request, also stopsguardrailConfigfrom leaking into request bodiesGuardrailConfigBlock.tracegains theenabled_fullvalue that both Converse and InvokeModel acceptRegression tests in
tests/test_litellm/llms/bedrock/chat/invoke_transformations/test_base_invoke_transformation.pycover the header mapping, the pop out ofoptional_params, explicit-header precedence, malformed or identifier-less config rejection, and a handler-flow test assertingguardrailConfigends in headers and never in the request body across the Anthropic, Titan, Mistral and Llama invoke transforms