fix(responses): normalize function-call ids and strip gemini thought signatures on cross-provider replay

This commit is contained in:
Shreehitha Arushan 2026-08-26 16:47:47 +05:30
parent 6e569ee0c7
commit f56bfdb2fe
3 changed files with 307 additions and 0 deletions

View file

@ -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,

View file

@ -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,

View file

@ -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"]