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 841dbea7a fix(ai-proxy): don't abort Anthropic response on bad
tool_call arguments (#13599)
841dbea7a is described below
commit 841dbea7a167684e6b9834c33e841ea61693a100
Author: AlinsRan <[email protected]>
AuthorDate: Thu Jun 25 07:26:03 2026 +0800
fix(ai-proxy): don't abort Anthropic response on bad tool_call arguments
(#13599)
---
.../anthropic-messages-to-openai-chat.lua | 11 ++-
t/plugin/ai-proxy-anthropic.t | 105 +++++++++++++++++++++
2 files changed, 114 insertions(+), 2 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 aa4d2285f..2eaf44459 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
@@ -659,8 +659,15 @@ function _M.convert_response(res_body, ctx)
local input = {}
if tc["function"] and type(tc["function"].arguments) == "string"
then
local decoded, err = core.json.decode(tc["function"].arguments)
- if decoded == nil then
- return nil, "invalid tool_call arguments: " .. (err or
"decode error")
+ if type(decoded) ~= "table" then
+ -- Upstream returned malformed or non-object tool_call
+ -- arguments. Don't abort the whole response conversion --
+ -- that would also drop already-collected text/thinking
+ -- content; fall back to an empty object and log instead.
+ core.log.warn("anthropic converter: failed to decode ",
+ "tool_call arguments, using empty input: ",
+ err or "not a JSON object")
+ decoded = {}
end
input = decoded
end
diff --git a/t/plugin/ai-proxy-anthropic.t b/t/plugin/ai-proxy-anthropic.t
index 6e3fc2b5c..4ed54a426 100644
--- a/t/plugin/ai-proxy-anthropic.t
+++ b/t/plugin/ai-proxy-anthropic.t
@@ -1841,3 +1841,108 @@ OK
OK
--- no_error_log
[error]
+
+
+
+=== TEST 54: malformed tool_call arguments fall back to empty input instead of
aborting
+An OpenAI-compatible upstream may emit tool_call arguments that are not valid
+JSON (or not a JSON object). The converter must not abort the whole response --
+which would also drop already-collected text/thinking content -- but fall back
+to an empty input object and log a warning.
+--- config
+ location /t {
+ content_by_lua_block {
+ local converter =
require("apisix.plugins.ai-protocols.converters.anthropic-messages-to-openai-chat")
+ local ctx = { var = { llm_model = "gpt-4o" } }
+
+ local res, err = converter.convert_response({
+ id = "msg_1",
+ choices = {{
+ message = {
+ content = "partial answer",
+ tool_calls = {{
+ id = "call_1",
+ type = "function",
+ ["function"] = { name = "do_it", arguments = "{not
valid json" },
+ }},
+ },
+ finish_reason = "tool_calls",
+ }},
+ usage = { prompt_tokens = 10, completion_tokens = 5 },
+ }, ctx)
+
+ assert(res ~= nil, "conversion must not abort: " .. tostring(err))
+
+ local has_text, has_tool = false, false
+ for _, c in ipairs(res.content) do
+ if c.type == "text" and c.text == "partial answer" then
+ has_text = true
+ end
+ if c.type == "tool_use" then
+ has_tool = true
+ assert(type(c.input) == "table", "input is an object")
+ assert(next(c.input) == nil, "input is an empty object")
+ assert(c.name == "do_it", "tool name preserved")
+ end
+ end
+ assert(has_text, "already-collected text content preserved")
+ assert(has_tool, "tool_use block still emitted")
+
+ ngx.say("OK")
+ }
+ }
+--- response_body
+OK
+--- error_log
+failed to decode tool_call arguments
+
+
+
+=== TEST 55: tool_call arguments that decode to non-object fall back to empty
input
+Valid JSON that is not an object (number, string, boolean) should also trigger
+the empty-object fallback and log "not a JSON object".
+--- config
+ location /t {
+ content_by_lua_block {
+ local converter =
require("apisix.plugins.ai-protocols.converters.anthropic-messages-to-openai-chat")
+ local ctx = { var = { llm_model = "gpt-4o" } }
+
+ local res, err = converter.convert_response({
+ id = "msg_1",
+ choices = {{
+ message = {
+ content = "answer",
+ tool_calls = {{
+ id = "call_1",
+ type = "function",
+ ["function"] = { name = "do_it", arguments = "123"
},
+ }},
+ },
+ finish_reason = "tool_calls",
+ }},
+ usage = { prompt_tokens = 10, completion_tokens = 5 },
+ }, ctx)
+
+ assert(res ~= nil, "conversion must not abort: " .. tostring(err))
+
+ local has_text, has_tool = false, false
+ for _, c in ipairs(res.content) do
+ if c.type == "text" and c.text == "answer" then
+ has_text = true
+ end
+ if c.type == "tool_use" then
+ has_tool = true
+ assert(type(c.input) == "table", "input is an object")
+ assert(next(c.input) == nil, "input is an empty object")
+ end
+ end
+ assert(has_text, "already-collected text content preserved")
+ assert(has_tool, "tool_use block emitted")
+
+ ngx.say("OK")
+ }
+ }
+--- response_body
+OK
+--- error_log
+not a JSON object