Skip to content

fix(server-utils): Include Gemini reasoning tokens in Vercel AI token usage - #23433

Merged
RulaKhaled merged 5 commits into
getsentry:v10from
zkasuran:fix/vercel-ai-gemini-reasoning-tokens
Sep 4, 2026
Merged

fix(server-utils): Include Gemini reasoning tokens in Vercel AI token usage#23433
RulaKhaled merged 5 commits into
getsentry:v10from
zkasuran:fix/vercel-ai-gemini-reasoning-tokens

Conversation

@zkasuran

Copy link
Copy Markdown
Contributor

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 outputTokens covers only the answer and exposes the reasoning count through providerMetadata.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 from gen_ai.usage.output_tokens and the total was computed as input + candidate-only output.

Per the gen_ai token usage conventions, gen_ai.usage.output_tokens includes reasoning tokens. The fix reads the google/vertex usageMetadata, derives output as candidatesTokenCount + thoughtsTokenCount, sets the total from the real totalTokenCount and records the reasoning breakdown under gen_ai.usage.reasoning.output_tokens. Both the OTel span path and the ai tracing-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 outputTokens itself, so it cannot double count. The change is gated on thoughtsTokenCount > 0, so non-reasoning Gemini responses are left exactly as they were.

Root cause

getProviderMetadataAttributes() in packages/server-utils/src/ai/vercel-ai/index.ts had no google/vertex branch. The total is also computed from input + output before provider metadata is applied. For a real Gemini reasoning response:

usageMetadata { promptTokenCount: 14, candidatesTokenCount: 1, thoughtsTokenCount: 100, totalTokenCount: 115 }
ai-sdk result.usage { inputTokens: 14, outputTokens: 1, totalTokens: 115, reasoningTokens: 100 }

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 v6 vertex key and a non-reasoning regression case) is added in packages/server-utils/test/ai/lib/tracing/vercel-ai-reasoning-tokens.test.ts.


  • If you've added code that should be tested, please add tests.
  • Ensure your code lints and the test suite passes (yarn lint) & (yarn test).
  • Link an issue if there is one related to your pull request. If no issue is linked, one will be auto-generated and linked.

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 test in packages/server-utils (377 passing, 4 new), yarn lint (oxlint, clean), oxfmt --check (clean) and yarn build:types (tsc, clean).

@zkasuran
zkasuran marked this pull request as ready for review August 17, 2026 09:56
@zkasuran
zkasuran requested a review from a team as a code owner August 17, 2026 09:56
@zkasuran
zkasuran requested review from logaretm and stephanie-anderson and removed request for a team August 17, 2026 09:56
@zkasuran
zkasuran force-pushed the fix/vercel-ai-gemini-reasoning-tokens branch from 2c74804 to 85dcd11 Compare August 17, 2026 09:56
@github-actions

Copy link
Copy Markdown
Contributor

👋 @logaretm, @stephanie-anderson — Please review this PR when you get a chance!

3 similar comments
@github-actions

Copy link
Copy Markdown
Contributor

👋 @logaretm, @stephanie-anderson — Please review this PR when you get a chance!

@github-actions

Copy link
Copy Markdown
Contributor

👋 @logaretm, @stephanie-anderson — Please review this PR when you get a chance!

@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

👋 @logaretm, @stephanie-anderson — Please review this PR when you get a chance!

@Lms24
Lms24 requested review from RulaKhaled and andreiborza and removed request for logaretm and stephanie-anderson September 2, 2026 10:40
@RulaKhaled

Copy link
Copy Markdown
Collaborator

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:

  1. total_tokens is written outside the candidatesTokenCount check. If Gemini omits that field (it’s optional), the span keeps candidate-only output_tokens but takes the thoughts-inclusive total.
  2. invoke_agent parents already have summed ai.usage.*; providerMetadata is last-step only, so writing output_tokens / total_tokens from it clobbers the aggregate (e.g. input 900, output 50, total 450). The event-processor path hides this via applyAccumulatedTokens; the streamed path ships it. Gate those two writes in addProviderMetadataToAttributes when operation_name === 'invoke_agent'. Leave reasoning.output_tokens. I recommend adding a multi-step test, i think current ones are all single-step

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>
@zkasuran
zkasuran changed the base branch from develop to v10 September 3, 2026 02:53
@zkasuran
zkasuran requested review from a team as code owners September 3, 2026 02:53
@zkasuran
zkasuran requested review from msonnb, mydea and nicohrubec and removed request for a team September 3, 2026 02:53
@zkasuran
zkasuran force-pushed the fix/vercel-ai-gemini-reasoning-tokens branch from 85dcd11 to d2612c4 Compare September 3, 2026 02:53
@zkasuran

zkasuran commented Sep 3, 2026

Copy link
Copy Markdown
Contributor Author

