diff --git a/litellm/litellm_core_utils/prompt_templates/factory.py b/litellm/litellm_core_utils/prompt_templates/factory.py index 795fb36961e..3d06b975342 100644 --- a/litellm/litellm_core_utils/prompt_templates/factory.py +++ b/litellm/litellm_core_utils/prompt_templates/factory.py @@ -1694,6 +1694,18 @@ def convert_function_to_anthropic_tool_invoke( raise e +def _find_server_tool_result( + tool_id: str, + web_search_results: Sequence[Any] | None, + tool_results: Sequence[Any] | None, +) -> dict[str, Any] | None: + candidates: Final = (*(web_search_results or ()), *(tool_results or ())) + return next( + (result for result in candidates if isinstance(result, dict) and result.get("tool_use_id") == tool_id), + None, + ) + + def convert_to_anthropic_tool_invoke( tool_calls: list[ChatCompletionAssistantToolCall], web_search_results: list[Any] | None = None, @@ -1758,32 +1770,22 @@ def convert_to_anthropic_tool_invoke( context="Anthropic tool invoke", ) - # Check if this is a server-side tool (web_search, tool_search, etc.) - # Server tool IDs start with "srvtoolu_" - if tool_id.startswith("srvtoolu_"): - # Create server_tool_use block instead of tool_use - _anthropic_server_tool_use: dict[str, object] = { - "type": "server_tool_use", - "id": tool_id, - "name": tool_name, - "input": tool_input, - } - anthropic_tool_invoke.append(_anthropic_server_tool_use) - - # Add corresponding tool result if available. - # Check both web_search_results (web_search_tool_result / web_fetch_tool_result) - # and tool_results (bash_code_execution_tool_result, etc.) - _all_tool_results: list[Any] = [] - if web_search_results: - _all_tool_results.extend(web_search_results) - if tool_results: - _all_tool_results.extend(tool_results) - for result in _all_tool_results: - if result.get("tool_use_id") == tool_id: - anthropic_tool_invoke.append(result) - break + server_tool_result = ( + _find_server_tool_result(tool_id, web_search_results, tool_results) + if tool_id.startswith("srvtoolu_") + else None + ) + if server_tool_result is not None: + anthropic_tool_invoke.append( + { + "type": "server_tool_use", + "id": tool_id, + "name": tool_name, + "input": tool_input, + } + ) + anthropic_tool_invoke.append(server_tool_result) else: - # Regular tool_use sanitized_tool_id = _sanitize_anthropic_tool_use_id(tool_id) _anthropic_tool_use_param = AnthropicMessagesToolUseParam( type="tool_use", diff --git a/litellm/llms/openai/responses/transformation.py b/litellm/llms/openai/responses/transformation.py index eadc087383a..bb3b78e4df2 100644 --- a/litellm/llms/openai/responses/transformation.py +++ b/litellm/llms/openai/responses/transformation.py @@ -14,6 +14,7 @@ from litellm.litellm_core_utils.llm_response_utils.convert_dict_to_response impo ) from litellm.litellm_core_utils.url_utils import encode_url_path_segment from litellm.llms.base_llm.responses.transformation import BaseResponsesAPIConfig +from litellm.responses.litellm_completion_transformation.custom_tools import TOOL_CALL_ITEM_ID_PREFIX_BY_TYPE from litellm.secret_managers.main import get_secret_str from litellm.types.llms.openai import * from litellm.types.responses.main import * @@ -35,6 +36,7 @@ else: _NO_TOOL_UPDATE: Final[Mapping[str, object]] = MappingProxyType({}) _MODEL_FAMILIES_REJECTING_TOP_LEVEL_SCHEMA_COMBINATORS: Final = ("gpt-4", "gpt-3.5", "chatgpt-4o", "o1", "o3", "o4") _PROVIDERS_WITH_COMBINATOR_REJECTING_VALIDATOR: Final = frozenset({LlmProviders.AZURE, LlmProviders.OPENAI}) +_PROVIDERS_VALIDATING_TOOL_CALL_ITEM_IDS: Final = frozenset({LlmProviders.AZURE, LlmProviders.OPENAI}) class OpenAIResponsesAPIConfig(BaseResponsesAPIConfig): @@ -179,8 +181,9 @@ class OpenAIResponsesAPIConfig(BaseResponsesAPIConfig): ) if sanitized_tools is not None: response_api_optional_request_params["tools"] = sanitized_tools + replay_safe_input: Final = self._drop_foreign_tool_call_item_ids(input) final_request_params: Final = dict( - ResponsesAPIRequestParams(model=model, input=input, **response_api_optional_request_params) + ResponsesAPIRequestParams(model=model, input=replay_safe_input, **response_api_optional_request_params) ) return final_request_params @@ -217,6 +220,23 @@ class OpenAIResponsesAPIConfig(BaseResponsesAPIConfig): return input, tools + def _drop_foreign_tool_call_item_ids(self, input: str | ResponseInputParam) -> str | ResponseInputParam: + if self.custom_llm_provider not in _PROVIDERS_VALIDATING_TOOL_CALL_ITEM_IDS or not isinstance(input, list): + return input + sanitized_items: Final = [self._without_foreign_tool_call_item_id(item) for item in input] + return cast("ResponseInputParam", sanitized_items) # cast-ok: items keep their shape, minus a rejected id + + @staticmethod + def _without_foreign_tool_call_item_id(item: object) -> object: + if not isinstance(item, dict): + return item + item_type: Final = item.get("type") + item_id: Final = item.get("id") + genuine_prefix: Final = TOOL_CALL_ITEM_ID_PREFIX_BY_TYPE.get(item_type) if isinstance(item_type, str) else None + if genuine_prefix is None or not isinstance(item_id, str) or item_id.startswith(genuine_prefix): + return item + return {key: value for key, value in item.items() if key != "id"} # mutable-ok: outgoing JSON request item + def _flatten_tool_schema_combinators_for_openai( self, model: str, @@ -742,7 +762,10 @@ class OpenAIResponsesAPIConfig(BaseResponsesAPIConfig): ) if sanitized_tools is not None: response_api_optional_request_params["tools"] = sanitized_tools - data: Final = dict(ResponsesAPIRequestParams(model=model, input=input, **response_api_optional_request_params)) + replay_safe_input: Final = self._drop_foreign_tool_call_item_ids(input) + data: Final = dict( + ResponsesAPIRequestParams(model=model, input=replay_safe_input, **response_api_optional_request_params) + ) return url, data diff --git a/litellm/responses/litellm_completion_transformation/custom_tools.py b/litellm/responses/litellm_completion_transformation/custom_tools.py index 90491739bb0..4aa489d9e50 100644 --- a/litellm/responses/litellm_completion_transformation/custom_tools.py +++ b/litellm/responses/litellm_completion_transformation/custom_tools.py @@ -17,6 +17,7 @@ logic. import json from collections.abc import Mapping, Sequence +from types import MappingProxyType from typing import Final from pydantic import BaseModel, TypeAdapter, ValidationError @@ -28,6 +29,15 @@ from litellm.types.llms.openai import ( _MAX_ARGUMENTS_LEN: Final = 1_000_000 +TOOL_CALL_ITEM_ID_PREFIX_BY_TYPE: Final = MappingProxyType({"function_call": "fc", "custom_tool_call": "ctc"}) + + +def openai_shaped_tool_call_item_id(item_type: str, tool_id: str) -> str: + prefix: Final = TOOL_CALL_ITEM_ID_PREFIX_BY_TYPE.get(item_type) + if prefix is None or not tool_id or tool_id.startswith(prefix): + return tool_id + return f"{prefix}_{tool_id}" + def extract_custom_tool_names(tools: Sequence[object] | None) -> set[str]: """Extract names of tools originally defined as ``type: "custom"``.""" @@ -103,7 +113,7 @@ def build_tool_call_item_kwargs( item_type: Final = "custom_tool_call" if custom else "function_call" kwargs: Final[dict[str, str]] = { "type": item_type, - "id": call_id, + "id": openai_shaped_tool_call_item_id(item_type, call_id), "call_id": call_id, "name": name, "status": status, diff --git a/litellm/responses/litellm_completion_transformation/transformation.py b/litellm/responses/litellm_completion_transformation/transformation.py index 3b7810e97e5..5f3e88bb12f 100644 --- a/litellm/responses/litellm_completion_transformation/transformation.py +++ b/litellm/responses/litellm_completion_transformation/transformation.py @@ -93,6 +93,7 @@ from .custom_tools import ( convert_custom_tool_to_function_tool, extract_custom_tool_names, is_custom_tool_call, + openai_shaped_tool_call_item_id, serialize_tool_call_arguments, unwrap_custom_tool_arguments, validated_allowed_callers, @@ -2034,7 +2035,7 @@ class LiteLLMCompletionResponsesConfig: custom_item = CustomToolCallOutputItem( type="custom_tool_call", call_id=tool_id, - id=tool_id, + id=openai_shaped_tool_call_item_id("custom_tool_call", tool_id), name=tool_name, input=input_str, status=function_definition.get("status") or "completed", @@ -2065,7 +2066,7 @@ class LiteLLMCompletionResponsesConfig: name=tool_name, arguments=tool_arguments, call_id=tool_id, - id=tool_id, + id=openai_shaped_tool_call_item_id("function_call", tool_id), type="function_call", status=function_definition.get("status") or "completed", ) @@ -2502,8 +2503,7 @@ class LiteLLMCompletionResponsesConfig: choice=choice, ) message_output_items.extend(image_generation_items) - else: - # Regular message output + elif choice.message.content is not None: message_output_items.append( GenericResponseOutputItem( type="message", diff --git a/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_factory.py b/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_factory.py index 72d26f31c60..64c96b575c5 100644 --- a/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_factory.py +++ b/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_factory.py @@ -3627,3 +3627,67 @@ def test_convert_gemini_tool_call_result_answers_tool_reference_only_result(): ) assert result == {"function_response": {"name": "ToolSearch", "response": {"content": ""}}} + + +def test_convert_to_anthropic_tool_invoke_degrades_unpaired_server_tool_use(): + """A replayed srvtoolu_ call whose server tool result is not available + (e.g. the Responses bridge replays items without provider_specific_fields) + must become a plain client tool_use so the client's tool_result can pair + with it. A dangling server_tool_use makes Anthropic 400 the request with + "unexpected `tool_use_id` found in `tool_result` blocks".""" + from litellm.litellm_core_utils.prompt_templates.factory import convert_to_anthropic_tool_invoke + + result = convert_to_anthropic_tool_invoke( + tool_calls=[ + { + "id": "srvtoolu_01Unpaired", + "type": "function", + "function": {"name": "web_search", "arguments": '{"query": "zig version"}'}, + } + ], + web_search_results=None, + tool_results=None, + ) + + assert result == [ + { + "type": "tool_use", + "id": "srvtoolu_01Unpaired", + "name": "web_search", + "input": {"query": "zig version"}, + } + ] + + +def test_convert_to_anthropic_tool_invoke_keeps_paired_server_tool_use(): + """When the paired server tool result is available, the srvtoolu_ call is + still reconstructed as server_tool_use followed by its result block.""" + from litellm.litellm_core_utils.prompt_templates.factory import convert_to_anthropic_tool_invoke + + server_result = { + "type": "web_search_tool_result", + "tool_use_id": "srvtoolu_01Paired", + "content": [{"type": "web_search_result", "url": "https://ziglang.org", "title": "Zig"}], + } + + result = convert_to_anthropic_tool_invoke( + tool_calls=[ + { + "id": "srvtoolu_01Paired", + "type": "function", + "function": {"name": "web_search", "arguments": '{"query": "zig version"}'}, + } + ], + web_search_results=[server_result], + tool_results=None, + ) + + assert result == [ + { + "type": "server_tool_use", + "id": "srvtoolu_01Paired", + "name": "web_search", + "input": {"query": "zig version"}, + }, + server_result, + ] diff --git a/tests/test_litellm/llms/openai/responses/test_openai_responses_transformation.py b/tests/test_litellm/llms/openai/responses/test_openai_responses_transformation.py index 1a90db7c1fe..b0ffd1845fe 100644 --- a/tests/test_litellm/llms/openai/responses/test_openai_responses_transformation.py +++ b/tests/test_litellm/llms/openai/responses/test_openai_responses_transformation.py @@ -220,6 +220,111 @@ class TestOpenAIResponsesAPIConfig: assert result["input"] == input_clean + def test_transform_drops_foreign_tool_call_item_ids(self): + """Replayed tool call items whose ids are not OpenAI-shaped (e.g. + Anthropic toolu_/srvtoolu_ ids after a router fallback) must be sent + without an id: OpenAI 400s foreign ids ("Expected an ID that begins + with 'fc'") but accepts the items with no id at all. Genuine fc_/ctc_ + ids and non-tool-call items pass through untouched.""" + replayed_input = [ + {"role": "user", "content": [{"type": "input_text", "text": "hi"}]}, + { + "type": "function_call", + "id": "toolu_01Foreign", + "call_id": "toolu_01Foreign", + "name": "get_weather", + "arguments": '{"city": "SF"}', + }, + {"type": "function_call_output", "call_id": "toolu_01Foreign", "output": "sunny"}, + { + "type": "custom_tool_call", + "id": "srvtoolu_01Foreign", + "call_id": "srvtoolu_01Foreign", + "name": "apply_patch", + "input": "patch", + }, + { + "type": "function_call", + "id": "fc_genuine", + "call_id": "call_genuine", + "name": "get_weather", + "arguments": "{}", + }, + {"type": "message", "id": "msg_1", "role": "assistant", "content": []}, + ] + + result = self.config.transform_responses_api_request( + model=self.model, + input=replayed_input, + response_api_optional_request_params={}, + litellm_params={}, + headers={}, + ) + + assert "id" not in result["input"][1] + assert result["input"][1]["call_id"] == "toolu_01Foreign" + assert "id" not in result["input"][3] + assert result["input"][3]["call_id"] == "srvtoolu_01Foreign" + assert result["input"][4]["id"] == "fc_genuine" + assert result["input"][5]["id"] == "msg_1" + assert replayed_input[1]["id"] == "toolu_01Foreign" + assert replayed_input[3]["id"] == "srvtoolu_01Foreign" + + def test_transform_keeps_foreign_tool_call_item_ids_for_other_providers(self): + """Providers reusing this config that do not enforce OpenAI's id + shapes must keep replayed ids untouched.""" + from litellm.types.utils import LlmProviders + + class _OpenRouterLikeConfig(OpenAIResponsesAPIConfig): + @property + def custom_llm_provider(self) -> LlmProviders: + return LlmProviders.OPENROUTER + + replayed_input = [ + { + "type": "function_call", + "id": "toolu_01Foreign", + "call_id": "toolu_01Foreign", + "name": "get_weather", + "arguments": "{}", + } + ] + + result = _OpenRouterLikeConfig().transform_responses_api_request( + model="openrouter/some-model", + input=replayed_input, + response_api_optional_request_params={}, + litellm_params={}, + headers={}, + ) + + assert result["input"][0]["id"] == "toolu_01Foreign" + + def test_transform_compact_drops_foreign_tool_call_item_ids(self): + """The compact request path replays input the same way, so it must + apply the same id drop.""" + replayed_input = [ + { + "type": "function_call", + "id": "toolu_01Foreign", + "call_id": "toolu_01Foreign", + "name": "get_weather", + "arguments": "{}", + } + ] + + _url, data = self.config.transform_compact_response_api_request( + model=self.model, + input=replayed_input, + response_api_optional_request_params={}, + api_base="https://api.openai.com/v1/responses", + litellm_params=GenericLiteLLMParams(), + headers={}, + ) + + assert "id" not in data["input"][0] + assert data["input"][0]["call_id"] == "toolu_01Foreign" + def test_transform_streaming_response(self): """Test streaming response transformation""" # Test with a text delta event 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 aba79fe11bf..fa373759cdd 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 @@ -648,6 +648,72 @@ class TestLiteLLMCompletionResponsesConfig: assert responses_api_response.status == "incomplete" + def test_tool_call_only_response_emits_no_null_text_message_item(self): + """A tool-calls-only turn (message content None, e.g. from Anthropic) + must not emit a message output item whose output_text has text null. + OpenAI rejects such an item on replay with + "Invalid type for 'input[..].content[..].text': expected a string, but + got null instead." Native OpenAI tool-only turns carry no message item.""" + chat_completion_response = ModelResponse( + id="test-response-id", + created=1234567890, + model="claude-sonnet-4-5", + object="chat.completion", + choices=[ + Choices( + finish_reason="tool_calls", + index=0, + message=Message( + content=None, + role="assistant", + tool_calls=[ + ChatCompletionMessageToolCall( + id="toolu_01OnlyToolCall", + type="function", + function=Function(name="get_weather", arguments='{"city": "SF"}'), + ) + ], + ), + ) + ], + ) + + responses_api_response = LiteLLMCompletionResponsesConfig.transform_chat_completion_response_to_responses_api_response( + request_input="what's the weather in SF?", + responses_api_request={}, + chat_completion_response=chat_completion_response, + ) + + output_types = [item.type for item in responses_api_response.output] + assert "message" not in output_types + assert "function_call" in output_types + + def test_content_bearing_response_still_emits_message_item(self): + """Turns with real text content must keep their message output item.""" + chat_completion_response = ModelResponse( + id="test-response-id", + created=1234567890, + model="claude-sonnet-4-5", + object="chat.completion", + choices=[ + Choices( + finish_reason="stop", + index=0, + message=Message(content="It is sunny.", role="assistant"), + ) + ], + ) + + responses_api_response = LiteLLMCompletionResponsesConfig.transform_chat_completion_response_to_responses_api_response( + request_input="what's the weather in SF?", + responses_api_request={}, + chat_completion_response=chat_completion_response, + ) + + message_items = [item for item in responses_api_response.output if item.type == "message"] + assert len(message_items) == 1 + assert message_items[0].content[0].text == "It is sunny." + def test_transform_chat_completion_response_preserves_hidden_params(self): """Test that _hidden_params from chat completion response are preserved in responses API response""" # Setup diff --git a/tests/test_litellm/responses/litellm_completion_transformation/test_streaming_iterator_transformation.py b/tests/test_litellm/responses/litellm_completion_transformation/test_streaming_iterator_transformation.py index 01148f627f1..59bd80791e3 100644 --- a/tests/test_litellm/responses/litellm_completion_transformation/test_streaming_iterator_transformation.py +++ b/tests/test_litellm/responses/litellm_completion_transformation/test_streaming_iterator_transformation.py @@ -349,7 +349,8 @@ def test_tool_call_delta_without_id_uses_index_mapping(): if evt.type == ResponsesAPIStreamEvents.OUTPUT_ITEM_ADDED ] assert len(output_item_added_events) == 1 - assert output_item_added_events[0].item.id == "call_abc123" + assert output_item_added_events[0].item.id == "fc_call_abc123" + assert output_item_added_events[0].item.call_id == "call_abc123" def test_parallel_tool_calls_without_ids_use_index_mapping(): diff --git a/tests/test_litellm/responses/test_custom_tool_call.py b/tests/test_litellm/responses/test_custom_tool_call.py index c605ef24934..5122c1c1d67 100644 --- a/tests/test_litellm/responses/test_custom_tool_call.py +++ b/tests/test_litellm/responses/test_custom_tool_call.py @@ -20,6 +20,7 @@ from litellm.responses.litellm_completion_transformation.transformation import ( from litellm.responses.litellm_completion_transformation.custom_tools import ( extract_custom_tool_names, is_custom_tool_call, + openai_shaped_tool_call_item_id, unwrap_custom_tool_arguments, build_tool_call_item_kwargs, convert_custom_tool_to_function_tool, @@ -129,6 +130,41 @@ class TestCustomToolUtilities: assert kwargs["arguments"] == raw assert "input" not in kwargs + def test_openai_shaped_tool_call_item_id_prefixes_foreign_ids(self): + """Anthropic-style tool ids must be normalized to OpenAI's item id + shapes (fc/ctc prefixes) so replaying the item to OpenAI does not 400 + with "Expected an ID that begins with 'fc'".""" + assert openai_shaped_tool_call_item_id("function_call", "toolu_01Abc") == "fc_toolu_01Abc" + assert openai_shaped_tool_call_item_id("function_call", "srvtoolu_01Xyz") == "fc_srvtoolu_01Xyz" + assert openai_shaped_tool_call_item_id("custom_tool_call", "toolu_01Abc") == "ctc_toolu_01Abc" + assert openai_shaped_tool_call_item_id("function_call", "fc_already") == "fc_already" + assert openai_shaped_tool_call_item_id("custom_tool_call", "ctc_already") == "ctc_already" + assert openai_shaped_tool_call_item_id("function_call", "") == "" + assert openai_shaped_tool_call_item_id("message", "toolu_01Abc") == "toolu_01Abc" + + def test_build_tool_call_item_kwargs_normalizes_item_id_keeps_call_id(self): + """The streaming item id gets the OpenAI shape while call_id stays raw + so tool_result pairing (which keys off call_id) keeps working.""" + function_kwargs = build_tool_call_item_kwargs( + call_id="toolu_01Abc", + name="get_weather", + arguments_or_input="{}", + status="completed", + custom_tool_names=set(), + ) + assert function_kwargs["id"] == "fc_toolu_01Abc" + assert function_kwargs["call_id"] == "toolu_01Abc" + + custom_kwargs = build_tool_call_item_kwargs( + call_id="toolu_01Def", + name="apply_patch", + arguments_or_input=json.dumps({"content": "patch"}), + status="completed", + custom_tool_names={"apply_patch"}, + ) + assert custom_kwargs["id"] == "ctc_toolu_01Def" + assert custom_kwargs["call_id"] == "toolu_01Def" + def test_unwrap_custom_tool_arguments_oversized_returns_raw(self): """Arguments larger than the safety cap are returned unchanged to avoid OOM on JSON parsing a pathologically large string.""" @@ -293,6 +329,52 @@ class TestTransformationCustomTools: assert item.name == "regular_tool" assert item.arguments == json.dumps({"param": "value"}) + def test_transform_anthropic_tool_call_ids_get_openai_item_id_shape(self): + """Anthropic tool ids (toolu_/srvtoolu_) surfacing through the bridge + must be emitted with fc/ctc-prefixed item ids so a Responses client can + replay them to OpenAI verbatim, while call_id stays raw for pairing.""" + from litellm.types.utils import ModelResponse, Choices, Message, ChatCompletionMessageToolCall, Function + + client_call = ChatCompletionMessageToolCall( + id="toolu_01ClientCall", + type="function", + function=Function(name="get_weather", arguments=json.dumps({"city": "SF"})), + ) + server_call = ChatCompletionMessageToolCall( + id="srvtoolu_01ServerCall", + type="function", + function=Function(name="web_search", arguments=json.dumps({"query": "zig"})), + ) + custom_call = ChatCompletionMessageToolCall( + id="toolu_01CustomCall", + type="function", + function=Function(name="apply_patch", arguments=json.dumps({"content": "patch content"})), + ) + + message = Message(role="assistant", content=None, tool_calls=[client_call, server_call, custom_call]) + choices = [Choices(index=0, message=message, finish_reason="tool_calls")] + response = ModelResponse( + id="test_response", choices=choices, created=1234567890, model="claude-sonnet-4-5", object="chat.completion" + ) + responses_api_request = { + "tools": [{"type": "custom", "name": "apply_patch"}, {"type": "function", "name": "get_weather"}] + } + + result = LiteLLMCompletionResponsesConfig.transform_chat_completion_tools_to_responses_tools( + response, responses_api_request=responses_api_request + ) + + assert [item.id for item in result] == [ + "fc_toolu_01ClientCall", + "fc_srvtoolu_01ServerCall", + "ctc_toolu_01CustomCall", + ] + assert [item.call_id for item in result] == [ + "toolu_01ClientCall", + "srvtoolu_01ServerCall", + "toolu_01CustomCall", + ] + def test_transform_mixed_tool_calls(self): """Test transformation with both custom and regular tool calls.""" from litellm.types.utils import ModelResponse, Choices, Message, ChatCompletionMessageToolCall, Function