Skip to content

fix(guardrails): walk Responses-API text taxonomy in shared content helpers - #32542

Merged
yucheng-berri merged 8 commits into
litellm_internal_stagingfrom
litellm_lit-4294-guardrails-responses
Jul 9, 2026
Merged

fix(guardrails): walk Responses-API text taxonomy in shared content helpers#32542
yucheng-berri merged 8 commits into
litellm_internal_stagingfrom
litellm_lit-4294-guardrails-responses

Conversation

@yucheng-berri

@yucheng-berri yucheng-berri commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Relevant issues

Linear ticket

Resolves LIT-4294

Pre-Submission checklist

  • 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 received a Greptile Confidence Score of at least 4/5 before requesting a maintainer review

Screenshots / Proof of Fix

Live proxy repro on both /v1/responses and /v1/chat/completions. The Responses request body is the shape a chat-to-Responses bridge (e.g. Google ADK) POSTs; the guardrail is a custom pre_call hook that logs what _content_utils extracted and calls walk_user_text to redact AKIAEXAMPLE

Before (unfixed, HEAD 0f1e29b)

curl -sS http://localhost:4000/v1/responses \
  -H "Authorization: Bearer sk-1234" -H "Content-Type: application/json" \
  -d '{
    "model": "gpt-4o-mini",
    "input": [
      {"type": "message", "role": "user",
       "content": [{"type": "input_text", "text": "AKIAEXAMPLE"}]},
      {"type": "function_call", "call_id": "c1", "name": "get_weather", "arguments": "{}"},
      {"type": "function_call_output", "call_id": "c1",
       "output": [{"type": "input_text", "text": "another AKIAEXAMPLE inside tool result"}]}
    ]
  }'

proxy log:

LIT4294 pre_call call_type=aresponses fragments_seen=0 inspection_msgs=0
LIT4294 fragments=[]
LIT4294 inspection=[]
LIT4294 walk_user_text visited=0

outbound POST body to OpenAI carries the unredacted keys; every text guardrail is a no-op on this path. /v1/chat/completions on the same proxy sees fragments_seen=1 and redacts correctly, so the bypass is Responses-API-only

After (this PR, HEAD 18d3a6f)

Same curl:

LIT4294 pre_call call_type=aresponses fragments_seen=2 inspection_msgs=2
LIT4294 fragments=['AKIAEXAMPLE', 'another AKIAEXAMPLE inside tool result']
LIT4294 inspection=[{'role': 'user', 'content': 'AKIAEXAMPLE'},
                    {'role': 'tool', 'content': 'another AKIAEXAMPLE inside tool result'}]
LIT4294 walk_user_text visited=2

outbound POST body to https://api.openai.com/v1/responses:

{'model': 'gpt-4o-mini', 'input': [
   {'type': 'message', 'role': 'user',
    'content': [{'type': 'input_text', 'text': '[REDACTED]'}]},
   {'type': 'function_call', 'call_id': 'c1', 'name': 'get_weather', 'arguments': '{}'},
   {'type': 'function_call_output', 'call_id': 'c1',
    'output': [{'type': 'input_text', 'text': 'another [REDACTED] inside tool result'}]}]}

Both user text and tool-output text are redacted end-to-end; function_call metadata is preserved. The inspection payload keeps role fidelity (function_call_output surfaces as role: "tool" for downstream guardrails to consume). AIM's schema-validating POST to /fw/v1/analyze collapses tool to user locally right before the POST, keeping AIM schema-safe without imposing that coercion on other guardrails

Type

🐛 Bug Fix

Changes

Two fixes in litellm/proxy/guardrails/_content_utils.py and one AIM-local fix in litellm/proxy/guardrails/guardrail_hooks/aim/aim.py.

  1. _iter_text_parts_in_content and the list branch of walk_user_text now recognise {text, input_text, output_text} as text-carrying part types via a new TEXT_PART_TYPES frozenset. The Responses API uses input_text on request messages and output_text on assistant messages; the previous single-value check made every text guardrail a no-op on /v1/responses
  2. _coerce_input_to_messages and the list branch of walk_user_text now walk the actual Responses input taxonomy (message items, function_call, function_call_output, bare content-part dicts, bare strings) instead of the all(item has role) gate. That gate returned False for any tool-calling turn (function_call and function_call_output have no role), which caused the whole list to collapse to a single-message blob whose content was never walked. function_call items are dropped from the inspection view since they carry no free-form text (arguments walking is LIT-4304 scope); function_call_output maps to role: tool by default, preserving the caller-supplied role when present. Role fidelity is otherwise preserved throughout: message items, bare parts, and content items pass through their original role
  3. AIM's schema-validating POST to /fw/v1/analyze needs {system, user, assistant}-only roles because the flatten drops tool_call_id and name. That coercion lives in a private AimGuardrail._build_aim_inspection_messages helper right before the POST rather than in the shared helper, so other guardrails (Lakera, Cato, Lasso, Repello, IBM, Azure, secret detection) see the caller's original role fidelity on their inspection payloads

Regression tests: tests/test_litellm/proxy/guardrails/test_content_utils.py covers the Responses taxonomy walk, input_text/output_text extraction and redaction, function_call_output.output list + string form, role preservation on bare parts and message items, and the function_call_outputrole: tool default. tests/test_litellm/proxy/guardrails/guardrail_hooks/test_aim.py covers the AIM-local role coercion. 41 tests pass; every new test fails on unfixed code

QA verification

Two layers: live proxy hitting real OpenAI, and unit-level checks directly against the helpers.

