Skip to content

feat(guardrails): add Aliyun text-moderation guardrail kind (#603) - #506

Merged
nic-6443 merged 3 commits into
mainfrom
feat/aliyun-guardrail-603
Jun 3, 2026
Merged

feat(guardrails): add Aliyun text-moderation guardrail kind (#603)#506
nic-6443 merged 3 commits into
mainfrom
feat/aliyun-guardrail-603

Conversation

@jarvis9443

@jarvis9443 jarvis9443 commented Jun 3, 2026

Copy link
Copy Markdown
Contributor

Problem

A customer uses Aliyun's AI 安全护栏 (AI Guardrails) rather than AWS Bedrock. The guardrail subsystem only shipped keyword, bedrock, and the two Azure Content Safety kinds. This adds an Aliyun provider so operators can moderate LLM input and output against Aliyun's content-safety service. Resolves api7/AISIX-Cloud#603 (data-plane half).

What changed

New guardrail kind aliyun_text_moderation that calls Aliyun's TextModerationPlus action on green-cip.<region>.aliyuncs.com:

  • aisix-coreAliyunTextModerationConfig + GuardrailKind::AliyunTextModeration arm; loader JSON-schema branch; regenerated schemas/resources/guardrail.schema.json.
  • aisix-guardrails — new aliyun.rs dispatcher behind the aliyun-text-moderation feature (default-on). Input hook → llm_query_moderation, output hook → llm_response_moderation. Builder wiring in build.rs.
  • docs — guardrails configuration page gains an Aliyun section.

Behavior

  • Block decision: returned RiskLevel (none < low < medium < high) is blocked when it reaches the configurable risk_level_threshold (default high).
  • Granularity / hooks / streaming: env/model/api_key/team scoping, input/output hooks, and streaming/non-streaming all reuse the existing attachment + chain infrastructure — no new plumbing.
  • Streaming: moderated incrementally via the windowed StreamOutputPolicy; each window reuses the response's provider request id as Aliyun's sessionId, so the chunks of one stream correlate. No proxy/trait change.
  • Fail-open: input honors the row-level fail_open; output defaults fail-closed (output_fail_open=false) so an Aliyun outage cannot release unscanned model output.

Signature

There is no official Aliyun Rust SDK. The RPC signature (v1, HMAC-SHA1) is hand-rolled over the shared reqwest client — the same approach already used for Azure Content Safety. v1 is chosen over v3 because its canonicalization is unambiguous for RPC-style products and is pinned by a known-vector unit test (openssl-computed reference). The access_key_secret is decrypted by cp-api before kine projection; the DP only holds plaintext in memory and never logs it.

Tests

  • Unit (aliyun.rs, wiremock): known-vector signature, percent-encoding, canonical string-to-sign, risk-threshold tuning (low/medium/high), fail-open buckets, app-level error codes, stream-policy mapping.
  • Standalone E2E (tests/e2e/guardrail-aliyun-e2e.test.ts): real aisix + etcd + mock upstream + mock green-cip endpoint — risky input → 422 content_filter with upstream never called; benign passes; risky model output → 422 after upstream; streaming risky output → SSE error frame with no [DONE]; asserts the output call carries a sessionId.

Follow-up

Control-plane (cp-api config handler + kine projection of the encrypted secret) and dashboard form are a separate PR against api7/AISIX-Cloud, opened after this merges and a new DP dev image is built.

Summary by CodeRabbit

  • New Features

    • Added Aliyun text moderation guardrail for input and output with configurable risk-level thresholds, streaming windowing, buffer modes, timeout and fail-open behavior; stable session correlation for streamed responses.
  • Documentation

    • Added configuration guide, examples, and operator guidance for tuning Aliyun text moderation.
  • Tests

    • Added unit, integration, wiremock, and end-to-end tests covering parsing, signing, RPC behavior, blocking, and streaming scenarios.

Review Change Stack


Live QA against production Aliyun

Verified the full code path (signer → HTTPS POST → response parse → verdict) against the real https://green-cip.cn-shanghai.aliyuncs.com endpoint with an active AccessKey, via the env-gated aliyun::tests::live_smoke_real_endpoint:

Hook (service) Prompt Aliyun RiskLevel Verdict
input (llm_query_moderation) benign ("今天北京的天气怎么样?") none Allow
input (llm_query_moderation) abuse + threat high Block
output (llm_response_moderation, with sessionId) abuse + threat high Block

The signature is accepted by Aliyun (no SignatureDoesNotMatch), both service codes resolve, and the returned RiskLevel maps to the expected verdict at the default high threshold. Credentials are supplied only via environment variables at run time and are not committed.

Add a new guardrail kind `aliyun_text_moderation` that calls Aliyun's
content-safety guardrail (`TextModerationPlus` on
`green-cip.<region>.aliyuncs.com`). The input hook uses
`llm_query_moderation`, the output hook `llm_response_moderation`; the DP
blocks when the returned `RiskLevel` (none<low<medium<high) reaches the
configurable `risk_level_threshold` (default `high`).

There is no official Aliyun Rust SDK, so the RPC signature (v1,
HMAC-SHA1) is hand-rolled over the shared reqwest client — same approach
the codebase already takes for Azure Content Safety. v1 is used over v3
because its canonicalization is unambiguous for RPC-style products and is
pinned by a known-vector unit test.

Streaming output is moderated incrementally via the windowed
StreamOutputPolicy; each window reuses the response's provider request id
as Aliyun's sessionId so the chunks of one stream correlate — no proxy or
trait change required.

Granularity (env/model/api_key/team), input/output hooks, and
streaming/non-streaming all reuse the existing guardrail attachment +
chain infrastructure.
@coderabbitai

coderabbitai Bot commented Jun 3, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

This PR implements Aliyun TextModerationPlus as a new guardrail provider. It adds configuration contracts in the core model, extends schema validation, implements the Aliyun v1-signed RPC dispatcher with fail-open/fail-closed policies for both input and output hooks, integrates the runtime builder, and validates the feature end-to-end with comprehensive tests.

Changes

Aliyun Text Moderation Guardrail

Layer / File(s) Summary
Configuration contract and workspace crypto dependencies
Cargo.toml, crates/aisix-core/src/models/guardrail.rs, crates/aisix-core/src/models/mod.rs
Workspace dependencies add sha1 = "0.10" and keep hmac = "0.12". AliyunTextModerationConfig defines credentials (region, access_key_id, access_key_secret), optional endpoint, risk_level_threshold, timeout_ms, output_fail_open, and streaming/window/buffer controls. GuardrailKind gains AliyunTextModeration. Includes deserialization unit tests for defaults and explicit fields.
JSON schema validation for guardrail configurations
crates/aisix-core/src/models/schema.rs, schemas/resources/guardrail.schema.json
Adds aliyun_text_moderation to the kind discriminator and a oneOf branch validating required Aliyun fields and optional endpoint, risk threshold, timeout, streaming modes, buffering, and fail-open options. Tests cover minimal/complete valid payloads and invalid/missing-field rejections.
Aliyun RPC dispatcher and moderation logic
crates/aisix-guardrails/Cargo.toml, crates/aisix-guardrails/src/aliyun.rs
Implements AliyunTextModerationGuardrail with RFC3986 percent-encoding, canonical key-sorted StringToSign, HMAC-SHA1+Base64 signing, form-encoded POST, timeout handling, HTTP/app-level failure categorization, RiskLevel parsing and thresholding, sessionId handling for streaming output, and mapping failures to Bypass/Block based on fail_open. Includes unit tests for signing and WireMock integration tests for behavior and failure handling.
Build-time integration and module exports
crates/aisix-guardrails/src/build.rs, crates/aisix-guardrails/src/lib.rs
Adds GuardrailKind::AliyunTextModeration handling in build_one() behind the aliyun-text-moderation feature; conditionally includes aliyun module and re-exports AliyunTextModerationGuardrail. Optional workspace deps and the new feature are declared in the crate Cargo.toml.
Documentation and end-to-end test coverage
docs/configuration/guardrails.md, tests/e2e/src/cases/guardrail-aliyun-e2e.test.ts
Documents kind: "aliyun_text_moderation" config and operator guidance; E2E suite mocks Aliyun TextModerationPlus, provisions an env-wide windowed guardrail, and tests risky input blocking (no upstream), risky output blocking (with sessionId), and streaming risky output (SSE error event, no [DONE], single sessionId across windows).

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title 'feat(guardrails): add Aliyun text-moderation guardrail kind (#603)' accurately summarizes the primary change: introducing a new Aliyun text moderation guardrail kind with supporting infrastructure.
Linked Issues check ✅ Passed The PR fully implements the requirements from issue #603: adds Aliyun TextModerationPlus guardrail support with proper RPC signing, streaming sessionId correlation, fail-open/fail-closed semantics, and comprehensive testing.
Out of Scope Changes check ✅ Passed All changes are scoped to implementing the Aliyun guardrail feature: core config models, dispatcher implementation, schema updates, documentation, and E2E tests. No unrelated or out-of-scope modifications detected.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

Warning

Review ran into problems

🔥 Problems

Git: Failed to clone repository. Please run the @coderabbitai full review command to re-trigger a full review. If the issue persists, set path_filters to include or exclude specific files.


Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (2)
tests/e2e/src/cases/guardrail-aliyun-e2e.test.ts (1)

329-362: 💤 Low value

Consider verifying sessionId stability across streaming windows.

The test validates the end-to-end error behavior but doesn't verify that Aliyun receives the same sessionId for all windows of the stream. Given window_size: 16 and ~30-char risky content, multiple moderation calls should occur. Checking that aliyun.requests.filter(r => r.service === "llm_response_moderation").every(r => r.sessionId === expectedId) would confirm the correlation feature works end-to-end.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/e2e/src/cases/guardrail-aliyun-e2e.test.ts` around lines 329 - 362, The
test "streaming risky output → SSE error event, no [DONE] (windowed)" currently
asserts the SSE error and parsed error type but doesn't verify that Aliyun
received a stable sessionId across streaming windows; update the test to capture
the expected sessionId (e.g., from the proxy request or generated value) and
then assert that all moderation requests to the mocked aliyun endpoint (use
aliyun.requests.filter(r => r.service === "llm_response_moderation")) have the
same sessionId by checking every(r => r.sessionId === expectedId), ensuring you
perform this check after the fetch completes and before finishing the test.
crates/aisix-guardrails/src/aliyun.rs (1)

159-273: 💤 Low value

Consider refactoring for readability.

The call() method is 115 lines and handles parameter construction, signing, HTTP request, and response parsing. While the logic is clear, extracting helpers for building signed params or parsing the response could improve maintainability.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/aisix-guardrails/src/aliyun.rs` around lines 159 - 273, The call()
method is doing several concerns (service parameter JSON, building the sorted
BTreeMap params, signing via sign(), URL-encoding with percent_encode(),
constructing the form body, sending the request via self.client.post(...), and
parsing AliyunResponse) which makes it long; factor it into small helpers such
as build_service_parameters(service, content, session_id) to produce the
ServiceParameters JSON, build_sorted_params_map(...) that returns the BTreeMap
used for signing, build_form_body(&params, &signature) that performs
percent-encoding and concatenation, and
send_and_handle_response(reqwest::Client, body, timeout) that performs the POST
and maps HTTP status to AliyunFailure and returns the parsed AliyunResponse;
keep call() as the orchestration layer that calls sign(), these helpers, and
then maps AliyunResponse.code into the existing AliyunFailure/Ok branches.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@Cargo.toml`:
- Around line 103-104: Update the pinned crate versions for sha1 and hmac in
Cargo.toml to the current releases: change the sha1 entry from "0.10" to
"0.11.0" and the hmac entry from "0.12" to "0.13.0"; after editing the sha1 and
hmac dependency lines, run cargo update (or cargo build/check) to refresh the
lockfile and verify there are no API compatibility issues in modules referencing
those crates (search for uses of the sha1 and hmac crates in your codebase to
fix any minor API changes if needed).

In `@crates/aisix-core/src/models/guardrail.rs`:
- Around line 277-291: The AliyunTextModerationConfig derives Debug while
containing sensitive access_key_secret; implement a custom std::fmt::Debug for
AliyunTextModerationConfig that formats all fields as before but replaces
access_key_secret with a redacted placeholder (e.g., "[REDACTED]") to avoid
leaking secrets; locate the struct AliyunTextModerationConfig and add an impl
std::fmt::Debug block that uses f.debug_struct(...).field(...) for region,
endpoint, access_key_id and other existing fields, and
.field("access_key_secret", &"[REDACTED]") before .finish().

---

Nitpick comments:
In `@crates/aisix-guardrails/src/aliyun.rs`:
- Around line 159-273: The call() method is doing several concerns (service
parameter JSON, building the sorted BTreeMap params, signing via sign(),
URL-encoding with percent_encode(), constructing the form body, sending the
request via self.client.post(...), and parsing AliyunResponse) which makes it
long; factor it into small helpers such as build_service_parameters(service,
content, session_id) to produce the ServiceParameters JSON,
build_sorted_params_map(...) that returns the BTreeMap used for signing,
build_form_body(&params, &signature) that performs percent-encoding and
concatenation, and send_and_handle_response(reqwest::Client, body, timeout) that
performs the POST and maps HTTP status to AliyunFailure and returns the parsed
AliyunResponse; keep call() as the orchestration layer that calls sign(), these
helpers, and then maps AliyunResponse.code into the existing AliyunFailure/Ok
branches.

In `@tests/e2e/src/cases/guardrail-aliyun-e2e.test.ts`:
- Around line 329-362: The test "streaming risky output → SSE error event, no
[DONE] (windowed)" currently asserts the SSE error and parsed error type but
doesn't verify that Aliyun received a stable sessionId across streaming windows;
update the test to capture the expected sessionId (e.g., from the proxy request
or generated value) and then assert that all moderation requests to the mocked
aliyun endpoint (use aliyun.requests.filter(r => r.service ===
"llm_response_moderation")) have the same sessionId by checking every(r =>
r.sessionId === expectedId), ensuring you perform this check after the fetch
completes and before finishing the test.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 40a92e08-5b87-4e6b-83b5-e4586e52297b

📥 Commits

Reviewing files that changed from the base of the PR and between 2d71f51 and f09ea9b.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (11)
  • Cargo.toml
  • crates/aisix-core/src/models/guardrail.rs
  • crates/aisix-core/src/models/mod.rs
  • crates/aisix-core/src/models/schema.rs
  • crates/aisix-guardrails/Cargo.toml
  • crates/aisix-guardrails/src/aliyun.rs
  • crates/aisix-guardrails/src/build.rs
  • crates/aisix-guardrails/src/lib.rs
  • docs/configuration/guardrails.md
  • schemas/resources/guardrail.schema.json
  • tests/e2e/src/cases/guardrail-aliyun-e2e.test.ts

Comment thread Cargo.toml
Comment thread crates/aisix-core/src/models/guardrail.rs
)

Strengthen the Aliyun streaming E2E to verify every windowed
llm_response_moderation call for one response carries the same sessionId
(the upstream request id), confirming Aliyun correlates the chunks of a
single stream. Per CodeRabbit review on #506.
@jarvis9443

Copy link
Copy Markdown
Contributor Author

Thanks @coderabbitai. Dispositions on the review:

  • sessionId stability test — added in 50adb74. The streaming case now asserts every windowed llm_response_moderation call for one response shares a single sessionId (strm-risky), so the chunk-correlation feature is verified end-to-end.
  • call() length — leaving as-is; it's a cohesive linear flow (build params → sign → POST → parse) and the suggested split would add indirection without changing behavior. Marked low-value upstream too.
  • Debug redaction and hmac/sha1 bump — declined with reasoning in the inline threads (consistency with the sibling secret configs; and keeping a single digest 0.10 major shared with sha2).

@coderabbitai

coderabbitai Bot commented Jun 3, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

@jarvis9443 Thanks for the clear rundown!

  • sessionId stability — the per-response session-identity assertion in commit 50adb74 covers the chunk-correlation story well; that concern is fully addressed.
  • call() length — understood. A single linear flow is easy to trace and the split would only add call-stack noise.
  • Debug redaction / hmac/sha1 — noted. Keeping consistency with the sibling secret-holding configs is a reasonable stance, and sharing the digest 0.10 major with sha2 avoids pulling in a second resolver version.

All good — the PR looks solid.

🐇

…603)

