"count_tokens() returned 1,200 tokens, so I built my monthly cost model around that. Two months later, the Cloud Console showed nearly four times the usage I budgeted for." That was me, in my second month of shipping a Gemini-powered indie app. I only noticed when the free-tier overage email landed.
The thing is: count_tokens() is a useful tool, but the number it returns rarely matches the billed token count in usage_metadata.total_token_count. About five distinct factors inflate the real charge, and most cost-modeling spreadsheets I see online quietly ignore four of them. In this article, I'll walk you through each contributor with reproducible code and finish with a template that gets your forecasts within striking distance of the actual bill.
Step zero: observe the gap precisely
Before chasing causes, put the two numbers side by side in a single script. Comparing the count_tokens() estimate with usage_metadata from the same prompt makes every later step easier — no more "I think it's higher" hand-waving.
# tools/compare_token_count.py
# Goal: compare the count_tokens estimate with the actual usage_metadata in one place
import os
from google import genai
from google.genai import types
client = genai.Client(api_key=os.environ["GEMINI_API_KEY"])
MODEL = "gemini-2.5-flash"
def compare(prompt: str, system_instruction: str | None = None, tools: list | None = None):
config = types.GenerateContentConfig(
system_instruction=system_instruction,
tools=tools,
)
# 1. Pre-flight estimate (free tokenizer call, no billing)
estimated = client.models.count_tokens(model=MODEL, contents=prompt).total_tokens
# 2. Real request
res = client.models.generate_content(model=MODEL, contents=prompt, config=config)
usage = res.usage_metadata
print(f"prompt : {prompt[:40]}...")
print(f"count_tokens : {estimated}")
print(f"usage prompt : {usage.prompt_token_count}")
print(f"usage thoughts: {getattr(usage, 'thoughts_token_count', 0)}")
print(f"usage output : {usage.candidates_token_count}")
print(f"usage TOTAL : {usage.total_token_count}")
print(f"diff (est→real): {usage.total_token_count - estimated:+d}")
compare("Briefly explain how Gemini API token billing works.")Run this against your real workload before going any further. The bigger the gap, the more likely one of the five contributors below is in play.
Cause 1: thinking models bill "thought tokens" as a separate line item
The first thing to internalize: Gemini 2.5 thinking models (gemini-2.5-pro / gemini-2.5-flash) emit a separate thoughts_token_count that is billed independently. count_tokens() only measures input tokens, so this category is invisible to your forecast by default.
In practice, on long reasoning tasks I've seen thought tokens come in at 2–3x the visible output. Pro charges them at the output rate, so missing this contributor alone can multiply your bill by 2–3x.
# Practical knobs to keep thought tokens predictable
config = types.GenerateContentConfig(
thinking_config=types.ThinkingConfig(
thinking_budget=512, # Hard cap
include_thoughts=False, # Don't return summaries
),
)In chat-style apps I cap thinking_budget at 512 to keep monthly variance compressed. For coding and analysis workloads that's too aggressive — quality drops noticeably — so I keep separate presets per use case rather than one global value. The full sizing approach is in the Gemini 2.5 thinking budget optimization guide.
The sneaky part is that thought tokens scale with task complexity, not with output length. A 50-word answer to a hard reasoning question can carry 2,000 thought tokens behind it. If you build cost forecasts purely from observed output lengths, you'll consistently underestimate thinking-model bills. Whenever I onboard a team to Gemini 2.5, the first dashboard I have them build is "thoughts vs output ratio per route" — that single chart usually surfaces an overlooked endpoint within the first day.
Cause 2: tool definitions are an "invisible prompt" that's billed
This one catches a lot of people. When you pass tools=[my_function], that function's schema is sent as part of the prompt and counted toward prompt_token_count. count_tokens() only inspects contents, so tool tokens fall outside its view.
# Demonstration: tool definitions inflate billed prompt tokens
def get_weather(location: str) -> str:
"""Return the current weather for the given city."""
return "sunny"
# Compare with-tools vs no-tools
for has_tools in [False, True]:
cfg = types.GenerateContentConfig(tools=[get_weather] if has_tools else None)
r = client.models.generate_content(
model=MODEL,
contents="Should I head out today?",
config=cfg,
)
label = "with tools" if has_tools else "no tools"
print(f"{label}: prompt_tokens={r.usage_metadata.prompt_token_count}")In my own measurements, each function adds roughly 50–120 tokens of overhead per request, mostly driven by the length of the schema's description fields. Keep tool definitions short and lean — every adjective you trim is a few thousand tokens of monthly savings on a high-traffic endpoint. For more on the system-instruction / tool-definition split, see seven reasons Gemini API ignores your system instruction.
Cause 3: images, video, and audio are billed by tile, not by character
Multimodal inputs are where forecasts go furthest off the rails. Text scales roughly with byte length, but images, video, and audio each follow their own formula.
These are the rough numbers I keep on a sticky note:
- Images: ~258 tokens flat for anything up to 768×768. Above that the image is split into 768px tiles and you pay 258 tokens per tile.
- Video: about 263 tokens per second on Gemini 2.5 Flash, varying with resolution.
- Audio: about 32 tokens per second.
count_tokens(contents=[image_part]) does support multimodal parts, but URI-based files require uploading through the File API first. Long videos and high-resolution images can each add thousands of tokens, so always pass the actual multimodal parts when you call count_tokens().
# Multimodal pre-flight estimate
import pathlib
from google.genai import types
img = types.Part.from_bytes(
data=pathlib.Path("./screenshot.png").read_bytes(),
mime_type="image/png",
)
prompt_parts = [img, "List three likely error causes from this screen."]
est = client.models.count_tokens(model=MODEL, contents=prompt_parts).total_tokens
print(f"multimodal estimated: {est}")The full multimodal cost-tuning playbook is in optimizing multimodal input cost on Gemini API.
Cause 4: system instruction and chat history are resent on every turn
This is the one I see overlooked the most. system_instruction and any chat history you maintain are resent in full on every request, so you pay for them every turn.
A "200-token system instruction plus 200-turn conversation" chat looks innocent until you realize the 200th turn ships every prior message back to the API. To get a realistic estimate, you have to call count_tokens() with the same contents list (history + new message) you'll actually send.
# Realistic estimate that includes chat history
history = [
types.Content(role="user", parts=[types.Part.from_text("first question")]),
types.Content(role="model", parts=[types.Part.from_text("first answer")]),
# ... longer histories mean larger billed prompts every turn
]
new_user_msg = types.Content(role="user", parts=[types.Part.from_text("follow-up question")])
contents = history + [new_user_msg]
est = client.models.count_tokens(model=MODEL, contents=contents).total_tokens
print(f"with history: {est}")In production, naively appending every turn breaks down quickly on cost. My default pattern is rolling summarization: keep the last N turns verbatim, replace earlier turns with a model-generated summary. The savings are visible in the first week and the implementation is approachable for solo developers.
A useful sanity check: write down the maximum conversation length you actually want to support, multiply that by your average per-turn token count, and see what count_tokens() returns for the resulting payload. If the worst-case number is higher than your monthly budget tolerates, decide on a truncation strategy before launch — not after the first user has a 300-turn conversation that costs more than their subscription. I learned this the hard way: my first chat-style indie app had a single power user whose 280-turn conversation accounted for nearly 18% of that month's API bill.
Cause 5: Context Caching introduces a separate billed line at a discounted rate
The final landmine is Context Caching. When you use cached_content, usage_metadata gains a cached_content_token_count field, and those tokens bill at roughly 25% of the normal input rate (it varies by model).
That means your forecast is wrong in both directions if you treat tokens uniformly. If you forget about the cached portion, you over-estimate; if you assume cache pricing for everything, you under-estimate. A correct forecast separates the two pools.
# Always re-check pricing on the official page; values shown are illustrative
PRICE_INPUT = 0.30 / 1_000_000 # e.g. $0.30 / 1M tokens
PRICE_CACHED_INPUT = 0.075 / 1_000_000 # e.g. $0.075 / 1M tokens
PRICE_OUTPUT = 2.50 / 1_000_000 # e.g. $2.50 / 1M tokens
PRICE_THOUGHTS = 2.50 / 1_000_000 # thinking models: same as output
def estimate_cost(usage) -> float:
cached = getattr(usage, "cached_content_token_count", 0) or 0
fresh_input = usage.prompt_token_count - cached
thoughts = getattr(usage, "thoughts_token_count", 0) or 0
output = usage.candidates_token_count
return (
fresh_input * PRICE_INPUT
+ cached * PRICE_CACHED_INPUT
+ output * PRICE_OUTPUT
+ thoughts * PRICE_THOUGHTS
)The full caching design is covered in Context Caching cost optimization on Gemini API.
A "production-shaped estimate" template
Pulling all five causes together, this is the template I now stand up at the start of every Gemini-based project before I commit to a pricing plan. Even for small indie apps, doing this once before you ship saves a lot of late-night Console anxiety.
# tools/realistic_estimate.py
# Goal: count_tokens with the full payload, then correct for the 5 hidden contributors
from dataclasses import dataclass
@dataclass
class CostEstimate:
prompt_tokens: int
expected_output: int
expected_thoughts: int
cached_tokens: int
def total_cost(self) -> float:
fresh = self.prompt_tokens - self.cached_tokens
return (
fresh * PRICE_INPUT
+ self.cached_tokens * PRICE_CACHED_INPUT
+ self.expected_output * PRICE_OUTPUT
+ self.expected_thoughts * PRICE_THOUGHTS
)
# Usage
prompt_tokens = client.models.count_tokens(
model=MODEL,
contents=full_contents_with_history_and_tools_and_media,
).total_tokens
estimate = CostEstimate(
prompt_tokens=prompt_tokens,
expected_output=400, # 95th percentile from logs
expected_thoughts=600, # measured value if using a thinking model
cached_tokens=0, # only set when cache is used
)
print(f"per request: ${estimate.total_cost():.6f}")Start with educated guesses for the "expected" fields. Once you have a week of production logs, replace them with the 95th percentile observed in real traffic — at that point, you'll be able to predict the monthly bill within a few percent.
Putting it together: a worked example
Here's a worked example from one of my own apps — a multimodal note-taking app where a user uploads a photo of a whiteboard along with a short request like "summarize this into action items."
A naive count_tokens() on the text part returned 22 tokens. The actual prompt_token_count came back at 1,068 — nearly 50x the estimate. Walking through the contributors:
- The whiteboard photo was 1536x1152, which split into four 768px tiles, adding 4 × 258 = 1,032 tokens.
- The system instruction (about 80 tokens) is invisible to
count_tokens()when passed viaconfig.system_instruction. - A small
tools=[create_action_item]definition added another 90 tokens of schema overhead.
Add 22 + 1,032 + 80 + 90 and you land at roughly 1,224 — still 156 tokens short of the real number, which I attribute to internal framing tokens added by the API. That gap is small enough to model with a flat 5–10% buffer, and the structure of the estimate now matches reality. Without doing this exercise, my pricing page would have been mis-calibrated by an order of magnitude.
The lesson I took away: the moment your app uses any combination of multimodal input, system instructions, tools, or thinking, count_tokens() becomes a starting point — not the answer. Build a thin wrapper that runs the full configuration through the API, and base your forecasts on what comes out of usage_metadata over your first week of real traffic.
Three misconceptions I keep hearing
The first is "if I set a budget alert based on count_tokens() results, I'm safe." That alert misses every contributor above and is anchored to a number that's much smaller than the real bill — you'll blow past it midway through the month. Anchor budget alerts on accumulated usage_metadata.total_token_count instead.
The second is "I'm on the free tier, so I don't need to think about cost yet." The free tier rate-limits you with 429 errors, but the moment you upgrade, all five contributors above turn into real money simultaneously. The single most common support thread I see is "I migrated to paid and my budget exploded the same week."
The third is "if I disable thinking, thought tokens stop being billed." On gemini-2.5-flash, you have to set thinking_config.thinking_budget=0 explicitly. Without that flag, the model still spends a small budget internally and you still get billed. Make this knob explicit in code, not implicit.
Where to go from here
The fastest first step is to log usage_metadata from every production request and store it for at least a week. Once you have that data, the ratio between count_tokens() predictions and actual total_token_count will stabilize, and you can start applying correction factors per route or per use case. For deeper instrumentation patterns — including dashboards and per-user cost tracking — pair this article with tracking Gemini API cost via usage_metadata.