From f56bfdb2fe3a19ed08e0aa03d001334365eb2a7b Mon Sep 17 00:00:00 2001 From: Shreehitha Arushan Date: Wed, 26 Aug 2026 16:47:47 +0530 Subject: [PATCH] fix(responses): normalize function-call ids and strip gemini thought signatures on cross-provider replay --- litellm/responses/main.py | 5 + litellm/responses/utils.py | 101 +++++++++ .../test_function_call_id_normalization.py | 201 ++++++++++++++++++ 3 files changed, 307 insertions(+) create mode 100644 tests/test_litellm/responses/test_function_call_id_normalization.py diff --git a/litellm/responses/main.py b/litellm/responses/main.py index d6ebc44ac52..33b76cdf1de 100644 --- a/litellm/responses/main.py +++ b/litellm/responses/main.py @@ -1064,6 +1064,11 @@ def responses( return _file_search_dispatch if responses_api_provider_config is None or use_chat_completions_api is True: + input = ResponsesAPIRequestUtils._normalize_function_call_ids_in_input( + request_input=input, + model=model, + custom_llm_provider=custom_llm_provider, + ) return litellm_completion_transformation_handler.response_api_handler( model=model, input=input, diff --git a/litellm/responses/utils.py b/litellm/responses/utils.py index 0ff6bc8a7d2..da2dd0e7082 100644 --- a/litellm/responses/utils.py +++ b/litellm/responses/utils.py @@ -524,6 +524,107 @@ class ResponsesAPIRequestUtils: return request_input + @staticmethod + def _normalize_call_id_for_provider( + call_id: str, + model: str | None, + custom_llm_provider: str | None, + ) -> str: + """Strip Gemini thought signatures from call_id when replaying to non-Gemini models.""" + from litellm.litellm_core_utils.prompt_templates.factory import ( + THOUGHT_SIGNATURE_SEPARATOR, + ) + from litellm.utils import _is_gemini_model, _remove_thought_signature_from_id + + if _is_gemini_model(model=model, custom_llm_provider=custom_llm_provider): + return call_id + return _remove_thought_signature_from_id(call_id, THOUGHT_SIGNATURE_SEPARATOR) + + @staticmethod + def _normalize_function_call_item_id_for_provider( + item_id: str, + model: str | None, + custom_llm_provider: str | None, + ) -> str: + """Rewrite foreign provider function_call item ids to OpenAI fc_ format.""" + item_id = ResponsesAPIRequestUtils._normalize_call_id_for_provider( + call_id=item_id, + model=model, + custom_llm_provider=custom_llm_provider, + ) + + if custom_llm_provider != "openai": + return item_id + + if item_id.startswith("call_"): + return f"fc_{item_id[len('call_') :]}" + if item_id.startswith("tooluse_"): + return f"fc_{item_id[len('tooluse_') :]}" + if item_id.startswith("toolu_vrtx_"): + return f"fc_{item_id[len('toolu_vrtx_') :]}" + + if not item_id.startswith("fc_"): + return f"fc_{item_id}" + + return item_id + + @staticmethod + def _normalize_function_call_ids_in_input( + request_input: Any, # noqa: ANN401 -- raw responses-api request body, structurally varied + model: str | None, + custom_llm_provider: str | None, + ) -> Any: # noqa: ANN401 -- mirrors request_input's type + """Normalize function_call / function_call_output IDs before upstream replay. + + - Strips Gemini thought signatures from call_id for non-Gemini targets. + - Rewrites foreign function_call item ids (call_, tooluse_, toolu_vrtx_) to fc_ + for OpenAI-compatible targets. + + Returns a new list; does not mutate the caller's original items. + """ + if not isinstance(request_input, list): + return request_input + + normalized_input: Final = [] # mutable-ok: holds passthrough items and normalized dict copies, no single element type + for item in request_input: + if not isinstance(item, dict): + normalized_input.append(item) + continue + + item_type = item.get("type") + if item_type == "function_call": + new_item = dict(item) # mutable-ok: fresh copy, caller's item stays untouched + call_id = new_item.get("call_id") + if call_id and isinstance(call_id, str): + new_item["call_id"] = ResponsesAPIRequestUtils._normalize_call_id_for_provider( + call_id=call_id, + model=model, + custom_llm_provider=custom_llm_provider, + ) + + item_id = new_item.get("id") + if item_id and isinstance(item_id, str): + new_item["id"] = ResponsesAPIRequestUtils._normalize_function_call_item_id_for_provider( + item_id=item_id, + model=model, + custom_llm_provider=custom_llm_provider, + ) + normalized_input.append(new_item) + elif item_type == "function_call_output": + new_item = dict(item) # mutable-ok: fresh copy, caller's item stays untouched + call_id = new_item.get("call_id") + if call_id and isinstance(call_id, str): + new_item["call_id"] = ResponsesAPIRequestUtils._normalize_call_id_for_provider( + call_id=call_id, + model=model, + custom_llm_provider=custom_llm_provider, + ) + normalized_input.append(new_item) + else: + normalized_input.append(item) + + return normalized_input + @staticmethod def _build_responses_api_response_id( custom_llm_provider: str | None, diff --git a/tests/test_litellm/responses/test_function_call_id_normalization.py b/tests/test_litellm/responses/test_function_call_id_normalization.py new file mode 100644 index 00000000000..dc47baf1bda --- /dev/null +++ b/tests/test_litellm/responses/test_function_call_id_normalization.py @@ -0,0 +1,201 @@ +import pytest + +from litellm.responses.utils import ResponsesAPIRequestUtils + + +class TestNormalizeCallIdForProvider: + def test_strips_thought_signature_for_non_gemini_target(self): + """Gemini thought signature should be stripped when replaying to a non-Gemini model""" + call_id = "call_23299__thought__AY89a1abc123" + result = ResponsesAPIRequestUtils._normalize_call_id_for_provider( + call_id=call_id, + model="gpt-4.1", + custom_llm_provider="openai", + ) + assert "__thought__" not in result + assert result == "call_23299" + + def test_preserves_thought_signature_for_gemini_target(self): + """Gemini thought signature must be preserved when target is also Gemini""" + call_id = "call_23299__thought__AY89a1abc123" + result = ResponsesAPIRequestUtils._normalize_call_id_for_provider( + call_id=call_id, + model="gemini-3.5-flash", + custom_llm_provider="gemini", + ) + assert result == call_id + + def test_no_thought_signature_is_unaffected(self): + """A call_id with no thought signature should pass through unchanged""" + call_id = "call_plainid123" + result = ResponsesAPIRequestUtils._normalize_call_id_for_provider( + call_id=call_id, + model="gpt-4.1", + custom_llm_provider="openai", + ) + assert result == call_id + + +class TestNormalizeFunctionCallItemIdForProvider: + @pytest.mark.parametrize( + "raw_id,expected", + [ + ("call_abc123", "fc_abc123"), + ("tooluse_abc123", "fc_abc123"), + ("toolu_vrtx_abc123", "fc_abc123"), + ], + ) + def test_rewrites_foreign_prefixes_to_fc_for_openai(self, raw_id, expected): + result = ResponsesAPIRequestUtils._normalize_function_call_item_id_for_provider( + item_id=raw_id, + model="gpt-4.1", + custom_llm_provider="openai", + ) + assert result == expected + + def test_leaves_prefix_unchanged_for_non_openai_target(self): + """Only rewrite to fc_ when the target provider is openai""" + raw_id = "call_abc123" + result = ResponsesAPIRequestUtils._normalize_function_call_item_id_for_provider( + item_id=raw_id, + model="claude-sonnet-4", + custom_llm_provider="anthropic", + ) + assert result == raw_id + + def test_strips_thought_signature_and_rewrites_prefix_together(self): + """Gemini id with embedded thought signature replayed to OpenAI: both fixes apply""" + raw_id = "call_23299__thought__AY89a1abc123" + result = ResponsesAPIRequestUtils._normalize_function_call_item_id_for_provider( + item_id=raw_id, + model="gpt-4.1", + custom_llm_provider="openai", + ) + assert result == "fc_23299" + + def test_already_fc_prefixed_id_is_unchanged(self): + raw_id = "fc_already_correct" + result = ResponsesAPIRequestUtils._normalize_function_call_item_id_for_provider( + item_id=raw_id, + model="gpt-4.1", + custom_llm_provider="openai", + ) + assert result == raw_id + + +class TestNormalizeFunctionCallIdsInInput: + def test_non_list_input_passes_through_unchanged(self): + assert ( + ResponsesAPIRequestUtils._normalize_function_call_ids_in_input( + request_input="not a list", + model="gpt-4.1", + custom_llm_provider="openai", + ) + == "not a list" + ) + + def test_normalizes_function_call_and_function_call_output_items(self): + """A Gemini function_call replayed with its matching function_call_output to OpenAI""" + gemini_call_id = "call_23299__thought__AY89a1abc123" + request_input = [ + {"role": "user", "content": "What is the weather in Tokyo?"}, + { + "type": "function_call", + "id": gemini_call_id, + "call_id": gemini_call_id, + "name": "get_weather", + "arguments": '{"city": "Tokyo"}', + "status": "completed", + }, + { + "type": "function_call_output", + "call_id": gemini_call_id, + "output": '{"temperature": "18C"}', + }, + ] + + result = ResponsesAPIRequestUtils._normalize_function_call_ids_in_input( + request_input=request_input, + model="gpt-4.1", + custom_llm_provider="openai", + ) + + function_call_item = result[1] + function_call_output_item = result[2] + + assert function_call_item["id"] == "fc_23299" + assert function_call_item["call_id"] == "call_23299" + assert function_call_output_item["call_id"] == "call_23299" + # non function-call items must be left alone + assert result[0] == {"role": "user", "content": "What is the weather in Tokyo?"} + + def test_items_missing_ids_are_skipped_without_error(self): + request_input = [ + {"type": "function_call", "name": "get_weather", "arguments": "{}"}, + {"type": "function_call_output", "output": "done"}, + ] + result = ResponsesAPIRequestUtils._normalize_function_call_ids_in_input( + request_input=request_input, + model="gpt-4.1", + custom_llm_provider="openai", + ) + assert result == request_input + + def test_non_dict_item_in_list_passes_through_unchanged(self): + """A non-dict item in the input list must be preserved as-is""" + request_input = [ + {"role": "user", "content": "hello"}, + "some non-dict item", + { + "type": "function_call", + "id": "call_abc", + "call_id": "call_abc", + "name": "get_weather", + "arguments": "{}", + }, + ] + result = ResponsesAPIRequestUtils._normalize_function_call_ids_in_input( + request_input=request_input, + model="gpt-4.1", + custom_llm_provider="openai", + ) + assert result[1] == "some non-dict item" + + +class TestNormalizeFunctionCallIdsIntegration: + def test_gemini_function_call_replayed_to_openai_via_responses_api(self): + """ + Integration-style check for the exact repro in the linked issue: + a Gemini function_call item with an embedded thought signature, replayed + to an OpenAI-compatible model via /v1/responses, must arrive at the + chat-completions bridge with a clean fc_-prefixed id and no thought signature. + """ + gemini_call_id = "call_23299__thought__AY89a1abc123" + request_input = [ + { + "type": "function_call", + "id": gemini_call_id, + "call_id": gemini_call_id, + "name": "get_weather", + "arguments": '{"city": "Tokyo"}', + "status": "completed", + }, + { + "type": "function_call_output", + "call_id": gemini_call_id, + "output": '{"temperature": "18C"}', + }, + ] + + normalized = ResponsesAPIRequestUtils._normalize_function_call_ids_in_input( + request_input=request_input, + model="gpt-4.1", + custom_llm_provider="openai", + ) + + assert normalized[0]["id"] == "fc_23299" + assert normalized[0]["call_id"] == "call_23299" + assert normalized[1]["call_id"] == "call_23299" + assert "__thought__" not in normalized[0]["id"] + assert "__thought__" not in normalized[0]["call_id"] + assert "__thought__" not in normalized[1]["call_id"]