fix: preserve Responses prompt detail fields

This commit is contained in:
Devin AI 2026-10-21 06:05:14 +00:00
parent c443f83a79
commit 5e4a2c533c
2 changed files with 55 additions and 27 deletions

View file

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

View file

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