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 1f6120d19 fix(ai-aliyun-content-moderation): report final results 
without usage (#13922)
1f6120d19 is described below

commit 1f6120d19bf4935df1cf9a5647fa7f8cb40f28e5
Author: Nic <[email protected]>
AuthorDate: Wed Sep 9 13:27:32 2026 +0800

    fix(ai-aliyun-content-moderation): report final results without usage 
(#13922)
---
 apisix/plugins/ai-aliyun-content-moderation.lua    |  58 ++--
 apisix/plugins/ai-lakera-guard.lua                 |   4 +-
 apisix/plugins/ai-protocols/anthropic-messages.lua |  20 ++
 apisix/plugins/ai-protocols/openai-chat.lua        |  23 ++
 apisix/plugins/ai-protocols/openai-responses.lua   |   6 +
 apisix/plugins/ai-providers/base.lua               |  20 +-
 .../latest/plugins/ai-aliyun-content-moderation.md |  12 +
 .../latest/plugins/ai-aliyun-content-moderation.md |  12 +
 t/fixtures/aliyun/result-anthropic-deny.sse        |  18 ++
 t/fixtures/aliyun/result-anthropic-error.sse       |  18 ++
 t/fixtures/aliyun/result-anthropic-null-delta.sse  |  18 ++
 t/fixtures/aliyun/result-anthropic-safe.sse        |  18 ++
 .../aliyun/result-anthropic-stop-sequence.sse      |  18 ++
 t/fixtures/aliyun/result-anthropic-usage.sse       |  18 ++
 t/fixtures/aliyun/result-chat-clean-eof.sse        |   8 +
 t/fixtures/aliyun/result-chat-deny.sse             |   8 +
 t/fixtures/aliyun/result-chat-error.sse            |   8 +
 t/fixtures/aliyun/result-chat-failure.sse          |   8 +
 t/fixtures/aliyun/result-chat-length.sse           |  10 +
 t/fixtures/aliyun/result-chat-multiple-choices.sse |  10 +
 t/fixtures/aliyun/result-chat-multiple-reasons.sse |  10 +
 t/fixtures/aliyun/result-chat-safe.sse             |   8 +
 t/fixtures/aliyun/result-chat-tool_calls.sse       |  10 +
 t/fixtures/aliyun/result-chat-usage.sse            |  10 +
 t/fixtures/aliyun/result-responses-deny.sse        |  24 ++
 t/fixtures/aliyun/result-responses-eof.sse         |  21 ++
 t/fixtures/aliyun/result-responses-error.sse       |  24 ++
 t/fixtures/aliyun/result-responses-failed.sse      |  24 ++
 t/fixtures/aliyun/result-responses-incomplete.sse  |  24 ++
 t/fixtures/aliyun/result-responses-safe.sse        |  24 ++
 t/fixtures/aliyun/result-responses-usage.sse       |  24 ++
 .../openai/chat-streaming-incomplete-no-usage.sse  |   9 +
 .../openai/chat-streaming-injection-no-usage.sse   |  10 +
 t/fixtures/openai/moderation-eof-no-usage.sse      |   6 +
 t/fixtures/openai/moderation-incomplete-event.sse  |   7 +
 t/fixtures/openai/moderation-no-usage.sse          |   8 +
 t/fixtures/openai/moderation-with-usage.sse        |  10 +
 t/lib/ai_moderation_result.lua                     | 255 ++++++++++++++++
 t/plugin/ai-aliyun-content-moderation-result.t     | 322 +++++++++++++++++++++
 .../ai-aliyun-content-moderation-stream-usage.t    | 189 ++++++++++++
 t/plugin/ai-lakera-guard.t                         | 108 ++++++-
 t/plugin/ai-proxy-stream-truncated.t               |   8 +-
 42 files changed, 1414 insertions(+), 36 deletions(-)

diff --git a/apisix/plugins/ai-aliyun-content-moderation.lua 
b/apisix/plugins/ai-aliyun-content-moderation.lua
index fe7748cb2..a0f866bda 100644
--- a/apisix/plugins/ai-aliyun-content-moderation.lua
+++ b/apisix/plugins/ai-aliyun-content-moderation.lua
@@ -172,7 +172,6 @@ local function calculate_sign(params, secret)
     table.sort(params_arr)
     local canonical_str = table.concat(params_arr, "&")
     local str_to_sign = "POST&%2F&" .. ngx.escape_uri(canonical_str)
-    core.log.debug("string to calculate signature: ", str_to_sign)
     return ngx.encode_base64(ngx.hmac_sha1(secret, str_to_sign))
 end
 
@@ -284,12 +283,13 @@ local function deny_message(ctx, message)
     local usage = ctx.llm_raw_usage
         or (proto.empty_usage and proto.empty_usage())
         or { prompt_tokens = 0, completion_tokens = 0, total_tokens = 0 }
+    message = message or "Your request violate our content policy."
     return proto.build_deny_response({
-        text = message or "Your request violate our content policy.",
+        text = message,
         model = model,
         usage = usage,
         stream = stream,
-    })
+    }), message
 end
 
 
@@ -500,45 +500,67 @@ function _M.lua_body_filter(conf, ctx, headers, body)
     local proto = protocols.get(ctx.ai_client_protocol)
 
     if conf.stream_check_mode == "final_packet" then
-        if not ctx.var.llm_response_text then
+        local content = ctx.var.llm_response_text
+        if not content or content == "" or ctx.ai_stream_aborted or 
ctx.ai_stream_failed
+           or ctx.aliyun_cm_done_sent then
             return
         end
         if not ctx.ai_aliyun_response_moderated then
-            response_content_moderation(ctx, conf, ctx.var.llm_response_text)
+            ctx.var.llm_content_risk_level = nil
+            local _, _, message = response_content_moderation(ctx, conf, 
content)
+            ctx.aliyun_cm_deny_message = message or ""
             release_cm_httpc(ctx, conf)
             ctx.ai_aliyun_response_moderated = true
         end
-        local events = sse.decode(body)
+        if not ctx.var.llm_content_risk_level then
+            return
+        end
+        local events = sse.decode(body or "")
         for _, event in ipairs(events) do
-            if proto and proto.is_data_event(event) then
+            if proto.is_data_event(event) then
                 local data, err = core.json.decode(event.data)
                 if not data then
                     core.log.warn("failed to decode SSE data: ", err)
                     goto CONTINUE
                 end
                 data.risk_level = ctx.var.llm_content_risk_level
+                data.deny_message = ctx.aliyun_cm_deny_message
                 event.data = core.json.encode(data)
+                if not ctx.ai_stream_has_usage and event.type == 
"message_delta"
+                   and type(data.delta) == "table" then
+                    -- Anthropic SDKs overwrite stop fields on every 
message_delta.
+                    ctx.aliyun_cm_message_delta = data.delta
+                end
             end
             ::CONTINUE::
         end
 
         local raw_events = {}
-        local contains_done_event = false
+        local done_index
         for _, event in ipairs(events) do
-            if proto and proto.is_done_event(event) then
-                contains_done_event = true
+            if proto.is_done_event(event) then
+                done_index = #raw_events + 1
             end
             table.insert(raw_events, sse.encode(event))
         end
-        -- llm_request_done only means "no more content is coming", which is 
also
-        -- set when a stream is cut short (upstream read error, stream limit).
-        -- ctx.ai_stream_aborted marks those cases: synthesizing a terminator 
there
-        -- would tell the client a truncated response completed successfully.
-        if not contains_done_event and proto and ctx.var.llm_request_done
-           and not ctx.ai_stream_aborted then
-            table.insert(raw_events, proto.build_done_event())
+        -- Converters dispatch several events after the source has completed.
+        -- Only a client terminator or the empty EOF flush can finish its 
output.
+        local eof = ctx.var.llm_request_done and (not body or body == "")
+        if not ctx.ai_stream_has_usage and proto.build_moderation_event
+           and (done_index or eof) then
+            table.insert(raw_events, done_index or #raw_events + 1,
+                sse.encode(proto.build_moderation_event({
+                    deny_message = ctx.aliyun_cm_deny_message,
+                    risk_level = ctx.var.llm_content_risk_level,
+                    model = ctx.var.request_llm_model,
+                    delta = ctx.aliyun_cm_message_delta or {},
+                })))
+        end
+        if not done_index and eof and proto.build_moderation_event then
+            table.insert(raw_events, proto.build_done_event() .. "\n\n")
         end
-        return nil, table.concat(raw_events, "\n")
+        ctx.aliyun_cm_done_sent = done_index ~= nil or eof
+        return nil, table.concat(raw_events)
     end
 
     if conf.stream_check_mode == "realtime" then
diff --git a/apisix/plugins/ai-lakera-guard.lua 
b/apisix/plugins/ai-lakera-guard.lua
index 0f159625f..444e83fcb 100644
--- a/apisix/plugins/ai-lakera-guard.lua
+++ b/apisix/plugins/ai-lakera-guard.lua
@@ -268,13 +268,13 @@ function _M.lua_body_filter(conf, ctx, headers, body)
         if not text then
             if conf.fail_open then
                 core.log.warn("ai-lakera-guard: streamed response ended 
without ",
-                              "an assembled completion (no upstream usage 
event?); ",
+                              "an assembled completion; ",
                               "fail_open=true, releasing unscanned")
                 ctx.lakera_response_decided = "clean"
                 return nil, concat(buffer)
             end
             core.log.error("ai-lakera-guard: streamed response ended without ",
-                           "an assembled completion (no upstream usage 
event?); ",
+                           "an assembled completion; ",
                            "fail_open=false, blocking response")
             ctx.lakera_response_decided = "blocked"
             return ngx.OK, deny_message(ctx, conf, 
conf.response_failure_message)
diff --git a/apisix/plugins/ai-protocols/anthropic-messages.lua 
b/apisix/plugins/ai-protocols/anthropic-messages.lua
index 7cef9a473..75971e172 100644
--- a/apisix/plugins/ai-protocols/anthropic-messages.lua
+++ b/apisix/plugins/ai-protocols/anthropic-messages.lua
@@ -424,12 +424,32 @@ function _M.empty_usage()
 end
 
 
+--- Message metadata can be appended after content blocks have closed.
+function _M.build_moderation_event(opts)
+    return {
+        type = "message_delta",
+        data = core.json.encode({
+            type = "message_delta",
+            delta = opts.delta,
+            usage = _M.empty_usage(),
+            risk_level = opts.risk_level,
+            deny_message = opts.deny_message or "",
+        }),
+    }
+end
+
+
 --- Check if an SSE event is a data event (contains parseable content).
 function _M.is_data_event(event)
     return event.type == "content_block_delta" or event.type == "message_delta"
 end
 
 
+function _M.is_error_event(event)
+    return event.type == "error"
+end
+
+
 --- Check if an SSE event is the terminal/done event.
 function _M.is_done_event(event)
     return event.type == "message_stop"
diff --git a/apisix/plugins/ai-protocols/openai-chat.lua 
b/apisix/plugins/ai-protocols/openai-chat.lua
index 4ac1cf0c7..a92b5164f 100644
--- a/apisix/plugins/ai-protocols/openai-chat.lua
+++ b/apisix/plugins/ai-protocols/openai-chat.lua
@@ -22,8 +22,10 @@
 local core = require("apisix.core")
 local uuid = require("resty.jit-uuid")
 local table = table
+local setmetatable = setmetatable
 local type = type
 local ipairs = ipairs
+local ngx_time = ngx.time
 
 local _M = {}
 
@@ -410,12 +412,33 @@ function _M.empty_usage()
 end
 
 
