From 923b5f9b41abcb5ada67007c0f6c6ac2fceae1e8 Mon Sep 17 00:00:00 2001 From: Aftab Date: Thu, 24 Sep 2026 06:21:40 +0530 Subject: [PATCH 1/4] fix(anthropic): unwrap double-encoded tool_call arguments for Anthropic providers When agent frameworks store/load tool call history they sometimes apply json.dumps twice to function.arguments, producing a JSON string whose first decode still returns a str. Anthropic tool_use.input must be an object; the extra string layer causes Azure AI Anthropic to return: messages.x.content.y.tool_use.input: Input should be an object parse_tool_call_arguments now detects this case: when json.loads() returns a str it attempts one additional decode and logs a warning naming the tool so callers can trace the source of the double-encoding. Fixes #42739 Signed-off-by: Aftabbs --- .../prompt_templates/common_utils.py | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/litellm/litellm_core_utils/prompt_templates/common_utils.py b/litellm/litellm_core_utils/prompt_templates/common_utils.py index 6c45622649f..cdc8da07321 100644 --- a/litellm/litellm_core_utils/prompt_templates/common_utils.py +++ b/litellm/litellm_core_utils/prompt_templates/common_utils.py @@ -1,4 +1,4 @@ -""" +""" Common utility functions used for translating messages across providers """ @@ -2382,7 +2382,24 @@ def parse_tool_call_arguments( return {} try: - return json.loads(arguments) + parsed = json.loads(arguments) + if isinstance(parsed, str): + # Double-encoded JSON: json.dumps applied twice by the caller (common + # in agent frameworks that serialise tool arguments through a store). + # Anthropic's tool_use.input must be an object, not a string, so + # unwrap one extra layer and warn so callers can fix the root cause. + verbose_logger.warning( + "Tool call arguments for tool '%s' (%s) decoded to a string " + "instead of an object — double-encoded JSON detected. " + "Attempting second decode.", + tool_name or "", + context or "unknown context", + ) + try: + parsed = json.loads(parsed) + except json.JSONDecodeError: + pass + return parsed except json.JSONDecodeError as original_error: repaired: Final = _attempt_json_repair(arguments) if repaired is not None: From b6934dc9c13db8c024be2efcb2859a5d0968bf07 Mon Sep 17 00:00:00 2001 From: Aftab Date: Thu, 24 Sep 2026 06:22:18 +0530 Subject: [PATCH 2/4] test(anthropic): add tests for double-encoded tool_call arguments Covers parse_tool_call_arguments unwrapping double-encoded JSON, the warning emission, and the full convert_to_anthropic_tool_invoke path for Azure AI Anthropic / issue #42739. Signed-off-by: Aftabbs --- tests/llm_translation/test_prompt_factory.py | 81 +++++++++++++++++++- 1 file changed, 80 insertions(+), 1 deletion(-) diff --git a/tests/llm_translation/test_prompt_factory.py b/tests/llm_translation/test_prompt_factory.py index 7b03736920b..f41ee745fd2 100644 --- a/tests/llm_translation/test_prompt_factory.py +++ b/tests/llm_translation/test_prompt_factory.py @@ -1,4 +1,4 @@ -#### What this tests #### +#### What this tests #### # This tests if prompts are being correctly formatted import pytest @@ -2040,6 +2040,85 @@ def test_parse_tool_call_arguments_still_raises_for_unrepairable(): assert "test context" in error_msg +def test_parse_tool_call_arguments_double_encoded_returns_dict(): + """parse_tool_call_arguments should unwrap double-encoded JSON to a dict. + + Agent frameworks sometimes store/load tool arguments as json.dumps applied + twice. Anthropic's tool_use.input must be an object; passing through a + string triggers: messages.x.content.y.tool_use.input: Input should be an + object. Fixes: https://github.com/BerriAI/litellm/issues/42739 + """ + import json + + from litellm.litellm_core_utils.prompt_templates.common_utils import ( + parse_tool_call_arguments, + ) + + inner = {"method": "POST", "path": "/studies/test/scenarios", "body": {"title": "SC1"}} + # Simulate double-encoding: json.dumps applied twice + double_encoded = json.dumps(json.dumps(inner)) + + result = parse_tool_call_arguments(double_encoded, tool_name="execute", context="Anthropic tool invoke") + + assert isinstance(result, dict), f"Expected dict, got {type(result)}: {result!r}" + assert result == inner + + +def test_parse_tool_call_arguments_double_encoded_warns(caplog): + """parse_tool_call_arguments logs a warning on double-encoded JSON.""" + import json + import logging + + from litellm.litellm_core_utils.prompt_templates.common_utils import ( + parse_tool_call_arguments, + ) + + inner = {"key": "value"} + double_encoded = json.dumps(json.dumps(inner)) + + with caplog.at_level(logging.WARNING): + result = parse_tool_call_arguments(double_encoded, tool_name="my_tool", context="test") + + assert isinstance(result, dict) + assert result == inner + assert any("double-encoded" in record.message.lower() for record in caplog.records) + + +def test_anthropic_tool_invoke_with_double_encoded_arguments(): + """convert_to_anthropic_tool_invoke unwraps double-encoded tool arguments. + + Validates the full path from OpenAI tool_calls (with double-encoded + function.arguments) through to Anthropic tool_use.input being an object. + """ + import json + + from litellm.litellm_core_utils.prompt_templates.factory import ( + convert_to_anthropic_tool_invoke, + ) + + inner_args = {"method": "POST", "path": "/studies/test/scenarios", "body": {"title": "SC1"}} + double_encoded = json.dumps(json.dumps(inner_args)) + + tool_calls = [ + { + "id": "toolu_test", + "type": "function", + "function": {"name": "execute", "arguments": double_encoded}, + } + ] + + result = convert_to_anthropic_tool_invoke(tool_calls) + + assert len(result) == 1 + tool_use = result[0] + assert tool_use["type"] == "tool_use" + assert tool_use["name"] == "execute" + assert isinstance(tool_use["input"], dict), ( + f"tool_use.input should be a dict, got {type(tool_use['input'])}: {tool_use['input']!r}" + ) + assert tool_use["input"] == inner_args + + def test_anthropic_messages_pt_interleave_thinking_with_server_tool_calls(): """ Test that thinking blocks are interleaved with server tool calls (web search) From 154a0a114efd5f4d84c34d7678c037f399dd0d4c Mon Sep 17 00:00:00 2001 From: Aftabbs Date: Mon, 28 Sep 2026 07:08:40 +0530 Subject: [PATCH 3/4] fix: guard double-decode to only unwrap when result is a dict Addresses Greptile P1: valid JSON string arguments like "123" were being coerced to integers because json.loads() was applied on the string result unconditionally. Only unwrap when the second decode produces a dict, which is the only valid shape for tool call arguments. Also removes disallowed explanatory comments per litellm AGENTS.md. --- .../prompt_templates/common_utils.py | 20 ++++++++----------- 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/litellm/litellm_core_utils/prompt_templates/common_utils.py b/litellm/litellm_core_utils/prompt_templates/common_utils.py index cdc8da07321..9838efa5db8 100644 --- a/litellm/litellm_core_utils/prompt_templates/common_utils.py +++ b/litellm/litellm_core_utils/prompt_templates/common_utils.py @@ -2384,19 +2384,15 @@ def parse_tool_call_arguments( try: parsed = json.loads(arguments) if isinstance(parsed, str): - # Double-encoded JSON: json.dumps applied twice by the caller (common - # in agent frameworks that serialise tool arguments through a store). - # Anthropic's tool_use.input must be an object, not a string, so - # unwrap one extra layer and warn so callers can fix the root cause. - verbose_logger.warning( - "Tool call arguments for tool '%s' (%s) decoded to a string " - "instead of an object — double-encoded JSON detected. " - "Attempting second decode.", - tool_name or "", - context or "unknown context", - ) try: - parsed = json.loads(parsed) + _second_decode = json.loads(parsed) + if isinstance(_second_decode, dict): + verbose_logger.warning( + "Tool call arguments for tool '%s' (%s) were double-encoded", + tool_name or "", + context or "unknown context", + ) + parsed = _second_decode except json.JSONDecodeError: pass return parsed From 79a71747e2899002cca41daf4927b3177c6bde3f Mon Sep 17 00:00:00 2001 From: Aftabbs Date: Mon, 28 Sep 2026 13:19:20 +0530 Subject: [PATCH 4/4] style: remove UTF-8 BOM from common_utils.py to pass ruff format check --- litellm/litellm_core_utils/prompt_templates/common_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/litellm/litellm_core_utils/prompt_templates/common_utils.py b/litellm/litellm_core_utils/prompt_templates/common_utils.py index 9838efa5db8..6949e500eb7 100644 --- a/litellm/litellm_core_utils/prompt_templates/common_utils.py +++ b/litellm/litellm_core_utils/prompt_templates/common_utils.py @@ -1,4 +1,4 @@ -""" +""" Common utility functions used for translating messages across providers """