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:
Mateo Wang 2026-09-14 23:33:38 -07:00 committed by GitHub
commit 1ce66e98a2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 560 additions and 104 deletions

View file

@ -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:

View file

@ -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 ()

View 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)

View file

@ -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,

View file

@ -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", {}),
)

View file

@ -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,

View file

@ -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,

View file

@ -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=[

View file

@ -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():

View file

@ -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")]

View file

@ -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()

View 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 == ()

View file

@ -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)