+--- Build a final moderation chunk without ending or replacing the original 
stream.
+function _M.build_moderation_event(opts)
+    local data = {
+        id = uuid.generate_v4(),
+        object = "chat.completion.chunk",
+        created = ngx_time(),
+        model = opts.model,
+        choices = setmetatable({}, core.json.array_mt),
+        usage = _M.empty_usage(),
+        risk_level = opts.risk_level,
+        deny_message = opts.deny_message or "",
+    }
+    return { type = "message", data = core.json.encode(data) }
+end
+
+
 --- Check if an SSE event is a data event (contains parseable content).
 function _M.is_data_event(event)
     return event.type == "message" and event.data ~= "[DONE]"
 end
 
 
+function _M.is_error_event(event, data)
+    return type(data) == "table" and type(data.error) == "table"
+end
+
+
 --- Check if an SSE event is the terminal/done event.
 function _M.is_done_event(event)
     return event.data == "[DONE]"
diff --git a/apisix/plugins/ai-protocols/openai-responses.lua 
b/apisix/plugins/ai-protocols/openai-responses.lua
index cd38b2296..485f8be4e 100644
--- a/apisix/plugins/ai-protocols/openai-responses.lua
+++ b/apisix/plugins/ai-protocols/openai-responses.lua
@@ -455,6 +455,12 @@ function _M.is_data_event(event)
 end
 
 
+function _M.is_error_event(event)
+    return event.type == "error" or event.type == "response.failed"
+           or event.type == "response.incomplete"
+end
+
+
 --- Check if an SSE event is the terminal/done event.
 function _M.is_done_event(event)
     return event.type == "response.completed"
