Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions apisix/plugins/ai-prompt-guard.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 = {}
Expand Down
6 changes: 2 additions & 4 deletions apisix/plugins/ai-protocols/openai-embeddings.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions docs/en/latest/plugins/ai-prompt-guard.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
4 changes: 4 additions & 0 deletions docs/zh/latest/plugins/ai-prompt-guard.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"` 适用于未识别的协议,不会仅因已识别请求没有选中文本而拒绝请求。

## 插件属性

| 名称 | 类型 | 必选项 | 默认值 | 有效值 | 描述 |
Expand Down
258 changes: 258 additions & 0 deletions t/plugin/ai-prompt-guard-embeddings.t
Original file line number Diff line number Diff line change
@@ -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
Loading