This is an automated email from the ASF dual-hosted git repository.

AlinsRan 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 03a727644 feat(ai-aliyun-content-moderation): moderate system and tool 
role content (#13646)
03a727644 is described below

commit 03a727644e23120689cc97539ce5e3fe37b493bc
Author: AlinsRan <[email protected]>
AuthorDate: Tue Jul 7 16:15:35 2026 +0800

    feat(ai-aliyun-content-moderation): moderate system and tool role content 
(#13646)
---
 apisix/plugins/ai-aliyun-content-moderation.lua    |  86 ++++-
 apisix/plugins/ai-protocols/anthropic-messages.lua |  39 ++-
 apisix/plugins/ai-protocols/bedrock-converse.lua   |  37 ++-
 apisix/plugins/ai-protocols/openai-chat.lua        |  38 ++-
 apisix/plugins/ai-protocols/openai-embeddings.lua  |  15 +-
 apisix/plugins/ai-protocols/openai-responses.lua   |  70 +++-
 .../latest/plugins/ai-aliyun-content-moderation.md |   3 +-
 .../latest/plugins/ai-aliyun-content-moderation.md |   3 +-
 t/plugin/ai-aliyun-content-moderation.t            | 359 +++++++++++++++++++++
 9 files changed, 597 insertions(+), 53 deletions(-)

diff --git a/apisix/plugins/ai-aliyun-content-moderation.lua 
b/apisix/plugins/ai-aliyun-content-moderation.lua
index 6e41364d9..8926be468 100644
--- a/apisix/plugins/ai-aliyun-content-moderation.lua
+++ b/apisix/plugins/ai-aliyun-content-moderation.lua
@@ -19,6 +19,7 @@ local ngx_ok    = ngx.OK
 local os        = os
 local pairs     = pairs
 local ipairs    = ipairs
+local next      = next
 local table     = table
 local string    = string
 local type      = type
@@ -66,8 +67,24 @@ local schema = {
             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.
+            which user/tool messages to moderate: last (only the latest 
consecutive
+            block of selected-role messages) | all (every selected-role 
message).
+            Does not apply to the system role, which is always checked.
+            ]]
+        },
+        request_check_roles = {
+            type = "array",
+            items = {type = "string", enum = {"user", "tool", "system"}},
+            minItems = 1,
+            uniqueItems = true,
+            default = {"user"},
+            description = [[
+            which message roles to moderate on the request side. user/tool 
follow
+            request_check_mode; system is checked on every request because it 
can
+            be poisoned by malicious ToolCall arguments. Note: tool-result
+            moderation applies to OpenAI-compatible formats where the tool 
output
+            is a distinct "tool" role/item; for Anthropic/Bedrock (tool results
+            are nested blocks inside user messages) tool content is not 
extracted.
             ]]
         },
         request_check_service = {type = "string", minLength = 1, default = 
"llm_query_moderation"},
@@ -390,23 +407,64 @@ function _M.access(conf, ctx)
         return
     end
 
