mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-07 02:59:05 +00:00
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:
parent
7276f44b1d
commit
bbba450301
6 changed files with 180 additions and 14 deletions
|
|
@ -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"]}]
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
51
tests/test_litellm/integrations/test_helicone.py
Normal file
51
tests/test_litellm/integrations/test_helicone.py
Normal 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
|
||||
40
tests/test_litellm/integrations/test_lunary.py
Normal file
40
tests/test_litellm/integrations/test_lunary.py
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue