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 5e784af30d fix(ai-proxy): avoid aborting streams on empty flushes 
(#13947)
5e784af30d is described below

commit 5e784af30d19ce508de5b88c5d438ddf69d156e0
Author: Nic <[email protected]>
AuthorDate: Tue Sep 15 17:52:11 2026 +0800

    fix(ai-proxy): avoid aborting streams on empty flushes (#13947)
---
 apisix/plugins/ai-providers/base.lua |  17 +-
 docs/en/latest/plugins/ai-proxy.md   |   2 +-
 docs/zh/latest/plugins/ai-proxy.md   |   2 +-
 t/plugin/ai-proxy-flush-fragmented.t | 332 +++++++++++++++++++++++++++++++++++
 4 files changed, 342 insertions(+), 11 deletions(-)

diff --git a/apisix/plugins/ai-providers/base.lua 
b/apisix/plugins/ai-providers/base.lua
index 8612f5dfd6..63f74246ba 100644
--- a/apisix/plugins/ai-providers/base.lua
+++ b/apisix/plugins/ai-providers/base.lua
@@ -508,10 +508,9 @@ function _M.parse_streaming_response(self, ctx, res, 
target_proto, converter, co
     local flush_interval_ms = conf and conf.streaming_flush_interval_ms or 0
     -- async_flush: true when the interval thread is responsible for flushing
     local async_flush = flush_interval_ms > 0
-    -- needs_flush is set to true immediately after dispatching a chunk so the
-    -- thread always flushes exactly the data that has been written.  Cleared
-    -- before ngx.flush() so any new chunks written during the flush yield are
-    -- picked up on the next interval rather than silently dropped.
+    -- Arm after dispatching a chunk; a response filter may still withhold its
+    -- output, so the flush thread must tolerate "nothing to flush". Clear the
+    -- flag before flushing so a chunk dispatched during a yield arms it again.
     local needs_flush = false
     local flush_thread
     local flush_err
@@ -524,11 +523,12 @@ function _M.parse_streaming_response(self, ctx, res, 
target_proto, converter, co
                 if needs_flush then
                     needs_flush = false
                     local ok, err = ngx.flush(false)
-                    if not ok then
+                    if ok then
+                        core.log.debug("ai-proxy: flush_thread periodic flush")
+                    elseif err ~= "nothing to flush" then
                         flush_err = err
                         return
                     end
-                    core.log.debug("ai-proxy: flush_thread periodic flush")
                 end
             end
         end)
@@ -756,6 +756,7 @@ function _M.parse_streaming_response(self, ctx, res, 
target_proto, converter, co
                     return
                 end
                 output_sent = true
+                needs_flush = true
             end
 
             if ctx.var.llm_request_done and #converted_chunks == 0
@@ -766,6 +767,7 @@ function _M.parse_streaming_response(self, ctx, res, 
target_proto, converter, co
                     abort_on_disconnect(flush_err)
                     return
                 end
+                needs_flush = true
             end
         elseif ctx.ai_stream_framing ~= "sse" or complete ~= "" then
             -- Native SSE filters need complete frames just like converters do.
@@ -778,9 +780,6 @@ function _M.parse_streaming_response(self, ctx, res, 
target_proto, converter, co
                 return
             end
             output_sent = true
-        end
-        -- Let the interval flush thread know there is unflushed output.
-        if async_flush then
             needs_flush = true
         end
 
diff --git a/docs/en/latest/plugins/ai-proxy.md 
b/docs/en/latest/plugins/ai-proxy.md
index 005f3108e4..5e24268e53 100644
--- a/docs/en/latest/plugins/ai-proxy.md
+++ b/docs/en/latest/plugins/ai-proxy.md
@@ -101,7 +101,7 @@ When `provider` is set to `bedrock`, the Plugin expects 
requests in the [Bedrock
 | keepalive_timeout | integer | False | 60000  | ≥ 1000                        
           | Keepalive timeout in milliseconds when connecting to the LLM 
service. |
 | keepalive_pool | integer | False    | 30       | ≥ 1                         
             | Keepalive pool size for the LLM service connection. |
 | ssl_verify     | boolean | False    | true   |                               
           | If true, verifies the LLM service's certificate. |
-| streaming_flush_interval_ms | integer | False | 10 | ≥ 0 | Interval in 
milliseconds for the background flush thread. When `> 0` (default: `10`), a 
background timer calls `ngx.flush(false)` every N ms, batching output for 
bursty upstreams. When `0`, the background thread is disabled and each chunk is 
flushed synchronously via `ngx.flush(true)`, guaranteeing immediate client 
delivery. |
+| streaming_flush_interval_ms | integer | False | 10 | ≥ 0 | Interval in 
milliseconds for the background flush thread. When `> 0` (default: `10`), a 
background timer calls `ngx.flush(false)` every N ms, batching output for 
bursty upstreams. For SSE streams, only complete frames or converted events 
queued for the client trigger a flush; partial frames wait for the remaining 
upstream data. If a periodic flush returns `nothing to flush`, the stream 
continues. When `0`, the background thread [...]
 
 ## Provider-aware `max_tokens` mapping
 
diff --git a/docs/zh/latest/plugins/ai-proxy.md 
b/docs/zh/latest/plugins/ai-proxy.md
index 62a1b4eae4..bdabdd300a 100644
--- a/docs/zh/latest/plugins/ai-proxy.md
+++ b/docs/zh/latest/plugins/ai-proxy.md
@@ -100,7 +100,7 @@ import TabItem from '@theme/TabItem';
 | keepalive_timeout | integer | 否 | 60000  | ≥ 1000                            
       | 连接到 LLM 服务时的保活超时时间(毫秒)。 |
 | keepalive_pool | integer | 否    | 30       | ≥ 1                             
         | LLM 服务连接的保活池大小。 |
 | ssl_verify     | boolean | 否    | true   |                                   
       | 如果为 true,验证 LLM 服务的证书。 |
-| streaming_flush_interval_ms | integer | 否 | 10 | ≥ 0 | 后台刷新线程的间隔时间(毫秒)。`> 
0`(默认值:`10`)时,后台定时器每隔 N 毫秒调用一次 `ngx.flush(false)`,适合上游批量发送 token 的场景。设为 `0` 
时禁用后台线程,改为每个 chunk 同步调用 `ngx.flush(true)` 立即刷新。 |
+| streaming_flush_interval_ms | integer | 否 | 10 | ≥ 0 | 后台刷新线程的间隔时间(毫秒)。`> 
0`(默认值:`10`)时,后台定时器每隔 N 毫秒调用一次 `ngx.flush(false)`,适合上游批量发送 token 的场景。对于 SSE 
流,只有已排队发给客户端的完整帧或转换后的事件才会触发刷新;不完整的帧会等待后续上游数据。定时刷新若返回 `nothing to 
flush`,会继续处理流式响应。设为 `0` 时禁用后台线程,改为每个 chunk 同步调用 `ngx.flush(true)` 立即刷新。 |
 
 ## Provider-aware `max_tokens` mapping
 
diff --git a/t/plugin/ai-proxy-flush-fragmented.t 
b/t/plugin/ai-proxy-flush-fragmented.t
new file mode 100644
index 0000000000..c877970a1d
--- /dev/null
+++ b/t/plugin/ai-proxy-flush-fragmented.t
@@ -0,0 +1,332 @@
+#
+# 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';
+use File::Slurp ();
+
+log_level("info");
+repeat_each(1);
+no_long_string();
+no_root_location();
+
+add_block_preprocessor(sub {
+    my ($block) = @_;
+    $block->set_value("extra_init_by_lua", <<'_EOC_');
+    local buffer_plugin = {
+        version = 0.1,
+        priority = 1,
+        name = "test-ai-buffer",
+        schema = {type = "object", properties = {}},
+    }
+    function buffer_plugin.check_schema(conf)
+        return require("apisix.core").schema.check(buffer_plugin.schema, conf)
+    end
+    function buffer_plugin.lua_body_filter(conf, ctx, headers, body)
+        ctx.test_ai_buffer = ctx.test_ai_buffer or {}
+        table.insert(ctx.test_ai_buffer, body)
+        if not ctx.var.llm_request_done then
+            return nil, ""
+        end
+        return nil, table.concat(ctx.test_ai_buffer)
+    end
+    package.loaded["apisix.plugins.test-ai-buffer"] = buffer_plugin
+_EOC_
+    $block->set_value("http_config", <<'_EOC_');
+        server {
+            listen 7752;
+            location /stream {
+                content_by_lua_block {
+                    local loader = require("lib.fixture_loader")
+                    local body = 
assert(loader.load(ngx.req.get_headers()["X-AI-Fixture"]))
+                    local args = ngx.req.get_uri_args()
+                    ngx.header["Content-Type"] = "text/event-stream"
+                    local function send(chunk)
+                        if not ngx.print(chunk) or not ngx.flush(true) then
+                            return false
+                        end
+                        ngx.sleep(0.05)
+                        return true
+                    end
+                    if args.skip then
+                        if not send(": keepalive\n\n") or not send(": 
keepalive\n\n") then
+                            return
+                        end
+                    end
+                    if args.hold_eof then
+                        ngx.shared.test:delete("converted-completion-received")
+                        local done = assert(body:find("data: [DONE]", 1, true))
+                        if not send(body:sub(1, done - 1)) then
+                            return
+                        end
+                        assert(ngx.print(body:sub(done)))
+                        assert(ngx.flush(true))
+                        for _ = 1, 100 do
+                            if 
ngx.shared.test:get("converted-completion-received") then
+                                return
+                            end
+                            ngx.sleep(0.01)
+                        end
+                        return
+                    end
+                    local offset = 0
+                    local complete_frames = args.buffer and 2 or 
args.after_first and 1 or 0
+                    for _ = 1, complete_frames do
+                        local boundary = assert(body:find("\n\n", offset + 1, 
true)) + 1
+                        if not send(body:sub(offset + 1, boundary)) then
+                            return
+                        end
+                        offset = boundary
+                    end
+                    if args.buffer then
+                        send(body:sub(offset + 1))
+                        return
+                    end
+                    -- Two fragments leave the next frame incomplete across a 
flush interval.
+                    if not send(body:sub(offset + 1, offset + 10))
+                       or not send(body:sub(offset + 11, offset + 20)) then
+                        return
+                    end
+                    ngx.print(body:sub(offset + 21))
+                }
+            }
+        }
+_EOC_
+    $block->set_value("apisix_yaml", $block->apisix_yaml // <<'_EOC_');
+routes:
+  - id: native
+    uri: /fragmented/v1/responses
+    plugins:
+      ai-proxy-multi:
+        instances:
+          - name: native
+            provider: openai-compatible
+            weight: 1
+            auth:
+              header:
+                Authorization: Bearer test-key
+            override:
+              endpoint: http://127.0.0.1:7752/stream
+  - id: after-first
+    uri: /after-first/v1/responses
+    plugins:
+      ai-proxy-multi:
+        instances:
+          - name: native
+            provider: openai-compatible
+            weight: 1
+            auth:
+              header:
+                Authorization: Bearer test-key
+            override:
+              endpoint: http://127.0.0.1:7752/stream?after_first=true
+  - id: sync
+    uri: /sync/v1/responses
+    plugins:
+      ai-proxy-multi:
+        streaming_flush_interval_ms: 0
+        instances:
+          - name: native
+            provider: openai-compatible
+            weight: 1
+            auth:
+              header:
+                Authorization: Bearer test-key
+            override:
+              endpoint: http://127.0.0.1:7752/stream
+  - id: converted
+    uri: /converted/v1/messages
+    plugins:
+      ai-proxy:
+        provider: openai-compatible
+        auth:
+          header:
+            Authorization: Bearer test-key
+        override:
+          endpoint: http://127.0.0.1:7752/stream?skip=true
+#END
+_EOC_
+});
+
+run_tests();
+
+__DATA__
+
+=== TEST 1: async flush waits for the first complete native SSE frame
+--- request
+POST /fragmented/v1/responses
+{"model":"test","input":"hi","stream":true}
+--- more_headers
+X-AI-Fixture: openai/responses-streaming.sse
+--- response_body eval
+scalar File::Slurp::read_file("t/fixtures/openai/responses-streaming.sse")
+--- no_error_log
+[error]
+nothing to flush
+client disconnected during AI streaming
+
+
+
+=== TEST 2: async flush preserves partial frames after earlier output
+--- request
+POST /after-first/v1/responses
+{"model":"test","input":"hi","stream":true}
+--- more_headers
+X-AI-Fixture: openai/responses-streaming.sse
+--- response_body eval
+scalar File::Slurp::read_file("t/fixtures/openai/responses-streaming.sse")
+--- no_error_log
+[error]
+nothing to flush
+client disconnected during AI streaming
+
+
+
+=== TEST 3: synchronous flush waits for a complete native SSE frame
+--- request
+POST /sync/v1/responses
+{"model":"test","input":"hi","stream":true}
+--- more_headers
+X-AI-Fixture: openai/responses-streaming.sse
+--- response_body eval
+scalar File::Slurp::read_file("t/fixtures/openai/responses-streaming.sse")
+--- no_error_log
+[error]
+nothing to flush
+client disconnected during AI streaming
+
+
+
+=== TEST 4: converter waits through skipped events and partial frames before 
output
+--- request
+POST /converted/v1/messages
+{"model":"test","messages":[{"role":"user","content":"hi"}],"max_tokens":32,"stream":true}
+--- more_headers
+X-AI-Fixture: protocol-conversion/openai-to-anthropic-stream.sse
+--- response_body_like eval
+qr/event: message_start.*Hello.* world.*event: message_stop/s
+--- no_error_log
+[error]
+nothing to flush
+client disconnected during AI streaming
+
+
+
+=== TEST 5: empty periodic flush resumes after a filter releases buffered 
output
+--- extra_yaml_config
+plugins:
+  - ai-proxy-multi
+  - test-ai-buffer
+--- apisix_yaml
+routes:
+  - id: buffered
+    uri: /buffered/v1/responses
+    plugins:
+      test-ai-buffer: {}
+      ai-proxy-multi:
+        instances:
+          - name: native
+            provider: openai-compatible
+            weight: 1
+            auth:
+              header:
+                Authorization: Bearer test-key
+            override:
+              endpoint: http://127.0.0.1:7752/stream?buffer=true
+#END
+--- request
+POST /buffered/v1/responses
+{"model":"test","input":"hi","stream":true}
+--- more_headers
+X-AI-Fixture: openai/responses-streaming.sse
+--- response_body eval
+scalar File::Slurp::read_file("t/fixtures/openai/responses-streaming.sse")
+--- no_error_log
+[error]
+nothing to flush
+client disconnected during AI streaming
+
+
+
+=== TEST 6: empty converted completion dispatch flushes buffered output before 
upstream EOF
+--- extra_yaml_config
+plugins:
+  - ai-proxy
+  - test-ai-buffer
+--- apisix_yaml
+routes:
+  - id: converted-buffered
+    uri: /converted-buffered/v1/messages
+    plugins:
+      test-ai-buffer: {}
+      ai-proxy:
+        provider: openai-compatible
+        auth:
+          header:
+            Authorization: Bearer test-key
+        override:
+          endpoint: http://127.0.0.1:7752/stream?hold_eof=true
+#END
+--- config
+    postpone_output 65536;
+    location /t {
+        content_by_lua_block {
+            local httpc = require("resty.http").new()
+            httpc:set_timeout(500)
+            assert(httpc:connect("127.0.0.1", ngx.var.server_port))
+            local res, err = httpc:request({
+                method = "POST",
+                path = "/converted-buffered/v1/messages",
+                headers = {
+                    ["Content-Type"] = "application/json",
+                    ["X-AI-Fixture"] = 
"protocol-conversion/usage-only-final-chunk.sse",
+                },
+                body = 
[[{"model":"test","messages":[{"role":"user","content":"hi"}],
+                         "max_tokens":32,"stream":true}]],
+            })
+            local body = ""
+            if res then
+                while true do
+                    local chunk
+                    chunk, err = res.body_reader()
+                    if not chunk then
+                        break
+                    end
+                    body = body .. chunk
+                    if body:find("event: message_stop", 1, true) then
+                        
assert(ngx.shared.test:set("converted-completion-received", true))
+                    end
+                end
+            end
+            httpc:close()
+            if err then
+                assert(ngx.shared.test:set("converted-completion-received", 
true))
+                ngx.say("failed: ", err)
+                return
+            end
+            assert(body:find('"text":"Hi"', 1, true), body)
+            assert(body:find("event: message_stop", 1, true), body)
+            ngx.say("complete response received before upstream EOF")
+        }
+    }
+--- request
+GET /t
+--- response_body
+complete response received before upstream EOF
+--- no_error_log
+[error]
+nothing to flush
+client disconnected during AI streaming

Reply via email to