diff --git a/litellm/responses/main.py b/litellm/responses/main.py index 944cf58df1c..791f9c04678 100644 --- a/litellm/responses/main.py +++ b/litellm/responses/main.py @@ -1111,6 +1111,11 @@ def responses( # Decode any litellm-encoded encrypted-content item IDs back to their original IDs input = ResponsesAPIRequestUtils._restore_encrypted_content_item_ids_in_input(input) + input = ResponsesAPIRequestUtils._normalize_function_call_ids_in_input( + request_input=input, + model=model, + custom_llm_provider=custom_llm_provider, + ) # Call the handler with _is_async flag instead of directly calling the async handler if custom_llm_provider is None: diff --git a/litellm/responses/utils.py b/litellm/responses/utils.py index ac92e5d6dcc..c271d4c9bec 100644 --- a/litellm/responses/utils.py +++ b/litellm/responses/utils.py @@ -463,6 +463,93 @@ 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_') :]}" + + return item_id + + @staticmethod + def _normalize_function_call_ids_in_input( + request_input: Any, + model: str | None, + custom_llm_provider: str | None, + ) -> Any: + """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_) to fc_ for OpenAI-compatible targets. + """ + if not isinstance(request_input, list): + return request_input + + for item in request_input: + if not isinstance(item, dict): + continue + + item_type = item.get("type") + if item_type == "function_call": + call_id = item.get("call_id") + if call_id and isinstance(call_id, str): + item["call_id"] = ResponsesAPIRequestUtils._normalize_call_id_for_provider( + call_id=call_id, + model=model, + custom_llm_provider=custom_llm_provider, + ) + + item_id = item.get("id") + if item_id and isinstance(item_id, str): + item["id"] = ResponsesAPIRequestUtils._normalize_function_call_item_id_for_provider( + item_id=item_id, + model=model, + custom_llm_provider=custom_llm_provider, + ) + elif item_type == "function_call_output": + call_id = item.get("call_id") + if call_id and isinstance(call_id, str): + item["call_id"] = ResponsesAPIRequestUtils._normalize_call_id_for_provider( + call_id=call_id, + model=model, + custom_llm_provider=custom_llm_provider, + ) + + return request_input + @staticmethod def _build_responses_api_response_id( custom_llm_provider: Optional[str], diff --git a/tests/test_litellm/responses/test_responses_utils.py b/tests/test_litellm/responses/test_responses_utils.py index 3a75a33fdc7..66c414512ee 100644 --- a/tests/test_litellm/responses/test_responses_utils.py +++ b/tests/test_litellm/responses/test_responses_utils.py @@ -228,6 +228,87 @@ class TestResponsesAPIRequestUtils: assert decoded.get("custom_llm_provider") == "azure" assert decoded.get("response_id") == "cntr_x" + def test_normalize_call_id_strips_thought_signature_for_non_gemini(self): + from litellm.litellm_core_utils.prompt_templates.factory import ( + THOUGHT_SIGNATURE_SEPARATOR, + ) + + call_id = f"call_abc123{THOUGHT_SIGNATURE_SEPARATOR}sig_xyz" + result = ResponsesAPIRequestUtils._normalize_call_id_for_provider( + call_id=call_id, + model="gpt-4o", + custom_llm_provider="openai", + ) + assert result == "call_abc123" + + def test_normalize_call_id_preserves_thought_signature_for_gemini(self): + from litellm.litellm_core_utils.prompt_templates.factory import ( + THOUGHT_SIGNATURE_SEPARATOR, + ) + + call_id = f"call_abc123{THOUGHT_SIGNATURE_SEPARATOR}sig_xyz" + result = ResponsesAPIRequestUtils._normalize_call_id_for_provider( + call_id=call_id, + model="gemini-2.5-flash", + custom_llm_provider="gemini", + ) + assert result == call_id + + def test_normalize_function_call_item_id_rewrites_for_openai(self): + result = ResponsesAPIRequestUtils._normalize_function_call_item_id_for_provider( + item_id="call_abc123", + model="gpt-4o", + custom_llm_provider="openai", + ) + assert result == "fc_abc123" + + result_tooluse = ( + ResponsesAPIRequestUtils._normalize_function_call_item_id_for_provider( + item_id="tooluse_abc123", + model="gpt-4o", + custom_llm_provider="openai", + ) + ) + assert result_tooluse == "fc_abc123" + + def test_normalize_function_call_item_id_no_rewrite_for_anthropic(self): + result = ResponsesAPIRequestUtils._normalize_function_call_item_id_for_provider( + item_id="call_abc123", + model="claude-3-5-sonnet", + custom_llm_provider="anthropic", + ) + assert result == "call_abc123" + + def test_normalize_function_call_ids_in_input(self): + from litellm.litellm_core_utils.prompt_templates.factory import ( + THOUGHT_SIGNATURE_SEPARATOR, + ) + + request_input = [ + { + "type": "function_call", + "id": "tooluse_xyz", + "call_id": f"call_abc{THOUGHT_SIGNATURE_SEPARATOR}sig", + "name": "get_weather", + "arguments": "{}", + }, + { + "type": "function_call_output", + "call_id": f"call_abc{THOUGHT_SIGNATURE_SEPARATOR}sig", + "output": "sunny", + }, + ] + + result = ResponsesAPIRequestUtils._normalize_function_call_ids_in_input( + request_input=request_input, + model="gpt-4o", + custom_llm_provider="openai", + ) + + assert result[0]["id"] == "fc_xyz" + assert result[0]["call_id"] == "call_abc" + assert result[1]["call_id"] == "call_abc" + class TestResponseAPILoggingUtils: def test_is_response_api_usage_true(self):