diff --git a/apisix/plugins/ai-providers/base.lua 
b/apisix/plugins/ai-providers/base.lua
index 41e3bffaa..8612f5dfd 100644
--- a/apisix/plugins/ai-providers/base.lua
+++ b/apisix/plugins/ai-providers/base.lua
@@ -460,6 +460,8 @@ function _M.parse_streaming_response(self, ctx, res, 
target_proto, converter, co
     -- attempt emitted no output (with headers sent, the retry dies earlier in
     -- core.response.set_header), so the flag is stale, not protective
     ctx.ai_stream_aborted = nil
+    ctx.ai_stream_has_usage = nil
+    ctx.ai_stream_failed = nil
     -- same for the completion flag. An attempt can set it and still produce no
     -- downstream output -- a converter fed a [DONE]-only stream emits nothing 
--
     -- which returns 502 and lets ai-proxy-multi fall back inside this same
@@ -607,6 +609,7 @@ function _M.parse_streaming_response(self, ctx, res, 
target_proto, converter, co
         if not chunk then
             local sse_rem = table.concat(sse_parts)
             if #sse_rem > 0 then
+                ctx.ai_stream_aborted = "incomplete_frame"
                 core.log.warn("dropping incomplete stream frame at EOF, size: 
",
                               #sse_rem)
             end
@@ -633,6 +636,9 @@ function _M.parse_streaming_response(self, ctx, res, 
target_proto, converter, co
                 flush_thread = nil
             end
             if output_sent and not ctx.var.llm_request_done then
+                if #sse_rem == 0 then
+                    ctx.var.llm_response_text = table.concat(contents, "")
+                end
                 ctx.var.llm_request_done = true
                 plugin.lua_response_filter(ctx, res.headers, "", nil, true)
             end
@@ -665,6 +671,7 @@ function _M.parse_streaming_response(self, ctx, res, 
target_proto, converter, co
         -- One-pass split + decode: finds all complete SSE events and the
         -- trailing remainder in a single forward scan (no PCRE, no double 
scan).
         local events, remainder = framing.decode_buf(candidate)
+        local complete = candidate:sub(1, #candidate - #remainder)
         local max_remainder = framing.max_remainder or 1024 * 1024
         if #remainder > max_remainder then
             core.log.warn("stream remainder exceeded ", max_remainder, " 
bytes, resetting")
@@ -682,6 +689,10 @@ function _M.parse_streaming_response(self, ctx, res, 
target_proto, converter, co
         for _, event in ipairs(events) do
             -- Target protocol parses the provider's SSE format
             local parsed = target_proto.parse_sse_event(event, ctx, sse_state)
+            if target_proto.is_error_event
+               and target_proto.is_error_event(event, parsed and parsed.data) 
then
+                ctx.ai_stream_failed = true
+            end
             if parsed and parsed.has_tool_call then
                 ctx.var.llm_has_tool_calls = "true"
             end
@@ -707,6 +718,7 @@ function _M.parse_streaming_response(self, ctx, res, 
target_proto, converter, co
             end
 
             if parsed.usage then
+                ctx.ai_stream_has_usage = true
                 core.log.info("got token usage from ai service: ",
                                     core.json.delay_encode(parsed.raw_usage or 
parsed.usage))
                 merge_usage(ctx, parsed)
@@ -722,6 +734,7 @@ function _M.parse_streaming_response(self, ctx, res, 
target_proto, converter, co
             end
 
             if parsed.type == "done" or parsed.type == "usage_and_done" then
+                ctx.var.llm_response_text = table.concat(contents, "")
                 ctx.var.llm_request_done = true
                 protocol_completed = true
             end
@@ -754,9 +767,12 @@ function _M.parse_streaming_response(self, ctx, res, 
target_proto, converter, co
                     return
                 end
             end
-        else
+        elseif ctx.ai_stream_framing ~= "sse" or complete ~= "" then
+            -- Native SSE filters need complete frames just like converters do.
+            -- Keep the original bytes, including comments and blank lines.
+            local downstream = ctx.ai_stream_framing == "sse" and complete or 
chunk
             local ok, flush_err = plugin.lua_response_filter(
-                ctx, res.headers, chunk, no_flush, true)
+                ctx, res.headers, downstream, no_flush, true)
             if not ok then
                 abort_on_disconnect(flush_err)
                 return
diff --git a/docs/en/latest/plugins/ai-aliyun-content-moderation.md 
b/docs/en/latest/plugins/ai-aliyun-content-moderation.md
index 7019149ed..b2e548311 100644
--- a/docs/en/latest/plugins/ai-aliyun-content-moderation.md
+++ b/docs/en/latest/plugins/ai-aliyun-content-moderation.md
@@ -74,6 +74,18 @@ The `ai-aliyun-content-moderation` Plugin should be used 
with either [`ai-proxy`
 | ssl_verify | boolean | False | `true` | | If `true`, enable SSL certificate 
verification. |
 | fail_mode | string | False | `"skip"` | `skip`, `warn`, `error` | Behavior 
when the request is not a recognized AI request that this plugin can inspect 
(for example, plain HTTP traffic 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. 
|
 
+In `final_packet` mode, `risk_level` and `deny_message` are added together to 
existing data events after the assembled response text is available. Rejected 
responses use the actual denial message; allowed responses use an empty string. 
Existing response content and token usage are preserved. The result is 
informational and cannot retract content already sent to the client.
+
+When the upstream omits `usage`, moderation runs at the end of the stream:
+
+- **OpenAI Chat Completions:** an additional `chat.completion.chunk` with 
`risk_level`, `deny_message`, and zero-valued `usage` is inserted before 
`[DONE]`. This is an empty usage chunk with `choices: []`: the denial message 
appears only in the top-level `deny_message` field, without appending text or 
changing the upstream finish reason.
+- **Anthropic Messages:** an additional `message_delta` carries `risk_level`, 
`deny_message`, zero-valued `usage`, and the original stop information before 
`message_stop`. Content blocks and `message_start` are not replayed.
+- **OpenAI Responses:** the existing `response.completed` event carries 
`risk_level` and `deny_message`; its output and usage are preserved. EOF 
without `response.completed` does not produce a synthetic completed response.
+
+Clients must consume through the stream terminator to receive an injected 
result. Usage-bearing streams receive in-place fields without an additional 
result event, so their reported usage is unchanged. New result events report 
zero usage; gateway accounting continues to use upstream usage. If moderation 
fails without returning a risk level, no result is fabricated. Error events are 
preserved, and aborted streams do not receive a synthesized terminator.
+
+Read moderation extensions from the SSE events. For example, the OpenAI Python 
SDK exposes Responses extensions through `responses.create(stream=True)`, while 
its higher-level `responses.stream()` wrapper can discard unknown top-level 
fields.
+
 ## Examples
 
 The following examples use OpenAI as the Upstream service provider. Before 
proceeding, create an [OpenAI account](https://openai.com) and obtain an [API 
key](https://openai.com/blog/openai-api). If you are working with other LLM 
providers, please refer to the provider's documentation to obtain an API key.
diff --git a/docs/zh/latest/plugins/ai-aliyun-content-moderation.md 
b/docs/zh/latest/plugins/ai-aliyun-content-moderation.md
index 5b68b94be..64c7297ec 100644
--- a/docs/zh/latest/plugins/ai-aliyun-content-moderation.md
+++ b/docs/zh/latest/plugins/ai-aliyun-content-moderation.md
@@ -74,6 +74,18 @@ import TabItem from '@theme/TabItem';
 | ssl_verify | boolean | 否 | `true` | | 如果为 `true`,启用 SSL 证书验证。 |
 | fail_mode | string | 否 | `"skip"` | `skip`、`warn`、`error` | 当请求不是该插件可识别的 AI 
请求时的处理行为(例如 Consumer 级别绑定时的普通 HTTP 流量,或未经过 `ai-proxy` 
的请求)。`skip`:放行请求且不做检查;`warn`:放行并记录 warning 日志;`error`:拒绝请求。 |
 
+`final_packet` 模式在已汇总的响应正文可用后,在现有数据事件中同时追加 `risk_level` 和 
`deny_message`。拒绝时使用最终实际拒绝文案,通过时为空字符串,保留事件原有正文和 token 用量。结果仅用于告知客户端,无法撤回已经发送的内容。
+
+上游未提供 `usage` 时,在流结束时执行审核:
+
+- **OpenAI Chat Completions:**在 `[DONE]` 前插入一个携带 `risk_level`、`deny_message` 
和全零 `usage` 的 `chat.completion.chunk`。这是 `choices: []` 的空用量包,拒绝文案仅通过顶层 
`deny_message` 字段返回,不追加正文,也不改变上游结束原因。
+- **Anthropic Messages:**在 `message_stop` 前补一个携带 
`risk_level`、`deny_message`、全零 `usage` 和原始结束信息的 `message_delta`,不重放内容块或 
`message_start`。
+- **OpenAI Responses:**在现有 `response.completed` 事件中附加 `risk_level` 和 
`deny_message`,保留原始输出和用量。EOF 时若没有 `response.completed`,不合成完成响应。
+
+客户端需读取至流终止符才能获取注入结果。有用量的流只在原事件追加字段,不额外补结果包,因此客户端收到的用量不变。新结果事件使用零用量;网关计费仍使用上游用量。审核失败且没有返回风险等级时不伪造结果;上游错误事件保留,中断的流不合成终止符。
+
+请从 SSE 事件读取审核扩展字段。例如,OpenAI Python SDK 的 `responses.create(stream=True)` 会保留 
Responses 扩展字段,但更高层的 `responses.stream()` 封装可能丢弃未知的顶层字段。
+
 ## 示例
 
 以下示例使用 OpenAI 作为上游服务提供商。在开始之前,请创建一个 [OpenAI 账号](https://openai.com) 并获取 [API 
密钥](https://openai.com/blog/openai-api)。如果你使用其他 LLM 提供商,请参考相应提供商的文档获取 API 密钥。
diff --git a/t/fixtures/aliyun/result-anthropic-deny.sse 
b/t/fixtures/aliyun/result-anthropic-deny.sse
new file mode 100644
index 000000000..5093a7f55
--- /dev/null
+++ b/t/fixtures/aliyun/result-anthropic-deny.sse
@@ -0,0 +1,18 @@
+event: message_start
+data: 
{"type":"message_start","message":{"id":"msg_moderation","type":"message","role":"assistant","model":"test-model","content":[],"stop_reason":null,"stop_sequence":null}}
+
+event: content_block_start
+data: 
{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}
+
+event: content_block_delta
+data: 
{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"kill
 you"}}
+
+event: content_block_stop
+data: {"type":"content_block_stop","index":0}
+
+event: message_delta
+data: 
{"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null}}
+
+event: message_stop
+data: {"type":"message_stop"}
+
diff --git a/t/fixtures/aliyun/result-anthropic-error.sse 
b/t/fixtures/aliyun/result-anthropic-error.sse
new file mode 100644
index 000000000..b72daa182
--- /dev/null
+++ b/t/fixtures/aliyun/result-anthropic-error.sse
@@ -0,0 +1,18 @@
+event: message_start
+data: 
{"type":"message_start","message":{"id":"msg_moderation","type":"message","role":"assistant","model":"test-model","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":10,"output_tokens":0}}}
+
+event: content_block_start
+data: 
{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}
+
+event: content_block_delta
+data: 
{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"kill
 you"}}
+
+event: content_block_stop
+data: {"type":"content_block_stop","index":0}
+
+event: message_delta
+data: 
{"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"output_tokens":8}}
+
+event: error
+data: {"type":"error","error":{"type":"overloaded_error","message":"stream 
failed"}}
+
diff --git a/t/fixtures/aliyun/result-anthropic-null-delta.sse 
b/t/fixtures/aliyun/result-anthropic-null-delta.sse
new file mode 100644
index 000000000..5adbf3cd3
--- /dev/null
+++ b/t/fixtures/aliyun/result-anthropic-null-delta.sse
@@ -0,0 +1,18 @@
+event: message_start
+data: 
{"type":"message_start","message":{"id":"msg_moderation","type":"message","role":"assistant","model":"test-model","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":10,"output_tokens":0}}}
+
+event: content_block_start
+data: 
{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}
+
+event: content_block_delta
+data: 
{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"kill
 you"}}
+
+event: content_block_stop
+data: {"type":"content_block_stop","index":0}
+
+event: message_delta
+data: {"type":"message_delta","delta":null,"usage":{"output_tokens":8}}
+
+event: message_stop
+data: {"type":"message_stop"}
+
diff --git a/t/fixtures/aliyun/result-anthropic-safe.sse 
b/t/fixtures/aliyun/result-anthropic-safe.sse
new file mode 100644
index 000000000..f9782f99f
--- /dev/null
+++ b/t/fixtures/aliyun/result-anthropic-safe.sse
@@ -0,0 +1,18 @@
+event: message_start
+data: 
{"type":"message_start","message":{"id":"msg_moderation","type":"message","role":"assistant","model":"test-model","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":10,"output_tokens":0}}}
+
+event: content_block_start
+data: 
{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}
+
+event: content_block_delta
+data: 
{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"safe
 output"}}
+
+event: content_block_stop
+data: {"type":"content_block_stop","index":0}
+
+event: message_delta
+data: 
{"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"output_tokens":8}}
+
+event: message_stop
+data: {"type":"message_stop"}
+
diff --git a/t/fixtures/aliyun/result-anthropic-stop-sequence.sse 
b/t/fixtures/aliyun/result-anthropic-stop-sequence.sse
new file mode 100644
index 000000000..fd4250735
--- /dev/null
+++ b/t/fixtures/aliyun/result-anthropic-stop-sequence.sse
@@ -0,0 +1,18 @@
+event: message_start
+data: 
{"type":"message_start","message":{"id":"msg_moderation","type":"message","role":"assistant","model":"test-model","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":10,"output_tokens":0}}}
+
+event: content_block_start
+data: 
{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}
+
+event: content_block_delta
+data: 
{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"kill
 you"}}
+
+event: content_block_stop
+data: {"type":"content_block_stop","index":0}
+
+event: message_delta
+data: 
{"type":"message_delta","delta":{"stop_reason":"stop_sequence","stop_sequence":"END"},"usage":{"output_tokens":8}}
+
+event: message_stop
+data: {"type":"message_stop"}
+
diff --git a/t/fixtures/aliyun/result-anthropic-usage.sse 
b/t/fixtures/aliyun/result-anthropic-usage.sse
new file mode 100644
index 000000000..22b5a5d2b
--- /dev/null
+++ b/t/fixtures/aliyun/result-anthropic-usage.sse
@@ -0,0 +1,18 @@
+event: message_start
+data: 
{"type":"message_start","message":{"id":"msg_moderation","type":"message","role":"assistant","model":"test-model","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":10,"output_tokens":0}}}
+
+event: content_block_start
+data: 
{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}
+
+event: content_block_delta
+data: 
{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"kill
 you"}}
+
+event: content_block_stop
+data: {"type":"content_block_stop","index":0}
+
+event: message_delta
+data: 
{"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"output_tokens":8}}
+
+event: message_stop
+data: {"type":"message_stop"}
+
diff --git a/t/fixtures/aliyun/result-chat-clean-eof.sse 
b/t/fixtures/aliyun/result-chat-clean-eof.sse
new file mode 100644
index 000000000..9296e8ffe
--- /dev/null
+++ b/t/fixtures/aliyun/result-chat-clean-eof.sse
@@ -0,0 +1,8 @@
+data: 
{"id":"chatcmpl-moderation","object":"chat.completion.chunk","created":1700000000,"model":"test-model","choices":[{"index":0,"delta":{"role":"assistant","content":null},"finish_reason":null}]}
+
+data: 
{"id":"chatcmpl-moderation","object":"chat.completion.chunk","created":1700000000,"model":"test-model","choices":[{"index":0,"delta":{"content":"kill
 "},"finish_reason":null}]}
+
+data: 
{"id":"chatcmpl-moderation","object":"chat.completion.chunk","created":1700000000,"model":"test-model","choices":[{"index":0,"delta":{"content":"you"},"finish_reason":null}]}
+
+data: 
{"id":"chatcmpl-moderation","object":"chat.completion.chunk","created":1700000000,"model":"test-model","choices":[],"usage":{"prompt_tokens":10,"completion_tokens":8,"total_tokens":18}}
+
diff --git a/t/fixtures/aliyun/result-chat-deny.sse 
b/t/fixtures/aliyun/result-chat-deny.sse
new file mode 100644
index 000000000..2be78f0ed
--- /dev/null
+++ b/t/fixtures/aliyun/result-chat-deny.sse
@@ -0,0 +1,8 @@
+data: 
{"id":"chatcmpl-moderation","object":"chat.completion.chunk","created":1700000000,"model":"test-model","choices":[{"index":0,"delta":{"role":"assistant","content":null},"finish_reason":null}]}
+
+data: 
{"id":"chatcmpl-moderation","object":"chat.completion.chunk","created":1700000000,"model":"test-model","choices":[{"index":0,"delta":{"content":"kill
 "},"finish_reason":null}]}
+
+data: 
{"id":"chatcmpl-moderation","object":"chat.completion.chunk","created":1700000000,"model":"test-model","choices":[{"index":0,"delta":{"content":"you"},"finish_reason":"stop"}]}
+
+data: [DONE]
+
diff --git a/t/fixtures/aliyun/result-chat-error.sse 
b/t/fixtures/aliyun/result-chat-error.sse
new file mode 100644
index 000000000..de60bc004
--- /dev/null
+++ b/t/fixtures/aliyun/result-chat-error.sse
@@ -0,0 +1,8 @@
+data: 
{"id":"chatcmpl-moderation","object":"chat.completion.chunk","created":1700000000,"model":"test-model","choices":[{"index":0,"delta":{"role":"assistant","content":null},"finish_reason":null}]}
+
+data: 
{"id":"chatcmpl-moderation","object":"chat.completion.chunk","created":1700000000,"model":"test-model","choices":[{"index":0,"delta":{"content":"kill
 "},"finish_reason":null}]}
+
+data: 
{"id":"chatcmpl-moderation","object":"chat.completion.chunk","created":1700000000,"model":"test-model","choices":[{"index":0,"delta":{"content":"you"},"finish_reason":"stop"}]}
+
+data: {"error":{"type":"server_error","message":"stream failed"}}
+
diff --git a/t/fixtures/aliyun/result-chat-failure.sse 
b/t/fixtures/aliyun/result-chat-failure.sse
new file mode 100644
index 000000000..bed0323d5
--- /dev/null
+++ b/t/fixtures/aliyun/result-chat-failure.sse
@@ -0,0 +1,8 @@
+data: 
{"id":"chatcmpl-moderation","object":"chat.completion.chunk","created":1700000000,"model":"test-model","choices":[{"index":0,"delta":{"role":"assistant","content":null},"finish_reason":null}]}
+
+data: 
{"id":"chatcmpl-moderation","object":"chat.completion.chunk","created":1700000000,"model":"test-model","choices":[{"index":0,"delta":{"content":"scan-"},"finish_reason":null}]}
+
+data: 
{"id":"chatcmpl-moderation","object":"chat.completion.chunk","created":1700000000,"model":"test-model","choices":[{"index":0,"delta":{"content":"error"},"finish_reason":"stop"}]}
+
+data: [DONE]
+
diff --git a/t/fixtures/aliyun/result-chat-length.sse 
b/t/fixtures/aliyun/result-chat-length.sse
new file mode 100644
index 000000000..6da1ae625
--- /dev/null
+++ b/t/fixtures/aliyun/result-chat-length.sse
@@ -0,0 +1,10 @@
+data: 
{"id":"chatcmpl-moderation","object":"chat.completion.chunk","created":1700000000,"model":"test-model","choices":[{"index":0,"delta":{"role":"assistant","content":null},"finish_reason":null}]}
+
+data: 
{"id":"chatcmpl-moderation","object":"chat.completion.chunk","created":1700000000,"model":"test-model","choices":[{"index":0,"delta":{"content":"kill
 "},"finish_reason":null}]}
+
+data: 
{"id":"chatcmpl-moderation","object":"chat.completion.chunk","created":1700000000,"model":"test-model","choices":[{"index":0,"delta":{"content":"you"},"finish_reason":"length"}]}
+
+data: 
{"id":"chatcmpl-moderation","object":"chat.completion.chunk","created":1700000000,"model":"test-model","choices":[],"usage":{"prompt_tokens":10,"completion_tokens":8,"total_tokens":18}}
+
+data: [DONE]
+
diff --git a/t/fixtures/aliyun/result-chat-multiple-choices.sse 
b/t/fixtures/aliyun/result-chat-multiple-choices.sse
new file mode 100644
index 000000000..a2eddfd37
--- /dev/null
+++ b/t/fixtures/aliyun/result-chat-multiple-choices.sse
@@ -0,0 +1,10 @@
+data: 
{"id":"chatcmpl-moderation","object":"chat.completion.chunk","created":1700000000,"model":"test-model","choices":[{"index":0,"delta":{"role":"assistant","content":null},"finish_reason":null}]}
+
+data: 
{"id":"chatcmpl-moderation","object":"chat.completion.chunk","created":1700000000,"model":"test-model","choices":[{"index":0,"delta":{"content":"kill
 "},"finish_reason":"stop"}]}
+
+data: 
{"id":"chatcmpl-moderation","object":"chat.completion.chunk","created":1700000000,"model":"test-model","choices":[{"index":1,"delta":{"content":"you"},"finish_reason":"stop"}]}
+
+data: 
{"id":"chatcmpl-moderation","object":"chat.completion.chunk","created":1700000000,"model":"test-model","choices":[],"usage":{"prompt_tokens":10,"completion_tokens":8,"total_tokens":18}}
+
+data: [DONE]
+
diff --git a/t/fixtures/aliyun/result-chat-multiple-reasons.sse 
b/t/fixtures/aliyun/result-chat-multiple-reasons.sse
new file mode 100644
index 000000000..d854a37d2
--- /dev/null
+++ b/t/fixtures/aliyun/result-chat-multiple-reasons.sse
@@ -0,0 +1,10 @@
+data: 
{"id":"chatcmpl-moderation","object":"chat.completion.chunk","created":1700000000,"model":"test-model","choices":[{"index":0,"delta":{"role":"assistant","content":null},"finish_reason":null}]}
+
+data: 
{"id":"chatcmpl-moderation","object":"chat.completion.chunk","created":1700000000,"model":"test-model","choices":[{"index":0,"delta":{"content":"kill
 "},"finish_reason":"length"}]}
+
+data: 
{"id":"chatcmpl-moderation","object":"chat.completion.chunk","created":1700000000,"model":"test-model","choices":[{"index":1,"delta":{"content":"you","tool_calls":[{"index":0,"id":"call_test","type":"function","function":{"name":"example","arguments":"{}"}}]},"finish_reason":"tool_calls"}]}
+
+data: 
{"id":"chatcmpl-moderation","object":"chat.completion.chunk","created":1700000000,"model":"test-model","choices":[],"usage":{"prompt_tokens":10,"completion_tokens":8,"total_tokens":18}}
+
+data: [DONE]
+
diff --git a/t/fixtures/aliyun/result-chat-safe.sse 
b/t/fixtures/aliyun/result-chat-safe.sse
new file mode 100644
index 000000000..7686320b7
--- /dev/null
+++ b/t/fixtures/aliyun/result-chat-safe.sse
@@ -0,0 +1,8 @@
+data: 
{"id":"chatcmpl-moderation","object":"chat.completion.chunk","created":1700000000,"model":"test-model","choices":[{"index":0,"delta":{"role":"assistant","content":null},"finish_reason":null}]}
+
+data: 
{"id":"chatcmpl-moderation","object":"chat.completion.chunk","created":1700000000,"model":"test-model","choices":[{"index":0,"delta":{"content":"safe
 "},"finish_reason":null}]}
+
+data: 
{"id":"chatcmpl-moderation","object":"chat.completion.chunk","created":1700000000,"model":"test-model","choices":[{"index":0,"delta":{"content":"output"},"finish_reason":"stop"}]}
+
+data: [DONE]
+
diff --git a/t/fixtures/aliyun/result-chat-tool_calls.sse 
b/t/fixtures/aliyun/result-chat-tool_calls.sse
new file mode 100644
index 000000000..b522b0b74
--- /dev/null
+++ b/t/fixtures/aliyun/result-chat-tool_calls.sse
@@ -0,0 +1,10 @@
+data: 
{"id":"chatcmpl-moderation","object":"chat.completion.chunk","created":1700000000,"model":"test-model","choices":[{"index":0,"delta":{"role":"assistant","content":null},"finish_reason":null}]}
+
+data: 
{"id":"chatcmpl-moderation","object":"chat.completion.chunk","created":1700000000,"model":"test-model","choices":[{"index":0,"delta":{"content":"kill
 "},"finish_reason":null}]}
+
+data: 
{"id":"chatcmpl-moderation","object":"chat.completion.chunk","created":1700000000,"model":"test-model","choices":[{"index":0,"delta":{"content":"you","tool_calls":[{"index":0,"id":"call_test","type":"function","function":{"name":"example","arguments":"{}"}}]},"finish_reason":"tool_calls"}]}
+
+data: 
{"id":"chatcmpl-moderation","object":"chat.completion.chunk","created":1700000000,"model":"test-model","choices":[],"usage":{"prompt_tokens":10,"completion_tokens":8,"total_tokens":18}}
+
+data: [DONE]
+
diff --git a/t/fixtures/aliyun/result-chat-usage.sse 
b/t/fixtures/aliyun/result-chat-usage.sse
new file mode 100644
index 000000000..7dbad95f7
--- /dev/null
+++ b/t/fixtures/aliyun/result-chat-usage.sse
@@ -0,0 +1,10 @@
+data: 
{"id":"chatcmpl-moderation","object":"chat.completion.chunk","created":1700000000,"model":"test-model","choices":[{"index":0,"delta":{"role":"assistant","content":null},"finish_reason":null}]}
+
+data: 
{"id":"chatcmpl-moderation","object":"chat.completion.chunk","created":1700000000,"model":"test-model","choices":[{"index":0,"delta":{"content":"kill
 "},"finish_reason":null}]}
+
+data: 
{"id":"chatcmpl-moderation","object":"chat.completion.chunk","created":1700000000,"model":"test-model","choices":[{"index":0,"delta":{"content":"you"},"finish_reason":"stop"}]}
+
+data: 
{"id":"chatcmpl-moderation","object":"chat.completion.chunk","created":1700000000,"model":"test-model","choices":[],"usage":{"prompt_tokens":10,"completion_tokens":8,"total_tokens":18}}
+
+data: [DONE]
+
diff --git a/t/fixtures/aliyun/result-responses-deny.sse 
b/t/fixtures/aliyun/result-responses-deny.sse
new file mode 100644
index 000000000..0a680b138
--- /dev/null
+++ b/t/fixtures/aliyun/result-responses-deny.sse
@@ -0,0 +1,24 @@
+event: response.created
+data: 
{"response":{"id":"resp_moderation","object":"response","created_at":1700000000,"status":"in_progress","error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"model":"test-model","output":[],"parallel_tool_calls":true,"previous_response_id":null,"reasoning":{"effort":null,"summary":null},"store":false,"temperature":1,"text":{"format":{"type":"text"}},"tool_choice":"auto","tools":[],"top_p":1,"truncation":"disabled","usage":null,"user":null,"metadata":{}
 [...]
+
+event: response.output_item.added
+data: 
{"output_index":0,"item":{"id":"msg_moderation","type":"message","role":"assistant","status":"in_progress","content":[]},"type":"response.output_item.added","sequence_number":1}
+
+event: response.content_part.added
+data: 
{"item_id":"msg_moderation","output_index":0,"content_index":0,"part":{"type":"output_text","text":"","annotations":[],"logprobs":[]},"type":"response.content_part.added","sequence_number":2}
+
+event: response.output_text.delta
+data: 
{"item_id":"msg_moderation","output_index":0,"content_index":0,"delta":"kill 
you","logprobs":[],"type":"response.output_text.delta","sequence_number":3}
+
+event: response.output_text.done
+data: 
{"item_id":"msg_moderation","output_index":0,"content_index":0,"text":"kill 
you","logprobs":[],"type":"response.output_text.done","sequence_number":4}
+
+event: response.content_part.done
+data: 
{"item_id":"msg_moderation","output_index":0,"content_index":0,"part":{"type":"output_text","text":"kill
 
you","annotations":[],"logprobs":[]},"type":"response.content_part.done","sequence_number":5}
+
+event: response.output_item.done
+data: 
{"output_index":0,"item":{"id":"msg_moderation","type":"message","role":"assistant","status":"completed","content":[{"type":"output_text","text":"kill
 
you","annotations":[],"logprobs":[]}]},"type":"response.output_item.done","sequence_number":6}
+
+event: response.completed
+data: 
{"response":{"id":"resp_moderation","object":"response","created_at":1700000000,"status":"completed","error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"model":"test-model","output":[{"id":"msg_moderation","type":"message","role":"assistant","status":"completed","content":[{"type":"output_text","text":"kill
 
you","annotations":[],"logprobs":[]}]}],"parallel_tool_calls":true,"previous_response_id":null,"reasoning":{"effort":null,"summary":null},"store
 [...]
+
diff --git a/t/fixtures/aliyun/result-responses-eof.sse 
b/t/fixtures/aliyun/result-responses-eof.sse
new file mode 100644
index 000000000..ecf572c81
--- /dev/null
+++ b/t/fixtures/aliyun/result-responses-eof.sse
@@ -0,0 +1,21 @@
+event: response.created
+data: 
{"response":{"id":"resp_moderation","object":"response","created_at":1700000000,"status":"in_progress","error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"model":"test-model","output":[],"parallel_tool_calls":true,"previous_response_id":null,"reasoning":{"effort":null,"summary":null},"store":false,"temperature":1,"text":{"format":{"type":"text"}},"tool_choice":"auto","tools":[],"top_p":1,"truncation":"disabled","usage":null,"user":null,"metadata":{}
 [...]
+
+event: response.output_item.added
+data: 
{"output_index":0,"item":{"id":"msg_moderation","type":"message","role":"assistant","status":"in_progress","content":[]},"type":"response.output_item.added","sequence_number":1}
+
+event: response.content_part.added
+data: 
{"item_id":"msg_moderation","output_index":0,"content_index":0,"part":{"type":"output_text","text":"","annotations":[],"logprobs":[]},"type":"response.content_part.added","sequence_number":2}
+
+event: response.output_text.delta
+data: 
{"item_id":"msg_moderation","output_index":0,"content_index":0,"delta":"kill 
you","logprobs":[],"type":"response.output_text.delta","sequence_number":3}
+
+event: response.output_text.done
+data: 
{"item_id":"msg_moderation","output_index":0,"content_index":0,"text":"kill 
you","logprobs":[],"type":"response.output_text.done","sequence_number":4}
+
+event: response.content_part.done
+data: 
{"item_id":"msg_moderation","output_index":0,"content_index":0,"part":{"type":"output_text","text":"kill
 
you","annotations":[],"logprobs":[]},"type":"response.content_part.done","sequence_number":5}
+
+event: response.output_item.done
+data: 
{"output_index":0,"item":{"id":"msg_moderation","type":"message","role":"assistant","status":"completed","content":[{"type":"output_text","text":"kill
 
you","annotations":[],"logprobs":[]}]},"type":"response.output_item.done","sequence_number":6}
+
diff --git a/t/fixtures/aliyun/result-responses-error.sse 
b/t/fixtures/aliyun/result-responses-error.sse
new file mode 100644
index 000000000..149d37e19
--- /dev/null
+++ b/t/fixtures/aliyun/result-responses-error.sse
@@ -0,0 +1,24 @@
+event: response.created
+data: 
{"response":{"id":"resp_moderation","object":"response","created_at":1700000000,"status":"in_progress","error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"model":"test-model","output":[],"parallel_tool_calls":true,"previous_response_id":null,"reasoning":{"effort":null,"summary":null},"store":false,"temperature":1,"text":{"format":{"type":"text"}},"tool_choice":"auto","tools":[],"top_p":1,"truncation":"disabled","usage":null,"user":null,"metadata":{}
 [...]
+
+event: response.output_item.added
+data: 
{"output_index":0,"item":{"id":"msg_moderation","type":"message","role":"assistant","status":"in_progress","content":[]},"type":"response.output_item.added","sequence_number":1}
+
+event: response.content_part.added
+data: 
{"item_id":"msg_moderation","output_index":0,"content_index":0,"part":{"type":"output_text","text":"","annotations":[],"logprobs":[]},"type":"response.content_part.added","sequence_number":2}
+
+event: response.output_text.delta
+data: 
{"item_id":"msg_moderation","output_index":0,"content_index":0,"delta":"kill 
you","logprobs":[],"type":"response.output_text.delta","sequence_number":3}
+
+event: response.output_text.done
+data: 
{"item_id":"msg_moderation","output_index":0,"content_index":0,"text":"kill 
you","logprobs":[],"type":"response.output_text.done","sequence_number":4}
+
+event: response.content_part.done
+data: 
{"item_id":"msg_moderation","output_index":0,"content_index":0,"part":{"type":"output_text","text":"kill
 
you","annotations":[],"logprobs":[]},"type":"response.content_part.done","sequence_number":5}
+
+event: response.output_item.done
+data: 
{"output_index":0,"item":{"id":"msg_moderation","type":"message","role":"assistant","status":"completed","content":[{"type":"output_text","text":"kill
 
you","annotations":[],"logprobs":[]}]},"type":"response.output_item.done","sequence_number":6}
+
+event: error
+data: 
{"type":"error","sequence_number":7,"code":"server_error","message":"stream 
failed"}
+
diff --git a/t/fixtures/aliyun/result-responses-failed.sse 
b/t/fixtures/aliyun/result-responses-failed.sse
new file mode 100644
index 000000000..59b94db8b
--- /dev/null
+++ b/t/fixtures/aliyun/result-responses-failed.sse
@@ -0,0 +1,24 @@
+event: response.created
+data: 
{"response":{"id":"resp_moderation","object":"response","created_at":1700000000,"status":"in_progress","error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"model":"test-model","output":[],"parallel_tool_calls":true,"previous_response_id":null,"reasoning":{"effort":null,"summary":null},"store":false,"temperature":1,"text":{"format":{"type":"text"}},"tool_choice":"auto","tools":[],"top_p":1,"truncation":"disabled","usage":null,"user":null,"metadata":{}
 [...]
+
+event: response.output_item.added
+data: 
{"output_index":0,"item":{"id":"msg_moderation","type":"message","role":"assistant","status":"in_progress","content":[]},"type":"response.output_item.added","sequence_number":1}
+
+event: response.content_part.added
+data: 
{"item_id":"msg_moderation","output_index":0,"content_index":0,"part":{"type":"output_text","text":"","annotations":[],"logprobs":[]},"type":"response.content_part.added","sequence_number":2}
+
+event: response.output_text.delta
+data: 
{"item_id":"msg_moderation","output_index":0,"content_index":0,"delta":"kill 
you","logprobs":[],"type":"response.output_text.delta","sequence_number":3}
+
+event: response.output_text.done
+data: 
{"item_id":"msg_moderation","output_index":0,"content_index":0,"text":"kill 
you","logprobs":[],"type":"response.output_text.done","sequence_number":4}
+
+event: response.content_part.done
+data: 
{"item_id":"msg_moderation","output_index":0,"content_index":0,"part":{"type":"output_text","text":"kill
 
you","annotations":[],"logprobs":[]},"type":"response.content_part.done","sequence_number":5}
+
+event: response.output_item.done
+data: 
{"output_index":0,"item":{"id":"msg_moderation","type":"message","role":"assistant","status":"completed","content":[{"type":"output_text","text":"kill
 
you","annotations":[],"logprobs":[]}]},"type":"response.output_item.done","sequence_number":6}
+
+event: response.failed
+data: 
{"type":"response.failed","sequence_number":7,"response":{"id":"resp_moderation","object":"response","created_at":1700000000,"status":"failed","error":{"code":"server_error","message":"stream
 
failed"},"incomplete_details":null,"instructions":null,"max_output_tokens":null,"model":"test-model","output":[{"id":"msg_moderation","type":"message","role":"assistant","status":"completed","content":[{"type":"output_text","text":"kill
 you","annotations":[],"logprobs":[]}]}],"parallel_tool_ca [...]
+
diff --git a/t/fixtures/aliyun/result-responses-incomplete.sse 
b/t/fixtures/aliyun/result-responses-incomplete.sse
new file mode 100644
index 000000000..1fab1e145
--- /dev/null
+++ b/t/fixtures/aliyun/result-responses-incomplete.sse
@@ -0,0 +1,24 @@
+event: response.created
+data: 
{"response":{"id":"resp_moderation","object":"response","created_at":1700000000,"status":"in_progress","error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"model":"test-model","output":[],"parallel_tool_calls":true,"previous_response_id":null,"reasoning":{"effort":null,"summary":null},"store":false,"temperature":1,"text":{"format":{"type":"text"}},"tool_choice":"auto","tools":[],"top_p":1,"truncation":"disabled","usage":null,"user":null,"metadata":{}
 [...]
+
+event: response.output_item.added
+data: 
{"output_index":0,"item":{"id":"msg_moderation","type":"message","role":"assistant","status":"in_progress","content":[]},"type":"response.output_item.added","sequence_number":1}
+
+event: response.content_part.added
+data: 
{"item_id":"msg_moderation","output_index":0,"content_index":0,"part":{"type":"output_text","text":"","annotations":[],"logprobs":[]},"type":"response.content_part.added","sequence_number":2}
+
+event: response.output_text.delta
+data: 
{"item_id":"msg_moderation","output_index":0,"content_index":0,"delta":"kill 
you","logprobs":[],"type":"response.output_text.delta","sequence_number":3}
+
+event: response.output_text.done
+data: 
{"item_id":"msg_moderation","output_index":0,"content_index":0,"text":"kill 
you","logprobs":[],"type":"response.output_text.done","sequence_number":4}
+
+event: response.content_part.done
+data: 
{"item_id":"msg_moderation","output_index":0,"content_index":0,"part":{"type":"output_text","text":"kill
 
you","annotations":[],"logprobs":[]},"type":"response.content_part.done","sequence_number":5}
+
+event: response.output_item.done
+data: 
{"output_index":0,"item":{"id":"msg_moderation","type":"message","role":"assistant","status":"completed","content":[{"type":"output_text","text":"kill
 
you","annotations":[],"logprobs":[]}]},"type":"response.output_item.done","sequence_number":6}
+
+event: response.incomplete
+data: 
{"type":"response.incomplete","sequence_number":7,"response":{"id":"resp_moderation","object":"response","created_at":1700000000,"status":"incomplete","error":null,"incomplete_details":{"reason":"max_output_tokens"},"instructions":null,"max_output_tokens":null,"model":"test-model","output":[{"id":"msg_moderation","type":"message","role":"assistant","status":"completed","content":[{"type":"output_text","text":"kill
 you","annotations":[],"logprobs":[]}]}],"parallel_tool_calls":true," [...]
+
diff --git a/t/fixtures/aliyun/result-responses-safe.sse 
b/t/fixtures/aliyun/result-responses-safe.sse
new file mode 100644
index 000000000..e2259392c
--- /dev/null
+++ b/t/fixtures/aliyun/result-responses-safe.sse
@@ -0,0 +1,24 @@
+event: response.created
+data: 
{"response":{"id":"resp_moderation","object":"response","created_at":1700000000,"status":"in_progress","error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"model":"test-model","output":[],"parallel_tool_calls":true,"previous_response_id":null,"reasoning":{"effort":null,"summary":null},"store":false,"temperature":1,"text":{"format":{"type":"text"}},"tool_choice":"auto","tools":[],"top_p":1,"truncation":"disabled","usage":null,"user":null,"metadata":{}
 [...]
+
+event: response.output_item.added
+data: 
{"output_index":0,"item":{"id":"msg_moderation","type":"message","role":"assistant","status":"in_progress","content":[]},"type":"response.output_item.added","sequence_number":1}
+
+event: response.content_part.added
+data: 
{"item_id":"msg_moderation","output_index":0,"content_index":0,"part":{"type":"output_text","text":"","annotations":[],"logprobs":[]},"type":"response.content_part.added","sequence_number":2}
+
+event: response.output_text.delta
+data: 
{"item_id":"msg_moderation","output_index":0,"content_index":0,"delta":"safe 
output","logprobs":[],"type":"response.output_text.delta","sequence_number":3}
+
+event: response.output_text.done
+data: 
{"item_id":"msg_moderation","output_index":0,"content_index":0,"text":"safe 
output","logprobs":[],"type":"response.output_text.done","sequence_number":4}
+
+event: response.content_part.done
+data: 
{"item_id":"msg_moderation","output_index":0,"content_index":0,"part":{"type":"output_text","text":"safe
 
output","annotations":[],"logprobs":[]},"type":"response.content_part.done","sequence_number":5}
+
+event: response.output_item.done
+data: 
{"output_index":0,"item":{"id":"msg_moderation","type":"message","role":"assistant","status":"completed","content":[{"type":"output_text","text":"safe
 
output","annotations":[],"logprobs":[]}]},"type":"response.output_item.done","sequence_number":6}
+
+event: response.completed
+data: 
{"response":{"id":"resp_moderation","object":"response","created_at":1700000000,"status":"completed","error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"model":"test-model","output":[{"id":"msg_moderation","type":"message","role":"assistant","status":"completed","content":[{"type":"output_text","text":"safe
 
output","annotations":[],"logprobs":[]}]}],"parallel_tool_calls":true,"previous_response_id":null,"reasoning":{"effort":null,"summary":null},"st
 [...]
+
diff --git a/t/fixtures/aliyun/result-responses-usage.sse 
b/t/fixtures/aliyun/result-responses-usage.sse
new file mode 100644
index 000000000..d901da1e3
--- /dev/null
+++ b/t/fixtures/aliyun/result-responses-usage.sse
@@ -0,0 +1,24 @@
+event: response.created
+data: 
{"response":{"id":"resp_moderation","object":"response","created_at":1700000000,"status":"in_progress","error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"model":"test-model","output":[],"parallel_tool_calls":true,"previous_response_id":null,"reasoning":{"effort":null,"summary":null},"store":false,"temperature":1,"text":{"format":{"type":"text"}},"tool_choice":"auto","tools":[],"top_p":1,"truncation":"disabled","usage":null,"user":null,"metadata":{}
 [...]
+
+event: response.output_item.added
+data: 
{"output_index":0,"item":{"id":"msg_moderation","type":"message","role":"assistant","status":"in_progress","content":[]},"type":"response.output_item.added","sequence_number":1}
+
+event: response.content_part.added
+data: 
{"item_id":"msg_moderation","output_index":0,"content_index":0,"part":{"type":"output_text","text":"","annotations":[],"logprobs":[]},"type":"response.content_part.added","sequence_number":2}
+
+event: response.output_text.delta
+data: 
{"item_id":"msg_moderation","output_index":0,"content_index":0,"delta":"kill 
you","logprobs":[],"type":"response.output_text.delta","sequence_number":3}
+
+event: response.output_text.done
+data: 
{"item_id":"msg_moderation","output_index":0,"content_index":0,"text":"kill 
you","logprobs":[],"type":"response.output_text.done","sequence_number":4}
+
+event: response.content_part.done
+data: 
{"item_id":"msg_moderation","output_index":0,"content_index":0,"part":{"type":"output_text","text":"kill
 
you","annotations":[],"logprobs":[]},"type":"response.content_part.done","sequence_number":5}
+
+event: response.output_item.done
+data: 
{"output_index":0,"item":{"id":"msg_moderation","type":"message","role":"assistant","status":"completed","content":[{"type":"output_text","text":"kill
 
you","annotations":[],"logprobs":[]}]},"type":"response.output_item.done","sequence_number":6}
+
+event: response.completed
+data: 
{"response":{"id":"resp_moderation","object":"response","created_at":1700000000,"status":"completed","error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"model":"test-model","output":[{"id":"msg_moderation","type":"message","role":"assistant","status":"completed","content":[{"type":"output_text","text":"kill
 
you","annotations":[],"logprobs":[]}]}],"parallel_tool_calls":true,"previous_response_id":null,"reasoning":{"effort":null,"summary":null},"store
 [...]
+
diff --git a/t/fixtures/openai/chat-streaming-incomplete-no-usage.sse 
b/t/fixtures/openai/chat-streaming-incomplete-no-usage.sse
new file mode 100644
index 000000000..bdb8a3df1
--- /dev/null
+++ b/t/fixtures/openai/chat-streaming-incomplete-no-usage.sse
@@ -0,0 +1,9 @@
+data: 
{"id":"chatcmpl-nousage","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-nousage","object":"chat.completion.chunk","created":1700000000,"model":"gpt-4o-2024-05-13","choices":[{"index":0,"delta":{"content":"Hello"},"finish_reason":null}]}
+
+data: 
{"id":"chatcmpl-nousage","object":"chat.completion.chunk","created":1700000000,"model":"gpt-4o-2024-05-13","choices":[{"index":0,"delta":{"content":"!"},"finish_reason":null}]}
+
+data: 
{"id":"chatcmpl-nousage","object":"chat.completion.chunk","created":1700000000,"model":"gpt-4o-2024-05-13","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}
+
+data: {"choices":
diff --git a/t/fixtures/openai/chat-streaming-injection-no-usage.sse 
b/t/fixtures/openai/chat-streaming-injection-no-usage.sse
new file mode 100644
index 000000000..b2c430046
--- /dev/null
+++ b/t/fixtures/openai/chat-streaming-injection-no-usage.sse
@@ -0,0 +1,10 @@
+data: 
{"id":"chatcmpl-nousage","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-nousage","object":"chat.completion.chunk","created":1700000000,"model":"gpt-4o-2024-05-13","choices":[{"index":0,"delta":{"content":"injection
 payload"},"finish_reason":null}]}
+
+data: 
{"id":"chatcmpl-nousage","object":"chat.completion.chunk","created":1700000000,"model":"gpt-4o-2024-05-13","choices":[{"index":0,"delta":{"content":""},"finish_reason":null}]}
+
+data: 
{"id":"chatcmpl-nousage","object":"chat.completion.chunk","created":1700000000,"model":"gpt-4o-2024-05-13","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}
+
+data: [DONE]
+
diff --git a/t/fixtures/openai/moderation-eof-no-usage.sse 
b/t/fixtures/openai/moderation-eof-no-usage.sse
new file mode 100644
index 000000000..9bba72258
--- /dev/null
+++ b/t/fixtures/openai/moderation-eof-no-usage.sse
@@ -0,0 +1,6 @@
+data: 
{"choices":[{"index":0,"delta":{"role":"assistant","content":null},"finish_reason":null}]}
+
+data: {"choices":[{"index":0,"delta":{"content":"kill 
"},"finish_reason":null}]}
+
+data: 
{"choices":[{"index":0,"delta":{"content":"you"},"finish_reason":"stop"}]}
+
diff --git a/t/fixtures/openai/moderation-incomplete-event.sse 
b/t/fixtures/openai/moderation-incomplete-event.sse
new file mode 100644
index 000000000..2f90bb69c
--- /dev/null
+++ b/t/fixtures/openai/moderation-incomplete-event.sse
@@ -0,0 +1,7 @@
+data: 
{"choices":[{"index":0,"delta":{"role":"assistant","content":null},"finish_reason":null}]}
+
+data: {"choices":[{"index":0,"delta":{"content":"kill 
"},"finish_reason":null}]}
+
+data: 
{"choices":[{"index":0,"delta":{"content":"you"},"finish_reason":"stop"}]}
+
+data: {"choices":
diff --git a/t/fixtures/openai/moderation-no-usage.sse 
b/t/fixtures/openai/moderation-no-usage.sse
new file mode 100644
index 000000000..1f3337827
--- /dev/null
+++ b/t/fixtures/openai/moderation-no-usage.sse
@@ -0,0 +1,8 @@
+data: 
{"choices":[{"index":0,"delta":{"role":"assistant","content":null},"finish_reason":null}]}
+
+data: {"choices":[{"index":0,"delta":{"content":"kill 
"},"finish_reason":null}]}
+
+data: 
{"choices":[{"index":0,"delta":{"content":"you"},"finish_reason":"stop"}]}
+
+data: [DONE]
+
diff --git a/t/fixtures/openai/moderation-with-usage.sse 
b/t/fixtures/openai/moderation-with-usage.sse
new file mode 100644
index 000000000..ef5ab28ab
--- /dev/null
+++ b/t/fixtures/openai/moderation-with-usage.sse
@@ -0,0 +1,10 @@
+data: 
{"choices":[{"index":0,"delta":{"role":"assistant","content":null},"finish_reason":null}]}
+
+data: {"choices":[{"index":0,"delta":{"content":"kill 
"},"finish_reason":null}]}
+
+data: 
{"choices":[{"index":0,"delta":{"content":"you"},"finish_reason":"stop"}]}
+
+data: 
{"choices":[],"usage":{"prompt_tokens":10,"completion_tokens":8,"total_tokens":18}}
+
+data: [DONE]
+
diff --git a/t/lib/ai_moderation_result.lua b/t/lib/ai_moderation_result.lua
new file mode 100644
index 000000000..9bc25153d
--- /dev/null
+++ b/t/lib/ai_moderation_result.lua
@@ -0,0 +1,255 @@
+--
+-- 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.
+--
+
+local core = require("apisix.core")
+local http = require("resty.http")
+local sse = require("apisix.plugins.ai-transport.sse")
+local _M = {}
+
+function _M.setup()
+    local test = require("lib.test_admin").test
+    local core = require("apisix.core")
+    local code, body = test("/apisix/admin/consumers", ngx.HTTP_PUT, [[{
+        "username":"moderation-result",
+        "plugins":{
+            "key-auth":{"key":"moderation-result"},
+            "ai-aliyun-content-moderation":{
+                "endpoint":"http://127.0.0.1:6724","region_id":"cn-beijing";,
+                "access_key_id":"test-key","access_key_secret":"test-secret",
+                "check_request":true,"check_response":true,
+                "request_check_service":"query_security_check",
+                "response_check_service":"response_security_check",
+                "stream_check_mode":"final_packet","deny_message":"response 
rejected"
+            }
+        }
+    }]])
+    assert(code < 300, body)
+    for id, route in ipairs({
+        {"/chat", "openai"}, {"/v1/messages", "anthropic"},
+        {"/v1/responses", "openai"}, {"/converted/v1/messages", "openai"}
+    }) do
+        local conf = {
+            uri = route[1],
+            plugins = {
+                ["key-auth"] = {},
+                ["ai-proxy"] = {
+                    provider = route[2],
+                    auth = {header = {Authorization = "Bearer test"}},
+                    override = {endpoint = "http://127.0.0.1:6725"},
+                    streaming_flush_interval_ms = 0,
+                },
+                ["serverless-post-function"] = {
+                    phase = "log",
+                    functions = {"return function(conf, ctx) "
+                        .. 
"require('lib.ai_moderation_result').record_usage(ctx) end"},
+                },
+            },
+        }
+        code, body = test("/apisix/admin/routes/" .. id, ngx.HTTP_PUT,
+                          core.json.encode(conf))
+        assert(code < 300, body)
+    end
+end
+
+
+function _M.record_usage(ctx)
+    local request_id = ctx.var.http_x_result_request_id
+    assert(ngx.shared.test:set("accounting:" .. request_id, core.json.encode({
+        tokens = tonumber(ctx.var.llm_total_tokens) or 0,
+        raw_usage = ctx.llm_raw_usage or {},
+    })))
+end
+
+
+function _M.check(case)
+    local request_id = require("resty.jit-uuid").generate_v4()
+    local accounting_key = "accounting:" .. request_id
+    ngx.shared.test:delete(accounting_key)
+    for _, service in ipairs({"query_security_check", 
"response_security_check"}) do
+        ngx.shared.test:delete(service .. "_calls")
+        ngx.shared.test:delete(service .. "_content")
+    end
+    local path = "/chat"
+    local body = {model = "test-model", stream = true,
+                  messages = {{role = "user", content = "hello"}}}
+    if case.protocol == "anthropic" then
+        path = "/v1/messages"
+        body.max_tokens = 100
+    elseif case.protocol == "responses" then
+        path = "/v1/responses"
+        body.messages = nil
+        body.input = "hello"
+    end
+    if case.converted then
+        path = "/converted" .. path
+    end
+    local res = assert(http.new():request_uri("http://127.0.0.1:"; .. 
ngx.var.server_port .. path, {
+        method = "POST",
+        headers = {
+            ["Content-Type"] = "application/json",
+            apikey = "moderation-result",
+            ["X-Result-Request-ID"] = request_id,
+            ["X-AI-Fixture"] = "aliyun/result-" .. case.fixture .. ".sse",
+            ["X-Buffered"] = case.buffered and "true" or nil,
+            ["X-Split-Done"] = case.split_done and "true" or nil,
+        },
+        body = core.json.encode(body),
+    }))
+    assert(res.status == 200, res.body)
+    if case.split_done then
+        assert(res.body:sub(1, 17) == ": keep-alive\r\n\r\n\n",
+               "complete-frame dispatch changed comments or blank lines")
+    end
+    local expected_text = case.text or "kill you"
+    for _, service in ipairs({"query_security_check", 
"response_security_check"}) do
+        local expected_calls = case.error and not case.scanned
+                               and service == "response_security_check" and 0 
or 1
+        assert((ngx.shared.test:get(service .. "_calls") or 0) == 
expected_calls,
+               service .. ": wrong scan count")
+    end
+    assert(ngx.shared.test:get("response_security_check_content") ==
+           ((not case.error or case.scanned) and expected_text or nil),
+           "response scan did not receive all content")
+
+    if case.error then
+        assert(res.body:find(case.error_text or "stream failed", 1, true),
+               "upstream error was lost")
+    end
+    local events, remainder = sse.decode_buf(res.body)
+    assert(remainder == "", "partial downstream event")
+    local risk_count, done_count, start_count, injected_count = 0, 0, 0, 0
+    local original = {}
+    local finish_reason
+    for i, event in ipairs(events) do
+        if event.data == "[DONE]" or event.type == "message_stop"
+           or event.type == "response.completed" then
+            done_count = done_count + 1
+            assert(i == #events, "event emitted after completion")
+        end
+        if event.type == "message_start" or event.type == "response.created" 
then
+            start_count = start_count + 1
+        end
+        if event.data == "[DONE]" then
+            goto CONTINUE
+        end
+        local data = assert(core.json.decode(event.data))
+        local injected = case.protocol == "chat" and data.risk_level and 
data.usage
+                         and data.usage.total_tokens == 0
+        if injected then
+            injected_count = injected_count + 1
+        end
+        if case.protocol == "chat" then
+            for _, choice in ipairs(data.choices or {}) do
+                if choice.finish_reason ~= core.json.null then
+                    finish_reason = choice.finish_reason or finish_reason
+                end
+                if type(choice.delta.content) == "string" then
+                    original[#original + 1] = choice.delta.content
+                end
+            end
+        elseif event.type == "content_block_delta" then
+            original[#original + 1] = data.delta.text
+        elseif event.type == "response.output_text.delta" then
+            original[#original + 1] = data.delta
+        end
+
+        if data.risk_level then
+            risk_count = risk_count + 1
+            assert(data.risk_level == (case.safe and "none" or "high"), "wrong 
risk")
+            assert(data.deny_message == (case.safe and "" or "response 
rejected"),
+                   "missing top-level denial message")
+            if case.protocol == "chat" and injected then
+                assert(i == #events - 1, "moderation result must precede DONE")
+                assert(type(data.id) == "string" and type(data.created) == 
"number",
+                       "invalid result metadata")
+                assert(data.model == "test-model", "invalid result model")
+                assert(type(data.choices) == "table" and #data.choices == 0
+                       and event.data:find('"choices":[]', 1, true),
+                       "moderation usage chunk must have empty choices")
+                assert(data.usage.prompt_tokens == 0 and 
data.usage.completion_tokens == 0
+                       and data.usage.total_tokens == 0, "result usage must be 
zero")
+            elseif case.protocol == "anthropic" then
+                assert(event.type == "message_delta" or event.type == 
"content_block_delta",
+                       "wrong Anthropic event")
+                if event.type ~= "message_delta" then
+                    goto CONTINUE
+                end
+                if case.missing_stop then
+                    assert(data.delta == core.json.null, "original delta was 
replaced")
+                    goto CONTINUE
+                end
+                assert(type(data.delta) == "table", "invalid Anthropic result 
delta")
+                if case.stop_sequence then
+                    assert(data.delta.stop_reason == "stop_sequence"
+                           and data.delta.stop_sequence == case.stop_sequence,
+                           "original Anthropic stop information was changed")
+                end
+                if data.usage then
+                    assert(data.usage.output_tokens == (case.tokens and 8 or 
0), "wrong usage")
+                    if not case.tokens then
+                        injected_count = injected_count + 1
+                    end
+                end
+            elseif case.protocol == "responses" then
+                assert(event.type == "response.completed", "wrong Responses 
event")
+                assert(data.response.id == "resp_moderation", "changed 
response identity")
+                assert(data.response.output[1].content[1].text == 
expected_text,
+                       "completed output was replaced")
+                if case.tokens then
+                    assert(data.response.usage.total_tokens == case.tokens, 
"usage was replaced")
+                end
+            end
+        end
+        ::CONTINUE::
+    end
+    assert(table.concat(original) == expected_text, "original response text 
was changed")
+    if case.finish_reason then
+        assert(finish_reason == case.finish_reason, "original finish reason 
was changed")
+    end
+    if case.failure or case.eof or (case.error and not case.scanned) then
+        assert(risk_count == 0, "unexpected moderation result")
+    else
+        assert(risk_count > 0, "missing moderation result")
+    end
+    if not case.error and not case.failure and case.protocol ~= "responses" 
then
+        assert(injected_count == (case.tokens and 0 or 1), "wrong injected 
result count")
+    end
+    assert(done_count == ((case.eof or case.error) and 0 or 1), "wrong 
terminator count")
+    if case.protocol ~= "chat" then
+        assert(start_count == 1, "message/response start was replayed")
+    end
+    local deadline = ngx.now() + 1
+    local accounting
+    repeat
+        accounting = ngx.shared.test:get(accounting_key)
+        if accounting then
+            break
+        end
+        ngx.sleep(0.01)
+    until ngx.now() >= deadline
+    assert(accounting, "log phase did not publish this request's accounting")
+    accounting = assert(core.json.decode(accounting))
+    ngx.shared.test:delete(accounting_key)
+    assert(accounting.tokens == (case.tokens or 0), "token accounting changed")
+    if case.tokens then
+        local raw = accounting.raw_usage
+        assert((raw.prompt_tokens or raw.input_tokens) == 10, "raw input usage 
changed")
+        assert((raw.completion_tokens or raw.output_tokens) == 8, "raw output 
usage changed")
+    end
+end
+
+return _M
diff --git a/t/plugin/ai-aliyun-content-moderation-result.t 
b/t/plugin/ai-aliyun-content-moderation-result.t
new file mode 100644
index 000000000..ab4d89cb4
--- /dev/null
+++ b/t/plugin/ai-aliyun-content-moderation-result.t
@@ -0,0 +1,322 @@
+#
+# 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.
+#
+
+BEGIN { $ENV{TEST_ENABLE_CONTROL_API_V1} = "0"; }
+use t::APISIX 'no_plan';
+repeat_each(1);
+no_long_string();
+no_root_location();
+
+add_block_preprocessor(sub {
+    my ($block) = @_;
+    $block->set_value("request", "GET /t");
+    $block->set_value("extra_yaml_config", <<_EOC_);
+plugins:
+  - ai-proxy
+  - ai-aliyun-content-moderation
+  - key-auth
+  - serverless-post-function
+_EOC_
+    $block->set_value("http_config", <<'_EOC_');
+    server {
+        listen 6724;
+        location / {
+            content_by_lua_block {
+                local core = require("apisix.core")
+                ngx.req.read_body()
+                local args = ngx.req.get_post_args()
+                local params = assert(core.json.decode(args.ServiceParameters))
+                assert(ngx.shared.test:incr(args.Service .. "_calls", 1, 0))
+                assert(ngx.shared.test:set(args.Service .. "_content", 
params.content))
+                if params.content == "scan-error" then
+                    ngx.status = 500
+                    ngx.say("scan failed")
+                    return
+                end
+                require("lib.server").aliyun_moderation()
+            }
+        }
+    }
+    server {
+        listen 6725;
+        location / {
+            content_by_lua_block {
+                local fixture = ngx.req.get_headers()["X-AI-Fixture"]
+                local content = 
assert(require("lib.fixture_loader").load(fixture))
+                ngx.header.content_type = "text/event-stream"
+                if ngx.req.get_headers()["X-Split-Done"] then
+                    ngx.print(": keep-alive\r\n\r\n\n" .. content:sub(1, -6))
+                    ngx.flush(true)
+                    ngx.sleep(0.01)
+                    ngx.print(content:sub(-5))
+                elseif ngx.req.get_headers()["X-Buffered"] then
+                    ngx.print(content)
+                else
+                    for event in content:gmatch("(.-\n\n)") do
+                        ngx.print(event)
+                        ngx.flush(true)
+                        ngx.sleep(0.01)
+                    end
+                end
+            }
+        }
+    }
+_EOC_
+    if ($block->case) {
+        my $case = $block->case;
+        $block->set_value("config", <<_EOC_);
+    location /t {
+        content_by_lua_block {
+            require("lib.ai_moderation_result").check($case)
+            ngx.say("passed")
+        }
+    }
+_EOC_
+    }
+});
+run_tests();
+__DATA__
+
+=== TEST 1: configure native protocol routes and Consumer moderation
+--- config
+    location /t {
+        content_by_lua_block {
+            require("lib.ai_moderation_result").setup()
+            ngx.say("passed")
+        }
+    }
+--- response_body
+passed
+
+
+
+=== TEST 2: Chat standalone DONE exposes denial and zero usage
+--- case: {fixture="chat-deny", protocol="chat"}
+--- response_body
+passed
+
+
+
+=== TEST 3: Chat coalesced JSON and DONE has the same result
+--- case: {fixture="chat-deny", protocol="chat", buffered=true}
+--- response_body
+passed
+
+
+
+=== TEST 4: Chat split DONE never exposes a partial terminator
+--- case: {fixture="chat-deny", protocol="chat", split_done=true}
+--- response_body
+passed
+
+
+
+=== TEST 5: Chat existing usage carries risk and denial without a new chunk
+--- case: {fixture="chat-usage", protocol="chat", tokens=18}
+--- response_body
+passed
+
+
+
+=== TEST 6: Chat safe result has no denial text
+--- case: {fixture="chat-safe", protocol="chat", text="safe output", safe=true}
+--- response_body
+passed
+
+
+
+=== TEST 7: Failed response scan cannot reuse the request risk level
+--- case: {fixture="chat-failure", protocol="chat", text="scan-error", 
failure=true}
+--- response_body
+passed
+--- error_log
+failed to check content:
+
+
+
+=== TEST 8: All choices finish before the single moderation result
+--- case: {fixture="chat-multiple-choices", protocol="chat", tokens=18}
+--- response_body
+passed
+
+
+
+=== TEST 9: Anthropic without usage gets a result without new content blocks
+--- case: {fixture="anthropic-deny", protocol="anthropic"}
+--- response_body
+passed
+
+
+
+=== TEST 10: Anthropic split usage is decorated in place
+--- case: {fixture="anthropic-usage", protocol="anthropic", tokens=18}
+--- response_body
+passed
+
+
+
+=== TEST 11: Anthropic coalesced message_delta is decorated in place
+--- case: {fixture="anthropic-usage", protocol="anthropic", tokens=18, 
buffered=true}
+--- response_body
+passed
+
+
+
+=== TEST 12: Responses completed retains output and denial metadata
+--- case: {fixture="responses-deny", protocol="responses"}
+--- response_body
+passed
+
+
+
+=== TEST 13: Responses completed preserves actual usage
+--- case: {fixture="responses-usage", protocol="responses", tokens=18}
+--- response_body
+passed
+
+
+
+=== TEST 14: Responses EOF does not fabricate a completed response
+--- case: {fixture="responses-eof", protocol="responses", eof=true}
+--- response_body
+passed
+
+
+
+=== TEST 15: Chat provider emits the final result in the Anthropic client 
protocol
+--- case: {fixture="chat-usage", protocol="anthropic", converted=true, 
tokens=18}
+--- response_body
+passed
+
+
+
+=== TEST 16: chat-error is preserved without a final moderation result
+--- case: {fixture="chat-error", protocol="chat", error=true}
+--- response_body
+passed
+
+
+
+=== TEST 17: Anthropic error after a scanned usage event is preserved without 
a terminator
+--- case: {fixture="anthropic-error", protocol="anthropic", error=true, 
tokens=18, scanned=true}
+--- response_body
+passed
+
+
+
+=== TEST 18: responses-error is preserved without a final moderation result
+--- case: {fixture="responses-error", protocol="responses", error=true}
+--- response_body
+passed
+
+
+
+=== TEST 19: responses-failed is preserved without a final moderation result
+--- case: {fixture="responses-failed", protocol="responses", error=true}
+--- response_body
+passed
+
+
+
+=== TEST 20: responses-incomplete is preserved without a final moderation 
result
+--- case: {fixture="responses-incomplete", protocol="responses", error=true, 
error_text="max_output_tokens"}
+--- response_body
+passed
+
+
+
+=== TEST 21: Chat result preserves the upstream length finish reason
+--- case: {fixture="chat-length", protocol="chat", tokens=18, 
finish_reason="length"}
+--- response_body
+passed
+
+
+
+=== TEST 22: Chat result preserves the upstream tool_calls finish reason
+--- case: {fixture="chat-tool_calls", protocol="chat", tokens=18, 
finish_reason="tool_calls"}
+--- response_body
+passed
+
+
+
+=== TEST 23: Anthropic result does not invent missing stop information
+--- case: {fixture="anthropic-null-delta", protocol="anthropic", tokens=18, 
missing_stop=true}
+--- response_body
+passed
+
+
+
+=== TEST 24: Chat clean EOF does not invent a finish reason
+--- case: {fixture="chat-clean-eof", protocol="chat", tokens=18}
+--- response_body
+passed
+
+
+
+=== TEST 25: multiple choices retain their independent finish reasons
+--- case: {fixture="chat-multiple-reasons", protocol="chat", tokens=18, 
finish_reason="tool_calls"}
+--- response_body
+passed
+
+
+
+=== TEST 26: anthropic safe result has an empty top-level denial message
+--- case: {fixture="anthropic-safe", protocol="anthropic", text="safe output", 
safe=true, tokens=18}
+--- response_body
+passed
+
+
+
+=== TEST 27: anthropic safe result has an empty top-level denial message in an 
existing event
+--- case: {fixture="anthropic-safe", protocol="anthropic", text="safe output", 
safe=true, tokens=18, buffered=true}
+--- response_body
+passed
+
+
+
+=== TEST 28: responses safe result has an empty top-level denial message
+--- case: {fixture="responses-safe", protocol="responses", text="safe output", 
safe=true, tokens=18}
+--- response_body
+passed
+
+
+
+=== TEST 29: Anthropic separate delta retains stop sequence
+--- case: {fixture="anthropic-stop-sequence", protocol="anthropic", tokens=18, 
stop_sequence="END"}
+--- response_body
+passed
+
+
+
+=== TEST 30: Anthropic result preserves stop sequence in the original event
+--- case: {fixture="anthropic-stop-sequence", protocol="anthropic", tokens=18, 
stop_sequence="END", buffered=true}
+--- response_body
+passed
+
+
+
+=== TEST 31: Chat coalesced usage retains the original chunks and billing
+--- case: {fixture="chat-usage", protocol="chat", tokens=18, buffered=true}
+--- response_body
+passed
+
+
+
+=== TEST 32: Chat without usage injects a result in the Anthropic client 
protocol
+--- case: {fixture="chat-deny", protocol="anthropic", converted=true}
+--- response_body
+passed
diff --git a/t/plugin/ai-aliyun-content-moderation-stream-usage.t 
b/t/plugin/ai-aliyun-content-moderation-stream-usage.t
new file mode 100644
index 000000000..e9816d6cd
--- /dev/null
+++ b/t/plugin/ai-aliyun-content-moderation-stream-usage.t
@@ -0,0 +1,189 @@
+#
+# 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';
+
+repeat_each(1);
+no_long_string();
+no_root_location();
+
+add_block_preprocessor(sub {
+    my ($block) = @_;
+    $block->set_value("request", "GET /t");
+    $block->set_value("extra_yaml_config", <<_EOC_);
+plugins:
+  - ai-proxy
+  - ai-aliyun-content-moderation
+  - key-auth
+_EOC_
+    $block->set_value("http_config", <<'_EOC_');
+    server {
+        listen 6724;
+        location / {
+            content_by_lua_block {
+                local core = require("apisix.core")
+                ngx.req.read_body()
+                local args = ngx.req.get_post_args()
+                local params = assert(core.json.decode(args.ServiceParameters))
+                assert(ngx.shared.test:incr(args.Service .. "_calls", 1, 0))
+                assert(ngx.shared.test:set(args.Service .. "_content", 
params.content))
+                local fixture = args.Service == "response_security_check"
+                    and "aliyun/moderation-risk.json" or 
"aliyun/moderation-safe.json"
+                ngx.header.content_type = "application/json"
+                ngx.print(assert(require("lib.fixture_loader").load(fixture)))
+            }
+        }
+    }
+_EOC_
+    if ($block->fixture) {
+        my $fixture = $block->fixture;
+        my $flush_events = $block->buffered ? "nil" : '"true"';
+        $block->set_value("config", <<_EOC_);
+    location /t {
+        content_by_lua_block {
+            local core = require("apisix.core")
+            local httpc = require("resty.http").new()
+            local res = assert(httpc:request_uri("http://127.0.0.1:"; .. 
ngx.var.server_port
+                .. "/chat", {
+                method = "POST",
+                headers = {
+                    ["Content-Type"] = "application/json",
+                    ["apikey"] = "stream-usage-key",
+                    ["X-AI-Fixture"] = "$fixture",
+                    ["X-AI-Fixture-Flush-Events"] = $flush_events,
+                },
+                body = 
[[{"messages":[{"role":"user","content":"hello"}],"stream":true}]],
+            }))
+            ngx.say("status: ", res.status)
+            for _, service in ipairs({"query_security_check", 
"response_security_check"}) do
+                ngx.say(service, ": ", ngx.shared.test:get(service .. 
"_calls"),
+                        " / ", ngx.shared.test:get(service .. "_content"))
+            end
+            local text = {}
+            local events, remainder = 
require("apisix.plugins.ai-transport.sse").decode_buf(res.body)
+            assert(remainder == "", "incomplete frame reached the client")
+            for _, event in ipairs(events) do
+                if event.data ~= "[DONE]" then
+                    local data = assert(core.json.decode(event.data))
+                    local content = core.table.try_read_attr(data, "choices", 
1,
+                                                              "delta", 
"content")
+                    if type(content) == "string" then
+                        text[#text + 1] = content
+                    end
+                end
+            end
+            ngx.say("text: ", table.concat(text))
+            local _, done_count = res.body:gsub("data: %[DONE%]", "")
+            ngx.say("done events: ", done_count)
+        }
+    }
+_EOC_
+    }
+});
+
+run_tests();
+
+__DATA__
+
+=== TEST 1: configure consumer request and final-packet response moderation
+--- config
+    location /t {
+        content_by_lua_block {
+            local test = require("lib.test_admin").test
+            local code, body = test("/apisix/admin/consumers", ngx.HTTP_PUT, 
[[{
+                "username": "stream-usage",
+                "plugins": {
+                    "key-auth": {"key": "stream-usage-key"},
+                    "ai-aliyun-content-moderation": {
+                        "endpoint": "http://127.0.0.1:6724";,
+                        "region_id": "cn-beijing",
+                        "access_key_id": "fake-key-id",
+                        "access_key_secret": "fake-key-secret",
+                        "check_request": true,
+                        "check_response": true,
+                        "request_check_service": "query_security_check",
+                        "response_check_service": "response_security_check",
+                        "stream_check_mode": "final_packet"
+                    }
+                }
+            }]])
+            assert(code < 300, body)
+            code, body = test("/apisix/admin/routes/1", ngx.HTTP_PUT, [[{
+                "uri": "/chat",
+                "plugins": {
+                    "key-auth": {},
+                    "ai-proxy": {
+                        "provider": "openai",
+                        "auth": {"header": {"Authorization": "Bearer test"}},
+                        "override": {"endpoint": "http://127.0.0.1:1980"},
+                        "streaming_flush_interval_ms": 0
+                    }
+                }
+            }]])
+            assert(code < 300, body)
+            ngx.say(body)
+        }
+    }
+--- response_body
+passed
+
+
+
+=== TEST 2: moderate complete text when the stream ends without usage
+--- fixture: openai/moderation-no-usage.sse
+--- response_body
+status: 200
+query_security_check: 1 / hello
+response_security_check: 1 / kill you
+text: kill you
+done events: 1
+
+
+
+=== TEST 3: preserve moderation when the stream includes usage
+--- fixture: openai/moderation-with-usage.sse
+--- response_body
+status: 200
+query_security_check: 1 / hello
+response_security_check: 1 / kill you
+text: kill you
+done events: 1
+
+
+
+=== TEST 4: moderate complete text at clean EOF without usage or a done event
+--- fixture: openai/moderation-eof-no-usage.sse
+--- response_body
+status: 200
+query_security_check: 1 / hello
+response_security_check: 1 / kill you
+text: kill you
+done events: 1
+
+
+
+=== TEST 5: do not finalize a stream with an incomplete trailing SSE event
+--- fixture: openai/moderation-incomplete-event.sse
+--- buffered: 1
+--- response_body
+status: 200
+query_security_check: 1 / hello
+response_security_check: nil / nil
+text: kill you
+done events: 0
+--- error_log
+dropping incomplete stream frame at EOF
diff --git a/t/plugin/ai-lakera-guard.t b/t/plugin/ai-lakera-guard.t
index 8ae98b449..7ae844538 100644
--- a/t/plugin/ai-lakera-guard.t
+++ b/t/plugin/ai-lakera-guard.t
@@ -917,22 +917,24 @@ passed
 
 
 
-=== TEST 37: a streamed response with no usage event cannot be scanned, so 
fail-closed blocks it
+=== TEST 37: an incomplete streamed response cannot be assembled, so 
fail-closed blocks it
 --- request
 POST /anything
 { "messages": [ { "role": "user", "content": "say hello" } ], "stream": true }
 --- more_headers
-X-AI-Fixture: openai/chat-streaming-no-usage.sse
+X-AI-Fixture: openai/chat-streaming-incomplete-no-usage.sse
 --- error_code: 200
 --- response_body_like eval
 qr/\A(?!.*Hello).*"content":"Response blocked by Lakera Guard".*\[DONE\]/s
 --- error_log
 streamed response ended without an assembled completion
 fail_open=false, blocking response
+--- no_error_log
+ai-lakera-guard mock: forwarded body=
 
 
 
-=== TEST 38: create a direction=output route with fail_open for the no-usage 
stream
+=== TEST 38: create a direction=output route with fail_open for an incomplete 
stream
 --- config
     location /t {
         content_by_lua_block {
@@ -970,18 +972,20 @@ passed
 
 
 
-=== TEST 39: with fail_open, an unscannable (no-usage) stream is released to 
the client unscanned
+=== TEST 39: with fail_open, an incomplete stream is released to the client 
unscanned
 --- request
 POST /anything
 { "messages": [ { "role": "user", "content": "say hello" } ], "stream": true }
 --- more_headers
-X-AI-Fixture: openai/chat-streaming-no-usage.sse
+X-AI-Fixture: openai/chat-streaming-incomplete-no-usage.sse
 --- error_code: 200
 --- response_body_like eval
-qr/\A(?!.*Response blocked by Lakera Guard).*Hello.*\[DONE\]/s
+qr/\A(?!.*Response blocked by Lakera Guard).*Hello/s
 --- error_log
 streamed response ended without an assembled completion
 fail_open=true, releasing unscanned
+--- no_error_log
+ai-lakera-guard mock: forwarded body=
 
 
 
@@ -1240,15 +1244,15 @@ passed
 
 
 
-=== TEST 50: a stream that ends at EOF with no terminal event is finalized 
(fail-closed block), not stranded as keep-alive heartbeats
+=== TEST 50: an incomplete stream at EOF is finalized (fail-closed block), not 
stranded as keep-alive heartbeats
 --- request
 POST /anything
 { "messages": [ { "role": "user", "content": "say hello" } ], "stream": true }
 --- more_headers
-X-AI-Fixture: openai/chat-streaming-many-chunks-no-usage.sse
+X-AI-Fixture: openai/chat-streaming-incomplete-no-usage.sse
 --- error_code: 200
 --- response_body_like eval
-qr/\A(?!.*chunk-00).*"content":"Response blocked by Lakera Guard"/s
+qr/\A(?!.*Hello).*"content":"Response blocked by Lakera Guard"/s
 --- error_log
 streamed response ended without an assembled completion
 fail_open=false, blocking response
@@ -1422,3 +1426,89 @@ POST /anything
 qr/\Adata:.*injection payload.*\[DONE\]/s
 --- error_log
 ai-lakera-guard: response flagged by Lakera Guard
+
+
+
+=== TEST 58: create a direction=output block route for complete streams 
without usage
+--- 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"
+                      }
+                    }
+                }]]
+            )
+
+            if code >= 300 then
+                ngx.status = code
+            end
+            ngx.say(body)
+        }
+    }
+--- response_body
+passed
+
+
+
+=== TEST 59: scan and release a complete no-usage stream
+--- request
+POST /anything
+{ "messages": [ { "role": "user", "content": "say hello" } ], "stream": true }
+--- more_headers
+X-AI-Fixture: openai/chat-streaming-no-usage.sse
+--- error_code: 200
+--- response_body_like eval
+qr/\A(?!.*Response blocked by Lakera Guard).*Hello.*\[DONE\]/s
+--- error_log
+"content":"Hello!"
+--- no_error_log
+streamed response ended without an assembled completion
+
+
+
+=== TEST 60: a flagged no-usage stream is scanned and blocked before its 
content is released
+--- request
+POST /anything
+{ "messages": [ { "role": "user", "content": "say hello" } ], "stream": true }
+--- more_headers
+X-AI-Fixture: openai/chat-streaming-injection-no-usage.sse
+--- error_code: 200
+--- response_body_like eval
+qr/\A(?!.*injection payload).*Response blocked by Lakera Guard.*\[DONE\]/s
+--- error_log
+ai-lakera-guard mock: forwarded body=
+"injection payload"
+--- no_error_log
+streamed response ended without an assembled completion
+
+
+
+=== TEST 61: clean EOF without usage is scanned and released
+--- request
+POST /anything
+{ "messages": [ { "role": "user", "content": "say hello" } ], "stream": true }
+--- more_headers
+X-AI-Fixture: openai/chat-streaming-many-chunks-no-usage.sse
+--- error_code: 200
+--- response_body_like eval
+qr/\A(?!.*Response blocked by Lakera Guard).*chunk-00/s
+--- error_log
+ai-lakera-guard mock: forwarded body=
+--- no_error_log
+streamed response ended without an assembled completion
diff --git a/t/plugin/ai-proxy-stream-truncated.t 
b/t/plugin/ai-proxy-stream-truncated.t
index 5e92f4b50..93e1b6d2f 100644
--- a/t/plugin/ai-proxy-stream-truncated.t
+++ b/t/plugin/ai-proxy-stream-truncated.t
@@ -319,11 +319,9 @@ passed
 POST /truncated
 {"messages":[{"role":"user","content":"hi"}],"model":"gpt-4","stream":true}
 --- response_body_like eval
-# The moderation plugin re-encodes every event, so the key order is not stable
-# enough to assert the body verbatim: require the delivered content and the
-# risk_level annotation that proves the final_packet branch ran, and reject a
-# [DONE] anywhere in the response.
-qr/^(?!.*\[DONE\])(?=.*"content":"hello")(?=.*"risk_level":"none")/s
+# Final-packet moderation requires completion; a transport failure must not
+# fabricate a final risk result or a successful protocol terminator.
+qr/^(?!.*\[DONE\])(?=.*"risk_level":"none")(?=.*"content":"hello")/s
 --- error_log
 failed to read response chunk: closed
 --- timeout: 10

Reply via email to