Skip to content

fix(bedrock): map guardrailConfig to InvokeModel guardrail headers - #31985

Merged
mateo-berri merged 2 commits into
litellm_internal_stagingfrom
litellm_bedrock_invoke_guardrail_headers
Jul 3, 2026
Merged

fix(bedrock): map guardrailConfig to InvokeModel guardrail headers#31985
mateo-berri merged 2 commits into
litellm_internal_stagingfrom
litellm_bedrock_invoke_guardrail_headers

Conversation

@mateo-berri

@mateo-berri mateo-berri commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Relevant issues

Reported by a customer: passing guardrailConfig to a Bedrock invoke-route model did nothing, so their Bedrock guardrails never ran. They are currently working around it by passing the X-Amzn-Bedrock-* headers explicitly on every request

Linear ticket

N/A

Pre-Submission checklist

Please complete all items before asking a LiteLLM maintainer to review your PR

  • I have added meaningful tests
  • My PR passes all CI/CD checks (e.g., lint, format, unit tests)
  • My PR's scope is as isolated as possible; it only solves 1 specific problem
  • I have requested a Greptile review by commenting @greptileai and received a Confidence Score of at least 4/5 before requesting a maintainer review

Screenshots / 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 them

Ran the proxy with python litellm/proxy/proxy_cli.py --config litellm/proxy/dev_config.yaml --detailed_debug after adding an invoke-route model to the config:

  - model_name: bedrock-invoke-guardrail-haiku
    litellm_params:
      model: bedrock/invoke/us.anthropic.claude-haiku-4-5-20251001-v1:0
      aws_region_name: us-east-1

Then sent the exact request an end user would send:

curl -sS http://localhost:4000/v1/chat/completions \
  -H "Authorization: Bearer sk-1234" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "bedrock-invoke-guardrail-haiku",
    "messages": [{"role": "user", "content": "Say hi in 3 words"}],
    "max_tokens": 20,
    "guardrailConfig": {
      "guardrailIdentifier": "ff6ujrregl1q",
      "guardrailVersion": "DRAFT",
      "trace": "enabled"
    }
  }'

On litellm_internal_staging (before this fix), --detailed_debug shows the request LiteLLM sends to Bedrock has no guardrail headers and leaks guardrailConfig into the body, which the Anthropic invoke spec rejects as an extra input:

POST Request Sent from LiteLLM:
curl -X POST \
https://bedrock-runtime.us-east-1.amazonaws.com/model/us.anthropic.claude-haiku-4-5-20251001-v1:0/invoke \
-H 'Content-Type: application/json' -H 'X-Amz-Date: 20260702T184715Z' -H 'Authorization: AW****2f' \
-d '{'messages': [{'role': 'user', 'content': [{'type': 'text', 'text': 'Say hi in 3 words'}]}], 'max_tokens': 20, 'guardrailConfig': {'guardrailIdentifier': 'ff6ujrregl1q', 'guardrailVersion': 'DRAFT', 'trace': 'enabled'}, 'anthropic_version': 'bedrock-2023-05-31'}'

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:

POST Request Sent from LiteLLM:
curl -X POST \
https://bedrock-runtime.us-east-1.amazonaws.com/model/us.anthropic.claude-haiku-4-5-20251001-v1:0/invoke \
-H 'Content-Type: application/json' -H 'X-Amzn-Bedrock-GuardrailIdentifier: ff6ujrregl1q' -H 'X-Amzn-Bedrock-GuardrailVersion: DRAFT' -H 'X-Amzn-Bedrock-Trace: ENABLED' -H 'X-Amz-Date: 20260702T184410Z' -H 'Authorization: AW****69' \
-d '{'messages': [{'role': 'user', 'content': [{'type': 'text', 'text': 'Say hi in 3 words'}]}], 'max_tokens': 20, 'anthropic_version': 'bedrock-2023-05-31'}'

The sandbox this ran in has no valid AWS credentials, so AWS answered both runs with InvalidClientTokenId after the request reached bedrock-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 that trace: enabled returns the guardrail trace) while the same prompt without guardrailConfig answers normally

