From bbba450301344ee8b4f4981a32dfe7fc63d10f9b Mon Sep 17 00:00:00 2001 From: Tin Chi Lo Date: Tue, 21 Jul 2026 20:13:02 -0700 Subject: [PATCH] fix(litellm): honor dict-form reasoning_effort in the bridge escape hatch and serialize custom tool calls in helicone and lunary logs The bridge gate compared reasoning_effort against the string "none", so litellm's dict form ({"effort": "none"}) wrongly bridged; the gate now reads the effort value from either form and treats a summary inside the dict as Responses-only regardless of effort. Helicone and lunary previously skipped custom tool calls entirely; both now serialize them (helicone as a tool_use block from the custom payload, lunary with the custom name and input in its function fields, keeping type custom), with new mapped tests for both integrations --- litellm/integrations/helicone.py | 29 +++++++---- litellm/integrations/lunary.py | 16 +++++- litellm/main.py | 8 +-- .../integrations/test_helicone.py | 51 +++++++++++++++++++ .../test_litellm/integrations/test_lunary.py | 40 +++++++++++++++ tests/test_litellm/test_main.py | 50 ++++++++++++++++++ 6 files changed, 180 insertions(+), 14 deletions(-) create mode 100644 tests/test_litellm/integrations/test_helicone.py create mode 100644 tests/test_litellm/integrations/test_lunary.py diff --git a/litellm/integrations/helicone.py b/litellm/integrations/helicone.py index 4c7a606c16f..c9346f7e6cf 100644 --- a/litellm/integrations/helicone.py +++ b/litellm/integrations/helicone.py @@ -60,16 +60,25 @@ class HeliconeLogger: if "tool_calls" in message and message["tool_calls"]: for tool_call in message["tool_calls"]: function = tool_call.get("function") - if not function: - continue - content.append( - { - "type": "tool_use", - "id": tool_call["id"], - "name": function["name"], - "input": function["arguments"], - } - ) + custom = tool_call.get("custom") + if function: + content.append( + { + "type": "tool_use", + "id": tool_call["id"], + "name": function["name"], + "input": function["arguments"], + } + ) + elif custom: + content.append( + { + "type": "tool_use", + "id": tool_call["id"], + "name": custom["name"], + "input": custom["input"], + } + ) elif "content" in message and message["content"]: content = [{"type": "text", "text": message["content"]}] diff --git a/litellm/integrations/lunary.py b/litellm/integrations/lunary.py index 448580f0b2d..94cb5bab8fe 100644 --- a/litellm/integrations/lunary.py +++ b/litellm/integrations/lunary.py @@ -20,6 +20,16 @@ def parse_tool_calls(tool_calls): return None def clean_tool_call(tool_call): + custom = getattr(tool_call, "custom", None) + if custom is not None: + return { + "type": tool_call.type, + "id": tool_call.id, + "function": { + "name": custom.name, + "arguments": custom.input, + }, + } serialized = { "type": tool_call.type, "id": tool_call.id, @@ -31,7 +41,11 @@ def parse_tool_calls(tool_calls): return serialized - return [clean_tool_call(tool_call) for tool_call in tool_calls if getattr(tool_call, "function", None) is not None] + return [ + clean_tool_call(tool_call) + for tool_call in tool_calls + if getattr(tool_call, "function", None) is not None or getattr(tool_call, "custom", None) is not None + ] def parse_messages(input): diff --git a/litellm/main.py b/litellm/main.py index 8a9e0e37ace..b6c6b44a6f7 100644 --- a/litellm/main.py +++ b/litellm/main.py @@ -1036,6 +1036,10 @@ def responses_api_bridge_check( (tool.get("type") == "function" if isinstance(tool, dict) else getattr(tool, "type", None) == "function") for tool in (tools or []) ) + if isinstance(reasoning_effort, dict): + reasoning_active = reasoning_effort.get("effort") != "none" or reasoning_effort.get("summary") is not None + else: + reasoning_active = reasoning_effort != "none" if ( custom_llm_provider in ("openai", "azure") and model_info.get("mode") != "responses" @@ -1043,9 +1047,7 @@ def responses_api_bridge_check( and not OpenAIGPT5Config.is_model_gpt_5_search_model(model) and ( (reasoning_effort is not None and reasoning_summary is not None) - or ( - OpenAIGPT5Config.is_model_gpt_5_4_plus_model(model) and has_function_tool and reasoning_effort != "none" - ) + or (OpenAIGPT5Config.is_model_gpt_5_4_plus_model(model) and has_function_tool and reasoning_active) ) ): model_info["mode"] = "responses" diff --git a/tests/test_litellm/integrations/test_helicone.py b/tests/test_litellm/integrations/test_helicone.py new file mode 100644 index 00000000000..eeef6fadbe5 --- /dev/null +++ b/tests/test_litellm/integrations/test_helicone.py @@ -0,0 +1,51 @@ +import os +import sys +import types + +sys.path.insert(0, os.path.abspath("../../..")) + +from litellm.integrations.helicone import HeliconeLogger + + +def _claude_mapping(messages, response_obj): + logger = HeliconeLogger.__new__(HeliconeLogger) + return logger.claude_mapping(model="gpt-5.6", messages=messages, response_obj=response_obj) + + +def test_claude_mapping_serializes_custom_tool_calls(monkeypatch): + try: + import anthropic # noqa: F401 + except ImportError: + stub = types.ModuleType("anthropic") + stub.HUMAN_PROMPT = "\n\nHuman:" + stub.AI_PROMPT = "\n\nAssistant:" + monkeypatch.setitem(sys.modules, "anthropic", stub) + response_obj = { + "id": "chatcmpl-1", + "choices": [ + { + "message": { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "id": "call_c", + "type": "custom", + "custom": {"name": "ApplyPatch", "input": "*** Begin Patch"}, + }, + { + "id": "call_f", + "type": "function", + "function": {"name": "read_file", "arguments": '{"path": "a.py"}'}, + }, + ], + }, + "finish_reason": "tool_calls", + } + ], + "usage": {"prompt_tokens": 1, "completion_tokens": 2}, + } + mapped = _claude_mapping([{"role": "user", "content": "hi"}], response_obj) + tool_use_blocks = [b for b in mapped["content"] if b["type"] == "tool_use"] + assert {"type": "tool_use", "id": "call_c", "name": "ApplyPatch", "input": "*** Begin Patch"} in tool_use_blocks + assert {"type": "tool_use", "id": "call_f", "name": "read_file", "input": '{"path": "a.py"}'} in tool_use_blocks diff --git a/tests/test_litellm/integrations/test_lunary.py b/tests/test_litellm/integrations/test_lunary.py new file mode 100644 index 00000000000..0a1ec100594 --- /dev/null +++ b/tests/test_litellm/integrations/test_lunary.py @@ -0,0 +1,40 @@ +import os +import sys + +sys.path.insert(0, os.path.abspath("../../..")) + +from litellm.integrations.lunary import parse_tool_calls +from litellm.types.utils import ( + ChatCompletionMessageCustomToolCall, + ChatCompletionMessageToolCall, + Function, +) + + +def test_parse_tool_calls_serializes_custom_tool_calls(): + custom_call = ChatCompletionMessageCustomToolCall( + id="call_c", + custom={"name": "ApplyPatch", "input": "*** Begin Patch"}, + ) + function_call = ChatCompletionMessageToolCall( + id="call_f", + type="function", + function=Function(name="read_file", arguments='{"path": "a.py"}'), + ) + parsed = parse_tool_calls([custom_call, function_call]) + assert parsed == [ + { + "type": "custom", + "id": "call_c", + "function": {"name": "ApplyPatch", "arguments": "*** Begin Patch"}, + }, + { + "type": "function", + "id": "call_f", + "function": {"name": "read_file", "arguments": '{"path": "a.py"}'}, + }, + ] + + +def test_parse_tool_calls_none_passthrough(): + assert parse_tool_calls(None) is None diff --git a/tests/test_litellm/test_main.py b/tests/test_litellm/test_main.py index 4a0ec04bed1..60558760f8e 100644 --- a/tests/test_litellm/test_main.py +++ b/tests/test_litellm/test_main.py @@ -948,6 +948,56 @@ def test_responses_api_bridge_check_gpt_5_4_flat_function_tool_routes_to_respons assert model_info.get("mode") == "responses" +def test_responses_api_bridge_check_dict_effort_none_stays_chat(): + """The escape hatch must honor litellm's dict form: {"effort": "none"} means reasoning off.""" + from litellm.main import responses_api_bridge_check + + with patch("litellm.main._get_model_info_helper") as mock_get_model_info: + mock_get_model_info.return_value = {"max_tokens": 128000} + model_info, model = responses_api_bridge_check( + model="gpt-5.6", + custom_llm_provider="openai", + tools=[{"type": "function", "function": {"name": "get_capital"}}], + reasoning_effort={"effort": "none"}, + ) + + assert model == "gpt-5.6" + assert model_info.get("mode") != "responses" + + +def test_responses_api_bridge_check_dict_effort_active_routes_to_responses(): + from litellm.main import responses_api_bridge_check + + with patch("litellm.main._get_model_info_helper") as mock_get_model_info: + mock_get_model_info.return_value = {"max_tokens": 128000} + model_info, model = responses_api_bridge_check( + model="gpt-5.6", + custom_llm_provider="openai", + tools=[{"type": "function", "function": {"name": "get_capital"}}], + reasoning_effort={"effort": "low"}, + ) + + assert model == "gpt-5.6" + assert model_info.get("mode") == "responses" + + +def test_responses_api_bridge_check_dict_effort_none_with_summary_routes_to_responses(): + """A summary inside the dict form is Responses-only even when effort is none.""" + from litellm.main import responses_api_bridge_check + + with patch("litellm.main._get_model_info_helper") as mock_get_model_info: + mock_get_model_info.return_value = {"max_tokens": 128000} + model_info, model = responses_api_bridge_check( + model="gpt-5.6", + custom_llm_provider="openai", + tools=[{"type": "function", "function": {"name": "get_capital"}}], + reasoning_effort={"effort": "none", "summary": "concise"}, + ) + + assert model == "gpt-5.6" + assert model_info.get("mode") == "responses" + + def test_responses_api_bridge_check_older_gpt_5_tools_without_reasoning_stays_chat(): """Pre-5.4 GPT-5 names keep the old boundary: tools alone never bridge.""" from litellm.main import responses_api_bridge_check