fix(guardrails): enforce admission around prompt guardrails and refuse unguarded passthrough - #972
fix(guardrails): enforce admission around prompt guardrails and refuse unguarded passthrough#972SantiagoDePolonia wants to merge 5 commits into
Conversation
…e unguarded passthrough
|
Preview deployment for your docs. Learn more about Mintlify Previews.
💡 Tip: Enable Automations to automatically generate PRs for you. |
|
Warning Review limit reachedNext included review available in 45 minutes. View limit detailsLimit details: You’ve used all 4 included reviews currently available. You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository. Review configuration: ⚙️ Run configurationConfiguration used: Organization UI Review profile: ASSERTIVE Plan: Advanced Run ID: 📒 Files selected for processing (21)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
|
…te semantics Only a provider shipping a passthrough semantics table names a GenAI operation, so the guardrail refusal missed text inference on every other provider (hetzner is passthrough-enabled by default and has no table) and on the legacy completion endpoints. The endpoint path now decides when the operation is unknown.
|
@coderabbitai review |
|
@greptileai review |
|
|
@coderabbitai review |
Rate Limit Exceeded
|
Two guardrail enforcement gaps found in pre-release testing.
Prompt-phase guardrail decisions bypassed rate limits and budgets
A prompt chain that blocks or answers a request returned before
handleWithCache, and admission only ran inside dispatch — so a blocked or guardrail-answered request consumed no rate-limit token and passed no budget check, while anllm_judgestep had already made a real, billed model call to decide it. Verified live: under a 3 req/min limit that 429s benign traffic, guardrail-answered requests returned 200 indefinitely, over budget too.Ordering chosen: when a request runs a prompt-phase chain, admission (rate limits, then budget) now runs as soon as the route is resolved and before the chain. Money is never spent on a judge call for a request the gateway will not serve, and a decision the chain makes is still counted. Admission is granted once per request and reused by dispatch, so nothing is counted twice.
The gate is conditional on the request actually running a prompt chain. Without one nothing is spent or decided before dispatch, so admission stays where it was and response-cache hits remain free, as
docs/features/rate-limits.mdxanddocs/features/budgets.mdxdocument. With a prompt chain the cache is consulted only after the chain has already been paid for, so counting those hits is the consistent choice; both docs now say so./p/{provider}/...passthrough ran no guardrails, and nothing said soThe same key that got
400 keyword_blockon/v1/chat/completionsgot200on/p/openai/v1/chat/completionsand/p/deepseek/v1/chat/completions. Passthrough routes are on by default.Running the chains there faithfully is not possible: passthrough forwards arbitrary provider-native bodies (OpenAI, Anthropic, Gemini, Cohere dialects, and non-chat endpoints) and relays the provider's answer byte-for-byte, which is the point of the route. So the gap is made explicit and fails safe instead: a passthrough request is refused with
403 passthrough_guardrails_unsupportedwhenever a guardrail workflow applies to the caller. Operators who accept the gap setserver.allow_unguarded_passthrough: true(ALLOW_UNGUARDED_PASSTHROUGH=true), or scope the workflow so it does not match those callers. Passthrough is unchanged for every caller no guardrail workflow matches, and for every non-inference passthrough route (model lists, files), which run no guardrail on/v1either.The workflow of a passthrough request is matched on the model read from its provider-native body, and that read is best effort. So a text-inference passthrough call whose model GoModel cannot read (chunked, oversized, or an unparsed dialect) is refused whenever any active workflow runs a guardrail chain — otherwise a caller could escape a model-scoped policy just by making the body unreadable.
Which routes count as text inference is decided by the route's GenAI operation, and by the endpoint path when the provider ships no passthrough semantics table (
hetzneris enabled by default and has none) or the table does not name the endpoint. Otherwise the refusal would have been escapable by sending the same body to such a provider, or to a legacy/completionsroute. Model lists, token counts, embeddings, files and batches keep passing through.User-visible impact
workflows.Service.HasGuardrailChainsreports whether any active workflow runs guardrails; passthrough uses it for the unreadable-model case.server.allow_unguarded_passthrough/ALLOW_UNGUARDED_PASSTHROUGH, defaultfalse. Deployments that use both guardrails and passthrough for the same callers get403until they opt in or rescope the workflow.docs/advanced/guardrails.mdx,docs/features/passthrough-api.mdx,docs/features/rate-limits.mdx,docs/features/budgets.mdx,.env.template,config/config.example.yaml.Testing
internal/server/guardrail_admission_test.go: block/respond decisions consume the limit, an over-budget request never reaches the prompt hook, an allowed request is counted once,admitOncegrants a single admission, the passthrough refusal / opt-out / no-chains / unreadable-model / non-inference cases, andinternal/workflows:HasGuardrailChainswith and without a guarded workflow. All three admission subtests fail onmain.go build ./...,go test -race ./internal/server ./internal/gateway ./config ./internal/app ./internal/guardrails,go test ./...,make lint, hot-path perf guard.400 400 429 429 429(was400 ×5); benign traffic under a 3/min limit200 200 200 429withrequests_used: 3;/p/deepseek/v1/chat/completionsand/p/deepseek/chat/completions403 passthrough_guardrails_unsupported,200again withALLOW_UNGUARDED_PASSTHROUGH=true; the same chat body sent withTransfer-Encoding: chunkedalso403, while/p/deepseek/v1/modelsstays200.