mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-15 04:19:08 +00:00
* fix(anthropic): keep tool_search_tool_result so the search call replays with its result Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(anthropic): drop a client tool_result for a search call replayed with its server result * fix(anthropic): replay a server tool call the client answered as the client sent it * fix(anthropic): keep a server tool result under modify_params, count client-answered marks, and replay it from run_tool_loop modify_params no longer adds a placeholder answer for a server tool call whose result is kept, unless the call ends the request. The cache control hook counts a mark on a server tool call the client answered, since that call goes out as a plain tool_use. run_tool_loop sends provider_specific_fields back and runs only the client's tool calls. * fix(anthropic): keep a server tool result when another tool answer follows, scope run_tool_loop's replay, and keep code_interpreter_results off tool search turns * test(anthropic): audit cells for tool search result round trips, replay, cache marks, pass-through logging and chaos --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
203 lines
8 KiB
Python
203 lines
8 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.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 _validate_tool_loop_args(max_rounds: int, completion_kwargs: _ToolLoopCompletionKwargs) -> None:
|
|
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")
|
|
|
|
|
|
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:
|
|
return ChatCompletionAssistantToolCall(
|
|
id=tool_call.id,
|
|
type="function",
|
|
function=ChatCompletionToolCallFunctionChunk(
|
|
name=tool_call.function.name, arguments=tool_call.function.arguments
|
|
),
|
|
)
|
|
|
|
|
|
def _assistant_message(
|
|
message: Message, tool_calls: tuple[ChatCompletionMessageToolCall, ...], replays_server_tool_results: bool
|
|
) -> ChatCompletionAssistantMessage:
|
|
return cast( # cast-ok: dict literal with thinking_blocks, reasoning_items and provider_specific_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_server_tool_results else 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 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
|
|
|
|
_validate_tool_loop_args(max_rounds, 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
|
|
tool_results = tuple(execute_tool(tool_call) for tool_call in client_tool_calls)
|
|
history = (
|
|
*history,
|
|
_assistant_message(message, tool_calls, client_tool_calls != 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
|
|
|
|
_validate_tool_loop_args(max_rounds, 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
|
|
tool_results = tuple([await execute_tool(tool_call) for tool_call in client_tool_calls])
|
|
history = (
|
|
*history,
|
|
_assistant_message(message, tool_calls, client_tool_calls != tool_calls),
|
|
*tool_results,
|
|
)
|
|
raise ToolLoopMaxRoundsExceeded(max_rounds)
|