diff --git a/litellm/llms/openai/responses/guardrail_translation/handler.py b/litellm/llms/openai/responses/guardrail_translation/handler.py index 5b3b8e88834..8e9f2a0a0bd 100644 --- a/litellm/llms/openai/responses/guardrail_translation/handler.py +++ b/litellm/llms/openai/responses/guardrail_translation/handler.py @@ -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]: diff --git a/litellm/proxy/response_api_endpoints/endpoints.py b/litellm/proxy/response_api_endpoints/endpoints.py index 574a677061d..11a0b947abe 100644 --- a/litellm/proxy/response_api_endpoints/endpoints.py +++ b/litellm/proxy/response_api_endpoints/endpoints.py @@ -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) diff --git a/litellm/responses/litellm_completion_transformation/client_tool_search.py b/litellm/responses/litellm_completion_transformation/client_tool_search.py index 030bb014b7a..1c715fc1191 100644 --- a/litellm/responses/litellm_completion_transformation/client_tool_search.py +++ b/litellm/responses/litellm_completion_transformation/client_tool_search.py @@ -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] diff --git a/litellm/responses/litellm_completion_transformation/session_handler.py b/litellm/responses/litellm_completion_transformation/session_handler.py index 6dbd7d69d11..2d40092c7b1 100644 --- a/litellm/responses/litellm_completion_transformation/session_handler.py +++ b/litellm/responses/litellm_completion_transformation/session_handler.py @@ -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, diff --git a/litellm/responses/litellm_completion_transformation/streaming_iterator.py b/litellm/responses/litellm_completion_transformation/streaming_iterator.py index 040e2af2a08..489ab8a317d 100644 --- a/litellm/responses/litellm_completion_transformation/streaming_iterator.py +++ b/litellm/responses/litellm_completion_transformation/streaming_iterator.py @@ -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) diff --git a/litellm/responses/litellm_completion_transformation/transformation.py b/litellm/responses/litellm_completion_transformation/transformation.py index 3c1abbfe492..22f4c218879 100644 --- a/litellm/responses/litellm_completion_transformation/transformation.py +++ b/litellm/responses/litellm_completion_transformation/transformation.py @@ -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) diff --git a/litellm/router_rust/steps/precall.py b/litellm/router_rust/steps/precall.py index c68c5fd5982..9de5c47b820 100644 --- a/litellm/router_rust/steps/precall.py +++ b/litellm/router_rust/steps/precall.py @@ -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)) diff --git a/litellm/router_utils/pre_call_checks/prompt_caching_deployment_check.py b/litellm/router_utils/pre_call_checks/prompt_caching_deployment_check.py index 2f0cbc0d474..47818f997b3 100644 --- a/litellm/router_utils/pre_call_checks/prompt_caching_deployment_check.py +++ b/litellm/router_utils/pre_call_checks/prompt_caching_deployment_check.py @@ -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, ) diff --git a/tests/unit/llms/openai/responses/test_openai_responses_guardrail_handler.py b/tests/unit/llms/openai/responses/test_openai_responses_guardrail_handler.py index 8902cc59f0f..b2bc886a6b7 100644 --- a/tests/unit/llms/openai/responses/test_openai_responses_guardrail_handler.py +++ b/tests/unit/llms/openai/responses/test_openai_responses_guardrail_handler.py @@ -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 diff --git a/tests/unit/proxy/response_api_endpoints/test_endpoints.py b/tests/unit/proxy/response_api_endpoints/test_endpoints.py index 2c23305d5c9..e4e4b27e1c2 100644 --- a/tests/unit/proxy/response_api_endpoints/test_endpoints.py +++ b/tests/unit/proxy/response_api_endpoints/test_endpoints.py @@ -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"] diff --git a/tests/unit/responses/litellm_completion_transformation/test_client_tool_search.py b/tests/unit/responses/litellm_completion_transformation/test_client_tool_search.py index dc975b3d112..03027d07976 100644 --- a/tests/unit/responses/litellm_completion_transformation/test_client_tool_search.py +++ b/tests/unit/responses/litellm_completion_transformation/test_client_tool_search.py @@ -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) diff --git a/tests/unit/responses/litellm_completion_transformation/test_litellm_completion_responses.py b/tests/unit/responses/litellm_completion_transformation/test_litellm_completion_responses.py index 8fb066d437f..0e16276ffa5 100644 --- a/tests/unit/responses/litellm_completion_transformation/test_litellm_completion_responses.py +++ b/tests/unit/responses/litellm_completion_transformation/test_litellm_completion_responses.py @@ -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"]] == [ diff --git a/tests/unit/responses/litellm_completion_transformation/test_session_handler.py b/tests/unit/responses/litellm_completion_transformation/test_session_handler.py index 9a8fa19e2e4..3e2a2c2cf34 100644 --- a/tests/unit/responses/litellm_completion_transformation/test_session_handler.py +++ b/tests/unit/responses/litellm_completion_transformation/test_session_handler.py @@ -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"), + ], ), )