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