From 5e4a2c533c6cdda77d46239a6469413c7ec8135e Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 21 Oct 2026 06:05:14 +0000 Subject: [PATCH] fix: preserve Responses prompt detail fields --- litellm/responses/utils.py | 68 +++++++++++-------- .../test_responses_cache_cost_breakdown.py | 14 ++++ 2 files changed, 55 insertions(+), 27 deletions(-) diff --git a/litellm/responses/utils.py b/litellm/responses/utils.py index fd5bc0a067a..038741b1b2c 100644 --- a/litellm/responses/utils.py +++ b/litellm/responses/utils.py @@ -14,7 +14,6 @@ from litellm.llms.base_llm.responses.transformation import BaseResponsesAPIConfi from litellm.types.llms.openai import ( AllMessageValues, CacheWriteTokensDetails, - InputTokensDetails, OutputTokensDetails, ResponseAPIUsage, ResponseInputParam, @@ -44,20 +43,24 @@ def _output_token_detail(details: object, field: str) -> int | None: return value if isinstance(value, int) else None -def _normalize_input_tokens_details(details: object) -> InputTokensDetails: - if isinstance(details, InputTokensDetails): - return details - return InputTokensDetails.model_validate(details) +def _raw_input_tokens_details( + usage_input: Mapping[str, object] | ResponseAPIUsage, +) -> object | None: + if isinstance(usage_input, Mapping): + input_tokens_details: Final = usage_input.get("input_tokens_details") + return input_tokens_details if input_tokens_details is not None else usage_input.get("input_token_details") + return usage_input.input_tokens_details def _cache_creation_token_details( - details: CacheWriteTokensDetails | None, + details: object | None, ) -> CacheCreationTokenDetails | None: if details is None: return None + cache_write_tokens_details: Final[CacheWriteTokensDetails] = CacheWriteTokensDetails.model_validate(details) return CacheCreationTokenDetails( - ephemeral_5m_input_tokens=details.ttl_30m_tokens, - ephemeral_1h_input_tokens=details.ttl_12h_tokens, + ephemeral_5m_input_tokens=cache_write_tokens_details.ttl_30m_tokens, + ephemeral_1h_input_tokens=cache_write_tokens_details.ttl_12h_tokens, ) @@ -1246,6 +1249,7 @@ class ResponseAPILoggingUtils: return usage_input if isinstance(usage_input, dict) and not ResponseAPILoggingUtils.is_response_api_usage(usage_input): return Usage(**usage_input) + raw_input_tokens_details: Final[object | None] = _raw_input_tokens_details(usage_input) response_api_usage: ResponseAPIUsage if isinstance(usage_input, dict): usage_input = dict(usage_input) # shallow copy; avoid mutating caller @@ -1266,25 +1270,35 @@ class ResponseAPILoggingUtils: completion_tokens: Final[int] = response_api_usage.output_tokens or 0 prompt_tokens_details: PromptTokensDetailsWrapper | None = None if response_api_usage.input_tokens_details: - input_tokens_details: Final[InputTokensDetails] = _normalize_input_tokens_details( - response_api_usage.input_tokens_details - ) - cache_write_tokens_details: Final[CacheWriteTokensDetails | None] = ( - input_tokens_details.cache_write_tokens_details - ) - prompt_tokens_details = PromptTokensDetailsWrapper( - cached_tokens=input_tokens_details.cached_tokens, - audio_tokens=input_tokens_details.audio_tokens, - text_tokens=input_tokens_details.text_tokens, - image_tokens=input_tokens_details.image_tokens, - cached_tokens_details=input_tokens_details.cached_tokens_details, - video_tokens=input_tokens_details.video_tokens, - cache_write_tokens=input_tokens_details.cache_write_tokens, - cache_type=getattr(input_tokens_details, "cache_type", None), - web_search_requests=getattr(input_tokens_details, "web_search_requests", None), - google_maps_grounding_requests=getattr(input_tokens_details, "google_maps_grounding_requests", None), - cache_creation_token_details=_cache_creation_token_details(cache_write_tokens_details), - ) + if isinstance(raw_input_tokens_details, dict): + dict_input_tokens_details: Final[Mapping[str, object]] = TypeAdapter( + Mapping[str, object] + ).validate_python(raw_input_tokens_details) + prompt_tokens_details = PromptTokensDetailsWrapper( + **dict_input_tokens_details, + cache_creation_token_details=_cache_creation_token_details( + dict_input_tokens_details.get("cache_write_tokens_details") + ), + ) + else: + input_tokens_details: Final = response_api_usage.input_tokens_details + prompt_tokens_details = PromptTokensDetailsWrapper( + cached_tokens=getattr(input_tokens_details, "cached_tokens", None), + audio_tokens=getattr(input_tokens_details, "audio_tokens", None), + text_tokens=getattr(input_tokens_details, "text_tokens", None), + image_tokens=getattr(input_tokens_details, "image_tokens", None), + cached_tokens_details=getattr(input_tokens_details, "cached_tokens_details", None), + video_tokens=getattr(input_tokens_details, "video_tokens", None), + cache_write_tokens=getattr(input_tokens_details, "cache_write_tokens", None), + cache_type=getattr(input_tokens_details, "cache_type", None), + web_search_requests=getattr(input_tokens_details, "web_search_requests", None), + google_maps_grounding_requests=getattr( + input_tokens_details, "google_maps_grounding_requests", None + ), + cache_creation_token_details=_cache_creation_token_details( + getattr(input_tokens_details, "cache_write_tokens_details", None) + ), + ) completion_tokens_details: CompletionTokensDetailsWrapper | None = None output_tokens_details: Final[OutputTokensDetails | None] = getattr( response_api_usage, "output_tokens_details", None diff --git a/tests/unit/litellm_core_utils/llm_cost_calc/test_responses_cache_cost_breakdown.py b/tests/unit/litellm_core_utils/llm_cost_calc/test_responses_cache_cost_breakdown.py index 21cbf370dff..f5539962f6a 100644 --- a/tests/unit/litellm_core_utils/llm_cost_calc/test_responses_cache_cost_breakdown.py +++ b/tests/unit/litellm_core_utils/llm_cost_calc/test_responses_cache_cost_breakdown.py @@ -151,6 +151,20 @@ def test_responses_api_12h_cache_writes_use_the_12h_rate(local_model_cost_map, d assert breakdown["input_cost"] == pytest.approx(expected_input) +def test_responses_api_dict_prompt_token_details_preserve_extra_fields(): + usage = ResponseAPILoggingUtils.transform_response_api_usage_to_chat_usage( + { + "input_tokens": 12000, + "output_tokens": 200, + "total_tokens": 12200, + "input_tokens_details": {"cached_tokens": 4000, "character_count": 123}, + } + ) + + assert usage.prompt_tokens_details is not None + assert usage.prompt_tokens_details.character_count == 123 + + def test_cache_fields_stay_unset_when_there_was_no_cache_activity(local_model_cost_map): """A request that neither read nor wrote the cache leaves both break-out fields off the breakdown, so they serialize as null rather than a misleading $0."""