fix(server-utils): Include Gemini reasoning tokens in Vercel AI token usage - #23433
Conversation
2c74804 to
85dcd11
Compare
|
👋 @logaretm, @stephanie-anderson — Please review this PR when you get a chance! |
3 similar comments
|
👋 @logaretm, @stephanie-anderson — Please review this PR when you get a chance! |
|
👋 @logaretm, @stephanie-anderson — Please review this PR when you get a chance! |
|
👋 @logaretm, @stephanie-anderson — Please review this PR when you get a chance! |
|
Thanks for digging in! I verified this, it affects v10 (ai v5/v6), which still has OTel path. The issue is the base, this targets develop v11, but #23384 already removed the Vercel AI OTel path, so it won’t compile. Could you rebase onto v10? The file is packages/core/src/tracing/vercel-ai/index.ts there. Two things to fix while you rebase:
|
Gemini reports reasoning ("thoughts") tokens separately from the candidate output
count, so the AI SDK's `outputTokens` covers only the visible answer and the count
reaches us only through `providerMetadata.google.usageMetadata`. A span built from
`ai.usage.*` alone undercounts output, and the total with it.
Output is recomputed as `candidatesTokenCount + thoughtsTokenCount` rather than added
onto the existing value, so it stays correct if a future SDK version folds reasoning in
itself. `candidatesTokenCount` is optional, so output and total are written together or
not at all: a thoughts-inclusive total beside a candidate-only output would describe a
span whose parts do not add up.
An `invoke_agent` span carries the summed usage of every step while `providerMetadata`
describes the last step alone, so writing output or total from it would replace the
aggregate with one step's figures. Both writes are skipped there. The reasoning count is
not an aggregate and nothing else carries it, so it is still recorded.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
85dcd11 to
d2612c4
Compare
|
Thanks, that was the right call on the base. Rebased onto
Multi-step test. Added, Verified on the rebased head, One thing I left as you described it rather than widening: the gate is on the operation name, not on whether the span has children, so a single-step |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 3 potential issues.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Want reviews to match your repository better? Bugbot Learning can learn team-specific rules from PR activity. A team admin can enable Learning in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit d2612c4. Configure here.
|
Semgrep found 1 Risk: Affected versions of esbuild are vulnerable to Origin Validation Error. esbuild's development server responds to every request, including Server-Sent Events connections, with Manual Review Advice: A vulnerability from this advisory is reachable if you run esbuild with the Fix: Upgrade this library to at least version 0.25.0 at sentry-javascript/yarn.lock:14996. Reference(s): GHSA-67mh-4wv8-2f99 Semgrep found 2 Risk: Affected versions of sharp are vulnerable to Dependency on Vulnerable Third-Party Component. sharp bundles a vulnerable version of the native libvips library, inheriting four memory-safety flaws: an integer overflow leading to a heap-based buffer overflow in the VIPS loader ( Fix: Upgrade this library to at least version 0.35.0 at sentry-javascript/yarn.lock:24915. Reference(s): GHSA-f88m-g3jw-g9cj Semgrep found 1 Risk: Affected versions of vite and vite-plus are vulnerable to Exposure of Sensitive Information to an Unauthorized Actor / Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal'). Vite's server.fs.deny blocklist—which protects sensitive files such as .env and certificate files from being served—can be bypassed on Windows using alternate path representations (NTFS Alternate Data Stream syntax like Manual Review Advice: A vulnerability from this advisory is reachable if you expose the Vite dev server or vite-plus to the network by configuring a non-loopback address using the --host CLI flag on Windows Fix: Upgrade this library to at least version 6.4.3 at sentry-javascript/yarn.lock:27422. Reference(s): https://euvd.enisa.europa.eu/vulnerability/EUVD-2026-38303, GHSA-fx2h-pf6j-xcff, CVE-2026-53571 Semgrep found 1 Risk: Affected versions of @babel/traverse and babel-traverse are vulnerable to Incomplete List of Disallowed Inputs / Incorrect Comparison. Compiling untrusted code with Babel using plugins that invoke the internal path.evaluate() or path.evaluateTruthy() methods (for example @babel/plugin-transform-runtime, @babel/preset-env with useBuiltIns, or any polyfill‐provider plugin) allows a maliciously crafted AST to execute arbitrary code on the build machine during compilation. Manual Review Advice: A vulnerability from this advisory is reachable if you use Babel to compile untrusted JavaScript Fix: There are no safe versions of this library available for upgrade. Library included at sentry-javascript/yarn.lock:2594. Reference(s): https://euvd.enisa.europa.eu/vulnerability/EUVD-2023-2669, GHSA-67hx-6x53-jw92, CVE-2023-45133 |
The conventions define `gen_ai.usage.reasoning.output_tokens` as a subset of `gen_ai.usage.output_tokens`, which is itself reasoning-inclusive. Two spans broke that: a model call whose response was truncated during thinking reported reasoning against the SDK's candidate-only output, and an `invoke_agent` parent reported the last step's reasoning against an output the gate deliberately leaves un-recomputed. Treat an absent `candidatesTokenCount` as zero rather than skipping the recompute. Gemini omits the field when no candidate tokens were produced, so the reasoning tokens belong in output either way; skipping left the span claiming zero output for a call that spent its whole budget thinking. Gate reasoning alongside output and total on `invoke_agent`. It is a subset of an output that span never recomputes, and the accumulator never sums it, so the last step's count would stand in for the whole call. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018TR1cvQA7t6T2saCrHwwUh
|
Looks good. I pushed a quick commit, hope you don't mind :) I went back to the conventions, output_tokens is defined as reasoning-inclusive, and reasoning.output_tokens as "a subset of gen_ai.usage.output_tokens". So reasoning should only ever appear next to an output that already contains it, so:
We said providerMetadata is last-step-only, so don't write output/total on parents from it. Then I told you to exempt reasoning, but it comes from that same last-step object, so the exemption doesn't hold up. Your two-step fixture shows it: the parent gets step two's reasoning: 100 when the call actually spent 80 + 100 = 180.
Your version is consistent, and that was the right instinct — it just drops the recompute, so a call truncated during thinking reports output_tokens: 0 after spending 500, with reasoning: 500 next to it. Gemini omits the field when there were no candidate tokens, so zero is the real value. Filed #23993 as a follow up. |
…` spans `getProviderMetadataAttributes` now derives `gen_ai.usage.output_tokens` and `gen_ai.usage.total_tokens`, but only one of its three callers dropped them on spans that report usage aggregated across steps. The channel and orchestrion subscribers call it directly rather than through `addProviderMetadataToAttributes`, so a top-level operation's span took the last step's figures over its own aggregate. Reachable on `ai` v4, where `generateText` accumulates `usage` across steps (`addLanguageModelUsage`) while exposing the final step's `providerMetadata`: a multi-step Gemini call reported the last step's output and total against the summed input. On v5+ the result's `usage` is the final step's, so the two agree and nothing changes. Export the key set from core and apply it in `enrichSpanOnEnd`, which both subscribers share, so all three callers follow the same rule. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018TR1cvQA7t6T2saCrHwwUh
The last-step usage gate pushed the function to 34. Extracting it keeps the same behavior without tripping oxlint.
|
Merged to get it out with the next release. |
|
Thanks alot @RulaKhaled |
… usage (#24066) Forward port of #23433, which landed on `v10` because the Vercel AI OTel span processing it originally targeted was removed here in #23384. Without this, upgrading v10 → v11 loses the fix. Gemini reports its reasoning ("thoughts") tokens separately from the candidate output count, so on `ai` v4/v5 the SDK's `outputTokens` covers only the visible answer and the reasoning count reaches us solely through `providerMetadata.google.usageMetadata`. `getProviderMetadataAttributes()` handled OpenAI, Anthropic, Bedrock and DeepSeek but never looked at the Google/Vertex block. The [conventions](https://getsentry.github.io/sentry-conventions/attributes/gen_ai/) define `gen_ai.usage.output_tokens` as reasoning-inclusive, so these spans were under-reporting rather than merely missing a breakdown — a real Gemini response of `{promptTokenCount: 14, candidatesTokenCount: 1, thoughtsTokenCount: 100, totalTokenCount: 115}` emitted `output 1 / total 15` instead of `output 101 / total 115`. **Known limitations, tracked in #23993:** `invoke_agent` spans carry no reasoning count at all, and nothing sums it from their children the way `applyAccumulatedTokens` does for input and output. Separately, `enrichSpanOnEnd` never reads `usage.outputTokenDetails.reasoningTokens`, which `ai` v6+ supplies directly — reading it would populate the reasoning breakdown for Gemini, OpenAI and Anthropic at once, and is the more valuable change for anyone on a current SDK version. This PR only helps v4/v5 users. --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

Gemini reasoning models undercount their output tokens in the Vercel AI integration. Gemini reports its reasoning ("thoughts") tokens separately from the visible candidate output, so the AI SDK's
outputTokenscovers only the answer and exposes the reasoning count throughproviderMetadata.google.usageMetadata.thoughtsTokenCount.getProviderMetadataAttributes()handled OpenAI, Anthropic, Bedrock and DeepSeek metadata but never looked at the Google/Vertex block, so the reasoning tokens were dropped fromgen_ai.usage.output_tokensand the total was computed as input + candidate-only output.Per the gen_ai token usage conventions,
gen_ai.usage.output_tokensincludes reasoning tokens. The fix reads thegoogle/vertexusageMetadata, derives output ascandidatesTokenCount + thoughtsTokenCount, sets the total from the realtotalTokenCountand records the reasoning breakdown undergen_ai.usage.reasoning.output_tokens. Both the OTel span path and theaitracing-channel path go through this shared helper, so both emit the corrected shape.Deriving output from the raw candidate + thoughts counts (rather than adding reasoning onto the existing SDK value) is deliberate: it stays correct even if a future AI SDK version folds reasoning into
outputTokensitself, so it cannot double count. The change is gated onthoughtsTokenCount > 0, so non-reasoning Gemini responses are left exactly as they were.Root cause
getProviderMetadataAttributes()inpackages/server-utils/src/ai/vercel-ai/index.tshad nogoogle/vertexbranch. The total is also computed from input + output before provider metadata is applied. For a real Gemini reasoning response:the emitted span attributes were, before the fix:
{"gen_ai.usage.output_tokens":1,"gen_ai.usage.input_tokens":14,"gen_ai.usage.total_tokens":15}and after the fix:
{"gen_ai.usage.output_tokens":101,"gen_ai.usage.input_tokens":14,"gen_ai.usage.total_tokens":115,"gen_ai.usage.reasoning.output_tokens":100}A vitest covering the OTel processor path and the shared
getProviderMetadataAttributes()helper (including the v6vertexkey and a non-reasoning regression case) is added inpackages/server-utils/test/ai/lib/tracing/vercel-ai-reasoning-tokens.test.ts.yarn lint) & (yarn test).AI assistance (Claude, Anthropic) was used in developing this change. The design, review and verification were done by the author. Verified locally before submitting:
yarn testinpackages/server-utils(377 passing, 4 new),yarn lint(oxlint, clean),oxfmt --check(clean) andyarn build:types(tsc, clean).