Merge remote-tracking branch 'origin/litellm_responses_tool_search_anthropic' into litellm_chat_completions_tool_search_anthropic

This commit is contained in:
mateo-berri 2026-10-13 22:04:35 -07:00
commit 63ef7505d6
19 changed files with 1641 additions and 256 deletions

View file

@ -1828,9 +1828,8 @@ def _tool_result_ids(answers: Iterable[AllMessageValues]) -> Iterator[str]:
def answered_tool_call_ids(messages: Sequence[AllMessageValues], assistant_index: int) -> frozenset[str]:
following: Final = itertools.dropwhile(
lambda message: message["role"] == "assistant", itertools.islice(messages, assistant_index, None)
)
from_assistant: Final = (messages[index] for index in range(assistant_index, len(messages)))
following: Final = itertools.dropwhile(lambda message: message["role"] == "assistant", from_assistant)
answers: Final = itertools.takewhile(lambda message: message["role"] in ("tool", "function"), following)
return frozenset(_tool_result_ids(answers))
@ -2618,6 +2617,7 @@ def anthropic_messages_pt(
unique_tool_ids: set[str] = set()
assistant_content: list[AnthropicMessagesAssistantMessageValues] = []
answered_tool_ids = answered_tool_call_ids(messages, msg_i)
## MERGE CONSECUTIVE ASSISTANT CONTENT ##
while msg_i < len(messages) and messages[msg_i]["role"] == "assistant":
assistant_content_block: ChatCompletionAssistantMessage = messages[msg_i]
@ -2671,7 +2671,7 @@ def anthropic_messages_pt(
assistant_tool_calls,
web_search_results=_web_search_results_tc,
tool_results=_tool_results_tc,
answered_tool_ids=answered_tool_call_ids(messages, msg_i),
answered_tool_ids=answered_tool_ids,
)
# Group tool invoke results into (server_tool_use, result) pairs
@ -2855,7 +2855,7 @@ def anthropic_messages_pt(
assistant_tool_calls,
web_search_results=_web_search_results,
tool_results=_tool_results,
answered_tool_ids=answered_tool_call_ids(messages, msg_i),
answered_tool_ids=answered_tool_ids,
)
# Prevent "tool_use ids must be unique" errors by filtering duplicates

View file

@ -63,6 +63,7 @@ from litellm.llms.base_llm.guardrail_translation.utils import (
unappliable_request_rewrite,
)
from litellm.llms.openai.responses.guardrail_translation.tool_merge import merge_guardrailed_tools
from litellm.responses.litellm_completion_transformation.custom_tools import without_malformed_tool_flags
from litellm.responses.litellm_completion_transformation.transformation import (
LiteLLMCompletionResponsesConfig,
)
@ -567,8 +568,9 @@ class OpenAIResponsesHandler(BaseTranslation):
original_tools: Final[tuple[Mapping[str, object], ...]] = (
tuple(raw_tools) if isinstance(raw_tools, list) else ()
)
scanned_tools: Final = tuple(without_malformed_tool_flags(tool) for tool in original_tools)
flattened_tool_groups: Final = tuple(
form.chat_tools for form in LiteLLMCompletionResponsesConfig.responses_tools_to_chat_forms(original_tools)
form.chat_tools for form in LiteLLMCompletionResponsesConfig.responses_tools_to_chat_forms(scanned_tools)
)
extracted: Final = self._extract_guardrail_inputs(
data, input_data, flattened_tool_groups, skip_system=skip_system

View file

@ -24116,6 +24116,60 @@
"title": "OutputTokensDetails",
"type": "object"
},
"OutputToolSearchCall": {
"additionalProperties": true,
"properties": {
"arguments": {
"title": "Arguments"
},
"call_id": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Call Id"
},
"execution": {
"enum": [
"server",
"client"
],
"title": "Execution",
"type": "string"
},
"id": {
"title": "Id",
"type": "string"
},
"status": {
"enum": [
"in_progress",
"completed",
"incomplete"
],
"title": "Status",
"type": "string"
},
"type": {
"const": "tool_search_call",
"title": "Type",
"type": "string"
}
},
"required": [
"id",
"arguments",
"execution",
"status",
"type"
],
"title": "OutputToolSearchCall",
"type": "object"
},
"PendingSafetyCheck": {
"additionalProperties": true,
"description": "A pending safety check for the computer call.",
@ -25748,7 +25802,7 @@
"$ref": "#/components/schemas/ResponseFunctionToolCallOutputItem"
},
{
"$ref": "#/components/schemas/openai__types__responses__response_tool_search_call__ResponseToolSearchCall"
"$ref": "#/components/schemas/ResponseToolSearchCall"
},
{
"$ref": "#/components/schemas/ResponseToolSearchOutputItem"
@ -26102,6 +26156,71 @@
"title": "ResponseTextConfigParam",
"type": "object"
},
"ResponseToolSearchCall": {
"additionalProperties": true,
"properties": {
"arguments": {
"title": "Arguments"
},
"call_id": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Call Id"
},
"created_by": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Created By"
},
"execution": {
"enum": [
"server",
"client"
],
"title": "Execution",
"type": "string"
},
"id": {
"title": "Id",
"type": "string"
},
"status": {
"enum": [
"in_progress",
"completed",
"incomplete"
],
"title": "Status",
"type": "string"
},
"type": {
"const": "tool_search_call",
"title": "Type",
"type": "string"
}
},
"required": [
"id",
"arguments",
"execution",
"status",
"type"
],
"title": "ResponseToolSearchCall",
"type": "object"
},
"ResponseToolSearchOutputItem": {
"additionalProperties": true,
"properties": {
@ -26336,7 +26455,7 @@
"$ref": "#/components/schemas/ResponseReasoningItem"
},
{
"$ref": "#/components/schemas/openai__types__responses__response_tool_search_call__ResponseToolSearchCall"
"$ref": "#/components/schemas/ResponseToolSearchCall"
},
{
"$ref": "#/components/schemas/ResponseToolSearchOutputItem"
@ -26419,7 +26538,7 @@
"$ref": "#/components/schemas/CustomToolCallOutputItem"
},
{
"$ref": "#/components/schemas/litellm__types__responses__main__ResponseToolSearchCall"
"$ref": "#/components/schemas/OutputToolSearchCall"
}
]
},
@ -27358,125 +27477,6 @@
"title": "WebSearchTool",
"type": "object"
},
"litellm__types__responses__main__ResponseToolSearchCall": {
"additionalProperties": true,
"properties": {
"arguments": {
"title": "Arguments"
},
"call_id": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Call Id"
},
"execution": {
"enum": [
"server",
"client"
],
"title": "Execution",
"type": "string"
},
"id": {
"title": "Id",
"type": "string"
},
"status": {
"enum": [
"in_progress",
"completed",
"incomplete"
],
"title": "Status",
"type": "string"
},
"type": {
"const": "tool_search_call",
"title": "Type",
"type": "string"
}
},
"required": [
"id",
"arguments",
"execution",
"status",
"type"
],
"title": "ResponseToolSearchCall",
"type": "object"
},
"openai__types__responses__response_tool_search_call__ResponseToolSearchCall": {
"additionalProperties": true,
"properties": {
"arguments": {
"title": "Arguments"
},
"call_id": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Call Id"
},
"created_by": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Created By"
},
"execution": {
"enum": [
"server",
"client"
],
"title": "Execution",
"type": "string"
},
"id": {
"title": "Id",
"type": "string"
},
"status": {
"enum": [
"in_progress",
"completed",
"incomplete"
],
"title": "Status",
"type": "string"
},
"type": {
"const": "tool_search_call",
"title": "Type",
"type": "string"
}
},
"required": [
"id",
"arguments",
"execution",
"status",
"type"
],
"title": "ResponseToolSearchCall",
"type": "object"
},
"openai__types__responses__web_search_preview_tool__UserLocation": {
"additionalProperties": true,
"description": "The user's location.",

View file

@ -180,6 +180,62 @@ def validated_defer_loading(value: object) -> bool:
raise BadRequestError(message="defer_loading must be a boolean", model="", llm_provider="") from exc
_TOOL_FLAG_ADAPTERS: Final = MappingProxyType(
{"defer_loading": _DEFER_LOADING_ADAPTER, "allowed_callers": _ALLOWED_CALLERS_ADAPTER}
)
def _is_well_formed_tool_flag(key: str, value: object) -> bool:
adapter: Final = _TOOL_FLAG_ADAPTERS.get(key)
if adapter is None:
return True
try:
adapter.validate_python(value, strict=True)
except ValidationError:
return False
return True
_NAMESPACE_MEMBERS_ADAPTER: Final = TypeAdapter[list[object]](list[object])
_TOOL_ADAPTER: Final = TypeAdapter[dict[str, object]](dict[str, object])
def _with_well_formed_flags(tool: Mapping[str, object]) -> Mapping[str, object]:
return {key: value for key, value in tool.items() if _is_well_formed_tool_flag(key, value)}
def _member_with_well_formed_flags(member: object) -> object:
try:
return _with_well_formed_flags(_TOOL_ADAPTER.validate_python(member))
except ValidationError:
return member
def without_malformed_tool_flags(tool: Mapping[str, object]) -> Mapping[str, object]:
kept: Final = _with_well_formed_flags(tool)
if tool.get("type") != "namespace":
return kept
try:
members: Final = _NAMESPACE_MEMBERS_ADAPTER.validate_python(tool.get("tools"))
except ValidationError:
return kept
return {**kept, "tools": [_member_with_well_formed_flags(member) for member in members]}
def function_chat_tool(
function: ChatCompletionToolParamFunctionChunk, allowed_callers: Sequence[str] | None, deferred: bool
) -> ChatCompletionToolParam:
if allowed_callers is None and not deferred:
return ChatCompletionToolParam(type="function", function=function)
if allowed_callers is None:
return ChatCompletionToolParam(type="function", function=function, defer_loading=True)
if not deferred:
return ChatCompletionToolParam(type="function", function=function, allowed_callers=list(allowed_callers))
return ChatCompletionToolParam(
type="function", function=function, allowed_callers=list(allowed_callers), defer_loading=True
)
def custom_tool_grammar_suffix(fmt: object) -> str:
try:
parsed: Final = _CustomToolFormat.model_validate(fmt)
@ -196,8 +252,8 @@ def convert_custom_tool_to_function_tool(tool: Mapping[str, object]) -> ChatComp
The grammar definition is embedded in the description so the model can
produce correctly-formatted output. Returns ``None`` if the tool is not a
custom tool. Raises ``ValueError`` if ``allowed_callers`` is not a list of
strings.
custom tool. Raises ``BadRequestError`` if ``allowed_callers`` is not a list
of strings or ``defer_loading`` is not a boolean.
"""
if tool.get("type") != "custom":
return None
@ -208,6 +264,7 @@ def convert_custom_tool_to_function_tool(tool: Mapping[str, object]) -> ChatComp
tool.get("format")
)
allowed_callers: Final = validated_allowed_callers(tool.get("allowed_callers"))
deferred: Final = validated_defer_loading(tool.get("defer_loading"))
function_chunk: Final = ChatCompletionToolParamFunctionChunk(
name=name,
description=description,
@ -222,6 +279,4 @@ def convert_custom_tool_to_function_tool(tool: Mapping[str, object]) -> ChatComp
"required": ["content"],
},
)
if allowed_callers is None:
return ChatCompletionToolParam(type="function", function=function_chunk)
return ChatCompletionToolParam(type="function", function=function_chunk, allowed_callers=allowed_callers)
return function_chat_tool(function_chunk, allowed_callers, deferred)

View file

@ -858,8 +858,8 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator):
if self._cached_item_id is None:
self._cached_item_id = f"msg_{uuid.uuid4()}"
text: Final = self.litellm_model_response.choices[0].message.content or ""
annotations = getattr(self.litellm_model_response.choices[0].message, "annotations", None)
text: Final = litellm_complete_object.choices[0].message.content or ""
annotations = getattr(litellm_complete_object.choices[0].message, "annotations", None)
response_annotations: Final = (
LiteLLMCompletionResponsesConfig.transform_chat_completion_annotations_to_response_output_annotations(

View file

@ -1,4 +1,4 @@
from collections.abc import Mapping, Sequence
from collections.abc import Iterable, Iterator, Mapping, Sequence, Set
from typing import Final, Literal, TypeAlias
from pydantic import ConfigDict, TypeAdapter, ValidationError
@ -6,22 +6,28 @@ from pydantic import ConfigDict, TypeAdapter, ValidationError
from litellm.litellm_core_utils.prompt_templates.factory import is_anthropic_tool_search_use
from litellm.types.llms.anthropic import ANTHROPIC_TOOL_SEARCH_TOOL_TYPES, AnthropicToolSearchToolRegex
from litellm.types.llms.base import LiteLLMBaseModel
from litellm.types.responses.main import ResponseToolSearchCall
from litellm.types.utils import LlmProviders
from litellm.types.responses.main import OutputToolSearchCall
from litellm.types.utils import LlmProviders, Message
from .custom_tools import openai_shaped_tool_call_item_id
__all__ = [
"ANTHROPIC_TOOL_SEARCH_TOOL_NAME",
"ANTHROPIC_TOOL_SEARCH_TOOL_TYPE",
"CompletionRoute",
"ToolSearchCallStatus",
"answers_tool_search",
"anthropic_tool_search_chat_tool",
"build_tool_search_call",
"has_server_tool_search",
"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",
"without_tool_search_calls",
"without_tool_search_uses",
]
ANTHROPIC_TOOL_SEARCH_TOOL_TYPE: Final = "tool_search_tool_regex_20251119"
@ -61,6 +67,7 @@ def is_anthropic_tool_search_chat_tool(tool: Mapping[str, object]) -> bool:
def provider_builds_anthropic_request(model: str, custom_llm_provider: str | None) -> bool:
from litellm.litellm_core_utils.get_llm_provider_logic import get_llm_provider
from litellm.llms.anthropic.chat.transformation import AnthropicConfig
from litellm.llms.openai.chat.gpt_transformation import OpenAIGPTConfig
from litellm.utils import ProviderConfigManager
_, provider, _, _ = get_llm_provider(model=model, custom_llm_provider=custom_llm_provider)
@ -69,7 +76,95 @@ def provider_builds_anthropic_request(model: str, custom_llm_provider: str | Non
except ValueError:
return False
chat_config: Final = ProviderConfigManager.get_provider_chat_config(model=model, provider=llm_provider)
return isinstance(chat_config, AnthropicConfig)
return isinstance(chat_config, AnthropicConfig) and not isinstance(chat_config, OpenAIGPTConfig)
class CompletionRoute(LiteLLMBaseModel):
model_config = ConfigDict(frozen=True)
model: str
custom_llm_provider: str | None = None
class _ToolCallFunctionFields(LiteLLMBaseModel):
model_config = ConfigDict(frozen=True, from_attributes=True)
name: str | None = None
class _ToolCallFields(LiteLLMBaseModel):
model_config = ConfigDict(frozen=True, from_attributes=True)
id: str | None = None
function: _ToolCallFunctionFields | None = None
class _SessionMessageFields(LiteLLMBaseModel):
model_config = ConfigDict(frozen=True, from_attributes=True)
role: str | None = None
tool_calls: tuple[object, ...] | None = None
tool_call_id: str | None = None
def _session_message_fields(message: object) -> _SessionMessageFields:
try:
return _SessionMessageFields.model_validate(message)
except ValidationError:
return _SessionMessageFields()
def _tool_search_call_id(tool_call: object) -> str | None:
try:
parsed: Final = _ToolCallFields.model_validate(tool_call)
except ValidationError:
return None
tool_id: Final = parsed.id or ""
tool_name: Final = (parsed.function.name if parsed.function is not None else None) or ""
return tool_id if is_anthropic_tool_search_use(tool_id, tool_name) else None
def _is_tool_search_call(tool_call: object) -> bool:
return _tool_search_call_id(tool_call) is not None
def _assistant_tool_calls(message: object) -> tuple[object, ...]:
fields: Final = _session_message_fields(message)
return (fields.tool_calls or ()) if fields.role == "assistant" else ()
def holds_tool_search_use(message: object) -> bool:
return any(_is_tool_search_call(tool_call) for tool_call in _assistant_tool_calls(message))
def _tool_search_call_ids_of(messages: Iterable[object]) -> Iterator[str]:
for message in messages:
for tool_call in _assistant_tool_calls(message):
if (tool_id := _tool_search_call_id(tool_call)) is not None:
yield tool_id
def tool_search_call_ids(messages: Iterable[object]) -> frozenset[str]:
return frozenset(_tool_search_call_ids_of(messages))
def answers_tool_search(message: object, search_call_ids: Set[str]) -> bool:
fields: Final = _session_message_fields(message)
return fields.role == "tool" and fields.tool_call_id in search_call_ids
def without_tool_search_calls(message: object) -> tuple[object, ...]:
return tuple(tool_call for tool_call in _assistant_tool_calls(message) if not _is_tool_search_call(tool_call))
def without_tool_search_uses(message: Message) -> Message | None:
tool_calls: Final = message.tool_calls or []
kept: Final = tuple(tool_call for tool_call in tool_calls if not _is_tool_search_call(tool_call))
if len(kept) == len(tool_calls):
return message
if not kept and not message.content:
return None
return message.model_copy(update={"tool_calls": list(kept) or None})
def _parsed_arguments(arguments_json: str) -> Mapping[str, object] | None:
@ -79,9 +174,9 @@ def _parsed_arguments(arguments_json: str) -> Mapping[str, object] | None:
return None
def build_tool_search_call(tool_id: str, arguments_json: str, status: ToolSearchCallStatus) -> ResponseToolSearchCall:
def build_tool_search_call(tool_id: str, arguments_json: str, status: ToolSearchCallStatus) -> OutputToolSearchCall:
arguments: Final = _parsed_arguments(arguments_json)
return ResponseToolSearchCall(
return OutputToolSearchCall(
id=openai_shaped_tool_call_item_id("tool_search_call", tool_id),
call_id=tool_id,
arguments={} if arguments is None else arguments,

View file

@ -33,9 +33,11 @@ from openai.types.responses.tool_param import FunctionToolParam
from pydantic import TypeAdapter, ValidationError
from typing_extensions import ReadOnly, TypedDict
import litellm
from litellm._logging import verbose_logger
from litellm.caching import InMemoryCache
from litellm.constants import REDACTED_BY_LITELLM, REDACTED_TOOL_CALL_ARGUMENTS_PLACEHOLDER
from litellm.litellm_core_utils.core_helpers import normalize_drop_params
from litellm.litellm_core_utils.get_supported_openai_params import (
get_supported_openai_params,
)
@ -91,7 +93,7 @@ from litellm.types.responses.main import (
OutputFunctionToolCall,
OutputImageGenerationCall,
OutputText,
ResponseToolSearchCall,
OutputToolSearchCall,
)
from litellm.types.utils import (
ChatCompletionAnnotation,
@ -106,22 +108,30 @@ from litellm.types.utils import (
from .custom_tools import (
convert_custom_tool_to_function_tool,
extract_custom_tool_names,
function_chat_tool,
is_custom_tool_call,
openai_shaped_tool_call_item_id,
serialize_tool_call_arguments,
unwrap_custom_tool_arguments,
validated_allowed_callers,
validated_defer_loading,
without_malformed_tool_flags,
)
from .reasoning_items import decode_thinking_blocks, encode_thinking_blocks, mint_reasoning_item_id
from .tool_search import (
CompletionRoute,
answers_tool_search,
anthropic_tool_search_chat_tool,
build_tool_search_call,
has_server_tool_search,
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,
without_tool_search_calls,
without_tool_search_uses,
)
NamespaceNameMap: TypeAlias = Mapping[str, tuple[str, str]]
@ -242,14 +252,37 @@ def _input_item_role(input_item: Mapping[str, object]) -> str:
return cast(str, input_item.get("role") or "user") # cast-ok: client-supplied role forwarded verbatim, unvalidated
SessionMessage: TypeAlias = (
AllMessageValues
| GenericChatCompletionMessage
| ChatCompletionMessageToolCall
| ChatCompletionResponseMessage
| Message
)
def _replayed_assistant_without_tool_search_uses(message: Mapping[str, object]) -> SessionMessage | None:
kept_calls: Final = without_tool_search_calls(message)
if not kept_calls and not message.get("content"):
return None
without_calls: Final = {key: value for key, value in message.items() if key != "tool_calls"}
return cast( # cast-ok: the replayed assistant message minus its search calls keeps every other field as stored
SessionMessage, {**without_calls, "tool_calls": list(kept_calls)} if kept_calls else without_calls
)
def _without_stored_tool_search_uses(message: SessionMessage, search_call_ids: frozenset[str]) -> SessionMessage | None:
if isinstance(message, Message):
return without_tool_search_uses(message)
if answers_tool_search(message, search_call_ids):
return None
if isinstance(message, Mapping) and holds_tool_search_use(message):
return _replayed_assistant_without_tool_search_uses(message)
return message
class ChatCompletionSession(TypedDict, total=False):
messages: list[
AllMessageValues
| GenericChatCompletionMessage
| ChatCompletionMessageToolCall
| ChatCompletionResponseMessage
| Message
]
messages: list[SessionMessage]
litellm_session_id: str | None
instructions: ReadOnly[str | None]
@ -382,6 +415,12 @@ class LiteLLMCompletionResponsesConfig:
)
return supported_params is not None and "web_search_options" not in supported_params
@staticmethod
def _without_malformed_tool_flags_when_dropping_params(tools: ResponseTools, drop_params: object) -> ResponseTools:
if litellm.drop_params is not True and normalize_drop_params(drop_params) is not True:
return tools
return [without_malformed_tool_flags(tool) for tool in tools or ()]
@staticmethod
def _without_unsupported_tool_search(
tools: ResponseTools, model: str, custom_llm_provider: str | None
@ -390,6 +429,8 @@ class LiteLLMCompletionResponsesConfig:
if not has_server_tool_search(tools):
return tools
if any(is_anthropic_tool_search_chat_tool(tool) for tool in tools or ()):
return [tool for tool in tools or () if not is_server_tool_search_tool(tool)]
route_runs_tool_search: Final = supports_tool_search(
model=model, custom_llm_provider=custom_llm_provider
) and provider_builds_anthropic_request(model, custom_llm_provider)
@ -498,13 +539,18 @@ class LiteLLMCompletionResponsesConfig:
custom_llm_provider: str | None = None,
stream: bool | None = None,
extra_headers: Mapping[str, object] | None = None,
drop_params: object = None,
**kwargs,
) -> dict:
"""
Transform a Responses API request into a Chat Completion request
"""
request_tools: Final = LiteLLMCompletionResponsesConfig._without_unsupported_tool_search(
responses_api_request.get("tools") or [], model=model, custom_llm_provider=custom_llm_provider
LiteLLMCompletionResponsesConfig._without_malformed_tool_flags_when_dropping_params(
responses_api_request.get("tools") or [], drop_params
),
model=model,
custom_llm_provider=custom_llm_provider,
)
(
converted_tools,
@ -627,6 +673,19 @@ class LiteLLMCompletionResponsesConfig:
return messages
@staticmethod
def _without_tool_search_uses_off_anthropic(
messages: Sequence[SessionMessage], route: CompletionRoute
) -> tuple[SessionMessage, ...]:
if provider_builds_anthropic_request(route.model, route.custom_llm_provider):
return tuple(messages)
search_call_ids: Final = tool_search_call_ids(messages)
return tuple(
kept
for message in messages
if (kept := _without_stored_tool_search_uses(message, search_call_ids)) is not None
)
@staticmethod
async def async_responses_api_session_handler(
previous_response_id: str,
@ -644,7 +703,14 @@ class LiteLLMCompletionResponsesConfig:
)
)
_messages: Final = litellm_completion_request.get("messages") or []
session_messages: Final = chat_completion_session.get("messages") or []
stored_messages: Final = chat_completion_session.get("messages") or []
session_messages: Final = (
LiteLLMCompletionResponsesConfig._without_tool_search_uses_off_anthropic(
stored_messages, CompletionRoute.model_validate(litellm_completion_request)
)
if any(holds_tool_search_use(message) for message in stored_messages)
else stored_messages
)
instructions_end: Final = 1 if instructions else 0
carried_instructions: Final = None if instructions else chat_completion_session.get("instructions")
leading_system_messages: Final = (
@ -658,7 +724,7 @@ class LiteLLMCompletionResponsesConfig:
# Store original _messages before combining for safety check
original_new_messages: Final = _messages.copy() if _messages else []
combined_messages = leading_system_messages + session_messages + _messages[instructions_end:]
combined_messages = [*leading_system_messages, *session_messages, *_messages[instructions_end:]]
# Fix: Ensure tool_results have corresponding tool_calls in previous assistant message
# Pass tools parameter to help reconstruct tool_calls if not in cache
@ -680,7 +746,7 @@ class LiteLLMCompletionResponsesConfig:
elif session_messages:
# If we had session messages but they got filtered out,
# restore them
combined_messages = session_messages
combined_messages = list(session_messages)
else:
# Both are empty - this likely means function_call_output had empty/invalid call_id
# Provide a helpful error message
@ -2001,16 +2067,10 @@ class LiteLLMCompletionResponsesConfig:
parameters=dict(normalized_parameters),
strict=bool(namespace_tool.get("strict", False)),
)
allowed_callers: Final = validated_allowed_callers(namespace_tool.get("allowed_callers"))
deferred: Final = validated_defer_loading(namespace_tool.get("defer_loading"))
if allowed_callers is None and not deferred:
return ChatCompletionToolParam(type="function", function=function)
if allowed_callers is None:
return ChatCompletionToolParam(type="function", function=function, defer_loading=True)
if not deferred:
return ChatCompletionToolParam(type="function", function=function, allowed_callers=allowed_callers)
return ChatCompletionToolParam(
type="function", function=function, allowed_callers=allowed_callers, defer_loading=True
return function_chat_tool(
function,
validated_allowed_callers(namespace_tool.get("allowed_callers")),
validated_defer_loading(namespace_tool.get("defer_loading")),
)
@staticmethod
@ -2096,8 +2156,9 @@ class LiteLLMCompletionResponsesConfig:
chat_completion_tool["cache_control"] = tool.get("cache_control")
if validated_defer_loading(tool.get("defer_loading")):
chat_completion_tool["defer_loading"] = True
if tool.get("allowed_callers"):
chat_completion_tool["allowed_callers"] = tool.get("allowed_callers")
function_allowed_callers: Final = validated_allowed_callers(tool.get("allowed_callers"))
if function_allowed_callers:
chat_completion_tool["allowed_callers"] = function_allowed_callers
if tool.get("input_examples"):
chat_completion_tool["input_examples"] = tool.get("input_examples")
if tool.get("eager_input_streaming") is not None:
@ -2230,7 +2291,7 @@ class LiteLLMCompletionResponsesConfig:
def transform_chat_completion_tools_to_responses_tools(
chat_completion_response: ModelResponse,
responses_api_request: ResponsesAPIOptionalRequestParams | None = None,
) -> list[ResponseFunctionToolCall | ResponseFunctionWebSearch | CustomToolCallOutputItem | ResponseToolSearchCall]:
) -> list[ResponseFunctionToolCall | ResponseFunctionWebSearch | CustomToolCallOutputItem | OutputToolSearchCall]:
"""
Transform a Chat Completion tools into a Responses API tools.
@ -2238,7 +2299,7 @@ class LiteLLMCompletionResponsesConfig:
with ``type="custom_tool_call"``. For regular function tools, returns
``ResponseFunctionToolCall`` with ``type="function_call"``. A provider-side
tool search call made for a request's ``tool_search`` tool returns
``ResponseToolSearchCall`` with ``type="tool_search_call"``.
``OutputToolSearchCall`` with ``type="tool_search_call"``.
"""
all_chat_completion_tools: Final[list[ChatCompletionMessageToolCall]] = []
for choice in chat_completion_response.choices:
@ -2260,9 +2321,7 @@ class LiteLLMCompletionResponsesConfig:
chat_completion_response
)
responses_tools: Final[
list[
ResponseFunctionToolCall | ResponseFunctionWebSearch | CustomToolCallOutputItem | ResponseToolSearchCall
]
list[ResponseFunctionToolCall | ResponseFunctionWebSearch | CustomToolCallOutputItem | OutputToolSearchCall]
] = []
for tool in all_chat_completion_tools:
if tool.type == "function":
@ -2339,7 +2398,7 @@ class LiteLLMCompletionResponsesConfig:
tool_arguments: str,
web_search_calls: Mapping[str, ResponseFunctionWebSearch],
tool_search_requested: bool,
) -> ResponseFunctionWebSearch | ResponseToolSearchCall | None:
) -> ResponseFunctionWebSearch | OutputToolSearchCall | None:
web_search_call: Final = web_search_calls.get(tool_id)
if web_search_call is not None:
return web_search_call
@ -2614,7 +2673,7 @@ class LiteLLMCompletionResponsesConfig:
| ResponseFunctionToolCall
| ResponseFunctionWebSearch
| CustomToolCallOutputItem
| ResponseToolSearchCall
| OutputToolSearchCall
]:
responses_output: list[
GenericResponseOutputItem
@ -2624,7 +2683,7 @@ class LiteLLMCompletionResponsesConfig:
| ResponseFunctionToolCall
| ResponseFunctionWebSearch
| CustomToolCallOutputItem
| ResponseToolSearchCall
| OutputToolSearchCall
] = []
responses_output.extend(

View file

@ -91,7 +91,7 @@ from litellm.types.responses.main import (
OutputCodeInterpreterCall,
OutputFunctionToolCall,
OutputImageGenerationCall,
ResponseToolSearchCall,
OutputToolSearchCall,
)
from .base import CachedTokensDetails
@ -1426,6 +1426,17 @@ One of: completed, failed, in_progress, cancelled, queued, or incomplete.
"""
_NULL_DROPPED_OUTPUT_ITEM_KEYS: Final = frozenset(
(
("reasoning", "status"),
("reasoning", "content"),
("reasoning", "encrypted_content"),
("tool_search_call", "created_by"),
("tool_search_output", "created_by"),
)
)
class ResponsesAPIResponse(BaseLiteLLMOpenAIResponseObject):
id: str
created_at: int
@ -1445,7 +1456,7 @@ class ResponsesAPIResponse(BaseLiteLLMOpenAIResponseObject):
| ResponseFunctionToolCall
| ResponseFunctionWebSearch
| CustomToolCallOutputItem
| ResponseToolSearchCall
| OutputToolSearchCall
]
)
parallel_tool_calls: bool | None = None
@ -1515,8 +1526,12 @@ class ResponsesAPIResponse(BaseLiteLLMOpenAIResponseObject):
return serialized
return [
(
{k: v for k, v in item.items() if v is not None or k not in ("status", "content", "encrypted_content")}
if isinstance(item, dict) and item.get("type") == "reasoning"
{
k: v
for k, v in item.items()
if v is not None or (item.get("type"), k) not in _NULL_DROPPED_OUTPUT_ITEM_KEYS
}
if isinstance(item, dict)
else item
)
for item in serialized

View file

@ -133,7 +133,7 @@ class CustomToolCallOutputItem(BaseLiteLLMOpenAIResponseObject):
status: Literal["in_progress", "completed", "incomplete"] | None = None
class ResponseToolSearchCall(BaseLiteLLMOpenAIResponseObject):
class OutputToolSearchCall(BaseLiteLLMOpenAIResponseObject):
model_config = ConfigDict(extra="allow", frozen=True, protected_namespaces=())
id: str

View file

@ -1,25 +1,27 @@
import asyncio
import itertools
import json
import re
import socket
import threading
import uuid
from collections.abc import Iterator, Mapping, Sequence
from collections.abc import Callable, Iterator, Mapping, Sequence
from dataclasses import dataclass
from pathlib import Path
from queue import SimpleQueue
from types import MappingProxyType
from typing import Final, Literal
from types import EllipsisType, MappingProxyType
from typing import Final, Literal, TypeAlias
from urllib.parse import unquote, urlsplit
import httpx
import psutil
import pytest
import yaml
from _pytest.mark.structures import ParameterSet
from integration._support import claude_code as cc
from integration._support import responses_vendor as rv
from integration._support.anthropic_thinking import EVENT_STREAM, Event, aws_chunks, sse_chunks
from integration._support.client import Gateway, eventually, gateway_from_environment
from integration._support.anthropic_thinking import BEDROCK_MODEL, EVENT_STREAM, Event, aws_chunks, sse_chunks
from integration._support.client import Gateway, Scenario, eventually, gateway_from_environment
from integration._support.database import read_rows
from integration._support.process import OwnedProxy, graceful_stop_seconds, owned_proxy_process
from integration._support.wire import Reply, Request, Wire, wire_server
@ -27,6 +29,7 @@ from pydantic import JsonValue
_CLAUDE: Final = cc.OPUS
_BEDROCK_CLAUDE: Final = "anthropic.claude-sonnet-5-5"
_MISTRAL: Final = "mistral-large-latest"
_CONFIG_MODEL: Final = "responses-bridge-tool-search-chaos"
_ANTHROPIC_BASE: Final = "http://api.anthropic.com"
_REGEX_TOOL_TYPE: Final = "tool_search_tool_regex_20251119"
@ -43,7 +46,15 @@ _WEATHER_PARAMETERS: Final[dict[str, JsonValue]] = {
"required": ["city"],
}
_ORDER_PARAMETERS: Final[dict[str, JsonValue]] = {
"type": "object",
"properties": {"order_id": {"type": "string"}},
"required": ["order_id"],
}
Endpoint = Literal["responses", "chat", "messages"]
StoredShape: TypeAlias = Literal["tool_use", "search_only", "search_and_text"]
OffAnthropicRoute: TypeAlias = Literal["mistral", "bedrock_converse"]
@dataclass(frozen=True, slots=True)
@ -635,3 +646,365 @@ def test_bedrock_invoke_pass_through_relays_the_search_frames_untouched(owned_ri
seconds=70,
)
assert str(rows[0]["status"]) == "success", rows
def _server_tool_use(marker: str) -> Event:
return {
"type": "server_tool_use",
"id": f"srvtoolu_{marker}",
"name": _REGEX_TOOL_NAME,
"input": json.loads(_SEARCH_ARGUMENTS),
}
def _stored_turn(marker: str, shape: StoredShape) -> dict[str, JsonValue]:
match shape:
case "tool_use":
return _message(marker)
case "search_only":
return {
**_message(marker),
"content": [_server_tool_use(marker), _search_result(marker)],
"stop_reason": "pause_turn",
}
case "search_and_text":
return {
**_message(marker),
"content": [
_server_tool_use(marker),
_search_result(marker),
{"type": "text", "text": f"found marker-{marker}"},
],
"stop_reason": "end_turn",
}
def _stored_turn_vendor(shape: StoredShape) -> Callable[[Request], Reply]:
def respond(request: Request) -> Reply:
if request.method != "POST":
return Reply()
marker: Final = rv.newest_marker(request.body.decode()) or "health"
return Reply(body=json.dumps(_stored_turn(marker, shape)).encode())
return respond
def _tool_call_ids(message: Mapping[str, JsonValue]) -> frozenset[str]:
return frozenset(str(call["id"]) for call in rv.ITEMS.validate_python(message.get("tool_calls") or []))
def _answer_ids_after(messages: Sequence[Mapping[str, JsonValue]], index: int) -> frozenset[str]:
answers: Final = itertools.takewhile(lambda message: message.get("role") == "tool", messages[index + 1 :])
return frozenset(str(message.get("tool_call_id")) for message in answers)
def _opening_call_ids(messages: Sequence[Mapping[str, JsonValue]], index: int) -> frozenset[str]:
opener: Final = next((message for message in reversed(messages[:index]) if message.get("role") != "tool"), None)
return frozenset() if opener is None else _tool_call_ids(opener)
def _unmatched_tool_ids(messages: Sequence[Mapping[str, JsonValue]]) -> Iterator[str]:
for index, message in enumerate(messages):
if message.get("role") == "assistant":
yield from sorted(_tool_call_ids(message) - _answer_ids_after(messages, index))
if message.get("role") == "tool" and str(message.get("tool_call_id")) not in _opening_call_ids(messages, index):
yield str(message.get("tool_call_id"))
def _chat_answer(marker: str, stream: bool) -> Reply:
head: Final[Mapping[str, JsonValue]] = {"id": f"cmpl-{marker}", "created": 1, "model": _MISTRAL}
if not stream:
message: Final = {"role": "assistant", "content": f"answered marker-{marker}"}
completion: Final = {
**head,
"object": "chat.completion",
"choices": [{"index": 0, "message": message, "finish_reason": "stop"}],
"usage": rv.CHAT_USAGE,
}
return Reply(body=json.dumps(completion).encode())
delta: Final[dict[str, JsonValue]] = {"role": "assistant", "content": f"answered marker-{marker}"}
opened: Final[dict[str, JsonValue]] = {"index": 0, "delta": delta, "finish_reason": None}
return Reply(
content_type="text/event-stream",
chunks=(
rv.chat_sse({**head, "object": "chat.completion.chunk", "choices": [opened]}),
rv.chat_sse(
{
**head,
"object": "chat.completion.chunk",
"choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}],
"usage": rv.CHAT_USAGE,
}
),
b"data: [DONE]\n\n",
),
)
def _strict_mistral(request: Request) -> Reply:
if request.method != "POST":
return Reply()
marker: Final = rv.newest_marker(request.body.decode()) or "health"
if request.target != "/v1/chat/completions":
return rv.error(404, f"unexpected target {request.target}", "not_found")
if "defer_loading" in request.body.decode():
return rv.error(422, f"tools: defer_loading: Extra inputs are not permitted marker-{marker}", "extra_forbidden")
body: Final = rv.JSON_OBJECT.validate_json(request.body)
unmatched: Final = tuple(_unmatched_tool_ids(rv.ITEMS.validate_python(body.get("messages") or [])))
if unmatched:
return rv.error(
400,
f"Not the same number of function calls and responses: {', '.join(unmatched)} marker-{marker}",
"invalid_request_message_order",
)
return _chat_answer(marker, body.get("stream") is True)
def _converse_ids(message: Mapping[str, JsonValue], block_key: str) -> frozenset[str]:
return frozenset(
str(rv.JSON_OBJECT.validate_python(block[block_key])["toolUseId"])
for block in rv.ITEMS.validate_python(message.get("content") or [])
if block_key in block
)
def _unpaired_converse_turns(messages: Sequence[Mapping[str, JsonValue]]) -> tuple[int, ...]:
return tuple(
index + 1
for index, (previous, current) in enumerate(zip(messages, messages[1:]))
if _converse_ids(previous, "toolUse") != _converse_ids(current, "toolResult")
)
def _strict_converse(request: Request) -> Reply:
if request.method != "POST":
return Reply()
marker: Final = rv.newest_marker(request.body.decode()) or "health"
if unquote(request.target) != f"/model/{BEDROCK_MODEL}/converse":
return rv.error(404, f"unexpected target {request.target}", "not_found")
body: Final = rv.JSON_OBJECT.validate_json(request.body)
unpaired: Final = _unpaired_converse_turns(rv.ITEMS.validate_python(body.get("messages") or []))
if unpaired:
refusal: Final = (
f"Expected toolResult blocks at messages.{unpaired[0]}.content for the toolUse ids of the previous turn "
f"marker-{marker}"
)
return Reply(status=400, body=json.dumps({"message": refusal}).encode())
answer: Final = {
"output": {"message": {"role": "assistant", "content": [{"text": f"answered marker-{marker}"}]}},
"stopReason": "end_turn",
"usage": {"inputTokens": 11, "outputTokens": 4, "totalTokens": 15},
"metrics": {"latencyMs": 1},
}
return Reply(body=json.dumps(answer).encode())
def _strict_vendor(route: OffAnthropicRoute) -> Callable[[Request], Reply]:
match route:
case "mistral":
return _strict_mistral
case "bedrock_converse":
return _strict_converse
def _off_anthropic_deployment(scenario: Scenario, route: OffAnthropicRoute, wire_url: str) -> str:
match route:
case "mistral":
return scenario.model(model=f"mistral/{_MISTRAL}", api_base=wire_url, api_key="synthetic-mistral-key")
case "bedrock_converse":
return scenario.model(
model=f"bedrock/converse/{BEDROCK_MODEL}",
aws_access_key_id="AKIASCRIPTEDPROVIDER",
aws_secret_access_key="scripted-secret",
aws_region_name="us-east-1",
aws_bedrock_runtime_endpoint=wire_url,
)
def _stored_first_turn(proxy: Gateway, model: str, marker: str) -> str:
first: Final = proxy.request("POST", "/v1/responses", _body(model, _Call("responses", False, marker)))
assert first.status_code == 200, first.text
payload: Final = rv.JSON_OBJECT.validate_json(first.text)
types: Final = [item["type"] for item in rv.ITEMS.validate_python(payload["output"])]
assert "tool_search_call" in types, types
response_id: Final = str(payload["id"])
eventually(
lambda: read_rows('SELECT request_id FROM "LiteLLM_SpendLogs" WHERE model_group=%s', (model,)),
lambda rows: any(rv.same_response(str(row["request_id"]), response_id) for row in rows),
seconds=70,
)
return response_id
def _follow_up_input(marker: str, shape: StoredShape) -> list[JsonValue]:
if shape == "tool_use":
return [{"type": "function_call_output", "call_id": f"toolu_{marker}", "output": f"22C marker-{marker}"}]
return [{"role": "user", "content": f"continue marker-{marker}"}]
def _chat_turn(message: Mapping[str, JsonValue]) -> tuple[str, tuple[str, ...]]:
if message.get("role") == "tool":
return "tool", (str(message.get("tool_call_id")),)
return str(message["role"]), tuple(sorted(_tool_call_ids(message)))
def _expected_chat_turns(marker: str, shape: StoredShape) -> tuple[tuple[str, tuple[str, ...]], ...]:
match shape:
case "tool_use":
return (("user", ()), ("assistant", (f"toolu_{marker}",)), ("tool", (f"toolu_{marker}",)))
case "search_only":
return (("user", ()), ("user", ()))
case "search_and_text":
return (("user", ()), ("assistant", ()), ("user", ()))
def _assert_converse_follow_up(messages: Sequence[Mapping[str, JsonValue]], marker: str) -> None:
assert [message["role"] for message in messages] == ["user", "assistant", "user"], messages
assert _converse_ids(messages[1], "toolUse") == {f"toolu_{marker}"}, messages
assert _converse_ids(messages[2], "toolResult") == {f"toolu_{marker}"}, messages
def _off_anthropic_cells() -> tuple[ParameterSet, ...]:
cases: Final[tuple[tuple[OffAnthropicRoute, StoredShape, bool], ...]] = (
("mistral", "search_only", False),
("mistral", "search_and_text", False),
("mistral", "search_and_text", True),
("bedrock_converse", "tool_use", False),
)
return tuple(
pytest.param(route, shape, stream, id=f"{route}-{shape}-{'stream' if stream else 'sync'}")
for route, shape, stream in cases
)
@pytest.mark.timeout(_OWNED_PROXY_CELL_SECONDS)
@pytest.mark.parametrize(("route", "shape", "stream"), _off_anthropic_cells())
def test_a_follow_up_off_anthropic_drops_the_stored_search_call_and_is_served(
owned_rig: _OwnedRig, route: OffAnthropicRoute, shape: StoredShape, stream: bool
) -> None:
marker: Final = uuid.uuid4().hex
proxy: Final = owned_rig.proxy.gateway
with (
wire_server(_stored_turn_vendor(shape)) as claude_wire,
wire_server(_strict_vendor(route)) as follow_up_wire,
proxy.scenario() as scenario,
):
claude: Final = scenario.model(
model=f"anthropic/{_CLAUDE}", api_base=claude_wire.url, api_key=cc.ANTHROPIC_API_KEY
)
target: Final = _off_anthropic_deployment(scenario, route, follow_up_wire.url)
response_id: Final = _stored_first_turn(proxy, claude, marker)
follow_up: Final = proxy.request(
"POST",
"/v1/responses",
{
**_body(target, _Call("responses", stream, marker)),
"previous_response_id": response_id,
"input": _follow_up_input(marker, shape),
},
)
assert follow_up.status_code == 200, follow_up.text
assert f"answered marker-{marker}" in follow_up.text, follow_up.text
(request,) = _marked_posts(follow_up_wire.drain(), marker)
sent: Final = request.body.decode()
assert "srvtoolu_" not in sent and "tool_search" not in sent and "defer_loading" not in sent, sent
body: Final = rv.JSON_OBJECT.validate_json(request.body)
messages: Final = rv.ITEMS.validate_python(body["messages"])
if route == "bedrock_converse":
_assert_converse_follow_up(messages, marker)
return
assert tuple(_chat_turn(message) for message in messages) == _expected_chat_turns(marker, shape), messages
tools: Final = rv.ITEMS.validate_python(body["tools"])
assert [rv.JSON_OBJECT.validate_python(tool["function"])["name"] for tool in tools] == ["get_weather"], tools
if shape == "search_and_text":
assert f"found marker-{marker}" in json.dumps(messages[1]), messages
@pytest.mark.timeout(_OWNED_PROXY_CELL_SECONDS)
def test_a_follow_up_on_anthropic_that_answers_the_stored_search_call_replays_it_with_its_answer(
owned_rig: _OwnedRig,
) -> None:
marker: Final = uuid.uuid4().hex
proxy: Final = owned_rig.proxy.gateway
with wire_server(_stored_turn_vendor("tool_use")) as claude_wire, proxy.scenario() as scenario:
claude: Final = scenario.model(
model=f"anthropic/{_CLAUDE}", api_base=claude_wire.url, api_key=cc.ANTHROPIC_API_KEY
)
response_id: Final = _stored_first_turn(proxy, claude, marker)
answers: Final[list[JsonValue]] = [
{"type": "function_call_output", "call_id": f"srvtoolu_{marker}", "output": f"get_weather marker-{marker}"},
{"type": "function_call_output", "call_id": f"toolu_{marker}", "output": f"22C marker-{marker}"},
]
follow_up: Final = proxy.request(
"POST",
"/v1/responses",
{**_body(claude, _Call("responses", False, marker)), "previous_response_id": response_id, "input": answers},
)
assert follow_up.status_code == 200, follow_up.text
opening, replay = _marked_posts(claude_wire.drain(), marker)
_assert_forwarded_with_tool_search(opening)
_assert_forwarded_with_tool_search(replay)
messages: Final = rv.ITEMS.validate_python(rv.JSON_OBJECT.validate_json(replay.body)["messages"])
stored_turn: Final = next(turn for turn in messages if turn["role"] == "assistant")
blocks: Final = rv.ITEMS.validate_python(stored_turn["content"])
assert [(block.get("id"), block.get("name")) for block in blocks if block["type"] != "text"] == [
(f"srvtoolu_{marker}", _REGEX_TOOL_NAME),
(f"toolu_{marker}", "get_weather"),
], blocks
assert sorted(str(block["tool_use_id"]) for block in _tool_results(messages)) == [
f"srvtoolu_{marker}",
f"toolu_{marker}",
], messages
@pytest.fixture(scope="module")
def drop_params_proxy(tmp_path_factory: pytest.TempPathFactory) -> Iterator[OwnedProxy]:
directory: Final = tmp_path_factory.mktemp("tool-search-drop-params")
config: Final = rv.JSON_OBJECT.validate_python(
yaml.safe_load(Path("tests/integration/proxy_config.yaml").read_text())
)
settings: Final = rv.JSON_OBJECT.validate_python(config["litellm_settings"])
path: Final = directory / "responses-bridge-tool-search-drop-params.yaml"
path.write_text(yaml.safe_dump({**config, "litellm_settings": {**settings, "drop_params": True}}))
with (
gateway_from_environment() as gateway,
owned_proxy_process(gateway, directory, {}, config=path, workers=2) as owned,
):
yield owned
@pytest.mark.timeout(_OWNED_PROXY_CELL_SECONDS)
@pytest.mark.parametrize("request_drop_params", [..., False], ids=["absent", "false"])
def test_a_global_drop_params_drops_a_malformed_defer_loading_and_serves_the_search_call(
drop_params_proxy: OwnedProxy, request_drop_params: bool | EllipsisType
) -> None:
marker: Final = uuid.uuid4().hex
proxy: Final = drop_params_proxy.gateway
with wire_server(_Vendor().respond) as wire, proxy.scenario() as scenario:
model: Final = scenario.model(model=f"anthropic/{_CLAUDE}", api_base=wire.url, api_key=cc.ANTHROPIC_API_KEY)
opening: Final = _body(model, _Call("responses", False, marker))
lookup: Final[dict[str, JsonValue]] = {
"type": "function",
"name": "lookup_order",
"description": "Look up an order",
"parameters": _ORDER_PARAMETERS,
"defer_loading": "true",
}
flag: Final[Mapping[str, JsonValue]] = (
{} if isinstance(request_drop_params, EllipsisType) else {"drop_params": request_drop_params}
)
response: Final = proxy.request(
"POST",
"/v1/responses",
{**opening, "tools": [*rv.ITEMS.validate_python(opening["tools"]), lookup], **flag},
)
_assert_carries_the_search_call(
_Served(_Call("responses", False, marker), response.status_code, response.text, "")
)
(request,) = _marked_posts(wire.drain(), marker)
tools: Final = rv.ITEMS.validate_python(rv.JSON_OBJECT.validate_json(request.body)["tools"])
assert tools[0] == dict(_REGEX_TOOL), tools
assert [(tool["name"], tool.get("defer_loading")) for tool in tools[1:]] == [
("get_weather", True),
("lookup_order", None),
], tools

View file

@ -29,6 +29,7 @@ _CACHE_BUST: Final[Mapping[str, JsonValue]] = MappingProxyType({"cache": {"no-ca
_JSON_OBJECT: Final = TypeAdapter(dict[str, JsonValue])
_ITEMS: Final = TypeAdapter(list[dict[str, JsonValue]])
_EMPTY_TURN: Final[Mapping[str, JsonValue]] = MappingProxyType({"role": "user", "content": ""})
_EARLIER_PAIRS: Final = 200
_WEATHER_PARAMETERS: Final[dict[str, JsonValue]] = {
"type": "object",
"properties": {"city": {"type": "string"}},
@ -283,6 +284,56 @@ def test_a_replayed_search_call_is_dropped_only_when_nothing_answers_it(
_assert_wire(call, request, body)
def _earlier_pairs(marker: str) -> Iterator[JsonValue]:
for index in range(_EARLIER_PAIRS):
call_id = f"toolu_{marker}_{index}"
call: JsonValue = {
"id": call_id,
"type": "function",
"function": {"name": "get_weather", "arguments": '{"city": "Paris"}'},
}
yield {"role": "assistant", "content": f"step {index}", "tool_calls": [call]}
yield _tool_message(call_id, "22C")
def _long_history(marker: str, answers: Sequence[Mapping[str, JsonValue]]) -> tuple[JsonValue, ...]:
first, *rest = _history(marker, answers)
return (first, *_earlier_pairs(marker), *rest)
def _last_assistant_tool_uses(messages: Sequence[Mapping[str, JsonValue]]) -> tuple[tuple[str, str], ...]:
last: Final = [turn for turn in messages if turn["role"] == "assistant"][-1]
blocks: Final = _ITEMS.validate_python(last["content"])
return tuple(sorted((str(block["id"]), str(block["name"])) for block in blocks if block["type"] == "tool_use"))
@pytest.mark.parametrize("answer", ["unanswered", "answered"])
def test_a_search_call_after_two_hundred_answered_tool_turns_is_paired_only_by_its_own_answers(
gateway: Gateway, answer: Answer
) -> None:
call: Final = _Call("anthropic", answer, False, uuid.uuid4().hex)
with wire_server(_vendor) as wire, gateway.scenario() as scenario:
model: Final = _deployment(scenario, "anthropic", wire.url)
history: Final = _long_history(call.marker, _answers(call.marker, answer))
response: Final = _raw(gateway, "/v1/chat/completions", _chat_body(model, call.marker, history, False))
_assert_served(response, call.marker, False)
request, body = _only_request(wire)
assert request.target == "/v1/messages", request.target
_assert_tools_forwarded("anthropic", body)
messages: Final = _ITEMS.validate_python(body["messages"])
earlier: Final = [f"toolu_{call.marker}_{index}" for index in range(_EARLIER_PAIRS)]
weather: Final = (f"toolu_{call.marker}", "get_weather")
search: Final = (f"srvtoolu_{call.marker}", _REGEX_TOOL_NAME)
match answer:
case "unanswered":
assert "srvtoolu_" not in json.dumps(messages), messages[-4:]
assert _last_assistant_tool_uses(messages) == (weather,), messages[-4:]
assert sorted(_tool_results(messages)) == sorted([*earlier, weather[0]]), messages[-4:]
case "answered":
assert _last_assistant_tool_uses(messages) == (search, weather), messages[-4:]
assert sorted(_tool_results(messages)) == sorted([*earlier, search[0], weather[0]]), messages[-4:]
def test_a_function_role_answer_keeps_the_replayed_search_call(gateway: Gateway) -> None:
call: Final = _Call("anthropic", "answered", False, uuid.uuid4().hex)
answer: Final[dict[str, JsonValue]] = {

View file

@ -1,10 +1,11 @@
import asyncio
import itertools
import json
import uuid
from collections.abc import Callable, Mapping, Sequence
from dataclasses import dataclass
from types import MappingProxyType
from typing import Final, Literal
from typing import Final, Literal, TypeAlias
from urllib.parse import unquote
import httpx
@ -23,6 +24,8 @@ _GEMINI: Final = "gemini-2.5-flash"
_MISTRAL: Final = "mistral-large-latest"
_REGEX_TOOL_TYPE: Final = "tool_search_tool_regex_20251119"
_REGEX_TOOL_NAME: Final = "tool_search_tool_regex"
_SEARCH_TOOL_TYPES: Final = frozenset({_REGEX_TOOL_TYPE, "tool_search_tool_bm25_20251119"})
_AZURE_MESSAGES_PATH: Final = "/anthropic/v1/messages"
_ALL_DEFERRED_ERROR: Final = "At least one tool must have defer_loading=false"
_DEFERRED_NAMES: Final = ("get_weather", "finance__get_stock_price")
_CACHE_BUST: Final[Mapping[str, JsonValue]] = MappingProxyType({"cache": {"no-cache": True}})
@ -44,8 +47,9 @@ _EMAIL_PARAMETERS: Final[dict[str, JsonValue]] = {
"required": ["to"],
}
Route = Literal["gemini", "bedrock_converse", "mistral", "unlisted_claude", "anthropic"]
Route = Literal["gemini", "bedrock_converse", "mistral", "unlisted_claude", "anthropic", "azure_ai"]
Search = Literal["absent", "dropped"]
NativeSearch: TypeAlias = Literal["regex", "bm25"]
@dataclass(frozen=True, slots=True)
@ -70,6 +74,10 @@ def _tool_search(**fields: JsonValue) -> dict[str, JsonValue]:
return {"type": "tool_search", **fields}
def _native_search_tool(kind: NativeSearch) -> Mapping[str, JsonValue]:
return MappingProxyType({"type": f"tool_search_tool_{kind}_20251119", "name": f"tool_search_tool_{kind}"})
def _function_tool(defer_loading: JsonValue = True) -> dict[str, JsonValue]:
return {
"type": "function",
@ -84,6 +92,10 @@ def _loaded_tool() -> dict[str, JsonValue]:
return {"type": "function", "name": "send_email", "description": "Send an email", "parameters": _EMAIL_PARAMETERS}
def _custom_tool() -> dict[str, JsonValue]:
return {"type": "custom", "name": "run_sql", "description": "Run a SQL query", "defer_loading": True}
def _namespace_tool() -> dict[str, JsonValue]:
return {
"type": "namespace",
@ -249,9 +261,9 @@ def _claude(request: Request) -> Reply:
marker: Final = rv.newest_marker(request.body.decode()) or "health"
body: Final = _JSON_OBJECT.validate_json(request.body)
tools: Final = _request_tools(body)
regex_tools: Final = [tool for tool in tools if tool.get("type") == _REGEX_TOOL_TYPE]
function_tools: Final = [tool for tool in tools if tool.get("type") != _REGEX_TOOL_TYPE]
if not regex_tools and function_tools and all(tool.get("defer_loading") is True for tool in function_tools):
search_tools: Final = [tool for tool in tools if tool.get("type") in _SEARCH_TOOL_TYPES]
function_tools: Final = [tool for tool in tools if tool.get("type") not in _SEARCH_TOOL_TYPES]
if not search_tools and function_tools and all(tool.get("defer_loading") is True for tool in function_tools):
return _anthropic_error(400, f"tools: {_ALL_DEFERRED_ERROR} marker-{marker}")
if body.get("stream") is True:
return Reply(content_type="text/event-stream", chunks=sse_chunks(_claude_events(marker)))
@ -266,7 +278,7 @@ def _vendor(route: Route) -> Callable[[Request], Reply]:
return _converse
case "mistral":
return _mistral
case "unlisted_claude" | "anthropic":
case "unlisted_claude" | "anthropic" | "azure_ai":
return _claude
@ -290,6 +302,10 @@ def _deployment(scenario: Scenario, route: Route, wire_url: str) -> str:
)
case "anthropic":
return scenario.model(model=f"anthropic/{_CLAUDE}", api_base=wire_url, api_key=cc.ANTHROPIC_API_KEY)
case "azure_ai":
return scenario.model(
model=f"azure_ai/{_CLAUDE}", api_base=f"{wire_url}{_AZURE_MESSAGES_PATH}", api_key="synthetic-azure-key"
)
def _responses_body(
@ -343,6 +359,9 @@ def _assert_target(route: Route, stream: bool, request: Request, body: Mapping[s
case "unlisted_claude" | "anthropic":
assert request.target == "/v1/messages", request.target
assert body.get("stream", False) is stream, body
case "azure_ai":
assert request.target == _AZURE_MESSAGES_PATH, request.target
assert body.get("stream", False) is stream, body
def _wire_tool_names(route: Route, body: Mapping[str, JsonValue]) -> list[str]:
@ -360,7 +379,7 @@ def _wire_tool_names(route: Route, body: Mapping[str, JsonValue]) -> list[str]:
str(_JSON_OBJECT.validate_python(tool["function"])["name"])
for tool in _ITEMS.validate_python(body["tools"])
]
case "unlisted_claude" | "anthropic":
case "unlisted_claude" | "anthropic" | "azure_ai":
return [str(tool["name"]) for tool in _ITEMS.validate_python(body["tools"])]
@ -434,6 +453,74 @@ def test_the_supported_anthropic_route_keeps_each_tools_own_defer_loading_with_t
assert "defer_loading" not in json.dumps(sent[3]), sent
def _native_cells() -> tuple[ParameterSet, ...]:
routes: Final[tuple[tuple[Route, NativeSearch], ...]] = (
("anthropic", "regex"),
("anthropic", "bm25"),
("azure_ai", "regex"),
)
return tuple(
pytest.param(route, kind, stream, id=f"{route}-{kind}-{'stream' if stream else 'sync'}")
for (route, kind), stream in itertools.product(routes, (False, True))
)
@pytest.mark.parametrize(("route", "kind", "stream"), _native_cells())
def test_a_native_anthropic_search_tool_reaches_the_wire_untouched_and_keeps_every_deferral(
gateway: Gateway, route: Route, kind: NativeSearch, stream: bool
) -> None:
marker: Final = uuid.uuid4().hex
with wire_server(_claude) as wire, gateway.scenario() as scenario:
model: Final = _deployment(scenario, route, wire.url)
tools: Final = (_native_search_tool(kind), _function_tool(), _namespace_tool())
response: Final = _raw(gateway, _responses_body(model, marker, tools, stream))
_assert_served(response, marker, stream)
request, body = _only_request(wire)
_assert_target(route, stream, request, body)
sent: Final = _ITEMS.validate_python(body["tools"])
assert sent[0] == dict(_native_search_tool(kind)), sent
assert [(tool["name"], tool.get("defer_loading")) for tool in sent[1:]] == [
(name, True) for name in _DEFERRED_NAMES
], sent
@pytest.mark.parametrize("stream", [False, True], ids=["sync", "stream"])
@pytest.mark.parametrize("route", ["anthropic", "mistral"])
def test_a_deferred_custom_tool_keeps_its_deferral_only_while_a_search_tool_goes_out(
gateway: Gateway, route: Route, stream: bool
) -> None:
marker: Final = uuid.uuid4().hex
with wire_server(_vendor(route)) as wire, gateway.scenario() as scenario:
model: Final = _deployment(scenario, route, wire.url)
tools: Final = (_tool_search(), _custom_tool(), _loaded_tool())
response: Final = _raw(gateway, _responses_body(model, marker, tools, stream))
_assert_served(response, marker, stream)
request, body = _only_request(wire)
_assert_target(route, stream, request, body)
if route == "mistral":
assert _wire_tool_names(route, body) == ["run_sql", "send_email"], body
assert "defer_loading" not in request.body.decode(), body
assert "tool_search" not in request.body.decode(), body
return
sent: Final = _ITEMS.validate_python(body["tools"])
assert sent[0] == {"type": _REGEX_TOOL_TYPE, "name": _REGEX_TOOL_NAME}, sent
assert [(tool["name"], tool.get("defer_loading")) for tool in sent[1:]] == [
("run_sql", True),
("send_email", None),
], sent
def test_bedrock_converse_skips_a_native_search_tool_and_serves_the_deferred_tools(gateway: Gateway) -> None:
call: Final = _Call("bedrock_converse", False, uuid.uuid4().hex)
with wire_server(_converse) as wire, gateway.scenario() as scenario:
model: Final = _deployment(scenario, "bedrock_converse", wire.url)
tools: Final = (_native_search_tool("regex"), _function_tool(), _namespace_tool())
response: Final = _raw(gateway, _responses_body(model, call.marker, tools, False))
_assert_served(response, call.marker, False)
request, body = _only_request(wire)
_assert_undeferred_wire(call, request, body)
@pytest.mark.parametrize("route", ["gemini", "mistral"])
def test_a_non_boolean_defer_loading_is_refused_with_400_on_a_route_without_tool_search(
gateway: Gateway, route: Route
@ -455,6 +542,108 @@ def test_a_non_boolean_defer_loading_is_refused_with_400_on_a_route_without_tool
_assert_undeferred_wire(follow_up, request, body)
def _text_parts(marker: str) -> tuple[str, ...]:
return ("part one ", "part two ", f"marker-{marker}")
def _split_claude_events(marker: str) -> tuple[Event, ...]:
opened: Final = {**_claude_message(marker), "content": [], "stop_reason": None}
deltas: Final[tuple[Event, ...]] = tuple(
{"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": part}}
for part in _text_parts(marker)
)
return (
{"type": "message_start", "message": opened},
{"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}},
*deltas,
{"type": "content_block_stop", "index": 0},
{
"type": "message_delta",
"delta": {"stop_reason": "end_turn", "stop_sequence": None},
"usage": {"output_tokens": 7},
},
{"type": "message_stop"},
)
def _split_chat_chunks(marker: str) -> tuple[bytes, ...]:
head: Final[Mapping[str, JsonValue]] = {
"id": f"cmpl-{marker}",
"object": "chat.completion.chunk",
"created": 1,
"model": _MISTRAL,
}
parts: Final = tuple(
rv.chat_sse(
{
**head,
"choices": [{"index": 0, "delta": {"role": "assistant", "content": part}, "finish_reason": None}],
}
)
for part in _text_parts(marker)
)
return (
*parts,
rv.chat_sse(
{
**head,
"choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}],
"usage": {"prompt_tokens": 5, "completion_tokens": 3, "total_tokens": 8},
}
),
b"data: [DONE]\n\n",
)
def _split_vendor(route: Route) -> Callable[[Request], Reply]:
def respond(request: Request) -> Reply:
if request.method != "POST":
return Reply()
marker: Final = rv.newest_marker(request.body.decode()) or "health"
if route == "mistral":
return Reply(content_type="text/event-stream", chunks=_split_chat_chunks(marker))
return Reply(content_type="text/event-stream", chunks=sse_chunks(_split_claude_events(marker)))
return respond
def _frames(text: str) -> tuple[dict[str, JsonValue], ...]:
return tuple(_JSON_OBJECT.validate_json(line[6:]) for line in text.splitlines() if line.startswith("data: {"))
def _message_texts(items: Sequence[Mapping[str, JsonValue]]) -> tuple[str, ...]:
return tuple(
"".join(str(part["text"]) for part in _ITEMS.validate_python(item["content"]))
for item in items
if item["type"] == "message"
)
@pytest.mark.parametrize("route", ["anthropic", "mistral"])
def test_a_streamed_answer_closes_its_message_item_with_every_streamed_delta(gateway: Gateway, route: Route) -> None:
marker: Final = uuid.uuid4().hex
with wire_server(_split_vendor(route)) as wire, gateway.scenario() as scenario:
model: Final = _deployment(scenario, route, wire.url)
tools: Final = (_tool_search(), _function_tool(), _loaded_tool())
response: Final = _raw(gateway, _responses_body(model, marker, tools, True))
assert response.status_code == 200, response.text
frames: Final = _frames(response.text)
expected: Final = "".join(_text_parts(marker))
deltas: Final = [str(frame["delta"]) for frame in frames if frame.get("type") == "response.output_text.delta"]
assert "".join(deltas) == expected, frames
done: Final = [
_JSON_OBJECT.validate_python(frame["item"])
for frame in frames
if frame.get("type") == "response.output_item.done"
]
assert _message_texts(done) == (expected,), done
(completed,) = [frame for frame in frames if frame.get("type") == "response.completed"]
output: Final = _ITEMS.validate_python(_JSON_OBJECT.validate_python(completed["response"])["output"])
assert _message_texts(output) == (expected,), output
request, body = _only_request(wire)
_assert_target(route, True, request, body)
async def _send(client: httpx.AsyncClient, key: str, model: str, call: _Call) -> _Served:
body: Final = _responses_body(model, call.marker, (_function_tool(), _namespace_tool()), call.stream)
async with client.stream(

View file

@ -1,26 +1,29 @@
import asyncio
import itertools
import json
import os
import uuid
from collections.abc import Iterator, Mapping, Sequence
from dataclasses import dataclass
from types import EllipsisType, MappingProxyType
from typing import Final, Literal
from typing import Final, Literal, TypeAlias
from urllib.parse import unquote
import anthropic
import httpx
import openai
import pytest
from _pytest.mark.structures import ParameterSet
from anthropic.types.beta import BetaToolUnionParam
from integration._support import claude_code as cc
from integration._support import responses_vendor as rv
from integration._support.anthropic_thinking import BEDROCK_MODEL, EVENT_STREAM, MODEL, Event, aws_chunks, sse_chunks
from integration._support.client import Gateway, Scenario, eventually
from integration._support.client import Gateway, Scenario, eventually, object_value
from integration._support.database import read_rows
from integration._support.vertex import service_account_json
from integration._support.wire import Reply, Request, Wire, wire_server
from integration.providers.test_bedrock_auth_wire import RESPONSE as CONVERSE_RESPONSE
from openai.types.responses import ResponseToolSearchCall
from pydantic import JsonValue, TypeAdapter
from redis import Redis
@ -35,6 +38,7 @@ _BEDROCK_REGEX_TOOL: Final[Mapping[str, JsonValue]] = MappingProxyType(
)
_REGEX_TOOL_TYPES: Final = frozenset({_REGEX_TOOL_TYPE, _REGEX_TOOL_NAME})
_TOOL_SEARCH_BETA_PREFIXES: Final = ("advanced-tool-use", "tool-search-tool")
_TOOL_SEARCH_CALL_KEYS: Final = frozenset({"id", "type", "call_id", "arguments", "execution", "status"})
_SEARCH_ARGUMENTS: Final = '{"pattern": "weather|stock"}'
_TRUNCATED_ARGUMENTS: Final = '{"pattern": '
_ALL_DEFERRED_ERROR: Final = "At least one tool must have defer_loading=false"
@ -64,8 +68,26 @@ _GEMINI_REPLY: Final[dict[str, JsonValue]] = {
"modelVersion": _GEMINI,
}
_ORDER_PARAMETERS: Final[dict[str, JsonValue]] = {
"type": "object",
"properties": {"order_id": {"type": "string"}},
"required": ["order_id"],
}
_MALFORMED_FLAGS: Final[Mapping[str, JsonValue]] = MappingProxyType(
{"defer_loading": "true", "allowed_callers": "direct"}
)
_WELL_FORMED_FLAGS: Final[Mapping[str, JsonValue]] = MappingProxyType(
{"defer_loading": True, "allowed_callers": ["direct"]}
)
_FLAG_REFUSALS: Final[Mapping[str, str]] = MappingProxyType(
{"defer_loading": "defer_loading must be a boolean", "allowed_callers": "allowed_callers must be a list of strings"}
)
_Client = Literal["sdk", "async_sdk", "raw"]
Provider = Literal["anthropic", "bedrock_invoke", "vertex_ai"]
FlaggedKind: TypeAlias = Literal["function", "custom", "namespace_member"]
ToolFlag: TypeAlias = Literal["defer_loading", "allowed_callers"]
DropSource: TypeAlias = Literal["off", "request", "deployment", "null", "request_string"]
@dataclass(frozen=True, slots=True)
@ -587,6 +609,53 @@ def test_bedrock_invoke_and_vertex_claude_keep_the_tool_search_tool(
_assert_spend_rows(model, (str(answer.payload["id"]),))
@pytest.mark.parametrize("provider", ["anthropic", "bedrock_invoke"])
def test_a_non_streaming_tool_search_call_item_carries_only_its_own_keys(gateway: Gateway, provider: Provider) -> None:
marker: Final = uuid.uuid4().hex
with wire_server(_Vendor().respond) as wire, gateway.scenario() as scenario:
model: Final = _deployment(scenario, provider, wire.url, gateway.upstream_url)
tools: Final = (_tool_search(), _function_tool(), _namespace_tool())
response: Final = _raw(
gateway, "/v1/responses", {**_responses_body(model, marker, tools, False), **_CACHE_BUST}
)
assert response.status_code == 200, response.text
payload: Final = _JSON_OBJECT.validate_json(response.content)
_assert_search_call_output(payload, marker)
(search,) = [item for item in _tool_items(payload) if item["type"] == "tool_search_call"]
assert frozenset(search) == _TOOL_SEARCH_CALL_KEYS, search
request, body = _only_request(wire)
_assert_claude_wire(provider, False, request, body, _DEFERRED_NAMES)
@pytest.mark.parametrize("provider", ["anthropic", "bedrock_invoke"])
def test_a_streamed_tool_search_call_item_carries_only_its_own_keys(gateway: Gateway, provider: Provider) -> None:
marker: Final = uuid.uuid4().hex
with wire_server(_Vendor().respond) as wire, gateway.scenario() as scenario:
model: Final = _deployment(scenario, provider, wire.url, gateway.upstream_url)
tools: Final = (_tool_search(), _function_tool(), _namespace_tool())
answer: Final = _raw_answer(gateway, _responses_body(model, marker, tools, True))
_assert_search_call_output(answer.payload, marker)
_assert_search_call_events(answer.events, marker, "completed")
streamed: Final = [_item_of(event) for event in _item_events(answer.events, "tool_search_call")]
(completed,) = [item for item in _tool_items(answer.payload) if item["type"] == "tool_search_call"]
assert [frozenset(item) for item in (*streamed, completed)] == [_TOOL_SEARCH_CALL_KEYS] * 3, streamed
request, body = _only_request(wire)
_assert_claude_wire(provider, True, request, body, _DEFERRED_NAMES)
def _schema_properties(schemas: Mapping[str, JsonValue], name: str) -> frozenset[str]:
return frozenset(object_value(object_value(schemas[name])["properties"]))
def test_the_openapi_schema_keeps_the_sdk_tool_search_call_beside_the_proxy_output_item(gateway: Gateway) -> None:
schemas: Final = object_value(object_value(gateway.get("/openapi.json")["components"])["schemas"])
proxy_item: Final = _schema_properties(schemas, "OutputToolSearchCall")
sdk_item: Final = _schema_properties(schemas, "ResponseToolSearchCall")
assert proxy_item == _TOOL_SEARCH_CALL_KEYS, schemas.get("OutputToolSearchCall")
assert sdk_item == frozenset(ResponseToolSearchCall.model_fields), schemas.get("ResponseToolSearchCall")
assert [name for name in schemas if name.endswith("ToolSearchCall") and "__" in name] == [], sorted(schemas)
def test_search_arguments_that_never_parse_stream_an_incomplete_tool_search_call(gateway: Gateway) -> None:
marker: Final = uuid.uuid4().hex
with wire_server(_Vendor(search_arguments=_TRUNCATED_ARGUMENTS).respond) as wire, gateway.scenario() as scenario:
@ -681,6 +750,24 @@ def test_a_cache_hit_still_carries_the_tool_search_call(gateway: Gateway) -> Non
assert sorted(str(row["cache_hit"]) == "True" for row in rows) == [False, True], rows
def test_a_cache_hit_tool_search_call_item_carries_only_its_own_keys(gateway: Gateway) -> None:
marker: Final = uuid.uuid4().hex
with wire_server(_Vendor().respond) as wire, gateway.scenario() as scenario:
model: Final = _deployment(scenario, "anthropic", wire.url, gateway.upstream_url)
body: Final = _responses_body(model, marker, (_tool_search(), _function_tool()), False)
first: Final = _raw(gateway, "/v1/responses", body)
assert first.status_code == 200, first.text
assert len(wire.drain()) == 1
_await_cached(f"srvtoolu_{marker}")
hit: Final = _raw(gateway, "/v1/responses", body)
assert hit.status_code == 200, hit.text
assert wire.drain() == (), hit.text
payload: Final = _JSON_OBJECT.validate_json(hit.content)
_assert_search_call_output(payload, marker)
(search,) = [item for item in _tool_items(payload) if item["type"] == "tool_search_call"]
assert frozenset(search) == _TOOL_SEARCH_CALL_KEYS, search
def test_model_info_reports_tool_search_support_for_the_claude_deployment(gateway: Gateway) -> None:
with wire_server(_Vendor().respond) as wire, gateway.scenario() as scenario:
model: Final = _deployment(scenario, "anthropic", wire.url, gateway.upstream_url)
@ -945,6 +1032,114 @@ def test_a_malformed_allowed_callers_on_a_namespace_member_is_refused_with_400(
assert wire.drain() == (), refused.text
def _tool_flags(flag: ToolFlag) -> Mapping[str, JsonValue]:
return {key: _MALFORMED_FLAGS[key] if key == flag else value for key, value in _WELL_FORMED_FLAGS.items()}
def _flagged_tool(kind: FlaggedKind, flag: ToolFlag) -> dict[str, JsonValue]:
match kind:
case "function":
return {
"type": "function",
"name": "lookup_order",
"description": "Look up an order",
"parameters": _ORDER_PARAMETERS,
**_tool_flags(flag),
}
case "custom":
return {"type": "custom", "name": "run_sql", "description": "Run a SQL query", **_tool_flags(flag)}
case "namespace_member":
return _namespace_tool(**_tool_flags(flag))
def _flagged_wire_name(kind: FlaggedKind) -> str:
match kind:
case "function":
return "lookup_order"
case "custom":
return "run_sql"
case "namespace_member":
return "finance__get_stock_price"
def _request_drop_params(source: DropSource) -> Mapping[str, JsonValue]:
match source:
case "off" | "deployment":
return {}
case "request":
return {"drop_params": True}
case "null":
return {"drop_params": None}
case "request_string":
return {"drop_params": "true"}
def _drop_params_deployment(scenario: Scenario, source: DropSource, wire_url: str) -> str:
if source == "deployment":
return scenario.model(
model=f"anthropic/{_CLAUDE}", api_base=wire_url, api_key=cc.ANTHROPIC_API_KEY, drop_params=True
)
return scenario.model(model=f"anthropic/{_CLAUDE}", api_base=wire_url, api_key=cc.ANTHROPIC_API_KEY)
def _drop_params_cells() -> tuple[ParameterSet, ...]:
kinds: Final[tuple[FlaggedKind, ...]] = ("function", "custom", "namespace_member")
flags: Final[tuple[ToolFlag, ...]] = ("defer_loading", "allowed_callers")
sources: Final[tuple[DropSource, ...]] = ("off", "request", "deployment")
grid: Final = tuple(
pytest.param(kind, flag, source, id=f"{kind}-{flag}-{source}")
for kind, flag, source in itertools.product(kinds, flags, sources)
)
edges: Final = tuple(
pytest.param("function", "defer_loading", source, id=f"function-defer_loading-{source}")
for source in ("null", "request_string")
)
return (*grid, *edges)
def _assert_flag_dropped_on_wire(body: Mapping[str, JsonValue], kind: FlaggedKind, flag: ToolFlag) -> None:
tools: Final = _ITEMS.validate_python(body["tools"])
assert tools[0] == dict(_REGEX_TOOL), tools
(flagged,) = [tool for tool in tools if tool.get("name") == _flagged_wire_name(kind)]
assert flag not in flagged, flagged
kept: Final = "allowed_callers" if flag == "defer_loading" else "defer_loading"
assert flagged[kept] == _WELL_FORMED_FLAGS[kept], flagged
(weather,) = [tool for tool in tools if tool.get("name") == "get_weather"]
assert weather.get("defer_loading") is True, weather
@pytest.mark.parametrize(("kind", "flag", "source"), _drop_params_cells())
def test_drop_params_drops_a_malformed_tool_flag_and_without_it_the_request_is_refused_with_400(
gateway: Gateway, kind: FlaggedKind, flag: ToolFlag, source: DropSource
) -> None:
marker: Final = uuid.uuid4().hex
with wire_server(_Vendor().respond) as wire, gateway.scenario() as scenario:
model: Final = _drop_params_deployment(scenario, source, wire.url)
tools: Final = (_tool_search(), _function_tool(), _flagged_tool(kind, flag))
response: Final = _raw(
gateway,
"/v1/responses",
{**_responses_body(model, marker, tools, False), **_CACHE_BUST, **_request_drop_params(source)},
)
if source in ("off", "null"):
assert response.status_code == 400, response.text
assert "litellm.BadRequestError" in response.text, response.text
assert _FLAG_REFUSALS[flag] in response.text, response.text
assert wire.drain() == (), response.text
follow_up: Final = uuid.uuid4().hex
served: Final = _raw_answer(
gateway, _responses_body(model, follow_up, (_tool_search(), _function_tool()), False)
)
_assert_search_call_output(served.payload, follow_up)
assert len(wire.drain()) == 1
return
assert response.status_code == 200, response.text
_assert_search_call_output(_JSON_OBJECT.validate_json(response.content), marker)
request, body = _only_request(wire)
assert request.target == "/v1/messages", request.target
_assert_flag_dropped_on_wire(body, kind, flag)
def test_duplicate_tool_search_tools_reach_claude_and_its_refusal_reaches_the_caller(gateway: Gateway) -> None:
marker: Final = uuid.uuid4().hex
with wire_server(_Vendor().respond) as wire, gateway.scenario() as scenario:

View file

@ -3,6 +3,7 @@ import json
import logging
import os
import re
import time
from typing import Final, List
from unittest.mock import MagicMock, patch
@ -7176,3 +7177,24 @@ def test_anthropic_messages_pt_keeps_a_replayed_tool_search_call_the_caller_answ
"srvtoolu_01Search",
"toolu_01Weather",
]
def _merged_assistant_run_cost(run_length: int) -> float:
messages: Final = [
{"role": "user", "content": "Plan the trip"},
*({"role": "assistant", "content": "Working on it", "tool_calls": []} for _ in range(run_length)),
{"role": "user", "content": "Go on"},
]
def once() -> float:
started: Final = time.perf_counter()
anthropic_messages_pt(messages=messages, model="claude-sonnet-5-5", llm_provider="anthropic")
return time.perf_counter() - started
return min(once() for _ in range(3))
def test_anthropic_messages_pt_merges_a_long_assistant_run_in_linear_time():
growth: Final = _merged_assistant_run_cost(4000) / _merged_assistant_run_cost(1000)
assert growth < 8.0, f"cost grew {growth:.1f}x for 4x the assistant run"

View file

@ -3197,6 +3197,25 @@ class TestOpenAIResponsesHandlerNamespaceTools:
assert result["tools"] == expected_tools
@pytest.mark.asyncio
async def test_scanning_tools_with_malformed_flags_leaves_them_for_the_route_to_judge(self):
data: Final = _codex_request("hi")
data["tools"][0]["defer_loading"] = "false"
data["tools"][1]["tools"][0]["allowed_callers"] = "direct"
data["tools"][2]["defer_loading"] = 1
expected_tools: Final = copy.deepcopy(data["tools"])
guardrail: Final = MockRecordingGuardrail(guardrail_name="test")
result: Final = await OpenAIResponsesHandler().process_input_messages(data, guardrail)
assert result["tools"] == expected_tools
assert [tool["function"]["name"] for tool in guardrail.seen_inputs[0]["tools"]] == [
"get_weather",
"mcp__confluence__confluence_get_page",
"mcp__confluence__confluence_search",
"apply_patch",
]
@pytest.mark.asyncio
async def test_appending_guardrail_keeps_namespace_and_adds_tool(self):
data = _codex_request("hi")

View file

@ -1,5 +1,6 @@
import json
from collections.abc import Mapping, Sequence
from itertools import chain
from typing import Final
from unittest.mock import AsyncMock, MagicMock, patch
@ -17,7 +18,7 @@ from litellm.responses.litellm_completion_transformation.transformation import (
LiteLLMCompletionResponsesConfig,
)
from litellm.types.llms.openai import ResponsesAPIStreamEvents
from litellm.types.utils import ModelResponse
from litellm.types.utils import Message, ModelResponse
def test_response_api_handler_merges_metadata_and_service_tier_without_error():
@ -304,6 +305,179 @@ async def test_previous_response_id_without_instructions_carries_the_latest_ones
assert [block.text for block in anthropic.request.system] == [expected_system]
_SEARCH_CALL: Final = {
"id": "srvtoolu_search",
"type": "function",
"function": {"name": "tool_search_tool_regex", "arguments": '{"pattern": "weather"}'},
}
_WEATHER_CALL: Final = {
"id": "toolu_weather",
"type": "function",
"function": {"name": "get_weather", "arguments": '{"city": "Tokyo"}'},
}
def _search_turn(tool_calls: Sequence[Mapping[str, object]]) -> Mapping[str, object]:
return {
"request_id": "chatcmpl-search-turn",
"call_type": "aresponses",
"session_id": "session-search",
"proxy_server_request": {"model": _MODEL, "input": "What is the weather in Tokyo?"},
"response": {
"id": "chatcmpl-search-turn",
"object": "chat.completion",
"created": 0,
"model": _MODEL,
"choices": [
{
"index": 0,
"finish_reason": "tool_calls",
"message": {"role": "assistant", "content": None, "tool_calls": list(tool_calls)},
}
],
},
}
async def _follow_up_messages(
tool_calls: Sequence[Mapping[str, object]], model: str, custom_llm_provider: str
) -> Sequence[object]:
return await _follow_up_messages_after(_search_turn(tool_calls), model, custom_llm_provider)
async def _follow_up_messages_after(
spend_log: Mapping[str, object], model: str, custom_llm_provider: str
) -> Sequence[object]:
with patch("litellm.proxy.proxy_server.prisma_client", _FakePrismaClient([spend_log])):
request: Final = await LiteLLMCompletionResponsesConfig.async_responses_api_session_handler(
previous_response_id="chatcmpl-search-turn",
litellm_completion_request={
"model": model,
"custom_llm_provider": custom_llm_provider,
"messages": [{"role": "user", "content": "And in Osaka?"}],
},
instructions=None,
)
return request["messages"]
def _replayed_tool_call_ids(messages: Sequence[object]) -> list[str]:
assistant_turns: Final = [message for message in messages if isinstance(message, Message)]
return [tool_call.id for turn in assistant_turns for tool_call in turn.tool_calls or ()]
@pytest.mark.asyncio
@pytest.mark.parametrize(
("model", "custom_llm_provider", "expected_tool_call_ids"),
[
("mistral/mistral-small-latest", "mistral", ["toolu_weather"]),
("gemini/gemini-2.5-flash", "gemini", ["toolu_weather"]),
("databricks/databricks-claude-sonnet-4-5", "databricks", ["toolu_weather"]),
(_MODEL, "anthropic", ["srvtoolu_search", "toolu_weather"]),
],
)
async def test_previous_response_id_keeps_a_stored_claude_search_call_only_on_an_anthropic_route(
model: str, custom_llm_provider: str, expected_tool_call_ids: list[str]
) -> None:
"""
A follow-up that routes a Claude turn's history elsewhere must not replay Anthropic's server-side
search call as a client tool call, which Mistral answers 400 "Unexpected tool call id"
"""
messages: Final = await _follow_up_messages([_SEARCH_CALL, _WEATHER_CALL], model, custom_llm_provider)
assert _replayed_tool_call_ids(messages) == expected_tool_call_ids
@pytest.mark.asyncio
async def test_previous_response_id_drops_a_claude_turn_that_held_only_a_search_call_off_anthropic() -> None:
messages: Final = await _follow_up_messages([_SEARCH_CALL], "mistral/mistral-small-latest", "mistral")
assert [message["role"] for message in messages] == ["user", "user"]
def _replayed_search_turn(search_answered: bool) -> Mapping[str, object]:
search_result: Final = (
[{"type": "function_call_output", "call_id": "srvtoolu_search", "output": "get_weather"}]
if search_answered
else []
)
return {
**_search_turn([]),
"proxy_server_request": {
"model": _MODEL,
"input": [
{"role": "user", "content": "What is the weather in Tokyo?"},
{
"type": "function_call",
"call_id": "srvtoolu_search",
"name": "tool_search_tool_regex",
"arguments": '{"pattern": "weather"}',
},
*search_result,
{
"type": "function_call",
"call_id": "toolu_weather",
"name": "get_weather",
"arguments": '{"city": "Tokyo"}',
},
{"type": "function_call_output", "call_id": "toolu_weather", "output": '{"temp_c": 18}'},
],
},
"response": {
"id": "chatcmpl-search-turn",
"object": "chat.completion",
"created": 0,
"model": _MODEL,
"choices": [
{
"index": 0,
"finish_reason": "stop",
"message": {"role": "assistant", "content": "It is 18C in Tokyo."},
}
],
},
}
def _tool_call_ids_of(message: object) -> Sequence[str]:
if isinstance(message, Message):
return [tool_call.id or "" for tool_call in message.tool_calls or ()]
if isinstance(message, dict):
return [tool_call["id"] for tool_call in message.get("tool_calls") or ()]
return []
def _history_tool_ids(messages: Sequence[object]) -> tuple[Sequence[str], Sequence[str]]:
called: Final = list(chain.from_iterable(map(_tool_call_ids_of, messages)))
answered: Final = [
message["tool_call_id"] for message in messages if isinstance(message, dict) and message["role"] == "tool"
]
return called, answered
@pytest.mark.asyncio
@pytest.mark.parametrize("search_answered", [False, True])
@pytest.mark.parametrize(
("model", "custom_llm_provider", "expect_search"),
[("mistral/mistral-small-latest", "mistral", False), (_MODEL, "anthropic", True)],
)
async def test_previous_response_id_drops_a_client_replayed_search_call_only_off_anthropic(
model: str, custom_llm_provider: str, expect_search: bool, search_answered: bool
) -> None:
"""
A client can replay Claude's search call as a ``function_call`` input item with its ``srvtoolu_`` id. A later
turn sent elsewhere must not carry it, which OpenAI-compatible providers answer 400 "did not have response messages"
"""
messages: Final = await _follow_up_messages_after(
_replayed_search_turn(search_answered), model, custom_llm_provider
)
called, answered = _history_tool_ids(messages)
search_ids: Final = ["srvtoolu_search"] if expect_search else []
assert called == [*search_ids, "toolu_weather"]
assert answered == [*(search_ids if search_answered else []), "toolu_weather"]
def _anthropic_sse(*events: Mapping[str, object]) -> bytes:
return b"".join(
f"event: {event['type']}\ndata: {json.dumps(event)}\n\n".encode() for event in events

View file

@ -1,4 +1,5 @@
import json
from collections.abc import Mapping
from copy import deepcopy
from typing import Final, Literal
@ -20,7 +21,7 @@ from litellm.responses.litellm_completion_transformation.transformation import (
LiteLLMCompletionResponsesConfig,
)
from litellm.types.llms.base import HiddenParams
from litellm.types.responses.main import ResponseToolSearchCall, build_web_search_call
from litellm.types.responses.main import OutputToolSearchCall, build_web_search_call
from litellm.types.utils import (
ChatCompletionMessageToolCall,
Choices,
@ -1853,6 +1854,27 @@ class TestToolTransformation:
assert chat_tools[0] == {"type": "tool_search_tool_bm25_20251119", "name": "tool_search_tool_bm25"}
assert [tool["defer_loading"] for tool in chat_tools[1:]] == [True, True]
@pytest.mark.parametrize(
"native_search_tool",
[
{"type": "tool_search_tool_regex_20251119", "name": "tool_search_tool_regex"},
{"type": "tool_search_tool_bm25_20251119", "name": "tool_search_tool_bm25"},
],
)
def test_server_tool_search_beside_a_native_search_tool_sends_only_the_native_one(
self, native_search_tool: Mapping[str, str]
) -> None:
result: Final = LiteLLMCompletionResponsesConfig.transform_responses_api_request_to_chat_completion_request(
model="anthropic/claude-sonnet-5-5",
input="What is the weather in Paris right now?",
responses_api_request={"tools": [native_search_tool, *_deferred_tools_with_server_tool_search()]},
custom_llm_provider="anthropic",
)
chat_tools: Final = result["tools"]
assert [tool for tool in chat_tools if tool["type"] != "function"] == [native_search_tool]
assert [tool["defer_loading"] for tool in chat_tools if tool["type"] == "function"] == [True, True]
def test_native_search_tool_passes_through_routes_without_tool_search(self) -> None:
model: Final = "azure_ai/claude-sonnet-5-5"
assert litellm.supports_tool_search(model, "azure_ai") is False
@ -1929,6 +1951,104 @@ class TestToolTransformation:
assert [tool["function"]["name"] for tool in not_deferred] == ["finance__get_stock_price", "get_weather"]
assert all("defer_loading" not in tool for tool in not_deferred)
@pytest.mark.parametrize("global_drop_params", [False, True])
def test_malformed_tool_flags_answer_400_unless_drop_params_drops_them(
self, monkeypatch: pytest.MonkeyPatch, global_drop_params: bool
) -> None:
monkeypatch.setattr(litellm, "drop_params", global_drop_params)
transform: Final = LiteLLMCompletionResponsesConfig.transform_responses_api_request_to_chat_completion_request
tools: Final = [
{"type": "tool_search", "execution": "server"},
{
"type": "function",
"name": "get_weather",
"parameters": {"type": "object"},
"defer_loading": "false",
"allowed_callers": "direct",
},
{
"type": "namespace",
"name": "finance",
"tools": [
"not a tool",
{
"type": "function",
"name": "get_stock_price",
"parameters": {"type": "object"},
"defer_loading": 1,
"allowed_callers": "direct",
},
{
"type": "function",
"name": "get_fx_rate",
"parameters": {"type": "object"},
"defer_loading": True,
},
],
},
{"type": "custom", "name": "apply_patch", "defer_loading": "yes"},
]
if not global_drop_params:
with pytest.raises(litellm.BadRequestError):
transform(
model="anthropic/claude-sonnet-5-5",
input="What is the weather in Paris right now?",
responses_api_request={"tools": tools},
custom_llm_provider="anthropic",
)
result: Final = transform(
model="anthropic/claude-sonnet-5-5",
input="What is the weather in Paris right now?",
responses_api_request={"tools": tools},
custom_llm_provider="anthropic",
drop_params=None if global_drop_params else True,
)
function_tools: Final = [tool for tool in result["tools"] if tool["type"] == "function"]
assert {tool["function"]["name"]: tool.get("defer_loading", False) for tool in function_tools} == {
"get_weather": False,
"finance__get_stock_price": False,
"finance__get_fx_rate": True,
"apply_patch": False,
}
assert all("allowed_callers" not in tool for tool in function_tools)
def test_top_level_function_allowed_callers_must_be_a_list_of_strings(self) -> None:
transform: Final = LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools
weather_tool: Final = {"type": "function", "name": "get_weather", "parameters": {"type": "object"}}
with pytest.raises(litellm.BadRequestError, match="allowed_callers must be a list of strings"):
transform(tools=[{**weather_tool, "allowed_callers": "direct"}])
converted, _ = transform(tools=[{**weather_tool, "allowed_callers": ["direct"]}])
assert converted[0]["allowed_callers"] == ["direct"]
def test_custom_tools_stay_deferred_when_tool_search_goes_out(self) -> None:
result: Final = LiteLLMCompletionResponsesConfig.transform_responses_api_request_to_chat_completion_request(
model="anthropic/claude-sonnet-5-5",
input="Patch the README",
responses_api_request={
"tools": [
{"type": "tool_search", "execution": "server"},
{"type": "custom", "name": "apply_patch", "defer_loading": True},
{
"type": "namespace",
"name": "editor",
"tools": [{"type": "custom", "name": "insert", "defer_loading": True}],
},
{"type": "function", "name": "get_weather", "parameters": {"type": "object"}},
]
},
custom_llm_provider="anthropic",
)
function_tools: Final = [tool for tool in result["tools"] if tool["type"] == "function"]
assert {tool["function"]["name"]: tool.get("defer_loading", False) for tool in function_tools} == {
"apply_patch": True,
"insert": True,
"get_weather": False,
}
def test_anthropic_tool_search_call_becomes_tool_search_call_item(self) -> None:
response: Final = _chat_response_with_tool_search_call('{"pattern": "weather|forecast"}')
with_tool_search: Final = LiteLLMCompletionResponsesConfig.transform_chat_completion_tools_to_responses_tools(
@ -1944,7 +2064,7 @@ class TestToolTransformation:
assert [item.type for item in with_tool_search] == ["tool_search_call", "function_call"]
search_call: Final = with_tool_search[0]
assert isinstance(search_call, ResponseToolSearchCall)
assert isinstance(search_call, OutputToolSearchCall)
assert search_call.id == "tsc_srvtoolu_01Search"
assert search_call.call_id == "srvtoolu_01Search"
assert search_call.arguments == {"pattern": "weather|forecast"}
@ -1969,7 +2089,7 @@ class TestToolTransformation:
)
search_call: Final = items[0]
assert isinstance(search_call, ResponseToolSearchCall)
assert isinstance(search_call, OutputToolSearchCall)
assert search_call.status == "incomplete"
assert search_call.arguments == {}
@ -2130,9 +2250,7 @@ class TestToolTransformation:
tools = [custom_tool]
with pytest.raises(litellm.BadRequestError, match="allowed_callers must be a list of strings"):
LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools(
tools=tools
)
LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools(tools=tools)
def test_transform_web_search_tools_to_web_search_options(self):
"""Test that web_search tools are converted to web_search_options"""

View file

@ -668,3 +668,21 @@ def test_openai_video_object_hidden_params_public_accessor_preserves_identity()
video.hidden_params = replacement
assert video._hidden_params is replacement
assert video.hidden_params["public_key"] == "visible"
@pytest.mark.parametrize("extra_keys", ({}, {"created_by": "server"}), ids=("bridge-item", "openai-item"))
def test_rebuilding_a_dumped_responses_answer_keeps_the_tool_search_call_keys(extra_keys: dict[str, str]) -> None:
search_call: Final = {
"id": "tsc_1",
"arguments": {"query": "weather"},
"call_id": None,
"execution": "server",
"status": "completed",
"type": "tool_search_call",
**extra_keys,
}
answer: Final = ResponsesAPIResponse(id="resp_1", created_at=1, output=[search_call])
rebuilt: Final = ResponsesAPIResponse(**answer.model_dump())
assert rebuilt.model_dump()["output"] == [search_call]

View file

@ -39598,6 +39598,32 @@ export interface components {
} & {
[key: string]: unknown;
};
/** OutputToolSearchCall */
OutputToolSearchCall: {
/** Arguments */
arguments: unknown;
/** Call Id */
call_id?: string | null;
/**
* Execution
* @enum {string}
*/
execution: "server" | "client";
/** Id */
id: string;
/**
* Status
* @enum {string}
*/
status: "in_progress" | "completed" | "incomplete";
/**
* Type
* @constant
*/
type: "tool_search_call";
} & {
[key: string]: unknown;
};
/**
* PageLinks
* @description Hypermedia for a paginated list. No `first`/`last`: without a total count the last page is unknown.
@ -43077,7 +43103,7 @@ export interface components {
*/
ResponseItemList: {
/** Data */
data: (components["schemas"]["ResponseInputMessageItem"] | components["schemas"]["ResponseOutputMessage"] | components["schemas"]["ResponseFileSearchToolCall"] | components["schemas"]["ResponseComputerToolCall"] | components["schemas"]["ResponseComputerToolCallOutputItem"] | components["schemas"]["ResponseFunctionWebSearch"] | components["schemas"]["ResponseFunctionToolCallItem"] | components["schemas"]["ResponseFunctionToolCallOutputItem"] | components["schemas"]["openai__types__responses__response_tool_search_call__ResponseToolSearchCall"] | components["schemas"]["ResponseToolSearchOutputItem"] | components["schemas"]["ResponseReasoningItem"] | components["schemas"]["ResponseCompactionItem"] | components["schemas"]["ImageGenerationCall"] | components["schemas"]["ResponseCodeInterpreterToolCall"] | components["schemas"]["LocalShellCall"] | components["schemas"]["LocalShellCallOutput"] | components["schemas"]["ResponseFunctionShellToolCall"] | components["schemas"]["ResponseFunctionShellToolCallOutput"] | components["schemas"]["ResponseApplyPatchToolCall"] | components["schemas"]["ResponseApplyPatchToolCallOutput"] | components["schemas"]["McpListTools"] | components["schemas"]["McpApprovalRequest"] | components["schemas"]["McpApprovalResponse"] | components["schemas"]["McpCall"] | components["schemas"]["ResponseCustomToolCallItem"] | components["schemas"]["ResponseCustomToolCallOutputItem"])[];
data: (components["schemas"]["ResponseInputMessageItem"] | components["schemas"]["ResponseOutputMessage"] | components["schemas"]["ResponseFileSearchToolCall"] | components["schemas"]["ResponseComputerToolCall"] | components["schemas"]["ResponseComputerToolCallOutputItem"] | components["schemas"]["ResponseFunctionWebSearch"] | components["schemas"]["ResponseFunctionToolCallItem"] | components["schemas"]["ResponseFunctionToolCallOutputItem"] | components["schemas"]["ResponseToolSearchCall"] | components["schemas"]["ResponseToolSearchOutputItem"] | components["schemas"]["ResponseReasoningItem"] | components["schemas"]["ResponseCompactionItem"] | components["schemas"]["ImageGenerationCall"] | components["schemas"]["ResponseCodeInterpreterToolCall"] | components["schemas"]["LocalShellCall"] | components["schemas"]["LocalShellCallOutput"] | components["schemas"]["ResponseFunctionShellToolCall"] | components["schemas"]["ResponseFunctionShellToolCallOutput"] | components["schemas"]["ResponseApplyPatchToolCall"] | components["schemas"]["ResponseApplyPatchToolCallOutput"] | components["schemas"]["McpListTools"] | components["schemas"]["McpApprovalRequest"] | components["schemas"]["McpApprovalResponse"] | components["schemas"]["McpCall"] | components["schemas"]["ResponseCustomToolCallItem"] | components["schemas"]["ResponseCustomToolCallOutputItem"])[];
/** First Id */
first_id: string;
/** Has More */
@ -43299,6 +43325,34 @@ export interface components {
} & {
[key: string]: unknown;
};
/** ResponseToolSearchCall */
ResponseToolSearchCall: {
/** Arguments */
arguments: unknown;
/** Call Id */
call_id?: string | null;
/** Created By */
created_by?: string | null;
/**
* Execution
* @enum {string}
*/
execution: "server" | "client";
/** Id */
id: string;
/**
* Status
* @enum {string}
*/
status: "in_progress" | "completed" | "incomplete";
/**
* Type
* @constant
*/
type: "tool_search_call";
} & {
[key: string]: unknown;
};
/** ResponseToolSearchOutputItem */
ResponseToolSearchOutputItem: {
/** Call Id */
@ -43533,9 +43587,9 @@ export interface components {
/** Object */
object?: string | null;
/** Output */
output: (components["schemas"]["ResponseOutputMessage"] | components["schemas"]["ResponseFileSearchToolCall"] | components["schemas"]["ResponseFunctionToolCall"] | components["schemas"]["ResponseFunctionToolCallOutputItem"] | components["schemas"]["ResponseFunctionWebSearch"] | components["schemas"]["ResponseComputerToolCall"] | components["schemas"]["ResponseComputerToolCallOutputItem"] | components["schemas"]["ResponseReasoningItem"] | components["schemas"]["openai__types__responses__response_tool_search_call__ResponseToolSearchCall"] | components["schemas"]["ResponseToolSearchOutputItem"] | components["schemas"]["ResponseCompactionItem"] | components["schemas"]["ImageGenerationCall"] | components["schemas"]["ResponseCodeInterpreterToolCall"] | components["schemas"]["LocalShellCall"] | components["schemas"]["LocalShellCallOutput"] | components["schemas"]["ResponseFunctionShellToolCall"] | components["schemas"]["ResponseFunctionShellToolCallOutput"] | components["schemas"]["ResponseApplyPatchToolCall"] | components["schemas"]["ResponseApplyPatchToolCallOutput"] | components["schemas"]["McpCall"] | components["schemas"]["McpListTools"] | components["schemas"]["McpApprovalRequest"] | components["schemas"]["McpApprovalResponse"] | components["schemas"]["ResponseCustomToolCall"] | components["schemas"]["ResponseCustomToolCallOutputItem"] | {
output: (components["schemas"]["ResponseOutputMessage"] | components["schemas"]["ResponseFileSearchToolCall"] | components["schemas"]["ResponseFunctionToolCall"] | components["schemas"]["ResponseFunctionToolCallOutputItem"] | components["schemas"]["ResponseFunctionWebSearch"] | components["schemas"]["ResponseComputerToolCall"] | components["schemas"]["ResponseComputerToolCallOutputItem"] | components["schemas"]["ResponseReasoningItem"] | components["schemas"]["ResponseToolSearchCall"] | components["schemas"]["ResponseToolSearchOutputItem"] | components["schemas"]["ResponseCompactionItem"] | components["schemas"]["ImageGenerationCall"] | components["schemas"]["ResponseCodeInterpreterToolCall"] | components["schemas"]["LocalShellCall"] | components["schemas"]["LocalShellCallOutput"] | components["schemas"]["ResponseFunctionShellToolCall"] | components["schemas"]["ResponseFunctionShellToolCallOutput"] | components["schemas"]["ResponseApplyPatchToolCall"] | components["schemas"]["ResponseApplyPatchToolCallOutput"] | components["schemas"]["McpCall"] | components["schemas"]["McpListTools"] | components["schemas"]["McpApprovalRequest"] | components["schemas"]["McpApprovalResponse"] | components["schemas"]["ResponseCustomToolCall"] | components["schemas"]["ResponseCustomToolCallOutputItem"] | {
[key: string]: unknown;
})[] | (components["schemas"]["GenericResponseOutputItem"] | components["schemas"]["OutputCodeInterpreterCall"] | components["schemas"]["OutputFunctionToolCall"] | components["schemas"]["OutputImageGenerationCall"] | components["schemas"]["ResponseFunctionToolCall"] | components["schemas"]["ResponseFunctionWebSearch"] | components["schemas"]["CustomToolCallOutputItem"] | components["schemas"]["litellm__types__responses__main__ResponseToolSearchCall"])[];
})[] | (components["schemas"]["GenericResponseOutputItem"] | components["schemas"]["OutputCodeInterpreterCall"] | components["schemas"]["OutputFunctionToolCall"] | components["schemas"]["OutputImageGenerationCall"] | components["schemas"]["ResponseFunctionToolCall"] | components["schemas"]["ResponseFunctionWebSearch"] | components["schemas"]["CustomToolCallOutputItem"] | components["schemas"]["OutputToolSearchCall"])[];
/** Parallel Tool Calls */
parallel_tool_calls?: boolean | null;
/** Previous Response Id */
@ -49368,32 +49422,6 @@ export interface components {
/** Type */
type: "computer_use_preview" | string;
};
/** ResponseToolSearchCall */
litellm__types__responses__main__ResponseToolSearchCall: {
/** Arguments */
arguments: unknown;
/** Call Id */
call_id?: string | null;
/**
* Execution
* @enum {string}
*/
execution: "server" | "client";
/** Id */
id: string;
/**
* Status
* @enum {string}
*/
status: "in_progress" | "completed" | "incomplete";
/**
* Type
* @constant
*/
type: "tool_search_call";
} & {
[key: string]: unknown;
};
/** ModelInfo */
litellm__types__router__ModelInfo: {
/** Access Windows */
@ -49476,34 +49504,6 @@ export interface components {
*/
type: "computer";
};
/** ResponseToolSearchCall */
openai__types__responses__response_tool_search_call__ResponseToolSearchCall: {
/** Arguments */
arguments: unknown;
/** Call Id */
call_id?: string | null;
/** Created By */
created_by?: string | null;
/**
* Execution
* @enum {string}
*/
execution: "server" | "client";
/** Id */
id: string;
/**
* Status
* @enum {string}
*/
status: "in_progress" | "completed" | "incomplete";
/**
* Type
* @constant
*/
type: "tool_search_call";
} & {
[key: string]: unknown;
};
/**
* UserLocation
* @description The user's location.