diff --git a/litellm/responses/litellm_completion_transformation/custom_tools.py b/litellm/responses/litellm_completion_transformation/custom_tools.py index de4df3175e4b..fa4ed73a1d67 100644 --- a/litellm/responses/litellm_completion_transformation/custom_tools.py +++ b/litellm/responses/litellm_completion_transformation/custom_tools.py @@ -111,7 +111,7 @@ class _CustomToolFormat(BaseModel): _ALLOWED_CALLERS_ADAPTER: Final = TypeAdapter(list[str] | None) -def _validated_allowed_callers(value: object) -> list[str] | None: +def validated_allowed_callers(value: object) -> list[str] | None: try: return _ALLOWED_CALLERS_ADAPTER.validate_python(value, strict=True) except ValidationError as exc: @@ -143,7 +143,7 @@ def convert_custom_tool_to_function_tool(tool: Mapping[str, object]) -> ChatComp 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")) - allowed_callers: Final = _validated_allowed_callers(tool.get("allowed_callers")) + allowed_callers: Final = validated_allowed_callers(tool.get("allowed_callers")) function_chunk: Final = ChatCompletionToolParamFunctionChunk( name=name, description=description, diff --git a/litellm/responses/litellm_completion_transformation/streaming_iterator.py b/litellm/responses/litellm_completion_transformation/streaming_iterator.py index 5a7380a55b8e..aa5708088b76 100644 --- a/litellm/responses/litellm_completion_transformation/streaming_iterator.py +++ b/litellm/responses/litellm_completion_transformation/streaming_iterator.py @@ -106,6 +106,9 @@ def __init__( self._accumulated_reasoning_content_parts: list[str] = [] self._accumulated_provider_specific_fields: dict[str, Any] = {} self._custom_tool_names: set[str] = extract_custom_tool_names(self.responses_api_request.get("tools")) + self._namespace_tool_names = LiteLLMCompletionResponsesConfig.namespace_tool_name_map( + self.responses_api_request.get("tools") + ) def _get_or_assign_tool_output_index(self, call_id: str) -> int: existing: Final = self._tool_output_index_by_call_id.get(call_id) @@ -125,6 +128,13 @@ def _normalize_tool_call_index(self, tool_call: object) -> int | None: except (TypeError, ValueError): return None + def _responses_namespace_tool_call_fields(self, fn_name: str) -> tuple[str, str | None]: + mapped: Final = self._namespace_tool_names.get(fn_name) + if mapped: + namespace, tool_name = mapped + return tool_name, namespace + return fn_name, None + def _is_reasoning_end(self, chunk): delta: Final = chunk.choices[0].delta @@ -183,13 +193,17 @@ def _queue_tool_call_delta_events(self, tool_calls: object) -> None: else: fn_name = str(getattr(fn, "name", "") or "") fn_args_delta = str(getattr(fn, "arguments", "") or "") + 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 - item_kwargs = build_tool_call_item_kwargs(call_id, fn_name, "", "in_progress", self._custom_tool_names) + names = self._custom_tool_names + item_kwargs = build_tool_call_item_kwargs(call_id, tool_name, "", "in_progress", names) + if tool_namespace: + item_kwargs["namespace"] = tool_namespace event = OutputItemAddedEvent( type=ResponsesAPIStreamEvents.OUTPUT_ITEM_ADDED, output_index=output_index, @@ -250,6 +264,7 @@ def _queue_final_tool_call_done_events(self, litellm_complete_object: ModelRespo else: fn_name = str(getattr(fn, "name", "") or "") fn_args = str(getattr(fn, "arguments", "") or "") + tool_name, tool_namespace = self._responses_namespace_tool_call_fields(fn_name) # Track if this is a new tool call that wasn't streamed is_new_tool_call = call_id not in self._tool_args_by_call_id @@ -258,7 +273,10 @@ def _queue_final_tool_call_done_events(self, litellm_complete_object: ModelRespo if is_new_tool_call: self._tool_args_by_call_id[call_id] = "" self._sequence_number += 1 - item_kwargs = build_tool_call_item_kwargs(call_id, fn_name, "", "in_progress", self._custom_tool_names) + names = self._custom_tool_names + item_kwargs = build_tool_call_item_kwargs(call_id, tool_name, "", "in_progress", names) + if tool_namespace: + item_kwargs["namespace"] = tool_namespace event = OutputItemAddedEvent( type=ResponsesAPIStreamEvents.OUTPUT_ITEM_ADDED, output_index=output_index, @@ -300,9 +318,10 @@ def _queue_final_tool_call_done_events(self, litellm_complete_object: ModelRespo self._pending_tool_events.append(done_event) self._sequence_number += 1 - item_kwargs = build_tool_call_item_kwargs( - call_id, fn_name, final_args, "completed", self._custom_tool_names - ) + names = self._custom_tool_names + item_kwargs = build_tool_call_item_kwargs(call_id, tool_name, final_args, "completed", names) + if tool_namespace: + item_kwargs["namespace"] = tool_namespace item_done_event = OutputItemDoneEvent( type=ResponsesAPIStreamEvents.OUTPUT_ITEM_DONE, output_index=output_index, diff --git a/litellm/responses/litellm_completion_transformation/transformation.py b/litellm/responses/litellm_completion_transformation/transformation.py index b2d065ea23bc..0377996021c0 100644 --- a/litellm/responses/litellm_completion_transformation/transformation.py +++ b/litellm/responses/litellm_completion_transformation/transformation.py @@ -5,7 +5,17 @@ import json import re from collections.abc import Iterator, Mapping, Sequence -from typing import TYPE_CHECKING, Any, Final, Literal, Protocol, cast, runtime_checkable +from types import MappingProxyType +from typing import ( + TYPE_CHECKING, + Any, + Final, + Literal, + Protocol, + TypeAlias, + cast, + runtime_checkable, +) from openai.types.chat.chat_completion_named_tool_choice_param import ( ChatCompletionNamedToolChoiceParam, @@ -38,9 +48,11 @@ ChatCompletionToolCallFunctionChunk, ChatCompletionToolMessage, ChatCompletionToolParam, + ChatCompletionToolParamFunctionChunk, ChatCompletionUserMessage, GenericChatCompletionMessage, InputTokensDetails, + OpenAIChatCompletionTextObject, OpenAIMcpServerTool, OpenAIWebSearchOptions, OpenAIWebSearchUserLocation, @@ -77,8 +89,13 @@ extract_custom_tool_names, is_custom_tool_call, unwrap_custom_tool_arguments, + validated_allowed_callers, ) +NamespaceNameMap: TypeAlias = Mapping[str, tuple[str, str]] +NamespaceTool: TypeAlias = Mapping[str, object] +ResponseTools: TypeAlias = Sequence[Mapping[str, object]] | None + if TYPE_CHECKING: from openai.types.responses.response_apply_patch_tool_call import ( ResponseApplyPatchToolCall, @@ -528,9 +545,52 @@ def _transform_response_input_param_to_chat_completion_message( messages.extend(deduped_in_place) continue + merged_assistant = LiteLLMCompletionResponsesConfig._merged_trailing_assistant_message( + messages=messages, + chat_completion_messages=chat_completion_messages, + ) + if merged_assistant is not None: + messages[-1] = merged_assistant + continue + messages.extend(chat_completion_messages) return messages + @staticmethod + def _merged_trailing_assistant_message( + messages: Sequence[ + AllMessageValues + | GenericChatCompletionMessage + | ChatCompletionMessageToolCall + | ChatCompletionResponseMessage + ], + chat_completion_messages: Sequence[ + AllMessageValues | GenericChatCompletionMessage | ChatCompletionResponseMessage + ], + ) -> ChatCompletionResponseMessage | None: + """Fold an assistant content message into a directly preceding assistant + tool_calls message. Providers like DeepSeek and Anthropic require tool + results immediately after the tool_calls message, so an assistant message + between them is rejected.""" + if not messages or len(chat_completion_messages) != 1: + return None + last_message = messages[-1] + new_message = chat_completion_messages[0] + if not isinstance(last_message, dict): + return None + if last_message.get("role") != "assistant" or new_message.get("role") != "assistant": + return None + if not last_message.get("tool_calls") or last_message.get("content") or new_message.get("tool_calls"): + return None + new_content = new_message.get("content") + if new_content is None: + return None + merged: Final = { # mutable-ok: json.dumps rejects MappingProxyType in outbound chat messages + **last_message, + "content": new_content, + } + return cast(ChatCompletionResponseMessage, merged) # cast-ok: TypedDict spread widens to dict[str, object] + @staticmethod def _deduplicate_tool_call_output_messages( tool_call_output_messages: list[ @@ -1163,11 +1223,14 @@ def _transform_responses_api_function_call_to_chat_completion_message( if not raw_arguments and function_call.get("type") == "custom_tool_call": raw_input: Final = function_call.get("input") or "" 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" tool_call: Final = ChatCompletionToolCallChunk( id=function_call.get("call_id") or function_call.get("id") or "", type="function", function=ChatCompletionToolCallFunctionChunk( - name=function_call.get("name") or "", + name=f"{namespace}__{raw_name}" if qualify else raw_name, arguments=str(raw_arguments or ""), ), index=0, @@ -1260,6 +1323,12 @@ def _transform_responses_api_content_to_chat_completion_content( if "cache_control" in item: image_block["cache_control"] = item["cache_control"] content_list.append(image_block) + elif item.get("type") == "encrypted_content": + encrypted_content = item.get("encrypted_content") + if encrypted_content is not None: + content_list.append( + OpenAIChatCompletionTextObject(type="text", text=str(encrypted_content)) + ) else: # Skip text blocks with None text to avoid downstream errors text_value = item.get("text") @@ -1320,6 +1389,92 @@ def transform_instructions_to_system_message( """ return ChatCompletionSystemMessage(role="system", content=instructions or "") + @staticmethod + def _build_ns_chat_tool( + namespace: str, + namespace_description: str, + namespace_tool: NamespaceTool, + nested: bool, + ) -> ChatCompletionToolParam | None: + if nested and namespace_tool.get("type") != "function": + return None + + raw_parameters: Final = namespace_tool.get("parameters") + parameters: Final = ( + MappingProxyType(raw_parameters) if isinstance(raw_parameters, Mapping) else MappingProxyType({}) + ) + normalized_parameters: Final = ( + 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}\n\n{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, + description=description, + parameters=dict( # mutable-ok: json.dumps rejects MappingProxyType in the outbound payload + normalized_parameters + ), + strict=bool(namespace_tool.get("strict", False)), + ) + allowed_callers: Final = validated_allowed_callers(namespace_tool.get("allowed_callers")) + if allowed_callers is None: + return ChatCompletionToolParam(type="function", function=function) + return ChatCompletionToolParam(type="function", function=function, allowed_callers=allowed_callers) + + @staticmethod + def _namespace_chat_tools(tool: NamespaceTool) -> tuple[ChatCompletionToolParam, ...]: + namespace: Final = str(tool.get("name") or "") + namespace_description: Final = str(tool.get("description") or "") + namespace_tools: Final = tool.get("tools") + if isinstance(namespace_tools, Sequence) and not isinstance(namespace_tools, (str, bytes)): + return tuple( + chat_tool + for raw_tool in namespace_tools + if isinstance(raw_tool, Mapping) + if ( + chat_tool := LiteLLMCompletionResponsesConfig._build_ns_chat_tool( + namespace, + namespace_description, + raw_tool, + True, + ) + ) + is not None + ) + flat_tool: Final = LiteLLMCompletionResponsesConfig._build_ns_chat_tool( + namespace, namespace_description, tool, False + ) + return (flat_tool,) if flat_tool is not None else () + + @staticmethod + def _validate_namespace_name_collisions(tools: ResponseTools) -> None: + top_level_function_names: Final = frozenset( + str(tool.get("name") or "") for tool in tools or () if tool.get("type") == "function" + ) + flattened_namespace_names: Final = frozenset( + f"{(tool.get('name') or '')!s}__{(namespace_tool.get('name') or '')!s}" + 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" + ) + conflicting_tool_names: Final = top_level_function_names & flattened_namespace_names + if conflicting_tool_names: + raise ValueError( + "Top-level function names conflict with flattened namespace tools: " + + ", ".join(sorted(conflicting_tool_names)) + ) + @staticmethod def transform_responses_api_tools_to_chat_completion_tools( tools: list[FunctionToolParam | OpenAIMcpServerTool] | None, @@ -1332,6 +1487,7 @@ def transform_responses_api_tools_to_chat_completion_tools( """ if tools is None: return [], None + LiteLLMCompletionResponsesConfig._validate_namespace_name_collisions(tools) chat_completion_tools: Final[list[ChatCompletionToolParam | OpenAIMcpServerTool]] = [] web_search_options: OpenAIWebSearchOptions | None = None for tool in tools: @@ -1373,13 +1529,15 @@ def transform_responses_api_tools_to_chat_completion_tools( if tool.get("input_examples"): chat_completion_tool["input_examples"] = tool.get("input_examples") chat_completion_tools.append(cast(ChatCompletionToolParam, chat_completion_tool)) + elif tool.get("type") == "namespace": + chat_completion_tools.extend(LiteLLMCompletionResponsesConfig._namespace_chat_tools(tool)) elif tool.get("type") == "custom": converted = convert_custom_tool_to_function_tool(tool) if converted is not None: chat_completion_tools.append(converted) else: _tool_type = tool.get("type") - if _tool_type in ("computer_use", "image_generation", "namespace", "shell"): + if _tool_type in ("computer_use", "image_generation", "shell"): # Drop unsupported Responses-API-only tool types that have no # Chat Completions equivalent. Passing them through verbatim # causes providers to reject the request with "'function' is a @@ -1435,6 +1593,44 @@ def transform_chat_completion_tool_params_to_responses_api_tools( result.append(dict(tool)) 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 "")) + 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" + ) + top_level_function_names: Final = frozenset( + str(tool.get("name") or "") for tool in tools or () if tool.get("type") == "function" + ) + unqualified_counts: Final = MappingProxyType( + { + tool_name: sum(1 for _, candidate_name in namespace_entries if candidate_name == tool_name) + for tool_name in frozenset(tool_name for _, tool_name in namespace_entries) + } + ) + unambiguous_entries: Final = tuple( + (tool_name, (namespace, tool_name)) + for namespace, tool_name in namespace_entries + if tool_name not in top_level_function_names and unqualified_counts[tool_name] == 1 + ) + 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)) + + @staticmethod + def _restore_namespace_tool_name(tool_name: str, names: NamespaceNameMap) -> tuple[str, str | None]: + mapped = names.get(tool_name) + if mapped is None: + return tool_name, None + namespace, restored_tool_name = mapped + return restored_tool_name, namespace + @staticmethod def transform_chat_completion_tools_to_responses_tools( chat_completion_response: ModelResponse, @@ -1458,10 +1654,9 @@ def transform_chat_completion_tools_to_responses_tools( value=tool_call, ) - # Extract custom tool names from the original request - custom_tool_names: set[str] = set() - if responses_api_request and "tools" in responses_api_request: - custom_tool_names = extract_custom_tool_names(responses_api_request["tools"]) + request_tools: Final = responses_api_request.get("tools") if responses_api_request is not None else None + custom_tool_names: Final = extract_custom_tool_names(request_tools) + namespace_tool_names: Final = LiteLLMCompletionResponsesConfig.namespace_tool_name_map(request_tools) responses_tools: Final[list[ResponseFunctionToolCall | CustomToolCallOutputItem]] = [] for tool in all_chat_completion_tools: @@ -1486,6 +1681,9 @@ def transform_chat_completion_tools_to_responses_tools( responses_tools.append(custom_item) else: # Build regular function_call output item + restore_name = LiteLLMCompletionResponsesConfig._restore_namespace_tool_name + tool_name, namespace = restore_name(tool_name, namespace_tool_names) + provider_specific_fields: dict | None = None if hasattr(tool, "provider_specific_fields") and getattr(tool, "provider_specific_fields", None): provider_specific_fields = getattr(tool, "provider_specific_fields") @@ -1510,6 +1708,8 @@ def transform_chat_completion_tools_to_responses_tools( type="function_call", status=function_definition.get("status") or "completed", ) + if namespace: + output_tool_call.namespace = namespace # Pass through provider_specific_fields as-is if present if provider_specific_fields: diff --git a/tests/test_litellm/responses/litellm_completion_transformation/test_litellm_completion_responses.py b/tests/test_litellm/responses/litellm_completion_transformation/test_litellm_completion_responses.py index 51f757c9eafd..d3aa8eddf45a 100644 --- a/tests/test_litellm/responses/litellm_completion_transformation/test_litellm_completion_responses.py +++ b/tests/test_litellm/responses/litellm_completion_transformation/test_litellm_completion_responses.py @@ -1,3 +1,4 @@ +import json import os import sys @@ -11,10 +12,6 @@ TOOL_CALLS_CACHE, LiteLLMCompletionResponsesConfig, ) -from litellm.types.llms.openai import ( - ChatCompletionResponseMessage, - ChatCompletionToolMessage, -) from litellm.types.utils import ( ChatCompletionMessageToolCall, Choices, @@ -608,6 +605,181 @@ def test_transform_chat_completion_response_handles_missing_hidden_params(self): assert hasattr(responses_api_response, "_hidden_params") assert responses_api_response._hidden_params == {} + def test_transform_chat_completion_response_restores_namespace_tool_call(self): + tool_call_id = "call_namespace_restore" + chat_completion_response = ModelResponse( + id="test-response-id", + created=1234567890, + model="gemini-3.1-pro-preview-customtools", + 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="collaboration__spawn_agent", + arguments='{"message":"hello"}', + ), + ) + ], + ), + ) + ], + ) + + try: + responses_api_response = LiteLLMCompletionResponsesConfig.transform_chat_completion_response_to_responses_api_response( + request_input="Spawn an agent", + responses_api_request={ + "tools": [ + { + "type": "namespace", + "name": "collaboration", + "tools": [ + { + "type": "function", + "name": "spawn_agent", + "parameters": { + "type": "object", + "properties": {}, + }, + } + ], + } + ] + }, + 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 len(tool_calls) == 1 + assert tool_calls[0].name == "spawn_agent" + assert tool_calls[0].namespace == "collaboration" + assert tool_calls[0].arguments == '{"message":"hello"}' + + 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.""" + tool_call_id = "call_plain_no_namespace" + chat_completion_response = ModelResponse( + id="test-response-id", + created=1234567890, + model="gpt-4o", + 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_weather", + arguments='{"city":"Paris"}', + ), + ) + ], + ), + ) + ], + ) + + try: + responses_api_response = LiteLLMCompletionResponsesConfig.transform_chat_completion_response_to_responses_api_response( + request_input="What is the weather in Paris?", + responses_api_request={ + "tools": [ + { + "type": "function", + "name": "get_weather", + "parameters": {"type": "object", "properties": {}}, + } + ] + }, + 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 len(tool_calls) == 1 + assert tool_calls[0].name == "get_weather" + assert tool_calls[0].namespace is None + assert "namespace" not in tool_calls[0].model_fields_set + + + def test_transform_top_level_function_collision_stays_unnamespaced(self): + tool_call_id = "call_top_level_collision" + chat_completion_response = ModelResponse( + 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="run", arguments="{}"), + ) + ], + ), + ) + ] + ) + responses_api_request = { + "tools": [ + {"type": "function", "name": "run", "parameters": {"type": "object"}}, + { + "type": "namespace", + "name": "admin", + "tools": [ + { + "type": "function", + "name": "run", + "parameters": {"type": "object"}, + } + ], + }, + ] + } + + try: + response = LiteLLMCompletionResponsesConfig.transform_chat_completion_response_to_responses_api_response( + request_input="Run the tool", + responses_api_request=responses_api_request, + chat_completion_response=chat_completion_response, + ) + finally: + TOOL_CALLS_CACHE.delete_cache(key=tool_call_id) + + tool_call = next(item for item in response.output if item.type == "function_call") + assert tool_call.name == "run" + assert getattr(tool_call, "namespace", None) is None + class TestFunctionCallTransformation: """Test cases for function_call input transformation""" @@ -1037,6 +1209,29 @@ def test_none_text_blocks_filtered_out(self): assert result[0]["text"] == "valid text" assert result[1]["text"] == "another valid" + def test_encrypted_content_blocks_preserved_as_text(self): + """ + OpenAI Responses agent messages can include encrypted_content blocks. + Chat-completions providers need the payload as text instead of silently + dropping it. + """ + content = [ + {"type": "input_text", "text": "Payload:\n"}, + { + "type": "encrypted_content", + "encrypted_content": "Reply exactly INPUT_AGENT_OK", + }, + ] + + result = LiteLLMCompletionResponsesConfig._transform_responses_api_content_to_chat_completion_content( + content + ) + + assert result == [ + {"type": "text", "text": "Payload:\n"}, + {"type": "text", "text": "Reply exactly INPUT_AGENT_OK"}, + ] + class TestToolTransformation: """Test cases for tool transformation from Responses API to Chat Completion format""" @@ -1642,6 +1837,335 @@ def test_unsupported_non_bedrock_provider_drops_derived_web_search_options(self) assert "web_search_options" not in result + def test_transform_nested_namespace_tools_to_function_tools(self): + """Codex Responses namespace tools contain nested functions that chat + providers need as flattened function names.""" + namespace_tool = { + "type": "namespace", + "name": "collaboration", + "description": "Multi-agent tools", + "tools": [ + { + "type": "function", + "name": "spawn_agent", + "description": "Spawn an agent", + "parameters": { + "type": "object", + "properties": { + "task_name": {"type": "string"}, + "message": {"type": "string"}, + }, + "required": ["task_name", "message"], + }, + } + ], + } + + result_tools, web_search_options = ( + LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools( + tools=[namespace_tool] + ) + ) + + assert web_search_options is None + assert len(result_tools) == 1 + result_tool = result_tools[0] + assert result_tool["type"] == "function" + assert result_tool["function"]["name"] == "collaboration__spawn_agent" + assert result_tool["function"]["parameters"] == namespace_tool["tools"][0]["parameters"] + assert result_tool["function"]["description"] == "Multi-agent tools\n\nSpawn an agent" + + def test_transform_namespace_tools_are_json_serializable(self): + """Outbound chat payloads go through json.dumps, which rejects MappingProxyType.""" + namespace_tool = { + "type": "namespace", + "name": "mcp__everything", + "description": "MCP tools", + "tools": [ + { + "type": "function", + "name": "get_sum", + "description": "Add two numbers", + "parameters": { + "type": "object", + "properties": {"a": {"type": "number"}, "b": {"type": "number"}}, + "required": ["a", "b"], + }, + } + ], + } + + result_tools, _ = LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools( + tools=[namespace_tool] + ) + + assert "mcp__everything__get_sum" in json.dumps(result_tools) + + def test_function_call_echo_requalifies_namespace_tool_name(self): + """Codex echoes restored history items as short name plus namespace; the + outbound chat tool_call must use the flattened name the provider was given.""" + messages = LiteLLMCompletionResponsesConfig._transform_responses_api_function_call_to_chat_completion_message( + function_call={ + "type": "function_call", + "name": "get_sum", + "namespace": "mcp__everything", + "call_id": "call_1", + "arguments": '{"a": 21, "b": 21}', + } + ) + + assert messages[0]["tool_calls"][0]["function"]["name"] == "mcp__everything__get_sum" + + 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.""" + messages = LiteLLMCompletionResponsesConfig._transform_responses_api_function_call_to_chat_completion_message( + function_call={ + "type": "custom_tool_call", + "name": "apply_patch", + "namespace": "mcp__everything", + "call_id": "call_2", + "input": "patch body", + } + ) + + assert messages[0]["tool_calls"][0]["function"]["name"] == "apply_patch" + + @pytest.mark.parametrize("nested", [True, False]) + def test_transform_namespace_tools_preserves_allowed_callers(self, nested): + function_tool = { + "type": "function", + "name": "spawn_agent", + "parameters": {"type": "object", "properties": {}}, + "allowed_callers": ["code_execution_20250825"], + } + namespace_tool = ( + { + "type": "namespace", + "name": "collaboration", + "tools": [function_tool], + } + if nested + else {**function_tool, "type": "namespace"} + ) + + result_tools, _ = LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools( + tools=[namespace_tool] + ) + + assert result_tools[0]["allowed_callers"] == ["code_execution_20250825"] + + + @pytest.mark.parametrize("nested", [True, False]) + def test_transform_namespace_tools_rejects_invalid_allowed_callers(self, nested): + function_tool = { + "type": "function", + "name": "spawn_agent", + "parameters": {"type": "object", "properties": {}}, + "allowed_callers": "code_execution_20250825", + } + namespace_tool = ( + { + "type": "namespace", + "name": "collaboration", + "tools": [function_tool], + } + if nested + else {**function_tool, "type": "namespace"} + ) + + with pytest.raises(ValueError, match="allowed_callers must be a list of strings"): + LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools( + tools=[namespace_tool] + ) + + + def test_transform_flat_namespace_tools_to_function_tools(self): + namespace_tool = { + "type": "namespace", + "name": "mcp__node_repl", + "description": "Run JavaScript in the node REPL", + "parameters": { + "type": "object", + "properties": { + "code": { + "type": "string", + "description": "JavaScript source to evaluate", + } + }, + "required": ["code"], + }, + } + + result_tools, web_search_options = ( + LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools( + tools=[namespace_tool] + ) + ) + + assert web_search_options is None + assert len(result_tools) == 1 + result_tool = result_tools[0] + assert result_tool["type"] == "function" + assert result_tool["function"]["name"] == "mcp__node_repl" + assert result_tool["function"]["description"] == "Run JavaScript in the node REPL" + assert result_tool["function"]["parameters"] == namespace_tool["parameters"] + + def test_namespace_tool_name_map_accepts_unique_unqualified_tool_names(self): + """Some chat providers return the nested tool name without its namespace.""" + namespace_tool = { + "type": "namespace", + "name": "collaboration", + "tools": [ + { + "type": "function", + "name": "wait_agent", + "parameters": {"type": "object", "properties": {}}, + } + ], + } + + result = LiteLLMCompletionResponsesConfig.namespace_tool_name_map( + [namespace_tool] + ) + + assert result["collaboration__wait_agent"] == ("collaboration", "wait_agent") + assert result["wait_agent"] == ("collaboration", "wait_agent") + + def test_namespace_tool_name_map_drops_ambiguous_unqualified_names(self): + tools = [ + {"type": "function", "name": "ordinary"}, + { + "type": "namespace", + "name": "alpha", + "tools": [ + { + "type": "function", + "name": "run", + "parameters": {"type": "object", "properties": {}}, + } + ], + }, + { + "type": "namespace", + "name": "beta", + "tools": [ + {"type": "namespace", "name": "ignored"}, + { + "type": "function", + "name": "run", + "parameters": {"type": "object", "properties": {}}, + }, + ], + }, + { + "type": "namespace", + "name": "gamma", + "tools": [ + { + "type": "function", + "name": "run", + "parameters": {"type": "object", "properties": {}}, + } + ], + }, + ] + + result = LiteLLMCompletionResponsesConfig.namespace_tool_name_map( + tools + ) + + assert result["alpha__run"] == ("alpha", "run") + assert result["beta__run"] == ("beta", "run") + assert result["gamma__run"] == ("gamma", "run") + assert "run" not in result + + def test_namespace_tool_name_map_drops_top_level_function_collision(self): + tools = [ + {"type": "function", "name": "run", "parameters": {"type": "object"}}, + { + "type": "namespace", + "name": "admin", + "tools": [ + { + "type": "function", + "name": "run", + "parameters": {"type": "object"}, + } + ], + }, + ] + + result = LiteLLMCompletionResponsesConfig.namespace_tool_name_map(tools) + + assert result["admin__run"] == ("admin", "run") + assert "run" not in result + + + def test_transform_tools_rejects_flattened_name_collision(self): + tools = [ + { + "type": "function", + "name": "admin__run", + "parameters": {"type": "object"}, + }, + { + "type": "namespace", + "name": "admin", + "tools": [ + { + "type": "function", + "name": "run", + "parameters": {"type": "object"}, + } + ], + }, + ] + + with pytest.raises( + ValueError, + match="Top-level function names conflict with flattened namespace tools: admin__run", + ): + LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools(tools=tools) + + + def test_restore_namespace_tool_name_leaves_unknown_tool_unchanged(self): + tool_name, namespace = LiteLLMCompletionResponsesConfig._restore_namespace_tool_name( + "mcp__node_repl", + {}, + ) + + assert tool_name == "mcp__node_repl" + assert namespace is None + + def test_transform_nested_namespace_ignores_non_function_subtools(self): + namespace_tool = { + "type": "namespace", + "name": "collaboration", + "tools": [ + "ignored", + {"type": "namespace", "name": "ignored"}, + { + "type": "function", + "name": "spawn_agent", + "parameters": {"properties": {"task_name": {"type": "string"}}}, + }, + ], + } + + result_tools, _ = ( + LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools( + tools=[namespace_tool] + ) + ) + + assert len(result_tools) == 1 + assert result_tools[0]["function"]["name"] == "collaboration__spawn_agent" + assert result_tools[0]["function"]["parameters"] == { + "properties": {"task_name": {"type": "string"}}, + "type": "object", + } + def test_bedrock_anthropic_responses_tools_yield_only_function_toolspec(self): """ End-to-end (no network) of the LIT-3858 acceptance criterion: the mixed tools array @@ -2185,7 +2709,7 @@ def test_streaming_iterator_uses_consistent_item_ids(self): # Transform chunks to response API events event1 = iterator._transform_chat_completion_chunk_to_response_api_chunk(chunk1) event2 = iterator._transform_chat_completion_chunk_to_response_api_chunk(chunk2) - event3 = iterator._transform_chat_completion_chunk_to_response_api_chunk(chunk3) + iterator._transform_chat_completion_chunk_to_response_api_chunk(chunk3) # Assert: All events should use the same item_id (from the first chunk) assert event1 is not None, "First event should not be None" @@ -2612,8 +3136,6 @@ class TestEnsureOutputItemContentPartAdded: def _make_iterator(self): """Create a minimal LiteLLMCompletionStreamingIterator for testing.""" - from unittest.mock import MagicMock - from litellm.responses.litellm_completion_transformation.streaming_iterator import ( LiteLLMCompletionStreamingIterator, ) @@ -2628,6 +3150,16 @@ def _make_iterator(self): iterator._cached_reasoning_item_id = None iterator._reasoning_active = False iterator._pending_response_events = [] + iterator._pending_tool_events = [] + iterator._tool_output_index_by_call_id = {} + iterator._tool_args_by_call_id = {} + iterator._tool_call_id_by_index = {} + iterator._ambiguous_tool_call_indexes = set() + iterator._next_tool_output_index = 1 + iterator._final_tool_events_queued = False + iterator._custom_tool_names = set() + iterator.responses_api_request = {} + iterator._namespace_tool_names = LiteLLMCompletionResponsesConfig.namespace_tool_name_map(None) return iterator def _make_text_chunk(self): @@ -2674,6 +3206,283 @@ def test_message_item_emits_content_part_added(self): assert events[1].part.type == "output_text" assert iterator.sent_content_part_added_event is True + def test_streaming_namespace_tool_calls_restore_responses_namespace(self): + """Flattened chat-completion namespace tool calls must stream back as + Responses function calls with name + namespace split.""" + iterator = self._make_iterator() + iterator.responses_api_request = { + "tools": [ + { + "type": "namespace", + "name": "collaboration", + "tools": [ + { + "type": "function", + "name": "spawn_agent", + "parameters": {"type": "object", "properties": {}}, + } + ], + } + ] + } + + 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_1", + "function": { + "name": "collaboration__spawn_agent", + "arguments": '{"task_name":"input_test"}', + }, + } + ] + ) + + added = iterator._pending_tool_events[0] + assert added.item.name == "spawn_agent" + assert added.item.namespace == "collaboration" + + 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() + iterator.responses_api_request = { + "tools": [ + { + "type": "namespace", + "name": "collaboration", + "tools": [ + { + "type": "function", + "name": "wait_agent", + "parameters": {"type": "object", "properties": {}}, + } + ], + } + ] + } + + 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_1", + "function": { + "name": "wait_agent", + "arguments": "{}", + }, + } + ] + ) + + added = iterator._pending_tool_events[0] + assert added.item.name == "wait_agent" + assert added.item.namespace == "collaboration" + + def test_streaming_flat_namespace_tool_call_keeps_flat_name(self): + iterator = self._make_iterator() + iterator.responses_api_request = { + "tools": [ + { + "type": "namespace", + "name": "mcp__node_repl", + "description": "Run JavaScript", + "parameters": { + "type": "object", + "properties": {"code": {"type": "string"}}, + }, + } + ] + } + + 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_1", + "function": { + "name": "mcp__node_repl", + "arguments": '{"code":"1+1"}', + }, + } + ] + ) + + chat_completion_response = ModelResponse( + id="chatcmpl-test", + created=1234567890, + model="gemini-3.1-pro-preview-customtools", + object="chat.completion", + choices=[ + Choices( + finish_reason="tool_calls", + index=0, + message=Message( + content=None, + role="assistant", + tool_calls=[ + ChatCompletionMessageToolCall( + id="call_1", + type="function", + function=Function( + name="mcp__node_repl", + arguments='{"code":"1+1"}', + ), + ) + ], + ), + ) + ], + ) + + iterator._queue_final_tool_call_done_events(chat_completion_response) + + added = iterator._pending_tool_events[0] + done = iterator._pending_tool_events[-1] + assert added.item.name == "mcp__node_repl" + assert getattr(added.item, "namespace", None) is None + assert done.item.name == "mcp__node_repl" + assert getattr(done.item, "namespace", None) is None + + def test_streaming_final_only_namespace_tool_call_restores_namespace(self): + from unittest.mock import MagicMock + + iterator = self._make_iterator() + iterator.responses_api_request = { + "tools": [ + { + "type": "namespace", + "name": "collaboration", + "tools": [ + { + "type": "function", + "name": "spawn_agent", + "parameters": {"type": "object", "properties": {}}, + } + ], + } + ] + } + iterator._namespace_tool_names = LiteLLMCompletionResponsesConfig.namespace_tool_name_map( + iterator.responses_api_request.get("tools") + ) + + message = MagicMock() + message.tool_calls = [ + { + "id": "call_1", + "function": { + "name": "collaboration__spawn_agent", + "arguments": '{"message":"hello world"}', + }, + } + ] + complete_response = MagicMock() + complete_response.choices = [MagicMock(message=message)] + + iterator._queue_final_tool_call_done_events(complete_response) + + added = iterator._pending_tool_events[0] + delta_events = iterator._pending_tool_events[1:-2] + done = iterator._pending_tool_events[-1] + assert added.item.name == "spawn_agent" + assert added.item.namespace == "collaboration" + assert "".join(event.delta for event in delta_events) == '{"message":"hello world"}' + assert done.item.name == "spawn_agent" + assert done.item.namespace == "collaboration" + + def test_streaming_top_level_function_collision_stays_unnamespaced(self): + iterator = self._make_iterator() + iterator.responses_api_request = { + "tools": [ + {"type": "function", "name": "run", "parameters": {"type": "object"}}, + { + "type": "namespace", + "name": "admin", + "tools": [ + { + "type": "function", + "name": "run", + "parameters": {"type": "object"}, + } + ], + }, + ] + } + + 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_top_level", + "function": {"name": "run", "arguments": "{}"}, + } + ] + ) + + added = iterator._pending_tool_events[0] + assert added.item.name == "run" + assert getattr(added.item, "namespace", None) is None + + + def test_streaming_namespace_map_is_built_once(self): + from unittest.mock import MagicMock, patch + + from litellm.responses.litellm_completion_transformation.streaming_iterator import ( + LiteLLMCompletionStreamingIterator, + ) + + mock_stream_wrapper = MagicMock() + mock_stream_wrapper.logging_obj = MagicMock() + request = { + "tools": [ + { + "type": "namespace", + "name": "admin", + "tools": [ + { + "type": "function", + "name": "run", + "parameters": {"type": "object"}, + } + ], + } + ] + } + + with patch.object( + LiteLLMCompletionResponsesConfig, + "namespace_tool_name_map", + wraps=LiteLLMCompletionResponsesConfig.namespace_tool_name_map, + ) as namespace_map: + iterator = LiteLLMCompletionStreamingIterator( + model="test-model", + litellm_custom_stream_wrapper=mock_stream_wrapper, + request_input="test", + responses_api_request=request, + ) + iterator._responses_namespace_tool_call_fields("admin__run") + iterator._responses_namespace_tool_call_fields("admin__run") + + namespace_map.assert_called_once_with(request["tools"]) + + def test_emit_response_completed_uses_stream_finish_reason(self): """ When the assembled model response carries finish_reason="content_filter" diff --git a/tests/test_litellm/responses/litellm_completion_transformation/test_tool_output_order_preserved_for_gemini.py b/tests/test_litellm/responses/litellm_completion_transformation/test_tool_output_order_preserved_for_gemini.py index 6b893e12285f..3a1c77d1dab3 100644 --- a/tests/test_litellm/responses/litellm_completion_transformation/test_tool_output_order_preserved_for_gemini.py +++ b/tests/test_litellm/responses/litellm_completion_transformation/test_tool_output_order_preserved_for_gemini.py @@ -75,3 +75,42 @@ def test_function_call_output_stays_adjacent_to_tool_call(): # Tool output must be right after tool call, and before the assistant "Done." message. assert tool_msg_idx == tool_call_idx + 1 assert assistant_ok_idx > tool_msg_idx + + +def test_assistant_message_after_tool_call_is_folded_into_it(): + """Codex echoes history as [function_call, assistant message, function_call_output]. + The assistant message must fold into the tool_calls message so the tool result + stays immediately after it (DeepSeek and Anthropic reject it otherwise).""" + msgs = LiteLLMCompletionResponsesConfig._transform_response_input_param_to_chat_completion_message( + input=[ + { + "role": "user", + "type": "message", + "content": [{"type": "input_text", "text": "Add 21 and 21."}], + }, + { + "type": "function_call", + "name": "get_sum", + "namespace": "mcp__everything", + "call_id": "call_1", + "arguments": '{"a":21,"b":21}', + }, + { + "role": "assistant", + "type": "message", + "content": [{"type": "output_text", "text": ""}], + }, + { + "type": "function_call_output", + "call_id": "call_1", + "output": "42", + }, + ] + ) + + roles = [m.get("role") for m in msgs if isinstance(m, dict)] + assert roles.count("assistant") == 1 + + tool_call_idx = next(i for i, m in enumerate(msgs) if isinstance(m, dict) and m.get("tool_calls")) + assert msgs[tool_call_idx].get("role") == "assistant" + assert msgs[tool_call_idx + 1].get("role") == "tool"