Repository navigation
fix(guardrails): scan each choice's tool-call arguments apart on n>1 streams and log why a rewrite was discarded - #40986
Conversation
…streams and log why a rewrite was discarded The rebuilt streamed response keyed tool-call fragments by tool index alone, so on n>1 chat streams the two choices' argument fragments were concatenated into one string and post_call guardrails scanned garbled JSON. Fragments are now keyed by (choice index, tool index). When a guardrail's rewrite cannot be written back to the stream (multi-choice streams, a rewrite that adds or drops a tool call, legacy-hook shapes the translation cannot rescan), the pipeline now logs a warning naming the guardrail and the exact reason before releasing the original stream. Also commits the regenerated dashboard API types that make check produced.
|
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
…d multi-choice chunk The rebuild's tool-call selection and its text-only fast path only looked at choice 0 of each chunk, so a chunk that packs several choices (Gemini with candidateCount above 1) lost a tool call carried by a later candidate, and a chunk whose later choice had no tool calls at all made the rebuild raise. Both now consider every choice in the chunk.
|
bugbot run |
Comments Outside DiffThese findings sit on lines the diff does not cover, so they could not be posted inline. Each one leaves this list once its file changes.
|
…e the OpenAPI snapshot
|
bugbot run |
There was a problem hiding this comment.
✅ 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 19d77e2. Configure here.
TLDR
Problem this solves:
How it solves it:
User Flow
Before: a developer streaming an n=2 tool call through a proxy whose post_call tool_permission guardrail allows lowercase fruit names gets HTTP 400 "arguments could not be parsed", their logging integration records one garbled tool call, and the proxy log never says why a mask was dropped
tool_permissionguardrail namedtool-arg-filtertogpt-4.1-minithat allowslookup_fruitwhen itsfruitargument matches^[a-z-]+$, plus a post_call content filter that masks the wordpersimmonPOST https://litellm-domain/v1/chat/completionswith"stream": true,"n": 2, the user message "Look up the fruit blackberry-elderberry-gooseberry-huckleberry-lingonberry.", onelookup_fruittool,tool_choiceforcing that tool, and"guardrails": ["tool-arg-filter"]{"error":{"message":"Guardrail raised an exception, Guardrail: tool-arg-filter, Message: Tool 'lookup_fruit' arguments could not be parsed required by rule 'lookup_fruit_lowercase'","type":"None","param":"None","code":"400"}}, even though both choices asked for a lowercase fruit the rule allowsguardrailsfield and get HTTP 200, the two choices' argument fragments interleaved chunk by chunk, each assembling to{"fruit":"persimmon"}, unmaskedlookup_fruitcall whose arguments read{"fruit":"{"persfruitim":"monpers"}immon"}Masked keyword 'persimmon' in contenttwice, then onlyPipeline: guardrail 'output-word-filter' rewrote the streamed response in a way this endpoint's streaming pipeline cannot deliver yet; the rewrite was discarded and the original stream released, with no reasonAfter: the same tool_permission request comes back HTTP 200 with both tool calls, the logging integration records each choice's arguments intact, and the proxy log names the guardrail and the reason the mask was dropped
tool_permissionguardrail namedtool-arg-filtertogpt-4.1-minithat allowslookup_fruitwhen itsfruitargument matches^[a-z-]+$, plus a post_call content filter that masks the wordpersimmonPOST https://litellm-domain/v1/chat/completionswith"stream": true,"n": 2, the user message "Look up the fruit blackberry-elderberry-gooseberry-huckleberry-lingonberry.", onelookup_fruittool,tool_choiceforcing that tool, and"guardrails": ["tool-arg-filter"]lookup_fruitcalls,{"fruit":"blackberry-elderberry-gooseberry-huckleberry-lingonberry"}and the second sample's shorter{"fruit":"blackberry"}, thendata: [DONE]guardrailsfield and get HTTP 200, the two choices' argument fragments interleaved chunk by chunk, each assembling to{"fruit":"persimmon"}, still unmaskedlookup_fruitcalls, each with arguments{"fruit":"persimmon"}Masked keyword 'persimmon' in contenttwice, thenPipeline: guardrail 'output-word-filter' rewrote the streamed response but the rewrite could not be written back to the stream: the stream carries 2 choices and tool-call rewrites are only written back on single-choice streams. The whole rewrite, text rewrites included, was discarded and the original stream releasedDesign decisions
tool_choice, so the two rebuilt tool calls in the proof share an id; that is provider behavior, not the rebuildRelevant issues
Affected release
Linear ticket
Resolves LIT-7346
Pre-Submission checklist
Please complete all items before asking a LiteLLM maintainer to review your PR
uv run pytest tests/test_litellm/<your_test_file>.py -v. Leave the suites (make test-unit-*,make test-unit) to CI: it finishes in ~15 minutes where a laptop takes an hour or more@greptileaito re-request a review after pushing changes)Delays in PR merge?
If you're seeing a delay in your PR being merged, ping the LiteLLM Team on Slack (#pr-review).
Screenshots / Proof of Fix
Both legs run the same config through a real proxy against the real OpenAI API with
gpt-4.1-mini, the chat model that still honorsn(the gpt-5.x chat models are bridged to the Responses API and ignore it). Each leg is its own checkout withPYTHONPATHpointing at it, one proxy per leg on its own random port, 2 uvicorn workers, no database, the master key and provider keys from the environment. Before is 1fcef68, the merge base 3df1593 plus the two test-only commits of #42048 (git diff --stat 3df159308d5 1fcef68ab7 -- . ':!tests'is empty). After is 2a35dc5; the three commits after it, 810acda (drops a comment, restores the OpenAPI snapshot), 2e83871, and 19d77e2 (type annotations on a test-only hook), cannot change behaviorConfig (
lit7346_config.yaml):Proxy boot, each leg from its own checkout on its own port:
Requests.
toolperm.jsonis the tool_permission case; the content-filter cases only swap the user message and drop theguardrailsfield:persimmon(masked, streamed aspers,im,mon),mangosteen(blocked, streamed asm,ang,ost,een),kumquat(blocked, streamed whole).persimmon_n1.jsonis the persimmon request with"n": 1,nonstream_n2.jsonthe persimmon request with"stream": false, and the two text requests carry no tools{"model":"gpt-4.1-mini","stream":true,"n":2,"messages":[{"role":"user","content":"Look up the fruit blackberry-elderberry-gooseberry-huckleberry-lingonberry."}],"tools":[{"type":"function","function":{"name":"lookup_fruit","parameters":{"type":"object","properties":{"fruit":{"type":"string"}},"required":["fruit"]}}}],"tool_choice":{"type":"function","function":{"name":"lookup_fruit"}},"guardrails":["tool-arg-filter"]}{"model":"gpt-4.1-mini","stream":true,"n":2,"messages":[{"role":"user","content":"Repeat exactly this sentence and nothing else: The persimmon is ripe."}]}The curl and the one-liner that pulls each choice's argument fragments out of a saved stream run the same way on every leg:
Before (1fcef68)
tool_permission on an n=2 tool call, three runs
for run in 1 2 3; do curl ... -d @toolperm.json -o toolperm.$run.out -w "run $run: HTTP %{http_code}\n"; donerun 1: HTTP 400,run 2: HTTP 400,run 3: HTTP 400cat toolperm.1.outprints{"error":{"message":"Guardrail raised an exception, Guardrail: tool-arg-filter, Message: Tool 'lookup_fruit' arguments could not be parsed required by rule 'lookup_fruit_lowercase'","type":"None","param":"None","code":"400"}}Tool Permission Guardrail: Found 1 tool callsand thenTool 'lookup_fruit' arguments could not be parsed required by rule 'lookup_fruit_lowercase'Masked word on an n=2 tool call (
persimmon), two runsfor run in 1 2; do curl ... -d @persimmon.json -o persimmon.$run.out -w "run $run: HTTP %{http_code}\n"; donerun 1: HTTP 200,run 2: HTTP 200persimmon.1.outprintsc0="" c0="{\" c0="fruit" c0="\" c1="" c1="{\" c0="pers" c1="fruit" c0="im" c1="\" c0="mon" c1="pers" c0="\" c1="im" c1="mon" c1="\": both choices assemble to{"fruit":"persimmon"}, unmaskedMasked keyword 'persimmon' in contenttwice, thenPipeline: guardrail 'output-word-filter' rewrote the streamed response in a way this endpoint's streaming pipeline cannot deliver yet; the rewrite was discarded and the original stream releasedBlocked word streamed in pieces (
mangosteen), three runsfor run in 1 2 3; do curl ... -d @mangosteen.json -o mangosteen.$run.out -w "run $run: HTTP %{http_code}\n"; donerun 1: HTTP 400,run 2: HTTP 400,run 3: HTTP 400cat mangosteen.1.outprintsdata: {"error": {"message": "Content blocked: keyword 'mangosteen' detected", "type": "invalid_request_error", "param": null, "code": "400", "provider_specific_fields": {"error": "Content blocked: keyword 'mangosteen' detected", "keyword": "mangosteen", "description": null, "guardrail_name": "output-word-filter", "guardrail_mode": "post_call"}}}thendata: [DONE]Blocked word streamed whole (
kumquat), three runsfor run in 1 2 3; do curl ... -d @kumquat.json -o kumquat.$run.out -w "run $run: HTTP %{http_code}\n"; donerun 1: HTTP 400,run 2: HTTP 400,run 3: HTTP 400cat kumquat.1.outprintsdata: {"error": {"message": "Content blocked: keyword 'kumquat' detected", ... "guardrail_name": "output-word-filter", "guardrail_mode": "post_call"}}thendata: [DONE]n=1 masked tool call (
persimmon_n1.json)curl ... -d @persimmon_n1.json -o persimmon_n1.1.out -w 'HTTP %{http_code}\n'printsHTTP 200{"id":"call_NADqNnHdBxx82AtliWXmXplc","function":{"arguments":"{\"fruit\": \"[KEYWORD_REDACTED]\"}","name":"lookup_fruit"},"type":"function","index":0}n=2 text (
text_n2.json)curl ... -d @text_n2.json -o text_n2.1.out -w 'HTTP %{http_code}\n'printsHTTP 200grep -oE '"index":[01],"delta":\{"content":"[^"]+"' text_n2.1.outprints"index":0,"delta":{"content":"The [KEYWORD_REDACTED] is ripe."and"index":1,"delta":{"content":"The [KEYWORD_REDACTED] is ripe."n=1 text (
text_n1.json)curl ... -d @text_n1.json -o text_n1.1.out -w 'HTTP %{http_code}\n'printsHTTP 200grep -oE '"content":"[^"]+"' text_n1.1.outprints"content":"The [KEYWORD_REDACTED] is ripe."Non-streamed n=2 tool call (
nonstream_n2.json)curl ... -d @nonstream_n2.json -o nonstream_n2.1.out -w 'HTTP %{http_code}\n'printsHTTP 200python3 -c 'import json; d=json.load(open("nonstream_n2.1.out")); print([(c["index"], c["message"]["tool_calls"][0]["function"]["arguments"]) for c in d["choices"]])'prints[(0, '{"fruit": "[KEYWORD_REDACTED]"}'), (1, '{"fruit": "[KEYWORD_REDACTED]"}')]After (2a35dc5)
tool_permission on an n=2 tool call, three runs
for run in 1 2 3; do curl ... -d @toolperm.json -o toolperm.$run.out -w "run $run: HTTP %{http_code}\n"; donerun 1: HTTP 200,run 2: HTTP 200,run 3: HTTP 200cat toolperm.1.outprints one chunk,data: {"id":"chatcmpl-EQ0GZevHMSR4bQ5ArLDSfouwMdV3w","created":1789866259,"model":"gpt-4.1-mini","object":"chat.completion.chunk","choices":[{"finish_reason":"stop","index":0,"delta":{"role":"assistant","tool_calls":[{"id":"call_DSgGIBOsD74A8TiADNbS7gsV","function":{"arguments":"{\"fruit\":\"blackberry-elderberry-gooseberry-huckleberry-lingonberry\"}","name":"lookup_fruit"},"type":"function","index":0},{"id":"call_DSgGIBOsD74A8TiADNbS7gsV","function":{"arguments":"{\"fruit\":\"blackberry\"}","name":"lookup_fruit"},"type":"function","index":1}]}}],"usage":{"completion_tokens":25,"prompt_tokens":67,"total_tokens":92, ...}}thendata: [DONE](the shorter second argument is the second sample's own output; runs 2 and 3 carry idscall_anAgl88K7lNkj9ZZITFa6zOPandcall_1i3P5gXo9H0kjrzt1arC23hN)Tool Permission Guardrail: Found 2 tool callsand thenTool Permission Guardrail Post-Call Hook: All tools allowedMasked word on an n=2 tool call (
persimmon), two runsfor run in 1 2; do curl ... -d @persimmon.json -o persimmon.$run.out -w "run $run: HTTP %{http_code}\n"; donerun 1: HTTP 200,run 2: HTTP 200persimmon.1.outprintsc0="" c0="{\" c0="fruit" c0="\" c1="" c1="{\" c0="pers" c1="fruit" c0="im" c1="\" c0="mon" c1="pers" c0="\" c1="im" c1="mon" c1="\": both choices assemble to{"fruit":"persimmon"}, still unmasked on the wireMasked keyword 'persimmon' in contenttwice, thenPipeline: guardrail 'output-word-filter' rewrote the streamed response but the rewrite could not be written back to the stream: the stream carries 2 choices and tool-call rewrites are only written back on single-choice streams. The whole rewrite, text rewrites included, was discarded and the original stream releasedBlocked word streamed in pieces (
mangosteen), three runsfor run in 1 2 3; do curl ... -d @mangosteen.json -o mangosteen.$run.out -w "run $run: HTTP %{http_code}\n"; donerun 1: HTTP 400,run 2: HTTP 400,run 3: HTTP 400cat mangosteen.1.outprintsdata: {"error": {"message": "Content blocked: keyword 'mangosteen' detected", "type": "invalid_request_error", "param": null, "code": "400", "provider_specific_fields": {"error": "Content blocked: keyword 'mangosteen' detected", "keyword": "mangosteen", "description": null, "guardrail_name": "output-word-filter", "guardrail_mode": "post_call"}}}thendata: [DONE]Blocked word streamed whole (
kumquat), three runsfor run in 1 2 3; do curl ... -d @kumquat.json -o kumquat.$run.out -w "run $run: HTTP %{http_code}\n"; donerun 1: HTTP 400,run 2: HTTP 400,run 3: HTTP 400cat kumquat.1.outprintsdata: {"error": {"message": "Content blocked: keyword 'kumquat' detected", ... "guardrail_name": "output-word-filter", "guardrail_mode": "post_call"}}thendata: [DONE]n=1 masked tool call (
persimmon_n1.json)curl ... -d @persimmon_n1.json -o persimmon_n1.1.out -w 'HTTP %{http_code}\n'printsHTTP 200{"id":"call_DxRVYMFHzB8K2CnOXudR8QKo","function":{"arguments":"{\"fruit\": \"[KEYWORD_REDACTED]\"}","name":"lookup_fruit"},"type":"function","index":0}n=2 text (
text_n2.json)curl ... -d @text_n2.json -o text_n2.1.out -w 'HTTP %{http_code}\n'printsHTTP 200grep -oE '"index":[01],"delta":\{"content":"[^"]+"' text_n2.1.outprints"index":0,"delta":{"content":"The [KEYWORD_REDACTED] is ripe."and"index":1,"delta":{"content":"The [KEYWORD_REDACTED] is ripe."n=1 text (
text_n1.json)curl ... -d @text_n1.json -o text_n1.1.out -w 'HTTP %{http_code}\n'printsHTTP 200grep -oE '"content":"[^"]+"' text_n1.1.outprints"content":"The [KEYWORD_REDACTED] is ripe."Non-streamed n=2 tool call (
nonstream_n2.json)curl ... -d @nonstream_n2.json -o nonstream_n2.1.out -w 'HTTP %{http_code}\n'printsHTTP 200python3 -c 'import json; d=json.load(open("nonstream_n2.1.out")); print([(c["index"], c["message"]["tool_calls"][0]["function"]["arguments"]) for c in d["choices"]])'prints[(0, '{"fruit": "[KEYWORD_REDACTED]"}'), (1, '{"fruit": "[KEYWORD_REDACTED]"}')]Observed next to the fix, none caused or worsened by this PR:
tool_choicemangosteenandkumquatare already blocked at the merge base (fix(policy_engine): deliver guardrail text rewrites on multi-choice, unfinished, and envelope-less streams #41933)Type
🐛 Bug Fix
Caveats (if any)
Medium
Low
tool_choice, so the rebuilt tool calls share an idcandidateCount> 1 on streams), so this is assumed, not testedintegration-extensions,integration-cost,logging_testing, andproxy_e2e_anthropic_messages_testsare red on this branch for failures main already fixed after the branch's merge base: the fastmcp import (test(mcp): restore scoped execution and credential isolation regressions #42050 daecea3), the fireworks cache-read cost case and the bedrock beta-header cases (fix(test): unbreak the integration-cost and proxy_e2e_anthropic_messages CircleCI jobs on main #42048 02736e2, 7966f50), and the GCS pub/sub spend-log golden missing the autorouter keys (test(logging): add autorouter estimate keys to the GCS pub/sub spend-log golden #42061 144cf9a); no other CircleCI job is redFinal Attestation
The tests check the right things, including the edge cases, and regressions in the respective real-world customer use-cases are not possible after this PR
2a35dc5 passes /live-pr-risk (810acda, 2e83871, and 19d77e2 after it are comment, snapshot, and test-typing only)
Note
Medium Risk
Changes streaming reassembly and post-call guardrail discard paths used on live proxy streams; behavior for successful single-choice streams should be unchanged, but n>1 and packed-chunk tool-call shapes differ.
Overview
Fixes garbled tool-call JSON on n>1 (and multi-choice-per-chunk) streams by assembling streaming tool-call fragments under
(choice index, tool index)instead of tool index alone, so post-call guardrails scan each choice’s arguments separately.stream_chunk_buildernow considers every choice in a chunk when detecting tool calls and when deciding the text-only fast path (via shared delta helpers), so tool calls on later choices are not dropped.When a guardrail rewrite cannot be written back to the stream,
UndeliverableStreamRewritecarries a specific reason; the pipeline executor logs that reason and still releases the original chunks. OpenAI, Anthropic, and Responses guardrail paths emit clearer messages (multi-choice streams, tool-call count mismatches, missing stop/terminal envelope, legacy-hook limits).Tests cover per-choice argument assembly and the new exception
reasonfields.Reviewed by Cursor Bugbot for commit 65160a9. Bugbot is set up for automated code reviews on this repo. Configure here.