mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
fix(anthropic): preserve prompt cache tokens in messages to responses api usage
Also map gpt-5.6 flex/priority cache_creation rates into ModelInfo so cache writes are not billed at the standard rate on those service tiers
This commit is contained in:
parent
daf22ec871
commit
ed366aafbe
8 changed files with 147 additions and 38 deletions
|
|
@ -7,6 +7,9 @@ from typing import Any, AsyncIterator, Dict
|
|||
|
||||
from litellm import verbose_logger
|
||||
from litellm._uuid import uuid
|
||||
from litellm.types.llms.anthropic_messages.anthropic_response import AnthropicUsage
|
||||
|
||||
from .transformation import LiteLLMAnthropicToResponsesAPIAdapter
|
||||
|
||||
|
||||
class AnthropicResponsesStreamWrapper:
|
||||
|
|
@ -226,24 +229,17 @@ class AnthropicResponsesStreamWrapper:
|
|||
event.get("response") if isinstance(event, dict) else None
|
||||
)
|
||||
stop_reason = "end_turn"
|
||||
input_tokens = 0
|
||||
output_tokens = 0
|
||||
cache_creation_tokens = 0
|
||||
cache_read_tokens = 0
|
||||
anthropic_usage: AnthropicUsage = AnthropicUsage(input_tokens=0, output_tokens=0)
|
||||
|
||||
if response_obj is not None:
|
||||
status = getattr(response_obj, "status", None)
|
||||
if status == "incomplete":
|
||||
stop_reason = "max_tokens"
|
||||
usage = getattr(response_obj, "usage", None)
|
||||
if usage is not None:
|
||||
input_tokens = getattr(usage, "input_tokens", 0) or 0
|
||||
output_tokens = getattr(usage, "output_tokens", 0) or 0
|
||||
cache_creation_tokens = getattr(usage, "input_tokens_details", None) # type: ignore[assignment]
|
||||
cache_read_tokens = getattr(usage, "output_tokens_details", None) # type: ignore[assignment]
|
||||
# Prefer direct cache fields if present
|
||||
cache_creation_tokens = int(getattr(usage, "cache_creation_input_tokens", 0) or 0)
|
||||
cache_read_tokens = int(getattr(usage, "cache_read_input_tokens", 0) or 0)
|
||||
anthropic_usage = (
|
||||
LiteLLMAnthropicToResponsesAPIAdapter.translate_responses_api_usage_to_anthropic_usage(
|
||||
getattr(response_obj, "usage", None)
|
||||
)
|
||||
)
|
||||
|
||||
# Check if tool_use was in the output to override stop_reason
|
||||
if response_obj is not None:
|
||||
|
|
@ -256,20 +252,11 @@ class AnthropicResponsesStreamWrapper:
|
|||
stop_reason = "tool_use"
|
||||
break
|
||||
|
||||
usage_delta: Dict[str, Any] = {
|
||||
"input_tokens": input_tokens,
|
||||
"output_tokens": output_tokens,
|
||||
}
|
||||
if cache_creation_tokens:
|
||||
usage_delta["cache_creation_input_tokens"] = cache_creation_tokens
|
||||
if cache_read_tokens:
|
||||
usage_delta["cache_read_input_tokens"] = cache_read_tokens
|
||||
|
||||
self._chunk_queue.append(
|
||||
{
|
||||
"type": "message_delta",
|
||||
"delta": {"stop_reason": stop_reason, "stop_sequence": None},
|
||||
"usage": usage_delta,
|
||||
"usage": dict(anthropic_usage),
|
||||
}
|
||||
)
|
||||
self._chunk_queue.append({"type": "message_stop"})
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ from litellm.types.llms.anthropic_messages.anthropic_response import (
|
|||
AnthropicMessagesResponse,
|
||||
AnthropicUsage,
|
||||
)
|
||||
from litellm.types.llms.openai import ResponsesAPIResponse
|
||||
from litellm.types.llms.openai import ResponseAPIUsage, ResponsesAPIResponse
|
||||
|
||||
|
||||
class LiteLLMAnthropicToResponsesAPIAdapter:
|
||||
|
|
@ -38,6 +38,24 @@ class LiteLLMAnthropicToResponsesAPIAdapter:
|
|||
converts Responses API responses back to Anthropic format.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def translate_responses_api_usage_to_anthropic_usage(
|
||||
raw_usage: Optional[ResponseAPIUsage],
|
||||
) -> AnthropicUsage:
|
||||
"""Map Responses API usage onto Anthropic usage, where ``input_tokens``
|
||||
excludes the cache-read and cache-write tokens reported alongside it.
|
||||
"""
|
||||
if raw_usage is None:
|
||||
return AnthropicUsage(input_tokens=0, output_tokens=0)
|
||||
|
||||
from litellm.llms.anthropic.experimental_pass_through.adapters.transformation import (
|
||||
LiteLLMAnthropicMessagesAdapter,
|
||||
)
|
||||
from litellm.responses.utils import ResponseAPILoggingUtils
|
||||
|
||||
chat_usage = ResponseAPILoggingUtils._transform_response_api_usage_to_chat_usage(raw_usage)
|
||||
return LiteLLMAnthropicMessagesAdapter._translate_openai_usage_to_anthropic_usage(chat_usage)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Request translation: Anthropic -> Responses API #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
|
@ -396,8 +414,6 @@ class LiteLLMAnthropicToResponsesAPIAdapter:
|
|||
ResponseReasoningItem,
|
||||
)
|
||||
|
||||
from litellm.types.llms.openai import ResponseAPIUsage
|
||||
|
||||
content: List[Dict[str, Any]] = []
|
||||
stop_reason: AnthropicFinishReason = "end_turn"
|
||||
|
||||
|
|
@ -463,15 +479,7 @@ class LiteLLMAnthropicToResponsesAPIAdapter:
|
|||
if response.status == "incomplete":
|
||||
stop_reason = "max_tokens"
|
||||
|
||||
# usage
|
||||
raw_usage: Optional[ResponseAPIUsage] = response.usage
|
||||
input_tokens = int(getattr(raw_usage, "input_tokens", 0) or 0)
|
||||
output_tokens = int(getattr(raw_usage, "output_tokens", 0) or 0)
|
||||
|
||||
anthropic_usage = AnthropicUsage(
|
||||
input_tokens=input_tokens,
|
||||
output_tokens=output_tokens,
|
||||
)
|
||||
anthropic_usage = self.translate_responses_api_usage_to_anthropic_usage(response.usage)
|
||||
|
||||
return AnthropicMessagesResponse(
|
||||
id=response.id,
|
||||
|
|
|
|||
|
|
@ -198,6 +198,8 @@ class ModelInfoBase(ProviderSpecificModelInfo, total=False):
|
|||
input_cost_per_token_flex: Optional[float] # OpenAI flex service tier pricing
|
||||
input_cost_per_token_priority: Optional[float] # OpenAI priority service tier pricing
|
||||
cache_creation_input_token_cost: Optional[float]
|
||||
cache_creation_input_token_cost_flex: Optional[float] # OpenAI flex service tier pricing
|
||||
cache_creation_input_token_cost_priority: Optional[float] # OpenAI priority service tier pricing
|
||||
cache_creation_input_token_cost_above_200k_tokens: Optional[float]
|
||||
cache_creation_input_token_cost_above_1hr: Optional[float]
|
||||
cache_read_input_token_cost: Optional[float]
|
||||
|
|
@ -3087,6 +3089,8 @@ class CustomPricingLiteLLMParams(BaseModel):
|
|||
input_cost_per_token_flex: Optional[float] = None
|
||||
input_cost_per_token_priority: Optional[float] = None
|
||||
cache_creation_input_token_cost: Optional[float] = None
|
||||
cache_creation_input_token_cost_flex: Optional[float] = None
|
||||
cache_creation_input_token_cost_priority: Optional[float] = None
|
||||
cache_creation_input_token_cost_above_1hr: Optional[float] = None
|
||||
cache_creation_input_token_cost_above_200k_tokens: Optional[float] = None
|
||||
cache_creation_input_audio_token_cost: Optional[float] = None
|
||||
|
|
|
|||
|
|
@ -5407,6 +5407,10 @@ def _get_model_info_helper(
|
|||
input_cost_per_token_flex=_model_info.get("input_cost_per_token_flex", None),
|
||||
input_cost_per_token_priority=_model_info.get("input_cost_per_token_priority", None),
|
||||
cache_creation_input_token_cost=_model_info.get("cache_creation_input_token_cost", None),
|
||||
cache_creation_input_token_cost_flex=_model_info.get("cache_creation_input_token_cost_flex", None),
|
||||
cache_creation_input_token_cost_priority=_model_info.get(
|
||||
"cache_creation_input_token_cost_priority", None
|
||||
),
|
||||
cache_creation_input_token_cost_above_200k_tokens=_model_info.get(
|
||||
"cache_creation_input_token_cost_above_200k_tokens", None
|
||||
),
|
||||
|
|
|
|||
|
|
@ -2399,3 +2399,44 @@ def test_generic_cost_per_token_gemini_35_flash_lite():
|
|||
)
|
||||
assert prompt_cost == pytest.approx(0.0003)
|
||||
assert completion_cost == pytest.approx(0.00125)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"service_tier,input_rate,cache_read_rate,cache_write_rate,output_rate",
|
||||
[
|
||||
("flex", 2.5e-6, 2.5e-7, 3.125e-6, 1.5e-5),
|
||||
("priority", 1e-5, 1e-6, 1.25e-5, 6e-5),
|
||||
],
|
||||
)
|
||||
def test_service_tier_cache_creation_rates_for_gpt_5_6(
|
||||
_local_model_cost_map,
|
||||
service_tier,
|
||||
input_rate,
|
||||
cache_read_rate,
|
||||
cache_write_rate,
|
||||
output_rate,
|
||||
):
|
||||
"""Regression: gpt-5.6 publishes cache_creation_input_token_cost_flex/_priority, so a
|
||||
flex or priority request must bill cache writes at that tier's rate instead of falling
|
||||
back to the standard 6.25e-6 rate."""
|
||||
usage = Usage(
|
||||
prompt_tokens=10_000,
|
||||
completion_tokens=500,
|
||||
total_tokens=10_500,
|
||||
prompt_tokens_details=PromptTokensDetailsWrapper(
|
||||
cached_tokens=6_000,
|
||||
cache_write_tokens=3_000,
|
||||
text_tokens=1_000,
|
||||
),
|
||||
)
|
||||
|
||||
prompt_cost, completion_cost = generic_cost_per_token(
|
||||
model="gpt-5.6-sol",
|
||||
usage=usage,
|
||||
custom_llm_provider="openai",
|
||||
service_tier=service_tier,
|
||||
)
|
||||
|
||||
expected_prompt = 1_000 * input_rate + 6_000 * cache_read_rate + 3_000 * cache_write_rate
|
||||
assert prompt_cost == pytest.approx(expected_prompt, rel=1e-9)
|
||||
assert completion_cost == pytest.approx(500 * output_rate, rel=1e-9)
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ Tests for AnthropicResponsesStreamWrapper
|
|||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
from types import SimpleNamespace
|
||||
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../../../../..")))
|
||||
|
||||
|
|
@ -130,3 +131,31 @@ class TestProcessEventTextDeltaWithoutOutputItemAdded:
|
|||
("content_block_start", 0),
|
||||
("content_block_delta", 0),
|
||||
]
|
||||
|
||||
|
||||
class TestResponseCompletedUsage:
|
||||
"""The Anthropic ``message_delta`` usage must report cache reads/writes and
|
||||
exclude them from ``input_tokens``, so spend is not billed at the uncached
|
||||
input rate."""
|
||||
|
||||
def test_response_completed_usage_carries_cache_tokens(self):
|
||||
from litellm.types.llms.openai import ResponseAPIUsage
|
||||
|
||||
response = SimpleNamespace(
|
||||
status="completed",
|
||||
output=[],
|
||||
usage=ResponseAPIUsage(
|
||||
input_tokens=4017,
|
||||
input_tokens_details={"cached_tokens": 4004, "cache_write_tokens": 10},
|
||||
output_tokens=5,
|
||||
total_tokens=4022,
|
||||
),
|
||||
)
|
||||
chunks = _process_all([{"type": "response.completed", "response": response}])
|
||||
message_delta = next(c for c in chunks if c["type"] == "message_delta")
|
||||
assert message_delta["usage"] == {
|
||||
"input_tokens": 3,
|
||||
"output_tokens": 5,
|
||||
"cache_creation_input_tokens": 10,
|
||||
"cache_read_input_tokens": 4004,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ from litellm.llms.anthropic.experimental_pass_through.responses_adapters.transfo
|
|||
LiteLLMAnthropicToResponsesAPIAdapter,
|
||||
)
|
||||
from litellm.types.llms.anthropic import AnthropicMessagesRequest
|
||||
from litellm.types.llms.openai import ResponseAPIUsage
|
||||
|
||||
|
||||
def _make_request(**overrides) -> AnthropicMessagesRequest:
|
||||
|
|
@ -823,11 +824,19 @@ def _make_mock_response(
|
|||
model: str = "gpt-4o",
|
||||
input_tokens: int = 100,
|
||||
output_tokens: int = 50,
|
||||
cached_tokens: int = 0,
|
||||
cache_write_tokens: int = 0,
|
||||
) -> MagicMock:
|
||||
"""Build a minimal mock ResponsesAPIResponse."""
|
||||
usage = MagicMock()
|
||||
usage.input_tokens = input_tokens
|
||||
usage.output_tokens = output_tokens
|
||||
usage = ResponseAPIUsage(
|
||||
input_tokens=input_tokens,
|
||||
input_tokens_details={
|
||||
"cached_tokens": cached_tokens,
|
||||
"cache_write_tokens": cache_write_tokens,
|
||||
},
|
||||
output_tokens=output_tokens,
|
||||
total_tokens=input_tokens + output_tokens,
|
||||
)
|
||||
|
||||
resp = MagicMock()
|
||||
resp.id = response_id
|
||||
|
|
@ -961,6 +970,25 @@ class TestTranslateResponse:
|
|||
assert result["usage"]["input_tokens"] == 200
|
||||
assert result["usage"]["output_tokens"] == 75
|
||||
|
||||
def test_cache_tokens_mapped_to_anthropic_usage(self):
|
||||
"""Cache reads/writes reported by the Responses API must survive the
|
||||
Anthropic mapping, and input_tokens must exclude them so spend is not
|
||||
billed at the uncached input rate."""
|
||||
response = _make_mock_response(
|
||||
output=[_make_output_message(["OK"])],
|
||||
input_tokens=4017,
|
||||
output_tokens=5,
|
||||
cached_tokens=4004,
|
||||
cache_write_tokens=10,
|
||||
)
|
||||
result: Any = _ADAPTER.translate_response(response)
|
||||
assert result["usage"] == {
|
||||
"input_tokens": 3,
|
||||
"output_tokens": 5,
|
||||
"cache_creation_input_tokens": 10,
|
||||
"cache_read_input_tokens": 4004,
|
||||
}
|
||||
|
||||
def test_model_and_id_preserved(self):
|
||||
"""Model and response ID from the Responses API are forwarded."""
|
||||
response = _make_mock_response(
|
||||
|
|
|
|||
8
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
8
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -25822,6 +25822,10 @@ export interface components {
|
|||
cache_creation_input_token_cost_above_1hr?: number | null;
|
||||
/** Cache Creation Input Token Cost Above 200K Tokens */
|
||||
cache_creation_input_token_cost_above_200k_tokens?: number | null;
|
||||
/** Cache Creation Input Token Cost Flex */
|
||||
cache_creation_input_token_cost_flex?: number | null;
|
||||
/** Cache Creation Input Token Cost Priority */
|
||||
cache_creation_input_token_cost_priority?: number | null;
|
||||
/** Cache Read Input Audio Token Cost */
|
||||
cache_read_input_audio_token_cost?: number | null;
|
||||
/** Cache Read Input Token Cost */
|
||||
|
|
@ -33912,6 +33916,10 @@ export interface components {
|
|||
cache_creation_input_token_cost_above_1hr?: number | null;
|
||||
/** Cache Creation Input Token Cost Above 200K Tokens */
|
||||
cache_creation_input_token_cost_above_200k_tokens?: number | null;
|
||||
/** Cache Creation Input Token Cost Flex */
|
||||
cache_creation_input_token_cost_flex?: number | null;
|
||||
/** Cache Creation Input Token Cost Priority */
|
||||
cache_creation_input_token_cost_priority?: number | null;
|
||||
/** Cache Read Input Audio Token Cost */
|
||||
cache_read_input_audio_token_cost?: number | null;
|
||||
/** Cache Read Input Token Cost */
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue