Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
86 changes: 86 additions & 0 deletions t/plugin/ai-proxy-anthropic.t
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Loading