mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-17 04:34:25 +00:00
fix(chat): drop unanswered Claude tool search calls only on routes that reject them
OpenAI, Azure OpenAI and Bedrock Converse answer 400 for a srvtoolu_ call with no tool result, while Gemini answers 200. Keep the history as sent everywhere else so routes that already work see what the caller sent.
This commit is contained in:
parent
62e4b272b1
commit
dedb0d7f69
3 changed files with 104 additions and 4 deletions
|
|
@ -1090,15 +1090,15 @@ def _is_claude_tool_target(custom_llm_provider: str | None, model: str) -> bool:
|
|||
|
||||
|
||||
def _history_without_unanswered_tool_search_calls(
|
||||
messages: Sequence[object], provider_config: BaseConfig
|
||||
messages: Sequence[object], custom_llm_provider: str | None, provider_config: BaseConfig
|
||||
) -> tuple[object, ...] | None:
|
||||
from litellm.responses.litellm_completion_transformation.tool_search import (
|
||||
chat_config_builds_anthropic_request,
|
||||
chat_messages_without_tool_search_calls,
|
||||
chat_route_rejects_unanswered_tool_calls,
|
||||
unanswered_tool_search_call_ids,
|
||||
)
|
||||
|
||||
if chat_config_builds_anthropic_request(provider_config):
|
||||
if not chat_route_rejects_unanswered_tool_calls(custom_llm_provider, provider_config):
|
||||
return None
|
||||
unanswered_search_call_ids: Final = unanswered_tool_search_call_ids(messages)
|
||||
if not unanswered_search_call_ids:
|
||||
|
|
@ -5575,7 +5575,7 @@ def completion(
|
|||
if provider_config is not None:
|
||||
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
|
||||
translated_messages, custom_llm_provider, 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)
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ __all__ = [
|
|||
"build_tool_search_call",
|
||||
"chat_config_builds_anthropic_request",
|
||||
"chat_messages_without_tool_search_calls",
|
||||
"chat_route_rejects_unanswered_tool_calls",
|
||||
"has_server_tool_search",
|
||||
"holds_tool_search_use",
|
||||
"is_anthropic_tool_search_chat_tool",
|
||||
|
|
@ -89,6 +90,17 @@ def chat_config_builds_anthropic_request(chat_config: object) -> bool:
|
|||
return isinstance(chat_config, AnthropicConfig) and not isinstance(chat_config, OpenAIGPTConfig)
|
||||
|
||||
|
||||
_PROVIDERS_REJECTING_UNANSWERED_TOOL_CALLS: Final = frozenset({LlmProviders.OPENAI.value, LlmProviders.AZURE.value})
|
||||
|
||||
|
||||
def chat_route_rejects_unanswered_tool_calls(custom_llm_provider: str | None, chat_config: object) -> bool:
|
||||
from litellm.llms.bedrock.chat.converse_transformation import AmazonConverseConfig
|
||||
|
||||
return custom_llm_provider in _PROVIDERS_REJECTING_UNANSWERED_TOOL_CALLS or isinstance(
|
||||
chat_config, AmazonConverseConfig
|
||||
)
|
||||
|
||||
|
||||
class CompletionRoute(LiteLLMBaseModel):
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
|
|
|
|||
|
|
@ -533,6 +533,94 @@ def test_completion_drops_unanswered_claude_tool_search_calls_before_the_respons
|
|||
]
|
||||
|
||||
|
||||
def _openai_called_names(body: Mapping[str, object]) -> tuple[str, ...]:
|
||||
messages: Final = body["messages"]
|
||||
assert isinstance(messages, list)
|
||||
return tuple(call["function"]["name"] for message in messages for call in message.get("tool_calls") or ())
|
||||
|
||||
|
||||
def _converse_called_names(body: Mapping[str, object]) -> tuple[str, ...]:
|
||||
messages: Final = body["messages"]
|
||||
assert isinstance(messages, list)
|
||||
blocks: Final = tuple(block for message in messages for block in message["content"])
|
||||
return tuple(block["toolUse"]["name"] for block in blocks if "toolUse" in block)
|
||||
|
||||
|
||||
def _gemini_called_names(body: Mapping[str, object]) -> tuple[str, ...]:
|
||||
contents: Final = body["contents"]
|
||||
assert isinstance(contents, list)
|
||||
parts: Final = tuple(part for content in contents for part in content["parts"])
|
||||
return tuple(part["function_call"]["name"] for part in parts if "function_call" in part)
|
||||
|
||||
|
||||
_OPENAI_CHAT_REPLY: Final = {
|
||||
"id": "chatcmpl-turn2",
|
||||
"object": "chat.completion",
|
||||
"created": 1,
|
||||
"model": "gpt-4o",
|
||||
"choices": [{"index": 0, "finish_reason": "stop", "message": {"role": "assistant", "content": "18C"}}],
|
||||
"usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("model", "route_kwargs", "url_pattern", "reply", "called_names", "sent_names"),
|
||||
[
|
||||
pytest.param(
|
||||
"azure/gpt-4o",
|
||||
{"api_base": "https://turn2.openai.azure.com", "api_key": "fake", "api_version": "2024-10-21"},
|
||||
r"https://turn2\.openai\.azure\.com/openai/deployments/gpt-4o/chat/completions.*",
|
||||
_OPENAI_CHAT_REPLY,
|
||||
_openai_called_names,
|
||||
("get_weather",),
|
||||
id="azure-openai-strips",
|
||||
),
|
||||
pytest.param(
|
||||
"bedrock/us.anthropic.claude-opus-5-5",
|
||||
{"aws_access_key_id": "fake", "aws_secret_access_key": "fake", "aws_region_name": "us-east-1"},
|
||||
r"https://bedrock-runtime\.us-east-1\.amazonaws\.com/model/.*/converse",
|
||||
{
|
||||
"output": {"message": {"role": "assistant", "content": [{"text": "18C"}]}},
|
||||
"stopReason": "end_turn",
|
||||
"usage": {"inputTokens": 1, "outputTokens": 1, "totalTokens": 2},
|
||||
},
|
||||
_converse_called_names,
|
||||
("get_weather",),
|
||||
id="bedrock-converse-strips",
|
||||
),
|
||||
pytest.param(
|
||||
"gemini/gemini-3.8-flash",
|
||||
{"api_key": "fake"},
|
||||
r"https://generativelanguage\.googleapis\.com/.*:generateContent.*",
|
||||
{
|
||||
"candidates": [{"content": {"role": "model", "parts": [{"text": "18C"}]}, "finishReason": "STOP"}],
|
||||
"usageMetadata": {"promptTokenCount": 1, "candidatesTokenCount": 1, "totalTokenCount": 2},
|
||||
},
|
||||
_gemini_called_names,
|
||||
("tool_search_tool_regex", "get_weather"),
|
||||
id="gemini-keeps",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_completion_strips_an_unanswered_claude_tool_search_call_only_for_routes_that_reject_it(
|
||||
respx_mock: respx.MockRouter, model, route_kwargs, url_pattern, reply, called_names, sent_names
|
||||
):
|
||||
route: Final = respx_mock.post(url__regex=url_pattern).respond(json=reply)
|
||||
|
||||
litellm.completion(
|
||||
model=model,
|
||||
messages=[
|
||||
_USER_TURN,
|
||||
{"role": "assistant", "content": None, "tool_calls": [_CLAUDE_TOOL_SEARCH_CALL, _LOADED_FUNCTION_CALL]},
|
||||
_WEATHER_RESULT,
|
||||
],
|
||||
tools=[{"type": "function", "function": {"name": "get_weather", "parameters": {"type": "object"}}}],
|
||||
**route_kwargs,
|
||||
)
|
||||
|
||||
assert called_names(json.loads(route.calls.last.request.content)) == sent_names
|
||||
|
||||
|
||||
def test_completion_keeps_a_function_call_whose_id_only_looks_like_a_claude_server_tool(
|
||||
respx_mock: respx.MockRouter, openai_api_response
|
||||
):
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue