Skip to content

Commit 841dbea

Browse files
authored
fix(ai-proxy): don't abort Anthropic response on bad tool_call arguments (apache#13599)
1 parent bb23888 commit 841dbea

2 files changed

Lines changed: 114 additions & 2 deletions

File tree

apisix/plugins/ai-protocols/converters/anthropic-messages-to-openai-chat.lua

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -659,8 +659,15 @@ function _M.convert_response(res_body, ctx)
659659
local input = {}
660660
if tc["function"] and type(tc["function"].arguments) == "string" then
661661
local decoded, err = core.json.decode(tc["function"].arguments)
662-
if decoded == nil then
663-
return nil, "invalid tool_call arguments: " .. (err or "decode error")
662+
if type(decoded) ~= "table" then
663+
-- Upstream returned malformed or non-object tool_call
664+
-- arguments. Don't abort the whole response conversion --
665+
-- that would also drop already-collected text/thinking
666+
-- content; fall back to an empty object and log instead.
667+
core.log.warn("anthropic converter: failed to decode ",
668+
"tool_call arguments, using empty input: ",
669+
err or "not a JSON object")
670+
decoded = {}
664671
end
665672
input = decoded
666673
end

t/plugin/ai-proxy-anthropic.t

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1841,3 +1841,108 @@ OK
18411841
OK
18421842
--- no_error_log
18431843
[error]
1844+
1845+
1846+
1847+
=== TEST 54: malformed tool_call arguments fall back to empty input instead of aborting
1848+
An OpenAI-compatible upstream may emit tool_call arguments that are not valid
1849+
JSON (or not a JSON object). The converter must not abort the whole response --
1850+
which would also drop already-collected text/thinking content -- but fall back
1851+
to an empty input object and log a warning.
1852+
--- config
1853+
location /t {
1854+
content_by_lua_block {
1855+
local converter = require("apisix.plugins.ai-protocols.converters.anthropic-messages-to-openai-chat")
1856+
local ctx = { var = { llm_model = "gpt-4o" } }
1857+
1858+
local res, err = converter.convert_response({
1859+
id = "msg_1",
1860+
choices = {{
1861+
message = {
1862+
content = "partial answer",
1863+
tool_calls = {{
1864+
id = "call_1",
1865+
type = "function",
1866+
["function"] = { name = "do_it", arguments = "{not valid json" },
1867+
}},
1868+
},
1869+
finish_reason = "tool_calls",
1870+
}},
1871+
usage = { prompt_tokens = 10, completion_tokens = 5 },
1872+
}, ctx)
1873+
1874+
assert(res ~= nil, "conversion must not abort: " .. tostring(err))
1875+
1876+
local has_text, has_tool = false, false
1877+
for _, c in ipairs(res.content) do
1878+
if c.type == "text" and c.text == "partial answer" then
1879+
has_text = true
1880+
end
1881+
if c.type == "tool_use" then
1882+
has_tool = true
1883+
assert(type(c.input) == "table", "input is an object")
1884+
assert(next(c.input) == nil, "input is an empty object")
1885+
assert(c.name == "do_it", "tool name preserved")
1886+
end
1887+
end
1888+
assert(has_text, "already-collected text content preserved")
1889+
assert(has_tool, "tool_use block still emitted")
1890+
1891+
ngx.say("OK")
1892+
}
1893+
}
1894+
--- response_body
1895+
OK
1896+
--- error_log
1897+
failed to decode tool_call arguments
1898+
1899+
1900+
1901+
=== TEST 55: tool_call arguments that decode to non-object fall back to empty input
1902+
Valid JSON that is not an object (number, string, boolean) should also trigger
1903+
the empty-object fallback and log "not a JSON object".
1904+
--- config
1905+
location /t {
1906+
content_by_lua_block {
1907+
local converter = require("apisix.plugins.ai-protocols.converters.anthropic-messages-to-openai-chat")
1908+
local ctx = { var = { llm_model = "gpt-4o" } }
1909+
1910+
local res, err = converter.convert_response({
1911+
id = "msg_1",
1912+
choices = {{
1913+
message = {
1914+
content = "answer",
1915+
tool_calls = {{
1916+
id = "call_1",
1917+
type = "function",
1918+
["function"] = { name = "do_it", arguments = "123" },
1919+
}},
1920+
},
1921+
finish_reason = "tool_calls",
1922+
}},
1923+
usage = { prompt_tokens = 10, completion_tokens = 5 },
1924+
}, ctx)
1925+
1926+
assert(res ~= nil, "conversion must not abort: " .. tostring(err))
1927+
1928+
local has_text, has_tool = false, false
1929+
for _, c in ipairs(res.content) do
1930+
if c.type == "text" and c.text == "answer" then
1931+
has_text = true
1932+
end
1933+
if c.type == "tool_use" then
1934+
has_tool = true
1935+
assert(type(c.input) == "table", "input is an object")
1936+
assert(next(c.input) == nil, "input is an empty object")
1937+
end
1938+
end
1939+
assert(has_text, "already-collected text content preserved")
1940+
assert(has_tool, "tool_use block emitted")
1941+
1942+
ngx.say("OK")
1943+
}
1944+
}
1945+
--- response_body
1946+
OK
1947+
--- error_log
1948+
not a JSON object

0 commit comments

Comments
 (0)