Thanks, that was the right call on the base. Rebased onto v10 and retargeted this PR, so it now sits on packages/core/src/tracing/vercel-ai/index.ts. All three of your points are in.

total_tokens outside the candidatesTokenCount check. Fixed. Both writes now sit inside it, so output and total move together or neither moves. leaves output and total alone when candidatesTokenCount is absent covers that case: output stays at the SDK's candidate-only 1, total is left unset rather than taking the thoughts-inclusive 115. The reasoning count is still reported, because nothing else on the span carries it.

invoke_agent parents. Gated in addProviderMetadataToAttributes, keyed on gen_ai.operation.name === 'invoke_agent', which is already set by the time that function runs. output_tokens and total_tokens are skipped there and reasoning.output_tokens is left alone. Your figures reproduce: without the gate a parent with summed input 900 and output 350 came back as output 101 and total 115 from the last step's metadata.

Multi-step test. Added, keeps a multi-step call consistent: one invoke_agent parent over two doGenerate children, each child with its own usageMetadata. Each step reports its own reasoning-inclusive output (100 and 101, reasoning 80 and 100) while the parent keeps its aggregate. The single-step aggregate case is a separate test so the two failure modes report separately.

Verified on the rebased head, packages/core vitest, 15 passed across the five vercel-ai* files. Each half of the change is pinned rather than merely covered: reverting the invoke_agent gate alone turns exactly the two aggregate tests red. Reverting the Google block turns all five red.

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 generateText parent also keeps its ai.usage.* output instead of the recomputed one. Those two are equal in the single-step case, so nothing is lost. Keying on children would mean reaching outside the attribute bag. Happy to change it if you would rather it were narrower.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 3 potential issues.

Fix All in Cursor

❌ 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.

Comment thread packages/core/src/tracing/vercel-ai/index.ts Outdated
Comment thread packages/core/src/tracing/vercel-ai/index.ts
Comment thread packages/core/test/lib/tracing/vercel-ai-reasoning-tokens.test.ts Outdated
@semgrep-code-getsentry

Copy link
Copy Markdown

Semgrep found 1 ssc-90df8fd1-2d4f-4e7b-a8aa-dfa15f51f5bf finding:

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 Access-Control-Allow-Origin: *. Any website a developer visits can therefore make cross-origin requests to the local dev server and read the responses, leaking bundled source code, source maps, and served file paths. Starting the dev server via serve() reaches the vulnerable code path.

Manual Review Advice: A vulnerability from this advisory is reachable if you run esbuild with the --serve flag to start the development server

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 ssc-8ec0dd3e-cfd5-4a9b-9479-f5400432931f findings:

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 (vipsload, CVE-2026-33327), an integer overflow in the GIF loader (gifload, CVE-2026-33328) causing a denial of service on 32-bit hosts only, a heap-based buffer overflow in the TIFF loader (tiffload, CVE-2026-35591) when handling JPEG or JPEG2000-encoded tiles, and an out-of-bounds read in the EXIF directory decoder (CVE-2026-35590). An attacker who can supply a crafted image can crash the process or corrupt heap memory. Because sharp selects the libvips loader by sniffing the input bytes, no call site can be shown to be safe, and the EXIF flaw is reachable from the JPEG, TIFF, WebP, PNG and HEIF loaders as well. Upgrade to sharp 0.35.0 or later, which bundles libvips 8.18.3. Blocking the affected loaders with sharp.block({ operation: ["VipsForeignLoadNsgif", "VipsForeignLoadTiff", "VipsForeignLoadVips"] }) is only a partial stopgap and does not mitigate the EXIF out-of-bounds read (CVE-2026-35590), for which no workaround exists.

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 ssc-c8b7a1f2-4d36-4f0a-9e2b-1a5c8d7e6f30 finding:

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 /.env::$DATA?raw, or 8.3 short filenames), allowing an attacker to read otherwise-denied files when the dev server is exposed to the network.

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 ssc-17eda294-146f-4ed3-91f7-5ef1b349d687 finding:

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
Comment thread packages/core/src/tracing/vercel-ai/index.ts
@RulaKhaled

Copy link
Copy Markdown
Collaborator

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:

  1. Reasoning is now gated too.

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.

  1. Absent candidatesTokenCount counts as zero (?? 0).

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.

RulaKhaled and others added 2 commits September 3, 2026 12:26
…` 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.
Comment thread packages/server-utils/src/vercel-ai/vercel-ai-dc-subscriber.ts
@RulaKhaled
RulaKhaled merged commit 3e0eccc into getsentry:v10 Sep 4, 2026
571 of 573 checks passed
@RulaKhaled

Copy link
Copy Markdown
Collaborator

Merged to get it out with the next release.

@zkasuran

zkasuran commented Sep 4, 2026

Copy link
Copy Markdown
Contributor Author

Thanks alot @RulaKhaled

RulaKhaled added a commit that referenced this pull request Sep 4, 2026
… 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants