mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-10 22:41:41 +00:00
Merge pull request #39850 from BerriAI/litellm_fix_realtime_reasoning_double_bill
fix(cost): bill realtime reasoning tokens nested in text_tokens once
This commit is contained in:
commit
3dac0ba79b
7 changed files with 312 additions and 6 deletions
|
|
@ -26,6 +26,7 @@ from litellm.types.utils import (
|
|||
PromptTokensDetailsWrapper,
|
||||
ServiceTier,
|
||||
Usage,
|
||||
text_tokens_without_nested_reasoning,
|
||||
)
|
||||
from litellm.utils import get_model_info
|
||||
|
||||
|
|
@ -860,7 +861,7 @@ def parse_completion_tokens_details(usage: Usage) -> CompletionTokensDetailsResu
|
|||
)
|
||||
or 0
|
||||
)
|
||||
text_tokens: Final = (
|
||||
reported_text_tokens: Final = (
|
||||
cast(
|
||||
int | None,
|
||||
getattr(usage.completion_tokens_details, "text_tokens", None),
|
||||
|
|
@ -882,6 +883,12 @@ def parse_completion_tokens_details(usage: Usage) -> CompletionTokensDetailsResu
|
|||
or 0
|
||||
)
|
||||
video_tokens: Final = _coerce_token_count(getattr(usage.completion_tokens_details, "video_tokens", 0))
|
||||
text_tokens: Final = text_tokens_without_nested_reasoning(
|
||||
completion_tokens=usage.completion_tokens,
|
||||
text_tokens=reported_text_tokens,
|
||||
reasoning_tokens=reasoning_tokens,
|
||||
other_modality_tokens=audio_tokens + image_tokens + video_tokens,
|
||||
)
|
||||
|
||||
return CompletionTokensDetailsResult(
|
||||
audio_tokens=audio_tokens,
|
||||
|
|
|
|||
|
|
@ -25,9 +25,15 @@ from litellm.types.utils import (
|
|||
PromptTokensDetailsWrapper,
|
||||
SpecialEnums,
|
||||
Usage,
|
||||
text_tokens_without_nested_reasoning,
|
||||
)
|
||||
|
||||
|
||||
def _output_token_detail(details: object, field: str) -> int | None:
|
||||
value: Final = getattr(details, field, None)
|
||||
return value if isinstance(value, int) else None
|
||||
|
||||
|
||||
def _is_object_sequence(value: object) -> TypeIs[Sequence[object]]: # guard-ok: a list is a Sequence of anything
|
||||
return isinstance(value, list)
|
||||
|
||||
|
|
@ -1137,11 +1143,22 @@ class ResponseAPILoggingUtils:
|
|||
response_api_usage, "output_tokens_details", None
|
||||
)
|
||||
if output_tokens_details:
|
||||
reasoning_tokens: Final = _output_token_detail(output_tokens_details, "reasoning_tokens")
|
||||
image_tokens: Final = _output_token_detail(output_tokens_details, "image_tokens")
|
||||
audio_tokens: Final = _output_token_detail(output_tokens_details, "audio_tokens")
|
||||
reported_text_tokens: Final = _output_token_detail(output_tokens_details, "text_tokens")
|
||||
completion_tokens_details = CompletionTokensDetailsWrapper(
|
||||
reasoning_tokens=getattr(output_tokens_details, "reasoning_tokens", None),
|
||||
image_tokens=getattr(output_tokens_details, "image_tokens", None),
|
||||
text_tokens=getattr(output_tokens_details, "text_tokens", None),
|
||||
audio_tokens=getattr(output_tokens_details, "audio_tokens", None),
|
||||
reasoning_tokens=reasoning_tokens,
|
||||
image_tokens=image_tokens,
|
||||
text_tokens=None
|
||||
if reported_text_tokens is None
|
||||
else text_tokens_without_nested_reasoning(
|
||||
completion_tokens=completion_tokens,
|
||||
text_tokens=reported_text_tokens,
|
||||
reasoning_tokens=reasoning_tokens or 0,
|
||||
other_modality_tokens=(audio_tokens or 0) + (image_tokens or 0),
|
||||
),
|
||||
audio_tokens=audio_tokens,
|
||||
)
|
||||
|
||||
extra_usage_fields: Final = {
|
||||
|
|
|
|||
|
|
@ -1625,6 +1625,17 @@ class Choices(SafeAttributeModel, OpenAIObject):
|
|||
setattr(self, key, value)
|
||||
|
||||
|
||||
def text_tokens_without_nested_reasoning(
|
||||
completion_tokens: int,
|
||||
text_tokens: int,
|
||||
reasoning_tokens: int,
|
||||
other_modality_tokens: int,
|
||||
) -> int:
|
||||
reported_total: Final = text_tokens + reasoning_tokens + other_modality_tokens
|
||||
nested_reasoning_tokens: Final = min(reasoning_tokens, text_tokens, max(reported_total - completion_tokens, 0))
|
||||
return text_tokens - nested_reasoning_tokens
|
||||
|
||||
|
||||
class CompletionTokensDetailsWrapper(CompletionTokensDetails): # wrapper for older openai versions
|
||||
text_tokens: int | None = None
|
||||
"""Text tokens generated by the model."""
|
||||
|
|
|
|||
|
|
@ -4787,3 +4787,100 @@ def test_route_image_generation_cost_falls_back_to_requested_size(monkeypatch, r
|
|||
)
|
||||
|
||||
assert cost == expected_cost
|
||||
|
||||
|
||||
def test_generic_cost_per_token_bills_reasoning_nested_in_text_tokens_once(_local_model_cost_map: None) -> None:
|
||||
"""
|
||||
Realtime usage (OpenAI and Azure) reports output_tokens == text_tokens + audio_tokens with
|
||||
reasoning_tokens already counted inside text_tokens, so reasoning must not be billed on top.
|
||||
"""
|
||||
|
||||
model = "gpt-realtime-2.1-mini"
|
||||
usage = Usage(
|
||||
prompt_tokens=346,
|
||||
completion_tokens=29,
|
||||
total_tokens=375,
|
||||
prompt_tokens_details=PromptTokensDetailsWrapper(
|
||||
text_tokens=152, image_tokens=194, audio_tokens=0, cached_tokens=128
|
||||
),
|
||||
completion_tokens_details=CompletionTokensDetailsWrapper(
|
||||
text_tokens=29, audio_tokens=0, reasoning_tokens=19
|
||||
),
|
||||
)
|
||||
|
||||
prompt_cost, completion_cost = generic_cost_per_token(model=model, usage=usage, custom_llm_provider="openai")
|
||||
breakdown = get_token_type_cost_breakdown(model=model, custom_llm_provider="openai", usage=usage)
|
||||
|
||||
info = litellm.get_model_info(model=model, custom_llm_provider="openai")
|
||||
assert completion_cost == pytest.approx(29 * info["output_cost_per_token"])
|
||||
assert completion_cost - breakdown.reasoning_cost == pytest.approx(10 * info["output_cost_per_token"])
|
||||
assert prompt_cost == pytest.approx(
|
||||
24 * info["input_cost_per_token"]
|
||||
+ 128 * info["cache_read_input_token_cost"]
|
||||
+ 194 * info["input_cost_per_image_token"]
|
||||
)
|
||||
|
||||
|
||||
def test_generic_cost_per_token_keeps_billing_reasoning_reported_beside_text_tokens(
|
||||
_local_model_cost_map: None,
|
||||
) -> None:
|
||||
"""Providers whose text_tokens exclude reasoning (text + reasoning == completion) stay billed in full."""
|
||||
|
||||
model = "gpt-realtime-2.1-mini"
|
||||
usage = Usage(
|
||||
prompt_tokens=100,
|
||||
completion_tokens=44,
|
||||
total_tokens=144,
|
||||
completion_tokens_details=CompletionTokensDetailsWrapper(
|
||||
text_tokens=25, audio_tokens=0, reasoning_tokens=19
|
||||
),
|
||||
)
|
||||
|
||||
_, completion_cost = generic_cost_per_token(model=model, usage=usage, custom_llm_provider="openai")
|
||||
|
||||
info = litellm.get_model_info(model=model, custom_llm_provider="openai")
|
||||
assert completion_cost == pytest.approx(44 * info["output_cost_per_token"])
|
||||
|
||||
|
||||
def test_generic_cost_per_token_strips_only_the_reasoning_share_when_text_over_reports(
|
||||
_local_model_cost_map: None,
|
||||
) -> None:
|
||||
"""Text over-reported past the reasoning share keeps its extra tokens billed; only the nested reasoning is netted out."""
|
||||
|
||||
model = "gpt-realtime-2.1-mini"
|
||||
usage = Usage(
|
||||
prompt_tokens=120,
|
||||
completion_tokens=100,
|
||||
total_tokens=220,
|
||||
completion_tokens_details=CompletionTokensDetailsWrapper(text_tokens=100, audio_tokens=70, reasoning_tokens=10),
|
||||
)
|
||||
|
||||
_, completion_cost = generic_cost_per_token(model=model, usage=usage, custom_llm_provider="openai")
|
||||
breakdown = get_token_type_cost_breakdown(model=model, custom_llm_provider="openai", usage=usage)
|
||||
|
||||
info = litellm.get_model_info(model=model, custom_llm_provider="openai")
|
||||
assert breakdown.reasoning_cost == pytest.approx(10 * info["output_cost_per_token"])
|
||||
assert completion_cost == pytest.approx(
|
||||
100 * info["output_cost_per_token"] + 70 * info["output_cost_per_audio_token"]
|
||||
)
|
||||
|
||||
|
||||
def test_generic_cost_per_token_bills_nested_reasoning_once_beside_audio_output(_local_model_cost_map: None) -> None:
|
||||
"""Audio-output realtime usage nests reasoning inside text_tokens next to audio_tokens; text is billed net of it."""
|
||||
|
||||
model = "gpt-realtime-2.1-mini"
|
||||
usage = Usage(
|
||||
prompt_tokens=120,
|
||||
completion_tokens=100,
|
||||
total_tokens=220,
|
||||
completion_tokens_details=CompletionTokensDetailsWrapper(text_tokens=30, audio_tokens=70, reasoning_tokens=20),
|
||||
)
|
||||
|
||||
_, completion_cost = generic_cost_per_token(model=model, usage=usage, custom_llm_provider="openai")
|
||||
breakdown = get_token_type_cost_breakdown(model=model, custom_llm_provider="openai", usage=usage)
|
||||
|
||||
info = litellm.get_model_info(model=model, custom_llm_provider="openai")
|
||||
assert breakdown.reasoning_cost == pytest.approx(20 * info["output_cost_per_token"])
|
||||
assert completion_cost == pytest.approx(
|
||||
30 * info["output_cost_per_token"] + 70 * info["output_cost_per_audio_token"]
|
||||
)
|
||||
|
|
|
|||
|
|
@ -440,6 +440,56 @@ class TestResponseAPILoggingUtils:
|
|||
assert result.completion_tokens_details.text_tokens == 20
|
||||
assert result.completion_tokens_details.audio_tokens is None
|
||||
|
||||
def test_transform_realtime_usage_partitions_reasoning_out_of_text_tokens(self):
|
||||
"""Realtime nests reasoning_tokens inside text_tokens; the stored text share excludes them."""
|
||||
usage = {
|
||||
"input_tokens": 237,
|
||||
"output_tokens": 70,
|
||||
"total_tokens": 307,
|
||||
"input_token_details": {"text_tokens": 43, "audio_tokens": 0, "image_tokens": 194, "cached_tokens": 0},
|
||||
"output_token_details": {"text_tokens": 70, "audio_tokens": 0, "reasoning_tokens": 52},
|
||||
}
|
||||
|
||||
result = ResponseAPILoggingUtils._transform_response_api_usage_to_chat_usage(usage)
|
||||
|
||||
assert result.completion_tokens == 70
|
||||
assert result.completion_tokens_details is not None
|
||||
assert result.completion_tokens_details.text_tokens == 18
|
||||
assert result.completion_tokens_details.reasoning_tokens == 52
|
||||
assert result.completion_tokens_details.audio_tokens == 0
|
||||
|
||||
def test_transform_realtime_usage_partitions_reasoning_beside_audio_output(self):
|
||||
"""Audio output stays as reported; only the text share sheds the nested reasoning tokens."""
|
||||
usage = {
|
||||
"input_tokens": 100,
|
||||
"output_tokens": 70,
|
||||
"total_tokens": 170,
|
||||
"input_token_details": {"text_tokens": 100, "audio_tokens": 0, "cached_tokens": 0},
|
||||
"output_token_details": {"text_tokens": 39, "audio_tokens": 31, "reasoning_tokens": 23},
|
||||
}
|
||||
|
||||
result = ResponseAPILoggingUtils._transform_response_api_usage_to_chat_usage(usage)
|
||||
|
||||
assert result.completion_tokens_details is not None
|
||||
assert result.completion_tokens_details.text_tokens == 16
|
||||
assert result.completion_tokens_details.audio_tokens == 31
|
||||
assert result.completion_tokens_details.reasoning_tokens == 23
|
||||
|
||||
def test_transform_response_api_usage_keeps_partitioned_text_tokens(self):
|
||||
"""A provider already reporting text_tokens beside reasoning_tokens is stored as sent."""
|
||||
usage = {
|
||||
"input_tokens": 10,
|
||||
"output_tokens": 20,
|
||||
"total_tokens": 30,
|
||||
"output_tokens_details": {"text_tokens": 12, "reasoning_tokens": 5},
|
||||
}
|
||||
|
||||
result = ResponseAPILoggingUtils._transform_response_api_usage_to_chat_usage(usage)
|
||||
|
||||
assert result.completion_tokens_details is not None
|
||||
assert result.completion_tokens_details.text_tokens == 12
|
||||
assert result.completion_tokens_details.reasoning_tokens == 5
|
||||
|
||||
def test_transform_response_api_usage_carries_extra_provider_fields(self):
|
||||
"""Non-standard usage fields (e.g. xAI tool details) must survive chat normalization."""
|
||||
details = {"web_search_calls": 2, "x_search_calls": 0}
|
||||
|
|
|
|||
|
|
@ -4550,3 +4550,98 @@ def test_batch_cost_calculator_gpt_6_astra_bills_half_the_standard_rate(_local_m
|
|||
|
||||
assert prompt_cost == pytest.approx(1000 * 5e-6)
|
||||
assert completion_cost == pytest.approx(500 * 2.5e-5)
|
||||
|
||||
|
||||
def test_handle_realtime_stream_cost_calculation_bills_nested_reasoning_tokens_once(
|
||||
_local_model_cost_map: None,
|
||||
) -> None:
|
||||
"""Realtime response.done nests reasoning_tokens inside text_tokens, so they are billed once."""
|
||||
results: OpenAIRealtimeStreamList = [
|
||||
{"type": "session.created", "session": {"model": "gpt-realtime-2.1-mini"}},
|
||||
{
|
||||
"type": "response.done",
|
||||
"response": {
|
||||
"usage": {
|
||||
"total_tokens": 260,
|
||||
"input_tokens": 237,
|
||||
"output_tokens": 23,
|
||||
"input_token_details": {
|
||||
"text_tokens": 43,
|
||||
"audio_tokens": 0,
|
||||
"image_tokens": 194,
|
||||
"cached_tokens": 0,
|
||||
"cached_tokens_details": {"text_tokens": 0, "audio_tokens": 0, "image_tokens": 0},
|
||||
},
|
||||
"output_token_details": {"text_tokens": 23, "audio_tokens": 0, "reasoning_tokens": 18},
|
||||
}
|
||||
},
|
||||
},
|
||||
]
|
||||
combined_usage_object = RealtimeAPITokenUsageProcessor.collect_and_combine_usage_from_realtime_stream_results(
|
||||
results=results,
|
||||
)
|
||||
|
||||
total_cost = handle_realtime_stream_cost_calculation(
|
||||
results=results,
|
||||
combined_usage_object=combined_usage_object,
|
||||
custom_llm_provider="azure",
|
||||
litellm_model_name="azure/gpt-realtime-2.1-mini",
|
||||
)
|
||||
|
||||
info = litellm.get_model_info(model="azure/gpt-realtime-2.1-mini", custom_llm_provider="azure")
|
||||
expected = (
|
||||
43 * info["input_cost_per_token"]
|
||||
+ 194 * info["input_cost_per_image_token"]
|
||||
+ 23 * info["output_cost_per_token"]
|
||||
)
|
||||
assert total_cost == pytest.approx(expected)
|
||||
assert total_cost == pytest.approx(0.0002362)
|
||||
|
||||
|
||||
def test_collect_and_combine_realtime_usage_stores_partitioned_text_tokens() -> None:
|
||||
"""The combined usage that lands in spend logs keeps reasoning out of text_tokens for every turn."""
|
||||
results: OpenAIRealtimeStreamList = [
|
||||
{"type": "session.created", "session": {"model": "gpt-realtime-2.1-mini"}},
|
||||
{
|
||||
"type": "response.done",
|
||||
"response": {
|
||||
"usage": {
|
||||
"total_tokens": 307,
|
||||
"input_tokens": 237,
|
||||
"output_tokens": 70,
|
||||
"input_token_details": {
|
||||
"text_tokens": 43,
|
||||
"audio_tokens": 0,
|
||||
"image_tokens": 194,
|
||||
"cached_tokens": 0,
|
||||
},
|
||||
"output_token_details": {"text_tokens": 70, "audio_tokens": 0, "reasoning_tokens": 52},
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "response.done",
|
||||
"response": {
|
||||
"usage": {
|
||||
"total_tokens": 363,
|
||||
"input_tokens": 300,
|
||||
"output_tokens": 63,
|
||||
"input_token_details": {
|
||||
"text_tokens": 106,
|
||||
"audio_tokens": 0,
|
||||
"image_tokens": 194,
|
||||
"cached_tokens": 0,
|
||||
},
|
||||
"output_token_details": {"text_tokens": 63, "audio_tokens": 0, "reasoning_tokens": 43},
|
||||
}
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
combined = RealtimeAPITokenUsageProcessor.collect_and_combine_usage_from_realtime_stream_results(results=results)
|
||||
|
||||
assert combined.completion_tokens == 133
|
||||
assert combined.completion_tokens_details is not None
|
||||
assert combined.completion_tokens_details.reasoning_tokens == 95
|
||||
assert combined.completion_tokens_details.text_tokens == 38
|
||||
assert combined.completion_tokens_details.audio_tokens == 0
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ from typing import Final
|
|||
import pytest
|
||||
|
||||
|
||||
from litellm.types.utils import HiddenParams, all_litellm_params
|
||||
from litellm.types.utils import HiddenParams, all_litellm_params, text_tokens_without_nested_reasoning
|
||||
|
||||
|
||||
def test_rust_is_a_known_litellm_param():
|
||||
|
|
@ -768,3 +768,32 @@ def test_image_response_keeps_background():
|
|||
response = ImageResponse(created=1, data=[{"b64_json": "aGk="}], background="transparent", output_format="png")
|
||||
assert response.background == "transparent"
|
||||
assert response.model_dump()["background"] == "transparent"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("completion_tokens", "text_tokens", "reasoning_tokens", "other_modality_tokens", "expected_text_tokens"),
|
||||
(
|
||||
pytest.param(50, 30, 20, 0, 30, id="details_sum_to_completion_is_a_no_op"),
|
||||
pytest.param(34, 30, 24, 0, 10, id="strip_is_capped_at_the_over_sum"),
|
||||
pytest.param(100, 100, 10, 70, 90, id="only_the_reasoning_share_is_stripped_when_text_over_reports_further"),
|
||||
pytest.param(10, 5, 20, 0, 0, id="text_never_goes_negative_when_reasoning_exceeds_it"),
|
||||
),
|
||||
)
|
||||
def test_text_tokens_without_nested_reasoning_clamps(
|
||||
completion_tokens: int,
|
||||
text_tokens: int,
|
||||
reasoning_tokens: int,
|
||||
other_modality_tokens: int,
|
||||
expected_text_tokens: int,
|
||||
) -> None:
|
||||
"""The strip never exceeds the reasoning share, the reported text, or the over-sum past completion_tokens."""
|
||||
|
||||
assert (
|
||||
text_tokens_without_nested_reasoning(
|
||||
completion_tokens=completion_tokens,
|
||||
text_tokens=text_tokens,
|
||||
reasoning_tokens=reasoning_tokens,
|
||||
other_modality_tokens=other_modality_tokens,
|
||||
)
|
||||
== expected_text_tokens
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue