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 11dfb1804 fix(ai-protocols): flatten structured message content in the 
protocol layer (#13634)
11dfb1804 is described below

commit 11dfb18046d4af07fd4227901adb1b179b80a686
Author: Nic <[email protected]>
AuthorDate: Thu Jul 2 17:32:10 2026 +0800

    fix(ai-protocols): flatten structured message content in the protocol layer 
(#13634)
---
 apisix/plugins/ai-lakera-guard.lua                 |  29 +--
 apisix/plugins/ai-protocols/anthropic-messages.lua |  23 +--
 apisix/plugins/ai-protocols/bedrock-converse.lua   |  80 +++-----
 apisix/plugins/ai-protocols/openai-chat.lua        |  21 ++-
 apisix/plugins/ai-protocols/openai-responses.lua   |  75 ++++----
 t/plugin/ai-prompt-guard.t                         | 208 +++++++++++++++++++++
 6 files changed, 309 insertions(+), 127 deletions(-)

diff --git a/apisix/plugins/ai-lakera-guard.lua 
b/apisix/plugins/ai-lakera-guard.lua
index 0399d5064..0f159625f 100644
--- a/apisix/plugins/ai-lakera-guard.lua
+++ b/apisix/plugins/ai-lakera-guard.lua
@@ -83,32 +83,13 @@ local function deny_message(ctx, conf, message, breakdown)
 end
 
 
--- Normalize a protocol's canonical {role, content} messages into the shape
--- Lakera /v2/guard accepts: role preserved, content coerced to a plain string.
--- Some adapters (e.g. openai-chat) return body.messages verbatim, so a 
message's
--- content can be a multimodal array or nil (tool-call turns); flatten the text
--- parts and drop messages that carry no text.
+-- get_messages returns canonical {role, content} with content already 
flattened
+-- to a string; drop turns without a role or with nothing for Lakera to scan.
 local function normalize_messages(messages)
     local out = {}
-    for _, message in ipairs(messages or {}) do
-        if type(message) == "table" and type(message.role) == "string" then
-            local content = message.content
-            local text
-            if type(content) == "string" then
-                text = content
-            elseif type(content) == "table" then
-                local parts = {}
-                for _, part in ipairs(content) do
-                    if type(part) == "table" and part.type == "text"
-                            and type(part.text) == "string" then
-                        core.table.insert(parts, part.text)
-                    end
-                end
-                text = concat(parts, " ")
-            end
-            if text and text ~= "" then
-                core.table.insert(out, { role = message.role, content = text })
-            end
+    for _, message in ipairs(messages) do
+        if type(message.role) == "string" and message.content ~= "" then
+            core.table.insert(out, message)
         end
     end
     return out
diff --git a/apisix/plugins/ai-protocols/anthropic-messages.lua 
b/apisix/plugins/ai-protocols/anthropic-messages.lua
index 5c9c286a9..7af1a0a7b 100644
--- a/apisix/plugins/ai-protocols/anthropic-messages.lua
+++ b/apisix/plugins/ai-protocols/anthropic-messages.lua
@@ -286,22 +286,13 @@ function _M.get_messages(body)
     end
     if type(body.messages) == "table" then
         for _, message in ipairs(body.messages) do
-            local content = message.content
-            if type(content) == "string" then
-                core.table.insert(messages, {role = message.role, content = 
content})
-            elseif type(content) == "table" then
-                local texts = {}
-                for _, block in ipairs(content) do
-                    if type(block) == "table" and block.type == "text" then
-                        core.table.insert(texts, block.text)
-                    end
-                end
-                if #texts > 0 then
-                    core.table.insert(messages, {
-                        role = message.role,
-                        content = table.concat(texts, " "),
-                    })
-                end
+            local texts = {}
+            append_message_text(texts, message)
+            if #texts > 0 then
+                core.table.insert(messages, {
+                    role = message.role,
+                    content = table.concat(texts, " "),
+                })
             end
         end
     end
diff --git a/apisix/plugins/ai-protocols/bedrock-converse.lua 
b/apisix/plugins/ai-protocols/bedrock-converse.lua
index 7fecd3efc..1b9f998bc 100644
--- a/apisix/plugins/ai-protocols/bedrock-converse.lua
+++ b/apisix/plugins/ai-protocols/bedrock-converse.lua
@@ -149,6 +149,19 @@ function _M.extract_usage(res_body)
 end
 
 
+-- Append the text of each Bedrock content block ({text = "..."}) into `texts`.
+local function append_block_texts(texts, blocks)
+    if type(blocks) ~= "table" then
+        return
+    end
+    for _, block in ipairs(blocks) do
+        if type(block) == "table" and type(block.text) == "string" then
+            core.table.insert(texts, block.text)
+        end
+    end
+end
+
+
 --- Extract response text from a Bedrock Converse response.
 -- Bedrock format: res_body.output.message.content[].text
 function _M.extract_response_text(res_body)
@@ -160,11 +173,7 @@ function _M.extract_response_text(res_body)
         return nil
     end
     local texts = {}
-    for _, block in ipairs(message.content) do
-        if type(block) == "table" and type(block.text) == "string" then
-            core.table.insert(texts, block.text)
-        end
-    end
+    append_block_texts(texts, message.content)
     if #texts > 0 then
         return table.concat(texts, " ")
     end
@@ -175,26 +184,12 @@ end
 --- Extract all text content from a request body for moderation.
 function _M.extract_request_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
+    append_block_texts(contents, body.system)
     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) == "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
+            if type(message) == "table" then
+                append_block_texts(contents, message.content)
             end
-            ::CONTINUE_MESSAGE::
         end
     end
     return contents
@@ -226,13 +221,8 @@ function _M.extract_user_content(body, mode)
     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
+        if type(message) == "table" and message.role == "user" then
+            append_block_texts(contents, message.content)
         end
     end
     return contents
@@ -243,32 +233,19 @@ end
 -- Bedrock content blocks [{text: "..."}] are flattened to plain text.
 function _M.get_messages(body)
     local messages = {}
-    if type(body.system) == "table" then
-        local texts = {}
-        for _, block in ipairs(body.system) do
-            if type(block) == "table" and type(block.text) == "string" then
-                core.table.insert(texts, block.text)
-            end
-        end
-        if #texts > 0 then
-            core.table.insert(messages, {
-                role = "system",
-                content = table.concat(texts, " "),
-            })
-        end
+    local system_texts = {}
+    append_block_texts(system_texts, body.system)
+    if #system_texts > 0 then
+        core.table.insert(messages, {
+            role = "system",
+            content = table.concat(system_texts, " "),
+        })
     end
     if type(body.messages) == "table" then
         for _, message in ipairs(body.messages) do
-            if type(message) ~= "table" then
-                goto CONTINUE
-            end
-            if type(message.content) == "table" then
+            if type(message) == "table" then
                 local texts = {}
-                for _, block in ipairs(message.content) do
-                    if type(block) == "table" and type(block.text) == "string" 
then
-                        core.table.insert(texts, block.text)
-                    end
-                end
+                append_block_texts(texts, message.content)
                 if #texts > 0 then
                     core.table.insert(messages, {
                         role = message.role,
@@ -276,7 +253,6 @@ function _M.get_messages(body)
                     })
                 end
             end
-            ::CONTINUE::
         end
     end
     return messages
diff --git a/apisix/plugins/ai-protocols/openai-chat.lua 
b/apisix/plugins/ai-protocols/openai-chat.lua
index ce54d736a..ce6a446b4 100644
--- a/apisix/plugins/ai-protocols/openai-chat.lua
+++ b/apisix/plugins/ai-protocols/openai-chat.lua
@@ -220,6 +220,9 @@ end
 
 -- Append a single message's text (string content or text parts) 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
@@ -279,8 +282,24 @@ 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
+-- consumers always receive string content, consistent with the other adapters.
 function _M.get_messages(body)
-    return body.messages or {}
+    local messages = {}
+    if type(body.messages) == "table" then
+        for _, message in ipairs(body.messages) do
+            local texts = {}
+            append_message_text(texts, message)
+            if #texts > 0 then
+                core.table.insert(messages, {
+                    role = message.role,
+                    content = table.concat(texts, " "),
+                })
+            end
+        end
+    end
+    return messages
 end
 
 
diff --git a/apisix/plugins/ai-protocols/openai-responses.lua 
b/apisix/plugins/ai-protocols/openai-responses.lua
index 8c263a0fb..1f2ae0fc5 100644
--- a/apisix/plugins/ai-protocols/openai-responses.lua
+++ b/apisix/plugins/ai-protocols/openai-responses.lua
@@ -193,6 +193,32 @@ function _M.extract_end_user_id(body)
 end
 
 
+-- Append an input item's text into `contents`. An item may be a plain string, 
a
+-- message object ({content = string | {parts}}), or a bare content part
+-- ({text = "..."}); text parts are flattened so consumers get plain strings.
+local function append_item_text(contents, item)
+    if type(item) == "string" then
+        core.table.insert(contents, item)
+        return
+    end
+    if type(item) ~= "table" then
+        return
+    end
+    local content = item.content
+    if type(content) == "string" then
+        core.table.insert(contents, content)
+    elseif type(content) == "table" then
+        for _, part in ipairs(content) do
+            if type(part) == "table" and type(part.text) == "string" then
+                core.table.insert(contents, part.text)
+            end
+        end
+    elseif content == nil and type(item.text) == "string" then
+        core.table.insert(contents, item.text)
+    end
+end
+
+
 --- Extract all text content from a request body for moderation.
 function _M.extract_request_content(body)
     local contents = {}
@@ -201,22 +227,10 @@ function _M.extract_request_content(body)
         core.table.insert(contents, input)
     elseif type(input) == "table" then
         for _, item in ipairs(input) do
-            if type(item) == "string" then
-                core.table.insert(contents, item)
-            elseif type(item) == "table" 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
+            append_item_text(contents, item)
         end
     end
-    if body.instructions then
+    if type(body.instructions) == "string" then
         core.table.insert(contents, body.instructions)
     end
     return contents
@@ -256,18 +270,8 @@ function _M.extract_user_content(body, mode)
     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
+        if type(item) == "string" or (type(item) == "table" and item.role == 
"user") then
+            append_item_text(contents, item)
         end
     end
     return contents
@@ -286,14 +290,17 @@ function _M.get_messages(body)
         core.table.insert(messages, {role = "user", content = input})
     elseif type(input) == "table" then
         for _, item in ipairs(input) do
-            if type(item) == "string" then
-                core.table.insert(messages, {role = "user", content = item})
-            elseif type(item) == "table" then
-                local role = item.role or "user"
-                local content = item.content or item.text
-                if type(content) == "string" then
-                    core.table.insert(messages, {role = role, content = 
content})
-                end
+            local role = "user"
+            if type(item) == "table" and type(item.role) == "string" then
+                role = item.role
+            end
+            local texts = {}
+            append_item_text(texts, item)
+            if #texts > 0 then
+                core.table.insert(messages, {
+                    role = role,
+                    content = table.concat(texts, " "),
+                })
             end
         end
     end
diff --git a/t/plugin/ai-prompt-guard.t b/t/plugin/ai-prompt-guard.t
index ae8d30142..cad3c1ea8 100644
--- a/t/plugin/ai-prompt-guard.t
+++ b/t/plugin/ai-prompt-guard.t
@@ -830,3 +830,211 @@ Content-Type: multipart/form-data
 hello world
 --- error_log
 ai-prompt-guard skipped
+
+
+
+=== TEST 36: setup route deny + match_all_roles + 
match_all_conversation_history
+--- 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": "/hello",
+                    "upstream": {
+                        "type": "roundrobin",
+                        "nodes": {
+                            "127.0.0.1:1980": 1
+                        }
+                    },
+                    "plugins": {
+                        "ai-prompt-guard": {
+                            "match_all_roles": true,
+                            "match_all_conversation_history": true,
+                            "deny_patterns": [
+                                "badword"
+                            ]
+                        }
+                    }
+            }]]
+            )
+
+        if code >= 300 then
+            ngx.status = code
+        end
+        ngx.say(body)
+    }
+}
+--- response_body
+passed
+
+
+
+=== TEST 37: structured array content in history with bad word is denied (no 
crash)
+--- request
+POST /hello
+{
+    "messages": [
+        { "role": "system", "content": "You are a test assistant." },
+        { "role": "assistant", "content": [ { "type": "text", "text": 
"badword" } ] },
+        { "role": "user", "content": "hello" }
+    ]
+}
+--- response_body
+{"message":"Request contains prohibited content"}
+--- error_code: 400
+
+
+
+=== TEST 38: structured array content in history with good word passes through 
(no crash)
+--- request
+POST /hello
+{
+    "messages": [
+        { "role": "system", "content": "You are a test assistant." },
+        { "role": "assistant", "content": [ { "type": "text", "text": 
"goodword" } ] },
+        { "role": "user", "content": "hello" }
+    ]
+}
+--- error_code: 200
+--- response_body
+hello world
+
+
+
+=== TEST 39: structured array content mixing text and non-text parts is scanned
+--- request
+POST /hello
+{
+    "messages": [
+        { "role": "user", "content": [
+            { "type": "image_url", "image_url": { "url": 
"https://example.com/a.png"; } },
+            { "type": "text", "text": "badword" }
+        ] }
+    ]
+}
+--- response_body
+{"message":"Request contains prohibited content"}
+--- error_code: 400
+
+
+
+=== TEST 40: setup route deny + default match modes (latest user message only)
+--- 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": "/hello",
+                    "upstream": {
+                        "type": "roundrobin",
+                        "nodes": {
+                            "127.0.0.1:1980": 1
+                        }
+                    },
+                    "plugins": {
+                        "ai-prompt-guard": {
+                            "deny_patterns": [
+                                "badword"
+                            ]
+                        }
+                    }
+            }]]
+            )
+
+        if code >= 300 then
+            ngx.status = code
+        end
+        ngx.say(body)
+    }
+}
+--- response_body
+passed
+
+
+
+=== TEST 41: structured array content in latest user message is denied (no 
crash)
+--- request
+POST /hello
+{
+    "messages": [
+        { "role": "user", "content": [ { "type": "text", "text": "badword" } ] 
}
+    ]
+}
+--- response_body
+{"message":"Request contains prohibited content"}
+--- error_code: 400
+
+
+
+=== TEST 42: deny word in a non-text part is not scanned, only text parts are
+--- request
+POST /hello
+{
+    "messages": [
+        { "role": "user", "content": [
+            { "type": "image_url", "image_url": { "url": 
"https://example.com/badword.png"; } },
+            { "type": "text", "text": "safe text" }
+        ] }
+    ]
+}
+--- error_code: 200
+--- response_body
+hello world
+
+
+
+=== TEST 43: Responses API - setup route with deny pattern
+--- 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,
+                [[{
+                    "uris": ["/hello", "/v1/responses"],
+                    "upstream": {
+                        "type": "roundrobin",
+                        "nodes": {
+                            "127.0.0.1:1980": 1
+                        }
+                    },
+                    "plugins": {
+                        "ai-prompt-guard": {
+                            "match_all_roles": true,
+                            "deny_patterns": [
+                                "badword"
+                            ]
+                        }
+                    }
+            }]]
+            )
+
+        if code >= 300 then
+            ngx.status = code
+        end
+        ngx.say(body)
+    }
+}
+--- response_body
+passed
+
+
+
+=== TEST 44: Responses API - structured array content parts are flattened and 
scanned
+--- request
+POST /v1/responses
+{
+    "model": "gpt-4o",
+    "input": [
+        { "type": "message", "role": "user", "content": [
+            { "type": "input_text", "text": "badword here" }
+        ] }
+    ]
+}
+--- response_body
+{"message":"Request contains prohibited content"}
+--- error_code: 400

Reply via email to