fix(responses): normalize tool call id shapes across the anthropic bridge and openai replay

The chat-completions bridge emitted Responses output items whose item ids
were raw Anthropic tool ids (toolu_/srvtoolu_), which OpenAI rejects on
replay with "Expected an ID that begins with 'fc'", breaking router
fallback conversations from gpt-5 to claude models.

Four fixes, composable and independently useful:
- emission: bridge output items get fc_/ctc_-prefixed item ids while
  call_id stays raw so tool_result pairing keeps working (streaming and
  non-streaming share the same helpers)
- openai replay: request transformation drops tool call item ids that do
  not match OpenAI's own shapes instead of forwarding them, gated to
  OpenAI and Azure, since the API accepts the items with no id at all
- anthropic replay: a replayed srvtoolu_ call whose paired server tool
  result is unavailable degrades to a plain client tool_use instead of a
  dangling server_tool_use that 400s the client's tool_result
- tool-only turns no longer emit a message output item with output_text
  text null, matching native OpenAI output
This commit is contained in:
mateo-berri 2026-09-01 11:12:24 -07:00
parent f2a4172c89
commit ab2c9aed0f
9 changed files with 386 additions and 33 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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():

View file

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