Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,8 @@ base64 = "0.22"

# Crypto (M6 v3 CP register flow + ApiKey hash auth)
sha2 = "0.10"
sha1 = "0.10"
hmac = "0.12"
Comment thread
jarvis9443 marked this conversation as resolved.
rcgen = { version = "0.13", default-features = false, features = ["pem", "ring"] }
hex = "0.4"

Expand Down
147 changes: 147 additions & 0 deletions crates/aisix-core/src/models/guardrail.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@
//! * `azure_content_safety` — calls Azure AI Content Safety Prompt
//! Shield (`/contentsafety/text:shieldPrompt`). Detects jailbreak
//! and indirect injection attacks. P1 (PRD-09c §6 P1).
//! * `aliyun_text_moderation` — calls Aliyun's content-safety
//! guardrail (`TextModerationPlus` on `green-cip.<region>.aliyuncs.com`).
//! Risk-level moderation on input (`llm_query_moderation`) and output
//! (`llm_response_moderation`). #603.
//!
//! See `aisix-guardrails/src/keyword.rs` for the runtime semantics
//! the snapshot is parsed into.
Expand Down Expand Up @@ -256,6 +260,83 @@ fn default_acs_on_buffer_exceeded() -> String {
"fail_closed".to_owned()
}

/// Config block for `kind: "aliyun_text_moderation"`. Calls Aliyun's
/// content-safety guardrail (`TextModerationPlus`, action version
/// `2022-03-02`) on the `green-cip.<region>.aliyuncs.com` endpoint for
/// category-risk moderation on input and/or output (including streaming
/// output). Issue #603.
///
/// The input hook uses the `llm_query_moderation` service code, the
/// output hook `llm_response_moderation`. Aliyun grades each call with a
/// `RiskLevel` (`none`/`low`/`medium`/`high`); the DP blocks when the
/// returned level reaches `risk_level_threshold`.
///
/// Only `access_key_secret` is a secret (decrypted by cp-api before kine
/// projection — plaintext in DP memory only, never logged); every other
/// field travels in the clear.
#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct AliyunTextModerationConfig {
/// Aliyun region the guardrail lives in, e.g. `cn-shanghai`. The DP
/// builds the endpoint `https://green-cip.<region>.aliyuncs.com`.
pub region: String,
/// Explicit endpoint override (full URL, no trailing slash). When set
/// it wins over `region`; used by tests/dev to point at a mock server.
#[serde(default)]
pub endpoint: Option<String>,
/// Aliyun AccessKey ID.
pub access_key_id: String,
/// Aliyun AccessKey secret. Decrypted by cp-api before kine projection;
/// plaintext in memory only, never logged. Used to sign the request.
pub access_key_secret: String,
Comment thread
jarvis9443 marked this conversation as resolved.
/// Minimum risk level that triggers a block: `low`, `medium`, or
/// `high` (default). A returned level at or above this blocks.
#[serde(default = "default_aliyun_risk_level_threshold")]
pub risk_level_threshold: String,
/// HTTP call timeout (ms); `fail_open` / `output_fail_open` govern the
/// verdict when it elapses. See the `AzureContentSafetyConfig` note on
/// why `0` means "fire immediately", not "no timeout".
#[serde(default = "default_acs_timeout_ms")]
pub timeout_ms: u32,
/// Fail-open policy for the OUTPUT hook. Defaults `false` (fail-closed)
/// so an Aliyun outage can't release unscanned model output.
#[serde(default)]
pub output_fail_open: bool,

// --- streaming-output controls (consumed by aisix-proxy build_sse_stream) ---
/// `window` (sliding-window incremental release; default) or
/// `buffer_full` (whole-response hold-back).
#[serde(default = "default_acs_stream_processing_mode")]
pub stream_processing_mode: String,
/// Sliding-window size in chars (window mode). Default 2 000 — Aliyun's
/// `llm_response_moderation` per-call content cap.
#[serde(default = "default_aliyun_window_size")]
pub window_size: u32,
/// Chars carried between windows so a span split across a boundary is
/// still caught. Default 128.
#[serde(default = "default_aliyun_window_overlap_size")]
pub window_overlap_size: u32,
/// Max bytes buffered in `buffer_full` mode before `on_buffer_exceeded`
/// applies. Default 262 144.
#[serde(default = "default_acs_max_buffer_bytes")]
pub max_buffer_bytes: u64,
/// `fail_closed` (default) or `fail_open` when the buffer cap is hit.
#[serde(default = "default_acs_on_buffer_exceeded")]
pub on_buffer_exceeded: String,
}

fn default_aliyun_risk_level_threshold() -> String {
"high".to_owned()
}

fn default_aliyun_window_size() -> u32 {
2_000
}

fn default_aliyun_window_overlap_size() -> u32 {
128
}