Live proxy (cases 1-9, 16): custom pre_call guardrail hook logs BEFORE/AFTER snapshots of data["messages"] and data["input"], the iter_message_text fragment list, and the build_inspection_messages role list. Proxy launched with PYTHONPATH pinned at the PR worktree so the exercised code is the PR source, not an installed package. One curl per case; each AFTER snapshot changed only leaf text values, every structural field (role, type, call_id, tool_call_id, tool_calls, name, arguments, annotations) byte-for-byte identical to BEFORE. OpenAI responses confirmed no AKIAEXAMPLE marker reached the model.

Unit-level (cases 9-15): import script that calls build_inspection_messages, iter_message_text, walk_user_text, apply_redacted_messages_back, and AimGuardrail._build_aim_inspection_messages directly with hand-constructed payloads. Uses copy.deepcopy between assertions. Covers shapes OpenAI's API rejects (bare input_text/output_text parts, developer role, exact AIM-payload role values).

Regression gate (case 17): ran the suite on the PR tip → 41 pass. Restored staging HEAD for _content_utils.py and aim.py while keeping the PR's tests checked out; the 4 AIM tests and 8 content-utils tests failed. Those 12 are exactly the behaviors this PR fixes.

Before-baseline: second proxy on staging HEAD, same curl → fragments=[], walk_visited=0, OpenAI response echoed the raw AKIAEXAMPLE back. Confirmed the bug on unfixed code before verifying the fix.

Case-by-case verdict on 18d3a6fe5b:

# Description Verdict
1 chat/completions plain string PASS
2 chat/completions multimodal text+image PASS
3 chat/completions tool-call conversation PASS
4 Responses raw string input PASS
5 Responses message with input_text PASS
6 Responses message with output_text PASS
7 Responses mixed tool-calling (main repro) PASS
8 Responses function_call_output string output PASS
9 Bare input_text/output_text parts PASS
10 Shared inspection role fidelity PASS
11 AIM pre-call schema-safe inspection PASS
12 AIM post-call / output inspection PASS
13 Non-AIM guardrail role fidelity PASS
14 Mask/write-back regression PASS
15 Empty / non-text / unsupported items PASS
16 Outbound request structural integrity PASS
17 Unit/regression tests PASS (41/41 on 18d3a6fe5b; 12 fail on staging HEAD)

Caveat

The apply_redacted_messages_back helper writes the flat [{role, content}] inspection payload back over data["messages"], which drops sibling assistant.tool_calls and any tool_call_id on a chat-completions tool-message masking path. That's a pre-existing structural flatten limitation, not introduced by this PR; verified case 14 does not corrupt the shape further. Fix belongs to a separate ticket.

Known follow-up scope

The reviewer surfaced three related silent-bypass paths on the Responses-API surface that this PR deliberately leaves for follow-up commits so the change stays scoped to the customer's original repro. Tickets filed:

  • LIT-4302: custom_tool_call_output items share the output shape with function_call_output and still bypass the helper.
  • LIT-4303: reasoning.summary_text items replayed as input (ADK bridge, LangGraph-style loops) carry chain-of-thought text that guardrails do not currently walk.
  • LIT-4304: function_call.arguments and its chat-completions sibling message.tool_calls[i].function.arguments are JSON strings that can carry user-supplied secrets; the shared helper does not walk them today. Pre-existing gap on both APIs, best fixed together.

Note

Medium Risk
Changes shared proxy guardrail parsing/redaction on the security-critical pre-call path; behavior shifts for all hooks using _content_utils, though scope is limited to Responses API shapes and extensive tests were added.

Overview
Fixes a Responses API bypass where text guardrails saw zero fragments on /v1/responses (including tool-calling turns), while chat completions kept working.

Shared _content_utils now treats text / input_text / output_text as text parts and walks mixed input lists item-by-item (messages, bare strings/parts, function_call_output.output) instead of collapsing the list when some items lack role. walk_user_text and build_inspection_messages follow the same rules so scan, redact, and remote inspection payloads stay aligned; function_call_output defaults to role: tool in the shared flatten.

AIM alone maps non-{system,user,assistant} roles to user before /fw/v1/analyze, avoiding 422s without changing other hooks’ role fidelity.

Regression coverage added in test_content_utils.py and test_aim.py.

Reviewed by Cursor Bugbot for commit 18d3a6f. Bugbot is set up for automated code reviews on this repo. Configure here.

…elpers

Every guardrail sharing litellm/proxy/guardrails/_content_utils.py silently
drops all text on the /v1/responses path. AIM turns it into a loud 422 (
{"error":"No messages in the request"}); every other guardrail (Lakera v2,
Cato, Lasso, Repello, IBM, Azure Content Safety, enterprise secret
detection) scans an empty payload and lets the request through unscanned.

Three defects, all in _content_utils.py:

1. _iter_text_parts_in_content recognised only part.type == "text", but the
   Responses API uses input_text (request) and output_text (assistant).
2. _coerce_input_to_messages gated on "every item has a role key"; any
   Responses input list containing a function_call or function_call_output
   item failed the check and was wrapped as one opaque blob.
3. build_inspection_messages forwarded any role through, including a bare
   tool role missing tool_call_id, which validators like AIM's /fw/v1/analyze
   reject with a schema error.

Fix walks the actual Responses item taxonomy (message, function_call,
function_call_output, bare content parts and strings), recognises
{text, input_text, output_text} everywhere, and coerces any role outside
{system, user, assistant} to user in the outbound inspection payload.
@greptile-apps

