This is an automated email from the ASF dual-hosted git repository.

AlinsRan 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 fe625ffc3 fix(ai-proxy): drop tool_choice without tools and stop 
hanging Anthropic stream (#13583)
fe625ffc3 is described below

commit fe625ffc3b54cf4a86a21c7923b89875871d690a
Author: AlinsRan <[email protected]>
AuthorDate: Wed Jun 24 08:16:20 2026 +0800

    fix(ai-proxy): drop tool_choice without tools and stop hanging Anthropic 
stream (#13583)
---
 .../anthropic-messages-to-openai-chat.lua          | 33 ++++++---
 t/plugin/ai-proxy-anthropic.t                      | 86 ++++++++++++++++++++++
 2 files changed, 108 insertions(+), 11 deletions(-)

diff --git 
a/apisix/plugins/ai-protocols/converters/anthropic-messages-to-openai-chat.lua 
b/apisix/plugins/ai-protocols/converters/anthropic-messages-to-openai-chat.lua
index 7d0b1b406..aa4d2285f 100644
--- 
a/apisix/plugins/ai-protocols/converters/anthropic-messages-to-openai-chat.lua
+++ 
b/apisix/plugins/ai-protocols/converters/anthropic-messages-to-openai-chat.lua
@@ -579,6 +579,15 @@ function _M.convert_request(request_table, ctx)
         end
     end
 
+    -- tool_choice and parallel_tool_calls are only valid alongside a non-empty
+    -- tools array. If no tools are forwarded to the upstream -- either none 
were
+    -- provided or all were dropped (Anthropic built-ins / invalid) -- drop 
them
+    -- to avoid the OpenAI-compatible upstream rejecting the request.
+    if openai_body.tools == nil then
+        openai_body.tool_choice = nil
+        openai_body.parallel_tool_calls = nil
+    end
+
     return openai_body
 end
 
@@ -939,22 +948,24 @@ function _M.convert_sse_events(parsed, ctx, state)
             return openai_to_anthropic_sse({ choices = {} }, state,
                                            ctx and ctx.anthropic_tool_name_map)
         end
-        -- If no pending_stop but stream never finished properly, emit minimal 
stop
+        -- If no pending_stop but stream never finished properly, emit minimal 
stop.
+        -- message_start may have been sent without ever opening a content 
block,
+        -- so emit message_stop regardless to avoid leaving the client hanging.
         if not state.is_done and state.is_first == false then
+            local events = {}
             if state.current_open_block ~= nil then
-                local events = {}
                 push_content_block_stop(events, state.current_open_block)
                 state.current_open_block = nil
-                local message_delta = {
-                    type = "message_delta",
-                    delta = { stop_reason = "end_turn" },
-                    usage = { input_tokens = 0, output_tokens = 0 },
-                }
-                table.insert(events, make_sse_event("message_delta", 
message_delta))
-                table.insert(events, make_sse_event("message_stop", { type = 
"message_stop" }))
-                state.is_done = true
-                return events
             end
+            local message_delta = {
+                type = "message_delta",
+                delta = { stop_reason = "end_turn" },
+                usage = { input_tokens = 0, output_tokens = 0 },
+            }
+            table.insert(events, make_sse_event("message_delta", 
message_delta))
+            table.insert(events, make_sse_event("message_stop", { type = 
"message_stop" }))
+            state.is_done = true
+            return events
         end
         return nil
     end
diff --git a/t/plugin/ai-proxy-anthropic.t b/t/plugin/ai-proxy-anthropic.t
index 67e435bbb..6e3fc2b5c 100644
--- a/t/plugin/ai-proxy-anthropic.t
+++ b/t/plugin/ai-proxy-anthropic.t
@@ -1755,3 +1755,89 @@ X-AI-Fixture: anthropic/messages-streaming-with-cache.sse
 --- error_code: 200
 --- access_log eval
 qr/127\.0\.0\.1:1980 200 [\d.]+ \"\S+\" claude-3-5-sonnet-20241022 
claude-3-5-sonnet-20241022 [\d.]+ 50 30 80 true false 0 \S* 200 100 0/
+
+
+
+=== TEST 52: tool_choice is dropped when no tools are forwarded to upstream
+--- config
+    location /t {
+        content_by_lua_block {
+            local converter = 
require("apisix.plugins.ai-protocols.converters.anthropic-messages-to-openai-chat")
+            local ctx = { var = {} }
+
+            -- tool_choice set but no tools field at all
+            local r = converter.convert_request({
+                model = "m", max_tokens = 100,
+                messages = {{ role = "user", content = "hi" }},
+                tool_choice = { type = "auto" },
+            }, ctx)
+            assert(r.tools == nil, "tools should be nil")
+            assert(r.tool_choice == nil, "tool_choice must be dropped without 
tools")
+
+            -- tools present but all are Anthropic built-ins (dropped)
+            r = converter.convert_request({
+                model = "m", max_tokens = 100,
+                messages = {{ role = "user", content = "hi" }},
+                tools = {{ type = "web_search", name = "web_search" }},
+                tool_choice = { type = "any", disable_parallel_tool_use = true 
},
+            }, ctx)
+            assert(r.tools == nil, "all built-in tools dropped, tools nil")
+            assert(r.tool_choice == nil, "tool_choice must be dropped when 
tools empty")
+            assert(r.parallel_tool_calls == nil, "parallel_tool_calls must be 
dropped too")
+
+            -- sanity: tool_choice preserved when a real tool remains
+            r = converter.convert_request({
+                model = "m", max_tokens = 100,
+                messages = {{ role = "user", content = "hi" }},
+                tools = {{ name = "f", input_schema = {} }},
+                tool_choice = { type = "auto" },
+            }, ctx)
+            assert(r.tool_choice == "auto", "tool_choice kept with a real 
tool")
+
+            ngx.say("OK")
+        }
+    }
+--- response_body
+OK
+--- no_error_log
+[error]
+
+
+
+=== TEST 53: streaming - done after message_start without content block emits 
message_stop
+--- config
+    location /t {
+        content_by_lua_block {
+            local core = require("apisix.core")
+            local converter = 
require("apisix.plugins.ai-protocols.converters.anthropic-messages-to-openai-chat")
+
+            local state = { is_first = true }
+
+            -- First chunk opens the message (message_start) but no content 
block
+            local events = converter.convert_sse_events({
+                type = "data",
+                data = { id = "x", model = "m", choices = {{ delta = { role = 
"assistant" } }} },
+            }, {}, state)
+            assert(#events >= 1, "expected message_start")
+            assert(core.json.decode(events[1].data).type == "message_start", 
"first is message_start")
+            assert(state.current_open_block == nil, "no content block opened")
+
+            -- Upstream ends the stream with [DONE] and no finish_reason chunk
+            events = converter.convert_sse_events({ type = "done" }, {}, state)
+            assert(events ~= nil, "done must not return nil after 
message_start")
+            local saw_stop = false
+            for _, e in ipairs(events) do
+                if core.json.decode(e.data).type == "message_stop" then
+                    saw_stop = true
+                end
+            end
+            assert(saw_stop, "message_stop must be emitted to avoid hanging 
the client")
+            assert(state.is_done, "stream marked done")
+
+            ngx.say("OK")
+        }
+    }
+--- response_body
+OK
+--- no_error_log
+[error]

Reply via email to