From 076ca313cf6b38341dcaaaf84e01e6a12b1b780a Mon Sep 17 00:00:00 2001 From: Sergiy Dybskiy Date: Fri, 11 Sep 2026 17:29:11 -0400 Subject: [PATCH 1/4] fix(server-utils): Read AI SDK cache token counts in Vercel AI channel subscriber The `ai:telemetry` channel subscriber only derived `gen_ai.usage.cache_read.input_tokens` and `gen_ai.usage.cache_creation.input_tokens` from provider-keyed `providerMetadata`. Through the Vercel AI Gateway that object is keyed `gateway`, so the cache counts the AI SDK already normalizes into its usage object were dropped. The v10 OTel-based integration mapped them. Read them from the usage object as well: `cachedInputTokens` (v5), `inputTokenDetails.cacheReadTokens` / `cacheWriteTokens` (v6), and the `inputTokens.cacheRead` / `cacheWrite` objects of v7 model-call usage. Provider-derived values are applied afterwards and still win. Co-authored-by: Claude --- .../vercel-ai/vercel-ai-dc-subscriber.ts | 27 +++ .../vercel-ai/cache-tokens.test.ts | 161 ++++++++++++++++++ 2 files changed, 188 insertions(+) create mode 100644 packages/server-utils/test/integrations/vercel-ai/cache-tokens.test.ts diff --git a/packages/server-utils/src/integrations/vercel-ai/vercel-ai-dc-subscriber.ts b/packages/server-utils/src/integrations/vercel-ai/vercel-ai-dc-subscriber.ts index 15f526c1b86d..ff7e3c43308b 100644 --- a/packages/server-utils/src/integrations/vercel-ai/vercel-ai-dc-subscriber.ts +++ b/packages/server-utils/src/integrations/vercel-ai/vercel-ai-dc-subscriber.ts @@ -18,6 +18,8 @@ import { GEN_AI_TOOL_DEFINITIONS, GEN_AI_TOOL_DESCRIPTION, GEN_AI_TOOL_NAME, + GEN_AI_USAGE_CACHE_CREATION_INPUT_TOKENS, + GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS, GEN_AI_USAGE_INPUT_TOKENS, GEN_AI_USAGE_OUTPUT_TOKENS, GEN_AI_USAGE_REASONING_OUTPUT_TOKENS, @@ -356,6 +358,9 @@ function enrichInvokeAgentFromStream( addTokensToSpan(span, GEN_AI_USAGE_INPUT_TOKENS, input); addTokensToSpan(span, GEN_AI_USAGE_OUTPUT_TOKENS, output); addTokensToSpan(span, GEN_AI_USAGE_TOTAL_TOKENS, tokenCount(usage.totalTokens) ?? sum(input, output)); + for (const [attribute, value] of Object.entries(cacheTokenAttributes(usage))) { + addTokensToSpan(span, attribute, value); + } } if (recordOutputs) { @@ -567,6 +572,8 @@ export function enrichSpanOnEnd( if (totalTokens !== undefined) { span.setAttribute(GEN_AI_USAGE_TOTAL_TOKENS, totalTokens); } + // Set before the `providerMetadata` attributes below so a provider-reported count still wins. + span.setAttributes(cacheTokenAttributes(usage)); } // Match the OTel integration: finish reasons live on the model-call (`generate_content`) span, not @@ -633,6 +640,26 @@ function tokenCount(value: unknown): number | undefined { return asNumber(value) ?? (isObjectLike(value) ? asNumber(value.total) : undefined); } +/** + * Reads the AI SDK's own cache token counts from a usage object. v5 reports `cachedInputTokens`, v6 + * adds `inputTokenDetails`, and v7 model-call usage nests them under `inputTokens`. `providerMetadata` + * only carries cache counts under a provider key, so through the AI Gateway (`gateway` key) these + * normalized counts are the sole source. + */ +function cacheTokenAttributes(usage: Record): Record { + const inputTokens = isObjectLike(usage.inputTokens) ? usage.inputTokens : undefined; + const inputTokenDetails = isObjectLike(usage.inputTokenDetails) ? usage.inputTokenDetails : undefined; + const cacheRead = + asNumber(inputTokens?.cacheRead) ?? + asNumber(inputTokenDetails?.cacheReadTokens) ?? + asNumber(usage.cachedInputTokens); + const cacheWrite = asNumber(inputTokens?.cacheWrite) ?? asNumber(inputTokenDetails?.cacheWriteTokens); + return { + ...(cacheRead !== undefined ? { [GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS]: cacheRead } : {}), + ...(cacheWrite !== undefined ? { [GEN_AI_USAGE_CACHE_CREATION_INPUT_TOKENS]: cacheWrite } : {}), + }; +} + function buildOutputMessages( parts: Array>, finishReason: string | undefined, diff --git a/packages/server-utils/test/integrations/vercel-ai/cache-tokens.test.ts b/packages/server-utils/test/integrations/vercel-ai/cache-tokens.test.ts new file mode 100644 index 000000000000..2a483b0277e6 --- /dev/null +++ b/packages/server-utils/test/integrations/vercel-ai/cache-tokens.test.ts @@ -0,0 +1,161 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { + GEN_AI_USAGE_CACHE_CREATION_INPUT_TOKENS, + GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS, + GEN_AI_USAGE_INPUT_TOKENS, + GEN_AI_USAGE_OUTPUT_TOKENS, + GEN_AI_USAGE_TOTAL_TOKENS, +} from '@sentry/conventions/attributes'; +import { getMainCarrier, setCurrentClient, spanToStaticSpanJSON } from '@sentry/core'; +import type { Span } from '@sentry/core'; +import { + createSpanFromMessage, + enrichSpanOnEnd, + streamedResultToChannelResult, +} from '../../../src/integrations/vercel-ai/vercel-ai-dc-subscriber'; +import { getDefaultTestClientOptions, TestClient } from '../../mocks/client'; + +/** + * The AI SDK normalizes cache token counts into its usage object. `providerMetadata` carries them + * only under a provider key, so through the AI Gateway (`gateway` key) the usage object is the only + * source. + */ +describe('Vercel AI SDK cache tokens', () => { + beforeEach(() => { + getMainCarrier().__SENTRY__ = undefined; + }); + + afterEach(() => { + getMainCarrier().__SENTRY__ = undefined; + }); + + function setupClient(): Span[] { + const client = new TestClient( + getDefaultTestClientOptions({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + tracesSampleRate: 1, + }), + ); + setCurrentClient(client); + client.init(); + + const endedSpans: Span[] = []; + client.on('spanEnd', span => endedSpans.push(span)); + return endedSpans; + } + + function runSpan(type: string, result: Record): Record { + const endedSpans = setupClient(); + const message = { type, event: {}, result } as Parameters[0]; + const span = createSpanFromMessage(message, {} as Parameters[1]); + enrichSpanOnEnd(span!, message, {} as Parameters[2]); + span?.end(); + return spanToStaticSpanJSON(endedSpans[0]!).data ?? {}; + } + + it('reads v5 `cachedInputTokens` when providerMetadata has no provider key', () => { + const data = runSpan('languageModelCall', { + usage: { + inputTokens: 120, + outputTokens: 10, + totalTokens: 130, + cachedInputTokens: 100, + }, + providerMetadata: { gateway: { routing: {} } }, + }); + + expect(data[GEN_AI_USAGE_INPUT_TOKENS]).toBe(120); + expect(data[GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS]).toBe(100); + expect(data[GEN_AI_USAGE_CACHE_CREATION_INPUT_TOKENS]).toBeUndefined(); + }); + + it('reads v6 `inputTokenDetails` cache read and write counts', () => { + const data = runSpan('languageModelCall', { + usage: { + inputTokens: 120, + inputTokenDetails: { + noCacheTokens: 20, + cacheReadTokens: 80, + cacheWriteTokens: 20, + }, + outputTokens: 10, + totalTokens: 130, + }, + }); + + expect(data[GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS]).toBe(80); + expect(data[GEN_AI_USAGE_CACHE_CREATION_INPUT_TOKENS]).toBe(20); + }); + + it('prefers `inputTokenDetails` over the deprecated `cachedInputTokens`', () => { + const data = runSpan('languageModelCall', { + usage: { + inputTokens: 120, + inputTokenDetails: { cacheReadTokens: 80 }, + cachedInputTokens: 5, + outputTokens: 10, + }, + }); + + expect(data[GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS]).toBe(80); + }); + + it('reads v7 `inputTokens` / `outputTokens` objects', () => { + const data = runSpan('languageModelCall', { + usage: { + inputTokens: { total: 120, noCache: 20, cacheRead: 80, cacheWrite: 20 }, + outputTokens: { total: 10, text: 10, reasoning: 0 }, + }, + }); + + expect(data[GEN_AI_USAGE_INPUT_TOKENS]).toBe(120); + expect(data[GEN_AI_USAGE_OUTPUT_TOKENS]).toBe(10); + expect(data[GEN_AI_USAGE_TOTAL_TOKENS]).toBe(130); + expect(data[GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS]).toBe(80); + expect(data[GEN_AI_USAGE_CACHE_CREATION_INPUT_TOKENS]).toBe(20); + }); + + it('sets nothing when the usage object carries no cache counts', () => { + const data = runSpan('languageModelCall', { + usage: { inputTokens: 120, outputTokens: 10, totalTokens: 130 }, + }); + + expect(data[GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS]).toBeUndefined(); + expect(data[GEN_AI_USAGE_CACHE_CREATION_INPUT_TOKENS]).toBeUndefined(); + }); + + it('keeps providerMetadata-derived counts over the SDK usage counts', () => { + const data = runSpan('languageModelCall', { + usage: { + inputTokens: 120, + inputTokenDetails: { cacheReadTokens: 80, cacheWriteTokens: 20 }, + cachedInputTokens: 80, + outputTokens: 10, + }, + providerMetadata: { + anthropic: { cacheReadInputTokens: 81, cacheCreationInputTokens: 21 }, + }, + }); + + expect(data[GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS]).toBe(81); + expect(data[GEN_AI_USAGE_CACHE_CREATION_INPUT_TOKENS]).toBe(21); + }); + + it('reads the counts from a streamed model call result', () => { + const data = runSpan( + 'languageModelCall', + streamedResultToChannelResult({ + text: 'hi', + toolCalls: [], + usage: { + inputTokens: { total: 120, cacheRead: 80, cacheWrite: 20 }, + outputTokens: { total: 10 }, + }, + }), + ); + + expect(data[GEN_AI_USAGE_INPUT_TOKENS]).toBe(120); + expect(data[GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS]).toBe(80); + expect(data[GEN_AI_USAGE_CACHE_CREATION_INPUT_TOKENS]).toBe(20); + }); +}); From f427cbeae30b64afdf10cadc01098776b7903835 Mon Sep 17 00:00:00 2001 From: Sergiy Dybskiy Date: Fri, 11 Sep 2026 17:50:53 -0400 Subject: [PATCH 2/4] Keep aggregated cache counts on root operations Co-authored-by: Claude --- .../server-utils/src/ai/vercel-ai/index.ts | 7 ++++++- .../vercel-ai/vercel-ai-dc-subscriber.ts | 7 ++----- .../vercel-ai/cache-tokens.test.ts | 21 ++++++++++++++----- 3 files changed, 24 insertions(+), 11 deletions(-) diff --git a/packages/server-utils/src/ai/vercel-ai/index.ts b/packages/server-utils/src/ai/vercel-ai/index.ts index ec491bd79e6c..76a355fdc588 100644 --- a/packages/server-utils/src/ai/vercel-ai/index.ts +++ b/packages/server-utils/src/ai/vercel-ai/index.ts @@ -92,7 +92,12 @@ export function getProviderMetadataAttributes(providerMetadata: unknown): Record * They must not be written onto a span that reports usage aggregated across steps * (`gen_ai.invoke_agent`), where they would replace the aggregate with one step's figures. */ -export const LAST_STEP_ONLY_USAGE_KEYS = new Set([GEN_AI_USAGE_OUTPUT_TOKENS, GEN_AI_USAGE_TOTAL_TOKENS]); +export const LAST_STEP_ONLY_USAGE_KEYS = new Set([ + GEN_AI_USAGE_OUTPUT_TOKENS, + GEN_AI_USAGE_TOTAL_TOKENS, + GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS, + GEN_AI_USAGE_CACHE_CREATION_INPUT_TOKENS, +]); /** * Sets an attribute only if the value is not null or undefined. diff --git a/packages/server-utils/src/integrations/vercel-ai/vercel-ai-dc-subscriber.ts b/packages/server-utils/src/integrations/vercel-ai/vercel-ai-dc-subscriber.ts index ff7e3c43308b..5f5305af1e10 100644 --- a/packages/server-utils/src/integrations/vercel-ai/vercel-ai-dc-subscriber.ts +++ b/packages/server-utils/src/integrations/vercel-ai/vercel-ai-dc-subscriber.ts @@ -572,7 +572,6 @@ export function enrichSpanOnEnd( if (totalTokens !== undefined) { span.setAttribute(GEN_AI_USAGE_TOTAL_TOKENS, totalTokens); } - // Set before the `providerMetadata` attributes below so a provider-reported count still wins. span.setAttributes(cacheTokenAttributes(usage)); } @@ -641,10 +640,8 @@ function tokenCount(value: unknown): number | undefined { } /** - * Reads the AI SDK's own cache token counts from a usage object. v5 reports `cachedInputTokens`, v6 - * adds `inputTokenDetails`, and v7 model-call usage nests them under `inputTokens`. `providerMetadata` - * only carries cache counts under a provider key, so through the AI Gateway (`gateway` key) these - * normalized counts are the sole source. + * Cache token counts as the AI SDK normalizes them: v5 `cachedInputTokens`, v6 `inputTokenDetails`, + * v7 `inputTokens.{cacheRead,cacheWrite}`. */ function cacheTokenAttributes(usage: Record): Record { const inputTokens = isObjectLike(usage.inputTokens) ? usage.inputTokens : undefined; diff --git a/packages/server-utils/test/integrations/vercel-ai/cache-tokens.test.ts b/packages/server-utils/test/integrations/vercel-ai/cache-tokens.test.ts index 2a483b0277e6..79f3e60f3b15 100644 --- a/packages/server-utils/test/integrations/vercel-ai/cache-tokens.test.ts +++ b/packages/server-utils/test/integrations/vercel-ai/cache-tokens.test.ts @@ -15,11 +15,6 @@ import { } from '../../../src/integrations/vercel-ai/vercel-ai-dc-subscriber'; import { getDefaultTestClientOptions, TestClient } from '../../mocks/client'; -/** - * The AI SDK normalizes cache token counts into its usage object. `providerMetadata` carries them - * only under a provider key, so through the AI Gateway (`gateway` key) the usage object is the only - * source. - */ describe('Vercel AI SDK cache tokens', () => { beforeEach(() => { getMainCarrier().__SENTRY__ = undefined; @@ -141,6 +136,22 @@ describe('Vercel AI SDK cache tokens', () => { expect(data[GEN_AI_USAGE_CACHE_CREATION_INPUT_TOKENS]).toBe(21); }); + it('keeps the aggregated SDK counts on a root operation over last-step providerMetadata', () => { + const data = runSpan('generateText', { + usage: { + inputTokens: 9500, + inputTokenDetails: { cacheReadTokens: 8000, cacheWriteTokens: 500 }, + outputTokens: 40, + }, + providerMetadata: { + anthropic: { cacheReadInputTokens: 3000, cacheCreationInputTokens: 0 }, + }, + }); + + expect(data[GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS]).toBe(8000); + expect(data[GEN_AI_USAGE_CACHE_CREATION_INPUT_TOKENS]).toBe(500); + }); + it('reads the counts from a streamed model call result', () => { const data = runSpan( 'languageModelCall', From 1bd0c23c8035acd5278ac36c4aa94ff28b4d9374 Mon Sep 17 00:00:00 2001 From: Sergiy Dybskiy Date: Fri, 11 Sep 2026 19:39:23 -0400 Subject: [PATCH 3/4] simplify Co-authored-by: Abdelrahman Awad --- .../src/integrations/vercel-ai/vercel-ai-dc-subscriber.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/server-utils/src/integrations/vercel-ai/vercel-ai-dc-subscriber.ts b/packages/server-utils/src/integrations/vercel-ai/vercel-ai-dc-subscriber.ts index 5f5305af1e10..8571a067d8ad 100644 --- a/packages/server-utils/src/integrations/vercel-ai/vercel-ai-dc-subscriber.ts +++ b/packages/server-utils/src/integrations/vercel-ai/vercel-ai-dc-subscriber.ts @@ -652,8 +652,8 @@ function cacheTokenAttributes(usage: Record): Record Date: Fri, 11 Sep 2026 21:38:51 -0400 Subject: [PATCH 4/4] Skip unreported cache counts instead of setting them to undefined Co-authored-by: Claude --- .../vercel-ai/vercel-ai-dc-subscriber.ts | 32 ++++++++++++------- .../vercel-ai/cache-tokens.test.ts | 18 ++++++++++- 2 files changed, 37 insertions(+), 13 deletions(-) diff --git a/packages/server-utils/src/integrations/vercel-ai/vercel-ai-dc-subscriber.ts b/packages/server-utils/src/integrations/vercel-ai/vercel-ai-dc-subscriber.ts index 8571a067d8ad..924cde857b6b 100644 --- a/packages/server-utils/src/integrations/vercel-ai/vercel-ai-dc-subscriber.ts +++ b/packages/server-utils/src/integrations/vercel-ai/vercel-ai-dc-subscriber.ts @@ -358,9 +358,9 @@ function enrichInvokeAgentFromStream( addTokensToSpan(span, GEN_AI_USAGE_INPUT_TOKENS, input); addTokensToSpan(span, GEN_AI_USAGE_OUTPUT_TOKENS, output); addTokensToSpan(span, GEN_AI_USAGE_TOTAL_TOKENS, tokenCount(usage.totalTokens) ?? sum(input, output)); - for (const [attribute, value] of Object.entries(cacheTokenAttributes(usage))) { - addTokensToSpan(span, attribute, value); - } + const { cacheRead, cacheWrite } = cacheTokens(usage); + addTokensToSpan(span, GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS, cacheRead); + addTokensToSpan(span, GEN_AI_USAGE_CACHE_CREATION_INPUT_TOKENS, cacheWrite); } if (recordOutputs) { @@ -572,7 +572,7 @@ export function enrichSpanOnEnd( if (totalTokens !== undefined) { span.setAttribute(GEN_AI_USAGE_TOTAL_TOKENS, totalTokens); } - span.setAttributes(cacheTokenAttributes(usage)); + setCacheTokens(span, usage); } // Match the OTel integration: finish reasons live on the model-call (`generate_content`) span, not @@ -643,17 +643,25 @@ function tokenCount(value: unknown): number | undefined { * Cache token counts as the AI SDK normalizes them: v5 `cachedInputTokens`, v6 `inputTokenDetails`, * v7 `inputTokens.{cacheRead,cacheWrite}`. */ -function cacheTokenAttributes(usage: Record): Record { +function setCacheTokens(span: Span, usage: Record): void { + const { cacheRead, cacheWrite } = cacheTokens(usage); + if (cacheRead !== undefined) { + span.setAttribute(GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS, cacheRead); + } + if (cacheWrite !== undefined) { + span.setAttribute(GEN_AI_USAGE_CACHE_CREATION_INPUT_TOKENS, cacheWrite); + } +} + +function cacheTokens(usage: Record): { cacheRead?: number; cacheWrite?: number } { const inputTokens = isObjectLike(usage.inputTokens) ? usage.inputTokens : undefined; const inputTokenDetails = isObjectLike(usage.inputTokenDetails) ? usage.inputTokenDetails : undefined; - const cacheRead = - asNumber(inputTokens?.cacheRead) ?? - asNumber(inputTokenDetails?.cacheReadTokens) ?? - asNumber(usage.cachedInputTokens); - const cacheWrite = asNumber(inputTokens?.cacheWrite) ?? asNumber(inputTokenDetails?.cacheWriteTokens); return { - [GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS]: cacheRead, - [GEN_AI_USAGE_CACHE_CREATION_INPUT_TOKENS]: cacheWrite, + cacheRead: + asNumber(inputTokens?.cacheRead) ?? + asNumber(inputTokenDetails?.cacheReadTokens) ?? + asNumber(usage.cachedInputTokens), + cacheWrite: asNumber(inputTokens?.cacheWrite) ?? asNumber(inputTokenDetails?.cacheWriteTokens), }; } diff --git a/packages/server-utils/test/integrations/vercel-ai/cache-tokens.test.ts b/packages/server-utils/test/integrations/vercel-ai/cache-tokens.test.ts index 79f3e60f3b15..54f249be0591 100644 --- a/packages/server-utils/test/integrations/vercel-ai/cache-tokens.test.ts +++ b/packages/server-utils/test/integrations/vercel-ai/cache-tokens.test.ts @@ -39,10 +39,15 @@ describe('Vercel AI SDK cache tokens', () => { return endedSpans; } - function runSpan(type: string, result: Record): Record { + function runSpan( + type: string, + result: Record, + existingAttributes: Record = {}, + ): Record { const endedSpans = setupClient(); const message = { type, event: {}, result } as Parameters[0]; const span = createSpanFromMessage(message, {} as Parameters[1]); + span!.setAttributes(existingAttributes); enrichSpanOnEnd(span!, message, {} as Parameters[2]); span?.end(); return spanToStaticSpanJSON(endedSpans[0]!).data ?? {}; @@ -119,6 +124,17 @@ describe('Vercel AI SDK cache tokens', () => { expect(data[GEN_AI_USAGE_CACHE_CREATION_INPUT_TOKENS]).toBeUndefined(); }); + it('leaves an existing count in place when the SDK usage does not report it', () => { + const data = runSpan( + 'languageModelCall', + { usage: { inputTokens: 120, cachedInputTokens: 80, outputTokens: 10 } }, + { [GEN_AI_USAGE_CACHE_CREATION_INPUT_TOKENS]: 20 }, + ); + + expect(data[GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS]).toBe(80); + expect(data[GEN_AI_USAGE_CACHE_CREATION_INPUT_TOKENS]).toBe(20); + }); + it('keeps providerMetadata-derived counts over the SDK usage counts', () => { const data = runSpan('languageModelCall', { usage: {