<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Aina Zulfiqar</title>
    <description>The latest articles on DEV Community by Aina Zulfiqar (@zedaina).</description>
    <link>https://dev.to/zedaina</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4085611%2F0b07034d-171f-4832-8c66-234fa6932491.png</url>
      <title>DEV Community: Aina Zulfiqar</title>
      <link>https://dev.to/zedaina</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/zedaina"/>
    <language>en</language>
    <item>
      <title>The grant money already exists. My AI kept inventing foundations to spend it on</title>
      <dc:creator>Aina Zulfiqar</dc:creator>
      <pubDate>Mon, 07 Sep 2026 06:57:48 +0000</pubDate>
      <link>https://dev.to/zedaina/the-grant-money-already-exists-my-ai-kept-inventing-foundations-to-spend-it-on-14po</link>
      <guid>https://dev.to/zedaina/the-grant-money-already-exists-my-ai-kept-inventing-foundations-to-spend-it-on-14po</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for &lt;a href="https://dev.to/challenges/weekend-2026-09-03"&gt;Weekend Challenge: Generosity Edition&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Built
&lt;/h2&gt;

&lt;p&gt;The money exists. A small NGO just cannot find it.&lt;/p&gt;

&lt;p&gt;That is what a generosity problem looks like at the small end. The giving has already happened — foundations with open, rolling, unclaimed programmes, sitting there — and it is spread across a few thousand pages nobody has time to read. After the 2025–26 collapse of USAID funding, organisations that had one funder now need six, and the people doing that searching are the same people running the programme: a director who is also the grant writer, working evenings. Generosity is not the scarce thing here. Attention is.&lt;/p&gt;

&lt;p&gt;So an AI grant finder is an obvious idea. It is also a dangerous one, because the failure mode is not "unhelpful." A three-person NGO that spends a week writing an application against a deadline that never existed has lost a week it cannot get back, and it will not find out until it submits. The tool would have taken the one thing that was actually scarce.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;FundFinderAI&lt;/strong&gt; is the response, in one sentence: it searches the live web for currently open grants that fit your NGO, and then it refuses to trust its own model about any of them. Every application URL Gemini produces is independently fetched before you see it, and the card tells you what happened when we tried.&lt;/p&gt;

&lt;p&gt;The interesting part is not that it searches. It is everything the app does to establish that the search actually happened and that the result actually exists.&lt;/p&gt;

&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Live: &lt;a href="https://fundfinder-ai.vercel.app" rel="noopener noreferrer"&gt;fundfinder-ai.vercel.app&lt;/a&gt;&lt;/strong&gt; — describe an NGO, get grants, open a drafted Letter of Inquiry.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftykt85qoxiefxegwdmqo.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftykt85qoxiefxegwdmqo.gif" alt="An NGO profile goes in; Gemini searches, every returned link is checked, and verified grants come back" width="560" height="394"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Give it 30–120 seconds. It is running ten to thirty real Google searches and then fetching every URL that comes back, and the page shows you the clock while it does. Paste this in if you would rather not invent an NGO:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;NGO name:&lt;/strong&gt; Kisumu STEM Girls Collective&lt;br&gt;
&lt;strong&gt;Location:&lt;/strong&gt; Kisumu, Kenya&lt;br&gt;
&lt;strong&gt;Mission:&lt;/strong&gt; We run after-school robotics and coding clubs for girls aged 12-17 in Kisumu, Kenya, and train their teachers to keep the clubs running.&lt;br&gt;
&lt;strong&gt;Focus areas:&lt;/strong&gt; STEM education, girls education, teacher training&lt;br&gt;
&lt;strong&gt;Annual budget size:&lt;/strong&gt; Under $250,000 annually&lt;br&gt;
&lt;strong&gt;Target population:&lt;/strong&gt; Adolescent girls in low-income neighbourhoods of Kisumu&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;You will not get my results. They vary run to run — which is itself the point, and is in Limitations.&lt;/p&gt;

&lt;p&gt;A real, unmodified run against the deployed app, for a girls' STEM education NGO in Kisumu, Kenya. Gemini ran &lt;strong&gt;26 Google searches&lt;/strong&gt; and returned five grants, sorted so the ones that survived checking lead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;✓ verified     Ambassador's Special Self-Help Fund      U.S. Embassy Kenya
✓ verified     Seed Grants                              The Pollination Project
✓ verified     Overseas Aid Trust grants                Blackfriars (BOAT)
  unverified   Grassroots Human Security Projects       Embassy of Japan in Kenya
⚠ didn't       Hansen Family Foundation Grant           Hansen Family Foundation
  resolve
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5ggsspz7jaj1943svye7.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5ggsspz7jaj1943svye7.png" alt="A dead funder link caught, with the grounding panel below it listing that same funder among the pages Google retrieved" width="799" height="562"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The bottom row is the product working — and the grounding panel on that same page lists &lt;code&gt;hansenfamilyfoundation.org&lt;/code&gt; among the pages it retrieved. Google's index has that funder. The live site does not answer. &lt;strong&gt;Grounding retrieved it and the URL is still dead&lt;/strong&gt; — which is the entire argument for checking rather than trusting, in one row.&lt;/p&gt;

&lt;p&gt;Ask for a Letter of Inquiry against a grant in that state and the app says so in red before you write a word: &lt;em&gt;we could not reach this funder's page, so we cannot confirm this grant — or this funder — exists.&lt;/em&gt; Drafting a warm, professional letter to an organisation that may not exist is the failure this whole project is against, and the button that does it should not be silent about it.&lt;/p&gt;

&lt;p&gt;An earlier run caught a harder one: &lt;code&gt;au-eu-youthlab.com&lt;/code&gt;, a confident, plausible, entirely non-existent domain. DNS does not resolve it. It never reached the user.&lt;/p&gt;

&lt;p&gt;When the link does check out, a second Gemini call drafts the letter — carrying its own standing caution, because a first draft is not a submission:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fe5rbmx0xlx9hc24mrcf4.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fe5rbmx0xlx9hc24mrcf4.png" alt="The drafted Letter of Inquiry, carrying its own standing caution that it is an AI draft to be checked before sending" width="799" height="562"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h3&gt;
  
  
  You do not have to take the screenshots on faith
&lt;/h3&gt;

&lt;p&gt;This is a post about plausible output not being proof, so the central claim — that the app searches rather than remembers — ships as a runnable check. It talks to the Gemini API and nothing else: not my server, not my code paths. About thirty seconds:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/ainazulfiqar99acc/fundfinder-ai &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd &lt;/span&gt;fundfinder-ai
npm &lt;span class="nb"&gt;install
cp&lt;/span&gt; .env.local.example .env.local   &lt;span class="c"&gt;# then put your key in it&lt;/span&gt;
npm run check-grounding
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;It prints which models actually honour &lt;code&gt;googleSearch&lt;/code&gt; and &lt;strong&gt;exits non-zero if the one this app is configured with did not search.&lt;/strong&gt; The table it produces is further down.&lt;/p&gt;
&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/ainazulfiqar99acc" rel="noopener noreferrer"&gt;
        ainazulfiqar99acc
      &lt;/a&gt; / &lt;a href="https://github.com/ainazulfiqar99acc/fundfinder-ai" rel="noopener noreferrer"&gt;
        fundfinder-ai
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Grant radar for small NGOs: Gemini Search grounding finds currently-open grants, then every application URL is independently checked before you see it.
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;FundFinderAI&lt;/h1&gt;
&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Live: &lt;a href="https://fundfinder-ai.vercel.app" rel="nofollow noopener noreferrer"&gt;https://fundfinder-ai.vercel.app&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A grant radar for small NGOs. Describe your organisation once; Gemini searches
the live web for currently open grants that fit, and drafts a tailored Letter
of Inquiry for any of them.&lt;/p&gt;
&lt;p&gt;Built for the &lt;a href="https://dev.to/challenges/weekend-2026-09-03" rel="nofollow"&gt;DEV Weekend Challenge: Generosity Edition&lt;/a&gt;.&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Why the verification matters more than the search&lt;/h2&gt;
&lt;/div&gt;
&lt;p&gt;An AI grant finder that invents a grant is worse than no grant finder. A
three-person NGO that spends a week preparing an application against a deadline
that never existed has lost something it cannot get back.&lt;/p&gt;
&lt;p&gt;So the model is not trusted about whether a grant is reachable. Every
&lt;code&gt;applicationUrl&lt;/code&gt; it produces is independently fetched before you ever see it
and the result is shown on the card:&lt;/p&gt;
&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Badge&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;✓ Link verified&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The page loads.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;→ Funder site&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The exact page the model named was gone; the funder's own site is live, so the card links&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;…&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/ainazulfiqar99acc/fundfinder-ai" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;p&gt;Four files carry the argument:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://github.com/ainazulfiqar99acc/fundfinder-ai/blob/main/src/lib/verify-link.ts" rel="noopener noreferrer"&gt;&lt;code&gt;src/lib/verify-link.ts&lt;/code&gt;&lt;/a&gt; — the gate. The asymmetry between &lt;code&gt;broken&lt;/code&gt; and &lt;code&gt;unverified&lt;/code&gt; is the whole design, and the comments say why.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/ainazulfiqar99acc/fundfinder-ai/blob/main/scripts/check-grounding.mjs" rel="noopener noreferrer"&gt;&lt;code&gt;scripts/check-grounding.mjs&lt;/code&gt;&lt;/a&gt; — the four-model grounding check above.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/ainazulfiqar99acc/fundfinder-ai/blob/main/src/app/api/match-grants/route.ts" rel="noopener noreferrer"&gt;&lt;code&gt;src/app/api/match-grants/route.ts&lt;/code&gt;&lt;/a&gt; — the grounded call, the prompt rule that every funder must have appeared in a search result, and verified-first ranking.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/ainazulfiqar99acc/fundfinder-ai/blob/main/src/types/index.ts" rel="noopener noreferrer"&gt;&lt;code&gt;src/types/index.ts&lt;/code&gt;&lt;/a&gt; — four link states, with the reasoning for each written into the type.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How I Built It
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The trap: a model can decline to search and never say so
&lt;/h3&gt;

&lt;p&gt;The premise is live Search grounding. The app was configured with &lt;code&gt;gemini-3.1-pro-preview&lt;/code&gt; and passed &lt;code&gt;tools: [{ googleSearch: {} }]&lt;/code&gt;, and it returned six well-formed, plausible grants in sixty seconds. It looked like it worked.&lt;/p&gt;

&lt;p&gt;It was not searching at all.&lt;/p&gt;

&lt;p&gt;The response carried no &lt;code&gt;groundingMetadata&lt;/code&gt; — the candidate had only &lt;code&gt;content&lt;/code&gt;, &lt;code&gt;finishReason&lt;/code&gt;, &lt;code&gt;index&lt;/code&gt;. No error, no warning, no refusal. The model accepted a tool it does not run and answered from memory, in exactly the shape I asked for. The tell was in the output, not the API: that run invented an application URL.&lt;/p&gt;

&lt;p&gt;This generalises further than one model. Sending one identical grounded prompt to four models:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;model                       time  chunks  queries  grounded
------------------------------------------------------------
gemini-2.5-flash             17s       0        8  YES
gemini-flash-latest          11s       0        0  NO  &amp;lt;-- ignored the tool
gemini-3.1-pro-preview       38s       0        0  NO  &amp;lt;-- ignored the tool
gemini-pro-latest            27s       0        0  NO  &amp;lt;-- ignored the tool
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three of four accept &lt;code&gt;googleSearch&lt;/code&gt; and never call it. Any of them would have produced a working, convincing, ungrounded app.&lt;/p&gt;

&lt;p&gt;That check is committed as &lt;code&gt;npm run check-grounding&lt;/code&gt;. It talks to the Gemini API and nothing else — not my server, not my code paths — and &lt;strong&gt;exits non-zero if the model the app is configured with did not actually search.&lt;/strong&gt; If the premise of this project ever stops being true, that command fails.&lt;/p&gt;

&lt;h3&gt;
  
  
  The second trap, which was mine
&lt;/h3&gt;

&lt;p&gt;My first version of that check asked whether &lt;code&gt;groundingChunks&lt;/code&gt; came back non-empty. By that measure &lt;code&gt;gemini-2.5-flash&lt;/code&gt; was also ungrounded, and I nearly changed the model a second time chasing it.&lt;/p&gt;

&lt;p&gt;Look at the &lt;code&gt;chunks&lt;/code&gt; column above: it is zero for the model that &lt;em&gt;is&lt;/em&gt; grounded. Grounding chunks attach citations to prose, and this prompt demands bare JSON with no prose in it, so there is nothing for a citation to attach to. The signal that grounding ran is &lt;code&gt;webSearchQueries&lt;/code&gt; — the actual searches issued.&lt;/p&gt;

&lt;p&gt;That mistake was live in the UI too. The app showed a red "Gemini may have answered from its training data" warning whenever chunks were empty, which meant it accused itself of hallucinating on runs where it had correctly searched eight times. The fix was to report the searches, which is also just better: the panel now shows the literal queries Gemini ran, which is far more meaningful to a user than a list of opaque &lt;code&gt;vertexaisearch&lt;/code&gt; redirect URLs.&lt;/p&gt;

&lt;h3&gt;
  
  
  Turning recall up, and watching precision fall over
&lt;/h3&gt;

&lt;p&gt;With grounding correctly on, a search for the Kisumu NGO came back with &lt;strong&gt;zero grants&lt;/strong&gt; — "no currently open grants were found with high confidence" — while the search panel showed ten real Google searches. The model had looked and then declined to commit.&lt;/p&gt;

&lt;p&gt;My first instinct was that the gate makes conservatism unnecessary: let the model offer plausible funders and let verification sort them out. So I told it that returning an empty list was a failure, and that a funder whose exact call it could not confirm still belonged in the results.&lt;/p&gt;

&lt;p&gt;That worked, and it was a bad trade. Results went from zero to six — and &lt;strong&gt;five of the six links were dead.&lt;/strong&gt; Not guessed paths on real funders, which the fallback handles: dead &lt;em&gt;domains&lt;/em&gt;. Told to reach a number, the model had begun assembling organisations that sound exactly like real grantmakers and do not exist.&lt;/p&gt;

