This is an automated email from the ASF dual-hosted git repository.
AlinsRan 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 b50930158 feat(ai-plugins): add fail_mode for Consumer-bound protocol
handling (#13489)
b50930158 is described below
commit b50930158c4d3515a8b02edb1748390fc5c5b333
Author: AlinsRan <[email protected]>
AuthorDate: Thu Jun 11 13:23:01 2026 +0800
feat(ai-plugins): add fail_mode for Consumer-bound protocol handling
(#13489)
---
apisix/plugins/ai-aliyun-content-moderation.lua | 34 ++++-
apisix/plugins/ai-aws-content-moderation.lua | 21 ++-
apisix/plugins/ai-prompt-guard.lua | 29 +++-
apisix/plugins/ai-protocols/binding.lua | 94 +++++++++++++
.../latest/plugins/ai-aliyun-content-moderation.md | 1 +
.../en/latest/plugins/ai-aws-content-moderation.md | 1 +
docs/en/latest/plugins/ai-prompt-guard.md | 1 +
.../latest/plugins/ai-aliyun-content-moderation.md | 1 +
.../zh/latest/plugins/ai-aws-content-moderation.md | 1 +
docs/zh/latest/plugins/ai-prompt-guard.md | 1 +
t/plugin/ai-aliyun-content-moderation.t | 56 +++++++-
t/plugin/ai-aws-content-moderation.t | 103 ++++++++++++++
t/plugin/ai-prompt-guard.t | 150 +++++++++++++++++++++
13 files changed, 485 insertions(+), 8 deletions(-)
diff --git a/apisix/plugins/ai-aliyun-content-moderation.lua
b/apisix/plugins/ai-aliyun-content-moderation.lua
index 49cc6c9e1..309af2b2d 100644
--- a/apisix/plugins/ai-aliyun-content-moderation.lua
+++ b/apisix/plugins/ai-aliyun-content-moderation.lua
@@ -28,6 +28,7 @@ local core = require("apisix.core")
local http = require("resty.http")
local uuid = require("resty.jit-uuid")
local protocols = require("apisix.plugins.ai-protocols")
+local binding = require("apisix.plugins.ai-protocols.binding")
local sse = require("apisix.plugins.ai-transport.sse")
local schema = {
@@ -57,6 +58,7 @@ local schema = {
region_id = {type ="string", minLength = 1},
access_key_id = {type = "string", minLength = 1},
access_key_secret = {type ="string", minLength = 1},
+ fail_mode = binding.schema_property("skip"),
check_request = {type = "boolean", default = true},
check_response = {type = "boolean", default = false},
request_check_service = {type = "string", minLength = 1, default =
"llm_query_moderation"},
@@ -305,17 +307,34 @@ end
function _M.access(conf, ctx)
if not ctx.picked_ai_instance then
- return 500, "no ai instance picked, " ..
+ local handled, code, body = binding.on_unsupported(
+ conf.fail_mode, _M.name, ctx,
+ "no ai instance picked (request did not pass through
ai-proxy/ai-proxy-multi)",
+ 500, "no ai instance picked, " ..
"ai-aliyun-content-moderation plugin must be used with " ..
- "ai-proxy or ai-proxy-multi plugin"
+ "ai-proxy or ai-proxy-multi plugin")
+ if handled then
+ return code, body
+ end
+ return
end
if not conf.check_request then
core.log.info("skip request check for this request")
return
end
local ct = core.request.header(ctx, "Content-Type")
+ -- media types are case-insensitive, normalize before matching
+ ct = ct and ct:lower()
if ct and not core.string.has_prefix(ct, "application/json") then
- return 400, "unsupported content-type: " .. ct .. ", only
application/json is supported"
+ local handled, code, body = binding.on_unsupported(
+ conf.fail_mode, _M.name, ctx,
+ "unsupported content-type: " .. ct,
+ 400, "unsupported content-type: " .. ct
+ .. ", only application/json is supported")
+ if handled then
+ return code, body
+ end
+ return
end
local request_tab, err = core.request.get_json_request_body_table()
if not request_tab then
@@ -324,7 +343,14 @@ function _M.access(conf, ctx)
local proto = protocols.get(ctx.ai_client_protocol)
if not proto or not proto.extract_request_content then
- return 500, "unsupported protocol: " .. (ctx.ai_client_protocol or
"unknown")
+ local handled, code, body = binding.on_unsupported(
+ conf.fail_mode, _M.name, ctx,
+ "unsupported protocol: " .. (ctx.ai_client_protocol or "unknown"),
+ 500, "unsupported protocol: " .. (ctx.ai_client_protocol or
"unknown"))
+ if handled then
+ return code, body
+ end
+ return
end
local contents = proto.extract_request_content(request_tab)
diff --git a/apisix/plugins/ai-aws-content-moderation.lua
b/apisix/plugins/ai-aws-content-moderation.lua
index 0fe62e231..a3a1295c6 100644
--- a/apisix/plugins/ai-aws-content-moderation.lua
+++ b/apisix/plugins/ai-aws-content-moderation.lua
@@ -17,6 +17,7 @@
require("resty.aws.config") -- to read env vars before initing aws module
local core = require("apisix.core")
+local binding = require("apisix.plugins.ai-protocols.binding")
local aws = require("resty.aws")
local aws_instance
@@ -67,7 +68,8 @@ local schema = {
minimum = 0,
maximum = 1,
default = 0.5
- }
+ },
+ fail_mode = binding.schema_property("skip"),
},
encrypt_fields = { "comprehend.secret_access_key" },
required = { "comprehend" },
@@ -88,6 +90,23 @@ end
function _M.rewrite(conf, ctx)
+ -- Consumer-bound moderation may receive non-AI traffic (e.g.
multipart/binary
+ -- uploads) whose body can't be moderated as text. Govern that via
fail_mode.
+ local ct = core.request.header(ctx, "Content-Type")
+ -- media types are case-insensitive, normalize before matching
+ ct = ct and ct:lower()
+ if ct and not core.string.has_prefix(ct, "application/json") then
+ local handled, code, resp = binding.on_unsupported(
+ conf.fail_mode, _M.name, ctx,
+ "unsupported content-type: " .. ct,
+ HTTP_BAD_REQUEST, "unsupported content-type: " .. ct
+ .. ", only application/json is supported")
+ if handled then
+ return code, resp
+ end
+ return
+ end
+
local body, err = core.request.get_body()
if not body then
return HTTP_BAD_REQUEST, err
diff --git a/apisix/plugins/ai-prompt-guard.lua
b/apisix/plugins/ai-prompt-guard.lua
index fbeac979b..56cec2f8a 100644
--- a/apisix/plugins/ai-prompt-guard.lua
+++ b/apisix/plugins/ai-prompt-guard.lua
@@ -16,6 +16,7 @@
--
local core = require("apisix.core")
local protocols = require("apisix.plugins.ai-protocols")
+local binding = require("apisix.plugins.ai-protocols.binding")
local ngx = ngx
local ipairs = ipairs
local table = table
@@ -45,6 +46,7 @@ local schema = {
items = {type = "string"},
default = {},
},
+ fail_mode = binding.schema_property("skip"),
},
}
@@ -104,10 +106,35 @@ function _M.access(conf, ctx)
local json_body, err = core.json.decode(body)
if err then
- return 400, {message = err}
+ -- Non-JSON body (plain form / multipart / etc.) never went through an
AI
+ -- protocol, so a Consumer-bound prompt guard should treat it like any
other
+ -- unsupported request and let fail_mode decide.
+ local handled, code, resp = binding.on_unsupported(
+ conf.fail_mode, plugin_name, ctx,
+ "request body is not valid JSON: " .. err,
+ 400, {message = err})
+ if handled then
+ return code, resp
+ end
+ return
end
local proto_name = protocols.detect(json_body, ctx)
+
+ -- Consumer-bound prompt guard may receive non-AI requests whose body
matches
+ -- no AI protocol. Historically these were silently allowed (security gap);
+ -- now the behavior is governed by fail_mode.
+ if not proto_name or proto_name == "passthrough" then
+ local handled, code, resp = binding.on_unsupported(
+ conf.fail_mode, plugin_name, ctx,
+ "request body does not match any supported AI protocol",
+ 400, {message = "Request format not recognized by
ai-prompt-guard"})
+ if handled then
+ return code, resp
+ end
+ return
+ end
+
local messages = protocols.get_messages(json_body, ctx)
-- Responses API: instructions + input are parallel fields, not
conversation history,
diff --git a/apisix/plugins/ai-protocols/binding.lua
b/apisix/plugins/ai-protocols/binding.lua
new file mode 100644
index 000000000..0b408e5e5
--- /dev/null
+++ b/apisix/plugins/ai-protocols/binding.lua
@@ -0,0 +1,94 @@
+--
+-- 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.
+--
+
+--- Shared helpers for AI plugins bound at Consumer/Service level.
+-- A Consumer-bound AI plugin may receive requests it cannot handle: plain HTTP
+-- traffic, requests that never passed through ai-proxy/ai-proxy-multi, or an
+-- unsupported content-type/protocol. This module standardizes that handling
via
+-- a configurable `fail_mode`.
+
+local core = require("apisix.core")
+local re_gsub = ngx.re.gsub
+local sub = string.sub
+
+local _M = {}
+
+local MAX_REASON_LEN = 256
+
+
+-- `reason` may embed request-controlled values (e.g. the Content-Type header),
+-- so strip control characters and cap the length before logging to avoid
+-- log forging / injection.
+local function sanitize_reason(reason)
+ local safe = reason or "unsupported request protocol"
+ safe = re_gsub(safe, "[[:cntrl:]]", " ", "jo")
+ if #safe > MAX_REASON_LEN then
+ safe = sub(safe, 1, MAX_REASON_LEN)
+ end
+ return safe
+end
+
+
+--- Build the `fail_mode` schema fragment for a plugin.
+-- Plugins default to "skip" so that Consumer-bound AI plugins let non-AI
traffic
+-- pass through unchecked; operators can opt into "warn" or "error"
(fail-closed)
+-- when every request from the binding must be an AI request.
+-- @param default string One of "skip" | "warn" | "error"
+-- @return table schema fragment
+function _M.schema_property(default)
+ return {
+ type = "string",
+ enum = {"skip", "warn", "error"},
+ default = default,
+ description = "Behavior when the request protocol/format is not
supported "
+ .. "by this AI plugin (e.g. non-AI HTTP traffic on a
Consumer-bound "
+ .. "plugin, or a request that did not pass through ai-proxy). "
+ .. "skip: pass the request through unchecked; "
+ .. "warn: pass through and log a warning; "
+ .. "error: reject the request.",
+ }
+end
+
+
+--- Decide what to do for a request this plugin cannot handle.
+-- For "error" mode it returns the caller-supplied code/body so the request is
+-- rejected. For "skip"/"warn" it logs (at info/warn level), then returns
+-- handled=false so the caller can `return` to let the request pass through
+-- unchecked.
+-- @param mode string conf.fail_mode
+-- @param plugin_name string the plugin name, used in the logs
+-- @param ctx table request context
+-- @param reason string human-readable reason, for logs
+-- @param err_code number return code used when mode == "error"
+-- @param err_body any return body used when mode == "error"
+-- @return boolean handled, number|nil code, any|nil body
+function _M.on_unsupported(mode, plugin_name, ctx, reason, err_code, err_body)
+ if mode == "error" then
+ return true, err_code, err_body
+ end
+
+ local msg = plugin_name .. " skipped: " .. sanitize_reason(reason)
+ if mode == "warn" then
+ core.log.warn(msg)
+ else
+ core.log.info(msg)
+ end
+ return false
+end
+
+
+return _M
diff --git a/docs/en/latest/plugins/ai-aliyun-content-moderation.md
b/docs/en/latest/plugins/ai-aliyun-content-moderation.md
index cfd275d05..3ba93081f 100644
--- a/docs/en/latest/plugins/ai-aliyun-content-moderation.md
+++ b/docs/en/latest/plugins/ai-aliyun-content-moderation.md
@@ -70,6 +70,7 @@ The `ai-aliyun-content-moderation` Plugin should be used with
either [`ai-proxy`
| keepalive_pool | integer | False | `30` | >= 1 | Maximum number of
connections in the keepalive pool. |
| keepalive_timeout | integer | False | `60000` | >= 1000 | Keepalive timeout
in milliseconds. |
| ssl_verify | boolean | False | `true` | | If `true`, enable SSL certificate
verification. |
+| fail_mode | string | False | `"skip"` | `skip`, `warn`, `error` | Behavior
when the request is not a recognized AI request that this plugin can inspect
(for example, plain HTTP traffic on a Consumer-bound plugin, or a request that
did not pass through `ai-proxy`). `skip`: let the request pass through
unchecked; `warn`: pass through and log a warning; `error`: reject the request.
|
## Examples
diff --git a/docs/en/latest/plugins/ai-aws-content-moderation.md
b/docs/en/latest/plugins/ai-aws-content-moderation.md
index dbb3f7ae0..fce2755a3 100644
--- a/docs/en/latest/plugins/ai-aws-content-moderation.md
+++ b/docs/en/latest/plugins/ai-aws-content-moderation.md
@@ -52,6 +52,7 @@ This Plugin must be used in Routes that proxy requests to
LLMs only.
| `comprehend.ssl_verify` | boolean | False | true | | If true, enable TLS
certificate verification. |
| `moderation_categories` | object | False | | | Key-value pairs of moderation
category and their corresponding threshold. In each pair, the key should be one
of `PROFANITY`, `HATE_SPEECH`, `INSULT`, `HARASSMENT_OR_ABUSE`, `SEXUAL`, or
`VIOLENCE_OR_THREAT`; and the threshold value should be between 0 and 1
(inclusive). |
| `moderation_threshold` | number | False | 0.5 | 0 - 1 | Overall toxicity
threshold. A higher value means more toxic content allowed. This option differs
from the individual category thresholds in `moderation_categories`. For
example, if `moderation_categories` is set with a `PROFANITY` threshold of
`0.5`, and a request has a `PROFANITY` score of `0.1`, the request will not
exceed the category threshold. However, if the request has other categories
like `SEXUAL` or `VIOLENCE_OR_THREAT` [...]
+| `fail_mode` | string | False | `skip` | `skip`, `warn`, `error` | Behavior
when the request body is not a recognized AI request that this plugin can
inspect (for example, a non-JSON `multipart/form-data` upload on a
Consumer-bound plugin, or a request that did not pass through `ai-proxy`).
`skip`: let the request pass through unchecked; `warn`: pass through and log a
warning; `error`: reject the request. |
## Examples
diff --git a/docs/en/latest/plugins/ai-prompt-guard.md
b/docs/en/latest/plugins/ai-prompt-guard.md
index 3d8eed458..b5ebd17cc 100644
--- a/docs/en/latest/plugins/ai-prompt-guard.md
+++ b/docs/en/latest/plugins/ai-prompt-guard.md
@@ -48,6 +48,7 @@ When both `allow_patterns` and `deny_patterns` are
configured, the Plugin first
| `match_all_conversation_history` | boolean | False | false | | If `true`,
concatenate and check all messages in the conversation history. If `false`,
only check the content of the last message. |
| `allow_patterns` | array | False | [] | | An array of regex patterns that
messages should match. When configured, messages must match at least one
pattern to be considered valid. |
| `deny_patterns` | array | False | [] | | An array of regex patterns that
messages should not match. If messages match any of the patterns, the request
is rejected. If both `allow_patterns` and `deny_patterns` are configured, the
Plugin first ensures that at least one `allow_patterns` is matched. |
+| `fail_mode` | string | False | `skip` | `skip`, `warn`, `error` | Behavior
when the request body matches no recognized AI protocol that this plugin can
inspect (for example, plain HTTP traffic on a Consumer-bound plugin, or a
request that did not pass through `ai-proxy`). `skip`: let the request pass
through unchecked; `warn`: pass through and log a warning; `error`: reject the
request. |
## Examples
diff --git a/docs/zh/latest/plugins/ai-aliyun-content-moderation.md
b/docs/zh/latest/plugins/ai-aliyun-content-moderation.md
index 38b1e2023..cc99e1cec 100644
--- a/docs/zh/latest/plugins/ai-aliyun-content-moderation.md
+++ b/docs/zh/latest/plugins/ai-aliyun-content-moderation.md
@@ -70,6 +70,7 @@ import TabItem from '@theme/TabItem';
| keepalive_pool | integer | 否 | `30` | >= 1 | 连接保活池的最大连接数。 |
| keepalive_timeout | integer | 否 | `60000` | >= 1000 | 连接保活超时时间(毫秒)。 |
| ssl_verify | boolean | 否 | `true` | | 如果为 `true`,启用 SSL 证书验证。 |
+| fail_mode | string | 否 | `"skip"` | `skip`、`warn`、`error` | 当请求不是该插件可识别的 AI
请求时的处理行为(例如 Consumer 级别绑定时的普通 HTTP 流量,或未经过 `ai-proxy`
的请求)。`skip`:放行请求且不做检查;`warn`:放行并记录 warning 日志;`error`:拒绝请求。 |
## 示例
diff --git a/docs/zh/latest/plugins/ai-aws-content-moderation.md
b/docs/zh/latest/plugins/ai-aws-content-moderation.md
index 9d7e7366f..f1dae3296 100644
--- a/docs/zh/latest/plugins/ai-aws-content-moderation.md
+++ b/docs/zh/latest/plugins/ai-aws-content-moderation.md
@@ -54,6 +54,7 @@ import TabItem from '@theme/TabItem';
| `comprehend.ssl_verify` | boolean | 否 | true | | 如果为 true,则启用 TLS 证书验证。 |
| `moderation_categories` | object | 否 | | | 审核类别及其对应阈值的键值对。在每个键值对中,键应为
`PROFANITY`、`HATE_SPEECH`、`INSULT`、`HARASSMENT_OR_ABUSE`、`SEXUAL` 或
`VIOLENCE_OR_THREAT` 之一;阈值应在 0 到 1 之间(包含)。 |
| `moderation_threshold` | number | 否 | 0.5 | 0 - 1 |
整体毒性阈值。值越高,允许的有害内容越多。此选项与 `moderation_categories` 中的单独类别阈值不同。例如,如果
`moderation_categories` 中设置了 `PROFANITY` 阈值为 `0.5`,而请求的 `PROFANITY` 分数为
`0.1`,则请求不会超过类别阈值。但如果请求的其他类别(如 `SEXUAL` 或 `VIOLENCE_OR_THREAT`)超过了
`moderation_threshold`,则请求将被拒绝。 |
+| `fail_mode` | string | 否 | `skip` | `skip`、`warn`、`error` | 当请求体不是该插件可识别的 AI
请求时的处理行为(例如 Consumer 级别绑定时的非 JSON `multipart/form-data` 上传,或未经过 `ai-proxy`
的请求)。`skip`:放行请求且不做检查;`warn`:放行并记录 warning 日志;`error`:拒绝请求。 |
## 使用示例
diff --git a/docs/zh/latest/plugins/ai-prompt-guard.md
b/docs/zh/latest/plugins/ai-prompt-guard.md
index 6da593e13..80e177b0a 100644
--- a/docs/zh/latest/plugins/ai-prompt-guard.md
+++ b/docs/zh/latest/plugins/ai-prompt-guard.md
@@ -48,6 +48,7 @@ import TabItem from '@theme/TabItem';
| `match_all_conversation_history` | boolean | 否 | false | | 如果为
`true`,连接并检查对话历史中的所有消息。如果为 `false`,仅检查最后一条消息的内容。 |
| `allow_patterns` | array | 否 | [] | |
消息应匹配的正则表达式模式数组。配置后,消息必须至少匹配一个模式才被视为有效。 |
| `deny_patterns` | array | 否 | [] | |
消息不应匹配的正则表达式模式数组。如果消息匹配任何模式,请求将被拒绝。如果同时配置了 `allow_patterns` 和
`deny_patterns`,插件会首先确保至少匹配一个 `allow_patterns`。 |
+| `fail_mode` | string | 否 | `skip` | `skip`、`warn`、`error` | 当请求体不匹配该插件可识别的任何
AI 协议时的处理行为(例如 Consumer 级别绑定时的普通 HTTP 流量,或未经过 `ai-proxy`
的请求)。`skip`:放行请求且不做检查;`warn`:放行并记录 warning 日志;`error`:拒绝请求。 |
## 使用示例
diff --git a/t/plugin/ai-aliyun-content-moderation.t
b/t/plugin/ai-aliyun-content-moderation.t
index a0777d605..a48a23446 100644
--- a/t/plugin/ai-aliyun-content-moderation.t
+++ b/t/plugin/ai-aliyun-content-moderation.t
@@ -100,7 +100,8 @@ __DATA__
"access_key_id": "fake-key-id",
"access_key_secret": "fake-key-secret",
"risk_level_bar": "high",
- "check_request": true
+ "check_request": true,
+ "fail_mode": "error"
}
}
}]]
@@ -117,7 +118,7 @@ passed
-=== TEST 2: use ai-aliyun-content-moderation plugin without ai-proxy or
ai-proxy-multi plugin should failed
+=== TEST 2: fail_mode=error without ai-proxy/ai-proxy-multi should fail
--- request
POST /chat
{"prompt": "What is 1+1?"}
@@ -1657,3 +1658,54 @@ POST /v1/responses
--- more_headers
X-AI-Fixture: aliyun/chat-with-harmful.json
--- error_code: 400
+
+
+
+=== TEST 45: route without ai-proxy, default fail_mode (skip)
+--- 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": "/plain",
+ "plugins": {
+ "ai-aliyun-content-moderation": {
+ "endpoint": "http://localhost:6724",
+ "region_id": "cn-shanghai",
+ "access_key_id": "fake-key-id",
+ "access_key_secret": "fake-key-secret",
+ "risk_level_bar": "high",
+ "check_request": true
+ }
+ },
+ "upstream": {
+ "type": "roundrobin",
+ "nodes": {
+ "127.0.0.1:6724": 1
+ }
+ }
+ }]]
+ )
+
+ if code >= 300 then
+ ngx.status = code
+ end
+ ngx.say(body)
+ }
+ }
+--- response_body
+passed
+
+
+
+=== TEST 46: plain HTTP request passes through by default (skip) and is logged
+--- request
+POST /plain
+name=alice&action=upload
+--- more_headers
+Content-Type: multipart/form-data
+--- error_code: 200
+--- error_log
+ai-aliyun-content-moderation skipped
diff --git a/t/plugin/ai-aws-content-moderation.t
b/t/plugin/ai-aws-content-moderation.t
index 7810dea55..765bba1ab 100644
--- a/t/plugin/ai-aws-content-moderation.t
+++ b/t/plugin/ai-aws-content-moderation.t
@@ -299,3 +299,106 @@ request body exceeds PROFANITY threshold
POST /echo
good_request
--- error_code: 200
+
+
+
+=== TEST 13: setup route with default fail_mode (skip)
+--- 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": "/echo",
+ "plugins": {
+ "ai-aws-content-moderation": {
+ "comprehend": {
+ "access_key_id": "access",
+ "secret_access_key": "ea+secret",
+ "region": "us-east-1",
+ "endpoint": "http://localhost:2668"
+ }
+ }
+ },
+ "upstream": {
+ "type": "roundrobin",
+ "nodes": {
+ "127.0.0.1:1980": 1
+ }
+ }
+ }]]
+ )
+
+ if code >= 300 then
+ ngx.status = code
+ end
+ ngx.say(body)
+ }
+ }
+--- response_body
+passed
+
+
+
+=== TEST 14: non-JSON (multipart) request passes through by default (skip) to
upstream
+--- request
+POST /echo
+name=alice&action=upload
+--- more_headers
+Content-Type: multipart/form-data
+--- error_code: 200
+--- response_body chomp
+name=alice&action=upload
+
+
+
+=== TEST 15: setup route with fail_mode=error
+--- 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": "/echo",
+ "plugins": {
+ "ai-aws-content-moderation": {
+ "comprehend": {
+ "access_key_id": "access",
+ "secret_access_key": "ea+secret",
+ "region": "us-east-1",
+ "endpoint": "http://localhost:2668"
+ },
+ "fail_mode": "error"
+ }
+ },
+ "upstream": {
+ "type": "roundrobin",
+ "nodes": {
+ "127.0.0.1:1980": 1
+ }
+ }
+ }]]
+ )
+
+ if code >= 300 then
+ ngx.status = code
+ end
+ ngx.say(body)
+ }
+ }
+--- response_body
+passed
+
+
+
+=== TEST 16: non-JSON request is rejected when fail_mode=error
+--- request
+POST /echo
+name=alice&action=upload
+--- more_headers
+Content-Type: multipart/form-data
+--- error_code: 400
+--- response_body eval
+qr/only application\/json is supported/
diff --git a/t/plugin/ai-prompt-guard.t b/t/plugin/ai-prompt-guard.t
index caf3c1eff..ae8d30142 100644
--- a/t/plugin/ai-prompt-guard.t
+++ b/t/plugin/ai-prompt-guard.t
@@ -680,3 +680,153 @@ POST /hello
--- response_body
{"message":"Request contains prohibited content"}
--- error_code: 400
+
+
+
+=== TEST 30: setup route with deny pattern, default fail_mode (skip)
+--- 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,
+ "deny_patterns": [
+ "badword"
+ ]
+ }
+ }
+ }]]
+ )
+
+ if code >= 300 then
+ ngx.status = code
+ end
+ ngx.say(body)
+ }
+}
+--- response_body
+passed
+
+
+
+=== TEST 31: non-AI request passes through by default (skip) to upstream
+--- request
+POST /hello
+{
+ "foo": "badword"
+}
+--- error_code: 200
+--- response_body
+hello world
+
+
+
+=== TEST 32: setup route with fail_mode = error
+--- 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,
+ "deny_patterns": [
+ "badword"
+ ],
+ "fail_mode": "error"
+ }
+ }
+ }]]
+ )
+
+ if code >= 300 then
+ ngx.status = code
+ end
+ ngx.say(body)
+ }
+}
+--- response_body
+passed
+
+
+
+=== TEST 33: non-AI request rejected when fail_mode = error
+--- request
+POST /hello
+{
+ "foo": "bar"
+}
+--- response_body
+{"message":"Request format not recognized by ai-prompt-guard"}
+--- error_code: 400
+
+
+
+=== TEST 34: setup route with deny pattern, default fail_mode (skip)
+--- 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,
+ "deny_patterns": [
+ "badword"
+ ]
+ }
+ }
+ }]]
+ )
+
+ if code >= 300 then
+ ngx.status = code
+ end
+ ngx.say(body)
+ }
+}
+--- response_body
+passed
+
+
+
+=== TEST 35: non-JSON request passes through by default (skip) to upstream
+--- request
+POST /hello
+name=alice&action=upload
+--- more_headers
+Content-Type: multipart/form-data
+--- error_code: 200
+--- response_body
+hello world
+--- error_log
+ai-prompt-guard skipped