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 77086b0db fix(ai-proxy): do not turn a streaming read error after 
partial output into a 5xx (#13876)
77086b0db is described below

commit 77086b0db0dc91a120d0aa21834e6e41e4823d93
Author: Nic <[email protected]>
AuthorDate: Wed Aug 26 14:35:54 2026 +0800

    fix(ai-proxy): do not turn a streaming read error after partial output into 
a 5xx (#13876)
---
 apisix/plugins/ai-aliyun-content-moderation.lua |   7 +-
 apisix/plugins/ai-aws-content-moderation.lua    |   7 +-
 apisix/plugins/ai-providers/base.lua            |  41 ++-
 docs/en/latest/plugins/ai-proxy-multi.md        |   2 +
 docs/en/latest/plugins/ai-proxy.md              |   2 +
 t/lib/truncated_sse.lua                         | 114 +++++++
 t/plugin/ai-proxy-stream-truncated.t            | 375 ++++++++++++++++++++++++
 7 files changed, 545 insertions(+), 3 deletions(-)

diff --git a/apisix/plugins/ai-aliyun-content-moderation.lua 
b/apisix/plugins/ai-aliyun-content-moderation.lua
index 8778b754d..fe7748cb2 100644
--- a/apisix/plugins/ai-aliyun-content-moderation.lua
+++ b/apisix/plugins/ai-aliyun-content-moderation.lua
@@ -530,7 +530,12 @@ function _M.lua_body_filter(conf, ctx, headers, body)
             end
             table.insert(raw_events, sse.encode(event))
         end
-        if not contains_done_event and proto and ctx.var.llm_request_done then
+        -- 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())
         end
         return nil, table.concat(raw_events, "\n")
diff --git a/apisix/plugins/ai-aws-content-moderation.lua 
b/apisix/plugins/ai-aws-content-moderation.lua
index cc23e627a..56b985465 100644
--- a/apisix/plugins/ai-aws-content-moderation.lua
+++ b/apisix/plugins/ai-aws-content-moderation.lua
@@ -446,7 +446,12 @@ local function annotate_stream(ctx, body)
         table.insert(raw_events, sse.encode(event))
     end
 
-    if not contains_done_event and proto.build_done_event and 
ctx.var.llm_request_done then
+    -- 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.build_done_event
+       and ctx.var.llm_request_done and not ctx.ai_stream_aborted then
         table.insert(raw_events, proto.build_done_event())
     end
     return table.concat(raw_events)
diff --git a/apisix/plugins/ai-providers/base.lua 
b/apisix/plugins/ai-providers/base.lua
index c8a5d6a71..41e3bffaa 100644
--- a/apisix/plugins/ai-providers/base.lua
+++ b/apisix/plugins/ai-providers/base.lua
@@ -460,6 +460,12 @@ 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
+    -- 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
+    -- request context. Left set, it would make the next attempt look finished
+    -- before it has parsed a completion event of its own.
+    ctx.var.llm_request_done = nil
     ngx.status = res.status
     local body_reader = res.body_reader
     local contents = {}
@@ -475,6 +481,10 @@ function _M.parse_streaming_response(self, ctx, res, 
target_proto, converter, co
     -- all events may be skipped and no output produced, leaving the response
     -- uncommitted and causing nginx to fall through to the balancer phase.
     local output_sent = false
+    -- Set when THIS attempt parses the protocol's completion event. The read
+    -- error path must not consult ctx.var.llm_request_done for that: it is
+    -- shared across fallback attempts and is also set on abort finalization.
+    local protocol_completed = false
 
     -- Runaway-upstream safeguards. Both are opt-in; unset means no cap.
     local max_duration_ms = conf and conf.max_stream_duration_ms
@@ -555,7 +565,12 @@ function _M.parse_streaming_response(self, ctx, res, 
target_proto, converter, co
 
         local chunk, err = body_reader()
         if err then
-            ctx.ai_stream_aborted = "read_error"
+            -- A read error that arrives after the protocol's completion event
+            -- means the stream itself finished and only the transport died 
late,
+            -- so the response is complete rather than aborted.
+            if not protocol_completed then
+                ctx.ai_stream_aborted = "read_error"
+            end
             ctx.var.apisix_upstream_response_time = math.floor(
                 (ngx_now() - ctx.llm_request_start_time) * 1000)
             core.log.warn("failed to read response chunk: ", err)
@@ -564,6 +579,29 @@ function _M.parse_streaming_response(self, ctx, res, 
target_proto, converter, co
                 ngx.thread.kill(flush_thread)
                 flush_thread = nil
             end
+            -- The connection broke mid-body, so it must never go back into the
+            -- keepalive pool: drop it before ai-proxy's set_keepalive() runs.
+            if res._httpc then
+                res._httpc:close()
+                res._httpc = nil
+            end
+            if output_sent then
+                -- The downstream response is already committed as 200 and 
part of
+                -- the stream has reached the client, so the status can no 
longer
+                -- be changed and ai-proxy-multi must not bill another 
instance for
+                -- a retry. Mirror the max_stream_duration_ms path: one last
+                -- body_filter pass with llm_request_done set, so plugins that
+                -- buffer the whole stream flush what they hold instead of
+                -- stranding it. No terminator is synthesized -- the client 
detects
+                -- the truncation from the missing protocol completion event 
(e.g.
+                -- OpenAI [DONE], Anthropic message_stop, Responses
+                -- response.completed).
+                if not protocol_completed then
+                    ctx.var.llm_request_done = true
+                    plugin.lua_response_filter(ctx, res.headers, "", nil, true)
+                end
+                return
+            end
             return transport_http.handle_error(err)
         end
         if not chunk then
@@ -685,6 +723,7 @@ function _M.parse_streaming_response(self, ctx, res, 
target_proto, converter, co
 
             if parsed.type == "done" or parsed.type == "usage_and_done" then
                 ctx.var.llm_request_done = true
+                protocol_completed = true
             end
 
             ::CONTINUE::
diff --git a/docs/en/latest/plugins/ai-proxy-multi.md 
b/docs/en/latest/plugins/ai-proxy-multi.md
index e04b327b4..4f0dc647a 100644
--- a/docs/en/latest/plugins/ai-proxy-multi.md
+++ b/docs/en/latest/plugins/ai-proxy-multi.md
@@ -177,6 +177,8 @@ When the selected LLM upstream returns a `429` or `5xx` 
status, `ai-proxy-multi`
 - If the request is retried on another instance (per `fallback_strategy`, 
`fallback_http_statuses`, `max_retries`, and `retry_on_failure_within_ms`), the 
failed instance's error body is recorded in the error log for diagnostics, 
since a later attempt's response is sent to the client instead.
 - If the request is not retried (no matching `fallback_strategy` or 
`fallback_http_statuses`, retries exhausted, or the failure took longer than 
`retry_on_failure_within_ms`), the upstream status code and error body are 
returned to the client, preserving the upstream `Content-Type`.
 
+A streaming response is different once part of it has been delivered. The 
downstream response is committed as `200` with the first SSE event, so a later 
failure to read from the upstream (a connection reset or a read timeout) can no 
longer change the status, and falling back would bill another instance for a 
response the client can never receive. In that case `ai-proxy-multi` stops 
reading, closes the upstream connection, and ends the downstream stream where 
it is, without a protocol-spe [...]
+
 ## Examples
 
 The examples below demonstrate how you can configure `ai-proxy-multi` for 
different scenarios.
diff --git a/docs/en/latest/plugins/ai-proxy.md 
b/docs/en/latest/plugins/ai-proxy.md
index c9f132bbb..005f3108e 100644
--- a/docs/en/latest/plugins/ai-proxy.md
+++ b/docs/en/latest/plugins/ai-proxy.md
@@ -166,6 +166,8 @@ The setting covers `ai-proxy`, `ai-proxy-multi`, and 
`ai-request-rewrite`, which
 
 When the LLM upstream returns a `429` or `5xx` status, `ai-proxy` reads the 
upstream error body and returns it to the client together with the upstream 
status code and `Content-Type`, so provider-side error details (such as 
rate-limit information or validation errors) are not discarded.
 
+A streaming response is different once part of it has been delivered. The 
downstream response is committed as `200` with the first SSE event, so a later 
failure to read from the upstream (a connection reset or a read timeout) can no 
longer change the status. In that case `ai-proxy` stops reading, closes the 
upstream connection, and ends the downstream stream where it is, without a 
protocol-specific terminator such as `[DONE]`, `message_stop`, or 
`response.completed`; well-behaved clients [...]
+
 ## Examples
 
 The examples below demonstrate how you can configure `ai-proxy` for different 
scenarios.
diff --git a/t/lib/truncated_sse.lua b/t/lib/truncated_sse.lua
new file mode 100644
index 000000000..3ceec4a67
--- /dev/null
+++ b/t/lib/truncated_sse.lua
@@ -0,0 +1,114 @@
+--
+-- 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.
+--
+
+-- Mock LLM upstream that commits a 200 SSE response and then drops the
+-- connection mid-body. Writing the response on the raw downstream socket is
+-- what makes the truncation possible: through the normal nginx output chain 
the
+-- terminating chunk is always appended, which is a clean EOF rather than the
+-- transport error this mock has to produce.
+local ngx = ngx
+
+local _M = {}
+
+local CRLF = string.char(13, 10)
+
+local DONE_STREAM = table.concat({
+    'data: {"id":"chatcmpl-1","object":"chat.completion.chunk",'
+    .. 
'"choices":[{"index":0,"delta":{"content":"hello"},"finish_reason":null}]}',
+    "",
+    "data: [DONE]",
+    "",
+    "",
+}, "\n")
+
+
+local function take_over()
+    ngx.req.read_body()
+    local sock, err = ngx.req.socket(true)
+    if not sock then
+        ngx.log(ngx.ERR, "failed to take over the downstream socket: ", err)
+        return nil
+    end
+    sock:send("HTTP/1.1 200 OK" .. CRLF
+              .. "Content-Type: text/event-stream" .. CRLF
+              .. "Transfer-Encoding: chunked" .. CRLF
+              .. "Connection: keep-alive" .. CRLF .. CRLF)
+    return sock
+end
+
+
+-- Sends every event in `events` as its own chunk, then closes without the
+-- terminating zero-length chunk, so the client's body reader fails with
+-- "closed" after the events have already been delivered.
+function _M.serve(events)
+    local sock = take_over()
+    if not sock then
+        return
+    end
+    for _, event in ipairs(events) do
+        sock:send(string.format("%x", #event) .. CRLF .. event .. CRLF)
+    end
+    return ngx.exit(444)
+end
+
+
+local done_then_truncate_hits = 0
+
+-- First call answers with a [DONE]-only stream. Behind a protocol converter
+-- that yields no downstream event for it, the attempt sets llm_request_done
+-- while output_sent stays false, so EOF becomes the 502 that ai-proxy-multi
+-- falls back on. Every later call emits one real event and then truncates,
+-- which is the attempt that must not inherit the first one's completion state.
+function _M.serve_done_then_truncate()
+    done_then_truncate_hits = done_then_truncate_hits + 1
+    if done_then_truncate_hits == 1 then
+        ngx.header["Content-Type"] = "text/event-stream"
+        ngx.print("data: [DONE]\n\n")
+        return ngx.flush(true)
+    end
+    return _M.serve({
+        'data: {"id":"chatcmpl-1","object":"chat.completion.chunk",'
+        .. '"choices":[{"index":0,"delta":{"content":"hello"},'
+        .. '"finish_reason":null}]}\n\n',
+        -- a usage event, so the content-moderation final_packet path has an
+        -- assembled completion to work with before the transport dies
+        'data: {"id":"chatcmpl-1","object":"chat.completion.chunk",'
+        .. '"choices":[],"usage":{"prompt_tokens":1,'
+        .. '"completion_tokens":1,"total_tokens":2}}\n\n',
+    })
+end
+
+
+local aborts = 0
+
+-- First call closes right after the headers, before any body byte; every later
+-- call streams a complete response.
+function _M.serve_abort_once()
+    aborts = aborts + 1
+    if aborts > 1 then
+        ngx.header["Content-Type"] = "text/event-stream"
+        ngx.print(DONE_STREAM)
+        return ngx.flush(true)
+    end
+    if not take_over() then
+        return
+    end
+    return ngx.exit(444)
+end
+
+
+return _M
diff --git a/t/plugin/ai-proxy-stream-truncated.t 
b/t/plugin/ai-proxy-stream-truncated.t
new file mode 100644
index 000000000..b75934626
--- /dev/null
+++ b/t/plugin/ai-proxy-stream-truncated.t
@@ -0,0 +1,375 @@
+#
+# 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.
+#
+
+# The control API v1 mounts a "/v1/" prefix route that would shadow the
+# "/v1/messages" route TEST 9 needs to activate the Anthropic client protocol.
+BEGIN {
+    $ENV{TEST_ENABLE_CONTROL_API_V1} = "0";
+}
+
+use t::APISIX 'no_plan';
+
+log_level("info");
+repeat_each(1);
+no_long_string();
+no_root_location();
+
+add_block_preprocessor(sub {
+    my ($block) = @_;
+
+    if (!defined $block->request) {
+        $block->set_value("request", "GET /t");
+    }
+
+    # The behaviour under test lives in ai-providers/base.lua and only depends 
on
+    # body_reader() reporting a transport error, so it is the same for either 
HTTP
+    # client. Pinning one keeps the error string this file asserts on stable.
+    my $user_yaml_config = <<_EOC_;
+plugins:
+  - ai-proxy
+  - ai-proxy-multi
+  - ai-aliyun-content-moderation
+plugin_attr:
+    ai-proxy:
+        http_client: lua-resty-http
+_EOC_
+    if (!defined $block->extra_yaml_config) {
+        $block->set_value("extra_yaml_config", $user_yaml_config);
+    }
+
+    my $http_config = $block->http_config // <<_EOC_;
+    server {
+        listen 6724;
+
+        # Commits a 200 SSE response, flushes one valid event, then drops the
+        # connection without the terminating chunk and without [DONE]. Takes 
over
+        # the raw socket because nginx would otherwise append a well-formed
+        # end-of-chunked-body on its own.
+        location /v1/chat/completions-truncate {
+            content_by_lua_block {
+                require("lib.truncated_sse").serve({
+                    'data: 
{"id":"chatcmpl-1","object":"chat.completion.chunk",'
+                    .. '"choices":[{"index":0,"delta":{"content":"hello"},'
+                    .. '"finish_reason":null}]}\\n\\n',
+                })
+            }
+        }
+
+        # Same, but a usage event lands before the truncation, so
+        # ctx.var.llm_response_text is set and the content-moderation
+        # final_packet path runs on the last body_filter pass.
+        location /v1/chat/completions-usage-then-truncate {
+            content_by_lua_block {
+                require("lib.truncated_sse").serve({
+                    'data: 
{"id":"chatcmpl-1","object":"chat.completion.chunk",'
+                    .. '"choices":[{"index":0,"delta":{"content":"hello"},'
+                    .. '"finish_reason":null}]}\\n\\n',
+                    'data: 
{"id":"chatcmpl-1","object":"chat.completion.chunk",'
+                    .. '"choices":[],"usage":{"prompt_tokens":1,'
+                    .. '"completion_tokens":1,"total_tokens":2}}\\n\\n',
+                })
+            }
+        }
+
+        # First hit commits the 200 SSE headers and drops the connection before
+        # any body byte; later hits stream a complete response. Reproduces a 
read
+        # error that happens before anything reaches the client.
+        location /v1/chat/completions-abort-once {
+            content_by_lua_block {
+                require("lib.truncated_sse").serve_abort_once()
+            }
+        }
+
+        # First hit answers with a [DONE]-only stream (no downstream output 
once
+        # the anthropic converter has skipped it, so the attempt 502s and is
+        # retryable); the retry emits one event and then truncates.
+        location /v1/chat/completions-done-then-truncate {
+            content_by_lua_block {
+                require("lib.truncated_sse").serve_done_then_truncate()
+            }
+        }
+
+        # Aliyun content-moderation endpoint.
+        location / {
+            content_by_lua_block {
+                local content = 
require("lib.fixture_loader").load("aliyun/moderation-safe.json")
+                ngx.status = 200
+                ngx.header["Content-Type"] = "application/json"
+                ngx.print(content)
+            }
+        }
+    }
+_EOC_
+    $block->set_value("http_config", $http_config);
+});
+
+run_tests();
+
+__DATA__
+
+=== TEST 1: ai-proxy route on the truncating upstream
+--- 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": "/truncated",
+                "plugins": {
+                    "ai-proxy": {
+                        "provider": "openai",
+                        "auth": {"header": {"Authorization": "Bearer test"}},
+                        "options": {"model": "gpt-4", "stream": true},
+                        "override": {
+                            "endpoint": 
"http://127.0.0.1:6724/v1/chat/completions-truncate";
+                        },
+                        "ssl_verify": false
+                    }
+                }
+            }]])
+            if code >= 300 then ngx.status = code end
+            ngx.say(body)
+        }
+    }
+--- response_body
+passed
+
+
+
+=== TEST 2: a read error after partial output leaves the committed 200 alone
+--- request
+POST /truncated
+{"messages":[{"role":"user","content":"hi"}],"model":"gpt-4","stream":true}
+--- response_body eval
+qq{data: 
{"id":"chatcmpl-1","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":"hello"},"finish_reason":null}]}\n\n}
+--- error_log
+failed to read response chunk: closed
+--- no_error_log
+exits with http status code
+attempt to set ngx.status
+failed to keepalive connection
+--- timeout: 10
+
+
+
+=== TEST 3: ai-proxy-multi route on the truncating upstream, with http_5xx 
fallback
+--- 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": "/truncated",
+                "plugins": {
+                    "ai-proxy-multi": {
+                        "fallback_strategy": ["http_5xx"],
+                        "ssl_verify": false,
+                        "instances": [
+                            {"name":"first","provider":"openai","weight":1,
+                             "auth":{"header":{"Authorization":"Bearer test"}},
+                             "options":{"model":"gpt-4","stream":true},
+                             
"override":{"endpoint":"http://127.0.0.1:6724/v1/chat/completions-truncate"}},
+                            {"name":"second","provider":"openai","weight":1,
+                             "auth":{"header":{"Authorization":"Bearer test"}},
+                             "options":{"model":"gpt-4","stream":true},
+                             
"override":{"endpoint":"http://127.0.0.1:6724/v1/chat/completions-truncate"}}
+                        ]
+                    }
+                }
+            }]])
+            if code >= 300 then ngx.status = code end
+            ngx.say(body)
+        }
+    }
+--- response_body
+passed
+
+
+
+=== TEST 4: no fallback after partial output -- exactly one upstream request 
is billed
+--- request
+POST /truncated
+{"messages":[{"role":"user","content":"hi"}],"model":"gpt-4","stream":true}
+--- response_body eval
+qq{data: 
{"id":"chatcmpl-1","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":"hello"},"finish_reason":null}]}\n\n}
+--- grep_error_log eval
+qr/sending request to LLM server/
+--- grep_error_log_out
+sending request to LLM server
+--- no_error_log
+falling back to
+attempt to set ngx.status
+--- timeout: 10
+
+
+
+=== TEST 5: ai-proxy-multi route on the abort-before-body upstream
+--- 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": "/truncated",
+                "plugins": {
+                    "ai-proxy-multi": {
+                        "fallback_strategy": ["http_5xx"],
+                        "ssl_verify": false,
+                        "instances": [
+                            {"name":"first","provider":"openai","weight":1,
+                             "auth":{"header":{"Authorization":"Bearer test"}},
+                             "options":{"model":"gpt-4","stream":true},
+                             
"override":{"endpoint":"http://127.0.0.1:6724/v1/chat/completions-abort-once"}},
+                            {"name":"spare","provider":"openai","weight":0,
+                             "auth":{"header":{"Authorization":"Bearer test"}},
+                             "options":{"model":"gpt-4","stream":true},
+                             
"override":{"endpoint":"http://127.0.0.1:6724/v1/chat/completions-abort-once"}}
+                        ]
+                    }
+                }
+            }]])
+            if code >= 300 then ngx.status = code end
+            ngx.say(body)
+        }
+    }
+--- response_body
+passed
+
+
+
+=== TEST 6: a read error before the first downstream byte still falls back
+--- request
+POST /truncated
+{"messages":[{"role":"user","content":"hi"}],"model":"gpt-4","stream":true}
+--- response_body_like eval
+qr/data: \[DONE\]/
+--- error_log
+failed to read response chunk: closed
+falling back to
+--- timeout: 10
+
+
+
+=== TEST 7: ai-proxy + ai-aliyun-content-moderation on the usage-then-truncate 
upstream
+--- 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": "/truncated",
+                "plugins": {
+                    "ai-proxy": {
+                        "provider": "openai",
+                        "auth": {"header": {"Authorization": "Bearer test"}},
+                        "options": {"model": "gpt-4", "stream": true},
+                        "override": {
+                            "endpoint": 
"http://127.0.0.1:6724/v1/chat/completions-usage-then-truncate";
+                        },
+                        "ssl_verify": false
+                    },
+                    "ai-aliyun-content-moderation": {
+                        "endpoint": "http://127.0.0.1:6724";,
+                        "region_id": "cn-shanghai",
+                        "access_key_id": "fake-key-id",
+                        "access_key_secret": "fake-key-secret",
+                        "risk_level_bar": "high",
+                        "check_request": false,
+                        "check_response": true,
+                        "stream_check_mode": "final_packet"
+                    }
+                }
+            }]])
+            if code >= 300 then ngx.status = code end
+            ngx.say(body)
+        }
+    }
+--- response_body
+passed
+
+
+
+=== TEST 8: a truncated stream is never terminated with a synthesized [DONE]
+--- request
+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
+--- error_log
+failed to read response chunk: closed
+--- timeout: 10
+
+
+
+=== TEST 9: ai-proxy-multi + a converter, where attempt 1 is a [DONE]-only 
stream
+--- config
+    location /t {
+        content_by_lua_block {
+            local t = require("lib.test_admin").test
+            local code, body = t('/apisix/admin/routes/1', ngx.HTTP_PUT, [[{
+                "uri": "/v1/messages",
+                "plugins": {
+                    "ai-proxy-multi": {
+                        "fallback_strategy": ["http_5xx"],
+                        "ssl_verify": false,
+                        "instances": [
+                            {"name":"primary","provider":"openai","weight":1,
+                             "auth":{"header":{"Authorization":"Bearer test"}},
+                             "options":{"model":"gpt-4","stream":true},
+                             
"override":{"endpoint":"http://127.0.0.1:6724/v1/chat/completions-done-then-truncate"}},
+                            {"name":"spare","provider":"openai","weight":0,
+                             "auth":{"header":{"Authorization":"Bearer test"}},
+                             "options":{"model":"gpt-4","stream":true},
+                             
"override":{"endpoint":"http://127.0.0.1:6724/v1/chat/completions-done-then-truncate"}}
+                        ]
+                    },
+                    "ai-aliyun-content-moderation": {
+                        "endpoint": "http://127.0.0.1:6724";,
+                        "region_id": "cn-shanghai",
+                        "access_key_id": "fake-key-id",
+                        "access_key_secret": "fake-key-secret",
+                        "risk_level_bar": "high",
+                        "check_request": false,
+                        "check_response": true,
+                        "stream_check_mode": "final_packet"
+                    }
+                }
+            }]])
+            if code >= 300 then ngx.status = code end
+            ngx.say(body)
+        }
+    }
+--- response_body
+passed
+
+
+
+=== TEST 10: the retried attempt does not inherit the first attempt's 
completion state
+--- request
+POST /v1/messages
+{"model":"claude-3-5-sonnet","max_tokens":64,"stream":true,"messages":[{"role":"user","content":"hi"}]}
+--- more_headers
+Content-Type: application/json
+--- response_body_like eval
+# Inheriting the first attempt's completion state makes the retry look finished
+# from its very first chunk: the abort marker is never set, so the moderation
+# plugin appends a message_stop and reports the truncated stream as complete.
+qr/^(?!.*message_stop)(?=.*"text":"hello")/s
+--- error_log
+failed to read response chunk: closed
+falling back to
+--- timeout: 10

Reply via email to