mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-17 04:34:25 +00:00
fix(chat): keep answered Claude tool search calls in history sent to other providers
Only a srvtoolu_ search call with no tool result is dropped before a non-Anthropic route, so an answered pair keeps the caller's shape where OpenAI and Gemini take it. The history check no longer copies the list through a TypeAdapter.
This commit is contained in:
parent
8b60b5c8ef
commit
62e4b272b1
3 changed files with 68 additions and 43 deletions
|
|
@ -1076,7 +1076,6 @@ from litellm.responses.bridge_check import ( # noqa: E402 # re-export at the o
|
|||
)
|
||||
|
||||
_ANTHROPIC_ONLY_TOOL_KEYS: Final = frozenset({"input_examples", "eager_input_streaming"})
|
||||
_CHAT_HISTORY_ADAPTER: Final = TypeAdapter(Sequence[object])
|
||||
|
||||
|
||||
def _is_claude_tool_target(custom_llm_provider: str | None, model: str) -> bool:
|
||||
|
|
@ -1090,13 +1089,21 @@ def _is_claude_tool_target(custom_llm_provider: str | None, model: str) -> bool:
|
|||
return False
|
||||
|
||||
|
||||
def _route_drops_tool_search_uses(messages: Sequence[object], provider_config: BaseConfig) -> bool:
|
||||
def _history_without_unanswered_tool_search_calls(
|
||||
messages: Sequence[object], provider_config: BaseConfig
|
||||
) -> tuple[object, ...] | None:
|
||||
from litellm.responses.litellm_completion_transformation.tool_search import (
|
||||
chat_config_builds_anthropic_request,
|
||||
history_holds_tool_search_use,
|
||||
chat_messages_without_tool_search_calls,
|
||||
unanswered_tool_search_call_ids,
|
||||
)
|
||||
|
||||
return not chat_config_builds_anthropic_request(provider_config) and history_holds_tool_search_use(messages)
|
||||
if chat_config_builds_anthropic_request(provider_config):
|
||||
return None
|
||||
unanswered_search_call_ids: Final = unanswered_tool_search_call_ids(messages)
|
||||
if not unanswered_search_call_ids:
|
||||
return None
|
||||
return chat_messages_without_tool_search_calls(messages, unanswered_search_call_ids)
|
||||
|
||||
|
||||
def _without_anthropic_only_tool_keys(tool: dict[str, object]) -> dict[str, object]:
|
||||
|
|
@ -5566,16 +5573,13 @@ def completion(
|
|||
)
|
||||
|
||||
if provider_config is not None:
|
||||
messages = provider_config.translate_developer_role_to_system_role(messages=messages)
|
||||
chat_history: Final = _CHAT_HISTORY_ADAPTER.validate_python(messages)
|
||||
if _route_drops_tool_search_uses(chat_history, provider_config):
|
||||
from litellm.responses.litellm_completion_transformation.tool_search import (
|
||||
chat_messages_without_tool_search_uses,
|
||||
)
|
||||
|
||||
messages = list( # rebind-ok: completion() rewrites messages step by step for the provider
|
||||
chat_messages_without_tool_search_uses(chat_history)
|
||||
)
|
||||
translated_messages: Final = provider_config.translate_developer_role_to_system_role(messages=messages)
|
||||
history_for_route: Final = _history_without_unanswered_tool_search_calls(
|
||||
translated_messages, provider_config
|
||||
)
|
||||
messages = ( # rebind-ok: completion() rewrites messages step by step for the provider
|
||||
translated_messages if history_for_route is None else list(history_for_route)
|
||||
)
|
||||
|
||||
if (
|
||||
supports_system_message is not None
|
||||
|
|
|
|||
|
|
@ -20,15 +20,15 @@ __all__ = [
|
|||
"anthropic_tool_search_chat_tool",
|
||||
"build_tool_search_call",
|
||||
"chat_config_builds_anthropic_request",
|
||||
"chat_messages_without_tool_search_uses",
|
||||
"chat_messages_without_tool_search_calls",
|
||||
"has_server_tool_search",
|
||||
"history_holds_tool_search_use",
|
||||
"holds_tool_search_use",
|
||||
"is_anthropic_tool_search_chat_tool",
|
||||
"is_anthropic_tool_search_use",
|
||||
"is_server_tool_search_tool",
|
||||
"provider_builds_anthropic_request",
|
||||
"tool_search_call_ids",
|
||||
"unanswered_tool_search_call_ids",
|
||||
"without_tool_search_calls",
|
||||
"without_tool_search_uses",
|
||||
]
|
||||
|
|
@ -177,10 +177,6 @@ def without_tool_search_uses(message: Message) -> Message | None:
|
|||
return message.model_copy(update={"tool_calls": list(kept) or None})
|
||||
|
||||
|
||||
def history_holds_tool_search_use(messages: Iterable[object]) -> bool:
|
||||
return any(_is_assistant_message(message) and holds_tool_search_use(message) for message in messages)
|
||||
|
||||
|
||||
def _is_assistant_message(message: object) -> bool:
|
||||
match message:
|
||||
case {"role": "assistant"}:
|
||||
|
|
@ -191,33 +187,48 @@ def _is_assistant_message(message: object) -> bool:
|
|||
return _session_message_fields(message).role == "assistant"
|
||||
|
||||
|
||||
def _assistant_mapping_without_tool_search_uses(message: Mapping[str, object]) -> Mapping[str, object] | None:
|
||||
kept_calls: Final = without_tool_search_calls(message)
|
||||
if not kept_calls and not message.get("content"):
|
||||
def _tool_result_ids(messages: Iterable[object]) -> frozenset[str]:
|
||||
return frozenset(
|
||||
tool_call_id
|
||||
for message in messages
|
||||
if (tool_call_id := _session_message_fields(message).tool_call_id) is not None
|
||||
)
|
||||
|
||||
|
||||
def unanswered_tool_search_call_ids(messages: Sequence[object]) -> frozenset[str]:
|
||||
search_call_ids: Final = tool_search_call_ids(message for message in messages if _is_assistant_message(message))
|
||||
if not search_call_ids:
|
||||
return search_call_ids
|
||||
return search_call_ids - _tool_result_ids(messages)
|
||||
|
||||
|
||||
def _assistant_mapping_with_tool_calls(
|
||||
message: Mapping[str, object], tool_calls: tuple[object, ...]
|
||||
) -> Mapping[str, object] | None:
|
||||
if not tool_calls and not message.get("content"):
|
||||
return None
|
||||
without_calls: Final = {key: value for key, value in message.items() if key != "tool_calls"}
|
||||
return {**without_calls, "tool_calls": list(kept_calls)} if kept_calls else without_calls
|
||||
return {**without_calls, "tool_calls": list(tool_calls)} if tool_calls else without_calls
|
||||
|
||||
|
||||
def _without_tool_search_use(message: object, search_call_ids: Set[str]) -> object | None:
|
||||
if answers_tool_search(message, search_call_ids):
|
||||
return None
|
||||
if not holds_tool_search_use(message):
|
||||
def _without_tool_search_calls(message: object, call_ids: Set[str]) -> object | None:
|
||||
tool_calls: Final = _assistant_tool_calls(message)
|
||||
kept_calls: Final = tuple(tool_call for tool_call in tool_calls if _tool_search_call_id(tool_call) not in call_ids)
|
||||
if len(kept_calls) == len(tool_calls):
|
||||
return message
|
||||
match message:
|
||||
case Message():
|
||||
return without_tool_search_uses(message)
|
||||
if not kept_calls and not message.content:
|
||||
return None
|
||||
return message.model_copy(update={"tool_calls": list(kept_calls) or None})
|
||||
case Mapping():
|
||||
return _assistant_mapping_without_tool_search_uses(_MESSAGE_FIELDS_ADAPTER.validate_python(message))
|
||||
return _assistant_mapping_with_tool_calls(_MESSAGE_FIELDS_ADAPTER.validate_python(message), kept_calls)
|
||||
case _:
|
||||
return message
|
||||
|
||||
|
||||
def chat_messages_without_tool_search_uses(messages: Sequence[object]) -> tuple[object, ...]:
|
||||
search_call_ids: Final = tool_search_call_ids(messages)
|
||||
return tuple(
|
||||
kept for message in messages if (kept := _without_tool_search_use(message, search_call_ids)) is not None
|
||||
)
|
||||
def chat_messages_without_tool_search_calls(messages: Sequence[object], call_ids: Set[str]) -> tuple[object, ...]:
|
||||
return tuple(kept for message in messages if (kept := _without_tool_search_calls(message, call_ids)) is not None)
|
||||
|
||||
|
||||
def _parsed_arguments(arguments_json: str) -> Mapping[str, object] | None:
|
||||
|
|
|
|||
|
|
@ -427,6 +427,7 @@ _LOADED_FUNCTION_CALL: Final = {
|
|||
}
|
||||
_USER_TURN: Final = {"role": "user", "content": "What is the weather in Paris?"}
|
||||
_WEATHER_RESULT: Final = {"role": "tool", "tool_call_id": "toolu_01weather", "content": "18C"}
|
||||
_SEARCH_RESULT: Final = {"role": "tool", "tool_call_id": "srvtoolu_01search", "content": "searched"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
|
|
@ -449,16 +450,26 @@ _WEATHER_RESULT: Final = {"role": "tool", "tool_call_id": "toolu_01weather", "co
|
|||
id="sdk-message-object",
|
||||
),
|
||||
pytest.param(
|
||||
[
|
||||
{"role": "assistant", "content": None, "tool_calls": [_CLAUDE_TOOL_SEARCH_CALL]},
|
||||
{"role": "tool", "tool_call_id": "srvtoolu_01search", "content": "searched"},
|
||||
],
|
||||
[{"role": "assistant", "content": None, "tool_calls": [_CLAUDE_TOOL_SEARCH_CALL]}],
|
||||
[],
|
||||
id="answered-search-alone",
|
||||
id="unanswered-search-alone",
|
||||
),
|
||||
pytest.param(
|
||||
[
|
||||
{"role": "assistant", "content": None, "tool_calls": [_CLAUDE_TOOL_SEARCH_CALL, _LOADED_FUNCTION_CALL]},
|
||||
_SEARCH_RESULT,
|
||||
_WEATHER_RESULT,
|
||||
],
|
||||
[
|
||||
{"role": "assistant", "tool_calls": [_CLAUDE_TOOL_SEARCH_CALL, _LOADED_FUNCTION_CALL]},
|
||||
_SEARCH_RESULT,
|
||||
_WEATHER_RESULT,
|
||||
],
|
||||
id="answered-search-kept",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_completion_drops_claude_tool_search_calls_from_history_sent_to_openai(
|
||||
def test_completion_drops_unanswered_claude_tool_search_calls_from_history_sent_to_openai(
|
||||
respx_mock: respx.MockRouter, openai_api_response, claude_turn, sent_after_user
|
||||
):
|
||||
api_base: Final = "http://localhost:12346/v1"
|
||||
|
|
@ -480,7 +491,7 @@ def test_completion_drops_claude_tool_search_calls_from_history_sent_to_openai(
|
|||
] == [_USER_TURN, *sent_after_user, {"role": "user", "content": "And tomorrow?"}]
|
||||
|
||||
|
||||
def test_completion_drops_claude_tool_search_calls_before_the_responses_bridge_sends_history_to_openai(
|
||||
def test_completion_drops_unanswered_claude_tool_search_calls_before_the_responses_bridge_sends_history_to_openai(
|
||||
respx_mock: respx.MockRouter,
|
||||
):
|
||||
route: Final = respx_mock.post("https://api.openai.com/v1/responses").respond(
|
||||
|
|
@ -510,7 +521,6 @@ def test_completion_drops_claude_tool_search_calls_before_the_responses_bridge_s
|
|||
messages=[
|
||||
_USER_TURN,
|
||||
{"role": "assistant", "content": None, "tool_calls": [_CLAUDE_TOOL_SEARCH_CALL, _LOADED_FUNCTION_CALL]},
|
||||
{"role": "tool", "tool_call_id": "srvtoolu_01search", "content": "searched"},
|
||||
_WEATHER_RESULT,
|
||||
],
|
||||
tools=[{"type": "function", "function": {"name": "get_weather", "parameters": {"type": "object"}}}],
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue