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