Skip to content

Commit 85dcd11

Browse files
zkasuranclaude
andcommitted
fix(server-utils): Include Gemini reasoning tokens in Vercel AI token usage
Gemini reports its reasoning ("thoughts") tokens separately from the candidate output count, so the Vercel AI SDK's `outputTokens` only covers the visible answer. `getProviderMetadataAttributes()` never looked at the Google/Vertex `usageMetadata`, so `gen_ai.usage.output_tokens` dropped the reasoning tokens and the total was computed as input + candidate-only output. For a real Gemini reasoning response with usageMetadata {promptTokenCount:14, candidatesTokenCount:1, thoughtsTokenCount:100, totalTokenCount:115} the span emitted output_tokens=1 and total=15 instead of output_tokens=101 and total=115. Read the google/vertex `usageMetadata` and derive output tokens as `candidatesTokenCount + thoughtsTokenCount`, set the total from the real `totalTokenCount`, and record the reasoning breakdown under `gen_ai.usage.reasoning.output_tokens`. Deriving output from the raw candidate + thoughts counts (rather than adding onto the SDK value) keeps it correct even if a future SDK version already folds reasoning into `outputTokens`. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 70b9e0e commit 85dcd11

3 files changed

Lines changed: 139 additions & 0 deletions

File tree

packages/server-utils/src/ai/vercel-ai/index.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -574,6 +574,21 @@ export function getProviderMetadataAttributes(providerMetadata: unknown): Record
574574
setAttributeIfDefined(attributes, 'gen_ai.usage.input_tokens.cache_miss', metadata.deepseek.promptCacheMissTokens);
575575
}
576576

577+
// Google (v5 uses 'google', v6 Vertex AI uses 'vertex'). Gemini reports its reasoning ("thoughts")
578+
// tokens separately from the candidate output count, so the SDK's `outputTokens` covers only the
579+
// visible answer. `gen_ai.usage.output_tokens` must include reasoning tokens, so recompute it (and
580+
// the total) from the raw usageMetadata. Deriving output from `candidatesTokenCount + thoughtsTokenCount`
581+
// rather than adding onto the existing value keeps this correct even if a future SDK version already
582+
// folds reasoning into `outputTokens`.
583+
const googleUsage = (metadata.google ?? metadata.vertex)?.usageMetadata;
584+
if (googleUsage && typeof googleUsage.thoughtsTokenCount === 'number' && googleUsage.thoughtsTokenCount > 0) {
585+
setAttributeIfDefined(attributes, GEN_AI_USAGE_REASONING_OUTPUT_TOKENS, googleUsage.thoughtsTokenCount);
586+
if (typeof googleUsage.candidatesTokenCount === 'number') {
587+
attributes[GEN_AI_USAGE_OUTPUT_TOKENS] = googleUsage.candidatesTokenCount + googleUsage.thoughtsTokenCount;
588+
}
589+
setAttributeIfDefined(attributes, GEN_AI_USAGE_TOTAL_TOKENS, googleUsage.totalTokenCount);
590+
}
591+
577592
return attributes;
578593
}
579594

packages/server-utils/src/ai/vercel-ai/vercel-ai-attributes.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -446,6 +446,20 @@ export interface GoogleGenerativeAIProviderMetadata {
446446
* @see https://cloud.google.com/vertex-ai/generative-ai/docs/multimodal/configure-safety-filters
447447
*/
448448
safetyRatings?: null | unknown;
449+
450+
/**
451+
* Raw token usage returned by the Gemini API. Reasoning ("thoughts") tokens are reported here in
452+
* `thoughtsTokenCount`, separately from the candidate output count, so they have to be added back
453+
* into `gen_ai.usage.output_tokens`.
454+
* @see https://ai.google.dev/api/generate-content#UsageMetadata
455+
* @see https://github.com/vercel/ai/blob/main/packages/google/src/google-language-model.ts
456+
*/
457+
usageMetadata?: null | {
458+
promptTokenCount?: number;
459+
candidatesTokenCount?: number;
460+
thoughtsTokenCount?: number;
461+
totalTokenCount?: number;
462+
};
449463
}
450464

