Repository navigation
feat(guardrails): per-mode stream_scope with bedrock stream and pass-through fixes - #43801
Conversation
…both Add stream_scope so a rail can target streaming inference, non-streaming inference, or both per pre, during, and post mode Co-authored-by: Cursor <cursoragent@cursor.com>
Pipeline steps skipped the stream_scope filter, direct construction ignored mixed-case maps, and schema.d.ts was stale. Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Passthrough routes like Gemini streamGenerateContent decide streaming from the URL, so stamp that onto hook data before guardrails run. Co-authored-by: Cursor <cursoragent@cursor.com>
Clippy treats clone-on-Copy as an error, which failed rust-lint on the messages request tests. Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
scan_raw_request snapshots copy each field, so a plain object() marker would lose identity and skip streaming-only rails. Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com> # Conflicts: # tests/unit/experimental_mcp_client/test_mcp_client.py # tests/unit/proxy/_experimental/mcp_server/test_mcp_client_unit.py # tests/unit/proxy/_experimental/mcp_server/test_mcp_server.py
Two main cost-map PRs both added the OpenRouter model, so the merge left a second key that CI rejects. Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com> # Conflicts: # litellm/model_prices_and_context_window_backup.json # model_prices_and_context_window.json
HTTP status errors from native routes map through route_error_to_pyerr, so the wheel SIGINT child was dying on an outdated RuntimeError check and never reached the hang probe. Co-authored-by: Cursor <cursoragent@cursor.com>
The live spec dropped CreateModelInteractionParams and renamed the item path to {interactionsId}. Misc CI failed because the compliance tests still looked those names up as literals.
Co-authored-by: Cursor <cursoragent@cursor.com>
…d gaps Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
…treaming_request Co-authored-by: Shivi Jain <mobile.350017@gmail.com> Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
…e builds Co-authored-by: Shivi Jain <mobile.350017@gmail.com> Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
|
I'll fix CI failures and address comments from users with write access. I'll skip comments containing "(aside)".
|
|
bugbot run |
|
… mutable-ok The clear/update on _parsed_body is load-bearing: rebinding to a fresh mapping instead breaks 75 pass-through tests because the marker-free body must propagate through the caller's request dict so downstream guardrail scans and snapshots never observe the server streaming marker.
|
@greptileai please review the current head ab01cf0 — the parameter-mutation finding is addressed as a documented mutable-ok (rebinding to a fresh mapping breaks 75 pass-through tests; the in-place strip is the feature's caller-visible contract), plus the earlier merge fallout fixes. |
|
bugbot run |
Inserting stream_scope before the existing timeout parameter shifted the positional slot of timeout, so positional callers constructed with their timeout bound to stream_scope (ValueError) and timeout silently None. Restores the base parameter order; keyword callers are unaffected.
|
@greptileai please review the current head 5640dc9 — one-line fix moving stream_scope after timeout in CustomGuardrail init to restore the base positional contract (positional timeout callers were binding to stream_scope); keyword callers unaffected; 1458 mapped tests passing. |
|
bugbot run |
stream_scope leaves the declared constructor parameters (restoring the base positional surface; keyword construction unchanged), unknown config values crossing the new stream-scope code paths get typed locals or cast-ok boundaries, and the passthrough payload literals are annotated. All three lint gates pass against current main; the guardrail suites are unchanged (236+5019 passing; one known anyio-driver failure pre-existing on base).
|
@greptileai please review the current head ee7b659 — typing pass for the lint gates (stream_scope out of the declared constructor signature restoring the base positional surface, typed boundaries for config values); all three lint gates green vs current main; 6158 mapped tests passing. |
|
bugbot run |
|
@greptileai please review the current head ff4d50f — import-order fix on top of the typing pass; all three lint gates green locally (type_check, ruff strict, type discipline), ruff format clean, 236 guardrail tests passing. |
|
bugbot run |
|
The |
The streaming check now uses is_bedrock_streaming_endpoint; nothing in the repo imports the name from this module.
|
@greptileai please review the current head 2dbea2d — dropped the unused BEDROCK_STREAMING_ACTIONS re-export (CodeQL finding), all lint gates re-verified green, full live matrix on this head: stream-scope gating (nonstream 200 / stream fires), constructor positional+keyword+map scopes, tolerant register/list/PATCH composition (invalid stream_scope → 422), tag-mode guardrail renders read-only in list and detail with no crash, 8x streaming burst + nonstream burst clean. |
|
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 2dbea2d. Configure here.
Real Bedrock Guardrail verification (no mocks)Verified at head 2dbea2d with a real AWS Bedrock Guardrail resource (word-deny policy, published version 1, created via the Bedrock API), wired through the stock
Each skip leg delivering the denied word proves the scan genuinely did not run; each run leg returns Bedrock's own policy-violation block. All six cells match the per-mode stream_scope contract exactly. Temporary guardrail resource deleted after the run. |
TLDR
Problem this solves:
converse-streamandinvoke-with-response-streamas non-streamingis_streaming_request, and broke reads of stored rows holding a bad valueHow it solves it:
stream_scopeguardrail setting:streaming,non_streamingorboth(default), as one value or per modeboth) with a warning, while new create, update and patch calls reject them with a 422Intentional product change:
POST /guardrails,PUT /guardrails/{id}andPATCH /guardrails/{id}now return 422 for an invalidstream_scope, where main stored the value and ignored itUser Flow
Before: a proxy admin can't limit a guardrail to streamed requests, so every rail runs on every request
stream_scope: streamingon a pre-call rail andstream_scope: non_streamingon anotherstream_scopeAfter: each rail only runs on the request shape it is scoped to
stream_scope: streamingon a pre-call rail andstream_scope: non_streamingon anotherstreamingrail scans it, and the same call to/converseruns only thenon_streamingrail. Chat, Messages and Responses behave the same way based on"stream": trueis_streaming_requeststill reaches the pass-through target unchangedRelevant issues
Follows up on #43174, which is left open for now. This branch carries its commits plus the fixes above
Linear ticket
Resolves LIT-8991
Pre-Submission checklist
Please complete all items before asking a LiteLLM maintainer to review your PR
uv run pytest tests/unit/<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)Screenshots / Proof of Fix
Docs: litellm-docs#2191 documents
stream_scope(values, per-mode maps, validation) in the guardrails quick start.What this PR looks like (screenshots)
What this shows: before the reviewer fix, opening the Custom Code editor for a guardrail whose
modeis the tag-scoped dict{tags: {team-a: pre_call}}, default: post_callcrashed the whole editor (React cannot render the raw object).What this shows: after the fix, the same editor opens and shows the mode read-only as
post_call, pre_call (tag-based)— the stored tag scoping is preserved and never rewritten on save.What this shows: the guardrail detail view rendering the mode read-only (same
formatGuardrailModeconvention as the rest of the dashboard).What this shows: an editor save succeeding during a 24x concurrent burst on a 4-worker rig (chaos cell from the audit — the read-only path stays stable under load).
What this shows: animated before-to-after flow of the editor crash turning into the working read-only view.
Both proxies ran the same config with 4 workers, real Postgres and Redis, real OpenAI, Anthropic and AWS Bedrock calls, and a local guardrail sink that records which rail was called. The key carries
r_pre_stream(pre_call,stream_scope: streaming) andr_pre_nonstream(pre_call,stream_scope: non_streaming).r_post_unsetis a post_call rail with nostream_scope./pt-echois a configured pass-through to a local echo targetUI configuration (real dashboard screenshots)
What this shows: the Add Guardrail form's Request shape control configuring
stream_scopeper mode;pre_call applies tois set to Streaming only via the dropdown (Streaming and non-streaming / Streaming only / Non-streaming only).What this shows: the stored guardrail's detail page rendering the per-mode scope summary read-only:
pre_call: Streaming only, post_call: Streaming and non-streaming.Before (c168199, merge base with main)
Bedrock pass-through
curl localhost:17751/bedrock/model/live-bedrock/converse -H 'Authorization: Bearer <key>' -d '{"messages":[{"role":"user","content":[{"text":"Reply ok"}]}],"inferenceConfig":{"maxTokens":5}}'returns200 application/json, and the sink shows{'R_PRE_STREAM': 1, 'R_PRE_NONSTREAM': 1}/converse-streamreturns200 application/vnd.amazon.eventstream, and the sink shows{'R_PRE_STREAM': 1, 'R_PRE_NONSTREAM': 1}/invoke-with-response-streamwith an Anthropic body returns200 application/vnd.amazon.eventstream, and the sink shows{'R_PRE_STREAM': 1, 'R_PRE_NONSTREAM': 1}Chat, Messages and Responses
/v1/chat/completions,/v1/messagesand/v1/responseswithoutstreamall return 200 and every rail runs, includingR_POST_STREAM"stream": truereturn 200 chunked and every rail runs, includingR_PRE_NONSTREAMCaller field on pass-through
curl localhost:17751/pt-echo -H 'Authorization: Bearer <master key>' -d '{"hello":"x","is_streaming_request":"caller-value"}'returns 200{'hello': 'x', 'is_streaming_request': 'caller-value'}Bad stream_scope values
/guardrailswith"stream_scope": "sometimes"returns 200 and stores it/guardrails/{id}/infoon a stored row holding"sometimes"returns 200, and chat through that rail returns 200 with the rail run once/guardrails/listwith a YAML rail holdingstream_scope: sometimesreturns 200 and echoes"sometimes"After (f38aa05, rerun at e9495a8)
e9495a8 and the tip 4d0da16 have the same
litellm/tree, since 4d0da16 only changes a test. The rerun at e9495a8 gave the same rail split as below across 33 scenarios, with the same status, content type and upstream calls on base and headBedrock pass-through
curl localhost:17750/bedrock/model/live-bedrock/converse -H 'Authorization: Bearer <key>' -d '{"messages":[{"role":"user","content":[{"text":"Reply ok"}]}],"inferenceConfig":{"maxTokens":5}}'returns200 application/json, and the sink shows{'R_PRE_NONSTREAM': 1}/converse-streamreturns200 application/vnd.amazon.eventstream, and the sink shows{'R_PRE_STREAM': 1}/invoke-with-response-streamwith an Anthropic body returns200 application/vnd.amazon.eventstream, and the sink shows{'R_PRE_STREAM': 1}. Router models (/bedrock/model/<router model>/...) and direct/bedrock/...routes gave the same splitChat, Messages and Responses
/v1/chat/completions,/v1/messagesand/v1/responseswithoutstreamall return 200 and the sink showsR_PRE_NONSTREAMandR_POST_UNSET, with noR_PRE_STREAMorR_POST_STREAM"stream": truereturn 200 chunked and the sink showsR_PRE_STREAM,R_POST_STREAMandR_POST_UNSET, with noR_PRE_NONSTREAMCaller field on pass-through
curl localhost:17750/pt-echo -H 'Authorization: Bearer <master key>' -d '{"hello":"x","is_streaming_request":"caller-value"}'returns 200{'hello': 'x', 'is_streaming_request': 'caller-value'}, same as main. Sendingis_streaming_request: trueorlitellm_server_streaming_classification: trueto a guarded pass-through still runs onlyR_PRE_NONSTREAM, so callers can't fake a streamBad stream_scope values
/guardrailswith"stream_scope": "sometimes",1or a mapping with an unknown mode returns 422, and PATCH with"sometimes"returns 422. Valid, mixed-case and null values return 200/guardrails/{id}/infoon a stored row holding"sometimes"returns 200, and chat through that rail returns 200 with the rail run once, streaming and non-streaming/guardrails/listwith a YAML rail holdingstream_scope: sometimesreturns 200 withstream_scope: null, where the previous head of this branch returned 500Vertex Live websocket pass-through
default_onpre_call rail withstream_scope: streamingand another withstream_scope: non_streaming, plus real Vertex credentialsws://localhost:<port>/vertex_ai/liveand sends a setup frame forgemini-live-2.5-flash-native-audiosetupCompleteback from Vertex. Main ran both rails, while this branch ran only thestreamingrailIntegration audit (4d0da16 vs merge base 7be2983)
Checked-in tests, owned Postgres, Redis, scripted upstream and guardrail sinks, 2 proxy workers,
run.py extensions --seed 4106601 --order-seed 0 --workers 2:tests/integration/observability/test_guardrail_stream_scope.pytests/integration/observability/test_guardrail_stream_scope_matrix.pytests/integration/observability/test_guardrail_stream_scope_chaos.pynullfor a bad YAML value. Unset, explicitboth, caller field forwarding, unauthenticated and provider-error cells pass on both/vertex_ai/liveroute against a scripted Vertex peer and checks the upstream path, auth header, forwarded frame andsetupCompletereplyran /live-pr-risk and found no regressions/backward incompatible risks (re-verified at 2dbea2d: full live matrix incl. stream-scope gating, constructor shapes, tolerant register/PATCH composition, tag-mode read-only rendering, 8x bursts)
Type
🆕 New Feature
🐛 Bug Fix
✅ Test
Caveats (if any)
Medium
stream_scopeon create, update or patch now returns 422 instead of being storedbothLow
gpt-realtime-2.1andgpt-5-miniand the remote DeepWiki MCP server. Only the intended scope differences showed up: anon_streamingtranscription rail and astreamingMCP rail no longer run, and team and key rails each follow their own scope. A client that sends its ownresponse.createafter a clean transcript getsconversation_already_has_active_response, on main too/v1/responsescall runs post-call rails more than once, on both main and this branchstrenum, so hooks canjson.dumpstheir data, and it is still identity-checked so JSON can't forge it. It is now stripped by value too, so a hook that rebuilds the body can't leak it upstream, and it is kept out of pass-through guardrail scan text and theproxy_server_requestsnapshot._update_stream_param_based_on_request_bodyandBEDROCK_STREAMING_ACTIONSkeep their main names, andBEDROCK_STREAMING_ACTIONSis now a frozenset. YAML guardrails that only subclassCustomLoggerboot again, subclasses that skipsuper().__init__()default toboth, and guardrails deep-copy and pickle. The mapped unit suites were rerun there, but the audit and live runs were notproxy_server_requestsnapshot now drop the server marker by value instead of by key name, so caller content underlitellm_server_streaming_classificationis scanned and logged like on main. Per-hook scopes are stored as a plain tuple with no custom__getstate__/__setstate__, so subclasses that define their own state hooks or__slots__copy and pickle again, and a Router with such a guardrail in deployment callbacks builds. The mapped unit suites pass therelitellm-server-streamingis stripped. A custom guardrail class that read its ownstream_scopeinit argument no longer receives it, sincestream_scopeis now a reserved settingmoderead-only instead of crashing, and leavesmodeandstream_scopeout of its update payload for those guardrailsstream_scopeand the newlogging_only_scopeside by sideLink to Devin session: https://app.devin.ai/sessions/40ff0e0ca23e4d7cb5b602c39bfb221c
Open in Devin Desktop: https://app.devin.ai/desktop/session/40ff0e0ca23e4d7cb5b602c39bfb221c?variant=devin
Requested by: @yucheng-berri
Merge resolution (6d15060)
Current main was merged into this branch (13 conflicting files between per-mode
stream_scopeandlogging_only_scope); both features are kept side by side — stream-scope state/payload/captions plus the logging-only-scope choice and clear-stored-scope flow, with the two tolerant parsers composed and main'sGuardrailModeCard/GuardrailModeRowsrefactor kept with the stream-scope detail reattached. Mapped Python suites: 1226 passed; guardrails UI suite: 298 passed, no type errors. The osv-scan failure is a newly publishednext16.3.6 advisory failing on every open PR and is not introduced here.Note
Medium Risk
Changes when security guardrails execute across chat, Bedrock, pass-through, and pipelines; invalid scope on write now 422s instead of being stored silently.
Overview
Adds
stream_scopeso guardrails can run only on streaming, non-streaming, or both (default), as a single value or per hook (pre_call,post_call, etc.).CustomGuardrail.should_run_guardrailand policy pipeline steps now skip out-of-scope rails (skip/next).The proxy classifies server-side streaming via
litellm_server_streaming_classification(identity-checked marker), not caller fields likeis_streaming_request. That marker is added for Bedrock streaming actions, pass-through/WebSocket routes, and realtime paths, and is stripped from upstream bodies, guardrail scan payloads, and request snapshots so clients cannot spoof streaming scope.Bedrock streaming detection uses the final path action (
converse-stream,invoke-with-response-stream) instead of substring matches on model names. Guardrail registry/API paths tolerate invalid storedstream_scopeas unset (both) while create/update/patch reject bad values with 422; OpenAPI documents the new field.Reviewed by Cursor Bugbot for commit 5640dc9. Bugbot is set up for automated code reviews on this repo. Configure here.