Half My Tasks Went to Pro — and So Did Only 61% of the Tokens
A singular model setting became a models collection, which means routing across models is now something you define yourself. Here is how I re-measured a task-type routing rule against the actual context-size distribution of 67 tasks in my own repository.
I was scrolling through the morning run logs when I stopped.
The rule that splits work between Flash and Pro by task type had been in place for a while. Screens and libraries go to Pro, everything else to Flash. By task count, Pro was clearly handling less.
It should have felt lighter. It didn't.
Around the same time, the singular gemini_config was replaced by a models collection on AgentConfig and LocalAgentConfig, bringing routing, fallback, and auto-selection helpers into the configuration itself. If I was going to rewrite the settings anyway, I wanted to rewrite the reasoning behind them too.
Rewriting the reasoning meant knowing what I was actually handing over. Once I measured it, the task-type axis turned out to matter far less than I assumed.
The question the models collection puts in your hands
A single model setting leaves nothing to decide. A collection changes that. Listing several models by role means you are the one who defines which work lands on which model.
An auto-selection helper still needs an axis, and that axis is yours to pick. The common one is task type: heavier reasoning to the larger model, routine work to the fast one. It reads sensibly, and I ran with it for a long time. I wrote about a related approach in routing /effort by task class.
What I had never done was check whether that axis was doing anything for cost.
You are not billed per task. You are billed per token. Nothing guarantees that the share of tasks and the share of tokens line up.
One caveat before the numbers: field names and structure move between versions. Confirm the exact shape against the built-in antigravity_guide or the current reference for whichever build you are on. What this article covers is how to decide what to feed that configuration.
Measuring what the agent actually receives
Define one task as editing one source file. What reaches the model is not that file alone — the local modules it imports come along with it.
So I treated the target file plus one hop of local dependencies as the context for a single task, and counted tokens. I used tiktoken with o200k_base as a stand-in tokenizer because it reproduces identically on my machine. It counts differently from the model's own tokenizer, so read the output as a ruler for the shape of the distribution rather than as absolute values.
#!/usr/bin/env python3"""Measure the real context size of agent tasks over a repository.One task = editing one file. The context handed over is that file plus itslocal dependencies (one hop)."""import os, re, sys, jsonimport tiktokenROOT = sys.argv[1]SRC = os.path.join(ROOT, "src")ENC = tiktoken.get_encoding("o200k_base")IMPORT_RE = re.compile(r"""(?:from|import)\s+['"]([^'"]+)['"]""")EXTS = (".ts", ".tsx")def files(): out = [] for d, _, fs in os.walk(SRC): for f in fs: if f.endswith(EXTS): out.append(os.path.join(d, f)) return sorted(out)def resolve(spec, origin): """Resolve an import spec to a local file path, or None for externals.""" if spec.startswith("@/"): base = os.path.join(SRC, spec[2:]) elif spec.startswith("."): base = os.path.normpath(os.path.join(os.path.dirname(origin), spec)) else: return None for cand in (base + ".ts", base + ".tsx", os.path.join(base, "index.ts"), os.path.join(base, "index.tsx")): if os.path.isfile(cand): return cand return NoneCACHE = {}def toks(path): if path not in CACHE: with open(path, encoding="utf-8", errors="replace") as fh: CACHE[path] = len(ENC.encode(fh.read())) return CACHE[path]def context(path): with open(path, encoding="utf-8", errors="replace") as fh: src = fh.read() deps = set() for spec in IMPORT_RE.findall(src): r = resolve(spec, path) if r and r != path: deps.add(r) return sorted(deps), len(ENC.encode(src))def pct(sorted_vals, p): k = (len(sorted_vals) - 1) * p / 100 lo, hi = int(k), min(int(k) + 1, len(sorted_vals) - 1) return sorted_vals[lo] + (sorted_vals[hi] - sorted_vals[lo]) * (k - lo)tasks = []for f in files(): deps, own = context(f) total = own + sum(toks(d) for d in deps) tasks.append({"file": os.path.relpath(f, ROOT), "deps": len(deps), "own": own, "ctx": total})vals = sorted(t["ctx"] for t in tasks)total_tokens = sum(vals)print(f"tasks={len(tasks)} total_ctx_tokens={total_tokens}")for p in (50, 75, 90, 95, 99): print(f" p{p} = {pct(vals, p):,.0f}")print(f" max = {vals[-1]:,} mean = {total_tokens/len(vals):,.0f}")top5 = max(1, round(len(vals) * 0.05))print(f"top {top5} tasks (5%) hold {sum(vals[-top5:])/total_tokens*100:.1f}% of all context tokens")print("\nlargest 5:")for t in sorted(tasks, key=lambda x: -x["ctx"])[:5]: print(f" {t['ctx']:>7,} deps={t['deps']:<3} {t['file']}")json.dump(tasks, open("tasks.json", "w"))
Pointed at the site repository I maintain as an indie developer — 67 TypeScript and TSX files — it finished in 0.6 seconds.
tasks=67 total_ctx_tokens=220556
p50 = 2,311
p75 = 4,618
p90 = 7,579
p95 = 8,106
p99 = 15,019
max = 23,119 mean = 3,292
top 3 tasks (5%) hold 19.2% of all context tokens
top 13 tasks (20%) hold 51.6%
largest 5:
23,119 deps=12 src/app/[locale]/articles/[category]/[slug]/page.tsx
10,846 deps=3 src/app/[locale]/support/page.tsx
8,382 deps=4 src/app/[locale]/membership/page.tsx
8,188 deps=0 src/app/[locale]/about/page.tsx
7,915 deps=1 src/app/[locale]/support/SupportClient.tsx
A median of 2,311 tokens against a maximum of 23,119 — a tenfold spread. The top fifth of tasks carries 51.6% of everything.
That was the moment I got a bad feeling. A rule that thinks in task counts cannot see this skew at all.
✦
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 complete Python script that measures the context an agent actually receives, including one hop of local imports, ready to point at your own repository
✦The measured gap between routing axes: the same task count sends 60.9% of tokens to Pro by task type, but 84.6% when chosen by size, and exactly where that gap comes from
✦Why the savings from routing disappear once your retry rate passes 8-10%, and why that break-even barely moves even when the price ratio quadruples
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.
I encoded the rule I had actually been using: screens, API routes, libraries, and config to Pro; components and data to Flash.
import jsontasks = json.load(open("tasks.json")) # output of the script aboveTOTAL = sum(t["ctx"] for t in tasks)def task_class(path): if "/app/" in path and path.endswith(("page.tsx", "route.ts", "layout.tsx")): return "route" # screens and API implementation if "/components/" in path: return "component" if "/lib/" in path or "/config/" in path: return "lib" if "/i18n/" in path or "/generated/" in path: return "data" return "other"PRO_CLASSES = {"route", "lib"} # the naive "reasoning work goes to the big model" rulepro = [t for t in tasks if task_class(t["file"]) in PRO_CLASSES]pro_n, pro_tok = len(pro), sum(t["ctx"] for t in pro)print(f"[class] Pro {pro_n}/{len(tasks)} = {pro_n/len(tasks)*100:.1f}%" f" | tokens {pro_tok/TOTAL*100:.1f}%")# If the same number of tasks were picked by size, how many tokens would that capture?by_size = sorted(tasks, key=lambda t: -t["ctx"])[:pro_n]print(f"[size ] same count {pro_n}: tokens {sum(t['ctx'] for t in by_size)/TOTAL*100:.1f}%" f" (threshold = {by_size[-1]['ctx']:,})")
Task type sends 47.8% of the work to Pro and 60.9% of the tokens. Picking the same 32 tasks by size captures 84.6%. Twenty-four points of difference, purely from the choice of axis.
Two views of where that gap lives:
tasks under 2,000 tokens inside the class router's Pro bucket: 12/32 (13,835 tokens = 6.3%)
tasks over 5,000 tokens the class router drops to Flash: 6 (40,775 tokens)
7,915 src/app/[locale]/support/SupportClient.tsx
7,610 src/app/sitemap.ts
7,559 src/components/layout/Header.tsx
6,227 src/app/[locale]/HomeClient.tsx
Twelve of the 32 tasks I was sending to Pro — nearly 40% — sit under 2,000 tokens, and together they account for 6.3% of the total. Upgrading them changes almost nothing about cost.
Meanwhile sitemap.ts and Header.tsx were landing on Flash. My taxonomy files them under "other" and "component" respectively, and both exceed 7,500 tokens. What their names and locations implied about weight and what they actually cost had drifted apart.
I don't think task type is the wrong axis. Quality requirements really do track type. But cost tracks volume, and I had been asking one rule to carry both jobs.
What one step of the threshold actually moves
Routing by size raises the obvious question of where to put the line. I swept it.
A "step" of the threshold means something completely different depending on where you are standing.
Threshold move
Task change
Token change
Tokens per task point
1,000 → 2,000
-13.5pt
-6.6pt
0.49
4,000 → 5,000
-7.5pt
-10.4pt
1.39
6,000 → 8,000
-10.4pt
-23.3pt
2.24
10,000 → 15,000
-1.5pt
-4.9pt
3.27
Down in the low range, cutting a lot of tasks barely moves the tokens — small tasks simply do not carry volume. Move from 6,000 to 8,000 and task share falls by only 10.4 points while token share drops 23.3.
I caught myself about to push the threshold downward on the theory that sending more tasks to Pro would raise quality. Seen from the cost side, that is spending money in the region where spending does the least.
How many hops you read changes what the threshold means
After settling on a threshold, something else surfaced: the context of a task depends on how far the dependency walk goes.
I re-measured the same 67 tasks from zero hops (the file alone) to three.
This is where my expectation broke. I assumed context would keep inflating the wider you let the agent read. It doesn't.
Going from zero to one hop nearly doubles the total, from 115,102 to 220,556, and the number of tasks above a 6,000-token line jumps from 2 (3.0%) to 11 (16.4%). But one hop to two adds only 2.8%, and two to three adds nothing at all. At this size, the dependency graph closes by the second hop.
In other words, the only step where "how much to read" moves cost is the first one. When you are setting a routing threshold, there is no need to carefully count two hops out. What you must not do is leave the direct-import question vague while tuning the threshold, because then two things are wobbling at once.
The break-even on retries is lower than it feels
The other half of the conversation is fallback: try the fast model, escalate to the larger one when it falls short. Handling most of the volume cheaply sounds appealing.
The easy thing to miss is that an escalated task is billed twice. The fast model's input is already spent, and the larger model's cost stacks on top.
Treating one Flash input token as 1 and Pro as R times that, here is the sweep over retry rate e:
def cost(T, R, e=0.0, biased=False): pro = [t for t in tasks if t["ctx"] >= T] fla = [t for t in tasks if t["ctx"] < T] c = sum(t["ctx"] for t in pro) * R + sum(t["ctx"] for t in fla) if e > 0 and fla: k = max(1, round(len(fla) * e)) # biased=True assumes retries concentrate on the larger tasks pick = sorted(fla, key=lambda t: -t["ctx"])[:k] if biased else \ sorted(fla, key=lambda t: t["file"])[:k] c += sum(t["ctx"] for t in pick) * R # Flash already paid; add the Pro pass return c
At R = 10 with a threshold of 6,000, relative to sending everything to Pro:
Configuration
Relative cost
vs class router
Everything to Pro
100.0%
154.3%
Class router
64.8%
100.0%
Size router T=6,000 (no retries)
51.6%
79.6%
+ 5% retries (biased to large tasks)
59.5%
91.8%
+ 10% retries
66.3%
102.3%
+ 20% retries
75.9%
117.2%
Everything to Flash
10.0%
15.4%
Past a 10% retry rate the size router's advantage is gone. A binary search puts the crossing at 9.8%.
"One retry in ten is cheap enough" is a sentence I would have said out loud. It turns out to be exactly the point where the savings break even. And since failures naturally cluster on the larger tasks rather than the small ones, an estimate built on a uniform assumption leans optimistic — which is why the table above uses the biased case.
The bigger surprise was how little the price ratio moves that crossing.
Price ratio R
Class router
Size router T=6,000
Retry rate that erases the gain
5
68.7%
57.0%
8.0%
10
64.8%
51.6%
9.8%
20
62.8%
48.9%
9.8%
Making the larger model four times more expensive shifts the break-even from 8.0% to 9.8%. I had believed that a wider price gap makes routing skill matter more. The opposite held. What dominates is not the price ratio but the shape of the context distribution. As long as that shape holds, a pricing change does not force you to redraw the threshold.
Put differently: measuring my own repository told me more than studying the price sheet did.
Three things that tripped me up while measuring
Each of these quietly distorts the result.
Only the first pass is slow. In the hop comparison above, zero hops took 65ms while one hop took 9ms. That inversion is the tokenizer encoding every file on the first walk and hitting the cache afterwards. If you care about timing, measure each hop in a separate process. I missed this at first and spent a minute staring at the impossible conclusion that reading more made things faster.
An unresolved alias passes silently as zero dependencies. If a path alias like @/ fails to resolve, the file is recorded as having no dependencies and a large task gets scored as a small one. The workaround is simple: eyeball the files reporting deps=0 once. In my case about/page.tsx genuinely has none and still weighs 8,188 tokens — large on its own rather than mis-measured.
External packages are not counted. The script only sums what resolves locally. If your production setup lets the agent read into node_modules, the meaning of the threshold changes entirely. I'd recommend recording what you included in the measurement right next to the threshold itself.
What to hand the auto-selection helper
Turning the measurements into a selection function, the point is to keep the quality axis (type) and the cost axis (volume) separate instead of using one as a proxy for the other.
# Derive the threshold from your own repository's distribution.# 6_000 below is the point that held 16.4% of tasks and 46.2% of tokens in the sweep.CTX_THRESHOLD = 6_000QUALITY_CRITICAL = {"route", "lib"} # type informs the quality decision onlydef select_model(task_path: str, ctx_tokens: int, retry: int = 0) -> str: """Return a key from the models collection. Check field names against your SDK build.""" if retry > 0: return "pro" # retries always go to the larger model if ctx_tokens >= CTX_THRESHOLD: return "pro" # where the cost concentrates if task_class(task_path) in QUALITY_CRITICAL and ctx_tokens >= 2_000: return "pro" # high quality bar and enough volume to matter return "flash"
The extra ctx_tokens >= 2_000 clause exists because of what the measurement showed: 40% of the Pro bucket sat under 2,000 tokens. Even when you want the larger model for quality reasons, if the volume is noise, you can hold that judgment.
The piece I would not skip in production is logging the retry rate. Once you know the break-even sits near 10%, a router without that number is a router nobody can evaluate.
import csv, time, pathlibLEDGER = pathlib.Path.home() / ".antigravity_routing.csv"def log_route(task_path, ctx_tokens, model, retry, ok): new = not LEDGER.exists() with LEDGER.open("a", newline="") as fh: w = csv.writer(fh) if new: w.writerow(["ts", "task", "ctx_tokens", "model", "retry", "ok"]) w.writerow([int(time.time()), task_path, ctx_tokens, model, retry, int(ok)])
Counting the share of rows with retry > 0 once a week is enough; I added one line to an existing Sunday job. If it stays above 10%, the signal is not to raise the threshold but to look again at what you are asking Flash to do.
An order of operations
Changing everything at once makes it impossible to attribute the result afterwards. The order I used:
Measure. Run the script against your repository and get the median, p90, maximum, and the share held by the top fifth
Place a provisional threshold. Build the sweep table and pick just below the point where tokens-per-task-point turns steep (6,000 in my case)
Add the selection function, but for the first week or two only record its decision without switching models
Read the log, check the retry rate and the density around the threshold, then switch
Re-measure monthly. As files accumulate, the threshold's meaning drifts
Skipping step 3 is tempting — I felt it too. Recording first is what lets you later separate "the model changed" from "the distribution changed."
Where to start
This began with a small event: a configuration format changed. If that were all it was, you would swap the syntax and move on. But having a place to write down what the choice is based on turned out to be an invitation to put numbers where I had been running on instinct.
Start by pointing the measurement script at your own repository and looking at the spread between the median and the maximum. If it sits around 2x, routing has little to win and the threshold is not worth agonizing over. If it opens up to 10x, the same savings I found are sitting in your repository too.
Thank you for reading. I'd be curious what your own numbers look like.
Share
Thank You for Reading
Antigravity 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.