This is an automated email from the ASF dual-hosted git repository.
nic-6443 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/apisix.git
The following commit(s) were added to refs/heads/master by this push:
new bb23888cd feat(ai-aliyun-content-moderation): role-aware
request_check_mode and O(n) content chunking (#13598)
bb23888cd is described below
commit bb23888cd786d412ef42fc7de161797faff441b6
Author: Nic <[email protected]>
AuthorDate: Wed Jun 24 20:28:01 2026 +0800
feat(ai-aliyun-content-moderation): role-aware request_check_mode and O(n)
content chunking (#13598)
---
apisix/plugins/ai-aliyun-content-moderation.lua | 137 ++++++++-----
apisix/plugins/ai-protocols/anthropic-messages.lua | 64 +++++--
apisix/plugins/ai-protocols/bedrock-converse.lua | 38 ++++
apisix/plugins/ai-protocols/openai-chat.lua | 57 +++++-
apisix/plugins/ai-protocols/openai-embeddings.lua | 7 +
apisix/plugins/ai-protocols/openai-responses.lua | 51 +++++
.../latest/plugins/ai-aliyun-content-moderation.md | 5 +-
.../latest/plugins/ai-aliyun-content-moderation.md | 5 +-
t/plugin/ai-aliyun-content-moderation.t | 213 +++++++++++++++++++++
9 files changed, 507 insertions(+), 70 deletions(-)
diff --git a/apisix/plugins/ai-aliyun-content-moderation.lua
b/apisix/plugins/ai-aliyun-content-moderation.lua
index 309af2b2d..710bda0ea 100644
--- a/apisix/plugins/ai-aliyun-content-moderation.lua
+++ b/apisix/plugins/ai-aliyun-content-moderation.lua
@@ -61,11 +61,20 @@ local schema = {
fail_mode = binding.schema_property("skip"),
check_request = {type = "boolean", default = true},
check_response = {type = "boolean", default = false},
+ request_check_mode = {
+ type = "string",
+ enum = {"last", "all"},
+ default = "last",
+ description = [[
+ which user messages to moderate: last (only the latest consecutive
user
+ message block) | all (every user message). Both ignore non-user
roles.
+ ]]
+ },
request_check_service = {type = "string", minLength = 1, default =
"llm_query_moderation"},
- request_check_length_limit = {type = "number", default = 2000},
+ request_check_length_limit = {type = "integer", minimum = 1, default =
2000},
response_check_service = {type = "string", minLength = 1,
default = "llm_response_moderation"},
- response_check_length_limit = {type = "number", default = 5000},
+ response_check_length_limit = {type = "integer", minimum = 1, default
= 5000},
risk_level_bar = {type = "string",
enum = {"none", "low", "medium", "high", "max"},
default = "high"},
@@ -112,21 +121,21 @@ local function risk_level_to_int(risk_level)
end
--- openresty ngx.escape_uri don't escape some sub-delimis in rfc 3986 but
aliyun do it,
--- in order to we can calculate same signature with aliyun, we need escape
those chars manually
+-- OpenResty's ngx.escape_uri doesn't escape some RFC 3986 sub-delimiters that
aliyun does,
+-- so to compute the same signature as aliyun we escape those characters
manually.
+-- A single JIT-compiled PCRE pass is ~20x faster than five Lua string.gsub
passes over the
+-- encoded text, which is the hottest per-chunk operation in the signing path.
local sub_delims_rfc3986 = {
- ["!"] = "%%21",
- ["'"] = "%%27",
- ["%("] = "%%28",
- ["%)"] = "%%29",
- ["*"] = "%%2A",
+ ["!"] = "%21",
+ ["'"] = "%27",
+ ["("] = "%28",
+ [")"] = "%29",
+ ["*"] = "%2A",
}
local function url_encoding(raw_str)
- local encoded_str = ngx.escape_uri(raw_str)
- for k, v in pairs(sub_delims_rfc3986) do
- encoded_str = string.gsub(encoded_str, k, v)
- end
- return encoded_str
+ return (ngx.re.gsub(ngx.escape_uri(raw_str), "[!'()*]", function(m)
+ return sub_delims_rfc3986[m[0]]
+ end, "jo"))
end
@@ -165,20 +174,27 @@ local function check_single_content(ctx, conf, content,
service_name)
}
params["Signature"] = calculate_sign(params, conf.access_key_secret .. "&")
- local httpc = http.new()
- httpc:set_timeout(conf.timeout)
-
- local parsed_url = url.parse(conf.endpoint)
- local ok, err = httpc:connect({
- scheme = parsed_url and parsed_url.scheme or "https",
- host = parsed_url and parsed_url.host,
- port = parsed_url and parsed_url.port,
- ssl_verify = conf.ssl_verify,
- ssl_server_name = parsed_url and parsed_url.host,
- pool_size = conf.keepalive and conf.keepalive_pool,
- })
- if not ok then
- return nil, "failed to connect: " .. err
+ -- Reuse one httpc across all moderation calls of a request (realtime fires
+ -- many): cached on ctx, returned to the keepalive pool once at request
end.
+ local httpc = conf.keepalive and ctx.aliyun_cm_httpc
+ if not httpc then
+ httpc = http.new()
+ httpc:set_timeout(conf.timeout)
+ local parsed_url = url.parse(conf.endpoint)
+ local ok, err = httpc:connect({
+ scheme = parsed_url and parsed_url.scheme or "https",
+ host = parsed_url and parsed_url.host,
+ port = parsed_url and parsed_url.port,
+ ssl_verify = conf.ssl_verify,
+ ssl_server_name = parsed_url and parsed_url.host,
+ pool_size = conf.keepalive and conf.keepalive_pool,
+ })
+ if not ok then
+ return nil, "failed to connect: " .. err
+ end
+ if conf.keepalive then
+ ctx.aliyun_cm_httpc = httpc
+ end
end
local body = ngx.encode_args(params)
@@ -191,17 +207,18 @@ local function check_single_content(ctx, conf, content,
service_name)
}
}
if not res then
+ ctx.aliyun_cm_httpc = nil
+ httpc:close()
return nil, "failed to request: " .. err
end
local raw_res_body, err = res:read_body()
if not raw_res_body then
+ ctx.aliyun_cm_httpc = nil
+ httpc:close()
return nil, "failed to read response body: " .. err
end
- if conf.keepalive then
- local ok, err = httpc:set_keepalive(conf.keepalive_timeout,
conf.keepalive_pool)
- if not ok then
- core.log.warn("failed to keepalive connection: ", err)
- end
+ if not conf.keepalive then
+ httpc:close()
end
if res.status ~= 200 then
return nil, "failed to request aliyun text moderation service, status:
" .. res.status
@@ -251,6 +268,19 @@ local function deny_message(ctx, message)
end
+local function release_cm_httpc(ctx, conf)
+ local httpc = ctx.aliyun_cm_httpc
+ if not httpc then
+ return
+ end
+ ctx.aliyun_cm_httpc = nil
+ local ok, err = httpc:set_keepalive(conf.keepalive_timeout,
conf.keepalive_pool)
+ if not ok then
+ core.log.warn("failed to keepalive connection: ", err)
+ end
+end
+
+
local function content_moderation(ctx, conf, content, length_limit,
service_name)
if not ctx.session_id then
ctx.session_id = uuid.generate_v4()
@@ -267,21 +297,28 @@ local function content_moderation(ctx, conf, content,
length_limit, service_name
return
end
- local index = 1
- while true do
- if index > #content then
- return
- end
- local hit, err = check_single_content(ctx, conf,
- utf8.sub(content, index, index
+ length_limit - 1),
- service_name)
- index = index + length_limit
+ -- Walk the content with a byte cursor. utf8.offset(content, length_limit
+ 1,
+ -- cur) returns the byte position length_limit characters ahead of cur,
+ -- scanning only that window, so slicing with byte-based string.sub keeps
the
+ -- whole loop O(n). The previous utf8.sub(content, index, ...) located the
+ -- index-th character by scanning from the string start on every chunk,
which
+ -- made large request/response bodies O(n^2).
+ local cur = 1
+ while cur <= #content do
+ local next_byte = utf8.offset(content, length_limit + 1, cur)
+ local piece = next_byte and string.sub(content, cur, next_byte - 1)
+ or string.sub(content, cur)
+ local hit, err = check_single_content(ctx, conf, piece, service_name)
if hit then
return conf.deny_code, deny_message(ctx, conf.deny_message or err)
end
if err then
core.log.error("failed to check content: ", err)
end
+ if not next_byte then
+ return
+ end
+ cur = next_byte
end
end
@@ -353,10 +390,16 @@ function _M.access(conf, ctx)
return
end
- local contents = proto.extract_request_content(request_tab)
+ -- Request moderation targets user input only (request_check_mode: "last" =
+ -- latest user turn, "all" = every user message). Protocols that can't
surface
+ -- user-role content have nothing to moderate, so the request passes
through.
+ local contents = proto.extract_user_content
+ and proto.extract_user_content(request_tab, conf.request_check_mode)
+ or {}
local content_to_check = table.concat(contents, " ")
local code, message = request_content_moderation(ctx, conf,
content_to_check)
+ release_cm_httpc(ctx, conf)
if code then
local stream = ctx.var.request_type == "ai_stream"
if stream then
@@ -383,7 +426,9 @@ function _M.lua_body_filter(conf, ctx, headers, body)
if request_type == "ai_chat" then
local content = ctx.var.llm_response_text
- return response_content_moderation(ctx, conf, content)
+ local code, message = response_content_moderation(ctx, conf, content)
+ release_cm_httpc(ctx, conf)
+ return code, message
end
local proto = protocols.get(ctx.ai_client_protocol)
@@ -393,6 +438,7 @@ function _M.lua_body_filter(conf, ctx, headers, body)
return
end
response_content_moderation(ctx, conf, ctx.var.llm_response_text)
+ release_cm_httpc(ctx, conf)
local events = sse.decode(body)
for _, event in ipairs(events) do
if proto and proto.is_data_event(event) then
@@ -435,6 +481,9 @@ function _M.lua_body_filter(conf, ctx, headers, body)
end
ctx.last_moderate_time = now_time
local _, message = response_content_moderation(ctx, conf,
ctx.content_moderation_cache)
+ if message or ctx.var.llm_request_done then
+ release_cm_httpc(ctx, conf)
+ end
if message then
return ngx_ok, message
end
diff --git a/apisix/plugins/ai-protocols/anthropic-messages.lua
b/apisix/plugins/ai-protocols/anthropic-messages.lua
index a777f5136..5c9c286a9 100644
--- a/apisix/plugins/ai-protocols/anthropic-messages.lua
+++ b/apisix/plugins/ai-protocols/anthropic-messages.lua
@@ -214,25 +214,63 @@ function _M.extract_end_user_id(body)
end
+-- Append a single message's text (string content or text blocks) into
`contents`.
+local function append_message_text(contents, message)
+ if type(message) ~= "table" then
+ return
+ end
+ if type(message.content) == "string" then
+ core.table.insert(contents, message.content)
+ elseif type(message.content) == "table" then
+ for _, block in ipairs(message.content) do
+ if type(block) == "table" and block.type == "text"
+ and type(block.text) == "string" then
+ core.table.insert(contents, block.text)
+ end
+ end
+ end
+end
+
+
--- Extract all text content from a request body for moderation.
function _M.extract_request_content(body)
local contents = {}
if type(body.messages) == "table" then
for _, message in ipairs(body.messages) do
- if type(message) ~= "table" then
- goto CONTINUE_MESSAGE
- end
- if type(message.content) == "string" then
- core.table.insert(contents, message.content)
- elseif type(message.content) == "table" then
- for _, block in ipairs(message.content) do
- if type(block) == "table" and block.type == "text"
- and type(block.text) == "string" then
- core.table.insert(contents, block.text)
- end
- end
+ append_message_text(contents, message)
+ end
+ end
+ return contents
+end
+
+
+-- Extract text from user-role messages for request moderation.
+-- mode "last" (default): only the last consecutive block of user messages (the
+-- latest user turn); mode "all": every user message. Non-user roles are
ignored
+-- (the Anthropic system prompt lives in body.system, not in messages).
+function _M.extract_user_content(body, mode)
+ local contents = {}
+ if type(body.messages) ~= "table" then
+ return contents
+ end
+ local messages = body.messages
+ local start_idx = 1
+ if mode ~= "all" then
+ start_idx = nil
+ for i = #messages, 1, -1 do
+ if type(messages[i]) == "table" and messages[i].role == "user" then
+ start_idx = i
+ else
+ break
end
- ::CONTINUE_MESSAGE::
+ end
+ if not start_idx then
+ return contents
+ end
+ end
+ for i = start_idx, #messages do
+ if type(messages[i]) == "table" and messages[i].role == "user" then
+ append_message_text(contents, messages[i])
end
end
return contents
diff --git a/apisix/plugins/ai-protocols/bedrock-converse.lua
b/apisix/plugins/ai-protocols/bedrock-converse.lua
index b0259b6c8..7fecd3efc 100644
--- a/apisix/plugins/ai-protocols/bedrock-converse.lua
+++ b/apisix/plugins/ai-protocols/bedrock-converse.lua
@@ -201,6 +201,44 @@ function _M.extract_request_content(body)
end
+-- Extract text from user-role messages for request moderation (mode "last" =
+-- latest user turn, "all" = every user message). The `system` blocks and
+-- non-user messages are ignored.
+function _M.extract_user_content(body, mode)
+ local contents = {}
+ if type(body.messages) ~= "table" then
+ return contents
+ end
+ local messages = body.messages
+ local start_idx = 1
+ if mode ~= "all" then
+ start_idx = nil
+ for i = #messages, 1, -1 do
+ if type(messages[i]) == "table" and messages[i].role == "user" then
+ start_idx = i
+ else
+ break
+ end
+ end
+ if not start_idx then
+ return contents
+ end
+ end
+ for i = start_idx, #messages do
+ local message = messages[i]
+ if type(message) == "table" and message.role == "user"
+ and type(message.content) == "table" then
+ for _, block in ipairs(message.content) do
+ if type(block) == "table" and type(block.text) == "string" then
+ core.table.insert(contents, block.text)
+ end
+ end
+ end
+ end
+ return contents
+end
+
+
--- Get messages in canonical {role, content} format.
-- Bedrock content blocks [{text: "..."}] are flattened to plain text.
function _M.get_messages(body)
diff --git a/apisix/plugins/ai-protocols/openai-chat.lua
b/apisix/plugins/ai-protocols/openai-chat.lua
index 096af76df..ce54d736a 100644
--- a/apisix/plugins/ai-protocols/openai-chat.lua
+++ b/apisix/plugins/ai-protocols/openai-chat.lua
@@ -218,22 +218,61 @@ function _M.extract_end_user_id(body)
end
+-- Append a single message's text (string content or text parts) into
`contents`.
+local function append_message_text(contents, message)
+ if type(message.content) == "string" then
+ core.table.insert(contents, message.content)
+ elseif type(message.content) == "table" then
+ for _, part in ipairs(message.content) do
+ if type(part) == "table" and part.type == "text"
+ and type(part.text) == "string" then
+ core.table.insert(contents, part.text)
+ end
+ end
+ end
+end
+
+
--- Extract all text content from a request body for moderation.
function _M.extract_request_content(body)
local contents = {}
if type(body.messages) == "table" then
for _, message in ipairs(body.messages) do
- if type(message.content) == "string" then
- core.table.insert(contents, message.content)
- elseif type(message.content) == "table" then
- for _, part in ipairs(message.content) do
- if type(part) == "table" and part.type == "text"
- and type(part.text) == "string" then
- core.table.insert(contents, part.text)
- end
- end
+ append_message_text(contents, message)
+ end
+ end
+ return contents
+end
+
+
+-- Extract text from user-role messages for request moderation.
+-- mode "last" (default): only the last consecutive block of user messages (the
+-- latest user turn); mode "all": every user message. Non-user roles are
ignored
+-- because the query moderation service is meant for user input.
+function _M.extract_user_content(body, mode)
+ local contents = {}
+ if type(body.messages) ~= "table" then
+ return contents
+ end
+ local messages = body.messages
+ local start_idx = 1
+ if mode ~= "all" then
+ start_idx = nil
+ for i = #messages, 1, -1 do
+ if type(messages[i]) == "table" and messages[i].role == "user" then
+ start_idx = i
+ else
+ break
end
end
+ if not start_idx then
+ return contents
+ end
+ end
+ for i = start_idx, #messages do
+ if type(messages[i]) == "table" and messages[i].role == "user" then
+ append_message_text(contents, messages[i])
+ end
end
return contents
end
diff --git a/apisix/plugins/ai-protocols/openai-embeddings.lua
b/apisix/plugins/ai-protocols/openai-embeddings.lua
index 51feb24a5..96f8faa2a 100644
--- a/apisix/plugins/ai-protocols/openai-embeddings.lua
+++ b/apisix/plugins/ai-protocols/openai-embeddings.lua
@@ -90,6 +90,13 @@ function _M.extract_request_content(body)
end
+-- Embeddings has no message roles; the `input` text is the user content. The
+-- mode argument does not apply (no conversation turns).
+function _M.extract_user_content(body, _)
+ return _M.extract_request_content(body)
+end
+
+
function _M.get_messages(body)
local messages = {}
if body and body.input then
diff --git a/apisix/plugins/ai-protocols/openai-responses.lua
b/apisix/plugins/ai-protocols/openai-responses.lua
index 9e78b3fb5..8c263a0fb 100644
--- a/apisix/plugins/ai-protocols/openai-responses.lua
+++ b/apisix/plugins/ai-protocols/openai-responses.lua
@@ -223,6 +223,57 @@ function _M.extract_request_content(body)
end
+-- Extract user input text for request moderation. A plain-string `input` is
the
+-- user's content. For an `input` array, only user-role items are considered
+-- (mode "last" = latest user turn, "all" = every user item); `instructions`
(the
+-- system prompt) and non-user items are ignored.
+local function is_user_item(item)
+ return type(item) == "string" or (type(item) == "table" and item.role ==
"user")
+end
+function _M.extract_user_content(body, mode)
+ local contents = {}
+ local input = body.input
+ if type(input) == "string" then
+ core.table.insert(contents, input)
+ return contents
+ end
+ if type(input) ~= "table" then
+ return contents
+ end
+ local start_idx = 1
+ if mode ~= "all" then
+ start_idx = nil
+ for i = #input, 1, -1 do
+ if is_user_item(input[i]) then
+ start_idx = i
+ else
+ break
+ end
+ end
+ if not start_idx then
+ return contents
+ end
+ end
+ for i = start_idx, #input do
+ local item = input[i]
+ if type(item) == "string" then
+ core.table.insert(contents, item)
+ elseif type(item) == "table" and item.role == "user" and item.content
then
+ if type(item.content) == "string" then
+ core.table.insert(contents, item.content)
+ elseif type(item.content) == "table" then
+ for _, part in ipairs(item.content) do
+ if type(part) == "table" and part.text then
+ core.table.insert(contents, part.text)
+ end
+ end
+ end
+ end
+ end
+ return contents
+end
+
+
--- Get messages in canonical {role, content} format.
-- Converts instructions + input into messages-style list.
function _M.get_messages(body)
diff --git a/docs/en/latest/plugins/ai-aliyun-content-moderation.md
b/docs/en/latest/plugins/ai-aliyun-content-moderation.md
index 3ba93081f..98b2344fe 100644
--- a/docs/en/latest/plugins/ai-aliyun-content-moderation.md
+++ b/docs/en/latest/plugins/ai-aliyun-content-moderation.md
@@ -58,10 +58,11 @@ The `ai-aliyun-content-moderation` Plugin should be used
with either [`ai-proxy`
| stream_check_mode | string | False | `"final_packet"` | `realtime`,
`final_packet` | Streaming moderation mode. `realtime`: batched checks during
streaming. `final_packet`: append risk level at the end. |
| stream_check_cache_size | integer | False | `128` | >= 1 | Maximum bytes per
moderation batch in `realtime` mode. Length is measured using Lua string
length, so for UTF-8 text non-ASCII characters may consume multiple bytes. |
| stream_check_interval | number | False | `3` | >= 0.1 | Seconds between
batch checks in `realtime` mode. |
+| request_check_mode | string | False | `"last"` | `last`, `all` | Which user
messages to moderate. `last`: only the latest consecutive block of user
messages (the newest user turn). `all`: every user message. Both modes consider
only `user`-role messages; `system`, `assistant` and `tool` messages are
ignored. |
| request_check_service | string | False | `"llm_query_moderation"` | | Aliyun
service for request moderation. |
-| request_check_length_limit | number | False | `2000` | | Request content
length limit, in bytes. Length is measured using Lua string length, so for
UTF-8 text non-ASCII characters may consume multiple bytes. If exceeded, the
content will be sent in chunks. For instance, if the request content is 250
bytes and the `request_check_length_limit` is set to `100`, then the content
will be sent in 3 requests to Aliyun. |
+| request_check_length_limit | number | False | `2000` | >= 1 | Request
content length limit. If exceeded, the content is sent to Aliyun in chunks. For
instance, if the request content is 250 characters and
`request_check_length_limit` is set to `100`, the content is sent in 3 requests
to Aliyun. |
| response_check_service | string | False | `"llm_response_moderation"` | |
Aliyun service for response moderation. |
-| response_check_length_limit | number | False | `5000` | | Response content
length limit, in bytes. Length is measured using Lua string length, so for
UTF-8 text non-ASCII characters may consume multiple bytes. If exceeded, the
content will be sent in chunks. For instance, if the response content is 250
bytes and the `response_check_length_limit` is set to `100`, then the content
will be sent in 3 requests to Aliyun. |
+| response_check_length_limit | number | False | `5000` | >= 1 | Response
content length limit. If exceeded, the content is sent to Aliyun in chunks. For
instance, if the response content is 250 characters and
`response_check_length_limit` is set to `100`, the content is sent in 3
requests to Aliyun. |
| risk_level_bar | string | False | `"high"` | `none`, `low`, `medium`,
`high`, `max` | If the evaluated risk level is lower than the `risk_level_bar`,
the request or response will be passed through to Upstream LLM or client
respectively. |
| deny_code | number | False | `200` | | Rejection HTTP status code. |
| deny_message | string | False | | | Rejection message. |
diff --git a/docs/zh/latest/plugins/ai-aliyun-content-moderation.md
b/docs/zh/latest/plugins/ai-aliyun-content-moderation.md
index cc99e1cec..04489e36d 100644
--- a/docs/zh/latest/plugins/ai-aliyun-content-moderation.md
+++ b/docs/zh/latest/plugins/ai-aliyun-content-moderation.md
@@ -58,10 +58,11 @@ import TabItem from '@theme/TabItem';
| stream_check_mode | string | 否 | `"final_packet"` |
`realtime`、`final_packet` |
流式审核模式。`realtime`:流式传输期间批量检查。`final_packet`:在最后附加风险等级。 |
| stream_check_cache_size | integer | 否 | `128` | >= 1 | `realtime`
模式下每次审核批次的最大字节数(按 UTF-8 编码后的字节长度计算)。 |
| stream_check_interval | number | 否 | `3` | >= 0.1 | `realtime`
模式下批次检查之间的间隔秒数。 |
+| request_check_mode | string | 否 | `"last"` | `last`, `all` | 审核哪些 user
消息。`last`:仅审核最后一段连续的 user 消息(最新的用户轮次);`all`:审核所有 user 消息。两种模式都只处理 `user`
角色的消息,`system`、`assistant`、`tool` 消息会被忽略。 |
| request_check_service | string | 否 | `"llm_query_moderation"` | |
用于请求审核的阿里云服务。 |
-| request_check_length_limit | number | 否 | `2000` | | 请求内容字节数上限(按 UTF-8
编码后的字节长度计算)。如果超过该限制,内容将被分块发送。对于非 ASCII 内容,可能会比按字符数理解时更早触发分块。例如,如果请求内容按 UTF-8
编码后有 250 个字节,且 `request_check_length_limit` 设置为 `100`,则内容将分 3 次请求发送到阿里云。 |
+| request_check_length_limit | number | 否 | `2000` | >= 1 |
请求内容长度上限。如果超过该限制,内容将分块发送到阿里云。例如,如果请求内容为 250 个字符,且 `request_check_length_limit`
设置为 `100`,则内容将分 3 次请求发送到阿里云。 |
| response_check_service | string | 否 | `"llm_response_moderation"` | |
用于响应审核的阿里云服务。 |
-| response_check_length_limit | number | 否 | `5000` | | 响应内容字节数上限(按 UTF-8
编码后的字节长度计算)。如果超过该限制,内容将被分块发送。对于非 ASCII 内容,可能会比按字符数理解时更早触发分块。例如,如果响应内容按 UTF-8
编码后有 250 个字节,且 `response_check_length_limit` 设置为 `100`,则内容将分 3 次请求发送到阿里云。 |
+| response_check_length_limit | number | 否 | `5000` | >= 1 |
响应内容长度上限。如果超过该限制,内容将分块发送到阿里云。例如,如果响应内容为 250 个字符,且 `response_check_length_limit`
设置为 `100`,则内容将分 3 次请求发送到阿里云。 |
| risk_level_bar | string | 否 | `"high"` | `none`、`low`、`medium`、`high`、`max`
| 如果评估的风险等级低于 `risk_level_bar`,请求或响应将分别被放行到上游 LLM 或客户端。 |
| deny_code | number | 否 | `200` | | 拒绝时的 HTTP 状态码。 |
| deny_message | string | 否 | | | 拒绝时的消息。 |
diff --git a/t/plugin/ai-aliyun-content-moderation.t
b/t/plugin/ai-aliyun-content-moderation.t
index a48a23446..9588b9a39 100644
--- a/t/plugin/ai-aliyun-content-moderation.t
+++ b/t/plugin/ai-aliyun-content-moderation.t
@@ -1709,3 +1709,216 @@ Content-Type: multipart/form-data
--- error_code: 200
--- error_log
ai-aliyun-content-moderation skipped
+
+
+
+=== TEST 47: create route for request_check_mode tests (default mode = last)
+--- config
+ location /t {
+ content_by_lua_block {
+ local t = require("lib.test_admin").test
+ local code, body = t('/apisix/admin/routes/1',
+ ngx.HTTP_PUT,
+ [[{
+ "uri": "/chat-last",
+ "plugins": {
+ "ai-proxy": {
+ "provider": "openai",
+ "auth": { "header": { "Authorization": "Bearer
wrongtoken" } },
+ "override": { "endpoint": "http://127.0.0.1:1980" }
+ },
+ "ai-aliyun-content-moderation": {
+ "endpoint": "http://localhost:6724",
+ "region_id": "cn-shanghai",
+ "access_key_id": "fake-key-id",
+ "access_key_secret": "fake-key-secret",
+ "risk_level_bar": "high",
+ "check_request": true
+ }
+ }
+ }]]
+ )
+ if code >= 300 then
+ ngx.status = code
+ end
+ ngx.say(body)
+ }
+ }
+--- response_body
+passed
+
+
+
+=== TEST 48: default mode (last) - harmful earlier user turn is skipped
+--- request
+POST /chat-last
+{ "messages": [ { "role": "user", "content": "I want to kill you" }, { "role":
"assistant", "content": "ok" }, { "role": "user", "content": "What is 1+1?" } ]
}
+--- more_headers
+X-AI-Fixture: aliyun/chat-with-harmful.json
+--- error_code: 200
+--- response_body_like eval
+qr/kill you/
+
+
+
+=== TEST 49: default mode (last) - harmful last user turn is detected
+--- request
+POST /chat-last
+{ "messages": [ { "role": "user", "content": "What is 1+1?" }, { "role":
"assistant", "content": "ok" }, { "role": "user", "content": "I want to kill
you" } ] }
+--- more_headers
+X-AI-Fixture: aliyun/chat-with-harmful.json
+--- error_code: 200
+--- response_body_like eval
+qr/cannot write unethical/
+
+
+
+=== TEST 50: role-aware - non-user (assistant) last message, no user turn to
check, request passes
+--- request
+POST /chat-last
+{ "messages": [ { "role": "user", "content": "What is 1+1?" }, { "role":
"assistant", "content": "I want to kill you" } ] }
+--- more_headers
+X-AI-Fixture: aliyun/chat-with-harmful.json
+--- error_code: 200
+--- response_body_like eval
+qr/kill you/
+
+
+
+=== TEST 51: create route with request_check_mode "all"
+--- config
+ location /t {
+ content_by_lua_block {
+ local t = require("lib.test_admin").test
+ local code, body = t('/apisix/admin/routes/2',
+ ngx.HTTP_PUT,
+ [[{
+ "uri": "/chat-all",
+ "plugins": {
+ "ai-proxy": {
+ "provider": "openai",
+ "auth": { "header": { "Authorization": "Bearer
wrongtoken" } },
+ "override": { "endpoint": "http://127.0.0.1:1980" }
+ },
+ "ai-aliyun-content-moderation": {
+ "endpoint": "http://localhost:6724",
+ "region_id": "cn-shanghai",
+ "access_key_id": "fake-key-id",
+ "access_key_secret": "fake-key-secret",
+ "risk_level_bar": "high",
+ "check_request": true,
+ "request_check_mode": "all"
+ }
+ }
+ }]]
+ )
+ if code >= 300 then
+ ngx.status = code
+ end
+ ngx.say(body)
+ }
+ }
+--- response_body
+passed
+
+
+
+=== TEST 52: request_check_mode "all" - harmful earlier user turn is detected
+--- request
+POST /chat-all
+{ "messages": [ { "role": "user", "content": "I want to kill you" }, { "role":
"assistant", "content": "ok" }, { "role": "user", "content": "What is 1+1?" } ]
}
+--- more_headers
+X-AI-Fixture: aliyun/chat-with-harmful.json
+--- error_code: 200
+--- response_body_like eval
+qr/cannot write unethical/
+
+
+
+=== TEST 53: request_check_mode "all" stays role-aware - harmful system
message is skipped
+--- request
+POST /chat-all
+{ "messages": [ { "role": "system", "content": "I want to kill you" }, {
"role": "user", "content": "hi" }, { "role": "user", "content": "What is 1+1?"
} ] }
+--- more_headers
+X-AI-Fixture: aliyun/chat-with-harmful.json
+--- error_code: 200
+--- response_body_like eval
+qr/kill you/
+
+
+
+=== TEST 54: create route with small request_check_length_limit (exercises
multi-chunk path)
+--- config
+ location /t {
+ content_by_lua_block {
+ local t = require("lib.test_admin").test
+ local code, body = t('/apisix/admin/routes/3',
+ ngx.HTTP_PUT,
+ [[{
+ "uri": "/chat-chunk",
+ "plugins": {
+ "ai-proxy": {
+ "provider": "openai",
+ "auth": { "header": { "Authorization": "Bearer
wrongtoken" } },
+ "override": { "endpoint": "http://127.0.0.1:1980" }
+ },
+ "ai-aliyun-content-moderation": {
+ "endpoint": "http://localhost:6724",
+ "region_id": "cn-shanghai",
+ "access_key_id": "fake-key-id",
+ "access_key_secret": "fake-key-secret",
+ "risk_level_bar": "high",
+ "check_request": true,
+ "request_check_length_limit": 10
+ }
+ }
+ }]]
+ )
+ if code >= 300 then
+ ngx.status = code
+ end
+ ngx.say(body)
+ }
+ }
+--- response_body
+passed
+
+
+
+=== TEST 55: multi-chunk - harmful content in a later chunk after multibyte
chars is detected
+--- request
+POST /chat-chunk
+{ "messages": [ { "role": "user", "content": "这是一段安全的中文内容 kill" } ] }
+--- more_headers
+X-AI-Fixture: aliyun/chat-with-harmful.json
+--- error_code: 200
+--- response_body_like eval
+qr/cannot write unethical/
+
+
+
+=== TEST 56: request_check_length_limit must be >= 1 (guards against infinite
loop)
+--- config
+ location /t {
+ content_by_lua_block {
+ local t = require("lib.test_admin").test
+ local code, body = t('/apisix/admin/routes/4',
+ ngx.HTTP_PUT,
+ [[{
+ "uri": "/chat-bad",
+ "plugins": {
+ "ai-aliyun-content-moderation": {
+ "endpoint": "http://localhost:6724",
+ "region_id": "cn-shanghai",
+ "access_key_id": "fake-key-id",
+ "access_key_secret": "fake-key-secret",
+ "request_check_length_limit": 0
+ }
+ }
+ }]]
+ )
+ ngx.say(code >= 300 and "rejected" or "accepted")
+ }
+ }
+--- response_body
+rejected