Skip to content

Commit 8bc638f

Browse files
committed
capture anthropic token cache
1 parent f1b2040 commit 8bc638f

5 files changed

Lines changed: 261 additions & 11 deletions

File tree

braintrust-sdk/instrumentation/anthropic_2_2_0/src/main/java/dev/braintrust/instrumentation/anthropic/v2_2_0/TracingHttpClient.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -303,7 +303,8 @@ private static void tagSpanFromBuffer(Span span, byte[] bytes, Long timeToFirstT
303303
InstrumentationSemConv.tagLLMSpanResponse(
304304
span,
305305
InstrumentationSemConv.PROVIDER_NAME_ANTHROPIC,
306-
new String(bytes, StandardCharsets.UTF_8));
306+
new String(bytes, StandardCharsets.UTF_8),
307+
null);
307308
}
308309
} catch (Exception e) {
309310
log.error("Could not tag span from Anthropic response buffer", e);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
package dev.braintrust.instrumentation.anthropic.v2_2_0;
2+
3+
import static org.junit.jupiter.api.Assertions.*;
4+
5+
import com.anthropic.client.AnthropicClient;
6+
import com.anthropic.client.okhttp.AnthropicOkHttpClient;
7+
import com.anthropic.core.JsonValue;
8+
import com.anthropic.models.messages.CacheControlEphemeral;
9+
import com.anthropic.models.messages.MessageCreateParams;
10+
import com.anthropic.models.messages.TextBlockParam;
11+
import com.fasterxml.jackson.databind.JsonNode;
12+
import com.fasterxml.jackson.databind.ObjectMapper;
13+
import dev.braintrust.TestHarness;
14+
import dev.braintrust.instrumentation.Instrumenter;
15+
import io.opentelemetry.api.common.AttributeKey;
16+
import java.util.List;
17+
import java.util.UUID;
18+
import lombok.SneakyThrows;
19+
import net.bytebuddy.agent.ByteBuddyAgent;
20+
import org.junit.jupiter.api.BeforeAll;
21+
import org.junit.jupiter.api.BeforeEach;
22+
import org.junit.jupiter.api.Test;
23+
24+
/**
25+
* Tests that prompt caching metrics (per-TTL breakdown) are correctly extracted from Anthropic API
26+
* responses and attached to spans.
27+
*
28+
* <p>Uses a cache-buster nonce in the system prompt to guarantee cache misses, ensuring {@code
29+
* cache_creation_input_tokens} is always positive. In VCR replay mode the nonce is a fixed string
30+
* so cassette matching still works.
31+
*/
32+
public class BraintrustAnthropicPromptCachingTest {
33+
private static final String TEST_MODEL = "claude-sonnet-4-5-20250929";
34+
private static final ObjectMapper JSON_MAPPER = new ObjectMapper();
35+
36+
/**
37+
* Nonce injected into system prompts to bust Anthropic's server-side prompt cache. Random UUID
38+
* when running live ({@code VCR_MODE=off}), fixed string otherwise so VCR cassettes match.
39+
*/
40+
private static final String VCR_NONCE;
41+
42+
static {
43+
String vcrMode = System.getenv().getOrDefault("VCR_MODE", "replay").toUpperCase();
44+
VCR_NONCE = "OFF".equals(vcrMode) ? UUID.randomUUID().toString() : "vcr-mode";
45+
}
46+
47+
@BeforeAll
48+
public static void beforeAll() {
49+
var instrumentation = ByteBuddyAgent.install();
50+
Instrumenter.install(
51+
instrumentation, BraintrustAnthropicPromptCachingTest.class.getClassLoader());
52+
}
53+
54+
private TestHarness testHarness;
55+
56+
@BeforeEach
57+
void beforeEach() {
58+
testHarness = TestHarness.setup();
59+
}
60+
61+
/**
62+
* Sends a single request with two system content blocks — one cached at 5m TTL and one at 1h
63+
* TTL — and verifies that the per-TTL prompt cache creation metrics are present on the span.
64+
*/
65+
@Test
66+
@SneakyThrows
67+
void testPromptCachingDualTtl() {
68+
AnthropicClient client =
69+
AnthropicOkHttpClient.builder()
70+
.baseUrl(testHarness.anthropicBaseUrl())
71+
.apiKey(testHarness.anthropicApiKey())
72+
.build();
73+
74+
// 1h TTL block — must come before 5m (Anthropic requires descending TTL order).
75+
// Requires the extended-cache-ttl beta header.
76+
// The text must exceed Claude Sonnet's minimum cacheable size (~1024 tokens / ~4000 chars).
77+
CacheControlEphemeral cacheControl1h =
78+
CacheControlEphemeral.builder()
79+
.putAdditionalProperty("ttl", JsonValue.from("1h"))
80+
.build();
81+
82+
TextBlockParam systemBlock1h =
83+
TextBlockParam.builder()
84+
.text(
85+
buildPaddedSystemText(
86+
"1h-block",
87+
"Reference: capitals include Paris, Berlin, Rome, Madrid,"
88+
+ " Lisbon."))
89+
.cacheControl(cacheControl1h)
90+
.build();
91+
92+
// 5m TTL block — default ephemeral cache.
93+
CacheControlEphemeral cacheControl5m =
94+
CacheControlEphemeral.builder()
95+
.putAdditionalProperty("ttl", JsonValue.from("5m"))
96+
.build();
97+
98+
TextBlockParam systemBlock5m =
99+
TextBlockParam.builder()
100+
.text(
101+
buildPaddedSystemText(
102+
"5m-block",
103+
"You are a helpful geography assistant. Answer in one"
104+
+ " sentence."))
105+
.cacheControl(cacheControl5m)
106+
.build();
107+
108+
var request =
109+
MessageCreateParams.builder()
110+
.model(TEST_MODEL)
111+
.systemOfTextBlockParams(List.of(systemBlock1h, systemBlock5m))
112+
.addUserMessage("What is the capital of France?")
113+
.maxTokens(128)
114+
.temperature(0.0)
115+
.putAdditionalHeader("anthropic-beta", "extended-cache-ttl-2025-04-11")
116+
.build();
117+
118+
var response = client.messages().create(request);
119+
120+
// Basic response sanity
121+
assertNotNull(response);
122+
assertNotNull(response.id());
123+
var contentBlock = response.content().get(0);
124+
assertTrue(contentBlock.isText());
125+
assertFalse(contentBlock.asText().text().isEmpty());
126+
127+
// Verify span metrics
128+
var spans = testHarness.awaitExportedSpans();
129+
assertEquals(1, spans.size());
130+
var span = spans.get(0);
131+
132+
String metricsJson = span.getAttributes().get(AttributeKey.stringKey("braintrust.metrics"));
133+
assertNotNull(metricsJson, "metrics should be present");
134+
JsonNode metrics = JSON_MAPPER.readTree(metricsJson);
135+
136+
// Standard token metrics
137+
assertPositive(metrics, "prompt_tokens");
138+
assertPositive(metrics, "completion_tokens");
139+
assertPositive(metrics, "tokens");
140+
141+
// Per-TTL breakdown — both should be positive on a cold cache
142+
assertFalse(
143+
metrics.has("prompt_cache_creation_tokens"),
144+
"anthropic cache tokens must be set INSTEAD of the aggregate meteric");
145+
assertPositive(metrics, "prompt_cache_creation_5m_tokens");
146+
assertPositive(metrics, "prompt_cache_creation_1h_tokens");
147+
assertEquals(
148+
response.usage().cacheCreationInputTokens().get(),
149+
(metrics.get("prompt_cache_creation_5m_tokens").intValue()
150+
+ metrics.get("prompt_cache_creation_1h_tokens").intValue()),
151+
"ttl tokens should sum to total token count");
152+
153+
// Cache read may be 0 on cold cache, but should be present and non-negative
154+
assertTrue(metrics.has("prompt_cached_tokens"), "prompt_cached_tokens should be present");
155+
assertTrue(
156+
metrics.get("prompt_cached_tokens").asDouble() >= 0,
157+
"prompt_cached_tokens should be non-negative");
158+
}
159+
160+
private static void assertPositive(JsonNode metrics, String field) {
161+
assertTrue(metrics.has(field), field + " should be present");
162+
assertTrue(
163+
metrics.get(field).isNumber(),
164+
field + " should be a number, got: " + metrics.get(field));
165+
assertTrue(
166+
metrics.get(field).asDouble() > 0,
167+
field + " should be positive, got: " + metrics.get(field).asDouble());
168+
}
169+
170+
/**
171+
* Build a system prompt text padded to exceed the minimum cacheable size (~4000 ASCII chars).
172+
* Includes the VCR nonce and a block identifier for cache isolation.
173+
*/
174+
private String buildPaddedSystemText(String blockId, String coreInstruction) {
175+
StringBuilder sb = new StringBuilder();
176+
sb.append("[cache-buster: ").append(blockId).append(" ").append(VCR_NONCE).append("]\n");
177+
sb.append(coreInstruction).append("\n\n");
178+
179+
// Pad with numbered guidelines to exceed the token minimum.
180+
for (int i = 1; i <= 80; i++) {
181+
sb.append(i)
182+
.append(". This is guideline number ")
183+
.append(i)
184+
.append(". It exists to pad the system prompt past the minimum cacheable ")
185+
.append("token threshold for Claude Sonnet models. Each guideline adds ")
186+
.append("approximately fifty tokens of padding text to the prompt.\n");
187+
}
188+
return sb.toString();
189+
}
190+
}

braintrust-sdk/instrumentation/anthropic_2_2_0/src/test/java/dev/braintrust/instrumentation/anthropic/v2_2_0/BraintrustAnthropicTest.java

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
import org.junit.jupiter.api.Test;
2020

2121
public class BraintrustAnthropicTest {
22+
private static final String TEST_MODEL = "claude-haiku-4-5";
2223
private static final ObjectMapper JSON_MAPPER = new ObjectMapper();
2324

2425
@BeforeAll
@@ -45,7 +46,7 @@ void testWrapAnthropic() {
4546

4647
var request =
4748
MessageCreateParams.builder()
48-
.model(Model.CLAUDE_3_HAIKU_20240307)
49+
.model(Model.of(TEST_MODEL))
4950
.system("You are a helpful assistant")
5051
.addUserMessage("What is the capital of France?")
5152
.maxTokens(50)
@@ -82,8 +83,8 @@ void testWrapAnthropic() {
8283
JsonNode metadata = JSON_MAPPER.readTree(metadataJson);
8384
assertEquals("anthropic", metadata.get("provider").asText());
8485
assertTrue(
85-
metadata.get("model").asText().startsWith("claude-3-haiku"),
86-
"model should start with claude-3-haiku");
86+
metadata.get("model").asText().startsWith("claude-haiku-4"),
87+
"model should start with claude-haiku-4");
8788

8889
// Verify input
8990
String inputJson =
@@ -128,7 +129,7 @@ void testWrapAnthropicStreaming() {
128129

129130
var request =
130131
MessageCreateParams.builder()
131-
.model(Model.CLAUDE_3_HAIKU_20240307)
132+
.model(Model.of(TEST_MODEL))
132133
.system("You are a helpful assistant")
133134
.addUserMessage("What is the capital of France?")
134135
.maxTokens(50)
@@ -200,7 +201,7 @@ void testWrapAnthropicAsync() {
200201

201202
var request =
202203
MessageCreateParams.builder()
203-
.model(Model.CLAUDE_3_HAIKU_20240307)
204+
.model(Model.of(TEST_MODEL))
204205
.system("You are a helpful assistant")
205206
.addUserMessage("What is the capital of France?")
206207
.maxTokens(50)
@@ -263,7 +264,7 @@ void testWrapAnthropicAsyncStreaming() {
263264

264265
var request =
265266
MessageCreateParams.builder()
266-
.model(Model.CLAUDE_3_HAIKU_20240307)
267+
.model(Model.of(TEST_MODEL))
267268
.system("You are a helpful assistant")
268269
.addUserMessage("What is the capital of France?")
269270
.maxTokens(50)
@@ -322,7 +323,7 @@ void testWrapAnthropicBeta() {
322323

323324
var request =
324325
com.anthropic.models.beta.messages.MessageCreateParams.builder()
325-
.model(Model.CLAUDE_3_HAIKU_20240307)
326+
.model(Model.of(TEST_MODEL))
326327
.system("You are a helpful assistant")
327328
.addUserMessage("What is the capital of France?")
328329
.maxTokens(50)
@@ -357,8 +358,8 @@ void testWrapAnthropicBeta() {
357358
JsonNode metadata = JSON_MAPPER.readTree(metadataJson);
358359
assertEquals("anthropic", metadata.get("provider").asText());
359360
assertTrue(
360-
metadata.get("model").asText().startsWith("claude-3-haiku"),
361-
"model should start with claude-3-haiku");
361+
metadata.get("model").asText().startsWith("claude-haiku-4"),
362+
"model should start with claude-haiku-4");
362363

363364
// Verify input
364365
String inputJson =
@@ -403,7 +404,7 @@ void testWrapAnthropicBetaStreaming() {
403404

404405
var request =
405406
com.anthropic.models.beta.messages.MessageCreateParams.builder()
406-
.model(Model.CLAUDE_3_HAIKU_20240307)
407+
.model(Model.of(TEST_MODEL))
407408
.system("You are a helpful assistant")
408409
.addUserMessage("What is the capital of France?")
409410
.maxTokens(50)

braintrust-sdk/src/main/java/dev/braintrust/instrumentation/InstrumentationSemConv.java

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@
1313
import javax.annotation.Nonnull;
1414
import javax.annotation.Nullable;
1515
import lombok.SneakyThrows;
16+
import lombok.extern.slf4j.Slf4j;
1617

18+
@Slf4j
1719
public class InstrumentationSemConv {
1820
public static final String PROVIDER_NAME_OPENAI = "openai";
1921
public static final String PROVIDER_NAME_ANTHROPIC = "anthropic";
@@ -258,13 +260,61 @@ private static void tagAnthropicResponse(
258260
"tokens",
259261
usage.get("input_tokens").asLong() + usage.get("output_tokens").asLong());
260262
}
263+
264+
// Prompt caching metrics
265+
if (usage.has("cache_read_input_tokens")) {
266+
metrics.put("prompt_cached_tokens", usage.get("cache_read_input_tokens"));
267+
}
268+
if (usage.has("cache_creation_input_tokens")) {
269+
long cacheCreationTokens = usage.get("cache_creation_input_tokens").asLong();
270+
271+
// Per-TTL breakdown from usage.cache_creation (e.g.
272+
// ephemeral_5m_input_tokens, ephemeral_1h_input_tokens).
273+
// When per-TTL metrics are emitted, the aggregate metric is omitted.
274+
boolean emittedPerTtl = addPerTtlCacheMetrics(metrics, usage);
275+
if (!emittedPerTtl) {
276+
metrics.put("prompt_cache_creation_tokens", cacheCreationTokens);
277+
}
278+
}
261279
}
262280

263281
if (!metrics.isEmpty()) {
264282
span.setAttribute("braintrust.metrics", toJson(metrics));
265283
}
266284
}
267285

286+
/**
287+
* Mapping from Anthropic {@code usage.cache_creation} field names to Braintrust per-TTL metric
288+
* names. Only supported TTL tiers are included.
289+
*/
290+
private static final Map<String, String> CACHE_CREATION_FIELD_TO_METRIC =
291+
Map.of(
292+
"ephemeral_5m_input_tokens", "prompt_cache_creation_5m_tokens",
293+
"ephemeral_1h_input_tokens", "prompt_cache_creation_1h_tokens");
294+
295+
/**
296+
* Extract per-TTL cache creation metrics from the Anthropic {@code usage.cache_creation}
297+
* response object. Fields like {@code ephemeral_5m_input_tokens} are mapped to {@code
298+
* prompt_cache_creation_5m_tokens}.
299+
*
300+
* @return {@code true} if at least one per-TTL metric was emitted
301+
*/
302+
private static boolean addPerTtlCacheMetrics(Map<String, Object> metrics, JsonNode usage) {
303+
if (!usage.has("cache_creation")) {
304+
return false;
305+
}
306+
JsonNode cacheCreation = usage.get("cache_creation");
307+
boolean emitted = false;
308+
for (Map.Entry<String, String> entry : CACHE_CREATION_FIELD_TO_METRIC.entrySet()) {
309+
if (cacheCreation.has(entry.getKey())) {
310+
long tokens = cacheCreation.get(entry.getKey()).asLong();
311+
metrics.put(entry.getValue(), tokens);
312+
emitted = true;
313+
}
314+
}
315+
return emitted;
316+
}
317+
268318
// -------------------------------------------------------------------------
269319
// AWS Bedrock provider implementation
270320
// -------------------------------------------------------------------------

btx/src/test/java/dev/braintrust/sdkspecimpl/SpanValidator.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,14 @@ private static void assertFnMatcher(Object actual, SpecMatcher.FnMatcher fn, Str
205205
context, v));
206206
}
207207
}
208+
case "undefined_or_null" -> {
209+
if (actual != null) {
210+
fail(
211+
String.format(
212+
"%s: undefined_or_null: expected null but got %s (value: %s)",
213+
context, actual.getClass().getSimpleName(), actual));
214+
}
215+
}
208216
case "is_non_empty_string" -> {
209217
if (!(actual instanceof String) || ((String) actual).isEmpty()) {
210218
fail(

0 commit comments

Comments
 (0)