This is an automated email from the ASF dual-hosted git repository.
nic-6443 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 421573156 fix(ai-aliyun-content-moderation): return nil instead of
ngx.OK in lua_body_filter (#13623)
421573156 is described below
commit 4215731562353dd3acf105b1d5e1ab61dbbbed95
Author: Mohammad Izzraff Janius
<[email protected]>
AuthorDate: Wed Jul 1 09:31:43 2026 +0800
fix(ai-aliyun-content-moderation): return nil instead of ngx.OK in
lua_body_filter (#13623)
---
apisix/plugins/ai-aliyun-content-moderation.lua | 11 +-
t/lib/fixture_loader.lua | 16 +++
t/plugin/ai-lakera-guard-chain.t | 162 ++++++++++++++++++++++++
3 files changed, 185 insertions(+), 4 deletions(-)
diff --git a/apisix/plugins/ai-aliyun-content-moderation.lua
b/apisix/plugins/ai-aliyun-content-moderation.lua
index 710bda0ea..6e41364d9 100644
--- a/apisix/plugins/ai-aliyun-content-moderation.lua
+++ b/apisix/plugins/ai-aliyun-content-moderation.lua
@@ -437,8 +437,11 @@ function _M.lua_body_filter(conf, ctx, headers, body)
if not ctx.var.llm_response_text then
return
end
- response_content_moderation(ctx, conf, ctx.var.llm_response_text)
- release_cm_httpc(ctx, conf)
+ if not ctx.ai_aliyun_response_moderated then
+ response_content_moderation(ctx, conf, ctx.var.llm_response_text)
+ release_cm_httpc(ctx, conf)
+ ctx.ai_aliyun_response_moderated = true
+ end
local events = sse.decode(body)
for _, event in ipairs(events) do
if proto and proto.is_data_event(event) then
@@ -461,10 +464,10 @@ function _M.lua_body_filter(conf, ctx, headers, body)
end
table.insert(raw_events, sse.encode(event))
end
- if not contains_done_event and proto then
+ if not contains_done_event and proto and ctx.var.llm_request_done then
table.insert(raw_events, proto.build_done_event())
end
- return ngx_ok, table.concat(raw_events, "\n")
+ return nil, table.concat(raw_events, "\n")
end
if conf.stream_check_mode == "realtime" then
diff --git a/t/lib/fixture_loader.lua b/t/lib/fixture_loader.lua
index de1273469..b67dc7b7f 100644
--- a/t/lib/fixture_loader.lua
+++ b/t/lib/fixture_loader.lua
@@ -22,6 +22,7 @@
-- Supported headers:
-- X-AI-Fixture: <path> -- fixture file path relative to t/fixtures/
-- X-AI-Fixture-Status: <code> -- optional HTTP status code (default 200)
+-- X-AI-Fixture-Flush-Events -- for .sse fixtures, flush each SSE event
separately
local _M = {}
local io = io
@@ -99,6 +100,17 @@ local function apply_template(content)
end
+-- Print each SSE event (delimited by a blank line) as its own flushed chunk,
+-- so the upstream is seen as a multi-chunk stream rather than one body.
+local function flush_sse_events(content)
+ for event in content:gmatch("(.-\n\n)") do
+ ngx.print(event)
+ ngx.flush(true)
+ ngx.sleep(0.01)
+ end
+end
+
+
-- Serve a fixture based on the X-AI-Fixture request header.
-- For .sse files: sets Content-Type to text/event-stream and sends content
as-is.
-- For other files: sets Content-Type to application/json.
@@ -130,6 +142,10 @@ function _M.dispatch()
ngx.header["Content-Type"] = "text/event-stream"
ngx.header["Cache-Control"] = "no-cache"
ngx.header["Transfer-Encoding"] = "chunked"
+ if headers["X-AI-Fixture-Flush-Events"] then
+ flush_sse_events(content)
+ return
+ end
ngx.print(content)
ngx.flush(true)
else
diff --git a/t/plugin/ai-lakera-guard-chain.t b/t/plugin/ai-lakera-guard-chain.t
new file mode 100644
index 000000000..56c5c91ef
--- /dev/null
+++ b/t/plugin/ai-lakera-guard-chain.t
@@ -0,0 +1,162 @@
+#
+# 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.
+#
+
+use t::APISIX 'no_plan';
+
+log_level("info");
+repeat_each(1);
+no_long_string();
+no_root_location();
+
+
+add_block_preprocessor(sub {
+ my ($block) = @_;
+
+ if (!defined $block->request) {
+ $block->set_value("request", "GET /t");
+ }
+
+ # Two response-path moderation backends share one route:
+ # * Lakera Guard on 6724 -- flags when the scanned text contains
+ # "injection" (the assembled streamed completion), else clean.
+ # * Aliyun text moderation on 6725 -- always returns "safe".
+ # ai-aliyun-content-moderation has the higher priority (1029) and runs its
+ # final_packet body filter before ai-lakera-guard (1028).
+ my $http_config = $block->http_config // <<_EOC_;
+ server {
+ listen 6724;
+ default_type 'application/json';
+ location /v2/guard {
+ content_by_lua_block {
+ local core = require("apisix.core")
+ local fixture_loader = require("lib.fixture_loader")
+ ngx.req.read_body()
+ local body = ngx.req.get_body_data() or ""
+ local fixture_name = "lakera/scan-clean.json"
+ if core.string.find(body, "injection") then
+ fixture_name = "lakera/scan-flagged.json"
+ end
+ local content, load_err = fixture_loader.load(fixture_name)
+ if not content then
+ ngx.status = 500
+ ngx.say(load_err)
+ return
+ end
+ ngx.status = 200
+ ngx.print(content)
+ }
+ }
+ }
+ server {
+ listen 6725;
+ default_type 'application/json';
+ location / {
+ content_by_lua_block {
+ require("lib.server").aliyun_moderation()
+ }
+ }
+ }
+_EOC_
+
+ $block->set_value("http_config", $http_config);
+});
+
+run_tests();
+
+__DATA__
+
+=== TEST 1: create a route chaining ai-lakera-guard (output/block) after
ai-aliyun-content-moderation (default priorities)
+--- 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": "/anything",
+ "plugins": {
+ "ai-proxy": {
+ "provider": "openai-compatible",
+ "auth": { "header": { "Authorization": "Bearer
token" } },
+ "options": { "model": "gpt-4" },
+ "override": { "endpoint":
"http://127.0.0.1:1980/v1/chat/completions" },
+ "ssl_verify": false
+ },
+ "ai-lakera-guard": {
+ "api_key": "test-key",
+ "lakera_endpoint": "http://127.0.0.1:6724/v2/guard",
+ "direction": "output",
+ "action": "block"
+ },
+ "ai-aliyun-content-moderation": {
+ "endpoint": "http://127.0.0.1:6725",
+ "region_id": "cn-shanghai",
+ "access_key_id": "fake-key-id",
+ "access_key_secret": "fake-key-secret",
+ "check_request": false,
+ "check_response": true
+ }
+ }
+ }]]
+ )
+ if code >= 300 then
+ ngx.status = code
+ end
+ ngx.say(body)
+ }
+ }
+--- response_body
+passed
+
+
+
+=== TEST 2: a flagged streamed response is still scanned and blocked by
ai-lakera-guard when chained after ai-aliyun-content-moderation
+--- request
+POST /anything
+{ "messages": [ { "role": "user", "content": "say something bad" } ],
"stream": true }
+--- more_headers
+X-AI-Fixture: openai/chat-streaming-injection.sse
+--- error_code: 200
+--- response_body_like eval
+qr/\A(?!.*injection payload).*"content":"Response blocked by Lakera
Guard".*\[DONE\]/s
+--- error_log
+ai-lakera-guard: response flagged by Lakera Guard
+
+
+
+=== TEST 3: a clean streamed response still passes through the chain to the
client
+--- request
+POST /anything
+{ "messages": [ { "role": "user", "content": "say hello" } ], "stream": true }
+--- more_headers
+X-AI-Fixture: openai/chat-streaming.sse
+--- error_code: 200
+--- response_body_like eval
+qr/\A(?!.*Response blocked by Lakera Guard).*Hello.*\[DONE\]/s
+
+
+
+=== TEST 4: split usage and done chunks produce exactly one terminal event
+--- request
+POST /anything
+{ "messages": [ { "role": "user", "content": "say hello" } ], "stream": true }
+--- more_headers
+X-AI-Fixture: openai/chat-streaming.sse
+X-AI-Fixture-Flush-Events: true
+--- error_code: 200
+--- response_body_like eval
+qr/\A(?!.*\[DONE\].*\[DONE\]).*Hello.*\[DONE\]/s