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 7d0b1b40625b..aa4d2285f843 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 67e435bbb883..6e3fc2b5cf4f 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]