HY-love-sleep opened a new issue, #7623:
URL: https://github.com/apache/shenyu/issues/7623

   ## Description
   
   ShenYu can already reject a prompt containing a forbidden word: 
`ai-sensitive-word` (#7153, registered
   in #7155) compiles a rule dictionary into an Aho–Corasick automaton and 
inspects the request body. That
   covers deterministic keyword compliance. It cannot cover what the AI gateway 
space has converged on: a
   *judgement* about the meaning of a prompt or of a model response.
   
   Commercial gateways ship exactly that:
   
   - Apache APISIX — `ai-aliyun-content-moderation` / 
`ai-aws-content-moderation`: `check_request` /
     `check_response`, `stream_check_mode: realtime | final_packet`, 
`risk_level_bar`, `deny_code` /
     `deny_message`, `fail_mode`.
   - Higress — `ai-security-guard`: `checkRequest` / `checkResponse`, JSONPath 
extraction per direction,
     `denyCode`, `ai_sec_request_deny` / `ai_sec_response_deny` metrics.
   
   All of them hard-code one moderation vendor (one plugin per provider). The 
gap this issue proposes to
   fill is a **judge-agnostic moderation plugin: the decision comes from an 
extension point, so a
   self-hosted judge model and a commercial moderation API are the same thing 
to the gateway.**
   
   That is practical now: Qwen3Guard-Gen / Qwen3Guard-Stream, Llama Guard and 
ShieldGemma can all be
   served behind an OpenAI-compatible `/v1/chat/completions` endpoint (vLLM, 
SGLang, Ollama), while a
   vendor API is a thin adapter away. One OpenAI-compatible judge 
implementation therefore covers both,
   with no vendor SDK inside the gateway.
   
   **Scope of this issue: design agreement first.** I deliberately do not 
include the admin registration
   rows or the console form here — I would like the behaviour and the extension 
point agreed before
   generating tens of rows per dialect for six tables.
   
   ## Design
   
   ### 1. Judge extension point
   
   `ContentModerationJudge` is the only contract: given text plus the rule 
handle, return a verdict. Two
   implementations ship with the plugin:
   
   - `openAiCompatibleJudge` — any OpenAI-compatible chat endpoint (self-hosted 
guard model, or a hosted
     service speaking the same shape);
   - `httpApiJudge` — a vendor moderation HTTP API driven by path/template 
fields, no vendor SDK.
   
   Model, prompt template, threshold, timeout and action live in the rule 
handle, so the *policy* is
   configuration rather than code (precedent: LiteLLM's `decision_model` 
guardrail, where the operator
   writes natural-language questions, each with its own threshold and action).
   
   ### 2. Where the checks run, and plugin order
   
   Two sides, independently configurable: `checkRequest` (before the prompt 
leaves) and `checkResponse`
   (response content before the client sees it).
   
   The response side requires an order **lower than** `aiProxy` (199), because 
a response decorator only
   wraps responses produced by plugins ordered after it:
   
   ```
   aiPrompt 170 · aiTokenLimiter 171 · … · [196] · sensitiveWord 197 · 
agentGateway 198 · aiProxy 199 · divide 200 · modifyResponse 220
   ```
   
   `196` is free. There is one real ordering question: the cheap dictionary 
check (`sensitiveWord`, 197)
   and this expensive judge cannot both sit immediately before `aiProxy`, and 
equal order values make the
   chain order nondeterministic.
   
   ### 3. Verdict model — four states, per-rule action
   
   Not two booleans. Following LiteLLM's `guardrail_status`:
   
   | state | meaning | behaviour |
   | --- | --- | --- |
   | `PASS` | judged safe | continue |
   | `FLAGGED` | risky, rule action = `log` | continue, record |
   | `BLOCKED` | risky, rule action = `block` | reject |
   | `UNAVAILABLE` | judge failed, timed out or returned a malformed result | 
per rule: `failClosed` |
   
   This also borrows Bedrock Guardrails' per-rule `action: BLOCK | NONE`: 
"detect without blocking" is a
   first-class setting, which is what makes an observe-then-enforce rollout 
possible. Keeping `UNAVAILABLE`
   distinct matters — otherwise "the judge was down" and "the content was 
clean" are indistinguishable in
   the metrics.
   
   ### 4. Streaming semantics
   
   Three modes. The plugin must never claim more than it can do:
   
   | `streamCheckMode` | behaviour | prevents leakage? | cost |
   | --- | --- | --- | --- |
   | `buffered` (proposed default) | hold the response back until the assembled 
content up to `holdBackChars` has a verdict; flush at end of stream | yes, to 
the judge's coverage | time-to-first-token = judge latency |
   | `realtime` | judge each batch; a batch's bytes are released only after 
that batch's own verdict | per batch; cross-batch content still covered by 
sending the window as context | one judge call per batch |
   | `final_packet` | judge the assembled response at the end; annotate only | 
no — audit only | none |
   
   The rule that a previous prototype of mine got wrong, and which I want 
stated explicitly in the review:
   **a batch must never be released without its own verdict.** Judging an 
accumulated window and then
   releasing the current batch unchecked is retrospective detection, not 
pre-release moderation. In
   `realtime` mode the judge receives the window (unreleased content plus the 
current batch) as context,
   but the gate is always the current batch.
   
   `realtime` / `final_packet` follow APISIX's `stream_check_mode` names and 
caveats; `buffered` is
   LiteLLM's shipped default for its built-in guardrails ("buffers every chunk 
until the assembled
   response passes moderation"), which is why I propose it as ours. The README 
and the rule form will
   state plainly that in `realtime` / `final_packet` bytes already sent cannot 
be retracted.
   
   One caution this design must not repeat: #6795 reports that `collectList` on 
streaming responses in the
   AI transformer / MCP decorator paths breaks backpressure and buffers without 
bound. `buffered` mode must
   therefore hold a bounded window (`holdBackChars`, with a policy when a 
single event exceeds it) rather
   than aggregate a whole response, and `final_packet` should reuse the 
existing incremental SSE parsing
   rather than introduce another full-body join.
   
   ### 5. Content extraction is configuration, not code
   
   The prototype hard-codes `choices[0].delta.content`. The plugin should 
default to a protocol profile and
   allow JSONPath overrides per direction:
   
   | protocol | request | non-streaming response | streaming delta |
   | --- | --- | --- | --- |
   | `openai-chat` | `messages[-1].content` | `choices[0].message.content` | 
`choices[0].delta.content` |
   | `openai-responses` | … | … | … |
   | `anthropic-messages` | … | … | … |
   | `mcp` | `params.arguments` | `result.content[*].text` | n/a |
   
   Higress uses JSONPath for the same reason (`requestContentJsonPath` / 
`responseContentJsonPath` /
   `responseStreamContentJsonPath`), and Envoy's guardrail adapter has an 
MCP-specific parser so the
   provider only ever sees inspectable text.
   
   Independently: **matched content is never echoed to the caller** (the same 
rule as `ai-sensitive-word`) —
   only the verdict.
   
   ### 6. Failure policy — the two sides are not symmetric
   
   | situation | proposed default | why |
   | --- | --- | --- |
   | judge unreachable / timeout / malformed, request side | pass through, 
warn, count (`failClosed=false`) | matches `ai-sensitive-word`: a broken judge 
must not take the traffic down. Set `true` per rule for strict compliance |
   | same, response side, before any byte is released | same flag | consistent, 
and observable via `UNAVAILABLE` |
   | same, response side, after bytes are released | log only, no option | 
nothing can be retracted |
   | request that is not a recognized AI request / nothing extractable | 
`unmatched: skip | warn | error` | APISIX's `fail_mode`; a silent pass-through 
must be a choice, not an accident |
   
   ### 7. Rejection response must be protocol-conformant
   
   - Non-streaming: built through `ResponsiveException` + `WebFluxResultUtils`, 
never hand-assembled JSON.
   - Streaming: a provider-conformant refusal event and a clean end of stream — 
never a bare JSON object
     injected into an SSE body, which no OpenAI client can parse.
   - Never echo the matched text; a generic reason only.
   
   ### 8. Observability
   
   Counters `ai_sec_request_deny`, `ai_sec_response_deny`, 
`ai_sec_judge_failure` plus a judge-latency
   timer; exchange attributes (`ai_sec_risklabel`, `ai_sec_deny_phase`) and one 
mirrored risk-level
   attribute so the logging and metrics plugins can consume the verdict instead 
of this plugin
   re-implementing them (APISIX publishes `llm_content_risk_level` for the same 
reason).
   
   ### 9. Non-blocking guarantee
   
   No Hystrix: upstream externalized it in #7499, and the enum only remains for 
compatibility. An HTTP
   judge needs no thread hand-off at all — `WebClient` with `.timeout()`, 
`onErrorResume` and cancellation
   propagation. If a blocking vendor SDK is ever wrapped it must be
   `subscribeOn(Schedulers.boundedElastic())` behind a bounded bulkhead, not a 
per-request thread pool.
   Circuit breaking reuses the existing Resilience4j dependency 
(`shenyu-plugin-resilience4j` already
   depends on `resilience4j-reactor`) with state shared per rule key, since 
per-request breaker state is
   useless.
   
   ### 10. Dependencies and boundaries — ai-proxy is under active refactor
   
   This plugin consumes **wire format and plugin order only**:
   
   - It must not import `org.apache.shenyu.plugin.ai.proxy.*`. #7351 is 
replacing those internal models
     (its own background: "tightly coupled to OpenAI Chat Completions and 
Spring AI types"), and #7397
     defines the new internal models and extension contracts.
   - It must override **both** `writeWith` and `writeAndFlushWith`: 
`AiProxyPlugin.writeResponse()` writes
     through `writeAndFlushWith`, and this area is still moving (`fix(ai): 
transform flushed response
     bodies` #7123, `fix(ai-proxy): propagate stream cancellation` #7338, 
`avoid replaying partial
     streams` #7205).
   - It must not assume the response keeps being written through 
`exchange.getResponse()` (#7543 already
     migrated ai-proxy to engine execution once). A fixture-based test with 
fixed SSE samples and no live
     upstream will fail if the response stops flowing through the decorator.
   - No general AI protocol layer here — that is #7382. If the Protocol 
contract later lands in
     `shenyu-plugin-ai-common` with a usable extraction hook, this plugin can 
consume it as a follow-up.
   - The order coupling (response side must precede `aiProxy`) is documented, 
not encoded.
   - The plugin also works on a plain HTTP route (request side only), like 
`ai-sensitive-word`, so it does
     not require ai-proxy to be configured.
   
   ### 11. Non-goals
   
   - Admin `plugin` / `plugin_handle` / `resource` / `permission` / 
`shenyu_dict` /
     `namespace_plugin_rel` rows and the console form — a follow-up issue, per 
the scope note above
     (#7154 is the precedent: the registration of `ai-sensitive-word` was 
tracked, and closed, separately
     from the plugin itself).
   - The watermark plugin from the same prototype branch (unrelated concern; a 
separate proposal if wanted).
   - Changes to `shenyu-plugin-base` — the prototype's shared response 
decorator belongs inside this module.
   - Hosting or managing judge models; word-list dictionaries (that is 
`ai-sensitive-word`); a sidecar /
     `ext_proc` deployment of the judge as Envoy AI Gateway does (noted as a 
possible future architecture,
     not part of this).
   
   ## Task List
   
   1. Plugin module `shenyu-plugin-ai/shenyu-plugin-ai-content-moderator`, its 
starter, a `PluginEnum`
      entry and the rule-handle DTO in `shenyu-common` (plus its test).
   2. `ContentModerationJudge` SPI + `openAiCompatibleJudge` + `httpApiJudge`.
   3. Request side: size bound checked against the declared `Content-Length` 
before the body is read, read
      through `ServerWebExchangeUtils.rewriteRequestBody`.
   4. Response side: non-streaming first, then streaming on both `writeWith` 
and `writeAndFlushWith`, with
      the three modes of §4.
   5. Verdict model, failure policy, metrics, README (streaming caveats, order 
requirement, judge
      throughput).
   6. Tests: `StepVerifier` for both write paths; per-batch gating; 
end-of-stream flush; short-stream tail
      (a stream shorter than the window must still be judged); no judge call 
for content-less frames;
      judge unavailable → configured policy; a test asserting the scan does not 
run on the event loop.
   7. Follow-up issue: admin registration rows and the console form.
   
   ## Open questions for maintainers (with my proposals)
   
   I have written a proposal after each question so a maintainer can simply 
agree or redirect; nothing here
   is decided on my own.
   
   1. **Plugin name and placement.** *Proposal:* 
`shenyu-plugin-ai/shenyu-plugin-ai-content-moderator`,
      `PluginEnum.AI_CONTENT_MODERATOR(196, 0, "aiContentModerator")`. 
Rationale: `ai-sensitive-word`
      already placed content compliance in the `ai` family, and a third family 
for the same concern would
      split it; the plugin name stays lowerCamel like `sensitiveWord` / 
`aiTokenLimiter`.
   
   2. **`PluginEnum` code, and therefore the order against the dictionary 
check.** *Proposal:* `196` with no
      renumbering, accepting that this judge then runs *before* `sensitiveWord` 
(197). Rationale: `196` is
      the only free value that still precedes `aiProxy`, which the response 
side requires, and taking it
      changes nothing else. The cost is bounded and easy to reason about: a 
request the dictionary would
      have rejected still reaches the judge. If maintainers prefer the cheap 
dictionary check to reject
      first — which saves a judge call on dictionary-dirty traffic — then this 
plugin should take 198 and
      `agentGateway` moves, since equal order values make the chain order 
nondeterministic. I recommend
      `196` for v1 and can renumber if that is the preferred direction.
   
   3. **Default policy when the judge is unreachable.** *Proposal:* fail open 
by default
      (`failClosed=false`), per-rule override to fail closed. Rationale: it 
matches the merged sibling
      `ai-sensitive-word`, whose README states that a broken dictionary must 
not take the traffic down;
      the moderation service is an external dependency shared by every rule 
that uses it, and the plugin is
      opt-in per rule, so a strict deployment can flip one flag. The request 
side and the response side are
      documented separately, because on the response side nothing can be 
retracted after the first byte is
      released.
   
   4. **Denial status and body for a recognized AI protocol.** *Proposal:* 
`denyStatusCode` default `403`
      with a provider-shaped body when a protocol is recognized. Rationale: 
`403` follows the sibling
      `WafPlugin` and keeps the rejection visible to infrastructure and 
monitoring, and the body is still
      built in the client's protocol so an AI SDK surfaces a parseable refusal 
rather than an opaque error.
      I am aware APISIX and Higress default to `200` specifically so the AI SDK 
treats the refusal as a
      normal completion; if maintainers prefer that behaviour for AI routes, I 
will make `200` the default
      there and keep `403` for plain HTTP. On the response side this only 
applies while nothing has been
      committed (i.e. before the first flush); afterwards the refusal can only 
be carried inside the stream.
   
   5. **Circuit breaking in v1, and the dependency.** *Proposal:* v1 ships 
`timeout` + `failClosed` and no
      new dependency. If maintainers want circuit breaking in v1, implement it 
on the Resilience4j
      dependency the fault-tolerance plugins already bring 
(`resilience4j-circuitbreaker` / `-reactor` via
      `shenyu-plugin-resilience4j`) with breaker state shared per rule key. 
Rationale: a breaker is only
      meaningful with shared state, and that is already the pattern in 
`Resilience4JRegistryFactory`; adding
      a second resilience library, or a Hystrix-style per-request thread pool, 
would be scope this issue does
      not need.
   
   6. **Where the protocol extraction profiles live.** *Proposal:* in this 
module for v1, with a follow-up
      to consume a shared hook if one appears. Rationale: #7382 is still 
completing the Protocol layer's
      response and streaming semantics, and those contracts are for the proxy's 
own encode/decode path
      rather than an extraction API for other plugins; depending on them now 
would tie this plugin's PR to
      an in-flight refactor. If the extraction contract later lands in 
`shenyu-plugin-ai-common`, moving
      these profiles there is a small, contained change.
   
   ## References
   
   - Merged sibling: #7153 (`ai-sensitive-word`), #7155 (its admin 
registration).
   - Related existing defect to avoid repeating: #6795 (unbounded buffering on 
streaming responses in the
     AI transformer / MCP decorator paths).
   - ai-proxy layering: #7351, #7381, #7382, #7383, #7384.
   - APISIX: `ai-aliyun-content-moderation` / `ai-aws-content-moderation` docs, 
and apache/apisix#13735
     for the response/streaming work.
   - Higress: `ai-security-guard`.
   - LiteLLM guardrails: modes `pre_call | during_call | post_call | 
logging_only`,
     `streaming_buffer_until_moderated`, `guardrail_status`, `decision_model`.
   - Amazon Bedrock Guardrails: content filters (categories including 
`PROMPT_ATTACK`,
     `inputStrength` / `outputStrength`), and the standalone `ApplyGuardrail` 
API.
   - Envoy AI Gateway / guardrail adapter over `ext_proc`.
   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to