-    -- 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 function set_deny_content_type()
+        if ctx.var.request_type == "ai_stream" then
+            core.response.set_header("Content-Type", "text/event-stream")
+        else
+            core.response.set_header("Content-Type", "application/json")
+        end
+    end
+
+    local roles = {}
+    for _, r in ipairs(conf.request_check_roles) do
+        roles[r] = true
+    end
+    local turn_roles = {}
+    if roles.user then turn_roles.user = true end
+    if roles.tool then turn_roles.tool = true end
+
+    -- A configured role whose extractor this protocol doesn't implement would
+    -- otherwise pass unmoderated. Route that through fail_mode instead of
+    -- silently skipping the configured moderation.
+    if (roles.system and not proto.extract_system_content)
+            or (next(turn_roles) and not proto.extract_turn_content) then
+        local handled, code, body = binding.on_unsupported(
+            conf.fail_mode, _M.name, ctx,
+            "protocol cannot extract configured request_check_roles",
+            500, "protocol " .. (ctx.ai_client_protocol or "unknown")
+                .. " cannot moderate the configured request_check_roles")
+        if handled then
+            return code, body
+        end
+        return
+    end
+
+    -- Collect the text to moderate from all configured roles and send it in a
+    -- single request. The Aliyun service takes a flat `content` string with no
+    -- role field, so there is nothing to gain from separate per-role calls.
+    -- system is always included (every request, not subject to 
request_check_mode,
+    -- because it can be poisoned by malicious ToolCall arguments); user/tool
+    -- follow request_check_mode ("last" = latest turn, "all" = every message).
+    local contents = {}
+    if roles.system then
+        local system_texts = proto.extract_system_content(request_tab)
+        for i = 1, #system_texts do
+            contents[#contents + 1] = system_texts[i]
+        end
+    end
+    if next(turn_roles) then
+        local turn_texts = proto.extract_turn_content(request_tab,
+                                                      conf.request_check_mode, 
turn_roles)
+        for i = 1, #turn_texts do
+            contents[#contents + 1] = turn_texts[i]
+        end
+    end
     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
-            core.response.set_header("Content-Type", "text/event-stream")
-        else
-            core.response.set_header("Content-Type", "application/json")
-        end
+        set_deny_content_type()
         return code, message
     end
 end
diff --git a/apisix/plugins/ai-protocols/anthropic-messages.lua 
b/apisix/plugins/ai-protocols/anthropic-messages.lua
index 7af1a0a7b..7cef9a473 100644
--- a/apisix/plugins/ai-protocols/anthropic-messages.lua
+++ b/apisix/plugins/ai-protocols/anthropic-messages.lua
@@ -244,11 +244,18 @@ function _M.extract_request_content(body)
 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 function is_turn_role(message, roles)
+    return type(message) == "table" and message.role ~= nil and 
roles[message.role]
+end
+
+
+-- Extract text from turn-role messages (user/tool) for request moderation.
+-- `roles` is a set such as {user = true, tool = true} selecting which roles to
+-- collect. mode "last" (default): only the last consecutive block of messages
+-- whose role is in `roles` (the latest turn); mode "all": every such message.
+-- The Anthropic system prompt lives in body.system and is handled separately 
by
+-- extract_system_content.
+function _M.extract_turn_content(body, mode, roles)
     local contents = {}
     if type(body.messages) ~= "table" then
         return contents
@@ -258,7 +265,7 @@ function _M.extract_user_content(body, mode)
     if mode ~= "all" then
         start_idx = nil
         for i = #messages, 1, -1 do
-            if type(messages[i]) == "table" and messages[i].role == "user" then
+            if is_turn_role(messages[i], roles) then
                 start_idx = i
             else
                 break
@@ -269,7 +276,7 @@ function _M.extract_user_content(body, mode)
         end
     end
     for i = start_idx, #messages do
-        if type(messages[i]) == "table" and messages[i].role == "user" then
+        if is_turn_role(messages[i], roles) then
             append_message_text(contents, messages[i])
         end
     end
@@ -277,6 +284,24 @@ function _M.extract_user_content(body, mode)
 end
 
 
+-- Extract system-role text for request moderation. Anthropic carries the 
system
+-- prompt in body.system (a string or an array of text blocks), not in 
messages.
+function _M.extract_system_content(body)
+    local contents = {}
+    if type(body.system) == "string" then
+        core.table.insert(contents, body.system)
+    elseif type(body.system) == "table" then
+        for _, block in ipairs(body.system) do
+            if type(block) == "table" and block.type == "text"
+                    and type(block.text) == "string" then
+                core.table.insert(contents, block.text)
+            end
+        end
+    end
+    return contents
+end
+
+
 --- Get messages in canonical {role, content} format.
 -- Anthropic content blocks are flattened to plain text.
 function _M.get_messages(body)
diff --git a/apisix/plugins/ai-protocols/bedrock-converse.lua 
b/apisix/plugins/ai-protocols/bedrock-converse.lua
index 1b9f998bc..1d179d01e 100644
--- a/apisix/plugins/ai-protocols/bedrock-converse.lua
+++ b/apisix/plugins/ai-protocols/bedrock-converse.lua
@@ -196,10 +196,17 @@ 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 function is_turn_role(message, roles)
+    return type(message) == "table" and message.role ~= nil and 
roles[message.role]
+end
+
+
+-- Extract text from turn-role messages (user/tool) for request moderation.
+-- `roles` is a set such as {user = true, tool = true} selecting which roles to
+-- collect. mode "last" = the latest consecutive block of selected-role 
messages,
+-- "all" = every such message. Bedrock `system` blocks are handled separately 
by
+-- extract_system_content.
+function _M.extract_turn_content(body, mode, roles)
     local contents = {}
     if type(body.messages) ~= "table" then
         return contents
@@ -209,7 +216,7 @@ function _M.extract_user_content(body, mode)
     if mode ~= "all" then
         start_idx = nil
         for i = #messages, 1, -1 do
-            if type(messages[i]) == "table" and messages[i].role == "user" then
+            if is_turn_role(messages[i], roles) then
                 start_idx = i
             else
                 break
@@ -220,9 +227,23 @@ function _M.extract_user_content(body, mode)
         end
     end
     for i = start_idx, #messages do
-        local message = messages[i]
-        if type(message) == "table" and message.role == "user" then
-            append_block_texts(contents, message.content)
+        if is_turn_role(messages[i], roles) then
+            append_block_texts(contents, messages[i].content)
+        end
+    end
+    return contents
+end
+
+
+-- Extract system-role text for request moderation. Bedrock carries the system
+-- prompt in body.system (an array of text blocks), not in messages.
+function _M.extract_system_content(body)
+    local contents = {}
+    if type(body.system) == "table" then
+        for _, block in ipairs(body.system) do
+            if type(block) == "table" and type(block.text) == "string" then
+                core.table.insert(contents, block.text)
+            end
         end
     end
     return contents
diff --git a/apisix/plugins/ai-protocols/openai-chat.lua 
b/apisix/plugins/ai-protocols/openai-chat.lua
index ce6a446b4..827512e8b 100644
--- a/apisix/plugins/ai-protocols/openai-chat.lua
+++ b/apisix/plugins/ai-protocols/openai-chat.lua
@@ -248,11 +248,19 @@ function _M.extract_request_content(body)
 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 function is_turn_role(message, roles)
+    return type(message) == "table" and message.role ~= nil and 
roles[message.role]
+end
+
+
+-- Extract text from turn-role messages (user/tool) for request moderation.
+-- `roles` is a set such as {user = true, tool = true} selecting which roles to
+-- collect. mode "last" (default): only the last consecutive block of messages
+-- whose role is in `roles` -- the latest turn, i.e. a fresh user message or 
the
+-- tool results appended in the current agent round, so history is not 
re-checked.
+-- mode "all": every such message. The system role is handled separately by
+-- extract_system_content because it is not subject to the last-turn rule.
+function _M.extract_turn_content(body, mode, roles)
     local contents = {}
     if type(body.messages) ~= "table" then
         return contents
@@ -262,7 +270,7 @@ function _M.extract_user_content(body, mode)
     if mode ~= "all" then
         start_idx = nil
         for i = #messages, 1, -1 do
-            if type(messages[i]) == "table" and messages[i].role == "user" then
+            if is_turn_role(messages[i], roles) then
                 start_idx = i
             else
                 break
@@ -273,7 +281,7 @@ function _M.extract_user_content(body, mode)
         end
     end
     for i = start_idx, #messages do
-        if type(messages[i]) == "table" and messages[i].role == "user" then
+        if is_turn_role(messages[i], roles) then
             append_message_text(contents, messages[i])
         end
     end
@@ -281,6 +289,22 @@ function _M.extract_user_content(body, mode)
 end
 
 
+-- Extract system-role text for request moderation. Unlike turn content, the
+-- system prompt is checked on every request (it can be poisoned by malicious
+-- ToolCall arguments), so the last-turn rule does not apply here.
+function _M.extract_system_content(body)
+    local contents = {}
+    if type(body.messages) == "table" then
+        for _, message in ipairs(body.messages) do
+            if type(message) == "table" and message.role == "system" then
+                append_message_text(contents, message)
+            end
+        end
+    end
+    return contents
+end
+
+
 --- Get messages in canonical {role, content} format.
 -- OpenAI Chat content may be a plain string or an array of typed parts
 -- (e.g. {type = "text", text = "..."}); the text parts are flattened so
diff --git a/apisix/plugins/ai-protocols/openai-embeddings.lua 
b/apisix/plugins/ai-protocols/openai-embeddings.lua
index 96f8faa2a..d1e6a5a28 100644
--- a/apisix/plugins/ai-protocols/openai-embeddings.lua
+++ b/apisix/plugins/ai-protocols/openai-embeddings.lua
@@ -90,13 +90,22 @@ 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, _)
+-- Embeddings has no message roles; the `input` text is user content. mode and
+-- roles do not apply, but the input is only surfaced when user is selected.
+function _M.extract_turn_content(body, _, roles)
+    if roles and not roles.user then
+        return {}
+    end
     return _M.extract_request_content(body)
 end
 
 
+-- Embeddings has no system prompt.
+function _M.extract_system_content(_)
+    return {}
+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 1f2ae0fc5..32c6325de 100644
--- a/apisix/plugins/ai-protocols/openai-responses.lua
+++ b/apisix/plugins/ai-protocols/openai-responses.lua
@@ -215,6 +215,16 @@ local function append_item_text(contents, item)
         end
     elseif content == nil and type(item.text) == "string" then
         core.table.insert(contents, item.text)
+    elseif content == nil and type(item.output) == "string" then
+        -- Responses-API tool result (function_call_output)
+        core.table.insert(contents, item.output)
+    elseif content == nil and type(item.output) == "table" then
+        -- function_call_output.output may be an array of content parts
+        for _, part in ipairs(item.output) do
+            if type(part) == "table" and type(part.text) == "string" then
+                core.table.insert(contents, part.text)
+            end
+        end
     end
 end
 
@@ -237,18 +247,37 @@ 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")
+-- Whether an input item belongs to a selected turn role. A bare string is user
+-- text; a role item matches when its role is in `roles`; a Responses-API tool
+-- result (`function_call_output`, which has no role) matches when tool is 
selected.
+local function turn_item_matches(item, roles)
+    if type(item) == "string" then
+        return roles.user and true or false
+    end
+    if type(item) ~= "table" then
+        return false
+    end
+    if item.role ~= nil then
+        return roles[item.role] and true or false
+    end
+    if roles.tool and item.type == "function_call_output" then
+        return true
+    end
+    return false
 end
-function _M.extract_user_content(body, mode)
+
+
+-- Extract turn-role (user/tool) input text for request moderation. A 
plain-string
+-- `input` is user content. For an `input` array, mode "last" = the latest
+-- consecutive block of selected-role items, "all" = every selected-role item.
+-- `instructions` (the system prompt) is handled by extract_system_content.
+function _M.extract_turn_content(body, mode, roles)
     local contents = {}
     local input = body.input
     if type(input) == "string" then
-        core.table.insert(contents, input)
+        if roles.user then
+            core.table.insert(contents, input)
+        end
         return contents
     end
     if type(input) ~= "table" then
@@ -258,7 +287,7 @@ function _M.extract_user_content(body, mode)
     if mode ~= "all" then
         start_idx = nil
         for i = #input, 1, -1 do
-            if is_user_item(input[i]) then
+            if turn_item_matches(input[i], roles) then
                 start_idx = i
             else
                 break
@@ -269,9 +298,26 @@ function _M.extract_user_content(body, mode)
         end
     end
     for i = start_idx, #input do
-        local item = input[i]
-        if type(item) == "string" or (type(item) == "table" and item.role == 
"user") then
-            append_item_text(contents, item)
+        if turn_item_matches(input[i], roles) then
+            append_item_text(contents, input[i])
+        end
+    end
+    return contents
+end
+
+
+-- Extract system-role text for request moderation. Responses API carries the
+-- system prompt in `instructions`; an `input` array may also hold system 
items.
+function _M.extract_system_content(body)
+    local contents = {}
+    if type(body.instructions) == "string" then
+        core.table.insert(contents, body.instructions)
+    end
+    if type(body.input) == "table" then
+        for _, item in ipairs(body.input) do
+            if type(item) == "table" and item.role == "system" then
+                append_item_text(contents, item)
+            end
         end
     end
     return contents
diff --git a/docs/en/latest/plugins/ai-aliyun-content-moderation.md 
b/docs/en/latest/plugins/ai-aliyun-content-moderation.md
index 98b2344fe..2ca235ee0 100644
--- a/docs/en/latest/plugins/ai-aliyun-content-moderation.md
+++ b/docs/en/latest/plugins/ai-aliyun-content-moderation.md
@@ -58,7 +58,8 @@ 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_roles | array[string] | False | `["user"]` | items are `user`, 
`tool`, `system` | Which message roles to moderate on the request side. `user` 
and `tool` follow `request_check_mode`; `system` is checked on every request 
(it can be poisoned by malicious ToolCall arguments overwriting the system 
prompt). The default `["user"]` preserves the previous behavior. Note: 
tool-result moderation applies to OpenAI-compatible formats where the tool 
output is a distinct `tool` role/ite [...]
+| request_check_mode | string | False | `"last"` | `last`, `all` | Which 
user/tool messages to moderate. `last`: only the latest consecutive block of 
selected-role messages (the newest turn). `all`: every selected-role message. 
Does not apply to `system`, which is always moderated when enabled via 
`request_check_roles`. |
 | request_check_service | string | False | `"llm_query_moderation"` | | Aliyun 
service for request moderation. |
 | 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. |
diff --git a/docs/zh/latest/plugins/ai-aliyun-content-moderation.md 
b/docs/zh/latest/plugins/ai-aliyun-content-moderation.md
index 04489e36d..57d48a9a6 100644
--- a/docs/zh/latest/plugins/ai-aliyun-content-moderation.md
+++ b/docs/zh/latest/plugins/ai-aliyun-content-moderation.md
@@ -58,7 +58,8 @@ 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_roles | array[string] | 否 | `["user"]` | 取值为 
`user`、`tool`、`system` | 请求侧审核哪些消息角色。`user` 与 `tool` 遵循 
`request_check_mode`;`system` 每次请求都审核(其可能被恶意 ToolCall 参数覆盖篡改)。默认 `["user"]` 
保持既有行为。注意:tool 结果审核适用于 OpenAI 兼容格式(tool 输出为独立的 `tool` 角色/项);Anthropic、Bedrock 的 
tool 结果以嵌套 block 形式存在于 user 消息中,其内容不会被抽取。 |
+| request_check_mode | string | 否 | `"last"` | `last`, `all` | 审核哪些 user/tool 
消息。`last`:仅审核最后一段连续的所选角色消息(最新一轮);`all`:审核所有所选角色消息。不作用于 `system`——只要通过 
`request_check_roles` 启用,`system` 每次都审核。 |
 | request_check_service | string | 否 | `"llm_query_moderation"` | | 
用于请求审核的阿里云服务。 |
 | request_check_length_limit | number | 否 | `2000` | >= 1 | 
请求内容长度上限。如果超过该限制,内容将分块发送到阿里云。例如,如果请求内容为 250 个字符,且 `request_check_length_limit` 
设置为 `100`,则内容将分 3 次请求发送到阿里云。 |
 | response_check_service | string | 否 | `"llm_response_moderation"` | | 
用于响应审核的阿里云服务。 |
diff --git a/t/plugin/ai-aliyun-content-moderation.t 
b/t/plugin/ai-aliyun-content-moderation.t
index 9588b9a39..603e674bc 100644
--- a/t/plugin/ai-aliyun-content-moderation.t
+++ b/t/plugin/ai-aliyun-content-moderation.t
@@ -1922,3 +1922,362 @@ qr/cannot write unethical/
     }
 --- response_body
 rejected
