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]