<?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: Ujjwal Tiwari</title>
    <description>The latest articles on DEV Community by Ujjwal Tiwari (@ultron03).</description>
    <link>https://dev.to/ultron03</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%2F1180851%2Fb551efc3-a81b-4d6d-9ffe-b5c136ae4994.jpeg</url>
      <title>DEV Community: Ujjwal Tiwari</title>
      <link>https://dev.to/ultron03</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ultron03"/>
    <language>en</language>
    <item>
      <title>Building Token Budgets for Spring AI: The Problems I Didn't Expect</title>
      <dc:creator>Ujjwal Tiwari</dc:creator>
      <pubDate>Tue, 10 Nov 2026 18:19:00 +0000</pubDate>
      <link>https://dev.to/ultron03/building-token-budgets-for-spring-ai-the-problems-i-didnt-expect-lg2</link>
      <guid>https://dev.to/ultron03/building-token-budgets-for-spring-ai-the-problems-i-didnt-expect-lg2</guid>
      <description>&lt;p&gt;&lt;em&gt;Rate limiters count requests. LLM bills count tokens.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Picture an &lt;code&gt;/agent&lt;/code&gt; endpoint in a Spring Boot app. It has two tools, and on a bad prompt the model decides it needs to call one of them again. And again. One HTTP request, forty model calls, each one sending the whole conversation so far back to the provider. Your rate limiter is happy, because it saw one request.&lt;/p&gt;

&lt;p&gt;Your LLM provider isn't. It just billed you for forty calls.&lt;/p&gt;

&lt;p&gt;That gap is why I wrote &lt;a href="https://github.com/Ultron03/token-allowance" rel="noopener noreferrer"&gt;Token Allowance&lt;/a&gt;, a Spring Boot starter that puts token and dollar budgets on Spring AI &lt;code&gt;ChatClient&lt;/code&gt; calls: per user, per tenant, per session, plus a cap on how many model calls one request may make. This post is about how it works and the parts that turned out harder than they looked.&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%2Fraw.githubusercontent.com%2FUltron03%2Ftoken-allowance-demo%2Fmain%2Fdemo%2Fagent-and-dollars.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%2Fraw.githubusercontent.com%2FUltron03%2Ftoken-allowance-demo%2Fmain%2Fdemo%2Fagent-and-dollars.gif" alt="Token Allowance demo showing an agent stopped after five model calls and dollar budget enforcement"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The agent and dollar-budget walkthrough uses the Gemini profile and requires a Gemini API key. The basic chat and 429 walkthrough also runs locally with Ollama.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why counting requests is the wrong unit
&lt;/h2&gt;

&lt;p&gt;Bucket4j or Resilience4j count requests. For an LLM app, the cost of one request depends on three things a request counter can't see:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;How many tokens went in (a chat with 30 messages of history costs far more than "hi").&lt;/li&gt;
&lt;li&gt;How many came out.&lt;/li&gt;
&lt;li&gt;How many model calls the request made, because every tool-loop round is a separate paid call.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So the budget has to be in tokens or dollars, and it has to be checked per model call, not per HTTP request.&lt;/p&gt;

&lt;h2&gt;
  
  
  What using it looks like
&lt;/h2&gt;

