●PRICE — Gemini 3.6 Flash consumes about 17% fewer output tokens and costs less at $1.50 per 1M input and $7.50 per 1M output, against $9 output for 3.5 Flash●LITE — Gemini 3.5 Flash-Lite targets high-throughput work at $0.3 per million input tokens●CYBER — Gemini 3.5 Flash Cyber powers vulnerability detection and patching inside Google's CodeMender agent●GEMINI4 — Google says it has already begun its most ambitious pre-training run yet, for Gemini 4, even as 3.5 Pro slips●SUNSET — The Imagen 4 and Gemini 3 Image generation models shut down on August 17, 2026, so integrations need moving to newer stable or preview endpoints●STUDIO — Gemini Omni Flash is available in Google AI Studio for the first time, putting cost-efficient video generation and conversational editing within reach●PRICE — Gemini 3.6 Flash consumes about 17% fewer output tokens and costs less at $1.50 per 1M input and $7.50 per 1M output, against $9 output for 3.5 Flash●LITE — Gemini 3.5 Flash-Lite targets high-throughput work at $0.3 per million input tokens●CYBER — Gemini 3.5 Flash Cyber powers vulnerability detection and patching inside Google's CodeMender agent●GEMINI4 — Google says it has already begun its most ambitious pre-training run yet, for Gemini 4, even as 3.5 Pro slips●SUNSET — The Imagen 4 and Gemini 3 Image generation models shut down on August 17, 2026, so integrations need moving to newer stable or preview endpoints●STUDIO — Gemini Omni Flash is available in Google AI Studio for the first time, putting cost-efficient video generation and conversational editing within reach
When Version Numbers Stopped Meaning Generations: Rebuilding Cost Attribution That Parsed Model IDs
A regex that derived generation and tier from Gemini model IDs broke quietly once Flash reached 3.6 while the top Pro stayed at 3.5. Here are the runnable probes, the attribution gap between regex-derived and registry-joined rollups, and the redesign that treats model IDs as opaque keys.
During the week of July 21, a band on my dashboard labeled "3.5 series" started sliding in a way I could not explain.
Call volume had not moved. Only the share of output tokens was falling. Nothing smelled like an outage, yet the numbers were quietly drifting.
Tracing it back, the cause was neither a model shutdown nor a prompt change. My rollup layer was reading generation and tier out of the model ID string, and that reading had lost its meaning the moment the lineup stopped lining up.
I run a wallpaper app's classification pipeline and a handful of automations as an indie developer. Every time a new call site appeared, I treated the model ID as a string packed with information rather than as a key. It was convenient — and it failed just as quietly.
The moment "3.5 series" stopped describing anything
The implementation was ordinary. A daily usage table keyed by model_id, and a dashboard layer applying a regex to derive generation and tier. Adding a model required no dashboard change, so it served me well for months.
It rested on two assumptions:
The number in a model ID marks the generation, and larger means newer
The third hyphen-delimited token marks the tier (flash or pro)
The July 2026 lineup satisfies neither. The newest Flash is 3.6, the top Pro is 3.5, the image model sits at 3.1, and Omni Flash carries no number at all. The digits no longer track generation, and they never tracked capability.
Model ID
Position
Readable from the number?
gemini-3.6-flash
Newer Flash release (July 21)
Yes
gemini-3.5-pro
Top of the Pro line
Number is lower than 3.6
gemini-3.5-flash-lite
Lightweight, high-volume work
Third token reads flash
gemini-3.1-flash-lite-image
Image generation and editing
Third token reads flash
gemini-omni-flash
Video generation preview
No number
imagen-4.0-generate-001
Image model scheduled for shutdown
Different prefix
Model names and availability move. If you keep a local table, confirm it against the official model list and the pricing page before acting on it.
Running the regex against the actual lineup
Rather than argue about it, I pulled the regex I had been using and ran it over the real IDs.
# parse_probe.py — the old derivation, applied to real IDsimport reMODELS = [ "gemini-3.6-flash", "gemini-3.5-flash", "gemini-3.5-flash-lite", "gemini-3.5-flash-cyber", "gemini-3.5-pro", "gemini-3.1-flash-lite-image", "gemini-omni-flash", "gemini-flash-latest", "imagen-4.0-generate-001",]PAT = re.compile(r"^gemini-(?P<major>\d+)\.(?P<minor>\d+)-(?P<tier>[a-z]+)")print(f"{'model_id':<30} {'gen':<8} {'tier':<8} note")print("-" * 62)for m in MODELS: hit = PAT.match(m) if not hit: print(f"{m:<30} {'-':<8} {'-':<8} UNPARSED -> falls to default bucket") continue gen = f"{hit['major']}.{hit['minor']}" print(f"{m:<30} {gen:<8} {hit['tier']:<8} parsed")
The output:
model_id gen tier note--------------------------------------------------------------gemini-3.6-flash 3.6 flash parsedgemini-3.5-flash 3.5 flash parsedgemini-3.5-flash-lite 3.5 flash parsedgemini-3.5-flash-cyber 3.5 flash parsedgemini-3.5-pro 3.5 pro parsedgemini-3.1-flash-lite-image 3.1 flash parsedgemini-omni-flash - - UNPARSED -> falls to default bucketgemini-flash-latest - - UNPARSED -> falls to default bucketimagen-4.0-generate-001 - - UNPARSED -> falls to default bucket
The shape of the failure is what mattered.
The three unparsed IDs are recoverable. They land in a - bucket, so a glance at the chart tells you something unfamiliar has arrived.
The four that parsed are the dangerous ones. flash-lite, flash-cyber, and flash-lite-image all pass through as tier=flash. No exception is raised. Nothing lands in a log. The values simply blend.
Parse errors are visible. Semantic mismatches are not. The half day I lost went to the second kind.
✦
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 probe run against the real July 2026 lineup showing flash-lite and flash-lite-image silently collapsing into the flash bucket
✦Three reasonable implementations of pick the newest model returning gemini-3.5-pro, gemini-3.6-flash, and gemini-3.10-flash
✦A SQLite harness where output tokens per call reads 26.52 under regex rollup and 100.00 under a registry join — a 3.8x gap
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.
Three plausible implementations, three different answers.
I included 3.10 because a two-digit minor could appear at any time. Under both string comparison and float conversion, 3.10 sorts below 3.6. The moment a version is treated as a decimal, .10 collapses into .1.
And note that [1] returned gemini-3.6-flash over gemini-3.5-pro. If reasoning-heavy work is routed by the phrase "newest model," it drifts toward Flash on nothing but digit comparison. No error, a valid response, and only the quality moves.
A wrong selection eventually surfaces as response quality. A wrong rollup lands straight in the ledger and stays there.
I loaded my usage ledger into SQLite and put the regex-derived tier next to a registry join. The ledger below reproduces the shape only — substitute your own export.
# attribution.py — regex derivation vs. registry joinimport re, sqlite3con = sqlite3.connect(":memory:")con.executescript("""CREATE TABLE usage_fact (day TEXT, model_id TEXT, calls INT, in_tok INT, out_tok INT);CREATE TABLE model_registry ( model_id TEXT PRIMARY KEY, family TEXT, generation TEXT, tier TEXT, modality TEXT);""")rows = [ ("2026-07-01", "gemini-3.5-flash", 9800, 4_900_000, 980_000), ("2026-07-01", "gemini-3.5-flash-lite", 41000, 8_200_000, 410_000), ("2026-07-01", "gemini-3.1-flash-lite-image", 2600, 520_000, 26_000), ("2026-07-01", "gemini-3.5-pro", 420, 1_260_000, 336_000), ("2026-07-22", "gemini-3.6-flash", 9100, 4_550_000, 755_000), ("2026-07-22", "gemini-3.5-flash-lite", 40500, 8_100_000, 405_000), ("2026-07-22", "gemini-3.1-flash-lite-image", 2700, 540_000, 27_000), ("2026-07-22", "gemini-3.5-pro", 400, 1_200_000, 320_000), ("2026-07-22", "gemini-omni-flash", 90, 45_000, 18_000),]con.executemany("INSERT INTO usage_fact VALUES (?,?,?,?,?)", rows)reg = [ ("gemini-3.5-flash", "flash", "3.5", "flash", "text"), ("gemini-3.6-flash", "flash", "3.6", "flash", "text"), ("gemini-3.5-flash-lite", "flash-lite", "3.5", "flash-lite", "text"), ("gemini-3.5-pro", "pro", "3.5", "pro", "text"), ("gemini-3.1-flash-lite-image", "image", "3.1", "flash-lite", "image"), ("gemini-omni-flash", "omni", "n/a", "omni", "video"),]con.executemany("INSERT INTO model_registry VALUES (?,?,?,?,?)", reg)PAT = re.compile(r"^gemini-(\d+)\.(\d+)-([a-z]+)")def derive(mid): h = PAT.match(mid) return (f"{h.group(1)}.{h.group(2)}", h.group(3)) if h else ("unknown", "unknown")print(f"{'tier(regex)':<14}{'out_tok':>12} | {'tier(registry)':<16}{'out_tok':>12}")a = {}for mid, out in con.execute("SELECT model_id, SUM(out_tok) FROM usage_fact GROUP BY model_id"): k = derive(mid)[1] a[k] = a.get(k, 0) + outb = dict(con.execute("""SELECT r.tier, SUM(f.out_tok) FROM usage_fact f JOIN model_registry r USING(model_id) GROUP BY r.tier"""))for k in sorted(set(a) | set(b)): print(f"{k:<14}{a.get(k,0):>12,} | {k:<16}{b.get(k,0):>12,}")
All 868,000 output tokens belonging to flash-lite were absorbed into flash, inflating that bucket to 2,603,000 against an actual 1,735,000 — roughly 1.5x.
Breaking the bucket down shows the shape of the contamination:
gemini-3.1-flash-lite-image 53,000 ( 2.0% of regex 'flash') gemini-3.5-flash 980,000 ( 37.6% of regex 'flash') gemini-3.5-flash-lite 815,000 ( 31.3% of regex 'flash') gemini-3.6-flash 755,000 ( 29.0% of regex 'flash')
Over 30 percent of what I was reading as "the Flash line" belonged to a different price band entirely. Text generation and image generation shared one container. I had built the ledger to see per-feature economics, and the container itself was mixed. It shares a root with the attribution problem I covered in seeing unit economics per feature.
The measurement of a price improvement gets diluted
This is the part that stung.
Gemini 3.6 Flash is described as consuming fewer output tokens for the same work. If you are deciding whether to switch, comparing output tokens per call before and after is the natural check.
I computed that metric under both groupings.
# effect.py — the same switch, measured two waysdef per_call(day, pred): c = o = 0 for mid, cl, ot in con.execute( "SELECT model_id, calls, out_tok FROM usage_fact WHERE day=?", (day,)): if pred(mid): c += cl o += ot return o / c if c else 0regex_flash = lambda m: (PAT.match(m).group(3) if PAT.match(m) else None) == "flash"reg_flash = lambda m: m in ("gemini-3.5-flash", "gemini-3.6-flash")for label, pred in (("regex tier='flash'", regex_flash), ("registry tier='flash'", reg_flash)): b, a = per_call("2026-07-01", pred), per_call("2026-07-22", pred) print(f"{label:<24} out_tok/call {b:7.2f} -> {a:7.2f} ({(a-b)/b*100:+5.1f}%)")
The reduction reads -17.0% under the registry join and -14.4% under the regex rollup. On its own that gap might pass for noise.
Look at the levels instead: 26.52 versus 100.00, a gap of roughly 3.8x. The high-volume lightweight model sat in the same container and dragged the average down.
Any ceiling or budget drawn from that level would have been drawn far from reality. The regex view survives a directional question — did it go down? — but not a decision that uses the absolute number.
Treating the model ID as an opaque key
The fix reduces to one rule: never derive properties from a model ID.
Keep the ID as a meaningless primary key. Put generation, tier, modality, shutdown date, and price in a declarative registry. Join to that registry when rolling up.
Store the raw ID in the fact table
The key detail is not writing derived values into the fact table. If you persist a generation column, fixing the registry does not fix the rows already written.
Store only the raw ID and every historical rollup corrects itself the moment the registry is corrected. The one thing that saved me after I found the regex bug was that I had done this by accident.
Give the registry a validity window
Model properties change. Prices are revised, shutdown dates get added.
CREATE TABLE model_registry ( model_id TEXT NOT NULL, generation TEXT NOT NULL, tier TEXT NOT NULL, modality TEXT NOT NULL, shutdown_on TEXT, valid_from TEXT NOT NULL, valid_to TEXT, PRIMARY KEY (model_id, valid_from));SELECT r.tier, SUM(f.out_tok) AS out_tokFROM usage_fact fJOIN model_registry r ON r.model_id = f.model_id AND f.day >= r.valid_from AND (r.valid_to IS NULL OR f.day < r.valid_to)GROUP BY r.tier;
A date-bounded join lets you rebuild rollups that span a price revision.
Let unknown IDs stay unknown
The resolver now returns "I do not know" rather than a guess.
from dataclasses import dataclass@dataclass(frozen=True)class ModelFacts: generation: str tier: str modality: str known: boolUNKNOWN = ModelFacts("unknown", "unknown", "unknown", known=False)def resolve(model_id: str, registry: dict[str, ModelFacts]) -> ModelFacts: facts = registry.get(model_id) if facts is None: # Do not round to a nearby name. A rounded value wears the face of a correct one. return UNKNOWN return facts
My first version used a startswith fallback that snapped unrecognized IDs onto the nearest match, because an unknown band on the chart bothered me.
That was the worst call in the whole exercise. A rounded value looks exactly like a correct one on a chart. Missing data is visible; wrong attribution is not. Now the unknown band stands on its own, and when it grows I update the registry.
Since the registry carries shutdown dates, deadline monitoring reads from the same table. The detection logic is unchanged from catching deprecated models in CI; only the source of truth moved.
What ran counter to my expectations
Three things landed the opposite of what I assumed.
Successful parses are more dangerous than failed ones. An exception always reaches somebody. A silent mismatch reaches nobody. The trap lived on the side that raised no error.
Fixing the regex solves only half the problem. It corrects rollups from today forward. If derived values were persisted, the historical bands stay wrong. What actually saved the recovery was keeping raw IDs in the fact table, not the regex fix.
The urge to eliminate unknown was the expensive one. A cosmetic fallback invalidated the entire measurement of a price improvement. A gap on a dashboard is not a defect to paper over; it is a signal arriving.
What I deliberately left alone
I did not rebuild everything.
The call path still passes the model string straight from configuration. The registry is read only by rollups and monitoring. Putting a table lookup on the request path means the app stops when the registry does.
I also limited the historical recomputation to the last three months. Anything older is not feeding a migration decision, so there is no return on touching it.
If you reference only two or three models, this is probably more machinery than the problem deserves. Consolidating your model strings into a single location will pay off faster than a registry will. When I was running a single pipeline for App Store metadata, that was enough.
Where to point your next hour
Start by grepping your live rollup queries for anywhere a model ID is taken apart as a string. substr, split_part, LIKE 'gemini-3.5-%' — they all rest on the same assumption.
Where you find one, expand a single day of that band by model ID. Contamination shows up immediately.
I am still partway through this myself, undecided on how much of the shutdown-date maintenance to automate. If you have solved that part in your own setup, I would be glad to learn from it.
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.