diff --git a/litellm/llms/chatgpt/responses/transformation.py b/litellm/llms/chatgpt/responses/transformation.py index 638a55452d2..ca30a5f685c 100644 --- a/litellm/llms/chatgpt/responses/transformation.py +++ b/litellm/llms/chatgpt/responses/transformation.py @@ -1,3 +1,4 @@ +import hashlib from collections.abc import Mapping from typing import TYPE_CHECKING, Any, Final, cast @@ -130,6 +131,7 @@ class ChatGPTResponsesAPIConfig(OpenAIResponsesAPIConfig): if "reasoning.encrypted_content" not in include: include.append("reasoning.encrypted_content") request["include"] = include + request["input"] = self._normalize_responses_input_for_chatgpt(request.get("input")) allowed_keys: Final = { "model", @@ -153,6 +155,36 @@ class ChatGPTResponsesAPIConfig(OpenAIResponsesAPIConfig): filtered["service_tier"] = service_tier return filtered + @classmethod + def _normalize_responses_input_for_chatgpt(cls, input_value: Any) -> Any: + if not isinstance(input_value, list): + return input_value + + normalized_input: list[Any] = [] + changed = False + for item in input_value: + if isinstance(item, dict): + item_type = item.get("type") + if item_type in {"reasoning", "web_search_call"}: + changed = True + continue + if item_type == "function_call": + current_id = item.get("id") + normalized_id = cls._normalize_function_call_item_id(current_id) + if normalized_id != current_id: + item = dict(item) + item["id"] = normalized_id + changed = True + normalized_input.append(item) + return normalized_input if changed else input_value + + @staticmethod + def _normalize_function_call_item_id(item_id: Any) -> Any: + if not isinstance(item_id, str) or not item_id or item_id.startswith("fc"): + return item_id + digest: Final = hashlib.sha256(item_id.encode("utf-8")).hexdigest()[:32] + return f"fc_{digest}" + def transform_response_api_response( self, model: str, diff --git a/tests/unit/llms/chatgpt/responses/test_chatgpt_responses_transformation.py b/tests/unit/llms/chatgpt/responses/test_chatgpt_responses_transformation.py index c456e782c09..3f22b8363b4 100644 --- a/tests/unit/llms/chatgpt/responses/test_chatgpt_responses_transformation.py +++ b/tests/unit/llms/chatgpt/responses/test_chatgpt_responses_transformation.py @@ -218,6 +218,68 @@ class TestChatGPTResponsesAPITransformation: assert "reasoning.encrypted_content" in request["include"] assert request["instructions"].startswith("You are Codex, based on GPT-5.") + def test_chatgpt_normalizes_foreign_function_call_item_id(self): + config = ChatGPTResponsesAPIConfig() + input_items = [ + { + "type": "function_call", + "id": "toolu_fixture_123", + "call_id": "call_fixture_123", + "name": "read_file", + "arguments": '{"path":"README.md"}', + } + ] + + request = config.transform_responses_api_request( + model="chatgpt/gpt-5.6-sol", + input=input_items, + response_api_optional_request_params={}, + litellm_params=GenericLiteLLMParams(), + headers={}, + ) + + assert request["input"] == [ + { + "type": "function_call", + "id": "fc_064009edc4c975f0fa4b447f8ea388e0", + "call_id": "call_fixture_123", + "name": "read_file", + "arguments": '{"path":"README.md"}', + } + ] + + def test_chatgpt_omits_replayed_private_output_items(self): + config = ChatGPTResponsesAPIConfig() + input_items = [ + {"type": "reasoning", "id": "rs_fixture", "encrypted_content": "opaque"}, + { + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": "continue"}], + }, + {"type": "web_search_call", "id": "ws_fixture", "status": "completed"}, + { + "type": "function_call", + "id": "fc_fixture", + "call_id": "call_fixture", + "name": "read_file", + "arguments": '{"path":"README.md"}', + }, + ] + + request = config.transform_responses_api_request( + model="chatgpt/gpt-5.6-sol", + input=input_items, + response_api_optional_request_params={}, + litellm_params=GenericLiteLLMParams(), + headers={}, + ) + + assert [item["type"] for item in request["input"]] == [ + "message", + "function_call", + ] + @pytest.mark.parametrize( "model_name", [