Skip to content

Commit e00cd1d

Browse files
committed
fix(llmobs/ai): surface prompt cache tokens for Vercel AI SDK integration across all supported providers (#8530)
* fix(llmobs/ai): surface prompt cache tokens for AI SDK Bedrock integration Reads `ai.usage.cachedInputTokens` (AI SDK v6+ standardized attribute) and `ai.response.providerMetadata.bedrock.usage.cache{Read,Write}InputTokens` and tags them as `cache_read_input_tokens` / `cache_write_input_tokens` on the LLMObs span. Without this, prompt-cached Bedrock requests via the Vercel AI SDK appeared as 100% non-cached input in LLMObs, breaking cost and cache-hit-rate dashboards. Tests use the Vertex AI fetch-stub pattern (passing a mock fetch directly to createAmazonBedrock) because @ai-sdk/amazon-bedrock uses globalThis.fetch which nock cannot intercept. Two real-shape Bedrock Converse API fixtures (cache read + cache write) exercise the extraction across ai 5.x / 6.x and the compatible @ai-sdk/amazon-bedrock 3.x / 4.x versions. Bumps the `ai` cap in test versions/package.json from 6.0.39 to 6.0.185 so tests run against the SDK version that emits cache attributes on the doGenerate span (older v6 versions only emit them on the parent generateText span). Supported versions table regenerated. Closes MLOB-7519 Refs MLOS-633 * fix(llmobs/ai): skip zero cache values and polyfill crypto for Node 18 The AI SDK sets `ai.usage.cachedInputTokens=0` on every language model span regardless of provider, which caused our plugin to tag `cache_read_input_tokens: 0` on spans that don't actually use prompt caching (e.g., OpenAI calls). This broke existing OpenAI tests that assert exact metric key counts. Skip emitting zero values so non-caching spans stay clean — the cost estimator math is unchanged because subtracting 0 is a no-op. Additionally, `@ai-sdk/amazon-bedrock` signs requests with `aws4fetch` which uses `globalThis.crypto` for SHA256/HMAC. Node 19+ exposes that as a global, but Node 18 (still supported and used in CI) does not. Polyfill from `node:crypto.webcrypto` in the Bedrock test setup. Bedrock cache test assertions updated to expect `undefined` (not `0`) for the absent cache field in each scenario. * test(llmobs/ai): add Anthropic prompt cache tests and parameterize for additional providers Extracts the Bedrock prompt-cache integration tests into a generic `describeProviderCacheTests()` helper that takes a provider config object (display name, package mapper, model builder, optional env vars). Adds Anthropic as the second consumer, exercising the `providerMetadata.anthropic.cache{Read,Creation}InputTokens` branch of `getProviderCacheTokens()` that was previously uncovered by tests (Codecov diff coverage gap). Anthropic SDK has the same multi-version normalization quirk as Bedrock (verified in source): `@ai-sdk/anthropic@3.x` normalizes `inputTokens.total = inputTokens + cacheCreation + cacheRead` via `convertToLanguageModelV3Usage`, while `@2.x` and `@1.x` pass the raw fresh count through. The generic test helper exercises all three majors via `withVersions`. Additional providers (OpenAI, Google, etc.) can be added by passing one more config object to `describeProviderCacheTests()` — no duplicated test orchestration code. * test(llmobs/ai): add OpenAI prompt cache tests across Chat Completions and Responses APIs Proves the cache_read capture in this plugin is provider-agnostic and not specific to Bedrock or Anthropic. OpenAI exercises the `ai.usage.cachedInputTokens` standardized-attribute path — by virtue of covering OpenAI, this also transitively validates Google Gemini, xAI, and any other provider whose `@ai-sdk/<name>` package populates the standardized attribute. Two consumers added because `@ai-sdk/openai` v1/v2/v3 route the default `openai(modelId)` factory to the Responses API, while `.chat(modelId)` routes to Chat Completions. The two endpoints have different response shapes and the SDK has separate parsing for each: - Chat Completions: usage.prompt_tokens_details.cached_tokens - Responses API: usage.input_tokens_details.cached_tokens Both endpoints surface cache_read via `ai.usage.cachedInputTokens` on the doGenerate span, but covering both paths future-proofs the plugin against any SDK regression that might affect only one endpoint — relevant because OpenAI is actively migrating customers from Chat Completions to Responses. Extends `describeProviderCacheTests()` helper with two new config options to support providers whose semantics differ from Bedrock/Anthropic: - `scenarios`: which scenarios to test (OpenAI lacks per-request cache_write, so it passes ['cache-read'] only) - `getExpectedMetrics`: version-aware expectations (OpenAI's `prompt_tokens` / `input_tokens` is always the sum at the API level, so `ai.usage.inputTokens` is 4448 across all ai versions — unlike Bedrock/Anthropic where the v5-paired SDK passes raw fresh through and the v6-paired SDK normalizes) 18 tests now exercise the full provider matrix: - Bedrock (cache_read + cache_write) x 3 ai versions = 6 - Anthropic (cache_read + cache_write) x 3 ai versions = 6 - OpenAI Chat Completions (cache_read) x 3 ai versions = 3 - OpenAI Responses API (cache_read) x 3 ai versions = 3 * test(llmobs/ai): add Google Gemini prompt cache tests Google Gemini exercises a third upstream API shape distinct from both the Bedrock/Anthropic family (provider-specific cache fields in providerMetadata) and the OpenAI-compatible family (Chat Completions / Responses with shared field names). Google's response uses `usageMetadata.cachedContentTokenCount` at the top level of the response (not nested under a provider namespace), and the `@ai-sdk/google` provider package maps it to `ai.usage.cachedInputTokens` via the standardized attribute path. Like OpenAI, Google's `promptTokenCount` already includes cached tokens at the API level, so `ai.usage.inputTokens` is the sum across all ai versions. Per-request cache write metric is not exposed by Google (context caching requires a separate API call to create the cache), so only cache-read is tested. By covering three distinct API shapes (Bedrock/Anthropic native, OpenAI shared, Google native), we establish that the plugin's cache token capture is genuinely provider-agnostic via the AI SDK v5+ standardized attribute, not just inferentially supported. Transitively this also covers xAI (which uses @ai-sdk/openai-compatible under the hood — same code paths as OpenAI). 21 tests now cover the full provider matrix. * fix(llmobs/ai): normalize inputTokens to the sum convention used by bedrockruntime Addresses Codex review feedback: some provider/SDK combinations leave `ai.usage.inputTokens` as the raw fresh count while still surfacing cache_write tokens in `providerMetadata`. A downstream consumer doing `nonCached = input - cacheRead - cacheWrite` would compute a negative number, and the upcoming backend partial-tokens UI warning would fire even though the cost math is otherwise correct via the token_normalizer clamp. Specifically affected stacks: - ai@5 + @ai-sdk/amazon-bedrock@3.x (bedrock@3 doesn't normalize) - ai@4-5 + @ai-sdk/anthropic@1-2.x (older anthropic SDK doesn't normalize) Matches the convention in bedrockruntime.js:157 — input_tokens is always the sum of fresh + cache_read + cache_write. Detection uses a math check: if `inputTokens < cacheRead + cacheWrite`, the value cannot already be a sum (would be impossible for fresh to be less than cached). This is the only signal that reliably differentiates "raw fresh" from "already normalized" without false positives on stacks where the upstream SDK has already done the work (ai@6 + bedrock@4 / anthropic@3, OpenAI, Google). Tests updated for ai@5 cache-write scenarios where `input_tokens` is now the normalized sum (4448) instead of raw fresh (23). * chore: regenerate supported versions after rebase * chore: sync versions/package.json with master and add provider sdk caps
1 parent acf2647 commit e00cd1d

13 files changed

Lines changed: 587 additions & 46 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
"id": "msg_01abc123def456",
3+
"type": "message",
4+
"role": "assistant",
5+
"model": "claude-3-5-haiku-20241022",
6+
"content": [{ "type": "text", "text": "Datadog LLM Observability provides end-to-end visibility." }],
7+
"stop_reason": "end_turn",
8+
"stop_sequence": null,
9+
"usage": {
10+
"input_tokens": 23,
11+
"output_tokens": 44,
12+
"cache_creation_input_tokens": 0,
13+
"cache_read_input_tokens": 4425
14+
}
15+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
"id": "msg_01xyz789ghi012",
3+
"type": "message",
4+
"role": "assistant",
5+
"model": "claude-3-5-haiku-20241022",
6+
"content": [{ "type": "text", "text": "Datadog LLM Observability provides end-to-end visibility." }],
7+
"stop_reason": "end_turn",
8+
"stop_sequence": null,
9+
"usage": {
10+
"input_tokens": 23,
11+
"output_tokens": 53,
12+
"cache_creation_input_tokens": 4425,
13+
"cache_read_input_tokens": 0
14+
}
15+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
{
2+
"stopReason": "end_turn",
3+
"output": {
4+
"message": {
5+
"role": "assistant",
6+
"content": [{ "type": "text", "text": "Datadog LLM Observability provides end-to-end visibility." }]
7+
}
8+
},
9+
"usage": {
10+
"inputTokens": 23,
11+
"outputTokens": 44,
12+
"totalTokens": 4492,
13+
"cacheReadInputTokens": 4425,
14+
"cacheWriteInputTokens": 0
15+
},
16+
"metrics": { "latencyMs": 1000 }
17+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
{
2+
"stopReason": "end_turn",
3+
"output": {
4+
"message": {
5+
"role": "assistant",
6+
"content": [{ "type": "text", "text": "Datadog LLM Observability provides end-to-end visibility." }]
7+
}
8+
},
9+
"usage": {
10+
"inputTokens": 23,
11+
"outputTokens": 53,
12+
"totalTokens": 76,
13+
"cacheReadInputTokens": 0,
14+
"cacheWriteInputTokens": 4425
15+
},
16+
"metrics": { "latencyMs": 1000 }
17+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
{
2+
"candidates": [
3+
{
4+
"content": {
5+
"role": "model",
6+
"parts": [
7+
{ "text": "Datadog LLM Observability provides end-to-end visibility." }
8+
]
9+
},
10+
"finishReason": "STOP"
11+
}
12+
],
13+
"usageMetadata": {
14+
"promptTokenCount": 4448,
15+
"candidatesTokenCount": 44,
16+
"totalTokenCount": 4492,
17+
"cachedContentTokenCount": 4425
18+
}
19+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
{
2+
"id": "chatcmpl-abc123def456",
3+
"object": "chat.completion",
4+
"created": 1779284000,
5+
"model": "gpt-4o-mini-2024-07-18",
6+
"choices": [
7+
{
8+
"index": 0,
9+
"message": {
10+
"role": "assistant",
11+
"content": "Datadog LLM Observability provides end-to-end visibility."
12+
},
13+
"logprobs": null,
14+
"finish_reason": "stop"
15+
}
16+
],
17+
"usage": {
18+
"prompt_tokens": 4448,
19+
"completion_tokens": 44,
20+
"total_tokens": 4492,
21+
"prompt_tokens_details": {
22+
"cached_tokens": 4425,
23+
"audio_tokens": 0
24+
},
25+
"completion_tokens_details": {
26+
"reasoning_tokens": 0,
27+
"audio_tokens": 0,
28+
"accepted_prediction_tokens": 0,
29+
"rejected_prediction_tokens": 0
30+
}
31+
},
32+
"system_fingerprint": "fp_test123"
33+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
{
2+
"id": "resp_abc123def456",
3+
"object": "response",
4+
"created_at": 1779284000,
5+
"status": "completed",
6+
"model": "gpt-4o-mini-2024-07-18",
7+
"incomplete_details": null,
8+
"output": [
9+
{
10+
"type": "message",
11+
"id": "msg_abc123",
12+
"status": "completed",
13+
"role": "assistant",
14+
"content": [
15+
{
16+
"type": "output_text",
17+
"text": "Datadog LLM Observability provides end-to-end visibility.",
18+
"annotations": []
19+
}
20+
]
21+
}
22+
],
23+
"usage": {
24+
"input_tokens": 4448,
25+
"input_tokens_details": {
26+
"cached_tokens": 4425
27+
},
28+
"output_tokens": 44,
29+
"output_tokens_details": {
30+
"reasoning_tokens": 0
31+
}
32+
}
33+
}

packages/dd-trace/src/llmobs/plugins/ai/util.js

Lines changed: 91 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -66,11 +66,22 @@ function getOperation (span) {
6666
}
6767

6868
/**
69-
* Get the LLM token usage from the span tags
70-
* Supports both AI SDK v4 (promptTokens/completionTokens) and v5 (inputTokens/outputTokens)
71-
* @template T extends {inputTokens: number, outputTokens: number, totalTokens: number}
72-
* @param {T} tags
73-
* @returns {Pick<T, 'inputTokens' | 'outputTokens' | 'totalTokens'>}
69+
* Get the LLM token usage from the span tags.
70+
*
71+
* Supports both AI SDK v4 (promptTokens/completionTokens) and v5+
72+
* (inputTokens/outputTokens), and surfaces prompt-cache metrics for providers
73+
* that report them. The AI SDK convention is that `inputTokens` already
74+
* includes cached tokens, so cache reads are reported as a subset of input
75+
* tokens rather than added on top.
76+
*
77+
* @param {SpanTags} tags
78+
* @returns {{
79+
* inputTokens?: number,
80+
* outputTokens?: number,
81+
* totalTokens?: number,
82+
* cacheReadTokens?: number,
83+
* cacheWriteTokens?: number
84+
* }}
7485
*/
7586
function getUsage (tags) {
7687
const usage = {}
@@ -87,9 +98,84 @@ function getUsage (tags) {
8798
const totalTokens = tags['ai.usage.totalTokens'] ?? (inputTokens + outputTokens)
8899
if (!Number.isNaN(totalTokens)) usage.totalTokens = totalTokens
89100

101+
// Prompt-cache metrics. AI SDK v6 standardizes cache READ tokens via
102+
// `ai.usage.cachedInputTokens`; cache WRITE tokens (and earlier AI SDK
103+
// versions / providers that don't fill `cachedInputTokens`) are only
104+
// available through provider-specific `ai.response.providerMetadata`.
105+
// Skip zero values: the AI SDK sets `cachedInputTokens=0` on every span
106+
// regardless of provider, so emitting it would add noise to spans that
107+
// don't actually use prompt caching (e.g. OpenAI).
108+
const providerCache = getProviderCacheTokens(tags['ai.response.providerMetadata'])
109+
110+
const cacheReadTokens = tags['ai.usage.cachedInputTokens'] ?? providerCache.cacheReadTokens
111+
if (cacheReadTokens) usage.cacheReadTokens = cacheReadTokens
112+
113+
if (providerCache.cacheWriteTokens) usage.cacheWriteTokens = providerCache.cacheWriteTokens
114+
115+
// Normalize `inputTokens` to the sum convention used by `bedrockruntime.js`.
116+
// Some SDK combinations (e.g. `ai@5` + `@ai-sdk/amazon-bedrock@3`) pass the
117+
// raw fresh count through, which makes `nonCached = input - cacheRead -
118+
// cacheWrite` go negative downstream.
119+
//
120+
// Detection: if `inputTokens < cacheSum`, the value cannot already be a sum
121+
// that includes them (non-negative arithmetic). This is provider/version
122+
// agnostic and won't double-count on stacks where the SDK already
123+
// normalized (`ai@6` + `bedrock@4` / `anthropic@3`, OpenAI, Google).
124+
if (usage.inputTokens != null) {
125+
const cacheSum = (usage.cacheReadTokens || 0) + (usage.cacheWriteTokens || 0)
126+
if (usage.inputTokens < cacheSum) {
127+
usage.inputTokens += cacheSum
128+
if (usage.totalTokens != null) {
129+
usage.totalTokens = usage.inputTokens + (usage.outputTokens || 0)
130+
}
131+
}
132+
}
133+
90134
return usage
91135
}
92136

137+
/**
138+
* Extract prompt-cache token counts from the stringified
139+
* `ai.response.providerMetadata` attribute.
140+
*
141+
* The AI SDK does not standardize cache WRITE tokens on the usage object, and
142+
* earlier versions / providers may also omit `ai.usage.cachedInputTokens`, so
143+
* we read the provider-specific shape directly. Only Bedrock and Anthropic
144+
* are handled here as they are the providers that report cache writes today.
145+
*
146+
* @see https://ai-sdk.dev/providers/ai-sdk-providers/amazon-bedrock#cache-points
147+
* @see https://ai-sdk.dev/providers/ai-sdk-providers/anthropic#cache-control
148+
*
149+
* @param {string | undefined} providerMetadataJson
150+
* @returns {{ cacheReadTokens?: number, cacheWriteTokens?: number }}
151+
*/
152+
function getProviderCacheTokens (providerMetadataJson) {
153+
if (!providerMetadataJson) return {}
154+
155+
const metadata = getJsonStringValue(providerMetadataJson, null)
156+
if (!metadata || typeof metadata !== 'object') return {}
157+
158+
const result = {}
159+
160+
const bedrockUsage = metadata.bedrock?.usage
161+
if (bedrockUsage) {
162+
if (bedrockUsage.cacheReadInputTokens != null) result.cacheReadTokens = bedrockUsage.cacheReadInputTokens
163+
if (bedrockUsage.cacheWriteInputTokens != null) result.cacheWriteTokens = bedrockUsage.cacheWriteInputTokens
164+
}
165+
166+
const anthropic = metadata.anthropic
167+
if (anthropic) {
168+
if (result.cacheReadTokens == null && anthropic.cacheReadInputTokens != null) {
169+
result.cacheReadTokens = anthropic.cacheReadInputTokens
170+
}
171+
if (result.cacheWriteTokens == null && anthropic.cacheCreationInputTokens != null) {
172+
result.cacheWriteTokens = anthropic.cacheCreationInputTokens
173+
}
174+
}
175+
176+
return result
177+
}
178+
93179
/**
94180
* Safely JSON parses a string value with a default fallback
95181
* @template T typeof defaultValue

0 commit comments

Comments
 (0)