greptile-apps Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes a Responses API bypass where shared guardrail content helpers silently skipped almost all /v1/responses bodies, making text guardrails (secret detection, PII, remote analyzers) a no-op on that path while /v1/chat/completions continued to work correctly.

  • Text-part type coverage: _iter_text_parts_in_content and the walk_user_text list branch now recognise input_text and output_text alongside text via a new TEXT_PART_TYPES frozenset.
  • Responses input taxonomy: _coerce_input_to_messages and walk_user_text now walk the full Responses input list item-by-item instead of the old all(item has role) gate.
  • Role fidelity + AIM coercion: build_inspection_messages preserves synthesised roles faithfully; AIM collapses unsupported roles to user in its own local helper.

Confidence Score: 5/5

The change is safe to merge: the new taxonomy walking is consistent between the read path and the write path, all previously-supported shapes remain covered, and targeted regression tests confirm both extraction and in-place redaction on the new Responses API paths.

All three modified helpers are updated consistently, the AIM-local role coercion is correctly isolated so the shared helper remains role-faithful for other guardrails, no existing tests are weakened, and the three CI failures are confirmed unrelated to this PR.

No files require special attention; the core change in _content_utils.py is well-scoped and the AIM integration follows naturally.

Important Files Changed

Filename Overview
litellm/proxy/guardrails/_content_utils.py Core fix: adds TEXT_PART_TYPES frozenset, rewrites _coerce_input_to_messages and the walk_user_text list branch to properly walk the Responses API taxonomy.
litellm/proxy/guardrails/guardrail_hooks/aim/aim.py Moves AIM-specific role coercion into _build_aim_inspection_messages, keeping the shared build_inspection_messages role-faithful.
tests/test_litellm/proxy/guardrails/test_content_utils.py Adds targeted regression tests for input_text/output_text extraction, mixed tool-call taxonomy walking, and function_call_output redaction.
tests/test_litellm/proxy/guardrails/guardrail_hooks/test_aim.py New unit tests for _build_aim_inspection_messages covering role coercion and safe-role pass-through.

Reviews (4): Last reviewed commit: "refactor(guardrails): preserve role fide..." | Re-trigger Greptile

@codecov

codecov Bot commented Jul 8, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@codspeed-hq

codspeed-hq Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will degrade performance by 12.1%

❌ 1 regressed benchmark
✅ 29 untouched benchmarks

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Benchmark BASE HEAD Efficiency
test_completion_simple_message 4.2 ms 4.8 ms -12.1%

Tip

Investigate this regression by commenting @codspeedbot fix this regression on this PR, or directly use the CodSpeed MCP with your agent.


Comparing litellm_lit-4294-guardrails-responses (18d3a6f) with litellm_internal_staging (9d74548)

Open in CodSpeed

Avoids ever materialising a schema-invalid bare tool message. The
downstream role-safety coercion in build_inspection_messages still
guards genuinely caller-supplied non-standard roles (developer,
function, custom values); add a regression test covering that path
so the coercion has real coverage after this simplification.
@yucheng-berri

Copy link
Copy Markdown
Contributor Author

@greptileai please re-review — pushed two follow-up commits since the last score:

  • 7e42d65: map function_call_output straight to role: user in _coerce_input_to_messages so a schema-invalid bare tool message is never materialised in memory, even in isolation. The _INSPECTION_SAFE_ROLES coercion in build_inspection_messages stays as defense-in-depth against caller-supplied non-standard roles (e.g. developer on chat completions where flattening drops the required tool_call_id).
  • 20dc5a3: three test additions (bare-string function_call_output.output, coercion of caller-supplied developer role, dropped em-dash in a new docstring).

Both changes are mutation-verified to fail on the pre-patch base branch. 35 tests pass locally, ruff format + ruff check clean.

@yucheng-berri

Copy link
Copy Markdown
Contributor Author

bugbot run

Comment thread litellm/proxy/guardrails/_content_utils.py
Comment thread litellm/proxy/guardrails/_content_utils.py
Comment thread tests/test_litellm/proxy/guardrails/test_content_utils.py Outdated
continue
role = message.get("role", "user") or "user"
if role not in _INSPECTION_SAFE_ROLES:
role = "user"

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.

so basically if its not a role we recognize then we say its user role instead, do i have that right?

can you give an example of this happening? ive never seen this but i could imagine it being the case

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.

{"role":"tool","tool_call_id":"c1","content":"sunny"} gets flattened to {"role":"tool","content":"sunny"}, which AIM rejects because tool_call_id is missing, so we coerce it to {"role":"user","content":"sunny"} to keep it valid

@cursor cursor Bot left a comment

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.

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit 7e42d65. Configure here.

Ryan's review flagged that several test docstrings assert AIM's
/fw/v1/analyze validates + rejects specific schema violations. That
behavior is customer-reported in the LIT-4294 writeup, not directly
verified by us. Rephrase to attribute the AIM 422 to the customer's
writeup and describe the underlying constraint as the OpenAI chat
schema; any downstream API that validates against that schema rejects
the same shape.
The generic coercion in build_inspection_messages collapsed any role
outside {system, user, assistant} to user for every caller of the
helper. Combined with the pre-existing apply_redacted_messages_back
write-back behavior in Lakera/AIM/Cato, that turned a loud OpenAI 400
on chat-completions tool-message masking into a silent semantic
corruption of the outbound request (role tool with tool_call_id got
rewritten to bare role user, dropping the assistant + tool_calls
sibling).

AIM specifically requires the coercion because its /fw/v1/analyze
validates the payload against the OpenAI chat schema; other guardrails
either do not validate roles or do their own reconstruction. Move the
coercion to AimGuardrail._build_aim_inspection_messages so the shared
helper keeps caller roles intact and no new cross-guardrail role
corruption is introduced. The pre-existing apply_redacted_messages_back
structural flatten remains as separate follow-up work.

function_call_output items still synthesise role user in the shared
helper because they have no natural role field, which is a different
concern from coercing a caller-supplied role.
@yucheng-berri

Copy link
Copy Markdown
Contributor Author

@greptileai please re-review — pushed a5bfcc4 which changes approach after QA + reviewer feedback:

The generic unsupported-role coercion in build_inspection_messages (previous commits) turned out to be too broad. Combined with the pre-existing apply_redacted_messages_back flatten-and-write-back in Lakera/AIM/Cato, it converted a loud OpenAI 400 on chat-completions tool-message masking into silent semantic corruption of the outbound request. Reproduced live via a Lakera-shaped custom guardrail.

Shift:

  • Removed the generic coercion + _INSPECTION_SAFE_ROLES from _content_utils.py. Shared helper now returns caller-supplied roles unchanged.
  • Added AIM-specific AimGuardrail._build_aim_inspection_messages that coerces unsupported roles to user right before the POST to /fw/v1/analyze, since AIM validates against the OpenAI chat schema and the flatten drops tool_call_id/name.
  • Moved the caller-supplied tool/developer role tests to a new test_aim.py. Kept test_build_inspection_messages_function_call_output_becomes_user in test_content_utils.py since that's about synthesising a role for a role-less Responses item, not caller coercion.

QA Path F re-run: shared helper now preserves role: tool on the inspection payload; Lakera/Cato-shaped mask+write-back path fails loud (OpenAI 400) instead of silently corrupting, matching pre-PR base behavior. The pre-existing apply_redacted_messages_back structural flatten remains as separate follow-up.

37 tests pass locally, ruff format + check clean.

Shared inspection helpers should extract text and preserve semantic
role signals; role coercion for third-party schema safety stays inside
the guardrail that needs it (AIM).

Three shared-helper changes:
- Bare content-part dicts (input_text/output_text) with an explicit role
  keep it; only role-less parts default to user.
- Responses message items already had their role preserved; the
  behavior is now covered by an explicit test.
- function_call_output items default to role tool (semantic equivalent
  of the chat-completions tool message shape) instead of role user, so
  Responses and chat completions produce symmetric inspection payloads.
  A caller-supplied role on the item is still preserved.

AIM's schema-safe coercion in _build_aim_inspection_messages already
handles the resulting role tool: it collapses to user before the POST
to /fw/v1/analyze so AIM's OpenAI-schema validator does not reject the
bare tool message (no tool_call_id can survive the flatten). Added a
regression test in test_aim.py covering that path.
@yucheng-berri

Copy link
Copy Markdown
Contributor Author

@greptileai please re-review — pushed 18d3a6f to tighten the role-handling logic in the shared helper. Preserve role fidelity whenever the source item provides one, only synthesise when the source shape genuinely has none:

  • Bare content-part dicts with an explicit role keep it; role-less ones default to user.
  • Responses message items pass their role through unchanged.
  • function_call_output defaults to role: tool (semantic equivalent of chat-completions tool messages) instead of role: user, so Responses and chat completions produce symmetric inspection payloads. A caller-supplied role still wins.

AIM's local coercion in _build_aim_inspection_messages still collapses tool/function/developer to user for its schema-validating POST, so nothing regresses there.

Also ran a 42-check acceptance matrix against HEAD covering (a) every old supported case (chat completions strings, multimodal text parts, raw input, list-of-messages input, empty strings, image parts), (b) every new Responses case (input_text/output_text extraction and redaction, tool-call taxonomy, function_call skip, function_call_output list + string form), (c) role fidelity (bare parts, message items, function_call_output default, explicit role preservation), (d) AIM-local coercion for unsupported roles, and (e) structural invariants (all metadata preserved, tool_calls / tool_call_id / assistant sibling preserved, no top-level messages field added). All 42 pass.

41 unit tests pass locally (34 shared + 7 AIM-specific). Ruff format + check clean. The 3 failing CI checks are the current Chainguard cgr.dev registry outage affecting every open PR (test-server-root-path × 2 + build_docker_database_image); unrelated to this PR.

@yucheng-berri

Copy link
Copy Markdown
Contributor Author

bugbot run

@cursor cursor Bot left a comment

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.

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 18d3a6f. Configure here.

elif "content" in item:
messages.append({"role": item.get("role") or "user", "content": item["content"]})
elif item.get("type") == "function_call_output" and "output" in item:
messages.append({"role": item.get("role") or "tool", "content": item["output"]})

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.

Cato analyze missing role coercion

Medium Severity

build_inspection_messages now emits role: "tool" for Responses function_call_output and no longer collapses non-chat roles, while Cato still POSTs that payload to /fw/v1/analyze without the AIM-local coercion. That can reproduce the bare-tool 422 AIM avoids on tool-calling /v1/responses requests.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 18d3a6f. Configure here.

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.

this is not a regression, Cato skipped check before.

@yucheng-berri
yucheng-berri merged commit e84a19a into litellm_internal_staging Jul 9, 2026
131 of 134 checks passed
@yucheng-berri
yucheng-berri deleted the litellm_lit-4294-guardrails-responses branch July 9, 2026 06:24
yuneng-berri added a commit that referenced this pull request Jul 11, 2026
…42-90x

chore(release): backport #32542 to stable/1.90.x and cut 1.90.4
yuneng-berri pushed a commit that referenced this pull request Jul 11, 2026
…elpers (#32542)

* fix(guardrails): walk Responses-API text taxonomy in shared content helpers

Every guardrail sharing litellm/proxy/guardrails/_content_utils.py silently
drops all text on the /v1/responses path. AIM turns it into a loud 422 (
{"error":"No messages in the request"}); every other guardrail (Lakera v2,
Cato, Lasso, Repello, IBM, Azure Content Safety, enterprise secret
detection) scans an empty payload and lets the request through unscanned.

Three defects, all in _content_utils.py:

1. _iter_text_parts_in_content recognised only part.type == "text", but the
   Responses API uses input_text (request) and output_text (assistant).
2. _coerce_input_to_messages gated on "every item has a role key"; any
   Responses input list containing a function_call or function_call_output
   item failed the check and was wrapped as one opaque blob.
3. build_inspection_messages forwarded any role through, including a bare
   tool role missing tool_call_id, which validators like AIM's /fw/v1/analyze
   reject with a schema error.

Fix walks the actual Responses item taxonomy (message, function_call,
function_call_output, bare content parts and strings), recognises
{text, input_text, output_text} everywhere, and coerces any role outside
{system, user, assistant} to user in the outbound inspection payload.

* style: ruff-format changed guardrail files

* test(guardrails): cover function_call_output string form; drop em-dash in new docstring

* fix(guardrails): map function_call_output straight to user role

Avoids ever materialising a schema-invalid bare tool message. The
downstream role-safety coercion in build_inspection_messages still
guards genuinely caller-supplied non-standard roles (developer,
function, custom values); add a regression test covering that path
so the coercion has real coverage after this simplification.

* test(guardrails): pin chat-completions tool-role coercion in build_inspection_messages

* docs(test): soften AIM-specific claims in LIT-4294 test docstrings

Ryan's review flagged that several test docstrings assert AIM's
/fw/v1/analyze validates + rejects specific schema violations. That
behavior is customer-reported in the LIT-4294 writeup, not directly
verified by us. Rephrase to attribute the AIM 422 to the customer's
writeup and describe the underlying constraint as the OpenAI chat
schema; any downstream API that validates against that schema rejects
the same shape.

* refactor(guardrails): move unsupported-role coercion into AIM only

The generic coercion in build_inspection_messages collapsed any role
outside {system, user, assistant} to user for every caller of the
helper. Combined with the pre-existing apply_redacted_messages_back
write-back behavior in Lakera/AIM/Cato, that turned a loud OpenAI 400
on chat-completions tool-message masking into a silent semantic
corruption of the outbound request (role tool with tool_call_id got
rewritten to bare role user, dropping the assistant + tool_calls
sibling).

AIM specifically requires the coercion because its /fw/v1/analyze
validates the payload against the OpenAI chat schema; other guardrails
either do not validate roles or do their own reconstruction. Move the
coercion to AimGuardrail._build_aim_inspection_messages so the shared
helper keeps caller roles intact and no new cross-guardrail role
corruption is introduced. The pre-existing apply_redacted_messages_back
structural flatten remains as separate follow-up work.

function_call_output items still synthesise role user in the shared
helper because they have no natural role field, which is a different
concern from coercing a caller-supplied role.

* refactor(guardrails): preserve role fidelity in shared _content_utils

Shared inspection helpers should extract text and preserve semantic
role signals; role coercion for third-party schema safety stays inside
the guardrail that needs it (AIM).

Three shared-helper changes:
- Bare content-part dicts (input_text/output_text) with an explicit role
  keep it; only role-less parts default to user.
- Responses message items already had their role preserved; the
  behavior is now covered by an explicit test.
- function_call_output items default to role tool (semantic equivalent
  of the chat-completions tool message shape) instead of role user, so
  Responses and chat completions produce symmetric inspection payloads.
  A caller-supplied role on the item is still preserved.

AIM's schema-safe coercion in _build_aim_inspection_messages already
handles the resulting role tool: it collapses to user before the POST
to /fw/v1/analyze so AIM's OpenAI-schema validator does not reject the
bare tool message (no tool_call_id can survive the flatten). Added a
regression test in test_aim.py covering that path.

(cherry picked from commit e84a19a)
yuneng-berri added a commit that referenced this pull request Jul 11, 2026
…rd-otel-0711

chore(release): backport #32542, #32655 to stable/1.91.x and cut 1.91.3
yuneng-berri added a commit that referenced this pull request Jul 11, 2026
…1.92.0 stable cut (#32959)

* fix(utils): resolve bedrock regional inference profiles to regional pricing in get_model_info (LIT-4056) (#32389)

* fix(utils): resolve bedrock regional inference profiles to regional pricing in get_model_info (LIT-4056)

* test(register_model): use a triple provider prefix as the unresolvable-key fixture

get_model_info now resolves bedrock/bedrock/... like a routing prefix, so the
double-prefix fixture stopped exercising the register_model fallback path.
Lock the new double-prefix resolution in as a model-info regression test

(cherry picked from commit 734fd29)

* fix(guardrails): walk Responses-API text taxonomy in shared content helpers (#32542)

* fix(guardrails): walk Responses-API text taxonomy in shared content helpers

Every guardrail sharing litellm/proxy/guardrails/_content_utils.py silently
drops all text on the /v1/responses path. AIM turns it into a loud 422 (
{"error":"No messages in the request"}); every other guardrail (Lakera v2,
Cato, Lasso, Repello, IBM, Azure Content Safety, enterprise secret
detection) scans an empty payload and lets the request through unscanned.

Three defects, all in _content_utils.py:

1. _iter_text_parts_in_content recognised only part.type == "text", but the
   Responses API uses input_text (request) and output_text (assistant).
2. _coerce_input_to_messages gated on "every item has a role key"; any
   Responses input list containing a function_call or function_call_output
   item failed the check and was wrapped as one opaque blob.
3. build_inspection_messages forwarded any role through, including a bare
   tool role missing tool_call_id, which validators like AIM's /fw/v1/analyze
   reject with a schema error.

Fix walks the actual Responses item taxonomy (message, function_call,
function_call_output, bare content parts and strings), recognises
{text, input_text, output_text} everywhere, and coerces any role outside
{system, user, assistant} to user in the outbound inspection payload.

* style: ruff-format changed guardrail files

* test(guardrails): cover function_call_output string form; drop em-dash in new docstring

* fix(guardrails): map function_call_output straight to user role

Avoids ever materialising a schema-invalid bare tool message. The
downstream role-safety coercion in build_inspection_messages still
guards genuinely caller-supplied non-standard roles (developer,
function, custom values); add a regression test covering that path
so the coercion has real coverage after this simplification.

* test(guardrails): pin chat-completions tool-role coercion in build_inspection_messages

* docs(test): soften AIM-specific claims in LIT-4294 test docstrings

Ryan's review flagged that several test docstrings assert AIM's
/fw/v1/analyze validates + rejects specific schema violations. That
behavior is customer-reported in the LIT-4294 writeup, not directly
verified by us. Rephrase to attribute the AIM 422 to the customer's
writeup and describe the underlying constraint as the OpenAI chat
schema; any downstream API that validates against that schema rejects
the same shape.

* refactor(guardrails): move unsupported-role coercion into AIM only

The generic coercion in build_inspection_messages collapsed any role
outside {system, user, assistant} to user for every caller of the
helper. Combined with the pre-existing apply_redacted_messages_back
write-back behavior in Lakera/AIM/Cato, that turned a loud OpenAI 400
on chat-completions tool-message masking into a silent semantic
corruption of the outbound request (role tool with tool_call_id got
rewritten to bare role user, dropping the assistant + tool_calls
sibling).

AIM specifically requires the coercion because its /fw/v1/analyze
validates the payload against the OpenAI chat schema; other guardrails
either do not validate roles or do their own reconstruction. Move the
coercion to AimGuardrail._build_aim_inspection_messages so the shared
helper keeps caller roles intact and no new cross-guardrail role
corruption is introduced. The pre-existing apply_redacted_messages_back
structural flatten remains as separate follow-up work.

function_call_output items still synthesise role user in the shared
helper because they have no natural role field, which is a different
concern from coercing a caller-supplied role.

* refactor(guardrails): preserve role fidelity in shared _content_utils

Shared inspection helpers should extract text and preserve semantic
role signals; role coercion for third-party schema safety stays inside
the guardrail that needs it (AIM).

Three shared-helper changes:
- Bare content-part dicts (input_text/output_text) with an explicit role
  keep it; only role-less parts default to user.
- Responses message items already had their role preserved; the
  behavior is now covered by an explicit test.
- function_call_output items default to role tool (semantic equivalent
  of the chat-completions tool message shape) instead of role user, so
  Responses and chat completions produce symmetric inspection payloads.
  A caller-supplied role on the item is still preserved.

AIM's schema-safe coercion in _build_aim_inspection_messages already
handles the resulting role tool: it collapses to user before the POST
to /fw/v1/analyze so AIM's OpenAI-schema validator does not reject the
bare tool message (no tool_call_id can survive the flatten). Added a
regression test in test_aim.py covering that path.

(cherry picked from commit e84a19a)

* feat: add Meta Model API provider and muse-spark-1.1 (day-0) (#32701)

(cherry picked from commit d82645d)

* fix(bedrock): keep mid-conversation system messages in place for Claude Invoke (#32578)

Hoisting every role system entry into the top-level system field mutates
the cache prefix whenever a client such as Claude Code appends a new
mid-conversation system message, invalidating the prompt cache for the
entire message history on Bedrock Invoke. Bedrock only rejects a system
entry at messages.0, so hoist just the leading run and forward the rest
in place

(cherry picked from commit cc36d54)

* feat(otel): emit the gen_ai.client.operation.exception event on failed LLM calls (#32655)

* feat(otel): emit the gen_ai.client.operation.exception event on failed LLM calls

The GenAI semantic conventions record failures of a GenAI client operation as
a log-based event named gen_ai.client.operation.exception, carrying the
exception.type / exception.message / exception.stacktrace trio at severity
WARN and correlated to the failed span. OTel v2 never emitted it: a failed LLM
call produced only the deprecated error.* span attributes, a generic exception
span event without a stacktrace, and the stacktrace under the vendor key
litellm.provider.error.stack_trace.

Build the logs pipeline (LoggerProvider + console/OTLP log exporters mirroring
the metrics plumbing) and record the event behind the enable_events flag, which
until now was defined but consumed nowhere. An operator-configured LoggerProvider
global is reused so the events ride their existing logs pipeline; an explicit
NoOpLoggerProvider global is honored as an opt-out and builds no recorder at all.

The existing span-side error surface (error.type, error.message, the exception
span event, and the litellm.provider.error.* detail keys) is untouched for
backwards compatibility.

* fix(otel): always ride the semconv-required exception pair on the GenAI event

Filtering the event attributes on truthiness conflated "absent" with "empty",
so an empty exception.type or exception.message would have been dropped, leaving
an event with neither semconv-required field. Build the attributes so the pair is
unconditional and only the recommended stacktrace is omitted when the payload
carries none.

* docs(otel): document the events plumbing module in the package README

* test(otel): cover the log exporter selection and logs endpoint normalization

The new logs plumbing had no coverage for exporter-kind selection, the
console fallback for an unrecognized kind, the /v1/logs signal-path rewriting
that lets one OTEL_ENDPOINT serve every signal, or the simple-vs-batch
processor split.

(cherry picked from commit 99b4c5e)

* fix(bedrock): gate in-place system role messages on model support for Claude Invoke (#32831)

* fix(bedrock): gate in-place system role messages on model support for Claude Invoke

* feat(bedrock): default unmapped Claude 4.8+ to in-place system role handling via fallback rule

(cherry picked from commit 5e23a5a)

* fix(anthropic): translate adaptive thinking/effort to pre-4.6 model support (#32867)

* fix(anthropic): translate adaptive thinking/effort to pre-4.6 model support

AnthropicMessagesConfig now reshapes the 4.6+ adaptive-thinking interface
(thinking:{type:adaptive} + output_config:{effort:...}) to whatever the routed
model supports. Thinking-capable non-adaptive models (e.g. Haiku 4.5, Sonnet 4.5)
get the effort translated to a legacy thinking budget_tokens. Models with no
reasoning support have thinking/effort dropped under drop_params. And because
adaptive thinking carries no budget while the legacy form must satisfy Anthropic's
max_tokens > budget_tokens rule, the translated budget is capped below max_tokens,
dropping thinking when max_tokens can't fit the minimum budget. 4.6+ models pass
through untouched.

This matters because clients like Claude Code speak native Anthropic /v1/messages
and send the adaptive interface unconditionally, regardless of the routed model.
The native passthrough previously only capability-gated the OpenAI-style
reasoning_effort alias and forwarded native output_config/adaptive thinking raw, so
a pre-4.6 model rejected it with "This model does not support the effort parameter"
and the request failed. Claude Code already gets drop_params auto-set, so its
requests now succeed.

* test(anthropic): gate undersized-max_tokens thinking drop on drop_params; add edge tests

Addresses review feedback on the max_tokens-too-small branch. Previously a
thinking-capable model whose max_tokens could not fit the minimum thinking budget
had thinking silently dropped regardless of drop_params, while a residual
output_config field in the same call still raised when drop_params was off. Gate
both consistently on drop_params: raise a clear error (naming max_tokens for the
undersized case) when drop_params is off, drop otherwise. Claude Code gets
drop_params auto-set, so it still succeeds.

Adds tests for the undersized-max_tokens raise, the residual output_config raise,
and the no-adaptive-interface passthrough on a non-adaptive model.

* fix(anthropic): make adaptive-effort translation silent to avoid breaking provider strip contracts

The previous raise-when-not-drop_params behavior broke existing bedrock and vertex
messages tests: those providers already silently strip unsupported output_config
for pre-4.6 models (issue #22797) with no drop_params required, and the shared
parent transform raising pre-empted that. It also conflicted with the goal of
keeping requests working rather than failing them.

Make the reshape silent: translate effort to legacy thinking for thinking-capable
models, drop thinking for non-reasoning models, and remove only the consumed effort
key from output_config, leaving any residual (e.g. format) for provider subclasses
(bedrock/vertex) to handle. No raise, no drop_params gating. This also resolves the
review note about inconsistent drop_params handling by making every path uniform.

Updates the tests to assert the silent behavior and residual output_config
preservation.

* fix(anthropic): handle output_config-capable but non-adaptive models (Opus 4.5)

Greptile caught a real bug: the early-return guard treated supports_output_config
as equivalent to supporting adaptive thinking. Claude Opus 4.5 advertises
supports_output_config (it accepts output_config.effort) but is not adaptive, so it
rejects thinking:{type:adaptive} with "adaptive thinking is not supported on this
model". The guard early-returned for Opus 4.5 and forwarded the adaptive thinking
block raw, reproducing the exact failure the fix is meant to prevent.

thinking:{type:adaptive} and output_config.effort are independent capabilities.
Only early-return for adaptive-thinking models. For a model that supports
output_config.effort but is not adaptive, keep the native effort and drop only the
unsupported adaptive thinking block. Verified live against Opus 4.5: the Claude Code
payload now returns 200 instead of 400.

Adds regression tests for Opus 4.5 with and without adaptive thinking.

* fix(anthropic): translate adaptive thinking for effort-capable pre-4.6 models

Claude Opus 4.5 advertises supports_output_config but not adaptive thinking,
so the early-return guard forwarded thinking.type=adaptive raw and Anthropic
rejected it. The guard now only skips true adaptive models; effort-only
requests on effort-capable models still pass through untouched. The
_map_reasoning_effort call is wrapped to surface unrecognized effort values
as a clean 400, matching _translate_reasoning_effort_to_anthropic

* fix(anthropic): fall back to legacy thinking when effort level unsupported

Opus 4.5 accepts output_config.effort but only low/medium/high; Claude Code
defaults to xhigh on newer models, so preserving that level raw gets rejected
by Anthropic. Gate the native-effort passthrough on _validate_effort_for_model
and fall through to the budget translation for unsupported levels

* fix(anthropic): keep effort-only requests untouched for provider normalization

The xhigh fall-through consumed effort-only requests on effort-capable
models, breaking bedrock invoke's own normalization which clamps xhigh to
the model's ceiling after the base transform runs
(test_bedrock_messages_normalizes_output_config_effort_for_opus). Restrict
the fall-through to requests that carry adaptive thinking; effort-only
requests pass through so provider subclasses keep owning level clamping

---------

Co-authored-by: Abhimanyu Kapur <38531241+akapur99@users.noreply.github.com>
(cherry picked from commit 3a62e54)

* fix(bedrock): flag mapped Claude 4.8+ entries with supports_mid_conversation_system (#32882)

Exact cost-map hits resolve before fallback-generalization rules, so the
mapped Sonnet 5, Fable 5 and jp Opus 4.8 Bedrock entries bypassed the
bedrock-anthropic-claude-mid-conversation-system rule and hoisted
mid-conversation system messages, invalidating the prompt cache.

(cherry picked from commit c15891f)

* Merge pull request #32873 from BerriAI/litellm_fallback_rules_routing_split

refactor(fallback-generalizations): split rules into routing and provider-neutral capability kinds

(cherry picked from commit 45d3644)

* Merge pull request #32874 from BerriAI/litellm_thread_provider_capability_probes

fix(anthropic): thread real provider through capability probes instead of pinning anthropic

(cherry picked from commit ead7ad3)

* test: add /v1/messages to supported_endpoints schema enum (#32739)

(cherry picked from commit bf02a4a)

---------

Co-authored-by: Mateo Wang <277851410+mateo-berri@users.noreply.github.com>
Co-authored-by: yucheng-berri <yucheng@berri.ai>
Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Yassin Kortam <yassin@berri.ai>
Co-authored-by: Abhimanyu Kapur <38531241+akapur99@users.noreply.github.com>
Co-authored-by: tin-berri <tin@berri.ai>
edelauna pushed a commit to edelauna/litellm that referenced this pull request Jul 22, 2026
…elpers (BerriAI#32542)

* fix(guardrails): walk Responses-API text taxonomy in shared content helpers

Every guardrail sharing litellm/proxy/guardrails/_content_utils.py silently
drops all text on the /v1/responses path. AIM turns it into a loud 422 (
{"error":"No messages in the request"}); every other guardrail (Lakera v2,
Cato, Lasso, Repello, IBM, Azure Content Safety, enterprise secret
detection) scans an empty payload and lets the request through unscanned.

Three defects, all in _content_utils.py:

1. _iter_text_parts_in_content recognised only part.type == "text", but the
   Responses API uses input_text (request) and output_text (assistant).
2. _coerce_input_to_messages gated on "every item has a role key"; any
   Responses input list containing a function_call or function_call_output
   item failed the check and was wrapped as one opaque blob.
3. build_inspection_messages forwarded any role through, including a bare
   tool role missing tool_call_id, which validators like AIM's /fw/v1/analyze
   reject with a schema error.

Fix walks the actual Responses item taxonomy (message, function_call,
function_call_output, bare content parts and strings), recognises
{text, input_text, output_text} everywhere, and coerces any role outside
{system, user, assistant} to user in the outbound inspection payload.

* style: ruff-format changed guardrail files

* test(guardrails): cover function_call_output string form; drop em-dash in new docstring

* fix(guardrails): map function_call_output straight to user role

Avoids ever materialising a schema-invalid bare tool message. The
downstream role-safety coercion in build_inspection_messages still
guards genuinely caller-supplied non-standard roles (developer,
function, custom values); add a regression test covering that path
so the coercion has real coverage after this simplification.

* test(guardrails): pin chat-completions tool-role coercion in build_inspection_messages

* docs(test): soften AIM-specific claims in LIT-4294 test docstrings

Ryan's review flagged that several test docstrings assert AIM's
/fw/v1/analyze validates + rejects specific schema violations. That
behavior is customer-reported in the LIT-4294 writeup, not directly
verified by us. Rephrase to attribute the AIM 422 to the customer's
writeup and describe the underlying constraint as the OpenAI chat
schema; any downstream API that validates against that schema rejects
the same shape.

* refactor(guardrails): move unsupported-role coercion into AIM only

The generic coercion in build_inspection_messages collapsed any role
outside {system, user, assistant} to user for every caller of the
helper. Combined with the pre-existing apply_redacted_messages_back
write-back behavior in Lakera/AIM/Cato, that turned a loud OpenAI 400
on chat-completions tool-message masking into a silent semantic
corruption of the outbound request (role tool with tool_call_id got
rewritten to bare role user, dropping the assistant + tool_calls
sibling).

AIM specifically requires the coercion because its /fw/v1/analyze
validates the payload against the OpenAI chat schema; other guardrails
either do not validate roles or do their own reconstruction. Move the
coercion to AimGuardrail._build_aim_inspection_messages so the shared
helper keeps caller roles intact and no new cross-guardrail role
corruption is introduced. The pre-existing apply_redacted_messages_back
structural flatten remains as separate follow-up work.

function_call_output items still synthesise role user in the shared
helper because they have no natural role field, which is a different
concern from coercing a caller-supplied role.

* refactor(guardrails): preserve role fidelity in shared _content_utils

Shared inspection helpers should extract text and preserve semantic
role signals; role coercion for third-party schema safety stays inside
the guardrail that needs it (AIM).

Three shared-helper changes:
- Bare content-part dicts (input_text/output_text) with an explicit role
  keep it; only role-less parts default to user.
- Responses message items already had their role preserved; the
  behavior is now covered by an explicit test.
- function_call_output items default to role tool (semantic equivalent
  of the chat-completions tool message shape) instead of role user, so
  Responses and chat completions produce symmetric inspection payloads.
  A caller-supplied role on the item is still preserved.

AIM's schema-safe coercion in _build_aim_inspection_messages already
handles the resulting role tool: it collapses to user before the POST
to /fw/v1/analyze so AIM's OpenAI-schema validator does not reject the
bare tool message (no tool_call_id can survive the flatten). Added a
regression test in test_aim.py covering that path.
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