&lt;p&gt;The rule that fixed it draws the line at the search result rather than at confidence:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Every single one must be an organisation that actually appeared in your search results. If you did not see the funder in a search result, it does not go in the list, however plausible it sounds. Three real funders is a good answer; six with two invented ones is a bad answer, because the reader cannot tell which is which.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Plus the same rule for the URL, which is the part most easily fabricated: use the address as it appeared, and if you did not see one, use the funder's homepage — a short address is far likelier to be real than a guessed &lt;code&gt;/grants/apply-2026&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Same profile, after: five grants, three verified, one inconclusive, one caught. A later run on the same profile came back four for four. The gate's job is to catch what slips through, not to license the model to guess — and the better the rule got, the less often the gate had anything to do.&lt;/p&gt;

&lt;h3&gt;
  
  
  The bug in the gate itself
&lt;/h3&gt;

&lt;p&gt;Worth admitting, because it is the same class of error as everything above. The funder-site fallback — the thing that rescues a real funder whose deep path the model guessed — was very nearly dead code. &lt;code&gt;checkLink&lt;/code&gt; tried &lt;code&gt;HEAD&lt;/code&gt; first and returned immediately on any settled verdict, and a 404 is a settled verdict, so the ordinary case (a well-behaved server answering &lt;code&gt;HEAD&lt;/code&gt; with 404) returned &lt;code&gt;broken&lt;/code&gt; and never reached the root probe below it. The fallback only ever fired for servers that reject &lt;code&gt;HEAD&lt;/code&gt; outright.&lt;/p&gt;

&lt;p&gt;The feature worked in the demo, was described accurately in the README, and was unreachable for the exact case it was written for. The fix is one condition — let &lt;code&gt;broken&lt;/code&gt; fall through instead of returning — and it is why real funders now surface as their homepage instead of a dead end.&lt;/p&gt;

&lt;h3&gt;
  
  
  The gate, and why its verdicts are deliberately lopsided
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;src/lib/verify-link.ts&lt;/code&gt; fetches every URL the model produced. What matters is what it refuses to conclude:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;broken&lt;/code&gt;&lt;/strong&gt; only on positive evidence the page is not there: a malformed URL, a DNS failure, or a 404/410 from the server itself.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;unverified&lt;/code&gt;&lt;/strong&gt; for everything ambiguous — timeouts, 403s from bot protection, rate limits, TLS quirks.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A funder's firewall blocking a robot is not evidence the grant is fake. Marking a real grant broken would be the same category of harm as inventing one, pointed the other way, so the check is built to be unsure out loud rather than confidently wrong.&lt;/p&gt;

&lt;p&gt;Two smaller things fell out of real runs. The model sometimes copies an opaque &lt;code&gt;vertexaisearch.../grounding-api-redirect/&lt;/code&gt; URL straight out of its own grounding metadata into &lt;code&gt;applicationUrl&lt;/code&gt;, so those get followed and replaced with wherever they land. And a dead deep path on a live domain turned out to be the single most common failure — real funder, guessed page — so the check retries the origin and offers the funder's own site under a distinct badge.&lt;/p&gt;

&lt;h3&gt;
  
  
  Honest note on the failures shown here
&lt;/h3&gt;

&lt;p&gt;Every failure in this post came out of a live, unmodified run against the deployed app. I did not corrupt a fixture or hand-write a bad response to make the gate look busy. The model produced &lt;code&gt;au-eu-youthlab.com&lt;/code&gt; and &lt;code&gt;ned.org/apply-for-a-grant/&lt;/code&gt; on its own, and both are exactly the kind of thing that would have cost somebody a week.&lt;/p&gt;

&lt;h2&gt;
  
  
  Limitations, plainly
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Reachability is not truth.&lt;/strong&gt; A page that loads is not proof the grant is open, or that the deadline and eligibility on the card are correct. The gate raises the floor; it does not certify a grant. Every card says to confirm on the funder's page, and it means it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Eligibility and match reasoning are the model's reading&lt;/strong&gt;, unverified by anything.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The LOI is a first draft&lt;/strong&gt;, not a submission. It is there to get past a blank page.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No real NGO has used this.&lt;/strong&gt; It is tested against profiles I wrote.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Grounded results are narrower.&lt;/strong&gt; Turning grounding on cut a six-grant answer to three-to-five. That is the honest number, and the six were partly fiction.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It is slow.&lt;/strong&gt; 30–120 seconds per search, because 10–30 real Google searches happen and then every URL returned is fetched. That is the cost of not guessing, but it is a real cost and I have not hidden it behind a fake progress bar.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Results vary between identical runs.&lt;/strong&gt; The same profile returns different funders each time, and the caught-link rate moves with it. There is no seed to pin.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Prize Categories
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Best Use of Google AI.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Gemini is used twice, and the interesting engineering is in refusing to trust it both times.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Search grounding is treated as a claim to be checked, not a setting to be enabled.&lt;/strong&gt; Three of four models tested accept &lt;code&gt;googleSearch&lt;/code&gt; and silently never run it, producing a fully convincing ungrounded app. &lt;code&gt;npm run check-grounding&lt;/code&gt; is a committed, runnable test of that premise that exits non-zero when it fails. The search call passes two Google tools — &lt;code&gt;googleSearch&lt;/code&gt; to retrieve, and &lt;code&gt;urlContext&lt;/code&gt; so the model reads candidate funder pages rather than only their snippets — and the app then re-fetches every URL itself anyway. A tool the model &lt;em&gt;may&lt;/em&gt; have invoked is not evidence that it did.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;webSearchQueries&lt;/code&gt;, not &lt;code&gt;groundingChunks&lt;/code&gt;, is the proof grounding ran&lt;/strong&gt; — a distinction that a bare-JSON prompt makes load-bearing, and getting it wrong made a correct configuration look broken.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The model's output is externally verified before display.&lt;/strong&gt; Every URL is fetched; verdicts are asymmetric so ambiguity never becomes a false accusation; dead deep paths fall back to the funder's live site with the invented URL shown struck through.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The second call is deliberately unglamorous.&lt;/strong&gt; LOI drafting needs no grounding, so it uses the stronger model with no tools — the two calls are configured for what they actually need rather than uniformly.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Remove Gemini and there is no product. Remove the verification and there is something worse than no product: a confident list of grants, some of which do not exist, handed to people who cannot afford to find out the hard way.&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>weekendchallenge</category>
      <category>googleai</category>
      <category>ai</category>
    </item>
    <item>
      <title>Gemini spent 341 tokens thinking. LangChain reported five.</title>
      <dc:creator>Aina Zulfiqar</dc:creator>
      <pubDate>Thu, 20 Aug 2026 21:09:47 +0000</pubDate>
      <link>https://dev.to/zedaina/gemini-spent-341-tokens-thinking-langchain-reported-five-311c</link>
      <guid>https://dev.to/zedaina/gemini-spent-341-tokens-thinking-langchain-reported-five-311c</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for &lt;a href="https://dev.to/bugsmash"&gt;DEV's Summer Bug Smash: Clear the Lineup&lt;/a&gt; powered by &lt;a href="https://sentry.io/" rel="noopener noreferrer"&gt;Sentry&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;If you bill customers from LangChain's token counts and your users talk to Gemini, check your numbers. On a reasoning call they are wrong for certain. On a streamed call they are wrong whatever the model.&lt;/p&gt;

