diff --git a/apisix/plugins/ai-prompt-guard.lua b/apisix/plugins/ai-prompt-guard.lua index 909c6bf0463c..99e97b3fdc9c 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 d1e6a5a283eb..b3eaa9ba3cdb 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 7950fd3a5eda..7cf96a0910d5 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 80e177b0a787..672f9e6db5c9 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 000000000000..04a6401fabac --- /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