●FLASH — Gemini 3.5 Flash is now generally available and powers gemini-flash-latest●AGENTS — Managed Agents in the Gemini API enter public preview, running autonomously in Google-hosted isolated Linux sandboxes●STUDIO — Google AI Studio adds project-level spend caps and native Android vibe coding●LIVE — Gemini 3.1 Flash Live Preview, an audio-to-audio model for real-time dialogue, is now available●IMAGE — Nano Banana 2 Lite arrives as the fastest, most cost-efficient image model, and Gemini Omni Flash is in API preview●LYRIA — Lyria 3 music generation models (clip-preview and pro-preview) are now available●FLASH — Gemini 3.5 Flash is now generally available and powers gemini-flash-latest●AGENTS — Managed Agents in the Gemini API enter public preview, running autonomously in Google-hosted isolated Linux sandboxes●STUDIO — Google AI Studio adds project-level spend caps and native Android vibe coding●LIVE — Gemini 3.1 Flash Live Preview, an audio-to-audio model for real-time dialogue, is now available●IMAGE — Nano Banana 2 Lite arrives as the fastest, most cost-efficient image model, and Gemini Omni Flash is in API preview●LYRIA — Lyria 3 music generation models (clip-preview and pro-preview) are now available
Setting a Token Budget Per Free User: Balancing AdMob Revenue Against AI Feature Cost
Rate limits protect requests per minute. They do nothing for the invoice that arrives at the end of the month. Here is how I derive a per-user token budget from ad revenue, keep the ledger inside a single call wrapper, degrade gracefully at a soft cap, and detect abuse with one concentration ratio.
Last month I put the AdMob report next to the Gemini API invoice, and only one of the two numbers had moved.
Ad revenue: essentially flat. API cost: 2.4x the previous month.
No outage, no bug. Digging through the logs, a single user had been calling the AI feature more than 300 times a day. Nothing malicious as far as I could tell — someone had simply found a feature they enjoyed and kept pulling the lever. The rate limiter had worked perfectly. Requests per minute stayed under the cap. Daily call counts stayed under the cap.
The thing that was not protected was the invoice.
What a rate limit protects, and what it does not
A rate limit governs flow per unit of time. What we actually want to protect is money per billing period. These look similar and measure entirely different quantities.
Ten calls a minute and 300 calls a day is not, on its face, a broken policy. But 300 x 30 = 9,000 calls from one free user is absurd. It is dozens of times more than that user will ever return in ad revenue.
When you run apps as an indie developer, that asymmetry lands directly on your bank balance. The month your user count grows is the month your balance shrinks.
What you need is a guard on the money axis. And a guard on the money axis needs a number derived from revenue.
Derive the budget from what a user is worth
Start with what one free user earns you in a month. My apps monetize primarily through AdMob interstitials, so the figure comes from eCPM and impressions per user.
Input
Value
Source
eCPM
JPY 380
AdMob, trailing 30 days
Daily impressions per DAU
3.0
AdMob ÷ Firebase DAU
Daily revenue per DAU
JPY 1.14
380 ÷ 1000 × 3.0
Median active days per month
9
In-app instrumentation
Monthly ARPU
JPY 10.3
1.14 × 9
Next, decide what share of that may go to AI cost. I cap it at 30%. After server costs and payment fees, that is the level at which the feature still pays for itself.
So the per-user monthly token budget is about JPY 3.0. Now apply your effective unit cost.
Use the number you back out of your own invoice, not the published rate card. Cache hit ratio and prompt length move the per-call figure substantially for the same model. Mine came to JPY 0.42 per call on average.
Metric
My measurement
Implied ceiling
Monthly ARPU
JPY 10.3
—
Max AI cost share
30%
JPY 3.09 per user / month
Effective cost per call
JPY 0.42
—
Monthly call ceiling, free tier
—
7 calls
Seven. The first time that number appeared on my screen I sat with it for a while. Offering an unlimited AI feature on a free tier had never been arithmetically coherent in the first place.
Budgeting in tokens rather than calls is more precise, so in the implementation I convert to a token-denominated budget — 7,500 weighted tokens per month, in my case.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦A worked calculation that turns eCPM and DAU into a per-user monthly spend ceiling, applied to my measured effective cost of JPY 0.42 per call
✦Python code that reconstructs true cost from the four usage_metadata fields and keeps the per-user ledger inside one call wrapper
✦A three-step degradation ladder (model downgrade at 70%, cached-only at 100%) plus the top-1% concentration ratio that surfaces abusive clients
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
Reconstruct true cost from four usage_metadata fields
Before you can hold a budget you need the exact cost of one call. Four fields on the response's usage_metadata give you that.
Field
Meaning
Billing treatment
prompt_token_count
Total input tokens
Includes cached tokens — subtract them
cached_content_token_count
Context cache hits
Cheaper than fresh input
candidates_token_count
Tokens returned as the answer
Output rate
thoughts_token_count
Thinking tokens
Output rate. Not included in candidates
That fourth row is where I lost money first. More on that below.
Keep the call wrapper in exactly one place. If parts of your app reach for the SDK directly, the ledger will leak.
# gemini_budget.pyfrom dataclasses import dataclassfrom google import genaiclient = genai.Client()# Effective rates (JPY per 1M tokens), backed out of my own invoice. Revisit quarterly.PRICE_JPY_PER_MTOK = { "input": 45.0, "cached": 11.0, "output": 360.0, "thoughts": 360.0,}@dataclassclass Usage: fresh_input: int cached_input: int output: int thoughts: int @property def cost_jpy(self) -> float: p = PRICE_JPY_PER_MTOK return ( self.fresh_input * p["input"] + self.cached_input * p["cached"] + self.output * p["output"] + self.thoughts * p["thoughts"] ) / 1_000_000 @property def weighted_tokens(self) -> int: # Denominate the budget in input-equivalent tokens so a price change # does not orphan the historical ledger. return int(self.cost_jpy / (PRICE_JPY_PER_MTOK["input"] / 1_000_000))def extract_usage(response) -> Usage: u = response.usage_metadata cached = getattr(u, "cached_content_token_count", 0) or 0 thoughts = getattr(u, "thoughts_token_count", 0) or 0 prompt = u.prompt_token_count or 0 output = u.candidates_token_count or 0 return Usage( fresh_input=max(prompt - cached, 0), cached_input=cached, output=output, thoughts=thoughts, )
The weighted_tokens indirection matters. A budget held in currency breaks continuity every time a rate changes or you swap models. Held in input-equivalent tokens, you only swap the price table.
Hold the cap in stages, not as a wall
Cutting a feature off the instant it hits 100% is indistinguishable, from the user's side, from the feature being broken. I run a three-step ladder instead.
Consumption
Behavior
What the user sees
0–70%
Normal model
No change
70–100%
Downgrade model, reduce thinking level
Slightly terser answers
Over 100%
Serve cached results only
A note about the monthly limit and the reset date
The downgrade at 70% exists to slow the burn rate itself. The lighter model costs roughly a quarter as much per call, so the remaining 30% of budget buys close to four times the calls it otherwise would. The share of users who reach the hard cap fell from 3.1% to 0.4% for me.
select_tier before the call, ledger.record after. Keep that order. Reversed, every user pays for exactly one over-budget call before the guard engages. For one user that is rounding error; on the day a few hundred users hit the ceiling together, it is not.
Set the project-level spend cap on the Gemini API side as a last line of defense as well. Your ledger cannot see calls that happen outside your ledger.
Abuse shows up as one concentration ratio
Averages hide abuse. The single number I watch is the share of total spend consumed by the top 1% of users.
In a healthy app it sits somewhere around 8–15%. Above 20%, there is an automated client or someone stuck in a loop with the feature.
# concentration.pyimport statisticsdef top_share(spend_by_user: dict[str, float], top_pct: float = 0.01) -> float: values = sorted(spend_by_user.values(), reverse=True) if not values: return 0.0 k = max(1, int(len(values) * top_pct)) return sum(values[:k]) / sum(values)def anomaly_report(spend_by_user: dict[str, float]) -> dict: values = list(spend_by_user.values()) median = statistics.median(values) outliers = { uid: v for uid, v in spend_by_user.items() if median > 0 and v > median * 20 } return { "top1_share": round(top_share(spend_by_user), 3), "median_tokens": median, "outlier_users": sorted(outliers, key=outliers.get, reverse=True)[:20], }
The 20x-the-median threshold has no principled basis. It is simply where, in my data, legitimate power users separated from automated clients. Plot the histogram on your own logs before you pick a number.
I run this daily and alert only on days when top1_share clears 20%. A report that arrives every morning stops being read by Wednesday.
Four places I was counting wrong
For about two months, my ledger and my invoice disagreed by roughly 10%. Every cause was on the accounting side.
1. Thinking tokens were missing
thoughts_token_count is not part of candidates_token_count. On a thinking-enabled model, a large fraction of your real output cost lives in those thinking tokens. That single omission left my ledger about 7% under.
2. Retries were double-counted
A call that returned 429 and was retried can still carry usage_metadata on the failed attempt. I wrote it into the wrapper contract: record exactly one successful response. The reverse case remains unresolved — a call that timed out on my side may still have been billed, and it never reaches the ledger at all.
3. Cache hits were priced as fresh input
I was multiplying prompt_token_count by the input rate without subtracting cached_content_token_count. The more effectively a feature used caching, the more the ledger overstated it — and the less the savings showed up. I spent a while puzzled by that.
4. The reset boundary drifted by nine hours
The ledger reset at the start of the UTC month while I read the invoice in Japan time. Nine hours a month, the two numbers described different periods. Aligning both to JST brought the gap under 1%.
Where to start today
None of this needs to be built at once. I recommend this order.
Funnel every call through one wrapper and simply log the four usage_metadata fields. No budget enforcement yet.
After a week, compute the top-1% share and the median from those logs, and look at your actual distribution.
Only then choose the budget and the thresholds — and ship the soft cap first.
Do it in the other order and the limit will be either far too tight or meaningless. My first attempt was a groundless "ten calls a day," and it throttled legitimate users before it touched anyone else.
Look at the numbers, then decide. Obvious enough, and yet when the topic turns to cost, the ceiling is always the first thing we want to pick.
Share
Thank You for Reading
Gemini Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.