fix(generic_guardrail_api): return tool_calls on the guardrail response - #41013
Conversation
`GenericGuardrailAPIRequest` carries `tool_calls` to the guardrail, but
`GenericGuardrailAPIResponse` has no matching field and
`_build_guardrail_return_inputs` never set the key, so a guardrail that rewrites
tool arguments cannot hand them back. The read side already exists:
- `litellm/llms/openai/chat/guardrail_translation/handler.py` reads
`guardrailed_inputs.get("tool_calls", [])`, and the output path reads
`returned_tool_calls` with a length guard
- `litellm/llms/anthropic/chat/guardrail_translation/handler.py` reads
`returned_tool_calls`, also length-guarded
- `litellm/llms/openai/responses/guardrail_translation/handler.py` passes it to
`_post_guardrail_tool_call_shapes`, which falls back and logs "guardrail
returned no tool calls for the N scanned" whenever the key is absent
Only the generic hook's write side was missing, so all of those lookups missed and
a guardrail's returned tool calls were dropped.
Add the field and thread it through, mirroring `tools`/`images`: the guardrail's
replacements win, otherwise the calls that were sent are echoed back unchanged.
Every consumer compares the returned list against the one it sent and falls back to
its own copy on a length mismatch, so an unchanged echo leaves existing guardrails
behaving exactly as before.
Greptile SummaryThis revision fixes the invalid TypedDict alias and keeps tool-call selection immutable while preserving the guardrail write-back behavior
Confidence Score: 4/5The code changes appear functionally safe, but the unresolved repository requirement for concise test comments must be satisfied before merging Tool-call rewrites are validated and downstream handlers preserve original calls when a returned list is absent or has the wrong length. The import-breaking type alias and malformed-call crash findings are fixed. Three earlier threads were manually resolved without explanatory replies. The existing verbose test-docstring finding remains unresolved Files Needing Attention: tests/test_litellm/proxy/guardrails/guardrail_hooks/test_generic_guardrail_api.py
|
| Filename | Overview |
|---|---|
| litellm/proxy/guardrails/guardrail_hooks/generic_guardrail_api/generic_guardrail_api.py | Propagates validated guardrail tool-call rewrites and retains original calls when returned values are unusable |
| litellm/types/proxy/guardrails/guardrail_hooks/generic_guardrail_api.py | Adds tool calls to the guardrail response model and replaces the import-breaking alias with explicit supported types |
| tests/test_litellm/proxy/guardrails/guardrail_hooks/test_generic_guardrail_api.py | Adds useful tool-call regression coverage, but the existing unresolved verbose-docstring policy finding remains |
Reviews (3): Last reviewed commit: "fix(generic_guardrail_api): annotate the..." | Re-trigger Greptile
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
…tra comments A same-length list of tool calls with no function block was forwarded as-is, and the chat handlers index function.name and function.arguments on whatever comes back, so an unusable list raised there instead of returning a response. Returned calls are now checked for those two fields, and the calls already sent are kept when one is missing them, which is the fallback the responses handler already takes. The new field is annotated with the request's own field type rather than spelling the list union out twice, which keeps it exact and keeps the type-discipline budget flat, and the comment blocks that restated the code are gone.
| @pytest.mark.asyncio | ||
| async def test_guardrail_returned_tool_calls_flow_back_to_inputs(self, generic_guardrail): | ||
| """A guardrail that rewrites tool arguments has to be able to return them. | ||
|
|
||
| ``tool_calls`` is sent on the request, so the matching response field is what closes | ||
| the loop. Without it the calls were dropped on the floor and the caller silently | ||
| kept the values it sent in -- for a rewriting guardrail (reversible redaction, for | ||
| instance) the rewritten arguments never reached the client. | ||
| """ |
There was a problem hiding this comment.
Verbose test comments violate policy
These docstrings restate straightforward behavior, including similar blocks near lines 1937 and 1999. Repository policy requires removing them before merge
Context Used: CLAUDE.md (source)
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
… never rebind The type alias that named this field read the request's field through a TypedDict subscript, which ruff reads as a forward reference, and it failed the basedpyright budget. The annotations spell the union out again, which is exact and checks against GenericGuardrailAPIInputs without an alias and without a suppression comment. The unusable-reply path no longer rebinds a Final local, so it needs no suppression comment either: the calls that will be applied are picked in one expression.
TLDR
Problem this solves:
How it solves it:
User Flow
Before: an operator rewrites tool arguments in a guardrail and the rewritten values never reach the application
"tools"present and gets backfinish_reason: "tool_calls"After: the same request comes back with the guardrail's values in the tool call
argumentsRelevant issues
Affected release
Linear ticket
Pre-Submission checklist
openai3.5.0 while this repo pins<3.0.0, so LiteLLM does not import here. See Caveatslintfails on this branch, see Caveats for which step and whyScreenshots / Proof of Fix
Pending: this environment cannot run a live proxy with a guardrail endpoint and a provider key, so I have no unmocked Before and After to show. The unit tests in
tests/test_litellm/proxy/guardrails/guardrail_hooks/test_generic_guardrail_api.pyare the evidence so far, and I will add the end to end run if a maintainer wants it before reviewType of Pull Request
🐛 Bug Fix
Caveats (if any)
Medium
tool_calls: list[...] | list[...] | Noneis the exact type the field carries, and it adds 6 to the LIT001 ceiling intype-discipline-budget.json. Annotating it as a read-onlySequenceis not an option: the value is assigned intoGenericGuardrailAPIInputs, whosetool_callsfield is declared as alist, so aSequencefails the basedpyright budget instead. Raising the ceiling by 6 or removing 6 elsewhere are both one line changes if a maintainer prefers eitherLow
incremental_diffis documented for stringdelta.contentonlyargumentsis not a string is treated as unusable and the sent calls are keptlintjob also fails on steps that are red on the base branch, so it is not a green signal here