mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-09 22:31:41 +00:00
Merge pull request #39981 from BerriAI/litellm_lit_7079_bridge_preserve_provider_metadata
fix: keep provider id and metadata on Responses API bridged chat completions
This commit is contained in:
commit
c8ef043087
4 changed files with 271 additions and 6 deletions
|
|
@ -5,8 +5,11 @@ Handler for transforming /chat/completions api requests to litellm.responses req
|
|||
import json
|
||||
import os
|
||||
from collections.abc import AsyncIterator, Callable, Iterable, Iterator, Mapping, Sequence
|
||||
from types import MappingProxyType
|
||||
from typing import TYPE_CHECKING, Any, Final, Literal, TypedDict, Union, cast, get_args
|
||||
|
||||
from openai.types.chat import ChatCompletion
|
||||
from openai.types.responses import Response
|
||||
from openai.types.responses.custom_tool_param import CustomToolParam
|
||||
from openai.types.responses.response_input_param import (
|
||||
FunctionCallOutput,
|
||||
|
|
@ -33,7 +36,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,
|
||||
|
|
@ -43,6 +46,7 @@ from litellm.types.llms.openai import (
|
|||
ChatCompletionToolParamFunctionChunk,
|
||||
Reasoning,
|
||||
ResponsesAPIOptionalRequestParams,
|
||||
ResponsesAPIResponse,
|
||||
ResponsesAPIStreamEvents,
|
||||
)
|
||||
from litellm.types.utils import GenericStreamingChunk, ModelResponseStream
|
||||
|
|
@ -54,7 +58,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 +73,28 @@ if TYPE_CHECKING:
|
|||
from litellm.types.utils import Choices
|
||||
|
||||
|
||||
_CHAT_COMPLETION_FIELDS: Final = frozenset((*ModelResponse.model_fields, "usage"))
|
||||
_RESPONSES_API_ONLY_FIELDS: Final = frozenset((*Response.model_fields, *ResponsesAPIResponse.model_fields)) - frozenset(
|
||||
ChatCompletion.model_fields
|
||||
)
|
||||
|
||||
|
||||
def _provider_metadata(response_fields: Mapping[str, object] | None) -> Mapping[str, object]:
|
||||
return MappingProxyType(
|
||||
{
|
||||
key: value
|
||||
for key, value in (response_fields.items() if response_fields else ())
|
||||
if value is not None and key not in _CHAT_COMPLETION_FIELDS and key not in _RESPONSES_API_ONLY_FIELDS
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
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 +930,10 @@ 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
|
||||
for key, value in _provider_metadata(raw_response.model_extra).items():
|
||||
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 +1389,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
|
||||
|
|
@ -1534,6 +1566,7 @@ class OpenAiResponsesToChatCompletionStreamIterator(BaseModelResponseIterator):
|
|||
from litellm.responses.utils import ResponseAPILoggingUtils
|
||||
|
||||
usage = ResponseAPILoggingUtils._transform_response_api_usage_to_chat_usage(response_data.get("usage"))
|
||||
provider_metadata: Final = _provider_metadata(response_data)
|
||||
return ModelResponseStream(
|
||||
choices=[
|
||||
StreamingChoices(
|
||||
|
|
@ -1546,6 +1579,7 @@ class OpenAiResponsesToChatCompletionStreamIterator(BaseModelResponseIterator):
|
|||
)
|
||||
],
|
||||
usage=usage,
|
||||
provider_specific_fields=dict(provider_metadata) or None, # mutable-ok: field is typed dict
|
||||
)
|
||||
else:
|
||||
pass
|
||||
|
|
|
|||
|
|
@ -3909,11 +3909,12 @@ class Logging(LiteLLMLoggingBaseClass):
|
|||
LiteLLMResponsesTransformationHandler,
|
||||
)
|
||||
|
||||
served_id: Final = _provider_response_id(result)
|
||||
try:
|
||||
return LiteLLMResponsesTransformationHandler().transform_response(
|
||||
translated: Final = LiteLLMResponsesTransformationHandler().transform_response(
|
||||
model=self.model,
|
||||
raw_response=result,
|
||||
model_response=litellm.ModelResponse(id=_provider_response_id(result)),
|
||||
model_response=litellm.ModelResponse(id=served_id),
|
||||
logging_obj=self,
|
||||
request_data={},
|
||||
messages=[],
|
||||
|
|
@ -3921,6 +3922,8 @@ class Logging(LiteLLMLoggingBaseClass):
|
|||
litellm_params={},
|
||||
encoding=litellm.encoding,
|
||||
)
|
||||
translated.id = served_id or translated.id
|
||||
return translated
|
||||
except Exception as e:
|
||||
verbose_logger.debug(
|
||||
"Responses API -> ModelResponse translation failed for "
|
||||
|
|
@ -3928,7 +3931,7 @@ class Logging(LiteLLMLoggingBaseClass):
|
|||
"usage-only ModelResponse to keep the spend_logs row.",
|
||||
str(e),
|
||||
)
|
||||
model_response: Final = litellm.ModelResponse(id=_provider_response_id(result))
|
||||
model_response: Final = litellm.ModelResponse(id=served_id)
|
||||
model_response.model = self.model
|
||||
usage: Final = getattr(result, "usage", None)
|
||||
if usage is not None and ResponseAPILoggingUtils._is_response_api_usage(usage):
|
||||
|
|
|
|||
|
|
@ -3952,3 +3952,198 @@ 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:
|
||||
from litellm.responses.utils import ResponsesAPIRequestUtils
|
||||
|
||||
return ResponsesAPIRequestUtils._build_responses_api_response_id(
|
||||
custom_llm_provider="azure", model_id="deployment-1", response_id=upstream_id
|
||||
)
|
||||
|
||||
|
||||
def test_transform_response_keeps_upstream_id_and_provider_extras():
|
||||
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,
|
||||
"background": False,
|
||||
"top_logprobs": 0,
|
||||
"store": True,
|
||||
}
|
||||
)
|
||||
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 not {"background", "top_logprobs", "store"} & dumped.keys(), (
|
||||
"Responses API bookkeeping must not ride along as chat metadata"
|
||||
)
|
||||
assert dumped["choices"][0]["message"]["tool_calls"][0]["function"]["name"] == "lookup_weather"
|
||||
|
||||
|
||||
def test_bridged_response_is_priced_by_the_reported_service_tier():
|
||||
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
|
||||
|
||||
raw_response = ResponsesAPIResponse.model_validate(
|
||||
{
|
||||
"id": "resp_flex",
|
||||
"created_at": 1734366691,
|
||||
"object": "response",
|
||||
"model": "gpt-5.4",
|
||||
"status": "completed",
|
||||
"output": [
|
||||
{
|
||||
"type": "message",
|
||||
"id": "msg_1",
|
||||
"role": "assistant",
|
||||
"status": "completed",
|
||||
"content": [{"type": "output_text", "text": "hi", "annotations": []}],
|
||||
}
|
||||
],
|
||||
"usage": {"input_tokens": 1000, "output_tokens": 100, "total_tokens": 1100},
|
||||
"service_tier": "flex",
|
||||
}
|
||||
)
|
||||
|
||||
result = LiteLLMResponsesTransformationHandler().transform_response(
|
||||
model="gpt-5.4",
|
||||
raw_response=raw_response,
|
||||
model_response=ModelResponse(),
|
||||
logging_obj=Mock(),
|
||||
request_data={"model": "gpt-5.4"},
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
optional_params={},
|
||||
litellm_params={},
|
||||
encoding=Mock(),
|
||||
)
|
||||
pricing = litellm.model_cost["gpt-5.4"]
|
||||
flex_cost = 1000 * pricing["input_cost_per_token_flex"] + 100 * pricing["output_cost_per_token_flex"]
|
||||
standard_cost = 1000 * pricing["input_cost_per_token"] + 100 * pricing["output_cost_per_token"]
|
||||
|
||||
cost = litellm.completion_cost(completion_response=result, custom_llm_provider="openai")
|
||||
|
||||
assert cost == pytest.approx(flex_cost)
|
||||
assert cost < standard_cost
|
||||
|
||||
|
||||
def test_streaming_chunks_carry_the_upstream_response_id():
|
||||
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}"
|
||||
|
||||
|
||||
def test_streaming_final_chunk_carries_provider_metadata():
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from litellm.completion_extras.litellm_responses_transformation.transformation import (
|
||||
OpenAiResponsesToChatCompletionStreamIterator,
|
||||
)
|
||||
from litellm.litellm_core_utils.streaming_handler import CustomStreamWrapper
|
||||
|
||||
iterator = OpenAiResponsesToChatCompletionStreamIterator(streaming_response=None, sync_stream=True)
|
||||
content_filters = [{"blocked": False, "source_type": "completion", "content_filter_results": {}}]
|
||||
events = [
|
||||
{"type": "response.created", "response": {"id": "resp_azure_stream", "output": []}},
|
||||
{"type": "response.output_text.delta", "delta": "Hello"},
|
||||
{
|
||||
"type": "response.completed",
|
||||
"response": {
|
||||
"id": "resp_azure_stream",
|
||||
"output": [{"type": "message"}],
|
||||
"usage": {"input_tokens": 3, "output_tokens": 1, "total_tokens": 4},
|
||||
"service_tier": "default",
|
||||
"content_filters": content_filters,
|
||||
"background": False,
|
||||
},
|
||||
},
|
||||
]
|
||||
stream = CustomStreamWrapper(
|
||||
completion_stream=iter([iterator.chunk_parser(event) for event in events]),
|
||||
model="gpt-5.6",
|
||||
custom_llm_provider="azure",
|
||||
logging_obj=MagicMock(),
|
||||
)
|
||||
|
||||
chunks = [chunk.model_dump() for chunk in stream]
|
||||
|
||||
assert chunks[-1]["choices"][0]["finish_reason"] == "stop"
|
||||
assert chunks[-1]["service_tier"] == "default"
|
||||
assert chunks[-1]["content_filters"] == content_filters
|
||||
assert "background" not in chunks[-1]
|
||||
assert all("service_tier" not in chunk for chunk in chunks[:-1])
|
||||
|
|
|
|||
|
|
@ -4471,6 +4471,39 @@ def test_handle_anthropic_messages_response_logging_translates_bare_responses_ap
|
|||
assert result.usage.total_tokens == 18 # type: ignore[attr-defined]
|
||||
|
||||
|
||||
def test_handle_anthropic_messages_response_logging_keeps_the_served_response_id():
|
||||
from openai.types.responses import ResponseOutputMessage, ResponseOutputText
|
||||
|
||||
from litellm.responses.utils import ResponsesAPIRequestUtils
|
||||
from litellm.types.llms.openai import ResponseAPIUsage, ResponsesAPIResponse
|
||||
|
||||
served_id = ResponsesAPIRequestUtils._build_responses_api_response_id(
|
||||
custom_llm_provider="openai", model_id="deployment-1", response_id="resp_upstream"
|
||||
)
|
||||
logging_obj = _anthropic_messages_logging_obj()
|
||||
result = logging_obj._handle_anthropic_messages_response_logging(
|
||||
result=ResponsesAPIResponse(
|
||||
id=served_id,
|
||||
created_at=1700000000,
|
||||
output=[
|
||||
ResponseOutputMessage(
|
||||
id="msg-1",
|
||||
type="message",
|
||||
role="assistant",
|
||||
status="completed",
|
||||
content=[ResponseOutputText(annotations=[], text="hi", type="output_text")],
|
||||
)
|
||||
],
|
||||
usage=ResponseAPIUsage(input_tokens=2, output_tokens=1, total_tokens=3),
|
||||
service_tier="flex",
|
||||
)
|
||||
)
|
||||
|
||||
assert isinstance(result, ModelResponse)
|
||||
assert result.id == served_id, "the spend log row must keep the id the caller was served"
|
||||
assert result.service_tier == "flex"
|
||||
|
||||
|
||||
def test_handle_anthropic_messages_response_logging_passes_model_response_through():
|
||||
"""Anthropic-native path already yields a ModelResponse; it must be returned unchanged."""
|
||||
logging_obj = _anthropic_messages_logging_obj()
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue