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


Reply via email to