mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-15 09:12:18 +00:00
fix(logging): keep the caller's call_type when chat completions are bridged to the Responses API (#49710)
* fix(logging): keep the caller's call_type when chat completions are bridged to the Responses API * fix(logging): log bridged chat completions with the chat response they returned * fix(logging): log bridged text completions and /v1/messages as served, with the request's options Bridged rows were translated without the request's options, so structured output and replay fields could differ from what the client got. Text completions and non-streamed /v1/messages through the chat bridge also kept a Responses API object or the encoded id. * fix(logging): redact reasoning summaries on bridged rows and log bridged text completions as text completions Redaction now drops reasoning_items on chat messages and deltas, so a bridged row keeps no reasoning summary when message logging is off. A non-streamed bridged text completion logs the text completion the client got, priced like the chat response it came from. The bridge handler's input TypedDict fields are ReadOnly. * fix(logging): redact bridged text completions for opted-out callbacks and pin the sync logged shape and usage ack * test(integration): look up bridged chat spend rows by the id the client got Bridged chat spend rows are keyed by the client-facing id and logged with the caller's call type, so the helpers that decoded the old resp_<base64> key or pinned call_type responses now assert the new contract --------- Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
This commit is contained in:
parent
7bd342d7ea
commit
ff182c194e
10 changed files with 587 additions and 76 deletions
|
|
@ -11,7 +11,7 @@ from typing import (
|
||||||
cast, # noqa: TID251 # optional_params reaches the bridge as an untyped dict
|
cast, # noqa: TID251 # optional_params reaches the bridge as an untyped dict
|
||||||
)
|
)
|
||||||
|
|
||||||
from typing_extensions import TypedDict
|
from typing_extensions import ReadOnly, TypedDict
|
||||||
|
|
||||||
from litellm.types.llms.openai import ResponsesAPIResponse
|
from litellm.types.llms.openai import ResponsesAPIResponse
|
||||||
|
|
||||||
|
|
@ -20,15 +20,15 @@ if TYPE_CHECKING:
|
||||||
|
|
||||||
|
|
||||||
class ResponsesToCompletionBridgeHandlerInputKwargs(TypedDict):
|
class ResponsesToCompletionBridgeHandlerInputKwargs(TypedDict):
|
||||||
model: str
|
model: ReadOnly[str]
|
||||||
messages: list
|
messages: ReadOnly[list]
|
||||||
optional_params: dict
|
optional_params: ReadOnly[dict[str, object]]
|
||||||
litellm_params: dict
|
litellm_params: ReadOnly[dict[str, object]]
|
||||||
headers: dict
|
headers: ReadOnly[dict]
|
||||||
model_response: "ModelResponse"
|
model_response: ReadOnly["ModelResponse"]
|
||||||
logging_obj: "LiteLLMLoggingObj"
|
logging_obj: ReadOnly["LiteLLMLoggingObj"]
|
||||||
custom_llm_provider: str
|
custom_llm_provider: ReadOnly[str]
|
||||||
encoding: object
|
encoding: ReadOnly[object]
|
||||||
|
|
||||||
|
|
||||||
def _restore_routing_prefix(model: str, custom_llm_provider: str) -> str:
|
def _restore_routing_prefix(model: str, custom_llm_provider: str) -> str:
|
||||||
|
|
@ -184,6 +184,7 @@ class ResponsesToCompletionBridgeHandler:
|
||||||
custom_llm_provider: Final = validated_kwargs["custom_llm_provider"]
|
custom_llm_provider: Final = validated_kwargs["custom_llm_provider"]
|
||||||
if kwargs.get("stream") is True and "stream" not in optional_params:
|
if kwargs.get("stream") is True and "stream" not in optional_params:
|
||||||
optional_params = {**optional_params, "stream": True}
|
optional_params = {**optional_params, "stream": True}
|
||||||
|
logging_obj.record_bridged_chat_request(optional_params, litellm_params)
|
||||||
|
|
||||||
request_data: Final = self.transformation_handler.transform_request(
|
request_data: Final = self.transformation_handler.transform_request(
|
||||||
model=model,
|
model=model,
|
||||||
|
|
@ -285,6 +286,7 @@ class ResponsesToCompletionBridgeHandler:
|
||||||
custom_llm_provider: Final = validated_kwargs["custom_llm_provider"]
|
custom_llm_provider: Final = validated_kwargs["custom_llm_provider"]
|
||||||
if kwargs.get("stream") is True and "stream" not in optional_params:
|
if kwargs.get("stream") is True and "stream" not in optional_params:
|
||||||
optional_params = {**optional_params, "stream": True}
|
optional_params = {**optional_params, "stream": True}
|
||||||
|
logging_obj.record_bridged_chat_request(optional_params, litellm_params)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
request_data: Final = self.transformation_handler.transform_request(
|
request_data: Final = self.transformation_handler.transform_request(
|
||||||
|
|
|
||||||
|
|
@ -1235,10 +1235,6 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge):
|
||||||
|
|
||||||
api_model: Final = model
|
api_model: Final = model
|
||||||
|
|
||||||
from litellm.types.utils import CallTypes
|
|
||||||
|
|
||||||
setattr(litellm_logging_obj, "call_type", CallTypes.responses.value)
|
|
||||||
|
|
||||||
sanitized_litellm_params: Final = self._build_sanitized_litellm_params(litellm_params)
|
sanitized_litellm_params: Final = self._build_sanitized_litellm_params(litellm_params)
|
||||||
|
|
||||||
request_data: Final = {
|
request_data: Final = {
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ from datetime import datetime as dt_object
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
from types import MappingProxyType, TracebackType
|
from types import MappingProxyType, TracebackType
|
||||||
from typing import TYPE_CHECKING, Any, ClassVar, Final, Literal, NamedTuple, cast
|
from typing import TYPE_CHECKING, Any, ClassVar, Final, Literal, NamedTuple, TypeVar, cast
|
||||||
|
|
||||||
from httpx import Response, Timeout
|
from httpx import Response, Timeout
|
||||||
from openai import AsyncStream as OpenAIAsyncStream
|
from openai import AsyncStream as OpenAIAsyncStream
|
||||||
|
|
@ -99,6 +99,7 @@ from litellm.litellm_core_utils.hidden_params import (
|
||||||
get_hidden_params,
|
get_hidden_params,
|
||||||
served_from_cache,
|
served_from_cache,
|
||||||
set_hidden_param,
|
set_hidden_param,
|
||||||
|
set_hidden_params,
|
||||||
)
|
)
|
||||||
from litellm.litellm_core_utils.internal_call_metadata import (
|
from litellm.litellm_core_utils.internal_call_metadata import (
|
||||||
MODEL_ACCESS_GROUP_METADATA_KEY,
|
MODEL_ACCESS_GROUP_METADATA_KEY,
|
||||||
|
|
@ -124,6 +125,7 @@ from litellm.litellm_core_utils.llm_cost_calc.zero_cost_diagnostic import (
|
||||||
diagnose_zero_cost,
|
diagnose_zero_cost,
|
||||||
zero_cost_warning,
|
zero_cost_warning,
|
||||||
)
|
)
|
||||||
|
from litellm.litellm_core_utils.llm_response_utils.convert_dict_to_response import LiteLLMResponseObjectHandler
|
||||||
from litellm.litellm_core_utils.logging_utils import (
|
from litellm.litellm_core_utils.logging_utils import (
|
||||||
truncate_base64_in_messages,
|
truncate_base64_in_messages,
|
||||||
truncate_base64_in_messages_async,
|
truncate_base64_in_messages_async,
|
||||||
|
|
@ -144,7 +146,7 @@ from litellm.litellm_core_utils.served_output_texts import (
|
||||||
from litellm.litellm_core_utils.thread_pool_executor import executor
|
from litellm.litellm_core_utils.thread_pool_executor import executor
|
||||||
from litellm.llms.base_llm.ocr.transformation import OCRResponse
|
from litellm.llms.base_llm.ocr.transformation import OCRResponse
|
||||||
from litellm.llms.base_llm.search.transformation import SearchResponse
|
from litellm.llms.base_llm.search.transformation import SearchResponse
|
||||||
from litellm.responses.utils import ResponseAPILoggingUtils
|
from litellm.responses.utils import ResponseAPILoggingUtils, ResponsesAPIRequestUtils
|
||||||
from litellm.types.agents import LiteLLMSendMessageResponse
|
from litellm.types.agents import LiteLLMSendMessageResponse
|
||||||
from litellm.types.containers.main import ContainerObject
|
from litellm.types.containers.main import ContainerObject
|
||||||
from litellm.types.decisions import DecisionsResponse, OpenAIDecisionResponse
|
from litellm.types.decisions import DecisionsResponse, OpenAIDecisionResponse
|
||||||
|
|
@ -302,6 +304,11 @@ class _EnterpriseAlertingLoggers(NamedTuple):
|
||||||
smtp_email: type[CustomLogger]
|
smtp_email: type[CustomLogger]
|
||||||
|
|
||||||
|
|
||||||
|
class BridgedChatRequest(NamedTuple):
|
||||||
|
optional_params: Mapping[str, object]
|
||||||
|
litellm_params: Mapping[str, object]
|
||||||
|
|
||||||
|
|
||||||
@functools.cache
|
@functools.cache
|
||||||
def _enterprise_alerting_loggers() -> _EnterpriseAlertingLoggers:
|
def _enterprise_alerting_loggers() -> _EnterpriseAlertingLoggers:
|
||||||
try:
|
try:
|
||||||
|
|
@ -582,6 +589,7 @@ _LITELLM_PARAMS: Final = TypeAdapter(dict[str, object])
|
||||||
_SERVED_ANTHROPIC_BODY: Final = TypeAdapter(dict[str, object])
|
_SERVED_ANTHROPIC_BODY: Final = TypeAdapter(dict[str, object])
|
||||||
_GOOGLE_USAGE_FIELDS: Final = frozenset(UsageMetadata.__annotations__)
|
_GOOGLE_USAGE_FIELDS: Final = frozenset(UsageMetadata.__annotations__)
|
||||||
_REDACTED_RESULT: Final = TypeAdapter(object)
|
_REDACTED_RESULT: Final = TypeAdapter(object)
|
||||||
|
_LoggedResponse: Final = TypeVar("_LoggedResponse")
|
||||||
_GUARDRAIL_INFORMATION_KEY: Final = "standard_logging_guardrail_information"
|
_GUARDRAIL_INFORMATION_KEY: Final = "standard_logging_guardrail_information"
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -875,6 +883,7 @@ class Logging(LiteLLMLoggingBaseClass):
|
||||||
# once that response is priced, the same way a non-streamed response is logged
|
# once that response is priced, the same way a non-streamed response is logged
|
||||||
self.client_facing_stream_model: str | None = None
|
self.client_facing_stream_model: str | None = None
|
||||||
self.client_facing_response_id: str | None = None
|
self.client_facing_response_id: str | None = None
|
||||||
|
self.bridged_chat_request: BridgedChatRequest | None = None
|
||||||
self.zero_cost_warned: bool = False
|
self.zero_cost_warned: bool = False
|
||||||
self.llm_caching_handler: LLMCachingHandler | None = None
|
self.llm_caching_handler: LLMCachingHandler | None = None
|
||||||
|
|
||||||
|
|
@ -3235,6 +3244,40 @@ class Logging(LiteLLMLoggingBaseClass):
|
||||||
return response
|
return response
|
||||||
return response.model_copy(update={"model": model})
|
return response.model_copy(update={"model": model})
|
||||||
|
|
||||||
|
def _as_served_chat_response(self, response: _LoggedResponse) -> _LoggedResponse | ModelResponse:
|
||||||
|
request: Final = self.bridged_chat_request
|
||||||
|
if request is None or not isinstance(response, ResponsesAPIResponse):
|
||||||
|
return response
|
||||||
|
return self._bridged_chat_response(response, request)
|
||||||
|
|
||||||
|
def _as_served_response(
|
||||||
|
self, response: _LoggedResponse
|
||||||
|
) -> _LoggedResponse | ModelResponse | TextCompletionResponse:
|
||||||
|
request: Final = self.bridged_chat_request
|
||||||
|
if request is None or not isinstance(response, ResponsesAPIResponse):
|
||||||
|
return response
|
||||||
|
served: Final = self._bridged_chat_response(response, request)
|
||||||
|
if self.call_type not in (CallTypes.text_completion.value, CallTypes.atext_completion.value): # pyright: ignore[reportUnknownMemberType] # Logging.call_type is untyped
|
||||||
|
return served
|
||||||
|
return self._as_text_completion_priced_as_chat(served)
|
||||||
|
|
||||||
|
def _as_text_completion_priced_as_chat(self, served: ModelResponse) -> TextCompletionResponse:
|
||||||
|
text_completion: Final = LiteLLMResponseObjectHandler.convert_chat_to_text_completion(
|
||||||
|
served, TextCompletionResponse()
|
||||||
|
)
|
||||||
|
set_hidden_params(text_completion, dict(get_hidden_params(served) or {}))
|
||||||
|
return text_completion
|
||||||
|
|
||||||
|
def _bridged_chat_response(self, response: ResponsesAPIResponse, request: BridgedChatRequest) -> ModelResponse:
|
||||||
|
translated: Final = self._translate_responses_api_response_to_model_response(response, request)
|
||||||
|
served: Final = translated.model_copy(
|
||||||
|
update={
|
||||||
|
"id": ResponsesAPIRequestUtils.decode_previous_response_id_to_original_previous_response_id(response.id)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
set_hidden_params(served, {**(get_hidden_params(translated) or {}), **(get_hidden_params(response) or {})})
|
||||||
|
return served
|
||||||
|
|
||||||
def _with_continued_response_id(
|
def _with_continued_response_id(
|
||||||
self,
|
self,
|
||||||
response: ModelResponse | TextCompletionResponse | ResponsesAPIResponse | InteractionsAPIResponse,
|
response: ModelResponse | TextCompletionResponse | ResponsesAPIResponse | InteractionsAPIResponse,
|
||||||
|
|
@ -3316,6 +3359,8 @@ class Logging(LiteLLMLoggingBaseClass):
|
||||||
)
|
)
|
||||||
|
|
||||||
result = self.normalize_endpoint_logging_result(result)
|
result = self.normalize_endpoint_logging_result(result)
|
||||||
|
if self.stream is not True:
|
||||||
|
result = self._as_served_response(result) # rebind-ok: bridged chat calls log the served response
|
||||||
|
|
||||||
logging_result: Final = self.normalize_logging_result(result=result)
|
logging_result: Final = self.normalize_logging_result(result=result)
|
||||||
if cache_hit is None and _served_from_cache_at_no_charge(logging_result):
|
if cache_hit is None and _served_from_cache_at_no_charge(logging_result):
|
||||||
|
|
@ -3670,7 +3715,9 @@ class Logging(LiteLLMLoggingBaseClass):
|
||||||
)
|
)
|
||||||
self._add_continued_partial_spend(complete_streaming_response)
|
self._add_continued_partial_spend(complete_streaming_response)
|
||||||
self._merge_hidden_params_from_response_into_metadata(complete_streaming_response)
|
self._merge_hidden_params_from_response_into_metadata(complete_streaming_response)
|
||||||
logged_streaming_response: Final = self._client_facing_streaming_response(complete_streaming_response)
|
logged_streaming_response: Final = self._client_facing_streaming_response(
|
||||||
|
self._as_served_chat_response(complete_streaming_response)
|
||||||
|
)
|
||||||
self.model_call_details["complete_streaming_response"] = logged_streaming_response
|
self.model_call_details["complete_streaming_response"] = logged_streaming_response
|
||||||
self.capture_usage_before_logging(logged_streaming_response, start_time, end_time)
|
self.capture_usage_before_logging(logged_streaming_response, start_time, end_time)
|
||||||
## STANDARDIZED LOGGING PAYLOAD
|
## STANDARDIZED LOGGING PAYLOAD
|
||||||
|
|
@ -4268,7 +4315,9 @@ class Logging(LiteLLMLoggingBaseClass):
|
||||||
|
|
||||||
await self._prepare_baseline_cache_estimate(complete_streaming_response)
|
await self._prepare_baseline_cache_estimate(complete_streaming_response)
|
||||||
|
|
||||||
logged_streaming_response: Final = self._client_facing_streaming_response(complete_streaming_response)
|
logged_streaming_response: Final = self._client_facing_streaming_response(
|
||||||
|
self._as_served_chat_response(complete_streaming_response)
|
||||||
|
)
|
||||||
self.model_call_details["async_complete_streaming_response"] = logged_streaming_response
|
self.model_call_details["async_complete_streaming_response"] = logged_streaming_response
|
||||||
|
|
||||||
## STANDARDIZED LOGGING PAYLOAD
|
## STANDARDIZED LOGGING PAYLOAD
|
||||||
|
|
@ -5227,6 +5276,13 @@ class Logging(LiteLLMLoggingBaseClass):
|
||||||
row is built from this response rather than re-parsed from the served body."""
|
row is built from this response rather than re-parsed from the served body."""
|
||||||
self.bridged_model_response = response
|
self.bridged_model_response = response
|
||||||
|
|
||||||
|
def record_bridged_chat_request(
|
||||||
|
self, optional_params: Mapping[str, object], litellm_params: Mapping[str, object]
|
||||||
|
) -> None:
|
||||||
|
self.bridged_chat_request = BridgedChatRequest(
|
||||||
|
MappingProxyType(dict(optional_params)), MappingProxyType(dict(litellm_params))
|
||||||
|
)
|
||||||
|
|
||||||
def record_bridged_responses_api_response(self, response: ResponsesAPIResponse) -> None:
|
def record_bridged_responses_api_response(self, response: ResponsesAPIResponse) -> None:
|
||||||
self.record_bridged_model_response(self._translate_responses_api_response_to_model_response(response))
|
self.record_bridged_model_response(self._translate_responses_api_response_to_model_response(response))
|
||||||
|
|
||||||
|
|
@ -5298,6 +5354,9 @@ class Logging(LiteLLMLoggingBaseClass):
|
||||||
):
|
):
|
||||||
result = result.response
|
result = result.response
|
||||||
if isinstance(result, ResponsesAPIResponse):
|
if isinstance(result, ResponsesAPIResponse):
|
||||||
|
bridged_request: Final = self.bridged_chat_request
|
||||||
|
if bridged_request is not None:
|
||||||
|
return self._bridged_chat_response(result, bridged_request)
|
||||||
return self._translate_responses_api_response_to_model_response(result)
|
return self._translate_responses_api_response_to_model_response(result)
|
||||||
|
|
||||||
raw_body: Final[object] = result
|
raw_body: Final[object] = result
|
||||||
|
|
@ -5349,7 +5408,9 @@ class Logging(LiteLLMLoggingBaseClass):
|
||||||
set_response_cost_in_hidden_params(result, reported_cost)
|
set_response_cost_in_hidden_params(result, reported_cost)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def _translate_responses_api_response_to_model_response(self, result: ResponsesAPIResponse) -> ModelResponse:
|
def _translate_responses_api_response_to_model_response(
|
||||||
|
self, result: ResponsesAPIResponse, request: BridgedChatRequest | None = None
|
||||||
|
) -> ModelResponse:
|
||||||
"""
|
"""
|
||||||
Convert a Responses API response into a ModelResponse for spend_logs.
|
Convert a Responses API response into a ModelResponse for spend_logs.
|
||||||
|
|
||||||
|
|
@ -5372,8 +5433,8 @@ class Logging(LiteLLMLoggingBaseClass):
|
||||||
logging_obj=self,
|
logging_obj=self,
|
||||||
request_data={},
|
request_data={},
|
||||||
messages=[],
|
messages=[],
|
||||||
optional_params={},
|
optional_params={} if request is None else dict(request.optional_params),
|
||||||
litellm_params={},
|
litellm_params={} if request is None else dict(request.litellm_params),
|
||||||
encoding=litellm.encoding,
|
encoding=litellm.encoding,
|
||||||
)
|
)
|
||||||
translated.id = served_id or translated.id
|
translated.id = served_id or translated.id
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
import copy
|
import copy
|
||||||
import inspect
|
import inspect
|
||||||
from collections.abc import Mapping, Sequence
|
from collections.abc import Iterable, Mapping, Sequence
|
||||||
from dataclasses import replace
|
from dataclasses import replace
|
||||||
from typing import TYPE_CHECKING, Any, Final
|
from typing import TYPE_CHECKING, Any, Final
|
||||||
|
|
||||||
|
|
@ -107,7 +107,13 @@ def redacted_response_obj_for_custom_logger(response_obj: object, custom_logger:
|
||||||
|
|
||||||
def _is_redactable_response(response_obj: object) -> bool:
|
def _is_redactable_response(response_obj: object) -> bool:
|
||||||
return isinstance(
|
return isinstance(
|
||||||
response_obj, (litellm.ModelResponse, litellm.ResponsesAPIResponse, litellm.EmbeddingResponse)
|
response_obj,
|
||||||
|
(
|
||||||
|
litellm.ModelResponse,
|
||||||
|
litellm.TextCompletionResponse,
|
||||||
|
litellm.ResponsesAPIResponse,
|
||||||
|
litellm.EmbeddingResponse,
|
||||||
|
),
|
||||||
) or (isinstance(response_obj, dict) and ("choices" in response_obj or "output" in response_obj))
|
) or (isinstance(response_obj, dict) and ("choices" in response_obj or "output" in response_obj))
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -208,6 +214,7 @@ def _redact_choice_content(choice):
|
||||||
choice.message.reasoning_content = REDACTED_BY_LITELLM
|
choice.message.reasoning_content = REDACTED_BY_LITELLM
|
||||||
if hasattr(choice.message, "thinking_blocks"):
|
if hasattr(choice.message, "thinking_blocks"):
|
||||||
choice.message.thinking_blocks = None
|
choice.message.thinking_blocks = None
|
||||||
|
_drop_field_when_present(choice.message, "reasoning_items")
|
||||||
_redact_provider_specific_fields(choice.message)
|
_redact_provider_specific_fields(choice.message)
|
||||||
_redact_tool_calls(getattr(choice.message, "tool_calls", None))
|
_redact_tool_calls(getattr(choice.message, "tool_calls", None))
|
||||||
_redact_function_call(getattr(choice.message, "function_call", None))
|
_redact_function_call(getattr(choice.message, "function_call", None))
|
||||||
|
|
@ -218,12 +225,14 @@ def _redact_choice_content(choice):
|
||||||
choice.delta.reasoning_content = REDACTED_BY_LITELLM
|
choice.delta.reasoning_content = REDACTED_BY_LITELLM
|
||||||
if hasattr(choice.delta, "thinking_blocks"):
|
if hasattr(choice.delta, "thinking_blocks"):
|
||||||
choice.delta.thinking_blocks = None
|
choice.delta.thinking_blocks = None
|
||||||
|
_drop_field_when_present(choice.delta, "reasoning_items")
|
||||||
_redact_provider_specific_fields(choice.delta)
|
_redact_provider_specific_fields(choice.delta)
|
||||||
_redact_tool_calls(getattr(choice.delta, "tool_calls", None))
|
_redact_tool_calls(getattr(choice.delta, "tool_calls", None))
|
||||||
_redact_function_call(getattr(choice.delta, "function_call", None))
|
_redact_function_call(getattr(choice.delta, "function_call", None))
|
||||||
elif isinstance(choice, litellm.utils.TextChoices):
|
elif isinstance(choice, litellm.utils.TextChoices):
|
||||||
_redact_field_when_present(choice, "text")
|
_redact_field_when_present(choice, "text")
|
||||||
_redact_field_when_present(choice, "reasoning_content")
|
_redact_field_when_present(choice, "reasoning_content")
|
||||||
|
_drop_field_when_present(choice, "logprobs")
|
||||||
|
|
||||||
|
|
||||||
def _as_mapping(value: object) -> Mapping[str, object] | None:
|
def _as_mapping(value: object) -> Mapping[str, object] | None:
|
||||||
|
|
@ -284,6 +293,11 @@ def _redact_field_when_present(item: object, field: str) -> None:
|
||||||
setattr(item, field, REDACTED_BY_LITELLM)
|
setattr(item, field, REDACTED_BY_LITELLM)
|
||||||
|
|
||||||
|
|
||||||
|
def _drop_field_when_present(item: object, field: str) -> None:
|
||||||
|
if getattr(item, field, None) is not None:
|
||||||
|
setattr(item, field, None)
|
||||||
|
|
||||||
|
|
||||||
def _redact_responses_api_output(output_items):
|
def _redact_responses_api_output(output_items):
|
||||||
"""Helper to redact ResponsesAPIResponse output items."""
|
"""Helper to redact ResponsesAPIResponse output items."""
|
||||||
for output_item in output_items:
|
for output_item in output_items:
|
||||||
|
|
@ -440,32 +454,22 @@ def _redact_tool_calls_dict(message: Mapping[str, object]) -> None:
|
||||||
function_call["arguments"] = REDACTED_BY_LITELLM
|
function_call["arguments"] = REDACTED_BY_LITELLM
|
||||||
|
|
||||||
|
|
||||||
def _redact_model_response_dict_choices(choices, redacted_str: str):
|
def _redact_model_response_dict_choices(choices: Iterable[object], redacted_str: str) -> None:
|
||||||
for choice in choices:
|
for choice in choices:
|
||||||
if isinstance(choice, dict):
|
if isinstance(choice, dict):
|
||||||
_redact_provider_specific_fields_dict(choice)
|
_redact_provider_specific_fields_dict(choice)
|
||||||
if choice.get("token_ids") is not None:
|
if choice.get("token_ids") is not None:
|
||||||
choice["token_ids"] = None
|
choice["token_ids"] = None
|
||||||
if "message" in choice and isinstance(choice["message"], dict):
|
if (
|
||||||
if choice["message"].get("content") is not None:
|
body := next((choice[key] for key in ("message", "delta") if isinstance(choice.get(key), dict)), None)
|
||||||
choice["message"]["content"] = redacted_str
|
) is not None:
|
||||||
if choice["message"].get("reasoning_content") is not None:
|
for field in ("content", "reasoning_content"):
|
||||||
choice["message"]["reasoning_content"] = redacted_str
|
if body.get(field) is not None:
|
||||||
if "thinking_blocks" in choice["message"]:
|
body[field] = redacted_str
|
||||||
choice["message"]["thinking_blocks"] = None
|
for field in ("thinking_blocks", "reasoning_items", "audio"):
|
||||||
if "audio" in choice["message"]:
|
if field in body:
|
||||||
choice["message"]["audio"] = None
|
body[field] = None
|
||||||
_redact_tool_calls_dict(choice["message"])
|
_redact_tool_calls_dict(body)
|
||||||
elif "delta" in choice and isinstance(choice["delta"], dict):
|
|
||||||
if choice["delta"].get("content") is not None:
|
|
||||||
choice["delta"]["content"] = redacted_str
|
|
||||||
if choice["delta"].get("reasoning_content") is not None:
|
|
||||||
choice["delta"]["reasoning_content"] = redacted_str
|
|
||||||
if "thinking_blocks" in choice["delta"]:
|
|
||||||
choice["delta"]["thinking_blocks"] = None
|
|
||||||
if "audio" in choice["delta"]:
|
|
||||||
choice["delta"]["audio"] = None
|
|
||||||
_redact_tool_calls_dict(choice["delta"])
|
|
||||||
elif choice.get("text") is not None:
|
elif choice.get("text") is not None:
|
||||||
choice["text"] = redacted_str
|
choice["text"] = redacted_str
|
||||||
else:
|
else:
|
||||||
|
|
@ -588,7 +592,7 @@ def perform_redaction(model_call_details: dict, result, redact_streaming_respons
|
||||||
return {"text": REDACTED_BY_LITELLM}
|
return {"text": REDACTED_BY_LITELLM}
|
||||||
|
|
||||||
_result: Final = copy.deepcopy(result)
|
_result: Final = copy.deepcopy(result)
|
||||||
if isinstance(_result, litellm.ModelResponse):
|
if isinstance(_result, (litellm.ModelResponse, litellm.TextCompletionResponse)):
|
||||||
if hasattr(_result, "choices") and _result.choices is not None:
|
if hasattr(_result, "choices") and _result.choices is not None:
|
||||||
for choice in _result.choices:
|
for choice in _result.choices:
|
||||||
_redact_choice_content(choice)
|
_redact_choice_content(choice)
|
||||||
|
|
|
||||||
|
|
@ -475,10 +475,6 @@ def row_key(request_id: str) -> str:
|
||||||
return upstream_id_of(request_id.split("_cache_hit", 1)[0])
|
return upstream_id_of(request_id.split("_cache_hit", 1)[0])
|
||||||
|
|
||||||
|
|
||||||
def caller_sees_upstream_id(endpoint: Endpoint, *, stream: bool) -> bool:
|
|
||||||
return (endpoint, stream) != ("messages", True)
|
|
||||||
|
|
||||||
|
|
||||||
def usage_of(payload: Mapping[str, JsonValue]) -> dict[str, JsonValue]:
|
def usage_of(payload: Mapping[str, JsonValue]) -> dict[str, JsonValue]:
|
||||||
usage: Final = payload.get("usage")
|
usage: Final = payload.get("usage")
|
||||||
return JSON.validate_python(usage) if isinstance(usage, dict) else {}
|
return JSON.validate_python(usage) if isinstance(usage, dict) else {}
|
||||||
|
|
@ -733,10 +729,9 @@ def marked_cell(gateway: Gateway, endpoint: Endpoint, *, stream: bool) -> None:
|
||||||
assert_answered(outcome, marker)
|
assert_answered(outcome, marker)
|
||||||
assert_wire(received, expected_wire(GPT, prompt_text(marker), endpoint=endpoint, marked=True), streaming=stream)
|
assert_wire(received, expected_wire(GPT, prompt_text(marker), endpoint=endpoint, marked=True), streaming=stream)
|
||||||
assert_usage(endpoint, outcome.usage)
|
assert_usage(endpoint, outcome.usage)
|
||||||
row: Final = success_row(name, marker)
|
row: Final = success_row(name, marker, outcome.response_id)
|
||||||
assert_priced_row(row, GPT)
|
assert_priced_row(row, GPT)
|
||||||
if caller_sees_upstream_id(endpoint, stream=stream):
|
assert row_key(str(row["request_id"])) == outcome.response_id, (row, outcome.response_id)
|
||||||
assert row_key(str(row["request_id"])) == outcome.response_id, (row, outcome.response_id)
|
|
||||||
if not stream:
|
if not stream:
|
||||||
assert math.isclose(float(outcome.headers["x-litellm-response-cost"]), expected_spend(GPT), rel_tol=1e-9), (
|
assert math.isclose(float(outcome.headers["x-litellm-response-cost"]), expected_spend(GPT), rel_tol=1e-9), (
|
||||||
outcome.headers
|
outcome.headers
|
||||||
|
|
@ -901,7 +896,7 @@ def assert_marked_sdk_cell(
|
||||||
only_received(wire), expected_wire(GPT, prompt_text(marker), endpoint=endpoint, marked=True), streaming=stream
|
only_received(wire), expected_wire(GPT, prompt_text(marker), endpoint=endpoint, marked=True), streaming=stream
|
||||||
)
|
)
|
||||||
assert_usage(endpoint, outcome.usage)
|
assert_usage(endpoint, outcome.usage)
|
||||||
assert_priced_row(success_row(name, marker), GPT)
|
assert_priced_row(success_row(name, marker, outcome.response_id), GPT)
|
||||||
|
|
||||||
|
|
||||||
HOSTILE_OPTIONS: Final[dict[str, JsonValue]] = {
|
HOSTILE_OPTIONS: Final[dict[str, JsonValue]] = {
|
||||||
|
|
|
||||||
|
|
@ -1211,6 +1211,6 @@ def test_non_claude_mantle_id_with_a_bare_bedrock_twin_keeps_its_route_and_spend
|
||||||
cost: Final = _mantle_cost(gateway, _LUNA)
|
cost: Final = _mantle_cost(gateway, _LUNA)
|
||||||
assert float(response.headers["x-litellm-response-cost"]) == pytest.approx(cost), response.text
|
assert float(response.headers["x-litellm-response-cost"]) == pytest.approx(cost), response.text
|
||||||
_assert_success_row(
|
_assert_success_row(
|
||||||
_spend_row(_BY_CALL_ID, response.headers["x-litellm-call-id"]), model, _LUNA, "responses", cost
|
_spend_row(_BY_CALL_ID, response.headers["x-litellm-call-id"]), model, _LUNA, "acompletion", cost
|
||||||
)
|
)
|
||||||
assert [(request.method, request.target) for request in wire.drain()] == [("POST", _RESPONSES_PATH)]
|
assert [(request.method, request.target) for request in wire.drain()] == [("POST", _RESPONSES_PATH)]
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import base64
|
|
||||||
import json
|
import json
|
||||||
import uuid
|
import uuid
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
|
|
@ -547,14 +546,8 @@ def test_azure_gpt_6_bridged_no_cache_function_requests_each_reach_provider_and_
|
||||||
lambda found: len(found) == 2,
|
lambda found: len(found) == 2,
|
||||||
seconds=70,
|
seconds=70,
|
||||||
)
|
)
|
||||||
by_response_id: Final = {
|
assert sorted(string_value(row["request_id"]) for row in rows) == sorted(response_ids), rows
|
||||||
(decoded := base64.b64decode(string_value(row["request_id"]).removeprefix("resp_")).decode())
|
for row in rows:
|
||||||
.rsplit("response_id:", 1)[1]: (decoded, row)
|
|
||||||
for row in rows
|
|
||||||
}
|
|
||||||
for response_id in response_ids:
|
|
||||||
decoded, row = by_response_id[response_id]
|
|
||||||
assert decoded.startswith("litellm:custom_llm_provider:azure;model_id:"), rows
|
|
||||||
assert (
|
assert (
|
||||||
string_value(row["status"]),
|
string_value(row["status"]),
|
||||||
string_value(row["cache_hit"]),
|
string_value(row["cache_hit"]),
|
||||||
|
|
@ -601,11 +594,7 @@ def test_azure_gpt_6_bridged_function_requests_without_cache_field_still_hit_cac
|
||||||
seconds=70,
|
seconds=70,
|
||||||
)
|
)
|
||||||
priced, cached = rows
|
priced, cached = rows
|
||||||
priced_request: Final = base64.b64decode(
|
assert string_value(priced["request_id"]) == "resp_cached", rows
|
||||||
string_value(priced["request_id"]).removeprefix("resp_")
|
|
||||||
).decode()
|
|
||||||
assert priced_request.startswith("litellm:custom_llm_provider:azure;model_id:"), rows
|
|
||||||
assert priced_request.endswith(";response_id:resp_cached"), rows
|
|
||||||
assert (
|
assert (
|
||||||
priced["status"],
|
priced["status"],
|
||||||
priced["cache_hit"],
|
priced["cache_hit"],
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import json
|
import json
|
||||||
from base64 import b64decode
|
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from hashlib import sha256
|
from hashlib import sha256
|
||||||
from typing import Final
|
from typing import Final
|
||||||
|
|
@ -100,8 +99,7 @@ def test_bridged_chat_completion_above_log_offload_threshold_logs_and_charges_on
|
||||||
call_rows: Final = [row for row in rows if row["litellm_call_id"] == call_id]
|
call_rows: Final = [row for row in rows if row["litellm_call_id"] == call_id]
|
||||||
assert len(call_rows) == 1, rows
|
assert len(call_rows) == 1, rows
|
||||||
assert float(str(call_rows[0]["spend"])) == pytest.approx(EXPECTED_SPEND), rows
|
assert float(str(call_rows[0]["spend"])) == pytest.approx(EXPECTED_SPEND), rows
|
||||||
logged_id: Final = b64decode(str(call_rows[0]["request_id"]).removeprefix("resp_")).decode()
|
assert call_rows[0]["request_id"] == upstream_id, rows
|
||||||
assert logged_id.startswith("litellm:") and f";response_id:{upstream_id}" in logged_id, rows
|
|
||||||
|
|
||||||
key_spend: Final = eventually(
|
key_spend: Final = eventually(
|
||||||
lambda: read_rows('SELECT spend FROM "LiteLLM_VerificationToken" WHERE token=%s', (digest,)),
|
lambda: read_rows('SELECT spend FROM "LiteLLM_VerificationToken" WHERE token=%s', (digest,)),
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,13 @@
|
||||||
|
import asyncio
|
||||||
import copy
|
import copy
|
||||||
import datetime
|
import datetime
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import queue
|
||||||
import unittest
|
import unittest
|
||||||
from collections.abc import Mapping, Sequence
|
from collections.abc import AsyncIterator, Mapping, Sequence
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from types import MappingProxyType
|
||||||
from typing import TYPE_CHECKING, Final, List, Literal, Optional, Tuple, cast, get_args
|
from typing import TYPE_CHECKING, Final, List, Literal, Optional, Tuple, cast, get_args
|
||||||
from unittest.mock import ANY, MagicMock, Mock, patch
|
from unittest.mock import ANY, MagicMock, Mock, patch
|
||||||
|
|
||||||
|
|
@ -24,15 +28,15 @@ import litellm
|
||||||
from litellm.completion_extras.litellm_responses_transformation.transformation import (
|
from litellm.completion_extras.litellm_responses_transformation.transformation import (
|
||||||
LiteLLMResponsesTransformationHandler,
|
LiteLLMResponsesTransformationHandler,
|
||||||
)
|
)
|
||||||
from litellm.types.llms.openai import AllMessageValues, REASONING_EFFORT
|
from litellm.integrations.custom_logger import CustomLogger
|
||||||
|
from litellm.litellm_core_utils.streaming_handler import CustomStreamWrapper
|
||||||
|
from litellm.types.llms.openai import AllMessageValues, REASONING_EFFORT, ResponsesAPIResponse
|
||||||
|
from litellm.types.utils import ModelResponse, StandardLoggingPayload, TextCompletionResponse
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from openai.types.responses import ResponseOutputItem
|
from openai.types.responses import ResponseOutputItem
|
||||||
from openai.types.responses.response_reasoning_item import ResponseReasoningItem
|
from openai.types.responses.response_reasoning_item import ResponseReasoningItem
|
||||||
|
|
||||||
from litellm.types.llms.openai import ResponsesAPIResponse
|
|
||||||
from litellm.types.utils import ModelResponse
|
|
||||||
|
|
||||||
|
|
||||||
def test_completion_over_responses_maps_anthropic_search_tool_to_openai_tool_search() -> None:
|
def test_completion_over_responses_maps_anthropic_search_tool_to_openai_tool_search() -> None:
|
||||||
response_body: Final = {
|
response_body: Final = {
|
||||||
|
|
@ -191,6 +195,417 @@ def test_auto_summary_sends_the_level_the_target_accepts(
|
||||||
assert request_body["reasoning"] == {"effort": "low", "summary": expected_summary}
|
assert request_body["reasoning"] == {"effort": "low", "summary": expected_summary}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class _LoggedSuccess:
|
||||||
|
call_type: str
|
||||||
|
payload: StandardLoggingPayload
|
||||||
|
response_obj: object
|
||||||
|
complete_streaming_response: object
|
||||||
|
|
||||||
|
|
||||||
|
class _LoggedCallTypes(CustomLogger):
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.seen_by_caller: Final = MappingProxyType(
|
||||||
|
{
|
||||||
|
"completion": queue.SimpleQueue[_LoggedSuccess](),
|
||||||
|
"acompletion": queue.SimpleQueue[_LoggedSuccess](),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _logged(kwargs: Mapping[str, object], response_obj: object, streaming_key: str) -> _LoggedSuccess:
|
||||||
|
return _LoggedSuccess(
|
||||||
|
call_type=str(kwargs["call_type"]),
|
||||||
|
payload=cast(StandardLoggingPayload, kwargs["standard_logging_object"]),
|
||||||
|
response_obj=response_obj,
|
||||||
|
complete_streaming_response=kwargs.get(streaming_key),
|
||||||
|
)
|
||||||
|
|
||||||
|
def log_success_event(
|
||||||
|
self,
|
||||||
|
kwargs: Mapping[str, object],
|
||||||
|
response_obj: object,
|
||||||
|
start_time: datetime.datetime,
|
||||||
|
end_time: datetime.datetime,
|
||||||
|
) -> None:
|
||||||
|
self.seen_by_caller["completion"].put(self._logged(kwargs, response_obj, "complete_streaming_response"))
|
||||||
|
|
||||||
|
async def async_log_success_event(
|
||||||
|
self,
|
||||||
|
kwargs: Mapping[str, object],
|
||||||
|
response_obj: object,
|
||||||
|
start_time: datetime.datetime,
|
||||||
|
end_time: datetime.datetime,
|
||||||
|
) -> None:
|
||||||
|
self.seen_by_caller["acompletion"].put(
|
||||||
|
self._logged(kwargs, response_obj, "async_complete_streaming_response")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_GET_WEATHER_TOOL: Final = {
|
||||||
|
"type": "function",
|
||||||
|
"function": {"name": "get_weather", "parameters": {"type": "object", "properties": {}}},
|
||||||
|
}
|
||||||
|
|
||||||
|
_BRIDGED_PROVIDERS: Final = pytest.mark.parametrize(
|
||||||
|
("model", "url", "provider_kwargs"),
|
||||||
|
[
|
||||||
|
("openai/gpt-5.3-codex", "https://api.openai.com/v1/responses", {}),
|
||||||
|
(
|
||||||
|
"azure/gpt-6-sol",
|
||||||
|
"https://example.invalid/openai/responses",
|
||||||
|
{"api_base": "https://example.invalid", "api_version": "2025-04-01-preview"},
|
||||||
|
),
|
||||||
|
("bedrock_mantle/openai.gpt-5.6-terra", _MANTLE_RESPONSES_URL, {"aws_region_name": "us-east-1"}),
|
||||||
|
],
|
||||||
|
ids=["openai-responses-only", "azure-tools", "bedrock-mantle-tools"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_PONG_MESSAGE: Final[Mapping[str, object]] = {
|
||||||
|
"type": "message",
|
||||||
|
"id": "msg_logged_call_type",
|
||||||
|
"status": "completed",
|
||||||
|
"role": "assistant",
|
||||||
|
"content": [{"type": "output_text", "text": "pong", "annotations": []}],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _searched_responses_body(model: str) -> Mapping[str, object]:
|
||||||
|
return {
|
||||||
|
"id": "resp_logged_call_type",
|
||||||
|
"object": "response",
|
||||||
|
"created_at": 1789788253,
|
||||||
|
"status": "completed",
|
||||||
|
"model": model.rsplit("/", 1)[-1],
|
||||||
|
"output": [
|
||||||
|
{
|
||||||
|
"type": "web_search_call",
|
||||||
|
"id": "ws_logged_call_type",
|
||||||
|
"status": "completed",
|
||||||
|
"action": {"type": "search", "query": "weather"},
|
||||||
|
},
|
||||||
|
_PONG_MESSAGE,
|
||||||
|
],
|
||||||
|
"usage": {"input_tokens": 10, "output_tokens": 5, "total_tokens": 15},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _responses_sse(response_body: Mapping[str, object]) -> str:
|
||||||
|
events: Final = (
|
||||||
|
{"type": "response.created", "response": {**response_body, "status": "in_progress", "output": [], "usage": None}},
|
||||||
|
{
|
||||||
|
"type": "response.output_text.delta",
|
||||||
|
"item_id": "msg_logged_call_type",
|
||||||
|
"output_index": 1,
|
||||||
|
"content_index": 0,
|
||||||
|
"delta": "pong",
|
||||||
|
},
|
||||||
|
{"type": "response.completed", "response": response_body},
|
||||||
|
)
|
||||||
|
return "".join(f"event: {event['type']}\ndata: {json.dumps(event)}\n\n" for event in events)
|
||||||
|
|
||||||
|
|
||||||
|
def _bridged_request(model: str, provider_kwargs: Mapping[str, object], stream: bool) -> Mapping[str, object]:
|
||||||
|
return {
|
||||||
|
"model": model,
|
||||||
|
"messages": [{"role": "user", "content": "ping"}],
|
||||||
|
"tools": [_GET_WEATHER_TOOL],
|
||||||
|
"api_key": "test-key",
|
||||||
|
"cache": {"no-cache": True},
|
||||||
|
"stream": stream,
|
||||||
|
**provider_kwargs,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _served_chunk_ids(caller: str, request: Mapping[str, object]) -> frozenset[str]:
|
||||||
|
if caller == "acompletion":
|
||||||
|
async_stream: Final = await litellm.acompletion(**request)
|
||||||
|
assert isinstance(async_stream, CustomStreamWrapper)
|
||||||
|
return frozenset([chunk.id async for chunk in async_stream])
|
||||||
|
sync_stream: Final = litellm.completion(**request)
|
||||||
|
assert isinstance(sync_stream, CustomStreamWrapper)
|
||||||
|
return frozenset(chunk.id for chunk in sync_stream)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("caller", ["completion", "acompletion"])
|
||||||
|
@_BRIDGED_PROVIDERS
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_bridged_chat_call_is_logged_with_the_callers_call_type(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, caller: str, model: str, url: str, provider_kwargs: Mapping[str, object]
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.delenv("AWS_BEARER_TOKEN_BEDROCK", raising=False)
|
||||||
|
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
|
||||||
|
recorder: Final = _LoggedCallTypes()
|
||||||
|
monkeypatch.setattr(litellm, "callbacks", [recorder])
|
||||||
|
response_body: Final = _searched_responses_body(model)
|
||||||
|
request: Final = _bridged_request(model, provider_kwargs, stream=False)
|
||||||
|
|
||||||
|
with respx.mock(assert_all_called=True) as router:
|
||||||
|
router.post(url__startswith=url).mock(return_value=httpx.Response(200, json=response_body))
|
||||||
|
if caller == "acompletion":
|
||||||
|
await litellm.acompletion(**request)
|
||||||
|
else:
|
||||||
|
litellm.completion(**request)
|
||||||
|
logged: Final = await asyncio.to_thread(recorder.seen_by_caller[caller].get, timeout=10)
|
||||||
|
|
||||||
|
assert (logged.call_type, logged.payload["call_type"]) == (caller, caller)
|
||||||
|
assert isinstance(logged.response_obj, ModelResponse)
|
||||||
|
assert logged.response_obj.id == response_body["id"]
|
||||||
|
assert logged.response_obj.choices[0].message.content == "pong"
|
||||||
|
assert logged.payload["response"]["choices"][0]["message"]["content"] == "pong"
|
||||||
|
assert logged.payload["response_cost"] == litellm.completion_cost(
|
||||||
|
completion_response=ResponsesAPIResponse.model_validate(response_body), model=model
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("caller", ["completion", "acompletion"])
|
||||||
|
@_BRIDGED_PROVIDERS
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_bridged_chat_stream_is_logged_as_the_chat_completion_it_streamed(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, caller: str, model: str, url: str, provider_kwargs: Mapping[str, object]
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.delenv("AWS_BEARER_TOKEN_BEDROCK", raising=False)
|
||||||
|
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
|
||||||
|
recorder: Final = _LoggedCallTypes()
|
||||||
|
monkeypatch.setattr(litellm, "callbacks", [recorder])
|
||||||
|
response_body: Final = _searched_responses_body(model)
|
||||||
|
request: Final = _bridged_request(model, provider_kwargs, stream=True)
|
||||||
|
|
||||||
|
with respx.mock(assert_all_called=True) as router:
|
||||||
|
router.post(url__startswith=url).mock(
|
||||||
|
return_value=httpx.Response(
|
||||||
|
200, text=_responses_sse(response_body), headers={"content-type": "text/event-stream"}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
served_ids: Final = await _served_chunk_ids(caller, request)
|
||||||
|
logged: Final = await asyncio.to_thread(recorder.seen_by_caller[caller].get, timeout=10)
|
||||||
|
|
||||||
|
assert isinstance(logged.complete_streaming_response, ModelResponse)
|
||||||
|
assert logged.response_obj is logged.complete_streaming_response
|
||||||
|
assert served_ids == {logged.complete_streaming_response.id}
|
||||||
|
assert logged.complete_streaming_response.choices[0].message.content == "pong"
|
||||||
|
assert logged.payload["response"]["choices"][0]["message"]["content"] == "pong"
|
||||||
|
assert logged.payload["response_cost"] == litellm.completion_cost(
|
||||||
|
completion_response=ResponsesAPIResponse.model_validate(response_body), model=model
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _json_message(item_id: str, document: Mapping[str, object]) -> Mapping[str, object]:
|
||||||
|
return {
|
||||||
|
"type": "message",
|
||||||
|
"id": item_id,
|
||||||
|
"status": "completed",
|
||||||
|
"role": "assistant",
|
||||||
|
"content": [{"type": "output_text", "text": json.dumps(document), "annotations": []}],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("caller", ["completion", "acompletion"])
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_bridged_structured_output_call_is_logged_with_the_json_document_the_client_got(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, caller: str
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
|
||||||
|
recorder: Final = _LoggedCallTypes()
|
||||||
|
monkeypatch.setattr(litellm, "callbacks", [recorder])
|
||||||
|
response_body: Final = {
|
||||||
|
**_searched_responses_body("openai/gpt-5.3-codex"),
|
||||||
|
"output": [_json_message("msg_first", {"answer": "pong"}), _json_message("msg_second", {"answer": "again"})],
|
||||||
|
}
|
||||||
|
request: Final = {
|
||||||
|
**_bridged_request("openai/gpt-5.3-codex", {}, stream=False),
|
||||||
|
"response_format": {"type": "json_object"},
|
||||||
|
}
|
||||||
|
|
||||||
|
with respx.mock(assert_all_called=True) as router:
|
||||||
|
router.post(url__startswith="https://api.openai.com/v1/responses").mock(
|
||||||
|
return_value=httpx.Response(200, json=response_body)
|
||||||
|
)
|
||||||
|
served: Final = await litellm.acompletion(**request) if caller == "acompletion" else litellm.completion(**request)
|
||||||
|
logged: Final = await asyncio.to_thread(recorder.seen_by_caller[caller].get, timeout=10)
|
||||||
|
|
||||||
|
assert isinstance(served, ModelResponse)
|
||||||
|
assert isinstance(logged.response_obj, ModelResponse)
|
||||||
|
served_message: Final = served.choices[0].message
|
||||||
|
logged_message: Final = logged.response_obj.choices[0].message
|
||||||
|
assert json.loads(served_message.content) == {"answer": "pong"}
|
||||||
|
assert logged_message.content == served_message.content
|
||||||
|
assert logged_message.provider_specific_fields == served_message.provider_specific_fields
|
||||||
|
assert logged.payload["response"]["choices"][0]["message"]["content"] == served_message.content
|
||||||
|
|
||||||
|
|
||||||
|
async def _logged_text_completion(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, caller: str
|
||||||
|
) -> tuple[TextCompletionResponse, _LoggedSuccess, Mapping[str, object]]:
|
||||||
|
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
|
||||||
|
recorder: Final = _LoggedCallTypes()
|
||||||
|
monkeypatch.setattr(litellm, "callbacks", [recorder])
|
||||||
|
response_body: Final = _searched_responses_body("openai/gpt-5.3-codex")
|
||||||
|
request: Final = {"model": "openai/gpt-5.3-codex", "prompt": "ping", "api_key": "test-key"}
|
||||||
|
|
||||||
|
with respx.mock(assert_all_called=True) as router:
|
||||||
|
router.post(url__startswith="https://api.openai.com/v1/responses").mock(
|
||||||
|
return_value=httpx.Response(200, json=response_body)
|
||||||
|
)
|
||||||
|
served: Final = (
|
||||||
|
await litellm.atext_completion(**request) if caller == "acompletion" else litellm.text_completion(**request)
|
||||||
|
)
|
||||||
|
logged: Final = await asyncio.to_thread(recorder.seen_by_caller[caller].get, timeout=10)
|
||||||
|
assert isinstance(served, TextCompletionResponse)
|
||||||
|
return served, logged, response_body
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(("caller", "call_type"), [("completion", "text_completion"), ("acompletion", "atext_completion")])
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_bridged_text_completion_is_logged_and_priced_under_the_callers_call_type(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, caller: str, call_type: str
|
||||||
|
) -> None:
|
||||||
|
served, logged, response_body = await _logged_text_completion(monkeypatch, caller)
|
||||||
|
|
||||||
|
assert (logged.call_type, logged.payload["call_type"]) == (call_type, call_type)
|
||||||
|
assert getattr(logged.response_obj, "id") == served.id == response_body["id"]
|
||||||
|
assert logged.payload["response_cost"] == litellm.completion_cost(
|
||||||
|
completion_response=ResponsesAPIResponse.model_validate(response_body), model="openai/gpt-5.3-codex"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("caller", ["completion", "acompletion"])
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_bridged_text_completion_is_logged_as_the_text_completion_it_served(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, caller: str
|
||||||
|
) -> None:
|
||||||
|
served, logged, _ = await _logged_text_completion(monkeypatch, caller)
|
||||||
|
|
||||||
|
assert isinstance(logged.response_obj, TextCompletionResponse)
|
||||||
|
assert logged.response_obj.choices[0].text == served.choices[0].text == "pong"
|
||||||
|
assert logged.payload["response"]["object"] == served.object == "text_completion"
|
||||||
|
assert logged.payload["response"]["choices"][0]["text"] == "pong"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_bridged_chat_response_waits_on_the_usage_ack_like_any_chat_response(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
from litellm.litellm_core_utils.litellm_logging import Logging
|
||||||
|
from litellm.proxy import proxy_server
|
||||||
|
from litellm.proxy.spend_tracking.usage_capture import UsageCaptureRuntime, capture_proxy_response
|
||||||
|
from litellm.proxy.spend_tracking.usage_meter import UsageCaptureSettings
|
||||||
|
from tests.unit.proxy.spend_tracking.test_usage_meter import CommitBarrier
|
||||||
|
|
||||||
|
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
|
||||||
|
database: Final = CommitBarrier()
|
||||||
|
runtime: Final = UsageCaptureRuntime()
|
||||||
|
runtime.start(database.write, UsageCaptureSettings(batch_size=2))
|
||||||
|
monkeypatch.setattr(proxy_server, "usage_capture", runtime)
|
||||||
|
monkeypatch.setattr(proxy_server, "general_settings", {"accounting_sync_ack": True})
|
||||||
|
logging_obj: Final = Logging(
|
||||||
|
model="openai/gpt-5.3-codex",
|
||||||
|
messages=[],
|
||||||
|
stream=False,
|
||||||
|
call_type="acompletion",
|
||||||
|
start_time=datetime.datetime.now(),
|
||||||
|
litellm_call_id="bridged-chat",
|
||||||
|
function_id="bridged-chat",
|
||||||
|
)
|
||||||
|
with respx.mock(assert_all_called=True) as router:
|
||||||
|
router.post(url__startswith="https://api.openai.com/v1/responses").mock(
|
||||||
|
return_value=httpx.Response(200, json=_searched_responses_body("openai/gpt-5.3-codex"))
|
||||||
|
)
|
||||||
|
served: Final = await litellm.acompletion(
|
||||||
|
model="openai/gpt-5.3-codex",
|
||||||
|
messages=[{"role": "user", "content": "ping"}],
|
||||||
|
api_key="test-key",
|
||||||
|
litellm_logging_obj=logging_obj,
|
||||||
|
)
|
||||||
|
response: Final = asyncio.create_task(capture_proxy_response(logging_obj, served))
|
||||||
|
try:
|
||||||
|
await database.entered.wait()
|
||||||
|
assert not response.done()
|
||||||
|
assert database.committed == ()
|
||||||
|
database.release.set()
|
||||||
|
await response
|
||||||
|
assert "bridged-chat:inbound" in database.committed
|
||||||
|
finally:
|
||||||
|
database.release.set()
|
||||||
|
await runtime.stop(1)
|
||||||
|
await response
|
||||||
|
|
||||||
|
|
||||||
|
def _served_message_start_id(events: Sequence[bytes]) -> object:
|
||||||
|
lines: Final = b"".join(events).decode().splitlines()
|
||||||
|
starts: Final = tuple(
|
||||||
|
json.loads(line.removeprefix("data:")) for line in lines if line.startswith("data:") and '"message_start"' in line
|
||||||
|
)
|
||||||
|
assert len(starts) == 1
|
||||||
|
return starts[0]["message"]["id"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("stream", [False, True], ids=["non-stream", "stream"])
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_bridged_messages_call_is_logged_as_anthropic_messages_under_the_id_the_client_got(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, stream: bool
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
|
||||||
|
monkeypatch.setattr(litellm, "use_chat_completions_url_for_anthropic_messages", True)
|
||||||
|
recorder: Final = _LoggedCallTypes()
|
||||||
|
monkeypatch.setattr(litellm, "callbacks", [recorder])
|
||||||
|
response_body: Final = {**_searched_responses_body("openai/gpt-5.3-codex"), "output": [_PONG_MESSAGE]}
|
||||||
|
upstream: Final = (
|
||||||
|
httpx.Response(200, text=_responses_sse(response_body), headers={"content-type": "text/event-stream"})
|
||||||
|
if stream
|
||||||
|
else httpx.Response(200, json=response_body)
|
||||||
|
)
|
||||||
|
|
||||||
|
with respx.mock(assert_all_called=True) as router:
|
||||||
|
router.post(url__startswith="https://api.openai.com/v1/responses").mock(return_value=upstream)
|
||||||
|
served: Final = await litellm.anthropic.messages.acreate(
|
||||||
|
model="openai/gpt-5.3-codex",
|
||||||
|
max_tokens=64,
|
||||||
|
messages=[{"role": "user", "content": "ping"}],
|
||||||
|
api_key="test-key",
|
||||||
|
stream=stream,
|
||||||
|
)
|
||||||
|
served_id: Final = (
|
||||||
|
_served_message_start_id([event async for event in cast(AsyncIterator[bytes], served)])
|
||||||
|
if stream
|
||||||
|
else cast(Mapping[str, object], served)["id"]
|
||||||
|
)
|
||||||
|
logged: Final = await asyncio.to_thread(recorder.seen_by_caller["acompletion"].get, timeout=10)
|
||||||
|
|
||||||
|
assert (logged.call_type, logged.payload["call_type"]) == ("anthropic_messages", "anthropic_messages")
|
||||||
|
assert logged.payload["id"] == served_id
|
||||||
|
assert logged.payload["response"]["choices"][0]["message"]["content"] == "pong"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("caller", ["completion", "acompletion"])
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_bridged_chat_stream_keeps_the_upstream_headers_when_its_output_cannot_be_translated(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, caller: str
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
|
||||||
|
recorder: Final = _LoggedCallTypes()
|
||||||
|
monkeypatch.setattr(litellm, "callbacks", [recorder])
|
||||||
|
response_body: Final = {**_searched_responses_body("openai/gpt-5.3-codex"), "output": []}
|
||||||
|
request: Final = _bridged_request("openai/gpt-5.3-codex", {}, stream=True)
|
||||||
|
|
||||||
|
with respx.mock(assert_all_called=True) as router:
|
||||||
|
router.post(url__startswith="https://api.openai.com/v1/responses").mock(
|
||||||
|
return_value=httpx.Response(
|
||||||
|
200,
|
||||||
|
text=_responses_sse(response_body),
|
||||||
|
headers={"content-type": "text/event-stream", "x-request-id": "req_upstream"},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await _served_chunk_ids(caller, request)
|
||||||
|
logged: Final = await asyncio.to_thread(recorder.seen_by_caller[caller].get, timeout=10)
|
||||||
|
|
||||||
|
assert isinstance(logged.complete_streaming_response, ModelResponse)
|
||||||
|
assert logged.complete_streaming_response.id == response_body["id"]
|
||||||
|
assert logged.payload["hidden_params"]["additional_headers"]["llm_provider-x-request-id"] == "req_upstream"
|
||||||
|
|
||||||
|
|
||||||
def test_convert_chat_completion_messages_to_responses_api_image_input():
|
def test_convert_chat_completion_messages_to_responses_api_image_input():
|
||||||
from litellm.completion_extras.litellm_responses_transformation.transformation import (
|
from litellm.completion_extras.litellm_responses_transformation.transformation import (
|
||||||
LiteLLMResponsesTransformationHandler,
|
LiteLLMResponsesTransformationHandler,
|
||||||
|
|
|
||||||
|
|
@ -112,6 +112,57 @@ def test_redaction_reuses_message_rules_for_request_body_and_provider_fields(sha
|
||||||
assert result == original_result and params == original_params
|
assert result == original_result and params == original_params
|
||||||
|
|
||||||
|
|
||||||
|
def _reasoning_items() -> list[dict[str, object]]:
|
||||||
|
return [{"type": "reasoning", "id": "rs_redaction", "summary": [{"type": "summary_text", "text": SECRET}]}]
|
||||||
|
|
||||||
|
|
||||||
|
def _message_response() -> ModelResponse:
|
||||||
|
return ModelResponse(
|
||||||
|
choices=[{"index": 0, "message": {"role": "assistant", "content": "answer", "reasoning_items": _reasoning_items()}}]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _delta_choices() -> dict[str, object]:
|
||||||
|
return {"choices": [{"index": 0, "delta": {"content": "answer", "reasoning_items": _reasoning_items()}}]}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("details", "result"),
|
||||||
|
[
|
||||||
|
pytest.param({}, _message_response(), id="message_object"),
|
||||||
|
pytest.param({}, _message_response().model_dump(), id="message_dict"),
|
||||||
|
pytest.param({}, _delta_choices(), id="delta_dict"),
|
||||||
|
pytest.param(
|
||||||
|
{"stream": True, "complete_streaming_response": ModelResponseStream(**_delta_choices())},
|
||||||
|
None,
|
||||||
|
id="stream_chunk",
|
||||||
|
),
|
||||||
|
pytest.param({"standard_logging_object": {"response": _message_response().model_dump()}}, None, id="logged"),
|
||||||
|
pytest.param({"standard_logging_object": {"response": _delta_choices()}}, None, id="logged_delta"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_redaction_hides_reasoning_item_summaries(details: dict[str, object], result: object) -> None:
|
||||||
|
assert SECRET in str(details) + str(result)
|
||||||
|
|
||||||
|
redacted: Final = perform_redaction(details, result)
|
||||||
|
|
||||||
|
assert SECRET not in str(redacted) and SECRET not in str(details), (redacted, details)
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_opted_out_callback_gets_a_text_completion_with_its_answer_redacted() -> None:
|
||||||
|
logprobs: Final = {"tokens": [SECRET], "token_logprobs": [-0.1], "text_offset": [0], "top_logprobs": [{SECRET: -0.1}]}
|
||||||
|
response: Final = TextCompletionResponse(
|
||||||
|
choices=[{"index": 0, "text": SECRET, "logprobs": logprobs, "finish_reason": "stop"}]
|
||||||
|
)
|
||||||
|
|
||||||
|
redacted: Final = redacted_response_obj_for_custom_logger(response, CustomLogger(message_logging=False))
|
||||||
|
|
||||||
|
assert isinstance(redacted, TextCompletionResponse)
|
||||||
|
assert redacted.choices[0].text == "redacted-by-litellm"
|
||||||
|
assert SECRET not in redacted.model_dump_json()
|
||||||
|
assert response.choices[0].text == SECRET, "the caller's own response is not redacted"
|
||||||
|
|
||||||
|
|
||||||
def test_redaction_redacts_reasoning_copied_into_hidden_params_in_place() -> None:
|
def test_redaction_redacts_reasoning_copied_into_hidden_params_in_place() -> None:
|
||||||
hidden_params: Final = {"model_id": "deployment", "provider_specific_fields": _provider_fields()}
|
hidden_params: Final = {"model_id": "deployment", "provider_specific_fields": _provider_fields()}
|
||||||
original_hidden_params: Final = copy.deepcopy(hidden_params)
|
original_hidden_params: Final = copy.deepcopy(hidden_params)
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue