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 6333d9367 fix(ai-lakera-guard): update alert mode behavior with 
fail_open option and add tests for streaming responses (#13622)
6333d9367 is described below

commit 6333d93674c728f267294b4cddf6e74bc81d03e5
Author: Mohammad Izzraff Janius 
<[email protected]>
AuthorDate: Tue Jun 30 15:05:29 2026 +0800

    fix(ai-lakera-guard): update alert mode behavior with fail_open option and 
add tests for streaming responses (#13622)
---
 apisix/plugins/ai-lakera-guard.lua                |   3 +-
 docs/en/latest/plugins/ai-lakera-guard.md         |   2 +-
 docs/zh/latest/plugins/ai-lakera-guard.md         |   2 +-
 t/fixtures/openai/chat-streaming-lakera-error.sse |  10 ++
 t/plugin/ai-lakera-guard.t                        | 110 +++++++++++++++++++++-
 5 files changed, 120 insertions(+), 7 deletions(-)

diff --git a/apisix/plugins/ai-lakera-guard.lua 
b/apisix/plugins/ai-lakera-guard.lua
index 7c9154658..0399d5064 100644
--- a/apisix/plugins/ai-lakera-guard.lua
+++ b/apisix/plugins/ai-lakera-guard.lua
@@ -240,8 +240,7 @@ function _M.lua_body_filter(conf, ctx, headers, body)
     end
 
     if ctx.var.request_type == "ai_stream" then
-        -- alert (shadow) mode non-blocking
-        if conf.action == "alert" then
+        if conf.action == "alert" and conf.fail_open then
             if ctx.var.llm_request_done and not ctx.lakera_response_decided 
then
                 ctx.lakera_response_decided = "clean"
                 local text = ctx.var.llm_response_text
diff --git a/docs/en/latest/plugins/ai-lakera-guard.md 
b/docs/en/latest/plugins/ai-lakera-guard.md
index 96263d138..c43a432aa 100644
--- a/docs/en/latest/plugins/ai-lakera-guard.md
+++ b/docs/en/latest/plugins/ai-lakera-guard.md
@@ -79,7 +79,7 @@ Response scanning (`output`/`both`) requires 
`ai-proxy`/`ai-proxy-multi`, which
 
 ### Streaming responses
 
-When the response is streamed (`stream: true`) in `block` mode, the Plugin 
**buffers the full SSE response, scans the assembled completion once, and only 
then releases it** to the client. This is required to enforce a block: partial 
flagged tokens must never reach the client. A clean response is forwarded with 
its original SSE framing intact; a flagged response is replaced with a 
provider-compatible deny SSE terminated by `data: [DONE]`. In `alert` mode the 
Plugin does **not** buffer — c [...]
+When the response is streamed (`stream: true`) in `block` mode, the Plugin 
**buffers the full SSE response, scans the assembled completion once, and only 
then releases it** to the client. This is required to enforce a block: partial 
flagged tokens must never reach the client. A clean response is forwarded with 
its original SSE framing intact; a flagged response is replaced with a 
provider-compatible deny SSE terminated by `data: [DONE]`. In `alert` mode, 
buffering follows `fail_open`: wi [...]
 
 :::note
 
diff --git a/docs/zh/latest/plugins/ai-lakera-guard.md 
b/docs/zh/latest/plugins/ai-lakera-guard.md
index 537a41a34..46750da4d 100644
--- a/docs/zh/latest/plugins/ai-lakera-guard.md
+++ b/docs/zh/latest/plugins/ai-lakera-guard.md
@@ -79,7 +79,7 @@ import TabItem from '@theme/TabItem';
 
 ### 流式响应
 
-当响应为流式(`stream: true`)且处于 `block` 模式时,插件会**缓冲完整的 SSE 
响应,对组装后的补全内容扫描一次,然后才将其释放**给客户端。这是实现拦截所必需的:被标记的部分 token 绝不能到达客户端。通过扫描的响应会以其原始 
SSE 帧格式原样转发;被标记的响应会被替换为以 `data: [DONE]` 结尾的、与提供商兼容的拒绝 SSE。在 `alert` 
模式下插件**不**缓冲——数据块逐 token 实时放行,组装后的补全内容仅用于记录判定结果(参见[先以影子模式上线](#先以影子模式上线))。
+当响应为流式(`stream: true`)且处于 `block` 模式时,插件会**缓冲完整的 SSE 
响应,对组装后的补全内容扫描一次,然后才将其释放**给客户端。这是实现拦截所必需的:被标记的部分 token 绝不能到达客户端。通过扫描的响应会以其原始 
SSE 帧格式原样转发;被标记的响应会被替换为以 `data: [DONE]` 结尾的、与提供商兼容的拒绝 SSE。在 `alert` 模式下,是否缓冲取决于 
`fail_open`:当 `fail_open: true` 时,数据块逐 token 实时放行(此时不会发生拦截);当 `fail_open: 
false`(默认值)时,会像 `block` 模式一样缓冲流,以便 Lakera 的错误/超时仍能 
fail-closed,而被标记的判定结果会被放行并仅记录日志(参见[先以影子模式上线](#先以影子模式上线))。
 
 :::note
 
diff --git a/t/fixtures/openai/chat-streaming-lakera-error.sse 
b/t/fixtures/openai/chat-streaming-lakera-error.sse
new file mode 100644
index 000000000..f1465a5e1
--- /dev/null
+++ b/t/fixtures/openai/chat-streaming-lakera-error.sse
@@ -0,0 +1,10 @@
+data: 
{"id":"chatcmpl-err123","object":"chat.completion.chunk","created":1700000000,"model":"gpt-4o-2024-05-13","choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null}]}
+
+data: 
{"id":"chatcmpl-err123","object":"chat.completion.chunk","created":1700000000,"model":"gpt-4o-2024-05-13","choices":[{"index":0,"delta":{"content":"trigger
 "},"finish_reason":null}]}
+
+data: 
{"id":"chatcmpl-err123","object":"chat.completion.chunk","created":1700000000,"model":"gpt-4o-2024-05-13","choices":[{"index":0,"delta":{"content":"lakera-error
 here"},"finish_reason":null}]}
+
+data: 
{"id":"chatcmpl-err123","object":"chat.completion.chunk","created":1700000000,"model":"gpt-4o-2024-05-13","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":10,"completion_tokens":8,"total_tokens":18}}
+
+data: [DONE]
+
diff --git a/t/plugin/ai-lakera-guard.t b/t/plugin/ai-lakera-guard.t
index 2643af021..8ae98b449 100644
--- a/t/plugin/ai-lakera-guard.t
+++ b/t/plugin/ai-lakera-guard.t
@@ -985,7 +985,7 @@ fail_open=true, releasing unscanned
 
 
 
-=== TEST 40: create a direction=output alert route to the multi-chunk 
streaming mock
+=== TEST 40: create a direction=output alert route (default fail_open) to the 
multi-chunk streaming mock
 --- config
     location /t {
         content_by_lua_block {
@@ -1023,13 +1023,13 @@ passed
 
 
 
-=== TEST 41: alert mode streams a multi-chunk response through live without 
buffering heartbeats
+=== TEST 41: alert + fail_open=false buffers the multi-chunk stream 
(heartbeats) then releases the flagged tokens
 --- request
 POST /anything
 { "messages": [ { "role": "user", "content": "say something bad" } ], 
"stream": true }
 --- error_code: 200
 --- response_body_like eval
-qr/\Adata:.*injection payload.*\[DONE\]/s
+qr/\A:.*injection payload.*\[DONE\]/s
 --- error_log
 ai-lakera-guard: response flagged by Lakera Guard
 
@@ -1318,3 +1318,107 @@ X-AI-Fixture: openai/chat-streaming-injection.sse
 qr/response flagged by Lakera Guard/
 --- grep_error_log_out
 response flagged by Lakera Guard
+
+
+
+=== TEST 54: create a direction=output alert route with fail_open=false for 
the Lakera-error stream
+--- 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": "alert",
+                          "fail_open": false
+                      }
+                    }
+                }]]
+            )
+
+            if code >= 300 then
+                ngx.status = code
+            end
+            ngx.say(body)
+        }
+    }
+--- response_body
+passed
+
+
+
+=== TEST 55: alert + fail_open=false - a Lakera error on the streamed response 
fails closed (no tokens leak)
+--- request
+POST /anything
+{ "messages": [ { "role": "user", "content": "say hello" } ], "stream": true }
+--- more_headers
+X-AI-Fixture: openai/chat-streaming-lakera-error.sse
+--- error_code: 200
+--- response_body_like eval
+qr/\A(?!.*lakera-error here).*"content":"Response blocked by Lakera 
Guard".*\[DONE\]/s
+--- error_log
+fail_open=false, blocking response
+
+
+
+=== TEST 56: create a direction=output alert + fail_open=true route to the 
multi-chunk streaming mock
+--- 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:1981/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": "alert",
+                          "fail_open": true
+                      }
+                    }
+                }]]
+            )
+
+            if code >= 300 then
+                ngx.status = code
+            end
+            ngx.say(body)
+        }
+    }
+--- response_body
+passed
+
+
+
+=== TEST 57: alert + fail_open=true streams the multi-chunk response through 
live (no buffering heartbeats)
+--- request
+POST /anything
+{ "messages": [ { "role": "user", "content": "say something bad" } ], 
"stream": true }
+--- error_code: 200
+--- response_body_like eval
+qr/\Adata:.*injection payload.*\[DONE\]/s
+--- error_log
+ai-lakera-guard: response flagged by Lakera Guard

Reply via email to