fix(guardrails): input guardrails on /v1/completions, /v1/rerank, /v1/images, /v1/audio/speech (#545) - #551
Conversation
… /v1/images, /v1/audio/speech (#545) These four inbound surfaces carry user-controlled text but ran NO guardrails, so a content/DLP block configured on /v1/chat/completions was bypassable by switching surface — the same class as #719. Add the input guardrail check (mirroring /v1/embeddings): resolve the per-request chain, synthesize a ChatFormat from the surface's text fields, run check_input, Block -> 422 content_filter. The check runs BEFORE quota::enforce so a content-policy refusal doesn't burn a rate-limit slot (matching /v1/chat/completions, and avoiding the #542 ordering issue on these new surfaces). - completions: `prompt` (string | array of strings; token-id arrays skipped) - images/generations: `prompt` - rerank: `query` + `documents` (strings or `{text}` objects) - audio/speech: `input` (text synthesized to audio) Matched-pattern detail stays in ops logs only (#153). Output is not scannable text on images (image)/rerank (scores)/speech (audio) — no output hook. /v1/messages/count_tokens is intentionally EXEMPT (documented): it's a pre-flight sizing call that never reaches a model and returns only an integer token count; the same `messages` payload is scanned on the actual /v1/messages request. Follow-up (not in this PR): /v1/completions also returns generated `choices[].text`, so it warrants an OUTPUT guardrail too (the analog of the /v1/responses output work) — tracked separately. Tests: per-surface wiremock tests — blocked input -> 422 content_filter with the upstream never contacted (expect(0)); benign input still forwards (expect(1)) -> 200. rerank covers both the query and documents channels. fmt + clippy clean; 401 aisix-proxy lib tests pass.
ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Free Run ID: 📒 Files selected for processing (5)
📝 WalkthroughWalkthroughThis PR adds input guardrail enforcement ( ChangesGuardrail enforcement across endpoints
🎯 3 (Moderate) | ⏱️ ~20 minutes Note 🎁 Summarized by CodeRabbit FreeYour organization has reached its limit of developer seats under the Pro Plan. For new users, CodeRabbit will generate a high-level summary and a walkthrough for each pull request. For a comprehensive line-by-line review, please add seats to your subscription by visiting https://app.coderabbit.ai/login.If you believe this is a mistake and have available seats, please assign one to the pull request author through the subscription management page using the link above. Comment |
… count_tokens doc (#545) Independent audit of #551 (MERGE): - LOW-1: rerank block tests now assert the matched literal is absent from the wire message (#153 parity with the other surfaces). - LOW-2: add a benign-with-guardrail rerank test (guardrail present, clean query/documents -> expect(1) + 200). - MEDIUM-1: scope the count_tokens guardrail-exemption doc to *content moderation*; note the DLP/egress nuance (messages forwarded to the provider count endpoint) is tracked in #555, out of scope for #545. Deferred completions output guardrail tracked in #554. fmt + clippy clean; 20 input_guardrail tests pass.
What
Closes the input half of #545: four inbound surfaces carry user-controlled text but ran no guardrails, so a content/DLP block configured on
/v1/chat/completionswas bypassable by switching surface — the same content-safety bypass class as #719./v1/completionsprompt(string / array of strings; token-id arrays skipped)/v1/images/generationsprompt/v1/rerankquery+documents(strings or{text})/v1/audio/speechinput(text → TTS)/v1/audio/speechwas not in the original #545 list — the independent audit surfaced it as a 4th surface in the same class.How
Per surface, mirroring the
/v1/embeddingspattern: resolve the per-request chain (RequestContext{model_id, api_key_id, team_id}), synthesize aChatFormatfrom the surface's text fields (scan-only, never sent upstream), runcheck_input;Block→ProxyError::ContentFiltered(422content_filter). Matched-pattern detail stays intracingonly (#153) — the wire message is generic.The check runs before
quota::enforce, so a content-policy refusal doesn't burn a rate-limit slot (matching/v1/chat/completions, and avoiding the #542 ordering issue on these new surfaces from the start).Exemption (documented)
/v1/messages/count_tokensis intentionally exempt: it's a pre-flight sizing call that never reaches a model and returns only an integer token count — there's nothing to moderate on either hook, and the samemessagespayload is scanned on the actual/v1/messagesrequest. Documented incount_tokens.rs.Follow-up (not in this PR)
/v1/completionsalso returns generatedchoices[].text, so it warrants an output guardrail too (the analog of the/v1/responsesoutput work in #544). Out of scope here to keep the diff focused on the demonstrated input bypass; will file/track separately.Reference
In-repo reference pattern:
/v1/embeddings(embeddings.rs) input check. Upstream field shapes: OpenAI completions, images, audio/speech; rerank follows the Cohere/Jinaquery+documentsshape.Test plan
Per-surface wiremock tests: blocked input →
422+error.type == content_filterwith the upstream never contacted (expect(0)); benign input still forwards (expect(1)) →200./v1/rerankcovers both thequeryanddocumentschannels. The blocked literal must not leak into the wire message.cargo fmt+clippyclean; 401aisix-proxylib tests pass.Independent audit (CLAUDE.md §8) — findings + resolution
A fresh agent reviewed this PR cold and returned MERGE. It verified the core contract: each surface scans the right text, the check runs before
quota::enforce(matching chat — no burned slot, no #542 issue), the forwarded body is unchanged, the no-guardrail path is a pure no-op, #153 holds, and the tests prove block (422 +expect(0)) and benign (expect(1)+ 200).messagesto the provider before the real/v1/messagescall — a DLP-class gap, tracked in DLP/egress gap: /v1/messages/count_tokens forwards messages to the provider without a guardrail #555. Doc scoped to content moderation. Not introduced by this PR; not a blocker./v1/completionsoutput guardrail — filed as /v1/completions: add output guardrails on generated choices[].text #554 (completions returns generated text, so it warrants an output hook like feat(guardrails): output guardrails on /v1/responses, streaming + non-streaming (#719) #544). Reasonable scope cut; the demonstrated bypass here is input-side.Merge gate: no HIGH; the MEDIUM is justified + tracked; LOWs addressed.
Refs #545, #719, #554, #555.
Summary by CodeRabbit
Release Notes
New Features