This is an automated email from the ASF dual-hosted git repository.
shreemaan-abhishek 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 2b6df4f3b refactor(ai-aws-content-moderation): moderate decoded LLM
content in access phase (#13647)
2b6df4f3b is described below
commit 2b6df4f3b31c2f3298b9c78719c17094a29dad02
Author: Shreemaan Abhishek <[email protected]>
AuthorDate: Tue Jul 7 15:29:03 2026 +0800
refactor(ai-aws-content-moderation): moderate decoded LLM content in access
phase (#13647)
---
apisix/plugins/ai-aws-content-moderation.lua | 167 +++++++++++++++------
.../en/latest/plugins/ai-aws-content-moderation.md | 61 ++++++--
.../zh/latest/plugins/ai-aws-content-moderation.md | 61 ++++++--
t/admin/plugins.t | 2 +-
t/plugin/ai-aws-content-moderation-secrets.t | 49 +++---
t/plugin/ai-aws-content-moderation.t | 156 +++++++++----------
t/plugin/ai-aws-content-moderation2.t | 26 ++--
7 files changed, 337 insertions(+), 185 deletions(-)
diff --git a/apisix/plugins/ai-aws-content-moderation.lua
b/apisix/plugins/ai-aws-content-moderation.lua
index a3a1295c6..93ed36857 100644
--- a/apisix/plugins/ai-aws-content-moderation.lua
+++ b/apisix/plugins/ai-aws-content-moderation.lua
@@ -16,17 +16,20 @@
--
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 core = require("apisix.core")
+local protocols = require("apisix.plugins.ai-protocols")
+local binding = require("apisix.plugins.ai-protocols.binding")
+local aws = require("resty.aws")
local aws_instance
local http = require("resty.http")
-local pairs = pairs
-local unpack = unpack
-local type = type
-local ipairs = ipairs
+local ngx = ngx
+local pairs = pairs
+local unpack = unpack
+local type = type
+local ipairs = ipairs
+local table = table
local HTTP_INTERNAL_SERVER_ERROR = ngx.HTTP_INTERNAL_SERVER_ERROR
local HTTP_BAD_REQUEST = ngx.HTTP_BAD_REQUEST
@@ -69,6 +72,9 @@ local schema = {
maximum = 1,
default = 0.5
},
+ check_request = { type = "boolean", default = true },
+ deny_code = { type = "number", default = 200 },
+ deny_message = { type = "string" },
fail_mode = binding.schema_property("skip"),
},
encrypt_fields = { "comprehend.secret_access_key" },
@@ -78,7 +84,7 @@ local schema = {
local _M = {
version = 0.1,
- priority = 1050,
+ priority = 1031,
name = "ai-aws-content-moderation",
schema = schema,
}
@@ -89,29 +95,10 @@ function _M.check_schema(conf)
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
- end
-
+-- Score content with AWS Comprehend detectToxicContent.
+-- Returns (reason, nil) when a category/toxicity threshold is exceeded,
+-- (nil, err) on a service error, and (nil, nil) when the content is clean.
+local function detect_toxic(conf, content)
local comprehend = conf.comprehend
if not aws_instance then
@@ -129,46 +116,136 @@ function _M.rewrite(conf, ctx)
aws_instance.config.endpoint = endpoint
aws_instance.config.ssl_verify = comprehend.ssl_verify
- local comprehend = aws_instance:Comprehend({
+ local comprehend_client = aws_instance:Comprehend({
credentials = credentials,
endpoint = endpoint,
region = comprehend.region,
port = port,
})
- local res, err = comprehend:detectToxicContent({
+ local res, err = comprehend_client:detectToxicContent({
LanguageCode = "en",
TextSegments = {{
- Text = body
+ Text = content
}},
})
-
if not res then
- core.log.error("failed to send request to ", endpoint, ": ", err)
- return HTTP_INTERNAL_SERVER_ERROR, err
+ return nil, "failed to send request to " .. endpoint .. ": " .. err
end
local results = res.body and res.body.ResultList
if type(results) ~= "table" or core.table.isempty(results) then
- return HTTP_INTERNAL_SERVER_ERROR, "failed to get moderation results
from response"
+ return nil, "failed to get moderation results from response"
end
for _, result in ipairs(results) do
if conf.moderation_categories then
for _, item in pairs(result.Labels) do
- if not conf.moderation_categories[item.Name] then
- goto continue
- end
- if item.Score > conf.moderation_categories[item.Name] then
- return HTTP_BAD_REQUEST, "request body exceeds " ..
item.Name .. " threshold"
+ local threshold = conf.moderation_categories[item.Name]
+ if threshold and item.Score > threshold then
+ return "request body exceeds " .. item.Name .. " threshold"
end
- ::continue::
end
end
if result.Toxicity > conf.moderation_threshold then
- return HTTP_BAD_REQUEST, "request body exceeds toxicity threshold"
+ return "request body exceeds toxicity threshold"
+ end
+ end
+end
+
+
+-- Build a provider-compatible deny body so the AI client isn't broken.
+local function build_deny_message(ctx, conf, reason)
+ local message = conf.deny_message or reason
+ local proto = protocols.get(ctx.ai_client_protocol)
+ if not proto then
+ return message
+ end
+ local stream = ctx.var.request_type == "ai_stream"
+ local usage = ctx.llm_raw_usage
+ or (proto.empty_usage and proto.empty_usage())
+ or { prompt_tokens = 0, completion_tokens = 0, total_tokens = 0 }
+ return proto.build_deny_response({
+ text = message,
+ model = ctx.var.request_llm_model,
+ usage = usage,
+ stream = stream,
+ })
+end
+
+
+function _M.access(conf, ctx)
+ if not ctx.picked_ai_instance then
+ 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)",
+ HTTP_INTERNAL_SERVER_ERROR, "no ai instance picked, " ..
+ "ai-aws-content-moderation plugin must be used with " ..
+ "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
+ local handled, code, body = 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, body
+ end
+ return
+ end
+
+ local request_tab, err = core.request.get_json_request_body_table()
+ if not request_tab then
+ return HTTP_BAD_REQUEST, err
+ end
+
+ local proto = protocols.get(ctx.ai_client_protocol)
+ if not proto or not proto.extract_request_content then
+ local handled, code, body = binding.on_unsupported(
+ conf.fail_mode, _M.name, ctx,
+ "unsupported protocol: " .. (ctx.ai_client_protocol or "unknown"),
+ HTTP_INTERNAL_SERVER_ERROR,
+ "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)
+ local content = table.concat(contents, " ")
+ if content == "" then
+ return
+ end
+
+ local reason, err = detect_toxic(conf, content)
+ if err then
+ core.log.error(err)
+ return HTTP_INTERNAL_SERVER_ERROR, err
+ end
+ if reason then
+ local stream = ctx.var.request_type == "ai_stream"
+ if stream then
+ core.response.set_header("Content-Type", "text/event-stream")
+ else
+ core.response.set_header("Content-Type", "application/json")
end
+ return conf.deny_code, build_deny_message(ctx, conf, reason)
end
end
diff --git a/docs/en/latest/plugins/ai-aws-content-moderation.md
b/docs/en/latest/plugins/ai-aws-content-moderation.md
index fce2755a3..2199d9083 100644
--- a/docs/en/latest/plugins/ai-aws-content-moderation.md
+++ b/docs/en/latest/plugins/ai-aws-content-moderation.md
@@ -36,9 +36,11 @@ import TabItem from '@theme/TabItem';
## Description
-The `ai-aws-content-moderation` Plugin integrates with [AWS
Comprehend](https://aws.amazon.com/comprehend/) to check request bodies for
toxicity when proxying to LLMs, such as profanity, hate speech, insult,
harassment, violence, and more, rejecting requests if the evaluated outcome
exceeds the configured threshold.
+The `ai-aws-content-moderation` Plugin integrates with [AWS
Comprehend](https://aws.amazon.com/comprehend/) to check request content for
toxicity when proxying to LLMs, such as profanity, hate speech, insult,
harassment, violence, and more, rejecting requests if the evaluated outcome
exceeds the configured threshold.
-This Plugin must be used in Routes that proxy requests to LLMs only.
+The Plugin is protocol-aware: it extracts the prompt content from the LLM
request (for example `messages[].content`) and moderates only that decoded
text, rather than the raw request body.
+
+The `ai-aws-content-moderation` Plugin should be used with either
[`ai-proxy`](./ai-proxy.md) or [`ai-proxy-multi`](./ai-proxy-multi.md) Plugin
for proxying LLM requests.
## Plugin Attributes
@@ -52,7 +54,10 @@ 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. |
+| `check_request` | boolean | False | `true` | | If `true`, moderate the
request content. |
+| `deny_code` | number | False | `200` | | HTTP status code returned when a
request is rejected. |
+| `deny_message` | string | False | | | Message returned when a request is
rejected. If unset, the moderation reason (for example `request body exceeds
toxicity threshold`) is returned. |
+| `fail_mode` | string | False | `skip` | `skip`, `warn`, `error` | Behavior
when the request did not pass through `ai-proxy`/`ai-proxy-multi` and therefore
cannot be moderated as an AI request. `skip`: let the request pass through
unchecked; `warn`: pass through and log a warning; `error`: reject the request.
|
## Examples
@@ -103,7 +108,8 @@ curl "http://127.0.0.1:9180/apisix/admin/routes/1" -X PUT \
},
"moderation_categories": {
"PROFANITY": 0.1
- }
+ },
+ "deny_code": 400
},
"ai-proxy": {
"provider": "openai",
@@ -142,6 +148,7 @@ services:
region: us-east-1
moderation_categories:
PROFANITY: 0.1
+ deny_code: 400
ai-proxy:
provider: openai
auth:
@@ -181,6 +188,7 @@ spec:
region: us-east-1
moderation_categories:
PROFANITY: 0.1
+ deny_code: 400
- name: ai-proxy
config:
provider: openai
@@ -242,6 +250,7 @@ spec:
region: us-east-1
moderation_categories:
PROFANITY: 0.1
+ deny_code: 400
- name: ai-proxy
enable: true
config:
@@ -278,10 +287,23 @@ curl -i "http://127.0.0.1:9080/post" -X POST \
}'
```
-You should receive an `HTTP/1.1 400 Bad Request` response and see the
following message:
+You should receive an `HTTP/1.1 400 Bad Request` response. The moderation
reason is returned in the response body in a provider-compatible format, so AI
clients are not broken:
-```text
-request body exceeds PROFANITY threshold
+```json
+{
+ ...,
+ "choices": [
+ {
+ "index": 0,
+ "message": {
+ "role": "assistant",
+ "content": "request body exceeds PROFANITY threshold"
+ },
+ "finish_reason": "stop"
+ }
+ ],
+ ...
+}
```
Send another request to the Route with a typical question in the request body:
@@ -343,7 +365,8 @@ curl "http://127.0.0.1:9180/apisix/admin/routes/1" -X PUT \
"moderation_categories": {
"PROFANITY": 1
},
- "moderation_threshold": 0.2
+ "moderation_threshold": 0.2,
+ "deny_code": 400
},
"ai-proxy": {
"provider": "openai",
@@ -383,6 +406,7 @@ services:
moderation_categories:
PROFANITY: 1
moderation_threshold: 0.2
+ deny_code: 400
ai-proxy:
provider: openai
auth:
@@ -423,6 +447,7 @@ spec:
moderation_categories:
PROFANITY: 1
moderation_threshold: 0.2
+ deny_code: 400
- name: ai-proxy
config:
provider: openai
@@ -485,6 +510,7 @@ spec:
moderation_categories:
PROFANITY: 1
moderation_threshold: 0.2
+ deny_code: 400
- name: ai-proxy
enable: true
config:
@@ -521,10 +547,23 @@ curl -i "http://127.0.0.1:9080/post" -X POST \
}'
```
-You should receive an `HTTP/1.1 400 Bad Request` response and see the
following message:
+You should receive an `HTTP/1.1 400 Bad Request` response. The moderation
reason is returned in the response body in a provider-compatible format, so AI
clients are not broken:
-```text
-request body exceeds toxicity threshold
+```json
+{
+ ...,
+ "choices": [
+ {
+ "index": 0,
+ "message": {
+ "role": "assistant",
+ "content": "request body exceeds toxicity threshold"
+ },
+ "finish_reason": "stop"
+ }
+ ],
+ ...
+}
```
Send another request to the Route without any profane word in the request body:
diff --git a/docs/zh/latest/plugins/ai-aws-content-moderation.md
b/docs/zh/latest/plugins/ai-aws-content-moderation.md
index f1dae3296..c002b3f48 100644
--- a/docs/zh/latest/plugins/ai-aws-content-moderation.md
+++ b/docs/zh/latest/plugins/ai-aws-content-moderation.md
@@ -38,9 +38,11 @@ import TabItem from '@theme/TabItem';
## 描述
-`ai-aws-content-moderation` 插件集成了 [AWS
Comprehend](https://aws.amazon.com/comprehend/),用于在代理请求到 LLM
时检查请求体中的有害内容,例如亵渎、仇恨言论、侮辱、骚扰、暴力等,如果评估结果超过配置的阈值则拒绝请求。
+`ai-aws-content-moderation` 插件集成了 [AWS
Comprehend](https://aws.amazon.com/comprehend/),用于在代理请求到 LLM
时检查请求内容中的有害内容,例如亵渎、仇恨言论、侮辱、骚扰、暴力等,如果评估结果超过配置的阈值则拒绝请求。
-此插件只能在代理请求到 LLM 的路由中使用。
+该插件是协议感知的:它会从 LLM 请求中提取提示内容(例如 `messages[].content`),仅审核解码后的文本,而不是原始请求体。
+
+`ai-aws-content-moderation` 插件应与 [`ai-proxy`](./ai-proxy.md) 或
[`ai-proxy-multi`](./ai-proxy-multi.md) 插件一起使用,以代理 LLM 请求。
## 插件属性
@@ -54,7 +56,10 @@ 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`:拒绝请求。 |
+| `check_request` | boolean | 否 | `true` | | 如果为 `true`,则审核请求内容。 |
+| `deny_code` | number | 否 | `200` | | 请求被拒绝时返回的 HTTP 状态码。 |
+| `deny_message` | string | 否 | | | 请求被拒绝时返回的消息。未设置时,返回审核原因(例如 `request body
exceeds toxicity threshold`)。 |
+| `fail_mode` | string | 否 | `skip` | `skip`、`warn`、`error` | 当请求未经过
`ai-proxy`/`ai-proxy-multi`,因而无法作为 AI
请求进行审核时的处理行为。`skip`:放行请求且不做检查;`warn`:放行并记录 warning 日志;`error`:拒绝请求。 |
## 使用示例
@@ -105,7 +110,8 @@ curl "http://127.0.0.1:9180/apisix/admin/routes/1" -X PUT \
},
"moderation_categories": {
"PROFANITY": 0.1
- }
+ },
+ "deny_code": 400
},
"ai-proxy": {
"provider": "openai",
@@ -144,6 +150,7 @@ services:
region: us-east-1
moderation_categories:
PROFANITY: 0.1
+ deny_code: 400
ai-proxy:
provider: openai
auth:
@@ -183,6 +190,7 @@ spec:
region: us-east-1
moderation_categories:
PROFANITY: 0.1
+ deny_code: 400
- name: ai-proxy
config:
provider: openai
@@ -244,6 +252,7 @@ spec:
region: us-east-1
moderation_categories:
PROFANITY: 0.1
+ deny_code: 400
- name: ai-proxy
enable: true
config:
@@ -280,10 +289,23 @@ curl -i "http://127.0.0.1:9080/post" -X POST \
}'
```
-您应该收到 `HTTP/1.1 400 Bad Request` 响应,并看到以下消息:
+您应该收到 `HTTP/1.1 400 Bad Request` 响应。审核原因会以与提供商兼容的格式返回在响应体中,从而不会破坏 AI 客户端:
-```text
-request body exceeds PROFANITY threshold
+```json
+{
+ ...,
+ "choices": [
+ {
+ "index": 0,
+ "message": {
+ "role": "assistant",
+ "content": "request body exceeds PROFANITY threshold"
+ },
+ "finish_reason": "stop"
+ }
+ ],
+ ...
+}
```
向路由发送另一个包含正常问题的请求:
@@ -345,7 +367,8 @@ curl "http://127.0.0.1:9180/apisix/admin/routes/1" -X PUT \
"moderation_categories": {
"PROFANITY": 1
},
- "moderation_threshold": 0.2
+ "moderation_threshold": 0.2,
+ "deny_code": 400
},
"ai-proxy": {
"provider": "openai",
@@ -385,6 +408,7 @@ services:
moderation_categories:
PROFANITY: 1
moderation_threshold: 0.2
+ deny_code: 400
ai-proxy:
provider: openai
auth:
@@ -425,6 +449,7 @@ spec:
moderation_categories:
PROFANITY: 1
moderation_threshold: 0.2
+ deny_code: 400
- name: ai-proxy
config:
provider: openai
@@ -487,6 +512,7 @@ spec:
moderation_categories:
PROFANITY: 1
moderation_threshold: 0.2
+ deny_code: 400
- name: ai-proxy
enable: true
config:
@@ -523,10 +549,23 @@ curl -i "http://127.0.0.1:9080/post" -X POST \
}'
```
-您应该收到 `HTTP/1.1 400 Bad Request` 响应,并看到以下消息:
+您应该收到 `HTTP/1.1 400 Bad Request` 响应。审核原因会以与提供商兼容的格式返回在响应体中,从而不会破坏 AI 客户端:
-```text
-request body exceeds toxicity threshold
+```json
+{
+ ...,
+ "choices": [
+ {
+ "index": 0,
+ "message": {
+ "role": "assistant",
+ "content": "request body exceeds toxicity threshold"
+ },
+ "finish_reason": "stop"
+ }
+ ],
+ ...
+}
```
向路由发送另一个不含亵渎词汇的请求:
diff --git a/t/admin/plugins.t b/t/admin/plugins.t
index ec33d940a..f0a512069 100644
--- a/t/admin/plugins.t
+++ b/t/admin/plugins.t
@@ -105,10 +105,10 @@ ai-prompt-guard
ai-prompt-template
ai-prompt-decorator
ai-rag
-ai-aws-content-moderation
ai-proxy-multi
ai-proxy
ai-cache
+ai-aws-content-moderation
ai-rate-limiting
ai-aliyun-content-moderation
ai-lakera-guard
diff --git a/t/plugin/ai-aws-content-moderation-secrets.t
b/t/plugin/ai-aws-content-moderation-secrets.t
index a88171ac1..5d069e52d 100644
--- a/t/plugin/ai-aws-content-moderation-secrets.t
+++ b/t/plugin/ai-aws-content-moderation-secrets.t
@@ -39,6 +39,9 @@ _EOC_
$block->set_value("main_config", $main_config);
+ # Mock AWS Comprehend: only answer (with a clean result) when the SigV4
+ # Authorization header carries the resolved access key id, proving the
+ # secret reference reached the plugin.
my $http_config = $block->http_config // <<_EOC_;
server {
listen 2668;
@@ -88,11 +91,9 @@ __DATA__
=== TEST 1: store secret into vault
--- exec
-VAULT_TOKEN='root' VAULT_ADDR='http://0.0.0.0:8200' vault kv put kv/apisix/foo
secret_access_key=super-secret
-VAULT_TOKEN='root' VAULT_ADDR='http://0.0.0.0:8200' vault kv put kv/apisix/foo
access_key_id=access-key-id
+VAULT_TOKEN='root' VAULT_ADDR='http://0.0.0.0:8200' vault kv put kv/apisix/foo
secret_access_key=super-secret access_key_id=access-key-id
--- response_body
Success! Data written to: kv/apisix/foo
-Success! Data written to: kv/apisix/foo
@@ -118,8 +119,13 @@ Success! Data written to: kv/apisix/foo
local code, body = t('/apisix/admin/routes/1',
ngx.HTTP_PUT,
[[{
- "uri": "/echo",
+ "uri": "/chat",
"plugins": {
+ "ai-proxy": {
+ "provider": "openai",
+ "auth": { "header": { "Authorization": "Bearer
token" } },
+ "override": { "endpoint":
"http://127.0.0.1:1980/v1/chat/completions" }
+ },
"ai-aws-content-moderation": {
"comprehend": {
"access_key_id":
"$secret://vault/test1/foo/access_key_id",
@@ -128,12 +134,6 @@ Success! Data written to: kv/apisix/foo
"endpoint": "http://localhost:2668"
}
}
- },
- "upstream": {
- "type": "roundrobin",
- "nodes": {
- "127.0.0.1:1980": 1
- }
}
}]]
)
@@ -152,13 +152,13 @@ success
-=== TEST 3: good request should pass
+=== TEST 3: good request should pass (secret credentials reach Comprehend)
--- request
-POST /echo
+POST /chat
{"model":"gpt-4o-mini","messages":[{"role":"user","content":"good_request"}]}
--- error_code: 200
---- response_body chomp
-{"model":"gpt-4o-mini","messages":[{"role":"user","content":"good_request"}]}
+--- response_body_like eval
+qr/good_request/
@@ -170,8 +170,13 @@ POST /echo
local code, body = t('/apisix/admin/routes/1',
ngx.HTTP_PUT,
[[{
- "uri": "/echo",
+ "uri": "/chat",
"plugins": {
+ "ai-proxy": {
+ "provider": "openai",
+ "auth": { "header": { "Authorization": "Bearer
token" } },
+ "override": { "endpoint":
"http://127.0.0.1:1980/v1/chat/completions" }
+ },
"ai-aws-content-moderation": {
"comprehend": {
"access_key_id": "$env://ACCESS_KEY_ID",
@@ -180,12 +185,6 @@ POST /echo
"endpoint": "http://localhost:2668"
}
}
- },
- "upstream": {
- "type": "roundrobin",
- "nodes": {
- "127.0.0.1:1980": 1
- }
}
}]]
)
@@ -204,10 +203,10 @@ success
-=== TEST 5: good request should pass
+=== TEST 5: good request should pass (env credentials reach Comprehend)
--- request
-POST /echo
+POST /chat
{"model":"gpt-4o-mini","messages":[{"role":"user","content":"good_request"}]}
--- error_code: 200
---- response_body chomp
-{"model":"gpt-4o-mini","messages":[{"role":"user","content":"good_request"}]}
+--- response_body_like eval
+qr/good_request/
diff --git a/t/plugin/ai-aws-content-moderation.t
b/t/plugin/ai-aws-content-moderation.t
index 765bba1ab..70baeb501 100644
--- a/t/plugin/ai-aws-content-moderation.t
+++ b/t/plugin/ai-aws-content-moderation.t
@@ -36,6 +36,8 @@ _EOC_
$block->set_value("main_config", $main_config);
+ # Mock AWS Comprehend detectToxicContent: looks up the extracted text
+ # (TextSegments[1].Text) in the canned responses fixture.
my $http_config = $block->http_config // <<_EOC_;
server {
listen 2668;
@@ -97,7 +99,7 @@ run_tests();
__DATA__
-=== TEST 1: sanity
+=== TEST 1: sanity, ai-proxy + moderation
--- config
location /t {
content_by_lua_block {
@@ -105,21 +107,21 @@ __DATA__
local code, body = t('/apisix/admin/routes/1',
ngx.HTTP_PUT,
[[{
- "uri": "/echo",
+ "uri": "/chat",
"plugins": {
+ "ai-proxy": {
+ "provider": "openai",
+ "auth": { "header": { "Authorization": "Bearer
token" } },
+ "override": { "endpoint":
"http://127.0.0.1:1980/v1/chat/completions" }
+ },
"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
+ },
+ "deny_code": 400
}
}
}]]
@@ -138,19 +140,21 @@ passed
=== TEST 2: toxic request should fail
--- request
-POST /echo
-toxic
+POST /chat
+{ "messages": [ { "role": "user", "content": "toxic" } ] }
--- error_code: 400
---- response_body chomp
-request body exceeds toxicity threshold
+--- response_body_like eval
+qr/request body exceeds toxicity threshold/
=== TEST 3: good request should pass
--- request
-POST /echo
-good_request
+POST /chat
+{ "messages": [ { "role": "user", "content": "good_request" } ] }
--- error_code: 200
+--- response_body_like eval
+qr/good_request/
@@ -162,8 +166,13 @@ good_request
local code, body = t('/apisix/admin/routes/1',
ngx.HTTP_PUT,
[[{
- "uri": "/echo",
+ "uri": "/chat",
"plugins": {
+ "ai-proxy": {
+ "provider": "openai",
+ "auth": { "header": { "Authorization": "Bearer
token" } },
+ "override": { "endpoint":
"http://127.0.0.1:1980/v1/chat/completions" }
+ },
"ai-aws-content-moderation": {
"comprehend": {
"access_key_id": "access",
@@ -171,15 +180,8 @@ good_request
"region": "us-east-1",
"endpoint": "http://localhost:2668"
},
- "moderation_categories": {
- "PROFANITY": 0.5
- }
- }
- },
- "upstream": {
- "type": "roundrobin",
- "nodes": {
- "127.0.0.1:1980": 1
+ "moderation_categories": { "PROFANITY": 0.5 },
+ "deny_code": 400
}
}
}]]
@@ -198,29 +200,31 @@ passed
=== TEST 5: profane request should fail
--- request
-POST /echo
-profane
+POST /chat
+{ "messages": [ { "role": "user", "content": "profane" } ] }
--- error_code: 400
---- response_body chomp
-request body exceeds PROFANITY threshold
+--- response_body_like eval
+qr/request body exceeds PROFANITY threshold/
=== TEST 6: very profane request should also fail
--- request
-POST /echo
-very_profane
+POST /chat
+{ "messages": [ { "role": "user", "content": "very_profane" } ] }
--- error_code: 400
---- response_body chomp
-request body exceeds PROFANITY threshold
+--- response_body_like eval
+qr/request body exceeds PROFANITY threshold/
=== TEST 7: good_request should pass
--- request
-POST /echo
-good_request
+POST /chat
+{ "messages": [ { "role": "user", "content": "good_request" } ] }
--- error_code: 200
+--- response_body_like eval
+qr/good_request/
@@ -232,8 +236,13 @@ good_request
local code, body = t('/apisix/admin/routes/1',
ngx.HTTP_PUT,
[[{
- "uri": "/echo",
+ "uri": "/chat",
"plugins": {
+ "ai-proxy": {
+ "provider": "openai",
+ "auth": { "header": { "Authorization": "Bearer
token" } },
+ "override": { "endpoint":
"http://127.0.0.1:1980/v1/chat/completions" }
+ },
"ai-aws-content-moderation": {
"comprehend": {
"access_key_id": "access",
@@ -241,15 +250,8 @@ good_request
"region": "us-east-1",
"endpoint": "http://localhost:2668"
},
- "moderation_categories": {
- "PROFANITY": 0.7
- }
- }
- },
- "upstream": {
- "type": "roundrobin",
- "nodes": {
- "127.0.0.1:1980": 1
+ "moderation_categories": { "PROFANITY": 0.7 },
+ "deny_code": 400
}
}
}]]
@@ -268,41 +270,45 @@ passed
=== TEST 9: profane request should pass profanity check but fail toxicity check
--- request
-POST /echo
-profane
+POST /chat
+{ "messages": [ { "role": "user", "content": "profane" } ] }
--- error_code: 400
---- response_body chomp
-request body exceeds toxicity threshold
+--- response_body_like eval
+qr/request body exceeds toxicity threshold/
=== TEST 10: profane_but_not_toxic request should pass
--- request
-POST /echo
-profane_but_not_toxic
+POST /chat
+{ "messages": [ { "role": "user", "content": "profane_but_not_toxic" } ] }
--- error_code: 200
+--- response_body_like eval
+qr/profane_but_not_toxic/
=== TEST 11: but very profane request will fail
--- request
-POST /echo
-very_profane
+POST /chat
+{ "messages": [ { "role": "user", "content": "very_profane" } ] }
--- error_code: 400
---- response_body chomp
-request body exceeds PROFANITY threshold
+--- response_body_like eval
+qr/request body exceeds PROFANITY threshold/
=== TEST 12: good_request should pass
--- request
-POST /echo
-good_request
+POST /chat
+{ "messages": [ { "role": "user", "content": "good_request" } ] }
--- error_code: 200
+--- response_body_like eval
+qr/good_request/
-=== TEST 13: setup route with default fail_mode (skip)
+=== TEST 13: setup route without ai-proxy, default fail_mode (skip)
--- config
location /t {
content_by_lua_block {
@@ -323,9 +329,7 @@ good_request
},
"upstream": {
"type": "roundrobin",
- "nodes": {
- "127.0.0.1:1980": 1
- }
+ "nodes": { "127.0.0.1:1980": 1 }
}
}]]
)
@@ -341,19 +345,19 @@ passed
-=== TEST 14: non-JSON (multipart) request passes through by default (skip) to
upstream
+=== TEST 14: request without ai-proxy passes through unchecked (skip)
--- request
POST /echo
-name=alice&action=upload
---- more_headers
-Content-Type: multipart/form-data
+{ "messages": [ { "role": "user", "content": "toxic" } ] }
--- error_code: 200
---- response_body chomp
-name=alice&action=upload
+--- response_body_like eval
+qr/toxic/
+--- error_log
+ai-aws-content-moderation skipped: no ai instance picked
-=== TEST 15: setup route with fail_mode=error
+=== TEST 15: setup route without ai-proxy, fail_mode=error
--- config
location /t {
content_by_lua_block {
@@ -375,9 +379,7 @@ name=alice&action=upload
},
"upstream": {
"type": "roundrobin",
- "nodes": {
- "127.0.0.1:1980": 1
- }
+ "nodes": { "127.0.0.1:1980": 1 }
}
}]]
)
@@ -393,12 +395,10 @@ passed
-=== TEST 16: non-JSON request is rejected when fail_mode=error
+=== TEST 16: request without ai-proxy 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/
+{ "messages": [ { "role": "user", "content": "good_request" } ] }
+--- error_code: 500
+--- response_body_chomp
+no ai instance picked, ai-aws-content-moderation plugin must be used with
ai-proxy or ai-proxy-multi plugin
diff --git a/t/plugin/ai-aws-content-moderation2.t
b/t/plugin/ai-aws-content-moderation2.t
index 869fcf09d..364077701 100644
--- a/t/plugin/ai-aws-content-moderation2.t
+++ b/t/plugin/ai-aws-content-moderation2.t
@@ -41,7 +41,7 @@ run_tests();
__DATA__
-=== TEST 1: sanity
+=== TEST 1: sanity, ai-proxy + moderation (no Comprehend server listening)
--- config
location /t {
content_by_lua_block {
@@ -49,22 +49,20 @@ __DATA__
local code, body = t('/apisix/admin/routes/1',
ngx.HTTP_PUT,
[[{
- "uri": "/echo",
+ "uri": "/chat",
"plugins": {
+ "ai-proxy": {
+ "provider": "openai",
+ "auth": { "header": { "Authorization": "Bearer
token" } },
+ "override": { "endpoint":
"http://127.0.0.1:1980/v1/chat/completions" }
+ },
"ai-aws-content-moderation": {
"comprehend": {
"access_key_id": "access",
"secret_access_key": "ea+secret",
"region": "us-east-1",
"endpoint": "http://localhost:2668"
- },
- "llm_provider": "openai"
- }
- },
- "upstream": {
- "type": "roundrobin",
- "nodes": {
- "127.0.0.1:1980": 1
+ }
}
}
}]]
@@ -81,12 +79,12 @@ passed
-=== TEST 2: request should fail
+=== TEST 2: request fails when Comprehend is unreachable
--- request
-POST /echo
-toxic
+POST /chat
+{ "messages": [ { "role": "user", "content": "toxic" } ] }
--- error_code: 500
--- response_body chomp
-Comprehend:detectToxicContent() failed to connect to 'http://localhost:2668':
connection refused
+failed to send request to http://localhost: Comprehend:detectToxicContent()
failed to connect to 'http://localhost:2668': connection refused
--- error_log
failed to send request to http://localhost: Comprehend:detectToxicContent()
failed to connect to 'http://localhost:2668': connection refused