fix: keep provider id and metadata on Responses API bridged chat completions

This commit is contained in:
mateo-berri 2026-09-05 16:54:50 -07:00
parent b1e2f5bc0b
commit 347ea8f7ca
2 changed files with 126 additions and 3 deletions

View file

@ -33,7 +33,7 @@ from litellm.responses.sse_output_recovery import (
record_output_item_chunk,
record_output_text_chunk,
)
from litellm.responses.utils import normalize_responses_api_stream_options
from litellm.responses.utils import ResponsesAPIRequestUtils, normalize_responses_api_stream_options
from litellm.types.llms.openai import (
REASONING_EFFORT,
ChatCompletionAnnotation,
@ -54,7 +54,7 @@ if TYPE_CHECKING:
)
from pydantic import BaseModel
from litellm import LiteLLMLoggingObj, ModelResponse
from litellm import LiteLLMLoggingObj
from litellm.llms.base_llm.base_model_iterator import BaseModelResponseIterator
from litellm.types.llms.openai import (
ALL_RESPONSES_API_TOOL_PARAMS,
@ -69,6 +69,15 @@ if TYPE_CHECKING:
from litellm.types.utils import Choices
_CHAT_COMPLETION_FIELDS: Final = frozenset((*ModelResponse.model_fields, "usage"))
def _upstream_response_id(response_id: str | None) -> str | None:
if response_id is None:
return None
return ResponsesAPIRequestUtils.decode_previous_response_id_to_original_previous_response_id(response_id)
class _ReasoningSummaryText(TypedDict):
type: str
text: str
@ -904,6 +913,12 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge):
ResponseAPILoggingUtils._transform_response_api_usage_to_chat_usage(raw_response.usage),
)
model_response.id = _upstream_response_id(raw_response.id) or raw_response.id
provider_extras: Final = raw_response.model_extra.items() if raw_response.model_extra else ()
for key, value in provider_extras:
if key not in _CHAT_COMPLETION_FIELDS and value is not None:
setattr(model_response, key, value)
# Preserve hidden params from the ResponsesAPIResponse, especially the headers
# which contain important provider information like x-request-id
raw_response_hidden_params: Final = getattr(raw_response, "_hidden_params", {})
@ -1359,14 +1374,16 @@ class OpenAiResponsesToChatCompletionStreamIterator(BaseModelResponseIterator):
if event_type == "response.created":
# Initial response creation event
verbose_logger.debug("Chat provider: response.created -> %s", parsed_chunk)
created_response: Final = parsed_chunk.get("response")
return ModelResponseStream(
id=_upstream_response_id(created_response.get("id")) if created_response else None,
choices=[
StreamingChoices(
index=0,
delta=Delta(content=""),
finish_reason=None,
)
]
],
)
elif event_type == "response.output_item.added":
# New output item added

View file

@ -3952,3 +3952,109 @@ def test_convert_chat_completion_messages_to_responses_api_tool_result_with_tool
function_call_output = next(item for item in response if item.get("type") == "function_call_output")
assert function_call_output["output"] == [{"type": "input_text", "text": "1 tool found"}]
def _litellm_encoded_response_id(upstream_id: str) -> str:
"""The id a Responses API call hands back through LiteLLM: the provider's id wrapped with the
deployment it came from, the way ``/v1/responses`` clients see it."""
import base64
tagged = f"litellm:custom_llm_provider:azure;model_id:deployment-1;response_id:{upstream_id}"
return "resp_" + base64.b64encode(tagged.encode()).decode()
def test_transform_response_keeps_upstream_id_and_provider_extras():
"""A chat completion bridged through the Responses API must answer with the provider's own
response id, decoded out of the deployment-tagged id LiteLLM wraps around it, and every
top-level field the provider adds beyond the Responses schema (Azure's ``content_filters``,
``service_tier``), the way the native chat path passes unknown top-level fields through,
instead of a locally minted ``chatcmpl-`` id and nothing else."""
from unittest.mock import Mock
from litellm.completion_extras.litellm_responses_transformation.transformation import (
LiteLLMResponsesTransformationHandler,
)
from litellm.types.llms.openai import ResponsesAPIResponse
from litellm.types.utils import ModelResponse, Usage
content_filters = [
{"blocked": False, "source_type": "prompt", "content_filter_results": {"hate": {"filtered": False}}}
]
raw_response = ResponsesAPIResponse.model_validate(
{
"id": _litellm_encoded_response_id("resp_azure_123"),
"created_at": 1734366691,
"object": "response",
"model": "gpt-5.6",
"status": "completed",
"output": [
{
"type": "function_call",
"id": "fc_1",
"call_id": "call_1",
"name": "lookup_weather",
"arguments": '{"city": "Seattle"}',
"status": "completed",
}
],
"parallel_tool_calls": True,
"tool_choice": "auto",
"tools": [],
"usage": {"input_tokens": 10, "output_tokens": 5, "total_tokens": 15},
"service_tier": "default",
"content_filters": content_filters,
"max_tool_calls": None,
}
)
model_response = ModelResponse(
id="chatcmpl-local",
created=1734366691,
model=None,
object="chat.completion",
choices=[],
usage=Usage(completion_tokens=0, prompt_tokens=0, total_tokens=0),
)
result = LiteLLMResponsesTransformationHandler().transform_response(
model="gpt-5.6",
raw_response=raw_response,
model_response=model_response,
logging_obj=Mock(),
request_data={"model": "gpt-5.6"},
messages=[{"role": "user", "content": "What is the weather in Seattle?"}],
optional_params={},
litellm_params={},
encoding=Mock(),
)
dumped = result.model_dump()
assert dumped["id"] == "resp_azure_123"
assert dumped["object"] == "chat.completion"
assert dumped["service_tier"] == "default"
assert dumped["content_filters"] == content_filters
assert "max_tool_calls" not in dumped, "a null provider field must not appear as a null top-level key"
assert "output" not in dumped and "status" not in dumped, (
"Responses schema fields must not leak into the chat response"
)
assert dumped["choices"][0]["message"]["tool_calls"][0]["function"]["name"] == "lookup_weather"
def test_streaming_chunks_carry_the_upstream_response_id():
"""Every streamed chunk of a bridged chat completion must carry the provider's response id
from ``response.created`` rather than a locally minted ``chatcmpl-`` id, so a client can
correlate the stream with the provider's request the same way the non-streaming path does."""
from litellm.completion_extras.litellm_responses_transformation.transformation import (
OpenAiResponsesToChatCompletionStreamIterator,
)
iterator = OpenAiResponsesToChatCompletionStreamIterator(streaming_response=None, sync_stream=True)
encoded_id = _litellm_encoded_response_id("resp_azure_stream")
events = [
{"type": "response.created", "response": {"id": encoded_id, "output": []}},
{"type": "response.output_text.delta", "delta": "Hel"},
{"type": "response.completed", "response": {"id": encoded_id, "output": [{"type": "message"}]}},
]
ids = [iterator.chunk_parser(event).id for event in events]
assert ids == ["resp_azure_stream"] * len(events), f"streamed chunks did not carry the upstream id: {ids}"