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:
zoveki 2026-11-03 02:02:54 -08:00 • committed by GitHub
parent 7bd342d7ea
commit ff182c194e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 587 additions and 76 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -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]] = {

View file

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

View file

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

View file

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

View file

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

View file

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