Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions langfuse/langchain/CallbackHandler.py
Original file line number Diff line number Diff line change
Expand Up @@ -1685,6 +1685,28 @@ def _parse_usage_model(usage: Union[pydantic.BaseModel, dict]) -> Any:
0, usage_model[f"input_modality_{item['modality']}"] - value
)

# Anthropic extended prompt caching — cache_creation is a nested dict
# keyed by cache tier, e.g. {"ephemeral_5m_input_tokens": 2048, "ephemeral_1h_input_tokens": 0}
if "cache_creation" in usage_model and isinstance(
usage_model["cache_creation"], dict
):
cache_creation = usage_model.pop("cache_creation")
total_cache_creation_tokens = 0

for key, value in cache_creation.items():
if not isinstance(value, int):
continue

usage_model[f"cache_creation_{key}"] = value

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep cache-creation buckets mutually exclusive

When Anthropic/ChatAnthropic returns both the top-level cache_creation_input_tokens and the nested cache_creation tiers, this adds per-tier cache_creation_*_input_tokens buckets while the aggregate is still retained. Langfuse stores flat usage_details verbatim and expects every non-total key to be a mutually exclusive bucket; because raw Anthropic usage has no total_tokens, the derived total/UI input usage will count cache writes once via cache_creation_input_tokens and again via the new tier keys, inflating usage and any matching inferred costs. Please emit either the aggregate or the tier buckets, not both.

Useful? React with 👍 / 👎.

total_cache_creation_tokens += value
Comment thread
hassiebp marked this conversation as resolved.

# Aggregate mirrors Anthropic's legacy cache_creation_input_tokens field;
# keep the API-provided value if it is already present
if total_cache_creation_tokens > 0:
usage_model.setdefault(
"cache_creation_input_tokens", total_cache_creation_tokens
)

usage_model = {k: v for k, v in usage_model.items() if isinstance(v, int)}

return usage_model if usage_model else None
Expand Down
54 changes: 54 additions & 0 deletions tests/unit/test_parse_usage_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,60 @@ def test_prompt_tokens_details_dict_empty():
assert result["output"] == 100


def test_anthropic_cache_creation_nested_dict():
"""Anthropic extended prompt caching: cache_creation nested dict is flattened."""
usage = {
"input_tokens": 9454,
"output_tokens": 380,
"cache_read_input_tokens": 0,
"cache_creation": {
"ephemeral_1h_input_tokens": 0,
"ephemeral_5m_input_tokens": 2048,
},
}
result = _parse_usage_model(usage)
assert result["input"] == 9454
assert result["output"] == 380
assert result["cache_creation_ephemeral_1h_input_tokens"] == 0
assert result["cache_creation_ephemeral_5m_input_tokens"] == 2048
assert result["cache_creation_input_tokens"] == 2048
# No nested dict may survive into the final usage model
assert all(isinstance(v, int) for v in result.values())


def test_anthropic_cache_creation_keeps_existing_aggregate():
"""API-provided cache_creation_input_tokens is not overwritten by the computed sum."""
usage = {
"input_tokens": 100,
"output_tokens": 10,
"cache_creation_input_tokens": 3000,
"cache_creation": {
"ephemeral_1h_input_tokens": 1000,
"ephemeral_5m_input_tokens": 2000,
},
}
result = _parse_usage_model(usage)
assert result["cache_creation_input_tokens"] == 3000
assert result["cache_creation_ephemeral_1h_input_tokens"] == 1000
assert result["cache_creation_ephemeral_5m_input_tokens"] == 2000


def test_anthropic_cache_creation_all_zero():
"""All-zero cache_creation tiers: flattened keys kept, no aggregate invented."""
usage = {
"input_tokens": 50,
"output_tokens": 5,
"cache_creation": {
"ephemeral_1h_input_tokens": 0,
"ephemeral_5m_input_tokens": 0,
},
}
result = _parse_usage_model(usage)
assert result["cache_creation_ephemeral_1h_input_tokens"] == 0
assert result["cache_creation_ephemeral_5m_input_tokens"] == 0
assert "cache_creation_input_tokens" not in result


def test_priority_tier_not_subtracted():
"""Priority tier: 'priority' and 'priority_*' keys must NOT be subtracted."""
usage = {
Expand Down
Loading