This is an automated email from the ASF dual-hosted git repository.
guoqqqi 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 ef7135862e fix(ai-prompt-guard): check embeddings string arrays before
forwarding (#14073)
ef7135862e is described below
commit ef7135862e809baa1ee14a5e552f774a349dd9d6
Author: ruanweihong-aaa <[email protected]>
AuthorDate: Thu Oct 29 17:48:18 2026 +0800
fix(ai-prompt-guard): check embeddings string arrays before forwarding
(#14073)
---
apisix/plugins/ai-prompt-guard.lua | 8 +-
apisix/plugins/ai-protocols/openai-embeddings.lua | 6 +-
docs/en/latest/plugins/ai-prompt-guard.md | 4 +
docs/zh/latest/plugins/ai-prompt-guard.md | 4 +
t/plugin/ai-prompt-guard-embeddings.t | 258 ++++++++++++++++++++++
5 files changed, 272 insertions(+), 8 deletions(-)
diff --git a/apisix/plugins/ai-prompt-guard.lua
b/apisix/plugins/ai-prompt-guard.lua
index 909c6bf046..99e97b3fdc 100644
--- a/apisix/plugins/ai-prompt-guard.lua
+++ b/apisix/plugins/ai-prompt-guard.lua
@@ -144,9 +144,9 @@ function _M.access(conf, ctx)
local messages = protocols.get_messages(json_body, ctx)
- -- Responses API: instructions + input are parallel fields, not
conversation history,
- -- so skip the "last message only" filtering of get_content_to_check.
- if proto_name ~= "openai-responses" then
+ -- Responses fields and Embeddings batch items are not conversation
history,
+ -- so check all of them instead of keeping only the last message.
+ if proto_name ~= "openai-responses" and proto_name ~= "openai-embeddings"
then
messages = get_content_to_check(conf, messages)
end
if not conf.match_all_roles then
@@ -160,7 +160,7 @@ function _M.access(conf, ctx)
messages = new_messages
end
if #messages == 0 then --nothing to check
- return 200
+ return
end
-- extract only messages
local content = {}
diff --git a/apisix/plugins/ai-protocols/openai-embeddings.lua
b/apisix/plugins/ai-protocols/openai-embeddings.lua
index d1e6a5a283..b3eaa9ba3c 100644
--- a/apisix/plugins/ai-protocols/openai-embeddings.lua
+++ b/apisix/plugins/ai-protocols/openai-embeddings.lua
@@ -108,10 +108,8 @@ end
function _M.get_messages(body)
local messages = {}
- if body and body.input then
- if type(body.input) == "string" then
- core.table.insert(messages, {role = "user", content = body.input})
- end
+ for i, content in ipairs(_M.extract_request_content(body)) do
+ messages[i] = {role = "user", content = content}
end
return messages
end
diff --git a/docs/en/latest/plugins/ai-prompt-guard.md
b/docs/en/latest/plugins/ai-prompt-guard.md
index 7950fd3a5e..7cf96a0910 100644
--- a/docs/en/latest/plugins/ai-prompt-guard.md
+++ b/docs/en/latest/plugins/ai-prompt-guard.md
@@ -40,6 +40,10 @@ The `ai-prompt-guard` Plugin safeguards your LLM endpoints
by inspecting and val
When both `allow_patterns` and `deny_patterns` are configured, the Plugin
first ensures that at least one `allow_patterns` is matched. If none match, the
request is rejected. If an allowed pattern is matched, it then checks for any
occurrences of denied patterns.
+For Embeddings requests, the Plugin checks a string `input` or all strings in
an `input` array. Batch items are joined with spaces and checked together,
regardless of `match_all_conversation_history`. Allow patterns apply to this
combined text; a denied pattern anywhere in the batch rejects the request.
+
+When no content is selected for inspection, the request continues upstream.
This includes messages excluded by role filtering and Embeddings inputs
containing only token IDs, which the Plugin does not decode into text.
`fail_mode: "error"` applies to unrecognized protocols; it does not reject
recognized requests solely because no text was selected.
+
## Plugin Attributes
| Name | Type | Required | Default | Valid values | Description |
diff --git a/docs/zh/latest/plugins/ai-prompt-guard.md
b/docs/zh/latest/plugins/ai-prompt-guard.md
index 80e177b0a7..672f9e6db5 100644
--- a/docs/zh/latest/plugins/ai-prompt-guard.md
+++ b/docs/zh/latest/plugins/ai-prompt-guard.md
@@ -40,6 +40,10 @@ import TabItem from '@theme/TabItem';
当同时配置了 `allow_patterns` 和 `deny_patterns` 时,插件首先确保至少匹配一个
`allow_patterns`。如果没有匹配,请求将被拒绝。如果匹配了允许的模式,它会继续检查是否存在任何拒绝模式的匹配。
+对于 Embeddings 请求,插件会检查字符串形式的 `input`,或 `input` 数组中的所有字符串。批量输入会用空格连接后一起检查,不受
`match_all_conversation_history` 影响。允许模式作用于连接后的文本;批量输入中任何位置匹配拒绝模式都会导致请求被拒绝。
+
+当没有选中需要检查的内容时,请求会继续转发到上游。这包括被角色过滤排除的消息,以及仅包含 token ID 的 Embeddings 输入;插件不会将
token ID 解码为文本。`fail_mode: "error"` 适用于未识别的协议,不会仅因已识别请求没有选中文本而拒绝请求。
+
## 插件属性
| 名称 | 类型 | 必选项 | 默认值 | 有效值 | 描述 |
diff --git a/t/plugin/ai-prompt-guard-embeddings.t
b/t/plugin/ai-prompt-guard-embeddings.t
new file mode 100644
index 0000000000..04a6401fab
--- /dev/null
+++ b/t/plugin/ai-prompt-guard-embeddings.t
@@ -0,0 +1,258 @@
+
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+
+BEGIN {
+ $ENV{TEST_ENABLE_CONTROL_API_V1} = "0";
+}
+
+use t::APISIX 'no_plan';
+
+add_block_preprocessor(sub {
+ my ($block) = @_;
+
+ if (!$block->request) {
+ $block->set_value("request", "GET /t");
+ }
+
+ if (!$block->no_error_log) {
+ $block->set_value("no_error_log", "[error]\n[alert]");
+ }
+});
+
+run_tests;
+
+__DATA__
+
+=== TEST 1: setup embeddings guard route
+--- 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 2: reject a denied first batch item
+--- request
+POST /hello
+{"model": "text-embedding-3-small", "input": ["badword", "safe"]}
+--- error_code: 400
+--- response_body
+{"message":"Request contains prohibited content"}
+
+
+
+=== TEST 3: reject a denied last batch item
+--- request
+POST /hello
+{"model": "text-embedding-3-small", "input": ["safe", "badword"]}
+--- error_code: 400
+--- response_body
+{"message":"Request contains prohibited content"}
+
+
+
+=== TEST 4: forward a safe string batch
+--- request
+POST /hello
+{"model": "text-embedding-3-small", "input": ["safe", "other"]}
+--- error_code: 200
+--- response_body
+hello world
+
+
+
+=== TEST 5: forward token IDs without text inspection
+--- request
+POST /hello
+{"model": "text-embedding-3-small", "input": [123, 456]}
+--- error_code: 200
+--- response_body
+hello world
+
+
+
+=== TEST 6: forward intentionally unselected system content
+--- request
+POST /hello
+{"messages":[{"role":"system","content":"badword"}]}
+--- error_code: 200
+--- response_body
+hello world
+
+
+
+=== TEST 7: enable strict options
+--- 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"],
+ "match_all_roles": true,
+ "match_all_conversation_history": true,
+ "fail_mode": "error"
+ }
+ }
+ }]])
+ if code >= 300 then
+ ngx.status = code
+ end
+ ngx.say(body)
+ }
+ }
+--- response_body
+passed
+
+
+
+=== TEST 8: reject the first denied item with strict options
+--- request
+POST /hello
+{"model": "text-embedding-3-small", "input": ["badword", "safe"]}
+--- error_code: 400
+--- response_body
+{"message":"Request contains prohibited content"}
+
+
+
+=== TEST 9: forward safe batch with strict options
+--- request
+POST /hello
+{"model": "text-embedding-3-small", "input": ["safe", "other"]}
+--- error_code: 200
+--- response_body
+hello world
+
+
+
+=== TEST 10: setup allow and deny patterns
+--- 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": {
+ "allow_patterns": ["goodword"],
+ "deny_patterns": ["badword"]
+ }
+ }
+ }]])
+ if code >= 300 then
+ ngx.status = code
+ end
+ ngx.say(body)
+ }
+ }
+--- response_body
+passed
+
+
+
+=== TEST 11: allow pattern can match the first batch item
+--- request
+POST /hello
+{"model": "text-embedding-3-small", "input": ["goodword", "safe"]}
+--- error_code: 200
+--- response_body
+hello world
+
+
+
+=== TEST 12: reject batch without an allow match
+--- request
+POST /hello
+{"model": "text-embedding-3-small", "input": ["safe", "other"]}
+--- error_code: 400
+--- response_body
+{"message":"Request doesn't match allow patterns"}
+
+
+
+=== TEST 13: deny match rejects an otherwise allowed batch
+--- request
+POST /hello
+{"model": "text-embedding-3-small", "input": ["goodword", "badword"]}
+--- error_code: 400
+--- response_body
+{"message":"Request contains prohibited content"}
+
+
+
+=== TEST 14: embeddings adapter normalizes text inputs without changing the
request
+--- config
+ location /t {
+ content_by_lua_block {
+ local core = require("apisix.core")
+ local proto =
require("apisix.plugins.ai-protocols.openai-embeddings")
+ local cases = {
+ {input = "hello", expected = {"hello"}},
+ {input = {"first", "last"}, expected = {"first", "last"}},
+ {input = {"", "last"}, expected = {"", "last"}},
+ {input = {123, 456}, expected = {}},
+ {input = {{123}, {456}}, expected = {}},
+ {input = {}, expected = {}},
+ }
+ for _, case in ipairs(cases) do
+ local body = {input = case.input}
+ local before = core.json.encode(body)
+ local messages = proto.get_messages(body)
+ assert(#messages == #case.expected)
+ for i, text in ipairs(case.expected) do
+ assert(messages[i].role == "user")
+ assert(messages[i].content == text)
+ end
+ assert(core.json.encode(body) == before)
+ end
+ assert(#proto.get_messages(nil) == 0)
+ ngx.say("passed")
+ }
+ }
+--- response_body
+passed