mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
fix(anthropic): carry tool_reference tool results through the guardrail translation round trip
This commit is contained in:
parent
147fcf767e
commit
99af9ad9eb
15 changed files with 363 additions and 125 deletions
|
|
@ -6,10 +6,10 @@
|
|||
"limit": 2564
|
||||
},
|
||||
"reportAssignmentType": {
|
||||
"limit": 320
|
||||
"limit": 319
|
||||
},
|
||||
"reportAttributeAccessIssue": {
|
||||
"limit": 483
|
||||
"limit": 480
|
||||
},
|
||||
"reportCallIssue": {
|
||||
"limit": 113
|
||||
|
|
@ -30,7 +30,7 @@
|
|||
"limit": 7
|
||||
},
|
||||
"reportGeneralTypeIssues": {
|
||||
"limit": 154
|
||||
"limit": 105
|
||||
},
|
||||
"reportIncompatibleMethodOverride": {
|
||||
"limit": 56
|
||||
|
|
@ -99,19 +99,19 @@
|
|||
"limit": 0
|
||||
},
|
||||
"reportUnknownArgumentType": {
|
||||
"limit": 44528
|
||||
"limit": 44526
|
||||
},
|
||||
"reportUnknownLambdaType": {
|
||||
"limit": 109
|
||||
},
|
||||
"reportUnknownMemberType": {
|
||||
"limit": 38804
|
||||
"limit": 38782
|
||||
},
|
||||
"reportUnknownParameterType": {
|
||||
"limit": 19829
|
||||
},
|
||||
"reportUnknownVariableType": {
|
||||
"limit": 30355
|
||||
"limit": 30349
|
||||
},
|
||||
"reportUnnecessaryCast": {
|
||||
"limit": 117
|
||||
|
|
@ -123,7 +123,7 @@
|
|||
"limit": 5
|
||||
},
|
||||
"reportUnnecessaryIsInstance": {
|
||||
"limit": 833
|
||||
"limit": 831
|
||||
},
|
||||
"reportUntypedBaseClass": {
|
||||
"limit": 0
|
||||
|
|
|
|||
|
|
@ -59,9 +59,11 @@ if TYPE_CHECKING:
|
|||
from litellm.types.llms.openai import (
|
||||
ALL_RESPONSES_API_TOOL_PARAMS,
|
||||
AllMessageValues,
|
||||
ChatCompletionFileObject,
|
||||
ChatCompletionImageObject,
|
||||
ChatCompletionRedactedThinkingBlock,
|
||||
ChatCompletionThinkingBlock,
|
||||
ChatCompletionToolReferenceObject,
|
||||
OpenAIMessageContentListBlock,
|
||||
)
|
||||
from litellm.types.utils import Choices
|
||||
|
|
@ -175,6 +177,16 @@ def _map_incomplete_reason_to_finish_reason(incomplete_reason: str | None) -> Li
|
|||
return "length"
|
||||
|
||||
|
||||
def _input_file_from_file_value(file_value: object) -> dict[str, object]:
|
||||
if not isinstance(file_value, dict):
|
||||
return {"type": "input_file"}
|
||||
file_dict: Final = cast("dict[str, object]", file_value) # cast-ok: runtime dict checked
|
||||
return {
|
||||
"type": "input_file",
|
||||
**{key: file_dict[key] for key in ("file_id", "file_data", "filename") if key in file_dict},
|
||||
}
|
||||
|
||||
|
||||
def _incomplete_reason_from_response_payload(response_payload: object) -> str | None:
|
||||
if not isinstance(response_payload, Mapping):
|
||||
return None
|
||||
|
|
@ -957,7 +969,12 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge):
|
|||
content: str
|
||||
| list[object]
|
||||
| Iterable[
|
||||
Union["OpenAIMessageContentListBlock", "ChatCompletionThinkingBlock", "ChatCompletionRedactedThinkingBlock"]
|
||||
Union[
|
||||
"OpenAIMessageContentListBlock",
|
||||
"ChatCompletionThinkingBlock",
|
||||
"ChatCompletionRedactedThinkingBlock",
|
||||
"ChatCompletionToolReferenceObject",
|
||||
]
|
||||
]
|
||||
| None,
|
||||
role: str,
|
||||
|
|
@ -1006,17 +1023,15 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge):
|
|||
result.append(converted)
|
||||
verbose_logger.debug("Chat provider: image -> %s", converted)
|
||||
elif item_type == "file":
|
||||
# Map Chat Completion file to Responses API input_file
|
||||
# {"type": "file", "file": {"file_data": "...", "filename": "..."}}
|
||||
# -> {"type": "input_file", "file_data": "...", "filename": "..."}
|
||||
file_data = item.get("file", {})
|
||||
converted = {"type": "input_file"}
|
||||
if isinstance(file_data, dict):
|
||||
for key in ["file_id", "file_data", "filename"]:
|
||||
if key in file_data:
|
||||
converted[key] = file_data[key]
|
||||
converted = _input_file_from_file_value(
|
||||
cast("ChatCompletionFileObject", item).get("file"), # cast-ok: type tag checked
|
||||
)
|
||||
result.append(converted)
|
||||
verbose_logger.debug("Chat provider: file -> %s", converted)
|
||||
elif item_type == "tool_reference":
|
||||
verbose_logger.debug(
|
||||
"Chat provider: tool_reference has no responses API equivalent; skipped"
|
||||
)
|
||||
elif item_type in [
|
||||
"input_text",
|
||||
"input_image",
|
||||
|
|
|
|||
|
|
@ -376,7 +376,7 @@ class AnthropicCacheControlHook(CustomPromptManagement):
|
|||
# 2. list of objects - only apply to last item per Anthropic spec
|
||||
elif isinstance(message_content, list):
|
||||
if len(message_content) > 0 and isinstance(message_content[-1], dict):
|
||||
message_content[-1]["cache_control"] = control
|
||||
message_content[-1]["cache_control"] = control # pyright: ignore[reportGeneralTypeIssues] # loose runtime dict
|
||||
return message
|
||||
|
||||
@staticmethod
|
||||
|
|
|
|||
|
|
@ -1412,7 +1412,7 @@ def convert_to_gemini_tool_call_result(
|
|||
)
|
||||
except Exception as e:
|
||||
verbose_logger.warning("Failed to process image in tool response: %s", e)
|
||||
elif content_type in ("file", "input_file"):
|
||||
elif content_type in ("file", "input_file"): # pyright: ignore[reportUnnecessaryContains] # loose runtime dict
|
||||
# Extract file for inline_data (for tool results with PDF, audio, video, etc.)
|
||||
file_data = content.get("file_data", "")
|
||||
if not file_data:
|
||||
|
|
@ -1564,14 +1564,23 @@ def convert_to_anthropic_tool_result(
|
|||
}
|
||||
"""
|
||||
anthropic_content: (
|
||||
str | list[AnthropicMessagesToolResultContent | AnthropicMessagesImageParam | AnthropicMessagesDocumentParam]
|
||||
str
|
||||
| list[
|
||||
AnthropicMessagesToolResultContent
|
||||
| AnthropicMessagesImageParam
|
||||
| AnthropicMessagesDocumentParam
|
||||
| ToolReference
|
||||
]
|
||||
) = ""
|
||||
if isinstance(message["content"], str):
|
||||
anthropic_content = message["content"]
|
||||
elif isinstance(message["content"], list):
|
||||
content_list: Final = message["content"]
|
||||
anthropic_content_list: list[
|
||||
AnthropicMessagesToolResultContent | AnthropicMessagesImageParam | AnthropicMessagesDocumentParam
|
||||
AnthropicMessagesToolResultContent
|
||||
| AnthropicMessagesImageParam
|
||||
| AnthropicMessagesDocumentParam
|
||||
| ToolReference
|
||||
] = []
|
||||
for content in content_list:
|
||||
if content["type"] == "text":
|
||||
|
|
@ -1614,6 +1623,8 @@ def convert_to_anthropic_tool_result(
|
|||
original_content_element=content,
|
||||
)
|
||||
anthropic_content_list.append(cast(AnthropicMessagesImageParam, _anthropic_image_param))
|
||||
elif content["type"] == "tool_reference":
|
||||
anthropic_content_list.append(ToolReference(type="tool_reference", tool_name=content["tool_name"]))
|
||||
elif content["type"] == "file":
|
||||
file_content = cast(ChatCompletionFileObject, content)
|
||||
_file_block = anthropic_process_openai_file_message(file_content)
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import copy
|
||||
import hashlib
|
||||
import json
|
||||
from collections.abc import AsyncIterator, Iterator, Mapping
|
||||
from typing import TYPE_CHECKING, Any, Final, Literal, TypeVar, cast
|
||||
from collections.abc import AsyncIterator, Iterator, Mapping, Sequence
|
||||
from typing import TYPE_CHECKING, Any, Final, Literal, TypeAlias, TypeVar, cast
|
||||
|
||||
import litellm
|
||||
from litellm.llms.anthropic.experimental_pass_through.utils import (
|
||||
|
|
@ -125,7 +125,9 @@ from litellm.types.llms.openai import (
|
|||
ChatCompletionToolMessage,
|
||||
ChatCompletionToolParam,
|
||||
ChatCompletionToolParamFunctionChunk,
|
||||
ChatCompletionToolReferenceObject,
|
||||
ChatCompletionUserMessage,
|
||||
ToolMessageContentPart,
|
||||
)
|
||||
from litellm.types.utils import Choices, ModelResponse, StreamingChoices, Usage
|
||||
|
||||
|
|
@ -134,6 +136,8 @@ from .streaming_iterator import AnthropicStreamWrapper
|
|||
if TYPE_CHECKING:
|
||||
from litellm.types.llms.anthropic import ContentBlockContentBlockDict
|
||||
|
||||
ToolResultContent: TypeAlias = str | list[ToolMessageContentPart]
|
||||
|
||||
|
||||
class AnthropicAdapter:
|
||||
def __init__(self) -> None:
|
||||
|
|
@ -411,90 +415,13 @@ class LiteLLMAnthropicMessagesAdapter:
|
|||
self._add_cache_control_if_applicable(content, doc_obj, model)
|
||||
new_user_content_list.append(doc_obj)
|
||||
elif content.get("type") == "tool_result":
|
||||
if "content" not in content:
|
||||
tool_result = ChatCompletionToolMessage(
|
||||
role="tool",
|
||||
tool_call_id=content.get("tool_use_id", ""),
|
||||
content="",
|
||||
)
|
||||
self._add_cache_control_if_applicable(content, tool_result, model)
|
||||
tool_message_list.append(tool_result)
|
||||
elif isinstance(content.get("content"), str):
|
||||
tool_result = ChatCompletionToolMessage(
|
||||
role="tool",
|
||||
tool_call_id=content.get("tool_use_id", ""),
|
||||
content=str(content.get("content", "")),
|
||||
)
|
||||
self._add_cache_control_if_applicable(content, tool_result, model)
|
||||
tool_message_list.append(tool_result)
|
||||
elif isinstance(content.get("content"), list):
|
||||
# Combine all content items into a single tool message
|
||||
# to avoid creating multiple tool_result blocks with the same ID
|
||||
# (each tool_use must have exactly one tool_result)
|
||||
content_items = list(content.get("content", []))
|
||||
|
||||
# Single-item text keeps the backward-compatible string format; a single
|
||||
# image or document becomes a structured image_url part
|
||||
if len(content_items) == 1:
|
||||
c = content_items[0]
|
||||
if isinstance(c, str):
|
||||
tool_result = ChatCompletionToolMessage(
|
||||
role="tool",
|
||||
tool_call_id=content.get("tool_use_id", ""),
|
||||
content=c,
|
||||
)
|
||||
self._add_cache_control_if_applicable(content, tool_result, model)
|
||||
tool_message_list.append(tool_result)
|
||||
elif isinstance(c, dict):
|
||||
if c.get("type") == "text":
|
||||
tool_result = ChatCompletionToolMessage(
|
||||
role="tool",
|
||||
tool_call_id=content.get("tool_use_id", ""),
|
||||
content=c.get("text", ""),
|
||||
)
|
||||
self._add_cache_control_if_applicable(content, tool_result, model)
|
||||
tool_message_list.append(tool_result)
|
||||
elif c.get("type") in ("image", "document"):
|
||||
image_part = self._tool_result_image_part(c.get("source"))
|
||||
tool_result = ChatCompletionToolMessage(
|
||||
role="tool",
|
||||
tool_call_id=content.get("tool_use_id", ""),
|
||||
content=[image_part] # mutable-ok: content must be a json list
|
||||
if image_part
|
||||
else "",
|
||||
)
|
||||
self._add_cache_control_if_applicable(content, tool_result, model)
|
||||
tool_message_list.append(tool_result)
|
||||
else:
|
||||
# For multiple content items, combine into a single tool message
|
||||
# with list content to preserve all items while having one tool_use_id
|
||||
combined_content_parts: list[
|
||||
ChatCompletionTextObject | ChatCompletionImageObject
|
||||
] = []
|
||||
for c in content_items:
|
||||
if isinstance(c, str):
|
||||
combined_content_parts.append(ChatCompletionTextObject(type="text", text=c))
|
||||
elif isinstance(c, dict):
|
||||
if c.get("type") == "text":
|
||||
combined_content_parts.append(
|
||||
ChatCompletionTextObject(
|
||||
type="text",
|
||||
text=c.get("text", ""),
|
||||
)
|
||||
)
|
||||
elif c.get("type") in ("image", "document"):
|
||||
image_part = self._tool_result_image_part(c.get("source"))
|
||||
if image_part:
|
||||
combined_content_parts.append(image_part)
|
||||
# Create a single tool message with combined content
|
||||
if combined_content_parts:
|
||||
tool_result = ChatCompletionToolMessage(
|
||||
role="tool",
|
||||
tool_call_id=content.get("tool_use_id", ""),
|
||||
content=combined_content_parts,
|
||||
)
|
||||
self._add_cache_control_if_applicable(content, tool_result, model)
|
||||
tool_message_list.append(tool_result)
|
||||
tool_result = ChatCompletionToolMessage(
|
||||
role="tool",
|
||||
tool_call_id=content.get("tool_use_id", ""),
|
||||
content=self._tool_result_content(content.get("content")),
|
||||
)
|
||||
self._add_cache_control_if_applicable(content, tool_result, model)
|
||||
tool_message_list.append(tool_result)
|
||||
|
||||
if len(tool_message_list) > 0:
|
||||
new_messages.extend(tool_message_list)
|
||||
|
|
@ -1209,6 +1136,39 @@ class LiteLLMAnthropicMessagesAdapter:
|
|||
|
||||
return None
|
||||
|
||||
def _tool_result_content(self, raw_content: object) -> ToolResultContent:
|
||||
if isinstance(raw_content, str):
|
||||
return raw_content
|
||||
if not isinstance(raw_content, list):
|
||||
return ""
|
||||
items: Final = cast(Sequence[object], raw_content) # cast-ok: untrusted client payload
|
||||
parts: Final = tuple(part for part in (self._tool_result_part(item) for item in items) if part is not None)
|
||||
match parts:
|
||||
case ():
|
||||
return ""
|
||||
case ({"type": "text", "text": str(text)},):
|
||||
return text
|
||||
case _:
|
||||
return list(parts) # mutable-ok: content must be a json list
|
||||
|
||||
def _tool_result_part(self, item: object) -> ToolMessageContentPart | None:
|
||||
if isinstance(item, str):
|
||||
return ChatCompletionTextObject(type="text", text=item)
|
||||
if not isinstance(item, dict):
|
||||
return None
|
||||
block: Final = cast(Mapping[str, object], item) # cast-ok: untrusted client payload
|
||||
match block.get("type"):
|
||||
case "text":
|
||||
return ChatCompletionTextObject(type="text", text=str(block.get("text") or ""))
|
||||
case "image" | "document":
|
||||
return self._tool_result_image_part(block.get("source"))
|
||||
case "tool_reference":
|
||||
return ChatCompletionToolReferenceObject(
|
||||
type="tool_reference", tool_name=str(block.get("tool_name") or "")
|
||||
)
|
||||
case _:
|
||||
return None
|
||||
|
||||
def _tool_result_image_part(self, image_source: object) -> ChatCompletionImageObject | None:
|
||||
if not isinstance(image_source, dict):
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ from litellm.litellm_core_utils.prompt_templates.factory import (
|
|||
from litellm.litellm_core_utils.prompt_templates.image_handling import (
|
||||
convert_url_to_base64,
|
||||
)
|
||||
from litellm.types.llms.openai import AllMessageValues, ChatCompletionFileObject
|
||||
from litellm.types.llms.openai import AllMessageValues, ChatCompletionFileObject, ChatCompletionImageObject
|
||||
from litellm.types.llms.vertex_ai import ContentType, PartType
|
||||
from litellm.utils import supports_reasoning
|
||||
|
||||
|
|
@ -16,6 +16,13 @@ from ...vertex_ai.gemini.transformation import _gemini_convert_messages_with_his
|
|||
from ...vertex_ai.gemini.vertex_and_google_ai_studio_gemini import VertexGeminiConfig
|
||||
|
||||
|
||||
def _image_url_fields(img_element: ChatCompletionImageObject) -> tuple[str | None, str | None, str | None]:
|
||||
image_value: Final = img_element.get("image_url")
|
||||
if isinstance(image_value, dict):
|
||||
return image_value.get("url"), image_value.get("format"), image_value.get("detail")
|
||||
return image_value, None, None
|
||||
|
||||
|
||||
class GoogleAIStudioGeminiConfig(VertexGeminiConfig):
|
||||
"""
|
||||
Reference: https://ai.google.dev/api/rest/v1beta/GenerationConfig
|
||||
|
|
@ -118,16 +125,8 @@ class GoogleAIStudioGeminiConfig(VertexGeminiConfig):
|
|||
_parts: list[PartType] = []
|
||||
for element in _message_content:
|
||||
if element.get("type") == "image_url":
|
||||
img_element = element
|
||||
_image_url: str | None = None
|
||||
format: str | None = None
|
||||
detail: str | None = None
|
||||
if isinstance(img_element.get("image_url"), dict):
|
||||
_image_url = img_element["image_url"].get("url")
|
||||
format = img_element["image_url"].get("format")
|
||||
detail = img_element["image_url"].get("detail")
|
||||
else:
|
||||
_image_url = img_element.get("image_url")
|
||||
img_element = cast(ChatCompletionImageObject, element) # cast-ok: runtime type tag checked
|
||||
_image_url, format, detail = _image_url_fields(img_element)
|
||||
if _image_url and "https://" in _image_url:
|
||||
image_obj = convert_to_anthropic_image_obj(_image_url, format=format)
|
||||
converted_image_url = convert_generic_image_chunk_to_openai_image_obj(image_obj)
|
||||
|
|
|
|||
|
|
@ -292,7 +292,7 @@ class MistralConfig(OpenAIGPTConfig):
|
|||
file_id = file_content.get("file", {}).get("file_id")
|
||||
if file_id:
|
||||
# Replace 'file' with 'file_id'
|
||||
file_content["file_id"] = file_id
|
||||
file_content["file_id"] = file_id # pyright: ignore[reportGeneralTypeIssues] # legacy in-place rewrite of the block shape
|
||||
file_content.pop("file", None)
|
||||
return messages
|
||||
|
||||
|
|
|
|||
|
|
@ -324,7 +324,12 @@ class AnthropicMessagesToolResultParam(TypedDict, total=False):
|
|||
is_error: bool
|
||||
content: (
|
||||
str
|
||||
| Iterable[AnthropicMessagesToolResultContent | AnthropicMessagesImageParam | AnthropicMessagesDocumentParam]
|
||||
| Iterable[
|
||||
AnthropicMessagesToolResultContent
|
||||
| AnthropicMessagesImageParam
|
||||
| AnthropicMessagesDocumentParam
|
||||
| ToolReference
|
||||
]
|
||||
)
|
||||
cache_control: dict | ChatCompletionCachedContent | None
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
from collections.abc import Iterable, Mapping
|
||||
from enum import Enum
|
||||
from os import PathLike
|
||||
from typing import IO, Any, Final, Literal, Optional, Union
|
||||
from typing import IO, Any, Final, Literal, Optional, TypeAlias, Union
|
||||
|
||||
import httpx
|
||||
from openai import Omit
|
||||
|
|
@ -820,9 +820,21 @@ class ChatCompletionAssistantMessage(OpenAIChatCompletionAssistantMessage, total
|
|||
reasoning_items: list[ChatCompletionReasoningItem] | None
|
||||
|
||||
|
||||
class ChatCompletionToolReferenceObject(TypedDict):
|
||||
"""Anthropic tool-search result block, carried through untouched so it survives a round trip."""
|
||||
|
||||
type: Literal["tool_reference"] # writable-ok: Pydantic warns on ReadOnly TypedDict fields
|
||||
tool_name: str # writable-ok: Pydantic warns on ReadOnly TypedDict fields
|
||||
|
||||
|
||||
ToolMessageContentPart: TypeAlias = (
|
||||
ChatCompletionTextObject | ChatCompletionImageObject | ChatCompletionToolReferenceObject
|
||||
)
|
||||
|
||||
|
||||
class ChatCompletionToolMessage(TypedDict):
|
||||
role: Literal["tool"]
|
||||
content: str | Iterable[ChatCompletionTextObject | ChatCompletionImageObject]
|
||||
content: str | Iterable[ToolMessageContentPart] # writable-ok: Pydantic warns on ReadOnly TypedDict fields
|
||||
tool_call_id: str
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -3903,3 +3903,39 @@ def test_stored_reasoning_items_win_over_thinking_blocks():
|
|||
reasoning_items = [item for item in input_items if item.get("type") == "reasoning"]
|
||||
assert len(reasoning_items) == 1
|
||||
assert reasoning_items[0]["id"] == "rs_real"
|
||||
|
||||
|
||||
def test_convert_chat_completion_messages_to_responses_api_tool_result_with_tool_reference():
|
||||
"""Tool-search tool_reference blocks have no Responses API equivalent: skip them, never stringify them."""
|
||||
from litellm.completion_extras.litellm_responses_transformation.transformation import (
|
||||
LiteLLMResponsesTransformationHandler,
|
||||
)
|
||||
|
||||
handler = LiteLLMResponsesTransformationHandler()
|
||||
|
||||
messages = [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": None,
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_abc123",
|
||||
"type": "function",
|
||||
"function": {"name": "ToolSearch", "arguments": '{"query": "web"}'},
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": "call_abc123",
|
||||
"content": [
|
||||
{"type": "tool_reference", "tool_name": "WebFetch"},
|
||||
{"type": "text", "text": "1 tool found"},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
response, _ = handler.convert_chat_completion_messages_to_responses_api(messages)
|
||||
|
||||
function_call_output = next(item for item in response if item.get("type") == "function_call_output")
|
||||
assert function_call_output["output"] == [{"type": "input_text", "text": "1 tool found"}]
|
||||
|
|
|
|||
|
|
@ -3578,3 +3578,52 @@ async def test_bedrock_converse_pdf_only_user_message_gets_text_block_async():
|
|||
assert len(result) == 1
|
||||
assert any("document" in block for block in result[0]["content"])
|
||||
assert _text_blocks(result[0]) == [BEDROCK_DOCUMENT_PLACEHOLDER_TEXT]
|
||||
|
||||
|
||||
def test_convert_to_anthropic_tool_result_keeps_tool_reference_blocks():
|
||||
from litellm.litellm_core_utils.prompt_templates.factory import convert_to_anthropic_tool_result
|
||||
|
||||
result = convert_to_anthropic_tool_result(
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": "toolu_01",
|
||||
"content": [
|
||||
{"type": "text", "text": "loaded"},
|
||||
{"type": "tool_reference", "tool_name": "WebFetch"},
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
assert result == {
|
||||
"type": "tool_result",
|
||||
"tool_use_id": "toolu_01",
|
||||
"content": [
|
||||
{"type": "text", "text": "loaded"},
|
||||
{"type": "tool_reference", "tool_name": "WebFetch"},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_convert_gemini_tool_call_result_answers_tool_reference_only_result():
|
||||
"""Every Gemini function call needs a function response, even when the tool result carries no text.
|
||||
Fixes: https://github.com/BerriAI/litellm/issues/37462
|
||||
"""
|
||||
result = convert_to_gemini_tool_call_result(
|
||||
message=ChatCompletionToolMessage(
|
||||
role="tool",
|
||||
tool_call_id="toolu_01",
|
||||
content=[{"type": "tool_reference", "tool_name": "WebFetch"}],
|
||||
),
|
||||
last_message_with_tool_calls={
|
||||
"role": "assistant",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "toolu_01",
|
||||
"type": "function",
|
||||
"function": {"name": "ToolSearch", "arguments": '{"query": "select:WebFetch"}'},
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
assert result == {"function_response": {"name": "ToolSearch", "response": {"content": ""}}}
|
||||
|
|
|
|||
|
|
@ -1818,3 +1818,72 @@ class TestAnthropicMessagesScanOnlyToolResults:
|
|||
|
||||
assert guardrail.captured_inputs is not None
|
||||
assert guardrail.captured_inputs.get("images") == ["TOOL_IMG"]
|
||||
|
||||
|
||||
class TestStructuredWriteBackKeepsToolResults:
|
||||
"""A guardrail rewrite must never leave a tool_use without its tool_result (Claude Code ToolSearch, LIT-6103)."""
|
||||
|
||||
@staticmethod
|
||||
def _claude_code_tool_search_turns(tool_result_content):
|
||||
return [
|
||||
{"role": "user", "content": "load WebFetch for bob@example.com"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_use",
|
||||
"id": "toolu_01",
|
||||
"name": "ToolSearch",
|
||||
"input": {"query": "select:WebFetch"},
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "tool_result", "tool_use_id": "toolu_01", "content": tool_result_content},
|
||||
{"type": "text", "text": "Now fetch the page."},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def _blocks(message):
|
||||
return message["content"] if isinstance(message["content"], list) else []
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("tool_result_content", "expected_written_back_content"),
|
||||
[
|
||||
(
|
||||
[{"type": "tool_reference", "tool_name": "WebFetch"}],
|
||||
[{"type": "tool_reference", "tool_name": "WebFetch"}],
|
||||
),
|
||||
([], ""),
|
||||
],
|
||||
ids=["tool_reference", "empty"],
|
||||
)
|
||||
async def test_tool_result_stays_right_after_its_tool_use(
|
||||
self, tool_result_content, expected_written_back_content
|
||||
):
|
||||
handler = AnthropicMessagesHandler()
|
||||
data = {"model": "claude-fable-5", "messages": self._claude_code_tool_search_turns(tool_result_content)}
|
||||
|
||||
await handler.process_input_messages(data=data, guardrail_to_apply=MockStructuredMaskingGuardrail())
|
||||
|
||||
serialized = json.dumps(data["messages"])
|
||||
assert "bob@example.com" not in serialized
|
||||
assert "<EMAIL>" in serialized
|
||||
|
||||
messages = data["messages"]
|
||||
tool_use_index = next(
|
||||
i for i, m in enumerate(messages) if any(b.get("type") == "tool_use" for b in self._blocks(m))
|
||||
)
|
||||
answer = messages[tool_use_index + 1]
|
||||
assert answer["role"] == "user"
|
||||
assert answer["content"][0] == {
|
||||
"type": "tool_result",
|
||||
"tool_use_id": "toolu_01",
|
||||
"content": expected_written_back_content,
|
||||
}
|
||||
later_blocks = [b for m in messages[tool_use_index + 1 :] for b in self._blocks(m)]
|
||||
assert {"type": "text", "text": "Now fetch the page."} in later_blocks
|
||||
|
|
|
|||
|
|
@ -3997,3 +3997,72 @@ def test_translate_anthropic_messages_to_openai_carries_midturn_system_prompt_ca
|
|||
assert result == [
|
||||
{"role": "system", "content": [{"type": "text", "text": "fix", "prompt_cache_breakpoint": explicit}]}
|
||||
]
|
||||
|
||||
|
||||
def _tool_reference_block(tool_name="WebFetch"):
|
||||
return {"type": "tool_reference", "tool_name": tool_name}
|
||||
|
||||
|
||||
def test_tool_result_tool_reference_is_carried_through_untouched():
|
||||
adapter = LiteLLMAnthropicMessagesAdapter()
|
||||
|
||||
result = adapter.translate_anthropic_messages_to_openai(
|
||||
messages=[
|
||||
_anthropic_tool_use_turn("toolu_01"),
|
||||
_anthropic_tool_result_turn({"toolu_01": [_tool_reference_block()]}),
|
||||
]
|
||||
)
|
||||
|
||||
assert [m["role"] for m in result] == ["assistant", "tool"]
|
||||
assert result[1]["tool_call_id"] == "toolu_01"
|
||||
assert result[1]["content"] == [{"type": "tool_reference", "tool_name": "WebFetch"}]
|
||||
|
||||
|
||||
def test_tool_result_text_beside_tool_reference_keeps_both_parts_in_order():
|
||||
adapter = LiteLLMAnthropicMessagesAdapter()
|
||||
|
||||
result = adapter.translate_anthropic_messages_to_openai(
|
||||
messages=[
|
||||
_anthropic_tool_use_turn("toolu_01"),
|
||||
_anthropic_tool_result_turn(
|
||||
{"toolu_01": [{"type": "text", "text": "loaded"}, _tool_reference_block("Grep")]}
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
assert result[1]["content"] == [
|
||||
{"type": "text", "text": "loaded"},
|
||||
{"type": "tool_reference", "tool_name": "Grep"},
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"tool_result_content",
|
||||
[
|
||||
[],
|
||||
None,
|
||||
"",
|
||||
{"not": "a list"},
|
||||
[{"type": "future_block", "payload": 1}],
|
||||
[{"type": "search_result", "source": "https://example.com", "title": "t", "content": []}],
|
||||
],
|
||||
ids=["empty_list", "null", "empty_string", "non_list", "unknown_block", "search_result_only"],
|
||||
)
|
||||
def test_tool_result_without_translatable_content_still_answers_its_tool_use(tool_result_content):
|
||||
adapter = LiteLLMAnthropicMessagesAdapter()
|
||||
|
||||
result = adapter.translate_anthropic_messages_to_openai(
|
||||
messages=[
|
||||
_anthropic_tool_use_turn("toolu_01"),
|
||||
{
|
||||
"role": "user",
|
||||
"content": [{"type": "tool_result", "tool_use_id": "toolu_01", "content": tool_result_content}],
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
assert result == [
|
||||
result[0],
|
||||
{"role": "tool", "tool_call_id": "toolu_01", "content": ""},
|
||||
]
|
||||
assert result[0]["role"] == "assistant"
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
"limit": 22733
|
||||
},
|
||||
"LIT002": {
|
||||
"limit": 26863
|
||||
"limit": 26860
|
||||
},
|
||||
"LIT003": {
|
||||
"limit": 269
|
||||
|
|
@ -33,6 +33,6 @@
|
|||
"limit": 5583
|
||||
},
|
||||
"LIT012": {
|
||||
"limit": 4510
|
||||
"limit": 4509
|
||||
}
|
||||
}
|
||||
|
|
|
|||
15
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
15
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -23643,7 +23643,7 @@ export interface components {
|
|||
/** ChatCompletionToolMessage */
|
||||
ChatCompletionToolMessage: {
|
||||
/** Content */
|
||||
content: string | (components["schemas"]["ChatCompletionTextObject"] | components["schemas"]["ChatCompletionImageObject"])[];
|
||||
content: string | (components["schemas"]["ChatCompletionTextObject"] | components["schemas"]["ChatCompletionImageObject"] | components["schemas"]["ChatCompletionToolReferenceObject"])[];
|
||||
/**
|
||||
* Role
|
||||
* @constant
|
||||
|
|
@ -23672,6 +23672,19 @@ export interface components {
|
|||
/** Strict */
|
||||
strict?: boolean;
|
||||
};
|
||||
/**
|
||||
* ChatCompletionToolReferenceObject
|
||||
* @description Anthropic tool-search result block, carried through untouched so it survives a round trip.
|
||||
*/
|
||||
ChatCompletionToolReferenceObject: {
|
||||
/** Tool Name */
|
||||
tool_name: string;
|
||||
/**
|
||||
* Type
|
||||
* @constant
|
||||
*/
|
||||
type: "tool_reference";
|
||||
};
|
||||
/** ChatCompletionUserMessage */
|
||||
ChatCompletionUserMessage: {
|
||||
cache_control?: components["schemas"]["ChatCompletionCachedContent"];
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue