●NANOLITE — Nano Banana 2 Lite is here: Google's fastest and most cost-efficient Gemini Image model, made for running lightweight image generation cheaply●OMNIFLASH — Gemini Omni Flash is in public preview, a natively multimodal model that lets enterprises and developers build custom, dynamic video workflows●AGENTS — Managed Agents expand with background: true for async server-side runs and polling, remote MCP server integration, and refreshing credentials across interactions●MEMORY — The Memory Bank IngestEvents API is generally available, decoupling event ingestion from memory generation so you can stream content continuously●THROUGHPUT — Provisioned Throughput now lets you submit up to seven pending orders for the same model and region●DEPRECATE — Image generation models shut down on August 17, and the Grok 4.1 family on the Gemini Enterprise Agent Platform on August 20●NANOLITE — Nano Banana 2 Lite is here: Google's fastest and most cost-efficient Gemini Image model, made for running lightweight image generation cheaply●OMNIFLASH — Gemini Omni Flash is in public preview, a natively multimodal model that lets enterprises and developers build custom, dynamic video workflows●AGENTS — Managed Agents expand with background: true for async server-side runs and polling, remote MCP server integration, and refreshing credentials across interactions●MEMORY — The Memory Bank IngestEvents API is generally available, decoupling event ingestion from memory generation so you can stream content continuously●THROUGHPUT — Provisioned Throughput now lets you submit up to seven pending orders for the same model and region●DEPRECATE — Image generation models shut down on August 17, and the Grok 4.1 family on the Gemini Enterprise Agent Platform on August 20
Track Gemini API Costs in Production with usageMetadata — A Per-Request Logging Pattern That Reconciles With Your Bill
A production pattern for capturing Gemini API's usageMetadata per request to attribute spend by endpoint, user, and model — hardened for the 3.5 Flash GA era where the default model can shift under you. Covers pricing keyed on resp.model_version and a nightly audit that flags model drift and unknown models before the invoice does.
About six months into using the Gemini API in a side project, I opened a Google Cloud bill that was twice what I had budgeted for. Drilling into the console only got me as far as the daily aggregate — there was no way to see which endpoint, which user, or which model had eaten the budget. The real culprit, I realized, was that I had never bothered to record usageMetadata from each response.
usageMetadata is an unassuming field that ships with every Gemini API response. Logging it per request gives you near-perfect attribution of every dollar after the fact. This article shares the exact pattern I now run in production, including how to reconcile your aggregated logs against the actual Google Cloud invoice.
Know every field inside usageMetadata
The first thing that throws people off is how many fields usageMetadata actually has. Print a response from the Python SDK (google-genai) and you'll see this:
from google import genaiclient = genai.Client(api_key="YOUR_API_KEY")resp = client.models.generate_content( model="gemini-2.5-flash", contents="Explain Gemini API cost tracking in 2 sentences.",)print(resp.usage_metadata)# UsageMetadata(# prompt_token_count=15,# candidates_token_count=84,# cached_content_token_count=0,# thoughts_token_count=0,# tool_use_prompt_token_count=0,# total_token_count=99# )
Here's what each field actually represents:
prompt_token_count — Total input tokens, including system instructions and chat history
cached_content_token_count — Tokens served from Context Caching (typically priced at ~25% of regular input)
candidates_token_count — Output tokens generated by the model (sum across all candidates if candidate_count > 1)
thoughts_token_count — Thinking tokens emitted by Gemini 2.5 series; billed at the same rate as output tokens
tool_use_prompt_token_count — Input tokens consumed internally by Function Calling or Code Execution
total_token_count — Sum of the above
The crucial point: you cannot compute the bill from total_token_count alone. The same 1,000 tokens cost wildly different amounts depending on whether they were input, output, cached, or thinking — so you have to log each component separately.
Append every call to a JSONL file
The pattern I run in production is intentionally boring: a thin wrapper around generate_content that appends usageMetadata to a JSONL file before returning. The "save now, aggregate later" mindset is what saves the most time at month-end.
import jsonimport timefrom pathlib import Pathfrom google import genaiLOG_PATH = Path("logs/gemini_usage.jsonl")LOG_PATH.parent.mkdir(exist_ok=True)client = genai.Client(api_key="YOUR_API_KEY")def call_gemini_logged(model: str, prompt: str, *, user_id: str, endpoint: str): """Wrapper that always writes usageMetadata to JSONL before returning.""" started = time.time() resp = client.models.generate_content(model=model, contents=prompt) um = resp.usage_metadata record = { "ts": time.time(), "elapsed_ms": int((time.time() - started) * 1000), "user_id": user_id, "endpoint": endpoint, "model": model, "prompt": um.prompt_token_count, "cached": um.cached_content_token_count or 0, "candidates": um.candidates_token_count or 0, "thoughts": um.thoughts_token_count or 0, "tool_use": um.tool_use_prompt_token_count or 0, "total": um.total_token_count, } with LOG_PATH.open("a", encoding="utf-8") as f: f.write(json.dumps(record) + "\n") return resp# Exampleresp = call_gemini_logged( model="gemini-2.5-flash", prompt="Summarise today's weather in one line", user_id="u_001", endpoint="/api/weather/summary",)print(resp.text)
JSONL is the right choice for two reasons. First, append writes are nearly atomic, so multiple processes can write without corrupting each other. Second, loading the file later into SQLite or DuckDB is a one-liner with read_json_auto().
✦
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
✦Re-key cost attribution on resp.model_version (the model that actually served the request) so totals stay aligned with the bill even when the default model changes
✦A nightly audit that raises on price-table-missing models instead of silently falling back to Pro pricing
✦Track the share of responses served by a model other than the one requested, catching default-model swaps the day they happen
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.
Embed a per-model price table and aggregate in your local currency
Once logs accumulate, fold them against a price table to convert tokens into dollars. I keep prices in a single Python module so updates are a one-line change.
# pricing.py# USD per 1M tokens (as of April 2026 — always verify against the official price page)PRICING = { "gemini-2.5-pro": { "input": 1.25, "cached": 0.31, "output": 10.00, "thoughts": 10.00, }, "gemini-2.5-flash": { "input": 0.30, "cached": 0.075, "output": 2.50, "thoughts": 2.50, }, "gemini-2.5-flash-lite": { "input": 0.10, "cached": 0.025, "output": 0.40, "thoughts": 0.40, },}def calc_usd(record: dict) -> float: p = PRICING.get(record["model"]) if p is None: # Fall back to Pro pricing for unknown models so you don't under-estimate p = PRICING["gemini-2.5-pro"] input_billable = max(0, record["prompt"] - record.get("cached", 0)) return ( input_billable / 1_000_000 * p["input"] + record.get("cached", 0) / 1_000_000 * p["cached"] + record.get("candidates", 0) / 1_000_000 * p["output"] + record.get("thoughts", 0) / 1_000_000 * p["thoughts"] )
A subtle but important detail: prompt_token_countalready includes any cached tokens, so the actual billable input is prompt - cached. Skip this subtraction and your savings from Context Caching will look invisible — a frustrating surprise after you've gone to the trouble of setting it up.
A weekly aggregation looks like this:
import jsonfrom collections import defaultdictfrom pricing import calc_usdusd_by_endpoint = defaultdict(float)with open("logs/gemini_usage.jsonl") as f: for line in f: rec = json.loads(line) usd_by_endpoint[rec["endpoint"]] += calc_usd(rec)for ep, usd in sorted(usd_by_endpoint.items(), key=lambda x: -x[1]): print(f"{ep:40s} ${usd:>8.4f}")
Sorting by endpoint reveals where the spend is actually going. Every month I find at least one surprise — "image classification preprocessing is more expensive than the inference itself" or "translation never needs Pro, Flash is fine" — that I would have missed without per-endpoint visibility.
Reconcile against the Google Cloud bill at month-end
The reconciliation step takes about five minutes but transforms how confident you feel about scaling the app.
In Google Cloud Console → Billing → Reports, filter by the Generative Language API or Vertex AI line item for the project
Compute the monthly USD total from your JSONL logs
If the difference is within 5% of the actual bill, you're good. Larger gaps mean something is unexplained — investigate
In my experience the three biggest causes of >5% drift are:
Forgetting the free tier — Free-tier requests show up in your logs but never on the bill
Failed requests — Some 4xx failures still consume billable tokens, depending on where they fail
Model alias mismatch — Aliases like gemini-2.5-pro-latest can map to a slightly different SKU than what your log records, so the rates don't line up
The model alias issue is small but it bites often. Pinning to a stable model ID like gemini-2.5-pro-001 in production makes the logs and the bill agree consistently.
Daily budget alerts close the loop in real time
Reconciling at month-end won't stop a runaway loop on the 12th. The simplest safeguard I rely on is checking the day's running total right after each request.
import jsonfrom datetime import datetime, timezonefrom pricing import calc_usdDAILY_BUDGET_USD = 3.0 # roughly a $90/month capdef today_total_usd() -> float: today = datetime.now(timezone.utc).date() total = 0.0 with open("logs/gemini_usage.jsonl") as f: for line in f: rec = json.loads(line) d = datetime.fromtimestamp(rec["ts"], tz=timezone.utc).date() if d == today: total += calc_usd(rec) return total# Call this immediately after a successful requestif today_total_usd() > DAILY_BUDGET_USD: # e.g. POST to a Slack webhook, or short-circuit further calls for the day raise RuntimeError("Daily Gemini budget exceeded")
Setting the threshold to your monthly budget divided by 30 catches almost any runaway behavior the same day it happens. Since I added this guard, the number of times I've been surprised by an end-of-month bill has dropped to zero.
JSONL is great for capturing data, but answering "which user spent the most last week?" is much faster in SQL. The conversion is two lines:
sqlite3 usage.db <<'SQL'.mode jsonCREATE TABLE IF NOT EXISTS usage AS SELECT * FROM read_json('logs/gemini_usage.jsonl');SQL
Once it's a table, the queries you actually want to run are short:
-- Top 10 endpoints by spend in the last 7 daysSELECT endpoint, SUM(prompt - cached) * 0.30 / 1e6 AS input_usd, SUM(candidates + thoughts) * 2.50 / 1e6 AS output_usdFROM usageWHERE ts > strftime('%s','now') - 7*86400 AND model = 'gemini-2.5-flash'GROUP BY endpointORDER BY input_usd + output_usd DESCLIMIT 10;
For a single-developer project the JSONL → SQLite flow stays well under a second even at hundreds of thousands of rows. I only graduated to a real warehouse once the logs crossed about 5 GB, which never happens for personal apps.
Node SDK quirks worth knowing
If you're on the Node SDK (@google/genai), the field names are camelCase rather than snake_case. The shape is otherwise identical:
import { GoogleGenAI } from "@google/genai";const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY });const resp = await ai.models.generateContent({ model: "gemini-2.5-flash", contents: "Hello",});const um = resp.usageMetadata;console.log({ prompt: um.promptTokenCount, cached: um.cachedContentTokenCount ?? 0, candidates: um.candidatesTokenCount ?? 0, thoughts: um.thoughtsTokenCount ?? 0,});
Two gotchas in the Node SDK that I've hit personally:
usageMetadata can be undefined on streaming responses until the final chunk. If you sum across chunks, only count it on the chunk where it appears
cachedContentTokenCount may be missing entirely (not zero) when caching isn't used; treat falsy values as zero rather than crashing on undefined
Your price table drifts silently when the default model changes
Up to here we've keyed the price table on the model we requested. That matched the bill exactly for about six months — until Gemini 3.5 Flash reached general availability in June 2026 and, in some environments, the default model quietly shifted to that new tier. Concretely: a request made through an alias like gemini-flash-latest was actually served by a different model generation. As long as you aggregate by the requested name, that gap never appears in your logs.
What you can trust is the model the response itself reports as having served the request. It lives in resp.model_version (Python SDK) and resp.modelVersion (Node SDK). Key your records on that, and your cost attribution stops drifting when the default moves underneath you.
def call_gemini_logged(model: str, prompt: str, *, user_id: str, endpoint: str): started = time.time() resp = client.models.generate_content(model=model, contents=prompt) um = resp.usage_metadata record = { "ts": time.time(), "elapsed_ms": int((time.time() - started) * 1000), "user_id": user_id, "endpoint": endpoint, "requested_model": model, # what you asked for (may be an alias) "served_model": resp.model_version, # what actually answered — price on this "prompt": um.prompt_token_count, "cached": um.cached_content_token_count or 0, "candidates": um.candidates_token_count or 0, "thoughts": um.thoughts_token_count or 0, "tool_use": um.tool_use_prompt_token_count or 0, "total": um.total_token_count, } with LOG_PATH.open("a", encoding="utf-8") as f: f.write(json.dumps(record, ensure_ascii=False) + "\n") return resp
The cost calculation now reads served_model. One subtlety: model_version won't always match a price-table key like gemini-2.5-flash exactly. A small normalization step that matches on the generation prefix keeps minor suffix differences from slipping through — and matching longest-key-first stops flash-lite from being swallowed by flash.
def resolve_price_key(served_model: str) -> str | None: """Normalize a model_version to a PRICING key; return None if unknown.""" if not served_model: return None m = served_model.lower() for key in sorted(PRICING, key=len, reverse=True): if m.startswith(key) or key in m: return key return Nonedef calc_usd(record: dict) -> float: key = resolve_price_key(record.get("served_model") or record.get("requested_model")) if key is None: # don't fall back to Pro silently — surface it raise KeyError(f"unknown model: {record.get('served_model')}") p = PRICING[key] input_billable = max(0, record["prompt"] - record.get("cached", 0)) return ( input_billable / 1_000_000 * p["input"] + record.get("cached", 0) / 1_000_000 * p["cached"] + record.get("candidates", 0) / 1_000_000 * p["output"] + record.get("thoughts", 0) / 1_000_000 * p["thoughts"] )
The original version "fell back to Pro pricing for unknown models, to stay on the safe side." During a period when the default keeps moving, that kindness backfires: on the first day a new tier becomes the default, every request is booked at Pro rates, your estimate balloons several-fold, and the real cost change is buried. Better to let an unknown model raise than to quietly substitute — you catch up to the correct price table far faster.
Audit the gap between logs and bill every night
Once you record served_model, you no longer have to wait for month-end to watch how often a request was answered by a different model than you asked for. I run this audit as a nightly batch and only alert on drift and unknown models.
import jsonfrom collections import Counterfrom datetime import datetime, timezonedef audit_recent(days: int = 1) -> dict: since = datetime.now(timezone.utc).timestamp() - days * 86400 drift = Counter() # (requested, served) mismatches unknown = Counter() # served_model missing from PRICING total = 0 with open("logs/gemini_usage.jsonl") as f: for line in f: rec = json.loads(line) if rec["ts"] < since: continue total += 1 served = rec.get("served_model") if resolve_price_key(served) is None: unknown[served] += 1 if served and rec.get("requested_model") and \ resolve_price_key(served) != resolve_price_key(rec["requested_model"]): drift[(rec["requested_model"], served)] += 1 return {"total": total, "drift": drift, "unknown": unknown}report = audit_recent(days=1)if report["unknown"]: print("A model with no price-table entry is answering:", dict(report["unknown"]))if report["drift"]: ratio = sum(report["drift"].values()) / max(report["total"], 1) print(f"{ratio:.1%} of responses came from a different model than requested:", dict(report["drift"]))
Anything in unknown is the signal that a new default has arrived. Add one row to PRICING and the next day's totals line up with the bill again. The drift ratio sits at zero in normal times and spikes only on the day the default flips. As an indie developer running this across several small services, catching the change on the day it happens — rather than in next month's invoice — is exactly the kind of early warning that pays for itself.
The smallest first step
You don't need to ship every piece of this on day one. After reading this, the only thing I'd suggest doing today is the five-line wrapper that appends usageMetadata to a JSONL file. Once data is accumulating, you can build aggregations, dashboards, and alerts whenever it makes sense.
Opening the bill at the end of the first month with a complete log file behind you is a small but real moment of relief. It's also, oddly, a little bit fun — there's always a surprise in there worth knowing.
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.