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:
mateo-berri 2026-10-14 01:33:20 -07:00
parent 8b60b5c8ef
commit 62e4b272b1
3 changed files with 68 additions and 43 deletions

View file

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

View file

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

View file

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