mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-24 00:52:24 +00:00
Merge pull request #40989 from BerriAI/litellm_responses_bridge_hoist_additional_tools
fix(responses): hoist Codex additional_tools input items into the chat bridge tools
This commit is contained in:
commit
1ce66e98a2
13 changed files with 560 additions and 104 deletions
|
|
@ -17,7 +17,7 @@ BaseAWSLLM._sign_request after the request body is finalized.
|
|||
|
||||
import json
|
||||
from collections.abc import Mapping
|
||||
from typing import Any, Final
|
||||
from typing import Any, Final, cast # noqa: TID251 # map_openai_params returns the filtered params as a bare dict
|
||||
|
||||
import httpx
|
||||
from typing_extensions import ReadOnly, TypedDict
|
||||
|
|
@ -32,6 +32,7 @@ from litellm.llms.bedrock_mantle.common_utils import (
|
|||
BedrockMantleAuthMixin,
|
||||
)
|
||||
from litellm.llms.openai.responses.transformation import OpenAIResponsesAPIConfig
|
||||
from litellm.responses.additional_tools import HoistedAdditionalTools, hoist_additional_tools
|
||||
from litellm.secret_managers.main import get_secret_str
|
||||
from litellm.types.llms.openai import (
|
||||
ResponseInputParam,
|
||||
|
|
@ -58,8 +59,6 @@ _BEDROCK_MANTLE_SUPPORTED_RESPONSE_TOOL_TYPES: Final = frozenset(
|
|||
_BEDROCK_MANTLE_SUPPORTED_SERVICE_TIERS: Final = frozenset({"auto", "default"})
|
||||
_BEDROCK_MANTLE_OPENAI_PATH_SUPPORTED_REASONING_SUMMARIES: Final = frozenset({"auto"})
|
||||
|
||||
_CODEX_ADDITIONAL_TOOLS_INPUT_ITEM_TYPE: Final = "additional_tools"
|
||||
|
||||
_CODEX_AGENT_MESSAGE_INPUT_ITEM_TYPE: Final = "agent_message"
|
||||
_CODEX_CONTEXT_COMPACTION_INPUT_ITEM_TYPE: Final = "context_compaction"
|
||||
_CODEX_LOCAL_SHELL_CALL_INPUT_ITEM_TYPE: Final = "local_shell_call"
|
||||
|
|
@ -233,17 +232,14 @@ class BedrockMantleResponsesAPIConfig(BedrockMantleAuthMixin, OpenAIResponsesAPI
|
|||
litellm_params: GenericLiteLLMParams,
|
||||
headers: dict,
|
||||
) -> dict:
|
||||
remaining_input, hoisted_tools = self._hoist_codex_additional_tools(input)
|
||||
normalized_input: Final = self._normalize_codex_input_items(remaining_input)
|
||||
params: Final = cast( # cast-ok: the base signature leaves the params dict untyped
|
||||
"ResponsesAPIOptionalRequestParams", response_api_optional_request_params
|
||||
)
|
||||
hoisted: Final = hoist_additional_tools(input, params.get("tools"))
|
||||
normalized_input: Final = self._normalize_codex_input_items(hoisted.input)
|
||||
request_params: Final = (
|
||||
{
|
||||
**response_api_optional_request_params,
|
||||
"tools": [
|
||||
*(response_api_optional_request_params.get("tools") or []),
|
||||
*hoisted_tools,
|
||||
],
|
||||
}
|
||||
if hoisted_tools
|
||||
self._params_with_hoisted_tools(params, hoisted)
|
||||
if hoisted.hoisted
|
||||
else response_api_optional_request_params
|
||||
)
|
||||
return super().transform_responses_api_request(
|
||||
|
|
@ -254,41 +250,14 @@ class BedrockMantleResponsesAPIConfig(BedrockMantleAuthMixin, OpenAIResponsesAPI
|
|||
headers=headers,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _is_codex_additional_tools_item(item: Any) -> bool:
|
||||
return isinstance(item, dict) and item.get("type") == _CODEX_ADDITIONAL_TOOLS_INPUT_ITEM_TYPE
|
||||
|
||||
@staticmethod
|
||||
def _tools_of_additional_tools_item(item: "dict[str, Any]") -> "list[Any]":
|
||||
tools: Final = item.get("tools")
|
||||
return tools if isinstance(tools, list) else []
|
||||
|
||||
@classmethod
|
||||
def _hoist_codex_additional_tools(
|
||||
cls,
|
||||
input: "str | ResponseInputParam",
|
||||
) -> "tuple[str | ResponseInputParam, list[Any]]":
|
||||
"""Codex's "responses lite" wire mode ships tool definitions inside
|
||||
`input` as {"type": "additional_tools", "role": "developer",
|
||||
"tools": [...]} items. api.openai.com accepts that item type; Mantle
|
||||
rejects the whole request with 400 "Invalid 'input': value did not
|
||||
match any expected variant" but accepts the same tools at the top
|
||||
level, so move them there and strip the items from `input`.
|
||||
"""
|
||||
if not isinstance(input, list):
|
||||
return input, []
|
||||
additional_tools_items: Final = [item for item in input if cls._is_codex_additional_tools_item(item)]
|
||||
if not additional_tools_items:
|
||||
return input, []
|
||||
remaining_input: Final = [item for item in input if not cls._is_codex_additional_tools_item(item)]
|
||||
hoisted_tools = [tool for item in additional_tools_items for tool in cls._tools_of_additional_tools_item(item)]
|
||||
verbose_logger.debug(
|
||||
"Bedrock Mantle Responses API: hoisting %d tool(s) out of %d 'additional_tools' input item(s) "
|
||||
"into the top-level tools param (Mantle rejects that input item type).",
|
||||
len(hoisted_tools),
|
||||
len(additional_tools_items),
|
||||
)
|
||||
return remaining_input, cls._filter_unsupported_tools(hoisted_tools)
|
||||
def _params_with_hoisted_tools(
|
||||
cls, params: Mapping[str, object], hoisted: HoistedAdditionalTools
|
||||
) -> dict[str, object]:
|
||||
supported_tools: Final = cls._filter_unsupported_tools(list(hoisted.tools))
|
||||
if supported_tools:
|
||||
return {**params, "tools": supported_tools}
|
||||
return {key: value for key, value in params.items() if key != "tools"}
|
||||
|
||||
@staticmethod
|
||||
def _agent_message_text(item: "Mapping[str, object]") -> str:
|
||||
|
|
|
|||
|
|
@ -6,8 +6,10 @@ from typing import Final, TypeAlias
|
|||
from pydantic import BaseModel, TypeAdapter, ValidationError
|
||||
|
||||
from litellm._logging import verbose_logger
|
||||
from litellm.responses.litellm_completion_transformation.custom_tools import custom_tool_grammar_suffix
|
||||
from litellm.responses.litellm_completion_transformation.transformation import (
|
||||
NAMESPACE_DESCRIPTION_SEPARATOR,
|
||||
NAMESPACE_MEMBER_TYPES_WITH_CHAT_TOOLS,
|
||||
LiteLLMCompletionResponsesConfig,
|
||||
)
|
||||
|
||||
|
|
@ -34,8 +36,8 @@ def _validated_tools(values: Iterable[object]) -> tuple[Tool, ...]:
|
|||
return tuple(tool for tool in validated if tool is not None)
|
||||
|
||||
|
||||
def _is_function(tool: Tool) -> bool:
|
||||
return tool.get("type") == "function"
|
||||
def _has_chat_tool(member: Tool) -> bool:
|
||||
return member.get("type") in NAMESPACE_MEMBER_TYPES_WITH_CHAT_TOOLS
|
||||
|
||||
|
||||
def _chat_tool_key(tool: Tool) -> str:
|
||||
|
|
@ -67,18 +69,19 @@ def _function_fields(tool: Tool) -> Tool:
|
|||
return function if function is not None else MappingProxyType({})
|
||||
|
||||
|
||||
def _without_namespace_prefix(key: str, value: object, prefix: str) -> object:
|
||||
if key != "description" or not isinstance(value, str) or not value.startswith(prefix):
|
||||
def _member_description(key: str, value: object, prefix: str, suffix: str) -> object:
|
||||
if key != "description" or not isinstance(value, str):
|
||||
return value
|
||||
return value[len(prefix) :]
|
||||
return value.replace(prefix, "", 1).replace(suffix, "", 1)
|
||||
|
||||
|
||||
def _rebuilt_member(member: Tool, flattened: Tool, guardrailed: Tool, namespace_description: str) -> Tool:
|
||||
flattened_function: Final = _function_fields(flattened)
|
||||
prefix: Final = f"{namespace_description}{NAMESPACE_DESCRIPTION_SEPARATOR}" if namespace_description else ""
|
||||
suffix: Final = custom_tool_grammar_suffix(member.get("format")) if member.get("type") == "custom" else ""
|
||||
changed_function: Final = MappingProxyType(
|
||||
{
|
||||
key: _without_namespace_prefix(key, value, prefix)
|
||||
key: _member_description(key, value, prefix, suffix)
|
||||
for key, value in _function_fields(guardrailed).items()
|
||||
if flattened_function.get(key) != value
|
||||
}
|
||||
|
|
@ -93,8 +96,8 @@ def _rebuilt_member(member: Tool, flattened: Tool, guardrailed: Tool, namespace_
|
|||
return {**member, **changed_extras, **changed_function} # mutable-ok: json.dumps rejects MappingProxyType
|
||||
|
||||
|
||||
def _rebuilt_function_members(
|
||||
function_members: Sequence[Tool],
|
||||
def _rebuilt_flattened_members(
|
||||
flattened_members: Sequence[Tool],
|
||||
flattened_group: Sequence[Tool],
|
||||
group_keys: Sequence[IndexedKey],
|
||||
guardrailed_by_key: Mapping[IndexedKey, Tool],
|
||||
|
|
@ -106,7 +109,7 @@ def _rebuilt_function_members(
|
|||
else member
|
||||
if guardrailed_by_key[key] == flattened
|
||||
else _rebuilt_member(member, flattened, guardrailed_by_key[key], namespace_description)
|
||||
for member, flattened, key in zip(function_members, flattened_group, group_keys)
|
||||
for member, flattened, key in zip(flattened_members, flattened_group, group_keys)
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -118,9 +121,9 @@ def _rebuilt_namespace(
|
|||
guardrailed_by_key: Mapping[IndexedKey, Tool],
|
||||
) -> tuple[Tool, ...]:
|
||||
namespace_description: Final = str(original.get("description") or "")
|
||||
rebuilt_functions: Final = iter(
|
||||
_rebuilt_function_members(
|
||||
tuple(member for member in members if _is_function(member)),
|
||||
rebuilt_flattened: Final = iter(
|
||||
_rebuilt_flattened_members(
|
||||
tuple(member for member in members if _has_chat_tool(member)),
|
||||
flattened_group,
|
||||
group_keys,
|
||||
guardrailed_by_key,
|
||||
|
|
@ -129,7 +132,7 @@ def _rebuilt_namespace(
|
|||
)
|
||||
rebuilt_members: Final = tuple(
|
||||
rebuilt
|
||||
for rebuilt in (next(rebuilt_functions) if _is_function(member) else member for member in members)
|
||||
for rebuilt in (next(rebuilt_flattened) if _has_chat_tool(member) else member for member in members)
|
||||
if rebuilt is not None
|
||||
)
|
||||
if not rebuilt_members:
|
||||
|
|
@ -149,7 +152,7 @@ def _merged_original(
|
|||
if guardrailed_group == tuple(flattened_group):
|
||||
return (original,)
|
||||
members: Final = _namespace_members(original) if original.get("type") == "namespace" else ()
|
||||
if members and sum(map(_is_function, members)) == len(flattened_group):
|
||||
if members and sum(map(_has_chat_tool, members)) == len(flattened_group):
|
||||
return _rebuilt_namespace(original, members, flattened_group, group_keys, guardrailed_by_key)
|
||||
if not guardrailed_group:
|
||||
return ()
|
||||
|
|
|
|||
65
litellm/responses/additional_tools.py
Normal file
65
litellm/responses/additional_tools.py
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
from typing import Final, cast # noqa: TID251 # validating the openai tool union strips vendor keys from raw tools
|
||||
|
||||
from pydantic import BaseModel, ValidationError
|
||||
|
||||
from litellm._logging import verbose_logger
|
||||
from litellm.types.llms.openai import ALL_RESPONSES_API_TOOL_PARAMS, ResponseInputParam
|
||||
|
||||
ADDITIONAL_TOOLS_INPUT_ITEM_TYPE: Final = "additional_tools"
|
||||
|
||||
|
||||
class _InputItemType(BaseModel):
|
||||
type: str = ""
|
||||
|
||||
|
||||
class _AdditionalToolsItem(BaseModel):
|
||||
tools: tuple[dict[str, object], ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class HoistedAdditionalTools:
|
||||
input: str | ResponseInputParam
|
||||
tools: tuple[ALL_RESPONSES_API_TOOL_PARAMS, ...]
|
||||
hoisted: tuple[ALL_RESPONSES_API_TOOL_PARAMS, ...]
|
||||
|
||||
|
||||
def _is_additional_tools_item(item: object) -> bool:
|
||||
try:
|
||||
return _InputItemType.model_validate(item).type == ADDITIONAL_TOOLS_INPUT_ITEM_TYPE
|
||||
except ValidationError:
|
||||
return False
|
||||
|
||||
|
||||
def _tools_of_item(item: object) -> tuple[ALL_RESPONSES_API_TOOL_PARAMS, ...]:
|
||||
try:
|
||||
parsed: Final = _AdditionalToolsItem.model_validate(item)
|
||||
except ValidationError:
|
||||
return ()
|
||||
return tuple(
|
||||
cast(
|
||||
"ALL_RESPONSES_API_TOOL_PARAMS", tool
|
||||
) # cast-ok: nested tools carry the same raw tool JSON as top-level tools
|
||||
for tool in parsed.tools
|
||||
)
|
||||
|
||||
|
||||
def hoist_additional_tools(
|
||||
input: str | ResponseInputParam,
|
||||
tools: Sequence[ALL_RESPONSES_API_TOOL_PARAMS] | None,
|
||||
) -> HoistedAdditionalTools:
|
||||
existing: Final = tuple(tools or ())
|
||||
if isinstance(input, str):
|
||||
return HoistedAdditionalTools(input=input, tools=existing, hoisted=())
|
||||
items: Final = tuple(item for item in input if _is_additional_tools_item(item))
|
||||
if not items:
|
||||
return HoistedAdditionalTools(input=input, tools=existing, hoisted=())
|
||||
hoisted: Final = tuple(tool for item in items for tool in _tools_of_item(item))
|
||||
verbose_logger.debug(
|
||||
"Responses API: hoisting %d tool(s) out of %d 'additional_tools' input item(s) into the top-level tools param.",
|
||||
len(hoisted),
|
||||
len(items),
|
||||
)
|
||||
remaining_input: Final = [item for item in input if not _is_additional_tools_item(item)]
|
||||
return HoistedAdditionalTools(input=remaining_input, tools=(*existing, *hoisted), hoisted=hoisted)
|
||||
|
|
@ -39,15 +39,38 @@ def openai_shaped_tool_call_item_id(item_type: str, tool_id: str) -> str:
|
|||
return f"{prefix}_{tool_id}"
|
||||
|
||||
|
||||
class _ToolNameFields(BaseModel):
|
||||
type: str = ""
|
||||
name: str = ""
|
||||
tools: tuple[object, ...] = ()
|
||||
|
||||
|
||||
def _tool_name_fields_of(tool: object) -> _ToolNameFields | None:
|
||||
try:
|
||||
return _ToolNameFields.model_validate(tool)
|
||||
except ValidationError:
|
||||
return None
|
||||
|
||||
|
||||
def _custom_tool_name_of(tool: object) -> str | None:
|
||||
parsed: Final = _tool_name_fields_of(tool)
|
||||
if parsed is None or parsed.type != "custom" or not parsed.name:
|
||||
return None
|
||||
return parsed.name
|
||||
|
||||
|
||||
def _nested_tools_of(tool: object) -> tuple[object, ...]:
|
||||
parsed: Final = _tool_name_fields_of(tool)
|
||||
if parsed is None or parsed.type != "namespace":
|
||||
return ()
|
||||
return parsed.tools
|
||||
|
||||
|
||||
def extract_custom_tool_names(tools: Sequence[object] | None) -> set[str]:
|
||||
"""Extract names of tools originally defined as ``type: "custom"``."""
|
||||
if not tools:
|
||||
return set()
|
||||
names: Final[set[str]] = set()
|
||||
for tool in tools:
|
||||
if isinstance(tool, dict) and tool.get("type") == "custom" and "name" in tool:
|
||||
names.add(tool["name"])
|
||||
return names
|
||||
"""Extract names of ``type: "custom"`` tools, at the top level or one level inside a ``namespace`` tool."""
|
||||
top_level: Final = tuple(tools or ())
|
||||
nested: Final = tuple(nested_tool for tool in top_level for nested_tool in _nested_tools_of(tool))
|
||||
return {name for tool in (*top_level, *nested) if (name := _custom_tool_name_of(tool)) is not None}
|
||||
|
||||
|
||||
def is_custom_tool_call(tool_name: str, custom_tool_names: set[str]) -> bool:
|
||||
|
|
@ -143,7 +166,7 @@ def validated_allowed_callers(value: object) -> list[str] | None:
|
|||
raise ValueError("allowed_callers must be a list of strings") from exc
|
||||
|
||||
|
||||
def _grammar_suffix(fmt: object) -> str:
|
||||
def custom_tool_grammar_suffix(fmt: object) -> str:
|
||||
try:
|
||||
parsed: Final = _CustomToolFormat.model_validate(fmt)
|
||||
except ValidationError:
|
||||
|
|
@ -167,7 +190,9 @@ def convert_custom_tool_to_function_tool(tool: Mapping[str, object]) -> ChatComp
|
|||
raw_name: Final = tool.get("name")
|
||||
name: Final = raw_name if isinstance(raw_name, str) else ""
|
||||
raw_description: Final = tool.get("description")
|
||||
description = (raw_description if isinstance(raw_description, str) else "") + _grammar_suffix(tool.get("format"))
|
||||
description: Final = (raw_description if isinstance(raw_description, str) else "") + custom_tool_grammar_suffix(
|
||||
tool.get("format")
|
||||
)
|
||||
allowed_callers: Final = validated_allowed_callers(tool.get("allowed_callers"))
|
||||
function_chunk: Final = ChatCompletionToolParamFunctionChunk(
|
||||
name=name,
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ from collections.abc import Coroutine, Mapping
|
|||
from typing import Final
|
||||
|
||||
import litellm
|
||||
from litellm.responses.additional_tools import hoist_additional_tools
|
||||
from litellm.responses.litellm_completion_transformation.streaming_iterator import (
|
||||
LiteLLMCompletionStreamingIterator,
|
||||
)
|
||||
|
|
@ -37,11 +38,16 @@ class LiteLLMCompletionTransformationHandler:
|
|||
| BaseResponsesAPIStreamingIterator
|
||||
| Coroutine[object, object, ResponsesAPIResponse | BaseResponsesAPIStreamingIterator]
|
||||
):
|
||||
hoisted: Final = hoist_additional_tools(input, responses_api_request.get("tools"))
|
||||
bridged_input: Final = hoisted.input
|
||||
bridged_request: Final[ResponsesAPIOptionalRequestParams] = (
|
||||
{**responses_api_request, "tools": list(hoisted.tools)} if hoisted.hoisted else responses_api_request
|
||||
)
|
||||
litellm_completion_request: Final[dict] = (
|
||||
LiteLLMCompletionResponsesConfig.transform_responses_api_request_to_chat_completion_request(
|
||||
model=model,
|
||||
input=input,
|
||||
responses_api_request=responses_api_request,
|
||||
input=bridged_input,
|
||||
responses_api_request=bridged_request,
|
||||
custom_llm_provider=custom_llm_provider,
|
||||
stream=stream,
|
||||
extra_headers=extra_headers,
|
||||
|
|
@ -52,8 +58,8 @@ class LiteLLMCompletionTransformationHandler:
|
|||
if _is_async:
|
||||
return self.async_response_api_handler(
|
||||
litellm_completion_request=litellm_completion_request,
|
||||
request_input=input,
|
||||
responses_api_request=responses_api_request,
|
||||
request_input=bridged_input,
|
||||
responses_api_request=bridged_request,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
|
@ -70,8 +76,8 @@ class LiteLLMCompletionTransformationHandler:
|
|||
responses_api_response: Final[ResponsesAPIResponse] = (
|
||||
LiteLLMCompletionResponsesConfig.transform_chat_completion_response_to_responses_api_response(
|
||||
chat_completion_response=litellm_completion_response,
|
||||
request_input=input,
|
||||
responses_api_request=responses_api_request,
|
||||
request_input=bridged_input,
|
||||
responses_api_request=bridged_request,
|
||||
)
|
||||
)
|
||||
|
||||
|
|
@ -81,8 +87,8 @@ class LiteLLMCompletionTransformationHandler:
|
|||
return LiteLLMCompletionStreamingIterator(
|
||||
model=model,
|
||||
litellm_custom_stream_wrapper=litellm_completion_response,
|
||||
request_input=input,
|
||||
responses_api_request=responses_api_request,
|
||||
request_input=bridged_input,
|
||||
responses_api_request=bridged_request,
|
||||
custom_llm_provider=custom_llm_provider,
|
||||
litellm_metadata=kwargs.get("litellm_metadata", {}),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ from litellm.main import stream_chunk_builder
|
|||
from litellm.responses.litellm_completion_transformation.custom_tools import (
|
||||
build_tool_call_item_kwargs,
|
||||
extract_custom_tool_names,
|
||||
is_custom_tool_call,
|
||||
serialize_tool_call_arguments,
|
||||
)
|
||||
from litellm.responses.litellm_completion_transformation.transformation import (
|
||||
|
|
@ -166,6 +167,14 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator):
|
|||
return tool_name, namespace
|
||||
return fn_name, None
|
||||
|
||||
def _tool_call_item_kwargs(self, call_id: str, fn_name: str, arguments: str, status: str) -> dict[str, str]:
|
||||
item_kwargs: Final = build_tool_call_item_kwargs(call_id, fn_name, arguments, status, self._custom_tool_names)
|
||||
if is_custom_tool_call(fn_name, self._custom_tool_names):
|
||||
return item_kwargs
|
||||
tool_name, tool_namespace = self._responses_namespace_tool_call_fields(fn_name)
|
||||
namespace_kwargs: Final = {"namespace": tool_namespace} if tool_namespace else {}
|
||||
return {**item_kwargs, "name": tool_name, **namespace_kwargs}
|
||||
|
||||
def _is_reasoning_end(self, chunk):
|
||||
delta: Final = chunk.choices[0].delta
|
||||
|
||||
|
|
@ -244,17 +253,13 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator):
|
|||
else:
|
||||
fn_name = str(getattr(fn, "name", "") or "")
|
||||
fn_args_delta = serialize_tool_call_arguments(getattr(fn, "arguments", ""))
|
||||
tool_name, tool_namespace = self._responses_namespace_tool_call_fields(fn_name)
|
||||
output_index = self._get_or_assign_tool_output_index(call_id)
|
||||
|
||||
if call_id not in self._tool_args_by_call_id:
|
||||
self._tool_args_by_call_id[call_id] = ""
|
||||
self._sequence_number += 1
|
||||
names = self._custom_tool_names
|
||||
item_kwargs = build_tool_call_item_kwargs(call_id, tool_name, "", "in_progress", names)
|
||||
item_kwargs = self._tool_call_item_kwargs(call_id, fn_name, "", "in_progress")
|
||||
self._tool_item_id_by_call_id[call_id] = item_kwargs["id"]
|
||||
if tool_namespace:
|
||||
item_kwargs["namespace"] = tool_namespace
|
||||
event = OutputItemAddedEvent(
|
||||
type=ResponsesAPIStreamEvents.OUTPUT_ITEM_ADDED,
|
||||
output_index=output_index,
|
||||
|
|
@ -315,7 +320,6 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator):
|
|||
else:
|
||||
fn_name = str(getattr(fn, "name", "") or "")
|
||||
fn_args = serialize_tool_call_arguments(getattr(fn, "arguments", ""))
|
||||
tool_name, tool_namespace = self._responses_namespace_tool_call_fields(fn_name)
|
||||
web_search_call = self._web_search_calls.get(call_id)
|
||||
if web_search_call is not None:
|
||||
if call_id not in self._queued_web_search_call_ids:
|
||||
|
|
@ -330,11 +334,8 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator):
|
|||
if is_new_tool_call:
|
||||
self._tool_args_by_call_id[call_id] = ""
|
||||
self._sequence_number += 1
|
||||
names = self._custom_tool_names
|
||||
item_kwargs = build_tool_call_item_kwargs(call_id, tool_name, "", "in_progress", names)
|
||||
item_kwargs = self._tool_call_item_kwargs(call_id, fn_name, "", "in_progress")
|
||||
self._tool_item_id_by_call_id[call_id] = item_kwargs["id"]
|
||||
if tool_namespace:
|
||||
item_kwargs["namespace"] = tool_namespace
|
||||
event = OutputItemAddedEvent(
|
||||
type=ResponsesAPIStreamEvents.OUTPUT_ITEM_ADDED,
|
||||
output_index=output_index,
|
||||
|
|
@ -376,11 +377,8 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator):
|
|||
self._pending_tool_events.append(done_event)
|
||||
|
||||
self._sequence_number += 1
|
||||
names = self._custom_tool_names
|
||||
item_kwargs = build_tool_call_item_kwargs(call_id, tool_name, final_args, "completed", names)
|
||||
item_kwargs = self._tool_call_item_kwargs(call_id, fn_name, final_args, "completed")
|
||||
item_kwargs["id"] = self._tool_item_id_by_call_id.setdefault(call_id, item_kwargs["id"])
|
||||
if tool_namespace:
|
||||
item_kwargs["namespace"] = tool_namespace
|
||||
item_done_event = OutputItemDoneEvent(
|
||||
type=ResponsesAPIStreamEvents.OUTPUT_ITEM_DONE,
|
||||
output_index=output_index,
|
||||
|
|
|
|||
|
|
@ -110,6 +110,7 @@ NamespaceTool: TypeAlias = Mapping[str, object]
|
|||
ResponseTools: TypeAlias = Sequence[Mapping[str, object]] | None
|
||||
ChatToolParam: TypeAlias = ChatCompletionToolParam | OpenAIMcpServerTool
|
||||
NAMESPACE_DESCRIPTION_SEPARATOR: Final = "\n\n"
|
||||
NAMESPACE_MEMBER_TYPES_WITH_CHAT_TOOLS: Final = frozenset({"function", "custom"})
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
|
|
@ -1891,9 +1892,21 @@ class LiteLLMCompletionResponsesConfig:
|
|||
namespace_tool: NamespaceTool,
|
||||
nested: bool,
|
||||
) -> ChatCompletionToolParam | None:
|
||||
if nested and namespace_tool.get("type") != "function":
|
||||
tool_type: Final = namespace_tool.get("type")
|
||||
if nested and tool_type not in NAMESPACE_MEMBER_TYPES_WITH_CHAT_TOOLS:
|
||||
return None
|
||||
|
||||
raw_description: Final = str(namespace_tool.get("description") or "")
|
||||
description: Final = (
|
||||
f"{namespace_description}{NAMESPACE_DESCRIPTION_SEPARATOR}{raw_description}"
|
||||
if nested and namespace_description and raw_description
|
||||
else namespace_description
|
||||
if nested and namespace_description
|
||||
else raw_description
|
||||
)
|
||||
if nested and tool_type == "custom":
|
||||
return convert_custom_tool_to_function_tool({**namespace_tool, "description": description})
|
||||
|
||||
raw_parameters: Final = namespace_tool.get("parameters")
|
||||
parameters: Final = (
|
||||
MappingProxyType(raw_parameters) if isinstance(raw_parameters, Mapping) else MappingProxyType({})
|
||||
|
|
@ -1902,14 +1915,6 @@ class LiteLLMCompletionResponsesConfig:
|
|||
parameters if parameters and "type" in parameters else MappingProxyType({**parameters, "type": "object"})
|
||||
)
|
||||
tool_name: Final = str(namespace_tool.get("name") or "")
|
||||
raw_description: Final = str(namespace_tool.get("description") or "")
|
||||
description: Final = (
|
||||
f"{namespace_description}{NAMESPACE_DESCRIPTION_SEPARATOR}{raw_description}"
|
||||
if nested and namespace_description and raw_description
|
||||
else namespace_description
|
||||
if nested and namespace_description
|
||||
else raw_description
|
||||
)
|
||||
chat_tool_name: Final = f"{namespace}__{tool_name}" if nested else tool_name
|
||||
function: Final = ChatCompletionToolParamFunctionChunk(
|
||||
name=chat_tool_name,
|
||||
|
|
|
|||
|
|
@ -773,6 +773,12 @@ class TestBedrockMantleCodexAdditionalTools:
|
|||
assert body["input"] == codex_agentic_items
|
||||
assert "tools" not in body
|
||||
|
||||
def test_input_without_additional_tools_sanitizes_tools_on_the_caller_params_object(self):
|
||||
params = {"tools": [{"type": "function", "name": "wait", "parameters": '{"type": "object"}'}]}
|
||||
body = self._transform(input=[self._USER_MESSAGE], params=params)
|
||||
assert body["tools"][0]["parameters"] == {"type": "object"}
|
||||
assert params["tools"][0]["parameters"] == {"type": "object"}
|
||||
|
||||
def test_malformed_additional_tools_item_without_tools_list_is_stripped(self):
|
||||
body = self._transform(
|
||||
input=[
|
||||
|
|
|
|||
|
|
@ -133,7 +133,20 @@ def test_namespace_keeps_a_non_function_member_when_a_function_member_is_edited(
|
|||
assert merged[0]["tools"][1] == custom_member
|
||||
|
||||
|
||||
def test_namespace_keeps_its_non_function_members_when_every_function_member_is_dropped():
|
||||
def test_namespace_keeps_its_custom_member_when_every_function_member_is_dropped():
|
||||
custom_member = {"type": "custom", "name": "grep", "description": "Grep", "format": {"type": "text"}}
|
||||
original = [
|
||||
{"type": "namespace", "name": "ns", "description": "NS", "tools": [_function("read"), custom_member]},
|
||||
_function("a"),
|
||||
]
|
||||
groups = _groups(original)
|
||||
|
||||
merged = merge_guardrailed_tools(original, groups, [groups[0][1], groups[1][0]])
|
||||
|
||||
assert list(merged) == [{"type": "namespace", "name": "ns", "description": "NS", "tools": [custom_member]}, _function("a")]
|
||||
|
||||
|
||||
def test_namespace_custom_member_is_dropped_when_the_guardrail_drops_its_chat_form():
|
||||
custom_member = {"type": "custom", "name": "grep", "description": "Grep", "format": {"type": "text"}}
|
||||
original = [
|
||||
{"type": "namespace", "name": "ns", "description": "NS", "tools": [_function("read"), custom_member]},
|
||||
|
|
@ -143,7 +156,37 @@ def test_namespace_keeps_its_non_function_members_when_every_function_member_is_
|
|||
|
||||
merged = merge_guardrailed_tools(original, groups, [groups[1][0]])
|
||||
|
||||
assert list(merged) == [{"type": "namespace", "name": "ns", "description": "NS", "tools": [custom_member]}, _function("a")]
|
||||
assert list(merged) == [_function("a")]
|
||||
|
||||
|
||||
def test_custom_member_description_edit_lands_without_the_namespace_prefix_or_grammar_block():
|
||||
grammar = {"type": "grammar", "syntax": "lark", "definition": "start: X"}
|
||||
custom_member = {"type": "custom", "name": "exec", "description": "Run a command", "format": grammar}
|
||||
original = [{"type": "namespace", "name": "shell", "description": "Shell", "tools": [custom_member]}]
|
||||
groups = _groups(original)
|
||||
assert groups[0][0]["function"]["description"] == "Shell\n\nRun a command\n\nFormat:\n```lark\nstart: X\n```"
|
||||
edited = copy.deepcopy(_flat(groups))
|
||||
edited[0]["function"]["description"] = "Shell\n\nRun a command (guarded)\n\nFormat:\n```lark\nstart: X\n```"
|
||||
|
||||
merged = merge_guardrailed_tools(original, groups, edited)
|
||||
|
||||
guarded_member = {**custom_member, "description": "Run a command (guarded)"}
|
||||
assert list(merged) == [{"type": "namespace", "name": "shell", "description": "Shell", "tools": [guarded_member]}]
|
||||
|
||||
|
||||
def test_text_appended_after_the_grammar_block_lands_on_the_member_without_the_block():
|
||||
grammar = {"type": "grammar", "syntax": "lark", "definition": "start: X"}
|
||||
custom_member = {"type": "custom", "name": "exec", "description": "Run a command", "format": grammar}
|
||||
original = [{"type": "namespace", "name": "shell", "description": "Shell", "tools": [custom_member]}]
|
||||
groups = _groups(original)
|
||||
edited = copy.deepcopy(_flat(groups))
|
||||
edited[0]["function"]["description"] = edited[0]["function"]["description"] + " [checked]"
|
||||
|
||||
merged = merge_guardrailed_tools(original, groups, edited)
|
||||
|
||||
assert merged[0]["tools"][0]["description"] == "Run a command [checked]"
|
||||
reflattened = _flat(_groups(merged))
|
||||
assert reflattened[0]["function"]["description"] == "Shell\n\nRun a command [checked]\n\nFormat:\n```lark\nstart: X\n```"
|
||||
|
||||
|
||||
def test_member_extras_edited_by_the_guardrail_land_on_that_member():
|
||||
|
|
|
|||
|
|
@ -68,3 +68,105 @@ async def test_async_fallback_tags_skip_responses_api_bridge():
|
|||
await coro
|
||||
|
||||
assert captured.get("_skip_responses_api_bridge") is True
|
||||
|
||||
|
||||
_CODEX_ADDITIONAL_TOOLS_ITEM = {
|
||||
"type": "additional_tools",
|
||||
"id": "at_codex",
|
||||
"role": "developer",
|
||||
"tools": [
|
||||
{
|
||||
"type": "namespace",
|
||||
"name": "functions",
|
||||
"description": "",
|
||||
"tools": [
|
||||
{
|
||||
"type": "custom",
|
||||
"name": "exec",
|
||||
"description": "Runs a shell command.",
|
||||
"format": {"type": "grammar", "syntax": "lark", "definition": "start: /.+/"},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"name": "wait",
|
||||
"description": "Waits for a background command.",
|
||||
"parameters": {"type": "object", "properties": {"id": {"type": "string"}}},
|
||||
},
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
_CODEX_INPUT = [_CODEX_ADDITIONAL_TOOLS_ITEM, {"type": "message", "role": "user", "content": "Run ls"}]
|
||||
|
||||
|
||||
def test_sync_fallback_hoists_additional_tools_input_items_into_chat_tools():
|
||||
handler = LiteLLMCompletionTransformationHandler()
|
||||
captured: dict = {}
|
||||
|
||||
def fake_completion(**kwargs):
|
||||
captured.update(kwargs)
|
||||
raise _StopForwarding()
|
||||
|
||||
with patch("litellm.completion", fake_completion): # test-quality-ok: no DI seam; the file stubs this same boundary
|
||||
with pytest.raises(_StopForwarding):
|
||||
handler.response_api_handler(
|
||||
model="bedrock/us.openai.gpt-5.6",
|
||||
input=_CODEX_INPUT,
|
||||
responses_api_request={},
|
||||
custom_llm_provider="bedrock",
|
||||
_is_async=False,
|
||||
)
|
||||
|
||||
assert [message["role"] for message in captured["messages"]] == ["user"]
|
||||
functions_by_name = {tool["function"]["name"]: tool["function"] for tool in captured["tools"]}
|
||||
assert set(functions_by_name) == {"exec", "functions__wait"}
|
||||
assert set(functions_by_name["exec"]["parameters"]["properties"]) == {"content"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_fallback_returns_hoisted_nested_custom_tool_call_as_custom_tool_call():
|
||||
from litellm.responses.litellm_completion_transformation.transformation import TOOL_CALLS_CACHE
|
||||
from litellm.types.utils import ChatCompletionMessageToolCall, Choices, Function, Message, ModelResponse
|
||||
|
||||
handler = LiteLLMCompletionTransformationHandler()
|
||||
tool_call_id = "call_exec_hoisted"
|
||||
|
||||
async def fake_acompletion(**kwargs):
|
||||
return ModelResponse(
|
||||
id="chatcmpl-exec",
|
||||
created=1,
|
||||
model="us.openai.gpt-5.6",
|
||||
object="chat.completion",
|
||||
choices=[
|
||||
Choices(
|
||||
finish_reason="tool_calls",
|
||||
index=0,
|
||||
message=Message(
|
||||
content=None,
|
||||
role="assistant",
|
||||
tool_calls=[
|
||||
ChatCompletionMessageToolCall(
|
||||
id=tool_call_id,
|
||||
type="function",
|
||||
function=Function(name="exec", arguments='{"content": "ls"}'),
|
||||
)
|
||||
],
|
||||
),
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
try:
|
||||
with patch("litellm.acompletion", fake_acompletion): # test-quality-ok: no DI seam; file stubs this boundary
|
||||
response = await handler.response_api_handler(
|
||||
model="bedrock/us.openai.gpt-5.6",
|
||||
input=_CODEX_INPUT,
|
||||
responses_api_request={},
|
||||
custom_llm_provider="bedrock",
|
||||
_is_async=True,
|
||||
)
|
||||
finally:
|
||||
TOOL_CALLS_CACHE.delete_cache(key=tool_call_id)
|
||||
|
||||
tool_calls = [(item.type, item.name, item.input) for item in response.output if item.type == "custom_tool_call"]
|
||||
assert tool_calls == [("custom_tool_call", "exec", "ls")]
|
||||
|
|
|
|||
|
|
@ -2506,6 +2506,7 @@ class TestToolTransformation:
|
|||
"tools": [
|
||||
"ignored",
|
||||
{"type": "namespace", "name": "ignored"},
|
||||
{"type": "web_search", "name": "ignored"},
|
||||
{
|
||||
"type": "function",
|
||||
"name": "spawn_agent",
|
||||
|
|
@ -2527,6 +2528,36 @@ class TestToolTransformation:
|
|||
"type": "object",
|
||||
}
|
||||
|
||||
def test_transform_nested_namespace_custom_tool_becomes_a_content_function_under_its_short_name(self):
|
||||
namespace_tool = {
|
||||
"type": "namespace",
|
||||
"name": "functions",
|
||||
"description": "Codex shell tools.",
|
||||
"tools": [
|
||||
{
|
||||
"type": "custom",
|
||||
"name": "exec",
|
||||
"description": "Runs a shell command.",
|
||||
"format": {"type": "grammar", "syntax": "lark", "definition": "start: /.+/"},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
result_tools, _ = (
|
||||
LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools(
|
||||
tools=[namespace_tool]
|
||||
)
|
||||
)
|
||||
|
||||
assert len(result_tools) == 1
|
||||
function = result_tools[0]["function"]
|
||||
assert function["name"] == "exec"
|
||||
assert function["description"].startswith("Codex shell tools.")
|
||||
assert "Runs a shell command." in function["description"]
|
||||
assert "start: /.+/" in function["description"]
|
||||
assert function["parameters"]["required"] == ["content"]
|
||||
assert function["parameters"]["properties"]["content"]["type"] == "string"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"model, custom_llm_provider",
|
||||
[
|
||||
|
|
@ -3788,6 +3819,143 @@ class TestEnsureOutputItemContentPartAdded:
|
|||
assert added.item.name == "spawn_agent"
|
||||
assert added.item.namespace == "collaboration"
|
||||
|
||||
def test_streaming_nested_custom_tool_call_comes_back_as_custom_tool_call(self):
|
||||
from litellm.responses.litellm_completion_transformation.custom_tools import extract_custom_tool_names
|
||||
|
||||
iterator = self._make_iterator()
|
||||
iterator.responses_api_request = {
|
||||
"tools": [
|
||||
{
|
||||
"type": "namespace",
|
||||
"name": "functions",
|
||||
"tools": [
|
||||
{
|
||||
"type": "custom",
|
||||
"name": "exec",
|
||||
"format": {"type": "grammar", "syntax": "lark", "definition": "start: /.+/"},
|
||||
}
|
||||
],
|
||||
}
|
||||
]
|
||||
}
|
||||
iterator._custom_tool_names = extract_custom_tool_names(iterator.responses_api_request.get("tools"))
|
||||
iterator._namespace_tool_names = LiteLLMCompletionResponsesConfig.namespace_tool_name_map(
|
||||
iterator.responses_api_request.get("tools")
|
||||
)
|
||||
|
||||
iterator._queue_tool_call_delta_events(
|
||||
[{"index": 0, "id": "call_exec", "function": {"name": "exec", "arguments": '{"content":"ls"}'}}]
|
||||
)
|
||||
iterator._queue_final_tool_call_done_events(
|
||||
ModelResponse(
|
||||
id="chatcmpl-exec",
|
||||
created=1,
|
||||
model="us.openai.gpt-5.6",
|
||||
object="chat.completion",
|
||||
choices=[
|
||||
Choices(
|
||||
finish_reason="tool_calls",
|
||||
index=0,
|
||||
message=Message(
|
||||
content=None,
|
||||
role="assistant",
|
||||
tool_calls=[
|
||||
ChatCompletionMessageToolCall(
|
||||
id="call_exec",
|
||||
type="function",
|
||||
function=Function(name="exec", arguments='{"content":"ls"}'),
|
||||
)
|
||||
],
|
||||
),
|
||||
)
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
added = iterator._pending_tool_events[0]
|
||||
assert added.item.type == "custom_tool_call"
|
||||
assert added.item.name == "exec"
|
||||
done = iterator._pending_tool_events[-1]
|
||||
assert done.item.type == "custom_tool_call"
|
||||
assert done.item.input == "ls"
|
||||
|
||||
def test_streaming_namespaced_function_sharing_a_nested_custom_short_name_stays_a_function_call(self):
|
||||
from litellm.responses.litellm_completion_transformation.custom_tools import extract_custom_tool_names
|
||||
|
||||
iterator = self._make_iterator()
|
||||
iterator.responses_api_request = {
|
||||
"tools": [
|
||||
{
|
||||
"type": "namespace",
|
||||
"name": "alpha",
|
||||
"tools": [
|
||||
{
|
||||
"type": "custom",
|
||||
"name": "run",
|
||||
"format": {"type": "grammar", "syntax": "lark", "definition": "start: /.+/"},
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"type": "namespace",
|
||||
"name": "beta",
|
||||
"tools": [
|
||||
{
|
||||
"type": "function",
|
||||
"name": "run",
|
||||
"parameters": {"type": "object", "properties": {"job_id": {"type": "string"}}},
|
||||
}
|
||||
],
|
||||
},
|
||||
]
|
||||
}
|
||||
iterator._custom_tool_names = extract_custom_tool_names(iterator.responses_api_request.get("tools"))
|
||||
iterator._namespace_tool_names = LiteLLMCompletionResponsesConfig.namespace_tool_name_map(
|
||||
iterator.responses_api_request.get("tools")
|
||||
)
|
||||
function_call = {"id": "call_fn", "function": {"name": "beta__run", "arguments": '{"job_id":"42"}'}}
|
||||
custom_call = {"id": "call_custom", "function": {"name": "run", "arguments": '{"content":"echo hi"}'}}
|
||||
|
||||
iterator._queue_tool_call_delta_events([{"index": 0, **function_call}, {"index": 1, **custom_call}])
|
||||
iterator._queue_final_tool_call_done_events(
|
||||
ModelResponse(
|
||||
id="chatcmpl-run",
|
||||
created=1,
|
||||
model="us.openai.gpt-5.6",
|
||||
object="chat.completion",
|
||||
choices=[
|
||||
Choices(
|
||||
finish_reason="tool_calls",
|
||||
index=0,
|
||||
message=Message(
|
||||
content=None,
|
||||
role="assistant",
|
||||
tool_calls=[
|
||||
ChatCompletionMessageToolCall(
|
||||
id=call["id"], type="function", function=Function(**call["function"])
|
||||
)
|
||||
for call in (function_call, custom_call)
|
||||
],
|
||||
),
|
||||
)
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
items = [
|
||||
event.item
|
||||
for event in iterator._pending_tool_events
|
||||
if event.type in ("response.output_item.added", "response.output_item.done")
|
||||
]
|
||||
function_items = [item for item in items if item.call_id == "call_fn"]
|
||||
custom_items = [item for item in items if item.call_id == "call_custom"]
|
||||
assert len(function_items) == 2 and len(custom_items) == 2
|
||||
assert all((item.type, item.name, item.namespace) == ("function_call", "run", "beta") for item in function_items)
|
||||
assert function_items[-1].arguments == '{"job_id":"42"}'
|
||||
assert all(item.type == "custom_tool_call" and item.name == "run" for item in custom_items)
|
||||
assert all(getattr(item, "namespace", None) is None for item in custom_items)
|
||||
assert custom_items[-1].input == "echo hi"
|
||||
|
||||
def test_streaming_unqualified_namespace_tool_calls_restore_namespace(self):
|
||||
"""A unique nested tool name without the namespace still maps back."""
|
||||
iterator = self._make_iterator()
|
||||
|
|
|
|||
48
tests/test_litellm/responses/test_additional_tools.py
Normal file
48
tests/test_litellm/responses/test_additional_tools.py
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
from litellm.responses.additional_tools import hoist_additional_tools
|
||||
|
||||
_EXEC_TOOL = {"type": "custom", "name": "exec", "format": {"type": "grammar", "syntax": "lark", "definition": "start: /.+/"}}
|
||||
_WAIT_TOOL = {"type": "function", "name": "wait", "parameters": {"type": "object", "properties": {}}}
|
||||
_TOP_LEVEL_TOOL = {"type": "function", "name": "top_level", "parameters": {"type": "object", "properties": {}}}
|
||||
_USER_MESSAGE = {"type": "message", "role": "user", "content": "Run ls"}
|
||||
|
||||
|
||||
def test_string_input_passes_through_with_existing_tools():
|
||||
hoisted = hoist_additional_tools("hello", [_TOP_LEVEL_TOOL])
|
||||
|
||||
assert hoisted.input == "hello"
|
||||
assert hoisted.tools == (_TOP_LEVEL_TOOL,)
|
||||
assert hoisted.hoisted == ()
|
||||
|
||||
|
||||
def test_input_without_additional_tools_items_is_returned_untouched():
|
||||
request_input = [_USER_MESSAGE]
|
||||
|
||||
hoisted = hoist_additional_tools(request_input, None)
|
||||
|
||||
assert hoisted.input is request_input
|
||||
assert hoisted.tools == ()
|
||||
assert hoisted.hoisted == ()
|
||||
|
||||
|
||||
def test_additional_tools_items_are_stripped_and_appended_after_top_level_tools_in_item_order():
|
||||
request_input = [
|
||||
{"type": "additional_tools", "id": "at_1", "role": "developer", "tools": [_EXEC_TOOL]},
|
||||
_USER_MESSAGE,
|
||||
{"type": "additional_tools", "id": "at_2", "role": "developer", "tools": [_WAIT_TOOL]},
|
||||
]
|
||||
|
||||
hoisted = hoist_additional_tools(request_input, [_TOP_LEVEL_TOOL])
|
||||
|
||||
assert hoisted.input == [_USER_MESSAGE]
|
||||
assert hoisted.tools == (_TOP_LEVEL_TOOL, _EXEC_TOOL, _WAIT_TOOL)
|
||||
assert hoisted.hoisted == (_EXEC_TOOL, _WAIT_TOOL)
|
||||
|
||||
|
||||
def test_additional_tools_item_without_a_tools_list_is_stripped_and_contributes_nothing():
|
||||
request_input = [{"type": "additional_tools", "id": "at_1", "role": "developer", "tools": "exec"}, _USER_MESSAGE]
|
||||
|
||||
hoisted = hoist_additional_tools(request_input, None)
|
||||
|
||||
assert hoisted.input == [_USER_MESSAGE]
|
||||
assert hoisted.tools == ()
|
||||
assert hoisted.hoisted == ()
|
||||
|
|
@ -55,6 +55,24 @@ class TestCustomToolUtilities:
|
|||
names = extract_custom_tool_names(tools)
|
||||
assert names == set()
|
||||
|
||||
def test_extract_custom_tool_names_walks_namespace_tools(self):
|
||||
tools = [
|
||||
{"type": "function", "name": "regular_tool"},
|
||||
{
|
||||
"type": "namespace",
|
||||
"name": "functions",
|
||||
"tools": [
|
||||
{"type": "custom", "name": "exec"},
|
||||
{"type": "function", "name": "wait"},
|
||||
"ignored",
|
||||
],
|
||||
},
|
||||
{"type": "namespace", "name": "empty", "tools": "not-a-list"},
|
||||
]
|
||||
|
||||
names = extract_custom_tool_names(tools)
|
||||
assert names == {"exec"}
|
||||
|
||||
def test_extract_custom_tool_names_none(self):
|
||||
"""Test extraction with None input."""
|
||||
names = extract_custom_tool_names(None)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue