mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-12 08:42:18 +00:00
* fix(tool_choice): accept the Responses API's flat allowed_tools layout on chat completions
An allowed_tools tool_choice in the flat Responses layout ({"type": "allowed_tools", "mode": ..., "tools": [...]})
answered 400 on litellm.completion, acompletion, Router and the proxy's chat and /v1/responses-to-chat routes. One
parser now reads both layouts into the nested Chat layout, and the existing Responses bridge flattens it back for
/v1/responses, so either layout reaches OpenAI in the shape each endpoint expects
* fix(tool_choice): drop an allowed_tools choice under drop_params and refuse conflicting flat entries
An allowed_tools tool_choice a provider can't enforce, or a malformed one, now drops under drop_params and answers 400 without it, the same rule LiteLLM uses for other unsupported values. That covers providers outside OpenAI and Azure, malformed choices on every provider, and Azure api versions older than 2023-12-01
A flat allowed_tools entry that carries both name and a nested function or custom key answers 400, since the two names can disagree, matching what flattening already refuses
* fix(tool-choice): keep the 400 when LiteLLM runs the tools, report auto after a drop
Under drop_params an allowed_tools choice a provider can't enforce was dropped, so on MCP gateway tools and in
run_tool_loop and arun_tool_loop LiteLLM could run a tool the allow list excluded. Those paths now keep the 400 they
answered before the flat layout was accepted, and a malformed choice stays a 400 on MCP gateway tools.
/v1/responses served through chat echoed a choice it had dropped, on sync, async and streamed responses. It now
reports auto, and a choice the provider received is still echoed as sent.
* fix(tool-choice): refuse an excluded tool right before LiteLLM runs it
Checking the provider up front missed a model alias, an old Azure api_version,
a litellm_proxy hop, and SDK fallbacks. The tool loop and the MCP gateway now
check the allowed_tools list right before they run a tool call, so an excluded
call answers 400 and never runs.
Under drop_params the choice now drops only when the caller runs every tool,
so a provider-run MCP server or built-in keeps the 400. The Azure checks read
the request's api_version, which also fixes the /v1/responses echo there.
* fix(tool-choice): drop the choice under drop_params and hand an excluded MCP call back unrun
The drop now applies to every provider that cannot take an allowed_tools
tool_choice, whatever the request's tools hold. LiteLLM never runs a call the
choice excludes: on MCP paths the whole round goes back to the caller in a 200,
the same shape as require_approval, and the tool loop keeps its 400 because it
only returns final content. A malformed choice stays a 400 before any request
on the tool loop and on chat with MCP gateway tools.
* fix(tool-choice): refuse an allowed_tools entry whose name is not a string
An mcp or other non-function entry with a list or dict name passed the
parser and crashed when the allow list was hashed. The parser now treats
it as malformed on both layouts, so it answers the same 400 as an empty
allow list, or drops under drop_params.
* fix(tool-choice): honor server-level and null-name mcp entries and stop hooks running excluded calls
* fix(tool-choice): match named mcp entries by server label and let built-in entries allow their interception call
* fix(tool-choice): leave a malformed allowed_tools choice unrewritten in the interception hooks
* fix(tool-choice): resolve mcp entries like the gateway and keep the choice on every hook path
Under drop_params a malformed allowed_tools choice now acts as if it was never sent, and only a choice that parses is stored and enforced. Without drop_params it answers 400 before any provider request, on /v1/responses with gateway tools before MCP listing
An mcp entry resolves its label the way the gateway picks servers to list (server name in any case, alias, access group, toolset), and a named entry matches the tool's own name or the gateway's prefixed name. The gateway rewrites mcp entries into function entries in the caller's layout and sends "none" when nothing is left
The web search follow-up keeps an allowed_tools choice, compression's litellm_content_retrieve is never excluded and joins the provider-facing choice, and the native /v1/responses hook loop applies the exclusion check and stores the choice
* fix(allowed_tools): stop web search and code interpreter follow-ups forcing another call
A kept allowed_tools choice now goes to the follow-up in mode auto, so a
required choice no longer loops to the agentic loop limit. The MCP label
scope dataclass uses a default_factory so the module imports on Python 3.11
* fix(allowed_tools): stop the Responses web search follow-up forcing another call
The Responses-shaped follow-up after a web search left tool_choice out of its patch, so the caller's required choice went out again through the chat to Responses bridge and looped to the loop limit. It now carries a kept allowed_tools choice in mode auto, like the chat follow-up
* fix(mcp): scope each MCP entry to its own allowed_tools and keep the allowlist on follow-ups
A label now covers only the tools its own allowed_tools lists, so a second label on the same server no longer widens it. Responses MCP follow-ups, streamed or not, keep an allowed_tools choice in auto mode instead of dropping it. The new Responses tests fake only the upstream MCP listing, so LiteLLM's own discovery runs
* fix(mcp): match an entry's allowed_tools against the tool name the gateway lists
A server with a display name override lists the display name, and the listing
filter matches allowed_tools against it, but the label scope only checked the
original name, so no spelling worked. The scope now also accepts the listed
name and its unprefixed part, like the listing filter. Tests now cover the
whole gateway and toolset entries and a display-named tool.
* fix(mcp): match an entry's allowed_tools only against a server's own prefix, not the first hyphen
* fix(mcp): match a tool no server resolves only by its exact listed name in an entry's allowed_tools
* fix(mcp): refuse a tool whose display name only looks like an allowed name, and let the chat follow-up answer
An MCP entry's allowed_tools now matches a tool only by its exact listed name or a spelling routing
accepts for its real name, so a display name that carries the server's own prefix no longer passes for
another tool. The chat MCP follow-up keeps an allowed_tools choice in auto mode, so the model can answer
with text after the tool ran.
* fix(mcp): match a toolset label's tools by exact native name
A toolset label served any tool whose name a routing spelling of a granted name matched, so a toolset granting the literal name "server-tool" also served the real tool "tool" that only another toolset grants. It now serves a tool only when the toolset grants its exact native name, the same check tool_is_granted uses when the gateway lists tools
249 lines
10 KiB
Python
249 lines
10 KiB
Python
"""Client-side tool-calling loop helpers for litellm.completion."""
|
|
|
|
from collections.abc import Awaitable, Callable, Sequence
|
|
from typing import Final, TypeAlias, cast
|
|
|
|
from typing_extensions import TypedDict, Unpack
|
|
|
|
from litellm.constants import DEFAULT_TOOL_LOOP_MAX_ROUNDS
|
|
from litellm.litellm_core_utils.allowed_tools_tool_choice import (
|
|
allowed_tools_tool_choice_in_effect,
|
|
refuse_tool_calls_the_choice_excludes,
|
|
)
|
|
from litellm.litellm_core_utils.core_helpers import drops_unsupported_params
|
|
from litellm.types.llms.openai import (
|
|
AllMessageValues,
|
|
ChatCompletionAssistantMessage,
|
|
ChatCompletionAssistantToolCall,
|
|
ChatCompletionToolCallFunctionChunk,
|
|
ChatCompletionToolMessage,
|
|
ChatCompletionToolParam,
|
|
)
|
|
from litellm.types.utils import ChatCompletionMessageToolCall, Message, ModelResponse
|
|
|
|
ToolExecutor: TypeAlias = Callable[[ChatCompletionMessageToolCall], ChatCompletionToolMessage]
|
|
AsyncToolExecutor: TypeAlias = Callable[[ChatCompletionMessageToolCall], Awaitable[ChatCompletionToolMessage]]
|
|
|
|
|
|
class _ToolLoopCompletionKwargs(TypedDict, total=False, extra_items=object):
|
|
"""Extra keywords forwarded verbatim to ``litellm.completion``, which owns their contract."""
|
|
|
|
|
|
class ToolLoopMaxRoundsExceeded(RuntimeError):
|
|
max_rounds: int
|
|
|
|
def __init__(self, max_rounds: int) -> None:
|
|
self.max_rounds = max_rounds
|
|
super().__init__(f"model still requested tool calls on round {max_rounds} of {max_rounds}")
|
|
|
|
|
|
def _tool_choice_in_effect(max_rounds: int, model: str, completion_kwargs: _ToolLoopCompletionKwargs) -> object:
|
|
if max_rounds < 1:
|
|
raise ValueError(f"max_rounds must be >= 1, got {max_rounds}")
|
|
if completion_kwargs.get("stream"):
|
|
raise ValueError("run_tool_loop requires whole responses; stream=True is not supported")
|
|
return allowed_tools_tool_choice_in_effect(
|
|
completion_kwargs.get("tool_choice"), model, drops_unsupported_params(completion_kwargs.get("drop_params"))
|
|
)
|
|
|
|
|
|
def _expect_model_response(response: object) -> ModelResponse:
|
|
if not isinstance(response, ModelResponse):
|
|
raise TypeError(f"run_tool_loop requires completion to return a ModelResponse, got {type(response).__name__}")
|
|
return response
|
|
|
|
|
|
def _assistant_tool_call(tool_call: ChatCompletionMessageToolCall) -> ChatCompletionAssistantToolCall:
|
|
provider_fields: Final[object] = getattr(tool_call, "provider_specific_fields", None)
|
|
return cast( # cast-ok: dict literal with provider_specific_fields spread in only when set
|
|
"ChatCompletionAssistantToolCall",
|
|
{
|
|
"id": tool_call.id,
|
|
"type": "function",
|
|
"function": ChatCompletionToolCallFunctionChunk(
|
|
name=tool_call.function.name, arguments=tool_call.function.arguments
|
|
),
|
|
**({"provider_specific_fields": provider_fields} if provider_fields else {}),
|
|
},
|
|
)
|
|
|
|
|
|
def _assistant_message(
|
|
message: Message, tool_calls: tuple[ChatCompletionMessageToolCall, ...], replays_provider_specific_fields: bool
|
|
) -> ChatCompletionAssistantMessage:
|
|
from litellm.completion_extras.litellm_responses_transformation.history import retained_replay_fields
|
|
|
|
return cast( # cast-ok: dict literal with the optional message fields spread in only when set
|
|
"ChatCompletionAssistantMessage",
|
|
{
|
|
"role": "assistant",
|
|
"content": message.content,
|
|
"tool_calls": [_assistant_tool_call(tc) for tc in tool_calls],
|
|
**{
|
|
key: value
|
|
for key, value in (
|
|
("thinking_blocks", getattr(message, "thinking_blocks", None)),
|
|
("reasoning_items", getattr(message, "reasoning_items", None)),
|
|
(
|
|
"provider_specific_fields",
|
|
getattr(message, "provider_specific_fields", None)
|
|
if replays_provider_specific_fields
|
|
else retained_replay_fields(getattr(message, "provider_specific_fields", None)),
|
|
),
|
|
)
|
|
if value is not None
|
|
},
|
|
},
|
|
)
|
|
|
|
|
|
def _function_tool_call(tool_call: object) -> ChatCompletionMessageToolCall:
|
|
if not isinstance(tool_call, ChatCompletionMessageToolCall):
|
|
raise TypeError(
|
|
f"run_tool_loop only executes function tool calls, got custom tool call {getattr(tool_call, 'id', None)}"
|
|
)
|
|
return tool_call
|
|
|
|
|
|
def _function_tool_calls(message: Message) -> tuple[ChatCompletionMessageToolCall, ...]:
|
|
return tuple(_function_tool_call(tool_call) for tool_call in message.tool_calls or ())
|
|
|
|
|
|
def _client_tool_calls(
|
|
message: Message, tool_calls: tuple[ChatCompletionMessageToolCall, ...]
|
|
) -> tuple[ChatCompletionMessageToolCall, ...]:
|
|
from litellm.llms.anthropic.common_utils import tool_call_is_rebuilt_as_server_tool_use
|
|
|
|
provider_specific_fields: Final = getattr(message, "provider_specific_fields", None)
|
|
return tuple(
|
|
tool_call
|
|
for tool_call in tool_calls
|
|
if not tool_call_is_rebuilt_as_server_tool_use(tool_call.id, provider_specific_fields)
|
|
)
|
|
|
|
|
|
def _refuse_tool_calls_the_choice_excludes(
|
|
tool_calls: tuple[ChatCompletionMessageToolCall, ...], model: str, tool_choice: object
|
|
) -> None:
|
|
refuse_tool_calls_the_choice_excludes(
|
|
tool_choice, tuple(tool_call.function.name or "" for tool_call in tool_calls), model
|
|
)
|
|
|
|
|
|
def _replays_provider_specific_fields(
|
|
message: Message,
|
|
tool_calls: tuple[ChatCompletionMessageToolCall, ...],
|
|
client_tool_calls: tuple[ChatCompletionMessageToolCall, ...],
|
|
) -> bool:
|
|
from litellm.completion_extras.litellm_responses_transformation.transformation import carries_tool_search_items
|
|
from litellm.llms.anthropic.common_utils import anthropic_server_tool_call_params
|
|
|
|
return (
|
|
client_tool_calls != tool_calls
|
|
or bool(anthropic_server_tool_call_params(message))
|
|
or carries_tool_search_items(getattr(message, "provider_specific_fields", None))
|
|
)
|
|
|
|
|
|
def run_tool_loop(
|
|
*,
|
|
model: str,
|
|
messages: Sequence[AllMessageValues],
|
|
tools: Sequence[ChatCompletionToolParam],
|
|
execute_tool: ToolExecutor,
|
|
max_rounds: int = DEFAULT_TOOL_LOOP_MAX_ROUNDS,
|
|
**completion_kwargs: Unpack[_ToolLoopCompletionKwargs], # kwargs-ok: forwarded verbatim to litellm.completion
|
|
) -> str | None:
|
|
"""Call completion, execute each requested tool, and repeat until the model answers.
|
|
|
|
Returns the final assistant message content. Raises ToolLoopMaxRoundsExceeded when the
|
|
model is still requesting tools after max_rounds completions.
|
|
|
|
Example:
|
|
execute = functools.partial(run_repo_tool, repository="litellm", revision="main")
|
|
answer = litellm.run_tool_loop(
|
|
model="anthropic/claude-sonnet-5-5", messages=messages, tools=tools, execute_tool=execute
|
|
)
|
|
"""
|
|
import litellm
|
|
|
|
tool_choice: Final = _tool_choice_in_effect(max_rounds, model, completion_kwargs)
|
|
history: tuple[AllMessageValues, ...] = tuple(messages) # rebind-ok: rounds append new turns
|
|
for round_number in range(1, max_rounds + 1):
|
|
message = (
|
|
_expect_model_response(
|
|
litellm.completion(model=model, messages=list(history), tools=list(tools), **completion_kwargs)
|
|
)
|
|
.choices[0]
|
|
.message
|
|
)
|
|
if not message.tool_calls:
|
|
return message.content
|
|
tool_calls = _function_tool_calls(message)
|
|
client_tool_calls = _client_tool_calls(message, tool_calls)
|
|
if not client_tool_calls:
|
|
return message.content
|
|
if round_number == max_rounds:
|
|
break
|
|
_refuse_tool_calls_the_choice_excludes(client_tool_calls, model, tool_choice)
|
|
tool_results = tuple(execute_tool(tool_call) for tool_call in client_tool_calls)
|
|
history = (
|
|
*history,
|
|
_assistant_message(
|
|
message, tool_calls, _replays_provider_specific_fields(message, tool_calls, client_tool_calls)
|
|
),
|
|
*tool_results,
|
|
)
|
|
raise ToolLoopMaxRoundsExceeded(max_rounds)
|
|
|
|
|
|
async def arun_tool_loop(
|
|
*,
|
|
model: str,
|
|
messages: Sequence[AllMessageValues],
|
|
tools: Sequence[ChatCompletionToolParam],
|
|
execute_tool: AsyncToolExecutor,
|
|
max_rounds: int = DEFAULT_TOOL_LOOP_MAX_ROUNDS,
|
|
**completion_kwargs: Unpack[_ToolLoopCompletionKwargs], # kwargs-ok: forwarded verbatim to litellm.acompletion
|
|
) -> str | None:
|
|
"""Async version of run_tool_loop, awaiting each execute_tool call in order.
|
|
|
|
Returns the final assistant message content. Raises ToolLoopMaxRoundsExceeded when the
|
|
model is still requesting tools after max_rounds completions.
|
|
|
|
Example:
|
|
execute = functools.partial(arun_repo_tool, repository="litellm", revision="main")
|
|
answer = await litellm.arun_tool_loop(
|
|
model="anthropic/claude-sonnet-5-5", messages=messages, tools=tools, execute_tool=execute
|
|
)
|
|
"""
|
|
import litellm
|
|
|
|
tool_choice: Final = _tool_choice_in_effect(max_rounds, model, completion_kwargs)
|
|
history: tuple[AllMessageValues, ...] = tuple(messages) # rebind-ok: rounds append new turns
|
|
for round_number in range(1, max_rounds + 1):
|
|
message = (
|
|
_expect_model_response(
|
|
await litellm.acompletion(model=model, messages=list(history), tools=list(tools), **completion_kwargs)
|
|
)
|
|
.choices[0]
|
|
.message
|
|
)
|
|
if not message.tool_calls:
|
|
return message.content
|
|
tool_calls = _function_tool_calls(message)
|
|
client_tool_calls = _client_tool_calls(message, tool_calls)
|
|
if not client_tool_calls:
|
|
return message.content
|
|
if round_number == max_rounds:
|
|
break
|
|
_refuse_tool_calls_the_choice_excludes(client_tool_calls, model, tool_choice)
|
|
tool_results = tuple([await execute_tool(tool_call) for tool_call in client_tool_calls])
|
|
history = (
|
|
*history,
|
|
_assistant_message(
|
|
message, tool_calls, _replays_provider_specific_fields(message, tool_calls, client_tool_calls)
|
|
),
|
|
*tool_results,
|
|
)
|
|
raise ToolLoopMaxRoundsExceeded(max_rounds)
|