Skip to content

fix(generic_guardrail_api): return tool_calls on the guardrail response - #41013

Open
ninadphalak wants to merge 5 commits into
BerriAI:mainfrom
ninadphalak:fix/generic-guardrail-response-tool-calls
Open

ninadphalak wants to merge 5 commits into
BerriAI:mainfrom
ninadphalak:fix/generic-guardrail-response-tool-calls

Conversation

@ninadphalak

@ninadphalak ninadphalak commented Sep 13, 2026 •

Copy link
Copy Markdown
Contributor

TLDR

Problem this solves:

  • a guardrail that rewrites tool arguments cannot return them
  • the reply keeps the arguments the caller sent in
  • the Responses path warns "no tool calls" on every tool call

How it solves it:

  • the guardrail response can carry tool_calls, parsed in from_dict by tool_calls_from_response
  • every returned row needs a function name and string arguments, otherwise the list is dropped with a warning
  • when the guardrail returns none, the sent calls are echoed back unchanged

User Flow

Before: an operator rewrites tool arguments in a guardrail and the rewritten values never reach the application

  1. The operator configures a guardrail on the generic guardrail API route that rewrites a postal code inside a tool call's arguments
  2. The application sends POST https://litellm-domain/v1/chat/completions with "tools" present and gets back finish_reason: "tool_calls"
  3. The tool call in the reply still carries the postal code the application sent, so it dispatches to the address it already knew
  4. Nothing in the response says the guardrail's values were ignored

After: the same request comes back with the guardrail's values in the tool call

  1. The application sends the same POST https://litellm-domain/v1/chat/completions
  2. The same tool call comes back with the rewritten postal code in arguments
  3. The application dispatches to the address the guardrail returned
  4. A guardrail that returns a call with no function name or arguments leaves the reply unchanged, and the proxy logs why

Pre-Submission checklist

  • I have added meaningful tests
  • The handful of test files covering my change pass locally: tests/unit/proxy/guardrails/guardrail_hooks/test_generic_guardrail_api.py, 80 passed
  • My PR passes all required CI/CD checks: everything passes except osv-scan, which fails on every open PR right now (a sharp advisory in ui/litellm-dashboard/package-lock.json)
  • 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

Screenshots / Proof of Fix

A real Gemini call through the proxy, with a generic guardrail that rewrites postal code 94107 to 10001 in returned tool calls. Before is ghcr.io/berriai/litellm:main-latest (sha256:67831417c141), After is the same image with this branch's litellm/ mounted over the installed package

docker compose up -d
curl -s localhost:4000/v1/chat/completions -H "Authorization: Bearer sk-litellm-test" -H "Content-Type: application/json" -d @request.json | jq -c '.choices[0].message.tool_calls[0].function'
docker compose logs guardrail

Before, the guardrail returns 10001 and the client still gets 94107

{"arguments":"{\"order_id\": \"1182\", \"postal_code\": \"94107\"}","name":"ship_order"}
guardrail response: 1 tool call(s)
guardrail returns arguments: ['{"order_id": "1182", "postal_code": "10001"}']

After (LITELLM_SRC=/path/to/this/branch docker compose -f compose.yaml -f pr.yaml up -d), the client gets the guardrail's value

{"arguments":"{\"order_id\": \"1182\", \"postal_code\": \"10001\"}","name":"ship_order"}
guardrail response: 1 tool call(s)
guardrail returns arguments: ['{"order_id": "1182", "postal_code": "10001"}']
compose.yaml, pr.yaml, config.yaml, request.json, rewrite_guardrail.py
# compose.yaml
services:
  guardrail:
    image: python:3.12-slim
    command: ["python", "/rewrite_guardrail.py"]
    volumes: ["./rewrite_guardrail.py:/rewrite_guardrail.py:ro"]
  litellm:
    image: ghcr.io/berriai/litellm:main-latest
    command: ["--config", "/config.yaml", "--port", "4000"]
    environment:
      LITELLM_MASTER_KEY: sk-litellm-test
      GEMINI_API_KEY: ${GEMINI_API_KEY:?}
    volumes: ["./config.yaml:/config.yaml:ro"]
    ports: ["4000:4000"]
    depends_on: [guardrail]
# pr.yaml
services:
  litellm:
    volumes:
      - ${LITELLM_SRC:?set LITELLM_SRC to a checkout of the PR branch}/litellm:/app/.venv/lib/python3.13/site-packages/litellm:ro
# config.yaml
model_list:
  - model_name: gemini-3.7-flash
    litellm_params:
      model: gemini/gemini-3.7-flash
      api_key: os.environ/GEMINI_API_KEY
      num_retries: 6

guardrails:
  - guardrail_name: rewrite-postal-code
    litellm_params:
      guardrail: generic_guardrail_api
      mode: post_call
      default_on: true
      api_base: http://guardrail:8100
{"model": "gemini-3.7-flash",
 "messages": [{"role": "user", "content": "Ship order 1182 to postal code 94107."}],
 "tools": [{"type": "function", "function": {"name": "ship_order", "description": "Ship an order",
   "parameters": {"type": "object", "properties": {"order_id": {"type": "string"}, "postal_code": {"type": "string"}},
   "required": ["order_id", "postal_code"]}}}],
 "tool_choice": {"type": "function", "function": {"name": "ship_order"}}}
import json
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer


class RewriteGuardrail(BaseHTTPRequestHandler):
    def do_POST(self):
        body = json.loads(self.rfile.read(int(self.headers["Content-Length"])))
        tool_calls = body.get("tool_calls") or []
        print(f"guardrail {body['input_type']}: {len(tool_calls)} tool call(s)", flush=True)
        reply = {"action": "NONE", "texts": body.get("texts") or []}
        if body["input_type"] == "response" and tool_calls:
            rewritten = [
                {**call, "function": {**call["function"], "arguments": call["function"]["arguments"].replace("94107", "10001")}}
                for call in tool_calls
            ]
            print(f"guardrail returns arguments: {[c['function']['arguments'] for c in rewritten]}", flush=True)
            reply = {"action": "GUARDRAIL_INTERVENED", "texts": body.get("texts") or [], "tool_calls": rewritten}
        payload = json.dumps(reply).encode()
        self.send_response(200)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(payload)))
        self.end_headers()
        self.wfile.write(payload)

    def log_message(self, *args):
        pass


ThreadingHTTPServer(("0.0.0.0", 8100), RewriteGuardrail).serve_forever()

Mutation check on the 8 new unit tests, each mutation reverted afterwards:

  • skip writing tool_calls back in _build_guardrail_return_inputs: 2 fail
  • skip the echo in _echo_sent_tool_calls: 2 fail
  • skip row validation in tool_calls_from_response: 2 fail

Type of Pull Request

🐛 Bug Fix

Caveats (if any)

Low

  • the live run above is a non-streaming chat completion. Streamed tool calls stay out of scope: incremental_diff is documented for string delta.content only
  • inside a policy pipeline, a multi-choice stream whose tool calls a guardrail rewrites is rolled back to the original chunks by PipelineExecutor, the same rule every guardrail there follows. Outside a pipeline the request fails instead
  • no new LIT, ruff-strict or basedpyright findings against the merge base, and no budget files are touched

`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.
@codspeed

codspeed Bot commented Sep 13, 2026 •

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 31 untouched benchmarks


Comparing ninadphalak:fix/generic-guardrail-response-tool-calls (e5cab21) with main (bd23e6f)1

Open in CodSpeed

Footnotes

  1. No successful run was found on main (090a4c3) during the generation of this report, so bd23e6f was used instead as the comparison base. There might be some changes unrelated to this pull request in this report. ↩

@greptile-apps

greptile-apps Bot commented Sep 13, 2026 •

Copy link
Copy Markdown
Contributor

RetriggerConfidence Score: 5/5

[Medium risk] Guardrail API now passes tool calls through its response.

The PR appears safe to merge based on this re-review.

Findings

  1. P2 Verbose test comments violate policy ▶

Summary

The PR lets the generic guardrail API return rewritten tool calls, validates returned calls before applying them, and retains sent calls when the guardrail supplies no usable replacement. The latest revision removes explanatory docstrings from the new helpers and tests.

Reviews (5) · Last reviewed commit: "chore(generic_guardrail_api): drop docst..."

Comment thread litellm/types/proxy/guardrails/guardrail_hooks/generic_guardrail_api.py Outdated
@codecov

codecov Bot commented Sep 13, 2026 •

Copy link
Copy Markdown

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.
@ninadphalak

Copy link
Copy Markdown
Contributor Author

@greptileai

Comment thread litellm/types/proxy/guardrails/guardrail_hooks/generic_guardrail_api.py Outdated
Comment on lines +1908 to +1916
@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.
"""

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 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!

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.

Removed the docstrings from the new tests in e5cab21, and from the new source helpers too, per the comment rule in AGENTS.md

… 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.
@ninadphalak

Copy link
Copy Markdown
Contributor Author

@greptileai

@devin-ai-integration
devin-ai-integration Bot changed the base branch from litellm_internal_staging to main September 23, 2026 14:34
@devin-ai-integration
devin-ai-integration Bot requested a review from a team September 23, 2026 14:34
Resolve conflicts with structured_messages, which landed in the same two
files; keep both fields.

The response now parses tool_calls in from_dict (tool_calls_from_response):
every row needs a function name and string arguments, otherwise the list is
dropped with a warning and the calls that were sent are kept. Fields are
read-only Sequences, and the sent calls are echoed by _echo_sent_tool_calls
in apply_guardrail, so the change adds no LIT001 or basedpyright findings.
if rows_to_write_back is not None:
return_inputs["structured_messages"] = list(rows_to_write_back)
if guardrail_response.tool_calls:
return_inputs["tool_calls"] = list(guardrail_response.tool_calls)

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: Multi-choice streams bypass text redaction

In a post-call policy pipeline using this guardrail, a caller can request stream: true, n: 2 and receive original, unredacted assistant text when the guardrail also rewrites a tool call: _write_ended_stream_tool_call_rewrites rejects multi-choice streams, and PipelineExecutor._run_streaming_step catches that rejection and restores all original chunks. The PR changes this integration from omitting tool-call rewrites to forwarding them, introducing a rollback path that also discards text redactions.

Before forwarding these rewrites on this path, implement per-choice tool-call delivery or make unsupported rewrites fail closed rather than releasing the original stream.

@ninadphalak ninadphalak Oct 6, 2026 •

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.

Leaving this: outside pipelines it fails closed, and inside them the executor rolls back every guardrail's undeliverable rewrite. Happy to change the executor separately

@veria-ai

veria-ai Bot commented Oct 6, 2026

Copy link
Copy Markdown
Contributor

PR overview

This PR updates the generic guardrail API integration to return tool-call rewrites in guardrail responses and forward them through response processing.

One issue remains open, with none addressed so far. A caller requesting a multi-choice stream can receive original, unredacted assistant text when the guardrail also rewrites a tool call: the unsupported rewrite triggers a fallback that restores the original chunks, discarding text redactions. This creates a conditional bypass of post-call redaction policies for affected streaming responses.

Open issues (1)

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

@ninadphalak

ninadphalak commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor Author

Merged main and fixed the lint failures. CI is green except osv-scan, which fails on every open PR. @yucheng-berri could you review this?

@ninadphalak

Copy link
Copy Markdown
Contributor Author

@greptileai please re-review

@ninadphalak

Copy link
Copy Markdown
Contributor Author

Added a live Docker run with Gemini to the description: main-latest returns the sent postal code, this branch returns the guardrail's

This branch has not been deployed

No deployments
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.

1 participant