/// Config block for `kind: "bedrock"`. Phase 1 stores the shape +
/// passes it through `aisix-guardrails::build` which logs
/// `bedrock not yet implemented` and skips the row.
Expand Down Expand Up @@ -294,6 +375,10 @@ pub enum GuardrailKind {
/// on input and/or output (including streaming output). P2
/// (PRD-09c §6 P2, #379).
AzureContentSafetyTextModeration(AzureContentSafetyTextModerationConfig),
/// Aliyun content-safety guardrail. Risk-level moderation via the
/// `TextModerationPlus` action on `green-cip.<region>.aliyuncs.com`,
/// on input and/or output (including streaming output). #603.
AliyunTextModeration(AliyunTextModerationConfig),
}

/// Top-level `Guardrail` resource shape. Mirrors what cp-api writes
Expand Down Expand Up @@ -736,6 +821,68 @@ mod tests {
}
}

#[test]
fn aliyun_text_moderation_kind_parses_with_defaults() {
// cp-api omits unset fields (omitempty); the DP must apply the
// documented defaults so a minimal row still moderates correctly.
let v = json!({
"name": "aliyun-guard",
"kind": "aliyun_text_moderation",
"region": "cn-shanghai",
"access_key_id": "LTAI_EXAMPLE",
"access_key_secret": "PLAINTEXT_FOR_TEST"
});
let g: Guardrail = serde_json::from_value(v).unwrap();
match g.config {
GuardrailKind::AliyunTextModeration(ref c) => {
assert_eq!(c.region, "cn-shanghai");
assert_eq!(c.access_key_id, "LTAI_EXAMPLE");
assert_eq!(c.access_key_secret, "PLAINTEXT_FOR_TEST");
assert_eq!(c.endpoint, None);
assert_eq!(c.risk_level_threshold, "high");
assert_eq!(c.timeout_ms, 5_000);
assert!(!c.output_fail_open);
assert_eq!(c.stream_processing_mode, "window");
assert_eq!(c.window_size, 2_000);
assert_eq!(c.window_overlap_size, 128);
assert_eq!(c.max_buffer_bytes, 262_144);
assert_eq!(c.on_buffer_exceeded, "fail_closed");
}
_ => panic!("expected AliyunTextModeration variant"),
}
}

#[test]
fn aliyun_text_moderation_kind_round_trips_set_fields() {
let v = json!({
"name": "aliyun-guard",
"kind": "aliyun_text_moderation",
"region": "cn-beijing",
"endpoint": "http://127.0.0.1:8080",
"access_key_id": "id",
"access_key_secret": "secret",
"risk_level_threshold": "medium",
"timeout_ms": 3000,
"output_fail_open": true,
"stream_processing_mode": "buffer_full",
"window_size": 1000,
"window_overlap_size": 0
});
let g: Guardrail = serde_json::from_value(v).unwrap();
match g.config {
GuardrailKind::AliyunTextModeration(ref c) => {
assert_eq!(c.endpoint.as_deref(), Some("http://127.0.0.1:8080"));
assert_eq!(c.risk_level_threshold, "medium");
assert_eq!(c.timeout_ms, 3000);
assert!(c.output_fail_open);
assert_eq!(c.stream_processing_mode, "buffer_full");
assert_eq!(c.window_size, 1000);
assert_eq!(c.window_overlap_size, 0, "explicit 0 overlap must survive");
}
_ => panic!("expected AliyunTextModeration variant"),
}
}

