fix(responses): keep the namespace == name shape for deferred top-level tools (#50602)

* fix(responses): keep the namespace == name shape for deferred top-level tools

The OpenAI Agents SDK matches a call to a deferred top-level function
(defer_loading=True) only when the function_call carries namespace == name,
the shape it reserves for deferred top-level tools. The Responses bridge
returned no namespace, so after a native tool search the SDK failed with
"Tool <name> not found". A client that adds namespace == name then hit the
echo path, which joined it into "<name>__<name>" for the provider.

- namespace_tool_name_map maps deferred top-level functions to (name, name),
  so non-streaming and streaming replies carry namespace == name
- the function_call echo does not qualify a name whose namespace equals it

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(responses): keep the qualified name for a namespace member that shares its namespace's name

A namespace "search" with a member "search" is advertised as the chat tool
"search__search" and comes back as name == namespace == "search". The echo
rule from the previous commit skipped the join for every namespace == name,
so that replay reached the provider as "search", a tool it was never given.

The bare-name echo now applies only to the request's deferred top-level
tools (deferred_top_level_names), and the reply map uses the same set. A
deferred top-level name that a self-named namespace member also uses is left
out of both directions, so the namespace member keeps its round trip.

The lookup reads defer_loading is True instead of validating it, so
callers that only read the input (token counting under drop_params) do not
raise on a malformed flag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(responses): keep the bare name for a namespace == name echo replayed without request tools

Stored history (previous_response_id) is rebuilt with no request tools, so the echo of a deferred
call carrying namespace == name used to join to name__name. Only a namespace that holds a member of
its own name still joins. The lookup also tolerates a tools value that is not a list, and the
comprehensions stay within one for and one if clause.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(responses): read request tools through one validated accessor

The deferred-name lookup and the response-side tool maps take the request's tools from one
TypeAdapter-backed accessor, so a tools value that is not a list of objects reads as no tools and
the type gate does not grow.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(responses): type the request mapping handed to the input transformer

transform_responses_api_input_to_messages takes dict[str, object] instead of a bare dict, so the
tools accessor's argument is known to the type gate and the instructions value is narrowed to str.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(responses): build the namespace tool map from the validated member names

namespace_tool_name_map reads each namespace's members through the same validated accessor as the
self-named check, which keeps the type gate's per-rule counts at their merge-base values.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(responses): replay stored turns with the tools they were sent with

Stored previous_response_id history is rebuilt with each turn's own tools, so a namespace member
that shares its namespace's name keeps the joined name__name form on replay, while a deferred
top-level call keeps the bare name. Adds the session handler regression.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(responses): keep the object entries of a tools list and read them on the streaming path too

One accessor hands the request's tools to the request path and the streaming iterator, dropping only
the entries that are not objects instead of the whole list, so both paths see the same custom, namespace
and deferred tools. Trims the explanatory comments.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(responses): count input tokens with the request's tools in hand

/v1/responses/input_tokens hands the request params, tools included, to the input transformer, so an
echoed call for a namespace member named after its namespace counts as name__name there too.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(responses): hand the request tools to every input conversion that reads an echoed call

The guardrail provenance pass, the prompt caching pre-call check and the Rust precall token count convert
Responses input with the request's tools, so a namespace member named after its namespace keeps name__name
there as it does on the request path.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(responses): keep namespace == name for deferred tools loaded by a client-side tool search

A tool the client's own tool search loaded is still a deferred top-level tool to the Agents SDK, so the bridge keeps its deferral for the response-side name map and the route strips it again before the provider sees the request.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: Homen Shum <317650355+homenshum-cheiron@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Kerry Lu 2026-11-10 14:24:15 -08:00 • committed by GitHub
parent e95bf288f3
commit 8207a57d84
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 528 additions and 45 deletions

View file

@ -111,7 +111,6 @@ from litellm.types.llms.openai import (
ResponseAPIUsage,
ResponseCompactionItem,
ResponseCompletedEvent,
ResponsesAPIOptionalRequestParams,
ResponsesAPIResponse,
ResponsesAPIStreamEvents,
ResponsesAPIStreamingResponse,
@ -299,8 +298,6 @@ _PATCHABLE_ITEM_FIELDS: Final[Mapping[str, str]] = MappingProxyType(
{"function_call_output": "output", "custom_tool_call_output": "output", "message": "content"}
)
_EMPTY_RESPONSES_REQUEST: Final[ResponsesAPIOptionalRequestParams] = {}
def _item_rewrite_field(item: Mapping[str, object]) -> str | None:
item_type: Final = item.get("type")
@ -522,13 +519,15 @@ def _provenance_unit_bounds(
def _input_item_provenance(
raw_input: Sequence[object],
expected_messages: Sequence[object],
request_tools: object = None,
) -> tuple[Mapping[int, int], frozenset[int]] | None:
if not all(isinstance(item, Mapping) for item in raw_input):
return None
request_params: Final = {"tools": request_tools}
solo_conversions: Final = tuple(
LiteLLMCompletionResponsesConfig.transform_responses_api_input_to_messages(
input=cast("ResponseInputParam", [item]), # cast-ok: items checked as Mappings above
responses_api_request=_EMPTY_RESPONSES_REQUEST,
responses_api_request=request_params,
replay_tool_search=True,
)
for item in raw_input
@ -536,7 +535,7 @@ def _input_item_provenance(
full_conversion: Final = tuple(
LiteLLMCompletionResponsesConfig.transform_responses_api_input_to_messages(
input=cast("ResponseInputParam", list(raw_input)), # cast-ok: items checked as Mappings above
responses_api_request=_EMPTY_RESPONSES_REQUEST,
responses_api_request=request_params,
replay_tool_search=True,
)
)
@ -549,7 +548,7 @@ def _input_item_provenance(
else tuple(
LiteLLMCompletionResponsesConfig.transform_responses_api_input_to_messages(
input=cast("ResponseInputParam", list(raw_input[start:end])), # cast-ok: checked as Mappings above
responses_api_request=_EMPTY_RESPONSES_REQUEST,
responses_api_request=request_params,
replay_tool_search=True,
)
)
@ -708,11 +707,12 @@ def _patched_request_fields(
instructions: object,
original_messages: Sequence[object],
structured_messages: Sequence[object],
request_tools: object = None,
) -> _RequestFields | None:
if not isinstance(raw_input, list) or len(original_messages) != len(structured_messages):
return None
offset: Final = 1 if instructions else 0
provenance: Final = _input_item_provenance(raw_input, tuple(original_messages)[offset:])
provenance: Final = _input_item_provenance(raw_input, tuple(original_messages)[offset:], request_tools)
if provenance is None:
return None
item_for_message, tainted = provenance
@ -755,10 +755,13 @@ def _patch_or_convert_request_fields(
instructions: object,
original_messages: Sequence[object],
structured_messages: Sequence[AllMessageValues],
request_tools: object = None,
) -> _RequestFields | None:
if not isinstance(structured_messages, list):
return None
patched: Final = _patched_request_fields(raw_input, instructions, original_messages, structured_messages)
patched: Final = _patched_request_fields(
raw_input, instructions, original_messages, structured_messages, request_tools
)
if patched is not None:
return patched
input_items, converted_instructions = (
@ -1042,7 +1045,7 @@ class OpenAIResponsesHandler(BaseTranslation):
)
)
return _patch_or_convert_request_fields(
data.get("input"), data.get("instructions"), structured_messages, merged
data.get("input"), data.get("instructions"), structured_messages, merged, data.get("tools")
)
def extract_request_tool_names(self, data: Mapping[str, object]) -> list[str]:

View file

@ -110,7 +110,6 @@ from litellm.router import Router
from litellm.types.llms.base import LiteLLMBaseModel
from litellm.types.llms.openai import (
REASONING_EFFORT,
ResponsesAPIOptionalRequestParams,
ResponsesAPIResponse,
)
from litellm.types.responses.main import DeleteResponseResult
@ -1709,16 +1708,18 @@ def _missing_responses_param_response(param: str) -> JSONResponse:
def _responses_input_as_token_count_messages(
input_value: str | ResponseInputParam,
instructions: str | None,
request_params: Mapping[str, object],
) -> tuple[Mapping[str, object], ...]:
from litellm.responses.litellm_completion_transformation.transformation import (
LiteLLMCompletionResponsesConfig,
)
request_params: Final[ResponsesAPIOptionalRequestParams] = {"instructions": instructions}
transformed: Final = LiteLLMCompletionResponsesConfig.transform_responses_api_input_to_messages(
input=input_value,
responses_api_request=request_params,
responses_api_request={
"instructions": request_params.get("instructions"),
"tools": request_params.get("tools"),
},
only_since_compaction=True,
)
return tuple(
@ -1799,10 +1800,7 @@ async def responses_input_tokens(
try:
payload: Final[_TokenCountPayload] = {
"model": model_name,
"messages": _responses_input_as_token_count_messages(
input_value=input_value,
instructions=data.get("instructions"),
),
"messages": _responses_input_as_token_count_messages(input_value=input_value, request_params=data),
"tools": token_fields.get("tools"),
}
token_request: Final = TokenCountRequest.model_validate(payload)

View file

@ -143,20 +143,33 @@ def _without_deferral(spec: Mapping[str, object]) -> Mapping[str, object]:
return {key: value for key, value in spec.items() if key != "defer_loading"}
def _loaded_tools(tool: Mapping[str, object]) -> tuple[_LoadedTool, ...]:
def _loaded_tools(tool: Mapping[str, object], deferred_names: frozenset[str] = frozenset()) -> tuple[_LoadedTool, ...]:
spec: Final = _tool_spec(tool)
if spec is None:
return ()
if spec.type != "namespace":
return (_LoadedTool(namespace=None, namespace_description="", spec=_without_deferral(tool)),)
# A loaded deferred tool keeps its deferral so its calls read back as namespace == name
deferred: Final = spec.defer_loading is True or spec.name in deferred_names
loaded_spec: Final = {**_without_deferral(tool), "defer_loading": True} if deferred else _without_deferral(tool)
return (_LoadedTool(namespace=None, namespace_description="", spec=loaded_spec),)
return tuple(
_LoadedTool(namespace=spec.name, namespace_description=spec.description, spec=_without_deferral(member))
for member in spec.tools
)
def _all_loaded_tools(tools: Iterable[Mapping[str, object]]) -> tuple[_LoadedTool, ...]:
return tuple(chain.from_iterable(map(_loaded_tools, tools)))
def _all_loaded_tools(
tools: Iterable[Mapping[str, object]], deferred_names: frozenset[str] = frozenset()
) -> tuple[_LoadedTool, ...]:
return tuple(chain.from_iterable(_loaded_tools(tool, deferred_names) for tool in tools))
def _deferred_top_level_names(tools: Iterable[Mapping[str, object]]) -> frozenset[str]:
return frozenset(
spec.name
for tool in tools
if (spec := _tool_spec(tool)) is not None and spec.type != "namespace" and _is_deferred(tool)
)
def _callable_names(tool: object) -> tuple[str, ...]:
@ -256,7 +269,9 @@ def bridge_client_tool_search(
if searches_on_client
else existing
)
loaded: Final = _all_loaded_tools((*loaded_earlier, *client_tool_search_loaded_tools(input)))
loaded: Final = _all_loaded_tools(
(*loaded_earlier, *client_tool_search_loaded_tools(input)), _deferred_top_level_names(existing)
)
taken: Final = frozenset(chain.from_iterable(map(_callable_names, eager)))
bridged_input: Final = (
input if isinstance(input, str) else [bridged for item in input if (bridged := _bridged_item(item)) is not None]

View file

@ -279,6 +279,11 @@ def _instruction_items_before(
)
def _stored_request_tools(proxy_server_request: Mapping[str, object] | None) -> object | None:
"""The stored turn's ``tools``, so its calls read the same namespace shapes it was sent with."""
return proxy_server_request.get("tools") if proxy_server_request is not None else None
def _stored_instructions(proxy_server_request: Mapping[str, object] | None) -> str | None:
instructions: Final = None if proxy_server_request is None else proxy_server_request.get("instructions")
return instructions if isinstance(instructions, str) and instructions else None
@ -443,6 +448,7 @@ class ResponsesSessionHandler:
)
response_input_param: str | ResponseInputParam | None = None
stored_tools: Final = _stored_request_tools(proxy_server_request_dict)
############################################################
# Add Input messages for this Spend Log
@ -463,7 +469,7 @@ class ResponsesSessionHandler:
chat_completion_message_history.extend(
LiteLLMCompletionResponsesConfig.transform_responses_api_input_to_messages(
input=bridge_client_tool_search(response_input_param, None).input,
responses_api_request={},
responses_api_request={"tools": stored_tools} if stored_tools is not None else {},
replay_reasoning=True,
item_cache_control=item_cache_control,
tool_call_provider_fields=tool_call_provider_fields,
@ -480,7 +486,7 @@ class ResponsesSessionHandler:
chat_completion_message_history.extend(
LiteLLMCompletionResponsesConfig.transform_responses_api_input_to_messages(
input=bridge_client_tool_search(responses_api_output, None).input,
responses_api_request={},
responses_api_request={"tools": stored_tools} if stored_tools is not None else {},
replay_reasoning=True,
item_cache_control=item_cache_control,
tool_call_provider_fields=tool_call_provider_fields,

View file

@ -241,7 +241,9 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator):
self._accumulated_provider_specific_fields: dict[str, object] = {}
request_tools: Final = self.responses_api_request.get("tools")
self._custom_tool_names: set[str] = extract_custom_tool_names(request_tools)
self._namespace_tool_names = LiteLLMCompletionResponsesConfig.namespace_tool_name_map(request_tools)
self._namespace_tool_names = LiteLLMCompletionResponsesConfig.namespace_tool_name_map(
LiteLLMCompletionResponsesConfig.request_tools(self.responses_api_request)
)
self._web_search_calls: dict[str, object] = {} # mutable-ok: latest call by provider id
self._queued_web_search_call_ids: set[str] = set() # mutable-ok: emitted call ids
self._tool_search = tool_search_routing(request_tools)

View file

@ -185,6 +185,7 @@ from .tool_search import (
answers_tool_search,
anthropic_tool_search_chat_tool,
build_tool_search_call,
has_client_tool_search,
has_server_tool_search,
holds_tool_search_use,
is_anthropic_tool_search_chat_tool,
@ -317,6 +318,21 @@ _TEXT_ADAPTER: Final = TypeAdapter(str)
_RESPONSES_API_TOOL_CHOICE_ADAPTER: Final = TypeAdapter(ToolChoice)
def _tool_mapping(entry: object) -> Mapping[str, object] | None:
try:
return _STR_KEY_DICT_ADAPTER.validate_python(entry)
except ValidationError:
return None
def _tool_mappings(raw_tools: object) -> tuple[Mapping[str, object], ...]:
try:
entries: Final = _OBJECT_LIST_ADAPTER.validate_python(raw_tools)
except ValidationError:
return ()
return tuple(tool for entry in entries if (tool := _tool_mapping(entry)) is not None)
def _validate_responses_media(adapter: TypeAdapter[_MediaT], value: object) -> _MediaT:
try:
return adapter.validate_python(value)
@ -1182,6 +1198,8 @@ class LiteLLMCompletionResponsesConfig:
) -> Sequence[ChatToolParam]:
if not any("defer_loading" in tool for tool in tools):
return tools
if has_client_tool_search(callers_tools):
return [LiteLLMCompletionResponsesConfig._without_deferral(tool) for tool in tools]
if any(is_anthropic_tool_search_chat_tool(tool) for tool in tools):
return tools
if not has_server_tool_search(callers_tools) and provider_builds_anthropic_request(model, custom_llm_provider):
@ -1498,6 +1516,9 @@ class LiteLLMCompletionResponsesConfig:
LiteLLMCompletionResponsesConfig._transform_response_input_param_to_chat_completion_message,
replay_reasoning=replay_reasoning,
native_compaction=native_compaction,
self_named_namespace_members=LiteLLMCompletionResponsesConfig.self_named_namespace_members(
LiteLLMCompletionResponsesConfig.request_tools(responses_api_request)
),
)
try:
converted: Final = (
@ -1679,6 +1700,7 @@ class LiteLLMCompletionResponsesConfig:
input: str | ResponseInputParam,
replay_reasoning: bool = False,
native_compaction: bool = False,
self_named_namespace_members: frozenset[str] = frozenset(),
) -> list[
AllMessageValues | GenericChatCompletionMessage | ChatCompletionMessageToolCall | ChatCompletionResponseMessage
]:
@ -1705,6 +1727,7 @@ class LiteLLMCompletionResponsesConfig:
input_item=_input,
replay_reasoning=replay_reasoning,
native_compaction=native_compaction,
self_named_namespace_members=self_named_namespace_members,
)
)
@ -2417,6 +2440,7 @@ class LiteLLMCompletionResponsesConfig:
input_item: Mapping[str, object],
replay_reasoning: bool = False,
native_compaction: bool = False,
self_named_namespace_members: frozenset[str] = frozenset(),
) -> list[AllMessageValues | GenericChatCompletionMessage | ChatCompletionResponseMessage]:
"""
Transform a Responses API input item into a Chat Completion message
@ -2470,7 +2494,8 @@ class LiteLLMCompletionResponsesConfig:
return LiteLLMCompletionResponsesConfig._transform_responses_api_function_call_to_chat_completion_message(
function_call=cast( # cast-ok: callee coerces every field it reads with `or ""` / str()
Mapping[str, str], input_item
)
),
self_named_namespace_members=self_named_namespace_members,
)
elif input_item.get("type") == "compaction":
return list(
@ -2739,6 +2764,7 @@ class LiteLLMCompletionResponsesConfig:
@staticmethod
def _transform_responses_api_function_call_to_chat_completion_message(
function_call: Mapping[str, str],
self_named_namespace_members: frozenset[str] = frozenset(),
) -> list[AllMessageValues | GenericChatCompletionMessage | ChatCompletionResponseMessage]:
"""
Transform a Responses API function_call into a Chat Completion message with tool calls
@ -2768,7 +2794,8 @@ class LiteLLMCompletionResponsesConfig:
raw_arguments = json.dumps({"content": raw_input}) if raw_input else ""
raw_name: Final = function_call.get("name") or ""
namespace: Final = function_call.get("namespace") or ""
qualify: Final = bool(namespace) and function_call.get("type") != "custom_tool_call"
deferred_top_level: Final = namespace == raw_name and raw_name not in self_named_namespace_members
qualify: Final = bool(namespace) and not deferred_top_level and function_call.get("type") != "custom_tool_call"
tool_call_id: Final = function_call.get("call_id") or function_call.get("id") or ""
tool_call_function: Final = ChatCompletionToolCallFunctionChunk(
name=f"{namespace}__{raw_name}" if qualify else raw_name,
@ -3151,15 +3178,57 @@ class LiteLLMCompletionResponsesConfig:
return result
@staticmethod
def namespace_tool_name_map(tools: ResponseTools) -> NamespaceNameMap:
namespace_entries: Final = tuple(
(str(tool.get("name") or ""), str(namespace_tool.get("name") or ""))
def request_tools(responses_api_request: Mapping[str, object] | None) -> ResponseTools:
"""The request's tools, keeping the entries that are objects."""
raw_tools: Final = responses_api_request.get("tools") if responses_api_request is not None else None
return _tool_mappings(raw_tools) if raw_tools is not None else None
@staticmethod
def _namespace_member_names(tool: Mapping[str, object]) -> tuple[str, ...]:
return tuple(
str(member.get("name") or "")
for member in _tool_mappings(tool.get("tools"))
if member.get("type") == "function"
)
@staticmethod
def self_named_namespace_members(tools: ResponseTools) -> frozenset[str]:
"""Names of ``namespace`` tools that hold a function tool of their own name.
Such a call comes back as namespace == name too, so it keeps the joined ``name__name`` form,
and a deferred top-level tool of that name does not get the namespace == name shape.
"""
return frozenset(
str(tool.get("name") or "")
for tool in tools or ()
if tool.get("type") == "namespace"
for namespace_tools in (tool.get("tools"),)
if isinstance(namespace_tools, Sequence) and not isinstance(namespace_tools, (str, bytes))
for namespace_tool in namespace_tools
if isinstance(namespace_tool, Mapping) and namespace_tool.get("type") == "function"
and str(tool.get("name") or "") in LiteLLMCompletionResponsesConfig._namespace_member_names(tool)
)
@staticmethod
def deferred_top_level_names(tools: ResponseTools) -> frozenset[str]:
"""Names of deferred top-level function tools that travel as namespace == name.
Only ``defer_loading is True`` counts here: the request path rejects other values, and
callers that only read the input (token counting under drop_params) must not raise.
"""
self_named: Final = LiteLLMCompletionResponsesConfig.self_named_namespace_members(tools)
return frozenset(
str(tool.get("name") or "")
for tool in tools or ()
if tool.get("type") == "function"
and tool.get("defer_loading") is True
and tool.get("name")
and str(tool.get("name") or "") not in self_named
)
@staticmethod
def namespace_tool_name_map(tools: ResponseTools) -> NamespaceNameMap:
namespace_entries: Final = tuple(
(str(tool.get("name") or ""), member_name)
for tool in tools or ()
if tool.get("type") == "namespace"
for member_name in LiteLLMCompletionResponsesConfig._namespace_member_names(tool)
)
top_level_function_names: Final = frozenset(
str(tool.get("name") or "") for tool in tools or () if tool.get("type") == "function"
@ -3178,7 +3247,11 @@ class LiteLLMCompletionResponsesConfig:
qualified_entries: Final = tuple(
(f"{namespace}__{tool_name}", (namespace, tool_name)) for namespace, tool_name in namespace_entries
)
return MappingProxyType(dict(qualified_entries + unambiguous_entries))
# The OpenAI Agents SDK reserves namespace == name for a deferred top-level tool (agents/_tool_identity.py)
deferred_top_level_entries: Final = tuple(
(name, (name, name)) for name in LiteLLMCompletionResponsesConfig.deferred_top_level_names(tools)
)
return MappingProxyType(dict(qualified_entries + unambiguous_entries + deferred_top_level_entries))
@staticmethod
def _restore_namespace_tool_name(tool_name: str, names: NamespaceNameMap) -> tuple[str, str | None]:
@ -3219,7 +3292,7 @@ class LiteLLMCompletionResponsesConfig:
value=tool_call,
)
request_tools: Final = responses_api_request.get("tools") if responses_api_request is not None else None
request_tools: Final = LiteLLMCompletionResponsesConfig.request_tools(responses_api_request)
custom_tool_names: Final = extract_custom_tool_names(request_tools)
namespace_tool_names: Final = LiteLLMCompletionResponsesConfig.namespace_tool_name_map(request_tools)
tool_search: Final = tool_search_routing(request_tools)

View file

@ -115,7 +115,7 @@ def _input_tokens(
instructions: Final = raw_instructions if isinstance(raw_instructions, str) else None
input_messages: Final = LiteLLMCompletionResponsesConfig.transform_responses_api_input_to_messages( # pyright: ignore[reportUnknownMemberType] # its input type is untyped
input=text_input, # pyright: ignore[reportArgumentType] # str | list, as the request carries it
responses_api_request={"instructions": instructions} if instructions is not None else {},
responses_api_request={"instructions": instructions, "tools": extras.get("tools")},
only_since_compaction=True,
)
return counter(model=tokenizer_model, messages=input_messages, tools=_tools(extras))

View file

@ -119,7 +119,7 @@ def _responses_prompt(request_data: Mapping[str, object]) -> _ResponsesPrompt |
)
messages: Final = LiteLLMCompletionResponsesConfig.transform_responses_api_input_to_messages( # pyright: ignore[reportUnknownMemberType] # its input type is untyped
input=cast(str | ResponseInputParam, request_input), # cast-ok: a bad item raises and is caught here
responses_api_request={"instructions": request_data.get("instructions")},
responses_api_request={"instructions": request_data.get("instructions"), "tools": request_tools},
item_cache_control=True,
only_since_compaction=True,
)

View file

@ -4224,6 +4224,26 @@ class TestProvenancePatching:
assert _input_item_provenance(["not a mapping"], []) is None
def test_provenance_reads_a_self_named_namespace_echo_with_the_request_tools(self):
from litellm.llms.openai.responses.guardrail_translation.handler import _input_item_provenance
from litellm.responses.litellm_completion_transformation.transformation import (
LiteLLMCompletionResponsesConfig,
)
tools = [{"type": "namespace", "name": "search", "tools": [{"type": "function", "name": "search"}]}]
raw_input = [
{"role": "user", "content": "Search for the release notes."},
{"type": "function_call", "name": "search", "namespace": "search", "call_id": "call_1", "arguments": "{}"},
{"type": "function_call_output", "call_id": "call_1", "output": "found"},
]
expected = LiteLLMCompletionResponsesConfig.transform_responses_api_input_to_messages(
input=raw_input, responses_api_request={"tools": tools}
)
assert expected[1]["tool_calls"][0]["function"]["name"] == "search__search"
assert _input_item_provenance(raw_input, expected, tools) is not None
assert _input_item_provenance(raw_input, expected) is None
def test_provenance_bails_when_expected_messages_disagree(self):
from litellm.llms.openai.responses.guardrail_translation.handler import _input_item_provenance

View file

@ -6173,6 +6173,24 @@ _SUMMARY_INPUT: Final = [{"type": "message", "role": "user", "content": _COMPACT
def test_input_token_count_reads_a_compaction_item_as_its_summary():
from litellm.proxy.response_api_endpoints.endpoints import _responses_input_as_token_count_messages
assert _responses_input_as_token_count_messages(_COMPACTED_INPUT, None) == (
_responses_input_as_token_count_messages(_SUMMARY_INPUT, None)
assert _responses_input_as_token_count_messages(_COMPACTED_INPUT, {}) == (
_responses_input_as_token_count_messages(_SUMMARY_INPUT, {})
)
def test_input_token_count_keeps_the_joined_name_of_a_self_named_namespace_member():
from litellm.proxy.response_api_endpoints.endpoints import _responses_input_as_token_count_messages
echo: Final = [
{"role": "user", "content": "Search for the release notes."},
{"type": "function_call", "name": "search", "namespace": "search", "call_id": "call_1", "arguments": "{}"},
{"type": "function_call_output", "call_id": "call_1", "output": "found"},
]
namespace_tools: Final = [{"type": "namespace", "name": "search", "tools": [{"type": "function", "name": "search"}]}]
def called_names(tools: object) -> list[str]:
messages = _responses_input_as_token_count_messages(cast(Any, echo), {"tools": tools})
return [call["function"]["name"] for message in messages for call in message.get("tool_calls") or []]
assert called_names(namespace_tools) == ["search__search"]
assert called_names([{"type": "function", "name": "search", "defer_loading": True}]) == ["search"]

View file

@ -1,6 +1,7 @@
from typing import Final
from litellm.responses.litellm_completion_transformation.client_tool_search import bridge_client_tool_search
from litellm.responses.litellm_completion_transformation.transformation import LiteLLMCompletionResponsesConfig
from litellm.types.llms.openai import ALL_RESPONSES_API_TOOL_PARAMS, ResponseInputParam
_CODEX_TOOL_SEARCH: Final = {"type": "tool_search", "execution": "client"}
@ -66,3 +67,25 @@ def test_deferred_request_tools_are_held_back_only_when_the_client_searches() ->
assert with_search.tools == (_CODEX_TOOL_SEARCH, _SHELL)
assert without_search.tools == (_SHELL, deferred)
def test_a_loaded_deferred_tool_keeps_its_deferral_so_its_calls_read_back_as_namespace_equals_name() -> None:
deferred: Final = {**_SHELL, "name": "deploy", "defer_loading": True}
loaded_plain: Final = {**_SHELL, "name": "deploy", "description": "loaded copy"}
bridged: Final = bridge_client_tool_search(
[_SEARCH_CALL, _search_output(loaded_plain)], [_CODEX_TOOL_SEARCH, _SHELL, deferred]
)
assert bridged.tools == (_CODEX_TOOL_SEARCH, _SHELL, {**loaded_plain, "defer_loading": True})
assert LiteLLMCompletionResponsesConfig.namespace_tool_name_map(
LiteLLMCompletionResponsesConfig.request_tools({"tools": list(bridged.tools)})
) == {"deploy": ("deploy", "deploy")}
def test_a_loaded_tool_that_was_never_deferred_stays_undeferred() -> None:
loaded_lint: Final = {**_SHELL, "name": "lint"}
bridged: Final = bridge_client_tool_search([_SEARCH_CALL, _search_output(loaded_lint)], [_CODEX_TOOL_SEARCH])
assert bridged.tools == (_CODEX_TOOL_SEARCH, loaded_lint)

View file

@ -910,6 +910,118 @@ class TestLiteLLMCompletionResponsesConfig:
assert tool_calls[0].namespace == "collaboration"
assert tool_calls[0].arguments == '{"message":"hello"}'
def test_transform_chat_completion_response_deferred_top_level_tool_call_has_namespace_equal_to_name(self):
"""A deferred top-level function (defer_loading=True) comes back with namespace == name,
the shape the OpenAI Agents SDK reserves for it. Without it the SDK raises
"Tool get_reporting_categories not found" after a native tool search."""
tool_call_id = "call_deferred_top_level"
chat_completion_response = ModelResponse(
id="test-response-id",
created=1234567890,
model="bedrock/invoke/anthropic.claude-opus-5",
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="get_reporting_categories", arguments='{"market":"TR-TITCK"}'),
)
],
),
)
],
)
try:
responses_api_response = (
LiteLLMCompletionResponsesConfig.transform_chat_completion_response_to_responses_api_response(
request_input="Is CHG-TR-0042 Type IB in TR-TITCK?",
responses_api_request={
"tools": [
{
"type": "function",
"name": "get_reporting_categories",
"defer_loading": True,
"parameters": {"type": "object", "properties": {"market": {"type": "string"}}},
}
]
},
chat_completion_response=chat_completion_response,
)
)
finally:
TOOL_CALLS_CACHE.delete_cache(key=tool_call_id)
tool_calls = [item for item in responses_api_response.output if item.type == "function_call"]
assert [(c.name, c.namespace, c.arguments) for c in tool_calls] == [
("get_reporting_categories", "get_reporting_categories", '{"market":"TR-TITCK"}')
]
def test_transform_chat_completion_response_tool_loaded_by_client_side_search_has_namespace_equal_to_name(self):
"""A deferred tool the client's own tool search loaded is still a deferred top-level tool to the
Agents SDK, so its call comes back as namespace == name through the client-search bridge too."""
from litellm.responses.litellm_completion_transformation.client_tool_search import bridge_client_tool_search
tool_call_id = "call_client_searched_deploy"
bridged = bridge_client_tool_search(
[
{"type": "tool_search_call", "call_id": "s1", "execution": "client", "arguments": {"query": "deploy"}},
{
"type": "tool_search_output",
"call_id": "s1",
"status": "completed",
"execution": "client",
"tools": [{"type": "function", "name": "deploy", "parameters": {"type": "object"}}],
},
],
[
{"type": "tool_search", "execution": "client"},
{"type": "function", "name": "deploy", "defer_loading": True, "parameters": {"type": "object"}},
],
)
chat_completion_response = ModelResponse(
id="test-response-id",
created=1234567890,
model="bedrock/invoke/anthropic.claude-opus-5",
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="deploy", arguments="{}")
)
],
),
)
],
)
try:
responses_api_response = (
LiteLLMCompletionResponsesConfig.transform_chat_completion_response_to_responses_api_response(
request_input=bridged.input,
responses_api_request={"tools": list(bridged.tools)},
chat_completion_response=chat_completion_response,
)
)
finally:
TOOL_CALLS_CACHE.delete_cache(key=tool_call_id)
tool_calls = [item for item in responses_api_response.output if item.type == "function_call"]
assert [(c.name, c.namespace) for c in tool_calls] == [("deploy", "deploy")]
def test_transform_chat_completion_response_plain_tool_call_has_no_namespace(self):
"""A non-namespace function call must not gain a namespace attribute, matching
the streaming path which only sets it when a namespace was restored."""
@ -1999,6 +2111,30 @@ class TestToolTransformation:
assert [tool["function"]["name"] for tool in result["tools"]] == ["get_weather", "finance__get_stock_price"]
assert all("defer_loading" not in tool for tool in result["tools"])
@pytest.mark.parametrize(
("model", "provider"),
[
("anthropic/claude-sonnet-5-5", "anthropic"),
("bedrock/invoke/us.anthropic.claude-opus-5", "bedrock"),
("gemini/gemini-2.5-flash", "gemini"),
],
)
def test_tools_loaded_by_a_client_side_search_go_out_undeferred(self, model: str, provider: str) -> None:
tools: Final = [
{"type": "tool_search", "execution": "client"},
{"type": "function", "name": "deploy", "defer_loading": True, "parameters": {"type": "object"}},
]
result: Final = LiteLLMCompletionResponsesConfig.transform_responses_api_request_to_chat_completion_request(
model=model,
input="Deploy the service.",
responses_api_request={"tools": tools},
custom_llm_provider=provider,
)
assert [tool["function"]["name"] for tool in result["tools"]] == ["tool_search", "deploy"]
assert all("defer_loading" not in tool for tool in result["tools"])
def test_native_bm25_search_tool_keeps_the_other_tools_deferred(self) -> None:
tools: Final = [
{"type": "tool_search_tool_bm25_20251119", "name": "tool_search_tool_bm25"},
@ -3144,6 +3280,104 @@ class TestToolTransformation:
assert messages[0]["tool_calls"][0]["function"]["name"] == "mcp__everything__get_sum"
def test_function_call_echo_with_namespace_equal_to_name_keeps_bare_name(self):
"""The Agents SDK echoes a deferred top-level call with namespace == name. The provider
was given the bare tool name, so the outbound chat tool_call must not become
"get_reporting_categories__get_reporting_categories"."""
messages = LiteLLMCompletionResponsesConfig.transform_responses_api_input_to_messages(
input=[
{
"type": "function_call",
"name": "get_reporting_categories",
"namespace": "get_reporting_categories",
"call_id": "call_3",
"arguments": '{"market": "KR-MFDS"}',
}
],
responses_api_request={
"tools": [{"type": "function", "name": "get_reporting_categories", "defer_loading": True}]
},
)
assert [call["function"]["name"] for call in messages[0]["tool_calls"]] == ["get_reporting_categories"]
def test_function_call_echo_with_namespace_equal_to_name_keeps_bare_name_without_request_tools(self):
"""Stored history (previous_response_id) is rebuilt with no request tools. A deferred call echoed
there still carries namespace == name and must go to the provider under its bare name, not
"get_reporting_categories__get_reporting_categories"."""
messages = LiteLLMCompletionResponsesConfig.transform_responses_api_input_to_messages(
input=[
{
"type": "function_call",
"name": "get_reporting_categories",
"namespace": "get_reporting_categories",
"call_id": "call_7",
"arguments": "{}",
}
],
responses_api_request={},
)
assert [call["function"]["name"] for call in messages[0]["tool_calls"]] == ["get_reporting_categories"]
def test_function_call_echo_from_namespace_with_member_of_same_name_keeps_qualified_name(self):
"""A namespace "search" with a member "search" is advertised as the chat tool
"search__search" and comes back as name == namespace == "search". Its echo must
still join, or the provider gets a call to a tool it was never given."""
tools = [
{
"type": "namespace",
"name": "search",
"description": "Search tools",
"tools": [{"type": "function", "name": "search", "defer_loading": True}],
}
]
messages = LiteLLMCompletionResponsesConfig.transform_responses_api_input_to_messages(
input=[
{
"type": "function_call",
"name": "search",
"namespace": "search",
"call_id": "call_4",
"arguments": "{}",
}
],
responses_api_request={"tools": tools},
)
assert [call["function"]["name"] for call in messages[0]["tool_calls"]] == ["search__search"]
assert LiteLLMCompletionResponsesConfig.deferred_top_level_names(tools) == frozenset()
def test_deferred_top_level_name_shared_with_self_named_namespace_member_stays_qualified(self):
"""When a deferred top-level "search" and a namespace "search" with a member "search"
are both in the request, namespace == name cannot tell them apart. Neither direction
uses that shape for the top-level tool, so the namespace member keeps its round trip."""
tools = [
{"type": "function", "name": "search", "defer_loading": True},
{"type": "function", "name": "lookup", "defer_loading": True},
{
"type": "namespace",
"name": "search",
"description": "Search tools",
"tools": [{"type": "function", "name": "search"}],
},
]
assert LiteLLMCompletionResponsesConfig.deferred_top_level_names(tools) == frozenset({"lookup"})
assert dict(LiteLLMCompletionResponsesConfig.namespace_tool_name_map(tools)) == {
"search__search": ("search", "search"),
"lookup": ("lookup", "lookup"),
}
messages = LiteLLMCompletionResponsesConfig.transform_responses_api_input_to_messages(
input=[
{"type": "function_call", "name": "search", "namespace": "search", "call_id": "c5", "arguments": "{}"},
{"type": "function_call", "name": "lookup", "namespace": "lookup", "call_id": "c6", "arguments": "{}"},
],
responses_api_request={"tools": tools},
)
assert [call["function"]["name"] for call in messages[0]["tool_calls"]] == ["search__search", "lookup"]
def test_custom_tool_call_echo_keeps_short_name(self):
"""Custom tools stay advertised under their short name, so a namespace on
a custom_tool_call echo is routing metadata and must not be prefixed."""
@ -3302,6 +3536,27 @@ class TestToolTransformation:
assert result["gamma__run"] == ("gamma", "run")
assert "run" not in result
def test_namespace_tool_name_map_keeps_the_object_tools_beside_a_malformed_entry(self):
tools = LiteLLMCompletionResponsesConfig.request_tools(
{
"tools": [
{
"type": "namespace",
"name": "ns",
"tools": [{"type": "function", "name": "a"}, "junk"],
},
"junk",
{"type": "function", "name": "top", "defer_loading": True},
]
}
)
assert LiteLLMCompletionResponsesConfig.namespace_tool_name_map(tools) == {
"ns__a": ("ns", "a"),
"a": ("ns", "a"),
"top": ("top", "top"),
}
def test_namespace_tool_name_map_drops_top_level_function_collision(self):
tools = [
{"type": "function", "name": "run", "parameters": {"type": "object"}},
@ -5051,7 +5306,8 @@ class TestEnsureOutputItemContentPartAdded:
iterator._responses_namespace_tool_call_fields("admin__run")
iterator._responses_namespace_tool_call_fields("admin__run")
namespace_map.assert_called_once_with(request["tools"])
namespace_map.assert_called_once()
assert namespace_map.call_args.args[0] == tuple(request["tools"])
def test_emit_terminal_event_uses_stream_finish_reason(self):
"""
@ -5453,11 +5709,21 @@ class TestHostedWebSearchReplay:
("server_tool_use", "expected_input"),
(
(
{"type": "server_tool_use", "id": "srvtoolu_01Pending", "name": "web_fetch", "input": {"url": _FETCHED_URL}},
{
"type": "server_tool_use",
"id": "srvtoolu_01Pending",
"name": "web_fetch",
"input": {"url": _FETCHED_URL},
},
{"url": _FETCHED_URL},
),
(
{"type": "server_tool_use", "id": "srvtoolu_01Pending", "name": "web_search", "input": {"query": "python"}},
{
"type": "server_tool_use",
"id": "srvtoolu_01Pending",
"name": "web_search",
"input": {"query": "python"},
},
{"query": "python"},
),
),
@ -5486,7 +5752,12 @@ class TestHostedWebSearchReplay:
assert [turn["role"] for turn in anthropic_turns] == ["user", "assistant", "user"]
assert [block for block in anthropic_turns[1]["content"] if block["type"] != "text"] == [
{"type": "server_tool_use", "id": "srvtoolu_01Pending", "name": server_tool_use["name"], "input": expected_input},
{
"type": "server_tool_use",
"id": "srvtoolu_01Pending",
"name": server_tool_use["name"],
"input": expected_input,
},
{"type": "tool_use", "id": "toolu_01Weather", "name": "get_weather", "input": {"city": "Paris"}},
]
assert [(block["type"], block["tool_use_id"]) for block in anthropic_turns[2]["content"]] == [

View file

@ -888,6 +888,57 @@ async def test_message_history_replays_real_key_named_tool_payloads() -> None:
assert json.loads(tool_message["content"]) == function_output
async def test_message_history_keeps_the_joined_name_of_a_stored_self_named_namespace_member() -> None:
request_id: Final = "chatcmpl-self-named-namespace"
responses_request_body: Final = {
"model": "anthropic/claude-sonnet-4-5",
"tools": [
{
"type": "namespace",
"name": "search",
"tools": [{"type": "function", "name": "search", "parameters": {"type": "object", "properties": {}}}],
}
],
"input": [
{"role": "user", "content": "Search for the release notes."},
{"type": "function_call", "call_id": "call_1", "name": "search", "namespace": "search", "arguments": "{}"},
{"type": "function_call_output", "call_id": "call_1", "output": "release notes found"},
],
}
with patch(
"litellm.proxy.spend_tracking.spend_tracking_utils.should_store_prompts_and_responses_in_spend_logs",
return_value=True,
):
proxy_server_request: Final = json.loads(
_get_proxy_server_request_for_spend_logs_payload(
metadata={},
litellm_params={"proxy_server_request": {"body": responses_request_body}},
kwargs={},
)
)
spend_log: Final = {
"request_id": request_id,
"call_type": "aresponses",
"session_id": "session-self-named-namespace",
"proxy_server_request": proxy_server_request,
"response": _chat_completion_response(request_id, "Found them."),
}
with patch.object(
ResponsesSessionHandler,
"get_all_spend_logs_for_previous_response_id",
new_callable=AsyncMock,
) as mock_get_spend_logs:
mock_get_spend_logs.return_value = [spend_log]
result: Final = await ResponsesSessionHandler.get_chat_completion_message_history_for_previous_response_id(
request_id
)
assert [call["function"]["name"] for call in result["messages"][1]["tool_calls"]] == ["search__search"]
def _turn_spend_log(
turn_input: str | list[dict[str, str]], answer: str, compaction_blocks: list[dict[str, str]] | None = None
) -> dict[str, object]:
@ -1162,7 +1213,10 @@ async def test_an_item_a_poll_caught_half_written_replays_its_finished_text_once
"resp_poll_done",
"aget_responses",
"completed",
[_identified_output_message("msg_answer", "One, two, three"), _identified_output_message("msg_more", "Done")],
[
_identified_output_message("msg_answer", "One, two, three"),
_identified_output_message("msg_more", "Done"),
],
),
)