&lt;p&gt;Sentry told me a Gemini call used 46 tokens. The call used 387. The other 341 were thinking tokens — billed by Google, buried inside &lt;code&gt;total_tokens&lt;/code&gt;, and named by no field LangChain returned.&lt;/p&gt;

&lt;p&gt;The shape is always the same: a value computed correctly, then dropped on the way out. Nothing throws. Nothing logs. The number that reaches your dashboard is usually a plausible number, which is exactly why this survived. I found it four times, across two packages and both of the channels LangChain reports usage on.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pull requests:&lt;/strong&gt; &lt;a href="https://github.com/langchain-ai/langchainjs/pull/11423" rel="noopener noreferrer"&gt;#11423&lt;/a&gt; and &lt;a href="https://github.com/langchain-ai/langchainjs/pull/11425" rel="noopener noreferrer"&gt;#11425&lt;/a&gt; &lt;strong&gt;Issues:&lt;/strong&gt; &lt;a href="https://github.com/langchain-ai/langchainjs/issues/11422" rel="noopener noreferrer"&gt;#11422&lt;/a&gt; and &lt;a href="https://github.com/langchain-ai/langchainjs/issues/11424" rel="noopener noreferrer"&gt;#11424&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;One note on the numbers below: Gemini's thinking budget is not deterministic, so exact counts shift between calls. What never shifts is that a gap opens the moment thinking is on. Every figure I quote comes from a run I screenshotted, and where two figures come from different calls I say so.&lt;/p&gt;

&lt;h2&gt;
  
  
  Project Overview
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;@langchain/google-genai&lt;/code&gt; is the LangChain integration for Google's Gemini models. If you use &lt;code&gt;ChatGoogleGenerativeAI&lt;/code&gt; in a TypeScript app, this is the package you installed.&lt;/p&gt;

&lt;p&gt;Token counts are not decoration in that stack. They are how you bill customers, enforce quotas, fill dashboards, and notice when a prompt change quietly triples your spend. LangChain exposes them two ways: &lt;code&gt;usage_metadata&lt;/code&gt; on the message, and &lt;code&gt;llmOutput&lt;/code&gt; for the callback system that observability tools hook into.&lt;/p&gt;

&lt;p&gt;Gemini 2.5 and 3 models think before answering, and they charge for it. The API reports that separately as &lt;code&gt;thoughtsTokenCount&lt;/code&gt;. That number is the whole story here.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug Fix or Performance Improvement
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What is broken
&lt;/h3&gt;

&lt;p&gt;Ask &lt;code&gt;gemini-2.5-flash&lt;/code&gt; a question that requires reasoning, with thinking enabled, and read &lt;code&gt;usage_metadata&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"input_tokens"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;41&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"output_tokens"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"total_tokens"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;387&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;41 plus 5 is 46. The object says 387. The 341-token difference is named by no field in the object.&lt;/p&gt;

&lt;p&gt;You do not need to know anything about LangChain to see the problem. The object contradicts itself.&lt;/p&gt;

&lt;p&gt;Any cost tracker reading &lt;code&gt;output_tokens&lt;/code&gt; sees &lt;strong&gt;5&lt;/strong&gt; for a call that produced 346 output tokens.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fr1j0j5hmmuh9z1hv39vu.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fr1j0j5hmmuh9z1hv39vu.png" alt="Sentry issues list filtered to the before environment, showing one error: token accounting is inconsistent, input_tokens 41 plus output_tokens 5 equals 46 but total_tokens is 387" width="800" height="180"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Before. The probe asserts &lt;code&gt;input + output == total&lt;/code&gt; and raises when it fails. This is a silent bug forced to announce itself — the issue title is the entire defect.&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Root cause
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;convertUsageMetadata&lt;/code&gt; in &lt;a href="https://github.com/langchain-ai/langchainjs/blob/main/libs/providers/langchain-google-genai/src/utils/common.ts#L977-L1013" rel="noopener noreferrer"&gt;&lt;code&gt;libs/providers/langchain-google-genai/src/utils/common.ts&lt;/code&gt;&lt;/a&gt; builds the result from four fields:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;output&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;UsageMetadata&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;input_tokens&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;usageMetadata&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;promptTokenCount&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;output_tokens&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;usageMetadata&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;candidatesTokenCount&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;total_tokens&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;usageMetadata&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;totalTokenCount&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;thoughtsTokenCount&lt;/code&gt; is never read. It does not appear anywhere in the package.&lt;/p&gt;

&lt;p&gt;The reason it was missed is worth knowing. This package still depends on the legacy &lt;code&gt;@google/generative-ai&lt;/code&gt; SDK, whose &lt;code&gt;UsageMetadata&lt;/code&gt; interface declares only four fields. &lt;code&gt;thoughtsTokenCount&lt;/code&gt; is not one of them. TypeScript never complained, because you cannot read a property the type says does not exist.&lt;/p&gt;

&lt;p&gt;It arrives at runtime anyway. This is the payload from a direct call to the same API with the same prompt — a separate call, so its thinking count is its own:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"promptTokenCount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;41&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"candidatesTokenCount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"totalTokenCount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;427&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"promptTokensDetails"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"modality"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"TEXT"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"tokenCount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;41&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"thoughtsTokenCount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;381&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"serviceTier"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"standard"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three of those fields are undeclared, including the one that matters. The type was the blindfold.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why the obvious fix is worse than no fix
&lt;/h3&gt;

&lt;p&gt;Reading &lt;code&gt;thoughtsTokenCount&lt;/code&gt; in &lt;code&gt;convertUsageMetadata&lt;/code&gt; seems like the whole job. It is not, and stopping there breaks streaming.&lt;/p&gt;

&lt;p&gt;Gemini reports usage cumulatively. Every chunk carries the running total for the whole request. The provider already knows this, which is why &lt;code&gt;_streamResponseChunks&lt;/code&gt; converts &lt;code&gt;input_tokens&lt;/code&gt;, &lt;code&gt;output_tokens&lt;/code&gt; and &lt;code&gt;total_tokens&lt;/code&gt; into per-chunk deltas after &lt;code&gt;convertUsageMetadata&lt;/code&gt; returns. But it leaves nested detail fields cumulative, and &lt;code&gt;mergeOutputTokenDetails&lt;/code&gt; in &lt;code&gt;@langchain/core&lt;/code&gt; sums &lt;code&gt;reasoning&lt;/code&gt; across chunks.&lt;/p&gt;

&lt;p&gt;So a three-chunk stream carrying cumulative thoughts of 100, 250 and 381 does this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;100 + 250 + 381 = 731
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The true value is 381. The converter-only fix reports 731. It does not merely fail to fix the bug, it creates a 92% overcount, which is the same double-counting class as &lt;a href="https://github.com/langchain-ai/langchainjs/issues/8266" rel="noopener noreferrer"&gt;#8266&lt;/a&gt; — an issue that sat open for over a year describing exactly this failure.&lt;/p&gt;

&lt;p&gt;There is a test in the PR that fails if you patch only the converter.&lt;/p&gt;

&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;

&lt;p&gt;Both fixes are open pull requests against &lt;code&gt;langchain-ai/langchainjs&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/langchain-ai/langchainjs/pull/11423" rel="noopener noreferrer"&gt;#11423&lt;/a&gt;&lt;/strong&gt; — reasoning tokens dropped in &lt;code&gt;@langchain/google-genai&lt;/code&gt;. +284 / -8 across 5 files, with 10 new unit tests.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/langchain-ai/langchainjs/pull/11425" rel="noopener noreferrer"&gt;#11425&lt;/a&gt;&lt;/strong&gt; — &lt;code&gt;llmOutput&lt;/code&gt; token usage wrong on both streaming paths, touching &lt;code&gt;@langchain/core&lt;/code&gt; and &lt;code&gt;@langchain/google-genai&lt;/code&gt;. +343 / -26, with 7 new unit tests.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;They close &lt;a href="https://github.com/langchain-ai/langchainjs/issues/11422" rel="noopener noreferrer"&gt;#11422&lt;/a&gt; and &lt;a href="https://github.com/langchain-ai/langchainjs/issues/11424" rel="noopener noreferrer"&gt;#11424&lt;/a&gt;, both of which I filed with reproductions.&lt;/p&gt;

&lt;p&gt;These are open pull requests against &lt;code&gt;langchain-ai/langchainjs&lt;/code&gt;. #11425 has already drawn review from another contributor; #11423 is open and unreviewed as I write this. Not a fork, not a patch in my own repo — if they merge, the fix reaches every consumer of &lt;code&gt;@langchain/google-genai&lt;/code&gt;. It is one of several packages in LangChain JS that reach the Gemini Developer API, and the only one that gets reasoning tokens wrong.&lt;/p&gt;

&lt;p&gt;The changes are walked through below.&lt;/p&gt;

&lt;h2&gt;
  
  
  My Improvements
&lt;/h2&gt;

&lt;p&gt;Three changes, all in &lt;a href="https://github.com/langchain-ai/langchainjs/pull/11423" rel="noopener noreferrer"&gt;#11423&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Read the field.&lt;/strong&gt; Defensively, since the legacy type does not declare it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;thoughtsTokenCount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getThoughtsTokenCount&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;usageMetadata&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;candidatesTokenCount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;usageMetadata&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;candidatesTokenCount&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;output&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;UsageMetadata&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;input_tokens&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;usageMetadata&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;promptTokenCount&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="c1"&gt;// Gemini reports reasoning separately from candidatesTokenCount, while&lt;/span&gt;
  &lt;span class="c1"&gt;// output_tokens is defined as the sum of all output token types.&lt;/span&gt;
  &lt;span class="na"&gt;output_tokens&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;candidatesTokenCount&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;thoughtsTokenCount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;total_tokens&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;usageMetadata&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;totalTokenCount&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;thoughtsTokenCount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;output&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;output_token_details&lt;/span&gt; &lt;span class="o"&gt;??=&lt;/span&gt; &lt;span class="p"&gt;{};&lt;/span&gt;
  &lt;span class="nx"&gt;output&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;output_token_details&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reasoning&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;thoughtsTokenCount&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;@langchain/google-common&lt;/code&gt; and &lt;code&gt;@langchain/google&lt;/code&gt; - which back the Vertex packages and also reach the Gemini Developer API - already read &lt;code&gt;thoughtsTokenCount&lt;/code&gt; this way. &lt;code&gt;@langchain/google-genai&lt;/code&gt;, the standalone Developer-API package built on the legacy SDK, is the one that does not. So this is two packages agreeing with a third, not new behaviour being proposed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Convert reasoning to a delta while streaming&lt;/strong&gt;, so concatenating chunks does not sum the running totals.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. An unrelated bug in the same function.&lt;/strong&gt; While reading it I found this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;model&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;gemini-3-pro-preview&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;over200k&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;usageMetadata&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;promptTokenCount&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;200000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;??&lt;/code&gt; binds looser than &lt;code&gt;-&lt;/code&gt;, so &lt;code&gt;0 - 200000&lt;/code&gt; evaluates first. The line actually reads &lt;code&gt;promptTokenCount ?? -200000&lt;/code&gt;, which for any real prompt is &lt;code&gt;Math.max(0, promptTokenCount)&lt;/code&gt;. On &lt;code&gt;gemini-3-pro-preview&lt;/code&gt;, a 500-token prompt reported 500 tokens of overage past 200k. The next line has the same problem. The model gate is probably why nobody had hit it yet.&lt;/p&gt;

&lt;h3&gt;
  
  
  The tests
&lt;/h3&gt;

&lt;p&gt;Ten unit tests, no network required. Revert the fix and eight of them fail with the exact wrong numbers — the other two are regression guards that have to keep passing either way:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;AssertionError: expected undefined to be 381
AssertionError: expected 5 to be 386
AssertionError: expected 46 to be 427
AssertionError: expected 500 to be undefined
AssertionError: expected 250000 to be 50000

Failed Tests 8
Test Files  2 failed (2)
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;expected 46 to be 427&lt;/code&gt; is the same arithmetic failure my assertion caught on a live call and Sentry carried, reproduced offline with no API key.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6lkigt3cyxh7sambi2kb.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6lkigt3cyxh7sambi2kb.png" alt="Terminal output showing eight failing test assertions after the fix is reverted, including expected 46 to be 427" width="799" height="263"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The same arithmetic failure the probe caught on a live call, reproduced offline with no API key. Revert the fix and these eight come back.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Full package suite passes: 103 tests, no type errors, &lt;code&gt;oxlint&lt;/code&gt; and &lt;code&gt;oxfmt&lt;/code&gt; clean.&lt;/p&gt;

&lt;h2&gt;
  
  
  The same bug, twice more in the callback path
&lt;/h2&gt;

&lt;p&gt;Once I knew the shape, I went looking. Two more, both in what the callback system reads — and, as it turns out, one I missed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Two.&lt;/strong&gt; &lt;code&gt;_generate&lt;/code&gt; returns &lt;code&gt;llmOutput: { estimatedTokenUsage: tokenUsage }&lt;/code&gt; on its streaming branch, where &lt;code&gt;tokenUsage&lt;/code&gt; is declared &lt;code&gt;{}&lt;/code&gt; at the top of the branch and returned at the bottom without ever being written to. Wrong key too. The non-streaming path of the same class returns &lt;code&gt;tokenUsage&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Three.&lt;/strong&gt; &lt;code&gt;_streamIterator&lt;/code&gt; in &lt;code&gt;@langchain/core&lt;/code&gt; reassigns &lt;code&gt;llmOutput&lt;/code&gt; on every chunk, so the surviving value is the last chunk's usage. Correct for providers that report cumulative totals on a final chunk. Wrong for providers emitting deltas, where the survivor is the last delta.&lt;/p&gt;

&lt;p&gt;Measured across all three call paths, with #11423 already applied so only the &lt;code&gt;llmOutput&lt;/code&gt; bug is visible:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;call path&lt;/th&gt;
&lt;th&gt;what the callback receives&lt;/th&gt;
&lt;th&gt;true usage&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;invoke()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{ 41, 366, 407 }&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;407&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;invoke()&lt;/code&gt; with &lt;code&gt;streaming: true&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;estimatedTokenUsage: {}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;401&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;.stream()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{ 0, 3, 3 }&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;458&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three tokens reported for a 458-token call. That one is worse than the empty object, because an empty object looks broken and gets noticed. &lt;code&gt;3&lt;/code&gt; renders fine on a dashboard.&lt;/p&gt;

&lt;p&gt;Both are fixed in &lt;a href="https://github.com/langchain-ai/langchainjs/pull/11425" rel="noopener noreferrer"&gt;#11425&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Best Use of Sentry
&lt;/h2&gt;

&lt;p&gt;Sentry is why I found instances two and three. Not as a monitoring tool, but because instrumenting the code forces one question: what does the observability layer actually read?&lt;/p&gt;

&lt;p&gt;The answer for LangChain in JavaScript is &lt;code&gt;llmOutput.tokenUsage&lt;/code&gt;, with an Anthropic-shaped &lt;code&gt;llmOutput.usage&lt;/code&gt; fallback and nothing after that. No &lt;code&gt;usage_metadata&lt;/code&gt; fallback, unlike the Python SDK, which checks the message first. So I went to look at what &lt;code&gt;llmOutput&lt;/code&gt; contained, and it was wrong in two different ways on two different paths.&lt;/p&gt;

&lt;h3&gt;
  
  
  Before and after
&lt;/h3&gt;

&lt;p&gt;I built a probe that makes the same call three ways, tagged the runs &lt;code&gt;before&lt;/code&gt; and &lt;code&gt;after&lt;/code&gt;, and pointed it at a local patched build.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpgdzljj71321ubtwd0wd.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpgdzljj71321ubtwd0wd.png" alt="Sentry trace waterfall showing seven spans: a probe transaction containing a gen_ai.generate_content span from Google's SDK and two gen_ai.chat spans from LangChain, each with an HTTP call to the Gemini API" width="800" height="225"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;One trace, one prompt. &lt;code&gt;generate_content&lt;/code&gt; is Google's own SDK; the two &lt;code&gt;chat&lt;/code&gt; spans are LangChain measuring the same work. The correct count and the wrong count, side by side.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Same span, same prompt, same response. The output count goes from 5 to 371, and every total downstream follows it:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvr8yhn3asf494o4k2bqw.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvr8yhn3asf494o4k2bqw.png" alt="Sentry span detail before the fix, reading Tokens 41 in plus 5 out equals 46 total, with Cost under one cent" width="799" height="290"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Before: 41 in, 5 out, 46 total — and look at the Cost line. Sentry priced the call from the undercounted tokens. Every number downstream inherits it: cost, context utilization, budget alerts.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Feli2uxvc6i4wc4ay3v00.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Feli2uxvc6i4wc4ay3v00.png" alt="Sentry span detail after the fix, reading Tokens 41 in plus 371 out equals 412 total" width="800" height="258"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;After: 41 in, 371 out, 412 total. Same prompt, same response, correct bill.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;That is the difference between a missing field and wrong billing, and Sentry is the one saying it.&lt;/p&gt;

&lt;p&gt;The response, by the way, was &lt;code&gt;$0.05&lt;/code&gt;. Three characters — and on that call, 366 of the 371 output tokens were the model thinking.&lt;/p&gt;

&lt;h3&gt;
  
  
  Making a silent bug visible
&lt;/h3&gt;

&lt;p&gt;Token miscounting throws nothing. So the probe asserts the arithmetic and raises an error when it fails.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fm7d0wgw1iz7raqu0e3gp.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fm7d0wgw1iz7raqu0e3gp.png" alt="Sentry breadcrumbs showing the arithmetic line, 41 plus 5 equals 46 versus total 387 with a gap of 341, and six milliseconds earlier the raw SDK line reporting thoughts 381 and total 427" width="800" height="898"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The wrong accounting and the ground truth, six milliseconds apart — LangChain reporting 46 while the raw SDK reports 381 thinking tokens on the same prompt.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdy2mtl15eds75uzy8wmq.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdy2mtl15eds75uzy8wmq.png" alt="Sentry issues list filtered to the after environment, showing an empty state reading no issues match your search" width="800" height="196"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;After the fix, nothing to report. The assertion that fired on every reasoning call now never fires.&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  What Seer made of it
&lt;/h3&gt;

&lt;p&gt;I pointed Seer at the issue without telling it anything about the bug — no source access to my fork, no hint about &lt;code&gt;thoughtsTokenCount&lt;/code&gt;. All it had was the error and the breadcrumbs.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxgcolatewhx20livxv08.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxgcolatewhx20livxv08.png" alt="Sentry Seer root cause analysis identifying that LangChain excluded thoughtsTokenCount from output_tokens" width="800" height="574"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;It got the root cause right:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;LangChain's Gemini integration excluded &lt;code&gt;thoughtsTokenCount&lt;/code&gt; from &lt;code&gt;output_tokens&lt;/code&gt;, causing &lt;code&gt;input+output ≠ total&lt;/code&gt; when the model uses extended thinking.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;And the mechanism, unprompted: that &lt;code&gt;output_tokens&lt;/code&gt; was being set to &lt;code&gt;candidatesTokenCount&lt;/code&gt; alone, that &lt;code&gt;totalTokenCount&lt;/code&gt; includes thinking tokens, and that the gap should equal &lt;code&gt;thoughtsTokenCount&lt;/code&gt; from the raw response. Its reproduction steps are the ones I had written by hand a day earlier.&lt;/p&gt;

&lt;p&gt;Two things it got wrong, and they are the interesting part. It named the function &lt;code&gt;responseToUsageMetadata&lt;/code&gt;; the real one is &lt;code&gt;convertUsageMetadata&lt;/code&gt;. And its evidence pointer — &lt;code&gt;gemini.ts L1235–L1274&lt;/code&gt; — is not the broken code at all. That file is &lt;code&gt;@langchain/google-common&lt;/code&gt;, the sibling package, and those exact lines read:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;thoughtsTokenCount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;usageMetadata&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;thoughtsTokenCount&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;output_tokens&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;candidatesTokenCount&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;thoughtsTokenCount&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the correct implementation. It is what my fix makes &lt;code&gt;@langchain/google-genai&lt;/code&gt; do.&lt;/p&gt;

&lt;p&gt;So Seer reasoned to the right diagnosis and then cited the one file in the repo that already does it properly. Which is what you would expect from a tool working backwards from telemetry rather than from the repository: the breadcrumbs told it what was wrong, and nothing in them could tell it which of several similarly-named packages the call had gone through.&lt;/p&gt;

&lt;p&gt;That is still the argument for making a silent bug loud. Give an RCA tool one contradiction to hold onto and it can walk back to the cause from a stack trace and eight console lines. Just check where it points before you believe it.&lt;/p&gt;

&lt;h3&gt;
  
  
  The agent view
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ft9i5t49d2otxtp8q2nu8.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ft9i5t49d2otxtp8q2nu8.png" alt="Sentry span detail with the Output tab open, showing the model response $0.05 in the same panel as a token line reading 41 in plus 5 out equals 46 total" width="800" height="303"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The same run, with the span's Output tab open. Because &lt;code&gt;recordInputs&lt;/code&gt; and &lt;code&gt;recordOutputs&lt;/code&gt; are on, Sentry captured the model's actual answer — &lt;code&gt;$0.05&lt;/code&gt; — and sits it in the same panel as the token line that says the call cost 46 tokens. A three-character response and a four-hundred-token bill, in one panel, disagreeing with each other. That juxtaposition is what makes the undercount legible as a cost rather than a stray metric.&lt;/p&gt;

&lt;h3&gt;
  
  
  One thing worth knowing
&lt;/h3&gt;

&lt;p&gt;Sentry's automatic AI instrumentation patches modules as they load, so it only works if &lt;code&gt;Sentry.init()&lt;/code&gt; runs before the AI package is evaluated. Under ESM every static import is hoisted, so calling &lt;code&gt;init()&lt;/code&gt; in the same file that imports LangChain is already too late. Sentry documents the fix - put init in its own file and run &lt;code&gt;node --import ./instrument.mjs app.mjs&lt;/code&gt; - and their own CI exercises exactly that for these integrations.&lt;/p&gt;

&lt;p&gt;I was running through &lt;code&gt;tsx&lt;/code&gt;, and even with &lt;code&gt;--import&lt;/code&gt; the loader hooks did not take for me: my first runs produced only &lt;code&gt;http.client&lt;/code&gt; spans and no &lt;code&gt;gen_ai&lt;/code&gt; spans at all, and I nearly wrote the wrong conclusion from it. Attaching the instrumentation by hand is the reliable escape hatch:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;handler&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Sentry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createLangChainCallbackHandler&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;recordInputs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;recordOutputs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ai&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Sentry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;instrumentGoogleGenAIClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;GoogleGenAI&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;apiKey&lt;/span&gt; &lt;span class="p"&gt;}));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If your AI spans are missing on ESM, check that you are loading Sentry via &lt;code&gt;--import&lt;/code&gt; before concluding anything - and if you are running through a custom loader like &lt;code&gt;tsx&lt;/code&gt;, try attaching by hand before blaming the SDK.&lt;/p&gt;

&lt;p&gt;Sentry also filtered the console breadcrumbs carrying the token JSON, marking them &lt;code&gt;[Filtered]&lt;/code&gt;. Its scrubber saw the word "token" and did what it should.&lt;/p&gt;

&lt;h2&gt;
  
  
  Best Use of Google AI
&lt;/h2&gt;

&lt;p&gt;Two models agreeing is not evidence — both can be wrong the same way. So the ground truth in this work is Google's own SDK, called directly.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;@google/genai&lt;/code&gt; types &lt;code&gt;thoughtsTokenCount&lt;/code&gt; properly. The legacy &lt;code&gt;@google/generative-ai&lt;/code&gt; SDK that LangChain still depends on does not declare it at all. That gap is why nobody caught it, and it is also how I proved it: run the same prompt through both, and read what each one admits to.&lt;/p&gt;

&lt;p&gt;The raw SDK's object closes its own arithmetic. LangChain's does not:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;raw @google/genai   41 prompt + 5 candidates + 0 tool-use + 381 thoughts = 427 = totalTokenCount   OK
LangChain           41 input  + 5 output     +         (missing)         = 46  != 387              FAIL
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is what establishes thinking tokens as a separate bucket rather than something folded into &lt;code&gt;candidatesTokenCount&lt;/code&gt;. Without it, "output_tokens should include reasoning" is an opinion about API semantics. With it, it is arithmetic, and the PR stops being a proposal and becomes a correction.&lt;/p&gt;

&lt;h3&gt;
  
  
  The controlled version
&lt;/h3&gt;

&lt;p&gt;Thinking budgets are not deterministic, so a single pair of calls proves less than it looks. I ran three prompts twice each — thinking on, thinking off — through both SDKs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csvs"&gt;&lt;code&gt;&lt;span class="k"&gt;prompt&lt;/span&gt;          &lt;span class="k"&gt;thinking&lt;/span&gt;  &lt;span class="k"&gt;raw&lt;/span&gt; &lt;span class="k"&gt;thoughts&lt;/span&gt;  &lt;span class="k"&gt;LC&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;  &lt;span class="k"&gt;LC&lt;/span&gt; &lt;span class="k"&gt;out&lt;/span&gt;  &lt;span class="k"&gt;LC&lt;/span&gt; &lt;span class="k"&gt;total&lt;/span&gt;  &lt;span class="k"&gt;gap&lt;/span&gt;
&lt;span class="k"&gt;bat&lt;/span&gt; &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="k"&gt;ball&lt;/span&gt;    &lt;span class="k"&gt;on&lt;/span&gt;        &lt;span class="mf"&gt;523&lt;/span&gt;           &lt;span class="mf"&gt;41&lt;/span&gt;     &lt;span class="mf"&gt;5&lt;/span&gt;       &lt;span class="mf"&gt;396&lt;/span&gt;       &lt;span class="mf"&gt;350&lt;/span&gt;
&lt;span class="k"&gt;bat&lt;/span&gt; &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="k"&gt;ball&lt;/span&gt;    &lt;span class="k"&gt;off&lt;/span&gt;       &lt;span class="k"&gt;absent&lt;/span&gt;        &lt;span class="mf"&gt;41&lt;/span&gt;     &lt;span class="mf"&gt;5&lt;/span&gt;       &lt;span class="mf"&gt;46&lt;/span&gt;        &lt;span class="mf"&gt;0&lt;/span&gt;
&lt;span class="k"&gt;primary&lt;/span&gt; &lt;span class="k"&gt;colour&lt;/span&gt;  &lt;span class="k"&gt;on&lt;/span&gt;        &lt;span class="mf"&gt;42&lt;/span&gt;            &lt;span class="mf"&gt;12&lt;/span&gt;     &lt;span class="mf"&gt;1&lt;/span&gt;       &lt;span class="mf"&gt;55&lt;/span&gt;        &lt;span class="mf"&gt;42&lt;/span&gt;
&lt;span class="k"&gt;primary&lt;/span&gt; &lt;span class="k"&gt;colour&lt;/span&gt;  &lt;span class="k"&gt;off&lt;/span&gt;       &lt;span class="k"&gt;absent&lt;/span&gt;        &lt;span class="mf"&gt;12&lt;/span&gt;     &lt;span class="mf"&gt;1&lt;/span&gt;       &lt;span class="mf"&gt;13&lt;/span&gt;        &lt;span class="mf"&gt;0&lt;/span&gt;
&lt;span class="k"&gt;sequence&lt;/span&gt;        &lt;span class="k"&gt;on&lt;/span&gt;        &lt;span class="mf"&gt;477&lt;/span&gt;           &lt;span class="mf"&gt;31&lt;/span&gt;     &lt;span class="mf"&gt;3&lt;/span&gt;       &lt;span class="mf"&gt;460&lt;/span&gt;       &lt;span class="mf"&gt;426&lt;/span&gt;
&lt;span class="k"&gt;sequence&lt;/span&gt;        &lt;span class="k"&gt;off&lt;/span&gt;       &lt;span class="k"&gt;absent&lt;/span&gt;        &lt;span class="mf"&gt;31&lt;/span&gt;     &lt;span class="mf"&gt;2&lt;/span&gt;       &lt;span class="mf"&gt;33&lt;/span&gt;        &lt;span class="mf"&gt;0&lt;/span&gt;

&lt;span class="k"&gt;thinking&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt;  &lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;3&lt;/span&gt;&lt;span class="err"&gt;/&lt;/span&gt;&lt;span class="mf"&gt;3&lt;/span&gt; &lt;span class="k"&gt;runs&lt;/span&gt; &lt;span class="k"&gt;have&lt;/span&gt; &lt;span class="k"&gt;an&lt;/span&gt; &lt;span class="k"&gt;unaccounted&lt;/span&gt; &lt;span class="k"&gt;gap&lt;/span&gt;
&lt;span class="k"&gt;thinking&lt;/span&gt; &lt;span class="k"&gt;OFF&lt;/span&gt; &lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0&lt;/span&gt;&lt;span class="err"&gt;/&lt;/span&gt;&lt;span class="mf"&gt;3&lt;/span&gt; &lt;span class="k"&gt;runs&lt;/span&gt; &lt;span class="k"&gt;have&lt;/span&gt; &lt;span class="k"&gt;an&lt;/span&gt; &lt;span class="k"&gt;unaccounted&lt;/span&gt; &lt;span class="k"&gt;gap&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With thinking off, &lt;code&gt;thoughtsTokenCount&lt;/code&gt; is absent and LangChain's arithmetic closes exactly. Turn thinking on and the gap opens in every run here. That is 3/3 on short text-only prompts against &lt;code&gt;gemini-2.5-flash&lt;/code&gt; - enough to show the gate is thinking, not enough to characterise every call shape.&lt;/p&gt;

&lt;p&gt;Which turned out to matter, because there is a fifth.&lt;/p&gt;

&lt;h3&gt;
  
  
  The one I found too late
&lt;/h3&gt;

&lt;p&gt;While fact-checking this post I ran the same comparison with thinking &lt;strong&gt;off&lt;/strong&gt; and a tool attached:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;prompt 14 + candidates 101 = 115,  total 150,  gap 35
toolUsePromptTokenCount: 35
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwqqblj41ergvlpb86bjk.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwqqblj41ergvlpb86bjk.png" alt="Terminal output showing toolUsePromptTokenCount of 35 unaccounted for with thinking disabled, and the fix failing to close the gap" width="800" height="282"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Thinking fully off, one tool attached. The gap is still there, and applying my fix does not close it.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;totalTokenCount&lt;/code&gt; also includes &lt;code&gt;toolUsePromptTokenCount&lt;/code&gt;, and &lt;code&gt;convertUsageMetadata&lt;/code&gt; does not read that field either. So the arithmetic breaks on any grounded call, with thinking fully disabled - and my fix does not close it, because it only adds &lt;code&gt;thoughts&lt;/code&gt;. Same shape, fifth occurrence, and it is in neither PR.&lt;/p&gt;

&lt;p&gt;It is not a one-line addition. &lt;code&gt;@langchain/core&lt;/code&gt;'s &lt;code&gt;InputTokenDetails&lt;/code&gt; has slots for cache and modalities but none for tool-use tokens, and &lt;code&gt;@langchain/google-common&lt;/code&gt; declares the field without mapping it either. That is an upstream conversation, not a patch to slip into a review that is already open.&lt;/p&gt;

&lt;p&gt;So the honest scope of what I fixed: reasoning tokens, and the &lt;code&gt;llmOutput&lt;/code&gt; paths. Tool-use tokens have the same defect and are still open.&lt;/p&gt;

&lt;p&gt;The raw SDK call also runs as a span in the same trace as the LangChain call, so the correct count and the wrong count sit side by side in one view.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would take from this
&lt;/h2&gt;

&lt;p&gt;The bug was not hard. One field, plus a delta, plus a pair of parentheses.&lt;/p&gt;

&lt;p&gt;Finding it was hard, and only because nothing was broken in a way anything noticed. No exception, no log line, no failing test. Wrong numbers on a dashboard look exactly like right numbers.&lt;/p&gt;

&lt;p&gt;Here is how well this shape hides. A few minutes after I opened the second PR, &lt;a href="https://github.com/jackjin1997" rel="noopener noreferrer"&gt;@jackjin1997&lt;/a&gt; pointed out another copy of the last-chunk logic in &lt;code&gt;_generateUncached&lt;/code&gt; — a second path in a file I had just finished reading, looking for exactly this. They had a reproduction: two chunks of &lt;code&gt;{4, 6, 10}&lt;/code&gt; and &lt;code&gt;{0, 3, 3}&lt;/code&gt; arriving at &lt;code&gt;handleLLMEnd&lt;/code&gt; as &lt;code&gt;{0, 3, 3}&lt;/code&gt; instead of &lt;code&gt;{4, 9, 13}&lt;/code&gt;. They were right. It went in the same day with their case as a regression test. &lt;a href="https://github.com/langchain-ai/langchainjs/pull/11425" rel="noopener noreferrer"&gt;Their review is here.&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I had the shape in my head, I was actively hunting it, and I still walked past one.&lt;/p&gt;

&lt;p&gt;So the question I am still sitting with: how do you catch a wrong number when nothing throws and the number is plausible? Error rates will not show it. Tests will not show it — you would have to already suspect the field to assert on it. The only thing that worked here was asking what the observability layer actually reads, and then checking whether the answer was true.&lt;/p&gt;

&lt;h2&gt;
  
  
  Links
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Fix 1: &lt;a href="https://github.com/langchain-ai/langchainjs/pull/11423" rel="noopener noreferrer"&gt;#11423&lt;/a&gt; — reasoning tokens, streaming delta, precedence&lt;/li&gt;
&lt;li&gt;Fix 2: &lt;a href="https://github.com/langchain-ai/langchainjs/pull/11425" rel="noopener noreferrer"&gt;#11425&lt;/a&gt; — &lt;code&gt;llmOutput&lt;/code&gt; on both streaming paths&lt;/li&gt;
&lt;li&gt;Issues: &lt;a href="https://github.com/langchain-ai/langchainjs/issues/11422" rel="noopener noreferrer"&gt;#11422&lt;/a&gt;, &lt;a href="https://github.com/langchain-ai/langchainjs/issues/11424" rel="noopener noreferrer"&gt;#11424&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Environment: &lt;code&gt;@langchain/google-genai&lt;/code&gt; 2.3.0, &lt;code&gt;@langchain/core&lt;/code&gt; 1.2.9, &lt;code&gt;@sentry/node&lt;/code&gt; 10.70.0, &lt;code&gt;gemini-2.5-flash&lt;/code&gt;, Node 24.&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>bugsmash</category>
      <category>typescript</category>
      <category>opensource</category>
    </item>
  </channel>
</rss>