#[test]
fn resource_trait_uses_name_and_guardrails_kind() {
let mut g: Guardrail = serde_json::from_value(json!({
Expand Down
6 changes: 3 additions & 3 deletions crates/aisix-core/src/models/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,9 @@ pub mod snapshot;
pub use apikey::ApiKey;
pub use cache_policy::{AppliesTo, CacheBackend, CachePolicy};
pub use guardrail::{
AzureContentSafetyConfig, AzureContentSafetyTextModerationConfig, BedrockAWSCredentials,
BedrockConfig, BedrockLatencyMode, Guardrail, GuardrailAttachment, GuardrailHookPoint,
GuardrailKind, GuardrailScopeType, KeywordConfig, KeywordPattern,
AliyunTextModerationConfig, AzureContentSafetyConfig, AzureContentSafetyTextModerationConfig,
BedrockAWSCredentials, BedrockConfig, BedrockLatencyMode, Guardrail, GuardrailAttachment,
GuardrailHookPoint, GuardrailKind, GuardrailScopeType, KeywordConfig, KeywordPattern,
};
pub use model::{
Adapter, BackgroundModelCheck, CooldownConfig, Model, DEFAULT_COOLDOWN_TRIGGER_STATUSES,
Expand Down
82 changes: 81 additions & 1 deletion crates/aisix-core/src/models/schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -409,7 +409,7 @@ fn guardrail_schema() -> Value {
"enabled": { "type": "boolean" },
"hook_point": { "enum": ["input", "output", "both"] },
"fail_open": { "type": "boolean" },
"kind": { "enum": ["keyword", "bedrock", "azure_content_safety", "azure_content_safety_text_moderation"] }
"kind": { "enum": ["keyword", "bedrock", "azure_content_safety", "azure_content_safety_text_moderation", "aliyun_text_moderation"] }
},
"oneOf": [
{
Expand Down Expand Up @@ -482,6 +482,31 @@ fn guardrail_schema() -> Value {
"on_buffer_exceeded": { "enum": ["fail_closed", "fail_open"] },
"output_fail_open": { "type": "boolean" }
}
},
{
// kind=aliyun_text_moderation — Aliyun content-safety
// guardrail (TextModerationPlus). Mirrors
// AliyunTextModerationConfig in guardrail.rs: region +
// access keys required, endpoint override + threshold +
// streaming params optional (cp-api applies defaults +
// strict validation on write). #603.
"type": "object",
"required": ["kind", "region", "access_key_id", "access_key_secret"],
"properties": {
"kind": { "const": "aliyun_text_moderation" },
"region": { "type": "string", "minLength": 1 },
"endpoint": { "type": "string", "minLength": 1 },
"access_key_id": { "type": "string", "minLength": 1 },
"access_key_secret": { "type": "string", "minLength": 1 },
"risk_level_threshold": { "enum": ["low", "medium", "high"] },
"timeout_ms": { "type": "integer", "minimum": 0, "maximum": 4_294_967_295u64 },
"output_fail_open": { "type": "boolean" },
"stream_processing_mode": { "enum": ["window", "buffer_full"] },
"window_size": { "type": "integer", "minimum": 1, "maximum": 2_000 },
"window_overlap_size": { "type": "integer", "minimum": 0 },
"max_buffer_bytes": { "type": "integer", "minimum": 1 },
"on_buffer_exceeded": { "enum": ["fail_closed", "fail_open"] }
}
}
],
"$defs": {
Expand Down Expand Up @@ -1213,6 +1238,61 @@ mod tests {
assert!(validate_guardrail(&v).is_err());
}

#[test]
fn guardrail_aliyun_text_moderation_passes() {
// Minimal row: region + access keys. Optional fields (endpoint,
// threshold, streaming params) omitted — the struct applies defaults.
let v = json!({
"name": "aliyun-guard",
"kind": "aliyun_text_moderation",
"hook_point": "both",
"region": "cn-shanghai",
"access_key_id": "LTAI_EXAMPLE",
"access_key_secret": "plaintext-secret"
});
validate_guardrail(&v).unwrap();
}

#[test]
fn guardrail_aliyun_text_moderation_with_optional_fields_passes() {
let v = json!({
"name": "aliyun-guard",
"kind": "aliyun_text_moderation",
"region": "cn-beijing",
"endpoint": "http://127.0.0.1:8080",
"access_key_id": "id",
"access_key_secret": "secret",
"risk_level_threshold": "medium",
"timeout_ms": 3000,
"stream_processing_mode": "buffer_full"
});
validate_guardrail(&v).unwrap();
}

#[test]
fn guardrail_aliyun_text_moderation_missing_secret_rejected() {
let v = json!({
"name": "g",
"kind": "aliyun_text_moderation",
"region": "cn-shanghai",
"access_key_id": "id"
});
assert!(validate_guardrail(&v).is_err());
}

#[test]
fn guardrail_aliyun_text_moderation_bad_threshold_rejected() {
let v = json!({
"name": "g",
"kind": "aliyun_text_moderation",
"region": "cn-shanghai",
"access_key_id": "id",
"access_key_secret": "s",
"risk_level_threshold": "none"
});
assert!(validate_guardrail(&v).is_err());
}

// ---- rate_limit_policy schema tests ----

#[test]
Expand Down
20 changes: 19 additions & 1 deletion crates/aisix-guardrails/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,18 @@ aws-smithy-types = { workspace = true, optional = true }
# so a slim build can opt out.
reqwest = { workspace = true, optional = true }

# kind=aliyun_text_moderation guardrail (#603). The Aliyun green-cip
# TextModerationPlus call is signed (RPC signature v1, HMAC-SHA1) and POSTed
# over the shared reqwest client. There is no official Aliyun Rust SDK, so the
# RPC signature is hand-rolled with these primitives (all workspace deps).
hmac = { workspace = true, optional = true }
sha1 = { workspace = true, optional = true }
base64 = { workspace = true, optional = true }
chrono = { workspace = true, optional = true }
uuid = { workspace = true, optional = true }

[features]
default = ["bedrock", "azure-content-safety"]
default = ["bedrock", "azure-content-safety", "aliyun-text-moderation"]
bedrock = [
"dep:aws-config",
"dep:aws-sdk-bedrockruntime",
Expand All @@ -46,6 +56,14 @@ bedrock = [
"dep:aws-smithy-types",
]
azure-content-safety = ["dep:reqwest"]
aliyun-text-moderation = [
"dep:reqwest",
"dep:hmac",
"dep:sha1",
"dep:base64",
"dep:chrono",
"dep:uuid",
]

[dev-dependencies]
tokio = { workspace = true, features = ["macros", "rt"] }
Expand Down
Loading
Loading