Ignored-by-default test that exercises the real signer + HTTP + parse
against green-cip TextModerationPlus when ALIYUN_AK_ID/ALIYUN_AK_SECRET
are set. No credentials are committed.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
crates/aisix-guardrails/src/aliyun.rs (1)

99-101: 💤 Low value

Consider configuring connection timeout on the client.

The reqwest client is built without explicit connection/pool timeouts. While tokio::time::timeout wraps the full request in call(), adding connect_timeout() provides defense-in-depth against DNS or connection establishment hangs.

Suggested improvement
         let client = reqwest::Client::builder()
+            .connect_timeout(Duration::from_secs(5))
+            .pool_idle_timeout(Duration::from_secs(90))
             .build()
             .expect("reqwest::Client::builder() failed; this should never happen");
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/aisix-guardrails/src/aliyun.rs` around lines 99 - 101, The reqwest
client in the client initialization path is created without an explicit
connection timeout, so update the Client builder in the ali yun client setup to
configure a connect_timeout alongside the existing build flow. Use the
reqwest::Client::builder() construction in the client creation logic and add a
reasonable connect timeout so connection establishment and DNS hangs are bounded
even before the tokio::time::timeout in call() applies.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@crates/aisix-guardrails/src/aliyun.rs`:
- Around line 99-101: The reqwest client in the client initialization path is
created without an explicit connection timeout, so update the Client builder in
the ali yun client setup to configure a connect_timeout alongside the existing
build flow. Use the reqwest::Client::builder() construction in the client
creation logic and add a reasonable connect timeout so connection establishment
and DNS hangs are bounded even before the tokio::time::timeout in call()
applies.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: a492a10f-013d-43c4-bb90-b0037a4974e8

📥 Commits

Reviewing files that changed from the base of the PR and between 50adb74 and d340c2e.

📒 Files selected for processing (1)
  • crates/aisix-guardrails/src/aliyun.rs

@nic-6443
nic-6443 merged commit 7bd9388 into main Jun 3, 2026
8 checks passed
@nic-6443
nic-6443 deleted the feat/aliyun-guardrail-603 branch June 3, 2026 11:58
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.

2 participants