&lt;p&gt;Add the starter next to your Spring AI model starter:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;dependency&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;groupId&amp;gt;&lt;/span&gt;io.github.ultron03&lt;span class="nt"&gt;&amp;lt;/groupId&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;artifactId&amp;gt;&lt;/span&gt;token-allowance-spring-boot-starter&lt;span class="nt"&gt;&amp;lt;/artifactId&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;version&amp;gt;&lt;/span&gt;0.3.0&lt;span class="nt"&gt;&amp;lt;/version&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/dependency&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Set prices (dollars per million tokens, copied from the provider's pricing page) and limits:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;token-allowance&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;pricing&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;models&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
      &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gpt-4o-mini&lt;/span&gt;
        &lt;span class="na"&gt;input-per-mtok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"0.15"&lt;/span&gt;
        &lt;span class="na"&gt;output-per-mtok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"0.60"&lt;/span&gt;
  &lt;span class="na"&gt;limits&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="s"&gt;scope:&lt;/span&gt; &lt;span class="s"&gt;user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;window:&lt;/span&gt; &lt;span class="s"&gt;daily&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;max-cost:&lt;/span&gt; &lt;span class="s2"&gt;"0.50"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="s"&gt;scope:&lt;/span&gt; &lt;span class="s"&gt;global&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;window:&lt;/span&gt; &lt;span class="s"&gt;monthly&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;max-cost:&lt;/span&gt; &lt;span class="s2"&gt;"200.00"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;per-request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;max-model-calls&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Your controller doesn't change. Inject the &lt;code&gt;ChatClient.Builder&lt;/code&gt; Spring AI gives you and build from it. When a user has spent their 50 cents for the day, the next request gets this instead of another paid call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="no"&gt;HTTP&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="mf"&gt;1.1&lt;/span&gt; &lt;span class="mi"&gt;429&lt;/span&gt; &lt;span class="nc"&gt;Too&lt;/span&gt; &lt;span class="nc"&gt;Many&lt;/span&gt; &lt;span class="nc"&gt;Requests&lt;/span&gt;
&lt;span class="nc"&gt;Retry&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nc"&gt;After&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;41520&lt;/span&gt;
&lt;span class="nc"&gt;Content&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nc"&gt;Type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;application&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;problem&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;

&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="s2"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"Token budget exceeded"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="s2"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;429&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="s2"&gt;"detail"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"user daily budget exceeded"&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;Retry-After&lt;/code&gt; is the number of seconds until the day's window resets. The user comes from an advisor param on the call, else the Spring Security principal, else a header that your gateway sets.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the check sits matters more than I expected
&lt;/h2&gt;

&lt;p&gt;Spring AI runs every &lt;code&gt;ChatClient&lt;/code&gt; call through a chain of advisors, ordered by &lt;code&gt;getOrder()&lt;/code&gt;. When you register tools, &lt;code&gt;ChatClient&lt;/code&gt; quietly adds its own &lt;code&gt;ToolCallingAdvisor&lt;/code&gt; to that chain, and the tool loop lives inside it: it calls the rest of the chain, sees a tool call in the answer, runs the tool, and calls the rest of the chain again.&lt;/p&gt;

&lt;p&gt;Before writing any library code I wrote a small spike: a counting advisor placed before &lt;code&gt;ToolCallingAdvisor&lt;/code&gt;, another placed after it, and a fake model that asks for one tool call and then answers. The one before counted 1. The one after counted 2.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Advisor position&lt;/th&gt;
&lt;th&gt;Calls counted&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Before ToolCallingAdvisor&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;After ToolCallingAdvisor&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That one number decides the design.&lt;/p&gt;

&lt;p&gt;An advisor &lt;strong&gt;before&lt;/strong&gt; the tool loop runs once per request and sees the usage of all rounds added up. Good place to ask "is this user already over budget?", useless for stopping a loop halfway.&lt;/p&gt;

&lt;p&gt;An advisor &lt;strong&gt;after&lt;/strong&gt; it runs once per model call and sees each call's own usage. That is the only place where you can count calls and stop at call number 10.&lt;/p&gt;

&lt;p&gt;Meter in both places and you charge every tool loop twice. So Token Allowance has two advisors with two different jobs. Admission sits before the tool loop and denies a request whose budget is already used up. Metering sits right before the model and does the per-call accounting.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check-then-call has a race in it
&lt;/h2&gt;

&lt;p&gt;The obvious version is: read the user's spend, compare with the cap, make the call, add the usage.&lt;/p&gt;

&lt;p&gt;Two parallel requests from the same user both read "40 cents spent of 50". Each request estimates a cost of 8 cents. Both pass the check because both see 10 cents remaining. They then both make their calls, and the actual spend reaches 56 cents.&lt;/p&gt;

&lt;p&gt;The cap means nothing under load.&lt;/p&gt;

&lt;p&gt;So metering reserves first. Before each model call it estimates the cost and adds that estimate to the budget as reserved spend, in one atomic check-and-add across every budget the call counts against, such as user, tenant and global. If any of them has no room, nothing is reserved and the call doesn't happen.&lt;/p&gt;

&lt;p&gt;With reservations, the first request takes the balance from 40 to 48 cents. The second request then sees only 2 cents remaining and gets refused because another 8-cent reservation would exceed the 50-cent limit.&lt;/p&gt;

&lt;p&gt;After the call, the reservation is replaced with the usage the provider reported.&lt;/p&gt;

&lt;p&gt;The estimate is deliberately crude: one token per three characters of prompt, plus the request's max output tokens (or 1,024 if none is set). For English and code that's above the real count, which is the side you want to be wrong on.&lt;/p&gt;

&lt;p&gt;It also has a cost I didn't see coming until I watched the demo. With 1,024 output tokens reserved, a user with 800 tokens left gets refused, even when their answer would have used 100. From their side it looks like the app is lying about their balance. 0.3.0 adds &lt;code&gt;estimate.output-tokens&lt;/code&gt;, so you can reserve a typical answer length instead. A longer answer is still charged in full, so the budget can go over by the difference. You get to choose which way it's wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  Money is a &lt;code&gt;long&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Costs are stored as micro-dollars in a &lt;code&gt;long&lt;/code&gt;, never a &lt;code&gt;double&lt;/code&gt;. Not for accuracy (floating-point error per call is far smaller than a stale price), but because limit comparisons have to be exact, Redis &lt;code&gt;INCRBY&lt;/code&gt; only works on integers, and tests need to be deterministic.&lt;/p&gt;

&lt;p&gt;Each call's cost is rounded up once, on the total, so rounding never favours the user over the cap.&lt;/p&gt;

&lt;h2&gt;
  
  
  The providers don't agree on what "input tokens" means
&lt;/h2&gt;

&lt;p&gt;This one surprised me most. OpenAI's &lt;code&gt;prompt_tokens&lt;/code&gt; includes tokens read from the prompt cache. Anthropic's &lt;code&gt;input_tokens&lt;/code&gt; doesn't: it counts only the tokens after the last cache breakpoint, and cache reads and writes are reported separately. A cached Anthropic call with a big system prompt can report &lt;code&gt;input_tokens: 12&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Spring AI passes both through as &lt;code&gt;promptTokens&lt;/code&gt;. If you price that number directly, cached Anthropic calls look almost free. The metering advisor normalises Anthropic usage, so "input" always means all input tokens, and cache reads and writes are priced on their own.&lt;/p&gt;

&lt;p&gt;Streaming has its own variant. With Ollama, every chunk carries a usage object, but it's 0/0 until the final chunk. With an OpenAI-compatible endpoint and stream usage turned off, every chunk is 0/0, forever.&lt;/p&gt;

&lt;p&gt;So "usage missing" can't be detected by type or null; it has to be "zero tokens reported". When that happens the reserved estimate is charged instead, and a metric records that it was an estimate.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sharing budgets across instances
&lt;/h2&gt;

&lt;p&gt;The default store is in memory, which is fine for one instance. With more than one, each instance would hand out the full budget on its own, so there is a Redis store.&lt;/p&gt;

&lt;p&gt;Reserve and settle are each a single Lua script, so the check-and-add stays atomic across instances. All keys share a &lt;code&gt;{ta}&lt;/code&gt; hash tag, so they land on one Redis Cluster node and one script can touch all of them.&lt;/p&gt;

&lt;p&gt;The harder question was what to do when Redis is down. There's no universally right answer, so it's a setting: &lt;code&gt;fail-closed&lt;/code&gt; returns 503, &lt;code&gt;fail-open&lt;/code&gt; lets calls through unmetered. The default follows the mode: in &lt;code&gt;observe&lt;/code&gt; mode, where you only log over-budget calls, it fails open; in &lt;code&gt;enforce&lt;/code&gt; mode it fails closed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stopping the agent
&lt;/h2&gt;

&lt;p&gt;The per-request cap is what deals with the forty-call loop from the start of this post. Set it app-wide with &lt;code&gt;per-request.max-model-calls&lt;/code&gt;, or per endpoint on the client you hand the agent:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nc"&gt;ChatClient&lt;/span&gt; &lt;span class="n"&gt;agentClient&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;builder&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;defaultAdvisors&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;param&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;RequestLimitParams&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="no"&gt;MAX_MODEL_CALLS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;param&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;RequestLimitParams&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="no"&gt;MAX_REQUEST_COST&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"0.05"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;build&lt;/span&gt;&lt;span class="p"&gt;(&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;After five model calls, the next one is refused with a 429 explaining which cap it hit.&lt;/p&gt;

&lt;p&gt;In the demo, an agent walking an org chart through a &lt;code&gt;managerOf&lt;/code&gt; tool gets cut off after five calls. The agent walkthrough uses the Gemini profile: small local models like &lt;code&gt;llama3.2&lt;/code&gt; often stop calling the tool on their own before reaching the limit.&lt;/p&gt;

&lt;p&gt;On Gemini's free tier, priced at Google's published rates, those five calls came to $0.000418.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it doesn't do
&lt;/h2&gt;

&lt;p&gt;Things you should know before you rely on it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Costs are estimates, not invoices.&lt;/strong&gt; They're your configured prices times the provider's reported usage. Compare them with your bill, and start in &lt;code&gt;observe&lt;/code&gt; mode for that reason.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You type the prices in.&lt;/strong&gt; No provider has an API that returns prices. A bundled price list is the thing I most want to add next.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Only &lt;code&gt;ChatClient&lt;/code&gt; calls are counted.&lt;/strong&gt; Calling a &lt;code&gt;ChatModel&lt;/code&gt; directly, or embedding models, skips the advisors.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Windows are fixed.&lt;/strong&gt; A minute cap can be spent at 10:00:59 and again at 10:01:00.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A budget can go over a little.&lt;/strong&gt; A call that has already happened is always charged, so parallel calls can overshoot by the gap between their estimates and their real usage.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://github.com/Ultron03/token-allowance-demo" rel="noopener noreferrer"&gt;demo app&lt;/a&gt; runs against a local Ollama model for the basic chat and budget walkthrough, so that part costs nothing. The agent and dollar-budget walkthrough uses Gemini and requires a free Gemini API key.&lt;/p&gt;

&lt;p&gt;It comes with terminal walkthroughs and a small web console where you can watch users and a tenant run out of budget.&lt;/p&gt;

&lt;p&gt;The library itself is on &lt;a href="https://github.com/Ultron03/token-allowance" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; and Maven Central under &lt;code&gt;io.github.ultron03&lt;/code&gt;, Apache 2.0.&lt;/p&gt;

&lt;p&gt;If you run Spring AI in production, I'd like to hear which cap you'd actually turn on first, and which one is missing. Issues and discussions are open.&lt;/p&gt;

</description>
      <category>java</category>
      <category>springboot</category>
      <category>ai</category>
      <category>opensource</category>
    </item>
  </channel>
</rss>
