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

Reply via email to