diff --git a/litellm/completion_extras/litellm_responses_transformation/handler.py b/litellm/completion_extras/litellm_responses_transformation/handler.py index 76d17ea9693..f6879c313de 100644 --- a/litellm/completion_extras/litellm_responses_transformation/handler.py +++ b/litellm/completion_extras/litellm_responses_transformation/handler.py @@ -11,7 +11,7 @@ from typing import ( 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 @@ -20,15 +20,15 @@ if TYPE_CHECKING: class ResponsesToCompletionBridgeHandlerInputKwargs(TypedDict): - model: str - messages: list - optional_params: dict - litellm_params: dict - headers: dict - model_response: "ModelResponse" - logging_obj: "LiteLLMLoggingObj" - custom_llm_provider: str - encoding: object + model: ReadOnly[str] + messages: ReadOnly[list] + optional_params: ReadOnly[dict[str, object]] + litellm_params: ReadOnly[dict[str, object]] + headers: ReadOnly[dict] + model_response: ReadOnly["ModelResponse"] + logging_obj: ReadOnly["LiteLLMLoggingObj"] + custom_llm_provider: ReadOnly[str] + encoding: ReadOnly[object] 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"] if kwargs.get("stream") is True and "stream" not in optional_params: optional_params = {**optional_params, "stream": True} + logging_obj.record_bridged_chat_request(optional_params, litellm_params) request_data: Final = self.transformation_handler.transform_request( model=model, @@ -285,6 +286,7 @@ class ResponsesToCompletionBridgeHandler: custom_llm_provider: Final = validated_kwargs["custom_llm_provider"] if kwargs.get("stream") is True and "stream" not in optional_params: optional_params = {**optional_params, "stream": True} + logging_obj.record_bridged_chat_request(optional_params, litellm_params) try: request_data: Final = self.transformation_handler.transform_request( diff --git a/litellm/completion_extras/litellm_responses_transformation/transformation.py b/litellm/completion_extras/litellm_responses_transformation/transformation.py index c03419436d6..8dd4d87ae13 100644 --- a/litellm/completion_extras/litellm_responses_transformation/transformation.py +++ b/litellm/completion_extras/litellm_responses_transformation/transformation.py @@ -1235,10 +1235,6 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge): 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) request_data: Final = { diff --git a/litellm/litellm_core_utils/litellm_logging.py b/litellm/litellm_core_utils/litellm_logging.py index 22c5f051d5c..97c019a2180 100644 --- a/litellm/litellm_core_utils/litellm_logging.py +++ b/litellm/litellm_core_utils/litellm_logging.py @@ -19,7 +19,7 @@ from datetime import datetime as dt_object from enum import Enum from functools import lru_cache 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 openai import AsyncStream as OpenAIAsyncStream @@ -99,6 +99,7 @@ from litellm.litellm_core_utils.hidden_params import ( get_hidden_params, served_from_cache, set_hidden_param, + set_hidden_params, ) from litellm.litellm_core_utils.internal_call_metadata import ( MODEL_ACCESS_GROUP_METADATA_KEY, @@ -124,6 +125,7 @@ from litellm.litellm_core_utils.llm_cost_calc.zero_cost_diagnostic import ( diagnose_zero_cost, 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 ( truncate_base64_in_messages, 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.llms.base_llm.ocr.transformation import OCRResponse 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.containers.main import ContainerObject from litellm.types.decisions import DecisionsResponse, OpenAIDecisionResponse @@ -302,6 +304,11 @@ class _EnterpriseAlertingLoggers(NamedTuple): smtp_email: type[CustomLogger] +class BridgedChatRequest(NamedTuple): + optional_params: Mapping[str, object] + litellm_params: Mapping[str, object] + + @functools.cache def _enterprise_alerting_loggers() -> _EnterpriseAlertingLoggers: try: @@ -582,6 +589,7 @@ _LITELLM_PARAMS: Final = TypeAdapter(dict[str, object]) _SERVED_ANTHROPIC_BODY: Final = TypeAdapter(dict[str, object]) _GOOGLE_USAGE_FIELDS: Final = frozenset(UsageMetadata.__annotations__) _REDACTED_RESULT: Final = TypeAdapter(object) +_LoggedResponse: Final = TypeVar("_LoggedResponse") _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 self.client_facing_stream_model: 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.llm_caching_handler: LLMCachingHandler | None = None @@ -3235,6 +3244,40 @@ class Logging(LiteLLMLoggingBaseClass): return response 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( self, response: ModelResponse | TextCompletionResponse | ResponsesAPIResponse | InteractionsAPIResponse, @@ -3316,6 +3359,8 @@ class Logging(LiteLLMLoggingBaseClass): ) 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) 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._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.capture_usage_before_logging(logged_streaming_response, start_time, end_time) ## STANDARDIZED LOGGING PAYLOAD @@ -4268,7 +4315,9 @@ class Logging(LiteLLMLoggingBaseClass): 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 ## STANDARDIZED LOGGING PAYLOAD @@ -5227,6 +5276,13 @@ class Logging(LiteLLMLoggingBaseClass): row is built from this response rather than re-parsed from the served body.""" 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: self.record_bridged_model_response(self._translate_responses_api_response_to_model_response(response)) @@ -5298,6 +5354,9 @@ class Logging(LiteLLMLoggingBaseClass): ): result = result.response 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) raw_body: Final[object] = result @@ -5349,7 +5408,9 @@ class Logging(LiteLLMLoggingBaseClass): set_response_cost_in_hidden_params(result, reported_cost) 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. @@ -5372,8 +5433,8 @@ class Logging(LiteLLMLoggingBaseClass): logging_obj=self, request_data={}, messages=[], - optional_params={}, - litellm_params={}, + optional_params={} if request is None else dict(request.optional_params), + litellm_params={} if request is None else dict(request.litellm_params), encoding=litellm.encoding, ) translated.id = served_id or translated.id diff --git a/litellm/litellm_core_utils/redact_messages.py b/litellm/litellm_core_utils/redact_messages.py index 078ec18e2db..9da0e35bdda 100644 --- a/litellm/litellm_core_utils/redact_messages.py +++ b/litellm/litellm_core_utils/redact_messages.py @@ -10,7 +10,7 @@ import asyncio import copy import inspect -from collections.abc import Mapping, Sequence +from collections.abc import Iterable, Mapping, Sequence from dataclasses import replace 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: 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)) @@ -208,6 +214,7 @@ def _redact_choice_content(choice): choice.message.reasoning_content = REDACTED_BY_LITELLM if hasattr(choice.message, "thinking_blocks"): choice.message.thinking_blocks = None + _drop_field_when_present(choice.message, "reasoning_items") _redact_provider_specific_fields(choice.message) _redact_tool_calls(getattr(choice.message, "tool_calls", 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 if hasattr(choice.delta, "thinking_blocks"): choice.delta.thinking_blocks = None + _drop_field_when_present(choice.delta, "reasoning_items") _redact_provider_specific_fields(choice.delta) _redact_tool_calls(getattr(choice.delta, "tool_calls", None)) _redact_function_call(getattr(choice.delta, "function_call", None)) elif isinstance(choice, litellm.utils.TextChoices): _redact_field_when_present(choice, "text") _redact_field_when_present(choice, "reasoning_content") + _drop_field_when_present(choice, "logprobs") 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) +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): """Helper to redact ResponsesAPIResponse 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 -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: if isinstance(choice, dict): _redact_provider_specific_fields_dict(choice) if choice.get("token_ids") is not None: choice["token_ids"] = None - if "message" in choice and isinstance(choice["message"], dict): - if choice["message"].get("content") is not None: - choice["message"]["content"] = redacted_str - if choice["message"].get("reasoning_content") is not None: - choice["message"]["reasoning_content"] = redacted_str - if "thinking_blocks" in choice["message"]: - choice["message"]["thinking_blocks"] = None - if "audio" in choice["message"]: - choice["message"]["audio"] = None - _redact_tool_calls_dict(choice["message"]) - 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"]) + if ( + body := next((choice[key] for key in ("message", "delta") if isinstance(choice.get(key), dict)), None) + ) is not None: + for field in ("content", "reasoning_content"): + if body.get(field) is not None: + body[field] = redacted_str + for field in ("thinking_blocks", "reasoning_items", "audio"): + if field in body: + body[field] = None + _redact_tool_calls_dict(body) elif choice.get("text") is not None: choice["text"] = redacted_str else: @@ -588,7 +592,7 @@ def perform_redaction(model_call_details: dict, result, redact_streaming_respons return {"text": REDACTED_BY_LITELLM} _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: for choice in _result.choices: _redact_choice_content(choice) diff --git a/tests/integration/providers/_mantle_gpt_prompt_cache_support.py b/tests/integration/providers/_mantle_gpt_prompt_cache_support.py index 8f497fea74f..96261bab8b2 100644 --- a/tests/integration/providers/_mantle_gpt_prompt_cache_support.py +++ b/tests/integration/providers/_mantle_gpt_prompt_cache_support.py @@ -475,10 +475,6 @@ def row_key(request_id: str) -> str: 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]: usage: Final = payload.get("usage") 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_wire(received, expected_wire(GPT, prompt_text(marker), endpoint=endpoint, marked=True), streaming=stream) 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) - 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: assert math.isclose(float(outcome.headers["x-litellm-response-cost"]), expected_spend(GPT), rel_tol=1e-9), ( 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 ) 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]] = { diff --git a/tests/integration/providers/test_bedrock_mantle_claude_chat_wire.py b/tests/integration/providers/test_bedrock_mantle_claude_chat_wire.py index 32b6c4f1690..3652e49fe8a 100644 --- a/tests/integration/providers/test_bedrock_mantle_claude_chat_wire.py +++ b/tests/integration/providers/test_bedrock_mantle_claude_chat_wire.py @@ -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) assert float(response.headers["x-litellm-response-cost"]) == pytest.approx(cost), response.text _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)] diff --git a/tests/integration/providers/test_openai_chat_wire.py b/tests/integration/providers/test_openai_chat_wire.py index 69295485380..5ba631ac776 100644 --- a/tests/integration/providers/test_openai_chat_wire.py +++ b/tests/integration/providers/test_openai_chat_wire.py @@ -1,4 +1,3 @@ -import base64 import json import uuid 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, seconds=70, ) - by_response_id: Final = { - (decoded := base64.b64decode(string_value(row["request_id"]).removeprefix("resp_")).decode()) - .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 sorted(string_value(row["request_id"]) for row in rows) == sorted(response_ids), rows + for row in rows: assert ( string_value(row["status"]), 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, ) priced, cached = rows - priced_request: Final = base64.b64decode( - 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 string_value(priced["request_id"]) == "resp_cached", rows assert ( priced["status"], priced["cache_hit"], diff --git a/tests/integration/spend/test_responses_bridge_spend_once.py b/tests/integration/spend/test_responses_bridge_spend_once.py index 9eddd832ca0..cc2a0af58a7 100644 --- a/tests/integration/spend/test_responses_bridge_spend_once.py +++ b/tests/integration/spend/test_responses_bridge_spend_once.py @@ -1,5 +1,4 @@ import json -from base64 import b64decode from collections.abc import Callable from hashlib import sha256 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] assert len(call_rows) == 1, 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 logged_id.startswith("litellm:") and f";response_id:{upstream_id}" in logged_id, rows + assert call_rows[0]["request_id"] == upstream_id, rows key_spend: Final = eventually( lambda: read_rows('SELECT spend FROM "LiteLLM_VerificationToken" WHERE token=%s', (digest,)), diff --git a/tests/unit/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_transformation.py b/tests/unit/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_transformation.py index 9313e3d7039..2d02468fd8c 100644 --- a/tests/unit/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_transformation.py +++ b/tests/unit/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_transformation.py @@ -1,9 +1,13 @@ +import asyncio import copy import datetime import json import os +import queue 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 unittest.mock import ANY, MagicMock, Mock, patch @@ -24,15 +28,15 @@ import litellm from litellm.completion_extras.litellm_responses_transformation.transformation import ( 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: from openai.types.responses import ResponseOutputItem 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: 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} +@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(): from litellm.completion_extras.litellm_responses_transformation.transformation import ( LiteLLMResponsesTransformationHandler, diff --git a/tests/unit/litellm_core_utils/test_redact_messages.py b/tests/unit/litellm_core_utils/test_redact_messages.py index 0aee61d8365..2fd3f16ab17 100644 --- a/tests/unit/litellm_core_utils/test_redact_messages.py +++ b/tests/unit/litellm_core_utils/test_redact_messages.py @@ -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 +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: hidden_params: Final = {"model_id": "deployment", "provider_specific_fields": _provider_fields()} original_hidden_params: Final = copy.deepcopy(hidden_params)