Skip to content

fix(guardrails): run input guardrails on /v1/responses and /v1/embeddings (#719) - #541

Merged
moonming merged 3 commits into
mainfrom
fix/issue-719-responses-guardrails
Jun 8, 2026
Merged

fix(guardrails): run input guardrails on /v1/responses and /v1/embeddings (#719)#541
moonming merged 3 commits into
mainfrom
fix/issue-719-responses-guardrails

Conversation

@moonming

@moonming moonming commented Jun 8, 2026

Copy link
Copy Markdown
Collaborator

What

Closes the input half of #719: a configured input guardrail that blocks on /v1/chat/completions was silently skipped on /v1/responses and /v1/embeddings. The same input that returns 422 content_filter on the chat surface returned 200 on these surfaces — /v1/responses echoed the content back, /v1/embeddings embedded it. A customer-configured content/DLP block was therefore bypassable just by switching surface.

This PR makes both non-chat input-bearing surfaces run the resolved input guardrail chain before dispatch, exactly as /v1/chat/completions and /v1/messages already do.

Why this happened

Guardrail invocation was inlined per-surface. chat.rs and messages.rs each resolve the chain and call check_input; responses.rs had zero guardrail calls and embeddings.rs explicitly documented "embeddings bypass guardrails today". So the enforcement gap was per-surface, not in the chain itself.

Changes

crates/aisix-proxy/src/responses.rs

  • After auth + rate-limit, before dispatch: resolve the per-request chain (RequestContext { model_id, api_key_id, team_id }), and if non-empty run check_input. BlockProxyError::ContentFiltered (422 content_filter).
  • New responses_input_to_chat() synthesizes a role-preserving ChatFormat from the Responses-API body: input as a bare string, or an array of message items whose content is a string or typed parts (input_text / output_text / text); optional top-level instructions → system message. Roles are preserved so the guardrail's user-vs-all message selection behaves identically to chat. The synthesized request is for scanning only — the original body is still forwarded verbatim.

crates/aisix-proxy/src/embeddings.rs

  • Same input check before the upstream call. embeddings_input_to_chat() turns the input (string / array of strings) into user messages. Embeddings responses are vectors → no output hook.
  • Corrected the now-stale telemetry comment that claimed embeddings bypass guardrails.

Shared semantics (both surfaces), matching /v1/messages:

Reference / spec

  • Responses-API input shape (string | array of input items; message content is a string or typed parts): https://platform.openai.com/docs/api-reference/responses/create. Embeddings input (string | array of strings): https://platform.openai.com/docs/api-reference/embeddings/create.
  • In-repo reference pattern this mirrors: messages.rs input check (resolve chain → is_empty() guard → translate body → check_inputContentFiltered on Block).
  • Mainstream AI gateways apply their moderation/guardrail hooks uniformly across every input-bearing surface, not only the chat endpoint; this change brings the non-chat surfaces in line with that norm.

#719 scope status

  • /v1/responses runs input guardrails — this PR
  • /v1/embeddings runs input guardrails (carries scannable user content) — this PR
  • Anthropic /v1/messages runs input + output guardrails — already present (messages.rs input :398, output non-streaming :1353, streaming :1529); verified, no change
  • Output guardrails on non-chat surfaces that return a body (/v1/responses non-streaming + streaming) — deliberately deferred to a follow-up PR

Why output is a separate PR (not in this one)

Output coverage on /v1/responses must be all-or-nothing: adding it to the non-streaming path only would make stream:true a new asymmetric bypass of the exact class #719 is about. Streaming output requires parsing the Responses-API SSE event protocol (response.output_text.delta / response.completed) — which the gateway deliberately does not do today (SSE is passed through verbatim; streaming usage extraction was itself deferred under #404). That is a focused, protocol-specific change with its own regression surface, so it is isolated for independent review rather than bundled with this clean, high-value input fix. Follow-up will use buffer-then-scan (BufferFull) semantics, matching the chat surface's secure default.

Test plan

6 new wiremock unit tests (3 per surface):

cargo fmt + cargo clippy --all-targets clean; 385 aisix-proxy lib tests pass.

The real-Azure M1 acceptance test (held back in AISIX-Cloud#718) will be re-added to AISIX-Cloud once this merges, per the source-blind discipline.

Independent audit (CLAUDE.md §8) — findings + resolution

A fresh agent reviewed this PR cold. It verified the core fix is solid: synthesized text is actually scannable (message_scan_text), the input check runs before the streaming split (covers both modes), #153 redaction holds (no blocklist leak), 422 content_filter confirmed, the forwarded upstream body is byte-identical, the no-guardrail path is a pure no-op, and the 6 tests genuinely assert the contract (wiremock expect(0)/expect(1)).

A second, focused audit of the MEDIUM-1 fix returned MERGE: the function_call_output channel is fully closed (string + array-of-parts forms; no over-scan of normal items; #153 intact), and it confirmed the generic output read also covers custom_tool_call_output / *_call_output. It found one further LOW residual — mcp_approval_response.reason (a niche MCP-approval justification that also reaches the model) was unscanned. Fixed (commit appended): responses_item_text now also reads reason, with a test. Net: every input-bearing channel on the input union is scanned.

Merge gate: both MEDIUMs fixed-or-justified; both LOW residuals fixed; LOW telemetry tracked in #543. No HIGH findings; both audits recommend merge.

Refs #719, #379, #542, #543.

…ings (#719)

A configured input guardrail that blocks on /v1/chat/completions was
silently skipped on /v1/responses and /v1/embeddings: the same input that
422s on the chat surface returned 200 (responses echoed it back;
embeddings embedded it). A customer-configured content/DLP block was
bypassable simply by calling a non-chat surface.

- responses.rs: synthesize a role-preserving ChatFormat from the
  Responses `input` (string | array of message items) + optional
  `instructions`, resolve the per-request guardrail chain, and run
  check_input before dispatch; Block short-circuits to 422 content_filter.
  Mirrors the /v1/messages pattern.
- embeddings.rs: synthesize a ChatFormat of user messages from `input`
  (string | array of strings) and run check_input before the upstream
  call; Block short-circuits to 422. Embeddings responses are vectors, so
  there is no output hook to run.
- Only Block is enforced; Rewrite/Bypass are not applied to the outgoing
  non-chat body (matching /v1/messages). Matched-pattern detail stays in
  ops logs only (#153) — the wire envelope stays generic so callers can't
  enumerate the blocklist via error probing.

/v1/messages already runs input + output guardrails (unchanged here).
Output guardrails on /v1/responses (non-streaming + streaming) are the
remaining #719 scope item and are tracked as a follow-up.

Tests: 6 wiremock unit tests across both surfaces — block on string and
array input returns 422 content_filter with the upstream never contacted
(expect(0)); benign input still forwards (expect(1)) and returns 200; the
blocked literal must not leak into the wire message. cargo fmt + clippy
clean; 385 aisix-proxy lib tests pass.
@coderabbitai

coderabbitai Bot commented Jun 8, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@moonming, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 6 seconds. Learn how PR review limits work.

Your organization has run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Free

Run ID: eca0edf4-97e9-4424-b1fe-c712186149d6

📥 Commits

Reviewing files that changed from the base of the PR and between 8aaab9b and bd2984d.

📒 Files selected for processing (1)
  • crates/aisix-proxy/src/responses.rs
📝 Walkthrough

Walkthrough

This PR extends input content filtering to embeddings and responses endpoints. Both handlers now convert their request inputs into chat format, execute configured guardrail chains, and block requests with 422 content-filter errors before reaching upstream when guardrails trigger. Tests verify blocking and pass-through behavior for both string and array input forms.

Changes

Input Guardrails for Embeddings and Responses

Layer / File(s) Summary
Embeddings input guardrails
crates/aisix-proxy/src/embeddings.rs
Import ChatFormat, add embeddings_input_to_chat helper to convert string or array inputs to chat messages, execute guardrail chain in handler with content-filter short-circuit on block, update documentation, and test guardrail blocking and pass-through for both input forms.
Responses input guardrails
crates/aisix-proxy/src/responses.rs
Import chat types, add conversion utilities extracting text from response instructions and typed content inputs, execute guardrail chain with content-filter short-circuit before upstream dispatch, and test guardrail blocking for string and array inputs with upstream isolation.

Sequence Diagrams

sequenceDiagram
  participant Client
  participant EmbeddingsHandler
  participant GuardrailChain
  participant UpstreamEmbedding
  Client->>EmbeddingsHandler: POST /v1/embeddings with input
  EmbeddingsHandler->>EmbeddingsHandler: Convert input to ChatFormat
  EmbeddingsHandler->>GuardrailChain: Resolve guardrail chain
  alt Guardrail Blocks
    GuardrailChain-->>EmbeddingsHandler: GuardrailVerdict::Block
    EmbeddingsHandler-->>Client: 422 content_filter error
  else Guardrail Passes
    GuardrailChain-->>EmbeddingsHandler: GuardrailVerdict::Pass
    EmbeddingsHandler->>UpstreamEmbedding: Forward embedding request
    UpstreamEmbedding-->>Client: Embedding response
  end
Loading
sequenceDiagram
  participant Client
  participant ResponsesHandler
  participant GuardrailChain
  participant UpstreamResponse
  Client->>ResponsesHandler: POST /v1/responses with instructions and input
  ResponsesHandler->>ResponsesHandler: Convert instructions and input to ChatFormat
  ResponsesHandler->>GuardrailChain: Resolve guardrail chain
  alt Guardrail Blocks
    GuardrailChain-->>ResponsesHandler: GuardrailVerdict::Block
    ResponsesHandler-->>Client: 422 content_filter error
  else Guardrail Passes
    GuardrailChain-->>ResponsesHandler: GuardrailVerdict::Pass
    ResponsesHandler->>UpstreamResponse: Forward response request
    UpstreamResponse-->>Client: Response completion
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes


Note

🎁 Summarized by CodeRabbit Free

Your 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 @coderabbitai help to get the list of available commands and usage tips.

moonming added 2 commits June 8, 2026 11:26
…sponses (#719)

Independent-audit MEDIUM-1 on #541: a Responses `input` array may contain
`function_call_output` items whose caller-supplied tool-result text lives
under `output`, not `content`. That text reaches the model, so leaving it
unscanned re-opened the #719 surface-switch bypass on the tool-result
channel (the /v1/chat/completions equivalent — a role:tool message — is
scanned by all-roles guardrails).

`responses_item_text` now reads both `content` and `output` (each a string
or an array of typed text parts). A `function_call_output` item carries no
`role`, so it maps to a user message and is scanned by every guardrail kind
(including text-moderation's user-only default).

Tests: function_call_output with a blocked literal in `output` -> 422
content_filter (upstream never contacted); top-level `instructions` with a
blocked literal -> 422.

fmt + clippy clean; all 10 input_guardrail tests pass.
…ses (#719)

Re-audit LOW-1 on #541: mcp_approval_response items carry caller-supplied
justification text under `reason`, which reaches the model. responses_item_text
now reads content/output/reason so no input-bearing channel on the Responses
`input` union is silently skipped. Reading a key absent on other item types is
a no-op. Test added; fmt + clippy clean, 11 input_guardrail tests pass.
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