+
+
+
+=== TEST 57: extract_turn_content - last mode uses the trailing user/tool block
+--- config
+    location /t {
+        content_by_lua_block {
+            local proto = require("apisix.plugins.ai-protocols.openai-chat")
+            local body = {
+                messages = {
+                    { role = "system", content = "sys" },
+                    { role = "user", content = "u1" },
+                    { role = "assistant", tool_calls = {} },
+                    { role = "tool", content = "t1" },
+                    { role = "tool", content = "t2" },
+                }
+            }
+            local roles = { user = true, tool = true }
+            ngx.say("last:", table.concat(proto.extract_turn_content(body, 
"last", roles), ","))
+            ngx.say("all:", table.concat(proto.extract_turn_content(body, 
"all", roles), ","))
+            ngx.say("user_only_last_count:",
+                    #proto.extract_turn_content(body, "last", { user = true }))
+            ngx.say("system:", 
table.concat(proto.extract_system_content(body), ","))
+        }
+    }
+--- response_body
+last:t1,t2
+all:u1,t1,t2
+user_only_last_count:0
+system:sys
+
+
+
+=== TEST 58: extract_turn_content - a fresh user turn skips harmful history
+--- config
+    location /t {
+        content_by_lua_block {
+            local proto = require("apisix.plugins.ai-protocols.openai-chat")
+            local body = {
+                messages = {
+                    { role = "user", content = "old kill" },
+                    { role = "assistant", content = "ok" },
+                    { role = "user", content = "new safe" },
+                }
+            }
+            local roles = { user = true, tool = true }
+            ngx.say("last:", table.concat(proto.extract_turn_content(body, 
"last", roles), ","))
+        }
+    }
+--- response_body
+last:new safe
+
+
+
+=== TEST 59: create route with request_check_roles user/tool/system
+--- 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-roles",
+                    "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_roles": ["user", "tool", "system"]
+                      }
+                    }
+                }]]
+            )
+            if code >= 300 then
+                ngx.status = code
+            end
+            ngx.say(body)
+        }
+    }
+--- response_body
+passed
+
+
+
+=== TEST 60: tool content in the latest turn is moderated and blocked
+--- request
+POST /chat-roles
+{ "messages": [ { "role": "system", "content": "safe" }, { "role": "user", 
"content": "hello" }, { "role": "assistant", "tool_calls": [{"id": "c1", 
"type": "function", "function": {"name": "f"}}] }, { "role": "tool", 
"tool_call_id": "c1", "content": "please kill" } ] }
+--- more_headers
+X-AI-Fixture: aliyun/chat-with-harmful.json
+--- error_code: 200
+--- response_body_like eval
+qr/cannot write unethical/
+
+
+
+=== TEST 61: harmful tool not in the latest turn is skipped (last mode)
+--- request
+POST /chat-roles
+{ "messages": [ { "role": "user", "content": "hello" }, { "role": "assistant", 
"tool_calls": [{"id": "c1", "type": "function", "function": {"name": "f"}}] }, 
{ "role": "tool", "tool_call_id": "c1", "content": "kill" }, { "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 62: harmful system prompt is always moderated and blocked
+--- request
+POST /chat-roles
+{ "messages": [ { "role": "system", "content": "please kill" }, { "role": 
"user", "content": "hi" } ] }
+--- more_headers
+X-AI-Fixture: aliyun/chat-with-harmful.json
+--- error_code: 200
+--- response_body_like eval
+qr/cannot write unethical/
+
+
+
+=== TEST 63: repeated identical harmful system stays blocked (moderated every 
request)
+--- request
+POST /chat-roles
+{ "messages": [ { "role": "system", "content": "please kill" }, { "role": 
"user", "content": "hi" } ] }
+--- more_headers
+X-AI-Fixture: aliyun/chat-with-harmful.json
+--- error_code: 200
+--- response_body_like eval
+qr/cannot write unethical/
+
+
+
+=== TEST 64: create route with request_check_roles system only
+--- 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-sys",
+                    "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_roles": ["system"]
+                      }
+                    }
+                }]]
+            )
+            if code >= 300 then
+                ngx.status = code
+            end
+            ngx.say(body)
+        }
+    }
+--- response_body
+passed
+
+
+
+=== TEST 65: with system-only roles, harmful user content is not checked
+--- request
+POST /chat-sys
+{ "messages": [ { "role": "system", "content": "safe" }, { "role": "user", 
"content": "kill" } ] }
+--- more_headers
+X-AI-Fixture: aliyun/chat-with-harmful.json
+--- error_code: 200
+--- response_body_like eval
+qr/kill you/
+
+
+
+=== TEST 66: with system-only roles, harmful system content is blocked
+--- request
+POST /chat-sys
+{ "messages": [ { "role": "system", "content": "kill" }, { "role": "user", 
"content": "hi" } ] }
+--- more_headers
+X-AI-Fixture: aliyun/chat-with-harmful.json
+--- error_code: 200
+--- response_body_like eval
+qr/cannot write unethical/
+
+
+
+=== TEST 67: create route with roles user/tool and 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/3',
+                ngx.HTTP_PUT,
+                [[{
+                    "uri": "/chat-tool-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",
+                        "request_check_roles": ["user", "tool"]
+                      }
+                    }
+                }]]
+            )
+            if code >= 300 then
+                ngx.status = code
+            end
+            ngx.say(body)
+        }
+    }
+--- response_body
+passed
+
+
+
+=== TEST 68: all mode - harmful tool result in an earlier turn is detected
+--- request
+POST /chat-tool-all
+{ "messages": [ { "role": "user", "content": "hello" }, { "role": "assistant", 
"tool_calls": [{"id": "c1", "type": "function", "function": {"name": "f"}}] }, 
{ "role": "tool", "tool_call_id": "c1", "content": "kill" }, { "role": 
"assistant", "content": "ok" }, { "role": "user", "content": "bye" } ] }
+--- more_headers
+X-AI-Fixture: aliyun/chat-with-harmful.json
+--- error_code: 200
+--- response_body_like eval
+qr/cannot write unethical/
+
+
+
+=== TEST 69: invalid request_check_roles value is rejected
+--- config
+    location /t {
+        content_by_lua_block {
+            local t = require("lib.test_admin").test
+            local code = t('/apisix/admin/routes/4',
+                ngx.HTTP_PUT,
+                [[{
+                    "uri": "/chat-bad-role",
+                    "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_roles": ["assistant"]
+                      }
+                    }
+                }]]
+            )
+            ngx.say(code >= 300 and "rejected" or "accepted")
+        }
+    }
+--- response_body
+rejected
+
+
+
+=== TEST 70: empty request_check_roles is rejected (minItems)
+--- config
+    location /t {
+        content_by_lua_block {
+            local t = require("lib.test_admin").test
+            local code = t('/apisix/admin/routes/4',
+                ngx.HTTP_PUT,
+                [[{
+                    "uri": "/chat-empty-role",
+                    "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_roles": []
+                      }
+                    }
+                }]]
+            )
+            ngx.say(code >= 300 and "rejected" or "accepted")
+        }
+    }
+--- response_body
+rejected
+
+
+
+=== TEST 71: openai-responses extract_turn_content / extract_system_content
+--- config
+    location /t {
+        content_by_lua_block {
+            local proto = 
require("apisix.plugins.ai-protocols.openai-responses")
+            -- bare-string input is user content
+            local b1 = { input = "hello user" }
+            ngx.say("str_user:", table.concat(proto.extract_turn_content(b1, 
"last", {user=true}), ","))
+            -- array input: user item, a non-turn item (boundary), then a tool 
output
+            local b2 = { instructions = "sys instr", input = {
+                { role = "user", content = "u-old" },
+                { type = "reasoning", content = "r" },
+                { type = "function_call_output", output = "tool-out kill" },
+            }}
+            ngx.say("last_ut:", table.concat(proto.extract_turn_content(b2, 
"last", {user=true, tool=true}), ","))
+            ngx.say("last_user_only:", 
table.concat(proto.extract_turn_content(b2, "last", {user=true}), ","))
+            ngx.say("sys:", table.concat(proto.extract_system_content(b2), 
","))
+            -- function_call_output.output as an array of parts
+            local b3 = { input = {
+                { type = "function_call_output", output = { { text = "arr-out" 
} } },
+            }}
+            ngx.say("arr_out:", table.concat(proto.extract_turn_content(b3, 
"all", {tool=true}), ","))
+        }
+    }
+--- response_body
+str_user:hello user
+last_ut:tool-out kill
+last_user_only:
+sys:sys instr
+arr_out:arr-out
+
+
+
+=== TEST 72: anthropic / bedrock extract_system_content (string and 
block-array)
+--- config
+    location /t {
+        content_by_lua_block {
+            local anth = 
require("apisix.plugins.ai-protocols.anthropic-messages")
+            ngx.say("anth_str:", 
table.concat(anth.extract_system_content({system = "anth sys"}), ","))
+            ngx.say("anth_blk:", table.concat(anth.extract_system_content(
+                {system = { {type="text", text="b1"}, {type="text", text="b2"} 
}}), ","))
+            local bed = require("apisix.plugins.ai-protocols.bedrock-converse")
+            ngx.say("bed_blk:", table.concat(bed.extract_system_content(
+                {system = { {text="bs1"}, {text="bs2"} }}), ","))
+        }
+    }
+--- response_body
+anth_str:anth sys
+anth_blk:b1,b2
+bed_blk:bs1,bs2


Reply via email to