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

Reply via email to