451465
/**
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import { describe, expect, it } from 'vitest';
2+
import type { SpanJSON } from '@sentry/core';
3+
import { addVercelAiProcessors, getProviderMetadataAttributes } from '../../../../src/ai/vercel-ai';
4+
import { getDefaultTestClientOptions, TestClient } from '../../../mocks/client';
5+
6+
function processSpan(data: SpanJSON['data']): SpanJSON {
7+
const options = getDefaultTestClientOptions({ tracesSampleRate: 1.0 });
8+
const client = new TestClient(options);
9+
client.init();
10+
addVercelAiProcessors(client);
11+
12+
const mockSpan: SpanJSON = {
13+
description: 'ai.generateText.doGenerate',
14+
span_id: 'test-span-id',
15+
trace_id: 'test-trace-id',
16+
start_timestamp: 1000,
17+
timestamp: 2000,
18+
origin: 'auto.vercelai.otel',
19+
data,
20+
};
21+
22+
const event = {
23+
type: 'transaction' as const,
24+
spans: [mockSpan],
25+
};
26+
27+
const eventProcessor = client['_eventProcessors'].find(processor => processor.id === 'VercelAiEventProcessor');
28+
expect(eventProcessor).toBeDefined();
29+
30+
return eventProcessor!(event, {})!.spans![0]!;
31+
}
32+
33+
// Real usage seen from a Gemini reasoning model: the candidate output is small but the model spent
34+
// most of its budget on hidden reasoning ("thoughts"). The AI SDK reports the candidate count as
35+
// `outputTokens` and exposes the reasoning count only through `providerMetadata.google.usageMetadata`.
36+
const GEMINI_REASONING_METADATA = {
37+
google: {
38+
groundingMetadata: null,
39+
safetyRatings: null,
40+
usageMetadata: {
41+
promptTokenCount: 14,
42+
candidatesTokenCount: 1,
43+
thoughtsTokenCount: 100,
44+
totalTokenCount: 115,
45+
},
46+
},
47+
};
48+
49+
describe('vercel-ai Gemini reasoning tokens', () => {
50+
it('includes reasoning (thoughts) tokens in output and total for a Gemini doGenerate span', () => {
51+
const span = processSpan({
52+
'ai.usage.promptTokens': 14,
53+
'ai.usage.completionTokens': 1,
54+
'ai.response.providerMetadata': JSON.stringify(GEMINI_REASONING_METADATA),
55+
});
56+
57+
expect(span.data?.['gen_ai.usage.input_tokens']).toBe(14);
58+
// output must include the 100 reasoning tokens, not just the single candidate token
59+
expect(span.data?.['gen_ai.usage.output_tokens']).toBe(101);
60+
expect(span.data?.['gen_ai.usage.reasoning.output_tokens']).toBe(100);
61+
// total is the real Gemini total, not input + candidate-only output (which would be 15)
62+
expect(span.data?.['gen_ai.usage.total_tokens']).toBe(115);
63+
});
64+
65+
it('derives reasoning-inclusive output/total from google usageMetadata', () => {
66+
const attributes = getProviderMetadataAttributes(GEMINI_REASONING_METADATA);
67+
68+
expect(attributes['gen_ai.usage.output_tokens']).toBe(101);
69+
expect(attributes['gen_ai.usage.reasoning.output_tokens']).toBe(100);
70+
expect(attributes['gen_ai.usage.total_tokens']).toBe(115);
71+
});
72+
73+
it('reads the v6 vertex provider metadata key too', () => {
74+
const attributes = getProviderMetadataAttributes({
75+
vertex: {
76+
usageMetadata: {
77+
promptTokenCount: 20,
78+
candidatesTokenCount: 5,
79+
thoughtsTokenCount: 40,
80+
totalTokenCount: 65,
81+
},
82+
},
83+
});
84+
85+
expect(attributes['gen_ai.usage.output_tokens']).toBe(45);
86+
expect(attributes['gen_ai.usage.reasoning.output_tokens']).toBe(40);
87+
expect(attributes['gen_ai.usage.total_tokens']).toBe(65);
88+
});
89+
90+
it('leaves non-reasoning Gemini responses untouched', () => {
91+
const span = processSpan({
92+
'ai.usage.promptTokens': 30,
93+
'ai.usage.completionTokens': 12,
94+
'ai.response.providerMetadata': JSON.stringify({
95+
google: {
96+
usageMetadata: {
97+
promptTokenCount: 30,
98+
candidatesTokenCount: 12,
99+
totalTokenCount: 42,
100+
},
101+
},
102+
}),
103+
});
104+
105+
expect(span.data?.['gen_ai.usage.input_tokens']).toBe(30);
106+
expect(span.data?.['gen_ai.usage.output_tokens']).toBe(12);
107+
expect(span.data?.['gen_ai.usage.total_tokens']).toBe(42);
108+
expect(span.data?.['gen_ai.usage.reasoning.output_tokens']).toBeUndefined();
109+
});
110+
});

0 commit comments

Comments
 (0)