Type

🐛 Bug Fix

Changes

AmazonInvokeConfig.validate_environment now pops guardrailConfig from the optional params, validates it against GuardrailConfigBlock with a pydantic TypeAdapter (a malformed value raises a 400 BedrockError client-side instead of an opaque AWS error), and merges the X-Amzn-Bedrock-GuardrailIdentifier, X-Amzn-Bedrock-GuardrailVersion and X-Amzn-Bedrock-Trace (uppercased, per the invoke spec) headers into the request before SigV4 signing. Headers the caller already set win over guardrailConfig, case-insensitively, so anyone who worked around this bug by passing the AWS headers directly sees no behavior change

Per Greptile's review, a guardrailConfig without guardrailIdentifier (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 removes

Every 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 /invoke and /invoke-with-response-stream. Popping the key in validate_environment, which the handler calls before transform_request, also stops guardrailConfig from leaking into request bodies

GuardrailConfigBlock.trace gains the enabled_full value that both Converse and InvokeModel accept

Regression tests in tests/test_litellm/llms/bedrock/chat/invoke_transformations/test_base_invoke_transformation.py cover the header mapping, the pop out of optional_params, explicit-header precedence, malformed or identifier-less config rejection, and a handler-flow test asserting guardrailConfig ends in headers and never in the request body across the Anthropic, Titan, Mistral and Llama invoke transforms

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
@mateo-berri

Copy link
Copy Markdown
Contributor Author

@greptileai


Generated by Claude Code

@greptile-apps

greptile-apps Bot commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes a bug where guardrailConfig passed to Bedrock InvokeModel-route models was silently ignored (and leaked into the request body, causing AWS to reject it). The fix correctly maps the config to the X-Amzn-Bedrock-GuardrailIdentifier, X-Amzn-Bedrock-GuardrailVersion, and X-Amzn-Bedrock-Trace headers that the InvokeModel API expects, with explicit-header precedence for callers already using the workaround.

  • validate_environment in AmazonInvokeConfig now pops guardrailConfig from optional_params, validates it with a Pydantic TypeAdapter, and merges the three guardrail headers (uppercasing trace) before SigV4 signing — covering all invoke-route providers and both /invoke and /invoke-with-response-stream.
  • GuardrailConfigBlock.trace gains the enabled_full literal that both Converse and InvokeModel accept.
  • Regression tests verify header mapping, no-op when absent, explicit-header precedence, malformed-config rejection (400), and end-to-end handler flow across Anthropic, Titan, Mistral, and Llama invoke transforms; all tests are mock-only.

Confidence Score: 5/5

Safe 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.

Important Files Changed

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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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

codecov Bot commented Jul 2, 2026

Copy link
Copy Markdown

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
@mateo-berri
mateo-berri marked this pull request as ready for review July 3, 2026 01:20
guardrail_headers = {
name: value
for name, value in _bedrock_invoke_guardrail_headers(raw_guardrail_config).items()
if name.lower() not in existing_header_names

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

@veria-ai

veria-ai Bot commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

PR overview

This pull request updates the Bedrock InvokeModel chat transformation so values from guardrailConfig are mapped into the appropriate Bedrock guardrail request headers. The touched code is in the Bedrock invocation path that builds outbound headers for model calls.

There is still one open security issue: caller-supplied outbound headers can take precedence over the configured guardrailConfig guardrail headers. That means a caller may be able to invoke a model with a different Bedrock guardrail than the one intended by configuration. No issues have been fixed yet, so the PR still needs a change that makes configured guardrail values authoritative or distinguishes trusted headers from request-provided ones.

Open issues (1)

Fixed/addressed: 0 · PR risk: 6/10

@mateo-berri
mateo-berri merged commit 138a69b into litellm_internal_staging Jul 3, 2026
125 checks passed
@mateo-berri
mateo-berri deleted the litellm_bedrock_invoke_guardrail_headers branch July 3, 2026 01:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants