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
This commit is contained in:
Tin Chi Lo 2026-07-21 20:13:02 -07:00
parent 7276f44b1d
commit bbba450301
6 changed files with 180 additions and 14 deletions

View file

@ -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"]}]

View file

@ -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):

View file

@ -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"

View file

@ -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

View file

@ -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

View file

@ -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