From da441703d1b9311413e50a45e5291a35f8a0777d Mon Sep 17 00:00:00 2001 From: ruanweihong-aaa Date: Fri, 23 Oct 2026 13:09:03 +0800 Subject: [PATCH 1/4] fix(ai-prompt-guard): check embeddings string arrays before forwarding --- apisix/plugins/ai-prompt-guard.lua | 16 +- 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 | 284 ++++++++++++++++++++++ 4 files changed, 306 insertions(+), 2 deletions(-) create mode 100644 t/plugin/ai-prompt-guard-embeddings.t diff --git a/apisix/plugins/ai-prompt-guard.lua b/apisix/plugins/ai-prompt-guard.lua index 909c6bf0463c..eb710c61a1d3 100644 --- a/apisix/plugins/ai-prompt-guard.lua +++ b/apisix/plugins/ai-prompt-guard.lua @@ -142,7 +142,19 @@ function _M.access(conf, ctx) return end - local messages = protocols.get_messages(json_body, ctx) + local messages + if proto_name == "openai-embeddings" then + -- A batch contains independent inputs, not conversation history. Keep + -- all its text in one user message so the last-message filter below + -- cannot discard earlier inputs. + local contents = protocols.get(proto_name).extract_request_content(json_body) + messages = {} + if #contents > 0 then + messages[1] = {role = "user", content = table.concat(contents, " ")} + end + else + messages = protocols.get_messages(json_body, ctx) + end -- Responses API: instructions + input are parallel fields, not conversation history, -- so skip the "last message only" filtering of get_content_to_check. @@ -160,7 +172,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/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..05abc2d685c4 --- /dev/null +++ b/t/plugin/ai-prompt-guard-embeddings.t @@ -0,0 +1,284 @@ + +# +# 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 string +--- request +POST /hello +{"model": "text-embedding-3-small", "input": "badword"} +--- error_code: 400 +--- response_body +{"message":"Request contains prohibited content"} + + + +=== TEST 3: forward a safe string +--- request +POST /hello +{"model": "text-embedding-3-small", "input": "safe"} +--- error_code: 200 +--- response_body +hello world + + + +=== TEST 4: reject a single denied batch item +--- request +POST /hello +{"model": "text-embedding-3-small", "input": ["badword"]} +--- error_code: 400 +--- response_body +{"message":"Request contains prohibited content"} + + + +=== TEST 5: 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 6: 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 7: forward a safe string batch +--- request +POST /hello +{"model": "text-embedding-3-small", "input": ["safe", "other"]} +--- error_code: 200 +--- response_body +hello world + + + +=== TEST 8: 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 9: forward token batches without text inspection +--- request +POST /hello +{"model": "text-embedding-3-small", "input": [[123], [456]]} +--- error_code: 200 +--- response_body +hello world + + + +=== TEST 10: forward an empty batch +--- request +POST /hello +{"model": "text-embedding-3-small", "input": []} +--- error_code: 200 +--- response_body +hello world + + + +=== TEST 11: forward intentionally unselected system content +--- request +POST /hello +{"messages":[{"role":"system","content":"badword"}]} +--- error_code: 200 +--- response_body +hello world + + + +=== TEST 12: 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 13: 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 14: 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 15: reject unrecognized protocol with fail_mode error +--- request +POST /hello +{"foo": "bar"} +--- error_code: 400 +--- response_body +{"message":"Request format not recognized by ai-prompt-guard"} + + + +=== TEST 16: 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 17: 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 18: 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 19: 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"} From ca9f3334f1cd8d838108c9cca0875c489b3f1f79 Mon Sep 17 00:00:00 2001 From: ruanweihong-aaa Date: Fri, 23 Oct 2026 13:15:32 +0800 Subject: [PATCH 2/4] test(ai-prompt-guard): trim redundant embeddings regression cases --- t/plugin/ai-prompt-guard-embeddings.t | 84 ++++----------------------- 1 file changed, 12 insertions(+), 72 deletions(-) diff --git a/t/plugin/ai-prompt-guard-embeddings.t b/t/plugin/ai-prompt-guard-embeddings.t index 05abc2d685c4..03088be0c83b 100644 --- a/t/plugin/ai-prompt-guard-embeddings.t +++ b/t/plugin/ai-prompt-guard-embeddings.t @@ -65,37 +65,7 @@ passed -=== TEST 2: reject a denied string ---- request -POST /hello -{"model": "text-embedding-3-small", "input": "badword"} ---- error_code: 400 ---- response_body -{"message":"Request contains prohibited content"} - - - -=== TEST 3: forward a safe string ---- request -POST /hello -{"model": "text-embedding-3-small", "input": "safe"} ---- error_code: 200 ---- response_body -hello world - - - -=== TEST 4: reject a single denied batch item ---- request -POST /hello -{"model": "text-embedding-3-small", "input": ["badword"]} ---- error_code: 400 ---- response_body -{"message":"Request contains prohibited content"} - - - -=== TEST 5: reject a denied first batch item +=== TEST 2: reject a denied first batch item --- request POST /hello {"model": "text-embedding-3-small", "input": ["badword", "safe"]} @@ -105,7 +75,7 @@ POST /hello -=== TEST 6: reject a denied last batch item +=== TEST 3: reject a denied last batch item --- request POST /hello {"model": "text-embedding-3-small", "input": ["safe", "badword"]} @@ -115,7 +85,7 @@ POST /hello -=== TEST 7: forward a safe string batch +=== TEST 4: forward a safe string batch --- request POST /hello {"model": "text-embedding-3-small", "input": ["safe", "other"]} @@ -125,7 +95,7 @@ hello world -=== TEST 8: forward token IDs without text inspection +=== TEST 5: forward token IDs without text inspection --- request POST /hello {"model": "text-embedding-3-small", "input": [123, 456]} @@ -135,27 +105,7 @@ hello world -=== TEST 9: forward token batches without text inspection ---- request -POST /hello -{"model": "text-embedding-3-small", "input": [[123], [456]]} ---- error_code: 200 ---- response_body -hello world - - - -=== TEST 10: forward an empty batch ---- request -POST /hello -{"model": "text-embedding-3-small", "input": []} ---- error_code: 200 ---- response_body -hello world - - - -=== TEST 11: forward intentionally unselected system content +=== TEST 6: forward intentionally unselected system content --- request POST /hello {"messages":[{"role":"system","content":"badword"}]} @@ -165,7 +115,7 @@ hello world -=== TEST 12: enable strict options +=== TEST 7: enable strict options --- config location /t { content_by_lua_block { @@ -196,7 +146,7 @@ passed -=== TEST 13: reject the first denied item with strict options +=== TEST 8: reject the first denied item with strict options --- request POST /hello {"model": "text-embedding-3-small", "input": ["badword", "safe"]} @@ -206,7 +156,7 @@ POST /hello -=== TEST 14: forward safe batch with strict options +=== TEST 9: forward safe batch with strict options --- request POST /hello {"model": "text-embedding-3-small", "input": ["safe", "other"]} @@ -216,17 +166,7 @@ hello world -=== TEST 15: reject unrecognized protocol with fail_mode error ---- request -POST /hello -{"foo": "bar"} ---- error_code: 400 ---- response_body -{"message":"Request format not recognized by ai-prompt-guard"} - - - -=== TEST 16: setup allow and deny patterns +=== TEST 10: setup allow and deny patterns --- config location /t { content_by_lua_block { @@ -255,7 +195,7 @@ passed -=== TEST 17: allow pattern can match the first batch item +=== TEST 11: allow pattern can match the first batch item --- request POST /hello {"model": "text-embedding-3-small", "input": ["goodword", "safe"]} @@ -265,7 +205,7 @@ hello world -=== TEST 18: reject batch without an allow match +=== TEST 12: reject batch without an allow match --- request POST /hello {"model": "text-embedding-3-small", "input": ["safe", "other"]} @@ -275,7 +215,7 @@ POST /hello -=== TEST 19: deny match rejects an otherwise allowed batch +=== TEST 13: deny match rejects an otherwise allowed batch --- request POST /hello {"model": "text-embedding-3-small", "input": ["goodword", "badword"]} From 8aa196bb36a51c6c8af40545103bced049fd9180 Mon Sep 17 00:00:00 2001 From: ruanweihong-aaa Date: Wed, 28 Oct 2026 10:12:09 +0800 Subject: [PATCH 3/4] refactor(ai-prompt-guard): share history filtering for parallel inputs --- apisix/plugins/ai-prompt-guard.lua | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/apisix/plugins/ai-prompt-guard.lua b/apisix/plugins/ai-prompt-guard.lua index eb710c61a1d3..6da88c322dca 100644 --- a/apisix/plugins/ai-prompt-guard.lua +++ b/apisix/plugins/ai-prompt-guard.lua @@ -144,21 +144,18 @@ function _M.access(conf, ctx) local messages if proto_name == "openai-embeddings" then - -- A batch contains independent inputs, not conversation history. Keep - -- all its text in one user message so the last-message filter below - -- cannot discard earlier inputs. local contents = protocols.get(proto_name).extract_request_content(json_body) messages = {} - if #contents > 0 then - messages[1] = {role = "user", content = table.concat(contents, " ")} + for i, content in ipairs(contents) do + messages[i] = {role = "user", content = content} end else messages = protocols.get_messages(json_body, ctx) end - -- 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 From 98625024dde3be6df7f3ac33b0861f47a3ced787 Mon Sep 17 00:00:00 2001 From: ruanweihong-aaa Date: Thu, 29 Oct 2026 12:02:06 +0800 Subject: [PATCH 4/4] fix(ai): normalize embeddings arrays in the protocol adapter --- apisix/plugins/ai-prompt-guard.lua | 11 +----- .../ai-protocols/openai-embeddings.lua | 6 ++-- t/plugin/ai-prompt-guard-embeddings.t | 34 +++++++++++++++++++ 3 files changed, 37 insertions(+), 14 deletions(-) diff --git a/apisix/plugins/ai-prompt-guard.lua b/apisix/plugins/ai-prompt-guard.lua index 6da88c322dca..99e97b3fdc9c 100644 --- a/apisix/plugins/ai-prompt-guard.lua +++ b/apisix/plugins/ai-prompt-guard.lua @@ -142,16 +142,7 @@ function _M.access(conf, ctx) return end - local messages - if proto_name == "openai-embeddings" then - local contents = protocols.get(proto_name).extract_request_content(json_body) - messages = {} - for i, content in ipairs(contents) do - messages[i] = {role = "user", content = content} - end - else - messages = protocols.get_messages(json_body, ctx) - end + local messages = protocols.get_messages(json_body, ctx) -- Responses fields and Embeddings batch items are not conversation history, -- so check all of them instead of keeping only the last message. 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/t/plugin/ai-prompt-guard-embeddings.t b/t/plugin/ai-prompt-guard-embeddings.t index 03088be0c83b..04a6401fabac 100644 --- a/t/plugin/ai-prompt-guard-embeddings.t +++ b/t/plugin/ai-prompt-guard-embeddings.t @@ -222,3 +222,37 @@ POST /hello --- 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