Skip to content

Commit 77b1058

Browse files
committed
fix(claude_agent_sdk): stop assigning query usage to the final call
ResultMessage usage is cumulative across the query, so attaching it to the final anthropic.messages.create span misattributes root and subagent tokens and produces incorrect prompt-cache costs. The caller's include_partial_messages option now determines where exact per-request completion usage comes from. Braintrust leaves this option unchanged because enabling it would expose additional public stream events. include_partial_messages=True message_start -> request ID and per-request prompt/cache usage message_delta -> final per-request completion usage result -> query metadata only include_partial_messages=False or omitted assistant -> request ID and per-request prompt/cache usage transcript -> final completion usage when an exact match exists no match -> omit completion_tokens and tokens result -> query metadata only Passive hooks keep root and subagent transcript paths separate: UserPromptSubmit --------> root transcript ------> [root, message ID] SubagentStop(tool A) ----> transcript A ---------> [tool A, message ID] SubagentStop(tool B) ----> transcript B ---------> [tool B, message ID] The agent context and message ID form one lookup key, preventing root and parallel subagent calls from sharing usage. Transcript recovery searches backward for the newest valid usage row and retries briefly for write races: assistant(id=msg_1) -----> save [context, msg_1] and span query stream ends -------> read context transcript <----- newest valid usage row update span [context, msg_1] missing row -------------> wait 25 ms and retry (maximum: 3 reads) Result usage remains query metadata only, and aggregate token metrics stay off the parent task to avoid double-counting. Caller options and yielded messages remain unchanged. Anthropic cache metrics now emit either TTL-specific or legacy cache-creation fields, never both, and prompt totals use the same representation. Sanitized subprocess cassettes cover partial, transcript, subagent, and fallback paths. Resolves #564
1 parent a2f8347 commit 77b1058

9 files changed

Lines changed: 3674 additions & 1075 deletions

py/src/braintrust/integrations/anthropic/_utils.py

Lines changed: 34 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -53,11 +53,18 @@ def _set_numeric_metric(metrics: dict[str, float], name: str, value: Any) -> Non
5353
metrics[name] = float(value)
5454

5555

56-
def extract_anthropic_usage(usage: Any) -> tuple[dict[str, float], dict[str, Any]]:
56+
def extract_anthropic_usage(
57+
usage: Any,
58+
*,
59+
include_output: bool = True,
60+
include_legacy_cache_creation: bool = True,
61+
) -> tuple[dict[str, float], dict[str, Any]]:
5762
"""Extract normalized metrics and allowlisted metadata from Anthropic usage.
5863
5964
Numeric usage fields are converted into Braintrust metrics. Allowlisted
6065
non-numeric fields are attached as span metadata with a ``usage_`` prefix.
66+
Anthropic's per-TTL cache creation breakdown supersedes the legacy aggregate
67+
metric, and totals are emitted only when completion usage is known.
6168
"""
6269
usage = _try_to_dict(usage)
6370
if usage is None:
@@ -66,6 +73,10 @@ def extract_anthropic_usage(usage: Any) -> tuple[dict[str, float], dict[str, Any
6673
metrics: dict[str, float] = {}
6774
metadata: dict[str, Any] = {}
6875
for source_name, metric_name in _ANTHROPIC_USAGE_METRIC_FIELDS:
76+
if metric_name == "completion_tokens" and not include_output:
77+
continue
78+
if metric_name == "prompt_cache_creation_tokens" and not include_legacy_cache_creation:
79+
continue
6980
_set_numeric_metric(metrics, metric_name, usage.get(source_name))
7081

7182
cache_creation = _try_to_dict(usage.get("cache_creation"))
@@ -77,22 +88,36 @@ def extract_anthropic_usage(usage: Any) -> tuple[dict[str, float], dict[str, Any
7788
metrics[metric_name] = float(value)
7889
cache_creation_breakdown.append(float(value))
7990

91+
if cache_creation_breakdown:
92+
metrics.pop("prompt_cache_creation_tokens", None)
93+
8094
server_tool_use = _try_to_dict(usage.get("server_tool_use"))
8195
if server_tool_use is not None:
8296
for source_name, value in server_tool_use.items():
8397
_set_numeric_metric(metrics, f"server_tool_use_{source_name}", value)
8498

85-
if "prompt_cache_creation_tokens" not in metrics and cache_creation_breakdown:
86-
metrics["prompt_cache_creation_tokens"] = sum(cache_creation_breakdown)
87-
88-
if metrics:
99+
has_prompt_usage = any(
100+
metric_name in metrics
101+
for metric_name in (
102+
"prompt_tokens",
103+
"prompt_cached_tokens",
104+
"prompt_cache_creation_tokens",
105+
"prompt_cache_creation_5m_tokens",
106+
"prompt_cache_creation_1h_tokens",
107+
)
108+
)
109+
if has_prompt_usage:
110+
effective_cache_creation_tokens = (
111+
sum(cache_creation_breakdown)
112+
if cache_creation_breakdown
113+
else metrics.get("prompt_cache_creation_tokens", 0)
114+
)
89115
total_prompt_tokens = (
90-
metrics.get("prompt_tokens", 0)
91-
+ metrics.get("prompt_cached_tokens", 0)
92-
+ metrics.get("prompt_cache_creation_tokens", 0)
116+
metrics.get("prompt_tokens", 0) + metrics.get("prompt_cached_tokens", 0) + effective_cache_creation_tokens
93117
)
94118
metrics["prompt_tokens"] = total_prompt_tokens
95-
metrics["tokens"] = total_prompt_tokens + metrics.get("completion_tokens", 0)
119+
if "completion_tokens" in metrics:
120+
metrics["tokens"] = total_prompt_tokens + metrics["completion_tokens"]
96121

97122
for name, value in usage.items():
98123
if name in _ANTHROPIC_USAGE_METADATA_FIELDS and value is not None:

py/src/braintrust/integrations/anthropic/test_anthropic.py

Lines changed: 32 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -376,7 +376,6 @@ def to_dict(self):
376376
"prompt_tokens": 21.0,
377377
"completion_tokens": 7.0,
378378
"prompt_cached_tokens": 3.0,
379-
"prompt_cache_creation_tokens": 7.0,
380379
"prompt_cache_creation_5m_tokens": 2.0,
381380
"prompt_cache_creation_1h_tokens": 5.0,
382381
"server_tool_use_web_search_requests": 2.0,
@@ -410,7 +409,7 @@ def test_anthropic_messages_create_prompt_cache_5m_metrics(memory_logger):
410409

411410
span = find_span_by_name(memory_logger.pop(), "anthropic.messages.create")
412411
assert span["output"]["role"] == response.role
413-
assert span["metrics"]["prompt_cache_creation_tokens"] == response.usage.cache_creation_input_tokens
412+
assert "prompt_cache_creation_tokens" not in span["metrics"]
414413
assert (
415414
span["metrics"]["prompt_cache_creation_5m_tokens"] == response.usage.cache_creation.ephemeral_5m_input_tokens
416415
)
@@ -442,7 +441,7 @@ def test_anthropic_messages_create_prompt_cache_1h_metrics(memory_logger):
442441

443442
span = find_span_by_name(memory_logger.pop(), "anthropic.messages.create")
444443
assert span["output"]["role"] == response.role
445-
assert span["metrics"]["prompt_cache_creation_tokens"] == response.usage.cache_creation_input_tokens
444+
assert "prompt_cache_creation_tokens" not in span["metrics"]
446445
assert (
447446
span["metrics"]["prompt_cache_creation_5m_tokens"] == response.usage.cache_creation.ephemeral_5m_input_tokens
448447
)
@@ -851,7 +850,7 @@ async def test_anthropic_messages_streaming_async(memory_logger):
851850
assert metrics["completion_tokens"] == usage.output_tokens
852851
assert metrics["tokens"] == usage.input_tokens + usage.output_tokens
853852
assert metrics["prompt_cached_tokens"] == usage.cache_read_input_tokens
854-
assert metrics["prompt_cache_creation_tokens"] == usage.cache_creation_input_tokens
853+
_assert_cache_creation_metrics(metrics, usage)
855854
assert log["metadata"]["model"] == MODEL
856855
assert log["metadata"]["max_tokens"] == 1024
857856

@@ -933,7 +932,7 @@ def test_anthropic_messages_streaming_sync(memory_logger):
933932
assert log["metrics"]["completion_tokens"] == usage.output_tokens
934933
assert log["metrics"]["tokens"] == usage.input_tokens + usage.output_tokens
935934
assert log["metrics"]["prompt_cached_tokens"] == usage.cache_read_input_tokens
936-
assert log["metrics"]["prompt_cache_creation_tokens"] == usage.cache_creation_input_tokens
935+
_assert_cache_creation_metrics(log["metrics"], usage)
937936

938937

939938
@pytest.mark.vcr
@@ -973,7 +972,7 @@ def test_anthropic_messages_streaming_sync_text_stream(memory_logger):
973972
assert log["metrics"]["completion_tokens"] == usage.output_tokens
974973
assert log["metrics"]["tokens"] == usage.input_tokens + usage.output_tokens
975974
assert log["metrics"]["prompt_cached_tokens"] == usage.cache_read_input_tokens
976-
assert log["metrics"]["prompt_cache_creation_tokens"] == usage.cache_creation_input_tokens
975+
_assert_cache_creation_metrics(log["metrics"], usage)
977976

978977

979978
@pytest.mark.vcr
@@ -1014,7 +1013,7 @@ async def test_anthropic_messages_streaming_async_text_stream(memory_logger):
10141013
assert log["metrics"]["completion_tokens"] == usage.output_tokens
10151014
assert log["metrics"]["tokens"] == usage.input_tokens + usage.output_tokens
10161015
assert log["metrics"]["prompt_cached_tokens"] == usage.cache_read_input_tokens
1017-
assert log["metrics"]["prompt_cache_creation_tokens"] == usage.cache_creation_input_tokens
1016+
_assert_cache_creation_metrics(log["metrics"], usage)
10181017

10191018

10201019
@pytest.mark.vcr
@@ -1119,6 +1118,30 @@ def test_anthropic_messages_sync_server_tool_spans(memory_logger):
11191118
assert tool_span["root_span_id"] == llm_span["root_span_id"]
11201119

11211120

1121+
def _assert_cache_creation_metrics(metrics, usage):
1122+
cache_creation = getattr(usage, "cache_creation", None)
1123+
if cache_creation is None:
1124+
assert metrics["prompt_cache_creation_tokens"] == usage.cache_creation_input_tokens
1125+
return
1126+
1127+
if isinstance(cache_creation, dict):
1128+
ephemeral_5m = cache_creation.get("ephemeral_5m_input_tokens")
1129+
ephemeral_1h = cache_creation.get("ephemeral_1h_input_tokens")
1130+
else:
1131+
ephemeral_5m = getattr(cache_creation, "ephemeral_5m_input_tokens", None)
1132+
ephemeral_1h = getattr(cache_creation, "ephemeral_1h_input_tokens", None)
1133+
1134+
if ephemeral_5m is None and ephemeral_1h is None:
1135+
assert metrics["prompt_cache_creation_tokens"] == usage.cache_creation_input_tokens
1136+
return
1137+
1138+
assert "prompt_cache_creation_tokens" not in metrics
1139+
if ephemeral_5m is not None:
1140+
assert metrics["prompt_cache_creation_5m_tokens"] == ephemeral_5m
1141+
if ephemeral_1h is not None:
1142+
assert metrics["prompt_cache_creation_1h_tokens"] == ephemeral_1h
1143+
1144+
11221145
def _assert_metrics_are_valid(metrics, start, end):
11231146
assert metrics["tokens"] > 0
11241147
assert metrics["prompt_tokens"] > 0
@@ -1467,7 +1490,7 @@ def test_setup_creates_spans(memory_logger):
14671490
usage.input_tokens + usage.cache_read_input_tokens + usage.cache_creation_input_tokens
14681491
)
14691492
assert metrics["completion_tokens"] == usage.output_tokens
1470-
assert metrics["prompt_cache_creation_tokens"] == usage.cache_creation_input_tokens
1493+
assert "prompt_cache_creation_tokens" not in metrics
14711494
assert metrics["prompt_cache_creation_5m_tokens"] == ephemeral_5m
14721495
assert metrics["prompt_cache_creation_1h_tokens"] == ephemeral_1h
14731496
assert "service_tier" not in metrics
@@ -1498,7 +1521,7 @@ def test_extract_anthropic_usage_preserves_nested_numeric_fields():
14981521
assert metrics["prompt_tokens"] == 15
14991522
assert metrics["completion_tokens"] == 12
15001523
assert metrics["tokens"] == 27
1501-
assert metrics["prompt_cache_creation_tokens"] == 7
1524+
assert "prompt_cache_creation_tokens" not in metrics
15021525
assert metrics["prompt_cache_creation_5m_tokens"] == 3
15031526
assert metrics["prompt_cache_creation_1h_tokens"] == 4
15041527
assert metrics["server_tool_use_web_search_requests"] == 2

0 commit comments

Comments
 (0)